543 lines
13 KiB
Vue
543 lines
13 KiB
Vue
<script setup lang="ts">
|
||
import {formatDate} from '@ztimson/utils';
|
||
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?.climb == null) return null
|
||
const v = vertical.value.convert(Number(latest.value.climb))
|
||
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: 0 0 110px;
|
||
}
|
||
|
||
.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-environment {
|
||
flex: 1;
|
||
min-width: 0;
|
||
}
|
||
|
||
.tp-environment-label {
|
||
color: #888;
|
||
font-size: 11px;
|
||
font-weight: bold;
|
||
margin-bottom: 3px;
|
||
}
|
||
|
||
.tp-environment-cards {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 12px;
|
||
}
|
||
|
||
.tp-environment-card {
|
||
min-height: 42px;
|
||
padding: 6px;
|
||
box-sizing: border-box;
|
||
background: rgba(255, 255, 255, 0.1);
|
||
border-radius: 3px;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 7px;
|
||
}
|
||
|
||
.tp-environment-icon {
|
||
width: 26px;
|
||
height: 26px;
|
||
flex: 0 0 26px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
background: rgba(255,170,0,0.08);
|
||
border-radius: 3px;
|
||
font-size: 14px;
|
||
}
|
||
|
||
.tp-environment-info {
|
||
min-width: 0;
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 2px;
|
||
}
|
||
|
||
.tp-environment-stat-label {
|
||
color: #888;
|
||
font-size: 10px;
|
||
font-weight: bold;
|
||
}
|
||
|
||
.tp-environment-stat-value {
|
||
color: white;
|
||
font-size: 13px;
|
||
font-weight: bold;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.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?.freqMhz?.toFixed(2) || '—' }} 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>
|
||
|
||
<div class="tp-environment">
|
||
<div class="tp-environment-label">Environment</div>
|
||
|
||
<div class="tp-environment-cards gap-1">
|
||
<div class="tp-environment-card">
|
||
<span class="tp-environment-icon">🌡️</span>
|
||
<div class="tp-environment-info">
|
||
<span class="tp-environment-stat-label">Temperature</span>
|
||
<span class="tp-environment-stat-value">
|
||
{{ latest?.temperature ?? '—' }} °C
|
||
</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="tp-environment-card">
|
||
<span class="tp-environment-icon">💧</span>
|
||
<div class="tp-environment-info">
|
||
<span class="tp-environment-stat-label">Humidity</span>
|
||
<span class="tp-environment-stat-value">
|
||
{{ latest?.humidity ?? '—' }} %
|
||
</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="tp-environment-card">
|
||
<span class="tp-environment-icon">⬇️</span>
|
||
<div class="tp-environment-info">
|
||
<span class="tp-environment-stat-label">Pressure</span>
|
||
<span class="tp-environment-stat-value">
|
||
{{ latest?.pressure > 0 ? latest.pressure : '—' }} hpa
|
||
</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="tp-environment-card">
|
||
<span class="tp-environment-icon">🫧</span>
|
||
<div class="tp-environment-info">
|
||
<span class="tp-environment-stat-label">PPM</span>
|
||
<span class="tp-environment-stat-value">
|
||
{{ latest?.ppm?.toFixed(2) ?? '—' }}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<Altitude
|
||
:history="altitudeHistory"
|
||
:currentAltitude="currentAltitude"
|
||
:unit="altitudeUnit.label"
|
||
start-time=" "
|
||
:end-time="formatDate('HH:mm:ss', latest.timestamp)"
|
||
color="#ffaa00"
|
||
/>
|
||
|
||
</div>
|
||
</template>
|