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:
278
SportsTime/Planning/Engine/GeographicRouteExplorer.swift
Normal file
278
SportsTime/Planning/Engine/GeographicRouteExplorer.swift
Normal file
@@ -0,0 +1,278 @@
|
||||
//
|
||||
// GeographicRouteExplorer.swift
|
||||
// SportsTime
|
||||
//
|
||||
// Shared logic for finding geographically sensible route variations.
|
||||
// Used by all scenario planners to explore and prune route combinations.
|
||||
//
|
||||
// Key Features:
|
||||
// - Tree exploration with pruning for route combinations
|
||||
// - Geographic sanity check using bounding box diagonal vs actual travel ratio
|
||||
// - Support for "anchor" games that cannot be removed from routes (Scenario B)
|
||||
//
|
||||
// Algorithm Overview:
|
||||
// Given games [A, B, C, D, E] in chronological order, we build a decision tree
|
||||
// where at each node we can either include or skip a game. Routes that would
|
||||
// create excessive zig-zagging are pruned. When anchors are specified, any
|
||||
// route that doesn't include ALL anchors is automatically discarded.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import CoreLocation
|
||||
|
||||
enum GeographicRouteExplorer {
|
||||
|
||||
// MARK: - Configuration
|
||||
|
||||
/// Maximum ratio of actual travel to bounding box diagonal.
|
||||
/// Routes exceeding this are considered zig-zags.
|
||||
/// - 1.0x = perfectly linear route
|
||||
/// - 1.5x = some detours, normal
|
||||
/// - 2.0x = significant detours, borderline
|
||||
/// - 2.5x+ = excessive zig-zag, reject
|
||||
private static let maxZigZagRatio = 2.5
|
||||
|
||||
/// Minimum bounding box diagonal (miles) to apply zig-zag check.
|
||||
/// Routes within a small area are always considered sane.
|
||||
private static let minDiagonalForCheck = 100.0
|
||||
|
||||
/// Maximum number of route options to return.
|
||||
private static let maxOptions = 10
|
||||
|
||||
// MARK: - Public API
|
||||
|
||||
/// Finds ALL geographically sensible subsets of games.
|
||||
///
|
||||
/// The problem: Games in a date range might be scattered across the country.
|
||||
/// Visiting all of them in chronological order could mean crazy zig-zags.
|
||||
///
|
||||
/// The solution: Explore all possible subsets, keeping those that pass
|
||||
/// geographic sanity. Return multiple options for the user to choose from.
|
||||
///
|
||||
/// Algorithm (tree exploration with pruning):
|
||||
///
|
||||
/// Input: [NY, TX, SC, DEN, NM, CA] (chronological order)
|
||||
///
|
||||
/// Build a decision tree:
|
||||
/// [NY]
|
||||
/// / \
|
||||
/// +TX / \ skip TX
|
||||
/// / \
|
||||
/// [NY,TX] [NY]
|
||||
/// / \ / \
|
||||
/// +SC / \ +SC / \
|
||||
/// ✗ | | |
|
||||
/// (prune) +DEN [NY,SC] ...
|
||||
///
|
||||
/// Each path that reaches the end = one valid option
|
||||
/// Pruning: If adding a game breaks sanity, don't explore that branch
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - games: All games to consider, should be in chronological order
|
||||
/// - stadiums: Dictionary mapping stadium IDs to Stadium objects
|
||||
/// - anchorGameIds: Game IDs that MUST be included in every route (for Scenario B)
|
||||
/// - stopBuilder: Closure that converts games to ItineraryStops
|
||||
///
|
||||
/// - Returns: Array of valid game combinations, sorted by number of games (most first)
|
||||
///
|
||||
static func findAllSensibleRoutes(
|
||||
from games: [Game],
|
||||
stadiums: [UUID: Stadium],
|
||||
anchorGameIds: Set<UUID> = [],
|
||||
stopBuilder: ([Game], [UUID: Stadium]) -> [ItineraryStop]
|
||||
) -> [[Game]] {
|
||||
|
||||
// 0-2 games = always sensible, only one option
|
||||
// But still verify anchors are present
|
||||
guard games.count > 2 else {
|
||||
// Verify all anchors are in the game list
|
||||
let gameIds = Set(games.map { $0.id })
|
||||
if anchorGameIds.isSubset(of: gameIds) {
|
||||
return games.isEmpty ? [] : [games]
|
||||
} else {
|
||||
// Missing anchors - no valid routes
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
// First, check if all games already form a sensible route
|
||||
let allStops = stopBuilder(games, stadiums)
|
||||
if isGeographicallySane(stops: allStops) {
|
||||
print("[GeographicExplorer] All \(games.count) games form a sensible route")
|
||||
return [games]
|
||||
}
|
||||
|
||||
print("[GeographicExplorer] Exploring route variations (anchors: \(anchorGameIds.count))...")
|
||||
|
||||
// Explore all valid subsets using recursive tree traversal
|
||||
var validRoutes: [[Game]] = []
|
||||
exploreRoutes(
|
||||
games: games,
|
||||
stadiums: stadiums,
|
||||
anchorGameIds: anchorGameIds,
|
||||
stopBuilder: stopBuilder,
|
||||
currentRoute: [],
|
||||
index: 0,
|
||||
validRoutes: &validRoutes
|
||||
)
|
||||
|
||||
// Filter routes that don't contain all anchors
|
||||
let routesWithAnchors = validRoutes.filter { route in
|
||||
let routeGameIds = Set(route.map { $0.id })
|
||||
return anchorGameIds.isSubset(of: routeGameIds)
|
||||
}
|
||||
|
||||
// Sort by number of games (most games first = best options)
|
||||
let sorted = routesWithAnchors.sorted { $0.count > $1.count }
|
||||
|
||||
// Limit to top options to avoid overwhelming the user
|
||||
let topRoutes = Array(sorted.prefix(maxOptions))
|
||||
|
||||
print("[GeographicExplorer] Found \(routesWithAnchors.count) valid routes with anchors, returning top \(topRoutes.count)")
|
||||
return topRoutes
|
||||
}
|
||||
|
||||
// MARK: - Geographic Sanity Check
|
||||
|
||||
/// Determines if a route is geographically sensible or zig-zags excessively.
|
||||
///
|
||||
/// The goal: Reject routes that oscillate back and forth across large distances.
|
||||
/// We want routes that make generally linear progress, not cross-country ping-pong.
|
||||
///
|
||||
/// Algorithm:
|
||||
/// 1. Calculate the "bounding box" of all stops (geographic spread)
|
||||
/// 2. Calculate total travel distance if we visit stops in order
|
||||
/// 3. Compare actual travel to the bounding box diagonal
|
||||
/// 4. If actual travel is WAY more than the diagonal, it's zig-zagging
|
||||
///
|
||||
/// Example VALID:
|
||||
/// Stops: LA, SF, Portland, Seattle
|
||||
/// Bounding box diagonal: ~1,100 miles
|
||||
/// Actual travel: ~1,200 miles (reasonable, mostly linear)
|
||||
/// Ratio: 1.1x → PASS
|
||||
///
|
||||
/// Example INVALID:
|
||||
/// Stops: NY, TX, SC, CA (zig-zag)
|
||||
/// Bounding box diagonal: ~2,500 miles
|
||||
/// Actual travel: ~6,000 miles (back and forth)
|
||||
/// Ratio: 2.4x → FAIL
|
||||
///
|
||||
static func isGeographicallySane(stops: [ItineraryStop]) -> Bool {
|
||||
|
||||
// Single stop or two stops = always valid (no zig-zag possible)
|
||||
guard stops.count > 2 else { return true }
|
||||
|
||||
// Collect all coordinates
|
||||
let coordinates = stops.compactMap { $0.coordinate }
|
||||
guard coordinates.count == stops.count else {
|
||||
// Missing coordinates - can't validate, assume valid
|
||||
return true
|
||||
}
|
||||
|
||||
// Calculate bounding box
|
||||
let lats = coordinates.map { $0.latitude }
|
||||
let lons = coordinates.map { $0.longitude }
|
||||
|
||||
guard let minLat = lats.min(), let maxLat = lats.max(),
|
||||
let minLon = lons.min(), let maxLon = lons.max() else {
|
||||
return true
|
||||
}
|
||||
|
||||
// Calculate bounding box diagonal distance
|
||||
let corner1 = CLLocationCoordinate2D(latitude: minLat, longitude: minLon)
|
||||
let corner2 = CLLocationCoordinate2D(latitude: maxLat, longitude: maxLon)
|
||||
let diagonalMiles = TravelEstimator.haversineDistanceMiles(from: corner1, to: corner2)
|
||||
|
||||
// Tiny bounding box = all games are close together = always valid
|
||||
if diagonalMiles < minDiagonalForCheck {
|
||||
return true
|
||||
}
|
||||
|
||||
// Calculate actual travel distance through all stops in order
|
||||
var actualTravelMiles: Double = 0
|
||||
for i in 0..<(stops.count - 1) {
|
||||
let from = stops[i]
|
||||
let to = stops[i + 1]
|
||||
actualTravelMiles += TravelEstimator.calculateDistanceMiles(from: from, to: to)
|
||||
}
|
||||
|
||||
// Compare: is actual travel reasonable compared to the geographic spread?
|
||||
let ratio = actualTravelMiles / diagonalMiles
|
||||
|
||||
if ratio > maxZigZagRatio {
|
||||
print("[GeographicExplorer] Sanity FAILED: travel=\(Int(actualTravelMiles))mi, diagonal=\(Int(diagonalMiles))mi, ratio=\(String(format: "%.1f", ratio))x")
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// MARK: - Private Helpers
|
||||
|
||||
/// Recursive helper to explore all valid route combinations.
|
||||
///
|
||||
/// At each game, we have two choices:
|
||||
/// 1. Include the game (if it doesn't break sanity)
|
||||
/// 2. Skip the game (only if it's not an anchor)
|
||||
///
|
||||
/// We explore BOTH branches when possible, building up all valid combinations.
|
||||
/// Anchor games MUST be included - we cannot skip them.
|
||||
///
|
||||
private static func exploreRoutes(
|
||||
games: [Game],
|
||||
stadiums: [UUID: Stadium],
|
||||
anchorGameIds: Set<UUID>,
|
||||
stopBuilder: ([Game], [UUID: Stadium]) -> [ItineraryStop],
|
||||
currentRoute: [Game],
|
||||
index: Int,
|
||||
validRoutes: inout [[Game]]
|
||||
) {
|
||||
// Base case: we've processed all games
|
||||
if index >= games.count {
|
||||
// Only save routes with at least 1 game
|
||||
if !currentRoute.isEmpty {
|
||||
validRoutes.append(currentRoute)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
let game = games[index]
|
||||
let isAnchor = anchorGameIds.contains(game.id)
|
||||
|
||||
// Option 1: Try INCLUDING this game
|
||||
var routeWithGame = currentRoute
|
||||
routeWithGame.append(game)
|
||||
let stopsWithGame = stopBuilder(routeWithGame, stadiums)
|
||||
|
||||
if isGeographicallySane(stops: stopsWithGame) {
|
||||
// This branch is valid, continue exploring
|
||||
exploreRoutes(
|
||||
games: games,
|
||||
stadiums: stadiums,
|
||||
anchorGameIds: anchorGameIds,
|
||||
stopBuilder: stopBuilder,
|
||||
currentRoute: routeWithGame,
|
||||
index: index + 1,
|
||||
validRoutes: &validRoutes
|
||||
)
|
||||
} else if isAnchor {
|
||||
// Anchor game breaks sanity - this entire branch is invalid
|
||||
// Don't explore further, don't add to valid routes
|
||||
// (We can't skip an anchor, and including it breaks sanity)
|
||||
return
|
||||
}
|
||||
|
||||
// Option 2: Try SKIPPING this game (only if it's not an anchor)
|
||||
if !isAnchor {
|
||||
exploreRoutes(
|
||||
games: games,
|
||||
stadiums: stadiums,
|
||||
anchorGameIds: anchorGameIds,
|
||||
stopBuilder: stopBuilder,
|
||||
currentRoute: currentRoute,
|
||||
index: index + 1,
|
||||
validRoutes: &validRoutes
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user