From dee6df1ac693f1bfca9292cf32dcc0ca84756682 Mon Sep 17 00:00:00 2001 From: Trey T Date: Wed, 27 May 2026 07:37:34 -0500 Subject: [PATCH] Live map: center on user, persist region, zoom-based aircraft cap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Initial region cascade: restore last viewed region → on location grant, animate to a city-level view centered on user; otherwise fall back to the saved/continental default. User pans are detected via a center-delta threshold so a late location grant doesn't yank the camera away from where the user is looking. - LocationService: thin one-shot CLLocationManager wrapper with CheckedContinuation. Added NSLocationWhenInUseUsageDescription via INFOPLIST_KEY_ build setting. - Visible-aircraft cap scales with zoom (<2°: uncapped, <8°: 100, <25°: 150, else 200). Active filters bypass the cap entirely so every match always renders. When capped, we keep the N closest to the map center. - Footer shows "Showing N of M" when the cap clips, "N aircraft" otherwise. Co-Authored-By: Claude Opus 4.7 --- Flights.xcodeproj/project.pbxproj | 6 ++ Flights/Services/LocationService.swift | 82 +++++++++++++++ Flights/Views/LiveFlightsView.swift | 140 ++++++++++++++++++++++++- 3 files changed, 225 insertions(+), 3 deletions(-) create mode 100644 Flights/Services/LocationService.swift diff --git a/Flights.xcodeproj/project.pbxproj b/Flights.xcodeproj/project.pbxproj index 596240f..0eedea2 100644 --- a/Flights.xcodeproj/project.pbxproj +++ b/Flights.xcodeproj/project.pbxproj @@ -58,6 +58,7 @@ LVAA000AAAA000AAAA000001 /* AircraftDatabase.swift in Sources */ = {isa = PBXBuildFile; fileRef = LVAA000AAAA000AAAA000002 /* AircraftDatabase.swift */; }; LVBB000BBBB000BBBB000001 /* LiveFilterPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = LVBB000BBBB000BBBB000002 /* LiveFilterPicker.swift */; }; LVCC000CCCC000CCCC000001 /* aircraftDB.json in Resources */ = {isa = PBXBuildFile; fileRef = LVCC000CCCC000CCCC000002 /* aircraftDB.json */; }; + LVDD000DDDD000DDDD000001 /* LocationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = LVDD000DDDD000DDDD000002 /* LocationService.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -124,6 +125,7 @@ LVAA000AAAA000AAAA000002 /* AircraftDatabase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AircraftDatabase.swift; sourceTree = ""; }; LVBB000BBBB000BBBB000002 /* LiveFilterPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveFilterPicker.swift; sourceTree = ""; }; LVCC000CCCC000CCCC000002 /* aircraftDB.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = aircraftDB.json; sourceTree = ""; }; + LVDD000DDDD000DDDD000002 /* LocationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationService.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -243,6 +245,7 @@ LV3300003333000033330002 /* AircraftRegistry.swift */, LV7700007777000077770002 /* OpenSkyCredentials.swift */, LVAA000AAAA000AAAA000002 /* AircraftDatabase.swift */, + LVDD000DDDD000DDDD000002 /* LocationService.swift */, ); path = Services; sourceTree = ""; @@ -414,6 +417,7 @@ LV8800008888000088880001 /* OpenSkySettingsView.swift in Sources */, LVAA000AAAA000AAAA000001 /* AircraftDatabase.swift in Sources */, LVBB000BBBB000BBBB000001 /* LiveFilterPicker.swift in Sources */, + LVDD000DDDD000DDDD000001 /* LocationService.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -437,6 +441,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = V3PF3M6B6U; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "Show your current location on the live flight map so you can quickly see aircraft overhead."; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; @@ -466,6 +471,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = V3PF3M6B6U; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "Show your current location on the live flight map so you can quickly see aircraft overhead."; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; diff --git a/Flights/Services/LocationService.swift b/Flights/Services/LocationService.swift new file mode 100644 index 0000000..8bcf2bf --- /dev/null +++ b/Flights/Services/LocationService.swift @@ -0,0 +1,82 @@ +import Foundation +import CoreLocation + +/// Thin wrapper around CLLocationManager exposing an async one-shot fix +/// API. We only need "where is the user right now so I can center the +/// map" — not continuous tracking, not background updates. The Live tab +/// calls `requestOneShotLocation()` once on appear and is done. +/// +/// Permission state is published so the view can decide between +/// (a) centering on the user, (b) restoring last viewed region, or +/// (c) showing a continental fallback. +@MainActor +final class LocationService: NSObject, ObservableObject { + static let shared = LocationService() + + @Published private(set) var authorization: CLAuthorizationStatus + @Published private(set) var lastKnown: CLLocationCoordinate2D? + + private let manager = CLLocationManager() + private var pendingContinuations: [CheckedContinuation] = [] + + private override init() { + self.authorization = manager.authorizationStatus + super.init() + manager.delegate = self + manager.desiredAccuracy = kCLLocationAccuracyHundredMeters + } + + /// Ask the OS for permission (if needed) and return the next location + /// fix, or nil if the user denied access. Resolves once — does not + /// stay subscribed. + func requestOneShotLocation() async -> CLLocationCoordinate2D? { + switch authorization { + case .denied, .restricted: + return nil + case .notDetermined: + manager.requestWhenInUseAuthorization() + // Fall through to request location after the delegate flips + // auth — we capture the continuation now and resume it once + // location/auth-denied lands. + default: + break + } + return await withCheckedContinuation { cont in + pendingContinuations.append(cont) + // requestLocation is a one-shot; safe to call before auth has + // been granted — CL will queue it and fire after grant. + manager.requestLocation() + } + } + + private func resumeAll(with value: CLLocationCoordinate2D?) { + let waiters = pendingContinuations + pendingContinuations.removeAll() + for c in waiters { c.resume(returning: value) } + } +} + +extension LocationService: CLLocationManagerDelegate { + nonisolated func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) { + Task { @MainActor in + self.authorization = manager.authorizationStatus + if manager.authorizationStatus == .denied || manager.authorizationStatus == .restricted { + self.resumeAll(with: nil) + } + } + } + + nonisolated func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) { + guard let loc = locations.last else { return } + Task { @MainActor in + self.lastKnown = loc.coordinate + self.resumeAll(with: loc.coordinate) + } + } + + nonisolated func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) { + Task { @MainActor in + self.resumeAll(with: nil) + } + } +} diff --git a/Flights/Views/LiveFlightsView.swift b/Flights/Views/LiveFlightsView.swift index 37f1afc..a227d34 100644 --- a/Flights/Views/LiveFlightsView.swift +++ b/Flights/Views/LiveFlightsView.swift @@ -73,6 +73,18 @@ struct LiveFlightsView: View { /// every time annotations re-render) don't fire fresh OpenSky calls. @State private var lastFetchedBoundingBox: (latMin: Double, lonMin: Double, latMax: Double, lonMax: Double)? + /// Total matches against the active filters (pre-cap). The map only + /// renders `cachedFilteredAircraft`; the footer uses this to surface + /// the "Showing N of M" message when the zoom cap clips the list. + @State private var filteredTotal: Int = 0 + + /// Set after the first camera change that meaningfully moves away + /// from the initial region. While this is false, an incoming location + /// fix can re-center the map on the user; after it flips true, the + /// user has expressed intent and we leave their pan alone. + @State private var userHasInteracted: Bool = false + @State private var initialRegionCenter: CLLocationCoordinate2D? + enum ActiveSheet: Identifiable { case aircraft(LiveAircraft) case settings @@ -175,6 +187,18 @@ struct LiveFlightsView: View { .mapStyle(.standard(elevation: .flat)) .onMapCameraChange(frequency: .onEnd) { context in visibleRegion = context.region + if let initial = initialRegionCenter { + let dLat = abs(context.region.center.latitude - initial.latitude) + let dLon = abs(context.region.center.longitude - initial.longitude) + if dLat > 0.1 || dLon > 0.1 { + userHasInteracted = true + } + } + Self.saveRegion(context.region) + // Span change shifts the cap, so the visible set might need + // to grow/shrink even when the underlying aircraft list is + // unchanged. + rebuildFilteredAircraft() Task { await refreshIfRegionChanged() } } } @@ -315,7 +339,7 @@ struct LiveFlightsView: View { Image(systemName: "antenna.radiowaves.left.and.right") .foregroundStyle(.white) } - Text("\(filteredAircraft.count) aircraft") + Text(countLabel) .font(.subheadline.weight(.semibold)) .foregroundStyle(.white) if let last = lastFetchAt { @@ -354,6 +378,16 @@ struct LiveFlightsView: View { /// cached snapshot; rebuilds happen via the .onChange handlers above. private var filteredAircraft: [LiveAircraft] { cachedFilteredAircraft } + /// Footer text. Renders "Showing N of M" when the zoom cap is + /// clipping the visible set, else "N aircraft". + private var countLabel: String { + let shown = cachedFilteredAircraft.count + if filteredTotal > shown { + return "Showing \(shown) of \(filteredTotal)" + } + return "\(shown) aircraft" + } + private func rebuildFilteredAircraft() { let s = searchText.trimmingCharacters(in: .whitespacesAndNewlines).uppercased() let airlines = selectedAirlineICAO @@ -361,7 +395,7 @@ struct LiveFlightsView: View { let band = selectedAltitudeBand let hideGround = hideOnGround - cachedFilteredAircraft = aircraft.filter { ac in + let filtered = aircraft.filter { ac in if hideGround && ac.onGround { return false } if !airlines.isEmpty { guard let code = ac.airlineICAO, airlines.contains(code) else { return false } @@ -378,6 +412,83 @@ struct LiveFlightsView: View { } return true } + + filteredTotal = filtered.count + + // Cap policy: any active filter bypasses the cap so the user + // always sees every match for their query. With no filter, we + // tie the visible count to the zoom level so a zoomed-out view + // doesn't spew 800+ pins onto the map. + let hasFilter = !airlines.isEmpty || !types.isEmpty || band != nil || hideGround || !s.isEmpty + let cap = Self.capForSpan(visibleRegion?.span) + if hasFilter || filtered.count <= cap { + cachedFilteredAircraft = filtered + return + } + // Cap by distance to map center — most users care about what's + // overhead first. Squared distance is fine for sorting. + if let center = visibleRegion?.center { + cachedFilteredAircraft = filtered + .map { ($0, Self.squaredDistance($0.coordinate, to: center)) } + .sorted { $0.1 < $1.1 } + .prefix(cap) + .map { $0.0 } + } else { + cachedFilteredAircraft = Array(filtered.prefix(cap)) + } + } + + /// Target visible count for a given map span. Empirically tuned so + /// the map doesn't feel sparse when zoomed out yet stays smooth. + /// Below ~2° (≈city/metro) we don't cap at all. + private static func capForSpan(_ span: MKCoordinateSpan?) -> Int { + guard let span else { return 150 } + let maxDelta = max(span.latitudeDelta, span.longitudeDelta) + switch maxDelta { + case ..<2: return .max + case ..<8: return 100 + case ..<25: return 150 + default: return 200 + } + } + + private static func squaredDistance(_ a: CLLocationCoordinate2D, to b: CLLocationCoordinate2D) -> Double { + let dLat = a.latitude - b.latitude + let dLon = a.longitude - b.longitude + return dLat * dLat + dLon * dLon + } + + // MARK: - Region persistence + + private struct SavedRegion: Codable { + let lat: Double + let lon: Double + let latDelta: Double + let lonDelta: Double + } + + private static let savedRegionKey = "live_flights.saved_region" + + private static func loadSavedRegion() -> MKCoordinateRegion? { + guard let data = UserDefaults.standard.data(forKey: savedRegionKey), + let saved = try? JSONDecoder().decode(SavedRegion.self, from: data) + else { return nil } + return MKCoordinateRegion( + center: CLLocationCoordinate2D(latitude: saved.lat, longitude: saved.lon), + span: MKCoordinateSpan(latitudeDelta: saved.latDelta, longitudeDelta: saved.lonDelta) + ) + } + + private static func saveRegion(_ r: MKCoordinateRegion) { + let payload = SavedRegion( + lat: r.center.latitude, + lon: r.center.longitude, + latDelta: r.span.latitudeDelta, + lonDelta: r.span.longitudeDelta + ) + if let data = try? JSONEncoder().encode(payload) { + UserDefaults.standard.set(data, forKey: savedRegionKey) + } } struct AirlineFilterItem: Hashable { @@ -445,12 +556,35 @@ struct LiveFlightsView: View { /// symptom. private func autoRefreshLoop() async { if visibleRegion == nil { - let initial = MKCoordinateRegion( + // Initial region cascade: + // 1. Restore the last region the user saw, if we have one + // 2. Otherwise fall back to a continental US view + // Either way we kick off a one-shot location request in + // parallel. If the user grants location *and* hasn't panned + // by the time the fix lands, we animate to a city-level + // view centered on them. + let initial = Self.loadSavedRegion() ?? MKCoordinateRegion( center: CLLocationCoordinate2D(latitude: 39.5, longitude: -98.0), span: MKCoordinateSpan(latitudeDelta: 30, longitudeDelta: 50) ) position = .region(initial) visibleRegion = initial + initialRegionCenter = initial.center + Task { + if let coord = await LocationService.shared.requestOneShotLocation(), + !userHasInteracted { + let userRegion = MKCoordinateRegion( + center: coord, + span: MKCoordinateSpan(latitudeDelta: 0.6, longitudeDelta: 0.6) + ) + withAnimation(.easeInOut(duration: 0.6)) { + position = .region(userRegion) + } + visibleRegion = userRegion + initialRegionCenter = userRegion.center + Self.saveRegion(userRegion) + } + } } await refreshNow() while !Task.isCancelled {