From c26b82799a35a20ee32dd8f92e5d1b9cb06ff29b Mon Sep 17 00:00:00 2001 From: Marc Sauer Date: Fri, 17 Jul 2026 14:27:10 +0200 Subject: [PATCH 1/2] First implementation of fleet map --- app/Http/Controllers/AircraftController.php | 56 ++++++++++++++++- app/Support/MapBounds.php | 58 ++++++++++++++++++ resources/views/fleet/_map_popup.blade.php | 19 ++++++ resources/views/fleet/index.blade.php | 25 ++++++++ tests/Feature/FleetMapTest.php | 67 +++++++++++++++++++++ tests/Unit/MapBoundsTest.php | 59 ++++++++++++++++++ 6 files changed, 283 insertions(+), 1 deletion(-) create mode 100644 app/Support/MapBounds.php create mode 100644 resources/views/fleet/_map_popup.blade.php create mode 100644 tests/Feature/FleetMapTest.php create mode 100644 tests/Unit/MapBoundsTest.php diff --git a/app/Http/Controllers/AircraftController.php b/app/Http/Controllers/AircraftController.php index cf2993d..29172f5 100644 --- a/app/Http/Controllers/AircraftController.php +++ b/app/Http/Controllers/AircraftController.php @@ -3,8 +3,10 @@ namespace App\Http\Controllers; use App\Models\Aircraft; +use App\Models\Airline; use App\Models\Airport; use App\Support\ActivityLevel; +use App\Support\MapBounds; use Illuminate\Http\Request; use Illuminate\Support\Facades\Session; @@ -97,7 +99,59 @@ public function index(Request $request) { ->limit($limit) ->get(); - return view('fleet.index', ['fleet' => $fleet, 'maxPages' => $maxPages, 'currentPage' => $page, 'showRetired' => $showRetired]); + // Fleet-location map data: the full active-airline fleet (retired aircraft + // excluded), independent of the table's search/pagination, grouped by airport. + [$mapMarkers, $mapView, $aircraftWithoutLocation] = $this->fleetMapData($currentActiveAirline); + + return view('fleet.index', [ + 'fleet' => $fleet, + 'maxPages' => $maxPages, + 'currentPage' => $page, + 'showRetired' => $showRetired, + 'mapMarkers' => $mapMarkers, + 'mapCenter' => $mapView['center'], + 'mapZoom' => $mapView['zoom'], + 'aircraftWithoutLocation' => $aircraftWithoutLocation, + ]); + } + + /** + * Build the Fleet Location map payload for an airline: one marker per airport + * (aircraft grouped together, popup listing them), the framing center/zoom, and + * a count of aircraft whose current location could not be resolved. + * + * @return array{0: array, 1: array{center: array{lat: float, long: float}, zoom: int}, 2: int} + */ + private function fleetMapData(Airline $airline): array + { + $aircraft = Aircraft::with('location') + ->where('used_by', $airline->id) + ->notRetired() + ->get(); + + $markers = []; + $points = []; + + // Skip (but count) aircraft with an unresolved/invalid current_loc, then + // group the rest by airport so each airport yields a single marker. + $located = $aircraft->filter(fn ($a) => $a->location !== null); + $withoutLocation = $aircraft->count() - $located->count(); + $groups = $located->groupBy(fn ($a) => $a->location->icao_code); + + foreach ($groups as $parked) { + $airport = $parked->first()->location; + $points[] = ['lat' => (float) $airport->latitude_deg, 'long' => (float) $airport->longitude_deg]; + $markers[] = [ + 'lat' => (float) $airport->latitude_deg, + 'long' => (float) $airport->longitude_deg, + 'info' => view('fleet._map_popup', [ + 'airport' => $airport, + 'aircraft' => $parked, + ])->render(), + ]; + } + + return [$markers, MapBounds::fit($points), $withoutLocation]; } public function create(Request $request) { diff --git a/app/Support/MapBounds.php b/app/Support/MapBounds.php new file mode 100644 index 0000000..bfca247 --- /dev/null +++ b/app/Support/MapBounds.php @@ -0,0 +1,58 @@ + float, 'long' => float]` (the component's marker keys). + */ +class MapBounds +{ + /** Clamp range for the derived zoom level (Leaflet 0 = whole world). */ + private const MIN_ZOOM = 2; + private const MAX_ZOOM = 10; + + /** + * @param array $points + * @return array{center: array{lat: float, long: float}, zoom: int} + */ + public static function fit(array $points): array + { + // No points: show the whole world. + if (empty($points)) { + return ['center' => ['lat' => 20.0, 'long' => 0.0], 'zoom' => self::MIN_ZOOM]; + } + + $lats = array_column($points, 'lat'); + $lngs = array_column($points, 'long'); + + // Single point (or several stacked on one spot): center on it, zoom in. + if (count($points) === 1) { + return [ + 'center' => ['lat' => (float) $lats[0], 'long' => (float) $lngs[0]], + 'zoom' => 5, + ]; + } + + $minLat = min($lats); + $maxLat = max($lats); + $minLng = min($lngs); + $maxLng = max($lngs); + + $center = [ + 'lat' => ($minLat + $maxLat) / 2, + 'long' => ($minLng + $maxLng) / 2, + ]; + + // Zoom from the larger of the two spans so both fit; -1 for padding. + $span = max($maxLat - $minLat, $maxLng - $minLng, 0.0001); + $zoom = (int) floor(log(360 / $span, 2)) - 1; + $zoom = max(self::MIN_ZOOM, min(self::MAX_ZOOM, $zoom)); + + return ['center' => $center, 'zoom' => $zoom]; + } +} diff --git a/resources/views/fleet/_map_popup.blade.php b/resources/views/fleet/_map_popup.blade.php new file mode 100644 index 0000000..b2450ce --- /dev/null +++ b/resources/views/fleet/_map_popup.blade.php @@ -0,0 +1,19 @@ +{{-- Fleet map marker popup: one airport, the aircraft parked there. --}} +
+
{{ $airport->icao_code }}
+ @if($airport->name) +
{{ $airport->name }}
+ @endif +
{{ $aircraft->count() }} {{ \Illuminate\Support\Str::plural('aircraft', $aircraft->count()) }}
+
    + @foreach($aircraft as $ac) +
  • + {{ $ac->registration }} + {{ $ac->full_type }} + @if($ac->status === \App\Models\Aircraft::STATUS_INACTIVE) + Inactive + @endif +
  • + @endforeach +
+
diff --git a/resources/views/fleet/index.blade.php b/resources/views/fleet/index.blade.php index 5b07911..ff55239 100644 --- a/resources/views/fleet/index.blade.php +++ b/resources/views/fleet/index.blade.php @@ -58,6 +58,31 @@ class="btn btn-outline-secondary px-3 py-2 d-inline-flex align-items-center gap- + +
+
+ Fleet Locations +
+ @if(count($mapMarkers) === 0) +
+ + No aircraft with a known location yet. +
+ @else + + @if($aircraftWithoutLocation > 0) + + @endif + @endif +
+ @if($errors->any()) - +
-
- Fleet Locations + - @if(count($mapMarkers) === 0) -
- - No aircraft with a known location yet. -
- @else - - @if($aircraftWithoutLocation > 0) - @if($errors->any()) @@ -282,6 +287,8 @@ class="btn btn-outline-secondary px-3 py-2 d-inline-flex align-items-center gap- .shadow-xs { box-shadow: 0 1px 2px rgba(0,0,0,0.05); } .max-w-md { max-width: 450px; } .mx-auto { margin-left: auto; margin-right: auto; } + .fleet-map-chevron { transition: transform 0.2s ease; } + [data-bs-target="#fleetMapCollapse"][aria-expanded="true"] .fleet-map-chevron { transform: rotate(180deg); } @endsection \ No newline at end of file