Compare commits

...
39 Commits
Author SHA1 Message Date
ztimson 25a3ce6b8f Merge pull request 'Issue 1' (#2) from issue-1 into master
Reviewed-on: #2
2026-09-16 23:38:41 -04:00
assistant cf693e6f08 Fix military aircraft classification regex to reduce false positives (issue-1) 2026-09-16 23:36:58 -04:00
assistant 4520447156 Fix military aircraft classification regex to reduce false positives (issue-1) 2026-09-16 23:27:47 -04:00
ztimson 534dc6d2df Better military plane ID 2026-09-15 15:42:02 -04:00
ztimson 4c7a7fd1f5 Better AutoMode 2026-09-15 11:47:29 -04:00
ztimson 28478251cd Fixed sonde freq 2026-09-14 08:49:27 -04:00
ztimson 1cf7545c93 Fixed sonde climb rate and freq 2026-09-14 08:46:54 -04:00
ztimson 6e21a8626c Fixed sonde climb rate and TTL 2026-09-14 08:43:46 -04:00
ztimson 9f65318aee Reactive sonde icon 2026-09-14 01:31:00 -04:00
ztimson 5ac3871da0 Reactive sonde icon 2026-09-14 01:15:02 -04:00
ztimson eaef6e8066 Image lookup fix? 2026-09-14 00:48:24 -04:00
ztimson a0c15f1631 Better aircraft categorizing 2026-09-14 00:20:39 -04:00
ztimson d57faa486c More military fixes 2026-09-13 23:59:50 -04:00
ztimson 3c6cc71346 Fix military classification 2026-09-13 23:41:47 -04:00
ztimson a255638f73 better military icao detection, airlines and stock image indicator 2026-09-13 23:33:47 -04:00
ztimson 746ec877da better categorizing? 2026-09-13 14:45:43 -04:00
ztimson 8eb3e49308 Finished Sonde support 2026-09-13 14:04:03 -04:00
ztimson 1d1e7351db Radiosonde + sat track updates 2026-09-13 13:16:40 -04:00
ztimson 0e152b4dc7 Test radiosonde.mjs 2026-09-13 11:49:50 -04:00
ztimson fca0e942e7 Radio sonde 2026-09-13 09:51:27 -04:00
ztimson 6c4310753f Radio sonde on map 2026-09-13 09:34:41 -04:00
ztimson 0ebf997924 Radio sonde on map 2026-09-13 09:32:49 -04:00
ztimson 2b299753ca Radio sonde on map 2026-09-13 09:29:19 -04:00
ztimson 83c48bf3e7 Radio sonde + auto mode 2026-09-13 09:12:06 -04:00
ztimson a99cbf3354 Save ais / adsb images to disk 2026-09-12 12:04:57 -04:00
ztimson a60f605e6b Fixed icao images 2026-09-12 11:11:06 -04:00
ztimson 6bcc5bec19 Hooked up auroras 2026-09-11 22:08:14 -04:00
ztimson fc306f277a Reverted tinygs mqtt 2026-09-11 12:17:55 -04:00
ztimson 396a1f4cab WIP: MQTT 2026-09-11 11:28:34 -04:00
ztimson b86e4613bf Use default non tls port for mqtt 2026-09-11 11:22:56 -04:00
ztimson a0cfe64188 updated mqtt address to match tinygs defaults 2026-09-11 11:12:14 -04:00
ztimson 033e34a971 new mqtt server for tinygs 2026-09-11 11:08:38 -04:00
ztimson 688552d0d3 Fixed plane trace history 2026-09-11 10:36:33 -04:00
ztimson 7cc60afbae Use frequency in sat id 2026-09-11 07:34:49 -04:00
ztimson 1db910cb4a Sat updates 2026-09-11 00:47:22 -04:00
ztimson 2b31bdde8f Sat updates 2026-09-11 00:40:48 -04:00
ztimson 79acad8c07 Sats 2026-09-10 22:52:44 -04:00
ztimson 2c25251864 Merge remote-tracking branch 'origin/master' 2026-09-04 08:57:17 -04:00
ztimson 9e1ef69b16 Fixed boat direction 2026-09-04 08:57:13 -04:00
26 changed files with 4265 additions and 947 deletions
+2
View File
@@ -0,0 +1,2 @@
User-Agent: *
Allow: /
Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

+88 -83
View File
@@ -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
@@ -127,10 +130,10 @@ const navball = computed(() => {
const [start, end] = [85, 115]
if (isMajor) {
pitchLines.push(`
<text x="${start - Math.abs(p) - 5}" y="${104 + pitch * 2.5 - p * 2.5}" fill="#fff" font-size="8" font-weight="bold" text-anchor="end">${Math.abs(p)}</text>
<line x1="${start - Math.abs(p)}" y1="${100 + pitch * 2.5 - p * 2.5}" x2="${end + Math.abs(p)}" y2="${100 + pitch * 2.5 - p * 2.5}" stroke="#fff" stroke-width="2"/>
<text x="${end + Math.abs(p) + 5}" y="${104 + pitch * 2.5 - p * 2.5}" fill="#fff" font-size="8" font-weight="bold">${Math.abs(p)}</text>
`)
<text x="${start - Math.abs(p) - 5}" y="${104 + pitch * 2.5 - p * 2.5}" fill="#fff" font-size="8" font-weight="bold" text-anchor="end">${Math.abs(p)}</text>
<line x1="${start - Math.abs(p)}" y1="${100 + pitch * 2.5 - p * 2.5}" x2="${end + Math.abs(p)}" y2="${100 + pitch * 2.5 - p * 2.5}" stroke="#fff" stroke-width="2"/>
<text x="${end + Math.abs(p) + 5}" y="${104 + pitch * 2.5 - p * 2.5}" fill="#fff" font-size="8" font-weight="bold">${Math.abs(p)}</text>
`)
} else {
pitchLines.push(`<line x1="${start}" y1="${100 + pitch * 2.5 - p * 2.5}" x2="${end}" y2="${100 + pitch * 2.5 - p * 2.5}" stroke="#fff" stroke-width="2"/>`)
}
@@ -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;
@@ -326,15 +266,16 @@ const navball = computed(() => {
align-items: center;
justify-content: center;
}
.ap-photo { width: 100%; height: 100%; object-fit: cover; display: block; }
.ap-photo--sil { object-fit: contain; box-sizing: border-box; image-rendering: auto; }
.ap-photo { width: 100%; height: 100%; object-fit: cover; display: block; }
.ap-photo--sil { object-fit: contain; box-sizing: border-box; image-rendering: auto; }
.ap-body {
display: flex;
gap: 16px;
padding: 12px 16px;
}
.ap-gauges { flex: 0 0 110px; }
.ap-gauges { flex: 0 0 110px; }
.ap-gauge-wrap { margin-bottom: 12px; }
.ap-gauge-label {
@@ -356,8 +297,72 @@ const navball = computed(() => {
gap: 4px;
transition: border-color 0.15s;
}
.ap-gauge:hover { border-color: #0ff; }
.ap-gauge-val { font-size: 20px; font-weight: bold; color: #0f0; }
.ap-gauge-unit { font-size: 11px; color: #0f0; margin-bottom: 2px; }
.ap-navball { flex: 1; margin-top: 1rem; }
.ap-gauge:hover { border-color: #0ff; }
.ap-gauge-val { font-size: 20px; font-weight: bold; color: #0f0; }
.ap-gauge-unit { font-size: 11px; color: #0f0; margin-bottom: 2px; }
.ap-navball { flex: 1; margin-top: 1rem; }
</style>
<template>
<div class="aircraft-popup" :style="{ left: pos.x + 'px', top: pos.y + 'px' }">
<div ref="header" class="ap-header" @mousedown="onMouseDown" @touchstart.passive="onTouchStart">
<h3 class="ap-callsign">
<a v-if="callsign" :href="`https://www.flightaware.com/live/flight/${callsign}`" target="_blank" @click.stop>{{ callsign.toUpperCase() }}</a>
<span v-else>N/A</span>
<span style="color: white"> / {{ (plane.icao || plane.hex || '').toUpperCase() }}</span>
</h3>
<button class="ap-close" @click.stop="emit('close')">×</button>
</div>
<div class="ap-meta flex-r justify-between">
<div class="flex-c 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>
+166
View File
@@ -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>
+599 -180
View File
@@ -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()
} else {
activeOverlays.value.add('wind')
showWindOverlay()
}
return
const handler = OVERLAY_HANDLERS[id];
if (!handler) return;
if (activeOverlays.value.has(id)) {
activeOverlays.value.delete(id);
handler.hide();
} else {
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
</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-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>
<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
</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-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>
<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>
+261
View File
@@ -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>
+6 -6
View File
@@ -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);
+76 -64
View File
@@ -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>
+542
View File
@@ -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>
+273 -186
View File
@@ -1,224 +1,311 @@
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,
latitude: a.lat,
icao: a.hex,
latitude: a.lat,
longitude: a.lon,
heading: a.track,
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)
}
heading: a.track,
speed: a.gs,
climb: a.baro_rate ?? a.geom_rate ?? 0,
name: a.flight?.trim(),
}));
}
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)
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);
}
this.traceSegs[icao] = segs
}
private _calcPopupPos(plane: any): { x: number; y: number } {
const pixel: any = this.map.getPixelFromCoordinate(fromLonLat([plane.longitude, plane.latitude]))
if (!pixel) return { x: 10, y: 60 }
const rect = (this.map.getTargetElement() as HTMLElement).getBoundingClientRect()
return { x: rect.left + pixel[0] + 16, y: rect.top + pixel[1] - 16 }
}
private _openPopup(plane: any) {
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];
}
}
+181 -120
View File
@@ -1,31 +1,52 @@
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',
'Class A': '#00ff00',
'Class B': '#8450ea',
'SAR Aircraft': '#ff0000',
'AtoN': '#ffffff',
'Class B/CS': '#8450ea',
'Base Station': '#ffffff',
'Class A': '#00ff00',
'Class B': '#8450ea',
'SAR Aircraft': '#ff0000',
'AtoN': '#ffffff',
'Class B/CS': '#8450ea',
'Sart/Epirb/MOB': '#00aaff',
'Unknown': '#fad106',
'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 }
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);
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;
}
}, 5_000)
}
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)
}
this.openPopup(boat);
};
private async _fetch() {
this.data = await fetch(`${API}/ais`).then(r => r.json()) || []
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),
anchor: [0.5, 0.5],
src: buildBoatIcon(boat),
scale: 0.8,
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)
}
}
+104
View File
@@ -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);
});
}
}
+306
View File
@@ -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);
}
}
+231
View File
@@ -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];
}
}
}
+14 -6
View File
@@ -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))]
+2
View File
@@ -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": {
+615
View File
@@ -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",
+1
View File
@@ -14,6 +14,7 @@
"cheerio": "^1.2.0",
"dotenv": "^16.3.1",
"express": "^4.18.2",
"sharp": "^0.35.4",
"yaml": "^2.9.0"
}
}
+394 -254
View File
@@ -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 DB_PATH = resolve(DATA, 'aircraft.db');
const CSV_CACHE = resolve(DATA, 'aircraft_db.csv');
const MAX_HISTORY = 500;
const HISTORY_TTL = 1000 * 60 * 60; // 1 hour
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 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 MILITARY_CLASSES = ['H1M', 'H2M', 'L1M', 'L2M', 'L4M', 'A0'];
const CSV_URL = 'https://s3.opensky-network.org/data-samples/metadata/aircraft-database-complete-2024-06.csv';
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', '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,19 +103,131 @@ function fetchWithTimeout(url, ms = 5000) {
return fetch(url, { signal: controller.signal }).finally(() => clearTimeout(id));
}
async function syncMilitaryRanges() {
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 (
icao TEXT PRIMARY KEY,
built TEXT,
categoryDescription TEXT,
country TEXT,
engines TEXT,
class TEXT,
manufacturer TEXT,
model TEXT,
modes TEXT,
operator TEXT,
operatorCallsign TEXT,
owner TEXT,
registration TEXT,
serialNumber TEXT,
aircraft TEXT
);
CREATE TABLE IF NOT EXISTS military_ranges (start TEXT NOT NULL, end TEXT NOT NULL);
`);
await syncAirlines();
await syncMilitaryIcao();
if(missing) {
const res = await fetch(CSV_URL);
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
)
`);
const insertMany = db.transaction(rows => {
for (const row of rows) insert.run(row);
});
insertMany(csv.map(row => ({
icao: (row.icao24 || '').toUpperCase(),
built: row.built || null,
categoryDescription: row.categoryDescription || null,
country: row.country || null,
engines: row.engines || null,
class: row.icaoAircraftClass || null,
manufacturer: row.manufacturerName || row.manufacturerIcao || null,
model: row.model || null,
modes: row.modes || null,
operator: row.operator || null,
operatorCallsign: row.operatorCallsign || null,
owner: row.owner || null,
registration: row.registration || null,
serialNumber: row.serialNumber || null,
aircraft: row.typecode || null
})));
fs.unlinkSync(CSV_CACHE);
console.log(`✈️ Registrations imported: ${csv.length}`);
} else {
const count = db.prepare('SELECT COUNT(*) as c FROM aircraft').get();
console.log(`✈️ Registrations loaded: ${count.c}`);
}
}
function isMilitaryIcao(icao) {
if (!icao) return false;
const hex = icao.toUpperCase();
return milRanges.some(([s, e]) => hex >= s && hex <= e);
}
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 res = await fetch(ADSB_URL + MIL_RANGES_URL);
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()]));
@@ -67,248 +238,117 @@ async function syncMilitaryRanges() {
}
}
export async function initAircraftDb() {
if (!fs.existsSync(DATA)) fs.mkdirSync(DATA, { 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 (
icao TEXT PRIMARY KEY,
built TEXT,
categoryDescription TEXT,
country TEXT,
engines TEXT,
class TEXT,
manufacturer TEXT,
model TEXT,
modes TEXT,
operator TEXT,
operatorCallsign TEXT,
owner TEXT,
registration TEXT,
serialNumber TEXT,
aircraft TEXT
);
CREATE TABLE IF NOT EXISTS military_ranges (
start TEXT NOT NULL,
end TEXT NOT NULL
);
`);
await syncMilitaryRanges();
if (missing) {
const res = await fetch(CSV_URL);
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
)
`);
const insertMany = db.transaction(rows => {
for (const row of rows) insert.run(row);
});
insertMany(csv.map(row => ({
icao: (row.icao24 || '').toUpperCase(),
built: row.built || null,
categoryDescription: row.categoryDescription || null,
country: row.country || null,
engines: row.engines || null,
class: row.icaoAircraftClass || null,
manufacturer: row.manufacturerName || row.manufacturerIcao || null,
model: row.model || null,
modes: row.modes || null,
operator: row.operator || null,
operatorCallsign: row.operatorCallsign || null,
owner: row.owner || null,
registration: row.registration || null,
serialNumber: row.serialNumber || null,
aircraft: row.typecode || null,
})));
fs.unlinkSync(CSV_CACHE);
console.log(`✈️ Aircraft imported: ${csv.length}`);
} else {
const count = db.prepare('SELECT COUNT(*) as c FROM aircraft').get();
console.log(`✈️ Aircraft loaded: ${count.c}`);
}
}
function isIcaoInMilitaryRange(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);
}
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)
ON CONFLICT(icao) DO UPDATE SET
registration = COALESCE(excluded.registration, registration),
manufacturer = COALESCE(excluded.manufacturer, manufacturer),
aircraft = COALESCE(excluded.aircraft, aircraft),
model = COALESCE(excluded.model, model),
operator = COALESCE(excluded.operator, operator),
country = COALESCE(excluded.country, country),
serialNumber = COALESCE(excluded.serialNumber, serialNumber),
engines = COALESCE(excluded.engines, engines),
categoryDescription = COALESCE(excluded.categoryDescription, categoryDescription),
class = COALESCE(excluded.class, class)
`).run({
ON CONFLICT(icao) DO UPDATE SET
registration = COALESCE(excluded.registration, registration),
manufacturer = COALESCE(excluded.manufacturer, manufacturer),
aircraft = COALESCE(excluded.aircraft, aircraft),
model = COALESCE(excluded.model, model),
operator = COALESCE(excluded.operator, operator),
country = COALESCE(excluded.country, country),
serialNumber = COALESCE(excluded.serialNumber, serialNumber),
engines = COALESCE(excluded.engines, engines),
categoryDescription = COALESCE(excluded.categoryDescription, categoryDescription),
class = COALESCE(excluded.class, class)
`).run({
icao,
registration: merged.registration || null,
manufacturer: merged.manufacturer || null,
aircraft: merged.aircraft || null,
model: merged.model || null,
operator: merged.operator || null,
country: merged.country || null,
serialNumber: merged.serialNumber || null,
engines: merged.engines || null,
registration: merged.registration || null,
manufacturer: merged.manufacturer || null,
aircraft: merged.aircraft || null,
model: merged.model || null,
operator: merged.operator || null,
country: merged.country || null,
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;
}
async function genericImage(modelType) {
if (!modelType) 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;
const filePath = resolve(IMAGES_DIR, 'generic', `${modelType}.jpg`);
if (fs.existsSync(filePath)) return fs.readFileSync(filePath);
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 blob = await duckduckgo(`${modelType} Aircraft`);
if (!blob) return null;
let src;
if(specific?.photos?.[0]) src = specific.photos[0].thumbnail_large?.src || specific.photos[0].thumbnail?.src;
else src = await generic;
fs.mkdirSync(dirname(filePath), { recursive: true });
if(!src) return null;
return fetch(src).then(resp => resp.blob());
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
View File
@@ -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;
}
+3 -6
View File
@@ -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;
+6 -1
View File
@@ -10,7 +10,12 @@ export function cfg() {
return {
PORT: process.env.PORT || 3000,
ADSB_URL: process.env.ADSB_URL || '',
DB_HOST: process.env.DB_HOST || 'http://localhost:8428',
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'),
LONGITUDE: parseFloat(process.env.LONGITUDE || '0'),
+256
View File
@@ -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),
};
});
};
+5 -2
View File
@@ -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
View File
@@ -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