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
192 lines
6.5 KiB
JavaScript
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 }
|
|
}
|