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

@@ -25,10 +25,11 @@ enum GameDAGRouter {
// MARK: - Configuration
/// Default beam width - how many partial routes to keep at each step
private static let defaultBeamWidth = 30
/// Increased to ensure we preserve diverse route lengths (short and long trips)
private static let defaultBeamWidth = 50
/// Maximum options to return
private static let maxOptions = 10
/// Maximum options to return (increased to provide more diverse trip lengths)
private static let maxOptions = 50
/// Buffer time after game ends before we can depart (hours)
private static let gameEndBufferHours: Double = 3.0
@@ -47,6 +48,7 @@ enum GameDAGRouter {
/// - stadiums: Dictionary mapping stadium IDs to Stadium objects
/// - constraints: Driving constraints (number of drivers, max hours per day)
/// - anchorGameIds: Games that MUST appear in every valid route (for Scenario B)
/// - allowRepeatCities: If false, each city can only appear once in a route
/// - beamWidth: How many partial routes to keep at each depth (default 30)
///
/// - Returns: Array of valid game combinations, sorted by score (most games, least driving)
@@ -56,6 +58,7 @@ enum GameDAGRouter {
stadiums: [UUID: Stadium],
constraints: DrivingConstraints,
anchorGameIds: Set<UUID> = [],
allowRepeatCities: Bool = true,
beamWidth: Int = defaultBeamWidth
) -> [[Game]] {
@@ -130,6 +133,15 @@ enum GameDAGRouter {
// Try adding each of today's games
for candidate in todaysGames {
// Check for repeat city violation during route building
if !allowRepeatCities {
let candidateCity = stadiums[candidate.stadiumId]?.city ?? ""
let pathCities = Set(path.compactMap { stadiums[$0.stadiumId]?.city })
if pathCities.contains(candidateCity) {
continue // Skip - would violate allowRepeatCities
}
}
if canTransition(from: lastGame, to: candidate, stadiums: stadiums, constraints: constraints) {
let newPath = path + [candidate]
nextBeam.append(newPath)
@@ -169,6 +181,7 @@ enum GameDAGRouter {
from games: [Game],
stadiums: [UUID: Stadium],
anchorGameIds: Set<UUID> = [],
allowRepeatCities: Bool = true,
stopBuilder: ([Game], [UUID: Stadium]) -> [ItineraryStop]
) -> [[Game]] {
// Use default driving constraints
@@ -178,7 +191,8 @@ enum GameDAGRouter {
games: games,
stadiums: stadiums,
constraints: constraints,
anchorGameIds: anchorGameIds
anchorGameIds: anchorGameIds,
allowRepeatCities: allowRepeatCities
)
}
@@ -288,8 +302,9 @@ enum GameDAGRouter {
// MARK: - Geographic Diversity
/// Selects geographically diverse routes from the candidate set.
/// Groups routes by their primary city (where most games are) and picks the best from each region.
/// Selects diverse routes from the candidate set.
/// Ensures diversity by BOTH route length (city count) AND primary city.
/// This guarantees users see 2-city trips alongside 5+ city trips.
private static func selectDiverseRoutes(
_ routes: [[Game]],
stadiums: [UUID: Stadium],
@@ -297,58 +312,88 @@ enum GameDAGRouter {
) -> [[Game]] {
guard !routes.isEmpty else { return [] }
// Group routes by primary city (the city with the most games in the route)
var routesByRegion: [String: [[Game]]] = [:]
// Group routes by city count (route length)
var routesByLength: [Int: [[Game]]] = [:]
for route in routes {
let primaryCity = getPrimaryCity(for: route, stadiums: stadiums)
routesByRegion[primaryCity, default: []].append(route)
let cityCount = Set(route.compactMap { stadiums[$0.stadiumId]?.city }).count
routesByLength[cityCount, default: []].append(route)
}
// Sort routes within each region by score (best first)
for (region, regionRoutes) in routesByRegion {
routesByRegion[region] = regionRoutes.sorted {
// Sort routes within each length by score
for (length, lengthRoutes) in routesByLength {
routesByLength[length] = lengthRoutes.sorted {
scorePath($0, stadiums: stadiums) > scorePath($1, stadiums: stadiums)
}
}
// Sort regions by their best route's score (so best regions come first)
let sortedRegions = routesByRegion.keys.sorted { region1, region2 in
let score1 = routesByRegion[region1]?.first.map { scorePath($0, stadiums: stadiums) } ?? 0
let score2 = routesByRegion[region2]?.first.map { scorePath($0, stadiums: stadiums) } ?? 0
return score1 > score2
}
// Allocate slots to each length category
// Goal: ensure at least 1 route per length category if available
let sortedLengths = routesByLength.keys.sorted()
let minPerLength = max(1, maxCount / max(1, sortedLengths.count))
// Pick routes round-robin from each region to ensure diversity
var selectedRoutes: [[Game]] = []
var regionIndices: [String: Int] = [:]
var selectedIds = Set<String>()
// First pass: get best route from each region
for region in sortedRegions {
// First pass: take best route(s) from each length category
for length in sortedLengths {
if selectedRoutes.count >= maxCount { break }
if let regionRoutes = routesByRegion[region], !regionRoutes.isEmpty {
selectedRoutes.append(regionRoutes[0])
regionIndices[region] = 1
}
}
// Second pass: fill remaining slots with next-best routes from top regions
var round = 1
while selectedRoutes.count < maxCount {
var addedAny = false
for region in sortedRegions {
if selectedRoutes.count >= maxCount { break }
let idx = regionIndices[region] ?? 0
if let regionRoutes = routesByRegion[region], idx < regionRoutes.count {
selectedRoutes.append(regionRoutes[idx])
regionIndices[region] = idx + 1
addedAny = true
if let lengthRoutes = routesByLength[length] {
let toTake = min(minPerLength, lengthRoutes.count, maxCount - selectedRoutes.count)
for route in lengthRoutes.prefix(toTake) {
let key = route.map { $0.id.uuidString }.joined(separator: "-")
if !selectedIds.contains(key) {
selectedRoutes.append(route)
selectedIds.insert(key)
}
}
}
if !addedAny { break }
round += 1
if round > 5 { break } // Safety limit
}
// Second pass: fill remaining slots, prioritizing geographic diversity
if selectedRoutes.count < maxCount {
// Group remaining routes by primary city
var remainingByCity: [String: [[Game]]] = [:]
for route in routes {
let key = route.map { $0.id.uuidString }.joined(separator: "-")
if !selectedIds.contains(key) {
let city = getPrimaryCity(for: route, stadiums: stadiums)
remainingByCity[city, default: []].append(route)
}
}
// Sort by score within each city
for (city, cityRoutes) in remainingByCity {
remainingByCity[city] = cityRoutes.sorted {
scorePath($0, stadiums: stadiums) > scorePath($1, stadiums: stadiums)
}
}
// Round-robin from each city
let sortedCities = remainingByCity.keys.sorted { city1, city2 in
let score1 = remainingByCity[city1]?.first.map { scorePath($0, stadiums: stadiums) } ?? 0
let score2 = remainingByCity[city2]?.first.map { scorePath($0, stadiums: stadiums) } ?? 0
return score1 > score2
}
var cityIndices: [String: Int] = [:]
while selectedRoutes.count < maxCount {
var addedAny = false
for city in sortedCities {
if selectedRoutes.count >= maxCount { break }
let idx = cityIndices[city] ?? 0
if let cityRoutes = remainingByCity[city], idx < cityRoutes.count {
let route = cityRoutes[idx]
let key = route.map { $0.id.uuidString }.joined(separator: "-")
if !selectedIds.contains(key) {
selectedRoutes.append(route)
selectedIds.insert(key)
addedAny = true
}
cityIndices[city] = idx + 1
}
}
if !addedAny { break }
}
}
return selectedRoutes
@@ -412,6 +457,7 @@ enum GameDAGRouter {
}
/// Prunes dominated paths and truncates to beam width.
/// Maintains diversity by both ending city AND route length to ensure short trips aren't eliminated.
private static func pruneAndTruncate(
_ paths: [[Game]],
beamWidth: Int,
@@ -429,32 +475,47 @@ enum GameDAGRouter {
}
}
// Sort by score (best first)
let sorted = uniquePaths.sorted { scorePath($0, stadiums: stadiums) > scorePath($1, stadiums: stadiums) }
// Group paths by unique city count (route length)
// This ensures we keep short trips (2 cities) alongside long trips (5+ cities)
var pathsByLength: [Int: [[Game]]] = [:]
for path in uniquePaths {
let cityCount = Set(path.compactMap { stadiums[$0.stadiumId]?.city }).count
pathsByLength[cityCount, default: []].append(path)
}
// Sort paths within each length group by score
for (length, lengthPaths) in pathsByLength {
pathsByLength[length] = lengthPaths.sorted {
scorePath($0, stadiums: stadiums) > scorePath($1, stadiums: stadiums)
}
}
// Allocate beam slots proportionally to length groups, with minimum per group
let sortedLengths = pathsByLength.keys.sorted()
let minPerLength = max(2, beamWidth / max(1, sortedLengths.count))
// Dominance pruning: within same ending city, keep only best paths
var pruned: [[Game]] = []
var bestByEndCity: [String: Double] = [:]
for path in sorted {
guard let lastGame = path.last else { continue }
let endCity = stadiums[lastGame.stadiumId]?.city ?? "Unknown"
let score = scorePath(path, stadiums: stadiums)
// Keep if this is the best path ending in this city, or if score is within 20% of best
if let bestScore = bestByEndCity[endCity] {
if score >= bestScore * 0.8 {
pruned.append(path)
}
} else {
bestByEndCity[endCity] = score
pruned.append(path)
// First pass: take minimum from each length group
for length in sortedLengths {
if let lengthPaths = pathsByLength[length] {
let toTake = min(minPerLength, lengthPaths.count)
pruned.append(contentsOf: lengthPaths.prefix(toTake))
}
}
// Stop if we have enough
if pruned.count >= beamWidth * 2 {
break
// Second pass: fill remaining slots with best paths overall
if pruned.count < beamWidth {
let remaining = beamWidth - pruned.count
let prunedIds = Set(pruned.map { $0.map { $0.id.uuidString }.joined(separator: "-") })
// Get all paths not yet added, sorted by score
var additional = uniquePaths.filter {
!prunedIds.contains($0.map { $0.id.uuidString }.joined(separator: "-"))
}
additional.sort { scorePath($0, stadiums: stadiums) > scorePath($1, stadiums: stadiums) }
pruned.append(contentsOf: additional.prefix(remaining))
}
// Final truncation