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 */; };
|
||||
LVCC000CCCC000CCCC000001 /* aircraftDB.json in Resources */ = {isa = PBXBuildFile; fileRef = LVCC000CCCC000CCCC000002 /* aircraftDB.json */; };
|
||||
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 */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
@@ -126,6 +127,7 @@
|
||||
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>"; };
|
||||
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 */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
@@ -246,6 +248,7 @@
|
||||
LV7700007777000077770002 /* OpenSkyCredentials.swift */,
|
||||
LVAA000AAAA000AAAA000002 /* AircraftDatabase.swift */,
|
||||
LVDD000DDDD000DDDD000002 /* LocationService.swift */,
|
||||
LVEE000EEEE000EEEE000002 /* FR24Client.swift */,
|
||||
);
|
||||
path = Services;
|
||||
sourceTree = "<group>";
|
||||
@@ -418,6 +421,7 @@
|
||||
LVAA000AAAA000AAAA000001 /* AircraftDatabase.swift in Sources */,
|
||||
LVBB000BBBB000BBBB000001 /* LiveFilterPicker.swift in Sources */,
|
||||
LVDD000DDDD000DDDD000001 /* LocationService.swift in Sources */,
|
||||
LVEE000EEEE000EEEE000001 /* FR24Client.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
|
||||
@@ -6,6 +6,7 @@ struct FlightsApp: App {
|
||||
let loadService: AirlineLoadService
|
||||
let routeExplorer = RouteExplorerClient()
|
||||
let openSky = OpenSkyClient()
|
||||
let fr24 = FR24Client()
|
||||
|
||||
init() {
|
||||
let db = AirportDatabase()
|
||||
@@ -25,7 +26,8 @@ struct FlightsApp: App {
|
||||
database: database,
|
||||
loadService: loadService,
|
||||
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).
|
||||
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 {
|
||||
CLLocationCoordinate2D(latitude: latitude, longitude: longitude)
|
||||
}
|
||||
@@ -69,12 +86,12 @@ struct LiveAircraft: Identifiable, Hashable, Sendable {
|
||||
return .level
|
||||
}
|
||||
|
||||
/// ICAO aircraft type designator (e.g. "B738", "A21N") looked up from
|
||||
/// the bundled aircraft DB. Nil if the airframe isn't in our slimmed
|
||||
/// commercial-class DB — typically true for GA / experimental / cargo
|
||||
/// freight without a public registration.
|
||||
/// ICAO aircraft type designator (e.g. "B738", "A21N"). Prefers the
|
||||
/// FR24-supplied model when present (more accurate, includes
|
||||
/// recent retrofits), else falls back to the bundled DB lookup.
|
||||
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? {
|
||||
@@ -82,10 +99,13 @@ struct LiveAircraft: Identifiable, Hashable, Sendable {
|
||||
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").
|
||||
/// 3-letter ICAO airline prefix. Prefers the FR24-supplied value when
|
||||
/// available — it correctly identifies SWA at "AA0013" style callsigns
|
||||
/// where the prefix derivation would fail. Falls back to extracting
|
||||
/// the leading letters from the callsign (works for OpenSky-style
|
||||
/// "AAL2152").
|
||||
var airlineICAO: String? {
|
||||
if let a = enrichment?.airlineICAO, !a.isEmpty { return a }
|
||||
guard let cs = trimmedCallsign else { return nil }
|
||||
let letters = cs.prefix(while: { $0.isLetter })
|
||||
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,
|
||||
squawk: raw.squawk,
|
||||
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?
|
||||
|
||||
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)
|
||||
/// OpenSky historical flight. departure + arrival from FAA tracking.
|
||||
case fromOpenSky(OpenSkyFlight, ageHours: Int)
|
||||
@@ -98,6 +104,21 @@ struct LiveFlightDetailSheet: View {
|
||||
isLoadingRoute = true
|
||||
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.
|
||||
if let scheduled = await tryScheduledLookup() {
|
||||
resolvedRoute = .scheduled(scheduled)
|
||||
@@ -286,6 +307,10 @@ struct LiveFlightDetailSheet: View {
|
||||
Circle()
|
||||
.fill(FlightTheme.onTime)
|
||||
.frame(width: 6, height: 6)
|
||||
} else if case .fromFR24 = resolved {
|
||||
Circle()
|
||||
.fill(FlightTheme.onTime)
|
||||
.frame(width: 6, height: 6)
|
||||
}
|
||||
}
|
||||
resolvedRouteCard(resolved)
|
||||
@@ -315,6 +340,8 @@ struct LiveFlightDetailSheet: View {
|
||||
|
||||
private func headerLabel(for r: ResolvedRoute) -> String {
|
||||
switch r {
|
||||
case .fromFR24:
|
||||
return aircraft.onGround ? "FLIGHT (ON GROUND)" : "IN FLIGHT"
|
||||
case .scheduled:
|
||||
return aircraft.onGround ? "FLIGHT (ON GROUND)" : "IN FLIGHT"
|
||||
case .fromOpenSky(_, let hoursAgo):
|
||||
@@ -329,6 +356,27 @@ struct LiveFlightDetailSheet: View {
|
||||
@ViewBuilder
|
||||
private func resolvedRouteCard(_ r: ResolvedRoute) -> some View {
|
||||
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):
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
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 {
|
||||
let iata = code.flatMap(icaoToIATA(_:)) ?? code ?? "—"
|
||||
let cityName: String = {
|
||||
|
||||
@@ -4,6 +4,7 @@ import CoreLocation
|
||||
|
||||
struct LiveFlightsView: View {
|
||||
let openSky: OpenSkyClient
|
||||
let fr24: FR24Client
|
||||
let routeExplorer: RouteExplorerClient
|
||||
let database: AirportDatabase
|
||||
|
||||
@@ -622,12 +623,39 @@ struct LiveFlightsView: View {
|
||||
defer { isLoading = false }
|
||||
|
||||
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 {
|
||||
let results = try await openSky.states(
|
||||
latMin: bb.latMin, lonMin: bb.lonMin, latMax: bb.latMax, lonMax: bb.lonMax
|
||||
)
|
||||
// Suppress the implicit crossfade SwiftUI would apply to the
|
||||
// map annotations when the underlying array swaps wholesale.
|
||||
commitResults(results, bb: bb)
|
||||
} 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()
|
||||
tx.disablesAnimations = true
|
||||
withTransaction(tx) {
|
||||
@@ -636,15 +664,6 @@ struct LiveFlightsView: View {
|
||||
lastFetchAt = Date()
|
||||
lastFetchedBoundingBox = bb
|
||||
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) {
|
||||
|
||||
@@ -9,6 +9,7 @@ struct RootView: View {
|
||||
let loadService: AirlineLoadService
|
||||
let routeExplorer: RouteExplorerClient
|
||||
let openSky: OpenSkyClient
|
||||
let fr24: FR24Client
|
||||
|
||||
@State private var selectedTab: Tab = .search
|
||||
|
||||
@@ -29,6 +30,7 @@ struct RootView: View {
|
||||
NavigationStack {
|
||||
LiveFlightsView(
|
||||
openSky: openSky,
|
||||
fr24: fr24,
|
||||
routeExplorer: routeExplorer,
|
||||
database: database
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user