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
This commit was merged in pull request #36.
This commit is contained in:
@@ -0,0 +1,117 @@
|
||||
import { bboxFetchKey } from '~/utils/alprViewport.js'
|
||||
|
||||
const DEBOUNCE_MS = 300
|
||||
const EMPTY_ENTITIES = Object.freeze({})
|
||||
|
||||
const expandBounds = (bounds, factor = 0.25) => {
|
||||
const latPad = (bounds.north - bounds.south) * factor
|
||||
const lngPad = (bounds.east - bounds.west) * factor
|
||||
return {
|
||||
south: Math.max(-90, bounds.south - latPad),
|
||||
north: Math.min(90, bounds.north + latPad),
|
||||
west: bounds.west - lngPad,
|
||||
east: bounds.east + lngPad,
|
||||
}
|
||||
}
|
||||
|
||||
const entitiesFromList = list => Object.freeze(
|
||||
Object.fromEntries(
|
||||
(list ?? []).filter(entity => entity?.id).map(entity => [entity.id, entity]),
|
||||
),
|
||||
)
|
||||
|
||||
export function useCotStream(boundsRef, layerQueryRef) {
|
||||
const entities = ref(EMPTY_ENTITIES)
|
||||
const cotEntities = computed(() => Object.freeze(Object.values(entities.value)))
|
||||
const eventSource = ref(null)
|
||||
const debounceTimer = ref(null)
|
||||
const subscribedKey = ref('')
|
||||
const streamBounds = ref(null)
|
||||
|
||||
const setEntities = (record) => {
|
||||
entities.value = Object.freeze(record)
|
||||
}
|
||||
|
||||
const parseEvent = (e, fn) => {
|
||||
try {
|
||||
fn(JSON.parse(e.data))
|
||||
}
|
||||
catch { /* ignore */ }
|
||||
}
|
||||
|
||||
const closeStream = () => {
|
||||
eventSource.value?.close()
|
||||
eventSource.value = null
|
||||
}
|
||||
|
||||
const connect = () => {
|
||||
if (typeof window === 'undefined' || typeof EventSource === 'undefined') return
|
||||
const bounds = streamBounds.value ?? boundsRef.value
|
||||
if (!bounds) return
|
||||
|
||||
closeStream()
|
||||
const q = new URLSearchParams({
|
||||
bbox: `${bounds.west},${bounds.south},${bounds.east},${bounds.north}`,
|
||||
layers: unref(layerQueryRef) || 'air,surface,ground',
|
||||
})
|
||||
const es = new EventSource(`/api/cot/stream?${q}`)
|
||||
eventSource.value = es
|
||||
|
||||
es.addEventListener('snapshot', e => parseEvent(e, ({ entities: list }) => {
|
||||
setEntities(entitiesFromList(list))
|
||||
}))
|
||||
es.addEventListener('update', e => parseEvent(e, ({ entity }) => {
|
||||
if (!entity?.id) return
|
||||
setEntities({ ...entities.value, [entity.id]: entity })
|
||||
}))
|
||||
es.addEventListener('remove', e => parseEvent(e, ({ id }) => {
|
||||
if (!id) return
|
||||
setEntities(Object.fromEntries(
|
||||
Object.entries(entities.value).filter(([key]) => key !== String(id)),
|
||||
))
|
||||
}))
|
||||
es.onerror = () => {
|
||||
closeStream()
|
||||
scheduleConnect()
|
||||
}
|
||||
}
|
||||
|
||||
const scheduleConnect = () => {
|
||||
if (debounceTimer.value) clearTimeout(debounceTimer.value)
|
||||
debounceTimer.value = setTimeout(() => {
|
||||
debounceTimer.value = null
|
||||
if (typeof document !== 'undefined' && document.visibilityState === 'hidden') return
|
||||
connect()
|
||||
}, DEBOUNCE_MS)
|
||||
}
|
||||
|
||||
const maybeReconnect = (bounds) => {
|
||||
if (!bounds) return
|
||||
const key = bboxFetchKey(bounds)
|
||||
if (key === subscribedKey.value) return
|
||||
subscribedKey.value = key
|
||||
streamBounds.value = expandBounds(bounds)
|
||||
scheduleConnect()
|
||||
}
|
||||
|
||||
watch(boundsRef, maybeReconnect, { deep: true })
|
||||
watch(layerQueryRef, () => {
|
||||
subscribedKey.value = ''
|
||||
scheduleConnect()
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
if (typeof document === 'undefined') return
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
document.visibilityState === 'visible' ? scheduleConnect() : closeStream()
|
||||
})
|
||||
if (document.visibilityState === 'visible') maybeReconnect(boundsRef.value)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (debounceTimer.value) clearTimeout(debounceTimer.value)
|
||||
closeStream()
|
||||
})
|
||||
|
||||
return Object.freeze({ cotEntities })
|
||||
}
|
||||
Reference in New Issue
Block a user