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:
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