diff --git a/client/public/sonde.png b/client/public/sonde.png new file mode 100644 index 0000000..eef1680 Binary files /dev/null and b/client/public/sonde.png differ diff --git a/client/src/components/MapView.vue b/client/src/components/MapView.vue index e373ad3..e93215a 100644 --- a/client/src/components/MapView.vue +++ b/client/src/components/MapView.vue @@ -20,6 +20,7 @@ import {fromLonLat, toLonLat} from 'ol/proj'; import {Style, Fill, Stroke, Circle as CircleStyle} from 'ol/style'; import {applyStyle} from 'ol-mapbox-style'; import {Aurora} from '@/services/aurora.ts'; +import {SondesLayer} from '@/services/sondes.ts'; import 'ol/ol.css'; const current = ref({latitude: 0, longitude: 0}); @@ -40,6 +41,7 @@ const OVERLAY_GROUPS = [ {id: 'aircraft', label: 'Aircraft', icon: '✈️'}, {id: 'marine', label: 'Marine', icon: '🚢'}, {id: 'satellites', label: 'Satellites', icon: '🛰️'}, + {id: 'sondes', label: 'Weather Balloons', icon: '️🎈'}, ], }, { @@ -84,6 +86,7 @@ let airTraffic: AirTrafficLayer; let aisLayer: AISLayer; let range: RangeLayer; let sats: SatsLayer; +let sondes: SondesLayer; let aurora: Aurora; const autoMode = ref(false); @@ -273,6 +276,14 @@ function toggleOverlay(id: string) { activeOverlays.value.add('satellites'); sats.show(); } + } else if (id === 'sondes') { + if(activeOverlays.value.has('sondes')) { + activeOverlays.value.delete('sondes'); + sondes.hide(); + } else { + activeOverlays.value.add('sondes'); + sondes.show(); + } } else if (id === 'aurora') { if (activeOverlays.value.has('aurora')) { activeOverlays.value.delete('aurora'); @@ -669,6 +680,7 @@ onMounted(async () => { aisLayer = new AISLayer(map); airTraffic = new AirTrafficLayer(map); sats = new SatsLayer(map); + sondes = new SondesLayer(map); aurora = new Aurora(map); range = new RangeLayer(map); @@ -692,6 +704,7 @@ onUnmounted(() => { airTraffic?.hide(); aisLayer?.hide(); sats?.hide(); + sondes?.hide(); aurora?.hide(); range?.hide(); }); diff --git a/client/src/components/WeatherBalloon.vue b/client/src/components/WeatherBalloon.vue new file mode 100644 index 0000000..30d273b --- /dev/null +++ b/client/src/components/WeatherBalloon.vue @@ -0,0 +1,256 @@ + + + + + diff --git a/client/src/services/sondes.ts b/client/src/services/sondes.ts new file mode 100644 index 0000000..73bd57d --- /dev/null +++ b/client/src/services/sondes.ts @@ -0,0 +1,267 @@ +import {api, BASE} from '@/services/api.ts'; +import {h, render as vueRender} from 'vue'; +import Map from 'ol/Map'; +import {Feature} from 'ol'; +import {LineString, Point} from 'ol/geom'; +import {fromLonLat} from 'ol/proj'; +import {Style, Stroke, Icon} from 'ol/style'; +import {Vector as VectorLayer} from 'ol/layer'; +import {Vector as VectorSource} from 'ol/source'; +import {adjustedInterval} from '@ztimson/utils'; +import SondePopup from '@/components/WeatherBalloon.vue'; +import {bringToFront} from './zindex'; + +const API = BASE + '/api'; + +interface SondeReading { + timestamp: number; + id: string; + type: string; + subtype: string; + latitude: number; + longitude: number; + altitude: number; + heading: number; + speed: number; + vertical_speed: number; + sats: number; + battery: number; + temperature: number; + humidity: number; + pressure: number; + frequency: number; + frequency_hz: number; + snr: number; + ppm: number; + aprsid: string; +} + +interface Sonde extends SondeReading { + history: SondeReading[]; +} + +export class SondesLayer { + private map: Map; + private layer!: VectorLayer; + private popups: any = {}; + private data: Sonde[] = []; + 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: 105}); + 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); + } + }, 5_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 id of Object.keys(this.popups)) this._closePopup(id); + if (this.layer) this.map.removeLayer(this.layer); + } + + getClosest(lat: number, lng: number): Sonde | null { + if (!this.data.length) return null; + + let closest: Sonde | null = null; + let closestDistance = Infinity; + + for (const sonde of this.data) { + if (sonde.latitude == null || sonde.longitude == null) continue; + + const distance = this._distance(lat, lng, sonde.latitude, sonde.longitude); + + if (distance < closestDistance) { + closestDistance = distance; + closest = sonde; + } + } + + return closest; + } + + getAutoCandidates(lat: number, lng: number, limit = Infinity): Sonde[] { + return this.data.filter(sonde => { + return sonde.latitude != null && sonde.longitude != null; + }).sort((a, b) => { + const ad = this._distance(lat, lng, a.latitude, a.longitude); + const bd = this._distance(lat, lng, b.latitude, b.longitude); + return ad - bd; + }).slice(0, limit); + } + + isPriority(sonde: Sonde): boolean { + return true; + } + + getAutoDistance(lat: number, lng: number, sonde: Sonde): number { + return this._distance(lat, lng, sonde.latitude, sonde.longitude); + } + + getById(id: string): Sonde | null { + return this.data.find(s => s.id === id) ?? null; + } + + openAutoPopup(sonde: Sonde) { + this._openPopup(sonde); + } + + closeAutoPopup(id: string) { + this._closePopup(id); + } + + private async _fetch() { + this.data = await fetch(`${API}/sondes`).then(r => r.json()) || []; + } + + private _draw() { + const source = this.layer.getSource()!; + source.clear(); + + for (const sonde of this.data) { + if (sonde.latitude == null || sonde.longitude == null) continue; + + const points: any = [...sonde.history, sonde].map(r => fromLonLat([r.longitude, r.latitude])); + + if (points.length > 1) { + const trail = new Feature({geometry: new LineString(points)}); + trail.setStyle(new Style({ + stroke: new Stroke({ + color: 'rgba(255,170,0,0.5)', + width: 2, + lineDash: [6, 4], + }), + })); + + source.addFeature(trail); + } + + const marker = new Feature({geometry: new Point(points[points.length - 1])}); + marker.set('sonde', sonde.id); + marker.set('sondeData', sonde); + marker.setStyle(new Style({ + image: new Icon({ + src: '/sonde.png', + scale: 0.1, + anchor: [0.5, 0.5], + }), + })); + + source.addFeature(marker); + } + } + + private _distance(lat1: number, lng1: number, lat2: number, lng2: number): number { + const toRad = (d: number) => d * Math.PI / 180; + const dLat = toRad(lat2 - lat1); + const dLng = toRad(lng2 - lng1); + const a = Math.sin(dLat / 2) ** 2 + + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLng / 2) ** 2; + + return 6371 * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); + } + + private _openPopup(sonde: Sonde) { + if (this.popups[sonde.id]) return; + + const history = [...sonde.history, sonde]; + + const container = document.createElement('div'); + document.body.appendChild(container); + + const mobile = window.innerWidth <= 768; + const makeProps = (h_: SondeReading[]) => ({ + history: h_, + position: mobile + ? {x: window.innerWidth / 2 - 180, y: window.innerHeight - 540 - 16} + : {x: 16, y: 16}, + onClose: () => this._closePopup(sonde.id), + onBringToFront: () => bringToFront(container), + }); + + vueRender(h(SondePopup, makeProps(history)), container); + + this.popups[sonde.id] = { + container, + update: (h_: SondeReading[]) => vueRender(h(SondePopup, makeProps(h_)), container), + unmount: () => { + vueRender(null, container); + container.remove(); + }, + }; + } + + private _closePopup(id: string) { + const popup = this.popups[id]; + + if (popup) { + popup.unmount(); + delete this.popups[id]; + } + } + + private _refreshPopups() { + for (const id of Object.keys(this.popups)) { + const sonde = this.data.find(s => s.id === id); + + if (!sonde) { + this._closePopup(id); + continue; + } + + this.popups[id].update([...sonde.history, sonde]); + } + } + + private _attachClick() { + this.clickHandler = evt => { + const f = this.map.forEachFeatureAtPixel(evt.pixel, f => f.get('sondeData') ? f : null); + if (!f) return; + + const sonde = f.get('sondeData') as Sonde; + + if (this.popups[sonde.id]) { + this._closePopup(sonde.id); + return; + } + + this._openPopup(sonde); + }; + + this.map.on('singleclick', this.clickHandler); + } +} diff --git a/server/src/radiosonde.mjs b/server/src/radiosonde.mjs index 7e8fa99..ed99058 100644 --- a/server/src/radiosonde.mjs +++ b/server/src/radiosonde.mjs @@ -20,8 +20,8 @@ export async function getSondes() { id, type: t.type, subtype: t.subtype, - lat: t.lat, - lon: t.lon, + latitude: t.lat, + longitude: t.lon, altitude: t.alt, heading: t.heading, speed: t.vel_h,