Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 55 additions & 1 deletion app/Http/Controllers/AircraftController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<int, array{lat: float, long: float, info: string}>, 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) {
Expand Down
58 changes: 58 additions & 0 deletions app/Support/MapBounds.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
<?php

namespace App\Support;

/**
* Computes a Leaflet map view (center + zoom) that frames a set of geographic
* points. The larswiegers/laravel-maps Leaflet component has no client-side
* `fitToBounds`, so we derive a sensible center and zoom on the server and feed
* them to the component's `centerPoint` / `zoomLevel` props.
*
* Points are `['lat' => 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<int, array{lat: float, long: float}> $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];
}
}
19 changes: 19 additions & 0 deletions resources/views/fleet/_map_popup.blade.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
{{-- Fleet map marker popup: one airport, the aircraft parked there. --}}
<div class="fleet-map-popup">
<div class="fw-bold font-monospace">{{ $airport->icao_code }}</div>
@if($airport->name)
<div class="text-muted small mb-2">{{ $airport->name }}</div>
@endif
<div class="text-muted small mb-1">{{ $aircraft->count() }} {{ \Illuminate\Support\Str::plural('aircraft', $aircraft->count()) }}</div>
<ul class="list-unstyled mb-0 small">
@foreach($aircraft as $ac)
<li class="d-flex align-items-center gap-1 mb-1">
<a href="{{ route('viewaircraft', $ac) }}" class="fw-bold text-decoration-none font-monospace">{{ $ac->registration }}</a>
<span class="text-muted">{{ $ac->full_type }}</span>
@if($ac->status === \App\Models\Aircraft::STATUS_INACTIVE)
<span class="badge bg-secondary ms-auto">Inactive</span>
@endif
</li>
@endforeach
</ul>
</div>
42 changes: 42 additions & 0 deletions resources/views/fleet/index.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,36 @@ class="btn btn-outline-secondary px-3 py-2 d-inline-flex align-items-center gap-
</div>
</div>

<!-- Fleet Locations Map (collapsed by default to save vertical space) -->
<div class="card border-0 shadow-sm mb-4 overflow-hidden">
<div class="card-header bg-white py-3 fw-bold border-bottom d-flex align-items-center justify-content-between"
role="button" data-bs-toggle="collapse" data-bs-target="#fleetMapCollapse"
aria-expanded="false" aria-controls="fleetMapCollapse" style="cursor: pointer;">
<span><i class="bi bi-geo-alt-fill text-danger me-2"></i> Fleet Locations</span>
<i class="bi bi-chevron-down fleet-map-chevron text-muted"></i>
</div>
<div class="collapse" id="fleetMapCollapse">
@if(count($mapMarkers) === 0)
<div class="card-body text-center text-muted py-5">
<i class="bi bi-map fs-1 d-block mb-2 opacity-50"></i>
No aircraft with a known location yet.
</div>
@else
<x-maps-leaflet id="fleetMap"
style="height: 380px; width: 100%;"
:markers="$mapMarkers"
:centerPoint="$mapCenter"
:zoomLevel="$mapZoom"></x-maps-leaflet>
@if($aircraftWithoutLocation > 0)
<div class="card-footer bg-light border-top text-muted small py-2">
<i class="bi bi-info-circle me-1"></i>
{{ $aircraftWithoutLocation }} {{ \Illuminate\Support\Str::plural('aircraft', $aircraftWithoutLocation) }} {{ $aircraftWithoutLocation === 1 ? 'has' : 'have' }} no known location and {{ $aircraftWithoutLocation === 1 ? 'is' : 'are' }} not shown.
</div>
@endif
@endif
</div>
</div>

@if($errors->any())
<div class="alert alert-danger alert-dismissible fade show shadow-sm mb-4" role="alert">
<h4 class="alert-heading fs-6 fw-bold"><i class="bi bi-exclamation-triangle-fill me-2"></i> Error during request</h4>
Expand Down Expand Up @@ -257,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); }
</style>

<script>
Expand All @@ -266,6 +298,16 @@ class="btn btn-outline-secondary px-3 py-2 d-inline-flex align-items-center gap-
var tooltipList = tooltipTriggerList.map(function (tooltipTriggerEl) {
return new bootstrap.Tooltip(tooltipTriggerEl)
})

// The fleet map initialises inside a collapsed (hidden) container, so
// Leaflet lays it out at zero size. Nudge it to recalculate on expand -
// Leaflet auto-fixes its size on a window resize event.
var fleetMapCollapse = document.getElementById('fleetMapCollapse');
if (fleetMapCollapse) {
fleetMapCollapse.addEventListener('shown.bs.collapse', function () {
window.dispatchEvent(new Event('resize'));
});
}
});
</script>
@endsection
67 changes: 67 additions & 0 deletions tests/Feature/FleetMapTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
<?php

namespace Tests\Feature;

use App\Models\Aircraft;
use App\Models\Airline;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\Concerns\SeedsDomain;
use Tests\TestCase;

/**
* The Fleet Overview page plots the whole active-airline fleet on a map, one
* marker per airport (aircraft grouped), excluding retired airframes.
*/
class FleetMapTest extends TestCase
{
use RefreshDatabase, SeedsDomain;

protected function setUp(): void
{
parent::setUp();
$this->seedReferenceData();
}

public function test_fleet_map_groups_aircraft_by_airport_and_excludes_retired(): void
{
$airline = Airline::factory()->create();

// Two airframes share Frankfurt, one sits at Heathrow, one retired at JFK.
$eddfOne = Aircraft::factory()->at('EDDF')->create(['used_by' => $airline->id]);
$eddfTwo = Aircraft::factory()->at('EDDF')->create(['used_by' => $airline->id]);
$egll = Aircraft::factory()->at('EGLL')->create(['used_by' => $airline->id]);
$retired = Aircraft::factory()->retired()->at('KJFK')->create(['used_by' => $airline->id]);

$pilot = $this->memberOf($airline, 'Pilot');

$response = $this->actingAs($pilot)
->withSession(['activeairline' => $airline])
->get(route('fleetmanager'));

$response->assertOk();
$response->assertSee('fleetMap');

// One marker per airport: Frankfurt + Heathrow = 2 (not 3 aircraft).
$this->assertSame(2, substr_count($response->getContent(), 'L.marker(['));

// Both Frankfurt airframes are listed in a popup; the retired one is gone.
$response->assertSee($eddfOne->registration);
$response->assertSee($eddfTwo->registration);
$response->assertSee($egll->registration);
$response->assertDontSee($retired->registration);
}

public function test_fleet_map_shows_empty_state_without_a_leaflet_map(): void
{
$airline = Airline::factory()->create();
$pilot = $this->memberOf($airline, 'Pilot');

$response = $this->actingAs($pilot)
->withSession(['activeairline' => $airline])
->get(route('fleetmanager'));

$response->assertOk();
$response->assertSee('No aircraft with a known location yet.');
$this->assertStringNotContainsString('L.marker([', $response->getContent());
}
}
59 changes: 59 additions & 0 deletions tests/Unit/MapBoundsTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
<?php

namespace Tests\Unit;

use App\Support\MapBounds;
use PHPUnit\Framework\TestCase;

/**
* MapBounds derives a Leaflet view (center + zoom) framing a set of points,
* standing in for the Leaflet component's missing client-side fitToBounds.
*/
class MapBoundsTest extends TestCase
{
public function test_no_points_returns_the_whole_world(): void
{
$view = MapBounds::fit([]);

$this->assertSame(['lat' => 20.0, 'long' => 0.0], $view['center']);
$this->assertSame(2, $view['zoom']);
}

public function test_single_point_centers_on_it_and_zooms_in(): void
{
$view = MapBounds::fit([['lat' => 50.0333, 'long' => 8.5706]]);

$this->assertSame(['lat' => 50.0333, 'long' => 8.5706], $view['center']);
$this->assertSame(5, $view['zoom']);
}

public function test_multiple_points_center_on_the_bounding_box_midpoint(): void
{
$view = MapBounds::fit([
['lat' => 40.0, 'long' => -10.0],
['lat' => 50.0, 'long' => 10.0],
]);

$this->assertEqualsWithDelta(45.0, $view['center']['lat'], 0.0001);
$this->assertEqualsWithDelta(0.0, $view['center']['long'], 0.0001);
}

public function test_zoom_is_clamped_to_the_supported_range(): void
{
// A globe-spanning spread stays zoomed out (>= 2).
$wide = MapBounds::fit([
['lat' => -80.0, 'long' => -170.0],
['lat' => 80.0, 'long' => 170.0],
]);
$this->assertGreaterThanOrEqual(2, $wide['zoom']);
$this->assertLessThanOrEqual(10, $wide['zoom']);

// Two airports a few degrees apart never exceed the max zoom.
$tight = MapBounds::fit([
['lat' => 50.0333, 'long' => 8.5706],
['lat' => 51.4700, 'long' => -0.4543],
]);
$this->assertLessThanOrEqual(10, $tight['zoom']);
$this->assertGreaterThanOrEqual(2, $tight['zoom']);
}
}
Loading