Add more airlines and fix edit bugs

This commit is contained in:
2026-04-20 09:23:26 +10:00
parent 4244b8835d
commit 8d7d8f02d3
66 changed files with 877 additions and 614 deletions
@@ -10,8 +10,7 @@ use Inertia\Inertia;
class FlightProfileController extends Controller class FlightProfileController extends Controller
{ {
public function view(string $username) public function profileData(string $username, string $view) : array {
{
$user = User::whereRaw(DB::raw('LOWER(name) = ?'), [strtolower($username)])->firstOrFail(); $user = User::whereRaw(DB::raw('LOWER(name) = ?'), [strtolower($username)])->firstOrFail();
$flights = UserFlight::where('user_id', $user->id) $flights = UserFlight::where('user_id', $user->id)
@@ -29,10 +28,31 @@ class FlightProfileController extends Controller
->orderBy('departure_date', 'desc') ->orderBy('departure_date', 'desc')
->get(); ->get();
return Inertia::render('FlightProfile', [ return [
'user' => $user, 'user' => $user,
'canEdit' => auth()->check() && auth()->id() === $user->id, 'canEdit' => auth()->check() && auth()->id() === $user->id,
'flights' => UserFlightResource::collection($flights)->resolve(), 'flights' => UserFlightResource::collection($flights)->resolve(),
]); 'initialView' => $view,
];
}
public function departureBoard(string $username){
$profileData = $this->profileData($username, 'board');
return Inertia::render('UserProfile', $profileData);
}
public function map(string $username){
$profileData = $this->profileData($username, 'map');
return Inertia::render('UserProfile', $profileData);
}
public function boardingPasses(string $username){
$profileData = $this->profileData($username, 'passes');
return Inertia::render('UserProfile', $profileData);
}
public function view(string $username)
{
return $this->departureBoard($username);
} }
} }
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+52
View File
@@ -15,6 +15,58 @@
} }
/* Share Tech Mono */
@font-face {
font-family: 'Share Tech Mono';
src: url('/fonts/share-tech-mono-v16-latin-regular.woff2') format('woff2');
font-weight: 400;
font-style: normal;
font-display: swap;
}
/* Barlow */
@font-face {
font-family: 'Barlow';
src: url('/fonts/barlow-v13-latin_latin-ext-regular.woff2') format('woff2');
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Barlow';
src: url('/fonts/barlow-v13-latin_latin-ext-500.woff2') format('woff2');
font-weight: 500;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Barlow';
src: url('/fonts/barlow-v13-latin_latin-ext-600.woff2') format('woff2');
font-weight: 600;
font-style: normal;
font-display: swap;
}
/* Barlow Condensed */
@font-face {
font-family: 'Barlow Condensed';
src: url('/fonts/barlow-condensed-v13-latin-500.woff2') format('woff2');
font-weight: 500;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Barlow Condensed';
src: url('/fonts/barlow-condensed-v13-latin-700.woff2') format('woff2');
font-weight: 700;
font-style: normal;
font-display: swap;
}
a { a {
cursor: pointer; cursor: pointer;
text-decoration: none; text-decoration: none;
@@ -5,6 +5,7 @@ import BoardingPass from "@/Components/FlightsGoneBy/BoardingPass.vue";
const props = defineProps<{ const props = defineProps<{
flights: Flight[] flights: Flight[]
canEdit: boolean
}>() }>()
const today = new Date() const today = new Date()
@@ -1,20 +1,18 @@
<template> <template>
<div class="chart-wrap"> <FlightChart
<div class="chart-title">Continents visited</div> title="Continents"
<apexchart
v-if="series.length" v-if="series.length"
type="donut" type="donut"
height="280" height="280"
:options="chartOptions" :options="chartOptions"
:series="seriesData" :series="seriesData"
/> />
<div v-else class="chart-empty">No continent data available</div>
</div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed } from 'vue'
import type { Flight } from '@/Types/types' import type { Flight } from '@/Types/types'
import FlightChart from "@/Components/FlightsGoneBy/FlightChart.vue";
const props = defineProps<{ const props = defineProps<{
flights: Flight[] flights: Flight[]
@@ -4,7 +4,7 @@
<div v-if="series.length" class="chart-outer"> <div v-if="series.length" class="chart-outer">
<div class="chart-scroll" :style="{ height: scrollHeight }" @mousemove="onMouseMove" @mouseleave="onMouseLeave"> <div class="chart-scroll" :style="{ height: scrollHeight }" @mousemove="onMouseMove" @mouseleave="onMouseLeave">
<apexchart <UnstyledFlightChart
type="bar" type="bar"
:height="chartHeight" :height="chartHeight"
:options="chartOptions" :options="chartOptions"
@@ -36,6 +36,7 @@ import { computed } from 'vue'
import type { Flight } from '@/Types/types' import type { Flight } from '@/Types/types'
import { useChartTooltip} from "@/Composables/useChartTooltip"; import { useChartTooltip} from "@/Composables/useChartTooltip";
import ChartTooltip from '@/Components/FlightsGoneBy/Charts/ChartTooltip.vue' import ChartTooltip from '@/Components/FlightsGoneBy/Charts/ChartTooltip.vue'
import UnstyledFlightChart from "@/Components/FlightsGoneBy/UnstyledFlightChart.vue";
const props = defineProps<{ const props = defineProps<{
flights: Flight[] flights: Flight[]
@@ -1,20 +1,18 @@
<template> <template>
<div class="chart-wrap"> <FlightChart
<div class="chart-title">Flight classes</div> title="Flight Classes"
<apexchart
v-if="series.length" v-if="series.length"
type="donut" type="donut"
height="280" height="280"
:options="chartOptions" :options="chartOptions"
:series="seriesData" :series="seriesData"
/> />
<div v-else class="chart-empty">No flight class data available</div>
</div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed } from 'vue'
import type { Flight } from '@/Types/types' import type { Flight } from '@/Types/types'
import FlightChart from "@/Components/FlightsGoneBy/FlightChart.vue";
const props = defineProps<{ const props = defineProps<{
flights: Flight[] flights: Flight[]
@@ -87,26 +85,5 @@ const chartOptions = computed(() => ({
</script> </script>
<style scoped> <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> </style>
@@ -1,20 +1,18 @@
<template> <template>
<div class="chart-wrap"> <FlightChart
<div class="chart-title">Flight reasons</div> title="Flight Reasons"
<apexchart
v-if="series.length" v-if="series.length"
type="donut" type="donut"
height="280" height="280"
:options="chartOptions" :options="chartOptions"
:series="seriesData" :series="seriesData"
/> />
<div v-else class="chart-empty">No flight reason data available</div>
</div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed } from 'vue'
import type { Flight } from '@/Types/types' import type { Flight } from '@/Types/types'
import FlightChart from "@/Components/FlightsGoneBy/FlightChart.vue";
const props = defineProps<{ const props = defineProps<{
flights: Flight[] flights: Flight[]
@@ -1,20 +1,18 @@
<template> <template>
<div class="chart-wrap"> <FlightChart
<div class="chart-title">International vs Domestic</div> title="International vs Domestic"
<apexchart
v-if="series.length" v-if="series.length"
type="donut" type="donut"
height="280" height="280"
:options="chartOptions" :options="chartOptions"
:series="seriesData" :series="seriesData"
/> />
<div v-else class="chart-empty">No flight data available</div>
</div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed } from 'vue'
import type { Flight } from '@/Types/types' import type { Flight } from '@/Types/types'
import FlightChart from "@/Components/FlightsGoneBy/FlightChart.vue";
const props = defineProps<{ const props = defineProps<{
flights: Flight[] flights: Flight[]
@@ -90,37 +88,4 @@ const chartOptions = computed(() => ({
</script> </script>
<style scoped> <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> </style>
@@ -1,18 +1,17 @@
<template> <template>
<div class="chart-wrap"> <FlightChart
<div class="chart-title">Flights per day of week</div> title="Flights per day of week"
<apexchart
type="bar" type="bar"
height="220" height="220"
:options="chartOptions" :options="chartOptions"
:series="series" :series="series"
/> />
</div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed } from 'vue'
import type { Flight } from '@/Types/types' import type { Flight } from '@/Types/types'
import FlightChart from "@/Components/FlightsGoneBy/FlightChart.vue";
const props = defineProps<{ const props = defineProps<{
flights: Flight[] flights: Flight[]
@@ -97,17 +96,4 @@ const chartOptions = computed(() => ({
</script> </script>
<style scoped> <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> </style>
@@ -1,18 +1,17 @@
<template> <template>
<div class="chart-wrap"> <FlightChart
<div class="chart-title">Flights per month</div> title="Flights per month"
<apexchart type="bar"
type="bar" height="220"
height="220" :options="chartOptions"
:options="chartOptions" :series="series"
:series="series" />
/>
</div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed } from 'vue'
import type { Flight } from '@/Types/types' import type { Flight } from '@/Types/types'
import FlightChart from "@/Components/FlightsGoneBy/FlightChart.vue";
const props = defineProps<{ const props = defineProps<{
flights: Flight[] flights: Flight[]
@@ -93,17 +92,4 @@ const chartOptions = computed(() => ({
</script> </script>
<style scoped> <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> </style>
@@ -1,18 +1,17 @@
<template> <template>
<div class="chart-wrap"> <FlightChart
<div class="chart-title">Flights per year</div> title="Flights per year"
<apexchart
type="bar" type="bar"
height="220" height="220"
:options="chartOptions" :options="chartOptions"
:series="series" :series="series"
/> />
</div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed } from 'vue'
import type { Flight } from '@/Types/types' import type { Flight } from '@/Types/types'
import FlightChart from "@/Components/FlightsGoneBy/FlightChart.vue";
const props = defineProps<{ const props = defineProps<{
flights: Flight[] flights: Flight[]
@@ -107,17 +106,5 @@ const chartOptions = computed(() => ({
</script> </script>
<style scoped> <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> </style>
@@ -1,20 +1,18 @@
<template> <template>
<div class="chart-wrap"> <FlightChart
<div class="chart-title">Seat types</div> title="Seat Types"
<apexchart v-if="series.length"
v-if="series.length" type="donut"
type="donut" height="280"
height="280" :options="chartOptions"
:options="chartOptions" :series="seriesData"
:series="seriesData" />
/>
<div v-else class="chart-empty">No seat type data available</div>
</div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed } from 'vue'
import type { Flight } from '@/Types/types' import type { Flight } from '@/Types/types'
import FlightChart from "@/Components/FlightsGoneBy/FlightChart.vue";
const props = defineProps<{ const props = defineProps<{
flights: Flight[] flights: Flight[]
@@ -87,26 +85,4 @@ const chartOptions = computed(() => ({
</script> </script>
<style scoped> <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> </style>
@@ -4,7 +4,7 @@
<div v-if="series.length" class="chart-outer"> <div v-if="series.length" class="chart-outer">
<div class="chart-scroll" :style="{ height: scrollHeight }" @mousemove="onMouseMove" @mouseleave="onMouseLeave"> <div class="chart-scroll" :style="{ height: scrollHeight }" @mousemove="onMouseMove" @mouseleave="onMouseLeave">
<apexchart <UnstyledFlightChart
type="bar" type="bar"
:height="chartHeight" :height="chartHeight"
:options="chartOptions" :options="chartOptions"
@@ -34,6 +34,7 @@ import type {Flight, SharedProps} from '@/Types/types'
import { useChartTooltip } from '@/Composables/useChartTooltip' import { useChartTooltip } from '@/Composables/useChartTooltip'
import ChartTooltip from '@/Components/FlightsGoneBy/Charts/ChartTooltip.vue' import ChartTooltip from '@/Components/FlightsGoneBy/Charts/ChartTooltip.vue'
import {usePage} from "@inertiajs/vue3"; import {usePage} from "@inertiajs/vue3";
import UnstyledFlightChart from "@/Components/FlightsGoneBy/UnstyledFlightChart.vue";
const page = usePage<SharedProps>().props const page = usePage<SharedProps>().props
@@ -4,7 +4,7 @@
<div v-if="series.length" class="chart-outer"> <div v-if="series.length" class="chart-outer">
<div class="chart-scroll" :style="{ height: scrollHeight }" @mousemove="onMouseMove" @mouseleave="onMouseLeave"> <div class="chart-scroll" :style="{ height: scrollHeight }" @mousemove="onMouseMove" @mouseleave="onMouseLeave">
<apexchart <UnstyledFlightChart
type="bar" type="bar"
:height="chartHeight" :height="chartHeight"
:options="chartOptions" :options="chartOptions"
@@ -44,6 +44,7 @@ import { computed } from 'vue'
import type { Airport, Flight } from '@/Types/types' import type { Airport, Flight } from '@/Types/types'
import { useChartTooltip } from '@/Composables/useChartTooltip' import { useChartTooltip } from '@/Composables/useChartTooltip'
import ChartTooltip from '@/Components/FlightsGoneBy/Charts/ChartTooltip.vue' import ChartTooltip from '@/Components/FlightsGoneBy/Charts/ChartTooltip.vue'
import UnstyledFlightChart from "@/Components/FlightsGoneBy/UnstyledFlightChart.vue";
const props = defineProps<{ const props = defineProps<{
flights: Flight[] 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>
@@ -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">,&nbsp;</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">,&nbsp;</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">,&nbsp;</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">,&nbsp;</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">,&nbsp;</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"> <script setup lang="ts">
import type { Flight } from '@/Types/types'
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import { usePage } from '@inertiajs/vue3' 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<{ const props = defineProps<{
flights: Flight[] flights: Flight[]
}>() }>()
const page = usePage<SharedProps>()
const now = new Date()
const selectedYears = ref<number[]>([]) const selectedYears = ref<number[]>([])
const selectedAirlines = ref<number[]>([]) const selectedAirlines = ref<number[]>([])
const selectedCountries = ref<string[]>([]) const selectedCountries = ref<string[]>([])
const selectedContinents = ref<string[]>([]) const selectedContinents = ref<string[]>([])
const selectedFlightClasses = ref<number[]>([]) 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) => const airlineLogoUrl = (id: number) =>
`${page.props.logo_api_url}/airlines/logos/tail/id/${id}` `${page.props.logo_api_url}/airlines/logos/tail/id/${id}`
const countryFlagClass = (code: string) => const countryFlagClass = (code: string) =>
`fi fi-${code.toLowerCase()}` `fi fi-${code.toLowerCase()}`
// Available filter options
const availableYears = computed(() => { const availableYears = computed(() => {
const years = new Set<number>() const years = new Set<number>()
props.flights.forEach(f => years.add(new Date(f.departure_date).getFullYear())) 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 availableAirlines = computed((): { id: number; name: string }[] => {
const map = new Map<number, { id: number; name: string }>() const map = new Map<number, { id: number; name: string }>()
props.flights.forEach(f => { 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 }) map.set(f.airline.id, { id: f.airline.id, name: f.airline.name })
}
}) })
return [...map.values()].sort((a, b) => a.name.localeCompare(b.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 availableFlightClasses = computed((): { id: number; name: string }[] => {
const map = new Map<number, { id: number; name: string }>() const map = new Map<number, { id: number; name: string }>()
props.flights.forEach(f => { 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 }) 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)) 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> </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">,&nbsp;</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">,&nbsp;</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">,&nbsp;</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">,&nbsp;</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">,&nbsp;</span>
</span>
<span v-if="index === 2" class="text-caption text-medium-emphasis">+{{ selectedFlightClasses.length - 2 }}</span>
</template>
</v-select>
</div>
</template>
<style scoped> <style scoped>
.profile-map__toolbar { .flight-filters {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 12px; gap: 12px;
margin-top: 12px; margin-top: 12px;
} }
.profile-map__toolbar .v-select { .flight-filters .v-select {
flex: 1 1 160px; flex: 1 1 160px;
} }
</style> </style>
@@ -1,6 +1,7 @@
<template> <template>
<div class="flight-map-wrapper"> <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"> <div v-if="!flights.length" class="empty-state">
<span class="mdi mdi-earth-off" /> <span class="mdi mdi-earth-off" />
<p>No flight data available</p> <p>No flight data available</p>
@@ -15,6 +16,7 @@ import 'maplibre-gl/dist/maplibre-gl.css'
import { SharedProps } from '@/Types/types' import { SharedProps } from '@/Types/types'
import { usePage } from '@inertiajs/vue3' import { usePage } from '@inertiajs/vue3'
import { Flight, Airport } from '@/Types/types' import { Flight, Airport } from '@/Types/types'
import PlaneLoader from "@/Components/FlightsGoneBy/PlaneLoader.vue";
interface RouteFlightBucket { interface RouteFlightBucket {
historical: Flight[] historical: Flight[]
@@ -39,8 +41,12 @@ interface RoutesGeoJSON {
future: GeoJSON.FeatureCollection<GeoJSON.LineString, RouteFeatureProperties> 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 toRad = (d: number): number => d * Math.PI / 180
const toDeg = (r: number): number => r * 180 / Math.PI const toDeg = (r: number): number => r * 180 / Math.PI
const lat1 = toRad(from[1]), lng1 = toRad(from[0]) 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]]] if (d === 0) return [[from[0], from[1]], [to[0], to[1]]]
const points: LngLat[] = [] const points: LngLat[] = []
for (let i = 0; i <= steps; i++) { for (let i = 0; i <= s; i++) {
const f = i / steps const f = i / s
const A = Math.sin((1 - f) * d) / Math.sin(d) const A = Math.sin((1 - f) * d) / Math.sin(d)
const B = Math.sin(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) 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 { function routePopupHTML(historical: Flight[], future: Flight[]): string {
const logoApiUrl = usePage<SharedProps>().props.logo_api_url
interface DirectionGroup { interface DirectionGroup {
label: string label: string
airlines: string[] airlines: string[]
@@ -140,6 +145,7 @@ function routePopupHTML(historical: Flight[], future: Flight[]): string {
export default defineComponent({ export default defineComponent({
name: 'FlightMap', name: 'FlightMap',
components: {PlaneLoader},
props: { props: {
flights: { flights: {
@@ -158,6 +164,7 @@ export default defineComponent({
const TOUCH_RADIUS = 20 const TOUCH_RADIUS = 20
const airportById = new Map<number, Airport>() const airportById = new Map<number, Airport>()
const routeFlights = new Map<string, RouteFlightBucket>() const routeFlights = new Map<string, RouteFlightBucket>()
const arcCache = new Map<string, LngLat[]>()
let selectedAirportId: number | null = null let selectedAirportId: number | null = null
const isTouchDevice = (): boolean => window.matchMedia('(pointer: coarse)').matches const isTouchDevice = (): boolean => window.matchMedia('(pointer: coarse)').matches
@@ -170,22 +177,23 @@ export default defineComponent({
const applyFilter = (): void => { const applyFilter = (): void => {
if (!map || !map.isStyleLoaded()) return if (!map || !map.isStyleLoaded()) return
const id = selectedAirportId const id = selectedAirportId
const filter: maplibregl.FilterSpecification | null = id
const routeFilter: maplibregl.FilterSpecification | null = id
? ['any', ['==', ['get', 'depId'], id], ['==', ['get', 'arrId'], id]] ? ['any', ['==', ['get', 'depId'], id], ['==', ['get', 'arrId'], id]]
: null : null
map.setFilter('routes-line', filter) map.setFilter('routes-line', routeFilter)
map.setFilter('routes-hit', filter) map.setFilter('routes-hit', routeFilter)
map.setFilter('routes-future-line', filter) map.setFilter('routes-future-line', routeFilter)
map.setFilter('routes-future-hit', filter) map.setFilter('routes-future-hit', routeFilter)
// Use setFilter on airport layers — GPU-side, no style recompilation
for (let i = 0; i < PULSE_PHASES; i++) { for (let i = 0; i < PULSE_PHASES; i++) {
map.setPaintProperty(`airports-dot-${i}`, 'circle-opacity', const airportFilter: maplibregl.FilterSpecification | null = id
id ? ['case', ['==', ['get', 'id'], id], 1, 0.25] : 1) ? ['==', ['get', 'id'], id]
map.setPaintProperty(`airports-dot-${i}`, 'circle-stroke-opacity', : null
id ? ['case', ['==', ['get', 'id'], id], 1, 0.25] : 1) map.setFilter(`airports-dot-${i}`, airportFilter)
map.setPaintProperty(`airports-pulse-${i}`, 'circle-stroke-opacity', map.setFilter(`airports-pulse-${i}`, airportFilter)
id ? ['case', ['==', ['get', 'id'], id], 0.6, 0] : 0.6)
} }
} }
@@ -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 ────────────────────────────────────────────────── // ── GeoJSON builders ──────────────────────────────────────────────────
const buildRoutesGeoJSON = (): RoutesGeoJSON => { const buildRoutesGeoJSON = (): RoutesGeoJSON => {
buildRouteFlights() buildRouteFlights()
@@ -219,51 +239,45 @@ export default defineComponent({
return '#a150d5' return '#a150d5'
} }
const historicalFeatures: GeoJSON.Feature<GeoJSON.LineString, RouteFeatureProperties>[] = const seenHistorical = new Set<string>()
props.flights const historicalFeatures: GeoJSON.Feature<GeoJSON.LineString, RouteFeatureProperties>[] = []
.filter(f => new Date(f.departure_date) <= now) props.flights
.map((flight) => { .filter(f => new Date(f.departure_date) <= now)
const key = [flight.departure_airport.id, flight.arrival_airport.id].sort().join('-') .forEach((flight) => {
const count = routeCounts.get(key) ?? 1 const key = [flight.departure_airport.id, flight.arrival_airport.id].sort().join('-')
return { if (seenHistorical.has(key)) return
type: 'Feature', seenHistorical.add(key)
properties: { const count = routeCounts.get(key) ?? 1
color: routeColor(count), routeKey: key, historicalFeatures.push({
depId: flight.departure_airport.id, type: 'Feature',
arrId: flight.arrival_airport.id, properties: {
}, color: routeColor(count), routeKey: key,
geometry: { depId: flight.departure_airport.id,
type: 'LineString', arrId: flight.arrival_airport.id,
coordinates: greatCircleGeoJSON( },
[flight.departure_airport.longitude_deg, flight.departure_airport.latitude_deg], geometry: { type: 'LineString', coordinates: getArc(flight) },
[flight.arrival_airport.longitude_deg, flight.arrival_airport.latitude_deg],
),
},
}
}) })
})
const historicalKeys = new Set(routeCounts.keys()) const historicalKeys = new Set(routeCounts.keys())
const futureFeatures: GeoJSON.Feature<GeoJSON.LineString, RouteFeatureProperties>[] = const seenFuture = new Set<string>()
props.flights const futureFeatures: GeoJSON.Feature<GeoJSON.LineString, RouteFeatureProperties>[] = []
.filter((flight) => { props.flights
if (new Date(flight.departure_date) <= now) return false .filter((flight) => {
const key = [flight.departure_airport.id, flight.arrival_airport.id].sort().join('-') if (new Date(flight.departure_date) <= now) return false
return !historicalKeys.has(key) 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('-') .forEach((flight) => {
return { const key = [flight.departure_airport.id, flight.arrival_airport.id].sort().join('-')
type: 'Feature', if (seenFuture.has(key)) return
properties: { routeKey: key, depId: flight.departure_airport.id, arrId: flight.arrival_airport.id }, seenFuture.add(key)
geometry: { futureFeatures.push({
type: 'LineString', type: 'Feature',
coordinates: greatCircleGeoJSON( properties: { routeKey: key, depId: flight.departure_airport.id, arrId: flight.arrival_airport.id },
[flight.departure_airport.longitude_deg, flight.departure_airport.latitude_deg], geometry: { type: 'LineString', coordinates: getArc(flight) },
[flight.arrival_airport.longitude_deg, flight.arrival_airport.latitude_deg],
),
},
}
}) })
})
return { return {
historical: { type: 'FeatureCollection', features: historicalFeatures }, 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) => { map!.on('click', (e) => {
// Query airports with a larger bounding box on touch for easier tapping
const airportQuery = isTouch 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.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 : e.point as maplibregl.PointLike
@@ -408,7 +421,6 @@ export default defineComponent({
return return
} }
// Then check routes
const routeFeatures = map!.queryRenderedFeatures(e.point, { const routeFeatures = map!.queryRenderedFeatures(e.point, {
layers: ['routes-hit', 'routes-future-hit'], layers: ['routes-hit', 'routes-future-hit'],
}) })
@@ -419,7 +431,6 @@ export default defineComponent({
return return
} }
// Empty map click — deselect everything
selectedAirportId = null selectedAirportId = null
applyFilter() applyFilter()
popup!.remove() popup!.remove()
@@ -443,12 +454,14 @@ export default defineComponent({
const fitBounds = (): void => { const fitBounds = (): void => {
if (!props.flights.length) return if (!props.flights.length) return
const lngs = props.flights.flatMap(f => [f.departure_airport.longitude_deg, f.arrival_airport.longitude_deg]) let minLng = Infinity, maxLng = -Infinity, minLat = Infinity, maxLat = -Infinity
const lats = props.flights.flatMap(f => [f.departure_airport.latitude_deg, f.arrival_airport.latitude_deg]) props.flights.forEach(f => {
map!.fitBounds( minLng = Math.min(minLng, f.departure_airport.longitude_deg, f.arrival_airport.longitude_deg)
[[Math.min(...lngs), Math.min(...lats)], [Math.max(...lngs), Math.max(...lats)]], maxLng = Math.max(maxLng, f.departure_airport.longitude_deg, f.arrival_airport.longitude_deg)
{ padding: 60, duration: 0 }, 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 ────────────────────────────────────────────────────────── // ── Map init ──────────────────────────────────────────────────────────
@@ -479,13 +492,18 @@ export default defineComponent({
renderWorldCopies: true, renderWorldCopies: true,
}) })
map.addControl(new maplibregl.NavigationControl({ showCompass: false }), 'top-right') 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 ────────────────────────────────────────────────────── // ── Data updates ──────────────────────────────────────────────────────
const updateData = (): void => { const updateData = (): void => {
if (!map || !map.isStyleLoaded()) return if (!map || !map.isStyleLoaded()) return
arcCache.clear()
airportById.clear() airportById.clear()
props.flights.forEach(({ departure_airport: dep, arrival_airport: arr }) => { props.flights.forEach(({ departure_airport: dep, arrival_airport: arr }) => {
airportById.set(dep.id, dep) airportById.set(dep.id, dep)
@@ -508,14 +526,16 @@ export default defineComponent({
initMap() initMap()
}) })
watch(() => props.flights, updateData, { deep: true }) watch(() => props.flights, updateData)
onBeforeUnmount(() => { onBeforeUnmount(() => {
if (pulseFrame !== null) cancelAnimationFrame(pulseFrame) if (pulseFrame !== null) cancelAnimationFrame(pulseFrame)
if (map) { map.remove(); map = null } if (map) { map.remove(); map = null }
}) })
return { mapContainer } const mapReady = ref(false)
return { mapContainer, mapReady }
}, },
}) })
</script> </script>
@@ -525,11 +545,22 @@ export default defineComponent({
position: relative; position: relative;
width: 100%; width: 100%;
aspect-ratio: 16 / 9; aspect-ratio: 16 / 9;
display: flex;
align-items: center;
justify-content: center;
} }
.map-container { .map-container {
width: 100%; position: absolute;
height: 100%; inset: 0;
}
.map-loader {
z-index: 10;
}
.map-hidden {
visibility: hidden;
} }
.empty-state { .empty-state {
@@ -547,6 +578,8 @@ export default defineComponent({
.empty-state .mdi { font-size: 48px; } .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>
<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-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 { background: rgba(10,14,22,0.7) !important; color: #666 !important; }
.maplibregl-ctrl-attrib a { color: #666 !important; } .maplibregl-ctrl-attrib a { color: #666 !important; }
</style> </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>
-181
View File
@@ -1,181 +0,0 @@
<script setup lang="ts">
import MainLayout from "@/Layouts/MainLayout.vue";
import { Head } from '@inertiajs/vue3';
import { Flight } from "@/Types/types";
import DepartureBoard from "@/Components/FlightsGoneBy/DepartureBoard.vue";
import BoardingPasses from "@/Components/FlightsGoneBy/BoardingPasses.vue";
import { ref } from "vue";
import ProfileMap from "@/Components/FlightsGoneBy/ProfileMap.vue";
defineOptions({
layout: MainLayout
})
defineProps<{
user: {
id: number
name: string
email: string
}
flights: Flight[]
canEdit: boolean
}>()
type View = 'board' | 'passes' | 'map'
const activeView = ref<View>('map')
</script>
<template>
<Head :title="`${user.name}'s Flights`" />
<div class="board-wrapper">
<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>
<!-- View switcher toolbar -->
<div class="view-toolbar">
<button
class="view-btn"
:class="{ active: activeView === 'map' }"
@click="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="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="activeView = 'passes'"
>
<span class="view-btn-icon mdi mdi-ticket"></span>
<span class="view-btn-label">BOARDING PASSES</span>
</button>
</div>
<DepartureBoard v-if="activeView === 'board'" :flights="flights" :canEdit="canEdit" />
<BoardingPasses v-else-if="activeView === 'passes'" :flights="flights" />
<ProfileMap v-else-if="activeView === 'map'" :flights="flights" />
</div>
</template>
<style scoped>
@import url('https://fonts.googleapis.com/css2?family=Share+Tech+Mono&family=Barlow:wght@400;500;600&family=Barlow+Condensed:wght@500;700&display=swap');
/* ── Wrapper ── */
.board-wrapper {
min-height: 100dvh;
padding: 2.5rem 2rem;
font-family: 'Barlow', sans-serif;
width: 100%;
}
/* ── 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;
}
/* ── 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 {
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,30 @@
<script setup lang="ts">
import MainLayout from "@/Layouts/MainLayout.vue";
import { Head } from '@inertiajs/vue3';
import {Flight, User} from "@/Types/types";
import ProfileViewSwitcher from "@/Components/FlightsGoneBy/ProfileViewSwitcher.vue";
import ProfileLayout from "@/Components/FlightsGoneBy/ProfileLayout.vue";
import BoardingPasses from "@/Components/FlightsGoneBy/BoardingPasses.vue";
defineOptions({
layout: MainLayout
})
defineProps<{
user: User
flights: Flight[]
canEdit: boolean
}>()
</script>
<template>
<Head :title="`${user.name}'s Flights`" />
<ProfileLayout :flights="flights" :user="user">
<ProfileViewSwitcher :user="user" active-view="passes" />
<BoardingPasses :flights="flights" :canEdit="canEdit" />
</ProfileLayout>
</template>
<style scoped>
</style>
+30
View File
@@ -0,0 +1,30 @@
<script setup lang="ts">
import MainLayout from "@/Layouts/MainLayout.vue";
import { Head } from '@inertiajs/vue3';
import {Flight, User} from "@/Types/types";
import ProfileViewSwitcher from "@/Components/FlightsGoneBy/ProfileViewSwitcher.vue";
import ProfileLayout from "@/Components/FlightsGoneBy/ProfileLayout.vue";
import FlightMapAndCharts from "@/Components/FlightsGoneBy/FlightMapAndCharts.vue";
defineOptions({
layout: MainLayout
})
defineProps<{
user: User
flights: Flight[]
canEdit: boolean
}>()
</script>
<template>
<Head :title="`${user.name}'s Flights`" />
<ProfileLayout :flights="flights" :user="user">
<ProfileViewSwitcher active-view="map" :user="user" />
<FlightMapAndCharts :flights="flights" :canEdit="canEdit" />
</ProfileLayout>
</template>
<style scoped>
</style>
+55
View File
@@ -0,0 +1,55 @@
<script setup lang="ts">
import MainLayout from "@/Layouts/MainLayout.vue";
import { Head } from '@inertiajs/vue3';
import { ref, defineAsyncComponent } from 'vue'
import {Flight, ProfileView, User} from "@/Types/types";
import { router } from '@inertiajs/vue3'
import ProfileViewSwitcher from "@/Components/FlightsGoneBy/ProfileViewSwitcher.vue";
import ProfileLayout from "@/Components/FlightsGoneBy/ProfileLayout.vue";
import DepartureBoard from "@/Components/FlightsGoneBy/DepartureBoard.vue";
import PlaneLoader from "@/Components/FlightsGoneBy/PlaneLoader.vue";
const FlightMapAndCharts = defineAsyncComponent(
() => import('@/Components/FlightsGoneBy/FlightMapAndCharts.vue')
)
const BoardingPasses = defineAsyncComponent(
() => import('@/Components/FlightsGoneBy/BoardingPasses.vue')
)
defineOptions({ layout: MainLayout })
const props = defineProps<{
user: User
flights: Flight[]
canEdit: boolean
initialView?: ProfileView
}>()
const activeView = ref<ProfileView>(props.initialView ?? 'board')
const routeNames = {
map: 'profile.map',
board: 'profile.departure-board',
passes: 'profile.boarding-passes',
} as const
function switchView(view: 'map' | 'board' | 'passes') {
activeView.value = view
window.history.replaceState(
window.history.state,
'',
route(routeNames[view], { username: props.user.name })
)
}
</script>
<template>
<Head :title="`${user.name}'s Flights`" />
<ProfileLayout :flights="flights" :user="user">
<ProfileViewSwitcher :active-view="activeView" @update:active-view="switchView" />
<DepartureBoard v-if="activeView === 'board'" :flights="flights" :canEdit="canEdit" />
<BoardingPasses v-else-if="activeView === 'passes'" :flights="flights" :canEdit="canEdit" />
<FlightMapAndCharts v-else-if="activeView === 'map'" :flights="flights" :canEdit="canEdit" />
</ProfileLayout>
</template>
+3
View File
@@ -8,6 +8,9 @@ declare module '@vue/runtime-core' {
} }
} }
export type ProfileView = 'map' | 'board' | 'passes';
export type ChartType = "line" | "area" | "bar" | "pie" | "donut" | "radialBar" | "scatter" | "bubble" | "heatmap" | "candlestick" | "boxPlot" | "radar" | "polarArea" | "rangeBar" | "rangeArea" | "treemap" | undefined
export interface User { export interface User {
id: number id: number
name: string name: string
+3
View File
@@ -72,6 +72,9 @@ Route::domain(config('app.domain'))->group(
Route::get('/u/{username}', [FlightProfileController::class, 'view'])->name('profile.view'); Route::get('/u/{username}', [FlightProfileController::class, 'view'])->name('profile.view');
Route::get('/u/{username}/map', [FlightProfileController::class, 'map'])->name('profile.map');
Route::get('/u/{username}/departure-board', [FlightProfileController::class, 'departureBoard'])->name('profile.departure-board');
Route::get('/u/{username}/boarding-passes', [FlightProfileController::class, 'boardingPasses'])->name('profile.boarding-passes');
require __DIR__.'/auth.php'; require __DIR__.'/auth.php';