feat(cctv): add the Open Calgary camera pack (#514)

Supersedes #438 by @rileygramlich, ported onto the current component layout with fixes from review.

Verified before merge: format, package boundaries, unit suite, build, and the browser tracking suite; a browser harness for the change where one applies; adversarial review applied.

Co-authored-by: rileygramlich <[email protected]>
This commit is contained in:
Bilawal Sidhuandrileygramlich authored and GitHub committed 2026-09-14 19:45:52 -05:00
1 parent a04502559e
commit 409e27d7f9
15 files changed
+4743 -4

No files matched your search

+3
View File
@@ -168,6 +168,9 @@ AISSTREAM_API_KEY=
# CCTV_WARENDORF_ENABLED=1 # set 0 to disable the Warendorf (Germany) municipal webcams
# CCTV_NSW_ENABLED=1 # set 0 to disable the Live Traffic NSW (Australia) cameras
# CCTV_NSW_MAX_SOURCES=250 # cap cameras loaded from Live Traffic NSW
# CCTV_CALGARY_ENABLED=1 # set 0 to disable the Open Calgary (Alberta) cameras
# CCTV_CALGARY_MAX_SOURCES=220 # cap cameras loaded from the Open Calgary catalog
# CCTV_CALGARY_ROWS_URL= # override the Open Calgary catalog URL
# CCTV_MAX_SOURCES=4000 # catalog-wide ceiling, shared round-robin across packs
# CCTV_PREFER_AUSTIN=1 # prioritize Austin cameras in the registry
# CCTV_FORCE_AUSTIN= # force live default sources when a source-pack JSON is configured
+9
View File
@@ -123,6 +123,15 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md
### Added
- Add Open Calgary traffic cameras as a keyless CCTV source pack (thanks
@rileygramlich): the public City of Calgary catalog, frames pinned to the
city's own host and upgraded to HTTPS, with the Open Government Licence –
City of Calgary attribution. The dataset publishes no camera facing — its
quadrant field and the quadrant suffix on each camera name are Calgary's
address grid — so headings use the shared id-hash fallback at low confidence
and are corrected with the calibration gizmo. `CCTV_CALGARY_MAX_SOURCES` sets
the cap and `CCTV_CALGARY_ENABLED=0` turns the pack off.
- Add Ontario 511 as a keyless CCTV source pack, including Kitchener-area
highway cameras, with server-registered still URLs and attribution.
- CCTV Mesh adds Finland: Fintraffic road weather cameras, keyless, nationwide, 300 by default. Each camera view of a station is placed separately; ambient stills refresh on the source's 10-minute cadence (the active camera keeps the usual 10-second refresh).
+2
View File
@@ -44,6 +44,7 @@ How to read this:
| **Ontario 511** | CCTV camera catalog + frames, Ontario highways including Kitchener-area routes | [Open Government Licence – Ontario](https://www.ontario.ca/page/open-government-licence-ontario) | "Ontario 511" plus Open Government Licence - Ontario |
| **Fintraffic / Digitraffic (weathercams)** | CCTV camera catalog + frames, Finland | [CC BY 4.0](https://www.digitraffic.fi/en/terms-of-service/) — attribution REQUIRED | "Fintraffic / digitraffic.fi, license CC BY 4.0" |
| **DriveBC** (Government of British Columbia) | CCTV camera catalog + frames, British Columbia highways | [Open Government Licence – British Columbia](https://www2.gov.bc.ca/gov/content/data/open-data/open-government-licence-bc) — attribution REQUIRED | "DriveBC. Contains information licensed under the Open Government Licence – British Columbia" |
| **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" |
| **GBFS (Lyft / BCycle)** | Bikeshare availability | Per-feed (attribution-only) | Credit the operator (e.g. Austin BCycle) + its `license_url` |
| **Radio Browser** | Geolocated internet-radio station directory and station-level tags | Public-domain directory data under PDDL 1.0; individual broadcaster stream terms apply | "Radio Browser" plus a link to the selected broadcaster |
| **Re:Earth Terrain** (Mapterhorn) | Terrain (keyless globe stacks — OSM etc. — + `/api/terrain/heights` ellipsoidal-height lookups) | Terrain mesh: CC BY 4.0; geoid: EGM2008 (NGA, public domain) | "Terrain (keyless globe stacks): Re:Earth Terrain / Mapterhorn (CC BY 4.0) / EGM2008 (NGA)" |
@@ -63,6 +64,7 @@ How to read this:
- **Tarktee / Transpordiamet.** Estonia road-weather camera locations and current JPEG URLs come from keyless DATEX2 endpoints on `tarktee.transpordiamet.ee` (`/api/v1/datex/roadCameraLocations` + `roadCameraImages`). Frame URLs rotate with each capture; the 15-minute CCTV source cache refreshes them. Only `https://tarktee.transpordiamet.ee/images/…` URLs are registered. Disable with `CCTV_TARKTEE_ENABLED=0`.
- **Warendorf webcam.** The Stadt Warendorf Marktplatz webcam, registered from the curated `config/cctv_sources.warendorf.json` (the Kreis Warendorf registration-office cameras from the contributing PR are indoor waiting rooms and were left out); only the municipal image host is proxied, frames are fetched at request time and never stored or redistributed. Disable with `CCTV_WARENDORF_ENABLED=0`.
- **Live Traffic NSW cameras.** The camera list is the keyless public `data.livetraffic.com/cameras/traffic-cam.json` feed (Transport for NSW open data, CC BY 4.0); frames are stills on `webcams.transport.nsw.gov.au`, and only that host is registered. That image host answers non-browser clients with an HTML placeholder, so the proxy identifies as a browser for that one host and as itself everywhere else. Frames are fetched at request time and never stored or redistributed. Disable with `CCTV_NSW_ENABLED=0`.
- **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`.
- **Camera frame content.** Public camera frames can contain people, vehicles and license plates, and the camera layer does nothing with them beyond putting them on the map. A successful upstream response is relayed as the provider served it: nothing in the camera pipeline blurs, redacts, enhances or restores it, and nothing there runs face, plate or object detection or otherwise recognises or classifies what is in the picture. (The ALPR layer described below maps where plate-reader cameras are, from locations contributors tagged in OpenStreetMap; it reads nothing from any camera frame.) When an upstream has no frame for a camera, what you see instead is a Google Street View still or the app's own placeholder card; the panel's source badge names which of them you are looking at, and a refresh that fails after a frame has arrived leaves the last good picture on screen rather than replacing it. The browser resamples each frame for display: onto a fixed 1920x1080 surface for the camera's projection plane in the scene, and onto a 192x108 canvas for the small ambient card. That scaling is the only change made to the picture, and the only pixels the camera layer reads back are a 64x36 downscale hashed to tell whether the picture has changed. Live streams are piped through; the still path reads the image into memory to pass it on — TxDOT wraps its JPEG in a JSON envelope, which is decoded there — and no frame is written to disk (the disk cache, `.gev-cache/`, holds feed JSON for other layers and never camera imagery). Outside that pipeline, the voice assistant is the one feature that sends imagery anywhere: when it answers a question about what is in view it may attach a downscaled screenshot of the rendered scene — including any camera plane on screen — to the request it sends to the voice model. Frames are fetched only from server-registered camera URLs — a client cannot hand the proxy a URL (see [SECURITY.md](SECURITY.md)). What the app does stays inside the line README draws: no named-person search, no face recognition, no tracking individuals.
- **Radio Browser.** `/api/radio/stations` discovers official API mirrors, makes bounded and coalesced healthy/geolocated HTTPS-station queries, caches the normalized public-domain directory for 45 minutes, and may serve the last good catalog for up to seven days during an outage. Refreshes must meet minimum accepted-query and station coverage before replacing a warm catalog; schema-valid responses whose rows all fail the product's health policy do not count as successful queries. A usable partial cold catalog is explicitly `DEGRADED`, and malformed or empty successful payloads are rejected atomically. Every directory and click-count request rejects redirects, validates all resolved addresses as globally routable (including reserved/documentation IPv4 and special/non-global IPv6 exclusions), and pins the TLS connection to a validated address. Only MP3/AAC non-HLS directory rows with public HTTPS stream targets are returned; favicons are intentionally omitted. Pressing play connects one browser audio element directly to the selected broadcaster and calls the directory's click counter through known-ID-only `POST /api/radio/click/:uuid`. GEV never proxies, caches, records, bundles, or redistributes audio. Radio Browser supplies station-level tags, not dependable current-song or upcoming-program metadata, so Radio filtering never claims either. Direct playback exposes the listener's IP address to the broadcaster, whose own stream terms apply.
- **TomTom Traffic.** Optional and BYOK: without `TOMTOM_API_KEY` the traffic layer runs its built-in simulation and no TomTom data (or attribution) appears. With a key, flow vector tiles are fetched through the server-side `/api/tomtom` proxy (120 s cache + a daily tile-budget governor — `TOMTOM_DAILY_TILE_BUDGET`, default 40,000, a configurable application safety ceiling, not a guarantee of staying within TomTom's monthly free allowance; TomTom's [current pricing](https://docs.tomtom.com/pricing/) lists 200K free tile requests per month) and the "Traffic flow data © TomTom" credit is registered in the Data attribution popover the moment live mode activates. TomTom data is served live and cached only transiently (≤120 s TTL under `.gev-cache/`, gitignored) — it is not bundled or redistributed. One 23 KB point-in-time tile snapshot is committed as a decode-test fixture (`src/data/fixtures/`, © TomTom, never served to the app).
+1 -1
View File
@@ -275,7 +275,7 @@ Thirteen layers and map sources. **Eleven have a keyless path.** Some offer addi
| 🛰️ **Satellites** | 838-object catalog, color-coded by class with a live legend — the **DENSE** chip drops in the whole Starlink shell | CelesTrak | 🟢 |
| 🌍 **Earthquakes** | Global seismic activity, last 24h | USGS | 🟢 |
| 🚗 **Traffic** | Simulated vehicles on OSM roads. With TomTom, live flow speeds drive the simulation and congestion colors below ~8 km; individual vehicle positions are not live observations | TomTom + OSM | 🟢 simulation · 🟡 live flow speeds |
| 📹 **CCTV Mesh** | ~3,400 public cameras projected *into* the 3D space — Austin · Texas (TxDOT) · California (Caltrans) · London (TfL) · Ontario (511) · Finland (Fintraffic) · British Columbia (DriveBC) · Estonia (Tallinn, Tarktee) · New South Wales (Live Traffic NSW). Positions are published; poses are estimated priors **you calibrate by dragging a gizmo on the camera itself** | City APIs | 🟢 |
| 📹 **CCTV Mesh** | ~3,600 public cameras projected *into* the 3D space — Austin · Texas (TxDOT) · California (Caltrans) · London (TfL) · Ontario (511) · Finland (Fintraffic) · British Columbia (DriveBC) · Estonia (Tallinn, Tarktee) · New South Wales (Live Traffic NSW) · Calgary. Positions are published; poses are estimated priors **you calibrate by dragging a gizmo on the camera itself** | City APIs | 🟢 |
| 📻 **Radio** | Geolocated world radio with an **analog tuner** — drag the needle across up to 750 stations and the globe flies to each broadcaster | Radio Browser / broadcasters | 🟢 |
| 🚲 **Bikeshare** | Live station availability | GBFS | 🟢 |
| 🔥 **Active Fires** | Live NASA FIRMS detections, trailing 24h | NASA FIRMS | 🟡 |
+1 -1
View File
@@ -2367,7 +2367,7 @@ its criteria cannot be silently ignored.
| Satellites | CelesTrak | `src/data/satellites.js` | `/api/celestrak` | 120s |
| Space Missions (30d) | Launch Library 2 + CelesTrak | `src/data/rocketLaunches.js` | `/api/launches` + `/api/celestrak/active` | 5 min |
| Traffic | OSM Overpass (+ optional TomTom live flow) | `src/data/traffic.js` | `/api/overpass` + `/api/tomtom` | viewport-driven |
| CCTV | Austin + Caltrans (CA) + TfL London + Ontario 511 + Fintraffic (FI) + DriveBC (BC) + TxDOT (TX) + Estonia (Tallinn, Tarktee) + Live Traffic NSW Open Data + Street View fallback | `src/data/cctv.js` | `/api/cctv` | 10s (active) |
| CCTV | Austin + Caltrans (CA) + TfL London + Ontario 511 + Fintraffic (FI) + DriveBC (BC) + TxDOT (TX) + Estonia (Tallinn, Tarktee) + Live Traffic NSW + Open Calgary Open Data + Street View fallback | `src/data/cctv.js` | `/api/cctv` | 10s (active) |
| Radio | Radio Browser (public-domain station directory) | `src/data/radio.js` | `/api/radio/stations`, `/api/radio/click/:uuid` | 45 min directory refresh |
| Bikeshare 🚲 | GBFS (Lyft + BCycle) | `src/data/bikeshare.js` | `/api/gbfs` | 60s |
| Datacenters ▣ | OSM extract (bundled) | `src/data/localLayers.js` | — | static |
+6 -1
View File
@@ -16,7 +16,8 @@ HOST="${HOST:-localhost}"
# including Kitchener-area highways), and Fintraffic Finland weathercams
# (~2,260 live presets). Caps keep the densest cores per pack; override
# per-run for lighter/heavier loads. Kill switches: CCTV_CALTRANS_DISTRICTS='',
# CCTV_TFL_ENABLED=0, CCTV_ONTARIO_ENABLED=0 and CCTV_FINTRAFFIC_ENABLED=0.
# CCTV_TFL_ENABLED=0, CCTV_ONTARIO_ENABLED=0, CCTV_FINTRAFFIC_ENABLED=0 and
# CCTV_CALGARY_ENABLED=0 (Open Calgary, ~215 live upstream).
CCTV_AUSTIN_MAX_SOURCES="${CCTV_AUSTIN_MAX_SOURCES:-250}"
# Use `-` not `:-` so an explicit empty string (the documented kill switch)
# is preserved rather than replaced by the default. Still set-u-safe when unset.
@@ -41,6 +42,8 @@ CCTV_TARKTEE_MAX_SOURCES="${CCTV_TARKTEE_MAX_SOURCES:-179}"
CCTV_WARENDORF_ENABLED="${CCTV_WARENDORF_ENABLED:-1}"
CCTV_NSW_ENABLED="${CCTV_NSW_ENABLED:-1}"
CCTV_NSW_MAX_SOURCES="${CCTV_NSW_MAX_SOURCES:-250}"
CCTV_CALGARY_ENABLED="${CCTV_CALGARY_ENABLED:-1}"
CCTV_CALGARY_MAX_SOURCES="${CCTV_CALGARY_MAX_SOURCES:-220}"
CCTV_MAX_SOURCES="${CCTV_MAX_SOURCES:-4000}"
# Capture which provider credentials genuinely came from the parent shell
@@ -402,6 +405,8 @@ put_env CCTV_TARKTEE_MAX_SOURCES "${CCTV_TARKTEE_MAX_SOURCES}"
put_env CCTV_WARENDORF_ENABLED "${CCTV_WARENDORF_ENABLED}"
put_env CCTV_NSW_ENABLED "${CCTV_NSW_ENABLED}"
put_env CCTV_NSW_MAX_SOURCES "${CCTV_NSW_MAX_SOURCES}"
put_env CCTV_CALGARY_ENABLED "${CCTV_CALGARY_ENABLED}"
put_env CCTV_CALGARY_MAX_SOURCES "${CCTV_CALGARY_MAX_SOURCES}"
put_env CCTV_MAX_SOURCES "${CCTV_MAX_SOURCES}"
put_env OPENSKY_AUTH_MODE "${OPENSKY_AUTH_MODE}"
put_env_if_set OPENSKY_CREDENTIALS_FILE "${OPENSKY_CREDENTIALS_FILE}"
+1
View File
@@ -468,6 +468,7 @@
"src/data/cctvFootprint.test.mjs",
"src/data/cctvEstonia.test.mjs",
"src/data/cctvWarendorf.test.mjs",
"src/data/cctvCalgary.test.mjs",
"src/data/cctvNswSource.test.mjs",
"src/data/cctvTxdotSource.test.mjs",
"src/layers/cctv/source.test.mjs",
+1
View File
@@ -154,6 +154,7 @@
"server/providers/cctv/normalize.js",
"server/providers/cctv/range.js",
"server/providers/cctv/sources.js",
"server/providers/common/http.js",
"src/data/directionText.js",
"server/providers/common/geo.js",
"server/providers/places/google-key.js",
+6
View File
@@ -16,6 +16,7 @@ import {
loadTarkteeSourcesFromDatex,
loadWarendorfSourcesFromCatalog,
loadNswSourcesFromOpenData,
loadCalgarySourcesFromOpenData,
} from './sources.js';
/** Env kill switch: unset or anything but "0" means enabled. */
@@ -80,6 +81,11 @@ const LIVE_PACKS = [
enabled: () => envEnabled('CCTV_NSW_ENABLED'),
load: loadNswSourcesFromOpenData,
},
{
name: 'calgary',
enabled: () => envEnabled('CCTV_CALGARY_ENABLED'),
load: loadCalgarySourcesFromOpenData,
},
];
/**
* Load CCTV sources from a local JSON file (CCTV_SOURCES_FILE env or default).
+16
View File
@@ -220,6 +220,22 @@ export const NSW_IMAGE_USER_AGENT =
* out around 120 characters.
*/
export const NSW_MAX_VIEW_LABEL = 140;
/** Open Calgary traffic cameras: one keyless Socrata endpoint for the whole
* city; frames are stills on a City of Calgary host. */
export const DEFAULT_CALGARY_ROWS_URL =
'https://data.calgary.ca/resource/k7p9-kppz.json?$limit=500';
/** The only origin Calgary camera frames may come from. The catalog publishes
* most rows as `http://`; that host serves HTTPS and 301-redirects to it, so
* URLs are upgraded and then pinned here before registration. */
export const CALGARY_IMAGE_ORIGIN = 'https://trafficcam.calgary.ca/';
export const DEFAULT_CALGARY_MAX_SOURCES = 220;
/** Centre Street / 7 Avenue: the prioritization anchor. */
export const CALGARY_DOWNTOWN = { lat: 51.0461, lon: -114.0626 };
/** Hard ceiling on the Calgary catalog body. The whole city is ~215 rows and
* under 100 KB; this only exists so an upstream that streams an unbounded
* body cannot be buffered without limit. */
export const CALGARY_MAX_CATALOG_BYTES = 4 * 1024 * 1024;
/** Camera CATALOGS change rarely; 15 min keeps multi-megabyte upstream list refetches (Austin rows.json + 4 Caltrans districts + TfL + Ontario 511) infrequent. Frames are fetched per-request and are unaffected. */
export const CCTV_SOURCE_CACHE_MS = 15 * 60 * 1000;
/** Per-provider catalog-fetch timeout. Bounds the worst-case refresh so one
+11
View File
@@ -380,6 +380,17 @@ export function isLikelyNswCoordinate(lat, lon) {
);
}
/** Calgary's municipal extent, with slack for the ring road. */
export function isLikelyCalgaryCoordinate(lat, lon) {
return (
isPlausibleLatLon(lat, lon) &&
lat >= 50.8 &&
lat <= 51.25 &&
lon >= -114.4 &&
lon <= -113.8
);
}
export function isLikelyFinlandCoordinate(lat, lon) {
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return false;
return lat >= 59.5 && lat <= 70.5 && lon >= 19 && lon <= 32;
+205
View File
@@ -50,6 +50,11 @@ import {
DEFAULT_NSW_MAX_SOURCES,
SYDNEY_CENTER,
NSW_MAX_VIEW_LABEL,
DEFAULT_CALGARY_ROWS_URL,
CALGARY_IMAGE_ORIGIN,
DEFAULT_CALGARY_MAX_SOURCES,
CALGARY_DOWNTOWN,
CALGARY_MAX_CATALOG_BYTES,
CCTV_SOURCE_FETCH_TIMEOUT_MS,
} from './constants.js';
import {
@@ -67,11 +72,13 @@ import {
isLikelyBcCoordinate,
isLikelyTexasCoordinate,
isLikelyNswCoordinate,
isLikelyCalgaryCoordinate,
cameraDisplayCode,
rowArrayToObject,
prioritizeSources,
} from './normalize.js';
import { directionToHeading } from '../../../src/data/directionText.js';
import { readResponseJsonCapped } from '../common/http.js';
/**
* Fetch and parse Austin traffic camera records from the city Open Data portal.
*
@@ -1389,3 +1396,201 @@ export async function loadNswSourcesFromOpenData() {
return [];
}
}
/**
* Upgrade a catalog frame URL to HTTPS and pin it to the City of Calgary host.
*
* Most rows ship `http://`; the host answers HTTPS and 301-redirects there, so
* upgrading avoids a redirect on every frame fetch. Anything not on the
* official origin is refused rather than proxied, the same pin the TfL and
* Tarktee packs apply.
*
* @param {string|null|undefined} raw - `camera_url.url` from the dataset.
* @returns {?string} Pinned HTTPS URL, or null when unusable.
*/
export function normalizeCalgaryImageUrl(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(CALGARY_IMAGE_ORIGIN) ? upgraded : null;
}
/**
* Stable camera id from a frame URL.
*
* The dataset carries no id column; the frame filename ("loc86.jpg") is the
* only stable per-camera token and is what the city keys on. Falls back to a
* slug of the whole path so a filename-scheme change degrades to a still-stable
* id rather than dropping the camera.
*
* @param {string} imageUrl - A normalized Calgary frame URL.
* @returns {?string} Provider-stable id, or null when underivable.
*/
export function calgaryCameraId(imageUrl) {
const text = String(imageUrl ?? '').trim();
if (!text) return null;
let path;
try {
path = new URL(text).pathname;
} catch {
return null;
}
const numbered = path.match(/loc(\d+)\.jpg$/i);
if (numbered) return `calgary-${numbered[1]}`;
const slug = path
.replace(/^\/+|\.[a-z0-9]+$/gi, '')
.replace(/[^a-zA-Z0-9]+/g, '-')
.toLowerCase();
return slug ? `calgary-${slug}` : null;
}
/**
* Label for one Calgary camera: the intersection an operator recognises
* ("Bow Trail / 37 Street SW"), used verbatim including its quadrant suffix,
* which is part of the street address. It must never be read as a facing.
*
* @param {object} record - Raw Socrata row.
* @param {string} cameraId - Derived stable id.
* @returns {string}
*/
export function calgaryCameraName(record, cameraId) {
const location = String(record?.camera_location ?? '').trim();
if (location) return location;
const described = String(record?.camera_url?.description ?? '').trim();
if (described) return described;
return `Calgary Camera ${String(cameraId).replace(/^calgary-/, '')}`;
}
/**
* One Open Calgary row -> one catalog source, or null.
*
* NO HEADING IS DERIVED FROM THE RECORD, and the fields that look like one are
* not. Every row carries a `quadrant` ("NE"/"NW"/"SE"/"SW", and combinations
* like "NW/NE") and a `camera_location` ending in the same token ("9 Avenue /
* 3 Street SE"). That is Calgary's address grid — the quarter of the city the
* intersection sits in — not a camera bearing. Handing either to
* directionToHeading() returns a confident compass bearing for every row and
* every one would be wrong. Headings therefore use the shared id-hash fallback
* at low confidence, exactly as headingless TfL and Fintraffic cameras do, and
* the operator corrects them with the calibration gizmo.
*
* @param {object} record - Raw Socrata row.
* @returns {?object}
*/
export function calgaryCameraToSource(record) {
if (!record || typeof record !== 'object') return null;
const coordinates = record?.point?.coordinates;
if (!Array.isArray(coordinates) || coordinates.length < 2) return null;
const lon = toFiniteNumber(coordinates[0]);
const lat = toFiniteNumber(coordinates[1]);
if (!isLikelyCalgaryCoordinate(lat, lon)) return null;
const imageUrl = normalizeCalgaryImageUrl(record?.camera_url?.url);
if (!imageUrl) return null;
const cameraId = calgaryCameraId(imageUrl);
if (!cameraId) return null;
const name = calgaryCameraName(record, cameraId);
return {
id: cameraId,
name,
city: 'Calgary',
cityId: 'calgary',
provider: 'The City of Calgary',
lat,
lon,
headingDeg: fallbackHeadingFromId(cameraId),
headingConfidence: 'low',
pitchDeg: -18,
fovDeg: 44,
rangeM: 145,
mountHeightM: 8,
// Calgary sits high on the prairie; the client's one-shot ground snap
// corrects this prior wherever 3D tiles are loaded.
groundElevationM: 1045,
feedType: 'image',
url: imageUrl,
snapshotUrl: imageUrl,
sourceKind: 'calgary-open-data',
license:
'Contains information licensed under the Open Government Licence – City of Calgary',
// Unselected-label code: the intersection, so a camera at rest reads as a
// place rather than as its id.
code: cameraDisplayCode(name.toUpperCase()),
};
}
/**
* Fetch City of Calgary traffic cameras from Open Calgary (Socrata dataset
* `k7p9-kppz`), keyless. Frames are stills on trafficcam.calgary.ca.
*
* @returns {Promise<Array<object>>} Normalized camera source objects.
*/
export async function loadCalgarySourcesFromOpenData() {
try {
const endpoint =
process.env.CCTV_CALGARY_ROWS_URL || DEFAULT_CALGARY_ROWS_URL;
const resp = await fetch(endpoint, {
headers: { Accept: 'application/json' },
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] Calgary catalog redirected; redirects are not followed',
);
return discard();
}
if (!resp.ok) {
console.warn('[CCTV] Calgary camera download failed:', resp.status);
return discard();
}
const rows = await readResponseJsonCapped(resp, CALGARY_MAX_CATALOG_BYTES);
if (!Array.isArray(rows)) return [];
const cameras = [];
const seen = new Set();
for (const record of rows) {
const camera = calgaryCameraToSource(record);
if (!camera || seen.has(camera.id)) continue;
seen.add(camera.id);
cameras.push(camera);
}
const maxRaw = Number(
process.env.CCTV_CALGARY_MAX_SOURCES || DEFAULT_CALGARY_MAX_SOURCES,
);
const maxCount = Number.isFinite(maxRaw)
? Math.max(8, Math.min(400, Math.floor(maxRaw)))
: DEFAULT_CALGARY_MAX_SOURCES;
const prioritized = prioritizeSources(cameras, maxCount, [
CALGARY_DOWNTOWN,
]);
console.log(
`[CCTV] Loaded Calgary camera sources: ${cameras.length} (using nearest ${prioritized.length})`,
);
return prioritized;
} catch (error) {
console.warn(
'[CCTV] Calgary camera download error:',
error?.message || error,
);
return [];
}
}
+389
View File
@@ -0,0 +1,389 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
calgaryCameraId,
calgaryCameraName,
calgaryCameraToSource,
loadCalgarySourcesFromOpenData,
normalizeCalgaryImageUrl,
} from '../../server/providers/cctv/sources.js';
import {
CALGARY_IMAGE_ORIGIN,
CALGARY_MAX_CATALOG_BYTES,
DEFAULT_CALGARY_ROWS_URL,
DEFAULT_CCTV_MAX_SOURCES,
} from '../../server/providers/cctv/constants.js';
import { CAMERA_CODE_MAX_CHARS } from '../../server/providers/cctv/normalize.js';
import { allocateSourceCap } from '../../server/providers/cctv/cap.js';
import { createCctvCatalog } from '../../server/providers/cctv/catalog.js';
import { directionToHeading } from './directionText.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 Open Calgary row, shaped like the live `k7p9-kppz` payload. */
const row = (overrides = {}) => ({
camera_url: {
url: 'http://trafficcam.calgary.ca/loc142.jpg',
description: 'Camera 143',
},
quadrant: 'SW',
camera_location: 'Bow Trail / 37 Street SW',
point: { type: 'Point', coordinates: [-114.1413616, 51.0453097] },
...overrides,
});
test('a Calgary row maps to a source on the pinned HTTPS frame host', () => {
const source = calgaryCameraToSource(row());
assert.equal(source.id, 'calgary-142');
assert.equal(source.name, 'Bow Trail / 37 Street SW');
assert.equal(source.cityId, 'calgary');
assert.equal(source.provider, 'The City of Calgary');
assert.equal(source.lat, 51.0453097);
assert.equal(source.lon, -114.1413616);
assert.equal(source.feedType, 'image');
assert.equal(source.sourceKind, 'calgary-open-data');
assert.equal(
source.license,
'Contains information licensed under the Open Government Licence – City of Calgary',
);
// The catalog publishes http://; the registered URL is the upgraded one.
assert.equal(source.url, 'https://trafficcam.calgary.ca/loc142.jpg');
assert.equal(source.url, source.snapshotUrl);
assert.ok(source.url.startsWith(CALGARY_IMAGE_ORIGIN));
});
test('no heading is derived from the quadrant or the address suffix', () => {
// Both fields parse as a confident compass bearing, and both would be wrong
// for every camera in the city: they are Calgary's address grid, not a
// camera facing. Nobody may "fix" this by wiring either one up.
assert.equal(directionToHeading('SW', true), 225);
assert.equal(directionToHeading('Bow Trail / 37 Street SW', true), 225);
for (const quadrant of ['NE', 'NW', 'SE', 'SW', 'NW/NE', 'S']) {
const source = calgaryCameraToSource(row({ quadrant }));
assert.equal(source.headingConfidence, 'low');
assert.ok(Number.isFinite(source.headingDeg));
// Same id, same fallback heading, whatever the quadrant says.
assert.equal(source.headingDeg, calgaryCameraToSource(row()).headingDeg);
}
});
test('rows outside Calgary, off-host frames and unusable geometry are dropped', () => {
// Toronto: a plausible lat/lon, but not a Calgary camera.
assert.equal(
calgaryCameraToSource(
row({ point: { type: 'Point', coordinates: [-79.3832, 43.6532] } }),
),
null,
);
// Null island, and the string coordinates that would become it.
assert.equal(
calgaryCameraToSource(
row({ point: { type: 'Point', coordinates: [0, 0] } }),
),
null,
);
assert.equal(calgaryCameraToSource(row({ point: null })), null);
assert.equal(
calgaryCameraToSource(row({ point: { coordinates: [-114.14] } })),
null,
);
// A catalog edit cannot steer the frame proxy off the city host.
assert.equal(
calgaryCameraToSource(
row({ camera_url: { url: 'https://evil.example/loc1.jpg' } }),
),
null,
);
assert.equal(
calgaryCameraToSource(
row({
camera_url: { url: 'https://trafficcam.calgary.ca.evil.test/1.jpg' },
}),
),
null,
);
assert.equal(
calgaryCameraToSource(row({ camera_url: { url: 'file:///etc/passwd' } })),
null,
);
assert.equal(calgaryCameraToSource(row({ camera_url: null })), null);
assert.equal(calgaryCameraToSource(null), null);
});
test('frame URLs upgrade to HTTPS and ids stay stable', () => {
assert.equal(
normalizeCalgaryImageUrl('http://trafficcam.calgary.ca/loc86.jpg'),
'https://trafficcam.calgary.ca/loc86.jpg',
);
assert.equal(
normalizeCalgaryImageUrl('https://trafficcam.calgary.ca/loc86.jpg'),
'https://trafficcam.calgary.ca/loc86.jpg',
);
assert.equal(normalizeCalgaryImageUrl(''), null);
assert.equal(normalizeCalgaryImageUrl('not a url'), null);
assert.equal(
calgaryCameraId('https://trafficcam.calgary.ca/loc86.jpg'),
'calgary-86',
);
// A filename-scheme change degrades to a still-stable slug, not a dropped camera.
assert.equal(
calgaryCameraId('https://trafficcam.calgary.ca/cams/deerfoot_16av.jpg'),
'calgary-cams-deerfoot-16av',
);
assert.equal(calgaryCameraId(''), null);
});
test('a nameless row still gets a label', () => {
assert.equal(
calgaryCameraName(
{ camera_location: ' 9 Avenue / 3 Street SE ' },
'calgary-1',
),
'9 Avenue / 3 Street SE',
);
assert.equal(
calgaryCameraName(
{ camera_url: { description: 'Camera 12' } },
'calgary-11',
),
'Camera 12',
);
assert.equal(calgaryCameraName({}, 'calgary-11'), 'Calgary Camera 11');
});
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 Response.json([
row(),
// Same frame file twice: one camera, not two.
row({ camera_location: 'Bow Trail / 37 Street SW (duplicate)' }),
row({
camera_url: { url: 'http://trafficcam.calgary.ca/loc86.jpg' },
camera_location: 'Stoney Trail / Deerfoot Trail SE',
point: { type: 'Point', coordinates: [-113.9766063, 50.9007257] },
}),
row({ camera_url: { url: 'https://evil.example/x.jpg' } }),
]);
});
const cameras = await loadCalgarySourcesFromOpenData();
assert.deepEqual(requested, [DEFAULT_CALGARY_ROWS_URL]);
assert.deepEqual(
cameras.map((camera) => camera.id),
// Nearest downtown first: Bow Trail is inner-city, Stoney Trail is the ring road.
['calgary-142', 'calgary-86'],
);
assert.equal(cameras[0].name, 'Bow Trail / 37 Street SW');
});
test('an upstream failure yields an empty pack and releases the response', async (t) => {
t.mock.method(console, 'warn', () => {});
// A 503 can still arrive with a streaming body; returning without cancelling
// it would hold the connection until the socket times out.
const failed = streamingResponse({ status: 503 });
t.mock.method(globalThis, 'fetch', async () => failed.response);
assert.deepEqual(await loadCalgarySourcesFromOpenData(), []);
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 loadCalgarySourcesFromOpenData(), []);
});
test('the unselected label is the intersection, trimmed to the code width', () => {
assert.equal(calgaryCameraToSource(row()).code, 'BOW TRAIL / 37 STREET SW');
const long = calgaryCameraToSource(
row({
camera_location: 'Bow Trail & Old Banff Coach Rd SW / Strathcona Blvd SW',
}),
);
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', () => {});
// A redirect is never followed: the list host cannot be steered. Its body is
// a live stream, so the test also proves the refusal releases the transport.
const seen = [];
const redirected = streamingResponse({
status: 302,
headers: { location: 'https://evil.example/rows.json' },
});
t.mock.method(globalThis, 'fetch', async (url, init) => {
seen.push([String(url), init.redirect]);
return redirected.response;
});
assert.deepEqual(await loadCalgarySourcesFromOpenData(), []);
assert.deepEqual(seen, [[DEFAULT_CALGARY_ROWS_URL, 'manual']]);
assert.equal(
redirected.state.cancelled,
true,
'the redirect body is cancelled',
);
// A body over the cap is refused rather than buffered.
t.mock.restoreAll();
t.mock.method(console, 'warn', () => {});
t.mock.method(globalThis, 'fetch', async () => {
const body = JSON.stringify([row()]);
return new Response(body, {
headers: {
'Content-Type': 'application/json',
'content-length': String(CALGARY_MAX_CATALOG_BYTES + 1),
},
});
});
assert.deepEqual(await loadCalgarySourcesFromOpenData(), []);
// So is a body that only declares its size once it is already too long.
t.mock.restoreAll();
t.mock.method(console, 'warn', () => {});
t.mock.method(globalThis, 'fetch', async () => {
const oversized = 'x'.repeat(CALGARY_MAX_CATALOG_BYTES + 1024);
return new Response(
new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode(oversized));
controller.close();
},
}),
{ headers: { 'Content-Type': 'application/json' } },
);
});
assert.deepEqual(await loadCalgarySourcesFromOpenData(), []);
});
test('a reduced catalog cap thins every pack instead of dropping Calgary', () => {
// Calgary merges last in LIVE_PACKS, so a positional slice would delete it
// outright. The round-robin allocation must give it its share.
const lane = (name, count) => ({
name,
sources: Array.from({ length: count }, (_, i) => ({ id: `${name}-${i}` })),
});
const { sources, packs } = allocateSourceCap(
[lane('austin', 250), lane('nsw', 217), lane('calgary', 215)],
30,
);
assert.equal(sources.length, 30);
assert.deepEqual(
packs.map((p) => [p.name, p.kept]),
[
['austin', 10],
['nsw', 10],
['calgary', 10],
],
);
// Calgary contributes its own highest-priority cameras, in its own order.
assert.deepEqual(
sources.filter((s) => s.id.startsWith('calgary-')).map((s) => s.id),
Array.from({ length: 10 }, (_, i) => `calgary-${i}`),
);
});
/**
* Serve the Calgary catalog to the Calgary 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.startsWith('https://data.calgary.ca/')) {
return Response.json([
row(),
row({
camera_url: { url: 'http://trafficcam.calgary.ca/loc86.jpg' },
camera_location: 'Stoney Trail / Deerfoot Trail SE',
point: { type: 'Point', coordinates: [-113.9766063, 50.9007257] },
}),
]);
}
return Response.json([]);
});
// A source root with no curated catalogs or ground-height sidecar, so the
// file-based packs contribute nothing and only the live lanes are in play.
const sources = await createCctvCatalog({ sourceRoot: '/nonexistent' })();
return { requested, sources };
};
test('the Calgary 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_CALGARY_ENABLED;
const { requested, sources } = await runCatalogWithMockedUpstreams(t);
assert.ok(
requested.includes(DEFAULT_CALGARY_ROWS_URL),
'the catalog refresh invokes the Calgary loader',
);
assert.deepEqual(
sources.filter((s) => s.cityId === 'calgary').map((s) => s.id),
['calgary-142', 'calgary-86'],
'Calgary 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_CALGARY_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_CALGARY_ENABLED = '0';
const { requested, sources } = await runCatalogWithMockedUpstreams(t);
assert.equal(
requested.includes(DEFAULT_CALGARY_ROWS_URL),
false,
'the disabled lane never reaches its upstream',
);
assert.deepEqual(
sources.filter((s) => s.cityId === 'calgary'),
[],
);
} 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);
});
+6
View File
@@ -163,6 +163,12 @@ export const DATA_CREDITS = [
'<a href="https://www.digitraffic.fi/en/" target="_blank" rel="noopener">digitraffic.fi</a>, ' +
'license CC BY 4.0',
},
{
key: 'calgary-cctv',
html:
'Traffic cameras (Calgary): contains information licensed under the ' +
'<a href="https://data.calgary.ca/stories/s/Open-Calgary-Terms-of-Use/u45n-7awa" target="_blank" rel="noopener">Open Government Licence – City of Calgary</a>',
},
{
key: 'gbfs',
html: 'Bikeshare availability: GBFS operator feeds (e.g. Austin BCycle)',
File diff suppressed because it is too large. Load diff