13 Commits
29 changed files with 837 additions and 114 deletions
@@ -102,6 +102,7 @@ class UserProfileController extends Controller
$canView = Gate::allows('viewProfileData', $user); $canView = Gate::allows('viewProfileData', $user);
$achievements = Achievement::with(['category', 'difficulty']) $achievements = Achievement::with(['category', 'difficulty'])
->orderBy('id', 'asc')
->get() ->get()
->groupBy(fn(Achievement $a) => $a->category->name) ->groupBy(fn(Achievement $a) => $a->category->name)
->map(fn($group) => $group->sortBy('sort_order')->values()); ->map(fn($group) => $group->sortBy('sort_order')->values());
@@ -171,6 +172,11 @@ class UserProfileController extends Controller
default => null, default => null,
}; };
$majorAlliances = match($achievement->internal_name){
'airlines_alliances.all_major_alliances' => Alliance::MAJOR_ALLIANCES,
default => [],
};
$continents = match($achievement->internal_name){ $continents = match($achievement->internal_name){
'countries_continents.all_continent_pairs_one_way', 'countries_continents.all_continent_pairs_both_ways' => Continent::all()->toArray(), 'countries_continents.all_continent_pairs_one_way', 'countries_continents.all_continent_pairs_both_ways' => Continent::all()->toArray(),
default => [], default => [],
@@ -207,6 +213,7 @@ class UserProfileController extends Controller
'continents' => $continents, 'continents' => $continents,
'aircraft_families' => $aircraftFamilies, 'aircraft_families' => $aircraftFamilies,
'achievementCount' => $user->unlockedAchievements()->count(), 'achievementCount' => $user->unlockedAchievements()->count(),
'majorAlliances' => Alliance::whereIn('internal_name', $majorAlliances)->get(),
'canView' => $canView, 'canView' => $canView,
]); ]);
} }
@@ -38,6 +38,7 @@ class HandleInertiaRequests extends Middleware
'roles' => $request->user()?->getRoleNames() ?? [], 'roles' => $request->user()?->getRoleNames() ?? [],
'permissions' => $request->user()?->getAllPermissions()->pluck('name') ?? [], 'permissions' => $request->user()?->getAllPermissions()->pluck('name') ?? [],
'apiToken' => session('api_token'), 'apiToken' => session('api_token'),
'following' => $request->user()?->following()->with('followee')->get()->pluck('followee')->filter()->pluck('name')->toArray() ?? [],
], ],
'flash' => [ 'flash' => [
'success' => $request->session()->get('success'), 'success' => $request->session()->get('success'),
+3
View File
@@ -26,6 +26,9 @@ class Alliance extends Model
'skyteam' => 'airlines_alliances.all_skyteam', 'skyteam' => 'airlines_alliances.all_skyteam',
]; ];
const array MAJOR_ALLIANCES = ['skyteam', 'oneworld', 'star_alliance'];
public function airlines(): HasMany public function airlines(): HasMany
{ {
return $this->hasMany(Airline::class); return $this->hasMany(Airline::class);
@@ -28,6 +28,12 @@ class AirlinesAndAlliancesChecker extends BaseChecker
$check = fn(string $alliance): int => $flownAllianceAirlines->get($alliance, 0); $check = fn(string $alliance): int => $flownAllianceAirlines->get($alliance, 0);
$majorAlliancesFlown = collect(Alliance::MAJOR_ALLIANCES)
->filter(fn(string $alliance) => $check($alliance) > 0)
->count();
$this->awardProgress($majorAlliancesFlown, 'airlines_alliances.all_major_alliances');
$this->awardProgress($check('skyteam'), 'airlines_alliances.all_skyteam'); $this->awardProgress($check('skyteam'), 'airlines_alliances.all_skyteam');
$this->awardProgress($check('oneworld'), 'airlines_alliances.all_oneworld'); $this->awardProgress($check('oneworld'), 'airlines_alliances.all_oneworld');
$this->awardProgress($check('star_alliance'), 'airlines_alliances.all_star_alliance'); $this->awardProgress($check('star_alliance'), 'airlines_alliances.all_star_alliance');
@@ -40,6 +46,8 @@ class AirlinesAndAlliancesChecker extends BaseChecker
$checkGroup = fn(array $group): int => $flownAirlines->intersect($group)->count(); $checkGroup = fn(array $group): int => $flownAirlines->intersect($group)->count();
$this->awardProgress($checkGroup(self::ME_3), 'airlines_alliances.me3'); $this->awardProgress($checkGroup(self::ME_3), 'airlines_alliances.me3');
$this->awardProgress($checkGroup(self::US_3), 'airlines_alliances.us3'); $this->awardProgress($checkGroup(self::US_3), 'airlines_alliances.us3');
$this->awardProgress($checkGroup(self::CN_3), 'airlines_alliances.cn3'); $this->awardProgress($checkGroup(self::CN_3), 'airlines_alliances.cn3');
@@ -0,0 +1,58 @@
<?php
use App\Models\Achievement;
use App\Models\AchievementCategory;
use App\Models\AchievementDifficulty;
use App\Models\User;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
$moderate = AchievementDifficulty::where('internal_name', 'moderate')->first();
$airlines_and_alliances = AchievementCategory::where('internal_name', 'airlines_and_alliances')->first();
Achievement::create([
'name' => 'Allied Forces',
'internal_name' => 'airlines_alliances.all_major_alliances',
'short_description' => 'Fly The 3 Major Airline Alliances (Skyteam, Star Alliance, Oneworld)',
'progressive' => true,
'difficulty_description' => 'For most people in most parts of the world this is quite achievable',
'threshold' => 3,
'achievement_category_id' => $airlines_and_alliances->id,
'achievement_difficulty_id' => $moderate->id,
'has_page' => true,
'sort_order' => 1,
'icon' => 'standard_achievement.png',
'long_description' => '',
]);
Achievement::where('internal_name', 'airlines_alliances.cn3')->update(['sort_order' => 3]);
Achievement::where('internal_name', 'airlines_alliances.us3')->update(['sort_order' => 1]);
Achievement::where('internal_name', 'airlines_alliances.me3')->update(['sort_order' => 2]);
Achievement::where('internal_name', 'airlines_alliances.all_major_alliances')->update(['sort_order' => 4]);
Achievement::where('internal_name', 'airlines_alliances.all_skyteam')->update(['sort_order' => 5]);
Achievement::where('internal_name', 'airlines_alliances.all_oneworld')->update(['sort_order' => 6]);
Achievement::where('internal_name', 'airlines_alliances.all_star_alliance')->update(['sort_order' => 7]);
Achievement::where('internal_name', 'airlines_alliances.all_vanilla_alliance')->update(['sort_order' => 8]);
foreach(User::all() as $user){
$user->calculateAchievements();
}
}
/**
* Reverse the migrations.
*/
public function down(): void
{
//
}
};
+1 -1
View File
@@ -8,7 +8,7 @@
"@mdi/font": "^7.4.47", "@mdi/font": "^7.4.47",
"apexcharts": "^5.10.5", "apexcharts": "^5.10.5",
"chart.js": "^4.5.1", "chart.js": "^4.5.1",
"echarts": "^6.0.0", "echarts": "^6.1.0",
"flag-icons": "^7.5.0", "flag-icons": "^7.5.0",
"maplibre-gl": "^5.22.0", "maplibre-gl": "^5.22.0",
"pinia": "^3.0.4", "pinia": "^3.0.4",
+1 -1
View File
@@ -41,7 +41,7 @@
"@mdi/font": "^7.4.47", "@mdi/font": "^7.4.47",
"apexcharts": "^5.10.5", "apexcharts": "^5.10.5",
"chart.js": "^4.5.1", "chart.js": "^4.5.1",
"echarts": "^6.0.0", "echarts": "^6.1.0",
"flag-icons": "^7.5.0", "flag-icons": "^7.5.0",
"maplibre-gl": "^5.22.0", "maplibre-gl": "^5.22.0",
"pinia": "^3.0.4", "pinia": "^3.0.4",
@@ -0,0 +1,20 @@
<script setup lang="ts">
import DonutChart from "@/Components/FlightsGoneBy/Charts/ChartTypes/DonutChart.vue";
import {FlightStats} from "@/Composables/useFlightStats";
const props = defineProps<{
flightStats: FlightStats
}>()
</script>
<template>
<DonutChart
title="Alliances"
:height="280"
:labels="flightStats.alliances.value.labels"
:series="flightStats.alliances.value.series"
total-label="Alliances"
center-mode="count"
/>
</template>
<style scoped>
</style>
@@ -1,80 +1,283 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref } from 'vue' import { computed, nextTick, onMounted, ref } from 'vue'
import { ChartType } from '@/Types/types' import { use } from 'echarts/core'
import PlaneLoader from "@/Components/FlightsGoneBy/PlaneLoader.vue" import { CanvasRenderer } from 'echarts/renderers'
import VueApexCharts from "vue3-apexcharts" import { PieChart } from 'echarts/charts'
import { TooltipComponent, LegendComponent } from 'echarts/components'
import VChart from 'vue-echarts'
use([CanvasRenderer, PieChart, TooltipComponent, LegendComponent])
const chartRef = ref<InstanceType<typeof VChart>>()
onMounted(() => {
// vue-echarts' autoresize can measure the container before the page's
// own layout (flex/grid) has settled, leaving the chart off-center until
// something else triggers a resize. Force one right after mount.
nextTick(() => chartRef.value?.resize())
})
type SeriesEntry = { name: string; data: number[] }
interface RingConfig {
name: string
series: number[]
colors: string[]
}
const props = defineProps<{ const props = defineProps<{
title: string title: string
height: number | string height: number | string
series: number[]
labels: string[] labels: string[]
// Either a flat array (single ring, e.g. Flight Types) or an array of
// named series (multi ring, e.g. Continents: [{name:'Flights',data},{name:'Upcoming',data}])
series: number[] | SeriesEntry[]
total?: number total?: number
options?: object totalLabel?: string
// 'sum' (default): center shows the total of all values (e.g. 308 flights).
// 'count': center shows how many categories have a non-zero value (e.g. 6
// continents) — for charts where summing the slices double-counts things,
// like a flight touching two continents.
centerMode?: 'sum' | 'count'
}>() }>()
const chartOptions = computed(() => ({ // Same color for the same label across every ring (matches the legend),
chart: { // distinguished visually by which ring they're in rather than by shade.
type: 'donut', const PALETTE = ['#4da6ff', '#ffc107', '#a150d5', '#22c55e', '#f97316', '#e11d48', '#06b6d4', 'pink']
background: 'transparent',
fontFamily: 'inherit',
animations: { enabled: false },
},
theme: { mode: 'dark' },
labels: props.labels,
colors: ['#4da6ff', '#ffc107', '#a150d5', '#22c55e', '#f97316', '#e11d48', '#06b6d4', 'pink'],
dataLabels: {
enabled: true,
formatter: (val: number) => `${Math.round(val)}%`,
style: { fontSize: '12px', fontWeight: 400 },
dropShadow: { enabled: false },
},
plotOptions: {
pie: {
donut: {
size: '60%',
labels: {
show: true,
total: {
show: true,
label: 'Total',
fontSize: '13px',
color: '#556677',
formatter: () => (props.total ?? props.series.reduce((a, b) => a + b, 0)).toString(),
},
value: {
fontSize: '22px',
fontWeight: 500,
color: '#e0e6f0',
},
},
},
},
},
legend: {
position: 'bottom',
labels: { colors: '#778899' },
markers: { width: 8, height: 8, radius: 2 },
itemMargin: { horizontal: 12, vertical: 4 },
},
stroke: { width: 0 },
tooltip: {
theme: 'dark',
y: { formatter: (val: number) => `${val} flights` },
},
}))
// Build rings from `series`, dropping any ring that's entirely zero
// (e.g. a continent with no upcoming flights at all -> no "Upcoming" ring).
const normalizedRings = computed<RingConfig[]>(() => {
const raw = props.series
if (!raw || !raw.length) return []
if (typeof raw[0] === 'number') {
return [{ name: props.title, series: raw as number[], colors: PALETTE }]
}
return (raw as SeriesEntry[])
.map((entry) => ({ name: entry.name, series: entry.data, colors: PALETTE }))
.filter((ring) => ring.series.some((v) => v > 0))
})
const isMulti = computed(() => normalizedRings.value.length > 1)
const mode = ref<'all' | string>('all')
const modeOptions = computed(() => [
{ key: 'all', label: normalizedRings.value.length === 2 ? 'Both' : 'All' },
...normalizedRings.value.map((r) => ({ key: r.name, label: `${r.name} only` })),
])
// Skip zero-value slices entirely, rather than drawing an invisible sliver with a stray label/border.
function toSeriesData(ring: RingConfig) {
return ring.series
.map((value, i) => ({
name: props.labels[i] ?? `Item ${i + 1}`,
value,
itemStyle: { color: ring.colors[i % ring.colors.length] },
}))
.filter((d) => d.value > 0)
}
function ringCount(ring: RingConfig) {
return ring.series.filter((v) => v > 0).length
}
function ringSum(ring: RingConfig) {
return ring.series.reduce((a, b) => a + b, 0)
}
const centerLabel = computed(() => {
const aggregate = props.centerMode === 'count' ? ringCount : ringSum
if (mode.value !== 'all') {
const ring = normalizedRings.value.find((r) => r.name === mode.value)!
return { title: ring.name, value: aggregate(ring) }
}
if (props.centerMode === 'count') {
// Distinct categories with a non-zero value in ANY ring (not summed
// across rings, since a continent visited on both legs is still one continent).
const present = new Set<string>()
normalizedRings.value.forEach((ring) => {
ring.series.forEach((value, i) => {
if (value > 0) present.add(props.labels[i] ?? `Item ${i + 1}`)
})
})
return { title: props.totalLabel ?? 'Total', value: present.size }
}
return {
title: props.totalLabel ?? 'Total',
value: props.total ?? normalizedRings.value.reduce((sum, r) => sum + ringSum(r), 0),
}
})
const activeRings = computed(() =>
mode.value === 'all' ? normalizedRings.value : normalizedRings.value.filter((r) => r.name === mode.value)
)
// Same radius bands as the original working sample:
// - isolated (single ring, or one toggled on its own): 55%80%
// - nested pair: outer 68%88%, inner 32%62% (leaves a roomy center hole)
// - 3+ rings: evenly spaced bands, falling back to a generous hole
function ringRadius(i: number, n: number, isolated: boolean): [number, number] {
if (isolated) return [55, 80]
if (n === 2) return i === 0 ? [68, 88] : [42, 62]
const edge = 88
const hole = 30
const gap = 6
const bandWidth = (edge - hole - gap * (n - 1)) / n
const outer = edge - i * (bandWidth + gap)
const inner = outer - bandWidth
return [inner, outer]
}
const numericHeight = computed(() => (typeof props.height === 'number' ? props.height : parseInt(String(props.height), 10) || 280))
interface SliceLabel {
key: string
x: number
y: number
text: string
}
// Computed independently of ECharts' own label placement (see chartOption below
// for why): for each visible slice, find its mid-angle and the ring's
// mid-radius, then convert to a pixel offset from the chart's center using
// plain trig. ECharts pies start at 90° (12 o'clock) and sweep clockwise,
// which in standard (counter-clockwise-positive) math angle terms means the
// angle decreases as cumulative value increases.
const sliceLabels = computed<SliceLabel[]>(() => {
const n = normalizedRings.value.length
const isolated = mode.value !== 'all' || n === 1
const chartRadiusPx = numericHeight.value / 2
const labels: SliceLabel[] = []
activeRings.value.forEach((ring) => {
const i = normalizedRings.value.findIndex((r) => r.name === ring.name)
const [innerPct, outerPct] = ringRadius(i, n, isolated)
const midRadiusPx = ((innerPct + outerPct) / 2 / 100) * chartRadiusPx
const total = ring.series.reduce((a, b) => a + b, 0)
if (!total) return
let cumulative = 0
ring.series.forEach((value, idx) => {
const percent = (value / total) * 100
const midFraction = (cumulative + value / 2) / total
cumulative += value
if (value <= 0 || percent < 5) return
const angleDeg = 90 - midFraction * 360
const angleRad = (angleDeg * Math.PI) / 180
labels.push({
key: `${ring.name}-${props.labels[idx] ?? idx}`,
x: midRadiusPx * Math.cos(angleRad),
y: -midRadiusPx * Math.sin(angleRad),
text: `${Math.round(percent)}%`,
})
})
})
return labels
})
const chartOption = computed(() => {
const n = normalizedRings.value.length
const isolated = mode.value !== 'all' || n === 1
const series = activeRings.value.map((ring) => {
const i = normalizedRings.value.findIndex((r) => r.name === ring.name)
const [innerPct, outerPct] = ringRadius(i, n, isolated)
return {
name: ring.name,
type: 'pie',
radius: [`${innerPct}%`, `${outerPct}%`],
center: ['50%', '50%'],
avoidLabelOverlap: false,
itemStyle: { borderColor: 'transparent', borderWidth: 0 },
// ECharts' own label placement (both 'inside' and 'outside' + negative
// distance) proved unreliable here, so labels are rendered separately
// as plain positioned HTML — see `sliceLabels` below.
label: { show: false },
labelLine: { show: false },
data: toSeriesData(ring),
}
})
return {
backgroundColor: 'transparent',
tooltip: {
trigger: 'item',
backgroundColor: '#0f1b33',
borderColor: '#2a3a5c',
textStyle: { color: '#e0e6f0', fontSize: 12 },
formatter: (p: any) =>
`<b>${p.seriesName} · ${p.name}</b><br/>${p.value} flights (${Math.round(p.percent)}%)`,
},
series,
}
})
const legendItems = computed(() => {
const first = normalizedRings.value[0]
if (!first) return []
return props.labels.map((label, i) => ({ label, color: first.colors[i % first.colors.length] }))
})
</script> </script>
<template> <template>
<div class="chart-wrap"> <div class="chart-wrap">
<div class="chart-title">{{ title }}</div> <div class="chart-title">{{ title }}</div>
<VueApexCharts
type="donut" <template v-if="isMulti">
:height="height" <div class="toggle-row">
:options="options ?? chartOptions" <button
:series="series" v-for="opt in modeOptions"
/> :key="opt.key"
class="toggle-btn"
:class="{ active: mode === opt.key }"
@click="mode = opt.key"
>
{{ opt.label }}
</button>
</div>
<div v-if="mode === 'all'" class="ring-labels">
<span v-for="(r, i) in normalizedRings" :key="r.name">
{{ i === 0 ? 'Outer' : i === normalizedRings.length - 1 ? 'Inner' : `Ring ${i + 1}` }}
Ring = {{ r.name }}
</span>
</div>
</template>
<div class="donut-container" :style="{ height: typeof height === 'number' ? `${height}px` : height }">
<VChart ref="chartRef" class="chart" :option="chartOption" autoresize />
<div class="center-label">
<div class="center-label-title">{{ centerLabel.title }}</div>
<div class="center-label-value">{{ centerLabel.value }}</div>
</div>
<div
v-for="lbl in sliceLabels"
:key="lbl.key"
class="slice-label"
:style="{ transform: `translate(calc(-50% + ${lbl.x}px), calc(-50% + ${lbl.y}px))` }"
>
{{ lbl.text }}
</div>
</div>
<div v-if="legendItems.length" class="legend">
<div v-for="item in legendItems" :key="item.label" class="legend-item">
<span class="dot" :style="{ background: item.color }"></span>
<span>{{ item.label }}</span>
</div>
</div>
</div> </div>
</template> </template>
@@ -82,6 +285,7 @@ const chartOptions = computed(() => ({
.chart-wrap { .chart-wrap {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
width: 100%;
gap: 4px; gap: 4px;
} }
@@ -93,11 +297,99 @@ const chartOptions = computed(() => ({
letter-spacing: 0.08em; letter-spacing: 0.08em;
} }
.chart-hidden { .donut-container {
visibility: hidden; position: relative;
width: 100%;
} }
:deep(svg) { .chart {
outline: none; width: 100%;
height: 100%;
}
.center-label {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
text-align: center;
pointer-events: none;
}
.center-label-title {
font-size: 13px;
color: #556677;
}
.center-label-value {
font-size: 22px;
font-weight: 500;
color: #e0e6f0;
}
.slice-label {
position: absolute;
top: 50%;
left: 50%;
color: #fff;
font-size: 11px;
font-weight: 500;
white-space: nowrap;
pointer-events: none;
}
.toggle-row {
display: flex;
justify-content: center;
gap: 8px;
margin: 4px 0 4px;
}
.toggle-btn {
background: transparent;
border: 1px solid #2a3a5c;
color: #778899;
border-radius: 20px;
padding: 5px 12px;
font-size: 11px;
cursor: pointer;
}
.toggle-btn.active {
background: #1e3a6a;
color: #e0e6f0;
border-color: #1e3a6a;
}
.ring-labels {
display: flex;
justify-content: center;
gap: 20px;
font-size: 11px;
color: #556677;
margin-bottom: 4px;
}
.legend {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 16px;
margin-top: 8px;
}
.legend-item {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: #778899;
}
.dot {
width: 8px;
height: 8px;
border-radius: 2px;
display: inline-block;
} }
</style> </style>
@@ -1,18 +1,22 @@
<script setup lang="ts"> <script setup lang="ts">
import DonutChart from "@/Components/FlightsGoneBy/Charts/ChartTypes/DonutChart.vue"; import DonutChart from "@/Components/FlightsGoneBy/Charts/ChartTypes/DonutChart.vue";
import {FlightStats} from "@/Composables/useFlightStats"; import { FlightStats } from "@/Composables/useFlightStats";
const props = defineProps<{ const props = defineProps<{
flightStats: FlightStats flightStats: FlightStats
}>() }>()
</script> </script>
<template> <template>
<DonutChart <DonutChart
title="Continents" title="Continents"
:height="280" :height="280"
:labels="flightStats.continents.value.labels" :labels="flightStats.continents.value.labels"
:series="flightStats.continents.value.series" :series="flightStats.continents.value.series"
total-label="Continents"
center-mode="count"
/> />
</template> </template>
<style scoped>
<style scoped>
</style> </style>
@@ -11,6 +11,8 @@ const props = defineProps<{
:height="280" :height="280"
:labels="flightStats.classes.value.labels" :labels="flightStats.classes.value.labels"
:series="flightStats.classes.value.series" :series="flightStats.classes.value.series"
total-label="Flight Classes"
center-mode="count"
/> />
</template> </template>
<style scoped> <style scoped>
@@ -11,6 +11,8 @@ const props = defineProps<{
:height="280" :height="280"
:labels="flightStats.reasons.value.labels" :labels="flightStats.reasons.value.labels"
:series="flightStats.reasons.value.series" :series="flightStats.reasons.value.series"
total-label="Reasons"
center-mode="count"
/> />
</template> </template>
<style scoped> <style scoped>
@@ -7,8 +7,9 @@ const props = defineProps<{
</script> </script>
<template> <template>
<DonutChart <DonutChart
title="Flight Types" title="Flight Scope"
:height="280" :height="280"
total-label="Flights"
:labels="flightStats.flightTypes.value.labels" :labels="flightStats.flightTypes.value.labels"
:series="flightStats.flightTypes.value.series" :series="flightStats.flightTypes.value.series"
/> />
@@ -11,6 +11,8 @@ const props = defineProps<{
:height="280" :height="280"
:labels="flightStats.seatTypes.value.labels" :labels="flightStats.seatTypes.value.labels"
:series="flightStats.seatTypes.value.series" :series="flightStats.seatTypes.value.series"
center-mode="count"
total-label="Seat Types"
/> />
</template> </template>
<style scoped> <style scoped>
@@ -52,7 +52,7 @@ const chartEvents = computed(() => ({
<InlineBadge v-if="index !== null" variant="generic">{{ aircraft[index].designator }}</InlineBadge> <InlineBadge v-if="index !== null" variant="generic">{{ aircraft[index].designator }}</InlineBadge>
</div> </div>
<div class="ct-row"> <div class="ct-row">
<span class="ct-label">Flights</span> <span class="ct-label">Flown</span>
<span class="ct-val">{{ index !== null ? aircraft[index].past : '' }}</span> <span class="ct-val">{{ index !== null ? aircraft[index].past : '' }}</span>
</div> </div>
<div v-if="index !== null && aircraft[index].upcoming" class="ct-row"> <div v-if="index !== null && aircraft[index].upcoming" class="ct-row">
@@ -52,7 +52,7 @@ const chartEvents = computed(() => ({
{{ index !== null ? airlines[index].name : '' }} {{ index !== null ? airlines[index].name : '' }}
</div> </div>
<div class="ct-row"> <div class="ct-row">
<span class="ct-label">Flights</span> <span class="ct-label">Flown</span>
<span class="ct-val">{{ index !== null ? airlines[index].past : '' }}</span> <span class="ct-val">{{ index !== null ? airlines[index].past : '' }}</span>
</div> </div>
<div v-if="index !== null && airlines[index].upcoming" class="ct-row"> <div v-if="index !== null && airlines[index].upcoming" class="ct-row">
@@ -37,7 +37,7 @@ const series = computed(() => props.flightStats.topRoutes.value.series)
<ChartTooltip :visible="visible" :x="x" :y="y"> <ChartTooltip :visible="visible" :x="x" :y="y">
<div class="ct-name">{{ index !== null ? routes[index].label : '' }}</div> <div class="ct-name">{{ index !== null ? routes[index].label : '' }}</div>
<div class="ct-row"> <div class="ct-row">
<span class="ct-label">Flights</span> <span class="ct-label">Flown</span>
<span class="ct-val">{{ index !== null ? routes[index].past : '' }}</span> <span class="ct-val">{{ index !== null ? routes[index].past : '' }}</span>
</div> </div>
<div v-if="index !== null && routes[index].upcoming" class="ct-row"> <div v-if="index !== null && routes[index].upcoming" class="ct-row">
@@ -15,6 +15,7 @@ import TopRoutesChart from '@/Components/FlightsGoneBy/Charts/TopRoutesChart.vue
import FlightTypeChart from '@/Components/FlightsGoneBy/Charts/FlightTypeChart.vue' import FlightTypeChart from '@/Components/FlightsGoneBy/Charts/FlightTypeChart.vue'
import {FlightStats} from "@/Composables/useFlightStats"; import {FlightStats} from "@/Composables/useFlightStats";
import TopAircraftChart from "@/Components/FlightsGoneBy/Charts/TopAircraftChart.vue"; import TopAircraftChart from "@/Components/FlightsGoneBy/Charts/TopAircraftChart.vue";
import AllianceChart from "@/Components/FlightsGoneBy/Charts/AllianceChart.vue";
defineProps<{ defineProps<{
flightStats: FlightStats flightStats: FlightStats
@@ -56,11 +57,14 @@ defineProps<{
<!-- Airlines + Airports --> <!-- Airlines + Airports -->
<div class="flight-charts glass charts-row"> <div class="flight-charts glass charts-row">
<TopAirlinesChart :flight-stats="flightStats" /> <TopAirlinesChart :flight-stats="flightStats" />
<TopAirportsChart :flight-stats="flightStats" /> <div class="charts-stack">
<AllianceChart :flight-stats="flightStats" />
</div>
</div> </div>
<!-- Routes + International vs Domestic --> <!-- Routes + International vs Domestic -->
<div class="flight-charts glass charts-row"> <div class="flight-charts glass charts-row">
<TopAirportsChart :flight-stats="flightStats" />
<TopRoutesChart :flight-stats="flightStats" /> <TopRoutesChart :flight-stats="flightStats" />
<TopAircraftChart :flight-stats="flightStats" /> <TopAircraftChart :flight-stats="flightStats" />
</div> </div>
@@ -23,6 +23,8 @@ const emit = defineEmits<{
seatTypes: number[] seatTypes: number[]
manufacturers: string[] manufacturers: string[]
aircraftModels: number[] aircraftModels: number[]
engineTypes: string[]
engineCounts: number[]
airportRegions: number[] airportRegions: number[]
otherUsers: string[] otherUsers: string[]
}] }]
@@ -62,6 +64,8 @@ function buildOptions(flights: Flight[]) {
const regionRanges = new Set<RegionRange>() const regionRanges = new Set<RegionRange>()
const manufacturers = new Map<string, { name: string }>() const manufacturers = new Map<string, { name: string }>()
const aircraftModels = new Map<number, { id: number; name: string }>() const aircraftModels = new Map<number, { id: number; name: string }>()
const engineTypes = new Map<string, { name: string }>()
const engineCounts = new Map<number, { count: number }>()
const airportRegions = new Map<number, { id: number; name: string; countryCodes: Set<string> }>() const airportRegions = new Map<number, { id: number; name: string; countryCodes: Set<string> }>()
const otherUsers = new Set<string>() const otherUsers = new Set<string>()
@@ -120,6 +124,12 @@ function buildOptions(flights: Flight[]) {
if (f.aircraft?.id != null && f.aircraft?.display_name_short) if (f.aircraft?.id != null && f.aircraft?.display_name_short)
aircraftModels.set(f.aircraft.id, { id: f.aircraft.id, name: f.aircraft.display_name_short }) aircraftModels.set(f.aircraft.id, { id: f.aircraft.id, name: f.aircraft.display_name_short })
if (f.aircraft?.engine_type)
engineTypes.set(f.aircraft.engine_type, { name: f.aircraft.engine_type })
if (f.aircraft?.engine_count != null)
engineCounts.set(f.aircraft.engine_count, { count: f.aircraft.engine_count })
flightScopes.add(f.scope) flightScopes.add(f.scope)
flightRanges.add(f.range) flightRanges.add(f.range)
regionRanges.add(f.region_range) regionRanges.add(f.region_range)
@@ -144,6 +154,8 @@ function buildOptions(flights: Flight[]) {
regionRanges: regionRangeOrder.filter(r => regionRanges.has(r)).map(r => ({ value: r, label: REGION_RANGE_LABELS[r] })), regionRanges: regionRangeOrder.filter(r => regionRanges.has(r)).map(r => ({ value: r, label: REGION_RANGE_LABELS[r] })),
manufacturers: [...manufacturers.values()].sort((a, b) => a.name.localeCompare(b.name)), manufacturers: [...manufacturers.values()].sort((a, b) => a.name.localeCompare(b.name)),
aircraftModels: [...aircraftModels.values()].sort((a, b) => a.name.localeCompare(b.name)), aircraftModels: [...aircraftModels.values()].sort((a, b) => a.name.localeCompare(b.name)),
engineTypes: [...engineTypes.values()].sort((a, b) => a.name.localeCompare(b.name)),
engineCounts: [...engineCounts.values()].sort((a, b) => a.count - b.count),
airportRegions: [...airportRegions.values()].sort((a, b) => a.name.localeCompare(b.name)), airportRegions: [...airportRegions.values()].sort((a, b) => a.name.localeCompare(b.name)),
otherUsers: [...otherUsers].sort((a, b) => a.localeCompare(b)), otherUsers: [...otherUsers].sort((a, b) => a.localeCompare(b)),
} }
@@ -167,6 +179,8 @@ const selectedFlightReasons = ref<number[]>([])
const selectedSeatTypes = ref<number[]>([]) const selectedSeatTypes = ref<number[]>([])
const selectedManufacturers = ref<string[]>([]) const selectedManufacturers = ref<string[]>([])
const selectedAircraftModels = ref<number[]>([]) const selectedAircraftModels = ref<number[]>([])
const selectedEngineTypes = ref<string[]>([])
const selectedEngineCounts = ref<number[]>([])
const selectedAirportRegions = ref<number[]>([]) const selectedAirportRegions = ref<number[]>([])
const selectedOtherUsers = ref<string[]>([]) const selectedOtherUsers = ref<string[]>([])
@@ -197,6 +211,8 @@ function emitFilters() {
seatTypes: selectedSeatTypes.value, seatTypes: selectedSeatTypes.value,
manufacturers: selectedManufacturers.value, manufacturers: selectedManufacturers.value,
aircraftModels: selectedAircraftModels.value, aircraftModels: selectedAircraftModels.value,
engineTypes: selectedEngineTypes.value,
engineCounts: selectedEngineCounts.value,
airportRegions: selectedAirportRegions.value, airportRegions: selectedAirportRegions.value,
otherUsers: selectedOtherUsers.value, otherUsers: selectedOtherUsers.value,
}) })
@@ -496,6 +512,42 @@ const countryFlagClass = (code: string) =>
</template> </template>
</v-select> </v-select>
<v-select
v-if="availableOptions.engineTypes.length > 0"
v-model="selectedEngineTypes"
:items="availableOptions.engineTypes"
item-title="name" item-value="name"
label="Engine Type"
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(selectedEngineTypes.length, 2) - 1">,&nbsp;</span>
</span>
<span v-if="index === 2" class="text-caption text-medium-emphasis">+{{ selectedEngineTypes.length - 2 }}</span>
</template>
</v-select>
<v-select
v-if="availableOptions.engineCounts.length > 0"
v-model="selectedEngineCounts"
:items="availableOptions.engineCounts"
item-title="count" item-value="count"
label="Engine Count"
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).count }}<span v-if="index < Math.min(selectedEngineCounts.length, 2) - 1">,&nbsp;</span>
</span>
<span v-if="index === 2" class="text-caption text-medium-emphasis">+{{ selectedEngineCounts.length - 2 }}</span>
</template>
</v-select>
<v-select <v-select
v-if="availableOptions.crewTypes.length > 0" v-if="availableOptions.crewTypes.length > 0"
v-model="selectedCrewTypes" v-model="selectedCrewTypes"
@@ -659,7 +659,9 @@ export default defineComponent({
const fitBounds = (): void => { const fitBounds = (): void => {
if (!props.flights.length) return if (!props.flights.length) return
const padding = props.compact ? 40 : 60 const padding = props.compact
? { top: 20, bottom: 20, left: 55, right: 55 }
: { top: 60, bottom: 60, left: 60, right: 60 }
const lngs = props.flights.flatMap(f => [f.departure_airport.longitude_deg, f.arrival_airport.longitude_deg]) 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]) const lats = props.flights.flatMap(f => [f.departure_airport.latitude_deg, f.arrival_airport.latitude_deg])
@@ -0,0 +1,46 @@
<script setup lang="ts">
import { usePage, router } from "@inertiajs/vue3";
import { SharedProps } from "@/Types/types";
const props = withDefaults(
defineProps<{
redirectRoute: string;
routeParams?: Record<string, string | number>;
placeholder?: string;
}>(),
{
routeParams: () => ({}),
}
);
const page = usePage<SharedProps>().props;
const following = [...page.auth?.following ?? [], page.auth?.user?.name]
.filter((x, i, a) => a.indexOf(x) === i);
function onUpdate(user: string) {
if (!user) return;
router.get(
route(props.redirectRoute, {
user,
...props.routeParams,
})
);
}
</script>
<template>
<VSelect
v-if="following.length > 1"
:placeholder="placeholder"
variant="outlined"
density="comfortable"
:items="following"
@update:model-value="onUpdate"
>
</VSelect>
</template>
<style scoped>
</style>
@@ -25,5 +25,4 @@ defineProps<{
background-size: cover; background-size: cover;
min-width: 300px; min-width: 300px;
} }
</style> </style>
@@ -16,7 +16,7 @@ const store = useNotificationStore();
max-width="360" max-width="360"
> >
<v-card-text class="d-flex align-center ga-3"> <v-card-text class="d-flex align-center ga-3">
<v-icon icon="mdi-trophy" color="amber" size="32" /> <v-icon :icon="notification.is_achievement ? 'mdi-trophy' : 'mdi-bell-ring'" color="amber" size="32" />
<div> <div>
<div class="text-subtitle-2 font-weight-bold">{{ notification.title }}</div> <div class="text-subtitle-2 font-weight-bold">{{ notification.title }}</div>
<div class="text-body-2 text-medium-emphasis">{{ notification.body }}</div> <div class="text-body-2 text-medium-emphasis">{{ notification.body }}</div>
+82 -31
View File
@@ -89,32 +89,51 @@ export function getFlightsPerDay(flights: Flight[], upcomingFlights: Flight[]) {
// ── Grouping helpers ────────────────────────────────────────────────────────── // ── Grouping helpers ──────────────────────────────────────────────────────────
function groupByName(flights: Flight[], accessor: (f: Flight) => string) { function groupByNameSplit(flights: Flight[], upcomingFlights: Flight[], accessor: (f: Flight) => string) {
const countOne = (list: Flight[]) => {
const counts = new Map<string, number>() const counts = new Map<string, number>()
flights.forEach(f => { list.forEach(f => {
const key = accessor(f) ?? 'Unknown' const key = accessor(f) ?? 'Unknown'
counts.set(key, (counts.get(key) ?? 0) + 1) counts.set(key, (counts.get(key) ?? 0) + 1)
}) })
const sorted = [...counts.entries()].sort((a, b) => b[1] - a[1]) return counts
}
const past = countOne(flights)
const upcoming = countOne(upcomingFlights)
const allNames = new Set([...past.keys(), ...upcoming.keys()])
const sorted = [...allNames]
.map(name => ({
name,
past: past.get(name) ?? 0,
upcoming: upcoming.get(name) ?? 0,
}))
.sort((a, b) => (b.past + b.upcoming) - (a.past + a.upcoming))
return { return {
labels: sorted.map(([name]) => name), labels: sorted.map(s => s.name),
series: sorted.map(([, count]) => count), series: [
{ name: 'Flown', data: sorted.map(s => s.past) },
{ name: 'Upcoming', data: sorted.map(s => s.upcoming) },
],
} }
} }
export function getFlightReasons(flights: Flight[]) { export function getFlightReasons(flights: Flight[], upcomingFlights: Flight[]) {
const result = groupByName(flights, f => f.flight_reason?.name ?? 'Unknown') return groupByNameSplit(flights, upcomingFlights, f => f.flight_reason?.name ?? 'Unknown')
return result
} }
export function getFlightClasses(flights: Flight[]) { export function getFlightClasses(flights: Flight[], upcomingFlights: Flight[]) {
const result = groupByName(flights, f => f.flight_class?.name ?? 'Unknown') return groupByNameSplit(flights, upcomingFlights, f => f.flight_class?.name ?? 'Unknown')
return result
} }
export function getSeatTypes(flights: Flight[]) { export function getSeatTypes(flights: Flight[], upcomingFlights: Flight[]) {
const result = groupByName(flights, f => f.seat_type?.name ?? 'Unknown') return groupByNameSplit(flights, upcomingFlights, f => f.seat_type?.name ?? 'Unknown')
return result }
export function getAlliances(flights: Flight[], upcomingFlights: Flight[]) {
return groupByNameSplit(flights, upcomingFlights, f => f.airline?.alliance?.name ?? 'No Alliance')
} }
// ── Countries ───────────────────────────────────────────────────────────────── // ── Countries ─────────────────────────────────────────────────────────────────
@@ -156,7 +175,7 @@ export function getCountries(flights: Flight[], upcomingFlights: Flight[]) {
const result = { const result = {
countries: sorted, countries: sorted,
series: [ series: [
{ name: 'Flights', data: sorted.map(s => s.past) }, { name: 'Flown', data: sorted.map(s => s.past) },
{ name: 'Upcoming', data: sorted.map(s => s.upcoming) }, { name: 'Upcoming', data: sorted.map(s => s.upcoming) },
], ],
} }
@@ -165,7 +184,7 @@ export function getCountries(flights: Flight[], upcomingFlights: Flight[]) {
// ── Continents ──────────────────────────────────────────────────────────────── // ── Continents ────────────────────────────────────────────────────────────────
export function getContinents(flights: Flight[]) { function countContinents(flights: Flight[]) {
const counts = new Map<string, number>() const counts = new Map<string, number>()
flights.forEach(f => { flights.forEach(f => {
const continents = new Set<string>() const continents = new Set<string>()
@@ -175,10 +194,28 @@ export function getContinents(flights: Flight[]) {
if (arr) continents.add(arr) if (arr) continents.add(arr)
continents.forEach(c => counts.set(c, (counts.get(c) ?? 0) + 1)) continents.forEach(c => counts.set(c, (counts.get(c) ?? 0) + 1))
}) })
const sorted = [...counts.entries()].sort((a, b) => b[1] - a[1]) return counts
}
export function getContinents(flights: Flight[], upcomingFlights: Flight[]) {
const past = countContinents(flights)
const upcoming = countContinents(upcomingFlights)
const allNames = new Set([...past.keys(), ...upcoming.keys()])
const sorted = [...allNames]
.map(name => ({
name,
past: past.get(name) ?? 0,
upcoming: upcoming.get(name) ?? 0,
}))
.sort((a, b) => (b.past + b.upcoming) - (a.past + a.upcoming))
const result = { const result = {
labels: sorted.map(([name]) => name), labels: sorted.map(s => s.name),
series: sorted.map(([, count]) => count), series: [
{ name: 'Flown', data: sorted.map(s => s.past) },
{ name: 'Upcoming', data: sorted.map(s => s.upcoming) },
],
} }
return result return result
} }
@@ -215,7 +252,7 @@ export function getTopAirlines(flights: Flight[], upcomingFlights: Flight[]) {
const result = { const result = {
airlines: sorted, airlines: sorted,
series: [ series: [
{ name: 'Flights', data: sorted.map(s => s.past) }, { name: 'Flown', data: sorted.map(s => s.past) },
{ name: 'Upcoming', data: sorted.map(s => s.upcoming) }, { name: 'Upcoming', data: sorted.map(s => s.upcoming) },
], ],
} }
@@ -338,7 +375,7 @@ export function getTopAircraft(flights: Flight[], upcomingFlights: Flight[]) {
const result = { const result = {
aircraft: sorted, aircraft: sorted,
series: [ series: [
{ name: 'Flights', data: sorted.map(s => s.past) }, { name: 'Flown', data: sorted.map(s => s.past) },
{ name: 'Upcoming', data: sorted.map(s => s.upcoming) }, { name: 'Upcoming', data: sorted.map(s => s.upcoming) },
], ],
} }
@@ -391,7 +428,7 @@ export function getTopRoutes(flights: Flight[], upcomingFlights: Flight[]) {
const result = { const result = {
routes: sorted, routes: sorted,
series: [ series: [
{ name: 'Flights', data: sorted.map(s => s.past) }, { name: 'Flown', data: sorted.map(s => s.past) },
{ name: 'Upcoming', data: sorted.map(s => s.upcoming) }, { name: 'Upcoming', data: sorted.map(s => s.upcoming) },
], ],
} }
@@ -400,7 +437,7 @@ export function getTopRoutes(flights: Flight[], upcomingFlights: Flight[]) {
// ── Flight types ────────────────────────────────────────────────────────────── // ── Flight types ──────────────────────────────────────────────────────────────
export function getFlightTypes(flights: Flight[]) { function countFlightTypes(flights: Flight[]) {
const counts = { International: 0, Domestic: 0 } const counts = { International: 0, Domestic: 0 }
flights.forEach(f => { flights.forEach(f => {
const dep = f.departure_airport.region?.country?.id const dep = f.departure_airport.region?.country?.id
@@ -409,10 +446,22 @@ export function getFlightTypes(flights: Flight[]) {
dep === arr ? counts.Domestic++ : counts.International++ dep === arr ? counts.Domestic++ : counts.International++
} }
}) })
const sorted = Object.entries(counts).filter(([, count]) => count > 0) return counts
}
export function getFlightTypes(flights: Flight[], upcomingFlights: Flight[]) {
const past = countFlightTypes(flights)
const upcoming = countFlightTypes(upcomingFlights)
const names = ['International', 'Domestic'] as const
const relevant = names.filter(name => past[name] > 0 || upcoming[name] > 0)
const result = { const result = {
labels: sorted.map(([name]) => name), labels: relevant,
series: sorted.map(([, count]) => count), series: [
{ name: 'Flown', data: relevant.map(name => past[name]) },
{ name: 'Upcoming', data: relevant.map(name => upcoming[name]) },
],
} }
return result return result
} }
@@ -443,16 +492,17 @@ export function useFlightStats(flights: Ref<Flight[]>) {
const perYear = computed(() => getFlightsPerYear(pastFlights.value, upcomingFlights.value)) const perYear = computed(() => getFlightsPerYear(pastFlights.value, upcomingFlights.value))
const perMonth = computed(() => getFlightsPerMonth(pastFlights.value, upcomingFlights.value)) const perMonth = computed(() => getFlightsPerMonth(pastFlights.value, upcomingFlights.value))
const perDay = computed(() => getFlightsPerDay(pastFlights.value, upcomingFlights.value)) const perDay = computed(() => getFlightsPerDay(pastFlights.value, upcomingFlights.value))
const reasons = computed(() => getFlightReasons(allFlights.value)) const reasons = computed(() => getFlightReasons(pastFlights.value, upcomingFlights.value))
const classes = computed(() => getFlightClasses(allFlights.value)) const classes = computed(() => getFlightClasses(pastFlights.value, upcomingFlights.value))
const seatTypes = computed(() => getSeatTypes(allFlights.value)) const seatTypes = computed(() => getSeatTypes(pastFlights.value, upcomingFlights.value))
const alliances = computed(() => getAlliances(pastFlights.value, upcomingFlights.value))
const countries = computed(() => getCountries(pastFlights.value, upcomingFlights.value)) const countries = computed(() => getCountries(pastFlights.value, upcomingFlights.value))
const continents = computed(() => getContinents(allFlights.value)) const continents = computed(() => getContinents(pastFlights.value, upcomingFlights.value))
const topAirlines = computed(() => getTopAirlines(pastFlights.value, upcomingFlights.value)) const topAirlines = computed(() => getTopAirlines(pastFlights.value, upcomingFlights.value))
const topAirports = computed(() => getTopAirports(pastFlights.value, upcomingFlights.value)) const topAirports = computed(() => getTopAirports(pastFlights.value, upcomingFlights.value))
const topAircraft = computed(() => getTopAircraft(pastFlights.value, upcomingFlights.value)) const topAircraft = computed(() => getTopAircraft(pastFlights.value, upcomingFlights.value))
const topRoutes = computed(() => getTopRoutes(pastFlights.value, upcomingFlights.value)) const topRoutes = computed(() => getTopRoutes(pastFlights.value, upcomingFlights.value))
const flightTypes = computed(() => getFlightTypes(allFlights.value)) const flightTypes = computed(() => getFlightTypes(pastFlights.value, upcomingFlights.value))
return { return {
pastFlights, pastFlights,
@@ -463,6 +513,7 @@ export function useFlightStats(flights: Ref<Flight[]>) {
reasons, reasons,
classes, classes,
seatTypes, seatTypes,
alliances,
countries, countries,
continents, continents,
flightTypes, flightTypes,
@@ -0,0 +1,146 @@
<script setup lang="ts">
import { computed } from 'vue'
import { Achievement, Alliance, Flight, User } from '@/Types/types'
import Panel from '@/Components/FlightsGoneBy/Panels/Panel.vue'
import PanelHeader from '@/Components/FlightsGoneBy/Panels/PanelHeader.vue'
import PanelSubHeader from '@/Components/FlightsGoneBy/Panels/PanelSubHeader.vue'
import BadgeTable from '@/Components/FlightsGoneBy/GenericBadgeTable.vue'
import AllianceLogo from '@/Components/FlightsGoneBy/AllianceLogo.vue'
import FlightBadge from '@/Components/FlightsGoneBy/FlightBadge.vue'
defineOptions({ inheritAttrs: false })
const props = defineProps<{
achievement: Achievement
user: User
followStatus: string
majorAlliances: Alliance[]
flights: Flight[]
}>()
interface AllianceEntry {
alliance: Alliance
flights: Flight[]
}
const alliances = computed<AllianceEntry[]>(() => {
const entries = props.majorAlliances.map(alliance => ({ alliance, flights: [] as Flight[] }))
const byName = new Map(entries.map(e => [e.alliance.internal_name, e]))
for (const flight of props.flights) {
const key = flight.airline?.alliance?.internal_name
if (!key) continue
byName.get(key)?.flights.push(flight)
}
return entries
})
const rows = computed(() => alliances.value.map(e => e.alliance.internal_name))
function entryFor(key: string): AllianceEntry {
return alliances.value.find(e => e.alliance.internal_name === key)!
}
const completedCount = computed(
() => alliances.value.filter(a => a.flights.length > 0).length
)
function flightTitle(flight: Flight): string {
const name = flight.airline?.name ?? 'Unknown airline'
return `${name}: ${flight.departure_airport?.iata_code}${flight.arrival_airport?.iata_code}`
}
</script>
<template>
<!-- Header -->
<Panel>
<div class="alliance-header">
<div class="logo-row">
<AllianceLogo
v-for="entry in alliances"
:key="entry.alliance.internal_name"
:alliance="entry.alliance"
size="56"
/>
</div>
</div>
</Panel>
<!-- Alliances table -->
<Panel>
<div class="table-toolbar">
<PanelHeader>Alliances</PanelHeader>
</div>
<BadgeTable
:rows="rows"
:rowKey="key => key"
:hasItems="key => entryFor(key).flights.length > 0"
labelWidth="14em"
>
<template #label="{ row: key }">
<div class="airline-label">
<AllianceLogo :alliance="entryFor(key).alliance" size="24" />
<span>{{ entryFor(key).alliance.name }}</span>
</div>
</template>
<template #items="{ row: key }">
<FlightBadge
v-for="flight in entryFor(key).flights"
:key="flight.id"
:title="flightTitle(flight)"
:flight="flight"
/>
</template>
</BadgeTable>
</Panel>
<!-- Requirements -->
<Panel>
<PanelHeader centered>Requirements</PanelHeader>
<p>
To complete this challenge you must fly with at least one member airline from each of
the three major alliances. Unlike the individual alliance challenges, you don't need
to fly every airline in an alliance just one flight per alliance is enough.
</p>
<p>
Codeshare flights do not count, the operating carrier must be a member of the
alliance.
</p>
</Panel>
</template>
<style scoped>
.alliance-header {
display: flex;
align-items: center;
justify-content: center;
gap: 1.5rem;
flex-wrap: wrap;
}
.logo-row {
display: flex;
gap: 0.75rem;
}
.table-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
margin-bottom: 0.75rem;
}
.airline-label {
width: 100%;
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0 0.25rem;
}
</style>
+10 -1
View File
@@ -12,6 +12,7 @@ import {useFlights} from "@/Composables/useFlights";
import InlineBadge from "@/Components/FlightsGoneBy/InlineBadge.vue"; import InlineBadge from "@/Components/FlightsGoneBy/InlineBadge.vue";
import GlassTooltip from "@/Components/FlightsGoneBy/GlassTooltip.vue"; import GlassTooltip from "@/Components/FlightsGoneBy/GlassTooltip.vue";
import ButtonLink from "@/Components/FlightsGoneBy/ButtonLink.vue"; import ButtonLink from "@/Components/FlightsGoneBy/ButtonLink.vue";
import FollowingSelectBox from "@/Components/FlightsGoneBy/FollowingSelectBox.vue";
defineOptions({ layout: MainLayout }) defineOptions({ layout: MainLayout })
@@ -26,6 +27,7 @@ const props = defineProps<{
airlines: Airline[] airlines: Airline[]
continents: Continent[] continents: Continent[]
aircraft_families: Record<string, string[]> aircraft_families: Record<string, string[]>
majorAlliances: Alliance[]
achievementCount: number achievementCount: number
canView: boolean canView: boolean
}>() }>()
@@ -63,7 +65,7 @@ const unlocked = computed(() => {
<VAlert type="info" v-if="loggedInUser?.id !== user.id && loggedInUser"> <VAlert type="info" v-if="loggedInUser?.id !== user.id && loggedInUser">
You are viewing {{user.name}}'s progress in this achievement. If you would like to see your progress, please You are viewing {{user.name}}'s progress in this achievement. If you would like to see your progress, please
<Link :href="route('profile.achievement', {user: loggedInUser.name, achievement: achievement.internal_name})"><VBtn variant="flat">click here.</VBtn></Link> <Link :href="route('profile.achievement', {user: loggedInUser.name, achievement: achievement.internal_name})"><u>click here.</u></Link>
</VAlert> </VAlert>
@@ -116,6 +118,13 @@ const unlocked = computed(() => {
:alliance="alliance" :alliance="alliance"
:continents="continents" :continents="continents"
:families="aircraft_families" :families="aircraft_families"
:major-alliances="majorAlliances"
/>
<FollowingSelectBox
redirect-route="profile.achievement"
:route-params="{ achievement: achievement.internal_name }"
placeholder="View the Progress of Someone You Follow"
/> />
</div> </div>
</ProfileLayout> </ProfileLayout>
+3
View File
@@ -9,6 +9,7 @@ import MainLayout from "@/Layouts/MainLayout.vue";
import Panel from "@/Components/FlightsGoneBy/Panels/Panel.vue"; import Panel from "@/Components/FlightsGoneBy/Panels/Panel.vue";
import PanelHeader from "@/Components/FlightsGoneBy/Panels/PanelHeader.vue"; import PanelHeader from "@/Components/FlightsGoneBy/Panels/PanelHeader.vue";
import {useUpdateSetting} from "@/Composables/useUpdateSetting"; import {useUpdateSetting} from "@/Composables/useUpdateSetting";
import FollowingSelectBox from "@/Components/FlightsGoneBy/FollowingSelectBox.vue";
const {updateSetting} = useUpdateSetting() const {updateSetting} = useUpdateSetting()
@@ -121,6 +122,8 @@ const filteredUnlockedCount = computed(() =>
/> />
</div> </div>
</Panel> </Panel>
<FollowingSelectBox redirect-route="profile.achievements" placeholder="View The Achievements of Someone You Follow" />
</div> </div>
</ProfileLayout> </ProfileLayout>
</template> </template>
+10
View File
@@ -56,6 +56,8 @@ const selectedFlightReasons = ref<number[]>([])
const selectedSeatTypes = ref<number[]>([]) const selectedSeatTypes = ref<number[]>([])
const selectedManufacturers = ref<string[]>([]) const selectedManufacturers = ref<string[]>([])
const selectedAircraftModels = ref<number[]>([]) const selectedAircraftModels = ref<number[]>([])
const selectedEngineTypes = ref<string[]>([])
const selectedEngineCounts = ref<number[]>([])
const selectedAirportRegions = ref<number[]>([]) const selectedAirportRegions = ref<number[]>([])
const selectedOtherUsers = ref<string[]>([]) const selectedOtherUsers = ref<string[]>([])
@@ -74,6 +76,8 @@ const activeFilterCount = computed(() =>
selectedSeatTypes.value.length + selectedSeatTypes.value.length +
selectedManufacturers.value.length + selectedManufacturers.value.length +
selectedAircraftModels.value.length + selectedAircraftModels.value.length +
selectedEngineTypes.value.length +
selectedEngineCounts.value.length +
selectedAirportRegions.value.length + selectedAirportRegions.value.length +
selectedOtherUsers.value.length selectedOtherUsers.value.length
) )
@@ -93,6 +97,8 @@ function onFiltersChange(filters: {
seatTypes: number[] seatTypes: number[]
manufacturers: string[] manufacturers: string[]
aircraftModels: number[] aircraftModels: number[]
engineTypes: string[]
engineCounts: number[]
airportRegions: number[] airportRegions: number[]
otherUsers: string[] otherUsers: string[]
}) { }) {
@@ -111,6 +117,8 @@ function onFiltersChange(filters: {
selectedSeatTypes.value = filters.seatTypes selectedSeatTypes.value = filters.seatTypes
selectedManufacturers.value = filters.manufacturers selectedManufacturers.value = filters.manufacturers
selectedAircraftModels.value = filters.aircraftModels selectedAircraftModels.value = filters.aircraftModels
selectedEngineTypes.value = filters.engineTypes
selectedEngineCounts.value = filters.engineCounts
selectedAirportRegions.value = filters.airportRegions selectedAirportRegions.value = filters.airportRegions
selectedOtherUsers.value = filters.otherUsers selectedOtherUsers.value = filters.otherUsers
} }
@@ -141,6 +149,8 @@ function matchesFilters(f: Flight): boolean {
if (selectedSeatTypes.value.length && !selectedSeatTypes.value.includes(f.seat_type?.id ?? -1)) return false if (selectedSeatTypes.value.length && !selectedSeatTypes.value.includes(f.seat_type?.id ?? -1)) return false
if (selectedManufacturers.value.length && !selectedManufacturers.value.includes(f.aircraft?.manufacturer_code ?? '')) return false if (selectedManufacturers.value.length && !selectedManufacturers.value.includes(f.aircraft?.manufacturer_code ?? '')) return false
if (selectedAircraftModels.value.length && !selectedAircraftModels.value.includes(f.aircraft?.id ?? -1)) return false if (selectedAircraftModels.value.length && !selectedAircraftModels.value.includes(f.aircraft?.id ?? -1)) return false
if (selectedEngineTypes.value.length && !selectedEngineTypes.value.includes(f.aircraft?.engine_type ?? '')) return false
if (selectedEngineCounts.value.length && !selectedEngineCounts.value.includes(f.aircraft?.engine_count ?? -1)) return false
if (selectedAirportRegions.value.length) { if (selectedAirportRegions.value.length) {
const depRegion = f.departure_airport.region?.id const depRegion = f.departure_airport.region?.id
const arrRegion = f.arrival_airport.region?.id const arrRegion = f.arrival_airport.region?.id
+1
View File
@@ -101,6 +101,7 @@ export type SharedProps = import('@inertiajs/core').PageProps & {
roles: string[]; roles: string[];
permissions: string[]; permissions: string[];
apiToken: string | null; apiToken: string | null;
following: string[];
}, },
flash: { flash: {
success?: string; success?: string;