Compare commits

...
38 Commits
Author SHA1 Message Date
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

+77 -72
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
@@ -191,75 +194,13 @@ const navball = computed(() => {
})
</script>
<template>
<div class="aircraft-popup" :style="{ left: pos.x + 'px', top: pos.y + 'px' }">
<!-- Header (drag handle) -->
<div ref="header" class="ap-header" @mousedown="onMouseDown" @touchstart.passive="onTouchStart">
<h3 class="ap-callsign">
<a v-if="callsign" :href="`https://www.flightaware.com/live/flight/${callsign}`" target="_blank" @click.stop>{{ callsign.toUpperCase() }}</a>
<span v-else>N/A</span>
<span style="color: white"> / {{ (plane.icao || plane.hex || '').toUpperCase() }}</span>
</h3>
<button class="ap-close" @click.stop="emit('close')">×</button>
</div>
<!-- Meta -->
<div class="ap-meta flex-r justify-between">
<div class="flex-c">
<span>{{ operator || 'Unknown Owner' }}</span>
<span>{{ plane.country || 'Unknown Country' }}</span>
</div>
<div class="flex-c align-x-end">
<span>{{ description || 'Unknown Aircraft' }}</span>
<span style="text-transform: capitalize">{{ plane.class || 'Unknown' }} {{ plane.type || 'Unknown' }}</span>
</div>
</div>
<!-- Photo -->
<div v-if="!photoError" class="ap-photo-wrap">
<img class="ap-photo" :src="BASE + '/api/adsb-image/' + plane.icao" :alt="callsign" @error="photoError = true" />
</div>
<!-- Body -->
<div class="ap-body">
<div class="ap-gauges">
<div class="ap-gauge-wrap">
<div class="ap-gauge-label">Air Speed</div>
<div class="ap-gauge" @click.stop="cycleSpeed">
<span class="ap-gauge-val">{{ speedVal }}</span>
<span class="ap-gauge-unit">{{ speed.label }}</span>
</div>
</div>
<div class="ap-gauge-wrap">
<div class="ap-gauge-label">Altitude</div>
<div class="ap-gauge" @click.stop="cycleAltitude">
<span class="ap-gauge-val">{{ altVal }}</span>
<span class="ap-gauge-unit">{{ altUnit }}</span>
</div>
</div>
<div class="ap-gauge-wrap">
<div class="ap-gauge-label">Climb</div>
<div class="ap-gauge" @click.stop="cycleVertical">
<span class="ap-gauge-val">{{ climbVal }}</span>
<span class="ap-gauge-unit">{{ vertical.label }}</span>
</div>
</div>
</div>
<div class="ap-navball" v-html="navball" />
</div>
</div>
</template>
<style scoped>
.aircraft-popup {
position: fixed;
z-index: 100;
pointer-events: auto;
background: rgba(0,0,0,0.88);
border: 1px solid rgba(200,200,220,0.3);
border: 1px solid rgba(0, 255, 0, 0.3);
border-radius: 8px;
box-shadow: 0 4px 24px rgba(0,0,0,0.5);
overflow: hidden;
@@ -272,7 +213,7 @@ const navball = computed(() => {
.ap-header {
position: relative;
padding: 12px 16px;
border-bottom: 1px solid rgba(200,200,220,0.2);
border-bottom: 1px solid rgba(0, 255, 0, 0.2);
cursor: move;
user-select: none;
-webkit-user-select: none;
@@ -315,7 +256,6 @@ const navball = computed(() => {
}
.ap-close:hover { background: rgba(255,255,255,0.35); }
/* ── Photo ── */
.ap-photo-wrap {
max-width: 360px;
max-height: 160px;
@@ -328,6 +268,7 @@ const navball = computed(() => {
}
.ap-photo { width: 100%; height: 100%; object-fit: cover; display: block; }
.ap-photo--sil { object-fit: contain; box-sizing: border-box; image-rendering: auto; }
.ap-body {
display: flex;
gap: 16px;
@@ -361,3 +302,67 @@ const navball = computed(() => {
.ap-gauge-unit { font-size: 11px; color: #0f0; margin-bottom: 2px; }
.ap-navball { flex: 1; margin-top: 1rem; }
</style>
<template>
<div class="aircraft-popup" :style="{ left: pos.x + 'px', top: pos.y + 'px' }">
<div ref="header" class="ap-header" @mousedown="onMouseDown" @touchstart.passive="onTouchStart">
<h3 class="ap-callsign">
<a v-if="callsign" :href="`https://www.flightaware.com/live/flight/${callsign}`" target="_blank" @click.stop>{{ callsign.toUpperCase() }}</a>
<span v-else>N/A</span>
<span style="color: white"> / {{ (plane.icao || plane.hex || '').toUpperCase() }}</span>
</h3>
<button class="ap-close" @click.stop="emit('close')">×</button>
</div>
<div class="ap-meta flex-r justify-between">
<div class="flex-c flex-fill">
<span>{{ operator || 'Unknown Owner' }}</span>
<span>{{ plane.country || 'Unknown Country' }}</span>
</div>
<div class="flex-c flex-fill align-x-end">
<span>{{ description || 'Unknown Aircraft' }}</span>
<span style="text-transform: capitalize">{{ plane.class || 'Unknown' }} {{ plane.type || 'Unknown' }}</span>
</div>
</div>
<div v-if="!photoError" class="ap-photo-wrap">
<img class="ap-photo" :src="BASE + '/api/adsb/' + plane.icao + '/image'" :alt="callsign" @error="photoError = true" />
</div>
<div class="ap-body">
<div class="ap-gauges">
<div class="ap-gauge-wrap">
<div class="ap-gauge-label">Air Speed</div>
<div class="ap-gauge" @click.stop="cycleSpeed">
<span class="ap-gauge-val">{{ speedVal }}</span>
<span class="ap-gauge-unit">{{ speed.label }}</span>
</div>
</div>
<div class="ap-gauge-wrap">
<div class="ap-gauge-label">Altitude</div>
<div class="ap-gauge" @click.stop="cycleAltitude">
<span class="ap-gauge-val">{{ altVal }}</span>
<span class="ap-gauge-unit">{{ altUnit }}</span>
</div>
</div>
<div class="ap-gauge-wrap">
<div class="ap-gauge-label">Climb</div>
<div class="ap-gauge" @click.stop="cycleVertical">
<span class="ap-gauge-val">{{ climbVal }}</span>
<span class="ap-gauge-unit">{{ vertical.label }}</span>
</div>
</div>
</div>
<div class="ap-navball" v-html="navball" />
</div>
<AircraftAltitude
:history="altitudeHistory"
:current-altitude="currentAltitude"
:unit="altitude.label"
/>
</div>
</template>
+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>
+590 -171
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 showWind = ref(false)
const windSrc = ref('')
const OVERLAYS = [
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 activeOverlays = ref<Set<string>>(new Set(['rain']))
const overlayLayers: { [key: string]: any } = {}
const DEFAULT_OVERLAYS = ['aircraft', 'marine', 'satellites', 'rain'];
const activeOverlays = ref<Set<string>>(new Set(DEFAULT_OVERLAYS));
const overlayLayers: {[key: string]: any} = {};
let map: Map
let stationLayer: VectorLayer<VectorSource>
let radarInterval: ReturnType<typeof setInterval>
let airTraffic: AirTrafficLayer
let aisLayer: AISLayer
let range: RangeLayer
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 AUTO_CATEGORIES = ['sondes', 'aircraft', 'marine', 'satellites'] as const;
type AutoCategory = typeof AUTO_CATEGORIES[number];
interface AutoObject {
category: AutoCategory;
id: string;
score: number;
}
let map: Map;
let stationLayer: VectorLayer<VectorSource>;
let radarInterval: ReturnType<typeof setInterval>;
let autoInterval: ReturnType<typeof setInterval>;
let airTraffic: AirTrafficLayer;
let aisLayer: AISLayer;
let range: RangeLayer;
let sats: SatsLayer;
let sondes: SondesLayer;
let aurora: Aurora;
const autoMode = ref(false);
const autoCategory = ref<AutoCategory | null>(null);
const autoTarget = ref<any>(null);
let autoTargetKey: string | null = null;
// Per-category lookup so auto-mode logic never branches on category directly.
const AUTO_CONFIG: Record<AutoCategory, {
layer: () => any;
id: (target: any) => string;
position: (target: any) => [number, number] | null;
zoom: number;
}> = {
sondes: {
layer: () => sondes,
id: t => String(t.id),
position: t => t.latitude != null && t.longitude != null ? [t.longitude, t.latitude] : null,
zoom: 8,
},
aircraft: {
layer: () => airTraffic,
id: t => String(t.icao),
position: t => t.latitude != null && t.longitude != null ? [t.longitude, t.latitude] : null,
zoom: 10,
},
marine: {
layer: () => aisLayer,
id: t => String(t.mmsi),
position: t => t.lat != null && t.lon != null ? [t.lon, t.lat] : null,
zoom: 12,
},
satellites: {
layer: () => sats,
id: t => String(t.id),
position: t => t.latitude != null && t.longitude != null ? [t.longitude, t.latitude] : null,
zoom: 6,
},
};
function loadMapState() {
try {
const raw = localStorage.getItem(MAP_STATE_KEY);
if (!raw) return null;
const state = JSON.parse(raw);
if (
typeof state.latitude !== 'number' ||
typeof state.longitude !== 'number' ||
typeof state.zoom !== 'number'
) return null;
return state;
} catch {
return null;
}
}
function saveMapState() {
if (!map) return;
try {
const view = map.getView();
const center = view.getCenter();
if (!center) return;
const [longitude, latitude] = toLonLat(center);
const zoom = view.getZoom();
if (typeof zoom !== 'number') return;
localStorage.setItem(MAP_STATE_KEY, JSON.stringify({latitude, longitude, zoom}));
} catch {
// Ignore localStorage failures.
}
}
function loadLayers() {
try {
const raw = localStorage.getItem(MAP_LAYERS_KEY);
if (!raw) return new Set(DEFAULT_OVERLAYS);
const layers = JSON.parse(raw);
if (!Array.isArray(layers)) return new Set(DEFAULT_OVERLAYS);
const validLayers = OVERLAY_GROUPS.flatMap(group => group.items.map(item => item.id));
return new Set(
layers.filter((id: unknown): id is string =>
typeof id === 'string' && validLayers.includes(id),
),
);
} catch {
return new Set(DEFAULT_OVERLAYS);
}
}
function saveLayers() {
try {
localStorage.setItem(MAP_LAYERS_KEY, JSON.stringify([...activeOverlays.value]));
} catch {
// Ignore localStorage failures.
}
}
function loadAutoMode() {
try {
return localStorage.getItem(AUTO_MODE_KEY) === 'true';
} catch {
return false;
}
}
function saveAutoMode() {
try {
localStorage.setItem(AUTO_MODE_KEY, String(autoMode.value));
} catch {
// Ignore localStorage failures.
}
}
function buildWindSrc(lat: number, lon: number, zoom: number) {
return `https://embed.windy.com/embed2.html?lat=${lat.toFixed(4)}&lon=${lon.toFixed(4)}&detailLat=${lat.toFixed(4)}&detailLon=${lon.toFixed(4)}&zoom=${Math.round(zoom)}&level=surface&overlay=wind&product=ecmwf&menu=&message=&marker=&calendar=now&pressure=&type=map&location=coordinates&detail=&metricWind=kt&metricTemp=%C2%B0C&radarRange=-1`
return `https://embed.windy.com/embed2.html?lat=${lat.toFixed(4)}&lon=${lon.toFixed(4)}&detailLat=${lat.toFixed(4)}&detailLon=${lon.toFixed(4)}&zoom=${Math.round(zoom)}&level=surface&overlay=wind&product=ecmwf&menu=&message=&marker=&calendar=now&pressure=&type=map&location=coordinates&detail=&metricWind=kt&metricTemp=%C2%B0C&radarRange=-1`;
}
function syncWindSrc() {
const view = map.getView()
const center: any = toLonLat(view.getCenter()!)
windSrc.value = buildWindSrc(center[1], center[0], view.getZoom() ?? 8)
const view = map.getView();
const center: any = toLonLat(view.getCenter()!);
windSrc.value = buildWindSrc(center[1], center[0], view.getZoom() ?? 8);
}
function showWindOverlay() {
syncWindSrc()
showWind.value = true
map.getInteractions().forEach(i => i.setActive(false))
syncWindSrc();
showWind.value = true;
map.getInteractions().forEach(i => i.setActive(false));
}
function hideWindOverlay() {
showWind.value = false
map.getInteractions().forEach(i => i.setActive(true))
showWind.value = false;
map.getInteractions().forEach(i => i.setActive(true));
}
async function fetchRadarUrl(): Promise<string | null> {
try {
const res = await fetch('https://api.rainviewer.com/public/weather-maps.json')
const data = await res.json()
const past = data.radar?.past ?? []
const latest = past[past.length - 1]
if (!latest) return null
return `https://tilecache.rainviewer.com${latest.path}/256/{z}/{x}/{y}/2/1_1.png`
const res = await fetch('https://api.rainviewer.com/public/weather-maps.json');
const data = await res.json();
const past = data.radar?.past ?? [];
const latest = past[past.length - 1];
if (!latest) return null;
return `https://tilecache.rainviewer.com${latest.path}/256/{z}/{x}/{y}/2/1_1.png`;
} catch {
return null
return null;
}
}
@@ -86,139 +245,273 @@ function buildRainLayer(url: string): TileLayer<XYZ> {
url,
maxZoom: 7,
tileLoadFunction: (tile: any, src: string) => {
const img = tile.getImage()
img.onerror = () => tile.setState(3)
img.src = src
const img = tile.getImage();
img.onerror = () => tile.setState(3);
img.src = src;
},
}),
opacity: 0.2,
zIndex: 5,
})
});
}
function removeRainLayer() {
if (!overlayLayers['rain']) return;
map.removeLayer(overlayLayers['rain']);
delete overlayLayers['rain'];
}
async function refreshRain() {
const url = await fetchRadarUrl()
if (!url) return
const url = await fetchRadarUrl();
removeRainLayer();
if (!url || !activeOverlays.value.has('rain')) return;
if (overlayLayers['rain']) map.removeLayer(overlayLayers['rain'])
if (!activeOverlays.value.has('rain')) return
const layer = buildRainLayer(url)
overlayLayers['rain'] = layer
map.addLayer(layer)
const layer = buildRainLayer(url);
overlayLayers['rain'] = layer;
map.addLayer(layer);
}
function toggleAT() {
if (atActive.value) {
aisLayer.hide()
airTraffic.hide()
range.hide()
} else {
aisLayer.show()
airTraffic.show()
range.show()
}
atActive.value = !atActive.value
}
// One handler per overlay id — toggleOverlay/restoreOverlayState just drive these.
const OVERLAY_HANDLERS: Record<string, {show: () => void; hide: () => void}> = {
aircraft: {show: () => { airTraffic.show(); range.show(); }, hide: () => { airTraffic.hide(); range.hide(); }},
marine: {show: () => aisLayer.show(), hide: () => aisLayer.hide()},
satellites: {show: () => sats.show(), hide: () => sats.hide()},
sondes: {show: () => sondes.show(), hide: () => sondes.hide()},
aurora: {show: () => aurora.show(), hide: () => aurora.hide()},
wind: {show: showWindOverlay, hide: hideWindOverlay},
rain: {show: refreshRain, hide: removeRainLayer},
};
function toggleOverlay(id: string) {
if (id === 'wind') {
if (activeOverlays.value.has('wind')) {
activeOverlays.value.delete('wind')
hideWindOverlay()
const handler = OVERLAY_HANDLERS[id];
if (!handler) return;
if (activeOverlays.value.has(id)) {
activeOverlays.value.delete(id);
handler.hide();
} else {
activeOverlays.value.add('wind')
showWindOverlay()
}
return
activeOverlays.value.add(id);
handler.show();
}
if (id === 'rain') {
if (activeOverlays.value.has('rain')) {
activeOverlays.value.delete('rain')
if (overlayLayers['rain']) { map.removeLayer(overlayLayers['rain']); delete overlayLayers['rain'] }
} else {
activeOverlays.value.add('rain')
refreshRain()
saveLayers();
}
return
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)
}));
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}),
}),
}))
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 }),
view: new View({
center: fromLonLat([initialLongitude, initialLatitude]),
zoom: initialZoom,
maxZoom: 13,
}),
controls: [],
})
});
aisLayer = new AISLayer(map)
aisLayer.show()
airTraffic = new AirTrafficLayer(map)
airTraffic.show()
range = new RangeLayer(map)
range.show()
map.on('moveend', saveMapState);
refreshRain()
radarInterval = setInterval(refreshRain, 5 * 60 * 1000)
})
aisLayer = new AISLayer(map);
airTraffic = new AirTrafficLayer(map);
sats = new SatsLayer(map);
sondes = new SondesLayer(map);
aurora = new Aurora(map);
range = new RangeLayer(map);
restoreOverlayState();
radarInterval = setInterval(refreshRain, 5 * 60 * 1000);
if (loadAutoMode()) setTimeout(() => { if (!autoMode.value) startAutoMode(); }, 1_000);
});
onUnmounted(() => {
clearInterval(radarInterval)
airTraffic.hide()
})
stopAutoMode();
clearInterval(radarInterval);
if (map) saveMapState();
airTraffic?.hide();
aisLayer?.hide();
sats?.hide();
sondes?.hide();
aurora?.hide();
range?.hide();
});
watch(() => props.dark, dark => {
map.getLayers().setAt(0, buildBaseLayer(dark))
map.removeLayer(stationLayer)
const lat = (current.value.latitude as number) || 0
const lon = (current.value.longitude as number) || 0
stationLayer = buildStationLayer(lat, lon, dark)
map.addLayer(stationLayer)
})
if (!map) return;
map.getLayers().setAt(0, buildBaseLayer(dark));
map.removeLayer(stationLayer);
stationLayer = buildStationLayer(current.value.latitude || 0, current.value.longitude || 0, dark);
map.addLayer(stationLayer);
});
</script>
<style scoped lang="scss">
@@ -244,9 +537,29 @@ watch(() => props.dark, dark => {
z-index: 10;
}
.overlay-toggles {
position: absolute;
bottom: 16px;
left: 50%;
transform: translateX(-50%);
display: flex;
gap: 8px;
z-index: 100;
background: var(--surface);
border-radius: 99px;
padding: 6px 10px;
box-shadow: 0 2px 12px rgba(0,0,0,0.2);
backdrop-filter: blur(8px);
}
.overlay-group {
position: relative;
}
.overlay-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
padding: 6px 12px;
border-radius: 99px;
@@ -264,24 +577,91 @@ watch(() => props.dark, dark => {
color: #fff;
}
&:hover:not(.active) { background: var(--hover); }
&:hover:not(.active) {
background: var(--hover);
}
}
.overlay-toggles {
.group-btn {
position: relative;
&.has-active {
border-color: var(--accent);
}
}
.group-indicator {
font-size: 9px;
opacity: 0.7;
margin-left: 2px;
}
.overlay-submenu {
position: absolute;
bottom: 16px;
bottom: calc(100% + 8px);
left: 50%;
transform: translateX(-50%);
display: flex;
gap: 8px;
z-index: 100;
flex-direction: column;
gap: 4px;
min-width: 130px;
background: var(--surface);
border-radius: 99px;
padding: 6px 10px;
border: 1px solid var(--border);
border-radius: 12px;
padding: 6px;
box-shadow: 0 2px 12px rgba(0,0,0,0.2);
backdrop-filter: blur(8px);
}
.submenu-btn {
width: 100%;
justify-content: flex-start;
border: none;
border-radius: 8px;
padding: 8px 10px;
&.active {
background: var(--accent);
color: #fff;
}
}
.auto-btn {
&.active {
background: var(--accent);
border-color: var(--accent);
color: #fff;
}
}
.auto-status {
display: flex;
align-items: center;
gap: 5px;
font-size: 11px;
font-weight: 600;
white-space: nowrap;
padding: 0 4px;
}
.auto-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--accent);
animation: auto-pulse 1.5s infinite;
}
@keyframes auto-pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.35;
}
}
.layers-menu {
position: absolute;
bottom: 16px;
@@ -309,6 +689,7 @@ watch(() => props.dark, dark => {
display: flex;
flex-direction: column;
gap: 6px;
align-items: stretch;
background: var(--surface);
border-radius: 12px;
padding: 8px;
@@ -316,14 +697,43 @@ watch(() => props.dark, dark => {
backdrop-filter: blur(8px);
}
.desktop { display: flex; }
.mobile { display: none; }
.mobile-group {
position: relative;
}
.mobile-submenu {
display: flex;
flex-direction: column;
gap: 4px;
margin-top: 4px;
padding-left: 8px;
}
.mobile-submenu .overlay-btn {
width: 100%;
justify-content: flex-start;
}
.desktop {
display: flex;
}
.mobile {
display: none;
}
@media (max-width: 768px) {
.desktop { display: none; }
.mobile { display: flex; }
.overlay-toggles { bottom: 10px; }
.layers-menu { bottom: 10px; }
.desktop {
display: none;
}
.mobile {
display: flex;
}
.layers-menu {
bottom: 10px;
}
}
</style>
@@ -331,25 +741,26 @@ watch(() => props.dark, dark => {
<div class="map-wrap">
<div ref="mapEl" class="map-el" />
<iframe
v-if="showWind"
class="wind-iframe"
:src="windSrc"
frameborder="0"
allowfullscreen
/>
<iframe v-if="showWind" class="wind-iframe" :src="windSrc" frameborder="0" allowfullscreen/>
<div class="overlay-toggles desktop">
<button class="overlay-btn" :class="{ active: atActive }" @click="toggleAT">
Traffic
<div v-for="group in OVERLAY_GROUPS" :key="group.id" class="overlay-group">
<button class="overlay-btn group-btn" :class="{'has-active': group.items.some(item =>activeOverlays.has(item.id),),}" @click="toggleMenu(group.id)">
{{ group.icon }} {{ group.label }}
<span class="group-indicator">
{{ openMenu === group.id ? '▲' : '▼' }}
</span>
</button>
<button
v-for="o in OVERLAYS" :key="o.id"
class="overlay-btn"
:class="{ active: activeOverlays.has(o.id) }"
@click="toggleOverlay(o.id)"
>
{{ o.icon }} {{ o.label }}
<div v-if="openMenu === group.id" class="overlay-submenu">
<button v-for="item in group.items" :key="item.id" class="overlay-btn submenu-btn" :class="{active: activeOverlays.has(item.id)}" @click="toggleOverlay(item.id)">
{{ item.icon }} {{ item.label }}
</button>
</div>
</div>
<button class="overlay-btn auto-btn" :class="{active: autoMode}" @click="toggleAutoMode">
{{ autoMode ? '' : '' }} Auto
</button>
</div>
@@ -357,17 +768,25 @@ watch(() => props.dark, dark => {
<button class="layers-btn" @click="showOverlays = !showOverlays">
Layers
</button>
<div v-if="showOverlays" class="layers-dropdown">
<button class="overlay-btn" :class="{ active: atActive }" @click="toggleAT">
Traffic
<div v-for="group in OVERLAY_GROUPS" :key="group.id" class="mobile-group">
<button class="overlay-btn" :class="{active: group.items.some(item =>activeOverlays.has(item.id),),}" @click="toggleMenu(group.id)">
{{ group.icon }} {{ group.label }}
<span class="group-indicator">
{{ openMenu === group.id ? '▲' : '▼' }}
</span>
</button>
<button
v-for="o in OVERLAYS" :key="o.id"
class="overlay-btn"
:class="{ active: activeOverlays.has(o.id) }"
@click="toggleOverlay(o.id)"
>
{{ o.icon }} {{ o.label }}
<div v-if="openMenu === group.id" class="mobile-submenu">
<button v-for="item in group.items" :key="item.id" class="overlay-btn" :class="{active: activeOverlays.has(item.id)}" @click="toggleOverlay(item.id)">
{{ item.icon }} {{ item.label }}
</button>
</div>
</div>
<button class="overlay-btn auto-btn" :class="{active: autoMode}" @click="toggleAutoMode">
{{ autoMode ? ' Stop Auto' : ' Auto Mode' }}
</button>
</div>
</div>
+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>
+265 -178
View File
@@ -1,88 +1,119 @@
import { BASE } from '@/services/api.ts'
import { createApp, ref } from 'vue'
import Map from 'ol/Map'
import { Feature } from 'ol'
import Point from 'ol/geom/Point'
import LineString from 'ol/geom/LineString'
import { fromLonLat } from 'ol/proj'
import { Style, Icon, Stroke } from 'ol/style'
import { Vector as VectorLayer } from 'ol/layer'
import { Vector as VectorSource } from 'ol/source'
import { adjustedInterval } from '@ztimson/utils'
import AircraftPopup from '@/components/Aircraft.vue'
import { bringToFront } from './zindex'
import {BASE} from '@/services/api.ts';
import {greatCircleDist} from '@/services/units.ts';
import {h, render as vueRender} from 'vue';
import Map from 'ol/Map';
import {Feature} from 'ol';
import Point from 'ol/geom/Point';
import LineString from 'ol/geom/LineString';
import {fromLonLat} from 'ol/proj';
import {Style, Icon, Stroke} from 'ol/style';
import {Vector as VectorLayer} from 'ol/layer';
import {Vector as VectorSource} from 'ol/source';
import {adjustedInterval, sortByProp} from '@ztimson/utils';
import AircraftPopup from '@/components/Aircraft.vue';
import {bringToFront} from './zindex';
const API = BASE + '/api'
const API = BASE + '/api';
const AUTO_RANGE = 92.6;
function 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);
}
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]
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]
const r = (a - 10000) / 15000;
return [Math.round(135 * (1 - r)), Math.round(206 * (1 - r)), Math.round(250 * (1 - r) + 255 * r)];
}
interface PopupInstance {
planeRef: ReturnType<typeof ref>
posRef: ReturnType<typeof ref>
unmount: () => void
if (a < 40000) {
const r = (a - 25000) / 15000;
return [0, 0, Math.round(255 * (1 - r) + 139 * r)];
}
const r = Math.min((a - 40000) / 10000, 1);
return [Math.round(128 * r), 0, 139];
}
export class AirTrafficLayer {
private map: Map
private layer!: VectorLayer<VectorSource>
private traceLayers: Record<string, VectorLayer<VectorSource>> = {}
private traceSegs: Record<string, Feature[]> = {}
private popups: any = {}
private historyCache: any = {}
private data: any[] = []
private clickHandler: ((e: any) => void) | null = null
private refreshTimer: any = null
visible = false
private map: Map;
private layer!: VectorLayer<VectorSource>;
private traceLayers: Record<string, VectorLayer<VectorSource>> = {};
private traceSegs: Record<string, Feature[]> = {};
private popups: any = {};
private historyCache: any = {};
private data: any[] = [];
private clickHandler: ((e: any) => void) | null = null;
private refreshTimer: any = null;
visible = false;
constructor(map: Map) { this.map = map }
async show() {
if (this.visible) return
this.visible = true
this.layer = new VectorLayer({ source: new VectorSource(), zIndex: 100 })
this.map.addLayer(this.layer)
await this._fetch()
this._draw()
this._attachClick()
this.refreshTimer = adjustedInterval(async () => {
try {
await this._fetch()
this._draw()
this._refreshPopups()
} catch (err) {
console.error(err)
}
}, 1_000)
constructor(map: Map) {
this.map = map;
}
hide() {
if (!this.visible) return
this.visible = false
if (this.refreshTimer) { clearInterval(this.refreshTimer); this.refreshTimer = null }
if (this.clickHandler) { this.map.un('singleclick', this.clickHandler); this.clickHandler = null }
for (const icao of Object.keys(this.popups)) this._closePopup(icao)
this.map.removeLayer(this.layer)
Object.values(this.traceLayers).forEach(l => this.map.removeLayer(l))
this.traceLayers = {}
this.traceSegs = {}
private _attachClick() {
this.clickHandler = evt => {
const f = this.map.forEachFeatureAtPixel(evt.pixel, f => f.get('planeData') ? f : null);
if (!f) return;
const plane = f.get('planeData');
if (this.popups[plane.icao]) {
this.closePopup(plane.icao);
return;
}
this.openPopup(plane);
};
this.map.on('singleclick', this.clickHandler);
}
private _draw() {
const source = this.layer.getSource()!;
source.clear();
for (const plane of this.data) {
if (plane.latitude == null || plane.longitude == null) continue;
const coord = fromLonLat([plane.longitude, plane.latitude]);
const f = new Feature({geometry: new Point(coord)});
f.set('icao', plane.icao);
f.set('planeData', plane);
f.setStyle(new Style({
image: new Icon({
src: `data:image/svg+xml,${encodeURIComponent(plane.icon)}`,
scale: 1.25,
rotation: (plane.heading ?? 0) * (Math.PI / 180),
anchor: [0.5, 0.5],
}),
}));
source.addFeature(f);
}
}
private async _fetch() {
const j = await fetch(`${API}/adsb`).then(r => r.ok ? r.json() : []);
this.data = (j || []).map((a: any) => ({
...a,
icao: a.hex,
@@ -92,133 +123,189 @@ export class AirTrafficLayer {
speed: a.gs,
climb: a.baro_rate ?? a.geom_rate ?? 0,
name: a.flight?.trim(),
}))
}
private _draw() {
const source = this.layer.getSource()!
source.clear()
for (const plane of this.data) {
if (plane.latitude == null) continue
const coord = fromLonLat([plane.longitude, plane.latitude])
const f = new Feature({ geometry: new Point(coord) })
f.set('icao', plane.icao)
f.set('planeData', plane)
f.setStyle(new Style({
image: new Icon({
src: `data:image/svg+xml,${encodeURIComponent(plane.icon)}`,
scale: 1.25,
rotation: (plane.heading ?? 0) * (Math.PI / 180),
anchor: [0.5, 0.5],
}),
}))
source.addFeature(f)
}
}));
}
private async _fetchTrace(plane: any) {
const icao = plane.icao
if (!this.historyCache[icao]) {
const j = await fetch(`${API}/adsb/${icao}`).then(r => r.json())
this.historyCache[icao] = j.history || []
}
const history = [...this.historyCache[icao]]
const cur = { latitude: plane.latitude, longitude: plane.longitude, altitude: plane.alt_baro ?? plane.alt_geom ?? 0 }
const last = history[history.length - 1]
if (!last || last.latitude !== cur.latitude || last.longitude !== cur.longitude) history.push(cur)
if (history.length < 2) return
const icao = plane.icao;
let traceLayer = this.traceLayers[icao]
let vs: VectorSource
if (!this.historyCache[icao]) {
const j = await fetch(`${API}/adsb/${icao}`).then(r => r.json());
this.historyCache[icao] = j.history || [];
}
const history = this.historyCache[icao];
const cur = {
ts: Math.floor(Date.now() / 1000),
latitude: plane.latitude,
longitude: plane.longitude,
altitude: plane.alt_baro ?? plane.alt_geom ?? 0,
live: true,
};
const last = history[history.length - 1];
if (!last || last.latitude !== cur.latitude || last.longitude !== cur.longitude) history.push(cur);
const traceHistory = history;
if (traceHistory.length < 2) {
const popup = this.popups[icao];
if (popup) popup.update(plane, history);
return;
}
let traceLayer = this.traceLayers[icao];
let vs: VectorSource;
if (!traceLayer) {
vs = new VectorSource()
traceLayer = new VectorLayer({ source: vs, zIndex: 99 })
this.map.addLayer(traceLayer)
this.traceLayers[icao] = traceLayer
this.traceSegs[icao] = []
vs = new VectorSource();
traceLayer = new VectorLayer({source: vs, zIndex: 99});
this.map.addLayer(traceLayer);
this.traceLayers[icao] = traceLayer;
this.traceSegs[icao] = [];
} else {
vs = traceLayer.getSource()!
;(this.traceSegs[icao] || []).forEach(f => vs.removeFeature(f))
vs = traceLayer.getSource()!;
}
const segs: Feature[] = []
for (let i = 0; i < history.length - 1; i++) {
const s = history[i], e = history[i + 1]
const sc = getAltColor(s.altitude || 0)
const ec = getAltColor(e.altitude || 0)
const avg = sc.map((v, idx) => Math.round((v + (ec[idx] as any)) / 2))
const f = new Feature(new LineString([fromLonLat([s.longitude, s.latitude]), fromLonLat([e.longitude, e.latitude])]))
f.setStyle(new Style({ stroke: new Stroke({ color: `rgba(${avg.join(',')},0.8)`, width: 3 }) }))
vs.addFeature(f)
segs.push(f)
}
this.traceSegs[icao] = segs
const segs: any = this.traceSegs[icao];
for (let i = segs.length; i < traceHistory.length - 1; i++) {
const s = traceHistory[i], e = traceHistory[i + 1];
const sc = getAltColor(s.altitude || 0), ec: any = getAltColor(e.altitude || 0);
const avg = sc.map((v, idx) => Math.round((v + ec[idx]) / 2));
const f = new Feature(new LineString([
fromLonLat([s.longitude, s.latitude]),
fromLonLat([e.longitude, e.latitude]),
]));
f.setStyle(new Style({
stroke: new Stroke({
color: `rgba(${avg.join(',')},0.8)`,
width: 3,
}),
}));
vs.addFeature(f);
segs.push(f);
}
private _calcPopupPos(plane: any): { x: number; y: number } {
const pixel: any = this.map.getPixelFromCoordinate(fromLonLat([plane.longitude, plane.latitude]))
if (!pixel) return { x: 10, y: 60 }
const rect = (this.map.getTargetElement() as HTMLElement).getBoundingClientRect()
return { x: rect.left + pixel[0] + 16, y: rect.top + pixel[1] - 16 }
}
private _openPopup(plane: any) {
const icao = plane.icao
if (this.popups[icao]) return
const planeRef = ref(plane)
const posRef = ref(this._calcPopupPos(plane))
const container = document.createElement('div')
document.body.appendChild(container)
const mobile = window.innerWidth <= 768;
const app = createApp(AircraftPopup, {
plane: planeRef.value,
position: mobile ? {x: window.innerWidth / 2 - 180, y: window.innerHeight - 540 - 16} : {x: window.innerWidth - 360 - 16, y: 16},
onClose: () => this._closePopup(icao),
onBringToFront: () => bringToFront(container),
})
app.mount(container)
this.popups[icao] = {
planeRef,
posRef,
unmount: () => { app.unmount(); container.remove() },
}
this._fetchTrace(plane)
}
private _closePopup(icao: string) {
const popup = this.popups[icao]
if (popup) { popup.unmount(); delete this.popups[icao] }
const segs = this.traceSegs[icao]
const layer = this.traceLayers[icao]
if (segs && layer) segs.forEach(f => layer.getSource()!.removeFeature(f))
if (layer) { this.map.removeLayer(layer); delete this.traceLayers[icao] }
delete this.traceSegs[icao]
const popup = this.popups[icao];
if (popup) popup.update(plane, history);
}
private _refreshPopups() {
for (const icao of Object.keys(this.popups)) {
const plane = this.data.find(p => p.icao === icao)
if (!plane) { this._closePopup(icao); continue }
this.popups[icao].planeRef.value = plane
this._fetchTrace(plane)
const plane = this.data.find(p => p.icao === icao);
if (!plane) {
this.closePopup(icao);
continue;
}
this._fetchTrace(plane);
}
}
private _attachClick() {
this.clickHandler = (evt) => {
const f = this.map.forEachFeatureAtPixel(evt.pixel, f => f.get('planeData') ? f : null)
if (!f) return
const plane = f.get('planeData')
if (this.popups[plane.icao]) { this._closePopup(plane.icao); return }
this._openPopup(plane)
hide() {
if(!this.visible) return;
this.visible = false;
if(this.refreshTimer) {
clearInterval(this.refreshTimer);
this.refreshTimer = null;
}
this.map.on('singleclick', this.clickHandler)
if(this.clickHandler) {
this.map.un('singleclick', this.clickHandler);
this.clickHandler = null;
}
for(const icao of Object.keys(this.popups)) this.closePopup(icao);
if(this.layer) this.map.removeLayer(this.layer);
Object.values(this.traceLayers).forEach(l => this.map.removeLayer(l));
this.traceLayers = {};
this.traceSegs = {};
}
async show() {
if(this.visible) return;
this.visible = true;
this.layer = new VectorLayer({source: new VectorSource(), zIndex: 100});
this.map.addLayer(this.layer);
await this._fetch();
this._draw();
this._attachClick();
this.refreshTimer = adjustedInterval(async () => {
try {
await this._fetch();
this._draw();
this._refreshPopups();
} catch (err) {
console.error(err);
}
}, 1_000);
}
getById(icao: string): any | null {
return this.data.find(p => String(p.icao) === String(icao)) ?? null;
}
getCandidates(latitude: number, longitude: number, limit = Infinity): any[] {
function score(plane: any): number {
const ratio = Math.min(greatCircleDist(latitude, longitude, plane.latitude, plane.longitude) / AUTO_RANGE, 1);
return isPriorityAircraft(plane) ? 2 - ratio : 1 - ratio;
}
return this.data.map(plane => ({...plane, score: score(plane)})).toSorted(sortByProp('score', true));
}
openPopup(plane: any) {
const icao = String(plane.icao);
if(this.popups[icao]) return;
const container = document.createElement('div');
document.body.appendChild(container);
const mobile = window.innerWidth <= 768;
const makeProps = (p: any, history: any[] = []) => ({
plane: p,
history,
position: mobile
? {x: window.innerWidth / 2 - 180, y: window.innerHeight - 540 - 16}
: {x: 16, y: 16},
onClose: () => this.closePopup(icao),
onBringToFront: () => bringToFront(container),
});
vueRender(h(AircraftPopup, makeProps(plane, this.historyCache[icao] || [])), container);
this.popups[icao] = {
update: (p: any, history: any[] = []) => vueRender(h(AircraftPopup, makeProps(p, history)), container),
unmount: () => {
vueRender(null, container);
container.remove();
},
};
this._fetchTrace(plane);
}
closePopup(icao: string) {
const popup = this.popups[icao];
if(popup) {
popup.unmount();
delete this.popups[icao];
}
const segs = this.traceSegs[icao], layer = this.traceLayers[icao];
if(segs && layer) segs.forEach(f => layer.getSource()!.removeFeature(f));
if(layer) {
this.map.removeLayer(layer);
delete this.traceLayers[icao];
}
delete this.traceSegs[icao];
}
}
+171 -110
View File
@@ -1,17 +1,19 @@
import { BASE } from '@/services/api.ts'
import { createApp, ref } from 'vue'
import Map from 'ol/Map'
import { Feature } from 'ol'
import Point from 'ol/geom/Point'
import { fromLonLat } from 'ol/proj'
import { Style, Icon } from 'ol/style'
import { Vector as VectorLayer } from 'ol/layer'
import { Vector as VectorSource } from 'ol/source'
import { adjustedInterval } from '@ztimson/utils'
import ShipPopup from '@/components/Ship.vue'
import { bringToFront } from './zindex'
import {BASE} from '@/services/api.ts';
import {greatCircleDist} from '@/services/units.ts';
import {h, render as vueRender} from 'vue';
import Map from 'ol/Map';
import {Feature} from 'ol';
import Point from 'ol/geom/Point';
import {fromLonLat} from 'ol/proj';
import {Style, Icon} from 'ol/style';
import {Vector as VectorLayer} from 'ol/layer';
import {Vector as VectorSource} from 'ol/source';
import {adjustedInterval, sortByProp} from '@ztimson/utils';
import ShipPopup from '@/components/Ship.vue';
import {bringToFront} from './zindex';
const API = BASE + '/api'
const API = BASE + '/api';
const AUTO_RANGE = 100;
const SHIP_COLORS: Record<string, string> = {
'Base Station': '#ffffff',
@@ -22,10 +24,29 @@ const SHIP_COLORS: Record<string, string> = {
'Class B/CS': '#8450ea',
'Sart/Epirb/MOB': '#00aaff',
'Unknown': '#fad106',
};
function isPriorityBoat(boat: any): boolean {
if (boat.military === true || boat.is_military === true || boat.sar === true || boat.is_sar === true) return true;
const text = [
boat.type,
boat.vesselType,
boat.category,
boat.name,
boat.shipname,
boat.operator,
boat.owner,
boat.callsign,
boat.description,
].filter(Boolean).join(' ').toLowerCase();
return /\b(military|mil|sar|search and rescue|rescue|coast guard|navy|army|air force|airforce|rcaf)\b/.test(text);
}
function buildBoatIcon(boat: any): string {
const color = SHIP_COLORS[boat.type] || SHIP_COLORS['Unknown']
const color = SHIP_COLORS[boat.type] || SHIP_COLORS['Unknown'];
if (['Base Station', 'AtoN'].includes(boat.type)) {
return `data:image/svg+xml,${encodeURIComponent(`
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
@@ -35,8 +56,9 @@ function buildBoatIcon(boat: any): string {
<line x1="11" y1="12" x2="21" y2="12" stroke="#000" stroke-width="1.8"/>
<path d="M9,18 Q16,26 23,18" fill="none" stroke="#000" stroke-width="1.5"/>
</svg>
`)}`
`)}`;
}
return `data:image/svg+xml,${encodeURIComponent(`
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="32" viewBox="0 0 24 32">
<polygon points="12,0 24,30 12,24 0,30" fill="${color}" stroke="#000" stroke-width="1.5"/>
@@ -45,129 +67,168 @@ function buildBoatIcon(boat: any): string {
<line x1="8" y1="14" x2="16" y2="14" stroke="#000" stroke-width="1.5"/>
<path d="M7,17 Q12,24 17,17" fill="none" stroke="#000" stroke-width="1.3"/>
</svg>
`)}`
`)}`;
}
export class AISLayer {
private map: Map
private layer!: VectorLayer<VectorSource>
private popups: any = {}
private data: any[] = []
private clickHandler: ((e: any) => void) | null = null
private refreshTimer: any = null
visible = false
private map: Map;
private layer!: VectorLayer<VectorSource>;
private popups: any = {};
private data: any[] = [];
private clickHandler: ((e: any) => void) | null = null;
private refreshTimer: any = null;
visible = false;
constructor(map: Map) { this.map = map }
async show() {
if (this.visible) return
this.visible = true
this.layer = new VectorLayer({ source: new VectorSource(), zIndex: 100 })
this.map.addLayer(this.layer)
await this._fetch()
this._draw()
this._attachClick()
this.refreshTimer = adjustedInterval(async () => {
try {
await this._fetch()
this._draw()
this._refreshPopups()
} catch (err) {
console.error(err);
}
}, 5_000)
constructor(map: Map) {
this.map = map;
}
hide() {
if (!this.visible) return
this.visible = false
if (this.refreshTimer) { clearInterval(this.refreshTimer); this.refreshTimer = null }
if (this.clickHandler) { this.map.un('singleclick', this.clickHandler); this.clickHandler = null }
for (const mmsi of Object.keys(this.popups)) this._closePopup(mmsi)
this.map.removeLayer(this.layer)
private _attachClick() {
this.clickHandler = evt => {
const f = this.map.forEachFeatureAtPixel(evt.pixel, f => f.get('boatData') ? f : null);
if (!f) return;
const boat = f.get('boatData');
if (this.popups[boat.mmsi]) {
this.closePopup(boat.mmsi);
return;
}
private async _fetch() {
this.data = await fetch(`${API}/ais`).then(r => r.json()) || []
this.openPopup(boat);
};
this.map.on('singleclick', this.clickHandler);
}
private _draw() {
const source = this.layer.getSource()!
source.clear()
const source = this.layer.getSource()!;
source.clear();
for (const boat of this.data) {
if (boat.lat == null || boat.lon == null) continue
const coord = fromLonLat([boat.lon, boat.lat])
const f = new Feature({ geometry: new Point(coord) })
f.set('mmsi', boat.mmsi)
f.set('boatData', boat)
if (boat.lat == null || boat.lon == null) continue;
const coord = fromLonLat([boat.lon, boat.lat]);
const f = new Feature({geometry: new Point(coord)});
f.set('mmsi', boat.mmsi);
f.set('boatData', boat);
f.setStyle(new Style({
image: new Icon({
src: buildBoatIcon(boat),
scale: 0.8,
rotation: (boat.heading ?? boat.course ?? 0) * (Math.PI / 180),
rotation: (boat.heading ?? boat.bearing ?? boat.cog ?? boat.course ?? 0) * (Math.PI / 180),
anchor: [0.5, 0.5],
}),
}))
source.addFeature(f)
}));
source.addFeature(f);
}
}
private _calcPopupPos(boat: any): { x: number; y: number } {
const pixel: any = this.map.getPixelFromCoordinate(fromLonLat([boat.lon, boat.lat]))
if (!pixel) return { x: 10, y: 60 }
const rect = (this.map.getTargetElement() as HTMLElement).getBoundingClientRect()
return { x: rect.left + pixel[0] + 16, y: rect.top + pixel[1] - 16 }
}
private _openPopup(boat: any) {
const mmsi = String(boat.mmsi)
if (this.popups[mmsi]) return
const boatRef = ref(boat)
const posRef = ref(this._calcPopupPos(boat))
const container = document.createElement('div')
document.body.appendChild(container)
const mobile = window.innerWidth <= 768;
const app = createApp(ShipPopup, {
boat: boatRef.value,
position: mobile ? {x: window.innerWidth / 2 - 180, y: window.innerHeight - 540 - 16} : {x: window.innerWidth - 360 - 16, y: 16},
onClose: () => this._closePopup(mmsi),
onBringToFront: () => bringToFront(container),
})
app.mount(container)
this.popups[mmsi] = {
boatRef,
posRef,
unmount: () => { app.unmount(); container.remove() },
}
}
private _closePopup(mmsi: string) {
const popup = this.popups[mmsi]
if (popup) { popup.unmount(); delete this.popups[mmsi] }
private async _fetch() {
this.data = await fetch(`${API}/ais`).then(r => r.json()) || [];
}
private _refreshPopups() {
for (const mmsi of Object.keys(this.popups)) {
const boat = this.data.find(b => String(b.mmsi) === mmsi)
if (!boat) { this._closePopup(mmsi); continue }
this.popups[mmsi].boatRef.value = boat
const boat = this.data.find(b => String(b.mmsi) === mmsi);
if (!boat) {
this.closePopup(mmsi);
continue;
}
this.popups[mmsi].update(boat);
}
}
private _attachClick() {
this.clickHandler = (evt) => {
const f = this.map.forEachFeatureAtPixel(evt.pixel, f => f.get('boatData') ? f : null)
if (!f) return
const boat = f.get('boatData')
const mmsi = String(boat.mmsi)
if (this.popups[mmsi]) { this._closePopup(mmsi); return }
this._openPopup(boat)
hide() {
if (!this.visible) return;
this.visible = false;
if (this.refreshTimer) {
clearInterval(this.refreshTimer);
this.refreshTimer = null;
}
if (this.clickHandler) {
this.map.un('singleclick', this.clickHandler);
this.clickHandler = null;
}
for (const mmsi of Object.keys(this.popups)) this.closePopup(mmsi);
if (this.layer) this.map.removeLayer(this.layer);
}
async show() {
if (this.visible) return;
this.visible = true;
this.layer = new VectorLayer({source: new VectorSource(), zIndex: 100});
this.map.addLayer(this.layer);
await this._fetch();
this._draw();
this._attachClick();
this.refreshTimer = adjustedInterval(async () => {
try {
await this._fetch();
this._draw();
this._refreshPopups();
} catch (err) {
console.error(err);
}
}, 5_000);
}
getById(mmsi: string): any | null {
return this.data.find(b => String(b.mmsi) === String(mmsi)) ?? null;
}
getCandidates(latitude: number, longitude: number, limit = Infinity): any[] {
function score(boat: any): number {
const ratio = Math.min(greatCircleDist(latitude, longitude, boat.lat, boat.lon) / AUTO_RANGE, 1);
return isPriorityBoat(boat) ? 2 - ratio : 1 - ratio;
}
return this.data.filter(b => b.lat != null && b.lon != null)
.map(boat => ({...boat, score: score(boat)}))
.toSorted(sortByProp('score', true));
}
openPopup(boat: any) {
const mmsi = String(boat.mmsi);
if (this.popups[mmsi]) return;
const container = document.createElement('div');
document.body.appendChild(container);
const mobile = window.innerWidth <= 768;
const makeProps = (b: any) => ({
boat: b,
position: mobile
? {x: window.innerWidth / 2 - 180, y: window.innerHeight - 540 - 16}
: {x: 16, y: 16},
onClose: () => this.closePopup(mmsi),
onBringToFront: () => bringToFront(container),
});
vueRender(h(ShipPopup, makeProps(boat)), container);
this.popups[mmsi] = {
update: (b: any) => vueRender(h(ShipPopup, makeProps(b)), container),
unmount: () => {
vueRender(null, container);
container.remove();
},
};
}
closePopup(mmsi: string) {
const popup = this.popups[mmsi];
if (popup) {
popup.unmount();
delete this.popups[mmsi];
}
this.map.on('singleclick', this.clickHandler)
}
}
+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"
}
}
+302 -162
View File
@@ -6,37 +6,96 @@ import Database from 'better-sqlite3';
import { fromCsv } from '@ztimson/utils';
import { getIcon } from './adsb-shapes.mjs';
import * as cheerio from 'cheerio';
import sharp from 'sharp';
const DIR = dirname(fileURLToPath(import.meta.url));
const DATA = resolve(DIR, '../data');
const IMAGES_DIR = resolve(DATA, 'aircraft');
const DB_PATH = resolve(DATA, 'aircraft.db');
const CSV_CACHE = resolve(DATA, 'aircraft_db.csv');
const AIRLINES_CACHE = resolve(DATA, 'airlines.csv');
const MAX_HISTORY = 500;
const HISTORY_TTL = 1000 * 60 * 60; // 1 hour
const ADSB_TTL = 1000;
const CSV_URL = 'https://s3.opensky-network.org/data-samples/metadata/aircraft-database-complete-2024-06.csv';
const MIL_RANGES_URL = ':8080/db-3.14.1708/ranges.js';
const MILITARY_OPERATORS = ['air force', 'army', 'navy', 'marine', 'coast guard', 'military', 'defence', 'defense', 'luftwaffe', 'RAF', 'USAF', 'USN', 'USMC'];
const AIRLINES_URL = 'https://raw.githubusercontent.com/jpatokal/openflights/master/data/airlines.dat';
const CARGO_OPERATORS = ['fedex', 'ups', 'dhl', 'cargo', 'freight', 'logistic', 'atlas air', 'kalitta', 'air freight'];
const PASSENGER_OPERATORS = ['airlines', 'airways', 'air ', 'jet', 'jetblue', 'easyjet', 'ryanair', 'delta', 'united', 'american', 'avianca', 'southwest', 'lufthansa', 'emirates', 'british'];
const PASSENGER_OPERATORS = ['airlines', 'airways', 'jetblue', 'easyjet', 'ryanair', 'delta', 'united', 'american', 'avianca', 'southwest', 'lufthansa', 'emirates', 'british'];
const MILITARY_CLASSES = ['H1M', 'H2M', 'L1M', 'L2M', 'L4M', 'A0'];
const MILITARY_KEYWORDS = [
'air force', 'airforce', 'navy', 'army', 'marine corps', 'coast guard',
'national guard', 'military', 'defence', 'defense', 'police', 'luftwaffe',
'aeronautica militare', 'armee de l air', 'raf', 'usaf'
];
let db;
let passengerOperators = PASSENGER_OPERATORS;
let adsbCache = null;
let adsbCacheTs = 0;
const noRecord = [];
const history = new Map();
const milRanges = [];
let db;
setInterval(() => {
const cutoff = Date.now() - HISTORY_TTL;
for (const [key, trail] of history) {
const last = trail.at(-1);
if (!last || last.ts < cutoff) history.delete(key);
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
};
}
}, 1000 * 60 * 5);
function fetchWithTimeout(url, ms = 5000) {
const controller = new AbortController();
@@ -44,35 +103,11 @@ function fetchWithTimeout(url, ms = 5000) {
return fetch(url, { signal: controller.signal }).finally(() => clearTimeout(id));
}
async function syncMilitaryRanges() {
const { ADSB_URL } = cfg();
try {
const res = await fetch(ADSB_URL + MIL_RANGES_URL);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const { military } = await res.json();
db.exec('DELETE FROM military_ranges');
const insert = db.prepare('INSERT INTO military_ranges (start, end) VALUES (?, ?)');
const insertMany = db.transaction(ranges => {
for (const [s, e] of ranges) insert.run(s.toUpperCase(), e.toUpperCase());
});
insertMany(military);
milRanges.length = 0;
milRanges.push(...military.map(([s, e]) => [s.toUpperCase(), e.toUpperCase()]));
} catch (e) {
const rows = db.prepare('SELECT start, end FROM military_ranges').all();
milRanges.length = 0;
milRanges.push(...rows.map(r => [r.start, r.end]));
}
}
export async function initAircraftDb() {
if (!fs.existsSync(DATA)) fs.mkdirSync(DATA, { recursive: true });
if (!fs.existsSync(IMAGES_DIR)) fs.mkdirSync(IMAGES_DIR, { recursive: true });
const missing = !fs.existsSync(DB_PATH);
if (missing) console.log(`✈️ Building database`);
db = new Database(DB_PATH);
db.exec(`
CREATE TABLE IF NOT EXISTS aircraft (
@@ -92,26 +127,36 @@ export async function initAircraftDb() {
serialNumber TEXT,
aircraft TEXT
);
CREATE TABLE IF NOT EXISTS military_ranges (
start TEXT NOT NULL,
end TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS military_ranges (start TEXT NOT NULL, end TEXT NOT NULL);
`);
await syncMilitaryRanges();
await syncAirlines();
await syncMilitaryIcao();
if(missing) {
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
@icao,
@built,
@categoryDescription,
@country,
@engines,
@class,
@manufacturer,
@model,
@modes,
@operator,
@operatorCallsign,
@owner,
@registration,
@serialNumber,
@aircraft
)
`);
@@ -134,128 +179,116 @@ export async function initAircraftDb() {
owner: row.owner || null,
registration: row.registration || null,
serialNumber: row.serialNumber || null,
aircraft: row.typecode || null,
aircraft: row.typecode || null
})));
fs.unlinkSync(CSV_CACHE);
console.log(`✈️ Aircraft imported: ${csv.length}`);
console.log(`✈️ Registrations imported: ${csv.length}`);
} else {
const count = db.prepare('SELECT COUNT(*) as c FROM aircraft').get();
console.log(`✈️ Aircraft loaded: ${count.c}`);
console.log(`✈️ Registrations loaded: ${count.c}`);
}
}
function isIcaoInMilitaryRange(icao) {
function isMilitaryIcao(icao) {
if (!icao) return false;
const hex = icao.toUpperCase();
return milRanges.some(([s, e]) => hex >= s && hex <= e);
}
function wordMatch(text, keyword) {
if (!text) return false;
const escaped = keyword.replace(/[.*+?^${}()|[\]\\]/gi, '\\$&');
return new RegExp(`(?<![\\w])${escaped}(?![\\w])`, 'i').test(text);
async function syncAirlines() {
try {
if (!fs.existsSync(AIRLINES_CACHE)) {
console.log('✈️ Downloading airline database');
const res = await fetchWithTimeout(AIRLINES_URL, 10000);
if(!res.ok) throw new Error(`HTTP ${res.status}`);
fs.writeFileSync(AIRLINES_CACHE, await res.text());
}
const text = fs.readFileSync(AIRLINES_CACHE, 'utf8');
const airlines = text.split('\n').slice(1).map(row =>
row.split(',')[1]?.slice(1, -1).toLowerCase()).filter(Boolean).flat();
passengerOperators = airlines.sort((a, b) => b.length - a.length);
console.log(`✈️ Airlines loaded: ${airlines.length}`);
} catch (e) {
console.warn(`⚠️ Could not load airline database: ${e.message}`);
}
}
async function syncMilitaryIcao() {
const { ADSB_URL } = cfg();
try {
const page = await fetch(ADSB_URL + ':8080').then(resp => resp.text());
const db = /databaseFolder = "(db-.+?)"/.exec(page);
const res = await fetch(ADSB_URL + `:8080/${db[1]}/ranges.js`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const { military } = await res.json();
db.exec('DELETE FROM military_ranges');
const insert = db.prepare('INSERT INTO military_ranges (start, end) VALUES (?, ?)');
const insertMany = db.transaction(ranges => {
for (const [s, e] of ranges) insert.run(s.toUpperCase(), e.toUpperCase());
});
insertMany(military);
milRanges.length = 0;
milRanges.push(...military.map(([s, e]) => [s.toUpperCase(), e.toUpperCase()]));
} catch (e) {
const rows = db.prepare('SELECT start, end FROM military_ranges').all();
milRanges.length = 0;
milRanges.push(...rows.map(r => [r.start, r.end]));
}
}
export function classifyAircraft(row) {
if (!row) return 'unknown';
const matchesAny = (fields, list) =>
fields.some(f => list.some(k => wordMatch(f, k)));
const matchesAny = (fields, list) => fields.some(f => {
const value = normalizeName(f);
return value && list.some(k => {
const kw = normalizeName(k);
return kw && new RegExp('\\b' + kw.replace(/[.*+?^${}()|[\\]\\]/g, '\\$&') + '\\b', 'i').test(value);
});
});
const operatorFields = [row.operator, row.operatorCallsign];
const ownerFields = [row.owner];
const allFields = [...operatorFields, ...ownerFields, row.categoryDescription];
const operators = [row.operator, row.operatorCallsign, row.owner];
if (isMilitaryIcao(row.icao) || MILITARY_CLASSES.includes(row.class) || matchesAny(operators, MILITARY_KEYWORDS))
return 'military';
if (isIcaoInMilitaryRange(row.icao) || MILITARY_CLASSES.includes(row.class) || matchesAny(allFields, MILITARY_OPERATORS)) return 'military';
if (row.categoryDescription?.toLowerCase().includes('cargo') || matchesAny(operatorFields, CARGO_OPERATORS)) return 'cargo';
if (matchesAny(operatorFields, PASSENGER_OPERATORS)) return 'passenger';
if (row.categoryDescription?.toLowerCase().includes('cargo') || matchesAny(operators, CARGO_OPERATORS)) return 'cargo';
if (matchesAny(operators, passengerOperators)) return 'passenger';
if (row.owner && !row.operator) return 'private';
return 'unknown';
}
async function fetchHexDb(icao) {
const resp = await fetchWithTimeout(`https://hexdb.io/api/v1/aircraft/${icao}`);
if (!resp.ok) return null;
const found = await resp.json();
return {
registration: found.Registration || null,
manufacturer: found.Manufacturer || null,
aircraft: found.ICAOTypeCode || null,
model: found.Type || null,
operator: found.RegisteredOwners || null,
};
function normalizeName(value) {
return String(value || '')
.normalize('NFKD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase()
.replace(/&/g, ' and ')
.replace(/[^a-z0-9]+/g, ' ')
.trim()
.replace(/\s+/g, ' ');
}
async function scrapeHexDatabase(icao) {
const url = `https://hexdatabase.com/h/${icao}`;
const resp = await fetch(url);
if (!resp.ok) return null;
const html = await resp.text();
const tableMatch = html.match(/<table[\s\S]*?<\/table>/i);
if (!tableMatch) return null;
const $ = cheerio.load(tableMatch[0]);
const row = $('tr').filter((_, el) => $(el).text().toLowerCase().includes(icao.toLowerCase())).first();
if (!row.length) return null;
const cells = row.find('td');
return {
registration: $(cells[1]).text().trim() || null,
aircraft: $(cells[2]).text().trim() || null,
operator: $(cells[3]).text().trim() || null,
serialNumber: $(cells[5]).text().trim() || null,
};
}
function backfillFromSimilar(aircraft) {
if (!aircraft) return {};
const similar = db.prepare(`
SELECT manufacturer, model, engines, categoryDescription, class
FROM aircraft
WHERE aircraft = ?
AND (manufacturer IS NOT NULL OR model IS NOT NULL OR engines IS NOT NULL)
LIMIT 1
`).get(aircraft);
if (!similar) return {};
return {
manufacturer: similar.manufacturer || null,
model: similar.model || null,
engines: similar.engines || null,
categoryDescription: similar.categoryDescription || null,
class: similar.class || null,
};
}
export async function enrichAircraft(a) {
export async function enrich(a) {
if (!a.hex) return a;
const icao = a.hex.toUpperCase();
// 1. Check own DB first
const row = db.prepare('SELECT * FROM aircraft WHERE icao = ?').get(icao);
if (row?.aircraft) return { ...a, ...row, type: classifyAircraft(row) };
// 2. Race the two external sources
if (noRecord.includes(icao)) return { icao, ...a, type: 'unknown' };
const hexDbPromise = fetchHexDb(icao);
const scrapePromise = scrapeHexDatabase(icao);
const hexDbPromise = fetchRegistration2(icao);
const scrapePromise = fetchRegistration1(icao);
let found = await hexDbPromise.catch(() => {});
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) {
db.prepare(`
INSERT INTO aircraft (icao, registration, manufacturer, aircraft, model, operator, country, serialNumber, engines, categoryDescription, class)
@@ -282,17 +315,19 @@ export async function enrichAircraft(a) {
serialNumber: merged.serialNumber || null,
engines: merged.engines || null,
categoryDescription: merged.categoryDescription || null,
class: merged.class || null,
class: merged.class || null
});
}
return {icao, ...a, ...merged, type: classifyAircraft(merged) }
return { icao, ...a, ...merged, type: classifyAircraft(merged) };
}
export async function getADSB() {
export async function get() {
if(adsbCache && Date.now() - adsbCacheTs < ADSB_TTL) return adsbCache;
const { ADSB_URL } = cfg();
if(!ADSB_URL) return [];
const r = await fetchWithTimeout(`${ADSB_URL}:8080/data/aircraft.json`);
const j = await r.json();
const aircraft = j.aircraft || [];
@@ -303,12 +338,17 @@ export async function getADSB() {
const key = a.hex.toLowerCase();
if (!history.has(key)) history.set(key, []);
const trail = history.get(key);
trail.push({ latitude: a.lat, longitude: a.lon, altitude: a.alt_baro || 0, ts: Date.now() });
trail.push({
latitude: a.lat,
longitude: a.lon,
altitude: a.alt_baro || 0,
ts: Date.now()
});
if (trail.length > MAX_HISTORY) trail.shift();
}
adsbCache = await Promise.all(aircraft.map(async a => {
a = await enrichAircraft(a);
a = await enrich(a);
a.icon = getIcon(a);
return a;
}));
@@ -316,11 +356,7 @@ export async function getADSB() {
return adsbCache;
}
export async function getADSBHistory(icao) {
return { history: history.get(icao?.toLowerCase()) || [] };
}
export async function getADSBRange() {
export async function getAttenuation() {
const { ADSB_URL } = cfg();
if (!ADSB_URL) return [];
const r = await fetch(`${ADSB_URL}:8080/data/outline.json`);
@@ -328,42 +364,146 @@ export async function getADSBRange() {
return j?.actualRange?.last24h?.points || [];
}
export async function getADSBImage(icao) {
export async function getHistory(icao) {
return { history: history.get(icao?.toLowerCase()) || [] };
}
export async function getIcaoImage(icao) {
const UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/153.0.0.0 Safari/537.36';
async function duckduckgo(query) {
async function getVqd(query) {
const res = await fetch(`https://duckduckgo.com/?q=${encodeURIComponent(query)}`, {
headers: { 'User-Agent': 'Mozilla/5.0' }
})
const html = await res.text()
const match = html.match(/vqd=['"]?([\d-]+)['"]?/)
return match?.[1]
const url = `https://duckduckgo.com/?q=${encodeURIComponent(query)}&iax=images&ia=images`;
const res = await fetch(url, {
headers: {
'User-Agent': UA,
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8',
'Accept-Language': 'en-US,en;q=0.9'
}
});
if(!res.ok) return console.warn(`DuckDuckGo Search failed: ${res.status}`);
const html = await res.text();
const match = html.match(/vqd=["']([^"']+)["']/);
return match?.[1] || null;
}
const vqd = await getVqd(query)
if (!vqd) throw new Error('Could not get vqd token')
const vqd = await getVqd(query);
if (!vqd) throw new Error('Could not get vqd token');
const url = `https://duckduckgo.com/i.js?q=${encodeURIComponent(query)}&o=json&vqd=${vqd}&f=,,,,,&p=1`
const data = await fetch(url,
{headers: {'User-Agent': 'Mozilla/5.0', 'Referer': 'https://duckduckgo.com/'}}
).then(resp => resp.json());
return data?.results?.[0]?.image || null;
await new Promise(resolve => setTimeout(resolve, 800 + Math.random() * 500));
const searchUrl = `https://duckduckgo.com/i.js?q=${encodeURIComponent(query)}&o=json&vqd=${encodeURIComponent(vqd)}&f=,,,,,&p=1`;
const data = await fetch(searchUrl, {
headers: {
'User-Agent': UA,
'Accept': 'application/json, text/javascript, */*; q=0.01',
'Accept-Language': 'en-US,en;q=0.9',
'Referer': `https://duckduckgo.com/?q=${encodeURIComponent(query)}&iax=images&ia=images`
}
}).then(async resp => {
if (!resp.ok) return console.warn(`DuckDuckGo image failed: ${resp.status}`);
const text = await resp.text();
try {
return JSON.parse(text);
} catch {
console.warn(`DuckDuckGo returned invalid JSON: ${text.slice(0, 200)}`);
return null;
}
});
if (data?.results?.length) {
for (const result of data.results) {
if (!result.image) continue;
try {
const imgRes = await fetch(result.image, {
headers: {
'Accept': 'image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8',
'User-Agent': UA,
'Referer': result.url || 'https://duckduckgo.com/'
}
});
if (imgRes.ok) return imgRes.blob();
console.warn(`Image fetch failed: ${imgRes.status} ${result.image}`);
} catch (e) {
console.warn(`Image fetch failed: ${result.image} - ${e.message}`);
}
}
}
return null;
}
const c = cfg();
let aircraft = (await adsbCache).find(a => a.icao?.toLowerCase() === icao?.toLowerCase());
if(!aircraft) aircraft = enrichAircraft({hex: icao});
if(!aircraft) return null;
async function genericImage(modelType) {
if (!modelType) return null;
const generic = duckduckgo(aircraft.model ? `${aircraft.manufacturer} ${aircraft.model}` : `${aircraft.aircraft} Aircraft`);
const specific = await fetch(`https://api.planespotters.net/pub/photos/hex/${icao.toLowerCase()}`,
{headers: {'User-Agent': `Personal ADSB Feeder + Dashboard (${c.EMAIL})`}}
).then(resp => resp.ok ? resp.json() : null);
const filePath = resolve(IMAGES_DIR, 'generic', `${modelType}.jpg`);
if (fs.existsSync(filePath)) return fs.readFileSync(filePath);
let src;
if(specific?.photos?.[0]) src = specific.photos[0].thumbnail_large?.src || specific.photos[0].thumbnail?.src;
else src = await generic;
const blob = await duckduckgo(`${modelType} Aircraft`);
if (!blob) return null;
fs.mkdirSync(dirname(filePath), { recursive: true });
const buffer = Buffer.from(await blob.arrayBuffer());
const metadata = await sharp(buffer).metadata();
const width = metadata.width || 1200;
const height = metadata.height || 800;
const fontSize = Math.max(48, Math.round(Math.min(width, height) * 0.16));
const watermark = Buffer.from(`
<svg width="${width}" height="${height}" xmlns="http://www.w3.org/2000/svg">
<text x="50%" y="50%" text-anchor="middle" dominant-baseline="middle"
font-family="Arial, Helvetica, sans-serif" font-size="${fontSize}px"
font-weight="700" fill="white" fill-opacity="0.65"
stroke="black" stroke-opacity="0.35" stroke-width="${Math.max(2, Math.round(fontSize * 0.04))}"
transform="rotate(-20 ${width / 2} ${height / 2})">STOCK IMAGE</text>
</svg>`);
const watermarked = await sharp(buffer).composite([{ input: watermark }]).jpeg().toBuffer();
fs.writeFileSync(filePath, watermarked);
return watermarked;
}
async function icaoImage(icao) {
const filePath = resolve(IMAGES_DIR, `${icao}.jpg`);
if (fs.existsSync(filePath)) return fs.readFileSync(filePath);
const planeSpotters = await fetch(`https://api.planespotters.net/pub/photos/hex/${icao}`, {
headers: { 'User-Agent': 'open-sight.net (zaktimson@gmail.com)' }
}).then(resp => resp.ok ? resp.json() : null).catch(() => null);
const src = planeSpotters?.photos?.[0]?.thumbnail_large?.src || planeSpotters?.photos?.[0]?.thumbnail?.src;
if (!src) return null;
return fetch(src).then(resp => resp.blob());
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);
+47 -12
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(!mmsi) return null;
mmsi = String(mmsi);
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="(.+?)"/)
const srcMatch = match[0].match(/src="(.+?)"/);
if(!srcMatch) return null;
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())
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;
+5
View File
@@ -10,6 +10,11 @@ export function cfg() {
return {
PORT: process.env.PORT || 3000,
ADSB_URL: process.env.ADSB_URL || '',
TINYGS_URL: process.env.TINYGS_URL || '',
TINYGS_AUTH: process.env.TINYGS_AUTH || '',
TINYGS_MQTT_PORT: process.env.TINYGS_MQTT_PORT || 1883,
TINYGS_MQTT_USER: process.env.TINYGS_MQTT_USER || '',
TINYGS_MQTT_PASS: process.env.TINYGS_MQTT_PASS || '',
DB_HOST: process.env.DB_HOST || 'http://localhost:8428',
EMAIL: process.env.EMAIL,
LATITUDE: parseFloat(process.env.LATITUDE || '0'),
+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),
};
});
};
+4 -1
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) {
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