Files
kestrelos/app/composables/useAlprCameras.js
T
keligrubb bb01e9a06c
Push / release (push) Successful in 13s
Push / publish (push) Successful in 1m4s
Add ADS-B, AIS, and ALPR map layers with live CoT streaming (#36)
## 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
2026-06-24 20:54:50 +00:00

147 lines
4.4 KiB
JavaScript

import { createClusterIndex } from '~/utils/mapCluster.js'
import {
MAX_BBOX_DEGREES,
bboxFetchKey,
bboxToTileKey,
tileKey,
tilesNearCenter,
} from '~/utils/alprViewport.js'
const STORAGE_KEY = 'kestrelos:showAlprLayer'
const MIN_FETCH_ZOOM = 10
const MAX_TILE_FETCHES = 16
const MAX_CACHED_TILES = 64
const TILE_RETENTION = 3
const EMPTY_TILES = Object.freeze({})
const mergeFeatures = lists => Object.values(
lists.flat().reduce((acc, feature) => {
const id = feature.id ?? feature.properties?.osmId
return id == null ? acc : { ...acc, [id]: feature }
}, {}),
)
const offsets = radius => Array.from({ length: 2 * radius + 1 }, (_, i) => i - radius)
const retentionKeys = bounds => new Set(
tilesNearCenter(bounds, MAX_TILE_FETCHES).flatMap((tile) => {
const r0 = Math.floor(tile.south / MAX_BBOX_DEGREES)
const c0 = Math.floor(tile.west / MAX_BBOX_DEGREES)
return offsets(TILE_RETENTION).flatMap(dr =>
offsets(TILE_RETENTION).map(dc => tileKey(r0 + dr, c0 + dc)),
)
}),
)
const pruneTileCache = (cache, bounds) => {
const keep = retentionKeys(bounds)
const retained = Object.fromEntries(
Object.entries(cache).filter(([key]) => keep.has(key)),
)
const overflow = Object.keys(retained).length - MAX_CACHED_TILES
if (overflow <= 0) return Object.freeze(retained)
const cr = Math.floor((bounds.south + bounds.north) / 2 / MAX_BBOX_DEGREES)
const cc = Math.floor((bounds.west + bounds.east) / 2 / MAX_BBOX_DEGREES)
const drop = new Set(
Object.keys(retained)
.map((key) => {
const [r, c] = key.split(',').map(Number)
return { key, dist: Math.hypot(r - cr, c - cc) }
})
.sort((a, b) => b.dist - a.dist)
.slice(0, overflow)
.map(({ key }) => key),
)
return Object.freeze(
Object.fromEntries(Object.entries(retained).filter(([key]) => !drop.has(key))),
)
}
const cacheChanged = (before, after) => Object.keys(before).length !== Object.keys(after).length
export function useAlprCameras() {
const showAlpr = useState('showAlprLayer', () => {
if (!import.meta.client) return true
return localStorage.getItem(STORAGE_KEY) !== '0'
})
const view = ref(null)
const tiles = ref(EMPTY_TILES)
const cluster = createClusterIndex({ radius: 50, maxZoom: 17 })
const debounceTimer = ref(null)
const requestId = ref(0)
const lastFetchKey = ref('')
const applyTiles = (next) => {
tiles.value = next
cluster.load(mergeFeatures(Object.values(next)))
}
watch(showAlpr, (enabled) => {
if (import.meta.client) localStorage.setItem(STORAGE_KEY, enabled ? '1' : '0')
if (!enabled) {
applyTiles(EMPTY_TILES)
view.value = null
lastFetchKey.value = ''
}
})
const alprMarkers = computed(() => view.value ? cluster.query(view.value) : [])
const toggleAlpr = () => {
showAlpr.value = !showAlpr.value
}
const onBoundsChange = (bounds) => {
if (!showAlpr.value || !bounds) return
view.value = bounds
const pruned = pruneTileCache(tiles.value, bounds)
if (cacheChanged(tiles.value, pruned)) applyTiles(pruned)
if ((bounds.zoom ?? 14) < MIN_FETCH_ZOOM) return
const key = bboxFetchKey(bounds)
if (key === lastFetchKey.value) return
if (debounceTimer.value) clearTimeout(debounceTimer.value)
debounceTimer.value = setTimeout(() => {
lastFetchKey.value = key
fetchViewport(bounds)
}, 400)
}
const fetchViewport = async (bounds) => {
const id = requestId.value + 1
requestId.value = id
const missing = tilesNearCenter(bounds, MAX_TILE_FETCHES)
.filter(tile => !(bboxToTileKey(tile) in tiles.value))
try {
const fetched = missing.length
? await Promise.all(missing.map(async (tile) => {
const params = new URLSearchParams({
south: String(tile.south),
west: String(tile.west),
north: String(tile.north),
east: String(tile.east),
})
const data = await $fetch(`/api/alpr?${params}`).catch(() => null)
return [bboxToTileKey(tile), data?.features ?? []]
}))
: []
if (id !== requestId.value) return
const merged = Object.freeze({
...tiles.value,
...Object.fromEntries(fetched),
})
const pruned = pruneTileCache(merged, bounds)
applyTiles(pruned)
}
catch { /* keep last good index */ }
}
return Object.freeze({ showAlpr, toggleAlpr, alprMarkers, onBoundsChange })
}