feat(cctv): seven camera packs, semantic labels, terrain-clear monitor planes (#433)

Seven contributed CCTV camera packs (Ontario 511, Fintraffic Finland, DriveBC, TxDOT Texas, Estonia Tallinn + Tarktee, Warendorf, Live Traffic NSW) on a fair per-pack catalog cap; semantic camera labels; monitor planes lifted clear of the terrain from a precomputed, bundled ground-height dataset. Default catalog 800 → 3,446 cameras. Full context in PR #433.

Co-authored-by: sendmebits <[email protected]>
Co-authored-by: avocadoAgent <[email protected]>
Co-authored-by: Alex Harvey <[email protected]>
Co-authored-by: Jason Meredith <[email protected]>
Co-authored-by: Daniel Smith <[email protected]>
Co-authored-by: Vladimir Sobolev <[email protected]>
Co-authored-by: Matthias Schneider <[email protected]>
Co-authored-by: Cyber Knight <[email protected]>
Co-authored-by: NovaSoft Australia <[email protected]>
This commit is contained in:
51 files changed
+78682 -939

No files matched your search

+15 -6
View File
@@ -8,9 +8,13 @@ import {
buildSyntheticCctvSvg,
proxyMediaResponse,
fetchCctvImageFromUpstream,
fetchTxdotSnapshot,
fetchCctvMediaUpstream,
} from './cctv/media.js';
import { CCTV_FRAME_FETCH_TIMEOUT_MS } from './cctv/constants.js';
import {
CCTV_FRAME_FETCH_TIMEOUT_MS,
CCTV_MAX_SOURCES_CEILING,
} from './cctv/constants.js';
import { googleServerApiKey } from './places/google-key.js';
export { CCTV_FRAME_FETCH_TIMEOUT_MS, fetchCctvImageFromUpstream };
/**
@@ -30,10 +34,10 @@ export function cctvProxy({ sourceRoot = process.cwd() } = {}) {
const getCctvSources = createCctvCatalog({ sourceRoot });
/** @type {Map<string,{id:string,status:string,sourceKind:string,label:string,message:string,updatedAt:number}>} */
const health = new Map();
/** Cap on health map entries to prevent unbounded growth. Sized to cover the
* full served catalog (CCTV_MAX_SOURCES hard-bounds at 1200) so health/status
* observability isn't silently evicted for a default 800-camera catalog. */
const HEALTH_MAX_ENTRIES = 1200;
/** Cap on health map entries to prevent unbounded growth. Sized to the
* CCTV_MAX_SOURCES ceiling so health/status observability is never evicted
* for any catalog the proxy can actually serve. */
const HEALTH_MAX_ENTRIES = CCTV_MAX_SOURCES_CEILING;
/** Update the health entry for a camera, evicting the oldest entry if at capacity. */
const setHealth = (cameraId, patch) => {
@@ -151,6 +155,9 @@ export function cctvProxy({ sourceRoot = process.cwd() } = {}) {
source.sourceKind || (source.url ? 'configured' : 'fallback'),
poseSource: source.poseSource,
license: source.license,
credit: source.credit || '',
code: source.code || '',
groundHeights: source.groundHeights || null,
})),
};
res.writeHead(200, {
@@ -328,7 +335,9 @@ export function cctvProxy({ sourceRoot = process.cwd() } = {}) {
: '');
const upstreamImage =
await fetchCctvImageFromUpstream(upstreamCandidate);
source?.sourceKind === 'txdot-its'
? await fetchTxdotSnapshot(upstreamCandidate)
: await fetchCctvImageFromUpstream(upstreamCandidate);
if (upstreamImage?.ok) {
setHealth(cameraId, {
status: 'ok',
+82
View File
@@ -0,0 +1,82 @@
import {
CCTV_MAX_SOURCES_CEILING,
DEFAULT_CCTV_MAX_SOURCES,
} from './constants.js';
/**
* Resolve the catalog-wide source cap from an env value.
*
* @param {string|number|undefined} raw - CCTV_MAX_SOURCES as configured.
* @returns {number} Cap bounded to [8, CCTV_MAX_SOURCES_CEILING].
*/
export function resolveCatalogCap(raw) {
const parsed = Number(raw || DEFAULT_CCTV_MAX_SOURCES);
if (!Number.isFinite(parsed)) return DEFAULT_CCTV_MAX_SOURCES;
return Math.max(8, Math.min(CCTV_MAX_SOURCES_CEILING, Math.floor(parsed)));
}
/**
* Merge source packs under one cap without starving any of them.
*
* Every pack arrives already ordered by its own priority (nearest-to-anchor
* first) and already trimmed to its own per-pack cap. When the packs together
* exceed the catalog cap, the cap is filled ROUND-ROBIN — one camera from
* each pack per turn, in each pack's own order — so a lowered
* CCTV_MAX_SOURCES thins every region a little instead of deleting whichever
* pack happened to be appended last. Duplicate ids resolve last-pack-wins so
* file/env overrides keep replacing live records; the record is then counted
* against the pack that won it.
*
* @param {Array<{name:string, sources:Array<object>}>} packs - Normalized
* packs in merge order (earlier packs lose duplicate ids to later ones).
* @param {number} maxCount - Catalog cap.
* @returns {{sources:Array<object>, packs:Array<{name:string, offered:number, kept:number}>}}
*/
export function allocateSourceCap(packs, maxCount) {
const owner = new Map();
for (const pack of packs) {
for (const source of pack.sources) {
if (!source?.id) continue;
owner.set(source.id, { pack: pack.name, source });
}
}
const lanes = packs.map((pack) => ({
name: pack.name,
// Only the record that WON its id (the last occurrence anywhere, so also
// the last within this pack) takes a lane slot; earlier duplicates never
// count against the cap.
queue: pack.sources.filter(
(source) => source?.id && owner.get(source.id)?.source === source,
),
next: 0,
kept: 0,
}));
const total = lanes.reduce((sum, lane) => sum + lane.queue.length, 0);
const limit = Math.min(total, Math.max(0, Math.floor(maxCount)));
const sources = [];
if (limit >= total) {
for (const lane of lanes) {
sources.push(...lane.queue);
lane.kept = lane.queue.length;
}
} else {
while (sources.length < limit) {
for (const lane of lanes) {
if (sources.length >= limit) break;
if (lane.next >= lane.queue.length) continue;
sources.push(lane.queue[lane.next]);
lane.next += 1;
lane.kept += 1;
}
}
}
return {
sources,
packs: lanes.map((lane) => ({
name: lane.name,
offered: lane.queue.length,
kept: lane.kept,
})),
};
}
+127 -59
View File
@@ -1,16 +1,86 @@
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, CCTV_SOURCE_CACHE_MS } from './constants.js';
import { allocateSourceCap, resolveCatalogCap } from './cap.js';
import { loadGroundHeights, joinGroundHeights } from './groundHeights.js';
import { normalizeSourceItem } from './normalize.js';
import {
loadAustinSourcesFromOpenData,
loadCaltransSourcesFromOpenData,
loadTflSourcesFromOpenData,
loadOntarioSourcesFromOpenData,
loadFintrafficSourcesFromOpenData,
loadDriveBcSourcesFromOpenData,
loadTxdotSourcesFromOpenData,
loadTallinnSourcesFromCatalog,
loadTarkteeSourcesFromDatex,
loadWarendorfSourcesFromCatalog,
loadNswSourcesFromOpenData,
} from './sources.js';
/** Env kill switch: unset or anything but "0" means enabled. */
const envEnabled = (name) => String(process.env[name] || '1').trim() !== '0';
/**
* Live open-data packs, in merge order. Adding a region is one entry here
* plus its loader in sources.js; the catalog cap is shared across entries
* round-robin (cap.js), so a new pack never silently evicts an older one.
* Each pack fails independently (allSettled) and is gated by its own env
* kill switch.
*/
const LIVE_PACKS = [
{ name: 'austin', enabled: () => true, load: loadAustinSourcesFromOpenData },
{
name: 'caltrans',
enabled: () => true,
load: loadCaltransSourcesFromOpenData,
},
{
name: 'tfl',
enabled: () => envEnabled('CCTV_TFL_ENABLED'),
load: loadTflSourcesFromOpenData,
},
{
name: 'ontario',
enabled: () => envEnabled('CCTV_ONTARIO_ENABLED'),
load: loadOntarioSourcesFromOpenData,
},
{
name: 'fintraffic',
enabled: () => envEnabled('CCTV_FINTRAFFIC_ENABLED'),
load: loadFintrafficSourcesFromOpenData,
},
{
name: 'drivebc',
enabled: () => envEnabled('CCTV_DRIVEBC_ENABLED'),
load: loadDriveBcSourcesFromOpenData,
},
{
name: 'txdot',
enabled: () => envEnabled('CCTV_TXDOT_ENABLED'),
load: loadTxdotSourcesFromOpenData,
},
{
name: 'tallinn',
enabled: () => envEnabled('CCTV_TALLINN_ENABLED'),
load: loadTallinnSourcesFromCatalog,
},
{
name: 'tarktee',
enabled: () => envEnabled('CCTV_TARKTEE_ENABLED'),
load: loadTarkteeSourcesFromDatex,
},
{
name: 'warendorf',
enabled: () => envEnabled('CCTV_WARENDORF_ENABLED'),
load: loadWarendorfSourcesFromCatalog,
},
{
name: 'nsw',
enabled: () => envEnabled('CCTV_NSW_ENABLED'),
load: loadNswSourcesFromOpenData,
},
];
/**
* Load CCTV sources from a local JSON file (CCTV_SOURCES_FILE env or default).
*
@@ -65,9 +135,9 @@ export function createCctvCatalog({ sourceRoot = process.cwd() } = {}) {
/**
* 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.
* Merges every source pack (live open-data packs, local file, env
* variable), deduplicates by ID, shares the catalog cap fairly across
* packs, and caches for CCTV_SOURCE_CACHE_MS.
*
* @returns {Promise<Array<object>>} Deduplicated, capped source list.
*/
@@ -104,64 +174,62 @@ export function createCctvCatalog({ sourceRoot = process.cwd() } = {}) {
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.
// Live open-data packs load unless a file/env pack is configured and live
// packs aren't forced — the same gate that governed the Austin-only fetch
// now governs every entry in LIVE_PACKS.
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,
const liveResults = needsLiveSources
? await Promise.allSettled(
// Invoked inside the promise so a loader that throws synchronously
// (a file-based pack on a malformed row) is isolated like any other
// failed pack instead of rejecting the whole refresh.
LIVE_PACKS.map((pack) =>
Promise.resolve().then(() =>
pack.enabled() ? pack.load({ sourceRoot }) : [],
),
),
)
: [];
// Live packs first so file/env overrides win on duplicate IDs; each pack
// keeps its own priority order and the catalog cap is shared fairly.
const normalizePack = (name, items) => ({
name,
sources: items
.filter((item) => item && typeof item === 'object')
.map((item) => normalizeSourceItem(item))
.filter((item) => item.id),
});
const packs = [
...LIVE_PACKS.map((pack, index) =>
normalizePack(
pack.name,
liveResults[index]?.status === 'fulfilled'
? liveResults[index].value
: [],
),
),
normalizePack('file', fromFile),
normalizePack('env', 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 = resolveCatalogCap(process.env.CCTV_MAX_SOURCES);
const allocation = allocateSourceCap(packs, maxCount);
// Shipped ground heights (src/data/local_data/cctv_ground_heights/, produced by
// scripts/precompute-cctv-heights.mjs) ride along on the served source so
// the client can place a camera and its monitor plane with zero sampling.
const capped = joinGroundHeights(
allocation.sources,
loadGroundHeights(sourceRoot),
);
const maxCount = Number.isFinite(maxRaw)
? Math.max(8, Math.min(1200, Math.floor(maxRaw)))
: DEFAULT_CCTV_MAX_SOURCES;
if (mergedSources.length > maxCount) {
const trimmed = allocation.packs.filter((pack) => pack.kept < pack.offered);
if (trimmed.length) {
const detail = trimmed
.map((pack) => `${pack.name} ${pack.kept}/${pack.offered}`)
.join(', ');
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).`,
`[CCTV] source catalog exceeds cap ${maxCount}; shared round-robin across packs (${detail}). Raise CCTV_MAX_SOURCES or lower a per-pack cap to change the mix.`,
);
}
const capped =
mergedSources.length > maxCount
? mergedSources.slice(0, maxCount)
: mergedSources;
if (capped.length > 0 || _cctvSourceCache.length === 0) {
_cctvSourceCache = capped;
} else {
+196 -3
View File
@@ -4,11 +4,103 @@ 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. */
export const DEFAULT_CCTV_MAX_SOURCES = 900;
/**
* Catalog-wide safety ceiling on served cameras. Each pack already caps
* itself (nearest-to-anchor first); this bound only matters when the packs
* together exceed it, and it is then filled round-robin across packs (see
* cap.js) so no region is silently dropped. Sized above the sum of the
* default per-pack caps so a default install never trims.
*/
export const DEFAULT_CCTV_MAX_SOURCES = 4000;
/** Hard upper bound for CCTV_MAX_SOURCES; also sizes the health map. */
export const CCTV_MAX_SOURCES_CEILING = 5000;
/** Reference point for Austin camera prioritization (Congress & 6th). */
export const AUSTIN_DOWNTOWN = { lat: 30.2672, lon: -97.7431 };
/** Caltrans CCTV: one JSON feed per district, identical schema statewide. */
/** TxDOT ITS: one keyless JSON catalog per district (25 districts statewide). */
export const TXDOT_ORIGIN = 'https://its.txdot.gov';
export const TXDOT_CCTV_STATUS_URL = (district) =>
`${TXDOT_ORIGIN}/its/DistrictIts/GetCctvStatusListByDistrict?districtCode=${encodeURIComponent(district)}`;
/** Per-camera frame. Returns JSON `{snippet:<base64 jpeg>}`, not an image body;
* media.js decodes it (fetchTxdotSnapshot) and only for this origin. */
export const TXDOT_CCTV_SNAPSHOT_URL = `${TXDOT_ORIGIN}/its/DistrictIts/GetCctvSnapshotByIcdId`;
/** Valid TxDOT district codes (the ITS map's own districtCodes list). */
export const TXDOT_DISTRICTS = new Set([
'ABL',
'AMA',
'ATL',
'AUS',
'BMT',
'BWD',
'BRY',
'CHS',
'CRP',
'DAL',
'ELP',
'FTW',
'HOU',
'LRD',
'LBB',
'LFK',
'ODA',
'PAR',
'PHR',
'SJT',
'SAT',
'TYL',
'WAC',
'WFS',
'YKM',
]);
/** Districts fetched by default: Austin (the reference camera city) and San
* Antonio. A statewide default was rejected: per-pack prioritization is
* nearest-to-any-anchor, so Dallas's dense core would take most slots.
* CCTV_TXDOT_DISTRICTS opens up the rest ("AUS,SAT,HOU,DAL,FTW" for the five
* big metros, or any of the 25 codes). */
export const DEFAULT_TXDOT_DISTRICTS = 'AUS,SAT';
export const DEFAULT_TXDOT_MAX_SOURCES = 500;
/** Prioritization anchors: downtown cores of the metro districts a user can
* select. Cameras rank by distance to the NEAREST anchor, so a widened
* CCTV_TXDOT_DISTRICTS still ranks sensibly instead of against Austin alone. */
export const TXDOT_ANCHORS = [
{ lat: 30.2672, lon: -97.7431 }, // Austin
{ lat: 29.4241, lon: -98.4936 }, // San Antonio
{ lat: 29.7604, lon: -95.3698 }, // Houston
{ lat: 32.7767, lon: -96.797 }, // Dallas
{ lat: 32.7555, lon: -97.3308 }, // Fort Worth
];
/** Ground-elevation priors in metres, by district. The TxDOT payload carries
* no elevation, and on a keyless (no-tileset) stack the client's ground snap
* never fires, so this prior is the only height a camera gets there. Texas
* spans sea level (Houston) to ~1,140 m (El Paso). */
export const TXDOT_DISTRICT_ELEVATION_M = Object.freeze({
ABL: 520,
AMA: 1099,
ATL: 105,
AUS: 149,
BMT: 5,
BWD: 425,
BRY: 111,
CHS: 250,
CRP: 7,
DAL: 131,
ELP: 1140,
FTW: 199,
HOU: 15,
LRD: 132,
LBB: 992,
LFK: 91,
ODA: 890,
PAR: 185,
PHR: 30,
SJT: 585,
SAT: 198,
TYL: 165,
WAC: 143,
WFS: 289,
YKM: 70,
});
export const TXDOT_DEFAULT_ELEVATION_M = 150;
export const CALTRANS_CCTV_URL = (district) =>
`https://cwwp2.dot.ca.gov/data/d${district}/cctv/cctvStatusD${String(district).padStart(2, '0')}.json`;
/** Districts fetched by default: SF Bay (4), LA (7), San Diego (11), Sacramento (3). */
@@ -27,7 +119,108 @@ 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. */
/** Ontario 511: keyless CARS/511 camera catalog; frame URLs are still images. */
export const ONTARIO_511_CAMERAS_URL =
'https://511on.ca/api/v2/get/cameras?format=json&lang=en';
export const ONTARIO_511_IMAGE_ORIGIN = 'https://511on.ca/map/Cctv/';
export const DEFAULT_ONTARIO_MAX_SOURCES = 1000;
export const ONTARIO_ANCHORS = [
{ lat: 43.4516, lon: -80.4925 }, // Kitchener
{ lat: 43.6532, lon: -79.3832 }, // Toronto
{ lat: 45.4215, lon: -75.6972 }, // Ottawa
{ lat: 43.2557, lon: -79.8711 }, // Hamilton
{ lat: 42.9849, lon: -81.2453 }, // London, Ontario
{ lat: 42.3149, lon: -83.0364 }, // Windsor
];
/** Fintraffic road weather cameras (Digitraffic): one keyless GeoJSON list
* covering all of Finland. Each STATION carries N presets (fixed camera views)
* that share the station position; one preset is one camera here. */
export const FINTRAFFIC_STATIONS_URL =
'https://tie.digitraffic.fi/api/weathercam/v1/stations';
/** Frames: `<origin><presetId>.jpg`. Preset ids are synthesized into this
* origin rather than read from the payload, so no upstream field can steer the
* frame proxy off-host. */
export const FINTRAFFIC_IMAGE_ORIGIN = 'https://weathercam.digitraffic.fi/';
/** Digitraffic asks every client to identify itself on API calls. */
export const DIGITRAFFIC_USER = 'gods-eye-view';
export const DEFAULT_FINTRAFFIC_MAX_SOURCES = 300;
/** Ground-elevation prior, in metres, for stations that report no altitude.
* 228 of 809 stations carry a real metre value (median 94 m); the rest report
* 0, which means "not reported" rather than sea level — Kouvola (~80 m of real
* elevation) reports 0. The observed median stands in for those. */
export const FINTRAFFIC_GROUND_ELEVATION_M = 90;
/** Prioritization anchors: the population centres strung along Finland's main
* road spine (vt1 Turku, vt3 Tampere, vt4 Jyväskylä–Oulu–Rovaniemi, vt5
* Kuopio), so a cap keeps national coverage rather than just the capital. */
export const FINLAND_ANCHORS = [
{ lat: 60.1699, lon: 24.9384 }, // Helsinki
{ lat: 60.4518, lon: 22.2666 }, // Turku
{ lat: 61.4978, lon: 23.761 }, // Tampere
{ lat: 62.2426, lon: 25.7473 }, // Jyväskylä
{ lat: 62.8924, lon: 27.677 }, // Kuopio
{ lat: 65.0121, lon: 25.4651 }, // Oulu
{ lat: 66.5039, lon: 25.7294 }, // Rovaniemi
];
/** Global cap on total CCTV sources served by the proxy: the default per-pack
* caps summed (Austin 250 + Caltrans 300 + TfL 250 + DriveBC 250). */
/** DriveBC highway cameras (British Columbia): the keyless camera list served by
* the DriveBC.ca site (github.com/bcgov/DriveBC.ca). The DataBC HighwayCams CSV
* lists the same cameras but still carries retired images.drivebc.ca frame URLs,
* so frames are built from the numeric camera id on the current image host. */
export const DRIVEBC_WEBCAMS_URL = 'https://www.drivebc.ca/api/webcams/';
export const DRIVEBC_IMAGE_URL = (id) =>
`https://www.drivebc.ca/images/${id}.jpg`;
export const DEFAULT_DRIVEBC_MAX_SOURCES = 250;
/** Prioritization anchors: downtown Vancouver and Victoria. */
export const DRIVEBC_ANCHORS = [
{ lat: 49.2827, lon: -123.1207 }, // Vancouver
{ lat: 48.4284, lon: -123.3656 }, // Victoria
];
/** Tallinn intersection cameras: curated catalog + public stills on ristmikud.tallinn.ee. */
export const DEFAULT_TALLINN_SOURCE_FILE = 'config/cctv_sources.tallinn.json';
export const DEFAULT_TALLINN_MAX_SOURCES = 255;
export const TALLINN_IMAGE_ORIGIN = 'https://ristmikud.tallinn.ee/';
export const TALLINN_CENTER = { lat: 59.437, lon: 24.753 };
/** Transpordiamet / Tarktee road-weather cameras: keyless DATEX2 feeds. */
export const TARKTEE_LOCATIONS_URL =
'https://tarktee.transpordiamet.ee/api/v1/datex/roadCameraLocations';
export const TARKTEE_IMAGES_URL =
'https://tarktee.transpordiamet.ee/api/v1/datex/roadCameraImages';
export const TARKTEE_IMAGE_ORIGIN = 'https://tarktee.transpordiamet.ee/images/';
export const DEFAULT_TARKTEE_MAX_SOURCES = 179;
export const TARKTEE_ANCHORS = [
{ lat: 59.437, lon: 24.753 }, // Tallinn
{ lat: 58.378, lon: 26.729 }, // Tartu
{ lat: 58.3859, lon: 24.4971 }, // Pärnu
{ lat: 59.3797, lon: 28.1791 }, // Narva
];
/** Warendorf (Germany): the Marktplatz municipal webcam from a curated catalog file. */
export const DEFAULT_WARENDORF_SOURCE_FILE =
'config/cctv_sources.warendorf.json';
export const WARENDORF_IMAGE_ORIGINS = Object.freeze([
'http://webcam.warendorf.de/',
'https://www.kreis-warendorf.de/',
]);
/** Live Traffic NSW (Transport for NSW): keyless public camera catalog. */
export const NSW_CAMERAS_URL =
'https://data.livetraffic.com/cameras/traffic-cam.json';
export const NSW_IMAGE_ORIGIN = 'https://webcams.transport.nsw.gov.au/';
export const DEFAULT_NSW_MAX_SOURCES = 250;
export const SYDNEY_CENTER = { lat: -33.8688, lon: 151.2093 };
/**
* The NSW webcam host answers non-browser clients with HTTP 200 and a short
* HTML body instead of the frame (verified 2026-09-13), so the proxy
* identifies as a browser for that one host. See media.js.
*/
export const NSW_IMAGE_USER_AGENT =
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36';
/**
* Longest NSW `view` sentence still usable as a label. NSW occasionally
* repurposes `view` for a multi-paragraph works notice; real descriptions top
* out around 120 characters.
*/
export const NSW_MAX_VIEW_LABEL = 140;
/** Camera CATALOGS change rarely; 15 min keeps multi-megabyte upstream list refetches (Austin rows.json + 4 Caltrans districts + TfL + Ontario 511) infrequent. Frames are fetched per-request and are unaffected. */
export const CCTV_SOURCE_CACHE_MS = 15 * 60 * 1000;
/** Per-provider catalog-fetch timeout. Bounds the worst-case refresh so one
* stalled upstream can't leave getCctvSources (and thus every CCTV route)
+74
View File
@@ -0,0 +1,74 @@
import fs from 'node:fs';
import path from 'node:path';
import { poseHash } from '../../../src/data/cctvFootprint.js';
/** Sidecar written by scripts/precompute-cctv-heights.mjs. */
export const DEFAULT_GROUND_HEIGHTS_FILE =
'src/data/local_data/cctv_ground_heights/cctv_ground_heights.json';
/** @type {{path:string, mtimeMs:number, cameras:object}|null} */
let _cache = null;
/**
* Load the precomputed ground-height sidecar (Google mesh heights under each
* camera's mount and monitor-plane support points). Cached by file mtime so a
* catalog refresh re-reads only when the file changed. Missing or malformed
* files mean "no shipped heights", never an error.
* @param {string} sourceRoot
* @returns {Record<string, object>} camera id → sidecar entry
*/
export function loadGroundHeights(sourceRoot = process.cwd()) {
const file =
process.env.CCTV_GROUND_HEIGHTS_FILE || DEFAULT_GROUND_HEIGHTS_FILE;
const resolved = path.isAbsolute(file)
? file
: path.resolve(sourceRoot, file);
try {
const stat = fs.statSync(resolved);
if (_cache && _cache.path === resolved && _cache.mtimeMs === stat.mtimeMs) {
return _cache.cameras;
}
const parsed = JSON.parse(fs.readFileSync(resolved, 'utf8'));
const cameras =
parsed &&
typeof parsed === 'object' &&
parsed.cameras &&
typeof parsed.cameras === 'object'
? parsed.cameras
: {};
_cache = { path: resolved, mtimeMs: stat.mtimeMs, cameras };
return cameras;
} catch {
_cache = null;
return {};
}
}
/**
* Attach a shipped entry to each served source whose id has one AND whose
* nominal pose still hashes to what the entry was sampled for. A pack whose
* feed moved a camera or changed its pose simply loses the shipped value and
* falls back to runtime placement.
* @param {Array<object>} sources - Normalized served sources.
* @param {Record<string, object>} entries - Sidecar cameras map.
* @returns {Array<object>} The same sources, `groundHeights` attached where valid.
*/
export function joinGroundHeights(sources, entries) {
if (!entries || typeof entries !== 'object') return sources;
for (const source of sources) {
const entry = entries[source.id];
if (!entry || entry.status !== 'ok' || !Number.isFinite(entry.mountGroundM))
continue;
if (entry.poseHash !== poseHash(source)) continue;
const supports = {};
for (const [key, value] of Object.entries(entry.supports || {})) {
if (Number.isFinite(value)) supports[key] = value;
}
source.groundHeights = {
poseHash: entry.poseHash,
mountGroundM: entry.mountGroundM,
supports,
};
}
return sources;
}
+168 -4
View File
@@ -5,6 +5,8 @@ import {
CCTV_FRAME_MAX_BODY_BYTES,
CCTV_MEDIA_FETCH_TIMEOUT_MS,
CCTV_MEDIA_MAX_BODY_BYTES,
NSW_IMAGE_ORIGIN,
NSW_IMAGE_USER_AGENT,
} from './constants.js';
/**
* Generate a synthetic SVG billboard image for a CCTV camera placeholder.
@@ -238,6 +240,163 @@ export async function fetchCctvMediaUpstream(
}
}
/**
* Fetch and decode a TxDOT ITS / TransGuide snapshot.
*
* TxDOT returns JSON with a base64-encoded JPEG in `snippet`, rather than
* returning image/jpeg directly.
*/
export async function fetchTxdotSnapshot(
url,
{
fetchImpl = fetch,
timeoutMs = CCTV_FRAME_FETCH_TIMEOUT_MS,
maxBytes = CCTV_FRAME_MAX_BODY_BYTES,
} = {},
) {
if (!url) return null;
let parsed;
try {
parsed = new URL(url);
} catch {
return null;
}
if (
parsed.origin !== 'https://its.txdot.gov' ||
parsed.pathname !== '/its/DistrictIts/GetCctvSnapshotByIcdId'
) {
return null;
}
// Base64 inflates by 4/3; the JSON envelope adds a few bytes of framing.
const maxEnvelopeBytes = Math.ceil((maxBytes * 4) / 3) + 4096;
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
try {
// The snapshot endpoint answers directly; a redirect is not followed, so
// the origin/path pin above holds for the request that is actually made.
const upstream = await fetchImpl(parsed.toString(), {
headers: {
Accept: 'application/json',
'User-Agent': 'gods-eye-view-cctv-proxy/1.0',
},
signal: controller.signal,
redirect: 'manual',
});
if (!upstream.ok) return null;
const envelope = await readCappedResponseBytes(upstream, maxEnvelopeBytes);
if (!envelope) return null;
let payload;
try {
payload = JSON.parse(envelope.toString('utf8'));
} catch {
return null;
}
let snippet =
typeof payload?.snippet === 'string' ? payload.snippet.trim() : '';
if (!snippet) return null;
snippet = snippet.replace(/^data:image\/jpeg;base64,/i, '');
// Canonical base64 only (4-char groups, padding only at the end):
// Buffer.from() silently skips junk, which would let a non-image body
// decode into "something".
if (
snippet.length > maxEnvelopeBytes ||
!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(
snippet,
)
) {
return null;
}
const body = Buffer.from(snippet, 'base64');
if (body.length < 4 || body.length > maxBytes) return null;
if (body[0] !== 0xff || body[1] !== 0xd8 || body[2] !== 0xff) return null;
return { ok: true, body, contentType: 'image/jpeg' };
} catch {
return null;
} finally {
clearTimeout(timeoutId);
controller.abort();
}
}
/** Redirect hops the frame path will follow, and only within the same host. */
const MAX_SAME_HOST_REDIRECTS = 2;
/**
* Fetch a registered frame URL following redirects ONLY within the original
* origin (scheme, host and port; at most MAX_SAME_HOST_REDIRECTS hops).
* Default redirect-following would let an upstream steer a host-pinned
* request, and its host-specific headers, to any origin, another port, or a
* plaintext downgrade.
*
* @param {string} url
* @param {object} init - fetch init (headers, signal).
* @param {typeof fetch} fetchImpl
* @returns {Promise<Response|null>} Final response, or null on an off-host or
* over-long redirect chain.
*/
export async function fetchWithinHost(url, init, fetchImpl = fetch) {
let current;
try {
current = new URL(url);
} catch {
return null;
}
const origin = current.origin;
for (let hop = 0; hop <= MAX_SAME_HOST_REDIRECTS; hop++) {
const upstream = await fetchImpl(current.toString(), {
...init,
redirect: 'manual',
});
// Anything that is not a 3xx (including a test double with no status) is
// the final answer.
const status = Number(upstream?.status);
if (!(status >= 300 && status < 400)) return upstream;
const location = upstream.headers.get('location');
try {
await upstream.body?.cancel();
} catch {
/* no-op */
}
if (!location || hop === MAX_SAME_HOST_REDIRECTS) return null;
let next;
try {
next = new URL(location, current);
} catch {
return null;
}
if (next.origin !== origin) return null;
current = next;
}
return null;
}
/**
* Image hosts that only serve frames to browser-identified clients, keyed by
* exact hostname. Every other upstream sees the proxy's own identifying
* User-Agent. Keyed on host, not on a URL substring, so a look-alike host or a
* path that merely mentions the host never inherits the header.
*/
const CCTV_IMAGE_USER_AGENT_BY_HOST = Object.freeze({
[new URL(NSW_IMAGE_ORIGIN).hostname]: NSW_IMAGE_USER_AGENT,
});
/**
* User-Agent for one upstream frame request.
*
* @param {string} url
* @returns {string}
*/
export function cctvUpstreamUserAgent(url) {
try {
return (
CCTV_IMAGE_USER_AGENT_BY_HOST[new URL(url).hostname] ||
'gods-eye-view-cctv-proxy/1.0'
);
} catch {
return 'gods-eye-view-cctv-proxy/1.0';
}
}
/**
* Fetch one upstream CCTV image within the frame-refresh budget.
*
@@ -268,10 +427,15 @@ export async function fetchCctvImageFromUpstream(
);
}, timeoutMs);
try {
const upstream = await fetchImpl(url, {
headers: { 'User-Agent': 'gods-eye-view-cctv-proxy/1.0' },
signal: controller.signal,
});
const upstream = await fetchWithinHost(
url,
{
headers: { 'User-Agent': cctvUpstreamUserAgent(url) },
signal: controller.signal,
},
fetchImpl,
);
if (!upstream) return null;
const contentType = upstream.headers.get('content-type') || '';
if (!upstream.ok || !contentType.startsWith('image/')) {
controller.abort();
+121 -9
View File
@@ -306,6 +306,109 @@ export function isLikelyAustinCoordinate(lat, lon) {
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
);
}
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.
*
@@ -353,14 +456,15 @@ export function prioritizeSources(cameras, maxCount, anchors) {
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;
}
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);
@@ -379,7 +483,7 @@ export function prioritizeSources(cameras, maxCount, anchors) {
return a.idx - b.idx;
});
return scored.slice(0, maxCount).map((entry) => entry.camera);
return scored.slice(0, cap).map((entry) => entry.camera);
}
/**
@@ -410,6 +514,14 @@ export function normalizeSourceItem(item) {
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 diff suppressed because it is too large. Load diff