Add ADS-B, AIS, and ALPR map layers with live CoT streaming (#36)
Push / release (push) Successful in 13s
Push / publish (push) Successful in 1m4s

## 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
This commit was merged in pull request #36.
This commit is contained in:
2026-06-24 20:54:50 +00:00
parent a6b87305a1
commit bb01e9a06c
64 changed files with 5762 additions and 2119 deletions
+39 -1
View File
@@ -8,7 +8,7 @@ import { registerCleanup } from './shutdown.js'
const requireFromRoot = createRequire(join(process.cwd(), 'package.json'))
const { DatabaseSync } = requireFromRoot('node:sqlite')
const SCHEMA_VERSION = 4
const SCHEMA_VERSION = 6
const DB_BUSY_TIMEOUT_MS = 5000
let dbInstance = null
@@ -59,6 +59,20 @@ const SCHEMA = {
source_type TEXT NOT NULL DEFAULT 'mjpeg',
config TEXT
)`,
alpr_nodes: `CREATE TABLE IF NOT EXISTS alpr_nodes (
osm_id INTEGER PRIMARY KEY,
lat REAL NOT NULL,
lng REAL NOT NULL,
manufacturer TEXT,
direction INTEGER,
tags TEXT NOT NULL,
fetched_at TEXT NOT NULL
)`,
alpr_nodes_index: 'CREATE INDEX IF NOT EXISTS idx_alpr_lat_lng ON alpr_nodes(lat, lng)',
alpr_tiles: `CREATE TABLE IF NOT EXISTS alpr_tiles (
tile_key TEXT PRIMARY KEY,
fetched_at TEXT NOT NULL
)`,
}
const getDbPath = () => {
@@ -118,6 +132,19 @@ const migrateToV4 = async (run, all) => {
await run('ALTER TABLE users ADD COLUMN cot_password_hash TEXT')
}
const migrateToV5 = async (run, all) => {
const tables = await all('SELECT name FROM sqlite_master WHERE type=\'table\' AND name=\'alpr_nodes\'')
if (tables.length > 0) return
await run(SCHEMA.alpr_nodes)
await run(SCHEMA.alpr_nodes_index)
}
const migrateToV6 = async (run, all) => {
const tables = await all('SELECT name FROM sqlite_master WHERE type=\'table\' AND name=\'alpr_tiles\'')
if (tables.length > 0) return
await run(SCHEMA.alpr_tiles)
}
const runMigrations = async (run, all, get) => {
const version = await getSchemaVersion(get)
if (version >= SCHEMA_VERSION) return
@@ -133,6 +160,14 @@ const runMigrations = async (run, all, get) => {
await migrateToV4(run, all)
await setSchemaVersion(run, 4)
}
if (version < 5) {
await migrateToV5(run, all)
await setSchemaVersion(run, 5)
}
if (version < 6) {
await migrateToV6(run, all)
await setSchemaVersion(run, 6)
}
}
const initDb = async (db, run, all, get) => {
@@ -149,6 +184,9 @@ const initDb = async (db, run, all, get) => {
await run(SCHEMA.sessions)
await run(SCHEMA.pois)
await run(SCHEMA.devices)
await run(SCHEMA.alpr_nodes)
await run(SCHEMA.alpr_nodes_index)
await run(SCHEMA.alpr_tiles)
if (!testPath) {
// Bootstrap admin user on first run