Add region-based filtering and route length diversity

- 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>
This commit is contained in:
Trey t
2026-01-09 15:18:37 -06:00
parent 3e778473e6
commit f5e509a9ae
20 changed files with 952 additions and 3245 deletions

View File

@@ -60,12 +60,24 @@ final class ScenarioAPlanner: ScenarioPlanner {
}
//
// Step 2: Filter games within date range
// Step 2: Filter games within date range and selected regions
//
// Get all games that fall within the user's travel dates.
// Sort by start time so we visit them in chronological order.
let selectedRegions = request.preferences.selectedRegions
let gamesInRange = request.allGames
.filter { dateRange.contains($0.startTime) }
.filter { game in
// Must be in date range
guard dateRange.contains(game.startTime) else { return false }
// Must be in selected region (if regions specified)
if !selectedRegions.isEmpty {
guard let stadium = request.stadiums[game.stadiumId] else { return false }
let gameRegion = Region.classify(longitude: stadium.coordinate.longitude)
return selectedRegions.contains(gameRegion)
}
return true
}
.sorted { $0.startTime < $1.startTime }
// No games? Nothing to plan.
@@ -91,11 +103,32 @@ final class ScenarioAPlanner: ScenarioPlanner {
// We explore ALL valid combinations and return multiple options.
// Uses GameDAGRouter for polynomial-time beam search.
//
let validRoutes = GameDAGRouter.findAllSensibleRoutes(
// Run beam search BOTH globally AND per-region to get diverse routes:
// - Global search finds cross-region routes
// - Per-region search ensures we have good regional options too
// Travel style filtering happens at UI layer.
//
var validRoutes: [[Game]] = []
// Global beam search (finds cross-region routes)
let globalRoutes = GameDAGRouter.findAllSensibleRoutes(
from: gamesInRange,
stadiums: request.stadiums,
allowRepeatCities: request.preferences.allowRepeatCities,
stopBuilder: buildStops
)
validRoutes.append(contentsOf: globalRoutes)
// Per-region beam search (ensures good regional options)
let regionalRoutes = findRoutesPerRegion(
games: gamesInRange,
stadiums: request.stadiums,
allowRepeatCities: request.preferences.allowRepeatCities
)
validRoutes.append(contentsOf: regionalRoutes)
// Deduplicate routes (same game IDs)
validRoutes = deduplicateRoutes(validRoutes)
print("🔍 ScenarioA: gamesInRange=\(gamesInRange.count), validRoutes=\(validRoutes.count)")
if let firstRoute = validRoutes.first {
@@ -201,11 +234,10 @@ final class ScenarioAPlanner: ScenarioPlanner {
let rankedOptions = ItineraryOption.sortByLeisure(
itineraryOptions,
leisureLevel: leisureLevel,
limit: request.preferences.maxTripOptions
leisureLevel: leisureLevel
)
print("🔍 ScenarioA: Returning \(rankedOptions.count) options after sorting (limit=\(request.preferences.maxTripOptions))")
print("🔍 ScenarioA: Returning \(rankedOptions.count) options after sorting")
if let first = rankedOptions.first {
print("🔍 ScenarioA: First option has \(first.stops.count) stops")
}
@@ -310,4 +342,69 @@ final class ScenarioAPlanner: ScenarioPlanner {
)
}
// MARK: - Route Deduplication
/// Removes duplicate routes (routes with identical game IDs).
private func deduplicateRoutes(_ routes: [[Game]]) -> [[Game]] {
var seen = Set<String>()
var unique: [[Game]] = []
for route in routes {
let key = route.map { $0.id.uuidString }.sorted().joined(separator: "-")
if !seen.contains(key) {
seen.insert(key)
unique.append(route)
}
}
return unique
}
// MARK: - Regional Route Finding
/// Finds routes by running beam search separately for each geographic region.
/// This ensures we get diverse options from East, Central, and West coasts.
private func findRoutesPerRegion(
games: [Game],
stadiums: [UUID: Stadium],
allowRepeatCities: Bool
) -> [[Game]] {
// Partition games by region
var gamesByRegion: [Region: [Game]] = [:]
for game in games {
guard let stadium = stadiums[game.stadiumId] else { continue }
let coord = stadium.coordinate
let region = Region.classify(longitude: coord.longitude)
// Only consider actual regions, not cross-country
if region != .crossCountry {
gamesByRegion[region, default: []].append(game)
}
}
print("🔍 ScenarioA Regional: Partitioned \(games.count) games into \(gamesByRegion.count) regions")
for (region, regionGames) in gamesByRegion {
print(" \(region.shortName): \(regionGames.count) games")
}
// Run beam search for each region
var allRoutes: [[Game]] = []
for (region, regionGames) in gamesByRegion {
guard !regionGames.isEmpty else { continue }
let regionRoutes = GameDAGRouter.findAllSensibleRoutes(
from: regionGames,
stadiums: stadiums,
allowRepeatCities: allowRepeatCities,
stopBuilder: buildStops
)
print("🔍 ScenarioA Regional: \(region.shortName) produced \(regionRoutes.count) routes")
allRoutes.append(contentsOf: regionRoutes)
}
return allRoutes
}
}