diff --git a/.env.example b/.env.example index 78264489b..55cb367a0 100644 --- a/.env.example +++ b/.env.example @@ -228,6 +228,9 @@ AISSTREAM_API_KEY= # CCTV_CALGARY_ROWS_URL= # override the Open Calgary catalog URL # CCTV_CATALONIA_ENABLED=1 # set 0 to disable the Servei Català de Trànsit cameras # CCTV_CATALONIA_MAX_SOURCES=150 # cap cameras loaded from Catalonia (~130 exist today) +# CCTV_HK_ENABLED=1 # set 0 to disable the Hong Kong Transport Department cameras +# CCTV_HK_MAX_SOURCES=1100 # cap cameras loaded from Hong Kong (~1,013 exist today); min 8, max 1200 +# CCTV_HK_CAMERAS_URL= # override the DATA.GOV.HK camera locations XML URL # CCTV_KINGCOUNTY_ENABLED=1 # set 0 to disable the King County (Washington) road cameras # CCTV_KINGCOUNTY_MAX_SOURCES=125 # cap cameras loaded from the King County catalog # CCTV_WSDOT_ENABLED=1 # set 0 to disable the WSDOT (Washington State) highway cameras diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md index 728381632..61b971b71 100644 --- a/DATA_SOURCES.md +++ b/DATA_SOURCES.md @@ -63,6 +63,7 @@ How to read this: | **Servei Català de Trànsit** (+ Ajuntament de Barcelona, Ajuntament de Terrassa) | CCTV camera catalog + frames, Catalonia | [Llicència oberta d'ús d'informació - Catalunya](https://web.gencat.cat/ca/generalitat/dades-indicadors/dades-obertes/llicencies) — attribution REQUIRED | "Servei Català de Trànsit"; partner-supplied cameras also name their owner | | **King County Road Services** (`services.arcgis.com`, King County GIS) | CCTV camera catalog + frames, unincorporated King County WA road network | [KCGIS Center terms](https://kingcounty.gov/en/dept/kcit/data-information-services/gis-center/about/terms-conditions-copyrights) — attribution required; public ArcGIS layer | "King County Road Services" (partner-city cameras name their owner) | | **WSDOT** (`data.wsdot.wa.gov`) | CCTV camera catalog + frames, Washington State highways | [WSDOT travel information disclaimer](https://wsdot.wa.gov/about/policies/travel-information-disclaimer) — public "low volume" feed; catalog cached, frames on WSDOT's own image host only | "Washington State Department of Transportation (Travel Center)" (courtesy) | +| **Hong Kong Transport Department / DATA.GOV.HK** | CCTV camera catalog + frames, Hong Kong traffic snapshot cameras | [DATA.GOV.HK Terms of Use](https://data.gov.hk/en/terms-and-conditions) — attribution REQUIRED | "Hong Kong Transport Department / DATA.GOV.HK" | | **Statens vegvesen** (catalog `ogckart-sn1.atlas.vegvesen.no`, frames `kamera.atlas.vegvesen.no`, HLS `kamera.vegvesen.no`) | CCTV camera catalog + frames + live HLS video, Norway road cameras | [NLOD 2.0](https://data.norge.no/nlod/en/2.0) — attribution REQUIRED | "Contains data under the Norwegian licence for Open Government data (NLOD) distributed by Statens vegvesen" | | **Kartverket** (`ws.geonorge.no/hoydedata/v1/punkt`) | Terrain heights (NN2000) used as the ground prior for Statens vegvesen cameras | [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) — attribution REQUIRED | "© Kartverket", modified (heights over water set to 0 m, rounded to 0.1 m) | | **NYC DOT Traffic Management Center** (`webcams.nyctmc.org`) | CCTV camera catalog + frames, New York City five boroughs | NYC DOT requires developers to sign a [data-sharing agreement](https://www.nyc.gov/html/dot/html/about/datafeeds.shtml) for the camera feed. **Off by default**; enable only under your own agreement | "NYC DOT Traffic Management Center" + NYC DOT's required application disclaimer (in the Data attribution popover) | @@ -169,6 +170,7 @@ The optional Local ADS-B layer and the broadcast-FM receiver use radio you recei - **Catalonia (Servei Català de Trànsit) cameras.** The camera list is the keyless `https://www.gencat.cat/transit/opendata/cameres.xml` WFS/GML feed, fetched over https with redirects refused and the body read capped at 1 MiB, since the list decides ids, coordinates and which allowlisted frame path is proxied. It carries a `font` field naming the real publisher per camera; this pack keeps SCT's own highway cameras plus hotlinked Barcelona (IMI) and Terrassa municipal cameras (~130 total), each confirmed to return a real frame, so frame URLs are checked against a small host allowlist (`mct.gencat.cat`, `www.bcn.cat`, `emap.terrassa.cat`) rather than one pinned origin (a link carrying `user:pass@` userinfo or an explicit port, or pointing outside that host's frame directory, is rejected even on an allowed host; an SCT `sctidcam` must be a bare `.gif`/`.jpg` filename), and every non-SCT camera carries its real owner as a `credit` shown beside the shared `provider`. The feed carries no compass heading, so every camera loads at low heading confidence (same as TfL and Fintraffic). SCT's own feed link (`RenderService`) needed a workaround: it always 302s to a second endpoint (`TransitCamera`) on a Location the upstream hardcodes as plain http even when requested over https — refused by `fetchWithinHost`'s same-origin (scheme-included) redirect policy — and separately, `mct.gencat.cat`'s TLS setup fails the handshake with Node's fetch outright (`ERR_SSL_WRONG_SIGNATURE_TYPE`) even though other HTTP clients complete it. The loader therefore builds the `TransitCamera` URL itself, over plain http, from `RenderService`'s `sctidcam` id, rather than registering the (broken) upstream link — the only transport this one host actually serves to this runtime. Camera ids are built from the SCT camera file or the partner image path, not the feed's `fid` (which embeds an upstream server-start stamp and changes on every upstream restart), and rows the feed lists more than once collapse to one camera. The feed carries no camera status field, so a camera its publisher has taken offline still registers and shows the publisher's own "image unavailable" still (SCT and Barcelona both serve one with HTTP 200), the same as other packs without a status field. Frames are fetched at request time and never stored or redistributed. The feed itself states no licence; the pack relies on the Generalitat de Catalunya open-data portal's terms for this dataset (the licence and attribution above). Disable with `CCTV_CATALONIA_ENABLED=0`. - **King County road cameras.** Keyless. The camera list is one public ArcGIS FeatureServer GeoJSON query (King County DOT Road Services, "King County and WSDOT Traffic Cameras"). The `outFields` list is explicit on purpose: the layer also carries asset-administration columns — including staff names — that are never fetched. Only live, active cameras with frames on the county's own `info.kingcounty.gov` image handler are registered (the feed writes `http://`; the handler serves HTTPS, so URLs are upgraded and pinned before registration). WSDOT-owned rows in the layer are skipped because their frames are WSDOT-hosted, outside the pinned image origin; partner-city cameras hosted on the county image handler (e.g. Newcastle) name their owner as the per-camera credit. The layer publishes no facing, so headings use the shared id-hash fallback at low confidence. Uniquely, the county publishes each camera's hardware `Manufacturer`/`Model` (Cohu 3950/3965, Bosch AUTODOME, Axis PTZ domes); both are carried on the source (and served by `/api/cctv/sources`) as `model` and `manufacturer` for identified-hardware consumers (vendor-generic strings such as `PTZ` name no model and are omitted). Frames are fetched at request time and never stored or redistributed. Disable with `CCTV_KINGCOUNTY_ENABLED=0`. - **WSDOT highway cameras.** Keyless. The camera list is the Esri JSON layer `data.wsdot.wa.gov/travelcenter/Cameras.json` — the same layer WSDOT's own Travel Center map consumes (~1,700 point features, EPSG:3857 geometry converted to WGS84 on load; the body carries Windows-1252 bytes and is decoded as such). Only frames on WSDOT's own `images.wsdot.wa.gov` host are registered — the catalog also lists ~70 partner cameras (Oregon DOT, City of Seattle, lodges, airports) whose own terms apply, and those are skipped rather than proxied. WSDOT's layer metadata marks the feed for "low volume" use (see WSDOT's [travel information disclaimer](https://wsdot.wa.gov/about/policies/travel-information-disclaimer)). The catalog is fetched once per CCTV catalog refresh (every 15 minutes), and a camera's frame is fetched only when that camera is viewed; frames are never stored or redistributed. The live feed's `CompassDirection` is a single cardinal (N/E/S/W), `B` (both directions — most rows), `O`, or null. A single cardinal gives a high-confidence heading (the diagonals NE/SE/SW/NW are mapped too should they appear); `B`, `O`, null, and any other code carry no single facing and use the shared id-hash fallback at low confidence. By default the 250 cameras nearest Seattle and Spokane load; `CCTV_WSDOT_MAX_SOURCES` changes the cap (8–1,200) and `CCTV_WSDOT_ENABLED=0` turns the pack off. A keyed WSDOT Traveler Information API serves the same population for higher-volume use and is deliberately not required here. +- **Hong Kong Transport Department traffic snapshot cameras.** Keyless. The English camera list is the public DATA.GOV.HK XML at `static.data.gov.hk/td/traffic-snapshot-images/code/Traffic_Camera_Locations_En.xml`; frames are stills on `tdcctv.data.one.gov.hk`, and only that host is registered. Frame URLs are rebuilt from each camera key (`https://tdcctv.data.one.gov.hk/{KEY}.JPG`) rather than taken verbatim from the catalog. Stills were probed to answer directly on that origin with no redirect hop. Frames are fetched at request time and never stored or redistributed. The dataset publishes no camera facing, so headings use the shared id-hash fallback at low confidence and are corrected with the in-app calibration gizmo. By default nearly the full catalog loads (~1,013; pack cap 1,100), prioritized nearest Central, Tsim Sha Tsui, Sha Tin, Tsuen Wan, Tai Po, Fanling, Tuen Mun, Yuen Long and Tseung Kwan O so a lowered cap still spans Hong Kong Island, Kowloon and the New Territories. When the sum of packs exceeds the catalog-wide ceiling, shared round-robin keeps every lane represented. `CCTV_HK_MAX_SOURCES` (8–1200) changes the cap and `CCTV_HK_ENABLED=0` turns the pack off. The DATA.GOV.HK Terms of Use require attribution to the Government / Transport Department / DATA.GOV.HK. - **Statens vegvesen road cameras.** Keyless. The camera list is one GeoJSON request to the OGC API Features view of the official DATEX 3.1 CCTV site table (`ogckart-sn1.atlas.vegvesen.no/ogc/features/v1/collections/datex_3_1:CctvSimple/items`), so no DATEX account is needed; frames are stills on `kamera.atlas.vegvesen.no/api/images/`, and only that exact path per camera is registered. Cameras the feed reports as faulty are dropped. About 135 working cameras also publish live HLS (`kamera.vegvesen.no/public//manifest.m3u8`, MPEG-TS); those play as live video through the bounded in-memory HLS relay, with the still as the snapshot fallback, and only that exact manifest path per camera is registered. `CCTV_VEGVESEN_VIDEO=0` keeps every camera on stills. `orientationDescription` names the place a camera faces, not a bearing, so it goes into the label and headings use the id-hash fallback at low confidence. By default every live camera loads (~850); `CCTV_VEGVESEN_MAX_SOURCES` (8–1000) lowers the cap, keeping the cameras nearest Oslo, Bergen, Stavanger, Trondheim, Kristiansand, Bodø and Tromsø and `CCTV_VEGVESEN_ENABLED=0` turns the pack off. Frames are fetched at request time and never stored or redistributed. The NLOD attribution is registered in the Data attribution popover. - **Kartverket ground heights for Statens vegvesen cameras.** The camera feed's points are 2D, so the loader asks Kartverket's keyless point-height API (`ws.geonorge.no/hoydedata/v1/punkt`, CC BY 4.0) for the terrain height at each camera. Requests carry 20 points: the API accepts 50, but above ~20 it answers the extra points from contour lines (`hoydekurver`, metres to ~20 m off) or not at all. Eight requests run at a time, about 36 for the whole country: a cold lookup took 6–13 s in testing (single requests 0.6–9.5 s). Answers are matched by the coordinates the API echoes and recorded only for points that request asked about. Over water the API returns the sea-floor depth (`dybdekurver` or `Havflate`), which is replaced by 0 m, the water surface; for the ~14 bridge cameras that is below the deck, but much closer than the sea floor or the flat prior. Heights are cached in memory by position for the life of the server, and points with no height (a few ferry quays and bridges, or a point a 200 answer leaves out) for 6 h, so the 15-minute catalog refresh only asks about new or moved cameras. The first failing request (a 408, 429 or 5xx, a network error, a request over 10 s, or an unreadable or oversized body) stops the lookup, with no retries, and no new lookup starts until the backoff ends (Retry-After when sent, else 30 min, longer than the refresh; bounded to 1 min–1 h). Any other 4xx marks that request's points as having no height and the lookup carries on. The whole lookup has a 30 s deadline; a lookup cut short by it resumes on the next refresh without a backoff. The lane waits at most 12 s, counted from the start of the loader, and only when more than 10% of the cameras have no answer yet; a few new cameras or expired misses are looked up in the background. A slower lookup keeps filling the cache, and the cameras it had not reached keep the flat prior until the next 15-minute refresh (about 190 of 865 on one slow first run). Only coordinates are sent, with the same User-Agent as the other CCTV requests. `CCTV_VEGVESEN_HEIGHTS=0` turns the lookup off. The Kartverket credit, with a note that heights over water are set to 0 m and heights are rounded to 0.1 m, is shown beside the Statens vegvesen entry in the Data attribution popover. - **NYC DOT Traffic Management Center cameras.** Off by default. NYC DOT's [developer data feeds page](https://www.nyc.gov/html/dot/html/about/datafeeds.shtml) asks developers who want the camera feed to contact `tmcdot@dot.nyc.gov` and sign a data-sharing agreement. This project does not hold one that covers downstream installations, so the pack loads only when an operator who holds their own agreement sets `CCTV_NYC_ENABLED=1`; with the flag unset, empty or any other value, the server makes no request to NYC DOT. The endpoint needs no key. The camera list is the public `webcams.nyctmc.org/api/cameras` catalog (~980 cameras across the five boroughs); frames are stills at `webcams.nyctmc.org/api/cameras//image`, synthesized from a validated UUID rather than taken from the payload, and only that host is registered. Cameras the feed reports as offline are dropped. The catalog publishes no camera facing, so headings use the shared id-hash fallback at low confidence and are corrected with the in-app calibration gizmo. Once enabled, the 250 cameras nearest the borough cores load; `CCTV_NYC_MAX_SOURCES` (8–400) changes the cap. Frames are fetched at request time and never stored or redistributed. NYC DOT asks every application that uses its data to carry its disclaimer; it is registered verbatim in the Data attribution popover. diff --git a/README.md b/README.md index 64f720a57..12b0705e3 100644 --- a/README.md +++ b/README.md @@ -381,7 +381,7 @@ Twenty layers and map sources. **Eighteen have a keyless path.** Some offer addi | 🌍 **Earthquakes** | Global seismic activity, last 24h | USGS | 🟢 | | ◍ **Air quality** | Live AQHI on the published 1–10+ health-risk scale, with a Weather-panel card showing the band legend and the highest readings in view. **Canada only** — ECCC is the first registered provider | ECCC MSC GeoMet | 🟢 | | 🚗 **Traffic** | Simulated vehicles on OSM roads. With TomTom, live flow speeds drive the simulation and congestion colors below ~8 km; individual vehicle positions are not live observations | TomTom + OSM | 🟢 simulation · 🟡 live flow speeds | -| 📹 **CCTV Mesh** | ~4,000 public cameras (shared cap) projected _into_ the 3D space — Austin · Texas (TxDOT) · California (Caltrans) · London (TfL) · Ontario (511, free developer key) · Finland (Fintraffic) · British Columbia (DriveBC) · Estonia (Tallinn, Tarktee) · Delaware (DelDOT live video) · New South Wales (Live Traffic NSW) · Calgary · Norway (Statens vegvesen) · Toronto · King County (WA) · Washington (WSDOT) · Catalonia (Servei Català de Trànsit) · Taiwan (Freeway Bureau, MOTC), plus opt-in New York City (NYC DOT TMC, needs your own NYC DOT agreement), opt-in Lithuania (Via Lietuva / Eismoinfo, photo licence unconfirmed) and opt-in Florida (FL511, individual personal use only; other use needs FDOT's written consent). Positions are published; poses are estimated priors **you calibrate by dragging a gizmo on the camera itself** | City APIs | 🟢 | +| 📹 **CCTV Mesh** | ~4,000 public cameras (shared cap) projected _into_ the 3D space — Austin · Texas (TxDOT) · California (Caltrans) · London (TfL) · Ontario (511, free developer key) · Finland (Fintraffic) · British Columbia (DriveBC) · Estonia (Tallinn, Tarktee) · Delaware (DelDOT live video) · New South Wales (Live Traffic NSW) · Calgary · Norway (Statens vegvesen) · Toronto · King County (WA) · Washington (WSDOT) · Catalonia (Servei Català de Trànsit) · Hong Kong (Transport Department) · Taiwan (Freeway Bureau, MOTC), plus opt-in New York City (NYC DOT TMC, needs your own NYC DOT agreement), opt-in Lithuania (Via Lietuva / Eismoinfo, photo licence unconfirmed) and opt-in Florida (FL511, individual personal use only; other use needs FDOT's written consent). Positions are published; poses are estimated priors **you calibrate by dragging a gizmo on the camera itself** | City APIs | 🟢 | | 📷 **Mapped ALPR Cameras** | License-plate-reader camera locations tagged by OpenStreetMap contributors, loaded one city-sized view at a time, with **SHOW NEAREST**. Locations and tags only: no plate data, no video | OpenStreetMap (incl. DeFlock mapping) | 🟢 | | 📷 **Street Level** | Street-level photos behind one panel, one chip per provider: coverage on the streets, image cones, an embedded viewer with full-screen **EXPAND**. Mapillary, Google Street View, KartaView and Panoramax | Mapillary · Google · KartaView · Panoramax | 🟢 KartaView, Panoramax · 🟡 Mapillary token · 🔴 Google Maps key | | 📻 **Radio** | Geolocated world radio with an **analog tuner** — drag the needle across up to 750 stations and the globe flies to each broadcaster | Radio Browser / broadcasters | 🟢 | diff --git a/changelog.d/990-hong-kong-cctv.md b/changelog.d/990-hong-kong-cctv.md new file mode 100644 index 000000000..a150b66ae --- /dev/null +++ b/changelog.d/990-hong-kong-cctv.md @@ -0,0 +1,7 @@ +- Add Hong Kong Transport Department traffic snapshot cameras as a keyless CCTV + source pack: the public DATA.GOV.HK English locations XML, frames pinned to + `tdcctv.data.one.gov.hk`, with DATA.GOV.HK Terms of Use attribution. By + default nearly the full TD catalog (~1,013 cameras; cap 1,100) loads, + prioritized across Hong Kong Island, Kowloon and the New Territories. Headings + use the shared id-hash fallback at low confidence. `CCTV_HK_MAX_SOURCES` sets + the cap (8–1,200) and `CCTV_HK_ENABLED=0` turns the pack off (JCY2515, #990). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 1c64f60fe..7d3ff3ac7 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -3478,7 +3478,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 | Selectable TomTom / OpenStreetMap / Hybrid roads; optional TomTom flow (BYOK) | `src/data/traffic.js` | browser-direct OpenFreeMap tiles; `/api/tomtom` for flow | viewport-driven; capped tile cache | -| CCTV | Austin + Caltrans (CA) + TfL London + Ontario 511 + Fintraffic (FI) + DriveBC (BC) + TxDOT (TX) + Estonia (Tallinn, Tarktee) + Live Traffic NSW + Open Calgary Open Data + Statens vegvesen (NO, stills + live HLS, Kartverket ground heights) + King County (WA) Open Data + WSDOT (WA) Open Data + NYC DOT TMC (opt-in) + City of Toronto RESCU + Catalonia (Servei Català de Trànsit) + FL511 (FL, opt-in) + Freeway Bureau, MOTC (Taiwan) + Street View fallback | `src/data/cctv.js` | `/api/cctv` | 10s (active) | +| CCTV | Austin + Caltrans (CA) + TfL London + Ontario 511 + Fintraffic (FI) + DriveBC (BC) + TxDOT (TX) + Estonia (Tallinn, Tarktee) + Live Traffic NSW + Open Calgary Open Data + Statens vegvesen (NO, stills + live HLS, Kartverket ground heights) + King County (WA) Open Data + WSDOT (WA) Open Data + NYC DOT TMC (opt-in) + City of Toronto RESCU + Catalonia (Servei Català de Trànsit) + Hong Kong Transport Department + FL511 (FL, opt-in) + Freeway Bureau, MOTC (Taiwan) + 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 | | Transit 🚌 | Operator GTFS-Realtime VehiclePositions (7 keyless regions, `src/data/transitFeeds.js`) | `src/layers/transit/` via `src/app/layers/transit.js` | `/api/transit` | 15s (poll + delayed playback) | | Bikeshare 🚲 | GBFS (Lyft + BCycle + Social Bicycles + PBSC incl. Bike Share Toronto, Bike Itaú (Brazil), Bicing (Barcelona) and seven other Spanish cities + Urban Sharing, Entur, JCDecaux (Lillestrøm, Valencia, Seville) + nextbike (AMBici)) | `src/data/bikeshare.js` | `/api/gbfs` | 60s | diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 27fd3d4b3..83e961d8b 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -552,6 +552,7 @@ "src/data/cctvWarendorf.test.mjs", "src/data/cctvCalgary.test.mjs", "src/data/cctvCataloniaSource.test.mjs", + "src/data/cctvHk.test.mjs", "src/data/cctvVegvesen.test.mjs", "src/data/cctvNyc.test.mjs", "src/data/cctvNswSource.test.mjs", diff --git a/server/providers/cctv/catalog.js b/server/providers/cctv/catalog.js index 94a377f16..747aae4f8 100644 --- a/server/providers/cctv/catalog.js +++ b/server/providers/cctv/catalog.js @@ -20,6 +20,7 @@ import { loadNswSourcesFromOpenData, loadCalgarySourcesFromOpenData, loadCataloniaSourcesFromOpenData, + loadHkSourcesFromOpenData, loadDelDOTSourcesFromOpenData, loadKingCountySourcesFromOpenData, loadWsdotSourcesFromOpenData, @@ -107,6 +108,11 @@ const LIVE_PACKS = [ enabled: () => envEnabled('CCTV_CATALONIA_ENABLED'), load: loadCataloniaSourcesFromOpenData, }, + { + name: 'hk', + enabled: () => envEnabled('CCTV_HK_ENABLED'), + load: loadHkSourcesFromOpenData, + }, { name: 'deldot', enabled: () => envEnabled('CCTV_DELDOT_ENABLED'), diff --git a/server/providers/cctv/constants.js b/server/providers/cctv/constants.js index 18b9f0c9d..257c292b3 100644 --- a/server/providers/cctv/constants.js +++ b/server/providers/cctv/constants.js @@ -309,6 +309,36 @@ export const CATALONIA_ANCHORS = [ { lat: 41.5636, lon: 2.0111 }, // Terrassa ]; +/** Hong Kong Transport Department traffic snapshot cameras: keyless English + * locations XML on DATA.GOV.HK; frames are stills on the TD CCTV host. */ +export const DEFAULT_HK_CAMERAS_URL = + 'https://static.data.gov.hk/td/traffic-snapshot-images/code/Traffic_Camera_Locations_En.xml'; +/** The only origin HK camera frames may come from. Verified 2026-10-11: still + * URLs answer 200 on this host with no redirect hop. */ +export const HK_IMAGE_ORIGIN = 'https://tdcctv.data.one.gov.hk/'; +/** Above the full TD English catalog (~1,013 cameras) so a default install + * keeps nearly all of them. Catalog-wide round-robin still applies when the + * sum of packs exceeds DEFAULT_CCTV_MAX_SOURCES (Norway-style). */ +export const DEFAULT_HK_MAX_SOURCES = 1100; +/** Prioritization anchors across Hong Kong Island, Kowloon and the New + * Territories so a lowered cap keeps territory-wide coverage rather than + * only the harbour corridor. */ +export const HK_ANCHORS = [ + { lat: 22.2819, lon: 114.158 }, // Central + { lat: 22.2976, lon: 114.1722 }, // Tsim Sha Tsui + { lat: 22.3827, lon: 114.19 }, // Sha Tin + { lat: 22.3701, lon: 114.114 }, // Tsuen Wan + { lat: 22.4503, lon: 114.1688 }, // Tai Po + { lat: 22.493, lon: 114.139 }, // Fanling + { lat: 22.391, lon: 113.977 }, // Tuen Mun + { lat: 22.4445, lon: 114.022 }, // Yuen Long + { lat: 22.307, lon: 114.26 }, // Tseung Kwan O +]; +/** Hard ceiling on the locations XML body. The live English list is ~400 KB + * for ~1,000 cameras; this only exists so an unbounded upstream cannot be + * buffered without limit. */ +export const HK_MAX_CATALOG_BYTES = 4 * 1024 * 1024; + /** Statens vegvesen (Norway) road cameras: the keyless OGC API Features view * of the official DATEX 3.1 CCTV site table, one GeoJSON request for the whole * country (~900 cameras). The raw DATEX node needs an account; this view does diff --git a/server/providers/cctv/normalize.js b/server/providers/cctv/normalize.js index 37d846c1c..0f84fc3e1 100644 --- a/server/providers/cctv/normalize.js +++ b/server/providers/cctv/normalize.js @@ -399,6 +399,17 @@ export function isLikelyNycCoordinate(lat, lon) { ); } +/** Hong Kong SAR extent, with slack for the outlying islands. */ +export function isLikelyHongKongCoordinate(lat, lon) { + return ( + isPlausibleLatLon(lat, lon) && + lat >= 22.15 && + lat <= 22.6 && + lon >= 113.8 && + lon <= 114.45 + ); +} + /** Mainland Norway, with slack for the coast and the Finnmark border. */ export function isLikelyNorwayCoordinate(lat, lon) { return ( diff --git a/server/providers/cctv/sources.js b/server/providers/cctv/sources.js index 57b8d23bf..fbd9cb99d 100644 --- a/server/providers/cctv/sources.js +++ b/server/providers/cctv/sources.js @@ -76,6 +76,11 @@ import { TORONTO_DOWNTOWN, TORONTO_GROUND_ELEVATION_M, TORONTO_MAX_CATALOG_BYTES, + DEFAULT_HK_CAMERAS_URL, + HK_IMAGE_ORIGIN, + DEFAULT_HK_MAX_SOURCES, + HK_ANCHORS, + HK_MAX_CATALOG_BYTES, CCTV_SOURCE_FETCH_TIMEOUT_MS, TARKTEE_FETCH_TIMEOUT_MS, DELDOT_CCTV_URL, @@ -138,6 +143,7 @@ import { isLikelyCataloniaCoordinate, decodeNumericEntities, isLikelyNycCoordinate, + isLikelyHongKongCoordinate, isLikelyNorwayCoordinate, isLikelyTorontoCoordinate, cameraDisplayCode, @@ -2003,6 +2009,226 @@ export async function loadCataloniaSourcesFromOpenData() { } } +/** + * Read one XML child tag's text from an `` block. + * + * @param {string} body + * @param {string} tag + * @returns {string} + */ +function hkXmlTag(body, tag) { + const match = new RegExp(`<${tag}>\\s*([^<]*?)\\s*`, 'i').exec( + String(body || ''), + ); + return match ? match[1].trim() : ''; +} + +/** + * Parse the Transport Department English camera-locations XML into rows. + * + * Schema-specific: each `` carries key, description, lat/lon and a + * still URL. No XML dependency — same regex style as the Tarktee DATEX parser. + * + * @param {string} xml + * @returns {Array} + */ +export function parseHkCameraLocationsXml(xml) { + const rows = []; + const blockRe = /([\s\S]*?)<\/image>/gi; + let match; + while ((match = blockRe.exec(String(xml || ''))) !== null) { + const body = match[1]; + rows.push({ + key: hkXmlTag(body, 'key'), + region: hkXmlTag(body, 'region'), + district: hkXmlTag(body, 'district'), + description: hkXmlTag(body, 'description'), + latitude: hkXmlTag(body, 'latitude'), + longitude: hkXmlTag(body, 'longitude'), + url: hkXmlTag(body, 'url'), + }); + } + return rows; +} + +/** + * Build a host-pinned still URL from a Transport Department camera key. + * + * The catalog also publishes a URL; the registered frame is always rebuilt + * from the key so a catalog edit cannot steer the proxy off-origin. + * + * @param {string|null|undefined} key + * @returns {?string} + */ +export function hkImageUrlFromKey(key) { + const id = String(key ?? '') + .trim() + .toUpperCase(); + if (!/^[A-Z0-9]{1,32}$/.test(id)) return null; + return `${HK_IMAGE_ORIGIN}${id}.JPG`; +} + +/** + * Pin a catalog frame URL to the Transport Department still host. + * + * @param {string|null|undefined} raw + * @returns {?string} + */ +export function normalizeHkImageUrl(raw) { + const text = String(raw ?? '').trim(); + if (!text) return null; + let parsed; + try { + parsed = new URL(text); + } catch { + return null; + } + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return null; + parsed.protocol = 'https:'; + const upgraded = parsed.toString(); + return upgraded.startsWith(HK_IMAGE_ORIGIN) ? upgraded : null; +} + +/** + * Label for one HK camera: the published English description, else the key. + * + * @param {object} record + * @param {string} cameraId + * @returns {string} + */ +export function hkCameraName(record, cameraId) { + const description = String(record?.description ?? '').trim(); + if (description) return description; + const key = String(record?.key ?? '').trim(); + if (key) return key; + return `HK Camera ${String(cameraId).replace(/^hk-/, '')}`; +} + +/** + * One Transport Department locations row -> one catalog source, or null. + * + * The dataset publishes no camera facing, so headings use the shared id-hash + * fallback at low confidence and are corrected with the calibration gizmo. + * + * @param {object} record + * @returns {?object} + */ +export function hkCameraToSource(record) { + if (!record || typeof record !== 'object') return null; + const key = String(record.key ?? '') + .trim() + .toUpperCase(); + if (!key) return null; + const lat = toFiniteNumber(record.latitude); + const lon = toFiniteNumber(record.longitude); + if (!isLikelyHongKongCoordinate(lat, lon)) return null; + + const imageUrl = hkImageUrlFromKey(key); + if (!imageUrl) return null; + // If the catalog carries a URL, it must already be on the pinned host. + const catalogUrl = String(record.url ?? '').trim(); + if (catalogUrl) { + const pinnedCatalog = normalizeHkImageUrl(catalogUrl); + if (!pinnedCatalog) return null; + } + + const cameraId = `hk-${key.toLowerCase()}`; + const name = hkCameraName(record, cameraId); + const region = String(record.region ?? '').trim(); + + return { + id: cameraId, + name, + city: region || 'Hong Kong', + cityId: 'hk', + provider: 'Transport Department', + lat, + lon, + headingDeg: fallbackHeadingFromId(cameraId), + headingConfidence: 'low', + pitchDeg: -18, + fovDeg: 44, + rangeM: 145, + mountHeightM: 8, + // Coastal / harbour prior; the client's one-shot ground snap corrects. + groundElevationM: 15, + feedType: 'image', + url: imageUrl, + snapshotUrl: imageUrl, + sourceKind: 'hk-td-open-data', + license: + 'Hong Kong Transport Department / DATA.GOV.HK — attribution required', + code: cameraDisplayCode(name.toUpperCase()), + }; +} + +/** + * Fetch Hong Kong Transport Department traffic snapshot cameras from the + * keyless DATA.GOV.HK English locations XML. Frames are stills on + * tdcctv.data.one.gov.hk. + * + * @returns {Promise>} Normalized camera source objects. + */ +export async function loadHkSourcesFromOpenData() { + try { + const endpoint = process.env.CCTV_HK_CAMERAS_URL || DEFAULT_HK_CAMERAS_URL; + const resp = await fetch(endpoint, { + headers: { Accept: 'application/xml,text/xml,*/*' }, + redirect: 'manual', + signal: AbortSignal.timeout(CCTV_SOURCE_FETCH_TIMEOUT_MS), + }); + const discard = async () => { + try { + await resp.body?.cancel(); + } catch { + /* no-op */ + } + return []; + }; + if (resp.status >= 300 && resp.status < 400) { + console.warn('[CCTV] HK catalog redirected; redirects are not followed'); + return discard(); + } + if (!resp.ok) { + console.warn('[CCTV] HK camera download failed:', resp.status); + return discard(); + } + let xml; + try { + xml = await readResponseTextCapped(resp, HK_MAX_CATALOG_BYTES); + } catch (error) { + if (error?.code === 'RESPONSE_TOO_LARGE') { + console.warn('[CCTV] HK catalog body exceeded byte ceiling'); + return []; + } + throw error; + } + const rows = parseHkCameraLocationsXml(xml); + const cameras = []; + const seen = new Set(); + for (const record of rows) { + const camera = hkCameraToSource(record); + if (!camera || seen.has(camera.id)) continue; + seen.add(camera.id); + cameras.push(camera); + } + const maxRaw = Number( + process.env.CCTV_HK_MAX_SOURCES || DEFAULT_HK_MAX_SOURCES, + ); + const maxCount = Number.isFinite(maxRaw) + ? Math.max(8, Math.min(1200, Math.floor(maxRaw))) + : DEFAULT_HK_MAX_SOURCES; + const prioritized = prioritizeSources(cameras, maxCount, HK_ANCHORS); + console.log( + `[CCTV] Loaded HK camera sources: ${cameras.length} (using nearest ${prioritized.length})`, + ); + return prioritized; + } catch (error) { + console.warn('[CCTV] HK camera download error:', error?.message || error); + return []; + } +} + export async function loadDelDOTSourcesFromOpenData() { try { const resp = await fetch(DELDOT_CCTV_URL, { diff --git a/src/data/cctvHk.test.mjs b/src/data/cctvHk.test.mjs new file mode 100644 index 000000000..ecdff231f --- /dev/null +++ b/src/data/cctvHk.test.mjs @@ -0,0 +1,464 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +import { + hkCameraName, + hkCameraToSource, + hkImageUrlFromKey, + loadHkSourcesFromOpenData, + normalizeHkImageUrl, + parseHkCameraLocationsXml, +} from '../../server/providers/cctv/sources.js'; +import { + DEFAULT_CCTV_MAX_SOURCES, + DEFAULT_HK_CAMERAS_URL, + DEFAULT_HK_MAX_SOURCES, + HK_ANCHORS, + HK_IMAGE_ORIGIN, + HK_MAX_CATALOG_BYTES, +} from '../../server/providers/cctv/constants.js'; +import { + CAMERA_CODE_MAX_CHARS, + prioritizeSources, +} from '../../server/providers/cctv/normalize.js'; +import { allocateSourceCap } from '../../server/providers/cctv/cap.js'; +import { createCctvCatalog } from '../../server/providers/cctv/catalog.js'; + +/** + * A response whose body is a live stream, plus a flag that flips when the + * stream is cancelled. A rejection path that returns without cancelling holds + * the transport open, so the flag is what the refusal tests actually assert. + */ +const streamingResponse = (init = {}) => { + const state = { cancelled: false }; + const body = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode('')); + }, + cancel() { + state.cancelled = true; + }, + }); + return { response: new Response(body, init), state }; +}; + +/** One Transport Department `` block as a parsed row. */ +const row = (overrides = {}) => ({ + key: 'BC101F', + region: 'New Territories', + district: 'North', + description: 'San Tin Highway near Lok Ma Chau [BC101F]', + latitude: '22.508', + longitude: '114.078', + url: 'https://tdcctv.data.one.gov.hk/BC101F.JPG', + ...overrides, +}); + +/** Fixture XML matching the published English locations schema. */ +const fixtureXml = (...records) => { + const images = records + .map( + (record) => ` + + ${record.key} + ${record.region} + ${record.district} + ${record.description} + ${record.latitude} + ${record.longitude} + ${record.url} + `, + ) + .join(''); + return `\n${images}\n\n`; +}; + +test('an HK row maps to a source on the pinned HTTPS frame host', () => { + const source = hkCameraToSource(row()); + assert.equal(source.id, 'hk-bc101f'); + assert.equal(source.name, 'San Tin Highway near Lok Ma Chau [BC101F]'); + assert.equal(source.cityId, 'hk'); + assert.equal(source.provider, 'Transport Department'); + assert.equal(source.lat, 22.508); + assert.equal(source.lon, 114.078); + assert.equal(source.feedType, 'image'); + assert.equal(source.sourceKind, 'hk-td-open-data'); + assert.equal( + source.license, + 'Hong Kong Transport Department / DATA.GOV.HK — attribution required', + ); + assert.equal(source.headingConfidence, 'low'); + assert.ok(Number.isFinite(source.headingDeg)); + assert.equal(source.url, 'https://tdcctv.data.one.gov.hk/BC101F.JPG'); + assert.equal(source.url, source.snapshotUrl); + assert.ok(source.url.startsWith(HK_IMAGE_ORIGIN)); +}); + +test('rows outside Hong Kong, off-host frames and unusable keys are dropped', () => { + assert.equal( + hkCameraToSource(row({ latitude: '43.6532', longitude: '-79.3832' })), + null, + ); + assert.equal(hkCameraToSource(row({ latitude: '0', longitude: '0' })), null); + assert.equal(hkCameraToSource(row({ key: '' })), null); + assert.equal(hkCameraToSource(row({ key: 'bad key!' })), null); + assert.equal( + hkCameraToSource(row({ url: 'https://evil.example/BC101F.JPG' })), + null, + ); + assert.equal( + hkCameraToSource( + row({ + url: 'https://tdcctv.data.one.gov.hk.evil.test/BC101F.JPG', + }), + ), + null, + ); + assert.equal(hkCameraToSource(row({ url: 'file:///etc/passwd' })), null); + assert.equal(hkCameraToSource(null), null); +}); + +test('frame URLs are rebuilt from the key and pinned to the TD host', () => { + assert.equal( + hkImageUrlFromKey('bc101f'), + 'https://tdcctv.data.one.gov.hk/BC101F.JPG', + ); + assert.equal(hkImageUrlFromKey(''), null); + assert.equal(hkImageUrlFromKey('bad/key'), null); + + assert.equal( + normalizeHkImageUrl('http://tdcctv.data.one.gov.hk/H429F.JPG'), + 'https://tdcctv.data.one.gov.hk/H429F.JPG', + ); + assert.equal( + normalizeHkImageUrl('https://tdcctv.data.one.gov.hk/H429F.JPG'), + 'https://tdcctv.data.one.gov.hk/H429F.JPG', + ); + assert.equal(normalizeHkImageUrl(''), null); + assert.equal(normalizeHkImageUrl('not a url'), null); + assert.equal(normalizeHkImageUrl('https://evil.example/H429F.JPG'), null); +}); + +test('a nameless row still gets a label', () => { + assert.equal( + hkCameraName( + { description: ' Aberdeen Praya Road near Fish Market [H429F] ' }, + 'hk-h429f', + ), + 'Aberdeen Praya Road near Fish Market [H429F]', + ); + assert.equal(hkCameraName({ key: 'H429F' }, 'hk-h429f'), 'H429F'); + assert.equal(hkCameraName({}, 'hk-h429f'), 'HK Camera h429f'); +}); + +test('the XML parser reads each image block', () => { + const rows = parseHkCameraLocationsXml( + fixtureXml( + row(), + row({ + key: 'H429F', + region: 'Hong Kong Island', + district: 'Southern', + description: 'Aberdeen Praya Road near Fish Market [H429F]', + latitude: '22.24845', + longitude: '114.1505', + url: 'https://tdcctv.data.one.gov.hk/H429F.JPG', + }), + ), + ); + assert.equal(rows.length, 2); + assert.equal(rows[0].key, 'BC101F'); + assert.equal(rows[1].key, 'H429F'); + assert.equal(rows[1].latitude, '22.24845'); +}); + +test('the loader reads the keyless catalog and collapses duplicate ids', async (t) => { + t.mock.method(console, 'log', () => {}); + const requested = []; + t.mock.method(globalThis, 'fetch', async (url) => { + requested.push(String(url)); + return new Response( + fixtureXml( + row(), + row({ description: 'San Tin Highway near Lok Ma Chau (duplicate)' }), + row({ + key: 'H210F', + region: 'Hong Kong Island', + district: 'Wan Chai', + description: 'Aberdeen Tunnel - Wan Chai Side [H210F]', + latitude: '22.2747', + longitude: '114.17936', + url: 'https://tdcctv.data.one.gov.hk/H210F.JPG', + }), + row({ url: 'https://evil.example/x.jpg' }), + ), + { headers: { 'Content-Type': 'application/xml' } }, + ); + }); + const cameras = await loadHkSourcesFromOpenData(); + assert.deepEqual(requested, [DEFAULT_HK_CAMERAS_URL]); + assert.deepEqual( + cameras.map((camera) => camera.id), + // Nearest Central/TST first: Wan Chai before the northern New Territories. + ['hk-h210f', 'hk-bc101f'], + ); + assert.equal(cameras[0].name, 'Aberdeen Tunnel - Wan Chai Side [H210F]'); +}); + +test('an upstream failure yields an empty pack and releases the response', async (t) => { + t.mock.method(console, 'warn', () => {}); + const failed = streamingResponse({ status: 503 }); + t.mock.method(globalThis, 'fetch', async () => failed.response); + assert.deepEqual(await loadHkSourcesFromOpenData(), []); + assert.equal(failed.state.cancelled, true, 'the failed body is cancelled'); + + t.mock.restoreAll(); + t.mock.method(console, 'warn', () => {}); + t.mock.method(globalThis, 'fetch', async () => { + throw new Error('network down'); + }); + assert.deepEqual(await loadHkSourcesFromOpenData(), []); +}); + +test('the unselected label is the description, trimmed to the code width', () => { + assert.equal( + hkCameraToSource(row({ description: 'San Tin Highway [BC101F]' })).code, + 'SAN TIN HIGHWAY [BC101F]', + ); + const long = hkCameraToSource( + row({ + description: + 'Bow Trail & Old Banff Coach Rd SW / Strathcona Blvd SW [BC101F]', + }), + ); + assert.equal(long.code.length, CAMERA_CODE_MAX_CHARS); + assert.ok(long.code.endsWith('…')); + assert.ok(long.code.startsWith('BOW TRAIL & OLD BANFF')); +}); + +test('the catalog fetch refuses redirects and oversized bodies', async (t) => { + t.mock.method(console, 'warn', () => {}); + const seen = []; + const redirected = streamingResponse({ + status: 302, + headers: { location: 'https://evil.example/cameras.xml' }, + }); + t.mock.method(globalThis, 'fetch', async (url, init) => { + seen.push([String(url), init.redirect]); + return redirected.response; + }); + assert.deepEqual(await loadHkSourcesFromOpenData(), []); + assert.deepEqual(seen, [[DEFAULT_HK_CAMERAS_URL, 'manual']]); + assert.equal( + redirected.state.cancelled, + true, + 'the redirect body is cancelled', + ); + + t.mock.restoreAll(); + t.mock.method(console, 'warn', () => {}); + t.mock.method(globalThis, 'fetch', async () => { + const body = fixtureXml(row()); + return new Response(body, { + headers: { + 'Content-Type': 'application/xml', + 'content-length': String(HK_MAX_CATALOG_BYTES + 1), + }, + }); + }); + assert.deepEqual(await loadHkSourcesFromOpenData(), []); + + t.mock.restoreAll(); + t.mock.method(console, 'warn', () => {}); + t.mock.method(globalThis, 'fetch', async () => { + const oversized = 'x'.repeat(HK_MAX_CATALOG_BYTES + 1024); + return new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(oversized)); + controller.close(); + }, + }), + { headers: { 'Content-Type': 'application/xml' } }, + ); + }); + assert.deepEqual(await loadHkSourcesFromOpenData(), []); +}); + +test('a reduced catalog cap thins every pack instead of dropping HK', () => { + const lane = (name, count) => ({ + name, + sources: Array.from({ length: count }, (_, i) => ({ id: `${name}-${i}` })), + }); + const { sources, packs } = allocateSourceCap( + [ + lane('austin', 250), + lane('calgary', 215), + lane('hk', DEFAULT_HK_MAX_SOURCES), + ], + 30, + ); + assert.equal(sources.length, 30); + assert.deepEqual( + packs.map((p) => [p.name, p.kept]), + [ + ['austin', 10], + ['calgary', 10], + ['hk', 10], + ], + ); + assert.deepEqual( + sources.filter((s) => s.id.startsWith('hk-')).map((s) => s.id), + Array.from({ length: 10 }, (_, i) => `hk-${i}`), + ); +}); + +/** + * Serve the HK catalog to the HK endpoint and an empty payload to every other + * pack, so one catalog refresh exercises the registration without reaching the + * network. Returns the URLs that were requested. + */ +const runCatalogWithMockedUpstreams = async (t) => { + const requested = []; + t.mock.method(console, 'log', () => {}); + t.mock.method(console, 'warn', () => {}); + t.mock.method(globalThis, 'fetch', async (url) => { + const href = String(url); + requested.push(href); + if (href.includes('Traffic_Camera_Locations_En.xml')) { + return new Response( + fixtureXml( + row(), + row({ + key: 'H210F', + region: 'Hong Kong Island', + district: 'Wan Chai', + description: 'Aberdeen Tunnel - Wan Chai Side [H210F]', + latitude: '22.2747', + longitude: '114.17936', + url: 'https://tdcctv.data.one.gov.hk/H210F.JPG', + }), + ), + { headers: { 'Content-Type': 'application/xml' } }, + ); + } + return Response.json([]); + }); + const sources = await createCctvCatalog({ sourceRoot: '/nonexistent' })(); + return { requested, sources }; +}; + +test('the HK lane is wired into the catalog and its loader runs', async (t) => { + const saved = { ...process.env }; + try { + delete process.env.CCTV_SOURCES_FILE; + delete process.env.CCTV_SOURCES_JSON; + delete process.env.CCTV_HK_ENABLED; + const { requested, sources } = await runCatalogWithMockedUpstreams(t); + assert.ok( + requested.includes(DEFAULT_HK_CAMERAS_URL), + 'the catalog refresh invokes the HK loader', + ); + assert.deepEqual( + sources.filter((s) => s.cityId === 'hk').map((s) => s.id), + ['hk-h210f', 'hk-bc101f'], + 'HK cameras reach the served catalog through the registered lane', + ); + } finally { + for (const key of Object.keys(process.env)) { + if (!(key in saved)) delete process.env[key]; + } + Object.assign(process.env, saved); + } +}); + +test('CCTV_HK_ENABLED=0 keeps the lane from being loaded at all', async (t) => { + const saved = { ...process.env }; + try { + delete process.env.CCTV_SOURCES_FILE; + delete process.env.CCTV_SOURCES_JSON; + process.env.CCTV_HK_ENABLED = '0'; + const { requested, sources } = await runCatalogWithMockedUpstreams(t); + assert.equal( + requested.includes(DEFAULT_HK_CAMERAS_URL), + false, + 'the disabled lane never reaches its upstream', + ); + assert.deepEqual( + sources.filter((s) => s.cityId === 'hk'), + [], + ); + } finally { + for (const key of Object.keys(process.env)) { + if (!(key in saved)) delete process.env[key]; + } + Object.assign(process.env, saved); + } +}); + +test('the shipped catalog ceiling is not raised to make room for this pack', () => { + assert.equal(DEFAULT_CCTV_MAX_SOURCES, 4000); +}); + +test('the HK pack default sits above the full TD catalog and anchors the New Territories', () => { + assert.ok( + DEFAULT_HK_MAX_SOURCES >= 1013, + 'default cap should keep nearly all published TD cameras', + ); + assert.ok( + DEFAULT_HK_MAX_SOURCES <= DEFAULT_CCTV_MAX_SOURCES, + 'a single pack must not outgrow the catalog-wide ceiling alone', + ); + const places = HK_ANCHORS.map( + (a) => `${a.lat.toFixed(2)},${a.lon.toFixed(2)}`, + ); + assert.ok(places.includes('22.38,114.19'), 'Sha Tin anchor'); + assert.ok(places.includes('22.37,114.11'), 'Tsuen Wan anchor'); + assert.ok(places.includes('22.45,114.17'), 'Tai Po anchor'); + assert.ok(places.includes('22.49,114.14'), 'Fanling anchor'); + assert.ok(places.includes('22.39,113.98'), 'Tuen Mun anchor'); + assert.ok(places.includes('22.44,114.02'), 'Yuen Long anchor'); + assert.ok(places.includes('22.31,114.26'), 'Tseung Kwan O anchor'); + assert.ok(places.includes('22.28,114.16'), 'Central anchor'); + assert.ok(places.includes('22.30,114.17'), 'Tsim Sha Tsui anchor'); +}); + +test('a lowered HK cap still keeps Sha Tin when New Territories anchors are set', () => { + const cameras = [ + hkCameraToSource( + row({ + key: 'H001F', + region: 'Hong Kong Island', + district: 'Central & Western', + description: 'Central harbour [H001F]', + latitude: '22.2819', + longitude: '114.158', + }), + ), + hkCameraToSource( + row({ + key: 'ST101F', + region: 'New Territories', + district: 'Sha Tin', + description: 'Sha Tin Road [ST101F]', + latitude: '22.3827', + longitude: '114.19', + }), + ), + hkCameraToSource( + row({ + key: 'K999F', + region: 'Kowloon', + district: 'Yau Tsim Mong', + description: 'Far Kowloon filler [K999F]', + latitude: '22.32', + longitude: '114.18', + }), + ), + ].filter(Boolean); + const kept = prioritizeSources(cameras, 2, HK_ANCHORS); + assert.deepEqual(kept.map((camera) => camera.id).sort(), [ + 'hk-h001f', + 'hk-st101f', + ]); +}); diff --git a/src/data/dataCredits.js b/src/data/dataCredits.js index 7b6cba9b7..a86f43465 100644 --- a/src/data/dataCredits.js +++ b/src/data/dataCredits.js @@ -297,6 +297,13 @@ export const DATA_CREDITS = [ 'KCGIS Center terms. ' + 'Partner-city cameras hosted on the county image handler (e.g. Newcastle) name their owner in the CCTV panel.', }, + { + key: 'hk-cctv', + html: + 'Traffic snapshot cameras (Hong Kong): ' + + 'Transport Department / DATA.GOV.HK ' + + '(Terms of Use — attribution required)', + }, { key: 'vegvesen-cctv', html: