mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 19:04:02 +00:00
style: adopt formatting for CCTV and radio provider modules
This commit is contained in:
1 parent
79b4929b88
commit
c69416ff6b
13 files changed
+862
-364
No files matched your search
@@ -1,8 +1,16 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { DEFAULT_CCTV_SOURCE_FILE, DEFAULT_CCTV_MAX_SOURCES, CCTV_SOURCE_CACHE_MS } from './constants.js';
|
||||
import {
|
||||
DEFAULT_CCTV_SOURCE_FILE,
|
||||
DEFAULT_CCTV_MAX_SOURCES,
|
||||
CCTV_SOURCE_CACHE_MS,
|
||||
} from './constants.js';
|
||||
import { normalizeSourceItem } from './normalize.js';
|
||||
import { loadAustinSourcesFromOpenData, loadCaltransSourcesFromOpenData, loadTflSourcesFromOpenData } from './sources.js';
|
||||
import {
|
||||
loadAustinSourcesFromOpenData,
|
||||
loadCaltransSourcesFromOpenData,
|
||||
loadTflSourcesFromOpenData,
|
||||
} from './sources.js';
|
||||
/**
|
||||
* Load CCTV sources from a local JSON file (CCTV_SOURCES_FILE env or default).
|
||||
*
|
||||
@@ -19,7 +27,11 @@ function loadSourcesFromFile(sourceRoot) {
|
||||
const parsed = JSON.parse(raw);
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch (error) {
|
||||
console.warn('[CCTV] failed to read source file:', resolved, error?.message || error);
|
||||
console.warn(
|
||||
'[CCTV] failed to read source file:',
|
||||
resolved,
|
||||
error?.message || error,
|
||||
);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -42,99 +54,128 @@ function loadSourcesFromEnv() {
|
||||
|
||||
/** Create an independent catalog rooted in the consuming application. */
|
||||
export function createCctvCatalog({ sourceRoot = process.cwd() } = {}) {
|
||||
/** @type {Array<object>} Cached merged + normalized CCTV source list. */
|
||||
let _cctvSourceCache = [];
|
||||
/** @type {number} Epoch-ms when the source cache was last refreshed. */
|
||||
let _cctvSourceCacheAt = 0;
|
||||
/** @type {Promise<Array<object>>|null} In-flight refresh, shared by concurrent
|
||||
* callers so a post-TTL burst launches ONE refetch, not one per request. */
|
||||
let _cctvSourceInflight = null;
|
||||
/** @type {Array<object>} Cached merged + normalized CCTV source list. */
|
||||
let _cctvSourceCache = [];
|
||||
/** @type {number} Epoch-ms when the source cache was last refreshed. */
|
||||
let _cctvSourceCacheAt = 0;
|
||||
/** @type {Promise<Array<object>>|null} In-flight refresh, shared by concurrent
|
||||
* callers so a post-TTL burst launches ONE refetch, not one per request. */
|
||||
let _cctvSourceInflight = null;
|
||||
|
||||
/**
|
||||
* Assemble and cache the merged CCTV source list.
|
||||
*
|
||||
* Merges sources from three origins (Austin Open Data, local file,
|
||||
* env variable), deduplicates by ID, applies the global max cap, and
|
||||
* caches for CCTV_SOURCE_CACHE_MS.
|
||||
*
|
||||
* @returns {Promise<Array<object>>} Deduplicated, capped source list.
|
||||
*/
|
||||
async function getCctvSources() {
|
||||
const now = Date.now();
|
||||
if (_cctvSourceCache.length && now - _cctvSourceCacheAt <= CCTV_SOURCE_CACHE_MS) {
|
||||
/**
|
||||
* Assemble and cache the merged CCTV source list.
|
||||
*
|
||||
* Merges sources from three origins (Austin Open Data, local file,
|
||||
* env variable), deduplicates by ID, applies the global max cap, and
|
||||
* caches for CCTV_SOURCE_CACHE_MS.
|
||||
*
|
||||
* @returns {Promise<Array<object>>} Deduplicated, capped source list.
|
||||
*/
|
||||
async function getCctvSources() {
|
||||
const now = Date.now();
|
||||
if (
|
||||
_cctvSourceCache.length &&
|
||||
now - _cctvSourceCacheAt <= CCTV_SOURCE_CACHE_MS
|
||||
) {
|
||||
return _cctvSourceCache;
|
||||
}
|
||||
// Single-flight: a burst of requests arriving past the TTL shares ONE refresh
|
||||
// instead of each launching the full multi-provider refetch. The `.finally`
|
||||
// clears the ref so the next post-TTL cycle starts fresh.
|
||||
if (_cctvSourceInflight) return _cctvSourceInflight;
|
||||
_cctvSourceInflight = refreshCctvSources().finally(() => {
|
||||
_cctvSourceInflight = null;
|
||||
});
|
||||
return _cctvSourceInflight;
|
||||
}
|
||||
|
||||
/**
|
||||
* Assemble and cache the merged CCTV source list from file/env + live packs.
|
||||
* Always resolves (loaders self-catch to []); on a fully-empty refresh with a
|
||||
* good prior catalog it serves stale rather than blanking the CCTV layer.
|
||||
*
|
||||
* @returns {Promise<Array<object>>} Deduplicated, capped source list.
|
||||
*/
|
||||
async function refreshCctvSources() {
|
||||
const fromFile = loadSourcesFromFile(sourceRoot);
|
||||
const fromEnv = loadSourcesFromEnv();
|
||||
|
||||
const forceAustin =
|
||||
String(process.env.CCTV_FORCE_AUSTIN || '').trim() === '1';
|
||||
const preferAustin =
|
||||
String(process.env.CCTV_PREFER_AUSTIN || '1').trim() !== '0';
|
||||
// Live open-data packs (Austin + Caltrans + TfL) load unless a file/env pack
|
||||
// is configured and live packs aren't forced — same gate that governed the
|
||||
// Austin-only fetch, now governing all three. Each pack fails independently.
|
||||
const needsLiveSources =
|
||||
forceAustin || (fromFile.length + fromEnv.length === 0 && preferAustin);
|
||||
const tflEnabled =
|
||||
String(process.env.CCTV_TFL_ENABLED || '1').trim() !== '0';
|
||||
|
||||
let fromAustin = [];
|
||||
let fromCaltrans = [];
|
||||
let fromTfl = [];
|
||||
if (needsLiveSources) {
|
||||
const [austinResult, caltransResult, tflResult] =
|
||||
await Promise.allSettled([
|
||||
loadAustinSourcesFromOpenData(),
|
||||
loadCaltransSourcesFromOpenData(),
|
||||
tflEnabled ? loadTflSourcesFromOpenData() : Promise.resolve([]),
|
||||
]);
|
||||
fromAustin =
|
||||
austinResult.status === 'fulfilled' ? austinResult.value : [];
|
||||
fromCaltrans =
|
||||
caltransResult.status === 'fulfilled' ? caltransResult.value : [];
|
||||
fromTfl = tflResult.status === 'fulfilled' ? tflResult.value : [];
|
||||
}
|
||||
// Live sources first so file/env overrides win on duplicate IDs (Map last-write).
|
||||
const merged = [
|
||||
...fromAustin,
|
||||
...fromCaltrans,
|
||||
...fromTfl,
|
||||
...fromFile,
|
||||
...fromEnv,
|
||||
];
|
||||
|
||||
// Deduplicate by camera ID (last-write wins because of Map.set)
|
||||
const byId = new Map();
|
||||
for (const item of merged) {
|
||||
if (!item || typeof item !== 'object') continue;
|
||||
const normalized = normalizeSourceItem(item);
|
||||
if (!normalized.id) continue;
|
||||
byId.set(normalized.id, normalized);
|
||||
}
|
||||
|
||||
const mergedSources = Array.from(byId.values());
|
||||
const maxRaw = Number(
|
||||
process.env.CCTV_MAX_SOURCES || DEFAULT_CCTV_MAX_SOURCES,
|
||||
);
|
||||
const maxCount = Number.isFinite(maxRaw)
|
||||
? Math.max(8, Math.min(1200, Math.floor(maxRaw)))
|
||||
: DEFAULT_CCTV_MAX_SOURCES;
|
||||
if (mergedSources.length > maxCount) {
|
||||
console.warn(
|
||||
`[CCTV] source catalog ${mergedSources.length} exceeds cap ${maxCount}; keeping the first ${maxCount} (raise CCTV_MAX_SOURCES or lower a per-pack cap to change which).`,
|
||||
);
|
||||
}
|
||||
const capped =
|
||||
mergedSources.length > maxCount
|
||||
? mergedSources.slice(0, maxCount)
|
||||
: mergedSources;
|
||||
if (capped.length > 0 || _cctvSourceCache.length === 0) {
|
||||
_cctvSourceCache = capped;
|
||||
} else {
|
||||
// Every source came back empty (all live packs timed out / upstream outage)
|
||||
// but a good catalog is already cached — serve it stale rather than blanking
|
||||
// every CCTV route. Advancing the timestamp waits one TTL before retrying,
|
||||
// which (with single-flight) bounds load on a persistently-down upstream.
|
||||
console.warn(
|
||||
`[CCTV] source refresh returned empty; serving ${_cctvSourceCache.length} stale cameras`,
|
||||
);
|
||||
}
|
||||
_cctvSourceCacheAt = Date.now();
|
||||
return _cctvSourceCache;
|
||||
}
|
||||
// Single-flight: a burst of requests arriving past the TTL shares ONE refresh
|
||||
// instead of each launching the full multi-provider refetch. The `.finally`
|
||||
// clears the ref so the next post-TTL cycle starts fresh.
|
||||
if (_cctvSourceInflight) return _cctvSourceInflight;
|
||||
_cctvSourceInflight = refreshCctvSources().finally(() => { _cctvSourceInflight = null; });
|
||||
return _cctvSourceInflight;
|
||||
}
|
||||
|
||||
/**
|
||||
* Assemble and cache the merged CCTV source list from file/env + live packs.
|
||||
* Always resolves (loaders self-catch to []); on a fully-empty refresh with a
|
||||
* good prior catalog it serves stale rather than blanking the CCTV layer.
|
||||
*
|
||||
* @returns {Promise<Array<object>>} Deduplicated, capped source list.
|
||||
*/
|
||||
async function refreshCctvSources() {
|
||||
const fromFile = loadSourcesFromFile(sourceRoot);
|
||||
const fromEnv = loadSourcesFromEnv();
|
||||
|
||||
const forceAustin = String(process.env.CCTV_FORCE_AUSTIN || '').trim() === '1';
|
||||
const preferAustin = String(process.env.CCTV_PREFER_AUSTIN || '1').trim() !== '0';
|
||||
// Live open-data packs (Austin + Caltrans + TfL) load unless a file/env pack
|
||||
// is configured and live packs aren't forced — same gate that governed the
|
||||
// Austin-only fetch, now governing all three. Each pack fails independently.
|
||||
const needsLiveSources = forceAustin || ((fromFile.length + fromEnv.length) === 0 && preferAustin);
|
||||
const tflEnabled = String(process.env.CCTV_TFL_ENABLED || '1').trim() !== '0';
|
||||
|
||||
let fromAustin = [];
|
||||
let fromCaltrans = [];
|
||||
let fromTfl = [];
|
||||
if (needsLiveSources) {
|
||||
const [austinResult, caltransResult, tflResult] = await Promise.allSettled([
|
||||
loadAustinSourcesFromOpenData(),
|
||||
loadCaltransSourcesFromOpenData(),
|
||||
tflEnabled ? loadTflSourcesFromOpenData() : Promise.resolve([]),
|
||||
]);
|
||||
fromAustin = austinResult.status === 'fulfilled' ? austinResult.value : [];
|
||||
fromCaltrans = caltransResult.status === 'fulfilled' ? caltransResult.value : [];
|
||||
fromTfl = tflResult.status === 'fulfilled' ? tflResult.value : [];
|
||||
}
|
||||
// Live sources first so file/env overrides win on duplicate IDs (Map last-write).
|
||||
const merged = [...fromAustin, ...fromCaltrans, ...fromTfl, ...fromFile, ...fromEnv];
|
||||
|
||||
// Deduplicate by camera ID (last-write wins because of Map.set)
|
||||
const byId = new Map();
|
||||
for (const item of merged) {
|
||||
if (!item || typeof item !== 'object') continue;
|
||||
const normalized = normalizeSourceItem(item);
|
||||
if (!normalized.id) continue;
|
||||
byId.set(normalized.id, normalized);
|
||||
}
|
||||
|
||||
const mergedSources = Array.from(byId.values());
|
||||
const maxRaw = Number(process.env.CCTV_MAX_SOURCES || DEFAULT_CCTV_MAX_SOURCES);
|
||||
const maxCount = Number.isFinite(maxRaw) ? Math.max(8, Math.min(1200, Math.floor(maxRaw))) : DEFAULT_CCTV_MAX_SOURCES;
|
||||
if (mergedSources.length > maxCount) {
|
||||
console.warn(`[CCTV] source catalog ${mergedSources.length} exceeds cap ${maxCount}; keeping the first ${maxCount} (raise CCTV_MAX_SOURCES or lower a per-pack cap to change which).`);
|
||||
}
|
||||
const capped = mergedSources.length > maxCount ? mergedSources.slice(0, maxCount) : mergedSources;
|
||||
if (capped.length > 0 || _cctvSourceCache.length === 0) {
|
||||
_cctvSourceCache = capped;
|
||||
} else {
|
||||
// Every source came back empty (all live packs timed out / upstream outage)
|
||||
// but a good catalog is already cached — serve it stale rather than blanking
|
||||
// every CCTV route. Advancing the timestamp waits one TTL before retrying,
|
||||
// which (with single-flight) bounds load on a persistently-down upstream.
|
||||
console.warn(`[CCTV] source refresh returned empty; serving ${_cctvSourceCache.length} stale cameras`);
|
||||
}
|
||||
_cctvSourceCacheAt = Date.now();
|
||||
return _cctvSourceCache;
|
||||
}
|
||||
|
||||
return getCctvSources;
|
||||
return getCctvSources;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
export const DEFAULT_CCTV_SOURCE_FILE = 'config/cctv_sources.austin.json';
|
||||
/** Austin Open Data portal endpoint for traffic camera records. */
|
||||
export const DEFAULT_AUSTIN_ROWS_URL = 'https://data.austintexas.gov/api/views/b4k4-adkb/rows.json?accessType=DOWNLOAD';
|
||||
export const DEFAULT_AUSTIN_ROWS_URL =
|
||||
'https://data.austintexas.gov/api/views/b4k4-adkb/rows.json?accessType=DOWNLOAD';
|
||||
/** Default cap on Austin cameras after distance-based prioritization. */
|
||||
export const DEFAULT_AUSTIN_MAX_SOURCES = 250;
|
||||
/** Global cap on total CCTV sources served by the proxy. */
|
||||
@@ -22,7 +23,8 @@ export const CALTRANS_ANCHORS = [
|
||||
];
|
||||
/** TfL JamCams: one keyless list endpoint; frames live on a public S3 bucket. */
|
||||
export const TFL_JAMCAM_URL = 'https://api.tfl.gov.uk/Place/Type/JamCam';
|
||||
export const TFL_IMAGE_ORIGIN = 'https://s3-eu-west-1.amazonaws.com/jamcams.tfl.gov.uk/';
|
||||
export const TFL_IMAGE_ORIGIN =
|
||||
'https://s3-eu-west-1.amazonaws.com/jamcams.tfl.gov.uk/';
|
||||
export const DEFAULT_TFL_MAX_SOURCES = 250;
|
||||
export const LONDON_CENTER = { lat: 51.5074, lon: -0.1278 };
|
||||
/** Camera CATALOGS change rarely; 15 min keeps multi-megabyte upstream list refetches (Austin rows.json + 4 Caltrans districts + TfL) infrequent. Frames are fetched per-request and are unaffected. */
|
||||
|
||||
@@ -97,8 +97,13 @@ export function toReadable(body) {
|
||||
* @param {object} [opts]
|
||||
* @param {string} [opts.sourceHeader='upstream'] - Value for X-CCTV-Source header.
|
||||
*/
|
||||
export async function proxyMediaResponse(res, upstream, { sourceHeader = 'upstream' } = {}) {
|
||||
const contentType = upstream.headers.get('content-type') || 'application/octet-stream';
|
||||
export async function proxyMediaResponse(
|
||||
res,
|
||||
upstream,
|
||||
{ sourceHeader = 'upstream' } = {},
|
||||
) {
|
||||
const contentType =
|
||||
upstream.headers.get('content-type') || 'application/octet-stream';
|
||||
const cacheControl = upstream.headers.get('cache-control') || 'no-store';
|
||||
const contentLength = upstream.headers.get('content-length');
|
||||
const contentRange = upstream.headers.get('content-range');
|
||||
@@ -116,10 +121,20 @@ export async function proxyMediaResponse(res, upstream, { sourceHeader = 'upstre
|
||||
// Live MJPEG/HLS streams are unbounded by design and send no content-length,
|
||||
// so they pipe normally (piping streams to the client, never buffering).
|
||||
const MEDIA_DECLARED_CAP_BYTES = 64 * 1024 * 1024;
|
||||
if (Number.isFinite(Number(contentLength)) && Number(contentLength) > MEDIA_DECLARED_CAP_BYTES) {
|
||||
res.writeHead(502, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
|
||||
if (
|
||||
Number.isFinite(Number(contentLength)) &&
|
||||
Number(contentLength) > MEDIA_DECLARED_CAP_BYTES
|
||||
) {
|
||||
res.writeHead(502, {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'no-store',
|
||||
});
|
||||
res.end(JSON.stringify({ error: 'Upstream media exceeds size cap' }));
|
||||
try { await upstream.body?.cancel(); } catch { /* no-op */ }
|
||||
try {
|
||||
await upstream.body?.cancel();
|
||||
} catch {
|
||||
/* no-op */
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -151,14 +166,16 @@ export async function proxyMediaResponse(res, upstream, { sourceHeader = 'upstre
|
||||
* @param {number} [options.timeoutMs=CCTV_FRAME_FETCH_TIMEOUT_MS] - Abort timeout.
|
||||
* @returns {Promise<{ok:true,body:Buffer,contentType:string}|null>}
|
||||
*/
|
||||
export async function fetchCctvImageFromUpstream(url, {
|
||||
fetchImpl = fetch,
|
||||
timeoutMs = CCTV_FRAME_FETCH_TIMEOUT_MS,
|
||||
} = {}) {
|
||||
export async function fetchCctvImageFromUpstream(
|
||||
url,
|
||||
{ fetchImpl = fetch, timeoutMs = CCTV_FRAME_FETCH_TIMEOUT_MS } = {},
|
||||
) {
|
||||
if (!url || !/^https?:\/\//i.test(url)) return null;
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => {
|
||||
controller.abort(new DOMException('CCTV upstream frame fetch timed out', 'TimeoutError'));
|
||||
controller.abort(
|
||||
new DOMException('CCTV upstream frame fetch timed out', 'TimeoutError'),
|
||||
);
|
||||
}, timeoutMs);
|
||||
try {
|
||||
const upstream = await fetchImpl(url, {
|
||||
|
||||
@@ -39,7 +39,9 @@ export function escapeXml(text) {
|
||||
* @returns {string} Normalized feed type.
|
||||
*/
|
||||
export function normalizeFeedType(value) {
|
||||
const raw = String(value || '').trim().toLowerCase();
|
||||
const raw = String(value || '')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
if (!raw) return 'image';
|
||||
if (raw === 'jpeg' || raw === 'jpg' || raw === 'png') return 'image';
|
||||
if (raw === 'mjpg') return 'mjpeg';
|
||||
@@ -93,7 +95,9 @@ export function normalizeKey(text) {
|
||||
* @returns {{lat:number, lon:number}}
|
||||
*/
|
||||
export function parsePointString(value) {
|
||||
const match = String(value || '').match(/POINT\s*\(\s*(-?\d+(?:\.\d+)?)\s+(-?\d+(?:\.\d+)?)\s*\)/i);
|
||||
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]),
|
||||
@@ -123,11 +127,16 @@ export function coerceLatLon(value) {
|
||||
|
||||
const lat = toFiniteNumber(
|
||||
value.latitude ?? value.lat ?? value.y ?? value.Latitude ?? value.Lat,
|
||||
NaN
|
||||
NaN,
|
||||
);
|
||||
const lon = toFiniteNumber(
|
||||
value.longitude ?? value.lon ?? value.lng ?? value.x ?? value.Longitude ?? value.Lon,
|
||||
NaN
|
||||
value.longitude ??
|
||||
value.lon ??
|
||||
value.lng ??
|
||||
value.x ??
|
||||
value.Longitude ??
|
||||
value.Lon,
|
||||
NaN,
|
||||
);
|
||||
return { lat, lon };
|
||||
}
|
||||
@@ -152,16 +161,24 @@ export function extractAustinCoords(record) {
|
||||
];
|
||||
for (const candidate of candidates) {
|
||||
const parsed = coerceLatLon(candidate);
|
||||
if (Number.isFinite(parsed.lat) && Number.isFinite(parsed.lon)) return parsed;
|
||||
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
|
||||
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
|
||||
record.longitude ??
|
||||
record.lon ??
|
||||
record.lng ??
|
||||
record.camera_longitude ??
|
||||
record.location_longitude,
|
||||
NaN,
|
||||
);
|
||||
return { lat, lon };
|
||||
}
|
||||
@@ -239,11 +256,19 @@ export function extractAustinName(record, cameraId) {
|
||||
* @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);
|
||||
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'];
|
||||
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;
|
||||
@@ -260,7 +285,9 @@ export function extractAustinHeading(record) {
|
||||
record.cross_street,
|
||||
record.description,
|
||||
record.name,
|
||||
].filter(Boolean).join(' ');
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
const inferred = directionToHeading(nameProbe);
|
||||
if (Number.isFinite(inferred)) return inferred;
|
||||
|
||||
@@ -276,7 +303,7 @@ export function extractAustinHeading(record) {
|
||||
*/
|
||||
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.40;
|
||||
return lat >= 30.02 && lat <= 30.58 && lon >= -98.12 && lon <= -97.4;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -324,18 +351,26 @@ export function rowArrayToObject(row, columns) {
|
||||
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)
|
||||
(a) => Number.isFinite(a?.lat) && Number.isFinite(a?.lon),
|
||||
);
|
||||
if (!Number.isFinite(maxCount) || maxCount <= 0 || list.length <= maxCount || !anchorList.length) {
|
||||
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;
|
||||
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 };
|
||||
});
|
||||
|
||||
@@ -363,7 +398,9 @@ export function normalizeSourceItem(item) {
|
||||
lat: toFiniteNumber(item.lat),
|
||||
lon: toFiniteNumber(item.lon),
|
||||
headingDeg: toFiniteNumber(item.headingDeg),
|
||||
headingConfidence: String(item.headingConfidence || item.headingSource || '').toLowerCase(),
|
||||
headingConfidence: String(
|
||||
item.headingConfidence || item.headingSource || '',
|
||||
).toLowerCase(),
|
||||
pitchDeg: toFiniteNumber(item.pitchDeg),
|
||||
fovDeg: toFiniteNumber(item.fovDeg),
|
||||
rangeM: toFiniteNumber(item.rangeM),
|
||||
|
||||
@@ -1,5 +1,28 @@
|
||||
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, CCTV_SOURCE_FETCH_TIMEOUT_MS } from './constants.js';
|
||||
import { toFiniteNumber, extractAustinCoords, extractAustinCameraId, extractAustinName, extractAustinHeading, isLikelyAustinCoordinate, fallbackHeadingFromId, rowArrayToObject, prioritizeSources } from './normalize.js';
|
||||
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,
|
||||
CCTV_SOURCE_FETCH_TIMEOUT_MS,
|
||||
} from './constants.js';
|
||||
import {
|
||||
toFiniteNumber,
|
||||
extractAustinCoords,
|
||||
extractAustinCameraId,
|
||||
extractAustinName,
|
||||
extractAustinHeading,
|
||||
isLikelyAustinCoordinate,
|
||||
fallbackHeadingFromId,
|
||||
rowArrayToObject,
|
||||
prioritizeSources,
|
||||
} from './normalize.js';
|
||||
import { directionToHeading } from '../../../src/data/directionText.js';
|
||||
/**
|
||||
* Fetch and parse Austin traffic camera records from the city Open Data portal.
|
||||
@@ -14,13 +37,18 @@ import { directionToHeading } from '../../../src/data/directionText.js';
|
||||
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) });
|
||||
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 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 [];
|
||||
|
||||
@@ -35,7 +63,9 @@ export async function loadAustinSourcesFromOpenData() {
|
||||
// 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();
|
||||
const status = String(record.camera_status || '')
|
||||
.trim()
|
||||
.toUpperCase();
|
||||
if (status && status !== 'TURNED_ON') continue;
|
||||
|
||||
const { lat, lon } = extractAustinCoords(record);
|
||||
@@ -44,7 +74,9 @@ export async function loadAustinSourcesFromOpenData() {
|
||||
|
||||
const extractedHeading = extractAustinHeading(record);
|
||||
const hasHeading = Number.isFinite(extractedHeading);
|
||||
const headingDeg = hasHeading ? extractedHeading : fallbackHeadingFromId(cameraId);
|
||||
const headingDeg = hasHeading
|
||||
? extractedHeading
|
||||
: fallbackHeadingFromId(cameraId);
|
||||
cameras.push({
|
||||
id: cameraId,
|
||||
name: extractAustinName(record, cameraId),
|
||||
@@ -68,18 +100,29 @@ export async function loadAustinSourcesFromOpenData() {
|
||||
});
|
||||
}
|
||||
|
||||
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 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})`);
|
||||
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);
|
||||
console.warn(
|
||||
'[CCTV] Austin source download error:',
|
||||
error?.message || error,
|
||||
);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -96,7 +139,8 @@ export async function loadAustinSourcesFromOpenData() {
|
||||
* @returns {Promise<Array<object>>} Normalized camera source objects.
|
||||
*/
|
||||
export async function loadCaltransSourcesFromOpenData() {
|
||||
const districtsRaw = process.env.CCTV_CALTRANS_DISTRICTS ?? DEFAULT_CALTRANS_DISTRICTS;
|
||||
const districtsRaw =
|
||||
process.env.CCTV_CALTRANS_DISTRICTS ?? DEFAULT_CALTRANS_DISTRICTS;
|
||||
const districts = String(districtsRaw)
|
||||
.split(',')
|
||||
.map((token) => Number(token.trim()))
|
||||
@@ -105,18 +149,24 @@ export async function loadCaltransSourcesFromOpenData() {
|
||||
|
||||
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) });
|
||||
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);
|
||||
console.warn(
|
||||
'[CCTV] Caltrans district fetch failed:',
|
||||
result.reason?.message || result.reason,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const { district, rows } = result.value;
|
||||
@@ -135,13 +185,17 @@ export async function loadCaltransSourcesFromOpenData() {
|
||||
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 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}`;
|
||||
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,
|
||||
@@ -168,7 +222,9 @@ export async function loadCaltransSourcesFromOpenData() {
|
||||
// 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;
|
||||
return Number.isFinite(ft)
|
||||
? Math.max(-100, Math.min(4000, ft * 0.3048))
|
||||
: 150;
|
||||
})(),
|
||||
feedType: 'image',
|
||||
url: imageUrl,
|
||||
@@ -179,10 +235,16 @@ export async function loadCaltransSourcesFromOpenData() {
|
||||
}
|
||||
}
|
||||
|
||||
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 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})`);
|
||||
console.log(
|
||||
`[CCTV] Loaded Caltrans camera sources: ${cameras.length} inService (using nearest ${prioritized.length})`,
|
||||
);
|
||||
return prioritized;
|
||||
}
|
||||
|
||||
@@ -199,8 +261,13 @@ export async function loadCaltransSourcesFromOpenData() {
|
||||
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) });
|
||||
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 [];
|
||||
@@ -251,10 +318,16 @@ export async function loadTflSourcesFromOpenData() {
|
||||
});
|
||||
}
|
||||
|
||||
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 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})`);
|
||||
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);
|
||||
|
||||
Reference in new issue
Block a user