bb01e9a06c
## Summary - **ADS-B & AIS:** OpenSky and AISStream OSINT feeds upsert into the CoT store; tactical tracks still arrive via adsbcot/aiscot on `:8089`. Map clients subscribe via `GET /api/cot/stream` (SSE) with viewport bbox filtering and Air / Surface / Team layer toggles. - **ALPR (Flock/OSM):** Toggleable license-plate reader layer sourced from OpenStreetMap, with SQLite cache, Overpass fallback, tiled viewport fetching, and clustered markers with direction cones. - **Map performance:** Ring-based tile selection (fixes zoom-out crash), immutable tile cache, incremental marker sync, split cluster load/query, and padded SSE bbox to reduce reconnect churn. ## Docs - `docs/tracking.md` — ADS-B/AIS accuracy tiers, freshness, self-hosted receivers, optional OSINT API keys - `docs/map-and-cameras.md` — ALPR layer and map behavior updates --------- Co-authored-by: Madison Grubb <madison@elastiflow.com> Reviewed-on: #36
264 lines
8.2 KiB
JavaScript
264 lines
8.2 KiB
JavaScript
import { cameraToFeature, featureCollection } from './alprGeo.js'
|
|
|
|
const OVERPASS_URL = 'https://overpass.deflock.org/api/interpreter'
|
|
const MAX_BBOX_DEGREES = 0.5
|
|
const CACHE_TTL_MS = 86_400_000
|
|
const TILE_SCALE = 10
|
|
|
|
function httpError(statusCode, message) {
|
|
const err = new Error(message)
|
|
err.statusCode = statusCode
|
|
return err
|
|
}
|
|
|
|
/**
|
|
* @param {unknown} tags
|
|
* @returns {Record<string, string>} String OSM tags only
|
|
*/
|
|
function normalizeTags(tags) {
|
|
if (!tags || typeof tags !== 'object') return {}
|
|
return Object.fromEntries(
|
|
Object.entries(/** @type {Record<string, unknown>} */ (tags))
|
|
.filter(([, v]) => typeof v === 'string')
|
|
.map(([k, v]) => [k, /** @type {string} */ (v)]),
|
|
)
|
|
}
|
|
|
|
/**
|
|
* @param {unknown} element
|
|
* @returns {{ osmId: number, lat: number, lng: number, manufacturer: string | null, direction: number | null, tags: Record<string, string> } | null} Parsed camera or null
|
|
*/
|
|
export function parseOverpassElement(element) {
|
|
if (!element || typeof element !== 'object') return null
|
|
const el = /** @type {Record<string, unknown>} */ (element)
|
|
if (el.type !== 'node' || typeof el.id !== 'number') return null
|
|
const lat = Number(el.lat)
|
|
const lng = Number(el.lon ?? el.lng)
|
|
if (!Number.isFinite(lat) || !Number.isFinite(lng)) return null
|
|
const tags = normalizeTags(el.tags)
|
|
const directionRaw = tags['camera:direction'] ?? tags.direction
|
|
const direction = directionRaw != null && directionRaw !== '' ? Number(directionRaw) : null
|
|
const fovRaw = tags['camera:angle'] ?? tags['surveillance:angle']
|
|
const fov = fovRaw != null && fovRaw !== '' ? Number(fovRaw) : null
|
|
return {
|
|
osmId: el.id,
|
|
lat,
|
|
lng,
|
|
manufacturer: tags.manufacturer ?? tags.brand ?? null,
|
|
direction: Number.isFinite(direction) ? direction : null,
|
|
fov: Number.isFinite(fov) ? fov : null,
|
|
tags,
|
|
}
|
|
}
|
|
|
|
export function parseBbox(query) {
|
|
const south = Number(query.south)
|
|
const west = Number(query.west)
|
|
const north = Number(query.north)
|
|
const east = Number(query.east)
|
|
if (![south, west, north, east].every(Number.isFinite)) {
|
|
throw httpError(400, 'south, west, north, east required')
|
|
}
|
|
if (south >= north || west >= east) {
|
|
throw httpError(400, 'invalid bbox')
|
|
}
|
|
if (north - south > MAX_BBOX_DEGREES || east - west > MAX_BBOX_DEGREES) {
|
|
throw httpError(400, `bbox exceeds ${MAX_BBOX_DEGREES}°`)
|
|
}
|
|
return { south, west, north, east }
|
|
}
|
|
|
|
/** @param {{ south: number, west: number, north: number, east: number }} bbox */
|
|
export function tileKeysForBbox(bbox) {
|
|
const latMin = Math.floor(bbox.south * TILE_SCALE)
|
|
const latMax = Math.floor(bbox.north * TILE_SCALE)
|
|
const lngMin = Math.floor(bbox.west * TILE_SCALE)
|
|
const lngMax = Math.floor(bbox.east * TILE_SCALE)
|
|
const keys = []
|
|
for (let lat = latMin; lat <= latMax; lat++) {
|
|
for (let lng = lngMin; lng <= lngMax; lng++) {
|
|
keys.push(`${lat}:${lng}`)
|
|
}
|
|
}
|
|
return keys
|
|
}
|
|
|
|
export function buildBboxQuery(bbox) {
|
|
const { south, west, north, east } = bbox
|
|
return `[out:json][timeout:25];node["surveillance:type"="ALPR"](${south},${west},${north},${east});out body;`
|
|
}
|
|
|
|
export function buildGlobalQuery() {
|
|
return '[out:json][timeout:300];node["surveillance:type"="ALPR"];out body;'
|
|
}
|
|
|
|
function rowToCamera(row, source) {
|
|
const tags = (() => {
|
|
try {
|
|
return JSON.parse(row.tags)
|
|
}
|
|
catch {
|
|
return {}
|
|
}
|
|
})()
|
|
const fovRaw = tags['camera:angle'] ?? tags['surveillance:angle']
|
|
const fov = fovRaw != null && fovRaw !== '' ? Number(fovRaw) : null
|
|
return {
|
|
osmId: row.osm_id,
|
|
lat: row.lat,
|
|
lng: row.lng,
|
|
manufacturer: row.manufacturer ?? null,
|
|
direction: row.direction ?? null,
|
|
fov: Number.isFinite(fov) ? fov : null,
|
|
tags,
|
|
source,
|
|
}
|
|
}
|
|
|
|
/** @param {Awaited<ReturnType<typeof import('./db.js').getDb>>} db */
|
|
export async function upsertAlprNode(db, camera) {
|
|
const now = new Date().toISOString()
|
|
await db.run(
|
|
`INSERT INTO alpr_nodes (osm_id, lat, lng, manufacturer, direction, tags, fetched_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
ON CONFLICT(osm_id) DO UPDATE SET
|
|
lat = excluded.lat,
|
|
lng = excluded.lng,
|
|
manufacturer = excluded.manufacturer,
|
|
direction = excluded.direction,
|
|
tags = excluded.tags,
|
|
fetched_at = excluded.fetched_at`,
|
|
[
|
|
camera.osmId,
|
|
camera.lat,
|
|
camera.lng,
|
|
camera.manufacturer,
|
|
camera.direction,
|
|
JSON.stringify(camera.tags),
|
|
now,
|
|
],
|
|
)
|
|
}
|
|
|
|
/** @param {Awaited<ReturnType<typeof import('./db.js').getDb>>} db */
|
|
export async function upsertAlprNodesBatch(db, cameras) {
|
|
if (!cameras.length) return
|
|
await db.run('BEGIN TRANSACTION')
|
|
try {
|
|
for (const camera of cameras) {
|
|
await upsertAlprNode(db, camera)
|
|
}
|
|
await db.run('COMMIT')
|
|
}
|
|
catch (error) {
|
|
await db.run('ROLLBACK').catch(() => {})
|
|
throw error
|
|
}
|
|
}
|
|
|
|
/** @param {Awaited<ReturnType<typeof import('./db.js').getDb>>} db */
|
|
export async function markTilesFetched(db, keys) {
|
|
if (!keys.length) return
|
|
const now = new Date().toISOString()
|
|
await db.run('BEGIN TRANSACTION')
|
|
try {
|
|
for (const key of keys) {
|
|
await db.run('INSERT OR REPLACE INTO alpr_tiles (tile_key, fetched_at) VALUES (?, ?)', [key, now])
|
|
}
|
|
await db.run('COMMIT')
|
|
}
|
|
catch (error) {
|
|
await db.run('ROLLBACK').catch(() => {})
|
|
throw error
|
|
}
|
|
}
|
|
|
|
/** @param {Awaited<ReturnType<typeof import('./db.js').getDb>>} db */
|
|
async function tilesAreFresh(db, keys) {
|
|
if (!keys.length) return false
|
|
const placeholders = keys.map(() => '?').join(',')
|
|
const rows = await db.all(
|
|
`SELECT tile_key, fetched_at FROM alpr_tiles WHERE tile_key IN (${placeholders})`,
|
|
keys,
|
|
)
|
|
if (rows.length !== keys.length) return false
|
|
const cutoff = Date.now() - CACHE_TTL_MS
|
|
return rows.every(row => Date.parse(row.fetched_at) >= cutoff)
|
|
}
|
|
|
|
/** @param {Awaited<ReturnType<typeof import('./db.js').getDb>>} db */
|
|
export async function getCachedAlprNodes(db, bbox) {
|
|
const rows = await db.all(
|
|
`SELECT osm_id, lat, lng, manufacturer, direction, tags
|
|
FROM alpr_nodes
|
|
WHERE lat >= ? AND lat <= ? AND lng >= ? AND lng <= ?`,
|
|
[bbox.south, bbox.north, bbox.west, bbox.east],
|
|
)
|
|
return rows.map(row => rowToCamera(row, 'cache'))
|
|
}
|
|
|
|
export async function fetchOverpass(query) {
|
|
const res = await fetch(OVERPASS_URL, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
body: `data=${encodeURIComponent(query)}`,
|
|
signal: AbortSignal.timeout(120_000),
|
|
})
|
|
if (!res.ok) {
|
|
throw httpError(502, `Overpass error: ${res.status}`)
|
|
}
|
|
const data = await res.json()
|
|
const elements = Array.isArray(data?.elements) ? data.elements : []
|
|
return elements.map(parseOverpassElement).filter(Boolean)
|
|
}
|
|
|
|
/** @param {Awaited<ReturnType<typeof import('./db.js').getDb>>} db */
|
|
async function fetchAndCacheBbox(db, bbox, tileKeys) {
|
|
const live = await fetchOverpass(buildBboxQuery(bbox))
|
|
await upsertAlprNodesBatch(db, live)
|
|
await markTilesFetched(db, tileKeys)
|
|
return live.map(c => ({ ...c, source: 'live' }))
|
|
}
|
|
|
|
const refreshInFlight = new Map()
|
|
|
|
/** @param {Awaited<ReturnType<typeof import('./db.js').getDb>>} db */
|
|
function refreshBboxInBackground(db, bbox, tileKeys) {
|
|
const key = tileKeys.join('|')
|
|
if (refreshInFlight.has(key)) return
|
|
const job = fetchAndCacheBbox(db, bbox, tileKeys)
|
|
.catch(() => {})
|
|
.finally(() => refreshInFlight.delete(key))
|
|
refreshInFlight.set(key, job)
|
|
}
|
|
|
|
/** @param {Awaited<ReturnType<typeof import('./db.js').getDb>>} db */
|
|
export async function getAlprCameras(db, bbox) {
|
|
const tileKeys = tileKeysForBbox(bbox)
|
|
const cached = await getCachedAlprNodes(db, bbox)
|
|
|
|
if (await tilesAreFresh(db, tileKeys)) {
|
|
return featureCollection(cached.map(cameraToFeature), 'cache')
|
|
}
|
|
|
|
if (cached.length > 0) {
|
|
refreshBboxInBackground(db, bbox, tileKeys)
|
|
return featureCollection(cached.map(cameraToFeature), 'cache')
|
|
}
|
|
|
|
try {
|
|
const live = await fetchAndCacheBbox(db, bbox, tileKeys)
|
|
return featureCollection(live.map(cameraToFeature), 'live')
|
|
}
|
|
catch {
|
|
return featureCollection(cached.map(cameraToFeature), 'cache')
|
|
}
|
|
}
|
|
|
|
/** @param {Awaited<ReturnType<typeof import('./db.js').getDb>>} db */
|
|
export async function importAllAlprNodes(db) {
|
|
const cameras = await fetchOverpass(buildGlobalQuery())
|
|
await upsertAlprNodesBatch(db, cameras)
|
|
return cameras.length
|
|
}
|