Add ADS-B, AIS, and ALPR map layers with live CoT streaming.
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

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:
Madison Grubb
2026-06-24 16:24:41 -04:00
parent a6b87305a1
commit aa8a0bd83f
64 changed files with 5761 additions and 2119 deletions
+6 -1
View File
@@ -26,6 +26,11 @@
:user="user"
@signout="onLogout"
/>
<span
v-else-if="authPending"
class="inline-block h-8 w-8"
aria-hidden="true"
/>
<NuxtLink
v-else
to="/login"
@@ -75,7 +80,7 @@ watch(sidebarCollapsed, (v) => {
}
})
const { user, refresh } = useUser()
const { user, authPending, refresh } = useUser()
watch(isMobile, (mobile) => {
if (mobile) drawerOpen.value = false
+136 -46
View File
@@ -47,11 +47,41 @@
@submit="onPoiSubmit"
@confirm-delete="confirmDeletePoi"
/>
<div
v-if="mapContext"
class="pointer-events-auto absolute right-3 top-3 z-[1000] flex gap-0.5 rounded border border-kestrel-border bg-kestrel-surface/95 p-0.5 text-xs shadow-glow"
data-testid="cot-layer-toggles"
>
<button
v-for="layer in COT_LAYERS"
:key="layer.key"
type="button"
class="kestrel-cot-layer-btn"
:class="{ 'kestrel-cot-layer-btn-active': cotLayers[layer.key] }"
@click="emit('toggleCotLayer', layer.key)"
>
{{ layer.label }}
</button>
</div>
</div>
</template>
<script setup>
import 'leaflet/dist/leaflet.css'
import {
createAlprControl,
createAlprLayer,
setAlprControlPressed,
syncAlprLayer,
} from '~/utils/alprMapLayer.js'
import {
createCotLayer,
getCotClusters,
loadCotCluster,
syncCotLayer,
} from '~/utils/cotMapLayer.js'
import { clearFeatureMarkers } from '~/utils/mapMarkerSync.js'
const props = defineProps({
devices: {
@@ -70,13 +100,25 @@ const props = defineProps({
type: Array,
default: () => [],
},
cotLayers: {
type: Object,
default: () => ({ air: true, surface: true, ground: true }),
},
alprMarkers: {
type: Array,
default: () => [],
},
showAlpr: {
type: Boolean,
default: false,
},
canEditPois: {
type: Boolean,
default: false,
},
})
const emit = defineEmits(['select', 'selectLive', 'refreshPois'])
const emit = defineEmits(['select', 'selectLive', 'refreshPois', 'boundsChange', 'toggleAlpr', 'toggleCotLayer'])
const CONTEXT_MENU_EMPTY = Object.freeze({ type: null, poi: null, latlng: null, x: 0, y: 0 })
const mapRef = ref(null)
const contextMenuRef = ref(null)
@@ -85,7 +127,9 @@ const mapContext = ref(null)
const markersRef = ref([])
const poiMarkersRef = ref({})
const liveMarkersRef = ref({})
const cotMarkersRef = ref({})
const cotLayerRef = ref(null)
const cotMapView = ref(null)
const alprLayerRef = ref(null)
const contextMenu = ref({ ...CONTEXT_MENU_EMPTY })
const showPoiModal = ref(false)
@@ -104,6 +148,11 @@ const DEFAULT_ZOOM = 17
const MARKER_ICON_PATH = '/'
const POI_TOOLTIP_CLASS = 'kestrel-poi-tooltip'
const POI_ICON_COLORS = { pin: '#22c9c9', flag: '#e53e3e', waypoint: '#a78bfa' }
const COT_LAYERS = Object.freeze([
{ key: 'air', label: 'Air' },
{ key: 'surface', label: 'Surface' },
{ key: 'ground', label: 'Team' },
])
const ICON_SIZE = 28
@@ -141,14 +190,41 @@ function getLiveSessionIcon(L) {
})
}
const COT_ICON_COLOR = '#f59e0b' /* amber - ATAK/CoT devices */
function getCotEntityIcon(L) {
const html = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="${COT_ICON_COLOR}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><circle cx="12" cy="8" r="2.5" fill="${COT_ICON_COLOR}"/></svg>`
return L.divIcon({
className: 'poi-div-icon cot-entity-icon',
html: `<span class="poi-icon-svg">${html}</span>`,
iconSize: [ICON_SIZE, ICON_SIZE],
iconAnchor: [ICON_SIZE / 2, ICON_SIZE],
function refreshCotMapView(map) {
const bounds = map.getBounds()
cotMapView.value = {
south: bounds.getSouth(),
west: bounds.getWest(),
north: bounds.getNorth(),
east: bounds.getEast(),
zoom: map.getZoom(),
}
}
function renderCotLayer() {
const ctx = mapContext.value
const { L } = leafletRef.value || {}
const layer = cotLayerRef.value
if (!ctx?.map || !L || !layer) return
const view = cotMapView.value
const features = view ? getCotClusters(view) : []
syncCotLayer(L, ctx.map, layer, features)
}
function reloadCotCluster() {
loadCotCluster(props.cotEntities || [])
renderCotLayer()
}
function emitBounds(map) {
refreshCotMapView(map)
const bounds = map.getBounds()
emit('boundsChange', {
south: bounds.getSouth(),
west: bounds.getWest(),
north: bounds.getNorth(),
east: bounds.getEast(),
zoom: map.getZoom(),
})
}
@@ -160,7 +236,7 @@ function createMap(initialCenter) {
? initialCenter
: DEFAULT_VIEW
const map = L.map(mapRef.value, { zoomControl: false, attributionControl: false }).setView(center, DEFAULT_ZOOM)
const map = L.map(mapRef.value, { zoomControl: false, attributionControl: false, minZoom: 1, maxZoom: 19 }).setView(center, DEFAULT_ZOOM)
L.control.zoom({ position: 'topleft' }).addTo(map)
const locateControl = L.control({ position: 'topleft' })
@@ -186,6 +262,14 @@ function createMap(initialCenter) {
}
locateControl.addTo(map)
const alprControl = createAlprControl(L, {
showAlpr: props.showAlpr,
onToggle: () => emit('toggleAlpr'),
})
alprControl.addTo(map)
const alprLayer = createAlprLayer(L, map)
const cotLayer = createCotLayer(L, map)
const baseLayer = L.tileLayer(TILE_URL, {
attribution: ATTRIBUTION,
subdomains: TILE_SUBDOMAINS,
@@ -214,12 +298,28 @@ function createMap(initialCenter) {
contextMenu.value = { type: 'map', latlng: e.latlng, x: pt.x, y: pt.y }
})
mapContext.value = { map, layer: baseLayer, control, locateControl }
map.on('moveend', () => {
emitBounds(map)
renderCotLayer()
})
map.on('zoomend', () => {
emitBounds(map)
renderCotLayer()
})
mapContext.value = { map, layer: baseLayer, control, locateControl, alprControl }
alprLayerRef.value = alprLayer
cotLayerRef.value = cotLayer
refreshCotMapView(map)
updateMarkers()
updatePoiMarkers()
updateLiveMarkers()
updateCotMarkers()
nextTick(() => map.invalidateSize())
reloadCotCluster()
updateAlprLayer()
nextTick(() => {
map.invalidateSize()
emitBounds(map)
})
}
function updateMarkers() {
@@ -309,37 +409,16 @@ function updateLiveMarkers() {
liveMarkersRef.value = next
}
function updateCotMarkers() {
function updateAlprLayer() {
const ctx = mapContext.value
const { L } = leafletRef.value || {}
if (!ctx?.map || !L) return
const entities = (props.cotEntities || []).filter(
e => typeof e?.lat === 'number' && typeof e?.lng === 'number' && e?.id,
)
const byId = Object.fromEntries(entities.map(e => [e.id, e]))
const prev = cotMarkersRef.value
const icon = getCotEntityIcon(L)
Object.keys(prev).forEach((id) => {
if (!byId[id]) prev[id]?.remove()
})
const next = entities.reduce((acc, entity) => {
const content = `<div class="kestrel-live-popup"><strong>${escapeHtml(entity.label || entity.id)}</strong> <span class="text-kestrel-muted">ATAK</span></div>`
const existing = prev[entity.id]
if (existing) {
existing.setLatLng([entity.lat, entity.lng])
existing.setIcon(icon)
existing.getPopup()?.setContent(content)
return { ...acc, [entity.id]: existing }
}
const marker = L.marker([entity.lat, entity.lng], { icon })
.addTo(ctx.map)
.bindPopup(content, { className: 'kestrel-live-popup-wrap', maxWidth: 360 })
return { ...acc, [entity.id]: marker }
}, {})
cotMarkersRef.value = next
const layer = alprLayerRef.value
if (!ctx?.map || !L || !layer) return
if (!props.showAlpr) {
clearFeatureMarkers(layer)
return
}
syncAlprLayer(L, ctx.map, layer, props.alprMarkers || [])
}
function escapeHtml(text) {
@@ -427,17 +506,22 @@ function destroyMap() {
poiMarkersRef.value = {}
Object.values(liveMarkersRef.value).forEach(m => m?.remove())
liveMarkersRef.value = {}
Object.values(cotMarkersRef.value).forEach(m => m?.remove())
cotMarkersRef.value = {}
clearFeatureMarkers(cotLayerRef.value)
clearFeatureMarkers(alprLayerRef.value)
const ctx = mapContext.value
if (ctx) {
if (ctx.control && ctx.map) ctx.map.removeControl(ctx.control)
if (ctx.locateControl && ctx.map) ctx.map.removeControl(ctx.locateControl)
if (ctx.alprControl && ctx.map) ctx.map.removeControl(ctx.alprControl)
if (cotLayerRef.value && ctx.map) ctx.map.removeLayer(cotLayerRef.value)
if (alprLayerRef.value && ctx.map) ctx.map.removeLayer(alprLayerRef.value)
if (ctx.layer && ctx.map) ctx.map.removeLayer(ctx.layer)
if (ctx.map) ctx.map.remove()
mapContext.value = null
}
cotLayerRef.value = null
alprLayerRef.value = null
}
function initMapWithLocation() {
@@ -503,5 +587,11 @@ onBeforeUnmount(() => {
watch(() => props.devices, () => updateMarkers(), { deep: true })
watch([() => props.pois, () => props.canEditPois], () => updatePoiMarkers(), { deep: true })
watch(() => props.liveSessions, () => updateLiveMarkers(), { deep: true })
watch(() => props.cotEntities, () => updateCotMarkers(), { deep: true })
watch(() => props.cotEntities, () => reloadCotCluster())
watch(() => props.alprMarkers, () => updateAlprLayer())
watch(() => props.showAlpr, (enabled) => {
setAlprControlPressed(mapContext.value?.alprControl, enabled)
if (enabled && mapContext.value?.map) emitBounds(mapContext.value.map)
else updateAlprLayer()
})
</script>