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:
Trey T
2026-05-29 18:26:51 -05:00
parent e1b7fd4b0d
commit f97d5f52ec
+445 -189
View File
@@ -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)
}
// 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") .navigationTitle(filters.isEmpty ? "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,124 +89,160 @@ 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
)
)
}
}
// In-flight plane icons (only the segments currently animating)
ForEach(schedule.segments) { seg in
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)
}
}
// 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: - Arcs // MARK: - Bottom drawer (sheet)
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 {
if arc.isMostRecent {
return Color(red: 1.0, green: 1.0, blue: 0.0) // fluorescent yellow for the latest leg
}
// Bulk lines in vivid runway orange.
return HistoryStyle.runwayOrange
}
// MARK: - Passport drawer
@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)
} }
.padding(.horizontal, 18) }
.frame(width: 24, height: 24)
// Filter chips (live) Button { restart() } label: {
if !filters.airports.isEmpty || !filters.airlines.isEmpty || !filters.years.isEmpty { Image(systemName: "arrow.clockwise")
ScrollView(.horizontal, showsIndicators: false) { .font(.system(size: 14, weight: .bold))
HStack(spacing: 6) { .foregroundStyle(.white)
ForEach(Array(filters.years).sorted(by: >), id: \.self) { y in .frame(width: 36, height: 36)
drawerChip("\(y)") { filters.years.remove(y) } .background(HistoryStyle.runwayOrange, in: Circle())
} }
ForEach(Array(filters.airlines).sorted(), id: \.self) { a in .buttonStyle(.plain)
drawerChip(a) { filters.airlines.remove(a) } }
} .padding(.horizontal, 18)
ForEach(Array(filters.airports).sorted(), id: \.self) { a in .padding(.vertical, 12)
drawerChip(a) { filters.airports.remove(a) } }
}
@ViewBuilder
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) {
HStack(spacing: 6) {
ForEach(Array(filters.years).sorted(by: >), id: \.self) { y in
drawerChip("\(y)") { filters.years.remove(y) }
}
ForEach(Array(filters.airlines).sorted(), id: \.self) { a in
drawerChip(a) { filters.airlines.remove(a) }
}
ForEach(Array(filters.airports).sorted(), id: \.self) { a in
drawerChip(a) { filters.airports.remove(a) }
} }
.padding(.horizontal, 18)
} }
} }
} }
.padding(.bottom, 22)
// 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)
} }
.frame(maxWidth: .infinity) .padding(.horizontal, 18)
.background( .padding(.vertical, 14)
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
}
let airports = firstArrivalForAirport.compactMap { iata, lit -> AirportLight? in
guard let coord = airportCoords[iata] else { return nil }
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
)
} }
/// Polyline samples along the great-circle path between two private static func dotSizeFor(count: Int) -> CGFloat {
/// coordinates. MapKit doesn't draw GC paths natively we // log scale, 9pt 22pt
/// approximate with N straight segments along the GC route. 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))
} }
} }