13 Commits
29 changed files with 837 additions and 114 deletions
@@ -102,6 +102,7 @@ class UserProfileController extends Controller
$canView = Gate::allows('viewProfileData', $user);
$achievements = Achievement::with(['category', 'difficulty'])
->orderBy('id', 'asc')
->get()
->groupBy(fn(Achievement $a) => $a->category->name)
->map(fn($group) => $group->sortBy('sort_order')->values());
@@ -171,6 +172,11 @@ class UserProfileController extends Controller
default => null,
};
$majorAlliances = match($achievement->internal_name){
'airlines_alliances.all_major_alliances' => Alliance::MAJOR_ALLIANCES,
default => [],
};
$continents = match($achievement->internal_name){
'countries_continents.all_continent_pairs_one_way', 'countries_continents.all_continent_pairs_both_ways' => Continent::all()->toArray(),
default => [],
@@ -207,6 +213,7 @@ class UserProfileController extends Controller
'continents' => $continents,
'aircraft_families' => $aircraftFamilies,
'achievementCount' => $user->unlockedAchievements()->count(),
'majorAlliances' => Alliance::whereIn('internal_name', $majorAlliances)->get(),
'canView' => $canView,
]);
}
@@ -38,6 +38,7 @@ class HandleInertiaRequests extends Middleware
'roles' => $request->user()?->getRoleNames() ?? [],
'permissions' => $request->user()?->getAllPermissions()->pluck('name') ?? [],
'apiToken' => session('api_token'),
'following' => $request->user()?->following()->with('followee')->get()->pluck('followee')->filter()->pluck('name')->toArray() ?? [],
],
'flash' => [
'success' => $request->session()->get('success'),
+3
View File
@@ -26,6 +26,9 @@ class Alliance extends Model
'skyteam' => 'airlines_alliances.all_skyteam',
];
const array MAJOR_ALLIANCES = ['skyteam', 'oneworld', 'star_alliance'];
public function airlines(): HasMany
{
return $this->hasMany(Airline::class);
@@ -28,6 +28,12 @@ class AirlinesAndAlliancesChecker extends BaseChecker
$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('oneworld'), 'airlines_alliances.all_oneworld');
$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();
$this->awardProgress($checkGroup(self::ME_3), 'airlines_alliances.me3');
$this->awardProgress($checkGroup(self::US_3), 'airlines_alliances.us3');
$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",
"apexcharts": "^5.10.5",
"chart.js": "^4.5.1",
"echarts": "^6.0.0",
"echarts": "^6.1.0",
"flag-icons": "^7.5.0",
"maplibre-gl": "^5.22.0",
"pinia": "^3.0.4",
+1 -1
View File
@@ -41,7 +41,7 @@
"@mdi/font": "^7.4.47",
"apexcharts": "^5.10.5",
"chart.js": "^4.5.1",
"echarts": "^6.0.0",
"echarts": "^6.1.0",
"flag-icons": "^7.5.0",
"maplibre-gl": "^5.22.0",
"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">
import { computed, ref } from 'vue'
import { ChartType } from '@/Types/types'
import PlaneLoader from "@/Components/FlightsGoneBy/PlaneLoader.vue"
import VueApexCharts from "vue3-apexcharts"
import { computed, nextTick, onMounted, ref } from 'vue'
import { use } from 'echarts/core'
import { CanvasRenderer } from 'echarts/renderers'
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<{
title: string
height: number | string
series: number[]
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
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(() => ({
chart: {
type: 'donut',
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` },
},
}))
// Same color for the same label across every ring (matches the legend),
// distinguished visually by which ring they're in rather than by shade.
const PALETTE = ['#4da6ff', '#ffc107', '#a150d5', '#22c55e', '#f97316', '#e11d48', '#06b6d4', 'pink']
// 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>
<template>
<div class="chart-wrap">
<div class="chart-title">{{ title }}</div>
<VueApexCharts
type="donut"
:height="height"
:options="options ?? chartOptions"
:series="series"
/>
<template v-if="isMulti">
<div class="toggle-row">
<button
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>
</template>
@@ -82,6 +285,7 @@ const chartOptions = computed(() => ({
.chart-wrap {
display: flex;
flex-direction: column;
width: 100%;
gap: 4px;
}
@@ -93,11 +297,99 @@ const chartOptions = computed(() => ({
letter-spacing: 0.08em;
}
.chart-hidden {
visibility: hidden;
.donut-container {
position: relative;
width: 100%;
}
:deep(svg) {
outline: none;
.chart {
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>
@@ -1,18 +1,22 @@
<script setup lang="ts">
import DonutChart from "@/Components/FlightsGoneBy/Charts/ChartTypes/DonutChart.vue";
import {FlightStats} from "@/Composables/useFlightStats";
import { FlightStats } from "@/Composables/useFlightStats";
const props = defineProps<{
flightStats: FlightStats
}>()
</script>
<template>
<DonutChart
title="Continents"
:height="280"
:labels="flightStats.continents.value.labels"
:series="flightStats.continents.value.series"
total-label="Continents"
center-mode="count"
/>
</template>
<style scoped>
<style scoped>
</style>
@@ -11,6 +11,8 @@ const props = defineProps<{
:height="280"
:labels="flightStats.classes.value.labels"
:series="flightStats.classes.value.series"
total-label="Flight Classes"
center-mode="count"
/>
</template>
<style scoped>
@@ -11,6 +11,8 @@ const props = defineProps<{
:height="280"
:labels="flightStats.reasons.value.labels"
:series="flightStats.reasons.value.series"
total-label="Reasons"
center-mode="count"
/>
</template>
<style scoped>
@@ -7,8 +7,9 @@ const props = defineProps<{
</script>
<template>
<DonutChart
title="Flight Types"
title="Flight Scope"
:height="280"
total-label="Flights"
:labels="flightStats.flightTypes.value.labels"
:series="flightStats.flightTypes.value.series"
/>
@@ -11,6 +11,8 @@ const props = defineProps<{
:height="280"
:labels="flightStats.seatTypes.value.labels"
:series="flightStats.seatTypes.value.series"
center-mode="count"
total-label="Seat Types"
/>
</template>
<style scoped>
@@ -52,7 +52,7 @@ const chartEvents = computed(() => ({
<InlineBadge v-if="index !== null" variant="generic">{{ aircraft[index].designator }}</InlineBadge>
</div>
<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>
</div>
<div v-if="index !== null && aircraft[index].upcoming" class="ct-row">
@@ -52,7 +52,7 @@ const chartEvents = computed(() => ({
{{ index !== null ? airlines[index].name : '' }}
</div>
<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>
</div>
<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">
<div class="ct-name">{{ index !== null ? routes[index].label : '' }}</div>
<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>
</div>
<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 {FlightStats} from "@/Composables/useFlightStats";
import TopAircraftChart from "@/Components/FlightsGoneBy/Charts/TopAircraftChart.vue";
import AllianceChart from "@/Components/FlightsGoneBy/Charts/AllianceChart.vue";
defineProps<{
flightStats: FlightStats
@@ -56,11 +57,14 @@ defineProps<{
<!-- Airlines + Airports -->
<div class="flight-charts glass charts-row">
<TopAirlinesChart :flight-stats="flightStats" />
<TopAirportsChart :flight-stats="flightStats" />
<div class="charts-stack">
<AllianceChart :flight-stats="flightStats" />
</div>
</div>
<!-- Routes + International vs Domestic -->
<div class="flight-charts glass charts-row">
<TopAirportsChart :flight-stats="flightStats" />
<TopRoutesChart :flight-stats="flightStats" />
<TopAircraftChart :flight-stats="flightStats" />
</div>
@@ -23,6 +23,8 @@ const emit = defineEmits<{
seatTypes: number[]
manufacturers: string[]
aircraftModels: number[]
engineTypes: string[]
engineCounts: number[]
airportRegions: number[]
otherUsers: string[]
}]
@@ -62,6 +64,8 @@ function buildOptions(flights: Flight[]) {
const regionRanges = new Set<RegionRange>()
const manufacturers = new Map<string, { 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 otherUsers = new Set<string>()
@@ -120,6 +124,12 @@ function buildOptions(flights: Flight[]) {
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 })
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)
flightRanges.add(f.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] })),
manufacturers: [...manufacturers.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)),
otherUsers: [...otherUsers].sort((a, b) => a.localeCompare(b)),
}
@@ -167,6 +179,8 @@ const selectedFlightReasons = ref<number[]>([])
const selectedSeatTypes = ref<number[]>([])
const selectedManufacturers = ref<string[]>([])
const selectedAircraftModels = ref<number[]>([])
const selectedEngineTypes = ref<string[]>([])
const selectedEngineCounts = ref<number[]>([])
const selectedAirportRegions = ref<number[]>([])
const selectedOtherUsers = ref<string[]>([])
@@ -197,6 +211,8 @@ function emitFilters() {
seatTypes: selectedSeatTypes.value,
manufacturers: selectedManufacturers.value,
aircraftModels: selectedAircraftModels.value,
engineTypes: selectedEngineTypes.value,
engineCounts: selectedEngineCounts.value,
airportRegions: selectedAirportRegions.value,
otherUsers: selectedOtherUsers.value,
})
@@ -496,6 +512,42 @@ const countryFlagClass = (code: string) =>
</template>
</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-if="availableOptions.crewTypes.length > 0"
v-model="selectedCrewTypes"
@@ -659,7 +659,9 @@ export default defineComponent({
const fitBounds = (): void => {
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 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;
min-width: 300px;
}
</style>
@@ -16,7 +16,7 @@ const store = useNotificationStore();
max-width="360"
>
<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 class="text-subtitle-2 font-weight-bold">{{ notification.title }}</div>
<div class="text-body-2 text-medium-emphasis">{{ notification.body }}</div>
+86 -35
View File
@@ -89,32 +89,51 @@ export function getFlightsPerDay(flights: Flight[], upcomingFlights: Flight[]) {
// ── Grouping helpers ──────────────────────────────────────────────────────────
function groupByName(flights: Flight[], accessor: (f: Flight) => string) {
const counts = new Map<string, number>()
flights.forEach(f => {
const key = accessor(f) ?? 'Unknown'
counts.set(key, (counts.get(key) ?? 0) + 1)
})
const sorted = [...counts.entries()].sort((a, b) => b[1] - a[1])
function groupByNameSplit(flights: Flight[], upcomingFlights: Flight[], accessor: (f: Flight) => string) {
const countOne = (list: Flight[]) => {
const counts = new Map<string, number>()
list.forEach(f => {
const key = accessor(f) ?? 'Unknown'
counts.set(key, (counts.get(key) ?? 0) + 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 {
labels: sorted.map(([name]) => name),
series: sorted.map(([, count]) => count),
labels: sorted.map(s => s.name),
series: [
{ name: 'Flown', data: sorted.map(s => s.past) },
{ name: 'Upcoming', data: sorted.map(s => s.upcoming) },
],
}
}
export function getFlightReasons(flights: Flight[]) {
const result = groupByName(flights, f => f.flight_reason?.name ?? 'Unknown')
return result
export function getFlightReasons(flights: Flight[], upcomingFlights: Flight[]) {
return groupByNameSplit(flights, upcomingFlights, f => f.flight_reason?.name ?? 'Unknown')
}
export function getFlightClasses(flights: Flight[]) {
const result = groupByName(flights, f => f.flight_class?.name ?? 'Unknown')
return result
export function getFlightClasses(flights: Flight[], upcomingFlights: Flight[]) {
return groupByNameSplit(flights, upcomingFlights, f => f.flight_class?.name ?? 'Unknown')
}
export function getSeatTypes(flights: Flight[]) {
const result = groupByName(flights, f => f.seat_type?.name ?? 'Unknown')
return result
export function getSeatTypes(flights: Flight[], upcomingFlights: Flight[]) {
return groupByNameSplit(flights, upcomingFlights, f => f.seat_type?.name ?? 'Unknown')
}
export function getAlliances(flights: Flight[], upcomingFlights: Flight[]) {
return groupByNameSplit(flights, upcomingFlights, f => f.airline?.alliance?.name ?? 'No Alliance')
}
// ── Countries ─────────────────────────────────────────────────────────────────
@@ -156,7 +175,7 @@ export function getCountries(flights: Flight[], upcomingFlights: Flight[]) {
const result = {
countries: sorted,
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) },
],
}
@@ -165,7 +184,7 @@ export function getCountries(flights: Flight[], upcomingFlights: Flight[]) {
// ── Continents ────────────────────────────────────────────────────────────────
export function getContinents(flights: Flight[]) {
function countContinents(flights: Flight[]) {
const counts = new Map<string, number>()
flights.forEach(f => {
const continents = new Set<string>()
@@ -175,10 +194,28 @@ export function getContinents(flights: Flight[]) {
if (arr) continents.add(arr)
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 = {
labels: sorted.map(([name]) => name),
series: sorted.map(([, count]) => count),
labels: sorted.map(s => s.name),
series: [
{ name: 'Flown', data: sorted.map(s => s.past) },
{ name: 'Upcoming', data: sorted.map(s => s.upcoming) },
],
}
return result
}
@@ -215,7 +252,7 @@ export function getTopAirlines(flights: Flight[], upcomingFlights: Flight[]) {
const result = {
airlines: sorted,
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) },
],
}
@@ -338,7 +375,7 @@ export function getTopAircraft(flights: Flight[], upcomingFlights: Flight[]) {
const result = {
aircraft: sorted,
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) },
],
}
@@ -391,7 +428,7 @@ export function getTopRoutes(flights: Flight[], upcomingFlights: Flight[]) {
const result = {
routes: sorted,
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) },
],
}
@@ -400,7 +437,7 @@ export function getTopRoutes(flights: Flight[], upcomingFlights: Flight[]) {
// ── Flight types ──────────────────────────────────────────────────────────────
export function getFlightTypes(flights: Flight[]) {
function countFlightTypes(flights: Flight[]) {
const counts = { International: 0, Domestic: 0 }
flights.forEach(f => {
const dep = f.departure_airport.region?.country?.id
@@ -409,10 +446,22 @@ export function getFlightTypes(flights: Flight[]) {
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 = {
labels: sorted.map(([name]) => name),
series: sorted.map(([, count]) => count),
labels: relevant,
series: [
{ name: 'Flown', data: relevant.map(name => past[name]) },
{ name: 'Upcoming', data: relevant.map(name => upcoming[name]) },
],
}
return result
}
@@ -443,16 +492,17 @@ export function useFlightStats(flights: Ref<Flight[]>) {
const perYear = computed(() => getFlightsPerYear(pastFlights.value, upcomingFlights.value))
const perMonth = computed(() => getFlightsPerMonth(pastFlights.value, upcomingFlights.value))
const perDay = computed(() => getFlightsPerDay(pastFlights.value, upcomingFlights.value))
const reasons = computed(() => getFlightReasons(allFlights.value))
const classes = computed(() => getFlightClasses(allFlights.value))
const seatTypes = computed(() => getSeatTypes(allFlights.value))
const reasons = computed(() => getFlightReasons(pastFlights.value, upcomingFlights.value))
const classes = computed(() => getFlightClasses(pastFlights.value, upcomingFlights.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 continents = computed(() => getContinents(allFlights.value))
const continents = computed(() => getContinents(pastFlights.value, upcomingFlights.value))
const topAirlines = computed(() => getTopAirlines(pastFlights.value, upcomingFlights.value))
const topAirports = computed(() => getTopAirports(pastFlights.value, upcomingFlights.value))
const topAircraft = computed(() => getTopAircraft(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 {
pastFlights,
@@ -463,6 +513,7 @@ export function useFlightStats(flights: Ref<Flight[]>) {
reasons,
classes,
seatTypes,
alliances,
countries,
continents,
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 GlassTooltip from "@/Components/FlightsGoneBy/GlassTooltip.vue";
import ButtonLink from "@/Components/FlightsGoneBy/ButtonLink.vue";
import FollowingSelectBox from "@/Components/FlightsGoneBy/FollowingSelectBox.vue";
defineOptions({ layout: MainLayout })
@@ -26,6 +27,7 @@ const props = defineProps<{
airlines: Airline[]
continents: Continent[]
aircraft_families: Record<string, string[]>
majorAlliances: Alliance[]
achievementCount: number
canView: boolean
}>()
@@ -63,7 +65,7 @@ const unlocked = computed(() => {
<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
<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>
@@ -116,6 +118,13 @@ const unlocked = computed(() => {
:alliance="alliance"
:continents="continents"
: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>
</ProfileLayout>
+3
View File
@@ -9,6 +9,7 @@ import MainLayout from "@/Layouts/MainLayout.vue";
import Panel from "@/Components/FlightsGoneBy/Panels/Panel.vue";
import PanelHeader from "@/Components/FlightsGoneBy/Panels/PanelHeader.vue";
import {useUpdateSetting} from "@/Composables/useUpdateSetting";
import FollowingSelectBox from "@/Components/FlightsGoneBy/FollowingSelectBox.vue";
const {updateSetting} = useUpdateSetting()
@@ -121,6 +122,8 @@ const filteredUnlockedCount = computed(() =>
/>
</div>
</Panel>
<FollowingSelectBox redirect-route="profile.achievements" placeholder="View The Achievements of Someone You Follow" />
</div>
</ProfileLayout>
</template>
+10
View File
@@ -56,6 +56,8 @@ const selectedFlightReasons = ref<number[]>([])
const selectedSeatTypes = ref<number[]>([])
const selectedManufacturers = ref<string[]>([])
const selectedAircraftModels = ref<number[]>([])
const selectedEngineTypes = ref<string[]>([])
const selectedEngineCounts = ref<number[]>([])
const selectedAirportRegions = ref<number[]>([])
const selectedOtherUsers = ref<string[]>([])
@@ -74,6 +76,8 @@ const activeFilterCount = computed(() =>
selectedSeatTypes.value.length +
selectedManufacturers.value.length +
selectedAircraftModels.value.length +
selectedEngineTypes.value.length +
selectedEngineCounts.value.length +
selectedAirportRegions.value.length +
selectedOtherUsers.value.length
)
@@ -93,6 +97,8 @@ function onFiltersChange(filters: {
seatTypes: number[]
manufacturers: string[]
aircraftModels: number[]
engineTypes: string[]
engineCounts: number[]
airportRegions: number[]
otherUsers: string[]
}) {
@@ -111,6 +117,8 @@ function onFiltersChange(filters: {
selectedSeatTypes.value = filters.seatTypes
selectedManufacturers.value = filters.manufacturers
selectedAircraftModels.value = filters.aircraftModels
selectedEngineTypes.value = filters.engineTypes
selectedEngineCounts.value = filters.engineCounts
selectedAirportRegions.value = filters.airportRegions
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 (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 (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) {
const depRegion = f.departure_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[];
permissions: string[];
apiToken: string | null;
following: string[];
},
flash: {
success?: string;