Add more airlines and fix edit bugs
This commit is contained in:
@@ -5,6 +5,7 @@ import BoardingPass from "@/Components/FlightsGoneBy/BoardingPass.vue";
|
||||
|
||||
const props = defineProps<{
|
||||
flights: Flight[]
|
||||
canEdit: boolean
|
||||
}>()
|
||||
|
||||
const today = new Date()
|
||||
|
||||
@@ -1,20 +1,18 @@
|
||||
<template>
|
||||
<div class="chart-wrap">
|
||||
<div class="chart-title">Continents visited</div>
|
||||
<apexchart
|
||||
<FlightChart
|
||||
title="Continents"
|
||||
v-if="series.length"
|
||||
type="donut"
|
||||
height="280"
|
||||
:options="chartOptions"
|
||||
:series="seriesData"
|
||||
/>
|
||||
<div v-else class="chart-empty">No continent data available</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { Flight } from '@/Types/types'
|
||||
import FlightChart from "@/Components/FlightsGoneBy/FlightChart.vue";
|
||||
|
||||
const props = defineProps<{
|
||||
flights: Flight[]
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
<div v-if="series.length" class="chart-outer">
|
||||
<div class="chart-scroll" :style="{ height: scrollHeight }" @mousemove="onMouseMove" @mouseleave="onMouseLeave">
|
||||
<apexchart
|
||||
<UnstyledFlightChart
|
||||
type="bar"
|
||||
:height="chartHeight"
|
||||
:options="chartOptions"
|
||||
@@ -36,6 +36,7 @@ import { computed } from 'vue'
|
||||
import type { Flight } from '@/Types/types'
|
||||
import { useChartTooltip} from "@/Composables/useChartTooltip";
|
||||
import ChartTooltip from '@/Components/FlightsGoneBy/Charts/ChartTooltip.vue'
|
||||
import UnstyledFlightChart from "@/Components/FlightsGoneBy/UnstyledFlightChart.vue";
|
||||
|
||||
const props = defineProps<{
|
||||
flights: Flight[]
|
||||
|
||||
@@ -1,20 +1,18 @@
|
||||
<template>
|
||||
<div class="chart-wrap">
|
||||
<div class="chart-title">Flight classes</div>
|
||||
<apexchart
|
||||
<FlightChart
|
||||
title="Flight Classes"
|
||||
v-if="series.length"
|
||||
type="donut"
|
||||
height="280"
|
||||
:options="chartOptions"
|
||||
:series="seriesData"
|
||||
/>
|
||||
<div v-else class="chart-empty">No flight class data available</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { Flight } from '@/Types/types'
|
||||
import FlightChart from "@/Components/FlightsGoneBy/FlightChart.vue";
|
||||
|
||||
const props = defineProps<{
|
||||
flights: Flight[]
|
||||
@@ -87,26 +85,5 @@ const chartOptions = computed(() => ({
|
||||
</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-empty {
|
||||
height: 280px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 13px;
|
||||
color: #445566;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,20 +1,18 @@
|
||||
<template>
|
||||
<div class="chart-wrap">
|
||||
<div class="chart-title">Flight reasons</div>
|
||||
<apexchart
|
||||
<FlightChart
|
||||
title="Flight Reasons"
|
||||
v-if="series.length"
|
||||
type="donut"
|
||||
height="280"
|
||||
:options="chartOptions"
|
||||
:series="seriesData"
|
||||
/>
|
||||
<div v-else class="chart-empty">No flight reason data available</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { Flight } from '@/Types/types'
|
||||
import FlightChart from "@/Components/FlightsGoneBy/FlightChart.vue";
|
||||
|
||||
const props = defineProps<{
|
||||
flights: Flight[]
|
||||
|
||||
@@ -1,20 +1,18 @@
|
||||
<template>
|
||||
<div class="chart-wrap">
|
||||
<div class="chart-title">International vs Domestic</div>
|
||||
<apexchart
|
||||
<FlightChart
|
||||
title="International vs Domestic"
|
||||
v-if="series.length"
|
||||
type="donut"
|
||||
height="280"
|
||||
:options="chartOptions"
|
||||
:series="seriesData"
|
||||
/>
|
||||
<div v-else class="chart-empty">No flight data available</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { Flight } from '@/Types/types'
|
||||
import FlightChart from "@/Components/FlightsGoneBy/FlightChart.vue";
|
||||
|
||||
const props = defineProps<{
|
||||
flights: Flight[]
|
||||
@@ -90,37 +88,4 @@ const chartOptions = computed(() => ({
|
||||
</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-empty {
|
||||
height: 280px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 13px;
|
||||
color: #445566;
|
||||
}
|
||||
|
||||
.charts-row {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.charts-row > * {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,18 +1,17 @@
|
||||
<template>
|
||||
<div class="chart-wrap">
|
||||
<div class="chart-title">Flights per day of week</div>
|
||||
<apexchart
|
||||
<FlightChart
|
||||
title="Flights per day of week"
|
||||
type="bar"
|
||||
height="220"
|
||||
:options="chartOptions"
|
||||
:series="series"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { Flight } from '@/Types/types'
|
||||
import FlightChart from "@/Components/FlightsGoneBy/FlightChart.vue";
|
||||
|
||||
const props = defineProps<{
|
||||
flights: Flight[]
|
||||
@@ -97,17 +96,4 @@ const chartOptions = computed(() => ({
|
||||
</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;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,18 +1,17 @@
|
||||
<template>
|
||||
<div class="chart-wrap">
|
||||
<div class="chart-title">Flights per month</div>
|
||||
<apexchart
|
||||
type="bar"
|
||||
height="220"
|
||||
:options="chartOptions"
|
||||
:series="series"
|
||||
/>
|
||||
</div>
|
||||
<FlightChart
|
||||
title="Flights per month"
|
||||
type="bar"
|
||||
height="220"
|
||||
:options="chartOptions"
|
||||
:series="series"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { Flight } from '@/Types/types'
|
||||
import FlightChart from "@/Components/FlightsGoneBy/FlightChart.vue";
|
||||
|
||||
const props = defineProps<{
|
||||
flights: Flight[]
|
||||
@@ -93,17 +92,4 @@ const chartOptions = computed(() => ({
|
||||
</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;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,18 +1,17 @@
|
||||
<template>
|
||||
<div class="chart-wrap">
|
||||
<div class="chart-title">Flights per year</div>
|
||||
<apexchart
|
||||
<FlightChart
|
||||
title="Flights per year"
|
||||
type="bar"
|
||||
height="220"
|
||||
:options="chartOptions"
|
||||
:series="series"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { Flight } from '@/Types/types'
|
||||
import FlightChart from "@/Components/FlightsGoneBy/FlightChart.vue";
|
||||
|
||||
const props = defineProps<{
|
||||
flights: Flight[]
|
||||
@@ -107,17 +106,5 @@ const chartOptions = computed(() => ({
|
||||
</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;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,20 +1,18 @@
|
||||
<template>
|
||||
<div class="chart-wrap">
|
||||
<div class="chart-title">Seat types</div>
|
||||
<apexchart
|
||||
v-if="series.length"
|
||||
type="donut"
|
||||
height="280"
|
||||
:options="chartOptions"
|
||||
:series="seriesData"
|
||||
/>
|
||||
<div v-else class="chart-empty">No seat type data available</div>
|
||||
</div>
|
||||
<FlightChart
|
||||
title="Seat Types"
|
||||
v-if="series.length"
|
||||
type="donut"
|
||||
height="280"
|
||||
:options="chartOptions"
|
||||
:series="seriesData"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { Flight } from '@/Types/types'
|
||||
import FlightChart from "@/Components/FlightsGoneBy/FlightChart.vue";
|
||||
|
||||
const props = defineProps<{
|
||||
flights: Flight[]
|
||||
@@ -87,26 +85,4 @@ const chartOptions = computed(() => ({
|
||||
</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-empty {
|
||||
height: 280px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 13px;
|
||||
color: #445566;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
<div v-if="series.length" class="chart-outer">
|
||||
<div class="chart-scroll" :style="{ height: scrollHeight }" @mousemove="onMouseMove" @mouseleave="onMouseLeave">
|
||||
<apexchart
|
||||
<UnstyledFlightChart
|
||||
type="bar"
|
||||
:height="chartHeight"
|
||||
:options="chartOptions"
|
||||
@@ -34,6 +34,7 @@ import type {Flight, SharedProps} from '@/Types/types'
|
||||
import { useChartTooltip } from '@/Composables/useChartTooltip'
|
||||
import ChartTooltip from '@/Components/FlightsGoneBy/Charts/ChartTooltip.vue'
|
||||
import {usePage} from "@inertiajs/vue3";
|
||||
import UnstyledFlightChart from "@/Components/FlightsGoneBy/UnstyledFlightChart.vue";
|
||||
|
||||
const page = usePage<SharedProps>().props
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
<div v-if="series.length" class="chart-outer">
|
||||
<div class="chart-scroll" :style="{ height: scrollHeight }" @mousemove="onMouseMove" @mouseleave="onMouseLeave">
|
||||
<apexchart
|
||||
<UnstyledFlightChart
|
||||
type="bar"
|
||||
:height="chartHeight"
|
||||
:options="chartOptions"
|
||||
@@ -44,6 +44,7 @@ import { computed } from 'vue'
|
||||
import type { Airport, Flight } from '@/Types/types'
|
||||
import { useChartTooltip } from '@/Composables/useChartTooltip'
|
||||
import ChartTooltip from '@/Components/FlightsGoneBy/Charts/ChartTooltip.vue'
|
||||
import UnstyledFlightChart from "@/Components/FlightsGoneBy/UnstyledFlightChart.vue";
|
||||
|
||||
const props = defineProps<{
|
||||
flights: Flight[]
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import PlaneLoader from "@/Components/FlightsGoneBy/PlaneLoader.vue";
|
||||
import VueApexCharts from 'vue3-apexcharts'
|
||||
import {ChartType} from "@/Types/types";
|
||||
|
||||
defineProps<{
|
||||
type: ChartType
|
||||
title: string
|
||||
height: number | string
|
||||
options: object
|
||||
series: unknown[]
|
||||
}>()
|
||||
|
||||
const ready = ref(false)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="chart-wrap">
|
||||
<div class="chart-title">{{title}}</div>
|
||||
<PlaneLoader v-if="!ready" />
|
||||
<VueApexCharts
|
||||
:type="type"
|
||||
:height="height"
|
||||
:options="options"
|
||||
:series="series"
|
||||
:class="{ 'chart-hidden': !ready }"
|
||||
@mounted="ready = true"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<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-empty {
|
||||
height: 280px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 13px;
|
||||
color: #445566;
|
||||
}
|
||||
</style>
|
||||
+141
-192
@@ -1,170 +1,45 @@
|
||||
<template>
|
||||
<FlightMap :page="page.props" :flights="mappedFlights" class="profile-map__map" />
|
||||
|
||||
<div class="profile-map__toolbar">
|
||||
<v-select
|
||||
v-model="selectedYears"
|
||||
:items="availableYears"
|
||||
label="Year"
|
||||
multiple
|
||||
clearable
|
||||
hide-details
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
>
|
||||
<template #selection="{ item, index }">
|
||||
<span v-if="index < 2" class="v-select__selection-text">
|
||||
{{ item }}<span v-if="index < Math.min(selectedYears.length, 2) - 1">, </span>
|
||||
</span>
|
||||
<span v-if="index === 2" class="text-caption text-medium-emphasis">+{{ selectedYears.length - 2 }}</span>
|
||||
</template>
|
||||
</v-select>
|
||||
|
||||
<v-select
|
||||
v-model="selectedAirlines"
|
||||
:items="availableAirlines"
|
||||
item-title="name"
|
||||
item-value="id"
|
||||
label="Airline"
|
||||
multiple
|
||||
clearable
|
||||
hide-details
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
>
|
||||
<template #item="{ item, props: itemProps }">
|
||||
<v-list-item v-bind="itemProps">
|
||||
<template #prepend="{ isSelected }">
|
||||
<v-checkbox-btn :model-value="isSelected" tabindex="-1" />
|
||||
</template>
|
||||
<template #title>
|
||||
<img
|
||||
:src="airlineLogoUrl((item as any).id)"
|
||||
width="32"
|
||||
height="32"
|
||||
style="object-fit: contain; margin-right: 8px; vertical-align: middle;"
|
||||
alt=""
|
||||
/>
|
||||
{{ (item as any).name }}
|
||||
</template>
|
||||
</v-list-item>
|
||||
</template>
|
||||
<template #selection="{ item, index }">
|
||||
<span v-if="index < 2" class="v-select__selection-text">
|
||||
{{ (item as any).name }}<span v-if="index < Math.min(selectedAirlines.length, 2) - 1">, </span>
|
||||
</span>
|
||||
<span v-if="index === 2" class="text-caption text-medium-emphasis">+{{ selectedAirlines.length - 2 }}</span>
|
||||
</template>
|
||||
</v-select>
|
||||
|
||||
<v-select
|
||||
v-model="selectedCountries"
|
||||
:items="availableCountries"
|
||||
item-title="name"
|
||||
item-value="code"
|
||||
label="Country"
|
||||
multiple
|
||||
clearable
|
||||
hide-details
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
>
|
||||
<template #item="{ item, props: itemProps }">
|
||||
<v-list-item v-bind="itemProps">
|
||||
<template #prepend="{ isSelected }">
|
||||
<v-checkbox-btn :model-value="isSelected" tabindex="-1" />
|
||||
</template>
|
||||
<template #title>
|
||||
<span :class="countryFlagClass((item as any).code)" style="margin-right: 8px; font-size: 1.1em;" />
|
||||
{{ (item as any).name }}
|
||||
</template>
|
||||
</v-list-item>
|
||||
</template>
|
||||
<template #selection="{ item, index }">
|
||||
<span v-if="index < 2" class="v-select__selection-text">
|
||||
<span :class="countryFlagClass((item as any).code)" style="margin-right: 4px;" />
|
||||
{{ (item as any).name }}<span v-if="index < Math.min(selectedCountries.length, 2) - 1">, </span>
|
||||
</span>
|
||||
<span v-if="index === 2" class="text-caption text-medium-emphasis">+{{ selectedCountries.length - 2 }}</span>
|
||||
</template>
|
||||
</v-select>
|
||||
|
||||
<v-select
|
||||
v-model="selectedContinents"
|
||||
:items="availableContinents"
|
||||
item-title="name"
|
||||
item-value="code"
|
||||
label="Continent"
|
||||
multiple
|
||||
clearable
|
||||
hide-details
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
>
|
||||
<template #selection="{ item, index }">
|
||||
<span v-if="index < 2" class="v-select__selection-text">
|
||||
{{ (item as any).name }}<span v-if="index < Math.min(selectedContinents.length, 2) - 1">, </span>
|
||||
</span>
|
||||
<span v-if="index === 2" class="text-caption text-medium-emphasis">+{{ selectedContinents.length - 2 }}</span>
|
||||
</template>
|
||||
</v-select>
|
||||
|
||||
<v-select
|
||||
v-model="selectedFlightClasses"
|
||||
:items="availableFlightClasses"
|
||||
item-title="name"
|
||||
item-value="id"
|
||||
label="Flight class"
|
||||
multiple
|
||||
clearable
|
||||
hide-details
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
>
|
||||
<template #selection="{ item, index }">
|
||||
<span v-if="index < 2" class="v-select__selection-text">
|
||||
{{ (item as any).name }}<span v-if="index < Math.min(selectedFlightClasses.length, 2) - 1">, </span>
|
||||
</span>
|
||||
<span v-if="index === 2" class="text-caption text-medium-emphasis">+{{ selectedFlightClasses.length - 2 }}</span>
|
||||
</template>
|
||||
</v-select>
|
||||
</div>
|
||||
|
||||
<FlightStatsBar :flights="pastFlights" :upcoming-flights="upcomingFlights" />
|
||||
<FlightCharts :flights="pastFlights" :upcoming-flights="upcomingFlights" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Flight } from '@/Types/types'
|
||||
import { computed, ref } from 'vue'
|
||||
import { usePage } from '@inertiajs/vue3'
|
||||
import FlightMap from '@/Components/FlightsGoneBy/FlightMap.vue'
|
||||
import type { Flight, SharedProps } from '@/Types/types'
|
||||
import FlightStatsBar from '@/Components/FlightsGoneBy/FlightStatsBar.vue'
|
||||
import FlightCharts from '@/Components/FlightsGoneBy/FlightCharts.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
flights: Flight[]
|
||||
}>()
|
||||
|
||||
const page = usePage<SharedProps>()
|
||||
const now = new Date()
|
||||
|
||||
const selectedYears = ref<number[]>([])
|
||||
const selectedAirlines = ref<number[]>([])
|
||||
const selectedCountries = ref<string[]>([])
|
||||
const selectedContinents = ref<string[]>([])
|
||||
const selectedFlightClasses = ref<number[]>([])
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
const emit = defineEmits<{
|
||||
change: [filters: {
|
||||
years: number[]
|
||||
airlines: number[]
|
||||
countries: string[]
|
||||
continents: string[]
|
||||
flightClasses: number[]
|
||||
}]
|
||||
}>()
|
||||
|
||||
function emitFilters() {
|
||||
emit('change', {
|
||||
years: selectedYears.value,
|
||||
airlines: selectedAirlines.value,
|
||||
countries: selectedCountries.value,
|
||||
continents: selectedContinents.value,
|
||||
flightClasses: selectedFlightClasses.value,
|
||||
})
|
||||
}
|
||||
|
||||
const page = usePage()
|
||||
const airlineLogoUrl = (id: number) =>
|
||||
`${page.props.logo_api_url}/airlines/logos/tail/id/${id}`
|
||||
|
||||
const countryFlagClass = (code: string) =>
|
||||
`fi fi-${code.toLowerCase()}`
|
||||
|
||||
// ── Available filter options ──────────────────────────────────────────────────
|
||||
|
||||
const availableYears = computed(() => {
|
||||
const years = new Set<number>()
|
||||
props.flights.forEach(f => years.add(new Date(f.departure_date).getFullYear()))
|
||||
@@ -174,9 +49,8 @@ const availableYears = computed(() => {
|
||||
const availableAirlines = computed((): { id: number; name: string }[] => {
|
||||
const map = new Map<number, { id: number; name: string }>()
|
||||
props.flights.forEach(f => {
|
||||
if (f.airline?.id && f.airline?.name) {
|
||||
if (f.airline?.id && f.airline?.name)
|
||||
map.set(f.airline.id, { id: f.airline.id, name: f.airline.name })
|
||||
}
|
||||
})
|
||||
return [...map.values()].sort((a, b) => a.name.localeCompare(b.name))
|
||||
})
|
||||
@@ -206,63 +80,138 @@ const availableContinents = computed((): { code: string; name: string }[] => {
|
||||
const availableFlightClasses = computed((): { id: number; name: string }[] => {
|
||||
const map = new Map<number, { id: number; name: string }>()
|
||||
props.flights.forEach(f => {
|
||||
if (f.flight_class?.id && f.flight_class?.name) {
|
||||
if (f.flight_class?.id && f.flight_class?.name)
|
||||
map.set(f.flight_class.id, { id: f.flight_class.id, name: f.flight_class.name })
|
||||
}
|
||||
})
|
||||
return [...map.values()].sort((a, b) => a.name.localeCompare(b.name))
|
||||
})
|
||||
|
||||
// ── Filter helper ─────────────────────────────────────────────────────────────
|
||||
|
||||
function matchesFilters(f: Flight, date: Date): boolean {
|
||||
if (selectedYears.value.length && !selectedYears.value.includes(date.getFullYear())) return false
|
||||
if (selectedAirlines.value.length && !selectedAirlines.value.includes(f.airline?.id ?? -1)) return false
|
||||
if (selectedCountries.value.length) {
|
||||
const depCode = f.departure_airport.region?.country?.code
|
||||
const arrCode = f.arrival_airport.region?.country?.code
|
||||
if (!selectedCountries.value.includes(depCode ?? '') && !selectedCountries.value.includes(arrCode ?? '')) return false
|
||||
}
|
||||
if (selectedContinents.value.length) {
|
||||
const depCode = f.departure_airport.region?.continent?.code
|
||||
const arrCode = f.arrival_airport.region?.continent?.code
|
||||
if (!selectedContinents.value.includes(depCode ?? '') && !selectedContinents.value.includes(arrCode ?? '')) return false
|
||||
}
|
||||
if (selectedFlightClasses.value.length && !selectedFlightClasses.value.includes(f.flight_class?.id ?? -1)) return false
|
||||
return true
|
||||
}
|
||||
|
||||
// ── Filtered flights ──────────────────────────────────────────────────────────
|
||||
|
||||
const pastFlights = computed(() =>
|
||||
props.flights.filter(f => {
|
||||
const date = new Date(f.departure_date)
|
||||
return date <= now && matchesFilters(f, date)
|
||||
})
|
||||
)
|
||||
|
||||
const upcomingFlights = computed(() =>
|
||||
props.flights.filter(f => {
|
||||
const date = new Date(f.departure_date)
|
||||
return date > now && matchesFilters(f, date)
|
||||
})
|
||||
)
|
||||
|
||||
const mappedFlights = computed(() => [
|
||||
...pastFlights.value,
|
||||
...upcomingFlights.value,
|
||||
])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flight-filters">
|
||||
<v-select
|
||||
v-model="selectedYears"
|
||||
:items="availableYears"
|
||||
label="Year"
|
||||
multiple clearable hide-details
|
||||
density="compact" variant="outlined"
|
||||
@update:model-value="emitFilters"
|
||||
>
|
||||
<template #selection="{ item, index }">
|
||||
<span v-if="index < 2" class="v-select__selection-text">
|
||||
{{ item }}<span v-if="index < Math.min(selectedYears.length, 2) - 1">, </span>
|
||||
</span>
|
||||
<span v-if="index === 2" class="text-caption text-medium-emphasis">+{{ selectedYears.length - 2 }}</span>
|
||||
</template>
|
||||
</v-select>
|
||||
|
||||
<v-select
|
||||
v-model="selectedAirlines"
|
||||
:items="availableAirlines"
|
||||
item-title="name" item-value="id"
|
||||
label="Airline"
|
||||
multiple clearable hide-details
|
||||
density="compact" variant="outlined"
|
||||
@update:model-value="emitFilters"
|
||||
>
|
||||
<template #item="{ item, props: itemProps }">
|
||||
<v-list-item v-bind="itemProps">
|
||||
<template #prepend="{ isSelected }">
|
||||
<v-checkbox-btn :model-value="isSelected" tabindex="-1" />
|
||||
</template>
|
||||
<template #title>
|
||||
<img
|
||||
:src="airlineLogoUrl((item as any).id)"
|
||||
width="32" height="32"
|
||||
style="object-fit: contain; margin-right: 8px; vertical-align: middle;"
|
||||
alt=""
|
||||
/>
|
||||
{{ (item as any).name }}
|
||||
</template>
|
||||
</v-list-item>
|
||||
</template>
|
||||
<template #selection="{ item, index }">
|
||||
<span v-if="index < 2" class="v-select__selection-text">
|
||||
{{ (item as any).name }}<span v-if="index < Math.min(selectedAirlines.length, 2) - 1">, </span>
|
||||
</span>
|
||||
<span v-if="index === 2" class="text-caption text-medium-emphasis">+{{ selectedAirlines.length - 2 }}</span>
|
||||
</template>
|
||||
</v-select>
|
||||
|
||||
<v-select
|
||||
v-model="selectedCountries"
|
||||
:items="availableCountries"
|
||||
item-title="name" item-value="code"
|
||||
label="Country"
|
||||
multiple clearable hide-details
|
||||
density="compact" variant="outlined"
|
||||
@update:model-value="emitFilters"
|
||||
>
|
||||
<template #item="{ item, props: itemProps }">
|
||||
<v-list-item v-bind="itemProps">
|
||||
<template #prepend="{ isSelected }">
|
||||
<v-checkbox-btn :model-value="isSelected" tabindex="-1" />
|
||||
</template>
|
||||
<template #title>
|
||||
<span :class="countryFlagClass((item as any).code)" style="margin-right: 8px; font-size: 1.1em;" />
|
||||
{{ (item as any).name }}
|
||||
</template>
|
||||
</v-list-item>
|
||||
</template>
|
||||
<template #selection="{ item, index }">
|
||||
<span v-if="index < 2" class="v-select__selection-text">
|
||||
<span :class="countryFlagClass((item as any).code)" style="margin-right: 4px;" />
|
||||
{{ (item as any).name }}<span v-if="index < Math.min(selectedCountries.length, 2) - 1">, </span>
|
||||
</span>
|
||||
<span v-if="index === 2" class="text-caption text-medium-emphasis">+{{ selectedCountries.length - 2 }}</span>
|
||||
</template>
|
||||
</v-select>
|
||||
|
||||
<v-select
|
||||
v-model="selectedContinents"
|
||||
:items="availableContinents"
|
||||
item-title="name" item-value="code"
|
||||
label="Continent"
|
||||
multiple clearable hide-details
|
||||
density="compact" variant="outlined"
|
||||
@update:model-value="emitFilters"
|
||||
>
|
||||
<template #selection="{ item, index }">
|
||||
<span v-if="index < 2" class="v-select__selection-text">
|
||||
{{ (item as any).name }}<span v-if="index < Math.min(selectedContinents.length, 2) - 1">, </span>
|
||||
</span>
|
||||
<span v-if="index === 2" class="text-caption text-medium-emphasis">+{{ selectedContinents.length - 2 }}</span>
|
||||
</template>
|
||||
</v-select>
|
||||
|
||||
<v-select
|
||||
v-model="selectedFlightClasses"
|
||||
:items="availableFlightClasses"
|
||||
item-title="name" item-value="id"
|
||||
label="Flight class"
|
||||
multiple clearable hide-details
|
||||
density="compact" variant="outlined"
|
||||
@update:model-value="emitFilters"
|
||||
>
|
||||
<template #selection="{ item, index }">
|
||||
<span v-if="index < 2" class="v-select__selection-text">
|
||||
{{ (item as any).name }}<span v-if="index < Math.min(selectedFlightClasses.length, 2) - 1">, </span>
|
||||
</span>
|
||||
<span v-if="index === 2" class="text-caption text-medium-emphasis">+{{ selectedFlightClasses.length - 2 }}</span>
|
||||
</template>
|
||||
</v-select>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.profile-map__toolbar {
|
||||
.flight-filters {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.profile-map__toolbar .v-select {
|
||||
.flight-filters .v-select {
|
||||
flex: 1 1 160px;
|
||||
}
|
||||
</style>
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
<template>
|
||||
<FlightMap :page="page.props" :flights="mappedFlights" class="profile-map__map" />
|
||||
<FlightFillter :flights="flights" @change="onFiltersChange" />
|
||||
<FlightStatsBar :flights="pastFlights" :upcoming-flights="upcomingFlights" />
|
||||
<FlightCharts :flights="pastFlights" :upcoming-flights="upcomingFlights" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { usePage } from '@inertiajs/vue3'
|
||||
import type { Flight, SharedProps } from '@/Types/types'
|
||||
import FlightStatsBar from '@/Components/FlightsGoneBy/FlightStatsBar.vue'
|
||||
|
||||
import FlightFillter from "@/Components/FlightsGoneBy/FlightFillter.vue";
|
||||
import FlightMap from "@/Components/FlightsGoneBy/FlightMap.vue";
|
||||
import FlightCharts from "@/Components/FlightsGoneBy/FlightCharts.vue";
|
||||
|
||||
const props = defineProps<{
|
||||
flights: Flight[]
|
||||
}>()
|
||||
|
||||
const page = usePage<SharedProps>()
|
||||
const now = new Date()
|
||||
|
||||
const selectedYears = ref<number[]>([])
|
||||
const selectedAirlines = ref<number[]>([])
|
||||
const selectedCountries = ref<string[]>([])
|
||||
const selectedContinents = ref<string[]>([])
|
||||
const selectedFlightClasses = ref<number[]>([])
|
||||
|
||||
function onFiltersChange(filters: {
|
||||
years: number[]
|
||||
airlines: number[]
|
||||
countries: string[]
|
||||
continents: string[]
|
||||
flightClasses: number[]
|
||||
}) {
|
||||
selectedYears.value = filters.years
|
||||
selectedAirlines.value = filters.airlines
|
||||
selectedCountries.value = filters.countries
|
||||
selectedContinents.value = filters.continents
|
||||
selectedFlightClasses.value = filters.flightClasses
|
||||
}
|
||||
|
||||
function matchesFilters(f: Flight, date: Date): boolean {
|
||||
if (selectedYears.value.length && !selectedYears.value.includes(date.getFullYear())) return false
|
||||
if (selectedAirlines.value.length && !selectedAirlines.value.includes(f.airline?.id ?? -1)) return false
|
||||
if (selectedCountries.value.length) {
|
||||
const depCode = f.departure_airport.region?.country?.code
|
||||
const arrCode = f.arrival_airport.region?.country?.code
|
||||
if (!selectedCountries.value.includes(depCode ?? '') && !selectedCountries.value.includes(arrCode ?? '')) return false
|
||||
}
|
||||
if (selectedContinents.value.length) {
|
||||
const depCode = f.departure_airport.region?.continent?.code
|
||||
const arrCode = f.arrival_airport.region?.continent?.code
|
||||
if (!selectedContinents.value.includes(depCode ?? '') && !selectedContinents.value.includes(arrCode ?? '')) return false
|
||||
}
|
||||
if (selectedFlightClasses.value.length && !selectedFlightClasses.value.includes(f.flight_class?.id ?? -1)) return false
|
||||
return true
|
||||
}
|
||||
|
||||
// ── Filtered flights ──────────────────────────────────────────────────────────
|
||||
|
||||
const pastFlights = computed(() =>
|
||||
props.flights.filter(f => {
|
||||
const date = new Date(f.departure_date)
|
||||
return date <= now && matchesFilters(f, date)
|
||||
})
|
||||
)
|
||||
|
||||
const upcomingFlights = computed(() =>
|
||||
props.flights.filter(f => {
|
||||
const date = new Date(f.departure_date)
|
||||
return date > now && matchesFilters(f, date)
|
||||
})
|
||||
)
|
||||
|
||||
const mappedFlights = computed(() => [
|
||||
...pastFlights.value,
|
||||
...upcomingFlights.value,
|
||||
])
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
</style>
|
||||
@@ -0,0 +1,31 @@
|
||||
<script setup lang="ts">
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="120" height="120" viewBox="0 0 120 120">
|
||||
<circle class="ring-spin" cx="60" cy="60" r="54" fill="none" stroke="#ffc107" stroke-width="1" stroke-dasharray="4 6" opacity="0.6"/>
|
||||
|
||||
<!-- Static inner ring -->
|
||||
<circle cx="60" cy="60" r="46" fill="none" stroke="#ffc107" stroke-width="0.5" opacity="0.3"/>
|
||||
|
||||
<!-- Pulsing dot markers on inner ring -->
|
||||
<circle class="ring-pulse" cx="60" cy="14" r="2" fill="#ffc107"/>
|
||||
<circle class="ring-pulse" cx="106" cy="60" r="2" fill="#ffc107" style="animation-delay: 0.5s"/>
|
||||
<circle class="ring-pulse" cx="60" cy="106" r="2" fill="#ffc107" style="animation-delay: 1s"/>
|
||||
<circle class="ring-pulse" cx="14" cy="60" r="2" fill="#ffc107" style="animation-delay: 1.5s"/>
|
||||
<text x="60" y="63" text-anchor="middle" font-size="6" letter-spacing="2.5" fill="#ffc107" font-family="monospace" opacity="0.7">Loading</text>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
@keyframes pulse {
|
||||
0%,100% { opacity: 0.3; }
|
||||
50% { opacity: 1; }
|
||||
}
|
||||
.ring-spin { animation: spin 8s linear infinite; transform-origin: 60px 60px; }
|
||||
.ring-pulse { animation: pulse 2s ease-in-out infinite; }
|
||||
</style>
|
||||
@@ -0,0 +1,72 @@
|
||||
<script setup lang="ts">
|
||||
import {Flight, User} from "@/Types/types";
|
||||
|
||||
defineProps<{
|
||||
user: User
|
||||
flights: Flight[]
|
||||
}>()
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="board-header">
|
||||
<div class="board-title-group">
|
||||
<span class="board-eyebrow">FLIGHT HISTORY</span>
|
||||
<h1 class="board-title">{{ user.name }}</h1>
|
||||
</div>
|
||||
<div class="board-count">
|
||||
<span class="count-number">{{ flights.length }}</span>
|
||||
<span class="count-label">FLIGHTS</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* ── Header ── */
|
||||
.board-header {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 2rem;
|
||||
border-bottom: 1px solid rgba(255,193,7,0.2);
|
||||
padding-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.board-eyebrow {
|
||||
display: block;
|
||||
font-family: 'Share Tech Mono', monospace;
|
||||
font-size: 0.7rem;
|
||||
letter-spacing: 0.2em;
|
||||
color: #ffc107;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.board-title {
|
||||
font-family: 'Barlow Condensed', sans-serif;
|
||||
font-size: 2.2rem;
|
||||
font-weight: 700;
|
||||
color: #f0f2f5;
|
||||
letter-spacing: 0.04em;
|
||||
line-height: 1;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.board-count {
|
||||
text-align: right;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.count-number {
|
||||
display: block;
|
||||
font-family: 'Share Tech Mono', monospace;
|
||||
font-size: 2rem;
|
||||
color: #ffc107;
|
||||
}
|
||||
|
||||
.count-label {
|
||||
font-family: 'Share Tech Mono', monospace;
|
||||
font-size: 0.65rem;
|
||||
letter-spacing: 0.18em;
|
||||
color: #556;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,25 @@
|
||||
<script setup lang="ts">
|
||||
import {Flight, User} from "@/Types/types";
|
||||
import ProfileHeader from "@/Components/FlightsGoneBy/ProfileHeader.vue";
|
||||
|
||||
defineProps<{
|
||||
user: User
|
||||
flights: Flight[]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="board-wrapper">
|
||||
<ProfileHeader :user="user" :flights="flights" />
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.board-wrapper {
|
||||
min-height: 100dvh;
|
||||
padding: 2.5rem 2rem;
|
||||
font-family: 'Barlow', sans-serif;
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,89 @@
|
||||
<script setup lang="ts">
|
||||
import {ProfileView} from "@/Types/types";
|
||||
|
||||
defineProps<{
|
||||
activeView: string;
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:activeView': [view: ProfileView]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="view-toolbar">
|
||||
<button
|
||||
class="view-btn"
|
||||
:class="{ active: activeView === 'map' }"
|
||||
@click="emit('update:activeView', 'map')"
|
||||
>
|
||||
<span class="view-btn-icon mdi mdi-earth"></span>
|
||||
<span class="view-btn-label">MAP</span>
|
||||
</button>
|
||||
<button
|
||||
class="view-btn"
|
||||
:class="{ active: activeView === 'board' }"
|
||||
@click="emit('update:activeView', 'board')"
|
||||
>
|
||||
<span class="view-btn-icon mdi mdi-table"></span>
|
||||
<span class="view-btn-label">DEPARTURE BOARD</span>
|
||||
</button>
|
||||
<button
|
||||
class="view-btn"
|
||||
:class="{ active: activeView === 'passes' }"
|
||||
@click="emit('update:activeView', 'passes')"
|
||||
>
|
||||
<span class="view-btn-icon mdi mdi-ticket"></span>
|
||||
<span class="view-btn-label">BOARDING PASSES</span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* ── View toolbar ── */
|
||||
.view-toolbar {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
margin-bottom: 1.5rem;
|
||||
border: 1px solid rgba(255,193,7,0.15);
|
||||
border-radius: 3px;
|
||||
width: fit-content;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.view-btn {
|
||||
text-decoration: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 1.25rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-right: 1px solid rgba(255,193,7,0.15);
|
||||
color: #556;
|
||||
font-family: 'Share Tech Mono', monospace;
|
||||
font-size: 0.68rem;
|
||||
letter-spacing: 0.15em;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.view-btn:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.view-btn:hover {
|
||||
background: rgba(255,193,7,0.05);
|
||||
color: #9aa;
|
||||
}
|
||||
|
||||
.view-btn.active {
|
||||
background: rgba(255,193,7,0.08);
|
||||
color: #ffc107;
|
||||
}
|
||||
|
||||
.view-btn-icon {
|
||||
font-size: 0.8rem;
|
||||
opacity: 0.8;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,31 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import PlaneLoader from "@/Components/FlightsGoneBy/PlaneLoader.vue";
|
||||
import {ChartType} from "@/Types/types";
|
||||
import VueApexCharts from 'vue3-apexcharts'
|
||||
|
||||
|
||||
defineProps<{
|
||||
type: ChartType
|
||||
height: number | string
|
||||
options: object
|
||||
series: unknown[]
|
||||
}>()
|
||||
|
||||
const ready = ref(false)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PlaneLoader v-if="!ready" />
|
||||
<VueApexCharts
|
||||
:type="type"
|
||||
:height="height"
|
||||
:options="options"
|
||||
:series="series"
|
||||
:class="{ 'chart-hidden': !ready }"
|
||||
@mounted="ready = true"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
</style>
|
||||
Reference in New Issue
Block a user