Added Charts

This commit is contained in:
2026-04-11 23:00:35 +10:00
parent 95624f345c
commit f335951784
4 changed files with 508 additions and 84 deletions
@@ -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;