358 lines
9.4 KiB
TypeScript
358 lines
9.4 KiB
TypeScript
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<VectorSource>;
|
|
private traceLayers: Record<string, VectorLayer<VectorSource>> = {};
|
|
private traceSegs: Record<string, Feature[]> = {};
|
|
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);
|
|
}
|
|
}
|