Travel segment architecture: - Remove departureTime/arrivalTime from TravelSegment (location-based, not date-based) - Fix travel sections appearing after destination instead of between cities - Fix missing travel segments when revisiting same city (consecutive grouping) - Remove unwanted rest day at end of trip Planning engine fixes: - All three planners now group only consecutive games at same stadium - Visiting A → B → A creates 3 stops with proper travel between UI simplification: - Remove redundant sort options (mostDriving/leastDriving, mostCities/leastCities) - Remove unused "Find Other Sports Along Route" toggle (was dead code) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
170 lines
5.3 KiB
Swift
170 lines
5.3 KiB
Swift
//
|
|
// 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 only if the segment exceeds maximum driving time.
|
|
static func estimate(
|
|
from: ItineraryStop,
|
|
to: ItineraryStop,
|
|
constraints: DrivingConstraints
|
|
) -> TravelSegment? {
|
|
|
|
let distanceMiles = calculateDistanceMiles(from: from, to: to)
|
|
let drivingHours = distanceMiles / averageSpeedMph
|
|
|
|
// Reject if segment requires more than 2 days of driving
|
|
let maxDailyHours = constraints.maxDailyDrivingHours
|
|
if drivingHours > maxDailyHours * 2 {
|
|
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 segment exceeds max driving time.
|
|
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
|
|
|
|
// Reject if > 2 days of driving
|
|
if drivingHours > constraints.maxDailyDrivingHours * 2 {
|
|
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..<daysOfDriving {
|
|
if let nextDay = calendar.date(byAdding: .day, value: dayOffset, to: startDay) {
|
|
days.append(nextDay)
|
|
}
|
|
}
|
|
|
|
return days
|
|
}
|
|
|
|
}
|