- 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>
254 lines
8.6 KiB
Swift
254 lines
8.6 KiB
Swift
//
|
|
// 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
|
|
}
|
|
}
|