mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-08 04:14:02 +00:00
feat(cctv): add Quebec, Ottawa, and World feeds
This commit is contained in:
1 parent
72ceeb2fce
commit
fa4ce4b2a5
59 files changed
+40815
-88
No files matched your search
@@ -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) |
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
@@ -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
|
||||
@@ -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>
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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, [
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 [];
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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 +
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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...": "جارٍ إعداد العارض…",
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.": "ピンを置く場所をクリックしてください。",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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...": "Инициализация систем...",
|
||||
|
||||
@@ -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.": "Клацніть там, де має бути мітка.",
|
||||
|
||||
@@ -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.": "点击图钉的放置位置。",
|
||||
|
||||
@@ -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.": "點擊圖釘的放置位置。",
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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: {},
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { CCTV_ACTIVATION_RESULT } from '../../cctvFocusRequest.js';
|
||||
import { createSelection } from './selection.js';
|
||||
|
||||
function makeSelection(sourceKind) {
|
||||
const camera = { id: 'same-camera', sourceKind };
|
||||
const record = { camera };
|
||||
const state = {
|
||||
_activeCameraId: camera.id,
|
||||
_activeCameraReselection: 0,
|
||||
_recordById: new Map([[camera.id, record]]),
|
||||
};
|
||||
let notifications = 0;
|
||||
const selection = createSelection({
|
||||
state,
|
||||
services: {
|
||||
activation: { CCTV_ACTIVATION_RESULT },
|
||||
picking: { resolvePickId: () => null },
|
||||
},
|
||||
parts: {
|
||||
model: {
|
||||
cctvRecordNeedsActivation: () => false,
|
||||
isBrowserOverlayCamera: (value) =>
|
||||
String(value?.sourceKind || '').startsWith('quebec-'),
|
||||
},
|
||||
presentation: { notifyListeners: () => notifications++ },
|
||||
},
|
||||
source: {},
|
||||
});
|
||||
return { selection, state, notifications: () => notifications };
|
||||
}
|
||||
|
||||
test('same-camera selection does not notify or mutate snapshot-feed state', () => {
|
||||
const fixture = makeSelection('tripcheck-odot');
|
||||
|
||||
assert.equal(
|
||||
fixture.selection.setActiveCamera('same-camera'),
|
||||
CCTV_ACTIVATION_RESULT.UNCHANGED,
|
||||
);
|
||||
assert.equal(fixture.state._activeCameraReselection, 0);
|
||||
assert.equal(fixture.notifications(), 0);
|
||||
});
|
||||
|
||||
test('same-camera selection notifies browser-overlay feeds so playback can retry', () => {
|
||||
const fixture = makeSelection('quebec-511-browser');
|
||||
|
||||
assert.equal(
|
||||
fixture.selection.setActiveCamera('same-camera'),
|
||||
CCTV_ACTIVATION_RESULT.UNCHANGED,
|
||||
);
|
||||
assert.equal(fixture.state._activeCameraReselection, 1);
|
||||
assert.equal(fixture.notifications(), 1);
|
||||
});
|
||||
@@ -29,6 +29,10 @@ export function createState({ services }) {
|
||||
|
||||
layerState._activeCameraId = null;
|
||||
|
||||
// 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)
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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++;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
Reference in new issue
Block a user