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:
+1
-1
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<NuxtLayout>
|
||||
<NuxtPage :key="$route.path" />
|
||||
<NuxtPage />
|
||||
</NuxtLayout>
|
||||
</template>
|
||||
|
||||
+38
-2
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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: [],
|
||||
})
|
||||
}
|
||||
@@ -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>`
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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),
|
||||
},
|
||||
}
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
Reference in New Issue
Block a user