Show live OpenSky air traffic on the Overview Live map

Add GET /api/integrations/opensky/states: resolves the caller's OpenSky
cascade and returns trimmed aircraft state vectors (icao24, callsign,
country, lat/lng, heading, velocity, altitude, onGround) for their bbox.
Reuses the settings gates (global master / org / personal opt-in) and is
backed by a new Manager.InvokeWith that runs the plugin's states.bbox
action on a transient instance. Proxied through the Web App BFF.

DeviceMap now overlays these as rotatable plane markers (accent when
airborne, grey on ground) with tooltips; Dashboard polls every 30s while
Overview is visible and shows an aircraft count badge.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
tajniak81andClaude Opus 4.8 committed 2026-07-13 22:15:18 +02:00
1 parent 97c19083cf
commit a578555b43
11 files changed
+377 -46

No files matched your search

+62 -10
View File
@@ -1,12 +1,12 @@
<script setup>
import { ref, reactive, computed, onMounted, onBeforeUnmount } from 'vue'
import { ref, reactive, computed, watch, onMounted, onBeforeUnmount } from 'vue'
import DeviceMap from './DeviceMap.vue'
import BrandMark from './BrandMark.vue'
import Icon from './Icon.vue'
import Settings from './Settings.vue'
import Logbook from './Logbook.vue'
import Documents from './Documents.vue'
import { getDevices, sendCommand } from '../api.js'
import { getDevices, sendCommand, getOpenSkyStates } from '../api.js'
import { formatTime } from '../prefs.js'
const props = defineProps({
@@ -24,6 +24,34 @@ const live = ref(false)
const log = reactive([])
const search = ref('')
/* ---------- OpenSky live air traffic (Overview map overlay) ---------- */
const aircraft = ref([]) // [{ icao24, callsign, lat, lng, heading, velocity, altitude, onGround }]
const airspace = reactive({ unavailable: false, detail: '', loaded: false })
const airborneCount = computed(() => aircraft.value.filter((a) => !a.onGround).length)
let airTimer = null
async function refreshAirspace() {
const { states, unavailable, detail } = await getOpenSkyStates()
aircraft.value = states
airspace.unavailable = unavailable
airspace.detail = detail
airspace.loaded = true
}
// Poll OpenSky only while the Overview map is on screen, on a credit-friendly
// cadence (state vectors refresh at most every ~10s upstream anyway).
function startAirspace() {
if (airTimer) return
refreshAirspace()
airTimer = setInterval(() => {
if (active.value === 'Overview') refreshAirspace()
}, 30000)
}
function stopAirspace() {
if (airTimer) clearInterval(airTimer)
airTimer = null
}
const active = ref('Overview')
const NAV = [
['grid', 'Overview'],
@@ -248,14 +276,22 @@ function track(id) {
active.value = 'Live flights'
}
// Re-fetch air traffic immediately when the operator returns to the Overview tab,
// so the map isn't stale for up to a poll interval.
watch(active, (v) => {
if (v === 'Overview') refreshAirspace()
})
onMounted(async () => {
;(await getDevices()).forEach(upsert)
connect()
startAirspace()
})
onBeforeUnmount(() => {
stopped = true
if (retry) clearTimeout(retry)
if (ws) ws.close()
stopAirspace()
})
</script>
@@ -370,15 +406,31 @@ onBeforeUnmount(() => {
<div class="eyebrow">Airspace</div>
<div class="mt-0.5 text-base font-semibold text-ink">Live map</div>
</div>
<span
v-if="flyingCount"
class="inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-[11px] font-semibold"
:class="badgeClass.success"
>
<span class="h-1.5 w-1.5 rounded-full bg-current"></span>{{ flyingCount }} airborne
</span>
<div class="flex items-center gap-2">
<span
v-if="airborneCount"
class="inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-[11px] font-semibold"
:class="badgeClass.accent"
title="Live aircraft from OpenSky Network"
>
<Icon name="radio" :size="12" />{{ airborneCount }} aircraft
</span>
<span
v-if="flyingCount"
class="inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-[11px] font-semibold"
:class="badgeClass.success"
>
<span class="h-1.5 w-1.5 rounded-full bg-current"></span>{{ flyingCount }} drones
</span>
</div>
</div>
<DeviceMap :position="position" :trail="trail" />
<DeviceMap :position="position" :trail="trail" :aircraft="aircraft" />
<p v-if="airspace.loaded && airspace.unavailable" class="mt-2.5 text-xs text-ink-muted">
{{ airspace.detail || 'Live air traffic is unavailable.' }}
</p>
<p v-else class="mt-2.5 text-xs text-ink-muted">
Live air traffic from OpenSky Network · updates every 30s
</p>
</div>
<div class="panel p-5">
+94 -1
View File
@@ -1,14 +1,76 @@
<script setup>
import { onMounted, watch, ref } from 'vue'
import { onMounted, onBeforeUnmount, watch, ref } from 'vue'
import L from 'leaflet'
const props = defineProps({
position: { type: Object, default: null }, // { lat, lng }
trail: { type: Array, default: () => [] },
// Live OpenSky aircraft to overlay: [{ icao24, callsign, lat, lng, heading,
// velocity, altitude, onGround, country }, ...].
aircraft: { type: Array, default: () => [] },
})
const el = ref(null)
let map, marker, line
let planeLayer // L.LayerGroup holding all aircraft markers
const planes = new Map() // icao24 -> L.Marker (reused across refreshes)
// A small rotatable plane glyph rendered as a divIcon so it can be coloured via
// the app accent and pointed along each aircraft's true track.
function planeIcon(heading, onGround) {
const accent =
getComputedStyle(document.documentElement).getPropertyValue('--accent').trim() || '#3D7BF0'
const color = onGround ? '#8a94a6' : accent
const rot = typeof heading === 'number' ? heading : 0
return L.divIcon({
className: 'plane-marker',
iconSize: [22, 22],
iconAnchor: [11, 11],
html:
`<svg viewBox="0 0 24 24" width="22" height="22" style="transform:rotate(${rot}deg)">` +
`<path fill="${color}" stroke="rgba(0,0,0,.35)" stroke-width="0.5" ` +
`d="M12 2l1.4 6.9 7.6 4.3-.1 1.6-7.2-1.9-.4 4.8 2.1 1.5-.1 1.3L12 21l-3.3-.6-.1-1.3 2.1-1.5-.4-4.8-7.2 1.9-.1-1.6 7.6-4.3z"/>` +
`</svg>`,
})
}
function aircraftLabel(a) {
const name = a.callsign || a.icao24 || 'aircraft'
const bits = [`<strong>${name}</strong>`]
if (a.country) bits.push(a.country)
if (typeof a.altitude === 'number') bits.push(`${Math.round(a.altitude)} m`)
if (typeof a.velocity === 'number') bits.push(`${Math.round(a.velocity * 3.6)} km/h`)
if (a.onGround) bits.push('on ground')
return bits.join(' · ')
}
function drawAircraft() {
if (!map) return
if (!planeLayer) planeLayer = L.layerGroup().addTo(map)
const seen = new Set()
for (const a of props.aircraft) {
if (typeof a.lat !== 'number' || typeof a.lng !== 'number') continue
seen.add(a.icao24)
const pos = [a.lat, a.lng]
let m = planes.get(a.icao24)
if (!m) {
m = L.marker(pos, { icon: planeIcon(a.heading, a.onGround) }).bindTooltip(aircraftLabel(a))
m.addTo(planeLayer)
planes.set(a.icao24, m)
} else {
m.setLatLng(pos)
m.setIcon(planeIcon(a.heading, a.onGround))
m.setTooltipContent(aircraftLabel(a))
}
}
// Drop aircraft that have left the box since the last refresh.
for (const [id, m] of planes) {
if (!seen.has(id)) {
planeLayer.removeLayer(m)
planes.delete(id)
}
}
}
function draw() {
if (!map) return
@@ -38,10 +100,41 @@ onMounted(() => {
}).addTo(map)
setTimeout(() => map.invalidateSize(), 60)
draw()
drawAircraft()
// With no tracked device to zoom to, frame the live-traffic box on first data.
if ((!props.position || (!props.position.lat && !props.position.lng)) && props.aircraft.length) {
fitAircraft()
}
})
// Fit the view to the current aircraft cloud (used when there is no device fix).
let didFit = false
function fitAircraft() {
if (didFit || !map || !props.aircraft.length) return
const pts = props.aircraft
.filter((a) => typeof a.lat === 'number' && typeof a.lng === 'number')
.map((a) => [a.lat, a.lng])
if (pts.length) {
map.fitBounds(L.latLngBounds(pts).pad(0.2))
didFit = true
}
}
onBeforeUnmount(() => {
if (map) map.remove()
map = null
})
watch(() => props.position, draw, { deep: true })
watch(() => props.trail, draw, { deep: true })
watch(
() => props.aircraft,
() => {
drawAircraft()
if (!props.position || (!props.position.lat && !props.position.lng)) fitAircraft()
},
{ deep: true },
)
</script>
<template>