Live feed: FR24 primary, OpenSky fallback
OpenSky's free anonymous tier has sparse ground coverage — at DAL it
returned a single airborne aircraft when there were 3+ SWA jets
visibly parked on the apron. FR24's feed.js aggregates ASDE-X, MLAT,
and multiple community ADS-B feeds and reliably surfaces ground
aircraft at major airports. We now query FR24 first and fall back to
OpenSky only when FR24 errors.
FR24's payload also carries departure + arrival IATA + flight number
+ aircraft type + tail number inline, so we shortcut the
route-explorer schedule lookup in the detail sheet: a new
`LiveAircraft.Enrichment` struct holds those fields, and the
ResolvedRoute cascade gains a `.fromFR24` first-tier case that uses
them directly. The `typeCode` and `airlineICAO` computed properties
prefer enrichment values over the AircraftDatabase / callsign-prefix
heuristics — this also fixes the case where FR24 callsigns use the
IATA carrier ("AA0013") which our 3-letter-prefix derivation would
have rejected.
OpenSky still owns trail polylines and recent-flights history; only
the live position fetch swapped sources.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -59,6 +59,7 @@
|
|||||||
LVBB000BBBB000BBBB000001 /* LiveFilterPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = LVBB000BBBB000BBBB000002 /* LiveFilterPicker.swift */; };
|
LVBB000BBBB000BBBB000001 /* LiveFilterPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = LVBB000BBBB000BBBB000002 /* LiveFilterPicker.swift */; };
|
||||||
LVCC000CCCC000CCCC000001 /* aircraftDB.json in Resources */ = {isa = PBXBuildFile; fileRef = LVCC000CCCC000CCCC000002 /* aircraftDB.json */; };
|
LVCC000CCCC000CCCC000001 /* aircraftDB.json in Resources */ = {isa = PBXBuildFile; fileRef = LVCC000CCCC000CCCC000002 /* aircraftDB.json */; };
|
||||||
LVDD000DDDD000DDDD000001 /* LocationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = LVDD000DDDD000DDDD000002 /* LocationService.swift */; };
|
LVDD000DDDD000DDDD000001 /* LocationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = LVDD000DDDD000DDDD000002 /* LocationService.swift */; };
|
||||||
|
LVEE000EEEE000EEEE000001 /* FR24Client.swift in Sources */ = {isa = PBXBuildFile; fileRef = LVEE000EEEE000EEEE000002 /* FR24Client.swift */; };
|
||||||
/* End PBXBuildFile section */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
/* Begin PBXContainerItemProxy section */
|
/* Begin PBXContainerItemProxy section */
|
||||||
@@ -126,6 +127,7 @@
|
|||||||
LVBB000BBBB000BBBB000002 /* LiveFilterPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveFilterPicker.swift; sourceTree = "<group>"; };
|
LVBB000BBBB000BBBB000002 /* LiveFilterPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveFilterPicker.swift; sourceTree = "<group>"; };
|
||||||
LVCC000CCCC000CCCC000002 /* aircraftDB.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = aircraftDB.json; sourceTree = "<group>"; };
|
LVCC000CCCC000CCCC000002 /* aircraftDB.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = aircraftDB.json; sourceTree = "<group>"; };
|
||||||
LVDD000DDDD000DDDD000002 /* LocationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationService.swift; sourceTree = "<group>"; };
|
LVDD000DDDD000DDDD000002 /* LocationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationService.swift; sourceTree = "<group>"; };
|
||||||
|
LVEE000EEEE000EEEE000002 /* FR24Client.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FR24Client.swift; sourceTree = "<group>"; };
|
||||||
/* End PBXFileReference section */
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
/* Begin PBXFrameworksBuildPhase section */
|
/* Begin PBXFrameworksBuildPhase section */
|
||||||
@@ -246,6 +248,7 @@
|
|||||||
LV7700007777000077770002 /* OpenSkyCredentials.swift */,
|
LV7700007777000077770002 /* OpenSkyCredentials.swift */,
|
||||||
LVAA000AAAA000AAAA000002 /* AircraftDatabase.swift */,
|
LVAA000AAAA000AAAA000002 /* AircraftDatabase.swift */,
|
||||||
LVDD000DDDD000DDDD000002 /* LocationService.swift */,
|
LVDD000DDDD000DDDD000002 /* LocationService.swift */,
|
||||||
|
LVEE000EEEE000EEEE000002 /* FR24Client.swift */,
|
||||||
);
|
);
|
||||||
path = Services;
|
path = Services;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -418,6 +421,7 @@
|
|||||||
LVAA000AAAA000AAAA000001 /* AircraftDatabase.swift in Sources */,
|
LVAA000AAAA000AAAA000001 /* AircraftDatabase.swift in Sources */,
|
||||||
LVBB000BBBB000BBBB000001 /* LiveFilterPicker.swift in Sources */,
|
LVBB000BBBB000BBBB000001 /* LiveFilterPicker.swift in Sources */,
|
||||||
LVDD000DDDD000DDDD000001 /* LocationService.swift in Sources */,
|
LVDD000DDDD000DDDD000001 /* LocationService.swift in Sources */,
|
||||||
|
LVEE000EEEE000EEEE000001 /* FR24Client.swift in Sources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ struct FlightsApp: App {
|
|||||||
let loadService: AirlineLoadService
|
let loadService: AirlineLoadService
|
||||||
let routeExplorer = RouteExplorerClient()
|
let routeExplorer = RouteExplorerClient()
|
||||||
let openSky = OpenSkyClient()
|
let openSky = OpenSkyClient()
|
||||||
|
let fr24 = FR24Client()
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
let db = AirportDatabase()
|
let db = AirportDatabase()
|
||||||
@@ -25,7 +26,8 @@ struct FlightsApp: App {
|
|||||||
database: database,
|
database: database,
|
||||||
loadService: loadService,
|
loadService: loadService,
|
||||||
routeExplorer: routeExplorer,
|
routeExplorer: routeExplorer,
|
||||||
openSky: openSky
|
openSky: openSky,
|
||||||
|
fr24: fr24
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,6 +43,23 @@ struct LiveAircraft: Identifiable, Hashable, Sendable {
|
|||||||
/// When the position was last updated (server-side).
|
/// When the position was last updated (server-side).
|
||||||
let lastContact: Date
|
let lastContact: Date
|
||||||
|
|
||||||
|
/// Extra fields the FR24 feed provides inline (departure/arrival IATA,
|
||||||
|
/// flight number, aircraft model, tail number, airline ICAO). Always
|
||||||
|
/// nil for aircraft sourced from OpenSky.
|
||||||
|
let enrichment: Enrichment?
|
||||||
|
|
||||||
|
/// FR24-only inline data. None of these are guaranteed even when the
|
||||||
|
/// outer envelope is FR24-sourced — gate aircraft often have no
|
||||||
|
/// flight number, GA aircraft no airline, etc.
|
||||||
|
struct Enrichment: Hashable, Sendable {
|
||||||
|
let modelType: String? // ICAO type designator, e.g. "B738"
|
||||||
|
let registration: String? // Tail number, e.g. "N971NN"
|
||||||
|
let flightIATA: String? // "AA2152"
|
||||||
|
let departureIATA: String? // "DFW"
|
||||||
|
let arrivalIATA: String? // "MSP"
|
||||||
|
let airlineICAO: String? // "AAL"
|
||||||
|
}
|
||||||
|
|
||||||
var coordinate: CLLocationCoordinate2D {
|
var coordinate: CLLocationCoordinate2D {
|
||||||
CLLocationCoordinate2D(latitude: latitude, longitude: longitude)
|
CLLocationCoordinate2D(latitude: latitude, longitude: longitude)
|
||||||
}
|
}
|
||||||
@@ -69,12 +86,12 @@ struct LiveAircraft: Identifiable, Hashable, Sendable {
|
|||||||
return .level
|
return .level
|
||||||
}
|
}
|
||||||
|
|
||||||
/// ICAO aircraft type designator (e.g. "B738", "A21N") looked up from
|
/// ICAO aircraft type designator (e.g. "B738", "A21N"). Prefers the
|
||||||
/// the bundled aircraft DB. Nil if the airframe isn't in our slimmed
|
/// FR24-supplied model when present (more accurate, includes
|
||||||
/// commercial-class DB — typically true for GA / experimental / cargo
|
/// recent retrofits), else falls back to the bundled DB lookup.
|
||||||
/// freight without a public registration.
|
|
||||||
var typeCode: String? {
|
var typeCode: String? {
|
||||||
AircraftDatabase.shared.typeCode(forICAO24: icao24)
|
if let m = enrichment?.modelType, !m.isEmpty { return m }
|
||||||
|
return AircraftDatabase.shared.typeCode(forICAO24: icao24)
|
||||||
}
|
}
|
||||||
|
|
||||||
var trimmedCallsign: String? {
|
var trimmedCallsign: String? {
|
||||||
@@ -82,10 +99,13 @@ struct LiveAircraft: Identifiable, Hashable, Sendable {
|
|||||||
return s.isEmpty ? nil : s
|
return s.isEmpty ? nil : s
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 3-letter ICAO airline prefix from the callsign (e.g. "DAL" from "DAL1234").
|
/// 3-letter ICAO airline prefix. Prefers the FR24-supplied value when
|
||||||
/// Returns nil when the callsign doesn't follow the standard pattern (e.g.
|
/// available — it correctly identifies SWA at "AA0013" style callsigns
|
||||||
/// GA tail numbers like "N12345").
|
/// where the prefix derivation would fail. Falls back to extracting
|
||||||
|
/// the leading letters from the callsign (works for OpenSky-style
|
||||||
|
/// "AAL2152").
|
||||||
var airlineICAO: String? {
|
var airlineICAO: String? {
|
||||||
|
if let a = enrichment?.airlineICAO, !a.isEmpty { return a }
|
||||||
guard let cs = trimmedCallsign else { return nil }
|
guard let cs = trimmedCallsign else { return nil }
|
||||||
let letters = cs.prefix(while: { $0.isLetter })
|
let letters = cs.prefix(while: { $0.isLetter })
|
||||||
guard letters.count == 3 else { return nil }
|
guard letters.count == 3 else { return nil }
|
||||||
|
|||||||
@@ -0,0 +1,201 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Live aircraft feed sourced from flightradar24.com's public
|
||||||
|
/// `/zones/fcgi/feed.js` endpoint. We use it as the primary live data
|
||||||
|
/// source because, unlike OpenSky's anonymous tier, FR24 aggregates
|
||||||
|
/// ASDE-X / MLAT / multiple ADS-B receivers and has solid ground
|
||||||
|
/// coverage at major airports — i.e. the parked SWA jet at DAL that
|
||||||
|
/// OpenSky just doesn't return.
|
||||||
|
///
|
||||||
|
/// Feed format (positional array per aircraft):
|
||||||
|
/// [0] icao24 hex (uppercase)
|
||||||
|
/// [1] latitude
|
||||||
|
/// [2] longitude
|
||||||
|
/// [3] heading (deg true)
|
||||||
|
/// [4] altitude (feet, baro)
|
||||||
|
/// [5] ground speed (knots)
|
||||||
|
/// [6] squawk
|
||||||
|
/// [7] radar source id (e.g. "T-KDFW42") — informational
|
||||||
|
/// [8] ICAO aircraft type designator ("B738")
|
||||||
|
/// [9] registration / tail number
|
||||||
|
/// [10] unix timestamp (seconds)
|
||||||
|
/// [11] departure airport IATA
|
||||||
|
/// [12] arrival airport IATA
|
||||||
|
/// [13] flight number with IATA carrier ("AA2152")
|
||||||
|
/// [14] on_ground (0/1)
|
||||||
|
/// [15] vertical rate (ft/min)
|
||||||
|
/// [16] callsign with ICAO carrier ("AAL2152")
|
||||||
|
/// [17] is_glider/special
|
||||||
|
/// [18] airline ICAO ("AAL")
|
||||||
|
///
|
||||||
|
/// The endpoint requires browser-shaped request headers (User-Agent +
|
||||||
|
/// Referer pointing at flightradar24.com). Plain curl is rejected.
|
||||||
|
actor FR24Client {
|
||||||
|
enum ClientError: LocalizedError {
|
||||||
|
case http(Int)
|
||||||
|
case decode(String)
|
||||||
|
case network(Error)
|
||||||
|
|
||||||
|
var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .http(let c): return "FR24 returned HTTP \(c)."
|
||||||
|
case .decode(let s): return "Couldn't read FR24 response: \(s)."
|
||||||
|
case .network(let e): return e.localizedDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private let session: URLSession
|
||||||
|
|
||||||
|
init(session: URLSession = .shared) {
|
||||||
|
self.session = session
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetch every aircraft currently inside the bbox. The map view passes
|
||||||
|
/// the visible region's corners — typical bbox at city zoom returns
|
||||||
|
/// ~3–30 entries; continental zoom can return several hundred.
|
||||||
|
func states(latMin: Double, lonMin: Double, latMax: Double, lonMax: Double) async throws -> [LiveAircraft] {
|
||||||
|
// FR24 expects bounds in the order: latNorth, latSouth, lonWest, lonEast.
|
||||||
|
let bounds = String(format: "%.4f,%.4f,%.4f,%.4f", latMax, latMin, lonMin, lonMax)
|
||||||
|
|
||||||
|
var comps = URLComponents(string: "https://data-cloud.flightradar24.com/zones/fcgi/feed.js")!
|
||||||
|
comps.queryItems = [
|
||||||
|
URLQueryItem(name: "bounds", value: bounds),
|
||||||
|
URLQueryItem(name: "faa", value: "1"),
|
||||||
|
URLQueryItem(name: "satellite", value: "1"),
|
||||||
|
URLQueryItem(name: "mlat", value: "1"),
|
||||||
|
URLQueryItem(name: "flarm", value: "1"),
|
||||||
|
URLQueryItem(name: "adsb", value: "1"),
|
||||||
|
URLQueryItem(name: "gnd", value: "1"),
|
||||||
|
URLQueryItem(name: "air", value: "1"),
|
||||||
|
URLQueryItem(name: "vehicles", value: "1"),
|
||||||
|
URLQueryItem(name: "estimated", value: "1"),
|
||||||
|
URLQueryItem(name: "maxage", value: "14400"),
|
||||||
|
URLQueryItem(name: "gliders", value: "1"),
|
||||||
|
URLQueryItem(name: "stats", value: "1"),
|
||||||
|
]
|
||||||
|
|
||||||
|
var req = URLRequest(url: comps.url!)
|
||||||
|
req.timeoutInterval = 12
|
||||||
|
req.setValue(
|
||||||
|
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148",
|
||||||
|
forHTTPHeaderField: "User-Agent"
|
||||||
|
)
|
||||||
|
req.setValue("https://www.flightradar24.com/", forHTTPHeaderField: "Referer")
|
||||||
|
req.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||||
|
|
||||||
|
let data: Data
|
||||||
|
let response: URLResponse
|
||||||
|
do {
|
||||||
|
(data, response) = try await session.data(for: req)
|
||||||
|
} catch {
|
||||||
|
throw ClientError.network(error)
|
||||||
|
}
|
||||||
|
guard let http = response as? HTTPURLResponse else {
|
||||||
|
throw ClientError.network(URLError(.badServerResponse))
|
||||||
|
}
|
||||||
|
guard (200..<300).contains(http.statusCode) else {
|
||||||
|
throw ClientError.http(http.statusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
return try parse(data: data)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func parse(data: Data) throws -> [LiveAircraft] {
|
||||||
|
let root: Any
|
||||||
|
do {
|
||||||
|
root = try JSONSerialization.jsonObject(with: data, options: [])
|
||||||
|
} catch {
|
||||||
|
throw ClientError.decode("not json")
|
||||||
|
}
|
||||||
|
guard let dict = root as? [String: Any] else {
|
||||||
|
throw ClientError.decode("root not object")
|
||||||
|
}
|
||||||
|
|
||||||
|
var out: [LiveAircraft] = []
|
||||||
|
out.reserveCapacity(dict.count)
|
||||||
|
for (_, value) in dict {
|
||||||
|
guard let arr = value as? [Any] else { continue }
|
||||||
|
if arr.count < 18 { continue }
|
||||||
|
if let ac = Self.aircraft(from: arr) {
|
||||||
|
out.append(ac)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert one positional entry into a LiveAircraft, returning nil
|
||||||
|
/// when required fields are missing (no position, no icao24).
|
||||||
|
private static func aircraft(from a: [Any]) -> LiveAircraft? {
|
||||||
|
guard let icaoRaw = a[0] as? String, !icaoRaw.isEmpty,
|
||||||
|
let lat = doubleVal(a[1]),
|
||||||
|
let lon = doubleVal(a[2]) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
let heading = doubleVal(a[3])
|
||||||
|
let altFeet = intVal(a[4]) ?? 0
|
||||||
|
let speedKnots = doubleVal(a[5])
|
||||||
|
let squawkRaw = a[6] as? String
|
||||||
|
let modelType = nonEmpty(a[8] as? String)
|
||||||
|
let registration = nonEmpty(a[9] as? String)
|
||||||
|
let timestamp = intVal(a[10]) ?? Int(Date().timeIntervalSince1970)
|
||||||
|
let depIATA = nonEmpty(a[11] as? String)
|
||||||
|
let arrIATA = nonEmpty(a[12] as? String)
|
||||||
|
let flightIATA = nonEmpty(a[13] as? String)
|
||||||
|
let onGround = (intVal(a[14]) ?? 0) != 0
|
||||||
|
let vertRateFpm = doubleVal(a[15]) ?? 0
|
||||||
|
let callsign = nonEmpty(a[16] as? String)
|
||||||
|
let airlineICAO = nonEmpty(a.count > 18 ? a[18] as? String : nil)
|
||||||
|
|
||||||
|
// Unit conversions — LiveAircraft stores baroAlt in meters,
|
||||||
|
// velocity in m/s, vertical rate in m/s (matching OpenSky).
|
||||||
|
let baroAltMeters: Double? = altFeet > 0 ? Double(altFeet) * 0.3048 : nil
|
||||||
|
let velocityMps: Double? = speedKnots.map { $0 * 0.514444 }
|
||||||
|
let vertRateMps: Double? = vertRateFpm != 0 ? vertRateFpm * 0.00508 : nil
|
||||||
|
|
||||||
|
return LiveAircraft(
|
||||||
|
icao24: icaoRaw.lowercased(),
|
||||||
|
callsign: callsign,
|
||||||
|
originCountry: "",
|
||||||
|
latitude: lat,
|
||||||
|
longitude: lon,
|
||||||
|
baroAltitude: baroAltMeters,
|
||||||
|
geoAltitude: nil,
|
||||||
|
velocity: velocityMps,
|
||||||
|
trueTrack: heading,
|
||||||
|
verticalRate: vertRateMps,
|
||||||
|
onGround: onGround,
|
||||||
|
squawk: nonEmpty(squawkRaw),
|
||||||
|
category: nil,
|
||||||
|
lastContact: Date(timeIntervalSince1970: TimeInterval(timestamp)),
|
||||||
|
enrichment: LiveAircraft.Enrichment(
|
||||||
|
modelType: modelType,
|
||||||
|
registration: registration,
|
||||||
|
flightIATA: flightIATA,
|
||||||
|
departureIATA: depIATA,
|
||||||
|
arrivalIATA: arrIATA,
|
||||||
|
airlineICAO: airlineICAO
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func doubleVal(_ x: Any) -> Double? {
|
||||||
|
if let d = x as? Double { return d }
|
||||||
|
if let i = x as? Int { return Double(i) }
|
||||||
|
if let s = x as? String { return Double(s) }
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func intVal(_ x: Any) -> Int? {
|
||||||
|
if let i = x as? Int { return i }
|
||||||
|
if let d = x as? Double { return Int(d) }
|
||||||
|
if let s = x as? String { return Int(s) }
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func nonEmpty(_ s: String?) -> String? {
|
||||||
|
guard let s, !s.isEmpty else { return nil }
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -236,7 +236,8 @@ private extension LiveAircraft {
|
|||||||
onGround: raw.onGround,
|
onGround: raw.onGround,
|
||||||
squawk: raw.squawk,
|
squawk: raw.squawk,
|
||||||
category: raw.category,
|
category: raw.category,
|
||||||
lastContact: Date(timeIntervalSince1970: TimeInterval(raw.lastContact))
|
lastContact: Date(timeIntervalSince1970: TimeInterval(raw.lastContact)),
|
||||||
|
enrichment: nil
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,13 @@ struct LiveFlightDetailSheet: View {
|
|||||||
@State private var resolvedRoute: ResolvedRoute?
|
@State private var resolvedRoute: ResolvedRoute?
|
||||||
|
|
||||||
enum ResolvedRoute {
|
enum ResolvedRoute {
|
||||||
/// Real schedule match from route-explorer. Best fidelity.
|
/// Inline data straight off the FR24 feed (departure + arrival
|
||||||
|
/// IATA, plus the flight number). This is the fast path — no
|
||||||
|
/// extra network call required because FR24 already gave us
|
||||||
|
/// these in the feed.js response.
|
||||||
|
case fromFR24(departureIATA: String?, arrivalIATA: String?, flightIATA: String?)
|
||||||
|
/// Real schedule match from route-explorer. Best fidelity for
|
||||||
|
/// flights that aren't FR24-sourced.
|
||||||
case scheduled(RouteFlight)
|
case scheduled(RouteFlight)
|
||||||
/// OpenSky historical flight. departure + arrival from FAA tracking.
|
/// OpenSky historical flight. departure + arrival from FAA tracking.
|
||||||
case fromOpenSky(OpenSkyFlight, ageHours: Int)
|
case fromOpenSky(OpenSkyFlight, ageHours: Int)
|
||||||
@@ -98,6 +104,21 @@ struct LiveFlightDetailSheet: View {
|
|||||||
isLoadingRoute = true
|
isLoadingRoute = true
|
||||||
defer { isLoadingRoute = false }
|
defer { isLoadingRoute = false }
|
||||||
|
|
||||||
|
// 0) Fast path: FR24's feed gave us departure + arrival inline.
|
||||||
|
// This is the common case now that FR24 is the primary feed.
|
||||||
|
// Skips the route-explorer network call entirely.
|
||||||
|
if let e = aircraft.enrichment,
|
||||||
|
e.departureIATA != nil || e.arrivalIATA != nil {
|
||||||
|
resolvedRoute = .fromFR24(
|
||||||
|
departureIATA: e.departureIATA,
|
||||||
|
arrivalIATA: e.arrivalIATA,
|
||||||
|
flightIATA: e.flightIATA
|
||||||
|
)
|
||||||
|
// Fire OpenSky history in the background for "recent flights".
|
||||||
|
recentFlights = await openSky.recentFlights(icao24: aircraft.icao24)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// 1) Try the scheduled lookup if we have a parseable airline + flight.
|
// 1) Try the scheduled lookup if we have a parseable airline + flight.
|
||||||
if let scheduled = await tryScheduledLookup() {
|
if let scheduled = await tryScheduledLookup() {
|
||||||
resolvedRoute = .scheduled(scheduled)
|
resolvedRoute = .scheduled(scheduled)
|
||||||
@@ -286,6 +307,10 @@ struct LiveFlightDetailSheet: View {
|
|||||||
Circle()
|
Circle()
|
||||||
.fill(FlightTheme.onTime)
|
.fill(FlightTheme.onTime)
|
||||||
.frame(width: 6, height: 6)
|
.frame(width: 6, height: 6)
|
||||||
|
} else if case .fromFR24 = resolved {
|
||||||
|
Circle()
|
||||||
|
.fill(FlightTheme.onTime)
|
||||||
|
.frame(width: 6, height: 6)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
resolvedRouteCard(resolved)
|
resolvedRouteCard(resolved)
|
||||||
@@ -315,6 +340,8 @@ struct LiveFlightDetailSheet: View {
|
|||||||
|
|
||||||
private func headerLabel(for r: ResolvedRoute) -> String {
|
private func headerLabel(for r: ResolvedRoute) -> String {
|
||||||
switch r {
|
switch r {
|
||||||
|
case .fromFR24:
|
||||||
|
return aircraft.onGround ? "FLIGHT (ON GROUND)" : "IN FLIGHT"
|
||||||
case .scheduled:
|
case .scheduled:
|
||||||
return aircraft.onGround ? "FLIGHT (ON GROUND)" : "IN FLIGHT"
|
return aircraft.onGround ? "FLIGHT (ON GROUND)" : "IN FLIGHT"
|
||||||
case .fromOpenSky(_, let hoursAgo):
|
case .fromOpenSky(_, let hoursAgo):
|
||||||
@@ -329,6 +356,27 @@ struct LiveFlightDetailSheet: View {
|
|||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private func resolvedRouteCard(_ r: ResolvedRoute) -> some View {
|
private func resolvedRouteCard(_ r: ResolvedRoute) -> some View {
|
||||||
switch r {
|
switch r {
|
||||||
|
case .fromFR24(let dep, let arr, _):
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
HStack(spacing: 16) {
|
||||||
|
fr24Endpoint(
|
||||||
|
iata: dep,
|
||||||
|
label: aircraft.onGround ? "From" : "Departed",
|
||||||
|
emptyText: "Unknown"
|
||||||
|
)
|
||||||
|
Image(systemName: "airplane")
|
||||||
|
.font(.title3)
|
||||||
|
.foregroundStyle(FlightTheme.accent)
|
||||||
|
.rotationEffect(.degrees(-45))
|
||||||
|
fr24Endpoint(
|
||||||
|
iata: arr,
|
||||||
|
label: aircraft.onGround ? "To" : "Heading to",
|
||||||
|
emptyText: "Unknown"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.flightCard()
|
||||||
|
|
||||||
case .scheduled(let f):
|
case .scheduled(let f):
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
HStack(spacing: 16) {
|
HStack(spacing: 16) {
|
||||||
@@ -414,6 +462,38 @@ struct LiveFlightDetailSheet: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Endpoint cell for FR24-sourced routes. Different from
|
||||||
|
/// `routeEndpoint` because FR24 gives us IATA codes directly (no
|
||||||
|
/// ICAO-to-IATA conversion) and no scheduled time.
|
||||||
|
private func fr24Endpoint(iata: String?, label: String, emptyText: String) -> some View {
|
||||||
|
let code = iata ?? ""
|
||||||
|
let cityName: String = {
|
||||||
|
guard let iata, let m = database.airport(byIATA: iata) else { return "" }
|
||||||
|
return m.name
|
||||||
|
}()
|
||||||
|
return VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(label)
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(FlightTheme.textTertiary)
|
||||||
|
.tracking(0.5)
|
||||||
|
Text(code.isEmpty ? "—" : code)
|
||||||
|
.font(FlightTheme.airportCode(28))
|
||||||
|
.foregroundStyle(code.isEmpty ? FlightTheme.textTertiary : FlightTheme.textPrimary)
|
||||||
|
if !cityName.isEmpty {
|
||||||
|
Text(cityName)
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(FlightTheme.textSecondary)
|
||||||
|
.lineLimit(1)
|
||||||
|
} else if code.isEmpty {
|
||||||
|
Text(emptyText)
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(FlightTheme.textSecondary)
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
}
|
||||||
|
|
||||||
private func routeEndpoint(code: String?, label: String, time: Date) -> some View {
|
private func routeEndpoint(code: String?, label: String, time: Date) -> some View {
|
||||||
let iata = code.flatMap(icaoToIATA(_:)) ?? code ?? "—"
|
let iata = code.flatMap(icaoToIATA(_:)) ?? code ?? "—"
|
||||||
let cityName: String = {
|
let cityName: String = {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import CoreLocation
|
|||||||
|
|
||||||
struct LiveFlightsView: View {
|
struct LiveFlightsView: View {
|
||||||
let openSky: OpenSkyClient
|
let openSky: OpenSkyClient
|
||||||
|
let fr24: FR24Client
|
||||||
let routeExplorer: RouteExplorerClient
|
let routeExplorer: RouteExplorerClient
|
||||||
let database: AirportDatabase
|
let database: AirportDatabase
|
||||||
|
|
||||||
@@ -622,12 +623,39 @@ struct LiveFlightsView: View {
|
|||||||
defer { isLoading = false }
|
defer { isLoading = false }
|
||||||
|
|
||||||
let bb = boundingBox(of: r)
|
let bb = boundingBox(of: r)
|
||||||
|
|
||||||
|
// Primary: FR24. Their feed includes ASDE-X + MLAT and reliably
|
||||||
|
// returns ground aircraft at major airports — OpenSky's free tier
|
||||||
|
// does not, which was the root cause of "no SWA jets at DAL".
|
||||||
|
// We fall through to OpenSky only when FR24 hard-errors (rare).
|
||||||
|
if let results = try? await fr24.states(
|
||||||
|
latMin: bb.latMin, lonMin: bb.lonMin, latMax: bb.latMax, lonMax: bb.lonMax
|
||||||
|
) {
|
||||||
|
commitResults(results, bb: bb)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: OpenSky. Same shape, missing the inline route data
|
||||||
|
// FR24 carries (departure/arrival/flight#), so the detail sheet
|
||||||
|
// route-resolver picks up the slack.
|
||||||
do {
|
do {
|
||||||
let results = try await openSky.states(
|
let results = try await openSky.states(
|
||||||
latMin: bb.latMin, lonMin: bb.lonMin, latMax: bb.latMax, lonMax: bb.lonMax
|
latMin: bb.latMin, lonMin: bb.lonMin, latMax: bb.latMax, lonMax: bb.lonMax
|
||||||
)
|
)
|
||||||
// Suppress the implicit crossfade SwiftUI would apply to the
|
commitResults(results, bb: bb)
|
||||||
// map annotations when the underlying array swaps wholesale.
|
} catch {
|
||||||
|
self.error = (error as? OpenSkyClient.ClientError)?.errorDescription
|
||||||
|
?? error.localizedDescription
|
||||||
|
if case OpenSkyClient.ClientError.throttled = error {
|
||||||
|
try? await Task.sleep(nanoseconds: 60 * 1_000_000_000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Commit a fresh aircraft list to state. Suppresses SwiftUI's
|
||||||
|
/// implicit crossfade when annotations swap so the map doesn't
|
||||||
|
/// flicker every 15 seconds.
|
||||||
|
private func commitResults(_ results: [LiveAircraft], bb: (latMin: Double, lonMin: Double, latMax: Double, lonMax: Double)) {
|
||||||
var tx = Transaction()
|
var tx = Transaction()
|
||||||
tx.disablesAnimations = true
|
tx.disablesAnimations = true
|
||||||
withTransaction(tx) {
|
withTransaction(tx) {
|
||||||
@@ -636,15 +664,6 @@ struct LiveFlightsView: View {
|
|||||||
lastFetchAt = Date()
|
lastFetchAt = Date()
|
||||||
lastFetchedBoundingBox = bb
|
lastFetchedBoundingBox = bb
|
||||||
error = nil
|
error = nil
|
||||||
} catch {
|
|
||||||
self.error = (error as? OpenSkyClient.ClientError)?.errorDescription
|
|
||||||
?? error.localizedDescription
|
|
||||||
if case OpenSkyClient.ClientError.throttled = error {
|
|
||||||
// On 429, slow the loop down to once per minute for the
|
|
||||||
// next sleep cycle.
|
|
||||||
try? await Task.sleep(nanoseconds: 60 * 1_000_000_000)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func boundingBox(of r: MKCoordinateRegion) -> (latMin: Double, lonMin: Double, latMax: Double, lonMax: Double) {
|
private func boundingBox(of r: MKCoordinateRegion) -> (latMin: Double, lonMin: Double, latMax: Double, lonMax: Double) {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ struct RootView: View {
|
|||||||
let loadService: AirlineLoadService
|
let loadService: AirlineLoadService
|
||||||
let routeExplorer: RouteExplorerClient
|
let routeExplorer: RouteExplorerClient
|
||||||
let openSky: OpenSkyClient
|
let openSky: OpenSkyClient
|
||||||
|
let fr24: FR24Client
|
||||||
|
|
||||||
@State private var selectedTab: Tab = .search
|
@State private var selectedTab: Tab = .search
|
||||||
|
|
||||||
@@ -29,6 +30,7 @@ struct RootView: View {
|
|||||||
NavigationStack {
|
NavigationStack {
|
||||||
LiveFlightsView(
|
LiveFlightsView(
|
||||||
openSky: openSky,
|
openSky: openSky,
|
||||||
|
fr24: fr24,
|
||||||
routeExplorer: routeExplorer,
|
routeExplorer: routeExplorer,
|
||||||
database: database
|
database: database
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user