Radio sonde on map

This commit is contained in:
2026-09-13 09:29:19 -04:00
parent 83c48bf3e7
commit 2b299753ca
5 changed files with 538 additions and 2 deletions

View File

@@ -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();
});

View File

@@ -0,0 +1,256 @@
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
const props = defineProps<{
history: any[]
position: { x: number; y: number }
}>()
const emit = defineEmits<{ (e: 'close'): void; (e: 'bringToFront'): void }>()
const latest = computed(() => props.history[props.history.length - 1])
const altitude = computed(() => latest.value?.altitude != null ? Math.round(latest.value.altitude / 1000) : null)
const speed = computed(() => latest.value?.speed != null ? latest.value.speed.toFixed(1) : null)
const climb = computed(() => latest.value?.vertical_speed != null ? latest.value.vertical_speed.toFixed(1) : null)
// ── Drag ──────────────────────────────────────────────────────────────────────
const pos = ref({ ...props.position })
const isDragging = ref(false)
const dragStart = ref({ mx: 0, my: 0, ex: 0, ey: 0 })
const longPressTimer = ref<number | null>(null)
const header = ref<HTMLElement | null>(null)
function onMouseDown(e: MouseEvent) {
if ((e.target as HTMLElement).classList.contains('tp-close')) return
emit('bringToFront')
isDragging.value = true
dragStart.value = { mx: e.clientX, my: e.clientY, ex: pos.value.x, ey: pos.value.y }
e.preventDefault()
}
function onMouseMove(e: MouseEvent) {
if (!isDragging.value) return
pos.value.x = dragStart.value.ex + (e.clientX - dragStart.value.mx)
pos.value.y = dragStart.value.ey + (e.clientY - dragStart.value.my)
}
function onMouseUp() { isDragging.value = false }
function onTouchStart(e: TouchEvent) {
if ((e.target as HTMLElement).classList.contains('tp-close')) return
const touch: any = e.touches[0]
const sx = touch.clientX, sy = touch.clientY
longPressTimer.value = window.setTimeout(() => {
emit('bringToFront')
isDragging.value = true
dragStart.value = { mx: sx, my: sy, ex: pos.value.x, ey: pos.value.y }
if (header.value) header.value.style.opacity = '0.8'
}, 500)
}
function onTouchMove(e: TouchEvent) {
if (!isDragging.value) return
const touch: any = e.touches[0]
pos.value.x = dragStart.value.ex + (touch.clientX - dragStart.value.mx)
pos.value.y = dragStart.value.ey + (touch.clientY - dragStart.value.my)
e.preventDefault()
}
function onTouchEnd() {
if (longPressTimer.value) { clearTimeout(longPressTimer.value); longPressTimer.value = null }
if (isDragging.value && header.value) header.value.style.opacity = '1'
isDragging.value = false
}
onMounted(() => {
document.addEventListener('mousemove', onMouseMove)
document.addEventListener('mouseup', onMouseUp)
document.addEventListener('touchmove', onTouchMove, { passive: false })
document.addEventListener('touchend', onTouchEnd)
document.addEventListener('touchcancel', onTouchEnd)
})
onUnmounted(() => {
document.removeEventListener('mousemove', onMouseMove)
document.removeEventListener('mouseup', onMouseUp)
document.removeEventListener('touchmove', onTouchMove)
document.removeEventListener('touchend', onTouchEnd)
document.removeEventListener('touchcancel', onTouchEnd)
})
</script>
<style scoped>
.sonde-popup {
position: fixed;
z-index: 100;
pointer-events: auto;
background: rgba(0,0,0,0.88);
border: 1px solid rgba(255,170,0,0.3);
border-radius: 8px;
box-shadow: 0 4px 24px rgba(0,0,0,0.5);
overflow: hidden;
font-family: monospace;
color: #ccc;
min-width: 320px;
max-width: 360px;
}
.tp-header {
position: relative;
padding: 12px 16px;
border-bottom: 1px solid rgba(255,170,0,0.2);
cursor: move;
user-select: none;
-webkit-user-select: none;
}
.tp-name { margin: 0 24px 0 0; color: #ffaa00; font-size: 16px; }
.tp-close {
position: absolute;
top: 8px;
right: 8px;
background: rgba(255,255,255,0.2);
border: none;
color: white;
width: 24px;
height: 24px;
border-radius: 4px;
cursor: pointer;
font-size: 16px;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.2s;
}
.tp-close:hover { background: rgba(255,255,255,0.35); }
.tp-meta {
font-size: 11px;
color: #888;
display: flex;
justify-content: space-between;
gap: 8px;
padding: 12px 16px;
border-bottom: 1px solid rgba(255,170,0,0.2);
}
.flex-c { display: flex; flex-direction: column; gap: 3px; }
.align-x-end { align-items: flex-end; }
.tp-body { display: flex; gap: 16px; padding: 12px 16px; }
.tp-gauges { flex: 1; }
.tp-gauge-wrap { margin-bottom: 12px; }
.tp-gauge-label { color: #888; font-size: 11px; font-weight: bold; margin-bottom: 3px; }
.tp-gauge {
padding: 6px;
background: rgba(0,0,0,0.95);
border: 2px solid #ffaa00;
border-radius: 3px;
display: flex;
align-items: flex-end;
justify-content: center;
gap: 4px;
}
.tp-gauge-val { font-size: 20px; font-weight: bold; color: #ffaa00; }
.tp-gauge-unit { font-size: 11px; color: #ffaa00; margin-bottom: 0.75em; }
.tp-fields {
width: 100%;
font-size: 11px;
padding: 0 16px 12px;
border-collapse: collapse;
}
.tp-fields td { padding: 2px 4px; color: #888; }
.tp-fields td:last-child { color: #ccc; text-align: right; }
.ok { color: #3fdb6d !important; }
.bad { color: #e0475a !important; }
</style>
<template>
<div class="sonde-popup" :style="{ left: pos.x + 'px', top: pos.y + 'px' }">
<div ref="header" class="tp-header" @mousedown="onMouseDown" @touchstart.passive="onTouchStart">
<h3 class="tp-name">🎈 {{ latest?.id || 'Unknown Sonde' }}</h3>
<button class="tp-close" @click.stop="emit('close')"></button>
</div>
<div class="tp-meta">
<div class="flex-c">
<span>{{ latest?.type || 'Unknown' }}</span>
<span>{{ latest?.frequency?.toFixed(3) || '—' }} MHz</span>
</div>
<div class="flex-c align-x-end">
<span>SNR: {{ latest?.snr ?? '—' }} dB</span>
<span>{{ latest?.sats ?? '—' }} sats</span>
</div>
</div>
<div class="tp-body">
<div class="tp-gauges">
<div class="tp-gauge-wrap">
<div class="tp-gauge-label">Altitude</div>
<div class="tp-gauge">
<span class="tp-gauge-val">{{ altitude != null ? altitude + ' km' : '—' }}</span>
</div>
</div>
<div class="tp-gauge-wrap">
<div class="tp-gauge-label">Speed</div>
<div class="tp-gauge">
<span class="tp-gauge-val">{{ speed ?? '—' }}</span>
<span class="tp-gauge-unit">m/s</span>
</div>
</div>
<div class="tp-gauge-wrap">
<div class="tp-gauge-label">Vertical Speed</div>
<div class="tp-gauge">
<span class="tp-gauge-val">{{ climb ?? '—' }}</span>
<span class="tp-gauge-unit">m/s</span>
</div>
</div>
</div>
</div>
<table class="tp-fields">
<tbody>
<tr>
<td>Heading</td>
<td>{{ latest?.heading?.toFixed(1) ?? '—' }}°</td>
</tr>
<tr>
<td>Temperature</td>
<td>{{ latest?.temperature ?? '—' }} °C</td>
</tr>
<tr>
<td>Humidity</td>
<td>{{ latest?.humidity ?? '—' }} %</td>
</tr>
<tr>
<td>Battery</td>
<td>{{ latest?.battery ?? '—' }} V</td>
</tr>
<tr>
<td>Frame</td>
<td>{{ latest?.frame ?? '—' }}</td>
</tr>
<tr>
<td>Frequency</td>
<td>{{ latest?.frequency_hz ? (latest.frequency_hz / 1000).toFixed(3) : '—' }} MHz</td>
</tr>
<tr>
<td>PPM</td>
<td>{{ latest?.ppm?.toFixed(2) ?? '—' }}</td>
</tr>
<tr>
<td>Latitude</td>
<td>{{ latest?.latitude?.toFixed(5) ?? '—' }}</td>
</tr>
<tr>
<td>Longitude</td>
<td>{{ latest?.longitude?.toFixed(5) ?? '—' }}</td>
</tr>
</tbody>
</table>
</div>
</template>