474 lines
11 KiB
Vue
474 lines
11 KiB
Vue
<script setup lang="ts">
|
|
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
|
import Altitude from '@/components/Altitude.vue'
|
|
|
|
const props = defineProps<{
|
|
history: any[]
|
|
position: { x: number; y: number }
|
|
}>()
|
|
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(() => {
|
|
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 })
|
|
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
|
|
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
|
|
}
|
|
|
|
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)
|
|
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>
|
|
|
|
<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;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.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%;
|
|
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 flex-fill">
|
|
<span>Type: {{ latest?.type || 'Unknown' }}</span>
|
|
<span>Freq: {{ latest?.frequency?.toFixed(3) || '—' }} MHz</span>
|
|
</div>
|
|
|
|
<div class="flex-c flex-fill align-x-end">
|
|
<span>Bat: {{ latest?.battery ?? '—' }} V</span>
|
|
<span>SNR: {{ latest?.snr ?? '—' }} dB</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"
|
|
@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">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>
|
|
|
|
<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>
|
|
|
|
<Altitude
|
|
:history="altitudeHistory"
|
|
:currentAltitude="currentAltitude"
|
|
:unit="altitudeUnit.label"
|
|
color="#ffaa00"
|
|
/>
|
|
|
|
</div>
|
|
</template>
|