Files
FlightsAPI/resources/js/Components/FlightsGoneBy/FlightMap.vue
T
2026-06-20 22:21:17 +10:00

950 lines
39 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="flight-map-wrapper">
<PlaneLoader v-if="!mapReady" class="map-loader" />
<div ref="mapContainer" class="map-container" :class="{ 'map-hidden': !mapReady }" />
<button @click="exportMapBasic()" class="export-btn" title="Download as Image">
<span class="mdi mdi-download" />
</button>
<button class="projection-btn" @click="toggleProjection" :title="isGlobe ? 'Switch to flat map' : 'Switch to globe'">
<span class="mdi" :class="isGlobe ? 'mdi-map' : 'mdi-earth'"/>
</button>
<div v-if="!flights.length" class="empty-state">
<span class="mdi mdi-earth-off" />
<p>No flight data available</p>
</div>
<div v-if="showLegend" class="map-legend" :class="{ 'map-legend--open': legendOpen }">
<button class="map-legend__toggle" @click="toggleLegend">
<span class="mdi mdi-format-list-bulleted" />
<span class="map-legend__toggle-label">Legend</span>
<span class="mdi" :class="legendOpen ? 'mdi-chevron-down' : 'mdi-chevron-up'" />
</button>
<div class="map-legend__body">
<div class="map-legend__row" v-for="item in legendItems" :key="item.label">
<svg width="28" height="10" class="map-legend__swatch">
<line
x1="2" y1="5" x2="26" y2="5"
:stroke="item.color"
:stroke-dasharray="item.dashed ? '4 3' : undefined"
stroke-width="2"
stroke-linecap="round"
/>
</svg>
<span class="map-legend__label">{{ item.label }}</span>
</div>
</div>
</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 { Flight, Airport, SharedProps } from '@/Types/types'
import PlaneLoader from '@/Components/FlightsGoneBy/PlaneLoader.vue'
import type { Feature, FeatureCollection, LineString, Point } from 'geojson'
import {useUpdateSetting} from "@/Composables/useUpdateSetting";
import {usePage} from "@inertiajs/vue3";
type LngLat = [number, number]
interface RouteFlightBucket {
historical: Flight[]
future: Flight[]
}
interface RouteFeatureProperties {
color?: string
routeKey: string
depId: number
arrId: number
}
interface AirportFeatureProperties {
id: number
}
interface RoutesGeoJSON {
historical: FeatureCollection<LineString, RouteFeatureProperties>
future: FeatureCollection<LineString, RouteFeatureProperties>
}
// ── Constants ─────────────────────────────────────────────────────────────────
const PULSE_PHASES = 6
const PULSE_PERIOD = 2200
const TOUCH_RADIUS = 20
const ROUTE_COLORS: [number, string][] = [
[5, '#f97316'],
[3, '#eab308'],
[2, '#22c55e'],
[1, '#a150d5'],
]
// ── Helpers ───────────────────────────────────────────────────────────────────
function routeKey(a: { id: number }, b: { id: number }): string {
return [a.id, b.id].sort().join('-')
}
function routeColor(flightCount: number): string {
return ROUTE_COLORS.find(([min]) => flightCount >= min)![1]
}
function isTouchDevice(): boolean {
return window.matchMedia('(pointer: coarse)').matches
}
function greatCirclePoints(from: LngLat, to: LngLat, steps?: number): LngLat[] {
const dist = Math.sqrt((to[0] - from[0]) ** 2 + (to[1] - from[1]) ** 2)
const s = steps ?? (dist > 60 ? 64 : dist > 20 ? 32 : 16)
const toRad = (d: number) => d * Math.PI / 180
const toDeg = (r: number) => r * 180 / Math.PI
const lat1 = toRad(from[1]), lng1 = toRad(from[0])
const lat2 = toRad(to[1]), lng2 = toRad(to[0])
const angularDist = 2 * Math.asin(Math.sqrt(
Math.sin((lat2 - lat1) / 2) ** 2 +
Math.cos(lat1) * Math.cos(lat2) * Math.sin((lng2 - lng1) / 2) ** 2
))
if (angularDist === 0) return [from, to]
const points: LngLat[] = []
for (let i = 0; i <= s; i++) {
const f = i / s
const A = Math.sin((1 - f) * angularDist) / Math.sin(angularDist)
const B = Math.sin(f * angularDist) / Math.sin(angularDist)
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)))])
}
// Unwrap antimeridian crossings
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
}
// ── Popup HTML ────────────────────────────────────────────────────────────────
function airportPopupHTML(airport: Airport): string {
const row = (label: string, value: string) =>
`<div class="ap-row"><span class="ap-label">${label}</span><span class="ap-value">${value}</span></div>`
const elevation = airport.elevation_ft !== null
? row('Elevation', `${airport.elevation_ft.toLocaleString()} ft`) : ''
const city = airport.municipality
? row('City', airport.municipality) : ''
const country = airport.region?.country
? row('Country', `${airport.region.country.name} <span class="fi fi-${airport.region.country.code.toLowerCase()}"></span>`) : ''
const badges = [airport.iata_code, airport.icao_code]
.filter(Boolean)
.map(code => `<span class="ap-badge">${code}</span>`)
.join('')
return `
<div class="ap-tooltip glass">
<div class="ap-header">
<div class="ap-name">${airport.name}</div>
<div class="ap-badges">${badges}</div>
</div>
<div class="ap-divider"></div>
<div class="ap-rows">
${city}${country}${elevation}
${row('Timezone', `<span class="ap-mono">${airport.timezone}</span>`)}
${row('Coordinates', `<span class="ap-mono ap-muted">${airport.latitude_deg.toFixed(4)}, ${airport.longitude_deg.toFixed(4)}</span>`)}
</div>
</div>`
}
function routePopupHTML(historical: Flight[], future: Flight[]): string {
interface AirlineEntry { html: string; count: number }
interface DirectionGroup { label: string; airlines: Map<string, AirlineEntry> }
const groupByDirection = (flights: Flight[]): DirectionGroup[] => {
const groups = new Map<string, DirectionGroup>()
flights.forEach(flight => {
const key = `${flight.departure_airport.id}-${flight.arrival_airport.id}`
const label = `${flight.departure_airport.municipality} to ${flight.arrival_airport.municipality}`
if (!groups.has(key)) groups.set(key, { label, airlines: new Map() })
if (!flight.airline) return
const { iata_code, logo_url, name } = flight.airline
const airlineKey = iata_code ?? ''
const airlines = groups.get(key)!.airlines
if (airlines.has(airlineKey)) {
airlines.get(airlineKey)!.count++
} else {
airlines.set(airlineKey, {
html: `<span style="display:inline-flex;align-items:center;gap:6px;">
<img src="${logo_url}" width="24" height="24" alt="${iata_code}" style="flex-shrink:0;" />
${name}
</span>`,
count: 1,
})
}
})
return [...groups.values()]
}
const renderSection = (title: string, flights: Flight[]): string => {
if (!flights.length) return ''
const rows = groupByDirection(flights).map(({ label, airlines }) => {
const airlineLines = [...airlines.values()]
.map(({ html, count }) => count > 1
? `<span style="display:inline-flex;align-items:center;gap:4px;">${html}<span style="color:#556677">(x${count})</span></span>`
: html)
.join('<br/>')
return `
<div class="rp-direction">
<div class="rp-route">${label}</div>
<div class="rp-airlines">${airlineLines || '—'}</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>`
}
// ── Component ─────────────────────────────────────────────────────────────────
export default defineComponent({
name: 'FlightMap',
components: { PlaneLoader },
props: {
flights: {
type: Array as PropType<Flight[]>,
default: (): Flight[] => [],
},
showLegend: {
type: Boolean,
default: true,
}
},
setup(props) {
const mapContainer = ref<HTMLDivElement | null>(null)
const mapReady = ref(false)
const { updateSetting } = useUpdateSetting()
const page = usePage<SharedProps>().props
let map: maplibregl.Map | null = null
let popup: maplibregl.Popup | null = null
let pulseFrame: number | null = null
const airportById = new Map<number, Airport>()
const routeFlights = new Map<string, RouteFlightBucket>()
const arcCache = new Map<string, LngLat[]>()
let selectedAirportId: number | null = null
const legendOpen = ref(page.auth?.user?.resolved_settings?.show_map_legend ?? true)
function toggleLegend() {
legendOpen.value = !legendOpen.value
updateSetting('show_map_legend', legendOpen.value).catch(() => {})
}
const isGlobe = ref(false)
const toggleProjection = (): void => {
if (!map?.isStyleLoaded()) return
isGlobe.value = !isGlobe.value
map.setProjection({ type: isGlobe.value ? 'globe' : 'mercator' })
}
const legendItems = [
{ color: '#f97316', label: '5+ flights' },
{ color: '#eab308', label: '34 flights' },
{ color: '#22c55e', label: '2 flights' },
{ color: '#a150d5', label: '1 flight' },
{ color: '#ffffff', label: 'Upcoming', dashed: true },
]
// ── Arc cache ─────────────────────────────────────────────────────────
const exportMap = (targetWidth = 7680, targetHeight = 4320): void => {
if (!map) return
const container = map.getContainer()
const originalWidth = container.offsetWidth
const originalHeight = container.offsetHeight
const currentCenter = map.getCenter()
const currentZoom = map.getZoom()
container.style.width = `${targetWidth}px`
container.style.height = `${targetHeight}px`
map.resize()
map.jumpTo({ center: [100, 20], zoom: 2 })// low zoom, centered roughly on your data
setTimeout(() => {
map!.triggerRepaint()
requestAnimationFrame(() => {
requestAnimationFrame(() => {
const dataURL = map!.getCanvas().toDataURL('image/jpeg', 0.95)
const link = document.createElement('a')
link.href = dataURL
link.download = 'flight-map.jpg'
link.click()
container.style.width = `${originalWidth}px`
container.style.height = `${originalHeight}px`
map!.resize()
map!.jumpTo({ center: currentCenter, zoom: currentZoom })
})
})
}, 10000)
}
const exportMapBasic = (): void => {
if (!map) return
const canvas = map.getCanvas()
const link = document.createElement('a')
link.href = canvas.toDataURL('image/jpeg', 0.95)
link.download = 'flight-map.jpg'
link.click()
}
const getArc = (flight: Flight): LngLat[] => {
const key = routeKey(flight.departure_airport, flight.arrival_airport)
if (!arcCache.has(key)) {
arcCache.set(key, greatCirclePoints(
[flight.departure_airport.longitude_deg, flight.departure_airport.latitude_deg],
[flight.arrival_airport.longitude_deg, flight.arrival_airport.latitude_deg],
))
}
return arcCache.get(key)!
}
// ── Popup ─────────────────────────────────────────────────────────────
const showPopup = (lngLat: maplibregl.LngLatLike, html: string) =>
popup!.setLngLat(lngLat).setHTML(html).addTo(map!)
// ── Filter ────────────────────────────────────────────────────────────
const applyFilter = (): void => {
if (!map?.isStyleLoaded()) return
const id = selectedAirportId
const routeFilter: maplibregl.FilterSpecification | null = id
? ['any', ['==', ['get', 'depId'], id], ['==', ['get', 'arrId'], id]]
: null
for (const layerId of ['routes-line', 'routes-hit', 'routes-future-line', 'routes-future-hit']) {
map.setFilter(layerId, routeFilter)
}
// Build the set of airports that should remain visible
let visibleAirportIds: Set<number> | null = null
if (id) {
visibleAirportIds = new Set([id])
routeFlights.forEach((bucket, key) => {
const allFlights = [...bucket.historical, ...bucket.future]
if (allFlights.some(f =>
f.departure_airport.id === id || f.arrival_airport.id === id
)) {
allFlights.forEach(f => {
visibleAirportIds!.add(f.departure_airport.id)
visibleAirportIds!.add(f.arrival_airport.id)
})
}
})
}
const airportFilter: maplibregl.FilterSpecification | null = visibleAirportIds
? ['in', ['get', 'id'], ['literal', [...visibleAirportIds]]]
: null
for (let i = 0; i < PULSE_PHASES; i++) {
map.setFilter(`airports-dot-${i}`, airportFilter)
map.setFilter(`airports-pulse-${i}`, airportFilter)
}
}
// ── GeoJSON builders ──────────────────────────────────────────────────
const buildRouteFlights = (): void => {
routeFlights.clear()
const now = new Date()
props.flights.forEach(flight => {
const key = routeKey(flight.departure_airport, flight.arrival_airport)
const bucket = routeFlights.get(key) ?? { historical: [], future: [] }
bucket[new Date(flight.departure_date) > now ? 'future' : 'historical'].push(flight)
routeFlights.set(key, bucket)
})
}
const buildRoutesGeoJSON = (): RoutesGeoJSON => {
buildRouteFlights()
const now = new Date()
// Count historical flights per route for colour coding
const flightCountByRoute = new Map<string, number>()
props.flights
.filter(f => new Date(f.departure_date) <= now)
.forEach(f => {
const key = routeKey(f.departure_airport, f.arrival_airport)
flightCountByRoute.set(key, (flightCountByRoute.get(key) ?? 0) + 1)
})
const makeFeature = (
flight: Flight,
extraProps: Partial<RouteFeatureProperties> = {}
): Feature<LineString, RouteFeatureProperties> => ({
type: 'Feature',
properties: {
routeKey: routeKey(flight.departure_airport, flight.arrival_airport),
depId: flight.departure_airport.id,
arrId: flight.arrival_airport.id,
...extraProps,
},
geometry: { type: 'LineString', coordinates: getArc(flight) },
})
// One feature per unique route, deduped by key
const historicalFeatures = [...new Map(
props.flights
.filter(f => new Date(f.departure_date) <= now)
.map(f => [
routeKey(f.departure_airport, f.arrival_airport),
makeFeature(f, { color: routeColor(flightCountByRoute.get(routeKey(f.departure_airport, f.arrival_airport)) ?? 1) }),
])
).values()]
const historicalRouteKeys = new Set(flightCountByRoute.keys())
const futureFeatures = [...new Map(
props.flights
.filter(f => new Date(f.departure_date) > now)
.filter(f => !historicalRouteKeys.has(routeKey(f.departure_airport, f.arrival_airport)))
.map(f => [routeKey(f.departure_airport, f.arrival_airport), makeFeature(f)])
).values()]
const toCollection = (features: Feature<LineString, RouteFeatureProperties>[]) =>
({ type: 'FeatureCollection' as const, features })
return {
historical: toCollection(historicalFeatures),
future: toCollection(futureFeatures),
}
}
const buildAirportsGeoJSON = (): FeatureCollection<Point, AirportFeatureProperties>[] => {
const uniqueAirports = new Map<number, Airport>()
props.flights.forEach(({ departure_airport: dep, arrival_airport: arr }) => {
uniqueAirports.set(dep.id, dep)
uniqueAirports.set(arr.id, arr)
})
// Spread airports across pulse phase buckets for staggered animation
const buckets: Airport[][] = Array.from({ length: PULSE_PHASES }, () => [])
uniqueAirports.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()
const isTouch = isTouchDevice()
// Routes
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 },
})
// Future routes
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 },
})
// Airport dots + pulse rings (one source per phase bucket)
buildAirportsGeoJSON().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)',
},
})
})
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}`)
// ── Hover events (desktop only) ───────────────────────────────────
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()
})
}
for (const layerId of ['routes-hit', 'routes-future-hit']) {
map!.on('mouseenter', layerId, e => {
map!.getCanvas().style.cursor = 'pointer'
const bucket = routeFlights.get(e.features![0].properties.routeKey as string)
if (bucket) showPopup(e.lngLat, routePopupHTML(bucket.historical, bucket.future))
})
map!.on('mouseleave', layerId, () => {
map!.getCanvas().style.cursor = ''
popup!.remove()
})
}
}
// ── Click handler ─────────────────────────────────────────────────
map!.on('click', e => {
// Widen hit area on touch
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 clickedAirport = map!.queryRenderedFeatures(airportQuery, { layers: airportLayerIds })[0]
if (clickedAirport) {
const id = Number(clickedAirport.properties.id)
selectedAirportId = selectedAirportId === id ? null : id
applyFilter()
if (isTouch) {
const airport = airportById.get(id)
const geom = clickedAirport.geometry
if (airport && geom.type === 'Point') showPopup(geom.coordinates as LngLat, airportPopupHTML(airport))
}
return
}
const clickedRoute = map!.queryRenderedFeatures(e.point, { layers: ['routes-hit', 'routes-future-hit'] })[0]
if (clickedRoute) {
const bucket = routeFlights.get(clickedRoute.properties.routeKey as string)
if (bucket) showPopup(e.lngLat, routePopupHTML(bucket.historical, bucket.future))
return
}
// Clicked empty space — deselect
selectedAirportId = null
applyFilter()
popup!.remove()
})
// ── Pulse animation ───────────────────────────────────────────────
const animate = (): void => {
const now = Date.now()
for (let i = 0; i < PULSE_PHASES; i++) {
const progress = ((now + (i / PULSE_PHASES) * PULSE_PERIOD) % PULSE_PERIOD) / PULSE_PERIOD
map!.setPaintProperty(`airports-pulse-${i}`, 'circle-radius', 5 + progress * 13)
if (!selectedAirportId) {
map!.setPaintProperty(`airports-pulse-${i}`, 'circle-stroke-opacity', 0.7 * (1 - progress))
}
}
pulseFrame = requestAnimationFrame(animate)
}
animate()
}
// ── Fit bounds ────────────────────────────────────────────────────────
const fitBounds = (padding = 60): 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])
const minLat = Math.min(...lats)
const maxLat = Math.max(...lats)
// Compare normal span vs dateline-crossing span to pick the tighter fit
const spanNormal = Math.max(...lngs) - Math.min(...lngs)
const lngs360 = lngs.map(lng => lng < 0 ? lng + 360 : lng)
const span360 = Math.max(...lngs360) - Math.min(...lngs360)
let minLng: number, maxLng: number
if (span360 < spanNormal) {
const min360 = Math.min(...lngs360)
const max360 = Math.max(...lngs360)
minLng = min360 > 180 ? min360 - 360 : min360
maxLng = max360 > 180 ? max360 - 360 : max360
} else {
minLng = Math.min(...lngs)
maxLng = Math.max(...lngs)
}
map!.fitBounds([[minLng, minLat], [maxLng, maxLat]], { padding, duration: 0 })
}
// ── Map init ──────────────────────────────────────────────────────────
const initMap = (): void => {
nextTick(() => {
map = new maplibregl.Map({
container: mapContainer.value!,
cooperativeGestures: true,
attributionControl: false,
center: [0, 20],
zoom: 2,
renderWorldCopies: true,
canvasContextAttributes: { preserveDrawingBuffer: true },
style: {
version: 8,
sources: {
'carto-dark': {
type: 'raster',
tileSize: 256,
maxzoom: 19,
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',
],
},
},
layers: [{ id: 'carto-dark', type: 'raster', source: 'carto-dark' }],
},
})
map.addControl(new maplibregl.NavigationControl({ showCompass: false }), 'top-right')
map.addControl(new maplibregl.FullscreenControl(), 'top-right')
map.on('style.load', () => {
map?.setProjection({ type: 'mercator' })
})
map.on('load', () => {
addLayers()
fitBounds()
mapReady.value = true
})
})
}
// ── Data updates ──────────────────────────────────────────────────────
const updateData = (): void => {
if (!map?.isStyleLoaded()) return
arcCache.clear()
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()
}
// ── Lifecycle ─────────────────────────────────────────────────────────
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)
onBeforeUnmount(() => {
if (pulseFrame !== null) cancelAnimationFrame(pulseFrame)
if (map) { map.remove(); map = null }
})
return { mapContainer, mapReady, exportMapBasic, legendOpen, legendItems, isGlobe, toggleProjection, toggleLegend }
},
})
</script>
<style scoped>
.export-btn {
position: absolute;
bottom: 12px;
right: 12px;
z-index: 10;
background: rgba(10,14,22,0.85);
border: 1px solid rgba(255,255,255,0.08);
color: #a0b4c8;
width: 30px;
height: 30px;
border-radius: 4px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
transition: color 0.15s, background 0.15s;
}
.export-btn:hover {
background: rgba(77,166,255,0.15);
color: #4da6ff;
}
.flight-map-wrapper {
position: relative;
width: 100%;
aspect-ratio: 16 / 9;
display: flex;
align-items: center;
justify-content: center;
}
@media (max-width: 768px) {
.flight-map-wrapper { aspect-ratio: 7 / 10; }
}
.map-container { position: absolute; inset: 0; }
.map-loader { z-index: 10; }
.map-hidden { visibility: hidden; }
.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>
/* Popup chrome */
.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; }
/* Airport tooltip */
.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; }
/* Route tooltip */
.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; }
/* Map controls */
.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; }
.map-legend {
position: absolute;
bottom: 12px;
left: 12px;
z-index: 10;
background: rgba(10,14,22,0.85);
border: 1px solid rgba(255,255,255,0.08);
border-radius: 6px;
overflow: hidden;
min-width: 148px;
}
.map-legend__toggle {
display: flex;
align-items: center;
gap: 6px;
width: 100%;
padding: 6px 10px;
background: transparent;
border: none;
color: #a0b4c8;
font-size: 12px;
cursor: pointer;
transition: color 0.15s;
white-space: nowrap;
}
.map-legend__toggle:hover { color: #4da6ff; }
.map-legend__toggle-label { flex: 1; text-align: left; }
.map-legend__body {
display: flex;
flex-direction: column;
gap: 6px;
padding: 0 10px 0;
max-height: 0;
overflow: hidden;
transition: max-height 0.2s ease, padding 0.2s ease;
}
.map-legend--open .map-legend__body {
max-height: 200px;
padding: 2px 10px 10px;
}
.map-legend__row {
display: flex;
align-items: center;
gap: 8px;
}
.map-legend__swatch { flex-shrink: 0; }
.map-legend__label {
font-family: 'Share Tech Mono', monospace;
font-size: 11px;
letter-spacing: 0.06em;
color: #c8cdd8;
}
.projection-btn {
position: absolute;
bottom: 48px;
right: 12px;
z-index: 10;
background: rgba(10,14,22,0.85);
border: 1px solid rgba(255,255,255,0.08);
color: #a0b4c8;
width: 30px;
height: 30px;
border-radius: 4px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
transition: color 0.15s, background 0.15s;
}
.projection-btn:hover { background: rgba(77,166,255,0.15); color: #4da6ff; }
.projection-btn.globe-active { color: #4da6ff; border-color: rgba(77,166,255,0.35); }
</style>