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.
This commit is contained in:
@@ -0,0 +1,146 @@
|
||||
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 })
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
/** Fetches devices + live sessions; polls when tab visible. */
|
||||
const POLL_MS = 1500
|
||||
const EMPTY_RESPONSE = Object.freeze({ devices: [], liveSessions: [], cotEntities: [] })
|
||||
const EMPTY_RESPONSE = Object.freeze({ devices: [], liveSessions: [] })
|
||||
|
||||
export function useCameras(options = {}) {
|
||||
const { poll: enablePoll = true } = options
|
||||
@@ -12,7 +12,6 @@ export function useCameras(options = {}) {
|
||||
|
||||
const devices = computed(() => Object.freeze([...(data.value?.devices ?? [])]))
|
||||
const liveSessions = computed(() => Object.freeze([...(data.value?.liveSessions ?? [])]))
|
||||
const cotEntities = computed(() => Object.freeze([...(data.value?.cotEntities ?? [])]))
|
||||
const cameras = computed(() => Object.freeze([...devices.value, ...liveSessions.value]))
|
||||
|
||||
const pollInterval = ref(null)
|
||||
@@ -37,5 +36,5 @@ export function useCameras(options = {}) {
|
||||
})
|
||||
onBeforeUnmount(stopPolling)
|
||||
|
||||
return Object.freeze({ data, devices, liveSessions, cotEntities, cameras, refresh, startPolling, stopPolling })
|
||||
return Object.freeze({ data, devices, liveSessions, cameras, refresh, startPolling, stopPolling })
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
const STORAGE_KEY = 'kestrel-cot-layers'
|
||||
|
||||
const DEFAULT_LAYERS = Object.freeze({ air: true, surface: true, ground: true })
|
||||
|
||||
function loadLayers() {
|
||||
if (typeof localStorage === 'undefined') return { ...DEFAULT_LAYERS }
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY)
|
||||
if (!raw) return { ...DEFAULT_LAYERS }
|
||||
const parsed = JSON.parse(raw)
|
||||
return {
|
||||
air: parsed.air !== false,
|
||||
surface: parsed.surface !== false,
|
||||
ground: parsed.ground !== false,
|
||||
}
|
||||
}
|
||||
catch {
|
||||
return { ...DEFAULT_LAYERS }
|
||||
}
|
||||
}
|
||||
|
||||
function saveLayers(layers) {
|
||||
if (typeof localStorage === 'undefined') return
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(layers))
|
||||
}
|
||||
catch { /* ignore quota */ }
|
||||
}
|
||||
|
||||
export function useCotLayers() {
|
||||
const layers = ref(loadLayers())
|
||||
|
||||
const layerQuery = computed(() => {
|
||||
const parts = []
|
||||
if (layers.value.air) parts.push('air')
|
||||
if (layers.value.surface) parts.push('surface')
|
||||
if (layers.value.ground) parts.push('ground')
|
||||
return parts.length ? parts.join(',') : 'none'
|
||||
})
|
||||
|
||||
function toggleLayer(name) {
|
||||
if (!(name in DEFAULT_LAYERS)) return
|
||||
layers.value = { ...layers.value, [name]: !layers.value[name] }
|
||||
saveLayers(layers.value)
|
||||
}
|
||||
|
||||
return Object.freeze({ layers, layerQuery, toggleLayer })
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
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 })
|
||||
}
|
||||
@@ -2,12 +2,13 @@ const EDIT_ROLES = Object.freeze(['admin', 'leader'])
|
||||
|
||||
export function useUser() {
|
||||
const requestFetch = useRequestFetch()
|
||||
const { data: user, refresh } = useAsyncData(
|
||||
const { data: user, refresh, status } = useAsyncData(
|
||||
'user',
|
||||
() => (requestFetch ?? $fetch)('/api/me').catch(() => null),
|
||||
{ default: () => null },
|
||||
)
|
||||
const authPending = computed(() => status.value === 'pending')
|
||||
const canEditPois = computed(() => EDIT_ROLES.includes(user.value?.role))
|
||||
const isAdmin = computed(() => user.value?.role === 'admin')
|
||||
return Object.freeze({ user, canEditPois, isAdmin, refresh })
|
||||
return Object.freeze({ user, authPending, canEditPois, isAdmin, refresh })
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user