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
147 lines
4.4 KiB
JavaScript
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 })
|
|
}
|