Cleanup
This commit is contained in:
@@ -1,10 +1,8 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import {ref, onMounted} from 'vue';
|
import {useWeather} from '../services/api';
|
||||||
import {api} from '../services/api';
|
|
||||||
import MetricRow from './MetricRow.vue';
|
import MetricRow from './MetricRow.vue';
|
||||||
|
|
||||||
const d = ref<any>(null);
|
const {current: d} = useWeather();
|
||||||
onMounted(async () => d.value = await api.current());
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|||||||
344
client/src/components/Aircraft.vue
Normal file
344
client/src/components/Aircraft.vue
Normal file
@@ -0,0 +1,344 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||||
|
|
||||||
|
const BASE_SPEED_UNITS = {
|
||||||
|
knots: { label: 'KTS', convert: (v: number) => Math.round(v) },
|
||||||
|
kph: { label: 'KPH', convert: (v: number) => Math.round(v * 1.852) },
|
||||||
|
mph: { label: 'MPH', convert: (v: number) => Math.round(v * 1.15078) },
|
||||||
|
}
|
||||||
|
const BASE_ALTITUDE_UNITS = {
|
||||||
|
meters: { label: 'M', convert: (v: number) => Math.round(v * 0.3048) },
|
||||||
|
feet: { label: 'FT', convert: (v: number) => v },
|
||||||
|
}
|
||||||
|
const BASE_VERTICAL_UNITS = {
|
||||||
|
mps: { label: 'M/S', convert: (v: number) => Math.round(v * 0.3048) },
|
||||||
|
fps: { label: 'FT/S', convert: (v: number) => v },
|
||||||
|
}
|
||||||
|
|
||||||
|
const UNIT_KEY = 'at_units'
|
||||||
|
function loadUnits() {
|
||||||
|
const s = localStorage.getItem(UNIT_KEY)
|
||||||
|
return s ? JSON.parse(s) : { speed: 'knots', altitude: 'meters', vertical: 'mps' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<{ plane: any; position: { x: number; y: number } }>()
|
||||||
|
const 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])
|
||||||
|
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 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 })
|
||||||
|
const longPressTimer = ref<number | null>(null)
|
||||||
|
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 }
|
||||||
|
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('ap-close')) return
|
||||||
|
const touch = e.touches[0]
|
||||||
|
const sx = touch.clientX, 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 = 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)
|
||||||
|
})
|
||||||
|
onUnmounted(() => {
|
||||||
|
document.removeEventListener('mousemove', onMouseMove)
|
||||||
|
document.removeEventListener('mouseup', onMouseUp)
|
||||||
|
document.removeEventListener('touchmove', onTouchMove)
|
||||||
|
document.removeEventListener('touchend', onTouchEnd)
|
||||||
|
document.removeEventListener('touchcancel', onTouchEnd)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Navball ───────────────────────────────────────────────────────────────────
|
||||||
|
const navball = computed(() => {
|
||||||
|
const data = props.plane
|
||||||
|
const heading = data.heading ?? data.track ?? 0
|
||||||
|
const airspeedKnots = data.speed || data.gs || 0
|
||||||
|
const vertRateFps = data.climb || data.baro_rate || 0
|
||||||
|
const groundSpeedFps = airspeedKnots * 1.68781
|
||||||
|
const pitchRad = groundSpeedFps > 0 ? Math.atan(vertRateFps / groundSpeedFps) : 0
|
||||||
|
const pitch = Math.max(-25, Math.min(25, pitchRad * (180 / Math.PI)))
|
||||||
|
|
||||||
|
const pitchLines: string[] = []
|
||||||
|
for (let p = -30; p <= 30; p += 5) {
|
||||||
|
if (p === 0) continue
|
||||||
|
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>
|
||||||
|
`)
|
||||||
|
} 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"/>`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
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
|
||||||
|
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 + 90 * Math.sin(rad)
|
||||||
|
const y = 100 - 90 * 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('')
|
||||||
|
|
||||||
|
return `
|
||||||
|
<svg width="200" height="220" viewBox="0 0 200 220">
|
||||||
|
<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="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="100%" style="stop-color:#654321"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect x="77" y="5" width="46" height="18" fill="rgba(0,0,0,0.95)" stroke="#0f0" stroke-width="2" rx="2"/>
|
||||||
|
<text x="100" y="18" text-anchor="middle" fill="#0f0" font-size="14" font-weight="bold">${heading.toFixed(0).padStart(3, '0')}°</text>
|
||||||
|
<g transform="translate(0,25)" clip-path="url(#navballClip)">
|
||||||
|
<rect x="15" y="${15 + pitch * 2.5 - 200}" width="170" height="285" fill="url(#skyGrad)"/>
|
||||||
|
<rect x="15" y="${100 + pitch * 2.5}" width="170" height="285" fill="url(#groundGrad)"/>
|
||||||
|
<line x1="15" y1="${100 + pitch * 2.5}" x2="185" y2="${100 + pitch * 2.5}" stroke="#fff" stroke-width="3"/>
|
||||||
|
${pitchLines.join('')}
|
||||||
|
</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="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>
|
||||||
|
<polygon points="100,37 95,30 105,30" fill="#ff0"/>
|
||||||
|
</svg>
|
||||||
|
`
|
||||||
|
})
|
||||||
|
</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>
|
||||||
|
|
||||||
|
<!-- Body -->
|
||||||
|
<div class="ap-meta flex-r justify-between">
|
||||||
|
<div class="flex-c">
|
||||||
|
<span>{{ operator }}</span>
|
||||||
|
<span>{{ plane.country || 'Unknown Country' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex-c align-x-end">
|
||||||
|
<span>{{ description }}</span>
|
||||||
|
<span style="text-transform: capitalize">{{ plane.class || 'Unknown' }} • {{ plane.type || 'Unknown' }}</span>
|
||||||
|
</div>
|
||||||
|
</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>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.aircraft-popup {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 9999;
|
||||||
|
pointer-events: auto;
|
||||||
|
background: rgba(0,0,0,0.88);
|
||||||
|
border: 1px solid rgba(200,200,220,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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ap-header {
|
||||||
|
position: relative;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-bottom: 1px solid rgba(200,200,220,0.2);
|
||||||
|
cursor: move;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ap-callsign {
|
||||||
|
margin: 0 24px 0 0;
|
||||||
|
color: #7eeee1;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
.ap-callsign a { color: #6bb6ff; text-decoration: none; }
|
||||||
|
|
||||||
|
.ap-meta {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #888;
|
||||||
|
margin-top: 4px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-bottom: 1px solid rgba(200,200,220,0.2);
|
||||||
|
}
|
||||||
|
.ap-meta--detail { margin-top: 3px; }
|
||||||
|
|
||||||
|
.ap-icao { text-align: right; }
|
||||||
|
.ap-operator { text-align: right; color: #aaa; }
|
||||||
|
|
||||||
|
.ap-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;
|
||||||
|
}
|
||||||
|
.ap-close:hover { background: rgba(255,255,255,0.35); }
|
||||||
|
|
||||||
|
.ap-body {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ap-gauges { flex: 0 0 110px; }
|
||||||
|
.ap-gauge-wrap { margin-bottom: 12px; }
|
||||||
|
|
||||||
|
.ap-gauge-label {
|
||||||
|
color: #888;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: bold;
|
||||||
|
margin-bottom: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ap-gauge {
|
||||||
|
padding: 6px;
|
||||||
|
background: rgba(0,0,0,0.95);
|
||||||
|
border: 2px solid #0f0;
|
||||||
|
border-radius: 3px;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: center;
|
||||||
|
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; }
|
||||||
|
</style>
|
||||||
@@ -1,23 +1,11 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import Loading from '@/components/Loading.vue';
|
||||||
import {formatDate} from '@ztimson/utils';
|
import {formatDate} from '@ztimson/utils';
|
||||||
import {ref, onMounted, computed, watchEffect} from 'vue';
|
import {ref, watchEffect} from 'vue';
|
||||||
import {api} from '../services/api';
|
import {useWeather} from '../services/api';
|
||||||
import MetricRow from './MetricRow.vue';
|
import MetricRow from './MetricRow.vue';
|
||||||
|
|
||||||
const d = ref<any>(null);
|
const {current: d} = useWeather();
|
||||||
onMounted(async () => d.value = await api.current());
|
|
||||||
|
|
||||||
const moonIcon = computed(() => {
|
|
||||||
if(!d.value) return '🌑';
|
|
||||||
const phase = d.value.moon_phase as string;
|
|
||||||
const map: Record<string, string> = {
|
|
||||||
'New Moon': '🌑', 'Waxing Crescent': '🌒', 'First Quarter': '🌓',
|
|
||||||
'Waxing Gibbous': '🌔', 'Full Moon': '🌕', 'Waning Gibbous': '🌖',
|
|
||||||
'Last Quarter': '🌗', 'Waning Crescent': '🌘',
|
|
||||||
};
|
|
||||||
return map[phase] ?? '🌕';
|
|
||||||
});
|
|
||||||
|
|
||||||
const canvasRef = ref<HTMLCanvasElement | null>(null);
|
const canvasRef = ref<HTMLCanvasElement | null>(null);
|
||||||
|
|
||||||
watchEffect(() => {
|
watchEffect(() => {
|
||||||
@@ -80,20 +68,31 @@ watchEffect(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="card" v-if="d">
|
<div class="card">
|
||||||
<div class="card-title">🌙 Moon</div>
|
<div class="card-title">🌙 Moon</div>
|
||||||
|
|
||||||
<div class="flex-r align-items-center gap-3 p-2">
|
<div class="flex-r align-items-center gap-3 p-2">
|
||||||
<canvas ref="canvasRef" style="width: 40px; height: 40px; border-radius: 50%;" />
|
<div v-if="!d" class="w-100 pos-rel br-2 overflow-hidden" style="height: 40px">
|
||||||
<div class="flex-c">
|
<Loading />
|
||||||
<span style="font-size: 13px; font-weight: 600; color: var(--text)">{{ d.moon_phase }}</span>
|
|
||||||
<span style="font-size: 11px; color: var(--text-muted)">{{ d.moon_illumination?.toFixed(1) }}% illuminated</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
<template v-else>
|
||||||
|
<canvas ref="canvasRef" style="width: 40px; height: 40px; border-radius: 50%;" />
|
||||||
|
<div class="flex-c">
|
||||||
|
<span style="font-size: 13px; font-weight: 600; color: var(--text)">{{ d.moon_phase }}</span>
|
||||||
|
<span style="font-size: 11px; color: var(--text-muted)">{{ d.moon_illumination?.toFixed(1) }}% illuminated</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="divider" />
|
<div class="divider" />
|
||||||
|
|
||||||
<MetricRow label="Next Full Moon" :value="formatDate('MMM D', d.moon_full)" />
|
<div v-if="!d" class="flex-c p-2 gap-2">
|
||||||
<MetricRow label="Next New Moon" :value="formatDate('MMM D', d.moon_new)" />
|
<div class="w-100 pos-rel br-2 overflow-hidden" style="height: 1.75em"><Loading /></div>
|
||||||
|
<div class="w-100 pos-rel br-2 overflow-hidden" style="height: 1.75em"><Loading /></div>
|
||||||
|
</div>
|
||||||
|
<template v-else>
|
||||||
|
<MetricRow label="Next Full Moon" :value="formatDate('MMM D', d.moon_full)" />
|
||||||
|
<MetricRow label="Next New Moon" :value="formatDate('MMM D', d.moon_new)" />
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import Loading from '@/components/Loading.vue';
|
import Loading from '@/components/Loading.vue';
|
||||||
import { inject, onMounted, ref } from 'vue';
|
import {inject} from 'vue';
|
||||||
import { api } from '../services/api';
|
import {useWeather} from '../services/api';
|
||||||
|
|
||||||
const d = ref<any>(null);
|
|
||||||
const openGraph = inject<(key: string) => void>('openGraph');
|
const openGraph = inject<(key: string) => void>('openGraph');
|
||||||
|
const {current: d} = useWeather();
|
||||||
onMounted(async () => d.value = await api.current());
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
@@ -59,7 +57,7 @@ onMounted(async () => d.value = await api.current());
|
|||||||
</style>
|
</style>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="precip-card" v-if="d">
|
<div class="precip-card">
|
||||||
<div class="card-title">🌧️ Precipitation</div>
|
<div class="card-title">🌧️ Precipitation</div>
|
||||||
|
|
||||||
<div class="stat-grid">
|
<div class="stat-grid">
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import Loading from '@/components/Loading.vue';
|
||||||
import {ref, onMounted, onUnmounted} from 'vue';
|
import {ref, onMounted, onUnmounted} from 'vue';
|
||||||
import {api} from '../services/api';
|
import {api} from '../services/api';
|
||||||
import MetricRow from './MetricRow.vue';
|
import MetricRow from './MetricRow.vue';
|
||||||
@@ -49,15 +50,19 @@ svg { width: 100%; height: 60px; overflow: visible; }
|
|||||||
</style>
|
</style>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="card" v-if="d">
|
<div class="card">
|
||||||
<div class="card-title">🫨 Seismic</div>
|
<div class="card-title">🫨 Seismic</div>
|
||||||
|
<div v-if="!d" class="p-2">
|
||||||
<MetricRow label="Magnitude" :value="d.magnitude?.toFixed(1)" metric-key="magnitude" :data="d" />
|
<div class="w-100 pos-rel br-2 overflow-hidden" style="height: 80px">
|
||||||
|
<Loading />
|
||||||
<svg :viewBox="`0 0 ${W} ${H}`" preserveAspectRatio="none">
|
</div>
|
||||||
<line :x1="0" :y1="H/2" :x2="W" :y2="H/2" stroke="var(--border)" stroke-width="1" />
|
</div>
|
||||||
<path :d="toPath(history)" fill="none" stroke="var(--accent)" stroke-width="1.5" />
|
<template v-else>
|
||||||
</svg>
|
<MetricRow label="Magnitude" :value="d.magnitude?.toFixed(1)" metric-key="magnitude" :data="d" />
|
||||||
|
<svg :viewBox="`0 0 ${W} ${H}`" preserveAspectRatio="none">
|
||||||
|
<line :x1="0" :y1="H/2" :x2="W" :y2="H/2" stroke="var(--border)" stroke-width="1" />
|
||||||
|
<path :d="toPath(history)" fill="none" stroke="var(--accent)" stroke-width="1.5" />
|
||||||
|
</svg>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import Loading from '@/components/Loading.vue';
|
import Loading from '@/components/Loading.vue';
|
||||||
import { inject, onMounted, ref, computed } from 'vue';
|
import {inject, computed } from 'vue';
|
||||||
import { api } from '../services/api';
|
import {useWeather} from '../services/api';
|
||||||
|
|
||||||
const data = ref<any>(null);
|
|
||||||
const openGraph = inject<(key: string) => void>('openGraph');
|
const openGraph = inject<(key: string) => void>('openGraph');
|
||||||
|
const {current: data} = useWeather();
|
||||||
onMounted(async () => data.value = await api.current());
|
|
||||||
|
|
||||||
const W = 320, H = 120, HORIZON = 45;
|
const W = 320, H = 120, HORIZON = 45;
|
||||||
|
|
||||||
@@ -22,41 +20,42 @@ const arc = computed(() => {
|
|||||||
const start = noon - 12 * 3600 * 1000;
|
const start = noon - 12 * 3600 * 1000;
|
||||||
const end = noon + 12 * 3600 * 1000;
|
const end = noon + 12 * 3600 * 1000;
|
||||||
|
|
||||||
const riseT = (rise - start) / (end - start);
|
const riseT = (rise - start) / (end - start);
|
||||||
const setT = (set - start) / (end - start);
|
const setT = (set - start) / (end - start);
|
||||||
const dayPct = Math.max(0, Math.min(1, (now - rise) / dayLen));
|
const dayPct = Math.max(0, Math.min(1, (now - rise) / dayLen));
|
||||||
|
const sunT = riseT + dayPct * (setT - riseT);
|
||||||
|
|
||||||
|
// After sunset, continue tracking sun below horizon
|
||||||
|
const totalPct = Math.max(0, Math.min(1, (now - start) / (end - start)));
|
||||||
|
|
||||||
const x1 = 0, x2 = W;
|
const x1 = 0, x2 = W;
|
||||||
const totalW = x2 - x1;
|
const totalW = x2 - x1;
|
||||||
|
const amplitude = H * 0.45;
|
||||||
|
const midY = H / 2;
|
||||||
|
|
||||||
|
const sineY = (t: number) => midY - Math.sin(t * Math.PI * 2 - Math.PI / 2) * amplitude;
|
||||||
|
const horizonY = sineY(riseT);
|
||||||
|
|
||||||
const pts = Array.from({ length: 201 }, (_, i) => {
|
const pts = Array.from({ length: 201 }, (_, i) => {
|
||||||
const t = i / 200;
|
const t = i / 200;
|
||||||
const x = x1 + totalW * t;
|
return { x: x1 + totalW * t, y: sineY(t) };
|
||||||
// Remap t so the arc peaks between riseT and setT only
|
|
||||||
const arcT = (t - riseT) / (setT - riseT);
|
|
||||||
const y = arcT >= 0 && arcT <= 1
|
|
||||||
? HORIZON - Math.sin(arcT * Math.PI) * HORIZON
|
|
||||||
: HORIZON + Math.abs(Math.sin(arcT * Math.PI)) * (HORIZON * 0.3); // subtle below-horizon dip
|
|
||||||
return { x, y };
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const sunT = riseT + dayPct * (setT - riseT);
|
const splitIdx = Math.round(totalPct * 200);
|
||||||
const splitIdx = Math.round(sunT * 200);
|
const { x: cx, y: cy } = pts[Math.min(splitIdx, 200)];
|
||||||
const { x: cx, y: cy } = pts[splitIdx];
|
|
||||||
|
|
||||||
const traveledPts = pts.slice(0, splitIdx + 1);
|
const traveledPts = pts.slice(0, splitIdx + 1);
|
||||||
const filledPath = traveledPts.map((p, i) => `${i === 0 ? 'M' : 'L'}${p.x.toFixed(1)},${p.y.toFixed(1)}`).join(' ')
|
const filledPath = traveledPts.map((p, i) => `${i === 0 ? 'M' : 'L'}${p.x.toFixed(1)},${p.y.toFixed(1)}`).join(' ')
|
||||||
+ ` L${traveledPts[traveledPts.length - 1].x.toFixed(1)},${HORIZON}`
|
+ ` L${traveledPts[traveledPts.length - 1].x.toFixed(1)},${horizonY}`
|
||||||
+ ` L${x1},${HORIZON} Z`;
|
+ ` L${x1},${horizonY} Z`;
|
||||||
|
|
||||||
const remainPts = pts.slice(splitIdx);
|
const remainPts = pts.slice(splitIdx);
|
||||||
const remainPath = remainPts.map((p, i) => `${i === 0 ? 'M' : 'L'}${p.x.toFixed(1)},${p.y.toFixed(1)}`).join(' ');
|
const remainPath = remainPts.map((p, i) => `${i === 0 ? 'M' : 'L'}${p.x.toFixed(1)},${p.y.toFixed(1)}`).join(' ');
|
||||||
|
|
||||||
const riseX = x1 + totalW * riseT;
|
const riseX = x1 + totalW * riseT;
|
||||||
const setX = x1 + totalW * setT;
|
const setX = x1 + totalW * setT;
|
||||||
const noonX = x1 + totalW * 0.5;
|
|
||||||
|
|
||||||
return { filledPath, remainPath, cx, cy, pct: dayPct, riseX, setX, noonX };
|
return { filledPath, remainPath, cx, cy, pct: dayPct, riseX, setX, horizonY };
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -64,28 +63,32 @@ const arc = computed(() => {
|
|||||||
<div class="sun-card">
|
<div class="sun-card">
|
||||||
<div class="card-title">🌅 Sun</div>
|
<div class="card-title">🌅 Sun</div>
|
||||||
|
|
||||||
<!-- Top labels -->
|
<div v-if="!data" class="w-100 pos-rel br-2 overflow-hidden mx-auto" style="width: 100%; aspect-ratio: 1.5"><Loading /></div>
|
||||||
<div class="sun-top-labels" v-if="data">
|
|
||||||
<div>
|
|
||||||
<div class="stat-label">Sunrise</div>
|
|
||||||
<div class="stat-value">{{ new Date(data.sunrise).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) }}</div>
|
|
||||||
</div>
|
|
||||||
<div style="text-align:right">
|
|
||||||
<div class="stat-label">Sunset</div>
|
|
||||||
<div class="stat-value">{{ new Date(data.sunset).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) }}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<svg :viewBox="`0 0 ${W} ${H}`" preserveAspectRatio="none">
|
<template v-else>
|
||||||
<!-- Filled traveled area -->
|
<!-- Top labels -->
|
||||||
<path v-if="arc.filledPath" :d="arc.filledPath" fill="#3b82f6" opacity="0.35" />
|
<div class="sun-top-labels" v-if="data">
|
||||||
<!-- Remaining curve stroke -->
|
<div>
|
||||||
<path v-if="arc.remainPath" :d="arc.remainPath" fill="none" stroke="#94a3b8" stroke-width="1.5" opacity="0.5" />
|
<div class="stat-label">Sunrise</div>
|
||||||
<!-- Horizon line -->
|
<div class="stat-value">{{ new Date(data.sunrise).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) }}</div>
|
||||||
<line :x1="0" :y1="HORIZON" :x2="W" :y2="HORIZON" stroke="var(--border)" stroke-width="1" />
|
</div>
|
||||||
<!-- Sun dot -->
|
<div style="text-align:right">
|
||||||
<circle v-if="data" :cx="arc.cx" :cy="arc.cy" r="8" fill="#f59e0b" class="sun-dot" />
|
<div class="stat-label">Sunset</div>
|
||||||
</svg>
|
<div class="stat-value">{{ new Date(data.sunset).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<svg :viewBox="`0 0 ${W} ${H}`" preserveAspectRatio="none">
|
||||||
|
<!-- Filled traveled area -->
|
||||||
|
<path v-if="arc.filledPath" :d="arc.filledPath" fill="#3b82f6" opacity="0.35" />
|
||||||
|
<!-- Remaining curve stroke -->
|
||||||
|
<path v-if="arc.remainPath" :d="arc.remainPath" fill="none" stroke="#94a3b8" stroke-width="1.5" opacity="0.5" />
|
||||||
|
<!-- Horizon line -->
|
||||||
|
<line :x1="0" :y1="arc.horizonY" :x2="W" :y2="arc.horizonY" stroke="var(--border)" stroke-width="1" />
|
||||||
|
<!-- Sun dot -->
|
||||||
|
<circle v-if="data" :cx="arc.cx" :cy="arc.cy" r="8" fill="#f59e0b" class="sun-dot" />
|
||||||
|
</svg>
|
||||||
|
</template>
|
||||||
|
|
||||||
<div class="divider" />
|
<div class="divider" />
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import Loading from '@/components/Loading.vue';
|
import Loading from '@/components/Loading.vue';
|
||||||
import { inject, onMounted, ref } from 'vue';
|
import {inject} from 'vue';
|
||||||
import { api } from '../services/api';
|
import {useWeather} from '../services/api';
|
||||||
|
|
||||||
const d = ref<any>(null);
|
|
||||||
const openGraph = inject<(key: string) => void>('openGraph');
|
const openGraph = inject<(key: string) => void>('openGraph');
|
||||||
|
const {current: d} = useWeather();
|
||||||
|
|
||||||
function dir(deg: number) {
|
function dir(deg: number) {
|
||||||
if(deg > 22.5 && deg <= 67.5) return 'North-East';
|
if(deg > 22.5 && deg <= 67.5) return 'North-East';
|
||||||
@@ -16,8 +16,6 @@ function dir(deg: number) {
|
|||||||
if(deg > 292.5 && deg <= 337.5) return 'North-West';
|
if(deg > 292.5 && deg <= 337.5) return 'North-West';
|
||||||
return 'North';
|
return 'North';
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => d.value = await api.current());
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
@@ -70,7 +68,7 @@ onMounted(async () => d.value = await api.current());
|
|||||||
</style>
|
</style>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="wind-card" v-if="d">
|
<div class="wind-card">
|
||||||
<div class="card-title">🍃 Wind</div>
|
<div class="card-title">🍃 Wind</div>
|
||||||
|
|
||||||
<div class="stat-grid">
|
<div class="stat-grid">
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import {BASE} from '@/services/api.ts';
|
import { BASE } from '@/services/api.ts'
|
||||||
|
import { createApp, ref } from 'vue'
|
||||||
import Map from 'ol/Map'
|
import Map from 'ol/Map'
|
||||||
import { Feature } from 'ol'
|
import { Feature } from 'ol'
|
||||||
import Point from 'ol/geom/Point'
|
import Point from 'ol/geom/Point'
|
||||||
@@ -7,32 +8,12 @@ import { fromLonLat } from 'ol/proj'
|
|||||||
import { Style, Icon, Stroke } from 'ol/style'
|
import { Style, Icon, Stroke } from 'ol/style'
|
||||||
import { Vector as VectorLayer } from 'ol/layer'
|
import { Vector as VectorLayer } from 'ol/layer'
|
||||||
import { Vector as VectorSource } from 'ol/source'
|
import { Vector as VectorSource } from 'ol/source'
|
||||||
import {adjustedInterval} from '@ztimson/utils';
|
import { adjustedInterval } from '@ztimson/utils'
|
||||||
|
import AircraftPopup from '@/components/Aircraft.vue'
|
||||||
|
|
||||||
const API = BASE + '/api'
|
const API = BASE + '/api'
|
||||||
|
|
||||||
const SPEED_UNITS = {
|
let highestZIndex = 1000
|
||||||
knots: { label: 'KTS', convert: (v: number) => Math.round(v) },
|
|
||||||
kph: { label: 'KPH', convert: (v: number) => Math.round(v * 1.852) },
|
|
||||||
mph: { label: 'MPH', convert: (v: number) => Math.round(v * 1.15078) },
|
|
||||||
}
|
|
||||||
|
|
||||||
const ALTITUDE_UNITS = {
|
|
||||||
meters: { label: 'M', convert: (v: number) => Math.round(v * 0.3048) },
|
|
||||||
feet: { label: 'FT', convert: (v: number) => v },
|
|
||||||
}
|
|
||||||
|
|
||||||
const VERTICAL_UNITS = {
|
|
||||||
mps: { label: 'M/S', convert: (v: number) => Math.round(v * 0.3048) },
|
|
||||||
fps: { label: 'FT/S', convert: (v: number) => v },
|
|
||||||
}
|
|
||||||
|
|
||||||
const UNIT_KEY = 'at_units'
|
|
||||||
function getUnits() {
|
|
||||||
const s = localStorage.getItem(UNIT_KEY)
|
|
||||||
return s ? JSON.parse(s) : { speed: 'knots', altitude: 'meters', vertical: 'mps' }
|
|
||||||
}
|
|
||||||
function saveUnits(p: any) { localStorage.setItem(UNIT_KEY, JSON.stringify(p)) }
|
|
||||||
|
|
||||||
function getAltColor(alt: number): [number, number, number] {
|
function getAltColor(alt: number): [number, number, number] {
|
||||||
const a = Math.max(0, alt)
|
const a = Math.max(0, alt)
|
||||||
@@ -50,127 +31,10 @@ function getAltColor(alt: number): [number, number, number] {
|
|||||||
return [Math.round(128 * r), 0, 139]
|
return [Math.round(128 * r), 0, 139]
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildNavballSvg(data: any): string {
|
interface PopupInstance {
|
||||||
const heading = data.heading ?? 0
|
planeRef: ReturnType<typeof ref>
|
||||||
const airspeedKnots = data.speed || 0
|
posRef: ReturnType<typeof ref>
|
||||||
const verticalRateFps = data.climb || 0
|
unmount: () => void
|
||||||
const groundSpeedFps = airspeedKnots * 1.68781
|
|
||||||
const pitchRad = groundSpeedFps > 0 ? Math.atan(verticalRateFps / groundSpeedFps) : 0
|
|
||||||
const pitch = Math.max(-25, Math.min(25, pitchRad * (180 / Math.PI)))
|
|
||||||
|
|
||||||
const pitchLines: string[] = []
|
|
||||||
for (let p = -30; p <= 30; p += 5) {
|
|
||||||
if (p === 0) continue
|
|
||||||
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>
|
|
||||||
`)
|
|
||||||
} 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"/>`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
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
|
|
||||||
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 + 90 * Math.sin(rad)
|
|
||||||
const y = 100 - 90 * 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('')
|
|
||||||
|
|
||||||
return `
|
|
||||||
<svg width="200" height="220" viewBox="0 0 200 220">
|
|
||||||
<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="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="100%" style="stop-color:#654321"/>
|
|
||||||
</linearGradient>
|
|
||||||
</defs>
|
|
||||||
<rect x="77" y="5" width="46" height="18" fill="rgba(0,0,0,0.95)" stroke="#0f0" stroke-width="2" rx="2"/>
|
|
||||||
<text x="100" y="18" text-anchor="middle" fill="#0f0" font-size="14" font-weight="bold">${heading.toFixed(0).padStart(3, '0')}°</text>
|
|
||||||
<g transform="translate(0,25)" clip-path="url(#navballClip)">
|
|
||||||
<rect x="15" y="${15 + pitch * 2.5 - 200}" width="170" height="285" fill="url(#skyGrad)"/>
|
|
||||||
<rect x="15" y="${100 + pitch * 2.5}" width="170" height="285" fill="url(#groundGrad)"/>
|
|
||||||
<line x1="15" y1="${100 + pitch * 2.5}" x2="185" y2="${100 + pitch * 2.5}" stroke="#fff" stroke-width="3"/>
|
|
||||||
${pitchLines.join('')}
|
|
||||||
</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="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>
|
|
||||||
<polygon points="100,37 95,30 105,30" fill="#ff0"/>
|
|
||||||
</svg>
|
|
||||||
`
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildGaugeHtml(label: string, value: any, unit: string, type: string): string {
|
|
||||||
return `
|
|
||||||
<div style="margin-bottom:12px">
|
|
||||||
<div style="color:#888;font-size:11px;font-weight:bold;margin-bottom:3px">${label}</div>
|
|
||||||
<div class="at-gauge" data-gauge="${type}" style="padding:6px;background:rgba(0,0,0,0.95);border:2px solid #0f0;border-radius:3px;cursor:pointer;display:flex;align-items:end;justify-content:center;gap:4px">
|
|
||||||
<span style="font-size:20px;font-weight:bold;color:#0f0">${value}</span>
|
|
||||||
<span style="font-size:11px;color:#0f0;margin-bottom:2px">${unit}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildPopupHtml(data: any): string {
|
|
||||||
const prefs = getUnits()
|
|
||||||
const speed = SPEED_UNITS[prefs.speed as keyof typeof SPEED_UNITS]
|
|
||||||
const alt = ALTITUDE_UNITS[prefs.altitude as keyof typeof ALTITUDE_UNITS]
|
|
||||||
const vert = VERTICAL_UNITS[prefs.vertical as keyof typeof VERTICAL_UNITS]
|
|
||||||
const callsign = data.name?.trim()
|
|
||||||
const altitude = data.alt_baro ?? data.alt_geom ?? 0
|
|
||||||
|
|
||||||
return `
|
|
||||||
<div style="font-family:monospace;color:#ccc;min-width:320px">
|
|
||||||
<div style="padding:12px 16px;border-bottom:1px solid rgba(200,200,220,0.2)">
|
|
||||||
<h3 style="margin:0;color:#7eeee1;font-size:16px">
|
|
||||||
✈️ ${callsign
|
|
||||||
? `<a href="https://www.flightaware.com/live/flight/${callsign}" target="_blank" style="color:#6bb6ff;text-decoration:none">${callsign.toUpperCase()}</a>`
|
|
||||||
: 'N/A'}
|
|
||||||
</h3>
|
|
||||||
<div style="font-size:11px;color:#888;margin-top:4px">
|
|
||||||
${data.country || ''} • ${data.class || 'Unknown'} • ${data.type || ''}
|
|
||||||
<span style="float:right">ICAO: ${(data.icao || '').toUpperCase()}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div style="display:flex;gap:16px;padding:12px 16px">
|
|
||||||
<div style="flex:0 0 110px">
|
|
||||||
${buildGaugeHtml('Air Speed', speed.convert(data.speed || 0), speed.label, 'speed')}
|
|
||||||
${buildGaugeHtml('Altitude', data.landed ? 'LANDED' : alt.convert(altitude), data.landed ? '' : alt.label, 'altitude')}
|
|
||||||
${buildGaugeHtml('Climb', vert.convert(data.climb || 0) >= 0 ? `+${vert.convert(data.climb || 0)}` : vert.convert(data.climb || 0), vert.label, 'vertical')}
|
|
||||||
</div>
|
|
||||||
<div style="flex:1">${buildNavballSvg(data)}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export class AirTrafficLayer {
|
export class AirTrafficLayer {
|
||||||
@@ -178,28 +42,25 @@ export class AirTrafficLayer {
|
|||||||
private layer!: VectorLayer<VectorSource>
|
private layer!: VectorLayer<VectorSource>
|
||||||
private traceLayers: Record<string, VectorLayer<VectorSource>> = {}
|
private traceLayers: Record<string, VectorLayer<VectorSource>> = {}
|
||||||
private traceSegs: Record<string, Feature[]> = {}
|
private traceSegs: Record<string, Feature[]> = {}
|
||||||
private popups: Record<string, { el: HTMLDivElement, destroy: () => void }> = {}
|
private popups: any = {}
|
||||||
private historyCache: Record<string, any[]> = {}
|
private historyCache: any = {}
|
||||||
private data: any[] = []
|
private data: any[] = []
|
||||||
private clickHandler: ((e: any) => void) | null = null
|
private clickHandler: ((e: any) => void) | null = null
|
||||||
private refreshTimer: any = null;
|
private refreshTimer: any = null
|
||||||
visible = false
|
visible = false
|
||||||
|
|
||||||
constructor(map: Map) { this.map = map }
|
constructor(map: Map) { this.map = map }
|
||||||
|
|
||||||
// ── Public ────────────────────────────────────────────────────────────────
|
// ── Public ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async show() {
|
async show() {
|
||||||
if (this.visible) return
|
if (this.visible) return
|
||||||
this.visible = true
|
this.visible = true
|
||||||
|
|
||||||
this.layer = new VectorLayer({ source: new VectorSource(), zIndex: 100 })
|
this.layer = new VectorLayer({ source: new VectorSource(), zIndex: 100 })
|
||||||
this.map.addLayer(this.layer)
|
this.map.addLayer(this.layer)
|
||||||
|
|
||||||
await this._fetch()
|
await this._fetch()
|
||||||
this._draw()
|
this._draw()
|
||||||
this._attachClick()
|
this._attachClick()
|
||||||
|
|
||||||
this.refreshTimer = adjustedInterval(async () => {
|
this.refreshTimer = adjustedInterval(async () => {
|
||||||
await this._fetch()
|
await this._fetch()
|
||||||
this._draw()
|
this._draw()
|
||||||
@@ -210,19 +71,16 @@ export class AirTrafficLayer {
|
|||||||
hide() {
|
hide() {
|
||||||
if (!this.visible) return
|
if (!this.visible) return
|
||||||
this.visible = false
|
this.visible = false
|
||||||
|
|
||||||
if (this.refreshTimer) { clearInterval(this.refreshTimer); this.refreshTimer = null }
|
if (this.refreshTimer) { clearInterval(this.refreshTimer); this.refreshTimer = null }
|
||||||
if (this.clickHandler) { this.map.un('singleclick', this.clickHandler); this.clickHandler = null }
|
if (this.clickHandler) { this.map.un('singleclick', this.clickHandler); this.clickHandler = null }
|
||||||
|
|
||||||
for (const icao of Object.keys(this.popups)) this._closePopup(icao)
|
for (const icao of Object.keys(this.popups)) this._closePopup(icao)
|
||||||
|
|
||||||
this.map.removeLayer(this.layer)
|
this.map.removeLayer(this.layer)
|
||||||
Object.values(this.traceLayers).forEach(l => this.map.removeLayer(l))
|
Object.values(this.traceLayers).forEach(l => this.map.removeLayer(l))
|
||||||
this.traceLayers = {}
|
this.traceLayers = {}
|
||||||
this.traceSegs = {}
|
this.traceSegs = {}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Private ───────────────────────────────────────────────────────────────
|
// ── Private ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
private async _fetch() {
|
private async _fetch() {
|
||||||
const j = await fetch(`${API}/adsb`).then(r => r.json())
|
const j = await fetch(`${API}/adsb`).then(r => r.json())
|
||||||
@@ -241,7 +99,6 @@ export class AirTrafficLayer {
|
|||||||
private _draw() {
|
private _draw() {
|
||||||
const source = this.layer.getSource()!
|
const source = this.layer.getSource()!
|
||||||
source.clear()
|
source.clear()
|
||||||
|
|
||||||
for (const plane of this.data) {
|
for (const plane of this.data) {
|
||||||
if (plane.latitude == null) continue
|
if (plane.latitude == null) continue
|
||||||
const coord = fromLonLat([plane.longitude, plane.latitude])
|
const coord = fromLonLat([plane.longitude, plane.latitude])
|
||||||
@@ -266,8 +123,7 @@ export class AirTrafficLayer {
|
|||||||
const j = await fetch(`${API}/adsb/${icao}`).then(r => r.json())
|
const j = await fetch(`${API}/adsb/${icao}`).then(r => r.json())
|
||||||
this.historyCache[icao] = j.history || []
|
this.historyCache[icao] = j.history || []
|
||||||
}
|
}
|
||||||
|
const history = [...this.historyCache[icao]]
|
||||||
const history = [...(<any>this.historyCache[icao])]
|
|
||||||
const cur = { latitude: plane.latitude, longitude: plane.longitude, altitude: plane.alt_baro ?? plane.alt_geom ?? 0 }
|
const cur = { latitude: plane.latitude, longitude: plane.longitude, altitude: plane.alt_baro ?? plane.alt_geom ?? 0 }
|
||||||
const last = history[history.length - 1]
|
const last = history[history.length - 1]
|
||||||
if (!last || last.latitude !== cur.latitude || last.longitude !== cur.longitude) history.push(cur)
|
if (!last || last.latitude !== cur.latitude || last.longitude !== cur.longitude) history.push(cur)
|
||||||
@@ -292,7 +148,7 @@ export class AirTrafficLayer {
|
|||||||
const s = history[i], e = history[i + 1]
|
const s = history[i], e = history[i + 1]
|
||||||
const sc = getAltColor(s.altitude || 0)
|
const sc = getAltColor(s.altitude || 0)
|
||||||
const ec = getAltColor(e.altitude || 0)
|
const ec = getAltColor(e.altitude || 0)
|
||||||
const avg = sc.map((v, idx) => Math.round((v + <any>ec[idx]) / 2))
|
const avg = sc.map((v, idx) => Math.round((v + (ec[idx] as any)) / 2))
|
||||||
const f = new Feature(new LineString([fromLonLat([s.longitude, s.latitude]), fromLonLat([e.longitude, e.latitude])]))
|
const f = new Feature(new LineString([fromLonLat([s.longitude, s.latitude]), fromLonLat([e.longitude, e.latitude])]))
|
||||||
f.setStyle(new Style({ stroke: new Stroke({ color: `rgba(${avg.join(',')},0.8)`, width: 3 }) }))
|
f.setStyle(new Style({ stroke: new Stroke({ color: `rgba(${avg.join(',')},0.8)`, width: 3 }) }))
|
||||||
vs.addFeature(f)
|
vs.addFeature(f)
|
||||||
@@ -301,67 +157,46 @@ export class AirTrafficLayer {
|
|||||||
this.traceSegs[icao] = segs
|
this.traceSegs[icao] = segs
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private _calcPopupPos(plane: any): { x: number; y: number } {
|
||||||
|
const pixel: any = this.map.getPixelFromCoordinate(fromLonLat([plane.longitude, plane.latitude]))
|
||||||
|
if (!pixel) return { x: 10, y: 60 }
|
||||||
|
const rect = (this.map.getTargetElement() as HTMLElement).getBoundingClientRect()
|
||||||
|
return { x: rect.left + pixel[0] + 16, y: rect.top + pixel[1] - 16 }
|
||||||
|
}
|
||||||
|
|
||||||
private _openPopup(plane: any) {
|
private _openPopup(plane: any) {
|
||||||
const icao = plane.icao
|
const icao = plane.icao
|
||||||
if (this.popups[icao]) return
|
if (this.popups[icao]) return
|
||||||
|
|
||||||
const el = document.createElement('div')
|
const planeRef = ref(plane)
|
||||||
el.style.cssText = `
|
const posRef = ref(this._calcPopupPos(plane))
|
||||||
position:absolute; z-index:9999; pointer-events:auto;
|
|
||||||
background:rgba(0,0,0,0.88); border:1px solid rgba(200,200,220,0.3);
|
|
||||||
border-radius:8px; box-shadow:0 4px 24px rgba(0,0,0,0.5); overflow:hidden;
|
|
||||||
`
|
|
||||||
|
|
||||||
const close = document.createElement('button')
|
const container = document.createElement('div')
|
||||||
close.innerHTML = '✕'
|
document.body.appendChild(container)
|
||||||
close.style.cssText = 'position:absolute;top:8px;right:8px;background:none;border:none;color:#888;font-size:14px;cursor:pointer;z-index:1'
|
|
||||||
close.onclick = () => this._closePopup(icao)
|
|
||||||
|
|
||||||
el.innerHTML = buildPopupHtml(plane)
|
const app = createApp(AircraftPopup, {
|
||||||
el.appendChild(close)
|
plane: planeRef.value,
|
||||||
document.body.appendChild(el)
|
position: posRef.value,
|
||||||
this._positionPopup(el, plane)
|
onClose: () => this._closePopup(icao),
|
||||||
|
onBringToFront: () => {
|
||||||
el.querySelectorAll('.at-gauge').forEach(g => {
|
highestZIndex++
|
||||||
g.addEventListener('click', (e) => {
|
container.style.zIndex = String(highestZIndex)
|
||||||
const prefs = getUnits()
|
},
|
||||||
const type = (e.currentTarget as HTMLElement).dataset.gauge!
|
|
||||||
if (type === 'speed') {
|
|
||||||
const keys = Object.keys(SPEED_UNITS)
|
|
||||||
prefs.speed = keys[(keys.indexOf(prefs.speed) + 1) % keys.length]
|
|
||||||
} else if (type === 'altitude') {
|
|
||||||
const keys = Object.keys(ALTITUDE_UNITS)
|
|
||||||
prefs.altitude = keys[(keys.indexOf(prefs.altitude) + 1) % keys.length]
|
|
||||||
prefs.vertical = prefs.altitude === 'meters' ? 'mps' : 'fps'
|
|
||||||
} else if (type === 'vertical') {
|
|
||||||
const keys = Object.keys(VERTICAL_UNITS)
|
|
||||||
prefs.vertical = keys[(keys.indexOf(prefs.vertical) + 1) % keys.length]
|
|
||||||
prefs.altitude = prefs.vertical === 'mps' ? 'meters' : 'feet'
|
|
||||||
}
|
|
||||||
saveUnits(prefs)
|
|
||||||
el.innerHTML = buildPopupHtml(plane)
|
|
||||||
el.appendChild(close)
|
|
||||||
})
|
|
||||||
g.addEventListener('mouseenter', e => (e.currentTarget as HTMLElement).style.borderColor = '#0ff')
|
|
||||||
g.addEventListener('mouseleave', e => (e.currentTarget as HTMLElement).style.borderColor = '#0f0')
|
|
||||||
})
|
})
|
||||||
|
|
||||||
this.popups[icao] = { el, destroy: () => el.remove() }
|
app.mount(container)
|
||||||
this._fetchTrace(plane)
|
|
||||||
}
|
|
||||||
|
|
||||||
private _positionPopup(el: HTMLDivElement, plane: any) {
|
this.popups[icao] = {
|
||||||
const pixel = this.map.getPixelFromCoordinate(fromLonLat([plane.longitude, plane.latitude]))
|
planeRef,
|
||||||
if (!pixel) return
|
posRef,
|
||||||
const rect = (this.map.getTargetElement() as HTMLElement).getBoundingClientRect()
|
unmount: () => { app.unmount(); container.remove() },
|
||||||
el.style.left = `${rect.left + <any>pixel[0] + 16}px`
|
}
|
||||||
el.style.top = `${rect.top + <any>pixel[1] - 16}px`
|
this._fetchTrace(plane)
|
||||||
}
|
}
|
||||||
|
|
||||||
private _closePopup(icao: string) {
|
private _closePopup(icao: string) {
|
||||||
const popup = this.popups[icao]
|
const popup = this.popups[icao]
|
||||||
if (popup) { popup.destroy(); delete this.popups[icao] }
|
if (popup) { popup.unmount(); delete this.popups[icao] }
|
||||||
|
|
||||||
const segs = this.traceSegs[icao]
|
const segs = this.traceSegs[icao]
|
||||||
const layer = this.traceLayers[icao]
|
const layer = this.traceLayers[icao]
|
||||||
if (segs && layer) segs.forEach(f => layer.getSource()!.removeFeature(f))
|
if (segs && layer) segs.forEach(f => layer.getSource()!.removeFeature(f))
|
||||||
@@ -373,14 +208,8 @@ export class AirTrafficLayer {
|
|||||||
for (const icao of Object.keys(this.popups)) {
|
for (const icao of Object.keys(this.popups)) {
|
||||||
const plane = this.data.find(p => p.icao === icao)
|
const plane = this.data.find(p => p.icao === icao)
|
||||||
if (!plane) { this._closePopup(icao); continue }
|
if (!plane) { this._closePopup(icao); continue }
|
||||||
const { el } = <any>this.popups[icao]
|
this.popups[icao].planeRef.value = plane
|
||||||
el.innerHTML = buildPopupHtml(plane)
|
// Don't reposition — user may have dragged it 🎯
|
||||||
const close = document.createElement('button')
|
|
||||||
close.innerHTML = '✕'
|
|
||||||
close.style.cssText = 'position:absolute;top:8px;right:8px;background:none;border:none;color:#888;font-size:14px;cursor:pointer;z-index:1'
|
|
||||||
close.onclick = () => this._closePopup(icao)
|
|
||||||
el.appendChild(close)
|
|
||||||
this._positionPopup(el, plane)
|
|
||||||
this._fetchTrace(plane)
|
this._fetchTrace(plane)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,16 @@
|
|||||||
export const BASE = import.meta.env.DEV ? 'http://10.69.5.23' : '';
|
import {ref, onUnmounted} from 'vue';
|
||||||
|
|
||||||
export type DataRow = Record<string, number | string | null>
|
export type DataRow = Record<string, number | string | null>
|
||||||
|
|
||||||
|
export const BASE = import.meta.env.DEV ? 'http://10.69.5.23' : '';
|
||||||
|
|
||||||
const cache: any = {};
|
const cache: any = {};
|
||||||
|
const current = ref<any>(null);
|
||||||
|
const hourly = ref<any[]>([]);
|
||||||
|
const daily = ref<any[]>([]);
|
||||||
|
|
||||||
|
let interval: ReturnType<typeof setInterval>;
|
||||||
|
let refCount = 0;
|
||||||
|
|
||||||
async function get<T>(path: string, params: Record<string, string> = {}): Promise<T> {
|
async function get<T>(path: string, params: Record<string, string> = {}): Promise<T> {
|
||||||
const url = new URL(`${BASE}${path}`, window.location.origin);
|
const url = new URL(`${BASE}${path}`, window.location.origin);
|
||||||
@@ -29,3 +37,24 @@ export const api = {
|
|||||||
...(fields ? {fields} : {})
|
...(fields ? {fields} : {})
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
async function refresh() {
|
||||||
|
const start = new Date(), end = new Date();
|
||||||
|
start.setDate(start.getDate() + 1);
|
||||||
|
end.setDate(end.getDate() + 5);
|
||||||
|
current.value = await api.current();
|
||||||
|
return {current}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useWeather(ms = 5 * 60_000) {
|
||||||
|
if(++refCount === 1) {
|
||||||
|
refresh();
|
||||||
|
interval = setInterval(refresh, ms);
|
||||||
|
}
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if(--refCount === 0) clearInterval(interval);
|
||||||
|
});
|
||||||
|
|
||||||
|
return {current, hourly, daily, refresh};
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import Precipitation from '@/components/Precipitation.vue';
|
|||||||
import Seismic from '@/components/Seismic.vue';
|
import Seismic from '@/components/Seismic.vue';
|
||||||
import Sun from '@/components/Sun.vue';
|
import Sun from '@/components/Sun.vue';
|
||||||
import Wind from '@/components/Wind.vue';
|
import Wind from '@/components/Wind.vue';
|
||||||
import {BASE} from '@/services/api.ts';
|
import {BASE, useWeather} from '@/services/api.ts';
|
||||||
import {formatDate} from '@ztimson/utils';
|
import {formatDate} from '@ztimson/utils';
|
||||||
import {onMounted, onUnmounted, provide, ref} from 'vue';
|
import {onMounted, onUnmounted, provide, ref} from 'vue';
|
||||||
import MapView from '../components/MapView.vue';
|
import MapView from '../components/MapView.vue';
|
||||||
@@ -23,6 +23,7 @@ let clock: ReturnType<typeof setInterval>;
|
|||||||
provide('openGraph', (key: string) => selectedMetric.value = key);
|
provide('openGraph', (key: string) => selectedMetric.value = key);
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
|
useWeather();
|
||||||
clock = setInterval(() => {
|
clock = setInterval(() => {
|
||||||
day.value = formatDate('dddd');
|
day.value = formatDate('dddd');
|
||||||
date.value = formatDate('MMMM D');
|
date.value = formatDate('MMMM D');
|
||||||
|
|||||||
@@ -56,20 +56,11 @@ app.get('/api/current', async (req, res) => {
|
|||||||
const space = getCelestialCurrent(coords.latitude, coords.longitude)
|
const space = getCelestialCurrent(coords.latitude, coords.longitude)
|
||||||
const condition = getWeatherCondition(Object.assign(sensors, space))
|
const condition = getWeatherCondition(Object.assign(sensors, space))
|
||||||
|
|
||||||
const forecast = await getForecast();
|
res.json(filterFields({
|
||||||
const merged = {
|
|
||||||
...(forecast?.summary ? {
|
|
||||||
precipitation_chance: forecast.summary.precipitation_chance,
|
|
||||||
temperature_max: forecast.summary.temperature_max,
|
|
||||||
temperature_min: forecast.summary.temperature_min,
|
|
||||||
uv_index_max: forecast.summary.uv_index,
|
|
||||||
} : {}),
|
|
||||||
...condition,
|
...condition,
|
||||||
...sensors,
|
...sensors,
|
||||||
...space,
|
...space,
|
||||||
}
|
}, fields))
|
||||||
|
|
||||||
res.json(filterFields(merged, fields))
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// ── Hourly ────────────────────────────────────────────────────────────────────
|
// ── Hourly ────────────────────────────────────────────────────────────────────
|
||||||
@@ -148,10 +139,10 @@ app.get('/api/position', async (req, res) => {
|
|||||||
// ── Space ─────────────────────────────────────────────────────────────────────
|
// ── Space ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
// app.get('/api/aurora', async (req, res) => res.json(await Aurora.get()));
|
// app.get('/api/aurora', async (req, res) => res.json(await Aurora.get()));
|
||||||
app.get('/api/space', async (req, res) => {
|
// app.get('/api/space', async (req, res) => {
|
||||||
const {fields} = req.query;
|
// const {fields} = req.query;
|
||||||
res.json(filterFields(await getSpaceWeather(), fields));
|
// res.json(filterFields(await getSpaceWeather(), fields));
|
||||||
});
|
// });
|
||||||
|
|
||||||
// ── ADSB Proxy ────────────────────────────────────────────────────────────────
|
// ── ADSB Proxy ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user