Live tab: stable refresh loop, replace dead Type filter with Altitude

Two reported bugs:

1) Bottom bar appeared to be constantly refreshing. Cause:
   .task(id: refreshTick) restarted every time isLoading flipped
   (twice per refresh cycle) AND every time aircraft.count changed.
   On top of that, on-pan refresh fired on every map camera settlement
   — including the micro-settlements that happen when annotations
   re-render after each fetch. The cumulative effect looked like a
   tight loop.

   Replaced the cascade with a single long-lived .task running a
   while-loop that sleeps refreshInterval then refreshes. SwiftUI
   cancels it when the view disappears. Added a bbox-delta gate
   (refreshIfRegionChanged) so pan-triggered refreshes only fire
   when the visible center moved >15% of the box width or the zoom
   changed >20%. Removed nextFetchAllowedAt — the throttle is now
   structural rather than time-based.

2) Tapping the "Type" filter did nothing. Cause: OpenSky's anonymous
   tier returns the ADS-B emitter category as null/0 for the vast
   majority of aircraft, so cachedCategoryItems was empty and the
   menu opened with no rows. Replaced the Type filter with an
   Altitude filter (below 10k / 10–25k / 25–40k / above 40k ft),
   driven by data we always have (baroAltitude / geoAltitude). The
   menu always has 4 rows with live counts.

