diff --git a/Flights/Views/LiveFlightsView.swift b/Flights/Views/LiveFlightsView.swift index 39a7c34..69475df 100644 --- a/Flights/Views/LiveFlightsView.swift +++ b/Flights/Views/LiveFlightsView.swift @@ -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 = [] - @State private var selectedCategories: Set = [] + @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) ) }