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 }) }