TripOptionCard improvements: - Replace horizontal route with vertical layout (start → end with arrow) - Remove rank badges (1, 2, 3, etc.) - Split stats into two rows: cities/miles and sports with game counts - Clear selection when navigating back from detail view Settings cleanup: - Remove unused settings (preferred game time, playoff games, notifications) - Convert remaining settings to sliders Planning fixes: - Fix multi-day driving calculation in canTransition - Remove over-restrictive trip rejection in TravelEstimator - Clear games cache when sport selection changes UI polish: - RoutePreviewStrip shows all cities (abbreviated) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
159 lines
4.9 KiB
Swift
159 lines
4.9 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.
|
|
/// Always creates a segment - feasibility is checked by GameDAGRouter.
|
|
static func estimate(
|
|
from: ItineraryStop,
|
|
to: ItineraryStop,
|
|
constraints: DrivingConstraints
|
|
) -> TravelSegment? {
|
|
|
|
let distanceMiles = calculateDistanceMiles(from: from, to: to)
|
|
let drivingHours = distanceMiles / averageSpeedMph
|
|
|
|
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 only if coordinates are missing.
|
|
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
|
|
|
|
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
|
|
}
|
|
|
|
}
|