/** * 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[] | 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} */ (message.MetaData) const msg = /** @type {Record} */ (message.Message) if (!msg || typeof msg !== 'object') return null const report = /** @type {Record} */ ( 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} 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 } }