From de7a70b198e4c7dda3e1f01912c6d7426ffbb189 Mon Sep 17 00:00:00 2001 From: Trey T Date: Wed, 27 May 2026 07:27:26 -0500 Subject: [PATCH] Live tab: hardening pass for smooth, snappy feel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Targeted every hitch in the live-tracker flow: 1) Async DB loading. AircraftDatabase (1.5MB JSON) and AircraftRegistry (200KB JSON) were parsing synchronously on the main thread the first time anything touched them — typically when the user first opened the Live tab. Both now bootstrap with a safe fallback in init() and parse the full JSON on a background Task at app launch (FlightsApp.init calls preload() on both). Reads are NSLock-guarded with lock.withLock {} (async-safe). 2) Crossfade suppression on refresh. The 15s auto-refresh swaps the `aircraft` array wholesale, which made SwiftUI try to crossfade every annotation. Wrapped the assignment in a Transaction with disablesAnimations = true so the swap is instant. 3) Cached filtered aircraft. `filteredAircraft` was a computed property running through every aircraft on every body re-render (e.g. while a sheet was animating in). Moved to @State, recomputed via .onChange handlers on each dependency. 4) Lighter pin view. AircraftPin no longer carries the full LiveAircraft struct or contains a conditional ZStack — just the minimal {tint, rotation, isSelected} props, conforms to Equatable so SwiftUI can skip diffing identical pins, and uses .animation(nil) on the tint to prevent color crossfades during refresh. 5) Coarse relative-time bucketing. The footer's "updated 5s ago" text was ticking every second, which dirtied the footer subtree on every body pass. Now snaps to {just now, <30s ago, <1m ago, Nm ago} — no second-by-second ticks. Net effect: tab opening is instant (DBs are warm), refreshes don't flicker, filter sheet animation is smooth, map panning isn't fighting view-tree rebuilds. Co-Authored-By: Claude Opus 4.7 (1M context) --- Flights/FlightsApp.swift | 6 ++ Flights/Services/AircraftDatabase.swift | 43 ++++++-- Flights/Services/AircraftRegistry.swift | 74 +++++++++----- Flights/Views/LiveFlightsView.swift | 125 +++++++++++++++--------- 4 files changed, 169 insertions(+), 79 deletions(-) diff --git a/Flights/FlightsApp.swift b/Flights/FlightsApp.swift index e053948..dcc848c 100644 --- a/Flights/FlightsApp.swift +++ b/Flights/FlightsApp.swift @@ -11,6 +11,12 @@ struct FlightsApp: App { let db = AirportDatabase() self.database = db self.loadService = AirlineLoadService(airportDatabase: db) + + // Pre-load the bundled airline + aircraft databases on a background + // thread. Both are large enough (200KB and 1.5MB) to noticeably + // jank the UI if we wait until first access on the Live tab. + AircraftRegistry.shared.preload() + AircraftDatabase.shared.preload() } var body: some Scene { diff --git a/Flights/Services/AircraftDatabase.swift b/Flights/Services/AircraftDatabase.swift index d17d099..e6ad061 100644 --- a/Flights/Services/AircraftDatabase.swift +++ b/Flights/Services/AircraftDatabase.swift @@ -14,22 +14,47 @@ import Foundation final class AircraftDatabase: @unchecked Sendable { static let shared = AircraftDatabase() - private let byICAO24: [String: String] + /// Storage. Empty until `preload()` has filled it. Reads before preload + /// return nil — the type-code filter will simply look empty until the + /// DB has loaded, instead of blocking the main thread for ~100-200ms + /// while parsing 1.5MB of JSON. + private let lock = NSLock() + private var byICAO24: [String: String] = [:] + private var isLoaded: Bool = false - private init() { - if let url = Bundle.main.url(forResource: "aircraftDB", withExtension: "json"), - let data = try? Data(contentsOf: url), - let decoded = try? JSONDecoder().decode([String: String].self, from: data) { - byICAO24 = decoded - } else { - byICAO24 = [:] + private init() {} + + /// Kick off the JSON parse on a background thread. Safe to call + /// multiple times — subsequent calls are no-ops. Call this once at + /// app launch (FlightsApp.init) so the data is ready by the time + /// the user opens the Live tab. + func preload() { + lock.lock() + if isLoaded { + lock.unlock() + return + } + isLoaded = true + lock.unlock() + + Task.detached(priority: .utility) { [weak self] in + guard let url = Bundle.main.url(forResource: "aircraftDB", withExtension: "json"), + let data = try? Data(contentsOf: url), + let decoded = try? JSONDecoder().decode([String: String].self, from: data) + else { return } + self?.lock.withLock { + self?.byICAO24 = decoded + } } } /// Returns the ICAO aircraft type designator for the given 24-bit - /// ICAO transponder address, or nil if the airframe isn't in the DB. + /// ICAO transponder address, or nil if the airframe isn't in the DB + /// (or the DB hasn't finished loading yet). func typeCode(forICAO24 icao24: String) -> String? { let key = icao24.lowercased() + lock.lock() + defer { lock.unlock() } return byICAO24[key] } diff --git a/Flights/Services/AircraftRegistry.swift b/Flights/Services/AircraftRegistry.swift index f05a035..2cc4111 100644 --- a/Flights/Services/AircraftRegistry.swift +++ b/Flights/Services/AircraftRegistry.swift @@ -20,38 +20,60 @@ final class AircraftRegistry: @unchecked Sendable { let logoURL: URL? // FR24 CDN URL, may be nil } - private let byICAO: [String: Entry] - private let byIATA: [String: Entry] + private let lock = NSLock() + private var byICAO: [String: Entry] = [:] + private var byIATA: [String: Entry] = [:] + private var isLoaded = false private init() { - let url = Bundle.main.url(forResource: "airlines", withExtension: "json") - guard let url, let data = try? Data(contentsOf: url), - let raw = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]] - else { - // Fallback to hardcoded subset if the bundled file is missing. - byICAO = Self.builtIn.reduce(into: [:]) { acc, kv in - acc[kv.key] = Entry(icao: kv.key, iata: kv.value.0, name: kv.value.1, logoURL: nil) - } - byIATA = byICAO.values.reduce(into: [:]) { acc, e in - if let i = e.iata { acc[i] = e } - } + // Bootstrap with the hardcoded fallback so reads never come back + // empty even before preload finishes. + var icao: [String: Entry] = [:] + var iata: [String: Entry] = [:] + for (code, info) in Self.builtIn { + let e = Entry(icao: code, iata: info.0, name: info.1, logoURL: nil) + icao[code] = e + iata[info.0] = e + } + byICAO = icao + byIATA = iata + } + + /// Kick off the airlines JSON parse on a background thread. Safe to + /// call multiple times. Call at app launch so we never hit the parse + /// cost when the user opens the Live tab. + func preload() { + lock.lock() + if isLoaded { + lock.unlock() return } + isLoaded = true + lock.unlock() - var icaoMap: [String: Entry] = [:] - var iataMap: [String: Entry] = [:] - for row in raw { - let icao = (row["i"] as? String).map { $0.isEmpty ? nil : $0 } ?? nil - let iata = (row["a"] as? String).map { $0.isEmpty ? nil : $0 } ?? nil - let name = row["n"] as? String ?? "" - let logo = (row["l"] as? String).flatMap(URL.init(string:)) - guard !name.isEmpty else { continue } - let entry = Entry(icao: icao, iata: iata, name: name, logoURL: logo) - if let icao { icaoMap[icao.uppercased()] = entry } - if let iata { iataMap[iata.uppercased()] = entry } + Task.detached(priority: .utility) { [weak self] in + guard let url = Bundle.main.url(forResource: "airlines", withExtension: "json"), + let data = try? Data(contentsOf: url), + let raw = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]] + else { return } + + var icaoMap: [String: Entry] = [:] + var iataMap: [String: Entry] = [:] + for row in raw { + let icao = (row["i"] as? String).map { $0.isEmpty ? nil : $0 } ?? nil + let iata = (row["a"] as? String).map { $0.isEmpty ? nil : $0 } ?? nil + let name = row["n"] as? String ?? "" + let logo = (row["l"] as? String).flatMap(URL.init(string:)) + guard !name.isEmpty else { continue } + let entry = Entry(icao: icao, iata: iata, name: name, logoURL: logo) + if let icao { icaoMap[icao.uppercased()] = entry } + if let iata { iataMap[iata.uppercased()] = entry } + } + self?.lock.withLock { + self?.byICAO = icaoMap + self?.byIATA = iataMap + } } - byICAO = icaoMap - byIATA = iataMap } /// Look up by ICAO 3-letter prefix (e.g. "DAL"). diff --git a/Flights/Views/LiveFlightsView.swift b/Flights/Views/LiveFlightsView.swift index d4757e1..37f1afc 100644 --- a/Flights/Views/LiveFlightsView.swift +++ b/Flights/Views/LiveFlightsView.swift @@ -63,6 +63,11 @@ struct LiveFlightsView: View { @State private var cachedAirlineItems: [AirlineFilterItem] = [] @State private var cachedTypeItems: [TypeFilterItem] = [] + /// Pre-filtered aircraft snapshot. Recomputed only when `aircraft` or + /// any filter state changes. Caching here avoids running the filter + /// loop on every body re-render (e.g. when a sheet animates in). + @State private var cachedFilteredAircraft: [LiveAircraft] = [] + /// Tracks the last bounding box we fetched against. Used to throttle /// the on-pan refresh so that micro-camera-settlements (which happen /// every time annotations re-render) don't fire fresh OpenSky calls. @@ -101,7 +106,15 @@ struct LiveFlightsView: View { .task(id: selectedAircraft?.icao24) { await loadTrackForSelection() } - .onChange(of: aircraft) { _, _ in rebuildFilterItems() } + .onChange(of: aircraft) { _, _ in + rebuildFilterItems() + rebuildFilteredAircraft() + } + .onChange(of: selectedAirlineICAO) { _, _ in rebuildFilteredAircraft() } + .onChange(of: selectedTypeCodes) { _, _ in rebuildFilteredAircraft() } + .onChange(of: selectedAltitudeBand) { _, _ in rebuildFilteredAircraft() } + .onChange(of: hideOnGround) { _, _ in rebuildFilteredAircraft() } + .onChange(of: searchText) { _, _ in rebuildFilteredAircraft() } .sheet(item: $activeSheet) { sheet in switch sheet { case .aircraft(let ac): @@ -145,8 +158,13 @@ struct LiveFlightsView: View { } ForEach(filteredAircraft) { ac in + // Pre-compute the pin's inputs outside the closure so the + // SwiftUI builder doesn't re-derive them on every diff. + let tint = aircraftTint(for: ac) + let rotation = Double(ac.heading ?? 0) - 45 // SF airplane symbol points up-right + let selected = selectedAircraft?.id == ac.id Annotation(ac.trimmedCallsign ?? ac.icao24, coordinate: ac.coordinate) { - AircraftPin(ac: ac, isSelected: selectedAircraft?.id == ac.id) + AircraftPin(tint: tint, headingMinus45: rotation, isSelected: selected) .onTapGesture { activeSheet = .aircraft(ac) } @@ -332,23 +350,26 @@ struct LiveFlightsView: View { // MARK: - Derived - private var filteredAircraft: [LiveAircraft] { + /// Quick read accessor used by the rendering layer. Always returns the + /// cached snapshot; rebuilds happen via the .onChange handlers above. + private var filteredAircraft: [LiveAircraft] { cachedFilteredAircraft } + + private func rebuildFilteredAircraft() { let s = searchText.trimmingCharacters(in: .whitespacesAndNewlines).uppercased() - return aircraft.filter { ac in - if hideOnGround && ac.onGround { return false } - if !selectedAirlineICAO.isEmpty { - // Tightened: require an airline match. Aircraft without a - // parseable ICAO airline prefix (N-numbers, ad-hoc callsigns) - // were slipping through as "uncategorized" — that's why - // selecting Southwest still left other carriers on screen. - guard let code = ac.airlineICAO, selectedAirlineICAO.contains(code) else { - return false - } + let airlines = selectedAirlineICAO + let types = selectedTypeCodes + let band = selectedAltitudeBand + let hideGround = hideOnGround + + cachedFilteredAircraft = aircraft.filter { ac in + if hideGround && ac.onGround { return false } + if !airlines.isEmpty { + guard let code = ac.airlineICAO, airlines.contains(code) else { return false } } - if !selectedTypeCodes.isEmpty { - guard let tc = ac.typeCode, selectedTypeCodes.contains(tc) else { return false } + if !types.isEmpty { + guard let tc = ac.typeCode, types.contains(tc) else { return false } } - if let band = selectedAltitudeBand { + if let band { guard let alt = ac.altitudeFeet, band.contains(alt) else { return false } } if !s.isEmpty { @@ -471,7 +492,13 @@ struct LiveFlightsView: View { let results = try await openSky.states( latMin: bb.latMin, lonMin: bb.lonMin, latMax: bb.latMax, lonMax: bb.lonMax ) - aircraft = results + // Suppress the implicit crossfade SwiftUI would apply to the + // map annotations when the underlying array swaps wholesale. + var tx = Transaction() + tx.disablesAnimations = true + withTransaction(tx) { + aircraft = results + } lastFetchAt = Date() lastFetchedBoundingBox = bb error = nil @@ -519,46 +546,56 @@ struct LiveFlightsView: View { private func relativeTime(_ d: Date) -> String { let secs = Int(Date().timeIntervalSince(d)) + // Snap to coarse buckets so the footer text doesn't tick every + // second (which would force the footer subtree to re-render + // every body pass that happens to land on a different second). if secs < 5 { return "just now" } - if secs < 60 { return "\(secs)s ago" } + if secs < 30 { return "<30s ago" } + if secs < 60 { return "<1m ago" } return "\(secs / 60)m ago" } } // MARK: - Aircraft pin -private struct AircraftPin: View { - let ac: LiveAircraft +/// Per-aircraft map pin. Kept deliberately minimal — every annotation is +/// a SwiftUI view in the map's content tree, so view-tree depth × N pins +/// directly affects scroll/pan performance. Equatable so SwiftUI can +/// skip diffing identical pins on re-renders. +private struct AircraftPin: View, Equatable { + let tint: Color + let headingMinus45: Double let isSelected: Bool var body: some View { - ZStack { - if isSelected { + Image(systemName: "airplane") + .font(.system(size: 14, weight: .bold)) + .foregroundStyle(.white) + .padding(6) + .background(Circle().fill(tint)) + .overlay( Circle() - .fill(FlightTheme.accent.opacity(0.25)) - .frame(width: 36, height: 36) - } - Image(systemName: "airplane") - .font(.system(size: isSelected ? 18 : 14, weight: .bold)) - .foregroundStyle(.white) - .padding(6) - .background( - Circle().fill(tint) - ) - .rotationEffect(.degrees(Double(ac.heading ?? 0) - 45)) - // SF Symbol "airplane" points up-and-right by default; - // -45° aligns it to true north before applying the heading. - } - .contentShape(Rectangle()) + .stroke(.white, lineWidth: isSelected ? 2.5 : 0) + ) + .rotationEffect(.degrees(headingMinus45)) + .scaleEffect(isSelected ? 1.3 : 1) + .animation(nil, value: tint) + .contentShape(Rectangle()) } - private var tint: Color { - if ac.onGround { return FlightTheme.textTertiary } - switch ac.verticalState { - case .climbing: return FlightTheme.onTime - case .descending: return FlightTheme.delayed - case .level: return FlightTheme.accent - } + static func == (lhs: AircraftPin, rhs: AircraftPin) -> Bool { + lhs.tint == rhs.tint + && lhs.headingMinus45 == rhs.headingMinus45 + && lhs.isSelected == rhs.isSelected + } +} + +private func aircraftTint(for ac: LiveAircraft) -> Color { + if ac.onGround { return FlightTheme.textTertiary } + switch ac.verticalState { + case .climbing: return FlightTheme.onTime + case .descending: return FlightTheme.delayed + case .level: return FlightTheme.accent } }