Test radiosonde.mjs
This commit is contained in:
Binary file not shown.
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 21 KiB |
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import {BASE} from '@/services/api.ts';
|
||||
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
|
||||
const BASE_SPEED_UNITS = {
|
||||
knots: { label: 'KTS', convert: (v: number) => Math.round(v) },
|
||||
@@ -18,6 +18,7 @@ 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' }
|
||||
@@ -26,31 +27,56 @@ function loadUnits() {
|
||||
const props = defineProps<{ plane: any; position: { x: number; y: number } }>()
|
||||
const emit = defineEmits<{ (e: 'close'): void; (e: 'bringToFront'): void }>()
|
||||
|
||||
// ── Units ─────────────────────────────────────────────────────────────────────
|
||||
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) }
|
||||
|
||||
const speed = computed(() => BASE_SPEED_UNITS[prefs.value.speed as keyof typeof BASE_SPEED_UNITS])
|
||||
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 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])
|
||||
|
||||
// ── Plane data ────────────────────────────────────────────────────────────────
|
||||
const callsign = computed(() => props.plane.name?.trim() || props.plane.flight?.trim())
|
||||
const altitudeVal = computed(() => props.plane.alt_baro ?? props.plane.alt_geom ?? 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 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 || '')
|
||||
|
||||
// ── Drag ──────────────────────────────────────────────────────────────────────
|
||||
const pos = ref({ ...props.position })
|
||||
const isDragging = ref(false)
|
||||
const dragStart = ref({ mx: 0, my: 0, ex: 0, ey: 0 })
|
||||
@@ -60,8 +86,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) {
|
||||
@@ -97,20 +123,21 @@ 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)
|
||||
})
|
||||
|
||||
// ── Navball ───────────────────────────────────────────────────────────────────
|
||||
const navball = computed(() => {
|
||||
const data = props.plane
|
||||
const heading = data.heading ?? data.track ?? 0
|
||||
@@ -123,14 +150,14 @@ 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(`
|
||||
<text x="${start - Math.abs(p) - 5}" y="${104 + pitch * 2.5 - p * 2.5}" fill="#fff" font-size="8" font-weight="bold" text-anchor="end">${Math.abs(p)}</text>
|
||||
<line x1="${start - Math.abs(p)}" y1="${100 + pitch * 2.5 - p * 2.5}" x2="${end + Math.abs(p)}" y2="${100 + pitch * 2.5 - p * 2.5}" stroke="#fff" stroke-width="2"/>
|
||||
<text x="${end + Math.abs(p) + 5}" y="${104 + pitch * 2.5 - p * 2.5}" fill="#fff" font-size="8" font-weight="bold">${Math.abs(p)}</text>
|
||||
`)
|
||||
<text x="${start - Math.abs(p) - 5}" y="${104 + pitch * 2.5 - p * 2.5}" fill="#fff" font-size="8" font-weight="bold" text-anchor="end">${Math.abs(p)}</text>
|
||||
<line x1="${start - Math.abs(p)}" y1="${100 + pitch * 2.5 - p * 2.5}" x2="${end + Math.abs(p)}" y2="${100 + pitch * 2.5 - p * 2.5}" stroke="#fff" stroke-width="2"/>
|
||||
<text x="${end + Math.abs(p) + 5}" y="${104 + pitch * 2.5 - p * 2.5}" fill="#fff" font-size="8" font-weight="bold">${Math.abs(p)}</text>
|
||||
`)
|
||||
} else {
|
||||
pitchLines.push(`<line x1="${start}" y1="${100 + pitch * 2.5 - p * 2.5}" x2="${end}" y2="${100 + pitch * 2.5 - p * 2.5}" stroke="#fff" stroke-width="2"/>`)
|
||||
}
|
||||
@@ -139,21 +166,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('')
|
||||
@@ -163,11 +190,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>
|
||||
@@ -181,7 +208,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>
|
||||
@@ -191,68 +218,6 @@ const navball = computed(() => {
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="aircraft-popup" :style="{ left: pos.x + 'px', top: pos.y + 'px' }">
|
||||
|
||||
<!-- Header (drag handle) -->
|
||||
<div ref="header" class="ap-header" @mousedown="onMouseDown" @touchstart.passive="onTouchStart">
|
||||
<h3 class="ap-callsign">
|
||||
✈️
|
||||
<a v-if="callsign" :href="`https://www.flightaware.com/live/flight/${callsign}`" target="_blank" @click.stop>{{ callsign.toUpperCase() }}</a>
|
||||
<span v-else>N/A</span>
|
||||
<span style="color: white"> / {{ (plane.icao || plane.hex || '').toUpperCase() }}</span>
|
||||
</h3>
|
||||
<button class="ap-close" @click.stop="emit('close')">×</button>
|
||||
</div>
|
||||
|
||||
<!-- Meta -->
|
||||
<div class="ap-meta flex-r justify-between">
|
||||
<div class="flex-c">
|
||||
<span>{{ operator || 'Unknown Owner' }}</span>
|
||||
<span>{{ plane.country || 'Unknown Country' }}</span>
|
||||
</div>
|
||||
<div class="flex-c align-x-end">
|
||||
<span>{{ description || 'Unknown Aircraft' }}</span>
|
||||
<span style="text-transform: capitalize">{{ plane.class || 'Unknown' }} • {{ plane.type || 'Unknown' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Photo -->
|
||||
<div v-if="!photoError" class="ap-photo-wrap">
|
||||
<img class="ap-photo" :src="BASE + '/api/adsb/' + plane.icao + '/image'" :alt="callsign" @error="photoError = true" />
|
||||
</div>
|
||||
|
||||
<!-- Body -->
|
||||
<div class="ap-body">
|
||||
<div class="ap-gauges">
|
||||
<div class="ap-gauge-wrap">
|
||||
<div class="ap-gauge-label">Air Speed</div>
|
||||
<div class="ap-gauge" @click.stop="cycleSpeed">
|
||||
<span class="ap-gauge-val">{{ speedVal }}</span>
|
||||
<span class="ap-gauge-unit">{{ speed.label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ap-gauge-wrap">
|
||||
<div class="ap-gauge-label">Altitude</div>
|
||||
<div class="ap-gauge" @click.stop="cycleAltitude">
|
||||
<span class="ap-gauge-val">{{ altVal }}</span>
|
||||
<span class="ap-gauge-unit">{{ altUnit }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ap-gauge-wrap">
|
||||
<div class="ap-gauge-label">Climb</div>
|
||||
<div class="ap-gauge" @click.stop="cycleVertical">
|
||||
<span class="ap-gauge-val">{{ climbVal }}</span>
|
||||
<span class="ap-gauge-unit">{{ vertical.label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ap-navball" v-html="navball" />
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.aircraft-popup {
|
||||
position: fixed;
|
||||
@@ -315,7 +280,6 @@ const navball = computed(() => {
|
||||
}
|
||||
.ap-close:hover { background: rgba(255,255,255,0.35); }
|
||||
|
||||
/* ── Photo ── */
|
||||
.ap-photo-wrap {
|
||||
max-width: 360px;
|
||||
max-height: 160px;
|
||||
@@ -326,15 +290,16 @@ const navball = computed(() => {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.ap-photo { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||
.ap-photo--sil { object-fit: contain; box-sizing: border-box; image-rendering: auto; }
|
||||
.ap-photo { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||
.ap-photo--sil { object-fit: contain; box-sizing: border-box; image-rendering: auto; }
|
||||
|
||||
.ap-body {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.ap-gauges { flex: 0 0 110px; }
|
||||
.ap-gauges { flex: 0 0 110px; }
|
||||
.ap-gauge-wrap { margin-bottom: 12px; }
|
||||
|
||||
.ap-gauge-label {
|
||||
@@ -356,8 +321,65 @@ 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; }
|
||||
.ap-navball { flex: 1; margin-top: 1rem; }
|
||||
|
||||
.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; }
|
||||
.ap-navball { flex: 1; margin-top: 1rem; }
|
||||
</style>
|
||||
|
||||
<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">
|
||||
✈️
|
||||
<a v-if="callsign" :href="`https://www.flightaware.com/live/flight/${callsign}`" target="_blank" @click.stop>{{ callsign.toUpperCase() }}</a>
|
||||
<span v-else>N/A</span>
|
||||
<span style="color: white"> / {{ (plane.icao || plane.hex || '').toUpperCase() }}</span>
|
||||
</h3>
|
||||
<button class="ap-close" @click.stop="emit('close')">×</button>
|
||||
</div>
|
||||
|
||||
<div class="ap-meta flex-r justify-between">
|
||||
<div class="flex-c">
|
||||
<span>{{ operator || 'Unknown Owner' }}</span>
|
||||
<span>{{ plane.country || 'Unknown Country' }}</span>
|
||||
</div>
|
||||
<div class="flex-c align-x-end">
|
||||
<span>{{ description || 'Unknown Aircraft' }}</span>
|
||||
<span style="text-transform: capitalize">{{ plane.class || 'Unknown' }} • {{ plane.type || 'Unknown' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!photoError" class="ap-photo-wrap">
|
||||
<img class="ap-photo" :src="BASE + '/api/adsb/' + plane.icao + '/image'" :alt="callsign" @error="photoError = true" />
|
||||
</div>
|
||||
|
||||
<div class="ap-body">
|
||||
<div class="ap-gauges">
|
||||
<div class="ap-gauge-wrap">
|
||||
<div class="ap-gauge-label">Air Speed</div>
|
||||
<div class="ap-gauge" @click.stop="cycleSpeed">
|
||||
<span class="ap-gauge-val">{{ speedVal }}</span>
|
||||
<span class="ap-gauge-unit">{{ speed.label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ap-gauge-wrap">
|
||||
<div class="ap-gauge-label">Altitude</div>
|
||||
<div class="ap-gauge" @click.stop="cycleAltitude">
|
||||
<span class="ap-gauge-val">{{ altVal }}</span>
|
||||
<span class="ap-gauge-unit">{{ altUnit }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ap-gauge-wrap">
|
||||
<div class="ap-gauge-label">Climb</div>
|
||||
<div class="ap-gauge" @click.stop="cycleVertical">
|
||||
<span class="ap-gauge-val">{{ climbVal }}</span>
|
||||
<span class="ap-gauge-unit">{{ vertical.label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ap-navball" v-html="navball" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
151
client/src/components/Altitude.vue
Normal file
151
client/src/components/Altitude.vue
Normal file
@@ -0,0 +1,151 @@
|
||||
<script setup lang="ts">
|
||||
import {computed} from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
history: any[]
|
||||
currentAltitude: number | null
|
||||
unit: string
|
||||
}>()
|
||||
|
||||
const WIDTH = 320
|
||||
const HEIGHT = 120
|
||||
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))
|
||||
|
||||
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)),
|
||||
altitude: props.currentAltitude,
|
||||
live: true,
|
||||
})
|
||||
}
|
||||
|
||||
return history
|
||||
})
|
||||
|
||||
const graph = computed(() => {
|
||||
const pts = points.value
|
||||
if (pts.length < 2) return null
|
||||
|
||||
const minTs = Number(pts[0].ts)
|
||||
const maxTs = Number(pts[pts.length - 1].ts)
|
||||
const rawAltitudes = pts.map(p => Number(p.altitude))
|
||||
const minAlt = Math.min(...rawAltitudes)
|
||||
const maxAlt = Math.max(...rawAltitudes)
|
||||
const altRange = Math.max(maxAlt - minAlt, 1)
|
||||
const timeRange = Math.max(maxTs - minTs, 1)
|
||||
|
||||
const x = (ts: number) => PAD.left + ((ts - minTs) / timeRange) * (WIDTH - PAD.left - PAD.right)
|
||||
const y = (alt: number) => PAD.top + (1 - (alt - minAlt) / altRange) * (HEIGHT - PAD.top - PAD.bottom)
|
||||
|
||||
const line = pts.map(p => `${x(Number(p.ts)).toFixed(1)},${y(Number(p.altitude)).toFixed(1)}`).join(' ')
|
||||
const latest = pts[pts.length - 1]
|
||||
const latestX = x(Number(latest.ts))
|
||||
const latestY = y(Number(latest.altitude))
|
||||
|
||||
const formatTime = (ts: number) => {
|
||||
const d = new Date(ts * 1000)
|
||||
return d.toLocaleTimeString([], {hour: '2-digit', minute: '2-digit'})
|
||||
}
|
||||
|
||||
return {
|
||||
line,
|
||||
latestX,
|
||||
latestY,
|
||||
minAlt,
|
||||
maxAlt,
|
||||
startTime: formatTime(minTs),
|
||||
endTime: formatTime(maxTs),
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.altitude-graph {
|
||||
padding: 0 16px 12px;
|
||||
}
|
||||
|
||||
.altitude-graph-title {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
color: #888;
|
||||
font-size: 11px;
|
||||
font-weight: bold;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.altitude-graph svg {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 120px;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.altitude-graph text {
|
||||
fill: #777;
|
||||
font-family: monospace;
|
||||
font-size: 9px;
|
||||
}
|
||||
</style>
|
||||
|
||||
<template>
|
||||
<div v-if="graph" class="altitude-graph">
|
||||
<div class="altitude-graph-title">
|
||||
<span>ALTITUDE</span>
|
||||
<span>{{ unit }}</span>
|
||||
</div>
|
||||
|
||||
<svg :viewBox="`0 0 ${WIDTH} ${HEIGHT}`" preserveAspectRatio="none">
|
||||
<line
|
||||
:x1="PAD.left"
|
||||
:y1="PAD.top"
|
||||
:x2="PAD.left"
|
||||
:y2="HEIGHT - PAD.bottom"
|
||||
stroke="rgba(255,255,255,0.2)"
|
||||
/>
|
||||
|
||||
<line
|
||||
:x1="PAD.left"
|
||||
:y1="HEIGHT - PAD.bottom"
|
||||
:x2="WIDTH - PAD.right"
|
||||
:y2="HEIGHT - PAD.bottom"
|
||||
stroke="rgba(255,255,255,0.2)"
|
||||
/>
|
||||
|
||||
<text :x="PAD.left - 5" :y="PAD.top + 4" text-anchor="end">
|
||||
{{ graph.maxAlt }}
|
||||
</text>
|
||||
|
||||
<text :x="PAD.left - 5" :y="HEIGHT - PAD.bottom + 4" text-anchor="end">
|
||||
{{ graph.minAlt }}
|
||||
</text>
|
||||
|
||||
<polyline
|
||||
:points="graph.line"
|
||||
fill="none"
|
||||
stroke="#0f0"
|
||||
stroke-width="2"
|
||||
/>
|
||||
|
||||
<circle
|
||||
:cx="graph.latestX"
|
||||
:cy="graph.latestY"
|
||||
r="3"
|
||||
fill="#0f0"
|
||||
/>
|
||||
|
||||
<text :x="PAD.left" :y="HEIGHT - 6">
|
||||
{{ graph.startTime }}
|
||||
</text>
|
||||
|
||||
<text :x="WIDTH - PAD.right" :y="HEIGHT - 6" text-anchor="end">
|
||||
{{ graph.endTime }}
|
||||
</text>
|
||||
</svg>
|
||||
</div>
|
||||
</template>
|
||||
@@ -9,9 +9,38 @@ const props = defineProps<{
|
||||
}>()
|
||||
const emit = defineEmits<{ (e: 'close'): void; (e: 'bringToFront'): void }>()
|
||||
|
||||
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 cycleAltitude() {
|
||||
const p = loadUnits()
|
||||
p.altitude = p.altitude === 'meters' ? 'feet' : 'meters'
|
||||
p.vertical = p.altitude === 'meters' ? 'mps' : 'fps'
|
||||
saveAndSet(p)
|
||||
}
|
||||
|
||||
function syncUnits(e: StorageEvent) {
|
||||
if (e.key === UNIT_KEY) prefs.value = loadUnits()
|
||||
}
|
||||
|
||||
const distance = computed(() => prefs.value.altitude === 'feet'
|
||||
? { label: 'mi', convert: (v: number) => v * 0.621371 }
|
||||
: { label: 'km', convert: (v: number) => v })
|
||||
|
||||
const latest = computed(() => props.history[props.history.length - 1])
|
||||
const rangeVal = computed(() => props.range ? Math.round(props.range.slantRange) : null)
|
||||
const altVal = computed(() => props.range ? Math.round(props.range.altitude) : null)
|
||||
const rangeVal = computed(() => props.range ? Math.round(distance.value.convert(props.range.slantRange)) : null)
|
||||
const altVal = computed(() => props.range ? Math.round(distance.value.convert(props.range.altitude)) : null)
|
||||
|
||||
// ── Drag ──────────────────────────────────────────────────────────────────────
|
||||
const pos = ref({ ...props.position })
|
||||
@@ -64,6 +93,7 @@ onMounted(() => {
|
||||
document.addEventListener('touchmove', onTouchMove, { passive: false })
|
||||
document.addEventListener('touchend', onTouchEnd)
|
||||
document.addEventListener('touchcancel', onTouchEnd)
|
||||
window.addEventListener('storage', syncUnits)
|
||||
})
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('mousemove', onMouseMove)
|
||||
@@ -71,6 +101,7 @@ onUnmounted(() => {
|
||||
document.removeEventListener('touchmove', onTouchMove)
|
||||
document.removeEventListener('touchend', onTouchEnd)
|
||||
document.removeEventListener('touchcancel', onTouchEnd)
|
||||
window.removeEventListener('storage', syncUnits)
|
||||
})
|
||||
|
||||
// ── Radar HUD ─────────────────────────────────────────────────────────────────
|
||||
@@ -156,6 +187,8 @@ const currentPoint = computed(() => latest.value ? polarPoint(latest.value.az, l
|
||||
}
|
||||
.tp-gauge-val { font-size: 20px; font-weight: bold; color: #d4a4ff; }
|
||||
.tp-gauge-unit { font-size: 11px; color: #d4a4ff; margin-bottom: 0.75em; }
|
||||
.tp-gauge-click { cursor: pointer; }
|
||||
.tp-gauge-click:hover { border-color: #fff; }
|
||||
|
||||
.tp-radar { flex: 1; }
|
||||
.ring, .axis { fill: none; stroke: rgba(212,164,255,0.3); stroke-width: 1; }
|
||||
@@ -181,7 +214,7 @@ const currentPoint = computed(() => latest.value ? polarPoint(latest.value.az, l
|
||||
<div class="tp-meta">
|
||||
<div class="flex-c">
|
||||
<span>{{ latest?.freqMHz }} MHz</span>
|
||||
<span>Range: {{ rangeVal != null ? rangeVal + ' km' : 'Range —' }}</span>
|
||||
<span>Range: {{ rangeVal != null ? rangeVal + ' ' + distance.label : 'Range —' }}</span>
|
||||
</div>
|
||||
<div class="flex-c align-x-end">
|
||||
<span>RSSI: {{ latest?.packetRssi }} dBm</span>
|
||||
@@ -193,8 +226,8 @@ const currentPoint = computed(() => latest.value ? polarPoint(latest.value.az, l
|
||||
<div class="tp-gauges">
|
||||
<div class="tp-gauge-wrap">
|
||||
<div class="tp-gauge-label">Altitude (±10%)</div>
|
||||
<div class="tp-gauge">
|
||||
<span class="tp-gauge-val">{{ altVal != null ? altVal + ' km' : '—' }}</span>
|
||||
<div class="tp-gauge tp-gauge-click" @click.stop="cycleAltitude">
|
||||
<span class="tp-gauge-val">{{ altVal != null ? altVal + ' ' + distance.label : '—' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tp-gauge-wrap">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import {BASE} from '@/services/api.ts';
|
||||
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
|
||||
const props = defineProps<{ boat: any; position: { x: number; y: number } }>()
|
||||
const emit = defineEmits<{ (e: 'close'): void; (e: 'bringToFront'): void }>()
|
||||
@@ -10,12 +10,15 @@ const SPEED_UNITS = {
|
||||
kph: { label: 'KPH', convert: (v: number) => (v * 1.852).toFixed(1) },
|
||||
mph: { label: 'MPH', convert: (v: number) => (v * 1.15078).toFixed(1) },
|
||||
}
|
||||
const UNIT_KEY = 'ais_units'
|
||||
|
||||
const UNIT_KEY = 'at_units'
|
||||
function loadUnits() {
|
||||
const s = localStorage.getItem(UNIT_KEY)
|
||||
return s ? JSON.parse(s) : { speed: 'knots' }
|
||||
return s ? JSON.parse(s) : { speed: 'knots', altitude: 'meters', vertical: 'mps' }
|
||||
}
|
||||
|
||||
const prefs = ref(loadUnits())
|
||||
|
||||
function cycleSpeed() {
|
||||
const keys = Object.keys(SPEED_UNITS)
|
||||
const p = loadUnits()
|
||||
@@ -23,6 +26,11 @@ function cycleSpeed() {
|
||||
prefs.value = { ...p }
|
||||
localStorage.setItem(UNIT_KEY, JSON.stringify(p))
|
||||
}
|
||||
|
||||
function syncUnits(e: StorageEvent) {
|
||||
if (e.key === UNIT_KEY) prefs.value = loadUnits()
|
||||
}
|
||||
|
||||
const speed = computed(() => SPEED_UNITS[prefs.value.speed as keyof typeof SPEED_UNITS])
|
||||
|
||||
const name = computed(() => props.boat.shipname?.trim() || props.boat.callsign?.trim() || '-')
|
||||
@@ -82,6 +90,7 @@ onMounted(() => {
|
||||
document.addEventListener('touchmove', onTouchMove, { passive: false })
|
||||
document.addEventListener('touchend', onTouchEnd)
|
||||
document.addEventListener('touchcancel', onTouchEnd)
|
||||
window.addEventListener('storage', syncUnits)
|
||||
})
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('mousemove', onMouseMove)
|
||||
@@ -89,6 +98,7 @@ onUnmounted(() => {
|
||||
document.removeEventListener('touchmove', onTouchMove)
|
||||
document.removeEventListener('touchend', onTouchEnd)
|
||||
document.removeEventListener('touchcancel', onTouchEnd)
|
||||
window.removeEventListener('storage', syncUnits)
|
||||
})
|
||||
|
||||
const compass = computed(() => {
|
||||
@@ -188,64 +198,6 @@ const compass = computed(() => {
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="ship-popup" :style="{ left: pos.x + 'px', top: pos.y + 'px' }">
|
||||
|
||||
<div ref="header" class="sp-header" @mousedown="onMouseDown" @touchstart.passive="onTouchStart">
|
||||
<h3 class="sp-name">
|
||||
🚢
|
||||
<span>{{ name.toUpperCase() }}</span>
|
||||
<span class="sp-mmsi"> / {{ boat.mmsi }}</span>
|
||||
</h3>
|
||||
<button class="sp-close" @click.stop="emit('close')">×</button>
|
||||
</div>
|
||||
|
||||
<div class="sp-meta">
|
||||
<div class="flex-c">
|
||||
<span>{{ boat.operator || boat.owner || 'Unknown Owner' }}</span>
|
||||
<span>{{ boat.country || 'Unknown Country' }}</span>
|
||||
</div>
|
||||
<div class="flex-c align-x-end">
|
||||
<span style="text-transform:capitalize">{{ boat.type || 'Unknown' }}</span>
|
||||
<span>{{ boat.ship_type || 'Unknown Class' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Photo -->
|
||||
<div v-if="!photoError" class="sp-photo-wrap">
|
||||
<img class="sp-photo" :src="BASE + '/api/ais/' + boat.mmsi + '/image'" :alt="name" @error="photoError = true" />
|
||||
</div>
|
||||
|
||||
<div class="sp-body">
|
||||
<div class="sp-gauges">
|
||||
<div class="sp-gauge-wrap">
|
||||
<div class="sp-gauge-label">Speed</div>
|
||||
<div class="sp-gauge" @click.stop="cycleSpeed">
|
||||
<span class="sp-gauge-val">{{ speedVal }}</span>
|
||||
<span class="sp-gauge-unit">{{ speed.label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sp-gauge-wrap">
|
||||
<div class="sp-gauge-label">Course</div>
|
||||
<div class="sp-gauge">
|
||||
<span class="sp-gauge-val">{{ course != null ? course.toFixed(0) : '—' }}</span>
|
||||
<span v-if="course != null" class="sp-gauge-unit">°</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sp-gauge-wrap">
|
||||
<div class="sp-gauge-label">Heading</div>
|
||||
<div class="sp-gauge">
|
||||
<span class="sp-gauge-val">{{ heading != null ? heading.toFixed(0) : '—' }}</span>
|
||||
<span v-if="heading != null" class="sp-gauge-unit">°</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sp-compass" v-html="compass"/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.ship-popup {
|
||||
position: fixed;
|
||||
@@ -315,6 +267,7 @@ const compass = computed(() => {
|
||||
border-bottom: 1px solid rgba(200,200,220,0.15);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sp-photo {
|
||||
width: 100%;
|
||||
height: 140px;
|
||||
@@ -355,3 +308,61 @@ const compass = computed(() => {
|
||||
.sp-gauge-unit { font-size: 11px; color: #0ff; margin-bottom: 5px; }
|
||||
.sp-compass { flex: 1; margin-top: 0.25rem; }
|
||||
</style>
|
||||
|
||||
<template>
|
||||
<div class="ship-popup" :style="{ left: pos.x + 'px', top: pos.y + 'px' }">
|
||||
|
||||
<div ref="header" class="sp-header" @mousedown="onMouseDown" @touchstart.passive="onTouchStart">
|
||||
<h3 class="sp-name">
|
||||
🚢
|
||||
<span>{{ name.toUpperCase() }}</span>
|
||||
<span class="sp-mmsi"> / {{ boat.mmsi }}</span>
|
||||
</h3>
|
||||
<button class="sp-close" @click.stop="emit('close')">×</button>
|
||||
</div>
|
||||
|
||||
<div class="sp-meta">
|
||||
<div class="flex-c">
|
||||
<span>{{ boat.operator || boat.owner || 'Unknown Owner' }}</span>
|
||||
<span>{{ boat.country || 'Unknown Country' }}</span>
|
||||
</div>
|
||||
<div class="flex-c align-x-end">
|
||||
<span style="text-transform:capitalize">{{ boat.type || 'Unknown' }}</span>
|
||||
<span>{{ boat.ship_type || 'Unknown Class' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Photo -->
|
||||
<div v-if="!photoError" class="sp-photo-wrap">
|
||||
<img class="sp-photo" :src="BASE + '/api/ais/' + boat.mmsi + '/image'" :alt="name" @error="photoError = true" />
|
||||
</div>
|
||||
|
||||
<div class="sp-body">
|
||||
<div class="sp-gauges">
|
||||
<div class="sp-gauge-wrap">
|
||||
<div class="sp-gauge-label">Speed</div>
|
||||
<div class="sp-gauge" @click.stop="cycleSpeed">
|
||||
<span class="sp-gauge-val">{{ speedVal }}</span>
|
||||
<span class="sp-gauge-unit">{{ speed.label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sp-gauge-wrap">
|
||||
<div class="sp-gauge-label">Course</div>
|
||||
<div class="sp-gauge">
|
||||
<span class="sp-gauge-val">{{ course != null ? course.toFixed(0) : '—' }}</span>
|
||||
<span v-if="course != null" class="sp-gauge-unit">°</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sp-gauge-wrap">
|
||||
<div class="sp-gauge-label">Heading</div>
|
||||
<div class="sp-gauge">
|
||||
<span class="sp-gauge-val">{{ heading != null ? heading.toFixed(0) : '—' }}</span>
|
||||
<span v-if="heading != null" class="sp-gauge-unit">°</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sp-compass" v-html="compass"/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -218,14 +218,22 @@ export class AirTrafficLayer {
|
||||
|
||||
const history = this.historyCache[icao];
|
||||
const cur = {
|
||||
ts: Math.floor(Date.now() / 1000),
|
||||
latitude: plane.latitude,
|
||||
longitude: plane.longitude,
|
||||
altitude: plane.alt_baro ?? plane.alt_geom ?? 0,
|
||||
live: true,
|
||||
};
|
||||
|
||||
const last = history[history.length - 1];
|
||||
if (!last || last.latitude !== cur.latitude || last.longitude !== cur.longitude) history.push(cur);
|
||||
if (history.length < 2) return;
|
||||
|
||||
const traceHistory = history;
|
||||
if (traceHistory.length < 2) {
|
||||
const popup = this.popups[icao];
|
||||
if (popup) popup.update(plane, history);
|
||||
return;
|
||||
}
|
||||
|
||||
let traceLayer = this.traceLayers[icao];
|
||||
let vs: VectorSource;
|
||||
@@ -242,8 +250,8 @@ export class AirTrafficLayer {
|
||||
|
||||
const segs: any = this.traceSegs[icao];
|
||||
|
||||
for (let i = segs.length; i < history.length - 1; i++) {
|
||||
const s = history[i], e = history[i + 1];
|
||||
for (let i = segs.length; i < traceHistory.length - 1; i++) {
|
||||
const s = traceHistory[i], e = traceHistory[i + 1];
|
||||
const sc = getAltColor(s.altitude || 0), ec: any = getAltColor(e.altitude || 0);
|
||||
const avg = sc.map((v, idx) => Math.round((v + ec[idx]) / 2));
|
||||
|
||||
@@ -262,6 +270,9 @@ export class AirTrafficLayer {
|
||||
vs.addFeature(f);
|
||||
segs.push(f);
|
||||
}
|
||||
|
||||
const popup = this.popups[icao];
|
||||
if (popup) popup.update(plane, history);
|
||||
}
|
||||
|
||||
private _openPopup(plane: any) {
|
||||
@@ -272,8 +283,9 @@ export class AirTrafficLayer {
|
||||
document.body.appendChild(container);
|
||||
|
||||
const mobile = window.innerWidth <= 768;
|
||||
const makeProps = (p: any) => ({
|
||||
const makeProps = (p: any, history: any[] = []) => ({
|
||||
plane: p,
|
||||
history,
|
||||
position: mobile
|
||||
? {x: window.innerWidth / 2 - 180, y: window.innerHeight - 540 - 16}
|
||||
: {x: 16, y: 16},
|
||||
@@ -281,10 +293,10 @@ export class AirTrafficLayer {
|
||||
onBringToFront: () => bringToFront(container),
|
||||
});
|
||||
|
||||
vueRender(h(AircraftPopup, makeProps(plane)), container);
|
||||
vueRender(h(AircraftPopup, makeProps(plane, this.historyCache[icao] || [])), container);
|
||||
|
||||
this.popups[icao] = {
|
||||
update: (p: any) => vueRender(h(AircraftPopup, makeProps(p)), container),
|
||||
update: (p: any, history: any[] = []) => vueRender(h(AircraftPopup, makeProps(p, history)), container),
|
||||
unmount: () => {
|
||||
vueRender(null, container);
|
||||
container.remove();
|
||||
@@ -322,7 +334,6 @@ export class AirTrafficLayer {
|
||||
continue;
|
||||
}
|
||||
|
||||
this.popups[icao].update(plane);
|
||||
this._fetchTrace(plane);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -176,7 +176,7 @@ export class SondesLayer {
|
||||
marker.setStyle(new Style({
|
||||
image: new Icon({
|
||||
src: '/sonde.png',
|
||||
scale: 0.15,
|
||||
scale: 0.1,
|
||||
anchor: [0.5, 0.5],
|
||||
}),
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user