614 lines
28 KiB
Vue
614 lines
28 KiB
Vue
<template>
|
|
<div class="flight-map-wrapper">
|
|
<div ref="mapContainer" class="map-container" />
|
|
<div v-if="!flights.length" class="empty-state">
|
|
<span class="mdi mdi-earth-off" />
|
|
<p>No flight data available</p>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script lang="ts">
|
|
import { defineComponent, ref, onMounted, onBeforeUnmount, watch, nextTick, PropType } from 'vue'
|
|
import maplibregl from 'maplibre-gl'
|
|
import 'maplibre-gl/dist/maplibre-gl.css'
|
|
import { SharedProps } from '@/Types/types'
|
|
import { usePage } from '@inertiajs/vue3'
|
|
import { Flight, Airport } from '@/Types/types'
|
|
|
|
interface RouteFlightBucket {
|
|
historical: Flight[]
|
|
future: Flight[]
|
|
}
|
|
|
|
type LngLat = [number, number]
|
|
|
|
interface RouteFeatureProperties {
|
|
color?: string
|
|
routeKey: string
|
|
depId: number
|
|
arrId: number
|
|
}
|
|
|
|
interface AirportFeatureProperties {
|
|
id: number
|
|
}
|
|
|
|
interface RoutesGeoJSON {
|
|
historical: GeoJSON.FeatureCollection<GeoJSON.LineString, RouteFeatureProperties>
|
|
future: GeoJSON.FeatureCollection<GeoJSON.LineString, RouteFeatureProperties>
|
|
}
|
|
|
|
|
|
function greatCircleGeoJSON(from: LngLat, to: LngLat, steps = 64): LngLat[] {
|
|
const toRad = (d: number): number => d * Math.PI / 180
|
|
const toDeg = (r: number): number => r * 180 / Math.PI
|
|
const lat1 = toRad(from[1]), lng1 = toRad(from[0])
|
|
const lat2 = toRad(to[1]), lng2 = toRad(to[0])
|
|
const d = 2 * Math.asin(Math.sqrt(
|
|
Math.sin((lat2 - lat1) / 2) ** 2 +
|
|
Math.cos(lat1) * Math.cos(lat2) * Math.sin((lng2 - lng1) / 2) ** 2
|
|
))
|
|
if (d === 0) return [[from[0], from[1]], [to[0], to[1]]]
|
|
const points: LngLat[] = []
|
|
for (let i = 0; i <= steps; i++) {
|
|
const f = i / steps
|
|
const A = Math.sin((1 - f) * d) / Math.sin(d)
|
|
const B = Math.sin(f * d) / Math.sin(d)
|
|
const x = A * Math.cos(lat1) * Math.cos(lng1) + B * Math.cos(lat2) * Math.cos(lng2)
|
|
const y = A * Math.cos(lat1) * Math.sin(lng1) + B * Math.cos(lat2) * Math.sin(lng2)
|
|
const z = A * Math.sin(lat1) + B * Math.sin(lat2)
|
|
points.push([toDeg(Math.atan2(y, x)), toDeg(Math.atan2(z, Math.sqrt(x * x + y * y)))])
|
|
}
|
|
for (let i = 1; i < points.length; i++) {
|
|
const diff = points[i][0] - points[i - 1][0]
|
|
if (diff > 180) points[i][0] -= 360
|
|
if (diff < -180) points[i][0] += 360
|
|
}
|
|
return points
|
|
}
|
|
|
|
function airportPopupHTML(airport: Airport): string {
|
|
const elevation = airport.elevation_ft !== null
|
|
? `<div class="ap-row"><span class="ap-label">Elevation</span><span class="ap-value">${airport.elevation_ft.toLocaleString()} ft</span></div>`
|
|
: ''
|
|
const city = airport.municipality
|
|
? `<div class="ap-row"><span class="ap-label">City</span><span class="ap-value">${airport.municipality}</span></div>`
|
|
: ''
|
|
const country = airport.region?.country
|
|
? `<div class="ap-row"><span class="ap-label">Country</span><span class="ap-value">${airport.region.country.name} <span class="fi fi-${airport.region.country.code.toLowerCase()}"></span></span></div>`
|
|
: ''
|
|
const iata = airport.iata_code ? `<span class="ap-badge">${airport.iata_code}</span>` : ''
|
|
const icao = airport.icao_code ? `<span class="ap-badge">${airport.icao_code}</span>` : ''
|
|
return `
|
|
<div class="ap-tooltip glass">
|
|
<div class="ap-header">
|
|
<div class="ap-name">${airport.name}</div>
|
|
<div class="ap-badges">${iata}${icao}</div>
|
|
</div>
|
|
<div class="ap-divider"></div>
|
|
<div class="ap-rows">
|
|
${city}${country}${elevation}
|
|
<div class="ap-row"><span class="ap-label">Timezone</span><span class="ap-value ap-mono">${airport.timezone}</span></div>
|
|
<div class="ap-row"><span class="ap-label">Coordinates</span><span class="ap-value ap-mono ap-muted">${airport.latitude_deg.toFixed(4)}, ${airport.longitude_deg.toFixed(4)}</span></div>
|
|
</div>
|
|
</div>`
|
|
}
|
|
|
|
function routePopupHTML(historical: Flight[], future: Flight[]): string {
|
|
const logoApiUrl = usePage<SharedProps>().props.logo_api_url
|
|
interface DirectionGroup {
|
|
label: string
|
|
airlines: string[]
|
|
}
|
|
|
|
const groupByDirection = (list: Flight[]): DirectionGroup[] => {
|
|
const dirs = new Map<string, DirectionGroup>()
|
|
list.forEach(f => {
|
|
const key = `${f.departure_airport.id}-${f.arrival_airport.id}`
|
|
const label = `${f.departure_airport.municipality} to ${f.arrival_airport.municipality}`
|
|
if (!dirs.has(key)) dirs.set(key, { label, airlines: [] })
|
|
const airline = `<span style="display:inline-flex;align-items:center;gap:6px;">
|
|
<img src="${logoApiUrl}/airlines/logos/tail/id/${f.airline?.id}" width="24" height="24" alt="${f.airline?.IATA_code}" style="flex-shrink:0;" />
|
|
${f.airline?.name}
|
|
</span>`
|
|
if (airline && !dirs.get(key)!.airlines.includes(airline)) {
|
|
dirs.get(key)!.airlines.push(airline)
|
|
}
|
|
})
|
|
return [...dirs.values()]
|
|
}
|
|
|
|
const renderSection = (title: string, list: Flight[]): string => {
|
|
if (!list.length) return ''
|
|
const rows = groupByDirection(list).map(({ label, airlines }) => `
|
|
<div class="rp-direction">
|
|
<div class="rp-route">${label}</div>
|
|
<div class="rp-airlines">${airlines.join('<br/>') || '—'}</div>
|
|
</div>`).join('')
|
|
return `<div class="rp-section"><div class="rp-section-title">${title}</div>${rows}</div>`
|
|
}
|
|
|
|
const divider = historical.length && future.length ? '<div class="rp-divider"></div>' : ''
|
|
return `
|
|
<div class="rp-tooltip glass">
|
|
${renderSection('Flown', historical)}
|
|
${divider}
|
|
${renderSection('Upcoming', future)}
|
|
</div>`
|
|
}
|
|
|
|
export default defineComponent({
|
|
name: 'FlightMap',
|
|
|
|
props: {
|
|
flights: {
|
|
type: Array as PropType<Flight[]>,
|
|
default: (): Flight[] => [],
|
|
},
|
|
},
|
|
|
|
setup(props) {
|
|
const mapContainer = ref<HTMLDivElement | null>(null)
|
|
let map: maplibregl.Map | null = null
|
|
let popup: maplibregl.Popup | null = null
|
|
let pulseFrame: number | null = null
|
|
|
|
const PULSE_PHASES = 6
|
|
const TOUCH_RADIUS = 20
|
|
const airportById = new Map<number, Airport>()
|
|
const routeFlights = new Map<string, RouteFlightBucket>()
|
|
let selectedAirportId: number | null = null
|
|
|
|
const isTouchDevice = (): boolean => window.matchMedia('(pointer: coarse)').matches
|
|
|
|
const showPopup = (lngLat: maplibregl.LngLatLike, html: string): void => {
|
|
popup!.setLngLat(lngLat).setHTML(html).addTo(map!)
|
|
}
|
|
|
|
// ── Filter ────────────────────────────────────────────────────────────
|
|
const applyFilter = (): void => {
|
|
if (!map || !map.isStyleLoaded()) return
|
|
const id = selectedAirportId
|
|
const filter: maplibregl.FilterSpecification | null = id
|
|
? ['any', ['==', ['get', 'depId'], id], ['==', ['get', 'arrId'], id]]
|
|
: null
|
|
|
|
map.setFilter('routes-line', filter)
|
|
map.setFilter('routes-hit', filter)
|
|
map.setFilter('routes-future-line', filter)
|
|
map.setFilter('routes-future-hit', filter)
|
|
|
|
for (let i = 0; i < PULSE_PHASES; i++) {
|
|
map.setPaintProperty(`airports-dot-${i}`, 'circle-opacity',
|
|
id ? ['case', ['==', ['get', 'id'], id], 1, 0.25] : 1)
|
|
map.setPaintProperty(`airports-dot-${i}`, 'circle-stroke-opacity',
|
|
id ? ['case', ['==', ['get', 'id'], id], 1, 0.25] : 1)
|
|
map.setPaintProperty(`airports-pulse-${i}`, 'circle-stroke-opacity',
|
|
id ? ['case', ['==', ['get', 'id'], id], 0.6, 0] : 0.6)
|
|
}
|
|
}
|
|
|
|
// ── Route flight lookup ───────────────────────────────────────────────
|
|
const buildRouteFlights = (): void => {
|
|
routeFlights.clear()
|
|
const now = new Date()
|
|
props.flights.forEach((flight) => {
|
|
const key = [flight.departure_airport.id, flight.arrival_airport.id].sort().join('-')
|
|
if (!routeFlights.has(key)) routeFlights.set(key, { historical: [], future: [] })
|
|
routeFlights.get(key)![new Date(flight.departure_date) > now ? 'future' : 'historical'].push(flight)
|
|
})
|
|
}
|
|
|
|
// ── GeoJSON builders ──────────────────────────────────────────────────
|
|
const buildRoutesGeoJSON = (): RoutesGeoJSON => {
|
|
buildRouteFlights()
|
|
const now = new Date()
|
|
|
|
const routeCounts = new Map<string, number>()
|
|
props.flights.forEach((flight) => {
|
|
if (new Date(flight.departure_date) > now) return
|
|
const key = [flight.departure_airport.id, flight.arrival_airport.id].sort().join('-')
|
|
routeCounts.set(key, (routeCounts.get(key) ?? 0) + 1)
|
|
})
|
|
|
|
const routeColor = (count: number): string => {
|
|
if (count >= 5) return '#f97316'
|
|
if (count >= 3) return '#eab308'
|
|
if (count >= 2) return '#22c55e'
|
|
return '#a150d5'
|
|
}
|
|
|
|
const historicalFeatures: GeoJSON.Feature<GeoJSON.LineString, RouteFeatureProperties>[] =
|
|
props.flights
|
|
.filter(f => new Date(f.departure_date) <= now)
|
|
.map((flight) => {
|
|
const key = [flight.departure_airport.id, flight.arrival_airport.id].sort().join('-')
|
|
const count = routeCounts.get(key) ?? 1
|
|
return {
|
|
type: 'Feature',
|
|
properties: {
|
|
color: routeColor(count), routeKey: key,
|
|
depId: flight.departure_airport.id,
|
|
arrId: flight.arrival_airport.id,
|
|
},
|
|
geometry: {
|
|
type: 'LineString',
|
|
coordinates: greatCircleGeoJSON(
|
|
[flight.departure_airport.longitude_deg, flight.departure_airport.latitude_deg],
|
|
[flight.arrival_airport.longitude_deg, flight.arrival_airport.latitude_deg],
|
|
),
|
|
},
|
|
}
|
|
})
|
|
|
|
const historicalKeys = new Set(routeCounts.keys())
|
|
const futureFeatures: GeoJSON.Feature<GeoJSON.LineString, RouteFeatureProperties>[] =
|
|
props.flights
|
|
.filter((flight) => {
|
|
if (new Date(flight.departure_date) <= now) return false
|
|
const key = [flight.departure_airport.id, flight.arrival_airport.id].sort().join('-')
|
|
return !historicalKeys.has(key)
|
|
})
|
|
.map((flight) => {
|
|
const key = [flight.departure_airport.id, flight.arrival_airport.id].sort().join('-')
|
|
return {
|
|
type: 'Feature',
|
|
properties: { routeKey: key, depId: flight.departure_airport.id, arrId: flight.arrival_airport.id },
|
|
geometry: {
|
|
type: 'LineString',
|
|
coordinates: greatCircleGeoJSON(
|
|
[flight.departure_airport.longitude_deg, flight.departure_airport.latitude_deg],
|
|
[flight.arrival_airport.longitude_deg, flight.arrival_airport.latitude_deg],
|
|
),
|
|
},
|
|
}
|
|
})
|
|
|
|
return {
|
|
historical: { type: 'FeatureCollection', features: historicalFeatures },
|
|
future: { type: 'FeatureCollection', features: futureFeatures },
|
|
}
|
|
}
|
|
|
|
const buildAirportsGeoJSON = (): GeoJSON.FeatureCollection<GeoJSON.Point, AirportFeatureProperties>[] => {
|
|
const seen = new Map<number, Airport>()
|
|
props.flights.forEach(({ departure_airport: dep, arrival_airport: arr }) => {
|
|
if (!seen.has(dep.id)) seen.set(dep.id, dep)
|
|
if (!seen.has(arr.id)) seen.set(arr.id, arr)
|
|
})
|
|
const buckets: Airport[][] = Array.from({ length: PULSE_PHASES }, () => [])
|
|
;[...seen.values()].forEach(airport => buckets[airport.id % PULSE_PHASES].push(airport))
|
|
return buckets.map(airports => ({
|
|
type: 'FeatureCollection',
|
|
features: airports.map(airport => ({
|
|
type: 'Feature',
|
|
properties: { id: airport.id },
|
|
geometry: { type: 'Point', coordinates: [airport.longitude_deg, airport.latitude_deg] },
|
|
})),
|
|
}))
|
|
}
|
|
|
|
// ── Map layers ────────────────────────────────────────────────────────
|
|
const addLayers = (): void => {
|
|
const { historical, future } = buildRoutesGeoJSON()
|
|
|
|
map!.addSource('routes', { type: 'geojson', data: historical })
|
|
map!.addLayer({
|
|
id: 'routes-line', type: 'line', source: 'routes',
|
|
paint: { 'line-color': ['get', 'color'], 'line-opacity': 0.75, 'line-width': 1.8 },
|
|
layout: { 'line-join': 'round', 'line-cap': 'round' },
|
|
})
|
|
map!.addLayer({
|
|
id: 'routes-hit', type: 'line', source: 'routes',
|
|
paint: { 'line-color': 'transparent', 'line-width': 12 },
|
|
})
|
|
|
|
map!.addSource('routes-future', { type: 'geojson', data: future })
|
|
map!.addLayer({
|
|
id: 'routes-future-line', type: 'line', source: 'routes-future',
|
|
paint: { 'line-color': '#ffffff', 'line-opacity': 0.5, 'line-width': 1.5, 'line-dasharray': [3, 3] },
|
|
layout: { 'line-join': 'round', 'line-cap': 'round' },
|
|
})
|
|
map!.addLayer({
|
|
id: 'routes-future-hit', type: 'line', source: 'routes-future',
|
|
paint: { 'line-color': 'transparent', 'line-width': 12 },
|
|
})
|
|
|
|
const airportBuckets = buildAirportsGeoJSON()
|
|
airportBuckets.forEach((data, i) => {
|
|
map!.addSource(`airports-${i}`, { type: 'geojson', data })
|
|
map!.addLayer({
|
|
id: `airports-pulse-${i}`, type: 'circle', source: `airports-${i}`,
|
|
paint: {
|
|
'circle-radius': 8, 'circle-color': 'transparent',
|
|
'circle-stroke-width': 1.5, 'circle-stroke-color': 'rgba(77,166,255,0.6)',
|
|
'circle-stroke-opacity': 0.6,
|
|
},
|
|
})
|
|
map!.addLayer({
|
|
id: `airports-dot-${i}`, type: 'circle', source: `airports-${i}`,
|
|
paint: {
|
|
'circle-radius': 5, 'circle-color': '#4da6ff',
|
|
'circle-stroke-width': 1.5, 'circle-stroke-color': 'rgba(255,255,255,0.9)',
|
|
},
|
|
})
|
|
})
|
|
|
|
const isTouch = isTouchDevice()
|
|
popup = new maplibregl.Popup({
|
|
closeButton: isTouch,
|
|
closeOnClick: false,
|
|
className: 'ap-popup',
|
|
maxWidth: 'none',
|
|
offset: 12,
|
|
})
|
|
|
|
const airportLayerIds = Array.from({ length: PULSE_PHASES }, (_, i) => `airports-dot-${i}`)
|
|
|
|
// ── Desktop hover events ──────────────────────────────────────────
|
|
if (!isTouch) {
|
|
for (let i = 0; i < PULSE_PHASES; i++) {
|
|
map!.on('mouseenter', `airports-dot-${i}`, (e) => {
|
|
map!.getCanvas().style.cursor = 'pointer'
|
|
const airport = airportById.get(Number(e.features![0].properties.id))
|
|
const geom = e.features![0].geometry
|
|
if (airport && geom.type === 'Point') {
|
|
showPopup(geom.coordinates as LngLat, airportPopupHTML(airport))
|
|
}
|
|
})
|
|
map!.on('mouseleave', `airports-dot-${i}`, () => {
|
|
map!.getCanvas().style.cursor = ''
|
|
popup!.remove()
|
|
})
|
|
}
|
|
|
|
map!.on('mouseenter', 'routes-hit', (e) => {
|
|
map!.getCanvas().style.cursor = 'pointer'
|
|
const rf = routeFlights.get(e.features![0].properties.routeKey as string)
|
|
if (rf) showPopup(e.lngLat, routePopupHTML(rf.historical, rf.future))
|
|
})
|
|
map!.on('mouseleave', 'routes-hit', () => {
|
|
map!.getCanvas().style.cursor = ''
|
|
popup!.remove()
|
|
})
|
|
|
|
map!.on('mouseenter', 'routes-future-hit', (e) => {
|
|
map!.getCanvas().style.cursor = 'pointer'
|
|
const rf = routeFlights.get(e.features![0].properties.routeKey as string)
|
|
if (rf) showPopup(e.lngLat, routePopupHTML(rf.historical, rf.future))
|
|
})
|
|
map!.on('mouseleave', 'routes-future-hit', () => {
|
|
map!.getCanvas().style.cursor = ''
|
|
popup!.remove()
|
|
})
|
|
}
|
|
|
|
// ── Unified click handler — airports take priority over routes ────
|
|
map!.on('click', (e) => {
|
|
// Query airports with a larger bounding box on touch for easier tapping
|
|
const airportQuery = isTouch
|
|
? [[e.point.x - TOUCH_RADIUS, e.point.y - TOUCH_RADIUS], [e.point.x + TOUCH_RADIUS, e.point.y + TOUCH_RADIUS]] as [maplibregl.PointLike, maplibregl.PointLike]
|
|
: e.point as maplibregl.PointLike
|
|
|
|
const airportFeatures = map!.queryRenderedFeatures(airportQuery, { layers: airportLayerIds })
|
|
|
|
if (airportFeatures.length) {
|
|
const id = Number(airportFeatures[0].properties.id)
|
|
selectedAirportId = selectedAirportId === id ? null : id
|
|
applyFilter()
|
|
|
|
if (isTouch) {
|
|
const airport = airportById.get(id)
|
|
const geom = airportFeatures[0].geometry
|
|
if (airport && geom.type === 'Point') {
|
|
showPopup(geom.coordinates as LngLat, airportPopupHTML(airport))
|
|
}
|
|
}
|
|
return
|
|
}
|
|
|
|
// Then check routes
|
|
const routeFeatures = map!.queryRenderedFeatures(e.point, {
|
|
layers: ['routes-hit', 'routes-future-hit'],
|
|
})
|
|
|
|
if (routeFeatures.length) {
|
|
const rf = routeFlights.get(routeFeatures[0].properties.routeKey as string)
|
|
if (rf) showPopup(e.lngLat, routePopupHTML(rf.historical, rf.future))
|
|
return
|
|
}
|
|
|
|
// Empty map click — deselect everything
|
|
selectedAirportId = null
|
|
applyFilter()
|
|
popup!.remove()
|
|
})
|
|
|
|
// ── Pulse animation ───────────────────────────────────────────────
|
|
const period = 2200
|
|
const animate = (): void => {
|
|
const now = Date.now()
|
|
for (let i = 0; i < PULSE_PHASES; i++) {
|
|
const t = ((now + (i / PULSE_PHASES) * period) % period) / period
|
|
map!.setPaintProperty(`airports-pulse-${i}`, 'circle-radius', 5 + t * 13)
|
|
if (!selectedAirportId) {
|
|
map!.setPaintProperty(`airports-pulse-${i}`, 'circle-stroke-opacity', 0.7 * (1 - t))
|
|
}
|
|
}
|
|
pulseFrame = requestAnimationFrame(animate)
|
|
}
|
|
animate()
|
|
}
|
|
|
|
const fitBounds = (): void => {
|
|
if (!props.flights.length) return
|
|
const lngs = props.flights.flatMap(f => [f.departure_airport.longitude_deg, f.arrival_airport.longitude_deg])
|
|
const lats = props.flights.flatMap(f => [f.departure_airport.latitude_deg, f.arrival_airport.latitude_deg])
|
|
map!.fitBounds(
|
|
[[Math.min(...lngs), Math.min(...lats)], [Math.max(...lngs), Math.max(...lats)]],
|
|
{ padding: 60, duration: 0 },
|
|
)
|
|
}
|
|
|
|
// ── Map init ──────────────────────────────────────────────────────────
|
|
const initMap = (): void => {
|
|
nextTick(() => {
|
|
map = new maplibregl.Map({
|
|
container: mapContainer.value!,
|
|
style: {
|
|
version: 8,
|
|
sources: {
|
|
'carto-dark': {
|
|
type: 'raster',
|
|
tiles: [
|
|
'https://a.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}@2x.png',
|
|
'https://b.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}@2x.png',
|
|
'https://c.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}@2x.png',
|
|
'https://d.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}@2x.png',
|
|
],
|
|
tileSize: 256,
|
|
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> © <a href="https://carto.com/attributions">CARTO</a>',
|
|
maxzoom: 19,
|
|
},
|
|
},
|
|
layers: [{ id: 'carto-dark', type: 'raster', source: 'carto-dark' }],
|
|
},
|
|
center: [0, 20],
|
|
zoom: 2,
|
|
renderWorldCopies: true,
|
|
})
|
|
map.addControl(new maplibregl.NavigationControl({ showCompass: false }), 'top-right')
|
|
map.on('load', () => { addLayers(); fitBounds() })
|
|
})
|
|
}
|
|
|
|
// ── Data updates ──────────────────────────────────────────────────────
|
|
const updateData = (): void => {
|
|
if (!map || !map.isStyleLoaded()) return
|
|
airportById.clear()
|
|
props.flights.forEach(({ departure_airport: dep, arrival_airport: arr }) => {
|
|
airportById.set(dep.id, dep)
|
|
airportById.set(arr.id, arr)
|
|
})
|
|
const { historical, future } = buildRoutesGeoJSON();
|
|
(map.getSource('routes') as maplibregl.GeoJSONSource)?.setData(historical);
|
|
(map.getSource('routes-future') as maplibregl.GeoJSONSource)?.setData(future)
|
|
buildAirportsGeoJSON().forEach((data, i) =>
|
|
(map!.getSource(`airports-${i}`) as maplibregl.GeoJSONSource)?.setData(data)
|
|
)
|
|
fitBounds()
|
|
}
|
|
|
|
onMounted(() => {
|
|
props.flights.forEach(({ departure_airport: dep, arrival_airport: arr }) => {
|
|
airportById.set(dep.id, dep)
|
|
airportById.set(arr.id, arr)
|
|
})
|
|
initMap()
|
|
})
|
|
|
|
watch(() => props.flights, updateData, { deep: true })
|
|
|
|
onBeforeUnmount(() => {
|
|
if (pulseFrame !== null) cancelAnimationFrame(pulseFrame)
|
|
if (map) { map.remove(); map = null }
|
|
})
|
|
|
|
return { mapContainer }
|
|
},
|
|
})
|
|
</script>
|
|
|
|
<style scoped>
|
|
.flight-map-wrapper {
|
|
position: relative;
|
|
width: 100%;
|
|
aspect-ratio: 16 / 9;
|
|
}
|
|
|
|
.map-container {
|
|
width: 100%;
|
|
height: 100%;
|
|
}
|
|
|
|
.empty-state {
|
|
position: absolute;
|
|
inset: 0;
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
justify-content: center;
|
|
color: #3a4a58;
|
|
gap: 12px;
|
|
z-index: 900;
|
|
pointer-events: none;
|
|
}
|
|
|
|
.empty-state .mdi { font-size: 48px; }
|
|
.empty-state p { font-size: 13px; letter-spacing: 0.1em; text-transform: uppercase; margin: 0; }
|
|
</style>
|
|
|
|
<style>
|
|
.ap-popup .maplibregl-popup-content {
|
|
background: transparent !important;
|
|
border: none; border-radius: 0; padding: 0;
|
|
box-shadow: none; backdrop-filter: none;
|
|
color: #c8cdd8; width: max-content;
|
|
}
|
|
.ap-popup .maplibregl-popup-tip { border-top-color: rgba(10,14,22,0.95); }
|
|
|
|
.ap-popup .maplibregl-popup-close-button {
|
|
color: #556677;
|
|
font-size: 18px;
|
|
padding: 4px 8px;
|
|
right: 4px;
|
|
top: 4px;
|
|
line-height: 1;
|
|
background: transparent;
|
|
border: none;
|
|
}
|
|
.ap-popup .maplibregl-popup-close-button:hover { color: #c8cdd8; }
|
|
|
|
.ap-tooltip { padding: 12px 14px; display: flex; flex-direction: column; gap: 8px; }
|
|
.ap-header { display: flex; flex-direction: column; gap: 6px; }
|
|
.ap-name { font-size: 1.2em; color: #e0e6f0; line-height: 1.3; white-space: nowrap; }
|
|
.ap-badges { display: flex; gap: 4px; flex-wrap: wrap; }
|
|
.ap-badge {
|
|
font-family: 'Share Tech Mono', monospace;
|
|
font-size: 0.65rem; letter-spacing: 0.1em;
|
|
background: rgba(77,166,255,0.12); border: 1px solid rgba(77,166,255,0.25);
|
|
color: #4da6ff; border-radius: 3px; padding: 1px 6px;
|
|
}
|
|
.ap-divider { height: 1px; background: rgba(255,255,255,0.08); }
|
|
.ap-rows { display: flex; flex-direction: column; gap: 4px; }
|
|
.ap-row { display: flex; justify-content: space-between; align-items: baseline; gap: 12px; }
|
|
.ap-label {
|
|
font-family: 'Share Tech Mono', monospace;
|
|
font-size: 0.6rem; letter-spacing: 0.15em; color: #445566;
|
|
text-transform: uppercase; flex-shrink: 0;
|
|
}
|
|
.ap-value { font-size: 0.78rem; color: #c8cdd8; text-align: right; }
|
|
.ap-mono { font-family: 'Share Tech Mono', monospace; font-size: 0.7rem; letter-spacing: 0.03em; }
|
|
.ap-muted { font-family: 'Share Tech Mono', monospace; font-size: 0.68rem; color: #556677; letter-spacing: 0.03em; }
|
|
.ap-value .fi { display: inline-block; vertical-align: middle; }
|
|
|
|
.rp-tooltip { padding: 10px 12px; display: flex; flex-direction: column; gap: 6px; min-width: 160px; }
|
|
.rp-section { display: flex; flex-direction: column; gap: 6px; }
|
|
.rp-section-title {
|
|
font-family: 'Share Tech Mono', monospace;
|
|
font-size: 0.65rem; letter-spacing: 0.12em;
|
|
text-transform: uppercase; color: #c8cdd8; margin-bottom: 2px;
|
|
}
|
|
.rp-direction { display: flex; flex-direction: column; gap: 2px; }
|
|
.rp-route { font-size: 0.82rem; color: #e0e6f0; }
|
|
.rp-airlines { font-size: 0.75rem; color: #778899; }
|
|
.rp-divider { height: 1px; background: rgba(255,255,255,0.08); margin: 2px 0; }
|
|
|
|
.maplibregl-ctrl-group { background: rgba(10,14,22,0.85) !important; border: 1px solid rgba(255,255,255,0.08) !important; }
|
|
.maplibregl-ctrl-group button { color: #a0b4c8 !important; }
|
|
.maplibregl-ctrl-group button:hover { background: rgba(77,166,255,0.15) !important; color: #4da6ff !important; }
|
|
.maplibregl-ctrl-attrib { background: rgba(10,14,22,0.7) !important; color: #666 !important; }
|
|
.maplibregl-ctrl-attrib a { color: #666 !important; }
|
|
</style>
|