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

+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 [];
}
}