// // TravelEstimator.swift // SportsTime // // Shared travel estimation logic used by all scenario planners. // Estimating travel from A to B is the same regardless of planning scenario. // import Foundation import CoreLocation enum TravelEstimator { // MARK: - Constants private static let averageSpeedMph: Double = 60.0 private static let roadRoutingFactor: Double = 1.3 // Straight line to road distance private static let fallbackDistanceMiles: Double = 300.0 // MARK: - Travel Estimation /// Estimates a travel segment between two stops. /// Returns nil if trip exceeds maximum allowed driving hours (2 days worth). static func estimate( from: ItineraryStop, to: ItineraryStop, constraints: DrivingConstraints ) -> TravelSegment? { let distanceMiles = calculateDistanceMiles(from: from, to: to) let drivingHours = distanceMiles / averageSpeedMph // Maximum allowed: 5 days of driving (matches GameDAGRouter.maxDayLookahead) // This allows multi-day cross-country segments like Chicago → Anaheim let maxAllowedHours = constraints.maxDailyDrivingHours * 5.0 if drivingHours > maxAllowedHours { return nil } return TravelSegment( fromLocation: from.location, toLocation: to.location, travelMode: .drive, distanceMeters: distanceMiles * 1609.34, durationSeconds: drivingHours * 3600 ) } /// Estimates a travel segment between two LocationInputs. /// Returns nil if coordinates are missing or if trip exceeds maximum allowed driving hours (2 days worth). static func estimate( from: LocationInput, to: LocationInput, constraints: DrivingConstraints ) -> TravelSegment? { guard let fromCoord = from.coordinate, let toCoord = to.coordinate else { return nil } let distanceMeters = haversineDistanceMeters(from: fromCoord, to: toCoord) let distanceMiles = distanceMeters * 0.000621371 * roadRoutingFactor let drivingHours = distanceMiles / averageSpeedMph // Maximum allowed: 5 days of driving (matches GameDAGRouter.maxDayLookahead) // This allows multi-day cross-country segments like Chicago → Anaheim let maxAllowedHours = constraints.maxDailyDrivingHours * 5.0 if drivingHours > maxAllowedHours { return nil } return TravelSegment( fromLocation: from, toLocation: to, travelMode: .drive, distanceMeters: distanceMeters * roadRoutingFactor, durationSeconds: drivingHours * 3600 ) } // MARK: - Distance Calculations /// Calculates distance in miles between two stops. /// Uses Haversine formula if coordinates available, fallback otherwise. static func calculateDistanceMiles( from: ItineraryStop, to: ItineraryStop ) -> Double { if let fromCoord = from.coordinate, let toCoord = to.coordinate { return haversineDistanceMiles(from: fromCoord, to: toCoord) * roadRoutingFactor } return estimateFallbackDistance(from: from, to: to) } /// Calculates distance in miles between two coordinates using Haversine. static func haversineDistanceMiles( from: CLLocationCoordinate2D, to: CLLocationCoordinate2D ) -> Double { let earthRadiusMiles = 3958.8 let lat1 = from.latitude * .pi / 180 let lat2 = to.latitude * .pi / 180 let deltaLat = (to.latitude - from.latitude) * .pi / 180 let deltaLon = (to.longitude - from.longitude) * .pi / 180 let a = sin(deltaLat / 2) * sin(deltaLat / 2) + cos(lat1) * cos(lat2) * sin(deltaLon / 2) * sin(deltaLon / 2) let c = 2 * atan2(sqrt(a), sqrt(1 - a)) return earthRadiusMiles * c } /// Calculates distance in meters between two coordinates using Haversine. static func haversineDistanceMeters( from: CLLocationCoordinate2D, to: CLLocationCoordinate2D ) -> Double { let earthRadiusMeters = 6371000.0 let lat1 = from.latitude * .pi / 180 let lat2 = to.latitude * .pi / 180 let deltaLat = (to.latitude - from.latitude) * .pi / 180 let deltaLon = (to.longitude - from.longitude) * .pi / 180 let a = sin(deltaLat / 2) * sin(deltaLat / 2) + cos(lat1) * cos(lat2) * sin(deltaLon / 2) * sin(deltaLon / 2) let c = 2 * atan2(sqrt(a), sqrt(1 - a)) return earthRadiusMeters * c } /// Fallback distance when coordinates aren't available. static func estimateFallbackDistance( from: ItineraryStop, to: ItineraryStop ) -> Double { if from.city == to.city { return 0 } return fallbackDistanceMiles } // MARK: - Travel Days /// Calculates which calendar days travel spans. static func calculateTravelDays( departure: Date, drivingHours: Double ) -> [Date] { var days: [Date] = [] let calendar = Calendar.current let startDay = calendar.startOfDay(for: departure) days.append(startDay) // Add days if driving takes multiple days (8 hrs/day max) let daysOfDriving = max(1, Int(ceil(drivingHours / 8.0))) for dayOffset in 1..