Files
Flights/Flights/Models/LiveAircraft.swift
T
Trey T 888943deb4 Add Live Flights tab: real-time aircraft map with filters + tap detail
New top-level TabView (RootView) splits the app into:
  Tab 1 (Search): existing RoutePlannerView home
  Tab 2 (Live):   live flight tracker

Live tab features:
- MapKit map showing every aircraft in the visible viewport, rotated
  to true heading. Color-coded by vertical state: climbing/level/
  descending/on-ground.
- Auto-refresh every 15s + on map pan/zoom (debounced); manual
  refresh button. Rate-limit aware (60s backoff on HTTP 429).
- Tap any aircraft → modal sheet with live state grid (altitude,
  speed, heading, vertical rate, squawk, last-contact), current
  route (lazily fetched per-aircraft from OpenSky's /flights/
  aircraft endpoint, mapped from ICAO to IATA airport codes), and
  recent flight history (up to 8 prior legs).
- Filters: airline (multi-select from currently visible callsigns,
  with counts), aircraft type (ADS-B emitter category), airborne-
  only toggle. All filters render as horizontal chips and clear
  with a single tap.
- Search bar: callsign/flight number — submitting centers the map
  on the match and opens its detail sheet.

Data source: OpenSky Network REST API. Free, anonymous (~100 req/
day cap), JSON. Same ADS-B data FR24 starts with — without satellite
ADS-B coverage but more than enough for the in-flight tracker use
case. Reviewed FR24's APK and confirmed they migrated their live
feed to gRPC+protobuf with anti-bot device-id headers; OpenSky's
plain JSON is the right tradeoff for our build.

Implementation:
- LiveAircraft model: decodes OpenSky's mixed-type position arrays
  into a typed struct; computed properties for ft/knots/heading and
  airline ICAO extracted from callsign.
- OpenSkyClient: actor with /states/all + /flights/aircraft. Bbox
  query, throttle-aware errors.
- AircraftRegistry: ~80 ICAO → (IATA, name) entries for the major
  carriers; everything else falls through to the raw ICAO code.
- LiveFlightsView: the main map + filter UI.
- LiveFlightDetailSheet: tap modal with live state + route history.
- RootView: TabView wrapping RoutePlannerView (Search) and the new
  LiveFlightsView (Live).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 06:08:58 -05:00

130 lines
3.9 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import Foundation
import CoreLocation
/// One aircraft's live state vector, normalized from OpenSky's `/states/all`
/// positional array format into a typed struct.
struct LiveAircraft: Identifiable, Hashable, Sendable {
var id: String { icao24 }
/// 24-bit ICAO transponder address as hex (lowercased).
let icao24: String
/// ADS-B broadcast callsign, e.g. `DAL1234` (ICAO airline code + flight number).
/// Often padded with trailing whitespace `trimmedCallsign` strips that.
let callsign: String?
/// ICAO-registered country of the operator.
let originCountry: String
let latitude: Double
let longitude: Double
/// Barometric altitude in meters. Falls back to geometric altitude in
/// `altitudeFeet`.
let baroAltitude: Double?
let geoAltitude: Double?
/// Velocity in m/s.
let velocity: Double?
/// True track in degrees from North (0..360).
let trueTrack: Double?
/// Vertical rate in m/s; positive = climbing.
let verticalRate: Double?
let onGround: Bool
let squawk: String?
/// Aircraft category from ADS-B emitter category (07). Decodes per
/// `aircraftCategoryName`.
let category: Int?
/// When the position was last updated (server-side).
let lastContact: Date
var coordinate: CLLocationCoordinate2D {
CLLocationCoordinate2D(latitude: latitude, longitude: longitude)
}
var altitudeFeet: Int? {
guard let alt = baroAltitude ?? geoAltitude else { return nil }
return Int(alt * 3.28084)
}
var velocityKnots: Int? {
guard let v = velocity else { return nil }
return Int(v * 1.94384)
}
var heading: Int? {
guard let t = trueTrack else { return nil }
return Int(t.truncatingRemainder(dividingBy: 360))
}
var verticalState: VerticalState {
guard let vr = verticalRate else { return .level }
if vr > 1.5 { return .climbing }
if vr < -1.5 { return .descending }
return .level
}
var trimmedCallsign: String? {
let s = (callsign ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
return s.isEmpty ? nil : s
}
/// 3-letter ICAO airline prefix from the callsign (e.g. "DAL" from "DAL1234").
/// Returns nil when the callsign doesn't follow the standard pattern (e.g.
/// GA tail numbers like "N12345").
var airlineICAO: String? {
guard let cs = trimmedCallsign else { return nil }
let letters = cs.prefix(while: { $0.isLetter })
guard letters.count == 3 else { return nil }
return String(letters)
}
/// Numeric flight number portion (everything after the airline prefix).
var flightNumber: String? {
guard let cs = trimmedCallsign else { return nil }
let s = String(cs.drop(while: { $0.isLetter }))
return s.isEmpty ? nil : s
}
}
enum VerticalState {
case climbing, descending, level
}
/// ADS-B emitter category, 17, per RTCA DO-260.
func aircraftCategoryName(_ code: Int?) -> String? {
switch code {
case 1: return "Light"
case 2: return "Small"
case 3: return "Large"
case 4: return "High vortex large"
case 5: return "Heavy"
case 6: return "High performance"
case 7: return "Rotorcraft"
default: return nil
}
}
/// Historical OpenSky flight record used to surface "where did this aircraft
/// take off from / where's it going" in the detail sheet.
struct OpenSkyFlight: Decodable, Sendable, Hashable {
let icao24: String
let firstSeen: Int
let lastSeen: Int
let estDepartureAirport: String?
let estArrivalAirport: String?
let callsign: String?
var departureDate: Date { Date(timeIntervalSince1970: TimeInterval(firstSeen)) }
var arrivalDate: Date { Date(timeIntervalSince1970: TimeInterval(lastSeen)) }
var trimmedCallsign: String? {
let s = (callsign ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
return s.isEmpty ? nil : s
}
}