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:
@@ -16,7 +16,6 @@ struct LiveFlightsView: View {
|
||||
|
||||
@State private var aircraft: [LiveAircraft] = []
|
||||
@State private var lastFetchAt: Date?
|
||||
@State private var nextFetchAllowedAt: Date = .distantPast
|
||||
@State private var isLoading = false
|
||||
@State private var error: String?
|
||||
|
||||
@@ -24,9 +23,28 @@ struct LiveFlightsView: View {
|
||||
|
||||
@State private var searchText: 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
|
||||
|
||||
/// 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
|
||||
//
|
||||
// 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
|
||||
// thread).
|
||||
@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 {
|
||||
case aircraft(LiveAircraft)
|
||||
@@ -69,11 +91,7 @@ struct LiveFlightsView: View {
|
||||
mapLayer
|
||||
.safeAreaInset(edge: .top, spacing: 0) { topFilterBar }
|
||||
.safeAreaInset(edge: .bottom, spacing: 0) { bottomStatusBar }
|
||||
.task { await initialFetch() }
|
||||
.task(id: refreshTick) {
|
||||
try? await Task.sleep(nanoseconds: UInt64(Self.refreshInterval * 1_000_000_000))
|
||||
await refreshIfAllowed()
|
||||
}
|
||||
.task { await autoRefreshLoop() }
|
||||
.task(id: selectedAircraft?.icao24) {
|
||||
await loadTrackForSelection()
|
||||
}
|
||||
@@ -121,7 +139,7 @@ struct LiveFlightsView: View {
|
||||
.mapStyle(.standard(elevation: .flat))
|
||||
.onMapCameraChange(frequency: .onEnd) { context in
|
||||
visibleRegion = context.region
|
||||
Task { await refreshIfAllowed() }
|
||||
Task { await refreshIfRegionChanged() }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -201,36 +219,37 @@ struct LiveFlightsView: View {
|
||||
}
|
||||
|
||||
Menu {
|
||||
Section("Aircraft type") {
|
||||
ForEach(cachedCategoryItems, id: \.code) { item in
|
||||
Section("Altitude") {
|
||||
let counts = altitudeBandCounts
|
||||
ForEach(AltitudeBand.allCases) { band in
|
||||
let count = counts[band] ?? 0
|
||||
Button {
|
||||
toggle(&selectedCategories, item.code)
|
||||
selectedAltitudeBand = (selectedAltitudeBand == band) ? nil : band
|
||||
} label: {
|
||||
if selectedCategories.contains(item.code) {
|
||||
Label(item.label, systemImage: "checkmark")
|
||||
let label = "\(band.rawValue) (\(count))"
|
||||
if selectedAltitudeBand == band {
|
||||
Label(label, systemImage: "checkmark")
|
||||
} else {
|
||||
Text(item.label)
|
||||
Text(label)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !selectedCategories.isEmpty {
|
||||
Button("Clear", role: .destructive) { selectedCategories.removeAll() }
|
||||
if selectedAltitudeBand != nil {
|
||||
Button("Clear", role: .destructive) { selectedAltitudeBand = nil }
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
FilterChipLabel(
|
||||
label: selectedCategories.isEmpty
|
||||
? "Type"
|
||||
: "Type · \(selectedCategories.count)",
|
||||
systemImage: "airplane.departure",
|
||||
isActive: !selectedCategories.isEmpty
|
||||
label: selectedAltitudeBand?.rawValue ?? "Altitude",
|
||||
systemImage: "arrow.up.and.down",
|
||||
isActive: selectedAltitudeBand != nil
|
||||
)
|
||||
}
|
||||
|
||||
if !selectedAirlineICAO.isEmpty || !selectedCategories.isEmpty || hideOnGround {
|
||||
if !selectedAirlineICAO.isEmpty || selectedAltitudeBand != nil || hideOnGround {
|
||||
Button {
|
||||
selectedAirlineICAO.removeAll()
|
||||
selectedCategories.removeAll()
|
||||
selectedAltitudeBand = nil
|
||||
hideOnGround = false
|
||||
} label: {
|
||||
FilterChipLabel(label: "Reset", systemImage: "arrow.counterclockwise", isActive: false)
|
||||
@@ -301,10 +320,6 @@ struct LiveFlightsView: View {
|
||||
|
||||
// MARK: - Derived
|
||||
|
||||
private var refreshTick: Int {
|
||||
aircraft.count &+ (isLoading ? 1 : 0)
|
||||
}
|
||||
|
||||
private var filteredAircraft: [LiveAircraft] {
|
||||
let s = searchText.trimmingCharacters(in: .whitespacesAndNewlines).uppercased()
|
||||
return aircraft.filter { ac in
|
||||
@@ -318,8 +333,8 @@ struct LiveFlightsView: View {
|
||||
return false
|
||||
}
|
||||
}
|
||||
if !selectedCategories.isEmpty {
|
||||
guard let cat = ac.category, selectedCategories.contains(cat) else { return false }
|
||||
if let band = selectedAltitudeBand {
|
||||
guard let alt = ac.altitudeFeet, band.contains(alt) else { return false }
|
||||
}
|
||||
if !s.isEmpty {
|
||||
let cs = ac.trimmedCallsign?.uppercased() ?? ""
|
||||
@@ -336,25 +351,14 @@ struct LiveFlightsView: View {
|
||||
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
|
||||
/// aircraft array so it doesn't run on every body re-render.
|
||||
private func rebuildFilterItems() {
|
||||
var airlines: [String: Int] = [:]
|
||||
var cats: [Int: Int] = [:]
|
||||
for ac in aircraft {
|
||||
if let code = ac.airlineICAO {
|
||||
airlines[code, default: 0] += 1
|
||||
}
|
||||
if let c = ac.category, c > 0 {
|
||||
cats[c, default: 0] += 1
|
||||
}
|
||||
}
|
||||
cachedAirlineItems = airlines.map { (icao, count) in
|
||||
AirlineFilterItem(
|
||||
@@ -363,16 +367,29 @@ struct LiveFlightsView: View {
|
||||
count: count
|
||||
)
|
||||
}.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
|
||||
}
|
||||
|
||||
cachedCategoryItems = cats.compactMap { (code, count) -> CategoryFilterItem? in
|
||||
guard let name = aircraftCategoryName(code) else { return nil }
|
||||
return CategoryFilterItem(code: code, name: name, count: count)
|
||||
}.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
|
||||
/// Counts of how many aircraft fall in each altitude band — drives the
|
||||
/// altitude filter menu labels.
|
||||
private var altitudeBandCounts: [AltitudeBand: Int] {
|
||||
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
|
||||
|
||||
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 {
|
||||
let initial = MKCoordinateRegion(
|
||||
center: CLLocationCoordinate2D(latitude: 39.5, longitude: -98.0),
|
||||
@@ -382,10 +399,32 @@ struct LiveFlightsView: View {
|
||||
visibleRegion = initial
|
||||
}
|
||||
await refreshNow()
|
||||
while !Task.isCancelled {
|
||||
try? await Task.sleep(nanoseconds: UInt64(Self.refreshInterval * 1_000_000_000))
|
||||
await refreshNow()
|
||||
}
|
||||
}
|
||||
|
||||
private func refreshIfAllowed() async {
|
||||
guard Date() >= nextFetchAllowedAt else { return }
|
||||
/// Called on map camera change. Only fires a fresh fetch if the new
|
||||
/// 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()
|
||||
}
|
||||
|
||||
@@ -394,34 +433,36 @@ struct LiveFlightsView: View {
|
||||
isLoading = true
|
||||
defer { isLoading = false }
|
||||
|
||||
let (latMin, lonMin, latMax, lonMax) = boundingBox(of: r)
|
||||
let bb = boundingBox(of: r)
|
||||
do {
|
||||
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
|
||||
lastFetchAt = Date()
|
||||
nextFetchAllowedAt = Date().addingTimeInterval(Self.refreshInterval)
|
||||
lastFetchedBoundingBox = bb
|
||||
error = nil
|
||||
} catch {
|
||||
self.error = (error as? OpenSkyClient.ClientError)?.errorDescription
|
||||
?? error.localizedDescription
|
||||
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 lon = r.center.longitude
|
||||
let dLat = r.span.latitudeDelta / 2
|
||||
let dLon = r.span.longitudeDelta / 2
|
||||
return (
|
||||
max(-90, lat - dLat),
|
||||
max(-180, lon - dLon),
|
||||
min( 90, lat + dLat),
|
||||
min( 180, lon + dLon)
|
||||
latMin: max(-90, lat - dLat),
|
||||
lonMin: max(-180, lon - dLon),
|
||||
latMax: min( 90, lat + dLat),
|
||||
lonMax: min( 180, lon + dLon)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user