mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 19:04:02 +00:00
feat(cctv): add the City of Toronto RESCU camera pack
The province's 511 pack covers Ontario's highways; this adds the municipal network underneath it — roughly 336 cameras across downtown arterials plus the Gardiner, DVP, Allen and Lake Shore, from the City's own open data. The list is keyless and carries no frame URL, so frame URLs are built from the convention the dataset documents (`CameraImages/loc####.jpg`) and a camera number validated as plain digits. The frame proxy is therefore pinned to the portal origin by construction rather than by trusting a field. The list fetch refuses redirects, bounds the body it buffers, and releases the transport on every rejection path. The dataset publishes no camera facing. `D1`-`D4` look like a compass and map trivially onto one, but they describe the *comparison* images — static reference stills captured in 2016 and served from a different path — not the live pan-tilt-zoom frame. A heading derived from them would be confidently wrong for every camera in the city, so every camera takes the shared id-hash fallback at low confidence, as headingless Austin, TfL, Fintraffic and Calgary cameras do. A test pins that down so it is not "fixed" later. Names stay verbatim: the feed's labels are upper-case, and title-casing would mangle MCCOWAN RD and ST HELEN'S AVE more often than it helped. Carries the Open Government Licence – Toronto attribution the licence requires. Disable with `CCTV_TORONTO_ENABLED=0`.
This commit is contained in:
1 parent
132b9e96dc
commit
51b89d3e4a
10 files changed
+675
-3
No files matched your search
@@ -19,6 +19,7 @@ import {
|
||||
loadCalgarySourcesFromOpenData,
|
||||
loadDelDOTSourcesFromOpenData,
|
||||
loadVegvesenSourcesFromOpenData,
|
||||
loadTorontoSourcesFromOpenData,
|
||||
} from './sources.js';
|
||||
|
||||
/** Env kill switch: unset or anything but "0" means enabled. */
|
||||
@@ -98,6 +99,11 @@ const LIVE_PACKS = [
|
||||
enabled: () => envEnabled('CCTV_VEGVESEN_ENABLED'),
|
||||
load: loadVegvesenSourcesFromOpenData,
|
||||
},
|
||||
{
|
||||
name: 'toronto',
|
||||
enabled: () => envEnabled('CCTV_TORONTO_ENABLED'),
|
||||
load: loadTorontoSourcesFromOpenData,
|
||||
},
|
||||
];
|
||||
/**
|
||||
* Load CCTV sources from a local JSON file (CCTV_SOURCES_FILE env or default).
|
||||
|
||||
@@ -275,6 +275,38 @@ export const DELDOT_ANCHORS = [
|
||||
{ lat: 39.1582, lon: -75.5244 }, // Dover (Kent)
|
||||
{ lat: 38.6906, lon: -75.3877 }, // Georgetown (Sussex)
|
||||
];
|
||||
/**
|
||||
* City of Toronto RESCU traffic cameras: the municipal network the province's
|
||||
* 511 pack does not cover — downtown arterials plus the Gardiner, DVP, Allen
|
||||
* and Lake Shore. One keyless list for the whole city (~336 cameras), from the
|
||||
* City's open data portal, refreshed within two minutes of any change.
|
||||
*
|
||||
* The list is served as JSONP (`jsonTMCEarthCamerasCallback({...})`); the
|
||||
* portal publishes the same data as .xml and .csv beside it.
|
||||
*/
|
||||
export const TORONTO_RESCU_LIST_URL =
|
||||
'https://opendata.toronto.ca/transportation/tmc/rescucameraimages/Data/tmcearthcameras.json';
|
||||
/**
|
||||
* The only origin Toronto camera frames may come from. The list carries no URL
|
||||
* field: frame URLs are BUILT from this origin and a strictly-digits camera
|
||||
* number (`loc####.jpg`, the convention the dataset documents), so no upstream
|
||||
* field can steer the frame proxy off-host.
|
||||
*/
|
||||
export const TORONTO_RESCU_IMAGE_ORIGIN =
|
||||
'https://opendata.toronto.ca/transportation/tmc/rescucameraimages/CameraImages/';
|
||||
/** The whole city fits under this; the cap exists to stay tunable, not to trim. */
|
||||
export const DEFAULT_TORONTO_MAX_SOURCES = 336;
|
||||
/** Yonge / Queen: the prioritization anchor. */
|
||||
export const TORONTO_DOWNTOWN = { lat: 43.6532, lon: -79.3832 };
|
||||
/** Toronto's lakeshore core sits near 76 m; the ravines and the north of the
|
||||
* city run higher. This prior only has to be close — the client's one-shot
|
||||
* ground snap corrects it wherever 3D tiles are loaded. */
|
||||
export const TORONTO_GROUND_ELEVATION_M = 90;
|
||||
/** Hard ceiling on the RESCU list body. The whole city is ~336 rows and under
|
||||
* 60 KB; this only exists so an upstream that streams an unbounded body cannot
|
||||
* be buffered without limit. */
|
||||
export const TORONTO_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
|
||||
|
||||
@@ -553,3 +553,14 @@ export function normalizeSourceItem(item) {
|
||||
poseSource: item.poseSource === 'curated' ? 'curated' : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/** City of Toronto bounding box (Steeles to the lake, Etobicoke to Scarborough). */
|
||||
export function isLikelyTorontoCoordinate(lat, lon) {
|
||||
return (
|
||||
isPlausibleLatLon(lat, lon) &&
|
||||
lat >= 43.55 &&
|
||||
lat <= 43.9 &&
|
||||
lon >= -79.7 &&
|
||||
lon <= -79.1
|
||||
);
|
||||
}
|
||||
@@ -55,6 +55,12 @@ import {
|
||||
DEFAULT_CALGARY_MAX_SOURCES,
|
||||
CALGARY_DOWNTOWN,
|
||||
CALGARY_MAX_CATALOG_BYTES,
|
||||
TORONTO_RESCU_LIST_URL,
|
||||
TORONTO_RESCU_IMAGE_ORIGIN,
|
||||
DEFAULT_TORONTO_MAX_SOURCES,
|
||||
TORONTO_DOWNTOWN,
|
||||
TORONTO_GROUND_ELEVATION_M,
|
||||
TORONTO_MAX_CATALOG_BYTES,
|
||||
CCTV_SOURCE_FETCH_TIMEOUT_MS,
|
||||
DELDOT_CCTV_URL,
|
||||
DEFAULT_DELDOT_MAX_SOURCES,
|
||||
@@ -83,12 +89,16 @@ import {
|
||||
isLikelyNswCoordinate,
|
||||
isLikelyCalgaryCoordinate,
|
||||
isLikelyNorwayCoordinate,
|
||||
isLikelyTorontoCoordinate,
|
||||
cameraDisplayCode,
|
||||
rowArrayToObject,
|
||||
prioritizeSources,
|
||||
} from './normalize.js';
|
||||
import { directionToHeading } from '../../../src/data/directionText.js';
|
||||
import { readResponseJsonCapped } from '../common/http.js';
|
||||
import {
|
||||
readResponseJsonCapped,
|
||||
readResponseTextCapped,
|
||||
} from '../common/http.js';
|
||||
/**
|
||||
* Fetch and parse Austin traffic camera records from the city Open Data portal.
|
||||
*
|
||||
@@ -1863,3 +1873,172 @@ export async function loadVegvesenSourcesFromOpenData() {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The JSONP envelope the City of Toronto wraps its camera list in. Anchored at
|
||||
* both ends, so only that exact callback is unwrapped — a payload that merely
|
||||
* contains the name somewhere is not treated as the list.
|
||||
*/
|
||||
const RESCU_JSONP = /^jsonTMCEarthCamerasCallback\(([\s\S]*)\)\s*;?$/;
|
||||
|
||||
/**
|
||||
* Unwrap the RESCU camera list. The portal serves the list as JSONP; should it
|
||||
* ever drop the wrapper, the bare JSON document still parses. Anything else —
|
||||
* a foreign callback, a truncated body, an error page — yields no cameras
|
||||
* rather than a thrown loader.
|
||||
*
|
||||
* @param {string} text - Raw response body.
|
||||
* @returns {Array<object>} The `Data` rows, or [] when the body is unusable.
|
||||
*/
|
||||
export function parseRescuCameraList(text) {
|
||||
const body = String(text ?? '').trim();
|
||||
if (!body) return [];
|
||||
const unwrapped = RESCU_JSONP.exec(body)?.[1] ?? body;
|
||||
try {
|
||||
const parsed = JSON.parse(unwrapped);
|
||||
const rows = parsed?.Data ?? parsed?.data;
|
||||
return Array.isArray(rows) ? rows : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert one RESCU list row into a camera source.
|
||||
*
|
||||
* The row carries no URL: the frame URL is built from the documented
|
||||
* `loc####.jpg` convention and a camera number validated as plain digits, so
|
||||
* the frame proxy is pinned to the city origin by construction and no upstream
|
||||
* field — present or planted — can steer it.
|
||||
*
|
||||
* No compass heading exists in this dataset. `D1`-`D4` are the directions of
|
||||
* the *comparison* images (static reference stills captured in 2016, served
|
||||
* from a different path); the live frame is one pan-tilt-zoom camera pointing
|
||||
* wherever the operator left it. Treating those letters as a facing would put
|
||||
* a confident, wrong bearing on every camera in the city, so every row takes
|
||||
* the id-hash fallback and the low-confidence pose, as headingless Austin,
|
||||
* TfL, Fintraffic and Calgary cameras do.
|
||||
*
|
||||
* @param {object} row - One entry from the RESCU `Data` array.
|
||||
* @returns {object|null} Normalized camera source, or null when unusable.
|
||||
*/
|
||||
export function torontoCameraToSource(row) {
|
||||
if (!row || typeof row !== 'object') return null;
|
||||
const number = String(row.Number ?? row.number ?? '').trim();
|
||||
// Plain digits only: a number is about to become a URL path segment.
|
||||
if (!/^\d{1,6}$/.test(number)) return null;
|
||||
|
||||
const lat = toFiniteNumber(row.Latitude ?? row.latitude);
|
||||
const lon = toFiniteNumber(row.Longitude ?? row.longitude);
|
||||
if (!isLikelyTorontoCoordinate(lat, lon)) return null;
|
||||
|
||||
const cameraId = `toronto-${number}`;
|
||||
// The feed's own label, verbatim. It is upper-case ("MCCOWAN RD", "ST
|
||||
// HELEN'S AVE"); title-casing it would mangle more names than it fixed.
|
||||
const name =
|
||||
String(row.Name ?? row.name ?? '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim() || `Toronto Camera ${number}`;
|
||||
|
||||
return {
|
||||
id: cameraId,
|
||||
name,
|
||||
city: 'Toronto',
|
||||
cityId: 'toronto',
|
||||
provider: 'City of Toronto',
|
||||
lat,
|
||||
lon,
|
||||
headingDeg: fallbackHeadingFromId(cameraId),
|
||||
headingConfidence: 'low',
|
||||
pitchDeg: -18,
|
||||
fovDeg: 44,
|
||||
rangeM: 145,
|
||||
mountHeightM: 8,
|
||||
groundElevationM: TORONTO_GROUND_ELEVATION_M,
|
||||
feedType: 'image',
|
||||
url: `${TORONTO_RESCU_IMAGE_ORIGIN}loc${number}.jpg`,
|
||||
snapshotUrl: `${TORONTO_RESCU_IMAGE_ORIGIN}loc${number}.jpg`,
|
||||
sourceKind: 'toronto-rescu-open-data',
|
||||
license:
|
||||
'Contains information licensed under the Open Government Licence – Toronto',
|
||||
// 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 Toronto RESCU traffic cameras from the City's open data
|
||||
* portal, keyless. One JSONP list for the whole city; frames are stills on the
|
||||
* same portal origin.
|
||||
*
|
||||
* The list fetch refuses redirects (`redirect: 'manual'`) so the list host
|
||||
* cannot be steered, and caps the body it will buffer.
|
||||
*
|
||||
* Attribution: Open Government Licence – Toronto, registered in
|
||||
* src/data/dataCredits.js.
|
||||
*
|
||||
* @returns {Promise<Array<object>>} Normalized camera source objects.
|
||||
*/
|
||||
export async function loadTorontoSourcesFromOpenData() {
|
||||
try {
|
||||
const endpoint =
|
||||
process.env.CCTV_TORONTO_LIST_URL || TORONTO_RESCU_LIST_URL;
|
||||
const resp = await fetch(endpoint, {
|
||||
headers: { Accept: 'application/json, text/javascript' },
|
||||
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] Toronto camera list redirected; redirects are not followed',
|
||||
);
|
||||
return discard();
|
||||
}
|
||||
if (!resp.ok) {
|
||||
console.warn('[CCTV] Toronto camera download failed:', resp.status);
|
||||
return discard();
|
||||
}
|
||||
const text = await readResponseTextCapped(resp, TORONTO_MAX_CATALOG_BYTES);
|
||||
const rows = parseRescuCameraList(text);
|
||||
if (!rows.length) return [];
|
||||
|
||||
const cameras = [];
|
||||
const seen = new Set();
|
||||
for (const record of rows) {
|
||||
const camera = torontoCameraToSource(record);
|
||||
if (!camera || seen.has(camera.id)) continue;
|
||||
seen.add(camera.id);
|
||||
cameras.push(camera);
|
||||
}
|
||||
const maxRaw = Number(
|
||||
process.env.CCTV_TORONTO_MAX_SOURCES || DEFAULT_TORONTO_MAX_SOURCES,
|
||||
);
|
||||
const maxCount = Number.isFinite(maxRaw)
|
||||
? Math.max(8, Math.min(500, Math.floor(maxRaw)))
|
||||
: DEFAULT_TORONTO_MAX_SOURCES;
|
||||
const prioritized = prioritizeSources(cameras, maxCount, [
|
||||
TORONTO_DOWNTOWN,
|
||||
]);
|
||||
console.log(
|
||||
`[CCTV] Loaded Toronto camera sources: ${cameras.length} (using nearest ${prioritized.length})`,
|
||||
);
|
||||
return prioritized;
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
'[CCTV] Toronto camera download error:',
|
||||
error?.message || error,
|
||||
);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user