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:
Trey T
2026-05-27 07:21:49 -05:00
parent d6fb73db2c
commit 390a158487
6 changed files with 406 additions and 35 deletions
+12
View File
@@ -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;
};
+8
View File
@@ -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
+221
View File
@@ -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 34 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"
]
}
+96
View File
@@ -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)
}
}
}
+68 -35
View File
@@ -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