ADSB
This commit is contained in:
@@ -1,13 +1,15 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<link rel="icon" href="/favicon.ico">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Vite App</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
<head>
|
||||
<title>Weather Station</title>
|
||||
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
|
||||
<link rel="icon" href="/favicon.png">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import type {AirTrafficLayer} from '@/services/airtraffic.ts';
|
||||
import { onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import Map from 'ol/Map'
|
||||
import View from 'ol/View'
|
||||
@@ -17,55 +18,32 @@ import 'ol/ol.css'
|
||||
const props = defineProps<{ dark: boolean }>()
|
||||
const mapEl = ref<HTMLDivElement>()
|
||||
const OWM_KEY = import.meta.env.VITE_OWM_API_KEY || ''
|
||||
const showOverlays = ref(false)
|
||||
const atActive = ref(true)
|
||||
|
||||
// Nautical miles to meters
|
||||
const NM = 1852
|
||||
|
||||
const OVERLAYS = [
|
||||
{
|
||||
id: 'rain',
|
||||
label: 'Rain',
|
||||
icon: '🌧️',
|
||||
url: (ts: number) => `https://tilecache.rainviewer.com/v2/radar/${ts}/256/{z}/{x}/{y}/4/1_1.png`,
|
||||
needsTs: true,
|
||||
},
|
||||
{
|
||||
id: 'clouds',
|
||||
label: 'Clouds',
|
||||
icon: '☁️',
|
||||
url: () => `https://tile.openweathermap.org/map/clouds_new/{z}/{x}/{y}.png?appid=${OWM_KEY}`,
|
||||
needsTs: false,
|
||||
},
|
||||
{
|
||||
id: 'wind',
|
||||
label: 'Wind',
|
||||
icon: '💨',
|
||||
url: () => `https://tile.openweathermap.org/map/wind_new/{z}/{x}/{y}.png?appid=${OWM_KEY}`,
|
||||
needsTs: false,
|
||||
},
|
||||
{
|
||||
id: 'temp',
|
||||
label: 'Temp',
|
||||
icon: '🌡️',
|
||||
url: () => `https://tile.openweathermap.org/map/temp_new/{z}/{x}/{y}.png?appid=${OWM_KEY}`,
|
||||
needsTs: false,
|
||||
},
|
||||
{
|
||||
id: 'pressure',
|
||||
label: 'Pressure',
|
||||
icon: '📊',
|
||||
url: () => `https://tile.openweathermap.org/map/pressure_new/{z}/{x}/{y}.png?appid=${OWM_KEY}`,
|
||||
needsTs: false,
|
||||
},
|
||||
{ id: 'rain', label: 'Rain', icon: '🌧️', url: (ts: number) => `https://tilecache.rainviewer.com/v2/radar/${ts}/256/{z}/{x}/{y}/4/1_1.png`, needsTs: false },
|
||||
{ id: 'clouds', label: 'Clouds', icon: '☁️', url: () => `https://tile.openweathermap.org/map/clouds_new/{z}/{x}/{y}.png?appid=${OWM_KEY}`, needsTs: false },
|
||||
{ id: 'wind', label: 'Wind', icon: '💨', url: () => `https://tile.openweathermap.org/map/wind_new/{z}/{x}/{y}.png?appid=${OWM_KEY}`, needsTs: false },
|
||||
{ id: 'temp', label: 'Temp', icon: '🌡️', url: () => `https://tile.openweathermap.org/map/temp_new/{z}/{x}/{y}.png?appid=${OWM_KEY}`, needsTs: false },
|
||||
{ id: 'pressure', label: 'Pressure', icon: '📊', url: () => `https://tile.openweathermap.org/map/pressure_new/{z}/{x}/{y}.png?appid=${OWM_KEY}`, needsTs: false },
|
||||
]
|
||||
|
||||
const activeOverlays = ref<Set<string>>(new Set(['rain']))
|
||||
const activeOverlays = ref<Set<string>>(new Set([]))
|
||||
const radarTs = ref(Math.floor(Date.now() / 1000))
|
||||
const overlayLayers: {[key: string]: any} = {}
|
||||
|
||||
let map: Map
|
||||
let stationLayer: VectorLayer<VectorSource>
|
||||
let radarInterval: ReturnType<typeof setInterval>
|
||||
let airTraffic: AirTrafficLayer
|
||||
|
||||
function toggleAT() {
|
||||
atActive.value ? airTraffic.hide() : airTraffic.show()
|
||||
atActive.value = !atActive.value
|
||||
}
|
||||
|
||||
function buildBaseLayer(dark: boolean) {
|
||||
return new TileLayer({
|
||||
@@ -90,12 +68,11 @@ function buildOverlayLayer(id: string): TileLayer<XYZ> {
|
||||
}
|
||||
|
||||
function buildStationLayer(lat: number, lon: number, dark: boolean): VectorLayer<VectorSource> {
|
||||
const center = fromLonLat([lon, lat])
|
||||
const source = new VectorSource()
|
||||
const ringColor = dark ? 'rgba(255,255,255,0.25)' : 'rgba(0,0,0,0.18)'
|
||||
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 ringColor = dark ? 'rgba(255,255,255,0.25)' : 'rgba(0,0,0,0.18)'
|
||||
const ringStroke = dark ? 'rgba(255,255,255,0.5)' : 'rgba(0,0,0,0.35)'
|
||||
|
||||
// Rings at 50, 100, 150, 200 nm
|
||||
for (const nm of [50, 100, 150, 200]) {
|
||||
const ring = new Feature(new CircleGeom(center, nm * NM))
|
||||
ring.setStyle(new Style({
|
||||
@@ -105,7 +82,6 @@ function buildStationLayer(lat: number, lon: number, dark: boolean): VectorLayer
|
||||
source.addFeature(ring)
|
||||
}
|
||||
|
||||
// Station dot
|
||||
const dot = new Feature(new Point(center))
|
||||
dot.setStyle(new Style({
|
||||
image: new CircleStyle({
|
||||
@@ -123,10 +99,7 @@ function toggleOverlay(id: string) {
|
||||
if (activeOverlays.value.has(id)) {
|
||||
activeOverlays.value.delete(id)
|
||||
const layer = overlayLayers[id]
|
||||
if (layer) {
|
||||
map.removeLayer(layer);
|
||||
delete overlayLayers[id];
|
||||
}
|
||||
if (layer) { map.removeLayer(layer); delete overlayLayers[id] }
|
||||
} else {
|
||||
activeOverlays.value.add(id)
|
||||
const layer = buildOverlayLayer(id)
|
||||
@@ -148,13 +121,15 @@ onMounted(() => {
|
||||
controls: [],
|
||||
})
|
||||
|
||||
airTraffic = new AirTrafficLayer(map)
|
||||
airTraffic.show()
|
||||
|
||||
for (const id of activeOverlays.value) {
|
||||
const layer = buildOverlayLayer(id)
|
||||
overlayLayers[id] = layer
|
||||
map.addLayer(layer)
|
||||
}
|
||||
|
||||
// Refresh radar every 5 min
|
||||
radarInterval = setInterval(() => {
|
||||
radarTs.value = Math.floor(Date.now() / 1000)
|
||||
const layer = overlayLayers['rain']
|
||||
@@ -165,17 +140,30 @@ onMounted(() => {
|
||||
}, 5 * 60 * 1000)
|
||||
})
|
||||
|
||||
onUnmounted(() => clearInterval(radarInterval))
|
||||
onUnmounted(() => {
|
||||
clearInterval(radarInterval)
|
||||
airTraffic.hide()
|
||||
})
|
||||
|
||||
watch(() => props.dark, dark => {
|
||||
map.getLayers().setAt(0, buildBaseLayer(dark))
|
||||
// Rebuild station layer with correct colors
|
||||
map.removeLayer(stationLayer)
|
||||
const lat = (current.value.gps_lat as number) || 0
|
||||
const lon = (current.value.gps_lon as number) || 0
|
||||
stationLayer = buildStationLayer(lat, lon, dark)
|
||||
map.addLayer(stationLayer)
|
||||
})
|
||||
|
||||
watch(() => current.value, () => {
|
||||
const lat = (current.value.gps_lat as number) || 0
|
||||
const lon = (current.value.gps_lon as number) || 0
|
||||
|
||||
map.getView().animate({ center: fromLonLat([lon, lat]), duration: 500 })
|
||||
|
||||
map.removeLayer(stationLayer)
|
||||
stationLayer = buildStationLayer(lat, lon, props.dark)
|
||||
map.addLayer(stationLayer)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@@ -192,21 +180,7 @@ watch(() => props.dark, dark => {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
// Shared button style
|
||||
.overlay-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -229,15 +203,76 @@ watch(() => props.dark, dark => {
|
||||
|
||||
&:hover:not(.active) { background: var(--hover); }
|
||||
}
|
||||
|
||||
// Desktop bar
|
||||
.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);
|
||||
}
|
||||
|
||||
// Mobile layers menu
|
||||
.layers-menu {
|
||||
position: absolute;
|
||||
bottom: 16px;
|
||||
right: 16px;
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.layers-btn {
|
||||
padding: 8px 14px;
|
||||
border-radius: 99px;
|
||||
border: 1.5px solid var(--border);
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
backdrop-filter: blur(8px);
|
||||
box-shadow: 0 2px 12px rgba(0,0,0,0.2);
|
||||
}
|
||||
|
||||
.layers-dropdown {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
background: var(--surface);
|
||||
border-radius: 12px;
|
||||
padding: 8px;
|
||||
box-shadow: 0 2px 12px rgba(0,0,0,0.2);
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
// Visibility switching
|
||||
.desktop { display: flex; }
|
||||
.mobile { display: none; }
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.desktop { display: none; }
|
||||
.mobile { display: flex; }
|
||||
}
|
||||
</style>
|
||||
|
||||
<template>
|
||||
<div class="map-wrap">
|
||||
<div ref="mapEl" class="map-el" />
|
||||
<div class="overlay-toggles">
|
||||
|
||||
<!-- Desktop toggles -->
|
||||
<div class="overlay-toggles desktop">
|
||||
<button
|
||||
v-for="o in OVERLAYS"
|
||||
:key="o.id"
|
||||
v-for="o in OVERLAYS" :key="o.id"
|
||||
class="overlay-btn"
|
||||
:class="{ active: activeOverlays.has(o.id) }"
|
||||
@click="toggleOverlay(o.id)"
|
||||
@@ -245,5 +280,25 @@ watch(() => props.dark, dark => {
|
||||
{{ o.icon }} {{ o.label }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Mobile layers button + dropdown -->
|
||||
<div class="layers-menu mobile">
|
||||
<button class="layers-btn" @click="showOverlays = !showOverlays">
|
||||
⚙️ Layers
|
||||
</button>
|
||||
<div v-if="showOverlays" class="layers-dropdown">
|
||||
<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 }}
|
||||
</button>
|
||||
<button class="overlay-btn" :class="{ active: atActive }" @click="toggleAT">
|
||||
✈️ Traffic
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
426
client/src/services/airtraffic.ts
Normal file
426
client/src/services/airtraffic.ts
Normal file
@@ -0,0 +1,426 @@
|
||||
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'
|
||||
|
||||
const API = '/api'
|
||||
|
||||
const COLORS: Record<string, string> = {
|
||||
'Cargo': '#00ff00',
|
||||
'Military': '#ff0000',
|
||||
'Private': '#8450ea',
|
||||
'Passenger': '#009dff',
|
||||
'Unknown': '#fad106',
|
||||
}
|
||||
|
||||
const SPEED_UNITS = {
|
||||
knots: { label: 'KTS', convert: (v: number) => Math.round(v) },
|
||||
kph: { label: 'KPH', convert: (v: number) => Math.round(v * 1.852) },
|
||||
mph: { label: 'MPH', convert: (v: number) => Math.round(v * 1.15078) },
|
||||
}
|
||||
|
||||
const ALTITUDE_UNITS = {
|
||||
meters: { label: 'M', convert: (v: number) => Math.round(v * 0.3048) },
|
||||
feet: { label: 'FT', convert: (v: number) => v },
|
||||
}
|
||||
|
||||
const VERTICAL_UNITS = {
|
||||
mps: { label: 'M/S', convert: (v: number) => Math.round(v * 0.3048) },
|
||||
fps: { label: 'FT/S', convert: (v: number) => v },
|
||||
}
|
||||
|
||||
const UNIT_KEY = 'at_units'
|
||||
function getUnits() {
|
||||
const s = localStorage.getItem(UNIT_KEY)
|
||||
return s ? JSON.parse(s) : { speed: 'knots', altitude: 'meters', vertical: 'mps' }
|
||||
}
|
||||
function saveUnits(p: any) { localStorage.setItem(UNIT_KEY, JSON.stringify(p)) }
|
||||
|
||||
function getAltColor(alt: number): [number, number, number] {
|
||||
const a = Math.max(0, alt)
|
||||
if (a < 10) return [0, 0, 0]
|
||||
if (a < 10000) return [135, 206, 250]
|
||||
if (a < 25000) {
|
||||
const r = (a - 10000) / 15000
|
||||
return [Math.round(135 * (1-r)), Math.round(206 * (1-r)), Math.round(250 * (1-r) + 255 * r)]
|
||||
}
|
||||
if (a < 40000) {
|
||||
const r = (a - 25000) / 15000
|
||||
return [0, 0, Math.round(255 * (1-r) + 139 * r)]
|
||||
}
|
||||
const r = Math.min((a - 40000) / 10000, 1)
|
||||
return [Math.round(128 * r), 0, 139]
|
||||
}
|
||||
|
||||
function buildPlaneIcon(plane: any, shapes: any): string {
|
||||
const color = COLORS[plane.class] || COLORS['Unknown']
|
||||
const shape = shapes[plane.shape] || shapes['unknown'] || shapes[<any>Object.keys(shapes)[0]]
|
||||
if (!shape) {
|
||||
return `data:image/svg+xml,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><polygon points="12,2 22,22 12,17 2,22" fill="${color}" stroke="#000" stroke-width="1"/></svg>`)}`
|
||||
}
|
||||
const paths = (Array.isArray(shape.path) ? shape.path : [shape.path])
|
||||
.map((p: string) => `<path d="${p}" fill="${color}" stroke="#000" stroke-width="${shape.stroke || 1}"/>`)
|
||||
.join('')
|
||||
return `data:image/svg+xml,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="${shape.w * 2}" height="${shape.h * 2}" viewBox="${shape.viewBox}">${paths}</svg>`)}`
|
||||
}
|
||||
|
||||
function buildNavballSvg(data: any): string {
|
||||
const heading = data.heading ?? 0
|
||||
const airspeedKnots = data.speed || 0
|
||||
const verticalRateFps = data.climb || 0
|
||||
const groundSpeedFps = airspeedKnots * 1.68781
|
||||
const pitchRad = groundSpeedFps > 0 ? Math.atan(verticalRateFps / groundSpeedFps) : 0
|
||||
const pitch = Math.max(-25, Math.min(25, pitchRad * (180 / Math.PI)))
|
||||
|
||||
const pitchLines: string[] = []
|
||||
for (let p = -30; p <= 30; p += 5) {
|
||||
if (p === 0) continue
|
||||
const isMajor = p % 10 === 0
|
||||
const [start, end] = [85, 115]
|
||||
if (isMajor) {
|
||||
pitchLines.push(`
|
||||
<text x="${start - Math.abs(p) - 5}" y="${104 + pitch * 2.5 - p * 2.5}" fill="#fff" font-size="8" font-weight="bold" text-anchor="end">${Math.abs(p)}</text>
|
||||
<line x1="${start - Math.abs(p)}" y1="${100 + pitch * 2.5 - p * 2.5}" x2="${end + Math.abs(p)}" y2="${100 + pitch * 2.5 - p * 2.5}" stroke="#fff" stroke-width="2"/>
|
||||
<text x="${end + Math.abs(p) + 5}" y="${104 + pitch * 2.5 - p * 2.5}" fill="#fff" font-size="8" font-weight="bold">${Math.abs(p)}</text>
|
||||
`)
|
||||
} else {
|
||||
pitchLines.push(`<line x1="${start}" y1="${100 + pitch * 2.5 - p * 2.5}" x2="${end}" y2="${100 + pitch * 2.5 - p * 2.5}" stroke="#fff" stroke-width="2"/>`)
|
||||
}
|
||||
}
|
||||
|
||||
const rollTicks: string[] = []
|
||||
for (const angle of [-90, -60, -45, -30, -20, -10, 0, 10, 20, 30, 45, 60, 90]) {
|
||||
const rad = angle * Math.PI / 180
|
||||
const a = Math.abs(angle)
|
||||
if (a === 45) {
|
||||
rollTicks.push(`<circle cx="${100 + 78 * Math.sin(rad)}" cy="${100 - 78 * Math.cos(rad)}" r="2.5" fill="#fff"/>`)
|
||||
} else {
|
||||
const inner = (a === 0 || a % 30 === 0) ? 72 : 77
|
||||
const sw = (a === 0 || a % 30 === 0) ? 2.5 : 1.5
|
||||
rollTicks.push(`<line x1="${100 + inner * Math.sin(rad)}" y1="${100 - inner * Math.cos(rad)}" x2="${100 + 85 * Math.sin(rad)}" y2="${100 - 85 * Math.cos(rad)}" stroke="#fff" stroke-width="${sw}"/>`)
|
||||
}
|
||||
}
|
||||
|
||||
const compassLabels = [-60, -45, -30, -15, 0, 15, 30, 45, 60].map(offset => {
|
||||
const rad = offset * Math.PI / 180
|
||||
const x = 100 + 90 * Math.sin(rad)
|
||||
const y = 100 - 90 * Math.cos(rad)
|
||||
const h = Math.round((offset + 360) % 360)
|
||||
const lbl = ({ 0: 'N', 90: 'E', 180: 'S', 270: 'W' } as any)[h] || ''
|
||||
return lbl ? `<text x="${x}" y="${y + 4}" text-anchor="middle" fill="#0f0" font-size="13" font-weight="bold">${lbl}</text>` : ''
|
||||
}).join('')
|
||||
|
||||
return `
|
||||
<svg width="200" height="220" viewBox="0 0 200 220">
|
||||
<defs>
|
||||
<clipPath id="navballClip"><circle cx="100" cy="100" r="85"/></clipPath>
|
||||
<linearGradient id="skyGrad" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#1e90ff"/>
|
||||
<stop offset="100%" style="stop-color:#4169e1"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="groundGrad" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#8b4513"/>
|
||||
<stop offset="100%" style="stop-color:#654321"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect x="77" y="5" width="46" height="18" fill="rgba(0,0,0,0.95)" stroke="#0f0" stroke-width="2" rx="2"/>
|
||||
<text x="100" y="18" text-anchor="middle" fill="#0f0" font-size="14" font-weight="bold">${heading.toFixed(0).padStart(3, '0')}°</text>
|
||||
<g transform="translate(0,25)" clip-path="url(#navballClip)">
|
||||
<rect x="15" y="${15 + pitch * 2.5 - 200}" width="170" height="285" fill="url(#skyGrad)"/>
|
||||
<rect x="15" y="${100 + pitch * 2.5}" width="170" height="285" fill="url(#groundGrad)"/>
|
||||
<line x1="15" y1="${100 + pitch * 2.5}" x2="185" y2="${100 + pitch * 2.5}" stroke="#fff" stroke-width="3"/>
|
||||
${pitchLines.join('')}
|
||||
</g>
|
||||
<g transform="translate(0,25)">${rollTicks.join('')}</g>
|
||||
<circle cx="100" cy="125" r="85" fill="none" stroke="#0f0" stroke-width="2.5"/>
|
||||
<line x1="40" y1="125" x2="80" y2="125" stroke="#ff0" stroke-width="3.5"/>
|
||||
<line x1="120" y1="125" x2="160" y2="125" stroke="#ff0" stroke-width="3.5"/>
|
||||
<circle cx="100" cy="125" r="4" fill="none" stroke="#ff0" stroke-width="2.5"/>
|
||||
<g transform="rotate(${-heading} 100 125)">${compassLabels}</g>
|
||||
<polygon points="100,37 95,30 105,30" fill="#ff0"/>
|
||||
</svg>
|
||||
`
|
||||
}
|
||||
|
||||
function buildGaugeHtml(label: string, value: any, unit: string, type: string): string {
|
||||
return `
|
||||
<div style="margin-bottom:12px">
|
||||
<div style="color:#888;font-size:11px;font-weight:bold;margin-bottom:3px">${label}</div>
|
||||
<div class="at-gauge" data-gauge="${type}" style="padding:6px;background:rgba(0,0,0,0.95);border:2px solid #0f0;border-radius:3px;cursor:pointer;display:flex;align-items:end;justify-content:center;gap:4px">
|
||||
<span style="font-size:20px;font-weight:bold;color:#0f0">${value}</span>
|
||||
<span style="font-size:11px;color:#0f0;margin-bottom:2px">${unit}</span>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
function buildPopupHtml(data: any): string {
|
||||
const prefs = getUnits()
|
||||
const speed = SPEED_UNITS[prefs.speed as keyof typeof SPEED_UNITS]
|
||||
const alt = ALTITUDE_UNITS[prefs.altitude as keyof typeof ALTITUDE_UNITS]
|
||||
const vert = VERTICAL_UNITS[prefs.vertical as keyof typeof VERTICAL_UNITS]
|
||||
const callsign = data.name?.trim()
|
||||
const altitude = data.alt_baro ?? data.alt_geom ?? 0
|
||||
|
||||
return `
|
||||
<div style="font-family:monospace;color:#ccc;min-width:320px">
|
||||
<div style="padding:12px 16px;border-bottom:1px solid rgba(200,200,220,0.2)">
|
||||
<h3 style="margin:0;color:#7eeee1;font-size:16px">
|
||||
✈️ ${callsign
|
||||
? `<a href="https://www.flightaware.com/live/flight/${callsign}" target="_blank" style="color:#6bb6ff;text-decoration:none">${callsign.toUpperCase()}</a>`
|
||||
: 'N/A'}
|
||||
</h3>
|
||||
<div style="font-size:11px;color:#888;margin-top:4px">
|
||||
${data.country || ''} • ${data.class || 'Unknown'} • ${data.type || ''}
|
||||
<span style="float:right">ICAO: ${(data.icao || '').toUpperCase()}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex;gap:16px;padding:12px 16px">
|
||||
<div style="flex:0 0 110px">
|
||||
${buildGaugeHtml('Air Speed', speed.convert(data.speed || 0), speed.label, 'speed')}
|
||||
${buildGaugeHtml('Altitude', data.landed ? 'LANDED' : alt.convert(altitude), data.landed ? '' : alt.label, 'altitude')}
|
||||
${buildGaugeHtml('Climb', vert.convert(data.climb || 0) >= 0 ? `+${vert.convert(data.climb || 0)}` : vert.convert(data.climb || 0), vert.label, 'vertical')}
|
||||
</div>
|
||||
<div style="flex:1">${buildNavballSvg(data)}</div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
export class AirTrafficLayer {
|
||||
private map: Map
|
||||
private layer!: VectorLayer<VectorSource>
|
||||
private traceLayers: Record<string, VectorLayer<VectorSource>> = {}
|
||||
private traceSegs: Record<string, Feature[]> = {}
|
||||
private popups: Record<string, { el: HTMLDivElement, destroy: () => void }> = {}
|
||||
private historyCache: Record<string, any[]> = {}
|
||||
private shapes: any = null
|
||||
private data: any[] = []
|
||||
private clickHandler: ((e: any) => void) | null = null
|
||||
private refreshTimer: ReturnType<typeof setInterval> | null = null
|
||||
visible = false
|
||||
|
||||
constructor(map: Map) { this.map = map }
|
||||
|
||||
// ── Public ────────────────────────────────────────────────────────────────
|
||||
|
||||
async show() {
|
||||
if (this.visible) return
|
||||
this.visible = true
|
||||
|
||||
if (!this.shapes) this.shapes = await fetch(`${API}/air-traffic-shapes`).then(r => r.json())
|
||||
|
||||
this.layer = new VectorLayer({ source: new VectorSource(), zIndex: 100 })
|
||||
this.map.addLayer(this.layer)
|
||||
|
||||
await this._fetch()
|
||||
this._draw()
|
||||
this._attachClick()
|
||||
|
||||
this.refreshTimer = setInterval(async () => {
|
||||
await this._fetch()
|
||||
this._draw()
|
||||
this._refreshPopups()
|
||||
}, 15_000)
|
||||
}
|
||||
|
||||
hide() {
|
||||
if (!this.visible) return
|
||||
this.visible = false
|
||||
|
||||
if (this.refreshTimer) { clearInterval(this.refreshTimer); this.refreshTimer = null }
|
||||
if (this.clickHandler) { this.map.un('singleclick', this.clickHandler); this.clickHandler = null }
|
||||
|
||||
for (const 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 ───────────────────────────────────────────────────────────────
|
||||
|
||||
private async _fetch() {
|
||||
const j = await fetch(`${API}/air-traffic`).then(r => r.json())
|
||||
this.data = j.data || []
|
||||
}
|
||||
|
||||
private _draw() {
|
||||
const source = this.layer.getSource()!
|
||||
const existing: Record<string, Feature> = {}
|
||||
source.getFeatures().forEach(f => { existing[f.get('icao')] = f })
|
||||
const incoming = new Set(this.data.filter(p => p.latitude != null).map(p => p.icao))
|
||||
|
||||
// Remove stale
|
||||
Object.keys(existing).forEach(icao => {
|
||||
if (!incoming.has(icao)) { source.removeFeature(<any>existing[icao]); this._closePopup(icao) }
|
||||
})
|
||||
|
||||
// Update / add
|
||||
for (const plane of this.data) {
|
||||
if (plane.latitude == null) continue
|
||||
const coord = fromLonLat([plane.longitude, plane.latitude])
|
||||
const style = new Style({
|
||||
image: new Icon({
|
||||
src: buildPlaneIcon(plane, this.shapes),
|
||||
scale: 1,
|
||||
rotation: (plane.heading ?? 0) * (Math.PI / 180),
|
||||
anchor: [0.5, 0.5],
|
||||
}),
|
||||
})
|
||||
|
||||
if (existing[plane.icao]) {
|
||||
;(<any>((<any>existing[plane.icao]).getGeometry() as Point)).setCoordinates(coord)
|
||||
(<any>existing[plane.icao]).setStyle(style)
|
||||
(<any>existing[plane.icao]).set('planeData', plane)
|
||||
} else {
|
||||
const f = new Feature({ geometry: new Point(coord) })
|
||||
f.set('icao', plane.icao)
|
||||
f.set('planeData', plane)
|
||||
f.setStyle(style)
|
||||
source.addFeature(f)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async _fetchTrace(plane: any) {
|
||||
const icao = plane.icao
|
||||
if (!this.historyCache[icao]) {
|
||||
const j = await fetch(`${API}/air-traffic/${icao}`).then(r => r.json())
|
||||
this.historyCache[icao] = j.history || []
|
||||
}
|
||||
|
||||
const history = [...(<any>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
|
||||
|
||||
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] = []
|
||||
} else {
|
||||
vs = traceLayer.getSource()!
|
||||
;(this.traceSegs[icao] || []).forEach(f => vs.removeFeature(f))
|
||||
}
|
||||
|
||||
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 + <any>ec[idx]) / 2))
|
||||
const f = new Feature(new LineString([fromLonLat([s.longitude, s.latitude]), fromLonLat([e.longitude, e.latitude])]))
|
||||
f.setStyle(new Style({ stroke: new Stroke({ color: `rgba(${avg.join(',')},0.8)`, width: 3 }) }))
|
||||
vs.addFeature(f)
|
||||
segs.push(f)
|
||||
}
|
||||
this.traceSegs[icao] = segs
|
||||
}
|
||||
|
||||
private _openPopup(plane: any) {
|
||||
const icao = plane.icao
|
||||
if (this.popups[icao]) return
|
||||
|
||||
const el = document.createElement('div')
|
||||
el.style.cssText = `
|
||||
position:absolute; z-index:9999; pointer-events:auto;
|
||||
background:rgba(0,0,0,0.88); border:1px solid rgba(200,200,220,0.3);
|
||||
border-radius:8px; box-shadow:0 4px 24px rgba(0,0,0,0.5); overflow:hidden;
|
||||
`
|
||||
|
||||
const close = document.createElement('button')
|
||||
close.innerHTML = '✕'
|
||||
close.style.cssText = 'position:absolute;top:8px;right:8px;background:none;border:none;color:#888;font-size:14px;cursor:pointer;z-index:1'
|
||||
close.onclick = () => this._closePopup(icao)
|
||||
|
||||
el.innerHTML = buildPopupHtml(plane)
|
||||
el.appendChild(close)
|
||||
document.body.appendChild(el)
|
||||
this._positionPopup(el, plane)
|
||||
|
||||
el.querySelectorAll('.at-gauge').forEach(g => {
|
||||
g.addEventListener('click', (e) => {
|
||||
const prefs = getUnits()
|
||||
const type = (e.currentTarget as HTMLElement).dataset.gauge!
|
||||
if (type === 'speed') {
|
||||
const keys = Object.keys(SPEED_UNITS)
|
||||
prefs.speed = keys[(keys.indexOf(prefs.speed) + 1) % keys.length]
|
||||
} else if (type === 'altitude') {
|
||||
const keys = Object.keys(ALTITUDE_UNITS)
|
||||
prefs.altitude = keys[(keys.indexOf(prefs.altitude) + 1) % keys.length]
|
||||
prefs.vertical = prefs.altitude === 'meters' ? 'mps' : 'fps'
|
||||
} else if (type === 'vertical') {
|
||||
const keys = Object.keys(VERTICAL_UNITS)
|
||||
prefs.vertical = keys[(keys.indexOf(prefs.vertical) + 1) % keys.length]
|
||||
prefs.altitude = prefs.vertical === 'mps' ? 'meters' : 'feet'
|
||||
}
|
||||
saveUnits(prefs)
|
||||
el.innerHTML = buildPopupHtml(plane)
|
||||
el.appendChild(close)
|
||||
})
|
||||
g.addEventListener('mouseenter', e => (e.currentTarget as HTMLElement).style.borderColor = '#0ff')
|
||||
g.addEventListener('mouseleave', e => (e.currentTarget as HTMLElement).style.borderColor = '#0f0')
|
||||
})
|
||||
|
||||
this.popups[icao] = { el, destroy: () => el.remove() }
|
||||
this._fetchTrace(plane)
|
||||
}
|
||||
|
||||
private _positionPopup(el: HTMLDivElement, plane: any) {
|
||||
const pixel = this.map.getPixelFromCoordinate(fromLonLat([plane.longitude, plane.latitude]))
|
||||
if (!pixel) return
|
||||
const rect = (this.map.getTargetElement() as HTMLElement).getBoundingClientRect()
|
||||
el.style.left = `${rect.left + <any>pixel[0] + 16}px`
|
||||
el.style.top = `${rect.top + <any>pixel[1] - 16}px`
|
||||
}
|
||||
|
||||
private _closePopup(icao: string) {
|
||||
const popup = this.popups[icao]
|
||||
if (popup) { popup.destroy(); 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]
|
||||
}
|
||||
|
||||
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 }
|
||||
const { el } = <any>this.popups[icao]
|
||||
el.innerHTML = buildPopupHtml(plane)
|
||||
const close = document.createElement('button')
|
||||
close.innerHTML = '✕'
|
||||
close.style.cssText = 'position:absolute;top:8px;right:8px;background:none;border:none;color:#888;font-size:14px;cursor:pointer;z-index:1'
|
||||
close.onclick = () => this._closePopup(icao)
|
||||
el.appendChild(close)
|
||||
this._positionPopup(el, plane)
|
||||
this._fetchTrace(plane)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
const BASE = import.meta.env.DEV ? 'http://localhost:3000' : ''
|
||||
const BASE = import.meta.env.DEV ? 'http://10.69.5.23:3000' : ''
|
||||
|
||||
export type DataRow = Record<string, number | string | null>
|
||||
|
||||
|
||||
@@ -14,20 +14,19 @@ const selectedMetric = ref<string | null>(null)
|
||||
let interval: ReturnType<typeof setInterval>
|
||||
|
||||
onMounted(async () => {
|
||||
await fetchAll()
|
||||
interval = setInterval(fetchAll, 60 * 1000)
|
||||
await fetchAll()
|
||||
interval = setInterval(fetchAll, 60 * 1000)
|
||||
})
|
||||
|
||||
onUnmounted(() => clearInterval(interval))
|
||||
|
||||
// Group all available metric keys present in current data
|
||||
const groupedMetrics = computed(() => {
|
||||
return GROUPS.map(group => ({
|
||||
group,
|
||||
keys: Object.entries(METRICS)
|
||||
.filter(([key, meta]) => meta.group === group && current.value[key] != null)
|
||||
.map(([key]) => key)
|
||||
})).filter(g => g.keys.length > 0)
|
||||
return GROUPS.map(group => ({
|
||||
group,
|
||||
keys: Object.entries(METRICS)
|
||||
.filter(([key, meta]) => meta.group === group && current.value[key] != null)
|
||||
.map(([key]) => key)
|
||||
})).filter(g => g.keys.length > 0)
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -96,50 +95,27 @@ const groupedMetrics = computed(() => {
|
||||
@media (max-width: 768px) {
|
||||
.dashboard {
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
overflow-y: auto;
|
||||
height: 100dvh;
|
||||
}
|
||||
|
||||
.map-col {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
height: 100vh;
|
||||
width: 100%;
|
||||
height: 100vw;
|
||||
min-height: 100vw;
|
||||
padding: 8px;
|
||||
z-index: 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.panel-col {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 10;
|
||||
width: 100%;
|
||||
flex-shrink: 0;
|
||||
border-left: none;
|
||||
border-top: 1px solid var(--border);
|
||||
border-radius: 16px 16px 0 0;
|
||||
|
||||
/* Show a peek of the panel, scrolls up over the map */
|
||||
max-height: calc(100vh - 120px);
|
||||
min-height: 40vh;
|
||||
overflow-y: auto;
|
||||
|
||||
background: var(--bg);
|
||||
padding: 12px 16px 24px;
|
||||
|
||||
/* Smooth momentum scroll on iOS */
|
||||
overflow-y: visible;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
display: block;
|
||||
width: 36px;
|
||||
height: 4px;
|
||||
background: var(--border);
|
||||
border-radius: 2px;
|
||||
margin: 0 auto 12px;
|
||||
}
|
||||
|
||||
&::before { display: none; }
|
||||
&::-webkit-scrollbar { display: none; }
|
||||
}
|
||||
}
|
||||
|
||||
49
server/airtraffic.mjs
Normal file
49
server/airtraffic.mjs
Normal file
@@ -0,0 +1,49 @@
|
||||
import { cfg } from './config.mjs'
|
||||
import { writeFile, readFile, existsSync } from 'fs'
|
||||
import { resolve, dirname } from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
|
||||
const DIR = dirname(fileURLToPath(import.meta.url))
|
||||
const SHAPES_PATH = resolve(DIR, 'shapes.json')
|
||||
|
||||
export async function getShapes() {
|
||||
if (existsSync(SHAPES_PATH)) {
|
||||
return JSON.parse(await readFile(SHAPES_PATH, 'utf8'))
|
||||
}
|
||||
|
||||
const r = await fetch('https://raw.githubusercontent.com/wiedehopf/tar1090/master/html/markers.min.js')
|
||||
const text = await r.text()
|
||||
|
||||
// tar1090 exposes a var with all the shape definitions
|
||||
let shapes = {}
|
||||
try {
|
||||
const match = text.match(/var\s+_aircraft_markers\s*=\s*(\{[\s\S]*?\});/)
|
||||
if (match) shapes = JSON.parse(match[1])
|
||||
} catch (e) {
|
||||
console.error('Failed to parse tar1090 shapes:', e)
|
||||
}
|
||||
|
||||
writeFile(SHAPES_PATH, JSON.stringify(shapes), () => {})
|
||||
return shapes
|
||||
}
|
||||
|
||||
export async function getAirTraffic() {
|
||||
const { ADSB_URL } = cfg()
|
||||
if (!ADSB_URL) return { data: [] }
|
||||
const r = await fetch(`${ADSB_URL}/re-api/?all`)
|
||||
const j = await r.json()
|
||||
return { data: j.aircraft || [] }
|
||||
}
|
||||
|
||||
export async function getAirTrafficHistory(icao) {
|
||||
const { ADSB_URL } = cfg()
|
||||
if (!ADSB_URL) return { history: [] }
|
||||
const r = await fetch(`${ADSB_URL}/re-api/?trace&icao=${icao}`)
|
||||
const j = await r.json()
|
||||
const history = (j.trace || []).map(t => ({
|
||||
latitude: t[1],
|
||||
longitude: t[2],
|
||||
altitude: t[3] || 0,
|
||||
}))
|
||||
return { history }
|
||||
}
|
||||
@@ -9,6 +9,7 @@ export function cfg() {
|
||||
config({ path: resolve(ROOT, '.env.local'), override: true })
|
||||
return {
|
||||
PORT: process.env.PORT || 3000,
|
||||
ADSB_URL: process.env.ADSB_URL || '',
|
||||
INFLUX_URL: process.env.INFLUX_URL || 'http://localhost:8086',
|
||||
INFLUX_TOKEN: process.env.INFLUX_TOKEN || '',
|
||||
INFLUX_ORG: process.env.INFLUX_ORG || 'weather',
|
||||
|
||||
BIN
server/public/favicon.png
Normal file
BIN
server/public/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 39 KiB |
@@ -9,6 +9,7 @@ import { getOpenMeteo } from './openmeteo.mjs'
|
||||
import { apiReference } from '@scalar/express-api-reference'
|
||||
import { spec } from './spec.mjs'
|
||||
import { existsSync } from 'fs'
|
||||
import {getAirTraffic, getAirTrafficHistory, getAirTrafficShapes} from './airtraffic.mjs';
|
||||
|
||||
const app = express()
|
||||
const DIR = dirname(fileURLToPath(import.meta.url))
|
||||
@@ -119,6 +120,11 @@ app.get('/api/daily', async (req, res) => {
|
||||
res.json(filterArr(result, fields))
|
||||
})
|
||||
|
||||
// -- ADSB ----------------------------------------------------------------------
|
||||
app.get('/api/air-traffic', async (req, res) => res.json(await getAirTraffic()))
|
||||
app.get('/api/air-traffic/:icao', async (req, res) => res.json(await getAirTrafficHistory(req.params.icao)))
|
||||
app.get('/api/air-traffic-shapes', async (req, res) => res.json(await getShapes()))
|
||||
|
||||
// ── DOCS ----------------------------------------------------------------------
|
||||
|
||||
app.get('/openapi.json', (req, res) => res.json(spec))
|
||||
|
||||
Reference in New Issue
Block a user