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
132 lines
5.3 KiB
JavaScript
132 lines
5.3 KiB
JavaScript
/** Map CoT / ADS-B entity display: icons and popups. */
|
|
|
|
export const COT_COLORS = {
|
|
air: '#60a5fa',
|
|
helicopter: '#fbbf24',
|
|
surface: '#38bdf8',
|
|
ground: '#f59e0b',
|
|
}
|
|
|
|
export function cotCategory(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'
|
|
}
|
|
|
|
/** Whether the entity is a helicopter or fixed-wing aircraft. @returns {'helicopter' | 'fixedWing'} */
|
|
export function cotAirIconKind(entity) {
|
|
const type = entity?.type ?? ''
|
|
if (type.endsWith('-C-H') || type.endsWith('-M-H')) return 'helicopter'
|
|
return 'fixedWing'
|
|
}
|
|
|
|
function iconWrap(heading, inner) {
|
|
const rotate = Number.isFinite(heading) ? ` style="transform:rotate(${heading}deg)"` : ''
|
|
return `<span class="poi-icon-svg cot-icon-rotatable"${rotate}>${inner}</span>`
|
|
}
|
|
|
|
const PLANE_SVG = color =>
|
|
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="${color}"><path d="M21 16v-2l-8-5V3.5a1.5 1.5 0 0 0-3 0V9l-8 5v2l8-2.5V19l-2 1.5V22l3.5-1 3.5 1v-1.5L13 19v-5.5l8 2.5z"/></svg>`
|
|
|
|
const HELI_SVG = color =>
|
|
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="${color}" stroke-width="1.75" stroke-linecap="round"><circle cx="12" cy="12" r="2.5" fill="${color}"/><path d="M3 8h18M3 12h18"/><path d="M12 8v8"/><path d="M9 16h6"/></svg>`
|
|
|
|
const SHIP_SVG = color =>
|
|
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="${color}" stroke-width="2"><path d="M2 20c2-4 6-6 10-6s8 2 10 6"/><path d="M12 14V4"/><path d="m8 8 4-4 4 4"/></svg>`
|
|
|
|
const GROUND_SVG = color =>
|
|
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="${color}" stroke-width="2"><circle cx="12" cy="12" r="9"/><circle cx="12" cy="8" r="2.5" fill="${color}"/></svg>`
|
|
|
|
export function getCotIconHtml(entity) {
|
|
const category = cotCategory(entity?.type)
|
|
const heading = Number(entity?.heading)
|
|
if (category === 'air') {
|
|
const kind = cotAirIconKind(entity)
|
|
const color = kind === 'helicopter' ? COT_COLORS.helicopter : COT_COLORS.air
|
|
const svg = kind === 'helicopter' ? HELI_SVG(color) : PLANE_SVG(color)
|
|
return { html: iconWrap(heading, svg), className: `cot-entity-${kind}` }
|
|
}
|
|
if (category === 'surface') {
|
|
return { html: iconWrap(heading, SHIP_SVG(COT_COLORS.surface)), className: 'cot-entity-surface' }
|
|
}
|
|
return { html: iconWrap(undefined, GROUND_SVG(COT_COLORS.ground)), className: 'cot-entity-ground' }
|
|
}
|
|
|
|
function msToKnots(ms) {
|
|
return Number.isFinite(ms) ? Math.round(ms * 1.94384) : null
|
|
}
|
|
|
|
function metersToFeet(m) {
|
|
return Number.isFinite(m) ? Math.round(m * 3.28084) : null
|
|
}
|
|
|
|
function fmtHeading(deg) {
|
|
return Number.isFinite(deg) ? `${Math.round(deg)}°` : null
|
|
}
|
|
|
|
function fmtVerticalFpm(ms) {
|
|
if (!Number.isFinite(ms) || ms === 0) return null
|
|
const fpm = Math.round(ms * 196.85)
|
|
return `${fpm > 0 ? '+' : ''}${fpm} fpm`
|
|
}
|
|
|
|
function icaoFromEntity(entity) {
|
|
if (entity?.icao) return String(entity.icao).toUpperCase()
|
|
if (typeof entity?.id === 'string' && entity.id.startsWith('ICAO.')) {
|
|
return entity.id.slice(5).toUpperCase()
|
|
}
|
|
return null
|
|
}
|
|
|
|
function mmsiFromEntity(entity) {
|
|
if (entity?.mmsi) return String(entity.mmsi)
|
|
if (typeof entity?.id === 'string' && entity.id.startsWith('MMSI.')) return entity.id.slice(5)
|
|
return null
|
|
}
|
|
|
|
function popupLine(escape, parts) {
|
|
const line = parts.filter(Boolean).join(' · ')
|
|
return line ? `<div class="text-kestrel-muted text-xs mt-1">${line}</div>` : ''
|
|
}
|
|
|
|
/**
|
|
* @param {Record<string, unknown>} entity
|
|
* @param {(s: string) => string} escape
|
|
*/
|
|
export function formatCotPopup(entity, escape) {
|
|
const category = cotCategory(entity?.type)
|
|
const label = escape(entity?.label || entity?.id || 'Unknown')
|
|
|
|
if (entity?.source === 'adsb' || category === 'air') {
|
|
const tag = cotAirIconKind(entity) === 'helicopter' ? 'Helicopter' : 'Aircraft'
|
|
const icao = icaoFromEntity(entity)
|
|
const meta = [
|
|
icao ? `ICAO ${icao}` : null,
|
|
entity?.originCountry ? escape(String(entity.originCountry)) : null,
|
|
].filter(Boolean).join(' · ')
|
|
const alt = metersToFeet(entity?.altitude)
|
|
const stats = [
|
|
alt != null ? `${alt.toLocaleString()} ft` : null,
|
|
entity?.onGround ? 'On ground' : null,
|
|
msToKnots(entity?.speed) != null ? `${msToKnots(entity.speed)} kt` : null,
|
|
fmtHeading(entity?.heading),
|
|
fmtVerticalFpm(entity?.verticalRate),
|
|
entity?.squawk ? `Squawk ${escape(String(entity.squawk))}` : null,
|
|
]
|
|
return `<div class="kestrel-live-popup"><strong>${label}</strong> <span class="text-kestrel-muted">${tag}</span>${meta ? `<div class="text-kestrel-muted text-xs mt-0.5">${meta}</div>` : ''}${popupLine(escape, stats)}</div>`
|
|
}
|
|
|
|
if (entity?.source === 'ais' || category === 'surface') {
|
|
const mmsi = mmsiFromEntity(entity)
|
|
const meta = mmsi ? `MMSI ${escape(mmsi)}` : ''
|
|
const stats = [
|
|
Number.isFinite(entity?.speed) ? `${Number(entity.speed).toFixed(1)} kt` : null,
|
|
fmtHeading(entity?.heading),
|
|
]
|
|
return `<div class="kestrel-live-popup"><strong>${label}</strong> <span class="text-kestrel-muted">Vessel</span>${meta ? `<div class="text-kestrel-muted text-xs mt-0.5">${meta}</div>` : ''}${popupLine(escape, stats)}</div>`
|
|
}
|
|
|
|
return `<div class="kestrel-live-popup"><strong>${label}</strong> <span class="text-kestrel-muted">Team</span></div>`
|
|
}
|