Sat updates
This commit is contained in:
@@ -3,6 +3,7 @@ import { AirTrafficLayer } from '@/services/adsb.ts'
|
|||||||
import { AISLayer } from '@/services/ais.ts'
|
import { AISLayer } from '@/services/ais.ts'
|
||||||
import { api } from '@/services/api.ts'
|
import { api } from '@/services/api.ts'
|
||||||
import { RangeLayer } from '@/services/range.ts'
|
import { RangeLayer } from '@/services/range.ts'
|
||||||
|
import {SatsLayer} from '@/services/sats.ts';
|
||||||
import VectorTileLayer from 'ol/layer/VectorTile'
|
import VectorTileLayer from 'ol/layer/VectorTile'
|
||||||
import { onMounted, onUnmounted, ref, watch } from 'vue'
|
import { onMounted, onUnmounted, ref, watch } from 'vue'
|
||||||
import Map from 'ol/Map'
|
import Map from 'ol/Map'
|
||||||
@@ -24,7 +25,6 @@ const current = ref({ latitude: 0, longitude: 0 })
|
|||||||
const props = defineProps<{ dark: boolean }>()
|
const props = defineProps<{ dark: boolean }>()
|
||||||
const mapEl = ref<HTMLDivElement>()
|
const mapEl = ref<HTMLDivElement>()
|
||||||
const showOverlays = ref(false)
|
const showOverlays = ref(false)
|
||||||
const atActive = ref(true)
|
|
||||||
|
|
||||||
const NM = 1852
|
const NM = 1852
|
||||||
|
|
||||||
@@ -38,7 +38,7 @@ const OVERLAYS = [
|
|||||||
{ id: 'wind', label: 'Wind', icon: '💨' },
|
{ id: 'wind', label: 'Wind', icon: '💨' },
|
||||||
]
|
]
|
||||||
|
|
||||||
const activeOverlays = ref<Set<string>>(new Set(['traffic', 'rain']))
|
const activeOverlays = ref<Set<string>>(new Set(['traffic', 'sats', 'rain']))
|
||||||
const overlayLayers: { [key: string]: any } = {}
|
const overlayLayers: { [key: string]: any } = {}
|
||||||
|
|
||||||
let map: Map
|
let map: Map
|
||||||
@@ -47,6 +47,7 @@ let radarInterval: ReturnType<typeof setInterval>
|
|||||||
let airTraffic: AirTrafficLayer
|
let airTraffic: AirTrafficLayer
|
||||||
let aisLayer: AISLayer
|
let aisLayer: AISLayer
|
||||||
let range: RangeLayer
|
let range: RangeLayer
|
||||||
|
let sats: SatsLayer
|
||||||
|
|
||||||
function buildWindSrc(lat: number, lon: number, zoom: number) {
|
function buildWindSrc(lat: number, lon: number, zoom: number) {
|
||||||
return `https://embed.windy.com/embed2.html?lat=${lat.toFixed(4)}&lon=${lon.toFixed(4)}&detailLat=${lat.toFixed(4)}&detailLon=${lon.toFixed(4)}&zoom=${Math.round(zoom)}&level=surface&overlay=wind&product=ecmwf&menu=&message=&marker=&calendar=now&pressure=&type=map&location=coordinates&detail=&metricWind=kt&metricTemp=%C2%B0C&radarRange=-1`
|
return `https://embed.windy.com/embed2.html?lat=${lat.toFixed(4)}&lon=${lon.toFixed(4)}&detailLat=${lat.toFixed(4)}&detailLon=${lon.toFixed(4)}&zoom=${Math.round(zoom)}&level=surface&overlay=wind&product=ecmwf&menu=&message=&marker=&calendar=now&pressure=&type=map&location=coordinates&detail=&metricWind=kt&metricTemp=%C2%B0C&radarRange=-1`
|
||||||
@@ -123,9 +124,15 @@ function toggleOverlay(id: string) {
|
|||||||
airTraffic.show()
|
airTraffic.show()
|
||||||
range.show()
|
range.show()
|
||||||
}
|
}
|
||||||
}
|
} else if (id === 'sats') {
|
||||||
|
if (activeOverlays.value.has('sats')) {
|
||||||
if (id === 'wind') {
|
activeOverlays.value.delete('sats')
|
||||||
|
sats.hide()
|
||||||
|
} else {
|
||||||
|
activeOverlays.value.add('sats')
|
||||||
|
sats.show()
|
||||||
|
}
|
||||||
|
} else if (id === 'wind') {
|
||||||
if (activeOverlays.value.has('wind')) {
|
if (activeOverlays.value.has('wind')) {
|
||||||
activeOverlays.value.delete('wind')
|
activeOverlays.value.delete('wind')
|
||||||
hideWindOverlay()
|
hideWindOverlay()
|
||||||
@@ -133,10 +140,7 @@ function toggleOverlay(id: string) {
|
|||||||
activeOverlays.value.add('wind')
|
activeOverlays.value.add('wind')
|
||||||
showWindOverlay()
|
showWindOverlay()
|
||||||
}
|
}
|
||||||
return
|
} else if (id === 'rain') {
|
||||||
}
|
|
||||||
|
|
||||||
if (id === 'rain') {
|
|
||||||
if (activeOverlays.value.has('rain')) {
|
if (activeOverlays.value.has('rain')) {
|
||||||
activeOverlays.value.delete('rain')
|
activeOverlays.value.delete('rain')
|
||||||
if (overlayLayers['rain']) { map.removeLayer(overlayLayers['rain']); delete overlayLayers['rain'] }
|
if (overlayLayers['rain']) { map.removeLayer(overlayLayers['rain']); delete overlayLayers['rain'] }
|
||||||
@@ -144,7 +148,6 @@ function toggleOverlay(id: string) {
|
|||||||
activeOverlays.value.add('rain')
|
activeOverlays.value.add('rain')
|
||||||
refreshRain()
|
refreshRain()
|
||||||
}
|
}
|
||||||
return
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -202,6 +205,8 @@ onMounted(async () => {
|
|||||||
aisLayer.show()
|
aisLayer.show()
|
||||||
airTraffic = new AirTrafficLayer(map)
|
airTraffic = new AirTrafficLayer(map)
|
||||||
airTraffic.show()
|
airTraffic.show()
|
||||||
|
sats = new SatsLayer(map)
|
||||||
|
sats.show();
|
||||||
range = new RangeLayer(map)
|
range = new RangeLayer(map)
|
||||||
range.show()
|
range.show()
|
||||||
|
|
||||||
|
|||||||
@@ -1,48 +1,228 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
history: any[]
|
||||||
|
range: { altitude: number; slantRange: number; horizonRadius: number; groundDist: number } | null
|
||||||
|
eta: number | null
|
||||||
|
position: { x: number; y: number }
|
||||||
|
}>()
|
||||||
|
const emit = defineEmits<{ (e: 'close'): void; (e: 'bringToFront'): void }>()
|
||||||
|
|
||||||
const emit = defineEmits(['bringToFront']);
|
|
||||||
const props = defineProps<{ history: any[]; position: { x: number, y: number }; onClose: () => void }>()
|
|
||||||
const latest = computed(() => props.history[props.history.length - 1])
|
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)
|
||||||
|
|
||||||
|
// ── Drag ──────────────────────────────────────────────────────────────────────
|
||||||
|
const pos = ref({ ...props.position })
|
||||||
|
const isDragging = ref(false)
|
||||||
|
const dragStart = ref({ mx: 0, my: 0, ex: 0, ey: 0 })
|
||||||
|
const longPressTimer = ref<number | null>(null)
|
||||||
|
const header = ref<HTMLElement | null>(null)
|
||||||
|
|
||||||
|
function onMouseDown(e: MouseEvent) {
|
||||||
|
if ((e.target as HTMLElement).classList.contains('tp-close')) return
|
||||||
|
emit('bringToFront')
|
||||||
|
isDragging.value = true
|
||||||
|
dragStart.value = { mx: e.clientX, my: e.clientY, ex: pos.value.x, ey: pos.value.y }
|
||||||
|
e.preventDefault()
|
||||||
|
}
|
||||||
|
function onMouseMove(e: MouseEvent) {
|
||||||
|
if (!isDragging.value) return
|
||||||
|
pos.value.x = dragStart.value.ex + (e.clientX - dragStart.value.mx)
|
||||||
|
pos.value.y = dragStart.value.ey + (e.clientY - dragStart.value.my)
|
||||||
|
}
|
||||||
|
function onMouseUp() { isDragging.value = false }
|
||||||
|
|
||||||
|
function onTouchStart(e: TouchEvent) {
|
||||||
|
if ((e.target as HTMLElement).classList.contains('tp-close')) return
|
||||||
|
const touch: any = e.touches[0]
|
||||||
|
const sx = touch.clientX, sy = touch.clientY
|
||||||
|
longPressTimer.value = window.setTimeout(() => {
|
||||||
|
emit('bringToFront')
|
||||||
|
isDragging.value = true
|
||||||
|
dragStart.value = { mx: sx, my: sy, ex: pos.value.x, ey: pos.value.y }
|
||||||
|
if (header.value) header.value.style.opacity = '0.8'
|
||||||
|
}, 500)
|
||||||
|
}
|
||||||
|
function onTouchMove(e: TouchEvent) {
|
||||||
|
if (!isDragging.value) return
|
||||||
|
const touch: any = e.touches[0]
|
||||||
|
pos.value.x = dragStart.value.ex + (touch.clientX - dragStart.value.mx)
|
||||||
|
pos.value.y = dragStart.value.ey + (touch.clientY - dragStart.value.my)
|
||||||
|
e.preventDefault()
|
||||||
|
}
|
||||||
|
function onTouchEnd() {
|
||||||
|
if (longPressTimer.value) { clearTimeout(longPressTimer.value); longPressTimer.value = null }
|
||||||
|
if (isDragging.value && header.value) header.value.style.opacity = '1'
|
||||||
|
isDragging.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
document.addEventListener('mousemove', onMouseMove)
|
||||||
|
document.addEventListener('mouseup', onMouseUp)
|
||||||
|
document.addEventListener('touchmove', onTouchMove, { passive: false })
|
||||||
|
document.addEventListener('touchend', onTouchEnd)
|
||||||
|
document.addEventListener('touchcancel', onTouchEnd)
|
||||||
|
})
|
||||||
|
onUnmounted(() => {
|
||||||
|
document.removeEventListener('mousemove', onMouseMove)
|
||||||
|
document.removeEventListener('mouseup', onMouseUp)
|
||||||
|
document.removeEventListener('touchmove', onTouchMove)
|
||||||
|
document.removeEventListener('touchend', onTouchEnd)
|
||||||
|
document.removeEventListener('touchcancel', onTouchEnd)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Radar HUD ─────────────────────────────────────────────────────────────────
|
||||||
/** Center = zenith (el 90), edge = horizon (el 0). */
|
/** Center = zenith (el 90), edge = horizon (el 0). */
|
||||||
function polarPoint(az: number, el: number, radius = 100) {
|
function polarPoint(az: number, el: number, radius = 100) {
|
||||||
const r = radius * (1 - el / 90)
|
const r = radius * (1 - el / 90)
|
||||||
const rad = (az - 90) * Math.PI / 180
|
const rad = (az - 90) * Math.PI / 180
|
||||||
return { x: 120 + r * Math.cos(rad), y: 120 + r * Math.sin(rad) }
|
return { x: 120 + r * Math.cos(rad), y: 120 + r * Math.sin(rad) }
|
||||||
}
|
}
|
||||||
|
|
||||||
const trackPoints = computed(() => props.history.map(r => polarPoint(r.az, r.el)).map(p => `${p.x},${p.y}`).join(' '))
|
const trackPoints = computed(() => props.history.map(r => polarPoint(r.az, r.el)).map(p => `${p.x},${p.y}`).join(' '))
|
||||||
|
const currentPoint = computed(() => latest.value ? polarPoint(latest.value.az, latest.value.el) : null)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped>
|
||||||
.tinygs-popup { position: fixed; width: 340px; background: var(--surface); border-radius: 12px; padding: 12px; box-shadow: 0 4px 20px rgba(0,0,0,.3); z-index: 1000; }
|
.sat-popup {
|
||||||
.close { position: absolute; top: 8px; right: 8px; background: none; border: none; cursor: pointer; }
|
position: fixed;
|
||||||
.fields { width: 100%; font-size: 13px; margin-bottom: 8px; td { padding: 2px 4px; } }
|
z-index: 100;
|
||||||
.ok { color: #3fdb6d; }
|
pointer-events: auto;
|
||||||
.bad { color: #e0475a; }
|
background: rgba(0,0,0,0.88);
|
||||||
.radar { width: 100%; }
|
border: 1px solid rgba(212,164,255,0.3);
|
||||||
.ring, .axis { fill: none; stroke: var(--border); stroke-width: 1; }
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 4px 24px rgba(0,0,0,0.5);
|
||||||
|
overflow: hidden;
|
||||||
|
font-family: monospace;
|
||||||
|
color: #ccc;
|
||||||
|
min-width: 320px;
|
||||||
|
max-width: 360px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tp-header {
|
||||||
|
position: relative;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-bottom: 1px solid rgba(212,164,255,0.2);
|
||||||
|
cursor: move;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
}
|
||||||
|
.tp-name { margin: 0 24px 0 0; color: #d4a4ff; font-size: 16px; }
|
||||||
|
|
||||||
|
.tp-close {
|
||||||
|
position: absolute;
|
||||||
|
top: 8px;
|
||||||
|
right: 8px;
|
||||||
|
background: rgba(255,255,255,0.2);
|
||||||
|
border: none;
|
||||||
|
color: white;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
.tp-close:hover { background: rgba(255,255,255,0.35); }
|
||||||
|
|
||||||
|
.tp-meta {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #888;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-bottom: 1px solid rgba(212,164,255,0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tp-body { display: flex; gap: 16px; padding: 12px 16px; }
|
||||||
|
|
||||||
|
.tp-gauges { flex: 0 0 110px; }
|
||||||
|
.tp-gauge-wrap { margin-bottom: 12px; }
|
||||||
|
.tp-gauge-label { color: #888; font-size: 11px; font-weight: bold; margin-bottom: 3px; }
|
||||||
|
.tp-gauge {
|
||||||
|
padding: 6px;
|
||||||
|
background: rgba(0,0,0,0.95);
|
||||||
|
border: 2px solid #d4a4ff;
|
||||||
|
border-radius: 3px;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
.tp-gauge-val { font-size: 20px; font-weight: bold; color: #d4a4ff; }
|
||||||
|
.tp-gauge-unit { font-size: 11px; color: #d4a4ff; margin-bottom: 0.75em; }
|
||||||
|
|
||||||
|
.tp-radar { flex: 1; }
|
||||||
|
.ring, .axis { fill: none; stroke: rgba(212,164,255,0.3); stroke-width: 1; }
|
||||||
|
.dir { fill: #d4a4ff; font-size: 10px; font-weight: bold; }
|
||||||
.track { fill: none; stroke: #d4a4ff; stroke-width: 1.5; }
|
.track { fill: none; stroke: #d4a4ff; stroke-width: 1.5; }
|
||||||
.current { fill: #d4a4ff; }
|
.current { fill: #d4a4ff; }
|
||||||
|
|
||||||
|
.tp-fields { width: 100%; font-size: 11px; padding: 0 16px 12px; border-collapse: collapse; }
|
||||||
|
.tp-fields td { padding: 2px 4px; color: #888; }
|
||||||
|
.tp-fields td:last-child { color: #ccc; text-align: right; }
|
||||||
|
.ok { color: #3fdb6d !important; }
|
||||||
|
.bad { color: #e0475a !important; }
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="tinygs-popup" :style="{ left: position.x + 'px', top: position.y + 'px' }" @mousedown="emit('bringToFront')">
|
<div class="sat-popup" :style="{ left: pos.x + 'px', top: pos.y + 'px' }">
|
||||||
<button class="close" @click="onClose">✕</button>
|
|
||||||
<h3>{{ latest?.satellite || 'Unknown' }}</h3>
|
<div ref="header" class="tp-header" @mousedown="onMouseDown" @touchstart.passive="onTouchStart">
|
||||||
<table class="fields">
|
<h3 class="tp-name">🛰️ {{ latest?.satellite || 'Unknown' }}</h3>
|
||||||
<tr><td>Frequency</td><td>{{ latest?.freqMHz }} MHz</td></tr>
|
<button class="tp-close" @click.stop="emit('close')">✕</button>
|
||||||
<tr><td>Packet RSSI</td><td>{{ latest?.packetRssi }} dBm</td></tr>
|
</div>
|
||||||
<tr><td>SNR</td><td>{{ latest?.packetSnr }} dB</td></tr>
|
|
||||||
<tr><td>Freq error</td><td>{{ latest?.freqError }} Hz</td></tr>
|
<div class="tp-meta">
|
||||||
<tr><td>CRC</td><td :class="latest?.crcOk ? 'ok' : 'bad'">{{ latest?.crcOk ? 'OK' : 'ERROR' }}</td></tr>
|
<div class="flex-c">
|
||||||
</table>
|
<span>{{ latest?.freqMHz }} MHz</span>
|
||||||
<svg viewBox="0 0 240 240" class="radar">
|
<span>Range: {{ rangeVal != null ? rangeVal + ' km' : 'Range —' }}</span>
|
||||||
<circle cx="120" cy="120" r="100" class="ring" /><circle cx="120" cy="120" r="66" class="ring" /><circle cx="120" cy="120" r="33" class="ring" />
|
</div>
|
||||||
<line x1="120" y1="20" x2="120" y2="220" class="axis" /><line x1="20" y1="120" x2="220" y2="120" class="axis" />
|
<div class="flex-c align-x-end">
|
||||||
<polyline :points="trackPoints" class="track" />
|
<span>RSSI: {{ latest?.packetRssi }} dBm</span>
|
||||||
<circle v-if="latest" v-bind="polarPoint(latest.az, latest.el)" r="4" class="current" />
|
<span>SNR: {{ latest?.packetSnr }} dB</span>
|
||||||
</svg>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tp-body">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
<div class="tp-gauge-wrap">
|
||||||
|
<div class="tp-gauge-label">Azimuth</div>
|
||||||
|
<div class="tp-gauge">
|
||||||
|
<span class="tp-gauge-val">{{ latest?.az?.toFixed(2) ?? '—' }}</span>
|
||||||
|
<span class="tp-gauge-unit">°</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="tp-gauge-wrap">
|
||||||
|
<div class="tp-gauge-label">Elevation</div>
|
||||||
|
<div class="tp-gauge">
|
||||||
|
<span class="tp-gauge-val">{{ latest?.el?.toFixed(2) ?? '—' }}</span>
|
||||||
|
<span class="tp-gauge-unit">°</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<svg viewBox="0 0 240 240" class="tp-radar">
|
||||||
|
<circle cx="120" cy="120" r="100" class="ring" /><circle cx="120" cy="120" r="66" class="ring" /><circle cx="120" cy="120" r="33" class="ring" />
|
||||||
|
<line x1="120" y1="20" x2="120" y2="220" class="axis" /><line x1="20" y1="120" x2="220" y2="120" class="axis" />
|
||||||
|
<text x="120" y="14" text-anchor="middle" class="dir">N</text>
|
||||||
|
<text x="228" y="124" text-anchor="middle" class="dir">E</text>
|
||||||
|
<text x="120" y="234" text-anchor="middle" class="dir">S</text>
|
||||||
|
<text x="12" y="124" text-anchor="middle" class="dir">W</text>
|
||||||
|
<polyline :points="trackPoints" class="track" />
|
||||||
|
<circle v-if="currentPoint" :cx="currentPoint.x" :cy="currentPoint.y" r="5" class="current" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { BASE } from '@/services/api.ts'
|
import { BASE } from '@/services/api.ts'
|
||||||
import { createApp, ref } from 'vue'
|
import { h, render as vueRender } 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'
|
||||||
@@ -30,12 +30,6 @@ function getAltColor(alt: number): [number, number, number] {
|
|||||||
return [Math.round(128 * r), 0, 139]
|
return [Math.round(128 * r), 0, 139]
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PopupInstance {
|
|
||||||
planeRef: ReturnType<typeof ref>
|
|
||||||
posRef: ReturnType<typeof ref>
|
|
||||||
unmount: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export class AirTrafficLayer {
|
export class AirTrafficLayer {
|
||||||
private map: Map
|
private map: Map
|
||||||
private layer!: VectorLayer<VectorSource>
|
private layer!: VectorLayer<VectorSource>
|
||||||
@@ -167,27 +161,22 @@ export class AirTrafficLayer {
|
|||||||
const icao = plane.icao
|
const icao = plane.icao
|
||||||
if (this.popups[icao]) return
|
if (this.popups[icao]) return
|
||||||
|
|
||||||
const planeRef = ref(plane)
|
|
||||||
const posRef = ref(this._calcPopupPos(plane))
|
|
||||||
|
|
||||||
const container = document.createElement('div')
|
const container = document.createElement('div')
|
||||||
document.body.appendChild(container)
|
document.body.appendChild(container)
|
||||||
|
const mobile = window.innerWidth <= 768
|
||||||
|
|
||||||
const mobile = window.innerWidth <= 768;
|
const makeProps = (p: any) => ({
|
||||||
|
plane: p,
|
||||||
const app = createApp(AircraftPopup, {
|
position: mobile ? { x: window.innerWidth / 2 - 180, y: window.innerHeight - 540 - 16 } : { x: window.innerWidth - 360 - 16, y: 16 },
|
||||||
plane: planeRef.value,
|
|
||||||
position: mobile ? {x: window.innerWidth / 2 - 180, y: window.innerHeight - 540 - 16} : {x: window.innerWidth - 360 - 16, y: 16},
|
|
||||||
onClose: () => this._closePopup(icao),
|
onClose: () => this._closePopup(icao),
|
||||||
onBringToFront: () => bringToFront(container),
|
onBringToFront: () => bringToFront(container),
|
||||||
})
|
})
|
||||||
|
|
||||||
app.mount(container)
|
vueRender(h(AircraftPopup, makeProps(plane)), container)
|
||||||
|
|
||||||
this.popups[icao] = {
|
this.popups[icao] = {
|
||||||
planeRef,
|
update: (p: any) => vueRender(h(AircraftPopup, makeProps(p)), container),
|
||||||
posRef,
|
unmount: () => { vueRender(null, container); container.remove() },
|
||||||
unmount: () => { app.unmount(); container.remove() },
|
|
||||||
}
|
}
|
||||||
this._fetchTrace(plane)
|
this._fetchTrace(plane)
|
||||||
}
|
}
|
||||||
@@ -206,7 +195,7 @@ 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 }
|
||||||
this.popups[icao].planeRef.value = plane
|
this.popups[icao].update(plane)
|
||||||
this._fetchTrace(plane)
|
this._fetchTrace(plane)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { BASE } from '@/services/api.ts'
|
import { BASE } from '@/services/api.ts'
|
||||||
import { createApp, ref } from 'vue'
|
import { h, render as vueRender } 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'
|
||||||
@@ -123,26 +123,22 @@ export class AISLayer {
|
|||||||
const mmsi = String(boat.mmsi)
|
const mmsi = String(boat.mmsi)
|
||||||
if (this.popups[mmsi]) return
|
if (this.popups[mmsi]) return
|
||||||
|
|
||||||
const boatRef = ref(boat)
|
|
||||||
const posRef = ref(this._calcPopupPos(boat))
|
|
||||||
|
|
||||||
const container = document.createElement('div')
|
const container = document.createElement('div')
|
||||||
document.body.appendChild(container)
|
document.body.appendChild(container)
|
||||||
|
const mobile = window.innerWidth <= 768
|
||||||
|
|
||||||
const mobile = window.innerWidth <= 768;
|
const makeProps = (b: any) => ({
|
||||||
|
boat: b,
|
||||||
const app = createApp(ShipPopup, {
|
position: mobile ? { x: window.innerWidth / 2 - 180, y: window.innerHeight - 540 - 16 } : { x: window.innerWidth - 360 - 16, y: 16 },
|
||||||
boat: boatRef.value,
|
|
||||||
position: mobile ? {x: window.innerWidth / 2 - 180, y: window.innerHeight - 540 - 16} : {x: window.innerWidth - 360 - 16, y: 16},
|
|
||||||
onClose: () => this._closePopup(mmsi),
|
onClose: () => this._closePopup(mmsi),
|
||||||
onBringToFront: () => bringToFront(container),
|
onBringToFront: () => bringToFront(container),
|
||||||
})
|
})
|
||||||
|
|
||||||
app.mount(container)
|
vueRender(h(ShipPopup, makeProps(boat)), container)
|
||||||
|
|
||||||
this.popups[mmsi] = {
|
this.popups[mmsi] = {
|
||||||
boatRef,
|
update: (b: any) => vueRender(h(ShipPopup, makeProps(b)), container),
|
||||||
posRef,
|
unmount: () => { vueRender(null, container); container.remove() },
|
||||||
unmount: () => { app.unmount(); container.remove() },
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -155,7 +151,7 @@ export class AISLayer {
|
|||||||
for (const mmsi of Object.keys(this.popups)) {
|
for (const mmsi of Object.keys(this.popups)) {
|
||||||
const boat = this.data.find(b => String(b.mmsi) === mmsi)
|
const boat = this.data.find(b => String(b.mmsi) === mmsi)
|
||||||
if (!boat) { this._closePopup(mmsi); continue }
|
if (!boat) { this._closePopup(mmsi); continue }
|
||||||
this.popups[mmsi].boatRef.value = boat
|
this.popups[mmsi].update(boat)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { BASE } from '@/services/api.ts'
|
import {api, BASE} from '@/services/api.ts';
|
||||||
import { createApp, ref } from 'vue'
|
import { h, render as vueRender } from 'vue'
|
||||||
import Map from 'ol/Map'
|
import Map from 'ol/Map'
|
||||||
import { Feature } from 'ol'
|
import { Feature } from 'ol'
|
||||||
import { LineString, Point } from 'ol/geom'
|
import { LineString, Point, Circle as CircleGeom } from 'ol/geom'
|
||||||
import { fromLonLat } from 'ol/proj'
|
import { fromLonLat } from 'ol/proj'
|
||||||
import { Style, Stroke, Fill, Circle as CircleStyle, Text } from 'ol/style'
|
import { Style, Stroke, Icon, Fill } 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'
|
||||||
@@ -12,6 +12,7 @@ import TinyGSPopup from '@/components/Satellite.vue'
|
|||||||
import { bringToFront } from './zindex'
|
import { bringToFront } from './zindex'
|
||||||
|
|
||||||
const API = BASE + '/api'
|
const API = BASE + '/api'
|
||||||
|
const EARTH_R = 6371 // km
|
||||||
|
|
||||||
interface TinyGSReading {
|
interface TinyGSReading {
|
||||||
timestamp: number
|
timestamp: number
|
||||||
@@ -31,16 +32,68 @@ interface TinyGSSatellite extends TinyGSReading {
|
|||||||
history: TinyGSReading[]
|
history: TinyGSReading[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export class TinyGSLayer {
|
interface RangeEstimate {
|
||||||
private map: Map
|
altitude: number // km, derived from az/el geometry
|
||||||
private layer!: VectorLayer<VectorSource>
|
slantRange: number // km, straight-line distance to satellite
|
||||||
private popups: any = {}
|
horizonRadius: number // km, max ground radius satellite is visible from at this altitude
|
||||||
private data: TinyGSSatellite[] = []
|
groundDist: number // km, great-circle distance to subpoint
|
||||||
private clickHandler: ((e: any) => void) | null = null
|
}
|
||||||
private refreshTimer: any = null
|
|
||||||
visible = false
|
|
||||||
|
|
||||||
constructor(map: Map) { this.map = map }
|
function greatCircleDist(lat1: number, lon1: number, lat2: number, lon2: number): number {
|
||||||
|
const toRad = (d: number) => d * Math.PI / 180
|
||||||
|
const dLat = toRad(lat2 - lat1), dLon = toRad(lon2 - lon1)
|
||||||
|
const a = Math.sin(dLat / 2) ** 2 + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2
|
||||||
|
return EARTH_R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Derives altitude/slant range from ground station + satellite subpoint + observed elevation (no TLE required). */
|
||||||
|
function estimateRange(gsLat: number, gsLon: number, satLat: number, satLon: number, elDeg: number): RangeEstimate | null {
|
||||||
|
const d = greatCircleDist(gsLat, gsLon, satLat, satLon)
|
||||||
|
const gamma = d / EARTH_R
|
||||||
|
const el = elDeg * Math.PI / 180
|
||||||
|
const k = Math.cos(gamma) - Math.tan(el) * Math.sin(gamma)
|
||||||
|
if (k <= 0) return null // inconsistent geometry (noisy reading)
|
||||||
|
|
||||||
|
const altitude = EARTH_R * (1 - k) / k
|
||||||
|
const slantRange = Math.sqrt(EARTH_R ** 2 + (EARTH_R + altitude) ** 2 - 2 * EARTH_R * (EARTH_R + altitude) * Math.cos(gamma))
|
||||||
|
const horizonRadius = Math.sqrt(2 * EARTH_R * altitude + altitude ** 2)
|
||||||
|
return { altitude, slantRange, horizonRadius, groundDist: d }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Linear regression on recent elevation history to project time until satellite sets below horizon (el = 0). */
|
||||||
|
function estimateEtaOut(history: TinyGSReading[]): number | null {
|
||||||
|
const pts = <any>history.slice(-6)
|
||||||
|
if (pts.length < 2) return null
|
||||||
|
|
||||||
|
const t0 = pts[0].timestamp
|
||||||
|
const xs = pts.map(p => (p.timestamp - t0) / 1000)
|
||||||
|
const ys = pts.map(p => p.el)
|
||||||
|
const n = xs.length
|
||||||
|
const sumX = xs.reduce((a, b) => a + b, 0), sumY = ys.reduce((a, b) => a + b, 0)
|
||||||
|
const sumXY = xs.reduce((a, x, i) => a + x * ys[i], 0), sumXX = xs.reduce((a, x) => a + x * x, 0)
|
||||||
|
const slope = (n * sumXY - sumX * sumY) / (n * sumXX - sumX * sumX)
|
||||||
|
if (!isFinite(slope) || slope >= 0) return null // rising or flat, can't predict setting
|
||||||
|
|
||||||
|
const lastEl = ys[ys.length - 1], lastX = xs[xs.length - 1]
|
||||||
|
const secsFromNow = (lastX - lastEl / slope) - lastX
|
||||||
|
return secsFromNow > 0 ? secsFromNow * 1000 : null
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SatsLayer {
|
||||||
|
private map: Map
|
||||||
|
private layer!: VectorLayer<VectorSource>
|
||||||
|
private popups: any = {}
|
||||||
|
private receptionCircles: Record<string, VectorLayer<VectorSource>> = {}
|
||||||
|
private data: TinyGSSatellite[] = []
|
||||||
|
private clickHandler: ((e: any) => void) | null = null
|
||||||
|
private refreshTimer: any = null
|
||||||
|
visible = false
|
||||||
|
gs;
|
||||||
|
|
||||||
|
constructor(map: Map) {
|
||||||
|
this.map = map
|
||||||
|
api.position().then(gs => this.gs = gs);
|
||||||
|
}
|
||||||
|
|
||||||
async show() {
|
async show() {
|
||||||
if (this.visible) return
|
if (this.visible) return
|
||||||
@@ -71,7 +124,7 @@ export class TinyGSLayer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async _fetch() {
|
private async _fetch() {
|
||||||
this.data = await fetch(`${API}/tinygs`).then(r => r.json()) || []
|
this.data = await fetch(`${API}/sats`).then(r => r.json()) || []
|
||||||
}
|
}
|
||||||
|
|
||||||
private _draw() {
|
private _draw() {
|
||||||
@@ -92,60 +145,83 @@ export class TinyGSLayer {
|
|||||||
marker.set('satellite', sat.satellite)
|
marker.set('satellite', sat.satellite)
|
||||||
marker.set('satData', sat)
|
marker.set('satData', sat)
|
||||||
marker.setStyle(new Style({
|
marker.setStyle(new Style({
|
||||||
image: new CircleStyle({
|
image: new Icon({
|
||||||
radius: 6,
|
src: '/satellite.png',
|
||||||
fill: new Fill({ color: sat.crcOk ? '#3fdb6d' : '#e0475a' }),
|
scale: 0.1,
|
||||||
stroke: new Stroke({ color: '#000', width: 1.5 }),
|
anchor: [0.5, 0.5],
|
||||||
}),
|
|
||||||
text: new Text({
|
|
||||||
text: sat.satellite,
|
|
||||||
font: '12px sans-serif',
|
|
||||||
fill: new Fill({ color: '#d4a4ff' }),
|
|
||||||
stroke: new Stroke({ color: '#000000', width: 2 }),
|
|
||||||
offsetY: -14,
|
|
||||||
}),
|
}),
|
||||||
}))
|
}))
|
||||||
source.addFeature(marker)
|
source.addFeature(marker)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private _calcPopupPos(sat: TinyGSSatellite): { x: number; y: number } {
|
private _calcRange(sat: TinyGSSatellite): RangeEstimate | null {
|
||||||
const pixel: any = this.map.getPixelFromCoordinate(fromLonLat([sat.longitude, sat.latitude]))
|
if (!this.gs) return null
|
||||||
if (!pixel) return { x: 10, y: 60 }
|
return estimateRange(this.gs.latitude, this.gs.longitude, sat.latitude, sat.longitude, sat.el)
|
||||||
const rect = (this.map.getTargetElement() as HTMLElement).getBoundingClientRect()
|
}
|
||||||
return { x: rect.left + pixel[0] + 16, y: rect.top + pixel[1] - 16 }
|
|
||||||
|
private _drawReceptionCircle(sat: TinyGSSatellite, radiusKm?: number | null) {
|
||||||
|
this._removeReceptionCircle(sat.satellite)
|
||||||
|
if (!radiusKm) return
|
||||||
|
const feature = new Feature({ geometry: new CircleGeom(fromLonLat([sat.longitude, sat.latitude]), radiusKm * 1000) })
|
||||||
|
feature.setStyle(new Style({
|
||||||
|
stroke: new Stroke({ color: 'rgba(212,164,255,0.6)', width: 2 }),
|
||||||
|
fill: new Fill({ color: 'rgba(212,164,255,0.12)' }),
|
||||||
|
}))
|
||||||
|
const layer = new VectorLayer({ source: new VectorSource({ features: [feature] }), zIndex: 104 })
|
||||||
|
this.map.addLayer(layer)
|
||||||
|
this.receptionCircles[sat.satellite] = layer
|
||||||
|
}
|
||||||
|
|
||||||
|
private _removeReceptionCircle(name: string) {
|
||||||
|
const l = this.receptionCircles[name]
|
||||||
|
if (l) { this.map.removeLayer(l); delete this.receptionCircles[name] }
|
||||||
}
|
}
|
||||||
|
|
||||||
private _openPopup(sat: TinyGSSatellite) {
|
private _openPopup(sat: TinyGSSatellite) {
|
||||||
if (this.popups[sat.satellite]) return
|
if (this.popups[sat.satellite]) return
|
||||||
|
|
||||||
const satRef = ref(sat)
|
const history = [...sat.history, sat]
|
||||||
|
const range = this._calcRange(sat)
|
||||||
|
const eta = estimateEtaOut(history)
|
||||||
|
this._drawReceptionCircle(sat, range?.horizonRadius)
|
||||||
|
|
||||||
const container = document.createElement('div')
|
const container = document.createElement('div')
|
||||||
document.body.appendChild(container)
|
document.body.appendChild(container)
|
||||||
|
|
||||||
const mobile = window.innerWidth <= 768
|
const mobile = window.innerWidth <= 768
|
||||||
|
|
||||||
const app = createApp(TinyGSPopup, {
|
const makeProps = (h_: any, r: any, e: any) => ({
|
||||||
satellite: satRef.value,
|
history: h_,
|
||||||
|
range: r,
|
||||||
|
eta: e,
|
||||||
position: mobile ? { x: window.innerWidth / 2 - 180, y: window.innerHeight - 540 - 16 } : { x: window.innerWidth - 360 - 16, y: 16 },
|
position: mobile ? { x: window.innerWidth / 2 - 180, y: window.innerHeight - 540 - 16 } : { x: window.innerWidth - 360 - 16, y: 16 },
|
||||||
onClose: () => this._closePopup(sat.satellite),
|
onClose: () => this._closePopup(sat.satellite),
|
||||||
onBringToFront: () => bringToFront(container),
|
onBringToFront: () => bringToFront(container),
|
||||||
})
|
})
|
||||||
|
|
||||||
app.mount(container)
|
vueRender(h(TinyGSPopup, makeProps(history, range, eta)), container)
|
||||||
this.popups[sat.satellite] = { satRef, unmount: () => { app.unmount(); container.remove() } }
|
|
||||||
|
this.popups[sat.satellite] = {
|
||||||
|
container,
|
||||||
|
update: (h_: any, r: any, e: any) => vueRender(h(TinyGSPopup, makeProps(h_, r, e)), container),
|
||||||
|
unmount: () => { vueRender(null, container); container.remove() },
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private _closePopup(name: string) {
|
private _closePopup(name: string) {
|
||||||
const popup = this.popups[name]
|
const popup = this.popups[name]
|
||||||
if (popup) { popup.unmount(); delete this.popups[name] }
|
if (popup) { popup.unmount(); delete this.popups[name] }
|
||||||
|
this._removeReceptionCircle(name)
|
||||||
}
|
}
|
||||||
|
|
||||||
private _refreshPopups() {
|
private _refreshPopups() {
|
||||||
for (const name of Object.keys(this.popups)) {
|
for (const name of Object.keys(this.popups)) {
|
||||||
const sat = this.data.find(s => s.satellite === name)
|
const sat = this.data.find(s => s.satellite === name)
|
||||||
if (!sat) { this._closePopup(name); continue }
|
if (!sat) { this._closePopup(name); continue }
|
||||||
this.popups[name].satRef.value = sat
|
const history = [...sat.history, sat]
|
||||||
|
const range = this._calcRange(sat)
|
||||||
|
this.popups[name].update(history, range, estimateEtaOut(history))
|
||||||
|
this._drawReceptionCircle(sat, range?.horizonRadius)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import {cfg} from './config.mjs';
|
import {cfg} from './config.mjs';
|
||||||
import {isEqual} from '@ztimson/utils';
|
import {isEqual} from '@ztimson/utils';
|
||||||
|
|
||||||
const HISTORY_LIMIT = 500
|
const HISTORY_LIMIT = 500;
|
||||||
const POLL_MS = 5_000
|
const POLL_MS = 5_000;
|
||||||
const TTL_MS = 15 * 60_000 // drop a satellite's bucket if nothing new for 15 min
|
const TTL_MS = 60_000;
|
||||||
|
|
||||||
function parseWm(raw) {
|
function parseWm(raw) {
|
||||||
const f = raw.replace(/<[^>]+>/g, '').split(',').map(s => s.trim());
|
const f = raw.replace(/<[^>]+>/g, '').split(',').map(s => s.trim());
|
||||||
|
|||||||
Reference in New Issue
Block a user