Files
FlightsAPI/resources/js/Components/FlightsGoneBy/FlightMap.vue
T
2026-05-19 13:02:02 +10:00

689 lines
30 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 }" />
<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'
import PlaneLoader from "@/Components/FlightsGoneBy/PlaneLoader.vue";
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?: 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): 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 <= s; i++) {
const f = i / s
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 {
interface DirectionGroup {
label: string
airlines: string[]
}
const groupByDirection = (list: Flight[]): DirectionGroup[] => {
const logoApiUrl = usePage<SharedProps>().props.logo_api_url
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 = f.airline ? `<span style="display:inline-flex;align-items:center;gap:6px;">
<img src="${f.airline?.logo_url}" 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',
components: {PlaneLoader},
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>()
const arcCache = new Map<string, LngLat[]>()
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 routeFilter: maplibregl.FilterSpecification | null = id
? ['any', ['==', ['get', 'depId'], id], ['==', ['get', 'arrId'], id]]
: null
map.setFilter('routes-line', routeFilter)
map.setFilter('routes-hit', routeFilter)
map.setFilter('routes-future-line', routeFilter)
map.setFilter('routes-future-hit', routeFilter)
// Use setFilter on airport layers — GPU-side, no style recompilation
for (let i = 0; i < PULSE_PHASES; i++) {
const airportFilter: maplibregl.FilterSpecification | null = id
? ['==', ['get', 'id'], id]
: null
map.setFilter(`airports-dot-${i}`, airportFilter)
map.setFilter(`airports-pulse-${i}`, airportFilter)
}
}
// ── 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)
})
}
// ── Arc cache helper ──────────────────────────────────────────────────
const getArc = (flight: Flight): LngLat[] => {
const key = [flight.departure_airport.id, flight.arrival_airport.id].sort().join('-')
if (!arcCache.has(key)) {
arcCache.set(key, greatCircleGeoJSON(
[flight.departure_airport.longitude_deg, flight.departure_airport.latitude_deg],
[flight.arrival_airport.longitude_deg, flight.arrival_airport.latitude_deg],
))
}
return arcCache.get(key)!
}
// ── 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 seenHistorical = new Set<string>()
const historicalFeatures: GeoJSON.Feature<GeoJSON.LineString, RouteFeatureProperties>[] = []
props.flights
.filter(f => new Date(f.departure_date) <= now)
.forEach((flight) => {
const key = [flight.departure_airport.id, flight.arrival_airport.id].sort().join('-')
if (seenHistorical.has(key)) return
seenHistorical.add(key)
const count = routeCounts.get(key) ?? 1
historicalFeatures.push({
type: 'Feature',
properties: {
color: routeColor(count), routeKey: key,
depId: flight.departure_airport.id,
arrId: flight.arrival_airport.id,
},
geometry: { type: 'LineString', coordinates: getArc(flight) },
})
})
const historicalKeys = new Set(routeCounts.keys())
const seenFuture = new Set<string>()
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)
})
.forEach((flight) => {
const key = [flight.departure_airport.id, flight.arrival_airport.id].sort().join('-')
if (seenFuture.has(key)) return
seenFuture.add(key)
futureFeatures.push({
type: 'Feature',
properties: { routeKey: key, depId: flight.departure_airport.id, arrId: flight.arrival_airport.id },
geometry: { type: 'LineString', coordinates: getArc(flight) },
})
})
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 ─────────────────────────────────────────
map!.on('click', (e) => {
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
}
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
}
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
// Collect all airport coordinates
const lngs: number[] = []
const lats: number[] = []
props.flights.forEach(f => {
lngs.push(f.departure_airport.longitude_deg, f.arrival_airport.longitude_deg)
lats.push(f.departure_airport.latitude_deg, f.arrival_airport.latitude_deg)
})
const minLat = Math.min(...lats)
const maxLat = Math.max(...lats)
// Check if wrapping across the dateline gives a tighter span
const minLngNormal = Math.min(...lngs)
const maxLngNormal = Math.max(...lngs)
const spanNormal = maxLngNormal - minLngNormal
// Shift negative longitudes into the 0360 range and recompute span
const lngs360 = lngs.map(lng => lng < 0 ? lng + 360 : lng)
const minLng360 = Math.min(...lngs360)
const maxLng360 = Math.max(...lngs360)
const span360 = maxLng360 - minLng360
let minLng: number
let maxLng: number
if (span360 < spanNormal) {
// Dateline-crossing view is tighter — use the shifted coords
minLng = minLng360 > 180 ? minLng360 - 360 : minLng360
maxLng = maxLng360 > 180 ? maxLng360 - 360 : maxLng360
} else {
minLng = minLngNormal
maxLng = maxLngNormal
}
map!.fitBounds([[minLng, minLat], [maxLng, maxLat]], { padding: 60, duration: 0 })
}
// ── Map init ──────────────────────────────────────────────────────────
const initMap = (): void => {
nextTick(() => {
map = new maplibregl.Map({
container: mapContainer.value!,
cooperativeGestures: true,
attributionControl: false,
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,
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.addControl(new maplibregl.FullscreenControl(), 'top-right')
map.on('load', () => {
addLayers()
fitBounds()
mapReady.value = true // ← add this
})
})
}
// ── Data updates ──────────────────────────────────────────────────────
const updateData = (): void => {
if (!map || !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()
}
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 }
})
const mapReady = ref(false)
return { mapContainer, mapReady }
},
})
</script>
<style scoped>
.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 {
width: 100%;
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>
.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>