mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
One WFS/GML XML feed (~135 cameras) aggregating three publishers under a `font` field: SCT's own highway cameras plus hotlinked Barcelona (IMI) and Terrassa municipal cameras. Frame URLs are checked against a small host allowlist rather than one pinned origin, and every non-SCT camera carries its real owner as a `credit` shown beside the shared provider. The feed carries no compass heading, so every camera loads at low heading confidence (same as TfL and Fintraffic), and ground elevation is primed per publisher rather than one flat prior. SCT's own frame link needed a workaround: RenderService always 302s to a second endpoint (TransitCamera) on a Location the upstream hardcodes as plain http even when requested over https — refused by fetchWithinHost's same-origin (scheme-included) redirect policy, by design (stops an upstream steering a host-pinned request into a plaintext downgrade). Separately, mct.gencat.cat's TLS setup fails the handshake with Node's fetch outright (ERR_SSL_WRONG_SIGNATURE_TYPE) even though other HTTP clients complete it. The loader builds the TransitCamera URL itself, over plain http, from RenderService's sctidcam id, instead of registering the broken upstream link — the only transport this host actually serves to this runtime. Verified against the live feed and the production fetch path (fetchCctvImageFromUpstream): all 135 cameras return real image bytes, 0 false positives. Ambient-card refresh cadence added to src/data/cctvLod.js (3 minutes), matching the upstream update rate measured empirically (no change inside 64s, changed within ~90-150s) — the same cadence already used for Caltrans/TfL/Ontario/TxDOT. CCTV_CATALONIA_MAX_SOURCES sets the cap and CCTV_CATALONIA_ENABLED=0 turns the pack off. No overlap with any existing pack (checked catalog.js and the git history for Spain/Catalonia/Barcelona coverage: none exists). Verified before this commit: format, check:boundaries, unit suite (3692 tests), and build all green. Co-Authored-By: Claude Sonnet 5 <[email protected]>
551 lines
17 KiB
JavaScript
551 lines
17 KiB
JavaScript
import {
|
|
normalizeFeedType,
|
|
isVideoFeedType,
|
|
} from '../../../src/sources/cctvTypes.js';
|
|
export { normalizeFeedType, isVideoFeedType };
|
|
import { directionToHeading } from '../../../src/data/directionText.js';
|
|
import { haversineKm } from '../common/geo.js';
|
|
/**
|
|
* FNV-1a 32-bit hash of a string, used to derive deterministic pseudo-random
|
|
* values (e.g. hue for synthetic SVG billboards, fallback heading angles).
|
|
*
|
|
* @param {string} text
|
|
* @returns {number} Unsigned 32-bit hash.
|
|
*/
|
|
export function hashSeed(text) {
|
|
let h = 2166136261 >>> 0; // FNV offset basis
|
|
for (let i = 0; i < text.length; i++) {
|
|
h ^= text.charCodeAt(i);
|
|
h = Math.imul(h, 16777619); // FNV prime
|
|
}
|
|
return h >>> 0;
|
|
}
|
|
|
|
/**
|
|
* Escape special XML/HTML characters for safe embedding in SVG text nodes.
|
|
*
|
|
* @param {string} text
|
|
* @returns {string}
|
|
*/
|
|
export function escapeXml(text) {
|
|
return String(text || '')
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, ''');
|
|
}
|
|
|
|
/**
|
|
* Coerce a value to a finite number, returning fallback if NaN/Infinity.
|
|
*
|
|
* @param {*} value
|
|
* @param {number} [fallback=NaN]
|
|
* @returns {number}
|
|
*/
|
|
export function toFiniteNumber(value, fallback = NaN) {
|
|
const num = Number(value);
|
|
return Number.isFinite(num) ? num : fallback;
|
|
}
|
|
|
|
/**
|
|
* Normalize a column/field name to a lowercase snake_case key.
|
|
*
|
|
* @param {string} text
|
|
* @returns {string}
|
|
*/
|
|
export function normalizeKey(text) {
|
|
return String(text || '')
|
|
.trim()
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9]+/g, '_')
|
|
.replace(/^_+|_+$/g, '');
|
|
}
|
|
|
|
/**
|
|
* Parse a WKT POINT string (e.g. "POINT(-97.74 30.27)") into lat/lon.
|
|
*
|
|
* WKT uses (lon lat) order; returned object uses {lat, lon}.
|
|
*
|
|
* @param {string} value
|
|
* @returns {{lat:number, lon:number}}
|
|
*/
|
|
export function parsePointString(value) {
|
|
const match = String(value || '').match(
|
|
/POINT\s*\(\s*(-?\d+(?:\.\d+)?)\s+(-?\d+(?:\.\d+)?)\s*\)/i,
|
|
);
|
|
if (!match) return { lat: NaN, lon: NaN };
|
|
return {
|
|
lon: toFiniteNumber(match[1]),
|
|
lat: toFiniteNumber(match[2]),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Extract lat/lon from a variety of coordinate representations.
|
|
*
|
|
* Handles WKT POINT strings, and objects with latitude/lat/y or
|
|
* longitude/lon/lng/x properties (various casing).
|
|
*
|
|
* @param {string|object|null} value
|
|
* @returns {{lat:number, lon:number}}
|
|
*/
|
|
export function coerceLatLon(value) {
|
|
if (!value) return { lat: NaN, lon: NaN };
|
|
|
|
if (typeof value === 'string') {
|
|
return parsePointString(value);
|
|
}
|
|
|
|
if (typeof value !== 'object') {
|
|
return { lat: NaN, lon: NaN };
|
|
}
|
|
|
|
const lat = toFiniteNumber(
|
|
value.latitude ?? value.lat ?? value.y ?? value.Latitude ?? value.Lat,
|
|
NaN,
|
|
);
|
|
const lon = toFiniteNumber(
|
|
value.longitude ??
|
|
value.lon ??
|
|
value.lng ??
|
|
value.x ??
|
|
value.Longitude ??
|
|
value.Lon,
|
|
NaN,
|
|
);
|
|
return { lat, lon };
|
|
}
|
|
|
|
/**
|
|
* Extract geographic coordinates from an Austin Open Data camera record.
|
|
*
|
|
* Tries several candidate fields (location, coordinates, the_geom,
|
|
* point, geocoded_column) via coerceLatLon, then falls back to
|
|
* explicit latitude/longitude scalar fields.
|
|
*
|
|
* @param {object} record - Flattened camera record.
|
|
* @returns {{lat:number, lon:number}}
|
|
*/
|
|
export function extractAustinCoords(record) {
|
|
const candidates = [
|
|
record.location,
|
|
record.coordinates,
|
|
record.the_geom,
|
|
record.point,
|
|
record.geocoded_column,
|
|
];
|
|
for (const candidate of candidates) {
|
|
const parsed = coerceLatLon(candidate);
|
|
if (Number.isFinite(parsed.lat) && Number.isFinite(parsed.lon))
|
|
return parsed;
|
|
}
|
|
|
|
const lat = toFiniteNumber(
|
|
record.latitude ??
|
|
record.lat ??
|
|
record.camera_latitude ??
|
|
record.location_latitude,
|
|
NaN,
|
|
);
|
|
const lon = toFiniteNumber(
|
|
record.longitude ??
|
|
record.lon ??
|
|
record.lng ??
|
|
record.camera_longitude ??
|
|
record.location_longitude,
|
|
NaN,
|
|
);
|
|
return { lat, lon };
|
|
}
|
|
|
|
/**
|
|
* Extract a numeric camera ID from an Austin Open Data record.
|
|
*
|
|
* Tries well-known field names first, then scans any field whose key
|
|
* contains "camera"/"cam"/"device" + "id".
|
|
*
|
|
* @param {object} record - Flattened camera record.
|
|
* @returns {string} Numeric ID string, or '' if none found.
|
|
*/
|
|
export function extractAustinCameraId(record) {
|
|
const preferredKeys = [
|
|
'camera_id',
|
|
'cameraid',
|
|
'cam_id',
|
|
'device_id',
|
|
'intersection_id',
|
|
'id',
|
|
];
|
|
for (const key of preferredKeys) {
|
|
const value = record[key];
|
|
if (value == null) continue;
|
|
const asText = String(value).trim();
|
|
if (!asText) continue;
|
|
if (/^\d+$/.test(asText)) return asText;
|
|
}
|
|
|
|
for (const [key, value] of Object.entries(record)) {
|
|
if (!/camera|cam|device/.test(key)) continue;
|
|
if (!/id/.test(key)) continue;
|
|
const asText = String(value || '').trim();
|
|
if (!asText) continue;
|
|
if (/^\d+$/.test(asText)) return asText;
|
|
}
|
|
|
|
return '';
|
|
}
|
|
|
|
/**
|
|
* Extract a human-readable camera name from an Austin record.
|
|
*
|
|
* @param {object} record - Flattened camera record.
|
|
* @param {string} cameraId - Fallback identifier if no name field found.
|
|
* @returns {string}
|
|
*/
|
|
export function extractAustinName(record, cameraId) {
|
|
const preferredKeys = [
|
|
'camera_name',
|
|
'location_name',
|
|
'intersection_name',
|
|
'location',
|
|
'cross_street',
|
|
'description',
|
|
'name',
|
|
];
|
|
for (const key of preferredKeys) {
|
|
const value = record[key];
|
|
if (typeof value !== 'string') continue;
|
|
const text = value.trim();
|
|
if (text) return text;
|
|
}
|
|
return `Austin Camera ${cameraId}`;
|
|
}
|
|
|
|
/**
|
|
* Extract camera heading (compass bearing) from an Austin record.
|
|
*
|
|
* Tries explicit numeric heading fields first, then direction-keyword
|
|
* fields, then infers from the camera name/description text.
|
|
*
|
|
* @param {object} record - Flattened camera record.
|
|
* @returns {number} Heading in degrees [0..360), or NaN if unknown.
|
|
*/
|
|
export function extractAustinHeading(record) {
|
|
const direct = toFiniteNumber(
|
|
record.heading_deg ?? record.heading ?? record.bearing,
|
|
NaN,
|
|
);
|
|
if (Number.isFinite(direct)) return ((direct % 360) + 360) % 360;
|
|
|
|
// Dedicated direction fields: bare cardinal words ("West") are real facings.
|
|
const directionKeys = [
|
|
'direction',
|
|
'travel_direction',
|
|
'facing',
|
|
'facing_direction',
|
|
];
|
|
for (const key of directionKeys) {
|
|
const heading = directionToHeading(record[key], true);
|
|
if (Number.isFinite(heading)) return heading;
|
|
}
|
|
|
|
// Free-form name/intersection text: only explicit travel forms ("WESTBOUND"/
|
|
// "WB") count — a bare "West" here is a street name ("5TH ST / WEST AVE"), not
|
|
// a facing, and must not promote the camera to a false high-confidence heading.
|
|
const nameProbe = [
|
|
record.camera_name,
|
|
record.location_name,
|
|
record.intersection_name,
|
|
record.location,
|
|
record.cross_street,
|
|
record.description,
|
|
record.name,
|
|
]
|
|
.filter(Boolean)
|
|
.join(' ');
|
|
const inferred = directionToHeading(nameProbe);
|
|
if (Number.isFinite(inferred)) return inferred;
|
|
|
|
return NaN;
|
|
}
|
|
|
|
/**
|
|
* Bounding-box sanity check: is this coordinate plausibly in the Austin metro area?
|
|
*
|
|
* @param {number} lat
|
|
* @param {number} lon
|
|
* @returns {boolean}
|
|
*/
|
|
export function isLikelyAustinCoordinate(lat, lon) {
|
|
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return false;
|
|
return lat >= 30.02 && lat <= 30.58 && lon >= -98.12 && lon <= -97.4;
|
|
}
|
|
|
|
/**
|
|
* Bounding-box sanity check: is this coordinate plausibly on the Finnish road
|
|
* network? Generous around the observed catalog extent (59.86..70.09 N,
|
|
* 19.62..31.28 E) so a real new station is never dropped, tight enough that a
|
|
* swapped lat/lon or a null island record is.
|
|
*
|
|
* @param {number} lat
|
|
* @param {number} lon
|
|
* @returns {boolean}
|
|
*/
|
|
/** Longest unselected camera label the HUD shows before it gets noisy. */
|
|
export const CAMERA_CODE_MAX_CHARS = 28;
|
|
|
|
/**
|
|
* Short display code for the unselected camera label ("CAM-<code>"): the
|
|
* feed's own name for the camera ("5TH ST / CONGRESS AVE", "TRAFALGAR
|
|
* SQUARE"), trimmed to CAMERA_CODE_MAX_CHARS. A pack may pass an explicit
|
|
* `code` (TxDOT's device key, NSW's title); the id is the last resort.
|
|
*
|
|
* @param {string} text
|
|
* @returns {string}
|
|
*/
|
|
export function cameraDisplayCode(text) {
|
|
const clean = String(text || '')
|
|
.replace(/\s+/g, ' ')
|
|
.trim();
|
|
if (clean.length <= CAMERA_CODE_MAX_CHARS) return clean;
|
|
return `${clean.slice(0, CAMERA_CODE_MAX_CHARS - 1).trimEnd()}…`;
|
|
}
|
|
|
|
/** Finite, in range, and not the null island that Number(null) produces. */
|
|
export function isPlausibleLatLon(lat, lon) {
|
|
return (
|
|
Number.isFinite(lat) &&
|
|
Number.isFinite(lon) &&
|
|
Math.abs(lat) <= 90 &&
|
|
Math.abs(lon) <= 180 &&
|
|
!(lat === 0 && lon === 0)
|
|
);
|
|
}
|
|
|
|
/** British Columbia bounding box (with the neighbouring border crossings). */
|
|
export function isLikelyBcCoordinate(lat, lon) {
|
|
return (
|
|
isPlausibleLatLon(lat, lon) &&
|
|
lat >= 48 &&
|
|
lat <= 60.5 &&
|
|
lon >= -139.5 &&
|
|
lon <= -114
|
|
);
|
|
}
|
|
|
|
/** Texas bounding box. */
|
|
export function isLikelyTexasCoordinate(lat, lon) {
|
|
return (
|
|
isPlausibleLatLon(lat, lon) &&
|
|
lat >= 25.5 &&
|
|
lat <= 36.7 &&
|
|
lon >= -107 &&
|
|
lon <= -93.4
|
|
);
|
|
}
|
|
|
|
/** New South Wales bounding box (incl. the ACT and Lord Howe Island). */
|
|
export function isLikelyNswCoordinate(lat, lon) {
|
|
return (
|
|
isPlausibleLatLon(lat, lon) &&
|
|
lat >= -38 &&
|
|
lat <= -28 &&
|
|
lon >= 140.9 &&
|
|
lon <= 159.2
|
|
);
|
|
}
|
|
|
|
/** Calgary's municipal extent, with slack for the ring road. */
|
|
export function isLikelyCalgaryCoordinate(lat, lon) {
|
|
return (
|
|
isPlausibleLatLon(lat, lon) &&
|
|
lat >= 50.8 &&
|
|
lat <= 51.25 &&
|
|
lon >= -114.4 &&
|
|
lon <= -113.8
|
|
);
|
|
}
|
|
|
|
export function isLikelyFinlandCoordinate(lat, lon) {
|
|
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return false;
|
|
return lat >= 59.5 && lat <= 70.5 && lon >= 19 && lon <= 32;
|
|
}
|
|
|
|
/** Catalonia bounding box (mainland is roughly 40.5..42.9 N, 0.15..3.4 E). */
|
|
export function isLikelyCataloniaCoordinate(lat, lon) {
|
|
return (
|
|
isPlausibleLatLon(lat, lon) &&
|
|
lat >= 40.3 &&
|
|
lat <= 42.9 &&
|
|
lon >= 0.0 &&
|
|
lon <= 3.4
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Decode decimal numeric HTML character references ("í" -> "í"), the
|
|
* entity form the Catalonia feed uses for accented place names, plus the
|
|
* handful of named entities XML/HTML text commonly carries.
|
|
*
|
|
* @param {string} text
|
|
* @returns {string}
|
|
*/
|
|
export function decodeNumericEntities(text) {
|
|
return String(text || '')
|
|
.replace(/&#(\d+);/g, (_, code) => String.fromCodePoint(Number(code)))
|
|
.replace(/&/g, '&')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, "'")
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.trim();
|
|
}
|
|
|
|
/**
|
|
* Human label for one Fintraffic preset (camera view).
|
|
*
|
|
* Station names are machine-shaped road codes ("vt3_Hyvinkää_Noppo"); the
|
|
* underscores become spaces. A preset id is always its station id plus a
|
|
* two-digit view number, so the remainder distinguishes the several views that
|
|
* share one station position. The station list endpoint carries no
|
|
* presentationName ("Helsinkiin"); that lives only on the per-station detail
|
|
* endpoint, which would cost one request per station.
|
|
*
|
|
* @param {string} stationName - Raw `properties.name`.
|
|
* @param {string} stationId - Raw `properties.id` (e.g. "C01503").
|
|
* @param {string} presetId - Raw preset id (e.g. "C0150301").
|
|
* @returns {string}
|
|
*/
|
|
export function fintrafficCameraName(stationName, stationId, presetId) {
|
|
const base =
|
|
String(stationName || '')
|
|
.replace(/_/g, ' ')
|
|
.trim() || `Fintraffic ${stationId}`;
|
|
const view = String(presetId || '').slice(String(stationId || '').length);
|
|
return view ? `${base} (view ${view})` : base;
|
|
}
|
|
|
|
/**
|
|
* Derive a deterministic fallback heading from a camera ID hash.
|
|
*
|
|
* Produces one of 16 evenly-spaced compass directions (0, 22.5, 45, ...).
|
|
*
|
|
* @param {string} cameraId
|
|
* @returns {number} Heading in degrees [0..360).
|
|
*/
|
|
export function fallbackHeadingFromId(cameraId) {
|
|
return (hashSeed(String(cameraId)) % 16) * 22.5;
|
|
}
|
|
|
|
/**
|
|
* Convert a Socrata rows.json array row into a keyed object using column metadata.
|
|
*
|
|
* @param {Array} row - Array of cell values from the Socrata payload.
|
|
* @param {Array<{fieldName?:string, name?:string}>} columns - Column descriptors.
|
|
* @returns {object} Keyed record with normalized snake_case keys.
|
|
*/
|
|
export function rowArrayToObject(row, columns) {
|
|
const record = {};
|
|
for (let idx = 0; idx < columns.length; idx++) {
|
|
const col = columns[idx];
|
|
const key = normalizeKey(col.fieldName || col.name || `col_${idx}`);
|
|
if (!key) continue;
|
|
record[key] = row[idx];
|
|
}
|
|
return record;
|
|
}
|
|
|
|
/**
|
|
* Distance-prioritizes cameras to a cap: keeps the maxCount cameras closest
|
|
* to ANY of the given anchor points (min distance over anchors), tie-broken
|
|
* by original array order. Used by every live source pack (Austin: one
|
|
* downtown anchor; Caltrans: one anchor per major CA metro; TfL: central
|
|
* London) so a cap always keeps the densest, most interesting cores.
|
|
*
|
|
* @param {Array<object>} cameras - Normalized camera source objects.
|
|
* @param {number} maxCount - Cap (<=0 or >= length disables).
|
|
* @param {Array<{lat:number,lon:number}>} anchors - At least one anchor.
|
|
* @returns {Array<object>} Capped, priority-ordered camera list.
|
|
*/
|
|
export function prioritizeSources(cameras, maxCount, anchors) {
|
|
const list = Array.isArray(cameras) ? cameras : [];
|
|
const anchorList = (Array.isArray(anchors) ? anchors : []).filter(
|
|
(a) => Number.isFinite(a?.lat) && Number.isFinite(a?.lon),
|
|
);
|
|
if (!anchorList.length) return list;
|
|
// Always sort when anchors exist, even when the pack fits its own cap: the
|
|
// catalog-wide cap (cap.js) thins a pack from the END of this order, so
|
|
// "nearest to an anchor first" has to hold whether or not the pack was
|
|
// trimmed here.
|
|
const cap =
|
|
Number.isFinite(maxCount) && maxCount > 0
|
|
? Math.min(maxCount, list.length)
|
|
: list.length;
|
|
|
|
const scored = list.map((camera, idx) => {
|
|
const lat = Number(camera?.lat);
|
|
const lon = Number(camera?.lon);
|
|
const distKm =
|
|
Number.isFinite(lat) && Number.isFinite(lon)
|
|
? Math.min(
|
|
...anchorList.map((a) => haversineKm(lat, lon, a.lat, a.lon)),
|
|
)
|
|
: Number.POSITIVE_INFINITY;
|
|
return { camera, idx, distKm };
|
|
});
|
|
|
|
scored.sort((a, b) => {
|
|
if (a.distKm !== b.distKm) return a.distKm - b.distKm;
|
|
return a.idx - b.idx;
|
|
});
|
|
|
|
return scored.slice(0, cap).map((entry) => entry.camera);
|
|
}
|
|
|
|
/**
|
|
* Normalize a raw CCTV source item into a canonical shape with safe defaults.
|
|
*
|
|
* @param {object} item - Raw source from file, env, or Austin Open Data.
|
|
* @returns {object} Normalized source with all expected fields populated.
|
|
*/
|
|
export function normalizeSourceItem(item) {
|
|
return {
|
|
id: String(item.id || '').trim(),
|
|
name: String(item.name || item.id || '').trim(),
|
|
city: String(item.city || ''),
|
|
cityId: String(item.cityId || ''),
|
|
provider: String(item.provider || 'Configured CCTV Source'),
|
|
lat: toFiniteNumber(item.lat),
|
|
lon: toFiniteNumber(item.lon),
|
|
headingDeg: toFiniteNumber(item.headingDeg),
|
|
headingConfidence: String(
|
|
item.headingConfidence || item.headingSource || '',
|
|
).toLowerCase(),
|
|
pitchDeg: toFiniteNumber(item.pitchDeg),
|
|
fovDeg: toFiniteNumber(item.fovDeg),
|
|
rangeM: toFiniteNumber(item.rangeM),
|
|
mountHeightM: toFiniteNumber(item.mountHeightM),
|
|
groundElevationM: toFiniteNumber(item.groundElevationM),
|
|
feedType: normalizeFeedType(item.feedType || item.type || ''),
|
|
url: typeof item.url === 'string' ? item.url : '',
|
|
snapshotUrl: typeof item.snapshotUrl === 'string' ? item.snapshotUrl : '',
|
|
license: String(item.license || item.licenseNote || ''),
|
|
// Per-camera attribution for feeds a partner supplies inside a pack
|
|
// (DriveBC: TransLink, city cameras). Shown beside the provider.
|
|
credit: String(item.credit || '').trim(),
|
|
// Unselected-label code: the pack's explicit short name, else the feed's
|
|
// name, else the id.
|
|
code: cameraDisplayCode(
|
|
item.code || String(item.name || '').toUpperCase() || item.id || '',
|
|
),
|
|
sourceKind: String(item.sourceKind || item.kind || 'configured'),
|
|
// Optional CAL badge input (cctv-v2 design §3b/§9.2, additive-only per the
|
|
// global constraints — nothing else in this file changes): hand-authored
|
|
// file/env catalog entries may declare poseSource:'curated' so the panel
|
|
// badge can distinguish them from raw automated priors (e.g. Austin Open
|
|
// Data, which never sets this field). Passed through as-is to the client.
|
|
poseSource: item.poseSource === 'curated' ? 'curated' : undefined,
|
|
};
|
|
}
|