Compare commits
38
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cf693e6f08 | ||
|
|
4520447156 | ||
|
|
534dc6d2df | ||
|
|
4c7a7fd1f5 | ||
|
|
28478251cd | ||
|
|
1cf7545c93 | ||
|
|
6e21a8626c | ||
|
|
9f65318aee | ||
|
|
5ac3871da0 | ||
|
|
eaef6e8066 | ||
|
|
a0c15f1631 | ||
|
|
d57faa486c | ||
|
|
3c6cc71346 | ||
|
|
a255638f73 | ||
|
|
746ec877da | ||
|
|
8eb3e49308 | ||
|
|
1d1e7351db | ||
|
|
0e152b4dc7 | ||
|
|
fca0e942e7 | ||
|
|
6c4310753f | ||
|
|
0ebf997924 | ||
|
|
2b299753ca | ||
|
|
83c48bf3e7 | ||
|
|
a99cbf3354 | ||
|
|
a60f605e6b | ||
|
|
6bcc5bec19 | ||
|
|
fc306f277a | ||
|
|
396a1f4cab | ||
|
|
b86e4613bf | ||
|
|
a0cfe64188 | ||
|
|
033e34a971 | ||
|
|
688552d0d3 | ||
|
|
7cc60afbae | ||
|
|
1db910cb4a | ||
|
|
2b31bdde8f | ||
|
|
79acad8c07 | ||
|
|
2c25251864 | ||
|
|
9e1ef69b16 |
@@ -0,0 +1,2 @@
|
||||
User-Agent: *
|
||||
Allow: /
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 30 KiB |
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import {BASE} from '@/services/api.ts';
|
||||
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import AircraftAltitude from '@/components/Altitude.vue'
|
||||
|
||||
const BASE_SPEED_UNITS = {
|
||||
knots: { label: 'KTS', convert: (v: number) => Math.round(v) },
|
||||
@@ -23,10 +24,9 @@ function loadUnits() {
|
||||
return s ? JSON.parse(s) : { speed: 'knots', altitude: 'meters', vertical: 'mps' }
|
||||
}
|
||||
|
||||
const props = defineProps<{ plane: any; position: { x: number; y: number } }>()
|
||||
const props = defineProps<{ plane: any; position: { x: number; y: number }; history?: any[] }>()
|
||||
const emit = defineEmits<{ (e: 'close'): void; (e: 'bringToFront'): void }>()
|
||||
|
||||
// ── Units ─────────────────────────────────────────────────────────────────────
|
||||
const prefs = ref(loadUnits())
|
||||
function saveAndSet(p: any) {
|
||||
prefs.value = { ...p }
|
||||
@@ -40,17 +40,21 @@ const speed = computed(() => BASE_SPEED_UNITS[prefs.value.speed as keyo
|
||||
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 description = computed(() => props.plane.desc || [props.plane.manufacturer, props.plane.model].filter(Boolean).join(' ') || props.plane.aircraft || '')
|
||||
const operator = computed(() => props.plane.operator || props.plane.owner || '')
|
||||
|
||||
// ── Drag ──────────────────────────────────────────────────────────────────────
|
||||
const altitudeHistory = computed(() => (props.history || [])
|
||||
.filter(p => !p.live && p.ts != null)
|
||||
.map(p => ({...p, altitude: altitude.value.convert(Number(p.altitude))})))
|
||||
|
||||
const currentAltitude = computed(() => props.plane.landed ? null : altitude.value.convert(Number(altitudeVal.value)))
|
||||
|
||||
const pos = ref({ ...props.position })
|
||||
const isDragging = ref(false)
|
||||
const dragStart = ref({ mx: 0, my: 0, ex: 0, ey: 0 })
|
||||
@@ -110,7 +114,6 @@ onUnmounted(() => {
|
||||
document.removeEventListener('touchcancel', onTouchEnd)
|
||||
})
|
||||
|
||||
// ── Navball ───────────────────────────────────────────────────────────────────
|
||||
const navball = computed(() => {
|
||||
const data = props.plane
|
||||
const heading = data.heading ?? data.track ?? 0
|
||||
@@ -191,75 +194,13 @@ const navball = computed(() => {
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="aircraft-popup" :style="{ left: pos.x + 'px', top: pos.y + 'px' }">
|
||||
|
||||
<!-- Header (drag handle) -->
|
||||
<div ref="header" class="ap-header" @mousedown="onMouseDown" @touchstart.passive="onTouchStart">
|
||||
<h3 class="ap-callsign">
|
||||
✈️
|
||||
<a v-if="callsign" :href="`https://www.flightaware.com/live/flight/${callsign}`" target="_blank" @click.stop>{{ callsign.toUpperCase() }}</a>
|
||||
<span v-else>N/A</span>
|
||||
<span style="color: white"> / {{ (plane.icao || plane.hex || '').toUpperCase() }}</span>
|
||||
</h3>
|
||||
<button class="ap-close" @click.stop="emit('close')">×</button>
|
||||
</div>
|
||||
|
||||
<!-- Meta -->
|
||||
<div class="ap-meta flex-r justify-between">
|
||||
<div class="flex-c">
|
||||
<span>{{ operator || 'Unknown Owner' }}</span>
|
||||
<span>{{ plane.country || 'Unknown Country' }}</span>
|
||||
</div>
|
||||
<div class="flex-c align-x-end">
|
||||
<span>{{ description || 'Unknown Aircraft' }}</span>
|
||||
<span style="text-transform: capitalize">{{ plane.class || 'Unknown' }} • {{ plane.type || 'Unknown' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Photo -->
|
||||
<div v-if="!photoError" class="ap-photo-wrap">
|
||||
<img class="ap-photo" :src="BASE + '/api/adsb-image/' + plane.icao" :alt="callsign" @error="photoError = true" />
|
||||
</div>
|
||||
|
||||
<!-- Body -->
|
||||
<div class="ap-body">
|
||||
<div class="ap-gauges">
|
||||
<div class="ap-gauge-wrap">
|
||||
<div class="ap-gauge-label">Air Speed</div>
|
||||
<div class="ap-gauge" @click.stop="cycleSpeed">
|
||||
<span class="ap-gauge-val">{{ speedVal }}</span>
|
||||
<span class="ap-gauge-unit">{{ speed.label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ap-gauge-wrap">
|
||||
<div class="ap-gauge-label">Altitude</div>
|
||||
<div class="ap-gauge" @click.stop="cycleAltitude">
|
||||
<span class="ap-gauge-val">{{ altVal }}</span>
|
||||
<span class="ap-gauge-unit">{{ altUnit }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ap-gauge-wrap">
|
||||
<div class="ap-gauge-label">Climb</div>
|
||||
<div class="ap-gauge" @click.stop="cycleVertical">
|
||||
<span class="ap-gauge-val">{{ climbVal }}</span>
|
||||
<span class="ap-gauge-unit">{{ vertical.label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ap-navball" v-html="navball" />
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.aircraft-popup {
|
||||
position: fixed;
|
||||
z-index: 100;
|
||||
pointer-events: auto;
|
||||
background: rgba(0,0,0,0.88);
|
||||
border: 1px solid rgba(200,200,220,0.3);
|
||||
border: 1px solid rgba(0, 255, 0, 0.3);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 24px rgba(0,0,0,0.5);
|
||||
overflow: hidden;
|
||||
@@ -272,7 +213,7 @@ const navball = computed(() => {
|
||||
.ap-header {
|
||||
position: relative;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid rgba(200,200,220,0.2);
|
||||
border-bottom: 1px solid rgba(0, 255, 0, 0.2);
|
||||
cursor: move;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
@@ -315,7 +256,6 @@ const navball = computed(() => {
|
||||
}
|
||||
.ap-close:hover { background: rgba(255,255,255,0.35); }
|
||||
|
||||
/* ── Photo ── */
|
||||
.ap-photo-wrap {
|
||||
max-width: 360px;
|
||||
max-height: 160px;
|
||||
@@ -328,6 +268,7 @@ const navball = computed(() => {
|
||||
}
|
||||
.ap-photo { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||
.ap-photo--sil { object-fit: contain; box-sizing: border-box; image-rendering: auto; }
|
||||
|
||||
.ap-body {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
@@ -361,3 +302,67 @@ const navball = computed(() => {
|
||||
.ap-gauge-unit { font-size: 11px; color: #0f0; margin-bottom: 2px; }
|
||||
.ap-navball { flex: 1; margin-top: 1rem; }
|
||||
</style>
|
||||
|
||||
<template>
|
||||
<div class="aircraft-popup" :style="{ left: pos.x + 'px', top: pos.y + 'px' }">
|
||||
|
||||
<div ref="header" class="ap-header" @mousedown="onMouseDown" @touchstart.passive="onTouchStart">
|
||||
<h3 class="ap-callsign">
|
||||
✈️
|
||||
<a v-if="callsign" :href="`https://www.flightaware.com/live/flight/${callsign}`" target="_blank" @click.stop>{{ callsign.toUpperCase() }}</a>
|
||||
<span v-else>N/A</span>
|
||||
<span style="color: white"> / {{ (plane.icao || plane.hex || '').toUpperCase() }}</span>
|
||||
</h3>
|
||||
<button class="ap-close" @click.stop="emit('close')">×</button>
|
||||
</div>
|
||||
|
||||
<div class="ap-meta flex-r justify-between">
|
||||
<div class="flex-c flex-fill">
|
||||
<span>{{ operator || 'Unknown Owner' }}</span>
|
||||
<span>{{ plane.country || 'Unknown Country' }}</span>
|
||||
</div>
|
||||
<div class="flex-c flex-fill align-x-end">
|
||||
<span>{{ description || 'Unknown Aircraft' }}</span>
|
||||
<span style="text-transform: capitalize">{{ plane.class || 'Unknown' }} • {{ plane.type || 'Unknown' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!photoError" class="ap-photo-wrap">
|
||||
<img class="ap-photo" :src="BASE + '/api/adsb/' + plane.icao + '/image'" :alt="callsign" @error="photoError = true" />
|
||||
</div>
|
||||
|
||||
<div class="ap-body">
|
||||
<div class="ap-gauges">
|
||||
<div class="ap-gauge-wrap">
|
||||
<div class="ap-gauge-label">Air Speed</div>
|
||||
<div class="ap-gauge" @click.stop="cycleSpeed">
|
||||
<span class="ap-gauge-val">{{ speedVal }}</span>
|
||||
<span class="ap-gauge-unit">{{ speed.label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ap-gauge-wrap">
|
||||
<div class="ap-gauge-label">Altitude</div>
|
||||
<div class="ap-gauge" @click.stop="cycleAltitude">
|
||||
<span class="ap-gauge-val">{{ altVal }}</span>
|
||||
<span class="ap-gauge-unit">{{ altUnit }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ap-gauge-wrap">
|
||||
<div class="ap-gauge-label">Climb</div>
|
||||
<div class="ap-gauge" @click.stop="cycleVertical">
|
||||
<span class="ap-gauge-val">{{ climbVal }}</span>
|
||||
<span class="ap-gauge-unit">{{ vertical.label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ap-navball" v-html="navball" />
|
||||
</div>
|
||||
|
||||
<AircraftAltitude
|
||||
:history="altitudeHistory"
|
||||
:current-altitude="currentAltitude"
|
||||
:unit="altitude.label"
|
||||
/>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
<script setup lang="ts">
|
||||
import {computed} from 'vue'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
history: any[]
|
||||
currentAltitude: number | null
|
||||
color?: string,
|
||||
unit: string,
|
||||
startTime?: string,
|
||||
endTime?: string,
|
||||
}>(), {
|
||||
color: '#0f0'
|
||||
});
|
||||
|
||||
const WIDTH = 320
|
||||
const HEIGHT = 120
|
||||
const PAD = {top: 10, right: 12, bottom: 24, left: 42}
|
||||
|
||||
const points = computed(() => {
|
||||
const history = [...(props.history || [])]
|
||||
.filter(p => p.altitude != null && p.altitude !== 0 && !p.live)
|
||||
.sort((a, b) => {
|
||||
if (a.ts == null || b.ts == null) return 0
|
||||
return Number(a.ts) - Number(b.ts)
|
||||
})
|
||||
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
|
||||
const points = history.map((p, i) => ({
|
||||
...p,
|
||||
ts: p.ts != null ? Number(p.ts) : now - (history.length - i - 1),
|
||||
}))
|
||||
|
||||
if (props.currentAltitude != null && points.length) {
|
||||
const last = points[points.length - 1]
|
||||
points.push({
|
||||
ts: Math.max(last.ts + 1, now),
|
||||
altitude: props.currentAltitude,
|
||||
live: true,
|
||||
})
|
||||
}
|
||||
|
||||
return points
|
||||
})
|
||||
|
||||
const graph = computed(() => {
|
||||
const pts = points.value
|
||||
if (pts.length < 2) return null
|
||||
|
||||
const minTs = Number(pts[0].ts)
|
||||
const maxTs = Number(pts[pts.length - 1].ts)
|
||||
const rawAltitudes = pts.map(p => Number(p.altitude))
|
||||
const minAlt = Math.min(...rawAltitudes)
|
||||
const maxAlt = Math.max(...rawAltitudes)
|
||||
const altRange = Math.max(maxAlt - minAlt, 1)
|
||||
const timeRange = Math.max(maxTs - minTs, 1)
|
||||
|
||||
const x = (ts: number) => PAD.left + ((ts - minTs) / timeRange) * (WIDTH - PAD.left - PAD.right)
|
||||
const y = (alt: number) => PAD.top + (1 - (alt - minAlt) / altRange) * (HEIGHT - PAD.top - PAD.bottom)
|
||||
|
||||
const line = pts.map(p => `${x(Number(p.ts)).toFixed(1)},${y(Number(p.altitude)).toFixed(1)}`).join(' ')
|
||||
const latest = pts[pts.length - 1]
|
||||
const latestX = x(Number(latest.ts))
|
||||
const latestY = y(Number(latest.altitude))
|
||||
|
||||
const formatTime = (ts: number) => {
|
||||
const d = new Date(ts * 1000)
|
||||
return d.toLocaleTimeString([], {hour: '2-digit', minute: '2-digit'})
|
||||
}
|
||||
|
||||
return {
|
||||
line,
|
||||
latestX,
|
||||
latestY,
|
||||
minAlt,
|
||||
maxAlt,
|
||||
startTime: formatTime(minTs),
|
||||
endTime: formatTime(maxTs),
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.altitude-graph {
|
||||
padding: 0 16px 12px;
|
||||
}
|
||||
|
||||
.altitude-graph-title {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
color: #888;
|
||||
font-size: 11px;
|
||||
font-weight: bold;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.altitude-graph svg {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 120px;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.altitude-graph text {
|
||||
fill: #777;
|
||||
font-family: monospace;
|
||||
font-size: 9px;
|
||||
}
|
||||
</style>
|
||||
|
||||
<template>
|
||||
<div v-if="graph" class="altitude-graph">
|
||||
<div class="altitude-graph-title">
|
||||
<span>ALTITUDE</span>
|
||||
<span>{{ unit }}</span>
|
||||
</div>
|
||||
|
||||
<svg :viewBox="`0 0 ${WIDTH} ${HEIGHT}`" preserveAspectRatio="none">
|
||||
<line
|
||||
:x1="PAD.left"
|
||||
:y1="PAD.top"
|
||||
:x2="PAD.left"
|
||||
:y2="HEIGHT - PAD.bottom"
|
||||
stroke="rgba(255,255,255,0.2)"
|
||||
/>
|
||||
|
||||
<line
|
||||
:x1="PAD.left"
|
||||
:y1="HEIGHT - PAD.bottom"
|
||||
:x2="WIDTH - PAD.right"
|
||||
:y2="HEIGHT - PAD.bottom"
|
||||
stroke="rgba(255,255,255,0.2)"
|
||||
/>
|
||||
|
||||
<text :x="PAD.left - 5" :y="PAD.top + 4" text-anchor="end">
|
||||
{{ graph.maxAlt }}
|
||||
</text>
|
||||
|
||||
<text :x="PAD.left - 5" :y="HEIGHT - PAD.bottom + 4" text-anchor="end">
|
||||
{{ graph.minAlt }}
|
||||
</text>
|
||||
|
||||
<polyline
|
||||
:points="graph.line"
|
||||
fill="none"
|
||||
:stroke="color"
|
||||
stroke-width="2"
|
||||
/>
|
||||
|
||||
<circle
|
||||
:cx="graph.latestX"
|
||||
:cy="graph.latestY"
|
||||
r="3"
|
||||
:fill="color"
|
||||
/>
|
||||
|
||||
<text :x="PAD.left" :y="HEIGHT - 6">
|
||||
{{ startTime || graph.startTime }}
|
||||
</text>
|
||||
|
||||
<text :x="WIDTH - PAD.right" :y="HEIGHT - 6" text-anchor="end">
|
||||
{{ endTime || graph.endTime }}
|
||||
</text>
|
||||
</svg>
|
||||
</div>
|
||||
</template>
|
||||
+596
-177
@@ -1,82 +1,241 @@
|
||||
<script setup lang="ts">
|
||||
import { AirTrafficLayer } from '@/services/adsb.ts'
|
||||
import { AISLayer } from '@/services/ais.ts'
|
||||
import { api } from '@/services/api.ts'
|
||||
import { RangeLayer } from '@/services/range.ts'
|
||||
import VectorTileLayer from 'ol/layer/VectorTile'
|
||||
import { onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import Map from 'ol/Map'
|
||||
import View from 'ol/View'
|
||||
import TileLayer from 'ol/layer/Tile'
|
||||
import VectorLayer from 'ol/layer/Vector'
|
||||
import VectorSource from 'ol/source/Vector'
|
||||
import XYZ from 'ol/source/XYZ'
|
||||
import Feature from 'ol/Feature'
|
||||
import Point from 'ol/geom/Point'
|
||||
import CircleGeom from 'ol/geom/Circle'
|
||||
import { defaults as defaultInteractions } from 'ol/interaction'
|
||||
import { fromLonLat, toLonLat } from 'ol/proj'
|
||||
import { Style, Fill, Stroke, Circle as CircleStyle } from 'ol/style'
|
||||
import { applyStyle } from 'ol-mapbox-style'
|
||||
import 'ol/ol.css'
|
||||
import {AirTrafficLayer} from '@/services/adsb.ts';
|
||||
import {AISLayer} from '@/services/ais.ts';
|
||||
import {api} from '@/services/api.ts';
|
||||
import {RangeLayer} from '@/services/range.ts';
|
||||
import {SatsLayer} from '@/services/sats.ts';
|
||||
import VectorTileLayer from 'ol/layer/VectorTile';
|
||||
import {onMounted, onUnmounted, ref, watch} from 'vue';
|
||||
import Map from 'ol/Map';
|
||||
import View from 'ol/View';
|
||||
import TileLayer from 'ol/layer/Tile';
|
||||
import VectorLayer from 'ol/layer/Vector';
|
||||
import VectorSource from 'ol/source/Vector';
|
||||
import XYZ from 'ol/source/XYZ';
|
||||
import Feature from 'ol/Feature';
|
||||
import Point from 'ol/geom/Point';
|
||||
import CircleGeom from 'ol/geom/Circle';
|
||||
import {defaults as defaultInteractions} from 'ol/interaction';
|
||||
import {fromLonLat, toLonLat} from 'ol/proj';
|
||||
import {Style, Fill, Stroke, Circle as CircleStyle} from 'ol/style';
|
||||
import {applyStyle} from 'ol-mapbox-style';
|
||||
import {Aurora} from '@/services/aurora.ts';
|
||||
import {SondesLayer} from '@/services/sondes.ts';
|
||||
import 'ol/ol.css';
|
||||
|
||||
const current = ref({ latitude: 0, longitude: 0 })
|
||||
const props = defineProps<{ dark: boolean }>()
|
||||
const mapEl = ref<HTMLDivElement>()
|
||||
const showOverlays = ref(false)
|
||||
const atActive = ref(true)
|
||||
const current = ref({latitude: 0, longitude: 0});
|
||||
const props = defineProps<{dark: boolean}>();
|
||||
const mapEl = ref<HTMLDivElement>();
|
||||
const showOverlays = ref(false);
|
||||
const openMenu = ref<string | null>(null);
|
||||
const NM = 1852;
|
||||
const showWind = ref(false);
|
||||
const windSrc = ref('');
|
||||
|
||||
const NM = 1852
|
||||
const OVERLAY_GROUPS = [
|
||||
{
|
||||
id: 'traffic',
|
||||
label: 'Traffic',
|
||||
icon: '✈️',
|
||||
items: [
|
||||
{id: 'aircraft', label: 'Aircraft', icon: '✈️'},
|
||||
{id: 'marine', label: 'Marine', icon: '🚢'},
|
||||
{id: 'satellites', label: 'Satellites', icon: '🛰️'},
|
||||
{id: 'sondes', label: 'Weather Balloons', icon: '️🎈'},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'weather',
|
||||
label: 'Weather',
|
||||
icon: '🌦️',
|
||||
items: [
|
||||
{id: 'aurora', label: 'Aurora', icon: '🌌'},
|
||||
{id: 'rain', label: 'Rain', icon: '🌧️'},
|
||||
{id: 'wind', label: 'Wind', icon: '💨'},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const showWind = ref(false)
|
||||
const windSrc = ref('')
|
||||
const DEFAULT_OVERLAYS = ['aircraft', 'marine', 'satellites', 'rain'];
|
||||
const activeOverlays = ref<Set<string>>(new Set(DEFAULT_OVERLAYS));
|
||||
const overlayLayers: {[key: string]: any} = {};
|
||||
|
||||
const OVERLAYS = [
|
||||
{ id: 'rain', label: 'Rain', icon: '🌧️' },
|
||||
{ id: 'wind', label: 'Wind', icon: '💨' },
|
||||
]
|
||||
const MAP_STATE_KEY = 'situation-center-map-state';
|
||||
const MAP_LAYERS_KEY = 'situation-center-map-layers';
|
||||
const AUTO_MODE_KEY = 'situation-center-auto-mode';
|
||||
const AUTO_INTERVAL_MS = 15_000;
|
||||
|
||||
const activeOverlays = ref<Set<string>>(new Set(['rain']))
|
||||
const overlayLayers: { [key: string]: any } = {}
|
||||
const AUTO_CATEGORIES = ['sondes', 'aircraft', 'marine', 'satellites'] as const;
|
||||
type AutoCategory = typeof AUTO_CATEGORIES[number];
|
||||
|
||||
let map: Map
|
||||
let stationLayer: VectorLayer<VectorSource>
|
||||
let radarInterval: ReturnType<typeof setInterval>
|
||||
let airTraffic: AirTrafficLayer
|
||||
let aisLayer: AISLayer
|
||||
let range: RangeLayer
|
||||
interface AutoObject {
|
||||
category: AutoCategory;
|
||||
id: string;
|
||||
score: number;
|
||||
}
|
||||
|
||||
let map: Map;
|
||||
let stationLayer: VectorLayer<VectorSource>;
|
||||
let radarInterval: ReturnType<typeof setInterval>;
|
||||
let autoInterval: ReturnType<typeof setInterval>;
|
||||
let airTraffic: AirTrafficLayer;
|
||||
let aisLayer: AISLayer;
|
||||
let range: RangeLayer;
|
||||
let sats: SatsLayer;
|
||||
let sondes: SondesLayer;
|
||||
let aurora: Aurora;
|
||||
|
||||
const autoMode = ref(false);
|
||||
const autoCategory = ref<AutoCategory | null>(null);
|
||||
const autoTarget = ref<any>(null);
|
||||
let autoTargetKey: string | null = null;
|
||||
|
||||
// Per-category lookup so auto-mode logic never branches on category directly.
|
||||
const AUTO_CONFIG: Record<AutoCategory, {
|
||||
layer: () => any;
|
||||
id: (target: any) => string;
|
||||
position: (target: any) => [number, number] | null;
|
||||
zoom: number;
|
||||
}> = {
|
||||
sondes: {
|
||||
layer: () => sondes,
|
||||
id: t => String(t.id),
|
||||
position: t => t.latitude != null && t.longitude != null ? [t.longitude, t.latitude] : null,
|
||||
zoom: 8,
|
||||
},
|
||||
aircraft: {
|
||||
layer: () => airTraffic,
|
||||
id: t => String(t.icao),
|
||||
position: t => t.latitude != null && t.longitude != null ? [t.longitude, t.latitude] : null,
|
||||
zoom: 10,
|
||||
},
|
||||
marine: {
|
||||
layer: () => aisLayer,
|
||||
id: t => String(t.mmsi),
|
||||
position: t => t.lat != null && t.lon != null ? [t.lon, t.lat] : null,
|
||||
zoom: 12,
|
||||
},
|
||||
satellites: {
|
||||
layer: () => sats,
|
||||
id: t => String(t.id),
|
||||
position: t => t.latitude != null && t.longitude != null ? [t.longitude, t.latitude] : null,
|
||||
zoom: 6,
|
||||
},
|
||||
};
|
||||
|
||||
function loadMapState() {
|
||||
try {
|
||||
const raw = localStorage.getItem(MAP_STATE_KEY);
|
||||
if (!raw) return null;
|
||||
|
||||
const state = JSON.parse(raw);
|
||||
|
||||
if (
|
||||
typeof state.latitude !== 'number' ||
|
||||
typeof state.longitude !== 'number' ||
|
||||
typeof state.zoom !== 'number'
|
||||
) return null;
|
||||
|
||||
return state;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function saveMapState() {
|
||||
if (!map) return;
|
||||
|
||||
try {
|
||||
const view = map.getView();
|
||||
const center = view.getCenter();
|
||||
if (!center) return;
|
||||
|
||||
const [longitude, latitude] = toLonLat(center);
|
||||
const zoom = view.getZoom();
|
||||
if (typeof zoom !== 'number') return;
|
||||
|
||||
localStorage.setItem(MAP_STATE_KEY, JSON.stringify({latitude, longitude, zoom}));
|
||||
} catch {
|
||||
// Ignore localStorage failures.
|
||||
}
|
||||
}
|
||||
|
||||
function loadLayers() {
|
||||
try {
|
||||
const raw = localStorage.getItem(MAP_LAYERS_KEY);
|
||||
if (!raw) return new Set(DEFAULT_OVERLAYS);
|
||||
|
||||
const layers = JSON.parse(raw);
|
||||
if (!Array.isArray(layers)) return new Set(DEFAULT_OVERLAYS);
|
||||
|
||||
const validLayers = OVERLAY_GROUPS.flatMap(group => group.items.map(item => item.id));
|
||||
|
||||
return new Set(
|
||||
layers.filter((id: unknown): id is string =>
|
||||
typeof id === 'string' && validLayers.includes(id),
|
||||
),
|
||||
);
|
||||
} catch {
|
||||
return new Set(DEFAULT_OVERLAYS);
|
||||
}
|
||||
}
|
||||
|
||||
function saveLayers() {
|
||||
try {
|
||||
localStorage.setItem(MAP_LAYERS_KEY, JSON.stringify([...activeOverlays.value]));
|
||||
} catch {
|
||||
// Ignore localStorage failures.
|
||||
}
|
||||
}
|
||||
|
||||
function loadAutoMode() {
|
||||
try {
|
||||
return localStorage.getItem(AUTO_MODE_KEY) === 'true';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function saveAutoMode() {
|
||||
try {
|
||||
localStorage.setItem(AUTO_MODE_KEY, String(autoMode.value));
|
||||
} catch {
|
||||
// Ignore localStorage failures.
|
||||
}
|
||||
}
|
||||
|
||||
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`;
|
||||
}
|
||||
|
||||
function syncWindSrc() {
|
||||
const view = map.getView()
|
||||
const center: any = toLonLat(view.getCenter()!)
|
||||
windSrc.value = buildWindSrc(center[1], center[0], view.getZoom() ?? 8)
|
||||
const view = map.getView();
|
||||
const center: any = toLonLat(view.getCenter()!);
|
||||
windSrc.value = buildWindSrc(center[1], center[0], view.getZoom() ?? 8);
|
||||
}
|
||||
|
||||
function showWindOverlay() {
|
||||
syncWindSrc()
|
||||
showWind.value = true
|
||||
map.getInteractions().forEach(i => i.setActive(false))
|
||||
syncWindSrc();
|
||||
showWind.value = true;
|
||||
map.getInteractions().forEach(i => i.setActive(false));
|
||||
}
|
||||
|
||||
function hideWindOverlay() {
|
||||
showWind.value = false
|
||||
map.getInteractions().forEach(i => i.setActive(true))
|
||||
showWind.value = false;
|
||||
map.getInteractions().forEach(i => i.setActive(true));
|
||||
}
|
||||
|
||||
async function fetchRadarUrl(): Promise<string | null> {
|
||||
try {
|
||||
const res = await fetch('https://api.rainviewer.com/public/weather-maps.json')
|
||||
const data = await res.json()
|
||||
const past = data.radar?.past ?? []
|
||||
const latest = past[past.length - 1]
|
||||
if (!latest) return null
|
||||
return `https://tilecache.rainviewer.com${latest.path}/256/{z}/{x}/{y}/2/1_1.png`
|
||||
const res = await fetch('https://api.rainviewer.com/public/weather-maps.json');
|
||||
const data = await res.json();
|
||||
const past = data.radar?.past ?? [];
|
||||
const latest = past[past.length - 1];
|
||||
if (!latest) return null;
|
||||
|
||||
return `https://tilecache.rainviewer.com${latest.path}/256/{z}/{x}/{y}/2/1_1.png`;
|
||||
} catch {
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,139 +245,273 @@ function buildRainLayer(url: string): TileLayer<XYZ> {
|
||||
url,
|
||||
maxZoom: 7,
|
||||
tileLoadFunction: (tile: any, src: string) => {
|
||||
const img = tile.getImage()
|
||||
img.onerror = () => tile.setState(3)
|
||||
img.src = src
|
||||
const img = tile.getImage();
|
||||
img.onerror = () => tile.setState(3);
|
||||
img.src = src;
|
||||
},
|
||||
}),
|
||||
opacity: 0.2,
|
||||
zIndex: 5,
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
function removeRainLayer() {
|
||||
if (!overlayLayers['rain']) return;
|
||||
map.removeLayer(overlayLayers['rain']);
|
||||
delete overlayLayers['rain'];
|
||||
}
|
||||
|
||||
async function refreshRain() {
|
||||
const url = await fetchRadarUrl()
|
||||
if (!url) return
|
||||
const url = await fetchRadarUrl();
|
||||
removeRainLayer();
|
||||
if (!url || !activeOverlays.value.has('rain')) return;
|
||||
|
||||
if (overlayLayers['rain']) map.removeLayer(overlayLayers['rain'])
|
||||
if (!activeOverlays.value.has('rain')) return
|
||||
|
||||
const layer = buildRainLayer(url)
|
||||
overlayLayers['rain'] = layer
|
||||
map.addLayer(layer)
|
||||
const layer = buildRainLayer(url);
|
||||
overlayLayers['rain'] = layer;
|
||||
map.addLayer(layer);
|
||||
}
|
||||
|
||||
function toggleAT() {
|
||||
if (atActive.value) {
|
||||
aisLayer.hide()
|
||||
airTraffic.hide()
|
||||
range.hide()
|
||||
} else {
|
||||
aisLayer.show()
|
||||
airTraffic.show()
|
||||
range.show()
|
||||
}
|
||||
atActive.value = !atActive.value
|
||||
}
|
||||
// One handler per overlay id — toggleOverlay/restoreOverlayState just drive these.
|
||||
const OVERLAY_HANDLERS: Record<string, {show: () => void; hide: () => void}> = {
|
||||
aircraft: {show: () => { airTraffic.show(); range.show(); }, hide: () => { airTraffic.hide(); range.hide(); }},
|
||||
marine: {show: () => aisLayer.show(), hide: () => aisLayer.hide()},
|
||||
satellites: {show: () => sats.show(), hide: () => sats.hide()},
|
||||
sondes: {show: () => sondes.show(), hide: () => sondes.hide()},
|
||||
aurora: {show: () => aurora.show(), hide: () => aurora.hide()},
|
||||
wind: {show: showWindOverlay, hide: hideWindOverlay},
|
||||
rain: {show: refreshRain, hide: removeRainLayer},
|
||||
};
|
||||
|
||||
function toggleOverlay(id: string) {
|
||||
if (id === 'wind') {
|
||||
if (activeOverlays.value.has('wind')) {
|
||||
activeOverlays.value.delete('wind')
|
||||
hideWindOverlay()
|
||||
const handler = OVERLAY_HANDLERS[id];
|
||||
if (!handler) return;
|
||||
|
||||
if (activeOverlays.value.has(id)) {
|
||||
activeOverlays.value.delete(id);
|
||||
handler.hide();
|
||||
} else {
|
||||
activeOverlays.value.add('wind')
|
||||
showWindOverlay()
|
||||
}
|
||||
return
|
||||
activeOverlays.value.add(id);
|
||||
handler.show();
|
||||
}
|
||||
|
||||
if (id === 'rain') {
|
||||
if (activeOverlays.value.has('rain')) {
|
||||
activeOverlays.value.delete('rain')
|
||||
if (overlayLayers['rain']) { map.removeLayer(overlayLayers['rain']); delete overlayLayers['rain'] }
|
||||
} else {
|
||||
activeOverlays.value.add('rain')
|
||||
refreshRain()
|
||||
}
|
||||
return
|
||||
saveLayers();
|
||||
}
|
||||
|
||||
function restoreOverlayState() {
|
||||
for (const id of Object.keys(OVERLAY_HANDLERS)) {
|
||||
activeOverlays.value.has(id) ? (<any>OVERLAY_HANDLERS)[id].show() : (<any>OVERLAY_HANDLERS)[id].hide();
|
||||
}
|
||||
}
|
||||
|
||||
function toggleMenu(id: string) {
|
||||
openMenu.value = openMenu.value === id ? null : id;
|
||||
}
|
||||
|
||||
function buildBaseLayer(dark: boolean) {
|
||||
const layer = new VectorTileLayer({ declutter: true })
|
||||
applyStyle(layer, dark ? '/dark-theme.json' : '/light-theme.json')
|
||||
return layer
|
||||
const layer = new VectorTileLayer({declutter: true});
|
||||
applyStyle(layer, dark ? '/dark-theme.json' : '/light-theme.json');
|
||||
return layer;
|
||||
}
|
||||
|
||||
function buildStationLayer(lat: number, lon: number, dark: boolean): VectorLayer<VectorSource> {
|
||||
const center = fromLonLat([lon, lat])
|
||||
const source = new VectorSource()
|
||||
const ringStroke = dark ? 'rgba(255,255,255,0.5)' : 'rgba(0,0,0,0.35)'
|
||||
const center = fromLonLat([lon, lat]);
|
||||
const source = new VectorSource();
|
||||
const ringStroke = dark ? 'rgba(255,255,255,0.5)' : 'rgba(0,0,0,0.35)';
|
||||
|
||||
for (const nm of [50, 100, 150, 200]) {
|
||||
const ring = new Feature(new CircleGeom(center, nm * NM))
|
||||
const ring = new Feature(new CircleGeom(center, nm * NM));
|
||||
|
||||
ring.setStyle(new Style({
|
||||
stroke: new Stroke({ color: ringStroke, width: 1, lineDash: [6, 4] }),
|
||||
fill: new Fill({ color: 'transparent' }),
|
||||
}))
|
||||
source.addFeature(ring)
|
||||
stroke: new Stroke({color: ringStroke, width: 1, lineDash: [6, 4]}),
|
||||
fill: new Fill({color: 'transparent'}),
|
||||
}));
|
||||
|
||||
source.addFeature(ring);
|
||||
}
|
||||
|
||||
const dot = new Feature(new Point(center))
|
||||
const dot = new Feature(new Point(center));
|
||||
|
||||
dot.setStyle(new Style({
|
||||
image: new CircleStyle({
|
||||
radius: 7,
|
||||
fill: new Fill({ color: dark ? '#ffffff' : '#000000' }),
|
||||
stroke: new Stroke({ color: dark ? '#000000' : '#ffffff', width: 2 }),
|
||||
fill: new Fill({color: dark ? '#ffffff' : '#000000'}),
|
||||
stroke: new Stroke({color: dark ? '#000000' : '#ffffff', width: 2}),
|
||||
}),
|
||||
}))
|
||||
source.addFeature(dot)
|
||||
}));
|
||||
|
||||
return new VectorLayer({ source, zIndex: 20 })
|
||||
source.addFeature(dot);
|
||||
|
||||
return new VectorLayer({source, zIndex: 20});
|
||||
}
|
||||
|
||||
function getAutoObjects(): AutoObject[] {
|
||||
const objects: AutoObject[] = [];
|
||||
|
||||
for (const category of AUTO_CATEGORIES) {
|
||||
if (!activeOverlays.value.has(category)) continue;
|
||||
|
||||
const config = AUTO_CONFIG[category];
|
||||
const layer = config.layer();
|
||||
if (!layer.visible) continue;
|
||||
|
||||
for (const target of layer.getCandidates(current.value.latitude, current.value.longitude)) {
|
||||
objects.push({category, id: config.id(target), score: target.score});
|
||||
}
|
||||
}
|
||||
|
||||
return objects;
|
||||
}
|
||||
|
||||
function focusAutoTarget(category: AutoCategory, target: any) {
|
||||
const config = AUTO_CONFIG[category];
|
||||
const position = config.position(target);
|
||||
if (!position) return;
|
||||
|
||||
map.getView().animate({center: fromLonLat(position), zoom: config.zoom, duration: 900});
|
||||
}
|
||||
|
||||
function clearAutoTarget() {
|
||||
if (autoCategory.value && autoTarget.value) {
|
||||
const config = AUTO_CONFIG[autoCategory.value];
|
||||
config.layer().closePopup(config.id(autoTarget.value));
|
||||
}
|
||||
|
||||
autoCategory.value = null;
|
||||
autoTarget.value = null;
|
||||
autoTargetKey = null;
|
||||
}
|
||||
|
||||
function setAutoTarget(object: AutoObject) {
|
||||
const config = AUTO_CONFIG[object.category];
|
||||
const target = config.layer().getById(object.id);
|
||||
if (!target) return;
|
||||
|
||||
clearAutoTarget();
|
||||
|
||||
autoCategory.value = object.category;
|
||||
autoTarget.value = target;
|
||||
autoTargetKey = `${object.category}:${object.id}`;
|
||||
|
||||
config.layer().openPopup(target);
|
||||
focusAutoTarget(object.category, target);
|
||||
}
|
||||
|
||||
function pickBestAndFocus() {
|
||||
if (!autoMode.value) return;
|
||||
|
||||
const objects = getAutoObjects();
|
||||
if (!objects.length) return clearAutoTarget();
|
||||
|
||||
const best: any = objects.sort((a, b) => b.score - a.score)[0];
|
||||
const bestKey = `${best.category}:${best.id}`;
|
||||
|
||||
if (bestKey === autoTargetKey) {
|
||||
const fresh = AUTO_CONFIG[best.category].layer().getById(best.id);
|
||||
if (!fresh) return clearAutoTarget();
|
||||
|
||||
autoTarget.value = fresh;
|
||||
focusAutoTarget(best.category, fresh);
|
||||
return;
|
||||
}
|
||||
|
||||
setAutoTarget(best);
|
||||
}
|
||||
|
||||
function startAutoMode() {
|
||||
if (autoMode.value) return;
|
||||
|
||||
autoMode.value = true;
|
||||
saveAutoMode();
|
||||
|
||||
clearAutoTarget();
|
||||
pickBestAndFocus();
|
||||
|
||||
autoInterval = setInterval(pickBestAndFocus, AUTO_INTERVAL_MS);
|
||||
}
|
||||
|
||||
function stopAutoMode() {
|
||||
if (autoInterval) {
|
||||
clearInterval(autoInterval);
|
||||
autoInterval = undefined as any;
|
||||
}
|
||||
|
||||
clearAutoTarget();
|
||||
autoMode.value = false;
|
||||
saveAutoMode();
|
||||
}
|
||||
|
||||
function toggleAutoMode() {
|
||||
autoMode.value ? stopAutoMode() : startAutoMode();
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
const position = await api.position()
|
||||
activeOverlays.value = loadLayers();
|
||||
|
||||
const savedMapState = loadMapState();
|
||||
const position = await api.position();
|
||||
|
||||
current.value = {
|
||||
latitude: position?.latitude || 0,
|
||||
longitude: position?.longitude || 0,
|
||||
}
|
||||
};
|
||||
|
||||
stationLayer = buildStationLayer(current.value.latitude, current.value.longitude, props.dark)
|
||||
stationLayer = buildStationLayer(current.value.latitude, current.value.longitude, props.dark);
|
||||
|
||||
const initialLatitude = savedMapState?.latitude ?? current.value.latitude;
|
||||
const initialLongitude = savedMapState?.longitude ?? current.value.longitude;
|
||||
const initialZoom = savedMapState?.zoom ?? 8;
|
||||
|
||||
map = new Map({
|
||||
target: mapEl.value!,
|
||||
layers: [buildBaseLayer(props.dark), stationLayer],
|
||||
interactions: defaultInteractions({ altShiftDragRotate: false, pinchRotate: false }),
|
||||
view: new View({ center: fromLonLat([current.value.longitude, current.value.latitude]), zoom: 8, maxZoom: 13 }),
|
||||
interactions: defaultInteractions({altShiftDragRotate: false, pinchRotate: false}),
|
||||
view: new View({
|
||||
center: fromLonLat([initialLongitude, initialLatitude]),
|
||||
zoom: initialZoom,
|
||||
maxZoom: 13,
|
||||
}),
|
||||
controls: [],
|
||||
})
|
||||
});
|
||||
|
||||
aisLayer = new AISLayer(map)
|
||||
aisLayer.show()
|
||||
airTraffic = new AirTrafficLayer(map)
|
||||
airTraffic.show()
|
||||
range = new RangeLayer(map)
|
||||
range.show()
|
||||
map.on('moveend', saveMapState);
|
||||
|
||||
refreshRain()
|
||||
radarInterval = setInterval(refreshRain, 5 * 60 * 1000)
|
||||
})
|
||||
aisLayer = new AISLayer(map);
|
||||
airTraffic = new AirTrafficLayer(map);
|
||||
sats = new SatsLayer(map);
|
||||
sondes = new SondesLayer(map);
|
||||
aurora = new Aurora(map);
|
||||
range = new RangeLayer(map);
|
||||
|
||||
restoreOverlayState();
|
||||
|
||||
radarInterval = setInterval(refreshRain, 5 * 60 * 1000);
|
||||
|
||||
if (loadAutoMode()) setTimeout(() => { if (!autoMode.value) startAutoMode(); }, 1_000);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
clearInterval(radarInterval)
|
||||
airTraffic.hide()
|
||||
})
|
||||
stopAutoMode();
|
||||
clearInterval(radarInterval);
|
||||
|
||||
if (map) saveMapState();
|
||||
|
||||
airTraffic?.hide();
|
||||
aisLayer?.hide();
|
||||
sats?.hide();
|
||||
sondes?.hide();
|
||||
aurora?.hide();
|
||||
range?.hide();
|
||||
});
|
||||
|
||||
watch(() => props.dark, dark => {
|
||||
map.getLayers().setAt(0, buildBaseLayer(dark))
|
||||
map.removeLayer(stationLayer)
|
||||
const lat = (current.value.latitude as number) || 0
|
||||
const lon = (current.value.longitude as number) || 0
|
||||
stationLayer = buildStationLayer(lat, lon, dark)
|
||||
map.addLayer(stationLayer)
|
||||
})
|
||||
if (!map) return;
|
||||
|
||||
map.getLayers().setAt(0, buildBaseLayer(dark));
|
||||
map.removeLayer(stationLayer);
|
||||
|
||||
stationLayer = buildStationLayer(current.value.latitude || 0, current.value.longitude || 0, dark);
|
||||
map.addLayer(stationLayer);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@@ -244,9 +537,29 @@ watch(() => props.dark, dark => {
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.overlay-toggles {
|
||||
position: absolute;
|
||||
bottom: 16px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
z-index: 100;
|
||||
background: var(--surface);
|
||||
border-radius: 99px;
|
||||
padding: 6px 10px;
|
||||
box-shadow: 0 2px 12px rgba(0,0,0,0.2);
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.overlay-group {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.overlay-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
padding: 6px 12px;
|
||||
border-radius: 99px;
|
||||
@@ -264,24 +577,91 @@ watch(() => props.dark, dark => {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
&:hover:not(.active) { background: var(--hover); }
|
||||
&:hover:not(.active) {
|
||||
background: var(--hover);
|
||||
}
|
||||
}
|
||||
|
||||
.overlay-toggles {
|
||||
.group-btn {
|
||||
position: relative;
|
||||
|
||||
&.has-active {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
}
|
||||
|
||||
.group-indicator {
|
||||
font-size: 9px;
|
||||
opacity: 0.7;
|
||||
margin-left: 2px;
|
||||
}
|
||||
|
||||
.overlay-submenu {
|
||||
position: absolute;
|
||||
bottom: 16px;
|
||||
bottom: calc(100% + 8px);
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
z-index: 100;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-width: 130px;
|
||||
background: var(--surface);
|
||||
border-radius: 99px;
|
||||
padding: 6px 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 6px;
|
||||
box-shadow: 0 2px 12px rgba(0,0,0,0.2);
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.submenu-btn {
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 8px 10px;
|
||||
|
||||
&.active {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
.auto-btn {
|
||||
&.active {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
.auto-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.auto-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
animation: auto-pulse 1.5s infinite;
|
||||
}
|
||||
|
||||
@keyframes auto-pulse {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: 0.35;
|
||||
}
|
||||
}
|
||||
|
||||
.layers-menu {
|
||||
position: absolute;
|
||||
bottom: 16px;
|
||||
@@ -309,6 +689,7 @@ watch(() => props.dark, dark => {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
align-items: stretch;
|
||||
background: var(--surface);
|
||||
border-radius: 12px;
|
||||
padding: 8px;
|
||||
@@ -316,14 +697,43 @@ watch(() => props.dark, dark => {
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.desktop { display: flex; }
|
||||
.mobile { display: none; }
|
||||
.mobile-group {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.mobile-submenu {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
margin-top: 4px;
|
||||
padding-left: 8px;
|
||||
}
|
||||
|
||||
.mobile-submenu .overlay-btn {
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.desktop {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.mobile {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.desktop { display: none; }
|
||||
.mobile { display: flex; }
|
||||
.overlay-toggles { bottom: 10px; }
|
||||
.layers-menu { bottom: 10px; }
|
||||
.desktop {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.mobile {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.layers-menu {
|
||||
bottom: 10px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -331,25 +741,26 @@ watch(() => props.dark, dark => {
|
||||
<div class="map-wrap">
|
||||
<div ref="mapEl" class="map-el" />
|
||||
|
||||
<iframe
|
||||
v-if="showWind"
|
||||
class="wind-iframe"
|
||||
:src="windSrc"
|
||||
frameborder="0"
|
||||
allowfullscreen
|
||||
/>
|
||||
<iframe v-if="showWind" class="wind-iframe" :src="windSrc" frameborder="0" allowfullscreen/>
|
||||
|
||||
<div class="overlay-toggles desktop">
|
||||
<button class="overlay-btn" :class="{ active: atActive }" @click="toggleAT">
|
||||
✈️ Traffic
|
||||
<div v-for="group in OVERLAY_GROUPS" :key="group.id" class="overlay-group">
|
||||
<button class="overlay-btn group-btn" :class="{'has-active': group.items.some(item =>activeOverlays.has(item.id),),}" @click="toggleMenu(group.id)">
|
||||
{{ group.icon }} {{ group.label }}
|
||||
<span class="group-indicator">
|
||||
{{ openMenu === group.id ? '▲' : '▼' }}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
v-for="o in OVERLAYS" :key="o.id"
|
||||
class="overlay-btn"
|
||||
:class="{ active: activeOverlays.has(o.id) }"
|
||||
@click="toggleOverlay(o.id)"
|
||||
>
|
||||
{{ o.icon }} {{ o.label }}
|
||||
|
||||
<div v-if="openMenu === group.id" class="overlay-submenu">
|
||||
<button v-for="item in group.items" :key="item.id" class="overlay-btn submenu-btn" :class="{active: activeOverlays.has(item.id)}" @click="toggleOverlay(item.id)">
|
||||
{{ item.icon }} {{ item.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="overlay-btn auto-btn" :class="{active: autoMode}" @click="toggleAutoMode">
|
||||
{{ autoMode ? '⏹' : '▶' }} Auto
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -357,17 +768,25 @@ watch(() => props.dark, dark => {
|
||||
<button class="layers-btn" @click="showOverlays = !showOverlays">
|
||||
⚙️ Layers
|
||||
</button>
|
||||
|
||||
<div v-if="showOverlays" class="layers-dropdown">
|
||||
<button class="overlay-btn" :class="{ active: atActive }" @click="toggleAT">
|
||||
✈️ Traffic
|
||||
<div v-for="group in OVERLAY_GROUPS" :key="group.id" class="mobile-group">
|
||||
<button class="overlay-btn" :class="{active: group.items.some(item =>activeOverlays.has(item.id),),}" @click="toggleMenu(group.id)">
|
||||
{{ group.icon }} {{ group.label }}
|
||||
<span class="group-indicator">
|
||||
{{ openMenu === group.id ? '▲' : '▼' }}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
v-for="o in OVERLAYS" :key="o.id"
|
||||
class="overlay-btn"
|
||||
:class="{ active: activeOverlays.has(o.id) }"
|
||||
@click="toggleOverlay(o.id)"
|
||||
>
|
||||
{{ o.icon }} {{ o.label }}
|
||||
|
||||
<div v-if="openMenu === group.id" class="mobile-submenu">
|
||||
<button v-for="item in group.items" :key="item.id" class="overlay-btn" :class="{active: activeOverlays.has(item.id)}" @click="toggleOverlay(item.id)">
|
||||
{{ item.icon }} {{ item.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="overlay-btn auto-btn" :class="{active: autoMode}" @click="toggleAutoMode">
|
||||
{{ autoMode ? '⏹ Stop Auto' : '▶ Auto Mode' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
<script setup lang="ts">
|
||||
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 UNIT_KEY = 'at_units'
|
||||
|
||||
function loadUnits() {
|
||||
const s = localStorage.getItem(UNIT_KEY)
|
||||
return s ? JSON.parse(s) : { speed: 'knots', altitude: 'meters', vertical: 'mps' }
|
||||
}
|
||||
|
||||
const prefs = ref(loadUnits())
|
||||
|
||||
function saveAndSet(p: any) {
|
||||
prefs.value = { ...p }
|
||||
localStorage.setItem(UNIT_KEY, JSON.stringify(prefs.value))
|
||||
}
|
||||
|
||||
function cycleAltitude() {
|
||||
const p = loadUnits()
|
||||
p.altitude = p.altitude === 'meters' ? 'feet' : 'meters'
|
||||
p.vertical = p.altitude === 'meters' ? 'mps' : 'fps'
|
||||
saveAndSet(p)
|
||||
}
|
||||
|
||||
function syncUnits(e: StorageEvent) {
|
||||
if (e.key === UNIT_KEY) prefs.value = loadUnits()
|
||||
}
|
||||
|
||||
const distance = computed(() => prefs.value.altitude === 'feet'
|
||||
? { label: 'mi', convert: (v: number) => v * 0.621371 }
|
||||
: { label: 'km', convert: (v: number) => v })
|
||||
|
||||
const latest = computed(() => props.history[props.history.length - 1])
|
||||
const rangeVal = computed(() => props.range ? Math.round(distance.value.convert(props.range.slantRange)) : null)
|
||||
const altVal = computed(() => props.range ? Math.round(distance.value.convert(props.range.altitude)) : null)
|
||||
|
||||
// ── Drag ──────────────────────────────────────────────────────────────────────
|
||||
const pos = ref({ ...props.position })
|
||||
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)
|
||||
window.addEventListener('storage', syncUnits)
|
||||
})
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('mousemove', onMouseMove)
|
||||
document.removeEventListener('mouseup', onMouseUp)
|
||||
document.removeEventListener('touchmove', onTouchMove)
|
||||
document.removeEventListener('touchend', onTouchEnd)
|
||||
document.removeEventListener('touchcancel', onTouchEnd)
|
||||
window.removeEventListener('storage', syncUnits)
|
||||
})
|
||||
|
||||
// ── Radar HUD ─────────────────────────────────────────────────────────────────
|
||||
/** Center = zenith (el 90), edge = horizon (el 0). */
|
||||
function polarPoint(az: number, el: number, radius = 100) {
|
||||
const r = radius * (1 - el / 90)
|
||||
const rad = (az - 90) * Math.PI / 180
|
||||
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 currentPoint = computed(() => latest.value ? polarPoint(latest.value.az, latest.value.el) : null)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.sat-popup {
|
||||
position: fixed;
|
||||
z-index: 100;
|
||||
pointer-events: auto;
|
||||
background: rgba(0,0,0,0.88);
|
||||
border: 1px solid rgba(212,164,255,0.3);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 24px rgba(0,0,0,0.5);
|
||||
overflow: hidden;
|
||||
font-family: monospace;
|
||||
color: #ccc;
|
||||
min-width: 320px;
|
||||
max-width: 360px;
|
||||
}
|
||||
|
||||
.tp-header {
|
||||
position: relative;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid rgba(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-gauge-click { cursor: pointer; }
|
||||
.tp-gauge-click:hover { border-color: #fff; }
|
||||
|
||||
.tp-radar { flex: 1; }
|
||||
.ring, .axis { fill: none; stroke: rgba(212,164,255,0.3); stroke-width: 1; }
|
||||
.dir { fill: #d4a4ff; font-size: 10px; font-weight: bold; }
|
||||
.track { fill: none; stroke: #d4a4ff; stroke-width: 1.5; }
|
||||
.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>
|
||||
|
||||
<template>
|
||||
<div class="sat-popup" :style="{ left: pos.x + 'px', top: pos.y + 'px' }">
|
||||
|
||||
<div ref="header" class="tp-header" @mousedown="onMouseDown" @touchstart.passive="onTouchStart">
|
||||
<h3 class="tp-name">🛰️ {{ latest?.satellite || 'Unknown' }}</h3>
|
||||
<button class="tp-close" @click.stop="emit('close')">✕</button>
|
||||
</div>
|
||||
|
||||
<div class="tp-meta">
|
||||
<div class="flex-c flex-fill">
|
||||
<span>Range: {{ rangeVal != null ? rangeVal + ' ' + distance.label : 'Range —' }}</span>
|
||||
<span>Freq: {{ latest?.freqMHz }} MHz</span>
|
||||
</div>
|
||||
<div class="flex-c flex-fill align-x-end">
|
||||
<span>RSSI: {{ latest?.packetRssi }} dBm</span>
|
||||
<span>SNR: {{ latest?.packetSnr }} dB</span>
|
||||
</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 tp-gauge-click" @click.stop="cycleAltitude">
|
||||
<span class="tp-gauge-val">{{ altVal != null ? altVal + ' ' + distance.label : '—' }}</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>
|
||||
</template>
|
||||
@@ -79,11 +79,11 @@ function drawTrace(ctx: CanvasRenderingContext2D, t: (typeof traces)[number], co
|
||||
ctx.lineJoin = 'round';
|
||||
|
||||
for (let i = 0; i < settledCount; i++) {
|
||||
const p = buf[i];
|
||||
const p = <any>buf[i];
|
||||
i === 0 ? ctx.moveTo(p.x + slide, p.y) : ctx.lineTo(p.x + slide, p.y);
|
||||
}
|
||||
|
||||
const animPoints = [anchor, ...buf.slice(settledCount)];
|
||||
const animPoints = <any>[anchor, ...buf.slice(settledCount)];
|
||||
|
||||
for (let i = 1; i <= animFloor && i < animPoints.length; i++) {
|
||||
ctx.lineTo(animPoints[i].x + slide, animPoints[i].y);
|
||||
@@ -116,9 +116,9 @@ function draw(timestamp: number) {
|
||||
|
||||
// Axis label — top-left of each band
|
||||
ctx.font = '16px monospace';
|
||||
ctx.fillStyle = AXES[i].color;
|
||||
ctx.fillStyle = (<any>AXES)[i].color;
|
||||
ctx.globalAlpha = 0.6;
|
||||
ctx.fillText(AXES[i].label, W - 20, i * BAND + 14);
|
||||
ctx.fillText((<any>AXES)[i].label, W - 20, i * BAND + 14);
|
||||
ctx.globalAlpha = 1;
|
||||
|
||||
// Center line per band — full width
|
||||
@@ -129,7 +129,7 @@ function draw(timestamp: number) {
|
||||
ctx.lineTo(W, mid);
|
||||
ctx.stroke();
|
||||
|
||||
drawTrace(ctx, traces[i], AXES[i].color);
|
||||
drawTrace(ctx, <any>traces[i], (<any>AXES)[i].color);
|
||||
});
|
||||
|
||||
rafId = requestAnimationFrame(draw);
|
||||
@@ -138,7 +138,7 @@ function draw(timestamp: number) {
|
||||
async function poll() {
|
||||
d.value = await api.current('seismic_magnitude,seismic_x,seismic_y,seismic_z');
|
||||
AXES.forEach((axis, i) => {
|
||||
const t = traces[i];
|
||||
const t = <any>traces[i];
|
||||
t.history.push(d.value[axis.key] ?? 0);
|
||||
if (t.history.length > MAX_PTS) t.history.shift();
|
||||
buildBuffer(t, i);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import {BASE} from '@/services/api.ts';
|
||||
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
|
||||
const props = defineProps<{ boat: any; position: { x: number; y: number } }>()
|
||||
const emit = defineEmits<{ (e: 'close'): void; (e: 'bringToFront'): void }>()
|
||||
@@ -10,12 +10,15 @@ const SPEED_UNITS = {
|
||||
kph: { label: 'KPH', convert: (v: number) => (v * 1.852).toFixed(1) },
|
||||
mph: { label: 'MPH', convert: (v: number) => (v * 1.15078).toFixed(1) },
|
||||
}
|
||||
const UNIT_KEY = 'ais_units'
|
||||
|
||||
const UNIT_KEY = 'at_units'
|
||||
function loadUnits() {
|
||||
const s = localStorage.getItem(UNIT_KEY)
|
||||
return s ? JSON.parse(s) : { speed: 'knots' }
|
||||
return s ? JSON.parse(s) : { speed: 'knots', altitude: 'meters', vertical: 'mps' }
|
||||
}
|
||||
|
||||
const prefs = ref(loadUnits())
|
||||
|
||||
function cycleSpeed() {
|
||||
const keys = Object.keys(SPEED_UNITS)
|
||||
const p = loadUnits()
|
||||
@@ -23,10 +26,16 @@ function cycleSpeed() {
|
||||
prefs.value = { ...p }
|
||||
localStorage.setItem(UNIT_KEY, JSON.stringify(p))
|
||||
}
|
||||
|
||||
function syncUnits(e: StorageEvent) {
|
||||
if (e.key === UNIT_KEY) prefs.value = loadUnits()
|
||||
}
|
||||
|
||||
const speed = computed(() => SPEED_UNITS[prefs.value.speed as keyof typeof SPEED_UNITS])
|
||||
|
||||
const name = computed(() => props.boat.shipname?.trim() || props.boat.callsign?.trim() || '-')
|
||||
const heading = computed(() => props.boat.heading ?? props.boat.course ?? 0)
|
||||
const heading = computed(() => props.boat.heading ?? props.boat.bearing)
|
||||
const course = computed(() => props.boat.cog ?? props.boat.course)
|
||||
const speedVal = computed(() => speed.value.convert(props.boat.speed ?? 0))
|
||||
const photoError = ref(false)
|
||||
|
||||
@@ -81,6 +90,7 @@ onMounted(() => {
|
||||
document.addEventListener('touchmove', onTouchMove, { passive: false })
|
||||
document.addEventListener('touchend', onTouchEnd)
|
||||
document.addEventListener('touchcancel', onTouchEnd)
|
||||
window.addEventListener('storage', syncUnits)
|
||||
})
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('mousemove', onMouseMove)
|
||||
@@ -88,6 +98,7 @@ onUnmounted(() => {
|
||||
document.removeEventListener('touchmove', onTouchMove)
|
||||
document.removeEventListener('touchend', onTouchEnd)
|
||||
document.removeEventListener('touchcancel', onTouchEnd)
|
||||
window.removeEventListener('storage', syncUnits)
|
||||
})
|
||||
|
||||
const compass = computed(() => {
|
||||
@@ -187,71 +198,13 @@ const compass = computed(() => {
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="ship-popup" :style="{ left: pos.x + 'px', top: pos.y + 'px' }">
|
||||
|
||||
<div ref="header" class="sp-header" @mousedown="onMouseDown" @touchstart.passive="onTouchStart">
|
||||
<h3 class="sp-name">
|
||||
🚢
|
||||
<span>{{ name.toUpperCase() }}</span>
|
||||
<span class="sp-mmsi"> / {{ boat.mmsi }}</span>
|
||||
</h3>
|
||||
<button class="sp-close" @click.stop="emit('close')">×</button>
|
||||
</div>
|
||||
|
||||
<div class="sp-meta">
|
||||
<div class="flex-c">
|
||||
<span>{{ boat.operator || boat.owner || 'Unknown Owner' }}</span>
|
||||
<span>{{ boat.country || 'Unknown Country' }}</span>
|
||||
</div>
|
||||
<div class="flex-c align-x-end">
|
||||
<span style="text-transform:capitalize">{{ boat.type || 'Unknown' }}</span>
|
||||
<span>{{ boat.ship_type || 'Unknown Class' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Photo -->
|
||||
<div v-if="!photoError" class="sp-photo-wrap">
|
||||
<img class="sp-photo" :src="BASE + '/api/ais-iamge/' + boat.mmsi" :alt="name" @error="photoError = true" />
|
||||
</div>
|
||||
|
||||
<div class="sp-body">
|
||||
<div class="sp-gauges">
|
||||
<div class="sp-gauge-wrap">
|
||||
<div class="sp-gauge-label">Speed</div>
|
||||
<div class="sp-gauge" @click.stop="cycleSpeed">
|
||||
<span class="sp-gauge-val">{{ speedVal }}</span>
|
||||
<span class="sp-gauge-unit">{{ speed.label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sp-gauge-wrap">
|
||||
<div class="sp-gauge-label">Course</div>
|
||||
<div class="sp-gauge">
|
||||
<span class="sp-gauge-val">{{ boat.course != null ? boat.course.toFixed(0) : '—' }}</span>
|
||||
<span v-if="boat.course != null" class="sp-gauge-unit">°</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sp-gauge-wrap">
|
||||
<div class="sp-gauge-label">Heading</div>
|
||||
<div class="sp-gauge">
|
||||
<span class="sp-gauge-val">{{ boat.heading != null ? boat.heading.toFixed(0) : '—' }}</span>
|
||||
<span v-if="boat.heading != null" class="sp-gauge-unit">°</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sp-compass" v-html="compass"/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.ship-popup {
|
||||
position: fixed;
|
||||
z-index: 100;
|
||||
pointer-events: auto;
|
||||
background: rgba(0,0,0,0.88);
|
||||
border: 1px solid rgba(200,200,220,0.3);
|
||||
border: 1px solid rgba(0,255,255,0.3);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 24px rgba(0,0,0,0.5);
|
||||
overflow: hidden;
|
||||
@@ -264,7 +217,7 @@ const compass = computed(() => {
|
||||
.sp-header {
|
||||
position: relative;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid rgba(200,200,220,0.2);
|
||||
border-bottom: 1px solid rgba(0,255,255,0.2);
|
||||
cursor: move;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
@@ -314,6 +267,7 @@ const compass = computed(() => {
|
||||
border-bottom: 1px solid rgba(200,200,220,0.15);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sp-photo {
|
||||
width: 100%;
|
||||
height: 140px;
|
||||
@@ -354,3 +308,61 @@ const compass = computed(() => {
|
||||
.sp-gauge-unit { font-size: 11px; color: #0ff; margin-bottom: 5px; }
|
||||
.sp-compass { flex: 1; margin-top: 0.25rem; }
|
||||
</style>
|
||||
|
||||
<template>
|
||||
<div class="ship-popup" :style="{ left: pos.x + 'px', top: pos.y + 'px' }">
|
||||
|
||||
<div ref="header" class="sp-header" @mousedown="onMouseDown" @touchstart.passive="onTouchStart">
|
||||
<h3 class="sp-name">
|
||||
🚢
|
||||
<span>{{ name.toUpperCase() }}</span>
|
||||
<span class="sp-mmsi"> / {{ boat.mmsi }}</span>
|
||||
</h3>
|
||||
<button class="sp-close" @click.stop="emit('close')">×</button>
|
||||
</div>
|
||||
|
||||
<div class="sp-meta">
|
||||
<div class="flex-c flex-fill">
|
||||
<span>{{ boat.operator || boat.owner || 'Unknown Owner' }}</span>
|
||||
<span>{{ boat.country || 'Unknown Country' }}</span>
|
||||
</div>
|
||||
<div class="flex-c flex-fill align-x-end">
|
||||
<span style="text-transform:capitalize">{{ boat.type || 'Unknown' }}</span>
|
||||
<span>{{ boat.ship_type || 'Unknown Class' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Photo -->
|
||||
<div v-if="!photoError" class="sp-photo-wrap">
|
||||
<img class="sp-photo" :src="BASE + '/api/ais/' + boat.mmsi + '/image'" :alt="name" @error="photoError = true" />
|
||||
</div>
|
||||
|
||||
<div class="sp-body">
|
||||
<div class="sp-gauges">
|
||||
<div class="sp-gauge-wrap">
|
||||
<div class="sp-gauge-label">Speed</div>
|
||||
<div class="sp-gauge" @click.stop="cycleSpeed">
|
||||
<span class="sp-gauge-val">{{ speedVal }}</span>
|
||||
<span class="sp-gauge-unit">{{ speed.label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sp-gauge-wrap">
|
||||
<div class="sp-gauge-label">Course</div>
|
||||
<div class="sp-gauge">
|
||||
<span class="sp-gauge-val">{{ course != null ? course.toFixed(0) : '—' }}</span>
|
||||
<span v-if="course != null" class="sp-gauge-unit">°</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sp-gauge-wrap">
|
||||
<div class="sp-gauge-label">Heading</div>
|
||||
<div class="sp-gauge">
|
||||
<span class="sp-gauge-val">{{ heading != null ? heading.toFixed(0) : '—' }}</span>
|
||||
<span v-if="heading != null" class="sp-gauge-unit">°</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sp-compass" v-html="compass"/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,542 @@
|
||||
<script setup lang="ts">
|
||||
import {formatDate} from '@ztimson/utils';
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import Altitude from '@/components/Altitude.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
history: any[]
|
||||
position: { x: number; y: number }
|
||||
}>()
|
||||
const emit = defineEmits<{ (e: 'close'): void; (e: 'bringToFront'): void }>()
|
||||
|
||||
// ── Units ─────────────────────────────────────────────────────────────────────
|
||||
const BASE_SPEED_UNITS = {
|
||||
knots: { label: 'KTS', convert: (v: number) => Math.round(v) },
|
||||
kph: { label: 'KPH', convert: (v: number) => Math.round(v * 3.6) },
|
||||
mph: { label: 'MPH', convert: (v: number) => Math.round(v * 2.23694) },
|
||||
}
|
||||
|
||||
const BASE_ALTITUDE_UNITS = {
|
||||
meters: { label: 'M', convert: (v: number) => Math.round(v) },
|
||||
feet: { label: 'FT', convert: (v: number) => Math.round(v * 3.28084) },
|
||||
}
|
||||
|
||||
const BASE_VERTICAL_UNITS = {
|
||||
mps: { label: 'M/S', convert: (v: number) => Number(v.toFixed(1)) },
|
||||
fps: { label: 'FT/S', convert: (v: number) => Number((v * 3.28084).toFixed(1)) },
|
||||
}
|
||||
|
||||
const UNIT_KEY = 'at_units'
|
||||
|
||||
function loadUnits() {
|
||||
const s = localStorage.getItem(UNIT_KEY)
|
||||
return s
|
||||
? JSON.parse(s)
|
||||
: { speed: 'knots', altitude: 'meters', vertical: 'mps' }
|
||||
}
|
||||
|
||||
const prefs = ref(loadUnits())
|
||||
|
||||
function saveAndSet(p: any) {
|
||||
prefs.value = { ...p }
|
||||
localStorage.setItem(UNIT_KEY, JSON.stringify(prefs.value))
|
||||
}
|
||||
|
||||
function cycleSpeed() {
|
||||
const keys = Object.keys(BASE_SPEED_UNITS)
|
||||
const p = loadUnits()
|
||||
p.speed = keys[(keys.indexOf(p.speed) + 1) % keys.length]
|
||||
saveAndSet(p)
|
||||
}
|
||||
|
||||
function cycleAltitude() {
|
||||
const keys = Object.keys(BASE_ALTITUDE_UNITS)
|
||||
const p = loadUnits()
|
||||
p.altitude = keys[(keys.indexOf(p.altitude) + 1) % keys.length]
|
||||
p.vertical = p.altitude === 'meters' ? 'mps' : 'fps'
|
||||
saveAndSet(p)
|
||||
}
|
||||
|
||||
function cycleVertical() {
|
||||
const keys = Object.keys(BASE_VERTICAL_UNITS)
|
||||
const p = loadUnits()
|
||||
p.vertical = keys[(keys.indexOf(p.vertical) + 1) % keys.length]
|
||||
p.altitude = p.vertical === 'mps' ? 'meters' : 'feet'
|
||||
saveAndSet(p)
|
||||
}
|
||||
|
||||
function syncUnits(e: StorageEvent) {
|
||||
if (e.key === UNIT_KEY) prefs.value = loadUnits()
|
||||
}
|
||||
|
||||
const speed = computed(() =>
|
||||
BASE_SPEED_UNITS[prefs.value.speed as keyof typeof BASE_SPEED_UNITS]
|
||||
)
|
||||
|
||||
const altitudeUnit = computed(() =>
|
||||
BASE_ALTITUDE_UNITS[prefs.value.altitude as keyof typeof BASE_ALTITUDE_UNITS]
|
||||
)
|
||||
|
||||
const vertical = computed(() =>
|
||||
BASE_VERTICAL_UNITS[prefs.value.vertical as keyof typeof BASE_VERTICAL_UNITS]
|
||||
)
|
||||
|
||||
// ── Data ──────────────────────────────────────────────────────────────────────
|
||||
const latest = computed(() => props.history[props.history.length - 1])
|
||||
|
||||
const altitude = computed(() => {
|
||||
if (latest.value?.altitude == null) return null
|
||||
return altitudeUnit.value.convert(Number(latest.value.altitude))
|
||||
})
|
||||
|
||||
const speedVal = computed(() => {
|
||||
if (latest.value?.speed == null) return null
|
||||
return speed.value.convert(Number(latest.value.speed))
|
||||
})
|
||||
|
||||
const climbVal = computed(() => {
|
||||
if (latest.value?.climb == null) return null
|
||||
const v = vertical.value.convert(Number(latest.value.climb))
|
||||
return v >= 0 ? `+${v}` : String(v)
|
||||
})
|
||||
|
||||
const altitudeHistory = computed(() => props.history.map(p => ({
|
||||
...p,
|
||||
altitude: p.altitude != null
|
||||
? altitudeUnit.value.convert(Number(p.altitude))
|
||||
: null,
|
||||
})))
|
||||
|
||||
const currentAltitude = computed(() =>
|
||||
latest.value?.altitude != null
|
||||
? altitudeUnit.value.convert(Number(latest.value.altitude))
|
||||
: null
|
||||
)
|
||||
|
||||
// ── Drag ──────────────────────────────────────────────────────────────────────
|
||||
const pos = ref({ ...props.position })
|
||||
const isDragging = ref(false)
|
||||
const dragStart = ref({ mx: 0, my: 0, ex: 0, ey: 0 })
|
||||
const longPressTimer = ref<number | null>(null)
|
||||
const header = ref<HTMLElement | null>(null)
|
||||
|
||||
function onMouseDown(e: MouseEvent) {
|
||||
if ((e.target as HTMLElement).classList.contains('tp-close')) return
|
||||
emit('bringToFront')
|
||||
isDragging.value = true
|
||||
dragStart.value = {
|
||||
mx: e.clientX,
|
||||
my: e.clientY,
|
||||
ex: pos.value.x,
|
||||
ey: pos.value.y
|
||||
}
|
||||
e.preventDefault()
|
||||
}
|
||||
|
||||
function onMouseMove(e: MouseEvent) {
|
||||
if (!isDragging.value) return
|
||||
|
||||
pos.value.x = dragStart.value.ex + (e.clientX - dragStart.value.mx)
|
||||
pos.value.y = dragStart.value.ey + (e.clientY - dragStart.value.my)
|
||||
}
|
||||
|
||||
function onMouseUp() {
|
||||
isDragging.value = false
|
||||
}
|
||||
|
||||
function onTouchStart(e: TouchEvent) {
|
||||
if ((e.target as HTMLElement).classList.contains('tp-close')) return
|
||||
|
||||
const touch: any = e.touches[0]
|
||||
const sx = touch.clientX
|
||||
const sy = touch.clientY
|
||||
|
||||
longPressTimer.value = window.setTimeout(() => {
|
||||
emit('bringToFront')
|
||||
isDragging.value = true
|
||||
dragStart.value = {
|
||||
mx: sx,
|
||||
my: sy,
|
||||
ex: pos.value.x,
|
||||
ey: pos.value.y
|
||||
}
|
||||
|
||||
if (header.value) header.value.style.opacity = '0.8'
|
||||
}, 500)
|
||||
}
|
||||
|
||||
function onTouchMove(e: TouchEvent) {
|
||||
if (!isDragging.value) return
|
||||
|
||||
const touch: any = e.touches[0]
|
||||
|
||||
pos.value.x = dragStart.value.ex + (touch.clientX - dragStart.value.mx)
|
||||
pos.value.y = dragStart.value.ey + (touch.clientY - dragStart.value.my)
|
||||
|
||||
e.preventDefault()
|
||||
}
|
||||
|
||||
function onTouchEnd() {
|
||||
if (longPressTimer.value) {
|
||||
clearTimeout(longPressTimer.value)
|
||||
longPressTimer.value = null
|
||||
}
|
||||
|
||||
if (isDragging.value && header.value) {
|
||||
header.value.style.opacity = '1'
|
||||
}
|
||||
|
||||
isDragging.value = false
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('mousemove', onMouseMove)
|
||||
document.addEventListener('mouseup', onMouseUp)
|
||||
document.addEventListener('touchmove', onTouchMove, { passive: false })
|
||||
document.addEventListener('touchend', onTouchEnd)
|
||||
document.addEventListener('touchcancel', onTouchEnd)
|
||||
window.addEventListener('storage', syncUnits)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('mousemove', onMouseMove)
|
||||
document.removeEventListener('mouseup', onMouseUp)
|
||||
document.removeEventListener('touchmove', onTouchMove)
|
||||
document.removeEventListener('touchend', onTouchEnd)
|
||||
document.removeEventListener('touchcancel', onTouchEnd)
|
||||
window.removeEventListener('storage', syncUnits)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.sonde-popup {
|
||||
position: fixed;
|
||||
z-index: 100;
|
||||
pointer-events: auto;
|
||||
background: rgba(0,0,0,0.88);
|
||||
border: 1px solid rgba(255,170,0,0.3);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 24px rgba(0,0,0,0.5);
|
||||
overflow: hidden;
|
||||
font-family: monospace;
|
||||
color: #ccc;
|
||||
min-width: 320px;
|
||||
max-width: 360px;
|
||||
}
|
||||
|
||||
.tp-header {
|
||||
position: relative;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid rgba(255,170,0,0.2);
|
||||
cursor: move;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
.tp-name {
|
||||
margin: 0 24px 0 0;
|
||||
color: #ffaa00;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.tp-close {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
background: rgba(255,255,255,0.2);
|
||||
border: none;
|
||||
color: white;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.tp-close:hover {
|
||||
background: rgba(255,255,255,0.35);
|
||||
}
|
||||
|
||||
.tp-meta {
|
||||
font-size: 11px;
|
||||
color: #888;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid rgba(255,170,0,0.2);
|
||||
}
|
||||
|
||||
.flex-c {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.align-x-end {
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.tp-body {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.tp-gauges {
|
||||
flex: 0 0 110px;
|
||||
}
|
||||
|
||||
.tp-gauge-wrap {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.tp-gauge-label {
|
||||
color: #888;
|
||||
font-size: 11px;
|
||||
font-weight: bold;
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
|
||||
.tp-gauge {
|
||||
padding: 6px;
|
||||
background: rgba(0,0,0,0.95);
|
||||
border: 2px solid #ffaa00;
|
||||
border-radius: 3px;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tp-gauge:hover {
|
||||
border-color: #fff;
|
||||
}
|
||||
|
||||
.tp-gauge-val {
|
||||
font-size: 20px;
|
||||
font-weight: bold;
|
||||
color: #ffaa00;
|
||||
}
|
||||
|
||||
.tp-gauge-unit {
|
||||
font-size: 11px;
|
||||
color: #ffaa00;
|
||||
margin-bottom: 0.25em;
|
||||
}
|
||||
|
||||
.tp-environment {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.tp-environment-label {
|
||||
color: #888;
|
||||
font-size: 11px;
|
||||
font-weight: bold;
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
|
||||
.tp-environment-cards {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.tp-environment-card {
|
||||
min-height: 42px;
|
||||
padding: 6px;
|
||||
box-sizing: border-box;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 3px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.tp-environment-icon {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
flex: 0 0 26px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(255,170,0,0.08);
|
||||
border-radius: 3px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.tp-environment-info {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.tp-environment-stat-label {
|
||||
color: #888;
|
||||
font-size: 10px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.tp-environment-stat-value {
|
||||
color: white;
|
||||
font-size: 13px;
|
||||
font-weight: bold;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ok {
|
||||
color: #3fdb6d !important;
|
||||
}
|
||||
|
||||
.bad {
|
||||
color: #e0475a !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="sonde-popup"
|
||||
:style="{ left: pos.x + 'px', top: pos.y + 'px' }"
|
||||
>
|
||||
|
||||
<div
|
||||
ref="header"
|
||||
class="tp-header"
|
||||
@mousedown="onMouseDown"
|
||||
@touchstart.passive="onTouchStart"
|
||||
>
|
||||
<h3 class="tp-name">🎈 {{ latest?.id || 'Unknown Sonde' }}</h3>
|
||||
<button
|
||||
class="tp-close"
|
||||
@click.stop="emit('close')"
|
||||
>✕</button>
|
||||
</div>
|
||||
|
||||
<div class="tp-meta">
|
||||
<div class="flex-c flex-fill">
|
||||
<span>Type: {{ latest?.type || 'Unknown' }}</span>
|
||||
<span>Freq: {{ latest?.freqMHz?.toFixed(2) || '—' }} MHz</span>
|
||||
</div>
|
||||
|
||||
<div class="flex-c flex-fill align-x-end">
|
||||
<span>Bat: {{ latest?.battery ?? '—' }} V</span>
|
||||
<span>SNR: {{ latest?.snr ?? '—' }} dB</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tp-body">
|
||||
<div class="tp-gauges">
|
||||
|
||||
<div class="tp-gauge-wrap">
|
||||
<div class="tp-gauge-label">Air Speed</div>
|
||||
<div
|
||||
class="tp-gauge"
|
||||
@click.stop="cycleSpeed"
|
||||
>
|
||||
<span class="tp-gauge-val">
|
||||
{{ speedVal ?? '—' }}
|
||||
</span>
|
||||
<span class="tp-gauge-unit">
|
||||
{{ speed.label }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tp-gauge-wrap">
|
||||
<div class="tp-gauge-label">Altitude</div>
|
||||
<div
|
||||
class="tp-gauge"
|
||||
@click.stop="cycleAltitude"
|
||||
>
|
||||
<span class="tp-gauge-val">
|
||||
{{ altitude != null ? altitude : '—' }}
|
||||
</span>
|
||||
<span class="tp-gauge-unit">
|
||||
{{ altitudeUnit.label }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tp-gauge-wrap">
|
||||
<div class="tp-gauge-label">Climb</div>
|
||||
<div
|
||||
class="tp-gauge"
|
||||
@click.stop="cycleVertical"
|
||||
>
|
||||
<span class="tp-gauge-val">
|
||||
{{ climbVal ?? '—' }}
|
||||
</span>
|
||||
<span class="tp-gauge-unit">
|
||||
{{ vertical.label }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="tp-environment">
|
||||
<div class="tp-environment-label">Environment</div>
|
||||
|
||||
<div class="tp-environment-cards gap-1">
|
||||
<div class="tp-environment-card">
|
||||
<span class="tp-environment-icon">🌡️</span>
|
||||
<div class="tp-environment-info">
|
||||
<span class="tp-environment-stat-label">Temperature</span>
|
||||
<span class="tp-environment-stat-value">
|
||||
{{ latest?.temperature ?? '—' }} °C
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tp-environment-card">
|
||||
<span class="tp-environment-icon">💧</span>
|
||||
<div class="tp-environment-info">
|
||||
<span class="tp-environment-stat-label">Humidity</span>
|
||||
<span class="tp-environment-stat-value">
|
||||
{{ latest?.humidity ?? '—' }} %
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tp-environment-card">
|
||||
<span class="tp-environment-icon">⬇️</span>
|
||||
<div class="tp-environment-info">
|
||||
<span class="tp-environment-stat-label">Pressure</span>
|
||||
<span class="tp-environment-stat-value">
|
||||
{{ latest?.pressure > 0 ? latest.pressure : '—' }} hpa
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tp-environment-card">
|
||||
<span class="tp-environment-icon">🫧</span>
|
||||
<div class="tp-environment-info">
|
||||
<span class="tp-environment-stat-label">PPM</span>
|
||||
<span class="tp-environment-stat-value">
|
||||
{{ latest?.ppm?.toFixed(2) ?? '—' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Altitude
|
||||
:history="altitudeHistory"
|
||||
:currentAltitude="currentAltitude"
|
||||
:unit="altitudeUnit.label"
|
||||
start-time=" "
|
||||
:end-time="formatDate('HH:mm:ss', latest.timestamp)"
|
||||
color="#ffaa00"
|
||||
/>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
+267
-180
@@ -1,88 +1,119 @@
|
||||
import { BASE } from '@/services/api.ts'
|
||||
import { createApp, ref } from 'vue'
|
||||
import Map from 'ol/Map'
|
||||
import { Feature } from 'ol'
|
||||
import Point from 'ol/geom/Point'
|
||||
import LineString from 'ol/geom/LineString'
|
||||
import { fromLonLat } from 'ol/proj'
|
||||
import { Style, Icon, Stroke } from 'ol/style'
|
||||
import { Vector as VectorLayer } from 'ol/layer'
|
||||
import { Vector as VectorSource } from 'ol/source'
|
||||
import { adjustedInterval } from '@ztimson/utils'
|
||||
import AircraftPopup from '@/components/Aircraft.vue'
|
||||
import { bringToFront } from './zindex'
|
||||
import {BASE} from '@/services/api.ts';
|
||||
import {greatCircleDist} from '@/services/units.ts';
|
||||
import {h, render as vueRender} from 'vue';
|
||||
import Map from 'ol/Map';
|
||||
import {Feature} from 'ol';
|
||||
import Point from 'ol/geom/Point';
|
||||
import LineString from 'ol/geom/LineString';
|
||||
import {fromLonLat} from 'ol/proj';
|
||||
import {Style, Icon, Stroke} from 'ol/style';
|
||||
import {Vector as VectorLayer} from 'ol/layer';
|
||||
import {Vector as VectorSource} from 'ol/source';
|
||||
import {adjustedInterval, sortByProp} from '@ztimson/utils';
|
||||
import AircraftPopup from '@/components/Aircraft.vue';
|
||||
import {bringToFront} from './zindex';
|
||||
|
||||
const API = BASE + '/api'
|
||||
const API = BASE + '/api';
|
||||
const AUTO_RANGE = 92.6;
|
||||
|
||||
function getAltColor(alt: number): [number, number, number] {
|
||||
const a = Math.max(0, alt)
|
||||
if (a < 10) return [0, 0, 0]
|
||||
if (a < 10000) return [135, 206, 250]
|
||||
if (a < 25000) {
|
||||
const r = (a - 10000) / 15000
|
||||
return [Math.round(135 * (1-r)), Math.round(206 * (1-r)), Math.round(250 * (1-r) + 255 * r)]
|
||||
}
|
||||
if (a < 40000) {
|
||||
const r = (a - 25000) / 15000
|
||||
return [0, 0, Math.round(255 * (1-r) + 139 * r)]
|
||||
}
|
||||
const r = Math.min((a - 40000) / 10000, 1)
|
||||
return [Math.round(128 * r), 0, 139]
|
||||
function isPriorityAircraft(plane: any): boolean {
|
||||
if (plane.military === true || plane.is_military === true || plane.sar === true || plane.is_sar === true) return true;
|
||||
|
||||
const text = [
|
||||
plane.type,
|
||||
plane.category,
|
||||
plane.aircraft_type,
|
||||
plane.operator,
|
||||
plane.owner,
|
||||
plane.flight,
|
||||
plane.callsign,
|
||||
plane.description,
|
||||
].filter(Boolean).join(' ').toLowerCase();
|
||||
|
||||
return /\b(military|mil|sar|search and rescue|rescue|coast guard|navy|army|air force|airforce|usaf|rcaf)\b/.test(text);
|
||||
}
|
||||
|
||||
interface PopupInstance {
|
||||
planeRef: ReturnType<typeof ref>
|
||||
posRef: ReturnType<typeof ref>
|
||||
unmount: () => void
|
||||
function getAltColor(alt: number): [number, number, number] {
|
||||
const a = Math.max(0, alt);
|
||||
if (a < 10) return [0, 0, 0];
|
||||
if (a < 10000) return [135, 206, 250];
|
||||
|
||||
if (a < 25000) {
|
||||
const r = (a - 10000) / 15000;
|
||||
return [Math.round(135 * (1 - r)), Math.round(206 * (1 - r)), Math.round(250 * (1 - r) + 255 * r)];
|
||||
}
|
||||
|
||||
if (a < 40000) {
|
||||
const r = (a - 25000) / 15000;
|
||||
return [0, 0, Math.round(255 * (1 - r) + 139 * r)];
|
||||
}
|
||||
|
||||
const r = Math.min((a - 40000) / 10000, 1);
|
||||
return [Math.round(128 * r), 0, 139];
|
||||
}
|
||||
|
||||
export class AirTrafficLayer {
|
||||
private map: Map
|
||||
private layer!: VectorLayer<VectorSource>
|
||||
private traceLayers: Record<string, VectorLayer<VectorSource>> = {}
|
||||
private traceSegs: Record<string, Feature[]> = {}
|
||||
private popups: any = {}
|
||||
private historyCache: any = {}
|
||||
private data: any[] = []
|
||||
private clickHandler: ((e: any) => void) | null = null
|
||||
private refreshTimer: any = null
|
||||
visible = false
|
||||
private map: Map;
|
||||
private layer!: VectorLayer<VectorSource>;
|
||||
private traceLayers: Record<string, VectorLayer<VectorSource>> = {};
|
||||
private traceSegs: Record<string, Feature[]> = {};
|
||||
private popups: any = {};
|
||||
private historyCache: any = {};
|
||||
private data: any[] = [];
|
||||
private clickHandler: ((e: any) => void) | null = null;
|
||||
private refreshTimer: any = null;
|
||||
visible = false;
|
||||
|
||||
constructor(map: Map) { this.map = map }
|
||||
|
||||
async show() {
|
||||
if (this.visible) return
|
||||
this.visible = true
|
||||
this.layer = new VectorLayer({ source: new VectorSource(), zIndex: 100 })
|
||||
this.map.addLayer(this.layer)
|
||||
await this._fetch()
|
||||
this._draw()
|
||||
this._attachClick()
|
||||
this.refreshTimer = adjustedInterval(async () => {
|
||||
try {
|
||||
await this._fetch()
|
||||
this._draw()
|
||||
this._refreshPopups()
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
}
|
||||
}, 1_000)
|
||||
constructor(map: Map) {
|
||||
this.map = map;
|
||||
}
|
||||
|
||||
hide() {
|
||||
if (!this.visible) return
|
||||
this.visible = false
|
||||
if (this.refreshTimer) { clearInterval(this.refreshTimer); this.refreshTimer = null }
|
||||
if (this.clickHandler) { this.map.un('singleclick', this.clickHandler); this.clickHandler = null }
|
||||
for (const icao of Object.keys(this.popups)) this._closePopup(icao)
|
||||
this.map.removeLayer(this.layer)
|
||||
Object.values(this.traceLayers).forEach(l => this.map.removeLayer(l))
|
||||
this.traceLayers = {}
|
||||
this.traceSegs = {}
|
||||
private _attachClick() {
|
||||
this.clickHandler = evt => {
|
||||
const f = this.map.forEachFeatureAtPixel(evt.pixel, f => f.get('planeData') ? f : null);
|
||||
if (!f) return;
|
||||
|
||||
const plane = f.get('planeData');
|
||||
|
||||
if (this.popups[plane.icao]) {
|
||||
this.closePopup(plane.icao);
|
||||
return;
|
||||
}
|
||||
|
||||
this.openPopup(plane);
|
||||
};
|
||||
|
||||
this.map.on('singleclick', this.clickHandler);
|
||||
}
|
||||
|
||||
private _draw() {
|
||||
const source = this.layer.getSource()!;
|
||||
source.clear();
|
||||
|
||||
for (const plane of this.data) {
|
||||
if (plane.latitude == null || plane.longitude == null) continue;
|
||||
|
||||
const coord = fromLonLat([plane.longitude, plane.latitude]);
|
||||
const f = new Feature({geometry: new Point(coord)});
|
||||
|
||||
f.set('icao', plane.icao);
|
||||
f.set('planeData', plane);
|
||||
f.setStyle(new Style({
|
||||
image: new Icon({
|
||||
src: `data:image/svg+xml,${encodeURIComponent(plane.icon)}`,
|
||||
scale: 1.25,
|
||||
rotation: (plane.heading ?? 0) * (Math.PI / 180),
|
||||
anchor: [0.5, 0.5],
|
||||
}),
|
||||
}));
|
||||
|
||||
source.addFeature(f);
|
||||
}
|
||||
}
|
||||
|
||||
private async _fetch() {
|
||||
const j = await fetch(`${API}/adsb`).then(r => r.ok ? r.json() : []);
|
||||
|
||||
this.data = (j || []).map((a: any) => ({
|
||||
...a,
|
||||
icao: a.hex,
|
||||
@@ -92,133 +123,189 @@ export class AirTrafficLayer {
|
||||
speed: a.gs,
|
||||
climb: a.baro_rate ?? a.geom_rate ?? 0,
|
||||
name: a.flight?.trim(),
|
||||
}))
|
||||
}
|
||||
|
||||
private _draw() {
|
||||
const source = this.layer.getSource()!
|
||||
source.clear()
|
||||
for (const plane of this.data) {
|
||||
if (plane.latitude == null) continue
|
||||
const coord = fromLonLat([plane.longitude, plane.latitude])
|
||||
const f = new Feature({ geometry: new Point(coord) })
|
||||
f.set('icao', plane.icao)
|
||||
f.set('planeData', plane)
|
||||
f.setStyle(new Style({
|
||||
image: new Icon({
|
||||
src: `data:image/svg+xml,${encodeURIComponent(plane.icon)}`,
|
||||
scale: 1.25,
|
||||
rotation: (plane.heading ?? 0) * (Math.PI / 180),
|
||||
anchor: [0.5, 0.5],
|
||||
}),
|
||||
}))
|
||||
source.addFeature(f)
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
private async _fetchTrace(plane: any) {
|
||||
const icao = plane.icao
|
||||
if (!this.historyCache[icao]) {
|
||||
const j = await fetch(`${API}/adsb/${icao}`).then(r => r.json())
|
||||
this.historyCache[icao] = j.history || []
|
||||
}
|
||||
const history = [...this.historyCache[icao]]
|
||||
const cur = { latitude: plane.latitude, longitude: plane.longitude, altitude: plane.alt_baro ?? plane.alt_geom ?? 0 }
|
||||
const last = history[history.length - 1]
|
||||
if (!last || last.latitude !== cur.latitude || last.longitude !== cur.longitude) history.push(cur)
|
||||
if (history.length < 2) return
|
||||
const icao = plane.icao;
|
||||
|
||||
let traceLayer = this.traceLayers[icao]
|
||||
let vs: VectorSource
|
||||
if (!this.historyCache[icao]) {
|
||||
const j = await fetch(`${API}/adsb/${icao}`).then(r => r.json());
|
||||
this.historyCache[icao] = j.history || [];
|
||||
}
|
||||
|
||||
const history = this.historyCache[icao];
|
||||
const cur = {
|
||||
ts: Math.floor(Date.now() / 1000),
|
||||
latitude: plane.latitude,
|
||||
longitude: plane.longitude,
|
||||
altitude: plane.alt_baro ?? plane.alt_geom ?? 0,
|
||||
live: true,
|
||||
};
|
||||
|
||||
const last = history[history.length - 1];
|
||||
if (!last || last.latitude !== cur.latitude || last.longitude !== cur.longitude) history.push(cur);
|
||||
|
||||
const traceHistory = history;
|
||||
|
||||
if (traceHistory.length < 2) {
|
||||
const popup = this.popups[icao];
|
||||
if (popup) popup.update(plane, history);
|
||||
return;
|
||||
}
|
||||
|
||||
let traceLayer = this.traceLayers[icao];
|
||||
let vs: VectorSource;
|
||||
|
||||
if (!traceLayer) {
|
||||
vs = new VectorSource()
|
||||
traceLayer = new VectorLayer({ source: vs, zIndex: 99 })
|
||||
this.map.addLayer(traceLayer)
|
||||
this.traceLayers[icao] = traceLayer
|
||||
this.traceSegs[icao] = []
|
||||
vs = new VectorSource();
|
||||
traceLayer = new VectorLayer({source: vs, zIndex: 99});
|
||||
this.map.addLayer(traceLayer);
|
||||
this.traceLayers[icao] = traceLayer;
|
||||
this.traceSegs[icao] = [];
|
||||
} else {
|
||||
vs = traceLayer.getSource()!
|
||||
;(this.traceSegs[icao] || []).forEach(f => vs.removeFeature(f))
|
||||
vs = traceLayer.getSource()!;
|
||||
}
|
||||
|
||||
const segs: Feature[] = []
|
||||
for (let i = 0; i < history.length - 1; i++) {
|
||||
const s = history[i], e = history[i + 1]
|
||||
const sc = getAltColor(s.altitude || 0)
|
||||
const ec = getAltColor(e.altitude || 0)
|
||||
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])]))
|
||||
f.setStyle(new Style({ stroke: new Stroke({ color: `rgba(${avg.join(',')},0.8)`, width: 3 }) }))
|
||||
vs.addFeature(f)
|
||||
segs.push(f)
|
||||
}
|
||||
this.traceSegs[icao] = segs
|
||||
const segs: any = this.traceSegs[icao];
|
||||
|
||||
for (let i = segs.length; i < traceHistory.length - 1; i++) {
|
||||
const s = traceHistory[i], e = traceHistory[i + 1];
|
||||
const sc = getAltColor(s.altitude || 0), ec: any = getAltColor(e.altitude || 0);
|
||||
const avg = sc.map((v, idx) => Math.round((v + ec[idx]) / 2));
|
||||
|
||||
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,
|
||||
}),
|
||||
}));
|
||||
|
||||
vs.addFeature(f);
|
||||
segs.push(f);
|
||||
}
|
||||
|
||||
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) {
|
||||
const icao = plane.icao
|
||||
if (this.popups[icao]) return
|
||||
|
||||
const planeRef = ref(plane)
|
||||
const posRef = ref(this._calcPopupPos(plane))
|
||||
|
||||
const container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
|
||||
const mobile = window.innerWidth <= 768;
|
||||
|
||||
const app = createApp(AircraftPopup, {
|
||||
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),
|
||||
onBringToFront: () => bringToFront(container),
|
||||
})
|
||||
|
||||
app.mount(container)
|
||||
|
||||
this.popups[icao] = {
|
||||
planeRef,
|
||||
posRef,
|
||||
unmount: () => { app.unmount(); container.remove() },
|
||||
}
|
||||
this._fetchTrace(plane)
|
||||
}
|
||||
|
||||
private _closePopup(icao: string) {
|
||||
const popup = this.popups[icao]
|
||||
if (popup) { popup.unmount(); delete this.popups[icao] }
|
||||
const segs = this.traceSegs[icao]
|
||||
const layer = this.traceLayers[icao]
|
||||
if (segs && layer) segs.forEach(f => layer.getSource()!.removeFeature(f))
|
||||
if (layer) { this.map.removeLayer(layer); delete this.traceLayers[icao] }
|
||||
delete this.traceSegs[icao]
|
||||
const popup = this.popups[icao];
|
||||
if (popup) popup.update(plane, history);
|
||||
}
|
||||
|
||||
private _refreshPopups() {
|
||||
for (const icao of Object.keys(this.popups)) {
|
||||
const plane = this.data.find(p => p.icao === icao)
|
||||
if (!plane) { this._closePopup(icao); continue }
|
||||
this.popups[icao].planeRef.value = plane
|
||||
this._fetchTrace(plane)
|
||||
const plane = this.data.find(p => p.icao === icao);
|
||||
if (!plane) {
|
||||
this.closePopup(icao);
|
||||
continue;
|
||||
}
|
||||
this._fetchTrace(plane);
|
||||
}
|
||||
}
|
||||
|
||||
private _attachClick() {
|
||||
this.clickHandler = (evt) => {
|
||||
const f = this.map.forEachFeatureAtPixel(evt.pixel, f => f.get('planeData') ? f : null)
|
||||
if (!f) return
|
||||
const plane = f.get('planeData')
|
||||
if (this.popups[plane.icao]) { this._closePopup(plane.icao); return }
|
||||
this._openPopup(plane)
|
||||
hide() {
|
||||
if(!this.visible) return;
|
||||
this.visible = false;
|
||||
|
||||
if(this.refreshTimer) {
|
||||
clearInterval(this.refreshTimer);
|
||||
this.refreshTimer = null;
|
||||
}
|
||||
this.map.on('singleclick', this.clickHandler)
|
||||
|
||||
if(this.clickHandler) {
|
||||
this.map.un('singleclick', this.clickHandler);
|
||||
this.clickHandler = null;
|
||||
}
|
||||
|
||||
for(const icao of Object.keys(this.popups)) this.closePopup(icao);
|
||||
if(this.layer) this.map.removeLayer(this.layer);
|
||||
Object.values(this.traceLayers).forEach(l => this.map.removeLayer(l));
|
||||
this.traceLayers = {};
|
||||
this.traceSegs = {};
|
||||
}
|
||||
|
||||
async show() {
|
||||
if(this.visible) return;
|
||||
|
||||
this.visible = true;
|
||||
this.layer = new VectorLayer({source: new VectorSource(), zIndex: 100});
|
||||
this.map.addLayer(this.layer);
|
||||
|
||||
await this._fetch();
|
||||
this._draw();
|
||||
this._attachClick();
|
||||
|
||||
this.refreshTimer = adjustedInterval(async () => {
|
||||
try {
|
||||
await this._fetch();
|
||||
this._draw();
|
||||
this._refreshPopups();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
}, 1_000);
|
||||
}
|
||||
|
||||
getById(icao: string): any | null {
|
||||
return this.data.find(p => String(p.icao) === String(icao)) ?? null;
|
||||
}
|
||||
|
||||
getCandidates(latitude: number, longitude: number, limit = Infinity): any[] {
|
||||
function score(plane: any): number {
|
||||
const ratio = Math.min(greatCircleDist(latitude, longitude, plane.latitude, plane.longitude) / AUTO_RANGE, 1);
|
||||
return isPriorityAircraft(plane) ? 2 - ratio : 1 - ratio;
|
||||
}
|
||||
|
||||
return this.data.map(plane => ({...plane, score: score(plane)})).toSorted(sortByProp('score', true));
|
||||
}
|
||||
|
||||
openPopup(plane: any) {
|
||||
const icao = String(plane.icao);
|
||||
if(this.popups[icao]) return;
|
||||
|
||||
const container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
|
||||
const mobile = window.innerWidth <= 768;
|
||||
const makeProps = (p: any, history: any[] = []) => ({
|
||||
plane: p,
|
||||
history,
|
||||
position: mobile
|
||||
? {x: window.innerWidth / 2 - 180, y: window.innerHeight - 540 - 16}
|
||||
: {x: 16, y: 16},
|
||||
onClose: () => this.closePopup(icao),
|
||||
onBringToFront: () => bringToFront(container),
|
||||
});
|
||||
|
||||
vueRender(h(AircraftPopup, makeProps(plane, this.historyCache[icao] || [])), container);
|
||||
|
||||
this.popups[icao] = {
|
||||
update: (p: any, history: any[] = []) => vueRender(h(AircraftPopup, makeProps(p, history)), container),
|
||||
unmount: () => {
|
||||
vueRender(null, container);
|
||||
container.remove();
|
||||
},
|
||||
};
|
||||
|
||||
this._fetchTrace(plane);
|
||||
}
|
||||
|
||||
closePopup(icao: string) {
|
||||
const popup = this.popups[icao];
|
||||
if(popup) {
|
||||
popup.unmount();
|
||||
delete this.popups[icao];
|
||||
}
|
||||
|
||||
const segs = this.traceSegs[icao], layer = this.traceLayers[icao];
|
||||
if(segs && layer) segs.forEach(f => layer.getSource()!.removeFeature(f));
|
||||
if(layer) {
|
||||
this.map.removeLayer(layer);
|
||||
delete this.traceLayers[icao];
|
||||
}
|
||||
|
||||
delete this.traceSegs[icao];
|
||||
}
|
||||
}
|
||||
|
||||
+171
-110
@@ -1,17 +1,19 @@
|
||||
import { BASE } from '@/services/api.ts'
|
||||
import { createApp, ref } from 'vue'
|
||||
import Map from 'ol/Map'
|
||||
import { Feature } from 'ol'
|
||||
import Point from 'ol/geom/Point'
|
||||
import { fromLonLat } from 'ol/proj'
|
||||
import { Style, Icon } from 'ol/style'
|
||||
import { Vector as VectorLayer } from 'ol/layer'
|
||||
import { Vector as VectorSource } from 'ol/source'
|
||||
import { adjustedInterval } from '@ztimson/utils'
|
||||
import ShipPopup from '@/components/Ship.vue'
|
||||
import { bringToFront } from './zindex'
|
||||
import {BASE} from '@/services/api.ts';
|
||||
import {greatCircleDist} from '@/services/units.ts';
|
||||
import {h, render as vueRender} from 'vue';
|
||||
import Map from 'ol/Map';
|
||||
import {Feature} from 'ol';
|
||||
import Point from 'ol/geom/Point';
|
||||
import {fromLonLat} from 'ol/proj';
|
||||
import {Style, Icon} from 'ol/style';
|
||||
import {Vector as VectorLayer} from 'ol/layer';
|
||||
import {Vector as VectorSource} from 'ol/source';
|
||||
import {adjustedInterval, sortByProp} from '@ztimson/utils';
|
||||
import ShipPopup from '@/components/Ship.vue';
|
||||
import {bringToFront} from './zindex';
|
||||
|
||||
const API = BASE + '/api'
|
||||
const API = BASE + '/api';
|
||||
const AUTO_RANGE = 100;
|
||||
|
||||
const SHIP_COLORS: Record<string, string> = {
|
||||
'Base Station': '#ffffff',
|
||||
@@ -22,10 +24,29 @@ const SHIP_COLORS: Record<string, string> = {
|
||||
'Class B/CS': '#8450ea',
|
||||
'Sart/Epirb/MOB': '#00aaff',
|
||||
'Unknown': '#fad106',
|
||||
};
|
||||
|
||||
function isPriorityBoat(boat: any): boolean {
|
||||
if (boat.military === true || boat.is_military === true || boat.sar === true || boat.is_sar === true) return true;
|
||||
|
||||
const text = [
|
||||
boat.type,
|
||||
boat.vesselType,
|
||||
boat.category,
|
||||
boat.name,
|
||||
boat.shipname,
|
||||
boat.operator,
|
||||
boat.owner,
|
||||
boat.callsign,
|
||||
boat.description,
|
||||
].filter(Boolean).join(' ').toLowerCase();
|
||||
|
||||
return /\b(military|mil|sar|search and rescue|rescue|coast guard|navy|army|air force|airforce|rcaf)\b/.test(text);
|
||||
}
|
||||
|
||||
function buildBoatIcon(boat: any): string {
|
||||
const color = SHIP_COLORS[boat.type] || SHIP_COLORS['Unknown']
|
||||
const color = SHIP_COLORS[boat.type] || SHIP_COLORS['Unknown'];
|
||||
|
||||
if (['Base Station', 'AtoN'].includes(boat.type)) {
|
||||
return `data:image/svg+xml,${encodeURIComponent(`
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
|
||||
@@ -35,8 +56,9 @@ function buildBoatIcon(boat: any): string {
|
||||
<line x1="11" y1="12" x2="21" y2="12" stroke="#000" stroke-width="1.8"/>
|
||||
<path d="M9,18 Q16,26 23,18" fill="none" stroke="#000" stroke-width="1.5"/>
|
||||
</svg>
|
||||
`)}`
|
||||
`)}`;
|
||||
}
|
||||
|
||||
return `data:image/svg+xml,${encodeURIComponent(`
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="32" viewBox="0 0 24 32">
|
||||
<polygon points="12,0 24,30 12,24 0,30" fill="${color}" stroke="#000" stroke-width="1.5"/>
|
||||
@@ -45,129 +67,168 @@ function buildBoatIcon(boat: any): string {
|
||||
<line x1="8" y1="14" x2="16" y2="14" stroke="#000" stroke-width="1.5"/>
|
||||
<path d="M7,17 Q12,24 17,17" fill="none" stroke="#000" stroke-width="1.3"/>
|
||||
</svg>
|
||||
`)}`
|
||||
`)}`;
|
||||
}
|
||||
|
||||
export class AISLayer {
|
||||
private map: Map
|
||||
private layer!: VectorLayer<VectorSource>
|
||||
private popups: any = {}
|
||||
private data: any[] = []
|
||||
private clickHandler: ((e: any) => void) | null = null
|
||||
private refreshTimer: any = null
|
||||
visible = false
|
||||
private map: Map;
|
||||
private layer!: VectorLayer<VectorSource>;
|
||||
private popups: any = {};
|
||||
private data: any[] = [];
|
||||
private clickHandler: ((e: any) => void) | null = null;
|
||||
private refreshTimer: any = null;
|
||||
visible = false;
|
||||
|
||||
constructor(map: Map) { this.map = map }
|
||||
|
||||
async show() {
|
||||
if (this.visible) return
|
||||
this.visible = true
|
||||
this.layer = new VectorLayer({ source: new VectorSource(), zIndex: 100 })
|
||||
this.map.addLayer(this.layer)
|
||||
await this._fetch()
|
||||
this._draw()
|
||||
this._attachClick()
|
||||
this.refreshTimer = adjustedInterval(async () => {
|
||||
try {
|
||||
await this._fetch()
|
||||
this._draw()
|
||||
this._refreshPopups()
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
}, 5_000)
|
||||
constructor(map: Map) {
|
||||
this.map = map;
|
||||
}
|
||||
|
||||
hide() {
|
||||
if (!this.visible) return
|
||||
this.visible = false
|
||||
if (this.refreshTimer) { clearInterval(this.refreshTimer); this.refreshTimer = null }
|
||||
if (this.clickHandler) { this.map.un('singleclick', this.clickHandler); this.clickHandler = null }
|
||||
for (const mmsi of Object.keys(this.popups)) this._closePopup(mmsi)
|
||||
this.map.removeLayer(this.layer)
|
||||
private _attachClick() {
|
||||
this.clickHandler = evt => {
|
||||
const f = this.map.forEachFeatureAtPixel(evt.pixel, f => f.get('boatData') ? f : null);
|
||||
if (!f) return;
|
||||
|
||||
const boat = f.get('boatData');
|
||||
|
||||
if (this.popups[boat.mmsi]) {
|
||||
this.closePopup(boat.mmsi);
|
||||
return;
|
||||
}
|
||||
|
||||
private async _fetch() {
|
||||
this.data = await fetch(`${API}/ais`).then(r => r.json()) || []
|
||||
this.openPopup(boat);
|
||||
};
|
||||
|
||||
this.map.on('singleclick', this.clickHandler);
|
||||
}
|
||||
|
||||
private _draw() {
|
||||
const source = this.layer.getSource()!
|
||||
source.clear()
|
||||
const source = this.layer.getSource()!;
|
||||
source.clear();
|
||||
|
||||
for (const boat of this.data) {
|
||||
if (boat.lat == null || boat.lon == null) continue
|
||||
const coord = fromLonLat([boat.lon, boat.lat])
|
||||
const f = new Feature({ geometry: new Point(coord) })
|
||||
f.set('mmsi', boat.mmsi)
|
||||
f.set('boatData', boat)
|
||||
if (boat.lat == null || boat.lon == null) continue;
|
||||
|
||||
const coord = fromLonLat([boat.lon, boat.lat]);
|
||||
const f = new Feature({geometry: new Point(coord)});
|
||||
|
||||
f.set('mmsi', boat.mmsi);
|
||||
f.set('boatData', boat);
|
||||
f.setStyle(new Style({
|
||||
image: new Icon({
|
||||
src: buildBoatIcon(boat),
|
||||
scale: 0.8,
|
||||
rotation: (boat.heading ?? boat.course ?? 0) * (Math.PI / 180),
|
||||
rotation: (boat.heading ?? boat.bearing ?? boat.cog ?? boat.course ?? 0) * (Math.PI / 180),
|
||||
anchor: [0.5, 0.5],
|
||||
}),
|
||||
}))
|
||||
source.addFeature(f)
|
||||
}));
|
||||
|
||||
source.addFeature(f);
|
||||
}
|
||||
}
|
||||
|
||||
private _calcPopupPos(boat: any): { x: number; y: number } {
|
||||
const pixel: any = this.map.getPixelFromCoordinate(fromLonLat([boat.lon, boat.lat]))
|
||||
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(boat: any) {
|
||||
const mmsi = String(boat.mmsi)
|
||||
if (this.popups[mmsi]) return
|
||||
|
||||
const boatRef = ref(boat)
|
||||
const posRef = ref(this._calcPopupPos(boat))
|
||||
|
||||
const container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
|
||||
const mobile = window.innerWidth <= 768;
|
||||
|
||||
const app = createApp(ShipPopup, {
|
||||
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),
|
||||
onBringToFront: () => bringToFront(container),
|
||||
})
|
||||
|
||||
app.mount(container)
|
||||
this.popups[mmsi] = {
|
||||
boatRef,
|
||||
posRef,
|
||||
unmount: () => { app.unmount(); container.remove() },
|
||||
}
|
||||
}
|
||||
|
||||
private _closePopup(mmsi: string) {
|
||||
const popup = this.popups[mmsi]
|
||||
if (popup) { popup.unmount(); delete this.popups[mmsi] }
|
||||
private async _fetch() {
|
||||
this.data = await fetch(`${API}/ais`).then(r => r.json()) || [];
|
||||
}
|
||||
|
||||
private _refreshPopups() {
|
||||
for (const mmsi of Object.keys(this.popups)) {
|
||||
const boat = this.data.find(b => String(b.mmsi) === mmsi)
|
||||
if (!boat) { this._closePopup(mmsi); continue }
|
||||
this.popups[mmsi].boatRef.value = boat
|
||||
const boat = this.data.find(b => String(b.mmsi) === mmsi);
|
||||
if (!boat) {
|
||||
this.closePopup(mmsi);
|
||||
continue;
|
||||
}
|
||||
this.popups[mmsi].update(boat);
|
||||
}
|
||||
}
|
||||
|
||||
private _attachClick() {
|
||||
this.clickHandler = (evt) => {
|
||||
const f = this.map.forEachFeatureAtPixel(evt.pixel, f => f.get('boatData') ? f : null)
|
||||
if (!f) return
|
||||
const boat = f.get('boatData')
|
||||
const mmsi = String(boat.mmsi)
|
||||
if (this.popups[mmsi]) { this._closePopup(mmsi); return }
|
||||
this._openPopup(boat)
|
||||
hide() {
|
||||
if (!this.visible) return;
|
||||
this.visible = false;
|
||||
|
||||
if (this.refreshTimer) {
|
||||
clearInterval(this.refreshTimer);
|
||||
this.refreshTimer = null;
|
||||
}
|
||||
|
||||
if (this.clickHandler) {
|
||||
this.map.un('singleclick', this.clickHandler);
|
||||
this.clickHandler = null;
|
||||
}
|
||||
|
||||
for (const mmsi of Object.keys(this.popups)) this.closePopup(mmsi);
|
||||
if (this.layer) this.map.removeLayer(this.layer);
|
||||
}
|
||||
|
||||
async show() {
|
||||
if (this.visible) return;
|
||||
|
||||
this.visible = true;
|
||||
this.layer = new VectorLayer({source: new VectorSource(), zIndex: 100});
|
||||
this.map.addLayer(this.layer);
|
||||
|
||||
await this._fetch();
|
||||
this._draw();
|
||||
this._attachClick();
|
||||
|
||||
this.refreshTimer = adjustedInterval(async () => {
|
||||
try {
|
||||
await this._fetch();
|
||||
this._draw();
|
||||
this._refreshPopups();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
}, 5_000);
|
||||
}
|
||||
|
||||
getById(mmsi: string): any | null {
|
||||
return this.data.find(b => String(b.mmsi) === String(mmsi)) ?? null;
|
||||
}
|
||||
|
||||
getCandidates(latitude: number, longitude: number, limit = Infinity): any[] {
|
||||
function score(boat: any): number {
|
||||
const ratio = Math.min(greatCircleDist(latitude, longitude, boat.lat, boat.lon) / AUTO_RANGE, 1);
|
||||
return isPriorityBoat(boat) ? 2 - ratio : 1 - ratio;
|
||||
}
|
||||
|
||||
return this.data.filter(b => b.lat != null && b.lon != null)
|
||||
.map(boat => ({...boat, score: score(boat)}))
|
||||
.toSorted(sortByProp('score', true));
|
||||
}
|
||||
|
||||
openPopup(boat: any) {
|
||||
const mmsi = String(boat.mmsi);
|
||||
if (this.popups[mmsi]) return;
|
||||
|
||||
const container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
|
||||
const mobile = window.innerWidth <= 768;
|
||||
const makeProps = (b: any) => ({
|
||||
boat: b,
|
||||
position: mobile
|
||||
? {x: window.innerWidth / 2 - 180, y: window.innerHeight - 540 - 16}
|
||||
: {x: 16, y: 16},
|
||||
onClose: () => this.closePopup(mmsi),
|
||||
onBringToFront: () => bringToFront(container),
|
||||
});
|
||||
|
||||
vueRender(h(ShipPopup, makeProps(boat)), container);
|
||||
|
||||
this.popups[mmsi] = {
|
||||
update: (b: any) => vueRender(h(ShipPopup, makeProps(b)), container),
|
||||
unmount: () => {
|
||||
vueRender(null, container);
|
||||
container.remove();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
closePopup(mmsi: string) {
|
||||
const popup = this.popups[mmsi];
|
||||
if (popup) {
|
||||
popup.unmount();
|
||||
delete this.popups[mmsi];
|
||||
}
|
||||
this.map.on('singleclick', this.clickHandler)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import 'ol/ol.css';
|
||||
import {BASE} from '@/services/api.ts';
|
||||
import {Feature} from 'ol';
|
||||
import Point from 'ol/geom/Point';
|
||||
import VectorLayer from 'ol/layer/Vector';
|
||||
import VectorSource from 'ol/source/Vector';
|
||||
import {Style} from 'ol/style';
|
||||
|
||||
export function normalize(coordinates: number | {lon: number} | {long: number} | {longitude: number}) {
|
||||
const convert = (n) => {
|
||||
if (n > 180) return n - 360;
|
||||
if (n < -180) return n + 360;
|
||||
return n;
|
||||
}
|
||||
|
||||
if(typeof coordinates == 'number') return convert(coordinates);
|
||||
if(typeof coordinates['lon'] == 'number') return convert(coordinates['lon']);
|
||||
if(typeof coordinates['long'] == 'number') return convert(coordinates['long']);
|
||||
if(typeof coordinates['longitude'] == 'number') return convert(coordinates['longitude']);
|
||||
}
|
||||
|
||||
export class Aurora {
|
||||
data: any = null;
|
||||
layer;
|
||||
map;
|
||||
name = 'aurora';
|
||||
refresh;
|
||||
refreshRate = 60_000 * 60;
|
||||
timestamp;
|
||||
visible = false;
|
||||
|
||||
constructor(map) {
|
||||
this.map = map;
|
||||
}
|
||||
|
||||
private style(intensity: number) {
|
||||
return new Style({renderer: (coordinates, state) => {
|
||||
const ctx = state.context as CanvasRenderingContext2D;
|
||||
const [x, y] = <[number, number]>coordinates;
|
||||
const previousComposite = ctx.globalCompositeOperation;
|
||||
ctx.globalCompositeOperation = 'screen';
|
||||
const zoom = this.map.getView().getZoom() || 1;
|
||||
const baseRadius = 50;
|
||||
const radius = baseRadius * Math.pow(2, zoom - 3);
|
||||
const gradient = ctx.createRadialGradient(x, y, 0, x, y, radius);
|
||||
const alpha = intensity / 50;
|
||||
gradient.addColorStop(0, `rgba(0, 255, 0, ${alpha})`);
|
||||
gradient.addColorStop(1, 'rgba(0, 255, 0, 0)');
|
||||
ctx.fillStyle = gradient;
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, radius, 0, 2 * Math.PI);
|
||||
ctx.fill();
|
||||
ctx.globalCompositeOperation = previousComposite;
|
||||
}});
|
||||
};
|
||||
|
||||
async update() {
|
||||
const d = await fetch(BASE + '/api/aurora').then(resp => resp.json());
|
||||
this.timestamp = new Date(d.timestamp).getTime();
|
||||
this.data = d.data;
|
||||
}
|
||||
|
||||
async redraw() {
|
||||
const threshold = 5;
|
||||
const features = (this.data?.coordinates || [])
|
||||
.filter(([lon, lat, intensity]: [number, number, number], i) => i % 4 == 0 && lat != 0 && intensity >= threshold)
|
||||
.map(([lon, lat, intensity]: [number, number, number]) => {
|
||||
const feature = new Feature({geometry: new Point([normalize(lon), lat])});
|
||||
const geom = feature.getGeometry() as Point;
|
||||
geom.transform('EPSG:4326', this.map.getView().getProjection());
|
||||
feature.setStyle(this.style(intensity));
|
||||
return feature;
|
||||
});
|
||||
if(this.layer) this.map.removeLayer(this.layer);
|
||||
if(!features.length) return;
|
||||
this.layer = new VectorLayer({source: new VectorSource({features, wrapX: true}), zIndex: 5030, opacity: 0.4});
|
||||
this.layer.set('name', this.name);
|
||||
this.map.addLayer(this.layer);
|
||||
}
|
||||
|
||||
async show() {
|
||||
if(this.visible) return;
|
||||
this.visible = true;
|
||||
if(!this.data || Date.now() - this.timestamp > this.refreshRate) await this.update();
|
||||
if(!this.visible) return;
|
||||
await this.redraw();
|
||||
if(!this.refresh) {
|
||||
this.refresh = setInterval(async () => {
|
||||
await this.update();
|
||||
if(!this.visible) return;
|
||||
await this.redraw();
|
||||
}, this.refreshRate);
|
||||
}
|
||||
}
|
||||
|
||||
hide() {
|
||||
if(!this.visible) return;
|
||||
this.visible = false;
|
||||
if(this.refresh) this.refresh = clearInterval(this.refresh);
|
||||
this.map.getLayers().forEach(layer => {
|
||||
if(layer?.get('name') == this.name) this.map.removeLayer(layer);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
import {api, BASE} from '@/services/api.ts';
|
||||
import {greatCircleDist} from '@/services/units.ts';
|
||||
import {h, render as vueRender} from 'vue';
|
||||
import Map from 'ol/Map';
|
||||
import {Feature} from 'ol';
|
||||
import {LineString, Point, Circle as CircleGeom} from 'ol/geom';
|
||||
import {fromLonLat} from 'ol/proj';
|
||||
import {Style, Stroke, Icon, Fill} from 'ol/style';
|
||||
import {Vector as VectorLayer} from 'ol/layer';
|
||||
import {Vector as VectorSource} from 'ol/source';
|
||||
import {adjustedInterval, sortByProp} from '@ztimson/utils';
|
||||
import Satellite from '@/components/Satellite.vue';
|
||||
import {bringToFront} from './zindex';
|
||||
|
||||
const API = BASE + '/api';
|
||||
const EARTH_R = 6371;
|
||||
|
||||
interface TinyGSReading {
|
||||
id: number;
|
||||
timestamp: number;
|
||||
freqMHz: number;
|
||||
satellite: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
az: number;
|
||||
el: number;
|
||||
packetRssi: number;
|
||||
packetSnr: number;
|
||||
freqError: number;
|
||||
crcOk: boolean;
|
||||
}
|
||||
|
||||
interface TinyGSSatellite extends TinyGSReading {
|
||||
history: TinyGSReading[];
|
||||
}
|
||||
|
||||
interface RangeEstimate {
|
||||
altitude: number;
|
||||
slantRange: number;
|
||||
horizonRadius: number;
|
||||
groundDist: number;
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
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};
|
||||
}
|
||||
|
||||
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);
|
||||
const sumY = ys.reduce((a, b) => a + b, 0);
|
||||
const sumXY = xs.reduce((a, x, i) => a + x * ys[i], 0);
|
||||
const 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;
|
||||
|
||||
const lastEl: any = ys[ys.length - 1], lastX: any = 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: any;
|
||||
|
||||
constructor(map: Map) {
|
||||
this.map = map;
|
||||
api.position().then(gs => this.gs = gs);
|
||||
}
|
||||
|
||||
private _attachClick() {
|
||||
this.clickHandler = evt => {
|
||||
const f = this.map.forEachFeatureAtPixel(evt.pixel, f => f.get('satData') ? f : null);
|
||||
if (!f) return;
|
||||
|
||||
const sat = f.get('satData') as TinyGSSatellite;
|
||||
|
||||
if (this.popups[sat.id]) return this.closePopup(sat.id);
|
||||
this.openPopup(sat);
|
||||
};
|
||||
|
||||
this.map.on('singleclick', this.clickHandler);
|
||||
}
|
||||
|
||||
private _draw() {
|
||||
const source = this.layer.getSource()!;
|
||||
source.clear();
|
||||
|
||||
for (const sat of this.data) {
|
||||
if (sat.latitude == null || sat.longitude == null) continue;
|
||||
|
||||
const points: any = [...sat.history, sat].map(r => fromLonLat([r.longitude, r.latitude]));
|
||||
|
||||
if (points.length > 1) {
|
||||
const trail = new Feature({geometry: new LineString(points)});
|
||||
trail.setStyle(new Style({
|
||||
stroke: new Stroke({
|
||||
color: 'rgba(212,164,255,0.5)',
|
||||
width: 2,
|
||||
lineDash: [6, 4],
|
||||
}),
|
||||
}));
|
||||
|
||||
source.addFeature(trail);
|
||||
}
|
||||
|
||||
const marker = new Feature({geometry: new Point(points[points.length - 1])});
|
||||
marker.set('satellite', sat.id);
|
||||
marker.set('satData', sat);
|
||||
marker.setStyle(new Style({
|
||||
image: new Icon({
|
||||
src: '/satellite.png',
|
||||
scale: 0.1,
|
||||
anchor: [0.5, 0.5],
|
||||
}),
|
||||
}));
|
||||
|
||||
source.addFeature(marker);
|
||||
}
|
||||
}
|
||||
|
||||
private async _fetch() {
|
||||
this.data = await fetch(`${API}/sats`).then(r => r.json()) || [];
|
||||
}
|
||||
|
||||
private _calcRange(sat: TinyGSSatellite): RangeEstimate | null {
|
||||
if (!this.gs) return null;
|
||||
return estimateRange(this.gs.latitude, this.gs.longitude, sat.latitude, sat.longitude, sat.el);
|
||||
}
|
||||
|
||||
private _drawReceptionCircle(sat: TinyGSSatellite, radiusKm?: number | null) {
|
||||
this._removeReceptionCircle(sat.id);
|
||||
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.id] = layer;
|
||||
}
|
||||
|
||||
private _removeReceptionCircle(id: number) {
|
||||
const layer = this.receptionCircles[id];
|
||||
if (layer) {
|
||||
this.map.removeLayer(layer);
|
||||
delete this.receptionCircles[id];
|
||||
}
|
||||
}
|
||||
|
||||
private _refreshPopups() {
|
||||
for (const id of Object.keys(this.popups).map(Number)) {
|
||||
const sat = this.data.find(s => s.id === id);
|
||||
if (!sat) {
|
||||
this.closePopup(id);
|
||||
continue;
|
||||
}
|
||||
|
||||
const history = [...sat.history, sat];
|
||||
const range = this._calcRange(sat);
|
||||
|
||||
this.popups[id].update(history, range, estimateEtaOut(history));
|
||||
this._drawReceptionCircle(sat, range?.horizonRadius);
|
||||
}
|
||||
}
|
||||
|
||||
hide() {
|
||||
if (!this.visible) return;
|
||||
this.visible = false;
|
||||
|
||||
if (this.refreshTimer) {
|
||||
clearInterval(this.refreshTimer);
|
||||
this.refreshTimer = null;
|
||||
}
|
||||
|
||||
if (this.clickHandler) {
|
||||
this.map.un('singleclick', this.clickHandler);
|
||||
this.clickHandler = null;
|
||||
}
|
||||
|
||||
for (const id of Object.keys(this.popups).map(Number)) this.closePopup(id);
|
||||
if (this.layer) this.map.removeLayer(this.layer);
|
||||
}
|
||||
|
||||
async show() {
|
||||
if (this.visible) return;
|
||||
|
||||
this.visible = true;
|
||||
this.layer = new VectorLayer({source: new VectorSource(), zIndex: 105});
|
||||
this.map.addLayer(this.layer);
|
||||
|
||||
await this._fetch();
|
||||
this._draw();
|
||||
this._attachClick();
|
||||
|
||||
this.refreshTimer = adjustedInterval(async () => {
|
||||
try {
|
||||
await this._fetch();
|
||||
this._draw();
|
||||
this._refreshPopups();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
}, 5_000);
|
||||
}
|
||||
|
||||
getById(id: number): TinyGSSatellite | null {
|
||||
return this.data.find(s => s.id === id) ?? null;
|
||||
}
|
||||
|
||||
getCandidates(latitude: number, longitude: number, limit = Infinity): TinyGSSatellite[] {
|
||||
const dist = (sat: TinyGSSatellite) => this._calcRange(sat)?.slantRange ?? greatCircleDist(latitude, longitude, sat.latitude, sat.longitude);
|
||||
|
||||
return this.data.filter(s => s.latitude != null && s.longitude != null)
|
||||
.map(sat => ({...sat, score: -dist(sat)}))
|
||||
.toSorted(sortByProp('score', true));
|
||||
}
|
||||
|
||||
getRange(sat: TinyGSSatellite): number | null {
|
||||
return this._calcRange(sat)?.slantRange ?? null;
|
||||
}
|
||||
|
||||
openPopup(sat: TinyGSSatellite) {
|
||||
if (this.popups[sat.id]) return;
|
||||
|
||||
const history = [...sat.history, sat];
|
||||
const range = this._calcRange(sat);
|
||||
const eta = estimateEtaOut(history);
|
||||
|
||||
this._drawReceptionCircle(sat, range?.horizonRadius);
|
||||
|
||||
const container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
|
||||
const mobile = window.innerWidth <= 768;
|
||||
const makeProps = (h_: TinyGSReading[], r: RangeEstimate | null, e: number | null) => ({
|
||||
history: h_,
|
||||
range: r,
|
||||
eta: e,
|
||||
position: mobile
|
||||
? {x: window.innerWidth / 2 - 180, y: window.innerHeight - 540 - 16}
|
||||
: {x: 16, y: 16},
|
||||
onClose: () => this.closePopup(sat.id),
|
||||
onBringToFront: () => bringToFront(container),
|
||||
});
|
||||
|
||||
vueRender(h(Satellite, makeProps(history, range, eta)), container);
|
||||
|
||||
this.popups[sat.id] = {
|
||||
update: (h_: TinyGSReading[], r: RangeEstimate | null, e: number | null) => vueRender(h(Satellite, makeProps(h_, r, e)), container),
|
||||
unmount: () => {
|
||||
vueRender(null, container);
|
||||
container.remove();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
closePopup(id: number) {
|
||||
const popup = this.popups[id];
|
||||
if (popup) {
|
||||
popup.unmount();
|
||||
delete this.popups[id];
|
||||
}
|
||||
|
||||
this._removeReceptionCircle(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
import {BASE} from '@/services/api.ts';
|
||||
import {greatCircleDist} from '@/services/units.ts';
|
||||
import {h, render as vueRender} from 'vue';
|
||||
import Map from 'ol/Map';
|
||||
import {Feature} from 'ol';
|
||||
import {LineString, Point} from 'ol/geom';
|
||||
import {fromLonLat} from 'ol/proj';
|
||||
import {Style, Stroke, Icon} from 'ol/style';
|
||||
import {Vector as VectorLayer} from 'ol/layer';
|
||||
import {Vector as VectorSource} from 'ol/source';
|
||||
import {adjustedInterval, sortByProp} from '@ztimson/utils';
|
||||
import SondePopup from '@/components/WeatherBalloon.vue';
|
||||
import {bringToFront} from './zindex';
|
||||
|
||||
const API = BASE + '/api';
|
||||
const SONDE_STALE_TTL = 60_000 * 5;
|
||||
|
||||
interface SondeReading {
|
||||
timestamp: number;
|
||||
id: string;
|
||||
type: string;
|
||||
subtype: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
altitude: number;
|
||||
heading: number;
|
||||
speed: number;
|
||||
climb: number;
|
||||
sats: number;
|
||||
battery: number;
|
||||
temperature: number;
|
||||
humidity: number;
|
||||
pressure: number;
|
||||
freqMHz: number;
|
||||
snr: number;
|
||||
ppm: number;
|
||||
}
|
||||
|
||||
interface Sonde extends SondeReading {
|
||||
history: SondeReading[];
|
||||
}
|
||||
|
||||
export class SondesLayer {
|
||||
private map: Map;
|
||||
private layer!: VectorLayer<VectorSource>;
|
||||
private popups: any = {};
|
||||
private data: Sonde[] = [];
|
||||
private clickHandler: ((e: any) => void) | null = null;
|
||||
private refreshTimer: any = null;
|
||||
visible = false;
|
||||
|
||||
constructor(map: Map) {
|
||||
this.map = map;
|
||||
}
|
||||
|
||||
private _attachClick() {
|
||||
this.clickHandler = evt => {
|
||||
const f = this.map.forEachFeatureAtPixel(evt.pixel, f => f.get('sondeData') ? f : null);
|
||||
if (!f) return;
|
||||
|
||||
const sonde = f.get('sondeData') as Sonde;
|
||||
|
||||
if (this.popups[sonde.id]) {
|
||||
this.closePopup(sonde.id);
|
||||
return;
|
||||
}
|
||||
|
||||
this.openPopup(sonde);
|
||||
};
|
||||
|
||||
this.map.on('singleclick', this.clickHandler);
|
||||
}
|
||||
|
||||
private _draw() {
|
||||
const source = this.layer.getSource()!;
|
||||
source.clear();
|
||||
|
||||
for (const sonde of this.data) {
|
||||
if (sonde.latitude == null || sonde.longitude == null) continue;
|
||||
|
||||
const points: any = [...sonde.history, sonde].map(r => fromLonLat([r.longitude, r.latitude]));
|
||||
|
||||
if (points.length > 1) {
|
||||
const trail = new Feature({geometry: new LineString(points)});
|
||||
trail.setStyle(new Style({
|
||||
stroke: new Stroke({
|
||||
color: 'rgba(255,170,0,0.5)',
|
||||
width: 2,
|
||||
lineDash: [6, 4],
|
||||
}),
|
||||
}));
|
||||
|
||||
source.addFeature(trail);
|
||||
}
|
||||
|
||||
const marker = new Feature({geometry: new Point(points[points.length - 1])});
|
||||
marker.set('sonde', sonde.id);
|
||||
marker.set('sondeData', sonde);
|
||||
marker.setStyle(new Style({
|
||||
image: new Icon({
|
||||
src: this._getIcon(sonde),
|
||||
scale: 0.75,
|
||||
anchor: [0.5, 0.5],
|
||||
}),
|
||||
}));
|
||||
|
||||
source.addFeature(marker);
|
||||
}
|
||||
}
|
||||
|
||||
private _getIcon(sonde: Sonde): string {
|
||||
const opacity = (Date.now() - sonde.timestamp > SONDE_STALE_TTL) ? 0.5 : 1;
|
||||
const descending = sonde.climb < 0;
|
||||
|
||||
const svg = descending
|
||||
? `<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48" stroke="black" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" opacity="${opacity}">
|
||||
<path d="M8 20a16 16 0 0 1 32 0Z" fill="#FFAA00" stroke-width="2" stroke="black"/>
|
||||
<path d="M8 20l10 12M40 20L30 32" fill="#FFAA00" stroke-width="2" stroke="black"/>
|
||||
<rect x="16" y="32" width="16" height="11" rx="2" fill="#FFAA00"/>
|
||||
</svg>`
|
||||
: `<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48" stroke="black" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" opacity="${opacity}">
|
||||
<circle cx="24" cy="13" r="12" fill="#FFAA00"/>
|
||||
<path d="M24 27v5" fill="#FFAA00" stroke-width="2" stroke="black" />
|
||||
<rect x="16" y="32" width="16" height="11" rx="2" fill="#FFAA00"/>
|
||||
</svg>`;
|
||||
|
||||
return `data:image/svg+xml;charset=UTF-8,${encodeURIComponent(svg)}`;
|
||||
}
|
||||
|
||||
private async _fetch() {
|
||||
this.data = await fetch(`${API}/sondes`).then(r => r.json()) || [];
|
||||
}
|
||||
|
||||
private _refreshPopups() {
|
||||
for (const id of Object.keys(this.popups)) {
|
||||
const sonde = this.data.find(s => s.id === id);
|
||||
if (!sonde) {
|
||||
this.closePopup(id);
|
||||
continue;
|
||||
}
|
||||
this.popups[id].update([...sonde.history, sonde]);
|
||||
}
|
||||
}
|
||||
|
||||
hide() {
|
||||
if (!this.visible) return;
|
||||
this.visible = false;
|
||||
|
||||
if (this.refreshTimer) {
|
||||
clearInterval(this.refreshTimer);
|
||||
this.refreshTimer = null;
|
||||
}
|
||||
|
||||
if (this.clickHandler) {
|
||||
this.map.un('singleclick', this.clickHandler);
|
||||
this.clickHandler = null;
|
||||
}
|
||||
|
||||
for (const id of Object.keys(this.popups)) this.closePopup(id);
|
||||
if (this.layer) this.map.removeLayer(this.layer);
|
||||
}
|
||||
|
||||
async show() {
|
||||
if (this.visible) return;
|
||||
|
||||
this.visible = true;
|
||||
this.layer = new VectorLayer({source: new VectorSource(), zIndex: 105});
|
||||
this.map.addLayer(this.layer);
|
||||
|
||||
await this._fetch();
|
||||
this._draw();
|
||||
this._attachClick();
|
||||
|
||||
this.refreshTimer = adjustedInterval(async () => {
|
||||
try {
|
||||
await this._fetch();
|
||||
this._draw();
|
||||
this._refreshPopups();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
}, 5_000);
|
||||
}
|
||||
|
||||
getById(id: string): Sonde | null {
|
||||
return this.data.find(s => s.id === id) ?? null;
|
||||
}
|
||||
|
||||
getCandidates(latitude: number, longitude: number, limit = Infinity): Sonde[] {
|
||||
return this.data.filter(s => s.latitude != null && s.longitude != null)
|
||||
.map(sonde => ({...sonde, score: -greatCircleDist(latitude, longitude, sonde.latitude, sonde.longitude)}))
|
||||
.toSorted(sortByProp('score', true));
|
||||
}
|
||||
|
||||
openPopup(sonde: Sonde) {
|
||||
if (this.popups[sonde.id]) return;
|
||||
|
||||
const history = [...sonde.history, sonde];
|
||||
|
||||
const container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
|
||||
const mobile = window.innerWidth <= 768;
|
||||
const makeProps = (h_: SondeReading[]) => ({
|
||||
history: h_,
|
||||
position: mobile
|
||||
? {x: window.innerWidth / 2 - 180, y: window.innerHeight - 540 - 16}
|
||||
: {x: 16, y: 16},
|
||||
onClose: () => this.closePopup(sonde.id),
|
||||
onBringToFront: () => bringToFront(container),
|
||||
});
|
||||
|
||||
vueRender(h(SondePopup, makeProps(history)), container);
|
||||
|
||||
this.popups[sonde.id] = {
|
||||
update: (h_: SondeReading[]) => vueRender(h(SondePopup, makeProps(h_)), container),
|
||||
unmount: () => {
|
||||
vueRender(null, container);
|
||||
container.remove();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
closePopup(id: string) {
|
||||
const popup = this.popups[id];
|
||||
if (popup) {
|
||||
popup.unmount();
|
||||
delete this.popups[id];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -62,6 +62,14 @@ export const METRICS: Record<string, MetricMeta> = {
|
||||
forecast_precipitation_probability: { label: 'Rain Chance', unit: '%', icon: '🌂', group: 'Forecast', precision: 0, color: '#60a5fa' },
|
||||
}
|
||||
|
||||
export function formatValue(key: string, value: number | string | null): string {
|
||||
if (value === null || value === undefined) return '—'
|
||||
const meta = METRICS[key]
|
||||
if (!meta) return String(value)
|
||||
if (typeof value === 'number') return `${value.toFixed(meta.precision)}${meta.unit}`
|
||||
return `${value}${meta.unit}`
|
||||
}
|
||||
|
||||
export function getPressureTrend(delta: number) {
|
||||
if (delta > 0.5) return 'Rising';
|
||||
if (delta < -0.5) return 'Falling';
|
||||
@@ -76,12 +84,12 @@ export function getUVLabel(uv: number) {
|
||||
return 'Extreme';
|
||||
}
|
||||
|
||||
export function formatValue(key: string, value: number | string | null): string {
|
||||
if (value === null || value === undefined) return '—'
|
||||
const meta = METRICS[key]
|
||||
if (!meta) return String(value)
|
||||
if (typeof value === 'number') return `${value.toFixed(meta.precision)}${meta.unit}`
|
||||
return `${value}${meta.unit}`
|
||||
const EARTH_R = 6371;
|
||||
export 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));
|
||||
}
|
||||
|
||||
export const GROUPS = [...new Set(Object.values(METRICS).map(m => m.group))]
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
// Extra safety for array and object lookups, but may have false positives.
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"skipLibCheck": true,
|
||||
"noImplicitAny": false,
|
||||
"lib": ["DOM","ESNext"],
|
||||
|
||||
// Path mapping for cleaner imports.
|
||||
"paths": {
|
||||
|
||||
Generated
+615
@@ -15,9 +15,568 @@
|
||||
"cheerio": "^1.2.0",
|
||||
"dotenv": "^16.3.1",
|
||||
"express": "^4.18.2",
|
||||
"sharp": "^0.35.4",
|
||||
"yaml": "^2.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/runtime": {
|
||||
"version": "1.11.3",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz",
|
||||
"integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/colour": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
|
||||
"integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-darwin-arm64": {
|
||||
"version": "0.35.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.4.tgz",
|
||||
"integrity": "sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-darwin-arm64": "1.3.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-darwin-x64": {
|
||||
"version": "0.35.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.4.tgz",
|
||||
"integrity": "sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-darwin-x64": "1.3.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-freebsd-wasm32": {
|
||||
"version": "0.35.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.4.tgz",
|
||||
"integrity": "sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==",
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"dependencies": {
|
||||
"@img/sharp-wasm32": "0.35.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-darwin-arm64": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.3.tgz",
|
||||
"integrity": "sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-darwin-x64": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.3.tgz",
|
||||
"integrity": "sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-arm": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.3.tgz",
|
||||
"integrity": "sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-arm64": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.3.tgz",
|
||||
"integrity": "sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-ppc64": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.3.tgz",
|
||||
"integrity": "sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-riscv64": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.3.tgz",
|
||||
"integrity": "sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-s390x": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.3.tgz",
|
||||
"integrity": "sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-x64": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.3.tgz",
|
||||
"integrity": "sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linuxmusl-arm64": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.3.tgz",
|
||||
"integrity": "sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linuxmusl-x64": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.3.tgz",
|
||||
"integrity": "sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-arm": {
|
||||
"version": "0.35.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.4.tgz",
|
||||
"integrity": "sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-arm": "1.3.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-arm64": {
|
||||
"version": "0.35.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.4.tgz",
|
||||
"integrity": "sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-arm64": "1.3.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-ppc64": {
|
||||
"version": "0.35.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.4.tgz",
|
||||
"integrity": "sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-ppc64": "1.3.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-riscv64": {
|
||||
"version": "0.35.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.4.tgz",
|
||||
"integrity": "sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-riscv64": "1.3.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-s390x": {
|
||||
"version": "0.35.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.4.tgz",
|
||||
"integrity": "sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-s390x": "1.3.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-x64": {
|
||||
"version": "0.35.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.4.tgz",
|
||||
"integrity": "sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-x64": "1.3.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linuxmusl-arm64": {
|
||||
"version": "0.35.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.4.tgz",
|
||||
"integrity": "sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linuxmusl-arm64": "1.3.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linuxmusl-x64": {
|
||||
"version": "0.35.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.4.tgz",
|
||||
"integrity": "sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linuxmusl-x64": "1.3.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-wasm32": {
|
||||
"version": "0.35.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.4.tgz",
|
||||
"integrity": "sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==",
|
||||
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/runtime": "^1.11.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-webcontainers-wasm32": {
|
||||
"version": "0.35.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.4.tgz",
|
||||
"integrity": "sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA==",
|
||||
"cpu": [
|
||||
"wasm32"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@img/sharp-wasm32": "0.35.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-win32-arm64": {
|
||||
"version": "0.35.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.4.tgz",
|
||||
"integrity": "sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "Apache-2.0 AND LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-win32-ia32": {
|
||||
"version": "0.35.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.4.tgz",
|
||||
"integrity": "sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"license": "Apache-2.0 AND LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-win32-x64": {
|
||||
"version": "0.35.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.4.tgz",
|
||||
"integrity": "sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "Apache-2.0 AND LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@scalar/client-side-rendering": {
|
||||
"version": "0.2.6",
|
||||
"resolved": "https://registry.npmjs.org/@scalar/client-side-rendering/-/client-side-rendering-0.2.6.tgz",
|
||||
@@ -1392,6 +1951,55 @@
|
||||
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/sharp": {
|
||||
"version": "0.35.4",
|
||||
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.4.tgz",
|
||||
"integrity": "sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@img/colour": "^1.1.0",
|
||||
"detect-libc": "^2.1.2",
|
||||
"semver": "^7.8.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-darwin-arm64": "0.35.4",
|
||||
"@img/sharp-darwin-x64": "0.35.4",
|
||||
"@img/sharp-freebsd-wasm32": "0.35.4",
|
||||
"@img/sharp-libvips-darwin-arm64": "1.3.3",
|
||||
"@img/sharp-libvips-darwin-x64": "1.3.3",
|
||||
"@img/sharp-libvips-linux-arm": "1.3.3",
|
||||
"@img/sharp-libvips-linux-arm64": "1.3.3",
|
||||
"@img/sharp-libvips-linux-ppc64": "1.3.3",
|
||||
"@img/sharp-libvips-linux-riscv64": "1.3.3",
|
||||
"@img/sharp-libvips-linux-s390x": "1.3.3",
|
||||
"@img/sharp-libvips-linux-x64": "1.3.3",
|
||||
"@img/sharp-libvips-linuxmusl-arm64": "1.3.3",
|
||||
"@img/sharp-libvips-linuxmusl-x64": "1.3.3",
|
||||
"@img/sharp-linux-arm": "0.35.4",
|
||||
"@img/sharp-linux-arm64": "0.35.4",
|
||||
"@img/sharp-linux-ppc64": "0.35.4",
|
||||
"@img/sharp-linux-riscv64": "0.35.4",
|
||||
"@img/sharp-linux-s390x": "0.35.4",
|
||||
"@img/sharp-linux-x64": "0.35.4",
|
||||
"@img/sharp-linuxmusl-arm64": "0.35.4",
|
||||
"@img/sharp-linuxmusl-x64": "0.35.4",
|
||||
"@img/sharp-webcontainers-wasm32": "0.35.4",
|
||||
"@img/sharp-win32-arm64": "0.35.4",
|
||||
"@img/sharp-win32-ia32": "0.35.4",
|
||||
"@img/sharp-win32-x64": "0.35.4"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/node": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/side-channel": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
|
||||
@@ -1585,6 +2193,13 @@
|
||||
"node": ">=0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"license": "0BSD",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/tunnel-agent": {
|
||||
"version": "0.6.0",
|
||||
"resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
"cheerio": "^1.2.0",
|
||||
"dotenv": "^16.3.1",
|
||||
"express": "^4.18.2",
|
||||
"sharp": "^0.35.4",
|
||||
"yaml": "^2.9.0"
|
||||
}
|
||||
}
|
||||
|
||||
+315
-175
@@ -4,39 +4,98 @@ import { fileURLToPath } from 'url';
|
||||
import * as fs from 'node:fs';
|
||||
import Database from 'better-sqlite3';
|
||||
import { fromCsv } from '@ztimson/utils';
|
||||
import {getIcon} from './adsb-shapes.mjs';
|
||||
import { getIcon } from './adsb-shapes.mjs';
|
||||
import * as cheerio from 'cheerio';
|
||||
import sharp from 'sharp';
|
||||
|
||||
const DIR = dirname(fileURLToPath(import.meta.url));
|
||||
const DATA = resolve(DIR, '../data');
|
||||
const IMAGES_DIR = resolve(DATA, 'aircraft');
|
||||
const DB_PATH = resolve(DATA, 'aircraft.db');
|
||||
const CSV_CACHE = resolve(DATA, 'aircraft_db.csv');
|
||||
const AIRLINES_CACHE = resolve(DATA, 'airlines.csv');
|
||||
const MAX_HISTORY = 500;
|
||||
const HISTORY_TTL = 1000 * 60 * 60; // 1 hour
|
||||
|
||||
const ADSB_TTL = 1000;
|
||||
const CSV_URL = 'https://s3.opensky-network.org/data-samples/metadata/aircraft-database-complete-2024-06.csv';
|
||||
const MIL_RANGES_URL = ':8080/db-3.14.1708/ranges.js';
|
||||
|
||||
const MILITARY_OPERATORS = ['air force', 'army', 'navy', 'marine', 'coast guard', 'military', 'defence', 'defense', 'luftwaffe', 'RAF', 'USAF', 'USN', 'USMC'];
|
||||
const AIRLINES_URL = 'https://raw.githubusercontent.com/jpatokal/openflights/master/data/airlines.dat';
|
||||
const CARGO_OPERATORS = ['fedex', 'ups', 'dhl', 'cargo', 'freight', 'logistic', 'atlas air', 'kalitta', 'air freight'];
|
||||
const PASSENGER_OPERATORS = ['airlines', 'airways', 'air ', 'jet', 'jetblue', 'easyjet', 'ryanair', 'delta', 'united', 'american', 'avianca', 'southwest', 'lufthansa', 'emirates', 'british'];
|
||||
const PASSENGER_OPERATORS = ['airlines', 'airways', 'jetblue', 'easyjet', 'ryanair', 'delta', 'united', 'american', 'avianca', 'southwest', 'lufthansa', 'emirates', 'british'];
|
||||
const MILITARY_CLASSES = ['H1M', 'H2M', 'L1M', 'L2M', 'L4M', 'A0'];
|
||||
const MILITARY_KEYWORDS = [
|
||||
'air force', 'airforce', 'navy', 'army', 'marine corps', 'coast guard',
|
||||
'national guard', 'military', 'defence', 'defense', 'police', 'luftwaffe',
|
||||
'aeronautica militare', 'armee de l air', 'raf', 'usaf'
|
||||
];
|
||||
|
||||
let db;
|
||||
let passengerOperators = PASSENGER_OPERATORS;
|
||||
let adsbCache = null;
|
||||
let adsbCacheTs = 0;
|
||||
const noRecord = [];
|
||||
const history = new Map();
|
||||
const milRanges = [];
|
||||
let db;
|
||||
|
||||
setInterval(() => {
|
||||
const cutoff = Date.now() - HISTORY_TTL;
|
||||
for (const [key, trail] of history) {
|
||||
const last = trail.at(-1);
|
||||
if (!last || last.ts < cutoff) history.delete(key);
|
||||
}
|
||||
}, 1000 * 60 * 5);
|
||||
function backfillModel(aircraft) {
|
||||
if (!aircraft) return {};
|
||||
const similar = db.prepare(`
|
||||
SELECT
|
||||
manufacturer,
|
||||
model,
|
||||
engines,
|
||||
categoryDescription,
|
||||
class
|
||||
FROM aircraft
|
||||
WHERE aircraft = ?
|
||||
AND (
|
||||
manufacturer IS NOT NULL
|
||||
OR model IS NOT NULL
|
||||
OR engines IS NOT NULL
|
||||
)
|
||||
LIMIT 1
|
||||
`).get(aircraft);
|
||||
if (!similar) return {};
|
||||
return {
|
||||
manufacturer: similar.manufacturer || null,
|
||||
model: similar.model || null,
|
||||
engines: similar.engines || null,
|
||||
categoryDescription: similar.categoryDescription || null,
|
||||
class: similar.class || null
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchRegistration1(icao) {
|
||||
const url = `https://hexdatabase.com/h/${icao}`;
|
||||
const resp = await fetch(url);
|
||||
if (!resp.ok) return null;
|
||||
const html = await resp.text();
|
||||
const tableMatch = html.match(/<table[\s\S]*?<\/table>/i);
|
||||
if (!tableMatch) return null;
|
||||
const $ = cheerio.load(tableMatch[0]);
|
||||
const row = $('tr').filter((_, el) => $(el).text()?.toLowerCase()?.includes(icao.toLowerCase())).first();
|
||||
if (!row.length) return null;
|
||||
const cells = row.find('td');
|
||||
return {
|
||||
registration: $(cells[1]).text().trim() || null,
|
||||
aircraft: $(cells[2]).text().trim() || null,
|
||||
operator: $(cells[3]).text().trim() || null,
|
||||
serialNumber: $(cells[5]).text().trim() || null
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchRegistration2(icao) {
|
||||
const resp = await fetchWithTimeout(`https://hexdb.io/api/v1/aircraft/${icao}`);
|
||||
if (!resp.ok) return null;
|
||||
const found = await resp.json();
|
||||
return {
|
||||
registration: found.Registration || null,
|
||||
manufacturer: found.Manufacturer || null,
|
||||
aircraft: found.ICAOTypeCode || null,
|
||||
model: found.Type || null,
|
||||
operator: found.RegisteredOwners || null
|
||||
};
|
||||
}
|
||||
|
||||
function fetchWithTimeout(url, ms = 5000) {
|
||||
const controller = new AbortController();
|
||||
@@ -44,35 +103,11 @@ function fetchWithTimeout(url, ms = 5000) {
|
||||
return fetch(url, { signal: controller.signal }).finally(() => clearTimeout(id));
|
||||
}
|
||||
|
||||
async function syncMilitaryRanges() {
|
||||
const { ADSB_URL } = cfg();
|
||||
try {
|
||||
const res = await fetch(ADSB_URL + MIL_RANGES_URL);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const { military } = await res.json();
|
||||
|
||||
db.exec('DELETE FROM military_ranges');
|
||||
const insert = db.prepare('INSERT INTO military_ranges (start, end) VALUES (?, ?)');
|
||||
const insertMany = db.transaction(ranges => {
|
||||
for (const [s, e] of ranges) insert.run(s.toUpperCase(), e.toUpperCase());
|
||||
});
|
||||
|
||||
insertMany(military);
|
||||
milRanges.length = 0;
|
||||
milRanges.push(...military.map(([s, e]) => [s.toUpperCase(), e.toUpperCase()]));
|
||||
} catch (e) {
|
||||
const rows = db.prepare('SELECT start, end FROM military_ranges').all();
|
||||
milRanges.length = 0;
|
||||
milRanges.push(...rows.map(r => [r.start, r.end]));
|
||||
}
|
||||
}
|
||||
|
||||
export async function initAircraftDb() {
|
||||
if (!fs.existsSync(DATA)) fs.mkdirSync(DATA, { recursive: true });
|
||||
|
||||
if (!fs.existsSync(IMAGES_DIR)) fs.mkdirSync(IMAGES_DIR, { recursive: true });
|
||||
const missing = !fs.existsSync(DB_PATH);
|
||||
if (missing) console.log(`✈️ Building database`);
|
||||
|
||||
db = new Database(DB_PATH);
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS aircraft (
|
||||
@@ -92,26 +127,36 @@ export async function initAircraftDb() {
|
||||
serialNumber TEXT,
|
||||
aircraft TEXT
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS military_ranges (
|
||||
start TEXT NOT NULL,
|
||||
end TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS military_ranges (start TEXT NOT NULL, end TEXT NOT NULL);
|
||||
`);
|
||||
|
||||
await syncMilitaryRanges();
|
||||
await syncAirlines();
|
||||
await syncMilitaryIcao();
|
||||
|
||||
if (missing) {
|
||||
if(missing) {
|
||||
const res = await fetch(CSV_URL);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
if(!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const text = await res.text();
|
||||
fs.writeFileSync(CSV_CACHE, text);
|
||||
|
||||
const csv = fromCsv(text, true);
|
||||
const insert = db.prepare(`
|
||||
INSERT OR REPLACE INTO aircraft VALUES (
|
||||
@icao, @built, @categoryDescription, @country, @engines,
|
||||
@class, @manufacturer, @model, @modes, @operator,
|
||||
@operatorCallsign, @owner, @registration, @serialNumber, @aircraft
|
||||
@icao,
|
||||
@built,
|
||||
@categoryDescription,
|
||||
@country,
|
||||
@engines,
|
||||
@class,
|
||||
@manufacturer,
|
||||
@model,
|
||||
@modes,
|
||||
@operator,
|
||||
@operatorCallsign,
|
||||
@owner,
|
||||
@registration,
|
||||
@serialNumber,
|
||||
@aircraft
|
||||
)
|
||||
`);
|
||||
|
||||
@@ -134,129 +179,117 @@ export async function initAircraftDb() {
|
||||
owner: row.owner || null,
|
||||
registration: row.registration || null,
|
||||
serialNumber: row.serialNumber || null,
|
||||
aircraft: row.typecode || null,
|
||||
aircraft: row.typecode || null
|
||||
})));
|
||||
|
||||
fs.unlinkSync(CSV_CACHE);
|
||||
console.log(`✈️ Aircraft imported: ${csv.length}`);
|
||||
console.log(`✈️ Registrations imported: ${csv.length}`);
|
||||
} else {
|
||||
const count = db.prepare('SELECT COUNT(*) as c FROM aircraft').get();
|
||||
console.log(`✈️ Aircraft loaded: ${count.c}`);
|
||||
console.log(`✈️ Registrations loaded: ${count.c}`);
|
||||
}
|
||||
}
|
||||
|
||||
function isIcaoInMilitaryRange(icao) {
|
||||
function isMilitaryIcao(icao) {
|
||||
if (!icao) return false;
|
||||
const hex = icao.toUpperCase();
|
||||
return milRanges.some(([s, e]) => hex >= s && hex <= e);
|
||||
}
|
||||
|
||||
function wordMatch(text, keyword) {
|
||||
if (!text) return false;
|
||||
const escaped = keyword.replace(/[.*+?^${}()|[\]\\]/gi, '\\$&');
|
||||
return new RegExp(`(?<![\\w])${escaped}(?![\\w])`, 'i').test(text);
|
||||
async function syncAirlines() {
|
||||
try {
|
||||
if (!fs.existsSync(AIRLINES_CACHE)) {
|
||||
console.log('✈️ Downloading airline database');
|
||||
const res = await fetchWithTimeout(AIRLINES_URL, 10000);
|
||||
if(!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
fs.writeFileSync(AIRLINES_CACHE, await res.text());
|
||||
}
|
||||
|
||||
const text = fs.readFileSync(AIRLINES_CACHE, 'utf8');
|
||||
const airlines = text.split('\n').slice(1).map(row =>
|
||||
row.split(',')[1]?.slice(1, -1).toLowerCase()).filter(Boolean).flat();
|
||||
passengerOperators = airlines.sort((a, b) => b.length - a.length);
|
||||
console.log(`✈️ Airlines loaded: ${airlines.length}`);
|
||||
} catch (e) {
|
||||
console.warn(`⚠️ Could not load airline database: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function syncMilitaryIcao() {
|
||||
const { ADSB_URL } = cfg();
|
||||
try {
|
||||
const page = await fetch(ADSB_URL + ':8080').then(resp => resp.text());
|
||||
const db = /databaseFolder = "(db-.+?)"/.exec(page);
|
||||
const res = await fetch(ADSB_URL + `:8080/${db[1]}/ranges.js`);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const { military } = await res.json();
|
||||
db.exec('DELETE FROM military_ranges');
|
||||
const insert = db.prepare('INSERT INTO military_ranges (start, end) VALUES (?, ?)');
|
||||
const insertMany = db.transaction(ranges => {
|
||||
for (const [s, e] of ranges) insert.run(s.toUpperCase(), e.toUpperCase());
|
||||
});
|
||||
insertMany(military);
|
||||
milRanges.length = 0;
|
||||
milRanges.push(...military.map(([s, e]) => [s.toUpperCase(), e.toUpperCase()]));
|
||||
} catch (e) {
|
||||
const rows = db.prepare('SELECT start, end FROM military_ranges').all();
|
||||
milRanges.length = 0;
|
||||
milRanges.push(...rows.map(r => [r.start, r.end]));
|
||||
}
|
||||
}
|
||||
|
||||
export function classifyAircraft(row) {
|
||||
if (!row) return 'unknown';
|
||||
|
||||
const matchesAny = (fields, list) =>
|
||||
fields.some(f => list.some(k => wordMatch(f, k)));
|
||||
const matchesAny = (fields, list) => fields.some(f => {
|
||||
const value = normalizeName(f);
|
||||
return value && list.some(k => {
|
||||
const kw = normalizeName(k);
|
||||
return kw && new RegExp('\\b' + kw.replace(/[.*+?^${}()|[\\]\\]/g, '\\$&') + '\\b', 'i').test(value);
|
||||
});
|
||||
});
|
||||
|
||||
const operatorFields = [row.operator, row.operatorCallsign];
|
||||
const ownerFields = [row.owner];
|
||||
const allFields = [...operatorFields, ...ownerFields, row.categoryDescription];
|
||||
const operators = [row.operator, row.operatorCallsign, row.owner];
|
||||
if (isMilitaryIcao(row.icao) || MILITARY_CLASSES.includes(row.class) || matchesAny(operators, MILITARY_KEYWORDS))
|
||||
return 'military';
|
||||
|
||||
if (isIcaoInMilitaryRange(row.icao) || MILITARY_CLASSES.includes(row.class) || matchesAny(allFields, MILITARY_OPERATORS)) return 'military';
|
||||
if (row.categoryDescription?.toLowerCase().includes('cargo') || matchesAny(operatorFields, CARGO_OPERATORS)) return 'cargo';
|
||||
if (matchesAny(operatorFields, PASSENGER_OPERATORS)) return 'passenger';
|
||||
if (row.categoryDescription?.toLowerCase().includes('cargo') || matchesAny(operators, CARGO_OPERATORS)) return 'cargo';
|
||||
if (matchesAny(operators, passengerOperators)) return 'passenger';
|
||||
if (row.owner && !row.operator) return 'private';
|
||||
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
async function fetchHexDb(icao) {
|
||||
const resp = await fetchWithTimeout(`https://hexdb.io/api/v1/aircraft/${icao}`);
|
||||
if (!resp.ok) return null;
|
||||
const found = await resp.json();
|
||||
return {
|
||||
registration: found.Registration || null,
|
||||
manufacturer: found.Manufacturer || null,
|
||||
aircraft: found.ICAOTypeCode || null,
|
||||
model: found.Type || null,
|
||||
operator: found.RegisteredOwners || null,
|
||||
};
|
||||
function normalizeName(value) {
|
||||
return String(value || '')
|
||||
.normalize('NFKD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.toLowerCase()
|
||||
.replace(/&/g, ' and ')
|
||||
.replace(/[^a-z0-9]+/g, ' ')
|
||||
.trim()
|
||||
.replace(/\s+/g, ' ');
|
||||
}
|
||||
|
||||
async function scrapeHexDatabase(icao) {
|
||||
const url = `https://hexdatabase.com/h/${icao}`;
|
||||
const resp = await fetch(url);
|
||||
if (!resp.ok) return null;
|
||||
|
||||
const html = await resp.text();
|
||||
const tableMatch = html.match(/<table[\s\S]*?<\/table>/i);
|
||||
if (!tableMatch) return null;
|
||||
|
||||
const $ = cheerio.load(tableMatch[0]);
|
||||
const row = $('tr').filter((_, el) => $(el).text().toLowerCase().includes(icao.toLowerCase())).first();
|
||||
if (!row.length) return null;
|
||||
|
||||
const cells = row.find('td');
|
||||
return {
|
||||
registration: $(cells[1]).text().trim() || null,
|
||||
aircraft: $(cells[2]).text().trim() || null,
|
||||
operator: $(cells[3]).text().trim() || null,
|
||||
serialNumber: $(cells[5]).text().trim() || null,
|
||||
};
|
||||
}
|
||||
|
||||
function backfillFromSimilar(aircraft) {
|
||||
if (!aircraft) return {};
|
||||
const similar = db.prepare(`
|
||||
SELECT manufacturer, model, engines, categoryDescription, class
|
||||
FROM aircraft
|
||||
WHERE aircraft = ?
|
||||
AND (manufacturer IS NOT NULL OR model IS NOT NULL OR engines IS NOT NULL)
|
||||
LIMIT 1
|
||||
`).get(aircraft);
|
||||
if (!similar) return {};
|
||||
return {
|
||||
manufacturer: similar.manufacturer || null,
|
||||
model: similar.model || null,
|
||||
engines: similar.engines || null,
|
||||
categoryDescription: similar.categoryDescription || null,
|
||||
class: similar.class || null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function enrichAircraft(a) {
|
||||
export async function enrich(a) {
|
||||
if (!a.hex) return a;
|
||||
const icao = a.hex.toUpperCase();
|
||||
|
||||
// 1. Check own DB first
|
||||
const row = db.prepare('SELECT * FROM aircraft WHERE icao = ?').get(icao);
|
||||
if(row?.aircraft) return { ...a, ...row, type: classifyAircraft(row) };
|
||||
|
||||
// 2. Race the two external sources
|
||||
if(noRecord.includes(icao)) return { icao, ...a, type: 'unknown' };
|
||||
const hexDbPromise = fetchHexDb(icao);
|
||||
const scrapePromise = scrapeHexDatabase(icao);
|
||||
if (row?.aircraft) return { ...a, ...row, type: classifyAircraft(row) };
|
||||
if (noRecord.includes(icao)) return { icao, ...a, type: 'unknown' };
|
||||
const hexDbPromise = fetchRegistration2(icao);
|
||||
const scrapePromise = fetchRegistration1(icao);
|
||||
let found = await hexDbPromise.catch(() => {});
|
||||
if(!found) found = await scrapePromise.catch(() => {});
|
||||
if(!found) {
|
||||
if (!found) found = await scrapePromise.catch(() => {});
|
||||
if (!found) {
|
||||
noRecord.push(icao);
|
||||
return { icao, ...a, type: 'unknown' };
|
||||
}
|
||||
|
||||
// 3. Backfill manufacturer/model/engines from similar aircraft type in DB
|
||||
const merged = { ...row, ...found };
|
||||
if (merged.aircraft && (!merged.manufacturer || !merged.model)) {
|
||||
const similar = backfillFromSimilar(merged.aircraft);
|
||||
const similar = backfillModel(merged.aircraft);
|
||||
Object.assign(merged, similar);
|
||||
}
|
||||
|
||||
// 4. Save back to DB
|
||||
if (found) {
|
||||
if(found) {
|
||||
db.prepare(`
|
||||
INSERT INTO aircraft (icao, registration, manufacturer, aircraft, model, operator, country, serialNumber, engines, categoryDescription, class)
|
||||
VALUES (@icao, @registration, @manufacturer, @aircraft, @model, @operator, @country, @serialNumber, @engines, @categoryDescription, @class)
|
||||
@@ -282,33 +315,40 @@ export async function enrichAircraft(a) {
|
||||
serialNumber: merged.serialNumber || null,
|
||||
engines: merged.engines || null,
|
||||
categoryDescription: merged.categoryDescription || null,
|
||||
class: merged.class || null,
|
||||
class: merged.class || null
|
||||
});
|
||||
}
|
||||
|
||||
return {icao, ...a, ...merged, type: classifyAircraft(merged) }
|
||||
return { icao, ...a, ...merged, type: classifyAircraft(merged) };
|
||||
}
|
||||
|
||||
export async function getADSB() {
|
||||
if (adsbCache && Date.now() - adsbCacheTs < ADSB_TTL) return adsbCache;
|
||||
export async function get() {
|
||||
if(adsbCache && Date.now() - adsbCacheTs < ADSB_TTL) return adsbCache;
|
||||
|
||||
const { ADSB_URL } = cfg();
|
||||
if (!ADSB_URL) return [];
|
||||
if(!ADSB_URL) return [];
|
||||
|
||||
const r = await fetchWithTimeout(`${ADSB_URL}:8080/data/aircraft.json`);
|
||||
const j = await r.json();
|
||||
const aircraft = j.aircraft || [];
|
||||
|
||||
for (const a of aircraft) {
|
||||
a.type = 'unknown';
|
||||
if (!a.hex || !a.lat || !a.lon) continue;
|
||||
if(!a.hex || !a.lat || !a.lon) continue;
|
||||
const key = a.hex.toLowerCase();
|
||||
if (!history.has(key)) history.set(key, []);
|
||||
const trail = history.get(key);
|
||||
trail.push({ latitude: a.lat, longitude: a.lon, altitude: a.alt_baro || 0, ts: Date.now() });
|
||||
trail.push({
|
||||
latitude: a.lat,
|
||||
longitude: a.lon,
|
||||
altitude: a.alt_baro || 0,
|
||||
ts: Date.now()
|
||||
});
|
||||
if (trail.length > MAX_HISTORY) trail.shift();
|
||||
}
|
||||
|
||||
adsbCache = await Promise.all(aircraft.map(async a => {
|
||||
a = await enrichAircraft(a);
|
||||
a = await enrich(a);
|
||||
a.icon = getIcon(a);
|
||||
return a;
|
||||
}));
|
||||
@@ -316,11 +356,7 @@ export async function getADSB() {
|
||||
return adsbCache;
|
||||
}
|
||||
|
||||
export async function getADSBHistory(icao) {
|
||||
return { history: history.get(icao?.toLowerCase()) || [] };
|
||||
}
|
||||
|
||||
export async function getADSBRange() {
|
||||
export async function getAttenuation() {
|
||||
const { ADSB_URL } = cfg();
|
||||
if (!ADSB_URL) return [];
|
||||
const r = await fetch(`${ADSB_URL}:8080/data/outline.json`);
|
||||
@@ -328,42 +364,146 @@ export async function getADSBRange() {
|
||||
return j?.actualRange?.last24h?.points || [];
|
||||
}
|
||||
|
||||
export async function getADSBImage(icao) {
|
||||
export async function getHistory(icao) {
|
||||
return { history: history.get(icao?.toLowerCase()) || [] };
|
||||
}
|
||||
|
||||
export async function getIcaoImage(icao) {
|
||||
const UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/153.0.0.0 Safari/537.36';
|
||||
|
||||
async function duckduckgo(query) {
|
||||
async function getVqd(query) {
|
||||
const res = await fetch(`https://duckduckgo.com/?q=${encodeURIComponent(query)}`, {
|
||||
headers: { 'User-Agent': 'Mozilla/5.0' }
|
||||
})
|
||||
const html = await res.text()
|
||||
const match = html.match(/vqd=['"]?([\d-]+)['"]?/)
|
||||
return match?.[1]
|
||||
const url = `https://duckduckgo.com/?q=${encodeURIComponent(query)}&iax=images&ia=images`;
|
||||
const res = await fetch(url, {
|
||||
headers: {
|
||||
'User-Agent': UA,
|
||||
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8',
|
||||
'Accept-Language': 'en-US,en;q=0.9'
|
||||
}
|
||||
});
|
||||
if(!res.ok) return console.warn(`DuckDuckGo Search failed: ${res.status}`);
|
||||
const html = await res.text();
|
||||
const match = html.match(/vqd=["']([^"']+)["']/);
|
||||
return match?.[1] || null;
|
||||
}
|
||||
|
||||
const vqd = await getVqd(query)
|
||||
if (!vqd) throw new Error('Could not get vqd token')
|
||||
const vqd = await getVqd(query);
|
||||
if (!vqd) throw new Error('Could not get vqd token');
|
||||
|
||||
const url = `https://duckduckgo.com/i.js?q=${encodeURIComponent(query)}&o=json&vqd=${vqd}&f=,,,,,&p=1`
|
||||
const data = await fetch(url,
|
||||
{headers: {'User-Agent': 'Mozilla/5.0', 'Referer': 'https://duckduckgo.com/'}}
|
||||
).then(resp => resp.json());
|
||||
return data?.results?.[0]?.image || null;
|
||||
await new Promise(resolve => setTimeout(resolve, 800 + Math.random() * 500));
|
||||
const searchUrl = `https://duckduckgo.com/i.js?q=${encodeURIComponent(query)}&o=json&vqd=${encodeURIComponent(vqd)}&f=,,,,,&p=1`;
|
||||
const data = await fetch(searchUrl, {
|
||||
headers: {
|
||||
'User-Agent': UA,
|
||||
'Accept': 'application/json, text/javascript, */*; q=0.01',
|
||||
'Accept-Language': 'en-US,en;q=0.9',
|
||||
'Referer': `https://duckduckgo.com/?q=${encodeURIComponent(query)}&iax=images&ia=images`
|
||||
}
|
||||
}).then(async resp => {
|
||||
if (!resp.ok) return console.warn(`DuckDuckGo image failed: ${resp.status}`);
|
||||
const text = await resp.text();
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
console.warn(`DuckDuckGo returned invalid JSON: ${text.slice(0, 200)}`);
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
if (data?.results?.length) {
|
||||
for (const result of data.results) {
|
||||
if (!result.image) continue;
|
||||
try {
|
||||
const imgRes = await fetch(result.image, {
|
||||
headers: {
|
||||
'Accept': 'image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8',
|
||||
'User-Agent': UA,
|
||||
'Referer': result.url || 'https://duckduckgo.com/'
|
||||
}
|
||||
});
|
||||
if (imgRes.ok) return imgRes.blob();
|
||||
console.warn(`Image fetch failed: ${imgRes.status} ${result.image}`);
|
||||
} catch (e) {
|
||||
console.warn(`Image fetch failed: ${result.image} - ${e.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
const c = cfg();
|
||||
let aircraft = (await adsbCache).find(a => a.icao?.toLowerCase() === icao?.toLowerCase());
|
||||
if(!aircraft) aircraft = enrichAircraft({hex: icao});
|
||||
if(!aircraft) return null;
|
||||
async function genericImage(modelType) {
|
||||
if (!modelType) return null;
|
||||
|
||||
const generic = duckduckgo(aircraft.model ? `${aircraft.manufacturer} ${aircraft.model}` : `${aircraft.aircraft} Aircraft`);
|
||||
const specific = await fetch(`https://api.planespotters.net/pub/photos/hex/${icao.toLowerCase()}`,
|
||||
{headers: {'User-Agent': `Personal ADSB Feeder + Dashboard (${c.EMAIL})`}}
|
||||
).then(resp => resp.ok ? resp.json() : null);
|
||||
const filePath = resolve(IMAGES_DIR, 'generic', `${modelType}.jpg`);
|
||||
if (fs.existsSync(filePath)) return fs.readFileSync(filePath);
|
||||
|
||||
let src;
|
||||
if(specific?.photos?.[0]) src = specific.photos[0].thumbnail_large?.src || specific.photos[0].thumbnail?.src;
|
||||
else src = await generic;
|
||||
const blob = await duckduckgo(`${modelType} Aircraft`);
|
||||
if (!blob) return null;
|
||||
|
||||
if(!src) return null;
|
||||
return fetch(src).then(resp => resp.blob());
|
||||
fs.mkdirSync(dirname(filePath), { recursive: true });
|
||||
|
||||
const buffer = Buffer.from(await blob.arrayBuffer());
|
||||
const metadata = await sharp(buffer).metadata();
|
||||
const width = metadata.width || 1200;
|
||||
const height = metadata.height || 800;
|
||||
const fontSize = Math.max(48, Math.round(Math.min(width, height) * 0.16));
|
||||
const watermark = Buffer.from(`
|
||||
<svg width="${width}" height="${height}" xmlns="http://www.w3.org/2000/svg">
|
||||
<text x="50%" y="50%" text-anchor="middle" dominant-baseline="middle"
|
||||
font-family="Arial, Helvetica, sans-serif" font-size="${fontSize}px"
|
||||
font-weight="700" fill="white" fill-opacity="0.65"
|
||||
stroke="black" stroke-opacity="0.35" stroke-width="${Math.max(2, Math.round(fontSize * 0.04))}"
|
||||
transform="rotate(-20 ${width / 2} ${height / 2})">STOCK IMAGE</text>
|
||||
</svg>`);
|
||||
|
||||
const watermarked = await sharp(buffer).composite([{ input: watermark }]).jpeg().toBuffer();
|
||||
fs.writeFileSync(filePath, watermarked);
|
||||
return watermarked;
|
||||
}
|
||||
|
||||
async function icaoImage(icao) {
|
||||
const filePath = resolve(IMAGES_DIR, `${icao}.jpg`);
|
||||
if (fs.existsSync(filePath)) return fs.readFileSync(filePath);
|
||||
|
||||
const planeSpotters = await fetch(`https://api.planespotters.net/pub/photos/hex/${icao}`, {
|
||||
headers: { 'User-Agent': 'open-sight.net (zaktimson@gmail.com)' }
|
||||
}).then(resp => resp.ok ? resp.json() : null).catch(() => null);
|
||||
|
||||
const src = planeSpotters?.photos?.[0]?.thumbnail_large?.src || planeSpotters?.photos?.[0]?.thumbnail?.src;
|
||||
if (!src) return null;
|
||||
|
||||
const resp = await fetch(src, {
|
||||
headers: {
|
||||
'Accept': 'image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8',
|
||||
'User-Agent': UA,
|
||||
'Referer': 'https://www.planespotters.net/'
|
||||
}
|
||||
});
|
||||
|
||||
if (!resp.ok) return null;
|
||||
|
||||
fs.mkdirSync(IMAGES_DIR, { recursive: true });
|
||||
|
||||
const buffer = Buffer.from(await resp.arrayBuffer());
|
||||
fs.writeFileSync(filePath, buffer);
|
||||
return buffer;
|
||||
}
|
||||
|
||||
icao = icao.toLowerCase();
|
||||
if (!fs.existsSync(IMAGES_DIR)) fs.mkdirSync(IMAGES_DIR, { recursive: true });
|
||||
const aircraft = await enrich({ hex: icao });
|
||||
const modelType = aircraft.aircraft || aircraft.model || aircraft.type;
|
||||
const specific = await icaoImage(icao);
|
||||
if (specific) return specific;
|
||||
return genericImage(modelType);
|
||||
}
|
||||
|
||||
// Purge old history
|
||||
setInterval(() => {
|
||||
const cutoff = Date.now() - HISTORY_TTL;
|
||||
for (const [key, trail] of history) {
|
||||
const last = trail.at(-1);
|
||||
if (!last || last.ts < cutoff) history.delete(key);
|
||||
}
|
||||
}, 1000 * 60 * 5);
|
||||
+49
-14
@@ -1,4 +1,7 @@
|
||||
import {cfg} from './config.mjs';
|
||||
import * as fs from 'node:fs';
|
||||
import { resolve, dirname } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const CHANNEL_MAP = {0: 'A, B', 1: 'A', 2: 'B'};
|
||||
const SENDER_TYPE = {
|
||||
@@ -8,6 +11,10 @@ const SENDER_TYPE = {
|
||||
12: 'AtoN', 13: 'SART/EPIRB'
|
||||
};
|
||||
|
||||
const DIR = dirname(fileURLToPath(import.meta.url));
|
||||
const DATA = resolve(DIR, '../data');
|
||||
const IMAGES_DIR = resolve(DATA, 'ships');
|
||||
|
||||
let aisCache = null;
|
||||
let aisCacheTs = 0;
|
||||
const AIS_TTL = 1000;
|
||||
@@ -89,19 +96,47 @@ export async function getAIS() {
|
||||
}
|
||||
|
||||
export async function getAISImage(mmsi) {
|
||||
console.log(`https://www.vesselfinder.com/vessels/details/${mmsi}`)
|
||||
const res = await fetch(`https://www.vesselfinder.com/vessels/details/${mmsi}`, {
|
||||
headers: { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36' }
|
||||
})
|
||||
const html = await res.text()
|
||||
const match = html.match(/<img.+main-photo.+>/)
|
||||
if(!match) return null;
|
||||
const srcMatch = match[0].match(/src="(.+?)"/)
|
||||
if (!srcMatch) return null;
|
||||
if(!mmsi) return null;
|
||||
mmsi = String(mmsi);
|
||||
|
||||
let imgUrl = srcMatch[1]
|
||||
if (imgUrl.startsWith('/')) imgUrl = `https://www.vesselfinder.com${imgUrl}`
|
||||
return fetch(imgUrl, {
|
||||
headers: { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36' }
|
||||
}).then(resp => resp.blob())
|
||||
const filePath = resolve(IMAGES_DIR, `${mmsi}.jpg`);
|
||||
if (fs.existsSync(filePath)) return fs.readFileSync(filePath);
|
||||
|
||||
function randomUA() {
|
||||
const v = 36 + Math.floor(Math.random() * 40);
|
||||
const builds = [
|
||||
`Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.${v} (KHTML, like Gecko) Chrome/1${10 + ~~(Math.random() * 16)}.0.0.0 Safari/537.${v}`,
|
||||
`Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.${v} (KHTML, like Gecko) Chrome/1${10 + ~~(Math.random() * 16)}.0.0.0 Safari/537.${v}`,
|
||||
`Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.${v} (KHTML, like Gecko) Chrome/1${10 + ~~(Math.random() * 16)}.0.0.0 Safari/537.${v}`,
|
||||
];
|
||||
return builds[~~(Math.random() * builds.length)];
|
||||
}
|
||||
|
||||
const html = await fetch(`https://www.vesselfinder.com/vessels/details/${mmsi}`, {
|
||||
headers: {'Accept': '*/*', 'User-Agent': randomUA(), 'Referer': 'https://www.vesselfinder.com/'}
|
||||
}).then(resp => {
|
||||
if(resp.ok) return resp.text();
|
||||
throw new Error('Failed to fetch vessel page');
|
||||
});
|
||||
|
||||
const match = html.match(/<img.+main-photo.+>/);
|
||||
if(!match) return null;
|
||||
|
||||
const srcMatch = match[0].match(/src="(.+?)"/);
|
||||
if(!srcMatch) return null;
|
||||
|
||||
let imgUrl = srcMatch[1];
|
||||
if(imgUrl.startsWith('//')) imgUrl = `https:${imgUrl}`;
|
||||
if(imgUrl.startsWith('/')) imgUrl = `https://www.vesselfinder.com${imgUrl}`;
|
||||
|
||||
const resp = await fetch(imgUrl, {
|
||||
headers: {'Accept': '*/*', 'User-Agent': randomUA(), 'Referer': 'https://www.vesselfinder.com/'}
|
||||
});
|
||||
|
||||
if(!resp.ok) throw new Error('Failed to fetch image');
|
||||
|
||||
const buffer = Buffer.from(await resp.arrayBuffer());
|
||||
fs.mkdirSync(IMAGES_DIR, { recursive: true });
|
||||
fs.writeFileSync(filePath, buffer);
|
||||
return buffer;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {adjustedInterval, Logger} from '@ztimson/utils';
|
||||
import {adjustedInterval} from '@ztimson/utils';
|
||||
|
||||
const cacheOptions = {
|
||||
ttl: null,
|
||||
@@ -12,7 +12,6 @@ export class CacheService {
|
||||
|
||||
cached = null;
|
||||
lastUpdate = null;
|
||||
logger;
|
||||
name;
|
||||
options;
|
||||
pending;
|
||||
@@ -24,7 +23,6 @@ export class CacheService {
|
||||
ttl: options.reload,
|
||||
...options
|
||||
};
|
||||
this.logger = new Logger(name);
|
||||
if(this.options.reload) setTimeout(() => this.startLoop(), 1000);
|
||||
}
|
||||
|
||||
@@ -62,16 +60,15 @@ export class CacheService {
|
||||
|
||||
update(catchErr = true) {
|
||||
if(this.pending) return this.pending;
|
||||
this.logger.info('Fetching latest');
|
||||
console.log('🌌 Fetching Aurora');
|
||||
this.pending = this.#fetchWithRetry().then(data => {
|
||||
if(data?.err) throw new Error(data.err?.stack || data.err?.message || data.err);
|
||||
if(data?.error) throw new Error(data.error?.stack || data.error?.message || data.error);
|
||||
this.lastUpdate = new Date();
|
||||
this.cached = data || null;
|
||||
this.logger.debug('Finished updating');
|
||||
return {timestamp: this.lastUpdate, data: this.cached};
|
||||
}).catch(err => {
|
||||
if(catchErr) this.logger.error(`Failed: ${typeof err == 'object' ? (err.stackTrace || err.message) : err}`)
|
||||
if(catchErr) console.error(`Failed: ${typeof err == 'object' ? (err.stackTrace || err.message) : err}`)
|
||||
else throw err;
|
||||
}).finally(() => this.pending = null);
|
||||
return this.pending;
|
||||
|
||||
@@ -10,6 +10,11 @@ export function cfg() {
|
||||
return {
|
||||
PORT: process.env.PORT || 3000,
|
||||
ADSB_URL: process.env.ADSB_URL || '',
|
||||
TINYGS_URL: process.env.TINYGS_URL || '',
|
||||
TINYGS_AUTH: process.env.TINYGS_AUTH || '',
|
||||
TINYGS_MQTT_PORT: process.env.TINYGS_MQTT_PORT || 1883,
|
||||
TINYGS_MQTT_USER: process.env.TINYGS_MQTT_USER || '',
|
||||
TINYGS_MQTT_PASS: process.env.TINYGS_MQTT_PASS || '',
|
||||
DB_HOST: process.env.DB_HOST || 'http://localhost:8428',
|
||||
EMAIL: process.env.EMAIL,
|
||||
LATITUDE: parseFloat(process.env.LATITUDE || '0'),
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
import {cfg} from './config.mjs';
|
||||
import {isEqual} from '@ztimson/utils';
|
||||
import {randomUUID} from 'node:crypto';
|
||||
|
||||
const HISTORY_LIMIT = 500;
|
||||
const POLL_MS = 5_000;
|
||||
const TTL_MS = 3 * 60_000;
|
||||
const MAX_PREDICTION_DT = 30;
|
||||
const MAX_AZ_ERROR = 35;
|
||||
const MAX_EL_ERROR = 20;
|
||||
const MAX_GROUND_DISTANCE_KM = 1000;
|
||||
const MAX_FREQ_ERROR_KHZ = 15;
|
||||
|
||||
const satellites = new Map();
|
||||
|
||||
function normalizeAzimuth(az) {
|
||||
return ((az % 360) + 360) % 360;
|
||||
}
|
||||
|
||||
function circularDistance(a, b) {
|
||||
const diff = Math.abs(normalizeAzimuth(a) - normalizeAzimuth(b));
|
||||
return Math.min(diff, 360 - diff);
|
||||
}
|
||||
|
||||
function circularDelta(from, to) {
|
||||
let delta = normalizeAzimuth(to) - normalizeAzimuth(from);
|
||||
if (delta > 180) delta -= 360;
|
||||
if (delta < -180) delta += 360;
|
||||
return delta;
|
||||
}
|
||||
|
||||
function haversineDistance(lat1, lon1, lat2, lon2) {
|
||||
const R = 6371;
|
||||
const dLat = (lat2 - lat1) * Math.PI / 180;
|
||||
const dLon = (lon2 - lon1) * Math.PI / 180;
|
||||
const a =
|
||||
Math.sin(dLat / 2) ** 2 +
|
||||
Math.cos(lat1 * Math.PI / 180) *
|
||||
Math.cos(lat2 * Math.PI / 180) *
|
||||
Math.sin(dLon / 2) ** 2;
|
||||
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||
}
|
||||
|
||||
function calculateBearing(lat1, lon1, lat2, lon2) {
|
||||
const φ1 = lat1 * Math.PI / 180;
|
||||
const φ2 = lat2 * Math.PI / 180;
|
||||
const λ = (lon2 - lon1) * Math.PI / 180;
|
||||
const y = Math.sin(λ) * Math.cos(φ2);
|
||||
const x = Math.cos(φ1) * Math.sin(φ2) - Math.sin(φ1) * Math.cos(φ2) * Math.cos(λ);
|
||||
return normalizeAzimuth(Math.atan2(y, x) * 180 / Math.PI);
|
||||
}
|
||||
|
||||
function parseWm(raw) {
|
||||
const f = raw.replace(/<[^>]+>/g, '').split(',').map(s => s.trim());
|
||||
const [latitude, longitude] = (f[15] || '').split('/').map(s => parseFloat(s));
|
||||
const [az, el] = (f[16] || '').split('/').map(s => parseFloat(s));
|
||||
return {
|
||||
timestamp: Date.now(),
|
||||
freqMHz: parseFloat(f[3]),
|
||||
satellite: f[14],
|
||||
latitude,
|
||||
longitude,
|
||||
az: normalizeAzimuth(az),
|
||||
el,
|
||||
packetRssi: parseFloat(f[21]),
|
||||
packetSnr: parseFloat(f[22]),
|
||||
freqError: parseFloat(f[23]),
|
||||
crcOk: !/CRC ERROR/i.test(f[24]),
|
||||
};
|
||||
}
|
||||
|
||||
function createTrack(reading) {
|
||||
const id = randomUUID();
|
||||
const track = {
|
||||
id,
|
||||
name: reading.satellite,
|
||||
history: [],
|
||||
lastSeen: reading.timestamp,
|
||||
state: {
|
||||
az: reading.az,
|
||||
el: reading.el,
|
||||
latitude: reading.latitude,
|
||||
longitude: reading.longitude,
|
||||
freqError: reading.freqError,
|
||||
azVelocity: 0,
|
||||
elVelocity: 0,
|
||||
latitudeVelocity: 0,
|
||||
longitudeVelocity: 0,
|
||||
groundSpeedKmh: 0,
|
||||
heading: null,
|
||||
freqErrorVelocity: 0,
|
||||
confidence: 0.25,
|
||||
},
|
||||
};
|
||||
|
||||
track.history.push(reading);
|
||||
satellites.set(id, track);
|
||||
return track;
|
||||
}
|
||||
|
||||
function predictTrack(track, timestamp) {
|
||||
const dtRaw = (timestamp - track.lastSeen) / 1000;
|
||||
const dt = Math.max(0, Math.min(dtRaw, MAX_PREDICTION_DT));
|
||||
const state = track.state;
|
||||
return {
|
||||
az: normalizeAzimuth(state.az + state.azVelocity * dt),
|
||||
el: state.el + state.elVelocity * dt,
|
||||
latitude: state.latitude + state.latitudeVelocity * dt,
|
||||
longitude: state.longitude + state.longitudeVelocity * dt,
|
||||
freqError: state.freqError + state.freqErrorVelocity * dt,
|
||||
dt,
|
||||
};
|
||||
}
|
||||
|
||||
function scoreTrack(track, reading) {
|
||||
if(!track || !reading) return Infinity;
|
||||
const dt = (reading.timestamp - track.lastSeen) / 1000;
|
||||
if(dt <= 0 || dt > MAX_PREDICTION_DT) return Infinity;
|
||||
if(track.name !== reading.satellite) return Infinity;
|
||||
|
||||
const predicted = predictTrack(track, reading.timestamp);
|
||||
const azError = circularDistance(predicted.az, reading.az);
|
||||
const elError = Math.abs(predicted.el - reading.el);
|
||||
if(azError > MAX_AZ_ERROR || elError > MAX_EL_ERROR) return Infinity;
|
||||
|
||||
let groundDistance = 0;
|
||||
if(Number.isFinite(reading.latitude) && Number.isFinite(reading.longitude) && Number.isFinite(predicted.latitude) && Number.isFinite(predicted.longitude)) {
|
||||
groundDistance = haversineDistance(predicted.latitude, predicted.longitude, reading.latitude, reading.longitude);
|
||||
if(groundDistance > MAX_GROUND_DISTANCE_KM) return Infinity;
|
||||
}
|
||||
|
||||
let freqErrorDifference = 0;
|
||||
if(Number.isFinite(reading.freqError) && Number.isFinite(predicted.freqError)) {
|
||||
freqErrorDifference = Math.abs(reading.freqError - predicted.freqError);
|
||||
if(freqErrorDifference > MAX_FREQ_ERROR_KHZ) return Infinity;
|
||||
}
|
||||
|
||||
return (azError * 4 + elError * 5 + groundDistance * 0.03 + freqErrorDifference * 0.15); // Lower = better
|
||||
}
|
||||
|
||||
function updateTrack(track, reading) {
|
||||
const previous = track.history.at(-1);
|
||||
|
||||
if (!previous) {
|
||||
track.history.push(reading);
|
||||
track.lastSeen = reading.timestamp;
|
||||
return;
|
||||
}
|
||||
|
||||
const dt = (reading.timestamp - previous.timestamp) / 1000;
|
||||
if(dt <= 0) return;
|
||||
|
||||
const state = track.state;
|
||||
const azVelocity = circularDelta(previous.az, reading.az) / dt;
|
||||
const elVelocity = (reading.el - previous.el) / dt;
|
||||
const latitudeVelocity = (reading.latitude - previous.latitude) / dt;
|
||||
const longitudeVelocity = (reading.longitude - previous.longitude) / dt;
|
||||
const distanceKm = haversineDistance(previous.latitude, previous.longitude, reading.latitude, reading.longitude);
|
||||
const groundSpeedKmh = distanceKm / dt * 3600;
|
||||
const heading = distanceKm > 0.01 ? calculateBearing(previous.latitude, previous.longitude, reading.latitude, reading.longitude) : state.heading;
|
||||
const freqErrorVelocity = Number.isFinite(reading.freqError) && Number.isFinite(previous.freqError) ? (reading.freqError - previous.freqError) / dt : state.freqErrorVelocity;
|
||||
const SMOOTHING = 0.35;
|
||||
|
||||
state.azVelocity = state.azVelocity * (1 - SMOOTHING) + azVelocity * SMOOTHING;
|
||||
state.elVelocity = state.elVelocity * (1 - SMOOTHING) + elVelocity * SMOOTHING;
|
||||
state.latitudeVelocity = state.latitudeVelocity * (1 - SMOOTHING) + latitudeVelocity * SMOOTHING;
|
||||
state.longitudeVelocity = state.longitudeVelocity * (1 - SMOOTHING) + longitudeVelocity * SMOOTHING;
|
||||
state.freqErrorVelocity = state.freqErrorVelocity * (1 - SMOOTHING) + freqErrorVelocity * SMOOTHING;
|
||||
state.az = reading.az;
|
||||
state.el = reading.el;
|
||||
state.latitude = reading.latitude;
|
||||
state.longitude = reading.longitude;
|
||||
state.freqError = reading.freqError;
|
||||
state.groundSpeedKmh = groundSpeedKmh;
|
||||
state.heading = heading;
|
||||
|
||||
const prediction = predictTrack(track, reading.timestamp);
|
||||
const predictionError = circularDistance(prediction.az, reading.az) + Math.abs(prediction.el - reading.el);
|
||||
|
||||
if(predictionError < 5) {
|
||||
state.confidence = Math.min(1, state.confidence + 0.08);
|
||||
} else if(predictionError < 15) {
|
||||
state.confidence = Math.min(1, state.confidence + 0.03);
|
||||
} else {
|
||||
state.confidence = Math.max(0, state.confidence - 0.08);
|
||||
}
|
||||
|
||||
track.lastSeen = reading.timestamp;
|
||||
if(!isEqual(track.history.at(-1), reading)) {
|
||||
track.history.push(reading);
|
||||
if(track.history.length > HISTORY_LIMIT) track.history.shift();
|
||||
}
|
||||
}
|
||||
|
||||
function pruneStale() {
|
||||
const now = Date.now();
|
||||
for(const [id, track] of satellites) {
|
||||
if(now - track.lastSeen > TTL_MS) satellites.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
function findBestTrack(reading) {
|
||||
let bestTrack = null;
|
||||
let bestScore = Infinity;
|
||||
for(const track of satellites.values()) {
|
||||
const score = scoreTrack(track, reading);
|
||||
if(score < bestScore) {
|
||||
bestScore = score;
|
||||
bestTrack = track;
|
||||
}
|
||||
}
|
||||
return {track: bestTrack, score: bestScore,};
|
||||
}
|
||||
|
||||
export async function pollTinyGS() {
|
||||
const {TINYGS_URL, TINYGS_AUTH} = cfg();
|
||||
try {
|
||||
const raw = await fetch(TINYGS_URL + '/wm', {
|
||||
headers: TINYGS_AUTH ? {Authorization: `Basic ${TINYGS_AUTH}`} : {},
|
||||
}).then(r => r.text());
|
||||
const reading = parseWm(raw);
|
||||
if(!reading.satellite || reading.satellite === '-') return;
|
||||
|
||||
pruneStale();
|
||||
const {track, score,} = findBestTrack(reading);
|
||||
if(!track || !Number.isFinite(score)) {
|
||||
createTrack(reading);
|
||||
return;
|
||||
}
|
||||
|
||||
updateTrack(track, reading);
|
||||
} catch (err) {
|
||||
console.error('[tinygs] poll failed', err);
|
||||
}
|
||||
}
|
||||
|
||||
setInterval(pollTinyGS, POLL_MS);
|
||||
|
||||
export const getTinyGSData = () => {
|
||||
pruneStale();
|
||||
return Array.from(satellites.values()).map(track => {
|
||||
const latest = track.history.at(-1);
|
||||
return {
|
||||
id: track.id,
|
||||
satellite: track.name,
|
||||
...latest,
|
||||
velocity: {
|
||||
v: track.state.groundSpeedKmh,
|
||||
az: track.state.azVelocity,
|
||||
el: track.state.elVelocity,
|
||||
},
|
||||
heading: track.state.heading,
|
||||
history: track.history.slice(0, -1),
|
||||
};
|
||||
});
|
||||
};
|
||||
@@ -108,11 +108,14 @@ export async function queryDaily(start, end) {
|
||||
|
||||
export async function getCoords() {
|
||||
const c = cfg();
|
||||
if(c.LATITUDE !== 0 && c.LONGITUDE !== 0 && c.ALTITUDE !== 0)
|
||||
return {latitude: c.LATITUDE, longitude: c.LONGITUDE, altitude: c.ALTITUDE};
|
||||
|
||||
const results = await queryInstant(c, `{__name__=~"latitude|longitude|altitude"}`);
|
||||
const fields = metricToFields(results);
|
||||
|
||||
if (fields.latitude && fields.longitude) {
|
||||
if(fields.latitude && fields.longitude) {
|
||||
return {latitude: fields.latitude, longitude: fields.longitude, altitude: fields.altitude || c.ALTITUDE};
|
||||
}
|
||||
return {latitude: c.LATITUDE, longitude: c.LONGITUDE, altitude: c.ALTITUDE};
|
||||
return {latitude: 0, longitude: 0, altitude: 0};
|
||||
}
|
||||
|
||||
+26
-25
@@ -8,10 +8,14 @@ import {apiReference} from '@scalar/express-api-reference';
|
||||
import {spec} from './spec.mjs';
|
||||
import {existsSync} from 'fs';
|
||||
import {getAIS, getAISImage} from './ais.mjs';
|
||||
import {getADSBImage, getADSB, getADSBHistory, getADSBRange, initAircraftDb} from './adsb.mjs';
|
||||
import {getIcaoImage, get, getHistory, getAttenuation, initAircraftDb} from './adsb.mjs';
|
||||
import {fetchIcon} from './openweather.mjs';
|
||||
import {getForecast, getWeatherCondition, forecastTTL} from './forecast.mjs';
|
||||
import {dailyWeather, hourlyWeather} from './openmeteo.mjs';
|
||||
import {getTinyGSData} from './sats.mjs';
|
||||
import {getSpaceWeather} from './space.mjs';
|
||||
import {Aurora} from './aurora.mjs';
|
||||
import {getSondes} from './sonde.mjs';
|
||||
|
||||
// ── Uncaught error handlers ───────────────────────────────────────────────────
|
||||
|
||||
@@ -47,7 +51,7 @@ function filterArr(arr, fields) {
|
||||
return arr.map(row => filterFields(row, fields));
|
||||
}
|
||||
|
||||
// ── Current ───────────────────────────────────────────────────────────────────
|
||||
// ── Weather ───────────────────────────────────────────────────────────────────
|
||||
|
||||
app.get('/api/current', asyncHandler(async (req, res) => {
|
||||
const { fields } = req.query
|
||||
@@ -71,8 +75,6 @@ app.get('/api/current', asyncHandler(async (req, res) => {
|
||||
res.json(filterFields(merged, fields))
|
||||
}));
|
||||
|
||||
// ── Hourly ────────────────────────────────────────────────────────────────────
|
||||
|
||||
app.get('/api/hourly', asyncHandler(async (req, res) => {
|
||||
const { fields } = req.query
|
||||
const coords = await getCoords()
|
||||
@@ -98,8 +100,6 @@ app.get('/api/hourly', asyncHandler(async (req, res) => {
|
||||
res.json(filterArr(combined, fields))
|
||||
}))
|
||||
|
||||
// ── Daily ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
app.get('/api/daily', asyncHandler(async (req, res) => {
|
||||
const { fields } = req.query
|
||||
const coords = await getCoords()
|
||||
@@ -129,6 +129,7 @@ app.get('/api/daily', asyncHandler(async (req, res) => {
|
||||
|
||||
// ── Position ──────────────────────────────────────────────────────────────────
|
||||
|
||||
app.get('/api/range', asyncHandler(async (req, res) => res.json(await getAttenuation())));
|
||||
app.get('/api/position', asyncHandler(async (req, res) => {
|
||||
const {fields} = req.query;
|
||||
const data = await getCoords();
|
||||
@@ -146,30 +147,30 @@ app.get('/api/icon/:icon', asyncHandler(async (req, res, next) => {
|
||||
|
||||
// ── Space ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
// app.get('/api/aurora', async (req, res) => res.json(await Aurora.get()));
|
||||
// app.get('/api/space', async (req, res) => {
|
||||
// const {fields} = req.query;
|
||||
// res.json(filterFields(await getSpaceWeather(), fields));
|
||||
// });
|
||||
app.get('/api/aurora', async (req, res) => res.json(await Aurora.get()));
|
||||
app.get('/api/space', async (req, res) => {
|
||||
const {fields} = req.query;
|
||||
res.json(filterFields(await getSpaceWeather(), fields));
|
||||
});
|
||||
|
||||
// ── ADSB/AIS Proxy ────────────────────────────────────────────────────────────
|
||||
// ── ADSB/AIS/SAT/RadioSonde Proxy ────────────────────────────────────────────────────────────
|
||||
|
||||
app.get('/api/adsb', asyncHandler(async (req, res) => res.json(await getADSB())));
|
||||
app.get('/api/adsb-image/:icao', asyncHandler(async (req, res) => {
|
||||
const blob = await getADSBImage(req.params.icao);
|
||||
if (!blob) return res.status(404).send('No image found');
|
||||
const buffer = Buffer.from(await blob.arrayBuffer());
|
||||
res.contentType(blob.type).send(buffer);
|
||||
app.get('/api/adsb', asyncHandler(async (req, res) => res.json(await get())));
|
||||
app.get('/api/adsb/:icao/image', asyncHandler(async (req, res) => {
|
||||
const buffer = await getIcaoImage(req.params.icao);
|
||||
res.contentType('image/jpeg').send(buffer)
|
||||
}));
|
||||
app.get('/api/adsb/:icao', asyncHandler(async (req, res) => res.json(await getADSBHistory(req.params.icao))));
|
||||
app.get('/api/adsb/:icao', asyncHandler(async (req, res) => res.json(await getHistory(req.params.icao))));
|
||||
|
||||
app.get('/api/ais', asyncHandler(async (req, res) => res.json(await getAIS())));
|
||||
app.get('/api/ais-image/:mmsi', asyncHandler(async (req, res) => {
|
||||
const blob = await getAISImage(req.params.mmsi);
|
||||
if (!blob) return res.status(404).send('No image found');
|
||||
const buffer = Buffer.from(await blob.arrayBuffer());
|
||||
res.contentType(blob.type).send(buffer);
|
||||
app.get('/api/ais/:mmsi/image', asyncHandler(async (req, res) => {
|
||||
const buffer = await getAISImage(req.params.mmsi);
|
||||
res.contentType('image/jpeg').send(buffer)
|
||||
}));
|
||||
app.get('/api/range', asyncHandler(async (req, res) => res.json(await getADSBRange())));
|
||||
|
||||
app.get('/api/sondes', asyncHandler(async (req, res) => res.json(await getSondes())));
|
||||
|
||||
app.get('/api/sats', (_req, res) => res.json(getTinyGSData()));
|
||||
|
||||
// ── DOCS ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user