- Add RegionMapSelector UI for geographic trip filtering (East/Central/West) - Add RouteFilters module for allowRepeatCities preference - Improve GameDAGRouter to preserve route length diversity - Routes now grouped by city count before scoring - Ensures 2-city trips appear alongside longer trips - Increased beam width and max options for better coverage - Add TripOptionsView filters (max cities slider, pace filter) - Remove TravelStyle section from trip creation (replaced by region selector) - Clean up debug logging from DataProvider and ScenarioAPlanner 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
590 lines
23 KiB
Swift
590 lines
23 KiB
Swift
//
|
|
// ScenarioCPlanner.swift
|
|
// SportsTime
|
|
//
|
|
// Scenario C: Start + End location planning.
|
|
// User specifies starting city and ending city. We find games along the route.
|
|
//
|
|
// Key Features:
|
|
// - Start/End are cities with stadiums from user's selected sports
|
|
// - Directional filtering: stadiums that "generally move toward" the end
|
|
// - When only day span provided (no dates): generate date ranges from games at start/end
|
|
// - Uses GeographicRouteExplorer for sensible route exploration
|
|
// - Returns top 5 options with most games
|
|
//
|
|
// Date Range Generation (when only day span provided):
|
|
// 1. Find all games at start city's stadiums
|
|
// 2. Find all games at end city's stadiums
|
|
// 3. For each start game + end game combo within day span: create a date range
|
|
// 4. Explore routes for each date range
|
|
//
|
|
// Directional Filtering:
|
|
// - Find stadiums that make forward progress from start to end
|
|
// - "Forward progress" = distance to end decreases (with tolerance)
|
|
// - Filter games to only those at directional stadiums
|
|
//
|
|
// Example:
|
|
// Start: Chicago, End: New York, Day span: 7 days
|
|
// Start game: Jan 5 at Chicago
|
|
// End game: Jan 10 at New York
|
|
// Date range: Jan 5-10
|
|
// Directional stadiums: Detroit, Cleveland, Pittsburgh (moving east)
|
|
// NOT directional: Minneapolis, St. Louis (moving away from NY)
|
|
//
|
|
|
|
import Foundation
|
|
import CoreLocation
|
|
|
|
/// Scenario C: Directional route planning from start city to end city
|
|
/// Input: start_location, end_location, day_span (or date_range)
|
|
/// Output: Top 5 itinerary options with games along the directional route
|
|
final class ScenarioCPlanner: ScenarioPlanner {
|
|
|
|
// MARK: - Configuration
|
|
|
|
/// Tolerance for "forward progress" - allow small increases in distance to end
|
|
/// A stadium is "directional" if it doesn't increase distance to end by more than this ratio
|
|
private let forwardProgressTolerance = 0.15 // 15% tolerance
|
|
|
|
// MARK: - ScenarioPlanner Protocol
|
|
|
|
func plan(request: PlanningRequest) -> ItineraryResult {
|
|
|
|
// ──────────────────────────────────────────────────────────────────
|
|
// Step 1: Validate start and end locations
|
|
// ──────────────────────────────────────────────────────────────────
|
|
guard let startLocation = request.startLocation else {
|
|
return .failure(
|
|
PlanningFailure(
|
|
reason: .missingLocations,
|
|
violations: [
|
|
ConstraintViolation(
|
|
type: .general,
|
|
description: "Start location is required for Scenario C",
|
|
severity: .error
|
|
)
|
|
]
|
|
)
|
|
)
|
|
}
|
|
|
|
guard let endLocation = request.endLocation else {
|
|
return .failure(
|
|
PlanningFailure(
|
|
reason: .missingLocations,
|
|
violations: [
|
|
ConstraintViolation(
|
|
type: .general,
|
|
description: "End location is required for Scenario C",
|
|
severity: .error
|
|
)
|
|
]
|
|
)
|
|
)
|
|
}
|
|
|
|
guard let startCoord = startLocation.coordinate,
|
|
let endCoord = endLocation.coordinate else {
|
|
return .failure(
|
|
PlanningFailure(
|
|
reason: .missingLocations,
|
|
violations: [
|
|
ConstraintViolation(
|
|
type: .general,
|
|
description: "Start and end locations must have coordinates",
|
|
severity: .error
|
|
)
|
|
]
|
|
)
|
|
)
|
|
}
|
|
|
|
// ──────────────────────────────────────────────────────────────────
|
|
// Step 2: Find stadiums at start and end cities
|
|
// ──────────────────────────────────────────────────────────────────
|
|
let startStadiums = findStadiumsInCity(
|
|
cityName: startLocation.name,
|
|
stadiums: request.stadiums
|
|
)
|
|
let endStadiums = findStadiumsInCity(
|
|
cityName: endLocation.name,
|
|
stadiums: request.stadiums
|
|
)
|
|
|
|
if startStadiums.isEmpty {
|
|
return .failure(
|
|
PlanningFailure(
|
|
reason: .noGamesInRange,
|
|
violations: [
|
|
ConstraintViolation(
|
|
type: .general,
|
|
description: "No stadiums found in start city: \(startLocation.name)",
|
|
severity: .error
|
|
)
|
|
]
|
|
)
|
|
)
|
|
}
|
|
|
|
if endStadiums.isEmpty {
|
|
return .failure(
|
|
PlanningFailure(
|
|
reason: .noGamesInRange,
|
|
violations: [
|
|
ConstraintViolation(
|
|
type: .general,
|
|
description: "No stadiums found in end city: \(endLocation.name)",
|
|
severity: .error
|
|
)
|
|
]
|
|
)
|
|
)
|
|
}
|
|
|
|
// ──────────────────────────────────────────────────────────────────
|
|
// Step 3: Generate date ranges
|
|
// ──────────────────────────────────────────────────────────────────
|
|
let dateRanges = generateDateRanges(
|
|
startStadiumIds: Set(startStadiums.map { $0.id }),
|
|
endStadiumIds: Set(endStadiums.map { $0.id }),
|
|
allGames: request.allGames,
|
|
request: request
|
|
)
|
|
|
|
if dateRanges.isEmpty {
|
|
return .failure(
|
|
PlanningFailure(
|
|
reason: .missingDateRange,
|
|
violations: [
|
|
ConstraintViolation(
|
|
type: .dateRange,
|
|
description: "No valid date ranges found with games at both start and end cities",
|
|
severity: .error
|
|
)
|
|
]
|
|
)
|
|
)
|
|
}
|
|
|
|
// ──────────────────────────────────────────────────────────────────
|
|
// Step 4: Find directional stadiums (moving from start toward end)
|
|
// ──────────────────────────────────────────────────────────────────
|
|
let directionalStadiums = findDirectionalStadiums(
|
|
from: startCoord,
|
|
to: endCoord,
|
|
stadiums: request.stadiums
|
|
)
|
|
|
|
// ──────────────────────────────────────────────────────────────────
|
|
// Step 5: For each date range, explore routes
|
|
// ──────────────────────────────────────────────────────────────────
|
|
var allItineraryOptions: [ItineraryOption] = []
|
|
|
|
for dateRange in dateRanges {
|
|
// Find games at directional stadiums within date range
|
|
let gamesInRange = request.allGames
|
|
.filter { dateRange.contains($0.startTime) }
|
|
.filter { directionalStadiums.contains($0.stadiumId) }
|
|
.sorted { $0.startTime < $1.startTime }
|
|
|
|
guard !gamesInRange.isEmpty else { continue }
|
|
|
|
// Use GameDAGRouter for polynomial-time beam search
|
|
let validRoutes = GameDAGRouter.findAllSensibleRoutes(
|
|
from: gamesInRange,
|
|
stadiums: request.stadiums,
|
|
anchorGameIds: [], // No anchors in Scenario C
|
|
stopBuilder: buildStops
|
|
)
|
|
|
|
// Build itineraries for each valid route
|
|
for routeGames in validRoutes {
|
|
let stops = buildStopsWithEndpoints(
|
|
start: startLocation,
|
|
end: endLocation,
|
|
games: routeGames,
|
|
stadiums: request.stadiums
|
|
)
|
|
guard !stops.isEmpty else { continue }
|
|
|
|
// Validate monotonic progress toward end
|
|
guard validateMonotonicProgress(
|
|
stops: stops,
|
|
toward: endCoord
|
|
) else {
|
|
continue
|
|
}
|
|
|
|
// Use shared ItineraryBuilder
|
|
guard let itinerary = ItineraryBuilder.build(
|
|
stops: stops,
|
|
constraints: request.drivingConstraints,
|
|
logPrefix: "[ScenarioC]"
|
|
) else {
|
|
continue
|
|
}
|
|
|
|
let gameCount = routeGames.count
|
|
let cities = stops.compactMap { $0.games.isEmpty ? nil : $0.city }.joined(separator: " → ")
|
|
|
|
let option = ItineraryOption(
|
|
rank: 0, // Will re-rank later
|
|
stops: itinerary.stops,
|
|
travelSegments: itinerary.travelSegments,
|
|
totalDrivingHours: itinerary.totalDrivingHours,
|
|
totalDistanceMiles: itinerary.totalDistanceMiles,
|
|
geographicRationale: "\(startLocation.name) → \(gameCount) games → \(endLocation.name): \(cities)"
|
|
)
|
|
allItineraryOptions.append(option)
|
|
}
|
|
}
|
|
|
|
// ──────────────────────────────────────────────────────────────────
|
|
// Step 6: Return top 5 ranked results
|
|
// ──────────────────────────────────────────────────────────────────
|
|
if allItineraryOptions.isEmpty {
|
|
return .failure(
|
|
PlanningFailure(
|
|
reason: .noValidRoutes,
|
|
violations: [
|
|
ConstraintViolation(
|
|
type: .geographicSanity,
|
|
description: "No valid directional routes found from \(startLocation.name) to \(endLocation.name)",
|
|
severity: .error
|
|
)
|
|
]
|
|
)
|
|
)
|
|
}
|
|
|
|
// Sort and rank based on leisure level
|
|
let leisureLevel = request.preferences.leisureLevel
|
|
let rankedOptions = ItineraryOption.sortByLeisure(
|
|
allItineraryOptions,
|
|
leisureLevel: leisureLevel
|
|
)
|
|
|
|
return .success(Array(rankedOptions))
|
|
}
|
|
|
|
// MARK: - Stadium Finding
|
|
|
|
/// Finds all stadiums in a given city (case-insensitive match).
|
|
private func findStadiumsInCity(
|
|
cityName: String,
|
|
stadiums: [UUID: Stadium]
|
|
) -> [Stadium] {
|
|
let normalizedCity = cityName.lowercased().trimmingCharacters(in: .whitespaces)
|
|
return stadiums.values.filter { stadium in
|
|
stadium.city.lowercased().trimmingCharacters(in: .whitespaces) == normalizedCity
|
|
}
|
|
}
|
|
|
|
/// Finds stadiums that make forward progress from start to end.
|
|
///
|
|
/// A stadium is "directional" if visiting it doesn't significantly increase
|
|
/// the distance to the end point. This filters out stadiums that would
|
|
/// require backtracking.
|
|
///
|
|
/// Algorithm:
|
|
/// 1. Calculate distance from start to end
|
|
/// 2. For each stadium, calculate: distance(start, stadium) + distance(stadium, end)
|
|
/// 3. If this "detour distance" is reasonable (within tolerance), include it
|
|
///
|
|
/// The tolerance allows for stadiums slightly off the direct path.
|
|
///
|
|
private func findDirectionalStadiums(
|
|
from start: CLLocationCoordinate2D,
|
|
to end: CLLocationCoordinate2D,
|
|
stadiums: [UUID: Stadium]
|
|
) -> Set<UUID> {
|
|
let directDistance = distanceBetween(start, end)
|
|
|
|
// Allow detours up to 50% longer than direct distance
|
|
let maxDetourDistance = directDistance * 1.5
|
|
|
|
var directionalIds: Set<UUID> = []
|
|
|
|
for (id, stadium) in stadiums {
|
|
let stadiumCoord = stadium.coordinate
|
|
|
|
// Calculate the detour: start → stadium → end
|
|
let toStadium = distanceBetween(start, stadiumCoord)
|
|
let fromStadium = distanceBetween(stadiumCoord, end)
|
|
let detourDistance = toStadium + fromStadium
|
|
|
|
// Also check that stadium is making progress (closer to end than start is)
|
|
let distanceToEnd = distanceBetween(stadiumCoord, end)
|
|
|
|
// Stadium should be within the "cone" from start to end
|
|
// Either closer to end than start, or the detour is acceptable
|
|
if detourDistance <= maxDetourDistance {
|
|
// Additional check: don't include if it's behind the start point
|
|
// (i.e., distance to end is greater than original distance)
|
|
if distanceToEnd <= directDistance * (1 + forwardProgressTolerance) {
|
|
directionalIds.insert(id)
|
|
}
|
|
}
|
|
}
|
|
|
|
return directionalIds
|
|
}
|
|
|
|
// MARK: - Date Range Generation
|
|
|
|
/// Generates date ranges for Scenario C.
|
|
///
|
|
/// Two modes:
|
|
/// 1. Explicit date range provided: Use it directly
|
|
/// 2. Only day span provided: Find game combinations at start/end cities
|
|
///
|
|
/// For mode 2:
|
|
/// - Find all games at start city stadiums
|
|
/// - Find all games at end city stadiums
|
|
/// - For each (start_game, end_game) pair where end_game - start_game <= day_span:
|
|
/// Create a date range from start_game.date to end_game.date
|
|
///
|
|
private func generateDateRanges(
|
|
startStadiumIds: Set<UUID>,
|
|
endStadiumIds: Set<UUID>,
|
|
allGames: [Game],
|
|
request: PlanningRequest
|
|
) -> [DateInterval] {
|
|
|
|
// If explicit date range exists, use it
|
|
if let dateRange = request.dateRange {
|
|
return [dateRange]
|
|
}
|
|
|
|
// Otherwise, use day span to find valid combinations
|
|
let daySpan = request.preferences.effectiveTripDuration
|
|
guard daySpan > 0 else { return [] }
|
|
|
|
// Find games at start and end cities
|
|
let startGames = allGames
|
|
.filter { startStadiumIds.contains($0.stadiumId) }
|
|
.sorted { $0.startTime < $1.startTime }
|
|
|
|
let endGames = allGames
|
|
.filter { endStadiumIds.contains($0.stadiumId) }
|
|
.sorted { $0.startTime < $1.startTime }
|
|
|
|
if startGames.isEmpty || endGames.isEmpty {
|
|
return []
|
|
}
|
|
|
|
// Generate all valid (start_game, end_game) combinations
|
|
var dateRanges: [DateInterval] = []
|
|
let calendar = Calendar.current
|
|
|
|
for startGame in startGames {
|
|
let startDate = calendar.startOfDay(for: startGame.startTime)
|
|
|
|
for endGame in endGames {
|
|
let endDate = calendar.startOfDay(for: endGame.startTime)
|
|
|
|
// End must be after start
|
|
guard endDate >= startDate else { continue }
|
|
|
|
// Calculate days between
|
|
let daysBetween = calendar.dateComponents([.day], from: startDate, to: endDate).day ?? 0
|
|
|
|
// Must be within day span
|
|
guard daysBetween < daySpan else { continue }
|
|
|
|
// Create date range (end date + 1 day to include the end game)
|
|
let rangeEnd = calendar.date(byAdding: .day, value: 1, to: endDate) ?? endDate
|
|
let range = DateInterval(start: startDate, end: rangeEnd)
|
|
|
|
// Avoid duplicate ranges
|
|
if !dateRanges.contains(where: { $0.start == range.start && $0.end == range.end }) {
|
|
dateRanges.append(range)
|
|
}
|
|
}
|
|
}
|
|
|
|
return dateRanges
|
|
}
|
|
|
|
// MARK: - Stop Building
|
|
|
|
/// Converts games to stops (used by GeographicRouteExplorer callback).
|
|
/// Groups consecutive games at the same stadium into one stop.
|
|
/// Creates separate stops when visiting the same city with other cities in between.
|
|
private func buildStops(
|
|
from games: [Game],
|
|
stadiums: [UUID: Stadium]
|
|
) -> [ItineraryStop] {
|
|
guard !games.isEmpty else { return [] }
|
|
|
|
// Sort games chronologically
|
|
let sortedGames = games.sorted { $0.startTime < $1.startTime }
|
|
|
|
// Group consecutive games at the same stadium
|
|
var stops: [ItineraryStop] = []
|
|
var currentStadiumId: UUID? = nil
|
|
var currentGames: [Game] = []
|
|
|
|
for game in sortedGames {
|
|
if game.stadiumId == currentStadiumId {
|
|
// Same stadium as previous game - add to current group
|
|
currentGames.append(game)
|
|
} else {
|
|
// Different stadium - finalize previous stop (if any) and start new one
|
|
if let stadiumId = currentStadiumId, !currentGames.isEmpty {
|
|
if let stop = createStop(from: currentGames, stadiumId: stadiumId, stadiums: stadiums) {
|
|
stops.append(stop)
|
|
}
|
|
}
|
|
currentStadiumId = game.stadiumId
|
|
currentGames = [game]
|
|
}
|
|
}
|
|
|
|
// Don't forget the last group
|
|
if let stadiumId = currentStadiumId, !currentGames.isEmpty {
|
|
if let stop = createStop(from: currentGames, stadiumId: stadiumId, stadiums: stadiums) {
|
|
stops.append(stop)
|
|
}
|
|
}
|
|
|
|
return stops
|
|
}
|
|
|
|
/// Creates an ItineraryStop from a group of games at the same stadium.
|
|
private func createStop(
|
|
from games: [Game],
|
|
stadiumId: UUID,
|
|
stadiums: [UUID: Stadium]
|
|
) -> ItineraryStop? {
|
|
guard !games.isEmpty else { return nil }
|
|
|
|
let sortedGames = games.sorted { $0.startTime < $1.startTime }
|
|
let stadium = stadiums[stadiumId]
|
|
let city = stadium?.city ?? "Unknown"
|
|
let state = stadium?.state ?? ""
|
|
let coordinate = stadium?.coordinate
|
|
|
|
let location = LocationInput(
|
|
name: city,
|
|
coordinate: coordinate,
|
|
address: stadium?.fullAddress
|
|
)
|
|
|
|
// departureDate is day AFTER last game (we leave the next morning)
|
|
let lastGameDate = sortedGames.last?.gameDate ?? Date()
|
|
let departureDateValue = Calendar.current.date(byAdding: .day, value: 1, to: lastGameDate) ?? lastGameDate
|
|
|
|
return ItineraryStop(
|
|
city: city,
|
|
state: state,
|
|
coordinate: coordinate,
|
|
games: sortedGames.map { $0.id },
|
|
arrivalDate: sortedGames.first?.gameDate ?? Date(),
|
|
departureDate: departureDateValue,
|
|
location: location,
|
|
firstGameStart: sortedGames.first?.startTime
|
|
)
|
|
}
|
|
|
|
/// Builds stops with start and end location endpoints.
|
|
private func buildStopsWithEndpoints(
|
|
start: LocationInput,
|
|
end: LocationInput,
|
|
games: [Game],
|
|
stadiums: [UUID: Stadium]
|
|
) -> [ItineraryStop] {
|
|
|
|
var stops: [ItineraryStop] = []
|
|
|
|
// Start stop (no games)
|
|
let startArrival = games.first?.gameDate.addingTimeInterval(-86400) ?? Date()
|
|
let startStop = ItineraryStop(
|
|
city: start.name,
|
|
state: "",
|
|
coordinate: start.coordinate,
|
|
games: [],
|
|
arrivalDate: startArrival,
|
|
departureDate: startArrival,
|
|
location: start,
|
|
firstGameStart: nil
|
|
)
|
|
stops.append(startStop)
|
|
|
|
// Game stops
|
|
let gameStops = buildStops(from: games, stadiums: stadiums)
|
|
stops.append(contentsOf: gameStops)
|
|
|
|
// End stop (no games)
|
|
let endArrival = games.last?.gameDate.addingTimeInterval(86400) ?? Date()
|
|
let endStop = ItineraryStop(
|
|
city: end.name,
|
|
state: "",
|
|
coordinate: end.coordinate,
|
|
games: [],
|
|
arrivalDate: endArrival,
|
|
departureDate: endArrival,
|
|
location: end,
|
|
firstGameStart: nil
|
|
)
|
|
stops.append(endStop)
|
|
|
|
return stops
|
|
}
|
|
|
|
// MARK: - Monotonic Progress Validation
|
|
|
|
/// Validates that the route makes generally forward progress toward the end.
|
|
///
|
|
/// Each stop should be closer to (or not significantly farther from) the end
|
|
/// than the previous stop. Small detours are allowed within tolerance.
|
|
///
|
|
private func validateMonotonicProgress(
|
|
stops: [ItineraryStop],
|
|
toward end: CLLocationCoordinate2D
|
|
) -> Bool {
|
|
|
|
var previousDistance: Double?
|
|
|
|
for stop in stops {
|
|
guard let stopCoord = stop.coordinate else { continue }
|
|
|
|
let currentDistance = distanceBetween(stopCoord, end)
|
|
|
|
if let prev = previousDistance {
|
|
// Allow increases up to tolerance percentage
|
|
let allowedIncrease = prev * forwardProgressTolerance
|
|
if currentDistance > prev + allowedIncrease {
|
|
return false
|
|
}
|
|
}
|
|
|
|
previousDistance = currentDistance
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
// MARK: - Geometry Helpers
|
|
|
|
/// Distance between two coordinates in miles using Haversine formula.
|
|
private func distanceBetween(
|
|
_ coord1: CLLocationCoordinate2D,
|
|
_ coord2: CLLocationCoordinate2D
|
|
) -> Double {
|
|
let earthRadiusMiles = 3958.8
|
|
|
|
let lat1 = coord1.latitude * .pi / 180
|
|
let lat2 = coord2.latitude * .pi / 180
|
|
let deltaLat = (coord2.latitude - coord1.latitude) * .pi / 180
|
|
let deltaLon = (coord2.longitude - coord1.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
|
|
}
|
|
}
|