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:
@@ -10,7 +10,8 @@
|
|||||||
"Bash(ls:*)",
|
"Bash(ls:*)",
|
||||||
"Bash(xcrun simctl install:*)",
|
"Bash(xcrun simctl install:*)",
|
||||||
"Skill(frontend-design:frontend-design)",
|
"Skill(frontend-design:frontend-design)",
|
||||||
"Bash(xcrun simctl io:*)"
|
"Bash(xcrun simctl io:*)",
|
||||||
|
"Bash(python cloudkit_import.py:*)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 540 KiB |
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -7,7 +7,7 @@
|
|||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
enum Region: String, CaseIterable, Identifiable {
|
enum Region: String, CaseIterable, Identifiable, Codable, Hashable {
|
||||||
case east = "East Coast"
|
case east = "East Coast"
|
||||||
case central = "Central"
|
case central = "Central"
|
||||||
case west = "West Coast"
|
case west = "West Coast"
|
||||||
|
|||||||
@@ -227,7 +227,8 @@ struct TripPreferences: Codable, Hashable {
|
|||||||
var lodgingType: LodgingType
|
var lodgingType: LodgingType
|
||||||
var numberOfDrivers: Int
|
var numberOfDrivers: Int
|
||||||
var maxDrivingHoursPerDriver: Double?
|
var maxDrivingHoursPerDriver: Double?
|
||||||
var maxTripOptions: Int
|
var allowRepeatCities: Bool
|
||||||
|
var selectedRegions: Set<Region>
|
||||||
|
|
||||||
init(
|
init(
|
||||||
planningMode: PlanningMode = .dateRange,
|
planningMode: PlanningMode = .dateRange,
|
||||||
@@ -248,7 +249,8 @@ struct TripPreferences: Codable, Hashable {
|
|||||||
lodgingType: LodgingType = .hotel,
|
lodgingType: LodgingType = .hotel,
|
||||||
numberOfDrivers: Int = 1,
|
numberOfDrivers: Int = 1,
|
||||||
maxDrivingHoursPerDriver: Double? = nil,
|
maxDrivingHoursPerDriver: Double? = nil,
|
||||||
maxTripOptions: Int = 10
|
allowRepeatCities: Bool = true,
|
||||||
|
selectedRegions: Set<Region> = [.east, .central, .west]
|
||||||
) {
|
) {
|
||||||
self.planningMode = planningMode
|
self.planningMode = planningMode
|
||||||
self.startLocation = startLocation
|
self.startLocation = startLocation
|
||||||
@@ -268,7 +270,8 @@ struct TripPreferences: Codable, Hashable {
|
|||||||
self.lodgingType = lodgingType
|
self.lodgingType = lodgingType
|
||||||
self.numberOfDrivers = numberOfDrivers
|
self.numberOfDrivers = numberOfDrivers
|
||||||
self.maxDrivingHoursPerDriver = maxDrivingHoursPerDriver
|
self.maxDrivingHoursPerDriver = maxDrivingHoursPerDriver
|
||||||
self.maxTripOptions = maxTripOptions
|
self.allowRepeatCities = allowRepeatCities
|
||||||
|
self.selectedRegions = selectedRegions
|
||||||
}
|
}
|
||||||
|
|
||||||
var totalDriverHoursPerDay: Double {
|
var totalDriverHoursPerDay: Double {
|
||||||
|
|||||||
@@ -156,7 +156,7 @@ final class AppDataProvider: ObservableObject {
|
|||||||
let canonicalGames = try context.fetch(descriptor)
|
let canonicalGames = try context.fetch(descriptor)
|
||||||
|
|
||||||
// Filter by sport and convert to domain models
|
// Filter by sport and convert to domain models
|
||||||
return canonicalGames.compactMap { canonical -> Game? in
|
let result = canonicalGames.compactMap { canonical -> Game? in
|
||||||
guard sportStrings.contains(canonical.sport) else { return nil }
|
guard sportStrings.contains(canonical.sport) else { return nil }
|
||||||
|
|
||||||
let homeTeamUUID = canonicalTeamUUIDs[canonical.homeTeamCanonicalId] ?? UUID()
|
let homeTeamUUID = canonicalTeamUUIDs[canonical.homeTeamCanonicalId] ?? UUID()
|
||||||
@@ -169,6 +169,8 @@ final class AppDataProvider: ObservableObject {
|
|||||||
stadiumUUID: stadiumUUID
|
stadiumUUID: stadiumUUID
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fetch a single game by ID
|
/// Fetch a single game by ID
|
||||||
|
|||||||
@@ -243,8 +243,7 @@ final class SuggestedTripsGenerator {
|
|||||||
sports: sports,
|
sports: sports,
|
||||||
startDate: tripStartDate,
|
startDate: tripStartDate,
|
||||||
endDate: tripEndDate,
|
endDate: tripEndDate,
|
||||||
leisureLevel: .moderate,
|
leisureLevel: .moderate
|
||||||
maxTripOptions: 1
|
|
||||||
)
|
)
|
||||||
|
|
||||||
let request = PlanningRequest(
|
let request = PlanningRequest(
|
||||||
@@ -421,8 +420,7 @@ final class SuggestedTripsGenerator {
|
|||||||
sports: sports,
|
sports: sports,
|
||||||
startDate: tripStartDate,
|
startDate: tripStartDate,
|
||||||
endDate: tripEndDate,
|
endDate: tripEndDate,
|
||||||
leisureLevel: .moderate,
|
leisureLevel: .moderate
|
||||||
maxTripOptions: 1
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Generate travel segments between stops
|
// Generate travel segments between stops
|
||||||
|
|||||||
@@ -26,10 +26,6 @@ final class SettingsViewModel {
|
|||||||
didSet { savePreferences() }
|
didSet { savePreferences() }
|
||||||
}
|
}
|
||||||
|
|
||||||
var maxTripOptions: Int {
|
|
||||||
didSet { savePreferences() }
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Sync State
|
// MARK: - Sync State
|
||||||
|
|
||||||
private(set) var isSyncing = false
|
private(set) var isSyncing = false
|
||||||
@@ -61,9 +57,6 @@ final class SettingsViewModel {
|
|||||||
let savedDrivingHours = defaults.integer(forKey: "maxDrivingHoursPerDay")
|
let savedDrivingHours = defaults.integer(forKey: "maxDrivingHoursPerDay")
|
||||||
self.maxDrivingHoursPerDay = savedDrivingHours == 0 ? 8 : savedDrivingHours
|
self.maxDrivingHoursPerDay = savedDrivingHours == 0 ? 8 : savedDrivingHours
|
||||||
|
|
||||||
let savedMaxTripOptions = defaults.integer(forKey: "maxTripOptions")
|
|
||||||
self.maxTripOptions = savedMaxTripOptions == 0 ? 10 : savedMaxTripOptions
|
|
||||||
|
|
||||||
// Last sync
|
// Last sync
|
||||||
self.lastSyncDate = defaults.object(forKey: "lastSyncDate") as? Date
|
self.lastSyncDate = defaults.object(forKey: "lastSyncDate") as? Date
|
||||||
|
|
||||||
@@ -101,7 +94,6 @@ final class SettingsViewModel {
|
|||||||
selectedTheme = .teal
|
selectedTheme = .teal
|
||||||
selectedSports = Set(Sport.supported)
|
selectedSports = Set(Sport.supported)
|
||||||
maxDrivingHoursPerDay = 8
|
maxDrivingHoursPerDay = 8
|
||||||
maxTripOptions = 10
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Persistence
|
// MARK: - Persistence
|
||||||
@@ -110,6 +102,5 @@ final class SettingsViewModel {
|
|||||||
let defaults = UserDefaults.standard
|
let defaults = UserDefaults.standard
|
||||||
defaults.set(selectedSports.map(\.rawValue), forKey: "selectedSports")
|
defaults.set(selectedSports.map(\.rawValue), forKey: "selectedSports")
|
||||||
defaults.set(maxDrivingHoursPerDay, forKey: "maxDrivingHoursPerDay")
|
defaults.set(maxDrivingHoursPerDay, forKey: "maxDrivingHoursPerDay")
|
||||||
defaults.set(maxTripOptions, forKey: "maxTripOptions")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -141,22 +141,6 @@ struct SettingsView: View {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
|
||||||
HStack {
|
|
||||||
Text("Trip Options to Show")
|
|
||||||
Spacer()
|
|
||||||
Text("\(viewModel.maxTripOptions)")
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
}
|
|
||||||
Slider(
|
|
||||||
value: Binding(
|
|
||||||
get: { Double(viewModel.maxTripOptions) },
|
|
||||||
set: { viewModel.maxTripOptions = Int($0) }
|
|
||||||
),
|
|
||||||
in: 1...20,
|
|
||||||
step: 1
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} header: {
|
} header: {
|
||||||
Text("Travel Preferences")
|
Text("Travel Preferences")
|
||||||
} footer: {
|
} footer: {
|
||||||
|
|||||||
@@ -86,6 +86,10 @@ final class TripCreationViewModel {
|
|||||||
var numberOfDrivers: Int = 1
|
var numberOfDrivers: Int = 1
|
||||||
var maxDrivingHoursPerDriver: Double = 8
|
var maxDrivingHoursPerDriver: Double = 8
|
||||||
|
|
||||||
|
// Travel Preferences
|
||||||
|
var allowRepeatCities: Bool = true
|
||||||
|
var selectedRegions: Set<Region> = [.east, .central, .west]
|
||||||
|
|
||||||
// MARK: - Dependencies
|
// MARK: - Dependencies
|
||||||
|
|
||||||
private let planningEngine = TripPlanningEngine()
|
private let planningEngine = TripPlanningEngine()
|
||||||
@@ -273,10 +277,6 @@ final class TripCreationViewModel {
|
|||||||
await loadScheduleData()
|
await loadScheduleData()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read max trip options from settings (default 10)
|
|
||||||
let savedMaxOptions = UserDefaults.standard.integer(forKey: "maxTripOptions")
|
|
||||||
let maxTripOptions = savedMaxOptions > 0 ? min(20, savedMaxOptions) : 10
|
|
||||||
|
|
||||||
// Build preferences
|
// Build preferences
|
||||||
let preferences = TripPreferences(
|
let preferences = TripPreferences(
|
||||||
planningMode: planningMode,
|
planningMode: planningMode,
|
||||||
@@ -297,7 +297,8 @@ final class TripCreationViewModel {
|
|||||||
lodgingType: lodgingType,
|
lodgingType: lodgingType,
|
||||||
numberOfDrivers: numberOfDrivers,
|
numberOfDrivers: numberOfDrivers,
|
||||||
maxDrivingHoursPerDriver: maxDrivingHoursPerDriver,
|
maxDrivingHoursPerDriver: maxDrivingHoursPerDriver,
|
||||||
maxTripOptions: maxTripOptions
|
allowRepeatCities: allowRepeatCities,
|
||||||
|
selectedRegions: selectedRegions
|
||||||
)
|
)
|
||||||
|
|
||||||
// Build planning request
|
// Build planning request
|
||||||
@@ -451,6 +452,17 @@ final class TripCreationViewModel {
|
|||||||
availableGames = []
|
availableGames = []
|
||||||
isLoadingGames = false
|
isLoadingGames = false
|
||||||
currentPreferences = nil
|
currentPreferences = nil
|
||||||
|
allowRepeatCities = true
|
||||||
|
selectedRegions = [.east, .central, .west]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Toggles region selection. Any combination is allowed.
|
||||||
|
func toggleRegion(_ region: Region) {
|
||||||
|
if selectedRegions.contains(region) {
|
||||||
|
selectedRegions.remove(region)
|
||||||
|
} else {
|
||||||
|
selectedRegions.insert(region)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Select a specific itinerary option and navigate to its detail
|
/// Select a specific itinerary option and navigate to its detail
|
||||||
@@ -465,9 +477,6 @@ final class TripCreationViewModel {
|
|||||||
|
|
||||||
/// Convert an itinerary option to a Trip (public for use by TripOptionsView)
|
/// Convert an itinerary option to a Trip (public for use by TripOptionsView)
|
||||||
func convertOptionToTrip(_ option: ItineraryOption) -> Trip {
|
func convertOptionToTrip(_ option: ItineraryOption) -> Trip {
|
||||||
let savedMaxOptions = UserDefaults.standard.integer(forKey: "maxTripOptions")
|
|
||||||
let maxOptions = savedMaxOptions > 0 ? min(20, savedMaxOptions) : 10
|
|
||||||
|
|
||||||
let preferences = currentPreferences ?? TripPreferences(
|
let preferences = currentPreferences ?? TripPreferences(
|
||||||
planningMode: planningMode,
|
planningMode: planningMode,
|
||||||
startLocation: nil,
|
startLocation: nil,
|
||||||
@@ -487,7 +496,8 @@ final class TripCreationViewModel {
|
|||||||
lodgingType: lodgingType,
|
lodgingType: lodgingType,
|
||||||
numberOfDrivers: numberOfDrivers,
|
numberOfDrivers: numberOfDrivers,
|
||||||
maxDrivingHoursPerDriver: maxDrivingHoursPerDriver,
|
maxDrivingHoursPerDriver: maxDrivingHoursPerDriver,
|
||||||
maxTripOptions: maxOptions
|
allowRepeatCities: allowRepeatCities,
|
||||||
|
selectedRegions: selectedRegions
|
||||||
)
|
)
|
||||||
return convertToTrip(option: option, preferences: preferences)
|
return convertToTrip(option: option, preferences: preferences)
|
||||||
}
|
}
|
||||||
|
|||||||
213
SportsTime/Features/Trip/Views/RegionMapSelector.swift
Normal file
213
SportsTime/Features/Trip/Views/RegionMapSelector.swift
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
//
|
||||||
|
// RegionMapSelector.swift
|
||||||
|
// SportsTime
|
||||||
|
//
|
||||||
|
// Interactive map for selecting travel regions.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// A map-based selector for choosing geographic regions.
|
||||||
|
/// Shows North America with three selectable zones: West, Central, East.
|
||||||
|
///
|
||||||
|
/// Selection rules:
|
||||||
|
/// - Can select: East, Central, West, East+Central, Central+West
|
||||||
|
/// - Cannot select: East+West (must have Central between them)
|
||||||
|
struct RegionMapSelector: View {
|
||||||
|
@Binding var selectedRegions: Set<Region>
|
||||||
|
let onToggle: (Region) -> Void
|
||||||
|
|
||||||
|
@Environment(\.colorScheme) private var colorScheme
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: Theme.Spacing.sm) {
|
||||||
|
// Map with regions
|
||||||
|
GeometryReader { geometry in
|
||||||
|
ZStack {
|
||||||
|
// Background map outline
|
||||||
|
mapBackground
|
||||||
|
|
||||||
|
// Selectable regions
|
||||||
|
HStack(spacing: 0) {
|
||||||
|
regionButton(.west)
|
||||||
|
regionButton(.central)
|
||||||
|
regionButton(.east)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(height: 140)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium))
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)
|
||||||
|
.stroke(Theme.textMuted(colorScheme).opacity(0.5), lineWidth: 1)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Legend
|
||||||
|
selectionLegend
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Map Background
|
||||||
|
|
||||||
|
private var mapBackground: some View {
|
||||||
|
ZStack {
|
||||||
|
// Simple gradient background representing land
|
||||||
|
LinearGradient(
|
||||||
|
colors: [
|
||||||
|
Color.green.opacity(0.15),
|
||||||
|
Color.green.opacity(0.1),
|
||||||
|
Color.green.opacity(0.15)
|
||||||
|
],
|
||||||
|
startPoint: .leading,
|
||||||
|
endPoint: .trailing
|
||||||
|
)
|
||||||
|
|
||||||
|
// Subtle grid lines for visual separation
|
||||||
|
HStack(spacing: 0) {
|
||||||
|
Color.clear
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
Rectangle()
|
||||||
|
.fill(Theme.textMuted(colorScheme).opacity(0.3))
|
||||||
|
.frame(width: 1)
|
||||||
|
Color.clear
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
Rectangle()
|
||||||
|
.fill(Theme.textMuted(colorScheme).opacity(0.3))
|
||||||
|
.frame(width: 1)
|
||||||
|
Color.clear
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Region Button
|
||||||
|
|
||||||
|
private func regionButton(_ region: Region) -> some View {
|
||||||
|
let isSelected = selectedRegions.contains(region)
|
||||||
|
let isDisabled = isRegionDisabled(region)
|
||||||
|
|
||||||
|
return Button {
|
||||||
|
onToggle(region)
|
||||||
|
} label: {
|
||||||
|
VStack(spacing: Theme.Spacing.xs) {
|
||||||
|
// Region icon
|
||||||
|
Image(systemName: iconForRegion(region))
|
||||||
|
.font(.system(size: 24))
|
||||||
|
.foregroundStyle(isSelected ? .white : Theme.textSecondary(colorScheme))
|
||||||
|
|
||||||
|
// Region name
|
||||||
|
Text(region.shortName)
|
||||||
|
.font(.system(size: Theme.FontSize.caption, weight: .semibold))
|
||||||
|
.foregroundStyle(isSelected ? .white : Theme.textPrimary(colorScheme))
|
||||||
|
|
||||||
|
// Cities hint
|
||||||
|
Text(citiesForRegion(region))
|
||||||
|
.font(.system(size: Theme.FontSize.micro))
|
||||||
|
.foregroundStyle(isSelected ? .white.opacity(0.8) : Theme.textMuted(colorScheme))
|
||||||
|
.lineLimit(2)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
.background(
|
||||||
|
isSelected
|
||||||
|
? regionColor(region)
|
||||||
|
: Color.clear
|
||||||
|
)
|
||||||
|
.opacity(isDisabled ? 0.4 : 1.0)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.disabled(isDisabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Helpers
|
||||||
|
|
||||||
|
private func iconForRegion(_ region: Region) -> String {
|
||||||
|
switch region {
|
||||||
|
case .west: return "sun.max.fill"
|
||||||
|
case .central: return "building.2.fill"
|
||||||
|
case .east: return "building.columns.fill"
|
||||||
|
case .crossCountry: return "arrow.left.arrow.right"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func citiesForRegion(_ region: Region) -> String {
|
||||||
|
switch region {
|
||||||
|
case .west: return "LA, SF, Seattle"
|
||||||
|
case .central: return "Chicago, Houston, Denver"
|
||||||
|
case .east: return "NYC, Boston, Miami"
|
||||||
|
case .crossCountry: return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func regionColor(_ region: Region) -> Color {
|
||||||
|
switch region {
|
||||||
|
case .west: return .orange
|
||||||
|
case .central: return .blue
|
||||||
|
case .east: return .green
|
||||||
|
case .crossCountry: return .purple
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// East and West cannot both be selected (not adjacent)
|
||||||
|
private func isRegionDisabled(_ region: Region) -> Bool {
|
||||||
|
// If trying to show East+West as disabled, we handle that in toggle logic instead
|
||||||
|
// This is for visual indication only
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Legend
|
||||||
|
|
||||||
|
private var selectionLegend: some View {
|
||||||
|
HStack(spacing: Theme.Spacing.md) {
|
||||||
|
if selectedRegions.isEmpty {
|
||||||
|
Text("Tap regions to select")
|
||||||
|
.font(.system(size: Theme.FontSize.micro))
|
||||||
|
.foregroundStyle(Theme.textMuted(colorScheme))
|
||||||
|
} else {
|
||||||
|
Text("Selected: \(selectedRegions.map { $0.shortName }.sorted().joined(separator: " + "))")
|
||||||
|
.font(.system(size: Theme.FontSize.caption, weight: .medium))
|
||||||
|
.foregroundStyle(Theme.textPrimary(colorScheme))
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Button {
|
||||||
|
selectedRegions.removeAll()
|
||||||
|
} label: {
|
||||||
|
Text("Clear")
|
||||||
|
.font(.system(size: Theme.FontSize.micro))
|
||||||
|
.foregroundStyle(Theme.warmOrange)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
struct PreviewWrapper: View {
|
||||||
|
@State private var selected: Set<Region> = [.central]
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 20) {
|
||||||
|
RegionMapSelector(selectedRegions: $selected) { region in
|
||||||
|
if selected.contains(region) {
|
||||||
|
selected.remove(region)
|
||||||
|
} else {
|
||||||
|
// Adjacency rule
|
||||||
|
if region == .east {
|
||||||
|
selected.remove(.west)
|
||||||
|
} else if region == .west {
|
||||||
|
selected.remove(.east)
|
||||||
|
}
|
||||||
|
selected.insert(region)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
|
||||||
|
Text("Selected: \(selected.map { $0.shortName }.joined(separator: ", "))")
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return PreviewWrapper()
|
||||||
|
}
|
||||||
@@ -78,7 +78,6 @@ struct TripCreationView: View {
|
|||||||
|
|
||||||
// Common sections
|
// Common sections
|
||||||
travelSection
|
travelSection
|
||||||
constraintsSection
|
|
||||||
optionalSection
|
optionalSection
|
||||||
|
|
||||||
// Validation message
|
// Validation message
|
||||||
@@ -591,6 +590,32 @@ struct TripCreationView: View {
|
|||||||
private var travelSection: some View {
|
private var travelSection: some View {
|
||||||
ThemedSection(title: "Travel") {
|
ThemedSection(title: "Travel") {
|
||||||
VStack(spacing: Theme.Spacing.md) {
|
VStack(spacing: Theme.Spacing.md) {
|
||||||
|
// Region selector
|
||||||
|
VStack(alignment: .leading, spacing: Theme.Spacing.xs) {
|
||||||
|
Text("Regions")
|
||||||
|
.font(.system(size: Theme.FontSize.caption))
|
||||||
|
.foregroundStyle(Theme.textSecondary(colorScheme))
|
||||||
|
|
||||||
|
RegionMapSelector(
|
||||||
|
selectedRegions: $viewModel.selectedRegions,
|
||||||
|
onToggle: { region in
|
||||||
|
viewModel.toggleRegion(region)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if viewModel.selectedRegions.isEmpty {
|
||||||
|
Text("Select at least one region")
|
||||||
|
.font(.system(size: Theme.FontSize.micro))
|
||||||
|
.foregroundStyle(Theme.warmOrange)
|
||||||
|
.padding(.top, Theme.Spacing.xxs)
|
||||||
|
} else {
|
||||||
|
Text("Games will be found in selected regions")
|
||||||
|
.font(.system(size: Theme.FontSize.micro))
|
||||||
|
.foregroundStyle(Theme.textMuted(colorScheme))
|
||||||
|
.padding(.top, Theme.Spacing.xxs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Route preference
|
// Route preference
|
||||||
VStack(alignment: .leading, spacing: Theme.Spacing.xs) {
|
VStack(alignment: .leading, spacing: Theme.Spacing.xs) {
|
||||||
Text("Route Preference")
|
Text("Route Preference")
|
||||||
@@ -604,53 +629,22 @@ struct TripCreationView: View {
|
|||||||
}
|
}
|
||||||
.pickerStyle(.segmented)
|
.pickerStyle(.segmented)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var constraintsSection: some View {
|
// Allow repeat cities
|
||||||
ThemedSection(title: "Trip Style") {
|
|
||||||
VStack(spacing: Theme.Spacing.md) {
|
|
||||||
ThemedToggle(
|
ThemedToggle(
|
||||||
label: "Limit Cities",
|
label: "Allow Repeat Cities",
|
||||||
isOn: $viewModel.useStopCount,
|
isOn: $viewModel.allowRepeatCities,
|
||||||
icon: "mappin.and.ellipse"
|
icon: "arrow.triangle.2.circlepath"
|
||||||
)
|
)
|
||||||
|
|
||||||
if viewModel.useStopCount {
|
if !viewModel.allowRepeatCities {
|
||||||
VStack(alignment: .leading, spacing: Theme.Spacing.xs) {
|
Text("Each city will only be visited on one day")
|
||||||
ThemedStepper(
|
|
||||||
label: "Number of Cities",
|
|
||||||
value: viewModel.numberOfStops,
|
|
||||||
range: 1...20,
|
|
||||||
onIncrement: { viewModel.numberOfStops += 1 },
|
|
||||||
onDecrement: { viewModel.numberOfStops -= 1 }
|
|
||||||
)
|
|
||||||
|
|
||||||
Text("How many different cities to visit on your trip. More cities = more variety, but more driving between them.")
|
|
||||||
.font(.system(size: Theme.FontSize.micro))
|
|
||||||
.foregroundStyle(Theme.textMuted(colorScheme))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: Theme.Spacing.xs) {
|
|
||||||
Text("Trip Pace")
|
|
||||||
.font(.system(size: Theme.FontSize.caption))
|
|
||||||
.foregroundStyle(Theme.textSecondary(colorScheme))
|
|
||||||
|
|
||||||
Picker("Pace", selection: $viewModel.leisureLevel) {
|
|
||||||
ForEach(LeisureLevel.allCases) { level in
|
|
||||||
Text(level.displayName).tag(level)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.pickerStyle(.segmented)
|
|
||||||
|
|
||||||
Text(viewModel.leisureLevel.description)
|
|
||||||
.font(.system(size: Theme.FontSize.micro))
|
.font(.system(size: Theme.FontSize.micro))
|
||||||
.foregroundStyle(Theme.textMuted(colorScheme))
|
.foregroundStyle(Theme.textMuted(colorScheme))
|
||||||
.padding(.top, Theme.Spacing.xxs)
|
.padding(.leading, 32)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.animation(.easeInOut(duration: 0.2), value: viewModel.selectedRegions)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1220,6 +1214,48 @@ enum TripSortOption: String, CaseIterable, Identifiable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum TripPaceFilter: String, CaseIterable, Identifiable {
|
||||||
|
case all = "All"
|
||||||
|
case packed = "Packed"
|
||||||
|
case moderate = "Moderate"
|
||||||
|
case relaxed = "Relaxed"
|
||||||
|
|
||||||
|
var id: String { rawValue }
|
||||||
|
|
||||||
|
var icon: String {
|
||||||
|
switch self {
|
||||||
|
case .all: return "rectangle.stack"
|
||||||
|
case .packed: return "flame"
|
||||||
|
case .moderate: return "equal.circle"
|
||||||
|
case .relaxed: return "leaf"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum CitiesFilter: Int, CaseIterable, Identifiable {
|
||||||
|
case noLimit = 100
|
||||||
|
case fifteen = 15
|
||||||
|
case ten = 10
|
||||||
|
case five = 5
|
||||||
|
case four = 4
|
||||||
|
case three = 3
|
||||||
|
case two = 2
|
||||||
|
|
||||||
|
var id: Int { rawValue }
|
||||||
|
|
||||||
|
var displayName: String {
|
||||||
|
switch self {
|
||||||
|
case .noLimit: return "No Limit"
|
||||||
|
case .fifteen: return "15"
|
||||||
|
case .ten: return "10"
|
||||||
|
case .five: return "5"
|
||||||
|
case .four: return "4"
|
||||||
|
case .three: return "3"
|
||||||
|
case .two: return "2"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
struct TripOptionsView: View {
|
struct TripOptionsView: View {
|
||||||
let options: [ItineraryOption]
|
let options: [ItineraryOption]
|
||||||
let games: [UUID: RichGame]
|
let games: [UUID: RichGame]
|
||||||
@@ -1229,23 +1265,55 @@ struct TripOptionsView: View {
|
|||||||
@State private var selectedTrip: Trip?
|
@State private var selectedTrip: Trip?
|
||||||
@State private var showTripDetail = false
|
@State private var showTripDetail = false
|
||||||
@State private var sortOption: TripSortOption = .recommended
|
@State private var sortOption: TripSortOption = .recommended
|
||||||
|
@State private var citiesFilter: CitiesFilter = .noLimit
|
||||||
|
@State private var paceFilter: TripPaceFilter = .all
|
||||||
@Environment(\.colorScheme) private var colorScheme
|
@Environment(\.colorScheme) private var colorScheme
|
||||||
|
|
||||||
private var sortedOptions: [ItineraryOption] {
|
// MARK: - Computed Properties
|
||||||
|
|
||||||
|
private func uniqueCityCount(for option: ItineraryOption) -> Int {
|
||||||
|
Set(option.stops.map { $0.city }).count
|
||||||
|
}
|
||||||
|
|
||||||
|
private var filteredAndSortedOptions: [ItineraryOption] {
|
||||||
|
// Apply filters first
|
||||||
|
let filtered = options.filter { option in
|
||||||
|
let cityCount = uniqueCityCount(for: option)
|
||||||
|
|
||||||
|
// City filter
|
||||||
|
guard cityCount <= citiesFilter.rawValue else { return false }
|
||||||
|
|
||||||
|
// Pace filter based on games per day ratio
|
||||||
|
switch paceFilter {
|
||||||
|
case .all:
|
||||||
|
return true
|
||||||
|
case .packed:
|
||||||
|
// High game density: > 0.8 games per day
|
||||||
|
return gamesPerDay(for: option) >= 0.8
|
||||||
|
case .moderate:
|
||||||
|
// Medium density: 0.4-0.8 games per day
|
||||||
|
let gpd = gamesPerDay(for: option)
|
||||||
|
return gpd >= 0.4 && gpd < 0.8
|
||||||
|
case .relaxed:
|
||||||
|
// Low density: < 0.4 games per day
|
||||||
|
return gamesPerDay(for: option) < 0.4
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Then apply sorting
|
||||||
switch sortOption {
|
switch sortOption {
|
||||||
case .recommended:
|
case .recommended:
|
||||||
return options
|
return filtered
|
||||||
case .mostGames:
|
case .mostGames:
|
||||||
return options.sorted { $0.totalGames > $1.totalGames }
|
return filtered.sorted { $0.totalGames > $1.totalGames }
|
||||||
case .leastGames:
|
case .leastGames:
|
||||||
return options.sorted { $0.totalGames < $1.totalGames }
|
return filtered.sorted { $0.totalGames < $1.totalGames }
|
||||||
case .mostMiles:
|
case .mostMiles:
|
||||||
return options.sorted { $0.totalDistanceMiles > $1.totalDistanceMiles }
|
return filtered.sorted { $0.totalDistanceMiles > $1.totalDistanceMiles }
|
||||||
case .leastMiles:
|
case .leastMiles:
|
||||||
return options.sorted { $0.totalDistanceMiles < $1.totalDistanceMiles }
|
return filtered.sorted { $0.totalDistanceMiles < $1.totalDistanceMiles }
|
||||||
case .bestEfficiency:
|
case .bestEfficiency:
|
||||||
// Games per driving hour (higher is better)
|
return filtered.sorted {
|
||||||
return options.sorted {
|
|
||||||
let effA = $0.totalDrivingHours > 0 ? Double($0.totalGames) / $0.totalDrivingHours : 0
|
let effA = $0.totalDrivingHours > 0 ? Double($0.totalGames) / $0.totalDrivingHours : 0
|
||||||
let effB = $1.totalDrivingHours > 0 ? Double($1.totalGames) / $1.totalDrivingHours : 0
|
let effB = $1.totalDrivingHours > 0 ? Double($1.totalGames) / $1.totalDrivingHours : 0
|
||||||
return effA > effB
|
return effA > effB
|
||||||
@@ -1253,42 +1321,48 @@ struct TripOptionsView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func gamesPerDay(for option: ItineraryOption) -> Double {
|
||||||
|
guard let first = option.stops.first,
|
||||||
|
let last = option.stops.last else { return 0 }
|
||||||
|
let days = max(1, Calendar.current.dateComponents([.day], from: first.arrivalDate, to: last.departureDate).day ?? 1)
|
||||||
|
return Double(option.totalGames) / Double(days)
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
LazyVStack(spacing: 20) {
|
LazyVStack(spacing: 16) {
|
||||||
// Hero header
|
// Hero header
|
||||||
VStack(spacing: 12) {
|
VStack(spacing: 8) {
|
||||||
Image(systemName: "point.topright.arrow.triangle.backward.to.point.bottomleft.scurvepath.fill")
|
Image(systemName: "point.topright.arrow.triangle.backward.to.point.bottomleft.scurvepath.fill")
|
||||||
.font(.system(size: 44))
|
.font(.system(size: 40))
|
||||||
.foregroundStyle(Theme.warmOrange)
|
.foregroundStyle(Theme.warmOrange)
|
||||||
|
|
||||||
Text("\(options.count) Routes Found")
|
Text("\(filteredAndSortedOptions.count) of \(options.count) Routes")
|
||||||
.font(.system(size: Theme.FontSize.sectionTitle, weight: .bold, design: .rounded))
|
.font(.system(size: Theme.FontSize.sectionTitle, weight: .bold, design: .rounded))
|
||||||
.foregroundStyle(Theme.textPrimary(colorScheme))
|
.foregroundStyle(Theme.textPrimary(colorScheme))
|
||||||
|
|
||||||
Text("Each route offers a unique adventure")
|
|
||||||
.font(.system(size: Theme.FontSize.body))
|
|
||||||
.foregroundStyle(Theme.textSecondary(colorScheme))
|
|
||||||
}
|
}
|
||||||
.padding(.top, Theme.Spacing.xl)
|
.padding(.top, Theme.Spacing.lg)
|
||||||
.padding(.bottom, Theme.Spacing.sm)
|
|
||||||
|
|
||||||
// Sort picker
|
// Filters section
|
||||||
sortPicker
|
filtersSection
|
||||||
.padding(.horizontal, Theme.Spacing.md)
|
.padding(.horizontal, Theme.Spacing.md)
|
||||||
.padding(.bottom, Theme.Spacing.sm)
|
|
||||||
|
|
||||||
// Options list
|
// Options list
|
||||||
ForEach(sortedOptions) { option in
|
if filteredAndSortedOptions.isEmpty {
|
||||||
TripOptionCard(
|
emptyFilterState
|
||||||
option: option,
|
.padding(.top, Theme.Spacing.xl)
|
||||||
games: games,
|
} else {
|
||||||
onSelect: {
|
ForEach(filteredAndSortedOptions) { option in
|
||||||
selectedTrip = convertToTrip(option)
|
TripOptionCard(
|
||||||
showTripDetail = true
|
option: option,
|
||||||
}
|
games: games,
|
||||||
)
|
onSelect: {
|
||||||
.padding(.horizontal, Theme.Spacing.md)
|
selectedTrip = convertToTrip(option)
|
||||||
|
showTripDetail = true
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.padding(.horizontal, Theme.Spacing.md)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.bottom, Theme.Spacing.xxl)
|
.padding(.bottom, Theme.Spacing.xxl)
|
||||||
@@ -1337,6 +1411,115 @@ struct TripOptionsView: View {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Filters Section
|
||||||
|
|
||||||
|
private var filtersSection: some View {
|
||||||
|
VStack(spacing: Theme.Spacing.md) {
|
||||||
|
// Sort and Pace row
|
||||||
|
HStack(spacing: Theme.Spacing.sm) {
|
||||||
|
sortPicker
|
||||||
|
Spacer()
|
||||||
|
pacePicker
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cities picker
|
||||||
|
citiesPicker
|
||||||
|
}
|
||||||
|
.padding(Theme.Spacing.md)
|
||||||
|
.background(Theme.cardBackground(colorScheme))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium))
|
||||||
|
}
|
||||||
|
|
||||||
|
private var pacePicker: some View {
|
||||||
|
Menu {
|
||||||
|
ForEach(TripPaceFilter.allCases) { pace in
|
||||||
|
Button {
|
||||||
|
withAnimation(.easeInOut(duration: 0.2)) {
|
||||||
|
paceFilter = pace
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Label(pace.rawValue, systemImage: pace.icon)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Image(systemName: paceFilter.icon)
|
||||||
|
.font(.system(size: 12))
|
||||||
|
Text(paceFilter.rawValue)
|
||||||
|
.font(.system(size: 13, weight: .medium))
|
||||||
|
Image(systemName: "chevron.down")
|
||||||
|
.font(.system(size: 10))
|
||||||
|
}
|
||||||
|
.foregroundStyle(paceFilter == .all ? Theme.textPrimary(colorScheme) : Theme.warmOrange)
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
.background(paceFilter == .all ? Theme.cardBackground(colorScheme) : Theme.warmOrange.opacity(0.15))
|
||||||
|
.clipShape(Capsule())
|
||||||
|
.overlay(
|
||||||
|
Capsule()
|
||||||
|
.strokeBorder(paceFilter == .all ? Theme.textMuted(colorScheme).opacity(0.2) : Theme.warmOrange.opacity(0.3), lineWidth: 1)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var citiesPicker: some View {
|
||||||
|
VStack(alignment: .leading, spacing: Theme.Spacing.xs) {
|
||||||
|
Label("Max Cities", systemImage: "mappin.circle")
|
||||||
|
.font(.system(size: 13, weight: .medium))
|
||||||
|
.foregroundStyle(Theme.textSecondary(colorScheme))
|
||||||
|
|
||||||
|
ScrollView(.horizontal, showsIndicators: false) {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
ForEach(CitiesFilter.allCases) { filter in
|
||||||
|
Button {
|
||||||
|
withAnimation(.easeInOut(duration: 0.2)) {
|
||||||
|
citiesFilter = filter
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Text(filter.displayName)
|
||||||
|
.font(.system(size: 13, weight: citiesFilter == filter ? .semibold : .medium))
|
||||||
|
.foregroundStyle(citiesFilter == filter ? .white : Theme.textPrimary(colorScheme))
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
.background(citiesFilter == filter ? Theme.warmOrange : Theme.cardBackground(colorScheme))
|
||||||
|
.clipShape(Capsule())
|
||||||
|
.overlay(
|
||||||
|
Capsule()
|
||||||
|
.strokeBorder(citiesFilter == filter ? Color.clear : Theme.textMuted(colorScheme).opacity(0.2), lineWidth: 1)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var emptyFilterState: some View {
|
||||||
|
VStack(spacing: Theme.Spacing.md) {
|
||||||
|
Image(systemName: "line.3.horizontal.decrease.circle")
|
||||||
|
.font(.system(size: 48))
|
||||||
|
.foregroundStyle(Theme.textMuted(colorScheme))
|
||||||
|
|
||||||
|
Text("No routes match your filters")
|
||||||
|
.font(.system(size: Theme.FontSize.body, weight: .medium))
|
||||||
|
.foregroundStyle(Theme.textSecondary(colorScheme))
|
||||||
|
|
||||||
|
Button {
|
||||||
|
withAnimation {
|
||||||
|
citiesFilter = .noLimit
|
||||||
|
paceFilter = .all
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Text("Reset Filters")
|
||||||
|
.font(.system(size: 14, weight: .medium))
|
||||||
|
.foregroundStyle(Theme.warmOrange)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.vertical, Theme.Spacing.xxl)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Trip Option Card
|
// MARK: - Trip Option Card
|
||||||
|
|||||||
@@ -25,10 +25,11 @@ enum GameDAGRouter {
|
|||||||
// MARK: - Configuration
|
// MARK: - Configuration
|
||||||
|
|
||||||
/// Default beam width - how many partial routes to keep at each step
|
/// 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
|
/// Maximum options to return (increased to provide more diverse trip lengths)
|
||||||
private static let maxOptions = 10
|
private static let maxOptions = 50
|
||||||
|
|
||||||
/// Buffer time after game ends before we can depart (hours)
|
/// Buffer time after game ends before we can depart (hours)
|
||||||
private static let gameEndBufferHours: Double = 3.0
|
private static let gameEndBufferHours: Double = 3.0
|
||||||
@@ -47,6 +48,7 @@ enum GameDAGRouter {
|
|||||||
/// - stadiums: Dictionary mapping stadium IDs to Stadium objects
|
/// - stadiums: Dictionary mapping stadium IDs to Stadium objects
|
||||||
/// - constraints: Driving constraints (number of drivers, max hours per day)
|
/// - constraints: Driving constraints (number of drivers, max hours per day)
|
||||||
/// - anchorGameIds: Games that MUST appear in every valid route (for Scenario B)
|
/// - 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)
|
/// - 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)
|
/// - Returns: Array of valid game combinations, sorted by score (most games, least driving)
|
||||||
@@ -56,6 +58,7 @@ enum GameDAGRouter {
|
|||||||
stadiums: [UUID: Stadium],
|
stadiums: [UUID: Stadium],
|
||||||
constraints: DrivingConstraints,
|
constraints: DrivingConstraints,
|
||||||
anchorGameIds: Set<UUID> = [],
|
anchorGameIds: Set<UUID> = [],
|
||||||
|
allowRepeatCities: Bool = true,
|
||||||
beamWidth: Int = defaultBeamWidth
|
beamWidth: Int = defaultBeamWidth
|
||||||
) -> [[Game]] {
|
) -> [[Game]] {
|
||||||
|
|
||||||
@@ -130,6 +133,15 @@ enum GameDAGRouter {
|
|||||||
|
|
||||||
// Try adding each of today's games
|
// Try adding each of today's games
|
||||||
for candidate in todaysGames {
|
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) {
|
if canTransition(from: lastGame, to: candidate, stadiums: stadiums, constraints: constraints) {
|
||||||
let newPath = path + [candidate]
|
let newPath = path + [candidate]
|
||||||
nextBeam.append(newPath)
|
nextBeam.append(newPath)
|
||||||
@@ -169,6 +181,7 @@ enum GameDAGRouter {
|
|||||||
from games: [Game],
|
from games: [Game],
|
||||||
stadiums: [UUID: Stadium],
|
stadiums: [UUID: Stadium],
|
||||||
anchorGameIds: Set<UUID> = [],
|
anchorGameIds: Set<UUID> = [],
|
||||||
|
allowRepeatCities: Bool = true,
|
||||||
stopBuilder: ([Game], [UUID: Stadium]) -> [ItineraryStop]
|
stopBuilder: ([Game], [UUID: Stadium]) -> [ItineraryStop]
|
||||||
) -> [[Game]] {
|
) -> [[Game]] {
|
||||||
// Use default driving constraints
|
// Use default driving constraints
|
||||||
@@ -178,7 +191,8 @@ enum GameDAGRouter {
|
|||||||
games: games,
|
games: games,
|
||||||
stadiums: stadiums,
|
stadiums: stadiums,
|
||||||
constraints: constraints,
|
constraints: constraints,
|
||||||
anchorGameIds: anchorGameIds
|
anchorGameIds: anchorGameIds,
|
||||||
|
allowRepeatCities: allowRepeatCities
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -288,8 +302,9 @@ enum GameDAGRouter {
|
|||||||
|
|
||||||
// MARK: - Geographic Diversity
|
// MARK: - Geographic Diversity
|
||||||
|
|
||||||
/// Selects geographically diverse routes from the candidate set.
|
/// Selects diverse routes from the candidate set.
|
||||||
/// Groups routes by their primary city (where most games are) and picks the best from each region.
|
/// 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(
|
private static func selectDiverseRoutes(
|
||||||
_ routes: [[Game]],
|
_ routes: [[Game]],
|
||||||
stadiums: [UUID: Stadium],
|
stadiums: [UUID: Stadium],
|
||||||
@@ -297,58 +312,88 @@ enum GameDAGRouter {
|
|||||||
) -> [[Game]] {
|
) -> [[Game]] {
|
||||||
guard !routes.isEmpty else { return [] }
|
guard !routes.isEmpty else { return [] }
|
||||||
|
|
||||||
// Group routes by primary city (the city with the most games in the route)
|
// Group routes by city count (route length)
|
||||||
var routesByRegion: [String: [[Game]]] = [:]
|
var routesByLength: [Int: [[Game]]] = [:]
|
||||||
|
|
||||||
for route in routes {
|
for route in routes {
|
||||||
let primaryCity = getPrimaryCity(for: route, stadiums: stadiums)
|
let cityCount = Set(route.compactMap { stadiums[$0.stadiumId]?.city }).count
|
||||||
routesByRegion[primaryCity, default: []].append(route)
|
routesByLength[cityCount, default: []].append(route)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort routes within each region by score (best first)
|
// Sort routes within each length by score
|
||||||
for (region, regionRoutes) in routesByRegion {
|
for (length, lengthRoutes) in routesByLength {
|
||||||
routesByRegion[region] = regionRoutes.sorted {
|
routesByLength[length] = lengthRoutes.sorted {
|
||||||
scorePath($0, stadiums: stadiums) > scorePath($1, stadiums: stadiums)
|
scorePath($0, stadiums: stadiums) > scorePath($1, stadiums: stadiums)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort regions by their best route's score (so best regions come first)
|
// Allocate slots to each length category
|
||||||
let sortedRegions = routesByRegion.keys.sorted { region1, region2 in
|
// Goal: ensure at least 1 route per length category if available
|
||||||
let score1 = routesByRegion[region1]?.first.map { scorePath($0, stadiums: stadiums) } ?? 0
|
let sortedLengths = routesByLength.keys.sorted()
|
||||||
let score2 = routesByRegion[region2]?.first.map { scorePath($0, stadiums: stadiums) } ?? 0
|
let minPerLength = max(1, maxCount / max(1, sortedLengths.count))
|
||||||
return score1 > score2
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// Pick routes round-robin from each region to ensure diversity
|
|
||||||
var selectedRoutes: [[Game]] = []
|
var selectedRoutes: [[Game]] = []
|
||||||
var regionIndices: [String: Int] = [:]
|
var selectedIds = Set<String>()
|
||||||
|
|
||||||
// First pass: get best route from each region
|
// First pass: take best route(s) from each length category
|
||||||
for region in sortedRegions {
|
for length in sortedLengths {
|
||||||
if selectedRoutes.count >= maxCount { break }
|
if selectedRoutes.count >= maxCount { break }
|
||||||
if let regionRoutes = routesByRegion[region], !regionRoutes.isEmpty {
|
if let lengthRoutes = routesByLength[length] {
|
||||||
selectedRoutes.append(regionRoutes[0])
|
let toTake = min(minPerLength, lengthRoutes.count, maxCount - selectedRoutes.count)
|
||||||
regionIndices[region] = 1
|
for route in lengthRoutes.prefix(toTake) {
|
||||||
}
|
let key = route.map { $0.id.uuidString }.joined(separator: "-")
|
||||||
}
|
if !selectedIds.contains(key) {
|
||||||
|
selectedRoutes.append(route)
|
||||||
// Second pass: fill remaining slots with next-best routes from top regions
|
selectedIds.insert(key)
|
||||||
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 !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
|
return selectedRoutes
|
||||||
@@ -412,6 +457,7 @@ enum GameDAGRouter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Prunes dominated paths and truncates to beam width.
|
/// 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(
|
private static func pruneAndTruncate(
|
||||||
_ paths: [[Game]],
|
_ paths: [[Game]],
|
||||||
beamWidth: Int,
|
beamWidth: Int,
|
||||||
@@ -429,32 +475,47 @@ enum GameDAGRouter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort by score (best first)
|
// Group paths by unique city count (route length)
|
||||||
let sorted = uniquePaths.sorted { scorePath($0, stadiums: stadiums) > scorePath($1, stadiums: stadiums) }
|
// 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 pruned: [[Game]] = []
|
||||||
var bestByEndCity: [String: Double] = [:]
|
|
||||||
|
|
||||||
for path in sorted {
|
// First pass: take minimum from each length group
|
||||||
guard let lastGame = path.last else { continue }
|
for length in sortedLengths {
|
||||||
let endCity = stadiums[lastGame.stadiumId]?.city ?? "Unknown"
|
if let lengthPaths = pathsByLength[length] {
|
||||||
let score = scorePath(path, stadiums: stadiums)
|
let toTake = min(minPerLength, lengthPaths.count)
|
||||||
|
pruned.append(contentsOf: lengthPaths.prefix(toTake))
|
||||||
// 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)
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Stop if we have enough
|
// Second pass: fill remaining slots with best paths overall
|
||||||
if pruned.count >= beamWidth * 2 {
|
if pruned.count < beamWidth {
|
||||||
break
|
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
|
// Final truncation
|
||||||
|
|||||||
60
SportsTime/Planning/Engine/RouteFilters.swift
Normal file
60
SportsTime/Planning/Engine/RouteFilters.swift
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
//
|
||||||
|
// RouteFilters.swift
|
||||||
|
// SportsTime
|
||||||
|
//
|
||||||
|
// Filters itinerary results based on user preferences.
|
||||||
|
// Applied in TripPlanningEngine AFTER scenario planners return.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import CoreLocation
|
||||||
|
|
||||||
|
enum RouteFilters {
|
||||||
|
|
||||||
|
// MARK: - Repeat Cities Filter
|
||||||
|
|
||||||
|
/// Filter itinerary options that violate repeat city rules.
|
||||||
|
/// When allowRepeatCities=false, each city must be visited on exactly ONE day.
|
||||||
|
static func filterRepeatCities(
|
||||||
|
_ options: [ItineraryOption],
|
||||||
|
allow: Bool
|
||||||
|
) -> [ItineraryOption] {
|
||||||
|
guard !allow else { return options }
|
||||||
|
return options.filter { !hasRepeatCityViolation($0) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if an itinerary visits any city on multiple days.
|
||||||
|
static func hasRepeatCityViolation(_ option: ItineraryOption) -> Bool {
|
||||||
|
let calendar = Calendar.current
|
||||||
|
var cityDays: [String: Set<Date>] = [:]
|
||||||
|
|
||||||
|
for stop in option.stops {
|
||||||
|
let city = stop.city
|
||||||
|
let day = calendar.startOfDay(for: stop.arrivalDate)
|
||||||
|
cityDays[city, default: []].insert(day)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Violation if any city has more than 1 day
|
||||||
|
return cityDays.values.contains(where: { $0.count > 1 })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get cities that are visited on multiple days (for error reporting).
|
||||||
|
static func findRepeatCities(in options: [ItineraryOption]) -> [String] {
|
||||||
|
var violatingCities = Set<String>()
|
||||||
|
let calendar = Calendar.current
|
||||||
|
|
||||||
|
for option in options {
|
||||||
|
var cityDays: [String: Set<Date>] = [:]
|
||||||
|
for stop in option.stops {
|
||||||
|
let day = calendar.startOfDay(for: stop.arrivalDate)
|
||||||
|
cityDays[stop.city, default: []].insert(day)
|
||||||
|
}
|
||||||
|
for (city, days) in cityDays where days.count > 1 {
|
||||||
|
violatingCities.insert(city)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array(violatingCities).sorted()
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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.
|
// Get all games that fall within the user's travel dates.
|
||||||
// Sort by start time so we visit them in chronological order.
|
// Sort by start time so we visit them in chronological order.
|
||||||
|
let selectedRegions = request.preferences.selectedRegions
|
||||||
let gamesInRange = request.allGames
|
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 }
|
.sorted { $0.startTime < $1.startTime }
|
||||||
|
|
||||||
// No games? Nothing to plan.
|
// No games? Nothing to plan.
|
||||||
@@ -91,11 +103,32 @@ final class ScenarioAPlanner: ScenarioPlanner {
|
|||||||
// We explore ALL valid combinations and return multiple options.
|
// We explore ALL valid combinations and return multiple options.
|
||||||
// Uses GameDAGRouter for polynomial-time beam search.
|
// 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,
|
from: gamesInRange,
|
||||||
stadiums: request.stadiums,
|
stadiums: request.stadiums,
|
||||||
|
allowRepeatCities: request.preferences.allowRepeatCities,
|
||||||
stopBuilder: buildStops
|
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)")
|
print("🔍 ScenarioA: gamesInRange=\(gamesInRange.count), validRoutes=\(validRoutes.count)")
|
||||||
if let firstRoute = validRoutes.first {
|
if let firstRoute = validRoutes.first {
|
||||||
@@ -201,11 +234,10 @@ final class ScenarioAPlanner: ScenarioPlanner {
|
|||||||
|
|
||||||
let rankedOptions = ItineraryOption.sortByLeisure(
|
let rankedOptions = ItineraryOption.sortByLeisure(
|
||||||
itineraryOptions,
|
itineraryOptions,
|
||||||
leisureLevel: leisureLevel,
|
leisureLevel: leisureLevel
|
||||||
limit: request.preferences.maxTripOptions
|
|
||||||
)
|
)
|
||||||
|
|
||||||
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 {
|
if let first = rankedOptions.first {
|
||||||
print("🔍 ScenarioA: First option has \(first.stops.count) stops")
|
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
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -85,12 +85,25 @@ final class ScenarioBPlanner: ScenarioPlanner {
|
|||||||
// Step 3: For each date range, find routes with anchors
|
// Step 3: For each date range, find routes with anchors
|
||||||
// ──────────────────────────────────────────────────────────────────
|
// ──────────────────────────────────────────────────────────────────
|
||||||
let anchorGameIds = Set(selectedGames.map { $0.id })
|
let anchorGameIds = Set(selectedGames.map { $0.id })
|
||||||
|
let selectedRegions = request.preferences.selectedRegions
|
||||||
var allItineraryOptions: [ItineraryOption] = []
|
var allItineraryOptions: [ItineraryOption] = []
|
||||||
|
|
||||||
for dateRange in dateRanges {
|
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
|
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 }
|
.sorted { $0.startTime < $1.startTime }
|
||||||
|
|
||||||
// Skip if no games (shouldn't happen if date range is valid)
|
// 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
|
// Find all sensible routes that include the anchor games
|
||||||
// Uses GameDAGRouter for polynomial-time beam search
|
// 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,
|
from: gamesInRange,
|
||||||
stadiums: request.stadiums,
|
stadiums: request.stadiums,
|
||||||
anchorGameIds: anchorGameIds,
|
anchorGameIds: anchorGameIds,
|
||||||
|
allowRepeatCities: request.preferences.allowRepeatCities,
|
||||||
stopBuilder: buildStops
|
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
|
// Build itineraries for each valid route
|
||||||
for routeGames in validRoutes {
|
for routeGames in validRoutes {
|
||||||
@@ -164,8 +195,7 @@ final class ScenarioBPlanner: ScenarioPlanner {
|
|||||||
let leisureLevel = request.preferences.leisureLevel
|
let leisureLevel = request.preferences.leisureLevel
|
||||||
let rankedOptions = ItineraryOption.sortByLeisure(
|
let rankedOptions = ItineraryOption.sortByLeisure(
|
||||||
allItineraryOptions,
|
allItineraryOptions,
|
||||||
leisureLevel: leisureLevel,
|
leisureLevel: leisureLevel
|
||||||
limit: request.preferences.maxTripOptions
|
|
||||||
)
|
)
|
||||||
|
|
||||||
return .success(Array(rankedOptions))
|
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
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -261,8 +261,7 @@ final class ScenarioCPlanner: ScenarioPlanner {
|
|||||||
let leisureLevel = request.preferences.leisureLevel
|
let leisureLevel = request.preferences.leisureLevel
|
||||||
let rankedOptions = ItineraryOption.sortByLeisure(
|
let rankedOptions = ItineraryOption.sortByLeisure(
|
||||||
allItineraryOptions,
|
allItineraryOptions,
|
||||||
leisureLevel: leisureLevel,
|
leisureLevel: leisureLevel
|
||||||
limit: request.preferences.maxTripOptions
|
|
||||||
)
|
)
|
||||||
|
|
||||||
return .success(Array(rankedOptions))
|
return .success(Array(rankedOptions))
|
||||||
|
|||||||
@@ -22,6 +22,41 @@ final class TripPlanningEngine {
|
|||||||
let planner = ScenarioPlannerFactory.planner(for: request)
|
let planner = ScenarioPlannerFactory.planner(for: request)
|
||||||
|
|
||||||
// Delegate to the scenario planner
|
// Delegate to the scenario planner
|
||||||
return planner.plan(request: request)
|
let result = planner.plan(request: request)
|
||||||
|
|
||||||
|
// Apply preference filters to successful results
|
||||||
|
return applyPreferenceFilters(to: result, request: request)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Private
|
||||||
|
|
||||||
|
/// Applies allowRepeatCities filter after scenario planners return.
|
||||||
|
/// Note: Region filtering is done during game selection in scenario planners.
|
||||||
|
private func applyPreferenceFilters(
|
||||||
|
to result: ItineraryResult,
|
||||||
|
request: PlanningRequest
|
||||||
|
) -> ItineraryResult {
|
||||||
|
guard case .success(let originalOptions) = result else {
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
var options = originalOptions
|
||||||
|
|
||||||
|
// Filter repeat cities (this is enforced during beam search, but double-check here)
|
||||||
|
options = RouteFilters.filterRepeatCities(
|
||||||
|
options,
|
||||||
|
allow: request.preferences.allowRepeatCities
|
||||||
|
)
|
||||||
|
|
||||||
|
if options.isEmpty && !request.preferences.allowRepeatCities {
|
||||||
|
let violatingCities = RouteFilters.findRepeatCities(in: originalOptions)
|
||||||
|
return .failure(PlanningFailure(
|
||||||
|
reason: .repeatCityViolation(cities: violatingCities)
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Region filtering is applied during game selection in scenario planners
|
||||||
|
|
||||||
|
return .success(options)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ struct PlanningFailure: Error {
|
|||||||
case travelSegmentMissing
|
case travelSegmentMissing
|
||||||
case constraintsUnsatisfiable
|
case constraintsUnsatisfiable
|
||||||
case geographicBacktracking
|
case geographicBacktracking
|
||||||
|
case repeatCityViolation(cities: [String])
|
||||||
|
|
||||||
static func == (lhs: FailureReason, rhs: FailureReason) -> Bool {
|
static func == (lhs: FailureReason, rhs: FailureReason) -> Bool {
|
||||||
switch (lhs, rhs) {
|
switch (lhs, rhs) {
|
||||||
@@ -50,6 +51,8 @@ struct PlanningFailure: Error {
|
|||||||
return true
|
return true
|
||||||
case (.dateRangeViolation(let g1), .dateRangeViolation(let g2)):
|
case (.dateRangeViolation(let g1), .dateRangeViolation(let g2)):
|
||||||
return g1.map { $0.id } == g2.map { $0.id }
|
return g1.map { $0.id } == g2.map { $0.id }
|
||||||
|
case (.repeatCityViolation(let c1), .repeatCityViolation(let c2)):
|
||||||
|
return c1 == c2
|
||||||
default:
|
default:
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -74,6 +77,10 @@ struct PlanningFailure: Error {
|
|||||||
case .travelSegmentMissing: return "Travel segment could not be created"
|
case .travelSegmentMissing: return "Travel segment could not be created"
|
||||||
case .constraintsUnsatisfiable: return "Cannot satisfy all trip constraints"
|
case .constraintsUnsatisfiable: return "Cannot satisfy all trip constraints"
|
||||||
case .geographicBacktracking: return "Route requires excessive backtracking"
|
case .geographicBacktracking: return "Route requires excessive backtracking"
|
||||||
|
case .repeatCityViolation(let cities):
|
||||||
|
let cityList = cities.prefix(3).joined(separator: ", ")
|
||||||
|
let suffix = cities.count > 3 ? " and \(cities.count - 3) more" : ""
|
||||||
|
return "Cannot visit cities on multiple days: \(cityList)\(suffix)"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -182,8 +189,7 @@ struct ItineraryOption: Identifiable {
|
|||||||
/// - Parameters:
|
/// - Parameters:
|
||||||
/// - options: The itinerary options to sort
|
/// - options: The itinerary options to sort
|
||||||
/// - leisureLevel: The user's leisure preference
|
/// - leisureLevel: The user's leisure preference
|
||||||
/// - limit: Maximum number of options to return (default 10)
|
/// - Returns: Sorted and ranked options (all options, no limit)
|
||||||
/// - Returns: Sorted and ranked options
|
|
||||||
///
|
///
|
||||||
/// Sorting behavior:
|
/// Sorting behavior:
|
||||||
/// - Packed: Most games first, then least driving
|
/// - Packed: Most games first, then least driving
|
||||||
@@ -191,8 +197,7 @@ struct ItineraryOption: Identifiable {
|
|||||||
/// - Relaxed: Least driving first, then fewer games
|
/// - Relaxed: Least driving first, then fewer games
|
||||||
static func sortByLeisure(
|
static func sortByLeisure(
|
||||||
_ options: [ItineraryOption],
|
_ options: [ItineraryOption],
|
||||||
leisureLevel: LeisureLevel,
|
leisureLevel: LeisureLevel
|
||||||
limit: Int = 10
|
|
||||||
) -> [ItineraryOption] {
|
) -> [ItineraryOption] {
|
||||||
let sorted = options.sorted { a, b in
|
let sorted = options.sorted { a, b in
|
||||||
let aGames = a.totalGames
|
let aGames = a.totalGames
|
||||||
@@ -220,8 +225,8 @@ struct ItineraryOption: Identifiable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Re-rank after sorting
|
// Re-rank after sorting (no limit - return all options)
|
||||||
return Array(sorted.prefix(limit)).enumerated().map { index, option in
|
return sorted.enumerated().map { index, option in
|
||||||
ItineraryOption(
|
ItineraryOption(
|
||||||
rank: index + 1,
|
rank: index + 1,
|
||||||
stops: option.stops,
|
stops: option.stops,
|
||||||
|
|||||||
Reference in New Issue
Block a user