refactor: enforce import directions and expose source factories

This commit is contained in:
Sameh Khamis committed 2026-09-14 21:12:01 -07:00
1 parent 704e0fcc51
commit cc960b0dbe
41 files changed
+1080 -417

No files matched your search

+2 -159
View File
@@ -1,52 +1,6 @@
import * as Cesium from 'cesium';
import {
QUERY_LIMIT,
EARTH_MEAN_RADIUS_M,
QUERY_SNAP_DEGREES,
} from './policy.js';
export function textTag(value) {
const t = String(value ?? '').trim();
return t || null;
}
export function numTag(value) {
// `Number('')` and `Number(' ')` are 0, not NaN — a blank or whitespace-only
// tag value must read as missing, never as a due-north bearing.
if (value == null) return null;
const trimmed = String(value).trim();
if (!trimmed) return null;
const n = Number(trimmed);
return Number.isFinite(n) ? n : null;
}
/**
* Snap a viewport box OUTWARD to the query grid, clamped to valid ranges.
* Pure so the reuse contract is pinnable.
* @param {{south:number,west:number,north:number,east:number}} box
* @param {number} [step=QUERY_SNAP_DEGREES]
*/
export function snapAlprBox(box, step = QUERY_SNAP_DEGREES) {
const down = (v) => Math.floor(v / step) * step;
const up = (v) => Math.ceil(v / step) * step;
return {
south: Math.max(-90, down(box.south)),
west: Math.max(-180, down(box.west)),
north: Math.min(90, up(box.north)),
east: Math.min(180, up(box.east)),
};
}
/** Whether `inner` lies entirely within `outer` (no dateline handling; the viewport gate already rejects east <= west). */
export function boxContains(outer, inner) {
if (!outer || !inner) return false;
return (
inner.south >= outer.south &&
inner.north <= outer.north &&
inner.west >= outer.west &&
inner.east <= outer.east
);
}
import { EARTH_MEAN_RADIUS_M } from './policy.js';
export * from './records.js';
/**
* Great-circle destination point — used only to draw the short
@@ -77,117 +31,6 @@ export function destinationPointDeg(latDeg, lonDeg, bearingDeg, distanceM) {
};
}
/**
* Backoff progression for the unavailable-state retry: 30 s, doubling to a
* 240 s ceiling. Pure so the progression is pinnable without booting the layer.
*/
export function alprRetryDelayMs(prevDelayMs) {
const RETRY_MIN_MS = 30000;
const RETRY_CEIL_MS = 240000;
if (!Number.isFinite(prevDelayMs) || prevDelayMs <= 0) return RETRY_MIN_MS;
return Math.min(prevDelayMs * 2, RETRY_CEIL_MS);
}
/**
* Whether a `surveillance:type` value denotes an ALPR camera. OSM allows
* semicolon multi-values (`camera;ALPR`) and mixed case, so an exact match
* would silently miss tagged cameras.
* @param {*} value - raw `surveillance:type` tag value
* @returns {boolean}
*/
export function isAlprSurveillanceType(value) {
return String(value ?? '')
.split(';')
.some((part) => part.trim().toUpperCase() === 'ALPR');
}
/** Map one raw Overpass node element to a plain camera record. Null for anything unusable. */
export function normalizeAlprNode(el) {
if (
!el ||
el.type !== 'node' ||
!Number.isSafeInteger(el.id) ||
el.id <= 0 ||
!Number.isFinite(el.lat) ||
Math.abs(el.lat) > 90 ||
!Number.isFinite(el.lon) ||
Math.abs(el.lon) > 180
)
return null;
const tags = el.tags || {};
if (!isAlprSurveillanceType(tags['surveillance:type'])) return null;
return {
id: `alpr:${el.id}`,
osmId: el.id,
latitude: el.lat,
longitude: el.lon,
operator: textTag(tags.operator),
manufacturer: textTag(tags.manufacturer),
cameraType: textTag(tags['camera:type']),
zone: textTag(tags['surveillance:zone']),
// Only numeric bearings are parsed; compass-word directions ("N"/"NE")
// are rare on this tag and simply render with no facing line.
directionDeg: normalizeDirection(
tags['camera:direction'] ?? tags.direction,
),
ref: textTag(tags.ref),
lastVerified: textTag(tags.check_date) || textTag(tags['survey:date']),
source: textTag(tags.source),
};
}
export function normalizeDirection(value) {
const degrees = numTag(value);
return degrees != null && degrees >= 0 && degrees <= 360
? degrees % 360
: null;
}
/**
* Overpass QL for the viewport. The tag match is a case-insensitive regex, not
* an exact `="ALPR"`, because OSM semicolon multi-values such as
* `surveillance:type=camera;ALPR` are common and an exact match drops them.
* Exported so the query shape is pinnable.
*/
export function buildOverpassQuery(south, west, north, east) {
return (
`[out:json][timeout:20];node["man_made"="surveillance"]["surveillance:type"~"(^|;)\\s*ALPR\\s*(;|$)",i]` +
`(${south},${west},${north},${east});out body ${QUERY_LIMIT};`
);
}
/** Validate a source-independent snapshot before replacing the accepted display. */
export function validateAlprSnapshot(snapshot) {
if (
!Array.isArray(snapshot?.records) ||
typeof snapshot.stale !== 'boolean' ||
typeof snapshot.saturated !== 'boolean'
) {
throw new TypeError('Camera source returned an invalid snapshot');
}
const ids = new Set();
for (const record of snapshot.records) {
if (
!record ||
typeof record.id !== 'string' ||
!record.id.trim() ||
ids.has(record.id) ||
!Number.isFinite(record.latitude) ||
Math.abs(record.latitude) > 90 ||
!Number.isFinite(record.longitude) ||
Math.abs(record.longitude) > 180 ||
(record.directionDeg != null &&
(!Number.isFinite(record.directionDeg) ||
record.directionDeg < 0 ||
record.directionDeg >= 360))
) {
throw new TypeError('Camera source returned an invalid record');
}
ids.add(record.id);
}
return snapshot;
}
/** Build a linked attribution from plain text and an HTTPS URL only. */
export function alprCreditMarkup(attribution) {
if (!attribution?.text || !attribution?.href) return null;
+155
View File
@@ -0,0 +1,155 @@
import { QUERY_LIMIT, QUERY_SNAP_DEGREES } from './policy.js';
export function textTag(value) {
const t = String(value ?? '').trim();
return t || null;
}
export function numTag(value) {
// `Number('')` and `Number(' ')` are 0, not NaN — a blank or whitespace-only
// tag value must read as missing, never as a due-north bearing.
if (value == null) return null;
const trimmed = String(value).trim();
if (!trimmed) return null;
const n = Number(trimmed);
return Number.isFinite(n) ? n : null;
}
/**
* Snap a viewport box OUTWARD to the query grid, clamped to valid ranges.
* Pure so the reuse contract is pinnable.
* @param {{south:number,west:number,north:number,east:number}} box
* @param {number} [step=QUERY_SNAP_DEGREES]
*/
export function snapAlprBox(box, step = QUERY_SNAP_DEGREES) {
const down = (v) => Math.floor(v / step) * step;
const up = (v) => Math.ceil(v / step) * step;
return {
south: Math.max(-90, down(box.south)),
west: Math.max(-180, down(box.west)),
north: Math.min(90, up(box.north)),
east: Math.min(180, up(box.east)),
};
}
/** Whether `inner` lies entirely within `outer` (no dateline handling; the viewport gate already rejects east <= west). */
export function boxContains(outer, inner) {
if (!outer || !inner) return false;
return (
inner.south >= outer.south &&
inner.north <= outer.north &&
inner.west >= outer.west &&
inner.east <= outer.east
);
}
/**
* Backoff progression for the unavailable-state retry: 30 s, doubling to a
* 240 s ceiling. Pure so the progression is pinnable without booting the layer.
*/
export function alprRetryDelayMs(prevDelayMs) {
const RETRY_MIN_MS = 30000;
const RETRY_CEIL_MS = 240000;
if (!Number.isFinite(prevDelayMs) || prevDelayMs <= 0) return RETRY_MIN_MS;
return Math.min(prevDelayMs * 2, RETRY_CEIL_MS);
}
/**
* Whether a `surveillance:type` value denotes an ALPR camera. OSM allows
* semicolon multi-values (`camera;ALPR`) and mixed case, so an exact match
* would silently miss tagged cameras.
* @param {*} value - raw `surveillance:type` tag value
* @returns {boolean}
*/
export function isAlprSurveillanceType(value) {
return String(value ?? '')
.split(';')
.some((part) => part.trim().toUpperCase() === 'ALPR');
}
/** Map one raw Overpass node element to a plain camera record. Null for anything unusable. */
export function normalizeAlprNode(el) {
if (
!el ||
el.type !== 'node' ||
!Number.isSafeInteger(el.id) ||
el.id <= 0 ||
!Number.isFinite(el.lat) ||
Math.abs(el.lat) > 90 ||
!Number.isFinite(el.lon) ||
Math.abs(el.lon) > 180
)
return null;
const tags = el.tags || {};
if (!isAlprSurveillanceType(tags['surveillance:type'])) return null;
return {
id: `alpr:${el.id}`,
osmId: el.id,
latitude: el.lat,
longitude: el.lon,
operator: textTag(tags.operator),
manufacturer: textTag(tags.manufacturer),
cameraType: textTag(tags['camera:type']),
zone: textTag(tags['surveillance:zone']),
// Only numeric bearings are parsed; compass-word directions ("N"/"NE")
// are rare on this tag and simply render with no facing line.
directionDeg: normalizeDirection(
tags['camera:direction'] ?? tags.direction,
),
ref: textTag(tags.ref),
lastVerified: textTag(tags.check_date) || textTag(tags['survey:date']),
source: textTag(tags.source),
};
}
export function normalizeDirection(value) {
const degrees = numTag(value);
return degrees != null && degrees >= 0 && degrees <= 360
? degrees % 360
: null;
}
/**
* Overpass QL for the viewport. The tag match is a case-insensitive regex, not
* an exact `="ALPR"`, because OSM semicolon multi-values such as
* `surveillance:type=camera;ALPR` are common and an exact match drops them.
* Exported so the query shape is pinnable.
*/
export function buildOverpassQuery(south, west, north, east) {
return (
`[out:json][timeout:20];node["man_made"="surveillance"]["surveillance:type"~"(^|;)\\s*ALPR\\s*(;|$)",i]` +
`(${south},${west},${north},${east});out body ${QUERY_LIMIT};`
);
}
/** Validate a source-independent snapshot before replacing the accepted display. */
export function validateAlprSnapshot(snapshot) {
if (
!Array.isArray(snapshot?.records) ||
typeof snapshot.stale !== 'boolean' ||
typeof snapshot.saturated !== 'boolean'
) {
throw new TypeError('Camera source returned an invalid snapshot');
}
const ids = new Set();
for (const record of snapshot.records) {
if (
!record ||
typeof record.id !== 'string' ||
!record.id.trim() ||
ids.has(record.id) ||
!Number.isFinite(record.latitude) ||
Math.abs(record.latitude) > 90 ||
!Number.isFinite(record.longitude) ||
Math.abs(record.longitude) > 180 ||
(record.directionDeg != null &&
(!Number.isFinite(record.directionDeg) ||
record.directionDeg < 0 ||
record.directionDeg >= 360))
) {
throw new TypeError('Camera source returned an invalid record');
}
ids.add(record.id);
}
return snapshot;
}
+3 -1
View File
@@ -4,7 +4,7 @@ import {
QUERY_SNAP_DEGREES,
QUERY_LIMIT,
} from './policy.js';
import { buildOverpassQuery, normalizeAlprNode } from './model.js';
import { buildOverpassQuery, normalizeAlprNode } from './records.js';
/** Construct the bounded OSM request adapter without starting a request. */
export function createOverpassAlprSource({
fetchImpl = (...args) => globalThis.fetch(...args),
@@ -80,3 +80,5 @@ export function createOverpassAlprSource({
},
};
}
export { buildOverpassQuery } from './records.js';
+7 -10
View File
@@ -1,3 +1,10 @@
export {
FRAME_ENDPOINT,
SOURCE_ENDPOINT,
HEALTH_ENDPOINT,
MEDIA_ENDPOINT,
ACTIVE_FRAME_REFRESH_MS,
} from './sourcePolicy.js';
import * as Cesium from 'cesium';
import { CCTV_CARD_FETCH_BURST_SPACING_MS } from '../../data/cctvCards.js';
import { CCTV_AMBIENT_CARD_MAX } from '../../data/cctvLod.js';
@@ -6,14 +13,6 @@ import { CCTV_AMBIENT_CARD_MAX } from '../../data/cctvLod.js';
// API endpoints
// ---------------------------------------------------------------------------
export const FRAME_ENDPOINT = '/api/cctv/frame';
export const SOURCE_ENDPOINT = '/api/cctv/sources';
export const HEALTH_ENDPOINT = '/api/cctv/health';
export const MEDIA_ENDPOINT = '/api/cctv/media';
// ---------------------------------------------------------------------------
// Timing and geometry constants
// ---------------------------------------------------------------------------
@@ -26,8 +25,6 @@ export const MAX_AUTO_HOP_SEC = 90;
export const HEALTH_SYNC_INTERVAL_MS = 7000;
export const ACTIVE_FRAME_REFRESH_MS = 10000;
export const IDLE_FRAME_REFRESH_MS = 60000;
export const PROJECTION_ACTIVE_REFRESH_MS = 10000;
+1 -1
View File
@@ -2,7 +2,7 @@ import {
ACTIVE_FRAME_REFRESH_MS,
FRAME_ENDPOINT,
MEDIA_ENDPOINT,
} from './policy.js';
} from './sourcePolicy.js';
function safeNumber(value, fallback = NaN) {
const n = Number(value);
return Number.isFinite(n) ? n : fallback;
+9
View File
@@ -0,0 +1,9 @@
export const FRAME_ENDPOINT = '/api/cctv/frame';
export const SOURCE_ENDPOINT = '/api/cctv/sources';
export const HEALTH_ENDPOINT = '/api/cctv/health';
export const MEDIA_ENDPOINT = '/api/cctv/media';
export const ACTIVE_FRAME_REFRESH_MS = 10000;
+1 -49
View File
@@ -99,52 +99,4 @@ export function mapAnalystRecord(raw, index = 0) {
};
}
/** Validate a complete feed before replacing the last good earthquake snapshot. */
export function normalizeEarthquakeSnapshot(geojson) {
if (!Array.isArray(geojson?.features)) return null;
const rows = [];
const ids = new Set();
for (const [index, feature] of geojson.features.entries()) {
const coordinates = feature?.geometry?.coordinates;
const properties = feature?.properties;
if (
!Array.isArray(coordinates) ||
coordinates.length < 2 ||
!properties ||
typeof properties !== 'object' ||
Array.isArray(properties) ||
(feature.geometry.type != null && feature.geometry.type !== 'Point')
)
return null;
const [lon, lat, depthKm] = coordinates;
const mag = properties.mag;
if (
!Number.isFinite(lon) ||
Math.abs(lon) > 180 ||
!Number.isFinite(lat) ||
Math.abs(lat) > 90 ||
(depthKm != null && !Number.isFinite(depthKm)) ||
(mag != null && (!Number.isFinite(mag) || mag > 10))
)
return null;
// A missing magnitude cannot establish that this event meets M2.5+.
if (mag == null || mag < 2.5) continue;
const stableId =
feature.id == null || feature.id === ''
? `event-${index + 1}`
: String(feature.id);
if (ids.has(stableId)) return null;
ids.add(stableId);
rows.push({
stableId,
usgsId: feature.id ?? null,
lon,
lat,
depthKm: depthKm ?? null,
mag,
place: typeof properties.place === 'string' ? properties.place : null,
time: Number.isFinite(properties.time) ? properties.time : null,
});
}
return rows;
}
export { normalizeEarthquakeSnapshot } from './records.js';
+49
View File
@@ -0,0 +1,49 @@
/** Validate a complete feed before replacing the last good earthquake snapshot. */
export function normalizeEarthquakeSnapshot(geojson) {
if (!Array.isArray(geojson?.features)) return null;
const rows = [];
const ids = new Set();
for (const [index, feature] of geojson.features.entries()) {
const coordinates = feature?.geometry?.coordinates;
const properties = feature?.properties;
if (
!Array.isArray(coordinates) ||
coordinates.length < 2 ||
!properties ||
typeof properties !== 'object' ||
Array.isArray(properties) ||
(feature.geometry.type != null && feature.geometry.type !== 'Point')
)
return null;
const [lon, lat, depthKm] = coordinates;
const mag = properties.mag;
if (
!Number.isFinite(lon) ||
Math.abs(lon) > 180 ||
!Number.isFinite(lat) ||
Math.abs(lat) > 90 ||
(depthKm != null && !Number.isFinite(depthKm)) ||
(mag != null && (!Number.isFinite(mag) || mag > 10))
)
return null;
// A missing magnitude cannot establish that this event meets M2.5+.
if (mag == null || mag < 2.5) continue;
const stableId =
feature.id == null || feature.id === ''
? `event-${index + 1}`
: String(feature.id);
if (ids.has(stableId)) return null;
ids.add(stableId);
rows.push({
stableId,
usgsId: feature.id ?? null,
lon,
lat,
depthKm: depthKm ?? null,
mag,
place: typeof properties.place === 'string' ? properties.place : null,
time: Number.isFinite(properties.time) ? properties.time : null,
});
}
return rows;
}
+1 -1
View File
@@ -1,4 +1,4 @@
import { normalizeEarthquakeSnapshot } from './model.js';
import { normalizeEarthquakeSnapshot } from './records.js';
const API_URL =
'https://earthquake.usgs.gov/earthquakes/feed/v1.0/summary/all_day.geojson';
/** Request and validate a complete USGS snapshot before it can replace displayed events. */