Add ADS-B, AIS, and ALPR map layers with live CoT streaming.
Ingest aircraft and vessel tracks via OSINT feeds and tactical CoT, expose viewport-filtered SSE to the map, and add an OSM ALPR layer with tiled caching and performant marker sync.
This commit is contained in:
@@ -0,0 +1,263 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user