Files
FlightsAPI/resources/js/Components/FlightsGoneBy/Charts/TopAirportsChart.vue
T
2026-04-11 17:30:16 +10:00

290 lines
8.1 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="chart-wrap">
<div class="chart-title">Top airports</div>
<div v-if="series.length" class="chart-outer">
<div class="chart-scroll" :style="{ height: scrollHeight }">
<apexchart
type="bar"
:height="chartHeight"
:options="chartOptions"
:series="chartSeries"
/>
</div>
<div class="chart-footer">
<span class="total-count">{{ totalAirports }}</span>
<span class="total-label">total airports</span>
</div>
</div>
<div v-else class="chart-empty">No airport data available</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type {Airport, Flight} from '@/Types/types'
const props = defineProps<{
flights: Flight[]
upcomingFlights: Flight[]
}>()
const MAX_VISIBLE = 12
const BAR_HEIGHT = 32
interface AirportCounts {
departures: number
arrivals: number
upcoming: number
}
function buildAirportMap(flights: Flight[], key: 'departures' | 'arrivals' | 'upcoming'): Map<string, AirportCounts> {
const map = new Map<string, AirportCounts>()
const empty = (): AirportCounts => ({ departures: 0, arrivals: 0, upcoming: 0 })
flights.forEach(f => {
const depName = f.departure_airport?.iata_code
? `${f.departure_airport.iata_code} ${f.departure_airport.name}`
: f.departure_airport?.name ?? null
const arrName = f.arrival_airport?.iata_code
? `${f.arrival_airport.iata_code} ${f.arrival_airport.name}`
: f.arrival_airport?.name ?? null
if (depName) {
const existing = map.get(depName) ?? empty()
existing[key]++
map.set(depName, existing)
}
if (arrName) {
const existing = map.get(arrName) ?? empty()
if (key !== 'upcoming') existing.arrivals++
else existing[key]++
map.set(arrName, existing)
}
})
return map
}
function airportLabel(airport: Airport | null | undefined): string | null {
if (!airport) return null
return airport.iata_code ?? airport.icao_code ?? airport.name ?? null
}
const series = computed(() => {
const past = new Map<string, AirportCounts & { label: string; fullName: string }>()
const empty = () => ({ departures: 0, arrivals: 0, upcoming: 0, label: '', fullName: '' })
props.flights.forEach(f => {
const depLabel = airportLabel(f.departure_airport)
const arrLabel = airportLabel(f.arrival_airport)
if (depLabel) {
const e = past.get(depLabel) ?? empty()
e.departures++
e.label = depLabel
e.fullName = f.departure_airport?.name ?? depLabel
past.set(depLabel, e)
}
if (arrLabel) {
const e = past.get(arrLabel) ?? empty()
e.arrivals++
e.label = arrLabel
e.fullName = f.arrival_airport?.name ?? arrLabel
past.set(arrLabel, e)
}
})
props.upcomingFlights.forEach(f => {
const depLabel = airportLabel(f.departure_airport)
const arrLabel = airportLabel(f.arrival_airport)
if (depLabel) {
const e = past.get(depLabel) ?? empty()
e.upcoming++
e.label = depLabel
e.fullName = f.departure_airport?.name ?? depLabel
past.set(depLabel, e)
}
if (arrLabel) {
const e = past.get(arrLabel) ?? empty()
e.upcoming++
e.label = arrLabel
e.fullName = f.arrival_airport?.name ?? arrLabel
past.set(arrLabel, e)
}
})
return [...past.entries()]
.map(([, counts]) => ({ ...counts }))
.sort((a, b) =>
(b.departures + b.arrivals + b.upcoming) - (a.departures + a.arrivals + a.upcoming)
)
})
const totalAirports = computed(() => series.value.length)
const chartHeight = computed(() => series.value.length * BAR_HEIGHT + 40)
const scrollHeight = computed(() => {
const visible = Math.min(series.value.length, MAX_VISIBLE)
return `${visible * BAR_HEIGHT + 40}px`
})
const chartSeries = computed(() => [
{ name: 'Departures', data: series.value.map(s => s.departures) },
{ name: 'Arrivals', data: series.value.map(s => s.arrivals) },
{ name: 'Upcoming', data: series.value.map(s => s.upcoming) },
])
const chartOptions = computed(() => ({
chart: {
type: 'bar',
background: 'transparent',
fontFamily: 'inherit',
toolbar: { show: false },
animations: { enabled: false },
stacked: true,
},
theme: { mode: 'dark' },
plotOptions: {
bar: {
horizontal: true,
barHeight: '60%',
borderRadius: 3,
borderRadiusWhenStacked: 'last',
},
},
colors: ['#4da6ff', '#a150d5', '#ffc107'],
dataLabels: { enabled: false },
xaxis: {
categories: series.value.map(s => s.label),
labels: { show: false },
axisBorder: { show: false },
axisTicks: { show: false },
},
yaxis: {
labels: {
style: {
colors: '#778899',
fontSize: '12px',
},
},
},
grid: { show: false },
legend: {
show: true,
position: 'top',
horizontalAlign: 'right',
labels: { colors: '#778899' },
markers: { width: 8, height: 8, radius: 2 },
itemMargin: { horizontal: 8 },
},
tooltip: {
theme: 'dark',
shared: true,
intersect: false,
custom: ({ dataPointIndex }: { dataPointIndex: number }) => {
const airport = series.value[dataPointIndex]
if (!airport) return ''
const rows = [
{ label: 'Departures', value: airport.departures, },
{ label: 'Arrivals', value: airport.arrivals, color: '#a150d5' },
{ label: 'Upcoming', value: airport.upcoming, color: '#ffc107' },
]
.filter(r => r.value > 0)
.map(r => `
<div class="glass" style="display:flex;align-items:center;gap:6px;padding:2px 0">
<span style="width:8px;height:8px;border-radius:2px;background:${r.color};flex-shrink:0"></span>
<span style="color:#778899">${r.label}:</span>
<span style="color:#e0e6f0;margin-left:auto;padding-left:12px">${r.value} flights</span>
</div>
`).join('')
return `
<div class="glass" style="padding:10px 12px;min-width:180px">
<div style="color:#e0e6f0;font-weight:500;margin-bottom:6px">${airport.fullName}</div>
<div style="color:#556677;font-size:11px;margin-bottom:8px">${airport.label}</div>
${rows}
</div>
`
},
},
states: {
hover: { filter: { type: 'lighten', value: 0.1 } },
},
}))
</script>
<style scoped>
.chart-wrap {
display: flex;
flex-direction: column;
gap: 4px;
}
.chart-title {
font-size: 13px;
font-weight: 500;
color: #556677;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.chart-outer {
display: flex;
flex-direction: column;
gap: 12px;
}
.chart-scroll {
overflow-y: auto;
overflow-x: hidden;
scrollbar-width: thin;
scrollbar-color: #334455 transparent;
}
.chart-scroll::-webkit-scrollbar {
width: 4px;
}
.chart-scroll::-webkit-scrollbar-track {
background: transparent;
}
.chart-scroll::-webkit-scrollbar-thumb {
background: #334455;
border-radius: 2px;
}
.chart-footer {
display: flex;
align-items: baseline;
gap: 6px;
padding-left: 4px;
}
.total-count {
font-size: 28px;
font-weight: 600;
color: #e0e6f0;
line-height: 1;
}
.total-label {
font-size: 13px;
color: #556677;
}
.chart-empty {
height: 280px;
display: flex;
align-items: center;
justify-content: center;
font-size: 13px;
color: #445566;
}
</style>