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:
@@ -85,12 +85,25 @@ final class ScenarioBPlanner: ScenarioPlanner {
|
||||
// Step 3: For each date range, find routes with anchors
|
||||
// ──────────────────────────────────────────────────────────────────
|
||||
let anchorGameIds = Set(selectedGames.map { $0.id })
|
||||
let selectedRegions = request.preferences.selectedRegions
|
||||
var allItineraryOptions: [ItineraryOption] = []
|
||||
|
||||
for dateRange in dateRanges {
|
||||
// Find all games in this date range
|
||||
// Find all games in this date range and selected regions
|
||||
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)
|
||||
// Note: Anchor games are always included regardless of region
|
||||
if !selectedRegions.isEmpty && !anchorGameIds.contains(game.id) {
|
||||
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 }
|
||||
|
||||
// Skip if no games (shouldn't happen if date range is valid)
|
||||
@@ -104,12 +117,30 @@ final class ScenarioBPlanner: ScenarioPlanner {
|
||||
|
||||
// Find all sensible routes that include the anchor games
|
||||
// Uses GameDAGRouter for polynomial-time beam search
|
||||
let validRoutes = GameDAGRouter.findAllSensibleRoutes(
|
||||
// Run BOTH global and per-region search for diverse routes
|
||||
var validRoutes: [[Game]] = []
|
||||
|
||||
// Global beam search (finds cross-region routes)
|
||||
let globalRoutes = GameDAGRouter.findAllSensibleRoutes(
|
||||
from: gamesInRange,
|
||||
stadiums: request.stadiums,
|
||||
anchorGameIds: anchorGameIds,
|
||||
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,
|
||||
anchorGameIds: anchorGameIds,
|
||||
allowRepeatCities: request.preferences.allowRepeatCities
|
||||
)
|
||||
validRoutes.append(contentsOf: regionalRoutes)
|
||||
|
||||
// Deduplicate
|
||||
validRoutes = deduplicateRoutes(validRoutes)
|
||||
|
||||
// Build itineraries for each valid route
|
||||
for routeGames in validRoutes {
|
||||
@@ -164,8 +195,7 @@ final class ScenarioBPlanner: ScenarioPlanner {
|
||||
let leisureLevel = request.preferences.leisureLevel
|
||||
let rankedOptions = ItineraryOption.sortByLeisure(
|
||||
allItineraryOptions,
|
||||
leisureLevel: leisureLevel,
|
||||
limit: request.preferences.maxTripOptions
|
||||
leisureLevel: leisureLevel
|
||||
)
|
||||
|
||||
return .success(Array(rankedOptions))
|
||||
@@ -354,4 +384,84 @@ final class ScenarioBPlanner: ScenarioPlanner {
|
||||
)
|
||||
}
|
||||
|
||||
// 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.
|
||||
/// For Scenario B, routes must still contain all anchor games.
|
||||
private func findRoutesPerRegion(
|
||||
games: [Game],
|
||||
stadiums: [UUID: Stadium],
|
||||
anchorGameIds: Set<UUID>,
|
||||
allowRepeatCities: Bool
|
||||
) -> [[Game]] {
|
||||
// First, determine which region(s) the anchor games are in
|
||||
var anchorRegions = Set<Region>()
|
||||
for game in games where anchorGameIds.contains(game.id) {
|
||||
guard let stadium = stadiums[game.stadiumId] else { continue }
|
||||
let coord = stadium.coordinate
|
||||
let region = Region.classify(longitude: coord.longitude)
|
||||
if region != .crossCountry {
|
||||
anchorRegions.insert(region)
|
||||
}
|
||||
}
|
||||
|
||||
// Partition all 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)
|
||||
if region != .crossCountry {
|
||||
gamesByRegion[region, default: []].append(game)
|
||||
}
|
||||
}
|
||||
|
||||
print("🔍 ScenarioB Regional: Anchor games in regions: \(anchorRegions.map { $0.shortName })")
|
||||
|
||||
// Run beam search for each region that has anchor games
|
||||
// (Other regions without anchor games would produce routes that don't satisfy anchors)
|
||||
var allRoutes: [[Game]] = []
|
||||
|
||||
for region in anchorRegions {
|
||||
guard let regionGames = gamesByRegion[region], !regionGames.isEmpty else { continue }
|
||||
|
||||
// Get anchor games in this region
|
||||
let regionAnchorIds = anchorGameIds.filter { anchorId in
|
||||
regionGames.contains { $0.id == anchorId }
|
||||
}
|
||||
|
||||
let regionRoutes = GameDAGRouter.findAllSensibleRoutes(
|
||||
from: regionGames,
|
||||
stadiums: stadiums,
|
||||
anchorGameIds: regionAnchorIds,
|
||||
allowRepeatCities: allowRepeatCities,
|
||||
stopBuilder: buildStops
|
||||
)
|
||||
|
||||
print("🔍 ScenarioB Regional: \(region.shortName) produced \(regionRoutes.count) routes")
|
||||
allRoutes.append(contentsOf: regionRoutes)
|
||||
}
|
||||
|
||||
return allRoutes
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user