mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
422 lines
12 KiB
JavaScript
422 lines
12 KiB
JavaScript
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, ''');
|
|
}
|
|
|
|
/**
|
|
* 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<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 (
|
|
!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,
|
|
};
|
|
}
|