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
+1 -1
View File
@@ -1,5 +1,5 @@
<template>
<NuxtLayout>
<NuxtPage :key="$route.path" />
<NuxtPage />
</NuxtLayout>
</template>
+38 -2
View File
@@ -16,6 +16,9 @@
.kestrel-btn-secondary { @apply rounded border border-kestrel-border px-4 py-2 text-sm text-kestrel-text transition-colors hover:bg-kestrel-border; }
.kestrel-context-menu-item { @apply block w-full px-3 py-1.5 text-left text-sm text-kestrel-text transition-colors hover:bg-kestrel-border; }
.kestrel-context-menu-item-danger { @apply block w-full px-3 py-1.5 text-left text-sm text-red-400 transition-colors hover:bg-kestrel-border; }
.kestrel-cot-layer-btn { @apply rounded px-1.5 py-0.5 text-kestrel-muted transition-colors hover:text-kestrel-text; }
.kestrel-cot-layer-btn-active { @apply bg-kestrel-border text-kestrel-accent; }
.cot-icon-rotatable { @apply inline-flex origin-center; }
.kestrel-panel-base { @apply flex flex-col border border-kestrel-border bg-kestrel-surface; }
.kestrel-panel-inline { @apply rounded-lg shadow-glow; }
.kestrel-panel-overlay { @apply absolute right-0 top-0 z-[1000] h-full w-full border-l shadow-glow md:w-[420px] shadow-glow-panel; }
@@ -84,12 +87,14 @@
}
.kestrel-map-container .leaflet-control-zoom,
.kestrel-map-container .leaflet-control-locate,
.kestrel-map-container .leaflet-control-alpr,
.kestrel-map-container .savetiles.leaflet-bar {
@apply rounded-md overflow-hidden font-mono border border-kestrel-glow shadow-glow-sm;
border-color: rgba(34, 201, 201, 0.35) !important;
}
.kestrel-map-container .leaflet-control-zoom a,
.kestrel-map-container .leaflet-control-locate,
.kestrel-map-container .leaflet-control-alpr,
.kestrel-map-container .savetiles.leaflet-bar a {
@apply w-8 h-8 leading-8 bg-kestrel-surface text-kestrel-text border-none rounded-none text-lg font-semibold no-underline transition-all duration-150;
width: 32px !important;
@@ -105,9 +110,14 @@
}
.kestrel-map-container .leaflet-control-zoom a:hover,
.kestrel-map-container .leaflet-control-locate:hover,
.kestrel-map-container .leaflet-control-alpr:hover,
.kestrel-map-container .savetiles.leaflet-bar a:hover {
@apply bg-kestrel-surface-hover text-kestrel-accent shadow-glow-md text-shadow-glow-md;
}
.kestrel-map-container .leaflet-control-alpr[aria-pressed="true"] {
color: #ef4444 !important;
box-shadow: 0 0 8px rgba(239, 68, 68, 0.45);
}
.kestrel-map-container .savetiles.leaflet-bar {
@apply flex flex-col;
}
@@ -119,12 +129,38 @@
padding: 6px 10px !important;
font-size: 11px !important;
}
.kestrel-map-container .leaflet-control-locate {
.kestrel-map-container .leaflet-control-locate,
.kestrel-map-container .leaflet-control-alpr {
@apply flex items-center justify-center p-0 cursor-pointer;
}
.kestrel-map-container .leaflet-control-locate svg {
.kestrel-map-container .leaflet-control-locate svg,
.kestrel-map-container .leaflet-control-alpr svg {
color: currentColor;
}
.kestrel-map-container .alpr-cone {
display: inline-flex;
transform-origin: center center;
}
.kestrel-map-container .alpr-cluster-icon {
background: transparent;
border: none;
}
.kestrel-map-container .alpr-cluster {
@apply flex items-center justify-center rounded-full bg-red-500/90 font-mono text-xs font-semibold text-white;
width: 100%;
height: 100%;
box-shadow: 0 0 8px rgba(239, 68, 68, 0.5);
}
.kestrel-map-container .cot-cluster-icon {
background: transparent;
border: none;
}
.kestrel-map-container .cot-cluster {
@apply flex items-center justify-center rounded-full bg-sky-500/90 font-mono text-xs font-semibold text-white;
width: 100%;
height: 100%;
box-shadow: 0 0 8px rgba(56, 189, 248, 0.5);
}
.kestrel-map-container .live-session-icon {
animation: live-pulse 1.5s ease-in-out infinite;
}
+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>
+146
View File
@@ -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 })
}
+2 -3
View File
@@ -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 })
}
+48
View File
@@ -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 })
}
+116
View File
@@ -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 })
}
+3 -2
View File
@@ -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 })
}
+1 -1
View File
@@ -3,7 +3,7 @@ const LOGIN_PATH = '/login'
export default defineNuxtRouteMiddleware(async (to) => {
if (to.path === LOGIN_PATH) return
const { user, refresh } = useUser()
await refresh()
if (!user.value) await refresh()
if (user.value) return
const redirect = to.fullPath.startsWith('/') ? to.fullPath : `/${to.fullPath}`
return navigateTo({ path: LOGIN_PATH, query: { redirect } }, { replace: true })
+22 -1
View File
@@ -7,11 +7,23 @@
:pois="pois ?? []"
:live-sessions="liveSessions ?? []"
:cot-entities="cotEntities ?? []"
:cot-layers="cotLayers"
:alpr-markers="showAlpr ? (alprMarkers ?? []) : []"
:show-alpr="showAlpr"
:can-edit-pois="canEditPois"
@select="selectedCamera = $event"
@select-live="onSelectLive($event)"
@refresh-pois="refreshPois"
@bounds-change="onMapBoundsChange"
@toggle-alpr="toggleAlpr"
@toggle-cot-layer="toggleLayer"
/>
<template #fallback>
<div
class="h-full min-h-[300px] bg-kestrel-bg"
aria-hidden="true"
/>
</template>
</ClientOnly>
</div>
<CameraViewer
@@ -23,11 +35,20 @@
</template>
<script setup>
const { devices, liveSessions, cotEntities } = useCameras()
const { devices, liveSessions } = useCameras()
const { data: pois, refresh: refreshPois } = usePois()
const { canEditPois } = useUser()
const { showAlpr, toggleAlpr, alprMarkers, onBoundsChange: onAlprBoundsChange } = useAlprCameras()
const { layers: cotLayers, layerQuery, toggleLayer } = useCotLayers()
const mapBounds = ref(null)
const { cotEntities } = useCotStream(mapBounds, layerQuery)
const selectedCamera = ref(null)
function onMapBoundsChange(bounds) {
mapBounds.value = bounds
onAlprBoundsChange(bounds)
}
function onSelectLive(session) {
selectedCamera.value = (liveSessions.value ?? []).find(s => s.id === session?.id) ?? session
}
+220
View File
@@ -0,0 +1,220 @@
import { syncFeatureMarkers } from './mapMarkerSync.js'
const ICON_SIZE = 28
const CONE_SIZE = 52
const ALPR_COLOR = '#ef4444'
const DEFAULT_FOV = 60
function escapeHtml(text) {
const div = document.createElement('div')
div.textContent = text
return div.innerHTML
}
function conePath(fov) {
const half = Math.min(Math.max(fov / 2, 10), 85)
const cx = 26
const cy = 26
const r = 24
const toRad = deg => ((deg - 90) * Math.PI) / 180
const x1 = cx + r * Math.cos(toRad(-half))
const y1 = cy + r * Math.sin(toRad(-half))
const x2 = cx + r * Math.cos(toRad(half))
const y2 = cy + r * Math.sin(toRad(half))
return `M ${cx} ${cy} L ${x1} ${y1} A ${r} ${r} 0 0 1 ${x2} ${y2} Z`
}
function compassLabel(deg) {
if (!Number.isFinite(deg)) return null
const dirs = ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW']
const idx = Math.round((((deg % 360) + 360) % 360) / 45) % 8
return dirs[idx]
}
function wikidataLink(label, qid) {
if (!qid) return escapeHtml(label)
const id = escapeHtml(qid)
return `<a href="https://www.wikidata.org/wiki/${id}" target="_blank" rel="noopener">${escapeHtml(label)}</a>`
}
function popupLine(parts) {
const line = parts.filter(Boolean).join(' · ')
return line ? `<div class="text-kestrel-muted text-xs mt-1">${line}</div>` : ''
}
function titleHtml(props) {
if (props.name) return escapeHtml(props.name)
if (props.model) {
const mfr = props.manufacturer ? escapeHtml(props.manufacturer) : null
return mfr ? `${mfr} <span class="text-kestrel-accent">${escapeHtml(props.model)}</span>` : escapeHtml(props.model)
}
const makeModel = [props.manufacturer, props.model].filter(Boolean).join(' ')
if (makeModel) {
if (props.manufacturerWikidata && !props.model) return wikidataLink(makeModel, props.manufacturerWikidata)
return escapeHtml(makeModel)
}
if (props.operator) return wikidataLink(props.operator, props.operatorWikidata)
if (props.ref) return escapeHtml(props.ref)
return 'ALPR camera'
}
function titleText(props) {
if (props.name) return props.name
if (props.model) {
return [props.manufacturer, props.model].filter(Boolean).join(' ')
}
const makeModel = [props.manufacturer, props.model].filter(Boolean).join(' ')
if (makeModel) return makeModel
if (props.operator) return props.operator
if (props.ref) return props.ref
return 'ALPR camera'
}
function modelLineHtml(props) {
if (props.model) {
return `<div class="text-sm mt-0.5"><span class="text-kestrel-muted">Model</span> <strong>${escapeHtml(props.model)}</strong></div>`
}
if (props.modelUnknown) {
return '<div class="text-kestrel-muted text-xs mt-0.5">Model not recorded in OpenStreetMap</div>'
}
return ''
}
/** Human-readable ALPR popup (GeoJSON properties in, HTML out). */
export function formatAlprPopup(props) {
const title = titleHtml(props)
const headline = titleText(props)
const makeModel = [props.manufacturer, props.model].filter(Boolean).join(' ')
const identity = []
if (props.name && makeModel) identity.push(escapeHtml(makeModel))
if (props.operator && headline !== props.operator) {
identity.push(wikidataLink(props.operator, props.operatorWikidata))
}
if (props.ref && headline !== props.ref) identity.push(escapeHtml(props.ref))
if (props.brand) identity.push(escapeHtml(props.brand))
const view = []
if (props.direction != null) {
const deg = Math.round(props.direction)
const compass = compassLabel(props.direction)
view.push(compass ? `Facing ${compass} (${deg}°)` : `Facing ${deg}°`)
}
if (props.fov != null && props.direction != null) view.push(`~${Math.round(props.fov)}° view`)
const note = props.description || props.note
const noteHtml = note ? `<div class="text-kestrel-muted text-xs mt-1">${escapeHtml(note)}</div>` : ''
const identityHtml = identity.length
? `<div class="text-kestrel-muted text-xs mt-0.5">${identity.join(' · ')}</div>`
: ''
const modelHtml = (props.model || props.modelUnknown) ? modelLineHtml(props) : ''
const foot = `<div class="text-kestrel-muted text-xs mt-2"><a href="https://www.openstreetmap.org/node/${props.osmId}" target="_blank" rel="noopener">View on OpenStreetMap</a></div>`
return `<div class="kestrel-live-popup"><strong>${title}</strong> <span class="text-kestrel-muted">License plate reader</span>${modelHtml}${identityHtml}${popupLine(view)}${noteHtml}${foot}</div>`
}
function popupHtml(props) {
return formatAlprPopup(props)
}
function pointIcon(L, direction, fov) {
const hasDirection = Number.isFinite(direction)
if (!hasDirection) {
const html = `<span class="poi-icon-svg"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="${ALPR_COLOR}" stroke-width="2"><rect x="3" y="5" width="18" height="12" rx="2"/><circle cx="12" cy="11" r="3"/><path d="M8 21h8"/></svg></span>`
return L.divIcon({
className: 'poi-div-icon alpr-icon',
html,
iconSize: [ICON_SIZE, ICON_SIZE],
iconAnchor: [ICON_SIZE / 2, ICON_SIZE],
})
}
const spread = Number.isFinite(fov) ? fov : DEFAULT_FOV
const html = `<span class="alpr-cone" style="transform:rotate(${direction}deg)"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 52 52" width="${CONE_SIZE}" height="${CONE_SIZE}"><path d="${conePath(spread)}" fill="${ALPR_COLOR}" fill-opacity="0.3" stroke="${ALPR_COLOR}" stroke-width="1.5"/><circle cx="26" cy="26" r="3" fill="${ALPR_COLOR}"/></svg></span>`
return L.divIcon({
className: 'poi-div-icon alpr-icon',
html,
iconSize: [CONE_SIZE, CONE_SIZE],
iconAnchor: [CONE_SIZE / 2, CONE_SIZE / 2],
})
}
function clusterIcon(L, count) {
const size = count < 10 ? 28 : count < 100 ? 34 : 40
return L.divIcon({
className: 'alpr-cluster-icon',
html: `<span class="alpr-cluster">${count}</span>`,
iconSize: [size, size],
iconAnchor: [size / 2, size / 2],
})
}
export function createAlprControl(L, { showAlpr, onToggle }) {
const control = L.control({ position: 'topleft' })
control.onAdd = function () {
const el = document.createElement('button')
el.type = 'button'
el.className = 'leaflet-bar leaflet-control-alpr'
el.title = 'Toggle ALPR cameras (OSM)'
el.setAttribute('aria-label', 'Toggle ALPR cameras')
el.setAttribute('aria-pressed', showAlpr ? 'true' : 'false')
el.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="20" height="20"><rect x="3" y="5" width="18" height="12" rx="2"/><circle cx="12" cy="11" r="3"/></svg>'
el.addEventListener('click', onToggle)
control._button = el
return el
}
return control
}
export function setAlprControlPressed(control, pressed) {
control?._button?.setAttribute('aria-pressed', pressed ? 'true' : 'false')
}
function featureKey(feature) {
const props = feature.properties ?? {}
if (props.cluster) return `c:${props.cluster_id}`
const id = props.osmId
return id != null ? `a:${id}` : null
}
function coords(feature) {
const [lng, lat] = feature.geometry.coordinates
return { lat, lng }
}
function attachClusterClick(marker, feature, map) {
marker.on('click', () => {
const { lat, lng } = coords(feature)
const props = feature.properties ?? {}
const zoom = props.expansionZoom ?? map.getZoom() + 2
map.setView([lat, lng], Math.min(zoom, 19), { animate: true })
})
}
export function createAlprLayer(L, map) {
return L.layerGroup().addTo(map)
}
export function syncAlprLayer(L, map, layer, features) {
syncFeatureMarkers(layer, features, {
keyFor: featureKey,
create: (feature) => {
const { lat, lng } = coords(feature)
const props = feature.properties ?? {}
const isCluster = Boolean(props.cluster)
const icon = isCluster ? clusterIcon(L, props.point_count) : pointIcon(L, props.direction, props.fov)
const marker = L.marker([lat, lng], { icon })
if (isCluster) attachClusterClick(marker, feature, map)
else marker.bindPopup(popupHtml(props), { className: 'kestrel-live-popup-wrap', maxWidth: 320 })
return marker
},
update: (marker, feature) => {
const { lat, lng } = coords(feature)
const props = feature.properties ?? {}
const isCluster = Boolean(props.cluster)
marker.setLatLng([lat, lng])
const icon = isCluster ? clusterIcon(L, props.point_count) : pointIcon(L, props.direction, props.fov)
marker.setIcon(icon)
if (!isCluster) marker.setPopupContent(popupHtml(props))
},
})
}
+77
View File
@@ -0,0 +1,77 @@
export const MAX_BBOX_DEGREES = 0.5
export function tileKey(row, col) {
return `${row},${col}`
}
export function bboxToTileKey(bbox) {
const row = Math.floor(bbox.south / MAX_BBOX_DEGREES)
const col = Math.floor(bbox.west / MAX_BBOX_DEGREES)
return tileKey(row, col)
}
function tileBox(row, col, step = MAX_BBOX_DEGREES) {
const south = row * step
const west = col * step
return { south, west, north: south + step, east: west + step }
}
export function bboxFetchKey(bounds) {
const zoom = bounds.zoom ?? 14
const step = zoom >= 14 ? 0.025 : zoom >= 11 ? 0.1 : zoom >= 8 ? 0.25 : 1
const q = v => Math.round(v / step) * step
return [q(bounds.south), q(bounds.west), q(bounds.north), q(bounds.east)].join(',')
}
function ringOffsets(radius) {
if (radius === 0) return [[0, 0]]
return Array.from({ length: 2 * radius + 1 }, (_, i) => i - radius)
.flatMap(dr => Array.from({ length: 2 * radius + 1 }, (_, j) => j - radius)
.filter(dc => Math.abs(dr) === radius || Math.abs(dc) === radius)
.map(dc => [dr, dc]))
}
function collectTiles(state) {
const {
centerRow, centerCol, minRow, maxRow, minCol, maxCol, step, limit, radius, seen, tiles,
} = state
if (tiles.length >= limit) return tiles
const inBounds = (row, col) => row >= minRow && row <= maxRow && col >= minCol && col <= maxCol
const { nextSeen, added } = ringOffsets(radius)
.map(([dr, dc]) => [centerRow + dr, centerCol + dc])
.filter(([row, col]) => inBounds(row, col))
.reduce((acc, [row, col]) => {
const key = `${row},${col}`
if (acc.nextSeen.has(key)) return acc
return {
nextSeen: new Set([...acc.nextSeen, key]),
added: [...acc.added, tileBox(row, col, step)],
}
}, { nextSeen: seen, added: [] })
if (added.length === 0 && radius > 0) return tiles
const nextTiles = [...tiles, ...added].slice(0, limit)
if (nextTiles.length >= limit) return nextTiles
return collectTiles({ ...state, radius: radius + 1, seen: nextSeen, tiles: nextTiles })
}
export function tilesNearCenter(bounds, limit) {
const step = MAX_BBOX_DEGREES
const lat = (bounds.south + bounds.north) / 2
const lng = (bounds.west + bounds.east) / 2
return collectTiles({
centerRow: Math.floor(lat / step),
centerCol: Math.floor(lng / step),
minRow: Math.floor(bounds.south / step),
maxRow: Math.ceil(bounds.north / step) - 1,
minCol: Math.floor(bounds.west / step),
maxCol: Math.ceil(bounds.east / step) - 1,
step,
limit,
radius: 0,
seen: new Set(),
tiles: [],
})
}
+131
View File
@@ -0,0 +1,131 @@
/** Map CoT / ADS-B entity display: icons and popups. */
export const COT_COLORS = {
air: '#60a5fa',
helicopter: '#fbbf24',
surface: '#38bdf8',
ground: '#f59e0b',
}
export function cotCategory(type) {
const t = typeof type === 'string' ? type : ''
if (t.startsWith('a-f-A-')) return 'air'
if (t.startsWith('a-f-S-')) return 'surface'
return 'ground'
}
/** Whether the entity is a helicopter or fixed-wing aircraft. @returns {'helicopter' | 'fixedWing'} */
export function cotAirIconKind(entity) {
const type = entity?.type ?? ''
if (type.endsWith('-C-H') || type.endsWith('-M-H')) return 'helicopter'
return 'fixedWing'
}
function iconWrap(heading, inner) {
const rotate = Number.isFinite(heading) ? ` style="transform:rotate(${heading}deg)"` : ''
return `<span class="poi-icon-svg cot-icon-rotatable"${rotate}>${inner}</span>`
}
const PLANE_SVG = color =>
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="${color}"><path d="M21 16v-2l-8-5V3.5a1.5 1.5 0 0 0-3 0V9l-8 5v2l8-2.5V19l-2 1.5V22l3.5-1 3.5 1v-1.5L13 19v-5.5l8 2.5z"/></svg>`
const HELI_SVG = color =>
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="${color}" stroke-width="1.75" stroke-linecap="round"><circle cx="12" cy="12" r="2.5" fill="${color}"/><path d="M3 8h18M3 12h18"/><path d="M12 8v8"/><path d="M9 16h6"/></svg>`
const SHIP_SVG = color =>
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="${color}" stroke-width="2"><path d="M2 20c2-4 6-6 10-6s8 2 10 6"/><path d="M12 14V4"/><path d="m8 8 4-4 4 4"/></svg>`
const GROUND_SVG = color =>
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="${color}" stroke-width="2"><circle cx="12" cy="12" r="9"/><circle cx="12" cy="8" r="2.5" fill="${color}"/></svg>`
export function getCotIconHtml(entity) {
const category = cotCategory(entity?.type)
const heading = Number(entity?.heading)
if (category === 'air') {
const kind = cotAirIconKind(entity)
const color = kind === 'helicopter' ? COT_COLORS.helicopter : COT_COLORS.air
const svg = kind === 'helicopter' ? HELI_SVG(color) : PLANE_SVG(color)
return { html: iconWrap(heading, svg), className: `cot-entity-${kind}` }
}
if (category === 'surface') {
return { html: iconWrap(heading, SHIP_SVG(COT_COLORS.surface)), className: 'cot-entity-surface' }
}
return { html: iconWrap(undefined, GROUND_SVG(COT_COLORS.ground)), className: 'cot-entity-ground' }
}
function msToKnots(ms) {
return Number.isFinite(ms) ? Math.round(ms * 1.94384) : null
}
function metersToFeet(m) {
return Number.isFinite(m) ? Math.round(m * 3.28084) : null
}
function fmtHeading(deg) {
return Number.isFinite(deg) ? `${Math.round(deg)}°` : null
}
function fmtVerticalFpm(ms) {
if (!Number.isFinite(ms) || ms === 0) return null
const fpm = Math.round(ms * 196.85)
return `${fpm > 0 ? '+' : ''}${fpm} fpm`
}
function icaoFromEntity(entity) {
if (entity?.icao) return String(entity.icao).toUpperCase()
if (typeof entity?.id === 'string' && entity.id.startsWith('ICAO.')) {
return entity.id.slice(5).toUpperCase()
}
return null
}
function mmsiFromEntity(entity) {
if (entity?.mmsi) return String(entity.mmsi)
if (typeof entity?.id === 'string' && entity.id.startsWith('MMSI.')) return entity.id.slice(5)
return null
}
function popupLine(escape, parts) {
const line = parts.filter(Boolean).join(' · ')
return line ? `<div class="text-kestrel-muted text-xs mt-1">${line}</div>` : ''
}
/**
* @param {Record<string, unknown>} entity
* @param {(s: string) => string} escape
*/
export function formatCotPopup(entity, escape) {
const category = cotCategory(entity?.type)
const label = escape(entity?.label || entity?.id || 'Unknown')
if (entity?.source === 'adsb' || category === 'air') {
const tag = cotAirIconKind(entity) === 'helicopter' ? 'Helicopter' : 'Aircraft'
const icao = icaoFromEntity(entity)
const meta = [
icao ? `ICAO ${icao}` : null,
entity?.originCountry ? escape(String(entity.originCountry)) : null,
].filter(Boolean).join(' · ')
const alt = metersToFeet(entity?.altitude)
const stats = [
alt != null ? `${alt.toLocaleString()} ft` : null,
entity?.onGround ? 'On ground' : null,
msToKnots(entity?.speed) != null ? `${msToKnots(entity.speed)} kt` : null,
fmtHeading(entity?.heading),
fmtVerticalFpm(entity?.verticalRate),
entity?.squawk ? `Squawk ${escape(String(entity.squawk))}` : null,
]
return `<div class="kestrel-live-popup"><strong>${label}</strong> <span class="text-kestrel-muted">${tag}</span>${meta ? `<div class="text-kestrel-muted text-xs mt-0.5">${meta}</div>` : ''}${popupLine(escape, stats)}</div>`
}
if (entity?.source === 'ais' || category === 'surface') {
const mmsi = mmsiFromEntity(entity)
const meta = mmsi ? `MMSI ${escape(mmsi)}` : ''
const stats = [
Number.isFinite(entity?.speed) ? `${Number(entity.speed).toFixed(1)} kt` : null,
fmtHeading(entity?.heading),
]
return `<div class="kestrel-live-popup"><strong>${label}</strong> <span class="text-kestrel-muted">Vessel</span>${meta ? `<div class="text-kestrel-muted text-xs mt-0.5">${meta}</div>` : ''}${popupLine(escape, stats)}</div>`
}
return `<div class="kestrel-live-popup"><strong>${label}</strong> <span class="text-kestrel-muted">Team</span></div>`
}
+107
View File
@@ -0,0 +1,107 @@
import { createClusterIndex } from './mapCluster.js'
import { syncFeatureMarkers } from './mapMarkerSync.js'
import { cotCategory, formatCotPopup, getCotIconHtml } from './cotDisplay.js'
const ICON_SIZE = 28
const CLUSTER = createClusterIndex({ radius: 50, maxZoom: 14, minPoints: 2 })
function escapeHtml(text) {
const div = document.createElement('div')
div.textContent = text
return div.innerHTML
}
export function entitiesToFeatures(entities) {
return (entities || [])
.filter(e => typeof e?.lat === 'number' && typeof e?.lng === 'number' && e?.id)
.map(e => ({
type: 'Feature',
geometry: { type: 'Point', coordinates: [e.lng, e.lat] },
properties: { entity: e, cotCategory: cotCategory(e.type) },
}))
}
export function loadCotCluster(entities) {
CLUSTER.load(entitiesToFeatures(entities))
}
export function getCotClusters(view) {
return CLUSTER.query(view)
}
function featureKey(feature) {
const props = feature.properties ?? {}
if (props.cluster) return `c:${props.cluster_id}`
const id = props.entity?.id
return id != null ? `e:${id}` : null
}
function clusterIcon(L, count) {
const size = count < 10 ? 28 : count < 100 ? 34 : 40
return L.divIcon({
className: 'cot-cluster-icon',
html: `<span class="cot-cluster">${count}</span>`,
iconSize: [size, size],
iconAnchor: [size / 2, size / 2],
})
}
function entityIcon(L, entity) {
const { html, className } = getCotIconHtml(entity)
return L.divIcon({
className: `poi-div-icon cot-entity-icon ${className}`,
html,
iconSize: [ICON_SIZE, ICON_SIZE],
iconAnchor: [ICON_SIZE / 2, ICON_SIZE / 2],
})
}
function coords(feature) {
const [lng, lat] = feature.geometry.coordinates
return { lat, lng }
}
function attachClusterClick(marker, feature, map) {
marker.on('click', () => {
const { lat, lng } = coords(feature)
const props = feature.properties ?? {}
const zoom = props.expansionZoom ?? map.getZoom() + 2
map.setView([lat, lng], Math.min(zoom, 19), { animate: true })
})
}
export function createCotLayer(L, map) {
return L.layerGroup().addTo(map)
}
export function syncCotLayer(L, map, layer, features) {
syncFeatureMarkers(layer, features, {
keyFor: featureKey,
create: (feature) => {
const { lat, lng } = coords(feature)
const props = feature.properties ?? {}
const isCluster = Boolean(props.cluster)
const icon = isCluster ? clusterIcon(L, props.point_count) : entityIcon(L, props.entity)
const marker = L.marker([lat, lng], { icon })
if (isCluster) attachClusterClick(marker, feature, map)
else if (props.entity) {
marker.bindPopup(
formatCotPopup(props.entity, escapeHtml),
{ className: 'kestrel-live-popup-wrap', maxWidth: 360 },
)
}
return marker
},
update: (marker, feature) => {
const { lat, lng } = coords(feature)
const props = feature.properties ?? {}
const isCluster = Boolean(props.cluster)
marker.setLatLng([lat, lng])
const icon = isCluster ? clusterIcon(L, props.point_count) : entityIcon(L, props.entity)
marker.setIcon(icon)
if (!isCluster && props.entity) {
marker.setPopupContent(formatCotPopup(props.entity, escapeHtml))
}
},
})
}
+31
View File
@@ -0,0 +1,31 @@
import Supercluster from 'supercluster'
export function createClusterIndex(options = {}) {
const index = new Supercluster(options)
const state = { features: Object.freeze([]) }
return {
load(features) {
const list = Object.freeze([...(features ?? [])])
index.load(list)
state.features = list
},
query(view) {
if (!view || state.features.length === 0) return []
const { west, south, east, north, zoom } = view
return index.getClusters(
[west, south, east, north],
Math.floor(zoom ?? 14),
).map((feature) => {
if (!feature.properties?.cluster) return feature
return {
...feature,
properties: {
...feature.properties,
expansionZoom: index.getClusterExpansionZoom(feature.properties.cluster_id),
},
}
})
},
}
}
+37
View File
@@ -0,0 +1,37 @@
const SYNC_KEY = '_kestrelMarkerSync'
const pointFeatures = (features, keyFor) => (features ?? [])
.filter(f => f?.geometry?.type === 'Point')
.map(f => ({ feature: f, key: keyFor(f) }))
.filter(({ key }) => key != null)
export function syncFeatureMarkers(layer, features, { keyFor, create, update }) {
const prev = layer[SYNC_KEY] ?? new Map()
const next = pointFeatures(features, keyFor).reduce((map, { feature, key }) => {
const existing = prev.get(key)
if (existing) {
update(existing, feature)
return new Map([...map, [key, existing]])
}
const marker = create(feature)
layer.addLayer(marker)
return new Map([...map, [key, marker]])
}, new Map())
Array.from(prev.entries())
.filter(([key]) => !next.has(key))
.forEach(([, marker]) => layer.removeLayer(marker))
layer[SYNC_KEY] = next
}
export function clearFeatureMarkers(layer) {
if (!layer) return
const prev = layer[SYNC_KEY]
if (prev) {
Array.from(prev.values()).forEach(marker => layer.removeLayer(marker))
layer[SYNC_KEY] = new Map()
return
}
layer.clearLayers()
}