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
221 lines
8.3 KiB
JavaScript
221 lines
8.3 KiB
JavaScript
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))
|
|
},
|
|
})
|
|
}
|