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>
143 lines
4.4 KiB
Vue
143 lines
4.4 KiB
Vue
<script setup>
|
|
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
|
|
const p = props.position
|
|
if (p && (p.lat || p.lng)) {
|
|
const pos = [p.lat, p.lng]
|
|
if (!marker) {
|
|
marker = L.marker(pos).addTo(map)
|
|
map.setView(pos, 17)
|
|
} else {
|
|
marker.setLatLng(pos)
|
|
}
|
|
}
|
|
if (line) line.remove()
|
|
if (props.trail.length) {
|
|
const accent =
|
|
getComputedStyle(document.documentElement).getPropertyValue('--accent').trim() || '#3D7BF0'
|
|
line = L.polyline(props.trail, { color: accent, weight: 3 }).addTo(map)
|
|
}
|
|
}
|
|
|
|
onMounted(() => {
|
|
map = L.map(el.value, { zoomControl: true }).setView([20, 0], 2)
|
|
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
|
attribution: '© OpenStreetMap',
|
|
maxZoom: 19,
|
|
}).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>
|
|
<div ref="el" class="h-[320px] w-full rounded-lg"></div>
|
|
</template>
|