Radiosonde + sat track updates
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import Altitude from '@/components/Altitude.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
history: any[]
|
||||
@@ -7,11 +8,110 @@ const props = defineProps<{
|
||||
}>()
|
||||
const emit = defineEmits<{ (e: 'close'): void; (e: 'bringToFront'): void }>()
|
||||
|
||||
// ── Units ─────────────────────────────────────────────────────────────────────
|
||||
const BASE_SPEED_UNITS = {
|
||||
knots: { label: 'KTS', convert: (v: number) => Math.round(v) },
|
||||
kph: { label: 'KPH', convert: (v: number) => Math.round(v * 3.6) },
|
||||
mph: { label: 'MPH', convert: (v: number) => Math.round(v * 2.23694) },
|
||||
}
|
||||
|
||||
const BASE_ALTITUDE_UNITS = {
|
||||
meters: { label: 'M', convert: (v: number) => Math.round(v) },
|
||||
feet: { label: 'FT', convert: (v: number) => Math.round(v * 3.28084) },
|
||||
}
|
||||
|
||||
const BASE_VERTICAL_UNITS = {
|
||||
mps: { label: 'M/S', convert: (v: number) => Number(v.toFixed(1)) },
|
||||
fps: { label: 'FT/S', convert: (v: number) => Number((v * 3.28084).toFixed(1)) },
|
||||
}
|
||||
|
||||
const UNIT_KEY = 'at_units'
|
||||
|
||||
function loadUnits() {
|
||||
const s = localStorage.getItem(UNIT_KEY)
|
||||
return s
|
||||
? JSON.parse(s)
|
||||
: { speed: 'knots', altitude: 'meters', vertical: 'mps' }
|
||||
}
|
||||
|
||||
const prefs = ref(loadUnits())
|
||||
|
||||
function saveAndSet(p: any) {
|
||||
prefs.value = { ...p }
|
||||
localStorage.setItem(UNIT_KEY, JSON.stringify(prefs.value))
|
||||
}
|
||||
|
||||
function cycleSpeed() {
|
||||
const keys = Object.keys(BASE_SPEED_UNITS)
|
||||
const p = loadUnits()
|
||||
p.speed = keys[(keys.indexOf(p.speed) + 1) % keys.length]
|
||||
saveAndSet(p)
|
||||
}
|
||||
|
||||
function cycleAltitude() {
|
||||
const keys = Object.keys(BASE_ALTITUDE_UNITS)
|
||||
const p = loadUnits()
|
||||
p.altitude = keys[(keys.indexOf(p.altitude) + 1) % keys.length]
|
||||
p.vertical = p.altitude === 'meters' ? 'mps' : 'fps'
|
||||
saveAndSet(p)
|
||||
}
|
||||
|
||||
function cycleVertical() {
|
||||
const keys = Object.keys(BASE_VERTICAL_UNITS)
|
||||
const p = loadUnits()
|
||||
p.vertical = keys[(keys.indexOf(p.vertical) + 1) % keys.length]
|
||||
p.altitude = p.vertical === 'mps' ? 'meters' : 'feet'
|
||||
saveAndSet(p)
|
||||
}
|
||||
|
||||
function syncUnits(e: StorageEvent) {
|
||||
if (e.key === UNIT_KEY) prefs.value = loadUnits()
|
||||
}
|
||||
|
||||
const speed = computed(() =>
|
||||
BASE_SPEED_UNITS[prefs.value.speed as keyof typeof BASE_SPEED_UNITS]
|
||||
)
|
||||
|
||||
const altitudeUnit = computed(() =>
|
||||
BASE_ALTITUDE_UNITS[prefs.value.altitude as keyof typeof BASE_ALTITUDE_UNITS]
|
||||
)
|
||||
|
||||
const vertical = computed(() =>
|
||||
BASE_VERTICAL_UNITS[prefs.value.vertical as keyof typeof BASE_VERTICAL_UNITS]
|
||||
)
|
||||
|
||||
// ── Data ──────────────────────────────────────────────────────────────────────
|
||||
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)
|
||||
const altitude = computed(() => {
|
||||
if (latest.value?.altitude == null) return null
|
||||
return altitudeUnit.value.convert(Number(latest.value.altitude))
|
||||
})
|
||||
|
||||
const speedVal = computed(() => {
|
||||
if (latest.value?.speed == null) return null
|
||||
return speed.value.convert(Number(latest.value.speed))
|
||||
})
|
||||
|
||||
const climbVal = computed(() => {
|
||||
if (latest.value?.vertical_speed == null) return null
|
||||
|
||||
const v = vertical.value.convert(Number(latest.value.vertical_speed))
|
||||
return v >= 0 ? `+${v}` : String(v)
|
||||
})
|
||||
|
||||
const altitudeHistory = computed(() => props.history.map(p => ({
|
||||
...p,
|
||||
altitude: p.altitude != null
|
||||
? altitudeUnit.value.convert(Number(p.altitude))
|
||||
: null,
|
||||
})))
|
||||
|
||||
const currentAltitude = computed(() =>
|
||||
latest.value?.altitude != null
|
||||
? altitudeUnit.value.convert(Number(latest.value.altitude))
|
||||
: null
|
||||
)
|
||||
|
||||
// ── Drag ──────────────────────────────────────────────────────────────────────
|
||||
const pos = ref({ ...props.position })
|
||||
@@ -24,37 +124,68 @@ 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 }
|
||||
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 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
|
||||
const sx = touch.clientX
|
||||
const 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 }
|
||||
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'
|
||||
if (longPressTimer.value) {
|
||||
clearTimeout(longPressTimer.value)
|
||||
longPressTimer.value = null
|
||||
}
|
||||
|
||||
if (isDragging.value && header.value) {
|
||||
header.value.style.opacity = '1'
|
||||
}
|
||||
|
||||
isDragging.value = false
|
||||
}
|
||||
|
||||
@@ -64,13 +195,16 @@ onMounted(() => {
|
||||
document.addEventListener('touchmove', onTouchMove, { passive: false })
|
||||
document.addEventListener('touchend', onTouchEnd)
|
||||
document.addEventListener('touchcancel', onTouchEnd)
|
||||
window.addEventListener('storage', syncUnits)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('mousemove', onMouseMove)
|
||||
document.removeEventListener('mouseup', onMouseUp)
|
||||
document.removeEventListener('touchmove', onTouchMove)
|
||||
document.removeEventListener('touchend', onTouchEnd)
|
||||
document.removeEventListener('touchcancel', onTouchEnd)
|
||||
window.removeEventListener('storage', syncUnits)
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -98,7 +232,12 @@ onUnmounted(() => {
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
.tp-name { margin: 0 24px 0 0; color: #ffaa00; font-size: 16px; }
|
||||
|
||||
.tp-name {
|
||||
margin: 0 24px 0 0;
|
||||
color: #ffaa00;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.tp-close {
|
||||
position: absolute;
|
||||
@@ -117,7 +256,10 @@ onUnmounted(() => {
|
||||
justify-content: center;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
.tp-close:hover { background: rgba(255,255,255,0.35); }
|
||||
|
||||
.tp-close:hover {
|
||||
background: rgba(255,255,255,0.35);
|
||||
}
|
||||
|
||||
.tp-meta {
|
||||
font-size: 11px;
|
||||
@@ -129,15 +271,36 @@ onUnmounted(() => {
|
||||
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; }
|
||||
.flex-c {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.tp-body { display: flex; gap: 16px; padding: 12px 16px; }
|
||||
.align-x-end {
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.tp-gauges { flex: 1; }
|
||||
.tp-body {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.tp-gauge-wrap { margin-bottom: 12px; }
|
||||
.tp-gauge-label { color: #888; font-size: 11px; font-weight: bold; margin-bottom: 3px; }
|
||||
.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;
|
||||
@@ -148,10 +311,24 @@ onUnmounted(() => {
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tp-gauge-val { font-size: 20px; font-weight: bold; color: #ffaa00; }
|
||||
.tp-gauge-unit { font-size: 11px; color: #ffaa00; margin-bottom: 0.75em; }
|
||||
.tp-gauge:hover {
|
||||
border-color: #fff;
|
||||
}
|
||||
|
||||
.tp-gauge-val {
|
||||
font-size: 20px;
|
||||
font-weight: bold;
|
||||
color: #ffaa00;
|
||||
}
|
||||
|
||||
.tp-gauge-unit {
|
||||
font-size: 11px;
|
||||
color: #ffaa00;
|
||||
margin-bottom: 0.25em;
|
||||
}
|
||||
|
||||
.tp-fields {
|
||||
width: 100%;
|
||||
@@ -159,98 +336,138 @@ onUnmounted(() => {
|
||||
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; }
|
||||
.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
|
||||
class="sonde-popup"
|
||||
:style="{ left: pos.x + 'px', top: pos.y + 'px' }"
|
||||
>
|
||||
|
||||
<div ref="header" class="tp-header" @mousedown="onMouseDown" @touchstart.passive="onTouchStart">
|
||||
<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>
|
||||
<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 class="flex-c flex-fill">
|
||||
<span>Type: {{ latest?.type || 'Unknown' }}</span>
|
||||
<span>Freq: {{ latest?.frequency?.toFixed(3) || '—' }} MHz</span>
|
||||
</div>
|
||||
<div class="flex-c align-x-end">
|
||||
|
||||
<div class="flex-c flex-fill align-x-end">
|
||||
<span>Bat: {{ latest?.battery ?? '—' }} V</span>
|
||||
<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">Air Speed</div>
|
||||
<div
|
||||
class="tp-gauge"
|
||||
@click.stop="cycleSpeed"
|
||||
>
|
||||
<span class="tp-gauge-val">
|
||||
{{ speedVal ?? '—' }}
|
||||
</span>
|
||||
<span class="tp-gauge-unit">
|
||||
{{ speed.label }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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
|
||||
class="tp-gauge"
|
||||
@click.stop="cycleAltitude"
|
||||
>
|
||||
<span class="tp-gauge-val">
|
||||
{{ altitude != null ? altitude : '—' }}
|
||||
</span>
|
||||
<span class="tp-gauge-unit">
|
||||
{{ altitudeUnit.label }}
|
||||
</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 class="tp-gauge-label">Climb</div>
|
||||
<div
|
||||
class="tp-gauge"
|
||||
@click.stop="cycleVertical"
|
||||
>
|
||||
<span class="tp-gauge-val">
|
||||
{{ climbVal ?? '—' }}
|
||||
</span>
|
||||
<span class="tp-gauge-unit">
|
||||
{{ vertical.label }}
|
||||
</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>
|
||||
|
||||
<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>PPM</td>
|
||||
<td>{{ latest?.ppm?.toFixed(2) ?? '—' }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</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>
|
||||
<Altitude
|
||||
:history="altitudeHistory"
|
||||
:currentAltitude="currentAltitude"
|
||||
:unit="altitudeUnit.label"
|
||||
color="#ffaa00"
|
||||
/>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
Reference in New Issue
Block a user