mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-07 20:04:00 +00:00
Compare commits
88
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
52187c91d1 | ||
|
|
084a63ab5f | ||
|
|
6af14f7f13 | ||
|
|
8a14029501 | ||
|
|
cc3d390911 | ||
|
|
b99c2f1ade | ||
|
|
ca6eace79e | ||
|
|
39d70fa1c0 | ||
|
|
8455c4bce1 | ||
|
|
872bbefb63 | ||
|
|
2213d3d8cf | ||
|
|
6cadfc9583 | ||
|
|
2cac463c6f | ||
|
|
479286a6a6 | ||
|
|
9b65d41bab | ||
|
|
891eb56091 | ||
|
|
c0a5d4141d | ||
|
|
f5d5da7023 | ||
|
|
021e887f94 | ||
|
|
8ec92eb48c | ||
|
|
5e42a83d88 | ||
|
|
6e4da23cf7 | ||
|
|
574430fb1d | ||
|
|
9c8bb206ef | ||
|
|
c56d5f2f06 | ||
|
|
8da8c05eaf | ||
|
|
5dd7f41436 | ||
|
|
efa4374644 | ||
|
|
13219828e3 | ||
|
|
096b891099 | ||
|
|
3cd7ed68aa | ||
|
|
4effadd3e9 | ||
|
|
ddc14c8f51 | ||
|
|
f7b9a2e5af | ||
|
|
8a7402d80c | ||
|
|
72e2eba70d | ||
|
|
41cc57d630 | ||
|
|
61dfdc771e | ||
|
|
b35c810284 | ||
|
|
d5d824f705 | ||
|
|
91ab9b5811 | ||
|
|
d544613aae | ||
|
|
c4298c4450 | ||
|
|
8917eebe12 | ||
|
|
5bab5ec26d | ||
|
|
5e64e599e1 | ||
|
|
7673351ce0 | ||
|
|
e1ea524b68 | ||
|
|
ea22d16c1a | ||
|
|
4e518821b5 | ||
|
|
3d6423c6f8 | ||
|
|
15fdf65a73 | ||
|
|
aca5317a2a | ||
|
|
141181f83a | ||
|
|
dda5ac6c00 | ||
|
|
0dbbd9047e | ||
|
|
579acf5081 | ||
|
|
0de347c328 | ||
|
|
7f8556c691 | ||
|
|
655d93cc92 | ||
|
|
ed0ad9cf0f | ||
|
|
c64ed40c62 | ||
|
|
5c04b12164 | ||
|
|
33a2876bc0 | ||
|
|
134597c3cf | ||
|
|
36fceb5675 | ||
|
|
2c936ae131 | ||
|
|
b68cd25e9e | ||
|
|
66a5c483c2 | ||
|
|
b7de7c1113 | ||
|
|
44581dccaf | ||
|
|
db8afbae81 | ||
|
|
92666fdbaf | ||
|
|
fa4ce4b2a5 | ||
|
|
72ceeb2fce | ||
|
|
16965c4fbc | ||
|
|
ed2aaa2c50 | ||
|
|
deb02f635e | ||
|
|
c6decfced4 | ||
|
|
b358b5dba1 | ||
|
|
1330605c18 | ||
|
|
9a5cafca27 | ||
|
|
0e2ae76f0a | ||
|
|
3a7c7b7c6a | ||
|
|
abaaf0c254 | ||
|
|
a803be1af2 | ||
|
|
81f49406b8 | ||
|
|
28f3243090 |
No files matched your search
+9
-1
@@ -218,6 +218,7 @@ AISSTREAM_API_KEY=
|
|||||||
# CCTV_TFL_MAX_SOURCES=250 # cap cameras loaded from TfL
|
# CCTV_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
|
||||||
@@ -238,6 +239,8 @@ 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
|
||||||
@@ -268,7 +271,12 @@ 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_MAX_SOURCES=4000 # catalog-wide ceiling, shared round-robin across packs
|
# CCTV_OTTAWA_ENABLED=1 # set 0 to skip City of Ottawa cameras (also needs OTTAWA_CAMERA_CERTIFICATE)
|
||||||
|
# CCTV_OTTAWA_MAX_SOURCES=250 # cap Ottawa cameras (8-500, nearest downtown first)
|
||||||
|
# CCTV_QUEBEC_ENABLED=1 # set 0 to skip the Québec camera locations (MTQ, Montréal, scenic links)
|
||||||
|
# CCTV_QUEBEC_VIDEO_ENABLED= # off by default; set 1 to let viewers play Québec 511 clips in their browser after pressing Play (511 terms of use unconfirmed; see DATA_SOURCES.md)
|
||||||
|
# CCTV_WORLD_CAMERAS_ENABLED=1 # set 0 to skip the Oregon and MLIT (Japan) still cameras
|
||||||
|
# CCTV_MAX_SOURCES=7350 # catalog-wide ceiling: 4,000 slots shared round-robin, the rest reserved for Québec and World
|
||||||
# CCTV_PREFER_AUSTIN=1 # prioritize Austin cameras in the registry
|
# CCTV_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
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -128,7 +128,7 @@ prints anything; never fix that by renaming or deleting PR A's published token.
|
|||||||
|
|
||||||
The highest-leverage places to jump in:
|
The highest-leverage places to jump in:
|
||||||
|
|
||||||
- **🌆 Add a CCTV source pack.** Austin is the reference camera source. Adding another city means a clean public camera catalog with coordinates, attribution, and server-registered frame URLs (the proxy only fetches registered URLs — never client-supplied ones, see [SECURITY.md](SECURITY.md)). City packs are the best first lane.
|
- **🌆 Add a CCTV source pack.** Austin is the reference camera source. Follow the [CCTV integration workflow](docs/CCTV_INTEGRATION.md) for source review, provider registration, proxy security, tests, and attribution. The proxy only fetches server-registered URLs — never client-supplied ones; see [SECURITY.md](SECURITY.md).
|
||||||
- **🛰️ Add or improve a data layer.** Layer factories live in `src/layers/<family>/`, with source, record, controller and renderer owners implementing the layer interface (`init/enable/disable/update/destroy/getStats`, optional `getDetectableObjects`/`getStats`). Use an existing layer as a template, and see [Adding a new layer](#adding-a-new-layer) for what the PR needs.
|
- **🛰️ Add or improve a data layer.** Layer factories live in `src/layers/<family>/`, with source, record, controller and renderer owners implementing the layer interface (`init/enable/disable/update/destroy/getStats`, optional `getDetectableObjects`/`getStats`). Use an existing layer as a template, and see [Adding a new layer](#adding-a-new-layer) for what the PR needs.
|
||||||
- **🎙️ Extend voice control.** Voice arguments are defined in `src/voice/actionSchemas.js`, with server-side descriptions in `server/providers/openai/tools.js` and client-side execution in `src/voice/gevActions.js`. Keep the tool surface tight and the responses honest (confirm only what actually happened).
|
- **🎙️ Extend voice control.** Voice arguments are defined in `src/voice/actionSchemas.js`, with server-side descriptions in `server/providers/openai/tools.js` and client-side execution in `src/voice/gevActions.js`. Keep the tool surface tight and the responses honest (confirm only what actually happened).
|
||||||
- **🎨 Add a visual style.** Styles are GLSL post-process shaders in `src/styles/`.
|
- **🎨 Add a visual style.** Styles are GLSL post-process shaders in `src/styles/`.
|
||||||
|
|||||||
+9
-1
@@ -65,9 +65,11 @@ How to read this:
|
|||||||
| **DriveBC** (Government of British Columbia) | CCTV camera catalog + frames, British Columbia highways | [Open Government Licence – British Columbia](https://www2.gov.bc.ca/gov/content/data/open-data/open-government-licence-bc) — attribution REQUIRED | "DriveBC. Contains information licensed under the Open Government Licence – British Columbia" |
|
| **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) |
|
||||||
@@ -122,7 +124,8 @@ The optional Local ADS-B layer and the broadcast-FM receiver use radio you recei
|
|||||||
**Quebec is thinly represented**, and that is upstream rather than a filter here: the province runs its own Info-Smog program, so only a handful of Quebec stations appear in ECCC's national feed. The reporting station count also varies hour to hour as stations report or fall silent.
|
**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, MOTC (Taiwan).** National freeway cameras from the Freeway Bureau's keyless [CCTV static table](https://tisvcloud.freeway.gov.tw/history/motc20/CCTV.xml) in the MOTC 2.0 standard, refreshed daily and fetched at runtime (never bundled). Freeway Bureau traffic data is published under the [Open Government Data License, Taiwan (Version 1.0)](https://data.gov.tw/license), which permits reuse, including commercial reuse, with attribution; the app credits the Freeway Bureau, MOTC, names the dataset and links the licence, as its attribution exhibit asks. The table publishes each camera's MJPEG stream on one of four official camera servers (`cctvn`, `cctvn5`, `cctvc`, `cctvs` under `freeway.gov.tw`). The frame proxy relays single images, so each published stream URL is mapped to the same server's single-JPEG path for the same camera number (`/abs2mjpg/bmjpg` → `/abs2mjpg/jpg`, `/live-view/mjpg/video.cgi` → `/live-view/jpg/image.cgi`); only those exact HTTPS pairs are registered, and the still path is not itself listed in the table. These are on-demand snapshots, not continuous video. Camera headings are low-confidence estimates. No API key is required. The table host does not answer from some networks outside Taiwan (the camera servers do); there the pack is empty, logs one warning and retries hourly. On by default; `CCTV_TAIWAN_ENABLED=0` skips the pack, and `CCTV_TAIWAN_MAX_SOURCES` defaults to 600 (bounded to 1–2000).
|
- **Freeway Bureau and Highway Bureau, MOTC (Taiwan).** National freeway cameras use the Freeway Bureau’s keyless [MOTC 2.0 CCTV table](https://tisvcloud.freeway.gov.tw/history/motc20/CCTV.xml), refreshed daily and fetched at runtime (never bundled). Freeway Bureau data follows the [Open Government Data License, Taiwan v1.0](https://data.gov.tw/license); the app credits the Bureau, names the dataset and links the licence. If that catalog is unreachable and no last-good source list exists, the app falls back to the Highway Bureau’s free [provincial-road CCTV XML feed](https://thbapp.thb.gov.tw/opendata/), with attribution as requested by the Bureau. Its MJPEG streams are accepted only from cctv-ss01 through cctv-ss08.thb.gov.tw over HTTPS; the frame proxy extracts one bounded JPEG per request. This fallback covers provincial roads, not national freeways. Neither feed requires an API key. CCTV_TAIWAN_ENABLED=0 disables both; CCTV_TAIWAN_MAX_SOURCES defaults to 600 (bounded to 1–2000).
|
||||||
|
- **Québec CCTV (MTQ / Québec 511, Montréal and scenic links).** `public/quebec-cameras.json` contains 680 MTQ positions from the official georeferenced WFS (CC BY 4.0), 426 Montréal camera locations derived from OSM (ODbL 1.0), and 14 scenic records with per-row provenance and accuracy. The MTQ video is **opt-in** (`CCTV_QUEBEC_VIDEO_ENABLED=1`): the CC BY 4.0 licence covers the camera locations only, and Québec 511's terms for embedding its clips on another site are unconfirmed, so by default MTQ cameras link to the Québec 511 viewer page instead. With the flag set, the 680 MTQ cameras play the operator's five-second MP4 clip directly in the viewer's browser from `www.quebec511.info/Carte/Fenetres/camera.ashx?id=<id>&format=mp4`, only after the viewer presses Play, so Québec 511 sees the viewer's IP address; Québec 511 refreshes those clips every few minutes. A second browser request supplies an approximate map still. The app does not proxy, store, or read video pixels, and no camera images are bundled. The selected-camera credit names both sources: locations MTQ under CC BY 4.0, video Québec 511. Montréal and scenic entries have provider-page links rather than direct frame URLs; their source-page terms apply. The video remains hosted by Québec 511 under its service terms. No API key is required; `CCTV_QUEBEC_ENABLED=0` disables the pack. Confirm Québec 511's terms of use before setting `CCTV_QUEBEC_VIDEO_ENABLED=1` on a public deployment.
|
||||||
|
|
||||||
- **Google Maps Platform.** You supply your own API key and are bound by [Google's ToS](https://cloud.google.com/maps-platform/terms). Google Maps Content (tiles, geocodes, places) **may not be cached, stored, rehosted, or committed** — this app only ever uses it live, which is the compliant pattern. The "Google" attribution is displayed on the globe and must stay visible. Restrict your key (see [SECURITY.md](SECURITY.md)).
|
- **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.
|
||||||
@@ -170,6 +173,8 @@ The optional Local ADS-B layer and the broadcast-FM receiver use radio you recei
|
|||||||
- **DelDOT live video cameras.** The keyless `tmc.deldot.gov/json/videocamera.json` catalog supplies active cameras and official `https://video.deldot.gov/live/…/playlist.m3u8` links. The local server transiently buffers bounded MPEG-TS segments in memory and deletes them on session release or idle expiry. No recordings are retained. `CCTV_DELDOT_ENABLED=0` disables the pack. Public availability is not a blanket redistribution license; deployment beyond personal local viewing needs confirmation of applicable DelDOT terms.
|
- **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`.
|
||||||
@@ -178,6 +183,7 @@ The optional Local ADS-B layer and the broadcast-FM receiver use radio you recei
|
|||||||
- **Open Calgary traffic cameras.** Keyless. The camera list is the public Socrata dataset `k7p9-kppz` on `data.calgary.ca`; frames are stills on the city's own `trafficcam.calgary.ca` host, and only that host is registered. Most rows publish an `http://` frame URL and the host redirects those to HTTPS, so URLs are upgraded and pinned to that origin before registration. Frames are fetched at request time and never stored or redistributed. The dataset publishes no camera facing: the `quadrant` field and the quadrant suffix on each camera name are Calgary's address grid, not a bearing, so headings use the shared id-hash fallback at low confidence and are corrected with the in-app calibration gizmo. The licence requires the attribution statement above. Disable with `CCTV_CALGARY_ENABLED=0`.
|
- **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.
|
||||||
@@ -220,6 +226,8 @@ 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) |
|
||||||
|
|||||||
@@ -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** | ~4,000 public cameras (shared cap) projected _into_ the 3D space — Austin · Texas (TxDOT) · California (Caltrans) · London (TfL) · Ontario (511, free developer key) · Finland (Fintraffic) · British Columbia (DriveBC) · Estonia (Tallinn, Tarktee) · Delaware (DelDOT live video) · New South Wales (Live Traffic NSW) · Calgary · Norway (Statens vegvesen) · Toronto · King County (WA) · Washington (WSDOT) · Catalonia (Servei Català de Trànsit) · Hong Kong (Transport Department) · Taiwan (Freeway Bureau, MOTC) · Italy (A22 Brennero motorway), plus opt-in New York City (NYC DOT TMC, needs your own NYC DOT agreement), opt-in Lithuania (Via Lietuva / Eismoinfo, photo licence unconfirmed), opt-in Florida (FL511, individual personal use only; other use needs FDOT's written consent) and opt-in Houston (TranStar, enable only with TxDOT's written permission per TranStar's Content Usage terms). Positions are published; poses are estimated priors **you calibrate by dragging a gizmo on the camera itself** | City APIs | 🟢 |
|
| 📹 **CCTV Mesh** | up to 7,350 camera locations (4,000 shared round-robin slots, plus room for 1,120 Québec and 2,230 World image cameras) projected _into_ the 3D space — Austin · Texas (TxDOT) · California (Caltrans) · London (TfL) · Ontario (511, free developer key) · Ottawa (City of Ottawa, free access certificate) · Finland (Fintraffic) · British Columbia (DriveBC) · Estonia (Tallinn, Tarktee) · Delaware (DelDOT live video) · New South Wales (Live Traffic NSW) · Calgary · Norway (Statens vegvesen) · Toronto · King County (WA) · Washington (WSDOT) · Catalonia (Servei Català de Trànsit) · Iceland (Vegagerðin) · Hong Kong (Transport Department) · Taiwan (Freeway Bureau, MOTC) · Québec (MTQ locations with Québec 511 links, Montréal locations, regional webcams) · Oregon (TripCheck) · Japan (MLIT river cameras) · Italy (A22 Brennero motorway), plus opt-in New York City (NYC DOT TMC, needs your own NYC DOT agreement), opt-in Lithuania (Via Lietuva / Eismoinfo, photo licence unconfirmed), opt-in Québec 511 in-browser video (embedding terms unconfirmed), opt-in Florida (FL511, individual personal use only; other use needs FDOT's written consent) and opt-in Houston (TranStar, enable only with TxDOT's written permission per TranStar's Content Usage terms). Positions are published or attributed; poses are estimated priors **you calibrate by dragging a gizmo on the camera itself** | City APIs | 🟢 |
|
||||||
| 📷 **Mapped ALPR Cameras** | License-plate-reader camera locations tagged by OpenStreetMap contributors, loaded one city-sized view at a time, with **SHOW NEAREST**. Locations and tags only: no plate data, no video | OpenStreetMap (incl. DeFlock mapping) | 🟢 |
|
| 📷 **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 the panel reads **KEY REQUIRED**. **Google Street View** uses `GOOGLE_MAPS_API_KEY` with the **Maps JavaScript API** enabled for it in Google Cloud; its chip starts off, since every panorama is billed to the key. **KartaView** and **Panoramax** need nothing: their public APIs are keyless.
|
**Setup.** At [mapillary.com/dashboard/developers](https://www.mapillary.com/dashboard/developers), register an application with **Read** access and copy its **Client Token** (`MLY|…`). Paste it into **POWER UP → Mapillary**, or set `MAPILLARY_CLIENT_TOKEN` in `.env` and restart. Without it Street Level starts on **KartaView** instead, and the Mapillary chip says which key to add. **Google Street View** uses `GOOGLE_MAPS_API_KEY` with the **Maps JavaScript API** enabled for it in Google Cloud; its chip starts off, since every panorama is billed to the key. **KartaView** and **Panoramax** need nothing: their public APIs are keyless.
|
||||||
|
|
||||||
**Use.** Turn on **Street Level** under DATA LAYERS → Cameras, or with the pill in the STREET LEVEL panel header. Each provider has a chip in its own colour (Mapillary green), and one is lit at a time: lighting one switches the others off. Mapillary, which is free, is lit by default. Zoom in to street level (below 60 km) for the coverage lines; from orbit the panel says to zoom in. The **IMAGERY** (ALL / 360° / FLAT) and **SINCE** filters apply to Mapillary, KartaView and Panoramax; Street View panoramas are all 360°, so with Street View lit the filters are hidden. Click a coverage line for its image cones and a cone to open the photo. Light **KARTAVIEW** (purple) and zoom in below about 6 km: its lines are the streets KartaView has photos of, and a click on one opens the photo nearest the click, flat or 360° (drag to look around), with cones along its drive; **‹ ›** or ← → step through the drive (Shift+← →, ↑ ↓ and + − look around a 360° photo; pinch zooms on a phone). Photos KartaView has archived are skipped, and where all are archived the panel says so. For KartaView, **SINCE** goes by upload date, as KartaView's own map does (it can filter by nothing else), and the panel notes it beside the SINCE value (ANY DATE (by upload date)); KartaView filters its lines on its own server, and a filtered area KartaView has not drawn before can take several seconds. Light **PANORAMAX** (orange) for the open, federated commons (strongest in France and Belgium): below about 60 km its lines are sequences. A click on a line opens that sequence's picture nearest the click without moving the camera, flat or 360°, with cones along the sequence around it; **‹ ›** or ← → step through it, and both filters apply. Its caption names the picture's authors (or the instance holding it) and licence (mostly CC BY-SA 4.0; pictures on the IGN instance are Licence Ouverte 2.0). On the **Google 3D** map (Google's terms keep Street View off other maps, where it does not appear), light **GOOGLE STREET VIEW** and click a street from below 3 km, or press **OPEN STREET VIEW** for the view centre, to open Google's panorama there; step along its arrows. Above the photo: **EXPAND** (full screen; Esc leaves) and **FIT / FILL** (Mapillary, and flat KartaView and Panoramax photos). The caption links to the image on the provider's site, and the globe credits each provider that is on ("© Mapillary contributors, CC BY-SA 4.0", "Street View imagery © Google", "© KartaView contributors, CC BY-SA 4.0", "© Panoramax contributors, licence per picture"). KartaView's own photo pages fail for signed-out visitors, so its link opens the full-size photo.
|
**Use.** Turn on **Street Level** under DATA LAYERS → Cameras, or with the pill in the STREET LEVEL panel header. Each provider has a chip in its own colour (Mapillary green), and one is lit at a time: lighting one switches the others off. Mapillary, which is free, is lit by default when its token is set; otherwise KartaView is. Zoom in to street level (below 60 km) for the coverage lines; from orbit the panel says to zoom in. The **IMAGERY** (ALL / 360° / FLAT) and **SINCE** filters apply to Mapillary, KartaView and Panoramax; Street View panoramas are all 360°, so with Street View lit the filters are hidden. Click a coverage line for its image cones and a cone to open the photo. Light **KARTAVIEW** (purple) and zoom in below about 6 km: its lines are the streets KartaView has photos of, and a click on one opens the photo nearest the click, flat or 360° (drag to look around), with cones along its drive; **‹ ›** or ← → step through the drive (Shift+← →, ↑ ↓ and + − look around a 360° photo; pinch zooms on a phone). Photos KartaView has archived are skipped, and where all are archived the panel says so. For KartaView, **SINCE** goes by upload date, as KartaView's own map does (it can filter by nothing else), and the panel notes it beside the SINCE value (ANY DATE (by upload date)); KartaView filters its lines on its own server, and a filtered area KartaView has not drawn before can take several seconds. Light **PANORAMAX** (orange) for the open, federated commons (strongest in France and Belgium): below about 60 km its lines are sequences. A click on a line opens that sequence's picture nearest the click without moving the camera, flat or 360°, with cones along the sequence around it; **‹ ›** or ← → step through it, and both filters apply. Its caption names the picture's authors (or the instance holding it) and licence (mostly CC BY-SA 4.0; pictures on the IGN instance are Licence Ouverte 2.0). On the **Google 3D** map (Google's terms keep Street View off other maps, where it does not appear), light **GOOGLE STREET VIEW** and click a street from below 3 km, or press **OPEN STREET VIEW** for the view centre, to open Google's panorama there; step along its arrows. Above the photo: **EXPAND** (full screen; Esc leaves) and **FIT / FILL** (Mapillary, and flat KartaView and Panoramax photos). The caption links to the image on the provider's site, and the globe credits each provider that is on ("© Mapillary contributors, CC BY-SA 4.0", "Street View imagery © Google", "© KartaView contributors, CC BY-SA 4.0", "© Panoramax contributors, licence per picture"). KartaView's own photo pages fail for signed-out visitors, so its link opens the full-size photo.
|
||||||
|
|
||||||
Share links carry the provider switches and filters (`0.m.0` Mapillary off, `0.m.0_0.r.google-1` Street View on, `0.m.0_0.r.google-0*kartaview-1*panoramax-0` KartaView on and `0.m.0_0.r.google-0*kartaview-0*panoramax-1` Panoramax on, as the app writes them (`0.m.0_0.r.kartaview-1` and `0.m.0_0.r.panoramax-1` work too; a link that leaves out `m` keeps Mapillary, the default, lit), `0.p.p` panoramas only, `0.s.365` the last year). To add a provider, implement the contract in `src/layers/streetLevel/registry.js` and register it in `src/app/layers/streetLevel.js`.
|
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 4 MiB with a ten
|
and 24 MiB in memory; individual downloads are capped at 8 MiB with a ten
|
||||||
second deadline. There are no segment files or ffmpeg processes. Redirects,
|
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
-2
@@ -69,13 +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 and segment bodies while streaming, and aborts timed-out or released sessions. At most two sessions retain 12 segments/24 MiB each, plus bounded download buffers. Stores are memory-only and idle sessions expire after 15 seconds. RTMP/ffmpeg execution is not enabled. Locally configured source URLs remain an operator trust boundary.
|
- **Live HLS is bounded and origin-pinned.** Registered HTTP(S) HLS sources use a Node puller that rejects redirects and off-origin playlist references, bounds playlists to 256 KiB and segment bodies to 8 MiB while streaming, and aborts timed-out or released sessions. At most two sessions retain 12 segments/24 MiB each, plus bounded download buffers. Stores are memory-only and idle sessions expire after 15 seconds. RTMP/ffmpeg execution is not enabled. Locally configured source URLs remain an operator trust boundary.
|
||||||
- **Radio is not an audio relay.** `/api/radio/stations` contacts only allowlisted Radio Browser HTTPS hosts and paths, rejects redirects, rejects any hostname with a loopback/private/link-local/metadata/non-public A or AAAA result, and pins each TLS connection to a validated address. It returns normalized public HTTPS stream URLs; `/api/radio/click/:uuid` applies the same destination policy and accepts only station IDs from the current bounded catalog. The browser then connects directly to the broadcaster after an explicit playback action, so the broadcaster sees the listener's IP address. GEV never proxies, caches, records, or redistributes audio.
|
- **Radio is not an audio relay.** `/api/radio/stations` contacts only allowlisted Radio Browser HTTPS hosts and paths, rejects redirects, rejects any hostname with a loopback/private/link-local/metadata/non-public A or AAAA result, and pins each TLS connection to a validated address. It returns normalized public HTTPS stream URLs; `/api/radio/click/:uuid` applies the same destination policy and accepts only station IDs from the current bounded catalog. The browser then connects directly to the broadcaster after an explicit playback action, so the broadcaster sees the listener's IP address. GEV never proxies, caches, records, or redistributes audio.
|
||||||
|
- **Québec 511 video plays in the browser, opt-in and after Play.** With `CCTV_QUEBEC_VIDEO_ENABLED=1`, `/api/cctv/sources` lists, for each MTQ camera, one URL built server-side from its numeric id on `https://www.quebec511.info/Carte/Fenetres/camera.ashx`; the overlay accepts no other host, path or format. The browser loads that clip only after the viewer presses Play for the selected camera, so Québec 511 sees the viewer's IP address, and the panel says so before Play. Selecting a camera by click, auto-hop, a scene or voice never loads it, and switching cameras stops playback. GEV never proxies, caches or reads the video's pixels. With the flag unset (the default) the browser makes no request to Québec 511.
|
||||||
- **No verbatim client headers upstream.** The CCTV media route is the one route that relays a request header (`Range`, for video seeking). It is parsed and canonicalized before it is forwarded: one `bytes=` range only, with every accepted form — explicit span, open-ended and suffix — bounded to the same 64 MiB ceiling the relay applies to a declared response body. Multi-range, malformed, inverted and non-`bytes` values are dropped and the request proceeds without a `Range`, as RFC 7233 §3.1 prescribes. Bounding the request bounds what is asked for: a response that declares no length — live streamed media, or a chunked body from an upstream that ignores the `Range` — has no ceiling. An upstream request the browser has stopped waiting for is cancelled rather than left running, whether the viewer leaves before the headers arrive or during the body.
|
- **No verbatim client headers upstream.** The CCTV media route is the one route that relays a request header (`Range`, for video seeking). It is parsed and canonicalized before it is forwarded: one `bytes=` range only, with every accepted form — explicit span, open-ended and suffix — bounded to the same 64 MiB ceiling the relay applies to a declared response body. Multi-range, malformed, inverted and non-`bytes` values are dropped and the request proceeds without a `Range`, as RFC 7233 §3.1 prescribes. Bounding the request bounds what is asked for: a response that declares no length — live streamed media, or a chunked body from an upstream that ignores the `Range` — has no ceiling. An upstream request the browser has stopped waiting for is cancelled rather than left running, whether the viewer leaves before the headers arrive or during the body.
|
||||||
- **Transit fetches registered feeds only.** `/api/transit/vehicles/<id>` resolves the id against `src/data/transitFeeds.js`; the browser never supplies a URL, and a feed that is registered but disabled does not resolve at all. Redirects are followed manually and each hop is validated against the feed's own https origin before it is requested, so an off-origin or downgraded hop is refused rather than contacted. Bodies are capped at 8 MB, the protobuf is decoded server-side under entity-count and string-length ceilings, a differential feed is refused, and snapshots live 15 s in memory with no disk cache. A per-feed admission limiter and a failure cooldown ladder bound what this process can ask of any operator.
|
- **Transit fetches registered feeds only.** `/api/transit/vehicles/<id>` resolves the id against `src/data/transitFeeds.js`; the browser never supplies a URL, and a feed that is registered but disabled does not resolve at all. Redirects are followed manually and each hop is validated against the feed's own https origin before it is requested, so an off-origin or downgraded hop is refused rather than contacted. Bodies are capped at 8 MB, the protobuf is decoded server-side under entity-count and string-length ceilings, a differential feed is refused, and snapshots live 15 s in memory with no disk cache. A per-feed admission limiter and a failure cooldown ladder bound what this process can ask of any operator.
|
||||||
- **Local receiver feeds are operator-configured and local-only.** `/api/local-receivers/aircraft` reads only the `aircraft.json` URLs in the server's `LOCAL_RECEIVER_FEEDS`; the browser never supplies an address. Each host must pass the shared tap address rule (`src/data/tapAddress.js`: loopback, RFC1918, `localhost`, `*.local`; no link-local, IPv6 or other names), the scheme must be http(s), the path must end in `aircraft.json`, and credentials, queries and fragments are refused. Invalid entries are logged at startup and never fetched. Reads use a 2 s timeout, refuse redirects, cap bodies at 2 MB and share one read per second; responses name each feed by band only and never carry upstream error text.
|
- **Local receiver feeds are operator-configured and local-only.** `/api/local-receivers/aircraft` reads only the `aircraft.json` URLs in the server's `LOCAL_RECEIVER_FEEDS`; the browser never supplies an address. Each host must pass the shared tap address rule (`src/data/tapAddress.js`: loopback, RFC1918, `localhost`, `*.local`; no link-local, IPv6 or other names), the scheme must be http(s), the path must end in `aircraft.json`, and credentials, queries and fragments are refused. Invalid entries are logged at startup and never fetched. Reads use a 2 s timeout, refuse redirects, cap bodies at 2 MB and share one read per second; responses name each feed by band only and never carry upstream error text.
|
||||||
- **Voice place search runs only on the operator's Overpass.** `/api/osm/features` answers only when `OVERPASS_UPSTREAMS` is set and never falls back to a public instance. The client sends a curated preset id, a box, a mode and a limit; the server writes and quotes the Overpass query itself, so no client or model text reaches it. Boxes are capped (36 deg² for a count, 4 deg² for a listing), as are the feature limit, the Overpass timeout and memory, and the response bytes read. Each client (IPv6 clients keyed by /64, as on `/api/overpass`) runs one search at a time and two run at once in all, under a per-client rate limit (12/min) and a global one (30/min); a search gets 22 s across every configured endpoint in turn (Overpass itself is told 20 s), which ends before the voice answer stops waiting at 25 s. The route sits behind the cross-site gate below. Answers are cached in memory and on disk (`.gev-cache/osm-features/`, at most 400 entries and 256 MB, kept a day), keyed to the configured instance so a repointed `OVERPASS_UPSTREAMS` never serves the old one's answers; a malformed answer is never cached.
|
- **Voice place search runs only on the operator's Overpass.** `/api/osm/features` answers only when `OVERPASS_UPSTREAMS` is set and never falls back to a public instance. The client sends a curated preset id, a box, a mode and a limit; the server writes and quotes the Overpass query itself, so no client or model text reaches it. Boxes are capped (36 deg² for a count, 4 deg² for a listing), as are the feature limit, the Overpass timeout and memory, and the response bytes read. Each client (IPv6 clients keyed by /64, as on `/api/overpass`) runs one search at a time and two run at once in all, under a per-client rate limit (12/min) and a global one (30/min); a search gets 22 s across every configured endpoint in turn (Overpass itself is told 20 s), which ends before the voice answer stops waiting at 25 s. The route sits behind the cross-site gate below. Answers are cached in memory and on disk (`.gev-cache/osm-features/`, at most 400 entries and 256 MB, kept a day), keyed to the configured instance so a repointed `OVERPASS_UPSTREAMS` never serves the old one's answers; a malformed answer is never cached.
|
||||||
- **Validated response formats.** Proxied bodies are relayed only in the format the route serves. CCTV frames and the Street View fallback accept JPEG, PNG, GIF, WebP and AVIF only, sent under their canonical type; any other type falls through to the next fallback. Live CCTV media must match its feed kind (`video/mp4`, `video/webm`, or `multipart/x-mixed-replace` with a boundary) before it is relayed or the camera counts as healthy, and its redirects stay on the registered origin. GBFS bodies must parse as JSON and are re-serialized as `application/json; charset=utf-8`. Camera catalog URLs are pinned by parsed URL — scheme, exact host, no credentials, default port, and a normalized path inside the agency's image directory — and a redirect on a pinned host must stay inside the same directory. Every `/api/cctv` route uses the keyless cross-site checks described below, so cameras stay reachable behind a reverse proxy; a request carrying forwarding headers never reaches the key-bearing Street View fallback and gets the placeholder frame instead, as when no key is configured.
|
- **Validated response formats.** Proxied bodies are relayed only in the format the route serves. CCTV frames and the Street View fallback accept JPEG, PNG, GIF, WebP and AVIF only, sent under their canonical type; any other type falls through to the next fallback. The one exception is a source registered as an MJPEG still (the Taiwan Highway Bureau feeds): its `multipart/x-mixed-replace` answer is read only up to its first JPEG part, capped at 2 MB, and sent as `image/jpeg`; a multipart answer from any other camera falls through. A camera whose API needs an operator credential (the City of Ottawa certificate, `OTTAWA_CAMERA_CERTIFICATE`) has a keyless catalog URL; the frame proxy adds the credential only at fetch time, only for a URL inside that camera's pinned path, so `/api/cctv/sources` and `/api/cctv/stream` never carry it. Live CCTV media must match its feed kind (`video/mp4`, `video/webm`, or `multipart/x-mixed-replace` with a boundary) before it is relayed or the camera counts as healthy, and its redirects stay on the registered origin. GBFS bodies must parse as JSON and are re-serialized as `application/json; charset=utf-8`. Camera catalog URLs are pinned by parsed URL — scheme, exact host, no credentials, default port, and a normalized path inside the agency's image directory — and a redirect on a pinned host must stay inside the same directory. Every `/api/cctv` route uses the keyless cross-site checks described below, so cameras stay reachable behind a reverse proxy; a request carrying forwarding headers never reaches the key-bearing Street View fallback and gets the placeholder frame instead, as when no key is configured.
|
||||||
- **Consistent API response headers.** Every `/api` response, including errors, partial content, Host refusals and unknown routes, carries `Content-Security-Policy: default-src 'none'; sandbox` and `X-Content-Type-Options: nosniff` (`apiResponseHeadersPlugin` in `build/vite.js`, installed ahead of the Host check). A route that sets its own policy adds it as a further policy; it never replaces this one.
|
- **Consistent API response headers.** Every `/api` response, including errors, partial content, Host refusals and unknown routes, carries `Content-Security-Policy: default-src 'none'; sandbox` and `X-Content-Type-Options: nosniff` (`apiResponseHeadersPlugin` in `build/vite.js`, installed ahead of the Host check). A route that sets its own policy adds it as a further policy; it never replaces this one.
|
||||||
- **Response-size caps and timeouts** on proxied responses.
|
- **Response-size caps and timeouts** on proxied responses.
|
||||||
- **Sanitized errors** — internal error details are not echoed back to clients.
|
- **Sanitized errors** — internal error details are not echoed back to clients.
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
- Expand CCTV coverage with a keyless Taiwan Highway Bureau fallback, 1,120
|
||||||
|
Québec camera locations, City of Ottawa traffic cameras (needs a free city
|
||||||
|
access certificate, `OTTAWA_CAMERA_CERTIFICATE`), and 2,230 curated
|
||||||
|
image-backed cameras from Oregon and Japan (InoukT, #1506).
|
||||||
|
- Québec 511 in-browser video is opt-in (`CCTV_QUEBEC_VIDEO_ENABLED=1`) and
|
||||||
|
loads only after the viewer presses Play; enforce provider-specific frame
|
||||||
|
refresh limits, and report recognized MLIT maintenance images as degraded
|
||||||
|
instead of healthy camera snapshots.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
- Add an adjustable event-age filter across S2 GhostMaps products, defaulting
|
||||||
|
to seven days with 1-, 3-, 14- and 30-day choices and an All markers option.
|
||||||
|
Preserve undated features, save the selected window and expire old events
|
||||||
|
while the map stays open. Thanks @dylanmaniatakes (#1528).
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
- Space Missions no longer locks the workspace when its mission index fails to
|
||||||
|
load (429, timeout or bad payload): entry rolls back to the previous layers
|
||||||
|
and camera view and a toast says the index could not load (German, @gekh,
|
||||||
|
#1578).
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
- Fix the translations of the voice area cards, the fire confidence filter,
|
||||||
|
the drone airspace layer and Sea Marks: one word for "landmark" per
|
||||||
|
language, the airspace layer names the airspace again, and Traditional
|
||||||
|
Chinese follows Simplified Chinese again (German, @gekh, #1581).
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
- Without a Mapillary token, Street Level starts on the first keyless source
|
||||||
|
(KartaView) instead of opening on Mapillary's `KEY REQUIRED` with nothing on
|
||||||
|
the globe. A share link that turns a source off is respected
|
||||||
|
(asturksever, #1601).
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
- Street Traffic offers separate Lines and Dots toggles while live flow is on:
|
||||||
|
Lines draws each road with flow in green, amber or red, and Dots shows or
|
||||||
|
hides the moving dots. A host can choose which road modes it offers; without
|
||||||
|
OpenStreetMap the layer never draws simulated roads (#1609).
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
- Typed chat on OpenAI answers again: OpenAI refused every message because
|
||||||
|
`resolve_area`'s area-id pattern uses Unicode classes Chat Completions does
|
||||||
|
not accept. Chat leaves those patterns out; area ids are still checked
|
||||||
|
before the tool runs (samehkhamis, #1616).
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
- Add Icelandic road cameras from the Icelandic Road and Coastal Administration
|
||||||
|
(Vegagerðin) as a CCTV pack. The camera list is one keyless JSON request to
|
||||||
|
`gagnaveita.vegagerdin.is/api/vefmyndavelar2014_1` (about 500 views at 166
|
||||||
|
stations); frames are JPEG stills in `www.vegagerdin.is/vgdata/vefmyndavelar/`,
|
||||||
|
and only links inside that directory are registered. The catalog is fetched
|
||||||
|
over https with redirects refused and a 2 MiB body cap. An explicit facing in
|
||||||
|
the view description ("til vesturs", "í suður", "séð austur") sets a
|
||||||
|
high-confidence heading; other views keep the low-confidence fallback pose.
|
||||||
|
IRCA publishes the images under CC BY 4.0, credited in the Data attribution
|
||||||
|
popover. Loads the 250 views nearest Reykjavík, Akureyri, Egilsstaðir,
|
||||||
|
Ísafjörður and Selfoss; `CCTV_ICELAND_MAX_SOURCES` changes that cap and
|
||||||
|
`CCTV_ICELAND_ENABLED=0` turns the pack off (daikaginza, #1617).
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
- Add an optional Giroc / Webcam Romania CCTV source pack with corrected
|
||||||
|
coordinates, live HLS, a snapshot fallback and provider attribution. Allow
|
||||||
|
HLS segments up to 8 MiB so high-resolution cameras whose segments exceed
|
||||||
|
4 MiB can play, while retaining the 24 MiB per-session cache and existing
|
||||||
|
origin and session limits (NeoXrated23, #1619).
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
- Turning CCTV on opens the camera nearest you instead of the first camera in
|
||||||
|
the catalog, which showed a Sydney camera in London. A share link opens on
|
||||||
|
the camera nearest the shared view once its flight lands (samehkhamis, #1620).
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
- Let the voice assistant read the storm you select in Cyclone advisories:
|
||||||
|
its official advisory, position, intensity and movement. Clearing the
|
||||||
|
selection and the storm leaving the feed are reported apart (kvnloo, #847).
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
- Reduce dead-reckoning work for flights already hidden behind the horizon.
|
||||||
|
Hidden contacts update on a staggered one-in-13-ticks rotation (about once a
|
||||||
|
second) while visible contacts and contacts with fleet models retain the full
|
||||||
|
update path (Wilmer Alzate, #870).
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "ro-giroc-webcamromania",
|
||||||
|
"name": "Giroc — Webcam Romania",
|
||||||
|
"city": "Giroc, Timiș",
|
||||||
|
"cityId": "giroc",
|
||||||
|
"provider": "Webcam Romania · webcamromania.ro",
|
||||||
|
"sourceKind": "configured",
|
||||||
|
"feedType": "hls",
|
||||||
|
"url": "https://w4.webcamromania.ro/giroc/index.m3u8",
|
||||||
|
"snapshotUrl": "https://webcamromania.ro/media/snapshots/01a0267e-d893-7203-a7eb-7ce3c8c99703/latest.jpg",
|
||||||
|
"lat": 45.70028947266781,
|
||||||
|
"lon": 21.233675914004227,
|
||||||
|
"headingDeg": 0,
|
||||||
|
"headingConfidence": "low",
|
||||||
|
"pitchDeg": -17,
|
||||||
|
"fovDeg": 74,
|
||||||
|
"rangeM": 700,
|
||||||
|
"mountHeightM": 24,
|
||||||
|
"groundElevationM": 87,
|
||||||
|
"license": "Public camera: https://webcamromania.ro/webcam-orase/webcam-giroc/ . Provider terms: https://webcamromania.ro/termeni-si-conditii/ . Video rights remain with the camera operator. Coordinates corrected by the contributor; direction, height, elevation and field of view are uncalibrated estimates."
|
||||||
|
}
|
||||||
|
]
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,155 @@
|
|||||||
|
# CCTV integration workflow
|
||||||
|
|
||||||
|
Use this workflow when adding a camera provider or repairing an existing feed.
|
||||||
|
The CCTV catalog comes through the local server; media normally does too. Keep
|
||||||
|
provider-specific URL policy on the server, including any validated browser-only
|
||||||
|
media URL permitted by the exception below.
|
||||||
|
|
||||||
|
## 1. Check the source before coding
|
||||||
|
|
||||||
|
- Identify the catalog endpoint and the actual frame or live-media endpoint.
|
||||||
|
They may be different hosts or use different formats.
|
||||||
|
- Read the provider's license, terms, attribution, rate limits, and access
|
||||||
|
requirements. Publicly reachable data is not automatically licensed for
|
||||||
|
reuse. Record whether the app may display it and whether any use is
|
||||||
|
restricted.
|
||||||
|
- Prefer a public, keyless feed. If credentials are required, keep them in a
|
||||||
|
server-only environment variable; never put a secret in browser code or a
|
||||||
|
Vite-exposed variable. A pack that needs a key is opt-in by that key: with
|
||||||
|
the variable unset it makes no request and logs once (Ontario 511 and
|
||||||
|
Ottawa). Keep the key out of catalog URLs and add it only at fetch time. Use
|
||||||
|
the provider's documented API, not a website endpoint that skips its key. If
|
||||||
|
access terms require an agreement or remain unconfirmed, make the pack
|
||||||
|
opt-in and ensure it makes no request by default.
|
||||||
|
- Confirm the feed can be fetched from the app's Node server environment. A URL
|
||||||
|
opening in a browser does not prove the server can reach it, and browser CORS
|
||||||
|
behavior does not diagnose a server-side fetch.
|
||||||
|
- Treat camera directories and aggregators such as [TW Live](https://tw.live/)
|
||||||
|
as discovery aids. Trace each listing to the original agency or camera owner;
|
||||||
|
their data and media terms govern reuse. For example, SkylineWebcams says
|
||||||
|
only camera hosts may embed its live feed and its terms restrict copying or
|
||||||
|
retransmitting footage, so do not proxy its stream without written
|
||||||
|
authorization ([FAQ](https://www.skylinewebcams.com/support/faq.html),
|
||||||
|
[terms](https://www.skylinewebcams.com/en/terms-of-use.html)).
|
||||||
|
- Decide whether the provider supplies still snapshots or continuous video.
|
||||||
|
Prefer stills unless live video is necessary and supported by the existing
|
||||||
|
proxy path.
|
||||||
|
|
||||||
|
## 2. Add and register the provider pack
|
||||||
|
|
||||||
|
Use the smallest existing provider as a template. Current owners are:
|
||||||
|
|
||||||
|
- `server/providers/cctv/sources.js` for standard open-data loaders.
|
||||||
|
- `server/providers/cctv/<provider>.js` for a provider with its own parser,
|
||||||
|
fallback, or more involved URL policy.
|
||||||
|
- `server/providers/cctv/catalog.js` to import the loader and add it to
|
||||||
|
`LIVE_PACKS`. Each pack is independently gated and the shared catalog cap
|
||||||
|
allocates space across packs.
|
||||||
|
- `server/providers/cctv/constants.js` for provider endpoints, caps, and
|
||||||
|
timeouts.
|
||||||
|
|
||||||
|
Return normalized camera records with stable unique IDs, readable names,
|
||||||
|
`city`/`cityId`, provider attribution, valid `lat`/`lon`, and the correct
|
||||||
|
`feedType`. Include camera heading only when the source actually provides a
|
||||||
|
bearing; descriptive direction text is not a numeric heading. Mark uncertain
|
||||||
|
heading data with the existing low-confidence conventions. Set `sourceKind` so
|
||||||
|
health and source diagnostics identify the provider. Apply a bounded per-pack
|
||||||
|
camera limit and geographic filtering where appropriate; avoid one provider
|
||||||
|
silently consuming the whole shared catalog cap.
|
||||||
|
|
||||||
|
Camera coordinates need their own verification. Prefer the original provider's
|
||||||
|
camera coordinates, then corroborate them against its location description,
|
||||||
|
route/mile marker, or official map. An aggregator's map dot, a town/county name,
|
||||||
|
or a geocoded attraction name is not a precise camera position. Do not pin a
|
||||||
|
camera to a city center or nearby landmark; if its actual location cannot be
|
||||||
|
verified, leave it out until it can. For manually curated coordinates, record
|
||||||
|
the evidence in the provider data or an adjacent comment. For example, TW Live
|
||||||
|
camera pages identify the originating agency; use that agency's catalog for
|
||||||
|
the camera point when available.
|
||||||
|
|
||||||
|
If the source becomes opt-in, use the catalog's opt-in gate (`envOptIn`) and
|
||||||
|
document the flag and reason in `DATA_SOURCES.md`. Otherwise use the normal
|
||||||
|
enabled-by-default flag pattern and document how to disable the pack.
|
||||||
|
|
||||||
|
## 3. Keep frame and media requests server-registered
|
||||||
|
|
||||||
|
The browser should request the local `/api/cctv/frame/:id` or
|
||||||
|
`/api/cctv/media/:id` endpoint. Do not pass an arbitrary upstream URL from the
|
||||||
|
browser, and do not fetch an unvalidated URL supplied by a catalog record.
|
||||||
|
Every provider must have a server-side rule that pins frame and media URLs to
|
||||||
|
the expected scheme, host, and path; use HTTPS where the provider supports it.
|
||||||
|
Use `pinnedCameraUrl()` for URLs that belong under a fixed provider directory,
|
||||||
|
or a stricter provider-specific validator when hosts or paths vary. Register
|
||||||
|
any new permitted image/media origin in the existing
|
||||||
|
`server/providers/cctv` media policy.
|
||||||
|
|
||||||
|
Catalog and media loaders must also:
|
||||||
|
|
||||||
|
- refuse unsafe schemes, credentials, unexpected ports, redirects outside the
|
||||||
|
registered origin/path, and malformed identifiers;
|
||||||
|
- set request deadlines and cap catalog and buffered frame bodies;
|
||||||
|
- parse only the expected format and validate coordinates before returning a
|
||||||
|
camera;
|
||||||
|
- handle offline cameras and partial provider failures without failing the
|
||||||
|
entire merged catalog; and
|
||||||
|
- avoid persistent frame storage unless the provider's terms explicitly allow
|
||||||
|
it and the design has been reviewed.
|
||||||
|
|
||||||
|
For live media, prefer the existing proxy and supported feed type (`mjpeg`,
|
||||||
|
`mp4`, `webm`, or `hls`). A direct browser-only video is a narrow fallback for
|
||||||
|
a source whose terms allow embedding when the browser can play it but the app
|
||||||
|
server cannot fetch it. Unconfirmed embedding terms make it opt-in like any
|
||||||
|
other pack, and the browser loads it only after an explicit play action, never
|
||||||
|
on selection, because the provider sees the viewer's IP address. Build its URL
|
||||||
|
from a validated provider ID on the server; never accept a browser-supplied
|
||||||
|
media URL. Render it in a native DOM
|
||||||
|
`<video>` without `crossOrigin`, and do not read its pixels into canvas, WebGL,
|
||||||
|
or a Cesium texture. If a map preview is needed, a second video request may be
|
||||||
|
paused as a DOM overlay at the camera location; label it as an approximate
|
||||||
|
still and scale it with map distance. If terms disallow embedding, use a source
|
||||||
|
link instead. Read [SECURITY.md](../SECURITY.md) before changing URL
|
||||||
|
validation, redirects, or proxy behavior.
|
||||||
|
|
||||||
|
When an image provider sets a minimum refresh interval, enforce it at the
|
||||||
|
server frame boundary as well as in the client. A bounded in-memory cache and
|
||||||
|
single-flight request per camera prevent projection, panel, and re-selection
|
||||||
|
paths from multiplying upstream requests; do not persist frames unless the
|
||||||
|
provider's terms explicitly allow it.
|
||||||
|
|
||||||
|
## 4. Add focused regression coverage
|
||||||
|
|
||||||
|
Add provider tests under `src/data/` following the existing CCTV test naming
|
||||||
|
and fixtures. Cover at least:
|
||||||
|
|
||||||
|
- representative valid catalog rows and their normalized output;
|
||||||
|
- missing or invalid IDs, coordinates, and frame URLs;
|
||||||
|
- duplicate records and the pack's cap/filter behavior;
|
||||||
|
- unsafe host/path, credentials, and redirect cases;
|
||||||
|
- timeout, oversized, malformed, and unavailable upstream responses; and
|
||||||
|
- the frame or live-media content type the provider actually serves.
|
||||||
|
|
||||||
|
Use `src/data/cctvProxy.test.mjs` for shared proxy/media behavior and
|
||||||
|
`src/data/cctvCatalogCap.test.mjs` for shared catalog allocation. For a
|
||||||
|
browser-only source, verify native playback, denied canvas pixel access, the
|
||||||
|
paused second-player overlay, and its cleanup separately from proxy tests. Run
|
||||||
|
the focused provider and proxy tests, then the standard checks from
|
||||||
|
[CONTRIBUTING.md](../CONTRIBUTING.md) that apply to the change. For runtime
|
||||||
|
verification, inspect `/api/cctv/sources`, `/api/cctv/health`, and the camera's
|
||||||
|
`/api/cctv/frame/:id` or `/api/cctv/stream/:id` response locally. Confirm a
|
||||||
|
successful frame is from the upstream camera by checking its content type and
|
||||||
|
`X-CCTV-Source`; a fallback image is not proof that the upstream feed works.
|
||||||
|
|
||||||
|
## 5. Update source documentation and submit
|
||||||
|
|
||||||
|
- Add the provider, endpoint, terms/license, required attribution, key status,
|
||||||
|
caching behavior, and any restrictions to `DATA_SOURCES.md`.
|
||||||
|
- Update the CCTV source section in `docs/CURRENT-STATE.md` when runtime
|
||||||
|
behavior, flags, caps, or fallback behavior changes.
|
||||||
|
- Register in-app attribution through the existing source/provider credit
|
||||||
|
fields; update `src/data/dataCredits.js` when the source needs a layer-level
|
||||||
|
credit.
|
||||||
|
- Add the required `changelog.d/<pr-number>-<slug>.md` entry for a runtime
|
||||||
|
change, and include the source, license, attribution, key/runtime-fetch
|
||||||
|
details, and verification evidence in the PR description.
|
||||||
|
- For a new layer, also follow the camera/source requirements in the
|
||||||
|
[new-layer PR checklist](../CONTRIBUTING.md#adding-a-new-layer).
|
||||||
+180
-62
@@ -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 animated dots or a pulsing jam heat-line. Empty and high-altitude views release the hold. A failed load leaves no hold only when no animated content is retained: if an earlier load succeeded, ingestion keeps its animated points and the hold stays. Population admission reacquires the hold and resets animation time. Clear, disable, and destroy also release it. Retained fading dots keep animation alive until they are removed. When the last point leaves mid height correction, the correction is settled at once, before the hold is released, so it cannot keep the layer reporting loading.
|
Traffic holds continuous rendering only while enabled with shown animated dots, a pulsing jam heat-line, or road lines whose geometry is still building (replaced lines stay drawn until their successors are ready, so a rebuild never blinks); hidden dots (`dots` off) and settled flow lines hold nothing. Empty and high-altitude views release the hold. A failed load leaves no hold only when no animated content is retained: if an earlier load succeeded, ingestion keeps its animated points and the hold stays. Population admission reacquires the hold and resets animation time. Clear, disable, and destroy also release it. Retained fading dots keep animation alive until they are removed. When the last point leaves mid height correction, the correction is settled at once, before the hold is released, so it cannot keep the layer reporting loading.
|
||||||
|
|
||||||
Shared distance checks use `src/geoDistance.js` with `{ lat, lon }` points and
|
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,7 +250,6 @@ 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`,
|
||||||
@@ -742,6 +741,12 @@ geographic seams and releases on disable; forecast animation is not implied.
|
|||||||
NHC movement speed is converted from miles per hour to rounded knots by the
|
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.
|
||||||
@@ -1553,6 +1558,32 @@ 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
|
||||||
@@ -1568,9 +1599,11 @@ 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 catalog-wide ceiling stays at 4,000 and is shared
|
`CCTV_NYC_MAX_SOURCES` sets the pack cap (8–400). The default catalog limit is
|
||||||
round-robin: no region is dropped outright, but when the packs together exceed
|
7,350: 4,000 shared round-robin slots, plus room above them for the 1,120
|
||||||
the ceiling, adding a pack trims cameras from the largest packs.
|
Québec locations and 2,230 World image sources. Lower explicit
|
||||||
|
`CCTV_MAX_SOURCES` values are honored; reservations only use the capacity
|
||||||
|
above the 4,000 shared slots.
|
||||||
|
|
||||||
## City of Toronto RESCU CCTV source pack
|
## City of Toronto RESCU CCTV source pack
|
||||||
|
|
||||||
@@ -1586,8 +1619,9 @@ 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 4,000
|
`CCTV_TORONTO_ENABLED=0` turns the pack off. The pack shares the default
|
||||||
catalog-wide ceiling round-robin with the other packs.
|
7,350-source catalog limit with the other packs; Québec and the World image
|
||||||
|
pack have reserved slots and the remaining sources use round-robin allocation.
|
||||||
|
|
||||||
## Lithuania CCTV source pack
|
## Lithuania CCTV source pack
|
||||||
|
|
||||||
@@ -1607,24 +1641,8 @@ 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
|
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.
|
||||||
Bureau, MOTC. One keyless request reads the daily MOTC 2.0 `CCTV.xml` table
|
Gate: src/data/cctvTaiwan.test.mjs and src/data/cctvProxy.test.mjs.
|
||||||
(~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
|
||||||
@@ -4342,35 +4360,35 @@ nearby place—always use station selection. Unqualified “turn on/start the ra
|
|||||||
requests use Play; a qualified Play-shaped tool call is normalized to Select so
|
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) + 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) + Iceland (Vegagerðin / IRCA) + Hong Kong Transport Department + FL511 (FL, opt-in) + Freeway Bureau, MOTC (Taiwan) + Houston TranStar (opt-in) + Autostrada del Brennero (IT, A22 stills) + Street View fallback | `src/data/cctv.js` | `/api/cctv` | 10s (active) |
|
||||||
| Radio | Radio Browser (public-domain station directory) | `src/data/radio.js` | `/api/radio/stations`, `/api/radio/click/:uuid` | 45 min directory refresh |
|
| 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;
|
||||||
@@ -4424,8 +4442,10 @@ provider draws in one colour (`PROVIDER_COLORS` in
|
|||||||
sequence is GEV cyan. Share options: `m` (Mapillary), `r` (the other providers' switches by id,
|
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` the panel reads KEY REQUIRED and the controls are
|
`MAPILLARY_CLIENT_TOKEN` (and no Graph proxy) Street Level starts on the first
|
||||||
disabled. With it, coverage draws as z11–14 sequence lines below 60 km; the proxy strips the unused `image` layer from z14
|
keyless provider (KartaView), including a restored "Mapillary on" unless the link
|
||||||
|
switches the keyless providers off; filter-only updates never change the source.
|
||||||
|
With it, coverage draws as z11–14 sequence lines below 60 km; the proxy strips the unused `image` layer from z14
|
||||||
tiles (12 MB → ~80 KB) and shares one upstream fetch between concurrent
|
tiles (12 MB → ~80 KB) and shares one upstream fetch between concurrent
|
||||||
requests for a tile.
|
requests for a tile.
|
||||||
|
|
||||||
@@ -4439,7 +4459,7 @@ the camera, nearest first, at most 9 (the halves of a view across
|
|||||||
the date line count a shared tile once). Lines are draped
|
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. Without `MAPILLARY_CLIENT_TOKEN` the panel
|
billboards with a horizon cull. When Mapillary is lit without `MAPILLARY_CLIENT_TOKEN` the panel
|
||||||
reads KEY REQUIRED, says which key to add and disables the filters; a rejected
|
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.
|
||||||
@@ -5329,7 +5349,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. A newer same-target ON request takes ownership of the pending entry without releasing its isolation snapshot, including when it arrives while the prior request is awaiting the adoption guard. A caller abort, resource cancellation, newer OFF, or layer teardown waits for exact manager settlement and restores that snapshot without resurrecting Rocket Launches. If an abort lands after only part of a restore settles, Context completes the same exact target without the stale caller signal and then replays newer explicit layer intent. Dedicated Voice Context cancellation reports a stable cancelled result plus the current Context state; generic layer visibility additionally exposes manager phase, reason, successor, and lifecycle details. Once an exact voice visibility intent commits, a newer voice turn cannot relabel it as cancelled while Context settlement completes; pre-commit aborts remain cancellable and final lifecycle mismatches remain failures. Clear Selected Layers reserves its complete captured OFF set before sequential teardown; a newer absolute request of any origin supersedes only its layer reservation and remains authoritative. A rejected layer teardown retains the truthful enabled state, rolls already-stopped siblings back to the captured pre-entry set, and aborts replay; rapid exit/re-entry serializes the full Satellite enabled-state and parameter restore before a new snapshot is taken. Contacts remains additive and restores user-enabled layers normally.
|
- Space Missions is replay-isolated: Rocket Launches and Satellites are the only Data Layers permitted while the mode is active. Direct UI and voice entry capture the same pre-entry snapshot; internal dependency and restoration enables do not create a user-owned Context session. Entry waits for incompatible layers to shut down, direct incompatible enables are blocked before lifecycle work, and the entry gate remains active through the complete Rocket Launches enable. That enable includes the first mission-index load: an HTTP error, timeout, network failure or malformed payload before any index has loaded fails the enable, so Context rolls back to the pre-entry layers instead of committing an empty, isolating mode; the camera returns to its pre-entry view unless navigation took it, and a toast says the mission index could not load. A valid empty index still enters, and a refresh failure after an index loaded keeps its records and the mode. Exiting cancels the layer's own camera flights but never one navigation started. A newer same-target ON request takes ownership of the pending entry without releasing its isolation snapshot, including when it arrives while the prior request is awaiting the adoption guard. A caller abort, resource cancellation, newer OFF, or layer teardown waits for exact manager settlement and restores that snapshot without resurrecting Rocket Launches. If an abort lands after only part of a restore settles, Context completes the same exact target without the stale caller signal and then replays newer explicit layer intent. Dedicated Voice Context cancellation reports a stable cancelled result plus the current Context state; generic layer visibility additionally exposes manager phase, reason, successor, and lifecycle details. Once an exact voice visibility intent commits, a newer voice turn cannot relabel it as cancelled while Context settlement completes; pre-commit aborts remain cancellable and final lifecycle mismatches remain failures. Clear Selected Layers reserves its complete captured OFF set before sequential teardown; a newer absolute request of any origin supersedes only its layer reservation and remains authoritative. A rejected layer teardown retains the truthful enabled state, rolls already-stopped siblings back to the captured pre-entry set, and aborts replay; rapid exit/re-entry serializes the full Satellite enabled-state and parameter restore before a new snapshot is taken. Contacts remains additive and restores user-enabled layers normally.
|
||||||
- Enabled Data Layer controls report normalized feed health on the button (`LOADING`, `DEGRADED`, `STALE`, `FALLBACK`, or `UNAVAILABLE`) while the metadata line retains the source and reason. A partial CelesTrak group failure keeps the usable catalog and reports `DEGRADED`; a total outage keeps last-good catalog data visible but reports `UNAVAILABLE`.
|
- 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.
|
||||||
@@ -5350,7 +5370,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 whole fleet dead-reckons at ~12Hz (1m² write gating); aircraft get a 3-poll grace period (faded icon) before removal. When a position epoch pauses, both layers coast for at least 60 seconds of contact grace with an absolute five-minute ceiling. Source backoff marks each contact and the cockpit `STALE`; the cockpit then holds the exact layer position instead of continuing inertial flight. Repeated-position kinematic changes create a forward-only synthetic fix rather than mutating history, and grounded history is lifted only when no owned 3D model already controls its datum. A nominally successful worldwide OpenSky response whose own snapshot epoch is more than two minutes old prefers the existing 250-nm viewport-scoped adsb.lol fallback and labels the source/coverage accordingly when that upstream is available. If the fallback is also unavailable, the layer's freshness/error fields use the source epoch—never the cache receipt time—so the UI reports an old snapshot rather than “just now.”
|
- **Flights (commercial + military)** render one poll interval behind real time (30s/15s) and interpolate between two known feed-stamped fixes (OpenSky `time_position`; adsb.lol `receipt − seen_pos`). The display latency is an intentional product decision — do not "fix" it away. The fleet dead-reckons at ~12Hz (1m² write gating); a contact already hidden behind the horizon whose last position still is re-reckons on a staggered one-in-13-ticks rotation (about once a second) instead, so the worldwide feed's ~13k contacts no longer cost a full pass when well under 1% are on screen. Anything that position puts in view, and any contact with a fleet model, takes the full pass immediately, and hidden-inclusive proximity queries read positions at most about a second old; aircraft get a 3-poll grace period (faded icon) before removal. When a position epoch pauses, both layers coast for at least 60 seconds of contact grace with an absolute five-minute ceiling. Source backoff marks each contact and the cockpit `STALE`; the cockpit then holds the exact layer position instead of continuing inertial flight. Repeated-position kinematic changes create a forward-only synthetic fix rather than mutating history, and grounded history is lifted only when no owned 3D model already controls its datum. A nominally successful worldwide OpenSky response whose own snapshot epoch is more than two minutes old prefers the existing 250-nm viewport-scoped adsb.lol fallback and labels the source/coverage accordingly when that upstream is available. If the fallback is also unavailable, the layer's freshness/error fields use the source epoch—never the cache receipt time—so the UI reports an old snapshot rather than “just now.”
|
||||||
- **World-space headings at every angle** (`src/data/iconOrientation.js`): aircraft/vessel icon rotation uses the camera right/up basis per tick (alignedAxis always ZERO), which is exact at screen center and for orthographic/nadir views and remains stable through >180° tracked orbits. Perspective rays vary across the viewport, so off-center contacts at oblique pitch can diverge from an exact finite-difference window projection; a regression test pins that known regime, and field evidence decides whether to adopt exact projection with the basis method as fallback. Fleet rotations refresh on camera-pose change; tracked entities per frame. Billboards are horizon-culled via a shared EllipsoidalOccluder.
|
- **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.
|
||||||
@@ -5415,8 +5435,10 @@ silently demoting every later lookup for the session.
|
|||||||
kill switch `CCTV_FINTRAFFIC_ENABLED=0`) are the fourth pack: one keyless GeoJSON station list
|
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` is a 4,000 catalog-wide ceiling shared round-robin
|
on the main road spine. `CCTV_MAX_SOURCES` defaults to 7,350: 4,000 shared
|
||||||
across packs, so a lower global cap thins every region instead of starving the last pack.
|
round-robin slots plus 3,350 reserved Québec and World image slots.
|
||||||
|
Reservations only use capacity above the 4,000 shared slots, so at 4,000 or
|
||||||
|
less every pack, Québec and World included, shares the cap round-robin.
|
||||||
~1,100 cameras total, all RAW PRIOR poses, stills-first. The catalog lives for 15 minutes; once
|
~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
|
||||||
@@ -6008,6 +6030,7 @@ request viewbox and continues searching without that geographic preference.
|
|||||||
|
|
||||||
- **What it shows.** Events ▸ **S2 GhostMaps** draws the current edition of S2 Underground's KMZ intel products from `github.com/s2underground/GhostMaps`. The products are the Common Intelligence Picture and the 30-day kinetic pull (both on by default), the Border Crisis Map, the Iran Common Intelligence Picture, Iran Kinetic Events, Yemen FLOT, and **Latest Drop**: the newest dated incident file outside those series, `0_Archive`, `Basedata` and `Reference Material`. Each product has a chip in the row. The chip choice is kept in `localStorage` (`gev.ghostmaps.products`) and falls back to the defaults when storage is blocked.
|
- **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.
|
||||||
@@ -6429,7 +6452,26 @@ easier to meet (detection is now on more often), but does not create it.
|
|||||||
- Road source (`src/layers/traffic/roadModes.js`): the Street Traffic row has
|
- 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. With no stored choice
|
overrides restored state until the user picks a chip. A host may offer
|
||||||
|
fewer modes with `createTrafficSource({ roadModes })` (a non-empty subset,
|
||||||
|
default all three): only offered modes get chips (none for a single mode),
|
||||||
|
and other choices from chips, restores, share links or the query are
|
||||||
|
ignored. Without `osm` the layer never simulates: keyless or with the
|
||||||
|
status probe unreachable, loads fail with "TomTom roads need a TomTom key"
|
||||||
|
or "TomTom roads unavailable while the traffic service is unreachable" and
|
||||||
|
no roads are drawn.
|
||||||
|
- Row toggles (live mode only): after the road-source chips, Lines draws
|
||||||
|
every road with flow as a steady draped line in its bucket color (green
|
||||||
|
free, amber slow, red jam; jam first, then slow, then free, longest first,
|
||||||
|
capped at 1,500; on 3D tiles under photoreal, on terrain or tiles
|
||||||
|
otherwise), and Dots shows or hides the moving dots (hidden dots are kept,
|
||||||
|
are not offered to detection overlays, and hold no render frames).
|
||||||
|
Without live flow only a hidden Dots toggle is offered, so dots hidden by a
|
||||||
|
live choice, a restore or a share link can always be shown again.
|
||||||
|
Defaults: Lines off, Dots on. Both are `lines` / `dots` params stored in the
|
||||||
|
layer state `lo` field (`l`, `d`). Toggling either restyles in place, with
|
||||||
|
no refetch. The owner `jamViz` heat-line prototype is unchanged when Lines
|
||||||
|
is off. With no stored choice
|
||||||
the default is Hybrid with a TomTom key and OSM without one; share links
|
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
|
||||||
@@ -7073,3 +7115,79 @@ the container healthy. Checks time out after 10 seconds, with a 30-second
|
|||||||
startup grace period and three consecutive failures before reporting unhealthy.
|
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.
|
||||||
+1
-1
@@ -26,7 +26,7 @@
|
|||||||
with the sources: src/materialSymbolsSubset.test.mjs fails when a source
|
with the sources: src/materialSymbolsSubset.test.mjs fails when a source
|
||||||
names a glyph this list is missing, because a missing glyph does not draw
|
names a glyph this list is missing, because a missing glyph does not draw
|
||||||
a box — the ligature never forms and the element renders the literal word. -->
|
a box — the ligature never forms and the element renders the literal word. -->
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20,400,0,0&icon_names=adjust,arrow_drop_down,arrow_forward,arrow_left,arrow_right,bolt,bug_report,category,chevron_left,chevron_right,close,close_fullscreen,dark_mode,draw,east,eco,egg_alt,flare,flight,handyman,layers,layers_clear,light_mode,local_fire_department,location_on,more_horiz,movie,my_location,navigation,normal,on,open_in_full,partly_cloudy_day,pentagon,pest_control,pets,public,question_mark,radar,radio,raven,right_panel_close,right_panel_open,rocket_launch,satellite_alt,search,set_meal,settings,skip_next,skip_previous,streetview,timeline,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,draw,east,eco,egg_alt,flare,flight,handyman,layers,layers_clear,light_mode,local_fire_department,location_on,more_horiz,movie,my_location,navigation,normal,on,open_in_full,partly_cloudy_day,pentagon,pest_control,pets,public,question_mark,radar,radio,raven,right_panel_close,right_panel_open,rocket_launch,satellite_alt,search,set_meal,settings,skip_next,skip_previous,streetview,timeline,traffic,tune,umbrella,upload_file,videocam,view_in_ar,visibility,visibility_off,water_drop" rel="stylesheet" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="gev-view" data-gev-view="scene-chrome"></div>
|
<div class="gev-view" data-gev-view="scene-chrome"></div>
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
{"source":"Revos addons/Qc cams/quebec_all_cameras.geojson","count":1120,"cameras":[{"id":"4103","kind":"traffic","name":"Route 117 à la jonction de la route 101 et à la hauteur du carrefour giratoire (Rouyn-Noranda)","nameEn":"Junction of route 117 and route 101 and at roundabout (Rouyn-Noranda)","category":"Highway traffic","operator":"Ministere des Transports et de la Mobilite durable (MTQ) - Quebec 511","region":"Abitibi-Témiscamingue","lat":48.26461,"lon":-79.01808,"accuracyM":0,"positionSource":"MTQ georeferenced point (WFS, EPSG:4326, 5 decimals)","viewerUrl":"https://www.quebec511.info/Carte/Fenetres/FenetreVideo.html?id=4103","notes":"Live since 2025/11/26"},{"id":"3820","kind":"traffic","name":"Aut. 20 à la hauteur de la montée Industrielle-et-Commerciale","nameEn":"Aut. 20 at montée Industrielle-et-Commerciale","category":"Highway traffic","operator":"Ministere des Transports et de la Mobilite durable (MTQ) - Quebec 511","region":"Bas-Saint-Laurent—Gaspésie—Îles-de-la-Madeleine","lat":48.43993,"lon":-68.47784,"accuracyM":0,"positionSource":"MTQ georeferenced point (WFS, EPSG:4326, 5 decimals)","viewerUrl":"https://www.quebec511.info/Carte/Fenetres/FenetreVideo.html?id=3820","notes":"Live since 2018/12/12"},{"id":"3821","kind":"traffic","name":"Montée Industrielle-et-Commerciale à Rimouski","nameEn":"Montée Industrielle-et-Commerciale at Rimouski","category":"Highway traffic","operator":"Ministere des Transports et de la Mobilite durable (MTQ) - Quebec 511","region":"Bas-Saint-Laurent—Gaspésie—Îles-de-la-Madeleine","lat":48.44639,"lon":-68.48528,"accuracyM":0,"positionSource":"MTQ georeferenced point (WFS, EPSG:4326, 5 decimals)","viewerUrl":"https://www.quebec511.info/Carte/Fenetres/FenetreVideo.html?id=3821","notes":"Live since 2018/12/12"},{"id":"3822","kind":"traffic","name":"Route 132 à la hauteur du Bic à la jonction de l'Aut. 20","nameEn":"Route 132 at Bic at the junction of Aut. 20","category":"Highway traffic","operator":"Ministere des Transports et de la Mobilite durable (MTQ) - Quebec 511","region":"Bas-Saint-Laurent—Gaspésie—Îles-de-la-Madeleine","lat":48.3775,"lon":-68.68694,"accuracyM":0,"positionSource":"MTQ georeferenced point (WFS, EPSG:4326, 5 decimals)","viewerUrl":"https://www.quebec511.info/Carte/Fenetres/FenetreVideo.html?id=3822","notes":"Live since 2018/12/12"},{"id":"3834","kind":"traffic","name":"Route 132 à la hauteur de Saint-Fabien","nameEn":"Route 132 at Saint-Fabien","category":"Highway traffic","operator":"Ministere des Transports et de la Mobilite durable (MTQ) - Quebec 511","region":"Bas-Saint-Laurent—Gaspésie—Îles-de-la-Madeleine","lat":48.32927,"lon":-68.80262,"accuracyM":0,"positionSource":"MTQ georeferenced point (WFS, EPSG:4326, 5 decimals)","viewerUrl":"https://www.quebec511.info/Carte/Fenetres/FenetreVideo.html?id=3834","notes":"Live since 2019/02/07"},{"id":"3835","kind":"traffic","name":"Route 132 à la hauteur de Cap-Chat (secteur Capucins)","nameEn":"Route 132 at Cap-Chat (Capucins sector)","category":"Highway traffic","operator":"Ministere des Transports et de la Mobilite durable (MTQ) - Quebec 511","region":"Bas-Saint-Laurent—Gaspésie—Îles-de-la-Madeleine","lat":49.04153,"lon":-66.86847,"accuracyM":0,"positionSource":"MTQ georeferenced point (WFS, EPSG:4326, 5 decimals)","viewerUrl":"https://www.quebec511.info/Carte/Fenetres/FenetreVideo.html?id=3835","notes":"Live since 2019/02/07"},{"id":"3856","kind":"traffic","name":"Route 132 à l'est de Gros-Morne","nameEn":"Route 132, east of Gros-Morne","category":"Highway traffic","operator":"Ministere des Transports et de la Mobilite durable (MTQ) - Quebec 511","region":"Bas-Saint-Laurent—Gaspésie—Îles-de-la-Madeleine","lat":49.25302,"lon":-65.48355,"accuracyM":0,"positionSource":"MTQ georeferenced point (WFS, EPSG:4326, 5 decimals)","viewerUrl":"https://www.quebec511.info/Carte/Fenetres/FenetreVideo.html?id=3856","notes":"Live since 2019/12/06"},{"id":"3836","kind":"traffic","name":"Route 132 à la hauteur de la baie du Petit Pabos","nameEn":"Route 132 at baie du Petit Pabos","category":"Highway traffic","operator":"Ministere des Transports et de la Mobilite durable (MTQ) - Quebec 511","region":"Bas-Saint-Laurent—Gaspésie—Îles-de-la-Madeleine","lat":48.37216,"lon":-64.58866,"accuracyM":0,"positionSource":"MTQ georeferenced point (WFS, EPSG:4326, 5 decimals)","viewerUrl":"https://www.quebec511.info/Carte/Fenetres/FenetreVideo.html?id=3836","notes":"Live since 2019/02/07"},{"id":"3837","kind":"traffic","name":"Route 132 à la hauteur de Bonaventure","nameEn":"Route 132 at Bonaventure","category":"Highway traffic","operator":"Ministere des Transports et de la Mobilite durable (MTQ) - Quebec 511","region":"Bas-Saint-Laurent—Gaspésie—Îles-de-la-Madeleine","lat":48.03448,"lon":-65.46953,"accuracyM":0,"positionSource":"MTQ georeferenced point (WFS, EPSG:4326, 5 decimals)","viewerUrl":"https://www.quebec511.info/Carte/Fenetres/FenetreVideo.html?id=3837","Line truncated
|
||||||
@@ -44,7 +44,7 @@ CCTV_NSW_ENABLED="${CCTV_NSW_ENABLED:-1}"
|
|||||||
CCTV_NSW_MAX_SOURCES="${CCTV_NSW_MAX_SOURCES:-250}"
|
CCTV_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:-4000}"
|
CCTV_MAX_SOURCES="${CCTV_MAX_SOURCES:-7350}"
|
||||||
|
|
||||||
# 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
|
||||||
|
|||||||
@@ -347,6 +347,7 @@
|
|||||||
"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",
|
||||||
@@ -474,6 +475,7 @@
|
|||||||
"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",
|
||||||
@@ -581,6 +583,7 @@
|
|||||||
"src/data/cctvHk.test.mjs",
|
"src/data/cctvHk.test.mjs",
|
||||||
"src/data/cctvVegvesen.test.mjs",
|
"src/data/cctvVegvesen.test.mjs",
|
||||||
"src/data/cctvA22.test.mjs",
|
"src/data/cctvA22.test.mjs",
|
||||||
|
"src/data/cctvIceland.test.mjs",
|
||||||
"src/data/cctvNyc.test.mjs",
|
"src/data/cctvNyc.test.mjs",
|
||||||
"src/data/cctvHouston.test.mjs",
|
"src/data/cctvHouston.test.mjs",
|
||||||
"src/data/cctvNswSource.test.mjs",
|
"src/data/cctvNswSource.test.mjs",
|
||||||
|
|||||||
@@ -363,6 +363,9 @@
|
|||||||
"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",
|
||||||
@@ -806,6 +809,7 @@
|
|||||||
"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",
|
||||||
@@ -814,6 +818,7 @@
|
|||||||
"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",
|
||||||
@@ -833,7 +838,7 @@
|
|||||||
"src/ui/stores/status.js",
|
"src/ui/stores/status.js",
|
||||||
"src/ui/i18n.js"
|
"src/ui/i18n.js"
|
||||||
],
|
],
|
||||||
"external": ["vue", "pinia", "vue-i18n"]
|
"external": ["vue", "pinia", "vue-i18n", "cesium"]
|
||||||
},
|
},
|
||||||
"context-controls": {
|
"context-controls": {
|
||||||
"exports": ["./ui/context", "./ui/context/policy"],
|
"exports": ["./ui/context", "./ui/context/policy"],
|
||||||
@@ -1006,6 +1011,8 @@
|
|||||||
"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",
|
||||||
@@ -1197,6 +1204,7 @@
|
|||||||
"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",
|
||||||
@@ -2336,6 +2344,7 @@
|
|||||||
"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",
|
||||||
@@ -2807,6 +2816,7 @@
|
|||||||
"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",
|
||||||
@@ -3380,6 +3390,7 @@
|
|||||||
"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",
|
||||||
|
|||||||
@@ -28,6 +28,8 @@
|
|||||||
* (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).
|
||||||
*
|
*
|
||||||
@@ -224,6 +226,77 @@ 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;
|
||||||
|
|||||||
@@ -296,6 +296,22 @@ async function expandPanel(page) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A keyless bundle opens on the first keyless source with live controls. */
|
||||||
|
async function assertKeylessDefault(page) {
|
||||||
|
await page.evaluate(() =>
|
||||||
|
window.__godsEyeView.dataManager.setEnabled('street-level', true, {
|
||||||
|
origin: 'user',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await uiUntil(page, litAlone, 'kartaview');
|
||||||
|
const shown = await page.evaluate(() => ({
|
||||||
|
status: document.getElementById('sl-status').textContent,
|
||||||
|
disabled: document.getElementById('sl-controls').disabled,
|
||||||
|
}));
|
||||||
|
assert.notEqual(shown.status, 'KEY REQUIRED');
|
||||||
|
assert.equal(shown.disabled, false, 'controls stay live');
|
||||||
|
}
|
||||||
|
|
||||||
async function assertKeyRequired(page) {
|
async function assertKeyRequired(page) {
|
||||||
await page.evaluate(() =>
|
await page.evaluate(() =>
|
||||||
window.__godsEyeView.dataManager.setEnabled('street-level', true, {
|
window.__godsEyeView.dataManager.setEnabled('street-level', true, {
|
||||||
@@ -528,11 +544,25 @@ async function main() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
await expandPanel(page);
|
await expandPanel(page);
|
||||||
if (!configured) {
|
if (!configured && litAlone(await getUI(page), 'mapillary')) {
|
||||||
|
// A keyed bundle behind a server without the token keeps Mapillary.
|
||||||
await step('keyless server: KEY REQUIRED gates the controls', () =>
|
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(
|
||||||
@@ -751,6 +781,8 @@ 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(
|
||||||
|
|||||||
+187
-5
@@ -1,4 +1,5 @@
|
|||||||
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,
|
||||||
@@ -18,6 +19,8 @@ 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 };
|
||||||
/**
|
/**
|
||||||
@@ -43,6 +46,120 @@ 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) => {
|
||||||
@@ -153,6 +270,7 @@ 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) => ({
|
||||||
@@ -164,6 +282,7 @@ 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,
|
||||||
@@ -174,6 +293,13 @@ 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 || '',
|
||||||
@@ -526,15 +652,71 @@ export function cctvProxy({ sourceRoot = process.cwd() } = {}) {
|
|||||||
: '');
|
: '');
|
||||||
|
|
||||||
const upstreamImage =
|
const upstreamImage =
|
||||||
source?.sourceKind === 'txdot-its'
|
source?.pack === 'world-cameras'
|
||||||
? await fetchTxdotSnapshot(upstreamCandidate)
|
? await fetchThrottledFrame(source, cameraId, upstreamCandidate)
|
||||||
: await fetchCctvImageFromUpstream(upstreamCandidate);
|
: source?.sourceKind === 'ottawa-traffic'
|
||||||
|
? await fetchThrottledFrame(
|
||||||
|
source,
|
||||||
|
cameraId,
|
||||||
|
ottawaAuthorizedFrameUrl(upstreamCandidate),
|
||||||
|
)
|
||||||
|
: source?.sourceKind === 'txdot-its'
|
||||||
|
? await fetchTxdotSnapshot(upstreamCandidate)
|
||||||
|
: await fetchCctvImageFromUpstream(upstreamCandidate, {
|
||||||
|
allowMjpeg: source?.sourceKind === 'taiwan-highway-motc',
|
||||||
|
});
|
||||||
|
if (
|
||||||
|
source?.sourceKind === 'mlit-river' &&
|
||||||
|
isMlitRiverAdjustmentImage(upstreamImage)
|
||||||
|
) {
|
||||||
|
const svg = buildSyntheticCctvSvg({
|
||||||
|
cameraId,
|
||||||
|
label,
|
||||||
|
city,
|
||||||
|
status: 'CAMERA UNDER ADJUSTMENT',
|
||||||
|
});
|
||||||
|
setHealth(cameraId, {
|
||||||
|
status: 'degraded',
|
||||||
|
sourceKind: 'snapshot',
|
||||||
|
label: source.provider || 'MLIT river cameras',
|
||||||
|
message: 'Provider camera under adjustment',
|
||||||
|
});
|
||||||
|
res.writeHead(200, {
|
||||||
|
'Content-Type': 'image/svg+xml',
|
||||||
|
'Cache-Control': 'no-store',
|
||||||
|
'X-CCTV-Source': 'provider-maintenance',
|
||||||
|
});
|
||||||
|
res.end(svg);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (upstreamImage?.throttled) {
|
||||||
|
// Inside the provider's refresh floor with no frame to reuse: a
|
||||||
|
// placeholder, not Street View, and no health change.
|
||||||
|
res.writeHead(200, {
|
||||||
|
'Content-Type': 'image/svg+xml',
|
||||||
|
'Cache-Control': 'no-store',
|
||||||
|
'X-CCTV-Source': 'synthetic',
|
||||||
|
});
|
||||||
|
res.end(
|
||||||
|
buildSyntheticCctvSvg({
|
||||||
|
cameraId,
|
||||||
|
label,
|
||||||
|
city,
|
||||||
|
status: 'WAITING FOR NEXT FRAME',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (upstreamImage?.ok) {
|
if (upstreamImage?.ok) {
|
||||||
setHealth(cameraId, {
|
setHealth(cameraId, {
|
||||||
status: 'ok',
|
status: upstreamImage.stale ? 'degraded' : 'ok',
|
||||||
sourceKind: 'snapshot',
|
sourceKind: 'snapshot',
|
||||||
label: source?.provider || 'Configured source',
|
label: source?.provider || 'Configured source',
|
||||||
message: 'Upstream snapshot active',
|
message: upstreamImage.stale
|
||||||
|
? 'Serving last snapshot; refresh failed'
|
||||||
|
: upstreamImage.cached
|
||||||
|
? 'Cached snapshot active'
|
||||||
|
: 'Upstream snapshot active',
|
||||||
});
|
});
|
||||||
res.writeHead(200, {
|
res.writeHead(200, {
|
||||||
'Content-Type': upstreamImage.contentType,
|
'Content-Type': upstreamImage.contentType,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
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';
|
||||||
|
|
||||||
@@ -23,16 +24,27 @@ 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. Duplicate ids resolve last-pack-wins so
|
* pack happened to be appended last. A pack with a `reservedCount` keeps up to
|
||||||
|
* that many rows only from the capacity ABOVE the shared floor, so a
|
||||||
|
* reservation adds room and never shrinks the other packs' round-robin
|
||||||
|
* share; at or below the floor reserved packs are ordinary lanes. Duplicate
|
||||||
|
* ids resolve last-pack-wins so
|
||||||
* file/env overrides keep replacing live records; the record is then counted
|
* 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(packs, maxCount) {
|
export function allocateSourceCap(
|
||||||
|
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) {
|
||||||
@@ -40,30 +52,53 @@ export function allocateSourceCap(packs, maxCount) {
|
|||||||
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.
|
||||||
queue: pack.sources.filter(
|
const queue = pack.sources.filter(
|
||||||
(source) => source?.id && owner.get(source.id)?.source === source,
|
(source) => source?.id && owner.get(source.id)?.source === source,
|
||||||
),
|
);
|
||||||
next: 0,
|
return {
|
||||||
kept: 0,
|
name: pack.name,
|
||||||
}));
|
queue,
|
||||||
|
reserved: Math.max(
|
||||||
|
0,
|
||||||
|
Math.min(queue.length, Math.floor(Number(pack.reservedCount) || 0)),
|
||||||
|
),
|
||||||
|
next: 0,
|
||||||
|
kept: 0,
|
||||||
|
};
|
||||||
|
});
|
||||||
const total = lanes.reduce((sum, lane) => sum + lane.queue.length, 0);
|
const 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 {
|
||||||
while (sources.length < limit) {
|
// Reserved packs draw only on the capacity above the shared floor, then
|
||||||
|
// every lane (reserved ones included) shares the rest round-robin.
|
||||||
|
const reserveBudget = Math.max(0, limit - Math.max(0, sharedFloor));
|
||||||
|
while (reservedSources.length < reserveBudget) {
|
||||||
|
let tookReserved = false;
|
||||||
for (const lane of lanes) {
|
for (const lane of lanes) {
|
||||||
if (sources.length >= limit) break;
|
if (reservedSources.length >= reserveBudget) break;
|
||||||
|
if (lane.kept >= lane.reserved) continue;
|
||||||
|
reservedSources.push(lane.queue[lane.next]);
|
||||||
|
lane.next += 1;
|
||||||
|
lane.kept += 1;
|
||||||
|
tookReserved = true;
|
||||||
|
}
|
||||||
|
if (!tookReserved) break;
|
||||||
|
}
|
||||||
|
while (sources.length + reservedSources.length < limit) {
|
||||||
|
for (const lane of lanes) {
|
||||||
|
if (sources.length + reservedSources.length >= limit) break;
|
||||||
if (lane.next >= lane.queue.length) continue;
|
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;
|
||||||
@@ -72,7 +107,9 @@ export function allocateSourceCap(packs, maxCount) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
sources,
|
// Keep the shared round-robin sequence first so the default active camera
|
||||||
|
// remains in its established region; guaranteed regional rows follow it.
|
||||||
|
sources: [...sources, ...reservedSources],
|
||||||
packs: lanes.map((lane) => ({
|
packs: lanes.map((lane) => ({
|
||||||
name: lane.name,
|
name: lane.name,
|
||||||
offered: lane.queue.length,
|
offered: lane.queue.length,
|
||||||
|
|||||||
@@ -1,11 +1,18 @@
|
|||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { DEFAULT_CCTV_SOURCE_FILE, CCTV_SOURCE_CACHE_MS } from './constants.js';
|
import {
|
||||||
|
DEFAULT_CCTV_SOURCE_FILE,
|
||||||
|
CCTV_SOURCE_CACHE_MS,
|
||||||
|
WORLD_CCTV_SOURCE_COUNT,
|
||||||
|
} from './constants.js';
|
||||||
import { allocateSourceCap, resolveCatalogCap } from './cap.js';
|
import { 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,
|
||||||
@@ -20,6 +27,7 @@ import {
|
|||||||
loadNswSourcesFromOpenData,
|
loadNswSourcesFromOpenData,
|
||||||
loadCalgarySourcesFromOpenData,
|
loadCalgarySourcesFromOpenData,
|
||||||
loadCataloniaSourcesFromOpenData,
|
loadCataloniaSourcesFromOpenData,
|
||||||
|
loadIcelandSourcesFromOpenData,
|
||||||
loadHkSourcesFromOpenData,
|
loadHkSourcesFromOpenData,
|
||||||
loadDelDOTSourcesFromOpenData,
|
loadDelDOTSourcesFromOpenData,
|
||||||
loadKingCountySourcesFromOpenData,
|
loadKingCountySourcesFromOpenData,
|
||||||
@@ -75,6 +83,11 @@ 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'),
|
||||||
@@ -110,6 +123,11 @@ 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'),
|
||||||
@@ -172,6 +190,18 @@ 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'),
|
||||||
@@ -316,7 +346,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) => ({
|
const normalizePack = (name, items, reservedCount = 0) => ({
|
||||||
name,
|
name,
|
||||||
sources: items
|
sources: items
|
||||||
.filter((item) => item && typeof item === 'object')
|
.filter((item) => item && typeof item === 'object')
|
||||||
@@ -326,6 +356,7 @@ 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) =>
|
||||||
@@ -334,6 +365,7 @@ export function createCctvCatalog({ sourceRoot = process.cwd() } = {}) {
|
|||||||
liveResults[index]?.status === 'fulfilled'
|
liveResults[index]?.status === 'fulfilled'
|
||||||
? liveResults[index].value
|
? liveResults[index].value
|
||||||
: [],
|
: [],
|
||||||
|
pack.reservedCount,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
normalizePack('file', fromFile),
|
normalizePack('file', fromFile),
|
||||||
|
|||||||
@@ -5,15 +5,18 @@ 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;
|
||||||
/**
|
/**
|
||||||
* Catalog-wide safety ceiling on served cameras. Each pack already caps
|
* Slots every pack shares round-robin before any reservation applies. A
|
||||||
* itself (nearest-to-anchor first); this bound only matters when the packs
|
* reserved pack (Québec, World) only takes capacity above this floor, so a
|
||||||
* together exceed it, and it is then filled round-robin across packs (see
|
* cap at or below it thins every region evenly (see cap.js).
|
||||||
* 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 DEFAULT_CCTV_MAX_SOURCES = 4000;
|
export const CCTV_SHARED_SOURCE_SLOTS = 4000;
|
||||||
|
/**
|
||||||
|
* Catalog-wide default: the 4,000 shared slots plus room reserved for the
|
||||||
|
* 1,120 Québec and 2,230 World image locations.
|
||||||
|
*/
|
||||||
|
export const DEFAULT_CCTV_MAX_SOURCES = 7350;
|
||||||
/** Hard upper bound for CCTV_MAX_SOURCES; also sizes the health map. */
|
/** Hard upper bound for CCTV_MAX_SOURCES; also sizes the health map. */
|
||||||
export const CCTV_MAX_SOURCES_CEILING = 5000;
|
export const CCTV_MAX_SOURCES_CEILING = 8000;
|
||||||
/** Reference point for Austin camera prioritization (Congress & 6th). */
|
/** 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. */
|
||||||
@@ -197,6 +200,39 @@ 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;
|
||||||
@@ -309,6 +345,37 @@ 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 =
|
||||||
|
|||||||
@@ -10,10 +10,15 @@ 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,
|
||||||
@@ -523,13 +528,18 @@ 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, [
|
||||||
@@ -621,6 +631,216 @@ export function cctvUpstreamUserAgent(url) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Largest single JPEG the MJPEG first-frame reader will buffer. */
|
||||||
|
export const CCTV_MJPEG_FRAME_MAX_BYTES = 2 * 1024 * 1024;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read one bounded JPEG part from an open MJPEG response, then stop the stream.
|
||||||
|
*
|
||||||
|
* The first part may follow a CRLF preamble, and its delimiter may be written
|
||||||
|
* as `--<boundary>` or, when the declared boundary already starts with `--`,
|
||||||
|
* as the boundary itself. A part with Content-Length is read to that length;
|
||||||
|
* a part without one ends at the next delimiter, so a JPEG end marker inside
|
||||||
|
* the image (an EXIF thumbnail) never truncates it. Bytes are copied once
|
||||||
|
* into a preallocated buffer and every search resumes where it stopped, so a
|
||||||
|
* trickling upstream costs linear time.
|
||||||
|
*/
|
||||||
|
async function readFirstMjpegFrame(upstream, maxBytes) {
|
||||||
|
const mediaType = cctvLiveMediaType(
|
||||||
|
'mjpeg',
|
||||||
|
upstream.headers.get('content-type'),
|
||||||
|
);
|
||||||
|
const boundary = parseMediaType(mediaType)?.params.get('boundary') || '';
|
||||||
|
const frameMax = Math.min(maxBytes, CCTV_MJPEG_FRAME_MAX_BYTES);
|
||||||
|
if (
|
||||||
|
!boundary ||
|
||||||
|
!upstream.body ||
|
||||||
|
!Number.isSafeInteger(frameMax) ||
|
||||||
|
frameMax < 4
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
await upstream.body?.cancel();
|
||||||
|
} catch {
|
||||||
|
/* no-op */
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const delimiters = [Buffer.from('--' + boundary, 'latin1')];
|
||||||
|
if (boundary.startsWith('--') && boundary.length > 2)
|
||||||
|
delimiters.push(Buffer.from(boundary, 'latin1'));
|
||||||
|
const lineEnd = Buffer.from('\r\n', 'ascii');
|
||||||
|
const headerTerminator = Buffer.from('\r\n\r\n', 'ascii');
|
||||||
|
const jpegStart = Buffer.from([0xff, 0xd8]);
|
||||||
|
const jpegEnd = Buffer.from([0xff, 0xd9]);
|
||||||
|
const maxPreambleBytes = 64;
|
||||||
|
const maxHeaderBytes = 8 * 1024;
|
||||||
|
const maxBufferedBytes =
|
||||||
|
maxPreambleBytes +
|
||||||
|
delimiters[0].length +
|
||||||
|
lineEnd.length +
|
||||||
|
maxHeaderBytes +
|
||||||
|
headerTerminator.length +
|
||||||
|
frameMax +
|
||||||
|
lineEnd.length +
|
||||||
|
delimiters[0].length;
|
||||||
|
const buffer = Buffer.allocUnsafe(maxBufferedBytes);
|
||||||
|
let length = 0;
|
||||||
|
let reader;
|
||||||
|
let headerStart = -1;
|
||||||
|
let delimiter = null;
|
||||||
|
let headerScanFrom = 0;
|
||||||
|
let bodyStart = -1;
|
||||||
|
let declaredLength = null;
|
||||||
|
let boundaryScanFrom = -1;
|
||||||
|
|
||||||
|
/** Match the opening delimiter after an optional CRLF preamble. */
|
||||||
|
function findOpening() {
|
||||||
|
let at = 0;
|
||||||
|
while (at < length && (buffer[at] === 0x0d || buffer[at] === 0x0a)) at++;
|
||||||
|
if (at > maxPreambleBytes) return { failed: true };
|
||||||
|
for (const candidate of delimiters) {
|
||||||
|
const line = Buffer.concat([candidate, lineEnd]);
|
||||||
|
const need = at + line.length;
|
||||||
|
if (length < need) {
|
||||||
|
const seen = buffer.subarray(at, length);
|
||||||
|
if (line.subarray(0, seen.length).equals(seen))
|
||||||
|
return { pending: true };
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (buffer.subarray(at, need).equals(line))
|
||||||
|
return { start: need, delimiter: candidate };
|
||||||
|
}
|
||||||
|
return { failed: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse the part headers; false rejects the stream. */
|
||||||
|
function parseHeaders(headerEnd) {
|
||||||
|
const headerText = buffer
|
||||||
|
.subarray(headerStart, headerEnd)
|
||||||
|
.toString('latin1');
|
||||||
|
const headers = new Map();
|
||||||
|
for (const line of headerText.split('\r\n')) {
|
||||||
|
const colon = line.indexOf(':');
|
||||||
|
if (colon <= 0) return false;
|
||||||
|
const name = line.slice(0, colon).trim().toLowerCase();
|
||||||
|
if (!name || headers.has(name)) return false;
|
||||||
|
headers.set(name, line.slice(colon + 1).trim());
|
||||||
|
}
|
||||||
|
if (cctvRasterImageType(headers.get('content-type')) !== 'image/jpeg')
|
||||||
|
return false;
|
||||||
|
const declared = headers.get('content-length');
|
||||||
|
if (declared !== undefined) {
|
||||||
|
if (!/^\d+$/.test(declared)) return false;
|
||||||
|
declaredLength = Number(declared);
|
||||||
|
if (
|
||||||
|
!Number.isSafeInteger(declaredLength) ||
|
||||||
|
declaredLength < jpegStart.length + jpegEnd.length ||
|
||||||
|
declaredLength > frameMax
|
||||||
|
)
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A complete JPEG body, or null. */
|
||||||
|
function asJpeg(frame) {
|
||||||
|
return frame.length >= jpegStart.length + jpegEnd.length &&
|
||||||
|
frame.subarray(0, jpegStart.length).equals(jpegStart) &&
|
||||||
|
frame.subarray(-jpegEnd.length).equals(jpegEnd)
|
||||||
|
? Buffer.from(frame)
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
reader = upstream.body.getReader();
|
||||||
|
for (;;) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) return null;
|
||||||
|
|
||||||
|
const chunk = Buffer.from(value);
|
||||||
|
const room = maxBufferedBytes - length;
|
||||||
|
const included = Math.min(room, chunk.length);
|
||||||
|
chunk.copy(buffer, length, 0, included);
|
||||||
|
length += included;
|
||||||
|
const overflow = included < chunk.length;
|
||||||
|
|
||||||
|
if (headerStart < 0) {
|
||||||
|
const opening = findOpening();
|
||||||
|
if (opening.failed) return null;
|
||||||
|
if (opening.pending) {
|
||||||
|
if (overflow) return null;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
headerStart = opening.start;
|
||||||
|
delimiter = opening.delimiter;
|
||||||
|
headerScanFrom = headerStart;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bodyStart < 0) {
|
||||||
|
const headerEnd = buffer
|
||||||
|
.subarray(0, length)
|
||||||
|
.indexOf(headerTerminator, headerScanFrom);
|
||||||
|
if (headerEnd < 0) {
|
||||||
|
if (length - headerStart > maxHeaderBytes || overflow) return null;
|
||||||
|
headerScanFrom = Math.max(
|
||||||
|
headerStart,
|
||||||
|
length - headerTerminator.length + 1,
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (headerEnd - headerStart > maxHeaderBytes) return null;
|
||||||
|
if (!parseHeaders(headerEnd)) return null;
|
||||||
|
bodyStart = headerEnd + headerTerminator.length;
|
||||||
|
boundaryScanFrom = bodyStart;
|
||||||
|
}
|
||||||
|
|
||||||
|
const available = length - bodyStart;
|
||||||
|
if (available >= jpegStart.length) {
|
||||||
|
if (
|
||||||
|
!buffer
|
||||||
|
.subarray(bodyStart, bodyStart + jpegStart.length)
|
||||||
|
.equals(jpegStart)
|
||||||
|
)
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const nextDelimiter = Buffer.concat([lineEnd, delimiter]);
|
||||||
|
const nextPart = buffer
|
||||||
|
.subarray(0, length)
|
||||||
|
.indexOf(nextDelimiter, boundaryScanFrom);
|
||||||
|
if (declaredLength !== null) {
|
||||||
|
if (nextPart >= bodyStart && nextPart - bodyStart < declaredLength)
|
||||||
|
return null;
|
||||||
|
if (available >= declaredLength)
|
||||||
|
return asJpeg(buffer.subarray(bodyStart, bodyStart + declaredLength));
|
||||||
|
} else if (nextPart >= bodyStart) {
|
||||||
|
const frame = buffer.subarray(bodyStart, nextPart);
|
||||||
|
return frame.length > frameMax ? null : asJpeg(frame);
|
||||||
|
} else if (available > frameMax) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (nextPart < 0)
|
||||||
|
boundaryScanFrom = Math.max(
|
||||||
|
bodyStart,
|
||||||
|
length - nextDelimiter.length + 1,
|
||||||
|
);
|
||||||
|
if (overflow) return null;
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (reader) {
|
||||||
|
try {
|
||||||
|
await reader.cancel();
|
||||||
|
} catch {
|
||||||
|
/* already closed or aborted */
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
reader.releaseLock();
|
||||||
|
} catch {
|
||||||
|
/* already released */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
/**
|
/**
|
||||||
* Fetch one upstream CCTV image within the frame-refresh budget.
|
* Fetch one upstream CCTV image within the frame-refresh budget.
|
||||||
*
|
*
|
||||||
@@ -633,6 +853,10 @@ export function cctvUpstreamUserAgent(url) {
|
|||||||
* @param {typeof fetch} [options.fetchImpl=fetch] - Fetch implementation.
|
* @param {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(
|
||||||
@@ -641,6 +865,7 @@ 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;
|
||||||
@@ -660,10 +885,7 @@ export async function fetchCctvImageFromUpstream(
|
|||||||
fetchImpl,
|
fetchImpl,
|
||||||
);
|
);
|
||||||
if (!upstream) return null;
|
if (!upstream) return null;
|
||||||
const contentType = cctvRasterImageType(
|
if (!upstream.ok) {
|
||||||
upstream.headers.get('content-type'),
|
|
||||||
);
|
|
||||||
if (!upstream.ok || !contentType) {
|
|
||||||
try {
|
try {
|
||||||
await upstream.body?.cancel();
|
await upstream.body?.cancel();
|
||||||
} catch {
|
} catch {
|
||||||
@@ -671,9 +893,24 @@ export async function fetchCctvImageFromUpstream(
|
|||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const body = await readCappedResponseBytes(upstream, maxBytes);
|
const upstreamType = upstream.headers.get('content-type');
|
||||||
|
const contentType = cctvRasterImageType(upstreamType);
|
||||||
|
if (contentType) {
|
||||||
|
const body = await readCappedResponseBytes(upstream, maxBytes);
|
||||||
|
if (!body) return null;
|
||||||
|
return { ok: true, body, contentType };
|
||||||
|
}
|
||||||
|
if (!allowMjpeg || !cctvLiveMediaType('mjpeg', upstreamType)) {
|
||||||
|
try {
|
||||||
|
await upstream.body?.cancel();
|
||||||
|
} catch {
|
||||||
|
/* no-op */
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const body = await readFirstMjpegFrame(upstream, maxBytes);
|
||||||
if (!body) return null;
|
if (!body) return null;
|
||||||
return { ok: true, body, contentType };
|
return { ok: true, body, contentType: 'image/jpeg' };
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -460,6 +460,17 @@ export function isLikelyCataloniaCoordinate(lat, lon) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Iceland bounding box (mainland and nearby islands, with a margin). */
|
||||||
|
export function isLikelyIcelandCoordinate(lat, lon) {
|
||||||
|
return (
|
||||||
|
isPlausibleLatLon(lat, lon) &&
|
||||||
|
lat >= 63.0 &&
|
||||||
|
lat <= 66.7 &&
|
||||||
|
lon >= -25.0 &&
|
||||||
|
lon <= -13.0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Decode numeric HTML character references, decimal ("í") and hex
|
* Decode numeric HTML character references, decimal ("í") and hex
|
||||||
* ("í") alike, the entity form the Catalonia feed uses for accented
|
* ("í") alike, the entity form the Catalonia feed uses for accented
|
||||||
@@ -639,6 +650,10 @@ 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.
|
||||||
|
|||||||
@@ -0,0 +1,185 @@
|
|||||||
|
import {
|
||||||
|
CCTV_SOURCE_FETCH_TIMEOUT_MS,
|
||||||
|
DEFAULT_OTTAWA_MAX_SOURCES,
|
||||||
|
OTTAWA_ANCHORS,
|
||||||
|
OTTAWA_API_USER_ID,
|
||||||
|
OTTAWA_CAMERAS_URL,
|
||||||
|
OTTAWA_FRAME_REFRESH_MS,
|
||||||
|
OTTAWA_IMAGE_ORIGIN,
|
||||||
|
OTTAWA_IMAGE_URL,
|
||||||
|
} from './constants.js';
|
||||||
|
import {
|
||||||
|
fallbackHeadingFromId,
|
||||||
|
pinnedCameraUrl,
|
||||||
|
prioritizeSources,
|
||||||
|
toFiniteNumber,
|
||||||
|
} from './normalize.js';
|
||||||
|
|
||||||
|
let ottawaCertificateMissingLogged = false;
|
||||||
|
|
||||||
|
/** Test hook: let the next missing-certificate skip log again. */
|
||||||
|
export function _resetOttawaCertificateWarningForTest() {
|
||||||
|
ottawaCertificateMissingLogged = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The operator's Traffic Camera Images API certificate, trimmed. */
|
||||||
|
function ottawaCertificate(env = process.env) {
|
||||||
|
return String(env.OTTAWA_CAMERA_CERTIFICATE ?? '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add the operator's certificate to a registered keyless Ottawa frame URL.
|
||||||
|
* Only a URL inside the pinned `/opendata/` directory with a numeric camera
|
||||||
|
* number is signed, so the certificate never goes to any other path or host.
|
||||||
|
*
|
||||||
|
* @param {string} url - Canonical frame URL from the catalog.
|
||||||
|
* @param {Record<string,string|undefined>} [env]
|
||||||
|
* @returns {string} The signed URL, or '' when there is no certificate or the
|
||||||
|
* URL is not a registered Ottawa frame.
|
||||||
|
*/
|
||||||
|
export function ottawaAuthorizedFrameUrl(url, env = process.env) {
|
||||||
|
const certificate = ottawaCertificate(env);
|
||||||
|
const pinned = pinnedCameraUrl(url, OTTAWA_IMAGE_ORIGIN);
|
||||||
|
if (!certificate || !pinned) return '';
|
||||||
|
const parsed = new URL(pinned);
|
||||||
|
const cameraNumber = parsed.searchParams.get('c') || '';
|
||||||
|
if (parsed.pathname !== '/opendata/camera' || !/^\d+$/.test(cameraNumber))
|
||||||
|
return '';
|
||||||
|
const signed = new URL(OTTAWA_IMAGE_URL(cameraNumber));
|
||||||
|
signed.searchParams.set('certificate', certificate);
|
||||||
|
signed.searchParams.set('id', OTTAWA_API_USER_ID);
|
||||||
|
return signed.href;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* City of Ottawa envelope: Kanata to Orléans, Barrhaven to the river, with
|
||||||
|
* slack for the MTO cameras on the 416/417 that the city feed also carries.
|
||||||
|
*/
|
||||||
|
const OTTAWA_BOUNDS = Object.freeze({
|
||||||
|
minLat: 45.1,
|
||||||
|
maxLat: 45.65,
|
||||||
|
minLon: -76.2,
|
||||||
|
maxLon: -75.1,
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Whether a point is inside the city feed's service area. */
|
||||||
|
function isOttawaCoordinate(lat, lon) {
|
||||||
|
return (
|
||||||
|
Number.isFinite(lat) &&
|
||||||
|
Number.isFinite(lon) &&
|
||||||
|
lat >= OTTAWA_BOUNDS.minLat &&
|
||||||
|
lat <= OTTAWA_BOUNDS.maxLat &&
|
||||||
|
lon >= OTTAWA_BOUNDS.minLon &&
|
||||||
|
lon <= OTTAWA_BOUNDS.maxLon
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load the City of Ottawa's traffic cameras.
|
||||||
|
*
|
||||||
|
* Camera images come from the city's Traffic Camera Images API, which needs a
|
||||||
|
* certificate from the city's registration form (OTTAWA_CAMERA_CERTIFICATE)
|
||||||
|
* and allows one fetch per camera per minute for a certificate and user id.
|
||||||
|
* Without a certificate the pack is skipped, with no request and one log line.
|
||||||
|
* The camera list is the documented `map/service/camera` JSON: a camera
|
||||||
|
* number, English and French names, coordinates and the owner (CITY or MTO).
|
||||||
|
* The feed carries no bearing, no elevation and no status field, so headings
|
||||||
|
* use the same deterministic low-confidence fallback as the other
|
||||||
|
* heading-less packs and the client's ground snap refines the elevation
|
||||||
|
* prior.
|
||||||
|
*
|
||||||
|
* @param {object} [_options]
|
||||||
|
* @param {Record<string,string|undefined>} [env]
|
||||||
|
* @returns {Promise<Array<object>>} Cameras nearest downtown first, or [] on
|
||||||
|
* a missing certificate or any upstream or payload failure.
|
||||||
|
*/
|
||||||
|
export async function loadOttawaSourcesFromOpenData(
|
||||||
|
_options = {},
|
||||||
|
env = process.env,
|
||||||
|
) {
|
||||||
|
if (!ottawaCertificate(env)) {
|
||||||
|
if (!ottawaCertificateMissingLogged) {
|
||||||
|
ottawaCertificateMissingLogged = true;
|
||||||
|
console.warn(
|
||||||
|
'[CCTV] Ottawa cameras skipped: the city camera image API needs a certificate (set OTTAWA_CAMERA_CERTIFICATE)',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const resp = await fetch(OTTAWA_CAMERAS_URL, {
|
||||||
|
headers: { Accept: 'application/json' },
|
||||||
|
signal: AbortSignal.timeout(CCTV_SOURCE_FETCH_TIMEOUT_MS),
|
||||||
|
});
|
||||||
|
if (!resp.ok) {
|
||||||
|
console.warn('[CCTV] Ottawa camera download failed:', resp.status);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const payload = await resp.json();
|
||||||
|
const rows = Array.isArray(payload?.cameras) ? payload.cameras : null;
|
||||||
|
if (!rows) return [];
|
||||||
|
|
||||||
|
const cameras = [];
|
||||||
|
const seen = new Set();
|
||||||
|
for (const row of rows) {
|
||||||
|
const number = toFiniteNumber(row?.camera_number);
|
||||||
|
if (!Number.isSafeInteger(number) || number <= 0 || seen.has(number))
|
||||||
|
continue;
|
||||||
|
const lat = toFiniteNumber(row?.latitude);
|
||||||
|
const lon = toFiniteNumber(row?.longitude);
|
||||||
|
if (!isOttawaCoordinate(lat, lon)) continue;
|
||||||
|
seen.add(number);
|
||||||
|
|
||||||
|
const cameraId = `ottawa-${number}`;
|
||||||
|
const imageUrl = OTTAWA_IMAGE_URL(number);
|
||||||
|
// The docs name the field camera_owner; the live feed sends cameraOwner.
|
||||||
|
const owner = String(row?.camera_owner || row?.cameraOwner || '')
|
||||||
|
.trim()
|
||||||
|
.toUpperCase();
|
||||||
|
cameras.push({
|
||||||
|
id: cameraId,
|
||||||
|
name: String(row?.name || '').trim() || `Ottawa camera ${number}`,
|
||||||
|
city: 'Ottawa',
|
||||||
|
cityId: 'ottawa',
|
||||||
|
provider: 'City of Ottawa',
|
||||||
|
lat,
|
||||||
|
lon,
|
||||||
|
headingDeg: fallbackHeadingFromId(cameraId),
|
||||||
|
headingConfidence: 'low',
|
||||||
|
pitchDeg: -18,
|
||||||
|
fovDeg: 44,
|
||||||
|
rangeM: 145,
|
||||||
|
mountHeightM: 8,
|
||||||
|
groundElevationM: 0,
|
||||||
|
feedType: 'image',
|
||||||
|
url: imageUrl,
|
||||||
|
snapshotUrl: imageUrl,
|
||||||
|
frameRefreshMs: OTTAWA_FRAME_REFRESH_MS,
|
||||||
|
sourceKind: 'ottawa-traffic',
|
||||||
|
license: 'City of Ottawa Traffic Camera Images API (registered access)',
|
||||||
|
credit:
|
||||||
|
owner === 'MTO'
|
||||||
|
? 'Ministry of Transportation of Ontario (MTO) camera'
|
||||||
|
: '',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxRaw = Number(
|
||||||
|
process.env.CCTV_OTTAWA_MAX_SOURCES || DEFAULT_OTTAWA_MAX_SOURCES,
|
||||||
|
);
|
||||||
|
const maxCount = Number.isFinite(maxRaw)
|
||||||
|
? Math.max(8, Math.min(500, Math.floor(maxRaw)))
|
||||||
|
: DEFAULT_OTTAWA_MAX_SOURCES;
|
||||||
|
const prioritized = prioritizeSources(cameras, maxCount, OTTAWA_ANCHORS);
|
||||||
|
console.log(
|
||||||
|
`[CCTV] Loaded Ottawa camera sources: ${cameras.length} published (using nearest ${prioritized.length})`,
|
||||||
|
);
|
||||||
|
return prioritized;
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(
|
||||||
|
'[CCTV] Ottawa camera download error:',
|
||||||
|
error?.message || error,
|
||||||
|
);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { cameraDisplayCode, fallbackHeadingFromId } from './normalize.js';
|
||||||
|
|
||||||
|
const QUEBEC_CATALOG_PATH = 'public/quebec-cameras.json';
|
||||||
|
export const QUEBEC_CAMERA_SOURCE_COUNT = 1120;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the operator opted in to the browser-direct Québec 511 video. Off by
|
||||||
|
* default: the camera locations are CC BY 4.0, but whether Québec 511's
|
||||||
|
* service terms allow another site to embed its clips is unconfirmed, so an
|
||||||
|
* unset flag lists the cameras with a link to the 511 viewer page only.
|
||||||
|
*/
|
||||||
|
export function quebecBrowserVideoEnabled(env = process.env) {
|
||||||
|
return String(env.CCTV_QUEBEC_VIDEO_ENABLED || '').trim() === '1';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build the direct browser-only MP4 URL for one namespaced MTQ camera id. */
|
||||||
|
export function quebec511VideoUrl(cameraId) {
|
||||||
|
const match = /^qc-511-(\d+)$/.exec(String(cameraId || ''));
|
||||||
|
return match
|
||||||
|
? `https://www.quebec511.info/Carte/Fenetres/camera.ashx?id=${match[1]}&format=mp4`
|
||||||
|
: '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeViewerUrl(value) {
|
||||||
|
try {
|
||||||
|
const url = new URL(String(value || ''));
|
||||||
|
return url.protocol === 'https:' && !url.username && !url.password
|
||||||
|
? url.href
|
||||||
|
: '';
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sourceId(camera) {
|
||||||
|
const id = String(camera?.id || '').trim();
|
||||||
|
if (camera?.kind === 'traffic' && /^\d+$/.test(id)) return `qc-511-${id}`;
|
||||||
|
if (camera?.kind === 'montreal' && /^mtl-\d+(?:-\d+)*$/.test(id))
|
||||||
|
return `qc-mtl-${id.slice(4)}`;
|
||||||
|
if (camera?.kind === 'webcam' && /^web-\d+$/.test(id))
|
||||||
|
return `qc-web-${id.slice(4)}`;
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read the checked-in, no-key Québec location catalog and adapt it to the
|
||||||
|
* regular CCTV source schema. MTQ video stays browser-only; other rows link to
|
||||||
|
* their operator page because this dataset has no direct image URL for them.
|
||||||
|
*/
|
||||||
|
export function loadQuebecSources({ sourceRoot = process.cwd() } = {}) {
|
||||||
|
const filename = path.resolve(sourceRoot, QUEBEC_CATALOG_PATH);
|
||||||
|
let cameras;
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse(fs.readFileSync(filename, 'utf8'));
|
||||||
|
cameras = Array.isArray(payload?.cameras) ? payload.cameras : [];
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[CCTV] Québec camera catalog unavailable:', error?.message);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const sources = [];
|
||||||
|
const seen = new Set();
|
||||||
|
for (const camera of cameras) {
|
||||||
|
const id = sourceId(camera);
|
||||||
|
const lat = Number(camera.lat);
|
||||||
|
const lon = Number(camera.lon);
|
||||||
|
if (
|
||||||
|
!id ||
|
||||||
|
seen.has(id) ||
|
||||||
|
!Number.isFinite(lat) ||
|
||||||
|
!Number.isFinite(lon) ||
|
||||||
|
lat < 44.9 ||
|
||||||
|
lat > 62.6 ||
|
||||||
|
lon < -79.9 ||
|
||||||
|
lon > -57.1
|
||||||
|
)
|
||||||
|
continue;
|
||||||
|
seen.add(id);
|
||||||
|
|
||||||
|
const name = String(camera.nameEn || camera.name || id).trim();
|
||||||
|
const isTraffic = camera.kind === 'traffic';
|
||||||
|
const isMontreal = camera.kind === 'montreal';
|
||||||
|
const sourceKind = isTraffic
|
||||||
|
? 'quebec-511-browser'
|
||||||
|
: isMontreal
|
||||||
|
? 'quebec-montreal-osm'
|
||||||
|
: 'quebec-webcam-link';
|
||||||
|
const provider = isTraffic
|
||||||
|
? 'Québec 511 / MTQ'
|
||||||
|
: isMontreal
|
||||||
|
? 'Ville de Montréal / OpenStreetMap'
|
||||||
|
: String(camera.operator || 'Québec webcam');
|
||||||
|
|
||||||
|
sources.push({
|
||||||
|
id,
|
||||||
|
name: name.slice(0, 180),
|
||||||
|
city: String(camera.region || 'Québec'),
|
||||||
|
cityId: 'quebec',
|
||||||
|
provider,
|
||||||
|
lat,
|
||||||
|
lon,
|
||||||
|
headingDeg: fallbackHeadingFromId(id),
|
||||||
|
headingConfidence: 'low',
|
||||||
|
pitchDeg: -18,
|
||||||
|
fovDeg: 74,
|
||||||
|
rangeM: 400,
|
||||||
|
mountHeightM: 8,
|
||||||
|
groundElevationM: 0,
|
||||||
|
feedType: 'image',
|
||||||
|
// No frame URL is registered: the server never fetches 511 media. With
|
||||||
|
// CCTV_QUEBEC_VIDEO_ENABLED=1 the browser plays the 511 MP4 in the DOM
|
||||||
|
// overlay after a play click; otherwise the camera links to 511.
|
||||||
|
url: '',
|
||||||
|
snapshotUrl: '',
|
||||||
|
sourceKind,
|
||||||
|
sourceUrl: safeViewerUrl(camera.viewerUrl),
|
||||||
|
accuracyM: Number.isFinite(Number(camera.accuracyM))
|
||||||
|
? Number(camera.accuracyM)
|
||||||
|
: null,
|
||||||
|
positionSource: String(camera.positionSource || ''),
|
||||||
|
license: isTraffic
|
||||||
|
? 'Locations: Données Québec / MTQ, CC BY 4.0. Video: hosted by Québec 511 under its terms of use'
|
||||||
|
: isMontreal
|
||||||
|
? 'OpenStreetMap contributors, ODbL 1.0'
|
||||||
|
: 'Operator terms apply; location provenance shown per camera',
|
||||||
|
credit: isTraffic
|
||||||
|
? 'Locations: MTQ · CC BY 4.0 · Video: Québec 511'
|
||||||
|
: isMontreal
|
||||||
|
? 'OpenStreetMap contributors · ODbL'
|
||||||
|
: provider,
|
||||||
|
code: cameraDisplayCode(name.toUpperCase()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return sources;
|
||||||
|
}
|
||||||
@@ -134,6 +134,12 @@ import {
|
|||||||
HOUSTON_MAX_CATALOG_BYTES,
|
HOUSTON_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,
|
||||||
@@ -152,6 +158,7 @@ import {
|
|||||||
isLikelyCalgaryCoordinate,
|
isLikelyCalgaryCoordinate,
|
||||||
isLikelyFloridaCoordinate,
|
isLikelyFloridaCoordinate,
|
||||||
isLikelyCataloniaCoordinate,
|
isLikelyCataloniaCoordinate,
|
||||||
|
isLikelyIcelandCoordinate,
|
||||||
decodeNumericEntities,
|
decodeNumericEntities,
|
||||||
isLikelyNycCoordinate,
|
isLikelyNycCoordinate,
|
||||||
isLikelyHoustonCoordinate,
|
isLikelyHoustonCoordinate,
|
||||||
@@ -2029,6 +2036,174 @@ export async function loadCataloniaSourcesFromOpenData() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compass headings for Icelandic direction words, as they appear after "til"
|
||||||
|
* (genitive: "til vesturs"), "í" (accusative: "í vestur") or "séð" ("seen",
|
||||||
|
* as in "séð austur").
|
||||||
|
*/
|
||||||
|
const ICELAND_DIRECTION_HEADINGS = {
|
||||||
|
norður: 0,
|
||||||
|
norðaustur: 45,
|
||||||
|
austur: 90,
|
||||||
|
suðaustur: 135,
|
||||||
|
suður: 180,
|
||||||
|
suðvestur: 225,
|
||||||
|
vestur: 270,
|
||||||
|
norðvestur: 315,
|
||||||
|
};
|
||||||
|
const ICELAND_DIRECTION_WORD =
|
||||||
|
'(norðaustur|norðvestur|suðaustur|suðvestur|norður|suður|austur|vestur)s?';
|
||||||
|
/** A facing, plus a second one joined by "og" ("til austurs og vesturs"). */
|
||||||
|
const ICELAND_DIRECTION_RE = new RegExp(
|
||||||
|
`(?<![\\p{L}])(?:til|í|séð)\\s+${ICELAND_DIRECTION_WORD}` +
|
||||||
|
`(?:\\s+og\\s+(?:til\\s+|í\\s+)?${ICELAND_DIRECTION_WORD})?(?![\\p{L}])`,
|
||||||
|
'gu',
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The compass heading an Icelandic view description states, or NaN. Only an
|
||||||
|
* explicit "til <átt>", "í <átt>" or "séð <átt>" counts: a bare place name or
|
||||||
|
* a road direction ("Miklabraut austur") is not a facing. A description naming
|
||||||
|
* two different directions ("til norðurs og til suðurs", "til austurs og
|
||||||
|
* vesturs") is ambiguous and yields NaN, and "niður á veg" (down onto the
|
||||||
|
* road) states none.
|
||||||
|
*
|
||||||
|
* @param {string} text - The feed's `Skyring` field.
|
||||||
|
* @returns {number} Heading in degrees [0..360), or NaN.
|
||||||
|
*/
|
||||||
|
export function icelandHeadingFromDescription(text) {
|
||||||
|
const headings = new Set();
|
||||||
|
for (const match of String(text || '')
|
||||||
|
.toLowerCase()
|
||||||
|
.matchAll(ICELAND_DIRECTION_RE)) {
|
||||||
|
for (const word of [match[1], match[2]]) {
|
||||||
|
if (word) headings.add(ICELAND_DIRECTION_HEADINGS[word]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return headings.size === 1 ? headings.values().next().value : NaN;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One Vegagerðin camera row -> one catalog source, or null. A row is one camera
|
||||||
|
* view; a station (`Maelist_nr`) carries several. The id comes from the frame
|
||||||
|
* file name, which is unique per view in the live feed.
|
||||||
|
*
|
||||||
|
* @param {object} row - Element of the `vefmyndavelar2014_1` array.
|
||||||
|
* @returns {?object}
|
||||||
|
*/
|
||||||
|
export function icelandCameraToSource(row) {
|
||||||
|
const url = pinnedCameraUrl(row?.Slod, ICELAND_IMAGE_ORIGIN);
|
||||||
|
if (!url || !/\.jpe?g$/i.test(new URL(url).pathname)) return null;
|
||||||
|
// Numbers only: Number(null) and Number('') are 0.
|
||||||
|
const lat = typeof row.Breidd === 'number' ? row.Breidd : NaN;
|
||||||
|
const lon = typeof row.Lengd === 'number' ? row.Lengd : NaN;
|
||||||
|
if (!isLikelyIcelandCoordinate(lat, lon)) return null;
|
||||||
|
const stem = new URL(url).pathname
|
||||||
|
.slice(new URL(ICELAND_IMAGE_ORIGIN).pathname.length)
|
||||||
|
.replace(/\.jpe?g$/i, '')
|
||||||
|
.toLowerCase();
|
||||||
|
if (!/^[a-z0-9_-]+$/.test(stem)) return null;
|
||||||
|
const cameraId = `is-vegagerdin-${stem}`;
|
||||||
|
const description = String(row.Skyring || '')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
// Letter lookarounds, not \b: \b is ASCII-only and would join "Þverá
|
||||||
|
// Árnessýslu" while missing "séð séð".
|
||||||
|
.replace(/(?<!\p{L})(\p{L}+) \1(?!\p{L})/giu, '$1')
|
||||||
|
.trim();
|
||||||
|
const station = String(row.Myndavel || '').trim();
|
||||||
|
const heading = icelandHeadingFromDescription(description);
|
||||||
|
const hasHeading = Number.isFinite(heading);
|
||||||
|
return {
|
||||||
|
id: cameraId,
|
||||||
|
name: description || station || `Vegagerðin ${row.Maelist_nr ?? stem}`,
|
||||||
|
city: 'Iceland',
|
||||||
|
cityId: 'iceland',
|
||||||
|
provider: 'Vegagerðin (IRCA)',
|
||||||
|
lat,
|
||||||
|
lon,
|
||||||
|
headingDeg: hasHeading ? heading : fallbackHeadingFromId(cameraId),
|
||||||
|
headingConfidence: hasHeading ? 'high' : 'low',
|
||||||
|
pitchDeg: hasHeading ? -24 : -18,
|
||||||
|
fovDeg: hasHeading ? 56 : 44,
|
||||||
|
rangeM: hasHeading ? 210 : 145,
|
||||||
|
mountHeightM: hasHeading ? 10 : 8,
|
||||||
|
groundElevationM: ICELAND_GROUND_ELEVATION_M,
|
||||||
|
feedType: 'image',
|
||||||
|
url,
|
||||||
|
snapshotUrl: url,
|
||||||
|
sourceKind: 'iceland-vegagerdin',
|
||||||
|
license:
|
||||||
|
'Icelandic Road and Coastal Administration (IRCA) — road camera images, CC BY 4.0',
|
||||||
|
code: cameraDisplayCode(station.toUpperCase() || description),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch Icelandic road cameras (Vegagerðin / IRCA), keyless: one JSON list.
|
||||||
|
* Frames are stills on the IRCA host and the loader registers only links
|
||||||
|
* inside that host's camera directory (ICELAND_IMAGE_ORIGIN). The list decides
|
||||||
|
* ids, coordinates, labels and which frame path is proxied, so it is fetched
|
||||||
|
* over https, redirects are refused and the body read is capped at
|
||||||
|
* ICELAND_MAX_CATALOG_BYTES.
|
||||||
|
*
|
||||||
|
* @returns {Promise<Array<object>>} Normalized camera source objects.
|
||||||
|
*/
|
||||||
|
export async function loadIcelandSourcesFromOpenData() {
|
||||||
|
try {
|
||||||
|
const resp = await fetch(ICELAND_CAMERAS_URL, {
|
||||||
|
headers: {
|
||||||
|
Accept: 'application/json',
|
||||||
|
'User-Agent': 'gods-eye-view-cctv-proxy/1.0',
|
||||||
|
},
|
||||||
|
redirect: 'manual',
|
||||||
|
signal: AbortSignal.timeout(CCTV_SOURCE_FETCH_TIMEOUT_MS),
|
||||||
|
});
|
||||||
|
// A response this loader will not read still owns its transport until the
|
||||||
|
// body is released, so every rejection path cancels before returning.
|
||||||
|
const discard = async () => {
|
||||||
|
try {
|
||||||
|
await resp.body?.cancel();
|
||||||
|
} catch {
|
||||||
|
/* no-op */
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
};
|
||||||
|
if (resp.status >= 300 && resp.status < 400) {
|
||||||
|
console.warn(
|
||||||
|
'[CCTV] Iceland catalog redirected; redirects are not followed',
|
||||||
|
);
|
||||||
|
return discard();
|
||||||
|
}
|
||||||
|
if (!resp.ok) {
|
||||||
|
console.warn('[CCTV] Iceland camera download failed:', resp.status);
|
||||||
|
return discard();
|
||||||
|
}
|
||||||
|
const body = await readResponseJsonCapped(resp, ICELAND_MAX_CATALOG_BYTES);
|
||||||
|
const rows = Array.isArray(body) ? body : [];
|
||||||
|
const cameras = rows.map(icelandCameraToSource).filter(Boolean);
|
||||||
|
const unique = Array.from(
|
||||||
|
new Map(cameras.map((camera) => [camera.id, camera])).values(),
|
||||||
|
);
|
||||||
|
const maxRaw = Number(
|
||||||
|
process.env.CCTV_ICELAND_MAX_SOURCES || DEFAULT_ICELAND_MAX_SOURCES,
|
||||||
|
);
|
||||||
|
const maxCount = Number.isFinite(maxRaw)
|
||||||
|
? Math.max(8, Math.min(600, Math.floor(maxRaw)))
|
||||||
|
: DEFAULT_ICELAND_MAX_SOURCES;
|
||||||
|
const prioritized = prioritizeSources(unique, maxCount, ICELAND_ANCHORS);
|
||||||
|
console.log(
|
||||||
|
`[CCTV] Loaded Iceland camera sources: ${unique.length} (using nearest ${prioritized.length})`,
|
||||||
|
);
|
||||||
|
return prioritized;
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(
|
||||||
|
'[CCTV] Iceland camera download error:',
|
||||||
|
error?.message || error,
|
||||||
|
);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Read one XML child tag's text from an `<image>` block.
|
* Read one XML child tag's text from an `<image>` block.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -5,7 +5,9 @@ export const HLS_LIMITS = Object.freeze({
|
|||||||
sessions: 2,
|
sessions: 2,
|
||||||
leasesPerSession: 8,
|
leasesPerSession: 8,
|
||||||
playlistBytes: 256 * 1024,
|
playlistBytes: 256 * 1024,
|
||||||
segmentBytes: 4 * 1024 * 1024,
|
// High-resolution cameras can exceed 4 MiB in a single ten-second TS segment.
|
||||||
|
// Retained data still shares the independent 24 MiB session budget below.
|
||||||
|
segmentBytes: 8 * 1024 * 1024,
|
||||||
sessionBytes: 24 * 1024 * 1024,
|
sessionBytes: 24 * 1024 * 1024,
|
||||||
segments: 12,
|
segments: 12,
|
||||||
pollMs: 2000,
|
pollMs: 2000,
|
||||||
|
|||||||
+158
-11
@@ -1,4 +1,7 @@
|
|||||||
import { CCTV_SOURCE_FETCH_TIMEOUT_MS } from './constants.js';
|
import {
|
||||||
|
CCTV_SOURCE_FETCH_TIMEOUT_MS,
|
||||||
|
TAIWAN_HIGHWAY_IMAGE_ORIGINS,
|
||||||
|
} from './constants.js';
|
||||||
import {
|
import {
|
||||||
cameraDisplayCode,
|
cameraDisplayCode,
|
||||||
fallbackHeadingFromId,
|
fallbackHeadingFromId,
|
||||||
@@ -10,6 +13,8 @@ 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;
|
||||||
@@ -35,6 +40,9 @@ 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. */
|
||||||
@@ -211,6 +219,74 @@ export function taiwanCameraToSource(row) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One Highway Bureau row -> a source whose first MJPEG frame is served as a
|
||||||
|
* snapshot by the existing camera-frame proxy.
|
||||||
|
*
|
||||||
|
* @param {object} row - Output of parseTaiwanCctvXml.
|
||||||
|
* @returns {?object}
|
||||||
|
*/
|
||||||
|
export function taiwanHighwayCameraToSource(row) {
|
||||||
|
if (!row || typeof row !== 'object') return null;
|
||||||
|
const { lat, lon } = row;
|
||||||
|
if (!isLikelyTaiwanCoordinate(lat, lon)) return null;
|
||||||
|
|
||||||
|
let url;
|
||||||
|
try {
|
||||||
|
url = new URL(String(row.streamUrl ?? ''));
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
url.protocol !== 'https:' ||
|
||||||
|
url.username ||
|
||||||
|
url.password ||
|
||||||
|
url.port ||
|
||||||
|
url.search ||
|
||||||
|
url.hash ||
|
||||||
|
!TAIWAN_HIGHWAY_CAMERA_HOSTS.has(url.hostname) ||
|
||||||
|
!/^\/[A-Za-z0-9._+~-]{1,100}$/.test(url.pathname) ||
|
||||||
|
url.pathname.includes('..')
|
||||||
|
)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
const rawId = String(row.cctvId ?? '').trim();
|
||||||
|
if (!/^[A-Za-z0-9-]{1,80}$/.test(rawId)) return null;
|
||||||
|
const cameraId = 'tw-thb-' + rawId.toLowerCase();
|
||||||
|
const road = String(row.roadName ?? '').trim();
|
||||||
|
const direction = DIRECTIONS[String(row.direction ?? '').trim()] || '';
|
||||||
|
const mile = String(row.mile ?? '').trim();
|
||||||
|
const section = [row.sectionStart, row.sectionEnd]
|
||||||
|
.map((part) => String(part ?? '').trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.join('→');
|
||||||
|
const name =
|
||||||
|
(road || mile || section
|
||||||
|
? [road, direction, mile, section].filter(Boolean).join(' ')
|
||||||
|
: '') || rawId;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: cameraId,
|
||||||
|
name: name.slice(0, 160),
|
||||||
|
city: 'Taiwan / 臺灣',
|
||||||
|
cityId: 'taiwan',
|
||||||
|
provider: 'Highway Bureau, MOTC',
|
||||||
|
lat,
|
||||||
|
lon,
|
||||||
|
headingDeg: fallbackHeadingFromId(cameraId),
|
||||||
|
headingConfidence: 'low',
|
||||||
|
pitchDeg: -18,
|
||||||
|
fovDeg: 44,
|
||||||
|
rangeM: 145,
|
||||||
|
mountHeightM: 8,
|
||||||
|
feedType: 'mjpeg',
|
||||||
|
url: url.href,
|
||||||
|
snapshotUrl: url.href,
|
||||||
|
sourceKind: 'taiwan-highway-motc',
|
||||||
|
license: 'Highway Bureau, MOTC open data',
|
||||||
|
code: cameraDisplayCode((road + ' ' + mile).trim().toUpperCase() || rawId),
|
||||||
|
};
|
||||||
|
}
|
||||||
let lastGood = null;
|
let lastGood = null;
|
||||||
let lastGoodAt = 0;
|
let lastGoodAt = 0;
|
||||||
let retryAfter = 0;
|
let retryAfter = 0;
|
||||||
@@ -237,6 +313,49 @@ 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
|
||||||
@@ -262,7 +381,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(
|
||||||
@@ -282,7 +401,11 @@ export async function loadTaiwanSources() {
|
|||||||
ANCHORS,
|
ANCHORS,
|
||||||
);
|
);
|
||||||
console.log(
|
console.log(
|
||||||
`[CCTV] Loaded Taiwan camera sources: ${cameras.size} (using ${sources.length})`,
|
'[CCTV] Loaded Taiwan camera sources: ' +
|
||||||
|
cameras.size +
|
||||||
|
' (using ' +
|
||||||
|
sources.length +
|
||||||
|
')',
|
||||||
);
|
);
|
||||||
lastGood = sources;
|
lastGood = sources;
|
||||||
lastGoodAt = Date.now();
|
lastGoodAt = Date.now();
|
||||||
@@ -291,13 +414,37 @@ 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();
|
||||||
console.warn(
|
if (fallback.length) {
|
||||||
`[CCTV] Taiwan camera download failed (${error?.message || error}); ` +
|
console.warn(
|
||||||
(fallback.length
|
'[CCTV] Taiwan Freeway camera download failed (' +
|
||||||
? `serving the last good ${fallback.length} cameras`
|
(error?.message || error) +
|
||||||
: 'no Taiwan cameras') +
|
'); serving the last good ' +
|
||||||
', next attempt in 1 h',
|
fallback.length +
|
||||||
);
|
' 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 [];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import {
|
||||||
|
DEFAULT_WORLD_CCTV_SOURCE_FILE,
|
||||||
|
MLIT_RIVER_IMAGE_ORIGIN,
|
||||||
|
TRIPCHECK_IMAGE_ORIGIN,
|
||||||
|
WORLD_CCTV_SOURCE_COUNT,
|
||||||
|
} from './constants.js';
|
||||||
|
import { pinnedCameraUrl } from './normalize.js';
|
||||||
|
|
||||||
|
const SOURCE_POLICIES = Object.freeze({
|
||||||
|
'tripcheck-odot': {
|
||||||
|
idPrefix: 'world-or-',
|
||||||
|
origin: TRIPCHECK_IMAGE_ORIGIN,
|
||||||
|
bounds: [41, 47, -125, -116],
|
||||||
|
filename: /^\/roadcams\/cams\/[^/]+\.jpe?g$/i,
|
||||||
|
frameRefreshMs: 5 * 60 * 1000,
|
||||||
|
},
|
||||||
|
'mlit-river': {
|
||||||
|
idPrefix: 'world-jp-river-',
|
||||||
|
origin: MLIT_RIVER_IMAGE_ORIGIN,
|
||||||
|
bounds: [24, 46, 122, 154],
|
||||||
|
filename: /^\/cam\/now\/\d+\.jpg$/i,
|
||||||
|
// The provider asks clients to avoid frequent repeated image collection.
|
||||||
|
frameRefreshMs: 10 * 60 * 1000,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// MLIT currently serves this fixed JPEG on cameras whose images are paused
|
||||||
|
// for maintenance (verified against two Yamagata cameras on 2026-11-02).
|
||||||
|
const MLIT_ADJUSTMENT_IMAGE_SHA256 =
|
||||||
|
'dbd2c3790eb75edf9ca44162f7979f2a3799090e23f2c5142f63211a035cb191';
|
||||||
|
|
||||||
|
export function isMlitRiverAdjustmentImage(image) {
|
||||||
|
return (
|
||||||
|
image?.ok === true &&
|
||||||
|
Buffer.isBuffer(image.body) &&
|
||||||
|
createHash('sha256').update(image.body).digest('hex') ===
|
||||||
|
MLIT_ADJUSTMENT_IMAGE_SHA256
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeFrameUrl(value, policy) {
|
||||||
|
const normalized = pinnedCameraUrl(value, policy.origin);
|
||||||
|
if (!normalized) return '';
|
||||||
|
const url = new URL(normalized);
|
||||||
|
return !url.search && !url.hash && policy.filename.test(url.pathname)
|
||||||
|
? url.href
|
||||||
|
: '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function inBounds(lat, lon, [minLat, maxLat, minLon, maxLon]) {
|
||||||
|
return (
|
||||||
|
Number.isFinite(lat) &&
|
||||||
|
Number.isFinite(lon) &&
|
||||||
|
lat >= minLat &&
|
||||||
|
lat <= maxLat &&
|
||||||
|
lon >= minLon &&
|
||||||
|
lon <= maxLon
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load only the new image-backed networks from the add-on export. Existing
|
||||||
|
* Canadian networks, Québec entries, JMA placeholder images, and NZTA's
|
||||||
|
* scraped Journey Planner rows are deliberately excluded. Vegagerðin (Iceland)
|
||||||
|
* rows are excluded too: the Iceland pack loads the full live list of those
|
||||||
|
* cameras, so a World copy would draw each one twice.
|
||||||
|
*/
|
||||||
|
export function loadWorldCameraSources({ sourceRoot = process.cwd() } = {}) {
|
||||||
|
const filename = path.resolve(sourceRoot, DEFAULT_WORLD_CCTV_SOURCE_FILE);
|
||||||
|
let rows;
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse(fs.readFileSync(filename, 'utf8'));
|
||||||
|
rows = Array.isArray(payload) ? payload : [];
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[CCTV] World camera catalog unavailable:', error?.message);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const seen = new Set();
|
||||||
|
const sources = [];
|
||||||
|
for (const row of rows) {
|
||||||
|
const sourceKind = String(row?.sourceKind || '');
|
||||||
|
const policy = SOURCE_POLICIES[sourceKind];
|
||||||
|
if (!policy) continue;
|
||||||
|
|
||||||
|
const id = String(row.id || '').trim();
|
||||||
|
const lat = Number(row.lat);
|
||||||
|
const lon = Number(row.lon);
|
||||||
|
const url = safeFrameUrl(row.url || row.snapshotUrl, policy);
|
||||||
|
const snapshotUrl = safeFrameUrl(row.snapshotUrl || row.url, policy);
|
||||||
|
if (
|
||||||
|
!id.startsWith(policy.idPrefix) ||
|
||||||
|
seen.has(id) ||
|
||||||
|
!inBounds(lat, lon, policy.bounds) ||
|
||||||
|
!url ||
|
||||||
|
!snapshotUrl ||
|
||||||
|
url !== snapshotUrl
|
||||||
|
)
|
||||||
|
continue;
|
||||||
|
seen.add(id);
|
||||||
|
sources.push({
|
||||||
|
id,
|
||||||
|
name: String(row.name || id)
|
||||||
|
.trim()
|
||||||
|
.slice(0, 180),
|
||||||
|
city: String(row.city || row.country || 'World'),
|
||||||
|
cityId: String(row.cityId || ''),
|
||||||
|
provider: String(row.provider || 'World traffic camera'),
|
||||||
|
lat,
|
||||||
|
lon,
|
||||||
|
headingDeg: Number.isFinite(Number(row.headingDeg))
|
||||||
|
? Number(row.headingDeg)
|
||||||
|
: undefined,
|
||||||
|
headingConfidence: String(row.headingConfidence || 'low'),
|
||||||
|
feedType: 'image',
|
||||||
|
sourceKind,
|
||||||
|
url,
|
||||||
|
snapshotUrl,
|
||||||
|
frameRefreshMs: policy.frameRefreshMs,
|
||||||
|
license: String(row.license || ''),
|
||||||
|
credit: String(row.credit || row.provider || ''),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`[CCTV] Loaded World camera image sources: ${sources.length}/${WORLD_CCTV_SOURCE_COUNT}`,
|
||||||
|
);
|
||||||
|
return sources;
|
||||||
|
}
|
||||||
@@ -64,6 +64,30 @@ 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,
|
||||||
@@ -126,7 +150,12 @@ export function createOpenAiChatHandler({
|
|||||||
tools: offeredTools(tools, { overpass }).map(
|
tools: offeredTools(tools, { overpass }).map(
|
||||||
({ name, description, parameters }) => ({
|
({ name, description, parameters }) => ({
|
||||||
type: 'function',
|
type: 'function',
|
||||||
function: { name, description, parameters, strict: false },
|
function: {
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
parameters: chatSchema(parameters),
|
||||||
|
strict: false,
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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 { createCyclonesLayer } from '../layers/cyclones/index.js';
|
import { createApplicationCyclones } from './layers/cyclones.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,
|
||||||
}),
|
}),
|
||||||
createCyclonesLayer({ feed: sources.cyclones }),
|
createApplicationCyclones({ feed: sources.cyclones }),
|
||||||
...createOsmOverlayLayers(),
|
...createOsmOverlayLayers(),
|
||||||
...createInfrastructureLayers(localGeoJsonServices),
|
...createInfrastructureLayers(localGeoJsonServices),
|
||||||
createApplicationCables({ source: sources.cables }),
|
createApplicationCables({ source: sources.cables }),
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { createCyclonesLayer } from '../../layers/cyclones/index.js';
|
||||||
|
import * as context from '../../data/contextStore.js';
|
||||||
|
|
||||||
|
/** Wire cyclone selection to the application shared context. */
|
||||||
|
export function createApplicationCyclones(options) {
|
||||||
|
return createCyclonesLayer({
|
||||||
|
context: typeof window !== 'undefined' ? context : null,
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -67,6 +67,8 @@ 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,
|
||||||
|
|||||||
@@ -61,6 +61,8 @@ import cctvLayer, {
|
|||||||
materializeCctvActiveCoverageEntities,
|
materializeCctvActiveCoverageEntities,
|
||||||
materializeCctvVisibleCoverageEntities,
|
materializeCctvVisibleCoverageEntities,
|
||||||
maybeAutoHop,
|
maybeAutoHop,
|
||||||
|
openingCameraId,
|
||||||
|
reopenNearestCamera,
|
||||||
prioritizeActiveCctvGeometryRecord,
|
prioritizeActiveCctvGeometryRecord,
|
||||||
processCctvGeometryDrainBatch,
|
processCctvGeometryDrainBatch,
|
||||||
processCctvGeometryQueueBatch,
|
processCctvGeometryQueueBatch,
|
||||||
@@ -1296,6 +1298,120 @@ test('CCTV null-active coverage, auto-hop, cycling, and panel targets stay hones
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('CCTV opens on the camera nearest the viewer, not the first in the catalog', () => {
|
||||||
|
// QA 2026-11-05: at King's Cross, turning CCTV on showed a Sydney camera,
|
||||||
|
// the first catalog record, instead of the TfL camera 0.3 km away.
|
||||||
|
const viewer = makeDeselectViewer();
|
||||||
|
viewer.camera.positionCartographic = Cesium.Cartographic.fromDegrees(
|
||||||
|
-0.1209,
|
||||||
|
51.5295,
|
||||||
|
187,
|
||||||
|
);
|
||||||
|
viewer.camera.positionWC = Cesium.Cartesian3.fromDegrees(
|
||||||
|
-0.1209,
|
||||||
|
51.5295,
|
||||||
|
187,
|
||||||
|
);
|
||||||
|
const sydney = makeDeselectRecord('au-sydney');
|
||||||
|
sydney.camera = { ...sydney.camera, lat: -34.03, lon: 151.11 };
|
||||||
|
const london = makeDeselectRecord('gb-kings-cross');
|
||||||
|
london.camera = { ...london.camera, lat: 51.5318, lon: -0.1209 };
|
||||||
|
try {
|
||||||
|
_setCctvCoverageStateForTest({
|
||||||
|
viewer,
|
||||||
|
records: [sydney, london],
|
||||||
|
activeCameraId: null,
|
||||||
|
enabled: false,
|
||||||
|
});
|
||||||
|
assert.equal(openingCameraId(), 'gb-kings-cross');
|
||||||
|
_setCctvCoverageStateForTest({
|
||||||
|
viewer: null,
|
||||||
|
records: [sydney, london],
|
||||||
|
activeCameraId: null,
|
||||||
|
enabled: false,
|
||||||
|
});
|
||||||
|
assert.equal(
|
||||||
|
openingCameraId(),
|
||||||
|
'au-sydney',
|
||||||
|
'with no viewer position the first record is the fallback',
|
||||||
|
);
|
||||||
|
_setCctvCoverageStateForTest({ viewer, records: [], enabled: false });
|
||||||
|
assert.equal(openingCameraId(), null);
|
||||||
|
} finally {
|
||||||
|
_setCctvCoverageStateForTest({ enabled: false });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the opening camera follows a shared view that lands after CCTV turned on, until someone picks one', () => {
|
||||||
|
// A share link turns CCTV on before its flight lands, so enable() picks
|
||||||
|
// from the startup view; the flight's settled move re-picks near London.
|
||||||
|
const viewer = makeDeselectViewer();
|
||||||
|
viewer.camera.positionCartographic = Cesium.Cartographic.fromDegrees(
|
||||||
|
-0.1209,
|
||||||
|
51.5295,
|
||||||
|
187,
|
||||||
|
);
|
||||||
|
const sydney = makeDeselectRecord('au-sydney');
|
||||||
|
sydney.camera = { ...sydney.camera, lat: -34.03, lon: 151.11 };
|
||||||
|
const london = makeDeselectRecord('gb-kings-cross');
|
||||||
|
london.camera = { ...london.camera, lat: 51.5318, lon: -0.1209 };
|
||||||
|
for (const record of [sydney, london])
|
||||||
|
Object.assign(record, {
|
||||||
|
groundResolved: {},
|
||||||
|
groundSamples: {},
|
||||||
|
groundPrior: 0,
|
||||||
|
frustumGeometry: null,
|
||||||
|
frustumPositions: null,
|
||||||
|
probeClampRangeM: null,
|
||||||
|
});
|
||||||
|
_setCctvOverlayHostForTest({
|
||||||
|
setEntries() {},
|
||||||
|
setVisible() {},
|
||||||
|
clearSource() {},
|
||||||
|
});
|
||||||
|
// Activation builds the projection canvas and frame image.
|
||||||
|
const originalDocument = globalThis.document;
|
||||||
|
const originalImage = globalThis.Image;
|
||||||
|
globalThis.document = {
|
||||||
|
createElement: () => ({ getContext: () => null }),
|
||||||
|
};
|
||||||
|
globalThis.Image = class {};
|
||||||
|
try {
|
||||||
|
_setCctvCoverageStateForTest({
|
||||||
|
viewer,
|
||||||
|
records: [sydney, london],
|
||||||
|
activeCameraId: 'au-sydney',
|
||||||
|
enabled: true,
|
||||||
|
openingCameraProvisional: true,
|
||||||
|
});
|
||||||
|
assert.equal(reopenNearestCamera(), true);
|
||||||
|
assert.equal(cctvLayer.getUIState().activeCameraId, 'gb-kings-cross');
|
||||||
|
assert.equal(reopenNearestCamera(), false, 'already the nearest');
|
||||||
|
|
||||||
|
// Someone chooses a camera: settled moves leave it alone.
|
||||||
|
setActiveCamera('au-sydney');
|
||||||
|
assert.equal(reopenNearestCamera(), false);
|
||||||
|
assert.equal(cctvLayer.getUIState().activeCameraId, 'au-sydney');
|
||||||
|
|
||||||
|
// A deselect is a choice too.
|
||||||
|
_setCctvCoverageStateForTest({
|
||||||
|
viewer,
|
||||||
|
records: [sydney, london],
|
||||||
|
activeCameraId: 'au-sydney',
|
||||||
|
enabled: true,
|
||||||
|
openingCameraProvisional: true,
|
||||||
|
});
|
||||||
|
deactivateActiveCamera();
|
||||||
|
assert.equal(reopenNearestCamera(), false);
|
||||||
|
assert.equal(cctvLayer.getUIState().activeCameraId, null);
|
||||||
|
} finally {
|
||||||
|
globalThis.document = originalDocument;
|
||||||
|
globalThis.Image = originalImage;
|
||||||
|
_setCctvOverlayHostForTest();
|
||||||
|
_setCctvCoverageStateForTest({ enabled: false });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('CCTV disable clears and hides its shared-host source through the real layer lifecycle', () => {
|
test('CCTV disable clears and hides its shared-host source through the real layer lifecycle', () => {
|
||||||
const calls = [];
|
const calls = [];
|
||||||
_setCctvOverlayHostForTest({
|
_setCctvOverlayHostForTest({
|
||||||
|
|||||||
@@ -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,
|
||||||
DEFAULT_CCTV_MAX_SOURCES,
|
CCTV_SHARED_SOURCE_SLOTS,
|
||||||
} 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 shipped catalog ceiling is not raised to make room for this pack', () => {
|
test('the shared catalog slots are not raised to make room for this pack', () => {
|
||||||
assert.equal(DEFAULT_CCTV_MAX_SOURCES, 4000);
|
assert.equal(CCTV_SHARED_SOURCE_SLOTS, 4000);
|
||||||
});
|
});
|
||||||
@@ -6,9 +6,12 @@ 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) => ({
|
||||||
@@ -99,6 +102,68 @@ test('duplicate ids resolve last-pack-wins and count against the winning pack',
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** Shared packs plus two reserved ones, shaped like the default catalog. */
|
||||||
|
const reservedFixture = () => [
|
||||||
|
...Array.from({ length: 20 }, (_, i) => pack(`shared${i}`, 250)),
|
||||||
|
{ ...pack('quebec', 1120), reservedCount: 1120 },
|
||||||
|
{ ...pack('world', 2230), reservedCount: 2230 },
|
||||||
|
];
|
||||||
|
const keptByName = (packs) =>
|
||||||
|
Object.fromEntries(packs.map(({ name, kept }) => [name, kept]));
|
||||||
|
|
||||||
|
test('the default cap is the shared floor plus the reserved Québec and World packs', () => {
|
||||||
|
assert.equal(
|
||||||
|
DEFAULT_CCTV_MAX_SOURCES,
|
||||||
|
CCTV_SHARED_SOURCE_SLOTS +
|
||||||
|
QUEBEC_CAMERA_SOURCE_COUNT +
|
||||||
|
WORLD_CCTV_SOURCE_COUNT,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('at the default cap reserved packs are whole and shared packs keep the floor', () => {
|
||||||
|
const { sources, packs } = allocateSourceCap(
|
||||||
|
reservedFixture(),
|
||||||
|
DEFAULT_CCTV_MAX_SOURCES,
|
||||||
|
);
|
||||||
|
const kept = keptByName(packs);
|
||||||
|
assert.equal(sources.length, DEFAULT_CCTV_MAX_SOURCES);
|
||||||
|
assert.equal(kept.quebec, 1120);
|
||||||
|
assert.equal(kept.world, 2230);
|
||||||
|
for (let i = 0; i < 20; i++) assert.equal(kept[`shared${i}`], 200);
|
||||||
|
assert.match(
|
||||||
|
sources[0].id,
|
||||||
|
/^shared0-/,
|
||||||
|
'the first camera stays in a shared pack',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a cap at the shared floor reserves nothing and thins every pack evenly', () => {
|
||||||
|
const { packs } = allocateSourceCap(reservedFixture(), 4000);
|
||||||
|
const kept = keptByName(packs);
|
||||||
|
// 22 lanes share 4,000 round-robin: nobody is wiped out by the reservation.
|
||||||
|
for (const count of Object.values(kept))
|
||||||
|
assert.ok(count >= 181 && count <= 182);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a small cap (dev-secure 48) still reaches the shared packs first', () => {
|
||||||
|
const { sources, packs } = allocateSourceCap(reservedFixture(), 48);
|
||||||
|
const kept = keptByName(packs);
|
||||||
|
assert.equal(sources.length, 48);
|
||||||
|
for (let i = 0; i < 20; i++) assert.ok(kept[`shared${i}`] >= 2);
|
||||||
|
assert.match(sources[0].id, /^shared0-/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reservations fill only the room above the floor, round-robin between reserved packs', () => {
|
||||||
|
const { packs } = allocateSourceCap(reservedFixture(), 4100);
|
||||||
|
const kept = keptByName(packs);
|
||||||
|
const shared = Object.entries(kept)
|
||||||
|
.filter(([name]) => name.startsWith('shared'))
|
||||||
|
.reduce((sum, [, count]) => sum + count, 0);
|
||||||
|
// 100 reserved slots split between Québec and World, then 4,000 round-robin.
|
||||||
|
assert.ok(shared >= 4000 - 2 * 182, `shared packs kept ${shared}`);
|
||||||
|
assert.equal(shared + kept.quebec + kept.world, 4100);
|
||||||
|
});
|
||||||
|
|
||||||
test('resolveCatalogCap bounds the env value and falls back to the default', () => {
|
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);
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ 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,
|
||||||
@@ -222,7 +224,7 @@ test('CCTV catalog merges DriveBC cameras and CCTV_DRIVEBC_ENABLED=0 skips the r
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('default caps of the original packs fit inside the default catalog cap', () => {
|
test('default caps of the original packs fit inside the shared catalog slots', () => {
|
||||||
// Every default pack at its own cap (Warendorf ships 1 curated camera).
|
// Every default pack at its own cap (Warendorf ships 1 curated camera).
|
||||||
const packs =
|
const packs =
|
||||||
DEFAULT_AUSTIN_MAX_SOURCES +
|
DEFAULT_AUSTIN_MAX_SOURCES +
|
||||||
@@ -231,15 +233,18 @@ test('default caps of the original packs fit inside the default catalog cap', ()
|
|||||||
DEFAULT_ONTARIO_MAX_SOURCES +
|
DEFAULT_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 <= DEFAULT_CCTV_MAX_SOURCES,
|
packs <= CCTV_SHARED_SOURCE_SLOTS,
|
||||||
`default packs (${packs}) would be thinned by the catalog cap (${DEFAULT_CCTV_MAX_SOURCES})`,
|
`default packs (${packs}) would be thinned by the shared slots (${CCTV_SHARED_SOURCE_SLOTS})`,
|
||||||
);
|
);
|
||||||
assert.ok(
|
assert.ok(
|
||||||
DEFAULT_CCTV_MAX_SOURCES <= CCTV_MAX_SOURCES_CEILING,
|
DEFAULT_CCTV_MAX_SOURCES <= CCTV_MAX_SOURCES_CEILING,
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
parseHkCameraLocationsXml,
|
parseHkCameraLocationsXml,
|
||||||
} from '../../server/providers/cctv/sources.js';
|
} from '../../server/providers/cctv/sources.js';
|
||||||
import {
|
import {
|
||||||
DEFAULT_CCTV_MAX_SOURCES,
|
CCTV_SHARED_SOURCE_SLOTS,
|
||||||
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 shipped catalog ceiling is not raised to make room for this pack', () => {
|
test('the shared catalog slots are not raised to make room for this pack', () => {
|
||||||
assert.equal(DEFAULT_CCTV_MAX_SOURCES, 4000);
|
assert.equal(CCTV_SHARED_SOURCE_SLOTS, 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 <= DEFAULT_CCTV_MAX_SOURCES,
|
DEFAULT_HK_MAX_SOURCES <= CCTV_SHARED_SOURCE_SLOTS,
|
||||||
'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(
|
||||||
|
|||||||
@@ -10,6 +10,77 @@ const playlist =
|
|||||||
'#EXTM3U\n#EXT-X-MEDIA-SEQUENCE:12\n#EXTINF:2,\na.ts\n#EXTINF:2,\nb.ts\n#EXTINF:2,\nc.ts\n';
|
'#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),
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
loadHoustonSourcesFromOpenData,
|
loadHoustonSourcesFromOpenData,
|
||||||
} from '../../server/providers/cctv/sources.js';
|
} from '../../server/providers/cctv/sources.js';
|
||||||
import {
|
import {
|
||||||
DEFAULT_CCTV_MAX_SOURCES,
|
CCTV_SHARED_SOURCE_SLOTS,
|
||||||
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 shipped catalog ceiling is not raised to make room for this pack', () => {
|
test('the shared catalog slots are not raised to make room for this pack', () => {
|
||||||
assert.equal(DEFAULT_CCTV_MAX_SOURCES, 4000);
|
assert.equal(CCTV_SHARED_SOURCE_SLOTS, 4000);
|
||||||
assert.equal(DEFAULT_HOUSTON_MAX_SOURCES, 250);
|
assert.equal(DEFAULT_HOUSTON_MAX_SOURCES, 250);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,429 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { createCctvCatalog } from '../../server/providers/cctv/catalog.js';
|
||||||
|
import {
|
||||||
|
DEFAULT_ICELAND_MAX_SOURCES,
|
||||||
|
ICELAND_CAMERAS_URL,
|
||||||
|
ICELAND_IMAGE_ORIGIN,
|
||||||
|
ICELAND_MAX_CATALOG_BYTES,
|
||||||
|
} from '../../server/providers/cctv/constants.js';
|
||||||
|
import { fetchWithinHost } from '../../server/providers/cctv/media.js';
|
||||||
|
import { isLikelyIcelandCoordinate } from '../../server/providers/cctv/normalize.js';
|
||||||
|
import {
|
||||||
|
icelandCameraToSource,
|
||||||
|
icelandHeadingFromDescription,
|
||||||
|
loadIcelandSourcesFromOpenData,
|
||||||
|
} from '../../server/providers/cctv/sources.js';
|
||||||
|
|
||||||
|
/** Set (or, for `undefined`, delete) environment variables for one test. */
|
||||||
|
function withEnv(t, env) {
|
||||||
|
for (const [name, value] of Object.entries(env)) {
|
||||||
|
const previous = process.env[name];
|
||||||
|
t.after(() => {
|
||||||
|
if (previous === undefined) delete process.env[name];
|
||||||
|
else process.env[name] = previous;
|
||||||
|
});
|
||||||
|
if (value === undefined) delete process.env[name];
|
||||||
|
else process.env[name] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One row shaped like the live `vefmyndavelar2014_1` payload. */
|
||||||
|
const row = (over = {}) => ({
|
||||||
|
Maelist_nr: 7001,
|
||||||
|
Myndavel: 'Hellisheiði',
|
||||||
|
Vegheiti: 'Hringvegur',
|
||||||
|
NrVegur: '1',
|
||||||
|
Skyring: 'Hellisheiði séð til vesturs',
|
||||||
|
Slod: `${ICELAND_IMAGE_ORIGIN}hellisheidi_1.jpg`,
|
||||||
|
PntX: 395000,
|
||||||
|
PntY: 390000,
|
||||||
|
Breidd: 64.0136,
|
||||||
|
Lengd: -21.4,
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
|
||||||
|
/** A live response body plus a flag that flips when the stream is cancelled. */
|
||||||
|
const streamingResponse = (init = {}) => {
|
||||||
|
const state = { cancelled: false };
|
||||||
|
const body = new ReadableStream({
|
||||||
|
start(controller) {
|
||||||
|
controller.enqueue(new TextEncoder().encode('['));
|
||||||
|
},
|
||||||
|
cancel() {
|
||||||
|
state.cancelled = true;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return { response: new Response(body, init), state };
|
||||||
|
};
|
||||||
|
|
||||||
|
const json = (value) =>
|
||||||
|
new Response(JSON.stringify(value), {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an Iceland row maps to a source on the pinned frame directory', () => {
|
||||||
|
const source = icelandCameraToSource(row());
|
||||||
|
assert.equal(source.id, 'is-vegagerdin-hellisheidi_1');
|
||||||
|
assert.equal(source.name, 'Hellisheiði séð til vesturs');
|
||||||
|
assert.equal(source.city, 'Iceland');
|
||||||
|
assert.equal(source.cityId, 'iceland');
|
||||||
|
assert.equal(source.provider, 'Vegagerðin (IRCA)');
|
||||||
|
assert.equal(source.lat, 64.0136);
|
||||||
|
assert.equal(source.lon, -21.4);
|
||||||
|
assert.equal(source.headingDeg, 270);
|
||||||
|
assert.equal(source.headingConfidence, 'high');
|
||||||
|
assert.equal(source.pitchDeg, -24);
|
||||||
|
assert.equal(source.fovDeg, 56);
|
||||||
|
assert.equal(source.rangeM, 210);
|
||||||
|
assert.equal(source.mountHeightM, 10);
|
||||||
|
assert.equal(source.feedType, 'image');
|
||||||
|
assert.equal(source.sourceKind, 'iceland-vegagerdin');
|
||||||
|
assert.equal(source.code, 'HELLISHEIÐI');
|
||||||
|
assert.match(source.license, /CC BY 4\.0/);
|
||||||
|
assert.ok(source.url.startsWith(ICELAND_IMAGE_ORIGIN));
|
||||||
|
assert.equal(source.snapshotUrl, source.url);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a view that states no facing takes the low-confidence id-hash pose', () => {
|
||||||
|
const source = icelandCameraToSource(
|
||||||
|
row({
|
||||||
|
Skyring: 'Hellisheiði séð niður á veg',
|
||||||
|
Slod: `${ICELAND_IMAGE_ORIGIN}hellisheidi_2.jpg`,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
assert.equal(source.headingConfidence, 'low');
|
||||||
|
assert.equal(source.headingDeg % 22.5, 0);
|
||||||
|
assert.equal(
|
||||||
|
source.headingDeg,
|
||||||
|
icelandCameraToSource(
|
||||||
|
row({ Slod: `${ICELAND_IMAGE_ORIGIN}hellisheidi_2.jpg` }),
|
||||||
|
).headingDeg,
|
||||||
|
'the fallback depends on the id, not on the text',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the label collapses the feed’s doubled spaces and repeated words', () => {
|
||||||
|
assert.equal(
|
||||||
|
icelandCameraToSource(
|
||||||
|
row({ Skyring: 'Strandarheiði séð niður á veg til til austurs' }),
|
||||||
|
).name,
|
||||||
|
'Strandarheiði séð niður á veg til austurs',
|
||||||
|
);
|
||||||
|
const label = (Skyring) => icelandCameraToSource(row({ Skyring })).name;
|
||||||
|
// Repeats of words with Icelandic letters collapse too.
|
||||||
|
assert.equal(
|
||||||
|
label('Hellisheiði séð séð til austurs'),
|
||||||
|
'Hellisheiði séð til austurs',
|
||||||
|
);
|
||||||
|
assert.equal(label('Brú á á Jökulsá'), 'Brú á Jökulsá');
|
||||||
|
// A word ending in a letter the next word starts with is not a repeat.
|
||||||
|
for (const text of [
|
||||||
|
'Þverá Árnessýslu',
|
||||||
|
'brú Úlfarsá',
|
||||||
|
'Vesturlandsvegur við Elliðaá Ártúnsbrekku',
|
||||||
|
])
|
||||||
|
assert.equal(label(text), text);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a row with no label, station or station number is named by its frame', () => {
|
||||||
|
const source = icelandCameraToSource(
|
||||||
|
row({ Skyring: '', Myndavel: '', Maelist_nr: undefined }),
|
||||||
|
);
|
||||||
|
assert.equal(source.name, 'Vegagerðin hellisheidi_1');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('only an explicit facing sets a heading', () => {
|
||||||
|
const cases = [
|
||||||
|
['Hellisheiði séð til vesturs', 270],
|
||||||
|
['Holtavörðuheiði til norðurs', 0],
|
||||||
|
['Klettsháls í suður', 180],
|
||||||
|
['Arnarneshæð séð austur', 90],
|
||||||
|
['Bústaðabrú séð norður Kringlumýrarbraut', 0],
|
||||||
|
['Staður séð til norðausturs', 45],
|
||||||
|
['Staður séð til suðausturs', 135],
|
||||||
|
['Staður séð í suðvestur', 225],
|
||||||
|
['Staður séð til norðvesturs', 315],
|
||||||
|
['STAÐUR SÉÐ TIL VESTURS', 270],
|
||||||
|
['Brú séð til austurs og Selfoss', 90], // "og" then a place, not a facing
|
||||||
|
['Staður séð til austurs og Vesturlandsvegur', 90],
|
||||||
|
];
|
||||||
|
for (const [text, heading] of cases) {
|
||||||
|
assert.equal(icelandHeadingFromDescription(text), heading, text);
|
||||||
|
}
|
||||||
|
for (const text of [
|
||||||
|
'',
|
||||||
|
null,
|
||||||
|
'Gautsdalur niður á veg',
|
||||||
|
'Miklabraut austur', // a road direction, not a facing
|
||||||
|
'Biskupsháls - norður',
|
||||||
|
'Hringtorg norðan Selfoss',
|
||||||
|
'Norðurárdalur niður á veg', // a place name that contains a cardinal
|
||||||
|
'Staður séð til suðurlands', // a longer word, not "suður"
|
||||||
|
'Staður séð til norðurs og til suðurs', // two facings: ambiguous
|
||||||
|
'Staður séð til austurs og vesturs', // the second facing shares "til"
|
||||||
|
'Staður séð í norður og suður',
|
||||||
|
'Staður séð í norður og í suður',
|
||||||
|
'Norðfjarðargöng séð til Eskifjarðar',
|
||||||
|
]) {
|
||||||
|
assert.ok(Number.isNaN(icelandHeadingFromDescription(text)), String(text));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rows off the pinned directory, or with unusable frames or places, are dropped', () => {
|
||||||
|
const bad = [
|
||||||
|
{ Slod: 'https://example.com/vgdata/vefmyndavelar/a_1.jpg' },
|
||||||
|
{ Slod: 'http://www.vegagerdin.is/vgdata/vefmyndavelar/a_1.jpg' },
|
||||||
|
{ Slod: 'https://user:[email protected]/vgdata/vefmyndavelar/a_1.jpg' },
|
||||||
|
{ Slod: 'https://www.vegagerdin.is:8443/vgdata/vefmyndavelar/a_1.jpg' },
|
||||||
|
{ Slod: 'https://www.vegagerdin.is/vgdata/other/a_1.jpg' },
|
||||||
|
{ Slod: 'https://www.vegagerdin.is/vgdata/vefmyndavelar/../x/a_1.jpg' },
|
||||||
|
{ Slod: 'https://www.vegagerdin.is/vgdata/vefmyndavelar/a%2Fb_1.jpg' },
|
||||||
|
{ Slod: 'https://www.vegagerdin.is/vgdata/vefmyndavelar/a_1.php' },
|
||||||
|
{ Slod: 'https://www.vegagerdin.is/vgdata/vefmyndavelar/a_1' },
|
||||||
|
{ Slod: 'https://www.vegagerdin.is/vgdata/vefmyndavelar/' },
|
||||||
|
{ Slod: 'https://www.vegagerdin.is/vgdata/vefmyndavelar/a b_1.jpg' },
|
||||||
|
{ Slod: '' },
|
||||||
|
{ Slod: null },
|
||||||
|
{ Breidd: 0, Lengd: 0 },
|
||||||
|
{ Breidd: null, Lengd: null },
|
||||||
|
{ Breidd: '64.01', Lengd: '-21.4' },
|
||||||
|
{ Breidd: '64.01' }, // each coordinate must be a number on its own
|
||||||
|
{ Lengd: '-21.4' },
|
||||||
|
{ Breidd: 59.9, Lengd: 10.7 }, // Oslo
|
||||||
|
{ Breidd: 64.01, Lengd: 21.4 }, // wrong hemisphere
|
||||||
|
];
|
||||||
|
for (const over of bad) {
|
||||||
|
assert.equal(icelandCameraToSource(row(over)), null, JSON.stringify(over));
|
||||||
|
}
|
||||||
|
assert.equal(icelandCameraToSource(null), null);
|
||||||
|
assert.equal(icelandCameraToSource({}), null);
|
||||||
|
assert.equal(isLikelyIcelandCoordinate(64.1466, -21.9426), true);
|
||||||
|
assert.equal(isLikelyIcelandCoordinate(66.3, -14.0), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the Iceland loader reads the list with redirects refused and keeps the nearest cameras', async (t) => {
|
||||||
|
t.mock.method(console, 'log', () => {});
|
||||||
|
t.mock.method(console, 'warn', () => {});
|
||||||
|
withEnv(t, { CCTV_ICELAND_MAX_SOURCES: '8' });
|
||||||
|
const rows = Array.from({ length: 12 }, (_, i) =>
|
||||||
|
row({
|
||||||
|
Maelist_nr: 8000 + i,
|
||||||
|
Slod: `${ICELAND_IMAGE_ORIGIN}far_${i}.jpg`,
|
||||||
|
Skyring: `Far ${i}`,
|
||||||
|
Breidd: 66.2,
|
||||||
|
Lengd: -15.9 + i * 0.01,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
// Reykjavík cameras come last in the feed but first in the priority order.
|
||||||
|
// A frame the feed repeats keeps its last row, as the other packs do.
|
||||||
|
rows.push(row({ Slod: `${ICELAND_IMAGE_ORIGIN}rvk_1.jpg`, Skyring: 'Old' }));
|
||||||
|
rows.push(
|
||||||
|
row({
|
||||||
|
Slod: `${ICELAND_IMAGE_ORIGIN}rvk_1.jpg`,
|
||||||
|
Skyring: 'Kringlumýrarbraut',
|
||||||
|
Breidd: 64.1466,
|
||||||
|
Lengd: -21.9426,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const seen = [];
|
||||||
|
t.mock.method(globalThis, 'fetch', async (url, init) => {
|
||||||
|
seen.push([String(url), init.redirect]);
|
||||||
|
return json(rows);
|
||||||
|
});
|
||||||
|
const sources = await loadIcelandSourcesFromOpenData();
|
||||||
|
assert.deepEqual(seen, [[ICELAND_CAMERAS_URL, 'manual']]);
|
||||||
|
assert.equal(sources.length, 8);
|
||||||
|
assert.equal(sources[0].id, 'is-vegagerdin-rvk_1');
|
||||||
|
assert.equal(sources[0].name, 'Kringlumýrarbraut');
|
||||||
|
assert.equal(sources[0].lat, 64.1466);
|
||||||
|
assert.equal(
|
||||||
|
new Set(sources.map((s) => s.id)).size,
|
||||||
|
sources.length,
|
||||||
|
'a frame the feed repeats collapses to one camera',
|
||||||
|
);
|
||||||
|
assert.equal(sources.available, 13);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the default Iceland cap is DEFAULT_ICELAND_MAX_SOURCES', async (t) => {
|
||||||
|
t.mock.method(console, 'log', () => {});
|
||||||
|
withEnv(t, { CCTV_ICELAND_MAX_SOURCES: undefined });
|
||||||
|
const rows = Array.from(
|
||||||
|
{ length: DEFAULT_ICELAND_MAX_SOURCES + 40 },
|
||||||
|
(_, i) => row({ Slod: `${ICELAND_IMAGE_ORIGIN}cam_${i}.jpg` }),
|
||||||
|
);
|
||||||
|
t.mock.method(globalThis, 'fetch', async () => json(rows));
|
||||||
|
assert.equal(
|
||||||
|
(await loadIcelandSourcesFromOpenData()).length,
|
||||||
|
DEFAULT_ICELAND_MAX_SOURCES,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('CCTV_ICELAND_MAX_SOURCES is clamped to 8..600 and ignored when not a number', async (t) => {
|
||||||
|
t.mock.method(console, 'log', () => {});
|
||||||
|
withEnv(t, { CCTV_ICELAND_MAX_SOURCES: undefined });
|
||||||
|
const rows = Array.from({ length: 650 }, (_, i) =>
|
||||||
|
row({ Slod: `${ICELAND_IMAGE_ORIGIN}cam_${i}.jpg` }),
|
||||||
|
);
|
||||||
|
t.mock.method(globalThis, 'fetch', async () => json(rows));
|
||||||
|
for (const [value, expected] of [
|
||||||
|
['0', 8],
|
||||||
|
['3', 8],
|
||||||
|
['1000', 600],
|
||||||
|
['abc', DEFAULT_ICELAND_MAX_SOURCES],
|
||||||
|
]) {
|
||||||
|
process.env.CCTV_ICELAND_MAX_SOURCES = value;
|
||||||
|
assert.equal(
|
||||||
|
(await loadIcelandSourcesFromOpenData()).length,
|
||||||
|
expected,
|
||||||
|
value,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a redirect on the Iceland frame host stays inside vefmyndavelar/', async () => {
|
||||||
|
const frame = `${ICELAND_IMAGE_ORIGIN}hellisheidi_1.jpg`;
|
||||||
|
const redirectTo = (location) => async (url) =>
|
||||||
|
url === frame
|
||||||
|
? new Response(null, { status: 302, headers: { location } })
|
||||||
|
: new Response('frame', { headers: { 'content-type': 'image/jpeg' } });
|
||||||
|
for (const location of [
|
||||||
|
'/vgdata/other/x.jpg',
|
||||||
|
'/vgdata/vefmyndavelar/../x.jpg',
|
||||||
|
'/',
|
||||||
|
])
|
||||||
|
assert.equal(
|
||||||
|
await fetchWithinHost(frame, {}, redirectTo(location)),
|
||||||
|
null,
|
||||||
|
location,
|
||||||
|
);
|
||||||
|
const followed = await fetchWithinHost(
|
||||||
|
frame,
|
||||||
|
{},
|
||||||
|
redirectTo('/vgdata/vefmyndavelar/hellisheidi_1b.jpg'),
|
||||||
|
);
|
||||||
|
assert.equal(await followed.text(), 'frame');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the Iceland loader fails soft on HTTP errors, bad payloads and network errors', async (t) => {
|
||||||
|
t.mock.method(console, 'log', () => {});
|
||||||
|
t.mock.method(console, 'warn', () => {});
|
||||||
|
t.mock.method(
|
||||||
|
globalThis,
|
||||||
|
'fetch',
|
||||||
|
async () => new Response('x', { status: 503 }),
|
||||||
|
);
|
||||||
|
assert.deepEqual(await loadIcelandSourcesFromOpenData(), []);
|
||||||
|
t.mock.restoreAll();
|
||||||
|
t.mock.method(console, 'log', () => {});
|
||||||
|
t.mock.method(console, 'warn', () => {});
|
||||||
|
t.mock.method(globalThis, 'fetch', async () => json({ rows: [] }));
|
||||||
|
assert.deepEqual(await loadIcelandSourcesFromOpenData(), []);
|
||||||
|
t.mock.method(
|
||||||
|
globalThis,
|
||||||
|
'fetch',
|
||||||
|
async () => new Response('<html>', { status: 200 }),
|
||||||
|
);
|
||||||
|
assert.deepEqual(await loadIcelandSourcesFromOpenData(), []);
|
||||||
|
t.mock.method(globalThis, 'fetch', async () => {
|
||||||
|
throw new Error('network down');
|
||||||
|
});
|
||||||
|
assert.deepEqual(await loadIcelandSourcesFromOpenData(), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the Iceland catalog fetch refuses redirects and oversized bodies', async (t) => {
|
||||||
|
t.mock.method(console, 'warn', () => {});
|
||||||
|
const redirected = streamingResponse({
|
||||||
|
status: 302,
|
||||||
|
headers: {
|
||||||
|
location: 'http://gagnaveita.vegagerdin.is/api/vefmyndavelar2014_1',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
t.mock.method(globalThis, 'fetch', async () => redirected.response);
|
||||||
|
assert.deepEqual(await loadIcelandSourcesFromOpenData(), []);
|
||||||
|
assert.equal(
|
||||||
|
redirected.state.cancelled,
|
||||||
|
true,
|
||||||
|
'the redirect body is cancelled',
|
||||||
|
);
|
||||||
|
|
||||||
|
t.mock.restoreAll();
|
||||||
|
t.mock.method(console, 'warn', () => {});
|
||||||
|
const failed = streamingResponse({ status: 500 });
|
||||||
|
t.mock.method(globalThis, 'fetch', async () => failed.response);
|
||||||
|
assert.deepEqual(await loadIcelandSourcesFromOpenData(), []);
|
||||||
|
assert.equal(failed.state.cancelled, true, 'the error body is cancelled');
|
||||||
|
|
||||||
|
const valid = JSON.stringify([row()]);
|
||||||
|
t.mock.restoreAll();
|
||||||
|
t.mock.method(console, 'warn', () => {});
|
||||||
|
t.mock.method(globalThis, 'fetch', async () => {
|
||||||
|
return new Response(valid, {
|
||||||
|
headers: { 'content-length': String(ICELAND_MAX_CATALOG_BYTES + 1) },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
assert.deepEqual(await loadIcelandSourcesFromOpenData(), []);
|
||||||
|
|
||||||
|
t.mock.restoreAll();
|
||||||
|
t.mock.method(console, 'warn', () => {});
|
||||||
|
t.mock.method(globalThis, 'fetch', async () => {
|
||||||
|
const padded = valid + ' '.repeat(ICELAND_MAX_CATALOG_BYTES + 1024);
|
||||||
|
return new Response(
|
||||||
|
new ReadableStream({
|
||||||
|
start(controller) {
|
||||||
|
controller.enqueue(new TextEncoder().encode(padded));
|
||||||
|
controller.close();
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
assert.deepEqual(await loadIcelandSourcesFromOpenData(), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('CCTV catalog merges Iceland cameras and CCTV_ICELAND_ENABLED=0 skips the request', async (t) => {
|
||||||
|
t.mock.method(console, 'log', () => {});
|
||||||
|
t.mock.method(console, 'warn', () => {});
|
||||||
|
const sourceRoot = fs.mkdtempSync(
|
||||||
|
path.join(os.tmpdir(), 'gev-cctv-catalog-iceland-'),
|
||||||
|
);
|
||||||
|
t.after(() => fs.rmSync(sourceRoot, { recursive: true, force: true }));
|
||||||
|
withEnv(t, {
|
||||||
|
CCTV_SOURCES_FILE: undefined,
|
||||||
|
CCTV_SOURCES_JSON: undefined,
|
||||||
|
CCTV_FORCE_AUSTIN: undefined,
|
||||||
|
CCTV_PREFER_AUSTIN: undefined,
|
||||||
|
CCTV_MAX_SOURCES: undefined,
|
||||||
|
CCTV_CALTRANS_DISTRICTS: undefined,
|
||||||
|
CCTV_TFL_ENABLED: undefined,
|
||||||
|
CCTV_ICELAND_ENABLED: undefined,
|
||||||
|
CCTV_ICELAND_MAX_SOURCES: undefined,
|
||||||
|
});
|
||||||
|
const requested = [];
|
||||||
|
t.mock.method(globalThis, 'fetch', async (url, init) => {
|
||||||
|
requested.push([String(url), init.redirect]);
|
||||||
|
if (String(url) !== ICELAND_CAMERAS_URL) {
|
||||||
|
return new Response('unavailable', { status: 503 });
|
||||||
|
}
|
||||||
|
return json([row()]);
|
||||||
|
});
|
||||||
|
|
||||||
|
const sources = await createCctvCatalog({ sourceRoot })();
|
||||||
|
assert.deepEqual(
|
||||||
|
sources.map((source) => source.id),
|
||||||
|
['is-vegagerdin-hellisheidi_1'],
|
||||||
|
);
|
||||||
|
|
||||||
|
process.env.CCTV_ICELAND_ENABLED = '0';
|
||||||
|
requested.length = 0;
|
||||||
|
assert.deepEqual(await createCctvCatalog({ sourceRoot })(), []);
|
||||||
|
assert.ok(requested.length > 0, 'the other live packs still load');
|
||||||
|
assert.equal(
|
||||||
|
requested.some(([url]) => url === ICELAND_CAMERAS_URL),
|
||||||
|
false,
|
||||||
|
'a disabled pack makes no request',
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
nycCameraToSource,
|
nycCameraToSource,
|
||||||
} from '../../server/providers/cctv/sources.js';
|
} from '../../server/providers/cctv/sources.js';
|
||||||
import {
|
import {
|
||||||
DEFAULT_CCTV_MAX_SOURCES,
|
CCTV_SHARED_SOURCE_SLOTS,
|
||||||
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 shipped catalog ceiling is not raised to make room for this pack', () => {
|
test('the shared catalog slots are not raised to make room for this pack', () => {
|
||||||
assert.equal(DEFAULT_CCTV_MAX_SOURCES, 4000);
|
assert.equal(CCTV_SHARED_SOURCE_SLOTS, 4000);
|
||||||
assert.equal(DEFAULT_NYC_MAX_SOURCES, 250);
|
assert.equal(DEFAULT_NYC_MAX_SOURCES, 250);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,255 @@
|
|||||||
|
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);
|
||||||
|
});
|
||||||
@@ -313,3 +313,234 @@ 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);
|
||||||
|
});
|
||||||
@@ -182,6 +182,86 @@ 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]) {
|
||||||
@@ -214,13 +294,17 @@ test('the Taiwan pack is in the catalog by default', async (t) => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('CCTV_TAIWAN_ENABLED=0 makes no Freeway Bureau request', async (t) => {
|
test('CCTV_TAIWAN_ENABLED=0 makes no Taiwan catalog 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'));
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -250,12 +334,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, 1, 'the second refresh made no request');
|
assert.equal(calls, 2, '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, 2, 'the backoff ends after an hour');
|
assert.equal(calls, 4, '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) => {
|
||||||
|
|||||||
@@ -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,
|
||||||
DEFAULT_CCTV_MAX_SOURCES,
|
CCTV_SHARED_SOURCE_SLOTS,
|
||||||
} 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 shipped catalog ceiling is not raised to make room for this pack', () => {
|
test('the shared catalog slots are not raised to make room for this pack', () => {
|
||||||
assert.equal(DEFAULT_CCTV_MAX_SOURCES, 4000);
|
assert.equal(CCTV_SHARED_SOURCE_SLOTS, 4000);
|
||||||
});
|
});
|
||||||
@@ -0,0 +1,316 @@
|
|||||||
|
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');
|
||||||
|
});
|
||||||
@@ -299,6 +299,38 @@ 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:
|
||||||
@@ -321,6 +353,15 @@ 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 & 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:
|
||||||
@@ -345,6 +386,14 @@ 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:
|
||||||
|
|||||||
@@ -153,3 +153,17 @@ 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\//,
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -335,6 +335,9 @@ 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'], {
|
||||||
|
|||||||
@@ -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 }, source: {} });
|
parts: { selection: empty, geometryQueue: empty, rendering: empty, projection: empty, cards: empty, presentation: empty, navigation: 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);
|
||||||
});
|
});
|
||||||
|
|||||||
+38
-20
@@ -301,6 +301,8 @@
|
|||||||
"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": "محطة الراديو السابقة في القائمة المصفّاة",
|
||||||
@@ -1747,6 +1749,11 @@
|
|||||||
"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": "تم استهلاك حصة حركة المرور الشهرية",
|
||||||
@@ -3226,7 +3233,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": "الحد الأدنى للقدرة الإشعاعية للحريق، بالميغاواط",
|
||||||
@@ -3244,15 +3251,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}",
|
||||||
@@ -3263,12 +3270,12 @@
|
|||||||
"other": "الصور المطابقة: {count} من {total}"
|
"other": "الصور المطابقة: {count} من {total}"
|
||||||
},
|
},
|
||||||
"{n} acquisitions searched": {
|
"{n} acquisitions searched": {
|
||||||
"few": "الصور التي جرى البحث فيها: {n}",
|
"zero": "الصور المفحوصة: {n}",
|
||||||
"many": "الصور التي جرى البحث فيها: {n}",
|
"one": "الصور المفحوصة: {n}",
|
||||||
"one": "الصور التي جرى البحث فيها: {n}",
|
"two": "الصور المفحوصة: {n}",
|
||||||
"two": "الصور التي جرى البحث فيها: {n}",
|
"few": "الصور المفحوصة: {n}",
|
||||||
"zero": "الصور التي جرى البحث فيها: {n}",
|
"many": "الصور المفحوصة: {n}",
|
||||||
"other": "الصور التي جرى البحث فيها: {n}"
|
"other": "الصور المفحوصة: {n}"
|
||||||
},
|
},
|
||||||
"No matching imagery": "لا توجد صور مطابقة",
|
"No matching imagery": "لا توجد صور مطابقة",
|
||||||
"Unknown number of {kind}": "{kind}: العدد غير معروف",
|
"Unknown number of {kind}": "{kind}: العدد غير معروف",
|
||||||
@@ -3276,10 +3283,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": "البحث عن صور حديثة",
|
||||||
@@ -3322,9 +3329,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": "اعرض تتبّع الرحلات المباشر",
|
||||||
@@ -3399,7 +3406,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": "للعلم فقط · لا يصلح لتخطيط الرحلات",
|
||||||
@@ -3407,7 +3414,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": "المجال الجوي المحظور والمقيّد",
|
||||||
@@ -3466,5 +3473,16 @@
|
|||||||
"Open {name} on Acartia": "فتح {name} على Acartia",
|
"Open {name} on Acartia": "فتح {name} على Acartia",
|
||||||
"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}ي",
|
||||||
|
"All markers": "كل العلامات",
|
||||||
|
"Show markers of every age": "عرض العلامات من جميع الأعمار",
|
||||||
|
"Maximum event age: {days}d; undated markers are kept": "الحد الأقصى لعمر الحدث: {days} يوم؛ تُحفظ العلامات غير المؤرخة",
|
||||||
|
"event age filter|All": "الكل",
|
||||||
|
"Event age": "عمر الحدث",
|
||||||
|
"Event age: {days}d · undated kept": "عمر الحدث: {days} يوم · الاحتفاظ بغير المؤرخ",
|
||||||
|
"Close camera preview": "إغلاق معاينة الكاميرا",
|
||||||
|
"Refresh map still": "تحديث صورة الخريطة الثابتة",
|
||||||
|
"The map still uses a separate request and may show a different moment.": "تستخدم صورة الخريطة طلبًا منفصلًا وقد تعرض لحظة مختلفة.",
|
||||||
|
"Video plays from Québec 511, which sees your IP address.": "يُشغَّل الفيديو من Québec 511، الذي يرى عنوان IP الخاص بك."
|
||||||
}
|
}
|
||||||
+36
-18
@@ -760,6 +760,8 @@
|
|||||||
"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",
|
||||||
@@ -1665,6 +1667,11 @@
|
|||||||
"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",
|
||||||
@@ -2918,7 +2925,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 Sperrgebiete nicht verfügbar",
|
"Restricted airspace source unavailable": "Quelle für Sperr- und Flugbeschränkungsgebiete 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).",
|
||||||
@@ -2955,13 +2962,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": "Minimale Strahlungsleistung des Brandes in Megawatt",
|
"Minimum fire radiative power, megawatts": "Mindestwert der 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",
|
||||||
@@ -3001,9 +3008,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 ein Wahrzeichen",
|
"Area around landmark": "Gebiet um einen Ort",
|
||||||
"Outline {place}": "{place} umreißen",
|
"Outline {place}": "Umriss von {place} zeichnen",
|
||||||
"Outline area": "Gebiet umreißen",
|
"Outline area": "Umriss zeichnen",
|
||||||
"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",
|
||||||
@@ -3014,27 +3021,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 in Größe des Ortes",
|
"buffer sized to the place": "Puffer entsprechend der Ortsgröße",
|
||||||
"enclosing mapped area": "umschließendes kartiertes Gebiet",
|
"enclosing mapped area": "umschließendes kartiertes Gebiet",
|
||||||
"the map was cleared while looking it up": "die Karte wurde während der Suche geleert",
|
"the map was cleared while looking it up": "die Zeichenfläche 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 das Wahrzeichen",
|
"{radius} m around the landmark": "{radius} m um den Ort",
|
||||||
"{count} parts": {
|
"{count} parts": {
|
||||||
"one": "Teile: {count}",
|
"one": "{count} Teil",
|
||||||
"other": "Teile: {count}"
|
"other": "{count} Teile"
|
||||||
},
|
},
|
||||||
"{drawn} of {total} parts drawn": {
|
"{drawn} of {total} parts drawn": {
|
||||||
"one": "Gezeichnete Teile: {drawn} von {total}",
|
"one": "{drawn} von {total} Teil gezeichnet",
|
||||||
"other": "Gezeichnete Teile: {drawn} von {total}"
|
"other": "{drawn} von {total} Teilen gezeichnet"
|
||||||
},
|
},
|
||||||
"{count} duplicate mappings merged": {
|
"{count} duplicate mappings merged": {
|
||||||
"one": "Zusammengeführte Doppelkartierungen: {count}",
|
"one": "{count} Duplikat zusammengeführt",
|
||||||
"other": "Zusammengeführte Doppelkartierungen: {count}"
|
"other": "{count} Duplikate zusammengeführt"
|
||||||
},
|
},
|
||||||
"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": "über das umgebende Rechteck gezählt ({area}), nicht über den Umriss",
|
"counted over the bounding box of {area}, not its outline": "im umgebenden Rechteck von {area} gezählt, nicht innerhalb des Umrisses",
|
||||||
"Search cancelled": "Suche abgebrochen",
|
"Search cancelled": "Suche abgebrochen",
|
||||||
"Sea Marks": "Seezeichen",
|
"Sea Marks": "Seezeichen",
|
||||||
"Ski Pistes": "Skipisten",
|
"Ski Pistes": "Skipisten",
|
||||||
@@ -3166,5 +3173,16 @@
|
|||||||
"Open {name} on Acartia": "{name} auf Acartia öffnen",
|
"Open {name} on Acartia": "{name} auf Acartia öffnen",
|
||||||
"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",
|
||||||
|
"All markers": "Alle Markierungen",
|
||||||
|
"Show markers of every age": "Markierungen jeden Alters anzeigen",
|
||||||
|
"Maximum event age: {days}d; undated markers are kept": "Maximales Ereignisalter: {days} Tage; undatierte Markierungen bleiben",
|
||||||
|
"event age filter|All": "Alle",
|
||||||
|
"Event age": "Ereignisalter",
|
||||||
|
"Event age: {days}d · undated kept": "Ereignisalter: {days} Tage · undatierte bleiben",
|
||||||
|
"Close camera preview": "Kameravorschau schließen",
|
||||||
|
"Refresh map still": "Kartenstandbild aktualisieren",
|
||||||
|
"The map still uses a separate request and may show a different moment.": "Das Kartenstandbild wird separat abgerufen und kann einen anderen Moment zeigen.",
|
||||||
|
"Video plays from Québec 511, which sees your IP address.": "Das Video wird von Québec 511 abgespielt, das Ihre IP-Adresse sieht."
|
||||||
}
|
}
|
||||||
+33
-15
@@ -192,6 +192,8 @@
|
|||||||
"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",
|
||||||
@@ -2697,6 +2699,11 @@
|
|||||||
"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",
|
||||||
@@ -2992,7 +2999,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 tienen confianza baja",
|
"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",
|
||||||
"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)",
|
||||||
@@ -3001,8 +3008,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": "{shown} de {total} mostrados",
|
"{shown} of {total} shown": "Mostrando {shown} de {total}",
|
||||||
"{lower} to {upper}": "{lower} a {upper}",
|
"{lower} to {upper}": "De {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",
|
||||||
@@ -3010,9 +3017,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, 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).",
|
"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).",
|
||||||
"Prohibited": "Prohibido",
|
"Prohibited": "Zona prohibida",
|
||||||
"Restricted": "Restringido",
|
"Restricted": "Zona restringida",
|
||||||
"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",
|
||||||
@@ -3050,11 +3057,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}?": "¿Cuál {place}?",
|
"Which {place}?": "¿Qué {place}?",
|
||||||
"Which place?": "¿Qué lugar?",
|
"Which place?": "¿Qué lugar?",
|
||||||
"Not drawn: {parts}": "Sin dibujar: {parts}",
|
"Not drawn: {parts}": "Sin dibujar: {parts}",
|
||||||
"approximate": "aproximado",
|
"approximate": "aproximada",
|
||||||
"{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}",
|
||||||
@@ -3068,7 +3075,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}: unos {count}",
|
"About {count} {kind}": "{kind}: aproximadamente {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}",
|
||||||
@@ -3087,12 +3094,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 cartografiada que lo rodea",
|
"enclosing mapped area": "zona mapeada que contiene el punto de interés",
|
||||||
"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 lugar",
|
"{radius} m around the landmark": "{radius} m alrededor del punto de interés",
|
||||||
"{count} parts": {
|
"{count} parts": {
|
||||||
"many": "Partes: {count}",
|
"many": "Partes: {count}",
|
||||||
"one": "Partes: {count}",
|
"one": "Partes: {count}",
|
||||||
@@ -3109,7 +3116,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": "contado en el rectángulo que lo rodea ({area}), no en su contorno",
|
"counted over the bounding box of {area}, not its outline": "se contó en el rectángulo que abarca {area}, no dentro de 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í",
|
||||||
@@ -3241,5 +3248,16 @@
|
|||||||
"Open {name} on Acartia": "Abrir {name} en Acartia",
|
"Open {name} on Acartia": "Abrir {name} en Acartia",
|
||||||
"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",
|
||||||
|
"All markers": "Todos los marcadores",
|
||||||
|
"Show markers of every age": "Mostrar marcadores de cualquier antigüedad",
|
||||||
|
"Maximum event age: {days}d; undated markers are kept": "Antigüedad máxima: {days} días; se conservan los marcadores sin fecha",
|
||||||
|
"event age filter|All": "Todos",
|
||||||
|
"Event age": "Antigüedad",
|
||||||
|
"Event age: {days}d · undated kept": "Antigüedad: {days} días · se conservan los marcadores sin fecha",
|
||||||
|
"Close camera preview": "Cerrar vista previa de la cámara",
|
||||||
|
"Refresh map still": "Actualizar imagen fija del mapa",
|
||||||
|
"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.",
|
||||||
|
"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."
|
||||||
}
|
}
|
||||||
+46
-28
@@ -1103,6 +1103,11 @@
|
|||||||
"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é",
|
||||||
@@ -2805,6 +2810,8 @@
|
|||||||
"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",
|
||||||
@@ -2983,18 +2990,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}": "{lower} à {upper}",
|
"{lower} to {upper}": "De {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 informatif uniquement · ne pas utiliser pour la planification de vol",
|
"Informational only · not for flight planning": "À titre indicatif · ne pas utiliser pour la préparation des vols",
|
||||||
"Unnamed zone": "Zone sans nom",
|
"Unnamed 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 informatif uniquement — ne remplace pas les outils officiels de planification de vol des 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 indicatif uniquement — ne remplace pas les outils officiels de préparation des vols de drones (B4UFLY / LAANC).",
|
||||||
"Prohibited": "Zone interdite",
|
"Prohibited": "Zone interdite",
|
||||||
"Restricted": "Zone réglementée",
|
"Restricted": "Zone réglementée",
|
||||||
"Prohibited & Restricted Airspace": "Zones interdites et réglementées",
|
"Prohibited & Restricted Airspace": "Espaces aériens interdits et réglementés",
|
||||||
"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",
|
||||||
@@ -3025,7 +3032,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 — beaucoup des feux les plus intenses ont une confiance faible",
|
"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",
|
||||||
"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)",
|
||||||
@@ -3034,7 +3041,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": "{shown} sur {total} affichées",
|
"{shown} of {total} shown": "Affichés : {shown} sur {total}",
|
||||||
"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}",
|
||||||
@@ -3044,7 +3051,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 de lieux par la voix (nécessite un serveur Overpass)",
|
"Voice place search (needs an Overpass server)": "Recherche vocale de lieux (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",
|
||||||
@@ -3052,9 +3059,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}": "Non dessiné : {parts}",
|
"Not drawn: {parts}": "Contour non tracé : {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}",
|
||||||
@@ -3073,43 +3080,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 repère",
|
"Area around landmark": "Environs d'un lieu",
|
||||||
"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 à la taille du lieu",
|
"buffer sized to the place": "zone tampon adaptée à 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}": "les {limit} premiers sont listés",
|
"listed the first {limit}": "liste limitée à {limit}",
|
||||||
"counted over the bounding box of {area}, not its outline": "compté sur le rectangle englobant ({area}), pas sur le contour",
|
"counted over the bounding box of {area}, not its outline": "compté dans le rectangle englobant ({area}), pas dans son contour exact",
|
||||||
"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",
|
||||||
@@ -3241,5 +3248,16 @@
|
|||||||
"Open {name} on Acartia": "Ouvrir {name} sur Acartia",
|
"Open {name} on Acartia": "Ouvrir {name} sur Acartia",
|
||||||
"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",
|
||||||
|
"All markers": "Tous les marqueurs",
|
||||||
|
"Show markers of every age": "Afficher les marqueurs de tout âge",
|
||||||
|
"Maximum event age: {days}d; undated markers are kept": "Ancienneté maximale : {days} j ; marqueurs sans date conservés",
|
||||||
|
"event age filter|All": "Tous",
|
||||||
|
"Event age": "Ancienneté",
|
||||||
|
"Event age: {days}d · undated kept": "Ancienneté : {days} j · marqueurs sans date conservés",
|
||||||
|
"Close camera preview": "Fermer l’aperçu de la caméra",
|
||||||
|
"Refresh map still": "Actualiser l’image fixe sur la carte",
|
||||||
|
"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.",
|
||||||
|
"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."
|
||||||
}
|
}
|
||||||
+39
-21
@@ -1103,6 +1103,11 @@
|
|||||||
"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",
|
||||||
@@ -2805,6 +2810,8 @@
|
|||||||
"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",
|
||||||
@@ -2983,18 +2990,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": "Zona senza nome",
|
"Unnamed zone": "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 proibita (P)",
|
"Prohibited (P)": "Zona vietata (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 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).",
|
"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).",
|
||||||
"Prohibited": "Zona proibita",
|
"Prohibited": "Zona vietata",
|
||||||
"Restricted": "Zona regolamentata",
|
"Restricted": "Zona regolamentata",
|
||||||
"Prohibited & Restricted Airspace": "Zone proibite e regolamentate",
|
"Prohibited & Restricted Airspace": "Spazio aereo vietato e regolamentato",
|
||||||
"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",
|
||||||
@@ -3025,7 +3032,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)",
|
||||||
@@ -3034,7 +3041,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": "{shown} di {total} mostrati",
|
"{shown} of {total} shown": "Mostrati: {shown} su {total}",
|
||||||
"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}",
|
||||||
@@ -3052,10 +3059,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}": "Non disegnati: {parts}",
|
"Not drawn: {parts}": "Contorno non disegnato: {parts}",
|
||||||
"approximate": "approssimativo",
|
"approximate": "approssimata",
|
||||||
"{pct}% cloud": "{pct}% di nuvole",
|
"{pct}% cloud": "{pct}% di nuvole",
|
||||||
"Over {area}": "Zona: {area}",
|
"Over {area}": "Area: {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}",
|
||||||
@@ -3074,8 +3081,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 contorno di {place}",
|
"Outline {place}": "Traccia il contorno di {place}",
|
||||||
"Outline area": "Traccia contorno area",
|
"Outline area": "Traccia il contorno dell'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",
|
||||||
@@ -3083,16 +3090,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 immagini",
|
"Loading imagery": "Caricamento delle 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 circostante",
|
"enclosing mapped area": "area mappata che contiene il punto di interesse",
|
||||||
"the map was cleared while looking it up": "la mappa è stata cancellata durante la ricerca",
|
"the map was cleared while looking it up": "la mappa è stata ripulita 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 attorno al luogo",
|
"{radius} m around the landmark": "{radius} m intorno al punto di interesse",
|
||||||
"{count} parts": {
|
"{count} parts": {
|
||||||
"many": "Parti: {count}",
|
"many": "Parti: {count}",
|
||||||
"one": "Parti: {count}",
|
"one": "Parti: {count}",
|
||||||
@@ -3108,8 +3115,8 @@
|
|||||||
"one": "Duplicati uniti: {count}",
|
"one": "Duplicati uniti: {count}",
|
||||||
"other": "Duplicati uniti: {count}"
|
"other": "Duplicati uniti: {count}"
|
||||||
},
|
},
|
||||||
"listed the first {limit}": "elencati i primi {limit}",
|
"listed the first {limit}": "elenco limitato ai primi risultati: {limit}",
|
||||||
"counted over the bounding box of {area}, not its outline": "contato sul riquadro di delimitazione ({area}), non sul contorno",
|
"counted over the bounding box of {area}, not its outline": "conteggio nel riquadro di delimitazione di {area}, non entro il suo 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",
|
||||||
@@ -3241,5 +3248,16 @@
|
|||||||
"Open {name} on Acartia": "Apri {name} su Acartia",
|
"Open {name} on Acartia": "Apri {name} su Acartia",
|
||||||
"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",
|
||||||
|
"All markers": "Tutti gli indicatori",
|
||||||
|
"Show markers of every age": "Mostra gli indicatori di qualsiasi età",
|
||||||
|
"Maximum event age: {days}d; undated markers are kept": "Età massima degli eventi: {days} giorni; indicatori senza data mantenuti",
|
||||||
|
"event age filter|All": "Tutti",
|
||||||
|
"Event age": "Età degli eventi",
|
||||||
|
"Event age: {days}d · undated kept": "Età degli eventi: {days} giorni · senza data mantenuti",
|
||||||
|
"Close camera preview": "Chiudi anteprima della telecamera",
|
||||||
|
"Refresh map still": "Aggiorna immagine fissa sulla mappa",
|
||||||
|
"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.",
|
||||||
|
"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."
|
||||||
}
|
}
|
||||||
+53
-35
@@ -1071,6 +1071,11 @@
|
|||||||
"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": "今月の交通情報の上限に達しました",
|
||||||
@@ -2663,6 +2668,8 @@
|
|||||||
"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": "局タグ",
|
||||||
@@ -2843,18 +2850,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": "シーンを作成・編集",
|
||||||
@@ -2885,10 +2892,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": "火災放射強度の最小値(メガワット)",
|
||||||
@@ -2906,16 +2913,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}件"
|
||||||
},
|
},
|
||||||
@@ -2924,45 +2931,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": "地球全体ビューに戻して",
|
||||||
@@ -3091,5 +3098,16 @@
|
|||||||
"Open {name} on Acartia": "Acartiaで{name}を開く",
|
"Open {name} on Acartia": "Acartiaで{name}を開く",
|
||||||
"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}日",
|
||||||
|
"All markers": "すべてのマーカー",
|
||||||
|
"Show markers of every age": "日付にかかわらずすべてのマーカーを表示",
|
||||||
|
"Maximum event age: {days}d; undated markers are kept": "イベントの最大経過日数:{days}日。日付のないマーカーは保持",
|
||||||
|
"event age filter|All": "すべて",
|
||||||
|
"Event age": "経過日数",
|
||||||
|
"Event age: {days}d · undated kept": "経過日数:{days}日以内 · 日付なしは保持",
|
||||||
|
"Close camera preview": "カメラのプレビューを閉じる",
|
||||||
|
"Refresh map still": "地図上の静止画を更新",
|
||||||
|
"The map still uses a separate request and may show a different moment.": "地図上の静止画は別のリクエストで取得されるため、異なる時点を表示する場合があります。",
|
||||||
|
"Video plays from Québec 511, which sees your IP address.": "動画は Québec 511 から再生され、Québec 511 にはあなたの IP アドレスが見えます。"
|
||||||
}
|
}
|
||||||
+41
-23
@@ -1101,6 +1101,11 @@
|
|||||||
"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",
|
||||||
@@ -2807,6 +2812,8 @@
|
|||||||
"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",
|
||||||
@@ -2987,14 +2994,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": "Zona sem nome",
|
"Unnamed zone": "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).": "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).",
|
"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).",
|
||||||
"Prohibited": "Área proibida",
|
"Prohibited": "Área proibida",
|
||||||
"Restricted": "Área restrita",
|
"Restricted": "Área restrita",
|
||||||
"Prohibited & Restricted Airspace": "Áreas proibidas e restritas",
|
"Prohibited & Restricted Airspace": "Espaço aéreo proibido e restrito",
|
||||||
"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",
|
||||||
@@ -3034,7 +3041,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": "{shown} de {total} exibidas",
|
"{shown} of {total} shown": "Detecções exibidas: {shown} de {total}",
|
||||||
"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}",
|
||||||
@@ -3053,18 +3060,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": "aproximado",
|
"approximate": "aproximada",
|
||||||
"{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": {
|
||||||
"many": "Aquisições correspondentes: {count} de {total}",
|
"one": "Imagens correspondentes: {count} de {total}",
|
||||||
"one": "Aquisições correspondentes: {count} de {total}",
|
"many": "Imagens correspondentes: {count} de {total}",
|
||||||
"other": "Aquisições correspondentes: {count} de {total}"
|
"other": "Imagens correspondentes: {count} de {total}"
|
||||||
},
|
},
|
||||||
"{n} acquisitions searched": {
|
"{n} acquisitions searched": {
|
||||||
"many": "Aquisições pesquisadas: {n}",
|
"one": "Imagens pesquisadas: {n}",
|
||||||
"one": "Aquisições pesquisadas: {n}",
|
"many": "Imagens pesquisadas: {n}",
|
||||||
"other": "Aquisições pesquisadas: {n}"
|
"other": "Imagens 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",
|
||||||
@@ -3076,23 +3083,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}": "Encontrar {place}",
|
"Find {place}": "Buscar {place}",
|
||||||
"Find area": "Encontrar área",
|
"Find area": "Buscar área",
|
||||||
"Find recent imagery": "Encontrar imagens recentes",
|
"Find recent imagery": "Buscar imagens recentes",
|
||||||
"Find {what}": "Encontrar {what}",
|
"Find {what}": "Buscar {what}",
|
||||||
"Find places": "Encontrar lugares",
|
"Find places": "Buscar 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": "zona de margem do tamanho do local",
|
"buffer sized to the place": "círculo do tamanho aproximado do lugar",
|
||||||
"enclosing mapped area": "área mapeada envolvente",
|
"enclosing mapped area": "área mapeada que contém o ponto de referência",
|
||||||
"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 local",
|
"{radius} m around the landmark": "{radius} m ao redor do ponto de referência",
|
||||||
"{count} parts": {
|
"{count} parts": {
|
||||||
"many": "Partes: {count}",
|
"many": "Partes: {count}",
|
||||||
"one": "Partes: {count}",
|
"one": "Partes: {count}",
|
||||||
@@ -3108,10 +3115,10 @@
|
|||||||
"one": "Duplicatas mescladas: {count}",
|
"one": "Duplicatas mescladas: {count}",
|
||||||
"other": "Duplicatas mescladas: {count}"
|
"other": "Duplicatas mescladas: {count}"
|
||||||
},
|
},
|
||||||
"listed the first {limit}": "listados os primeiros {limit}",
|
"listed the first {limit}": "lista limitada a {limit}",
|
||||||
"counted over the bounding box of {area}, not its outline": "contado na caixa delimitadora ({area}), não no contorno",
|
"counted over the bounding box of {area}, not its outline": "contagem na caixa delimitadora de {area}, não no contorno",
|
||||||
"Search cancelled": "Busca cancelada",
|
"Search cancelled": "Busca cancelada",
|
||||||
"Sea Marks": "Sinais marítimos",
|
"Sea Marks": "Sinalização náutica",
|
||||||
"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",
|
||||||
@@ -3241,5 +3248,16 @@
|
|||||||
"Open {name} on Acartia": "Abrir {name} no Acartia",
|
"Open {name} on Acartia": "Abrir {name} no Acartia",
|
||||||
"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",
|
||||||
|
"All markers": "Todos os marcadores",
|
||||||
|
"Show markers of every age": "Mostrar marcadores de qualquer idade",
|
||||||
|
"Maximum event age: {days}d; undated markers are kept": "Idade máxima do evento: {days} dias; marcadores sem data mantidos",
|
||||||
|
"event age filter|All": "Todos",
|
||||||
|
"Event age": "Idade do evento",
|
||||||
|
"Event age: {days}d · undated kept": "Idade do evento: {days} dias · sem data mantidos",
|
||||||
|
"Close camera preview": "Fechar prévia da câmera",
|
||||||
|
"Refresh map still": "Atualizar imagem fixa no mapa",
|
||||||
|
"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.",
|
||||||
|
"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."
|
||||||
}
|
}
|
||||||
+39
-21
@@ -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,6 +722,8 @@
|
|||||||
"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": "ТЕГ СТАНЦИИ",
|
||||||
@@ -2755,6 +2757,11 @@
|
|||||||
"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 сопоставляется, где он есть",
|
||||||
@@ -3062,27 +3069,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": "ошибка прокси маршрутов",
|
||||||
@@ -3116,13 +3123,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}",
|
||||||
@@ -3143,7 +3150,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}",
|
||||||
@@ -3159,12 +3166,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}",
|
||||||
@@ -3183,11 +3190,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": "Вернись к виду всего глобуса",
|
||||||
@@ -3316,5 +3323,16 @@
|
|||||||
"Open {name} on Acartia": "Открыть {name} в Acartia",
|
"Open {name} on Acartia": "Открыть {name} в Acartia",
|
||||||
"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}д",
|
||||||
|
"All markers": "Все метки",
|
||||||
|
"Show markers of every age": "Показать метки любого возраста",
|
||||||
|
"Maximum event age: {days}d; undated markers are kept": "Максимальный возраст события: {days} дн.; метки без даты сохранены",
|
||||||
|
"event age filter|All": "Все",
|
||||||
|
"Event age": "Возраст события",
|
||||||
|
"Event age: {days}d · undated kept": "Возраст события: {days} дн. · метки без даты сохранены",
|
||||||
|
"Close camera preview": "Закрыть просмотр камеры",
|
||||||
|
"Refresh map still": "Обновить кадр на карте",
|
||||||
|
"The map still uses a separate request and may show a different moment.": "Кадр на карте запрашивается отдельно и может показывать другой момент.",
|
||||||
|
"Video plays from Québec 511, which sees your IP address.": "Видео воспроизводится с Québec 511, который видит ваш IP-адрес."
|
||||||
}
|
}
|
||||||
+40
-22
@@ -1118,6 +1118,11 @@
|
|||||||
"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": "Місячний ліміт даних про трафік вичерпано",
|
||||||
@@ -2875,6 +2880,8 @@
|
|||||||
"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": "ТЕГ СТАНЦІЇ",
|
||||||
@@ -3063,19 +3070,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": "СТАРТ",
|
||||||
@@ -3116,7 +3123,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": "природний регіон",
|
||||||
@@ -3147,9 +3154,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": "Знайти місця",
|
||||||
@@ -3157,14 +3164,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}",
|
||||||
@@ -3178,16 +3185,16 @@
|
|||||||
"other": "Намальовано частин: {drawn} з {total}"
|
"other": "Намальовано частин: {drawn} з {total}"
|
||||||
},
|
},
|
||||||
"{count} duplicate mappings merged": {
|
"{count} duplicate mappings merged": {
|
||||||
"few": "Об’єднано дублікатів: {count}",
|
"one": "Обʼєднано дублікатів: {count}",
|
||||||
"many": "Об’єднано дублікатів: {count}",
|
"few": "Обʼєднано дублікатів: {count}",
|
||||||
"one": "Об’єднано дублікатів: {count}",
|
"many": "Обʼєднано дублікатів: {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": "Поверни вигляд глобуса",
|
||||||
@@ -3316,5 +3323,16 @@
|
|||||||
"Open {name} on Acartia": "Відкрити {name} в Acartia",
|
"Open {name} on Acartia": "Відкрити {name} в Acartia",
|
||||||
"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}д",
|
||||||
|
"All markers": "Усі позначки",
|
||||||
|
"Show markers of every age": "Показати позначки будь-якого віку",
|
||||||
|
"Maximum event age: {days}d; undated markers are kept": "Максимальний вік події: {days} дн.; позначки без дати збережено",
|
||||||
|
"event age filter|All": "Усі",
|
||||||
|
"Event age": "Вік події",
|
||||||
|
"Event age: {days}d · undated kept": "Вік події: {days} дн. · позначки без дати збережено",
|
||||||
|
"Close camera preview": "Закрити перегляд камери",
|
||||||
|
"Refresh map still": "Оновити кадр на карті",
|
||||||
|
"The map still uses a separate request and may show a different moment.": "Кадр на карті запитується окремо та може показувати інший момент.",
|
||||||
|
"Video plays from Québec 511, which sees your IP address.": "Відео відтворюється з Québec 511, який бачить вашу IP-адресу."
|
||||||
}
|
}
|
||||||
+39
-21
@@ -1047,6 +1047,11 @@
|
|||||||
"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": "本月交通流量额度已用完",
|
||||||
@@ -2562,6 +2567,8 @@
|
|||||||
"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": "电台标签",
|
||||||
@@ -2850,26 +2857,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": "限制区",
|
||||||
@@ -2915,7 +2922,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}"
|
||||||
},
|
},
|
||||||
@@ -2930,36 +2937,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} 米",
|
"{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": "滑雪道",
|
||||||
@@ -3091,5 +3098,16 @@
|
|||||||
"Open {name} on Acartia": "在 Acartia 上打开 {name}",
|
"Open {name} on Acartia": "在 Acartia 上打开 {name}",
|
||||||
"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}天",
|
||||||
|
"All markers": "所有标记",
|
||||||
|
"Show markers of every age": "显示所有时间的标记",
|
||||||
|
"Maximum event age: {days}d; undated markers are kept": "事件最长保留时间:{days}天;保留无日期标记",
|
||||||
|
"event age filter|All": "全部",
|
||||||
|
"Event age": "事件时间",
|
||||||
|
"Event age: {days}d · undated kept": "事件时间:{days}天内 · 保留无日期标记",
|
||||||
|
"Close camera preview": "关闭摄像头预览",
|
||||||
|
"Refresh map still": "刷新地图静止画面",
|
||||||
|
"The map still uses a separate request and may show a different moment.": "地图静止画面通过单独的请求获取,可能显示不同的时刻。",
|
||||||
|
"Video plays from Québec 511, which sees your IP address.": "视频从 Québec 511 播放,Québec 511 会看到你的 IP 地址。"
|
||||||
}
|
}
|
||||||
@@ -1042,6 +1042,11 @@
|
|||||||
"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": "本月交通流量額度已用完",
|
||||||
@@ -2550,6 +2555,8 @@
|
|||||||
"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": "電臺標籤",
|
||||||
@@ -2853,16 +2860,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": "場景",
|
||||||
@@ -2889,17 +2896,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": "請先退出座艙,再飛往繪製位置",
|
||||||
@@ -2915,7 +2922,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}"
|
||||||
},
|
},
|
||||||
@@ -2930,36 +2937,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} 米",
|
"{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": "滑雪道",
|
||||||
@@ -3091,5 +3098,16 @@
|
|||||||
"Open {name} on Acartia": "在 Acartia 上打開 {name}",
|
"Open {name} on Acartia": "在 Acartia 上打開 {name}",
|
||||||
"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}天",
|
||||||
|
"All markers": "所有標記",
|
||||||
|
"Show markers of every age": "顯示所有時間的標記",
|
||||||
|
"Maximum event age: {days}d; undated markers are kept": "事件最長保留時間:{days}天;保留無日期標記",
|
||||||
|
"event age filter|All": "全部",
|
||||||
|
"Event age": "事件時間",
|
||||||
|
"Event age: {days}d · undated kept": "事件時間:{days}天內 · 保留無日期標記",
|
||||||
|
"Close camera preview": "關閉攝影機預覽",
|
||||||
|
"Refresh map still": "重新整理地圖靜態畫面",
|
||||||
|
"The map still uses a separate request and may show a different moment.": "地圖靜態畫面使用獨立請求,顯示的時間可能不同。",
|
||||||
|
"Video plays from Québec 511, which sees your IP address.": "影片從 Québec 511 播放,Québec 511 會看到你的 IP 位址。"
|
||||||
}
|
}
|
||||||
@@ -132,9 +132,11 @@ export function createCards({ state: layerState, services, parts, source }) {
|
|||||||
record.camera.lon,
|
record.camera.lon,
|
||||||
),
|
),
|
||||||
inView,
|
inView,
|
||||||
isVideo: parts.model.isVideoFeedType(
|
// Québec's browser-only sources have no registered frame endpoint;
|
||||||
parts.model.normalizeFeedType(record.camera.feedType),
|
// keep them out of still-card fill just like video feeds.
|
||||||
),
|
isVideo:
|
||||||
|
parts.model.isVideoFeedCamera(record.camera) ||
|
||||||
|
parts.model.isBrowserOverlayCamera(record.camera),
|
||||||
sx,
|
sx,
|
||||||
sy,
|
sy,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -237,9 +237,14 @@ 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'
|
||||||
|
|||||||
@@ -255,11 +255,20 @@ 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 refreshMs =
|
const defaultRefreshMs =
|
||||||
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;
|
||||||
if (!force && now - runtime.lastImageRefreshAt < refreshMs) return;
|
const sourceRefreshMs = parts.model.safeNumber(
|
||||||
|
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);
|
||||||
|
|||||||
@@ -49,9 +49,8 @@ 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.isVideoFeedType(
|
!parts.model.isBrowserOverlayCamera(record.camera) &&
|
||||||
parts.model.normalizeFeedType(record.camera.feedType),
|
!parts.model.isVideoFeedCamera(record.camera);
|
||||||
);
|
|
||||||
if (eligible) {
|
if (eligible) {
|
||||||
cancelHoverRelease();
|
cancelHoverRelease();
|
||||||
layerState._hoverCardId = cameraId;
|
layerState._hoverCardId = cameraId;
|
||||||
|
|||||||
@@ -112,6 +112,8 @@ 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,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -151,6 +151,7 @@ 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();
|
||||||
@@ -299,11 +300,8 @@ export function createLifecycle({
|
|||||||
}
|
}
|
||||||
|
|
||||||
layerState._count = layerState._records.length;
|
layerState._count = layerState._records.length;
|
||||||
if (layerState._records.length > 0) {
|
// No camera is picked here: enable() opens on the one nearest the
|
||||||
// Projection runtime + first frame fetch are deferred to enable() so
|
// viewer, unless restored params selected one first.
|
||||||
// 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
|
||||||
@@ -361,6 +359,7 @@ 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(
|
||||||
@@ -508,8 +507,9 @@ 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 = layerState._records[0].camera.id;
|
layerState._activeCameraId = parts.navigation.openingCameraId();
|
||||||
layerState._autoHopSuspended = false;
|
layerState._autoHopSuspended = false;
|
||||||
|
layerState._openingCameraProvisional = true;
|
||||||
}
|
}
|
||||||
const activeRecord = parts.selection.getActiveRecord();
|
const activeRecord = parts.selection.getActiveRecord();
|
||||||
if (activeRecord) {
|
if (activeRecord) {
|
||||||
@@ -629,6 +629,7 @@ 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();
|
||||||
|
|||||||
@@ -96,6 +96,19 @@ 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.
|
||||||
*
|
*
|
||||||
@@ -447,7 +460,12 @@ 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;
|
||||||
if (layerState._showProjection && parts.selection.getActiveRecord())
|
const active = parts.selection.getActiveRecord();
|
||||||
|
if (
|
||||||
|
layerState._showProjection &&
|
||||||
|
active &&
|
||||||
|
!isBrowserOverlayCamera(active.camera)
|
||||||
|
)
|
||||||
return true;
|
return true;
|
||||||
return focusPassIsNeeded(
|
return focusPassIsNeeded(
|
||||||
getFocusTarget(),
|
getFocusTarget(),
|
||||||
@@ -553,6 +571,8 @@ export function createModel({ state: layerState, services, parts, source }) {
|
|||||||
angularDeltaAbs,
|
angularDeltaAbs,
|
||||||
normalizeFeedType,
|
normalizeFeedType,
|
||||||
isVideoFeedType,
|
isVideoFeedType,
|
||||||
|
isVideoFeedCamera,
|
||||||
|
isBrowserOverlayCamera,
|
||||||
safeNumber,
|
safeNumber,
|
||||||
headingFromId,
|
headingFromId,
|
||||||
quantize,
|
quantize,
|
||||||
|
|||||||
@@ -33,6 +33,36 @@ 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.
|
||||||
@@ -149,6 +179,8 @@ export function createNavigation({
|
|||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
nearestCameraIdToViewer,
|
nearestCameraIdToViewer,
|
||||||
|
openingCameraId,
|
||||||
|
reopenNearestCamera,
|
||||||
focusCctvRecord,
|
focusCctvRecord,
|
||||||
focusCamera,
|
focusCamera,
|
||||||
maybeAutoHop,
|
maybeAutoHop,
|
||||||
|
|||||||
@@ -160,9 +160,14 @@ 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 refreshMs = isActive
|
const defaultRefreshMs = 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,
|
||||||
@@ -192,13 +197,18 @@ 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.isVideoFeedType(camera.feedType) &&
|
parts.model.isVideoFeedCamera(camera) &&
|
||||||
record.projection?.mode !== 'image',
|
record.projection?.mode !== 'image',
|
||||||
sourceKind:
|
sourceKind: camera.sourceKind?.startsWith('quebec-')
|
||||||
health?.sourceKind ||
|
? camera.sourceKind
|
||||||
camera.sourceKind ||
|
: health?.sourceKind ||
|
||||||
(camera.feedConfigured ? 'configured' : 'seed'),
|
camera.sourceKind ||
|
||||||
|
(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 || '',
|
||||||
@@ -276,6 +286,7 @@ 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(),
|
||||||
|
|||||||
@@ -171,6 +171,9 @@ 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;
|
||||||
|
|||||||
@@ -35,8 +35,7 @@ export function createRendering({
|
|||||||
function cameraColorFor(record) {
|
function cameraColorFor(record) {
|
||||||
if (record.camera.id === layerState._activeCameraId)
|
if (record.camera.id === layerState._activeCameraId)
|
||||||
return ACTIVE_CAMERA_COLOR;
|
return ACTIVE_CAMERA_COLOR;
|
||||||
if (parts.model.isVideoFeedType(record.camera.feedType))
|
if (parts.model.isVideoFeedCamera(record.camera)) return LIVE_CAMERA_COLOR;
|
||||||
return LIVE_CAMERA_COLOR;
|
|
||||||
return IDLE_CAMERA_COLOR;
|
return IDLE_CAMERA_COLOR;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -405,8 +404,9 @@ export function createRendering({
|
|||||||
if (
|
if (
|
||||||
layerState._enabled &&
|
layerState._enabled &&
|
||||||
isActive &&
|
isActive &&
|
||||||
|
!parts.model.isBrowserOverlayCamera(record.camera) &&
|
||||||
(layerState._showProjection ||
|
(layerState._showProjection ||
|
||||||
parts.model.isVideoFeedType(record.camera.feedType))
|
parts.model.isVideoFeedCamera(record.camera))
|
||||||
) {
|
) {
|
||||||
parts.projection.ensureProjectionRuntime(record);
|
parts.projection.ensureProjectionRuntime(record);
|
||||||
}
|
}
|
||||||
@@ -416,7 +416,8 @@ export function createRendering({
|
|||||||
const planeShowing = !!(
|
const planeShowing = !!(
|
||||||
layerState._enabled &&
|
layerState._enabled &&
|
||||||
layerState._showProjection &&
|
layerState._showProjection &&
|
||||||
isActive
|
isActive &&
|
||||||
|
!parts.model.isBrowserOverlayCamera(record.camera)
|
||||||
);
|
);
|
||||||
|
|
||||||
const inVisibleSet = coverageVisible.has(record.camera.id);
|
const inVisibleSet = coverageVisible.has(record.camera.id);
|
||||||
|
|||||||
@@ -56,7 +56,12 @@ test('focus de-emphasis fades each icon from its own color', () => {
|
|||||||
const { refreshCctvFocusStyles } = createRendering({
|
const { refreshCctvFocusStyles } = createRendering({
|
||||||
state,
|
state,
|
||||||
services: { focus },
|
services: { focus },
|
||||||
parts: { model: { isVideoFeedType } },
|
parts: {
|
||||||
|
model: {
|
||||||
|
isVideoFeedCamera: (camera) =>
|
||||||
|
isVideoFeedType(camera.feedType) || Boolean(camera.browserMediaUrl),
|
||||||
|
},
|
||||||
|
},
|
||||||
source: {},
|
source: {},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -79,10 +79,17 @@ export function createSelection({
|
|||||||
record,
|
record,
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
|
// Browser-only feeds have a separate player; a reselect retries it
|
||||||
|
// without re-running activation or touching the projection geometry.
|
||||||
|
if (parts.model.isBrowserOverlayCamera(record.camera)) {
|
||||||
|
layerState._activeCameraReselection += 1;
|
||||||
|
parts.presentation.notifyListeners();
|
||||||
|
}
|
||||||
return CCTV_ACTIVATION_RESULT.UNCHANGED;
|
return CCTV_ACTIVATION_RESULT.UNCHANGED;
|
||||||
}
|
}
|
||||||
layerState._activeCameraId = cameraId;
|
layerState._activeCameraId = cameraId;
|
||||||
layerState._autoHopSuspended = false;
|
layerState._autoHopSuspended = false;
|
||||||
|
layerState._openingCameraProvisional = false;
|
||||||
// A real activation creates projection work — wake the self-stopping loop.
|
// A real activation creates projection work — wake the self-stopping loop.
|
||||||
parts.projection.startProjectionLoop();
|
parts.projection.startProjectionLoop();
|
||||||
if (previousActiveRecord && previousActiveRecord !== record) {
|
if (previousActiveRecord && previousActiveRecord !== record) {
|
||||||
@@ -169,6 +176,7 @@ export function createSelection({
|
|||||||
if (!record) return false;
|
if (!record) return false;
|
||||||
layerState._activeCameraId = null;
|
layerState._activeCameraId = null;
|
||||||
layerState._autoHopSuspended = true;
|
layerState._autoHopSuspended = true;
|
||||||
|
layerState._openingCameraProvisional = false;
|
||||||
record.activationDone = false;
|
record.activationDone = false;
|
||||||
parts.geometry.clearProbeClampOnDeactivation(record, (previous) => {
|
parts.geometry.clearProbeClampOnDeactivation(record, (previous) => {
|
||||||
parts.geometry.applyFrustumGeometry(
|
parts.geometry.applyFrustumGeometry(
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { CCTV_ACTIVATION_RESULT } from '../../cctvFocusRequest.js';
|
||||||
|
import { createSelection } from './selection.js';
|
||||||
|
|
||||||
|
function makeSelection(sourceKind) {
|
||||||
|
const camera = { id: 'same-camera', sourceKind };
|
||||||
|
const record = { camera };
|
||||||
|
const state = {
|
||||||
|
_activeCameraId: camera.id,
|
||||||
|
_activeCameraReselection: 0,
|
||||||
|
_recordById: new Map([[camera.id, record]]),
|
||||||
|
};
|
||||||
|
let notifications = 0;
|
||||||
|
const selection = createSelection({
|
||||||
|
state,
|
||||||
|
services: {
|
||||||
|
activation: { CCTV_ACTIVATION_RESULT },
|
||||||
|
picking: { resolvePickId: () => null },
|
||||||
|
},
|
||||||
|
parts: {
|
||||||
|
model: {
|
||||||
|
cctvRecordNeedsActivation: () => false,
|
||||||
|
isBrowserOverlayCamera: (value) =>
|
||||||
|
String(value?.sourceKind || '').startsWith('quebec-'),
|
||||||
|
},
|
||||||
|
presentation: { notifyListeners: () => notifications++ },
|
||||||
|
},
|
||||||
|
source: {},
|
||||||
|
});
|
||||||
|
return { selection, state, notifications: () => notifications };
|
||||||
|
}
|
||||||
|
|
||||||
|
test('same-camera selection does not notify or mutate snapshot-feed state', () => {
|
||||||
|
const fixture = makeSelection('tripcheck-odot');
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
fixture.selection.setActiveCamera('same-camera'),
|
||||||
|
CCTV_ACTIVATION_RESULT.UNCHANGED,
|
||||||
|
);
|
||||||
|
assert.equal(fixture.state._activeCameraReselection, 0);
|
||||||
|
assert.equal(fixture.notifications(), 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('same-camera selection notifies browser-overlay feeds so playback can retry', () => {
|
||||||
|
const fixture = makeSelection('quebec-511-browser');
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
fixture.selection.setActiveCamera('same-camera'),
|
||||||
|
CCTV_ACTIVATION_RESULT.UNCHANGED,
|
||||||
|
);
|
||||||
|
assert.equal(fixture.state._activeCameraReselection, 1);
|
||||||
|
assert.equal(fixture.notifications(), 1);
|
||||||
|
});
|
||||||
@@ -29,6 +29,10 @@ export function createState({ services }) {
|
|||||||
|
|
||||||
layerState._activeCameraId = null;
|
layerState._activeCameraId = null;
|
||||||
|
|
||||||
|
// Advances when a user reselects the already-active camera. The geometry
|
||||||
|
// activation remains a no-op, but browser-only camera UI can retry playback.
|
||||||
|
layerState._activeCameraReselection = 0;
|
||||||
|
|
||||||
layerState._coverageMode = 'on';
|
layerState._coverageMode = 'on';
|
||||||
// 'off' | 'on' (wireframes) | 'viewshed' (color-coded volumes)
|
// 'off' | 'on' (wireframes) | 'viewshed' (color-coded volumes)
|
||||||
|
|
||||||
@@ -36,6 +40,12 @@ export function createState({ services }) {
|
|||||||
|
|
||||||
layerState._autoHop = false;
|
layerState._autoHop = false;
|
||||||
|
|
||||||
|
// True while the active camera is the one enable() opened on and nobody has
|
||||||
|
// chosen a camera since: each settled move then re-picks the one nearest
|
||||||
|
// the view, so a share flight that lands after enable still opens nearby.
|
||||||
|
|
||||||
|
layerState._openingCameraProvisional = false;
|
||||||
|
|
||||||
// An explicit empty-space deselect keeps AUTO HOP configured but prevents its
|
// An explicit empty-space deselect keeps AUTO HOP configured but prevents its
|
||||||
// timer from silently choosing a replacement. A later explicit activation or
|
// timer from silently choosing a replacement. A later explicit activation or
|
||||||
// AUTO HOP toggle-on releases the hold.
|
// AUTO HOP toggle-on releases the hold.
|
||||||
|
|||||||
@@ -91,6 +91,7 @@ export function createTesting({ state: layerState, services, parts, source }) {
|
|||||||
coverageMode = 'on',
|
coverageMode = 'on',
|
||||||
showProjection = false,
|
showProjection = false,
|
||||||
healthById = new Map(),
|
healthById = new Map(),
|
||||||
|
openingCameraProvisional = false,
|
||||||
} = {}) {
|
} = {}) {
|
||||||
layerState._viewer = viewer;
|
layerState._viewer = viewer;
|
||||||
layerState._records = Array.isArray(records) ? records : [];
|
layerState._records = Array.isArray(records) ? records : [];
|
||||||
@@ -104,6 +105,7 @@ export function createTesting({ state: layerState, services, parts, source }) {
|
|||||||
layerState._billboards = null;
|
layerState._billboards = null;
|
||||||
layerState._activeCameraId = activeCameraId;
|
layerState._activeCameraId = activeCameraId;
|
||||||
layerState._autoHopSuspended = false;
|
layerState._autoHopSuspended = false;
|
||||||
|
layerState._openingCameraProvisional = !!openingCameraProvisional;
|
||||||
layerState._enabled = !!enabled;
|
layerState._enabled = !!enabled;
|
||||||
layerState._coverageMode = parts.model.normalizeCoverageMode(
|
layerState._coverageMode = parts.model.normalizeCoverageMode(
|
||||||
coverageMode,
|
coverageMode,
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ export function createCyclonesLayer({
|
|||||||
feed,
|
feed,
|
||||||
hitTestOverlay = hitTestWorldOverlay,
|
hitTestOverlay = hitTestWorldOverlay,
|
||||||
overlayHost,
|
overlayHost,
|
||||||
|
context = null,
|
||||||
cesium = Cesium,
|
cesium = Cesium,
|
||||||
createRendering = createCycloneRendering,
|
createRendering = createCycloneRendering,
|
||||||
matchMedia = globalThis.matchMedia?.bind(globalThis),
|
matchMedia = globalThis.matchMedia?.bind(globalThis),
|
||||||
@@ -82,6 +83,7 @@ export function createCyclonesLayer({
|
|||||||
selectedId = null,
|
selectedId = null,
|
||||||
selectionIntent = 'auto',
|
selectionIntent = 'auto',
|
||||||
navigationGeneration = 0,
|
navigationGeneration = 0,
|
||||||
|
selectedContextStormId = null,
|
||||||
clickHandler = null,
|
clickHandler = null,
|
||||||
removeClickCapture = null;
|
removeClickCapture = null;
|
||||||
let enabled = false,
|
let enabled = false,
|
||||||
@@ -92,6 +94,62 @@ export function createCyclonesLayer({
|
|||||||
const notify = () => listener?.();
|
const notify = () => listener?.();
|
||||||
const selected = () =>
|
const selected = () =>
|
||||||
snapshot?.storms.find((storm) => storm.id === selectedId) || null;
|
snapshot?.storms.find((storm) => storm.id === selectedId) || null;
|
||||||
|
function clearSelectedContext({ evicted = false } = {}) {
|
||||||
|
if (selectedContextStormId) {
|
||||||
|
if (!evicted)
|
||||||
|
context.clearSelectedEntityContextForLayer('weather-cyclones');
|
||||||
|
context.removeEntityContextsForLayer('weather-cyclones');
|
||||||
|
}
|
||||||
|
selectedContextStormId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Callers publish only while selectedId names a storm in the snapshot.
|
||||||
|
function publishSelectedContext({ announce = false } = {}) {
|
||||||
|
const storm = selected();
|
||||||
|
if (!context) return;
|
||||||
|
const entity = {};
|
||||||
|
const contextId = `cyclone:${storm.id}`;
|
||||||
|
context.registerEntityContext(entity, {
|
||||||
|
id: contextId,
|
||||||
|
layerId: 'weather-cyclones',
|
||||||
|
layerName: 'Cyclone advisories',
|
||||||
|
source: 'NOAA NHC / CPHC',
|
||||||
|
label: storm.name,
|
||||||
|
latitude: storm.position.latitude,
|
||||||
|
longitude: storm.position.longitude,
|
||||||
|
// At most 12 properties reach the voice summary; keep the list short.
|
||||||
|
properties: {
|
||||||
|
classification: storm.classification,
|
||||||
|
classificationName: Object.hasOwn(
|
||||||
|
CLASSIFICATION_NAMES,
|
||||||
|
storm.classification,
|
||||||
|
)
|
||||||
|
? CLASSIFICATION_NAMES[storm.classification]
|
||||||
|
: undefined,
|
||||||
|
basin: storm.basin,
|
||||||
|
advisoryNumber: storm.advisoryNumber,
|
||||||
|
issuedAt: storm.issuedAt,
|
||||||
|
positionAt: storm.positionAt,
|
||||||
|
windKt: storm.windKt,
|
||||||
|
pressureHpa: storm.pressureHpa,
|
||||||
|
// Flat scalars: the voice context summary drops nested objects.
|
||||||
|
movementDirectionDegrees: storm.movement?.directionDegrees,
|
||||||
|
movementSpeedKt: storm.movement?.speedKt,
|
||||||
|
advisoryUrl: storm.advisoryUrl,
|
||||||
|
// Only while the proxy serves its cache because NHC is unreachable.
|
||||||
|
staleCacheFetchedAt:
|
||||||
|
snapshot.stale && Number.isFinite(snapshot.fetchedAt)
|
||||||
|
? new Date(snapshot.fetchedAt).toISOString()
|
||||||
|
: undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
selectedContextStormId = storm.id;
|
||||||
|
if (announce) context.selectEntityContext(entity);
|
||||||
|
context.removeEntityContextsForLayer('weather-cyclones', {
|
||||||
|
retainIds: new Set([contextId]),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function select(id) {
|
function select(id) {
|
||||||
if (selectedId !== id) ++navigationGeneration;
|
if (selectedId !== id) ++navigationGeneration;
|
||||||
selectedId = id;
|
selectedId = id;
|
||||||
@@ -236,6 +294,7 @@ export function createCyclonesLayer({
|
|||||||
selectedId = null;
|
selectedId = null;
|
||||||
selectionIntent = 'auto';
|
selectionIntent = 'auto';
|
||||||
++navigationGeneration;
|
++navigationGeneration;
|
||||||
|
clearSelectedContext();
|
||||||
rendering?.clear();
|
rendering?.clear();
|
||||||
},
|
},
|
||||||
async update(_viewer, { signal } = {}) {
|
async update(_viewer, { signal } = {}) {
|
||||||
@@ -259,6 +318,7 @@ export function createCyclonesLayer({
|
|||||||
snapshot = next;
|
snapshot = next;
|
||||||
selectedId = null;
|
selectedId = null;
|
||||||
++navigationGeneration;
|
++navigationGeneration;
|
||||||
|
clearSelectedContext({ evicted: true });
|
||||||
error = next.reason || ADVISORIES_UNAVAILABLE;
|
error = next.reason || ADVISORIES_UNAVAILABLE;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -281,6 +341,10 @@ export function createCyclonesLayer({
|
|||||||
: snapshot.storms[0]?.id || null,
|
: snapshot.storms[0]?.id || null,
|
||||||
);
|
);
|
||||||
else rendering.setSelection(selectedId);
|
else rendering.setSelection(selectedId);
|
||||||
|
if (selectedContextStormId) {
|
||||||
|
if (selectedId === selectedContextStormId) publishSelectedContext();
|
||||||
|
else clearSelectedContext({ evicted: true });
|
||||||
|
}
|
||||||
return true;
|
return true;
|
||||||
} catch (cause) {
|
} catch (cause) {
|
||||||
if (controller.signal.aborted || request !== controller) return false;
|
if (controller.signal.aborted || request !== controller) return false;
|
||||||
@@ -290,6 +354,7 @@ export function createCyclonesLayer({
|
|||||||
snapshot = null;
|
snapshot = null;
|
||||||
selectedId = null;
|
selectedId = null;
|
||||||
++navigationGeneration;
|
++navigationGeneration;
|
||||||
|
clearSelectedContext({ evicted: true });
|
||||||
return true;
|
return true;
|
||||||
} finally {
|
} finally {
|
||||||
signal?.removeEventListener('abort', abort);
|
signal?.removeEventListener('abort', abort);
|
||||||
@@ -305,6 +370,7 @@ export function createCyclonesLayer({
|
|||||||
if (params.clear === true || params.stormId === null) {
|
if (params.clear === true || params.stormId === null) {
|
||||||
selectionIntent = 'cleared';
|
selectionIntent = 'cleared';
|
||||||
select(null);
|
select(null);
|
||||||
|
clearSelectedContext();
|
||||||
notify();
|
notify();
|
||||||
} else if (
|
} else if (
|
||||||
typeof params.stormId === 'string' &&
|
typeof params.stormId === 'string' &&
|
||||||
@@ -312,6 +378,7 @@ export function createCyclonesLayer({
|
|||||||
) {
|
) {
|
||||||
selectionIntent = 'user';
|
selectionIntent = 'user';
|
||||||
select(params.stormId);
|
select(params.stormId);
|
||||||
|
publishSelectedContext({ announce: true });
|
||||||
notify();
|
notify();
|
||||||
}
|
}
|
||||||
const storm = selected();
|
const storm = selected();
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ import {
|
|||||||
isPointerFree,
|
isPointerFree,
|
||||||
} from '../../data/inputOwnership.js';
|
} from '../../data/inputOwnership.js';
|
||||||
import { isOwnedByOtherLayer } from '../../data/pickRegistry.js';
|
import { isOwnedByOtherLayer } from '../../data/pickRegistry.js';
|
||||||
|
import * as contextStore from '../../data/contextStore.js';
|
||||||
|
import { createGevActionRunner } from '../../voice/gevActions.js';
|
||||||
|
import * as Cesium from 'cesium';
|
||||||
|
|
||||||
const time = '2026-09-16T03:00:00.000Z';
|
const time = '2026-09-16T03:00:00.000Z';
|
||||||
const storm = (id = 'ep152026') => ({
|
const storm = (id = 'ep152026') => ({
|
||||||
@@ -338,7 +341,7 @@ test('a click on a storm card or lead-hour label selects that storm without pick
|
|||||||
});
|
});
|
||||||
function harness(
|
function harness(
|
||||||
feed = { getSnapshot: async () => snapshot() },
|
feed = { getSnapshot: async () => snapshot() },
|
||||||
{ reducedMotion = false } = {},
|
{ reducedMotion = false, context = null } = {},
|
||||||
) {
|
) {
|
||||||
const applied = [],
|
const applied = [],
|
||||||
navigation = [],
|
navigation = [],
|
||||||
@@ -368,6 +371,7 @@ function harness(
|
|||||||
};
|
};
|
||||||
const layer = createCyclonesLayer({
|
const layer = createCyclonesLayer({
|
||||||
feed,
|
feed,
|
||||||
|
context,
|
||||||
createRendering: () => rendering,
|
createRendering: () => rendering,
|
||||||
matchMedia: () => ({ matches: reducedMotion }),
|
matchMedia: () => ({ matches: reducedMotion }),
|
||||||
openLink: (url) => opened.push(url),
|
openLink: (url) => opened.push(url),
|
||||||
@@ -401,6 +405,256 @@ function harness(
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
// Real shared-context store on a stand-in window; the voice runner reads it.
|
||||||
|
async function withSharedContext(t, body) {
|
||||||
|
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, 'window');
|
||||||
|
const host = new EventTarget();
|
||||||
|
Object.defineProperty(globalThis, 'window', {
|
||||||
|
configurable: true,
|
||||||
|
value: host,
|
||||||
|
});
|
||||||
|
const layers = [];
|
||||||
|
t.after(() => {
|
||||||
|
try {
|
||||||
|
for (const layer of layers) layer.destroy();
|
||||||
|
} finally {
|
||||||
|
if (previousWindow)
|
||||||
|
Object.defineProperty(globalThis, 'window', previousWindow);
|
||||||
|
else delete globalThis.window;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const events = [];
|
||||||
|
host.addEventListener('gev:entity-selected', ({ detail }) =>
|
||||||
|
events.push(['selected', detail.id]),
|
||||||
|
);
|
||||||
|
host.addEventListener('gev:entity-selection-cleared', ({ detail }) =>
|
||||||
|
events.push(['cleared', detail.layerId, detail.reason]),
|
||||||
|
);
|
||||||
|
// The voice runner bounds its basemap wait with the window timers.
|
||||||
|
Object.assign(host, { setTimeout, clearTimeout });
|
||||||
|
const position = Cesium.Cartesian3.fromDegrees(-125.8, 15.5, 5_000_000);
|
||||||
|
const runner = createGevActionRunner({
|
||||||
|
viewer: {
|
||||||
|
clock: { onTick: { addEventListener: () => () => {} } },
|
||||||
|
scene: { canvas: { addEventListener() {}, removeEventListener() {} } },
|
||||||
|
camera: {
|
||||||
|
moveEnd: { addEventListener() {} },
|
||||||
|
positionWC: position,
|
||||||
|
positionCartographic: Cesium.Cartographic.fromCartesian(position),
|
||||||
|
heading: 0,
|
||||||
|
pitch: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
styleManager: {},
|
||||||
|
dataManager: {
|
||||||
|
layers: new Map(),
|
||||||
|
isEnabled: (id) => id === 'weather-cyclones',
|
||||||
|
getAll: () => [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const selectedProperties = async () => {
|
||||||
|
const result = await runner('get_entity_context', { scope: 'selected' });
|
||||||
|
assert.equal(result.scope, 'selected');
|
||||||
|
return result.selected.properties;
|
||||||
|
};
|
||||||
|
const store = contextStore.getContextStore();
|
||||||
|
const open = (feed) => {
|
||||||
|
const h = harness(feed, { context: contextStore });
|
||||||
|
layers.push(h.layer);
|
||||||
|
return h;
|
||||||
|
};
|
||||||
|
await body({ events, selectedProperties, store, open });
|
||||||
|
}
|
||||||
|
|
||||||
|
test('shared cyclone context distinguishes switches, deliberate clears and feed eviction', async (t) => {
|
||||||
|
await withSharedContext(
|
||||||
|
t,
|
||||||
|
async ({ events, selectedProperties, store, open }) => {
|
||||||
|
let next = snapshot([
|
||||||
|
{ ...storm(), movement: { directionDegrees: 315, speedKt: 12 } },
|
||||||
|
{
|
||||||
|
...storm('ep162026'),
|
||||||
|
movement: { directionDegrees: 0, speedKt: 10 },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const h = open({ getSnapshot: async () => next });
|
||||||
|
h.layer.enable();
|
||||||
|
await h.layer.update();
|
||||||
|
assert.deepEqual(
|
||||||
|
events,
|
||||||
|
[],
|
||||||
|
'the automatic first-storm row selection must not claim shared context',
|
||||||
|
);
|
||||||
|
assert.equal(store.entities.size, 0);
|
||||||
|
|
||||||
|
h.layer.setParams({ stormId: 'ep152026' });
|
||||||
|
assert.equal(store.entities.get('cyclone:ep152026').label, 'Fifteen-E');
|
||||||
|
let properties = await selectedProperties();
|
||||||
|
assert.equal(properties.classification, 'PTC');
|
||||||
|
assert.equal(properties.classificationName, 'Post-tropical cyclone');
|
||||||
|
assert.equal(properties.advisoryNumber, '10');
|
||||||
|
assert.equal(
|
||||||
|
properties.movementDirectionDegrees,
|
||||||
|
'315',
|
||||||
|
'selected storm movement should survive voice summarization',
|
||||||
|
);
|
||||||
|
assert.equal(properties.movementSpeedKt, '12');
|
||||||
|
assert.equal(
|
||||||
|
properties.advisoryUrl,
|
||||||
|
'https://www.nhc.noaa.gov/text/MIATCMEP5.shtml',
|
||||||
|
);
|
||||||
|
assert.equal(properties.staleCacheFetchedAt, undefined);
|
||||||
|
|
||||||
|
h.layer.setParams({ stormId: 'ep162026' });
|
||||||
|
properties = await selectedProperties();
|
||||||
|
assert.equal(
|
||||||
|
properties.movementDirectionDegrees,
|
||||||
|
'0',
|
||||||
|
'a due-north heading is a value, not a missing one',
|
||||||
|
);
|
||||||
|
assert.equal(properties.movementSpeedKt, '10');
|
||||||
|
assert.deepEqual(events, [
|
||||||
|
['selected', 'cyclone:ep152026'],
|
||||||
|
['selected', 'cyclone:ep162026'],
|
||||||
|
]);
|
||||||
|
assert.deepEqual([...store.entities.keys()], ['cyclone:ep162026']);
|
||||||
|
|
||||||
|
next = snapshot([
|
||||||
|
{ ...storm(), movement: { directionDegrees: 315, speedKt: 12 } },
|
||||||
|
{ ...storm('ep162026'), movement: { directionDegrees: 0, speedKt: 0 } },
|
||||||
|
]);
|
||||||
|
await h.layer.update();
|
||||||
|
assert.equal(events.length, 2, 'refresh must not announce selection');
|
||||||
|
properties = await selectedProperties();
|
||||||
|
assert.equal(
|
||||||
|
properties.movementSpeedKt,
|
||||||
|
'0',
|
||||||
|
'stationary is a value, refreshed into the selected record',
|
||||||
|
);
|
||||||
|
|
||||||
|
h.layer.setParams({ clear: true });
|
||||||
|
assert.deepEqual(events.at(-1), [
|
||||||
|
'cleared',
|
||||||
|
'weather-cyclones',
|
||||||
|
'deliberate',
|
||||||
|
]);
|
||||||
|
assert.equal(store.entities.size, 0);
|
||||||
|
|
||||||
|
h.layer.setParams({ stormId: 'ep162026' });
|
||||||
|
next = snapshot([storm()]);
|
||||||
|
await h.layer.update();
|
||||||
|
assert.deepEqual(
|
||||||
|
events.at(-1),
|
||||||
|
['cleared', 'weather-cyclones', 'evicted'],
|
||||||
|
'a vanished user-selected storm must clear instead of transferring context to the fallback row',
|
||||||
|
);
|
||||||
|
assert.equal(store.entities.size, 0);
|
||||||
|
assert.equal(store.selectedEntityId, null);
|
||||||
|
|
||||||
|
h.layer.setParams({ stormId: 'ep152026' });
|
||||||
|
h.layer.disable();
|
||||||
|
assert.deepEqual(events.at(-1), [
|
||||||
|
'cleared',
|
||||||
|
'weather-cyclones',
|
||||||
|
'deliberate',
|
||||||
|
]);
|
||||||
|
assert.equal(store.entities.size, 0);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shared cyclone context is evicted when the source fails and marks a stale cache', async (t) => {
|
||||||
|
await withSharedContext(
|
||||||
|
t,
|
||||||
|
async ({ events, selectedProperties, store, open }) => {
|
||||||
|
let next = snapshot([{ ...storm(), classification: 'XX' }]);
|
||||||
|
let fail = false;
|
||||||
|
const h = open({
|
||||||
|
getSnapshot: async () => {
|
||||||
|
if (fail) throw new Error('Network unavailable');
|
||||||
|
return next;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
h.layer.enable();
|
||||||
|
await h.layer.update();
|
||||||
|
h.layer.setParams({ stormId: 'ep152026' });
|
||||||
|
let properties = await selectedProperties();
|
||||||
|
assert.equal(properties.classification, 'XX');
|
||||||
|
assert.equal(
|
||||||
|
properties.classificationName,
|
||||||
|
undefined,
|
||||||
|
'an unknown code is not given a guessed meaning',
|
||||||
|
);
|
||||||
|
|
||||||
|
next = { ...snapshot(), stale: true };
|
||||||
|
await h.layer.update();
|
||||||
|
properties = await selectedProperties();
|
||||||
|
assert.equal(properties.staleCacheFetchedAt, time);
|
||||||
|
assert.equal(events.length, 1, 'refresh must not announce selection');
|
||||||
|
|
||||||
|
next = {
|
||||||
|
...snapshot([]),
|
||||||
|
unavailable: true,
|
||||||
|
reason: 'Cyclone data unavailable',
|
||||||
|
};
|
||||||
|
await h.layer.update();
|
||||||
|
assert.deepEqual(events.at(-1), [
|
||||||
|
'cleared',
|
||||||
|
'weather-cyclones',
|
||||||
|
'evicted',
|
||||||
|
]);
|
||||||
|
assert.equal(store.entities.size, 0);
|
||||||
|
|
||||||
|
next = snapshot();
|
||||||
|
await h.layer.update();
|
||||||
|
h.layer.setParams({ stormId: 'ep152026' });
|
||||||
|
assert.equal(store.selectedEntityId, 'cyclone:ep152026');
|
||||||
|
fail = true;
|
||||||
|
await h.layer.update();
|
||||||
|
assert.deepEqual(events.at(-1), [
|
||||||
|
'cleared',
|
||||||
|
'weather-cyclones',
|
||||||
|
'evicted',
|
||||||
|
]);
|
||||||
|
assert.equal(store.entities.size, 0);
|
||||||
|
assert.equal(store.selectedEntityId, null);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('cyclone clears leave another layer selection and its records alone', async (t) => {
|
||||||
|
await withSharedContext(t, async ({ events, store, open }) => {
|
||||||
|
const h = open({ getSnapshot: async () => snapshot() });
|
||||||
|
h.layer.enable();
|
||||||
|
await h.layer.update();
|
||||||
|
h.layer.setParams({ stormId: 'ep152026' });
|
||||||
|
const foreign = {};
|
||||||
|
contextStore.registerEntityContext(foreign, {
|
||||||
|
id: 'flight:abc123',
|
||||||
|
layerId: 'flights',
|
||||||
|
label: 'Foreign',
|
||||||
|
});
|
||||||
|
contextStore.selectEntityContext(foreign);
|
||||||
|
const before = events.length;
|
||||||
|
|
||||||
|
h.layer.setParams({ clear: true });
|
||||||
|
assert.equal(store.selectedEntityId, 'flight:abc123');
|
||||||
|
assert.ok(store.entities.has('flight:abc123'));
|
||||||
|
assert.equal(store.entities.has('cyclone:ep152026'), false);
|
||||||
|
assert.equal(
|
||||||
|
events.length,
|
||||||
|
before,
|
||||||
|
'no clear is reported for a foreign selection',
|
||||||
|
);
|
||||||
|
|
||||||
|
// A cyclone clear does not report a dangling foreign selection as evicted.
|
||||||
|
store.entities.delete('flight:abc123');
|
||||||
|
h.layer.disable();
|
||||||
|
assert.equal(store.selectedEntityId, 'flight:abc123');
|
||||||
|
assert.equal(events.length, before);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test('refresh preserves selection intent, including explicit clears and missing storms', async () => {
|
test('refresh preserves selection intent, including explicit clears and missing storms', async () => {
|
||||||
let next = snapshot([storm(), storm('ep162026')]);
|
let next = snapshot([storm(), storm('ep162026')]);
|
||||||
const h = harness({ getSnapshot: async () => next });
|
const h = harness({ getSnapshot: async () => next });
|
||||||
|
|||||||
@@ -0,0 +1,163 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import * as Cesium from 'cesium';
|
||||||
|
import { createFlightState } from './state.js';
|
||||||
|
import { createRendering } from './rendering.js';
|
||||||
|
import {
|
||||||
|
FLEET_DR_INTERVAL_MS,
|
||||||
|
HIDDEN_CONTACT_DR_INTERVAL_MS,
|
||||||
|
HIDDEN_CONTACT_DR_TICKS,
|
||||||
|
hiddenContactDrPhase,
|
||||||
|
} from './policy.js';
|
||||||
|
|
||||||
|
// Contacts hidden behind the horizon re-reckon on one fleet tick per rotation
|
||||||
|
// instead of every tick. Their positions still feed hidden-inclusive proximity
|
||||||
|
// queries, so the rotation must reach every contact about once a second, and
|
||||||
|
// it must spread them across ticks so no single tick carries the whole fleet.
|
||||||
|
|
||||||
|
const icao = (n) => n.toString(16).padStart(6, '0');
|
||||||
|
|
||||||
|
test('the rotation revisits each hidden contact about once a second', () => {
|
||||||
|
const periodMs = HIDDEN_CONTACT_DR_TICKS * FLEET_DR_INTERVAL_MS;
|
||||||
|
assert.ok(HIDDEN_CONTACT_DR_TICKS > 1);
|
||||||
|
assert.ok(
|
||||||
|
Math.abs(periodMs - HIDDEN_CONTACT_DR_INTERVAL_MS) <= FLEET_DR_INTERVAL_MS,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a contact keeps the same slot, inside the rotation', () => {
|
||||||
|
for (let n = 0; n < 500; n++) {
|
||||||
|
const phase = hiddenContactDrPhase(icao(n * 7919));
|
||||||
|
assert.ok(Number.isInteger(phase));
|
||||||
|
assert.ok(phase >= 0 && phase < HIDDEN_CONTACT_DR_TICKS);
|
||||||
|
assert.equal(hiddenContactDrPhase(icao(n * 7919)), phase);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a worldwide fleet is spread evenly enough that no tick carries a burst', () => {
|
||||||
|
const fleet = 13000;
|
||||||
|
const perSlot = new Array(HIDDEN_CONTACT_DR_TICKS).fill(0);
|
||||||
|
// Real ICAO24 addresses cluster by national block; sample several blocks.
|
||||||
|
for (let n = 0; n < fleet; n++)
|
||||||
|
perSlot[hiddenContactDrPhase(icao(0x0a0000 + n * 53))]++;
|
||||||
|
const fair = fleet / HIDDEN_CONTACT_DR_TICKS;
|
||||||
|
for (const count of perSlot) assert.ok(count < fair * 1.5, `${count} > 1.5×`);
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Real fleet tick and horizon culling, with motion and presentation injected. */
|
||||||
|
function fleetFixture() {
|
||||||
|
let now = 1000;
|
||||||
|
let reckonings = 0;
|
||||||
|
const services = {
|
||||||
|
groundSnap: { createGroundSnap: () => ({ clear() {} }) },
|
||||||
|
militaryRegistry: { isMilitaryIcao: () => false },
|
||||||
|
aircraftPresentation: { isTr3b: () => false },
|
||||||
|
camera: {},
|
||||||
|
focus: {
|
||||||
|
focusNowMs: () => now,
|
||||||
|
getFocusTarget: () => null,
|
||||||
|
nearFarScalarValueAtDistance: () => 1,
|
||||||
|
advanceProjectedSpriteFocus: () => ({ factor: 1 }),
|
||||||
|
},
|
||||||
|
recession: {
|
||||||
|
applyAircraftBillboardTreatment: () => ({
|
||||||
|
factors: { scale: 1 },
|
||||||
|
alpha: 1,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const flightState = createFlightState({ services });
|
||||||
|
const camera = {
|
||||||
|
positionWC: Cesium.Cartesian3.fromDegrees(0, 0, 100),
|
||||||
|
positionCartographic: { height: 100 },
|
||||||
|
heading: 0,
|
||||||
|
pitch: 0,
|
||||||
|
roll: 0,
|
||||||
|
frustum: {
|
||||||
|
computeCullingVolume: () => ({
|
||||||
|
computeVisibility: () => Cesium.Intersect.INSIDE,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const id = 'abc123';
|
||||||
|
// 111 km away at ground level is beyond a 100 m camera's horizon, but
|
||||||
|
// inside the model keep radius. No WebGL scene is needed for the culler.
|
||||||
|
const billboard = {
|
||||||
|
show: false,
|
||||||
|
position: Cesium.Cartesian3.fromDegrees(1, 0, 0),
|
||||||
|
};
|
||||||
|
flightState._viewer = { camera, scene: {} };
|
||||||
|
flightState._billboardCollection = { show: true };
|
||||||
|
flightState._billboards.set(id, billboard);
|
||||||
|
flightState.records.data.set(id, {});
|
||||||
|
flightState._models3dEnabled = false;
|
||||||
|
// Far cockpit contacts avoid glyph rasterization, keeping the fixture DOM-free.
|
||||||
|
flightState._cockpitContactMode = true;
|
||||||
|
const parts = {
|
||||||
|
tracking: { _refreshCockpitNearContacts() {} },
|
||||||
|
motion: {
|
||||||
|
_deadReckon() {
|
||||||
|
reckonings++;
|
||||||
|
return billboard.position;
|
||||||
|
},
|
||||||
|
_floorGroundedDisplayPosition(id, info, position) {
|
||||||
|
return position;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const rendering = createRendering({ flightState, services, parts });
|
||||||
|
return {
|
||||||
|
flightState,
|
||||||
|
camera,
|
||||||
|
billboard,
|
||||||
|
id,
|
||||||
|
reckonings: () => reckonings,
|
||||||
|
tick() {
|
||||||
|
now += FLEET_DR_INTERVAL_MS;
|
||||||
|
rendering._fleetTick();
|
||||||
|
},
|
||||||
|
offTurn() {
|
||||||
|
flightState._fleetTickSeq =
|
||||||
|
(hiddenContactDrPhase(id) + 1) % HIDDEN_CONTACT_DR_TICKS;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('the fleet tick dead-reckons a hidden contact once per complete rotation', () => {
|
||||||
|
const f = fleetFixture();
|
||||||
|
for (let i = 0; i < HIDDEN_CONTACT_DR_TICKS; i++) f.tick();
|
||||||
|
assert.equal(f.reckonings(), 1);
|
||||||
|
assert.equal(f.billboard.show, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a camera reveal bypasses the hidden rotation on the same fleet tick', () => {
|
||||||
|
const f = fleetFixture();
|
||||||
|
f.offTurn();
|
||||||
|
f.tick();
|
||||||
|
assert.equal(f.reckonings(), 0);
|
||||||
|
f.camera.positionWC = Cesium.Cartesian3.fromDegrees(1, 0, 100);
|
||||||
|
f.offTurn();
|
||||||
|
f.tick();
|
||||||
|
assert.equal(f.reckonings(), 1);
|
||||||
|
assert.equal(f.billboard.show, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a hidden contact with a retained fleet model is never rotation-skipped', () => {
|
||||||
|
const f = fleetFixture();
|
||||||
|
f.flightState._models3dEnabled = true;
|
||||||
|
const model = { ready: true, show: true };
|
||||||
|
f.flightState._models.set(f.id, model);
|
||||||
|
f.offTurn();
|
||||||
|
f.tick();
|
||||||
|
assert.equal(f.reckonings(), 1);
|
||||||
|
assert.equal(
|
||||||
|
f.flightState._models.get(f.id),
|
||||||
|
model,
|
||||||
|
'model remains in keep range',
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
model.show,
|
||||||
|
false,
|
||||||
|
'the full pass hides its model beyond the horizon',
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -198,6 +198,37 @@ export const FLEET_DR_INTERVAL_MS = 80;
|
|||||||
|
|
||||||
export const ROTATION_REFRESH_MS = 1000;
|
export const ROTATION_REFRESH_MS = 1000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @constant {number} Dead-reckoning cadence (ms) for contacts hidden behind the
|
||||||
|
* horizon. The worldwide feed carries ~13k contacts and a zoomed-in view shows
|
||||||
|
* well under 1% of them; the rest still need positions for hidden-inclusive
|
||||||
|
* proximity queries, but ~1 s old (≈250 m for an airliner) is well inside the
|
||||||
|
* 30 s poll's own uncertainty. Each hidden contact is re-reckoned on one tick in
|
||||||
|
* every HIDDEN_CONTACT_DR_TICKS, staggered by id so no tick carries them all.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const HIDDEN_CONTACT_DR_INTERVAL_MS = 1000;
|
||||||
|
|
||||||
|
export const HIDDEN_CONTACT_DR_TICKS = Math.max(
|
||||||
|
1,
|
||||||
|
Math.round(HIDDEN_CONTACT_DR_INTERVAL_MS / FLEET_DR_INTERVAL_MS),
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stable slot in [0, HIDDEN_CONTACT_DR_TICKS) for a contact id, so each hidden
|
||||||
|
* contact re-reckons on the same tick of every rotation.
|
||||||
|
* @param {string} id Contact id (ICAO24).
|
||||||
|
* @returns {number}
|
||||||
|
*/
|
||||||
|
|
||||||
|
export function hiddenContactDrPhase(id) {
|
||||||
|
const text = String(id);
|
||||||
|
let hash = 0;
|
||||||
|
for (let i = 0; i < text.length; i++)
|
||||||
|
hash = (hash * 31 + text.charCodeAt(i)) >>> 0;
|
||||||
|
return hash % HIDDEN_CONTACT_DR_TICKS;
|
||||||
|
}
|
||||||
|
|
||||||
/** Max course slew (deg/s) — well above real turns (≤4°/s), hides fix-boundary
|
/** Max course slew (deg/s) — well above real turns (≤4°/s), hides fix-boundary
|
||||||
* steps. Scaled down toward COURSE_MIN_DPS at low speed (courseSlewCapDps). */
|
* steps. Scaled down toward COURSE_MIN_DPS at low speed (courseSlewCapDps). */
|
||||||
|
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ import {
|
|||||||
COURSE_SLEW_DT_MAX_SEC,
|
COURSE_SLEW_DT_MAX_SEC,
|
||||||
ROTATION_REFRESH_MS,
|
ROTATION_REFRESH_MS,
|
||||||
COURSE_MAX_DPS,
|
COURSE_MAX_DPS,
|
||||||
|
HIDDEN_CONTACT_DR_TICKS,
|
||||||
|
hiddenContactDrPhase,
|
||||||
} from './policy.js';
|
} from './policy.js';
|
||||||
import { AIRCRAFT_ENVIRONMENT_MAP_OPTIONS } from '../../data/aircraftModelLighting.js';
|
import { AIRCRAFT_ENVIRONMENT_MAP_OPTIONS } from '../../data/aircraftModelLighting.js';
|
||||||
|
|
||||||
@@ -839,6 +841,12 @@ export function createRendering({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The contact's hidden-rotation slot, cached on its billboard. */
|
||||||
|
function _hiddenDrPhase(bb, icao24) {
|
||||||
|
bb._gevHiddenDrPhase ??= hiddenContactDrPhase(icao24);
|
||||||
|
return bb._gevHiddenDrPhase;
|
||||||
|
}
|
||||||
|
|
||||||
function _fleetTick() {
|
function _fleetTick() {
|
||||||
if (
|
if (
|
||||||
!flightState._viewer ||
|
!flightState._viewer ||
|
||||||
@@ -965,9 +973,25 @@ export function createRendering({
|
|||||||
for (const icao of toRelease) _releaseModel(icao);
|
for (const icao of toRelease) _releaseModel(icao);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const hiddenDrPhase = flightState._fleetTickSeq++ % HIDDEN_CONTACT_DR_TICKS;
|
||||||
|
|
||||||
for (const [icao24, bb] of flightState._billboards) {
|
for (const [icao24, bb] of flightState._billboards) {
|
||||||
if (icao24 === flightState._trackedIcao) continue; // tracked entity owns its own motion
|
if (icao24 === flightState._trackedIcao) continue; // tracked entity owns its own motion
|
||||||
|
|
||||||
|
// A contact still behind the horizon at its last position would only be
|
||||||
|
// re-reckoned to be hidden again, so off its turn it keeps that position
|
||||||
|
// (see HIDDEN_CONTACT_DR_TICKS). Anything the stale position puts in view,
|
||||||
|
// and any contact with a fleet model, takes the full pass below at once.
|
||||||
|
if (
|
||||||
|
!bb.show &&
|
||||||
|
_hiddenDrPhase(bb, icao24) !== hiddenDrPhase &&
|
||||||
|
!flightState._models.has(icao24) &&
|
||||||
|
!occluder.isPointVisible(
|
||||||
|
flightState._cullPositions.get(icao24) || bb.position,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
continue;
|
||||||
|
|
||||||
const info = flightState.records.data.get(icao24);
|
const info = flightState.records.data.get(icao24);
|
||||||
|
|
||||||
const dr = parts.motion._deadReckon(
|
const dr = parts.motion._deadReckon(
|
||||||
|
|||||||
@@ -204,6 +204,10 @@ export function createFlightState({ source, services }) {
|
|||||||
|
|
||||||
flightState._lastFleetTickMs = 0;
|
flightState._lastFleetTickMs = 0;
|
||||||
|
|
||||||
|
/** @type {number} Fleet ticks run; selects which hidden contacts re-reckon */
|
||||||
|
|
||||||
|
flightState._fleetTickSeq = 0;
|
||||||
|
|
||||||
/** @type {string} Camera pose signature at the last rotation pass */
|
/** @type {string} Camera pose signature at the last rotation pass */
|
||||||
|
|
||||||
flightState._lastCamPoseSig = '';
|
flightState._lastCamPoseSig = '';
|
||||||
|
|||||||
+190
-57
@@ -16,7 +16,15 @@ import {
|
|||||||
} from '../../i18n/index.js';
|
} from '../../i18n/index.js';
|
||||||
import { createHorizonCull } from '../streetLevel/view.js';
|
import { createHorizonCull } from '../streetLevel/view.js';
|
||||||
import { readGhostMapsKml } from './kml.js';
|
import { readGhostMapsKml } from './kml.js';
|
||||||
import { fieldLabel, parseDescriptionFields, wrapCardText } from './records.js';
|
import {
|
||||||
|
fieldLabel,
|
||||||
|
GHOSTMAPS_AGE_OPTIONS,
|
||||||
|
GHOSTMAPS_DEFAULT_AGE_DAYS,
|
||||||
|
ghostMapsEventDate,
|
||||||
|
isGhostMapsEventExpired,
|
||||||
|
parseDescriptionFields,
|
||||||
|
wrapCardText,
|
||||||
|
} from './records.js';
|
||||||
export * from './catalog.js';
|
export * from './catalog.js';
|
||||||
export * from './records.js';
|
export * from './records.js';
|
||||||
export { readGhostMapsKml } from './kml.js';
|
export { readGhostMapsKml } from './kml.js';
|
||||||
@@ -26,6 +34,7 @@ export const GHOSTMAPS_LAYER_ID = 's2-ghostmaps';
|
|||||||
export const GHOSTMAPS_OVERLAY_SOURCE_ID = 's2-ghostmaps';
|
export const GHOSTMAPS_OVERLAY_SOURCE_ID = 's2-ghostmaps';
|
||||||
const PICK_PREFIX = 'ghostmaps:';
|
const PICK_PREFIX = 'ghostmaps:';
|
||||||
const STORAGE_KEY = 'gev.ghostmaps.products';
|
const STORAGE_KEY = 'gev.ghostmaps.products';
|
||||||
|
const AGE_STORAGE_KEY = 'gev.ghostmaps.ageDays';
|
||||||
const ACCENT = '#c8ff00';
|
const ACCENT = '#c8ff00';
|
||||||
const MAX_ICON_BYTES = 512 * 1024;
|
const MAX_ICON_BYTES = 512 * 1024;
|
||||||
const CARD_HOST_OPTIONS = Object.freeze({
|
const CARD_HOST_OPTIONS = Object.freeze({
|
||||||
@@ -82,6 +91,19 @@ function writeStoredSelection(storage, selected) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readStoredAge(storage) {
|
||||||
|
try {
|
||||||
|
const raw = storage?.getItem(AGE_STORAGE_KEY);
|
||||||
|
if (raw !== null && raw !== undefined) {
|
||||||
|
const value = JSON.parse(raw);
|
||||||
|
if (GHOSTMAPS_AGE_OPTIONS.includes(value)) return value;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* unavailable or invalid storage uses the default */
|
||||||
|
}
|
||||||
|
return GHOSTMAPS_DEFAULT_AGE_DAYS;
|
||||||
|
}
|
||||||
|
|
||||||
/** A round marker for placemarks whose icon is missing or remote. */
|
/** A round marker for placemarks whose icon is missing or remote. */
|
||||||
const markerCache = new Map();
|
const markerCache = new Map();
|
||||||
function markerImage(color) {
|
function markerImage(color) {
|
||||||
@@ -137,6 +159,7 @@ export function createGhostMapsLayer({
|
|||||||
pointer = null,
|
pointer = null,
|
||||||
openExternal = null,
|
openExternal = null,
|
||||||
storage = defaultStorage(),
|
storage = defaultStorage(),
|
||||||
|
now = () => Date.now(),
|
||||||
} = {}) {
|
} = {}) {
|
||||||
if (typeof source?.getCatalog !== 'function')
|
if (typeof source?.getCatalog !== 'function')
|
||||||
throw new TypeError('GhostMaps layer requires a catalog source');
|
throw new TypeError('GhostMaps layer requires a catalog source');
|
||||||
@@ -154,7 +177,9 @@ export function createGhostMapsLayer({
|
|||||||
let _selectedLink = null;
|
let _selectedLink = null;
|
||||||
let _lifetime = new AbortController();
|
let _lifetime = new AbortController();
|
||||||
let _selection = readStoredSelection(storage);
|
let _selection = readStoredSelection(storage);
|
||||||
/** productId → {version, dataSource, urls, count, features} */
|
let _ageDays = readStoredAge(storage);
|
||||||
|
let _expiryTimer = null;
|
||||||
|
/** productId → {version, dataSource, urls, features: Map<index, record>, pins} */
|
||||||
const _loaded = new Map();
|
const _loaded = new Map();
|
||||||
/** productId → in-flight version */
|
/** productId → in-flight version */
|
||||||
const _loading = new Map();
|
const _loading = new Map();
|
||||||
@@ -183,17 +208,40 @@ export function createGhostMapsLayer({
|
|||||||
return (_catalog?.products || []).filter(wanted).map(({ id }) => id);
|
return (_catalog?.products || []).filter(wanted).map(({ id }) => id);
|
||||||
}
|
}
|
||||||
|
|
||||||
function unload(id) {
|
function unload(id, keepSelection = false) {
|
||||||
const item = _loaded.get(id);
|
const item = _loaded.get(id);
|
||||||
if (!item) return;
|
if (!item) return;
|
||||||
_loaded.delete(id);
|
_loaded.delete(id);
|
||||||
_viewer?.dataSources?.remove(item.dataSource, true);
|
_viewer?.dataSources?.remove(item.dataSource, true);
|
||||||
item.urls.forEach((url) => URL.revokeObjectURL(url));
|
item.urls.forEach((url) => URL.revokeObjectURL(url));
|
||||||
if (_selectedKey?.startsWith(`${id}:`)) clearSelection();
|
if (!keepSelection && _selectedKey?.startsWith(`${id}:`)) clearSelection();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove expired entities, rather than hiding them: horizon culling must
|
||||||
|
// never make an old pin visible again. Map keys keep surviving picks stable.
|
||||||
|
function expireEvents() {
|
||||||
|
const at = now();
|
||||||
|
let changed = false;
|
||||||
|
for (const [productId, item] of _loaded) {
|
||||||
|
for (const [index, feature] of item.features) {
|
||||||
|
if (!isGhostMapsEventExpired(feature.dateMs, at, _ageDays)) continue;
|
||||||
|
for (const entity of feature.entities)
|
||||||
|
item.dataSource.entities.remove(entity);
|
||||||
|
item.features.delete(index);
|
||||||
|
if (_selectedKey === `${productId}:${index}`) clearSelection();
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
item.pins = item.pins.filter((pin) => item.features.has(pin.index));
|
||||||
|
}
|
||||||
|
if (changed) {
|
||||||
|
notifyRow();
|
||||||
|
render();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Build one product's data source off-scene, then swap it in. */
|
/** Build one product's data source off-scene, then swap it in. */
|
||||||
async function load(product, signal) {
|
async function load(product, signal) {
|
||||||
|
const ageDays = _ageDays;
|
||||||
const bytes = await source.getFile(product.id, product.version, { signal });
|
const bytes = await source.getFile(product.id, product.version, { signal });
|
||||||
signal.throwIfAborted();
|
signal.throwIfAborted();
|
||||||
const entries = await readZipEntries(bytes);
|
const entries = await readZipEntries(bytes);
|
||||||
@@ -223,12 +271,16 @@ export function createGhostMapsLayer({
|
|||||||
const dataSource = new Cesium.CustomDataSource(
|
const dataSource = new Cesium.CustomDataSource(
|
||||||
`${GHOSTMAPS_LAYER_ID}:${product.id}`,
|
`${GHOSTMAPS_LAYER_ID}:${product.id}`,
|
||||||
);
|
);
|
||||||
const features = [];
|
const features = new Map();
|
||||||
const pins = [];
|
const pins = [];
|
||||||
|
const at = now();
|
||||||
dataSource.entities.suspendEvents();
|
dataSource.entities.suspendEvents();
|
||||||
parsed.features.forEach((feature, index) => {
|
parsed.features.forEach((feature, index) => {
|
||||||
|
const dateMs = ghostMapsEventDate(feature.description);
|
||||||
|
if (isGhostMapsEventExpired(dateMs, at, ageDays)) return;
|
||||||
const key = `${product.id}:${index}`;
|
const key = `${product.id}:${index}`;
|
||||||
const { style } = feature;
|
const { style } = feature;
|
||||||
|
const entities = [];
|
||||||
feature.geometries.forEach((geometry, part) => {
|
feature.geometries.forEach((geometry, part) => {
|
||||||
const id = `${PICK_PREFIX}${key}:${part}`;
|
const id = `${PICK_PREFIX}${key}:${part}`;
|
||||||
if (geometry.type === 'point') {
|
if (geometry.type === 'point') {
|
||||||
@@ -255,7 +307,9 @@ export function createGhostMapsLayer({
|
|||||||
disableDepthTestDistance: Number.POSITIVE_INFINITY,
|
disableDepthTestDistance: Number.POSITIVE_INFINITY,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
entities.push(entity);
|
||||||
pins.push({
|
pins.push({
|
||||||
|
index,
|
||||||
position,
|
position,
|
||||||
get show() {
|
get show() {
|
||||||
return entity.show;
|
return entity.show;
|
||||||
@@ -265,61 +319,76 @@ export function createGhostMapsLayer({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
} else if (geometry.type === 'line') {
|
} else if (geometry.type === 'line') {
|
||||||
dataSource.entities.add({
|
entities.push(
|
||||||
id,
|
|
||||||
polyline: {
|
|
||||||
positions: Cesium.Cartesian3.fromDegreesArray(
|
|
||||||
geometry.positions.flat(),
|
|
||||||
),
|
|
||||||
width: Math.min(8, Math.max(1.5, style.lineWidth ?? 2)),
|
|
||||||
material: cesiumColor(style.lineColor, color),
|
|
||||||
clampToGround: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
const ring = (points) =>
|
|
||||||
Cesium.Cartesian3.fromDegreesArray(points.flat());
|
|
||||||
dataSource.entities.add({
|
|
||||||
id,
|
|
||||||
polygon: {
|
|
||||||
hierarchy: new Cesium.PolygonHierarchy(
|
|
||||||
ring(geometry.outer),
|
|
||||||
geometry.holes.map(
|
|
||||||
(hole) => new Cesium.PolygonHierarchy(ring(hole)),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
material:
|
|
||||||
style.fill === false
|
|
||||||
? Cesium.Color.TRANSPARENT
|
|
||||||
: cesiumColor(
|
|
||||||
style.polyColor,
|
|
||||||
color,
|
|
||||||
style.polyColor ? undefined : 0.25,
|
|
||||||
),
|
|
||||||
classificationType: Cesium.ClassificationType.BOTH,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (style.outline !== false)
|
|
||||||
dataSource.entities.add({
|
dataSource.entities.add({
|
||||||
id: `${id}:outline`,
|
id,
|
||||||
polyline: {
|
polyline: {
|
||||||
positions: ring([...geometry.outer, geometry.outer[0]]),
|
positions: Cesium.Cartesian3.fromDegreesArray(
|
||||||
width: Math.min(6, Math.max(1.5, style.lineWidth ?? 2)),
|
geometry.positions.flat(),
|
||||||
|
),
|
||||||
|
width: Math.min(8, Math.max(1.5, style.lineWidth ?? 2)),
|
||||||
material: cesiumColor(style.lineColor, color),
|
material: cesiumColor(style.lineColor, color),
|
||||||
clampToGround: true,
|
clampToGround: true,
|
||||||
},
|
},
|
||||||
});
|
}),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
const ring = (points) =>
|
||||||
|
Cesium.Cartesian3.fromDegreesArray(points.flat());
|
||||||
|
entities.push(
|
||||||
|
dataSource.entities.add({
|
||||||
|
id,
|
||||||
|
polygon: {
|
||||||
|
hierarchy: new Cesium.PolygonHierarchy(
|
||||||
|
ring(geometry.outer),
|
||||||
|
geometry.holes.map(
|
||||||
|
(hole) => new Cesium.PolygonHierarchy(ring(hole)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
material:
|
||||||
|
style.fill === false
|
||||||
|
? Cesium.Color.TRANSPARENT
|
||||||
|
: cesiumColor(
|
||||||
|
style.polyColor,
|
||||||
|
color,
|
||||||
|
style.polyColor ? undefined : 0.25,
|
||||||
|
),
|
||||||
|
classificationType: Cesium.ClassificationType.BOTH,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
if (style.outline !== false)
|
||||||
|
entities.push(
|
||||||
|
dataSource.entities.add({
|
||||||
|
id: `${id}:outline`,
|
||||||
|
polyline: {
|
||||||
|
positions: ring([...geometry.outer, geometry.outer[0]]),
|
||||||
|
width: Math.min(6, Math.max(1.5, style.lineWidth ?? 2)),
|
||||||
|
material: cesiumColor(style.lineColor, color),
|
||||||
|
clampToGround: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
features.push({
|
features.set(index, {
|
||||||
name: feature.name,
|
name: feature.name,
|
||||||
folder: feature.folder,
|
folder: feature.folder,
|
||||||
description: feature.description,
|
description: feature.description,
|
||||||
|
dateMs,
|
||||||
|
entities,
|
||||||
anchor: anchorOf(feature.geometries[0]),
|
anchor: anchorOf(feature.geometries[0]),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
dataSource.entities.resumeEvents();
|
dataSource.entities.resumeEvents();
|
||||||
return { version: product.version, dataSource, urls, features, pins };
|
return {
|
||||||
|
version: product.version,
|
||||||
|
ageDays,
|
||||||
|
dataSource,
|
||||||
|
urls,
|
||||||
|
features,
|
||||||
|
pins,
|
||||||
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
urls.forEach((url) => URL.revokeObjectURL(url));
|
urls.forEach((url) => URL.revokeObjectURL(url));
|
||||||
throw error;
|
throw error;
|
||||||
@@ -330,6 +399,7 @@ export function createGhostMapsLayer({
|
|||||||
function sync() {
|
function sync() {
|
||||||
const run = _queue.then(async () => {
|
const run = _queue.then(async () => {
|
||||||
if (!_enabled || !_viewer || !_catalog) return;
|
if (!_enabled || !_viewer || !_catalog) return;
|
||||||
|
expireEvents();
|
||||||
const signal = _lifetime.signal;
|
const signal = _lifetime.signal;
|
||||||
const products = new Map(_catalog.products.map((p) => [p.id, p]));
|
const products = new Map(_catalog.products.map((p) => [p.id, p]));
|
||||||
for (const id of [..._loaded.keys()]) {
|
for (const id of [..._loaded.keys()]) {
|
||||||
@@ -338,21 +408,33 @@ export function createGhostMapsLayer({
|
|||||||
}
|
}
|
||||||
for (const product of _catalog.products) {
|
for (const product of _catalog.products) {
|
||||||
if (!wanted(product) || signal.aborted) continue;
|
if (!wanted(product) || signal.aborted) continue;
|
||||||
if (_loaded.get(product.id)?.version === product.version) continue;
|
const loaded = _loaded.get(product.id);
|
||||||
|
if (loaded?.version === product.version && loaded.ageDays === _ageDays)
|
||||||
|
continue;
|
||||||
_loading.set(product.id, product.version);
|
_loading.set(product.id, product.version);
|
||||||
_failed.delete(product.id);
|
_failed.delete(product.id);
|
||||||
notifyRow();
|
notifyRow();
|
||||||
try {
|
try {
|
||||||
const next = await load(product, signal);
|
const next = await load(product, signal);
|
||||||
if (signal.aborted || !_enabled) {
|
if (signal.aborted || !_enabled || next.ageDays !== _ageDays) {
|
||||||
next.urls.forEach((url) => URL.revokeObjectURL(url));
|
next.urls.forEach((url) => URL.revokeObjectURL(url));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Cull before the first frame so far-side pins never flash.
|
// Cull before the first frame so far-side pins never flash.
|
||||||
_horizon.update(next.pins);
|
_horizon.update(next.pins);
|
||||||
await _viewer.dataSources.add(next.dataSource);
|
await _viewer.dataSources.add(next.dataSource);
|
||||||
// The new edition is on the globe before the old one leaves.
|
// The new edition is on the globe before the old one leaves. The card
|
||||||
unload(product.id);
|
// stays only if its index still holds the same report: the server
|
||||||
|
// may answer a pinned version with newer bytes.
|
||||||
|
const index = Number(_selectedKey?.split(':')[1]);
|
||||||
|
const before = loaded?.features.get(index);
|
||||||
|
const after = next.features.get(index);
|
||||||
|
unload(
|
||||||
|
product.id,
|
||||||
|
Boolean(before && after) &&
|
||||||
|
before.name === after.name &&
|
||||||
|
before.description === after.description,
|
||||||
|
);
|
||||||
_loaded.set(product.id, next);
|
_loaded.set(product.id, next);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (signal.aborted) return;
|
if (signal.aborted) return;
|
||||||
@@ -391,12 +473,38 @@ export function createGhostMapsLayer({
|
|||||||
void sync();
|
void sync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setAgeDays(ageDays) {
|
||||||
|
if (!GHOSTMAPS_AGE_OPTIONS.includes(ageDays) || ageDays === _ageDays)
|
||||||
|
return;
|
||||||
|
_ageDays = ageDays;
|
||||||
|
try {
|
||||||
|
storage?.setItem(AGE_STORAGE_KEY, JSON.stringify(ageDays));
|
||||||
|
} catch {
|
||||||
|
/* private window or blocked storage: keep the session choice */
|
||||||
|
}
|
||||||
|
expireEvents();
|
||||||
|
// Narrowing only removes markers. A product loaded with a narrower window
|
||||||
|
// keeps its old ageDays, so sync() restores omitted placemarks from the
|
||||||
|
// cached KMZ even though its upstream version has not changed.
|
||||||
|
for (const [id, item] of _loaded)
|
||||||
|
if (
|
||||||
|
item.ageDays === null ||
|
||||||
|
(ageDays !== null && item.ageDays >= ageDays)
|
||||||
|
) {
|
||||||
|
item.ageDays = ageDays;
|
||||||
|
// A failed wider reload no longer matters; a stale edition retries.
|
||||||
|
_failed.delete(id);
|
||||||
|
}
|
||||||
|
notifyRow();
|
||||||
|
return sync();
|
||||||
|
}
|
||||||
|
|
||||||
// ---- selection card ----------------------------------------------------
|
// ---- selection card ----------------------------------------------------
|
||||||
function featureFor(key) {
|
function featureFor(key) {
|
||||||
const [productId, index] = key.split(':');
|
const [productId, index] = key.split(':');
|
||||||
return {
|
return {
|
||||||
product: _catalog?.products.find((p) => p.id === productId) || null,
|
product: _catalog?.products.find((p) => p.id === productId) || null,
|
||||||
feature: _loaded.get(productId)?.features[Number(index)] || null,
|
feature: _loaded.get(productId)?.features.get(Number(index)) || null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -490,7 +598,7 @@ export function createGhostMapsLayer({
|
|||||||
if (typeof pickId !== 'string' || !pickId.startsWith(PICK_PREFIX))
|
if (typeof pickId !== 'string' || !pickId.startsWith(PICK_PREFIX))
|
||||||
return null;
|
return null;
|
||||||
const [productId, index] = pickId.slice(PICK_PREFIX.length).split(':');
|
const [productId, index] = pickId.slice(PICK_PREFIX.length).split(':');
|
||||||
return _loaded.get(productId)?.features[Number(index)]
|
return _loaded.get(productId)?.features.has(Number(index))
|
||||||
? `${productId}:${index}`
|
? `${productId}:${index}`
|
||||||
: null;
|
: null;
|
||||||
}
|
}
|
||||||
@@ -542,7 +650,7 @@ export function createGhostMapsLayer({
|
|||||||
|
|
||||||
function featureCount() {
|
function featureCount() {
|
||||||
let count = 0;
|
let count = 0;
|
||||||
for (const item of _loaded.values()) count += item.features.length;
|
for (const item of _loaded.values()) count += item.features.size;
|
||||||
return count;
|
return count;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -563,6 +671,8 @@ export function createGhostMapsLayer({
|
|||||||
enable() {
|
enable() {
|
||||||
_enabled = true;
|
_enabled = true;
|
||||||
_lifetime = new AbortController();
|
_lifetime = new AbortController();
|
||||||
|
if (_expiryTimer === null)
|
||||||
|
_expiryTimer = setInterval(expireEvents, 60_000);
|
||||||
for (const item of _loaded.values()) item.dataSource.show = true;
|
for (const item of _loaded.values()) item.dataSource.show = true;
|
||||||
installClickHandler();
|
installClickHandler();
|
||||||
if (!_unsubscribe && typeof source.subscribe === 'function')
|
if (!_unsubscribe && typeof source.subscribe === 'function')
|
||||||
@@ -580,6 +690,8 @@ export function createGhostMapsLayer({
|
|||||||
|
|
||||||
disable() {
|
disable() {
|
||||||
_enabled = false;
|
_enabled = false;
|
||||||
|
clearInterval(_expiryTimer);
|
||||||
|
_expiryTimer = null;
|
||||||
_lifetime.abort();
|
_lifetime.abort();
|
||||||
_unsubscribe?.();
|
_unsubscribe?.();
|
||||||
_unsubscribe = null;
|
_unsubscribe = null;
|
||||||
@@ -593,6 +705,7 @@ export function createGhostMapsLayer({
|
|||||||
|
|
||||||
async update(_viewerArg, { signal } = {}) {
|
async update(_viewerArg, { signal } = {}) {
|
||||||
if (!_enabled || signal?.aborted) return false;
|
if (!_enabled || signal?.aborted) return false;
|
||||||
|
expireEvents();
|
||||||
try {
|
try {
|
||||||
const catalog = await source.getCatalog({ signal });
|
const catalog = await source.getCatalog({ signal });
|
||||||
if (!_enabled) return false;
|
if (!_enabled) return false;
|
||||||
@@ -637,6 +750,25 @@ export function createGhostMapsLayer({
|
|||||||
})
|
})
|
||||||
: t('Connecting to the S2 GhostMaps repository…');
|
: t('Connecting to the S2 GhostMaps repository…');
|
||||||
return {
|
return {
|
||||||
|
segmented: {
|
||||||
|
id: 'age',
|
||||||
|
label: t('Event age'),
|
||||||
|
options: GHOSTMAPS_AGE_OPTIONS.map((days) => ({
|
||||||
|
id: `age-${days ?? 'all'}`,
|
||||||
|
label:
|
||||||
|
days === null
|
||||||
|
? t('All', { context: 'event age filter' })
|
||||||
|
: t('{days}d', { days: formatNumber(days) }),
|
||||||
|
title:
|
||||||
|
days === null
|
||||||
|
? t('Show markers of every age')
|
||||||
|
: t('Maximum event age: {days}d; undated markers are kept', {
|
||||||
|
days: formatNumber(days),
|
||||||
|
}),
|
||||||
|
active: days === _ageDays,
|
||||||
|
onClick: () => setAgeDays(days),
|
||||||
|
})),
|
||||||
|
},
|
||||||
chips: products.map((product) => {
|
chips: products.map((product) => {
|
||||||
const loaded = _loaded.get(product.id);
|
const loaded = _loaded.get(product.id);
|
||||||
const failed = _failed.get(product.id);
|
const failed = _failed.get(product.id);
|
||||||
@@ -651,11 +783,11 @@ export function createGhostMapsLayer({
|
|||||||
product.fileTitle,
|
product.fileTitle,
|
||||||
loaded
|
loaded
|
||||||
? tn(
|
? tn(
|
||||||
loaded.features.length,
|
loaded.features.size,
|
||||||
'{count} placemark',
|
'{count} placemark',
|
||||||
'{count} placemarks',
|
'{count} placemarks',
|
||||||
{
|
{
|
||||||
count: formatNumber(loaded.features.length),
|
count: formatNumber(loaded.features.size),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
: null,
|
: null,
|
||||||
@@ -667,7 +799,7 @@ export function createGhostMapsLayer({
|
|||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
legend: [],
|
legend: [],
|
||||||
info,
|
info: `${_ageDays === null ? t('All markers') : t('Event age: {days}d · undated kept', { days: formatNumber(_ageDays) })} · ${info}`,
|
||||||
infoTitle: t(
|
infoTitle: t(
|
||||||
'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 publishes these KMZ products to GitHub. New uploads are detected within seconds and replace the previous edition automatically. Click a marker for its report.',
|
||||||
),
|
),
|
||||||
@@ -677,6 +809,7 @@ export function createGhostMapsLayer({
|
|||||||
getStats() {
|
getStats() {
|
||||||
return {
|
return {
|
||||||
count: featureCount(),
|
count: featureCount(),
|
||||||
|
ageDays: _ageDays,
|
||||||
products: [..._loaded.keys()],
|
products: [..._loaded.keys()],
|
||||||
commit: _catalog?.commit || null,
|
commit: _catalog?.commit || null,
|
||||||
lastUpdate: _catalog?.committedAt || _catalog?.changedAt || null,
|
lastUpdate: _catalog?.committedAt || _catalog?.changedAt || null,
|
||||||
@@ -693,7 +826,7 @@ export function createGhostMapsLayer({
|
|||||||
if (!_enabled) return [];
|
if (!_enabled) return [];
|
||||||
const out = [];
|
const out = [];
|
||||||
for (const [productId, item] of _loaded) {
|
for (const [productId, item] of _loaded) {
|
||||||
for (const feature of item.features) {
|
for (const feature of item.features.values()) {
|
||||||
if (out.length >= maxCount) return out;
|
if (out.length >= maxCount) return out;
|
||||||
out.push({
|
out.push({
|
||||||
product: productId,
|
product: productId,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import assert from 'node:assert/strict';
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import * as Cesium from 'cesium';
|
import * as Cesium from 'cesium';
|
||||||
import { createGhostMapsLayer } from './index.js';
|
import { createGhostMapsLayer } from './index.js';
|
||||||
|
import { GHOSTMAPS_PRODUCTS, GHOSTMAPS_LATEST_DROP } from './catalog.js';
|
||||||
|
|
||||||
function element(localName, children = [], value = '', attributes = {}) {
|
function element(localName, children = [], value = '', attributes = {}) {
|
||||||
const node = {
|
const node = {
|
||||||
@@ -22,7 +23,7 @@ function element(localName, children = [], value = '', attributes = {}) {
|
|||||||
return node;
|
return node;
|
||||||
}
|
}
|
||||||
|
|
||||||
function documentFixture() {
|
function documentFixture(reports = [{ name: 'Test report' }]) {
|
||||||
return element('document', [
|
return element('document', [
|
||||||
element('kml', [
|
element('kml', [
|
||||||
element('Document', [
|
element('Document', [
|
||||||
@@ -36,11 +37,14 @@ function documentFixture() {
|
|||||||
'',
|
'',
|
||||||
{ id: 'pin' },
|
{ id: 'pin' },
|
||||||
),
|
),
|
||||||
element('Placemark', [
|
...reports.map(({ name, description = '', geometry }) =>
|
||||||
element('name', [], 'Test report'),
|
element('Placemark', [
|
||||||
element('styleUrl', [], '#pin'),
|
element('name', [], name),
|
||||||
element('Point', [element('coordinates', [], '12,34')]),
|
element('description', [], description),
|
||||||
]),
|
element('styleUrl', [], '#pin'),
|
||||||
|
geometry || element('Point', [element('coordinates', [], '12,34')]),
|
||||||
|
]),
|
||||||
|
),
|
||||||
]),
|
]),
|
||||||
]),
|
]),
|
||||||
]);
|
]);
|
||||||
@@ -83,11 +87,21 @@ function fixtureKmz() {
|
|||||||
return new Uint8Array(Buffer.concat([...local, directory, end]));
|
return new Uint8Array(Buffer.concat([...local, directory, end]));
|
||||||
}
|
}
|
||||||
|
|
||||||
function fixture(t, { beforeFileReturns, onCreateUrl } = {}) {
|
function fixture(
|
||||||
|
t,
|
||||||
|
{
|
||||||
|
beforeFileReturns,
|
||||||
|
onCreateUrl,
|
||||||
|
reports,
|
||||||
|
now,
|
||||||
|
products,
|
||||||
|
controls = {},
|
||||||
|
} = {},
|
||||||
|
) {
|
||||||
const previous = Object.getOwnPropertyDescriptor(globalThis, 'DOMParser');
|
const previous = Object.getOwnPropertyDescriptor(globalThis, 'DOMParser');
|
||||||
globalThis.DOMParser = class {
|
globalThis.DOMParser = class {
|
||||||
parseFromString() {
|
parseFromString() {
|
||||||
return documentFixture();
|
return documentFixture(reports);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
t.after(() => {
|
t.after(() => {
|
||||||
@@ -119,9 +133,13 @@ function fixture(t, { beforeFileReturns, onCreateUrl } = {}) {
|
|||||||
};
|
};
|
||||||
const layer = createGhostMapsLayer({
|
const layer = createGhostMapsLayer({
|
||||||
storage: null,
|
storage: null,
|
||||||
|
now,
|
||||||
|
...controls,
|
||||||
source: {
|
source: {
|
||||||
getCatalog: async () => ({
|
getCatalog: async () => ({
|
||||||
products: [{ id: 'cip', version: 'a'.repeat(40), defaultOn: true }],
|
products: products ?? [
|
||||||
|
{ id: 'cip', version: 'a'.repeat(40), defaultOn: true },
|
||||||
|
],
|
||||||
}),
|
}),
|
||||||
getFile: async () => {
|
getFile: async () => {
|
||||||
beforeFileReturns?.(layer);
|
beforeFileReturns?.(layer);
|
||||||
@@ -178,3 +196,416 @@ test('successful load keeps icons until the product is disabled', async (t) => {
|
|||||||
assert.deepEqual(revoked, created);
|
assert.deepEqual(revoked, created);
|
||||||
assert.equal(mounted.size, 0);
|
assert.equal(mounted.size, 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const dated = (name, value, label = 'Date') => ({
|
||||||
|
name,
|
||||||
|
description: `<table><tr><td>${label}</td><td>${value}</td></tr></table>`,
|
||||||
|
});
|
||||||
|
|
||||||
|
const ageChip = (layer, days) =>
|
||||||
|
layer
|
||||||
|
.getRowControls()
|
||||||
|
.segmented.options.find(({ id }) => id === `age-${days ?? 'all'}`);
|
||||||
|
|
||||||
|
test('age controls default to seven days and widening restores filtered and expired reports', async (t) => {
|
||||||
|
let at = Date.UTC(2026, 10, 3, 12);
|
||||||
|
const { layer, viewer, mounted } = fixture(t, {
|
||||||
|
now: () => at,
|
||||||
|
reports: [
|
||||||
|
dated('Old', '10/1/2026'),
|
||||||
|
dated('Ten days', '10/24/2026'),
|
||||||
|
dated('Recent', '11/2/2026'),
|
||||||
|
{ name: 'Undated' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
await layer.update(viewer);
|
||||||
|
assert.equal(layer.getStats().ageDays, 7);
|
||||||
|
assert.equal(ageChip(layer, 7).active, true);
|
||||||
|
assert.equal(layer.getStats().count, 2);
|
||||||
|
await ageChip(layer, 14).onClick();
|
||||||
|
assert.equal(layer.getStats().count, 3);
|
||||||
|
await ageChip(layer, null).onClick();
|
||||||
|
assert.equal(layer.getStats().count, 4);
|
||||||
|
at += 100 * 86400_000;
|
||||||
|
await layer.update(viewer);
|
||||||
|
assert.equal(layer.getStats().count, 4, 'all markers never ages out');
|
||||||
|
await ageChip(layer, 1).onClick();
|
||||||
|
assert.equal(layer.getStats().count, 1);
|
||||||
|
assert.equal(layer.getAnalystRecords()[0].name, 'Undated');
|
||||||
|
await ageChip(layer, null).onClick();
|
||||||
|
assert.equal(layer.getStats().count, 4);
|
||||||
|
assert.equal(mounted.size, 1, 'filter changes never duplicate data sources');
|
||||||
|
const controls = layer.getRowControls();
|
||||||
|
assert.equal(
|
||||||
|
controls.segmented.options.filter(({ active }) => active).length,
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
controls.chips.some(({ id }) => id.startsWith('age-')),
|
||||||
|
false,
|
||||||
|
'age options are one switch, not product chips',
|
||||||
|
);
|
||||||
|
assert.equal(ageChip(layer, null).active, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('age preferences persist independently of product choices and reject invalid storage', async (t) => {
|
||||||
|
const values = new Map([['gev.ghostmaps.products', '["cip"]']]);
|
||||||
|
const storage = {
|
||||||
|
getItem: (key) => values.get(key) ?? null,
|
||||||
|
setItem: (key, value) => values.set(key, value),
|
||||||
|
};
|
||||||
|
await t.test('save All markers without changing products', async (t) => {
|
||||||
|
const { layer, viewer } = fixture(t, { controls: { storage } });
|
||||||
|
await layer.update(viewer);
|
||||||
|
await ageChip(layer, null).onClick();
|
||||||
|
assert.equal(values.get('gev.ghostmaps.ageDays'), 'null');
|
||||||
|
assert.equal(values.get('gev.ghostmaps.products'), '["cip"]');
|
||||||
|
});
|
||||||
|
await t.test('restore All markers and save thirty days', async (t) => {
|
||||||
|
const { layer } = fixture(t, { controls: { storage } });
|
||||||
|
assert.equal(layer.getStats().ageDays, null);
|
||||||
|
await ageChip(layer, 30).onClick();
|
||||||
|
assert.equal(values.get('gev.ghostmaps.ageDays'), '30');
|
||||||
|
});
|
||||||
|
await t.test('restore thirty days', (t) => {
|
||||||
|
const { layer } = fixture(t, { controls: { storage } });
|
||||||
|
assert.equal(layer.getStats().ageDays, 30);
|
||||||
|
});
|
||||||
|
for (const value of ['garbage', '"7"', '-1', '0', '2', '{}']) {
|
||||||
|
await t.test(`reject ${value}`, (t) => {
|
||||||
|
values.set('gev.ghostmaps.ageDays', value);
|
||||||
|
const { layer } = fixture(t, { controls: { storage } });
|
||||||
|
assert.equal(layer.getStats().ageDays, 7);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('blocked storage still permits changing the age within the session', async (t) => {
|
||||||
|
const storage = {
|
||||||
|
getItem() {
|
||||||
|
throw new Error('blocked');
|
||||||
|
},
|
||||||
|
setItem() {
|
||||||
|
throw new Error('blocked');
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const { layer, viewer } = fixture(t, { controls: { storage } });
|
||||||
|
await layer.update(viewer);
|
||||||
|
assert.equal(layer.getStats().ageDays, 7);
|
||||||
|
await ageChip(layer, null).onClick();
|
||||||
|
assert.equal(layer.getStats().ageDays, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a filter changed during a pending load never installs the superseded filter', async (t) => {
|
||||||
|
let changed = false;
|
||||||
|
const { layer, viewer, mounted, revoked, created } = fixture(t, {
|
||||||
|
now: () => Date.UTC(2026, 10, 3),
|
||||||
|
reports: [dated('Old', '10/1/2026')],
|
||||||
|
beforeFileReturns: (layer) => {
|
||||||
|
if (changed) return;
|
||||||
|
changed = true;
|
||||||
|
ageChip(layer, null).onClick();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await layer.update(viewer);
|
||||||
|
await layer.update(viewer);
|
||||||
|
assert.equal(layer.getStats().count, 1);
|
||||||
|
assert.equal(layer.getStats().ageDays, null);
|
||||||
|
assert.equal(mounted.size, 1);
|
||||||
|
assert.equal(
|
||||||
|
revoked.length,
|
||||||
|
created.length - 1,
|
||||||
|
'discarded load releases icons',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('age filtering and All markers apply to every conflict-map product, including optional maps', async (t) => {
|
||||||
|
for (const product of [...GHOSTMAPS_PRODUCTS, GHOSTMAPS_LATEST_DROP]) {
|
||||||
|
await t.test(product.id, async (t) => {
|
||||||
|
const { layer, viewer, mounted } = fixture(t, {
|
||||||
|
now: () => Date.UTC(2026, 10, 3, 12),
|
||||||
|
products: [{ ...product, version: 'a'.repeat(40) }],
|
||||||
|
reports: [
|
||||||
|
dated(
|
||||||
|
'Old event',
|
||||||
|
'10/1/2026',
|
||||||
|
product.id === 'cip' ? 'Date of Event' : 'Date',
|
||||||
|
),
|
||||||
|
dated('Recent event', '11/2/2026'),
|
||||||
|
{ name: 'Undated reference feature' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
await layer.update(viewer);
|
||||||
|
if (!product.defaultOn) {
|
||||||
|
layer
|
||||||
|
.getRowControls()
|
||||||
|
.chips.find(({ id }) => id === product.id)
|
||||||
|
.onClick();
|
||||||
|
await layer.update(viewer);
|
||||||
|
}
|
||||||
|
assert.deepEqual(layer.getStats().products, [product.id]);
|
||||||
|
assert.equal(layer.getStats().count, 2);
|
||||||
|
assert.deepEqual(
|
||||||
|
layer.getAnalystRecords().map(({ name }) => name),
|
||||||
|
['Recent event', 'Undated reference feature'],
|
||||||
|
);
|
||||||
|
await ageChip(layer, null).onClick();
|
||||||
|
assert.equal(
|
||||||
|
layer.getStats().count,
|
||||||
|
3,
|
||||||
|
"All markers restores this product's old events",
|
||||||
|
);
|
||||||
|
await ageChip(layer, 7).onClick();
|
||||||
|
assert.equal(layer.getStats().count, 2);
|
||||||
|
assert.equal(mounted.size, 1);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('default products filter old event dates while keeping recent, future and undated markers', async (t) => {
|
||||||
|
const { layer, viewer, mounted } = fixture(t, {
|
||||||
|
now: () => Date.UTC(2026, 10, 3, 12),
|
||||||
|
reports: [
|
||||||
|
dated('Old event', '10/20/2026'),
|
||||||
|
dated('Recent event', '11/2/2026'),
|
||||||
|
dated('At cutoff', '10/27/2026 12:00:00 PM'),
|
||||||
|
dated('Before cutoff', '10/27/2026 11:59:59 AM'),
|
||||||
|
dated('Future event', '11/4/2026'),
|
||||||
|
{ name: 'Undated site' },
|
||||||
|
dated('Blank date', '<Null>'),
|
||||||
|
dated('Invalid date', '2/30/2026'),
|
||||||
|
dated('Only a report date', '10/1/2026', 'Date Of Report'),
|
||||||
|
{
|
||||||
|
name: 'Old event reported recently',
|
||||||
|
description:
|
||||||
|
'<table><tr><td>Date of Event</td><td>10/1/2026</td></tr><tr><td>Date Of Report</td><td>11/2/2026</td></tr></table>',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
await layer.update(viewer);
|
||||||
|
assert.deepEqual(layer.getStats().products, ['cip']);
|
||||||
|
assert.deepEqual(
|
||||||
|
layer.getAnalystRecords().map((r) => r.name),
|
||||||
|
[
|
||||||
|
'Recent event',
|
||||||
|
'At cutoff',
|
||||||
|
'Future event',
|
||||||
|
'Undated site',
|
||||||
|
'Blank date',
|
||||||
|
'Invalid date',
|
||||||
|
'Only a report date',
|
||||||
|
],
|
||||||
|
);
|
||||||
|
assert.equal(layer.getStats().count, 7);
|
||||||
|
assert.equal([...mounted][0].entities.values.length, 7);
|
||||||
|
// Filtering cannot shift pick ids onto a different report.
|
||||||
|
assert.ok([...mounted][0].entities.getById('ghostmaps:cip:1:0'));
|
||||||
|
assert.equal(
|
||||||
|
[...mounted][0].entities.getById('ghostmaps:cip:0:0'),
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('unchanged cached products age out on the timer, clear selected cards and keep surviving picks', async (t) => {
|
||||||
|
t.mock.timers.enable({ apis: ['setInterval'] });
|
||||||
|
let at = Date.UTC(2026, 10, 3, 12);
|
||||||
|
let click;
|
||||||
|
const cards = [];
|
||||||
|
const { layer, viewer, mounted } = fixture(t, {
|
||||||
|
now: () => at,
|
||||||
|
reports: [
|
||||||
|
dated('Expiring', '10/27/2026 12:00:00 PM'),
|
||||||
|
dated('Surviving', '11/2/2026'),
|
||||||
|
],
|
||||||
|
controls: {
|
||||||
|
overlayHost: { setEntries: (_id, entries) => cards.push(entries) },
|
||||||
|
screenSpaceEventHandlerFactory: () => ({
|
||||||
|
setInputAction: (fn) => {
|
||||||
|
click = fn;
|
||||||
|
},
|
||||||
|
destroy() {},
|
||||||
|
}),
|
||||||
|
picking: { resolvePickId: (picked) => picked },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await layer.update(viewer);
|
||||||
|
const ds = [...mounted][0];
|
||||||
|
viewer.scene.pick = () => 'ghostmaps:cip:0:0';
|
||||||
|
click({ position: { x: 0, y: 0 } });
|
||||||
|
assert.equal(cards.at(-1).length, 1);
|
||||||
|
at += 60_000;
|
||||||
|
t.mock.timers.tick(60_000);
|
||||||
|
assert.equal(layer.getStats().count, 1);
|
||||||
|
assert.equal(ds.entities.values.length, 1);
|
||||||
|
assert.equal(ds.entities.getById('ghostmaps:cip:0:0'), undefined);
|
||||||
|
assert.ok(ds.entities.getById('ghostmaps:cip:1:0'));
|
||||||
|
assert.deepEqual(cards.at(-1), []);
|
||||||
|
await layer.update(viewer);
|
||||||
|
assert.equal([...mounted][0], ds, 'same-version KMZ is not reloaded');
|
||||||
|
viewer.scene.pick = () => 'ghostmaps:cip:1:0';
|
||||||
|
click({ position: { x: 0, y: 0 } });
|
||||||
|
assert.match(cards.at(-1)[0].title, /Surviving/);
|
||||||
|
layer.disable();
|
||||||
|
at += 14 * 86400_000;
|
||||||
|
t.mock.timers.tick(60_000);
|
||||||
|
assert.equal(mounted.size, 0);
|
||||||
|
layer.enable();
|
||||||
|
await layer.update(viewer);
|
||||||
|
assert.equal(layer.getStats().count, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('expiry removes every geometry and polygon outline belonging to an old report', async (t) => {
|
||||||
|
t.mock.timers.enable({ apis: ['setInterval'] });
|
||||||
|
// 10/27 is a whole UTC day: at 7d it expires when 11/3 ends.
|
||||||
|
let at = Date.UTC(2026, 10, 4) - 30_000;
|
||||||
|
const polygon = element('Polygon', [
|
||||||
|
element('outerBoundaryIs', [
|
||||||
|
element('LinearRing', [
|
||||||
|
element('coordinates', [], '12,34 13,34 13,35 12,34'),
|
||||||
|
]),
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
const { layer, viewer, mounted } = fixture(t, {
|
||||||
|
now: () => at,
|
||||||
|
reports: [
|
||||||
|
{
|
||||||
|
...dated('Multi-part', '10/27/2026'),
|
||||||
|
geometry: element('MultiGeometry', [
|
||||||
|
element('Point', [element('coordinates', [], '12,34')]),
|
||||||
|
element('LineString', [element('coordinates', [], '12,34 13,35')]),
|
||||||
|
polygon,
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
await layer.update(viewer);
|
||||||
|
const ds = [...mounted][0];
|
||||||
|
assert.equal(ds.entities.values.length, 4);
|
||||||
|
at += 60_000;
|
||||||
|
t.mock.timers.tick(60_000);
|
||||||
|
assert.equal(ds.entities.values.length, 0);
|
||||||
|
assert.equal(layer.getStats().count, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('narrowing then returning to the previous window restores its reports without a duplicate source', async (t) => {
|
||||||
|
const { layer, viewer, mounted } = fixture(t, {
|
||||||
|
now: () => Date.UTC(2026, 10, 3, 12),
|
||||||
|
reports: [dated('Today', '11/3/2026'), dated('Three days', '10/31/2026')],
|
||||||
|
});
|
||||||
|
await layer.update(viewer);
|
||||||
|
assert.equal(layer.getStats().count, 2);
|
||||||
|
await ageChip(layer, 1).onClick();
|
||||||
|
assert.equal(layer.getStats().count, 1);
|
||||||
|
await ageChip(layer, 7).onClick();
|
||||||
|
await layer.update(viewer);
|
||||||
|
assert.equal(layer.getStats().count, 2, '7d must show both reports again');
|
||||||
|
assert.equal(mounted.size, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('two quick clicks, 1d then 7d, restore the report', async (t) => {
|
||||||
|
const at = Date.UTC(2026, 10, 3, 12);
|
||||||
|
const { layer, viewer } = fixture(t, {
|
||||||
|
now: () => at,
|
||||||
|
reports: [dated('Today', '11/3/2026'), dated('Three days', '10/31/2026')],
|
||||||
|
});
|
||||||
|
await layer.update(viewer);
|
||||||
|
const a = ageChip(layer, 1).onClick();
|
||||||
|
const b = ageChip(layer, 7).onClick();
|
||||||
|
await a;
|
||||||
|
await b;
|
||||||
|
await layer.update(viewer);
|
||||||
|
assert.equal(layer.getStats().count, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('narrowing the window keeps the open card of a surviving marker', async (t) => {
|
||||||
|
let click;
|
||||||
|
const cards = [];
|
||||||
|
const { layer, viewer } = fixture(t, {
|
||||||
|
now: () => Date.UTC(2026, 10, 3, 12),
|
||||||
|
reports: [dated('Recent', '11/2/2026'), dated('Older', '10/20/2026')],
|
||||||
|
controls: {
|
||||||
|
overlayHost: { setEntries: (_id, entries) => cards.push(entries) },
|
||||||
|
screenSpaceEventHandlerFactory: () => ({
|
||||||
|
setInputAction: (fn) => {
|
||||||
|
click = fn;
|
||||||
|
},
|
||||||
|
destroy() {},
|
||||||
|
}),
|
||||||
|
picking: { resolvePickId: (picked) => picked },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await layer.update(viewer);
|
||||||
|
await ageChip(layer, 30).onClick();
|
||||||
|
assert.equal(layer.getStats().count, 2);
|
||||||
|
viewer.scene.pick = () => 'ghostmaps:cip:0:0';
|
||||||
|
click({ position: { x: 0, y: 0 } });
|
||||||
|
assert.equal(cards.at(-1).length, 1);
|
||||||
|
await ageChip(layer, 3).onClick();
|
||||||
|
assert.equal(layer.getStats().count, 1);
|
||||||
|
assert.equal(
|
||||||
|
cards.at(-1).length,
|
||||||
|
1,
|
||||||
|
'card of the surviving marker stays open',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a report dated yesterday is shown by 1d one hour into today (UTC)', async (t) => {
|
||||||
|
const { layer, viewer } = fixture(t, {
|
||||||
|
now: () => Date.UTC(2026, 10, 6, 1),
|
||||||
|
reports: [dated('Yesterday', '11/5/2026')],
|
||||||
|
});
|
||||||
|
await layer.update(viewer);
|
||||||
|
await ageChip(layer, 1).onClick();
|
||||||
|
assert.equal(layer.getStats().count, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a widened reload that returns a different report at the selected index closes its card', async (t) => {
|
||||||
|
let click;
|
||||||
|
const cards = [];
|
||||||
|
const reports = [dated('Recent', '11/2/2026'), dated('Older', '10/20/2026')];
|
||||||
|
const { layer, viewer } = fixture(t, {
|
||||||
|
now: () => Date.UTC(2026, 10, 3, 12),
|
||||||
|
reports,
|
||||||
|
controls: {
|
||||||
|
overlayHost: { setEntries: (_id, entries) => cards.push(entries) },
|
||||||
|
screenSpaceEventHandlerFactory: () => ({
|
||||||
|
setInputAction: (fn) => {
|
||||||
|
click = fn;
|
||||||
|
},
|
||||||
|
destroy() {},
|
||||||
|
}),
|
||||||
|
picking: { resolvePickId: (picked) => picked },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await layer.update(viewer);
|
||||||
|
viewer.scene.pick = () => 'ghostmaps:cip:0:0';
|
||||||
|
click({ position: { x: 0, y: 0 } });
|
||||||
|
assert.equal(cards.at(-1).length, 1);
|
||||||
|
// The server answers the pinned version with a newer edition's bytes.
|
||||||
|
reports[0] = dated('Replacement', '11/2/2026');
|
||||||
|
await ageChip(layer, 30).onClick();
|
||||||
|
assert.equal(layer.getStats().count, 2);
|
||||||
|
assert.equal(
|
||||||
|
cards.at(-1).length,
|
||||||
|
0,
|
||||||
|
'a different report must not keep the old card',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('narrowing to a window the loaded data covers clears a failed wider reload', async (t) => {
|
||||||
|
let fail = false;
|
||||||
|
const { layer, viewer } = fixture(t, {
|
||||||
|
now: () => Date.UTC(2026, 10, 3, 12),
|
||||||
|
reports: [dated('Recent', '11/2/2026'), dated('Older', '10/20/2026')],
|
||||||
|
beforeFileReturns: () => {
|
||||||
|
if (fail) throw new Error('network down');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await layer.update(viewer);
|
||||||
|
fail = true;
|
||||||
|
await ageChip(layer, 30).onClick();
|
||||||
|
assert.ok(layer.getStats().error, 'the wider reload failed');
|
||||||
|
await ageChip(layer, 3).onClick();
|
||||||
|
assert.equal(layer.getStats().error, null);
|
||||||
|
assert.equal(layer.getStats().count, 1);
|
||||||
|
});
|
||||||
+103
-11
@@ -1,4 +1,4 @@
|
|||||||
// Pure helpers for GhostMaps placemarks: description tables to plain fields,
|
// Pure helpers for GhostMaps placemarks: report fields and event dates,
|
||||||
// KML colors, and card text wrapping. Strings in, data out — description
|
// KML colors, and card text wrapping. Strings in, data out — description
|
||||||
// markup is only ever read as text, never inserted into the page.
|
// markup is only ever read as text, never inserted into the page.
|
||||||
|
|
||||||
@@ -51,6 +51,106 @@ const MAX_FIELDS = 24;
|
|||||||
const MAX_VALUE = 600;
|
const MAX_VALUE = 600;
|
||||||
const MAX_LINKS = 6;
|
const MAX_LINKS = 6;
|
||||||
|
|
||||||
|
// Use the same leaf rows for report cards and event dates, without the card's
|
||||||
|
// field-count/value-length limits affecting the age filter.
|
||||||
|
function* descriptionRows(source) {
|
||||||
|
for (const row of source.matchAll(
|
||||||
|
/<tr\b[^>]*>((?:(?!<tr\b)[\s\S])*?)<\/tr\s*>/gi,
|
||||||
|
)) {
|
||||||
|
const cells = [
|
||||||
|
...row[1].matchAll(/<t[dh]\b[^>]*>([\s\S]*?)<\/t[dh]\s*>/gi),
|
||||||
|
].map((cell) => cell[1]);
|
||||||
|
if (cells.length === 2)
|
||||||
|
yield {
|
||||||
|
label: htmlToText(cells[0]),
|
||||||
|
value: htmlToText(cells[1]),
|
||||||
|
markup: cells[1],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const GHOSTMAPS_DEFAULT_AGE_DAYS = 7;
|
||||||
|
export const GHOSTMAPS_AGE_OPTIONS = Object.freeze([1, 3, 7, 14, 30, null]);
|
||||||
|
|
||||||
|
/** ArcGIS US dates (optionally with a time) and ISO dates, interpreted in UTC
|
||||||
|
* when no zone is supplied. A date without a time means the whole UTC day, so
|
||||||
|
* it resolves to the day's last millisecond: "1d" still shows yesterday's
|
||||||
|
* reports. Reject calendar rollovers instead of guessing. */
|
||||||
|
export function parseGhostMapsEventDate(value) {
|
||||||
|
const input = String(value ?? '').trim();
|
||||||
|
const us =
|
||||||
|
/^(\d{1,2})\/(\d{1,2})\/(\d{4})(?:\s+(\d{1,2}):(\d{2})(?::(\d{2}))?\s*(AM|PM))?$/i.exec(
|
||||||
|
input,
|
||||||
|
);
|
||||||
|
const iso =
|
||||||
|
/^(\d{4})-(\d{2})-(\d{2})(?:T(\d{2}):(\d{2})(?::(\d{2})(?:\.\d{1,3})?)?(?:Z|[+-]\d{2}:\d{2})?)?$/.exec(
|
||||||
|
input,
|
||||||
|
);
|
||||||
|
if (!us && !iso) return null;
|
||||||
|
const year = Number(us ? us[3] : iso[1]);
|
||||||
|
const month = Number(us ? us[1] : iso[2]);
|
||||||
|
const day = Number(us ? us[2] : iso[3]);
|
||||||
|
let hour = Number(us?.[4] ?? iso?.[4] ?? 0);
|
||||||
|
const minute = Number(us?.[5] ?? iso?.[5] ?? 0);
|
||||||
|
const second = Number(us?.[6] ?? iso?.[6] ?? 0);
|
||||||
|
if (us?.[7]) {
|
||||||
|
if (hour < 1 || hour > 12) return null;
|
||||||
|
hour = (hour % 12) + (us[7].toUpperCase() === 'PM' ? 12 : 0);
|
||||||
|
}
|
||||||
|
const at = Date.UTC(year, month - 1, day, hour, minute, second);
|
||||||
|
const date = new Date(at);
|
||||||
|
if (
|
||||||
|
year < 1900 ||
|
||||||
|
month < 1 ||
|
||||||
|
month > 12 ||
|
||||||
|
day < 1 ||
|
||||||
|
date.getUTCDate() !== day ||
|
||||||
|
hour > 23 ||
|
||||||
|
minute > 59 ||
|
||||||
|
second > 59
|
||||||
|
)
|
||||||
|
return null;
|
||||||
|
if (iso?.[4]) {
|
||||||
|
const zoned = /(?:Z|[+-]\d{2}:\d{2})$/.test(input) ? input : `${input}Z`;
|
||||||
|
const parsed = Date.parse(zoned);
|
||||||
|
return Number.isFinite(parsed) ? parsed : null;
|
||||||
|
}
|
||||||
|
return us?.[4] ? at : at + 86400_000 - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The event's date, never its report/upload date or dates mentioned in prose. */
|
||||||
|
export function ghostMapsEventDate(description) {
|
||||||
|
const rows = [
|
||||||
|
...descriptionRows(
|
||||||
|
String(description ?? '').replace(
|
||||||
|
/<(script|style|head)\b[\s\S]*?<\/\1\s*>/gi,
|
||||||
|
' ',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
for (const name of ['dateofevent', 'eventdate', 'date']) {
|
||||||
|
for (const { label, value } of rows) {
|
||||||
|
if (label.toLowerCase().replace(/[\s_]+/g, '') !== name) continue;
|
||||||
|
const at = parseGhostMapsEventDate(value);
|
||||||
|
if (at !== null) return at;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Unknown and future dates stay visible; only a confirmed old event expires. */
|
||||||
|
export function isGhostMapsEventExpired(
|
||||||
|
dateMs,
|
||||||
|
now = Date.now(),
|
||||||
|
ageDays = GHOSTMAPS_DEFAULT_AGE_DAYS,
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
ageDays !== null &&
|
||||||
|
Number.isFinite(dateMs) &&
|
||||||
|
dateMs < now - ageDays * 86400_000
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fields and links from a placemark description. ArcGIS "popup" exports are a
|
* Fields and links from a placemark description. ArcGIS "popup" exports are a
|
||||||
* two-column table (label, value); anything else becomes one unlabelled field.
|
* two-column table (label, value); anything else becomes one unlabelled field.
|
||||||
@@ -73,18 +173,10 @@ export function parseDescriptionFields(html) {
|
|||||||
};
|
};
|
||||||
// Innermost rows only: ArcGIS nests the field table inside an outer row,
|
// Innermost rows only: ArcGIS nests the field table inside an outer row,
|
||||||
// and only leaf rows with exactly two cells are label/value pairs.
|
// and only leaf rows with exactly two cells are label/value pairs.
|
||||||
for (const row of source.matchAll(
|
for (const { label, value, markup } of descriptionRows(source)) {
|
||||||
/<tr\b[^>]*>((?:(?!<tr\b)[\s\S])*?)<\/tr\s*>/gi,
|
|
||||||
)) {
|
|
||||||
const cells = [
|
|
||||||
...row[1].matchAll(/<t[dh]\b[^>]*>([\s\S]*?)<\/t[dh]\s*>/gi),
|
|
||||||
].map((cell) => cell[1]);
|
|
||||||
if (cells.length !== 2) continue;
|
|
||||||
const label = htmlToText(cells[0]);
|
|
||||||
const value = htmlToText(cells[1]);
|
|
||||||
if (!label || SKIPPED_LABELS.has(label.toLowerCase())) continue;
|
if (!label || SKIPPED_LABELS.has(label.toLowerCase())) continue;
|
||||||
if (EMPTY_VALUES.has(value.toLowerCase())) continue;
|
if (EMPTY_VALUES.has(value.toLowerCase())) continue;
|
||||||
addLinks(cells[1]);
|
addLinks(markup);
|
||||||
// A bare research link is carried by `links`; the card does not need
|
// A bare research link is carried by `links`; the card does not need
|
||||||
// the URL twice.
|
// the URL twice.
|
||||||
if (/^https?:\/\/\S+$/i.test(value)) continue;
|
if (/^https?:\/\/\S+$/i.test(value)) continue;
|
||||||
|
|||||||
@@ -7,8 +7,83 @@ import {
|
|||||||
kmlColor,
|
kmlColor,
|
||||||
parseDescriptionFields,
|
parseDescriptionFields,
|
||||||
wrapCardText,
|
wrapCardText,
|
||||||
|
ghostMapsEventDate,
|
||||||
|
isGhostMapsEventExpired,
|
||||||
|
parseGhostMapsEventDate,
|
||||||
} from './records.js';
|
} from './records.js';
|
||||||
|
|
||||||
|
test('event dates parse ArcGIS calendar dates and timestamps consistently in UTC', () => {
|
||||||
|
for (const [value, iso] of [
|
||||||
|
['10/28/2026', '2026-10-28T23:59:59.999Z'],
|
||||||
|
['10/28/2026 6:12:00 PM', '2026-10-28T18:12:00Z'],
|
||||||
|
['1/17/2026 9:46:41 AM', '2026-01-17T09:46:41Z'],
|
||||||
|
['10/28/2026 12:00 AM', '2026-10-28T00:00:00Z'],
|
||||||
|
['10/28/2026 12:00 PM', '2026-10-28T12:00:00Z'],
|
||||||
|
['2026-10-28', '2026-10-28T23:59:59.999Z'],
|
||||||
|
['2026-10-28T12:34:56', '2026-10-28T12:34:56Z'],
|
||||||
|
['2026-10-28T12:34:56.123-04:00', '2026-10-28T16:34:56.123Z'],
|
||||||
|
['2/29/2024', '2024-02-29T23:59:59.999Z'],
|
||||||
|
])
|
||||||
|
assert.equal(parseGhostMapsEventDate(value), Date.parse(iso), value);
|
||||||
|
for (const value of [
|
||||||
|
'',
|
||||||
|
'<Null>',
|
||||||
|
'N/A',
|
||||||
|
'unknown',
|
||||||
|
'2/29/2026',
|
||||||
|
'9/31/2026',
|
||||||
|
'2026-02-30',
|
||||||
|
'13/1/2026',
|
||||||
|
'1/0/2026',
|
||||||
|
'10/28/2026 13:00 PM',
|
||||||
|
'2026-10-28T24:00:00Z',
|
||||||
|
'2026-10-28T12:60:00Z',
|
||||||
|
]) {
|
||||||
|
assert.equal(parseGhostMapsEventDate(value), null, value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('event date fields take precedence over report dates and prose, even after long report tables', () => {
|
||||||
|
const row = (label, value) => `<tr><td>${label}</td><td>${value}</td></tr>`;
|
||||||
|
const html = `<table><tr><td><table>${
|
||||||
|
row('Date Of Report', '11/2/2026') +
|
||||||
|
row('Description', 'Incident mentioned on 11/1/2026') +
|
||||||
|
Array.from({ length: 30 }, (_, i) => row(`Field${i}`, 'value')).join('') +
|
||||||
|
row('Date', '10/30/2026') +
|
||||||
|
row('Date of Event', '10/28/2026')
|
||||||
|
}</table></td></tr></table>`;
|
||||||
|
assert.equal(ghostMapsEventDate(html), Date.UTC(2026, 9, 29) - 1);
|
||||||
|
assert.equal(
|
||||||
|
ghostMapsEventDate(
|
||||||
|
`<table>${row('Date', '<Null>')}${row('Date Of Report', '11/2/2026')}</table>`,
|
||||||
|
),
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
ghostMapsEventDate(`<table>${row('Date_Of_Event', '10/28/2026')}</table>`),
|
||||||
|
Date.UTC(2026, 9, 29) - 1,
|
||||||
|
);
|
||||||
|
assert.equal(ghostMapsEventDate('Occurred 10/28/2026'), null);
|
||||||
|
assert.equal(
|
||||||
|
ghostMapsEventDate(
|
||||||
|
`<script><table>${row('Date', '1/1/2020')}</table></script>`,
|
||||||
|
),
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('seven-day expiry keeps the exact boundary, future dates and unknown dates', () => {
|
||||||
|
const at = Date.UTC(2026, 10, 3, 12);
|
||||||
|
assert.equal(isGhostMapsEventExpired(Date.UTC(2026, 9, 27, 12), at), false);
|
||||||
|
assert.equal(
|
||||||
|
isGhostMapsEventExpired(Date.UTC(2026, 9, 27, 12) - 1, at),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
assert.equal(isGhostMapsEventExpired(Date.UTC(2026, 10, 4), at), false);
|
||||||
|
for (const value of [null, undefined, NaN])
|
||||||
|
assert.equal(isGhostMapsEventExpired(value, at), false);
|
||||||
|
});
|
||||||
|
|
||||||
// The shape ArcGIS Pro writes into every S2 placemark description.
|
// The shape ArcGIS Pro writes into every S2 placemark description.
|
||||||
const ARCGIS = `<html xmlns:fo="http://www.w3.org/1999/XSL/Format"><head>
|
const ARCGIS = `<html xmlns:fo="http://www.w3.org/1999/XSL/Format"><head>
|
||||||
<META http-equiv="Content-Type" content="text/html"></head>
|
<META http-equiv="Content-Type" content="text/html"></head>
|
||||||
@@ -27,6 +102,14 @@ const ARCGIS = `<html xmlns:fo="http://www.w3.org/1999/XSL/Format"><head>
|
|||||||
function changeImage(a, b) { document.getElementById('x').innerHTML = b; }
|
function changeImage(a, b) { document.getElementById('x').innerHTML = b; }
|
||||||
</script></html>`;
|
</script></html>`;
|
||||||
|
|
||||||
|
test('custom ages change the cutoff and all markers disables expiry', () => {
|
||||||
|
const at = Date.UTC(2026, 10, 3, 12);
|
||||||
|
const event = at - 10 * 86400_000;
|
||||||
|
assert.equal(isGhostMapsEventExpired(event, at, 7), true);
|
||||||
|
assert.equal(isGhostMapsEventExpired(event, at, 14), false);
|
||||||
|
assert.equal(isGhostMapsEventExpired(event, at, null), false);
|
||||||
|
});
|
||||||
|
|
||||||
test('ArcGIS popup tables become plain label/value fields and http links', () => {
|
test('ArcGIS popup tables become plain label/value fields and http links', () => {
|
||||||
const { fields, links } = parseDescriptionFields(ARCGIS);
|
const { fields, links } = parseDescriptionFields(ARCGIS);
|
||||||
assert.deepEqual(fields, [
|
assert.deepEqual(fields, [
|
||||||
|
|||||||
Vendored
+4
@@ -12,6 +12,9 @@ export function createControls({ state: layerState, services, parts, source }) {
|
|||||||
|
|
||||||
/** Release only Space Mission camera ownership, preserving layer and selection state. */
|
/** Release only Space Mission camera ownership, preserving layer and selection state. */
|
||||||
releaseCameraOwnership() {
|
releaseCameraOwnership() {
|
||||||
|
// Navigation takes the camera; a failed entry must not restore the old view.
|
||||||
|
layerState._entryCameraView = null;
|
||||||
|
layerState._cameraReleased = true;
|
||||||
parts.panel.clearMissionRosterHover();
|
parts.panel.clearMissionRosterHover();
|
||||||
parts.replay.stopMissionReplay();
|
parts.replay.stopMissionReplay();
|
||||||
parts.selection.stopMissionZoomAnchor();
|
parts.selection.stopMissionZoomAnchor();
|
||||||
@@ -57,6 +60,7 @@ export function createControls({ state: layerState, services, parts, source }) {
|
|||||||
orbitMatches: layerState._orbitMatches,
|
orbitMatches: layerState._orbitMatches,
|
||||||
lastUpdate: layerState._lastUpdate,
|
lastUpdate: layerState._lastUpdate,
|
||||||
error: layerState._lastError,
|
error: layerState._lastError,
|
||||||
|
errorAt: layerState._lastErrorAt,
|
||||||
stale: layerState._stale,
|
stale: layerState._stale,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|||||||
Loaded 100 of 138 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user