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:
@@ -0,0 +1,191 @@
|
||||
/**
|
||||
* 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 }
|
||||
}
|
||||
Reference in New Issue
Block a user