Add more airlines and fix edit bugs
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user