From 021e887f94929578544b8654eb68d77b99a0b023 Mon Sep 17 00:00:00 2001 From: daika Date: Sat, 7 Nov 2026 12:57:08 +0700 Subject: [PATCH 1/3] =?UTF-8?q?feat(cctv):=20add=20Iceland=20(Vegager?= =?UTF-8?q?=C3=B0in=20/=20IRCA)=20as=20a=20keyless=20CCTV=20source=20pack?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One JSON list from gagnaveita.vegagerdin.is (about 500 camera views at roughly 170 road stations). Frames are JPEG stills in a single directory on www.vegagerdin.is; only links inside that directory are registered, and the media proxy pins the same directory. The list decides ids, coordinates and which frame path is proxied, so it is fetched over https with redirects refused and the body read capped at 2 MiB; every rejection path releases the response body. Camera ids come from the frame file name, which is unique per view in the live feed, and repeated frames collapse before the cap. The feed's view description (`Skyring`) is the label. An explicit Icelandic facing ("til vesturs", "í suður", "séð austur") sets a high-confidence heading (264 of 499 views); a view that states none, or two different ones, keeps the id-hash low-confidence pose. A bare road direction ("Miklabraut austur") is not read as a facing. IRCA publishes road camera images under CC BY 4.0 and asks that the licensor is acknowledged without implying endorsement; the attribution is in the Data attribution popover and DATA_SOURCES.md, and every camera carries the licence string. Default cap is 250 with anchors at Reykjavík and four regional centres so a cap keeps every region. CCTV_ICELAND_ENABLED=0 turns the pack off. Verified against the live feed through fetchCctvImageFromUpstream: 499 of 499 frames returned a JPEG, all distinct, all under 20 minutes old. --- .env.example | 2 + DATA_SOURCES.md | 2 + README.md | 2 +- docs/CURRENT-STATE.md | 2 +- server/providers/cctv/catalog.js | 6 + server/providers/cctv/constants.js | 31 +++ server/providers/cctv/media.js | 2 + server/providers/cctv/normalize.js | 11 + server/providers/cctv/sources.js | 164 +++++++++++++++ src/data/cctvIceland.test.mjs | 327 +++++++++++++++++++++++++++++ src/data/dataCredits.js | 7 + 11 files changed, 554 insertions(+), 2 deletions(-) create mode 100644 src/data/cctvIceland.test.mjs diff --git a/.env.example b/.env.example index b3de4cefa..169ec66e3 100644 --- a/.env.example +++ b/.env.example @@ -238,6 +238,8 @@ 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_ICELAND_ENABLED=1 # set 0 to disable the Vegagerðin (Iceland) road cameras +# CCTV_ICELAND_MAX_SOURCES=250 # cap cameras loaded from Iceland (~500 views exist today) # CCTV_HK_ENABLED=1 # set 0 to disable the Hong Kong Transport Department cameras # CCTV_HK_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 diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md index 03747265d..94f581fe4 100644 --- a/DATA_SOURCES.md +++ b/DATA_SOURCES.md @@ -65,6 +65,7 @@ How to read this: | **DriveBC** (Government of British Columbia) | CCTV camera catalog + frames, British Columbia highways | [Open Government Licence – British Columbia](https://www2.gov.bc.ca/gov/content/data/open-data/open-government-licence-bc) — attribution REQUIRED | "DriveBC. Contains information licensed under the Open Government Licence – British Columbia" | | **Open Calgary** (`data.calgary.ca`) | CCTV camera catalog + frames, Calgary | [Open Government Licence – City of Calgary](https://data.calgary.ca/stories/s/Open-Calgary-Terms-of-Use/u45n-7awa) — attribution REQUIRED | "Contains information licensed under the Open Government Licence – City of Calgary" | | **Servei Català de Trànsit** (+ Ajuntament de Barcelona, Ajuntament de Terrassa) | CCTV camera catalog + frames, Catalonia | [Llicència oberta d'ús d'informació - Catalunya](https://web.gencat.cat/ca/generalitat/dades-indicadors/dades-obertes/llicencies) — attribution REQUIRED | "Servei Català de Trànsit"; partner-supplied cameras also name their owner | +| **Icelandic Road and Coastal Administration (Vegagerðin, IRCA)** | CCTV camera catalog + frames, Iceland road cameras | [CC BY 4.0](https://www.vegagerdin.is/vegagerdin/gagnasafn/vefthjonustur/terms-and-conditions) (IRCA terms of use, Act 45/2018) — attribution REQUIRED; no suggestion of IRCA endorsement; IRCA and State logos are excluded; real-time data is provided as is | "Icelandic Road and Coastal Administration (Vegagerðin)", CC BY 4.0 | | **King County Road Services** (`services.arcgis.com`, King County GIS) | CCTV camera catalog + frames, unincorporated King County WA road network | [KCGIS Center terms](https://kingcounty.gov/en/dept/kcit/data-information-services/gis-center/about/terms-conditions-copyrights) — attribution required; public ArcGIS layer | "King County Road Services" (partner-city cameras name their owner) | | **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" | @@ -178,6 +179,7 @@ The optional Local ADS-B layer and the broadcast-FM receiver use radio you recei - **Open Calgary traffic cameras.** Keyless. The camera list is the public Socrata dataset `k7p9-kppz` on `data.calgary.ca`; frames are stills on the city's own `trafficcam.calgary.ca` host, and only that host is registered. Most rows publish an `http://` frame URL and the host redirects those to HTTPS, so URLs are upgraded and pinned to that origin before registration. Frames are fetched at request time and never stored or redistributed. The dataset publishes no camera facing: the `quadrant` field and the quadrant suffix on each camera name are Calgary's address grid, not a bearing, so headings use the shared id-hash fallback at low confidence and are corrected with the in-app calibration gizmo. The licence requires the attribution statement above. Disable with `CCTV_CALGARY_ENABLED=0`. - **FL511 Florida cameras.** Off by default; set `CCTV_FLORIDA_ENABLED=1` to load them. The camera list is the keyless FL511 traffic camera layer on ArcGIS (`services.arcgis.com/.../FL511_Traffic_Cameras`), published from a Florida Division of Emergency Management account rather than by FL511 directly, and the service carries no licence text of its own. It is paged up to 2,000 rows at a time, ordered by the layer's object id and following ArcGIS's `exceededTransferLimit`, to cover the ~4,000-camera statewide set. Frames are stills on two Divas Cloud hosts (`images-dis.divas.cloud`, `snapshots.divas.cloud`); both are host-pinned and a row whose image resolves elsewhere is dropped. `CCTV_FLORIDA_MAX_SOURCES` caps the pack (default 500, anchored per region across the state). Each catalog page is read under a 2 MiB cap and redirects are refused. Frames are fetched live at request time and never stored or redistributed. FL511's terms (fl511.com/privacy#disclaimer) license its content for individual, personal use only; any other use, including a public deployment, needs FDOT's express written consent. - **Catalonia (Servei Català de Trànsit) cameras.** The camera list is the keyless `https://www.gencat.cat/transit/opendata/cameres.xml` WFS/GML feed, fetched over https with redirects refused and the body read capped at 1 MiB, since the list decides ids, coordinates and which allowlisted frame path is proxied. It carries a `font` field naming the real publisher per camera; this pack keeps SCT's own highway cameras plus hotlinked Barcelona (IMI) and Terrassa municipal cameras (~130 total), each confirmed to return a real frame, so frame URLs are checked against a small host allowlist (`mct.gencat.cat`, `www.bcn.cat`, `emap.terrassa.cat`) rather than one pinned origin (a link carrying `user:pass@` userinfo or an explicit port, or pointing outside that host's frame directory, is rejected even on an allowed host; an SCT `sctidcam` must be a bare `.gif`/`.jpg` filename), and every non-SCT camera carries its real owner as a `credit` shown beside the shared `provider`. The feed carries no compass heading, so every camera loads at low heading confidence (same as TfL and Fintraffic). SCT's own feed link (`RenderService`) needed a workaround: it always 302s to a second endpoint (`TransitCamera`) on a Location the upstream hardcodes as plain http even when requested over https — refused by `fetchWithinHost`'s same-origin (scheme-included) redirect policy — and separately, `mct.gencat.cat`'s TLS setup fails the handshake with Node's fetch outright (`ERR_SSL_WRONG_SIGNATURE_TYPE`) even though other HTTP clients complete it. The loader therefore builds the `TransitCamera` URL itself, over plain http, from `RenderService`'s `sctidcam` id, rather than registering the (broken) upstream link — the only transport this one host actually serves to this runtime. Camera ids are built from the SCT camera file or the partner image path, not the feed's `fid` (which embeds an upstream server-start stamp and changes on every upstream restart), and rows the feed lists more than once collapse to one camera. The feed carries no camera status field, so a camera its publisher has taken offline still registers and shows the publisher's own "image unavailable" still (SCT and Barcelona both serve one with HTTP 200), the same as other packs without a status field. Frames are fetched at request time and never stored or redistributed. The feed itself states no licence; the pack relies on the Generalitat de Catalunya open-data portal's terms for this dataset (the licence and attribution above). Disable with `CCTV_CATALONIA_ENABLED=0`. +- **Iceland (Vegagerðin / IRCA) cameras.** The camera list is the keyless `https://gagnaveita.vegagerdin.is/api/vefmyndavelar2014_1` JSON feed (about 500 camera views at roughly 170 stations), fetched over https with redirects refused and the body read capped at 2 MiB, since the list decides ids, coordinates and which frame path is proxied. Frames are JPEG stills in `https://www.vegagerdin.is/vgdata/vefmyndavelar/`; only links inside that directory are registered (userinfo, explicit ports and encoded path separators are rejected). Camera ids come from the frame file name. The feed's `Skyring` view description is the camera label, and a stated facing ("til vesturs", "í suður", "séð austur") sets a high-confidence heading; a view that states none, or two different ones ("niður á veg", a bare road direction), takes the id-hash low-confidence pose. The feed has no elevation, so cameras carry a flat prior that the client's ground snap corrects. IRCA publishes road camera images under CC BY 4.0 and requires the licensor to be acknowledged without implying endorsement; the pack shows the attribution above in the Data attribution popover and carries the licence string on every camera. IRCA describes the images as fetched several times per hour (a live frame's `Last-Modified` was about four minutes old on 2026-11-06) and the real-time data as provided as is, without warranty. Frames are fetched at request time and never stored or redistributed. Disable with `CCTV_ICELAND_ENABLED=0`. - **King County road cameras.** Keyless. The camera list is one public ArcGIS FeatureServer GeoJSON query (King County DOT Road Services, "King County and WSDOT Traffic Cameras"). The `outFields` list is explicit on purpose: the layer also carries asset-administration columns — including staff names — that are never fetched. Only live, active cameras with frames on the county's own `info.kingcounty.gov` image handler are registered (the feed writes `http://`; the handler serves HTTPS, so URLs are upgraded and pinned before registration). WSDOT-owned rows in the layer are skipped because their frames are WSDOT-hosted, outside the pinned image origin; partner-city cameras hosted on the county image handler (e.g. Newcastle) name their owner as the per-camera credit. The layer publishes no facing, so headings use the shared id-hash fallback at low confidence. Uniquely, the county publishes each camera's hardware `Manufacturer`/`Model` (Cohu 3950/3965, Bosch AUTODOME, Axis PTZ domes); both are carried on the source (and served by `/api/cctv/sources`) as `model` and `manufacturer` for identified-hardware consumers (vendor-generic strings such as `PTZ` name no model and are omitted). Frames are fetched at request time and never stored or redistributed. Disable with `CCTV_KINGCOUNTY_ENABLED=0`. - **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. diff --git a/README.md b/README.md index 32981b337..8af137fcb 100644 --- a/README.md +++ b/README.md @@ -440,7 +440,7 @@ Twenty-two layers and map sources. **Twenty have a keyless path.** Some offer ad | 🌍 **Earthquakes** | Global seismic activity, last 24h | USGS | 🟢 | | ◍ **Air quality** | Live AQHI on the published 1–10+ health-risk scale, with a Weather-panel card showing the band legend and the highest readings in view. **Canada only** — ECCC is the first registered provider | ECCC MSC GeoMet | 🟢 | | 🚗 **Traffic** | Simulated vehicles on OSM roads. With TomTom, live flow speeds drive the simulation and congestion colors below ~8 km; individual vehicle positions are not live observations | TomTom + OSM | 🟢 simulation · 🟡 live flow speeds | -| 📹 **CCTV Mesh** | ~4,000 public cameras (shared cap) projected _into_ the 3D space — Austin · Texas (TxDOT) · California (Caltrans) · London (TfL) · Ontario (511, free developer key) · Finland (Fintraffic) · British Columbia (DriveBC) · Estonia (Tallinn, Tarktee) · Delaware (DelDOT live video) · New South Wales (Live Traffic NSW) · Calgary · Norway (Statens vegvesen) · Toronto · King County (WA) · Washington (WSDOT) · Catalonia (Servei Català de Trànsit) · Hong Kong (Transport Department) · Taiwan (Freeway Bureau, MOTC) · Italy (A22 Brennero motorway), plus opt-in New York City (NYC DOT TMC, needs your own NYC DOT agreement), opt-in Lithuania (Via Lietuva / Eismoinfo, photo licence unconfirmed), opt-in Florida (FL511, individual personal use only; other use needs FDOT's written consent) and opt-in Houston (TranStar, enable only with TxDOT's written permission per TranStar's Content Usage terms). Positions are published; poses are estimated priors **you calibrate by dragging a gizmo on the camera itself** | City APIs | 🟢 | +| 📹 **CCTV Mesh** | ~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) · Iceland (Vegagerðin) · Hong Kong (Transport Department) · Taiwan (Freeway Bureau, MOTC) · Italy (A22 Brennero motorway), plus opt-in New York City (NYC DOT TMC, needs your own NYC DOT agreement), opt-in Lithuania (Via Lietuva / Eismoinfo, photo licence unconfirmed), opt-in Florida (FL511, individual personal use only; other use needs FDOT's written consent) and opt-in Houston (TranStar, enable only with TxDOT's written permission per TranStar's Content Usage terms). Positions are published; poses are estimated priors **you calibrate by dragging a gizmo on the camera itself** | City APIs | 🟢 | | 📷 **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/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index c91f1c439..b16fd30d1 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -4358,7 +4358,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) + Hong Kong Transport Department + FL511 (FL, opt-in) + Freeway Bureau, MOTC (Taiwan) + Houston TranStar (opt-in) + Autostrada del Brennero (IT, A22 stills) + Street View fallback | `src/data/cctv.js` | `/api/cctv` | 10s (active) | +| CCTV | Austin + Caltrans (CA) + TfL London + Ontario 511 + Fintraffic (FI) + DriveBC (BC) + TxDOT (TX) + Estonia (Tallinn, Tarktee) + Live Traffic NSW + Open Calgary Open Data + Statens vegvesen (NO, stills + live HLS, Kartverket ground heights) + King County (WA) Open Data + WSDOT (WA) Open Data + NYC DOT TMC (opt-in) + City of Toronto RESCU + Catalonia (Servei Català de Trànsit) + Iceland (Vegagerðin / IRCA) + Hong Kong Transport Department + FL511 (FL, opt-in) + Freeway Bureau, MOTC (Taiwan) + Houston TranStar (opt-in) + Autostrada del Brennero (IT, A22 stills) + Street View fallback | `src/data/cctv.js` | `/api/cctv` | 10s (active) | | Radio | Radio Browser (public-domain station directory) | `src/data/radio.js` | `/api/radio/stations`, `/api/radio/click/:uuid` | 45 min directory refresh | | 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/server/providers/cctv/catalog.js b/server/providers/cctv/catalog.js index 4d3cf63fe..b5128e02a 100644 --- a/server/providers/cctv/catalog.js +++ b/server/providers/cctv/catalog.js @@ -20,6 +20,7 @@ import { loadNswSourcesFromOpenData, loadCalgarySourcesFromOpenData, loadCataloniaSourcesFromOpenData, + loadIcelandSourcesFromOpenData, loadHkSourcesFromOpenData, loadDelDOTSourcesFromOpenData, loadKingCountySourcesFromOpenData, @@ -110,6 +111,11 @@ const LIVE_PACKS = [ enabled: () => envEnabled('CCTV_CATALONIA_ENABLED'), load: loadCataloniaSourcesFromOpenData, }, + { + name: 'iceland', + enabled: () => envEnabled('CCTV_ICELAND_ENABLED'), + load: loadIcelandSourcesFromOpenData, + }, { name: 'hk', enabled: () => envEnabled('CCTV_HK_ENABLED'), diff --git a/server/providers/cctv/constants.js b/server/providers/cctv/constants.js index e49567c1f..096a67f96 100644 --- a/server/providers/cctv/constants.js +++ b/server/providers/cctv/constants.js @@ -309,6 +309,37 @@ export const CATALONIA_ANCHORS = [ { lat: 41.5636, lon: 2.0111 }, // Terrassa ]; +/** + * Icelandic Road and Coastal Administration (Vegagerðin, IRCA) road cameras: + * one keyless JSON list, one row per camera view (about 500 views at roughly + * 170 stations, verified 2026-11-06). Frames are stills on the IRCA host, each + * rewritten by the publisher every few minutes. Camera images are published + * under CC BY 4.0 (IRCA terms of use, "Open data" section). + */ +export const ICELAND_CAMERAS_URL = + 'https://gagnaveita.vegagerdin.is/api/vefmyndavelar2014_1'; +/** The only directory Iceland camera frames may come from. */ +export const ICELAND_IMAGE_ORIGIN = + 'https://www.vegagerdin.is/vgdata/vefmyndavelar/'; +/** Byte ceiling for the Iceland catalog read (~150 KB observed 2026-11-06). */ +export const ICELAND_MAX_CATALOG_BYTES = 2 * 1024 * 1024; +/** Ground-elevation prior in metres: the feed carries none, and the country + * runs from sea level to highland passes. The client's ground snap corrects + * this on 3D-tile stacks. */ +export const ICELAND_GROUND_ELEVATION_M = 60; +/** The feed lists about 500 views; the default keeps half so the shared + * catalog cap still leaves room for denser regions. */ +export const DEFAULT_ICELAND_MAX_SOURCES = 250; +/** Prioritization anchors: Reykjavík and the regional centres, so a cap keeps + * every region's nearest cameras instead of only the capital's. */ +export const ICELAND_ANCHORS = [ + { lat: 64.1466, lon: -21.9426 }, // Reykjavík + { lat: 65.6885, lon: -18.1262 }, // Akureyri + { lat: 65.2639, lon: -14.3948 }, // Egilsstaðir + { lat: 66.0749, lon: -23.1351 }, // Ísafjörður + { lat: 63.9331, lon: -20.9971 }, // Selfoss +]; + /** Hong Kong Transport Department traffic snapshot cameras: keyless English * locations XML on DATA.GOV.HK; frames are stills on the TD CCTV host. */ export const DEFAULT_HK_CAMERAS_URL = diff --git a/server/providers/cctv/media.js b/server/providers/cctv/media.js index 7f89f2ae1..ecb423c4f 100644 --- a/server/providers/cctv/media.js +++ b/server/providers/cctv/media.js @@ -14,6 +14,7 @@ import { NSW_IMAGE_USER_AGENT, TALLINN_IMAGE_ORIGIN, TARKTEE_IMAGE_ORIGIN, + ICELAND_IMAGE_ORIGIN, TFL_IMAGE_ORIGIN, WARENDORF_IMAGE_ORIGINS, WSDOT_IMAGE_ORIGIN, @@ -523,6 +524,7 @@ for (const prefix of [ TFL_IMAGE_ORIGIN, TALLINN_IMAGE_ORIGIN, TARKTEE_IMAGE_ORIGIN, + ICELAND_IMAGE_ORIGIN, ...WARENDORF_IMAGE_ORIGINS, NSW_IMAGE_ORIGIN, CALGARY_IMAGE_ORIGIN, diff --git a/server/providers/cctv/normalize.js b/server/providers/cctv/normalize.js index e49d12ffa..a1815eedc 100644 --- a/server/providers/cctv/normalize.js +++ b/server/providers/cctv/normalize.js @@ -460,6 +460,17 @@ export function isLikelyCataloniaCoordinate(lat, lon) { ); } +/** Iceland bounding box (mainland and nearby islands, with a margin). */ +export function isLikelyIcelandCoordinate(lat, lon) { + return ( + isPlausibleLatLon(lat, lon) && + lat >= 63.0 && + lat <= 66.7 && + lon >= -25.0 && + lon <= -13.0 + ); +} + /** * Decode numeric HTML character references, decimal ("í") and hex * ("í") alike, the entity form the Catalonia feed uses for accented diff --git a/server/providers/cctv/sources.js b/server/providers/cctv/sources.js index 1ae4de637..1a7cf9f54 100644 --- a/server/providers/cctv/sources.js +++ b/server/providers/cctv/sources.js @@ -134,6 +134,12 @@ import { HOUSTON_MAX_CATALOG_BYTES, HOUSTON_GROUND_ELEVATION_M, HOUSTON_ANCHORS, + ICELAND_CAMERAS_URL, + ICELAND_IMAGE_ORIGIN, + ICELAND_MAX_CATALOG_BYTES, + ICELAND_GROUND_ELEVATION_M, + DEFAULT_ICELAND_MAX_SOURCES, + ICELAND_ANCHORS, } from './constants.js'; import { toFiniteNumber, @@ -152,6 +158,7 @@ import { isLikelyCalgaryCoordinate, isLikelyFloridaCoordinate, isLikelyCataloniaCoordinate, + isLikelyIcelandCoordinate, decodeNumericEntities, isLikelyNycCoordinate, isLikelyHoustonCoordinate, @@ -2029,6 +2036,163 @@ export async function loadCataloniaSourcesFromOpenData() { } } +/** + * Compass headings for Icelandic direction words, as they appear after "til" + * (genitive: "til vesturs"), "í" (accusative: "í vestur") or "séð" ("seen", + * as in "séð austur"). + */ +const ICELAND_DIRECTION_HEADINGS = { + norður: 0, + norðaustur: 45, + austur: 90, + suðaustur: 135, + suður: 180, + suðvestur: 225, + vestur: 270, + norðvestur: 315, +}; +const ICELAND_DIRECTION_RE = + /(?", "í <átt>" or "séð <átt>" counts: a bare place name or + * a road direction ("Miklabraut austur") is not a facing. A description naming two + * different directions is ambiguous and yields NaN, and "niður á veg" (down + * onto the road) states none. + * + * @param {string} text - The feed's `Skyring` field. + * @returns {number} Heading in degrees [0..360), or NaN. + */ +export function icelandHeadingFromDescription(text) { + const headings = new Set(); + for (const match of String(text || '') + .toLowerCase() + .matchAll(ICELAND_DIRECTION_RE)) { + headings.add(ICELAND_DIRECTION_HEADINGS[match[1]]); + } + return headings.size === 1 ? headings.values().next().value : NaN; +} + +/** + * One Vegagerðin camera row -> one catalog source, or null. A row is one camera + * view; a station (`Maelist_nr`) carries several. The id comes from the frame + * file name, which is unique per view in the live feed. + * + * @param {object} row - Element of the `vefmyndavelar2014_1` array. + * @returns {?object} + */ +export function icelandCameraToSource(row) { + const url = pinnedCameraUrl(row?.Slod, ICELAND_IMAGE_ORIGIN); + if (!url || !/\.jpe?g$/i.test(new URL(url).pathname)) return null; + // Numbers only: Number(null) and Number('') are 0. + const lat = typeof row.Breidd === 'number' ? row.Breidd : NaN; + const lon = typeof row.Lengd === 'number' ? row.Lengd : NaN; + if (!isLikelyIcelandCoordinate(lat, lon)) return null; + const stem = new URL(url).pathname + .slice(new URL(ICELAND_IMAGE_ORIGIN).pathname.length) + .replace(/\.jpe?g$/i, '') + .toLowerCase(); + if (!/^[a-z0-9_-]+$/.test(stem)) return null; + const cameraId = `is-vegagerdin-${stem}`; + const description = String(row.Skyring || '') + .replace(/\s+/g, ' ') + .replace(/\b(\p{L}+) \1\b/giu, '$1') + .trim(); + const station = String(row.Myndavel || '').trim(); + const heading = icelandHeadingFromDescription(description); + const hasHeading = Number.isFinite(heading); + return { + id: cameraId, + name: description || station || `Vegagerðin ${row.Maelist_nr}`, + city: 'Iceland', + cityId: 'iceland', + provider: 'Vegagerðin (IRCA)', + lat, + lon, + headingDeg: hasHeading ? heading : fallbackHeadingFromId(cameraId), + headingConfidence: hasHeading ? 'high' : 'low', + pitchDeg: hasHeading ? -24 : -18, + fovDeg: hasHeading ? 56 : 44, + rangeM: hasHeading ? 210 : 145, + mountHeightM: hasHeading ? 10 : 8, + groundElevationM: ICELAND_GROUND_ELEVATION_M, + feedType: 'image', + url, + snapshotUrl: url, + sourceKind: 'iceland-vegagerdin', + license: + 'Icelandic Road and Coastal Administration (IRCA) — road camera images, CC BY 4.0', + code: cameraDisplayCode(station.toUpperCase() || description), + }; +} + +/** + * Fetch Icelandic road cameras (Vegagerðin / IRCA), keyless: one JSON list. + * Frames are stills on the IRCA host and the loader registers only links + * inside that host's camera directory (ICELAND_IMAGE_ORIGIN). The list decides + * ids, coordinates, labels and which frame path is proxied, so it is fetched + * over https, redirects are refused and the body read is capped at + * ICELAND_MAX_CATALOG_BYTES. + * + * @returns {Promise>} Normalized camera source objects. + */ +export async function loadIcelandSourcesFromOpenData() { + try { + const resp = await fetch(ICELAND_CAMERAS_URL, { + headers: { + Accept: 'application/json', + 'User-Agent': 'gods-eye-view-cctv-proxy/1.0', + }, + redirect: 'manual', + signal: AbortSignal.timeout(CCTV_SOURCE_FETCH_TIMEOUT_MS), + }); + // A response this loader will not read still owns its transport until the + // body is released, so every rejection path cancels before returning. + const discard = async () => { + try { + await resp.body?.cancel(); + } catch { + /* no-op */ + } + return []; + }; + if (resp.status >= 300 && resp.status < 400) { + console.warn( + '[CCTV] Iceland catalog redirected; redirects are not followed', + ); + return discard(); + } + if (!resp.ok) { + console.warn('[CCTV] Iceland camera download failed:', resp.status); + return discard(); + } + const body = await readResponseJsonCapped(resp, ICELAND_MAX_CATALOG_BYTES); + const rows = Array.isArray(body) ? body : []; + const cameras = rows.map(icelandCameraToSource).filter(Boolean); + const unique = Array.from( + new Map(cameras.map((camera) => [camera.id, camera])).values(), + ); + const maxRaw = Number( + process.env.CCTV_ICELAND_MAX_SOURCES || DEFAULT_ICELAND_MAX_SOURCES, + ); + const maxCount = Number.isFinite(maxRaw) + ? Math.max(8, Math.min(600, Math.floor(maxRaw))) + : DEFAULT_ICELAND_MAX_SOURCES; + const prioritized = prioritizeSources(unique, maxCount, ICELAND_ANCHORS); + console.log( + `[CCTV] Loaded Iceland camera sources: ${unique.length} (using nearest ${prioritized.length})`, + ); + return prioritized; + } catch (error) { + console.warn( + '[CCTV] Iceland camera download error:', + error?.message || error, + ); + return []; + } +} + /** * Read one XML child tag's text from an `` block. * diff --git a/src/data/cctvIceland.test.mjs b/src/data/cctvIceland.test.mjs new file mode 100644 index 000000000..3d1d6384c --- /dev/null +++ b/src/data/cctvIceland.test.mjs @@ -0,0 +1,327 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { createCctvCatalog } from '../../server/providers/cctv/catalog.js'; +import { + DEFAULT_ICELAND_MAX_SOURCES, + ICELAND_CAMERAS_URL, + ICELAND_IMAGE_ORIGIN, + ICELAND_MAX_CATALOG_BYTES, +} from '../../server/providers/cctv/constants.js'; +import { isLikelyIcelandCoordinate } from '../../server/providers/cctv/normalize.js'; +import { + icelandCameraToSource, + icelandHeadingFromDescription, + loadIcelandSourcesFromOpenData, +} from '../../server/providers/cctv/sources.js'; + +/** Set (or, for `undefined`, delete) environment variables for one test. */ +function withEnv(t, env) { + for (const [name, value] of Object.entries(env)) { + const previous = process.env[name]; + t.after(() => { + if (previous === undefined) delete process.env[name]; + else process.env[name] = previous; + }); + if (value === undefined) delete process.env[name]; + else process.env[name] = value; + } +} + +/** One row shaped like the live `vefmyndavelar2014_1` payload. */ +const row = (over = {}) => ({ + Maelist_nr: 7001, + Myndavel: 'Hellisheiði', + Vegheiti: 'Hringvegur', + NrVegur: '1', + Skyring: 'Hellisheiði séð til vesturs', + Slod: `${ICELAND_IMAGE_ORIGIN}hellisheidi_1.jpg`, + PntX: 395000, + PntY: 390000, + Breidd: 64.0136, + Lengd: -21.4, + ...over, +}); + +/** A live response body plus a flag that flips when the stream is cancelled. */ +const streamingResponse = (init = {}) => { + const state = { cancelled: false }; + const body = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode('[')); + }, + cancel() { + state.cancelled = true; + }, + }); + return { response: new Response(body, init), state }; +}; + +const json = (value) => + new Response(JSON.stringify(value), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + +test('an Iceland row maps to a source on the pinned frame directory', () => { + const source = icelandCameraToSource(row()); + assert.equal(source.id, 'is-vegagerdin-hellisheidi_1'); + assert.equal(source.name, 'Hellisheiði séð til vesturs'); + assert.equal(source.city, 'Iceland'); + assert.equal(source.cityId, 'iceland'); + assert.equal(source.provider, 'Vegagerðin (IRCA)'); + assert.equal(source.lat, 64.0136); + assert.equal(source.lon, -21.4); + assert.equal(source.headingDeg, 270); + assert.equal(source.headingConfidence, 'high'); + assert.equal(source.feedType, 'image'); + assert.equal(source.sourceKind, 'iceland-vegagerdin'); + assert.equal(source.code, 'HELLISHEIÐI'); + assert.match(source.license, /CC BY 4\.0/); + assert.ok(source.url.startsWith(ICELAND_IMAGE_ORIGIN)); + assert.equal(source.snapshotUrl, source.url); +}); + +test('a view that states no facing takes the low-confidence id-hash pose', () => { + const source = icelandCameraToSource( + row({ + Skyring: 'Hellisheiði séð niður á veg', + Slod: `${ICELAND_IMAGE_ORIGIN}hellisheidi_2.jpg`, + }), + ); + assert.equal(source.headingConfidence, 'low'); + assert.equal(source.headingDeg % 22.5, 0); + assert.equal( + source.headingDeg, + icelandCameraToSource( + row({ Slod: `${ICELAND_IMAGE_ORIGIN}hellisheidi_2.jpg` }), + ).headingDeg, + 'the fallback depends on the id, not on the text', + ); +}); + +test('the label collapses the feed’s doubled spaces and repeated words', () => { + assert.equal( + icelandCameraToSource( + row({ Skyring: 'Strandarheiði séð niður á veg til til austurs' }), + ).name, + 'Strandarheiði séð niður á veg til austurs', + ); +}); + +test('only an explicit facing sets a heading', () => { + const cases = [ + ['Hellisheiði séð til vesturs', 270], + ['Holtavörðuheiði til norðurs', 0], + ['Klettsháls í suður', 180], + ['Arnarneshæð séð austur', 90], + ['Bústaðabrú séð norður Kringlumýrarbraut', 0], + ['Staður séð til norðausturs', 45], + ['Staður séð til suðausturs', 135], + ['Staður séð í suðvestur', 225], + ['Staður séð til norðvesturs', 315], + ['STAÐUR SÉÐ TIL VESTURS', 270], + ]; + for (const [text, heading] of cases) { + assert.equal(icelandHeadingFromDescription(text), heading, text); + } + for (const text of [ + '', + null, + 'Gautsdalur niður á veg', + 'Miklabraut austur', // a road direction, not a facing + 'Biskupsháls - norður', + 'Hringtorg norðan Selfoss', + 'Norðurárdalur niður á veg', // a place name that contains a cardinal + 'Staður séð til suðurlands', // a longer word, not "suður" + 'Staður séð til norðurs og til suðurs', // two facings: ambiguous + 'Norðfjarðargöng séð til Eskifjarðar', + ]) { + assert.ok(Number.isNaN(icelandHeadingFromDescription(text)), String(text)); + } +}); + +test('rows off the pinned directory, or with unusable frames or places, are dropped', () => { + const bad = [ + { Slod: 'https://example.com/vgdata/vefmyndavelar/a_1.jpg' }, + { Slod: 'http://www.vegagerdin.is/vgdata/vefmyndavelar/a_1.jpg' }, + { Slod: 'https://user:pw@www.vegagerdin.is/vgdata/vefmyndavelar/a_1.jpg' }, + { Slod: 'https://www.vegagerdin.is:8443/vgdata/vefmyndavelar/a_1.jpg' }, + { Slod: 'https://www.vegagerdin.is/vgdata/other/a_1.jpg' }, + { Slod: 'https://www.vegagerdin.is/vgdata/vefmyndavelar/../x/a_1.jpg' }, + { Slod: 'https://www.vegagerdin.is/vgdata/vefmyndavelar/a%2Fb_1.jpg' }, + { Slod: 'https://www.vegagerdin.is/vgdata/vefmyndavelar/a_1.php' }, + { Slod: 'https://www.vegagerdin.is/vgdata/vefmyndavelar/a_1' }, + { Slod: 'https://www.vegagerdin.is/vgdata/vefmyndavelar/' }, + { Slod: 'https://www.vegagerdin.is/vgdata/vefmyndavelar/a b_1.jpg' }, + { Slod: '' }, + { Slod: null }, + { Breidd: 0, Lengd: 0 }, + { Breidd: null, Lengd: null }, + { Breidd: '64.01', Lengd: '-21.4' }, + { Breidd: '64.01' }, // each coordinate must be a number on its own + { Lengd: '-21.4' }, + { Breidd: 59.9, Lengd: 10.7 }, // Oslo + { Breidd: 64.01, Lengd: 21.4 }, // wrong hemisphere + ]; + for (const over of bad) { + assert.equal(icelandCameraToSource(row(over)), null, JSON.stringify(over)); + } + assert.equal(icelandCameraToSource(null), null); + assert.equal(icelandCameraToSource({}), null); + assert.equal(isLikelyIcelandCoordinate(64.1466, -21.9426), true); + assert.equal(isLikelyIcelandCoordinate(66.3, -14.0), true); +}); + +test('the Iceland loader reads the list with redirects refused and keeps the nearest cameras', async (t) => { + t.mock.method(console, 'log', () => {}); + t.mock.method(console, 'warn', () => {}); + withEnv(t, { CCTV_ICELAND_MAX_SOURCES: '8' }); + const rows = Array.from({ length: 12 }, (_, i) => + row({ + Maelist_nr: 8000 + i, + Slod: `${ICELAND_IMAGE_ORIGIN}far_${i}.jpg`, + Skyring: `Far ${i}`, + Breidd: 66.2, + Lengd: -15.9 + i * 0.01, + }), + ); + // Reykjavík cameras come last in the feed but first in the priority order. + rows.push( + row({ Slod: `${ICELAND_IMAGE_ORIGIN}rvk_1.jpg`, Breidd: 64.1466, Lengd: -21.9426 }), + ); + rows.push(row({ Slod: `${ICELAND_IMAGE_ORIGIN}rvk_1.jpg` })); // repeated frame + const seen = []; + t.mock.method(globalThis, 'fetch', async (url, init) => { + seen.push([String(url), init.redirect]); + return json(rows); + }); + const sources = await loadIcelandSourcesFromOpenData(); + assert.deepEqual(seen, [[ICELAND_CAMERAS_URL, 'manual']]); + assert.equal(sources.length, 8); + assert.equal(sources[0].id, 'is-vegagerdin-rvk_1'); + assert.equal( + new Set(sources.map((s) => s.id)).size, + sources.length, + 'a frame the feed repeats collapses to one camera', + ); + assert.equal(sources.available, 13); +}); + +test('the default Iceland cap is DEFAULT_ICELAND_MAX_SOURCES', async (t) => { + t.mock.method(console, 'log', () => {}); + withEnv(t, { CCTV_ICELAND_MAX_SOURCES: undefined }); + const rows = Array.from({ length: DEFAULT_ICELAND_MAX_SOURCES + 40 }, (_, i) => + row({ Slod: `${ICELAND_IMAGE_ORIGIN}cam_${i}.jpg` }), + ); + t.mock.method(globalThis, 'fetch', async () => json(rows)); + assert.equal( + (await loadIcelandSourcesFromOpenData()).length, + DEFAULT_ICELAND_MAX_SOURCES, + ); +}); + +test('the Iceland loader fails soft on HTTP errors, bad payloads and network errors', async (t) => { + t.mock.method(console, 'log', () => {}); + t.mock.method(console, 'warn', () => {}); + t.mock.method(globalThis, 'fetch', async () => new Response('x', { status: 503 })); + assert.deepEqual(await loadIcelandSourcesFromOpenData(), []); + t.mock.restoreAll(); + t.mock.method(console, 'log', () => {}); + t.mock.method(console, 'warn', () => {}); + t.mock.method(globalThis, 'fetch', async () => json({ rows: [] })); + assert.deepEqual(await loadIcelandSourcesFromOpenData(), []); + t.mock.method(globalThis, 'fetch', async () => new Response('', { status: 200 })); + assert.deepEqual(await loadIcelandSourcesFromOpenData(), []); + t.mock.method(globalThis, 'fetch', async () => { + throw new Error('network down'); + }); + assert.deepEqual(await loadIcelandSourcesFromOpenData(), []); +}); + +test('the Iceland catalog fetch refuses redirects and oversized bodies', async (t) => { + t.mock.method(console, 'warn', () => {}); + const redirected = streamingResponse({ + status: 302, + headers: { location: 'http://gagnaveita.vegagerdin.is/api/vefmyndavelar2014_1' }, + }); + t.mock.method(globalThis, 'fetch', async () => redirected.response); + assert.deepEqual(await loadIcelandSourcesFromOpenData(), []); + assert.equal(redirected.state.cancelled, true, 'the redirect body is cancelled'); + + t.mock.restoreAll(); + t.mock.method(console, 'warn', () => {}); + const failed = streamingResponse({ status: 500 }); + t.mock.method(globalThis, 'fetch', async () => failed.response); + assert.deepEqual(await loadIcelandSourcesFromOpenData(), []); + assert.equal(failed.state.cancelled, true, 'the error body is cancelled'); + + const valid = JSON.stringify([row()]); + t.mock.restoreAll(); + t.mock.method(console, 'warn', () => {}); + t.mock.method(globalThis, 'fetch', async () => { + return new Response(valid, { + headers: { 'content-length': String(ICELAND_MAX_CATALOG_BYTES + 1) }, + }); + }); + assert.deepEqual(await loadIcelandSourcesFromOpenData(), []); + + t.mock.restoreAll(); + t.mock.method(console, 'warn', () => {}); + t.mock.method(globalThis, 'fetch', async () => { + const padded = valid + ' '.repeat(ICELAND_MAX_CATALOG_BYTES + 1024); + return new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(padded)); + controller.close(); + }, + }), + ); + }); + assert.deepEqual(await loadIcelandSourcesFromOpenData(), []); +}); + +test('CCTV catalog merges Iceland cameras and CCTV_ICELAND_ENABLED=0 skips the request', async (t) => { + t.mock.method(console, 'log', () => {}); + t.mock.method(console, 'warn', () => {}); + const sourceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'gev-cctv-catalog-iceland-')); + t.after(() => fs.rmSync(sourceRoot, { recursive: true, force: true })); + withEnv(t, { + CCTV_SOURCES_FILE: undefined, + CCTV_SOURCES_JSON: undefined, + CCTV_FORCE_AUSTIN: undefined, + CCTV_PREFER_AUSTIN: undefined, + CCTV_MAX_SOURCES: undefined, + CCTV_CALTRANS_DISTRICTS: undefined, + CCTV_TFL_ENABLED: undefined, + CCTV_ICELAND_ENABLED: undefined, + CCTV_ICELAND_MAX_SOURCES: undefined, + }); + const requested = []; + t.mock.method(globalThis, 'fetch', async (url, init) => { + requested.push([String(url), init.redirect]); + if (String(url) !== ICELAND_CAMERAS_URL) { + return new Response('unavailable', { status: 503 }); + } + return json([row()]); + }); + + const sources = await createCctvCatalog({ sourceRoot })(); + assert.deepEqual( + sources.map((source) => source.id), + ['is-vegagerdin-hellisheidi_1'], + ); + + process.env.CCTV_ICELAND_ENABLED = '0'; + requested.length = 0; + assert.deepEqual(await createCctvCatalog({ sourceRoot })(), []); + assert.ok(requested.length > 0, 'the other live packs still load'); + assert.equal( + requested.some(([url]) => url === ICELAND_CAMERAS_URL), + false, + 'a disabled pack makes no request', + ); +}); diff --git a/src/data/dataCredits.js b/src/data/dataCredits.js index 7d88c6462..1734809c6 100644 --- a/src/data/dataCredits.js +++ b/src/data/dataCredits.js @@ -321,6 +321,13 @@ export const DATA_CREDITS = [ '(Llicència oberta d’ús d’informació - Catalunya); some cameras are supplied by partners ' + '(Ajuntament de Barcelona, Ajuntament de Terrassa), each named in the CCTV panel', }, + { + key: 'iceland-cctv', + html: + 'CCTV cameras & frames (Iceland): ' + + 'Icelandic Road and Coastal Administration (Vegagerðin) — ' + + 'CC BY 4.0', + }, { key: 'kingcounty-cctv', html: From f5d5da702376cce3fcc346e62a7002d02e029949 Mon Sep 17 00:00:00 2001 From: daika Date: Sat, 7 Nov 2026 12:57:48 +0700 Subject: [PATCH 2/3] docs(changelog): add the Iceland CCTV pack fragment --- changelog.d/1617-cctv-iceland.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 changelog.d/1617-cctv-iceland.md diff --git a/changelog.d/1617-cctv-iceland.md b/changelog.d/1617-cctv-iceland.md new file mode 100644 index 000000000..cf66651bf --- /dev/null +++ b/changelog.d/1617-cctv-iceland.md @@ -0,0 +1,12 @@ +- Add Icelandic road cameras from the Icelandic Road and Coastal Administration + (Vegagerðin) as a CCTV pack. The camera list is one keyless JSON request to + `gagnaveita.vegagerdin.is/api/vefmyndavelar2014_1` (about 500 views at 166 + stations); frames are JPEG stills in `www.vegagerdin.is/vgdata/vefmyndavelar/`, + and only links inside that directory are registered. The catalog is fetched + over https with redirects refused and a 2 MiB body cap. An explicit facing in + the view description ("til vesturs", "í suður", "séð austur") sets a + high-confidence heading; other views keep the low-confidence fallback pose. + IRCA publishes the images under CC BY 4.0, credited in the Data attribution + popover. Loads the 250 views nearest Reykjavík, Akureyri, Egilsstaðir, + Ísafjörður and Selfoss; `CCTV_ICELAND_MAX_SOURCES` changes that cap and + `CCTV_ICELAND_ENABLED=0` turns the pack off (daikaginza, #1617). From 39d70fa1c0e1878e6208068b1876d16614274fba Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Sat, 7 Nov 2026 14:38:52 +0000 Subject: [PATCH 3/3] fix(cctv): keep Icelandic labels intact and treat 'og' facings as ambiguous MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Label repeat-collapse uses letter lookarounds instead of ASCII-only \b, so 'Þverá Árnessýslu' is no longer joined and 'séð séð' collapses. - 'til austurs og vesturs' and 'í norður og suður' name two facings and keep the low-confidence pose. - Fallback name uses the frame stem when the station number is missing. - Tests: label and heading regressions, dedupe keeps the last row, cap clamp, high-confidence pose, frame redirect pin; file added to format scope. - Credit names the dataset, links the terms and states no endorsement. --- DATA_SOURCES.md | 2 +- scripts/format-scope.json | 1 + server/providers/cctv/sources.js | 27 ++++--- src/data/cctvIceland.test.mjs | 120 ++++++++++++++++++++++++++++--- src/data/dataCredits.js | 6 +- 5 files changed, 136 insertions(+), 20 deletions(-) diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md index 94f581fe4..e97d2f021 100644 --- a/DATA_SOURCES.md +++ b/DATA_SOURCES.md @@ -179,7 +179,7 @@ The optional Local ADS-B layer and the broadcast-FM receiver use radio you recei - **Open Calgary traffic cameras.** Keyless. The camera list is the public Socrata dataset `k7p9-kppz` on `data.calgary.ca`; frames are stills on the city's own `trafficcam.calgary.ca` host, and only that host is registered. Most rows publish an `http://` frame URL and the host redirects those to HTTPS, so URLs are upgraded and pinned to that origin before registration. Frames are fetched at request time and never stored or redistributed. The dataset publishes no camera facing: the `quadrant` field and the quadrant suffix on each camera name are Calgary's address grid, not a bearing, so headings use the shared id-hash fallback at low confidence and are corrected with the in-app calibration gizmo. The licence requires the attribution statement above. Disable with `CCTV_CALGARY_ENABLED=0`. - **FL511 Florida cameras.** Off by default; set `CCTV_FLORIDA_ENABLED=1` to load them. The camera list is the keyless FL511 traffic camera layer on ArcGIS (`services.arcgis.com/.../FL511_Traffic_Cameras`), published from a Florida Division of Emergency Management account rather than by FL511 directly, and the service carries no licence text of its own. It is paged up to 2,000 rows at a time, ordered by the layer's object id and following ArcGIS's `exceededTransferLimit`, to cover the ~4,000-camera statewide set. Frames are stills on two Divas Cloud hosts (`images-dis.divas.cloud`, `snapshots.divas.cloud`); both are host-pinned and a row whose image resolves elsewhere is dropped. `CCTV_FLORIDA_MAX_SOURCES` caps the pack (default 500, anchored per region across the state). Each catalog page is read under a 2 MiB cap and redirects are refused. Frames are fetched live at request time and never stored or redistributed. FL511's terms (fl511.com/privacy#disclaimer) license its content for individual, personal use only; any other use, including a public deployment, needs FDOT's express written consent. - **Catalonia (Servei Català de Trànsit) cameras.** The camera list is the keyless `https://www.gencat.cat/transit/opendata/cameres.xml` WFS/GML feed, fetched over https with redirects refused and the body read capped at 1 MiB, since the list decides ids, coordinates and which allowlisted frame path is proxied. It carries a `font` field naming the real publisher per camera; this pack keeps SCT's own highway cameras plus hotlinked Barcelona (IMI) and Terrassa municipal cameras (~130 total), each confirmed to return a real frame, so frame URLs are checked against a small host allowlist (`mct.gencat.cat`, `www.bcn.cat`, `emap.terrassa.cat`) rather than one pinned origin (a link carrying `user:pass@` userinfo or an explicit port, or pointing outside that host's frame directory, is rejected even on an allowed host; an SCT `sctidcam` must be a bare `.gif`/`.jpg` filename), and every non-SCT camera carries its real owner as a `credit` shown beside the shared `provider`. The feed carries no compass heading, so every camera loads at low heading confidence (same as TfL and Fintraffic). SCT's own feed link (`RenderService`) needed a workaround: it always 302s to a second endpoint (`TransitCamera`) on a Location the upstream hardcodes as plain http even when requested over https — refused by `fetchWithinHost`'s same-origin (scheme-included) redirect policy — and separately, `mct.gencat.cat`'s TLS setup fails the handshake with Node's fetch outright (`ERR_SSL_WRONG_SIGNATURE_TYPE`) even though other HTTP clients complete it. The loader therefore builds the `TransitCamera` URL itself, over plain http, from `RenderService`'s `sctidcam` id, rather than registering the (broken) upstream link — the only transport this one host actually serves to this runtime. Camera ids are built from the SCT camera file or the partner image path, not the feed's `fid` (which embeds an upstream server-start stamp and changes on every upstream restart), and rows the feed lists more than once collapse to one camera. The feed carries no camera status field, so a camera its publisher has taken offline still registers and shows the publisher's own "image unavailable" still (SCT and Barcelona both serve one with HTTP 200), the same as other packs without a status field. Frames are fetched at request time and never stored or redistributed. The feed itself states no licence; the pack relies on the Generalitat de Catalunya open-data portal's terms for this dataset (the licence and attribution above). Disable with `CCTV_CATALONIA_ENABLED=0`. -- **Iceland (Vegagerðin / IRCA) cameras.** The camera list is the keyless `https://gagnaveita.vegagerdin.is/api/vefmyndavelar2014_1` JSON feed (about 500 camera views at roughly 170 stations), fetched over https with redirects refused and the body read capped at 2 MiB, since the list decides ids, coordinates and which frame path is proxied. Frames are JPEG stills in `https://www.vegagerdin.is/vgdata/vefmyndavelar/`; only links inside that directory are registered (userinfo, explicit ports and encoded path separators are rejected). Camera ids come from the frame file name. The feed's `Skyring` view description is the camera label, and a stated facing ("til vesturs", "í suður", "séð austur") sets a high-confidence heading; a view that states none, or two different ones ("niður á veg", a bare road direction), takes the id-hash low-confidence pose. The feed has no elevation, so cameras carry a flat prior that the client's ground snap corrects. IRCA publishes road camera images under CC BY 4.0 and requires the licensor to be acknowledged without implying endorsement; the pack shows the attribution above in the Data attribution popover and carries the licence string on every camera. IRCA describes the images as fetched several times per hour (a live frame's `Last-Modified` was about four minutes old on 2026-11-06) and the real-time data as provided as is, without warranty. Frames are fetched at request time and never stored or redistributed. Disable with `CCTV_ICELAND_ENABLED=0`. +- **Iceland (Vegagerðin / IRCA) cameras.** The camera list is the keyless `https://gagnaveita.vegagerdin.is/api/vefmyndavelar2014_1` JSON feed (about 500 camera views at roughly 170 stations), fetched over https with redirects refused and the body read capped at 2 MiB, since the list decides ids, coordinates and which frame path is proxied. Frames are JPEG stills in `https://www.vegagerdin.is/vgdata/vefmyndavelar/`; only links inside that directory are registered (userinfo, explicit ports and encoded path separators are rejected). Camera ids come from the frame file name. The feed's `Skyring` view description is the camera label, and a stated facing ("til vesturs", "í suður", "séð austur") sets a high-confidence heading; a view that states none, or two different ones ("niður á veg", a bare road direction), takes the id-hash low-confidence pose. The feed has no elevation, so cameras carry a flat prior that the client's ground snap corrects. IRCA publishes road camera images under CC BY 4.0 and requires the licensor to be acknowledged without implying endorsement; the pack shows the attribution above (licensor, dataset, CC BY 4.0, terms link and no-endorsement note) in the Data attribution popover, and every camera record carries the licence string. IRCA describes the images as fetched several times per hour (a live frame's `Last-Modified` was about four minutes old on 2026-11-06) and the real-time data as provided as is, without warranty. Frames are fetched at request time and never stored or redistributed. Disable with `CCTV_ICELAND_ENABLED=0`. - **King County road cameras.** Keyless. The camera list is one public ArcGIS FeatureServer GeoJSON query (King County DOT Road Services, "King County and WSDOT Traffic Cameras"). The `outFields` list is explicit on purpose: the layer also carries asset-administration columns — including staff names — that are never fetched. Only live, active cameras with frames on the county's own `info.kingcounty.gov` image handler are registered (the feed writes `http://`; the handler serves HTTPS, so URLs are upgraded and pinned before registration). WSDOT-owned rows in the layer are skipped because their frames are WSDOT-hosted, outside the pinned image origin; partner-city cameras hosted on the county image handler (e.g. Newcastle) name their owner as the per-camera credit. The layer publishes no facing, so headings use the shared id-hash fallback at low confidence. Uniquely, the county publishes each camera's hardware `Manufacturer`/`Model` (Cohu 3950/3965, Bosch AUTODOME, Axis PTZ domes); both are carried on the source (and served by `/api/cctv/sources`) as `model` and `manufacturer` for identified-hardware consumers (vendor-generic strings such as `PTZ` name no model and are omitted). Frames are fetched at request time and never stored or redistributed. Disable with `CCTV_KINGCOUNTY_ENABLED=0`. - **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. diff --git a/scripts/format-scope.json b/scripts/format-scope.json index dc9700593..b23ab75b0 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -583,6 +583,7 @@ "src/data/cctvHk.test.mjs", "src/data/cctvVegvesen.test.mjs", "src/data/cctvA22.test.mjs", + "src/data/cctvIceland.test.mjs", "src/data/cctvNyc.test.mjs", "src/data/cctvHouston.test.mjs", "src/data/cctvNswSource.test.mjs", diff --git a/server/providers/cctv/sources.js b/server/providers/cctv/sources.js index 1a7cf9f54..f6bfa35be 100644 --- a/server/providers/cctv/sources.js +++ b/server/providers/cctv/sources.js @@ -2051,15 +2051,22 @@ const ICELAND_DIRECTION_HEADINGS = { vestur: 270, norðvestur: 315, }; -const ICELAND_DIRECTION_RE = - /(?", "í <átt>" or "séð <átt>" counts: a bare place name or - * a road direction ("Miklabraut austur") is not a facing. A description naming two - * different directions is ambiguous and yields NaN, and "niður á veg" (down - * onto the road) states none. + * a road direction ("Miklabraut austur") is not a facing. A description naming + * two different directions ("til norðurs og til suðurs", "til austurs og + * vesturs") is ambiguous and yields NaN, and "niður á veg" (down onto the + * road) states none. * * @param {string} text - The feed's `Skyring` field. * @returns {number} Heading in degrees [0..360), or NaN. @@ -2069,7 +2076,9 @@ export function icelandHeadingFromDescription(text) { for (const match of String(text || '') .toLowerCase() .matchAll(ICELAND_DIRECTION_RE)) { - headings.add(ICELAND_DIRECTION_HEADINGS[match[1]]); + for (const word of [match[1], match[2]]) { + if (word) headings.add(ICELAND_DIRECTION_HEADINGS[word]); + } } return headings.size === 1 ? headings.values().next().value : NaN; } @@ -2097,14 +2106,16 @@ export function icelandCameraToSource(row) { const cameraId = `is-vegagerdin-${stem}`; const description = String(row.Skyring || '') .replace(/\s+/g, ' ') - .replace(/\b(\p{L}+) \1\b/giu, '$1') + // Letter lookarounds, not \b: \b is ASCII-only and would join "Þverá + // Árnessýslu" while missing "séð séð". + .replace(/(? { assert.equal(source.lon, -21.4); assert.equal(source.headingDeg, 270); assert.equal(source.headingConfidence, 'high'); + assert.equal(source.pitchDeg, -24); + assert.equal(source.fovDeg, 56); + assert.equal(source.rangeM, 210); + assert.equal(source.mountHeightM, 10); assert.equal(source.feedType, 'image'); assert.equal(source.sourceKind, 'iceland-vegagerdin'); assert.equal(source.code, 'HELLISHEIÐI'); @@ -109,6 +114,27 @@ test('the label collapses the feed’s doubled spaces and repeated words', () => ).name, 'Strandarheiði séð niður á veg til austurs', ); + const label = (Skyring) => icelandCameraToSource(row({ Skyring })).name; + // Repeats of words with Icelandic letters collapse too. + assert.equal( + label('Hellisheiði séð séð til austurs'), + 'Hellisheiði séð til austurs', + ); + assert.equal(label('Brú á á Jökulsá'), 'Brú á Jökulsá'); + // A word ending in a letter the next word starts with is not a repeat. + for (const text of [ + 'Þverá Árnessýslu', + 'brú Úlfarsá', + 'Vesturlandsvegur við Elliðaá Ártúnsbrekku', + ]) + assert.equal(label(text), text); +}); + +test('a row with no label, station or station number is named by its frame', () => { + const source = icelandCameraToSource( + row({ Skyring: '', Myndavel: '', Maelist_nr: undefined }), + ); + assert.equal(source.name, 'Vegagerðin hellisheidi_1'); }); test('only an explicit facing sets a heading', () => { @@ -123,6 +149,8 @@ test('only an explicit facing sets a heading', () => { ['Staður séð í suðvestur', 225], ['Staður séð til norðvesturs', 315], ['STAÐUR SÉÐ TIL VESTURS', 270], + ['Brú séð til austurs og Selfoss', 90], // "og" then a place, not a facing + ['Staður séð til austurs og Vesturlandsvegur', 90], ]; for (const [text, heading] of cases) { assert.equal(icelandHeadingFromDescription(text), heading, text); @@ -137,6 +165,9 @@ test('only an explicit facing sets a heading', () => { 'Norðurárdalur niður á veg', // a place name that contains a cardinal 'Staður séð til suðurlands', // a longer word, not "suður" 'Staður séð til norðurs og til suðurs', // two facings: ambiguous + 'Staður séð til austurs og vesturs', // the second facing shares "til" + 'Staður séð í norður og suður', + 'Staður séð í norður og í suður', 'Norðfjarðargöng séð til Eskifjarðar', ]) { assert.ok(Number.isNaN(icelandHeadingFromDescription(text)), String(text)); @@ -189,10 +220,16 @@ test('the Iceland loader reads the list with redirects refused and keeps the nea }), ); // Reykjavík cameras come last in the feed but first in the priority order. + // A frame the feed repeats keeps its last row, as the other packs do. + rows.push(row({ Slod: `${ICELAND_IMAGE_ORIGIN}rvk_1.jpg`, Skyring: 'Old' })); rows.push( - row({ Slod: `${ICELAND_IMAGE_ORIGIN}rvk_1.jpg`, Breidd: 64.1466, Lengd: -21.9426 }), + row({ + Slod: `${ICELAND_IMAGE_ORIGIN}rvk_1.jpg`, + Skyring: 'Kringlumýrarbraut', + Breidd: 64.1466, + Lengd: -21.9426, + }), ); - rows.push(row({ Slod: `${ICELAND_IMAGE_ORIGIN}rvk_1.jpg` })); // repeated frame const seen = []; t.mock.method(globalThis, 'fetch', async (url, init) => { seen.push([String(url), init.redirect]); @@ -202,6 +239,8 @@ test('the Iceland loader reads the list with redirects refused and keeps the nea assert.deepEqual(seen, [[ICELAND_CAMERAS_URL, 'manual']]); assert.equal(sources.length, 8); assert.equal(sources[0].id, 'is-vegagerdin-rvk_1'); + assert.equal(sources[0].name, 'Kringlumýrarbraut'); + assert.equal(sources[0].lat, 64.1466); assert.equal( new Set(sources.map((s) => s.id)).size, sources.length, @@ -213,8 +252,9 @@ test('the Iceland loader reads the list with redirects refused and keeps the nea test('the default Iceland cap is DEFAULT_ICELAND_MAX_SOURCES', async (t) => { t.mock.method(console, 'log', () => {}); withEnv(t, { CCTV_ICELAND_MAX_SOURCES: undefined }); - const rows = Array.from({ length: DEFAULT_ICELAND_MAX_SOURCES + 40 }, (_, i) => - row({ Slod: `${ICELAND_IMAGE_ORIGIN}cam_${i}.jpg` }), + const rows = Array.from( + { length: DEFAULT_ICELAND_MAX_SOURCES + 40 }, + (_, i) => row({ Slod: `${ICELAND_IMAGE_ORIGIN}cam_${i}.jpg` }), ); t.mock.method(globalThis, 'fetch', async () => json(rows)); assert.equal( @@ -223,17 +263,71 @@ test('the default Iceland cap is DEFAULT_ICELAND_MAX_SOURCES', async (t) => { ); }); +test('CCTV_ICELAND_MAX_SOURCES is clamped to 8..600 and ignored when not a number', async (t) => { + t.mock.method(console, 'log', () => {}); + withEnv(t, { CCTV_ICELAND_MAX_SOURCES: undefined }); + const rows = Array.from({ length: 650 }, (_, i) => + row({ Slod: `${ICELAND_IMAGE_ORIGIN}cam_${i}.jpg` }), + ); + t.mock.method(globalThis, 'fetch', async () => json(rows)); + for (const [value, expected] of [ + ['0', 8], + ['3', 8], + ['1000', 600], + ['abc', DEFAULT_ICELAND_MAX_SOURCES], + ]) { + process.env.CCTV_ICELAND_MAX_SOURCES = value; + assert.equal( + (await loadIcelandSourcesFromOpenData()).length, + expected, + value, + ); + } +}); + +test('a redirect on the Iceland frame host stays inside vefmyndavelar/', async () => { + const frame = `${ICELAND_IMAGE_ORIGIN}hellisheidi_1.jpg`; + const redirectTo = (location) => async (url) => + url === frame + ? new Response(null, { status: 302, headers: { location } }) + : new Response('frame', { headers: { 'content-type': 'image/jpeg' } }); + for (const location of [ + '/vgdata/other/x.jpg', + '/vgdata/vefmyndavelar/../x.jpg', + '/', + ]) + assert.equal( + await fetchWithinHost(frame, {}, redirectTo(location)), + null, + location, + ); + const followed = await fetchWithinHost( + frame, + {}, + redirectTo('/vgdata/vefmyndavelar/hellisheidi_1b.jpg'), + ); + assert.equal(await followed.text(), 'frame'); +}); + test('the Iceland loader fails soft on HTTP errors, bad payloads and network errors', async (t) => { t.mock.method(console, 'log', () => {}); t.mock.method(console, 'warn', () => {}); - t.mock.method(globalThis, 'fetch', async () => new Response('x', { status: 503 })); + t.mock.method( + globalThis, + 'fetch', + async () => new Response('x', { status: 503 }), + ); assert.deepEqual(await loadIcelandSourcesFromOpenData(), []); t.mock.restoreAll(); t.mock.method(console, 'log', () => {}); t.mock.method(console, 'warn', () => {}); t.mock.method(globalThis, 'fetch', async () => json({ rows: [] })); assert.deepEqual(await loadIcelandSourcesFromOpenData(), []); - t.mock.method(globalThis, 'fetch', async () => new Response('', { status: 200 })); + t.mock.method( + globalThis, + 'fetch', + async () => new Response('', { status: 200 }), + ); assert.deepEqual(await loadIcelandSourcesFromOpenData(), []); t.mock.method(globalThis, 'fetch', async () => { throw new Error('network down'); @@ -245,11 +339,17 @@ test('the Iceland catalog fetch refuses redirects and oversized bodies', async ( t.mock.method(console, 'warn', () => {}); const redirected = streamingResponse({ status: 302, - headers: { location: 'http://gagnaveita.vegagerdin.is/api/vefmyndavelar2014_1' }, + headers: { + location: 'http://gagnaveita.vegagerdin.is/api/vefmyndavelar2014_1', + }, }); t.mock.method(globalThis, 'fetch', async () => redirected.response); assert.deepEqual(await loadIcelandSourcesFromOpenData(), []); - assert.equal(redirected.state.cancelled, true, 'the redirect body is cancelled'); + assert.equal( + redirected.state.cancelled, + true, + 'the redirect body is cancelled', + ); t.mock.restoreAll(); t.mock.method(console, 'warn', () => {}); @@ -287,7 +387,9 @@ test('the Iceland catalog fetch refuses redirects and oversized bodies', async ( test('CCTV catalog merges Iceland cameras and CCTV_ICELAND_ENABLED=0 skips the request', async (t) => { t.mock.method(console, 'log', () => {}); t.mock.method(console, 'warn', () => {}); - const sourceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'gev-cctv-catalog-iceland-')); + const sourceRoot = fs.mkdtempSync( + path.join(os.tmpdir(), 'gev-cctv-catalog-iceland-'), + ); t.after(() => fs.rmSync(sourceRoot, { recursive: true, force: true })); withEnv(t, { CCTV_SOURCES_FILE: undefined, diff --git a/src/data/dataCredits.js b/src/data/dataCredits.js index 1734809c6..704db7fb8 100644 --- a/src/data/dataCredits.js +++ b/src/data/dataCredits.js @@ -324,9 +324,11 @@ export const DATA_CREDITS = [ { key: 'iceland-cctv', html: - 'CCTV cameras & frames (Iceland): ' + + 'CCTV cameras & frames (Iceland): road camera images, fetched live, from the ' + 'Icelandic Road and Coastal Administration (Vegagerðin) — ' + - 'CC BY 4.0', + 'CC BY 4.0 ' + + '(terms); ' + + 'no endorsement by IRCA is implied', }, { key: 'kingcounty-cctv',