943 lines
38 KiB
Vue
943 lines
38 KiB
Vue
<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="legendOpen = !legendOpen">
|
||
<span class="mdi mdi-map-legend" />
|
||
<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 { usePage } from '@inertiajs/vue3'
|
||
import { Flight, Airport, SharedProps } from '@/Types/types'
|
||
import PlaneLoader from '@/Components/FlightsGoneBy/PlaneLoader.vue'
|
||
import type { Feature, FeatureCollection, LineString, Point } from 'geojson'
|
||
|
||
// ── Types ─────────────────────────────────────────────────────────────────────
|
||
|
||
|
||
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)
|
||
|
||
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(true)
|
||
|
||
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: '3–4 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 }
|
||
},
|
||
})
|
||
|
||
|
||
</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 10px;
|
||
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-top: 2px;
|
||
}
|
||
|
||
.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>
|