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