6 Commits
13 changed files with 193 additions and 65 deletions
@@ -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'),
@@ -1,115 +1,125 @@
<script setup lang="ts">
import { computed } from 'vue'
import { Achievement, Alliance, Flight, User } from '@/Types/types'
import { computed, ref } from 'vue'
import { Achievement, Airline, 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";
defineOptions({ inheritAttrs: false })
interface AirlineEntry {
airline: Airline
flights: Flight[]
}
const props = defineProps<{
achievement: Achievement
user: User
followStatus: string
majorAlliances: Alliance[]
alliance: Alliance
airlines: Airline[]
flights: Flight[]
}>()
interface AllianceEntry {
alliance: Alliance
flights: Flight[]
}
const flightsByAirline = computed(() => {
const map = new Map<string, AirlineEntry>()
const idToKey = new Map<number, string>()
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)
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)
}
}
return entries
for (const flight of props.flights) {
const airline = flight.airline
if (!airline) continue
const key = idToKey.get(airline.id)
if (!key) continue
map.get(key)?.flights.push(flight)
}
return map
})
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}`
function rowKey(airline: Airline): string {
return airline.iata_code ?? airline.internal_name
}
const rows = computed(() => [...flightsByAirline.value.keys()])
function entryFor(key: string): AirlineEntry {
return flightsByAirline.value.get(key)!
}
</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>
<AllianceLogo :alliance="alliance" size="56" />
<div>
<PanelHeader centered>Major Alliances</PanelHeader>
<PanelHeader centered>{{ alliance.name }}</PanelHeader>
<PanelSubHeader centered>
{{ completedCount }} / {{ alliances.length }} alliances flown
<slot />
</PanelSubHeader>
</div>
</div>
</Panel>
<!-- Per-alliance breakdown -->
<Panel v-for="entry in alliances" :key="entry.alliance.internal_name">
<!-- Airlines table -->
<Panel>
<div class="table-toolbar">
<PanelHeader>{{ entry.alliance.name }}</PanelHeader>
<PanelHeader>Airlines</PanelHeader>
</div>
<BadgeTable
:rows="[entry.alliance.internal_name]"
:rows="rows"
:rowKey="key => key"
:hasItems="() => entry.flights.length > 0"
:hasItems="key => entryFor(key).flights.length > 0"
labelWidth="14em"
>
<template #label>
<div class="airline-label">
<AllianceLogo :alliance="entry.alliance" size="24" />
<span>{{ entry.alliance.name }}</span>
<template #label="{ row: key }">
<div class="airline-label" >
<AirlineLogo :airline="entryFor(key).airline" size="24" />
<span>{{ entryFor(key).airline.name }}</span>
</div>
</template>
<template #items>
<template #items="{ row: key }">
<FlightBadge
v-for="flight in entry.flights"
v-for="flight in entryFor(key).flights"
:key="flight.id"
:title="flightTitle(flight)"
:flight="flight"
/>
:title="`${flight.departure_airport?.iata_code} → ${flight.arrival_airport?.iata_code}`"
: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 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.
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.
</p>
<p>
Codeshare flights do not count, the operating carrier must be a member of the
alliance.
Codeshare flights do not count, the operating carrier must be a member of {{alliance.name}}.
</p>
</Panel>
</template>
@@ -123,10 +133,6 @@ function flightTitle(flight: Flight): string {
flex-wrap: wrap;
}
.logo-row {
display: flex;
gap: 0.75rem;
}
.table-toolbar {
display: flex;
@@ -136,6 +142,7 @@ function flightTitle(flight: Flight): string {
margin-bottom: 0.75rem;
}
.airline-label {
width: 100%;
display: flex;
@@ -143,4 +150,5 @@ function flightTitle(flight: Flight): string {
gap: 0.5rem;
padding: 0 0.25rem;
}
</style>
@@ -250,7 +250,7 @@ const legendItems = computed(() => {
<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}` }}
{{ i === 0 ? 'Outer' : i === normalizedRings.length - 1 ? 'Inner' : `Ring ${i + 1}` }}
Ring = {{ r.name }}
</span>
</div>
@@ -7,10 +7,9 @@ const props = defineProps<{
</script>
<template>
<DonutChart
title="Flight Scopes"
title="Flight Scope"
:height="280"
center-mode="count"
total-label="Flight Scopes"
total-label="Flights"
:labels="flightStats.flightTypes.value.labels"
:series="flightStats.flightTypes.value.series"
/>
@@ -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 ? 50 : 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>
+1 -1
View File
@@ -175,7 +175,7 @@ export function getCountries(flights: Flight[], upcomingFlights: Flight[]) {
const result = {
countries: sorted,
series: [
{ name: 'Departed', data: sorted.map(s => s.past) },
{ name: 'Flown', data: sorted.map(s => s.past) },
{ name: 'Upcoming', data: sorted.map(s => s.upcoming) },
],
}
@@ -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 })
@@ -119,6 +120,12 @@ const unlocked = computed(() => {
: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>
</template>
+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;