mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 02:14:01 +00:00
feat(cctv): seven camera packs, semantic labels, terrain-clear monitor planes (#433)
Seven contributed CCTV camera packs (Ontario 511, Fintraffic Finland, DriveBC, TxDOT Texas, Estonia Tallinn + Tarktee, Warendorf, Live Traffic NSW) on a fair per-pack catalog cap; semantic camera labels; monitor planes lifted clear of the terrain from a precomputed, bundled ground-height dataset. Default catalog 800 → 3,446 cameras. Full context in PR #433. Co-authored-by: sendmebits <[email protected]> Co-authored-by: avocadoAgent <[email protected]> Co-authored-by: Alex Harvey <[email protected]> Co-authored-by: Jason Meredith <[email protected]> Co-authored-by: Daniel Smith <[email protected]> Co-authored-by: Vladimir Sobolev <[email protected]> Co-authored-by: Matthias Schneider <[email protected]> Co-authored-by: Cyber Knight <[email protected]> Co-authored-by: NovaSoft Australia <[email protected]>
This commit is contained in:
51 files changed
+78562
-819
No files matched your search
+23
-3
@@ -147,9 +147,29 @@ AISSTREAM_API_KEY=
|
||||
# CCTV_SOURCES_FILE=config/cctv_sources.austin.json # path to a source-pack JSON
|
||||
# CCTV_SOURCES_JSON= # inline source-pack JSON (overrides file)
|
||||
# CCTV_AUSTIN_ROWS_URL= # override the Austin Open Data catalog URL
|
||||
# CCTV_AUSTIN_MAX_SOURCES=36 # cap cameras loaded from the Austin catalog
|
||||
# CCTV_MAX_SOURCES=48 # overall cap on registered cameras
|
||||
# CCTV_AUSTIN_MAX_SOURCES=250 # cap cameras loaded from the Austin catalog
|
||||
# CCTV_CALTRANS_DISTRICTS=4,7,11,3 # Caltrans districts; empty string disables Caltrans
|
||||
# CCTV_CALTRANS_MAX_SOURCES=300 # cap cameras loaded from Caltrans
|
||||
# CCTV_TFL_ENABLED=1 # set 0 to disable Transport for London JamCams
|
||||
# CCTV_TFL_MAX_SOURCES=250 # cap cameras loaded from TfL
|
||||
# CCTV_ONTARIO_ENABLED=1 # set 0 to disable Ontario 511 cameras
|
||||
# CCTV_ONTARIO_MAX_SOURCES=1000 # cap cameras loaded from Ontario 511
|
||||
# CCTV_FINTRAFFIC_ENABLED=1 # set 0 to drop the Fintraffic (Finland) pack
|
||||
# CCTV_FINTRAFFIC_MAX_SOURCES=300 # cap cameras loaded from the Fintraffic catalog
|
||||
# CCTV_DRIVEBC_MAX_SOURCES=250 # cap DriveBC (British Columbia) cameras, nearest Vancouver/Victoria first
|
||||
# CCTV_DRIVEBC_ENABLED=1 # 0 turns off the DriveBC camera pack
|
||||
# CCTV_TXDOT_ENABLED=1 # set 0 to disable the TxDOT (Texas) pack
|
||||
# CCTV_TXDOT_DISTRICTS=AUS,SAT # TxDOT district codes (25 exist); empty disables the pack
|
||||
# CCTV_TXDOT_MAX_SOURCES=500 # cap cameras loaded from the TxDOT catalogs
|
||||
# CCTV_TALLINN_ENABLED=1 # set 0 to disable the Tallinn (Estonia) intersection cameras
|
||||
# CCTV_TALLINN_MAX_SOURCES=255 # cap cameras loaded from the Tallinn catalog
|
||||
# CCTV_TARKTEE_ENABLED=1 # set 0 to disable the Tarktee (Estonia) road-weather cameras
|
||||
# CCTV_TARKTEE_MAX_SOURCES=179 # cap cameras loaded from Tarktee
|
||||
# CCTV_WARENDORF_ENABLED=1 # set 0 to disable the Warendorf (Germany) municipal webcams
|
||||
# CCTV_NSW_ENABLED=1 # set 0 to disable the Live Traffic NSW (Australia) cameras
|
||||
# CCTV_NSW_MAX_SOURCES=250 # cap cameras loaded from Live Traffic NSW
|
||||
# CCTV_MAX_SOURCES=4000 # catalog-wide ceiling, shared round-robin across packs
|
||||
# CCTV_PREFER_AUSTIN=1 # prioritize Austin cameras in the registry
|
||||
# CCTV_FORCE_AUSTIN= # force Austin-only sources
|
||||
# CCTV_FORCE_AUSTIN= # force live default sources when a source-pack JSON is configured
|
||||
# CCTV_AUTO_CALIBRATE=1 # auto-calibrate camera projection (client flag)
|
||||
# CCTV_DRAPE_MESH=1 # drape frames onto 3D geometry (client flag)
|
||||
@@ -73,6 +73,35 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md
|
||||
|
||||
### Added
|
||||
|
||||
- Add Ontario 511 as a keyless CCTV source pack, including Kitchener-area
|
||||
highway cameras, with server-registered still URLs and attribution.
|
||||
- CCTV Mesh adds Finland: Fintraffic road weather cameras, keyless, nationwide, 300 by default. Each camera view of a station is placed separately; ambient stills refresh on the source's 10-minute cadence (the active camera keeps the usual 10-second refresh).
|
||||
- Add DriveBC highway cameras for British Columbia to the CCTV layer: the 250
|
||||
nearest Vancouver and Victoria by default, with Open Government Licence –
|
||||
British Columbia attribution. `CCTV_DRIVEBC_MAX_SOURCES` sets the cap and
|
||||
`CCTV_DRIVEBC_ENABLED=0` turns the pack off.
|
||||
- Add TxDOT highway cameras for Texas as a keyless CCTV pack: the Austin and
|
||||
San Antonio districts by default (`CCTV_TXDOT_DISTRICTS` selects any of the
|
||||
25), only cameras reporting Device Online, snapshots decoded from TxDOT's
|
||||
JSON-wrapped JPEG for the official origin only.
|
||||
- Add Estonia CCTV source packs: Tallinn intersection stills (`ristmikud.tallinn.ee`,
|
||||
curated catalog) and nationwide Transpordiamet / Tarktee road-weather cameras
|
||||
(DATEX2 locations + rotating JPEG URLs), with Tallinn city POIs and attribution.
|
||||
- Add a Warendorf (Germany) source pack: the Stadt Warendorf Marktplatz webcam, with a
|
||||
curated pose.
|
||||
- Add Live Traffic NSW (Transport for NSW, CC BY 4.0) as a keyless CCTV pack: 217
|
||||
Sydney and regional cameras with compass headings and view descriptions.
|
||||
- CCTV monitor planes no longer clip into the terrain. The plane is lifted
|
||||
rigidly by the largest clearance deficit over a 3×3 grid of support points
|
||||
against the ground under each (the ground at the mount where nothing finer is
|
||||
known), and the client honours pack ranges instead of inflating them to 220 m.
|
||||
`src/data/local_data/cctv_ground_heights/` ships Google 3D Tiles ground heights under
|
||||
every camera's mount and plane footprint (3,445 of 3,446 cameras), produced by
|
||||
`scripts/precompute-cctv-heights.mjs`; cameras with shipped heights are placed
|
||||
with zero runtime sampling, and the rest resolve the ground under their plane
|
||||
from the Re:Earth DEM on activation. The footprint lift is capped at 60 m
|
||||
above the mount-based lift so a tower under a far edge cannot launch the plane.
|
||||
|
||||
- Press backtick (`) to toggle a rendered-frame-rate readout beneath the logo.
|
||||
Typing fields retain the key; monitoring stops when hidden.
|
||||
|
||||
@@ -129,6 +158,8 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md
|
||||
- Extract shared surface keyboard handling for the welcome launcher and Provider
|
||||
Settings, preserving Tab/Escape behavior and releasing the listener on teardown.
|
||||
|
||||
### Added
|
||||
|
||||
### Security
|
||||
|
||||
- Validate configured Google Places coordinates and text queries before rate
|
||||
|
||||
+22
-1
@@ -33,8 +33,16 @@ How to read this:
|
||||
| **Google News RSS** | Primary locality-matched headlines in the cockpit Regional News page | [Google News Terms of Service](https://www.google.com/intl/en_us/terms_google_news.html) restrict use to personal, noncommercial use; linked articles remain third-party publisher content and retain publisher terms | "Google News RSS" plus each article's linked publisher/domain |
|
||||
| **GDELT Project DOC 2.0** | Fail-soft fallback for location-matched cockpit headlines | [GDELT Terms of Use](https://www.gdeltproject.org/about.html#termsofuse): unrestricted academic/commercial/governmental dataset use, with citation and link required; linked articles retain publisher terms | "GDELT Project" plus each article's linked publisher/domain |
|
||||
| **City of Austin Open Data** | CCTV camera catalog + frames | City of Austin Open Data Terms of Use | "City of Austin, TX — data.austintexas.gov" |
|
||||
| **TxDOT ITS (its.txdot.gov)** | CCTV camera catalogs + frames, Texas districts | Public TxDOT traffic camera data | "Texas Department of Transportation" (courtesy) |
|
||||
| **City of Tallinn (ristmikud.tallinn.ee)** | CCTV camera catalog + frames, Tallinn intersections | Public City of Tallinn traffic camera data | "City of Tallinn — ristmikud.tallinn.ee" (courtesy) |
|
||||
| **Transpordiamet / Tarktee** | CCTV camera catalog + frames, Estonia road-weather cameras | Public Transpordiamet / Tarktee road camera data | "Transpordiamet / Tarktee — tarktee.transpordiamet.ee" (courtesy) |
|
||||
| **Stadt Warendorf webcam** | Webcam frames, Marktplatz / Historisches Rathaus (`config/cctv_sources.warendorf.json`) | Public municipal webcam data | "Stadt Warendorf — webcam.warendorf.de" (courtesy); mount and heading derived from OSM geometry, "© OpenStreetMap contributors" (ODbL) |
|
||||
| **Live Traffic NSW (Transport for NSW)** | CCTV camera catalog + frames, New South Wales | [CC BY 4.0](https://opendata.transport.nsw.gov.au/) — attribution REQUIRED | "Live Traffic NSW — Transport for NSW" |
|
||||
| **Caltrans (cwwp2.dot.ca.gov)** | CCTV camera catalogs + frames, California districts | Public Caltrans traffic camera data | "Caltrans — cwwp2.dot.ca.gov" (courtesy) |
|
||||
| **TfL Open Data (JamCams)** | CCTV camera catalog + frames, London | [TfL Open Data terms](https://tfl.gov.uk/info-for/open-data-users/) — attribution REQUIRED | "Powered by TfL Open Data. Contains OS data © Crown copyright and database rights" |
|
||||
| **Ontario 511** | CCTV camera catalog + frames, Ontario highways including Kitchener-area routes | [Open Government Licence – Ontario](https://www.ontario.ca/page/open-government-licence-ontario) | "Ontario 511" plus Open Government Licence - Ontario |
|
||||
| **Fintraffic / Digitraffic (weathercams)** | CCTV camera catalog + frames, Finland | [CC BY 4.0](https://www.digitraffic.fi/en/terms-of-service/) — attribution REQUIRED | "Fintraffic / digitraffic.fi, license CC BY 4.0" |
|
||||
| **DriveBC** (Government of British Columbia) | CCTV camera catalog + frames, British Columbia highways | [Open Government Licence – British Columbia](https://www2.gov.bc.ca/gov/content/data/open-data/open-government-licence-bc) — attribution REQUIRED | "DriveBC. Contains information licensed under the Open Government Licence – British Columbia" |
|
||||
| **GBFS (Lyft / BCycle)** | Bikeshare availability | Per-feed (attribution-only) | Credit the operator (e.g. Austin BCycle) + its `license_url` |
|
||||
| **Radio Browser** | Geolocated internet-radio station directory and station-level tags | Public-domain directory data under PDDL 1.0; individual broadcaster stream terms apply | "Radio Browser" plus a link to the selected broadcaster |
|
||||
| **Re:Earth Terrain** (Mapterhorn) | Terrain (keyless globe stacks — OSM etc. — + `/api/terrain/heights` ellipsoidal-height lookups) | Terrain mesh: CC BY 4.0; geoid: EGM2008 (NGA, public domain) | "Terrain (keyless globe stacks): Re:Earth Terrain / Mapterhorn (CC BY 4.0) / EGM2008 (NGA)" |
|
||||
@@ -46,6 +54,14 @@ How to read this:
|
||||
- **adsb.lol flight fallback.** When OpenSky is unavailable and no last-good OpenSky response exists, the server requests a cached, capped 250 nm adsb.lol point snapshot around the current camera subpoint. This is regional observed context, not worldwide completeness; provenance is exposed in the Flights stats/context row. Military ICAOs remain reconciled through the existing dedicated military registry rather than duplicated.
|
||||
- **Launch Library 2.** `/api/launches` makes a server-side rolling-30-day query against the supported v2.3 detailed launch endpoint, caches successful responses for 15 minutes in memory and on disk, and serves the last successful response during a throttle or transient outage. Anonymous access is limited to 15 calls/hour; deployments can provide `LL2_API_TOKEN` for authenticated access. The Space Devs' published terms permit using and sharing the API data in any form, ask users not to forward it without adding value, disclaim complete accuracy, and encourage—but do not require—attribution. This app keeps a courtesy credit. Payload and stage/recovery records are shown only when supplied. Failed launches expose their source status and never receive fallback orbit geometry or a live/estimated marker. LL2 supplies launch context and event timing, not continuous ascent telemetry or live orbital state.
|
||||
- **TfL JamCams.** The camera list comes from the keyless `api.tfl.gov.uk` endpoint (an optional `TFL_APP_KEY` raises its rate limit); frames come from TfL's public S3 bucket. The "Powered by TfL Open Data" attribution is required by TfL's terms and is registered in the Data attribution popover.
|
||||
- **Ontario 511 CCTV.** The camera list comes from the keyless `511on.ca/api/v2/get/cameras` endpoint; frames are server-registered still images under `511on.ca/map/Cctv/`. The source is live-fetched at runtime and attributed under the Open Government Licence - Ontario.
|
||||
- **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.
|
||||
- **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`.
|
||||
- **Warendorf webcam.** The Stadt Warendorf Marktplatz webcam, registered from the curated `config/cctv_sources.warendorf.json` (the Kreis Warendorf registration-office cameras from the contributing PR are indoor waiting rooms and were left out); only the municipal image host is proxied, frames are fetched at request time and never stored or redistributed. Disable with `CCTV_WARENDORF_ENABLED=0`.
|
||||
- **Live Traffic NSW cameras.** The camera list is the keyless public `data.livetraffic.com/cameras/traffic-cam.json` feed (Transport for NSW open data, CC BY 4.0); frames are stills on `webcams.transport.nsw.gov.au`, and only that host is registered. That image host answers non-browser clients with an HTML placeholder, so the proxy identifies as a browser for that one host and as itself everywhere else. Frames are fetched at request time and never stored or redistributed. Disable with `CCTV_NSW_ENABLED=0`.
|
||||
- **Radio Browser.** `/api/radio/stations` discovers official API mirrors, makes bounded and coalesced healthy/geolocated HTTPS-station queries, caches the normalized public-domain directory for 45 minutes, and may serve the last good catalog for up to seven days during an outage. Refreshes must meet minimum accepted-query and station coverage before replacing a warm catalog; schema-valid responses whose rows all fail the product's health policy do not count as successful queries. A usable partial cold catalog is explicitly `DEGRADED`, and malformed or empty successful payloads are rejected atomically. Every directory and click-count request rejects redirects, validates all resolved addresses as globally routable (including reserved/documentation IPv4 and special/non-global IPv6 exclusions), and pins the TLS connection to a validated address. Only MP3/AAC non-HLS directory rows with public HTTPS stream targets are returned; favicons are intentionally omitted. Pressing play connects one browser audio element directly to the selected broadcaster and calls the directory's click counter through known-ID-only `POST /api/radio/click/:uuid`. GEV never proxies, caches, records, bundles, or redistributes audio. Radio Browser supplies station-level tags, not dependable current-song or upcoming-program metadata, so Radio filtering never claims either. Direct playback exposes the listener's IP address to the broadcaster, whose own stream terms apply.
|
||||
- **TomTom Traffic.** Optional and BYOK: without `TOMTOM_API_KEY` the traffic layer runs its built-in simulation and no TomTom data (or attribution) appears. With a key, flow vector tiles are fetched through the server-side `/api/tomtom` proxy (120 s cache + a daily tile-budget governor — `TOMTOM_DAILY_TILE_BUDGET`, default 40,000, a configurable application safety ceiling, not a guarantee of staying within TomTom's monthly free allowance; TomTom's [current pricing](https://docs.tomtom.com/pricing/) lists 200K free tile requests per month) and the "Traffic flow data © TomTom" credit is registered in the Data attribution popover the moment live mode activates. TomTom data is served live and cached only transiently (≤120 s TTL under `.gev-cache/`, gitignored) — it is not bundled or redistributed. One 23 KB point-in-time tile snapshot is committed as a decode-test fixture (`src/data/fixtures/`, © TomTom, never served to the app).
|
||||
- **Re:Earth Terrain.** Keyless (no API key). Used two ways: (1) `src/mapStackController.js` swaps in a `Cesium.CesiumTerrainProvider` pointed at Re:Earth's `cesium-mesh/ellipsoid` quantized-mesh endpoint for globe stacks without a Cesium ion token (e.g. OSM), replacing a flat `EllipsoidTerrainProvider`; falls back to the flat provider if the endpoint can't be reached. (2) The server-side `/api/terrain/heights` proxy (disk-cached, serve-stale) resolves per-point ellipsoidal ground height for entity placement. Both are best-effort with a keyless-safe fallback (bundled EGM96 geoid math) if Re:Earth is unreachable.
|
||||
@@ -66,6 +82,11 @@ Static datasets shipped in the repo for an out-of-the-box experience. **None are
|
||||
| **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) |
|
||||
| **CCTV ground heights** (3,445 cameras, 34,444 sampled points) | `cctv_ground_heights/` | Derived placement data: heights sampled from the rendered Google Photorealistic 3D Tiles surface (provenance in the folder README) | Same terms as the map it was sampled from | None beyond the map's own Google credit |
|
||||
|
||||
### CCTV ground heights are derived placement data
|
||||
|
||||
`cctv_ground_heights/` holds, per camera in the served CCTV catalog, the WGS84 ellipsoidal ground height Google Photorealistic 3D Tiles report at the camera's position and under the nine support points of its monitor plane, sampled from a running app by `scripts/precompute-cctv-heights.mjs`. It is numeric placement data (a pin dropped on the rendered surface), not imagery or tiles, and it exists so the client can place cameras and planes with zero runtime sampling. Each entry is keyed to the pose it was sampled for and is regenerated whenever a pack's poses change; the folder README documents the schema and the regeneration command. It lives with the other bundled datasets so any consumer that renders these cameras on the same surface reads the same file, under the same terms as the map it came from.
|
||||
|
||||
### ⚠️ TeleGeography is bundled but NonCommercial
|
||||
|
||||
@@ -130,4 +151,4 @@ Douglas-Peucker simplification, 6-decimal rounding).
|
||||
|
||||
## In-app attribution
|
||||
|
||||
The required Google Maps / Cesium credit renders on the on-globe credit line (`#cesium-credits`, bottom-left) and must stay visible — including in clean-view and recording modes (the whole line, logo + "Google Maps" + the "Data attribution" link, stays on screen; only the GEV panels/HUD fade). The layer-specific credits (adsb.lol, TeleGeography, OSM datacenters/dams/roads, NASA FIRMS, CelesTrak, USGS, City of Austin, GBFS, Radio Browser, OpenSky, AISStream) are registered into the expandable **"Data attribution"** popover on that credit line via `viewer.creditDisplay.addStaticCredit(new Cesium.Credit(html, /* showOnScreen */ false))` — see `src/data/dataCredits.js`. When you add a new data source, add its license and attribution to this file **and** append an entry to `DATA_CREDITS` in `src/data/dataCredits.js` so it surfaces in the app.
|
||||
The required Google Maps / Cesium credit renders on the on-globe credit line (`#cesium-credits`, bottom-left) and must stay visible — including in clean-view and recording modes (the whole line, logo + "Google Maps" + the "Data attribution" link, stays on screen; only the GEV panels/HUD fade). The layer-specific credits (adsb.lol, TeleGeography, OSM datacenters/dams/roads, NASA FIRMS, CelesTrak, USGS, City of Austin, Fintraffic, GBFS, Radio Browser, OpenSky, AISStream) are registered into the expandable **"Data attribution"** popover on that credit line via `viewer.creditDisplay.addStaticCredit(new Cesium.Credit(html, /* showOnScreen */ false))` — see `src/data/dataCredits.js`. When you add a new data source, add its license and attribution to this file **and** append an entry to `DATA_CREDITS` in `src/data/dataCredits.js` so it surfaces in the app.
|
||||
@@ -188,7 +188,7 @@ Choose a first-run mission, or try these in order. The GIFs show Google Photorea
|
||||
|
||||

|
||||
|
||||
4. **Look through a public camera.** Turn on **CCTV** over Austin, London, or California. The feeds aren't webcam embeds — they project *into* the 3D city. Cycle coverage to **VIEWSHED** and every camera draws its estimated coverage volume — where it reaches, and where it goes blind.
|
||||
4. **Look through a public camera.** Turn on **CCTV** over Austin, London, California, or Finland. The feeds aren't webcam embeds — they project *into* the 3D city. Cycle coverage to **VIEWSHED** and every camera draws its estimated coverage volume — where it reaches, and where it goes blind.
|
||||
|
||||

|
||||
|
||||
@@ -275,7 +275,7 @@ Thirteen layers and map sources. **Eleven have a keyless path.** Some offer addi
|
||||
| 🛰️ **Satellites** | 838-object catalog, color-coded by class with a live legend — the **DENSE** chip drops in the whole Starlink shell | CelesTrak | 🟢 |
|
||||
| 🌍 **Earthquakes** | Global seismic activity, last 24h | USGS | 🟢 |
|
||||
| 🚗 **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** | ~800 public cameras projected *into* the 3D space — Austin · California (Caltrans) · London (TfL). Positions are published; poses are estimated priors **you calibrate by dragging a gizmo on the camera itself** | City APIs | 🟢 |
|
||||
| 📹 **CCTV Mesh** | ~3,400 public cameras projected *into* the 3D space — Austin · Texas (TxDOT) · California (Caltrans) · London (TfL) · Ontario (511) · Finland (Fintraffic) · British Columbia (DriveBC) · Estonia (Tallinn, Tarktee) · New South Wales (Live Traffic NSW). Positions are published; poses are estimated priors **you calibrate by dragging a gizmo on the camera itself** | City APIs | 🟢 |
|
||||
| 📻 **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 | 🟢 |
|
||||
| 🚲 **Bikeshare** | Live station availability | GBFS | 🟢 |
|
||||
| 🔥 **Active Fires** | Live NASA FIRMS detections, trailing 24h | NASA FIRMS | 🟡 |
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,23 @@
|
||||
[
|
||||
{
|
||||
"id": "warendorf-marktplatz-rathaus",
|
||||
"name": "Marktplatz / Historisches Rathaus",
|
||||
"city": "Warendorf",
|
||||
"provider": "Stadt Warendorf",
|
||||
"sourceKind": "municipal-webcam",
|
||||
"feedType": "image",
|
||||
"url": "http://webcam.warendorf.de/image/jpeg.cgi",
|
||||
"snapshotUrl": "http://webcam.warendorf.de/image/jpeg.cgi",
|
||||
"lat": 51.9526613,
|
||||
"lon": 7.9908867,
|
||||
"headingDeg": 221,
|
||||
"headingConfidence": "high",
|
||||
"pitchDeg": -23,
|
||||
"fovDeg": 84,
|
||||
"rangeM": 260,
|
||||
"mountHeightM": 14,
|
||||
"groundElevationM": 55,
|
||||
"poseSource": "curated",
|
||||
"license": "Public municipal webcam data — Stadt Warendorf (courtesy). Mount and heading derived from OpenStreetMap geometry, © OpenStreetMap contributors (ODbL)."
|
||||
}
|
||||
]
|
||||
+29
-3
@@ -217,6 +217,22 @@ owners. Tuner calculations have a pure entry, with existing layer exports
|
||||
preserved. Destruction removes listeners and state subscriptions before ending
|
||||
tuning; a delayed Enable result cannot reveal or refocus removed controls.
|
||||
|
||||
## DriveBC CCTV source pack
|
||||
|
||||
The CCTV catalog adds DriveBC highway cameras for British Columbia alongside the
|
||||
Austin, Caltrans and TfL packs. The server reads the keyless camera list that
|
||||
DriveBC.ca serves at `https://www.drivebc.ca/api/webcams/` and keeps cameras that
|
||||
are switched on and published. Frame URLs are built from each numeric camera id
|
||||
under `https://www.drivebc.ca/images/`; they are never taken from the payload.
|
||||
DriveBC's eight compass orientations become high-confidence heading priors, and
|
||||
its elevation (metres above sea level) seeds the ground height.
|
||||
|
||||
By default the 250 cameras nearest downtown Vancouver and Victoria load.
|
||||
`CCTV_DRIVEBC_MAX_SOURCES` sets the pack cap (8–1200) and
|
||||
`CCTV_DRIVEBC_ENABLED=0` turns the pack off. The default catalog cap rises from
|
||||
900 to 1,050, the sum of the default pack caps, so no default camera is dropped.
|
||||
The Open Government Licence – British Columbia attribution is registered in the
|
||||
Data attribution popover.
|
||||
|
||||
## Location control ownership
|
||||
|
||||
@@ -2250,7 +2266,7 @@ its criteria cannot be silently ignored.
|
||||
| Satellites | CelesTrak | `src/data/satellites.js` | `/api/celestrak` | 120s |
|
||||
| Space Missions (30d) | Launch Library 2 + CelesTrak | `src/data/rocketLaunches.js` | `/api/launches` + `/api/celestrak/active` | 5 min |
|
||||
| Traffic | OSM Overpass (+ optional TomTom live flow) | `src/data/traffic.js` | `/api/overpass` + `/api/tomtom` | viewport-driven |
|
||||
| CCTV | Austin + Caltrans (CA) + TfL London Open Data + Street View fallback | `src/data/cctv.js` | `/api/cctv` | 10s (active) |
|
||||
| CCTV | Austin + Caltrans (CA) + TfL London + Ontario 511 + Fintraffic (FI) + DriveBC (BC) + TxDOT (TX) + Estonia (Tallinn, Tarktee) + Live Traffic NSW Open Data + Street View fallback | `src/data/cctv.js` | `/api/cctv` | 10s (active) |
|
||||
| Radio | Radio Browser (public-domain station directory) | `src/data/radio.js` | `/api/radio/stations`, `/api/radio/click/:uuid` | 45 min directory refresh |
|
||||
| Bikeshare 🚲 | GBFS (Lyft + BCycle) | `src/data/bikeshare.js` | `/api/gbfs` | 60s |
|
||||
| Datacenters ▣ | OSM extract (bundled) | `src/data/localLayers.js` | — | static |
|
||||
@@ -2601,7 +2617,16 @@ silently demoting every later lookup for the session.
|
||||
default 36 → 250, hard bound 300), filtered to `camera_status === TURNED_ON` (~815 live of
|
||||
1,003 rows). City packs (2026-07-04): Caltrans (districts 4/7/11/3 — SF, LA, San Diego,
|
||||
Sacramento; cap 300) and TfL London JamCams (cap 250) join Austin (cap 250) as keyless default
|
||||
sources — ~800 cameras total, all RAW PRIOR poses, stills-first.
|
||||
sources. Ontario 511 (2026-09-12) adds keyless highway cameras including Kitchener-area routes
|
||||
(cap 1,000, all enabled rows from the current ~944-camera catalog). All sources are RAW PRIOR
|
||||
poses, and the layer is stills-first.
|
||||
sources. Fintraffic Finland road weather cameras (2026-09-13; cap 300, `CCTV_FINTRAFFIC_MAX_SOURCES`,
|
||||
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.
|
||||
~1,100 cameras total, all RAW PRIOR poses, stills-first.
|
||||
- **CCTV v3 UX — viewshed + calibration gizmo** (built 2026-07-05 and field
|
||||
validated 2026-07-21): the COVERAGE toggle is a
|
||||
tri-state cycle `OFF → ON → VIEWSHED`; viewshed mode renders each visible camera's frustum
|
||||
@@ -2634,7 +2659,8 @@ silently demoting every later lookup for the session.
|
||||
2026-08-02): the LOD-selected nearby static cameras (20/28/40 by zoom,
|
||||
`cctvLod.js`) get **screen-space thumbnail cards** through the shared world-overlay host
|
||||
showing paced static frames — reselection on `camera.moveEnd` only, at most one frame fetch
|
||||
per second layer-wide, per-source cadences (Austin 5 min, TfL/Caltrans 3 min). Zero-flicker:
|
||||
per second layer-wide, per-source cadences (Austin 5 min, TfL/Caltrans 3 min, Fintraffic 10 min
|
||||
— its stations publish on a 600 s collection interval). Zero-flicker:
|
||||
a card renders nothing until its first frame, a drawn frame persists through failed fetches,
|
||||
and eviction grace (2-pass/5 s) stops budget-edge churn. Camera icons stay visible at every
|
||||
zoom. Eligible candidates are filtered to in-view stills with valid IDs,
|
||||
|
||||
+39
-4
@@ -11,9 +11,11 @@ PORT="${PORT:-4173}"
|
||||
HOST="${HOST:-localhost}"
|
||||
# CCTV source packs (all keyless): Austin (~815 live upstream), Caltrans
|
||||
# districts 4,7,11,3 = SF/LA/San Diego/Sacramento (~1,860 live upstream),
|
||||
# TfL London JamCams (~870 live upstream). Caps keep the densest cores per
|
||||
# pack; override per-run for lighter/heavier loads. Kill switches:
|
||||
# CCTV_CALTRANS_DISTRICTS='' and CCTV_TFL_ENABLED=0.
|
||||
# TfL London JamCams (~870 live upstream), Ontario 511 (~944 live upstream,
|
||||
# including Kitchener-area highways), and Fintraffic Finland weathercams
|
||||
# (~2,260 live presets). Caps keep the densest cores per pack; override
|
||||
# per-run for lighter/heavier loads. Kill switches: CCTV_CALTRANS_DISTRICTS='',
|
||||
# CCTV_TFL_ENABLED=0, CCTV_ONTARIO_ENABLED=0 and CCTV_FINTRAFFIC_ENABLED=0.
|
||||
CCTV_AUSTIN_MAX_SOURCES="${CCTV_AUSTIN_MAX_SOURCES:-250}"
|
||||
# Use `-` not `:-` so an explicit empty string (the documented kill switch)
|
||||
# is preserved rather than replaced by the default. Still set-u-safe when unset.
|
||||
@@ -21,7 +23,24 @@ CCTV_CALTRANS_DISTRICTS="${CCTV_CALTRANS_DISTRICTS-4,7,11,3}"
|
||||
CCTV_CALTRANS_MAX_SOURCES="${CCTV_CALTRANS_MAX_SOURCES:-300}"
|
||||
CCTV_TFL_ENABLED="${CCTV_TFL_ENABLED:-1}"
|
||||
CCTV_TFL_MAX_SOURCES="${CCTV_TFL_MAX_SOURCES:-250}"
|
||||
CCTV_MAX_SOURCES="${CCTV_MAX_SOURCES:-900}"
|
||||
CCTV_ONTARIO_ENABLED="${CCTV_ONTARIO_ENABLED:-1}"
|
||||
CCTV_ONTARIO_MAX_SOURCES="${CCTV_ONTARIO_MAX_SOURCES:-1000}"
|
||||
CCTV_FINTRAFFIC_ENABLED="${CCTV_FINTRAFFIC_ENABLED:-1}"
|
||||
CCTV_FINTRAFFIC_MAX_SOURCES="${CCTV_FINTRAFFIC_MAX_SOURCES:-300}"
|
||||
CCTV_DRIVEBC_ENABLED="${CCTV_DRIVEBC_ENABLED:-1}"
|
||||
CCTV_DRIVEBC_MAX_SOURCES="${CCTV_DRIVEBC_MAX_SOURCES:-250}"
|
||||
CCTV_TXDOT_ENABLED="${CCTV_TXDOT_ENABLED:-1}"
|
||||
# Use `-` so an explicit empty string (the documented kill switch) is preserved.
|
||||
CCTV_TXDOT_DISTRICTS="${CCTV_TXDOT_DISTRICTS-AUS,SAT}"
|
||||
CCTV_TXDOT_MAX_SOURCES="${CCTV_TXDOT_MAX_SOURCES:-500}"
|
||||
CCTV_TALLINN_ENABLED="${CCTV_TALLINN_ENABLED:-1}"
|
||||
CCTV_TALLINN_MAX_SOURCES="${CCTV_TALLINN_MAX_SOURCES:-255}"
|
||||
CCTV_TARKTEE_ENABLED="${CCTV_TARKTEE_ENABLED:-1}"
|
||||
CCTV_TARKTEE_MAX_SOURCES="${CCTV_TARKTEE_MAX_SOURCES:-179}"
|
||||
CCTV_WARENDORF_ENABLED="${CCTV_WARENDORF_ENABLED:-1}"
|
||||
CCTV_NSW_ENABLED="${CCTV_NSW_ENABLED:-1}"
|
||||
CCTV_NSW_MAX_SOURCES="${CCTV_NSW_MAX_SOURCES:-250}"
|
||||
CCTV_MAX_SOURCES="${CCTV_MAX_SOURCES:-4000}"
|
||||
|
||||
# Capture which provider credentials genuinely came from the parent shell
|
||||
# before this launcher resolves dotenv and Keychain fallbacks. Only names are
|
||||
@@ -364,7 +383,23 @@ put_env CCTV_CALTRANS_DISTRICTS "${CCTV_CALTRANS_DISTRICTS}"
|
||||
put_env CCTV_CALTRANS_MAX_SOURCES "${CCTV_CALTRANS_MAX_SOURCES}"
|
||||
put_env CCTV_TFL_ENABLED "${CCTV_TFL_ENABLED}"
|
||||
put_env CCTV_TFL_MAX_SOURCES "${CCTV_TFL_MAX_SOURCES}"
|
||||
put_env CCTV_ONTARIO_ENABLED "${CCTV_ONTARIO_ENABLED}"
|
||||
put_env CCTV_ONTARIO_MAX_SOURCES "${CCTV_ONTARIO_MAX_SOURCES}"
|
||||
put_env_if_set TFL_APP_KEY "${TFL_APP_KEY:-}"
|
||||
put_env CCTV_FINTRAFFIC_ENABLED "${CCTV_FINTRAFFIC_ENABLED}"
|
||||
put_env CCTV_FINTRAFFIC_MAX_SOURCES "${CCTV_FINTRAFFIC_MAX_SOURCES}"
|
||||
put_env CCTV_DRIVEBC_ENABLED "${CCTV_DRIVEBC_ENABLED}"
|
||||
put_env CCTV_DRIVEBC_MAX_SOURCES "${CCTV_DRIVEBC_MAX_SOURCES}"
|
||||
put_env CCTV_TXDOT_ENABLED "${CCTV_TXDOT_ENABLED}"
|
||||
put_env CCTV_TXDOT_DISTRICTS "${CCTV_TXDOT_DISTRICTS}"
|
||||
put_env CCTV_TXDOT_MAX_SOURCES "${CCTV_TXDOT_MAX_SOURCES}"
|
||||
put_env CCTV_TALLINN_ENABLED "${CCTV_TALLINN_ENABLED}"
|
||||
put_env CCTV_TALLINN_MAX_SOURCES "${CCTV_TALLINN_MAX_SOURCES}"
|
||||
put_env CCTV_TARKTEE_ENABLED "${CCTV_TARKTEE_ENABLED}"
|
||||
put_env CCTV_TARKTEE_MAX_SOURCES "${CCTV_TARKTEE_MAX_SOURCES}"
|
||||
put_env CCTV_WARENDORF_ENABLED "${CCTV_WARENDORF_ENABLED}"
|
||||
put_env CCTV_NSW_ENABLED "${CCTV_NSW_ENABLED}"
|
||||
put_env CCTV_NSW_MAX_SOURCES "${CCTV_NSW_MAX_SOURCES}"
|
||||
put_env CCTV_MAX_SOURCES "${CCTV_MAX_SOURCES}"
|
||||
put_env OPENSKY_AUTH_MODE "${OPENSKY_AUTH_MODE}"
|
||||
put_env_if_set OPENSKY_CREDENTIALS_FILE "${OPENSKY_CREDENTIALS_FILE}"
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
"scripts/package-boundaries.json",
|
||||
"scripts/format.mjs",
|
||||
"scripts/check-package-boundaries.mjs",
|
||||
"scripts/precompute-cctv-heights.mjs",
|
||||
"src/tooling/precomputeCctvHeights.test.mjs",
|
||||
"src/tooling/format.test.mjs",
|
||||
"src/tooling/packageBoundaries.test.mjs",
|
||||
"src/data/infrastructure.js",
|
||||
@@ -71,6 +73,8 @@
|
||||
"src/data/firmsCsv.test.mjs",
|
||||
"src/tooling/environmentProviders.test.mjs",
|
||||
"server/providers/cctv.js",
|
||||
"server/providers/cctv/cap.js",
|
||||
"server/providers/cctv/groundHeights.js",
|
||||
"server/providers/cctv/catalog.js",
|
||||
"server/providers/cctv/constants.js",
|
||||
"server/providers/cctv/media.js",
|
||||
@@ -455,6 +459,15 @@
|
||||
"src/layers/cctv/ground.js",
|
||||
"src/layers/cctv/health.js",
|
||||
"src/data/cctv.js",
|
||||
"src/data/cctvCatalogCap.test.mjs",
|
||||
"src/data/cctvDisplayCode.test.mjs",
|
||||
"src/data/cctvFootprint.js",
|
||||
"src/data/cctvGroundHeights.test.mjs",
|
||||
"src/data/cctvFootprint.test.mjs",
|
||||
"src/data/cctvEstonia.test.mjs",
|
||||
"src/data/cctvWarendorf.test.mjs",
|
||||
"src/data/cctvNswSource.test.mjs",
|
||||
"src/data/cctvTxdotSource.test.mjs",
|
||||
"src/layers/cctv/source.test.mjs",
|
||||
"src/layers/radio/catalogModel.js",
|
||||
"src/layers/radio/categories.js",
|
||||
|
||||
@@ -141,6 +141,8 @@
|
||||
"exports": ["./server/providers/cctv"],
|
||||
"modules": [
|
||||
"server/providers/cctv.js",
|
||||
"server/providers/cctv/cap.js",
|
||||
"server/providers/cctv/groundHeights.js",
|
||||
"server/providers/cctv/catalog.js",
|
||||
"server/providers/cctv/constants.js",
|
||||
"server/providers/cctv/media.js",
|
||||
@@ -149,7 +151,8 @@
|
||||
"src/data/directionText.js",
|
||||
"server/providers/common/geo.js",
|
||||
"server/providers/places/google-key.js",
|
||||
"scripts/google-server-key.mjs"
|
||||
"scripts/google-server-key.mjs",
|
||||
"src/data/cctvFootprint.js"
|
||||
],
|
||||
"external": []
|
||||
},
|
||||
@@ -890,7 +893,8 @@
|
||||
"src/layers/cctv/source.js",
|
||||
"src/layers/cctv/state.js",
|
||||
"src/layers/cctv/testing.js",
|
||||
"src/overlays/worldOverlayTokens.js"
|
||||
"src/overlays/worldOverlayTokens.js",
|
||||
"src/data/cctvFootprint.js"
|
||||
],
|
||||
"external": ["cesium"]
|
||||
},
|
||||
|
||||
@@ -0,0 +1,487 @@
|
||||
import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
import {
|
||||
planeSupportPoints,
|
||||
poseHash,
|
||||
SUPPORT_KEYS,
|
||||
} from '../src/data/cctvFootprint.js';
|
||||
|
||||
const ROOT = fileURLToPath(new URL('../', import.meta.url));
|
||||
const SAMPLE_KEYS = ['mount', ...SUPPORT_KEYS];
|
||||
const BATCH_SIZES = [16, 4, 1];
|
||||
const DEADLINE_MS = 90_000;
|
||||
|
||||
/** Extract the served nominal pose (the values the catalog ships). */
|
||||
export function nominalPose(source) {
|
||||
const pose = Object.fromEntries(
|
||||
[
|
||||
'lat',
|
||||
'lon',
|
||||
'headingDeg',
|
||||
'pitchDeg',
|
||||
'fovDeg',
|
||||
'rangeM',
|
||||
'mountHeightM',
|
||||
].map((key) => {
|
||||
if (typeof source[key] !== 'number' || !Number.isFinite(source[key])) {
|
||||
throw new Error(`Camera ${source.id}: invalid ${key}`);
|
||||
}
|
||||
return [key, source[key]];
|
||||
}),
|
||||
);
|
||||
if (Math.abs(pose.lat) > 90 || Math.abs(pose.lon) > 180) {
|
||||
throw new Error(`Camera ${source.id}: invalid coordinates`);
|
||||
}
|
||||
// Hashed exactly as served: the server join (groundHeights.js) and the
|
||||
// client (camera.servedPose) hash the same raw values, so no clamp here.
|
||||
return pose;
|
||||
}
|
||||
|
||||
/** Return a roughly 2 km grid key, with longitude width fixed per row. */
|
||||
export function cellKey({ lat, lon }) {
|
||||
if (
|
||||
!Number.isFinite(lat) ||
|
||||
!Number.isFinite(lon) ||
|
||||
Math.abs(lat) > 90 ||
|
||||
Math.abs(lon) > 180
|
||||
) {
|
||||
throw new Error('Invalid cell coordinates');
|
||||
}
|
||||
const row = Math.min(8999, Math.floor((lat + 90) / 0.02));
|
||||
const rowLat = -90 + (row + 0.5) * 0.02;
|
||||
const lonWidth = 0.02 / Math.cos((rowLat * Math.PI) / 180);
|
||||
const col = Math.floor(((lon === 180 ? -180 : lon) + 180) / lonWidth);
|
||||
return `${row},${col}`;
|
||||
}
|
||||
|
||||
/** Split an ordered array into bounded batches without mutating it. */
|
||||
export function batches(items, size = 16) {
|
||||
if (!Number.isSafeInteger(size) || size < 1)
|
||||
throw new Error('Invalid batch size');
|
||||
const result = [];
|
||||
for (let i = 0; i < items.length; i += size)
|
||||
result.push(items.slice(i, i + size));
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Group cameras by cell and order cells numerically by row, then column. */
|
||||
export function planCells(cameras) {
|
||||
const cells = new Map();
|
||||
for (const camera of cameras) {
|
||||
const key = cellKey(camera.pose);
|
||||
if (!cells.has(key)) cells.set(key, { key, cameras: [] });
|
||||
cells.get(key).cameras.push(camera);
|
||||
}
|
||||
return [...cells.values()].sort((a, b) => {
|
||||
const [ar, ac] = a.key.split(',').map(Number);
|
||||
const [br, bc] = b.key.split(',').map(Number);
|
||||
return ar - br || ac - bc;
|
||||
});
|
||||
}
|
||||
|
||||
/** Preserve ellipsoidal metres; missing, non-finite and implausible values are null. */
|
||||
export function plausibleHeight(height) {
|
||||
return Number.isFinite(height) && height >= -150 ? height : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Only a complete, successful entry for exactly the current nominal pose is
|
||||
* reusable; an entry with missing supports is resampled (valid heights are
|
||||
* kept by mergeCameraSamples).
|
||||
*/
|
||||
export function needsSampling(entry, pose) {
|
||||
return (
|
||||
entry?.status !== 'ok' ||
|
||||
entry.poseHash !== poseHash(pose) ||
|
||||
(Array.isArray(entry.misses) && entry.misses.length > 0)
|
||||
);
|
||||
}
|
||||
|
||||
/** Merge retry samples without losing valid heights; a missing mount stores no heights. */
|
||||
export function mergeCameraSamples(
|
||||
pose,
|
||||
heights,
|
||||
previous,
|
||||
attempts,
|
||||
sampledAt,
|
||||
) {
|
||||
const hash = poseHash(pose);
|
||||
const prior = previous?.poseHash === hash ? previous : null;
|
||||
const values = SAMPLE_KEYS.map(
|
||||
(key, i) =>
|
||||
plausibleHeight(heights[i]) ??
|
||||
plausibleHeight(
|
||||
key === 'mount' ? prior?.mountGroundM : prior?.supports?.[key],
|
||||
),
|
||||
);
|
||||
const entry = {
|
||||
poseHash: hash,
|
||||
status: values[0] === null ? 'miss' : 'ok',
|
||||
misses: SAMPLE_KEYS.filter((_, i) => values[i] === null),
|
||||
sampledAt,
|
||||
attempts,
|
||||
};
|
||||
if (entry.status === 'ok') {
|
||||
entry.mountGroundM = values[0];
|
||||
entry.supports = Object.fromEntries(
|
||||
SUPPORT_KEYS.map((key, i) => [key, values[i + 1]]),
|
||||
);
|
||||
}
|
||||
return entry;
|
||||
}
|
||||
|
||||
/** Build a sidecar while retaining entries outside this run's selection. */
|
||||
export function mergeSidecar(existing, updates, generatedAt) {
|
||||
const cameras = { ...existing?.cameras, ...updates };
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
provider: 'google-photorealistic-3d',
|
||||
heightReference: 'WGS84-ellipsoid',
|
||||
generatedAt,
|
||||
cameras: Object.fromEntries(
|
||||
Object.keys(cameras)
|
||||
.sort()
|
||||
.map((id) => [id, cameras[id]]),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/** Parse pilot filters; the limit applies after kinds, before resume filtering. */
|
||||
export function parseArgs(argv) {
|
||||
const options = { limit: Infinity, kinds: null, dryRun: false };
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
const arg = argv[i];
|
||||
if (arg === '--dry-run') options.dryRun = true;
|
||||
else if (arg === '--limit') {
|
||||
const value = argv[++i];
|
||||
if (!/^\d+$/.test(value ?? '') || !Number.isSafeInteger(Number(value))) {
|
||||
throw new Error('--limit requires a non-negative integer');
|
||||
}
|
||||
options.limit = Number(value);
|
||||
} else if (arg === '--kinds') {
|
||||
const value = argv[++i];
|
||||
if (
|
||||
!value ||
|
||||
value.startsWith('--') ||
|
||||
value.split(',').some((kind) => !kind.trim())
|
||||
) {
|
||||
throw new Error('--kinds requires comma-separated sourceKinds');
|
||||
}
|
||||
options.kinds = value.split(',').map((kind) => kind.trim());
|
||||
} else throw new Error(`Unknown argument: ${arg}`);
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
/** Use the shared footprint's mount and nine supports in the sampling wire order. */
|
||||
export function samplingPoints(pose) {
|
||||
const footprint = planeSupportPoints(pose);
|
||||
return SAMPLE_KEYS.map((key) => {
|
||||
const point = key === 'mount' ? footprint.mount : footprint.supports[key];
|
||||
if (!Number.isFinite(point?.lat) || !Number.isFinite(point?.lon)) {
|
||||
throw new Error(
|
||||
`Shared planeSupportPoints returned invalid coordinates for ${key}`,
|
||||
);
|
||||
}
|
||||
return { lat: point.lat, lon: point.lon };
|
||||
});
|
||||
}
|
||||
|
||||
/** Sample one cell serially, retrying failed cameras at most three times (16/4/1). */
|
||||
export async function sampleCell(
|
||||
cameras,
|
||||
sampler,
|
||||
now = () => new Date().toISOString(),
|
||||
existing = null,
|
||||
) {
|
||||
// Seed with the prior entries so a resumed camera keeps every valid height
|
||||
// it already has (mergeCameraSamples only replaces misses).
|
||||
const entries = Object.create(null);
|
||||
for (const camera of cameras) {
|
||||
if (existing?.[camera.id]) entries[camera.id] = existing[camera.id];
|
||||
}
|
||||
let pending = cameras;
|
||||
for (const [index, size] of BATCH_SIZES.entries()) {
|
||||
const retry = [];
|
||||
for (const batch of batches(pending, size)) {
|
||||
let samples;
|
||||
try {
|
||||
samples = await sampler.sample(batch);
|
||||
} catch (error) {
|
||||
sampler.warn?.(
|
||||
`Attempt ${index + 1}, ${batch.length} cameras: ${error.message}`,
|
||||
);
|
||||
// A timed-out evaluate can still be sampling: destroy its page before retrying.
|
||||
await sampler.recycle();
|
||||
samples = batch.map(() => []);
|
||||
}
|
||||
const sampledAt = now();
|
||||
for (let i = 0; i < batch.length; i += 1) {
|
||||
const camera = batch[i];
|
||||
const entry = mergeCameraSamples(
|
||||
camera.pose,
|
||||
samples[i] ?? [],
|
||||
entries[camera.id],
|
||||
index + 1,
|
||||
sampledAt,
|
||||
);
|
||||
entries[camera.id] = entry;
|
||||
if (entry.misses.length) retry.push(camera);
|
||||
}
|
||||
}
|
||||
pending = retry;
|
||||
if (!pending.length) break;
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
/** Bound a sampling operation; callers must recycle its page after rejection. */
|
||||
export async function withDeadline(operation, timeoutMs = DEADLINE_MS) {
|
||||
let timer;
|
||||
try {
|
||||
return await Promise.race([
|
||||
operation,
|
||||
new Promise((_, reject) => {
|
||||
timer = setTimeout(
|
||||
() =>
|
||||
reject(
|
||||
new Error(
|
||||
`Height sampling timed out after ${timeoutMs / 1000} s`,
|
||||
),
|
||||
),
|
||||
timeoutMs,
|
||||
);
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
async function openPage(browser, base) {
|
||||
const page = await browser.newPage();
|
||||
try {
|
||||
await page.goto(
|
||||
`${base}/#v=2&lat=30&lon=-95&alt=17000000&heading=0&pitch=-90&roll=0`,
|
||||
);
|
||||
await page.waitForFunction(() => !!window.__godsEyeView?.viewer);
|
||||
return page;
|
||||
} catch (error) {
|
||||
await page.close();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSidecar(output) {
|
||||
let existing;
|
||||
try {
|
||||
existing = JSON.parse(await readFile(output, 'utf8'));
|
||||
} catch (error) {
|
||||
if (error.code === 'ENOENT') return null;
|
||||
throw error;
|
||||
}
|
||||
if (
|
||||
existing?.schemaVersion !== 1 ||
|
||||
existing.provider !== 'google-photorealistic-3d' ||
|
||||
existing.heightReference !== 'WGS84-ellipsoid' ||
|
||||
!existing.cameras ||
|
||||
typeof existing.cameras !== 'object' ||
|
||||
Array.isArray(existing.cameras)
|
||||
) {
|
||||
throw new Error(`Incompatible sidecar: ${output}`);
|
||||
}
|
||||
return existing;
|
||||
}
|
||||
|
||||
async function writeSidecar(output, sidecar) {
|
||||
await mkdir(path.dirname(output), { recursive: true });
|
||||
const temporary = `${output}.${process.pid}.tmp`;
|
||||
try {
|
||||
await writeFile(temporary, `${JSON.stringify(sidecar, null, 2)}\n`);
|
||||
await rename(temporary, output);
|
||||
} finally {
|
||||
await rm(temporary, { force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function counts(cameras, entries) {
|
||||
const ok = cameras.filter(
|
||||
(camera) => entries[camera.id]?.status === 'ok',
|
||||
).length;
|
||||
return { ok, miss: cameras.length - ok };
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const started = Date.now();
|
||||
const options = parseArgs(process.argv.slice(2));
|
||||
const base = (process.env.GEV_BASE || 'http://localhost:4173').replace(
|
||||
/\/+$/,
|
||||
'',
|
||||
);
|
||||
const output = path.resolve(
|
||||
ROOT,
|
||||
process.env.OUT ||
|
||||
'src/data/local_data/cctv_ground_heights/cctv_ground_heights.json',
|
||||
);
|
||||
let sidecar = await loadSidecar(output);
|
||||
const response = await fetch(`${base}/api/cctv/sources`, {
|
||||
signal: AbortSignal.timeout(30_000),
|
||||
});
|
||||
if (!response.ok) throw new Error(`Catalog HTTP ${response.status}`);
|
||||
const catalog = await response.json();
|
||||
if (!Array.isArray(catalog.sources))
|
||||
throw new Error('Catalog must contain sources[]');
|
||||
const ids = new Set();
|
||||
const selected = catalog.sources
|
||||
.filter(
|
||||
(source) => !options.kinds || options.kinds.includes(source.sourceKind),
|
||||
)
|
||||
.slice(0, options.limit)
|
||||
.flatMap((source) => {
|
||||
if (typeof source.id !== 'string' || !source.id || ids.has(source.id)) {
|
||||
throw new Error('Catalog has an invalid or duplicate camera id');
|
||||
}
|
||||
ids.add(source.id);
|
||||
let pose;
|
||||
try {
|
||||
pose = nominalPose(source);
|
||||
} catch (error) {
|
||||
// One camera with a malformed pose must not abort the whole run.
|
||||
console.warn(`skip ${source.id}: ${error.message}`);
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
{
|
||||
id: source.id,
|
||||
sourceKind: source.sourceKind || 'unknown',
|
||||
pose,
|
||||
},
|
||||
];
|
||||
});
|
||||
const pending = selected.filter((camera) =>
|
||||
needsSampling(sidecar?.cameras?.[camera.id], camera.pose),
|
||||
);
|
||||
const cells = planCells(pending);
|
||||
console.log(
|
||||
`${selected.length} cameras selected, ${selected.length - pending.length} resumed, ${pending.length} to sample in ${cells.length} cells`,
|
||||
);
|
||||
if (options.dryRun) {
|
||||
for (const cell of cells) {
|
||||
const sizes = batches(cell.cameras).map(
|
||||
(batch) => `${batch.length} cameras/${batch.length * 10} points`,
|
||||
);
|
||||
console.log(
|
||||
`cell ${cell.key}: ${cell.cameras.length} cameras; batches: ${sizes.join(', ')}`,
|
||||
);
|
||||
}
|
||||
console.log(
|
||||
'Retries: failed cameras in batches of 4, then singly; 3 attempts, 90 s per call.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate shared geometry before starting an expensive browser session.
|
||||
for (const camera of pending) camera.points = samplingPoints(camera.pose);
|
||||
let browser;
|
||||
try {
|
||||
if (pending.length) {
|
||||
const { default: puppeteer } = await import('puppeteer');
|
||||
browser = await puppeteer.launch({
|
||||
headless: false,
|
||||
executablePath:
|
||||
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
||||
protocolTimeout: 600000,
|
||||
args: [
|
||||
'--no-sandbox',
|
||||
'--disable-background-timer-throttling',
|
||||
'--disable-renderer-backgrounding',
|
||||
'--window-size=1200,800',
|
||||
],
|
||||
});
|
||||
let page = await openPage(browser, base);
|
||||
const sampler = {
|
||||
warn: (message) => console.error(message),
|
||||
recycle: async () => {
|
||||
await page.close();
|
||||
page = await openPage(browser, base);
|
||||
},
|
||||
sample: (batch) =>
|
||||
withDeadline(
|
||||
page.evaluate(async (cameras) => {
|
||||
const gev = window.__godsEyeView;
|
||||
const scene = gev.viewer.scene;
|
||||
const Cartesian3 = gev.viewer.camera.position.constructor;
|
||||
const Cartographic =
|
||||
gev.viewer.camera.positionCartographic.constructor;
|
||||
const lat =
|
||||
cameras.reduce((sum, camera) => sum + camera.pose.lat, 0) /
|
||||
cameras.length;
|
||||
const lon =
|
||||
cameras.reduce((sum, camera) => sum + camera.pose.lon, 0) /
|
||||
cameras.length;
|
||||
gev.viewer.camera.setView({
|
||||
destination: Cartesian3.fromDegrees(lon, lat, 3000),
|
||||
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
|
||||
});
|
||||
const cartographics = cameras.flatMap((camera) =>
|
||||
camera.points.map((point) =>
|
||||
Cartographic.fromDegrees(point.lon, point.lat),
|
||||
),
|
||||
);
|
||||
const sampled =
|
||||
await scene.sampleHeightMostDetailed(cartographics);
|
||||
return cameras.map((_, i) =>
|
||||
sampled
|
||||
.slice(i * 10, (i + 1) * 10)
|
||||
.map((point) =>
|
||||
Number.isFinite(point.height) ? point.height : null,
|
||||
),
|
||||
);
|
||||
}, batch),
|
||||
),
|
||||
};
|
||||
for (const cell of cells) {
|
||||
const cellStarted = Date.now();
|
||||
const entries = await sampleCell(
|
||||
cell.cameras,
|
||||
sampler,
|
||||
undefined,
|
||||
sidecar?.cameras,
|
||||
);
|
||||
sidecar = mergeSidecar(sidecar, entries, new Date().toISOString());
|
||||
await writeSidecar(output, sidecar);
|
||||
const tally = counts(cell.cameras, entries);
|
||||
console.log(
|
||||
`cell ${cell.key}: ${cell.cameras.length} cameras, ${tally.ok} ok/${tally.miss} miss, ${((Date.now() - cellStarted) / 1000).toFixed(1)} s`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (browser) await browser.close();
|
||||
}
|
||||
const entries = sidecar?.cameras || {};
|
||||
const tally = counts(selected, entries);
|
||||
console.log(
|
||||
`Total: ${selected.length} cameras, ${tally.ok} ok/${tally.miss} miss, ${((Date.now() - started) / 1000).toFixed(1)} s`,
|
||||
);
|
||||
for (const kind of [
|
||||
...new Set(selected.map((camera) => camera.sourceKind)),
|
||||
].sort()) {
|
||||
const tally = counts(
|
||||
selected.filter((camera) => camera.sourceKind === kind),
|
||||
entries,
|
||||
);
|
||||
console.log(` ${kind}: ${tally.ok} ok/${tally.miss} miss`);
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
process.argv[1] &&
|
||||
import.meta.url === pathToFileURL(process.argv[1]).href
|
||||
) {
|
||||
main().catch((error) => {
|
||||
console.error(error.message);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
+778
-287
File diff suppressed because it is too large.
Load diff
@@ -8,9 +8,13 @@ import {
|
||||
buildSyntheticCctvSvg,
|
||||
proxyMediaResponse,
|
||||
fetchCctvImageFromUpstream,
|
||||
fetchTxdotSnapshot,
|
||||
fetchCctvMediaUpstream,
|
||||
} from './cctv/media.js';
|
||||
import { CCTV_FRAME_FETCH_TIMEOUT_MS } from './cctv/constants.js';
|
||||
import {
|
||||
CCTV_FRAME_FETCH_TIMEOUT_MS,
|
||||
CCTV_MAX_SOURCES_CEILING,
|
||||
} from './cctv/constants.js';
|
||||
import { googleServerApiKey } from './places/google-key.js';
|
||||
export { CCTV_FRAME_FETCH_TIMEOUT_MS, fetchCctvImageFromUpstream };
|
||||
/**
|
||||
@@ -30,10 +34,10 @@ export function cctvProxy({ sourceRoot = process.cwd() } = {}) {
|
||||
const getCctvSources = createCctvCatalog({ sourceRoot });
|
||||
/** @type {Map<string,{id:string,status:string,sourceKind:string,label:string,message:string,updatedAt:number}>} */
|
||||
const health = new Map();
|
||||
/** Cap on health map entries to prevent unbounded growth. Sized to cover the
|
||||
* full served catalog (CCTV_MAX_SOURCES hard-bounds at 1200) so health/status
|
||||
* observability isn't silently evicted for a default 800-camera catalog. */
|
||||
const HEALTH_MAX_ENTRIES = 1200;
|
||||
/** Cap on health map entries to prevent unbounded growth. Sized to the
|
||||
* CCTV_MAX_SOURCES ceiling so health/status observability is never evicted
|
||||
* for any catalog the proxy can actually serve. */
|
||||
const HEALTH_MAX_ENTRIES = CCTV_MAX_SOURCES_CEILING;
|
||||
|
||||
/** Update the health entry for a camera, evicting the oldest entry if at capacity. */
|
||||
const setHealth = (cameraId, patch) => {
|
||||
@@ -151,6 +155,9 @@ export function cctvProxy({ sourceRoot = process.cwd() } = {}) {
|
||||
source.sourceKind || (source.url ? 'configured' : 'fallback'),
|
||||
poseSource: source.poseSource,
|
||||
license: source.license,
|
||||
credit: source.credit || '',
|
||||
code: source.code || '',
|
||||
groundHeights: source.groundHeights || null,
|
||||
})),
|
||||
};
|
||||
res.writeHead(200, {
|
||||
@@ -328,7 +335,9 @@ export function cctvProxy({ sourceRoot = process.cwd() } = {}) {
|
||||
: '');
|
||||
|
||||
const upstreamImage =
|
||||
await fetchCctvImageFromUpstream(upstreamCandidate);
|
||||
source?.sourceKind === 'txdot-its'
|
||||
? await fetchTxdotSnapshot(upstreamCandidate)
|
||||
: await fetchCctvImageFromUpstream(upstreamCandidate);
|
||||
if (upstreamImage?.ok) {
|
||||
setHealth(cameraId, {
|
||||
status: 'ok',
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import {
|
||||
CCTV_MAX_SOURCES_CEILING,
|
||||
DEFAULT_CCTV_MAX_SOURCES,
|
||||
} from './constants.js';
|
||||
|
||||
/**
|
||||
* Resolve the catalog-wide source cap from an env value.
|
||||
*
|
||||
* @param {string|number|undefined} raw - CCTV_MAX_SOURCES as configured.
|
||||
* @returns {number} Cap bounded to [8, CCTV_MAX_SOURCES_CEILING].
|
||||
*/
|
||||
export function resolveCatalogCap(raw) {
|
||||
const parsed = Number(raw || DEFAULT_CCTV_MAX_SOURCES);
|
||||
if (!Number.isFinite(parsed)) return DEFAULT_CCTV_MAX_SOURCES;
|
||||
return Math.max(8, Math.min(CCTV_MAX_SOURCES_CEILING, Math.floor(parsed)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge source packs under one cap without starving any of them.
|
||||
*
|
||||
* Every pack arrives already ordered by its own priority (nearest-to-anchor
|
||||
* first) and already trimmed to its own per-pack cap. When the packs together
|
||||
* exceed the catalog cap, the cap is filled ROUND-ROBIN — one camera from
|
||||
* each pack per turn, in each pack's own order — so a lowered
|
||||
* CCTV_MAX_SOURCES thins every region a little instead of deleting whichever
|
||||
* pack happened to be appended last. Duplicate ids resolve last-pack-wins so
|
||||
* file/env overrides keep replacing live records; the record is then counted
|
||||
* against the pack that won it.
|
||||
*
|
||||
* @param {Array<{name:string, sources:Array<object>}>} packs - Normalized
|
||||
* packs in merge order (earlier packs lose duplicate ids to later ones).
|
||||
* @param {number} maxCount - Catalog cap.
|
||||
* @returns {{sources:Array<object>, packs:Array<{name:string, offered:number, kept:number}>}}
|
||||
*/
|
||||
export function allocateSourceCap(packs, maxCount) {
|
||||
const owner = new Map();
|
||||
for (const pack of packs) {
|
||||
for (const source of pack.sources) {
|
||||
if (!source?.id) continue;
|
||||
owner.set(source.id, { pack: pack.name, source });
|
||||
}
|
||||
}
|
||||
const lanes = packs.map((pack) => ({
|
||||
name: pack.name,
|
||||
// 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(
|
||||
(source) => source?.id && owner.get(source.id)?.source === source,
|
||||
),
|
||||
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 = [];
|
||||
if (limit >= total) {
|
||||
for (const lane of lanes) {
|
||||
sources.push(...lane.queue);
|
||||
lane.kept = lane.queue.length;
|
||||
}
|
||||
} else {
|
||||
while (sources.length < limit) {
|
||||
for (const lane of lanes) {
|
||||
if (sources.length >= limit) break;
|
||||
if (lane.next >= lane.queue.length) continue;
|
||||
sources.push(lane.queue[lane.next]);
|
||||
lane.next += 1;
|
||||
lane.kept += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
sources,
|
||||
packs: lanes.map((lane) => ({
|
||||
name: lane.name,
|
||||
offered: lane.queue.length,
|
||||
kept: lane.kept,
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -1,16 +1,86 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
DEFAULT_CCTV_SOURCE_FILE,
|
||||
DEFAULT_CCTV_MAX_SOURCES,
|
||||
CCTV_SOURCE_CACHE_MS,
|
||||
} from './constants.js';
|
||||
import { DEFAULT_CCTV_SOURCE_FILE, CCTV_SOURCE_CACHE_MS } from './constants.js';
|
||||
import { allocateSourceCap, resolveCatalogCap } from './cap.js';
|
||||
import { loadGroundHeights, joinGroundHeights } from './groundHeights.js';
|
||||
import { normalizeSourceItem } from './normalize.js';
|
||||
import {
|
||||
loadAustinSourcesFromOpenData,
|
||||
loadCaltransSourcesFromOpenData,
|
||||
loadTflSourcesFromOpenData,
|
||||
loadOntarioSourcesFromOpenData,
|
||||
loadFintrafficSourcesFromOpenData,
|
||||
loadDriveBcSourcesFromOpenData,
|
||||
loadTxdotSourcesFromOpenData,
|
||||
loadTallinnSourcesFromCatalog,
|
||||
loadTarkteeSourcesFromDatex,
|
||||
loadWarendorfSourcesFromCatalog,
|
||||
loadNswSourcesFromOpenData,
|
||||
} from './sources.js';
|
||||
|
||||
/** Env kill switch: unset or anything but "0" means enabled. */
|
||||
const envEnabled = (name) => String(process.env[name] || '1').trim() !== '0';
|
||||
|
||||
/**
|
||||
* Live open-data packs, in merge order. Adding a region is one entry here
|
||||
* plus its loader in sources.js; the catalog cap is shared across entries
|
||||
* round-robin (cap.js), so a new pack never silently evicts an older one.
|
||||
* Each pack fails independently (allSettled) and is gated by its own env
|
||||
* kill switch.
|
||||
*/
|
||||
const LIVE_PACKS = [
|
||||
{ name: 'austin', enabled: () => true, load: loadAustinSourcesFromOpenData },
|
||||
{
|
||||
name: 'caltrans',
|
||||
enabled: () => true,
|
||||
load: loadCaltransSourcesFromOpenData,
|
||||
},
|
||||
{
|
||||
name: 'tfl',
|
||||
enabled: () => envEnabled('CCTV_TFL_ENABLED'),
|
||||
load: loadTflSourcesFromOpenData,
|
||||
},
|
||||
{
|
||||
name: 'ontario',
|
||||
enabled: () => envEnabled('CCTV_ONTARIO_ENABLED'),
|
||||
load: loadOntarioSourcesFromOpenData,
|
||||
},
|
||||
{
|
||||
name: 'fintraffic',
|
||||
enabled: () => envEnabled('CCTV_FINTRAFFIC_ENABLED'),
|
||||
load: loadFintrafficSourcesFromOpenData,
|
||||
},
|
||||
{
|
||||
name: 'drivebc',
|
||||
enabled: () => envEnabled('CCTV_DRIVEBC_ENABLED'),
|
||||
load: loadDriveBcSourcesFromOpenData,
|
||||
},
|
||||
{
|
||||
name: 'txdot',
|
||||
enabled: () => envEnabled('CCTV_TXDOT_ENABLED'),
|
||||
load: loadTxdotSourcesFromOpenData,
|
||||
},
|
||||
{
|
||||
name: 'tallinn',
|
||||
enabled: () => envEnabled('CCTV_TALLINN_ENABLED'),
|
||||
load: loadTallinnSourcesFromCatalog,
|
||||
},
|
||||
{
|
||||
name: 'tarktee',
|
||||
enabled: () => envEnabled('CCTV_TARKTEE_ENABLED'),
|
||||
load: loadTarkteeSourcesFromDatex,
|
||||
},
|
||||
{
|
||||
name: 'warendorf',
|
||||
enabled: () => envEnabled('CCTV_WARENDORF_ENABLED'),
|
||||
load: loadWarendorfSourcesFromCatalog,
|
||||
},
|
||||
{
|
||||
name: 'nsw',
|
||||
enabled: () => envEnabled('CCTV_NSW_ENABLED'),
|
||||
load: loadNswSourcesFromOpenData,
|
||||
},
|
||||
];
|
||||
/**
|
||||
* Load CCTV sources from a local JSON file (CCTV_SOURCES_FILE env or default).
|
||||
*
|
||||
@@ -65,9 +135,9 @@ export function createCctvCatalog({ sourceRoot = process.cwd() } = {}) {
|
||||
/**
|
||||
* Assemble and cache the merged CCTV source list.
|
||||
*
|
||||
* Merges sources from three origins (Austin Open Data, local file,
|
||||
* env variable), deduplicates by ID, applies the global max cap, and
|
||||
* caches for CCTV_SOURCE_CACHE_MS.
|
||||
* Merges every source pack (live open-data packs, local file, env
|
||||
* variable), deduplicates by ID, shares the catalog cap fairly across
|
||||
* packs, and caches for CCTV_SOURCE_CACHE_MS.
|
||||
*
|
||||
* @returns {Promise<Array<object>>} Deduplicated, capped source list.
|
||||
*/
|
||||
@@ -104,64 +174,62 @@ export function createCctvCatalog({ sourceRoot = process.cwd() } = {}) {
|
||||
String(process.env.CCTV_FORCE_AUSTIN || '').trim() === '1';
|
||||
const preferAustin =
|
||||
String(process.env.CCTV_PREFER_AUSTIN || '1').trim() !== '0';
|
||||
// Live open-data packs (Austin + Caltrans + TfL) load unless a file/env pack
|
||||
// is configured and live packs aren't forced — same gate that governed the
|
||||
// Austin-only fetch, now governing all three. Each pack fails independently.
|
||||
// Live open-data packs load unless a file/env pack is configured and live
|
||||
// packs aren't forced — the same gate that governed the Austin-only fetch
|
||||
// now governs every entry in LIVE_PACKS.
|
||||
const needsLiveSources =
|
||||
forceAustin || (fromFile.length + fromEnv.length === 0 && preferAustin);
|
||||
const tflEnabled =
|
||||
String(process.env.CCTV_TFL_ENABLED || '1').trim() !== '0';
|
||||
|
||||
let fromAustin = [];
|
||||
let fromCaltrans = [];
|
||||
let fromTfl = [];
|
||||
if (needsLiveSources) {
|
||||
const [austinResult, caltransResult, tflResult] =
|
||||
await Promise.allSettled([
|
||||
loadAustinSourcesFromOpenData(),
|
||||
loadCaltransSourcesFromOpenData(),
|
||||
tflEnabled ? loadTflSourcesFromOpenData() : Promise.resolve([]),
|
||||
]);
|
||||
fromAustin =
|
||||
austinResult.status === 'fulfilled' ? austinResult.value : [];
|
||||
fromCaltrans =
|
||||
caltransResult.status === 'fulfilled' ? caltransResult.value : [];
|
||||
fromTfl = tflResult.status === 'fulfilled' ? tflResult.value : [];
|
||||
}
|
||||
// Live sources first so file/env overrides win on duplicate IDs (Map last-write).
|
||||
const merged = [
|
||||
...fromAustin,
|
||||
...fromCaltrans,
|
||||
...fromTfl,
|
||||
...fromFile,
|
||||
...fromEnv,
|
||||
const liveResults = needsLiveSources
|
||||
? await Promise.allSettled(
|
||||
// Invoked inside the promise so a loader that throws synchronously
|
||||
// (a file-based pack on a malformed row) is isolated like any other
|
||||
// failed pack instead of rejecting the whole refresh.
|
||||
LIVE_PACKS.map((pack) =>
|
||||
Promise.resolve().then(() =>
|
||||
pack.enabled() ? pack.load({ sourceRoot }) : [],
|
||||
),
|
||||
),
|
||||
)
|
||||
: [];
|
||||
// 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) => ({
|
||||
name,
|
||||
sources: items
|
||||
.filter((item) => item && typeof item === 'object')
|
||||
.map((item) => normalizeSourceItem(item))
|
||||
.filter((item) => item.id),
|
||||
});
|
||||
const packs = [
|
||||
...LIVE_PACKS.map((pack, index) =>
|
||||
normalizePack(
|
||||
pack.name,
|
||||
liveResults[index]?.status === 'fulfilled'
|
||||
? liveResults[index].value
|
||||
: [],
|
||||
),
|
||||
),
|
||||
normalizePack('file', fromFile),
|
||||
normalizePack('env', fromEnv),
|
||||
];
|
||||
|
||||
// Deduplicate by camera ID (last-write wins because of Map.set)
|
||||
const byId = new Map();
|
||||
for (const item of merged) {
|
||||
if (!item || typeof item !== 'object') continue;
|
||||
const normalized = normalizeSourceItem(item);
|
||||
if (!normalized.id) continue;
|
||||
byId.set(normalized.id, normalized);
|
||||
}
|
||||
|
||||
const mergedSources = Array.from(byId.values());
|
||||
const maxRaw = Number(
|
||||
process.env.CCTV_MAX_SOURCES || DEFAULT_CCTV_MAX_SOURCES,
|
||||
const maxCount = resolveCatalogCap(process.env.CCTV_MAX_SOURCES);
|
||||
const allocation = allocateSourceCap(packs, maxCount);
|
||||
// Shipped ground heights (src/data/local_data/cctv_ground_heights/, produced by
|
||||
// scripts/precompute-cctv-heights.mjs) ride along on the served source so
|
||||
// the client can place a camera and its monitor plane with zero sampling.
|
||||
const capped = joinGroundHeights(
|
||||
allocation.sources,
|
||||
loadGroundHeights(sourceRoot),
|
||||
);
|
||||
const maxCount = Number.isFinite(maxRaw)
|
||||
? Math.max(8, Math.min(1200, Math.floor(maxRaw)))
|
||||
: DEFAULT_CCTV_MAX_SOURCES;
|
||||
if (mergedSources.length > maxCount) {
|
||||
const trimmed = allocation.packs.filter((pack) => pack.kept < pack.offered);
|
||||
if (trimmed.length) {
|
||||
const detail = trimmed
|
||||
.map((pack) => `${pack.name} ${pack.kept}/${pack.offered}`)
|
||||
.join(', ');
|
||||
console.warn(
|
||||
`[CCTV] source catalog ${mergedSources.length} exceeds cap ${maxCount}; keeping the first ${maxCount} (raise CCTV_MAX_SOURCES or lower a per-pack cap to change which).`,
|
||||
`[CCTV] source catalog exceeds cap ${maxCount}; shared round-robin across packs (${detail}). Raise CCTV_MAX_SOURCES or lower a per-pack cap to change the mix.`,
|
||||
);
|
||||
}
|
||||
const capped =
|
||||
mergedSources.length > maxCount
|
||||
? mergedSources.slice(0, maxCount)
|
||||
: mergedSources;
|
||||
if (capped.length > 0 || _cctvSourceCache.length === 0) {
|
||||
_cctvSourceCache = capped;
|
||||
} else {
|
||||
|
||||
@@ -4,11 +4,103 @@ export const DEFAULT_AUSTIN_ROWS_URL =
|
||||
'https://data.austintexas.gov/api/views/b4k4-adkb/rows.json?accessType=DOWNLOAD';
|
||||
/** Default cap on Austin cameras after distance-based prioritization. */
|
||||
export const DEFAULT_AUSTIN_MAX_SOURCES = 250;
|
||||
/** Global cap on total CCTV sources served by the proxy. */
|
||||
export const DEFAULT_CCTV_MAX_SOURCES = 900;
|
||||
/**
|
||||
* 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. Sized above the sum of the
|
||||
* default per-pack caps so a default install never trims.
|
||||
*/
|
||||
export const DEFAULT_CCTV_MAX_SOURCES = 4000;
|
||||
/** Hard upper bound for CCTV_MAX_SOURCES; also sizes the health map. */
|
||||
export const CCTV_MAX_SOURCES_CEILING = 5000;
|
||||
/** 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. */
|
||||
/** TxDOT ITS: one keyless JSON catalog per district (25 districts statewide). */
|
||||
export const TXDOT_ORIGIN = 'https://its.txdot.gov';
|
||||
export const TXDOT_CCTV_STATUS_URL = (district) =>
|
||||
`${TXDOT_ORIGIN}/its/DistrictIts/GetCctvStatusListByDistrict?districtCode=${encodeURIComponent(district)}`;
|
||||
/** Per-camera frame. Returns JSON `{snippet:<base64 jpeg>}`, not an image body;
|
||||
* media.js decodes it (fetchTxdotSnapshot) and only for this origin. */
|
||||
export const TXDOT_CCTV_SNAPSHOT_URL = `${TXDOT_ORIGIN}/its/DistrictIts/GetCctvSnapshotByIcdId`;
|
||||
/** Valid TxDOT district codes (the ITS map's own districtCodes list). */
|
||||
export const TXDOT_DISTRICTS = new Set([
|
||||
'ABL',
|
||||
'AMA',
|
||||
'ATL',
|
||||
'AUS',
|
||||
'BMT',
|
||||
'BWD',
|
||||
'BRY',
|
||||
'CHS',
|
||||
'CRP',
|
||||
'DAL',
|
||||
'ELP',
|
||||
'FTW',
|
||||
'HOU',
|
||||
'LRD',
|
||||
'LBB',
|
||||
'LFK',
|
||||
'ODA',
|
||||
'PAR',
|
||||
'PHR',
|
||||
'SJT',
|
||||
'SAT',
|
||||
'TYL',
|
||||
'WAC',
|
||||
'WFS',
|
||||
'YKM',
|
||||
]);
|
||||
/** Districts fetched by default: Austin (the reference camera city) and San
|
||||
* Antonio. A statewide default was rejected: per-pack prioritization is
|
||||
* nearest-to-any-anchor, so Dallas's dense core would take most slots.
|
||||
* CCTV_TXDOT_DISTRICTS opens up the rest ("AUS,SAT,HOU,DAL,FTW" for the five
|
||||
* big metros, or any of the 25 codes). */
|
||||
export const DEFAULT_TXDOT_DISTRICTS = 'AUS,SAT';
|
||||
export const DEFAULT_TXDOT_MAX_SOURCES = 500;
|
||||
/** Prioritization anchors: downtown cores of the metro districts a user can
|
||||
* select. Cameras rank by distance to the NEAREST anchor, so a widened
|
||||
* CCTV_TXDOT_DISTRICTS still ranks sensibly instead of against Austin alone. */
|
||||
export const TXDOT_ANCHORS = [
|
||||
{ lat: 30.2672, lon: -97.7431 }, // Austin
|
||||
{ lat: 29.4241, lon: -98.4936 }, // San Antonio
|
||||
{ lat: 29.7604, lon: -95.3698 }, // Houston
|
||||
{ lat: 32.7767, lon: -96.797 }, // Dallas
|
||||
{ lat: 32.7555, lon: -97.3308 }, // Fort Worth
|
||||
];
|
||||
/** Ground-elevation priors in metres, by district. The TxDOT payload carries
|
||||
* no elevation, and on a keyless (no-tileset) stack the client's ground snap
|
||||
* never fires, so this prior is the only height a camera gets there. Texas
|
||||
* spans sea level (Houston) to ~1,140 m (El Paso). */
|
||||
export const TXDOT_DISTRICT_ELEVATION_M = Object.freeze({
|
||||
ABL: 520,
|
||||
AMA: 1099,
|
||||
ATL: 105,
|
||||
AUS: 149,
|
||||
BMT: 5,
|
||||
BWD: 425,
|
||||
BRY: 111,
|
||||
CHS: 250,
|
||||
CRP: 7,
|
||||
DAL: 131,
|
||||
ELP: 1140,
|
||||
FTW: 199,
|
||||
HOU: 15,
|
||||
LRD: 132,
|
||||
LBB: 992,
|
||||
LFK: 91,
|
||||
ODA: 890,
|
||||
PAR: 185,
|
||||
PHR: 30,
|
||||
SJT: 585,
|
||||
SAT: 198,
|
||||
TYL: 165,
|
||||
WAC: 143,
|
||||
WFS: 289,
|
||||
YKM: 70,
|
||||
});
|
||||
export const TXDOT_DEFAULT_ELEVATION_M = 150;
|
||||
export const CALTRANS_CCTV_URL = (district) =>
|
||||
`https://cwwp2.dot.ca.gov/data/d${district}/cctv/cctvStatusD${String(district).padStart(2, '0')}.json`;
|
||||
/** Districts fetched by default: SF Bay (4), LA (7), San Diego (11), Sacramento (3). */
|
||||
@@ -27,7 +119,108 @@ export const TFL_IMAGE_ORIGIN =
|
||||
'https://s3-eu-west-1.amazonaws.com/jamcams.tfl.gov.uk/';
|
||||
export const DEFAULT_TFL_MAX_SOURCES = 250;
|
||||
export const LONDON_CENTER = { lat: 51.5074, lon: -0.1278 };
|
||||
/** Camera CATALOGS change rarely; 15 min keeps multi-megabyte upstream list refetches (Austin rows.json + 4 Caltrans districts + TfL) infrequent. Frames are fetched per-request and are unaffected. */
|
||||
/** Ontario 511: keyless CARS/511 camera catalog; frame URLs are still images. */
|
||||
export const ONTARIO_511_CAMERAS_URL =
|
||||
'https://511on.ca/api/v2/get/cameras?format=json&lang=en';
|
||||
export const ONTARIO_511_IMAGE_ORIGIN = 'https://511on.ca/map/Cctv/';
|
||||
export const DEFAULT_ONTARIO_MAX_SOURCES = 1000;
|
||||
export const ONTARIO_ANCHORS = [
|
||||
{ lat: 43.4516, lon: -80.4925 }, // Kitchener
|
||||
{ lat: 43.6532, lon: -79.3832 }, // Toronto
|
||||
{ lat: 45.4215, lon: -75.6972 }, // Ottawa
|
||||
{ lat: 43.2557, lon: -79.8711 }, // Hamilton
|
||||
{ lat: 42.9849, lon: -81.2453 }, // London, Ontario
|
||||
{ lat: 42.3149, lon: -83.0364 }, // Windsor
|
||||
];
|
||||
/** Fintraffic road weather cameras (Digitraffic): one keyless GeoJSON list
|
||||
* covering all of Finland. Each STATION carries N presets (fixed camera views)
|
||||
* that share the station position; one preset is one camera here. */
|
||||
export const FINTRAFFIC_STATIONS_URL =
|
||||
'https://tie.digitraffic.fi/api/weathercam/v1/stations';
|
||||
/** Frames: `<origin><presetId>.jpg`. Preset ids are synthesized into this
|
||||
* origin rather than read from the payload, so no upstream field can steer the
|
||||
* frame proxy off-host. */
|
||||
export const FINTRAFFIC_IMAGE_ORIGIN = 'https://weathercam.digitraffic.fi/';
|
||||
/** Digitraffic asks every client to identify itself on API calls. */
|
||||
export const DIGITRAFFIC_USER = 'gods-eye-view';
|
||||
export const DEFAULT_FINTRAFFIC_MAX_SOURCES = 300;
|
||||
/** Ground-elevation prior, in metres, for stations that report no altitude.
|
||||
* 228 of 809 stations carry a real metre value (median 94 m); the rest report
|
||||
* 0, which means "not reported" rather than sea level — Kouvola (~80 m of real
|
||||
* elevation) reports 0. The observed median stands in for those. */
|
||||
export const FINTRAFFIC_GROUND_ELEVATION_M = 90;
|
||||
/** Prioritization anchors: the population centres strung along Finland's main
|
||||
* road spine (vt1 Turku, vt3 Tampere, vt4 Jyväskylä–Oulu–Rovaniemi, vt5
|
||||
* Kuopio), so a cap keeps national coverage rather than just the capital. */
|
||||
export const FINLAND_ANCHORS = [
|
||||
{ lat: 60.1699, lon: 24.9384 }, // Helsinki
|
||||
{ lat: 60.4518, lon: 22.2666 }, // Turku
|
||||
{ lat: 61.4978, lon: 23.761 }, // Tampere
|
||||
{ lat: 62.2426, lon: 25.7473 }, // Jyväskylä
|
||||
{ lat: 62.8924, lon: 27.677 }, // Kuopio
|
||||
{ lat: 65.0121, lon: 25.4651 }, // Oulu
|
||||
{ lat: 66.5039, lon: 25.7294 }, // Rovaniemi
|
||||
];
|
||||
/** Global cap on total CCTV sources served by the proxy: the default per-pack
|
||||
* caps summed (Austin 250 + Caltrans 300 + TfL 250 + DriveBC 250). */
|
||||
/** DriveBC highway cameras (British Columbia): the keyless camera list served by
|
||||
* the DriveBC.ca site (github.com/bcgov/DriveBC.ca). The DataBC HighwayCams CSV
|
||||
* lists the same cameras but still carries retired images.drivebc.ca frame URLs,
|
||||
* so frames are built from the numeric camera id on the current image host. */
|
||||
export const DRIVEBC_WEBCAMS_URL = 'https://www.drivebc.ca/api/webcams/';
|
||||
export const DRIVEBC_IMAGE_URL = (id) =>
|
||||
`https://www.drivebc.ca/images/${id}.jpg`;
|
||||
export const DEFAULT_DRIVEBC_MAX_SOURCES = 250;
|
||||
/** Prioritization anchors: downtown Vancouver and Victoria. */
|
||||
export const DRIVEBC_ANCHORS = [
|
||||
{ lat: 49.2827, lon: -123.1207 }, // Vancouver
|
||||
{ lat: 48.4284, lon: -123.3656 }, // Victoria
|
||||
];
|
||||
/** 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;
|
||||
export const TALLINN_IMAGE_ORIGIN = 'https://ristmikud.tallinn.ee/';
|
||||
export const TALLINN_CENTER = { lat: 59.437, lon: 24.753 };
|
||||
/** Transpordiamet / Tarktee road-weather cameras: keyless DATEX2 feeds. */
|
||||
export const TARKTEE_LOCATIONS_URL =
|
||||
'https://tarktee.transpordiamet.ee/api/v1/datex/roadCameraLocations';
|
||||
export const TARKTEE_IMAGES_URL =
|
||||
'https://tarktee.transpordiamet.ee/api/v1/datex/roadCameraImages';
|
||||
export const TARKTEE_IMAGE_ORIGIN = 'https://tarktee.transpordiamet.ee/images/';
|
||||
export const DEFAULT_TARKTEE_MAX_SOURCES = 179;
|
||||
export const TARKTEE_ANCHORS = [
|
||||
{ lat: 59.437, lon: 24.753 }, // Tallinn
|
||||
{ lat: 58.378, lon: 26.729 }, // Tartu
|
||||
{ lat: 58.3859, lon: 24.4971 }, // Pärnu
|
||||
{ lat: 59.3797, lon: 28.1791 }, // Narva
|
||||
];
|
||||
/** Warendorf (Germany): the Marktplatz municipal webcam from a curated catalog file. */
|
||||
export const DEFAULT_WARENDORF_SOURCE_FILE =
|
||||
'config/cctv_sources.warendorf.json';
|
||||
export const WARENDORF_IMAGE_ORIGINS = Object.freeze([
|
||||
'http://webcam.warendorf.de/',
|
||||
'https://www.kreis-warendorf.de/',
|
||||
]);
|
||||
/** Live Traffic NSW (Transport for NSW): keyless public camera catalog. */
|
||||
export const NSW_CAMERAS_URL =
|
||||
'https://data.livetraffic.com/cameras/traffic-cam.json';
|
||||
export const NSW_IMAGE_ORIGIN = 'https://webcams.transport.nsw.gov.au/';
|
||||
export const DEFAULT_NSW_MAX_SOURCES = 250;
|
||||
export const SYDNEY_CENTER = { lat: -33.8688, lon: 151.2093 };
|
||||
/**
|
||||
* The NSW webcam host answers non-browser clients with HTTP 200 and a short
|
||||
* HTML body instead of the frame (verified 2026-09-13), so the proxy
|
||||
* identifies as a browser for that one host. See media.js.
|
||||
*/
|
||||
export const NSW_IMAGE_USER_AGENT =
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36';
|
||||
/**
|
||||
* Longest NSW `view` sentence still usable as a label. NSW occasionally
|
||||
* repurposes `view` for a multi-paragraph works notice; real descriptions top
|
||||
* out around 120 characters.
|
||||
*/
|
||||
export const NSW_MAX_VIEW_LABEL = 140;
|
||||
/** Camera CATALOGS change rarely; 15 min keeps multi-megabyte upstream list refetches (Austin rows.json + 4 Caltrans districts + TfL + Ontario 511) infrequent. Frames are fetched per-request and are unaffected. */
|
||||
export const CCTV_SOURCE_CACHE_MS = 15 * 60 * 1000;
|
||||
/** Per-provider catalog-fetch timeout. Bounds the worst-case refresh so one
|
||||
* stalled upstream can't leave getCctvSources (and thus every CCTV route)
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { poseHash } from '../../../src/data/cctvFootprint.js';
|
||||
|
||||
/** Sidecar written by scripts/precompute-cctv-heights.mjs. */
|
||||
export const DEFAULT_GROUND_HEIGHTS_FILE =
|
||||
'src/data/local_data/cctv_ground_heights/cctv_ground_heights.json';
|
||||
|
||||
/** @type {{path:string, mtimeMs:number, cameras:object}|null} */
|
||||
let _cache = null;
|
||||
|
||||
/**
|
||||
* Load the precomputed ground-height sidecar (Google mesh heights under each
|
||||
* camera's mount and monitor-plane support points). Cached by file mtime so a
|
||||
* catalog refresh re-reads only when the file changed. Missing or malformed
|
||||
* files mean "no shipped heights", never an error.
|
||||
* @param {string} sourceRoot
|
||||
* @returns {Record<string, object>} camera id → sidecar entry
|
||||
*/
|
||||
export function loadGroundHeights(sourceRoot = process.cwd()) {
|
||||
const file =
|
||||
process.env.CCTV_GROUND_HEIGHTS_FILE || DEFAULT_GROUND_HEIGHTS_FILE;
|
||||
const resolved = path.isAbsolute(file)
|
||||
? file
|
||||
: path.resolve(sourceRoot, file);
|
||||
try {
|
||||
const stat = fs.statSync(resolved);
|
||||
if (_cache && _cache.path === resolved && _cache.mtimeMs === stat.mtimeMs) {
|
||||
return _cache.cameras;
|
||||
}
|
||||
const parsed = JSON.parse(fs.readFileSync(resolved, 'utf8'));
|
||||
const cameras =
|
||||
parsed &&
|
||||
typeof parsed === 'object' &&
|
||||
parsed.cameras &&
|
||||
typeof parsed.cameras === 'object'
|
||||
? parsed.cameras
|
||||
: {};
|
||||
_cache = { path: resolved, mtimeMs: stat.mtimeMs, cameras };
|
||||
return cameras;
|
||||
} catch {
|
||||
_cache = null;
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach a shipped entry to each served source whose id has one AND whose
|
||||
* nominal pose still hashes to what the entry was sampled for. A pack whose
|
||||
* feed moved a camera or changed its pose simply loses the shipped value and
|
||||
* falls back to runtime placement.
|
||||
* @param {Array<object>} sources - Normalized served sources.
|
||||
* @param {Record<string, object>} entries - Sidecar cameras map.
|
||||
* @returns {Array<object>} The same sources, `groundHeights` attached where valid.
|
||||
*/
|
||||
export function joinGroundHeights(sources, entries) {
|
||||
if (!entries || typeof entries !== 'object') return sources;
|
||||
for (const source of sources) {
|
||||
const entry = entries[source.id];
|
||||
if (!entry || entry.status !== 'ok' || !Number.isFinite(entry.mountGroundM))
|
||||
continue;
|
||||
if (entry.poseHash !== poseHash(source)) continue;
|
||||
const supports = {};
|
||||
for (const [key, value] of Object.entries(entry.supports || {})) {
|
||||
if (Number.isFinite(value)) supports[key] = value;
|
||||
}
|
||||
source.groundHeights = {
|
||||
poseHash: entry.poseHash,
|
||||
mountGroundM: entry.mountGroundM,
|
||||
supports,
|
||||
};
|
||||
}
|
||||
return sources;
|
||||
}
|
||||
@@ -5,6 +5,8 @@ import {
|
||||
CCTV_FRAME_MAX_BODY_BYTES,
|
||||
CCTV_MEDIA_FETCH_TIMEOUT_MS,
|
||||
CCTV_MEDIA_MAX_BODY_BYTES,
|
||||
NSW_IMAGE_ORIGIN,
|
||||
NSW_IMAGE_USER_AGENT,
|
||||
} from './constants.js';
|
||||
/**
|
||||
* Generate a synthetic SVG billboard image for a CCTV camera placeholder.
|
||||
@@ -238,6 +240,163 @@ export async function fetchCctvMediaUpstream(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch and decode a TxDOT ITS / TransGuide snapshot.
|
||||
*
|
||||
* TxDOT returns JSON with a base64-encoded JPEG in `snippet`, rather than
|
||||
* returning image/jpeg directly.
|
||||
*/
|
||||
export async function fetchTxdotSnapshot(
|
||||
url,
|
||||
{
|
||||
fetchImpl = fetch,
|
||||
timeoutMs = CCTV_FRAME_FETCH_TIMEOUT_MS,
|
||||
maxBytes = CCTV_FRAME_MAX_BODY_BYTES,
|
||||
} = {},
|
||||
) {
|
||||
if (!url) return null;
|
||||
let parsed;
|
||||
try {
|
||||
parsed = new URL(url);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (
|
||||
parsed.origin !== 'https://its.txdot.gov' ||
|
||||
parsed.pathname !== '/its/DistrictIts/GetCctvSnapshotByIcdId'
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
// Base64 inflates by 4/3; the JSON envelope adds a few bytes of framing.
|
||||
const maxEnvelopeBytes = Math.ceil((maxBytes * 4) / 3) + 4096;
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
// The snapshot endpoint answers directly; a redirect is not followed, so
|
||||
// the origin/path pin above holds for the request that is actually made.
|
||||
const upstream = await fetchImpl(parsed.toString(), {
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'User-Agent': 'gods-eye-view-cctv-proxy/1.0',
|
||||
},
|
||||
signal: controller.signal,
|
||||
redirect: 'manual',
|
||||
});
|
||||
if (!upstream.ok) return null;
|
||||
const envelope = await readCappedResponseBytes(upstream, maxEnvelopeBytes);
|
||||
if (!envelope) return null;
|
||||
let payload;
|
||||
try {
|
||||
payload = JSON.parse(envelope.toString('utf8'));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
let snippet =
|
||||
typeof payload?.snippet === 'string' ? payload.snippet.trim() : '';
|
||||
if (!snippet) return null;
|
||||
snippet = snippet.replace(/^data:image\/jpeg;base64,/i, '');
|
||||
// Canonical base64 only (4-char groups, padding only at the end):
|
||||
// Buffer.from() silently skips junk, which would let a non-image body
|
||||
// decode into "something".
|
||||
if (
|
||||
snippet.length > maxEnvelopeBytes ||
|
||||
!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(
|
||||
snippet,
|
||||
)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const body = Buffer.from(snippet, 'base64');
|
||||
if (body.length < 4 || body.length > maxBytes) return null;
|
||||
if (body[0] !== 0xff || body[1] !== 0xd8 || body[2] !== 0xff) return null;
|
||||
return { ok: true, body, contentType: 'image/jpeg' };
|
||||
} catch {
|
||||
return null;
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
controller.abort();
|
||||
}
|
||||
}
|
||||
|
||||
/** Redirect hops the frame path will follow, and only within the same host. */
|
||||
const MAX_SAME_HOST_REDIRECTS = 2;
|
||||
|
||||
/**
|
||||
* Fetch a registered frame URL following redirects ONLY within the original
|
||||
* origin (scheme, host and port; at most MAX_SAME_HOST_REDIRECTS hops).
|
||||
* Default redirect-following would let an upstream steer a host-pinned
|
||||
* request, and its host-specific headers, to any origin, another port, or a
|
||||
* plaintext downgrade.
|
||||
*
|
||||
* @param {string} url
|
||||
* @param {object} init - fetch init (headers, signal).
|
||||
* @param {typeof fetch} fetchImpl
|
||||
* @returns {Promise<Response|null>} Final response, or null on an off-host or
|
||||
* over-long redirect chain.
|
||||
*/
|
||||
export async function fetchWithinHost(url, init, fetchImpl = fetch) {
|
||||
let current;
|
||||
try {
|
||||
current = new URL(url);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const origin = current.origin;
|
||||
for (let hop = 0; hop <= MAX_SAME_HOST_REDIRECTS; hop++) {
|
||||
const upstream = await fetchImpl(current.toString(), {
|
||||
...init,
|
||||
redirect: 'manual',
|
||||
});
|
||||
// Anything that is not a 3xx (including a test double with no status) is
|
||||
// the final answer.
|
||||
const status = Number(upstream?.status);
|
||||
if (!(status >= 300 && status < 400)) return upstream;
|
||||
const location = upstream.headers.get('location');
|
||||
try {
|
||||
await upstream.body?.cancel();
|
||||
} catch {
|
||||
/* no-op */
|
||||
}
|
||||
if (!location || hop === MAX_SAME_HOST_REDIRECTS) return null;
|
||||
let next;
|
||||
try {
|
||||
next = new URL(location, current);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (next.origin !== origin) return null;
|
||||
current = next;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Image hosts that only serve frames to browser-identified clients, keyed by
|
||||
* exact hostname. Every other upstream sees the proxy's own identifying
|
||||
* User-Agent. Keyed on host, not on a URL substring, so a look-alike host or a
|
||||
* path that merely mentions the host never inherits the header.
|
||||
*/
|
||||
const CCTV_IMAGE_USER_AGENT_BY_HOST = Object.freeze({
|
||||
[new URL(NSW_IMAGE_ORIGIN).hostname]: NSW_IMAGE_USER_AGENT,
|
||||
});
|
||||
|
||||
/**
|
||||
* User-Agent for one upstream frame request.
|
||||
*
|
||||
* @param {string} url
|
||||
* @returns {string}
|
||||
*/
|
||||
export function cctvUpstreamUserAgent(url) {
|
||||
try {
|
||||
return (
|
||||
CCTV_IMAGE_USER_AGENT_BY_HOST[new URL(url).hostname] ||
|
||||
'gods-eye-view-cctv-proxy/1.0'
|
||||
);
|
||||
} catch {
|
||||
return 'gods-eye-view-cctv-proxy/1.0';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch one upstream CCTV image within the frame-refresh budget.
|
||||
*
|
||||
@@ -268,10 +427,15 @@ export async function fetchCctvImageFromUpstream(
|
||||
);
|
||||
}, timeoutMs);
|
||||
try {
|
||||
const upstream = await fetchImpl(url, {
|
||||
headers: { 'User-Agent': 'gods-eye-view-cctv-proxy/1.0' },
|
||||
const upstream = await fetchWithinHost(
|
||||
url,
|
||||
{
|
||||
headers: { 'User-Agent': cctvUpstreamUserAgent(url) },
|
||||
signal: controller.signal,
|
||||
});
|
||||
},
|
||||
fetchImpl,
|
||||
);
|
||||
if (!upstream) return null;
|
||||
const contentType = upstream.headers.get('content-type') || '';
|
||||
if (!upstream.ok || !contentType.startsWith('image/')) {
|
||||
controller.abort();
|
||||
|
||||
@@ -306,6 +306,109 @@ export function isLikelyAustinCoordinate(lat, lon) {
|
||||
return lat >= 30.02 && lat <= 30.58 && lon >= -98.12 && lon <= -97.4;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bounding-box sanity check: is this coordinate plausibly on the Finnish road
|
||||
* network? Generous around the observed catalog extent (59.86..70.09 N,
|
||||
* 19.62..31.28 E) so a real new station is never dropped, tight enough that a
|
||||
* swapped lat/lon or a null island record is.
|
||||
*
|
||||
* @param {number} lat
|
||||
* @param {number} lon
|
||||
* @returns {boolean}
|
||||
*/
|
||||
/** Longest unselected camera label the HUD shows before it gets noisy. */
|
||||
export const CAMERA_CODE_MAX_CHARS = 28;
|
||||
|
||||
/**
|
||||
* Short display code for the unselected camera label ("CAM-<code>"): the
|
||||
* feed's own name for the camera ("5TH ST / CONGRESS AVE", "TRAFALGAR
|
||||
* SQUARE"), trimmed to CAMERA_CODE_MAX_CHARS. A pack may pass an explicit
|
||||
* `code` (TxDOT's device key, NSW's title); the id is the last resort.
|
||||
*
|
||||
* @param {string} text
|
||||
* @returns {string}
|
||||
*/
|
||||
export function cameraDisplayCode(text) {
|
||||
const clean = String(text || '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
if (clean.length <= CAMERA_CODE_MAX_CHARS) return clean;
|
||||
return `${clean.slice(0, CAMERA_CODE_MAX_CHARS - 1).trimEnd()}…`;
|
||||
}
|
||||
|
||||
/** Finite, in range, and not the null island that Number(null) produces. */
|
||||
export function isPlausibleLatLon(lat, lon) {
|
||||
return (
|
||||
Number.isFinite(lat) &&
|
||||
Number.isFinite(lon) &&
|
||||
Math.abs(lat) <= 90 &&
|
||||
Math.abs(lon) <= 180 &&
|
||||
!(lat === 0 && lon === 0)
|
||||
);
|
||||
}
|
||||
|
||||
/** British Columbia bounding box (with the neighbouring border crossings). */
|
||||
export function isLikelyBcCoordinate(lat, lon) {
|
||||
return (
|
||||
isPlausibleLatLon(lat, lon) &&
|
||||
lat >= 48 &&
|
||||
lat <= 60.5 &&
|
||||
lon >= -139.5 &&
|
||||
lon <= -114
|
||||
);
|
||||
}
|
||||
|
||||
/** Texas bounding box. */
|
||||
export function isLikelyTexasCoordinate(lat, lon) {
|
||||
return (
|
||||
isPlausibleLatLon(lat, lon) &&
|
||||
lat >= 25.5 &&
|
||||
lat <= 36.7 &&
|
||||
lon >= -107 &&
|
||||
lon <= -93.4
|
||||
);
|
||||
}
|
||||
|
||||
/** New South Wales bounding box (incl. the ACT and Lord Howe Island). */
|
||||
export function isLikelyNswCoordinate(lat, lon) {
|
||||
return (
|
||||
isPlausibleLatLon(lat, lon) &&
|
||||
lat >= -38 &&
|
||||
lat <= -28 &&
|
||||
lon >= 140.9 &&
|
||||
lon <= 159.2
|
||||
);
|
||||
}
|
||||
|
||||
export function isLikelyFinlandCoordinate(lat, lon) {
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return false;
|
||||
return lat >= 59.5 && lat <= 70.5 && lon >= 19 && lon <= 32;
|
||||
}
|
||||
|
||||
/**
|
||||
* Human label for one Fintraffic preset (camera view).
|
||||
*
|
||||
* Station names are machine-shaped road codes ("vt3_Hyvinkää_Noppo"); the
|
||||
* underscores become spaces. A preset id is always its station id plus a
|
||||
* two-digit view number, so the remainder distinguishes the several views that
|
||||
* share one station position. The station list endpoint carries no
|
||||
* presentationName ("Helsinkiin"); that lives only on the per-station detail
|
||||
* endpoint, which would cost one request per station.
|
||||
*
|
||||
* @param {string} stationName - Raw `properties.name`.
|
||||
* @param {string} stationId - Raw `properties.id` (e.g. "C01503").
|
||||
* @param {string} presetId - Raw preset id (e.g. "C0150301").
|
||||
* @returns {string}
|
||||
*/
|
||||
export function fintrafficCameraName(stationName, stationId, presetId) {
|
||||
const base =
|
||||
String(stationName || '')
|
||||
.replace(/_/g, ' ')
|
||||
.trim() || `Fintraffic ${stationId}`;
|
||||
const view = String(presetId || '').slice(String(stationId || '').length);
|
||||
return view ? `${base} (view ${view})` : base;
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive a deterministic fallback heading from a camera ID hash.
|
||||
*
|
||||
@@ -353,14 +456,15 @@ export function prioritizeSources(cameras, maxCount, anchors) {
|
||||
const anchorList = (Array.isArray(anchors) ? anchors : []).filter(
|
||||
(a) => Number.isFinite(a?.lat) && Number.isFinite(a?.lon),
|
||||
);
|
||||
if (
|
||||
!Number.isFinite(maxCount) ||
|
||||
maxCount <= 0 ||
|
||||
list.length <= maxCount ||
|
||||
!anchorList.length
|
||||
) {
|
||||
return list;
|
||||
}
|
||||
if (!anchorList.length) return list;
|
||||
// Always sort when anchors exist, even when the pack fits its own cap: the
|
||||
// catalog-wide cap (cap.js) thins a pack from the END of this order, so
|
||||
// "nearest to an anchor first" has to hold whether or not the pack was
|
||||
// trimmed here.
|
||||
const cap =
|
||||
Number.isFinite(maxCount) && maxCount > 0
|
||||
? Math.min(maxCount, list.length)
|
||||
: list.length;
|
||||
|
||||
const scored = list.map((camera, idx) => {
|
||||
const lat = Number(camera?.lat);
|
||||
@@ -379,7 +483,7 @@ export function prioritizeSources(cameras, maxCount, anchors) {
|
||||
return a.idx - b.idx;
|
||||
});
|
||||
|
||||
return scored.slice(0, maxCount).map((entry) => entry.camera);
|
||||
return scored.slice(0, cap).map((entry) => entry.camera);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -410,6 +514,14 @@ export function normalizeSourceItem(item) {
|
||||
url: typeof item.url === 'string' ? item.url : '',
|
||||
snapshotUrl: typeof item.snapshotUrl === 'string' ? item.snapshotUrl : '',
|
||||
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.
|
||||
credit: String(item.credit || '').trim(),
|
||||
// Unselected-label code: the pack's explicit short name, else the feed's
|
||||
// name, else the id.
|
||||
code: cameraDisplayCode(
|
||||
item.code || String(item.name || '').toUpperCase() || item.id || '',
|
||||
),
|
||||
sourceKind: String(item.sourceKind || item.kind || 'configured'),
|
||||
// Optional CAL badge input (cctv-v2 design §3b/§9.2, additive-only per the
|
||||
// global constraints — nothing else in this file changes): hand-authored
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -27,6 +27,7 @@ const layer = createCctvLayer({
|
||||
},
|
||||
});
|
||||
export const calibrationPatchMovesAnchor = layer.calibrationPatchMovesAnchor;
|
||||
export const migrateRangeScaleForFloor = layer.migrateRangeScaleForFloor;
|
||||
export const _pushAmbientCardEntriesForTest =
|
||||
layer._pushAmbientCardEntriesForTest;
|
||||
export const _setCctvOverlayHostForTest = layer._setCctvOverlayHostForTest;
|
||||
|
||||
+663
-151
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,137 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
allocateSourceCap,
|
||||
resolveCatalogCap,
|
||||
} from '../../server/providers/cctv/cap.js';
|
||||
import {
|
||||
CCTV_MAX_SOURCES_CEILING,
|
||||
DEFAULT_CCTV_MAX_SOURCES,
|
||||
} from '../../server/providers/cctv/constants.js';
|
||||
import { createCctvCatalog } from '../../server/providers/cctv/catalog.js';
|
||||
|
||||
const pack = (name, count, prefix = name) => ({
|
||||
name,
|
||||
sources: Array.from({ length: count }, (_, i) => ({
|
||||
id: `${prefix}-${i}`,
|
||||
rank: i,
|
||||
})),
|
||||
});
|
||||
|
||||
test('under the cap every pack is served whole, in merge order', () => {
|
||||
const { sources, packs } = allocateSourceCap(
|
||||
[pack('a', 3), pack('b', 2)],
|
||||
10,
|
||||
);
|
||||
assert.deepEqual(
|
||||
sources.map((s) => s.id),
|
||||
['a-0', 'a-1', 'a-2', 'b-0', 'b-1'],
|
||||
);
|
||||
assert.deepEqual(packs, [
|
||||
{ name: 'a', offered: 3, kept: 3 },
|
||||
{ name: 'b', offered: 2, kept: 2 },
|
||||
]);
|
||||
});
|
||||
|
||||
test('over the cap the packs share it round-robin, and the last pack is never starved', () => {
|
||||
// The old positional slice kept the first N of [a..., b..., c...], which
|
||||
// deleted pack c entirely; the fair split thins every pack instead.
|
||||
const { sources, packs } = allocateSourceCap(
|
||||
[pack('a', 100), pack('b', 100), pack('c', 100)],
|
||||
9,
|
||||
);
|
||||
assert.equal(sources.length, 9);
|
||||
assert.deepEqual(
|
||||
packs.map((p) => p.kept),
|
||||
[3, 3, 3],
|
||||
);
|
||||
// Each pack contributes its own highest-priority cameras, in its own order.
|
||||
assert.deepEqual(
|
||||
sources.filter((s) => s.id.startsWith('c-')).map((s) => s.rank),
|
||||
[0, 1, 2],
|
||||
);
|
||||
});
|
||||
|
||||
test('a duplicate inside one pack takes one slot, not two', () => {
|
||||
const dup = {
|
||||
name: 'file',
|
||||
sources: [{ id: 'x', v: 'old' }, { id: 'x', v: 'new' }, { id: 'y' }],
|
||||
};
|
||||
const { sources } = allocateSourceCap([dup], 2);
|
||||
assert.deepEqual(
|
||||
sources.map((s) => [s.id, s.v]),
|
||||
[
|
||||
['x', 'new'],
|
||||
['y', undefined],
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test('a short pack releases its turns to the others', () => {
|
||||
const { packs } = allocateSourceCap([pack('a', 2), pack('b', 50)], 10);
|
||||
assert.deepEqual(
|
||||
packs.map((p) => p.kept),
|
||||
[2, 8],
|
||||
);
|
||||
});
|
||||
|
||||
test('duplicate ids resolve last-pack-wins and count against the winning pack', () => {
|
||||
const live = {
|
||||
name: 'live',
|
||||
sources: [{ id: 'cam-1', label: 'live' }, { id: 'cam-2' }],
|
||||
};
|
||||
const override = {
|
||||
name: 'env',
|
||||
sources: [{ id: 'cam-1', label: 'override' }],
|
||||
};
|
||||
const { sources, packs } = allocateSourceCap([live, override], 10);
|
||||
assert.deepEqual(
|
||||
sources.map((s) => [s.id, s.label]),
|
||||
[
|
||||
['cam-2', undefined],
|
||||
['cam-1', 'override'],
|
||||
],
|
||||
);
|
||||
assert.deepEqual(packs, [
|
||||
{ name: 'live', offered: 1, kept: 1 },
|
||||
{ name: 'env', offered: 1, kept: 1 },
|
||||
]);
|
||||
});
|
||||
|
||||
test('resolveCatalogCap bounds the env value and falls back to the default', () => {
|
||||
assert.equal(resolveCatalogCap(undefined), DEFAULT_CCTV_MAX_SOURCES);
|
||||
assert.equal(resolveCatalogCap('nonsense'), DEFAULT_CCTV_MAX_SOURCES);
|
||||
assert.equal(resolveCatalogCap('1'), 8);
|
||||
assert.equal(resolveCatalogCap('999999'), CCTV_MAX_SOURCES_CEILING);
|
||||
assert.equal(resolveCatalogCap('120.9'), 120);
|
||||
});
|
||||
|
||||
test('the catalog applies the fair cap to configured packs end to end', async () => {
|
||||
const saved = { ...process.env };
|
||||
try {
|
||||
process.env.CCTV_PREFER_AUSTIN = '0';
|
||||
process.env.CCTV_SOURCES_FILE = '/nonexistent/cctv.json';
|
||||
process.env.CCTV_SOURCES_JSON = JSON.stringify(
|
||||
Array.from({ length: 12 }, (_, i) => ({
|
||||
id: `env-${i}`,
|
||||
name: `Env ${i}`,
|
||||
lat: 30 + i * 0.01,
|
||||
lon: -97,
|
||||
url: `https://example.test/${i}.jpg`,
|
||||
})),
|
||||
);
|
||||
process.env.CCTV_MAX_SOURCES = '9';
|
||||
const getSources = createCctvCatalog({ sourceRoot: '/nonexistent' });
|
||||
const sources = await getSources();
|
||||
assert.equal(sources.length, 9);
|
||||
assert.deepEqual(
|
||||
sources.slice(0, 3).map((s) => s.id),
|
||||
['env-0', 'env-1', 'env-2'],
|
||||
);
|
||||
} finally {
|
||||
for (const key of Object.keys(process.env)) {
|
||||
if (!(key in saved)) delete process.env[key];
|
||||
}
|
||||
Object.assign(process.env, saved);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
CAMERA_CODE_MAX_CHARS,
|
||||
cameraDisplayCode,
|
||||
normalizeSourceItem,
|
||||
} from '../../server/providers/cctv/normalize.js';
|
||||
|
||||
test('cameraDisplayCode trims long names with an ellipsis and collapses whitespace', () => {
|
||||
assert.equal(
|
||||
cameraDisplayCode(' IH 35 at Cesar Chavez '),
|
||||
'IH 35 at Cesar Chavez',
|
||||
);
|
||||
const long = 'City of Windsor – Goyeau Street and Park Street';
|
||||
const code = cameraDisplayCode(long);
|
||||
assert.equal(code.length, CAMERA_CODE_MAX_CHARS);
|
||||
assert.ok(code.endsWith('…'));
|
||||
assert.equal(cameraDisplayCode(''), '');
|
||||
});
|
||||
|
||||
test('every pack labels by its feed name; explicit codes win; the id is the last resort', () => {
|
||||
const base = { lat: 1, lon: 1, url: 'https://x.test/a.jpg' };
|
||||
assert.equal(
|
||||
normalizeSourceItem({
|
||||
...base,
|
||||
id: '354',
|
||||
name: '5TH ST / CONGRESS AVE',
|
||||
sourceKind: 'austin-open-data',
|
||||
}).code,
|
||||
'5TH ST / CONGRESS AVE',
|
||||
);
|
||||
assert.equal(
|
||||
normalizeSourceItem({
|
||||
...base,
|
||||
id: 'on-860',
|
||||
name: 'Goyeau Street and Park Street',
|
||||
sourceKind: 'ontario-511-open-data',
|
||||
}).code,
|
||||
'GOYEAU STREET AND PARK STRE…',
|
||||
);
|
||||
assert.equal(
|
||||
normalizeSourceItem({
|
||||
...base,
|
||||
id: 'nsw-46fa631a',
|
||||
name: 'Erskine Street looking west',
|
||||
sourceKind: 'nsw-livetraffic',
|
||||
code: 'ERSKINE ST (SYDNEY)',
|
||||
}).code,
|
||||
'ERSKINE ST (SYDNEY)',
|
||||
);
|
||||
assert.equal(
|
||||
normalizeSourceItem({
|
||||
...base,
|
||||
id: 'fi-c1455101',
|
||||
name: '',
|
||||
sourceKind: 'fintraffic-open-data',
|
||||
}).code,
|
||||
'fi-c1455101',
|
||||
'a missing name falls back to the id',
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,272 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { createCctvCatalog } from '../../server/providers/cctv/catalog.js';
|
||||
import {
|
||||
DEFAULT_AUSTIN_MAX_SOURCES,
|
||||
DEFAULT_CALTRANS_MAX_SOURCES,
|
||||
DEFAULT_CCTV_MAX_SOURCES,
|
||||
CCTV_MAX_SOURCES_CEILING,
|
||||
DEFAULT_DRIVEBC_MAX_SOURCES,
|
||||
DEFAULT_TFL_MAX_SOURCES,
|
||||
DEFAULT_ONTARIO_MAX_SOURCES,
|
||||
DEFAULT_FINTRAFFIC_MAX_SOURCES,
|
||||
DEFAULT_TXDOT_MAX_SOURCES,
|
||||
DEFAULT_TALLINN_MAX_SOURCES,
|
||||
DEFAULT_TARKTEE_MAX_SOURCES,
|
||||
DEFAULT_NSW_MAX_SOURCES,
|
||||
DRIVEBC_WEBCAMS_URL,
|
||||
} from '../../server/providers/cctv/constants.js';
|
||||
import {
|
||||
driveBcImageCredit,
|
||||
loadDriveBcSourcesFromOpenData,
|
||||
} from '../../server/providers/cctv/sources.js';
|
||||
|
||||
/** Set (or, for `undefined`, delete) environment variables for one test. */
|
||||
function withEnv(t, env) {
|
||||
for (const [name, value] of Object.entries(env)) {
|
||||
const previous = process.env[name];
|
||||
t.after(() => {
|
||||
if (previous === undefined) delete process.env[name];
|
||||
else process.env[name] = previous;
|
||||
});
|
||||
if (value === undefined) delete process.env[name];
|
||||
else process.env[name] = value;
|
||||
}
|
||||
}
|
||||
|
||||
/** Silence the loaders' progress and failure logging. */
|
||||
function quiet(t) {
|
||||
t.mock.method(console, 'log', () => {});
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
}
|
||||
|
||||
/** A DriveBC `/api/webcams/` row carrying the fields the loader reads. */
|
||||
function driveBcRow(id, lat, lon, overrides = {}) {
|
||||
return {
|
||||
id,
|
||||
name: `Camera ${id}`,
|
||||
is_on: true,
|
||||
should_appear: true,
|
||||
region_name: 'Lower Mainland',
|
||||
orientation: 'N',
|
||||
elevation: 12,
|
||||
location: { type: 'Point', coordinates: [lon, lat] },
|
||||
links: { imageDisplay: `/images/${id}.jpg?t=1` },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test('DriveBC loader keeps published cameras and builds frame URLs from the camera id', async (t) => {
|
||||
quiet(t);
|
||||
withEnv(t, { CCTV_DRIVEBC_MAX_SOURCES: undefined });
|
||||
const requested = [];
|
||||
t.mock.method(globalThis, 'fetch', async (url) => {
|
||||
requested.push(String(url));
|
||||
return Response.json([
|
||||
driveBcRow(13, 49.001268, -122.756658, {
|
||||
name: 'Douglas (Peace Arch) Border Crossing',
|
||||
region_name: 'Border Cams',
|
||||
elevation: 10,
|
||||
// The payload's own link is ignored; frames come from the pinned host.
|
||||
links: { imageDisplay: 'https://example.com/13.jpg' },
|
||||
}),
|
||||
driveBcRow(682, 49.0579, -123.0569, {
|
||||
orientation: 'se',
|
||||
elevation: null,
|
||||
credit:
|
||||
'Images courtesy of <a href="https://www.translink.ca/" target="_blank">TransLink</a>',
|
||||
}),
|
||||
driveBcRow(1, 49.2, -123.1, { is_on: false }),
|
||||
driveBcRow(2, 49.2, -123.1, { should_appear: false }),
|
||||
driveBcRow('3', 49.2, -123.1),
|
||||
driveBcRow(4, null, -123.1),
|
||||
driveBcRow(5, 49.2, -123.1, { location: null }),
|
||||
driveBcRow(6, 1000, -123.1),
|
||||
driveBcRow(7, 34.05, -118.24),
|
||||
]);
|
||||
});
|
||||
|
||||
const cameras = await loadDriveBcSourcesFromOpenData();
|
||||
|
||||
assert.deepEqual(requested, [DRIVEBC_WEBCAMS_URL]);
|
||||
// Nearest to the Vancouver anchor first: the pack is always distance-sorted
|
||||
// so the catalog-wide cap thins it from the far end.
|
||||
assert.deepEqual(
|
||||
cameras.map((camera) => camera.id),
|
||||
['drivebc-682', 'drivebc-13'],
|
||||
);
|
||||
const border = cameras.find((camera) => camera.id === 'drivebc-13');
|
||||
const deltaport = cameras.find((camera) => camera.id === 'drivebc-682');
|
||||
assert.equal(border.url, 'https://www.drivebc.ca/images/13.jpg');
|
||||
assert.equal(border.snapshotUrl, border.url);
|
||||
assert.equal(border.name, 'Douglas (Peace Arch) Border Crossing');
|
||||
assert.equal(border.city, 'BC Border');
|
||||
assert.equal(border.provider, 'DriveBC');
|
||||
assert.equal(border.feedType, 'image');
|
||||
assert.equal(border.headingDeg, 0);
|
||||
assert.equal(border.headingConfidence, 'high');
|
||||
assert.equal(border.groundElevationM, 10);
|
||||
assert.match(border.license, /Open Government Licence – British Columbia/);
|
||||
assert.equal(
|
||||
border.credit,
|
||||
'',
|
||||
'a provincial camera carries no partner credit',
|
||||
);
|
||||
assert.equal(
|
||||
deltaport.credit,
|
||||
'Images courtesy of TransLink',
|
||||
'a partner-supplied camera names its owner, HTML stripped',
|
||||
);
|
||||
assert.equal(deltaport.city, 'Lower Mainland');
|
||||
assert.equal(
|
||||
deltaport.headingDeg,
|
||||
135,
|
||||
'orientation codes are case-insensitive',
|
||||
);
|
||||
assert.equal(
|
||||
deltaport.groundElevationM,
|
||||
0,
|
||||
'a missing elevation falls back to sea level',
|
||||
);
|
||||
});
|
||||
|
||||
test('DriveBC loader keeps the cameras nearest Vancouver and Victoria under its cap', async (t) => {
|
||||
quiet(t);
|
||||
withEnv(t, { CCTV_DRIVEBC_MAX_SOURCES: '8' });
|
||||
const rows = [
|
||||
// Fort Nelson rows come first in payload order, so only distance can drop them.
|
||||
...[58.8, 58.81, 58.82, 58.83].map((lat, i) =>
|
||||
driveBcRow(100 + i, lat, -122.7),
|
||||
),
|
||||
...[49.28, 49.27, 49.26, 49.25].map((lat, i) =>
|
||||
driveBcRow(200 + i, lat, -123.12),
|
||||
),
|
||||
...[48.43, 48.44, 48.45, 48.46].map((lat, i) =>
|
||||
driveBcRow(300 + i, lat, -123.37),
|
||||
),
|
||||
];
|
||||
t.mock.method(globalThis, 'fetch', async () => Response.json(rows));
|
||||
|
||||
const cameras = await loadDriveBcSourcesFromOpenData();
|
||||
|
||||
assert.equal(cameras.length, 8);
|
||||
assert.deepEqual(
|
||||
cameras
|
||||
.map((camera) => camera.id)
|
||||
.filter((id) => /^drivebc-10\d$/.test(id)),
|
||||
[],
|
||||
'the northern cameras are the ones dropped',
|
||||
);
|
||||
});
|
||||
|
||||
test('DriveBC 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({ webcams: [] }),
|
||||
async () => {
|
||||
throw new TypeError('fetch failed');
|
||||
},
|
||||
]) {
|
||||
assert.deepEqual(await loadDriveBcSourcesFromOpenData(), []);
|
||||
}
|
||||
});
|
||||
|
||||
test('CCTV catalog merges DriveBC cameras and CCTV_DRIVEBC_ENABLED=0 skips the request', async (t) => {
|
||||
quiet(t);
|
||||
const sourceRoot = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), 'gev-cctv-catalog-'),
|
||||
);
|
||||
t.after(() => fs.rmSync(sourceRoot, { recursive: true, force: true }));
|
||||
withEnv(t, {
|
||||
CCTV_SOURCES_FILE: undefined,
|
||||
CCTV_SOURCES_JSON: undefined,
|
||||
CCTV_FORCE_AUSTIN: undefined,
|
||||
CCTV_PREFER_AUSTIN: undefined,
|
||||
CCTV_MAX_SOURCES: undefined,
|
||||
CCTV_CALTRANS_DISTRICTS: undefined,
|
||||
CCTV_TFL_ENABLED: undefined,
|
||||
CCTV_DRIVEBC_ENABLED: undefined,
|
||||
CCTV_DRIVEBC_MAX_SOURCES: undefined,
|
||||
});
|
||||
const requested = [];
|
||||
t.mock.method(globalThis, 'fetch', async (url) => {
|
||||
requested.push(String(url));
|
||||
// Every other live pack is down; only DriveBC answers.
|
||||
return String(url) === DRIVEBC_WEBCAMS_URL
|
||||
? Response.json([driveBcRow(13, 49.001268, -122.756658)])
|
||||
: new Response('unavailable', { status: 503 });
|
||||
});
|
||||
|
||||
const sources = await createCctvCatalog({ sourceRoot })();
|
||||
assert.deepEqual(
|
||||
sources.map((source) => source.id),
|
||||
['drivebc-13'],
|
||||
);
|
||||
assert.equal(sources[0].url, 'https://www.drivebc.ca/images/13.jpg');
|
||||
|
||||
process.env.CCTV_DRIVEBC_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(DRIVEBC_WEBCAMS_URL),
|
||||
false,
|
||||
'a disabled pack makes no request',
|
||||
);
|
||||
});
|
||||
|
||||
test('default per-pack camera caps fit inside the default catalog cap', () => {
|
||||
// Every default pack at its own cap (Warendorf ships 1 curated camera).
|
||||
const packs =
|
||||
DEFAULT_AUSTIN_MAX_SOURCES +
|
||||
DEFAULT_CALTRANS_MAX_SOURCES +
|
||||
DEFAULT_TFL_MAX_SOURCES +
|
||||
DEFAULT_ONTARIO_MAX_SOURCES +
|
||||
DEFAULT_FINTRAFFIC_MAX_SOURCES +
|
||||
DEFAULT_DRIVEBC_MAX_SOURCES +
|
||||
DEFAULT_TXDOT_MAX_SOURCES +
|
||||
DEFAULT_TALLINN_MAX_SOURCES +
|
||||
DEFAULT_TARKTEE_MAX_SOURCES +
|
||||
DEFAULT_NSW_MAX_SOURCES +
|
||||
1;
|
||||
assert.ok(
|
||||
packs <= DEFAULT_CCTV_MAX_SOURCES,
|
||||
`default packs (${packs}) would be thinned by the catalog cap (${DEFAULT_CCTV_MAX_SOURCES})`,
|
||||
);
|
||||
assert.ok(
|
||||
DEFAULT_CCTV_MAX_SOURCES <= CCTV_MAX_SOURCES_CEILING,
|
||||
'the default cap must sit inside the CCTV_MAX_SOURCES ceiling',
|
||||
);
|
||||
});
|
||||
|
||||
test('DriveBC credit keeps partner attribution and drops operational notes', () => {
|
||||
assert.equal(
|
||||
driveBcImageCredit(
|
||||
'Images courtesy of <a href="https://x.test/">TransLink</a>',
|
||||
),
|
||||
'Images courtesy of TransLink',
|
||||
);
|
||||
assert.equal(
|
||||
driveBcImageCredit('Camera image provided by City of Vancouver'),
|
||||
'Camera image provided by City of Vancouver',
|
||||
);
|
||||
assert.equal(
|
||||
driveBcImageCredit(
|
||||
'BC HighwayCam presented in cooperation with AtkinsRéalis.',
|
||||
),
|
||||
'BC HighwayCam presented in cooperation with AtkinsRéalis.',
|
||||
);
|
||||
assert.equal(
|
||||
driveBcImageCredit(
|
||||
'This camera is located in a remote area and relies on solar power. As a result, transmission delays may occur.',
|
||||
),
|
||||
'',
|
||||
);
|
||||
assert.equal(driveBcImageCredit(null), '');
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
loadTallinnSourcesFromCatalog,
|
||||
parseTarkteeDatexImages,
|
||||
parseTarkteeDatexLocations,
|
||||
} from '../../server/providers/cctv/sources.js';
|
||||
|
||||
const SAMPLE_LOCATIONS_XML = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<d2LogicalModel xmlns="http://datex2.eu/schema/2/2_0">
|
||||
<payloadPublication>
|
||||
<predefinedLocationContainer id="TRAFFIC_CAMERAS" version="0">
|
||||
<predefinedLocation id="aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" version="0">
|
||||
<predefinedLocationName>
|
||||
<values><value lang="et">Test Site</value></values>
|
||||
</predefinedLocationName>
|
||||
<location>
|
||||
<pointByCoordinates>
|
||||
<pointCoordinates>
|
||||
<latitude>59.4370</latitude>
|
||||
<longitude>24.7530</longitude>
|
||||
</pointCoordinates>
|
||||
</pointByCoordinates>
|
||||
</location>
|
||||
</predefinedLocation>
|
||||
</predefinedLocationContainer>
|
||||
</payloadPublication>
|
||||
</d2LogicalModel>`;
|
||||
|
||||
const SAMPLE_IMAGES_XML = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<d2LogicalModel xmlns="http://datex2.eu/schema/2/2_0">
|
||||
<payloadPublication>
|
||||
<trafficView id="1">
|
||||
<linearTrafficView id="1">
|
||||
<linearPredefinedLocationReference targetClass="PredefinedLocation" id="aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" version="0"/>
|
||||
<trafficViewRecord id="1-1">
|
||||
<urlLink>
|
||||
<urlLinkAddress>https://tarktee.transpordiamet.ee/images/42/42_202608251542.jpg</urlLinkAddress>
|
||||
<urlLinkType>image</urlLinkType>
|
||||
</urlLink>
|
||||
</trafficViewRecord>
|
||||
</linearTrafficView>
|
||||
</trafficView>
|
||||
</payloadPublication>
|
||||
</d2LogicalModel>`;
|
||||
|
||||
test('Tallinn catalog loads curated ristmikud stills only', () => {
|
||||
const cameras = loadTallinnSourcesFromCatalog();
|
||||
assert.ok(
|
||||
cameras.length >= 200,
|
||||
`expected a full Tallinn pack, got ${cameras.length}`,
|
||||
);
|
||||
for (const camera of cameras.slice(0, 25)) {
|
||||
assert.match(camera.id, /^tln-/);
|
||||
assert.equal(camera.cityId, 'tallinn');
|
||||
assert.equal(camera.feedType, 'image');
|
||||
assert.equal(camera.sourceKind, 'tallinn-ristmikud');
|
||||
assert.ok(camera.url.startsWith('https://ristmikud.tallinn.ee/'));
|
||||
assert.equal(camera.url, camera.snapshotUrl);
|
||||
assert.ok(Number.isFinite(camera.lat) && Number.isFinite(camera.lon));
|
||||
}
|
||||
});
|
||||
|
||||
test('Tarktee DATEX parsers join location UUIDs to image URLs', () => {
|
||||
const locations = parseTarkteeDatexLocations(SAMPLE_LOCATIONS_XML);
|
||||
const images = parseTarkteeDatexImages(SAMPLE_IMAGES_XML);
|
||||
assert.equal(locations.size, 1);
|
||||
assert.equal(images.size, 1);
|
||||
const id = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee';
|
||||
assert.equal(locations.get(id)?.name, 'Test Site');
|
||||
assert.equal(locations.get(id)?.lat, 59.437);
|
||||
assert.equal(locations.get(id)?.lon, 24.753);
|
||||
assert.equal(
|
||||
images.get(id),
|
||||
'https://tarktee.transpordiamet.ee/images/42/42_202608251542.jpg',
|
||||
);
|
||||
});
|
||||
|
||||
test('Tarktee DATEX image parser rejects non-official hosts', () => {
|
||||
const xml = SAMPLE_IMAGES_XML.replace(
|
||||
'https://tarktee.transpordiamet.ee/images/42/42_202608251542.jpg',
|
||||
'https://evil.example/images/42/42.jpg',
|
||||
);
|
||||
const images = parseTarkteeDatexImages(xml);
|
||||
assert.equal(images.size, 0);
|
||||
});
|
||||
@@ -0,0 +1,256 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { loadFintrafficSourcesFromOpenData } from '../../server/providers/cctv/sources.js';
|
||||
import {
|
||||
fintrafficCameraName,
|
||||
isLikelyFinlandCoordinate,
|
||||
} from '../../server/providers/cctv/normalize.js';
|
||||
import {
|
||||
FINTRAFFIC_GROUND_ELEVATION_M,
|
||||
FINTRAFFIC_IMAGE_ORIGIN,
|
||||
FINTRAFFIC_STATIONS_URL,
|
||||
} from '../../server/providers/cctv/constants.js';
|
||||
|
||||
/** One station feature in the shape tie.digitraffic.fi actually returns. */
|
||||
function station(
|
||||
id,
|
||||
{
|
||||
name = `vt4_${id}`,
|
||||
status = 'GATHERING',
|
||||
lon = 24.9384,
|
||||
lat = 60.1699,
|
||||
altitude = 0,
|
||||
presets = [],
|
||||
} = {},
|
||||
) {
|
||||
return {
|
||||
type: 'Feature',
|
||||
id,
|
||||
geometry: { type: 'Point', coordinates: [lon, lat, altitude] },
|
||||
properties: {
|
||||
id,
|
||||
name,
|
||||
collectionStatus: status,
|
||||
dataUpdatedTime: '2026-09-13T03:25:36Z',
|
||||
presets,
|
||||
state: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Run the loader against a canned station list, restoring global fetch after. */
|
||||
async function loadWith(features, { env = {}, capture = {} } = {}) {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const originalEnv = {};
|
||||
for (const [key, value] of Object.entries(env)) {
|
||||
originalEnv[key] = process.env[key];
|
||||
if (value === undefined) delete process.env[key];
|
||||
else process.env[key] = value;
|
||||
}
|
||||
globalThis.fetch = async (url, options) => {
|
||||
capture.url = String(url);
|
||||
capture.options = options;
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
type: 'FeatureCollection',
|
||||
dataUpdatedTime: '2026-09-13T05:31:58Z',
|
||||
features,
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
},
|
||||
);
|
||||
};
|
||||
try {
|
||||
return await loadFintrafficSourcesFromOpenData();
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
for (const [key, value] of Object.entries(originalEnv)) {
|
||||
if (value === undefined) delete process.env[key];
|
||||
else process.env[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test('Fintraffic loader turns every in-collection preset into one camera', async () => {
|
||||
const cameras = await loadWith([
|
||||
station('C01503', {
|
||||
name: 'kt51_Inkoo',
|
||||
lon: 23.99616,
|
||||
lat: 60.05374,
|
||||
presets: [
|
||||
{ id: 'C0150301', inCollection: true },
|
||||
{ id: 'C0150302', inCollection: true },
|
||||
],
|
||||
}),
|
||||
]);
|
||||
|
||||
assert.equal(cameras.length, 2);
|
||||
const [first, second] = cameras;
|
||||
assert.equal(first.id, 'fi-c0150301');
|
||||
assert.equal(first.name, 'kt51 Inkoo (view 01)');
|
||||
assert.equal(second.name, 'kt51 Inkoo (view 02)');
|
||||
assert.equal(first.provider, 'Fintraffic');
|
||||
assert.equal(first.city, 'Finland');
|
||||
assert.equal(first.cityId, 'finland');
|
||||
assert.equal(first.sourceKind, 'fintraffic-open-data');
|
||||
assert.equal(first.license, 'Fintraffic / digitraffic.fi (CC BY 4.0)');
|
||||
assert.equal(first.feedType, 'image');
|
||||
// Presets of one station share the station position...
|
||||
assert.equal(first.lat, 60.05374);
|
||||
assert.equal(second.lat, first.lat);
|
||||
assert.equal(second.lon, first.lon);
|
||||
// ...so the id-hash fallback heading is what keeps their gizmos apart.
|
||||
assert.notEqual(first.headingDeg, second.headingDeg);
|
||||
assert.equal(first.headingConfidence, 'low');
|
||||
});
|
||||
|
||||
test('Fintraffic frame URLs are built on the official image origin', async () => {
|
||||
const cameras = await loadWith([
|
||||
station('C01503', { presets: [{ id: 'C0150301', inCollection: true }] }),
|
||||
]);
|
||||
|
||||
assert.equal(cameras[0].url, `${FINTRAFFIC_IMAGE_ORIGIN}C0150301.jpg`);
|
||||
assert.equal(cameras[0].snapshotUrl, cameras[0].url);
|
||||
assert.ok(cameras[0].url.startsWith('https://weathercam.digitraffic.fi/'));
|
||||
});
|
||||
|
||||
test('Fintraffic loader drops dead stations, uncollected presets and bad ids', async () => {
|
||||
const cameras = await loadWith([
|
||||
// Station off collection: every preset goes, live or not.
|
||||
station('C01601', {
|
||||
status: 'REMOVED_TEMPORARILY',
|
||||
presets: [{ id: 'C0160101', inCollection: true }],
|
||||
}),
|
||||
station('C01503', {
|
||||
presets: [
|
||||
{ id: 'C0150301', inCollection: true },
|
||||
{ id: 'C0150302', inCollection: false },
|
||||
{ id: 'C0150303' },
|
||||
// Ids that would escape the synthesized frame path, or belong to
|
||||
// another station, never become a camera.
|
||||
{ id: '../../etc/passwd', inCollection: true },
|
||||
{ id: 'C9999901', inCollection: true },
|
||||
],
|
||||
}),
|
||||
// Coordinates nowhere near Finland (swapped lat/lon).
|
||||
station('C01504', {
|
||||
lon: 60.1699,
|
||||
lat: 24.9384,
|
||||
presets: [{ id: 'C0150401', inCollection: true }],
|
||||
}),
|
||||
]);
|
||||
|
||||
assert.deepEqual(
|
||||
cameras.map((camera) => camera.id),
|
||||
['fi-c0150301'],
|
||||
);
|
||||
});
|
||||
|
||||
test('Fintraffic ground elevation uses a reported altitude and falls back otherwise', async () => {
|
||||
const cameras = await loadWith([
|
||||
station('C08508', {
|
||||
altitude: 136,
|
||||
presets: [{ id: 'C0850801', inCollection: true }],
|
||||
}),
|
||||
station('C01503', {
|
||||
altitude: 0,
|
||||
presets: [{ id: 'C0150301', inCollection: true }],
|
||||
}),
|
||||
station('C03506', {
|
||||
altitude: 99_000,
|
||||
presets: [{ id: 'C0350601', inCollection: true }],
|
||||
}),
|
||||
]);
|
||||
|
||||
const byId = new Map(cameras.map((camera) => [camera.id, camera]));
|
||||
assert.equal(byId.get('fi-c0850801').groundElevationM, 136);
|
||||
// 0 is "not reported", not sea level.
|
||||
assert.equal(
|
||||
byId.get('fi-c0150301').groundElevationM,
|
||||
FINTRAFFIC_GROUND_ELEVATION_M,
|
||||
);
|
||||
// A garbage upstream value cannot fling a camera kilometres up.
|
||||
assert.equal(byId.get('fi-c0350601').groundElevationM, 1400);
|
||||
});
|
||||
|
||||
test('Fintraffic loader identifies itself to Digitraffic and refuses redirects', async () => {
|
||||
const capture = {};
|
||||
await loadWith(
|
||||
[station('C01503', { presets: [{ id: 'C0150301', inCollection: true }] })],
|
||||
{ capture },
|
||||
);
|
||||
|
||||
assert.equal(capture.url, FINTRAFFIC_STATIONS_URL);
|
||||
assert.equal(capture.options.headers['Digitraffic-User'], 'gods-eye-view');
|
||||
assert.equal(capture.options.headers['Accept-Encoding'], 'gzip');
|
||||
assert.equal(capture.options.redirect, 'manual');
|
||||
assert.ok(capture.options.signal instanceof AbortSignal);
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = async () =>
|
||||
new Response(null, {
|
||||
status: 302,
|
||||
headers: { Location: 'https://example.com/stations' },
|
||||
});
|
||||
try {
|
||||
assert.deepEqual(await loadFintrafficSourcesFromOpenData(), []);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test('Fintraffic pack caps itself and fails to an empty list, never a throw', async () => {
|
||||
const presets = Array.from({ length: 40 }, (_, index) => ({
|
||||
id: `C01503${String(index).padStart(2, '0')}`,
|
||||
inCollection: true,
|
||||
}));
|
||||
const capped = await loadWith([station('C01503', { presets })], {
|
||||
env: { CCTV_FINTRAFFIC_MAX_SOURCES: '8' },
|
||||
});
|
||||
assert.equal(capped.length, 8);
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = async () => {
|
||||
throw new Error('network down');
|
||||
};
|
||||
try {
|
||||
assert.deepEqual(await loadFintrafficSourcesFromOpenData(), []);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
|
||||
globalThis.fetch = async () => new Response('nope', { status: 503 });
|
||||
try {
|
||||
assert.deepEqual(await loadFintrafficSourcesFromOpenData(), []);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test('fintrafficCameraName unpacks the machine station name and the view number', () => {
|
||||
assert.equal(
|
||||
fintrafficCameraName('vt3_Hyvinkää_Noppo', 'C01234', 'C0123402'),
|
||||
'vt3 Hyvinkää Noppo (view 02)',
|
||||
);
|
||||
// Nameless station still gets a usable label.
|
||||
assert.equal(
|
||||
fintrafficCameraName('', 'C01234', 'C0123409'),
|
||||
'Fintraffic C01234 (view 09)',
|
||||
);
|
||||
// A preset id that is just the station id has no view suffix to add.
|
||||
assert.equal(
|
||||
fintrafficCameraName('vt4_Mäntsälä', 'C01234', 'C01234'),
|
||||
'vt4 Mäntsälä',
|
||||
);
|
||||
});
|
||||
|
||||
test('isLikelyFinlandCoordinate spans the catalog extent and rejects the rest', () => {
|
||||
assert.equal(isLikelyFinlandCoordinate(60.1699, 24.9384), true); // Helsinki
|
||||
assert.equal(isLikelyFinlandCoordinate(70.088512, 27.0), true); // Utsjoki, the northern extreme
|
||||
assert.equal(isLikelyFinlandCoordinate(59.856454, 19.618722), true); // the southwestern extreme
|
||||
assert.equal(isLikelyFinlandCoordinate(51.5074, -0.1278), false); // London
|
||||
assert.equal(isLikelyFinlandCoordinate(0, 0), false); // null island
|
||||
assert.equal(isLikelyFinlandCoordinate(NaN, 24.9384), false);
|
||||
});
|
||||
@@ -0,0 +1,198 @@
|
||||
/**
|
||||
* Pure monitor-plane footprint math shared by the client geometry
|
||||
* (src/layers/cctv/geometry.js), the server sidecar join
|
||||
* (server/providers/cctv/groundHeights.js) and the offline precompute
|
||||
* (scripts/precompute-cctv-heights.mjs). Lives outside every ownership
|
||||
* package on purpose. No Cesium, no DOM: the same function
|
||||
* decides where a plane's support points are on the ground for both.
|
||||
*
|
||||
* The plane is the pitched far cap of the camera frustum: center at range R
|
||||
* along the heading (horizontal R·cos(pitch)), half-width R·tan(hFov/2),
|
||||
* half-height R·tan(vFov/2) with vFov from a 16:9 aspect, tilted by the
|
||||
* pitch. Support points are a 3×3 grid over that rectangle: rows bottom /
|
||||
* middle / top, columns left / center / right, named `bl bm br ml mc mr tl tm
|
||||
* tr`. Their horizontal positions depend on the pose only, never on ground
|
||||
* height, so an offline job can sample the ground under them for the nominal
|
||||
* pose and the client can reuse those samples as long as the pose is the same.
|
||||
*/
|
||||
|
||||
const EARTH_RADIUS_M = 6371000;
|
||||
export const PLANE_VERT_ASPECT = 16 / 9;
|
||||
export const SUPPORT_KEYS = Object.freeze([
|
||||
'bl',
|
||||
'bm',
|
||||
'br',
|
||||
'ml',
|
||||
'mc',
|
||||
'mr',
|
||||
'tl',
|
||||
'tm',
|
||||
'tr',
|
||||
]);
|
||||
|
||||
const toRad = (deg) => (deg * Math.PI) / 180;
|
||||
const toDeg = (rad) => (rad * 180) / Math.PI;
|
||||
|
||||
/**
|
||||
* Offset a lat/lon by a distance along a compass heading (spherical earth;
|
||||
* sub-centimetre at the ≤ 5 km ranges cameras use).
|
||||
* @param {number} lat @param {number} lon @param {number} headingDeg @param {number} distM
|
||||
* @returns {{lat:number, lon:number}}
|
||||
*/
|
||||
export function projectPoint(lat, lon, headingDeg, distM) {
|
||||
const b = toRad(headingDeg);
|
||||
const la = toRad(lat);
|
||||
const lo = toRad(lon);
|
||||
const ad = distM / EARTH_RADIUS_M;
|
||||
const la2 = Math.asin(
|
||||
Math.sin(la) * Math.cos(ad) + Math.cos(la) * Math.sin(ad) * Math.cos(b),
|
||||
);
|
||||
const lo2 =
|
||||
lo +
|
||||
Math.atan2(
|
||||
Math.sin(b) * Math.sin(ad) * Math.cos(la),
|
||||
Math.cos(ad) - Math.sin(la) * Math.sin(la2),
|
||||
);
|
||||
return { lat: toDeg(la2), lon: toDeg(lo2) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Plane dimensions and offsets for a pose, before any ground is known.
|
||||
* @param {{pitchDeg:number, fovDeg:number, rangeM:number}} pose
|
||||
* @returns {{R:number, pitch:number, halfW:number, halfH:number, horiz:number,
|
||||
* vert:number, upVert:number, upHoriz:number, vFovDeg:number}}
|
||||
*/
|
||||
export function planeDimensions(pose) {
|
||||
const R = Math.max(1, Number(pose.rangeM) || 1);
|
||||
const pitch = toRad(Math.max(-89, Math.min(89, Number(pose.pitchDeg) || 0)));
|
||||
const hFov = toRad(Math.max(8, Math.min(160, Number(pose.fovDeg) || 74)));
|
||||
const halfW = R * Math.tan(hFov / 2);
|
||||
const vFovRad = 2 * Math.atan(Math.tan(hFov / 2) / PLANE_VERT_ASPECT);
|
||||
const halfH = R * Math.tan(vFovRad / 2);
|
||||
return {
|
||||
R,
|
||||
pitch,
|
||||
halfW,
|
||||
halfH,
|
||||
horiz: R * Math.cos(pitch),
|
||||
vert: R * Math.sin(pitch),
|
||||
// In-plane "up", decomposed into a vertical part and a horizontal part
|
||||
// along the heading (a downward pitch tilts the plane's top forward).
|
||||
upVert: Math.cos(pitch) * halfH,
|
||||
upHoriz: -Math.sin(pitch) * halfH,
|
||||
vFovDeg: toDeg(vFovRad),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Horizontal (lat/lon) positions of the mount and the nine plane support
|
||||
* points for a pose. Altitudes are not part of this: the caller adds the
|
||||
* ground it measured under each point.
|
||||
* @param {{lat:number, lon:number, headingDeg:number, pitchDeg:number,
|
||||
* fovDeg:number, rangeM:number}} pose
|
||||
* @returns {{mount:{lat:number,lon:number}, capCenter:{lat:number,lon:number},
|
||||
* supports: Record<string,{lat:number,lon:number, row:-1|0|1, col:-1|0|1}>}}
|
||||
*/
|
||||
export function planeSupportPoints(pose) {
|
||||
const dims = planeDimensions(pose);
|
||||
const heading = Number(pose.headingDeg) || 0;
|
||||
const cap = projectPoint(pose.lat, pose.lon, heading, dims.horiz);
|
||||
const supports = {};
|
||||
const rows = { b: -1, m: 0, t: 1 };
|
||||
// Column letter: l / r, with the centre spelled `m` in the bottom and top
|
||||
// rows (bm, tm) and `c` in the middle row (mc).
|
||||
const cols = { l: -1, m: 0, c: 0, r: 1 };
|
||||
for (const key of SUPPORT_KEYS) {
|
||||
const row = rows[key[0]];
|
||||
const col = cols[key[1]];
|
||||
const across = projectPoint(
|
||||
cap.lat,
|
||||
cap.lon,
|
||||
heading + 90,
|
||||
col * dims.halfW,
|
||||
);
|
||||
const along = projectPoint(
|
||||
across.lat,
|
||||
across.lon,
|
||||
heading,
|
||||
row * dims.upHoriz,
|
||||
);
|
||||
supports[key] = { lat: along.lat, lon: along.lon, row, col };
|
||||
}
|
||||
return { mount: { lat: pose.lat, lon: pose.lon }, capCenter: cap, supports };
|
||||
}
|
||||
|
||||
/**
|
||||
* Altitude of the plane at a support point, given the plane center altitude:
|
||||
* the rigid rectangle's rows sit ± upVert around the center.
|
||||
* @param {number} capAltM @param {-1|0|1} row @param {{upVert:number}} dims
|
||||
* @returns {number}
|
||||
*/
|
||||
export function supportAltitude(capAltM, row, dims) {
|
||||
return capAltM + row * dims.upVert;
|
||||
}
|
||||
|
||||
/**
|
||||
* The single rigid lift (metres, ≥ 0) that puts every support point at least
|
||||
* `clearanceM` above the ground measured under it. Support points without a
|
||||
* measured ground fall back to `fallbackGroundM` (normally the ground at the
|
||||
* mount), which is what makes the pre-precompute path deterministic instead
|
||||
* of buried.
|
||||
* @param {number} capAltM - Unlifted plane center altitude.
|
||||
* @param {{upVert:number}} dims
|
||||
* @param {Record<string, number>|null|undefined} groundUnder - support key → ground altitude.
|
||||
* @param {number} fallbackGroundM
|
||||
* @param {number} clearanceM
|
||||
* @returns {{liftM:number, limitingKey:string|null}}
|
||||
*/
|
||||
export function requiredPlaneLift(
|
||||
capAltM,
|
||||
dims,
|
||||
groundUnder,
|
||||
fallbackGroundM,
|
||||
clearanceM,
|
||||
) {
|
||||
let liftM = 0;
|
||||
let limitingKey = null;
|
||||
const rows = { b: -1, m: 0, t: 1 };
|
||||
for (const key of SUPPORT_KEYS) {
|
||||
const raw = groundUnder ? groundUnder[key] : undefined;
|
||||
// Strict: a null/absent support must fall back, not read as 0 m.
|
||||
const ground =
|
||||
typeof raw === 'number' && Number.isFinite(raw) ? raw : fallbackGroundM;
|
||||
if (!Number.isFinite(ground)) continue;
|
||||
const alt = supportAltitude(capAltM, rows[key[0]], dims);
|
||||
const deficit = ground + clearanceM - alt;
|
||||
if (deficit > liftM) {
|
||||
liftM = deficit;
|
||||
limitingKey = key;
|
||||
}
|
||||
}
|
||||
return { liftM, limitingKey };
|
||||
}
|
||||
|
||||
/**
|
||||
* Stable hash of the nominal pose a footprint was sampled for, so shipped
|
||||
* samples are only reused while the pose they describe is unchanged.
|
||||
* @param {{lat:number, lon:number, headingDeg:number, pitchDeg:number,
|
||||
* fovDeg:number, rangeM:number, mountHeightM:number}} pose
|
||||
* @returns {string}
|
||||
*/
|
||||
export function poseHash(pose) {
|
||||
const text = [
|
||||
Number(pose.lat).toFixed(6),
|
||||
Number(pose.lon).toFixed(6),
|
||||
Number(pose.headingDeg).toFixed(1),
|
||||
Number(pose.pitchDeg).toFixed(1),
|
||||
Number(pose.fovDeg).toFixed(1),
|
||||
Number(pose.rangeM).toFixed(1),
|
||||
Number(pose.mountHeightM).toFixed(1),
|
||||
].join('|');
|
||||
// FNV-1a 32-bit, base36: short, stable, dependency-free.
|
||||
let h = 0x811c9dc5;
|
||||
for (let i = 0; i < text.length; i += 1) {
|
||||
h ^= text.charCodeAt(i);
|
||||
h = Math.imul(h, 0x01000193) >>> 0;
|
||||
}
|
||||
return `p1-${h.toString(36)}`;
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
SUPPORT_KEYS,
|
||||
planeDimensions,
|
||||
planeSupportPoints,
|
||||
poseHash,
|
||||
projectPoint,
|
||||
requiredPlaneLift,
|
||||
} from './cctvFootprint.js';
|
||||
|
||||
const pose = {
|
||||
lat: 30.2672,
|
||||
lon: -97.7431,
|
||||
headingDeg: 90,
|
||||
pitchDeg: -24,
|
||||
fovDeg: 56,
|
||||
rangeM: 210,
|
||||
mountHeightM: 12,
|
||||
};
|
||||
|
||||
test('support points form a 3×3 grid across the pitched far cap', () => {
|
||||
const { mount, capCenter, supports } = planeSupportPoints(pose);
|
||||
assert.deepEqual(mount, { lat: pose.lat, lon: pose.lon });
|
||||
assert.deepEqual(Object.keys(supports).sort(), [...SUPPORT_KEYS].sort());
|
||||
const dims = planeDimensions(pose);
|
||||
// Cap center sits R·cos(pitch) east of the mount (heading 90).
|
||||
const east = projectPoint(pose.lat, pose.lon, 90, dims.horiz);
|
||||
assert.ok(
|
||||
Math.abs(capCenter.lat - east.lat) < 1e-9 &&
|
||||
Math.abs(capCenter.lon - east.lon) < 1e-9,
|
||||
);
|
||||
// Middle-center is the cap center; left/right columns straddle it across the heading.
|
||||
assert.ok(Math.abs(supports.mc.lat - capCenter.lat) < 1e-9);
|
||||
assert.ok(
|
||||
supports.ml.lat > capCenter.lat && supports.mr.lat < capCenter.lat,
|
||||
'left is north of centre when looking east',
|
||||
);
|
||||
// A downward pitch tilts the top row forward (further along the heading).
|
||||
assert.ok(
|
||||
supports.tm.lon > supports.mc.lon && supports.bm.lon < supports.mc.lon,
|
||||
);
|
||||
});
|
||||
|
||||
test('the rigid lift is the largest clearance deficit over the supports', () => {
|
||||
const dims = planeDimensions(pose);
|
||||
const ground = 149;
|
||||
const capAlt = ground + pose.mountHeightM + dims.vert; // far below ground for -24°
|
||||
const flat = requiredPlaneLift(capAlt, dims, null, ground, 2);
|
||||
assert.ok(
|
||||
Math.abs(flat.liftM - (ground + 2 - (capAlt - dims.upVert))) < 1e-9,
|
||||
);
|
||||
assert.equal(flat.limitingKey[0], 'b');
|
||||
const bump = requiredPlaneLift(capAlt, dims, { tr: ground + 200 }, ground, 2);
|
||||
assert.equal(bump.limitingKey, 'tr');
|
||||
assert.ok(bump.liftM > flat.liftM);
|
||||
// Already-clear planes need no lift at all.
|
||||
assert.deepEqual(requiredPlaneLift(ground + 500, dims, null, ground, 2), {
|
||||
liftM: 0,
|
||||
limitingKey: null,
|
||||
});
|
||||
// Unusable measurements fall back to the mount ground rather than zero.
|
||||
assert.equal(
|
||||
requiredPlaneLift(capAlt, dims, { bl: null, br: 'x' }, ground, 2).liftM,
|
||||
flat.liftM,
|
||||
);
|
||||
});
|
||||
|
||||
test('poseHash is stable for the same pose and changes with any pose field', () => {
|
||||
const h = poseHash(pose);
|
||||
assert.equal(poseHash({ ...pose }), h);
|
||||
assert.match(h, /^p1-[0-9a-z]+$/);
|
||||
for (const [key, delta] of [
|
||||
['lat', 0.00001],
|
||||
['headingDeg', 0.1],
|
||||
['pitchDeg', 0.1],
|
||||
['fovDeg', 0.1],
|
||||
['rangeM', 1],
|
||||
['mountHeightM', 0.1],
|
||||
]) {
|
||||
assert.notEqual(poseHash({ ...pose, [key]: pose[key] + delta }), h, key);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
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 {
|
||||
joinGroundHeights,
|
||||
loadGroundHeights,
|
||||
} from '../../server/providers/cctv/groundHeights.js';
|
||||
import { poseHash } from './cctvFootprint.js';
|
||||
|
||||
const source = (over = {}) => ({
|
||||
id: 'cam-1',
|
||||
lat: 30.2672,
|
||||
lon: -97.7431,
|
||||
headingDeg: 90,
|
||||
pitchDeg: -24,
|
||||
fovDeg: 56,
|
||||
rangeM: 210,
|
||||
mountHeightM: 12,
|
||||
...over,
|
||||
});
|
||||
|
||||
test('a shipped entry attaches only while the nominal pose still matches', () => {
|
||||
const s = source();
|
||||
const entry = {
|
||||
status: 'ok',
|
||||
poseHash: poseHash(s),
|
||||
mountGroundM: 151.2,
|
||||
supports: { bl: 150.1, bm: 149.9, br: 155.5, tl: null, tm: 'x' },
|
||||
};
|
||||
const [joined] = joinGroundHeights([s], { 'cam-1': entry });
|
||||
assert.deepEqual(joined.groundHeights, {
|
||||
poseHash: entry.poseHash,
|
||||
mountGroundM: 151.2,
|
||||
supports: { bl: 150.1, bm: 149.9, br: 155.5 },
|
||||
});
|
||||
|
||||
const moved = source({ lat: 30.2673 });
|
||||
const [notJoined] = joinGroundHeights([moved], { 'cam-1': entry });
|
||||
assert.equal(
|
||||
notJoined.groundHeights,
|
||||
undefined,
|
||||
'a moved camera loses the shipped value',
|
||||
);
|
||||
|
||||
const [missed] = joinGroundHeights([source()], {
|
||||
'cam-1': { ...entry, status: 'miss', mountGroundM: null },
|
||||
});
|
||||
assert.equal(missed.groundHeights, undefined, 'a miss ships nothing');
|
||||
assert.deepEqual(
|
||||
joinGroundHeights([source()], null)[0].groundHeights,
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
test('the sidecar loads from the source root and tolerates a missing or malformed file', () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gev-heights-'));
|
||||
fs.mkdirSync(
|
||||
path.join(dir, 'src', 'data', 'local_data', 'cctv_ground_heights'),
|
||||
{
|
||||
recursive: true,
|
||||
},
|
||||
);
|
||||
assert.deepEqual(loadGroundHeights(dir), {}, 'missing file');
|
||||
const file = path.join(
|
||||
dir,
|
||||
'src',
|
||||
'data',
|
||||
'local_data',
|
||||
'cctv_ground_heights',
|
||||
'cctv_ground_heights.json',
|
||||
);
|
||||
fs.writeFileSync(file, '{not json');
|
||||
assert.deepEqual(loadGroundHeights(dir), {}, 'malformed file');
|
||||
fs.writeFileSync(
|
||||
file,
|
||||
JSON.stringify({ schemaVersion: 1, cameras: { a: { status: 'ok' } } }),
|
||||
);
|
||||
assert.deepEqual(loadGroundHeights(dir), { a: { status: 'ok' } });
|
||||
});
|
||||
+117
-42
@@ -31,6 +31,13 @@ const PROVIDER_STATIC_REFRESH_MS = Object.freeze({
|
||||
'austin transportation & public works': 5 * 60 * 1000,
|
||||
'transport for london': 3 * 60 * 1000,
|
||||
caltrans: 3 * 60 * 1000,
|
||||
'ontario 511': 3 * 60 * 1000,
|
||||
// TxDOT publishes roughly once a minute; 3 min matches the other highway packs.
|
||||
txdot: 3 * 60 * 1000,
|
||||
// Digitraffic publishes a new weathercam frame on each station's
|
||||
// collectionInterval, 600 s for every station sampled; polling faster only
|
||||
// re-fetches the same JPEG.
|
||||
fintraffic: 10 * 60 * 1000,
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -42,7 +49,9 @@ const PROVIDER_STATIC_REFRESH_MS = Object.freeze({
|
||||
* @returns {{cardLimit:number}}
|
||||
*/
|
||||
export function cctvLodBudgets(cameraHeightM) {
|
||||
const height = Number.isFinite(cameraHeightM) ? Math.max(0, cameraHeightM) : CITY_HEIGHT_M;
|
||||
const height = Number.isFinite(cameraHeightM)
|
||||
? Math.max(0, cameraHeightM)
|
||||
: CITY_HEIGHT_M;
|
||||
if (height <= STREET_HEIGHT_M) {
|
||||
return { cardLimit: CCTV_AMBIENT_CARD_MIN };
|
||||
}
|
||||
@@ -68,7 +77,11 @@ export const CCTV_CARD_INCUMBENT_FACTOR = 0.8;
|
||||
* @param {number} [factor]
|
||||
* @returns {number}
|
||||
*/
|
||||
export function incumbentRankKm(distanceKm, isIncumbent, factor = CCTV_CARD_INCUMBENT_FACTOR) {
|
||||
export function incumbentRankKm(
|
||||
distanceKm,
|
||||
isIncumbent,
|
||||
factor = CCTV_CARD_INCUMBENT_FACTOR,
|
||||
) {
|
||||
const km = Number.isFinite(distanceKm) ? Math.max(0, distanceKm) : Infinity;
|
||||
return isIncumbent ? km * factor : km;
|
||||
}
|
||||
@@ -81,8 +94,9 @@ export const CCTV_CARD_SPREAD_PERCENTILE = 0.9;
|
||||
|
||||
/** True only when both viewport dimensions are finite positive pixels. */
|
||||
export function hasFiniteCctvViewport(viewW, viewH) {
|
||||
return Number.isFinite(viewW) && viewW > 0
|
||||
&& Number.isFinite(viewH) && viewH > 0;
|
||||
return (
|
||||
Number.isFinite(viewW) && viewW > 0 && Number.isFinite(viewH) && viewH > 0
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -117,7 +131,10 @@ export function cctvCandidateSpreadKm(
|
||||
.filter((distanceKm) => Number.isFinite(distanceKm) && distanceKm >= 0)
|
||||
.sort((a, b) => a - b);
|
||||
if (!finite.length) return 0;
|
||||
const p = Math.min(1, Math.max(0, Number.isFinite(percentile) ? percentile : 0));
|
||||
const p = Math.min(
|
||||
1,
|
||||
Math.max(0, Number.isFinite(percentile) ? percentile : 0),
|
||||
);
|
||||
return finite[Math.round(p * (finite.length - 1))];
|
||||
}
|
||||
|
||||
@@ -176,32 +193,47 @@ export const CCTV_CARD_GRID_ROWS = 4;
|
||||
* @returns {string[]} Winner ids in priority order (cell winners, then
|
||||
* global-rank fill).
|
||||
*/
|
||||
export function distributeCctvCards(candidates, {
|
||||
export function distributeCctvCards(
|
||||
candidates,
|
||||
{
|
||||
budget = CCTV_AMBIENT_CARD_MAX,
|
||||
viewW = 0,
|
||||
viewH = 0,
|
||||
cols = CCTV_CARD_GRID_COLS,
|
||||
rows = CCTV_CARD_GRID_ROWS,
|
||||
} = {}) {
|
||||
} = {},
|
||||
) {
|
||||
const cap = Number.isFinite(budget) ? Math.max(0, Math.floor(budget)) : 0;
|
||||
const valid = (Array.isArray(candidates) ? candidates : [])
|
||||
.filter((c) => c && typeof c.id === 'string' && c.id
|
||||
&& Number.isFinite(c.sx) && Number.isFinite(c.sy));
|
||||
const valid = (Array.isArray(candidates) ? candidates : []).filter(
|
||||
(c) =>
|
||||
c &&
|
||||
typeof c.id === 'string' &&
|
||||
c.id &&
|
||||
Number.isFinite(c.sx) &&
|
||||
Number.isFinite(c.sy),
|
||||
);
|
||||
if (!cap || !valid.length) return [];
|
||||
const validViewport = hasFiniteCctvViewport(viewW, viewH);
|
||||
const width = validViewport ? viewW : 1;
|
||||
const height = validViewport ? viewH : 1;
|
||||
const nCols = Math.max(1, Math.floor(cols));
|
||||
const nRows = Math.max(1, Math.floor(rows));
|
||||
const byRank = (a, b) => (Number.isFinite(a.rankKm) ? a.rankKm : Infinity)
|
||||
- (Number.isFinite(b.rankKm) ? b.rankKm : Infinity)
|
||||
|| a.id.localeCompare(b.id);
|
||||
const byRank = (a, b) =>
|
||||
(Number.isFinite(a.rankKm) ? a.rankKm : Infinity) -
|
||||
(Number.isFinite(b.rankKm) ? b.rankKm : Infinity) ||
|
||||
a.id.localeCompare(b.id);
|
||||
|
||||
/** @type {Map<number, Array>} occupied cell -> its candidates */
|
||||
const cells = new Map();
|
||||
for (const candidate of valid) {
|
||||
const col = Math.min(nCols - 1, Math.max(0, Math.floor((candidate.sx / width) * nCols)));
|
||||
const row = Math.min(nRows - 1, Math.max(0, Math.floor((candidate.sy / height) * nRows)));
|
||||
const col = Math.min(
|
||||
nCols - 1,
|
||||
Math.max(0, Math.floor((candidate.sx / width) * nCols)),
|
||||
);
|
||||
const row = Math.min(
|
||||
nRows - 1,
|
||||
Math.max(0, Math.floor((candidate.sy / height) * nRows)),
|
||||
);
|
||||
const key = row * nCols + col;
|
||||
const bucket = cells.get(key);
|
||||
if (bucket) bucket.push(candidate);
|
||||
@@ -249,29 +281,42 @@ export function distributeCctvCards(candidates, {
|
||||
* @param {number} [options.viewH] - Viewport height (CSS px).
|
||||
* @returns {{cardIds:string[],budgets:{cardLimit:number}}}
|
||||
*/
|
||||
export function selectCctvLod(candidates, { cameraHeightM, incumbentIds, viewW, viewH } = {}) {
|
||||
export function selectCctvLod(
|
||||
candidates,
|
||||
{ cameraHeightM, incumbentIds, viewW, viewH } = {},
|
||||
) {
|
||||
const budgets = cctvLodBudgets(cameraHeightM);
|
||||
const incumbents = incumbentIds instanceof Set ? incumbentIds : new Set(incumbentIds || []);
|
||||
const incumbents =
|
||||
incumbentIds instanceof Set ? incumbentIds : new Set(incumbentIds || []);
|
||||
const screened = hasFiniteCctvViewport(viewW, viewH);
|
||||
if (!screened) {
|
||||
// Preserve the pre-refinement nearest-first path byte-for-behavior,
|
||||
// including its acceptance of any non-empty string ID. The stricter
|
||||
// valid-viewport rules below must not leak into this compatibility branch.
|
||||
const inView = (Array.isArray(candidates) ? candidates : [])
|
||||
.filter((candidate) => candidate && typeof candidate.id === 'string' && candidate.id)
|
||||
.filter(
|
||||
(candidate) =>
|
||||
candidate && typeof candidate.id === 'string' && candidate.id,
|
||||
)
|
||||
.map((candidate) => ({
|
||||
id: candidate.id,
|
||||
distanceKm: Number.isFinite(candidate.distanceKm) ? Math.max(0, candidate.distanceKm) : Infinity,
|
||||
rankKm: incumbentRankKm(candidate.distanceKm, incumbents.has(candidate.id)),
|
||||
distanceKm: Number.isFinite(candidate.distanceKm)
|
||||
? Math.max(0, candidate.distanceKm)
|
||||
: Infinity,
|
||||
rankKm: incumbentRankKm(
|
||||
candidate.distanceKm,
|
||||
incumbents.has(candidate.id),
|
||||
),
|
||||
inView: candidate.inView === true,
|
||||
isVideo: candidate.isVideo === true,
|
||||
}))
|
||||
.filter((candidate) => candidate.inView)
|
||||
.sort((a, b) => (
|
||||
a.rankKm - b.rankKm
|
||||
|| a.distanceKm - b.distanceKm
|
||||
|| a.id.localeCompare(b.id)
|
||||
));
|
||||
.sort(
|
||||
(a, b) =>
|
||||
a.rankKm - b.rankKm ||
|
||||
a.distanceKm - b.distanceKm ||
|
||||
a.id.localeCompare(b.id),
|
||||
);
|
||||
const cardIds = [];
|
||||
const seen = new Set();
|
||||
for (const candidate of inView) {
|
||||
@@ -284,9 +329,15 @@ export function selectCctvLod(candidates, { cameraHeightM, incumbentIds, viewW,
|
||||
}
|
||||
const eligibleById = new Map();
|
||||
for (const candidate of Array.isArray(candidates) ? candidates : []) {
|
||||
if (!candidate || typeof candidate.id !== 'string' || !candidate.id.trim()
|
||||
|| candidate.inView !== true || candidate.isVideo === true
|
||||
|| !Number.isFinite(candidate.distanceKm)) continue;
|
||||
if (
|
||||
!candidate ||
|
||||
typeof candidate.id !== 'string' ||
|
||||
!candidate.id.trim() ||
|
||||
candidate.inView !== true ||
|
||||
candidate.isVideo === true ||
|
||||
!Number.isFinite(candidate.distanceKm)
|
||||
)
|
||||
continue;
|
||||
const normalized = {
|
||||
id: candidate.id,
|
||||
distanceKm: Math.max(0, candidate.distanceKm),
|
||||
@@ -295,7 +346,14 @@ export function selectCctvLod(candidates, { cameraHeightM, incumbentIds, viewW,
|
||||
sy: Number.isFinite(candidate.sy) ? candidate.sy : NaN,
|
||||
};
|
||||
const current = eligibleById.get(normalized.id);
|
||||
if (!current || compareCctvRepresentative(normalized, current, { screened, viewW, viewH }) < 0) {
|
||||
if (
|
||||
!current ||
|
||||
compareCctvRepresentative(normalized, current, {
|
||||
screened,
|
||||
viewW,
|
||||
viewH,
|
||||
}) < 0
|
||||
) {
|
||||
eligibleById.set(normalized.id, normalized);
|
||||
}
|
||||
}
|
||||
@@ -304,7 +362,9 @@ export function selectCctvLod(candidates, { cameraHeightM, incumbentIds, viewW,
|
||||
// robust distance scale or consume a bounded ambient-card slot.
|
||||
const stills = [...eligibleById.values()];
|
||||
|
||||
const spreadKm = cctvCandidateSpreadKm(stills.map((candidate) => candidate.distanceKm));
|
||||
const spreadKm = cctvCandidateSpreadKm(
|
||||
stills.map((candidate) => candidate.distanceKm),
|
||||
);
|
||||
for (const candidate of stills) {
|
||||
const blendedKm = blendCenterRankKm(
|
||||
candidate.distanceKm,
|
||||
@@ -313,17 +373,22 @@ export function selectCctvLod(candidates, { cameraHeightM, incumbentIds, viewW,
|
||||
);
|
||||
candidate.rankKm = incumbentRankKm(blendedKm, incumbents.has(candidate.id));
|
||||
}
|
||||
stills.sort((a, b) => (
|
||||
a.rankKm - b.rankKm
|
||||
|| a.distanceKm - b.distanceKm
|
||||
|| a.id.localeCompare(b.id)
|
||||
));
|
||||
stills.sort(
|
||||
(a, b) =>
|
||||
a.rankKm - b.rankKm ||
|
||||
a.distanceKm - b.distanceKm ||
|
||||
a.id.localeCompare(b.id),
|
||||
);
|
||||
|
||||
let cardIds;
|
||||
// Item C: screen-distributed fill. Candidates lacking screen anchors are
|
||||
// dropped by the distribution pass; top up from the ranked pool
|
||||
// (defensive — cctv.js always projects anchors for in-view candidates).
|
||||
cardIds = distributeCctvCards(stills, { budget: budgets.cardLimit, viewW, viewH });
|
||||
cardIds = distributeCctvCards(stills, {
|
||||
budget: budgets.cardLimit,
|
||||
viewW,
|
||||
viewH,
|
||||
});
|
||||
if (cardIds.length < budgets.cardLimit) {
|
||||
const chosen = new Set(cardIds);
|
||||
for (const candidate of stills) {
|
||||
@@ -336,10 +401,12 @@ export function selectCctvLod(candidates, { cameraHeightM, incumbentIds, viewW,
|
||||
}
|
||||
|
||||
function compareCctvRepresentative(a, b, { screened, viewW, viewH }) {
|
||||
if (a.distanceKm !== b.distanceKm) return a.distanceKm < b.distanceKm ? -1 : 1;
|
||||
if (a.distanceKm !== b.distanceKm)
|
||||
return a.distanceKm < b.distanceKm ? -1 : 1;
|
||||
if (screened) {
|
||||
const centerDelta = screenCenterFraction(a.sx, a.sy, viewW, viewH)
|
||||
- screenCenterFraction(b.sx, b.sy, viewW, viewH);
|
||||
const centerDelta =
|
||||
screenCenterFraction(a.sx, a.sy, viewW, viewH) -
|
||||
screenCenterFraction(b.sx, b.sy, viewW, viewH);
|
||||
if (centerDelta) return centerDelta;
|
||||
}
|
||||
const ax = Number.isFinite(a.sx) ? a.sx : Infinity;
|
||||
@@ -414,7 +481,10 @@ export function applyEvictionGrace({
|
||||
// Under cap pressure the grace-period cards go first, oldest-in-grace
|
||||
// first (they have had the longest chance to return); ties break on more
|
||||
// misses, then id for determinism.
|
||||
graced.sort((a, b) => a.since - b.since || b.misses - a.misses || a.id.localeCompare(b.id));
|
||||
graced.sort(
|
||||
(a, b) =>
|
||||
a.since - b.since || b.misses - a.misses || a.id.localeCompare(b.id),
|
||||
);
|
||||
const capacity = Math.max(0, cardLimit - keepIds.length);
|
||||
const overflow = Math.max(0, graced.length - capacity);
|
||||
for (let i = 0; i < graced.length; i++) {
|
||||
@@ -423,7 +493,10 @@ export function applyEvictionGrace({
|
||||
continue;
|
||||
}
|
||||
keepIds.push(graced[i].id);
|
||||
nextGrace.set(graced[i].id, { misses: graced[i].misses, since: graced[i].since });
|
||||
nextGrace.set(graced[i].id, {
|
||||
misses: graced[i].misses,
|
||||
since: graced[i].since,
|
||||
});
|
||||
}
|
||||
|
||||
return { keepIds, evictIds, graceState: nextGrace };
|
||||
@@ -442,6 +515,8 @@ export function staticFrameRefreshMs(camera) {
|
||||
if (Number.isFinite(explicit) && explicit > 0) {
|
||||
return Math.max(60_000, Math.min(20 * 60 * 1000, Math.round(explicit)));
|
||||
}
|
||||
const provider = String(camera?.provider || '').trim().toLowerCase();
|
||||
const provider = String(camera?.provider || '')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
return PROVIDER_STATIC_REFRESH_MS[provider] || DEFAULT_STATIC_REFRESH_MS;
|
||||
}
|
||||
+196
-55
@@ -41,7 +41,9 @@ test('cctvLodBudgets scales the card budget from 20 to 40 with view height', ()
|
||||
assert.equal(CCTV_AMBIENT_CARD_MAX, 40);
|
||||
assert.deepEqual(cctvLodBudgets(500), { cardLimit: CCTV_AMBIENT_CARD_MIN });
|
||||
assert.deepEqual(cctvLodBudgets(8_000), { cardLimit: CCTV_AMBIENT_CARD_MID });
|
||||
assert.deepEqual(cctvLodBudgets(30_000), { cardLimit: CCTV_AMBIENT_CARD_MAX });
|
||||
assert.deepEqual(cctvLodBudgets(30_000), {
|
||||
cardLimit: CCTV_AMBIENT_CARD_MAX,
|
||||
});
|
||||
});
|
||||
|
||||
test('selectCctvLod caps ambient cards at the 40 hard cap regardless of catalog size', () => {
|
||||
@@ -54,19 +56,30 @@ test('selectCctvLod uses only in-view cameras and orders by viewer distance', ()
|
||||
const input = candidates(20, { hiddenAt: 0 }).reverse();
|
||||
const selected = selectCctvLod(input, { cameraHeightM: 500 });
|
||||
assert.equal(selected.cardIds.includes('cam-000'), false);
|
||||
assert.deepEqual(selected.cardIds.slice(0, 3), ['cam-001', 'cam-002', 'cam-003']);
|
||||
assert.deepEqual(selected.cardIds.slice(0, 3), [
|
||||
'cam-001',
|
||||
'cam-002',
|
||||
'cam-003',
|
||||
]);
|
||||
});
|
||||
|
||||
test('video cameras never consume ambient card slots', () => {
|
||||
const selected = selectCctvLod(candidates(30, { videoAt: 0 }), { cameraHeightM: 500 });
|
||||
const selected = selectCctvLod(candidates(30, { videoAt: 0 }), {
|
||||
cameraHeightM: 500,
|
||||
});
|
||||
assert.equal(selected.cardIds.includes('cam-000'), false);
|
||||
assert.equal(selected.cardIds.length, CCTV_AMBIENT_CARD_MIN);
|
||||
});
|
||||
|
||||
test('selectCctvLod tolerates malformed candidate rows', () => {
|
||||
const selected = selectCctvLod(
|
||||
[null, {}, { id: '', inView: true }, { id: 'cam-ok', inView: true, distanceKm: NaN }],
|
||||
{ cameraHeightM: 500 }
|
||||
[
|
||||
null,
|
||||
{},
|
||||
{ id: '', inView: true },
|
||||
{ id: 'cam-ok', inView: true, distanceKm: NaN },
|
||||
],
|
||||
{ cameraHeightM: 500 },
|
||||
);
|
||||
assert.deepEqual(selected.cardIds, ['cam-ok']);
|
||||
});
|
||||
@@ -84,7 +97,9 @@ test('selectCctvLod: a carded camera outranks a slightly-nearer non-carded one',
|
||||
// incumbent at 5.0 km holds its card against a newcomer at 4.5 km (only
|
||||
// 10% closer).
|
||||
const anchors = Array.from({ length: CCTV_AMBIENT_CARD_MIN - 1 }, (_, i) => ({
|
||||
id: `anchor-${String(i).padStart(2, '0')}`, distanceKm: i / 100, inView: true,
|
||||
id: `anchor-${String(i).padStart(2, '0')}`,
|
||||
distanceKm: i / 100,
|
||||
inView: true,
|
||||
}));
|
||||
const contested = [
|
||||
{ id: 'incumbent', distanceKm: 5.0, inView: true },
|
||||
@@ -101,7 +116,9 @@ test('selectCctvLod: a carded camera outranks a slightly-nearer non-carded one',
|
||||
|
||||
test('selectCctvLod: a meaningfully (>20%) closer camera still displaces an incumbent', () => {
|
||||
const anchors = Array.from({ length: CCTV_AMBIENT_CARD_MIN - 1 }, (_, i) => ({
|
||||
id: `anchor-${String(i).padStart(2, '0')}`, distanceKm: i / 100, inView: true,
|
||||
id: `anchor-${String(i).padStart(2, '0')}`,
|
||||
distanceKm: i / 100,
|
||||
inView: true,
|
||||
}));
|
||||
const contested = [
|
||||
{ id: 'incumbent', distanceKm: 5.0, inView: true },
|
||||
@@ -111,13 +128,19 @@ test('selectCctvLod: a meaningfully (>20%) closer camera still displaces an incu
|
||||
cameraHeightM: 500,
|
||||
incumbentIds: new Set(['incumbent']),
|
||||
});
|
||||
assert.ok(selected.cardIds.includes('newcomer'), 'meaningfully closer camera wins');
|
||||
assert.ok(
|
||||
selected.cardIds.includes('newcomer'),
|
||||
'meaningfully closer camera wins',
|
||||
);
|
||||
assert.equal(selected.cardIds.includes('incumbent'), false);
|
||||
});
|
||||
|
||||
test('selectCctvLod: no incumbents means plain nearest-first (unchanged behavior)', () => {
|
||||
const plain = selectCctvLod(candidates(20), { cameraHeightM: 500 });
|
||||
const withEmpty = selectCctvLod(candidates(20), { cameraHeightM: 500, incumbentIds: new Set() });
|
||||
const withEmpty = selectCctvLod(candidates(20), {
|
||||
cameraHeightM: 500,
|
||||
incumbentIds: new Set(),
|
||||
});
|
||||
assert.deepEqual(plain.cardIds, withEmpty.cardIds);
|
||||
});
|
||||
|
||||
@@ -129,25 +152,39 @@ test('distributeCctvCards spreads clustered candidates across cells', () => {
|
||||
// sits alone bottom-right. Nearest-first would take the whole cluster —
|
||||
// distribution gives every occupied cell its best first.
|
||||
const clustered = Array.from({ length: 4 }, (_, i) => ({
|
||||
id: `center-${i}`, sx: 50 + i * 10, sy: 50, rankKm: i,
|
||||
id: `center-${i}`,
|
||||
sx: 50 + i * 10,
|
||||
sy: 50,
|
||||
rankKm: i,
|
||||
}));
|
||||
const lonely = { id: 'periphery', sx: 950, sy: 750, rankKm: 30 };
|
||||
assert.deepEqual(
|
||||
distributeCctvCards([...clustered, lonely], { budget: 2, viewW: 1000, viewH: 800 }),
|
||||
['center-0', 'periphery']
|
||||
distributeCctvCards([...clustered, lonely], {
|
||||
budget: 2,
|
||||
viewW: 1000,
|
||||
viewH: 800,
|
||||
}),
|
||||
['center-0', 'periphery'],
|
||||
);
|
||||
});
|
||||
|
||||
test('distributeCctvCards: leftover budget falls back to global rank order', () => {
|
||||
const clustered = Array.from({ length: 4 }, (_, i) => ({
|
||||
id: `center-${i}`, sx: 50 + i * 10, sy: 50, rankKm: i,
|
||||
id: `center-${i}`,
|
||||
sx: 50 + i * 10,
|
||||
sy: 50,
|
||||
rankKm: i,
|
||||
}));
|
||||
const lonely = { id: 'periphery', sx: 950, sy: 750, rankKm: 30 };
|
||||
// 2 occupied cells, budget 4: both cell winners, then the cluster's next
|
||||
// two by rank.
|
||||
assert.deepEqual(
|
||||
distributeCctvCards([...clustered, lonely], { budget: 4, viewW: 1000, viewH: 800 }),
|
||||
['center-0', 'periphery', 'center-1', 'center-2']
|
||||
distributeCctvCards([...clustered, lonely], {
|
||||
budget: 4,
|
||||
viewW: 1000,
|
||||
viewH: 800,
|
||||
}),
|
||||
['center-0', 'periphery', 'center-1', 'center-2'],
|
||||
);
|
||||
});
|
||||
|
||||
@@ -155,64 +192,101 @@ test('distributeCctvCards: empty cells are skipped, budget respected', () => {
|
||||
// Everything in ONE cell — the 19 empty cells claim nothing; the single
|
||||
// occupied cell fills the whole budget in rank order.
|
||||
const oneCell = Array.from({ length: 5 }, (_, i) => ({
|
||||
id: `c-${i}`, sx: 20 + i * 5, sy: 20, rankKm: i,
|
||||
id: `c-${i}`,
|
||||
sx: 20 + i * 5,
|
||||
sy: 20,
|
||||
rankKm: i,
|
||||
}));
|
||||
const ids = distributeCctvCards(oneCell, { budget: 3, viewW: 1000, viewH: 800 });
|
||||
const ids = distributeCctvCards(oneCell, {
|
||||
budget: 3,
|
||||
viewW: 1000,
|
||||
viewH: 800,
|
||||
});
|
||||
assert.deepEqual(ids, ['c-0', 'c-1', 'c-2']);
|
||||
});
|
||||
|
||||
test('distributeCctvCards: an under-budget cut keeps the nearest cell winners', () => {
|
||||
const ids = distributeCctvCards([
|
||||
const ids = distributeCctvCards(
|
||||
[
|
||||
{ id: 'far-cell', sx: 900, sy: 700, rankKm: 9 },
|
||||
{ id: 'near-cell', sx: 100, sy: 100, rankKm: 1 },
|
||||
], { budget: 1, viewW: 1000, viewH: 800 });
|
||||
],
|
||||
{ budget: 1, viewW: 1000, viewH: 800 },
|
||||
);
|
||||
assert.deepEqual(ids, ['near-cell']);
|
||||
});
|
||||
|
||||
test('distributeCctvCards: incumbency (effective distance) is honored inside a cell', () => {
|
||||
// Same cell: the incumbent at 5.0 km (discounted to 4.0) beats the
|
||||
// non-carded newcomer at 4.5 km for the cell slot.
|
||||
const ids = distributeCctvCards([
|
||||
const ids = distributeCctvCards(
|
||||
[
|
||||
{ id: 'newcomer', sx: 100, sy: 100, rankKm: incumbentRankKm(4.5, false) },
|
||||
{ id: 'incumbent', sx: 120, sy: 100, rankKm: incumbentRankKm(5.0, true) },
|
||||
], { budget: 1, viewW: 1000, viewH: 800 });
|
||||
],
|
||||
{ budget: 1, viewW: 1000, viewH: 800 },
|
||||
);
|
||||
assert.deepEqual(ids, ['incumbent']);
|
||||
});
|
||||
|
||||
test('distributeCctvCards: malformed rows dropped; offscreen anchors clamp to edge cells', () => {
|
||||
const ids = distributeCctvCards([
|
||||
const ids = distributeCctvCards(
|
||||
[
|
||||
null,
|
||||
{ id: '', sx: 10, sy: 10, rankKm: 0 },
|
||||
{ id: 'nan', sx: NaN, sy: 10, rankKm: 0 },
|
||||
{ id: 'ok', sx: 10, sy: 10, rankKm: 1 },
|
||||
// Margin candidates just outside the viewport land in the edge cells.
|
||||
{ id: 'edge', sx: -20, sy: 850, rankKm: 2 },
|
||||
], { budget: 5, viewW: 1000, viewH: 800 });
|
||||
],
|
||||
{ budget: 5, viewW: 1000, viewH: 800 },
|
||||
);
|
||||
assert.deepEqual(ids.sort(), ['edge', 'ok']);
|
||||
assert.deepEqual(distributeCctvCards([], { budget: 5, viewW: 1000, viewH: 800 }), []);
|
||||
assert.deepEqual(
|
||||
distributeCctvCards([], { budget: 5, viewW: 1000, viewH: 800 }),
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
test('selectCctvLod with viewport dims routes the fill through screen distribution', () => {
|
||||
// A cluster larger than the street budget sits mid-screen; a periphery
|
||||
// camera ranks past the budget. Nearest-first drops it — distribution
|
||||
// keeps it because its cell is otherwise empty.
|
||||
const clustered = Array.from({ length: CCTV_AMBIENT_CARD_MIN + 2 }, (_, i) => ({
|
||||
const clustered = Array.from(
|
||||
{ length: CCTV_AMBIENT_CARD_MIN + 2 },
|
||||
(_, i) => ({
|
||||
id: `center-${String(i).padStart(2, '0')}`,
|
||||
distanceKm: i / 10,
|
||||
inView: true,
|
||||
sx: 400 + (i % 5) * 8,
|
||||
sy: 300 + Math.floor(i / 5) * 8,
|
||||
}));
|
||||
const periphery = { id: 'periphery', distanceKm: 9, inView: true, sx: 950, sy: 750 };
|
||||
}),
|
||||
);
|
||||
const periphery = {
|
||||
id: 'periphery',
|
||||
distanceKm: 9,
|
||||
inView: true,
|
||||
sx: 950,
|
||||
sy: 750,
|
||||
};
|
||||
const selected = selectCctvLod([...clustered, periphery], {
|
||||
cameraHeightM: 500, viewW: 1000, viewH: 800,
|
||||
cameraHeightM: 500,
|
||||
viewW: 1000,
|
||||
viewH: 800,
|
||||
});
|
||||
assert.equal(selected.cardIds.length, CCTV_AMBIENT_CARD_MIN, 'budget respected');
|
||||
assert.ok(selected.cardIds.includes('periphery'), 'periphery cell holds a card');
|
||||
assert.equal(
|
||||
selected.cardIds.length,
|
||||
CCTV_AMBIENT_CARD_MIN,
|
||||
'budget respected',
|
||||
);
|
||||
assert.ok(
|
||||
selected.cardIds.includes('periphery'),
|
||||
'periphery cell holds a card',
|
||||
);
|
||||
// Without screen info the original nearest-first cap applies unchanged.
|
||||
const plain = selectCctvLod(
|
||||
[...clustered, periphery].map(({ sx, sy, ...rest }) => rest),
|
||||
{ cameraHeightM: 500 }
|
||||
{ cameraHeightM: 500 },
|
||||
);
|
||||
assert.equal(plain.cardIds.includes('periphery'), false);
|
||||
});
|
||||
@@ -253,11 +327,13 @@ test('non-finite positive viewport dimensions retain exact legacy nearest-first
|
||||
const legacy = selectCctvLod(input, { cameraHeightM: 500 });
|
||||
|
||||
assert.deepEqual(
|
||||
selectCctvLod(input, { cameraHeightM: 500, viewW: Infinity, viewH: 800 }).cardIds,
|
||||
selectCctvLod(input, { cameraHeightM: 500, viewW: Infinity, viewH: 800 })
|
||||
.cardIds,
|
||||
legacy.cardIds,
|
||||
);
|
||||
assert.deepEqual(
|
||||
selectCctvLod(input, { cameraHeightM: 500, viewW: 1000, viewH: Infinity }).cardIds,
|
||||
selectCctvLod(input, { cameraHeightM: 500, viewW: 1000, viewH: Infinity })
|
||||
.cardIds,
|
||||
legacy.cardIds,
|
||||
);
|
||||
|
||||
@@ -266,7 +342,8 @@ test('non-finite positive viewport dimensions retain exact legacy nearest-first
|
||||
{ id: 'normal', distanceKm: 2, inView: true },
|
||||
];
|
||||
assert.deepEqual(
|
||||
selectCctvLod(legacyEdgeIds, { cameraHeightM: 500, viewW: 0, viewH: 800 }).cardIds,
|
||||
selectCctvLod(legacyEdgeIds, { cameraHeightM: 500, viewW: 0, viewH: 800 })
|
||||
.cardIds,
|
||||
[' ', 'normal'],
|
||||
'invalid viewport preserves every non-empty string ID accepted by the legacy path',
|
||||
);
|
||||
@@ -294,18 +371,34 @@ test('center weighting deterministically favors center without starving peripher
|
||||
const { edge, center } = edgeVsCenterPool();
|
||||
const view = { cameraHeightM: 500, viewW: 1000, viewH: 800 };
|
||||
const selected = selectCctvLod([...edge, ...center], view);
|
||||
const reordered = selectCctvLod([...center].reverse().concat([...edge].reverse()), view);
|
||||
const reordered = selectCctvLod(
|
||||
[...center].reverse().concat([...edge].reverse()),
|
||||
view,
|
||||
);
|
||||
|
||||
assert.equal(selected.cardIds.length, CCTV_AMBIENT_CARD_MIN);
|
||||
assert.equal(selected.cardIds.filter((id) => id.startsWith('center-')).length, 10);
|
||||
assert.equal(selected.cardIds.filter((id) => id.startsWith('edge-')).length, 10);
|
||||
assert.deepEqual(reordered.cardIds, selected.cardIds, 'input order cannot affect winners or ordering');
|
||||
assert.equal(
|
||||
selected.cardIds.filter((id) => id.startsWith('center-')).length,
|
||||
10,
|
||||
);
|
||||
assert.equal(
|
||||
selected.cardIds.filter((id) => id.startsWith('edge-')).length,
|
||||
10,
|
||||
);
|
||||
assert.deepEqual(
|
||||
reordered.cardIds,
|
||||
selected.cardIds,
|
||||
'input order cannot affect winners or ordering',
|
||||
);
|
||||
|
||||
const legacy = selectCctvLod(
|
||||
[...edge, ...center].map(({ sx, sy, ...candidate }) => candidate),
|
||||
{ cameraHeightM: 500 },
|
||||
);
|
||||
assert.equal(legacy.cardIds.filter((id) => id.startsWith('center-')).length, 0);
|
||||
assert.equal(
|
||||
legacy.cardIds.filter((id) => id.startsWith('center-')).length,
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
test('ineligible video, hidden, and malformed-distance rows cannot alter eligible winners', () => {
|
||||
@@ -322,13 +415,26 @@ test('ineligible video, hidden, and malformed-distance rows cannot alter eligibl
|
||||
sx: 500,
|
||||
sy: 400,
|
||||
})),
|
||||
{ id: 'hidden-outlier', distanceKm: 9_000, inView: false, sx: 900, sy: 700 },
|
||||
{
|
||||
id: 'hidden-outlier',
|
||||
distanceKm: 9_000,
|
||||
inView: false,
|
||||
sx: 900,
|
||||
sy: 700,
|
||||
},
|
||||
{ id: 'bad-distance', distanceKm: NaN, inView: true, sx: 900, sy: 700 },
|
||||
{ id: '', distanceKm: 0, inView: true, sx: 500, sy: 400 },
|
||||
];
|
||||
|
||||
assert.deepEqual(selectCctvLod([...eligible, ...noise], view).cardIds, baseline);
|
||||
assert.deepEqual(selectCctvLod([...noise].reverse().concat([...eligible].reverse()), view).cardIds, baseline);
|
||||
assert.deepEqual(
|
||||
selectCctvLod([...eligible, ...noise], view).cardIds,
|
||||
baseline,
|
||||
);
|
||||
assert.deepEqual(
|
||||
selectCctvLod([...noise].reverse().concat([...eligible].reverse()), view)
|
||||
.cardIds,
|
||||
baseline,
|
||||
);
|
||||
});
|
||||
|
||||
test('malformed-distance rows remain slot-neutral when valid candidates leave spare capacity', () => {
|
||||
@@ -339,7 +445,9 @@ test('malformed-distance rows remain slot-neutral when valid candidates leave sp
|
||||
];
|
||||
|
||||
assert.deepEqual(selectCctvLod(candidates, view).cardIds, ['valid']);
|
||||
assert.deepEqual(selectCctvLod([...candidates].reverse(), view).cardIds, ['valid']);
|
||||
assert.deepEqual(selectCctvLod([...candidates].reverse(), view).cardIds, [
|
||||
'valid',
|
||||
]);
|
||||
});
|
||||
|
||||
test('duplicate IDs are slot-neutral and choose a deterministic representative', () => {
|
||||
@@ -353,7 +461,10 @@ test('duplicate IDs are slot-neutral and choose a deterministic representative',
|
||||
{ ...center[0], distanceKm: NaN, sx: 500, sy: 400 },
|
||||
];
|
||||
|
||||
assert.deepEqual(selectCctvLod([...eligible, ...duplicates], view).cardIds, baseline);
|
||||
assert.deepEqual(
|
||||
selectCctvLod([...eligible, ...duplicates], view).cardIds,
|
||||
baseline,
|
||||
);
|
||||
assert.deepEqual(
|
||||
selectCctvLod([...duplicates, ...eligible].reverse(), view).cardIds,
|
||||
baseline,
|
||||
@@ -362,13 +473,16 @@ test('duplicate IDs are slot-neutral and choose a deterministic representative',
|
||||
});
|
||||
|
||||
test('center weighting has deterministic ID ties and preserves invalid-anchor eligibility', () => {
|
||||
const anchors = Array.from({ length: CCTV_AMBIENT_CARD_MIN - 1 }, (_, index) => ({
|
||||
const anchors = Array.from(
|
||||
{ length: CCTV_AMBIENT_CARD_MIN - 1 },
|
||||
(_, index) => ({
|
||||
id: `anchor-${String(index).padStart(2, '0')}`,
|
||||
distanceKm: index / 100,
|
||||
inView: true,
|
||||
sx: 500,
|
||||
sy: 400,
|
||||
}));
|
||||
}),
|
||||
);
|
||||
const tied = [
|
||||
{ id: 'tie-b', distanceKm: 10, inView: true, sx: 500, sy: 400 },
|
||||
{ id: 'tie-a', distanceKm: 10, inView: true, sx: 500, sy: 400 },
|
||||
@@ -380,13 +494,26 @@ test('center weighting has deterministic ID ties and preserves invalid-anchor el
|
||||
viewH: 800,
|
||||
});
|
||||
assert.ok(selected.cardIds.includes('tie-a'));
|
||||
assert.equal(selected.cardIds.includes('tie-b'), false, 'ID breaks an exact contested tie');
|
||||
assert.equal(selected.cardIds.includes('invalid-anchor'), false, 'invalid anchor does not displace valid winners');
|
||||
assert.equal(
|
||||
selected.cardIds.includes('tie-b'),
|
||||
false,
|
||||
'ID breaks an exact contested tie',
|
||||
);
|
||||
assert.equal(
|
||||
selected.cardIds.includes('invalid-anchor'),
|
||||
false,
|
||||
'invalid anchor does not displace valid winners',
|
||||
);
|
||||
|
||||
const onlyInvalid = selectCctvLod([
|
||||
{ id: 'invalid-only', distanceKm: 1, inView: true, sx: NaN, sy: NaN },
|
||||
], { cameraHeightM: 500, viewW: 1000, viewH: 800 });
|
||||
assert.deepEqual(onlyInvalid.cardIds, ['invalid-only'], 'defensive top-up preserves eligibility');
|
||||
const onlyInvalid = selectCctvLod(
|
||||
[{ id: 'invalid-only', distanceKm: 1, inView: true, sx: NaN, sy: NaN }],
|
||||
{ cameraHeightM: 500, viewW: 1000, viewH: 800 },
|
||||
);
|
||||
assert.deepEqual(
|
||||
onlyInvalid.cardIds,
|
||||
['invalid-only'],
|
||||
'defensive top-up preserves eligibility',
|
||||
);
|
||||
});
|
||||
|
||||
test('applyEvictionGrace keeps a fallen-out card and clears grace when it returns', () => {
|
||||
@@ -480,7 +607,11 @@ test('applyEvictionGrace never exceeds the hard cap; graced cards go first, olde
|
||||
// cam-c is newly selected and enters immediately despite grace pressure.
|
||||
assert.ok(result.keepIds.includes('cam-c'));
|
||||
assert.ok(result.keepIds.includes('cam-new'), 'newest grace entry survives');
|
||||
assert.deepEqual(result.evictIds, ['cam-old'], 'oldest grace entry evicted for the cap');
|
||||
assert.deepEqual(
|
||||
result.evictIds,
|
||||
['cam-old'],
|
||||
'oldest grace entry evicted for the cap',
|
||||
);
|
||||
});
|
||||
|
||||
test('applyEvictionGrace with a full selection keeps no graced cards', () => {
|
||||
@@ -497,9 +628,19 @@ test('applyEvictionGrace with a full selection keeps no graced cards', () => {
|
||||
});
|
||||
|
||||
test('staticFrameRefreshMs follows known pack cadences and bounds explicit values', () => {
|
||||
assert.equal(staticFrameRefreshMs({ provider: 'Austin Transportation & Public Works' }), 300_000);
|
||||
assert.equal(staticFrameRefreshMs({ provider: 'Transport for London' }), 180_000);
|
||||
assert.equal(
|
||||
staticFrameRefreshMs({ provider: 'Austin Transportation & Public Works' }),
|
||||
300_000,
|
||||
);
|
||||
assert.equal(
|
||||
staticFrameRefreshMs({ provider: 'Transport for London' }),
|
||||
180_000,
|
||||
);
|
||||
assert.equal(staticFrameRefreshMs({ provider: 'Caltrans' }), 180_000);
|
||||
assert.equal(staticFrameRefreshMs({ provider: 'Ontario 511' }), 180_000);
|
||||
assert.equal(staticFrameRefreshMs({ provider: 'TxDOT' }), 180_000);
|
||||
// Digitraffic publishes one weathercam frame per 600 s collection interval.
|
||||
assert.equal(staticFrameRefreshMs({ provider: 'Fintraffic' }), 600_000);
|
||||
assert.equal(staticFrameRefreshMs({ frameRefreshMs: 5_000 }), 60_000);
|
||||
assert.equal(staticFrameRefreshMs({ frameRefreshMs: 3_000_000 }), 1_200_000);
|
||||
assert.equal(staticFrameRefreshMs({ provider: 'Unknown Provider' }), 300_000);
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
loadNswSourcesFromOpenData,
|
||||
nswCameraLabel,
|
||||
nswCameraToSource,
|
||||
} from '../../server/providers/cctv/sources.js';
|
||||
import {
|
||||
cctvUpstreamUserAgent,
|
||||
fetchCctvImageFromUpstream,
|
||||
} from '../../server/providers/cctv/media.js';
|
||||
import {
|
||||
NSW_CAMERAS_URL,
|
||||
NSW_IMAGE_USER_AGENT,
|
||||
} from '../../server/providers/cctv/constants.js';
|
||||
|
||||
const feature = (overrides = {}, props = {}) => ({
|
||||
type: 'Feature',
|
||||
id: '5_ways_miranda',
|
||||
geometry: { type: 'Point', coordinates: [151.1036, -34.0337] },
|
||||
properties: {
|
||||
region: 'SYD_SOUTH',
|
||||
title: '5 Ways (Miranda)',
|
||||
view: '5 Ways at The Boulevarde looking west towards Sutherland.',
|
||||
direction: 'W',
|
||||
href: 'https://webcams.transport.nsw.gov.au/livetraffic-webcams/cameras/5_ways_miranda.jpeg',
|
||||
...props,
|
||||
},
|
||||
...overrides,
|
||||
});
|
||||
|
||||
test('NSW label prefers the view sentence and falls back to the title for works notices', () => {
|
||||
assert.equal(
|
||||
nswCameraLabel({ title: '5 Ways (Miranda)', view: '5 Ways looking west.' }),
|
||||
'5 Ways looking west.',
|
||||
);
|
||||
assert.equal(
|
||||
nswCameraLabel({ title: 'Victoria Pass', view: 'x'.repeat(200) }),
|
||||
'Victoria Pass',
|
||||
);
|
||||
assert.equal(nswCameraLabel({ title: 'T', view: 'line one\nline two' }), 'T');
|
||||
assert.equal(nswCameraLabel({}), '');
|
||||
});
|
||||
|
||||
test('NSW feature maps to a source with a compass heading and a pinned image host', () => {
|
||||
const source = nswCameraToSource(feature({}, { direction: 'N-E' }));
|
||||
assert.equal(source.id, 'nsw-5_ways_miranda');
|
||||
assert.equal(source.city, 'SYD SOUTH');
|
||||
assert.equal(source.provider, 'Live Traffic NSW');
|
||||
assert.equal(source.lat, -34.0337);
|
||||
assert.equal(source.lon, 151.1036);
|
||||
assert.equal(source.headingDeg, 45);
|
||||
assert.equal(source.headingConfidence, 'high');
|
||||
assert.equal(source.sourceKind, 'nsw-livetraffic');
|
||||
assert.match(source.license, /CC BY 4.0/);
|
||||
assert.equal(source.url, source.snapshotUrl);
|
||||
});
|
||||
|
||||
test('NSW mapping rejects off-host images and unusable geometry', () => {
|
||||
assert.equal(
|
||||
nswCameraToSource(feature({}, { href: 'https://evil.example/x.jpeg' })),
|
||||
null,
|
||||
);
|
||||
assert.equal(nswCameraToSource(feature({ geometry: null })), null);
|
||||
assert.equal(nswCameraToSource(feature({ id: '' })), null);
|
||||
const noBearing = nswCameraToSource(feature({}, { direction: '' }));
|
||||
assert.equal(noBearing.headingConfidence, 'low');
|
||||
assert.ok(Number.isFinite(noBearing.headingDeg));
|
||||
});
|
||||
|
||||
test('NSW loader reads the keyless feed and caps around Sydney', async (t) => {
|
||||
t.mock.method(console, 'log', () => {});
|
||||
const requested = [];
|
||||
t.mock.method(globalThis, 'fetch', async (url) => {
|
||||
requested.push(String(url));
|
||||
return Response.json({
|
||||
type: 'FeatureCollection',
|
||||
features: [
|
||||
feature(),
|
||||
feature({ id: 'other' }, { href: 'https://evil.example/x.jpeg' }),
|
||||
],
|
||||
});
|
||||
});
|
||||
const cameras = await loadNswSourcesFromOpenData();
|
||||
assert.deepEqual(requested, [NSW_CAMERAS_URL]);
|
||||
assert.deepEqual(
|
||||
cameras.map((camera) => camera.id),
|
||||
['nsw-5_ways_miranda'],
|
||||
);
|
||||
});
|
||||
|
||||
test('the browser User-Agent applies to the NSW image host only', async () => {
|
||||
assert.equal(
|
||||
cctvUpstreamUserAgent(
|
||||
'https://webcams.transport.nsw.gov.au/livetraffic-webcams/cameras/x.jpeg',
|
||||
),
|
||||
NSW_IMAGE_USER_AGENT,
|
||||
);
|
||||
assert.equal(
|
||||
cctvUpstreamUserAgent(
|
||||
'https://webcams.transport.nsw.gov.au.evil.test/x.jpeg',
|
||||
),
|
||||
'gods-eye-view-cctv-proxy/1.0',
|
||||
);
|
||||
assert.equal(
|
||||
cctvUpstreamUserAgent(
|
||||
'https://example.test/webcams.transport.nsw.gov.au/x.jpeg',
|
||||
),
|
||||
'gods-eye-view-cctv-proxy/1.0',
|
||||
);
|
||||
assert.equal(
|
||||
cctvUpstreamUserAgent('not a url'),
|
||||
'gods-eye-view-cctv-proxy/1.0',
|
||||
);
|
||||
|
||||
const seen = [];
|
||||
await fetchCctvImageFromUpstream(
|
||||
'https://cctv.austinmobility.io/image/1.jpg',
|
||||
{
|
||||
timeoutMs: 100,
|
||||
fetchImpl: async (url, init) => {
|
||||
seen.push(init.headers['User-Agent']);
|
||||
return new Response(Buffer.from([0xff, 0xd8, 0xff]), {
|
||||
headers: { 'Content-Type': 'image/jpeg' },
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
assert.deepEqual(seen, ['gods-eye-view-cctv-proxy/1.0']);
|
||||
});
|
||||
|
||||
test('the frame path follows redirects within the host only', async () => {
|
||||
const jpeg = () =>
|
||||
new Response(Buffer.from([0xff, 0xd8, 0xff]), {
|
||||
headers: { 'Content-Type': 'image/jpeg' },
|
||||
});
|
||||
const redirect = (location) =>
|
||||
new Response(null, { status: 302, headers: { location } });
|
||||
const start = 'https://cctv.austinmobility.io/image/1.jpg';
|
||||
|
||||
const sameHost = [];
|
||||
const followed = await fetchCctvImageFromUpstream(start, {
|
||||
timeoutMs: 100,
|
||||
fetchImpl: async (url, init) => {
|
||||
assert.equal(init.redirect, 'manual', 'the fetch itself never follows');
|
||||
sameHost.push(url);
|
||||
return sameHost.length === 1 ? redirect('/image/moved/1.jpg') : jpeg();
|
||||
},
|
||||
});
|
||||
assert.equal(followed?.ok, true);
|
||||
assert.deepEqual(sameHost, [
|
||||
start,
|
||||
'https://cctv.austinmobility.io/image/moved/1.jpg',
|
||||
]);
|
||||
|
||||
const offHost = [];
|
||||
const refused = await fetchCctvImageFromUpstream(start, {
|
||||
timeoutMs: 100,
|
||||
fetchImpl: async (url) => {
|
||||
offHost.push(url);
|
||||
return redirect('https://evil.example/1.jpg');
|
||||
},
|
||||
});
|
||||
assert.equal(refused, null);
|
||||
assert.deepEqual(offHost, [start], 'the off-host target is never requested');
|
||||
|
||||
// Same host on another port, a plaintext downgrade, and a protocol-relative
|
||||
// other host are all a different origin and are refused.
|
||||
for (const target of [
|
||||
'https://cctv.austinmobility.io:8443/image/1.jpg',
|
||||
'http://cctv.austinmobility.io/image/1.jpg',
|
||||
'//evil.example/1.jpg',
|
||||
]) {
|
||||
const seen = [];
|
||||
const result = await fetchCctvImageFromUpstream(start, {
|
||||
timeoutMs: 100,
|
||||
fetchImpl: async (url) => {
|
||||
seen.push(url);
|
||||
return redirect(target);
|
||||
},
|
||||
});
|
||||
assert.equal(result, null, target);
|
||||
assert.deepEqual(seen, [start], target);
|
||||
}
|
||||
|
||||
let hops = 0;
|
||||
const looped = await fetchCctvImageFromUpstream(start, {
|
||||
timeoutMs: 100,
|
||||
fetchImpl: async () => {
|
||||
hops += 1;
|
||||
return redirect('/image/again.jpg');
|
||||
},
|
||||
});
|
||||
assert.equal(looped, null);
|
||||
assert.equal(hops, 3, 'at most two redirect hops are followed');
|
||||
});
|
||||
@@ -0,0 +1,328 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
loadTxdotSourcesFromOpenData,
|
||||
normalizeTxdotDistrictPayload,
|
||||
} from '../../server/providers/cctv/sources.js';
|
||||
import { fetchTxdotSnapshot } from '../../server/providers/cctv/media.js';
|
||||
import { TXDOT_CCTV_STATUS_URL } from '../../server/providers/cctv/constants.js';
|
||||
|
||||
/** Minimal valid JPEG head: SOI marker plus a byte of payload. */
|
||||
const JPEG_BYTES = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0x00]);
|
||||
const JPEG_B64 = JPEG_BYTES.toString('base64');
|
||||
|
||||
/** One "Device Online" record in the shape GetCctvStatusListByDistrict returns. */
|
||||
function cameraRow(overrides = {}) {
|
||||
return {
|
||||
icd_Id: 'FM-734 @ US-290 EB',
|
||||
name: 'FM-734 @ US-290 EB',
|
||||
latitude: 30.34396,
|
||||
longitude: -97.57988,
|
||||
statusDescription: 'Device Online',
|
||||
hasSnapshot: true,
|
||||
dirDescription: 'North',
|
||||
equipLoc: { roadway: 'FM-734', direction: 'North' },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const districtPayload = (rows) => ({
|
||||
roadwayCctvStatuses: { 'FM-734': rows },
|
||||
});
|
||||
|
||||
function quiet(t) {
|
||||
t.mock.method(console, 'log', () => {});
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
}
|
||||
|
||||
test('TxDOT catalog keeps only online cameras with finite coordinates', () => {
|
||||
const cameras = normalizeTxdotDistrictPayload(
|
||||
districtPayload([
|
||||
cameraRow({ icd_Id: 'a', name: 'a' }),
|
||||
cameraRow({
|
||||
icd_Id: 'b',
|
||||
name: 'b',
|
||||
statusDescription: 'Device Offline',
|
||||
}),
|
||||
cameraRow({ icd_Id: 'c', name: 'c', statusDescription: 'Device Error' }),
|
||||
cameraRow({ icd_Id: 'd', name: 'd', latitude: null }),
|
||||
cameraRow({ icd_Id: 'e', name: 'e', longitude: 'not-a-number' }),
|
||||
cameraRow({ icd_Id: 'f', name: 'f', latitude: 0, longitude: 0 }),
|
||||
cameraRow({
|
||||
icd_Id: 'g',
|
||||
name: 'g',
|
||||
latitude: '30.1',
|
||||
longitude: '-97.7',
|
||||
}),
|
||||
cameraRow({ icd_Id: 'h', name: 'h', hasSnapshot: false }),
|
||||
]),
|
||||
'AUS',
|
||||
);
|
||||
|
||||
assert.equal(cameras.length, 1);
|
||||
assert.equal(cameras[0].name, 'a');
|
||||
assert.equal(cameras[0].provider, 'TxDOT');
|
||||
assert.equal(cameras[0].feedType, 'image');
|
||||
assert.equal(cameras[0].sourceKind, 'txdot-its');
|
||||
assert.equal(cameras[0].license, 'Public TxDOT traffic camera data');
|
||||
assert.equal(
|
||||
cameras[0].url,
|
||||
'https://its.txdot.gov/its/DistrictIts/GetCctvSnapshotByIcdId?icdId=a&districtCode=AUS',
|
||||
);
|
||||
assert.equal(
|
||||
cameras[0].id,
|
||||
`txdot-aus-${Buffer.from('a').toString('base64url')}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('TxDOT ids stay distinct for distinct device keys, whatever they hash or slug to', () => {
|
||||
const ids = normalizeTxdotDistrictPayload(
|
||||
districtPayload([
|
||||
// These two share a 32-bit FNV hash.
|
||||
cameraRow({ icd_Id: 'costarring', name: 'costarring' }),
|
||||
cameraRow({ icd_Id: 'liquid', name: 'liquid' }),
|
||||
// These two share a lowercase slug and a hash.
|
||||
cameraRow({ icd_Id: 'abcDEfaBCDEFAbcdEFABCdEfAbcdeFaB', name: 'x' }),
|
||||
cameraRow({ icd_Id: 'ABcDeFABCDefaBCDeFAbCdeFabcdefAB', name: 'y' }),
|
||||
]),
|
||||
'AUS',
|
||||
).map((camera) => camera.id);
|
||||
assert.equal(new Set(ids).size, 4);
|
||||
assert.equal(
|
||||
Buffer.from(ids[0].replace(/^txdot-aus-/, ''), 'base64url').toString(
|
||||
'utf8',
|
||||
),
|
||||
'costarring',
|
||||
'the device key is recoverable from the id',
|
||||
);
|
||||
});
|
||||
|
||||
test('TxDOT catalog dedupes a camera listed under two roadways', () => {
|
||||
const cameras = normalizeTxdotDistrictPayload(
|
||||
{
|
||||
roadwayCctvStatuses: {
|
||||
'IH-35': [cameraRow({ icd_Id: 'shared', name: 'IH-35 @ SH-71' })],
|
||||
'SH-71': [cameraRow({ icd_Id: 'shared', name: 'IH-35 @ SH-71' })],
|
||||
},
|
||||
},
|
||||
'AUS',
|
||||
);
|
||||
assert.equal(cameras.length, 1);
|
||||
});
|
||||
|
||||
test('TxDOT heading comes from an explicit travel token, never the roadway direction', () => {
|
||||
const [eastbound, westbound, plain] = normalizeTxdotDistrictPayload(
|
||||
districtPayload([
|
||||
cameraRow({ icd_Id: 'eb', name: 'FM-734 @ US-290 EB' }),
|
||||
cameraRow({ icd_Id: 'wb', name: 'FM-734 @ US-290 WB' }),
|
||||
cameraRow({ icd_Id: 'plain', name: 'FM-734 @ Bellingham Dr' }),
|
||||
]),
|
||||
'AUS',
|
||||
);
|
||||
assert.equal(eastbound.headingDeg, 90);
|
||||
assert.equal(eastbound.headingConfidence, 'high');
|
||||
assert.equal(westbound.headingDeg, 270);
|
||||
assert.equal(westbound.headingConfidence, 'high');
|
||||
assert.equal(plain.headingConfidence, 'low');
|
||||
assert.ok(Number.isFinite(plain.headingDeg));
|
||||
});
|
||||
|
||||
test('TxDOT heading refuses bare cardinals in Texas route names', () => {
|
||||
const cameras = normalizeTxdotDistrictPayload(
|
||||
districtPayload([
|
||||
cameraRow({ icd_Id: 'lamar', name: 'N Lamar Blvd @ Rundberg Ln' }),
|
||||
cameraRow({ icd_Id: 'west', name: 'West Ave @ 6th St' }),
|
||||
]),
|
||||
'AUS',
|
||||
);
|
||||
for (const camera of cameras) {
|
||||
assert.equal(
|
||||
camera.headingConfidence,
|
||||
'low',
|
||||
`${camera.name} must not claim a heading`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('TxDOT camera ids are district-scoped and stable across runs', () => {
|
||||
const build = (district) =>
|
||||
normalizeTxdotDistrictPayload(districtPayload([cameraRow()]), district)[0]
|
||||
.id;
|
||||
assert.equal(build('AUS'), build('AUS'));
|
||||
assert.ok(build('AUS').startsWith('txdot-aus-'));
|
||||
assert.ok(build('HOU').startsWith('txdot-hou-'));
|
||||
assert.notEqual(build('AUS'), build('HOU'));
|
||||
});
|
||||
|
||||
test('TxDOT ground elevation uses a per-district prior', () => {
|
||||
const at = (district) =>
|
||||
normalizeTxdotDistrictPayload(districtPayload([cameraRow()]), district)[0]
|
||||
.groundElevationM;
|
||||
assert.equal(at('HOU'), 15);
|
||||
assert.equal(at('ELP'), 1140);
|
||||
assert.equal(at('AUS'), 149);
|
||||
assert.equal(at('SAT'), 198);
|
||||
});
|
||||
|
||||
test('TxDOT catalog tolerates a malformed payload', () => {
|
||||
assert.deepEqual(normalizeTxdotDistrictPayload(null, 'AUS'), []);
|
||||
assert.deepEqual(normalizeTxdotDistrictPayload({}, 'AUS'), []);
|
||||
assert.deepEqual(
|
||||
normalizeTxdotDistrictPayload({ roadwayCctvStatuses: [] }, 'AUS'),
|
||||
[],
|
||||
);
|
||||
assert.deepEqual(
|
||||
normalizeTxdotDistrictPayload(
|
||||
{ roadwayCctvStatuses: { 'IH-35': null } },
|
||||
'AUS',
|
||||
),
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
test('TxDOT loader fetches each configured district and ignores unknown codes', async (t) => {
|
||||
quiet(t);
|
||||
const saved = process.env.CCTV_TXDOT_DISTRICTS;
|
||||
process.env.CCTV_TXDOT_DISTRICTS = 'aus, SAT, NOPE, aus';
|
||||
const requested = [];
|
||||
t.mock.method(globalThis, 'fetch', async (url) => {
|
||||
requested.push(String(url));
|
||||
const district = new URL(String(url)).searchParams.get('districtCode');
|
||||
return Response.json(
|
||||
districtPayload([
|
||||
cameraRow({ icd_Id: `${district}-1`, name: `${district}-1` }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
try {
|
||||
const cameras = await loadTxdotSourcesFromOpenData();
|
||||
assert.deepEqual(requested, [
|
||||
TXDOT_CCTV_STATUS_URL('AUS'),
|
||||
TXDOT_CCTV_STATUS_URL('SAT'),
|
||||
]);
|
||||
assert.deepEqual(cameras.map((camera) => camera.cityId).sort(), [
|
||||
'tx-aus',
|
||||
'tx-sat',
|
||||
]);
|
||||
} finally {
|
||||
if (saved === undefined) delete process.env.CCTV_TXDOT_DISTRICTS;
|
||||
else process.env.CCTV_TXDOT_DISTRICTS = saved;
|
||||
}
|
||||
});
|
||||
|
||||
test('TxDOT snapshot decodes JSON only from the official origin and only as JPEG', async () => {
|
||||
const jsonResponse = (body) =>
|
||||
new Response(JSON.stringify(body), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json; charset=utf-8' },
|
||||
});
|
||||
const official =
|
||||
'https://its.txdot.gov/its/DistrictIts/GetCctvSnapshotByIcdId?icdId=x&districtCode=AUS';
|
||||
|
||||
const frame = await fetchTxdotSnapshot(official, {
|
||||
timeoutMs: 100,
|
||||
fetchImpl: async () => jsonResponse({ snippet: JPEG_B64 }),
|
||||
});
|
||||
assert.equal(frame?.ok, true);
|
||||
assert.equal(frame?.contentType, 'image/jpeg');
|
||||
assert.deepEqual(frame?.body, JPEG_BYTES);
|
||||
|
||||
// Off-origin with the right path, and right origin with the wrong path,
|
||||
// are each refused on their own.
|
||||
for (const bad of [
|
||||
'https://evil.example/its/DistrictIts/GetCctvSnapshotByIcdId?icdId=x',
|
||||
'https://its.txdot.gov/its/DistrictIts/Other?icdId=x',
|
||||
]) {
|
||||
assert.equal(
|
||||
await fetchTxdotSnapshot(bad, {
|
||||
timeoutMs: 100,
|
||||
fetchImpl: async () => jsonResponse({ snippet: JPEG_B64 }),
|
||||
}),
|
||||
null,
|
||||
bad,
|
||||
);
|
||||
}
|
||||
// A redirect is never followed, so the pin holds for the request made.
|
||||
let calls = 0;
|
||||
assert.equal(
|
||||
await fetchTxdotSnapshot(official, {
|
||||
timeoutMs: 100,
|
||||
fetchImpl: async (_url, init) => {
|
||||
calls += 1;
|
||||
assert.equal(init.redirect, 'manual');
|
||||
return new Response(null, {
|
||||
status: 302,
|
||||
headers: { location: 'https://evil.example/x.json' },
|
||||
});
|
||||
},
|
||||
}),
|
||||
null,
|
||||
);
|
||||
assert.equal(calls, 1);
|
||||
// Junk base64 and oversize bodies are refused before decoding.
|
||||
assert.equal(
|
||||
await fetchTxdotSnapshot(official, {
|
||||
timeoutMs: 100,
|
||||
fetchImpl: async () => jsonResponse({ snippet: '/9!@#j/4AA=' }),
|
||||
}),
|
||||
null,
|
||||
);
|
||||
// Malformed padding is refused even though Buffer.from() would decode it.
|
||||
assert.equal(
|
||||
await fetchTxdotSnapshot(official, {
|
||||
timeoutMs: 100,
|
||||
fetchImpl: async () => jsonResponse({ snippet: '/9j/4AA==' }),
|
||||
}),
|
||||
null,
|
||||
);
|
||||
// A real JPEG larger than the frame cap is refused after decoding...
|
||||
const bigJpeg = Buffer.concat([JPEG_BYTES, Buffer.alloc(59, 0x11)]);
|
||||
assert.equal(bigJpeg.length, 64);
|
||||
assert.equal(
|
||||
await fetchTxdotSnapshot(official, {
|
||||
timeoutMs: 100,
|
||||
maxBytes: 8,
|
||||
fetchImpl: async () =>
|
||||
jsonResponse({ snippet: bigJpeg.toString('base64') }),
|
||||
}),
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
(
|
||||
await fetchTxdotSnapshot(official, {
|
||||
timeoutMs: 100,
|
||||
maxBytes: 64,
|
||||
fetchImpl: async () =>
|
||||
jsonResponse({ snippet: bigJpeg.toString('base64') }),
|
||||
})
|
||||
)?.body.length,
|
||||
64,
|
||||
'the same frame passes when it fits the cap',
|
||||
);
|
||||
// ...and an envelope that overflows the streaming cap never gets parsed.
|
||||
const hugeJpeg = Buffer.concat([JPEG_BYTES, Buffer.alloc(8192, 0x11)]);
|
||||
assert.equal(
|
||||
await fetchTxdotSnapshot(official, {
|
||||
timeoutMs: 100,
|
||||
maxBytes: 8,
|
||||
fetchImpl: async () =>
|
||||
jsonResponse({ snippet: hugeJpeg.toString('base64') }),
|
||||
}),
|
||||
null,
|
||||
);
|
||||
const notJpeg = Buffer.from('<html>nope</html>').toString('base64');
|
||||
for (const body of [
|
||||
null,
|
||||
{ snippet: null },
|
||||
{ snippet: '' },
|
||||
{ snippet: notJpeg },
|
||||
]) {
|
||||
assert.equal(
|
||||
await fetchTxdotSnapshot(official, {
|
||||
timeoutMs: 100,
|
||||
fetchImpl: async () => jsonResponse(body),
|
||||
}),
|
||||
null,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
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 { loadWarendorfSourcesFromCatalog } from '../../server/providers/cctv/sources.js';
|
||||
|
||||
test('Warendorf catalog registers the Marktplatz webcam on official hosts only', (t) => {
|
||||
t.mock.method(console, 'log', () => {});
|
||||
const cameras = loadWarendorfSourcesFromCatalog();
|
||||
assert.deepEqual(
|
||||
cameras.map((camera) => camera.id),
|
||||
['warendorf-marktplatz-rathaus'],
|
||||
);
|
||||
for (const camera of cameras) {
|
||||
assert.ok(
|
||||
/^https?:\/\/(webcam\.warendorf\.de|www\.kreis-warendorf\.de)\//.test(
|
||||
camera.url,
|
||||
),
|
||||
camera.url,
|
||||
);
|
||||
assert.equal(camera.snapshotUrl, camera.url);
|
||||
assert.equal(camera.sourceKind, 'municipal-webcam');
|
||||
assert.equal(camera.poseSource, 'curated');
|
||||
assert.match(camera.license, /^Public municipal webcam data/);
|
||||
}
|
||||
});
|
||||
|
||||
test('Warendorf loader tolerates a missing catalog file', (t) => {
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
assert.deepEqual(
|
||||
loadWarendorfSourcesFromCatalog({ sourceRoot: '/nonexistent' }),
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
test('Warendorf loader skips malformed rows without throwing', (t) => {
|
||||
t.mock.method(console, 'log', () => {});
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gev-warendorf-'));
|
||||
fs.mkdirSync(path.join(dir, 'config'));
|
||||
fs.writeFileSync(
|
||||
path.join(dir, 'config', 'cctv_sources.warendorf.json'),
|
||||
JSON.stringify([
|
||||
{
|
||||
id: { toString: null },
|
||||
url: 'https://www.kreis-warendorf.de/a.jpg',
|
||||
lat: 51.9,
|
||||
lon: 7.9,
|
||||
},
|
||||
{
|
||||
id: 'ok',
|
||||
url: 'https://www.kreis-warendorf.de/a.jpg',
|
||||
lat: 51.9,
|
||||
lon: 7.9,
|
||||
},
|
||||
{
|
||||
id: 'text-coords',
|
||||
url: 'https://www.kreis-warendorf.de/a.jpg',
|
||||
lat: '51.9',
|
||||
lon: '7.9',
|
||||
},
|
||||
{
|
||||
id: 'null-island',
|
||||
url: 'https://www.kreis-warendorf.de/a.jpg',
|
||||
lat: 0,
|
||||
lon: 0,
|
||||
},
|
||||
{
|
||||
id: 'off-host',
|
||||
url: 'https://evil.example/a.jpg',
|
||||
lat: 51.9,
|
||||
lon: 7.9,
|
||||
},
|
||||
]),
|
||||
);
|
||||
const cameras = loadWarendorfSourcesFromCatalog({ sourceRoot: dir });
|
||||
assert.deepEqual(
|
||||
cameras.map((camera) => camera.id),
|
||||
['ok'],
|
||||
);
|
||||
});
|
||||
+55
-1
@@ -7,7 +7,7 @@ import * as Cesium from 'cesium';
|
||||
* docs/pre-ship-audit-2026-07-01.md): every third-party data layer this app can
|
||||
* display carries its own license and required attribution — ODbL (OSM
|
||||
* datacenters/dams, adsb.lol, Overpass roads), CC BY-NC-SA (TeleGeography
|
||||
* cables), NASA FIRMS, CelesTrak, USGS, City of Austin, GBFS operators, OpenSky.
|
||||
* cables), NASA FIRMS, CelesTrak, USGS, City of Austin, Fintraffic (CC BY 4.0),
|
||||
* The MIT code license does NOT cover this data.
|
||||
*
|
||||
* These credits are registered ONCE at init as STATIC credits with
|
||||
@@ -122,6 +122,12 @@ export const DATA_CREDITS = [
|
||||
'CCTV cameras & frames: City of Austin, TX — ' +
|
||||
'<a href="https://data.austintexas.gov" target="_blank" rel="noopener">data.austintexas.gov</a>',
|
||||
},
|
||||
{
|
||||
key: 'txdot-cctv',
|
||||
html:
|
||||
'CCTV cameras & frames (Texas): ' +
|
||||
'<a href="https://its.txdot.gov/" target="_blank" rel="noopener">Texas Department of Transportation</a> (courtesy)',
|
||||
},
|
||||
{
|
||||
key: 'caltrans-cctv',
|
||||
html:
|
||||
@@ -135,6 +141,20 @@ export const DATA_CREDITS = [
|
||||
'<a href="https://tfl.gov.uk/info-for/open-data-users/" target="_blank" rel="noopener">Powered by TfL Open Data</a>. ' +
|
||||
'Contains OS data © Crown copyright and database rights.',
|
||||
},
|
||||
{
|
||||
key: 'ontario-511-cctv',
|
||||
html:
|
||||
'CCTV cameras & frames (Ontario): ' +
|
||||
'<a href="https://511on.ca/" target="_blank" rel="noopener">Ontario 511</a> ' +
|
||||
'(<a href="https://www.ontario.ca/page/open-government-licence-ontario" target="_blank" rel="noopener">Open Government Licence - Ontario</a>)',
|
||||
},
|
||||
{
|
||||
key: 'fintraffic-cctv',
|
||||
html:
|
||||
'CCTV cameras & frames (Finland): Fintraffic / ' +
|
||||
'<a href="https://www.digitraffic.fi/en/" target="_blank" rel="noopener">digitraffic.fi</a>, ' +
|
||||
'license CC BY 4.0',
|
||||
},
|
||||
{
|
||||
key: 'gbfs',
|
||||
html: 'Bikeshare availability: GBFS operator feeds (e.g. Austin BCycle)',
|
||||
@@ -176,6 +196,40 @@ export const DATA_CREDITS = [
|
||||
'(<a href="https://earthdata.nasa.gov/firms" target="_blank" rel="noopener">earthdata.nasa.gov/firms</a>), ' +
|
||||
'part of NASA’s Earth Observing System Data and Information System (EOSDIS)',
|
||||
},
|
||||
{
|
||||
key: 'drivebc-cctv',
|
||||
html:
|
||||
'CCTV cameras & frames (British Columbia): ' +
|
||||
'<a href="https://www.drivebc.ca/" target="_blank" rel="noopener">DriveBC</a>. ' +
|
||||
'Contains information licensed under the ' +
|
||||
'<a href="https://www2.gov.bc.ca/gov/content/data/open-data/open-government-licence-bc" target="_blank" rel="noopener">Open Government Licence – British Columbia</a>. ' +
|
||||
'Some cameras are supplied by partners (TransLink, the City of Vancouver, the City of Surrey, Parks Canada and others); each names its provider in the CCTV panel.',
|
||||
},
|
||||
{
|
||||
key: 'tallinn-cctv',
|
||||
html:
|
||||
'CCTV cameras & frames (Tallinn): City of Tallinn — ' +
|
||||
'<a href="https://ristmikud.tallinn.ee/" target="_blank" rel="noopener">ristmikud.tallinn.ee</a> (courtesy)',
|
||||
},
|
||||
{
|
||||
key: 'tarktee-cctv',
|
||||
html:
|
||||
'CCTV cameras & frames (Estonia road weather): Transpordiamet / Tarktee — ' +
|
||||
'<a href="https://tarktee.transpordiamet.ee/" target="_blank" rel="noopener">tarktee.transpordiamet.ee</a> (courtesy)',
|
||||
},
|
||||
{
|
||||
key: 'warendorf-cctv',
|
||||
html:
|
||||
'Webcam (Warendorf): <a href="https://www.warendorf.de/" target="_blank" rel="noopener">Stadt Warendorf</a> (courtesy); ' +
|
||||
'camera poses derived from OpenStreetMap geometry, © OpenStreetMap contributors (ODbL)',
|
||||
},
|
||||
{
|
||||
key: 'nsw-cctv',
|
||||
html:
|
||||
'CCTV cameras & frames (New South Wales): ' +
|
||||
'<a href="https://www.livetraffic.com/" target="_blank" rel="noopener">Live Traffic NSW</a> — Transport for NSW ' +
|
||||
'(<a href="https://creativecommons.org/licenses/by/4.0/" target="_blank" rel="noopener">CC BY 4.0</a>)',
|
||||
},
|
||||
{
|
||||
key: 'telegeography',
|
||||
html:
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
# CCTV ground heights
|
||||
|
||||
Precomputed ground height under every camera in the served CCTV catalog and
|
||||
under the nine support points of its monitor plane, so the client can place a
|
||||
camera and its plane with no runtime sampling. Shared placement data: any
|
||||
consumer that renders these cameras on the same photorealistic surface can
|
||||
reuse it.
|
||||
|
||||
Source surface: Google Photorealistic 3D Tiles, sampled at maximum detail with
|
||||
CesiumJS `scene.sampleHeightMostDetailed` from a running God's Eye View
|
||||
(scripts/precompute-cctv-heights.mjs). Heights are WGS84 **ellipsoidal**
|
||||
metres; no geoid conversion is needed or applied. This is placement data
|
||||
derived from the rendered surface, not imagery.
|
||||
|
||||
Runtime output: `cctv_ground_heights.json`
|
||||
|
||||
```
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"provider": "google-photorealistic-3d",
|
||||
"heightReference": "WGS84-ellipsoid",
|
||||
"generatedAt": "<ISO>",
|
||||
"cameras": {
|
||||
"<camera id>": {
|
||||
"poseHash": "p1-…", // hash of the served pose the samples describe
|
||||
"status": "ok" | "miss",
|
||||
"mountGroundM": <number>, // ground under the camera position (absent on a miss)
|
||||
"supports": { // ground under the plane's 3×3 support grid; a value is
|
||||
// null where that point had no sample (absent on a miss)
|
||||
"bl","bm","br", // bottom row (left, middle, right)
|
||||
"ml","mc","mr", // middle row
|
||||
"tl","tm","tr" // top row
|
||||
},
|
||||
"misses": [<keys with no sample: "mount" and/or support keys>],
|
||||
"sampledAt": "<ISO>",
|
||||
"attempts": <n>
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Support point positions are defined by `src/data/cctvFootprint.js`
|
||||
(`planeSupportPoints`), which the client, the server join
|
||||
(`server/providers/cctv/groundHeights.js`) and the precompute all share. An
|
||||
entry is used only while the camera's served pose still hashes to
|
||||
`poseHash`; a camera whose feed moved it, or whose pose a user edited, falls
|
||||
back to runtime placement from the Re:Earth DEM.
|
||||
|
||||
Regenerate (resumable; only cameras whose pose changed or whose entry is
|
||||
incomplete are re-sampled):
|
||||
|
||||
```
|
||||
GEV_BASE=http://localhost:4173 node scripts/precompute-cctv-heights.mjs
|
||||
```
|
||||
|
||||
Coverage at generation (2026-09-14): 3,445 of 3,446 cameras `ok` (six of them
|
||||
with one or more null supports), 34,444 sampled heights, 43 minutes on a real
|
||||
GPU. Regenerate whenever a pack changes its poses or a new pack lands.
|
||||
File diff suppressed because it is too large.
Load diff
@@ -2,6 +2,8 @@ import { createCalibrationGizmo } from '../../data/cctvGizmo.js';
|
||||
import {
|
||||
DEFAULT_CAMERA_CALIBRATION,
|
||||
CCTV_CALIBRATION_STORAGE_KEY_V2,
|
||||
CALIBRATION_RANGE_FLOOR_M,
|
||||
LEGACY_CALIBRATION_RANGE_FLOOR_M,
|
||||
} from './policy.js';
|
||||
|
||||
export function createCalibration({
|
||||
@@ -75,6 +77,35 @@ export function createCalibration({
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-expresses a saved `rangeScale` after the catalog's range floor moved.
|
||||
* Entries were authored as a multiplier on the OLD client base range
|
||||
* (`max(220, packRange)`); the base is now `max(120, packRange)`, so a camera
|
||||
* whose pack range sits below 220 m would silently shrink. Scaling by the
|
||||
* ratio of the two bases keeps the user's effective range; the 3× ceiling
|
||||
* still applies (an extreme entry loses a little rather than exploding).
|
||||
* @param {Object} values - Normalized 7-field calibration.
|
||||
* @param {number} packRangeM - The camera's served (pack) range.
|
||||
* @returns {Object} A new values object.
|
||||
*/
|
||||
function migrateRangeScaleForFloor(values, packRangeM) {
|
||||
const oldBase = parts.model.clamp(
|
||||
parts.model.safeNumber(packRangeM, 700),
|
||||
LEGACY_CALIBRATION_RANGE_FLOOR_M,
|
||||
2200,
|
||||
);
|
||||
const newBase = parts.model.clamp(
|
||||
parts.model.safeNumber(packRangeM, 700),
|
||||
CALIBRATION_RANGE_FLOOR_M,
|
||||
2200,
|
||||
);
|
||||
if (oldBase === newBase) return values;
|
||||
return normalizeCalibration({
|
||||
...values,
|
||||
rangeScale: values.rangeScale * (oldBase / newBase),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the given calibration is effectively the default (all offsets near zero).
|
||||
* @param {Object} calibration
|
||||
@@ -120,6 +151,9 @@ export function createCalibration({
|
||||
values: normalizeCalibration(entry.values),
|
||||
source: 'manual',
|
||||
savedAt: parts.model.safeNumber(entry.savedAt, 0),
|
||||
// Range floor the entry's rangeScale was authored against; absent
|
||||
// on entries saved before the floor dropped from 220 m to 120 m.
|
||||
rangeFloorM: parts.model.safeNumber(entry.rangeFloorM, NaN),
|
||||
});
|
||||
}
|
||||
return map;
|
||||
@@ -147,6 +181,12 @@ export function createCalibration({
|
||||
values: normalizeCalibration(entry.values),
|
||||
source: 'manual',
|
||||
savedAt: parts.model.safeNumber(entry.savedAt, Date.now()),
|
||||
// Only entries that were migrated or saved against the current
|
||||
// floor carry the marker; an entry for a camera absent from this
|
||||
// catalog keeps its eligibility to migrate when it returns.
|
||||
...(Number.isFinite(entry.rangeFloorM)
|
||||
? { rangeFloorM: entry.rangeFloorM }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
storage.setItem(CCTV_CALIBRATION_STORAGE_KEY_V2, JSON.stringify(payload));
|
||||
@@ -243,6 +283,8 @@ export function createCalibration({
|
||||
parts.ground.groundAltFor(record),
|
||||
);
|
||||
}
|
||||
// The edited pose has a new footprint; resolve the ground under it once.
|
||||
void parts.ground.resolveFootprintGround(record);
|
||||
parts.frames.refreshProjectionImage(record, true);
|
||||
return true;
|
||||
}
|
||||
@@ -279,6 +321,9 @@ export function createCalibration({
|
||||
endPatch: (draggedRecord) => {
|
||||
const record = liveRecord(draggedRecord);
|
||||
if (!record) return;
|
||||
// The drag changed the plane's footprint; resolve the ground under
|
||||
// the released pose once (revision-guarded, cached proxy).
|
||||
void parts.ground.resolveFootprintGround(record);
|
||||
if (record.calibrationAnchorDirty) {
|
||||
record.calibrationAnchorDirty = false;
|
||||
parts.ground.resolveCommittedGroundAnchor(record);
|
||||
@@ -298,6 +343,7 @@ export function createCalibration({
|
||||
calibrationPatchMovesAnchor,
|
||||
normalizeCalibration,
|
||||
isDefaultCalibration,
|
||||
migrateRangeScaleForFloor,
|
||||
readCalibrationStoreV2,
|
||||
writeCalibrationStoreV2,
|
||||
loadCalibrationStore,
|
||||
|
||||
@@ -136,9 +136,12 @@ export function createCatalog({ state: layerState, services, parts, source }) {
|
||||
20,
|
||||
125,
|
||||
);
|
||||
// Same floor as the pose model (model.js): the packs' 145/210 m ranges
|
||||
// are intentional, and inflating them to 220 m pushed the monitor
|
||||
// plane's far edge into higher ground (owner field test 2026-09-13).
|
||||
const rangeM = parts.model.clamp(
|
||||
parts.model.safeNumber(source.rangeM, seed?.rangeM ?? 700),
|
||||
220,
|
||||
120,
|
||||
2200,
|
||||
);
|
||||
const mountHeightM = parts.model.clamp(
|
||||
@@ -193,6 +196,13 @@ export function createCatalog({ state: layerState, services, parts, source }) {
|
||||
absoluteHeightM: groundElevationM + mountHeightM,
|
||||
pitchDeg,
|
||||
license: String(source.license || source.licenseNote || ''),
|
||||
credit: String(source.credit || ''),
|
||||
code: String(source.code || ''),
|
||||
// Shipped precompute (see server/providers/cctv/groundHeights.js).
|
||||
groundHeights:
|
||||
source.groundHeights && typeof source.groundHeights === 'object'
|
||||
? source.groundHeights
|
||||
: null,
|
||||
poseSource,
|
||||
};
|
||||
parts.model.ensureCameraPose(camera);
|
||||
|
||||
Vendored
+8
-2
@@ -3,6 +3,7 @@ import {
|
||||
MIN_AUTO_HOP_SEC,
|
||||
MAX_AUTO_HOP_SEC,
|
||||
DEFAULT_CAMERA_CALIBRATION,
|
||||
CALIBRATION_RANGE_FLOOR_M,
|
||||
} from './policy.js';
|
||||
|
||||
export function createControls({ state: layerState, services, parts, source }) {
|
||||
@@ -100,8 +101,10 @@ export function createControls({ state: layerState, services, parts, source }) {
|
||||
parts.model.ensureCameraPose(targetRecord.camera);
|
||||
layerState._calibrationById.delete(targetCameraId);
|
||||
parts.calibration.saveCalibrationStore();
|
||||
// Reset returns to the base lat/lon, so resolve that anchor once.
|
||||
// Reset returns to the base lat/lon, so resolve that anchor once,
|
||||
// and the footprint under the restored pose.
|
||||
parts.ground.resolveCommittedGroundAnchor(targetRecord);
|
||||
void parts.ground.resolveFootprintGround(targetRecord);
|
||||
parts.frames.refreshProjectionImage(targetRecord, true);
|
||||
}
|
||||
if (
|
||||
@@ -134,6 +137,8 @@ export function createControls({ state: layerState, services, parts, source }) {
|
||||
values: { ...targetRecord.camera.calibration },
|
||||
source: 'manual',
|
||||
savedAt: Date.now(),
|
||||
// Authored against the current range floor: never migrated.
|
||||
rangeFloorM: CALIBRATION_RANGE_FLOOR_M,
|
||||
});
|
||||
}
|
||||
targetRecord.calDirty = false;
|
||||
@@ -217,7 +222,8 @@ export function createControls({ state: layerState, services, parts, source }) {
|
||||
objects.push({
|
||||
position: layerState._records[i].position,
|
||||
sourceId: camera.id,
|
||||
id: `CAM-${camera.id}`,
|
||||
// Short semantic code where the id is opaque (see cameraDisplayCode).
|
||||
id: `CAM-${camera.code || camera.id}`,
|
||||
type: 'CAM',
|
||||
});
|
||||
if (objects.length >= maxCount) break;
|
||||
|
||||
+161
-40
@@ -8,12 +8,87 @@ import {
|
||||
COVERAGE_NEIGHBOR_RADIUS_KM,
|
||||
COVERAGE_NEIGHBOR_LIMIT,
|
||||
IDLE_COVERAGE_COLOR,
|
||||
PLANE_FOOTPRINT_LIFT_CAP_M,
|
||||
} from './policy.js';
|
||||
|
||||
import {
|
||||
planeDimensions,
|
||||
poseHash,
|
||||
requiredPlaneLift,
|
||||
} from '../../data/cctvFootprint.js';
|
||||
|
||||
export function createGeometry({ state: layerState, services, parts, source }) {
|
||||
const { warmGroundFloor, cachedGroundFloor } = services.ground;
|
||||
const { sampleMeshFloorCells } = services.mesh;
|
||||
|
||||
/**
|
||||
* Ground measured under the plane's support points for the record's
|
||||
* CURRENT pose, or null. Shipped precompute samples (`camera.groundHeights`)
|
||||
* were taken for the nominal pose; they apply only while the pose hash still
|
||||
* matches, so a calibrated (edited) camera falls back to the mount ground
|
||||
* until its own footprint is resolved. Photoreal regime only: the samples
|
||||
* are Google mesh heights and would be wrong against a globe DEM.
|
||||
* @param {Object} record
|
||||
* @returns {Record<string, number>|null}
|
||||
*/
|
||||
function footprintGroundFor(record) {
|
||||
if (hasShippedFootprint(record)) {
|
||||
return record.camera.groundHeights.supports || null;
|
||||
}
|
||||
// DEM footprint resolved on demand (activation / calibration commit) —
|
||||
// valid in either regime, the DEM is what the globe stacks render anyway.
|
||||
const resolved = record?.footprintGround;
|
||||
if (resolved && resolved.poseHash === poseHash(footprintPose(record))) {
|
||||
return resolved.supports || null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The pose the monitor plane is actually rendered with: the calibrated
|
||||
* camera fields plus the activation probe's range clamp. Footprint samples
|
||||
* (shipped or DEM) are only valid for exactly this pose.
|
||||
* @param {Object} record
|
||||
* @returns {{lat:number, lon:number, headingDeg:number, pitchDeg:number,
|
||||
* fovDeg:number, rangeM:number, mountHeightM:number}}
|
||||
*/
|
||||
function footprintPose(record) {
|
||||
const camera = record.camera;
|
||||
const override = parts.model.safeNumber(record.probeClampRangeM, NaN);
|
||||
const rangeM =
|
||||
Number.isFinite(override) && override > 0
|
||||
? Math.min(camera.rangeM, override)
|
||||
: camera.rangeM;
|
||||
return {
|
||||
lat: camera.lat,
|
||||
lon: camera.lon,
|
||||
headingDeg: camera.headingDeg,
|
||||
pitchDeg: camera.pitchDeg,
|
||||
fovDeg: camera.fovDeg,
|
||||
rangeM,
|
||||
mountHeightM: camera.mountHeightM,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the record carries shipped precompute samples for its CURRENT
|
||||
* pose and the scene is rendering the photoreal mesh they were taken from.
|
||||
* @param {Object} record
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function hasShippedFootprint(record) {
|
||||
const shipped = record?.camera?.groundHeights;
|
||||
if (!shipped || typeof shipped !== 'object') return false;
|
||||
if (parts.ground.currentSurfaceRegime() !== 'google-3d') return false;
|
||||
// Samples describe the pose as served. Any calibration, client clamp or
|
||||
// activation range clamp changes the rendered pose and its hash, and the
|
||||
// camera then falls back to its own DEM footprint.
|
||||
return (
|
||||
Number.isFinite(shipped.mountGroundM) &&
|
||||
shipped.poseHash === poseHash(footprintPose(record))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* V2 core geometry (design §2a): computes the pitched frustum pyramid — mount
|
||||
* point, far-cap (monitor plane) center, and the 4 far-plane corners — purely
|
||||
@@ -28,16 +103,20 @@ export function createGeometry({ state: layerState, services, parts, source }) {
|
||||
* vFov = 2·atan(tan(hFov/2) / (16/9)) → halfH = R·tan(vFov/2)
|
||||
* upOffset = cos(pitch)·halfH vertical + (−sin(pitch))·halfH along heading
|
||||
* corners = (capCenter ∓ halfW toward heading∓90°) ± upOffset
|
||||
* The cap CENTER altitude clamps to ≥ groundAltM + 2 m (§6 risk: fabricated
|
||||
* pitch must never bury the plane's anchor); corners derive rigidly from the
|
||||
* clamped center so the wireframe rays always terminate on the plane's
|
||||
* corners — the bottom pair may dip below ground (tiles occlude it).
|
||||
* The whole rectangle is then lifted rigidly by the smallest amount that
|
||||
* puts every support point (a 3×3 grid over the plane) at least 2 m above
|
||||
* the ground measured under it — the ground at the mount where nothing
|
||||
* finer is known — so the plane never clips into the terrain and the
|
||||
* wireframe rays still terminate exactly on its corners.
|
||||
*
|
||||
* @param {Object} camera - Pose: lat, lon, headingDeg, pitchDeg, fovDeg,
|
||||
* rangeM, mountHeightM.
|
||||
* @param {number} groundAltM - Ground altitude at the mount (metres).
|
||||
* @param {number|null} [rangeOverrideM=null] - Obstruction-probe clamp: caps the
|
||||
* effective range (never lengthens it).
|
||||
* @param {Record<string, number>|null} [groundUnderPlane=null] - Ground
|
||||
* altitude measured under each plane support point (`bl`…`tr`, see
|
||||
* footprint.js), from the shipped precompute or a DEM lookup.
|
||||
* @returns {{ rangeM: number, vFovDeg: number, halfW: number, halfH: number,
|
||||
* mount: {lat:number,lon:number,alt:number},
|
||||
* capCenter: {lat:number,lon:number,alt:number},
|
||||
@@ -45,7 +124,12 @@ export function createGeometry({ state: layerState, services, parts, source }) {
|
||||
* topCenter: {lat:number,lon:number,alt:number}, groundAltM: number }}
|
||||
*/
|
||||
|
||||
function computeFrustumGeometry(camera, groundAltM, rangeOverrideM = null) {
|
||||
function computeFrustumGeometry(
|
||||
camera,
|
||||
groundAltM,
|
||||
rangeOverrideM = null,
|
||||
groundUnderPlane = null,
|
||||
) {
|
||||
const ground = parts.model.safeNumber(groundAltM, 0);
|
||||
const poseRange = Math.max(1, parts.model.safeNumber(camera.rangeM, 700));
|
||||
const override = parts.model.safeNumber(rangeOverrideM, NaN);
|
||||
@@ -53,73 +137,92 @@ export function createGeometry({ state: layerState, services, parts, source }) {
|
||||
Number.isFinite(override) && override > 0
|
||||
? Math.min(poseRange, override)
|
||||
: poseRange;
|
||||
const pitch = parts.model.toRad(
|
||||
parts.model.clamp(parts.model.safeNumber(camera.pitchDeg, -17), -89, 89),
|
||||
const pitchDeg = parts.model.clamp(
|
||||
parts.model.safeNumber(camera.pitchDeg, -17),
|
||||
-89,
|
||||
89,
|
||||
);
|
||||
const hFov = parts.model.toRad(
|
||||
parts.model.clamp(parts.model.safeNumber(camera.fovDeg, 74), 8, 160),
|
||||
const fovDeg = parts.model.clamp(
|
||||
parts.model.safeNumber(camera.fovDeg, 74),
|
||||
8,
|
||||
160,
|
||||
);
|
||||
const heading = parts.model.safeNumber(camera.headingDeg, 0);
|
||||
const mountAlt = ground + parts.model.safeNumber(camera.mountHeightM, 24);
|
||||
const dims = planeDimensions({ rangeM: R, pitchDeg, fovDeg });
|
||||
|
||||
const horiz = R * Math.cos(pitch);
|
||||
const vert = R * Math.sin(pitch);
|
||||
const capLL = parts.model.projectPoint(
|
||||
camera.lat,
|
||||
camera.lon,
|
||||
heading,
|
||||
horiz,
|
||||
dims.horiz,
|
||||
);
|
||||
const capAlt = mountAlt + vert;
|
||||
|
||||
const halfW = R * Math.tan(hFov / 2);
|
||||
const vFovRad = 2 * Math.atan(Math.tan(hFov / 2) / PROJECTION_VERT_ASPECT);
|
||||
const halfH = R * Math.tan(vFovRad / 2);
|
||||
|
||||
// In-plane "up" of the pitched cap, decomposed into a vertical part and a
|
||||
// horizontal part along the heading (pitch < 0 tilts the cap's top forward).
|
||||
const upVert = Math.cos(pitch) * halfH;
|
||||
const upHoriz = -Math.sin(pitch) * halfH;
|
||||
|
||||
const capAlt = mountAlt + dims.vert;
|
||||
const capL = parts.model.projectPoint(
|
||||
capLL.lat,
|
||||
capLL.lon,
|
||||
heading - 90,
|
||||
halfW,
|
||||
dims.halfW,
|
||||
);
|
||||
const capR = parts.model.projectPoint(
|
||||
capLL.lat,
|
||||
capLL.lon,
|
||||
heading + 90,
|
||||
halfW,
|
||||
dims.halfW,
|
||||
);
|
||||
// Ground clamp (§6 risk): lift the CAP CENTER once so a fabricated pitch
|
||||
// never buries the plane's anchor — then derive the corners RIGIDLY from the
|
||||
// lifted center. Clamping each corner independently flattened the wireframe
|
||||
// into a ground-hugging fan while the rigid plane kept its height (owner
|
||||
// field test 2026-07-04): the corner rays must always terminate exactly on
|
||||
// the monitor plane's corners. The bottom pair may dip below ground; the 3D
|
||||
// tiles occlude that portion, exactly as they do for the plane itself.
|
||||
const minAlt = ground + FRUSTUM_GROUND_CLEARANCE_M;
|
||||
const capAltClamped = Math.max(minAlt, capAlt);
|
||||
// Ground clearance: ONE rigid lift for the whole rectangle, sized by the
|
||||
// support point with the largest deficit against the ground measured
|
||||
// under it (a 3×3 grid over the plane; see footprint.js). Points without
|
||||
// a measurement fall back to the ground at the mount, so with no
|
||||
// footprint data the bottom edge still clears the mount's ground — the
|
||||
// old center-only clamp left the bottom edge tens of metres underground
|
||||
// (owner field test 2026-09-13). The rectangle stays rigid and the
|
||||
// wireframe rays still terminate exactly on its corners.
|
||||
// Two lifts: what the mount's own ground demands (always honoured), plus
|
||||
// whatever the measured footprint adds, capped so a building under the
|
||||
// far edge cannot send the plane into the sky.
|
||||
const base = requiredPlaneLift(
|
||||
capAlt,
|
||||
dims,
|
||||
null,
|
||||
ground,
|
||||
FRUSTUM_GROUND_CLEARANCE_M,
|
||||
);
|
||||
const measured = requiredPlaneLift(
|
||||
capAlt,
|
||||
dims,
|
||||
groundUnderPlane,
|
||||
ground,
|
||||
FRUSTUM_GROUND_CLEARANCE_M,
|
||||
);
|
||||
const footprintExtraM = Math.max(0, measured.liftM - base.liftM);
|
||||
const liftM =
|
||||
base.liftM + Math.min(PLANE_FOOTPRINT_LIFT_CAP_M, footprintExtraM);
|
||||
const limitingKey =
|
||||
footprintExtraM > 0 ? measured.limitingKey : base.limitingKey;
|
||||
const capAltLifted = capAlt + liftM;
|
||||
const corner = (base, sign) => {
|
||||
const ll = parts.model.projectPoint(
|
||||
base.lat,
|
||||
base.lon,
|
||||
heading,
|
||||
sign * upHoriz,
|
||||
sign * dims.upHoriz,
|
||||
);
|
||||
return { lat: ll.lat, lon: ll.lon, alt: capAltClamped + sign * upVert };
|
||||
return {
|
||||
lat: ll.lat,
|
||||
lon: ll.lon,
|
||||
alt: capAltLifted + sign * dims.upVert,
|
||||
};
|
||||
};
|
||||
|
||||
const topCenter = corner(capLL, 1);
|
||||
return {
|
||||
rangeM: R,
|
||||
vFovDeg: Cesium.Math.toDegrees(vFovRad),
|
||||
halfW,
|
||||
halfH,
|
||||
vFovDeg: dims.vFovDeg,
|
||||
halfW: dims.halfW,
|
||||
halfH: dims.halfH,
|
||||
mount: { lat: camera.lat, lon: camera.lon, alt: mountAlt },
|
||||
capCenter: { lat: capLL.lat, lon: capLL.lon, alt: capAltClamped },
|
||||
capCenter: { lat: capLL.lat, lon: capLL.lon, alt: capAltLifted },
|
||||
corners: {
|
||||
tl: corner(capL, 1),
|
||||
tr: corner(capR, 1),
|
||||
@@ -128,6 +231,10 @@ export function createGeometry({ state: layerState, services, parts, source }) {
|
||||
},
|
||||
topCenter,
|
||||
groundAltM: ground,
|
||||
liftM,
|
||||
liftLimitedBy: limitingKey,
|
||||
liftCapped: footprintExtraM > PLANE_FOOTPRINT_LIFT_CAP_M,
|
||||
footprintMeasured: !!groundUnderPlane,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -230,6 +337,7 @@ export function createGeometry({ state: layerState, services, parts, source }) {
|
||||
record.camera,
|
||||
groundAltM,
|
||||
record.probeClampRangeM,
|
||||
footprintGroundFor(record),
|
||||
);
|
||||
const positions = frustumCartesians(geometry);
|
||||
record.frustumGeometry = geometry;
|
||||
@@ -327,6 +435,17 @@ export function createGeometry({ state: layerState, services, parts, source }) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Photoreal regime with shipped precompute samples for exactly this pose:
|
||||
// the mount ground and the footprint come from the sidecar, no scene
|
||||
// query and no shared-cell write (aircraft floors stay untouched).
|
||||
if (hasShippedFootprint(record)) {
|
||||
const shippedGround = record.camera.groundHeights.mountGroundM;
|
||||
record.groundSamples['google-3d'] = shippedGround;
|
||||
record.groundResolved['google-3d'] = true;
|
||||
applyFrustumGeometry(record, shippedGround);
|
||||
return;
|
||||
}
|
||||
|
||||
// Photoreal regime. Sampling is delegated to the shared coarse-cell
|
||||
// sampler. It remains event-driven, one-shot per cell, and keeps its
|
||||
// existing acceptance window; CCTV adds no rooftop rejection policy.
|
||||
@@ -696,6 +815,8 @@ export function createGeometry({ state: layerState, services, parts, source }) {
|
||||
}
|
||||
return {
|
||||
computeFrustumGeometry,
|
||||
hasShippedFootprint,
|
||||
footprintPose,
|
||||
activationProbeClampRange,
|
||||
frustumCartesians,
|
||||
frustumFrameEcef,
|
||||
|
||||
@@ -1,3 +1,12 @@
|
||||
import {
|
||||
SUPPORT_KEYS,
|
||||
planeSupportPoints,
|
||||
poseHash,
|
||||
} from '../../data/cctvFootprint.js';
|
||||
|
||||
/** Cooldown before a provisional (geoid-fallback / partial) footprint is retried. */
|
||||
const FOOTPRINT_RETRY_MS = 60_000;
|
||||
|
||||
export function createGround({ state: layerState, services, parts, source }) {
|
||||
const { resolveGroundFloorCells } = services.ground;
|
||||
const { resolveEllipsoidalGround } = services.terrain;
|
||||
@@ -248,8 +257,13 @@ export function createGround({ state: layerState, services, parts, source }) {
|
||||
}
|
||||
const ground = groundAltFor(record, regime);
|
||||
// Skip the entity rewrite when the applied ground already matches (e.g.
|
||||
// entering google-3d before any sample: prior → prior is a no-op).
|
||||
// entering google-3d before any sample: prior → prior is a no-op) —
|
||||
// unless the record has a footprint source, whose eligibility depends
|
||||
// on the regime (shipped mesh samples apply in google-3d only).
|
||||
const hasFootprintSource =
|
||||
!!record.camera?.groundHeights || !!record.footprintGround;
|
||||
if (
|
||||
!hasFootprintSource &&
|
||||
record.frustumGeometry &&
|
||||
Math.abs(record.frustumGeometry.groundAltM - ground) < 0.001
|
||||
) {
|
||||
@@ -266,11 +280,77 @@ export function createGround({ state: layerState, services, parts, source }) {
|
||||
// under the ≤1-per-(camera, session) ceiling.
|
||||
layerState._tilesReadyReenqueued = false;
|
||||
}
|
||||
// The active camera may have just lost its shipped footprint (globe
|
||||
// regime) — resolve its DEM footprint for the new surface.
|
||||
const active = layerState._recordById.get(layerState._activeCameraId);
|
||||
if (active) void resolveFootprintGround(active);
|
||||
parts.presentation.notifyListeners();
|
||||
}
|
||||
/**
|
||||
* Resolves the ground under the monitor plane's nine support points for the
|
||||
* record's CURRENT pose from the Re:Earth DEM (network-cached proxy, never a
|
||||
* scene query), then re-applies the geometry so the rigid lift accounts for
|
||||
* terrain rising under the plane's far edge. This is the fallback for
|
||||
* cameras without a shipped precompute for this pose (a new pack, or a
|
||||
* camera the user has edited), and it runs only on demand: activation and
|
||||
* calibration commit. A late result for a stale pose or a torn-down record
|
||||
* is dropped. Never rejects.
|
||||
* @param {Object} record - Camera record.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function resolveFootprintGround(record) {
|
||||
if (!record?.camera) return;
|
||||
const pose = parts.geometry.footprintPose(record);
|
||||
const hash = poseHash(pose);
|
||||
const existing = record.footprintGround;
|
||||
if (existing?.poseHash === hash) {
|
||||
// Complete real-DEM results are final; a geoid-fallback (proxy outage)
|
||||
// or partial result is retried after a cooldown.
|
||||
if (!existing.provisional || Date.now() < existing.retryAt) return;
|
||||
}
|
||||
if (parts.geometry.hasShippedFootprint(record)) return;
|
||||
const revision = (record.footprintRevision || 0) + 1;
|
||||
record.footprintRevision = revision;
|
||||
const { supports } = planeSupportPoints(pose);
|
||||
const coords = SUPPORT_KEYS.map((key) => ({
|
||||
lat: supports[key].lat,
|
||||
lon: supports[key].lon,
|
||||
}));
|
||||
let results = null;
|
||||
try {
|
||||
results = await resolveEllipsoidalGround(coords);
|
||||
} catch {
|
||||
results = null;
|
||||
}
|
||||
if (!Array.isArray(results)) return;
|
||||
if (record.footprintRevision !== revision) return;
|
||||
if (layerState._recordById.get(record.camera.id) !== record) return;
|
||||
if (poseHash(parts.geometry.footprintPose(record)) !== hash) return;
|
||||
const under = {};
|
||||
let real = 0;
|
||||
results.forEach((result, index) => {
|
||||
if (Number.isFinite(result?.ellipsoid)) {
|
||||
under[SUPPORT_KEYS[index]] = result.ellipsoid;
|
||||
if (result.source === 'reearth') real += 1;
|
||||
}
|
||||
});
|
||||
const provisional = real < SUPPORT_KEYS.length;
|
||||
record.footprintGround = {
|
||||
poseHash: hash,
|
||||
supports: under,
|
||||
source: provisional ? 'dem-provisional' : 'dem',
|
||||
provisional,
|
||||
retryAt: provisional ? Date.now() + FOOTPRINT_RETRY_MS : 0,
|
||||
};
|
||||
parts.geometry.applyFrustumGeometry(record, groundAltFor(record));
|
||||
parts.rendering.refreshCoverageStyles();
|
||||
parts.presentation.notifyListeners();
|
||||
}
|
||||
|
||||
return {
|
||||
surfaceRegimeKey,
|
||||
currentSurfaceRegime,
|
||||
resolveFootprintGround,
|
||||
groundPriorAltFor,
|
||||
isGroundResolved,
|
||||
groundAltFor,
|
||||
|
||||
@@ -57,6 +57,7 @@ export function createCctvLayer({ services, source }) {
|
||||
{
|
||||
calibrationPatchMovesAnchor:
|
||||
parts.calibration.calibrationPatchMovesAnchor,
|
||||
migrateRangeScaleForFloor: parts.calibration.migrateRangeScaleForFloor,
|
||||
_pushAmbientCardEntriesForTest:
|
||||
parts.testing._pushAmbientCardEntriesForTest,
|
||||
_setCctvOverlayHostForTest: parts.testing._setCctvOverlayHostForTest,
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
GROUND_PRIOR_INIT_WAIT_MS,
|
||||
CAMERA_ICON,
|
||||
IDLE_CAMERA_COLOR,
|
||||
CALIBRATION_RANGE_FLOOR_M,
|
||||
} from './policy.js';
|
||||
|
||||
export function createLifecycle({
|
||||
@@ -92,9 +93,18 @@ export function createLifecycle({
|
||||
for (const camera of catalog) {
|
||||
const savedEntry = layerState._calibrationById.get(camera.id);
|
||||
if (savedEntry) {
|
||||
camera.calibration = parts.calibration.normalizeCalibration(
|
||||
// Entries saved before the range floor dropped (no rangeFloorM)
|
||||
// keep their effective range by re-basing rangeScale once.
|
||||
const values =
|
||||
savedEntry.rangeFloorM === CALIBRATION_RANGE_FLOOR_M
|
||||
? savedEntry.values
|
||||
: parts.calibration.migrateRangeScaleForFloor(
|
||||
savedEntry.values,
|
||||
camera.rangeM,
|
||||
);
|
||||
savedEntry.values = values;
|
||||
savedEntry.rangeFloorM = CALIBRATION_RANGE_FLOOR_M;
|
||||
camera.calibration = parts.calibration.normalizeCalibration(values);
|
||||
camera.calSource = savedEntry.source;
|
||||
}
|
||||
parts.model.ensureCameraPose(camera);
|
||||
|
||||
@@ -98,6 +98,17 @@ export const PROJECTION_VERT_ASPECT =
|
||||
// the monitor plane in the 3D tiles. Exported for the unit suite.
|
||||
|
||||
export const FRUSTUM_GROUND_CLEARANCE_M = 2;
|
||||
/**
|
||||
* Most the footprint clearance may lift a monitor plane beyond what the
|
||||
* ground at its own mount requires (metres). A plane whose far edge crosses
|
||||
* a tall building would otherwise float hundreds of metres up to "clear" it;
|
||||
* past this the plane accepts the intersection instead of leaving the scene.
|
||||
*/
|
||||
export const PLANE_FOOTPRINT_LIFT_CAP_M = 60;
|
||||
/** Client range floor the pose model and catalog agree on (metres). */
|
||||
export const CALIBRATION_RANGE_FLOOR_M = 120;
|
||||
/** The floor saved v2 calibrations were authored against before 2026-09-13. */
|
||||
export const LEGACY_CALIBRATION_RANGE_FLOOR_M = 220;
|
||||
|
||||
/** Public result codes for explicit CCTV camera flights. */
|
||||
|
||||
|
||||
@@ -90,6 +90,7 @@ export function createPresentation({
|
||||
sourceStatus: health?.status || 'unknown',
|
||||
sourceMessage: health?.message || '',
|
||||
sourceLabel: health?.label || camera.provider || '',
|
||||
credit: camera.credit || '',
|
||||
calibration: {
|
||||
...parts.calibration.normalizeCalibration(camera.calibration),
|
||||
},
|
||||
|
||||
@@ -135,6 +135,10 @@ export function createSelection({
|
||||
// until update()'s one-shot tiles-ready completion pass re-grounds it.
|
||||
parts.ground.rearmGroundResolution(record);
|
||||
parts.geometry.updateRecordGeometry(record);
|
||||
// Ground under the plane's footprint (DEM, cached proxy) for cameras
|
||||
// without a shipped precompute for this pose — only the camera the user
|
||||
// is looking at ever asks.
|
||||
void parts.ground.resolveFootprintGround(record);
|
||||
record.activationDone = true;
|
||||
parts.rendering.refreshCoverageStyles();
|
||||
// The newly active camera leaves the ambient ring (its monitor plane takes
|
||||
|
||||
+654
-142
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,365 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
batches,
|
||||
cellKey,
|
||||
mergeCameraSamples,
|
||||
mergeSidecar,
|
||||
needsSampling,
|
||||
nominalPose,
|
||||
parseArgs,
|
||||
planCells,
|
||||
sampleCell,
|
||||
samplingPoints,
|
||||
withDeadline,
|
||||
plausibleHeight,
|
||||
} from '../../scripts/precompute-cctv-heights.mjs';
|
||||
import {
|
||||
planeSupportPoints,
|
||||
poseHash,
|
||||
SUPPORT_KEYS,
|
||||
} from '../data/cctvFootprint.js';
|
||||
|
||||
const pose = {
|
||||
lat: 30,
|
||||
lon: -95,
|
||||
headingDeg: 45,
|
||||
pitchDeg: -17,
|
||||
fovDeg: 74,
|
||||
rangeM: 145,
|
||||
mountHeightM: 8,
|
||||
};
|
||||
const sampledAt = '2026-09-13T12:00:00.000Z';
|
||||
const camera = (id, overrides = {}) => ({
|
||||
id,
|
||||
sourceKind: 'test',
|
||||
pose: { ...pose, ...overrides },
|
||||
});
|
||||
|
||||
test('nominal poses are hashed exactly as served, never clamped', () => {
|
||||
assert.deepEqual(nominalPose({ id: 'a', ...pose }), pose);
|
||||
// The server join and the client hash the same raw served values, so a
|
||||
// range outside the client's display clamp still round-trips untouched.
|
||||
for (const rangeM of [20, 120, 145, 3000]) {
|
||||
const result = nominalPose({ id: 'a', ...pose, rangeM });
|
||||
assert.equal(result.rangeM, rangeM);
|
||||
assert.equal(poseHash(result), poseHash({ ...pose, rangeM }));
|
||||
}
|
||||
assert.throws(() => nominalPose({ ...pose, rangeM: null }), /invalid rangeM/);
|
||||
assert.throws(() => nominalPose({ ...pose, lat: 91 }), /invalid coordinates/);
|
||||
assert.equal(pose.rangeM, 145);
|
||||
});
|
||||
|
||||
test('cells have stable per-row longitude widths and handle boundaries', () => {
|
||||
assert.equal(cellKey({ lat: 0.001, lon: 0.001 }), '4500,9000');
|
||||
assert.equal(cellKey({ lat: 0.019, lon: 0.001 }), '4500,9000');
|
||||
assert.equal(cellKey({ lat: 0.021, lon: 0.001 }).split(',')[0], '4501');
|
||||
assert.equal(cellKey({ lat: -0.001, lon: 0.001 }).split(',')[0], '4499');
|
||||
const column = (lat, lon) => Number(cellKey({ lat, lon }).split(',')[1]);
|
||||
assert.equal(column(0.001, 2) - column(0.001, 0), 100);
|
||||
assert.equal(column(60.001, 2) - column(60.001, 0), 50);
|
||||
assert.equal(cellKey({ lat: 30, lon: 180 }), cellKey({ lat: 30, lon: -180 }));
|
||||
for (const lat of [-90, 90])
|
||||
assert.match(cellKey({ lat, lon: 0 }), /^\d+,\d+$/);
|
||||
assert.throws(() => cellKey({ lat: NaN, lon: 0 }), /Invalid/);
|
||||
});
|
||||
|
||||
test('plans order cells numerically and preserve camera order within cells', () => {
|
||||
const cameras = [
|
||||
camera('north', { lat: 50 }),
|
||||
camera('east', { lon: -90 }),
|
||||
camera('a'),
|
||||
camera('b'),
|
||||
];
|
||||
const plan = planCells(cameras);
|
||||
assert.deepEqual(
|
||||
plan.map((cell) => cell.cameras.map((item) => item.id)),
|
||||
[['a', 'b'], ['east'], ['north']],
|
||||
);
|
||||
const sparse = planCells([
|
||||
camera('ten', { lat: -89.79 }),
|
||||
camera('two', { lat: -89.95 }),
|
||||
]);
|
||||
assert.deepEqual(
|
||||
sparse.map((cell) => cell.cameras[0].id),
|
||||
['two', 'ten'],
|
||||
);
|
||||
assert.deepEqual(
|
||||
cameras.map((item) => item.id),
|
||||
['north', 'east', 'a', 'b'],
|
||||
);
|
||||
});
|
||||
|
||||
test('batches cap 16 cameras at 160 points, including partial and empty batches', () => {
|
||||
const items = Array.from({ length: 35 }, (_, i) => i);
|
||||
const result = batches(items);
|
||||
assert.deepEqual(
|
||||
result.map((batch) => batch.length),
|
||||
[16, 16, 3],
|
||||
);
|
||||
assert.deepEqual(result.flat(), items);
|
||||
assert.deepEqual(batches([], 4), []);
|
||||
for (const size of [0, -1, 1.5, Infinity])
|
||||
assert.throws(() => batches(items, size));
|
||||
});
|
||||
|
||||
test('plausibility preserves ellipsoid heights without null or string coercion', () => {
|
||||
for (const height of [-150, -10.25, 0, 123.456, 9000])
|
||||
assert.equal(plausibleHeight(height), height);
|
||||
for (const height of [
|
||||
-150.001,
|
||||
undefined,
|
||||
null,
|
||||
NaN,
|
||||
Infinity,
|
||||
-Infinity,
|
||||
'0',
|
||||
false,
|
||||
])
|
||||
assert.equal(plausibleHeight(height), null);
|
||||
});
|
||||
|
||||
test('resume requires both ok status and the current shared pose hash', () => {
|
||||
const entry = { status: 'ok', poseHash: poseHash(pose) };
|
||||
assert.equal(needsSampling(entry, pose), false);
|
||||
for (const key of Object.keys(pose))
|
||||
assert.equal(needsSampling(entry, { ...pose, [key]: pose[key] + 1 }), true);
|
||||
for (const prior of [
|
||||
undefined,
|
||||
{},
|
||||
{ ...entry, status: 'miss' },
|
||||
{ poseHash: entry.poseHash },
|
||||
])
|
||||
assert.equal(needsSampling(prior, pose), true);
|
||||
// A complete-looking entry with missing supports is resampled.
|
||||
assert.equal(needsSampling({ ...entry, misses: ['tr'] }, pose), true);
|
||||
assert.equal(needsSampling({ ...entry, misses: [] }, pose), false);
|
||||
});
|
||||
|
||||
test('entries preserve support ordering, partial misses and retry successes', () => {
|
||||
const first = mergeCameraSamples(
|
||||
pose,
|
||||
[0, 10, null, 12, 13, 14, 15, 16, 17, -151],
|
||||
null,
|
||||
1,
|
||||
sampledAt,
|
||||
);
|
||||
assert.equal(first.status, 'ok');
|
||||
assert.equal(first.mountGroundM, 0);
|
||||
assert.deepEqual(Object.keys(first.supports), SUPPORT_KEYS);
|
||||
assert.deepEqual(first.misses, ['bm', 'tr']);
|
||||
assert.equal(first.supports.bm, null);
|
||||
const retried = mergeCameraSamples(
|
||||
pose,
|
||||
[undefined, null, 11, null, null, null, null, null, null, 18],
|
||||
first,
|
||||
2,
|
||||
sampledAt,
|
||||
);
|
||||
assert.deepEqual(
|
||||
retried.supports,
|
||||
Object.fromEntries(SUPPORT_KEYS.map((key, i) => [key, i + 10])),
|
||||
);
|
||||
assert.deepEqual(retried.misses, []);
|
||||
assert.equal(retried.mountGroundM, 0);
|
||||
assert.equal(retried.attempts, 2);
|
||||
assert.equal(retried.sampledAt, sampledAt);
|
||||
// A partial first pass is 'ok' (the mount landed) but still resamples so
|
||||
// the missing supports get another chance.
|
||||
assert.equal(needsSampling(first, pose), first.misses.length > 0);
|
||||
});
|
||||
|
||||
test('a missing mount stores no heights and stale poses cannot supply retry heights', () => {
|
||||
const missing = mergeCameraSamples(
|
||||
pose,
|
||||
[-151, ...Array(9).fill(10)],
|
||||
null,
|
||||
3,
|
||||
sampledAt,
|
||||
);
|
||||
assert.equal(missing.status, 'miss');
|
||||
assert.deepEqual(missing.misses, ['mount']);
|
||||
assert.equal(Object.hasOwn(missing, 'mountGroundM'), false);
|
||||
assert.equal(Object.hasOwn(missing, 'supports'), false);
|
||||
const previous = mergeCameraSamples(
|
||||
{ ...pose, rangeM: 200 },
|
||||
Array(10).fill(99),
|
||||
null,
|
||||
1,
|
||||
sampledAt,
|
||||
);
|
||||
const entry = mergeCameraSamples(pose, [], previous, 2, sampledAt);
|
||||
assert.equal(entry.status, 'miss');
|
||||
assert.deepEqual(entry.misses, ['mount', ...SUPPORT_KEYS]);
|
||||
});
|
||||
|
||||
test('sidecar merging sorts ids, preserves unselected entries and replaces stale heights', () => {
|
||||
const old = {
|
||||
cameras: { z: { status: 'ok' }, a: { status: 'ok', mountGroundM: 20 } },
|
||||
};
|
||||
const result = mergeSidecar(
|
||||
old,
|
||||
{ b: { status: 'ok' }, a: { status: 'miss' } },
|
||||
sampledAt,
|
||||
);
|
||||
assert.deepEqual(Object.keys(result.cameras), ['a', 'b', 'z']);
|
||||
assert.deepEqual(result.cameras.a, { status: 'miss' });
|
||||
assert.equal(old.cameras.a.mountGroundM, 20);
|
||||
assert.equal(result.schemaVersion, 1);
|
||||
assert.equal(result.provider, 'google-photorealistic-3d');
|
||||
assert.equal(result.heightReference, 'WGS84-ellipsoid');
|
||||
assert.equal(result.generatedAt, sampledAt);
|
||||
assert.deepEqual(mergeSidecar(null, {}, sampledAt).cameras, {});
|
||||
});
|
||||
|
||||
test('CLI supports combined pilot filters and rejects malformed flags', () => {
|
||||
assert.deepEqual(
|
||||
parseArgs(['--kinds', 'a, b', '--limit', '40', '--dry-run']),
|
||||
{ limit: 40, kinds: ['a', 'b'], dryRun: true },
|
||||
);
|
||||
assert.equal(parseArgs(['--limit', '0']).limit, 0);
|
||||
assert.equal(parseArgs([]).limit, Infinity);
|
||||
for (const args of [
|
||||
['--limit'],
|
||||
['--limit', '-1'],
|
||||
['--limit', '1.5'],
|
||||
['--limit', '1e3'],
|
||||
['--kinds'],
|
||||
['--kinds', 'a,'],
|
||||
['--kinds', '--dry-run'],
|
||||
['--unknown'],
|
||||
])
|
||||
assert.throws(() => parseArgs(args));
|
||||
});
|
||||
|
||||
test('sampling uses the shared footprint directly and rejects invalid geometry', () => {
|
||||
const footprint = planeSupportPoints(pose);
|
||||
const expected = [
|
||||
footprint.mount,
|
||||
...SUPPORT_KEYS.map((key) => footprint.supports[key]),
|
||||
].map(({ lat, lon }) => ({ lat, lon }));
|
||||
assert.deepEqual(samplingPoints(pose), expected);
|
||||
assert.equal(expected.length, 10);
|
||||
assert.ok(
|
||||
expected.every(
|
||||
(point) => Number.isFinite(point.lat) && Number.isFinite(point.lon),
|
||||
),
|
||||
);
|
||||
assert.throws(
|
||||
() => samplingPoints({ ...pose, lat: NaN }),
|
||||
/invalid coordinates/,
|
||||
);
|
||||
});
|
||||
|
||||
test('failed calls recycle before serial retries in batches of 4, then singly', async () => {
|
||||
const cameras = Array.from({ length: 16 }, (_, i) => camera(String(i)));
|
||||
const sizes = [];
|
||||
const seen = new Map();
|
||||
let active = 0;
|
||||
let needsRecycle = false;
|
||||
let recycled = 0;
|
||||
const result = await sampleCell(
|
||||
cameras,
|
||||
{
|
||||
async sample(batch) {
|
||||
assert.equal(active, 0);
|
||||
assert.equal(needsRecycle, false);
|
||||
active += 1;
|
||||
sizes.push(batch.length);
|
||||
await Promise.resolve();
|
||||
active -= 1;
|
||||
for (const item of batch)
|
||||
seen.set(item.id, (seen.get(item.id) || 0) + 1);
|
||||
if (sizes.length === 1) {
|
||||
needsRecycle = true;
|
||||
throw new Error('timeout');
|
||||
}
|
||||
return batch.map((item) =>
|
||||
seen.get(item.id) === 2 ? [] : Array(10).fill(25),
|
||||
);
|
||||
},
|
||||
async recycle() {
|
||||
assert.equal(active, 0);
|
||||
needsRecycle = false;
|
||||
recycled += 1;
|
||||
},
|
||||
},
|
||||
() => sampledAt,
|
||||
);
|
||||
assert.deepEqual(sizes, [16, 4, 4, 4, 4, ...Array(16).fill(1)]);
|
||||
assert.equal(recycled, 1);
|
||||
for (const entry of Object.values(result)) {
|
||||
assert.equal(entry.status, 'ok');
|
||||
assert.equal(entry.attempts, 3);
|
||||
}
|
||||
});
|
||||
|
||||
test('only failed cameras retry; partial successes survive and mount misses stop after three attempts', async () => {
|
||||
const calls = [];
|
||||
const result = await sampleCell(
|
||||
[camera('ok'), camera('partial'), camera('miss')],
|
||||
{
|
||||
async sample(batch) {
|
||||
calls.push(batch.map((item) => item.id));
|
||||
return batch.map((item) => {
|
||||
if (item.id === 'miss') return [];
|
||||
if (item.id === 'partial')
|
||||
return calls.length === 1
|
||||
? [10, ...Array(8).fill(11), null]
|
||||
: [null, ...Array(8).fill(null), 12];
|
||||
return Array(10).fill(10);
|
||||
});
|
||||
},
|
||||
async recycle() {
|
||||
assert.fail('No call failed');
|
||||
},
|
||||
},
|
||||
() => sampledAt,
|
||||
);
|
||||
assert.deepEqual(calls, [
|
||||
['ok', 'partial', 'miss'],
|
||||
['partial', 'miss'],
|
||||
['miss'],
|
||||
]);
|
||||
assert.equal(result.ok.attempts, 1);
|
||||
assert.equal(result.partial.attempts, 2);
|
||||
assert.deepEqual(result.partial.misses, []);
|
||||
assert.equal(result.partial.supports.tr, 12);
|
||||
assert.equal(result.miss.status, 'miss');
|
||||
assert.equal(result.miss.attempts, 3);
|
||||
});
|
||||
|
||||
test('deadline rejects hung calls and passes successful or rejected operations through', async () => {
|
||||
assert.equal(await withDeadline(Promise.resolve(42)), 42);
|
||||
await assert.rejects(
|
||||
withDeadline(Promise.reject(new Error('provider failed'))),
|
||||
/provider failed/,
|
||||
);
|
||||
await assert.rejects(withDeadline(new Promise(() => {}), 5), /timed out/);
|
||||
});
|
||||
|
||||
test('a resumed camera keeps its valid heights when the retry only fails', async () => {
|
||||
const prior = mergeCameraSamples(
|
||||
pose,
|
||||
[1, 2, 3, 4, 5, 6, 7, 8, 9, 10],
|
||||
undefined,
|
||||
1,
|
||||
't0',
|
||||
);
|
||||
const partial = {
|
||||
...prior,
|
||||
supports: { ...prior.supports, tr: null },
|
||||
misses: ['tr'],
|
||||
};
|
||||
const sampler = {
|
||||
sample: async (batch) => batch.map(() => Array(10).fill(undefined)),
|
||||
recycle: async () => {},
|
||||
};
|
||||
const entries = await sampleCell([{ id: 'cam', pose }], sampler, () => 't1', {
|
||||
cam: partial,
|
||||
});
|
||||
assert.equal(entries.cam.status, 'ok');
|
||||
assert.equal(entries.cam.mountGroundM, 1);
|
||||
assert.equal(entries.cam.supports.bl, 2);
|
||||
assert.deepEqual(entries.cam.misses, ['tr']);
|
||||
});
|
||||
@@ -148,11 +148,13 @@ export function _renderCctvState(state) {
|
||||
const statusMsg = activeCamera.sourceMessage
|
||||
? ` · ${activeCamera.sourceMessage}`
|
||||
: '';
|
||||
// A partner-supplied feed inside a pack names its owner here.
|
||||
const credit = activeCamera.credit ? ` · ${activeCamera.credit}` : '';
|
||||
const calBadge = activeCamera.calBadge
|
||||
? this._calBadgeLabel(activeCamera.calBadge)
|
||||
: '';
|
||||
const projLabel = state?.showProjection !== false ? 'MONITOR' : 'OFF';
|
||||
this._cctvMeta.textContent = `${activeCamera.city} · HDG ${Math.round(activeCamera.headingDeg)}° · FOV ${Math.round(activeCamera.fovDeg)}° · RANGE ${Math.round(activeCamera.rangeM)}m · ${projLabel}${calBadge ? ` · ${calBadge}` : ''} · ${provider}${statusMsg}`;
|
||||
this._cctvMeta.textContent = `${activeCamera.city} · HDG ${Math.round(activeCamera.headingDeg)}° · FOV ${Math.round(activeCamera.fovDeg)}° · RANGE ${Math.round(activeCamera.rangeM)}m · ${projLabel}${calBadge ? ` · ${calBadge}` : ''} · ${provider}${credit}${statusMsg}`;
|
||||
} else if (cameras.length > 0) {
|
||||
this._cctvMeta.textContent = enabled
|
||||
? `${cameras.length} cameras loaded · click a camera to activate`
|
||||
|
||||
Reference in new issue
Block a user