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:
127
SportsTime/Planning/Validators/DateRangeValidator.swift
Normal file
127
SportsTime/Planning/Validators/DateRangeValidator.swift
Normal file
@@ -0,0 +1,127 @@
|
||||
//
|
||||
// DateRangeValidator.swift
|
||||
// SportsTime
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// Validates that all games fall within the specified date range.
|
||||
/// Priority 1 in the rule hierarchy - checked first before any other constraints.
|
||||
struct DateRangeValidator {
|
||||
|
||||
// MARK: - Validation Result
|
||||
|
||||
struct ValidationResult {
|
||||
let isValid: Bool
|
||||
let violations: [ConstraintViolation]
|
||||
let gamesOutsideRange: [UUID]
|
||||
|
||||
static let valid = ValidationResult(isValid: true, violations: [], gamesOutsideRange: [])
|
||||
|
||||
static func invalid(games: [UUID]) -> ValidationResult {
|
||||
let violations = games.map { gameId in
|
||||
ConstraintViolation(
|
||||
type: .dateRange,
|
||||
description: "Game \(gameId.uuidString.prefix(8)) falls outside the specified date range",
|
||||
severity: .error
|
||||
)
|
||||
}
|
||||
return ValidationResult(isValid: false, violations: violations, gamesOutsideRange: games)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Validation
|
||||
|
||||
/// Validates that ALL selected games (must-see games) fall within the date range.
|
||||
/// This is a HARD constraint - if any selected game is outside the range, planning fails.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - mustSeeGameIds: Set of game IDs that MUST be included in the trip
|
||||
/// - allGames: All available games to check against
|
||||
/// - startDate: Start of the valid date range (inclusive)
|
||||
/// - endDate: End of the valid date range (inclusive)
|
||||
/// - Returns: ValidationResult indicating success or failure with specific violations
|
||||
func validate(
|
||||
mustSeeGameIds: Set<UUID>,
|
||||
allGames: [Game],
|
||||
startDate: Date,
|
||||
endDate: Date
|
||||
) -> ValidationResult {
|
||||
// If no must-see games, validation passes
|
||||
guard !mustSeeGameIds.isEmpty else {
|
||||
return .valid
|
||||
}
|
||||
|
||||
// Find all must-see games that fall outside the range
|
||||
let gamesOutsideRange = allGames
|
||||
.filter { mustSeeGameIds.contains($0.id) }
|
||||
.filter { game in
|
||||
game.dateTime < startDate || game.dateTime > endDate
|
||||
}
|
||||
.map { $0.id }
|
||||
|
||||
if gamesOutsideRange.isEmpty {
|
||||
return .valid
|
||||
} else {
|
||||
return .invalid(games: gamesOutsideRange)
|
||||
}
|
||||
}
|
||||
|
||||
/// Validates games for Scenario B (Selected Games mode).
|
||||
/// ALL selected games MUST be within the date range - no exceptions.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - request: The planning request containing preferences and games
|
||||
/// - Returns: ValidationResult with explicit failure if any selected game is out of range
|
||||
func validateForScenarioB(_ request: PlanningRequest) -> ValidationResult {
|
||||
return validate(
|
||||
mustSeeGameIds: request.preferences.mustSeeGameIds,
|
||||
allGames: request.availableGames,
|
||||
startDate: request.preferences.startDate,
|
||||
endDate: request.preferences.endDate
|
||||
)
|
||||
}
|
||||
|
||||
/// Checks if there are any games available within the date range.
|
||||
/// Used to determine if planning can proceed at all.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - games: All available games
|
||||
/// - startDate: Start of the valid date range
|
||||
/// - endDate: End of the valid date range
|
||||
/// - sports: Sports to filter by
|
||||
/// - Returns: True if at least one game exists in the range
|
||||
func hasGamesInRange(
|
||||
games: [Game],
|
||||
startDate: Date,
|
||||
endDate: Date,
|
||||
sports: Set<Sport>
|
||||
) -> Bool {
|
||||
games.contains { game in
|
||||
game.dateTime >= startDate &&
|
||||
game.dateTime <= endDate &&
|
||||
sports.contains(game.sport)
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns all games that fall within the specified date range.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - games: All available games
|
||||
/// - startDate: Start of the valid date range
|
||||
/// - endDate: End of the valid date range
|
||||
/// - sports: Sports to filter by
|
||||
/// - Returns: Array of games within the range
|
||||
func gamesInRange(
|
||||
games: [Game],
|
||||
startDate: Date,
|
||||
endDate: Date,
|
||||
sports: Set<Sport>
|
||||
) -> [Game] {
|
||||
games.filter { game in
|
||||
game.dateTime >= startDate &&
|
||||
game.dateTime <= endDate &&
|
||||
sports.contains(game.sport)
|
||||
}
|
||||
}
|
||||
}
|
||||
200
SportsTime/Planning/Validators/DrivingFeasibilityValidator.swift
Normal file
200
SportsTime/Planning/Validators/DrivingFeasibilityValidator.swift
Normal file
@@ -0,0 +1,200 @@
|
||||
//
|
||||
// DrivingFeasibilityValidator.swift
|
||||
// SportsTime
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// Validates driving feasibility based on daily hour limits.
|
||||
/// Priority 4 in the rule hierarchy.
|
||||
///
|
||||
/// A route is valid ONLY IF:
|
||||
/// - Daily driving time ≤ maxDailyDrivingHours for EVERY day
|
||||
/// - Games are reachable between scheduled times
|
||||
struct DrivingFeasibilityValidator {
|
||||
|
||||
// MARK: - Validation Result
|
||||
|
||||
struct ValidationResult {
|
||||
let isValid: Bool
|
||||
let violations: [ConstraintViolation]
|
||||
let failedSegment: SegmentFailure?
|
||||
|
||||
static let valid = ValidationResult(isValid: true, violations: [], failedSegment: nil)
|
||||
|
||||
static func drivingExceeded(
|
||||
segment: String,
|
||||
requiredHours: Double,
|
||||
limitHours: Double
|
||||
) -> ValidationResult {
|
||||
let violation = ConstraintViolation(
|
||||
type: .drivingTime,
|
||||
description: "\(segment) requires \(String(format: "%.1f", requiredHours)) hours driving (limit: \(String(format: "%.1f", limitHours)) hours)",
|
||||
severity: .error
|
||||
)
|
||||
let failure = SegmentFailure(
|
||||
segmentDescription: segment,
|
||||
requiredHours: requiredHours,
|
||||
limitHours: limitHours
|
||||
)
|
||||
return ValidationResult(isValid: false, violations: [violation], failedSegment: failure)
|
||||
}
|
||||
|
||||
static func gameUnreachable(
|
||||
gameId: UUID,
|
||||
arrivalTime: Date,
|
||||
gameTime: Date
|
||||
) -> ValidationResult {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateStyle = .short
|
||||
formatter.timeStyle = .short
|
||||
let violation = ConstraintViolation(
|
||||
type: .gameReachability,
|
||||
description: "Cannot arrive (\(formatter.string(from: arrivalTime))) before game starts (\(formatter.string(from: gameTime)))",
|
||||
severity: .error
|
||||
)
|
||||
return ValidationResult(isValid: false, violations: [violation], failedSegment: nil)
|
||||
}
|
||||
}
|
||||
|
||||
struct SegmentFailure {
|
||||
let segmentDescription: String
|
||||
let requiredHours: Double
|
||||
let limitHours: Double
|
||||
}
|
||||
|
||||
// MARK: - Properties
|
||||
|
||||
let constraints: DrivingConstraints
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
init(constraints: DrivingConstraints = .default) {
|
||||
self.constraints = constraints
|
||||
}
|
||||
|
||||
init(from preferences: TripPreferences) {
|
||||
self.constraints = DrivingConstraints(from: preferences)
|
||||
}
|
||||
|
||||
// MARK: - Validation
|
||||
|
||||
/// Validates that a single travel segment is feasible within daily driving limits.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - drivingHours: Required driving hours for this segment
|
||||
/// - origin: Description of the origin location
|
||||
/// - destination: Description of the destination location
|
||||
/// - Returns: ValidationResult indicating if the segment is feasible
|
||||
func validateSegment(
|
||||
drivingHours: Double,
|
||||
origin: String,
|
||||
destination: String
|
||||
) -> ValidationResult {
|
||||
let maxDaily = constraints.maxDailyDrivingHours
|
||||
|
||||
// A segment is valid if it can be completed in one day OR
|
||||
// can be split across multiple days with overnight stops
|
||||
if drivingHours <= maxDaily {
|
||||
return .valid
|
||||
}
|
||||
|
||||
// Check if it can be reasonably split across multiple days
|
||||
// We allow up to 2 driving days for a single segment
|
||||
let maxTwoDayDriving = maxDaily * 2
|
||||
if drivingHours <= maxTwoDayDriving {
|
||||
return .valid
|
||||
}
|
||||
|
||||
// Segment requires more than 2 days of driving - too long
|
||||
return .drivingExceeded(
|
||||
segment: "\(origin) → \(destination)",
|
||||
requiredHours: drivingHours,
|
||||
limitHours: maxDaily
|
||||
)
|
||||
}
|
||||
|
||||
/// Validates that a game can be reached in time given departure time and driving duration.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - gameId: ID of the game to reach
|
||||
/// - gameTime: When the game starts
|
||||
/// - departureTime: When we leave the previous stop
|
||||
/// - drivingHours: Hours of driving required
|
||||
/// - bufferHours: Buffer time needed before game (default 1 hour for parking, etc.)
|
||||
/// - Returns: ValidationResult indicating if we can reach the game in time
|
||||
func validateGameReachability(
|
||||
gameId: UUID,
|
||||
gameTime: Date,
|
||||
departureTime: Date,
|
||||
drivingHours: Double,
|
||||
bufferHours: Double = 1.0
|
||||
) -> ValidationResult {
|
||||
// Calculate arrival time
|
||||
let drivingSeconds = drivingHours * 3600
|
||||
let bufferSeconds = bufferHours * 3600
|
||||
let arrivalTime = departureTime.addingTimeInterval(drivingSeconds)
|
||||
let requiredArrivalTime = gameTime.addingTimeInterval(-bufferSeconds)
|
||||
|
||||
if arrivalTime <= requiredArrivalTime {
|
||||
return .valid
|
||||
} else {
|
||||
return .gameUnreachable(
|
||||
gameId: gameId,
|
||||
arrivalTime: arrivalTime,
|
||||
gameTime: gameTime
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Validates an entire itinerary's travel segments for driving feasibility.
|
||||
///
|
||||
/// - Parameter segments: Array of travel segments to validate
|
||||
/// - Returns: ValidationResult with first failure found, or valid if all pass
|
||||
func validateItinerary(segments: [TravelSegment]) -> ValidationResult {
|
||||
for segment in segments {
|
||||
let result = validateSegment(
|
||||
drivingHours: segment.estimatedDrivingHours,
|
||||
origin: segment.fromLocation.name,
|
||||
destination: segment.toLocation.name
|
||||
)
|
||||
if !result.isValid {
|
||||
return result
|
||||
}
|
||||
}
|
||||
return .valid
|
||||
}
|
||||
|
||||
/// Calculates how many travel days are needed for a given driving distance.
|
||||
///
|
||||
/// - Parameter drivingHours: Total hours of driving required
|
||||
/// - Returns: Number of calendar days the travel will span
|
||||
func travelDaysRequired(for drivingHours: Double) -> Int {
|
||||
let maxDaily = constraints.maxDailyDrivingHours
|
||||
if drivingHours <= maxDaily {
|
||||
return 1
|
||||
}
|
||||
return Int(ceil(drivingHours / maxDaily))
|
||||
}
|
||||
|
||||
/// Determines if an overnight stop is needed between two points.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - drivingHours: Hours of driving between points
|
||||
/// - departureTime: When we plan to leave
|
||||
/// - Returns: True if an overnight stop is recommended
|
||||
func needsOvernightStop(drivingHours: Double, departureTime: Date) -> Bool {
|
||||
// If driving exceeds daily limit, we need an overnight
|
||||
if drivingHours > constraints.maxDailyDrivingHours {
|
||||
return true
|
||||
}
|
||||
|
||||
// Also check if arrival would be unreasonably late
|
||||
let arrivalTime = departureTime.addingTimeInterval(drivingHours * 3600)
|
||||
let calendar = Calendar.current
|
||||
let arrivalHour = calendar.component(.hour, from: arrivalTime)
|
||||
|
||||
// Arriving after 11 PM suggests we should have stopped
|
||||
return arrivalHour >= 23
|
||||
}
|
||||
}
|
||||
229
SportsTime/Planning/Validators/GeographicSanityChecker.swift
Normal file
229
SportsTime/Planning/Validators/GeographicSanityChecker.swift
Normal file
@@ -0,0 +1,229 @@
|
||||
//
|
||||
// GeographicSanityChecker.swift
|
||||
// SportsTime
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import CoreLocation
|
||||
|
||||
/// Validates geographic sanity of routes - no zig-zagging or excessive backtracking.
|
||||
/// Priority 5 in the rule hierarchy.
|
||||
///
|
||||
/// For Scenario C (directional routes with start+end):
|
||||
/// - Route MUST make net progress toward the end location
|
||||
/// - Temporary increases in distance are allowed only if minor and followed by progress
|
||||
/// - Large backtracking or oscillation is prohibited
|
||||
///
|
||||
/// For all scenarios:
|
||||
/// - Detects obvious zig-zag patterns (e.g., Chicago → Dallas → San Diego → Minnesota → NY)
|
||||
struct GeographicSanityChecker {
|
||||
|
||||
// MARK: - Validation Result
|
||||
|
||||
struct ValidationResult {
|
||||
let isValid: Bool
|
||||
let violations: [ConstraintViolation]
|
||||
let backtrackingDetails: BacktrackingInfo?
|
||||
|
||||
static let valid = ValidationResult(isValid: true, violations: [], backtrackingDetails: nil)
|
||||
|
||||
static func backtracking(info: BacktrackingInfo) -> ValidationResult {
|
||||
let violation = ConstraintViolation(
|
||||
type: .geographicSanity,
|
||||
description: info.description,
|
||||
severity: .error
|
||||
)
|
||||
return ValidationResult(isValid: false, violations: [violation], backtrackingDetails: info)
|
||||
}
|
||||
}
|
||||
|
||||
struct BacktrackingInfo {
|
||||
let fromCity: String
|
||||
let toCity: String
|
||||
let distanceIncreasePercent: Double
|
||||
let description: String
|
||||
|
||||
init(fromCity: String, toCity: String, distanceIncreasePercent: Double) {
|
||||
self.fromCity = fromCity
|
||||
self.toCity = toCity
|
||||
self.distanceIncreasePercent = distanceIncreasePercent
|
||||
self.description = "Route backtracks from \(fromCity) to \(toCity) (distance to destination increased by \(String(format: "%.0f", distanceIncreasePercent))%)"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Configuration
|
||||
|
||||
/// Maximum allowed distance increase before flagging as backtracking (percentage)
|
||||
private let maxAllowedDistanceIncrease: Double = 0.15 // 15%
|
||||
|
||||
/// Number of consecutive distance increases before flagging as zig-zag
|
||||
private let maxConsecutiveIncreases: Int = 2
|
||||
|
||||
// MARK: - Scenario C: Directional Route Validation
|
||||
|
||||
/// Validates that a route makes monotonic progress toward the end location.
|
||||
/// This is the primary validation for Scenario C (start + end location).
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - stops: Ordered array of stops in the route
|
||||
/// - endCoordinate: The target destination coordinate
|
||||
/// - Returns: ValidationResult indicating if route has valid directional progress
|
||||
func validateDirectionalProgress(
|
||||
stops: [ItineraryStop],
|
||||
endCoordinate: CLLocationCoordinate2D
|
||||
) -> ValidationResult {
|
||||
guard stops.count >= 2 else {
|
||||
return .valid // Single stop or empty route is trivially valid
|
||||
}
|
||||
|
||||
var consecutiveIncreases = 0
|
||||
var previousDistance: CLLocationDistance?
|
||||
var previousCity: String?
|
||||
|
||||
for stop in stops {
|
||||
guard let coordinate = stop.coordinate else { continue }
|
||||
|
||||
let currentDistance = distance(from: coordinate, to: endCoordinate)
|
||||
|
||||
if let prevDist = previousDistance, let prevCity = previousCity {
|
||||
if currentDistance > prevDist {
|
||||
// Distance to end increased - potential backtracking
|
||||
let increasePercent = (currentDistance - prevDist) / prevDist
|
||||
consecutiveIncreases += 1
|
||||
|
||||
// Check if this increase is too large
|
||||
if increasePercent > maxAllowedDistanceIncrease {
|
||||
return .backtracking(info: BacktrackingInfo(
|
||||
fromCity: prevCity,
|
||||
toCity: stop.city,
|
||||
distanceIncreasePercent: increasePercent * 100
|
||||
))
|
||||
}
|
||||
|
||||
// Check for oscillation (too many consecutive increases)
|
||||
if consecutiveIncreases >= maxConsecutiveIncreases {
|
||||
return .backtracking(info: BacktrackingInfo(
|
||||
fromCity: prevCity,
|
||||
toCity: stop.city,
|
||||
distanceIncreasePercent: increasePercent * 100
|
||||
))
|
||||
}
|
||||
} else {
|
||||
// Making progress - reset counter
|
||||
consecutiveIncreases = 0
|
||||
}
|
||||
}
|
||||
|
||||
previousDistance = currentDistance
|
||||
previousCity = stop.city
|
||||
}
|
||||
|
||||
return .valid
|
||||
}
|
||||
|
||||
// MARK: - General Geographic Sanity
|
||||
|
||||
/// Validates that a route doesn't have obvious zig-zag patterns.
|
||||
/// Uses compass bearing analysis to detect direction reversals.
|
||||
///
|
||||
/// - Parameter stops: Ordered array of stops in the route
|
||||
/// - Returns: ValidationResult indicating if route is geographically sane
|
||||
func validateNoZigZag(stops: [ItineraryStop]) -> ValidationResult {
|
||||
guard stops.count >= 3 else {
|
||||
return .valid // Need at least 3 stops to detect zig-zag
|
||||
}
|
||||
|
||||
var bearingReversals = 0
|
||||
var previousBearing: Double?
|
||||
|
||||
for i in 0..<(stops.count - 1) {
|
||||
guard let from = stops[i].coordinate,
|
||||
let to = stops[i + 1].coordinate else { continue }
|
||||
|
||||
let currentBearing = bearing(from: from, to: to)
|
||||
|
||||
if let prevBearing = previousBearing {
|
||||
// Check if we've reversed direction (>90 degree change)
|
||||
let bearingChange = abs(normalizedBearingDifference(prevBearing, currentBearing))
|
||||
if bearingChange > 90 {
|
||||
bearingReversals += 1
|
||||
}
|
||||
}
|
||||
|
||||
previousBearing = currentBearing
|
||||
}
|
||||
|
||||
// Allow at most one major direction change (e.g., going east then north is fine)
|
||||
// But multiple reversals indicate zig-zagging
|
||||
if bearingReversals > 1 {
|
||||
return .backtracking(info: BacktrackingInfo(
|
||||
fromCity: stops.first?.city ?? "Start",
|
||||
toCity: stops.last?.city ?? "End",
|
||||
distanceIncreasePercent: Double(bearingReversals) * 30 // Rough estimate
|
||||
))
|
||||
}
|
||||
|
||||
return .valid
|
||||
}
|
||||
|
||||
/// Validates a complete route for both directional progress (if end is specified)
|
||||
/// and general geographic sanity.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - stops: Ordered array of stops
|
||||
/// - endCoordinate: Optional end coordinate for directional validation
|
||||
/// - Returns: Combined validation result
|
||||
func validate(
|
||||
stops: [ItineraryStop],
|
||||
endCoordinate: CLLocationCoordinate2D?
|
||||
) -> ValidationResult {
|
||||
// If we have an end coordinate, validate directional progress
|
||||
if let end = endCoordinate {
|
||||
let directionalResult = validateDirectionalProgress(stops: stops, endCoordinate: end)
|
||||
if !directionalResult.isValid {
|
||||
return directionalResult
|
||||
}
|
||||
}
|
||||
|
||||
// Always check for zig-zag patterns
|
||||
return validateNoZigZag(stops: stops)
|
||||
}
|
||||
|
||||
// MARK: - Helper Methods
|
||||
|
||||
/// Calculates distance between two coordinates in meters.
|
||||
private func distance(
|
||||
from: CLLocationCoordinate2D,
|
||||
to: CLLocationCoordinate2D
|
||||
) -> CLLocationDistance {
|
||||
let fromLocation = CLLocation(latitude: from.latitude, longitude: from.longitude)
|
||||
let toLocation = CLLocation(latitude: to.latitude, longitude: to.longitude)
|
||||
return fromLocation.distance(from: toLocation)
|
||||
}
|
||||
|
||||
/// Calculates bearing (direction) from one coordinate to another in degrees.
|
||||
private func bearing(
|
||||
from: CLLocationCoordinate2D,
|
||||
to: CLLocationCoordinate2D
|
||||
) -> Double {
|
||||
let lat1 = from.latitude * .pi / 180
|
||||
let lat2 = to.latitude * .pi / 180
|
||||
let dLon = (to.longitude - from.longitude) * .pi / 180
|
||||
|
||||
let y = sin(dLon) * cos(lat2)
|
||||
let x = cos(lat1) * sin(lat2) - sin(lat1) * cos(lat2) * cos(dLon)
|
||||
|
||||
var bearing = atan2(y, x) * 180 / .pi
|
||||
bearing = (bearing + 360).truncatingRemainder(dividingBy: 360)
|
||||
|
||||
return bearing
|
||||
}
|
||||
|
||||
/// Calculates the normalized difference between two bearings (-180 to 180).
|
||||
private func normalizedBearingDifference(_ bearing1: Double, _ bearing2: Double) -> Double {
|
||||
var diff = bearing2 - bearing1
|
||||
while diff > 180 { diff -= 360 }
|
||||
while diff < -180 { diff += 360 }
|
||||
return diff
|
||||
}
|
||||
}
|
||||
253
SportsTime/Planning/Validators/MustStopValidator.swift
Normal file
253
SportsTime/Planning/Validators/MustStopValidator.swift
Normal file
@@ -0,0 +1,253 @@
|
||||
//
|
||||
// MustStopValidator.swift
|
||||
// SportsTime
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import CoreLocation
|
||||
|
||||
/// Validates that must-stop locations are reachable by the route.
|
||||
/// Priority 6 in the rule hierarchy (lowest priority).
|
||||
///
|
||||
/// A route "passes" a must-stop location if:
|
||||
/// - Any travel segment comes within the proximity threshold (default 25 miles)
|
||||
/// - The must-stop does NOT require a separate overnight stay
|
||||
struct MustStopValidator {
|
||||
|
||||
// MARK: - Validation Result
|
||||
|
||||
struct ValidationResult {
|
||||
let isValid: Bool
|
||||
let violations: [ConstraintViolation]
|
||||
let unreachableLocations: [String]
|
||||
|
||||
static let valid = ValidationResult(isValid: true, violations: [], unreachableLocations: [])
|
||||
|
||||
static func unreachable(locations: [String]) -> ValidationResult {
|
||||
let violations = locations.map { location in
|
||||
ConstraintViolation(
|
||||
type: .mustStop,
|
||||
description: "Required stop '\(location)' is not reachable within \(Int(MustStopConfig.defaultProximityMiles)) miles of any route segment",
|
||||
severity: .error
|
||||
)
|
||||
}
|
||||
return ValidationResult(isValid: false, violations: violations, unreachableLocations: locations)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Properties
|
||||
|
||||
let config: MustStopConfig
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
init(config: MustStopConfig = MustStopConfig()) {
|
||||
self.config = config
|
||||
}
|
||||
|
||||
// MARK: - Validation
|
||||
|
||||
/// Validates that all must-stop locations can be reached by the route.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - mustStopLocations: Array of locations that must be visited/passed
|
||||
/// - stops: The planned stops in the itinerary
|
||||
/// - segments: The travel segments between stops
|
||||
/// - Returns: ValidationResult indicating if all must-stops are reachable
|
||||
func validate(
|
||||
mustStopLocations: [LocationInput],
|
||||
stops: [ItineraryStop],
|
||||
segments: [TravelSegment]
|
||||
) -> ValidationResult {
|
||||
guard !mustStopLocations.isEmpty else {
|
||||
return .valid
|
||||
}
|
||||
|
||||
var unreachable: [String] = []
|
||||
|
||||
for mustStop in mustStopLocations {
|
||||
if !isReachable(mustStop: mustStop, stops: stops, segments: segments) {
|
||||
unreachable.append(mustStop.name)
|
||||
}
|
||||
}
|
||||
|
||||
if unreachable.isEmpty {
|
||||
return .valid
|
||||
} else {
|
||||
return .unreachable(locations: unreachable)
|
||||
}
|
||||
}
|
||||
|
||||
/// Validates must-stop locations from a planning request.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - request: The planning request with must-stop preferences
|
||||
/// - stops: The planned stops
|
||||
/// - segments: The travel segments
|
||||
/// - Returns: ValidationResult
|
||||
func validate(
|
||||
request: PlanningRequest,
|
||||
stops: [ItineraryStop],
|
||||
segments: [TravelSegment]
|
||||
) -> ValidationResult {
|
||||
return validate(
|
||||
mustStopLocations: request.preferences.mustStopLocations,
|
||||
stops: stops,
|
||||
segments: segments
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Reachability Check
|
||||
|
||||
/// Checks if a must-stop location is reachable by the route.
|
||||
/// A location is reachable if:
|
||||
/// 1. It's within proximity of any stop, OR
|
||||
/// 2. It's within proximity of any travel segment path
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - mustStop: The location to check
|
||||
/// - stops: Planned stops
|
||||
/// - segments: Travel segments
|
||||
/// - Returns: True if the location is reachable
|
||||
private func isReachable(
|
||||
mustStop: LocationInput,
|
||||
stops: [ItineraryStop],
|
||||
segments: [TravelSegment]
|
||||
) -> Bool {
|
||||
guard let mustStopCoord = mustStop.coordinate else {
|
||||
// If we don't have coordinates, we can't validate - assume reachable
|
||||
return true
|
||||
}
|
||||
|
||||
// Check if any stop is within proximity
|
||||
for stop in stops {
|
||||
if let stopCoord = stop.coordinate {
|
||||
let distance = distanceInMiles(from: mustStopCoord, to: stopCoord)
|
||||
if distance <= config.proximityMiles {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check if any segment passes within proximity
|
||||
for segment in segments {
|
||||
if isNearSegment(point: mustStopCoord, segment: segment) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/// Checks if a point is near a travel segment.
|
||||
/// Uses perpendicular distance to the segment line.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - point: The point to check
|
||||
/// - segment: The travel segment
|
||||
/// - Returns: True if within proximity
|
||||
private func isNearSegment(
|
||||
point: CLLocationCoordinate2D,
|
||||
segment: TravelSegment
|
||||
) -> Bool {
|
||||
guard let originCoord = segment.fromLocation.coordinate,
|
||||
let destCoord = segment.toLocation.coordinate else {
|
||||
return false
|
||||
}
|
||||
|
||||
// Calculate perpendicular distance from point to segment
|
||||
let distance = perpendicularDistance(
|
||||
point: point,
|
||||
lineStart: originCoord,
|
||||
lineEnd: destCoord
|
||||
)
|
||||
|
||||
return distance <= config.proximityMiles
|
||||
}
|
||||
|
||||
/// Calculates the minimum distance from a point to a line segment in miles.
|
||||
/// Uses the perpendicular distance if the projection falls on the segment,
|
||||
/// otherwise uses the distance to the nearest endpoint.
|
||||
private func perpendicularDistance(
|
||||
point: CLLocationCoordinate2D,
|
||||
lineStart: CLLocationCoordinate2D,
|
||||
lineEnd: CLLocationCoordinate2D
|
||||
) -> Double {
|
||||
let pointLoc = CLLocation(latitude: point.latitude, longitude: point.longitude)
|
||||
let startLoc = CLLocation(latitude: lineStart.latitude, longitude: lineStart.longitude)
|
||||
let endLoc = CLLocation(latitude: lineEnd.latitude, longitude: lineEnd.longitude)
|
||||
|
||||
let lineLength = startLoc.distance(from: endLoc)
|
||||
|
||||
// Handle degenerate case where start == end
|
||||
if lineLength < 1 {
|
||||
return pointLoc.distance(from: startLoc) / 1609.34 // meters to miles
|
||||
}
|
||||
|
||||
// Calculate projection parameter t
|
||||
// t = ((P - A) · (B - A)) / |B - A|²
|
||||
let dx = endLoc.coordinate.longitude - startLoc.coordinate.longitude
|
||||
let dy = endLoc.coordinate.latitude - startLoc.coordinate.latitude
|
||||
let px = point.longitude - lineStart.longitude
|
||||
let py = point.latitude - lineStart.latitude
|
||||
|
||||
let t = max(0, min(1, (px * dx + py * dy) / (dx * dx + dy * dy)))
|
||||
|
||||
// Calculate closest point on segment
|
||||
let closestLat = lineStart.latitude + t * dy
|
||||
let closestLon = lineStart.longitude + t * dx
|
||||
let closestLoc = CLLocation(latitude: closestLat, longitude: closestLon)
|
||||
|
||||
// Return distance in miles
|
||||
return pointLoc.distance(from: closestLoc) / 1609.34
|
||||
}
|
||||
|
||||
/// Calculates distance between two coordinates in miles.
|
||||
private func distanceInMiles(
|
||||
from: CLLocationCoordinate2D,
|
||||
to: CLLocationCoordinate2D
|
||||
) -> Double {
|
||||
let fromLoc = CLLocation(latitude: from.latitude, longitude: from.longitude)
|
||||
let toLoc = CLLocation(latitude: to.latitude, longitude: to.longitude)
|
||||
return fromLoc.distance(from: toLoc) / 1609.34 // meters to miles
|
||||
}
|
||||
|
||||
// MARK: - Route Modification
|
||||
|
||||
/// Finds the best position to insert a must-stop location into an itinerary.
|
||||
/// Used when we need to add an explicit stop for a must-stop location.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - mustStop: The location to insert
|
||||
/// - stops: Current stops in order
|
||||
/// - Returns: The index where the stop should be inserted (1-based, between existing stops)
|
||||
func bestInsertionIndex(
|
||||
for mustStop: LocationInput,
|
||||
in stops: [ItineraryStop]
|
||||
) -> Int {
|
||||
guard let mustStopCoord = mustStop.coordinate, stops.count >= 2 else {
|
||||
return 1 // Insert after first stop
|
||||
}
|
||||
|
||||
var bestIndex = 1
|
||||
var minDetour = Double.greatestFiniteMagnitude
|
||||
|
||||
for i in 0..<(stops.count - 1) {
|
||||
guard let fromCoord = stops[i].coordinate,
|
||||
let toCoord = stops[i + 1].coordinate else { continue }
|
||||
|
||||
// Calculate detour: (from→mustStop + mustStop→to) - (from→to)
|
||||
let direct = distanceInMiles(from: fromCoord, to: toCoord)
|
||||
let via = distanceInMiles(from: fromCoord, to: mustStopCoord) +
|
||||
distanceInMiles(from: mustStopCoord, to: toCoord)
|
||||
let detour = via - direct
|
||||
|
||||
if detour < minDetour {
|
||||
minDetour = detour
|
||||
bestIndex = i + 1
|
||||
}
|
||||
}
|
||||
|
||||
return bestIndex
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user