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,