feat(cctv): add Quebec, Ottawa, and World feeds

This commit is contained in:
InoukT committed 2026-11-02 22:57:22 -05:00
1 parent 72ceeb2fce
commit fa4ce4b2a5
59 files changed
+40815 -88

No files matched your search

+5
View File
@@ -116,6 +116,7 @@ The optional Local ADS-B layer and the broadcast-FM receiver use radio you recei
- **Via Lietuva / Eismoinfo (Lithuania).** Public road-camera catalogs and periodically updated still images from [Eismoinfo](https://eismoinfo.lt/), credited to Via Lietuva / eismoinfo.lt in the app. **Off by default.** The camera endpoints are the eismoinfo.lt website backend and are not among the services listed in Via Lietuva's [open-data statement](https://eismoinfo.lt/eismoinfo-backend/content?id=44), whose conditions are attribution and reuse on the same terms. Until the statement is confirmed to cover the camera photos, the pack loads only when an operator sets `CCTV_LITHUANIA_ENABLED=1`; with the flag unset, empty or any other value, the server makes no request to Eismoinfo. `eismoinfo-backend/camera-info-table` is joined by ID to `layer-static-features/VKR`. Without `lks=true`, the latter returns WGS84 `[latitude, longitude]`; the table's `x/y` instead use LKS-94 metres. Only the official HTTPS `/eismoinfo-backend/image-provider/camera/last?id=…` image endpoint is registered. Catalogs use the shared 15-minute cache; frames are fetched on demand and never bundled or archived. These are periodic snapshots, not continuous video. Camera headings are low-confidence estimates. No API key is required. `CCTV_LITHUANIA_MAX_SOURCES` defaults to 400.
- **Freeway Bureau and Highway Bureau, MOTC (Taiwan).** National freeway cameras use the Freeway Bureau’s keyless [MOTC 2.0 CCTV table](https://tisvcloud.freeway.gov.tw/history/motc20/CCTV.xml), refreshed daily and fetched at runtime (never bundled). Freeway Bureau data follows the [Open Government Data License, Taiwan v1.0](https://data.gov.tw/license); the app credits the Bureau, names the dataset and links the licence. If that catalog is unreachable and no last-good source list exists, the app falls back to the Highway Bureau’s free [provincial-road CCTV XML feed](https://thbapp.thb.gov.tw/opendata/), with attribution as requested by the Bureau. Its MJPEG streams are accepted only from cctv-ss01 through cctv-ss08.thb.gov.tw over HTTPS; the frame proxy extracts one bounded JPEG per request. This fallback covers provincial roads, not national freeways. Neither feed requires an API key. CCTV_TAIWAN_ENABLED=0 disables both; CCTV_TAIWAN_MAX_SOURCES defaults to 600 (bounded to 1–2000).
- **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 680 MTQ cameras play the operator's five-second MP4 clip directly in the browser from `www.quebec511.info/Carte/Fenetres/camera.ashx?id=<id>&format=mp4`; 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. 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.
- **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)).
- **OpenSky Network.** Its license is **non-commercial**, and operational use of the REST API in a live product can require a prior written agreement with OpenSky — even for non-profit/government use. If you deploy this commercially, contact OpenSky for your own terms. The flights layer is a toggle and runs anonymously by default.
@@ -162,6 +163,8 @@ The optional Local ADS-B layer and the broadcast-FM receiver use radio you recei
- **DelDOT live video cameras.** The keyless `tmc.deldot.gov/json/videocamera.json` catalog supplies active cameras and official `https://video.deldot.gov/live/…/playlist.m3u8` links. The local server transiently buffers bounded MPEG-TS segments in memory and deletes them on session release or idle expiry. No recordings are retained. `CCTV_DELDOT_ENABLED=0` disables the pack. Public availability is not a blanket redistribution license; deployment beyond personal local viewing needs confirmation of applicable DelDOT terms.
- **Fintraffic weathercams.** The camera list is one keyless GeoJSON request to `tie.digitraffic.fi/api/weathercam/v1/stations` (809 stations / 2,275 presets), sent with the `Digitraffic-User: gods-eye-view` header the service asks clients to identify themselves with and with gzip; frames are stills on `weathercam.digitraffic.fi`, refreshed on each station's 600 s collection interval, which is also the client's ambient-still refresh cadence for this pack (the active camera keeps the usual 10-second refresh). One preset (a fixed camera view) is one camera; stations not `GATHERING` and presets not `inCollection` are dropped. Attribution is required by the CC BY 4.0 licence and is registered in the Data attribution popover. `CCTV_FINTRAFFIC_ENABLED=0` is the kill switch.
- **DriveBC highway cameras.** The camera list comes from the keyless `https://www.drivebc.ca/api/webcams/` endpoint served by the DriveBC.ca site ([bcgov/DriveBC.ca](https://github.com/bcgov/DriveBC.ca)); frames come from `https://www.drivebc.ca/images/<id>.jpg`, built from the numeric camera id. DataBC's [DriveBC HighwayCams](https://catalogue.data.gov.bc.ca/dataset/bc-highwaycams) catalogue entry publishes the same cameras under the Open Government Licence – British Columbia, but its CSV still lists the retired `images.drivebc.ca` frame URLs, which now return a placeholder. The licence's attribution statement is required and is registered in the Data attribution popover. By default the 250 cameras nearest Vancouver and Victoria load; `CCTV_DRIVEBC_MAX_SOURCES` changes the cap and `CCTV_DRIVEBC_ENABLED=0` turns the pack off. Some cameras in the feed are supplied by partners (TransLink, the City of Vancouver, the City of Surrey, Parks Canada / Alberta Motor Association and others); the feed's per-camera credit is carried onto the camera record and shown beside the provider in the CCTV panel.
- **City of Ottawa traffic cameras.** The keyless `traffic.ottawa.ca/map/camera_list` JSON supplies an id, coordinates, an English/French description and a type; frames are city-served JPEG stills built from the id at `traffic.ottawa.ca/map/camera?id=<id>`, and only that directory is pinned. 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 fetched at request time and never stored. The city publishes no explicit reuse licence for this feed; deployment beyond personal local viewing needs confirmation of applicable terms. `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 166 Icelandic Road and Coastal Administration cameras, 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 server validates and pins each image URL to its provider path. [Vegagerðin's data-service licence](https://www.vegagerdin.is/vegagerdin/gagnasafn/vefthjonustur/terms-and-conditions) permits reuse with attribution but excludes third-party rights; [ODOT allows TripCheck image use and requests credit](https://www.tripcheck.com/Pages/Frequently-Asked-Questions); MLIT's [site terms](https://www.river.go.jp/kawabou/kwb_apend/html/caution.html) are CC BY-compatible with attribution and warn against high-volume/periodic collection, so those frames refresh at ten minutes per camera. Images are not bundled or persisted; a bounded 32 MB in-memory cache reuses them until the per-source cadence. The 249 NZTA Journey Planner rows are excluded because its [website terms prohibit scraping](https://www.journeys.nzta.govt.nz/about-this-site/terms-of-use); use an authorized API feed or obtain permission before adding those rows.
- **TxDOT ITS cameras.** The catalog comes from the keyless `its.txdot.gov/its/DistrictIts/GetCctvStatusListByDistrict` endpoint, one call per district. Frames come from the per-camera `GetCctvSnapshotByIcdId` endpoint, which answers JSON carrying a base64 JPEG rather than an image body; the proxy decodes it only for responses from the official TxDOT origin and validates the JPEG header before serving. Only cameras reporting `Device Online` are registered. Districts default to Austin and San Antonio (`CCTV_TXDOT_DISTRICTS`; 25 codes exist statewide); `CCTV_TXDOT_MAX_SOURCES` caps the pack and `CCTV_TXDOT_ENABLED=0` turns it off. Frames are fetched live at request time and are never stored or redistributed.
- **Tallinn ristmikud.** Intersection camera stills are fetched live from `ristmikud.tallinn.ee` (`/last/camNNN.jpg`). The curated catalog (`config/cctv_sources.tallinn.json`) supplies coordinates and heading priors; only server-registered ristmikud HTTPS URLs are proxied. Frames are fetched at request time and never stored or redistributed. Disable with `CCTV_TALLINN_ENABLED=0`.
- **Tarktee / Transpordiamet.** Estonia road-weather camera locations and current JPEG URLs come from keyless DATEX2 endpoints on `tarktee.transpordiamet.ee` (`/api/v1/datex/roadCameraLocations` + `roadCameraImages`). Frame URLs rotate with each capture; the 15-minute CCTV source cache refreshes them. Only `https://tarktee.transpordiamet.ee/images/…` URLs are registered. Disable with `CCTV_TARKTEE_ENABLED=0`.
@@ -203,6 +206,8 @@ The [Bhote Koshi event pack](public/events/bhote-koshi-2026/README.md), under `p
| **Datacenters** (4,351) | `datacenters/` | **ODbL 1.0** (OpenStreetMap extract) | ✅ (attribution + share-alike on data) | © OpenStreetMap contributors (shared credit) |
| **Dams** (704) | `dams/` | **ODbL 1.0** (OpenInfraMap / OSM extract) | ✅ (attribution + share-alike on data) | © OpenStreetMap contributors (shared credit) + Open Infrastructure Map |
| **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,396 locations) | `config/cctv_sources.world-image-cameras.json` | Mixed: Vegagerðin open-government licence, ODOT TripCheck public images, and MLIT site content 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, Oregon Department of Transportation, and Japan Ministry of Land, Infrastructure, Transport and Tourism (MLIT) |
| **TeleGeography Submarine Cable Map** (712 cables + 1,917 landing points) | `telegeography_submarine_cables/` | **CC BY-NC-SA 3.0** | ❌ **NonCommercial — remove for commercial use** | "© TeleGeography — submarinecablemap.com" |
| **Natural Earth physical regions** (1,046 land + 292 marine named polygons) | `natural_earth/` | **Public domain** | ✅ (no restrictions) | "Made with Natural Earth" (courtesy credit — not legally required) |
| **DataSF Analysis Neighborhoods** (41 SF neighborhood polygons) | `neighborhoods/` | **PDDL 1.0** (public domain) | ✅ (no restrictions) | "City & County of San Francisco — DataSF" (courtesy — not legally required) |
+1 -1
View File
@@ -435,7 +435,7 @@ Twenty layers and map sources. **Eighteen have a keyless path.** Some offer addi
| 🌍 **Earthquakes** | Global seismic activity, last 24h | USGS | 🟢 |
| ◍ **Air quality** | Live AQHI on the published 1–10+ health-risk scale, with a Weather-panel card showing the band legend and the highest readings in view. **Canada only** — ECCC is the first registered provider | ECCC MSC GeoMet | 🟢 |
| 🚗 **Traffic** | Simulated vehicles on OSM roads. With TomTom, live flow speeds drive the simulation and congestion colors below ~8 km; individual vehicle positions are not live observations | TomTom + OSM | 🟢 simulation · 🟡 live flow speeds |
| 📹 **CCTV Mesh** | ~4,000 public cameras (shared cap) projected _into_ the 3D space — Austin · Texas (TxDOT) · California (Caltrans) · London (TfL) · Ontario (511, free developer key) · Finland (Fintraffic) · British Columbia (DriveBC) · Estonia (Tallinn, Tarktee) · Delaware (DelDOT live video) · New South Wales (Live Traffic NSW) · Calgary · Norway (Statens vegvesen) · Toronto · King County (WA) · Washington (WSDOT) · Catalonia (Servei Català de Trànsit) · Hong Kong (Transport Department) · Taiwan (Freeway Bureau, MOTC) · Italy (A22 Brennero motorway), plus opt-in New York City (NYC DOT TMC, needs your own NYC DOT agreement), opt-in Lithuania (Via Lietuva / Eismoinfo, photo licence unconfirmed) and opt-in Florida (FL511, individual personal use only; other use needs FDOT's written consent). Positions are published; poses are estimated priors **you calibrate by dragging a gizmo on the camera itself** | City APIs | 🟢 |
| 📹 **CCTV Mesh** | ~7,516 camera locations (4,000 shared slots + 1,120 Québec + 2,396 World image feeds) 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) · Québec (MTQ browser MP4, Montréal locations, regional webcams) · Iceland (Vegagerðin) · 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) and opt-in Florida (FL511, individual personal use only; other use needs FDOT's written consent). Positions are published or attributed; poses are estimated priors **you calibrate by dragging a gizmo on the camera itself** | City APIs | 🟢 |
| 📷 **Mapped ALPR Cameras** | License-plate-reader camera locations tagged by OpenStreetMap contributors, loaded one city-sized view at a time, with **SHOW NEAREST**. Locations and tags only: no plate data, no video | OpenStreetMap (incl. DeFlock mapping) | 🟢 |
| 📷 **Street Level** | Street-level photos behind one panel, one chip per provider: coverage on the streets, image cones, an embedded viewer with full-screen **EXPAND**. Mapillary, Google Street View, KartaView and Panoramax | Mapillary · Google · KartaView · Panoramax | 🟢 KartaView, Panoramax · 🟡 Mapillary token · 🔴 Google Maps key |
| 📻 **Radio** | Geolocated world radio with an **analog tuner** — drag the needle across up to 750 stations and the globe flies to each broadcaster | Radio Browser / broadcasters | 🟢 |
@@ -1,3 +1,6 @@
- Add a keyless Highway Bureau fallback for Taiwan CCTV when the Freeway Bureau
catalog is unavailable, keeping official camera coordinates and serving
bounded snapshots from MJPEG feeds (InoukT, #1506).
- Expand CCTV coverage with a keyless Taiwan Highway Bureau fallback, Québec
camera catalog and map preview, Ottawa traffic cameras, and 2,396 curated
image-backed cameras from Iceland, Oregon, and Japan (InoukT, #1506).
- Keep Québec 511 video browser-only, enforce provider-specific frame refresh
limits, and report recognized MLIT maintenance images as degraded instead of
healthy camera snapshots.
File diff suppressed because it is too large. Load diff
+24 -9
View File
@@ -1,8 +1,9 @@
# CCTV integration workflow
Use this workflow when adding a camera provider or repairing an existing feed.
The CCTV layer gets its catalog and media through the local server; keep
provider-specific network access and URL policy on the server side.
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
@@ -90,11 +91,23 @@ Catalog and media loaders must also:
- avoid persistent frame storage unless the provider's terms explicitly allow
it and the design has been reviewed.
For live media, use the existing media proxy and supported feed type (`mjpeg`,
`mp4`, `webm`, or `hls`) rather than adding a direct browser stream. Validate
the upstream content type and bound or cancel streams according to the
existing proxy behavior. Read [SECURITY.md](../SECURITY.md) before changing
URL validation, redirects, or proxy behavior.
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. 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
@@ -109,8 +122,10 @@ and fixtures. Cover at least:
- 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. Run the
focused provider and proxy tests, then the standard checks from
`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
+61 -7
View File
@@ -1322,9 +1322,10 @@ from the payload. There is no heading in the feed, so gizmos use the shared
id-hash fallback at low confidence.
Once enabled, the 250 cameras nearest the five borough cores load.
`CCTV_NYC_MAX_SOURCES` sets the pack cap (8–400). The catalog-wide ceiling stays at 4,000 and is shared
round-robin: no region is dropped outright, but when the packs together exceed
the ceiling, adding a pack trims cameras from the largest packs.
`CCTV_NYC_MAX_SOURCES` sets the pack cap (8–400). The default catalog limit is
7,516: 4,000 shared round-robin slots, 1,120 reserved Québec locations, and
2,396 reserved World image sources. Lower explicit `CCTV_MAX_SOURCES` values
are honored.
## City of Toronto RESCU CCTV source pack
@@ -1340,8 +1341,9 @@ cameras return a 200 "Image Unavailable" placeholder frame instead of an error.
By default all ~336 cameras load, nearest downtown first.
`CCTV_TORONTO_MAX_SOURCES` sets the pack cap (8–500) and
`CCTV_TORONTO_ENABLED=0` turns the pack off. The pack shares the 4,000
catalog-wide ceiling round-robin with the other packs.
`CCTV_TORONTO_ENABLED=0` turns the pack off. The pack shares the default
7,516-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
@@ -5019,8 +5021,9 @@ silently demoting every later lookup for the session.
kill switch `CCTV_FINTRAFFIC_ENABLED=0`) are the fourth pack: one keyless GeoJSON station list
covering the whole country, where one _preset_ (a station's fixed view) is one camera — 806
GATHERING stations carry 2,256 in-collection presets, prioritized to 300 against seven anchors
on the main road spine. `CCTV_MAX_SOURCES` is a 4,000 catalog-wide ceiling shared round-robin
across packs, so a lower global cap thins every region instead of starving the last pack.
on the main road spine. `CCTV_MAX_SOURCES` defaults to 7,516: 4,000 shared
round-robin slots plus 3,516 reserved Québec and World image slots. Lower
caps fill reservations first; at 3,516 or less, no shared slots remain.
~1,100 cameras total, all RAW PRIOR poses, stills-first. The catalog lives for 15 minutes; once
it expires, `/api/cctv` keeps answering from it while one background refresh reloads every
pack, so only the very first load waits for the slowest pack (a live first load took about
@@ -6567,3 +6570,54 @@ the container healthy. Checks time out after 10 seconds, with a 30-second
startup grace period and three consecutive failures before reporting unhealthy.
This reports app HTTP availability, not individual provider health, and does not
configure automatic restarts.
## Québec CCTV integration
The regular CCTV source loader reads `public/quebec-cameras.json` by default
when `CCTV_QUEBEC_ENABLED` is not `0`. The pack contains 1,120 records: 680
MTQ highway cameras, 426 Montréal OSM camera locations, and 14 scenic webcams.
The default catalog limit is 7,516: it reserves all 1,120 Québec locations and
2,396 World image feeds, and keeps 4,000 slots for the existing round-robin
camera packs. A lower explicit `CCTV_MAX_SOURCES` value is still honored and
can trim the reserved packs if it is below their combined size.
Selecting an MTQ camera opens the official Québec 511 MP4 in a floating 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. 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. The one-camera `/quebec511-camera-preview.html` page remains for
source review; Québec cameras now load through the normal CCTV layer.
## 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 166 Vegagerðin,
1,176 Oregon TripCheck, and 1,054 MLIT river cameras. Duplicate Canadian feeds
are excluded because DriveBC, Ottawa, and Québec 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. Iceland and 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
View File
@@ -26,7 +26,7 @@
with the sources: src/materialSymbolsSubset.test.mjs fails when a source
names a glyph this list is missing, because a missing glyph does not draw
a box — the ligature never forms and the element renders the literal word. -->
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20,400,0,0&icon_names=adjust,arrow_drop_down,arrow_forward,arrow_left,arrow_right,bolt,chevron_left,chevron_right,close,close_fullscreen,dark_mode,draw,east,flare,flight,handyman,layers,layers_clear,light_mode,local_fire_department,location_on,more_horiz,movie,my_location,navigation,normal,on,open_in_full,partly_cloudy_day,pentagon,public,radar,radio,right_panel_close,right_panel_open,rocket_launch,satellite_alt,search,settings,skip_next,skip_previous,streetview,timeline,tune,upload_file,videocam,view_in_ar,visibility,visibility_off" rel="stylesheet" />
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20,400,0,0&icon_names=adjust,arrow_drop_down,arrow_forward,arrow_left,arrow_right,bolt,chevron_left,chevron_right,close,close_fullscreen,dark_mode,draw,east,flare,flight,handyman,layers,layers_clear,light_mode,local_fire_department,location_on,more_horiz,movie,my_location,navigation,normal,on,open_in_full,partly_cloudy_day,pentagon,public,radar,radio,right_panel_close,right_panel_open,rocket_launch,satellite_alt,search,settings,skip_next,skip_previous,streetview,timeline,traffic,tune,upload_file,videocam,view_in_ar,visibility,visibility_off" rel="stylesheet" />
</head>
<body>
<div class="gev-view" data-gev-view="scene-chrome"></div>
+1
View File
@@ -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
+151
View File
@@ -0,0 +1,151 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="dark" />
<title>Québec 511 · Camera 3735 preview</title>
<style>
:root {
color-scheme: dark;
font-family: Inter, 'Segoe UI', sans-serif;
background: #060a0e;
color: #e7f5fb;
}
* {
box-sizing: border-box;
}
body {
min-height: 100vh;
margin: 0;
padding: clamp(20px, 5vw, 56px);
background:
radial-gradient(
ellipse at 12% 0%,
rgba(0, 181, 216, 0.13),
transparent 45%
),
#060a0e;
}
main {
width: min(100%, 1024px);
margin: 0 auto;
}
.eyebrow,
.meta,
.notice,
.source-link {
font-family: 'Cascadia Code', Consolas, monospace;
}
.eyebrow {
margin: 0 0 12px;
color: #5bd8f2;
font-size: 12px;
letter-spacing: 0.16em;
text-transform: uppercase;
}
h1 {
margin: 0;
font-size: clamp(28px, 5vw, 48px);
line-height: 1.08;
font-weight: 650;
}
.meta {
margin: 14px 0 28px;
color: #92a9b4;
font-size: 13px;
}
.player {
overflow: hidden;
border: 1px solid rgba(91, 216, 242, 0.3);
border-radius: 14px;
background: #020405;
box-shadow: 0 20px 70px rgba(0, 0, 0, 0.45);
}
video {
display: block;
width: 100%;
aspect-ratio: 16 / 9;
background: #020405;
}
.notice {
margin: 18px 0;
padding: 14px 16px;
border-left: 2px solid #e9b95f;
background: rgba(233, 185, 95, 0.08);
color: #d8cba9;
font-size: 12px;
line-height: 1.6;
}
.source-link {
display: inline-flex;
align-items: center;
gap: 8px;
color: #71def5;
font-size: 13px;
text-decoration: none;
}
.source-link:hover {
color: #c2f5ff;
text-decoration: underline;
}
.source-link:focus-visible {
outline: 2px solid #71def5;
outline-offset: 4px;
}
</style>
</head>
<body>
<main>
<p class="eyebrow">God’s Eye View · Single-camera preview</p>
<h1>Route 136 at Rue Panet</h1>
<p class="meta">MTQ camera 3735 · Montréal · 45.51651, −73.55020</p>
<section class="player" aria-label="Québec 511 camera video">
<video controls autoplay muted playsinline preload="metadata">
<source
src="https://www.quebec511.info/Carte/Fenetres/camera.ashx?id=3735&format=mp4"
type="video/mp4"
/>
Your browser cannot play this Québec 511 camera feed.
</video>
</section>
<p class="notice">
This page plays the official feed in the browser. God’s Eye shows a
separate paused browser video at the camera’s map position; Québec 511
does not grant pixel access, so it is not a Cesium texture. Location
data: Ministère des Transports et de la Mobilité durable,
<a
href="https://www.donneesquebec.ca/recherche/dataset/camera-de-circulation"
>Caméra de circulation (CC BY 4.0)</a
>.
</p>
<a class="source-link" href="/?welcome=0">
Open God’s Eye CCTV cameras <span aria-hidden="true">↗</span>
</a>
<br />
<a
class="source-link"
href="https://www.quebec511.info/Carte/Fenetres/FenetreVideo.html?id=3735"
target="_blank"
rel="noopener noreferrer"
>
Open official Québec 511 viewer <span aria-hidden="true">↗</span>
</a>
</main>
</body>
</html>
+10 -1
View File
@@ -341,6 +341,9 @@
"server/providers/cctv/catalog.js",
"server/providers/cctv/lithuania.js",
"server/providers/cctv/taiwan.js",
"server/providers/cctv/quebec.js",
"server/providers/cctv/ottawa.js",
"server/providers/cctv/worldCameras.js",
"server/providers/cctv/constants.js",
"server/providers/cctv/media.js",
"server/providers/cctv/normalize.js",
@@ -754,6 +757,7 @@
"cctv-controls": {
"exports": ["./ui/cctv"],
"modules": [
"src/data/iconOrientation.js",
"src/data/headingDegrees.js",
"src/ui/cctv.js",
"src/ui/cctvBindings.js",
@@ -762,6 +766,7 @@
"src/ui/cctvFrames.js",
"src/ui/cctvVideo.js",
"src/ui/cctvPresentation.js",
"src/ui/quebecCctvOverlay.js",
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/ar.json",
@@ -781,7 +786,7 @@
"src/ui/stores/status.js",
"src/ui/i18n.js"
],
"external": ["vue", "pinia", "vue-i18n"]
"external": ["vue", "pinia", "vue-i18n", "cesium"]
},
"context-controls": {
"exports": ["./ui/context", "./ui/context/policy"],
@@ -951,6 +956,8 @@
"src/ui/cctvFrames.js",
"src/ui/cctvVideo.js",
"src/ui/cctvPresentation.js",
"src/data/iconOrientation.js",
"src/ui/quebecCctvOverlay.js",
"src/ui/clearLayersControl.js",
"src/ui/cockpit.js",
"src/ui/cockpitBriefing.js",
@@ -1131,6 +1138,7 @@
"src/ui/styles/layers.css",
"src/ui/styles/radio.css",
"src/ui/styles/cctv.css",
"src/ui/styles/quebecCctvOverlay.css",
"src/ui/styles/scenes.css",
"src/ui/styles/bhote-koshi.css",
"src/ui/styles/recording.css",
@@ -2714,6 +2722,7 @@
"src/ui/cctvFrames.js",
"src/ui/cctvVideo.js",
"src/ui/cctvPresentation.js",
"src/ui/quebecCctvOverlay.js",
"src/ui/clearLayersControl.js",
"src/ui/cockpit.js",
"src/ui/cockpitBriefing.js",
+144 -5
View File
@@ -1,4 +1,5 @@
import { createCctvCatalog } from './cctv/catalog.js';
import { quebec511VideoUrl } from './cctv/quebec.js';
import { normalizeFeedType, isVideoFeedType } from './cctv/normalize.js';
import {
buildSyntheticCctvSvg,
@@ -18,6 +19,7 @@ import { sanitizeCctvRangeHeader } from './cctv/range.js';
import { admitSameSiteKeyless } from './common/same-site.js';
import { PROXY_SIGNALS } from '../../src/localRequestGate.mjs';
import { createHlsPuller } from './cctv/stream.js';
import { isMlitRiverAdjustmentImage } from './cctv/worldCameras.js';
import { googleServerApiKey } from './places/google-key.js';
export { CCTV_FRAME_FETCH_TIMEOUT_MS, fetchCctvImageFromUpstream };
/**
@@ -43,6 +45,105 @@ export function cctvProxy({ sourceRoot = process.cwd() } = {}) {
const HEALTH_MAX_ENTRIES = CCTV_MAX_SOURCES_CEILING;
/** Live HLS strategies (see ./cctv/stream.js). Shared across dev and preview. */
const puller = createHlsPuller();
/** Bounded transient image reuse keeps World providers inside their cadence. */
const WORLD_FRAME_CACHE_MAX_ENTRIES = 128;
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;
}
}
async function fetchWorldCameraFrame(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 nextAllowedAt = worldFrameNextAllowedAt.get(cameraId) || 0;
if (now < nextAllowedAt) {
return cached
? {
ok: true,
body: cached.body,
contentType: cached.contentType,
stale: true,
}
: null;
}
worldFrameNextAllowedAt.delete(cameraId);
worldFrameNextAllowedAt.set(cameraId, now + refreshMs);
while (worldFrameNextAllowedAt.size > 4096) {
worldFrameNextAllowedAt.delete(
worldFrameNextAllowedAt.keys().next().value,
);
}
let request;
request = fetchCctvImageFromUpstream(frameUrl)
.then((image) => {
if (image?.ok) {
storeWorldFrame(cameraId, image);
return image;
}
return cached
? {
ok: true,
body: cached.body,
contentType: cached.contentType,
stale: true,
}
: image;
})
.finally(() => {
if (worldFrameInflight.get(cameraId) === request)
worldFrameInflight.delete(cameraId);
});
worldFrameInflight.set(cameraId, request);
return request;
}
/** Update the health entry for a camera, evicting the oldest entry if at capacity. */
const setHealth = (cameraId, patch) => {
@@ -164,6 +265,7 @@ export function cctvProxy({ sourceRoot = process.cwd() } = {}) {
provider: source.provider,
lat: source.lat,
lon: source.lon,
frameRefreshMs: source.frameRefreshMs,
headingDeg: source.headingDeg,
headingConfidence: source.headingConfidence || '',
pitchDeg: source.pitchDeg,
@@ -174,6 +276,13 @@ export function cctvProxy({ sourceRoot = process.cwd() } = {}) {
feedType: normalizeFeedType(source.feedType),
sourceKind:
source.sourceKind || (source.url ? 'configured' : 'fallback'),
sourceUrl: source.sourceUrl || '',
browserMediaUrl:
source.sourceKind === 'quebec-511-browser'
? quebec511VideoUrl(source.id)
: '',
accuracyM: source.accuracyM,
positionSource: source.positionSource || '',
poseSource: source.poseSource,
model: source.model || '',
manufacturer: source.manufacturer || '',
@@ -526,15 +635,45 @@ export function cctvProxy({ sourceRoot = process.cwd() } = {}) {
: '');
const upstreamImage =
source?.sourceKind === 'txdot-its'
? await fetchTxdotSnapshot(upstreamCandidate)
: await fetchCctvImageFromUpstream(upstreamCandidate);
source?.pack === 'world-cameras'
? await fetchWorldCameraFrame(source, cameraId, upstreamCandidate)
: source?.sourceKind === 'txdot-its'
? await fetchTxdotSnapshot(upstreamCandidate)
: await fetchCctvImageFromUpstream(upstreamCandidate);
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?.ok) {
setHealth(cameraId, {
status: 'ok',
status: upstreamImage.stale ? 'degraded' : 'ok',
sourceKind: 'snapshot',
label: source?.provider || 'Configured source',
message: 'Upstream snapshot active',
message: upstreamImage.stale
? 'Serving last snapshot; refresh failed'
: upstreamImage.cached
? 'Cached snapshot active'
: 'Upstream snapshot active',
});
res.writeHead(200, {
'Content-Type': upstreamImage.contentType,
+35 -10
View File
@@ -40,30 +40,53 @@ export function allocateSourceCap(packs, maxCount) {
owner.set(source.id, { pack: pack.name, source });
}
}
const lanes = packs.map((pack) => ({
name: pack.name,
const lanes = packs.map((pack) => {
// Only the record that WON its id (the last occurrence anywhere, so also
// the last within this pack) takes a lane slot; earlier duplicates never
// count against the cap.
queue: pack.sources.filter(
const queue = pack.sources.filter(
(source) => source?.id && owner.get(source.id)?.source === source,
),
next: 0,
kept: 0,
}));
);
return {
name: pack.name,
queue,
reserved: Math.max(
0,
Math.min(queue.length, Math.floor(Number(pack.reservedCount) || 0)),
),
next: 0,
kept: 0,
};
});
const total = lanes.reduce((sum, lane) => sum + lane.queue.length, 0);
const limit = Math.min(total, Math.max(0, Math.floor(maxCount)));
const sources = [];
const reservedSources = [];
if (limit >= total) {
for (const lane of lanes) {
sources.push(...lane.queue);
lane.kept = lane.queue.length;
}
} else {
while (sources.length < limit) {
// Some complete regional catalogs are intentionally guaranteed. Consume
// their requested slots first, then distribute the remaining catalog
// capacity with the existing round-robin policy.
while (sources.length + reservedSources.length < limit) {
let tookReserved = false;
for (const lane of lanes) {
if (sources.length >= limit) break;
if (sources.length + reservedSources.length >= limit) break;
if (lane.kept >= lane.reserved) continue;
reservedSources.push(lane.queue[lane.next]);
lane.next += 1;
lane.kept += 1;
tookReserved = true;
}
if (!tookReserved) break;
}
while (sources.length + reservedSources.length < limit) {
for (const lane of lanes) {
if (sources.length + reservedSources.length >= limit) break;
if (lane.next >= lane.queue.length) continue;
sources.push(lane.queue[lane.next]);
lane.next += 1;
@@ -72,7 +95,9 @@ export function allocateSourceCap(packs, maxCount) {
}
}
return {
sources,
// Keep the shared round-robin sequence first so the default active camera
// remains in its established region; guaranteed regional rows follow it.
sources: [...sources, ...reservedSources],
packs: lanes.map((lane) => ({
name: lane.name,
offered: lane.queue.length,
+28 -2
View File
@@ -1,11 +1,18 @@
import fs from 'node:fs';
import path from 'node:path';
import { DEFAULT_CCTV_SOURCE_FILE, CCTV_SOURCE_CACHE_MS } from './constants.js';
import {
DEFAULT_CCTV_SOURCE_FILE,
CCTV_SOURCE_CACHE_MS,
WORLD_CCTV_SOURCE_COUNT,
} from './constants.js';
import { allocateSourceCap, resolveCatalogCap } from './cap.js';
import { loadGroundHeights, joinGroundHeights } from './groundHeights.js';
import { cameraRegion, normalizeSourceItem } from './normalize.js';
import { loadLithuaniaSources } from './lithuania.js';
import { loadOttawaSourcesFromOpenData } from './ottawa.js';
import { loadQuebecSources, QUEBEC_CAMERA_SOURCE_COUNT } from './quebec.js';
import { loadTaiwanSources } from './taiwan.js';
import { loadWorldCameraSources } from './worldCameras.js';
import {
loadAustinSourcesFromOpenData,
loadCaltransSourcesFromOpenData,
@@ -74,6 +81,11 @@ const LIVE_PACKS = [
enabled: () => envEnabled('CCTV_DRIVEBC_ENABLED'),
load: loadDriveBcSourcesFromOpenData,
},
{
name: 'ottawa',
enabled: () => envEnabled('CCTV_OTTAWA_ENABLED'),
load: loadOttawaSourcesFromOpenData,
},
{
name: 'txdot',
enabled: () => envEnabled('CCTV_TXDOT_ENABLED'),
@@ -166,6 +178,18 @@ const LIVE_PACKS = [
enabled: () => envEnabled('CCTV_TAIWAN_ENABLED'),
load: loadTaiwanSources,
},
{
name: 'quebec',
enabled: () => envEnabled('CCTV_QUEBEC_ENABLED'),
load: loadQuebecSources,
reservedCount: QUEBEC_CAMERA_SOURCE_COUNT,
},
{
name: 'world-cameras',
enabled: () => envEnabled('CCTV_WORLD_CAMERAS_ENABLED'),
load: loadWorldCameraSources,
reservedCount: WORLD_CCTV_SOURCE_COUNT,
},
{
name: 'a22',
enabled: () => envEnabled('CCTV_A22_ENABLED'),
@@ -310,7 +334,7 @@ export function createCctvCatalog({ sourceRoot = process.cwd() } = {}) {
: [];
// Live packs first so file/env overrides win on duplicate IDs; each pack
// keeps its own priority order and the catalog cap is shared fairly.
const normalizePack = (name, items) => ({
const normalizePack = (name, items, reservedCount = 0) => ({
name,
sources: items
.filter((item) => item && typeof item === 'object')
@@ -320,6 +344,7 @@ export function createCctvCatalog({ sourceRoot = process.cwd() } = {}) {
? items.available
: items.length,
region: items.region ?? null,
reservedCount,
});
const packs = [
...LIVE_PACKS.map((pack, index) =>
@@ -328,6 +353,7 @@ export function createCctvCatalog({ sourceRoot = process.cwd() } = {}) {
liveResults[index]?.status === 'fulfilled'
? liveResults[index].value
: [],
pack.reservedCount,
),
),
normalizePack('file', fromFile),
+24 -7
View File
@@ -5,15 +5,13 @@ export const DEFAULT_AUSTIN_ROWS_URL =
/** Default cap on Austin cameras after distance-based prioritization. */
export const DEFAULT_AUSTIN_MAX_SOURCES = 250;
/**
* Catalog-wide safety ceiling on served cameras. Each pack already caps
* itself (nearest-to-anchor first); this bound only matters when the packs
* together exceed it, and it is then filled round-robin across packs (see
* cap.js) so no region is silently dropped. The default per-pack caps
* together can exceed it, so a default install may be thinned evenly here.
* Catalog-wide default: preserve 4,000 shared slots and reserve 1,120 Québec
* plus 2,396 World image locations. Shared packs still split their slots
* round-robin (see cap.js).
*/
export const DEFAULT_CCTV_MAX_SOURCES = 4000;
export const DEFAULT_CCTV_MAX_SOURCES = 7516;
/** Hard upper bound for CCTV_MAX_SOURCES; also sizes the health map. */
export const CCTV_MAX_SOURCES_CEILING = 5000;
export const CCTV_MAX_SOURCES_CEILING = 8000;
/** Reference point for Austin camera prioritization (Congress & 6th). */
export const AUSTIN_DOWNTOWN = { lat: 30.2672, lon: -97.7431 };
/** Caltrans CCTV: one JSON feed per district, identical schema statewide. */
@@ -197,6 +195,25 @@ export const DRIVEBC_ANCHORS = [
{ lat: 49.2827, lon: -123.1207 }, // Vancouver
{ lat: 48.4284, lon: -123.3656 }, // Victoria
];
/** City of Ottawa traffic cameras: keyless JSON list, city-served JPEG stills. */
export const OTTAWA_CAMERAS_URL = 'https://traffic.ottawa.ca/map/camera_list';
/** Frame for one camera; the city serves the still on its own map path. */
export const OTTAWA_IMAGE_URL = (id) =>
`https://traffic.ottawa.ca/map/camera?id=${encodeURIComponent(id)}`;
/** Pinned frame directory, for redirect containment in the media proxy. */
export const OTTAWA_IMAGE_ORIGIN = 'https://traffic.ottawa.ca/map/';
export const DEFAULT_OTTAWA_MAX_SOURCES = 250;
/** Prioritization anchor: downtown Ottawa (Parliament Hill). */
export const OTTAWA_ANCHORS = [{ lat: 45.4215, lon: -75.6972 }];
/** Direct still-image directories from the World cams add-on's eligible packs. */
export const VEGAGERDIN_IMAGE_ORIGIN =
'https://www.vegagerdin.is/vgdata/vefmyndavelar/';
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 = 2396;
/** Tallinn intersection cameras: curated catalog + public stills on ristmikud.tallinn.ee. */
export const DEFAULT_TALLINN_SOURCE_FILE = 'config/cctv_sources.tallinn.json';
export const DEFAULT_TALLINN_MAX_SOURCES = 255;
+8
View File
@@ -10,8 +10,12 @@ import {
CCTV_MEDIA_MAX_BODY_BYTES,
HK_IMAGE_ORIGIN,
KING_COUNTY_IMAGE_ORIGIN,
MLIT_RIVER_IMAGE_ORIGIN,
TRIPCHECK_IMAGE_ORIGIN,
NSW_IMAGE_ORIGIN,
NSW_IMAGE_USER_AGENT,
VEGAGERDIN_IMAGE_ORIGIN,
OTTAWA_IMAGE_ORIGIN,
TALLINN_IMAGE_ORIGIN,
TARKTEE_IMAGE_ORIGIN,
TFL_IMAGE_ORIGIN,
@@ -525,11 +529,15 @@ for (const prefix of [
TARKTEE_IMAGE_ORIGIN,
...WARENDORF_IMAGE_ORIGINS,
NSW_IMAGE_ORIGIN,
OTTAWA_IMAGE_ORIGIN,
CALGARY_IMAGE_ORIGIN,
HK_IMAGE_ORIGIN,
KING_COUNTY_IMAGE_ORIGIN,
WSDOT_IMAGE_ORIGIN,
A22_IMAGE_ORIGIN,
VEGAGERDIN_IMAGE_ORIGIN,
TRIPCHECK_IMAGE_ORIGIN,
MLIT_RIVER_IMAGE_ORIGIN,
]) {
const host = new URL(prefix).hostname;
CCTV_PINNED_PREFIXES_BY_HOST.set(host, [
+4
View File
@@ -627,6 +627,10 @@ export function normalizeSourceItem(item) {
feedType: normalizeFeedType(item.feedType || item.type || ''),
url: typeof item.url === 'string' ? item.url : '',
snapshotUrl: typeof item.snapshotUrl === 'string' ? item.snapshotUrl : '',
frameRefreshMs: toFiniteNumber(item.frameRefreshMs),
sourceUrl: typeof item.sourceUrl === 'string' ? item.sourceUrl : '',
accuracyM: toFiniteNumber(item.accuracyM),
positionSource: String(item.positionSource || ''),
license: String(item.license || item.licenseNote || ''),
// Per-camera attribution for feeds a partner supplies inside a pack
// (DriveBC: TransLink, city cameras). Shown beside the provider.
+119
View File
@@ -0,0 +1,119 @@
import {
CCTV_SOURCE_FETCH_TIMEOUT_MS,
DEFAULT_OTTAWA_MAX_SOURCES,
OTTAWA_ANCHORS,
OTTAWA_CAMERAS_URL,
OTTAWA_IMAGE_URL,
} from './constants.js';
import {
fallbackHeadingFromId,
prioritizeSources,
toFiniteNumber,
} from './normalize.js';
/**
* 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 public traffic camera list.
*
* The keyless `camera_list` JSON publishes an id, coordinates, an English and
* French description, and a `type`; frames are JPEG stills built from the id
* on the city's own image path. 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.
*
* @returns {Promise<Array<object>>} Cameras nearest downtown first, or [] on
* any upstream or payload failure.
*/
export async function loadOttawaSourcesFromOpenData() {
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 rows = await resp.json();
if (!Array.isArray(rows)) return [];
const cameras = [];
for (const row of rows) {
const id = toFiniteNumber(row?.id);
if (!Number.isSafeInteger(id) || id <= 0) continue;
const lat = toFiniteNumber(row?.latitude);
const lon = toFiniteNumber(row?.longitude);
if (!isOttawaCoordinate(lat, lon)) continue;
const cameraId = `ottawa-${id}`;
const imageUrl = OTTAWA_IMAGE_URL(id);
const type = String(row?.type || '').trim();
cameras.push({
id: cameraId,
name: String(row?.description || '').trim() || `Ottawa camera ${id}`,
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,
sourceKind: 'ottawa-traffic',
license: 'City of Ottawa public traffic camera feed',
credit:
type === '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 [];
}
}
+127
View File
@@ -0,0 +1,127 @@
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;
/** 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: 511 denies the server-side media proxy,
// while the browser can play its MP4 in the dedicated DOM overlay.
url: '',
snapshotUrl: '',
sourceKind,
sourceUrl: safeViewerUrl(camera.viewerUrl),
accuracyM: Number.isFinite(Number(camera.accuracyM))
? Number(camera.accuracyM)
: null,
positionSource: String(camera.positionSource || ''),
license: isTraffic
? 'Données Québec / MTQ, CC BY 4.0'
: isMontreal
? 'OpenStreetMap contributors, ODbL 1.0'
: 'Operator terms apply; location provenance shown per camera',
credit: isTraffic
? 'MTQ · CC BY 4.0'
: isMontreal
? 'OpenStreetMap contributors · ODbL'
: provider,
code: cameraDisplayCode(name.toUpperCase()),
});
}
return sources;
}
+138
View File
@@ -0,0 +1,138 @@
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,
VEGAGERDIN_IMAGE_ORIGIN,
WORLD_CCTV_SOURCE_COUNT,
} from './constants.js';
import { pinnedCameraUrl } from './normalize.js';
const SOURCE_POLICIES = Object.freeze({
'vegagerdin-road': {
idPrefix: 'world-is-',
origin: VEGAGERDIN_IMAGE_ORIGIN,
bounds: [63, 68, -26, -12],
filename: /^\/vgdata\/vefmyndavelar\/[^/]+\.jpg$/i,
frameRefreshMs: 5 * 60 * 1000,
},
'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.
*/
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;
}
+2 -2
View File
@@ -384,6 +384,6 @@ test('CCTV_CALGARY_ENABLED=0 keeps the lane from being loaded at all', async (t)
}
});
test('the shipped catalog ceiling is not raised to make room for this pack', () => {
assert.equal(DEFAULT_CCTV_MAX_SOURCES, 4000);
test('the default catalog preserves shared, Québec, and World image slots', () => {
assert.equal(DEFAULT_CCTV_MAX_SOURCES, 7516);
});
+2
View File
@@ -14,6 +14,7 @@ import {
DEFAULT_ONTARIO_MAX_SOURCES,
DEFAULT_FINTRAFFIC_MAX_SOURCES,
DEFAULT_TXDOT_MAX_SOURCES,
DEFAULT_OTTAWA_MAX_SOURCES,
DEFAULT_TALLINN_MAX_SOURCES,
DEFAULT_TARKTEE_MAX_SOURCES,
DEFAULT_NSW_MAX_SOURCES,
@@ -231,6 +232,7 @@ test('default caps of the original packs fit inside the default catalog cap', ()
DEFAULT_ONTARIO_MAX_SOURCES +
DEFAULT_FINTRAFFIC_MAX_SOURCES +
DEFAULT_DRIVEBC_MAX_SOURCES +
DEFAULT_OTTAWA_MAX_SOURCES +
DEFAULT_TXDOT_MAX_SOURCES +
DEFAULT_TALLINN_MAX_SOURCES +
DEFAULT_TARKTEE_MAX_SOURCES +
+2 -2
View File
@@ -396,8 +396,8 @@ test('CCTV_HK_ENABLED=0 keeps the lane from being loaded at all', async (t) => {
}
});
test('the shipped catalog ceiling is not raised to make room for this pack', () => {
assert.equal(DEFAULT_CCTV_MAX_SOURCES, 4000);
test('the default catalog preserves shared, Québec, and World image slots', () => {
assert.equal(DEFAULT_CCTV_MAX_SOURCES, 7516);
});
test('the HK pack default sits above the full TD catalog and anchors the New Territories', () => {
+2 -2
View File
@@ -375,8 +375,8 @@ for (const [label, flag] of [
});
}
test('the shipped catalog ceiling is not raised to make room for this pack', () => {
assert.equal(DEFAULT_CCTV_MAX_SOURCES, 4000);
test('the default catalog preserves shared, Québec, and World image slots', () => {
assert.equal(DEFAULT_CCTV_MAX_SOURCES, 7516);
assert.equal(DEFAULT_NYC_MAX_SOURCES, 250);
});
+200
View File
@@ -0,0 +1,200 @@
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 { loadOttawaSourcesFromOpenData } from '../../server/providers/cctv/ottawa.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;
}
}
/** Silence the loader's progress and failure logging. */
function quiet(t) {
t.mock.method(console, 'log', () => {});
t.mock.method(console, 'warn', () => {});
}
/** One `/map/camera_list` row carrying the fields the loader reads. */
function ottawaRow(id, lat, lon, overrides = {}) {
return {
id,
number: 2000 + id,
description: `Camera ${id}`,
descriptionFr: `Caméra ${id}`,
type: 'City',
latitude: lat,
longitude: lon,
...overrides,
};
}
test('Ottawa loader keeps in-city cameras and builds frame URLs from the id', 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 Response.json([
ottawaRow(309, 45.341343, -75.81467, {
description: '(MTO) Hwy 416 NB ramp to Hwy 417 East',
type: 'MTO',
}),
ottawaRow(1, 45.4215, -75.6972, {
description: 'Bank St at Somerset St',
type: 'City',
}),
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();
assert.deepEqual(requested, [OTTAWA_CAMERAS_URL]);
assert.deepEqual(cameras.map((camera) => camera.id).sort(), [
'ottawa-1',
'ottawa-309',
]);
const mto = cameras.find((camera) => camera.id === 'ottawa-309');
const city = cameras.find((camera) => camera.id === 'ottawa-1');
assert.equal(mto.url, 'https://traffic.ottawa.ca/map/camera?id=309');
assert.equal(mto.snapshotUrl, mto.url);
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-309'),
'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 () => Response.json(rows));
const cameras = await loadOttawaSourcesFromOpenData();
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 () => {
throw new TypeError('fetch failed');
},
]) {
assert.deepEqual(await loadOttawaSourcesFromOpenData(), []);
}
});
test('CCTV catalog merges Ottawa cameras 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,
});
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
? Response.json([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/map/camera?id=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/map/camera?id=309',
OTTAWA_IMAGE_ORIGIN,
),
);
for (const url of [
'https://traffic.ottawa.ca.evil.test/map/camera?id=309',
'https://traffic.ottawa.ca/other/camera?id=309',
'https://user:[email protected]/map/camera?id=309',
'https://traffic.ottawa.ca:8443/map/camera?id=309',
'http://traffic.ottawa.ca/map/camera?id=309',
])
assert.equal(pinnedCameraUrl(url, OTTAWA_IMAGE_ORIGIN), null, url);
});
+2 -2
View File
@@ -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', () => {
assert.equal(DEFAULT_CCTV_MAX_SOURCES, 4000);
test('the default catalog preserves shared, Québec, and World image slots', () => {
assert.equal(DEFAULT_CCTV_MAX_SOURCES, 7516);
});
+5
View File
@@ -1,4 +1,9 @@
{
"MTQ · CC BY 4.0": "MTQ · CC BY 4.0",
"OpenStreetMap contributors · ODbL": "OpenStreetMap contributors · ODbL",
"Close camera preview": "إغلاق معاينة الكاميرا",
"Refresh map still": "تحديث صورة الخريطة الثابتة",
"The map still uses a separate request and may show a different moment.": "تستخدم صورة الخريطة طلبًا منفصلًا وقد تعرض لحظة مختلفة.",
"NO PLACE LEFT BEHIND": "لا مكان يُترك خلفنا",
"Initializing photorealistic world...": "جارٍ تهيئة العالم الواقعي…",
"Configuring viewer...": "جارٍ إعداد العارض…",
+5
View File
@@ -1,4 +1,9 @@
{
"MTQ · CC BY 4.0": "MTQ · CC BY 4.0",
"OpenStreetMap contributors · ODbL": "OpenStreetMap contributors · ODbL",
"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.",
"Flying to Austin, TX...": "Flug nach Austin, TX...",
"Restoring shared view...": "Geteilte Ansicht wird wiederhergestellt...",
"Configuring viewer...": "Viewer wird konfiguriert...",
+5
View File
@@ -1,4 +1,9 @@
{
"MTQ · CC BY 4.0": "MTQ · CC BY 4.0",
"OpenStreetMap contributors · ODbL": "OpenStreetMap contributors · ODbL",
"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.",
"NO PLACE LEFT BEHIND": "NINGÚN RINCÓN SIN VIGILAR",
"Initializing photorealistic world...": "Inicializando el mundo fotorrealista...",
"Configuring viewer...": "Configurando el visor...",
+5
View File
@@ -1,4 +1,9 @@
{
"MTQ · CC BY 4.0": "MTQ · CC BY 4.0",
"OpenStreetMap contributors · ODbL": "OpenStreetMap contributors · ODbL",
"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.",
"Pick a shape, then click the map.": "Choisissez une forme, puis cliquez sur la carte.",
"Enter to place the pin, Esc to cancel.": "Enter pour poser l'épingle, Esc pour annuler.",
"Click where the pin goes.": "Cliquez à l'endroit où poser l'épingle.",
+5
View File
@@ -1,4 +1,9 @@
{
"MTQ · CC BY 4.0": "MTQ · CC BY 4.0",
"OpenStreetMap contributors · ODbL": "OpenStreetMap contributors · ODbL",
"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.",
"Pick a shape, then click the map.": "Scegli una forma, poi fai clic sulla mappa.",
"Enter to place the pin, Esc to cancel.": "Premi Enter per posizionare il segnaposto, Esc per annullare.",
"Click where the pin goes.": "Fai clic dove va il segnaposto.",
+5
View File
@@ -1,4 +1,9 @@
{
"MTQ · CC BY 4.0": "MTQ · CC BY 4.0",
"OpenStreetMap contributors · ODbL": "OpenStreetMap contributors · ODbL",
"Close camera preview": "カメラのプレビューを閉じる",
"Refresh map still": "地図上の静止画を更新",
"The map still uses a separate request and may show a different moment.": "地図上の静止画は別のリクエストで取得されるため、異なる時点を表示する場合があります。",
"Pick a shape, then click the map.": "図形を選んでから、地図をクリックしてください。",
"Enter to place the pin, Esc to cancel.": "Enterでピンを配置、Escでキャンセル。",
"Click where the pin goes.": "ピンを置く場所をクリックしてください。",
+5
View File
@@ -1,4 +1,9 @@
{
"MTQ · CC BY 4.0": "MTQ · CC BY 4.0",
"OpenStreetMap contributors · ODbL": "OpenStreetMap contributors · ODbL",
"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.",
"Pick a shape, then click the map.": "Escolha uma forma e clique no mapa.",
"Enter to place the pin, Esc to cancel.": "Enter para colocar o marcador, Esc para cancelar.",
"Click where the pin goes.": "Clique onde o marcador deve ficar.",
+5
View File
@@ -1,4 +1,9 @@
{
"MTQ · CC BY 4.0": "MTQ · CC BY 4.0",
"OpenStreetMap contributors · ODbL": "OpenStreetMap contributors · ODbL",
"Close camera preview": "Закрыть просмотр камеры",
"Refresh map still": "Обновить кадр на карте",
"The map still uses a separate request and may show a different moment.": "Кадр на карте запрашивается отдельно и может показывать другой момент.",
"Flying to Austin, TX...": "Перелёт в Остин, Техас...",
"Restoring shared view...": "Восстановление общего вида...",
"Initializing systems...": "Инициализация систем...",
+5
View File
@@ -1,4 +1,9 @@
{
"MTQ · CC BY 4.0": "MTQ · CC BY 4.0",
"OpenStreetMap contributors · ODbL": "OpenStreetMap contributors · ODbL",
"Close camera preview": "Закрити перегляд камери",
"Refresh map still": "Оновити кадр на карті",
"The map still uses a separate request and may show a different moment.": "Кадр на карті запитується окремо та може показувати інший момент.",
"Pick a shape, then click the map.": "Виберіть фігуру й клацніть на карті.",
"Enter to place the pin, Esc to cancel.": "Enter — поставити мітку, Esc — скасувати.",
"Click where the pin goes.": "Клацніть там, де має бути мітка.",
+5
View File
@@ -1,4 +1,9 @@
{
"MTQ · CC BY 4.0": "MTQ · CC BY 4.0",
"OpenStreetMap contributors · ODbL": "OpenStreetMap contributors · ODbL",
"Close camera preview": "关闭摄像头预览",
"Refresh map still": "刷新地图静止画面",
"The map still uses a separate request and may show a different moment.": "地图静止画面通过单独的请求获取,可能显示不同的时刻。",
"Pick a shape, then click the map.": "先选择形状,再在地图上点击。",
"Enter to place the pin, Esc to cancel.": "按 Enter 放置图钉,按 Esc 取消。",
"Click where the pin goes.": "点击图钉的放置位置。",
+5
View File
@@ -1,4 +1,9 @@
{
"MTQ · CC BY 4.0": "MTQ · CC BY 4.0",
"OpenStreetMap contributors · ODbL": "OpenStreetMap contributors · ODbL",
"Close camera preview": "關閉攝影機預覽",
"Refresh map still": "重新整理地圖靜態畫面",
"The map still uses a separate request and may show a different moment.": "地圖靜態畫面使用獨立請求,顯示的時間可能不同。",
"Pick a shape, then click the map.": "先選擇形狀,再在地圖上點擊。",
"Enter to place the pin, Esc to cancel.": "按 Enter 放置圖釘,按 Esc 取消。",
"Click where the pin goes.": "點擊圖釘的放置位置。",
+33 -11
View File
@@ -31,7 +31,9 @@
"dB",
"hPa",
"{source} HTTP {status}",
"▲ {frp} MW"
"▲ {frp} MW",
"MTQ · CC BY 4.0",
"OpenStreetMap contributors · ODbL"
],
"de": [
"1090 MHz",
@@ -199,7 +201,9 @@
"{value} KM/S",
"ΔN",
"Ⅱ PAUSE",
"▲ {frp} MW"
"▲ {frp} MW",
"MTQ · CC BY 4.0",
"OpenStreetMap contributors · ODbL"
],
"es": [
"1090 MHz",
@@ -327,7 +331,9 @@
"{value} KM/S",
"ΔE",
"ΔN",
"▲ {frp} MW"
"▲ {frp} MW",
"MTQ · CC BY 4.0",
"OpenStreetMap contributors · ODbL"
],
"fr": [
"1090 MHz",
@@ -508,7 +514,9 @@
"ΔE",
"ΔN",
"Ⅱ PAUSE",
"▲ {frp} MW"
"▲ {frp} MW",
"MTQ · CC BY 4.0",
"OpenStreetMap contributors · ODbL"
],
"it": [
"1090 MHz",
@@ -679,7 +687,9 @@
"{value} conf",
"ΔE",
"ΔN",
"▲ {frp} MW"
"▲ {frp} MW",
"MTQ · CC BY 4.0",
"OpenStreetMap contributors · ODbL"
],
"ja": [
"1090 MHz",
@@ -728,7 +738,9 @@
"{type} SPD: {speed} HDG: {heading}",
"ΔE",
"ΔN",
"▲ {frp} MW"
"▲ {frp} MW",
"MTQ · CC BY 4.0",
"OpenStreetMap contributors · ODbL"
],
"pt": [
"1090 MHz",
@@ -857,7 +869,9 @@
"{value} KM/H",
"{value} KM/S",
"ΔN",
"▲ {frp} MW"
"▲ {frp} MW",
"MTQ · CC BY 4.0",
"OpenStreetMap contributors · ODbL"
],
"ru": [
"AIS: --",
@@ -875,7 +889,9 @@
"S2 GhostMaps",
"S2 · {name}",
"VS",
"WHOT/BHOT"
"WHOT/BHOT",
"MTQ · CC BY 4.0",
"OpenStreetMap contributors · ODbL"
],
"uk": [
"AIS: --",
@@ -888,7 +904,9 @@
"NVG",
"REC",
"S2 GhostMaps",
"WHOT/BHOT"
"WHOT/BHOT",
"MTQ · CC BY 4.0",
"OpenStreetMap contributors · ODbL"
],
"zh-CN": [
"1090 MHz",
@@ -945,7 +963,9 @@
"{value} KM/S",
"ΔE",
"ΔN",
"▲ {frp} MW"
"▲ {frp} MW",
"MTQ · CC BY 4.0",
"OpenStreetMap contributors · ODbL"
],
"zh-Hant": [
"1090 MHz",
@@ -1002,6 +1022,8 @@
"{value} KM/S",
"ΔE",
"ΔN",
"▲ {frp} MW"
"▲ {frp} MW",
"MTQ · CC BY 4.0",
"OpenStreetMap contributors · ODbL"
]
}
+5 -3
View File
@@ -132,9 +132,11 @@ export function createCards({ state: layerState, services, parts, source }) {
record.camera.lon,
),
inView,
isVideo: parts.model.isVideoFeedType(
parts.model.normalizeFeedType(record.camera.feedType),
),
// Québec's browser-only sources have no registered frame endpoint;
// keep them out of still-card fill just like video feeds.
isVideo:
parts.model.isVideoFeedCamera(record.camera) ||
parts.model.isBrowserOverlayCamera(record.camera),
sx,
sy,
});
+5
View File
@@ -237,9 +237,14 @@ export function createCatalog({ state: layerState, services, parts, source }) {
groundElevationM,
absoluteHeightM: groundElevationM + mountHeightM,
pitchDeg,
frameRefreshMs: parts.model.safeNumber(source.frameRefreshMs, NaN),
license: String(source.license || source.licenseNote || ''),
credit: String(source.credit || ''),
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).
groundHeights:
source.groundHeights && typeof source.groundHeights === 'object'
+11 -2
View File
@@ -255,11 +255,20 @@ export function createFrames({ state: layerState, services, parts, source }) {
// clears this latch so the next normal tick can refresh.
if (runtime.imageLoading) return;
const now = Date.now();
const refreshMs =
const defaultRefreshMs =
record.camera.id === layerState._activeCameraId
? PROJECTION_ACTIVE_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;
const frameUrl = frameUrlFor(record.camera, refreshMs);
+2 -3
View File
@@ -49,9 +49,8 @@ export function createHover({ state: layerState, services, parts, source }) {
!!record &&
cameraId !== layerState._activeCameraId &&
!layerState._cardIds.has(cameraId) &&
!parts.model.isVideoFeedType(
parts.model.normalizeFeedType(record.camera.feedType),
);
!parts.model.isBrowserOverlayCamera(record.camera) &&
!parts.model.isVideoFeedCamera(record.camera);
if (eligible) {
cancelHoverRelease();
layerState._hoverCardId = cameraId;
+21 -1
View File
@@ -96,6 +96,19 @@ export function createModel({ state: layerState, services, parts, source }) {
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.
*
@@ -447,7 +460,12 @@ export function createModel({ state: layerState, services, parts, source }) {
function projectionLoopIsNeeded() {
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 focusPassIsNeeded(
getFocusTarget(),
@@ -553,6 +571,8 @@ export function createModel({ state: layerState, services, parts, source }) {
angularDeltaAbs,
normalizeFeedType,
isVideoFeedType,
isVideoFeedCamera,
isBrowserOverlayCamera,
safeNumber,
headingFromId,
quantize,
+17 -6
View File
@@ -160,9 +160,14 @@ export function createPresentation({
const camera = record.camera;
const health = layerState._healthById.get(camera.id) || null;
const isActive = camera.id === resolvedActiveId;
const refreshMs = isActive
const defaultRefreshMs = isActive
? ACTIVE_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 {
id: camera.id,
name: camera.name,
@@ -192,13 +197,18 @@ export function createPresentation({
mountHeightM: camera.mountHeightM,
active: isActive,
feedType: camera.feedType,
sourceUrl: camera.sourceUrl || '',
browserMediaUrl: camera.browserMediaUrl || '',
accuracyM: Number.isFinite(camera.accuracyM) ? camera.accuracyM : null,
positionSource: camera.positionSource || '',
isVideo:
parts.model.isVideoFeedType(camera.feedType) &&
parts.model.isVideoFeedCamera(camera) &&
record.projection?.mode !== 'image',
sourceKind:
health?.sourceKind ||
camera.sourceKind ||
(camera.feedConfigured ? 'configured' : 'seed'),
sourceKind: camera.sourceKind?.startsWith('quebec-')
? camera.sourceKind
: health?.sourceKind ||
camera.sourceKind ||
(camera.feedConfigured ? 'configured' : 'seed'),
sourceStatus: health?.status || 'unknown',
sourceMessage: health?.message || '',
sourceLabel: health?.label || camera.provider || '',
@@ -276,6 +286,7 @@ export function createPresentation({
hoverId: layerState._hoverCardId,
},
activeCameraId: activeId,
activeCameraReselection: layerState._activeCameraReselection,
activeCamera: active ? getPublicCameraState(active, activeId) : null,
cameraIndex: cameraIndex(),
summary: buildSummaryText(),
+3
View File
@@ -171,6 +171,9 @@ export function createProjection({
function createProjectionRuntime(record) {
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');
canvas.width = PROJECTION_CANVAS_WIDTH;
canvas.height = PROJECTION_CANVAS_HEIGHT;
+5 -4
View File
@@ -35,8 +35,7 @@ export function createRendering({
function cameraColorFor(record) {
if (record.camera.id === layerState._activeCameraId)
return ACTIVE_CAMERA_COLOR;
if (parts.model.isVideoFeedType(record.camera.feedType))
return LIVE_CAMERA_COLOR;
if (parts.model.isVideoFeedCamera(record.camera)) return LIVE_CAMERA_COLOR;
return IDLE_CAMERA_COLOR;
}
@@ -405,8 +404,9 @@ export function createRendering({
if (
layerState._enabled &&
isActive &&
!parts.model.isBrowserOverlayCamera(record.camera) &&
(layerState._showProjection ||
parts.model.isVideoFeedType(record.camera.feedType))
parts.model.isVideoFeedCamera(record.camera))
) {
parts.projection.ensureProjectionRuntime(record);
}
@@ -416,7 +416,8 @@ export function createRendering({
const planeShowing = !!(
layerState._enabled &&
layerState._showProjection &&
isActive
isActive &&
!parts.model.isBrowserOverlayCamera(record.camera)
);
const inVisibleSet = coverageVisible.has(record.camera.id);
+6 -1
View File
@@ -56,7 +56,12 @@ test('focus de-emphasis fades each icon from its own color', () => {
const { refreshCctvFocusStyles } = createRendering({
state,
services: { focus },
parts: { model: { isVideoFeedType } },
parts: {
model: {
isVideoFeedCamera: (camera) =>
isVideoFeedType(camera.feedType) || Boolean(camera.browserMediaUrl),
},
},
source: {},
});
+6
View File
@@ -79,6 +79,12 @@ export function createSelection({
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;
}
layerState._activeCameraId = cameraId;
+54
View File
@@ -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);
});
+4
View File
@@ -29,6 +29,10 @@ export function createState({ services }) {
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';
// 'off' | 'on' (wireframes) | 'viewshed' (color-coded volumes)
+114
View File
@@ -0,0 +1,114 @@
.qc-preview-panel {
position: fixed;
z-index: 2000;
top: 80px;
right: 24px;
width: min(360px, calc(100vw - 32px));
overflow: hidden;
border: 1px solid #3eabbc;
border-radius: 8px;
background: #071117;
color: #e6f8fb;
box-shadow: 0 12px 36px #000a;
font: 12px/1.4 sans-serif;
}
.qc-preview-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 10px;
cursor: move;
touch-action: none;
}
.qc-preview-panel button {
border: 1px solid #43818d;
border-radius: 4px;
padding: 4px 8px;
background: #102830;
color: inherit;
cursor: pointer;
}
.qc-preview-panel button:focus-visible {
outline: 2px solid #82e9f7;
outline-offset: 2px;
}
.qc-preview-live {
display: block;
width: 100%;
aspect-ratio: 16 / 9;
background: #000;
}
.qc-preview-panel .qc-preview-live[hidden],
.qc-preview-marker video[hidden] {
display: none;
}
.qc-preview-panel select {
display: block;
width: calc(100% - 20px);
margin: 0 10px 8px;
border: 1px solid #43818d;
border-radius: 4px;
padding: 6px 8px;
background: #102830;
color: inherit;
}
.qc-preview-coordinates {
margin: 0 10px 8px;
color: #a9c5ca;
font:
10px/1.4 'Cascadia Code',
Consolas,
monospace;
}
.qc-preview-panel .qc-preview-live[hidden],
.qc-preview-marker video[hidden] {
display: none;
}
.qc-preview-actions {
display: flex;
gap: 8px;
padding: 8px 10px 0;
}
.qc-preview-panel p {
margin: 8px 10px 10px;
color: #a9c5ca;
}
.qc-preview-panel a {
display: inline-block;
margin: 0 10px 10px;
color: #82e9f7;
}
.qc-preview-marker {
position: fixed;
z-index: 500;
width: 160px;
transform: translate(-50%, -100%) scale(var(--qc-preview-scale, 1));
transform-origin: 50% 100%;
pointer-events: none;
padding: 3px;
border: 1px solid #65d7e7;
background: #071117;
color: #d8f9ff;
font: 10px/1.3 sans-serif;
}
.qc-preview-marker[hidden] {
display: none;
}
.qc-preview-marker video {
display: block;
width: 100%;
aspect-ratio: 16 / 9;
background: #000;
}
.qc-preview-marker span {
display: block;
padding: 3px;
}
.qc-preview-marker-link-only {
width: max-content;
max-width: 220px;
}
.qc-preview-marker-link-only {
width: max-content;
max-width: 220px;
}
+319
View File
@@ -0,0 +1,319 @@
import * as Cesium from 'cesium';
import { horizonOccluder } from '../data/iconOrientation.js';
import { msg, t } from '../i18n/index.js';
const CAMERA_ORIGIN = 'https://www.quebec511.info/Carte/Fenetres/camera.ashx';
// i18n-ignore: provider and licence labels are required source attribution.
const MTQ_ATTRIBUTION = msg('MTQ · CC BY 4.0');
// i18n-ignore: OpenStreetMap credit is shown in its standard form.
const OSM_ATTRIBUTION = msg('OpenStreetMap contributors · ODbL');
function directVideoUrl(camera) {
return camera.kind === 'traffic' && /^\d+$/.test(camera.id)
? `${CAMERA_ORIGIN}?id=${camera.id}&format=mp4`
: '';
}
function safeViewerUrl(value) {
try {
const url = new URL(String(value || ''));
return url.protocol === 'https:' && !url.username && !url.password
? url.href
: '';
} catch {
return '';
}
}
/** Install the opt-in Québec camera browser preview and return its cleanup. */
export function installQuebecCameraPreview(
viewer,
{ initialId = '3735', fetchImpl = globalThis.fetch } = {},
) {
const panel = document.createElement('section');
panel.className = 'qc-preview-panel';
panel.setAttribute('role', 'dialog');
panel.setAttribute('aria-label', t('CCTV camera'));
const header = document.createElement('header');
header.className = 'qc-preview-header';
const title = document.createElement('strong');
const close = document.createElement('button');
close.type = 'button';
close.textContent = '×';
close.setAttribute('aria-label', t('Close camera preview'));
header.append(title, close);
const select = document.createElement('select');
select.setAttribute('aria-label', t('CCTV camera'));
const coordinates = document.createElement('p');
coordinates.className = 'qc-preview-coordinates';
const live = document.createElement('video');
live.className = 'qc-preview-live';
live.controls = true;
live.setAttribute('aria-label', t('CCTV camera'));
live.autoplay = true;
live.muted = true;
live.loop = true;
live.playsInline = true;
const actions = document.createElement('div');
actions.className = 'qc-preview-actions';
const fly = document.createElement('button');
fly.type = 'button';
fly.textContent = t('FOCUS');
const refresh = document.createElement('button');
refresh.type = 'button';
refresh.textContent = t('Refresh map still');
actions.append(fly, refresh);
const note = document.createElement('p');
const source = document.createElement('a');
source.target = '_blank';
source.rel = 'noopener noreferrer';
const viewerLink = document.createElement('a');
viewerLink.target = '_blank';
viewerLink.rel = 'noopener noreferrer';
viewerLink.textContent = t("Open this image on the provider's site");
panel.append(
header,
select,
coordinates,
live,
actions,
note,
source,
viewerLink,
);
const marker = document.createElement('div');
marker.className = 'qc-preview-marker';
marker.hidden = true;
const still = document.createElement('video');
still.setAttribute('aria-hidden', 'true');
still.muted = true;
still.playsInline = true;
still.preload = 'auto';
const label = document.createElement('span');
marker.append(still, label);
document.body.append(panel, marker);
const controller = new AbortController();
let disposed = false;
let selected = null;
let cameraPosition = null;
let mapStillUrl = '';
const cameraData = [];
function setMedia(video, url) {
if (video.src === url) return;
video.pause();
video.removeAttribute('src');
if (!url) {
video.load();
return;
}
video.src = url;
video.load();
}
function refreshStill() {
if (!mapStillUrl) return;
still.load();
void still.play().catch(() => {});
}
function useCamera(camera) {
selected = camera;
cameraPosition = Cesium.Cartesian3.fromDegrees(camera.lon, camera.lat, 10);
const mediaUrl = directVideoUrl(camera);
const viewerUrl = safeViewerUrl(camera.viewerUrl);
title.textContent = camera.nameEn || camera.name;
select.value = camera.id;
// i18n-ignore: coordinates, accuracy and provenance are camera data.
coordinates.textContent = `${camera.lat.toFixed(camera.kind === 'traffic' ? 5 : 6)}, ${camera.lon.toFixed(camera.kind === 'traffic' ? 5 : 6)} · ${camera.accuracyM ? `±${camera.accuracyM} m` : camera.positionSource}`;
live.hidden = !mediaUrl;
refresh.hidden = !mediaUrl;
still.hidden = !mediaUrl;
if (mediaUrl) {
note.textContent = t(
'The map still uses a separate request and may show a different moment.',
);
setMedia(live, mediaUrl);
live.play().catch(() => {});
if (mapStillUrl !== mediaUrl) {
mapStillUrl = mediaUrl;
setMedia(still, mediaUrl);
refreshStill();
}
// i18n-ignore: this suffix is provider-provided location provenance.
label.textContent = `${t('PREVIEW')} · ${camera.positionSource}`;
marker.classList.remove('qc-preview-marker-link-only');
} else {
note.textContent = viewerUrl
? t("Open this image on the provider's site")
: t('Source unavailable');
setMedia(live, '');
setMedia(still, '');
mapStillUrl = '';
// i18n-ignore: camera names and accuracy are supplied as catalog data.
label.textContent = camera.accuracyM
? `${camera.name} · ±${camera.accuracyM} m`
: camera.name;
marker.classList.add('qc-preview-marker-link-only');
}
const osmPosition =
camera.kind === 'montreal' ||
/openstreetmap|\bosm\b/i.test(camera.positionSource);
const attribution =
camera.kind === 'traffic'
? t('Source: {label}', { label: MTQ_ATTRIBUTION })
: osmPosition
? t('Source: {label}', { label: OSM_ATTRIBUTION })
: t('Source: {label}', {
label: camera.operator || camera.positionSource || t('Source'),
});
source.textContent = attribution;
const attributionUrl =
camera.kind === 'traffic'
? 'https://www.donneesquebec.ca/recherche/dataset/camera-de-circulation'
: osmPosition
? 'https://www.openstreetmap.org/copyright'
: viewerUrl;
source.href = attributionUrl || '#';
source.hidden = !attributionUrl;
viewerLink.href = viewerUrl || '#';
viewerLink.hidden = !viewerUrl;
updateMarker();
}
function updateMarker() {
if (!selected || !cameraPosition) {
marker.hidden = true;
return;
}
const canvas = viewer.scene.canvas;
const point = viewer.scene.cartesianToCanvasCoordinates(cameraPosition);
const visible =
point &&
horizonOccluder(viewer.camera).isPointVisible(cameraPosition) &&
point.x >= 0 &&
point.y >= 0 &&
point.x <= canvas.clientWidth &&
point.y <= canvas.clientHeight;
marker.hidden = !visible;
if (!visible) return;
const rect = canvas.getBoundingClientRect();
marker.style.left = `${rect.left + point.x}px`;
marker.style.top = `${rect.top + point.y}px`;
const distance = Cesium.Cartesian3.distance(
viewer.camera.positionWC,
cameraPosition,
);
marker.style.setProperty(
'--qc-preview-scale',
String(Math.min(1, 600 / Math.max(1, distance))),
);
}
select.addEventListener('change', () => {
const camera = cameraData.find((item) => item.id === select.value);
if (camera) useCamera(camera);
});
still.addEventListener('loadeddata', () => still.pause());
still.addEventListener('error', () => {
label.textContent = t('Source unavailable');
});
live.addEventListener('error', () => {
note.textContent = t('Source unavailable');
});
refresh.addEventListener('click', refreshStill);
fly.addEventListener('click', () => {
if (!selected) return;
viewer.camera.flyTo({
destination: Cesium.Cartesian3.fromDegrees(
selected.lon,
selected.lat,
600,
),
orientation: { heading: 0, pitch: Cesium.Math.toRadians(-90), roll: 0 },
duration: 1.5,
});
});
header.addEventListener('pointerdown', (event) => {
if (event.target.closest('button')) return;
const rect = panel.getBoundingClientRect();
const offsetX = event.clientX - rect.left;
const offsetY = event.clientY - rect.top;
header.setPointerCapture(event.pointerId);
const move = (moveEvent) => {
panel.style.left = `${Math.max(0, Math.min(window.innerWidth - panel.offsetWidth, moveEvent.clientX - offsetX))}px`;
panel.style.top = `${Math.max(0, Math.min(window.innerHeight - panel.offsetHeight, moveEvent.clientY - offsetY))}px`;
panel.style.right = 'auto';
};
const stop = () => {
header.removeEventListener('pointermove', move);
header.removeEventListener('pointerup', stop);
header.removeEventListener('pointercancel', stop);
};
header.addEventListener('pointermove', move);
header.addEventListener('pointerup', stop);
header.addEventListener('pointercancel', stop);
});
const removeRender = viewer.scene.postRender.addEventListener(updateMarker);
const ready = (async () => {
try {
const response = await fetchImpl('/quebec-cameras.json', {
signal: controller.signal,
});
if (!response.ok) throw new Error(`Camera list HTTP ${response.status}`);
const payload = await response.json();
const cameras = Array.isArray(payload?.cameras) ? payload.cameras : [];
if (!cameras.length) throw new Error('Empty Québec camera list');
cameraData.push(...cameras);
// i18n-ignore: these group names are provider/source categories.
for (const kind of ['traffic', 'montreal', 'webcam']) {
const group = document.createElement('optgroup');
group.label =
kind === 'traffic'
? 'MTQ / Québec 511'
: kind === 'montreal'
? 'Montréal / OpenStreetMap'
: 'Scenic webcams';
for (const camera of cameras.filter((item) => item.kind === kind)) {
const option = document.createElement('option');
option.value = camera.id;
// i18n-ignore: camera names and regions are provider catalog data.
option.textContent = `${camera.nameEn || camera.name} · ${camera.region || camera.category}`;
group.append(option);
}
if (group.children.length) select.append(group);
}
const chosen =
cameras.find((camera) => camera.id === initialId) ||
cameras.find((camera) => camera.id === '3735') ||
cameras[0];
useCamera(chosen);
} catch {
if (!disposed) note.textContent = t('Source unavailable');
}
})();
const dispose = () => {
if (disposed) return;
disposed = true;
controller.abort();
removeRender();
for (const video of [live, still]) {
video.pause();
video.removeAttribute('src');
video.load();
}
panel.remove();
marker.remove();
};
close.addEventListener('click', dispose);
return { dispose, ready };
}
+136
View File
@@ -0,0 +1,136 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { Window } from 'happy-dom';
import * as Cesium from 'cesium';
import { installQuebecCameraPreview } from './quebecCameraPreview.js';
test('Québec preview selects direct-feed and viewer-only cameras and scales the map card', async () => {
const window = new Window({ url: 'http://localhost:4173/?qc-preview=all' });
const previousWindow = globalThis.window;
const previousDocument = globalThis.document;
globalThis.window = window;
globalThis.document = window.document;
window.HTMLMediaElement.prototype.load = function () {};
window.HTMLMediaElement.prototype.play = function () {
return Promise.resolve();
};
window.HTMLMediaElement.prototype.pause = function () {
this.dataset.paused = 'yes';
};
let removed = 0;
let destination;
let render;
let point = { x: 200, y: 150 };
const viewer = {
camera: {
positionWC: Cesium.Cartesian3.fromDegrees(-73.5502, 45.51651, 1000),
flyTo: (options) => {
destination = options.destination;
},
},
scene: {
canvas: {
clientWidth: 800,
clientHeight: 600,
getBoundingClientRect: () => ({ left: 30, top: 40 }),
},
cartesianToCanvasCoordinates: () => point,
postRender: {
addEventListener: (callback) => {
render = callback;
return () => {
removed++;
};
},
},
},
};
const cameras = [
{
id: '3735',
kind: 'traffic',
name: 'Route 136 at rue Panet',
nameEn: 'Route 136 at rue Panet',
lat: 45.51651,
lon: -73.5502,
viewerUrl:
'https://www.quebec511.info/Carte/Fenetres/FenetreVideo.html?id=3735',
positionSource: 'MTQ WFS',
accuracyM: null,
},
{
id: 'mtl-465',
kind: 'montreal',
name: '1re Avenue / Sortie A-20',
lat: 45.44462,
lon: -73.663552,
viewerUrl: 'https://ville.montreal.qc.ca/circulation/node/465',
positionSource: 'OSM node',
accuracyM: 15,
},
];
try {
const preview = installQuebecCameraPreview(viewer, {
initialId: '3735',
fetchImpl: async () => ({ ok: true, json: async () => ({ cameras }) }),
});
await preview.ready;
const videos = [...window.document.querySelectorAll('video')];
assert.equal(videos.length, 2);
assert.equal(
videos[0].src,
'https://www.quebec511.info/Carte/Fenetres/camera.ashx?id=3735&format=mp4',
);
assert.equal(videos[1].src, videos[0].src);
assert.equal(videos[0].hidden, false);
assert.match(
window.document.querySelector('.qc-preview-coordinates').textContent,
/45\.51651, -73\.55020/,
);
videos[1].dispatchEvent(new window.Event('loadeddata'));
assert.equal(videos[1].dataset.paused, 'yes');
const marker = window.document.querySelector('.qc-preview-marker');
assert.equal(marker.hidden, false);
assert.equal(marker.style.left, '230px');
assert.equal(marker.style.top, '190px');
const nearScale = Number(
marker.style.getPropertyValue('--qc-preview-scale'),
);
viewer.camera.positionWC = Cesium.Cartesian3.fromDegrees(
-73.5502,
45.51651,
80000,
);
render();
assert.ok(
Number(marker.style.getPropertyValue('--qc-preview-scale')) < nearScale,
);
const select = window.document.querySelector('select');
select.value = 'mtl-465';
select.dispatchEvent(new window.Event('change'));
assert.equal(videos[0].hidden, true);
assert.equal(videos[1].hidden, true);
assert.match(
window.document.querySelectorAll(
'.qc-preview-panel a[target="_blank"]',
)[1].href,
/ville\.montreal\.qc\.ca/,
);
window.document.querySelector('.qc-preview-actions button').click();
assert.ok(destination);
point = { x: -1, y: 150 };
render();
assert.equal(marker.hidden, true);
window.document.querySelector('.qc-preview-header button').click();
preview.dispose();
assert.equal(removed, 1);
assert.equal(window.document.querySelectorAll('video').length, 0);
} finally {
globalThis.window = previousWindow;
globalThis.document = previousDocument;
window.close();
}
});
+1
View File
@@ -1139,6 +1139,7 @@ export class StyleManager extends ShellFacade {
_cctvSummary: this._cctvSummary,
},
cctv: cctvLayer,
viewer: this.viewer,
actions: {
isEnabled: () => this._dataManager?.isEnabled('cctv'),
setParams: (params, options) =>
+5
View File
@@ -23,6 +23,7 @@ import { useCctvStore } from './stores/cctv.js';
import { useStatusStore } from './stores/status.js';
import { applicationPinia } from './vueRuntime.js';
import { onLocaleChange } from '../i18n/index.js';
import { createQuebecCctvOverlay } from './quebecCctvOverlay.js';
/**
* Own camera-panel interaction and presentation; receive the camera port and
@@ -34,6 +35,7 @@ export class CctvControls {
constructor({
elements,
cctv,
viewer = null,
actions,
store = useCctvStore(applicationPinia()),
statusStore = useStatusStore(applicationPinia()),
@@ -42,11 +44,13 @@ export class CctvControls {
this.store = store;
this.statusStore = statusStore;
this.cctv = cctv;
this._quebecCctvOverlay = createQuebecCctvOverlay(viewer);
this.actions = actions;
this.destroyed = false;
this.listeners = new AbortController();
this._cctvUnsubscribe = null;
this._cctvState = null;
this._lastQuebecSelectionRevision = null;
this._cctvSummaryTypingTimer = null;
this._lastCctvSummaryText = '';
this._lastSeenCctvActiveId = null;
@@ -146,6 +150,7 @@ export class CctvControls {
if (this.destroyed) return;
this.destroyed = true;
this._offLocale();
this._quebecCctvOverlay?.dispose();
this._cctvVideoSurface?.stop();
this._videoVisibilityObserver?.disconnect();
this._actionGeneration++;
+19 -3
View File
@@ -154,6 +154,20 @@ export function _renderCctvState(state) {
const enabled = !!state?.enabled && !!this.actions.isEnabled();
const activeId = state?.activeCameraId || '';
const activeCamera = state?.activeCamera || null;
const selectionRevision = Number(state?.activeCameraReselection);
const reselected =
this._lastQuebecSelectionRevision !== null &&
Number.isFinite(selectionRevision) &&
selectionRevision !== this._lastQuebecSelectionRevision;
this._lastQuebecSelectionRevision = Number.isFinite(selectionRevision)
? selectionRevision
: null;
const isQuebecCamera =
enabled && activeCamera?.sourceKind?.startsWith('quebec-');
this._quebecCctvOverlay?.setCamera(isQuebecCamera ? activeCamera : null, {
reselected,
});
if (this._cctvFrameWrap) this._cctvFrameWrap.hidden = Boolean(isQuebecCamera);
// Auto-expand the panel when the active camera CHANGES to a new non-null
// id while the layer is enabled. Covers click-on-globe, panel controls,
@@ -241,7 +255,8 @@ export function _renderCctvState(state) {
this.store.publish(presentCctvPanel(state, enabled));
const liveIntent = enabled && !!activeCamera?.isVideo;
const liveIntent =
enabled && !!activeCamera?.isVideo && !activeCamera?.browserMediaUrl;
this.store.setLive(liveIntent);
if (this._cctvVideo) {
const visible =
@@ -261,8 +276,9 @@ export function _renderCctvState(state) {
}
if (this._cctvFrame && !liveIntent) {
const nextSrc = enabled ? activeCamera?.frameUrl : null;
const nextCameraId = enabled ? activeCamera?.id || '' : '';
const nextSrc = enabled && !isQuebecCamera ? activeCamera?.frameUrl : null;
const nextCameraId =
enabled && !isQuebecCamera ? activeCamera?.id || '' : '';
const cameraChanged = this._frame.cameraId !== nextCameraId;
const frameLoading = this._frame.loading;
// A same-camera refresh waits for the current image to settle. Replacing
+389
View File
@@ -0,0 +1,389 @@
import * as Cesium from 'cesium';
import { horizonOccluder } from '../data/iconOrientation.js';
import { t } from '../i18n/index.js';
function isQuebecCamera(camera) {
return String(camera?.sourceKind || '').startsWith('quebec-');
}
function safeSourceUrl(value) {
try {
const url = new URL(String(value || ''));
return url.protocol === 'https:' && !url.username && !url.password
? url.href
: '';
} catch {
return '';
}
}
function safeMediaUrl(value) {
try {
const url = new URL(String(value || ''));
if (
url.protocol !== 'https:' ||
url.hostname !== 'www.quebec511.info' ||
url.username ||
url.password ||
url.pathname !== '/Carte/Fenetres/camera.ashx' ||
url.searchParams.get('format') !== 'mp4' ||
!/^\d+$/.test(url.searchParams.get('id') || '')
)
return '';
return url.href;
} catch {
return '';
}
}
function setMedia(video, url) {
const current = video.currentSrc || video.src;
if (current === url) return;
video.pause();
video.removeAttribute('src');
if (!url) {
video.load();
return;
}
video.src = url;
video.load();
}
function mediaRefreshUrl(value) {
const url = new URL(value);
url.searchParams.set('_', String(Date.now()));
return url.href;
}
/** Attach the active Québec source to a floating browser player and map preview. */
export function createQuebecCctvOverlay(viewer) {
if (!viewer?.scene || typeof document === 'undefined') return null;
const controller = new AbortController();
const { signal } = controller;
const panel = document.createElement('section');
panel.className = 'qci-panel';
panel.hidden = true;
panel.setAttribute('role', 'dialog');
panel.setAttribute('aria-label', t('CCTV camera'));
const header = document.createElement('header');
header.className = 'qci-header';
const title = document.createElement('strong');
const close = document.createElement('button');
close.type = 'button';
close.textContent = '×';
close.setAttribute('aria-label', t('Close camera preview'));
header.append(title, close);
const coordinates = document.createElement('p');
coordinates.className = 'qci-coordinates';
const credit = document.createElement('p');
credit.className = 'qci-credit';
const live = document.createElement('video');
live.className = 'qci-live';
live.controls = true;
live.muted = true;
live.autoplay = true;
live.loop = true;
live.playsInline = true;
live.setAttribute('aria-label', t('CCTV camera'));
const actions = document.createElement('div');
actions.className = 'qci-actions';
const refresh = document.createElement('button');
refresh.type = 'button';
refresh.textContent = t('Refresh map still');
actions.append(refresh);
const note = document.createElement('p');
note.className = 'qci-note';
const sourceLink = document.createElement('a');
sourceLink.target = '_blank';
sourceLink.rel = 'noopener noreferrer';
sourceLink.textContent = t("Open this image on the provider's site");
panel.append(header, coordinates, credit, live, actions, note, sourceLink);
const marker = document.createElement('div');
marker.className = 'qci-marker';
marker.hidden = true;
const still = document.createElement('video');
still.className = 'qci-marker-still';
still.muted = true;
still.playsInline = true;
still.preload = 'auto';
still.setAttribute('aria-hidden', 'true');
const markerLabel = document.createElement('span');
markerLabel.className = 'qci-marker-label';
marker.append(still, markerLabel);
document.body.append(panel, marker);
let selected = null;
let selectedPosition = null;
let closedWindowCameraId = null;
let selectedMediaUrl = '';
let mediaRefreshTimer = 0;
let liveUnavailable = false;
let stillUnavailable = false;
function markerLabelText(camera, hasMedia) {
return hasMedia
? `${t('PREVIEW')} · ${camera.positionSource || camera.name}`
: Number.isFinite(camera.accuracyM) && camera.accuracyM > 0
? `${camera.name} · ±${Math.round(camera.accuracyM)} m`
: camera.name;
}
function markLiveUnavailable() {
liveUnavailable = true;
note.textContent = t('Source unavailable');
note.hidden = false;
}
function markStillUnavailable() {
stillUnavailable = true;
markerLabel.textContent = t('Source unavailable');
}
function stopMediaRefresh() {
clearInterval(mediaRefreshTimer);
mediaRefreshTimer = 0;
}
function startMediaRefresh() {
stopMediaRefresh();
if (!selectedMediaUrl) return;
mediaRefreshTimer = window.setInterval(
() => {
if (document.hidden || closedWindowCameraId === selected?.id) return;
setMedia(live, mediaRefreshUrl(selectedMediaUrl));
void live.play().catch(markLiveUnavailable);
refreshStill();
},
2 * 60 * 1000,
);
}
function refreshStill() {
if (!selectedMediaUrl) return;
setMedia(still, '');
setMedia(still, mediaRefreshUrl(selectedMediaUrl));
void still.play().catch(markStillUnavailable);
}
function setCamera(camera, { reselected = false } = {}) {
if (!isQuebecCamera(camera)) {
selected = null;
selectedPosition = null;
closedWindowCameraId = null;
selectedMediaUrl = '';
liveUnavailable = false;
stillUnavailable = false;
stopMediaRefresh();
panel.hidden = true;
marker.hidden = true;
setMedia(live, '');
setMedia(still, '');
return;
}
const changed = selected?.id !== camera.id;
if (changed || reselected) closedWindowCameraId = null;
selected = camera;
selectedPosition = Cesium.Cartesian3.fromDegrees(
Number(camera.lon),
Number(camera.lat),
Number(camera.elevationM) || 10,
);
if (closedWindowCameraId === camera.id && !reselected) {
panel.hidden = true;
updateMarker();
return;
}
const mediaUrl = safeMediaUrl(camera.browserMediaUrl);
const sourceUrl = safeSourceUrl(camera.sourceUrl);
if (changed || mediaUrl !== selectedMediaUrl) {
liveUnavailable = false;
stillUnavailable = false;
}
title.textContent = camera.name || t('CCTV camera');
coordinates.textContent = [
`${Number(camera.lat).toFixed(5)}, ${Number(camera.lon).toFixed(5)}`,
Number.isFinite(camera.accuracyM) && camera.accuracyM > 0
? `±${Math.round(camera.accuracyM)} m`
: '',
camera.positionSource || '',
]
.filter(Boolean)
.join(' · ');
credit.textContent = camera.credit || camera.provider || '';
credit.hidden = !credit.textContent;
live.hidden = !mediaUrl;
refresh.hidden = !mediaUrl;
actions.hidden = !mediaUrl;
note.hidden = !mediaUrl;
note.textContent = mediaUrl
? liveUnavailable
? t('Source unavailable')
: t(
'The map still uses a separate request and may show a different moment.',
)
: sourceUrl
? ''
: t('Source unavailable');
note.hidden = !note.textContent;
sourceLink.href = sourceUrl || '#';
sourceLink.hidden = !sourceUrl;
markerLabel.textContent = stillUnavailable
? t('Source unavailable')
: markerLabelText(camera, mediaUrl);
marker.classList.toggle('qci-marker-link-only', !mediaUrl);
still.hidden = !mediaUrl;
panel.hidden = false;
if (changed || mediaUrl !== selectedMediaUrl) {
selectedMediaUrl = mediaUrl;
setMedia(live, mediaUrl);
setMedia(still, mediaUrl);
if (mediaUrl) {
void live.play().catch(markLiveUnavailable);
void still.play().catch(markStillUnavailable);
startMediaRefresh();
} else {
stopMediaRefresh();
}
} else if (reselected && mediaUrl) {
if (live.error || live.networkState === 3 || live.readyState < 2) {
setMedia(live, mediaRefreshUrl(mediaUrl));
}
if (live.paused) void live.play().catch(markLiveUnavailable);
startMediaRefresh();
if (still.error || still.networkState === 3 || still.readyState < 2)
refreshStill();
}
updateMarker();
}
function updateMarker() {
if (!selected || !selectedPosition) {
marker.hidden = true;
return;
}
const canvas = viewer.scene.canvas;
const point = viewer.scene.cartesianToCanvasCoordinates(selectedPosition);
const visible =
point &&
horizonOccluder(viewer.camera).isPointVisible(selectedPosition) &&
point.x >= 0 &&
point.y >= 0 &&
point.x <= canvas.clientWidth &&
point.y <= canvas.clientHeight;
marker.hidden = !visible;
if (!visible) return;
const rect = canvas.getBoundingClientRect();
marker.style.left = `${rect.left + point.x}px`;
marker.style.top = `${rect.top + point.y}px`;
const distance = Cesium.Cartesian3.distance(
viewer.camera.positionWC,
selectedPosition,
);
marker.style.setProperty(
'--qci-scale',
String(Math.min(1, 600 / Math.max(1, distance))),
);
}
still.addEventListener(
'loadeddata',
() => {
stillUnavailable = false;
if (selected)
markerLabel.textContent = markerLabelText(
selected,
Boolean(selectedMediaUrl),
);
still.pause();
},
{ signal },
);
still.addEventListener('error', markStillUnavailable, { signal });
live.addEventListener('error', markLiveUnavailable, { signal });
live.addEventListener(
'loadeddata',
() => {
liveUnavailable = false;
note.textContent = t(
'The map still uses a separate request and may show a different moment.',
);
note.hidden = false;
},
{ signal },
);
refresh.addEventListener('click', refreshStill, { signal });
close.addEventListener(
'click',
() => {
closedWindowCameraId = selected?.id || null;
panel.hidden = true;
live.pause();
stopMediaRefresh();
// The floating player closes independently; keep the paused map still.
updateMarker();
},
{ signal },
);
header.addEventListener(
'pointerdown',
(event) => {
if (event.target.closest('button')) return;
const rect = panel.getBoundingClientRect();
const offsetX = event.clientX - rect.left;
const offsetY = event.clientY - rect.top;
header.setPointerCapture(event.pointerId);
const move = (moveEvent) => {
panel.style.left = `${Math.max(0, Math.min(window.innerWidth - panel.offsetWidth, moveEvent.clientX - offsetX))}px`;
panel.style.top = `${Math.max(0, Math.min(window.innerHeight - panel.offsetHeight, moveEvent.clientY - offsetY))}px`;
panel.style.right = 'auto';
};
const stop = () => {
header.removeEventListener('pointermove', move);
header.removeEventListener('pointerup', stop);
header.removeEventListener('pointercancel', stop);
};
header.addEventListener('pointermove', move, { signal });
header.addEventListener('pointerup', stop, { once: true, signal });
header.addEventListener('pointercancel', stop, { once: true, signal });
},
{ signal },
);
document.addEventListener(
'visibilitychange',
() => {
if (!selectedMediaUrl) return;
if (document.hidden) {
live.pause();
still.pause();
} else if (closedWindowCameraId !== selected?.id) {
void live.play().catch(markLiveUnavailable);
refreshStill();
}
},
{ signal },
);
const removeRender = viewer.scene.postRender.addEventListener(updateMarker);
return {
setCamera,
dispose() {
controller.abort();
stopMediaRefresh();
removeRender();
setMedia(live, '');
setMedia(still, '');
panel.remove();
marker.remove();
selected = null;
selectedPosition = null;
},
};
}
+71
View File
@@ -0,0 +1,71 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { Window } from 'happy-dom';
import * as Cesium from 'cesium';
import { createQuebecCctvOverlay } from './quebecCctvOverlay.js';
test('routine same-camera updates keep Québec feed errors visible until recovery', async () => {
const window = new Window({ url: 'http://localhost:4173/' });
const previousWindow = globalThis.window;
const previousDocument = globalThis.document;
globalThis.window = window;
globalThis.document = window.document;
window.HTMLMediaElement.prototype.load = function () {};
window.HTMLMediaElement.prototype.play = function () {
return Promise.resolve();
};
window.HTMLMediaElement.prototype.pause = function () {};
window.setInterval = () => 0;
const viewer = {
camera: {
positionWC: Cesium.Cartesian3.fromDegrees(-73.5502, 45.51651, 1000),
},
scene: {
canvas: { clientWidth: 800, clientHeight: 600 },
cartesianToCanvasCoordinates: () => null,
postRender: { addEventListener: () => () => {} },
},
};
let overlay;
try {
overlay = createQuebecCctvOverlay(viewer);
const camera = {
id: 'qc-511-3735',
sourceKind: 'quebec-511-browser',
name: 'Route 136 at rue Panet',
lat: 45.51651,
lon: -73.5502,
positionSource: 'MTQ georeferenced point',
browserMediaUrl:
'https://www.quebec511.info/Carte/Fenetres/camera.ashx?id=3735&format=mp4',
sourceUrl:
'https://www.quebec511.info/Carte/Fenetres/FenetreVideo.html?id=3735',
};
overlay.setCamera(camera);
const [live, still] = window.document.querySelectorAll('video');
live.dispatchEvent(new window.Event('error'));
still.dispatchEvent(new window.Event('error'));
overlay.setCamera({ ...camera });
assert.equal(window.document.querySelector('.qci-note').textContent, 'Source unavailable');
assert.equal(window.document.querySelector('.qci-marker-label').textContent, 'Source unavailable');
live.dispatchEvent(new window.Event('loadeddata'));
still.dispatchEvent(new window.Event('loadeddata'));
assert.match(
window.document.querySelector('.qci-note').textContent,
/separate request/,
);
assert.match(
window.document.querySelector('.qci-marker-label').textContent,
/^PREVIEW ·/,
);
} finally {
overlay?.dispose();
globalThis.window = previousWindow;
globalThis.document = previousDocument;
window.close();
}
});
+98
View File
@@ -0,0 +1,98 @@
.qci-panel {
position: fixed;
z-index: 2000;
top: 80px;
right: 24px;
width: min(360px, calc(100vw - 32px));
overflow: hidden;
border: 1px solid #3eabbc;
border-radius: 8px;
background: #071117;
color: #e6f8fb;
box-shadow: 0 12px 36px #000a;
font: 12px/1.4 sans-serif;
}
.qci-panel[hidden],
.qci-live[hidden],
.qci-note[hidden],
.qci-panel a[hidden],
.qci-marker[hidden],
.qci-marker-still[hidden] {
display: none;
}
.qci-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 10px;
cursor: move;
touch-action: none;
}
.qci-panel button {
border: 1px solid #43818d;
border-radius: 4px;
padding: 4px 8px;
background: #102830;
color: inherit;
cursor: pointer;
}
.qci-panel button:focus-visible {
outline: 2px solid #82e9f7;
outline-offset: 2px;
}
.qci-coordinates,
.qci-credit,
.qci-note {
margin: 0 10px 8px;
color: #a9c5ca;
font: 10px/1.45 'Cascadia Code', Consolas, monospace;
}
.qci-credit {
color: #7fc7d2;
}
.qci-live {
display: block;
width: 100%;
aspect-ratio: 16 / 9;
background: #000;
}
.qci-actions {
display: flex;
gap: 8px;
padding: 8px 10px 0;
}
.qci-actions[hidden] {
display: none;
}
.qci-panel a {
display: inline-block;
margin: 0 10px 10px;
color: #82e9f7;
}
.qci-marker {
position: fixed;
z-index: 500;
width: 160px;
transform: translate(-50%, -100%) scale(var(--qci-scale, 1));
transform-origin: 50% 100%;
pointer-events: none;
padding: 3px;
border: 1px solid #65d7e7;
background: #071117;
color: #d8f9ff;
font: 10px/1.3 sans-serif;
}
.qci-marker-still {
display: block;
width: 100%;
aspect-ratio: 16 / 9;
background: #000;
}
.qci-marker-label {
display: block;
padding: 3px;
}
.qci-marker-link-only {
width: max-content;
max-width: 220px;
}
+1
View File
@@ -12,6 +12,7 @@
@import './src/ui/styles/layers.css';
@import './src/ui/styles/radio.css';
@import './src/ui/styles/cctv.css';
@import './src/ui/styles/quebecCctvOverlay.css';
@import './src/ui/styles/street-level.css';
@import './src/ui/styles/scenes.css';
@import './src/ui/styles/bhote-koshi.css';