mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
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:
1 parent
a04502559e
commit
409e27d7f9
15 files changed
+4743
-4
No files matched your search
@@ -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).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 [];
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user