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 OSM tags only */ function normalizeTags(tags) { if (!tags || typeof tags !== 'object') return {} return Object.fromEntries( Object.entries(/** @type {Record} */ (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 } | null} Parsed camera or null */ export function parseOverpassElement(element) { if (!element || typeof element !== 'object') return null const el = /** @type {Record} */ (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>} 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>} 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>} 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>} 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>} 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>} 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>} 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>} 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>} db */ export async function importAllAlprNodes(db) { const cameras = await fetchOverpass(buildGlobalQuery()) await upsertAlprNodesBatch(db, cameras) return cameras.length }