diff --git a/app/Http/Controllers/FlightController.php b/app/Http/Controllers/FlightController.php new file mode 100644 index 0000000..9c972ff --- /dev/null +++ b/app/Http/Controllers/FlightController.php @@ -0,0 +1,31 @@ +query('number', ''))); + + // Extract the airline code prefix — letters at the start e.g. "QF" from "QF1" + preg_match('/^([A-Z]{2,3})/', $number, $matches); + $iataCode = $matches[1] ?? null; + + $airlines = $iataCode + ? Airline::where('IATA_code', $iataCode) + ->get() + ->map(fn ($a) => ['value' => $a->id, 'title' => $a->name]) + : collect(); + + return response()->json([ + 'airline_options' => $airlines, + 'from_options' => [], // populate from a flight schedule API if you have one + 'to_options' => [], + 'aircraft_options' => [], + ]); + } +} diff --git a/resources/js/Components/FlightsGoneBy/FlightMap.vue b/resources/js/Components/FlightsGoneBy/FlightMap.vue index 75222b9..92cbf87 100644 --- a/resources/js/Components/FlightsGoneBy/FlightMap.vue +++ b/resources/js/Components/FlightsGoneBy/FlightMap.vue @@ -14,7 +14,7 @@ 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, Airline, Region, Country} from "@/Types/types"; +import { Flight, Airport } from '@/Types/types' interface RouteFlightBucket { historical: Flight[] @@ -34,9 +34,11 @@ interface AirportFeatureProperties { id: number } -// ── GeoJSON helpers ─────────────────────────────────────────────────────────── +interface RoutesGeoJSON { + historical: GeoJSON.FeatureCollection + future: GeoJSON.FeatureCollection +} -const page = usePage().props function greatCircleGeoJSON(from: LngLat, to: LngLat, steps = 64): LngLat[] { const toRad = (d: number): number => d * Math.PI / 180 @@ -94,6 +96,7 @@ function airportPopupHTML(airport: Airport): string { } function routePopupHTML(historical: Flight[], future: Flight[]): string { + const logoApiUrl = usePage().props.logo_api_url interface DirectionGroup { label: string airlines: string[] @@ -106,9 +109,9 @@ function routePopupHTML(historical: Flight[], future: Flight[]): string { const label = `${f.departure_airport.municipality} to ${f.arrival_airport.municipality}` if (!dirs.has(key)) dirs.set(key, { label, airlines: [] }) const airline = ` - ${f.airline?.IATA_code} - ${f.airline?.name} - ` + ${f.airline?.IATA_code} + ${f.airline?.name} + ` if (airline && !dirs.get(key)!.airlines.includes(airline)) { dirs.get(key)!.airlines.push(airline) } @@ -118,14 +121,11 @@ function routePopupHTML(historical: Flight[], future: Flight[]): string { const renderSection = (title: string, list: Flight[]): string => { if (!list.length) return '' - const rows = groupByDirection(list).map(({ label, airlines }) => { - - return ` + const rows = groupByDirection(list).map(({ label, airlines }) => `
${label}
-
${airlines.join('
') || '—'}
-
` - }).join('') +
${airlines.join('
') || '—'}
+ `).join('') return `
${title}
${rows}
` } @@ -155,10 +155,10 @@ export default defineComponent({ let pulseFrame: number | null = null const PULSE_PHASES = 6 + const TOUCH_RADIUS = 20 const airportById = new Map() const routeFlights = new Map() let selectedAirportId: number | null = null - let suppressRoutePopup = false const isTouchDevice = (): boolean => window.matchMedia('(pointer: coarse)').matches @@ -200,11 +200,6 @@ export default defineComponent({ }) } - interface RoutesGeoJSON { - historical: GeoJSON.FeatureCollection - future: GeoJSON.FeatureCollection - } - // ── GeoJSON builders ────────────────────────────────────────────────── const buildRoutesGeoJSON = (): RoutesGeoJSON => { buildRouteFlights() @@ -349,82 +344,88 @@ export default defineComponent({ offset: 12, }) - for (let i = 0; i < PULSE_PHASES; i++) { - map!.on('mouseenter', `airports-dot-${i}`, (e) => { + const airportLayerIds = Array.from({ length: PULSE_PHASES }, (_, i) => `airports-dot-${i}`) + + // ── Desktop hover events ────────────────────────────────────────── + if (!isTouch) { + for (let i = 0; i < PULSE_PHASES; i++) { + 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)) + } + }) + map!.on('mouseleave', `airports-dot-${i}`, () => { + map!.getCanvas().style.cursor = '' + popup!.remove() + }) + } + + map!.on('mouseenter', 'routes-hit', (e) => { map!.getCanvas().style.cursor = 'pointer' - if (isTouch) return - 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 rf = routeFlights.get(e.features![0].properties.routeKey as string) + if (rf) showPopup(e.lngLat, routePopupHTML(rf.historical, rf.future)) }) - map!.on('mouseleave', `airports-dot-${i}`, () => { + map!.on('mouseleave', 'routes-hit', () => { map!.getCanvas().style.cursor = '' - if (isTouch) return popup!.remove() }) - map!.on('click', `airports-dot-${i}`, (e) => { - suppressRoutePopup = true + 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() - - const id = Number(e.features![0].properties.id) - selectedAirportId = selectedAirportId === id ? null : id - applyFilter() - - setTimeout(() => { suppressRoutePopup = false }, 0) }) } - map!.on('mouseenter', 'routes-hit', (e) => { - if (isTouch) return - 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', () => { - if (isTouch) return - map!.getCanvas().style.cursor = '' - popup!.remove() - }) - - map!.on('click', 'routes-hit', (e) => { - if (!isTouch) return - if (suppressRoutePopup) return - const rf = routeFlights.get(e.features![0].properties.routeKey as string) - if (rf) showPopup(e.lngLat, routePopupHTML(rf.historical, rf.future)) - }) - - map!.on('mouseenter', 'routes-future-hit', (e) => { - if (isTouch) return - 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', () => { - if (isTouch) return - map!.getCanvas().style.cursor = '' - popup!.remove() - }) - map!.on('click', 'routes-future-hit', (e) => { - if (!isTouch) return - if (suppressRoutePopup) return - const rf = routeFlights.get(e.features![0].properties.routeKey as string) - if (rf) showPopup(e.lngLat, routePopupHTML(rf.historical, rf.future)) - }) - + // ── Unified click handler — airports take priority over routes ──── map!.on('click', (e) => { - const features = map!.queryRenderedFeatures(e.point, { - layers: Array.from({ length: PULSE_PHASES }, (_, i) => `airports-dot-${i}`), - }) - if (!features.length) { - selectedAirportId = null + // Query airports with a larger bounding box on touch for easier tapping + 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 }) + + if (airportFeatures.length) { + const id = Number(airportFeatures[0].properties.id) + selectedAirportId = selectedAirportId === id ? null : id applyFilter() - popup!.remove() + + if (isTouch) { + const airport = airportById.get(id) + const geom = airportFeatures[0].geometry + if (airport && geom.type === 'Point') { + showPopup(geom.coordinates as LngLat, airportPopupHTML(airport)) + } + } + return } + + // Then check routes + const routeFeatures = map!.queryRenderedFeatures(e.point, { + layers: ['routes-hit', 'routes-future-hit'], + }) + + if (routeFeatures.length) { + const rf = routeFlights.get(routeFeatures[0].properties.routeKey as string) + if (rf) showPopup(e.lngLat, routePopupHTML(rf.historical, rf.future)) + return + } + + // Empty map click — deselect everything + selectedAirportId = null + applyFilter() + popup!.remove() }) + // ── Pulse animation ─────────────────────────────────────────────── const period = 2200 const animate = (): void => { const now = Date.now() @@ -545,7 +546,7 @@ export default defineComponent({ } .empty-state .mdi { font-size: 48px; } -.empty-state p { font-size: 13px; letter-spacing: 0.1em; text-transform: uppercase; margin: 0; } +.empty-state p { font-size: 13px; letter-spacing: 0.1em; text-transform: uppercase; margin: 0; } diff --git a/routes/web.php b/routes/web.php index 92ed468..0da4694 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,11 +1,15 @@ group( return Inertia::render('Fr24Import'); })->name('import.fr24'); + Route::get('/add-flight', function () { + return Inertia::render('AddFlight', [ + 'seat_types' => SeatType::all()->map(fn ($s) => ['value' => $s->id, 'title' => $s->name]), + 'flight_reasons' => FlightReason::all()->map(fn ($f) => ['value' => $f->id, 'title' => $f->name]), + 'flight_classes' => FlightClass::all()->map(fn ($f) => ['value' => $f->id, 'title' => $f->name]), + ]); + }); + Route::get('/reconcile', function () { $flight = new FlightImportController()->reconcile(request()); @@ -46,7 +58,7 @@ Route::domain(config('app.domain'))->group( ]); })->name('reconcile'); - + Route::get('/flights/lookup', [FlightController::class, 'lookup'])->name('flights.lookup'); Route::post('/flights/import', [FlightImportController::class, 'store'])->name('flights.import.store'); Route::get('/profile', [ProfileController::class, 'edit'])->name('profile.edit');