de7a70b198
Targeted every hitch in the live-tracker flow:
1) Async DB loading. AircraftDatabase (1.5MB JSON) and
AircraftRegistry (200KB JSON) were parsing synchronously on the
main thread the first time anything touched them — typically when
the user first opened the Live tab. Both now bootstrap with a
safe fallback in init() and parse the full JSON on a background
Task at app launch (FlightsApp.init calls preload() on both).
Reads are NSLock-guarded with lock.withLock {} (async-safe).
2) Crossfade suppression on refresh. The 15s auto-refresh swaps the
`aircraft` array wholesale, which made SwiftUI try to crossfade
every annotation. Wrapped the assignment in a Transaction with
disablesAnimations = true so the swap is instant.
3) Cached filtered aircraft. `filteredAircraft` was a computed
property running through every aircraft on every body re-render
(e.g. while a sheet was animating in). Moved to @State,
recomputed via .onChange handlers on each dependency.
4) Lighter pin view. AircraftPin no longer carries the full
LiveAircraft struct or contains a conditional ZStack — just the
minimal {tint, rotation, isSelected} props, conforms to
Equatable so SwiftUI can skip diffing identical pins, and uses
.animation(nil) on the tint to prevent color crossfades during
refresh.
5) Coarse relative-time bucketing. The footer's "updated 5s ago"
text was ticking every second, which dirtied the footer subtree
on every body pass. Now snaps to {just now, <30s ago, <1m ago,
Nm ago} — no second-by-second ticks.
Net effect: tab opening is instant (DBs are warm), refreshes don't
flicker, filter sheet animation is smooth, map panning isn't fighting
view-tree rebuilds.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
640 lines
25 KiB
Swift
640 lines
25 KiB
Swift
import SwiftUI
|
||
import MapKit
|
||
import CoreLocation
|
||
|
||
struct LiveFlightsView: View {
|
||
let openSky: OpenSkyClient
|
||
let routeExplorer: RouteExplorerClient
|
||
let database: AirportDatabase
|
||
|
||
// MARK: - Map state
|
||
|
||
@State private var position: MapCameraPosition = .automatic
|
||
@State private var visibleRegion: MKCoordinateRegion?
|
||
|
||
// MARK: - Data state
|
||
|
||
@State private var aircraft: [LiveAircraft] = []
|
||
@State private var lastFetchAt: Date?
|
||
@State private var isLoading = false
|
||
@State private var error: String?
|
||
|
||
// MARK: - Filters
|
||
|
||
@State private var searchText: String = ""
|
||
@State private var selectedAirlineICAO: Set<String> = []
|
||
@State private var selectedTypeCodes: Set<String> = []
|
||
@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,
|
||
// so SwiftUI only ever sees one .sheet modifier on this view. Two
|
||
// stacked .sheet modifiers fight each other and produce the "tap-the-
|
||
// plane-freezes-the-app" symptom.
|
||
|
||
@State private var activeSheet: ActiveSheet?
|
||
@State private var selectedTrack: AircraftTrack?
|
||
|
||
// Cached filter-menu items — recomputed only when `aircraft` changes.
|
||
// Computing them on every body re-render caused the menu-tap freeze
|
||
// (each refresh fed the airlines registry an N×M lookup on the main
|
||
// thread).
|
||
@State private var cachedAirlineItems: [AirlineFilterItem] = []
|
||
@State private var cachedTypeItems: [TypeFilterItem] = []
|
||
|
||
/// Pre-filtered aircraft snapshot. Recomputed only when `aircraft` or
|
||
/// any filter state changes. Caching here avoids running the filter
|
||
/// loop on every body re-render (e.g. when a sheet animates in).
|
||
@State private var cachedFilteredAircraft: [LiveAircraft] = []
|
||
|
||
/// 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)
|
||
case settings
|
||
case airlinePicker
|
||
case typePicker
|
||
var id: String {
|
||
switch self {
|
||
case .aircraft(let a): return "ac-\(a.icao24)"
|
||
case .settings: return "settings"
|
||
case .airlinePicker: return "airline"
|
||
case .typePicker: return "type"
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Aircraft currently selected (if any), unwrapped from `activeSheet`.
|
||
private var selectedAircraft: LiveAircraft? {
|
||
if case .aircraft(let a) = activeSheet { return a }
|
||
return nil
|
||
}
|
||
|
||
// Refresh interval — paired with the rate-limit guard. Anonymous OpenSky
|
||
// is 100/day so we keep the auto-refresh tab-conservative.
|
||
private static let refreshInterval: TimeInterval = 15
|
||
|
||
var body: some View {
|
||
mapLayer
|
||
.safeAreaInset(edge: .top, spacing: 0) { topFilterBar }
|
||
.safeAreaInset(edge: .bottom, spacing: 0) { bottomStatusBar }
|
||
.task { await autoRefreshLoop() }
|
||
.task(id: selectedAircraft?.icao24) {
|
||
await loadTrackForSelection()
|
||
}
|
||
.onChange(of: aircraft) { _, _ in
|
||
rebuildFilterItems()
|
||
rebuildFilteredAircraft()
|
||
}
|
||
.onChange(of: selectedAirlineICAO) { _, _ in rebuildFilteredAircraft() }
|
||
.onChange(of: selectedTypeCodes) { _, _ in rebuildFilteredAircraft() }
|
||
.onChange(of: selectedAltitudeBand) { _, _ in rebuildFilteredAircraft() }
|
||
.onChange(of: hideOnGround) { _, _ in rebuildFilteredAircraft() }
|
||
.onChange(of: searchText) { _, _ in rebuildFilteredAircraft() }
|
||
.sheet(item: $activeSheet) { sheet in
|
||
switch sheet {
|
||
case .aircraft(let ac):
|
||
LiveFlightDetailSheet(
|
||
aircraft: ac,
|
||
openSky: openSky,
|
||
routeExplorer: routeExplorer,
|
||
database: database
|
||
)
|
||
.presentationDetents([.medium, .large])
|
||
.presentationDragIndicator(.visible)
|
||
case .settings:
|
||
OpenSkySettingsView()
|
||
case .airlinePicker:
|
||
LiveFilterPicker(
|
||
title: "Airline",
|
||
items: cachedAirlineItems.map { .init(id: $0.icao, label: $0.name, count: $0.count) },
|
||
selection: $selectedAirlineICAO
|
||
)
|
||
case .typePicker:
|
||
LiveFilterPicker(
|
||
title: "Aircraft Type",
|
||
items: cachedTypeItems.map { .init(id: $0.code, label: $0.label, count: $0.count) },
|
||
selection: $selectedTypeCodes
|
||
)
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Map
|
||
|
||
private var mapLayer: some View {
|
||
Map(position: $position) {
|
||
// Trail polyline for the currently selected aircraft.
|
||
if let track = selectedTrack {
|
||
let coords = track.path.map {
|
||
CLLocationCoordinate2D(latitude: $0.latitude, longitude: $0.longitude)
|
||
}
|
||
MapPolyline(coordinates: coords)
|
||
.stroke(FlightTheme.accent, lineWidth: 3)
|
||
}
|
||
|
||
ForEach(filteredAircraft) { ac in
|
||
// Pre-compute the pin's inputs outside the closure so the
|
||
// SwiftUI builder doesn't re-derive them on every diff.
|
||
let tint = aircraftTint(for: ac)
|
||
let rotation = Double(ac.heading ?? 0) - 45 // SF airplane symbol points up-right
|
||
let selected = selectedAircraft?.id == ac.id
|
||
Annotation(ac.trimmedCallsign ?? ac.icao24, coordinate: ac.coordinate) {
|
||
AircraftPin(tint: tint, headingMinus45: rotation, isSelected: selected)
|
||
.onTapGesture {
|
||
activeSheet = .aircraft(ac)
|
||
}
|
||
}
|
||
.annotationTitles(.hidden)
|
||
}
|
||
}
|
||
.mapStyle(.standard(elevation: .flat))
|
||
.onMapCameraChange(frequency: .onEnd) { context in
|
||
visibleRegion = context.region
|
||
Task { await refreshIfRegionChanged() }
|
||
}
|
||
}
|
||
|
||
/// Fetches the trail for whichever aircraft is currently selected.
|
||
/// Cleared automatically on deselection. Race-guarded.
|
||
private func loadTrackForSelection() async {
|
||
guard let selected = selectedAircraft else {
|
||
selectedTrack = nil
|
||
return
|
||
}
|
||
let track = await openSky.track(icao24: selected.icao24)
|
||
if selectedAircraft?.icao24 == selected.icao24 {
|
||
selectedTrack = track
|
||
}
|
||
}
|
||
|
||
// MARK: - Top filter bar (lives inside the safe-area inset, never under
|
||
// the nav title or the tab bar)
|
||
|
||
private var topFilterBar: some View {
|
||
VStack(spacing: 8) {
|
||
HStack(spacing: 8) {
|
||
Image(systemName: "magnifyingglass")
|
||
.foregroundStyle(FlightTheme.textSecondary)
|
||
TextField("Search callsign or flight (e.g. AA2178)", text: $searchText)
|
||
.autocorrectionDisabled()
|
||
.textInputAutocapitalization(.characters)
|
||
.onSubmit { centerOnSearchMatch() }
|
||
|
||
if !searchText.isEmpty {
|
||
Button { searchText = "" } label: {
|
||
Image(systemName: "xmark.circle.fill")
|
||
.foregroundStyle(FlightTheme.textSecondary)
|
||
}
|
||
.buttonStyle(.plain)
|
||
}
|
||
}
|
||
.padding(.horizontal, 14)
|
||
.padding(.vertical, 10)
|
||
.background(FlightTheme.cardBackground)
|
||
.clipShape(Capsule())
|
||
.shadow(color: FlightTheme.cardShadow, radius: 6, y: 2)
|
||
|
||
ScrollView(.horizontal, showsIndicators: false) {
|
||
HStack(spacing: 8) {
|
||
FilterChip(
|
||
label: hideOnGround ? "Airborne only" : "Include ground",
|
||
systemImage: hideOnGround ? "airplane" : "airplane.circle",
|
||
isActive: hideOnGround
|
||
) { hideOnGround.toggle() }
|
||
|
||
Button { activeSheet = .airlinePicker } label: {
|
||
FilterChipLabel(
|
||
label: selectedAirlineICAO.isEmpty
|
||
? "Airline"
|
||
: "Airline · \(selectedAirlineICAO.count)",
|
||
systemImage: "building.2",
|
||
isActive: !selectedAirlineICAO.isEmpty
|
||
)
|
||
}
|
||
.buttonStyle(.plain)
|
||
|
||
Button { activeSheet = .typePicker } label: {
|
||
FilterChipLabel(
|
||
label: selectedTypeCodes.isEmpty
|
||
? "Type"
|
||
: "Type · \(selectedTypeCodes.count)",
|
||
systemImage: "airplane.departure",
|
||
isActive: !selectedTypeCodes.isEmpty
|
||
)
|
||
}
|
||
.buttonStyle(.plain)
|
||
|
||
Menu {
|
||
let counts = altitudeBandCounts
|
||
ForEach(AltitudeBand.allCases) { band in
|
||
let count = counts[band] ?? 0
|
||
Button {
|
||
selectedAltitudeBand = (selectedAltitudeBand == band) ? nil : band
|
||
} label: {
|
||
let label = "\(band.rawValue) (\(count))"
|
||
if selectedAltitudeBand == band {
|
||
Label(label, systemImage: "checkmark")
|
||
} else {
|
||
Text(label)
|
||
}
|
||
}
|
||
}
|
||
if selectedAltitudeBand != nil {
|
||
Button("Clear", role: .destructive) { selectedAltitudeBand = nil }
|
||
}
|
||
} label: {
|
||
FilterChipLabel(
|
||
label: selectedAltitudeBand?.rawValue ?? "Altitude",
|
||
systemImage: "arrow.up.and.down",
|
||
isActive: selectedAltitudeBand != nil
|
||
)
|
||
}
|
||
|
||
if !selectedAirlineICAO.isEmpty || !selectedTypeCodes.isEmpty || selectedAltitudeBand != nil || hideOnGround {
|
||
Button {
|
||
selectedAirlineICAO.removeAll()
|
||
selectedTypeCodes.removeAll()
|
||
selectedAltitudeBand = nil
|
||
hideOnGround = false
|
||
} label: {
|
||
FilterChipLabel(label: "Reset", systemImage: "arrow.counterclockwise", isActive: false)
|
||
}
|
||
}
|
||
}
|
||
.padding(.horizontal, 12)
|
||
}
|
||
}
|
||
.padding(.horizontal, 12)
|
||
.padding(.top, 8)
|
||
.padding(.bottom, 8)
|
||
.background(.ultraThinMaterial)
|
||
}
|
||
|
||
// MARK: - Bottom status bar (count, refresh, gear)
|
||
|
||
private var bottomStatusBar: some View {
|
||
VStack(spacing: 6) {
|
||
if let error {
|
||
Text(error)
|
||
.font(.caption)
|
||
.foregroundStyle(.white)
|
||
.padding(.horizontal, 12)
|
||
.padding(.vertical, 6)
|
||
.background(FlightTheme.cancelled, in: Capsule())
|
||
}
|
||
|
||
HStack(spacing: 12) {
|
||
if isLoading {
|
||
ProgressView().tint(.white)
|
||
} else {
|
||
Image(systemName: "antenna.radiowaves.left.and.right")
|
||
.foregroundStyle(.white)
|
||
}
|
||
Text("\(filteredAircraft.count) aircraft")
|
||
.font(.subheadline.weight(.semibold))
|
||
.foregroundStyle(.white)
|
||
if let last = lastFetchAt {
|
||
Text("· updated \(relativeTime(last))")
|
||
.font(.caption)
|
||
.foregroundStyle(.white.opacity(0.8))
|
||
}
|
||
Spacer()
|
||
Button {
|
||
Task { await refreshNow() }
|
||
} label: {
|
||
Image(systemName: "arrow.clockwise")
|
||
.foregroundStyle(.white)
|
||
}
|
||
.disabled(isLoading)
|
||
.opacity(isLoading ? 0.3 : 1)
|
||
|
||
Button {
|
||
activeSheet = .settings
|
||
} label: {
|
||
Image(systemName: "gearshape")
|
||
.foregroundStyle(.white)
|
||
}
|
||
}
|
||
.padding(.horizontal, 16)
|
||
.padding(.vertical, 12)
|
||
.background(Color.black.opacity(0.7), in: Capsule())
|
||
}
|
||
.padding(.horizontal, 12)
|
||
.padding(.bottom, 8)
|
||
}
|
||
|
||
// MARK: - Derived
|
||
|
||
/// Quick read accessor used by the rendering layer. Always returns the
|
||
/// cached snapshot; rebuilds happen via the .onChange handlers above.
|
||
private var filteredAircraft: [LiveAircraft] { cachedFilteredAircraft }
|
||
|
||
private func rebuildFilteredAircraft() {
|
||
let s = searchText.trimmingCharacters(in: .whitespacesAndNewlines).uppercased()
|
||
let airlines = selectedAirlineICAO
|
||
let types = selectedTypeCodes
|
||
let band = selectedAltitudeBand
|
||
let hideGround = hideOnGround
|
||
|
||
cachedFilteredAircraft = aircraft.filter { ac in
|
||
if hideGround && ac.onGround { return false }
|
||
if !airlines.isEmpty {
|
||
guard let code = ac.airlineICAO, airlines.contains(code) else { return false }
|
||
}
|
||
if !types.isEmpty {
|
||
guard let tc = ac.typeCode, types.contains(tc) else { return false }
|
||
}
|
||
if let band {
|
||
guard let alt = ac.altitudeFeet, band.contains(alt) else { return false }
|
||
}
|
||
if !s.isEmpty {
|
||
let cs = ac.trimmedCallsign?.uppercased() ?? ""
|
||
if !cs.contains(s) { return false }
|
||
}
|
||
return true
|
||
}
|
||
}
|
||
|
||
struct AirlineFilterItem: Hashable {
|
||
let icao: String
|
||
let name: String
|
||
let count: Int
|
||
var label: String { "\(name) (\(count))" }
|
||
}
|
||
|
||
struct TypeFilterItem: Hashable {
|
||
let code: String
|
||
let label: String // e.g. "Boeing 737-800 · B738"
|
||
let count: Int
|
||
}
|
||
|
||
/// 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 types: [String: Int] = [:]
|
||
for ac in aircraft {
|
||
if let code = ac.airlineICAO {
|
||
airlines[code, default: 0] += 1
|
||
}
|
||
if let tc = ac.typeCode {
|
||
types[tc, default: 0] += 1
|
||
}
|
||
}
|
||
cachedAirlineItems = airlines.map { (icao, count) in
|
||
AirlineFilterItem(
|
||
icao: icao,
|
||
name: AircraftRegistry.shared.displayName(icao: icao),
|
||
count: count
|
||
)
|
||
}.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
|
||
|
||
cachedTypeItems = types.map { (code, count) in
|
||
let friendly = AircraftDatabase.shared.displayName(forTypeCode: code)
|
||
// If the friendly name differs from the raw code, show both;
|
||
// otherwise just the code so we don't render "B738 · B738".
|
||
let label = friendly == code ? code : "\(friendly) · \(code)"
|
||
return TypeFilterItem(code: code, label: label, count: count)
|
||
}.sorted { $0.label.localizedCaseInsensitiveCompare($1.label) == .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
|
||
|
||
/// 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),
|
||
span: MKCoordinateSpan(latitudeDelta: 30, longitudeDelta: 50)
|
||
)
|
||
position = .region(initial)
|
||
visibleRegion = initial
|
||
}
|
||
await refreshNow()
|
||
while !Task.isCancelled {
|
||
try? await Task.sleep(nanoseconds: UInt64(Self.refreshInterval * 1_000_000_000))
|
||
await refreshNow()
|
||
}
|
||
}
|
||
|
||
/// 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()
|
||
}
|
||
|
||
private func refreshNow() async {
|
||
guard !isLoading, let r = visibleRegion else { return }
|
||
isLoading = true
|
||
defer { isLoading = false }
|
||
|
||
let bb = boundingBox(of: r)
|
||
do {
|
||
let results = try await openSky.states(
|
||
latMin: bb.latMin, lonMin: bb.lonMin, latMax: bb.latMax, lonMax: bb.lonMax
|
||
)
|
||
// Suppress the implicit crossfade SwiftUI would apply to the
|
||
// map annotations when the underlying array swaps wholesale.
|
||
var tx = Transaction()
|
||
tx.disablesAnimations = true
|
||
withTransaction(tx) {
|
||
aircraft = results
|
||
}
|
||
lastFetchAt = Date()
|
||
lastFetchedBoundingBox = bb
|
||
error = nil
|
||
} catch {
|
||
self.error = (error as? OpenSkyClient.ClientError)?.errorDescription
|
||
?? error.localizedDescription
|
||
if case OpenSkyClient.ClientError.throttled = error {
|
||
// 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) -> (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 (
|
||
latMin: max(-90, lat - dLat),
|
||
lonMin: max(-180, lon - dLon),
|
||
latMax: min( 90, lat + dLat),
|
||
lonMax: min( 180, lon + dLon)
|
||
)
|
||
}
|
||
|
||
// MARK: - Search / selection helpers
|
||
|
||
private func centerOnSearchMatch() {
|
||
let s = searchText.trimmingCharacters(in: .whitespacesAndNewlines).uppercased()
|
||
guard let match = aircraft.first(where: {
|
||
($0.trimmedCallsign?.uppercased() ?? "").contains(s)
|
||
}) else { return }
|
||
activeSheet = .aircraft(match)
|
||
position = .region(MKCoordinateRegion(
|
||
center: match.coordinate,
|
||
span: MKCoordinateSpan(latitudeDelta: 4, longitudeDelta: 6)
|
||
))
|
||
}
|
||
|
||
private func toggle<T: Hashable>(_ set: inout Set<T>, _ value: T) {
|
||
if set.contains(value) { set.remove(value) } else { set.insert(value) }
|
||
}
|
||
|
||
private func relativeTime(_ d: Date) -> String {
|
||
let secs = Int(Date().timeIntervalSince(d))
|
||
// Snap to coarse buckets so the footer text doesn't tick every
|
||
// second (which would force the footer subtree to re-render
|
||
// every body pass that happens to land on a different second).
|
||
if secs < 5 { return "just now" }
|
||
if secs < 30 { return "<30s ago" }
|
||
if secs < 60 { return "<1m ago" }
|
||
return "\(secs / 60)m ago"
|
||
}
|
||
}
|
||
|
||
// MARK: - Aircraft pin
|
||
|
||
/// Per-aircraft map pin. Kept deliberately minimal — every annotation is
|
||
/// a SwiftUI view in the map's content tree, so view-tree depth × N pins
|
||
/// directly affects scroll/pan performance. Equatable so SwiftUI can
|
||
/// skip diffing identical pins on re-renders.
|
||
private struct AircraftPin: View, Equatable {
|
||
let tint: Color
|
||
let headingMinus45: Double
|
||
let isSelected: Bool
|
||
|
||
var body: some View {
|
||
Image(systemName: "airplane")
|
||
.font(.system(size: 14, weight: .bold))
|
||
.foregroundStyle(.white)
|
||
.padding(6)
|
||
.background(Circle().fill(tint))
|
||
.overlay(
|
||
Circle()
|
||
.stroke(.white, lineWidth: isSelected ? 2.5 : 0)
|
||
)
|
||
.rotationEffect(.degrees(headingMinus45))
|
||
.scaleEffect(isSelected ? 1.3 : 1)
|
||
.animation(nil, value: tint)
|
||
.contentShape(Rectangle())
|
||
}
|
||
|
||
static func == (lhs: AircraftPin, rhs: AircraftPin) -> Bool {
|
||
lhs.tint == rhs.tint
|
||
&& lhs.headingMinus45 == rhs.headingMinus45
|
||
&& lhs.isSelected == rhs.isSelected
|
||
}
|
||
}
|
||
|
||
private func aircraftTint(for ac: LiveAircraft) -> Color {
|
||
if ac.onGround { return FlightTheme.textTertiary }
|
||
switch ac.verticalState {
|
||
case .climbing: return FlightTheme.onTime
|
||
case .descending: return FlightTheme.delayed
|
||
case .level: return FlightTheme.accent
|
||
}
|
||
}
|
||
|
||
// MARK: - Filter chips
|
||
|
||
private struct FilterChip: View {
|
||
let label: String
|
||
let systemImage: String
|
||
let isActive: Bool
|
||
let action: () -> Void
|
||
|
||
var body: some View {
|
||
Button(action: action) {
|
||
FilterChipLabel(label: label, systemImage: systemImage, isActive: isActive)
|
||
}
|
||
.buttonStyle(.plain)
|
||
}
|
||
}
|
||
|
||
private struct FilterChipLabel: View {
|
||
let label: String
|
||
let systemImage: String
|
||
let isActive: Bool
|
||
|
||
var body: some View {
|
||
HStack(spacing: 6) {
|
||
Image(systemName: systemImage)
|
||
.font(.caption)
|
||
Text(label)
|
||
.font(.caption.weight(.semibold))
|
||
}
|
||
.foregroundStyle(isActive ? .white : FlightTheme.textPrimary)
|
||
.padding(.horizontal, 12)
|
||
.padding(.vertical, 8)
|
||
.background(
|
||
Capsule()
|
||
.fill(isActive ? FlightTheme.accent : FlightTheme.cardBackground)
|
||
)
|
||
.shadow(color: FlightTheme.cardShadow, radius: 4, y: 1)
|
||
}
|
||
}
|