- 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>
214 lines
6.9 KiB
Swift
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()
|
|
}
|