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, isPlausibleLatLon, isLikelyBcCoordinate, isLikelyTexasCoordinate, isLikelyNswCoordinate, isLikelyCalgaryCoordinate, isLikelyFloridaCoordinate, isLikelyCataloniaCoordinate, decodeNumericEntities, isLikelyNycCoordinate, isLikelyHongKongCoordinate, isLikelyNorwayCoordinate, isLikelyTorontoCoordinate, cameraDisplayCode, rowArrayToObject, prioritizeSources, pinnedCameraUrl, } 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>} 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>} 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; // Official-host pin (see JSDoc). Also drops records with no still image. const imageUrl = pinnedCameraUrl( cctv.imageData?.static?.currentImageURL, 'https://cwwp2.dot.ca.gov/', ); if (!imageUrl) 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>} 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 = pinnedCameraUrl(props.imageUrl, TFL_IMAGE_ORIGIN); if (!imageUrl) 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} 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>} 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>} 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>} 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} 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>} 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} 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 = pinnedCameraUrl( typeof item.url === 'string' ? item.url : typeof item.snapshotUrl === 'string' ? item.snapshotUrl : '', TALLINN_IMAGE_ORIGIN, ); if (!imageUrl) 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} */ export function parseTarkteeDatexLocations(xml) { const out = new Map(); const blockRe = /]*>([\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 = /\s*(-?\d+(?:\.\d+)?)\s*<\/latitude>/i.exec( body, ); const lonMatch = /\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 = /]*>\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} */ export function parseTarkteeDatexImages(xml) { const out = new Map(); const blockRe = /]*>([\s\S]*?)<\/trafficView>/g; let match; while ((match = blockRe.exec(String(xml || ''))) !== null) { const body = match[1]; const refMatch = /]*\bid="([^"]+)"/i.exec(body); const urlMatch = /\s*([^<\s]+)\s*<\/urlLinkAddress>/i.exec( body, ); if (!refMatch || !urlMatch) continue; const url = pinnedCameraUrl(urlMatch[1], TARKTEE_IMAGE_ORIGIN); if (!url) 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>} 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} 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 raw = typeof item.url === 'string' ? item.url : typeof item.snapshotUrl === 'string' ? item.snapshotUrl : ''; const url = WARENDORF_IMAGE_ORIGINS.map((o) => pinnedCameraUrl(raw, o), ).find(Boolean); if (!id || !url) 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 = pinnedCameraUrl(props.href, NSW_IMAGE_ORIGIN); if (!url) 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>} 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:'; return pinnedCameraUrl(parsed.toString(), CALGARY_IMAGE_ORIGIN); } /** * 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>} 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 = /([\s\S]*?)<\/gml:featureMember>/g; const tag = (block, name) => { const match = new RegExp(`([^<]*)<\\/cite:${name}>`).exec( block, ); return match ? decodeNumericEntities(match[1]) : ''; }; let match; while ((match = blockRe.exec(String(xml || ''))) !== null) { const block = match[1]; const fidMatch = /]*>([^<]+)<\/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:pass@www.bcn.cat/...`) 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} */ 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: " ()", * 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>} 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 `` block. * * @param {string} body * @param {string} tag * @returns {string} */ function hkXmlTag(body, tag) { const match = new RegExp(`<${tag}>\\s*([^<]*?)\\s*`, 'i').exec( String(body || ''), ); return match ? match[1].trim() : ''; } /** * Parse the Transport Department English camera-locations XML into rows. * * Schema-specific: each `` 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} */ export function parseHkCameraLocationsXml(xml) { const rows = []; const blockRe = /([\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:'; return pinnedCameraUrl(parsed.toString(), HK_IMAGE_ORIGIN); } /** * 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>} 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>} 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 = pinnedCameraUrl( String(attrs.ImageURL || '') .trim() .replace( /^http:\/\/info\.kingcounty\.gov\//, KING_COUNTY_IMAGE_ORIGIN, ), KING_COUNTY_IMAGE_ORIGIN, ); if (!imageUrl) 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>} 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 = pinnedCameraUrl(attrs.ImageURL, WSDOT_IMAGE_ORIGIN); if (!imageUrl) 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>} 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, 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} cameras - Sources from vegvesenCameraToSource(). * @param {{waitMs?: number, now?: () => number, timeoutMs?: number}} [options] * `timeoutMs` is the lookup's shared deadline. * @returns {Promise} 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>} 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} 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>} 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>} 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 []; } }