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:
Trey T
2026-05-27 07:52:56 -05:00
parent 68c60ec087
commit 92bc6ed52e
8 changed files with 352 additions and 23 deletions
+4
View File
@@ -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;
}; };
+3 -1
View File
@@ -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
) )
} }
} }
+28 -8
View File
@@ -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 }
+201
View File
@@ -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
/// ~330 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
}
}
+2 -1
View File
@@ -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
) )
} }
} }
+81 -1
View File
@@ -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 = {
+31 -12
View File
@@ -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,31 +623,49 @@ 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.
var tx = Transaction()
tx.disablesAnimations = true
withTransaction(tx) {
aircraft = results
}
lastFetchAt = Date()
lastFetchedBoundingBox = bb
error = nil
} catch { } catch {
self.error = (error as? OpenSkyClient.ClientError)?.errorDescription self.error = (error as? OpenSkyClient.ClientError)?.errorDescription
?? error.localizedDescription ?? error.localizedDescription
if case OpenSkyClient.ClientError.throttled = error { 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) 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) {
aircraft = results
}
lastFetchAt = Date()
lastFetchedBoundingBox = bb
error = nil
}
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) {
let lat = r.center.latitude let lat = r.center.latitude
let lon = r.center.longitude let lon = r.center.longitude
+2
View File
@@ -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
) )