From f97d5f52ec4a4d46a4ee5f3823c94dcb5dd777cf Mon Sep 17 00:00:00 2001 From: Trey T Date: Fri, 29 May 2026 18:26:51 -0500 Subject: [PATCH] =?UTF-8?q?Route=20map:=20full=20rewrite=20=E2=80=94=20pla?= =?UTF-8?q?ne=20fly-through,=20fit=20camera,=20real=20drawer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- Flights/Views/HistoryRouteMapView.swift | 634 +++++++++++++++++------- 1 file changed, 445 insertions(+), 189 deletions(-) diff --git a/Flights/Views/HistoryRouteMapView.swift b/Flights/Views/HistoryRouteMapView.swift index 7dc7a75..8f83a41 100644 --- a/Flights/Views/HistoryRouteMapView.swift +++ b/Flights/Views/HistoryRouteMapView.swift @@ -2,28 +2,41 @@ import SwiftUI import MapKit import CoreLocation -/// Interactive lifetime route map. -/// - Animated great-circle arcs for every flight in the current -/// (filtered) view, drawn oldest → newest on first appear. -/// - Airport dots sized log-scale by visit count (across the whole -/// log, not just the filtered set, so the map's geography stays -/// stable as the user toggles filters). -/// - Tap an airport dot → present every flight through that airport. -/// - Tap an arc → jump straight to that flight's detail. +/// Lifetime route map — redesigned. +/// +/// What it does, in order: +/// 1. On appear (and whenever filters change), the camera fits to +/// the bounding region of every filtered flight's dep + arr +/// airports with padding. You always see your data. +/// 2. An animation auto-plays from oldest flight to newest. Each +/// 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 { - let flights: [LoggedFlight] // already filtered by HistoryView - let allFlights: [LoggedFlight] // unfiltered, used for visit counts + let flights: [LoggedFlight] // filtered + let allFlights: [LoggedFlight] // unfiltered (kept for parent-API compat) let database: AirportDatabase let openSky: OpenSkyClient let store: FlightHistoryStore @Binding var filters: HistoryFilters - @State private var revealCount: Int = 0 @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 selectedFlight: LoggedFlight? - @State private var revealKey: Int = 0 // bump to retrigger the reveal animation - @State private var drawerExpanded: Bool = false + @State private var sheetDetent: PresentationDetent = .fraction(0.14) + + /// ~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 { let iata: String @@ -31,57 +44,37 @@ struct HistoryRouteMapView: View { } var body: some View { - let arcs = self.arcs - - 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 + ZStack { + mapLayer } - .navigationTitle(filters.isEmpty ? "Lifetime Routes" : "Filtered Routes") + .navigationTitle(filters.isEmpty ? "Routes" : "Filtered Routes") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .primaryAction) { - Button { - revealCount = 0 - revealKey += 1 - } label: { - Image(systemName: "play.circle") + Button { restart() } label: { + Image(systemName: "arrow.clockwise.circle.fill") + .font(.title3) + .foregroundStyle(HistoryStyle.runwayOrange) } } } - .task(id: "\(revealKey)-\(arcs.count)") { - // Stagger reveal — capped so 200+ flights still finish - // animating in a couple seconds. - revealCount = 0 - let step = max(0.012, min(0.04, 4.0 / Double(arcs.count + 1))) - for i in 0...arcs.count { - revealCount = i - try? await Task.sleep(nanoseconds: UInt64(step * 1_000_000_000)) - if Task.isCancelled { return } - } + // Recompute the schedule and the camera fit on first appear + // and whenever the filtered flight set changes. + .onAppear { reset() } + .onChange(of: filters) { _, _ in reset() } + // Drive the progress with a Task loop. Re-runs on animationKey + // bump (which restart() triggers). + .task(id: animationKey) { + await runAnimation() + } + // 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 NavigationStack { @@ -96,124 +89,160 @@ struct HistoryRouteMapView: View { } .presentationDetents([.medium, .large]) } - .sheet(item: $selectedFlight) { flight in - NavigationStack { - HistoryDetailView( - flight: flight, - store: store, - database: database, - openSky: openSky - ) + } + + // MARK: - Map + + private var mapLayer: some View { + Map(position: $position) { + // 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 - - 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 + // MARK: - Bottom drawer (sheet) @ViewBuilder - private var passportDrawer: some View { - VStack(spacing: 8) { - // Tab handle - Capsule() - .fill(.white.opacity(0.4)) - .frame(width: 36, height: 5) - .padding(.top, 8) + private var mapDrawer: some View { + VStack(spacing: 0) { + drawerPeek + if sheetDetent != .fraction(0.14) { + Divider() + drawerExpanded + } + Spacer(minLength: 0) + } + } - VStack(spacing: 12) { - HStack { - VStack(alignment: .leading, spacing: 2) { - Text(filters.isEmpty ? "ALL TIME" : "FILTERED") - .font(.system(size: 10, weight: .heavy)) - .tracking(2) - .foregroundStyle(HistoryStyle.runwayOrange) - Text("\(flights.count) flights · \(numberStringMiles())") - .font(.system(size: 16, weight: .heavy)) - .foregroundStyle(.white) - } - Spacer() - Button { - revealCount = 0 - revealKey += 1 - } label: { - Image(systemName: "play.fill") - .font(.system(size: 12, weight: .bold)) - .padding(10) - .background(.white.opacity(0.18), in: Circle()) - .foregroundStyle(.white) - } + private var drawerPeek: some View { + HStack(spacing: 14) { + VStack(alignment: .leading, spacing: 1) { + Text(filters.isEmpty ? "ALL TIME" : "FILTERED") + .font(.system(size: 10, weight: .heavy)) + .tracking(2) + .foregroundStyle(HistoryStyle.runwayOrange) + Text("\(flights.count) flights · \(totalMilesString)") + .font(.system(size: 17, weight: .heavy)) + .foregroundStyle(.primary) + } + Spacer(minLength: 12) + // Animation progress + ZStack { + Circle() + .stroke(.gray.opacity(0.18), lineWidth: 3) + Circle() + .trim(from: 0, to: progress) + .stroke(HistoryStyle.runwayOrange, + style: StrokeStyle(lineWidth: 3, lineCap: .round)) + .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) - if !filters.airports.isEmpty || !filters.airlines.isEmpty || !filters.years.isEmpty { - 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) } - } + 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(.vertical, 12) + } + + @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) - .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) + .padding(.horizontal, 18) + .padding(.vertical, 14) } private func drawerChip(_ label: String, onRemove: @escaping () -> Void) -> some View { HStack(spacing: 4) { - Text(label) - .font(.system(size: 11, weight: .bold).monospaced()) - Image(systemName: "xmark") - .font(.system(size: 8, weight: .bold)) + Text(label).font(.system(size: 11, weight: .bold).monospaced()) + Image(systemName: "xmark").font(.system(size: 8, weight: .bold)) } .foregroundStyle(.white) .padding(.horizontal, 10) @@ -222,46 +251,192 @@ struct HistoryRouteMapView: View { .onTapGesture(perform: onRemove) } - private func numberStringMiles() -> String { - let total = flights.reduce(0) { acc, f in - acc + (store.distanceMiles(for: f) ?? 0) + private func statTile(label: String, value: String) -> some View { + VStack(alignment: .leading, spacing: 2) { + 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() f.numberStyle = .decimal return (f.string(from: NSNumber(value: total)) ?? "\(total)") + " mi" } - // MARK: - Airports + // MARK: - Lifecycle - private struct AirportItem: Hashable { - let iata: String - let coord: CLLocationCoordinate2D - let count: Int - - static func == (lhs: AirportItem, rhs: AirportItem) -> Bool { lhs.iata == rhs.iata } - func hash(into hasher: inout Hasher) { hasher.combine(iata) } + /// Build the schedule and snap the camera to fit before the + /// animation begins. Called on appear and on every filter change. + private func reset() { + schedule = AnimationSchedule.build(flights: flights, database: database, totalDuration: Self.totalDuration) + if let region = schedule.fitRegion { + position = .region(region) + } + progress = 0 + animationKey += 1 } - /// Airport dot universe is the FULL flight log (not the filtered - /// view) so panning around with filters on doesn't make airports - /// pop in and out as the user toggles airlines. - private var airportItems: [AirportItem] { - let codes = allFlights.flatMap { [$0.departureIATA, $0.arrivalIATA] }.filter { !$0.isEmpty } - let counts = Dictionary(grouping: codes) { $0 }.mapValues(\.count) - return counts.compactMap { code, count in - guard let m = database.airport(byIATA: code) else { return nil } - return AirportItem(iata: code, coord: m.coordinate, count: count) + private func restart() { + progress = 0 + animationKey += 1 + } + + /// Drive `progress` from 0 → 1 over `totalDuration` seconds. Uses + /// the system clock so the animation finishes in real time even + /// if a frame is dropped. + 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 { - let v = log(Double(count) + 1) * 4 + 6 - return CGFloat(min(20, max(7, v))) +// MARK: - Animation schedule +// +// 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 - /// coordinates. MapKit doesn't draw GC paths natively — we - /// approximate with N straight segments along the GC route. + 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] { let lat1 = a.latitude * .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 -/// when the user is currently filtering on that airport. -private struct AirportDot: View { +/// One animatable flight segment. `coordsVisible` slices the pre- +/// computed great-circle array based on the global animation +/// 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 timeSinceLit: Double 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 { Circle() .fill(isSelected ? Color.yellow : HistoryStyle.runwayOrange) .frame(width: size, height: size) .overlay(Circle().stroke(.white, lineWidth: 2)) - .shadow(color: .black.opacity(0.5), radius: 2, y: 1) - .contentShape(Circle()) + .shadow(color: .black.opacity(0.4), radius: 3, y: 1) + .scaleEffect(pulseScale) + .contentShape(Circle().inset(by: -10)) } }