Files
kestrelos/server/utils/cotEntityUtils.js
T
keligrubb bb01e9a06c
Push / release (push) Successful in 13s
Push / publish (push) Successful in 1m4s
Add ADS-B, AIS, and ALPR map layers with live CoT streaming (#36)
## 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
2026-06-24 20:54:50 +00:00

192 lines
6.5 KiB
JavaScript

/**
* CoT entity helpers: filters and OSINT → CoT mapping.
*/
/**
* @param {string} id
* @returns {'adsb' | 'ais' | 'tak'} Inferred track source.
*/
export function inferSourceFromId(id) {
if (typeof id !== 'string') return 'tak'
if (id.startsWith('ICAO.')) return 'adsb'
if (id.startsWith('MMSI.')) return 'ais'
return 'tak'
}
/**
* @param {string} [type]
* @returns {'air' | 'surface' | 'ground'} CoT display category.
*/
export function cotCategoryFromType(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'
}
/**
* @param {{ lat: number, lng: number }} point
* @param {{ west: number, south: number, east: number, north: number }} bbox
*/
export function isInBbox(point, bbox) {
if (!point || !bbox) return false
const { lat, lng } = point
if (!Number.isFinite(lat) || !Number.isFinite(lng)) return false
return lat >= bbox.south && lat <= bbox.north && lng >= bbox.west && lng <= bbox.east
}
/**
* @param {Set<string> | string[] | undefined} layers
* @param {{ type?: string, source?: string }} entity
*/
export function matchesLayerFilter(layers, entity) {
if (!layers || layers.size === 0) return true
const category = cotCategoryFromType(entity.type)
if (layers.has('air') && category === 'air') return true
if (layers.has('surface') && category === 'surface') return true
if (layers.has('ground') && category === 'ground') return true
return false
}
/** OpenSky emitter category → MilStd CoT air type. */
function openSkyCategoryToType(category) {
if (category === 8) return 'a-f-A-C-H' // rotorcraft
if (category === 14) return 'a-f-A-C-F' // UAV — plane icon
return 'a-f-A-C-F'
}
/** OpenSky state vector → CoT upsert. */
export function openSkyStateToCot(state) {
if (!Array.isArray(state) || state.length < 11) return null
const icao24 = String(state[0] ?? '').trim().toLowerCase()
if (!icao24) return null
const lat = Number(state[6])
const lng = Number(state[5])
if (!Number.isFinite(lat) || !Number.isFinite(lng)) return null
const callsign = typeof state[1] === 'string' ? state[1].trim() : ''
const originCountry = typeof state[2] === 'string' ? state[2].trim() : ''
const category = Number(state[17])
const type = Number.isFinite(category) ? openSkyCategoryToType(category) : 'a-f-A-C-F'
const heading = Number(state[10])
const speed = Number(state[9])
const altitude = Number(state[7])
const verticalRate = Number(state[11])
const onGround = state[8] === true
const squawk = state[14] != null ? String(state[14]).padStart(4, '0') : undefined
return {
id: `ICAO.${icao24}`,
lat,
lng,
label: callsign || icao24.toUpperCase(),
type,
source: 'adsb',
icao: icao24,
originCountry: originCountry || undefined,
heading: Number.isFinite(heading) ? heading : undefined,
speed: Number.isFinite(speed) ? speed : undefined,
altitude: Number.isFinite(altitude) ? altitude : undefined,
verticalRate: Number.isFinite(verticalRate) ? verticalRate : undefined,
onGround: onGround || undefined,
squawk: squawk && squawk !== '0000' ? squawk : undefined,
}
}
/** AISStream position report → CoT upsert. */
export function aisStreamMessageToCot(message) {
if (!message || typeof message !== 'object') return null
const meta = /** @type {Record<string, unknown>} */ (message.MetaData)
const msg = /** @type {Record<string, unknown>} */ (message.Message)
if (!msg || typeof msg !== 'object') return null
const report = /** @type {Record<string, unknown>} */ (
msg.PositionReport ?? msg.StandardClassBPositionReport ?? msg.ExtendedClassBPositionReport
)
if (!report || typeof report !== 'object') return null
const mmsi = Number(meta?.MMSI ?? report.UserID)
if (!Number.isFinite(mmsi)) return null
const lat = Number(report.Latitude)
const lng = Number(report.Longitude)
if (!Number.isFinite(lat) || !Number.isFinite(lng)) return null
const shipName = typeof meta?.ShipName === 'string' ? meta.ShipName.trim() : ''
const heading = Number(report.Cog ?? report.TrueHeading)
const speed = Number(report.Sog)
return {
id: `MMSI.${mmsi}`,
lat,
lng,
label: shipName || `MMSI ${mmsi}`,
type: 'a-f-S-C',
source: 'ais',
mmsi: String(mmsi),
heading: Number.isFinite(heading) ? heading : undefined,
speed: Number.isFinite(speed) ? speed : undefined,
}
}
/**
* Union of subscriber bboxes.
* @param {Array<{ west: number, south: number, east: number, north: number } | null | undefined>} boxes
*/
export function unionBboxes(boxes) {
let west = Infinity
let south = Infinity
let east = -Infinity
let north = -Infinity
let has = false
for (const bbox of boxes) {
if (!bbox) continue
has = true
west = Math.min(west, bbox.west)
south = Math.min(south, bbox.south)
east = Math.max(east, bbox.east)
north = Math.max(north, bbox.north)
}
return has ? { west, south, east, north } : null
}
/**
* Shrink bbox to max span (degrees per axis), centered on midpoint.
* @param {{ west: number, south: number, east: number, north: number } | null} bbox
* @param {number} maxDegrees
*/
export function clampBbox(bbox, maxDegrees) {
if (!bbox || !Number.isFinite(maxDegrees) || maxDegrees <= 0) return bbox
const latSpan = bbox.north - bbox.south
const lngSpan = bbox.east - bbox.west
if (latSpan <= maxDegrees && lngSpan <= maxDegrees) return bbox
const latMid = (bbox.north + bbox.south) / 2
const lngMid = (bbox.east + bbox.west) / 2
const half = maxDegrees / 2
return {
south: latMid - half,
north: latMid + half,
west: lngMid - half,
east: lngMid + half,
}
}
/**
* @param {string | undefined} raw
* @returns {Set<string>} Enabled layer names.
*/
export function parseLayersParam(raw) {
if (!raw || typeof raw !== 'string') {
return new Set(['air', 'surface', 'ground'])
}
const parts = raw.split(',').map(s => s.trim().toLowerCase()).filter(Boolean)
if (parts.length === 0 || parts.includes('none')) return new Set()
return new Set(parts)
}
/**
* @param {string | undefined} raw
* @returns {{ west: number, south: number, east: number, north: number } | null} Parsed bbox or null.
*/
export function parseBboxParam(raw) {
if (!raw || typeof raw !== 'string') return null
const parts = raw.split(',').map(s => Number(s.trim()))
if (parts.length !== 4 || parts.some(n => !Number.isFinite(n))) return null
const [west, south, east, north] = parts
if (south > north || west > east) return null
return { west, south, east, north }
}