Radiosonde + sat track updates
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import {BASE} from '@/services/api.ts';
|
import {BASE} from '@/services/api.ts';
|
||||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||||
|
import AircraftAltitude from '@/components/Altitude.vue'
|
||||||
|
|
||||||
const BASE_SPEED_UNITS = {
|
const BASE_SPEED_UNITS = {
|
||||||
knots: { label: 'KTS', convert: (v: number) => Math.round(v) },
|
knots: { label: 'KTS', convert: (v: number) => Math.round(v) },
|
||||||
@@ -18,48 +19,22 @@ const BASE_VERTICAL_UNITS = {
|
|||||||
|
|
||||||
const photoError = ref(false);
|
const photoError = ref(false);
|
||||||
const UNIT_KEY = 'at_units'
|
const UNIT_KEY = 'at_units'
|
||||||
|
|
||||||
function loadUnits() {
|
function loadUnits() {
|
||||||
const s = localStorage.getItem(UNIT_KEY)
|
const s = localStorage.getItem(UNIT_KEY)
|
||||||
return s ? JSON.parse(s) : { speed: 'knots', altitude: 'meters', vertical: 'mps' }
|
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 emit = defineEmits<{ (e: 'close'): void; (e: 'bringToFront'): void }>()
|
||||||
|
|
||||||
const prefs = ref(loadUnits())
|
const prefs = ref(loadUnits())
|
||||||
|
|
||||||
function saveAndSet(p: any) {
|
function saveAndSet(p: any) {
|
||||||
prefs.value = { ...p }
|
prefs.value = { ...p }
|
||||||
localStorage.setItem(UNIT_KEY, JSON.stringify(prefs.value))
|
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 onUnitsChanged() {
|
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) }
|
||||||
prefs.value = loadUnits()
|
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 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 altitude = computed(() => BASE_ALTITUDE_UNITS[prefs.value.altitude as keyof typeof BASE_ALTITUDE_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 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 altVal = computed(() => props.plane.landed ? 'LANDED' : altitude.value.convert(altitudeVal.value))
|
||||||
const altUnit = computed(() => props.plane.landed ? '' : altitude.value.label)
|
const altUnit = computed(() => props.plane.landed ? '' : altitude.value.label)
|
||||||
const climbVal = computed(() => {
|
const climbVal = computed(() => { const v = vertical.value.convert(props.plane.climb || props.plane.baro_rate || 0); return v >= 0 ? `+${v}` : String(v) })
|
||||||
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 description = computed(() => props.plane.desc || [props.plane.manufacturer, props.plane.model].filter(Boolean).join(' ') || '')
|
||||||
const operator = computed(() => props.plane.operator || props.plane.owner || '')
|
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 pos = ref({ ...props.position })
|
||||||
const isDragging = ref(false)
|
const isDragging = ref(false)
|
||||||
const dragStart = ref({ mx: 0, my: 0, ex: 0, ey: 0 })
|
const dragStart = ref({ mx: 0, my: 0, ex: 0, ey: 0 })
|
||||||
@@ -127,7 +105,6 @@ onMounted(() => {
|
|||||||
document.addEventListener('touchmove', onTouchMove, { passive: false })
|
document.addEventListener('touchmove', onTouchMove, { passive: false })
|
||||||
document.addEventListener('touchend', onTouchEnd)
|
document.addEventListener('touchend', onTouchEnd)
|
||||||
document.addEventListener('touchcancel', onTouchEnd)
|
document.addEventListener('touchcancel', onTouchEnd)
|
||||||
window.addEventListener('storage', onUnitsChanged)
|
|
||||||
})
|
})
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
document.removeEventListener('mousemove', onMouseMove)
|
document.removeEventListener('mousemove', onMouseMove)
|
||||||
@@ -135,7 +112,6 @@ onUnmounted(() => {
|
|||||||
document.removeEventListener('touchmove', onTouchMove)
|
document.removeEventListener('touchmove', onTouchMove)
|
||||||
document.removeEventListener('touchend', onTouchEnd)
|
document.removeEventListener('touchend', onTouchEnd)
|
||||||
document.removeEventListener('touchcancel', onTouchEnd)
|
document.removeEventListener('touchcancel', onTouchEnd)
|
||||||
window.removeEventListener('storage', onUnitsChanged)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const navball = computed(() => {
|
const navball = computed(() => {
|
||||||
@@ -321,7 +297,6 @@ const navball = computed(() => {
|
|||||||
gap: 4px;
|
gap: 4px;
|
||||||
transition: border-color 0.15s;
|
transition: border-color 0.15s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ap-gauge:hover { border-color: #0ff; }
|
.ap-gauge:hover { border-color: #0ff; }
|
||||||
.ap-gauge-val { font-size: 20px; font-weight: bold; color: #0f0; }
|
.ap-gauge-val { font-size: 20px; font-weight: bold; color: #0f0; }
|
||||||
.ap-gauge-unit { font-size: 11px; color: #0f0; margin-bottom: 2px; }
|
.ap-gauge-unit { font-size: 11px; color: #0f0; margin-bottom: 2px; }
|
||||||
@@ -330,6 +305,7 @@ const navball = computed(() => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="aircraft-popup" :style="{ left: pos.x + 'px', top: pos.y + 'px' }">
|
<div class="aircraft-popup" :style="{ left: pos.x + 'px', top: pos.y + 'px' }">
|
||||||
|
|
||||||
<div ref="header" class="ap-header" @mousedown="onMouseDown" @touchstart.passive="onTouchStart">
|
<div ref="header" class="ap-header" @mousedown="onMouseDown" @touchstart.passive="onTouchStart">
|
||||||
<h3 class="ap-callsign">
|
<h3 class="ap-callsign">
|
||||||
✈️
|
✈️
|
||||||
@@ -341,11 +317,11 @@ const navball = computed(() => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="ap-meta flex-r justify-between">
|
<div class="ap-meta flex-r justify-between">
|
||||||
<div class="flex-c">
|
<div class="flex-c flex-fill">
|
||||||
<span>{{ operator || 'Unknown Owner' }}</span>
|
<span>{{ operator || 'Unknown Owner' }}</span>
|
||||||
<span>{{ plane.country || 'Unknown Country' }}</span>
|
<span>{{ plane.country || 'Unknown Country' }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-c align-x-end">
|
<div class="flex-c flex-fill align-x-end">
|
||||||
<span>{{ description || 'Unknown Aircraft' }}</span>
|
<span>{{ description || 'Unknown Aircraft' }}</span>
|
||||||
<span style="text-transform: capitalize">{{ plane.class || 'Unknown' }} • {{ plane.type || 'Unknown' }}</span>
|
<span style="text-transform: capitalize">{{ plane.class || 'Unknown' }} • {{ plane.type || 'Unknown' }}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -381,5 +357,12 @@ const navball = computed(() => {
|
|||||||
</div>
|
</div>
|
||||||
<div class="ap-navball" v-html="navball" />
|
<div class="ap-navball" v-html="navball" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<AircraftAltitude
|
||||||
|
:history="altitudeHistory"
|
||||||
|
:current-altitude="currentAltitude"
|
||||||
|
:unit="altitude.label"
|
||||||
|
/>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import {computed} from 'vue'
|
import {computed} from 'vue'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = withDefaults(defineProps<{
|
||||||
history: any[]
|
history: any[]
|
||||||
currentAltitude: number | null
|
currentAltitude: number | null
|
||||||
|
color?: string,
|
||||||
unit: string
|
unit: string
|
||||||
}>()
|
}>(), {
|
||||||
|
color: '#0f0'
|
||||||
|
});
|
||||||
|
|
||||||
const WIDTH = 320
|
const WIDTH = 320
|
||||||
const HEIGHT = 120
|
const HEIGHT = 120
|
||||||
@@ -13,19 +16,29 @@ const PAD = {top: 10, right: 12, bottom: 24, left: 42}
|
|||||||
|
|
||||||
const points = computed(() => {
|
const points = computed(() => {
|
||||||
const history = [...(props.history || [])]
|
const history = [...(props.history || [])]
|
||||||
.filter(p => p.ts != null && p.altitude != null && !p.live)
|
.filter(p => p.altitude != null && p.altitude !== 0 && !p.live)
|
||||||
.sort((a, b) => Number(a.ts) - Number(b.ts))
|
.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 now = Math.floor(Date.now() / 1000)
|
||||||
const last = history[history.length - 1]
|
|
||||||
history.push({
|
const points = history.map((p, i) => ({
|
||||||
ts: Math.max(Number(last.ts) + 1, Math.floor(Date.now() / 1000)),
|
...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,
|
altitude: props.currentAltitude,
|
||||||
live: true,
|
live: true,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return history
|
return points
|
||||||
})
|
})
|
||||||
|
|
||||||
const graph = computed(() => {
|
const graph = computed(() => {
|
||||||
@@ -128,7 +141,7 @@ const graph = computed(() => {
|
|||||||
<polyline
|
<polyline
|
||||||
:points="graph.line"
|
:points="graph.line"
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="#0f0"
|
:stroke="color"
|
||||||
stroke-width="2"
|
stroke-width="2"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -136,7 +149,7 @@ const graph = computed(() => {
|
|||||||
:cx="graph.latestX"
|
:cx="graph.latestX"
|
||||||
:cy="graph.latestY"
|
:cy="graph.latestY"
|
||||||
r="3"
|
r="3"
|
||||||
fill="#0f0"
|
:fill="color"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<text :x="PAD.left" :y="HEIGHT - 6">
|
<text :x="PAD.left" :y="HEIGHT - 6">
|
||||||
|
|||||||
@@ -212,11 +212,11 @@ const currentPoint = computed(() => latest.value ? polarPoint(latest.value.az, l
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="tp-meta">
|
<div class="tp-meta">
|
||||||
<div class="flex-c">
|
<div class="flex-c flex-fill">
|
||||||
<span>{{ latest?.freqMHz }} MHz</span>
|
|
||||||
<span>Range: {{ rangeVal != null ? rangeVal + ' ' + distance.label : 'Range —' }}</span>
|
<span>Range: {{ rangeVal != null ? rangeVal + ' ' + distance.label : 'Range —' }}</span>
|
||||||
|
<span>Freq: {{ latest?.freqMHz }} MHz</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-c align-x-end">
|
<div class="flex-c flex-fill align-x-end">
|
||||||
<span>RSSI: {{ latest?.packetRssi }} dBm</span>
|
<span>RSSI: {{ latest?.packetRssi }} dBm</span>
|
||||||
<span>SNR: {{ latest?.packetSnr }} dB</span>
|
<span>SNR: {{ latest?.packetSnr }} dB</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -322,11 +322,11 @@ const compass = computed(() => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="sp-meta">
|
<div class="sp-meta">
|
||||||
<div class="flex-c">
|
<div class="flex-c flex-fill">
|
||||||
<span>{{ boat.operator || boat.owner || 'Unknown Owner' }}</span>
|
<span>{{ boat.operator || boat.owner || 'Unknown Owner' }}</span>
|
||||||
<span>{{ boat.country || 'Unknown Country' }}</span>
|
<span>{{ boat.country || 'Unknown Country' }}</span>
|
||||||
</div>
|
</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 style="text-transform:capitalize">{{ boat.type || 'Unknown' }}</span>
|
||||||
<span>{{ boat.ship_type || 'Unknown Class' }}</span>
|
<span>{{ boat.ship_type || 'Unknown Class' }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||||
|
import Altitude from '@/components/Altitude.vue'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
history: any[]
|
history: any[]
|
||||||
@@ -7,11 +8,110 @@ const props = defineProps<{
|
|||||||
}>()
|
}>()
|
||||||
const emit = defineEmits<{ (e: 'close'): void; (e: 'bringToFront'): void }>()
|
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 latest = computed(() => props.history[props.history.length - 1])
|
||||||
|
|
||||||
const altitude = computed(() => latest.value?.altitude != null ? Math.round(latest.value.altitude / 1000) : null)
|
const altitude = computed(() => {
|
||||||
const speed = computed(() => latest.value?.speed != null ? latest.value.speed.toFixed(1) : null)
|
if (latest.value?.altitude == null) return null
|
||||||
const climb = computed(() => latest.value?.vertical_speed != null ? latest.value.vertical_speed.toFixed(1) : 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 ──────────────────────────────────────────────────────────────────────
|
// ── Drag ──────────────────────────────────────────────────────────────────────
|
||||||
const pos = ref({ ...props.position })
|
const pos = ref({ ...props.position })
|
||||||
@@ -24,37 +124,68 @@ function onMouseDown(e: MouseEvent) {
|
|||||||
if ((e.target as HTMLElement).classList.contains('tp-close')) return
|
if ((e.target as HTMLElement).classList.contains('tp-close')) return
|
||||||
emit('bringToFront')
|
emit('bringToFront')
|
||||||
isDragging.value = true
|
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()
|
e.preventDefault()
|
||||||
}
|
}
|
||||||
|
|
||||||
function onMouseMove(e: MouseEvent) {
|
function onMouseMove(e: MouseEvent) {
|
||||||
if (!isDragging.value) return
|
if (!isDragging.value) return
|
||||||
|
|
||||||
pos.value.x = dragStart.value.ex + (e.clientX - dragStart.value.mx)
|
pos.value.x = dragStart.value.ex + (e.clientX - dragStart.value.mx)
|
||||||
pos.value.y = dragStart.value.ey + (e.clientY - dragStart.value.my)
|
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) {
|
function onTouchStart(e: TouchEvent) {
|
||||||
if ((e.target as HTMLElement).classList.contains('tp-close')) return
|
if ((e.target as HTMLElement).classList.contains('tp-close')) return
|
||||||
|
|
||||||
const touch: any = e.touches[0]
|
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(() => {
|
longPressTimer.value = window.setTimeout(() => {
|
||||||
emit('bringToFront')
|
emit('bringToFront')
|
||||||
isDragging.value = true
|
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'
|
if (header.value) header.value.style.opacity = '0.8'
|
||||||
}, 500)
|
}, 500)
|
||||||
}
|
}
|
||||||
|
|
||||||
function onTouchMove(e: TouchEvent) {
|
function onTouchMove(e: TouchEvent) {
|
||||||
if (!isDragging.value) return
|
if (!isDragging.value) return
|
||||||
|
|
||||||
const touch: any = e.touches[0]
|
const touch: any = e.touches[0]
|
||||||
|
|
||||||
pos.value.x = dragStart.value.ex + (touch.clientX - dragStart.value.mx)
|
pos.value.x = dragStart.value.ex + (touch.clientX - dragStart.value.mx)
|
||||||
pos.value.y = dragStart.value.ey + (touch.clientY - dragStart.value.my)
|
pos.value.y = dragStart.value.ey + (touch.clientY - dragStart.value.my)
|
||||||
|
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
}
|
}
|
||||||
|
|
||||||
function onTouchEnd() {
|
function onTouchEnd() {
|
||||||
if (longPressTimer.value) { clearTimeout(longPressTimer.value); longPressTimer.value = null }
|
if (longPressTimer.value) {
|
||||||
if (isDragging.value && header.value) header.value.style.opacity = '1'
|
clearTimeout(longPressTimer.value)
|
||||||
|
longPressTimer.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isDragging.value && header.value) {
|
||||||
|
header.value.style.opacity = '1'
|
||||||
|
}
|
||||||
|
|
||||||
isDragging.value = false
|
isDragging.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,13 +195,16 @@ onMounted(() => {
|
|||||||
document.addEventListener('touchmove', onTouchMove, { passive: false })
|
document.addEventListener('touchmove', onTouchMove, { passive: false })
|
||||||
document.addEventListener('touchend', onTouchEnd)
|
document.addEventListener('touchend', onTouchEnd)
|
||||||
document.addEventListener('touchcancel', onTouchEnd)
|
document.addEventListener('touchcancel', onTouchEnd)
|
||||||
|
window.addEventListener('storage', syncUnits)
|
||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
document.removeEventListener('mousemove', onMouseMove)
|
document.removeEventListener('mousemove', onMouseMove)
|
||||||
document.removeEventListener('mouseup', onMouseUp)
|
document.removeEventListener('mouseup', onMouseUp)
|
||||||
document.removeEventListener('touchmove', onTouchMove)
|
document.removeEventListener('touchmove', onTouchMove)
|
||||||
document.removeEventListener('touchend', onTouchEnd)
|
document.removeEventListener('touchend', onTouchEnd)
|
||||||
document.removeEventListener('touchcancel', onTouchEnd)
|
document.removeEventListener('touchcancel', onTouchEnd)
|
||||||
|
window.removeEventListener('storage', syncUnits)
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -98,7 +232,12 @@ onUnmounted(() => {
|
|||||||
user-select: none;
|
user-select: none;
|
||||||
-webkit-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 {
|
.tp-close {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@@ -117,7 +256,10 @@ onUnmounted(() => {
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
transition: background 0.2s;
|
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 {
|
.tp-meta {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
@@ -129,15 +271,36 @@ onUnmounted(() => {
|
|||||||
border-bottom: 1px solid rgba(255,170,0,0.2);
|
border-bottom: 1px solid rgba(255,170,0,0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.flex-c { display: flex; flex-direction: column; gap: 3px; }
|
.flex-c {
|
||||||
.align-x-end { align-items: flex-end; }
|
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-gauges {
|
||||||
.tp-gauge-label { color: #888; font-size: 11px; font-weight: bold; margin-bottom: 3px; }
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tp-gauge-wrap {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tp-gauge-label {
|
||||||
|
color: #888;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: bold;
|
||||||
|
margin-bottom: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
.tp-gauge {
|
.tp-gauge {
|
||||||
padding: 6px;
|
padding: 6px;
|
||||||
@@ -148,10 +311,24 @@ onUnmounted(() => {
|
|||||||
align-items: flex-end;
|
align-items: flex-end;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tp-gauge-val { font-size: 20px; font-weight: bold; color: #ffaa00; }
|
.tp-gauge:hover {
|
||||||
.tp-gauge-unit { font-size: 11px; color: #ffaa00; margin-bottom: 0.75em; }
|
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 {
|
.tp-fields {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -159,57 +336,105 @@ onUnmounted(() => {
|
|||||||
padding: 0 16px 12px;
|
padding: 0 16px 12px;
|
||||||
border-collapse: collapse;
|
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; }
|
.tp-fields td {
|
||||||
.bad { color: #e0475a !important; }
|
padding: 2px 4px;
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tp-fields td:last-child {
|
||||||
|
color: #ccc;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ok {
|
||||||
|
color: #3fdb6d !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bad {
|
||||||
|
color: #e0475a !important;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<template>
|
<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>
|
<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>
|
||||||
|
|
||||||
<div class="tp-meta">
|
<div class="tp-meta">
|
||||||
<div class="flex-c">
|
<div class="flex-c flex-fill">
|
||||||
<span>{{ latest?.type || 'Unknown' }}</span>
|
<span>Type: {{ latest?.type || 'Unknown' }}</span>
|
||||||
<span>{{ latest?.frequency?.toFixed(3) || '—' }} MHz</span>
|
<span>Freq: {{ latest?.frequency?.toFixed(3) || '—' }} MHz</span>
|
||||||
</div>
|
</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>SNR: {{ latest?.snr ?? '—' }} dB</span>
|
||||||
<span>{{ latest?.sats ?? '—' }} sats</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="tp-body">
|
<div class="tp-body">
|
||||||
<div class="tp-gauges">
|
<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-wrap">
|
||||||
<div class="tp-gauge-label">Altitude</div>
|
<div class="tp-gauge-label">Altitude</div>
|
||||||
<div class="tp-gauge">
|
<div
|
||||||
<span class="tp-gauge-val">{{ altitude != null ? altitude + ' km' : '—' }}</span>
|
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>
|
</div>
|
||||||
|
|
||||||
<div class="tp-gauge-wrap">
|
<div class="tp-gauge-wrap">
|
||||||
<div class="tp-gauge-label">Speed</div>
|
<div class="tp-gauge-label">Climb</div>
|
||||||
<div class="tp-gauge">
|
<div
|
||||||
<span class="tp-gauge-val">{{ speed ?? '—' }}</span>
|
class="tp-gauge"
|
||||||
<span class="tp-gauge-unit">m/s</span>
|
@click.stop="cycleVertical"
|
||||||
|
>
|
||||||
|
<span class="tp-gauge-val">
|
||||||
|
{{ climbVal ?? '—' }}
|
||||||
|
</span>
|
||||||
|
<span class="tp-gauge-unit">
|
||||||
|
{{ vertical.label }}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
|
|
||||||
<table class="tp-fields">
|
<table class="tp-fields">
|
||||||
@@ -218,39 +443,31 @@ onUnmounted(() => {
|
|||||||
<td>Heading</td>
|
<td>Heading</td>
|
||||||
<td>{{ latest?.heading?.toFixed(1) ?? '—' }}°</td>
|
<td>{{ latest?.heading?.toFixed(1) ?? '—' }}°</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
<tr>
|
<tr>
|
||||||
<td>Temperature</td>
|
<td>Temperature</td>
|
||||||
<td>{{ latest?.temperature ?? '—' }} °C</td>
|
<td>{{ latest?.temperature ?? '—' }} °C</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
<tr>
|
<tr>
|
||||||
<td>Humidity</td>
|
<td>Humidity</td>
|
||||||
<td>{{ latest?.humidity ?? '—' }} %</td>
|
<td>{{ latest?.humidity ?? '—' }} %</td>
|
||||||
</tr>
|
</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>
|
<tr>
|
||||||
<td>PPM</td>
|
<td>PPM</td>
|
||||||
<td>{{ latest?.ppm?.toFixed(2) ?? '—' }}</td>
|
<td>{{ latest?.ppm?.toFixed(2) ?? '—' }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
|
||||||
<td>Latitude</td>
|
|
||||||
<td>{{ latest?.latitude?.toFixed(5) ?? '—' }}</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>Longitude</td>
|
|
||||||
<td>{{ latest?.longitude?.toFixed(5) ?? '—' }}</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Altitude
|
||||||
|
:history="altitudeHistory"
|
||||||
|
:currentAltitude="currentAltitude"
|
||||||
|
:unit="altitudeUnit.label"
|
||||||
|
color="#ffaa00"
|
||||||
|
/>
|
||||||
|
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,59 +1,234 @@
|
|||||||
import {cfg} from './config.mjs';
|
import {cfg} from './config.mjs';
|
||||||
import {isEqual} from '@ztimson/utils';
|
import {isEqual} from '@ztimson/utils';
|
||||||
|
import {randomUUID} from 'node:crypto';
|
||||||
|
|
||||||
const HISTORY_LIMIT = 500;
|
const HISTORY_LIMIT = 500;
|
||||||
const POLL_MS = 5_000;
|
const POLL_MS = 5_000;
|
||||||
const TTL_MS = 3 * 60_000;
|
const TTL_MS = 3 * 60_000;
|
||||||
|
const MAX_PREDICTION_DT = 30;
|
||||||
|
const MAX_AZ_ERROR = 35;
|
||||||
|
const MAX_EL_ERROR = 20;
|
||||||
|
const MAX_GROUND_DISTANCE_KM = 1000;
|
||||||
|
const MAX_FREQ_ERROR_KHZ = 15;
|
||||||
|
|
||||||
|
const satellites = new Map();
|
||||||
|
|
||||||
|
function normalizeAzimuth(az) {
|
||||||
|
return ((az % 360) + 360) % 360;
|
||||||
|
}
|
||||||
|
|
||||||
|
function circularDistance(a, b) {
|
||||||
|
const diff = Math.abs(normalizeAzimuth(a) - normalizeAzimuth(b));
|
||||||
|
return Math.min(diff, 360 - diff);
|
||||||
|
}
|
||||||
|
|
||||||
|
function circularDelta(from, to) {
|
||||||
|
let delta = normalizeAzimuth(to) - normalizeAzimuth(from);
|
||||||
|
if (delta > 180) delta -= 360;
|
||||||
|
if (delta < -180) delta += 360;
|
||||||
|
return delta;
|
||||||
|
}
|
||||||
|
|
||||||
|
function haversineDistance(lat1, lon1, lat2, lon2) {
|
||||||
|
const R = 6371;
|
||||||
|
const dLat = (lat2 - lat1) * Math.PI / 180;
|
||||||
|
const dLon = (lon2 - lon1) * Math.PI / 180;
|
||||||
|
const a =
|
||||||
|
Math.sin(dLat / 2) ** 2 +
|
||||||
|
Math.cos(lat1 * Math.PI / 180) *
|
||||||
|
Math.cos(lat2 * Math.PI / 180) *
|
||||||
|
Math.sin(dLon / 2) ** 2;
|
||||||
|
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||||
|
}
|
||||||
|
|
||||||
|
function calculateBearing(lat1, lon1, lat2, lon2) {
|
||||||
|
const φ1 = lat1 * Math.PI / 180;
|
||||||
|
const φ2 = lat2 * Math.PI / 180;
|
||||||
|
const λ = (lon2 - lon1) * Math.PI / 180;
|
||||||
|
const y = Math.sin(λ) * Math.cos(φ2);
|
||||||
|
const x = Math.cos(φ1) * Math.sin(φ2) - Math.sin(φ1) * Math.cos(φ2) * Math.cos(λ);
|
||||||
|
return normalizeAzimuth(Math.atan2(y, x) * 180 / Math.PI);
|
||||||
|
}
|
||||||
|
|
||||||
function parseWm(raw) {
|
function parseWm(raw) {
|
||||||
const f = raw.replace(/<[^>]+>/g, '').split(',').map(s => s.trim());
|
const f = raw.replace(/<[^>]+>/g, '').split(',').map(s => s.trim());
|
||||||
const [latitude, longitude] = f[15].split('/').map(s => parseFloat(s));
|
const [latitude, longitude] = (f[15] || '').split('/').map(s => parseFloat(s));
|
||||||
const [az, el] = f[16].split('/').map(s => parseFloat(s));
|
const [az, el] = (f[16] || '').split('/').map(s => parseFloat(s));
|
||||||
return {
|
return {
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
freqMHz: parseFloat(f[3]),
|
freqMHz: parseFloat(f[3]),
|
||||||
satellite: f[14],
|
satellite: f[14],
|
||||||
latitude, longitude,
|
latitude,
|
||||||
az, el,
|
longitude,
|
||||||
|
az: normalizeAzimuth(az),
|
||||||
|
el,
|
||||||
packetRssi: parseFloat(f[21]),
|
packetRssi: parseFloat(f[21]),
|
||||||
packetSnr: parseFloat(f[22]),
|
packetSnr: parseFloat(f[22]),
|
||||||
freqError: parseFloat(f[23]),
|
freqError: parseFloat(f[23]),
|
||||||
crcOk: !/CRC ERROR/i.test(f[24]),
|
crcOk: !/CRC ERROR/i.test(f[24]),
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const satellites = new Map();
|
function createTrack(reading) {
|
||||||
|
const id = randomUUID();
|
||||||
|
const track = {
|
||||||
|
id,
|
||||||
|
name: reading.satellite,
|
||||||
|
history: [],
|
||||||
|
lastSeen: reading.timestamp,
|
||||||
|
state: {
|
||||||
|
az: reading.az,
|
||||||
|
el: reading.el,
|
||||||
|
latitude: reading.latitude,
|
||||||
|
longitude: reading.longitude,
|
||||||
|
freqError: reading.freqError,
|
||||||
|
azVelocity: 0,
|
||||||
|
elVelocity: 0,
|
||||||
|
latitudeVelocity: 0,
|
||||||
|
longitudeVelocity: 0,
|
||||||
|
groundSpeedKmh: 0,
|
||||||
|
heading: null,
|
||||||
|
freqErrorVelocity: 0,
|
||||||
|
confidence: 0.25,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
track.history.push(reading);
|
||||||
|
satellites.set(id, track);
|
||||||
|
return track;
|
||||||
|
}
|
||||||
|
|
||||||
|
function predictTrack(track, timestamp) {
|
||||||
|
const dtRaw = (timestamp - track.lastSeen) / 1000;
|
||||||
|
const dt = Math.max(0, Math.min(dtRaw, MAX_PREDICTION_DT));
|
||||||
|
const state = track.state;
|
||||||
|
return {
|
||||||
|
az: normalizeAzimuth(state.az + state.azVelocity * dt),
|
||||||
|
el: state.el + state.elVelocity * dt,
|
||||||
|
latitude: state.latitude + state.latitudeVelocity * dt,
|
||||||
|
longitude: state.longitude + state.longitudeVelocity * dt,
|
||||||
|
freqError: state.freqError + state.freqErrorVelocity * dt,
|
||||||
|
dt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function scoreTrack(track, reading) {
|
||||||
|
if(!track || !reading) return Infinity;
|
||||||
|
const dt = (reading.timestamp - track.lastSeen) / 1000;
|
||||||
|
if(dt <= 0 || dt > MAX_PREDICTION_DT) return Infinity;
|
||||||
|
if(track.name !== reading.satellite) return Infinity;
|
||||||
|
|
||||||
|
const predicted = predictTrack(track, reading.timestamp);
|
||||||
|
const azError = circularDistance(predicted.az, reading.az);
|
||||||
|
const elError = Math.abs(predicted.el - reading.el);
|
||||||
|
if(azError > MAX_AZ_ERROR || elError > MAX_EL_ERROR) return Infinity;
|
||||||
|
|
||||||
|
let groundDistance = 0;
|
||||||
|
if(Number.isFinite(reading.latitude) && Number.isFinite(reading.longitude) && Number.isFinite(predicted.latitude) && Number.isFinite(predicted.longitude)) {
|
||||||
|
groundDistance = haversineDistance(predicted.latitude, predicted.longitude, reading.latitude, reading.longitude);
|
||||||
|
if(groundDistance > MAX_GROUND_DISTANCE_KM) return Infinity;
|
||||||
|
}
|
||||||
|
|
||||||
|
let freqErrorDifference = 0;
|
||||||
|
if(Number.isFinite(reading.freqError) && Number.isFinite(predicted.freqError)) {
|
||||||
|
freqErrorDifference = Math.abs(reading.freqError - predicted.freqError);
|
||||||
|
if(freqErrorDifference > MAX_FREQ_ERROR_KHZ) return Infinity;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (azError * 4 + elError * 5 + groundDistance * 0.03 + freqErrorDifference * 0.15); // Lower = better
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateTrack(track, reading) {
|
||||||
|
const previous = track.history.at(-1);
|
||||||
|
|
||||||
|
if (!previous) {
|
||||||
|
track.history.push(reading);
|
||||||
|
track.lastSeen = reading.timestamp;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dt = (reading.timestamp - previous.timestamp) / 1000;
|
||||||
|
if(dt <= 0) return;
|
||||||
|
|
||||||
|
const state = track.state;
|
||||||
|
const azVelocity = circularDelta(previous.az, reading.az) / dt;
|
||||||
|
const elVelocity = (reading.el - previous.el) / dt;
|
||||||
|
const latitudeVelocity = (reading.latitude - previous.latitude) / dt;
|
||||||
|
const longitudeVelocity = (reading.longitude - previous.longitude) / dt;
|
||||||
|
const distanceKm = haversineDistance(previous.latitude, previous.longitude, reading.latitude, reading.longitude);
|
||||||
|
const groundSpeedKmh = distanceKm / dt * 3600;
|
||||||
|
const heading = distanceKm > 0.01 ? calculateBearing(previous.latitude, previous.longitude, reading.latitude, reading.longitude) : state.heading;
|
||||||
|
const freqErrorVelocity = Number.isFinite(reading.freqError) && Number.isFinite(previous.freqError) ? (reading.freqError - previous.freqError) / dt : state.freqErrorVelocity;
|
||||||
|
const SMOOTHING = 0.35;
|
||||||
|
|
||||||
|
state.azVelocity = state.azVelocity * (1 - SMOOTHING) + azVelocity * SMOOTHING;
|
||||||
|
state.elVelocity = state.elVelocity * (1 - SMOOTHING) + elVelocity * SMOOTHING;
|
||||||
|
state.latitudeVelocity = state.latitudeVelocity * (1 - SMOOTHING) + latitudeVelocity * SMOOTHING;
|
||||||
|
state.longitudeVelocity = state.longitudeVelocity * (1 - SMOOTHING) + longitudeVelocity * SMOOTHING;
|
||||||
|
state.freqErrorVelocity = state.freqErrorVelocity * (1 - SMOOTHING) + freqErrorVelocity * SMOOTHING;
|
||||||
|
state.az = reading.az;
|
||||||
|
state.el = reading.el;
|
||||||
|
state.latitude = reading.latitude;
|
||||||
|
state.longitude = reading.longitude;
|
||||||
|
state.freqError = reading.freqError;
|
||||||
|
state.groundSpeedKmh = groundSpeedKmh;
|
||||||
|
state.heading = heading;
|
||||||
|
|
||||||
|
const prediction = predictTrack(track, reading.timestamp);
|
||||||
|
const predictionError = circularDistance(prediction.az, reading.az) + Math.abs(prediction.el - reading.el);
|
||||||
|
|
||||||
|
if(predictionError < 5) {
|
||||||
|
state.confidence = Math.min(1, state.confidence + 0.08);
|
||||||
|
} else if(predictionError < 15) {
|
||||||
|
state.confidence = Math.min(1, state.confidence + 0.03);
|
||||||
|
} else {
|
||||||
|
state.confidence = Math.max(0, state.confidence - 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
track.lastSeen = reading.timestamp;
|
||||||
|
if(!isEqual(track.history.at(-1), reading)) {
|
||||||
|
track.history.push(reading);
|
||||||
|
if(track.history.length > HISTORY_LIMIT) track.history.shift();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function pruneStale() {
|
function pruneStale() {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
for (const [name, bucket] of satellites) {
|
for(const [id, track] of satellites) {
|
||||||
if (now - bucket.lastSeen > TTL_MS) satellites.delete(name);
|
if(now - track.lastSeen > TTL_MS) satellites.delete(id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function findBestTrack(reading) {
|
||||||
|
let bestTrack = null;
|
||||||
|
let bestScore = Infinity;
|
||||||
|
for(const track of satellites.values()) {
|
||||||
|
const score = scoreTrack(track, reading);
|
||||||
|
if(score < bestScore) {
|
||||||
|
bestScore = score;
|
||||||
|
bestTrack = track;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {track: bestTrack, score: bestScore,};
|
||||||
|
}
|
||||||
|
|
||||||
export async function pollTinyGS() {
|
export async function pollTinyGS() {
|
||||||
const {TINYGS_URL, TINYGS_AUTH} = cfg();
|
const {TINYGS_URL, TINYGS_AUTH} = cfg();
|
||||||
try {
|
try {
|
||||||
const raw = await fetch(TINYGS_URL + '/wm', {
|
const raw = await fetch(TINYGS_URL + '/wm', {
|
||||||
headers: TINYGS_AUTH ? {Authorization: `Basic ${TINYGS_AUTH}`} : {}
|
headers: TINYGS_AUTH ? {Authorization: `Basic ${TINYGS_AUTH}`} : {},
|
||||||
}).then(r => r.text());
|
}).then(r => r.text());
|
||||||
const reading = parseWm(raw);
|
const reading = parseWm(raw);
|
||||||
if(!reading.satellite || reading.satellite === '-') return;
|
if(!reading.satellite || reading.satellite === '-') return;
|
||||||
|
|
||||||
const key = reading.satellite + '-' + reading.freqMHz;
|
|
||||||
let bucket = satellites.get(key);
|
|
||||||
if (!bucket) {
|
|
||||||
bucket = { history: [], lastSeen: 0 };
|
|
||||||
satellites.set(key, bucket);
|
|
||||||
}
|
|
||||||
|
|
||||||
bucket.lastSeen = reading.timestamp;
|
|
||||||
if (!isEqual(bucket.history.at(-1), reading)) {
|
|
||||||
bucket.history.push(reading);
|
|
||||||
if (bucket.history.length > HISTORY_LIMIT) bucket.history.shift();
|
|
||||||
}
|
|
||||||
|
|
||||||
pruneStale();
|
pruneStale();
|
||||||
|
const {track, score,} = findBestTrack(reading);
|
||||||
|
if(!track || !Number.isFinite(score)) {
|
||||||
|
createTrack(reading);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateTrack(track, reading);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[tinygs] poll failed', err);
|
console.error('[tinygs] poll failed', err);
|
||||||
}
|
}
|
||||||
@@ -63,8 +238,20 @@ setInterval(pollTinyGS, POLL_MS);
|
|||||||
|
|
||||||
export const getTinyGSData = () => {
|
export const getTinyGSData = () => {
|
||||||
pruneStale();
|
pruneStale();
|
||||||
return Array.from(satellites.entries()).map(([name, bucket]) => ({
|
return Array.from(satellites.values()).map(track => {
|
||||||
...bucket.history.at(-1),
|
const latest = track.history.at(-1);
|
||||||
history: bucket.history.slice(0, -1),
|
return {
|
||||||
}));
|
id: track.id,
|
||||||
}
|
satellite: track.name,
|
||||||
|
...latest,
|
||||||
|
altitudeKm: null,
|
||||||
|
velocity: {
|
||||||
|
v: track.state.groundSpeedKmh,
|
||||||
|
az: track.state.azVelocity,
|
||||||
|
el: track.state.elVelocity,
|
||||||
|
},
|
||||||
|
heading: track.state.heading,
|
||||||
|
history: track.history.slice(0, -1),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import {dailyWeather, hourlyWeather} from './openmeteo.mjs';
|
|||||||
import {getTinyGSData} from './sats.mjs';
|
import {getTinyGSData} from './sats.mjs';
|
||||||
import {getSpaceWeather} from './space.mjs';
|
import {getSpaceWeather} from './space.mjs';
|
||||||
import {Aurora} from './aurora.mjs';
|
import {Aurora} from './aurora.mjs';
|
||||||
import {getSondes} from './radiosonde.mjs';
|
import {getSondes} from './sonde.mjs';
|
||||||
|
|
||||||
// ── Uncaught error handlers ───────────────────────────────────────────────────
|
// ── Uncaught error handlers ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ export async function getSondes() {
|
|||||||
const t = sonde.latest_telem ?? {};
|
const t = sonde.latest_telem ?? {};
|
||||||
|
|
||||||
const history = (sonde.path ?? []).map(([latitude, longitude, altitude]) => ({
|
const history = (sonde.path ?? []).map(([latitude, longitude, altitude]) => ({
|
||||||
id,
|
|
||||||
latitude,
|
latitude,
|
||||||
longitude,
|
longitude,
|
||||||
altitude
|
altitude
|
||||||
Reference in New Issue
Block a user