diff --git a/Shared/Map Utilities/MapUtilities.swift b/Shared/Map Utilities/MapUtilities.swift new file mode 100644 index 0000000..40d6bc5 --- /dev/null +++ b/Shared/Map Utilities/MapUtilities.swift @@ -0,0 +1,265 @@ +// +// MapUtilities.swift +// Shuttle Tracker +// +// Geographic utilities for shuttle animation along polylines. +// + +import CoreLocation + +// MARK: - Find Nearest Point on Polyline + +/// Result of finding the nearest point on a polyline +struct NearestPointResult { + let index: Int // Index of segment start + let point: CLLocationCoordinate2D // Projected point on polyline + let distance: CLLocationDistance // Distance from target to projected point (meters) +} + +/// Finds the nearest point on a polyline to a given coordinate. +/// - Parameters: +/// - target: The coordinate to find the nearest point to +/// - polyline: Array of coordinates forming the polyline +/// - Returns: The segment index, projected point, and distance +func findNearestPointOnPolyline( + target: CLLocationCoordinate2D, + polyline: [CLLocationCoordinate2D] +) -> NearestPointResult { + guard polyline.count >= 2 else { + return NearestPointResult(index: 0, point: polyline.first ?? target, distance: 0) + } + + var minDistance = CLLocationDistance.infinity + var bestIndex = 0 + var bestPoint = polyline[0] + + for i in 0..<(polyline.count - 1) { + let p1 = polyline[i] + let p2 = polyline[i + 1] + + let projected = projectPointOnSegment(point: target, p1: p1, p2: p2) + let dist = distance(from: target, to: projected) + + if dist < minDistance { + minDistance = dist + bestIndex = i + bestPoint = projected + } + } + + return NearestPointResult(index: bestIndex, point: bestPoint, distance: minDistance) +} + +// MARK: - Point Projection + +/// Projects a point onto a line segment. +/// Uses Euclidean approximation with longitude scaling for local accuracy. +/// - Parameters: +/// - point: The point to project +/// - p1: Start of segment +/// - p2: End of segment +/// - Returns: The projected point on the segment +private func projectPointOnSegment( + point: CLLocationCoordinate2D, + p1: CLLocationCoordinate2D, + p2: CLLocationCoordinate2D +) -> CLLocationCoordinate2D { + // Scale longitude by cos(latitude) to handle convergence at poles + let meanLat = (p1.latitude + p2.latitude) / 2.0 * .pi / 180.0 + let cosLat = cos(meanLat) + + // Segment vector B + let bx = (p2.longitude - p1.longitude) * cosLat + let by = p2.latitude - p1.latitude + + // Segment length squared + let l2 = bx * bx + by * by + guard l2 > 0 else { return p1 } + + // Vector from p1 to point + let ax = (point.longitude - p1.longitude) * cosLat + let ay = point.latitude - p1.latitude + + // Project A onto B: t = (A · B) / |B|² + var t = (ax * bx + ay * by) / l2 + t = max(0, min(1, t)) // Clamp to segment + + return CLLocationCoordinate2D( + latitude: p1.latitude + t * (p2.latitude - p1.latitude), + longitude: p1.longitude + t * (p2.longitude - p1.longitude) + ) +} + +// MARK: - Move Along Polyline + +/// Result of moving along a polyline +struct MoveResult { + let index: Int + let point: CLLocationCoordinate2D +} + +/// Moves a point along the polyline by a specified distance. +/// - Parameters: +/// - polyline: The route polyline +/// - startIndex: Index of segment containing startPoint +/// - startPoint: Current position on polyline +/// - distanceMeters: Distance to move (positive = forward, negative = backward) +/// - Returns: New position on polyline +func moveAlongPolyline( + polyline: [CLLocationCoordinate2D], + startIndex: Int, + startPoint: CLLocationCoordinate2D, + distanceMeters: Double +) -> MoveResult { + if distanceMeters < 0 { + return moveBackward(polyline: polyline, startIndex: startIndex, startPoint: startPoint, distanceMeters: -distanceMeters) + } + + var currentIndex = startIndex + var currentPoint = startPoint + var remainingDist = distanceMeters + + while remainingDist > 0 && currentIndex < polyline.count - 1 { + let nextPoint = polyline[currentIndex + 1] + let segmentDist = distance(from: currentPoint, to: nextPoint) + + if remainingDist <= segmentDist { + // Target is on this segment + let ratio = remainingDist / segmentDist + let newLat = currentPoint.latitude + (nextPoint.latitude - currentPoint.latitude) * ratio + let newLon = currentPoint.longitude + (nextPoint.longitude - currentPoint.longitude) * ratio + return MoveResult(index: currentIndex, point: CLLocationCoordinate2D(latitude: newLat, longitude: newLon)) + } else { + // Move to next segment + remainingDist -= segmentDist + currentPoint = nextPoint + currentIndex += 1 + } + } + + // Reached end of polyline + return MoveResult(index: polyline.count - 1, point: polyline[polyline.count - 1]) +} + +/// Moves backward along the polyline. +private func moveBackward( + polyline: [CLLocationCoordinate2D], + startIndex: Int, + startPoint: CLLocationCoordinate2D, + distanceMeters: Double +) -> MoveResult { + var currentIndex = startIndex + var currentPoint = startPoint + var remainingDist = distanceMeters + + while remainingDist > 0 && currentIndex >= 0 { + let prevPoint = polyline[currentIndex] + let segmentDist = distance(from: currentPoint, to: prevPoint) + + if remainingDist <= segmentDist { + let ratio = remainingDist / segmentDist + let newLat = currentPoint.latitude + (prevPoint.latitude - currentPoint.latitude) * ratio + let newLon = currentPoint.longitude + (prevPoint.longitude - currentPoint.longitude) * ratio + return MoveResult(index: currentIndex, point: CLLocationCoordinate2D(latitude: newLat, longitude: newLon)) + } else { + remainingDist -= segmentDist + currentPoint = prevPoint + currentIndex -= 1 + } + } + + // Reached start of polyline + return MoveResult(index: 0, point: polyline[0]) +} + +// MARK: - Distance Along Polyline + +/// Calculates distance along polyline between two points. +/// Handles circular routes where startIndex > endIndex. +func calculateDistanceAlongPolyline( + polyline: [CLLocationCoordinate2D], + startIndex: Int, + startPoint: CLLocationCoordinate2D, + endIndex: Int, + endPoint: CLLocationCoordinate2D +) -> Double { + if startIndex <= endIndex { + return calculateForwardDistance(polyline: polyline, startIndex: startIndex, startPoint: startPoint, endIndex: endIndex, endPoint: endPoint) + } + + // Circular route wrap-around + let distToEnd = calculateForwardDistance( + polyline: polyline, + startIndex: startIndex, + startPoint: startPoint, + endIndex: polyline.count - 2, + endPoint: polyline[polyline.count - 1] + ) + let distFromStart = calculateForwardDistance( + polyline: polyline, + startIndex: 0, + startPoint: polyline[0], + endIndex: endIndex, + endPoint: endPoint + ) + return distToEnd + distFromStart +} + +private func calculateForwardDistance( + polyline: [CLLocationCoordinate2D], + startIndex: Int, + startPoint: CLLocationCoordinate2D, + endIndex: Int, + endPoint: CLLocationCoordinate2D +) -> Double { + if startIndex == endIndex { + return distance(from: startPoint, to: endPoint) + } + + var total: Double = 0 + + // Distance from startPoint to end of its segment + total += distance(from: startPoint, to: polyline[startIndex + 1]) + + // Full segments in between + for i in (startIndex + 1).. Double { + let lat1 = start.latitude * .pi / 180 + let lat2 = end.latitude * .pi / 180 + let diffLong = (end.longitude - start.longitude) * .pi / 180 + + let x = sin(diffLong) * cos(lat2) + let y = cos(lat1) * sin(lat2) - sin(lat1) * cos(lat2) * cos(diffLong) + + let initialBearing = atan2(x, y) + return (initialBearing * 180 / .pi + 360).truncatingRemainder(dividingBy: 360) +} + +/// Calculates the smallest difference between two angles. +/// - Returns: Difference in degrees (0-180) +func angleDifference(_ angle1: Double, _ angle2: Double) -> Double { + let diff = abs(angle1 - angle2).truncatingRemainder(dividingBy: 360) + return diff > 180 ? 360 - diff : diff +} + +// MARK: - Helper using native CLLocation + +/// Distance between two coordinates in meters using native CoreLocation. +private func distance(from c1: CLLocationCoordinate2D, to c2: CLLocationCoordinate2D) -> CLLocationDistance { + let loc1 = CLLocation(latitude: c1.latitude, longitude: c1.longitude) + let loc2 = CLLocation(latitude: c2.latitude, longitude: c2.longitude) + return loc1.distance(from: loc2) +} diff --git a/Shared/Views/MapView.swift b/Shared/Views/MapView.swift index 1d3870c..bea7966 100644 --- a/Shared/Views/MapView.swift +++ b/Shared/Views/MapView.swift @@ -1,15 +1,17 @@ import MapKit import SwiftUI +import os struct MapView: View { @AppStorage("hasSeenOnboarding") private var hasSeenOnboarding: Bool = false @State private var showOnboarding = false - @State private var showSheet = false @State private var showSettings = false @AppStorage("isDeveloperMode") private var isDeveloperMode: Bool = false @State private var showDeveloperPanel = false + private let logger = Logger(subsystem: "com.shuttletracker", category: "mapview") + @State private var region = MKCoordinateRegion( center: CLLocationCoordinate2D.RensselaerUnion, span: MKCoordinateSpan( @@ -18,22 +20,24 @@ struct MapView: View { ) ) @StateObject private var locationManager = LocationManager() - @State private var vehicleLocations: VehicleInformationMap = [:] + @StateObject private var animationManager = ShuttleAnimationManager() @StateObject private var routeManager = RouteDataManager() + @State private var vehicleLocations: VehicleInformationMap = [:] @State private var timer: Timer? var body: some View { ZStack { Map(position: .constant(.region(region))) { - // Add vehicle markers + // Add vehicle markers with animated positions ForEach(Array(vehicleLocations), id: \.key) { vehicleId, vehicle in + let animatedCoord = + animationManager.animatedPositions[vehicleId] + ?? CLLocationCoordinate2D(latitude: vehicle.latitude, longitude: vehicle.longitude) + Marker( vehicle.name, systemImage: "bus.fill", - coordinate: CLLocationCoordinate2D( - latitude: vehicle.latitude, - longitude: vehicle.longitude - ) + coordinate: animatedCoord ) .tint(routeColor(for: vehicle.routeName)) } @@ -152,19 +156,25 @@ struct MapView: View { .onAppear { fetchLocations() // Routes are now managed by RouteDataManager + animationManager.startAnimating() if !hasSeenOnboarding { showOnboarding = true } - timer = Timer.scheduledTimer(withTimeInterval: 5.0, repeats: true) { _ in - fetchLocations() + // Only create timer if one doesn't already exist (prevents leaks on re-appear) + if timer == nil { + timer = Timer.scheduledTimer(withTimeInterval: 5.0, repeats: true) { _ in + fetchLocations() + } } } .onDisappear { timer?.invalidate() timer = nil - }.sheet(isPresented: $showOnboarding) { + animationManager.stopAnimating() + } + .sheet(isPresented: $showOnboarding) { VStack(spacing: 16) { Image(systemName: "location.circle.fill").font(.system(size: 56)).foregroundStyle(.tint) Text("We use your location to show nearby shuttles") @@ -206,9 +216,11 @@ struct MapView: View { VehicleInformationMap.self, endpoint: "locations") await MainActor.run { vehicleLocations = locations + // Feed data to animation manager for smooth interpolation + animationManager.updateVehicleData(locations, routes: routeManager.routes) } } catch { - print("Error fetching vehicle locations: \(error)") + logger.error("Error fetching vehicle locations: \(error)") } } } diff --git a/Shared/Views/ShuttleAnimationManager.swift b/Shared/Views/ShuttleAnimationManager.swift new file mode 100644 index 0000000..7694cc5 --- /dev/null +++ b/Shared/Views/ShuttleAnimationManager.swift @@ -0,0 +1,352 @@ +// +// ShuttleAnimationManager.swift +// Shuttle Tracker +// +// Manages smooth animation of shuttle positions along route polylines. +// + +import Combine +import CoreLocation +import Foundation +import QuartzCore +import os + +/// State for animating a single vehicle +struct VehicleAnimationState { + var polylineIndex: Int + var currentPoint: CLLocationCoordinate2D + var targetDistance: Double + var distanceTraveled: Double + var lastUpdateTime: Date + var lastServerTimestamp: String +} + +// MARK: - Constants + +private enum AnimationConstants { + /// Prediction window in seconds (matches server update interval) + static let predictionWindowSeconds: Double = 5.0 + /// Maximum gap before snapping to server position (meters) + static let maxReasonableGapMeters: Double = 250.0 + /// Conversion factor from mph to meters per second + static let mphToMetersPerSecond: Double = 0.44704 + /// Maximum heading deviation before considering wrong direction (degrees) + static let maxHeadingDeviationDegrees: Double = 90.0 + /// Maximum frame time delta before skipping animation (seconds) + static let maxFrameDeltaSeconds: Double = 1.0 +} + +/// Manages smooth shuttle animation using prediction-based interpolation. +/// Uses CADisplayLink for 60fps updates, predicting shuttle positions between +/// 5-second server updates to eliminate visual "jumping". +@MainActor +class ShuttleAnimationManager: ObservableObject { + /// Published positions for SwiftUI to observe + @Published var animatedPositions: [String: CLLocationCoordinate2D] = [:] + + /// Internal animation state per vehicle + private var animationStates: [String: VehicleAnimationState] = [:] + + /// Flattened route polylines for each route name + private var routePolylines: [String: [CLLocationCoordinate2D]] = [:] + + /// Cached route keys for invalidation detection + private var cachedRouteKeys: Set = [] + + /// Current vehicle data from server + private var vehicles: VehicleInformationMap = [:] + + /// Display link for animation loop + private var displayLink: CADisplayLink? + + /// Time of last animation frame + private var lastFrameTime: Date = Date() + + /// Logger for diagnostics + private let logger = Logger(subsystem: "com.shuttletracker", category: "animation") + + // MARK: - Public API + + /// Updates vehicle data and route polylines. + /// Called when new data arrives from the API. + func updateVehicleData(_ vehicles: VehicleInformationMap, routes: ShuttleRouteData) { + self.vehicles = vehicles + + // Rebuild polylines if routes have changed + let currentRouteKeys = Set(routes.keys) + if routePolylines.isEmpty || cachedRouteKeys != currentRouteKeys { + buildRoutePolylines(from: routes) + cachedRouteKeys = currentRouteKeys + } + + // Update animation states for each vehicle + let now = Date() + + for (key, vehicle) in vehicles { + // Skip vehicles without a valid route + guard let routeName = vehicle.routeName, + let polyline = routePolylines[routeName], + polyline.count >= 2 + else { + // Fall back to server position for unrouted vehicles + animatedPositions[key] = CLLocationCoordinate2D( + latitude: vehicle.latitude, + longitude: vehicle.longitude + ) + continue + } + + let vehicleCoord = CLLocationCoordinate2D( + latitude: vehicle.latitude, longitude: vehicle.longitude) + let serverTime = vehicle.timestamp + + // Check if we already have animation state + if let animState = animationStates[key] { + // Skip if server data hasn't changed (cached response) + if animState.lastServerTimestamp == serverTime { + continue + } + + // Calculate new target using prediction smoothing + updateAnimationState( + key: key, vehicle: vehicle, animState: animState, polyline: polyline, now: now) + } else { + // New vehicle - snap to polyline + snapToPolyline( + key: key, vehicleCoord: vehicleCoord, polyline: polyline, serverTime: serverTime, now: now + ) + } + } + + // Remove stale vehicles + let currentKeys = Set(vehicles.keys) + for key in animationStates.keys { + if !currentKeys.contains(key) { + animationStates.removeValue(forKey: key) + animatedPositions.removeValue(forKey: key) + } + } + } + + /// Starts the animation loop. + func startAnimating() { + guard displayLink == nil else { return } + + lastFrameTime = Date() + + // Use a trampoline to avoid capturing @MainActor self directly + let trampoline = DisplayLinkTrampoline { [weak self] in + Task { @MainActor in + self?.animationTick() + } + } + + displayLink = CADisplayLink(target: trampoline, selector: #selector(DisplayLinkTrampoline.tick)) + displayLink?.add(to: .main, forMode: .common) + } + + /// Stops the animation loop. + func stopAnimating() { + displayLink?.invalidate() + displayLink = nil + } + + /// Invalidates the route polyline cache, forcing a rebuild on next update. + func invalidateRouteCache() { + routePolylines.removeAll() + cachedRouteKeys.removeAll() + } + + // MARK: - Private Methods + + /// Builds flattened polyline arrays from route data. + private func buildRoutePolylines(from routes: ShuttleRouteData) { + routePolylines.removeAll() + + for (routeName, routeData) in routes { + var flattenedCoords: [CLLocationCoordinate2D] = [] + + for segment in routeData.routes { + for (i, coordPair) in segment.enumerated() { + guard coordPair.count == 2 else { continue } + let coord = CLLocationCoordinate2D(latitude: coordPair[0], longitude: coordPair[1]) + + // Avoid duplicates at segment boundaries + if i == 0 && !flattenedCoords.isEmpty { + if let last = flattenedCoords.last, + abs(last.latitude - coord.latitude) < 0.00001 + && abs(last.longitude - coord.longitude) < 0.00001 + { + continue + } + } + flattenedCoords.append(coord) + } + } + + if flattenedCoords.count >= 2 { + routePolylines[routeName] = flattenedCoords + } + } + + logger.debug("Built polylines for \(routes.count) routes") + } + + /// Snaps a vehicle to its nearest point on the polyline. + private func snapToPolyline( + key: String, + vehicleCoord: CLLocationCoordinate2D, + polyline: [CLLocationCoordinate2D], + serverTime: String, + now: Date + ) { + let result = findNearestPointOnPolyline(target: vehicleCoord, polyline: polyline) + + animationStates[key] = VehicleAnimationState( + polylineIndex: result.index, + currentPoint: result.point, + targetDistance: 0, + distanceTraveled: 0, + lastUpdateTime: now, + lastServerTimestamp: serverTime + ) + + animatedPositions[key] = result.point + } + + /// Updates animation state using prediction smoothing algorithm. + private func updateAnimationState( + key: String, + vehicle: VehicleLocationData, + animState: VehicleAnimationState, + polyline: [CLLocationCoordinate2D], + now: Date + ) { + let vehicleCoord = CLLocationCoordinate2D( + latitude: vehicle.latitude, longitude: vehicle.longitude) + + // Step 1: Find where server says vehicle is now + let serverResult = findNearestPointOnPolyline(target: vehicleCoord, polyline: polyline) + + // Step 2: Calculate projected target position + let speedMetersPerSecond = vehicle.speedMph * AnimationConstants.mphToMetersPerSecond + let projectedDistance = speedMetersPerSecond * AnimationConstants.predictionWindowSeconds + + let targetResult = moveAlongPolyline( + polyline: polyline, + startIndex: serverResult.index, + startPoint: serverResult.point, + distanceMeters: projectedDistance + ) + + // Step 3: Verify direction (optional - check heading matches route bearing) + var isMovingCorrectDirection = true + if polyline.count > serverResult.index + 1 && vehicle.speedMph > 1 { + let segmentStart = polyline[serverResult.index] + let segmentEnd = polyline[serverResult.index + 1] + let segmentBearing = calculateBearing(from: segmentStart, to: segmentEnd) + let headingDiff = angleDifference(segmentBearing, vehicle.headingDegrees) + + if headingDiff > AnimationConstants.maxHeadingDeviationDegrees { + isMovingCorrectDirection = false + } + } + + // Step 4: Calculate distance from current visual position to target + let distanceToTarget = calculateDistanceAlongPolyline( + polyline: polyline, + startIndex: animState.polylineIndex, + startPoint: animState.currentPoint, + endIndex: targetResult.index, + endPoint: targetResult.point + ) + + // Step 5: Determine target distance with direction check + var targetDistanceMeters = distanceToTarget + if !isMovingCorrectDirection { + targetDistanceMeters = 0 + } + + // Step 6: Snap or smooth based on gap size + if abs(distanceToTarget) > AnimationConstants.maxReasonableGapMeters { + // Too far - snap to server position + snapToPolyline( + key: key, vehicleCoord: vehicleCoord, polyline: polyline, serverTime: vehicle.timestamp, + now: now) + } else { + // Smooth animation + animationStates[key] = VehicleAnimationState( + polylineIndex: animState.polylineIndex, + currentPoint: animState.currentPoint, + targetDistance: targetDistanceMeters, + distanceTraveled: 0, + lastUpdateTime: now, + lastServerTimestamp: vehicle.timestamp + ) + } + } + + /// Animation tick called by display link trampoline. + private func animationTick() { + let now = Date() + let dt = now.timeIntervalSince(lastFrameTime) + lastFrameTime = now + + // Skip large jumps (app was backgrounded) + guard dt < AnimationConstants.maxFrameDeltaSeconds else { return } + + for key in animationStates.keys { + guard var animState = animationStates[key], + let vehicle = vehicles[key], + let routeName = vehicle.routeName, + let polyline = routePolylines[routeName] + else { + continue + } + + // Calculate progress through prediction window + let timeElapsed = now.timeIntervalSince(animState.lastUpdateTime) + let progress = min(timeElapsed / AnimationConstants.predictionWindowSeconds, 1.0) + + // Linear interpolation of target distance + let targetPosition = animState.targetDistance * progress + let distanceToMove = targetPosition - animState.distanceTraveled + + // Skip if no movement needed + guard distanceToMove != 0 else { continue } + + // Move along polyline + let moveResult = moveAlongPolyline( + polyline: polyline, + startIndex: animState.polylineIndex, + startPoint: animState.currentPoint, + distanceMeters: distanceToMove + ) + + // Update state + animState.polylineIndex = moveResult.index + animState.currentPoint = moveResult.point + animState.distanceTraveled = targetPosition + animationStates[key] = animState + + // Publish new position + animatedPositions[key] = moveResult.point + } + } +} + +// MARK: - Display Link Trampoline + +/// Helper class to bridge CADisplayLink to @MainActor isolated code. +/// Avoids Swift 6 concurrency issues with capturing @MainActor self. +private class DisplayLinkTrampoline { + private let callback: () -> Void + + init(callback: @escaping () -> Void) { + self.callback = callback + } + + @objc func tick() { + callback() + } +}