mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
feat(cctv): add Houston TranStar camera pack
Register the keyless TranStar snapshot catalog as a live CCTV source, synthesize frame URLs from validated numeric camera ids, and keep the nearest cameras to Houston's metro cores under the existing catalog ceiling.
This commit is contained in:
1 parent
1df2a272cf
commit
052435473e
12 files changed
+613
-2
No files matched your search
@@ -25,6 +25,7 @@ import {
|
||||
loadVegvesenSourcesFromOpenData,
|
||||
loadNycSourcesFromOpenData,
|
||||
loadTorontoSourcesFromOpenData,
|
||||
loadHoustonSourcesFromOpenData,
|
||||
} from './sources.js';
|
||||
|
||||
/** Env kill switch: unset or anything but "0" means enabled. */
|
||||
@@ -144,6 +145,11 @@ const LIVE_PACKS = [
|
||||
enabled: () => envOptIn('CCTV_LITHUANIA_ENABLED'),
|
||||
load: loadLithuaniaSources,
|
||||
},
|
||||
{
|
||||
name: 'houston',
|
||||
enabled: () => envEnabled('CCTV_HOUSTON_ENABLED'),
|
||||
load: loadHoustonSourcesFromOpenData,
|
||||
},
|
||||
];
|
||||
/**
|
||||
* Load CCTV sources from a local JSON file (CCTV_SOURCES_FILE env or default).
|
||||
|
||||
@@ -347,6 +347,37 @@ export const NYC_ANCHORS = [
|
||||
{ lat: 40.5795, lon: -74.1502 }, // Staten Island
|
||||
];
|
||||
|
||||
/** Houston TranStar cameras: one keyless JSON list for the metro (~1,000
|
||||
* cameras with a numeric id). Frames are stills synthesized from that id on
|
||||
* the city's snapshot host — never taken from the payload. The catalog is
|
||||
* served as `application/javascript`; asking for `application/json` gets HTTP
|
||||
* 406, so the loader accepts any media type. */
|
||||
export const HOUSTON_CAMERAS_URL =
|
||||
'https://traffic.houstontranstar.org/data/layers/cctvSnapshots_json.js';
|
||||
/** The only origin Houston camera frames may come from. The catalog's `url`
|
||||
* is a `showWin(...)` viewer helper and is ignored; the registered path is
|
||||
* always this origin plus `/snapshots/cctv/<id>.jpg`. */
|
||||
export const HOUSTON_IMAGE_ORIGIN = 'https://www.houstontranstar.org/';
|
||||
export const HOUSTON_IMAGE_URL = (id) =>
|
||||
`${HOUSTON_IMAGE_ORIGIN}snapshots/cctv/${id}.jpg`;
|
||||
/** Numeric TranStar snapshot ids: 3–5 digits, no leading zero. Id `0` is a
|
||||
* different (non-snapshot) camera class in the same list. */
|
||||
export const HOUSTON_CAMERA_ID_RE = /^[1-9]\d{2,4}$/;
|
||||
export const DEFAULT_HOUSTON_MAX_SOURCES = 250;
|
||||
export const HOUSTON_MAX_CATALOG_BYTES = 4 * 1024 * 1024;
|
||||
/** Ground-elevation prior in metres. The TranStar payload carries none, and
|
||||
* Houston sits a few metres above sea level. */
|
||||
export const HOUSTON_GROUND_ELEVATION_M = 15;
|
||||
/** Downtown Houston (Main @ Texas): the primary prioritization anchor. */
|
||||
export const HOUSTON_DOWNTOWN = { lat: 29.7604, lon: -95.3698 };
|
||||
export const HOUSTON_ANCHORS = [
|
||||
{ lat: 29.7604, lon: -95.3698 }, // Downtown
|
||||
{ lat: 29.739, lon: -95.461 }, // Uptown / Galleria
|
||||
{ lat: 29.71, lon: -95.398 }, // Texas Medical Center
|
||||
{ lat: 29.9902, lon: -95.3368 }, // IAH
|
||||
{ lat: 29.3013, lon: -94.7977 }, // Galveston
|
||||
];
|
||||
|
||||
/** DelDOT CCTV: one keyless statewide JSON catalog; live video via RTMP-over-HTTP (rtmpt:80). */
|
||||
export const DELDOT_CCTV_URL = 'https://tmc.deldot.gov/json/videocamera.json';
|
||||
export const DEFAULT_DELDOT_MAX_SOURCES = 300;
|
||||
|
||||
@@ -388,6 +388,18 @@ export function isLikelyNycCoordinate(lat, lon) {
|
||||
);
|
||||
}
|
||||
|
||||
/** Greater Houston (the Woodlands to Galveston, Katy to Baytown). Farther
|
||||
* TranStar partners (Dallas, Columbus) are out of this city pack. */
|
||||
export function isLikelyHoustonCoordinate(lat, lon) {
|
||||
return (
|
||||
isPlausibleLatLon(lat, lon) &&
|
||||
lat >= 29.2 &&
|
||||
lat <= 30.35 &&
|
||||
lon >= -96.05 &&
|
||||
lon <= -94.7
|
||||
);
|
||||
}
|
||||
|
||||
/** Mainland Norway, with slack for the coast and the Finnmark border. */
|
||||
export function isLikelyNorwayCoordinate(lat, lon) {
|
||||
return (
|
||||
|
||||
@@ -96,6 +96,13 @@ import {
|
||||
NYC_MAX_CATALOG_BYTES,
|
||||
NYC_GROUND_ELEVATION_M,
|
||||
NYC_ANCHORS,
|
||||
HOUSTON_CAMERAS_URL,
|
||||
HOUSTON_IMAGE_URL,
|
||||
HOUSTON_CAMERA_ID_RE,
|
||||
DEFAULT_HOUSTON_MAX_SOURCES,
|
||||
HOUSTON_MAX_CATALOG_BYTES,
|
||||
HOUSTON_GROUND_ELEVATION_M,
|
||||
HOUSTON_ANCHORS,
|
||||
} from './constants.js';
|
||||
import {
|
||||
toFiniteNumber,
|
||||
@@ -116,6 +123,7 @@ import {
|
||||
isLikelyCataloniaCoordinate,
|
||||
decodeNumericEntities,
|
||||
isLikelyNycCoordinate,
|
||||
isLikelyHoustonCoordinate,
|
||||
isLikelyNorwayCoordinate,
|
||||
isLikelyTorontoCoordinate,
|
||||
cameraDisplayCode,
|
||||
@@ -2784,3 +2792,138 @@ export async function loadTorontoSourcesFromOpenData() {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Label for one Houston TranStar camera: the interchange an operator
|
||||
* recognises ("10 EAST @ SAN JACINTO").
|
||||
*
|
||||
* @param {object} record - Raw TranStar camera row.
|
||||
* @param {string} cameraId - Derived stable id.
|
||||
* @returns {string}
|
||||
*/
|
||||
export function houstonCameraName(record, cameraId) {
|
||||
const name = String(record?.location ?? '').trim();
|
||||
if (name) return name;
|
||||
const short = String(cameraId || '').replace(/^houston-/, '');
|
||||
return short ? `Houston Camera ${short}` : 'Houston Camera';
|
||||
}
|
||||
|
||||
/**
|
||||
* One Houston TranStar camera row -> one catalog source, or null.
|
||||
*
|
||||
* Frame URLs are synthesized from a validated numeric id on the pinned
|
||||
* snapshot origin. The payload's `url` is a `showWin(...)` viewer helper
|
||||
* and is ignored so a catalog edit cannot steer the frame proxy off-host.
|
||||
* Dedicated `dir` values (North/South/East/West) become high-confidence
|
||||
* headings; `None`/blank fall back to the shared id-hash heading.
|
||||
*
|
||||
* @param {object} record - Raw TranStar camera row.
|
||||
* @returns {?object}
|
||||
*/
|
||||
export function houstonCameraToSource(record) {
|
||||
if (!record || typeof record !== 'object') return null;
|
||||
|
||||
const lat = toFiniteNumber(record.lat);
|
||||
const lon = toFiniteNumber(record.lng);
|
||||
if (!isLikelyHoustonCoordinate(lat, lon)) return null;
|
||||
|
||||
const rawId = String(record.id ?? '').trim();
|
||||
if (!HOUSTON_CAMERA_ID_RE.test(rawId)) return null;
|
||||
const cameraId = `houston-${rawId}`;
|
||||
const imageUrl = HOUSTON_IMAGE_URL(rawId);
|
||||
const name = houstonCameraName(record, cameraId);
|
||||
const extractedHeading = directionToHeading(record.dir, true);
|
||||
const hasHeading = Number.isFinite(extractedHeading);
|
||||
|
||||
return {
|
||||
id: cameraId,
|
||||
name,
|
||||
city: 'Houston',
|
||||
cityId: 'houston',
|
||||
provider: 'Houston TranStar',
|
||||
lat,
|
||||
lon,
|
||||
headingDeg: hasHeading ? extractedHeading : fallbackHeadingFromId(cameraId),
|
||||
headingConfidence: hasHeading ? 'high' : 'low',
|
||||
pitchDeg: hasHeading ? -24 : -18,
|
||||
fovDeg: hasHeading ? 56 : 44,
|
||||
rangeM: hasHeading ? 210 : 145,
|
||||
mountHeightM: hasHeading ? 10 : 8,
|
||||
groundElevationM: HOUSTON_GROUND_ELEVATION_M,
|
||||
feedType: 'image',
|
||||
url: imageUrl,
|
||||
snapshotUrl: imageUrl,
|
||||
sourceKind: 'houston-transtar',
|
||||
license: 'Public Houston TranStar traffic camera',
|
||||
code: cameraDisplayCode(name.toUpperCase()),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch Houston TranStar cameras from the keyless `cctvSnapshots_json.js`
|
||||
* catalog. Frames are stills on `www.houstontranstar.org`, registered from
|
||||
* a validated numeric id rather than from the payload URL. The catalog host
|
||||
* answers `Accept: application/json` with HTTP 406, so this request accepts
|
||||
* any media type.
|
||||
*
|
||||
* @returns {Promise<Array<object>>} Normalized camera source objects.
|
||||
*/
|
||||
export async function loadHoustonSourcesFromOpenData() {
|
||||
try {
|
||||
const endpoint =
|
||||
process.env.CCTV_HOUSTON_CAMERAS_URL || HOUSTON_CAMERAS_URL;
|
||||
const resp = await fetch(endpoint, {
|
||||
headers: { Accept: '*/*' },
|
||||
redirect: 'manual',
|
||||
signal: AbortSignal.timeout(CCTV_SOURCE_FETCH_TIMEOUT_MS),
|
||||
});
|
||||
const discard = async () => {
|
||||
try {
|
||||
await resp.body?.cancel();
|
||||
} catch {
|
||||
/* no-op */
|
||||
}
|
||||
return [];
|
||||
};
|
||||
if (resp.status >= 300 && resp.status < 400) {
|
||||
console.warn(
|
||||
'[CCTV] Houston catalog redirected; redirects are not followed',
|
||||
);
|
||||
return discard();
|
||||
}
|
||||
if (!resp.ok) {
|
||||
console.warn('[CCTV] Houston camera download failed:', resp.status);
|
||||
return discard();
|
||||
}
|
||||
const payload = await readResponseJsonCapped(
|
||||
resp,
|
||||
HOUSTON_MAX_CATALOG_BYTES,
|
||||
);
|
||||
const rows = Array.isArray(payload?.cameras) ? payload.cameras : [];
|
||||
const cameras = [];
|
||||
const seen = new Set();
|
||||
for (const record of rows) {
|
||||
const camera = houstonCameraToSource(record);
|
||||
if (!camera || seen.has(camera.id)) continue;
|
||||
seen.add(camera.id);
|
||||
cameras.push(camera);
|
||||
}
|
||||
const maxRaw = Number(
|
||||
process.env.CCTV_HOUSTON_MAX_SOURCES || DEFAULT_HOUSTON_MAX_SOURCES,
|
||||
);
|
||||
const maxCount = Number.isFinite(maxRaw)
|
||||
? Math.max(8, Math.min(400, Math.floor(maxRaw)))
|
||||
: DEFAULT_HOUSTON_MAX_SOURCES;
|
||||
const prioritized = prioritizeSources(cameras, maxCount, HOUSTON_ANCHORS);
|
||||
console.log(
|
||||
`[CCTV] Loaded Houston camera sources: ${cameras.length} (using nearest ${prioritized.length})`,
|
||||
);
|
||||
return prioritized;
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
'[CCTV] Houston camera download error:',
|
||||
error?.message || error,
|
||||
);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user