Add more airlines and fix edit bugs

This commit is contained in:
2026-04-20 09:23:26 +10:00
parent 4244b8835d
commit 8d7d8f02d3
66 changed files with 877 additions and 614 deletions
@@ -1,6 +1,7 @@
<template>
<div class="flight-map-wrapper">
<div ref="mapContainer" class="map-container" />
<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>
@@ -15,6 +16,7 @@ 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[]
@@ -39,8 +41,12 @@ interface RoutesGeoJSON {
future: GeoJSON.FeatureCollection<GeoJSON.LineString, RouteFeatureProperties>
}
const logoApiUrl = usePage<SharedProps>().props.logo_api_url
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)
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])
@@ -51,8 +57,8 @@ function greatCircleGeoJSON(from: LngLat, to: LngLat, steps = 64): LngLat[] {
))
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
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)
@@ -96,7 +102,6 @@ function airportPopupHTML(airport: Airport): string {
}
function routePopupHTML(historical: Flight[], future: Flight[]): string {
const logoApiUrl = usePage<SharedProps>().props.logo_api_url
interface DirectionGroup {
label: string
airlines: string[]
@@ -140,6 +145,7 @@ function routePopupHTML(historical: Flight[], future: Flight[]): string {
export default defineComponent({
name: 'FlightMap',
components: {PlaneLoader},
props: {
flights: {
@@ -158,6 +164,7 @@ export default defineComponent({
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
@@ -170,22 +177,23 @@ export default defineComponent({
const applyFilter = (): void => {
if (!map || !map.isStyleLoaded()) return
const id = selectedAirportId
const filter: maplibregl.FilterSpecification | null = id
const routeFilter: 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)
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++) {
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)
const airportFilter: maplibregl.FilterSpecification | null = id
? ['==', ['get', 'id'], id]
: null
map.setFilter(`airports-dot-${i}`, airportFilter)
map.setFilter(`airports-pulse-${i}`, airportFilter)
}
}
@@ -200,6 +208,18 @@ export default defineComponent({
})
}
// ── 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()
@@ -219,51 +239,45 @@ export default defineComponent({
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 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 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],
),
},
}
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 },
@@ -384,9 +398,8 @@ export default defineComponent({
})
}
// ── Unified click handler — airports take priority over routes ────
// ── Unified click handler ─────────────────────────────────────────
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
@@ -408,7 +421,6 @@ export default defineComponent({
return
}
// Then check routes
const routeFeatures = map!.queryRenderedFeatures(e.point, {
layers: ['routes-hit', 'routes-future-hit'],
})
@@ -419,7 +431,6 @@ export default defineComponent({
return
}
// Empty map click — deselect everything
selectedAirportId = null
applyFilter()
popup!.remove()
@@ -443,12 +454,14 @@ export default defineComponent({
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 },
)
let minLng = Infinity, maxLng = -Infinity, minLat = Infinity, maxLat = -Infinity
props.flights.forEach(f => {
minLng = Math.min(minLng, f.departure_airport.longitude_deg, f.arrival_airport.longitude_deg)
maxLng = Math.max(maxLng, f.departure_airport.longitude_deg, f.arrival_airport.longitude_deg)
minLat = Math.min(minLat, f.departure_airport.latitude_deg, f.arrival_airport.latitude_deg)
maxLat = Math.max(maxLat, f.departure_airport.latitude_deg, f.arrival_airport.latitude_deg)
})
map!.fitBounds([[minLng, minLat], [maxLng, maxLat]], { padding: 60, duration: 0 })
}
// ── Map init ──────────────────────────────────────────────────────────
@@ -479,13 +492,18 @@ export default defineComponent({
renderWorldCopies: true,
})
map.addControl(new maplibregl.NavigationControl({ showCompass: false }), 'top-right')
map.on('load', () => { addLayers(); fitBounds() })
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)
@@ -508,14 +526,16 @@ export default defineComponent({
initMap()
})
watch(() => props.flights, updateData, { deep: true })
watch(() => props.flights, updateData)
onBeforeUnmount(() => {
if (pulseFrame !== null) cancelAnimationFrame(pulseFrame)
if (map) { map.remove(); map = null }
})
return { mapContainer }
const mapReady = ref(false)
return { mapContainer, mapReady }
},
})
</script>
@@ -525,11 +545,22 @@ export default defineComponent({
position: relative;
width: 100%;
aspect-ratio: 16 / 9;
display: flex;
align-items: center;
justify-content: center;
}
.map-container {
width: 100%;
height: 100%;
position: absolute;
inset: 0;
}
.map-loader {
z-index: 10;
}
.map-hidden {
visibility: hidden;
}
.empty-state {
@@ -547,6 +578,8 @@ export default defineComponent({
.empty-state .mdi { font-size: 48px; }
.empty-state p { font-size: 13px; letter-spacing: 0.1em; text-transform: uppercase; margin: 0; }
</style>
<style>
@@ -610,4 +643,7 @@ export default defineComponent({
.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>