7 Commits
23 changed files with 771 additions and 176 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,
]);
}
+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",
@@ -1,125 +1,115 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { Achievement, Airline, Alliance, Flight, User } from '@/Types/types'
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 InlineBadge from '@/Components/FlightsGoneBy/InlineBadge.vue'
import AirlineLogo from '@/Components/FlightsGoneBy/AirlineLogo.vue'
import AllianceLogo from '@/Components/FlightsGoneBy/AllianceLogo.vue'
import FlightBadge from "@/Components/FlightsGoneBy/FlightBadge.vue";
import FlightBadge from '@/Components/FlightsGoneBy/FlightBadge.vue'
interface AirlineEntry {
airline: Airline
flights: Flight[]
}
defineOptions({ inheritAttrs: false })
const props = defineProps<{
achievement: Achievement
user: User
followStatus: string
alliance: Alliance
airlines: Airline[]
majorAlliances: Alliance[]
flights: Flight[]
}>()
const flightsByAirline = computed(() => {
const map = new Map<string, AirlineEntry>()
const idToKey = new Map<number, string>()
interface AllianceEntry {
alliance: Alliance
flights: Flight[]
}
for (const airline of props.airlines) {
const key = rowKey(airline)
map.set(key, { airline, flights: [] })
idToKey.set(airline.id, key)
for (const alias of airline.merged_from ?? []) {
idToKey.set(alias.id, key)
}
}
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 airline = flight.airline
if (!airline) continue
const key = idToKey.get(airline.id)
const key = flight.airline?.alliance?.internal_name
if (!key) continue
map.get(key)?.flights.push(flight)
byName.get(key)?.flights.push(flight)
}
return map
return entries
})
function rowKey(airline: Airline): string {
return airline.iata_code ?? airline.internal_name
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}`
}
const rows = computed(() => [...flightsByAirline.value.keys()])
function entryFor(key: string): AirlineEntry {
return flightsByAirline.value.get(key)!
}
</script>
<template>
<!-- Header -->
<Panel>
<div class="alliance-header">
<AllianceLogo :alliance="alliance" size="56" />
<div class="logo-row">
<AllianceLogo
v-for="entry in alliances"
:key="entry.alliance.internal_name"
:alliance="entry.alliance"
size="56"
/>
</div>
<div>
<PanelHeader centered>{{ alliance.name }}</PanelHeader>
<PanelHeader centered>Major Alliances</PanelHeader>
<PanelSubHeader centered>
<slot />
{{ completedCount }} / {{ alliances.length }} alliances flown
</PanelSubHeader>
</div>
</div>
</Panel>
<!-- Airlines table -->
<Panel>
<!-- Per-alliance breakdown -->
<Panel v-for="entry in alliances" :key="entry.alliance.internal_name">
<div class="table-toolbar">
<PanelHeader>Airlines</PanelHeader>
<PanelHeader>{{ entry.alliance.name }}</PanelHeader>
</div>
<BadgeTable
:rows="rows"
:rows="[entry.alliance.internal_name]"
:rowKey="key => key"
:hasItems="key => entryFor(key).flights.length > 0"
:hasItems="() => entry.flights.length > 0"
labelWidth="14em"
>
<template #label="{ row: key }">
<div class="airline-label" >
<AirlineLogo :airline="entryFor(key).airline" size="24" />
<span>{{ entryFor(key).airline.name }}</span>
<template #label>
<div class="airline-label">
<AllianceLogo :alliance="entry.alliance" size="24" />
<span>{{ entry.alliance.name }}</span>
</div>
</template>
<template #items="{ row: key }">
<template #items>
<FlightBadge
v-for="flight in entryFor(key).flights"
v-for="flight in entry.flights"
:key="flight.id"
:title="`${flight.departure_airport?.iata_code} → ${flight.arrival_airport?.iata_code}`"
:flight="flight" />
:title="flightTitle(flight)"
:flight="flight"
/>
</template>
</BadgeTable>
</Panel>
<!-- Slot for alliance-specific panels -->
<slot name="extra" />
<!-- Requirements -->
<Panel>
<PanelHeader centered>Requirements</PanelHeader>
<p>
To complete this challenge you must fly with every current member airline of
<strong>{{ alliance.name }}</strong>. Alliance membership changes over time, so the
required airlines reflect the current roster.
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 {{alliance.name}}.
Codeshare flights do not count, the operating carrier must be a member of the
alliance.
</p>
</Panel>
</template>
@@ -133,6 +123,10 @@ function entryFor(key: string): AirlineEntry {
flex-wrap: wrap;
}
.logo-row {
display: flex;
gap: 0.75rem;
}
.table-toolbar {
display: flex;
@@ -142,7 +136,6 @@ function entryFor(key: string): AirlineEntry {
margin-bottom: 0.75rem;
}
.airline-label {
width: 100%;
display: flex;
@@ -150,5 +143,4 @@ function entryFor(key: string): AirlineEntry {
gap: 0.5rem;
padding: 0 0.25rem;
}
</style>
@@ -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">
&#9899; {{ 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,10 @@ const props = defineProps<{
</script>
<template>
<DonutChart
title="Flight Types"
title="Flight Scopes"
:height="280"
center-mode="count"
total-label="Flight Scopes"
: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>
@@ -659,7 +659,7 @@ export default defineComponent({
const fitBounds = (): void => {
if (!props.flights.length) return
const padding = props.compact ? 40 : 60
const padding = props.compact ? 50 : 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])
@@ -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: 'Departed', 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>
@@ -26,6 +26,7 @@ const props = defineProps<{
airlines: Airline[]
continents: Continent[]
aircraft_families: Record<string, string[]>
majorAlliances: Alliance[]
achievementCount: number
canView: boolean
}>()
@@ -63,7 +64,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 +117,7 @@ const unlocked = computed(() => {
:alliance="alliance"
:continents="continents"
:families="aircraft_families"
:major-alliances="majorAlliances"
/>
</div>
</ProfileLayout>