Overview
Air Tracker displays detailed flight information in two responsive formats:
Desktop Panel
Fixed side panel with scrollable content
Mobile Bottom Sheet
Draggable sheet with responsive grid layouts
Desktop Panel Component
Component Architecture
The FlightDetailPanelComponent displays flight details in a fixed right-side panel:
@Component({
selector: 'app-flight-panel',
imports: [
AirlineDisplayComponent,
MatButtonModule,
MatIconModule,
CommonModule,
MatTooltipModule,
MatExpansionModule,
MatGridListModule,
UnitPipe,
FlightStatusLedComponent,
LogoComponent
],
templateUrl: './flight-detail-panel.component.html',
styleUrl: './flight-detail-panel.component.scss',
})
export class FlightDetailPanelComponent {
flightId = input.required<string>();
photo = input<AircraftPhoto | null>();
closed = output<void>();
readonly store = inject(FlightsStoreService);
// Computed signal to find the flight by ID
readonly flight = computed<Flight | null>(() =>
this.store.flights().find(f => f.icao24 === this.flightId()) ?? null
);
close() {
this.closed.emit();
}
}
The component uses a computed signal to reactively find the flight data whenever the flights array or flightId changes.
Panel Sections
The desktop panel is organized into distinct information sections:
Mobile Bottom Sheet Component
Component Structure
The FlightDetailBottomSheetComponent provides a mobile-optimized view:
@Component({
selector: 'app-flight-detail-bottom-sheet',
imports: [
CommonModule,
MatIcon,
FlightStatusLedComponent,
MatButtonModule,
MatIconModule,
MatGridList,
MatGridTile,
AirlineDisplayComponent,
UnitPipe
],
templateUrl: './flight-detail-bottom-sheet.component.html',
styleUrl: './flight-detail-bottom-sheet.component.scss',
})
export class FlightDetailBottomSheetComponent {
private readonly ref = inject(MatBottomSheetRef<FlightDetailBottomSheetComponent>);
protected readonly store = inject(FlightsStoreService);
protected readonly viewport = inject(ViewportService);
protected readonly data = inject(MAT_BOTTOM_SHEET_DATA) as FlightDetailBottomSheetData;
readonly flight = computed<Flight | null>(() =>
this.store.flights().find(f => f.icao24 === this.data.flightId) ?? null
);
// Responsive grid configurations
readonly opertorInfoGridRowHeight = computed(() => {
if (this.viewport.isTablet()) return '5:1';
if (this.viewport.isMobile()) return '3:1';
return '5:1';
});
readonly aircraftInfoGridRowHeight = computed(() => {
if (this.viewport.isTablet()) return '5:3';
if (this.viewport.isMobile()) return '10:11';
return '5:1';
});
readonly flightInfoGridRowHeight = computed(() => {
if (this.viewport.isTablet()) return '6:4';
if (this.viewport.isMobile()) return '11:12';
return '5:1';
});
close(): void {
this.ref.dismiss('user' satisfies SheetCloseReason);
}
}
The bottom sheet uses responsive computed signals to adjust grid layouts based on device type (mobile, tablet, desktop).
Aircraft Photos
Both views display aircraft photos fetched from the backend:
// From FlightsApiService
getPhotosByIcao24(icao24: string): Observable<AircraftPhotoDto> {
return this.http.get<AircraftPhotoDto>(
`${this.baseUrl}/aircraft-photos/${icao24}`
);
}
Photo Model
export interface AircraftPhoto {
largeUrl: string; // Full-size image URL
photographer: string; // Photo credit
link: string; // External source link
attribution: string; // Alt text / description
}
Photos use loading="lazy" to improve initial page load performance and only load when scrolled into view.
The detail views show comprehensive flight data organized into categories:
Airline Logo
Visual airline identification
Operator Name
Full airline/operator name
Call Sign
Radio callsign identifier
<mat-grid-list cols="2" rowHeight="3:1" gutterSize="3px">
<mat-grid-tile colspan="1" rowspan="2">
<app-airline-display
[operatorIcao]="flight.operatorIcao"
[operator]="flight.operator">
</app-airline-display>
</mat-grid-tile>
<mat-grid-tile colspan="1">
<div class="grid-item">
<span class="label">Operator</span>
<span class="value">{{ flight.operator}}</span>
</div>
</mat-grid-tile>
<mat-grid-tile colspan="1">
<div class="grid-item">
<span class="label">Call sign</span>
<span class="value">{{ flight.callsign || 'N/A'}}</span>
</div>
</mat-grid-tile>
</mat-grid-list>
2. Aircraft Details
<mat-grid-list cols="6" rowHeight="9:11" gutterSize="3px">
<mat-grid-tile colspan="1" rowspan="3">
<div class="grid-icon">
<mat-icon class="aircraft-icon">airplanemode_active</mat-icon>
</div>
</mat-grid-tile>
<mat-grid-tile colspan="5">
<div class="grid-item">
<span class="label">
Aircraft type @if(flight.typecode) {
({{ flight.typecode }})
}
</span>
<span class="value" [matTooltip]="flight.model">
{{ flight.model || 'N/A'}}
</span>
</div>
</mat-grid-tile>
<mat-grid-tile colspan="2">
<div class="grid-item">
<span class="label">Registration</span>
<span class="value" [matTooltip]="flight.registration">
{{ flight.registration || 'N/A' }}
</span>
</div>
</mat-grid-tile>
<mat-grid-tile colspan="3">
<div class="grid-item">
<span class="label">Category</span>
<span class="value" [matTooltip]="flight.category">
{{ flight.category || 'N/A' }}
</span>
</div>
</mat-grid-tile>
<mat-grid-tile colspan="2">
<div class="grid-item">
<span class="label">Owner</span>
<span class="value" [matTooltip]="flight.owner">
{{ flight.owner || 'N/A'}}
</span>
</div>
</mat-grid-tile>
<mat-grid-tile colspan="3">
<div class="grid-item">
<span class="label">Country of Reg</span>
<span class="value" [matTooltip]="flight.originCountry">
{{ flight.originCountry || 'N/A' }}
</span>
</div>
</mat-grid-tile>
</mat-grid-list>
Displays:
- Aircraft type and model
- Registration number
- Aircraft category
- Owner information
- Country of registration
- ICAO type code
3. Position & Navigation
<mat-grid-list cols="6" rowHeight="9:12" gutterSize="3px">
<mat-grid-tile colspan="1" rowspan="3">
<div class="grid-icon">
<mat-icon class="flight-icon">explore</mat-icon>
</div>
</mat-grid-tile>
<mat-grid-tile colspan="5">
<div class="grid-item">
<span class="label">Position (Lat / Lon)</span>
<span class="value">
{{ flight.latitude}}° / {{ flight.longitude }}°
</span>
</div>
</mat-grid-tile>
<mat-grid-tile colspan="2">
<div class="grid-item">
<span class="label">Altitude Geo.</span>
<span class="value">{{ flight.altitudeGeo | unit:'m' }}</span>
</div>
</mat-grid-tile>
<mat-grid-tile colspan="3">
<div class="grid-item">
<span class="label">Altitude Bar.</span>
<span class="value">{{ flight.altitudeBaro | unit:'m' }}</span>
</div>
</mat-grid-tile>
<mat-grid-tile colspan="5">
<div class="grid-item">
<span class="label">Course</span>
<span class="value">{{ flight.heading | unit:'°'}}</span>
</div>
</mat-grid-tile>
</mat-grid-list>
Displays:
- GPS coordinates (latitude/longitude)
- Geometric altitude
- Barometric altitude
- Heading/course
The unit pipe automatically formats values with appropriate units (meters, degrees, etc.) and handles null values gracefully.
<mat-grid-list class="performance-grid" cols="6" rowHeight="9:11" gutterSize="3px">
<mat-grid-tile colspan="1" rowspan="3">
<div class="grid-icon">
<mat-icon class="performance-icon">square_foot</mat-icon>
</div>
</mat-grid-tile>
<mat-grid-tile colspan="5">
<div class="grid-item">
<span class="label">Speed</span>
<span class="value">{{ flight.velocity | unit:'m/s'}}</span>
</div>
</mat-grid-tile>
<mat-grid-tile colspan="5">
<div class="grid-item">
<span class="label">Vertical rate</span>
<div class="vertical-rate-container">
<span class="value">{{ flight.verticalRate | unit:'m/s'}}</span>
@if(flight.verticalRate){
<mat-icon class="vertical-rate-icon"
[ngClass]="{
'climbing': flight.verticalRate > 0,
'descending': flight.verticalRate < 0
}">
{{ flight.verticalRate > 0 ? 'arrow_drop_up' : 'arrow_drop_down' }}
</mat-icon>
}
</div>
</div>
</mat-grid-tile>
<mat-grid-tile colspan="2">
<div class="grid-item">
<span class="label">Squawk</span>
<span class="value">{{ flight.squawk ?? 'N/A'}}</span>
</div>
</mat-grid-tile>
<mat-grid-tile colspan="3">
<div class="grid-item">
<span class="label">Spi</span>
<span class="value">{{ flight.spi || 'N/A' }}</span>
</div>
</mat-grid-tile>
</mat-grid-list>
Displays:
- Ground speed
- Vertical rate with visual indicator (↑ climbing / ↓ descending)
- Squawk code (transponder)
- SPI (Special Position Identification)
Selection & Deselection
Selection Flow
// In MapMarkerService - marker click handler
marker.on('click', () => {
const currentSelected = this.store.selectedFlightId();
if (currentSelected === flight.icao24) {
this.store.clearSelection(); // Toggle off if already selected
} else {
this.store.setSelectedFlightId(flight.icao24); // Select new flight
}
});
Select Flight
Deselect Flight
- User clicks aircraft marker on map
- Store updates
selectedFlightId signal
- Map updates marker icon to highlighted state
- Detail panel/sheet opens with flight data
- Aircraft photo is fetched asynchronously
- User clicks selected marker again OR
- User clicks close button in panel/sheet
- Store sets
selectedFlightId to null
- Map resets marker icon to normal state
- Detail panel/sheet closes
Store Methods
// From FlightsStoreService
setSelectedFlightId(id: string | null): void {
this._selectedFlightId.set(id);
}
clearSelection(): void {
this._selectedFlightId.set(null);
}
Responsive Behavior
Desktop (>= 1024px)
- Fixed side panel on the right
- Full information with all sections expanded
- Large aircraft photo at top of panel
- Scrollable content within fixed panel
Tablet (768px - 1023px)
- Bottom sheet slides up from bottom
- Adjusted grid ratios for better fit
- Medium-sized photo
- Draggable sheet to expand/collapse
Mobile (< 768px)
- Compact bottom sheet optimized for small screens
- Tighter grid layouts (
10:11, 11:12 ratios)
- Essential information prioritized
- Swipe to dismiss gesture
The ViewportService provides reactive signals for device type detection, enabling automatic layout adjustments.
Usage Example
import { Component, inject } from '@angular/core';
import { FlightsStoreService } from './services/flights-store.service';
import { FlightDetailPanelComponent } from './flight-detail-panel/flight-detail-panel.component';
import { MatBottomSheet } from '@angular/material/bottom-sheet';
import { FlightDetailBottomSheetComponent } from './flight-detail-bottom-sheet/flight-detail-bottom-sheet.component';
import { ViewportService } from './services/viewport.service';
@Component({
selector: 'app-flights-shell',
template: `
<div class="flights-container">
<app-flights-map [flights]="store.filteredFlights()"></app-flights-map>
@if (store.selectedFlightId() && viewport.isDesktop()) {
<app-flight-panel
[flightId]="store.selectedFlightId()!"
[photo]="currentPhoto"
(closed)="store.clearSelection()">
</app-flight-panel>
}
</div>
`
})
export class FlightsShellComponent {
store = inject(FlightsStoreService);
viewport = inject(ViewportService);
bottomSheet = inject(MatBottomSheet);
// Open bottom sheet on mobile
openBottomSheet(flightId: string) {
this.bottomSheet.open(FlightDetailBottomSheetComponent, {
data: { flightId }
});
}
}