Files
kestrelos/app/composables/useCotStream.js
T
Madison Grubb aa8a0bd83f
PR / lint (pull_request) Failing after 31s
PR / test (pull_request) Successful in 45s
PR / docker-build (pull_request) Successful in 1m3s
PR / e2e (pull_request) Successful in 1m33s
Add ADS-B, AIS, and ALPR map layers with live CoT streaming.
Ingest aircraft and vessel tracks via OSINT feeds and tactical CoT, expose viewport-filtered SSE to the map, and add an OSM ALPR layer with tiled caching and performant marker sync.
2026-06-24 16:24:41 -04:00

117 lines
3.3 KiB
JavaScript

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
const { [id]: _removed, ...rest } = entities.value
setEntities(rest)
}))
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 })
}