Live tab: restore type filter via bundled aircraft DB + sheet pickers
Two issues:
1) Menu list stutters. SwiftUI's Menu renders all rows in a
non-virtualized popover. With 30+ airlines and Buttons containing
Labels, scrolling jank starts immediately. Switched the airline +
type filters to a sheet-based picker (LiveFilterPicker) backed by
a real List — virtualized scrolling, plus a search bar that
filters as you type.
2) Type filter was non-functional because OpenSky's anonymous tier
returns ADS-B emitter category as null for most aircraft.
Replaced with a real type-code lookup: bundled aircraftDB.json
(1.5MB slimmed copy of OpenSky's aircraft metadata, 100k
commercial-class airframes, filtered to skip GA / gliders /
ultralights). AircraftDatabase.shared.typeCode(forICAO24:)
returns the ICAO type designator (B738, A21N, etc.).
AircraftDatabase.displayName(forTypeCode:) maps the top ~130
common codes to friendly names ("B738" → "Boeing 737-800").
LiveAircraft now exposes a `typeCode` computed property that
indexes into the DB. The type filter chip → sheet flow uses the
same LiveFilterPicker as airlines, with multi-select + counts +
search.
Both pickers keep the "Selected (N)" group pinned at the top so
the user always sees what they have active without scrolling.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -55,6 +55,9 @@
|
||||
LV7700007777000077770001 /* OpenSkyCredentials.swift in Sources */ = {isa = PBXBuildFile; fileRef = LV7700007777000077770002 /* OpenSkyCredentials.swift */; };
|
||||
LV8800008888000088880001 /* OpenSkySettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = LV8800008888000088880002 /* OpenSkySettingsView.swift */; };
|
||||
LV9900009999000099990001 /* airlines.json in Resources */ = {isa = PBXBuildFile; fileRef = LV9900009999000099990002 /* airlines.json */; };
|
||||
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 */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
@@ -118,6 +121,9 @@
|
||||
LV7700007777000077770002 /* OpenSkyCredentials.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenSkyCredentials.swift; sourceTree = "<group>"; };
|
||||
LV8800008888000088880002 /* OpenSkySettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenSkySettingsView.swift; sourceTree = "<group>"; };
|
||||
LV9900009999000099990002 /* airlines.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = airlines.json; sourceTree = "<group>"; };
|
||||
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>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
@@ -155,6 +161,7 @@
|
||||
LV5500005555000055550002 /* LiveFlightDetailSheet.swift */,
|
||||
LV6600006666000066660002 /* RootView.swift */,
|
||||
LV8800008888000088880002 /* OpenSkySettingsView.swift */,
|
||||
LVBB000BBBB000BBBB000002 /* LiveFilterPicker.swift */,
|
||||
AA5555555555555555555555 /* Styles */,
|
||||
AA6666666666666666666666 /* Components */,
|
||||
);
|
||||
@@ -190,6 +197,7 @@
|
||||
D9E26DCDE2904210ABCA7855 /* Assets.xcassets */,
|
||||
53F457716F0642BDBCBA93EA /* airports.json */,
|
||||
LV9900009999000099990002 /* airlines.json */,
|
||||
LVCC000CCCC000CCCC000002 /* aircraftDB.json */,
|
||||
);
|
||||
path = Flights;
|
||||
sourceTree = "<group>";
|
||||
@@ -234,6 +242,7 @@
|
||||
LV2200002222000022220002 /* OpenSkyClient.swift */,
|
||||
LV3300003333000033330002 /* AircraftRegistry.swift */,
|
||||
LV7700007777000077770002 /* OpenSkyCredentials.swift */,
|
||||
LVAA000AAAA000AAAA000002 /* AircraftDatabase.swift */,
|
||||
);
|
||||
path = Services;
|
||||
sourceTree = "<group>";
|
||||
@@ -348,6 +357,7 @@
|
||||
F79789179F4443FD859BDEF0 /* Assets.xcassets in Resources */,
|
||||
80D2BC95002A4931B3C10B4C /* airports.json in Resources */,
|
||||
LV9900009999000099990001 /* airlines.json in Resources */,
|
||||
LVCC000CCCC000CCCC000001 /* aircraftDB.json in Resources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -402,6 +412,8 @@
|
||||
LV6600006666000066660001 /* RootView.swift in Sources */,
|
||||
LV7700007777000077770001 /* OpenSkyCredentials.swift in Sources */,
|
||||
LV8800008888000088880001 /* OpenSkySettingsView.swift in Sources */,
|
||||
LVAA000AAAA000AAAA000001 /* AircraftDatabase.swift in Sources */,
|
||||
LVBB000BBBB000BBBB000001 /* LiveFilterPicker.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
|
||||
@@ -69,6 +69,14 @@ struct LiveAircraft: Identifiable, Hashable, Sendable {
|
||||
return .level
|
||||
}
|
||||
|
||||
/// ICAO aircraft type designator (e.g. "B738", "A21N") looked up from
|
||||
/// the bundled aircraft DB. Nil if the airframe isn't in our slimmed
|
||||
/// commercial-class DB — typically true for GA / experimental / cargo
|
||||
/// freight without a public registration.
|
||||
var typeCode: String? {
|
||||
AircraftDatabase.shared.typeCode(forICAO24: icao24)
|
||||
}
|
||||
|
||||
var trimmedCallsign: String? {
|
||||
let s = (callsign ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return s.isEmpty ? nil : s
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
import Foundation
|
||||
|
||||
/// Look up the ICAO aircraft type designator (e.g. "B738", "A21N") for a
|
||||
/// given 24-bit ICAO transponder address.
|
||||
///
|
||||
/// Backed by `aircraftDB.json` — slimmed copy of OpenSky's aircraft
|
||||
/// metadata, ~100k commercial-class entries, ~1.5MB on disk. Loads on
|
||||
/// first access and stays in memory for the rest of the session.
|
||||
///
|
||||
/// Used to power the "Aircraft type" filter on the live map. The ADS-B
|
||||
/// emitter category that OpenSky's anonymous `/states/all` returns is
|
||||
/// almost always null, so the type designator from the DB is what we
|
||||
/// surface to the user.
|
||||
final class AircraftDatabase: @unchecked Sendable {
|
||||
static let shared = AircraftDatabase()
|
||||
|
||||
private let byICAO24: [String: String]
|
||||
|
||||
private init() {
|
||||
if let url = Bundle.main.url(forResource: "aircraftDB", withExtension: "json"),
|
||||
let data = try? Data(contentsOf: url),
|
||||
let decoded = try? JSONDecoder().decode([String: String].self, from: data) {
|
||||
byICAO24 = decoded
|
||||
} else {
|
||||
byICAO24 = [:]
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the ICAO aircraft type designator for the given 24-bit
|
||||
/// ICAO transponder address, or nil if the airframe isn't in the DB.
|
||||
func typeCode(forICAO24 icao24: String) -> String? {
|
||||
let key = icao24.lowercased()
|
||||
return byICAO24[key]
|
||||
}
|
||||
|
||||
/// Friendly display name for an ICAO type designator, e.g.
|
||||
/// "B738" → "Boeing 737-800". Falls back to the raw code when not in
|
||||
/// the table.
|
||||
func displayName(forTypeCode code: String) -> String {
|
||||
Self.typeNames[code.uppercased()] ?? code
|
||||
}
|
||||
|
||||
/// Friendly names for the ~150 most common commercial type designators
|
||||
/// we'd see on the map. Anything else displays as the raw 3–4 letter
|
||||
/// code (still useful for filtering). This is by ICAO Doc 8643.
|
||||
private static let typeNames: [String: String] = [
|
||||
// Airbus narrowbody
|
||||
"A318": "Airbus A318",
|
||||
"A319": "Airbus A319",
|
||||
"A320": "Airbus A320",
|
||||
"A321": "Airbus A321",
|
||||
"A19N": "Airbus A319neo",
|
||||
"A20N": "Airbus A320neo",
|
||||
"A21N": "Airbus A321neo",
|
||||
|
||||
// Airbus widebody
|
||||
"A30B": "Airbus A300",
|
||||
"A306": "Airbus A300-600",
|
||||
"A310": "Airbus A310",
|
||||
"A332": "Airbus A330-200",
|
||||
"A333": "Airbus A330-300",
|
||||
"A337": "Airbus A330-700 BelugaXL",
|
||||
"A338": "Airbus A330-800neo",
|
||||
"A339": "Airbus A330-900neo",
|
||||
"A342": "Airbus A340-200",
|
||||
"A343": "Airbus A340-300",
|
||||
"A345": "Airbus A340-500",
|
||||
"A346": "Airbus A340-600",
|
||||
"A359": "Airbus A350-900",
|
||||
"A35K": "Airbus A350-1000",
|
||||
"A388": "Airbus A380",
|
||||
|
||||
// A220 / CSeries
|
||||
"BCS1": "Airbus A220-100",
|
||||
"BCS3": "Airbus A220-300",
|
||||
|
||||
// Boeing narrowbody
|
||||
"B712": "Boeing 717",
|
||||
"B721": "Boeing 727",
|
||||
"B722": "Boeing 727-200",
|
||||
"B731": "Boeing 737-100",
|
||||
"B732": "Boeing 737-200",
|
||||
"B733": "Boeing 737-300",
|
||||
"B734": "Boeing 737-400",
|
||||
"B735": "Boeing 737-500",
|
||||
"B736": "Boeing 737-600",
|
||||
"B737": "Boeing 737-700",
|
||||
"B738": "Boeing 737-800",
|
||||
"B739": "Boeing 737-900",
|
||||
"B37M": "Boeing 737 MAX 7",
|
||||
"B38M": "Boeing 737 MAX 8",
|
||||
"B39M": "Boeing 737 MAX 9",
|
||||
"B3XM": "Boeing 737 MAX 10",
|
||||
"B752": "Boeing 757-200",
|
||||
"B753": "Boeing 757-300",
|
||||
|
||||
// Boeing widebody
|
||||
"B741": "Boeing 747-100",
|
||||
"B742": "Boeing 747-200",
|
||||
"B743": "Boeing 747-300",
|
||||
"B744": "Boeing 747-400",
|
||||
"B748": "Boeing 747-8",
|
||||
"B74F": "Boeing 747 Freighter",
|
||||
"B762": "Boeing 767-200",
|
||||
"B763": "Boeing 767-300",
|
||||
"B764": "Boeing 767-400",
|
||||
"B772": "Boeing 777-200",
|
||||
"B77L": "Boeing 777-200LR",
|
||||
"B773": "Boeing 777-300",
|
||||
"B77W": "Boeing 777-300ER",
|
||||
"B77F": "Boeing 777F",
|
||||
"B778": "Boeing 777-8",
|
||||
"B779": "Boeing 777-9",
|
||||
"B788": "Boeing 787-8",
|
||||
"B789": "Boeing 787-9",
|
||||
"B78X": "Boeing 787-10",
|
||||
|
||||
// Embraer regional
|
||||
"E135": "Embraer ERJ-135",
|
||||
"E140": "Embraer ERJ-140",
|
||||
"E145": "Embraer ERJ-145",
|
||||
"E170": "Embraer E170",
|
||||
"E175": "Embraer E175",
|
||||
"E190": "Embraer E190",
|
||||
"E195": "Embraer E195",
|
||||
"E290": "Embraer E190-E2",
|
||||
"E295": "Embraer E195-E2",
|
||||
|
||||
// Bombardier / Mitsubishi regional
|
||||
"CRJ1": "CRJ-100",
|
||||
"CRJ2": "CRJ-200",
|
||||
"CRJ7": "CRJ-700",
|
||||
"CRJ9": "CRJ-900",
|
||||
"CRJX": "CRJ-1000",
|
||||
"MRJ": "Mitsubishi SpaceJet",
|
||||
|
||||
// De Havilland / Dash
|
||||
"DH8A": "Dash 8-100",
|
||||
"DH8B": "Dash 8-200",
|
||||
"DH8C": "Dash 8-300",
|
||||
"DH8D": "Dash 8 Q400",
|
||||
|
||||
// ATR
|
||||
"AT43": "ATR 42-300",
|
||||
"AT45": "ATR 42-500",
|
||||
"AT46": "ATR 42-600",
|
||||
"AT72": "ATR 72-200",
|
||||
"AT75": "ATR 72-500",
|
||||
"AT76": "ATR 72-600",
|
||||
|
||||
// Business jets
|
||||
"BE20": "Beechcraft King Air 200",
|
||||
"BE40": "Beechjet 400",
|
||||
"BE9L": "King Air 90",
|
||||
"B190": "Beechcraft 1900",
|
||||
"B350": "King Air 350",
|
||||
"CL30": "Bombardier Challenger 300",
|
||||
"CL60": "Bombardier Challenger 600",
|
||||
"CL65": "Bombardier Challenger 650",
|
||||
"GLEX": "Bombardier Global Express",
|
||||
"GL5T": "Bombardier Global 5000",
|
||||
"GLF4": "Gulfstream IV",
|
||||
"GLF5": "Gulfstream V",
|
||||
"GLF6": "Gulfstream G650",
|
||||
"G280": "Gulfstream G280",
|
||||
"FA10": "Dassault Falcon 10",
|
||||
"FA20": "Dassault Falcon 20",
|
||||
"FA50": "Dassault Falcon 50",
|
||||
"FA7X": "Dassault Falcon 7X",
|
||||
"FA8X": "Dassault Falcon 8X",
|
||||
"C25A": "Cessna Citation CJ2",
|
||||
"C25B": "Cessna Citation CJ3",
|
||||
"C25C": "Cessna Citation CJ4",
|
||||
"C56X": "Cessna Citation Excel",
|
||||
"C680": "Cessna Citation Sovereign",
|
||||
"C68A": "Cessna Citation Latitude",
|
||||
"C700": "Cessna Citation Longitude",
|
||||
"PC12": "Pilatus PC-12",
|
||||
"PC24": "Pilatus PC-24",
|
||||
"PRM1": "Hawker Premier",
|
||||
|
||||
// Helicopters
|
||||
"AS32": "Eurocopter Super Puma",
|
||||
"AS50": "Eurocopter Squirrel",
|
||||
"AS65": "Eurocopter Dauphin",
|
||||
"EC20": "Eurocopter EC120",
|
||||
"EC25": "Eurocopter EC225",
|
||||
"EC30": "Eurocopter EC130",
|
||||
"EC35": "Eurocopter EC135",
|
||||
"EC45": "Eurocopter EC145",
|
||||
"EC55": "Eurocopter EC155",
|
||||
"EC75": "Eurocopter EC175",
|
||||
"H125": "Airbus H125",
|
||||
"H135": "Airbus H135",
|
||||
"H145": "Airbus H145",
|
||||
"H160": "Airbus H160",
|
||||
"H225": "Airbus H225",
|
||||
"B06": "Bell 206",
|
||||
"B407": "Bell 407",
|
||||
"B412": "Bell 412",
|
||||
"B429": "Bell 429",
|
||||
"S70": "Sikorsky S-70",
|
||||
"S76": "Sikorsky S-76",
|
||||
"S92": "Sikorsky S-92",
|
||||
|
||||
// Misc / cargo classic
|
||||
"MD80": "MD-80",
|
||||
"MD81": "MD-81",
|
||||
"MD82": "MD-82",
|
||||
"MD83": "MD-83",
|
||||
"MD87": "MD-87",
|
||||
"MD88": "MD-88",
|
||||
"MD90": "MD-90",
|
||||
"MD11": "MD-11",
|
||||
"DC10": "DC-10",
|
||||
"DC9": "DC-9",
|
||||
"B717": "Boeing 717",
|
||||
"F70": "Fokker 70",
|
||||
"F100": "Fokker 100"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Sheet-based filter picker. Backs the Live tab's airline + aircraft-type
|
||||
/// filters. Uses a real `List` (virtualized, smooth scrolling) instead of
|
||||
/// SwiftUI's `Menu`, which renders all items eagerly in a popover and
|
||||
/// stutters once the count goes past ~20.
|
||||
///
|
||||
/// Multi-select. Tap-to-toggle. Search-as-you-type filters the list.
|
||||
struct LiveFilterPicker: View {
|
||||
let title: String
|
||||
let items: [Item]
|
||||
@Binding var selection: Set<String>
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var query: String = ""
|
||||
|
||||
struct Item: Hashable, Identifiable {
|
||||
let id: String
|
||||
let label: String
|
||||
let count: Int
|
||||
}
|
||||
|
||||
private var filteredItems: [Item] {
|
||||
let q = query.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
if q.isEmpty { return items }
|
||||
return items.filter { $0.label.lowercased().contains(q) || $0.id.lowercased().contains(q) }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List {
|
||||
if !selection.isEmpty {
|
||||
Section {
|
||||
ForEach(items.filter { selection.contains($0.id) }) { item in
|
||||
row(item)
|
||||
}
|
||||
Button(role: .destructive) {
|
||||
selection.removeAll()
|
||||
} label: {
|
||||
Label("Clear selection", systemImage: "xmark.circle")
|
||||
}
|
||||
} header: {
|
||||
Text("Selected (\(selection.count))")
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
ForEach(filteredItems.filter { !selection.contains($0.id) }) { item in
|
||||
row(item)
|
||||
}
|
||||
} header: {
|
||||
if !selection.isEmpty {
|
||||
Text("All")
|
||||
}
|
||||
}
|
||||
}
|
||||
.listStyle(.insetGrouped)
|
||||
.searchable(text: $query, placement: .navigationBarDrawer(displayMode: .always),
|
||||
prompt: "Search \(title.lowercased())")
|
||||
.navigationTitle(title)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("Done") { dismiss() }
|
||||
.fontWeight(.semibold)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func row(_ item: Item) -> some View {
|
||||
Button {
|
||||
toggle(item.id)
|
||||
} label: {
|
||||
HStack {
|
||||
Image(systemName: selection.contains(item.id) ? "checkmark.circle.fill" : "circle")
|
||||
.foregroundStyle(selection.contains(item.id) ? FlightTheme.accent : FlightTheme.textTertiary)
|
||||
Text(item.label)
|
||||
.foregroundStyle(FlightTheme.textPrimary)
|
||||
Spacer()
|
||||
Text("\(item.count)")
|
||||
.font(.caption.monospacedDigit())
|
||||
.foregroundStyle(FlightTheme.textSecondary)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
private func toggle(_ id: String) {
|
||||
if selection.contains(id) {
|
||||
selection.remove(id)
|
||||
} else {
|
||||
selection.insert(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,7 @@ struct LiveFlightsView: View {
|
||||
|
||||
@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
|
||||
|
||||
@@ -60,6 +61,7 @@ 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 cachedTypeItems: [TypeFilterItem] = []
|
||||
|
||||
/// Tracks the last bounding box we fetched against. Used to throttle
|
||||
/// the on-pan refresh so that micro-camera-settlements (which happen
|
||||
@@ -69,10 +71,14 @@ struct LiveFlightsView: View {
|
||||
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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -109,6 +115,18 @@ struct LiveFlightsView: View {
|
||||
.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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -191,24 +209,7 @@ struct LiveFlightsView: View {
|
||||
isActive: hideOnGround
|
||||
) { hideOnGround.toggle() }
|
||||
|
||||
Menu {
|
||||
Section("Airline") {
|
||||
ForEach(cachedAirlineItems, id: \.icao) { item in
|
||||
Button {
|
||||
toggle(&selectedAirlineICAO, item.icao)
|
||||
} label: {
|
||||
if selectedAirlineICAO.contains(item.icao) {
|
||||
Label(item.label, systemImage: "checkmark")
|
||||
} else {
|
||||
Text(item.label)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !selectedAirlineICAO.isEmpty {
|
||||
Button("Clear", role: .destructive) { selectedAirlineICAO.removeAll() }
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Button { activeSheet = .airlinePicker } label: {
|
||||
FilterChipLabel(
|
||||
label: selectedAirlineICAO.isEmpty
|
||||
? "Airline"
|
||||
@@ -217,26 +218,36 @@ struct LiveFlightsView: View {
|
||||
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 {
|
||||
Section("Altitude") {
|
||||
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)
|
||||
}
|
||||
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 }
|
||||
}
|
||||
}
|
||||
if selectedAltitudeBand != nil {
|
||||
Button("Clear", role: .destructive) { selectedAltitudeBand = nil }
|
||||
}
|
||||
} label: {
|
||||
FilterChipLabel(
|
||||
@@ -246,9 +257,10 @@ struct LiveFlightsView: View {
|
||||
)
|
||||
}
|
||||
|
||||
if !selectedAirlineICAO.isEmpty || selectedAltitudeBand != nil || hideOnGround {
|
||||
if !selectedAirlineICAO.isEmpty || !selectedTypeCodes.isEmpty || selectedAltitudeBand != nil || hideOnGround {
|
||||
Button {
|
||||
selectedAirlineICAO.removeAll()
|
||||
selectedTypeCodes.removeAll()
|
||||
selectedAltitudeBand = nil
|
||||
hideOnGround = false
|
||||
} label: {
|
||||
@@ -333,6 +345,9 @@ struct LiveFlightsView: View {
|
||||
return false
|
||||
}
|
||||
}
|
||||
if !selectedTypeCodes.isEmpty {
|
||||
guard let tc = ac.typeCode, selectedTypeCodes.contains(tc) else { return false }
|
||||
}
|
||||
if let band = selectedAltitudeBand {
|
||||
guard let alt = ac.altitudeFeet, band.contains(alt) else { return false }
|
||||
}
|
||||
@@ -351,14 +366,24 @@ struct LiveFlightsView: View {
|
||||
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(
|
||||
@@ -367,6 +392,14 @@ struct LiveFlightsView: View {
|
||||
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
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user