Radio sonde + auto mode
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -1,213 +1,346 @@
|
|||||||
import { BASE } from '@/services/api.ts'
|
import {BASE} from '@/services/api.ts';
|
||||||
import { h, render as vueRender } from 'vue'
|
import {h, render as vueRender} from 'vue';
|
||||||
import Map from 'ol/Map'
|
import Map from 'ol/Map';
|
||||||
import { Feature } from 'ol'
|
import {Feature} from 'ol';
|
||||||
import Point from 'ol/geom/Point'
|
import Point from 'ol/geom/Point';
|
||||||
import LineString from 'ol/geom/LineString'
|
import LineString from 'ol/geom/LineString';
|
||||||
import { fromLonLat } from 'ol/proj'
|
import {fromLonLat} from 'ol/proj';
|
||||||
import { Style, Icon, Stroke } from 'ol/style'
|
import {Style, Icon, Stroke} from 'ol/style';
|
||||||
import { Vector as VectorLayer } from 'ol/layer'
|
import {Vector as VectorLayer} from 'ol/layer';
|
||||||
import { Vector as VectorSource } from 'ol/source'
|
import {Vector as VectorSource} from 'ol/source';
|
||||||
import { adjustedInterval } from '@ztimson/utils'
|
import {adjustedInterval} from '@ztimson/utils';
|
||||||
import AircraftPopup from '@/components/Aircraft.vue'
|
import AircraftPopup from '@/components/Aircraft.vue';
|
||||||
import { bringToFront } from './zindex'
|
import {bringToFront} from './zindex';
|
||||||
|
|
||||||
const API = BASE + '/api'
|
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] {
|
function getAltColor(alt: number): [number, number, number] {
|
||||||
const a = Math.max(0, alt)
|
const a = Math.max(0, alt);
|
||||||
if (a < 10) return [0, 0, 0]
|
if (a < 10) return [0, 0, 0];
|
||||||
if (a < 10000) return [135, 206, 250]
|
if (a < 10000) return [135, 206, 250];
|
||||||
|
|
||||||
if (a < 25000) {
|
if (a < 25000) {
|
||||||
const r = (a - 10000) / 15000
|
const r = (a - 10000) / 15000;
|
||||||
return [Math.round(135 * (1-r)), Math.round(206 * (1-r)), Math.round(250 * (1-r) + 255 * r)]
|
return [Math.round(135 * (1 - r)), Math.round(206 * (1 - r)), Math.round(250 * (1 - r) + 255 * r)];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (a < 40000) {
|
if (a < 40000) {
|
||||||
const r = (a - 25000) / 15000
|
const r = (a - 25000) / 15000;
|
||||||
return [0, 0, Math.round(255 * (1-r) + 139 * r)]
|
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]
|
const r = Math.min((a - 40000) / 10000, 1);
|
||||||
|
return [Math.round(128 * r), 0, 139];
|
||||||
}
|
}
|
||||||
|
|
||||||
export class AirTrafficLayer {
|
export class AirTrafficLayer {
|
||||||
private map: Map
|
private map: Map;
|
||||||
private layer!: VectorLayer<VectorSource>
|
private layer!: VectorLayer<VectorSource>;
|
||||||
private traceLayers: Record<string, VectorLayer<VectorSource>> = {}
|
private traceLayers: Record<string, VectorLayer<VectorSource>> = {};
|
||||||
private traceSegs: Record<string, Feature[]> = {}
|
private traceSegs: Record<string, Feature[]> = {};
|
||||||
private popups: any = {}
|
private popups: any = {};
|
||||||
private historyCache: any = {}
|
private historyCache: any = {};
|
||||||
private data: any[] = []
|
private data: any[] = [];
|
||||||
private clickHandler: ((e: any) => void) | null = null
|
private clickHandler: ((e: any) => void) | null = null;
|
||||||
private refreshTimer: any = null
|
private refreshTimer: any = null;
|
||||||
visible = false
|
visible = false;
|
||||||
|
|
||||||
constructor(map: Map) { this.map = map }
|
constructor(map: Map) {
|
||||||
|
this.map = map;
|
||||||
|
}
|
||||||
|
|
||||||
async show() {
|
async show() {
|
||||||
if (this.visible) return
|
if (this.visible) return;
|
||||||
this.visible = true
|
|
||||||
this.layer = new VectorLayer({ source: new VectorSource(), zIndex: 100 })
|
this.visible = true;
|
||||||
this.map.addLayer(this.layer)
|
this.layer = new VectorLayer({source: new VectorSource(), zIndex: 100});
|
||||||
await this._fetch()
|
this.map.addLayer(this.layer);
|
||||||
this._draw()
|
|
||||||
this._attachClick()
|
await this._fetch();
|
||||||
|
this._draw();
|
||||||
|
this._attachClick();
|
||||||
|
|
||||||
this.refreshTimer = adjustedInterval(async () => {
|
this.refreshTimer = adjustedInterval(async () => {
|
||||||
try {
|
try {
|
||||||
await this._fetch()
|
await this._fetch();
|
||||||
this._draw()
|
this._draw();
|
||||||
this._refreshPopups()
|
this._refreshPopups();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err)
|
console.error(err);
|
||||||
}
|
}
|
||||||
}, 1_000)
|
}, 1_000);
|
||||||
}
|
}
|
||||||
|
|
||||||
hide() {
|
hide() {
|
||||||
if (!this.visible) return
|
if (!this.visible) return;
|
||||||
this.visible = false
|
|
||||||
if (this.refreshTimer) { clearInterval(this.refreshTimer); this.refreshTimer = null }
|
this.visible = false;
|
||||||
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.refreshTimer) {
|
||||||
this.map.removeLayer(this.layer)
|
clearInterval(this.refreshTimer);
|
||||||
Object.values(this.traceLayers).forEach(l => this.map.removeLayer(l))
|
this.refreshTimer = null;
|
||||||
this.traceLayers = {}
|
}
|
||||||
this.traceSegs = {}
|
|
||||||
|
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() {
|
private async _fetch() {
|
||||||
const j = await fetch(`${API}/adsb`).then(r => r.ok ? r.json() : []);
|
const j = await fetch(`${API}/adsb`).then(r => r.ok ? r.json() : []);
|
||||||
this.data = (j || []).map((a: any) => ({
|
this.data = (j || []).map((a: any) => ({
|
||||||
...a,
|
...a,
|
||||||
icao: a.hex,
|
icao: a.hex,
|
||||||
latitude: a.lat,
|
latitude: a.lat,
|
||||||
longitude: a.lon,
|
longitude: a.lon,
|
||||||
heading: a.track,
|
heading: a.track,
|
||||||
speed: a.gs,
|
speed: a.gs,
|
||||||
climb: a.baro_rate ?? a.geom_rate ?? 0,
|
climb: a.baro_rate ?? a.geom_rate ?? 0,
|
||||||
name: a.flight?.trim(),
|
name: a.flight?.trim(),
|
||||||
}))
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
private _draw() {
|
private _draw() {
|
||||||
const source = this.layer.getSource()!
|
const source = this.layer.getSource()!;
|
||||||
source.clear()
|
source.clear();
|
||||||
|
|
||||||
for (const plane of this.data) {
|
for (const plane of this.data) {
|
||||||
if (plane.latitude == null) continue
|
if (plane.latitude == null || plane.longitude == null) continue;
|
||||||
const coord = fromLonLat([plane.longitude, plane.latitude])
|
|
||||||
const f = new Feature({ geometry: new Point(coord) })
|
const coord = fromLonLat([plane.longitude, plane.latitude]);
|
||||||
f.set('icao', plane.icao)
|
const f = new Feature({geometry: new Point(coord)});
|
||||||
f.set('planeData', plane)
|
|
||||||
|
f.set('icao', plane.icao);
|
||||||
|
f.set('planeData', plane);
|
||||||
f.setStyle(new Style({
|
f.setStyle(new Style({
|
||||||
image: new Icon({
|
image: new Icon({
|
||||||
src: `data:image/svg+xml,${encodeURIComponent(plane.icon)}`,
|
src: `data:image/svg+xml,${encodeURIComponent(plane.icon)}`,
|
||||||
scale: 1.25,
|
scale: 1.25,
|
||||||
rotation: (plane.heading ?? 0) * (Math.PI / 180),
|
rotation: (plane.heading ?? 0) * (Math.PI / 180),
|
||||||
anchor: [0.5, 0.5],
|
anchor: [0.5, 0.5],
|
||||||
}),
|
}),
|
||||||
}))
|
}));
|
||||||
source.addFeature(f)
|
|
||||||
|
source.addFeature(f);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async _fetchTrace(plane: any) {
|
private async _fetchTrace(plane: any) {
|
||||||
const icao = plane.icao
|
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 = { latitude: plane.latitude, longitude: plane.longitude, altitude: plane.alt_baro ?? plane.alt_geom ?? 0 }
|
|
||||||
const last = history[history.length - 1]
|
|
||||||
if (!last || last.latitude !== cur.latitude || last.longitude !== cur.longitude) history.push(cur)
|
|
||||||
if (history.length < 2) return
|
|
||||||
|
|
||||||
let traceLayer = this.traceLayers[icao]
|
if (!this.historyCache[icao]) {
|
||||||
let vs: VectorSource
|
const j = await fetch(`${API}/adsb/${icao}`).then(r => r.json());
|
||||||
|
this.historyCache[icao] = j.history || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const history = this.historyCache[icao];
|
||||||
|
const cur = {
|
||||||
|
latitude: plane.latitude,
|
||||||
|
longitude: plane.longitude,
|
||||||
|
altitude: plane.alt_baro ?? plane.alt_geom ?? 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const last = history[history.length - 1];
|
||||||
|
if (!last || last.latitude !== cur.latitude || last.longitude !== cur.longitude) history.push(cur);
|
||||||
|
if (history.length < 2) return;
|
||||||
|
|
||||||
|
let traceLayer = this.traceLayers[icao];
|
||||||
|
let vs: VectorSource;
|
||||||
|
|
||||||
if (!traceLayer) {
|
if (!traceLayer) {
|
||||||
vs = new VectorSource()
|
vs = new VectorSource();
|
||||||
traceLayer = new VectorLayer({ source: vs, zIndex: 99 })
|
traceLayer = new VectorLayer({source: vs, zIndex: 99});
|
||||||
this.map.addLayer(traceLayer)
|
this.map.addLayer(traceLayer);
|
||||||
this.traceLayers[icao] = traceLayer
|
this.traceLayers[icao] = traceLayer;
|
||||||
this.traceSegs[icao] = []
|
this.traceSegs[icao] = [];
|
||||||
} else {
|
} else {
|
||||||
vs = traceLayer.getSource()!
|
vs = traceLayer.getSource()!;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only draw segments that haven't been drawn yet
|
const segs: any = this.traceSegs[icao];
|
||||||
const segs = <any>this.traceSegs[icao]
|
|
||||||
const start = segs.length
|
|
||||||
for (let i = start; i < history.length - 1; i++) {
|
|
||||||
const s = history[i], e = history[i + 1]
|
|
||||||
const sc = getAltColor(s.altitude || 0)
|
|
||||||
const ec = getAltColor(e.altitude || 0)
|
|
||||||
const avg = sc.map((v, idx) => Math.round((v + (ec[idx] as any)) / 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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private _calcPopupPos(plane: any): { x: number; y: number } {
|
for (let i = segs.length; i < history.length - 1; i++) {
|
||||||
const pixel: any = this.map.getPixelFromCoordinate(fromLonLat([plane.longitude, plane.latitude]))
|
const s = history[i], e = history[i + 1];
|
||||||
if (!pixel) return { x: 10, y: 60 }
|
const sc = getAltColor(s.altitude || 0), ec: any = getAltColor(e.altitude || 0);
|
||||||
const rect = (this.map.getTargetElement() as HTMLElement).getBoundingClientRect()
|
const avg = sc.map((v, idx) => Math.round((v + ec[idx]) / 2));
|
||||||
return { x: rect.left + pixel[0] + 16, y: rect.top + pixel[1] - 16 }
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private _openPopup(plane: any) {
|
private _openPopup(plane: any) {
|
||||||
const icao = plane.icao
|
const icao = String(plane.icao);
|
||||||
if (this.popups[icao]) return
|
if (this.popups[icao]) return;
|
||||||
|
|
||||||
const container = document.createElement('div')
|
const container = document.createElement('div');
|
||||||
document.body.appendChild(container)
|
document.body.appendChild(container);
|
||||||
const mobile = window.innerWidth <= 768
|
|
||||||
|
|
||||||
|
const mobile = window.innerWidth <= 768;
|
||||||
const makeProps = (p: any) => ({
|
const makeProps = (p: any) => ({
|
||||||
plane: p,
|
plane: p,
|
||||||
position: mobile ? { x: window.innerWidth / 2 - 180, y: window.innerHeight - 540 - 16 } : { x: 16, y: 16 },
|
position: mobile
|
||||||
onClose: () => this._closePopup(icao),
|
? {x: window.innerWidth / 2 - 180, y: window.innerHeight - 540 - 16}
|
||||||
|
: {x: 16, y: 16},
|
||||||
|
onClose: () => this._closePopup(icao),
|
||||||
onBringToFront: () => bringToFront(container),
|
onBringToFront: () => bringToFront(container),
|
||||||
})
|
});
|
||||||
|
|
||||||
vueRender(h(AircraftPopup, makeProps(plane)), container)
|
vueRender(h(AircraftPopup, makeProps(plane)), container);
|
||||||
|
|
||||||
this.popups[icao] = {
|
this.popups[icao] = {
|
||||||
update: (p: any) => vueRender(h(AircraftPopup, makeProps(p)), container),
|
update: (p: any) => vueRender(h(AircraftPopup, makeProps(p)), container),
|
||||||
unmount: () => { vueRender(null, container); container.remove() },
|
unmount: () => {
|
||||||
}
|
vueRender(null, container);
|
||||||
this._fetchTrace(plane)
|
container.remove();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
this._fetchTrace(plane);
|
||||||
}
|
}
|
||||||
|
|
||||||
private _closePopup(icao: string) {
|
private _closePopup(icao: string) {
|
||||||
const popup = this.popups[icao]
|
const popup = this.popups[icao];
|
||||||
if (popup) { popup.unmount(); delete this.popups[icao] }
|
|
||||||
const segs = this.traceSegs[icao]
|
if (popup) {
|
||||||
const layer = this.traceLayers[icao]
|
popup.unmount();
|
||||||
if (segs && layer) segs.forEach(f => layer.getSource()!.removeFeature(f))
|
delete this.popups[icao];
|
||||||
if (layer) { this.map.removeLayer(layer); delete this.traceLayers[icao] }
|
}
|
||||||
delete this.traceSegs[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() {
|
private _refreshPopups() {
|
||||||
for (const icao of Object.keys(this.popups)) {
|
for (const icao of Object.keys(this.popups)) {
|
||||||
const plane = this.data.find(p => p.icao === icao)
|
const plane = this.data.find(p => p.icao === icao);
|
||||||
if (!plane) { this._closePopup(icao); continue }
|
|
||||||
this.popups[icao].update(plane)
|
if (!plane) {
|
||||||
this._fetchTrace(plane)
|
this._closePopup(icao);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.popups[icao].update(plane);
|
||||||
|
this._fetchTrace(plane);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private _attachClick() {
|
private _attachClick() {
|
||||||
this.clickHandler = (evt) => {
|
this.clickHandler = evt => {
|
||||||
const f = this.map.forEachFeatureAtPixel(evt.pixel, f => f.get('planeData') ? f : null)
|
const f = this.map.forEachFeatureAtPixel(evt.pixel, f => f.get('planeData') ? f : null);
|
||||||
if (!f) return
|
if (!f) return;
|
||||||
const plane = f.get('planeData')
|
|
||||||
if (this.popups[plane.icao]) { this._closePopup(plane.icao); return }
|
const plane = f.get('planeData');
|
||||||
this._openPopup(plane)
|
if (this.popups[plane.icao]) {
|
||||||
}
|
this._closePopup(plane.icao);
|
||||||
this.map.on('singleclick', this.clickHandler)
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._openPopup(plane);
|
||||||
|
};
|
||||||
|
|
||||||
|
this.map.on('singleclick', this.clickHandler);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,31 +1,59 @@
|
|||||||
import { BASE } from '@/services/api.ts'
|
import {BASE} from '@/services/api.ts';
|
||||||
import { h, render as vueRender } from 'vue'
|
import {h, render as vueRender} from 'vue';
|
||||||
import Map from 'ol/Map'
|
import Map from 'ol/Map';
|
||||||
import { Feature } from 'ol'
|
import {Feature} from 'ol';
|
||||||
import Point from 'ol/geom/Point'
|
import Point from 'ol/geom/Point';
|
||||||
import { fromLonLat } from 'ol/proj'
|
import {fromLonLat} from 'ol/proj';
|
||||||
import { Style, Icon } from 'ol/style'
|
import {Style, Icon} from 'ol/style';
|
||||||
import { Vector as VectorLayer } from 'ol/layer'
|
import {Vector as VectorLayer} from 'ol/layer';
|
||||||
import { Vector as VectorSource } from 'ol/source'
|
import {Vector as VectorSource} from 'ol/source';
|
||||||
import { adjustedInterval } from '@ztimson/utils'
|
import {adjustedInterval} from '@ztimson/utils';
|
||||||
import ShipPopup from '@/components/Ship.vue'
|
import ShipPopup from '@/components/Ship.vue';
|
||||||
import { bringToFront } from './zindex'
|
import {bringToFront} from './zindex';
|
||||||
|
|
||||||
const API = BASE + '/api'
|
const API = BASE + '/api';
|
||||||
|
const EARTH_R = 6371;
|
||||||
|
const AUTO_RANGE = 100;
|
||||||
|
|
||||||
const SHIP_COLORS: Record<string, string> = {
|
const SHIP_COLORS: Record<string, string> = {
|
||||||
'Base Station': '#ffffff',
|
'Base Station': '#ffffff',
|
||||||
'Class A': '#00ff00',
|
'Class A': '#00ff00',
|
||||||
'Class B': '#8450ea',
|
'Class B': '#8450ea',
|
||||||
'SAR Aircraft': '#ff0000',
|
'SAR Aircraft': '#ff0000',
|
||||||
'AtoN': '#ffffff',
|
'AtoN': '#ffffff',
|
||||||
'Class B/CS': '#8450ea',
|
'Class B/CS': '#8450ea',
|
||||||
'Sart/Epirb/MOB': '#00aaff',
|
'Sart/Epirb/MOB': '#00aaff',
|
||||||
'Unknown': '#fad106',
|
'Unknown': '#fad106',
|
||||||
|
};
|
||||||
|
|
||||||
|
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 isPriorityBoat(boat: any): boolean {
|
||||||
|
if (boat.military === true || boat.is_military === true || boat.sar === true || boat.is_sar === true) return true;
|
||||||
|
|
||||||
|
const text = [
|
||||||
|
boat.type,
|
||||||
|
boat.vesselType,
|
||||||
|
boat.category,
|
||||||
|
boat.name,
|
||||||
|
boat.shipname,
|
||||||
|
boat.operator,
|
||||||
|
boat.owner,
|
||||||
|
boat.callsign,
|
||||||
|
boat.description,
|
||||||
|
].filter(Boolean).join(' ').toLowerCase();
|
||||||
|
|
||||||
|
return /\b(military|mil|sar|search and rescue|rescue|coast guard|navy|army|air force|airforce|rcaf)\b/.test(text);
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildBoatIcon(boat: any): string {
|
function buildBoatIcon(boat: any): string {
|
||||||
const color = SHIP_COLORS[boat.type] || SHIP_COLORS['Unknown']
|
const color = SHIP_COLORS[boat.type] || SHIP_COLORS['Unknown'];
|
||||||
|
|
||||||
if (['Base Station', 'AtoN'].includes(boat.type)) {
|
if (['Base Station', 'AtoN'].includes(boat.type)) {
|
||||||
return `data:image/svg+xml,${encodeURIComponent(`
|
return `data:image/svg+xml,${encodeURIComponent(`
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
|
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
|
||||||
@@ -35,8 +63,9 @@ function buildBoatIcon(boat: any): string {
|
|||||||
<line x1="11" y1="12" x2="21" y2="12" stroke="#000" stroke-width="1.8"/>
|
<line x1="11" y1="12" x2="21" y2="12" stroke="#000" stroke-width="1.8"/>
|
||||||
<path d="M9,18 Q16,26 23,18" fill="none" stroke="#000" stroke-width="1.5"/>
|
<path d="M9,18 Q16,26 23,18" fill="none" stroke="#000" stroke-width="1.5"/>
|
||||||
</svg>
|
</svg>
|
||||||
`)}`
|
`)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return `data:image/svg+xml,${encodeURIComponent(`
|
return `data:image/svg+xml,${encodeURIComponent(`
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="32" viewBox="0 0 24 32">
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="32" viewBox="0 0 24 32">
|
||||||
<polygon points="12,0 24,30 12,24 0,30" fill="${color}" stroke="#000" stroke-width="1.5"/>
|
<polygon points="12,0 24,30 12,24 0,30" fill="${color}" stroke="#000" stroke-width="1.5"/>
|
||||||
@@ -45,125 +74,207 @@ function buildBoatIcon(boat: any): string {
|
|||||||
<line x1="8" y1="14" x2="16" y2="14" stroke="#000" stroke-width="1.5"/>
|
<line x1="8" y1="14" x2="16" y2="14" stroke="#000" stroke-width="1.5"/>
|
||||||
<path d="M7,17 Q12,24 17,17" fill="none" stroke="#000" stroke-width="1.3"/>
|
<path d="M7,17 Q12,24 17,17" fill="none" stroke="#000" stroke-width="1.3"/>
|
||||||
</svg>
|
</svg>
|
||||||
`)}`
|
`)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class AISLayer {
|
export class AISLayer {
|
||||||
private map: Map
|
private map: Map;
|
||||||
private layer!: VectorLayer<VectorSource>
|
private layer!: VectorLayer<VectorSource>;
|
||||||
private popups: any = {}
|
private popups: any = {};
|
||||||
private data: any[] = []
|
private data: any[] = [];
|
||||||
private clickHandler: ((e: any) => void) | null = null
|
private clickHandler: ((e: any) => void) | null = null;
|
||||||
private refreshTimer: any = null
|
private refreshTimer: any = null;
|
||||||
visible = false
|
visible = false;
|
||||||
|
|
||||||
constructor(map: Map) { this.map = map }
|
constructor(map: Map) {
|
||||||
|
this.map = map;
|
||||||
|
}
|
||||||
|
|
||||||
async show() {
|
async show() {
|
||||||
if (this.visible) return
|
if (this.visible) return;
|
||||||
this.visible = true
|
|
||||||
this.layer = new VectorLayer({ source: new VectorSource(), zIndex: 100 })
|
this.visible = true;
|
||||||
this.map.addLayer(this.layer)
|
this.layer = new VectorLayer({source: new VectorSource(), zIndex: 100});
|
||||||
await this._fetch()
|
this.map.addLayer(this.layer);
|
||||||
this._draw()
|
|
||||||
this._attachClick()
|
await this._fetch();
|
||||||
|
this._draw();
|
||||||
|
this._attachClick();
|
||||||
|
|
||||||
this.refreshTimer = adjustedInterval(async () => {
|
this.refreshTimer = adjustedInterval(async () => {
|
||||||
try {
|
try {
|
||||||
await this._fetch()
|
await this._fetch();
|
||||||
this._draw()
|
this._draw();
|
||||||
this._refreshPopups()
|
this._refreshPopups();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
}
|
}
|
||||||
}, 5_000)
|
}, 5_000);
|
||||||
}
|
}
|
||||||
|
|
||||||
hide() {
|
hide() {
|
||||||
if (!this.visible) return
|
if (!this.visible) return;
|
||||||
this.visible = false
|
|
||||||
if (this.refreshTimer) { clearInterval(this.refreshTimer); this.refreshTimer = null }
|
this.visible = false;
|
||||||
if (this.clickHandler) { this.map.un('singleclick', this.clickHandler); this.clickHandler = null }
|
|
||||||
for (const mmsi of Object.keys(this.popups)) this._closePopup(mmsi)
|
if (this.refreshTimer) {
|
||||||
this.map.removeLayer(this.layer)
|
clearInterval(this.refreshTimer);
|
||||||
|
this.refreshTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.clickHandler) {
|
||||||
|
this.map.un('singleclick', this.clickHandler);
|
||||||
|
this.clickHandler = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const mmsi of Object.keys(this.popups)) this._closePopup(mmsi);
|
||||||
|
if (this.layer) this.map.removeLayer(this.layer);
|
||||||
|
}
|
||||||
|
|
||||||
|
getClosest(latitude: number, longitude: number): any | null {
|
||||||
|
let closest: any = null;
|
||||||
|
let closestDistance = Infinity;
|
||||||
|
|
||||||
|
for (const boat of this.data) {
|
||||||
|
if (boat.lat == null || boat.lon == null) continue;
|
||||||
|
|
||||||
|
const distance = greatCircleDist(latitude, longitude, boat.lat, boat.lon);
|
||||||
|
if (distance < closestDistance) {
|
||||||
|
closestDistance = distance;
|
||||||
|
closest = boat;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return closest;
|
||||||
|
}
|
||||||
|
|
||||||
|
getAutoCandidates(latitude: number, longitude: number, limit = Infinity): any[] {
|
||||||
|
return this.data.filter(boat => {
|
||||||
|
if (boat.lat == null || boat.lon == null) return false;
|
||||||
|
return greatCircleDist(latitude, longitude, boat.lat, boat.lon) <= AUTO_RANGE;
|
||||||
|
}).sort((a, b) => {
|
||||||
|
const priority = Number(isPriorityBoat(b)) - Number(isPriorityBoat(a));
|
||||||
|
if (priority) return priority;
|
||||||
|
|
||||||
|
return greatCircleDist(latitude, longitude, a.lat, a.lon) - greatCircleDist(latitude, longitude, b.lat, b.lon);
|
||||||
|
}).slice(0, limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
isPriority(boat: any): boolean {
|
||||||
|
return isPriorityBoat(boat);
|
||||||
|
}
|
||||||
|
|
||||||
|
getAutoDistance(latitude: number, longitude: number, boat: any): number {
|
||||||
|
return greatCircleDist(latitude, longitude, boat.lat, boat.lon);
|
||||||
|
}
|
||||||
|
|
||||||
|
getById(mmsi: string): any | null {
|
||||||
|
return this.data.find(b => String(b.mmsi) === String(mmsi)) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
openAutoPopup(boat: any) {
|
||||||
|
this._openPopup(boat);
|
||||||
|
}
|
||||||
|
|
||||||
|
closeAutoPopup(mmsi: string) {
|
||||||
|
this._closePopup(String(mmsi));
|
||||||
}
|
}
|
||||||
|
|
||||||
private async _fetch() {
|
private async _fetch() {
|
||||||
this.data = await fetch(`${API}/ais`).then(r => r.json()) || []
|
this.data = await fetch(`${API}/ais`).then(r => r.json()) || [];
|
||||||
}
|
}
|
||||||
|
|
||||||
private _draw() {
|
private _draw() {
|
||||||
const source = this.layer.getSource()!
|
const source = this.layer.getSource()!;
|
||||||
source.clear()
|
source.clear();
|
||||||
|
|
||||||
for (const boat of this.data) {
|
for (const boat of this.data) {
|
||||||
if (boat.lat == null || boat.lon == null) continue
|
if (boat.lat == null || boat.lon == null) continue;
|
||||||
const coord = fromLonLat([boat.lon, boat.lat])
|
|
||||||
const f = new Feature({ geometry: new Point(coord) })
|
const coord = fromLonLat([boat.lon, boat.lat]);
|
||||||
f.set('mmsi', boat.mmsi)
|
const f = new Feature({geometry: new Point(coord)});
|
||||||
f.set('boatData', boat)
|
|
||||||
|
f.set('mmsi', boat.mmsi);
|
||||||
|
f.set('boatData', boat);
|
||||||
f.setStyle(new Style({
|
f.setStyle(new Style({
|
||||||
image: new Icon({
|
image: new Icon({
|
||||||
src: buildBoatIcon(boat),
|
src: buildBoatIcon(boat),
|
||||||
scale: 0.8,
|
scale: 0.8,
|
||||||
rotation: (boat.heading ?? boat.bearing ?? boat.cog ?? boat.course ?? 0) * (Math.PI / 180),
|
rotation: (boat.heading ?? boat.bearing ?? boat.cog ?? boat.course ?? 0) * (Math.PI / 180),
|
||||||
anchor: [0.5, 0.5],
|
anchor: [0.5, 0.5],
|
||||||
}),
|
}),
|
||||||
}))
|
}));
|
||||||
source.addFeature(f)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private _calcPopupPos(boat: any): { x: number; y: number } {
|
source.addFeature(f);
|
||||||
const pixel: any = this.map.getPixelFromCoordinate(fromLonLat([boat.lon, boat.lat]))
|
}
|
||||||
if (!pixel) return { x: 10, y: 60 }
|
|
||||||
const rect = (this.map.getTargetElement() as HTMLElement).getBoundingClientRect()
|
|
||||||
return { x: rect.left + pixel[0] + 16, y: rect.top + pixel[1] - 16 }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private _openPopup(boat: any) {
|
private _openPopup(boat: any) {
|
||||||
const mmsi = String(boat.mmsi)
|
const mmsi = String(boat.mmsi);
|
||||||
if (this.popups[mmsi]) return
|
if (this.popups[mmsi]) return;
|
||||||
|
|
||||||
const container = document.createElement('div')
|
const container = document.createElement('div');
|
||||||
document.body.appendChild(container)
|
document.body.appendChild(container);
|
||||||
const mobile = window.innerWidth <= 768
|
|
||||||
|
|
||||||
|
const mobile = window.innerWidth <= 768;
|
||||||
const makeProps = (b: any) => ({
|
const makeProps = (b: any) => ({
|
||||||
boat: b,
|
boat: b,
|
||||||
position: mobile ? { x: window.innerWidth / 2 - 180, y: window.innerHeight - 540 - 16 } : { x: 16, y: 16 },
|
position: mobile
|
||||||
onClose: () => this._closePopup(mmsi),
|
? {x: window.innerWidth / 2 - 180, y: window.innerHeight - 540 - 16}
|
||||||
|
: {x: 16, y: 16},
|
||||||
|
onClose: () => this._closePopup(mmsi),
|
||||||
onBringToFront: () => bringToFront(container),
|
onBringToFront: () => bringToFront(container),
|
||||||
})
|
});
|
||||||
|
|
||||||
vueRender(h(ShipPopup, makeProps(boat)), container)
|
vueRender(h(ShipPopup, makeProps(boat)), container);
|
||||||
|
|
||||||
this.popups[mmsi] = {
|
this.popups[mmsi] = {
|
||||||
update: (b: any) => vueRender(h(ShipPopup, makeProps(b)), container),
|
update: (b: any) => vueRender(h(ShipPopup, makeProps(b)), container),
|
||||||
unmount: () => { vueRender(null, container); container.remove() },
|
unmount: () => {
|
||||||
}
|
vueRender(null, container);
|
||||||
|
container.remove();
|
||||||
|
},
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private _closePopup(mmsi: string) {
|
private _closePopup(mmsi: string) {
|
||||||
const popup = this.popups[mmsi]
|
const popup = this.popups[mmsi];
|
||||||
if (popup) { popup.unmount(); delete this.popups[mmsi] }
|
|
||||||
|
if (popup) {
|
||||||
|
popup.unmount();
|
||||||
|
delete this.popups[mmsi];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private _refreshPopups() {
|
private _refreshPopups() {
|
||||||
for (const mmsi of Object.keys(this.popups)) {
|
for (const mmsi of Object.keys(this.popups)) {
|
||||||
const boat = this.data.find(b => String(b.mmsi) === mmsi)
|
const boat = this.data.find(b => String(b.mmsi) === mmsi);
|
||||||
if (!boat) { this._closePopup(mmsi); continue }
|
|
||||||
this.popups[mmsi].update(boat)
|
if (!boat) {
|
||||||
|
this._closePopup(mmsi);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.popups[mmsi].update(boat);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private _attachClick() {
|
private _attachClick() {
|
||||||
this.clickHandler = (evt) => {
|
this.clickHandler = evt => {
|
||||||
const f = this.map.forEachFeatureAtPixel(evt.pixel, f => f.get('boatData') ? f : null)
|
const f = this.map.forEachFeatureAtPixel(evt.pixel, f => f.get('boatData') ? f : null);
|
||||||
if (!f) return
|
if (!f) return;
|
||||||
const boat = f.get('boatData')
|
|
||||||
const mmsi = String(boat.mmsi)
|
const boat = f.get('boatData');
|
||||||
if (this.popups[mmsi]) { this._closePopup(mmsi); return }
|
const mmsi = String(boat.mmsi);
|
||||||
this._openPopup(boat)
|
|
||||||
}
|
if (this.popups[mmsi]) {
|
||||||
this.map.on('singleclick', this.clickHandler)
|
this._closePopup(mmsi);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._openPopup(boat);
|
||||||
|
};
|
||||||
|
|
||||||
|
this.map.on('singleclick', this.clickHandler);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,238 +1,380 @@
|
|||||||
import {api, BASE} from '@/services/api.ts';
|
import {api, BASE} from '@/services/api.ts';
|
||||||
import { h, render as vueRender } from 'vue'
|
import {h, render as vueRender} from 'vue';
|
||||||
import Map from 'ol/Map'
|
import Map from 'ol/Map';
|
||||||
import { Feature } from 'ol'
|
import {Feature} from 'ol';
|
||||||
import { LineString, Point, Circle as CircleGeom } from 'ol/geom'
|
import {LineString, Point, Circle as CircleGeom} from 'ol/geom';
|
||||||
import { fromLonLat } from 'ol/proj'
|
import {fromLonLat} from 'ol/proj';
|
||||||
import { Style, Stroke, Icon, Fill } from 'ol/style'
|
import {Style, Stroke, Icon, Fill} from 'ol/style';
|
||||||
import { Vector as VectorLayer } from 'ol/layer'
|
import {Vector as VectorLayer} from 'ol/layer';
|
||||||
import { Vector as VectorSource } from 'ol/source'
|
import {Vector as VectorSource} from 'ol/source';
|
||||||
import { adjustedInterval } from '@ztimson/utils'
|
import {adjustedInterval} from '@ztimson/utils';
|
||||||
import TinyGSPopup from '@/components/Satellite.vue'
|
import TinyGSPopup from '@/components/Satellite.vue';
|
||||||
import { bringToFront } from './zindex'
|
import {bringToFront} from './zindex';
|
||||||
|
|
||||||
const API = BASE + '/api'
|
const API = BASE + '/api';
|
||||||
const EARTH_R = 6371 // km
|
const EARTH_R = 6371;
|
||||||
|
const AUTO_ELEVATION = 70;
|
||||||
|
|
||||||
interface TinyGSReading {
|
interface TinyGSReading {
|
||||||
timestamp: number
|
timestamp: number;
|
||||||
freqMHz: number
|
freqMHz: number;
|
||||||
satellite: string
|
satellite: string;
|
||||||
latitude: number
|
latitude: number;
|
||||||
longitude: number
|
longitude: number;
|
||||||
az: number
|
az: number;
|
||||||
el: number
|
el: number;
|
||||||
packetRssi: number
|
packetRssi: number;
|
||||||
packetSnr: number
|
packetSnr: number;
|
||||||
freqError: number
|
freqError: number;
|
||||||
crcOk: boolean
|
crcOk: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TinyGSSatellite extends TinyGSReading {
|
interface TinyGSSatellite extends TinyGSReading {
|
||||||
history: TinyGSReading[]
|
history: TinyGSReading[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RangeEstimate {
|
interface RangeEstimate {
|
||||||
altitude: number // km, derived from az/el geometry
|
altitude: number;
|
||||||
slantRange: number // km, straight-line distance to satellite
|
slantRange: number;
|
||||||
horizonRadius: number // km, max ground radius satellite is visible from at this altitude
|
horizonRadius: number;
|
||||||
groundDist: number // km, great-circle distance to subpoint
|
groundDist: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
function greatCircleDist(lat1: number, lon1: number, lat2: number, lon2: number): number {
|
function greatCircleDist(lat1: number, lon1: number, lat2: number, lon2: number): number {
|
||||||
const toRad = (d: number) => d * Math.PI / 180
|
const toRad = (d: number) => d * Math.PI / 180;
|
||||||
const dLat = toRad(lat2 - lat1), dLon = toRad(lon2 - lon1)
|
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
|
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))
|
return EARTH_R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Derives altitude/slant range from ground station + satellite subpoint + observed elevation (no TLE required). */
|
|
||||||
function estimateRange(gsLat: number, gsLon: number, satLat: number, satLon: number, elDeg: number): RangeEstimate | null {
|
function estimateRange(gsLat: number, gsLon: number, satLat: number, satLon: number, elDeg: number): RangeEstimate | null {
|
||||||
const d = greatCircleDist(gsLat, gsLon, satLat, satLon)
|
const d = greatCircleDist(gsLat, gsLon, satLat, satLon);
|
||||||
const gamma = d / EARTH_R
|
const gamma = d / EARTH_R;
|
||||||
const el = elDeg * Math.PI / 180
|
const el = elDeg * Math.PI / 180;
|
||||||
const k = Math.cos(gamma) - Math.tan(el) * Math.sin(gamma)
|
const k = Math.cos(gamma) - Math.tan(el) * Math.sin(gamma);
|
||||||
if (k <= 0) return null // inconsistent geometry (noisy reading)
|
|
||||||
|
|
||||||
const altitude = EARTH_R * (1 - k) / k
|
if (k <= 0) return null;
|
||||||
const slantRange = Math.sqrt(EARTH_R ** 2 + (EARTH_R + altitude) ** 2 - 2 * EARTH_R * (EARTH_R + altitude) * Math.cos(gamma))
|
|
||||||
const horizonRadius = Math.sqrt(2 * EARTH_R * altitude + altitude ** 2)
|
const altitude = EARTH_R * (1 - k) / k;
|
||||||
return { altitude, slantRange, horizonRadius, groundDist: d }
|
const slantRange = Math.sqrt(EARTH_R ** 2 + (EARTH_R + altitude) ** 2 - 2 * EARTH_R * (EARTH_R + altitude) * Math.cos(gamma));
|
||||||
|
const horizonRadius = Math.sqrt(2 * EARTH_R * altitude + altitude ** 2);
|
||||||
|
|
||||||
|
return {altitude, slantRange, horizonRadius, groundDist: d};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Linear regression on recent elevation history to project time until satellite sets below horizon (el = 0). */
|
|
||||||
function estimateEtaOut(history: TinyGSReading[]): number | null {
|
function estimateEtaOut(history: TinyGSReading[]): number | null {
|
||||||
const pts = <any>history.slice(-6)
|
const pts: any = history.slice(-6);
|
||||||
if (pts.length < 2) return null
|
if (pts.length < 2) return null;
|
||||||
|
|
||||||
const t0 = pts[0].timestamp
|
const t0 = pts[0].timestamp;
|
||||||
const xs = pts.map(p => (p.timestamp - t0) / 1000)
|
const xs = pts.map(p => (p.timestamp - t0) / 1000);
|
||||||
const ys = pts.map(p => p.el)
|
const ys = pts.map(p => p.el);
|
||||||
const n = xs.length
|
const n = xs.length;
|
||||||
const sumX = xs.reduce((a, b) => a + b, 0), sumY = ys.reduce((a, b) => a + b, 0)
|
const sumX = xs.reduce((a, b) => a + b, 0);
|
||||||
const sumXY = xs.reduce((a, x, i) => a + x * ys[i], 0), sumXX = xs.reduce((a, x) => a + x * x, 0)
|
const sumY = ys.reduce((a, b) => a + b, 0);
|
||||||
const slope = (n * sumXY - sumX * sumY) / (n * sumXX - sumX * sumX)
|
const sumXY = xs.reduce((a, x, i) => a + x * ys[i], 0);
|
||||||
if (!isFinite(slope) || slope >= 0) return null // rising or flat, can't predict setting
|
const sumXX = xs.reduce((a, x) => a + x * x, 0);
|
||||||
|
const slope = (n * sumXY - sumX * sumY) / (n * sumXX - sumX * sumX);
|
||||||
|
|
||||||
const lastEl = ys[ys.length - 1], lastX = xs[xs.length - 1]
|
if (!isFinite(slope) || slope >= 0) return null;
|
||||||
const secsFromNow = (lastX - lastEl / slope) - lastX
|
|
||||||
return secsFromNow > 0 ? secsFromNow * 1000 : null
|
const lastEl: any = ys[ys.length - 1], lastX: any = xs[xs.length - 1];
|
||||||
|
const secsFromNow = (lastX - lastEl / slope) - lastX;
|
||||||
|
|
||||||
|
return secsFromNow > 0 ? secsFromNow * 1000 : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class SatsLayer {
|
export class SatsLayer {
|
||||||
private map: Map
|
private map: Map;
|
||||||
private layer!: VectorLayer<VectorSource>
|
private layer!: VectorLayer<VectorSource>;
|
||||||
private popups: any = {}
|
private popups: any = {};
|
||||||
private receptionCircles: Record<string, VectorLayer<VectorSource>> = {}
|
private receptionCircles: Record<string, VectorLayer<VectorSource>> = {};
|
||||||
private data: TinyGSSatellite[] = []
|
private data: TinyGSSatellite[] = [];
|
||||||
private clickHandler: ((e: any) => void) | null = null
|
private clickHandler: ((e: any) => void) | null = null;
|
||||||
private refreshTimer: any = null
|
private refreshTimer: any = null;
|
||||||
visible = false
|
visible = false;
|
||||||
gs;
|
gs: any;
|
||||||
|
|
||||||
constructor(map: Map) {
|
constructor(map: Map) {
|
||||||
this.map = map
|
this.map = map;
|
||||||
api.position().then(gs => this.gs = gs);
|
api.position().then(gs => this.gs = gs);
|
||||||
}
|
}
|
||||||
|
|
||||||
async show() {
|
async show() {
|
||||||
if (this.visible) return
|
if (this.visible) return;
|
||||||
this.visible = true
|
|
||||||
this.layer = new VectorLayer({ source: new VectorSource(), zIndex: 105 })
|
this.visible = true;
|
||||||
this.map.addLayer(this.layer)
|
this.layer = new VectorLayer({source: new VectorSource(), zIndex: 105});
|
||||||
await this._fetch()
|
this.map.addLayer(this.layer);
|
||||||
this._draw()
|
|
||||||
this._attachClick()
|
await this._fetch();
|
||||||
|
this._draw();
|
||||||
|
this._attachClick();
|
||||||
|
|
||||||
this.refreshTimer = adjustedInterval(async () => {
|
this.refreshTimer = adjustedInterval(async () => {
|
||||||
try {
|
try {
|
||||||
await this._fetch()
|
await this._fetch();
|
||||||
this._draw()
|
this._draw();
|
||||||
this._refreshPopups()
|
this._refreshPopups();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err)
|
console.error(err);
|
||||||
}
|
}
|
||||||
}, 5_000)
|
}, 5_000);
|
||||||
}
|
}
|
||||||
|
|
||||||
hide() {
|
hide() {
|
||||||
if (!this.visible) return
|
if (!this.visible) return;
|
||||||
this.visible = false
|
|
||||||
if (this.refreshTimer) { clearInterval(this.refreshTimer); this.refreshTimer = null }
|
this.visible = false;
|
||||||
if (this.clickHandler) { this.map.un('singleclick', this.clickHandler); this.clickHandler = null }
|
|
||||||
for (const name of Object.keys(this.popups)) this._closePopup(name)
|
if (this.refreshTimer) {
|
||||||
this.map.removeLayer(this.layer)
|
clearInterval(this.refreshTimer);
|
||||||
|
this.refreshTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.clickHandler) {
|
||||||
|
this.map.un('singleclick', this.clickHandler);
|
||||||
|
this.clickHandler = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const name of Object.keys(this.popups)) this._closePopup(name);
|
||||||
|
if (this.layer) this.map.removeLayer(this.layer);
|
||||||
|
}
|
||||||
|
|
||||||
|
getClosest(lat: number, lng: number): TinyGSSatellite | null {
|
||||||
|
if (!this.data.length) return null;
|
||||||
|
|
||||||
|
let closest: TinyGSSatellite | null = null;
|
||||||
|
let closestDistance = Infinity;
|
||||||
|
|
||||||
|
for (const sat of this.data) {
|
||||||
|
if (sat.latitude == null || sat.longitude == null) continue;
|
||||||
|
|
||||||
|
const range = this._calcRange(sat);
|
||||||
|
|
||||||
|
if (range && range.slantRange < closestDistance) {
|
||||||
|
closestDistance = range.slantRange;
|
||||||
|
closest = sat;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (closest || !this.gs) return closest;
|
||||||
|
|
||||||
|
closestDistance = Infinity;
|
||||||
|
|
||||||
|
for (const sat of this.data) {
|
||||||
|
const distance = greatCircleDist(this.gs.latitude, this.gs.longitude, sat.latitude, sat.longitude);
|
||||||
|
|
||||||
|
if (distance < closestDistance) {
|
||||||
|
closestDistance = distance;
|
||||||
|
closest = sat;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return closest;
|
||||||
|
}
|
||||||
|
|
||||||
|
getAutoCandidates(lat: number, lng: number, limit = Infinity): TinyGSSatellite[] {
|
||||||
|
return this.data.filter(sat => {
|
||||||
|
if (sat.latitude == null || sat.longitude == null) return false;
|
||||||
|
return sat.el >= AUTO_ELEVATION;
|
||||||
|
}).sort((a, b) => {
|
||||||
|
const ar = this._calcRange(a), br = this._calcRange(b);
|
||||||
|
const ad = ar?.slantRange ?? greatCircleDist(lat, lng, a.latitude, a.longitude);
|
||||||
|
const bd = br?.slantRange ?? greatCircleDist(lat, lng, b.latitude, b.longitude);
|
||||||
|
|
||||||
|
return ad - bd;
|
||||||
|
}).slice(0, limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
isPriority(sat: TinyGSSatellite): boolean {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
getAutoDistance(lat: number, lng: number, sat: TinyGSSatellite): number {
|
||||||
|
return this._calcRange(sat)?.slantRange ?? greatCircleDist(lat, lng, sat.latitude, sat.longitude);
|
||||||
|
}
|
||||||
|
|
||||||
|
getRange(sat: TinyGSSatellite): number | null {
|
||||||
|
return this._calcRange(sat)?.slantRange ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
getById(name: string): TinyGSSatellite | null {
|
||||||
|
return this.data.find(s => s.satellite === name) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
openAutoPopup(sat: TinyGSSatellite) {
|
||||||
|
this._openPopup(sat);
|
||||||
|
}
|
||||||
|
|
||||||
|
closeAutoPopup(name: string) {
|
||||||
|
this._closePopup(name);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async _fetch() {
|
private async _fetch() {
|
||||||
this.data = await fetch(`${API}/sats`).then(r => r.json()) || []
|
this.data = await fetch(`${API}/sats`).then(r => r.json()) || [];
|
||||||
}
|
}
|
||||||
|
|
||||||
private _draw() {
|
private _draw() {
|
||||||
const source = this.layer.getSource()!
|
const source = this.layer.getSource()!;
|
||||||
source.clear()
|
source.clear();
|
||||||
|
|
||||||
for (const sat of this.data) {
|
for (const sat of this.data) {
|
||||||
if (sat.latitude == null || sat.longitude == null) continue
|
if (sat.latitude == null || sat.longitude == null) continue;
|
||||||
|
|
||||||
|
const points: any = [...sat.history, sat].map(r => fromLonLat([r.longitude, r.latitude]));
|
||||||
|
|
||||||
const points = <any>[...sat.history, sat].map(r => fromLonLat([r.longitude, r.latitude]))
|
|
||||||
if (points.length > 1) {
|
if (points.length > 1) {
|
||||||
const trail = new Feature({ geometry: new LineString(points) })
|
const trail = new Feature({geometry: new LineString(points)});
|
||||||
trail.setStyle(new Style({ stroke: new Stroke({ color: 'rgba(212,164,255,0.5)', width: 2, lineDash: [6, 4] }) }))
|
trail.setStyle(new Style({
|
||||||
source.addFeature(trail)
|
stroke: new Stroke({
|
||||||
|
color: 'rgba(212,164,255,0.5)',
|
||||||
|
width: 2,
|
||||||
|
lineDash: [6, 4],
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
source.addFeature(trail);
|
||||||
}
|
}
|
||||||
|
|
||||||
const marker = new Feature({ geometry: new Point(points[points.length - 1]) })
|
const marker = new Feature({geometry: new Point(points[points.length - 1])});
|
||||||
marker.set('satellite', sat.satellite)
|
marker.set('satellite', sat.satellite);
|
||||||
marker.set('satData', sat)
|
marker.set('satData', sat);
|
||||||
marker.setStyle(new Style({
|
marker.setStyle(new Style({
|
||||||
image: new Icon({
|
image: new Icon({
|
||||||
src: '/satellite.png',
|
src: '/satellite.png',
|
||||||
scale: 0.1,
|
scale: 0.1,
|
||||||
anchor: [0.5, 0.5],
|
anchor: [0.5, 0.5],
|
||||||
}),
|
}),
|
||||||
}))
|
}));
|
||||||
source.addFeature(marker)
|
|
||||||
|
source.addFeature(marker);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private _calcRange(sat: TinyGSSatellite): RangeEstimate | null {
|
private _calcRange(sat: TinyGSSatellite): RangeEstimate | null {
|
||||||
if (!this.gs) return null
|
if (!this.gs) return null;
|
||||||
return estimateRange(this.gs.latitude, this.gs.longitude, sat.latitude, sat.longitude, sat.el)
|
return estimateRange(this.gs.latitude, this.gs.longitude, sat.latitude, sat.longitude, sat.el);
|
||||||
}
|
}
|
||||||
|
|
||||||
private _drawReceptionCircle(sat: TinyGSSatellite, radiusKm?: number | null) {
|
private _drawReceptionCircle(sat: TinyGSSatellite, radiusKm?: number | null) {
|
||||||
this._removeReceptionCircle(sat.satellite)
|
this._removeReceptionCircle(sat.satellite);
|
||||||
if (!radiusKm) return
|
if (!radiusKm) return;
|
||||||
const feature = new Feature({ geometry: new CircleGeom(fromLonLat([sat.longitude, sat.latitude]), radiusKm * 1000) })
|
|
||||||
|
const feature = new Feature({
|
||||||
|
geometry: new CircleGeom(fromLonLat([sat.longitude, sat.latitude]), radiusKm * 1000),
|
||||||
|
});
|
||||||
|
|
||||||
feature.setStyle(new Style({
|
feature.setStyle(new Style({
|
||||||
stroke: new Stroke({ color: 'rgba(212,164,255,0.6)', width: 2 }),
|
stroke: new Stroke({
|
||||||
fill: new Fill({ color: 'rgba(212,164,255,0.12)' }),
|
color: 'rgba(212,164,255,0.6)',
|
||||||
}))
|
width: 2,
|
||||||
const layer = new VectorLayer({ source: new VectorSource({ features: [feature] }), zIndex: 104 })
|
}),
|
||||||
this.map.addLayer(layer)
|
fill: new Fill({color: 'rgba(212,164,255,0.12)'}),
|
||||||
this.receptionCircles[sat.satellite] = layer
|
}));
|
||||||
|
|
||||||
|
const layer = new VectorLayer({
|
||||||
|
source: new VectorSource({features: [feature]}),
|
||||||
|
zIndex: 104,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.map.addLayer(layer);
|
||||||
|
this.receptionCircles[sat.satellite] = layer;
|
||||||
}
|
}
|
||||||
|
|
||||||
private _removeReceptionCircle(name: string) {
|
private _removeReceptionCircle(name: string) {
|
||||||
const l = this.receptionCircles[name]
|
const layer = this.receptionCircles[name];
|
||||||
if (l) { this.map.removeLayer(l); delete this.receptionCircles[name] }
|
|
||||||
}
|
|
||||||
|
|
||||||
private _openPopup(sat: TinyGSSatellite) {
|
if (layer) {
|
||||||
if (this.popups[sat.satellite]) return
|
this.map.removeLayer(layer);
|
||||||
|
delete this.receptionCircles[name];
|
||||||
const history = [...sat.history, sat]
|
|
||||||
const range = this._calcRange(sat)
|
|
||||||
const eta = estimateEtaOut(history)
|
|
||||||
this._drawReceptionCircle(sat, range?.horizonRadius)
|
|
||||||
|
|
||||||
const container = document.createElement('div')
|
|
||||||
document.body.appendChild(container)
|
|
||||||
const mobile = window.innerWidth <= 768
|
|
||||||
|
|
||||||
const makeProps = (h_: any, r: any, e: any) => ({
|
|
||||||
history: h_,
|
|
||||||
range: r,
|
|
||||||
eta: e,
|
|
||||||
position: mobile ? { x: window.innerWidth / 2 - 180, y: window.innerHeight - 540 - 16 } : { x: 16, y: 16 },
|
|
||||||
onClose: () => this._closePopup(sat.satellite),
|
|
||||||
onBringToFront: () => bringToFront(container),
|
|
||||||
})
|
|
||||||
|
|
||||||
vueRender(h(TinyGSPopup, makeProps(history, range, eta)), container)
|
|
||||||
|
|
||||||
this.popups[sat.satellite] = {
|
|
||||||
container,
|
|
||||||
update: (h_: any, r: any, e: any) => vueRender(h(TinyGSPopup, makeProps(h_, r, e)), container),
|
|
||||||
unmount: () => { vueRender(null, container); container.remove() },
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private _openPopup(sat: TinyGSSatellite) {
|
||||||
|
if (this.popups[sat.satellite]) return;
|
||||||
|
|
||||||
|
const history = [...sat.history, sat];
|
||||||
|
const range = this._calcRange(sat);
|
||||||
|
const eta = estimateEtaOut(history);
|
||||||
|
|
||||||
|
this._drawReceptionCircle(sat, range?.horizonRadius);
|
||||||
|
|
||||||
|
const container = document.createElement('div');
|
||||||
|
document.body.appendChild(container);
|
||||||
|
|
||||||
|
const mobile = window.innerWidth <= 768;
|
||||||
|
const makeProps = (h_: TinyGSReading[], r: RangeEstimate | null, e: number | null) => ({
|
||||||
|
history: h_,
|
||||||
|
range: r,
|
||||||
|
eta: e,
|
||||||
|
position: mobile
|
||||||
|
? {x: window.innerWidth / 2 - 180, y: window.innerHeight - 540 - 16}
|
||||||
|
: {x: 16, y: 16},
|
||||||
|
onClose: () => this._closePopup(sat.satellite),
|
||||||
|
onBringToFront: () => bringToFront(container),
|
||||||
|
});
|
||||||
|
|
||||||
|
vueRender(h(TinyGSPopup, makeProps(history, range, eta)), container);
|
||||||
|
|
||||||
|
this.popups[sat.satellite] = {
|
||||||
|
container,
|
||||||
|
update: (h_: TinyGSReading[], r: RangeEstimate | null, e: number | null) => vueRender(h(TinyGSPopup, makeProps(h_, r, e)), container),
|
||||||
|
unmount: () => {
|
||||||
|
vueRender(null, container);
|
||||||
|
container.remove();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
private _closePopup(name: string) {
|
private _closePopup(name: string) {
|
||||||
const popup = this.popups[name]
|
const popup = this.popups[name];
|
||||||
if (popup) { popup.unmount(); delete this.popups[name] }
|
|
||||||
this._removeReceptionCircle(name)
|
if (popup) {
|
||||||
|
popup.unmount();
|
||||||
|
delete this.popups[name];
|
||||||
|
}
|
||||||
|
|
||||||
|
this._removeReceptionCircle(name);
|
||||||
}
|
}
|
||||||
|
|
||||||
private _refreshPopups() {
|
private _refreshPopups() {
|
||||||
for (const name of Object.keys(this.popups)) {
|
for (const name of Object.keys(this.popups)) {
|
||||||
const sat = this.data.find(s => s.satellite === name)
|
const sat = this.data.find(s => s.satellite === name);
|
||||||
if (!sat) { this._closePopup(name); continue }
|
|
||||||
const history = [...sat.history, sat]
|
if (!sat) {
|
||||||
const range = this._calcRange(sat)
|
this._closePopup(name);
|
||||||
this.popups[name].update(history, range, estimateEtaOut(history))
|
continue;
|
||||||
this._drawReceptionCircle(sat, range?.horizonRadius)
|
}
|
||||||
|
|
||||||
|
const history = [...sat.history, sat];
|
||||||
|
const range = this._calcRange(sat);
|
||||||
|
|
||||||
|
this.popups[name].update(
|
||||||
|
history,
|
||||||
|
range,
|
||||||
|
estimateEtaOut(history),
|
||||||
|
);
|
||||||
|
|
||||||
|
this._drawReceptionCircle(sat, range?.horizonRadius);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private _attachClick() {
|
private _attachClick() {
|
||||||
this.clickHandler = (evt) => {
|
this.clickHandler = evt => {
|
||||||
const f = this.map.forEachFeatureAtPixel(evt.pixel, f => f.get('satData') ? f : null)
|
const f = this.map.forEachFeatureAtPixel(evt.pixel, f => f.get('satData') ? f : null);
|
||||||
if (!f) return
|
if (!f) return;
|
||||||
const sat = f.get('satData') as TinyGSSatellite
|
|
||||||
if (this.popups[sat.satellite]) { this._closePopup(sat.satellite); return }
|
const sat = f.get('satData') as TinyGSSatellite;
|
||||||
this._openPopup(sat)
|
|
||||||
}
|
if (this.popups[sat.satellite]) {
|
||||||
this.map.on('singleclick', this.clickHandler)
|
this._closePopup(sat.satellite);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._openPopup(sat);
|
||||||
|
};
|
||||||
|
|
||||||
|
this.map.on('singleclick', this.clickHandler);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
54
server/src/radiosonde.mjs
Normal file
54
server/src/radiosonde.mjs
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import {cfg} from './config.mjs';
|
||||||
|
|
||||||
|
let sondeCache = null;
|
||||||
|
let sondeCacheTs = 0;
|
||||||
|
const SONDE_TTL = 1000;
|
||||||
|
|
||||||
|
export async function getSondes() {
|
||||||
|
if (sondeCache && Date.now() - sondeCacheTs < SONDE_TTL) return sondeCache;
|
||||||
|
const {ADSB_URL} = cfg();
|
||||||
|
if (!ADSB_URL) return {data: []};
|
||||||
|
|
||||||
|
const r = await fetch(`${ADSB_URL}:9989/get_telemetry_archive`);
|
||||||
|
const data = await r.json();
|
||||||
|
|
||||||
|
sondeCache = Object.entries(data).map(([id, sonde]) => {
|
||||||
|
const t = sonde.latest_telem ?? {};
|
||||||
|
const path = sonde.path ?? [];
|
||||||
|
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
type: t.type,
|
||||||
|
subtype: t.subtype,
|
||||||
|
lat: t.lat,
|
||||||
|
lon: t.lon,
|
||||||
|
altitude: t.alt,
|
||||||
|
heading: t.heading,
|
||||||
|
speed: t.vel_h,
|
||||||
|
vertical_speed: t.vel_v,
|
||||||
|
sats: t.sats,
|
||||||
|
timestamp: t.datetime ? Date.parse(t.datetime) : sonde.timestamp * 1000,
|
||||||
|
datetime: t.datetime,
|
||||||
|
frame: t.frame,
|
||||||
|
battery: t.batt,
|
||||||
|
temperature: t.temp,
|
||||||
|
humidity: t.humidity,
|
||||||
|
pressure: t.pressure,
|
||||||
|
frequency: t.freq_float,
|
||||||
|
frequency_hz: t.tx_frequency,
|
||||||
|
snr: t.snr,
|
||||||
|
ppm: t.ppm,
|
||||||
|
aprsid: t.aprsid?.trim(),
|
||||||
|
rs41_mainboard: t.rs41_mainboard,
|
||||||
|
rs41_mainboard_fw: t.rs41_mainboard_fw,
|
||||||
|
version: t.version,
|
||||||
|
ref_datetime: t.ref_datetime,
|
||||||
|
ref_position: t.ref_position,
|
||||||
|
sdr_device_idx: t.sdr_device_idx,
|
||||||
|
path
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
sondeCacheTs = Date.now();
|
||||||
|
return sondeCache;
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ import {dailyWeather, hourlyWeather} from './openmeteo.mjs';
|
|||||||
import {getTinyGSData} from './sats.mjs';
|
import {getTinyGSData} from './sats.mjs';
|
||||||
import {getSpaceWeather} from './space.mjs';
|
import {getSpaceWeather} from './space.mjs';
|
||||||
import {Aurora} from './aurora.mjs';
|
import {Aurora} from './aurora.mjs';
|
||||||
|
import {getSondes} from './radiosonde.mjs';
|
||||||
|
|
||||||
// ── Uncaught error handlers ───────────────────────────────────────────────────
|
// ── Uncaught error handlers ───────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -152,7 +153,7 @@ app.get('/api/space', async (req, res) => {
|
|||||||
res.json(filterFields(await getSpaceWeather(), fields));
|
res.json(filterFields(await getSpaceWeather(), fields));
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── ADSB/AIS/SAT Proxy ────────────────────────────────────────────────────────────
|
// ── ADSB/AIS/SAT/RadioSonde Proxy ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
app.get('/api/adsb', asyncHandler(async (req, res) => res.json(await getADSB())));
|
app.get('/api/adsb', asyncHandler(async (req, res) => res.json(await getADSB())));
|
||||||
app.get('/api/adsb/:icao/image', asyncHandler(async (req, res) => {
|
app.get('/api/adsb/:icao/image', asyncHandler(async (req, res) => {
|
||||||
@@ -167,6 +168,8 @@ app.get('/api/ais/:mmsi/image', asyncHandler(async (req, res) => {
|
|||||||
res.contentType('image/jpeg').send(buffer)
|
res.contentType('image/jpeg').send(buffer)
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
app.get('/api/sondes', asyncHandler(async (req, res) => res.json(await getSondes())));
|
||||||
|
|
||||||
app.get('/api/sats', (_req, res) => res.json(getTinyGSData()));
|
app.get('/api/sats', (_req, res) => res.json(getTinyGSData()));
|
||||||
|
|
||||||
// ── DOCS ──────────────────────────────────────────────────────────────────────
|
// ── DOCS ──────────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
Reference in New Issue
Block a user