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 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)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user