Files
gods-eye-view/server/providers/cctv/normalize.js
T

578 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
/**
* 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
);
}
/** New York City's five boroughs, with slack for the harbor and city line. */
export function isLikelyNycCoordinate(lat, lon) {
return (
isPlausibleLatLon(lat, lon) &&
lat >= 40.45 &&
lat <= 40.95 &&
lon >= -74.3 &&
lon <= -73.65
);
}
/** Mainland Norway, with slack for the coast and the Finnmark border. */
export function isLikelyNorwayCoordinate(lat, lon) {
return (
isPlausibleLatLon(lat, lon) &&
lat >= 57.9 &&
lat <= 71.3 &&
lon >= 4.4 &&
lon <= 31.3
);
}
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;
}
/**
* 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;
});
const kept = scored.slice(0, cap).map((entry) => entry.camera);
// How many cameras the pack offered before trimming, and where, for
// coverage reports.
Object.defineProperty(kept, 'available', { value: list.length });
Object.defineProperty(kept, 'region', { value: cameraRegion(list) });
return kept;
}
/** The bounding box of cameras with coordinates, or null. */
export function cameraRegion(cameras) {
let region = null;
for (const camera of cameras || []) {
const lat = Number(camera?.lat);
const lon = Number(camera?.lon);
if (!Number.isFinite(lat) || !Number.isFinite(lon)) continue;
region = region
? {
west: Math.min(region.west, lon),
south: Math.min(region.south, lat),
east: Math.max(region.east, lon),
north: Math.max(region.north, lat),
}
: { west: lon, south: lat, east: lon, north: lat };
}
return region;
}
/**
* 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,
};
}
/** City of Toronto bounding box (Steeles to the lake, Etobicoke to Scarborough). */
export function isLikelyTorontoCoordinate(lat, lon) {
return (
isPlausibleLatLon(lat, lon) &&
lat >= 43.55 &&
lat <= 43.9 &&
lon >= -79.7 &&
lon <= -79.1
);
}