Added Charts
This commit is contained in:
@@ -14,7 +14,7 @@ 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";
|
||||
import { Flight, Airport } from '@/Types/types'
|
||||
|
||||
interface RouteFlightBucket {
|
||||
historical: Flight[]
|
||||
@@ -34,9 +34,11 @@ interface AirportFeatureProperties {
|
||||
id: number
|
||||
}
|
||||
|
||||
// ── GeoJSON helpers ───────────────────────────────────────────────────────────
|
||||
interface RoutesGeoJSON {
|
||||
historical: GeoJSON.FeatureCollection<GeoJSON.LineString, RouteFeatureProperties>
|
||||
future: GeoJSON.FeatureCollection<GeoJSON.LineString, RouteFeatureProperties>
|
||||
}
|
||||
|
||||
const page = usePage<SharedProps>().props
|
||||
|
||||
function greatCircleGeoJSON(from: LngLat, to: LngLat, steps = 64): LngLat[] {
|
||||
const toRad = (d: number): number => d * Math.PI / 180
|
||||
@@ -94,6 +96,7 @@ 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[]
|
||||
@@ -106,9 +109,9 @@ function routePopupHTML(historical: Flight[], future: Flight[]): string {
|
||||
const label = `${f.departure_airport.municipality} to ${f.arrival_airport.municipality}`
|
||||
if (!dirs.has(key)) dirs.set(key, { label, airlines: [] })
|
||||
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>`
|
||||
<img src="${logoApiUrl}/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)
|
||||
}
|
||||
@@ -118,14 +121,11 @@ function routePopupHTML(historical: Flight[], future: Flight[]): string {
|
||||
|
||||
const renderSection = (title: string, list: Flight[]): string => {
|
||||
if (!list.length) return ''
|
||||
const rows = groupByDirection(list).map(({ label, airlines }) => {
|
||||
|
||||
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('')
|
||||
<div class="rp-airlines">${airlines.join('<br/>') || '—'}</div>
|
||||
</div>`).join('')
|
||||
return `<div class="rp-section"><div class="rp-section-title">${title}</div>${rows}</div>`
|
||||
}
|
||||
|
||||
@@ -155,10 +155,10 @@ export default defineComponent({
|
||||
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>()
|
||||
let selectedAirportId: number | null = null
|
||||
let suppressRoutePopup = false
|
||||
|
||||
const isTouchDevice = (): boolean => window.matchMedia('(pointer: coarse)').matches
|
||||
|
||||
@@ -200,11 +200,6 @@ export default defineComponent({
|
||||
})
|
||||
}
|
||||
|
||||
interface RoutesGeoJSON {
|
||||
historical: GeoJSON.FeatureCollection<GeoJSON.LineString, RouteFeatureProperties>
|
||||
future: GeoJSON.FeatureCollection<GeoJSON.LineString, RouteFeatureProperties>
|
||||
}
|
||||
|
||||
// ── GeoJSON builders ──────────────────────────────────────────────────
|
||||
const buildRoutesGeoJSON = (): RoutesGeoJSON => {
|
||||
buildRouteFlights()
|
||||
@@ -349,82 +344,88 @@ export default defineComponent({
|
||||
offset: 12,
|
||||
})
|
||||
|
||||
for (let i = 0; i < PULSE_PHASES; i++) {
|
||||
map!.on('mouseenter', `airports-dot-${i}`, (e) => {
|
||||
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'
|
||||
if (isTouch) return
|
||||
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))
|
||||
}
|
||||
const rf = routeFlights.get(e.features![0].properties.routeKey as string)
|
||||
if (rf) showPopup(e.lngLat, routePopupHTML(rf.historical, rf.future))
|
||||
})
|
||||
map!.on('mouseleave', `airports-dot-${i}`, () => {
|
||||
map!.on('mouseleave', 'routes-hit', () => {
|
||||
map!.getCanvas().style.cursor = ''
|
||||
if (isTouch) return
|
||||
popup!.remove()
|
||||
})
|
||||
|
||||
map!.on('click', `airports-dot-${i}`, (e) => {
|
||||
suppressRoutePopup = true
|
||||
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()
|
||||
|
||||
const id = Number(e.features![0].properties.id)
|
||||
selectedAirportId = selectedAirportId === id ? null : id
|
||||
applyFilter()
|
||||
|
||||
setTimeout(() => { suppressRoutePopup = false }, 0)
|
||||
})
|
||||
}
|
||||
|
||||
map!.on('mouseenter', 'routes-hit', (e) => {
|
||||
if (isTouch) return
|
||||
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', () => {
|
||||
if (isTouch) return
|
||||
map!.getCanvas().style.cursor = ''
|
||||
popup!.remove()
|
||||
})
|
||||
|
||||
map!.on('click', 'routes-hit', (e) => {
|
||||
if (!isTouch) return
|
||||
if (suppressRoutePopup) return
|
||||
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) => {
|
||||
if (isTouch) return
|
||||
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', () => {
|
||||
if (isTouch) return
|
||||
map!.getCanvas().style.cursor = ''
|
||||
popup!.remove()
|
||||
})
|
||||
map!.on('click', 'routes-future-hit', (e) => {
|
||||
if (!isTouch) return
|
||||
if (suppressRoutePopup) return
|
||||
const rf = routeFlights.get(e.features![0].properties.routeKey as string)
|
||||
if (rf) showPopup(e.lngLat, routePopupHTML(rf.historical, rf.future))
|
||||
})
|
||||
|
||||
// ── Unified click handler — airports take priority over routes ────
|
||||
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
|
||||
// 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
|
||||
|
||||
const airportFeatures = map!.queryRenderedFeatures(airportQuery, { layers: airportLayerIds })
|
||||
|
||||
if (airportFeatures.length) {
|
||||
const id = Number(airportFeatures[0].properties.id)
|
||||
selectedAirportId = selectedAirportId === id ? null : id
|
||||
applyFilter()
|
||||
popup!.remove()
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// Then check routes
|
||||
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
|
||||
}
|
||||
|
||||
// Empty map click — deselect everything
|
||||
selectedAirportId = null
|
||||
applyFilter()
|
||||
popup!.remove()
|
||||
})
|
||||
|
||||
// ── Pulse animation ───────────────────────────────────────────────
|
||||
const period = 2200
|
||||
const animate = (): void => {
|
||||
const now = Date.now()
|
||||
@@ -545,7 +546,7 @@ export default defineComponent({
|
||||
}
|
||||
|
||||
.empty-state .mdi { font-size: 48px; }
|
||||
.empty-state p { font-size: 13px; letter-spacing: 0.1em; text-transform: uppercase; margin: 0; }
|
||||
.empty-state p { font-size: 13px; letter-spacing: 0.1em; text-transform: uppercase; margin: 0; }
|
||||
</style>
|
||||
|
||||
<style>
|
||||
@@ -557,7 +558,6 @@ export default defineComponent({
|
||||
}
|
||||
.ap-popup .maplibregl-popup-tip { border-top-color: rgba(10,14,22,0.95); }
|
||||
|
||||
/* Close button styling for touch devices */
|
||||
.ap-popup .maplibregl-popup-close-button {
|
||||
color: #556677;
|
||||
font-size: 18px;
|
||||
@@ -593,8 +593,8 @@ export default defineComponent({
|
||||
.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-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;
|
||||
|
||||
Reference in New Issue
Block a user