mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
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
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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
Reference in new issue
Block a user