diff --git a/app/Http/Controllers/Auth/AuthenticatedSessionController.php b/app/Http/Controllers/Auth/AuthenticatedSessionController.php index d44fe97..628a33b 100644 --- a/app/Http/Controllers/Auth/AuthenticatedSessionController.php +++ b/app/Http/Controllers/Auth/AuthenticatedSessionController.php @@ -33,7 +33,7 @@ class AuthenticatedSessionController extends Controller $request->session()->regenerate(); - return redirect()->intended(route('dashboard', absolute: false)); + return redirect()->intended(route('home', absolute: false)); } /** diff --git a/app/Http/Controllers/FlightProfileController.php b/app/Http/Controllers/FlightProfileController.php index c14f9ee..ea07a43 100644 --- a/app/Http/Controllers/FlightProfileController.php +++ b/app/Http/Controllers/FlightProfileController.php @@ -10,6 +10,25 @@ use Inertia\Inertia; class FlightProfileController extends Controller { + public function index(){ + if (auth()->check()) { + $user = auth()->user(); + $defaultPage = $user->resolved_settings['default_login_page']; + + $route = match ($defaultPage) { + 'feed_first' => $user->following()->count() > 0 ? 'feed' : 'profile.view', + 'feed' => 'feed', + 'profile' => 'profile.view', + 'dashboard' => 'dashboard', + }; + + $args = $route == 'profile.view' ? $user->name : null; + + return redirect()->route($route, $args); + } + return redirect()->route('login'); + } + public static function getUserFlightApiURL(User $user){ return '/data/user/'.$user->name.'/flights'; } @@ -43,7 +62,25 @@ class FlightProfileController extends Controller public function view(User $user) { - return $this->departureBoard($user); + $loggedInUser = auth()->user(); + + $isPrivate = $user->resolved_settings['private_profile']; + + if ($isPrivate && $user->id !== $loggedInUser?->id) { + if (!$loggedInUser || !$loggedInUser->isFollowing($user)) { + abort(404); + } + } + + $defaultView = $loggedInUser ? $loggedInUser->resolved_settings['default_profile_view'] : 'map'; + + return match($defaultView) { + 'boarding-passes' => $this->boardingPasses($user), + 'map' => $this->map($user), + 'departure-board' => $this->departureBoard($user), + 'achievements' => redirect()->route('profile.achievements', $user->name), + }; + } public function flight(User $user, UserFlight $userFlight) diff --git a/app/Settings/SettingsRegistry.php b/app/Settings/SettingsRegistry.php index 9794d8c..8e186fe 100644 --- a/app/Settings/SettingsRegistry.php +++ b/app/Settings/SettingsRegistry.php @@ -44,14 +44,14 @@ class SettingsRegistry [ 'key' => 'default_login_page', 'type' => 'select', - 'label' => 'Default Page After Login', + 'label' => 'Default Page', 'category' => 'FlightsGoneBy Settings', 'default' => 'feed_first', 'options' => [ - ['value' => 'feed_first', 'label' => 'Feed if Following People, Profile if Not'], - ['value' => 'profile', 'label' => 'Your Profile'], - ['value' => 'feed', 'label' => 'Your Feed'], - ['value' => 'dashboard', 'label' => 'Your Dashboard'], + ['value' => 'feed_first', 'label' => 'My Feed if Following People, My Profile if Not'], + ['value' => 'profile', 'label' => 'My Profile'], + ['value' => 'feed', 'label' => 'My Feed'], + ['value' => 'dashboard', 'label' => 'My Dashboard'], ], ], [ diff --git a/package-lock.json b/package-lock.json index adde092..013aa90 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,7 @@ "@inertiajs/vue3": "^2.0.0", "@tailwindcss/forms": "^0.5.3", "@tailwindcss/vite": "^4.0.0", + "@types/geojson": "^7946.0.16", "@types/leaflet": "^1.9.21", "@types/node": "^25.5.0", "@vitejs/plugin-vue": "^6.0.0", diff --git a/package.json b/package.json index 11d27c8..9a6de63 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "@inertiajs/vue3": "^2.0.0", "@tailwindcss/forms": "^0.5.3", "@tailwindcss/vite": "^4.0.0", + "@types/geojson": "^7946.0.16", "@types/leaflet": "^1.9.21", "@types/node": "^25.5.0", "@vitejs/plugin-vue": "^6.0.0", diff --git a/resources/js/Components/FlightsGoneBy/AircraftToolTip.vue b/resources/js/Components/FlightsGoneBy/AircraftToolTip.vue index 951193b..3c6c907 100644 --- a/resources/js/Components/FlightsGoneBy/AircraftToolTip.vue +++ b/resources/js/Components/FlightsGoneBy/AircraftToolTip.vue @@ -4,6 +4,11 @@ import GlassTooltip from "@/Components/FlightsGoneBy/GlassTooltip.vue"; import InlineBadge from "@/Components/FlightsGoneBy/InlineBadge.vue"; import WakeTurbulence from "@/Components/FlightsGoneBy/WakeTurbulence.vue"; import LiveryImage from "@/Components/FlightsGoneBy/LiveryImage.vue"; +import ToolTipDivider from "@/Components/FlightsGoneBy/Tooltips/ToolTipDivider.vue"; +import ToolTipRows from "@/Components/FlightsGoneBy/Tooltips/ToolTipRows.vue"; +import ToolTipRow from "@/Components/FlightsGoneBy/Tooltips/ToolTipRow.vue"; +import ToolTipName from "@/Components/FlightsGoneBy/Tooltips/ToolTipName.vue"; +import ToolTipHeader from "@/Components/FlightsGoneBy/Tooltips/ToolTipHeader.vue"; defineProps<{ aircraft: Aircraft @@ -26,99 +31,36 @@ function formatEngineType(type: string): string { -
- {{ aircraft.manufacturer_code }} {{ aircraft.model_full_name }} + + {{ aircraft.manufacturer_code }} {{ aircraft.model_full_name }} + -
- -
+ {{aircraft.designator }} -
+ -
+ -
-
- Engines - {{ aircraft.engine_count }}x {{ formatEngineType(aircraft.engine_type) }} -
-
+ + + -
-
+ + -
-
-
-
- Registration - {{ flight.aircraft_registration}} -
-
+ + + + + diff --git a/resources/js/Components/FlightsGoneBy/AirlineLogo.vue b/resources/js/Components/FlightsGoneBy/AirlineLogo.vue index e405588..dfed17a 100644 --- a/resources/js/Components/FlightsGoneBy/AirlineLogo.vue +++ b/resources/js/Components/FlightsGoneBy/AirlineLogo.vue @@ -5,6 +5,11 @@ import {usePage} from "@inertiajs/vue3"; import GlassTooltip from "@/Components/FlightsGoneBy/GlassTooltip.vue"; import InlineBadge from "@/Components/FlightsGoneBy/InlineBadge.vue"; import AllianceLogo from "@/Components/FlightsGoneBy/AllianceLogo.vue"; +import ToolTipHeader from "@/Components/FlightsGoneBy/Tooltips/ToolTipHeader.vue"; +import ToolTipDivider from "@/Components/FlightsGoneBy/Tooltips/ToolTipDivider.vue"; +import ToolTipName from "@/Components/FlightsGoneBy/Tooltips/ToolTipName.vue"; +import ToolTipRows from "@/Components/FlightsGoneBy/Tooltips/ToolTipRows.vue"; +import ToolTipRow from "@/Components/FlightsGoneBy/Tooltips/ToolTipRow.vue"; const page = usePage().props; @@ -33,26 +38,21 @@ const size = computed(() => props.size ? props.size + 'px' : '30px');
-
+
{{ airline.name }}
-
+ + {{ airline.iata_code }} {{ airline.icao_code }} -
-
-
-
- - {{ airline.country.name }} -
+ + + + +
@@ -77,45 +77,4 @@ span.airline-logo { align-items: center; gap: 10px; } - -.codes{ - width:100%; - display: flex; - gap: 4px; - padding-top:1em; - justify-content:flex-start; -} - - -.airline-name { - letter-spacing: 0.03em; -} - -.tooltip-divider { - height: 1px; - background: var(--table-border); -} - -.tooltip-meta { - display: flex; - align-items: center; - gap: 8px; - color: var(--muted); -} - -.country-flag { - font-size: 1rem; -} - -.country-name { - flex: 1; - font-size: 0.8rem; -} - -.codes { - display: flex; - gap: 4px; - margin-left: auto; -} - diff --git a/resources/js/Components/FlightsGoneBy/AirportToolTip.vue b/resources/js/Components/FlightsGoneBy/AirportToolTip.vue index fbd753e..68219bf 100644 --- a/resources/js/Components/FlightsGoneBy/AirportToolTip.vue +++ b/resources/js/Components/FlightsGoneBy/AirportToolTip.vue @@ -2,6 +2,12 @@ import { Airport } from "@/Types/types"; import GlassTooltip from "@/Components/FlightsGoneBy/GlassTooltip.vue"; import InlineBadge from "@/Components/FlightsGoneBy/InlineBadge.vue"; +import ToolTipDivider from "@/Components/FlightsGoneBy/Tooltips/ToolTipDivider.vue"; +import ToolTipRows from "@/Components/FlightsGoneBy/Tooltips/ToolTipRows.vue"; +import ToolTipHeader from "@/Components/FlightsGoneBy/Tooltips/ToolTipHeader.vue"; +import ToolTipRow from "@/Components/FlightsGoneBy/Tooltips/ToolTipRow.vue"; +import CountryRow from "@/Components/FlightsGoneBy/Tooltips/CountryRow.vue"; +import ToolTipName from "@/Components/FlightsGoneBy/Tooltips/ToolTipName.vue"; defineProps<{ airport: Airport @@ -12,9 +18,6 @@ function formatElevation(ft: number | null): string | null { return `${ft.toLocaleString()} ft` } -function formatType(type: string): string { - return type.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase()) -}
- -
-
-
{{ airport.name }}
-
- {{ airport.iata_code }} - {{ airport.icao_code }} -
-
-
-
+ {{ airport.name }} - -
-
- City - {{ airport.municipality }} -
-
- Country - - {{airport.region.country.name}}  - - -
-
- Elevation - {{ formatElevation(airport.elevation_ft) }} -
-
- Timezone - {{ airport.timezone }} -
-
- Coordinates - - {{ airport.latitude_deg.toFixed(4) }}, {{ airport.longitude_deg.toFixed(4) }} - -
-
+ + {{ airport.iata_code }} + {{ airport.icao_code }} + + + + + + + + + +
@@ -74,78 +52,4 @@ function formatType(type: string): string { .airport-tooltip { display: contents; } - -.fi{ - padding:0.25em; -} - -.tooltip-header { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 0.75rem; -} - -.iata{ - font-size:1.2em; -} - -.tooltip-codes { - display: flex; - flex-direction: column; - align-items: baseline; - gap: 0.5rem; - flex-wrap: wrap; -} - -.code-badges { - display: flex; - gap: 0.3rem; - flex-wrap: wrap; -} - -.tooltip-divider { - height: 1px; - background: var(--table-border, rgba(255,255,255,0.08)); -} - -.tooltip-rows { - display: flex; - flex-direction: column; - gap: 0.3rem; -} - -.tooltip-row { - display: flex; - align-items: baseline; - justify-content: space-between; - gap: 1rem; -} - -.tooltip-label { - font-family: 'Share Tech Mono', monospace; - font-size: 0.6rem; - letter-spacing: 0.15em; - color: var(--muted, #445566); - flex-shrink: 0; -} - -.tooltip-value { - font-size: 0.78rem; - color: var(--text, #c8cdd8); - text-align: right; -} - -.tz { - font-family: 'Share Tech Mono', monospace; - font-size: 0.7rem; - letter-spacing: 0.03em; -} - -.coords { - font-family: 'Share Tech Mono', monospace; - font-size: 0.68rem; - color: var(--muted, #556677); - letter-spacing: 0.03em; -} diff --git a/resources/js/Components/FlightsGoneBy/FlightMap.vue b/resources/js/Components/FlightsGoneBy/FlightMap.vue index 6f9db36..71031d8 100644 --- a/resources/js/Components/FlightsGoneBy/FlightMap.vue +++ b/resources/js/Components/FlightsGoneBy/FlightMap.vue @@ -2,10 +2,34 @@
+

No flight data available

+
+ +
+
+ + + + {{ item.label }} +
+
+
@@ -13,23 +37,26 @@ import { defineComponent, ref, onMounted, onBeforeUnmount, watch, nextTick, PropType } from 'vue' import maplibregl from 'maplibre-gl' import 'maplibre-gl/dist/maplibre-gl.css' -import { SharedProps } from '@/Types/types' import { usePage } from '@inertiajs/vue3' -import { Flight, Airport } from '@/Types/types' -import PlaneLoader from "@/Components/FlightsGoneBy/PlaneLoader.vue"; +import { Flight, Airport, SharedProps } from '@/Types/types' +import PlaneLoader from '@/Components/FlightsGoneBy/PlaneLoader.vue' +import type { Feature, FeatureCollection, LineString, Point } from 'geojson' + +// ── Types ───────────────────────────────────────────────────────────────────── -interface RouteFlightBucket { - historical: Flight[] - future: Flight[] -} type LngLat = [number, number] +interface RouteFlightBucket { + historical: Flight[] + future: Flight[] +} + interface RouteFeatureProperties { - color?: string - routeKey: string - depId: number - arrId: number + color?: string + routeKey: string + depId: number + arrId: number } interface AirportFeatureProperties { @@ -37,105 +64,166 @@ interface AirportFeatureProperties { } interface RoutesGeoJSON { - historical: GeoJSON.FeatureCollection - future: GeoJSON.FeatureCollection + historical: FeatureCollection + future: FeatureCollection } -function greatCircleGeoJSON(from: LngLat, to: LngLat, steps?: number): LngLat[] { - const dist = Math.sqrt((to[0] - from[0]) ** 2 + (to[1] - from[1]) ** 2) - const s = steps ?? (dist > 60 ? 64 : dist > 20 ? 32 : 16) - const toRad = (d: number): number => d * Math.PI / 180 - const toDeg = (r: number): number => r * 180 / Math.PI +// ── Constants ───────────────────────────────────────────────────────────────── + +const PULSE_PHASES = 6 +const PULSE_PERIOD = 2200 +const TOUCH_RADIUS = 20 + +const ROUTE_COLORS: [number, string][] = [ + [5, '#f97316'], + [3, '#eab308'], + [2, '#22c55e'], + [1, '#a150d5'], +] + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +function routeKey(a: { id: number }, b: { id: number }): string { + return [a.id, b.id].sort().join('-') +} + +function routeColor(flightCount: number): string { + return ROUTE_COLORS.find(([min]) => flightCount >= min)![1] +} + +function isTouchDevice(): boolean { + return window.matchMedia('(pointer: coarse)').matches +} + +function greatCirclePoints(from: LngLat, to: LngLat, steps?: number): LngLat[] { + const dist = Math.sqrt((to[0] - from[0]) ** 2 + (to[1] - from[1]) ** 2) + const s = steps ?? (dist > 60 ? 64 : dist > 20 ? 32 : 16) + + const toRad = (d: number) => d * Math.PI / 180 + const toDeg = (r: number) => r * 180 / Math.PI + const lat1 = toRad(from[1]), lng1 = toRad(from[0]) const lat2 = toRad(to[1]), lng2 = toRad(to[0]) - const d = 2 * Math.asin(Math.sqrt( + + const angularDist = 2 * Math.asin(Math.sqrt( Math.sin((lat2 - lat1) / 2) ** 2 + Math.cos(lat1) * Math.cos(lat2) * Math.sin((lng2 - lng1) / 2) ** 2 )) - if (d === 0) return [[from[0], from[1]], [to[0], to[1]]] + + if (angularDist === 0) return [from, to] + const points: LngLat[] = [] for (let i = 0; i <= s; i++) { const f = i / s - const A = Math.sin((1 - f) * d) / Math.sin(d) - const B = Math.sin(f * d) / Math.sin(d) + const A = Math.sin((1 - f) * angularDist) / Math.sin(angularDist) + const B = Math.sin(f * angularDist) / Math.sin(angularDist) const x = A * Math.cos(lat1) * Math.cos(lng1) + B * Math.cos(lat2) * Math.cos(lng2) const y = A * Math.cos(lat1) * Math.sin(lng1) + B * Math.cos(lat2) * Math.sin(lng2) const z = A * Math.sin(lat1) + B * Math.sin(lat2) points.push([toDeg(Math.atan2(y, x)), toDeg(Math.atan2(z, Math.sqrt(x * x + y * y)))]) } + + // Unwrap antimeridian crossings for (let i = 1; i < points.length; i++) { const diff = points[i][0] - points[i - 1][0] - if (diff > 180) points[i][0] -= 360 + if (diff > 180) points[i][0] -= 360 if (diff < -180) points[i][0] += 360 } + return points } +// ── Popup HTML ──────────────────────────────────────────────────────────────── + function airportPopupHTML(airport: Airport): string { + const row = (label: string, value: string) => + `
${label}${value}
` + const elevation = airport.elevation_ft !== null - ? `
Elevation${airport.elevation_ft.toLocaleString()} ft
` - : '' - const city = airport.municipality - ? `
City${airport.municipality}
` - : '' + ? row('Elevation', `${airport.elevation_ft.toLocaleString()} ft`) : '' + const city = airport.municipality + ? row('City', airport.municipality) : '' const country = airport.region?.country - ? `
Country${airport.region.country.name}
` - : '' - const iata = airport.iata_code ? `${airport.iata_code}` : '' - const icao = airport.icao_code ? `${airport.icao_code}` : '' + ? row('Country', `${airport.region.country.name} `) : '' + const badges = [airport.iata_code, airport.icao_code] + .filter(Boolean) + .map(code => `${code}`) + .join('') + return `
${airport.name}
-
${iata}${icao}
+
${badges}
${city}${country}${elevation} -
Timezone${airport.timezone}
-
Coordinates${airport.latitude_deg.toFixed(4)}, ${airport.longitude_deg.toFixed(4)}
+ ${row('Timezone', `${airport.timezone}`)} + ${row('Coordinates', `${airport.latitude_deg.toFixed(4)}, ${airport.longitude_deg.toFixed(4)}`)}
` } function routePopupHTML(historical: Flight[], future: Flight[]): string { - interface DirectionGroup { - label: string - airlines: string[] - } + interface AirlineEntry { html: string; count: number } + interface DirectionGroup { label: string; airlines: Map } - const groupByDirection = (list: Flight[]): DirectionGroup[] => { - const logoApiUrl = usePage().props.logo_api_url + const groupByDirection = (flights: Flight[]): DirectionGroup[] => { + const groups = new Map() - const dirs = new Map() - list.forEach(f => { - const key = `${f.departure_airport.id}-${f.arrival_airport.id}` - const label = `${f.departure_airport.municipality} to ${f.arrival_airport.municipality}` - if (!dirs.has(key)) dirs.set(key, { label, airlines: [] }) - const airline = f.airline ? ` - ${f.airline?.iata_code} - ${f.airline?.name} - ` : '' - if (airline && !dirs.get(key)!.airlines.includes(airline)) { - dirs.get(key)!.airlines.push(airline) + flights.forEach(flight => { + const key = `${flight.departure_airport.id}-${flight.arrival_airport.id}` + const label = `${flight.departure_airport.municipality} to ${flight.arrival_airport.municipality}` + + if (!groups.has(key)) groups.set(key, { label, airlines: new Map() }) + + if (!flight.airline) return + + const { iata_code, logo_url, name } = flight.airline + const airlineKey = iata_code ?? '' + const airlines = groups.get(key)!.airlines + + if (airlines.has(airlineKey)) { + airlines.get(airlineKey)!.count++ + } else { + airlines.set(airlineKey, { + html: ` + ${iata_code} + ${name} + `, + count: 1, + }) } }) - return [...dirs.values()] + + return [...groups.values()] } - const renderSection = (title: string, list: Flight[]): string => { - if (!list.length) return '' - const rows = groupByDirection(list).map(({ label, airlines }) => ` -
-
${label}
-
${airlines.join('
') || '—'}
-
`).join('') + const renderSection = (title: string, flights: Flight[]): string => { + if (!flights.length) return '' + + const rows = groupByDirection(flights).map(({ label, airlines }) => { + const airlineLines = [...airlines.values()] + .map(({ html, count }) => count > 1 + ? `${html}(x${count})` + : html) + .join('
') + + return ` +
+
${label}
+
${airlineLines || '—'}
+
` + }).join('') + return `
${title}
${rows}
` } const divider = historical.length && future.length ? '
' : '' + return `
${renderSection('Flown', historical)} @@ -144,76 +232,97 @@ function routePopupHTML(historical: Flight[], future: Flight[]): string {
` } +// ── Component ───────────────────────────────────────────────────────────────── + export default defineComponent({ name: 'FlightMap', - components: {PlaneLoader}, + components: { PlaneLoader }, props: { flights: { - type: Array as PropType, + type: Array as PropType, default: (): Flight[] => [], }, + showLegend: { + type: Boolean, + default: true, + } + }, setup(props) { const mapContainer = ref(null) - let map: maplibregl.Map | null = null - let popup: maplibregl.Popup | null = null - let pulseFrame: number | null = null + const mapReady = ref(false) + + let map: maplibregl.Map | null = null + let popup: maplibregl.Popup | null = null + let pulseFrame: number | null = null + + const airportById = new Map() + const routeFlights = new Map() + const arcCache = new Map() - const PULSE_PHASES = 6 - const TOUCH_RADIUS = 20 - const airportById = new Map() - const routeFlights = new Map() - const arcCache = new Map() let selectedAirportId: number | null = null - const isTouchDevice = (): boolean => window.matchMedia('(pointer: coarse)').matches + const legendOpen = ref(true) - const showPopup = (lngLat: maplibregl.LngLatLike, html: string): void => { - popup!.setLngLat(lngLat).setHTML(html).addTo(map!) + const legendItems = [ + { color: '#f97316', label: '5+ flights' }, + { color: '#eab308', label: '3–4 flights' }, + { color: '#22c55e', label: '2 flights' }, + { color: '#a150d5', label: '1 flight' }, + { color: '#ffffff', label: 'Upcoming', dashed: true }, + ] + + + // ── Arc cache ───────────────────────────────────────────────────────── + + const exportMap = (targetWidth = 7680, targetHeight = 4320): void => { + if (!map) return + + const container = map.getContainer() + const originalWidth = container.offsetWidth + const originalHeight = container.offsetHeight + const currentCenter = map.getCenter() + const currentZoom = map.getZoom() + + container.style.width = `${targetWidth}px` + container.style.height = `${targetHeight}px` + map.resize() + map.jumpTo({ center: [100, 20], zoom: 2 })// low zoom, centered roughly on your data + + setTimeout(() => { + map!.triggerRepaint() + requestAnimationFrame(() => { + requestAnimationFrame(() => { + const dataURL = map!.getCanvas().toDataURL('image/jpeg', 0.95) + const link = document.createElement('a') + link.href = dataURL + link.download = 'flight-map.jpg' + link.click() + + container.style.width = `${originalWidth}px` + container.style.height = `${originalHeight}px` + map!.resize() + map!.jumpTo({ center: currentCenter, zoom: currentZoom }) + }) + }) + }, 10000) } - // ── Filter ──────────────────────────────────────────────────────────── - const applyFilter = (): void => { - if (!map || !map.isStyleLoaded()) return - const id = selectedAirportId - - const routeFilter: maplibregl.FilterSpecification | null = id - ? ['any', ['==', ['get', 'depId'], id], ['==', ['get', 'arrId'], id]] - : null - - map.setFilter('routes-line', routeFilter) - map.setFilter('routes-hit', routeFilter) - map.setFilter('routes-future-line', routeFilter) - map.setFilter('routes-future-hit', routeFilter) - - // Use setFilter on airport layers — GPU-side, no style recompilation - for (let i = 0; i < PULSE_PHASES; i++) { - const airportFilter: maplibregl.FilterSpecification | null = id - ? ['==', ['get', 'id'], id] - : null - map.setFilter(`airports-dot-${i}`, airportFilter) - map.setFilter(`airports-pulse-${i}`, airportFilter) - } + const exportMapBasic = (): void => { + if (!map) return + const canvas = map.getCanvas() + const link = document.createElement('a') + link.href = canvas.toDataURL('image/jpeg', 0.95) + link.download = 'flight-map.jpg' + link.click() } - // ── Route flight lookup ─────────────────────────────────────────────── - const buildRouteFlights = (): void => { - routeFlights.clear() - const now = new Date() - props.flights.forEach((flight) => { - const key = [flight.departure_airport.id, flight.arrival_airport.id].sort().join('-') - if (!routeFlights.has(key)) routeFlights.set(key, { historical: [], future: [] }) - routeFlights.get(key)![new Date(flight.departure_date) > now ? 'future' : 'historical'].push(flight) - }) - } - - // ── Arc cache helper ────────────────────────────────────────────────── const getArc = (flight: Flight): LngLat[] => { - const key = [flight.departure_airport.id, flight.arrival_airport.id].sort().join('-') + const key = routeKey(flight.departure_airport, flight.arrival_airport) if (!arcCache.has(key)) { - arcCache.set(key, greatCircleGeoJSON( + arcCache.set(key, greatCirclePoints( [flight.departure_airport.longitude_deg, flight.departure_airport.latitude_deg], [flight.arrival_airport.longitude_deg, flight.arrival_airport.latitude_deg], )) @@ -221,97 +330,152 @@ export default defineComponent({ return arcCache.get(key)! } + // ── Popup ───────────────────────────────────────────────────────────── + + const showPopup = (lngLat: maplibregl.LngLatLike, html: string) => + popup!.setLngLat(lngLat).setHTML(html).addTo(map!) + + // ── Filter ──────────────────────────────────────────────────────────── + + const applyFilter = (): void => { + if (!map?.isStyleLoaded()) return + const id = selectedAirportId + + const routeFilter: maplibregl.FilterSpecification | null = id + ? ['any', ['==', ['get', 'depId'], id], ['==', ['get', 'arrId'], id]] + : null + + for (const layerId of ['routes-line', 'routes-hit', 'routes-future-line', 'routes-future-hit']) { + map.setFilter(layerId, routeFilter) + } + + // Build the set of airports that should remain visible + let visibleAirportIds: Set | null = null + if (id) { + visibleAirportIds = new Set([id]) + routeFlights.forEach((bucket, key) => { + const allFlights = [...bucket.historical, ...bucket.future] + if (allFlights.some(f => + f.departure_airport.id === id || f.arrival_airport.id === id + )) { + allFlights.forEach(f => { + visibleAirportIds!.add(f.departure_airport.id) + visibleAirportIds!.add(f.arrival_airport.id) + }) + } + }) + } + + const airportFilter: maplibregl.FilterSpecification | null = visibleAirportIds + ? ['in', ['get', 'id'], ['literal', [...visibleAirportIds]]] + : null + + for (let i = 0; i < PULSE_PHASES; i++) { + map.setFilter(`airports-dot-${i}`, airportFilter) + map.setFilter(`airports-pulse-${i}`, airportFilter) + } + } + // ── GeoJSON builders ────────────────────────────────────────────────── + + const buildRouteFlights = (): void => { + routeFlights.clear() + const now = new Date() + + props.flights.forEach(flight => { + const key = routeKey(flight.departure_airport, flight.arrival_airport) + const bucket = routeFlights.get(key) ?? { historical: [], future: [] } + bucket[new Date(flight.departure_date) > now ? 'future' : 'historical'].push(flight) + routeFlights.set(key, bucket) + }) + } + const buildRoutesGeoJSON = (): RoutesGeoJSON => { buildRouteFlights() const now = new Date() - const routeCounts = new Map() - props.flights.forEach((flight) => { - if (new Date(flight.departure_date) > now) return - const key = [flight.departure_airport.id, flight.arrival_airport.id].sort().join('-') - routeCounts.set(key, (routeCounts.get(key) ?? 0) + 1) - }) - - const routeColor = (count: number): string => { - if (count >= 5) return '#f97316' - if (count >= 3) return '#eab308' - if (count >= 2) return '#22c55e' - return '#a150d5' - } - - const seenHistorical = new Set() - const historicalFeatures: GeoJSON.Feature[] = [] + // Count historical flights per route for colour coding + const flightCountByRoute = new Map() props.flights .filter(f => new Date(f.departure_date) <= now) - .forEach((flight) => { - const key = [flight.departure_airport.id, flight.arrival_airport.id].sort().join('-') - if (seenHistorical.has(key)) return - seenHistorical.add(key) - const count = routeCounts.get(key) ?? 1 - historicalFeatures.push({ - type: 'Feature', - properties: { - color: routeColor(count), routeKey: key, - depId: flight.departure_airport.id, - arrId: flight.arrival_airport.id, - }, - geometry: { type: 'LineString', coordinates: getArc(flight) }, - }) + .forEach(f => { + const key = routeKey(f.departure_airport, f.arrival_airport) + flightCountByRoute.set(key, (flightCountByRoute.get(key) ?? 0) + 1) }) - const historicalKeys = new Set(routeCounts.keys()) - const seenFuture = new Set() - const futureFeatures: GeoJSON.Feature[] = [] - props.flights - .filter((flight) => { - if (new Date(flight.departure_date) <= now) return false - const key = [flight.departure_airport.id, flight.arrival_airport.id].sort().join('-') - return !historicalKeys.has(key) - }) - .forEach((flight) => { - const key = [flight.departure_airport.id, flight.arrival_airport.id].sort().join('-') - if (seenFuture.has(key)) return - seenFuture.add(key) - futureFeatures.push({ - type: 'Feature', - properties: { routeKey: key, depId: flight.departure_airport.id, arrId: flight.arrival_airport.id }, - geometry: { type: 'LineString', coordinates: getArc(flight) }, - }) - }) + const makeFeature = ( + flight: Flight, + extraProps: Partial = {} + ): Feature => ({ + type: 'Feature', + properties: { + routeKey: routeKey(flight.departure_airport, flight.arrival_airport), + depId: flight.departure_airport.id, + arrId: flight.arrival_airport.id, + ...extraProps, + }, + geometry: { type: 'LineString', coordinates: getArc(flight) }, + }) + + // One feature per unique route, deduped by key + const historicalFeatures = [...new Map( + props.flights + .filter(f => new Date(f.departure_date) <= now) + .map(f => [ + routeKey(f.departure_airport, f.arrival_airport), + makeFeature(f, { color: routeColor(flightCountByRoute.get(routeKey(f.departure_airport, f.arrival_airport)) ?? 1) }), + ]) + ).values()] + + const historicalRouteKeys = new Set(flightCountByRoute.keys()) + const futureFeatures = [...new Map( + props.flights + .filter(f => new Date(f.departure_date) > now) + .filter(f => !historicalRouteKeys.has(routeKey(f.departure_airport, f.arrival_airport))) + .map(f => [routeKey(f.departure_airport, f.arrival_airport), makeFeature(f)]) + ).values()] + + const toCollection = (features: Feature[]) => + ({ type: 'FeatureCollection' as const, features }) return { - historical: { type: 'FeatureCollection', features: historicalFeatures }, - future: { type: 'FeatureCollection', features: futureFeatures }, + historical: toCollection(historicalFeatures), + future: toCollection(futureFeatures), } } - const buildAirportsGeoJSON = (): GeoJSON.FeatureCollection[] => { - const seen = new Map() + const buildAirportsGeoJSON = (): FeatureCollection[] => { + const uniqueAirports = new Map() props.flights.forEach(({ departure_airport: dep, arrival_airport: arr }) => { - if (!seen.has(dep.id)) seen.set(dep.id, dep) - if (!seen.has(arr.id)) seen.set(arr.id, arr) + uniqueAirports.set(dep.id, dep) + uniqueAirports.set(arr.id, arr) }) + + // Spread airports across pulse phase buckets for staggered animation const buckets: Airport[][] = Array.from({ length: PULSE_PHASES }, () => []) - ;[...seen.values()].forEach(airport => buckets[airport.id % PULSE_PHASES].push(airport)) + uniqueAirports.forEach(airport => buckets[airport.id % PULSE_PHASES].push(airport)) + return buckets.map(airports => ({ type: 'FeatureCollection', features: airports.map(airport => ({ - type: 'Feature', + type: 'Feature', properties: { id: airport.id }, - geometry: { type: 'Point', coordinates: [airport.longitude_deg, airport.latitude_deg] }, + geometry: { type: 'Point', coordinates: [airport.longitude_deg, airport.latitude_deg] }, })), })) } // ── Map layers ──────────────────────────────────────────────────────── + const addLayers = (): void => { const { historical, future } = buildRoutesGeoJSON() + const isTouch = isTouchDevice() + // Routes map!.addSource('routes', { type: 'geojson', data: historical }) map!.addLayer({ id: 'routes-line', type: 'line', source: 'routes', - paint: { 'line-color': ['get', 'color'], 'line-opacity': 0.75, 'line-width': 1.8 }, + paint: { 'line-color': ['get', 'color'], 'line-opacity': 0.75, 'line-width': 1.8 }, layout: { 'line-join': 'round', 'line-cap': 'round' }, }) map!.addLayer({ @@ -319,10 +483,11 @@ export default defineComponent({ paint: { 'line-color': 'transparent', 'line-width': 12 }, }) + // Future routes map!.addSource('routes-future', { type: 'geojson', data: future }) map!.addLayer({ id: 'routes-future-line', type: 'line', source: 'routes-future', - paint: { 'line-color': '#ffffff', 'line-opacity': 0.5, 'line-width': 1.5, 'line-dasharray': [3, 3] }, + paint: { 'line-color': '#ffffff', 'line-opacity': 0.5, 'line-width': 1.5, 'line-dasharray': [3, 3] }, layout: { 'line-join': 'round', 'line-cap': 'round' }, }) map!.addLayer({ @@ -330,8 +495,8 @@ export default defineComponent({ paint: { 'line-color': 'transparent', 'line-width': 12 }, }) - const airportBuckets = buildAirportsGeoJSON() - airportBuckets.forEach((data, i) => { + // Airport dots + pulse rings (one source per phase bucket) + buildAirportsGeoJSON().forEach((data, i) => { map!.addSource(`airports-${i}`, { type: 'geojson', data }) map!.addLayer({ id: `airports-pulse-${i}`, type: 'circle', source: `airports-${i}`, @@ -350,7 +515,6 @@ export default defineComponent({ }) }) - const isTouch = isTouchDevice() popup = new maplibregl.Popup({ closeButton: isTouch, closeOnClick: false, @@ -361,16 +525,15 @@ export default defineComponent({ const airportLayerIds = Array.from({ length: PULSE_PHASES }, (_, i) => `airports-dot-${i}`) - // ── Desktop hover events ────────────────────────────────────────── + // ── Hover events (desktop only) ─────────────────────────────────── + if (!isTouch) { for (let i = 0; i < PULSE_PHASES; i++) { - map!.on('mouseenter', `airports-dot-${i}`, (e) => { + map!.on('mouseenter', `airports-dot-${i}`, e => { map!.getCanvas().style.cursor = 'pointer' const airport = airportById.get(Number(e.features![0].properties.id)) - const geom = e.features![0].geometry - if (airport && geom.type === 'Point') { - showPopup(geom.coordinates as LngLat, airportPopupHTML(airport)) - } + const geom = e.features![0].geometry + if (airport && geom.type === 'Point') showPopup(geom.coordinates as LngLat, airportPopupHTML(airport)) }) map!.on('mouseleave', `airports-dot-${i}`, () => { map!.getCanvas().style.cursor = '' @@ -378,74 +541,65 @@ export default defineComponent({ }) } - map!.on('mouseenter', 'routes-hit', (e) => { - map!.getCanvas().style.cursor = 'pointer' - const rf = routeFlights.get(e.features![0].properties.routeKey as string) - if (rf) showPopup(e.lngLat, routePopupHTML(rf.historical, rf.future)) - }) - map!.on('mouseleave', 'routes-hit', () => { - map!.getCanvas().style.cursor = '' - popup!.remove() - }) - - map!.on('mouseenter', 'routes-future-hit', (e) => { - map!.getCanvas().style.cursor = 'pointer' - const rf = routeFlights.get(e.features![0].properties.routeKey as string) - if (rf) showPopup(e.lngLat, routePopupHTML(rf.historical, rf.future)) - }) - map!.on('mouseleave', 'routes-future-hit', () => { - map!.getCanvas().style.cursor = '' - popup!.remove() - }) + for (const layerId of ['routes-hit', 'routes-future-hit']) { + map!.on('mouseenter', layerId, e => { + map!.getCanvas().style.cursor = 'pointer' + const bucket = routeFlights.get(e.features![0].properties.routeKey as string) + if (bucket) showPopup(e.lngLat, routePopupHTML(bucket.historical, bucket.future)) + }) + map!.on('mouseleave', layerId, () => { + map!.getCanvas().style.cursor = '' + popup!.remove() + }) + } } - // ── Unified click handler ───────────────────────────────────────── - map!.on('click', (e) => { + // ── Click handler ───────────────────────────────────────────────── + + map!.on('click', e => { + // Widen hit area on touch const airportQuery = isTouch ? [[e.point.x - TOUCH_RADIUS, e.point.y - TOUCH_RADIUS], [e.point.x + TOUCH_RADIUS, e.point.y + TOUCH_RADIUS]] as [maplibregl.PointLike, maplibregl.PointLike] : e.point as maplibregl.PointLike - const airportFeatures = map!.queryRenderedFeatures(airportQuery, { layers: airportLayerIds }) + const clickedAirport = map!.queryRenderedFeatures(airportQuery, { layers: airportLayerIds })[0] - if (airportFeatures.length) { - const id = Number(airportFeatures[0].properties.id) + if (clickedAirport) { + const id = Number(clickedAirport.properties.id) selectedAirportId = selectedAirportId === id ? null : id applyFilter() if (isTouch) { const airport = airportById.get(id) - const geom = airportFeatures[0].geometry - if (airport && geom.type === 'Point') { - showPopup(geom.coordinates as LngLat, airportPopupHTML(airport)) - } + const geom = clickedAirport.geometry + if (airport && geom.type === 'Point') showPopup(geom.coordinates as LngLat, airportPopupHTML(airport)) } return } - const routeFeatures = map!.queryRenderedFeatures(e.point, { - layers: ['routes-hit', 'routes-future-hit'], - }) + const clickedRoute = map!.queryRenderedFeatures(e.point, { layers: ['routes-hit', 'routes-future-hit'] })[0] - if (routeFeatures.length) { - const rf = routeFlights.get(routeFeatures[0].properties.routeKey as string) - if (rf) showPopup(e.lngLat, routePopupHTML(rf.historical, rf.future)) + if (clickedRoute) { + const bucket = routeFlights.get(clickedRoute.properties.routeKey as string) + if (bucket) showPopup(e.lngLat, routePopupHTML(bucket.historical, bucket.future)) return } + // Clicked empty space — deselect selectedAirportId = null applyFilter() popup!.remove() }) // ── Pulse animation ─────────────────────────────────────────────── - const period = 2200 + const animate = (): void => { const now = Date.now() for (let i = 0; i < PULSE_PHASES; i++) { - const t = ((now + (i / PULSE_PHASES) * period) % period) / period - map!.setPaintProperty(`airports-pulse-${i}`, 'circle-radius', 5 + t * 13) + const progress = ((now + (i / PULSE_PHASES) * PULSE_PERIOD) % PULSE_PERIOD) / PULSE_PERIOD + map!.setPaintProperty(`airports-pulse-${i}`, 'circle-radius', 5 + progress * 13) if (!selectedAirportId) { - map!.setPaintProperty(`airports-pulse-${i}`, 'circle-stroke-opacity', 0.7 * (1 - t)) + map!.setPaintProperty(`airports-pulse-${i}`, 'circle-stroke-opacity', 0.7 * (1 - progress)) } } pulseFrame = requestAnimationFrame(animate) @@ -453,102 +607,102 @@ export default defineComponent({ animate() } - const fitBounds = (): void => { + // ── Fit bounds ──────────────────────────────────────────────────────── + + const fitBounds = (padding = 60): void => { if (!props.flights.length) return - // Collect all airport coordinates - const lngs: number[] = [] - const lats: number[] = [] - props.flights.forEach(f => { - lngs.push(f.departure_airport.longitude_deg, f.arrival_airport.longitude_deg) - lats.push(f.departure_airport.latitude_deg, f.arrival_airport.latitude_deg) - }) + const lngs = props.flights.flatMap(f => [f.departure_airport.longitude_deg, f.arrival_airport.longitude_deg]) + const lats = props.flights.flatMap(f => [f.departure_airport.latitude_deg, f.arrival_airport.latitude_deg]) const minLat = Math.min(...lats) const maxLat = Math.max(...lats) - // Check if wrapping across the dateline gives a tighter span - const minLngNormal = Math.min(...lngs) - const maxLngNormal = Math.max(...lngs) - const spanNormal = maxLngNormal - minLngNormal - - // Shift negative longitudes into the 0–360 range and recompute span - const lngs360 = lngs.map(lng => lng < 0 ? lng + 360 : lng) - const minLng360 = Math.min(...lngs360) - const maxLng360 = Math.max(...lngs360) - const span360 = maxLng360 - minLng360 - - let minLng: number - let maxLng: number + // Compare normal span vs dateline-crossing span to pick the tighter fit + const spanNormal = Math.max(...lngs) - Math.min(...lngs) + const lngs360 = lngs.map(lng => lng < 0 ? lng + 360 : lng) + const span360 = Math.max(...lngs360) - Math.min(...lngs360) + let minLng: number, maxLng: number if (span360 < spanNormal) { - // Dateline-crossing view is tighter — use the shifted coords - minLng = minLng360 > 180 ? minLng360 - 360 : minLng360 - maxLng = maxLng360 > 180 ? maxLng360 - 360 : maxLng360 + const min360 = Math.min(...lngs360) + const max360 = Math.max(...lngs360) + minLng = min360 > 180 ? min360 - 360 : min360 + maxLng = max360 > 180 ? max360 - 360 : max360 } else { - minLng = minLngNormal - maxLng = maxLngNormal + minLng = Math.min(...lngs) + maxLng = Math.max(...lngs) } - map!.fitBounds([[minLng, minLat], [maxLng, maxLat]], { padding: 60, duration: 0 }) + map!.fitBounds([[minLng, minLat], [maxLng, maxLat]], { padding, duration: 0 }) } // ── Map init ────────────────────────────────────────────────────────── + const initMap = (): void => { nextTick(() => { map = new maplibregl.Map({ - container: mapContainer.value!, + container: mapContainer.value!, cooperativeGestures: true, - attributionControl: false, + attributionControl: false, + center: [0, 20], + zoom: 2, + renderWorldCopies: true, + canvasContextAttributes: { preserveDrawingBuffer: true }, style: { version: 8, sources: { 'carto-dark': { - type: 'raster', + type: 'raster', + tileSize: 256, + maxzoom: 19, tiles: [ 'https://a.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}@2x.png', 'https://b.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}@2x.png', 'https://c.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}@2x.png', 'https://d.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}@2x.png', ], - tileSize: 256, - maxzoom: 19, }, }, layers: [{ id: 'carto-dark', type: 'raster', source: 'carto-dark' }], }, - center: [0, 20], - zoom: 2, - renderWorldCopies: true, }) + map.addControl(new maplibregl.NavigationControl({ showCompass: false }), 'top-right') map.addControl(new maplibregl.FullscreenControl(), 'top-right') map.on('load', () => { addLayers() fitBounds() - mapReady.value = true // ← add this + mapReady.value = true }) }) } + // ── Data updates ────────────────────────────────────────────────────── + const updateData = (): void => { - if (!map || !map.isStyleLoaded()) return + if (!map?.isStyleLoaded()) return + arcCache.clear() airportById.clear() props.flights.forEach(({ departure_airport: dep, arrival_airport: arr }) => { airportById.set(dep.id, dep) airportById.set(arr.id, arr) }) - const { historical, future } = buildRoutesGeoJSON(); - (map.getSource('routes') as maplibregl.GeoJSONSource)?.setData(historical); - (map.getSource('routes-future') as maplibregl.GeoJSONSource)?.setData(future) + + const { historical, future } = buildRoutesGeoJSON() + ;(map.getSource('routes') as maplibregl.GeoJSONSource)?.setData(historical) + ;(map.getSource('routes-future') as maplibregl.GeoJSONSource)?.setData(future) buildAirportsGeoJSON().forEach((data, i) => (map!.getSource(`airports-${i}`) as maplibregl.GeoJSONSource)?.setData(data) ) + fitBounds() } + // ── Lifecycle ───────────────────────────────────────────────────────── + onMounted(() => { props.flights.forEach(({ departure_airport: dep, arrival_airport: arr }) => { airportById.set(dep.id, dep) @@ -564,84 +718,88 @@ export default defineComponent({ if (map) { map.remove(); map = null } }) - const mapReady = ref(false) - - return { mapContainer, mapReady } + return { mapContainer, mapReady, exportMapBasic, legendOpen, legendItems } }, }) + + diff --git a/resources/js/Components/FlightsGoneBy/FlightStatsBar.vue b/resources/js/Components/FlightsGoneBy/FlightStatsBar.vue index 0db9e4a..7d9979b 100644 --- a/resources/js/Components/FlightsGoneBy/FlightStatsBar.vue +++ b/resources/js/Components/FlightsGoneBy/FlightStatsBar.vue @@ -40,7 +40,9 @@
diff --git a/resources/js/Components/FlightsGoneBy/FlightToolTip.vue b/resources/js/Components/FlightsGoneBy/FlightToolTip.vue index b95a797..6a5940f 100644 --- a/resources/js/Components/FlightsGoneBy/FlightToolTip.vue +++ b/resources/js/Components/FlightsGoneBy/FlightToolTip.vue @@ -3,6 +3,12 @@ import { Flight } from "@/Types/types"; import GlassTooltip from "@/Components/FlightsGoneBy/GlassTooltip.vue"; import InlineBadge from "@/Components/FlightsGoneBy/InlineBadge.vue"; import AirlineLogo from "@/Components/FlightsGoneBy/AirlineLogo.vue"; +import LiveryImage from "@/Components/FlightsGoneBy/LiveryImage.vue"; +import ToolTipDivider from "@/Components/FlightsGoneBy/Tooltips/ToolTipDivider.vue"; +import ToolTipRow from "@/Components/FlightsGoneBy/Tooltips/ToolTipRow.vue"; +import ToolTipRows from "@/Components/FlightsGoneBy/Tooltips/ToolTipRows.vue"; +import ToolTipName from "@/Components/FlightsGoneBy/Tooltips/ToolTipName.vue"; +import ToolTipHeader from "@/Components/FlightsGoneBy/Tooltips/ToolTipHeader.vue"; defineProps<{ flight: Flight @@ -17,101 +23,45 @@ defineProps<{
-
+ {{ flight.flight_number || `${flight.departure_airport.display_code}-${flight.arrival_airport.display_code}` }} -
+ -
+ {{ flight.departure_airport.display_code }} {{ flight.arrival_airport.display_code }} -
+ -
+ -
-
- From - {{ flight.departure_airport.municipality }} -
-
- To - {{ flight.arrival_airport.municipality }} -
-
- Airline - {{ flight.airline.name }} -
-
- Aircraft - {{ flight.aircraft?.display_name_short }} -
-
- Date - {{ flight.departure_date_display }} -
-
+ + + + + + + + + + + + + diff --git a/resources/js/Components/FlightsGoneBy/Panels/RoutePanel.vue b/resources/js/Components/FlightsGoneBy/Panels/RoutePanel.vue index 9fe617d..85136e5 100644 --- a/resources/js/Components/FlightsGoneBy/Panels/RoutePanel.vue +++ b/resources/js/Components/FlightsGoneBy/Panels/RoutePanel.vue @@ -13,7 +13,7 @@ defineProps<{