888943deb4
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>
130 lines
3.9 KiB
Swift
130 lines
3.9 KiB
Swift
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 (0–7). 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, 1–7, 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
|
||
}
|
||
}
|