mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
- State FL511's terms the same way everywhere: individual, personal use only, and any other use (a public deployment included) needs FDOT's express written consent. .env.example, README, dataCredits, the source licence string, the catalog comment and the changelog fragment said only "individual, non-commercial use". - Page the ArcGIS catalog on exceededTransferLimit alone and advance the offset by the rows each page returned, so a lower server-side max record count no longer cuts the catalog short at the first short page. - Order pages by OBJECTID_1 (this layer's object id; a plain OBJECTID is rejected with HTTP 400) so offset paging is stable. - Raise the page ceiling to 40 and warn when it is hit. - Tests: short pages with exceededTransferLimit keep paging, orderByFields is sent, and a service that ignores resultOffset stops at the ceiling. Co-Authored-By: Claude Opus 5.5 <[email protected]>
343 lines
12 KiB
JavaScript
343 lines
12 KiB
JavaScript
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
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 { cameraRegion, normalizeSourceItem } from './normalize.js';
|
|
import { loadLithuaniaSources } from './lithuania.js';
|
|
import {
|
|
loadAustinSourcesFromOpenData,
|
|
loadCaltransSourcesFromOpenData,
|
|
loadTflSourcesFromOpenData,
|
|
loadOntarioSourcesFromOpenData,
|
|
loadFintrafficSourcesFromOpenData,
|
|
loadDriveBcSourcesFromOpenData,
|
|
loadTxdotSourcesFromOpenData,
|
|
loadTallinnSourcesFromCatalog,
|
|
loadTarkteeSourcesFromDatex,
|
|
loadWarendorfSourcesFromCatalog,
|
|
loadNswSourcesFromOpenData,
|
|
loadCalgarySourcesFromOpenData,
|
|
loadCataloniaSourcesFromOpenData,
|
|
loadDelDOTSourcesFromOpenData,
|
|
loadKingCountySourcesFromOpenData,
|
|
loadWsdotSourcesFromOpenData,
|
|
loadVegvesenSourcesFromOpenData,
|
|
loadNycSourcesFromOpenData,
|
|
loadTorontoSourcesFromOpenData,
|
|
loadFloridaSourcesFromOpenData,
|
|
} from './sources.js';
|
|
|
|
/** Env kill switch: unset or anything but "0" means enabled. */
|
|
const envEnabled = (name) => String(process.env[name] || '1').trim() !== '0';
|
|
/** Env opt-in: only "1" enables. For packs whose feed access needs an
|
|
* operator agreement or whose terms are unconfirmed, so an unset flag never
|
|
* reaches the upstream. */
|
|
const envOptIn = (name) => String(process.env[name] || '').trim() === '1';
|
|
|
|
/**
|
|
* Live open-data packs, in merge order. Adding a region is one entry here
|
|
* plus its loader (in sources.js or its own module); 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, or by an opt-in flag where access needs an agreement or the
|
|
* terms are unconfirmed.
|
|
*/
|
|
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,
|
|
},
|
|
{
|
|
name: 'calgary',
|
|
enabled: () => envEnabled('CCTV_CALGARY_ENABLED'),
|
|
load: loadCalgarySourcesFromOpenData,
|
|
},
|
|
{
|
|
name: 'catalonia',
|
|
enabled: () => envEnabled('CCTV_CATALONIA_ENABLED'),
|
|
load: loadCataloniaSourcesFromOpenData,
|
|
},
|
|
{
|
|
name: 'deldot',
|
|
enabled: () => envEnabled('CCTV_DELDOT_ENABLED'),
|
|
load: loadDelDOTSourcesFromOpenData,
|
|
},
|
|
{
|
|
name: 'kingcounty',
|
|
enabled: () => envEnabled('CCTV_KINGCOUNTY_ENABLED'),
|
|
load: loadKingCountySourcesFromOpenData,
|
|
},
|
|
{
|
|
name: 'wsdot',
|
|
enabled: () => envEnabled('CCTV_WSDOT_ENABLED'),
|
|
load: loadWsdotSourcesFromOpenData,
|
|
},
|
|
{
|
|
name: 'vegvesen',
|
|
enabled: () => envEnabled('CCTV_VEGVESEN_ENABLED'),
|
|
load: loadVegvesenSourcesFromOpenData,
|
|
},
|
|
{
|
|
// NYC DOT requires a data-sharing agreement for this feed, so the pack is
|
|
// off unless the operator opts in (see DATA_SOURCES.md).
|
|
name: 'nyc',
|
|
enabled: () => envOptIn('CCTV_NYC_ENABLED'),
|
|
load: loadNycSourcesFromOpenData,
|
|
},
|
|
{
|
|
name: 'toronto',
|
|
enabled: () => envEnabled('CCTV_TORONTO_ENABLED'),
|
|
load: loadTorontoSourcesFromOpenData,
|
|
},
|
|
{
|
|
// Opt-in until the Via Lietuva open-data terms are confirmed to cover the
|
|
// camera photos (see DATA_SOURCES.md).
|
|
name: 'lithuania',
|
|
enabled: () => envOptIn('CCTV_LITHUANIA_ENABLED'),
|
|
load: loadLithuaniaSources,
|
|
},
|
|
{
|
|
// FL511's terms limit its content to individual, personal use (other use
|
|
// needs FDOT's written consent), so the pack is off unless the operator
|
|
// opts in (see DATA_SOURCES.md).
|
|
name: 'florida',
|
|
enabled: () => envOptIn('CCTV_FLORIDA_ENABLED'),
|
|
load: loadFloridaSourcesFromOpenData,
|
|
},
|
|
];
|
|
/**
|
|
* Load CCTV sources from a local JSON file (CCTV_SOURCES_FILE env or default).
|
|
*
|
|
* @returns {Array<object>} Array of raw source objects, or [] on error.
|
|
*/
|
|
function loadSourcesFromFile(sourceRoot) {
|
|
const sourceFile = process.env.CCTV_SOURCES_FILE || DEFAULT_CCTV_SOURCE_FILE;
|
|
const resolved = path.isAbsolute(sourceFile)
|
|
? sourceFile
|
|
: path.resolve(sourceRoot, sourceFile);
|
|
try {
|
|
if (!fs.existsSync(resolved)) return [];
|
|
const raw = fs.readFileSync(resolved, 'utf8');
|
|
const parsed = JSON.parse(raw);
|
|
return Array.isArray(parsed) ? parsed : [];
|
|
} catch (error) {
|
|
console.warn(
|
|
'[CCTV] failed to read source file:',
|
|
resolved,
|
|
error?.message || error,
|
|
);
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Load CCTV sources from the CCTV_SOURCES_JSON env variable (inline JSON).
|
|
*
|
|
* @returns {Array<object>} Array of raw source objects, or [] if unset/invalid.
|
|
*/
|
|
function loadSourcesFromEnv() {
|
|
const raw = process.env.CCTV_SOURCES_JSON;
|
|
if (!raw) return [];
|
|
try {
|
|
const parsed = JSON.parse(raw);
|
|
return Array.isArray(parsed) ? parsed : [];
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/** 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<{pack: string, available: number, served: number}>} Packs
|
|
* that serve fewer cameras than they offer. */
|
|
let _cctvTrimmedPacks = [];
|
|
|
|
/**
|
|
* Assemble and cache the merged CCTV source list.
|
|
*
|
|
* 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.
|
|
*/
|
|
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 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 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), pack: name }))
|
|
.filter((item) => item.id),
|
|
available: Number.isFinite(items.available)
|
|
? items.available
|
|
: items.length,
|
|
region: items.region ?? null,
|
|
});
|
|
const packs = [
|
|
...LIVE_PACKS.map((pack, index) =>
|
|
normalizePack(
|
|
pack.name,
|
|
liveResults[index]?.status === 'fulfilled'
|
|
? liveResults[index].value
|
|
: [],
|
|
),
|
|
),
|
|
normalizePack('file', fromFile),
|
|
normalizePack('env', fromEnv),
|
|
];
|
|
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 trimmed = allocation.packs.filter((pack) => pack.kept < pack.offered);
|
|
// Each pack's own nearest-first cap and the shared catalog cap both trim.
|
|
const trimmedPacks = packs
|
|
.map((pack, index) => ({
|
|
pack: pack.name,
|
|
available: Math.max(pack.available, allocation.packs[index].offered),
|
|
served: allocation.packs[index].kept,
|
|
// Where the pack's cameras are, including those not served.
|
|
region: pack.region ?? cameraRegion(pack.sources),
|
|
}))
|
|
.filter((pack) => pack.served < pack.available);
|
|
if (trimmed.length) {
|
|
const detail = trimmed
|
|
.map((pack) => `${pack.name} ${pack.kept}/${pack.offered}`)
|
|
.join(', ');
|
|
console.warn(
|
|
`[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.`,
|
|
);
|
|
}
|
|
if (capped.length > 0 || _cctvSourceCache.length === 0) {
|
|
_cctvSourceCache = capped;
|
|
_cctvTrimmedPacks = trimmedPacks;
|
|
} 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;
|
|
}
|
|
|
|
/** Packs serving fewer cameras than they offer, as of the last refresh. */
|
|
getCctvSources.trimmedPacks = () => _cctvTrimmedPacks;
|
|
return getCctvSources;
|
|
}
|