Route map: full rewrite — plane fly-through, fit camera, real drawer
The old map opened at a default camera with arcs staggered in over seconds and dots/lines in random order. This rewrites it end-to-end to match the spec the user signed off on. Behaviors: - Camera fits to the bounding region of all filtered dep/arr coords with 40% padding on first appear AND on every filter change. You always see your data on open. - A 4-second total animation auto-plays from oldest flight to newest. Each flight gets a plane icon (rotated to its travel bearing) that flies along the great-circle from dep to arr, drawing a solid orange line behind it. Arcs accumulate and stay drawn at full color. - Airport dots are invisible at start. Each one pops in with a brief scale-up pulse (~200ms eased) when its first flight reaches it — departure when the plane takes off, arrival when it lands. - Most-recent flight stays highlighted in yellow with a thicker stroke as a focal point. - Map style switched from `.imagery` to `.standard(.muted)` — desaturated political map with borders + labels so dots actually read against the surface. - The always-on bottom overlay is now a real swipe-up sheet with detents [.fraction(0.14), .medium, .large]. Peek shows the count + a circular progress ring + a replay button. Expanded shows filter chips and quick stats (airports / routes / years). Architecture: - AnimationSchedule value type holds per-flight FlightSegments and per-airport AirportLights with their start/end progress windows. Built once per filter change, immutable thereafter. Reads from the view body are O(1) sliced lookups. - FlightSegment.coordsVisible(at:) slices the precomputed great-circle array for partial draw. FlightSegment.head(at:) returns the plane's current coord + travel bearing. - runAnimation() drives progress 0→1 over 4s via a Task loop at ~60fps using system clock (so dropped frames don't slow it down). - Old code dropped: the staggered .task(id:) reveal, the always-visible drawer overlay, the AirportDot view, the inline great-circle helper. All consolidated into AnimationSchedule and AirportPulseDot. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -2,28 +2,41 @@ import SwiftUI
|
|||||||
import MapKit
|
import MapKit
|
||||||
import CoreLocation
|
import CoreLocation
|
||||||
|
|
||||||
/// Interactive lifetime route map.
|
/// Lifetime route map — redesigned.
|
||||||
/// - Animated great-circle arcs for every flight in the current
|
///
|
||||||
/// (filtered) view, drawn oldest → newest on first appear.
|
/// What it does, in order:
|
||||||
/// - Airport dots sized log-scale by visit count (across the whole
|
/// 1. On appear (and whenever filters change), the camera fits to
|
||||||
/// log, not just the filtered set, so the map's geography stays
|
/// the bounding region of every filtered flight's dep + arr
|
||||||
/// stable as the user toggles filters).
|
/// airports with padding. You always see your data.
|
||||||
/// - Tap an airport dot → present every flight through that airport.
|
/// 2. An animation auto-plays from oldest flight to newest. Each
|
||||||
/// - Tap an arc → jump straight to that flight's detail.
|
/// flight gets a small plane icon that flies along the great-
|
||||||
|
/// circle from departure to arrival, drawing a solid orange
|
||||||
|
/// line behind it. The whole sweep takes ~4 seconds regardless
|
||||||
|
/// of flight count.
|
||||||
|
/// 3. Airport dots are invisible at start. Each one pops in with a
|
||||||
|
/// brief pulse when its first flight lands there.
|
||||||
|
/// 4. Drawn arcs stay drawn at full color; the most-recent flight
|
||||||
|
/// stays slightly brighter/thicker as a focal point.
|
||||||
|
/// 5. Bottom drawer is a real swipe-up sheet with detents — peek
|
||||||
|
/// shows the count + replay; expanded shows filter chips.
|
||||||
struct HistoryRouteMapView: View {
|
struct HistoryRouteMapView: View {
|
||||||
let flights: [LoggedFlight] // already filtered by HistoryView
|
let flights: [LoggedFlight] // filtered
|
||||||
let allFlights: [LoggedFlight] // unfiltered, used for visit counts
|
let allFlights: [LoggedFlight] // unfiltered (kept for parent-API compat)
|
||||||
let database: AirportDatabase
|
let database: AirportDatabase
|
||||||
let openSky: OpenSkyClient
|
let openSky: OpenSkyClient
|
||||||
let store: FlightHistoryStore
|
let store: FlightHistoryStore
|
||||||
@Binding var filters: HistoryFilters
|
@Binding var filters: HistoryFilters
|
||||||
|
|
||||||
@State private var revealCount: Int = 0
|
|
||||||
@State private var position: MapCameraPosition = .automatic
|
@State private var position: MapCameraPosition = .automatic
|
||||||
|
@State private var progress: Double = 0
|
||||||
|
@State private var animationKey: Int = 0
|
||||||
|
@State private var schedule: AnimationSchedule = .empty
|
||||||
@State private var selectedAirportSheet: AirportSheet?
|
@State private var selectedAirportSheet: AirportSheet?
|
||||||
@State private var selectedFlight: LoggedFlight?
|
@State private var sheetDetent: PresentationDetent = .fraction(0.14)
|
||||||
@State private var revealKey: Int = 0 // bump to retrigger the reveal animation
|
|
||||||
@State private var drawerExpanded: Bool = false
|
/// ~4 second total sweep, snappy. Per-flight slice is computed
|
||||||
|
/// from this in `AnimationSchedule.build`.
|
||||||
|
private static let totalDuration: TimeInterval = 4.0
|
||||||
|
|
||||||
struct AirportSheet: Identifiable {
|
struct AirportSheet: Identifiable {
|
||||||
let iata: String
|
let iata: String
|
||||||
@@ -31,57 +44,37 @@ struct HistoryRouteMapView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
let arcs = self.arcs
|
ZStack {
|
||||||
|
mapLayer
|
||||||
return ZStack(alignment: .bottom) {
|
|
||||||
Map(position: $position) {
|
|
||||||
// Airport dots
|
|
||||||
ForEach(airportItems, id: \.iata) { item in
|
|
||||||
Annotation(item.iata, coordinate: item.coord) {
|
|
||||||
AirportDot(
|
|
||||||
size: dotSize(for: item.count),
|
|
||||||
isSelected: filters.airports.contains(item.iata)
|
|
||||||
)
|
|
||||||
.onTapGesture { selectedAirportSheet = AirportSheet(iata: item.iata) }
|
|
||||||
}
|
}
|
||||||
.annotationTitles(.hidden)
|
.navigationTitle(filters.isEmpty ? "Routes" : "Filtered Routes")
|
||||||
}
|
|
||||||
// Animated arcs
|
|
||||||
ForEach(arcs.prefix(revealCount)) { arc in
|
|
||||||
MapPolyline(coordinates: arc.coords)
|
|
||||||
.stroke(arcColor(for: arc), lineWidth: arc.isMostRecent ? 3.0 : 1.6)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.mapStyle(.imagery(elevation: .flat))
|
|
||||||
.ignoresSafeArea(edges: .bottom)
|
|
||||||
|
|
||||||
// Bottom passport drawer — always-visible card peeking up
|
|
||||||
// from the bottom of the map, showing the scoped passport
|
|
||||||
// summary and the active filter set. Tap to expand.
|
|
||||||
passportDrawer
|
|
||||||
}
|
|
||||||
.navigationTitle(filters.isEmpty ? "Lifetime Routes" : "Filtered Routes")
|
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .primaryAction) {
|
ToolbarItem(placement: .primaryAction) {
|
||||||
Button {
|
Button { restart() } label: {
|
||||||
revealCount = 0
|
Image(systemName: "arrow.clockwise.circle.fill")
|
||||||
revealKey += 1
|
.font(.title3)
|
||||||
} label: {
|
.foregroundStyle(HistoryStyle.runwayOrange)
|
||||||
Image(systemName: "play.circle")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.task(id: "\(revealKey)-\(arcs.count)") {
|
// Recompute the schedule and the camera fit on first appear
|
||||||
// Stagger reveal — capped so 200+ flights still finish
|
// and whenever the filtered flight set changes.
|
||||||
// animating in a couple seconds.
|
.onAppear { reset() }
|
||||||
revealCount = 0
|
.onChange(of: filters) { _, _ in reset() }
|
||||||
let step = max(0.012, min(0.04, 4.0 / Double(arcs.count + 1)))
|
// Drive the progress with a Task loop. Re-runs on animationKey
|
||||||
for i in 0...arcs.count {
|
// bump (which restart() triggers).
|
||||||
revealCount = i
|
.task(id: animationKey) {
|
||||||
try? await Task.sleep(nanoseconds: UInt64(step * 1_000_000_000))
|
await runAnimation()
|
||||||
if Task.isCancelled { return }
|
|
||||||
}
|
}
|
||||||
|
// The drawer is an always-presented sheet with detents — the
|
||||||
|
// user can drag it from peek up to large and back down.
|
||||||
|
.sheet(isPresented: .constant(true)) {
|
||||||
|
mapDrawer
|
||||||
|
.presentationDetents([.fraction(0.14), .medium, .large], selection: $sheetDetent)
|
||||||
|
.presentationBackgroundInteraction(.enabled)
|
||||||
|
.presentationDragIndicator(.visible)
|
||||||
|
.interactiveDismissDisabled()
|
||||||
}
|
}
|
||||||
.sheet(item: $selectedAirportSheet) { sheet in
|
.sheet(item: $selectedAirportSheet) { sheet in
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
@@ -96,88 +89,128 @@ struct HistoryRouteMapView: View {
|
|||||||
}
|
}
|
||||||
.presentationDetents([.medium, .large])
|
.presentationDetents([.medium, .large])
|
||||||
}
|
}
|
||||||
.sheet(item: $selectedFlight) { flight in
|
}
|
||||||
NavigationStack {
|
|
||||||
HistoryDetailView(
|
// MARK: - Map
|
||||||
flight: flight,
|
|
||||||
store: store,
|
private var mapLayer: some View {
|
||||||
database: database,
|
Map(position: $position) {
|
||||||
openSky: openSky
|
// Completed + currently-animating arcs
|
||||||
|
ForEach(schedule.segments) { seg in
|
||||||
|
if let visibleCoords = seg.coordsVisible(at: progress) {
|
||||||
|
let isMostRecent = seg.id == schedule.mostRecentId
|
||||||
|
MapPolyline(coordinates: visibleCoords)
|
||||||
|
.stroke(
|
||||||
|
isMostRecent ? Color.yellow : HistoryStyle.runwayOrange,
|
||||||
|
style: StrokeStyle(
|
||||||
|
lineWidth: isMostRecent ? 3.0 : 2.0,
|
||||||
|
lineCap: .round,
|
||||||
|
lineJoin: .round
|
||||||
)
|
)
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Arcs
|
|
||||||
|
|
||||||
private struct Arc: Identifiable {
|
|
||||||
let id = UUID()
|
|
||||||
let coords: [CLLocationCoordinate2D]
|
|
||||||
let flight: LoggedFlight
|
|
||||||
let isMostRecent: Bool
|
|
||||||
}
|
|
||||||
|
|
||||||
private var arcs: [Arc] {
|
|
||||||
let sorted = flights.sorted { $0.flightDate < $1.flightDate }
|
|
||||||
guard let mostRecentDate = sorted.last?.flightDate else { return [] }
|
|
||||||
return sorted.compactMap { f in
|
|
||||||
guard let dep = database.airport(byIATA: f.departureIATA),
|
|
||||||
let arr = database.airport(byIATA: f.arrivalIATA)
|
|
||||||
else { return nil }
|
|
||||||
return Arc(
|
|
||||||
coords: Self.greatCircle(from: dep.coordinate, to: arr.coordinate, segments: 48),
|
|
||||||
flight: f,
|
|
||||||
isMostRecent: f.flightDate == mostRecentDate
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func arcColor(for arc: Arc) -> Color {
|
// In-flight plane icons (only the segments currently animating)
|
||||||
if arc.isMostRecent {
|
ForEach(schedule.segments) { seg in
|
||||||
return Color(red: 1.0, green: 1.0, blue: 0.0) // fluorescent yellow for the latest leg
|
if let head = seg.head(at: progress) {
|
||||||
|
Annotation("", coordinate: head.coord) {
|
||||||
|
Image(systemName: "airplane")
|
||||||
|
.font(.system(size: 16, weight: .black))
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
.padding(5)
|
||||||
|
.background(HistoryStyle.runwayOrange, in: Circle())
|
||||||
|
.shadow(color: .black.opacity(0.4), radius: 3, y: 1)
|
||||||
|
.rotationEffect(.degrees(head.bearing - 90))
|
||||||
|
}
|
||||||
|
.annotationTitles(.hidden)
|
||||||
}
|
}
|
||||||
// Bulk lines in vivid runway orange.
|
|
||||||
return HistoryStyle.runwayOrange
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Passport drawer
|
// Airports — pop in on first arrival
|
||||||
|
ForEach(schedule.airports) { ap in
|
||||||
|
if progress >= ap.litAt {
|
||||||
|
Annotation(ap.iata, coordinate: ap.coord) {
|
||||||
|
AirportPulseDot(
|
||||||
|
size: ap.dotSize,
|
||||||
|
timeSinceLit: progress - ap.litAt,
|
||||||
|
isSelected: filters.airports.contains(ap.iata)
|
||||||
|
)
|
||||||
|
.onTapGesture {
|
||||||
|
selectedAirportSheet = AirportSheet(iata: ap.iata)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.annotationTitles(.hidden)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.mapStyle(.standard(elevation: .flat, emphasis: .muted))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Bottom drawer (sheet)
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private var passportDrawer: some View {
|
private var mapDrawer: some View {
|
||||||
VStack(spacing: 8) {
|
VStack(spacing: 0) {
|
||||||
// Tab handle
|
drawerPeek
|
||||||
Capsule()
|
if sheetDetent != .fraction(0.14) {
|
||||||
.fill(.white.opacity(0.4))
|
Divider()
|
||||||
.frame(width: 36, height: 5)
|
drawerExpanded
|
||||||
.padding(.top, 8)
|
}
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
VStack(spacing: 12) {
|
private var drawerPeek: some View {
|
||||||
HStack {
|
HStack(spacing: 14) {
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 1) {
|
||||||
Text(filters.isEmpty ? "ALL TIME" : "FILTERED")
|
Text(filters.isEmpty ? "ALL TIME" : "FILTERED")
|
||||||
.font(.system(size: 10, weight: .heavy))
|
.font(.system(size: 10, weight: .heavy))
|
||||||
.tracking(2)
|
.tracking(2)
|
||||||
.foregroundStyle(HistoryStyle.runwayOrange)
|
.foregroundStyle(HistoryStyle.runwayOrange)
|
||||||
Text("\(flights.count) flights · \(numberStringMiles())")
|
Text("\(flights.count) flights · \(totalMilesString)")
|
||||||
.font(.system(size: 16, weight: .heavy))
|
.font(.system(size: 17, weight: .heavy))
|
||||||
.foregroundStyle(.white)
|
.foregroundStyle(.primary)
|
||||||
}
|
}
|
||||||
Spacer()
|
Spacer(minLength: 12)
|
||||||
Button {
|
// Animation progress
|
||||||
revealCount = 0
|
ZStack {
|
||||||
revealKey += 1
|
Circle()
|
||||||
} label: {
|
.stroke(.gray.opacity(0.18), lineWidth: 3)
|
||||||
Image(systemName: "play.fill")
|
Circle()
|
||||||
.font(.system(size: 12, weight: .bold))
|
.trim(from: 0, to: progress)
|
||||||
.padding(10)
|
.stroke(HistoryStyle.runwayOrange,
|
||||||
.background(.white.opacity(0.18), in: Circle())
|
style: StrokeStyle(lineWidth: 3, lineCap: .round))
|
||||||
.foregroundStyle(.white)
|
.rotationEffect(.degrees(-90))
|
||||||
|
if progress >= 1 {
|
||||||
|
Image(systemName: "checkmark")
|
||||||
|
.font(.system(size: 11, weight: .black))
|
||||||
|
.foregroundStyle(HistoryStyle.runwayOrange)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.frame(width: 24, height: 24)
|
||||||
|
|
||||||
|
Button { restart() } label: {
|
||||||
|
Image(systemName: "arrow.clockwise")
|
||||||
|
.font(.system(size: 14, weight: .bold))
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
.frame(width: 36, height: 36)
|
||||||
|
.background(HistoryStyle.runwayOrange, in: Circle())
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
.padding(.horizontal, 18)
|
.padding(.horizontal, 18)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
}
|
||||||
|
|
||||||
// Filter chips (live)
|
@ViewBuilder
|
||||||
if !filters.airports.isEmpty || !filters.airlines.isEmpty || !filters.years.isEmpty {
|
private var drawerExpanded: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 14) {
|
||||||
|
if !filters.years.isEmpty || !filters.airlines.isEmpty || !filters.airports.isEmpty {
|
||||||
|
Text("FILTERS")
|
||||||
|
.font(.system(size: 10, weight: .heavy))
|
||||||
|
.tracking(2)
|
||||||
|
.foregroundStyle(HistoryStyle.runwayOrange)
|
||||||
ScrollView(.horizontal, showsIndicators: false) {
|
ScrollView(.horizontal, showsIndicators: false) {
|
||||||
HStack(spacing: 6) {
|
HStack(spacing: 6) {
|
||||||
ForEach(Array(filters.years).sorted(by: >), id: \.self) { y in
|
ForEach(Array(filters.years).sorted(by: >), id: \.self) { y in
|
||||||
@@ -190,30 +223,26 @@ struct HistoryRouteMapView: View {
|
|||||||
drawerChip(a) { filters.airports.remove(a) }
|
drawerChip(a) { filters.airports.remove(a) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Quick stats grid
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
statTile(label: "Airports", value: "\(schedule.airports.count)")
|
||||||
|
statTile(label: "Routes", value: "\(uniqueRoutes)")
|
||||||
|
statTile(label: "Years", value: "\(yearSpan)")
|
||||||
|
}
|
||||||
|
.padding(.top, 4)
|
||||||
|
Spacer(minLength: 12)
|
||||||
|
}
|
||||||
.padding(.horizontal, 18)
|
.padding(.horizontal, 18)
|
||||||
}
|
.padding(.vertical, 14)
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(.bottom, 22)
|
|
||||||
}
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
.background(
|
|
||||||
LinearGradient(
|
|
||||||
colors: [HistoryStyle.midnightNavy.opacity(0.92), HistoryStyle.midnightNavy],
|
|
||||||
startPoint: .top, endPoint: .bottom
|
|
||||||
)
|
|
||||||
.clipShape(UnevenRoundedRectangle(topLeadingRadius: 22, topTrailingRadius: 22))
|
|
||||||
.ignoresSafeArea(edges: .bottom)
|
|
||||||
)
|
|
||||||
.shadow(color: .black.opacity(0.4), radius: 12, y: -4)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func drawerChip(_ label: String, onRemove: @escaping () -> Void) -> some View {
|
private func drawerChip(_ label: String, onRemove: @escaping () -> Void) -> some View {
|
||||||
HStack(spacing: 4) {
|
HStack(spacing: 4) {
|
||||||
Text(label)
|
Text(label).font(.system(size: 11, weight: .bold).monospaced())
|
||||||
.font(.system(size: 11, weight: .bold).monospaced())
|
Image(systemName: "xmark").font(.system(size: 8, weight: .bold))
|
||||||
Image(systemName: "xmark")
|
|
||||||
.font(.system(size: 8, weight: .bold))
|
|
||||||
}
|
}
|
||||||
.foregroundStyle(.white)
|
.foregroundStyle(.white)
|
||||||
.padding(.horizontal, 10)
|
.padding(.horizontal, 10)
|
||||||
@@ -222,46 +251,192 @@ struct HistoryRouteMapView: View {
|
|||||||
.onTapGesture(perform: onRemove)
|
.onTapGesture(perform: onRemove)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func numberStringMiles() -> String {
|
private func statTile(label: String, value: String) -> some View {
|
||||||
let total = flights.reduce(0) { acc, f in
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
acc + (store.distanceMiles(for: f) ?? 0)
|
Text(label.uppercased())
|
||||||
|
.font(.system(size: 10, weight: .heavy))
|
||||||
|
.tracking(1.2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Text(value)
|
||||||
|
.font(.system(size: 22, weight: .heavy).monospacedDigit())
|
||||||
}
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.padding(12)
|
||||||
|
.background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 12))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Derived
|
||||||
|
|
||||||
|
private var uniqueRoutes: Int {
|
||||||
|
Set(flights.map { [$0.departureIATA, $0.arrivalIATA].sorted().joined(separator: "↔") }).count
|
||||||
|
}
|
||||||
|
|
||||||
|
private var yearSpan: Int {
|
||||||
|
let years = Set(flights.map { Calendar.current.component(.year, from: $0.flightDate) })
|
||||||
|
return years.count
|
||||||
|
}
|
||||||
|
|
||||||
|
private var totalMilesString: String {
|
||||||
|
let total = flights.reduce(0) { $0 + (store.distanceMiles(for: $1) ?? 0) }
|
||||||
let f = NumberFormatter()
|
let f = NumberFormatter()
|
||||||
f.numberStyle = .decimal
|
f.numberStyle = .decimal
|
||||||
return (f.string(from: NSNumber(value: total)) ?? "\(total)") + " mi"
|
return (f.string(from: NSNumber(value: total)) ?? "\(total)") + " mi"
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Airports
|
// MARK: - Lifecycle
|
||||||
|
|
||||||
private struct AirportItem: Hashable {
|
/// Build the schedule and snap the camera to fit before the
|
||||||
let iata: String
|
/// animation begins. Called on appear and on every filter change.
|
||||||
let coord: CLLocationCoordinate2D
|
private func reset() {
|
||||||
let count: Int
|
schedule = AnimationSchedule.build(flights: flights, database: database, totalDuration: Self.totalDuration)
|
||||||
|
if let region = schedule.fitRegion {
|
||||||
static func == (lhs: AirportItem, rhs: AirportItem) -> Bool { lhs.iata == rhs.iata }
|
position = .region(region)
|
||||||
func hash(into hasher: inout Hasher) { hasher.combine(iata) }
|
}
|
||||||
|
progress = 0
|
||||||
|
animationKey += 1
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Airport dot universe is the FULL flight log (not the filtered
|
private func restart() {
|
||||||
/// view) so panning around with filters on doesn't make airports
|
progress = 0
|
||||||
/// pop in and out as the user toggles airlines.
|
animationKey += 1
|
||||||
private var airportItems: [AirportItem] {
|
}
|
||||||
let codes = allFlights.flatMap { [$0.departureIATA, $0.arrivalIATA] }.filter { !$0.isEmpty }
|
|
||||||
let counts = Dictionary(grouping: codes) { $0 }.mapValues(\.count)
|
/// Drive `progress` from 0 → 1 over `totalDuration` seconds. Uses
|
||||||
return counts.compactMap { code, count in
|
/// the system clock so the animation finishes in real time even
|
||||||
guard let m = database.airport(byIATA: code) else { return nil }
|
/// if a frame is dropped.
|
||||||
return AirportItem(iata: code, coord: m.coordinate, count: count)
|
private func runAnimation() async {
|
||||||
|
let start = Date()
|
||||||
|
let dur = Self.totalDuration
|
||||||
|
while !Task.isCancelled {
|
||||||
|
let elapsed = Date().timeIntervalSince(start)
|
||||||
|
if elapsed >= dur {
|
||||||
|
progress = 1
|
||||||
|
return
|
||||||
|
}
|
||||||
|
progress = max(progress, elapsed / dur)
|
||||||
|
// ~60fps tick
|
||||||
|
try? await Task.sleep(nanoseconds: 16_666_666)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func dotSize(for count: Int) -> CGFloat {
|
// MARK: - Animation schedule
|
||||||
let v = log(Double(count) + 1) * 4 + 6
|
//
|
||||||
return CGFloat(min(20, max(7, v)))
|
// Plain value types. Built once per filter change. No I/O, no closure
|
||||||
|
// captures. Reads cleanly from the view's `body` because `coordsVisible`
|
||||||
|
// and `head` are O(1) sliced lookups against precomputed arrays.
|
||||||
|
|
||||||
|
struct AnimationSchedule {
|
||||||
|
var segments: [FlightSegment] = []
|
||||||
|
var airports: [AirportLight] = []
|
||||||
|
var fitRegion: MKCoordinateRegion?
|
||||||
|
var mostRecentId: UUID?
|
||||||
|
|
||||||
|
static let empty = AnimationSchedule()
|
||||||
|
|
||||||
|
/// Build the per-flight start/end progress windows and the per-
|
||||||
|
/// airport "lights up at" progress from the user's filtered set.
|
||||||
|
static func build(flights: [LoggedFlight], database: AirportDatabase, totalDuration: TimeInterval) -> AnimationSchedule {
|
||||||
|
let sorted = flights.sorted { $0.flightDate < $1.flightDate }
|
||||||
|
let resolved: [(flight: LoggedFlight, dep: CLLocationCoordinate2D, arr: CLLocationCoordinate2D)] =
|
||||||
|
sorted.compactMap { f in
|
||||||
|
guard let dep = database.airport(byIATA: f.departureIATA),
|
||||||
|
let arr = database.airport(byIATA: f.arrivalIATA)
|
||||||
|
else { return nil }
|
||||||
|
return (f, dep.coordinate, arr.coordinate)
|
||||||
|
}
|
||||||
|
guard !resolved.isEmpty else { return .empty }
|
||||||
|
|
||||||
|
// Per-flight slice: we want a ~4s total with the planes
|
||||||
|
// overlapping just enough that one starts before the previous
|
||||||
|
// finishes (otherwise the map looks empty between flights).
|
||||||
|
let n = Double(resolved.count)
|
||||||
|
// Each plane traverses for `flightDur`. We stagger starts by
|
||||||
|
// `(1 - flightDur) / (n - 1)` so the last finishes at 1.0.
|
||||||
|
let flightDur: Double = min(0.18, max(0.06, 1.0 / max(n, 1.0) * 2.5))
|
||||||
|
let stagger: Double = n > 1 ? (1.0 - flightDur) / (n - 1) : 0
|
||||||
|
|
||||||
|
var segments: [FlightSegment] = []
|
||||||
|
var firstArrivalForAirport: [String: Double] = [:] // IATA → litAt progress
|
||||||
|
var airportCounts: [String: Int] = [:]
|
||||||
|
var airportCoords: [String: CLLocationCoordinate2D] = [:]
|
||||||
|
|
||||||
|
for (i, item) in resolved.enumerated() {
|
||||||
|
let startP = Double(i) * stagger
|
||||||
|
let endP = startP + flightDur
|
||||||
|
let coords = greatCircle(from: item.dep, to: item.arr, segments: 40)
|
||||||
|
let seg = FlightSegment(
|
||||||
|
id: UUID(),
|
||||||
|
flightId: item.flight.id,
|
||||||
|
coords: coords,
|
||||||
|
startProgress: startP,
|
||||||
|
endProgress: endP
|
||||||
|
)
|
||||||
|
segments.append(seg)
|
||||||
|
|
||||||
|
// Departure lights up at startP (when its plane takes off);
|
||||||
|
// arrival at endP (when the plane lands).
|
||||||
|
let depIata = item.flight.departureIATA
|
||||||
|
let arrIata = item.flight.arrivalIATA
|
||||||
|
firstArrivalForAirport[depIata] = min(firstArrivalForAirport[depIata] ?? .infinity, startP)
|
||||||
|
firstArrivalForAirport[arrIata] = min(firstArrivalForAirport[arrIata] ?? .infinity, endP)
|
||||||
|
airportCounts[depIata, default: 0] += 1
|
||||||
|
airportCounts[arrIata, default: 0] += 1
|
||||||
|
airportCoords[depIata] = item.dep
|
||||||
|
airportCoords[arrIata] = item.arr
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Polyline samples along the great-circle path between two
|
let airports = firstArrivalForAirport.compactMap { iata, lit -> AirportLight? in
|
||||||
/// coordinates. MapKit doesn't draw GC paths natively — we
|
guard let coord = airportCoords[iata] else { return nil }
|
||||||
/// approximate with N straight segments along the GC route.
|
let count = airportCounts[iata] ?? 1
|
||||||
|
return AirportLight(
|
||||||
|
iata: iata,
|
||||||
|
coord: coord,
|
||||||
|
litAt: lit,
|
||||||
|
dotSize: dotSizeFor(count: count)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fit region — union of all dep + arr coordinates, padded.
|
||||||
|
let coords = resolved.flatMap { [$0.dep, $0.arr] }
|
||||||
|
let fit = boundingRegion(for: coords)
|
||||||
|
|
||||||
|
return AnimationSchedule(
|
||||||
|
segments: segments,
|
||||||
|
airports: airports,
|
||||||
|
fitRegion: fit,
|
||||||
|
mostRecentId: segments.last?.id
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func dotSizeFor(count: Int) -> CGFloat {
|
||||||
|
// log scale, 9pt → 22pt
|
||||||
|
let v = log(Double(count) + 1) * 4 + 8
|
||||||
|
return CGFloat(min(22, max(9, v)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bounding `MKCoordinateRegion` that fits all coords with padding.
|
||||||
|
/// Adds ~20% margin to span so dots don't pin to the edges.
|
||||||
|
private static func boundingRegion(for coords: [CLLocationCoordinate2D]) -> MKCoordinateRegion? {
|
||||||
|
guard !coords.isEmpty else { return nil }
|
||||||
|
let lats = coords.map { $0.latitude }
|
||||||
|
let lons = coords.map { $0.longitude }
|
||||||
|
let minLat = lats.min() ?? 0, maxLat = lats.max() ?? 0
|
||||||
|
let minLon = lons.min() ?? 0, maxLon = lons.max() ?? 0
|
||||||
|
let center = CLLocationCoordinate2D(
|
||||||
|
latitude: (minLat + maxLat) / 2,
|
||||||
|
longitude: (minLon + maxLon) / 2
|
||||||
|
)
|
||||||
|
let latDelta = max(0.5, (maxLat - minLat) * 1.4)
|
||||||
|
let lonDelta = max(0.5, (maxLon - minLon) * 1.4)
|
||||||
|
return MKCoordinateRegion(
|
||||||
|
center: center,
|
||||||
|
span: MKCoordinateSpan(latitudeDelta: latDelta, longitudeDelta: lonDelta)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 41-point great-circle sample. MapKit doesn't draw GC paths
|
||||||
|
/// natively, so we approximate with straight segments.
|
||||||
private static func greatCircle(from a: CLLocationCoordinate2D, to b: CLLocationCoordinate2D, segments: Int) -> [CLLocationCoordinate2D] {
|
private static func greatCircle(from a: CLLocationCoordinate2D, to b: CLLocationCoordinate2D, segments: Int) -> [CLLocationCoordinate2D] {
|
||||||
let lat1 = a.latitude * .pi / 180
|
let lat1 = a.latitude * .pi / 180
|
||||||
let lon1 = a.longitude * .pi / 180
|
let lon1 = a.longitude * .pi / 180
|
||||||
@@ -291,18 +466,99 @@ struct HistoryRouteMapView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Tappable airport dot. Sized log-scale by visit count; highlighted
|
/// One animatable flight segment. `coordsVisible` slices the pre-
|
||||||
/// when the user is currently filtering on that airport.
|
/// computed great-circle array based on the global animation
|
||||||
private struct AirportDot: View {
|
/// progress; `head` gives the current plane position.
|
||||||
|
struct FlightSegment: Identifiable, Hashable {
|
||||||
|
let id: UUID
|
||||||
|
let flightId: UUID
|
||||||
|
let coords: [CLLocationCoordinate2D]
|
||||||
|
let startProgress: Double
|
||||||
|
let endProgress: Double
|
||||||
|
|
||||||
|
/// Returns the visible portion of the arc at the given global
|
||||||
|
/// progress. nil if the flight hasn't started yet.
|
||||||
|
func coordsVisible(at progress: Double) -> [CLLocationCoordinate2D]? {
|
||||||
|
if progress < startProgress { return nil }
|
||||||
|
if progress >= endProgress { return coords }
|
||||||
|
let local = (progress - startProgress) / max(0.0001, endProgress - startProgress)
|
||||||
|
let cutoff = max(1, Int(local * Double(coords.count - 1)) + 1)
|
||||||
|
return Array(coords.prefix(cutoff))
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Head {
|
||||||
|
let coord: CLLocationCoordinate2D
|
||||||
|
let bearing: Double
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the moving plane's current coordinate and travel
|
||||||
|
/// bearing. nil when the flight isn't in-flight at this moment.
|
||||||
|
func head(at progress: Double) -> Head? {
|
||||||
|
guard progress >= startProgress, progress < endProgress else { return nil }
|
||||||
|
let local = (progress - startProgress) / max(0.0001, endProgress - startProgress)
|
||||||
|
let i = max(0, min(coords.count - 1, Int(local * Double(coords.count - 1))))
|
||||||
|
let here = coords[i]
|
||||||
|
let next = coords[min(coords.count - 1, i + 1)]
|
||||||
|
return Head(coord: here, bearing: bearing(from: here, to: next))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func bearing(from a: CLLocationCoordinate2D, to b: CLLocationCoordinate2D) -> Double {
|
||||||
|
let lat1 = a.latitude * .pi / 180
|
||||||
|
let lat2 = b.latitude * .pi / 180
|
||||||
|
let dLon = (b.longitude - a.longitude) * .pi / 180
|
||||||
|
let y = sin(dLon) * cos(lat2)
|
||||||
|
let x = cos(lat1) * sin(lat2) - sin(lat1) * cos(lat2) * cos(dLon)
|
||||||
|
let theta = atan2(y, x)
|
||||||
|
return (theta * 180 / .pi + 360).truncatingRemainder(dividingBy: 360)
|
||||||
|
}
|
||||||
|
|
||||||
|
func hash(into hasher: inout Hasher) { hasher.combine(id) }
|
||||||
|
static func == (lhs: FlightSegment, rhs: FlightSegment) -> Bool { lhs.id == rhs.id }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One airport on the map. `litAt` is the progress value at which the
|
||||||
|
/// dot first appears (when the first plane to/from this airport
|
||||||
|
/// lands or takes off).
|
||||||
|
struct AirportLight: Identifiable, Hashable {
|
||||||
|
let iata: String
|
||||||
|
let coord: CLLocationCoordinate2D
|
||||||
|
let litAt: Double
|
||||||
|
let dotSize: CGFloat
|
||||||
|
var id: String { iata }
|
||||||
|
|
||||||
|
func hash(into hasher: inout Hasher) { hasher.combine(iata) }
|
||||||
|
static func == (lhs: AirportLight, rhs: AirportLight) -> Bool { lhs.iata == rhs.iata }
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Airport dot with light-up pulse
|
||||||
|
|
||||||
|
/// Tappable airport dot with a brief scale-up "pop" the first time it
|
||||||
|
/// appears, then settles. `timeSinceLit` is the global progress
|
||||||
|
/// elapsed since this airport first lit up, so we can drive a
|
||||||
|
/// transient pulse without per-dot @State.
|
||||||
|
private struct AirportPulseDot: View {
|
||||||
let size: CGFloat
|
let size: CGFloat
|
||||||
|
let timeSinceLit: Double
|
||||||
let isSelected: Bool
|
let isSelected: Bool
|
||||||
|
|
||||||
|
/// Pulse window: bump scale up for ~0.05 progress (≈ 200ms at our
|
||||||
|
/// 4s sweep), then ease back to 1.0.
|
||||||
|
private var pulseScale: CGFloat {
|
||||||
|
let window: Double = 0.05
|
||||||
|
guard timeSinceLit < window else { return 1.0 }
|
||||||
|
let frac = timeSinceLit / window
|
||||||
|
// half-cosine ease — 1.6 at start, 1.0 at end
|
||||||
|
let eased = 1.0 + 0.6 * cos(frac * .pi / 2)
|
||||||
|
return CGFloat(eased)
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Circle()
|
Circle()
|
||||||
.fill(isSelected ? Color.yellow : HistoryStyle.runwayOrange)
|
.fill(isSelected ? Color.yellow : HistoryStyle.runwayOrange)
|
||||||
.frame(width: size, height: size)
|
.frame(width: size, height: size)
|
||||||
.overlay(Circle().stroke(.white, lineWidth: 2))
|
.overlay(Circle().stroke(.white, lineWidth: 2))
|
||||||
.shadow(color: .black.opacity(0.5), radius: 2, y: 1)
|
.shadow(color: .black.opacity(0.4), radius: 3, y: 1)
|
||||||
.contentShape(Circle())
|
.scaleEffect(pulseScale)
|
||||||
|
.contentShape(Circle().inset(by: -10))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user