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, '''); } /** * Canonicalize a CCTV feed type string to one of: * 'image', 'mjpeg', 'mp4', 'webm', 'hls', or pass-through. * * @param {string} value - Raw feed type (e.g. 'jpeg', 'mjpg', 'video', 'stream'). * @returns {string} Normalized feed type. */ export function normalizeFeedType(value) { const raw = String(value || '') .trim() .toLowerCase(); if (!raw) return 'image'; if (raw === 'jpeg' || raw === 'jpg' || raw === 'png') return 'image'; if (raw === 'mjpg') return 'mjpeg'; if (raw === 'video') return 'mp4'; if (raw === 'stream') return 'hls'; return raw; } /** * Check whether a normalized feed type represents streaming video. * * @param {string} feedType * @returns {boolean} */ export function isVideoFeedType(feedType) { return feedType === 'mp4' || feedType === 'webm' || feedType === 'hls'; } /** * 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; } /** * 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} 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} 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 ( !Number.isFinite(maxCount) || maxCount <= 0 || list.length <= maxCount || !anchorList.length ) { return list; } 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, maxCount).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 || ''), 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, }; }