From 2b299753ca3e0414d4e02a115bcd3c7820d3e1f3 Mon Sep 17 00:00:00 2001 From: ztimson Date: Sun, 13 Sep 2026 09:29:19 -0400 Subject: [PATCH] Radio sonde on map --- client/public/sonde.png | Bin 0 -> 5288 bytes client/src/components/MapView.vue | 13 ++ client/src/components/WeatherBalloon.vue | 256 ++++++++++++++++++++++ client/src/services/sondes.ts | 267 +++++++++++++++++++++++ server/src/radiosonde.mjs | 4 +- 5 files changed, 538 insertions(+), 2 deletions(-) create mode 100644 client/public/sonde.png create mode 100644 client/src/components/WeatherBalloon.vue create mode 100644 client/src/services/sondes.ts diff --git a/client/public/sonde.png b/client/public/sonde.png new file mode 100644 index 0000000000000000000000000000000000000000..eef16806ba5a8b91104f3696478e44ca3239399c GIT binary patch literal 5288 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 E0JVAr?*IS* literal 0 HcmV?d00001 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,