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