Files
Sportstime/SportsTime/Features/Trip/Views/RegionMapSelector.swift
Trey t f5e509a9ae 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>
2026-01-09 15:18:37 -06:00

214 lines
6.9 KiB
Swift

//
// 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()
}