Files
gods-eye-view/server/providers/cctv/sources.js
T
JCY2515andCursor 22c22d1876 feat: add Hong Kong Transport Department CCTV pack
Fetch keyless DATA.GOV.HK camera locations at runtime and pin stills to
tdcctv.data.one.gov.hk with required attribution. Default cap 1,100 covers
the ~1,013-camera TD catalog, prioritized across Hong Kong Island, Kowloon
and the New Territories.

Co-authored-by: Cursor <[email protected]>
2026-10-20 14:40:01 +01:00

3637 lines
126 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import fs from 'node:fs';
import path from 'node:path';
import {
DEFAULT_AUSTIN_ROWS_URL,
DEFAULT_AUSTIN_MAX_SOURCES,
AUSTIN_DOWNTOWN,
CALTRANS_CCTV_URL,
DEFAULT_CALTRANS_DISTRICTS,
DEFAULT_CALTRANS_MAX_SOURCES,
CALTRANS_ANCHORS,
TFL_JAMCAM_URL,
TFL_IMAGE_ORIGIN,
DEFAULT_TFL_MAX_SOURCES,
LONDON_CENTER,
ONTARIO_511_CAMERAS_URL,
ONTARIO_511_IMAGE_ORIGIN,
DEFAULT_ONTARIO_MAX_SOURCES,
ONTARIO_ANCHORS,
FINTRAFFIC_STATIONS_URL,
FINTRAFFIC_IMAGE_ORIGIN,
FINTRAFFIC_GROUND_ELEVATION_M,
DIGITRAFFIC_USER,
DEFAULT_FINTRAFFIC_MAX_SOURCES,
FINLAND_ANCHORS,
DRIVEBC_WEBCAMS_URL,
DRIVEBC_IMAGE_URL,
DEFAULT_DRIVEBC_MAX_SOURCES,
DRIVEBC_ANCHORS,
TXDOT_CCTV_STATUS_URL,
TXDOT_CCTV_SNAPSHOT_URL,
TXDOT_DISTRICTS,
DEFAULT_TXDOT_DISTRICTS,
DEFAULT_TXDOT_MAX_SOURCES,
TXDOT_ANCHORS,
TXDOT_DISTRICT_ELEVATION_M,
TXDOT_DEFAULT_ELEVATION_M,
DEFAULT_TALLINN_SOURCE_FILE,
DEFAULT_TALLINN_MAX_SOURCES,
TALLINN_IMAGE_ORIGIN,
TALLINN_CENTER,
TARKTEE_LOCATIONS_URL,
TARKTEE_IMAGES_URL,
TARKTEE_IMAGE_ORIGIN,
DEFAULT_TARKTEE_MAX_SOURCES,
TARKTEE_MAX_CATALOG_BYTES,
TARKTEE_ANCHORS,
DEFAULT_WARENDORF_SOURCE_FILE,
WARENDORF_IMAGE_ORIGINS,
NSW_CAMERAS_URL,
NSW_IMAGE_ORIGIN,
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,
FLORIDA_CCTV_URL,
FLORIDA_IMAGE_HOSTS,
DEFAULT_FLORIDA_MAX_SOURCES,
FLORIDA_ANCHORS,
FLORIDA_MAX_CATALOG_BYTES,
FLORIDA_MAX_CATALOG_PAGES,
CATALONIA_CAMERAS_URL,
CATALONIA_MAX_CATALOG_BYTES,
CATALONIA_IMAGE_HOSTS,
CATALONIA_SCT_CAMERA_FILE_RE,
CATALONIA_FONT_CREDIT,
CATALONIA_FONT_ELEVATION_M,
DEFAULT_CATALONIA_MAX_SOURCES,
CATALONIA_ANCHORS,
TORONTO_RESCU_LIST_URL,
TORONTO_RESCU_IMAGE_ORIGIN,
DEFAULT_TORONTO_MAX_SOURCES,
TORONTO_DOWNTOWN,
TORONTO_GROUND_ELEVATION_M,
TORONTO_MAX_CATALOG_BYTES,
DEFAULT_HK_CAMERAS_URL,
HK_IMAGE_ORIGIN,
DEFAULT_HK_MAX_SOURCES,
HK_ANCHORS,
HK_MAX_CATALOG_BYTES,
CCTV_SOURCE_FETCH_TIMEOUT_MS,
TARKTEE_FETCH_TIMEOUT_MS,
DELDOT_CCTV_URL,
DEFAULT_DELDOT_MAX_SOURCES,
DELDOT_ANCHORS,
KING_COUNTY_CAMERAS_URL,
KING_COUNTY_IMAGE_ORIGIN,
DEFAULT_KING_COUNTY_MAX_SOURCES,
KING_COUNTY_ANCHORS,
KING_COUNTY_MAX_CATALOG_BYTES,
WSDOT_CAMERAS_URL,
WSDOT_IMAGE_ORIGIN,
DEFAULT_WSDOT_MAX_SOURCES,
WSDOT_ANCHORS,
WSDOT_MAX_CATALOG_BYTES,
DEFAULT_VEGVESEN_CCTV_URL,
VEGVESEN_IMAGE_ORIGIN,
VEGVESEN_VIDEO_URL,
DEFAULT_VEGVESEN_MAX_SOURCES,
VEGVESEN_MAX_CATALOG_BYTES,
NORWAY_ANCHORS,
KARTVERKET_HEIGHT_URL,
KARTVERKET_MAX_POINTS,
KARTVERKET_CONCURRENCY,
KARTVERKET_LOOKUP_TIMEOUT_MS,
KARTVERKET_REQUEST_TIMEOUT_MS,
KARTVERKET_WAIT_MISSING_SHARE,
KARTVERKET_CATALOG_WAIT_MS,
KARTVERKET_NULL_TTL_MS,
KARTVERKET_FAILURE_BACKOFF_MS,
KARTVERKET_MAX_BACKOFF_MS,
KARTVERKET_USER_AGENT,
KARTVERKET_MAX_RESPONSE_BYTES,
KARTVERKET_HEIGHT_RANGE_M,
NYC_CAMERAS_URL,
NYC_IMAGE_URL,
NYC_CAMERA_ID_RE,
DEFAULT_NYC_MAX_SOURCES,
NYC_MAX_CATALOG_BYTES,
NYC_GROUND_ELEVATION_M,
NYC_ANCHORS,
} from './constants.js';
import {
toFiniteNumber,
extractAustinCoords,
extractAustinCameraId,
extractAustinName,
extractAustinHeading,
isLikelyAustinCoordinate,
fallbackHeadingFromId,
isLikelyFinlandCoordinate,
fintrafficCameraName,
hashSeed,
isPlausibleLatLon,
isLikelyBcCoordinate,
isLikelyTexasCoordinate,
isLikelyNswCoordinate,
isLikelyCalgaryCoordinate,
isLikelyFloridaCoordinate,
isLikelyCataloniaCoordinate,
decodeNumericEntities,
isLikelyNycCoordinate,
isLikelyHongKongCoordinate,
isLikelyNorwayCoordinate,
isLikelyTorontoCoordinate,
cameraDisplayCode,
rowArrayToObject,
prioritizeSources,
} from './normalize.js';
import { directionToHeading } from '../../../src/data/directionText.js';
import {
readResponseBytesCapped,
readResponseJsonCapped,
readResponseTextCapped,
} from '../common/http.js';
/**
* Fetch and parse Austin traffic camera records from the city Open Data portal.
*
* Downloads the Socrata rows.json payload, converts each row to a keyed
* record, extracts camera ID / coords / heading / name, validates against
* the Austin bounding box, deduplicates by ID, then distance-prioritizes
* to stay within CCTV_AUSTIN_MAX_SOURCES.
*
* @returns {Promise<Array<object>>} Normalized camera source objects.
*/
export async function loadAustinSourcesFromOpenData() {
const endpoint = process.env.CCTV_AUSTIN_ROWS_URL || DEFAULT_AUSTIN_ROWS_URL;
try {
const resp = await fetch(endpoint, {
headers: { Accept: 'application/json' },
signal: AbortSignal.timeout(CCTV_SOURCE_FETCH_TIMEOUT_MS),
});
if (!resp.ok) {
console.warn('[CCTV] Austin source download failed:', resp.status);
return [];
}
const payload = await resp.json();
const columns = Array.isArray(payload?.meta?.view?.columns)
? payload.meta.view.columns
: [];
const rows = Array.isArray(payload?.data) ? payload.data : [];
if (!columns.length || !rows.length) return [];
const cameras = [];
for (const row of rows) {
if (!Array.isArray(row)) continue;
const record = rowArrayToObject(row, columns);
const cameraId = extractAustinCameraId(record);
if (!cameraId) continue;
// Only live cameras: the dataset carries DESIRED (planned, not built),
// REMOVED and VOID rows whose frame URLs never resolve — those cameras
// would render as permanent Street View / synthetic fallbacks. Tolerate
// a missing column (keep the row) so a schema change fails open.
const status = String(record.camera_status || '')
.trim()
.toUpperCase();
if (status && status !== 'TURNED_ON') continue;
const { lat, lon } = extractAustinCoords(record);
if (!Number.isFinite(lat) || !Number.isFinite(lon)) continue;
if (!isLikelyAustinCoordinate(lat, lon)) continue;
const extractedHeading = extractAustinHeading(record);
const hasHeading = Number.isFinite(extractedHeading);
const headingDeg = hasHeading
? extractedHeading
: fallbackHeadingFromId(cameraId);
cameras.push({
id: cameraId,
name: extractAustinName(record, cameraId),
city: 'Austin',
cityId: 'austin',
provider: 'Austin Transportation & Public Works',
lat,
lon,
headingDeg,
headingConfidence: hasHeading ? 'high' : 'low',
pitchDeg: hasHeading ? -24 : -18,
fovDeg: hasHeading ? 56 : 44,
rangeM: hasHeading ? 210 : 145,
mountHeightM: hasHeading ? 10 : 8,
groundElevationM: 150,
feedType: 'image',
url: `https://cctv.austinmobility.io/image/${encodeURIComponent(cameraId)}.jpg`,
snapshotUrl: `https://cctv.austinmobility.io/image/${encodeURIComponent(cameraId)}.jpg`,
sourceKind: 'austin-open-data',
license: 'Public city traffic camera frame',
});
}
const unique = Array.from(
new Map(cameras.map((camera) => [camera.id, camera])).values(),
);
const maxRaw = Number(
process.env.CCTV_AUSTIN_MAX_SOURCES || DEFAULT_AUSTIN_MAX_SOURCES,
);
const maxCount = Number.isFinite(maxRaw)
? Math.max(8, Math.min(300, Math.floor(maxRaw)))
: DEFAULT_AUSTIN_MAX_SOURCES;
const prioritized = prioritizeSources(unique, maxCount, [AUSTIN_DOWNTOWN]);
if (prioritized.length < unique.length) {
console.log(
`[CCTV] Loaded Austin camera sources: ${unique.length} (using nearest ${prioritized.length})`,
);
} else {
console.log('[CCTV] Loaded Austin camera sources:', prioritized.length);
}
return prioritized;
} catch (error) {
console.warn(
'[CCTV] Austin source download error:',
error?.message || error,
);
return [];
}
}
/**
* Fetch Caltrans CCTV cameras for the configured districts (CCTV_CALTRANS_DISTRICTS,
* comma-separated 1..12; empty string disables the pack). One official JSON feed per
* district, identical schema statewide; keyless. Only inService cameras with finite
* coords and a cwwp2.dot.ca.gov https image URL are kept (the image-URL origin check
* is defense-in-depth: the proxy only ever fetches catalog URLs, and this pins the
* catalog to the official host). Districts fetch in parallel and fail independently
* (Promise.allSettled) — one district outage never darkens the others.
*
* @returns {Promise<Array<object>>} Normalized camera source objects.
*/
export async function loadCaltransSourcesFromOpenData() {
const districtsRaw =
process.env.CCTV_CALTRANS_DISTRICTS ?? DEFAULT_CALTRANS_DISTRICTS;
const districts = String(districtsRaw)
.split(',')
.map((token) => Number(token.trim()))
.filter((n) => Number.isInteger(n) && n >= 1 && n <= 12);
if (!districts.length) return [];
const settled = await Promise.allSettled(
districts.map(async (district) => {
const resp = await fetch(CALTRANS_CCTV_URL(district), {
headers: { Accept: 'application/json' },
signal: AbortSignal.timeout(CCTV_SOURCE_FETCH_TIMEOUT_MS),
});
if (!resp.ok) throw new Error(`D${district} HTTP ${resp.status}`);
const payload = await resp.json();
const rows = Array.isArray(payload?.data) ? payload.data : [];
return { district, rows };
}),
);
const cameras = [];
for (const result of settled) {
if (result.status !== 'fulfilled') {
console.warn(
'[CCTV] Caltrans district fetch failed:',
result.reason?.message || result.reason,
);
continue;
}
const { district, rows } = result.value;
for (const row of rows) {
const cctv = row?.cctv;
if (!cctv || String(cctv.inService).toLowerCase() !== 'true') continue;
const loc = cctv.location || {};
const lat = toFiniteNumber(loc.latitude);
const lon = toFiniteNumber(loc.longitude);
if (!Number.isFinite(lat) || !Number.isFinite(lon)) continue;
const imageUrl = String(cctv.imageData?.static?.currentImageURL || '');
// Official-host pin (see JSDoc). Also drops records with no still image.
if (!imageUrl.startsWith('https://cwwp2.dot.ca.gov/')) continue;
const locationName = String(loc.locationName || '').trim();
// Leading token of locationName is the stable camera code ("TV102 -- I-580 : …").
const codeMatch = /^([A-Za-z0-9_-]+)\s*--/.exec(locationName);
const code = (
codeMatch ? codeMatch[1] : `x${cameras.length}`
).toLowerCase();
const cameraId = `ca-d${district}-${code}`;
// loc.direction is a dedicated field ("West", "South") → allow bare words.
const heading = directionToHeading(loc.direction, true);
const hasHeading = Number.isFinite(heading);
const label =
locationName.replace(/^([A-Za-z0-9_-]+)\s*--\s*/, '') ||
`Caltrans D${district} ${code}`;
cameras.push({
id: cameraId,
name: loc.nearbyPlace ? `${label} (${loc.nearbyPlace})` : label,
city: String(loc.nearbyPlace || `Caltrans D${district}`),
cityId: `ca-d${district}`,
provider: 'Caltrans',
lat,
lon,
headingDeg: hasHeading ? heading : fallbackHeadingFromId(cameraId),
headingConfidence: hasHeading ? 'high' : 'low',
// Same two fabricated pose personalities as Austin (design §1a): these are
// RAW PRIOR starting points; the client's one-shot ground snap + manual
// calibration own the truth.
pitchDeg: hasHeading ? -24 : -18,
fovDeg: hasHeading ? 56 : 44,
rangeM: hasHeading ? 210 : 145,
mountHeightM: hasHeading ? 10 : 8,
// loc.elevation is reported in FEET (verified: D3 maxes at 7427 ft ≈
// 2264 m for the Sierra passes — as metres that would top Mt Whitney).
// Convert to metres and clamp to a sane CA-roads range so an occasional
// garbage upstream value can't fling a camera kilometres up. Prior only:
// the client one-shot snap corrects it on 3D-tile stacks — but on a
// no-tileset stack (keyless OSM) the snap misses and this height freezes,
// so it must be right-ish on its own.
groundElevationM: (() => {
const ft = toFiniteNumber(loc.elevation, NaN);
return Number.isFinite(ft)
? Math.max(-100, Math.min(4000, ft * 0.3048))
: 150;
})(),
feedType: 'image',
url: imageUrl,
snapshotUrl: imageUrl,
sourceKind: 'caltrans-open-data',
license: 'Public Caltrans highway camera frame',
});
}
}
const maxRaw = Number(
process.env.CCTV_CALTRANS_MAX_SOURCES || DEFAULT_CALTRANS_MAX_SOURCES,
);
const maxCount = Number.isFinite(maxRaw)
? Math.max(8, Math.min(600, Math.floor(maxRaw)))
: DEFAULT_CALTRANS_MAX_SOURCES;
const prioritized = prioritizeSources(cameras, maxCount, CALTRANS_ANCHORS);
console.log(
`[CCTV] Loaded Caltrans camera sources: ${cameras.length} inService (using nearest ${prioritized.length})`,
);
return prioritized;
}
/**
* Fetch TfL JamCams (London). Keyless: the optional TFL_APP_KEY only raises the
* list-endpoint rate limit (frames come from TfL's public S3 bucket, which is not
* rate-limited); the 15-min source cache keeps list hits far below anonymous
* limits anyway. Only `available === "true"` cameras with finite coords and an
* image URL on the official bucket are kept. Attribution: "Powered by TfL Open
* Data" (registered in src/data/dataCredits.js).
*
* @returns {Promise<Array<object>>} Normalized camera source objects.
*/
export async function loadTflSourcesFromOpenData() {
try {
const appKey = String(process.env.TFL_APP_KEY || '').trim();
const url = appKey
? `${TFL_JAMCAM_URL}?app_key=${encodeURIComponent(appKey)}`
: TFL_JAMCAM_URL;
const resp = await fetch(url, {
headers: { Accept: 'application/json' },
signal: AbortSignal.timeout(CCTV_SOURCE_FETCH_TIMEOUT_MS),
});
if (!resp.ok) {
console.warn('[CCTV] TfL JamCam download failed:', resp.status);
return [];
}
const places = await resp.json();
if (!Array.isArray(places)) return [];
const cameras = [];
for (const place of places) {
const props = {};
for (const p of place?.additionalProperties || []) {
if (p?.key) props[p.key] = p.value;
}
if (String(props.available).toLowerCase() !== 'true') continue;
const lat = toFiniteNumber(place?.lat);
const lon = toFiniteNumber(place?.lon);
if (!Number.isFinite(lat) || !Number.isFinite(lon)) continue;
const imageUrl = String(props.imageUrl || '');
if (!imageUrl.startsWith(TFL_IMAGE_ORIGIN)) continue; // official-bucket pin
// "JamCams_00002.00865" → "tfl-00002.00865" (provider-stable id).
const rawId = String(place?.id || '').replace(/^JamCams_/, '');
if (!rawId) continue;
const cameraId = `tfl-${rawId}`;
cameras.push({
id: cameraId,
name: String(place?.commonName || `JamCam ${rawId}`),
city: 'London',
cityId: 'london',
provider: 'Transport for London',
lat,
lon,
// No heading signal at all in JamCam data → id-hash fallback, low
// confidence personality (same as headingless Austin cameras).
headingDeg: fallbackHeadingFromId(cameraId),
headingConfidence: 'low',
pitchDeg: -18,
fovDeg: 44,
rangeM: 145,
mountHeightM: 8,
groundElevationM: 15, // Thames-basin prior; one-shot snap corrects.
feedType: 'image', // stills-first (owner decision); props.videoUrl deliberately unused
url: imageUrl,
snapshotUrl: imageUrl,
sourceKind: 'tfl-open-data',
license: 'Powered by TfL Open Data',
});
}
const maxRaw = Number(
process.env.CCTV_TFL_MAX_SOURCES || DEFAULT_TFL_MAX_SOURCES,
);
const maxCount = Number.isFinite(maxRaw)
? Math.max(8, Math.min(600, Math.floor(maxRaw)))
: DEFAULT_TFL_MAX_SOURCES;
const prioritized = prioritizeSources(cameras, maxCount, [LONDON_CENTER]);
console.log(
`[CCTV] Loaded TfL JamCam sources: ${cameras.length} available (using nearest ${prioritized.length})`,
);
return prioritized;
} catch (error) {
console.warn('[CCTV] TfL JamCam download error:', error?.message || error);
return [];
}
}
/**
* Bounding-box sanity check for Ontario 511 rows.
*
* @param {number} lat
* @param {number} lon
* @returns {boolean}
*/
function isLikelyOntarioCoordinate(lat, lon) {
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return false;
return lat >= 41.0 && lat <= 57.5 && lon >= -95.6 && lon <= -74.0;
}
/**
* Pin an Ontario 511 camera view URL to the official still-image host.
*
* @param {string} value - Upstream view URL.
* @returns {string} Canonical 511on.ca still URL, or '' if not accepted.
*/
function normalizeOntarioCctvUrl(value) {
try {
const parsed = new URL(String(value || '').trim());
const match = /^\/map\/Cctv\/([^/?#]+)$/.exec(parsed.pathname);
if (!match) return '';
const host = parsed.hostname.toLowerCase();
if (
parsed.protocol !== 'https:' ||
(host !== '511on.ca' && !host.endsWith('.traveliq.co'))
) {
return '';
}
const viewId = decodeURIComponent(match[1]);
if (!/^[A-Za-z0-9_.-]+$/.test(viewId)) return '';
return `${ONTARIO_511_IMAGE_ORIGIN}${encodeURIComponent(viewId)}`;
} catch {
return '';
}
}
/**
* Select the best Ontario 511 still view for a camera.
*
* @param {Array<object>} views
* @returns {{url:string,description:string}|null}
*/
function pickOntarioCctvView(views) {
const enabled = (Array.isArray(views) ? views : [])
.filter(
(view) =>
String(view?.Status || view?.status || '')
.trim()
.toLowerCase() === 'enabled',
)
.map((view) => ({
url: normalizeOntarioCctvUrl(view?.Url || view?.url),
description: String(view?.Description || view?.description || '').trim(),
}))
.filter((view) => view.url);
if (!enabled.length) return null;
return (
enabled.find((view) => !/\bdown\b/i.test(view.description)) || enabled[0]
);
}
let ontarioKeyMissingLogged = false;
/** Test seam: let the next keyless call log the missing-key line again. */
export function _resetOntarioKeyWarningForTest() {
ontarioKeyMissingLogged = false;
}
/**
* Fetch Ontario 511 CCTV cameras. The camera list comes from the 511 API,
* which now requires a free developer key (511on.ca/developers/doc, "Requires a
* developer key"; unkeyed calls answer 400 "Invalid Key"), read from
* ONTARIO_511_API_KEY. Without one the pack is skipped with a single log line.
* Frame URLs are stable still-image endpoints under 511on.ca/map/Cctv/. Only
* rows with finite Ontario coords and at least one enabled official still
* view are kept.
*
* @param {object} [_options] The catalog's loader options (unused).
* @param {object} [env] Environment to read the key and the source cap from.
* @returns {Promise<Array<object>>} Normalized camera source objects.
*/
export async function loadOntarioSourcesFromOpenData(
_options = {},
env = process.env,
) {
const key = String(env.ONTARIO_511_API_KEY ?? '').trim();
if (!key) {
if (!ontarioKeyMissingLogged) {
ontarioKeyMissingLogged = true;
console.warn(
'[CCTV] Ontario 511 cameras skipped: the 511on.ca API needs a free developer key (set ONTARIO_511_API_KEY)',
);
}
return [];
}
try {
const url = new URL(ONTARIO_511_CAMERAS_URL);
url.searchParams.set('key', key);
const resp = await fetch(url, {
headers: { Accept: 'application/json' },
signal: AbortSignal.timeout(CCTV_SOURCE_FETCH_TIMEOUT_MS),
});
if (!resp.ok) {
// The API answers 400 "Invalid Key" to a wrong or revoked key.
const keyHint =
resp.status === 400 || resp.status === 401 || resp.status === 403
? ' (check ONTARIO_511_API_KEY)'
: '';
console.warn(
`[CCTV] Ontario 511 camera download failed: ${resp.status}${keyHint}`,
);
return [];
}
const rows = await resp.json();
if (!Array.isArray(rows)) return [];
const cameras = [];
for (const row of rows) {
const rawId = String(row?.Id ?? row?.id ?? '').trim();
if (!rawId) continue;
const lat = toFiniteNumber(row?.Latitude ?? row?.latitude);
const lon = toFiniteNumber(row?.Longitude ?? row?.longitude);
if (!isLikelyOntarioCoordinate(lat, lon)) continue;
const view = pickOntarioCctvView(row?.Views || row?.views);
if (!view) continue;
const cameraId = `on-${rawId}`;
const location = String(row?.Location || row?.location || '').trim();
const roadway = String(row?.Roadway || row?.roadway || '').trim();
const viewLabel =
view.description && !/\bdown\b/i.test(view.description)
? view.description
: '';
const label = [
location || roadway || `Ontario 511 Camera ${rawId}`,
viewLabel,
]
.filter(Boolean)
.join(' - ');
let heading = directionToHeading(row?.Direction ?? row?.direction, true);
if (!Number.isFinite(heading)) {
heading = directionToHeading(view.description, true);
}
const hasHeading = Number.isFinite(heading);
cameras.push({
id: cameraId,
name: label,
city: location || roadway || 'Ontario',
cityId: 'ontario',
provider: 'Ontario 511',
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: 200,
feedType: 'image',
url: view.url,
snapshotUrl: view.url,
sourceKind: 'ontario-511-open-data',
license: 'Open Government Licence - Ontario',
});
}
const unique = Array.from(
new Map(cameras.map((camera) => [camera.id, camera])).values(),
);
const maxRaw = Number(
env.CCTV_ONTARIO_MAX_SOURCES || DEFAULT_ONTARIO_MAX_SOURCES,
);
const maxCount = Number.isFinite(maxRaw)
? Math.max(8, Math.min(1000, Math.floor(maxRaw)))
: DEFAULT_ONTARIO_MAX_SOURCES;
const prioritized = prioritizeSources(unique, maxCount, ONTARIO_ANCHORS);
console.log(
`[CCTV] Loaded Ontario 511 camera sources: ${unique.length} enabled (using nearest ${prioritized.length})`,
);
return prioritized;
} catch (error) {
console.warn(
'[CCTV] Ontario 511 camera download error:',
error?.message || error,
);
return [];
}
}
/**
* Fetch Fintraffic road weather cameras (all of Finland) from Digitraffic.
* Keyless; one GeoJSON station list per refresh (~37 KB gzipped, 809 stations
* / 2,275 presets), identifying itself with the `Digitraffic-User` header the
* service asks for. One PRESET — one fixed view of a station — is one camera
* here; the presets of a station share its position, and the id-hash fallback
* heading fans their gizmos apart instead of stacking them on one bearing.
*
* Skips stations whose `collectionStatus` is anything but GATHERING and presets
* with `inCollection: false`, so the mesh carries no dead cameras. Frame URLs
* are BUILT from the official image origin and a strictly-validated preset id
* rather than read from the payload, which pins the frame proxy to
* weathercam.digitraffic.fi by construction; the catalog fetch refuses
* redirects (`redirect: 'manual'`) so the list host cannot be steered either.
*
* No compass heading exists anywhere in this dataset: the per-preset
* `direction` on the detail endpoint is road-register relative
* (INCREASING_DIRECTION = "towards higher road addresses"), not a bearing, and
* converting it would need road geometry this app does not load. Every preset
* therefore takes the id-hash fallback and the low-confidence pose personality,
* the same as headingless Austin and TfL cameras.
*
* Attribution: "Fintraffic / digitraffic.fi" (CC BY 4.0), registered in
* src/data/dataCredits.js.
*
* @returns {Promise<Array<object>>} Normalized camera source objects.
*/
export async function loadFintrafficSourcesFromOpenData() {
try {
const resp = await fetch(FINTRAFFIC_STATIONS_URL, {
headers: {
Accept: 'application/json',
'Accept-Encoding': 'gzip',
'Digitraffic-User': DIGITRAFFIC_USER,
},
redirect: 'manual',
signal: AbortSignal.timeout(CCTV_SOURCE_FETCH_TIMEOUT_MS),
});
if (resp.status >= 300 && resp.status < 400) {
console.warn(
'[CCTV] Fintraffic station list redirected; redirects are not followed',
);
return [];
}
if (!resp.ok) {
console.warn('[CCTV] Fintraffic station download failed:', resp.status);
return [];
}
const payload = await resp.json();
const features = Array.isArray(payload?.features) ? payload.features : [];
if (!features.length) return [];
const cameras = [];
let stationsSeen = 0;
for (const feature of features) {
const props = feature?.properties || {};
const stationId = String(props.id || '').trim();
if (!stationId) continue;
// GATHERING is the only status that means "this station is collecting
// images right now"; REMOVED_TEMPORARILY and friends would render as
// permanent Street View / synthetic fallbacks.
if (String(props.collectionStatus || '').toUpperCase() !== 'GATHERING')
continue;
const coords = feature?.geometry?.coordinates;
const lon = toFiniteNumber(coords?.[0]);
const lat = toFiniteNumber(coords?.[1]);
if (!isLikelyFinlandCoordinate(lat, lon)) continue;
// Third coordinate is metres, but 0 means "not reported" rather than sea
// level, so only a positive value is a real reading. Prior only: the
// client's one-shot ground snap corrects it on 3D-tile stacks, and on a
// no-tileset stack this height is what freezes in.
const reportedElevation = toFiniteNumber(coords?.[2], 0);
const groundElevationM =
reportedElevation > 0
? Math.min(1400, reportedElevation)
: FINTRAFFIC_GROUND_ELEVATION_M;
stationsSeen += 1;
for (const preset of props.presets || []) {
if (preset?.inCollection !== true) continue;
const presetId = String(preset?.id || '').trim();
// Strict id shape (station id + two-digit view). Also the guard that
// keeps a hostile id out of the synthesized frame URL's path.
if (!/^C\d{7}$/.test(presetId)) continue;
if (!presetId.startsWith(stationId)) continue;
const cameraId = `fi-${presetId.toLowerCase()}`;
const imageUrl = `${FINTRAFFIC_IMAGE_ORIGIN}${presetId}.jpg`;
cameras.push({
id: cameraId,
name: fintrafficCameraName(props.name, stationId, presetId),
city: 'Finland',
cityId: 'finland',
provider: 'Fintraffic',
lat,
lon,
// Headingless personality (see JSDoc), identical to TfL's.
headingDeg: fallbackHeadingFromId(cameraId),
headingConfidence: 'low',
pitchDeg: -18,
fovDeg: 44,
rangeM: 145,
mountHeightM: 8,
groundElevationM,
feedType: 'image',
url: imageUrl,
snapshotUrl: imageUrl,
sourceKind: 'fintraffic-open-data',
license: 'Fintraffic / digitraffic.fi (CC BY 4.0)',
});
}
}
const maxRaw = Number(
process.env.CCTV_FINTRAFFIC_MAX_SOURCES || DEFAULT_FINTRAFFIC_MAX_SOURCES,
);
const maxCount = Number.isFinite(maxRaw)
? Math.max(8, Math.min(600, Math.floor(maxRaw)))
: DEFAULT_FINTRAFFIC_MAX_SOURCES;
const prioritized = prioritizeSources(cameras, maxCount, FINLAND_ANCHORS);
console.log(
`[CCTV] Loaded Fintraffic camera sources: ${cameras.length} live presets across ${stationsSeen} stations (using nearest ${prioritized.length})`,
);
return prioritized;
} catch (error) {
console.warn(
'[CCTV] Fintraffic station download error:',
error?.message || error,
);
return [];
}
}
/**
* The DriveBC `credit` field mixes third-party image attribution ("Images
* courtesy of TransLink") with operational notes ("relies on solar power").
* Only the attribution kind is carried onto the camera, HTML stripped, so a
* partner-owned feed names its owner in the panel; everything else is dropped.
*
* @param {unknown} raw
* @returns {string}
*/
export function driveBcImageCredit(raw) {
const text = String(raw || '')
.replace(/<[^>]*>/g, '')
.replace(/\s+/g, ' ')
.trim();
if (!text) return '';
return /courtesy|provided by|presented in cooperation|city of|parks canada/i.test(
text,
)
? text
: '';
}
/** DriveBC orientation codes (the eight compass points) as headings in degrees. */
const DRIVEBC_ORIENTATION_HEADINGS = Object.freeze({
N: 0,
NE: 45,
E: 90,
SE: 135,
S: 180,
SW: 225,
W: 270,
NW: 315,
});
/**
* Fetch DriveBC highway cameras (British Columbia). Keyless: one list endpoint
* served by the DriveBC.ca site. Only cameras that are switched on and published
* (`is_on` and `should_appear`) with a positive integer id and finite coordinates
* are kept. Frame URLs are built from that id on the official image host and are
* never read from the payload. Orientation codes give a high-confidence heading;
* `elevation` is metres above sea level. Attribution: Open Government Licence –
* British Columbia (registered in src/data/dataCredits.js).
*
* @returns {Promise<Array<object>>} Normalized camera source objects.
*/
export async function loadDriveBcSourcesFromOpenData() {
try {
const resp = await fetch(DRIVEBC_WEBCAMS_URL, {
headers: { Accept: 'application/json' },
signal: AbortSignal.timeout(CCTV_SOURCE_FETCH_TIMEOUT_MS),
});
if (!resp.ok) {
console.warn('[CCTV] DriveBC camera download failed:', resp.status);
return [];
}
const rows = await resp.json();
if (!Array.isArray(rows)) return [];
const cameras = [];
for (const row of rows) {
if (row?.is_on !== true || row?.should_appear !== true) continue;
if (!Number.isSafeInteger(row.id) || row.id <= 0) continue;
// GeoJSON point order: [longitude, latitude].
const [lon, lat] = Array.isArray(row.location?.coordinates)
? row.location.coordinates
: [];
if (!isLikelyBcCoordinate(lat, lon)) continue;
const cameraId = `drivebc-${row.id}`;
const heading =
DRIVEBC_ORIENTATION_HEADINGS[
String(row.orientation || '')
.trim()
.toUpperCase()
];
const hasHeading = Number.isFinite(heading);
const region = String(row.region_name || '').trim();
const imageUrl = DRIVEBC_IMAGE_URL(row.id);
const credit = driveBcImageCredit(row.credit);
cameras.push({
id: cameraId,
name: String(row.name || '').trim() || `DriveBC camera ${row.id}`,
// DriveBC regions: Lower Mainland, Vancouver Island, Southern Interior,
// Northern, and "Border Cams" for the US crossings.
city:
region === 'Border Cams' ? 'BC Border' : region || 'British Columbia',
cityId: 'british-columbia',
provider: 'DriveBC',
lat,
lon,
headingDeg: hasHeading ? heading : fallbackHeadingFromId(cameraId),
headingConfidence: hasHeading ? 'high' : 'low',
// Same two pose personalities as the other packs: raw priors that the
// client's ground snap and manual calibration refine.
pitchDeg: hasHeading ? -24 : -18,
fovDeg: hasHeading ? 56 : 44,
rangeM: hasHeading ? 210 : 145,
mountHeightM: hasHeading ? 10 : 8,
// Clamped like Caltrans so a garbage value can't fling a camera
// kilometres up; sea level is the prior for the coastal default anchors.
groundElevationM: Number.isFinite(row.elevation)
? Math.max(-100, Math.min(4000, row.elevation))
: 0,
feedType: 'image',
url: imageUrl,
snapshotUrl: imageUrl,
sourceKind: 'drivebc-open-data',
license: 'DriveBC, Open Government Licence – British Columbia',
credit,
});
}
const maxRaw = Number(
process.env.CCTV_DRIVEBC_MAX_SOURCES || DEFAULT_DRIVEBC_MAX_SOURCES,
);
// Up to the catalog ceiling, so a BC-only setup can load the whole province.
const maxCount = Number.isFinite(maxRaw)
? Math.max(8, Math.min(1200, Math.floor(maxRaw)))
: DEFAULT_DRIVEBC_MAX_SOURCES;
const prioritized = prioritizeSources(cameras, maxCount, DRIVEBC_ANCHORS);
console.log(
`[CCTV] Loaded DriveBC camera sources: ${cameras.length} published (using nearest ${prioritized.length})`,
);
return prioritized;
} catch (error) {
console.warn(
'[CCTV] DriveBC camera download error:',
error?.message || error,
);
return [];
}
}
/**
* Normalize one TxDOT district catalog into camera source objects. Split out
* from the fetch so the shape handling is unit-testable without a network.
*
* The payload nests cameras under `roadwayCctvStatuses`, keyed by roadway.
* Only `Device Online` rows register: an offline TxDOT device keeps serving a
* stale frame that can be years old and would otherwise look live.
*
* @param {object} payload - Parsed GetCctvStatusListByDistrict response.
* @param {string} district - TxDOT district code, e.g. 'AUS'.
* @returns {Array<object>} Normalized camera source objects.
*/
export function normalizeTxdotDistrictPayload(payload, district) {
const byRoadway = payload?.roadwayCctvStatuses;
if (!byRoadway || typeof byRoadway !== 'object') return [];
const code = String(district || '').toUpperCase();
const groundElevationM =
TXDOT_DISTRICT_ELEVATION_M[code] ?? TXDOT_DEFAULT_ELEVATION_M;
const cameras = [];
const seen = new Set();
for (const rows of Object.values(byRoadway)) {
if (!Array.isArray(rows)) continue;
for (const row of rows) {
if (String(row?.statusDescription || '') !== 'Device Online') continue;
if (row?.hasSnapshot === false) continue;
// Coordinates must be present as numbers: Number(null) and Number('')
// are both 0, which would silently park a camera on null island.
const lat = typeof row?.latitude === 'number' ? row.latitude : NaN;
const lon = typeof row?.longitude === 'number' ? row.longitude : NaN;
if (!isLikelyTexasCoordinate(lat, lon)) continue;
// icd_Id is the device key the snapshot endpoint takes and is unique
// within a district. An interchange camera appears under both of its
// roadways, so dedupe on it.
const icdId = String(row?.icd_Id || '').trim();
if (!icdId || seen.has(icdId)) continue;
seen.add(icdId);
const name = String(row?.name || icdId).trim();
// Heading comes from an explicit travel token in the NAME ("US-290 EB"),
// parsed in strict mode: bare cardinals are refused because Texas route
// names are full of them ("N Lamar", "West Ave"). Deliberately NOT from
// row.dirDescription / equipLoc.direction: that is the ROADWAY's
// canonical direction, not the camera's facing (it reads "North" for
// most Austin rows, including every east-west highway).
const heading = directionToHeading(name, false);
const hasHeading = Number.isFinite(heading);
// The device key itself, base64url-encoded, so every distinct key gets
// a distinct id (a hash or a slug can collide) and the key is
// recoverable from the id.
const cameraId = `txdot-${code.toLowerCase()}-${Buffer.from(icdId, 'utf8').toString('base64url')}`;
const snapshot = new URL(TXDOT_CCTV_SNAPSHOT_URL);
snapshot.searchParams.set('icdId', icdId);
snapshot.searchParams.set('districtCode', code);
cameras.push({
id: cameraId,
name,
city: String(row?.equipLoc?.roadway || code),
cityId: `tx-${code.toLowerCase()}`,
provider: 'TxDOT',
lat,
lon,
headingDeg: hasHeading ? heading : fallbackHeadingFromId(cameraId),
headingConfidence: hasHeading ? 'high' : 'low',
// Same two pose personalities as the other packs: raw priors the
// client's ground snap and manual calibration refine.
pitchDeg: hasHeading ? -24 : -18,
fovDeg: hasHeading ? 56 : 44,
rangeM: hasHeading ? 210 : 145,
// TxDOT mounts run tall on highway poles and mast arms.
mountHeightM: hasHeading ? 12 : 10,
groundElevationM,
feedType: 'image',
// JSON carrying a base64 JPEG; media.js decodes it for this origin only.
url: snapshot.toString(),
snapshotUrl: snapshot.toString(),
sourceKind: 'txdot-its',
license: 'Public TxDOT traffic camera data',
code: cameraDisplayCode(icdId.toUpperCase()),
});
}
}
return cameras;
}
/**
* Fetch TxDOT ITS highway cameras (Texas), keyless. Districts come from
* CCTV_TXDOT_DISTRICTS (comma-separated codes; empty string disables the
* pack). One official JSON catalog per district, identical schema statewide;
* districts fetch in parallel and fail independently.
*
* @returns {Promise<Array<object>>} Normalized camera source objects.
*/
export async function loadTxdotSourcesFromOpenData() {
const districtsRaw =
process.env.CCTV_TXDOT_DISTRICTS ?? DEFAULT_TXDOT_DISTRICTS;
const districts = [
...new Set(
String(districtsRaw)
.split(',')
.map((token) => token.trim().toUpperCase())
.filter((code) => TXDOT_DISTRICTS.has(code)),
),
];
if (!districts.length) return [];
const settled = await Promise.allSettled(
districts.map(async (district) => {
const resp = await fetch(TXDOT_CCTV_STATUS_URL(district), {
headers: {
Accept: 'application/json',
'User-Agent': 'gods-eye-view-cctv-proxy/1.0',
},
signal: AbortSignal.timeout(CCTV_SOURCE_FETCH_TIMEOUT_MS),
});
if (!resp.ok) throw new Error(`${district} HTTP ${resp.status}`);
return { district, payload: await resp.json() };
}),
);
const cameras = [];
for (const result of settled) {
if (result.status !== 'fulfilled') {
console.warn(
'[CCTV] TxDOT district fetch failed:',
result.reason?.message || result.reason,
);
continue;
}
cameras.push(
...normalizeTxdotDistrictPayload(
result.value.payload,
result.value.district,
),
);
}
const maxRaw = Number(
process.env.CCTV_TXDOT_MAX_SOURCES || DEFAULT_TXDOT_MAX_SOURCES,
);
const maxCount = Number.isFinite(maxRaw)
? Math.max(8, Math.min(2000, Math.floor(maxRaw)))
: DEFAULT_TXDOT_MAX_SOURCES;
const prioritized = prioritizeSources(cameras, maxCount, TXDOT_ANCHORS);
console.log(
`[CCTV] Loaded TxDOT camera sources: ${cameras.length} online across ${districts.join(',')} (using nearest ${prioritized.length})`,
);
return prioritized;
}
/**
* Load Tallinn intersection cameras from the curated catalog file.
*
* Frames are public JPEG stills on ristmikud.tallinn.ee (stable /last/camNNN.jpg
* URLs). The catalog ships coordinates + curated heading priors; only official
* ristmikud HTTPS URLs are kept (proxy fetches registered URLs only).
*
* @returns {Array<object>} Normalized camera source objects.
*/
export function loadTallinnSourcesFromCatalog({
sourceRoot = process.cwd(),
} = {}) {
const sourceFile =
process.env.CCTV_TALLINN_SOURCES_FILE || DEFAULT_TALLINN_SOURCE_FILE;
const resolved = path.isAbsolute(sourceFile)
? sourceFile
: path.resolve(sourceRoot, sourceFile);
let rows = [];
try {
if (!fs.existsSync(resolved)) {
console.warn('[CCTV] Tallinn source file missing:', resolved);
return [];
}
const parsed = JSON.parse(fs.readFileSync(resolved, 'utf8'));
rows = Array.isArray(parsed) ? parsed : [];
} catch (error) {
console.warn(
'[CCTV] Tallinn source file read error:',
error?.message || error,
);
return [];
}
const cameras = [];
for (const item of rows) {
if (!item || typeof item !== 'object') continue;
const cameraId =
typeof item.id === 'string' || typeof item.id === 'number'
? String(item.id).trim()
: '';
if (!cameraId) continue;
const lat = typeof item.lat === 'number' ? item.lat : NaN;
const lon = typeof item.lon === 'number' ? item.lon : NaN;
if (!Number.isFinite(lat) || !Number.isFinite(lon)) continue;
// Rough Estonia/Tallinn metro sanity (allows nearby suburbs already in the pack).
if (lat < 59.2 || lat > 59.7 || lon < 24.3 || lon > 25.4) continue;
const imageUrl =
typeof item.url === 'string'
? item.url.trim()
: typeof item.snapshotUrl === 'string'
? item.snapshotUrl.trim()
: '';
if (!imageUrl.startsWith(TALLINN_IMAGE_ORIGIN)) continue;
const extractedHeading = toFiniteNumber(item.headingDeg, NaN);
const hasHeading = Number.isFinite(extractedHeading);
const headingDeg = hasHeading
? ((extractedHeading % 360) + 360) % 360
: fallbackHeadingFromId(cameraId);
const headingConfidence = hasHeading
? String(item.headingConfidence || '').toLowerCase() === 'low'
? 'low'
: 'high'
: 'low';
cameras.push({
id: cameraId,
name: String(item.name || cameraId).trim(),
city: 'Tallinn',
cityId: 'tallinn',
provider: 'City of Tallinn',
lat,
lon,
headingDeg,
headingConfidence,
pitchDeg: headingConfidence === 'high' ? -24 : -18,
fovDeg: headingConfidence === 'high' ? 56 : 44,
rangeM: headingConfidence === 'high' ? 210 : 145,
mountHeightM: headingConfidence === 'high' ? 10 : 8,
groundElevationM: toFiniteNumber(item.groundElevationM, 15),
feedType: 'image',
url: imageUrl,
snapshotUrl: imageUrl,
sourceKind: 'tallinn-ristmikud',
license: 'Public City of Tallinn traffic camera data',
poseSource: hasHeading ? 'curated' : undefined,
});
}
const unique = Array.from(
new Map(cameras.map((camera) => [camera.id, camera])).values(),
);
const maxRaw = Number(
process.env.CCTV_TALLINN_MAX_SOURCES || DEFAULT_TALLINN_MAX_SOURCES,
);
const maxCount = Number.isFinite(maxRaw)
? Math.max(8, Math.min(300, Math.floor(maxRaw)))
: DEFAULT_TALLINN_MAX_SOURCES;
const prioritized = prioritizeSources(unique, maxCount, [TALLINN_CENTER]);
console.log(
`[CCTV] Loaded Tallinn camera sources: ${unique.length} (using nearest ${prioritized.length})`,
);
return prioritized;
}
/**
* Extract DATEX2 predefined-location id → {name, lat, lon} from Tarktee XML.
*
* @param {string} xml
* @returns {Map<string,{name:string,lat:number,lon:number}>}
*/
export function parseTarkteeDatexLocations(xml) {
const out = new Map();
const blockRe =
/<predefinedLocation\s+id="([^"]+)"[^>]*>([\s\S]*?)<\/predefinedLocation>/g;
let match;
while ((match = blockRe.exec(String(xml || ''))) !== null) {
const id = match[1];
const body = match[2];
// Skip the group container (no coordinates of its own).
const latMatch = /<latitude>\s*(-?\d+(?:\.\d+)?)\s*<\/latitude>/i.exec(
body,
);
const lonMatch = /<longitude>\s*(-?\d+(?:\.\d+)?)\s*<\/longitude>/i.exec(
body,
);
if (!latMatch || !lonMatch) continue;
const lat = toFiniteNumber(latMatch[1]);
const lon = toFiniteNumber(lonMatch[1]);
if (!Number.isFinite(lat) || !Number.isFinite(lon)) continue;
const nameMatch = /<value\b[^>]*>\s*([^<]+?)\s*<\/value>/i.exec(body);
const name = nameMatch ? nameMatch[1].trim() : id;
out.set(id, { name, lat, lon });
}
return out;
}
/**
* Extract DATEX2 traffic-view location id → HTTPS image URL from Tarktee XML.
*
* @param {string} xml
* @returns {Map<string,string>}
*/
export function parseTarkteeDatexImages(xml) {
const out = new Map();
const blockRe = /<trafficView\b[^>]*>([\s\S]*?)<\/trafficView>/g;
let match;
while ((match = blockRe.exec(String(xml || ''))) !== null) {
const body = match[1];
const refMatch =
/<linearPredefinedLocationReference\b[^>]*\bid="([^"]+)"/i.exec(body);
const urlMatch = /<urlLinkAddress>\s*([^<\s]+)\s*<\/urlLinkAddress>/i.exec(
body,
);
if (!refMatch || !urlMatch) continue;
const url = urlMatch[1].trim();
if (!url.startsWith(TARKTEE_IMAGE_ORIGIN)) continue;
out.set(refMatch[1], url);
}
return out;
}
/**
* Fetch Estonian Transpordiamet / Tarktee road-weather cameras via DATEX2.
*
* Locations and current still URLs are keyless public feeds. Image paths on the
* ArcGIS MapServer layer go stale; DATEX always carries the current JPEG URL.
* Only https://tarktee.transpordiamet.ee/images/… URLs are registered.
*
* @returns {Promise<Array<object>>} Normalized camera source objects.
*/
export async function loadTarkteeSourcesFromDatex() {
try {
const responses = await Promise.allSettled([
fetch(TARKTEE_LOCATIONS_URL, {
headers: { Accept: 'application/xml,text/xml,*/*' },
redirect: 'manual',
signal: AbortSignal.timeout(TARKTEE_FETCH_TIMEOUT_MS),
}),
fetch(TARKTEE_IMAGES_URL, {
headers: { Accept: 'application/xml,text/xml,*/*' },
redirect: 'manual',
signal: AbortSignal.timeout(TARKTEE_FETCH_TIMEOUT_MS),
}),
]);
const [locResp, imgResp] = responses.map((result) =>
result.status === 'fulfilled' ? result.value : undefined,
);
// Both responses are fetched in parallel; if either is rejected or overflows
// its byte cap, both bodies must be released so neither transport leaks.
const discardBoth = async () => {
await Promise.allSettled([
locResp?.body?.cancel(),
imgResp?.body?.cancel(),
]);
return [];
};
const rejected = responses.find((result) => result.status === 'rejected');
if (rejected) {
await discardBoth();
throw rejected.reason;
}
if (
(locResp.status >= 300 && locResp.status < 400) ||
(imgResp.status >= 300 && imgResp.status < 400)
) {
console.warn(
'[CCTV] Tarktee DATEX catalog redirected; redirects are not followed',
);
return discardBoth();
}
if (!locResp.ok) {
console.warn('[CCTV] Tarktee locations download failed:', locResp.status);
return discardBoth();
}
if (!imgResp.ok) {
console.warn('[CCTV] Tarktee images download failed:', imgResp.status);
return discardBoth();
}
let locXml;
let imgXml;
const readAbort = new AbortController();
try {
[locXml, imgXml] = await Promise.all([
readResponseTextCapped(
locResp,
TARKTEE_MAX_CATALOG_BYTES,
readAbort.signal,
),
readResponseTextCapped(
imgResp,
TARKTEE_MAX_CATALOG_BYTES,
readAbort.signal,
),
]);
} catch (error) {
readAbort.abort();
await discardBoth();
throw error;
}
const locations = parseTarkteeDatexLocations(locXml);
const images = parseTarkteeDatexImages(imgXml);
if (!locations.size || !images.size) {
console.warn('[CCTV] Tarktee DATEX parse empty:', {
locations: locations.size,
images: images.size,
});
return [];
}
const cameras = [];
const seen = new Set();
for (const [locationId, loc] of locations.entries()) {
const imageUrl = images.get(locationId);
if (!imageUrl) continue;
// Estonia bounding box (mainland + nearby islands).
if (loc.lat < 57.4 || loc.lat > 59.9 || loc.lon < 21.5 || loc.lon > 28.4)
continue;
const numMatch = /\/images\/(\d+)\//.exec(imageUrl);
const cameraId = numMatch
? `ee-tarktee-${numMatch[1]}`
: `ee-tarktee-${locationId}`;
if (seen.has(cameraId)) continue;
seen.add(cameraId);
cameras.push({
id: cameraId,
name: loc.name,
city: loc.name,
cityId: 'estonia',
provider: 'Transpordiamet (Tarktee)',
lat: loc.lat,
lon: loc.lon,
headingDeg: fallbackHeadingFromId(cameraId),
headingConfidence: 'low',
pitchDeg: -18,
fovDeg: 44,
rangeM: 145,
mountHeightM: 8,
groundElevationM: 40,
feedType: 'image',
url: imageUrl,
snapshotUrl: imageUrl,
sourceKind: 'tarktee-datex',
license: 'Public Transpordiamet / Tarktee road weather camera data',
});
}
const maxRaw = Number(
process.env.CCTV_TARKTEE_MAX_SOURCES || DEFAULT_TARKTEE_MAX_SOURCES,
);
const maxCount = Number.isFinite(maxRaw)
? Math.max(8, Math.min(300, Math.floor(maxRaw)))
: DEFAULT_TARKTEE_MAX_SOURCES;
const prioritized = prioritizeSources(cameras, maxCount, TARKTEE_ANCHORS);
console.log(
`[CCTV] Loaded Tarktee camera sources: ${cameras.length} with images (using nearest ${prioritized.length})`,
);
return prioritized;
} catch (error) {
console.warn(
'[CCTV] Tarktee DATEX download error:',
error?.message || error,
);
return [];
}
}
/**
* Load the Warendorf municipal webcams (Stadt Warendorf Marktplatz, Kreis
* Warendorf registration offices) from the curated catalog file. Poses are
* curated; only the two official municipal image hosts are registered.
*
* @returns {Array<object>} Normalized camera source objects.
*/
export function loadWarendorfSourcesFromCatalog({
sourceRoot = process.cwd(),
} = {}) {
const sourceFile =
process.env.CCTV_WARENDORF_SOURCES_FILE || DEFAULT_WARENDORF_SOURCE_FILE;
const resolved = path.isAbsolute(sourceFile)
? sourceFile
: path.resolve(sourceRoot, sourceFile);
let rows = [];
try {
if (!fs.existsSync(resolved)) {
console.warn('[CCTV] Warendorf source file missing:', resolved);
return [];
}
const parsed = JSON.parse(fs.readFileSync(resolved, 'utf8'));
rows = Array.isArray(parsed) ? parsed : [];
} catch (error) {
console.warn(
'[CCTV] Warendorf source file read error:',
error?.message || error,
);
return [];
}
const cameras = [];
for (const item of rows) {
if (!item || typeof item !== 'object') continue;
const id = typeof item.id === 'string' ? item.id.trim() : '';
const url =
typeof item.url === 'string'
? item.url.trim()
: typeof item.snapshotUrl === 'string'
? item.snapshotUrl.trim()
: '';
if (!id || !WARENDORF_IMAGE_ORIGINS.some((o) => url.startsWith(o)))
continue;
const lat = typeof item.lat === 'number' ? item.lat : NaN;
const lon = typeof item.lon === 'number' ? item.lon : NaN;
if (!isPlausibleLatLon(lat, lon)) continue;
cameras.push({
...item,
id,
url,
snapshotUrl: url,
cityId: String(item.cityId || 'warendorf'),
feedType: 'image',
sourceKind: 'municipal-webcam',
});
}
console.log('[CCTV] Loaded Warendorf camera sources:', cameras.length);
return cameras;
}
/**
* Label for one NSW camera: its `view` sentence when that is really a view
* ("5 Ways at The Boulevarde looking west towards Sutherland"), else the
* title ("5 Ways (Miranda)"). A works notice longer than NSW_MAX_VIEW_LABEL or
* containing a line break is not a label.
*
* @param {{view?:string, title?:string}} props
* @returns {string}
*/
export function nswCameraLabel(props) {
const view = String(props?.view || '').trim();
const title = String(props?.title || '').trim();
const viewIsALabel =
view.length > 0 &&
view.length <= NSW_MAX_VIEW_LABEL &&
!/[\r\n]/.test(view);
return viewIsALabel ? view : title;
}
/**
* One Live Traffic NSW camera feature -> one catalog source, or null. Every
* camera carries a compass `direction` ("N-E") and a `view` sentence.
*
* @param {object} feature - GeoJSON feature from the traffic-cam feed.
* @returns {?object}
*/
export function nswCameraToSource(feature) {
const rawId = String(feature?.id || '').trim();
if (!rawId) return null;
const coords = feature?.geometry?.coordinates;
// Numbers only: Number(null) and Number('') are 0, which would park a
// camera on the equator.
const lon = typeof coords?.[0] === 'number' ? coords[0] : NaN;
const lat = typeof coords?.[1] === 'number' ? coords[1] : NaN;
if (!isLikelyNswCoordinate(lat, lon)) return null;
const props = feature?.properties || {};
const url = String(props.href || '').trim();
if (!url.startsWith(NSW_IMAGE_ORIGIN)) return null;
// "N-E" -> "NE" for the compass lookup.
const direction = String(props.direction || '')
.trim()
.toUpperCase()
.replace(/-/g, '');
const heading = directionToHeading(direction, true);
const hasHeading = Number.isFinite(heading);
const cameraId = `nsw-${rawId}`;
return {
id: cameraId,
name: nswCameraLabel(props) || `NSW ${rawId}`,
city: String(props.region || 'New South Wales').replace(/_/g, ' '),
cityId: 'nsw',
provider: 'Live Traffic NSW',
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: 25, // Sydney basin prior; the client's ground snap corrects.
feedType: 'image',
url,
snapshotUrl: url,
sourceKind: 'nsw-livetraffic',
license: 'Live Traffic NSW — Transport for NSW, CC BY 4.0',
code: cameraDisplayCode(String(props.title || '').toUpperCase() || rawId),
};
}
/**
* Fetch Live Traffic NSW cameras (New South Wales), keyless: the public
* traffic-cam GeoJSON feed. Frames are stills on webcams.transport.nsw.gov.au.
*
* @returns {Promise<Array<object>>} Normalized camera source objects.
*/
export async function loadNswSourcesFromOpenData() {
try {
const resp = await fetch(NSW_CAMERAS_URL, {
headers: {
Accept: 'application/json',
'User-Agent': 'gods-eye-view-cctv-proxy/1.0',
},
signal: AbortSignal.timeout(CCTV_SOURCE_FETCH_TIMEOUT_MS),
});
if (!resp.ok) {
console.warn('[CCTV] NSW camera download failed:', resp.status);
return [];
}
const body = await resp.json();
const features = Array.isArray(body?.features) ? body.features : [];
const cameras = features.map(nswCameraToSource).filter(Boolean);
const maxRaw = Number(
process.env.CCTV_NSW_MAX_SOURCES || DEFAULT_NSW_MAX_SOURCES,
);
const maxCount = Number.isFinite(maxRaw)
? Math.max(8, Math.min(900, Math.floor(maxRaw)))
: DEFAULT_NSW_MAX_SOURCES;
const prioritized = prioritizeSources(cameras, maxCount, [SYDNEY_CENTER]);
console.log(
`[CCTV] Loaded NSW camera sources: ${cameras.length} (using nearest ${prioritized.length})`,
);
return prioritized;
} catch (error) {
console.warn('[CCTV] NSW camera download error:', error?.message || error);
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 [];
}
}
/**
* Parse the Catalonia (`cameres.xml`) WFS/GML feed into raw feature records.
* Regex-based rather than a full XML parser — the feed is small (~160
* features) and this avoids a new dependency for one field set per feature.
*
* @param {string} xml
* @returns {Array<{fid:string, lat:number, lon:number, carretera:string, municipi:string, pk:string, link:string, font:string}>}
*/
export function parseCataloniaXml(xml) {
const out = [];
const blockRe = /<gml:featureMember>([\s\S]*?)<\/gml:featureMember>/g;
const tag = (block, name) => {
const match = new RegExp(`<cite:${name}>([^<]*)<\\/cite:${name}>`).exec(
block,
);
return match ? decodeNumericEntities(match[1]) : '';
};
let match;
while ((match = blockRe.exec(String(xml || ''))) !== null) {
const block = match[1];
const fidMatch = /<cite:cameres\s+fid="([^"]+)"/.exec(block);
const coordsMatch = /<gml:coordinates[^>]*>([^<]+)<\/gml:coordinates>/.exec(
block,
);
if (!fidMatch || !coordsMatch) continue;
const [lonRaw, latRaw] = coordsMatch[1].split(',');
out.push({
fid: fidMatch[1],
lon: toFiniteNumber(lonRaw),
lat: toFiniteNumber(latRaw),
carretera: tag(block, 'carretera'),
municipi: tag(block, 'municipi'),
pk: tag(block, 'pk'),
link: tag(block, 'link'),
font: tag(block, 'font'),
});
}
return out;
}
/**
* Validate and normalize one Catalonia feed `link` against the publisher
* host allowlist (CATALONIA_IMAGE_HOSTS), upgrading to https. Rejects
* anything else, including a host that merely resembles an allowed one, any
* URL carrying userinfo (`https://user:[email protected]/...`) or an explicit
* port — the hostname check alone would pass either, letting catalog data
* inject credentials or steer the server-side frame request to another
* service on that host — and any path outside the host's frame directory.
*
* SCT's own `RenderService` frame link is a special case, for two stacked
* reasons (both verified 2026-09-15):
* 1. It always 302s to `TransitCamera`, on a Location the upstream
* hardcodes as plain http even when RenderService itself was requested
* over https. fetchWithinHost (media.js) only follows same-origin
* redirects — same scheme included, by design, to stop an upstream
* steering a host-pinned request into a plaintext downgrade — so that
* hop is refused and the camera would render as a placeholder.
* 2. mct.gencat.cat's TLS setup itself does not complete a handshake with
* Node's fetch/OpenSSL (`ERR_SSL_WRONG_SIGNATURE_TYPE`) even though it
* is reachable over https from other clients (e.g. curl) — an upstream
* server misconfiguration this proxy cannot route around.
* TransitCamera answers the frame directly over **http**, no redirect, and
* that is the only transport this host actually serves to this runtime — so
* this builds that URL itself from RenderService's `sctidcam` id instead of
* registering the (https, then-redirecting) RenderService link. The
* `sctidcam` id must be a bare image filename (CATALONIA_SCT_CAMERA_FILE_RE).
*
* @param {string} rawUrl
* @returns {string} Normalized URL, or '' if not accepted.
*/
export function normalizeCataloniaImageUrl(rawUrl) {
let parsed;
try {
parsed = new URL(String(rawUrl || '').trim());
} catch {
return '';
}
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return '';
if (parsed.username || parsed.password || parsed.port) return '';
const dir = CATALONIA_IMAGE_HOSTS.get(parsed.hostname);
if (!dir || !parsed.pathname.startsWith(dir)) return '';
const file = parsed.pathname.slice(dir.length);
if (!file || file.includes('/')) return '';
if (parsed.hostname === 'mct.gencat.cat') {
const camId = parsed.searchParams.get('sctidcam');
if (file !== 'RenderService') return '';
if (!camId || !CATALONIA_SCT_CAMERA_FILE_RE.test(camId)) return '';
const direct = new URL('http://mct.gencat.cat/mct2bo/TransitCamera');
direct.searchParams.set('nom', camId);
direct.searchParams.set('visualitzacio', 'imatge');
return direct.toString();
}
parsed.protocol = 'https:';
return parsed.toString();
}
/**
* Stable camera id for one normalized Catalonia frame URL (the output of
* normalizeCataloniaImageUrl). The feed's `fid` is not used: it embeds an
* upstream server-start stamp and changes on every upstream restart. SCT
* cameras key on the `sctidcam` filename stem (`cat-sct-nc87`), partner
* cameras on the frame host and path (`cat-www-bcn-cat-transit-imatges-…`).
*
* @param {string} imageUrl - A URL normalizeCataloniaImageUrl accepted.
* @returns {string}
*/
export function cataloniaCameraId(imageUrl) {
const parsed = new URL(imageUrl);
const slug = (text) =>
text
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
if (parsed.hostname === 'mct.gencat.cat') {
const file = parsed.searchParams.get('nom') || '';
return `cat-sct-${slug(file.replace(/\.[^.]*$/, ''))}`;
}
return `cat-${slug(parsed.hostname)}-${slug(parsed.pathname)}`;
}
/**
* Human label for one Catalonia camera: "<road/place> (<municipality>)",
* skipping the parenthetical when the municipality is already named in the
* road/place text or when one half is missing.
*
* @param {string} carretera
* @param {string} municipi
* @returns {string}
*/
export function cataloniaCameraName(carretera, municipi) {
const road = String(carretera || '').trim();
const city = String(municipi || '').trim();
if (road && city) {
return road.toLowerCase().includes(city.toLowerCase())
? road
: `${road} (${city})`;
}
return road || city || 'Càmera de trànsit';
}
/**
* Fetch Catalonia traffic cameras (Servei Català de Trànsit), keyless: one
* WFS/GML XML list. The feed carries a `font` field naming the real
* publisher per camera; only SCT's own highway cameras and the hotlinked
* Barcelona (IMI) and Terrassa municipal cameras are kept — the publishers
* whose frame links were confirmed to serve images. Frame URLs are checked
* against a host allowlist (CATALONIA_IMAGE_HOSTS), so a row from any other
* publisher is dropped. The feed carries no camera status, so a camera its
* publisher has taken offline still registers and serves that publisher's
* own "image unavailable" still.
* Each non-SCT camera carries its publisher as a `credit` alongside the
* shared `provider`. The feed carries no heading, so every camera takes the
* id-hash fallback, low-confidence pose (same as TfL and Fintraffic).
*
* Ids come from the validated frame URL (cataloniaCameraId), not the feed's
* restart-dependent `fid`, and rows sharing one are collapsed before the cap
* (the live feed repeats a few SCT cameras).
*
* The list decides ids, coordinates, labels and which allowlisted frame path
* is proxied, so it is fetched over https, redirects are refused (the list
* host cannot be steered or downgraded) and the body read is capped at
* CATALONIA_MAX_CATALOG_BYTES.
*
* @returns {Promise<Array<object>>} Normalized camera source objects.
*/
export async function loadCataloniaSourcesFromOpenData() {
try {
const resp = await fetch(CATALONIA_CAMERAS_URL, {
headers: { Accept: 'application/xml,text/xml,*/*' },
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] Catalonia catalog redirected; redirects are not followed',
);
return discard();
}
if (!resp.ok) {
console.warn('[CCTV] Catalonia camera download failed:', resp.status);
return discard();
}
const xml = await readResponseTextCapped(resp, CATALONIA_MAX_CATALOG_BYTES);
const rows = parseCataloniaXml(xml);
if (!rows.length) return [];
const cameras = [];
for (const row of rows) {
if (!isLikelyCataloniaCoordinate(row.lat, row.lon)) continue;
const imageUrl = normalizeCataloniaImageUrl(row.link);
if (!imageUrl) continue;
const font = row.font.trim();
const cameraId = cataloniaCameraId(imageUrl);
cameras.push({
id: cameraId,
name: cataloniaCameraName(row.carretera, row.municipi),
city: row.municipi.trim() || 'Catalunya',
cityId: 'catalunya',
provider: 'Servei Català de Trànsit',
lat: row.lat,
lon: row.lon,
headingDeg: fallbackHeadingFromId(cameraId),
headingConfidence: 'low',
pitchDeg: -18,
fovDeg: 44,
rangeM: 145,
mountHeightM: 8,
groundElevationM: CATALONIA_FONT_ELEVATION_M[font] ?? 80,
feedType: 'image',
url: imageUrl,
snapshotUrl: imageUrl,
sourceKind: 'catalonia-open-data',
license:
"Generalitat de Catalunya — Llicència oberta d'ús d'informació",
credit: CATALONIA_FONT_CREDIT[font] || '',
});
}
const unique = Array.from(
new Map(cameras.map((camera) => [camera.id, camera])).values(),
);
const maxRaw = Number(
process.env.CCTV_CATALONIA_MAX_SOURCES || DEFAULT_CATALONIA_MAX_SOURCES,
);
const maxCount = Number.isFinite(maxRaw)
? Math.max(8, Math.min(600, Math.floor(maxRaw)))
: DEFAULT_CATALONIA_MAX_SOURCES;
const prioritized = prioritizeSources(unique, maxCount, CATALONIA_ANCHORS);
console.log(
`[CCTV] Loaded Catalonia camera sources: ${unique.length} (using nearest ${prioritized.length})`,
);
return prioritized;
} catch (error) {
console.warn(
'[CCTV] Catalonia camera download error:',
error?.message || error,
);
return [];
}
}
/**
* Read one XML child tag's text from an `<image>` block.
*
* @param {string} body
* @param {string} tag
* @returns {string}
*/
function hkXmlTag(body, tag) {
const match = new RegExp(`<${tag}>\\s*([^<]*?)\\s*</${tag}>`, 'i').exec(
String(body || ''),
);
return match ? match[1].trim() : '';
}
/**
* Parse the Transport Department English camera-locations XML into rows.
*
* Schema-specific: each `<image>` carries key, description, lat/lon and a
* still URL. No XML dependency — same regex style as the Tarktee DATEX parser.
*
* @param {string} xml
* @returns {Array<object>}
*/
export function parseHkCameraLocationsXml(xml) {
const rows = [];
const blockRe = /<image>([\s\S]*?)<\/image>/gi;
let match;
while ((match = blockRe.exec(String(xml || ''))) !== null) {
const body = match[1];
rows.push({
key: hkXmlTag(body, 'key'),
region: hkXmlTag(body, 'region'),
district: hkXmlTag(body, 'district'),
description: hkXmlTag(body, 'description'),
latitude: hkXmlTag(body, 'latitude'),
longitude: hkXmlTag(body, 'longitude'),
url: hkXmlTag(body, 'url'),
});
}
return rows;
}
/**
* Build a host-pinned still URL from a Transport Department camera key.
*
* The catalog also publishes a URL; the registered frame is always rebuilt
* from the key so a catalog edit cannot steer the proxy off-origin.
*
* @param {string|null|undefined} key
* @returns {?string}
*/
export function hkImageUrlFromKey(key) {
const id = String(key ?? '')
.trim()
.toUpperCase();
if (!/^[A-Z0-9]{1,32}$/.test(id)) return null;
return `${HK_IMAGE_ORIGIN}${id}.JPG`;
}
/**
* Pin a catalog frame URL to the Transport Department still host.
*
* @param {string|null|undefined} raw
* @returns {?string}
*/
export function normalizeHkImageUrl(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(HK_IMAGE_ORIGIN) ? upgraded : null;
}
/**
* Label for one HK camera: the published English description, else the key.
*
* @param {object} record
* @param {string} cameraId
* @returns {string}
*/
export function hkCameraName(record, cameraId) {
const description = String(record?.description ?? '').trim();
if (description) return description;
const key = String(record?.key ?? '').trim();
if (key) return key;
return `HK Camera ${String(cameraId).replace(/^hk-/, '')}`;
}
/**
* One Transport Department locations row -> one catalog source, or null.
*
* The dataset publishes no camera facing, so headings use the shared id-hash
* fallback at low confidence and are corrected with the calibration gizmo.
*
* @param {object} record
* @returns {?object}
*/
export function hkCameraToSource(record) {
if (!record || typeof record !== 'object') return null;
const key = String(record.key ?? '')
.trim()
.toUpperCase();
if (!key) return null;
const lat = toFiniteNumber(record.latitude);
const lon = toFiniteNumber(record.longitude);
if (!isLikelyHongKongCoordinate(lat, lon)) return null;
const imageUrl = hkImageUrlFromKey(key);
if (!imageUrl) return null;
// If the catalog carries a URL, it must already be on the pinned host.
const catalogUrl = String(record.url ?? '').trim();
if (catalogUrl) {
const pinnedCatalog = normalizeHkImageUrl(catalogUrl);
if (!pinnedCatalog) return null;
}
const cameraId = `hk-${key.toLowerCase()}`;
const name = hkCameraName(record, cameraId);
const region = String(record.region ?? '').trim();
return {
id: cameraId,
name,
city: region || 'Hong Kong',
cityId: 'hk',
provider: 'Transport Department',
lat,
lon,
headingDeg: fallbackHeadingFromId(cameraId),
headingConfidence: 'low',
pitchDeg: -18,
fovDeg: 44,
rangeM: 145,
mountHeightM: 8,
// Coastal / harbour prior; the client's one-shot ground snap corrects.
groundElevationM: 15,
feedType: 'image',
url: imageUrl,
snapshotUrl: imageUrl,
sourceKind: 'hk-td-open-data',
license:
'Hong Kong Transport Department / DATA.GOV.HK — attribution required',
code: cameraDisplayCode(name.toUpperCase()),
};
}
/**
* Fetch Hong Kong Transport Department traffic snapshot cameras from the
* keyless DATA.GOV.HK English locations XML. Frames are stills on
* tdcctv.data.one.gov.hk.
*
* @returns {Promise<Array<object>>} Normalized camera source objects.
*/
export async function loadHkSourcesFromOpenData() {
try {
const endpoint = process.env.CCTV_HK_CAMERAS_URL || DEFAULT_HK_CAMERAS_URL;
const resp = await fetch(endpoint, {
headers: { Accept: 'application/xml,text/xml,*/*' },
redirect: 'manual',
signal: AbortSignal.timeout(CCTV_SOURCE_FETCH_TIMEOUT_MS),
});
const discard = async () => {
try {
await resp.body?.cancel();
} catch {
/* no-op */
}
return [];
};
if (resp.status >= 300 && resp.status < 400) {
console.warn('[CCTV] HK catalog redirected; redirects are not followed');
return discard();
}
if (!resp.ok) {
console.warn('[CCTV] HK camera download failed:', resp.status);
return discard();
}
let xml;
try {
xml = await readResponseTextCapped(resp, HK_MAX_CATALOG_BYTES);
} catch (error) {
if (error?.code === 'RESPONSE_TOO_LARGE') {
console.warn('[CCTV] HK catalog body exceeded byte ceiling');
return [];
}
throw error;
}
const rows = parseHkCameraLocationsXml(xml);
const cameras = [];
const seen = new Set();
for (const record of rows) {
const camera = hkCameraToSource(record);
if (!camera || seen.has(camera.id)) continue;
seen.add(camera.id);
cameras.push(camera);
}
const maxRaw = Number(
process.env.CCTV_HK_MAX_SOURCES || DEFAULT_HK_MAX_SOURCES,
);
const maxCount = Number.isFinite(maxRaw)
? Math.max(8, Math.min(1200, Math.floor(maxRaw)))
: DEFAULT_HK_MAX_SOURCES;
const prioritized = prioritizeSources(cameras, maxCount, HK_ANCHORS);
console.log(
`[CCTV] Loaded HK camera sources: ${cameras.length} (using nearest ${prioritized.length})`,
);
return prioritized;
} catch (error) {
console.warn('[CCTV] HK camera download error:', error?.message || error);
return [];
}
}
export async function loadDelDOTSourcesFromOpenData() {
try {
const resp = await fetch(DELDOT_CCTV_URL, {
headers: { Accept: 'application/json' },
signal: AbortSignal.timeout(CCTV_SOURCE_FETCH_TIMEOUT_MS),
redirect: 'error',
});
if (!resp.ok) {
console.warn('[CCTV] DelDOT source download failed:', resp.status);
return [];
}
const payload = await readResponseJsonCapped(resp, 2 * 1024 * 1024);
const rows = Array.isArray(payload?.videoCameras)
? payload.videoCameras
: [];
if (!rows.length) return [];
const cameras = [];
for (const row of rows) {
if (String(row?.status).toLowerCase() !== 'active') continue;
const lat = toFiniteNumber(row?.lat);
const lon = toFiniteNumber(row?.lon);
if (!Number.isFinite(lat) || !Number.isFinite(lon)) continue;
// Delaware bounding box — a bad upstream coord can't place a camera out of state.
if (lat < 38.4 || lat > 39.9 || lon < -75.85 || lon > -75.0) continue;
// Prefer the catalog's HTTPS HLS transport; no subprocess is required.
let stream;
try {
stream = new URL(String(row?.urls?.m3u8s || ''));
} catch {
continue;
}
if (
stream.origin !== 'https://video.deldot.gov' ||
stream.username ||
stream.password ||
!/^\/live\/[A-Za-z0-9_.-]+\/playlist\.m3u8$/.test(stream.pathname)
)
continue;
const streamUrl = stream.href;
const id = String(row?.id || '').trim();
if (!/^[A-Za-z0-9_-]{1,80}$/.test(id)) continue;
const cameraId = `deldot-${id.toLowerCase()}`;
const title = String(row?.title || '').trim();
// Title carries an unambiguous travel token for ~1 in 5 cameras
// ("US 13 SB @ ..."). directionToHeading (allowBare=false) matches only
// NB/SB/EB/WB, never a bare "W"/"N" street prefix — "W NORTH ST" yields
// no heading and falls back.
const heading = directionToHeading(title);
const hasHeading = Number.isFinite(heading);
cameras.push({
id: cameraId,
name: title || `DelDOT ${id}`,
city: String(row?.county ? `${row.county} County` : 'Delaware'),
cityId: `deldot-${String(row?.county || 'de')
.toLowerCase()
.replace(/\s+/g, '-')}`,
provider: 'DelDOT',
lat,
lon,
headingDeg: hasHeading ? heading : fallbackHeadingFromId(cameraId),
headingConfidence: hasHeading ? 'high' : 'low',
// Fabricated RAW PRIOR poses (same personalities as Austin/Caltrans);
// the client ground-snap + manual calibration own the truth.
pitchDeg: hasHeading ? -24 : -18,
fovDeg: hasHeading ? 56 : 44,
rangeM: hasHeading ? 210 : 145,
mountHeightM: hasHeading ? 10 : 8,
groundElevationM: 10, // Estimated prior; client ground resolution owns placement.
feedType: 'hls',
url: streamUrl,
snapshotUrl: '',
sourceKind: 'deldot-open-data',
license: 'Public DelDOT traffic camera',
});
}
const unique = Array.from(
new Map(cameras.map((camera) => [camera.id, camera])).values(),
);
const maxRaw = Number(
process.env.CCTV_DELDOT_MAX_SOURCES || DEFAULT_DELDOT_MAX_SOURCES,
);
const maxCount = Number.isFinite(maxRaw)
? Math.max(8, Math.min(400, Math.floor(maxRaw)))
: DEFAULT_DELDOT_MAX_SOURCES;
const prioritized = prioritizeSources(unique, maxCount, DELDOT_ANCHORS);
console.log(
`[CCTV] Loaded DelDOT camera sources: ${unique.length} Active (using nearest ${prioritized.length})`,
);
return prioritized;
} catch (error) {
console.warn(
'[CCTV] DelDOT source download error:',
error?.message || error,
);
return [];
}
}
/** Rough King County, WA bounding box. */
function isLikelyKingCountyCoordinate(lat, lon) {
return (
Number.isFinite(lat) &&
Number.isFinite(lon) &&
lat >= 47.0 &&
lat <= 48.0 &&
lon >= -123.1 &&
lon <= -121.0
);
}
/**
* A King County `Model` value, whitespace-collapsed, or '' when it is a
* vendor-generic string ("PTZ", "PTZ IP", "BOSCH/PTZ") that names no model.
*/
function kingCountyModel(raw) {
const model = String(raw || '')
.replace(/\s+/g, ' ')
.trim();
return /^(?:[a-z]+\/)?ptz(?: ip)?$/i.test(model) ? '' : model;
}
/**
* Fetch King County road cameras (Washington State). Keyless: one public
* ArcGIS FeatureServer GeoJSON query (King County DOT Road Services), with an
* explicit `outFields` list so the layer's asset-administration columns are
* never fetched. Only cameras that are live and active (`Live`,
* `CurrentStatus`) with a positive integer id, finite in-county coordinates,
* and a frame on the county's own image handler are kept. WSDOT-owned rows in
* the layer are skipped because their frames are WSDOT-hosted, outside the
* pinned image origin, while partner-city cameras hosted on the county image
* handler (e.g. Newcastle) name their owner as the per-camera credit,
* DriveBC-style. The layer publishes no facing, so every heading is the id
* hash at low confidence.
*
* Uniquely among the packs, the county publishes each camera's hardware
* `Manufacturer`/`Model`; the model string (whitespace-collapsed — the feed
* carries stray newlines) rides along as the source's `model` for
* identified-hardware consumers. Vendor-generic strings ("PTZ", "PTZ IP",
* "BOSCH/PTZ") name no model and are treated as absent.
*
* @returns {Promise<Array<object>>} Normalized camera source objects.
*/
export async function loadKingCountySourcesFromOpenData() {
try {
const resp = await fetch(KING_COUNTY_CAMERAS_URL, {
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] King County catalog redirected; redirects are not followed',
);
return discard();
}
if (!resp.ok) {
console.warn('[CCTV] King County camera download failed:', resp.status);
return discard();
}
const payload = await readResponseJsonCapped(
resp,
KING_COUNTY_MAX_CATALOG_BYTES,
);
const rows = Array.isArray(payload?.features) ? payload.features : [];
const cameras = [];
const seen = new Set();
for (const row of rows) {
const attrs = row?.properties;
const rawId = attrs?.AssetID;
if (!Number.isSafeInteger(rawId) || rawId <= 0) continue;
if (attrs.Live !== 1) continue;
if (String(attrs.CurrentStatus || '').trim() !== 'Active') continue;
const owner = String(attrs.Owner || '').trim();
// WSDOT-owned rows serve WSDOT-hosted frames, outside the pinned
// county image origin, so they are skipped.
if (owner === 'WSDOT') continue;
// The handler is served over HTTPS; the feed still writes http://.
const imageUrl = String(attrs.ImageURL || '')
.trim()
.replace(/^http:\/\/info\.kingcounty\.gov\//, KING_COUNTY_IMAGE_ORIGIN);
if (!imageUrl.startsWith(KING_COUNTY_IMAGE_ORIGIN)) continue;
const [lon, lat] = Array.isArray(row?.geometry?.coordinates)
? row.geometry.coordinates
: [];
if (!isLikelyKingCountyCoordinate(lat, lon)) continue;
const cameraId = `kingcounty-${rawId}`;
if (seen.has(cameraId)) continue;
seen.add(cameraId);
const model = kingCountyModel(attrs.Model);
const manufacturer = String(attrs.Manufacturer || '')
.replace(/\s+/g, ' ')
.trim();
const camera = {
id: cameraId,
name:
String(attrs.Location || '').trim() || `King County camera ${rawId}`,
city: 'King County',
cityId: 'king-county',
provider: 'King County Road Services',
lat,
lon,
headingDeg: fallbackHeadingFromId(cameraId),
headingConfidence: 'low',
// The low-confidence pose personality shared by the other packs.
pitchDeg: -18,
fovDeg: 44,
rangeM: 145,
mountHeightM: 8,
feedType: 'image',
url: imageUrl,
snapshotUrl: imageUrl,
sourceKind: 'kingcounty-open-data',
license: 'King County GIS open data (KCGIS Center terms of use)',
credit: owner && owner !== 'King County' ? owner : '',
};
if (model) camera.model = model;
if (model && manufacturer) camera.manufacturer = manufacturer;
cameras.push(camera);
}
const maxRaw = Number(
process.env.CCTV_KINGCOUNTY_MAX_SOURCES ||
DEFAULT_KING_COUNTY_MAX_SOURCES,
);
const maxCount = Number.isFinite(maxRaw)
? Math.max(8, Math.min(1200, Math.floor(maxRaw)))
: DEFAULT_KING_COUNTY_MAX_SOURCES;
const prioritized = prioritizeSources(
cameras,
maxCount,
KING_COUNTY_ANCHORS,
);
console.log(
`[CCTV] Loaded King County camera sources: ${cameras.length} published (using nearest ${prioritized.length})`,
);
return prioritized;
} catch (error) {
console.warn(
'[CCTV] King County camera download error:',
error?.message || error,
);
return [];
}
}
/** WSDOT `CompassDirection` codes as headings in degrees. The live feed sends
* single cardinals (N/E/S/W), `B` (both directions — most rows), `O`, and
* null; the diagonals are mapped in case they appear. `B`, `O`, null, and any
* other code carry no single facing and fall through to the id hash. */
const WSDOT_COMPASS_HEADINGS = Object.freeze({
N: 0,
NE: 45,
E: 90,
SE: 135,
S: 180,
SW: 225,
W: 270,
NW: 315,
});
/**
* Converts an EPSG:3857 (Web Mercator) coordinate pair to WGS84 degrees.
* The WSDOT camera layer publishes point geometry in wkid 102100.
* @param {number} x - Web Mercator metres east.
* @param {number} y - Web Mercator metres north.
* @returns {{lat: number, lon: number}} WGS84 degrees.
*/
export function webMercatorToWgs84(x, y) {
const R = 6378137;
const lon = (x / R) * (180 / Math.PI);
const lat = (2 * Math.atan(Math.exp(y / R)) - Math.PI / 2) * (180 / Math.PI);
return { lat, lon };
}
/** Rough Washington State bounding box (with the Columbia River border cams). */
function isLikelyWashingtonCoordinate(lat, lon) {
return (
Number.isFinite(lat) &&
Number.isFinite(lon) &&
lat >= 45.3 &&
lat <= 49.3 &&
lon >= -125 &&
lon <= -116.5
);
}
/**
* Fetch WSDOT highway cameras (Washington State). Keyless: one Esri JSON
* layer on WSDOT's official open-data host (the same layer the WSDOT Travel
* Center map consumes; a keyed Traveler Information API serves the identical
* population for higher-volume use). Only cameras with a positive integer id,
* finite in-state coordinates, and a frame on WSDOT's own image host are
* kept — the catalog's ~70 partner cameras (Oregon DOT, City of Seattle,
* lodges, airports) are skipped rather than proxied under someone else's
* terms. A single-facing `CompassDirection` (a cardinal) gives a
* high-confidence heading; `B` (both directions), `O`, and null fall back to
* the id hash.
* The feed carries Windows-1252 bytes (en-dashes in titles) with no charset
* declaration, so the body is decoded explicitly before parsing.
*
* @returns {Promise<Array<object>>} Normalized camera source objects.
*/
export async function loadWsdotSourcesFromOpenData() {
try {
const resp = await fetch(WSDOT_CAMERAS_URL, {
headers: { Accept: 'application/json' },
redirect: 'manual',
signal: AbortSignal.timeout(CCTV_SOURCE_FETCH_TIMEOUT_MS),
});
const discard = async () => {
try {
await resp.body?.cancel();
} catch {
/* no-op */
}
return [];
};
if (resp.status >= 300 && resp.status < 400) {
console.warn(
'[CCTV] WSDOT catalog redirected; redirects are not followed',
);
return discard();
}
if (!resp.ok) {
console.warn('[CCTV] WSDOT camera download failed:', resp.status);
return discard();
}
const payload = JSON.parse(
new TextDecoder('windows-1252').decode(
await readResponseBytesCapped(resp, WSDOT_MAX_CATALOG_BYTES),
),
);
const rows = Array.isArray(payload?.features) ? payload.features : [];
const cameras = [];
for (const row of rows) {
const attrs = row?.attributes;
const rawId = attrs?.CameraID;
if (!Number.isSafeInteger(rawId) || rawId <= 0) continue;
const imageUrl = String(attrs.ImageURL || '').trim();
if (!imageUrl.startsWith(WSDOT_IMAGE_ORIGIN)) continue;
const x = row?.geometry?.x;
const y = row?.geometry?.y;
if (!Number.isFinite(x) || !Number.isFinite(y)) continue;
const { lat, lon } = webMercatorToWgs84(x, y);
if (!isLikelyWashingtonCoordinate(lat, lon)) continue;
const cameraId = `wsdot-${rawId}`;
const heading =
WSDOT_COMPASS_HEADINGS[
String(attrs.CompassDirection || '')
.trim()
.toUpperCase()
];
const hasHeading = Number.isFinite(heading);
cameras.push({
id: cameraId,
name: String(attrs.CameraTitle || '').trim() || `WSDOT camera ${rawId}`,
city: 'Washington',
cityId: 'washington-state',
provider: 'WSDOT',
lat,
lon,
headingDeg: hasHeading ? heading : fallbackHeadingFromId(cameraId),
headingConfidence: hasHeading ? 'high' : 'low',
// Same two pose personalities as the other packs: raw priors that the
// client's ground snap and manual calibration refine.
pitchDeg: hasHeading ? -24 : -18,
fovDeg: hasHeading ? 56 : 44,
rangeM: hasHeading ? 210 : 145,
mountHeightM: hasHeading ? 10 : 8,
feedType: 'image',
url: imageUrl,
snapshotUrl: imageUrl,
sourceKind: 'wsdot-open-data',
license: 'WSDOT Travel Center camera data (public low-volume feed)',
});
}
const maxRaw = Number(
process.env.CCTV_WSDOT_MAX_SOURCES || DEFAULT_WSDOT_MAX_SOURCES,
);
const maxCount = Number.isFinite(maxRaw)
? Math.max(8, Math.min(1200, Math.floor(maxRaw)))
: DEFAULT_WSDOT_MAX_SOURCES;
const prioritized = prioritizeSources(cameras, maxCount, WSDOT_ANCHORS);
console.log(
`[CCTV] Loaded WSDOT camera sources: ${cameras.length} published (using nearest ${prioritized.length})`,
);
return prioritized;
} catch (error) {
console.warn(
'[CCTV] WSDOT camera download error:',
error?.message || error,
);
return [];
}
}
/**
* One Statens vegvesen `CctvSimple` feature -> one catalog source, or null.
*
* `orientationDescription` names the place the camera looks towards
* ("Svelgen"), not a bearing, so it goes into the label and the heading uses
* the shared id-hash fallback at low confidence, like Calgary and Fintraffic.
* Cameras the feed reports as faulty are dropped. Cameras that publish HLS
* get live video, with the still as the snapshot fallback.
*
* @param {object} feature - GeoJSON feature from the OGC items response.
* @returns {?object}
*/
export function vegvesenCameraToSource(feature) {
if (!feature || typeof feature !== 'object') return null;
const props = feature.properties;
if (!props || typeof props !== 'object') return null;
const availability = String(
props['status.stillImageAvailability'] ?? '',
).trim();
if (availability && availability !== 'videoOrImagesAvailable') return null;
const coordinates = feature?.geometry?.coordinates;
if (!Array.isArray(coordinates) || coordinates.length < 2) return null;
const lon = toFiniteNumber(coordinates[0]);
const lat = toFiniteNumber(coordinates[1]);
if (!isLikelyNorwayCoordinate(lat, lon)) return null;
const rawId = String(props.cameraId ?? '').trim();
if (!/^[A-Za-z0-9_-]{1,40}$/.test(rawId)) return null;
let image;
try {
image = new URL(String(props.stillImageUrl ?? ''));
} catch {
return null;
}
if (image.username || image.password) return null;
const imageUrl = image.href;
if (imageUrl !== `${VEGVESEN_IMAGE_ORIGIN}${rawId}`) return null;
const cameraId = `no-vegvesen-${rawId.toLowerCase()}`;
const place = String(props.description ?? '').trim() || `Kamera ${rawId}`;
const towards = String(props.orientationDescription ?? '').trim();
const road = String(props.roadNumber ?? '').trim();
const name = towards && towards !== place ? `${place} → ${towards}` : place;
// Live video when the camera publishes HLS on the agency's own host; the
// still stays the snapshot fallback. CCTV_VEGVESEN_VIDEO=0 keeps stills only.
const videoUrl =
String(process.env.CCTV_VEGVESEN_VIDEO || '1').trim() !== '0' &&
Number(props.videoServiceLevel) > 0 &&
String(props.videoEncodingStandard ?? '').toLowerCase() === 'hls' &&
String(props.videoUrl ?? '').trim() === VEGVESEN_VIDEO_URL(rawId)
? VEGVESEN_VIDEO_URL(rawId)
: '';
return {
id: cameraId,
name: road ? `${road} ${name}` : name,
// One country-wide category in the camera picker, like Finland; the
// place itself is already in the name.
city: 'Norway',
cityId: 'norway',
provider: 'Statens vegvesen',
lat,
lon,
headingDeg: fallbackHeadingFromId(cameraId),
headingConfidence: 'low',
pitchDeg: -18,
fovDeg: 44,
rangeM: 145,
mountHeightM: 8,
// The feed's coordinates are 2D. The loader replaces this flat prior with
// Kartverket's terrain height for each camera (applyVegvesenGroundHeights);
// it only survives when that lookup fails, and then a mountain-pass camera
// can sit below the terrain on a stack without 3D tiles, the same risk the
// Caltrans pack documents.
groundElevationM: 150,
feedType: videoUrl ? 'hls' : 'image',
url: videoUrl || imageUrl,
snapshotUrl: imageUrl,
sourceKind: 'vegvesen-datex',
license:
'Contains data under the Norwegian licence for Open Government data (NLOD) distributed by Statens vegvesen',
code: cameraDisplayCode(place.toUpperCase()),
};
}
/**
* Fetch Statens vegvesen (Norway) road cameras from the keyless OGC API
* Features view of the DATEX 3.1 CCTV table. Frames are stills on
* kamera.atlas.vegvesen.no.
*
* @returns {Promise<Array<object>>} Normalized camera source objects.
*/
export async function loadVegvesenSourcesFromOpenData() {
const startedAt = Date.now();
try {
const endpoint = process.env.CCTV_VEGVESEN_URL || DEFAULT_VEGVESEN_CCTV_URL;
const resp = await fetch(endpoint, {
headers: { Accept: 'application/geo+json,application/json' },
redirect: 'manual',
signal: AbortSignal.timeout(CCTV_SOURCE_FETCH_TIMEOUT_MS),
});
const discard = async () => {
try {
await resp.body?.cancel();
} catch {
/* no-op */
}
return [];
};
if (resp.status >= 300 && resp.status < 400) {
console.warn(
'[CCTV] Vegvesen catalog redirected; redirects are not followed',
);
return discard();
}
if (!resp.ok) {
console.warn('[CCTV] Vegvesen camera download failed:', resp.status);
return discard();
}
const payload = await readResponseJsonCapped(
resp,
VEGVESEN_MAX_CATALOG_BYTES,
);
const features = Array.isArray(payload?.features) ? payload.features : [];
const cameras = [];
const seen = new Set();
for (const feature of features) {
const camera = vegvesenCameraToSource(feature);
if (!camera || seen.has(camera.id)) continue;
seen.add(camera.id);
cameras.push(camera);
}
const maxRaw = Number(
process.env.CCTV_VEGVESEN_MAX_SOURCES || DEFAULT_VEGVESEN_MAX_SOURCES,
);
const maxCount = Number.isFinite(maxRaw)
? Math.max(8, Math.min(1000, Math.floor(maxRaw)))
: DEFAULT_VEGVESEN_MAX_SOURCES;
const prioritized = prioritizeSources(cameras, maxCount, NORWAY_ANCHORS);
await applyVegvesenGroundHeights(prioritized, {
waitMs: Math.max(
0,
KARTVERKET_CATALOG_WAIT_MS - (Date.now() - startedAt),
),
});
console.log(
`[CCTV] Loaded Vegvesen camera sources: ${cameras.length} (using nearest ${prioritized.length})`,
);
return prioritized;
} catch (error) {
console.warn(
'[CCTV] Vegvesen camera download error:',
error?.message || error,
);
return [];
}
}
/**
* Kartverket answers by rounded position: `{z, expires}`, where z is metres or
* null for "no height there". Heights never expire (camera positions barely
* change); nulls expire after KARTVERKET_NULL_TTL_MS. The 15-minute catalog
* refresh therefore only asks about new or moved cameras. Bounded well above
* the ~900 cameras the pack can load.
*/
const kartverketHeightCache = new Map();
const KARTVERKET_CACHE_LIMIT = 5000;
/** The lookup in flight, so overlapping catalog loads never start a second. */
let kartverketPending = null;
/** No lookup starts before this time (epoch ms) after a transport failure. */
let kartverketBackoffUntil = 0;
/** Bumped by clearKartverketHeightCache, so a lookup started before a clear
* can no longer write to the cache, the backoff or the pending slot. */
let kartverketGeneration = 0;
/** Cache key for one position, at the ~0.1 m precision the request sends. */
const kartverketKey = (lon, lat) => `${lon.toFixed(6)},${lat.toFixed(6)}`;
/** Statuses that say the service, not the request, is failing. */
const isKartverketTransportStatus = (status) =>
status === 408 || status === 429 || status >= 500;
/** Forget cached heights, the backoff and any lookup in flight. Tests only. */
export function clearKartverketHeightCache() {
kartverketGeneration += 1;
kartverketHeightCache.clear();
kartverketPending = null;
kartverketBackoffUntil = 0;
}
/**
* How long to back off after a failed request: the Retry-After the service
* sent (seconds or an HTTP date), else the default, bounded to [1 min, 1 h].
*
* @param {?string} retryAfter - The Retry-After header, if any.
* @param {number} now - Epoch ms.
* @returns {number} Milliseconds.
*/
export function kartverketBackoffMs(retryAfter, now = Date.now()) {
let ms = KARTVERKET_FAILURE_BACKOFF_MS;
const text = String(retryAfter ?? '').trim();
if (/^\d+$/.test(text)) ms = Number(text) * 1000;
else if (text && Number.isFinite(Date.parse(text))) {
ms = Date.parse(text) - now;
}
return Math.max(60 * 1000, Math.min(KARTVERKET_MAX_BACKOFF_MS, ms));
}
/**
* Terrain heights above sea level (NN2000) from Kartverket's keyless
* point-height API, KARTVERKET_MAX_POINTS points per request. Each answer is
* matched back by the coordinates the API echoes, and only for points that
* request asked about, then reported through `into.set(key, z)` as its
* request lands, so a caller that stops waiting keeps what has arrived. `z` is
* metres, or null when the API has no usable height there. A point a 200
* answer leaves out is reported as null too, so it is not asked again on
* every refresh.
*
* Over water `/punkt` returns the depth of the sea floor (`datakilde:
* dybdekurver`, `terreng: Havflate`; −547 m under Nordhordlandsbrua). Either
* marker counts as 0 m, the water surface. For the ~14 bridge cameras that is
* below the deck, but far closer than the sea floor or a flat prior.
*
* The first transport failure (a 408, 429 or 5xx, a network error, a request
* past KARTVERKET_REQUEST_TIMEOUT_MS, an unreadable or oversized body, or the
* shared deadline) stops the lookup: no further requests start, and the
* failure is returned so the caller can back off. Points in failed requests
* are not reported, so they are asked again later. Any other 4xx rejects that
* request's points, not the service: they are reported as null and the lookup
* carries on.
*
* @param {Array<{lon:number, lat:number}>} points
* @param {{into?: {set: Function}, timeoutMs?: number, requestTimeoutMs?: number}} [options]
* @returns {Promise<{heights: Map<string, ?number>, failure: ?{status: ?number, retryAfter: ?string, deadline: boolean}}>}
* `heights` is `into` when it is a Map, else a Map of what was reported.
* `failure.deadline` is true when only the shared deadline ran out.
*/
export async function fetchKartverketHeights(
points,
{
into = new Map(),
timeoutMs = KARTVERKET_LOOKUP_TIMEOUT_MS,
requestTimeoutMs = KARTVERKET_REQUEST_TIMEOUT_MS,
} = {},
) {
const heights = into instanceof Map ? into : new Map();
const report = (key, z) => {
if (heights !== into) heights.set(key, z);
into.set(key, z);
};
const unique = Array.from(
new Map(
(points || [])
.filter((p) => isLikelyNorwayCoordinate(p?.lat, p?.lon))
.map((p) => [kartverketKey(p.lon, p.lat), p]),
).values(),
);
let failure = null;
if (!unique.length) return { heights, failure };
const batches = [];
for (let i = 0; i < unique.length; i += KARTVERKET_MAX_POINTS) {
batches.push(unique.slice(i, i + KARTVERKET_MAX_POINTS));
}
const deadline = AbortSignal.timeout(timeoutMs);
const fetchBatch = async (batch) => {
const asked = new Set(batch.map((p) => kartverketKey(p.lon, p.lat)));
const url = new URL(KARTVERKET_HEIGHT_URL);
url.searchParams.set(
'punkter',
JSON.stringify(
batch.map((p) => [Number(p.lon.toFixed(6)), Number(p.lat.toFixed(6))]),
),
);
url.searchParams.set('koordsys', '4258');
const resp = await fetch(url, {
headers: {
Accept: 'application/json',
'User-Agent': KARTVERKET_USER_AGENT,
},
redirect: 'error',
signal: AbortSignal.any([
deadline,
AbortSignal.timeout(requestTimeoutMs),
]),
});
if (!resp.ok) {
try {
await resp.body?.cancel();
} catch {
/* no-op */
}
if (!isKartverketTransportStatus(resp.status)) {
console.warn(
`[CCTV] Kartverket rejected ${asked.size} points: HTTP ${resp.status}`,
);
for (const key of asked) report(key, null);
return;
}
const error = new Error(`HTTP ${resp.status}`);
error.status = resp.status;
error.retryAfter = resp.headers.get('retry-after');
throw error;
}
const payload = await readResponseJsonCapped(
resp,
KARTVERKET_MAX_RESPONSE_BYTES,
);
for (const point of Array.isArray(payload?.punkter)
? payload.punkter
: []) {
const x = toFiniteNumber(point?.x);
const y = toFiniteNumber(point?.y);
if (!Number.isFinite(x) || !Number.isFinite(y)) continue;
const key = kartverketKey(x, y);
// Only the points this request asked about: an odd answer can neither
// grow the cache nor overwrite another camera.
if (!asked.has(key)) continue;
asked.delete(key);
// `z: null` means no height there; Number(null) would read 0.
const raw = point?.z;
let z =
raw === null || raw === undefined || raw === ''
? NaN
: toFiniteNumber(raw);
if (
Number.isFinite(z) &&
(point?.datakilde === 'dybdekurver' || point?.terreng === 'Havflate')
) {
z = 0;
}
const usable =
Number.isFinite(z) &&
z >= KARTVERKET_HEIGHT_RANGE_M.min &&
z <= KARTVERKET_HEIGHT_RANGE_M.max;
report(key, usable ? z : null);
}
// A 200 that leaves a point out has no height for it either.
for (const key of asked) report(key, null);
};
let next = 0;
const worker = async () => {
while (next < batches.length && !failure) {
const batch = batches[next++];
try {
await fetchBatch(batch);
} catch (error) {
if (!failure) {
failure = {
status: Number.isFinite(error?.status) ? error.status : null,
retryAfter: error?.retryAfter ?? null,
deadline: deadline.aborted,
};
console.warn(
'[CCTV] Kartverket height lookup failed:',
error?.message || error,
);
}
}
}
};
await Promise.all(
Array.from(
{ length: Math.min(KARTVERKET_CONCURRENCY, batches.length) },
worker,
),
);
return { heights, failure };
}
/**
* Give each Vegvesen camera its own terrain height as the ground prior, in
* place. Waits at most `waitMs` for positions it has no answer for, and only
* when more than KARTVERKET_WAIT_MISSING_SHARE of the cameras are missing
* (a cold cache); a few new cameras or expired misses are looked up in the
* background. A lookup still running when the wait ends keeps filling the
* cache for the next catalog refresh. Cameras without a height keep the flat
* prior. After a transport failure no lookup starts until the backoff ends,
* which outlasts the 15-minute catalog refresh, so a failing Kartverket costs
* one attempt per backoff, not one per refresh. A lookup cut short only by its
* shared deadline is resumed on the next refresh without a backoff. Cameras
* without finite coordinates are skipped. Never throws.
* CCTV_VEGVESEN_HEIGHTS=0 turns it off.
*
* @param {Array<object>} cameras - Sources from vegvesenCameraToSource().
* @param {{waitMs?: number, now?: () => number, timeoutMs?: number}} [options]
* `timeoutMs` is the lookup's shared deadline.
* @returns {Promise<number>} How many cameras got a terrain height.
*/
export async function applyVegvesenGroundHeights(
cameras,
{
waitMs = KARTVERKET_CATALOG_WAIT_MS,
now = Date.now,
timeoutMs = KARTVERKET_LOOKUP_TIMEOUT_MS,
} = {},
) {
if (String(process.env.CCTV_VEGVESEN_HEIGHTS || '1').trim() === '0') return 0;
const list = (Array.isArray(cameras) ? cameras : []).filter(
(c) => Number.isFinite(c?.lon) && Number.isFinite(c?.lat),
);
const startedAt = now();
const missing = list.filter((c) => {
const entry = kartverketHeightCache.get(kartverketKey(c.lon, c.lat));
return !entry || entry.expires <= startedAt;
});
if (
missing.length &&
!kartverketPending &&
startedAt >= kartverketBackoffUntil
) {
if (kartverketHeightCache.size + missing.length > KARTVERKET_CACHE_LIMIT) {
kartverketHeightCache.clear();
}
const generation = kartverketGeneration;
const current = () => generation === kartverketGeneration;
const sink = {
set: (key, z) => {
if (!current()) return;
kartverketHeightCache.set(key, {
z,
expires: z === null ? now() + KARTVERKET_NULL_TTL_MS : Infinity,
});
},
};
const pending = fetchKartverketHeights(missing, { into: sink, timeoutMs })
.then(({ failure }) => {
if (failure && !failure.deadline && current()) {
kartverketBackoffUntil =
now() + kartverketBackoffMs(failure.retryAfter, now());
}
})
.catch((error) => {
if (current()) {
kartverketBackoffUntil = now() + KARTVERKET_FAILURE_BACKOFF_MS;
}
console.warn(
'[CCTV] Kartverket height lookup error:',
error?.message || error,
);
})
.finally(() => {
if (kartverketPending === pending) kartverketPending = null;
});
kartverketPending = pending;
}
const cold = missing.length > list.length * KARTVERKET_WAIT_MISSING_SHARE;
if (cold && kartverketPending && waitMs > 0) {
let timer;
await Promise.race([
kartverketPending,
new Promise((resolve) => {
timer = setTimeout(resolve, waitMs);
timer.unref?.();
}),
]);
clearTimeout(timer);
}
let applied = 0;
for (const camera of list) {
const z = kartverketHeightCache.get(
kartverketKey(camera.lon, camera.lat),
)?.z;
if (!Number.isFinite(z)) continue;
camera.groundElevationM = Math.round(z * 10) / 10;
applied += 1;
}
if (list.length) {
console.log(
`[CCTV] Kartverket ground heights: ${applied}/${list.length} Vegvesen cameras`,
);
}
return applied;
}
const NYC_BOROUGHS = new Set([
'Manhattan',
'Brooklyn',
'Queens',
'Bronx',
'Staten Island',
]);
/** NYC TMC publishes `isOnline` as the strings "true"/"false". */
export function nycCameraIsOnline(value) {
return (
value === true ||
String(value ?? '')
.trim()
.toLowerCase() === 'true'
);
}
/**
* Label for one NYC camera: the intersection an operator recognises
* ("Central Park West @ 86 St"). The catalog's `area` is the borough, not
* a facing, and is not part of the name.
*
* @param {object} record - Raw TMC camera row.
* @param {string} cameraId - Derived stable id.
* @returns {string}
*/
export function nycCameraName(record, cameraId) {
const name = String(record?.name ?? '').trim();
if (name) return name;
const short = String(cameraId || '')
.replace(/^nyc-/, '')
.slice(0, 8);
return short ? `NYC Camera ${short}` : 'NYC Camera';
}
/**
* One NYC DOT TMC camera row -> one catalog source, or null.
*
* Frame URLs are synthesized from a validated UUID on the pinned TMC image
* origin. The payload's `imageUrl` is ignored so a catalog edit cannot steer
* the frame proxy off-host. There is no heading in the feed; headings use
* the shared id-hash fallback at low confidence, like Calgary and Fintraffic.
*
* @param {object} record - Raw TMC camera row.
* @returns {?object}
*/
export function nycCameraToSource(record) {
if (!record || typeof record !== 'object') return null;
if (!nycCameraIsOnline(record.isOnline)) return null;
const lat = toFiniteNumber(record.latitude);
const lon = toFiniteNumber(record.longitude);
if (!isLikelyNycCoordinate(lat, lon)) return null;
const uuid = String(record.id ?? '').trim();
if (!NYC_CAMERA_ID_RE.test(uuid)) return null;
const cameraId = `nyc-${uuid.toLowerCase()}`;
const imageUrl = NYC_IMAGE_URL(uuid);
const name = nycCameraName(record, cameraId);
const area = String(record.area ?? '').trim();
const city = NYC_BOROUGHS.has(area) ? area : 'New York';
return {
id: cameraId,
name,
city,
cityId: 'nyc',
provider: 'NYC DOT Traffic Management Center',
lat,
lon,
headingDeg: fallbackHeadingFromId(cameraId),
headingConfidence: 'low',
pitchDeg: -18,
fovDeg: 44,
rangeM: 145,
mountHeightM: 8,
groundElevationM: NYC_GROUND_ELEVATION_M,
feedType: 'image',
url: imageUrl,
snapshotUrl: imageUrl,
sourceKind: 'nyc-dot-tmc',
license: 'Public NYC DOT Traffic Management Center traffic camera',
code: cameraDisplayCode(name.toUpperCase()),
};
}
/**
* Fetch NYC DOT Traffic Management Center cameras from the keyless
* `webcams.nyctmc.org` catalog. Frames are stills on that same host,
* registered from a validated UUID rather than from the payload URL.
*
* @returns {Promise<Array<object>>} Normalized camera source objects.
*/
export async function loadNycSourcesFromOpenData() {
try {
const endpoint = process.env.CCTV_NYC_CAMERAS_URL || NYC_CAMERAS_URL;
const resp = await fetch(endpoint, {
headers: { Accept: 'application/json' },
redirect: 'manual',
signal: AbortSignal.timeout(CCTV_SOURCE_FETCH_TIMEOUT_MS),
});
const discard = async () => {
try {
await resp.body?.cancel();
} catch {
/* no-op */
}
return [];
};
if (resp.status >= 300 && resp.status < 400) {
console.warn('[CCTV] NYC catalog redirected; redirects are not followed');
return discard();
}
if (!resp.ok) {
console.warn('[CCTV] NYC camera download failed:', resp.status);
return discard();
}
const rows = await readResponseJsonCapped(resp, NYC_MAX_CATALOG_BYTES);
if (!Array.isArray(rows)) return [];
const cameras = [];
const seen = new Set();
for (const record of rows) {
const camera = nycCameraToSource(record);
if (!camera || seen.has(camera.id)) continue;
seen.add(camera.id);
cameras.push(camera);
}
const maxRaw = Number(
process.env.CCTV_NYC_MAX_SOURCES || DEFAULT_NYC_MAX_SOURCES,
);
const maxCount = Number.isFinite(maxRaw)
? Math.max(8, Math.min(400, Math.floor(maxRaw)))
: DEFAULT_NYC_MAX_SOURCES;
const prioritized = prioritizeSources(cameras, maxCount, NYC_ANCHORS);
console.log(
`[CCTV] Loaded NYC camera sources: ${cameras.length} (using nearest ${prioritized.length})`,
);
return prioritized;
} catch (error) {
console.warn('[CCTV] NYC camera download error:', error?.message || error);
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 [];
}
}
/** FL511 DIRECTION codes as headings; "NOT DIRECTIONAL" has none. */
const FLORIDA_DIRECTION_HEADINGS = Object.freeze({
N: 0,
E: 90,
S: 180,
W: 270,
});
/**
* Stable camera id from a frame URL. FL511's ID column repeats across regions
* (ID 1220 is both an I-75 and an I-95 camera), so the image channel is the
* key, as Calgary keys on its frame filename. Falls back to a path slug.
*
* @param {string} imageUrl - A host-pinned FL511 frame URL.
* @returns {?string} Provider-stable id, or null when underivable.
*/
export function floridaCameraId(imageUrl) {
let path;
try {
path = new URL(String(imageUrl ?? '')).pathname;
} catch {
return null;
}
const channel = /\/chan-(\d+)_[a-z]+\.jpg$/i.exec(path);
if (channel) return `fl-${channel[1]}`;
const slug = path
.replace(/^\/+|\.[a-z0-9]+$/gi, '')
.replace(/[^a-zA-Z0-9]+/g, '-')
.toLowerCase();
return slug ? `fl-${slug}` : null;
}
/**
* One FL511 ArcGIS feature -> one catalog source, or null. DIRECTION is a
* one-letter code the free-text parser won't read, so a table maps it.
*
* @param {object} feature - ArcGIS feature from the FL511 camera layer.
* @returns {?object}
*/
export function floridaCameraToSource(feature) {
const a = feature?.attributes;
if (!a || typeof a !== 'object') return null;
const lat = toFiniteNumber(a.LATITUDE);
const lon = toFiniteNumber(a.LONGITUDE);
if (!isLikelyFloridaCoordinate(lat, lon)) return null;
const imageUrl = String(a.IMAGE || '');
let image;
try {
image = new URL(imageUrl);
} catch {
return null;
}
// Official image hosts only, HTTPS, no embedded credentials.
if (
image.protocol !== 'https:' ||
!FLORIDA_IMAGE_HOSTS.includes(image.host) ||
image.username ||
image.password
)
return null;
const cameraId = floridaCameraId(imageUrl);
if (!cameraId) return null;
const heading =
FLORIDA_DIRECTION_HEADINGS[
String(a.DIRECTION || '')
.trim()
.toUpperCase()
];
const hasHeading = Number.isFinite(heading);
return {
id: cameraId,
name: String(a.DESCRIPT || '').trim() || `FL511 ${cameraId.slice(3)}`,
city: String(a.COUNTY ? `${a.COUNTY} County` : 'Florida'),
cityId: 'florida',
provider: 'FL511',
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: 5, // Coastal prior; the client's ground snap corrects.
feedType: 'image',
url: imageUrl,
snapshotUrl: imageUrl,
sourceKind: 'fl511-open-data',
license:
'FL511 (FDOT), individual personal use only; other use needs FDOT written consent',
};
}
/**
* Fetch FL511 (Florida DOT) cameras, keyless: an ArcGIS FeatureServer paged
* up to 2,000 rows at a time, ordered by the layer's object id so offsets are
* stable. Paging follows `exceededTransferLimit` and advances by the rows each
* page returned, so a lower server-side max record count still reads the whole
* layer. Frames are stills on the Divas Cloud hosts. A redirect on any page
* drops the pack; any other failed page keeps the rows already read.
*
* @returns {Promise<Array<object>>} Normalized camera source objects.
*/
export async function loadFloridaSourcesFromOpenData() {
try {
const cameras = [];
const seen = new Set();
const pageSize = 2000;
let offset = 0;
// Page ceiling, in case the service ignores resultOffset.
for (let page = 0; ; page += 1) {
if (page >= FLORIDA_MAX_CATALOG_PAGES) {
console.warn(
`[CCTV] Florida catalog stopped at the ${FLORIDA_MAX_CATALOG_PAGES}-page ceiling; the catalog may be incomplete`,
);
break;
}
// OBJECTID_1 is this layer's object id field (a plain OBJECTID is a 400).
const url = `${FLORIDA_CCTV_URL}?where=1%3D1&outFields=*&orderByFields=OBJECTID_1&f=json&resultRecordCount=${pageSize}&resultOffset=${offset}`;
let resp;
try {
resp = await fetch(url, {
headers: { Accept: 'application/json' },
redirect: 'manual',
signal: AbortSignal.timeout(CCTV_SOURCE_FETCH_TIMEOUT_MS),
});
} catch (error) {
console.warn(
'[CCTV] Florida catalog page failed:',
error?.message || error,
);
break;
}
// Release the body on every path that will not read it.
const discard = async () => {
try {
await resp.body?.cancel();
} catch {
/* no-op */
}
};
if (resp.status >= 300 && resp.status < 400) {
console.warn(
'[CCTV] Florida catalog redirected; redirects are not followed',
);
await discard();
return [];
}
if (!resp.ok) {
console.warn('[CCTV] Florida camera download failed:', resp.status);
await discard();
break;
}
let body;
try {
body = await readResponseJsonCapped(resp, FLORIDA_MAX_CATALOG_BYTES);
} catch (error) {
console.warn(
'[CCTV] Florida catalog page unreadable:',
error?.message || error,
);
break;
}
const feats = Array.isArray(body?.features) ? body.features : [];
for (const feature of feats) {
const camera = floridaCameraToSource(feature);
if (!camera || seen.has(camera.id)) continue;
seen.add(camera.id);
cameras.push(camera);
}
// ArcGIS sets exceededTransferLimit while more rows remain. A page may
// be shorter than pageSize when the layer's max record count is lower.
if (body?.exceededTransferLimit !== true || feats.length === 0) break;
offset += feats.length;
}
const maxRaw = Number(
process.env.CCTV_FLORIDA_MAX_SOURCES || DEFAULT_FLORIDA_MAX_SOURCES,
);
const maxCount = Number.isFinite(maxRaw)
? Math.max(8, Math.min(4200, Math.floor(maxRaw)))
: DEFAULT_FLORIDA_MAX_SOURCES;
const prioritized = prioritizeSources(cameras, maxCount, FLORIDA_ANCHORS);
console.log(
`[CCTV] Loaded Florida camera sources: ${cameras.length} (using nearest ${prioritized.length})`,
);
return prioritized;
} catch (error) {
console.warn(
'[CCTV] Florida camera download error:',
error?.message || error,
);
return [];
}
}