- 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>
279 lines
10 KiB
Swift
279 lines
10 KiB
Swift
//
|
|
// 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
|
|
)
|
|
}
|
|
}
|
|
}
|