Also: tightened boundingBox(of:) to return a labeled tuple so the
delta-gate code can use .latMin / .lonMax directly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Trey T
2026-05-27 07:01:54 -05:00
parent a031a1aafd
commit d6fb73db2c
+98 -57
View File
@@ -16,7 +16,6 @@ struct LiveFlightsView: View {
@State private var aircraft: [LiveAircraft] = [] @State private var aircraft: [LiveAircraft] = []
@State private var lastFetchAt: Date? @State private var lastFetchAt: Date?
@State private var nextFetchAllowedAt: Date = .distantPast
@State private var isLoading = false @State private var isLoading = false
@State private var error: String? @State private var error: String?
@@ -24,9 +23,28 @@ struct LiveFlightsView: View {
@State private var searchText: String = "" @State private var searchText: String = ""
@State private var selectedAirlineICAO: Set<String> = [] @State private var selectedAirlineICAO: Set<String> = []
@State private var selectedCategories: Set<Int> = [] @State private var selectedAltitudeBand: AltitudeBand? = nil
@State private var hideOnGround: Bool = false @State private var hideOnGround: Bool = false
/// Altitude bands the user can filter by. Derived from data we always
/// have from OpenSky (baroAltitude / geoAltitude) unlike `category`,
/// which the anonymous tier returns as null for most aircraft.
enum AltitudeBand: String, CaseIterable, Identifiable, Hashable {
case lowLevel = "Below 10k ft"
case midLevel = "10k 25k ft"
case cruiseLevel = "25k 40k ft"
case highLevel = "Above 40k ft"
var id: String { rawValue }
func contains(_ ft: Int) -> Bool {
switch self {
case .lowLevel: return ft < 10_000
case .midLevel: return ft >= 10_000 && ft < 25_000
case .cruiseLevel: return ft >= 25_000 && ft < 40_000
case .highLevel: return ft >= 40_000
}
}
}
// MARK: - Selection & sheets // MARK: - Selection & sheets
// //
// A single `activeSheet` is set both for tap-on-aircraft and tap-on-gear, // A single `activeSheet` is set both for tap-on-aircraft and tap-on-gear,
@@ -42,7 +60,11 @@ struct LiveFlightsView: View {
// (each refresh fed the airlines registry an N×M lookup on the main // (each refresh fed the airlines registry an N×M lookup on the main
// thread). // thread).
@State private var cachedAirlineItems: [AirlineFilterItem] = [] @State private var cachedAirlineItems: [AirlineFilterItem] = []
@State private var cachedCategoryItems: [CategoryFilterItem] = []
/// 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.
@State private var lastFetchedBoundingBox: (latMin: Double, lonMin: Double, latMax: Double, lonMax: Double)?
enum ActiveSheet: Identifiable { enum ActiveSheet: Identifiable {
case aircraft(LiveAircraft) case aircraft(LiveAircraft)
@@ -69,11 +91,7 @@ struct LiveFlightsView: View {
mapLayer mapLayer
.safeAreaInset(edge: .top, spacing: 0) { topFilterBar } .safeAreaInset(edge: .top, spacing: 0) { topFilterBar }
.safeAreaInset(edge: .bottom, spacing: 0) { bottomStatusBar } .safeAreaInset(edge: .bottom, spacing: 0) { bottomStatusBar }
.task { await initialFetch() } .task { await autoRefreshLoop() }
.task(id: refreshTick) {
try? await Task.sleep(nanoseconds: UInt64(Self.refreshInterval * 1_000_000_000))
await refreshIfAllowed()
}
.task(id: selectedAircraft?.icao24) { .task(id: selectedAircraft?.icao24) {
await loadTrackForSelection() await loadTrackForSelection()
} }
@@ -121,7 +139,7 @@ struct LiveFlightsView: View {
.mapStyle(.standard(elevation: .flat)) .mapStyle(.standard(elevation: .flat))
.onMapCameraChange(frequency: .onEnd) { context in .onMapCameraChange(frequency: .onEnd) { context in
visibleRegion = context.region visibleRegion = context.region
Task { await refreshIfAllowed() } Task { await refreshIfRegionChanged() }
} }
} }
@@ -201,36 +219,37 @@ struct LiveFlightsView: View {
} }
Menu { Menu {
Section("Aircraft type") { Section("Altitude") {
ForEach(cachedCategoryItems, id: \.code) { item in let counts = altitudeBandCounts
ForEach(AltitudeBand.allCases) { band in
let count = counts[band] ?? 0
Button { Button {
toggle(&selectedCategories, item.code) selectedAltitudeBand = (selectedAltitudeBand == band) ? nil : band
} label: { } label: {
if selectedCategories.contains(item.code) { let label = "\(band.rawValue) (\(count))"
Label(item.label, systemImage: "checkmark") if selectedAltitudeBand == band {
Label(label, systemImage: "checkmark")
} else { } else {
Text(item.label) Text(label)
} }
} }
} }
if !selectedCategories.isEmpty { if selectedAltitudeBand != nil {
Button("Clear", role: .destructive) { selectedCategories.removeAll() } Button("Clear", role: .destructive) { selectedAltitudeBand = nil }
} }
} }
} label: { } label: {
FilterChipLabel( FilterChipLabel(
label: selectedCategories.isEmpty label: selectedAltitudeBand?.rawValue ?? "Altitude",
? "Type" systemImage: "arrow.up.and.down",
: "Type · \(selectedCategories.count)", isActive: selectedAltitudeBand != nil
systemImage: "airplane.departure",
isActive: !selectedCategories.isEmpty
) )
} }
if !selectedAirlineICAO.isEmpty || !selectedCategories.isEmpty || hideOnGround { if !selectedAirlineICAO.isEmpty || selectedAltitudeBand != nil || hideOnGround {
Button { Button {
selectedAirlineICAO.removeAll() selectedAirlineICAO.removeAll()
selectedCategories.removeAll() selectedAltitudeBand = nil
hideOnGround = false hideOnGround = false
} label: { } label: {
FilterChipLabel(label: "Reset", systemImage: "arrow.counterclockwise", isActive: false) FilterChipLabel(label: "Reset", systemImage: "arrow.counterclockwise", isActive: false)
@@ -301,10 +320,6 @@ struct LiveFlightsView: View {
// MARK: - Derived // MARK: - Derived
private var refreshTick: Int {
aircraft.count &+ (isLoading ? 1 : 0)
}
private var filteredAircraft: [LiveAircraft] { private var filteredAircraft: [LiveAircraft] {
let s = searchText.trimmingCharacters(in: .whitespacesAndNewlines).uppercased() let s = searchText.trimmingCharacters(in: .whitespacesAndNewlines).uppercased()
return aircraft.filter { ac in return aircraft.filter { ac in
@@ -318,8 +333,8 @@ struct LiveFlightsView: View {
return false return false
} }
} }
if !selectedCategories.isEmpty { if let band = selectedAltitudeBand {
guard let cat = ac.category, selectedCategories.contains(cat) else { return false } guard let alt = ac.altitudeFeet, band.contains(alt) else { return false }
} }
if !s.isEmpty { if !s.isEmpty {
let cs = ac.trimmedCallsign?.uppercased() ?? "" let cs = ac.trimmedCallsign?.uppercased() ?? ""
@@ -336,25 +351,14 @@ struct LiveFlightsView: View {
var label: String { "\(name) (\(count))" } var label: String { "\(name) (\(count))" }
} }
struct CategoryFilterItem: Hashable {
let code: Int
let name: String
let count: Int
var label: String { "\(name) (\(count))" }
}
/// Rebuilds the cached filter items. Called from a .task tied to the /// Rebuilds the cached filter items. Called from a .task tied to the
/// aircraft array so it doesn't run on every body re-render. /// aircraft array so it doesn't run on every body re-render.
private func rebuildFilterItems() { private func rebuildFilterItems() {
var airlines: [String: Int] = [:] var airlines: [String: Int] = [:]
var cats: [Int: Int] = [:]
for ac in aircraft { for ac in aircraft {
if let code = ac.airlineICAO { if let code = ac.airlineICAO {
airlines[code, default: 0] += 1 airlines[code, default: 0] += 1
} }
if let c = ac.category, c > 0 {
cats[c, default: 0] += 1
}
} }
cachedAirlineItems = airlines.map { (icao, count) in cachedAirlineItems = airlines.map { (icao, count) in
AirlineFilterItem( AirlineFilterItem(
@@ -363,16 +367,29 @@ struct LiveFlightsView: View {
count: count count: count
) )
}.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } }.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
}
cachedCategoryItems = cats.compactMap { (code, count) -> CategoryFilterItem? in /// Counts of how many aircraft fall in each altitude band drives the
guard let name = aircraftCategoryName(code) else { return nil } /// altitude filter menu labels.
return CategoryFilterItem(code: code, name: name, count: count) private var altitudeBandCounts: [AltitudeBand: Int] {
}.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } var counts: [AltitudeBand: Int] = [:]
for ac in aircraft {
guard let ft = ac.altitudeFeet else { continue }
for band in AltitudeBand.allCases where band.contains(ft) {
counts[band, default: 0] += 1
}
}
return counts
} }
// MARK: - Fetch // MARK: - Fetch
private func initialFetch() async { /// Single long-lived auto-refresh loop. Runs for the lifetime of the
/// view (cancelled by SwiftUI when the tab disappears). Replaces the
/// old .task(id:) cascade, which restarted the timer every time
/// `isLoading` flipped and produced the "constantly refreshing"
/// symptom.
private func autoRefreshLoop() async {
if visibleRegion == nil { if visibleRegion == nil {
let initial = MKCoordinateRegion( let initial = MKCoordinateRegion(
center: CLLocationCoordinate2D(latitude: 39.5, longitude: -98.0), center: CLLocationCoordinate2D(latitude: 39.5, longitude: -98.0),
@@ -382,10 +399,32 @@ struct LiveFlightsView: View {
visibleRegion = initial visibleRegion = initial
} }
await refreshNow() await refreshNow()
while !Task.isCancelled {
try? await Task.sleep(nanoseconds: UInt64(Self.refreshInterval * 1_000_000_000))
await refreshNow()
}
} }
private func refreshIfAllowed() async { /// Called on map camera change. Only fires a fresh fetch if the new
guard Date() >= nextFetchAllowedAt else { return } /// bounding box actually moved by a meaningful amount micro-camera
/// settlements caused by annotation re-renders would otherwise
/// hammer OpenSky.
private func refreshIfRegionChanged() async {
guard let r = visibleRegion else { return }
let bb = boundingBox(of: r)
if let last = lastFetchedBoundingBox {
let centerDelta = max(
abs((bb.latMin + bb.latMax) / 2 - (last.latMin + last.latMax) / 2),
abs((bb.lonMin + bb.lonMax) / 2 - (last.lonMin + last.lonMax) / 2)
)
let widthRatio = (bb.lonMax - bb.lonMin) / max(0.001, last.lonMax - last.lonMin)
// Center moved less than 15% of the box width, AND box didn't
// zoom by more than 20% skip.
let halfWidth = (last.lonMax - last.lonMin) / 2
if centerDelta < halfWidth * 0.15, widthRatio > 0.8, widthRatio < 1.2 {
return
}
}
await refreshNow() await refreshNow()
} }
@@ -394,34 +433,36 @@ struct LiveFlightsView: View {
isLoading = true isLoading = true
defer { isLoading = false } defer { isLoading = false }
let (latMin, lonMin, latMax, lonMax) = boundingBox(of: r) let bb = boundingBox(of: r)
do { do {
let results = try await openSky.states( let results = try await openSky.states(
latMin: latMin, lonMin: lonMin, latMax: latMax, lonMax: lonMax latMin: bb.latMin, lonMin: bb.lonMin, latMax: bb.latMax, lonMax: bb.lonMax
) )
aircraft = results aircraft = results
lastFetchAt = Date() lastFetchAt = Date()
nextFetchAllowedAt = Date().addingTimeInterval(Self.refreshInterval) lastFetchedBoundingBox = bb
error = nil 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 {
nextFetchAllowedAt = Date().addingTimeInterval(60) // 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) -> (Double, Double, Double, 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
let dLat = r.span.latitudeDelta / 2 let dLat = r.span.latitudeDelta / 2
let dLon = r.span.longitudeDelta / 2 let dLon = r.span.longitudeDelta / 2
return ( return (
max(-90, lat - dLat), latMin: max(-90, lat - dLat),
max(-180, lon - dLon), lonMin: max(-180, lon - dLon),
min( 90, lat + dLat), latMax: min( 90, lat + dLat),
min( 180, lon + dLon) lonMax: min( 180, lon + dLon)
) )
} }