bb01e9a06c
## Summary - **ADS-B & AIS:** OpenSky and AISStream OSINT feeds upsert into the CoT store; tactical tracks still arrive via adsbcot/aiscot on `:8089`. Map clients subscribe via `GET /api/cot/stream` (SSE) with viewport bbox filtering and Air / Surface / Team layer toggles. - **ALPR (Flock/OSM):** Toggleable license-plate reader layer sourced from OpenStreetMap, with SQLite cache, Overpass fallback, tiled viewport fetching, and clustered markers with direction cones. - **Map performance:** Ring-based tile selection (fixes zoom-out crash), immutable tile cache, incremental marker sync, split cluster load/query, and padded SSE bbox to reduce reconnect churn. ## Docs - `docs/tracking.md` — ADS-B/AIS accuracy tiers, freshness, self-hosted receivers, optional OSINT API keys - `docs/map-and-cameras.md` — ALPR layer and map behavior updates --------- Co-authored-by: Madison Grubb <madison@elastiflow.com> Reviewed-on: #36
108 lines
3.2 KiB
JavaScript
108 lines
3.2 KiB
JavaScript
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))
|
|
}
|
|
},
|
|
})
|
|
}
|