Radiosonde + sat track updates

This commit is contained in:
2026-09-13 13:16:40 -04:00
parent 0e152b4dc7
commit 1d1e7351db
8 changed files with 585 additions and 186 deletions

View File

@@ -1,6 +1,7 @@
<script setup lang="ts">
import {BASE} from '@/services/api.ts';
import { ref, computed, onMounted, onUnmounted } from 'vue'
import AircraftAltitude from '@/components/Altitude.vue'
const BASE_SPEED_UNITS = {
knots: { label: 'KTS', convert: (v: number) => Math.round(v) },
@@ -18,50 +19,24 @@ const BASE_VERTICAL_UNITS = {
const photoError = ref(false);
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 props = defineProps<{ plane: any; position: { x: number; y: number } }>()
const props = defineProps<{ plane: any; position: { x: number; y: number }; history?: any[] }>()
const emit = defineEmits<{ (e: 'close'): void; (e: 'bringToFront'): void }>()
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 onUnitsChanged() {
prefs.value = loadUnits()
}
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)
}
const speed = computed(() => BASE_SPEED_UNITS[prefs.value.speed as keyof typeof BASE_SPEED_UNITS])
const speed = computed(() => BASE_SPEED_UNITS[prefs.value.speed as keyof typeof BASE_SPEED_UNITS])
const altitude = 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])
@@ -70,13 +45,16 @@ const altitudeVal = computed(() => props.plane.alt_baro ?? props.plane.alt_geom
const speedVal = computed(() => speed.value.convert(props.plane.speed || props.plane.gs || 0))
const altVal = computed(() => props.plane.landed ? 'LANDED' : altitude.value.convert(altitudeVal.value))
const altUnit = computed(() => props.plane.landed ? '' : altitude.value.label)
const climbVal = computed(() => {
const v = vertical.value.convert(props.plane.climb || props.plane.baro_rate || 0)
return v >= 0 ? `+${v}` : String(v)
})
const climbVal = computed(() => { const v = vertical.value.convert(props.plane.climb || props.plane.baro_rate || 0); return v >= 0 ? `+${v}` : String(v) })
const description = computed(() => props.plane.desc || [props.plane.manufacturer, props.plane.model].filter(Boolean).join(' ') || '')
const operator = computed(() => props.plane.operator || props.plane.owner || '')
const altitudeHistory = computed(() => (props.history || [])
.filter(p => !p.live && p.ts != null && p.altitude != null)
.map(p => ({...p, altitude: altitude.value.convert(Number(p.altitude))})))
const currentAltitude = computed(() => props.plane.landed ? null : altitude.value.convert(Number(altitudeVal.value)))
const pos = ref({ ...props.position })
const isDragging = ref(false)
const dragStart = ref({ mx: 0, my: 0, ex: 0, ey: 0 })
@@ -86,8 +64,8 @@ const header = ref<HTMLElement | null>(null)
function onMouseDown(e: MouseEvent) {
if ((e.target as HTMLElement).classList.contains('ap-close')) return
emit('bringToFront')
isDragging.value = true
dragStart.value = { mx: e.clientX, my: e.clientY, ex: pos.value.x, ey: pos.value.y }
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) {
@@ -123,19 +101,17 @@ function onTouchEnd() {
onMounted(() => {
document.addEventListener('mousemove', onMouseMove)
document.addEventListener('mouseup', onMouseUp)
document.addEventListener('mouseup', onMouseUp)
document.addEventListener('touchmove', onTouchMove, { passive: false })
document.addEventListener('touchend', onTouchEnd)
document.addEventListener('touchend', onTouchEnd)
document.addEventListener('touchcancel', onTouchEnd)
window.addEventListener('storage', onUnitsChanged)
})
onUnmounted(() => {
document.removeEventListener('mousemove', onMouseMove)
document.removeEventListener('mouseup', onMouseUp)
document.removeEventListener('mouseup', onMouseUp)
document.removeEventListener('touchmove', onTouchMove)
document.removeEventListener('touchend', onTouchEnd)
document.removeEventListener('touchend', onTouchEnd)
document.removeEventListener('touchcancel', onTouchEnd)
window.removeEventListener('storage', onUnitsChanged)
})
const navball = computed(() => {
@@ -150,7 +126,7 @@ const navball = computed(() => {
const pitchLines: string[] = []
for (let p = -30; p <= 30; p += 5) {
if (p === 0) continue
const isMajor = p % 10 === 0
const isMajor = p % 10 === 0
const [start, end] = [85, 115]
if (isMajor) {
pitchLines.push(`
@@ -166,21 +142,21 @@ const navball = computed(() => {
const rollTicks: string[] = []
for (const angle of [-90, -60, -45, -30, -20, -10, 0, 10, 20, 30, 45, 60, 90]) {
const rad = angle * Math.PI / 180
const a = Math.abs(angle)
const a = Math.abs(angle)
if (a === 45) {
rollTicks.push(`<circle cx="${100 + 78 * Math.sin(rad)}" cy="${100 - 78 * Math.cos(rad)}" r="2.5" fill="#fff"/>`)
} else {
const inner = (a === 0 || a % 30 === 0) ? 72 : 77
const sw = (a === 0 || a % 30 === 0) ? 2.5 : 1.5
const sw = (a === 0 || a % 30 === 0) ? 2.5 : 1.5
rollTicks.push(`<line x1="${100 + inner * Math.sin(rad)}" y1="${100 - inner * Math.cos(rad)}" x2="${100 + 85 * Math.sin(rad)}" y2="${100 - 85 * Math.cos(rad)}" stroke="#fff" stroke-width="${sw}"/>`)
}
}
const compassLabels = [-60, -45, -30, -15, 0, 15, 30, 45, 60].map(offset => {
const rad = offset * Math.PI / 180
const x = 100 + 70 * Math.sin(rad)
const y = 100 - 70 * Math.cos(rad)
const h = Math.round((offset + 360) % 360)
const x = 100 + 70 * Math.sin(rad)
const y = 100 - 70 * Math.cos(rad)
const h = Math.round((offset + 360) % 360)
const lbl = ({ 0: 'N', 90: 'E', 180: 'S', 270: 'W' } as any)[h] || ''
return lbl ? `<text x="${x}" y="${y + 4}" text-anchor="middle" fill="#0f0" font-size="13" font-weight="bold">${lbl}</text>` : ''
}).join('')
@@ -190,11 +166,11 @@ const navball = computed(() => {
<defs>
<clipPath id="navballClip"><circle cx="100" cy="100" r="85"/></clipPath>
<linearGradient id="skyGrad" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" style="stop-color:#1e90ff"/>
<stop offset="0%" style="stop-color:#1e90ff"/>
<stop offset="100%" style="stop-color:#4169e1"/>
</linearGradient>
<linearGradient id="groundGrad" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" style="stop-color:#8b4513"/>
<stop offset="0%" style="stop-color:#8b4513"/>
<stop offset="100%" style="stop-color:#654321"/>
</linearGradient>
</defs>
@@ -208,7 +184,7 @@ const navball = computed(() => {
</g>
<g transform="translate(0,25)">${rollTicks.join('')}</g>
<circle cx="100" cy="125" r="85" fill="none" stroke="#0f0" stroke-width="2.5"/>
<line x1="40" y1="125" x2="80" y2="125" stroke="#ff0" stroke-width="3.5"/>
<line x1="40" y1="125" x2="80" y2="125" stroke="#ff0" stroke-width="3.5"/>
<line x1="120" y1="125" x2="160" y2="125" stroke="#ff0" stroke-width="3.5"/>
<circle cx="100" cy="125" r="4" fill="none" stroke="#ff0" stroke-width="2.5"/>
<g transform="rotate(${-heading} 100 125)">${compassLabels}</g>
@@ -321,7 +297,6 @@ const navball = computed(() => {
gap: 4px;
transition: border-color 0.15s;
}
.ap-gauge:hover { border-color: #0ff; }
.ap-gauge-val { font-size: 20px; font-weight: bold; color: #0f0; }
.ap-gauge-unit { font-size: 11px; color: #0f0; margin-bottom: 2px; }
@@ -330,6 +305,7 @@ const navball = computed(() => {
<template>
<div class="aircraft-popup" :style="{ left: pos.x + 'px', top: pos.y + 'px' }">
<div ref="header" class="ap-header" @mousedown="onMouseDown" @touchstart.passive="onTouchStart">
<h3 class="ap-callsign">
@@ -341,11 +317,11 @@ const navball = computed(() => {
</div>
<div class="ap-meta flex-r justify-between">
<div class="flex-c">
<div class="flex-c flex-fill">
<span>{{ operator || 'Unknown Owner' }}</span>
<span>{{ plane.country || 'Unknown Country' }}</span>
</div>
<div class="flex-c align-x-end">
<div class="flex-c flex-fill align-x-end">
<span>{{ description || 'Unknown Aircraft' }}</span>
<span style="text-transform: capitalize">{{ plane.class || 'Unknown' }} {{ plane.type || 'Unknown' }}</span>
</div>
@@ -381,5 +357,12 @@ const navball = computed(() => {
</div>
<div class="ap-navball" v-html="navball" />
</div>
<AircraftAltitude
:history="altitudeHistory"
:current-altitude="currentAltitude"
:unit="altitude.label"
/>
</div>
</template>

View File

@@ -1,11 +1,14 @@
<script setup lang="ts">
import {computed} from 'vue'
const props = defineProps<{
const props = withDefaults(defineProps<{
history: any[]
currentAltitude: number | null
color?: string,
unit: string
}>()
}>(), {
color: '#0f0'
});
const WIDTH = 320
const HEIGHT = 120
@@ -13,19 +16,29 @@ const PAD = {top: 10, right: 12, bottom: 24, left: 42}
const points = computed(() => {
const history = [...(props.history || [])]
.filter(p => p.ts != null && p.altitude != null && !p.live)
.sort((a, b) => Number(a.ts) - Number(b.ts))
.filter(p => p.altitude != null && p.altitude !== 0 && !p.live)
.sort((a, b) => {
if (a.ts == null || b.ts == null) return 0
return Number(a.ts) - Number(b.ts)
})
if (props.currentAltitude != null && history.length) {
const last = history[history.length - 1]
history.push({
ts: Math.max(Number(last.ts) + 1, Math.floor(Date.now() / 1000)),
const now = Math.floor(Date.now() / 1000)
const points = history.map((p, i) => ({
...p,
ts: p.ts != null ? Number(p.ts) : now - (history.length - i - 1),
}))
if (props.currentAltitude != null && points.length) {
const last = points[points.length - 1]
points.push({
ts: Math.max(last.ts + 1, now),
altitude: props.currentAltitude,
live: true,
})
}
return history
return points
})
const graph = computed(() => {
@@ -128,7 +141,7 @@ const graph = computed(() => {
<polyline
:points="graph.line"
fill="none"
stroke="#0f0"
:stroke="color"
stroke-width="2"
/>
@@ -136,7 +149,7 @@ const graph = computed(() => {
:cx="graph.latestX"
:cy="graph.latestY"
r="3"
fill="#0f0"
:fill="color"
/>
<text :x="PAD.left" :y="HEIGHT - 6">

View File

@@ -212,11 +212,11 @@ const currentPoint = computed(() => latest.value ? polarPoint(latest.value.az, l
</div>
<div class="tp-meta">
<div class="flex-c">
<span>{{ latest?.freqMHz }} MHz</span>
<div class="flex-c flex-fill">
<span>Range: {{ rangeVal != null ? rangeVal + ' ' + distance.label : 'Range —' }}</span>
<span>Freq: {{ latest?.freqMHz }} MHz</span>
</div>
<div class="flex-c align-x-end">
<div class="flex-c flex-fill align-x-end">
<span>RSSI: {{ latest?.packetRssi }} dBm</span>
<span>SNR: {{ latest?.packetSnr }} dB</span>
</div>

View File

@@ -322,11 +322,11 @@ const compass = computed(() => {
</div>
<div class="sp-meta">
<div class="flex-c">
<div class="flex-c flex-fill">
<span>{{ boat.operator || boat.owner || 'Unknown Owner' }}</span>
<span>{{ boat.country || 'Unknown Country' }}</span>
</div>
<div class="flex-c align-x-end">
<div class="flex-c flex-fill align-x-end">
<span style="text-transform:capitalize">{{ boat.type || 'Unknown' }}</span>
<span>{{ boat.ship_type || 'Unknown Class' }}</span>
</div>

View File

@@ -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>