Files
Sportstime/SportsTime/Planning/Validators/MustStopValidator.swift
Trey t 9088b46563 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>
2026-01-07 00:46:40 -06:00

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: (frommustStop + mustStopto) - (fromto)
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
}
}