Live map: center on user, persist region, zoom-based aircraft cap

- 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 <noreply@anthropic.com>
This commit is contained in:
Trey T
2026-05-27 07:37:34 -05:00
parent de7a70b198
commit dee6df1ac6
3 changed files with 225 additions and 3 deletions
+6
View File
@@ -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 = "<group>"; };
LVBB000BBBB000BBBB000002 /* LiveFilterPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveFilterPicker.swift; sourceTree = "<group>"; };
LVCC000CCCC000CCCC000002 /* aircraftDB.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = aircraftDB.json; sourceTree = "<group>"; };
LVDD000DDDD000DDDD000002 /* LocationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationService.swift; sourceTree = "<group>"; };
/* 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 = "<group>";
@@ -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;
+82
View File
@@ -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<CLLocationCoordinate2D?, Never>] = []
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)
}
}
}
+137 -3
View File
@@ -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 {