Added Charts
This commit is contained in:
@@ -8,14 +8,39 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { defineComponent, ref, onMounted, onBeforeUnmount, watch, nextTick } from 'vue'
|
||||
<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, Airline, Region, Country} from "@/Types/types";
|
||||
|
||||
function greatCircleGeoJSON(from, to, steps = 64) {
|
||||
const toRad = d => d * Math.PI / 180
|
||||
const toDeg = r => r * 180 / Math.PI
|
||||
interface RouteFlightBucket {
|
||||
historical: Flight[]
|
||||
future: Flight[]
|
||||
}
|
||||
|
||||
type LngLat = [number, number]
|
||||
|
||||
interface RouteFeatureProperties {
|
||||
color?: string
|
||||
routeKey: string
|
||||
depId: number
|
||||
arrId: number
|
||||
}
|
||||
|
||||
interface AirportFeatureProperties {
|
||||
id: number
|
||||
}
|
||||
|
||||
// ── GeoJSON helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
const page = usePage<SharedProps>().props
|
||||
|
||||
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])
|
||||
const lat2 = toRad(to[1]), lng2 = toRad(to[0])
|
||||
const d = 2 * Math.asin(Math.sqrt(
|
||||
@@ -23,7 +48,7 @@ function greatCircleGeoJSON(from, to, steps = 64) {
|
||||
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 = []
|
||||
const points: LngLat[] = []
|
||||
for (let i = 0; i <= steps; i++) {
|
||||
const f = i / steps
|
||||
const A = Math.sin((1 - f) * d) / Math.sin(d)
|
||||
@@ -41,7 +66,7 @@ function greatCircleGeoJSON(from, to, steps = 64) {
|
||||
return points
|
||||
}
|
||||
|
||||
function airportPopupHTML(airport) {
|
||||
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>`
|
||||
: ''
|
||||
@@ -68,29 +93,42 @@ function airportPopupHTML(airport) {
|
||||
</div>`
|
||||
}
|
||||
|
||||
function routePopupHTML(historical, future) {
|
||||
const groupByDirection = (list) => {
|
||||
const dirs = new Map()
|
||||
function routePopupHTML(historical: Flight[], future: Flight[]): string {
|
||||
interface DirectionGroup {
|
||||
label: string
|
||||
airlines: string[]
|
||||
}
|
||||
|
||||
const groupByDirection = (list: Flight[]): DirectionGroup[] => {
|
||||
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?.name
|
||||
if (airline && !dirs.get(key).airlines.includes(airline)) {
|
||||
dirs.get(key).airlines.push(airline)
|
||||
const airline = `<span style="display:inline-flex;align-items:center;gap:6px;">
|
||||
<img src="${page.logo_api_url}/airlines/logos/tail/id/${f.airline?.id}" 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, list) => {
|
||||
|
||||
const renderSection = (title: string, list: Flight[]): string => {
|
||||
if (!list.length) return ''
|
||||
const rows = groupByDirection(list).map(({ label, airlines }) => `
|
||||
const rows = groupByDirection(list).map(({ label, airlines }) => {
|
||||
|
||||
return `
|
||||
<div class="rp-direction">
|
||||
<div class="rp-route">${label}</div>
|
||||
<div class="rp-airlines">${airlines.join(', ') || '—'}</div>
|
||||
</div>`).join('')
|
||||
<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">
|
||||
@@ -105,34 +143,34 @@ export default defineComponent({
|
||||
|
||||
props: {
|
||||
flights: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
type: Array as PropType<Flight[]>,
|
||||
default: (): Flight[] => [],
|
||||
},
|
||||
},
|
||||
|
||||
setup(props) {
|
||||
const mapContainer = ref(null)
|
||||
let map = null
|
||||
let popup = null
|
||||
let pulseFrame = null
|
||||
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 airportById = new Map()
|
||||
const routeFlights = new Map()
|
||||
let selectedAirportId = null
|
||||
const airportById = new Map<number, Airport>()
|
||||
const routeFlights = new Map<string, RouteFlightBucket>()
|
||||
let selectedAirportId: number | null = null
|
||||
let suppressRoutePopup = false
|
||||
|
||||
const isTouchDevice = () => window.matchMedia('(pointer: coarse)').matches
|
||||
const isTouchDevice = (): boolean => window.matchMedia('(pointer: coarse)').matches
|
||||
|
||||
const showPopup = (lngLat, html) => {
|
||||
popup.setLngLat(lngLat).setHTML(html).addTo(map)
|
||||
const showPopup = (lngLat: maplibregl.LngLatLike, html: string): void => {
|
||||
popup!.setLngLat(lngLat).setHTML(html).addTo(map!)
|
||||
}
|
||||
|
||||
// ── Filter ────────────────────────────────────────────────────────────
|
||||
const applyFilter = () => {
|
||||
const applyFilter = (): void => {
|
||||
if (!map || !map.isStyleLoaded()) return
|
||||
const id = selectedAirportId
|
||||
const filter = id
|
||||
const filter: maplibregl.FilterSpecification | null = id
|
||||
? ['any', ['==', ['get', 'depId'], id], ['==', ['get', 'arrId'], id]]
|
||||
: null
|
||||
|
||||
@@ -152,78 +190,85 @@ export default defineComponent({
|
||||
}
|
||||
|
||||
// ── Route flight lookup ───────────────────────────────────────────────
|
||||
const buildRouteFlights = () => {
|
||||
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)
|
||||
routeFlights.get(key)![new Date(flight.departure_date) > now ? 'future' : 'historical'].push(flight)
|
||||
})
|
||||
}
|
||||
|
||||
interface RoutesGeoJSON {
|
||||
historical: GeoJSON.FeatureCollection<GeoJSON.LineString, RouteFeatureProperties>
|
||||
future: GeoJSON.FeatureCollection<GeoJSON.LineString, RouteFeatureProperties>
|
||||
}
|
||||
|
||||
// ── GeoJSON builders ──────────────────────────────────────────────────
|
||||
const buildRoutesGeoJSON = () => {
|
||||
const buildRoutesGeoJSON = (): RoutesGeoJSON => {
|
||||
buildRouteFlights()
|
||||
const now = new Date()
|
||||
|
||||
const routeCounts = new Map()
|
||||
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) => {
|
||||
const routeColor = (count: number): string => {
|
||||
if (count >= 5) return '#f97316'
|
||||
if (count >= 3) return '#eab308'
|
||||
if (count >= 2) return '#22c55e'
|
||||
return '#a150d5'
|
||||
}
|
||||
|
||||
const historicalFeatures = 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 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 historicalKeys = new Set(routeCounts.keys())
|
||||
const futureFeatures = 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 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],
|
||||
),
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
historical: { type: 'FeatureCollection', features: historicalFeatures },
|
||||
@@ -231,13 +276,13 @@ export default defineComponent({
|
||||
}
|
||||
}
|
||||
|
||||
const buildAirportsGeoJSON = () => {
|
||||
const seen = new Map()
|
||||
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 = Array.from({ length: PULSE_PHASES }, () => [])
|
||||
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',
|
||||
@@ -250,35 +295,35 @@ export default defineComponent({
|
||||
}
|
||||
|
||||
// ── Map layers ────────────────────────────────────────────────────────
|
||||
const addLayers = () => {
|
||||
const addLayers = (): void => {
|
||||
const { historical, future } = buildRoutesGeoJSON()
|
||||
|
||||
map.addSource('routes', { type: 'geojson', data: historical })
|
||||
map.addLayer({
|
||||
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({
|
||||
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({
|
||||
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({
|
||||
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({
|
||||
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',
|
||||
@@ -286,7 +331,7 @@ export default defineComponent({
|
||||
'circle-stroke-opacity': 0.6,
|
||||
},
|
||||
})
|
||||
map.addLayer({
|
||||
map!.addLayer({
|
||||
id: `airports-dot-${i}`, type: 'circle', source: `airports-${i}`,
|
||||
paint: {
|
||||
'circle-radius': 5, 'circle-color': '#4da6ff',
|
||||
@@ -305,25 +350,26 @@ export default defineComponent({
|
||||
})
|
||||
|
||||
for (let i = 0; i < PULSE_PHASES; i++) {
|
||||
// Desktop: hover to show airport popup
|
||||
map.on('mouseenter', `airports-dot-${i}`, (e) => {
|
||||
map.getCanvas().style.cursor = 'pointer'
|
||||
map!.on('mouseenter', `airports-dot-${i}`, (e) => {
|
||||
map!.getCanvas().style.cursor = 'pointer'
|
||||
if (isTouch) return
|
||||
const airport = airportById.get(Number(e.features[0].properties.id))
|
||||
if (airport) showPopup(e.features[0].geometry.coordinates, airportPopupHTML(airport))
|
||||
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 = ''
|
||||
map!.on('mouseleave', `airports-dot-${i}`, () => {
|
||||
map!.getCanvas().style.cursor = ''
|
||||
if (isTouch) return
|
||||
popup.remove()
|
||||
popup!.remove()
|
||||
})
|
||||
|
||||
// Click: select airport, suppress route popup, close any open popup
|
||||
map.on('click', `airports-dot-${i}`, (e) => {
|
||||
map!.on('click', `airports-dot-${i}`, (e) => {
|
||||
suppressRoutePopup = true
|
||||
popup.remove()
|
||||
popup!.remove()
|
||||
|
||||
const id = Number(e.features[0].properties.id)
|
||||
const id = Number(e.features![0].properties.id)
|
||||
selectedAirportId = selectedAirportId === id ? null : id
|
||||
applyFilter()
|
||||
|
||||
@@ -331,65 +377,62 @@ export default defineComponent({
|
||||
})
|
||||
}
|
||||
|
||||
// Desktop: hover to show route popup
|
||||
map.on('mouseenter', 'routes-hit', (e) => {
|
||||
map!.on('mouseenter', 'routes-hit', (e) => {
|
||||
if (isTouch) return
|
||||
map.getCanvas().style.cursor = 'pointer'
|
||||
const rf = routeFlights.get(e.features[0].properties.routeKey)
|
||||
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!.on('mouseleave', 'routes-hit', () => {
|
||||
if (isTouch) return
|
||||
map.getCanvas().style.cursor = ''
|
||||
popup.remove()
|
||||
map!.getCanvas().style.cursor = ''
|
||||
popup!.remove()
|
||||
})
|
||||
|
||||
// Touch: tap to show route popup (suppressed if airport was just tapped)
|
||||
map.on('click', 'routes-hit', (e) => {
|
||||
map!.on('click', 'routes-hit', (e) => {
|
||||
if (!isTouch) return
|
||||
if (suppressRoutePopup) return
|
||||
const rf = routeFlights.get(e.features[0].properties.routeKey)
|
||||
const rf = routeFlights.get(e.features![0].properties.routeKey as string)
|
||||
if (rf) showPopup(e.lngLat, routePopupHTML(rf.historical, rf.future))
|
||||
})
|
||||
|
||||
map.on('mouseenter', 'routes-future-hit', (e) => {
|
||||
map!.on('mouseenter', 'routes-future-hit', (e) => {
|
||||
if (isTouch) return
|
||||
map.getCanvas().style.cursor = 'pointer'
|
||||
const rf = routeFlights.get(e.features[0].properties.routeKey)
|
||||
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!.on('mouseleave', 'routes-future-hit', () => {
|
||||
if (isTouch) return
|
||||
map.getCanvas().style.cursor = ''
|
||||
popup.remove()
|
||||
map!.getCanvas().style.cursor = ''
|
||||
popup!.remove()
|
||||
})
|
||||
map.on('click', 'routes-future-hit', (e) => {
|
||||
map!.on('click', 'routes-future-hit', (e) => {
|
||||
if (!isTouch) return
|
||||
if (suppressRoutePopup) return
|
||||
const rf = routeFlights.get(e.features[0].properties.routeKey)
|
||||
const rf = routeFlights.get(e.features![0].properties.routeKey as string)
|
||||
if (rf) showPopup(e.lngLat, routePopupHTML(rf.historical, rf.future))
|
||||
})
|
||||
|
||||
// Tap on empty map: deselect airport and close any popup
|
||||
map.on('click', (e) => {
|
||||
const features = map.queryRenderedFeatures(e.point, {
|
||||
map!.on('click', (e) => {
|
||||
const features = map!.queryRenderedFeatures(e.point, {
|
||||
layers: Array.from({ length: PULSE_PHASES }, (_, i) => `airports-dot-${i}`),
|
||||
})
|
||||
if (!features.length) {
|
||||
selectedAirportId = null
|
||||
applyFilter()
|
||||
popup.remove()
|
||||
popup!.remove()
|
||||
}
|
||||
})
|
||||
|
||||
const period = 2200
|
||||
const animate = () => {
|
||||
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)
|
||||
map!.setPaintProperty(`airports-pulse-${i}`, 'circle-radius', 5 + t * 13)
|
||||
if (!selectedAirportId) {
|
||||
map.setPaintProperty(`airports-pulse-${i}`, 'circle-stroke-opacity', 0.7 * (1 - t))
|
||||
map!.setPaintProperty(`airports-pulse-${i}`, 'circle-stroke-opacity', 0.7 * (1 - t))
|
||||
}
|
||||
}
|
||||
pulseFrame = requestAnimationFrame(animate)
|
||||
@@ -397,21 +440,21 @@ export default defineComponent({
|
||||
animate()
|
||||
}
|
||||
|
||||
const fitBounds = () => {
|
||||
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(
|
||||
map!.fitBounds(
|
||||
[[Math.min(...lngs), Math.min(...lats)], [Math.max(...lngs), Math.max(...lats)]],
|
||||
{ padding: 60, duration: 0 },
|
||||
)
|
||||
}
|
||||
|
||||
// ── Map init ──────────────────────────────────────────────────────────
|
||||
const initMap = () => {
|
||||
const initMap = (): void => {
|
||||
nextTick(() => {
|
||||
map = new maplibregl.Map({
|
||||
container: mapContainer.value,
|
||||
container: mapContainer.value!,
|
||||
style: {
|
||||
version: 8,
|
||||
sources: {
|
||||
@@ -440,17 +483,19 @@ export default defineComponent({
|
||||
}
|
||||
|
||||
// ── Data updates ──────────────────────────────────────────────────────
|
||||
const updateData = () => {
|
||||
const updateData = (): void => {
|
||||
if (!map || !map.isStyleLoaded()) return
|
||||
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')?.setData(historical)
|
||||
map.getSource('routes-future')?.setData(future)
|
||||
buildAirportsGeoJSON().forEach((data, i) => map.getSource(`airports-${i}`)?.setData(data))
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -465,7 +510,7 @@ export default defineComponent({
|
||||
watch(() => props.flights, updateData, { deep: true })
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (pulseFrame) cancelAnimationFrame(pulseFrame)
|
||||
if (pulseFrame !== null) cancelAnimationFrame(pulseFrame)
|
||||
if (map) { map.remove(); map = null }
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user