Initial commit: SportsTime trip planning app
- Three-scenario planning engine (A: date range, B: selected games, C: directional routes) - GeographicRouteExplorer with anchor game support for route exploration - Shared ItineraryBuilder for travel segment calculation - TravelEstimator for driving time/distance estimation - SwiftUI views for trip creation and detail display - CloudKit integration for schedule data - Python scraping scripts for sports schedules 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
180
SportsTime/Planning/Engine/TravelEstimator.swift
Normal file
180
SportsTime/Planning/Engine/TravelEstimator.swift
Normal file
@@ -0,0 +1,180 @@
|
||||
//
|
||||
// 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
|
||||
}
|
||||
|
||||
// Calculate times (assume 8 AM departure)
|
||||
let departureTime = from.departureDate.addingTimeInterval(8 * 3600)
|
||||
let arrivalTime = departureTime.addingTimeInterval(drivingHours * 3600)
|
||||
|
||||
return TravelSegment(
|
||||
fromLocation: from.location,
|
||||
toLocation: to.location,
|
||||
travelMode: .drive,
|
||||
distanceMeters: distanceMiles * 1609.34,
|
||||
durationSeconds: drivingHours * 3600,
|
||||
departureTime: departureTime,
|
||||
arrivalTime: arrivalTime
|
||||
)
|
||||
}
|
||||
|
||||
/// 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
|
||||
}
|
||||
|
||||
let departureTime = Date()
|
||||
let arrivalTime = departureTime.addingTimeInterval(drivingHours * 3600)
|
||||
|
||||
return TravelSegment(
|
||||
fromLocation: from,
|
||||
toLocation: to,
|
||||
travelMode: .drive,
|
||||
distanceMeters: distanceMeters * roadRoutingFactor,
|
||||
durationSeconds: drivingHours * 3600,
|
||||
departureTime: departureTime,
|
||||
arrivalTime: arrivalTime
|
||||
)
|
||||
}
|
||||
|
||||
// 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 = 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
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user