mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
Merge remote-tracking branch 'upstream/main' into fix-1237
# Conflicts: # .env.example # README.md # server/providers/cctv/catalog.js
This commit is contained in:
commit
0868bb97f6
251 files changed
+27462
-17542
No files matched your search
@@ -26,6 +26,7 @@ import {
|
||||
loadVegvesenSourcesFromOpenData,
|
||||
loadNycSourcesFromOpenData,
|
||||
loadTorontoSourcesFromOpenData,
|
||||
loadFloridaSourcesFromOpenData,
|
||||
} from './sources.js';
|
||||
|
||||
/** Env kill switch: unset or anything but "0" means enabled. */
|
||||
@@ -145,6 +146,14 @@ const LIVE_PACKS = [
|
||||
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,
|
||||
},
|
||||
{
|
||||
name: 'taiwan',
|
||||
enabled: () => envEnabled('CCTV_TAIWAN_ENABLED'),
|
||||
@@ -201,16 +210,19 @@ export function createCctvCatalog({ sourceRoot = process.cwd() } = {}) {
|
||||
/** @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 = [];
|
||||
/** @type {WeakMap<Array<object>, Array<{pack: string, available: number, served: number}>>}
|
||||
* Packs that serve fewer cameras than they offer, keyed by the source list
|
||||
* they describe, so a request served the old list during a background
|
||||
* refresh never pairs it with the new list's trim notes. */
|
||||
const _cctvTrimmedPacks = new WeakMap();
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* packs, and caches for CCTV_SOURCE_CACHE_MS. Past that, the cached list is
|
||||
* returned while a background refresh replaces it; only a first load waits.
|
||||
*
|
||||
* @returns {Promise<Array<object>>} Deduplicated, capped source list.
|
||||
*/
|
||||
@@ -225,11 +237,31 @@ export function createCctvCatalog({ sourceRoot = process.cwd() } = {}) {
|
||||
// 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;
|
||||
if (!_cctvSourceInflight) {
|
||||
_cctvSourceInflight = refreshCctvSources().finally(() => {
|
||||
_cctvSourceInflight = null;
|
||||
});
|
||||
// A background refresh has no caller waiting on it: report its failure
|
||||
// here, once per refresh rather than once per request it serves. Keep
|
||||
// the old catalog for another TTL, as an empty refresh does, so a refresh
|
||||
// that keeps failing is retried once per TTL, not back to back.
|
||||
if (_cctvSourceCache.length) {
|
||||
_cctvSourceInflight.catch((error) => {
|
||||
_cctvSourceCacheAt = Date.now();
|
||||
console.warn(
|
||||
'[CCTV] background catalog refresh failed; serving',
|
||||
_cctvSourceCache.length,
|
||||
'cameras from the last catalog:',
|
||||
error?.message || error,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
// With a catalog in hand, answer with it while the refresh runs: the
|
||||
// refresh lasts as long as the slowest pack (Tarktee's DATEX feeds take up
|
||||
// to its 30 s timeout), and every /api/cctv request would otherwise wait
|
||||
// for it. Only the first load, with nothing to serve yet, waits.
|
||||
return _cctvSourceCache.length ? _cctvSourceCache : _cctvSourceInflight;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -319,7 +351,7 @@ export function createCctvCatalog({ sourceRoot = process.cwd() } = {}) {
|
||||
}
|
||||
if (capped.length > 0 || _cctvSourceCache.length === 0) {
|
||||
_cctvSourceCache = capped;
|
||||
_cctvTrimmedPacks = trimmedPacks;
|
||||
_cctvTrimmedPacks.set(capped, 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
|
||||
@@ -333,7 +365,10 @@ export function createCctvCatalog({ sourceRoot = process.cwd() } = {}) {
|
||||
return _cctvSourceCache;
|
||||
}
|
||||
|
||||
/** Packs serving fewer cameras than they offer, as of the last refresh. */
|
||||
getCctvSources.trimmedPacks = () => _cctvTrimmedPacks;
|
||||
/** Packs serving fewer cameras than they offer in `sources` (by default the
|
||||
* current catalog). Pass the list a request was served to keep the two in
|
||||
* step while a background refresh swaps the catalog. */
|
||||
getCctvSources.trimmedPacks = (sources = _cctvSourceCache) =>
|
||||
_cctvTrimmedPacks.get(sources) ?? [];
|
||||
return getCctvSources;
|
||||
}
|
||||
@@ -113,13 +113,34 @@ export const CALTRANS_ANCHORS = [
|
||||
{ lat: 32.7157, lon: -117.1611 }, // San Diego
|
||||
{ lat: 38.5816, lon: -121.4944 }, // Sacramento
|
||||
];
|
||||
export const FLORIDA_CCTV_URL =
|
||||
'https://services.arcgis.com/3wFbqsFPLeKqOlIK/arcgis/rest/services/FL511_Traffic_Cameras/FeatureServer/0/query';
|
||||
export const FLORIDA_IMAGE_HOSTS = [
|
||||
'images-dis.divas.cloud',
|
||||
'snapshots.divas.cloud',
|
||||
];
|
||||
export const DEFAULT_FLORIDA_MAX_SOURCES = 500;
|
||||
/** Byte cap per catalog page (2,000 rows, about 650 KiB measured). */
|
||||
export const FLORIDA_MAX_CATALOG_BYTES = 2 * 1024 * 1024;
|
||||
/** Catalog page ceiling: 40 pages covers 4,000+ cameras even at 100 rows a page. */
|
||||
export const FLORIDA_MAX_CATALOG_PAGES = 40;
|
||||
export const FLORIDA_ANCHORS = [
|
||||
{ lat: 25.7617, lon: -80.1918 }, // Miami
|
||||
{ lat: 28.5384, lon: -81.3789 }, // Orlando
|
||||
{ lat: 27.9506, lon: -82.4572 }, // Tampa
|
||||
{ lat: 30.3322, lon: -81.6557 }, // Jacksonville
|
||||
{ lat: 30.4383, lon: -84.2807 }, // Tallahassee
|
||||
{ lat: 26.1224, lon: -80.1373 }, // Fort Lauderdale
|
||||
{ lat: 26.7153, lon: -80.0534 }, // West Palm Beach
|
||||
{ lat: 30.4213, lon: -87.2169 }, // Pensacola (panhandle)
|
||||
];
|
||||
/** TfL JamCams: one keyless list endpoint; frames live on a public S3 bucket. */
|
||||
export const TFL_JAMCAM_URL = 'https://api.tfl.gov.uk/Place/Type/JamCam';
|
||||
export const TFL_IMAGE_ORIGIN =
|
||||
'https://s3-eu-west-1.amazonaws.com/jamcams.tfl.gov.uk/';
|
||||
export const DEFAULT_TFL_MAX_SOURCES = 250;
|
||||
export const LONDON_CENTER = { lat: 51.5074, lon: -0.1278 };
|
||||
/** Ontario 511: keyless CARS/511 camera catalog; frame URLs are still images. */
|
||||
/** Ontario 511: CARS/511 camera catalog, which needs a developer key (ONTARIO_511_API_KEY); 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/';
|
||||
@@ -188,6 +209,8 @@ 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;
|
||||
/** Hard ceiling per Tarktee DATEX XML document (~120–165 KB live). */
|
||||
export const TARKTEE_MAX_CATALOG_BYTES = 4 * 1024 * 1024;
|
||||
export const TARKTEE_ANCHORS = [
|
||||
{ lat: 59.437, lon: 24.753 }, // Tallinn
|
||||
{ lat: 58.378, lon: 26.729 }, // Tartu
|
||||
@@ -317,6 +340,49 @@ export const NORWAY_ANCHORS = [
|
||||
{ lat: 69.6492, lon: 18.9553 }, // Tromsø
|
||||
];
|
||||
|
||||
/** Kartverket's keyless point-height API (CC BY 4.0): terrain height above
|
||||
* sea level (NN2000), used as the Vegvesen cameras' ground prior because the
|
||||
* camera feed's points are 2D. */
|
||||
export const KARTVERKET_HEIGHT_URL =
|
||||
'https://ws.geonorge.no/hoydedata/v1/punkt';
|
||||
/** Points per request. The API takes up to 50, but above ~20 it answers the
|
||||
* extra points from contour lines (`hoydekurver`, metres to ~20 m off) or not
|
||||
* at all; at 20 nearly every camera resolves from the 1 m terrain model. */
|
||||
export const KARTVERKET_MAX_POINTS = 20;
|
||||
/** Requests in flight at once. The whole country is ~36 requests of
|
||||
* 0.6-9.5 s each, so a cold lookup takes ~6-13 s. */
|
||||
export const KARTVERKET_CONCURRENCY = 8;
|
||||
/** Deadline for one complete lookup, shared by every request. A lookup cut
|
||||
* short by it is resumed on the next refresh, without a backoff. */
|
||||
export const KARTVERKET_LOOKUP_TIMEOUT_MS = 30 * 1000;
|
||||
/** One request that takes longer than this counts as a failing service. */
|
||||
export const KARTVERKET_REQUEST_TIMEOUT_MS = 10 * 1000;
|
||||
/** The Vegvesen lane's budget, measured from the start of the loader (catalog
|
||||
* fetch included), for waiting on heights. Under the 15 s per-provider fetch
|
||||
* timeout so this pack is never the catalog's slowest lane. A lookup still
|
||||
* running after it keeps filling the cache for the next refresh. */
|
||||
export const KARTVERKET_CATALOG_WAIT_MS = 12 * 1000;
|
||||
/** The lane only waits when more than this share of its cameras has no
|
||||
* answer yet (a cold cache). A few new cameras or expired misses are looked
|
||||
* up in the background instead of holding the refresh. */
|
||||
export const KARTVERKET_WAIT_MISSING_SHARE = 0.1;
|
||||
/** A point Kartverket has no height for (some ferry quays and bridges) is not
|
||||
* asked about again for this long. */
|
||||
export const KARTVERKET_NULL_TTL_MS = 6 * 60 * 60 * 1000;
|
||||
/** After a failing request (408, 429, 5xx, a network error, a request
|
||||
* timeout or an unreadable body), no lookup starts for this long, or for the
|
||||
* Retry-After the service sent, bounded to [1 min, 1 h]. Longer than the
|
||||
* 15-minute catalog refresh (CCTV_SOURCE_CACHE_MS), so a failing service
|
||||
* costs one attempt per backoff, not one per refresh. */
|
||||
export const KARTVERKET_FAILURE_BACKOFF_MS = 30 * 60 * 1000;
|
||||
export const KARTVERKET_MAX_BACKOFF_MS = 60 * 60 * 1000;
|
||||
export const KARTVERKET_MAX_RESPONSE_BYTES = 256 * 1024;
|
||||
/** Plausible Norwegian terrain: below sea level only at the shore, and no road
|
||||
* camera sits above ~1,500 m. Anything outside is treated as no height. */
|
||||
export const KARTVERKET_HEIGHT_RANGE_M = Object.freeze({ min: -20, max: 2500 });
|
||||
/** Same identity the other CCTV upstream requests send. */
|
||||
export const KARTVERKET_USER_AGENT = 'gods-eye-view-cctv-proxy/1.0';
|
||||
|
||||
/** NYC DOT Traffic Management Center cameras: one keyless JSON list for the
|
||||
* five boroughs (~980 cameras). Frames are stills synthesized from each
|
||||
* camera UUID on the city's webcam host — never taken from the payload. */
|
||||
@@ -439,6 +505,10 @@ export const CCTV_SOURCE_CACHE_MS = 15 * 60 * 1000;
|
||||
* pending forever — a hung fetch aborts, the loader returns [], and
|
||||
* serve-stale/other packs take over. */
|
||||
export const CCTV_SOURCE_FETCH_TIMEOUT_MS = 15 * 1000;
|
||||
/** Tarktee's two DATEX feeds (120 and 160 KB) answered in 2 to 16.6 s across
|
||||
* ten requests on 2026-10-18, so the shared 15 s timeout dropped the Estonian
|
||||
* pack on about a third of loads. Both must arrive for any camera to load. */
|
||||
export const TARKTEE_FETCH_TIMEOUT_MS = 30 * 1000;
|
||||
/** Individual CCTV image fetches must settle before the active 10-second
|
||||
* client refresh cadence. A bounded miss can fall through to Street View or
|
||||
* the synthetic frame instead of leaving the browser preview pending. */
|
||||
|
||||
@@ -377,6 +377,17 @@ export function isLikelyCalgaryCoordinate(lat, lon) {
|
||||
);
|
||||
}
|
||||
|
||||
/** Florida statewide, Key West to Georgia state line, with slack at each edge */
|
||||
export function isLikelyFloridaCoordinate(lat, lon) {
|
||||
return (
|
||||
isPlausibleLatLon(lat, lon) &&
|
||||
lat >= 24.3 &&
|
||||
lat <= 31.1 &&
|
||||
lon >= -87.7 &&
|
||||
lon <= -79.8
|
||||
);
|
||||
}
|
||||
|
||||
/** New York City's five boroughs, with slack for the harbor and city line. */
|
||||
export function isLikelyNycCoordinate(lat, lon) {
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
_resetOntarioKeyWarningForTest,
|
||||
loadOntarioSourcesFromOpenData,
|
||||
} from './sources.js';
|
||||
|
||||
const ROW = {
|
||||
Id: '42',
|
||||
Latitude: 43.6532,
|
||||
Longitude: -79.3832,
|
||||
Location: 'Hwy 401 near Yonge St',
|
||||
Direction: 'Eastbound',
|
||||
Views: [
|
||||
{
|
||||
Status: 'Enabled',
|
||||
Url: 'https://511on.ca/map/Cctv/4201',
|
||||
Description: 'Eastbound',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
function captureConsole(t) {
|
||||
const lines = [];
|
||||
const capture = (...args) => lines.push(args.join(' '));
|
||||
t.mock.method(console, 'warn', capture);
|
||||
t.mock.method(console, 'log', capture);
|
||||
return lines;
|
||||
}
|
||||
|
||||
test.beforeEach(() => _resetOntarioKeyWarningForTest());
|
||||
|
||||
test('without ONTARIO_511_API_KEY the Ontario pack is skipped, logged once', async (t) => {
|
||||
const lines = captureConsole(t);
|
||||
let fetches = 0;
|
||||
t.mock.method(globalThis, 'fetch', async () => {
|
||||
fetches += 1;
|
||||
return Response.json([ROW]);
|
||||
});
|
||||
assert.deepEqual(await loadOntarioSourcesFromOpenData({}, {}), []);
|
||||
assert.deepEqual(
|
||||
await loadOntarioSourcesFromOpenData({}, { ONTARIO_511_API_KEY: ' ' }),
|
||||
[],
|
||||
);
|
||||
assert.equal(fetches, 0);
|
||||
assert.equal(lines.length, 1);
|
||||
assert.match(lines[0], /ONTARIO_511_API_KEY/);
|
||||
});
|
||||
|
||||
test('with a key the camera list is requested with it and parsed', async (t) => {
|
||||
const lines = captureConsole(t);
|
||||
const requested = [];
|
||||
t.mock.method(globalThis, 'fetch', async (url) => {
|
||||
requested.push(String(url));
|
||||
return Response.json([ROW]);
|
||||
});
|
||||
const cameras = await loadOntarioSourcesFromOpenData(
|
||||
{ sourceRoot: '/ignored' },
|
||||
{ ONTARIO_511_API_KEY: ' test-key ' },
|
||||
);
|
||||
assert.equal(requested.length, 1);
|
||||
const url = new URL(requested[0]);
|
||||
assert.equal(url.host, '511on.ca');
|
||||
assert.equal(url.searchParams.get('key'), 'test-key');
|
||||
assert.equal(url.searchParams.get('format'), 'json');
|
||||
assert.equal(cameras.length, 1);
|
||||
assert.equal(cameras[0].id, 'on-42');
|
||||
assert.equal(cameras[0].url, 'https://511on.ca/map/Cctv/4201');
|
||||
// The records go to the browser through /api/cctv; the key must stay on
|
||||
// the server-side catalog request.
|
||||
assert.doesNotMatch(JSON.stringify(cameras), /test-key/);
|
||||
assert.doesNotMatch(lines.join('\n'), /test-key/);
|
||||
});
|
||||
|
||||
test('the source cap is read from the same environment as the key', async (t) => {
|
||||
captureConsole(t);
|
||||
const rows = Array.from({ length: 12 }, (_, i) => ({
|
||||
...ROW,
|
||||
Id: String(100 + i),
|
||||
Latitude: ROW.Latitude + i * 0.01,
|
||||
Views: [{ ...ROW.Views[0], Url: `https://511on.ca/map/Cctv/${100 + i}` }],
|
||||
}));
|
||||
t.mock.method(globalThis, 'fetch', async () => Response.json(rows));
|
||||
const cameras = await loadOntarioSourcesFromOpenData(
|
||||
{},
|
||||
{ ONTARIO_511_API_KEY: 'test-key', CCTV_ONTARIO_MAX_SOURCES: '8' },
|
||||
);
|
||||
assert.equal(cameras.length, 8);
|
||||
});
|
||||
|
||||
for (const status of [400, 401, 403]) {
|
||||
test(`a ${status} points at the key by name, never by value`, async (t) => {
|
||||
const lines = captureConsole(t);
|
||||
t.mock.method(globalThis, 'fetch', async () =>
|
||||
Response.json({ Message: 'Invalid Key' }, { status }),
|
||||
);
|
||||
const cameras = await loadOntarioSourcesFromOpenData(
|
||||
{},
|
||||
{ ONTARIO_511_API_KEY: 'secret-key-value' },
|
||||
);
|
||||
assert.deepEqual(cameras, []);
|
||||
assert.match(
|
||||
lines.join('\n'),
|
||||
new RegExp(`failed: ${status} \\(check ONTARIO_511_API_KEY\\)`),
|
||||
);
|
||||
assert.doesNotMatch(lines.join('\n'), /secret-key-value/);
|
||||
});
|
||||
}
|
||||
|
||||
for (const status of [429, 500, 503]) {
|
||||
test(`a ${status} is reported without blaming the key`, async (t) => {
|
||||
const lines = captureConsole(t);
|
||||
t.mock.method(globalThis, 'fetch', async () =>
|
||||
Response.json({}, { status }),
|
||||
);
|
||||
const cameras = await loadOntarioSourcesFromOpenData(
|
||||
{},
|
||||
{ ONTARIO_511_API_KEY: 'secret-key-value' },
|
||||
);
|
||||
assert.deepEqual(cameras, []);
|
||||
assert.equal(lines.length, 1);
|
||||
assert.equal(
|
||||
lines[0],
|
||||
`[CCTV] Ontario 511 camera download failed: ${status}`,
|
||||
);
|
||||
assert.doesNotMatch(lines[0], /secret-key-value/);
|
||||
});
|
||||
}
|
||||
@@ -42,6 +42,7 @@ import {
|
||||
TARKTEE_IMAGES_URL,
|
||||
TARKTEE_IMAGE_ORIGIN,
|
||||
DEFAULT_TARKTEE_MAX_SOURCES,
|
||||
TARKTEE_MAX_CATALOG_BYTES,
|
||||
TARKTEE_ANCHORS,
|
||||
DEFAULT_WARENDORF_SOURCE_FILE,
|
||||
WARENDORF_IMAGE_ORIGINS,
|
||||
@@ -55,6 +56,12 @@ import {
|
||||
DEFAULT_CALGARY_MAX_SOURCES,
|
||||
CALGARY_DOWNTOWN,
|
||||
CALGARY_MAX_CATALOG_BYTES,
|
||||
FLORIDA_CCTV_URL,
|
||||
FLORIDA_IMAGE_HOSTS,
|
||||
DEFAULT_FLORIDA_MAX_SOURCES,
|
||||
FLORIDA_ANCHORS,
|
||||
FLORIDA_MAX_CATALOG_BYTES,
|
||||
FLORIDA_MAX_CATALOG_PAGES,
|
||||
CATALONIA_CAMERAS_URL,
|
||||
CATALONIA_MAX_CATALOG_BYTES,
|
||||
CATALONIA_IMAGE_HOSTS,
|
||||
@@ -70,6 +77,7 @@ import {
|
||||
TORONTO_GROUND_ELEVATION_M,
|
||||
TORONTO_MAX_CATALOG_BYTES,
|
||||
CCTV_SOURCE_FETCH_TIMEOUT_MS,
|
||||
TARKTEE_FETCH_TIMEOUT_MS,
|
||||
DELDOT_CCTV_URL,
|
||||
DEFAULT_DELDOT_MAX_SOURCES,
|
||||
DELDOT_ANCHORS,
|
||||
@@ -89,6 +97,19 @@ import {
|
||||
DEFAULT_VEGVESEN_MAX_SOURCES,
|
||||
VEGVESEN_MAX_CATALOG_BYTES,
|
||||
NORWAY_ANCHORS,
|
||||
KARTVERKET_HEIGHT_URL,
|
||||
KARTVERKET_MAX_POINTS,
|
||||
KARTVERKET_CONCURRENCY,
|
||||
KARTVERKET_LOOKUP_TIMEOUT_MS,
|
||||
KARTVERKET_REQUEST_TIMEOUT_MS,
|
||||
KARTVERKET_WAIT_MISSING_SHARE,
|
||||
KARTVERKET_CATALOG_WAIT_MS,
|
||||
KARTVERKET_NULL_TTL_MS,
|
||||
KARTVERKET_FAILURE_BACKOFF_MS,
|
||||
KARTVERKET_MAX_BACKOFF_MS,
|
||||
KARTVERKET_USER_AGENT,
|
||||
KARTVERKET_MAX_RESPONSE_BYTES,
|
||||
KARTVERKET_HEIGHT_RANGE_M,
|
||||
NYC_CAMERAS_URL,
|
||||
NYC_IMAGE_URL,
|
||||
NYC_CAMERA_ID_RE,
|
||||
@@ -113,6 +134,7 @@ import {
|
||||
isLikelyTexasCoordinate,
|
||||
isLikelyNswCoordinate,
|
||||
isLikelyCalgaryCoordinate,
|
||||
isLikelyFloridaCoordinate,
|
||||
isLikelyCataloniaCoordinate,
|
||||
decodeNumericEntities,
|
||||
isLikelyNycCoordinate,
|
||||
@@ -502,22 +524,56 @@ function pickOntarioCctvView(views) {
|
||||
);
|
||||
}
|
||||
|
||||
let ontarioKeyMissingLogged = false;
|
||||
|
||||
/** Test seam: let the next keyless call log the missing-key line again. */
|
||||
export function _resetOntarioKeyWarningForTest() {
|
||||
ontarioKeyMissingLogged = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch Ontario 511 CCTV cameras. Keyless: the catalog is exposed by the
|
||||
* public 511 API, while frame URLs are stable still-image endpoints under
|
||||
* 511on.ca/map/Cctv/. Only rows with finite Ontario coords and at least one
|
||||
* enabled official still view are kept.
|
||||
* Fetch Ontario 511 CCTV cameras. The camera list comes from the 511 API,
|
||||
* which now requires a free developer key (511on.ca/developers/doc, "Requires a
|
||||
* developer key"; unkeyed calls answer 400 "Invalid Key"), read from
|
||||
* ONTARIO_511_API_KEY. Without one the pack is skipped with a single log line.
|
||||
* Frame URLs are stable still-image endpoints under 511on.ca/map/Cctv/. Only
|
||||
* rows with finite Ontario coords and at least one enabled official still
|
||||
* view are kept.
|
||||
*
|
||||
* @param {object} [_options] The catalog's loader options (unused).
|
||||
* @param {object} [env] Environment to read the key and the source cap from.
|
||||
* @returns {Promise<Array<object>>} Normalized camera source objects.
|
||||
*/
|
||||
export async function loadOntarioSourcesFromOpenData() {
|
||||
export async function loadOntarioSourcesFromOpenData(
|
||||
_options = {},
|
||||
env = process.env,
|
||||
) {
|
||||
const key = String(env.ONTARIO_511_API_KEY ?? '').trim();
|
||||
if (!key) {
|
||||
if (!ontarioKeyMissingLogged) {
|
||||
ontarioKeyMissingLogged = true;
|
||||
console.warn(
|
||||
'[CCTV] Ontario 511 cameras skipped: the 511on.ca API needs a free developer key (set ONTARIO_511_API_KEY)',
|
||||
);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
const resp = await fetch(ONTARIO_511_CAMERAS_URL, {
|
||||
const url = new URL(ONTARIO_511_CAMERAS_URL);
|
||||
url.searchParams.set('key', key);
|
||||
const resp = await fetch(url, {
|
||||
headers: { Accept: 'application/json' },
|
||||
signal: AbortSignal.timeout(CCTV_SOURCE_FETCH_TIMEOUT_MS),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
console.warn('[CCTV] Ontario 511 camera download failed:', resp.status);
|
||||
// The API answers 400 "Invalid Key" to a wrong or revoked key.
|
||||
const keyHint =
|
||||
resp.status === 400 || resp.status === 401 || resp.status === 403
|
||||
? ' (check ONTARIO_511_API_KEY)'
|
||||
: '';
|
||||
console.warn(
|
||||
`[CCTV] Ontario 511 camera download failed: ${resp.status}${keyHint}`,
|
||||
);
|
||||
return [];
|
||||
}
|
||||
const rows = await resp.json();
|
||||
@@ -580,7 +636,7 @@ export async function loadOntarioSourcesFromOpenData() {
|
||||
new Map(cameras.map((camera) => [camera.id, camera])).values(),
|
||||
);
|
||||
const maxRaw = Number(
|
||||
process.env.CCTV_ONTARIO_MAX_SOURCES || DEFAULT_ONTARIO_MAX_SOURCES,
|
||||
env.CCTV_ONTARIO_MAX_SOURCES || DEFAULT_ONTARIO_MAX_SOURCES,
|
||||
);
|
||||
const maxCount = Number.isFinite(maxRaw)
|
||||
? Math.max(8, Math.min(1000, Math.floor(maxRaw)))
|
||||
@@ -1191,28 +1247,73 @@ export function parseTarkteeDatexImages(xml) {
|
||||
*/
|
||||
export async function loadTarkteeSourcesFromDatex() {
|
||||
try {
|
||||
const [locResp, imgResp] = await Promise.all([
|
||||
const responses = await Promise.allSettled([
|
||||
fetch(TARKTEE_LOCATIONS_URL, {
|
||||
headers: { Accept: 'application/xml,text/xml,*/*' },
|
||||
signal: AbortSignal.timeout(CCTV_SOURCE_FETCH_TIMEOUT_MS),
|
||||
redirect: 'manual',
|
||||
signal: AbortSignal.timeout(TARKTEE_FETCH_TIMEOUT_MS),
|
||||
}),
|
||||
fetch(TARKTEE_IMAGES_URL, {
|
||||
headers: { Accept: 'application/xml,text/xml,*/*' },
|
||||
signal: AbortSignal.timeout(CCTV_SOURCE_FETCH_TIMEOUT_MS),
|
||||
redirect: 'manual',
|
||||
signal: AbortSignal.timeout(TARKTEE_FETCH_TIMEOUT_MS),
|
||||
}),
|
||||
]);
|
||||
const [locResp, imgResp] = responses.map((result) =>
|
||||
result.status === 'fulfilled' ? result.value : undefined,
|
||||
);
|
||||
// Both responses are fetched in parallel; if either is rejected or overflows
|
||||
// its byte cap, both bodies must be released so neither transport leaks.
|
||||
const discardBoth = async () => {
|
||||
await Promise.allSettled([
|
||||
locResp?.body?.cancel(),
|
||||
imgResp?.body?.cancel(),
|
||||
]);
|
||||
return [];
|
||||
};
|
||||
const rejected = responses.find((result) => result.status === 'rejected');
|
||||
if (rejected) {
|
||||
await discardBoth();
|
||||
throw rejected.reason;
|
||||
}
|
||||
if (
|
||||
(locResp.status >= 300 && locResp.status < 400) ||
|
||||
(imgResp.status >= 300 && imgResp.status < 400)
|
||||
) {
|
||||
console.warn(
|
||||
'[CCTV] Tarktee DATEX catalog redirected; redirects are not followed',
|
||||
);
|
||||
return discardBoth();
|
||||
}
|
||||
if (!locResp.ok) {
|
||||
console.warn('[CCTV] Tarktee locations download failed:', locResp.status);
|
||||
return [];
|
||||
return discardBoth();
|
||||
}
|
||||
if (!imgResp.ok) {
|
||||
console.warn('[CCTV] Tarktee images download failed:', imgResp.status);
|
||||
return [];
|
||||
return discardBoth();
|
||||
}
|
||||
let locXml;
|
||||
let imgXml;
|
||||
const readAbort = new AbortController();
|
||||
try {
|
||||
[locXml, imgXml] = await Promise.all([
|
||||
readResponseTextCapped(
|
||||
locResp,
|
||||
TARKTEE_MAX_CATALOG_BYTES,
|
||||
readAbort.signal,
|
||||
),
|
||||
readResponseTextCapped(
|
||||
imgResp,
|
||||
TARKTEE_MAX_CATALOG_BYTES,
|
||||
readAbort.signal,
|
||||
),
|
||||
]);
|
||||
} catch (error) {
|
||||
readAbort.abort();
|
||||
await discardBoth();
|
||||
throw error;
|
||||
}
|
||||
const [locXml, imgXml] = await Promise.all([
|
||||
locResp.text(),
|
||||
imgResp.text(),
|
||||
]);
|
||||
const locations = parseTarkteeDatexLocations(locXml);
|
||||
const images = parseTarkteeDatexImages(imgXml);
|
||||
if (!locations.size || !images.size) {
|
||||
@@ -1224,6 +1325,7 @@ export async function loadTarkteeSourcesFromDatex() {
|
||||
}
|
||||
|
||||
const cameras = [];
|
||||
const seen = new Set();
|
||||
for (const [locationId, loc] of locations.entries()) {
|
||||
const imageUrl = images.get(locationId);
|
||||
if (!imageUrl) continue;
|
||||
@@ -1235,6 +1337,8 @@ export async function loadTarkteeSourcesFromDatex() {
|
||||
const cameraId = numMatch
|
||||
? `ee-tarktee-${numMatch[1]}`
|
||||
: `ee-tarktee-${locationId}`;
|
||||
if (seen.has(cameraId)) continue;
|
||||
seen.add(cameraId);
|
||||
cameras.push({
|
||||
id: cameraId,
|
||||
name: loc.name,
|
||||
@@ -2386,14 +2490,11 @@ export function vegvesenCameraToSource(feature) {
|
||||
fovDeg: 44,
|
||||
rangeM: 145,
|
||||
mountHeightM: 8,
|
||||
// KNOWN LIMITATION: the feed's coordinates are 2D, so this is one flat
|
||||
// prior for the whole country, while cameras run from sea level to
|
||||
// mountain passes near 1,000 m (Haukelifjell, Sjonfjellet, Rugeldalen).
|
||||
// The point-height prior and the client's one-shot ground snap correct it
|
||||
// where they resolve; on a stack where neither does (no 3D tiles), a pass
|
||||
// camera stays hundreds of metres below the terrain, the same risk the
|
||||
// Caltrans pack documents. A per-camera height (e.g. Kartverket's keyless
|
||||
// point-height API) would remove it.
|
||||
// The feed's coordinates are 2D. The loader replaces this flat prior with
|
||||
// Kartverket's terrain height for each camera (applyVegvesenGroundHeights);
|
||||
// it only survives when that lookup fails, and then a mountain-pass camera
|
||||
// can sit below the terrain on a stack without 3D tiles, the same risk the
|
||||
// Caltrans pack documents.
|
||||
groundElevationM: 150,
|
||||
feedType: videoUrl ? 'hls' : 'image',
|
||||
url: videoUrl || imageUrl,
|
||||
@@ -2413,6 +2514,7 @@ export function vegvesenCameraToSource(feature) {
|
||||
* @returns {Promise<Array<object>>} Normalized camera source objects.
|
||||
*/
|
||||
export async function loadVegvesenSourcesFromOpenData() {
|
||||
const startedAt = Date.now();
|
||||
try {
|
||||
const endpoint = process.env.CCTV_VEGVESEN_URL || DEFAULT_VEGVESEN_CCTV_URL;
|
||||
const resp = await fetch(endpoint, {
|
||||
@@ -2458,6 +2560,12 @@ export async function loadVegvesenSourcesFromOpenData() {
|
||||
? Math.max(8, Math.min(1000, Math.floor(maxRaw)))
|
||||
: DEFAULT_VEGVESEN_MAX_SOURCES;
|
||||
const prioritized = prioritizeSources(cameras, maxCount, NORWAY_ANCHORS);
|
||||
await applyVegvesenGroundHeights(prioritized, {
|
||||
waitMs: Math.max(
|
||||
0,
|
||||
KARTVERKET_CATALOG_WAIT_MS - (Date.now() - startedAt),
|
||||
),
|
||||
});
|
||||
console.log(
|
||||
`[CCTV] Loaded Vegvesen camera sources: ${cameras.length} (using nearest ${prioritized.length})`,
|
||||
);
|
||||
@@ -2471,6 +2579,322 @@ export async function loadVegvesenSourcesFromOpenData() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Kartverket answers by rounded position: `{z, expires}`, where z is metres or
|
||||
* null for "no height there". Heights never expire (camera positions barely
|
||||
* change); nulls expire after KARTVERKET_NULL_TTL_MS. The 15-minute catalog
|
||||
* refresh therefore only asks about new or moved cameras. Bounded well above
|
||||
* the ~900 cameras the pack can load.
|
||||
*/
|
||||
const kartverketHeightCache = new Map();
|
||||
const KARTVERKET_CACHE_LIMIT = 5000;
|
||||
/** The lookup in flight, so overlapping catalog loads never start a second. */
|
||||
let kartverketPending = null;
|
||||
/** No lookup starts before this time (epoch ms) after a transport failure. */
|
||||
let kartverketBackoffUntil = 0;
|
||||
/** Bumped by clearKartverketHeightCache, so a lookup started before a clear
|
||||
* can no longer write to the cache, the backoff or the pending slot. */
|
||||
let kartverketGeneration = 0;
|
||||
|
||||
/** Cache key for one position, at the ~0.1 m precision the request sends. */
|
||||
const kartverketKey = (lon, lat) => `${lon.toFixed(6)},${lat.toFixed(6)}`;
|
||||
|
||||
/** Statuses that say the service, not the request, is failing. */
|
||||
const isKartverketTransportStatus = (status) =>
|
||||
status === 408 || status === 429 || status >= 500;
|
||||
|
||||
/** Forget cached heights, the backoff and any lookup in flight. Tests only. */
|
||||
export function clearKartverketHeightCache() {
|
||||
kartverketGeneration += 1;
|
||||
kartverketHeightCache.clear();
|
||||
kartverketPending = null;
|
||||
kartverketBackoffUntil = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* How long to back off after a failed request: the Retry-After the service
|
||||
* sent (seconds or an HTTP date), else the default, bounded to [1 min, 1 h].
|
||||
*
|
||||
* @param {?string} retryAfter - The Retry-After header, if any.
|
||||
* @param {number} now - Epoch ms.
|
||||
* @returns {number} Milliseconds.
|
||||
*/
|
||||
export function kartverketBackoffMs(retryAfter, now = Date.now()) {
|
||||
let ms = KARTVERKET_FAILURE_BACKOFF_MS;
|
||||
const text = String(retryAfter ?? '').trim();
|
||||
if (/^\d+$/.test(text)) ms = Number(text) * 1000;
|
||||
else if (text && Number.isFinite(Date.parse(text))) {
|
||||
ms = Date.parse(text) - now;
|
||||
}
|
||||
return Math.max(60 * 1000, Math.min(KARTVERKET_MAX_BACKOFF_MS, ms));
|
||||
}
|
||||
|
||||
/**
|
||||
* Terrain heights above sea level (NN2000) from Kartverket's keyless
|
||||
* point-height API, KARTVERKET_MAX_POINTS points per request. Each answer is
|
||||
* matched back by the coordinates the API echoes, and only for points that
|
||||
* request asked about, then reported through `into.set(key, z)` as its
|
||||
* request lands, so a caller that stops waiting keeps what has arrived. `z` is
|
||||
* metres, or null when the API has no usable height there. A point a 200
|
||||
* answer leaves out is reported as null too, so it is not asked again on
|
||||
* every refresh.
|
||||
*
|
||||
* Over water `/punkt` returns the depth of the sea floor (`datakilde:
|
||||
* dybdekurver`, `terreng: Havflate`; −547 m under Nordhordlandsbrua). Either
|
||||
* marker counts as 0 m, the water surface. For the ~14 bridge cameras that is
|
||||
* below the deck, but far closer than the sea floor or a flat prior.
|
||||
*
|
||||
* The first transport failure (a 408, 429 or 5xx, a network error, a request
|
||||
* past KARTVERKET_REQUEST_TIMEOUT_MS, an unreadable or oversized body, or the
|
||||
* shared deadline) stops the lookup: no further requests start, and the
|
||||
* failure is returned so the caller can back off. Points in failed requests
|
||||
* are not reported, so they are asked again later. Any other 4xx rejects that
|
||||
* request's points, not the service: they are reported as null and the lookup
|
||||
* carries on.
|
||||
*
|
||||
* @param {Array<{lon:number, lat:number}>} points
|
||||
* @param {{into?: {set: Function}, timeoutMs?: number, requestTimeoutMs?: number}} [options]
|
||||
* @returns {Promise<{heights: Map<string, ?number>, failure: ?{status: ?number, retryAfter: ?string, deadline: boolean}}>}
|
||||
* `heights` is `into` when it is a Map, else a Map of what was reported.
|
||||
* `failure.deadline` is true when only the shared deadline ran out.
|
||||
*/
|
||||
export async function fetchKartverketHeights(
|
||||
points,
|
||||
{
|
||||
into = new Map(),
|
||||
timeoutMs = KARTVERKET_LOOKUP_TIMEOUT_MS,
|
||||
requestTimeoutMs = KARTVERKET_REQUEST_TIMEOUT_MS,
|
||||
} = {},
|
||||
) {
|
||||
const heights = into instanceof Map ? into : new Map();
|
||||
const report = (key, z) => {
|
||||
if (heights !== into) heights.set(key, z);
|
||||
into.set(key, z);
|
||||
};
|
||||
const unique = Array.from(
|
||||
new Map(
|
||||
(points || [])
|
||||
.filter((p) => isLikelyNorwayCoordinate(p?.lat, p?.lon))
|
||||
.map((p) => [kartverketKey(p.lon, p.lat), p]),
|
||||
).values(),
|
||||
);
|
||||
let failure = null;
|
||||
if (!unique.length) return { heights, failure };
|
||||
const batches = [];
|
||||
for (let i = 0; i < unique.length; i += KARTVERKET_MAX_POINTS) {
|
||||
batches.push(unique.slice(i, i + KARTVERKET_MAX_POINTS));
|
||||
}
|
||||
const deadline = AbortSignal.timeout(timeoutMs);
|
||||
const fetchBatch = async (batch) => {
|
||||
const asked = new Set(batch.map((p) => kartverketKey(p.lon, p.lat)));
|
||||
const url = new URL(KARTVERKET_HEIGHT_URL);
|
||||
url.searchParams.set(
|
||||
'punkter',
|
||||
JSON.stringify(
|
||||
batch.map((p) => [Number(p.lon.toFixed(6)), Number(p.lat.toFixed(6))]),
|
||||
),
|
||||
);
|
||||
url.searchParams.set('koordsys', '4258');
|
||||
const resp = await fetch(url, {
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'User-Agent': KARTVERKET_USER_AGENT,
|
||||
},
|
||||
redirect: 'error',
|
||||
signal: AbortSignal.any([
|
||||
deadline,
|
||||
AbortSignal.timeout(requestTimeoutMs),
|
||||
]),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
try {
|
||||
await resp.body?.cancel();
|
||||
} catch {
|
||||
/* no-op */
|
||||
}
|
||||
if (!isKartverketTransportStatus(resp.status)) {
|
||||
console.warn(
|
||||
`[CCTV] Kartverket rejected ${asked.size} points: HTTP ${resp.status}`,
|
||||
);
|
||||
for (const key of asked) report(key, null);
|
||||
return;
|
||||
}
|
||||
const error = new Error(`HTTP ${resp.status}`);
|
||||
error.status = resp.status;
|
||||
error.retryAfter = resp.headers.get('retry-after');
|
||||
throw error;
|
||||
}
|
||||
const payload = await readResponseJsonCapped(
|
||||
resp,
|
||||
KARTVERKET_MAX_RESPONSE_BYTES,
|
||||
);
|
||||
for (const point of Array.isArray(payload?.punkter)
|
||||
? payload.punkter
|
||||
: []) {
|
||||
const x = toFiniteNumber(point?.x);
|
||||
const y = toFiniteNumber(point?.y);
|
||||
if (!Number.isFinite(x) || !Number.isFinite(y)) continue;
|
||||
const key = kartverketKey(x, y);
|
||||
// Only the points this request asked about: an odd answer can neither
|
||||
// grow the cache nor overwrite another camera.
|
||||
if (!asked.has(key)) continue;
|
||||
asked.delete(key);
|
||||
// `z: null` means no height there; Number(null) would read 0.
|
||||
const raw = point?.z;
|
||||
let z =
|
||||
raw === null || raw === undefined || raw === ''
|
||||
? NaN
|
||||
: toFiniteNumber(raw);
|
||||
if (
|
||||
Number.isFinite(z) &&
|
||||
(point?.datakilde === 'dybdekurver' || point?.terreng === 'Havflate')
|
||||
) {
|
||||
z = 0;
|
||||
}
|
||||
const usable =
|
||||
Number.isFinite(z) &&
|
||||
z >= KARTVERKET_HEIGHT_RANGE_M.min &&
|
||||
z <= KARTVERKET_HEIGHT_RANGE_M.max;
|
||||
report(key, usable ? z : null);
|
||||
}
|
||||
// A 200 that leaves a point out has no height for it either.
|
||||
for (const key of asked) report(key, null);
|
||||
};
|
||||
let next = 0;
|
||||
const worker = async () => {
|
||||
while (next < batches.length && !failure) {
|
||||
const batch = batches[next++];
|
||||
try {
|
||||
await fetchBatch(batch);
|
||||
} catch (error) {
|
||||
if (!failure) {
|
||||
failure = {
|
||||
status: Number.isFinite(error?.status) ? error.status : null,
|
||||
retryAfter: error?.retryAfter ?? null,
|
||||
deadline: deadline.aborted,
|
||||
};
|
||||
console.warn(
|
||||
'[CCTV] Kartverket height lookup failed:',
|
||||
error?.message || error,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
await Promise.all(
|
||||
Array.from(
|
||||
{ length: Math.min(KARTVERKET_CONCURRENCY, batches.length) },
|
||||
worker,
|
||||
),
|
||||
);
|
||||
return { heights, failure };
|
||||
}
|
||||
|
||||
/**
|
||||
* Give each Vegvesen camera its own terrain height as the ground prior, in
|
||||
* place. Waits at most `waitMs` for positions it has no answer for, and only
|
||||
* when more than KARTVERKET_WAIT_MISSING_SHARE of the cameras are missing
|
||||
* (a cold cache); a few new cameras or expired misses are looked up in the
|
||||
* background. A lookup still running when the wait ends keeps filling the
|
||||
* cache for the next catalog refresh. Cameras without a height keep the flat
|
||||
* prior. After a transport failure no lookup starts until the backoff ends,
|
||||
* which outlasts the 15-minute catalog refresh, so a failing Kartverket costs
|
||||
* one attempt per backoff, not one per refresh. A lookup cut short only by its
|
||||
* shared deadline is resumed on the next refresh without a backoff. Cameras
|
||||
* without finite coordinates are skipped. Never throws.
|
||||
* CCTV_VEGVESEN_HEIGHTS=0 turns it off.
|
||||
*
|
||||
* @param {Array<object>} cameras - Sources from vegvesenCameraToSource().
|
||||
* @param {{waitMs?: number, now?: () => number, timeoutMs?: number}} [options]
|
||||
* `timeoutMs` is the lookup's shared deadline.
|
||||
* @returns {Promise<number>} How many cameras got a terrain height.
|
||||
*/
|
||||
export async function applyVegvesenGroundHeights(
|
||||
cameras,
|
||||
{
|
||||
waitMs = KARTVERKET_CATALOG_WAIT_MS,
|
||||
now = Date.now,
|
||||
timeoutMs = KARTVERKET_LOOKUP_TIMEOUT_MS,
|
||||
} = {},
|
||||
) {
|
||||
if (String(process.env.CCTV_VEGVESEN_HEIGHTS || '1').trim() === '0') return 0;
|
||||
const list = (Array.isArray(cameras) ? cameras : []).filter(
|
||||
(c) => Number.isFinite(c?.lon) && Number.isFinite(c?.lat),
|
||||
);
|
||||
const startedAt = now();
|
||||
const missing = list.filter((c) => {
|
||||
const entry = kartverketHeightCache.get(kartverketKey(c.lon, c.lat));
|
||||
return !entry || entry.expires <= startedAt;
|
||||
});
|
||||
if (
|
||||
missing.length &&
|
||||
!kartverketPending &&
|
||||
startedAt >= kartverketBackoffUntil
|
||||
) {
|
||||
if (kartverketHeightCache.size + missing.length > KARTVERKET_CACHE_LIMIT) {
|
||||
kartverketHeightCache.clear();
|
||||
}
|
||||
const generation = kartverketGeneration;
|
||||
const current = () => generation === kartverketGeneration;
|
||||
const sink = {
|
||||
set: (key, z) => {
|
||||
if (!current()) return;
|
||||
kartverketHeightCache.set(key, {
|
||||
z,
|
||||
expires: z === null ? now() + KARTVERKET_NULL_TTL_MS : Infinity,
|
||||
});
|
||||
},
|
||||
};
|
||||
const pending = fetchKartverketHeights(missing, { into: sink, timeoutMs })
|
||||
.then(({ failure }) => {
|
||||
if (failure && !failure.deadline && current()) {
|
||||
kartverketBackoffUntil =
|
||||
now() + kartverketBackoffMs(failure.retryAfter, now());
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
if (current()) {
|
||||
kartverketBackoffUntil = now() + KARTVERKET_FAILURE_BACKOFF_MS;
|
||||
}
|
||||
console.warn(
|
||||
'[CCTV] Kartverket height lookup error:',
|
||||
error?.message || error,
|
||||
);
|
||||
})
|
||||
.finally(() => {
|
||||
if (kartverketPending === pending) kartverketPending = null;
|
||||
});
|
||||
kartverketPending = pending;
|
||||
}
|
||||
const cold = missing.length > list.length * KARTVERKET_WAIT_MISSING_SHARE;
|
||||
if (cold && kartverketPending && waitMs > 0) {
|
||||
let timer;
|
||||
await Promise.race([
|
||||
kartverketPending,
|
||||
new Promise((resolve) => {
|
||||
timer = setTimeout(resolve, waitMs);
|
||||
timer.unref?.();
|
||||
}),
|
||||
]);
|
||||
clearTimeout(timer);
|
||||
}
|
||||
let applied = 0;
|
||||
for (const camera of list) {
|
||||
const z = kartverketHeightCache.get(
|
||||
kartverketKey(camera.lon, camera.lat),
|
||||
)?.z;
|
||||
if (!Number.isFinite(z)) continue;
|
||||
camera.groundElevationM = Math.round(z * 10) / 10;
|
||||
applied += 1;
|
||||
}
|
||||
if (list.length) {
|
||||
console.log(
|
||||
`[CCTV] Kartverket ground heights: ${applied}/${list.length} Vegvesen cameras`,
|
||||
);
|
||||
}
|
||||
return applied;
|
||||
}
|
||||
|
||||
const NYC_BOROUGHS = new Set([
|
||||
'Manhattan',
|
||||
'Brooklyn',
|
||||
@@ -2784,3 +3208,203 @@ export async function loadTorontoSourcesFromOpenData() {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** FL511 DIRECTION codes as headings; "NOT DIRECTIONAL" has none. */
|
||||
const FLORIDA_DIRECTION_HEADINGS = Object.freeze({
|
||||
N: 0,
|
||||
E: 90,
|
||||
S: 180,
|
||||
W: 270,
|
||||
});
|
||||
|
||||
/**
|
||||
* Stable camera id from a frame URL. FL511's ID column repeats across regions
|
||||
* (ID 1220 is both an I-75 and an I-95 camera), so the image channel is the
|
||||
* key, as Calgary keys on its frame filename. Falls back to a path slug.
|
||||
*
|
||||
* @param {string} imageUrl - A host-pinned FL511 frame URL.
|
||||
* @returns {?string} Provider-stable id, or null when underivable.
|
||||
*/
|
||||
export function floridaCameraId(imageUrl) {
|
||||
let path;
|
||||
try {
|
||||
path = new URL(String(imageUrl ?? '')).pathname;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const channel = /\/chan-(\d+)_[a-z]+\.jpg$/i.exec(path);
|
||||
if (channel) return `fl-${channel[1]}`;
|
||||
const slug = path
|
||||
.replace(/^\/+|\.[a-z0-9]+$/gi, '')
|
||||
.replace(/[^a-zA-Z0-9]+/g, '-')
|
||||
.toLowerCase();
|
||||
return slug ? `fl-${slug}` : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* One FL511 ArcGIS feature -> one catalog source, or null. DIRECTION is a
|
||||
* one-letter code the free-text parser won't read, so a table maps it.
|
||||
*
|
||||
* @param {object} feature - ArcGIS feature from the FL511 camera layer.
|
||||
* @returns {?object}
|
||||
*/
|
||||
export function floridaCameraToSource(feature) {
|
||||
const a = feature?.attributes;
|
||||
if (!a || typeof a !== 'object') return null;
|
||||
const lat = toFiniteNumber(a.LATITUDE);
|
||||
const lon = toFiniteNumber(a.LONGITUDE);
|
||||
if (!isLikelyFloridaCoordinate(lat, lon)) return null;
|
||||
|
||||
const imageUrl = String(a.IMAGE || '');
|
||||
let image;
|
||||
try {
|
||||
image = new URL(imageUrl);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
// Official image hosts only, HTTPS, no embedded credentials.
|
||||
if (
|
||||
image.protocol !== 'https:' ||
|
||||
!FLORIDA_IMAGE_HOSTS.includes(image.host) ||
|
||||
image.username ||
|
||||
image.password
|
||||
)
|
||||
return null;
|
||||
|
||||
const cameraId = floridaCameraId(imageUrl);
|
||||
if (!cameraId) return null;
|
||||
|
||||
const heading =
|
||||
FLORIDA_DIRECTION_HEADINGS[
|
||||
String(a.DIRECTION || '')
|
||||
.trim()
|
||||
.toUpperCase()
|
||||
];
|
||||
const hasHeading = Number.isFinite(heading);
|
||||
|
||||
return {
|
||||
id: cameraId,
|
||||
name: String(a.DESCRIPT || '').trim() || `FL511 ${cameraId.slice(3)}`,
|
||||
city: String(a.COUNTY ? `${a.COUNTY} County` : 'Florida'),
|
||||
cityId: 'florida',
|
||||
provider: 'FL511',
|
||||
lat,
|
||||
lon,
|
||||
headingDeg: hasHeading ? heading : fallbackHeadingFromId(cameraId),
|
||||
headingConfidence: hasHeading ? 'high' : 'low',
|
||||
pitchDeg: hasHeading ? -24 : -18,
|
||||
fovDeg: hasHeading ? 56 : 44,
|
||||
rangeM: hasHeading ? 210 : 145,
|
||||
mountHeightM: hasHeading ? 10 : 8,
|
||||
groundElevationM: 5, // Coastal prior; the client's ground snap corrects.
|
||||
feedType: 'image',
|
||||
url: imageUrl,
|
||||
snapshotUrl: imageUrl,
|
||||
sourceKind: 'fl511-open-data',
|
||||
license:
|
||||
'FL511 (FDOT), individual personal use only; other use needs FDOT written consent',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch FL511 (Florida DOT) cameras, keyless: an ArcGIS FeatureServer paged
|
||||
* up to 2,000 rows at a time, ordered by the layer's object id so offsets are
|
||||
* stable. Paging follows `exceededTransferLimit` and advances by the rows each
|
||||
* page returned, so a lower server-side max record count still reads the whole
|
||||
* layer. Frames are stills on the Divas Cloud hosts. A redirect on any page
|
||||
* drops the pack; any other failed page keeps the rows already read.
|
||||
*
|
||||
* @returns {Promise<Array<object>>} Normalized camera source objects.
|
||||
*/
|
||||
export async function loadFloridaSourcesFromOpenData() {
|
||||
try {
|
||||
const cameras = [];
|
||||
const seen = new Set();
|
||||
const pageSize = 2000;
|
||||
let offset = 0;
|
||||
// Page ceiling, in case the service ignores resultOffset.
|
||||
for (let page = 0; ; page += 1) {
|
||||
if (page >= FLORIDA_MAX_CATALOG_PAGES) {
|
||||
console.warn(
|
||||
`[CCTV] Florida catalog stopped at the ${FLORIDA_MAX_CATALOG_PAGES}-page ceiling; the catalog may be incomplete`,
|
||||
);
|
||||
break;
|
||||
}
|
||||
// OBJECTID_1 is this layer's object id field (a plain OBJECTID is a 400).
|
||||
const url = `${FLORIDA_CCTV_URL}?where=1%3D1&outFields=*&orderByFields=OBJECTID_1&f=json&resultRecordCount=${pageSize}&resultOffset=${offset}`;
|
||||
let resp;
|
||||
try {
|
||||
resp = await fetch(url, {
|
||||
headers: { Accept: 'application/json' },
|
||||
redirect: 'manual',
|
||||
signal: AbortSignal.timeout(CCTV_SOURCE_FETCH_TIMEOUT_MS),
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
'[CCTV] Florida catalog page failed:',
|
||||
error?.message || error,
|
||||
);
|
||||
break;
|
||||
}
|
||||
// Release the body on every path that will not read it.
|
||||
const discard = async () => {
|
||||
try {
|
||||
await resp.body?.cancel();
|
||||
} catch {
|
||||
/* no-op */
|
||||
}
|
||||
};
|
||||
if (resp.status >= 300 && resp.status < 400) {
|
||||
console.warn(
|
||||
'[CCTV] Florida catalog redirected; redirects are not followed',
|
||||
);
|
||||
await discard();
|
||||
return [];
|
||||
}
|
||||
if (!resp.ok) {
|
||||
console.warn('[CCTV] Florida camera download failed:', resp.status);
|
||||
await discard();
|
||||
break;
|
||||
}
|
||||
let body;
|
||||
try {
|
||||
body = await readResponseJsonCapped(resp, FLORIDA_MAX_CATALOG_BYTES);
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
'[CCTV] Florida catalog page unreadable:',
|
||||
error?.message || error,
|
||||
);
|
||||
break;
|
||||
}
|
||||
const feats = Array.isArray(body?.features) ? body.features : [];
|
||||
for (const feature of feats) {
|
||||
const camera = floridaCameraToSource(feature);
|
||||
if (!camera || seen.has(camera.id)) continue;
|
||||
seen.add(camera.id);
|
||||
cameras.push(camera);
|
||||
}
|
||||
// ArcGIS sets exceededTransferLimit while more rows remain. A page may
|
||||
// be shorter than pageSize when the layer's max record count is lower.
|
||||
if (body?.exceededTransferLimit !== true || feats.length === 0) break;
|
||||
offset += feats.length;
|
||||
}
|
||||
|
||||
const maxRaw = Number(
|
||||
process.env.CCTV_FLORIDA_MAX_SOURCES || DEFAULT_FLORIDA_MAX_SOURCES,
|
||||
);
|
||||
const maxCount = Number.isFinite(maxRaw)
|
||||
? Math.max(8, Math.min(4200, Math.floor(maxRaw)))
|
||||
: DEFAULT_FLORIDA_MAX_SOURCES;
|
||||
const prioritized = prioritizeSources(cameras, maxCount, FLORIDA_ANCHORS);
|
||||
console.log(
|
||||
`[CCTV] Loaded Florida camera sources: ${cameras.length} (using nearest ${prioritized.length})`,
|
||||
);
|
||||
return prioritized;
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
'[CCTV] Florida camera download error:',
|
||||
error?.message || error,
|
||||
);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user