import {BASE} from '@/services/api.ts'; import {h, render as vueRender} from 'vue'; import Map from 'ol/Map'; import {Feature} from 'ol'; import Point from 'ol/geom/Point'; import LineString from 'ol/geom/LineString'; import {fromLonLat} from 'ol/proj'; import {Style, Icon, Stroke} from 'ol/style'; import {Vector as VectorLayer} from 'ol/layer'; import {Vector as VectorSource} from 'ol/source'; import {adjustedInterval} from '@ztimson/utils'; import AircraftPopup from '@/components/Aircraft.vue'; import {bringToFront} from './zindex'; const API = BASE + '/api'; const EARTH_R = 6371; const AUTO_RANGE = 92.6; function greatCircleDist(lat1: number, lon1: number, lat2: number, lon2: number): number { const toRad = (d: number) => d * Math.PI / 180; const dLat = toRad(lat2 - lat1), dLon = toRad(lon2 - lon1); const a = Math.sin(dLat / 2) ** 2 + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2; return EARTH_R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); } function isPriorityAircraft(plane: any): boolean { if (plane.military === true || plane.is_military === true || plane.sar === true || plane.is_sar === true) return true; const text = [ plane.type, plane.category, plane.aircraft_type, plane.operator, plane.owner, plane.flight, plane.callsign, plane.description, ].filter(Boolean).join(' ').toLowerCase(); return /\b(military|mil|sar|search and rescue|rescue|coast guard|navy|army|air force|airforce|usaf|rcaf)\b/.test(text); } function getAltColor(alt: number): [number, number, number] { const a = Math.max(0, alt); if (a < 10) return [0, 0, 0]; if (a < 10000) return [135, 206, 250]; if (a < 25000) { const r = (a - 10000) / 15000; return [Math.round(135 * (1 - r)), Math.round(206 * (1 - r)), Math.round(250 * (1 - r) + 255 * r)]; } if (a < 40000) { const r = (a - 25000) / 15000; return [0, 0, Math.round(255 * (1 - r) + 139 * r)]; } const r = Math.min((a - 40000) / 10000, 1); return [Math.round(128 * r), 0, 139]; } export class AirTrafficLayer { private map: Map; private layer!: VectorLayer; private traceLayers: Record> = {}; private traceSegs: Record = {}; private popups: any = {}; private historyCache: any = {}; private data: any[] = []; private clickHandler: ((e: any) => void) | null = null; private refreshTimer: any = null; visible = false; constructor(map: Map) { this.map = map; } async show() { if (this.visible) return; this.visible = true; this.layer = new VectorLayer({source: new VectorSource(), zIndex: 100}); this.map.addLayer(this.layer); await this._fetch(); this._draw(); this._attachClick(); this.refreshTimer = adjustedInterval(async () => { try { await this._fetch(); this._draw(); this._refreshPopups(); } catch (err) { console.error(err); } }, 1_000); } hide() { if (!this.visible) return; this.visible = false; if (this.refreshTimer) { clearInterval(this.refreshTimer); this.refreshTimer = null; } if (this.clickHandler) { this.map.un('singleclick', this.clickHandler); this.clickHandler = null; } for (const icao of Object.keys(this.popups)) this._closePopup(icao); if (this.layer) this.map.removeLayer(this.layer); Object.values(this.traceLayers).forEach(l => this.map.removeLayer(l)); this.traceLayers = {}; this.traceSegs = {}; } getClosest(latitude: number, longitude: number): any | null { let closest: any = null; let closestDistance = Infinity; for (const plane of this.data) { if (plane.latitude == null || plane.longitude == null) continue; const distance = greatCircleDist(latitude, longitude, plane.latitude, plane.longitude); if (distance < closestDistance) { closestDistance = distance; closest = plane; } } return closest; } getAutoCandidates(latitude: number, longitude: number, limit = Infinity): any[] { return this.data.filter(plane => { if (plane.latitude == null || plane.longitude == null) return false; return greatCircleDist(latitude, longitude, plane.latitude, plane.longitude) <= AUTO_RANGE; }).sort((a, b) => { const priority = Number(isPriorityAircraft(b)) - Number(isPriorityAircraft(a)); if (priority) return priority; return greatCircleDist(latitude, longitude, a.latitude, a.longitude) - greatCircleDist(latitude, longitude, b.latitude, b.longitude); }).slice(0, limit); } isPriority(plane: any): boolean { return isPriorityAircraft(plane); } getAutoDistance(latitude: number, longitude: number, plane: any): number { return greatCircleDist(latitude, longitude, plane.latitude, plane.longitude); } getById(icao: string): any | null { return this.data.find(p => String(p.icao) === String(icao)) ?? null; } openAutoPopup(plane: any) { this._openPopup(plane); } closeAutoPopup(icao: string) { this._closePopup(String(icao)); } private async _fetch() { const j = await fetch(`${API}/adsb`).then(r => r.ok ? r.json() : []); this.data = (j || []).map((a: any) => ({ ...a, icao: a.hex, latitude: a.lat, longitude: a.lon, heading: a.track, speed: a.gs, climb: a.baro_rate ?? a.geom_rate ?? 0, name: a.flight?.trim(), })); } private _draw() { const source = this.layer.getSource()!; source.clear(); for (const plane of this.data) { if (plane.latitude == null || plane.longitude == null) continue; const coord = fromLonLat([plane.longitude, plane.latitude]); const f = new Feature({geometry: new Point(coord)}); f.set('icao', plane.icao); f.set('planeData', plane); f.setStyle(new Style({ image: new Icon({ src: `data:image/svg+xml,${encodeURIComponent(plane.icon)}`, scale: 1.25, rotation: (plane.heading ?? 0) * (Math.PI / 180), anchor: [0.5, 0.5], }), })); source.addFeature(f); } } private async _fetchTrace(plane: any) { const icao = plane.icao; if (!this.historyCache[icao]) { const j = await fetch(`${API}/adsb/${icao}`).then(r => r.json()); this.historyCache[icao] = j.history || []; } const history = this.historyCache[icao]; const cur = { ts: Math.floor(Date.now() / 1000), latitude: plane.latitude, longitude: plane.longitude, altitude: plane.alt_baro ?? plane.alt_geom ?? 0, live: true, }; const last = history[history.length - 1]; if (!last || last.latitude !== cur.latitude || last.longitude !== cur.longitude) history.push(cur); const traceHistory = history; if (traceHistory.length < 2) { const popup = this.popups[icao]; if (popup) popup.update(plane, history); return; } let traceLayer = this.traceLayers[icao]; let vs: VectorSource; if (!traceLayer) { vs = new VectorSource(); traceLayer = new VectorLayer({source: vs, zIndex: 99}); this.map.addLayer(traceLayer); this.traceLayers[icao] = traceLayer; this.traceSegs[icao] = []; } else { vs = traceLayer.getSource()!; } const segs: any = this.traceSegs[icao]; for (let i = segs.length; i < traceHistory.length - 1; i++) { const s = traceHistory[i], e = traceHistory[i + 1]; const sc = getAltColor(s.altitude || 0), ec: any = getAltColor(e.altitude || 0); const avg = sc.map((v, idx) => Math.round((v + ec[idx]) / 2)); const f = new Feature(new LineString([ fromLonLat([s.longitude, s.latitude]), fromLonLat([e.longitude, e.latitude]), ])); f.setStyle(new Style({ stroke: new Stroke({ color: `rgba(${avg.join(',')},0.8)`, width: 3, }), })); vs.addFeature(f); segs.push(f); } const popup = this.popups[icao]; if (popup) popup.update(plane, history); } private _openPopup(plane: any) { const icao = String(plane.icao); if (this.popups[icao]) return; const container = document.createElement('div'); document.body.appendChild(container); const mobile = window.innerWidth <= 768; const makeProps = (p: any, history: any[] = []) => ({ plane: p, history, position: mobile ? {x: window.innerWidth / 2 - 180, y: window.innerHeight - 540 - 16} : {x: 16, y: 16}, onClose: () => this._closePopup(icao), onBringToFront: () => bringToFront(container), }); vueRender(h(AircraftPopup, makeProps(plane, this.historyCache[icao] || [])), container); this.popups[icao] = { update: (p: any, history: any[] = []) => vueRender(h(AircraftPopup, makeProps(p, history)), container), unmount: () => { vueRender(null, container); container.remove(); }, }; this._fetchTrace(plane); } private _closePopup(icao: string) { const popup = this.popups[icao]; if (popup) { popup.unmount(); delete this.popups[icao]; } const segs = this.traceSegs[icao], layer = this.traceLayers[icao]; if (segs && layer) segs.forEach(f => layer.getSource()!.removeFeature(f)); if (layer) { this.map.removeLayer(layer); delete this.traceLayers[icao]; } delete this.traceSegs[icao]; } private _refreshPopups() { for (const icao of Object.keys(this.popups)) { const plane = this.data.find(p => p.icao === icao); if (!plane) { this._closePopup(icao); continue; } this._fetchTrace(plane); } } private _attachClick() { this.clickHandler = evt => { const f = this.map.forEachFeatureAtPixel(evt.pixel, f => f.get('planeData') ? f : null); if (!f) return; const plane = f.get('planeData'); if (this.popups[plane.icao]) { this._closePopup(plane.icao); return; } this._openPopup(plane); }; this.map.on('singleclick', this.clickHandler); } }