// // RegionMapSelector.swift // SportsTime // // Interactive map for selecting travel regions using MapKit. // import MapKit import SwiftUI /// A map-based selector for choosing geographic regions. /// Shows North America with three tappable overlay 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 let onToggle: (Region) -> Void @Environment(\.colorScheme) private var colorScheme @Environment(\.isDemoMode) private var isDemoMode @State private var hasAppliedDemoSelection = false // Camera position centered on continental US @State private var cameraPosition: MapCameraPosition = .camera( MapCamera( centerCoordinate: CLLocationCoordinate2D(latitude: 39.5, longitude: -98.0), distance: 8_000_000, heading: 0, pitch: 0 ) ) var body: some View { VStack(spacing: Theme.Spacing.sm) { // Map with region overlays ZStack { MapReader { proxy in Map(position: $cameraPosition, interactionModes: []) { // West region polygon MapPolygon(coordinates: RegionCoordinates.west) .foregroundStyle(fillColor(for: .west)) .stroke(strokeColor(for: .west), lineWidth: strokeWidth(for: .west)) // Central region polygon MapPolygon(coordinates: RegionCoordinates.central) .foregroundStyle(fillColor(for: .central)) .stroke(strokeColor(for: .central), lineWidth: strokeWidth(for: .central)) // East region polygon MapPolygon(coordinates: RegionCoordinates.east) .foregroundStyle(fillColor(for: .east)) .stroke(strokeColor(for: .east), lineWidth: strokeWidth(for: .east)) } .mapStyle(.standard(elevation: .flat, pointsOfInterest: .excludingAll)) .onTapGesture { location in if let coordinate = proxy.convert(location, from: .local) { let tappedRegion = regionForCoordinate(coordinate) onToggle(tappedRegion) } } } // Invisible button overlays for UI testing accessibility HStack(spacing: 0) { Button { onToggle(.west) } label: { Color.clear } .accessibilityIdentifier("wizard.regions.west") .accessibilityLabel("West region") .accessibilityValue(selectedRegions.contains(.west) ? "Selected" : "Not selected") .accessibilityHint( selectedRegions.contains(.west) ? "Double-tap to deselect this region" : "Double-tap to select this region" ) .accessibilityAddTraits(selectedRegions.contains(.west) ? .isSelected : []) .frame(maxWidth: .infinity) Button { onToggle(.central) } label: { Color.clear } .accessibilityIdentifier("wizard.regions.central") .accessibilityLabel("Central region") .accessibilityValue(selectedRegions.contains(.central) ? "Selected" : "Not selected") .accessibilityHint( selectedRegions.contains(.central) ? "Double-tap to deselect this region" : "Double-tap to select this region" ) .accessibilityAddTraits(selectedRegions.contains(.central) ? .isSelected : []) .frame(maxWidth: .infinity) Button { onToggle(.east) } label: { Color.clear } .accessibilityIdentifier("wizard.regions.east") .accessibilityLabel("East region") .accessibilityValue(selectedRegions.contains(.east) ? "Selected" : "Not selected") .accessibilityHint( selectedRegions.contains(.east) ? "Double-tap to deselect this region" : "Double-tap to select this region" ) .accessibilityAddTraits(selectedRegions.contains(.east) ? .isSelected : []) .frame(maxWidth: .infinity) } } .frame(height: 160) .clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)) .overlay( RoundedRectangle(cornerRadius: Theme.CornerRadius.medium) .stroke(Theme.textMuted(colorScheme).opacity(0.3), lineWidth: 1) ) // Region legend with colors and example states regionLegend // Selection footer selectionFooter } .onAppear { if isDemoMode && !hasAppliedDemoSelection && selectedRegions.isEmpty { hasAppliedDemoSelection = true DispatchQueue.main.asyncAfter(deadline: .now() + DemoConfig.selectionDelay) { onToggle(DemoConfig.demoRegion) } } } } // MARK: - Coordinate to Region /// Determines which region a coordinate falls into based on state groupings. /// West: States west of ~-102° (WA, OR, CA, NV, ID, UT, AZ, MT, WY, CO, NM) /// Central: States between ~-102° and ~-89° (ND, SD, NE, KS, OK, TX, MN, IA, MO, AR, LA, WI, IL) /// East: States east of ~-89° private func regionForCoordinate(_ coordinate: CLLocationCoordinate2D) -> Region { let longitude = coordinate.longitude if longitude < -102 { return .west } else if longitude > -89 { return .east } else { return .central } } // MARK: - Styling private func fillColor(for region: Region) -> Color { let isSelected = selectedRegions.contains(region) if isSelected { return regionColor(region).opacity(0.6) } return Color.gray.opacity(0.15) } private func strokeColor(for region: Region) -> Color { let isSelected = selectedRegions.contains(region) if isSelected { return regionColor(region) } return Color.white.opacity(0.4) } private func strokeWidth(for region: Region) -> CGFloat { selectedRegions.contains(region) ? 3 : 1 } private func regionColor(_ region: Region) -> Color { switch region { case .west: return Color(hex: "f97316") // Orange case .central: return Color(hex: "3b82f6") // Blue case .east: return Color(hex: "22c55e") // Green case .crossCountry: return .purple } } // MARK: - Legend private var regionLegend: some View { HStack(spacing: Theme.Spacing.md) { legendItem(.west, states: "CA, WA, OR, AZ") legendItem(.central, states: "TX, IL, CO, MN") legendItem(.east, states: "NY, FL, MA, GA") } } private func legendItem(_ region: Region, states: String) -> some View { let isSelected = selectedRegions.contains(region) return HStack(spacing: 6) { Circle() .fill(regionColor(region)) .frame(width: 10, height: 10) .overlay( Circle() .stroke(Color.white.opacity(0.5), lineWidth: isSelected ? 2 : 0) ) .accessibilityHidden(true) VStack(alignment: .leading, spacing: 1) { Text(region.shortName) .font(.caption2) .foregroundStyle(isSelected ? regionColor(region) : Theme.textPrimary(colorScheme)) Text(states) .font(.caption2) .foregroundStyle(Theme.textMuted(colorScheme)) } } .frame(maxWidth: .infinity, alignment: .leading) } private var selectionFooter: some View { HStack(spacing: Theme.Spacing.md) { if selectedRegions.isEmpty { Text("Tap map to select regions") .font(.caption) .foregroundStyle(Theme.textMuted(colorScheme)) } else { Spacer() Button { selectedRegions.removeAll() } label: { Text("Clear") .font(.caption) .foregroundStyle(Theme.warmOrange) } } } } } // MARK: - Region Coordinates /// Geographic coordinates tracing actual state borders. /// West: WA, OR, CA, NV, ID, UT, AZ, MT, WY, CO, NM /// Central: ND, SD, NE, KS, OK, TX, MN, IA, MO, AR, LA, WI, IL /// East: All remaining states private enum RegionCoordinates { // MARK: - West Region // Outer boundary of: WA, OR, CA, NV, ID, UT, AZ, MT, WY, CO, NM static let west: [CLLocationCoordinate2D] = [ // Start: Canada/WA border, Pacific CLLocationCoordinate2D(latitude: 49.0, longitude: -123.3), // WA coast south CLLocationCoordinate2D(latitude: 46.3, longitude: -124.0), // OR coast CLLocationCoordinate2D(latitude: 42.0, longitude: -124.2), // CA coast CLLocationCoordinate2D(latitude: 39.0, longitude: -123.7), CLLocationCoordinate2D(latitude: 34.5, longitude: -120.5), CLLocationCoordinate2D(latitude: 32.5, longitude: -117.1), // CA/Mexico border CLLocationCoordinate2D(latitude: 32.7, longitude: -114.7), // AZ/Mexico border CLLocationCoordinate2D(latitude: 31.3, longitude: -111.1), // NM/Mexico border to TX border CLLocationCoordinate2D(latitude: 31.8, longitude: -108.2), CLLocationCoordinate2D(latitude: 32.0, longitude: -106.6), // NM/TX border north CLLocationCoordinate2D(latitude: 32.0, longitude: -103.0), // NM/OK/TX corner, then CO/OK border CLLocationCoordinate2D(latitude: 36.5, longitude: -103.0), // CO/KS border CLLocationCoordinate2D(latitude: 37.0, longitude: -102.0), CLLocationCoordinate2D(latitude: 40.0, longitude: -102.0), // CO/NE border to WY CLLocationCoordinate2D(latitude: 41.0, longitude: -102.0), // WY/NE/SD corner CLLocationCoordinate2D(latitude: 43.0, longitude: -104.0), // WY/SD border CLLocationCoordinate2D(latitude: 45.0, longitude: -104.0), // MT/ND border CLLocationCoordinate2D(latitude: 49.0, longitude: -104.0), // Canada border west CLLocationCoordinate2D(latitude: 49.0, longitude: -117.0), ] // MARK: - Central Region // Outer boundary of: ND, SD, NE, KS, OK, TX, MN, IA, MO, AR, LA, WI, IL static let central: [CLLocationCoordinate2D] = [ // Start: Canada/ND border at MT CLLocationCoordinate2D(latitude: 49.0, longitude: -104.0), // MT/ND border south to SD CLLocationCoordinate2D(latitude: 45.9, longitude: -104.0), // WY/SD border CLLocationCoordinate2D(latitude: 45.0, longitude: -104.0), CLLocationCoordinate2D(latitude: 43.0, longitude: -104.0), // NE/WY border CLLocationCoordinate2D(latitude: 41.0, longitude: -104.0), // CO/NE border CLLocationCoordinate2D(latitude: 41.0, longitude: -102.0), // KS/CO border CLLocationCoordinate2D(latitude: 40.0, longitude: -102.0), CLLocationCoordinate2D(latitude: 37.0, longitude: -102.0), // OK/CO/NM corner CLLocationCoordinate2D(latitude: 36.5, longitude: -103.0), // TX/NM border CLLocationCoordinate2D(latitude: 32.0, longitude: -103.0), CLLocationCoordinate2D(latitude: 32.0, longitude: -106.6), // TX/Mexico border (Rio Grande) CLLocationCoordinate2D(latitude: 31.8, longitude: -106.4), CLLocationCoordinate2D(latitude: 29.5, longitude: -103.4), CLLocationCoordinate2D(latitude: 26.0, longitude: -97.4), // TX Gulf coast CLLocationCoordinate2D(latitude: 26.1, longitude: -97.2), CLLocationCoordinate2D(latitude: 29.4, longitude: -94.7), // LA Gulf coast CLLocationCoordinate2D(latitude: 29.5, longitude: -93.8), CLLocationCoordinate2D(latitude: 29.1, longitude: -89.0), // LA/MS border at Gulf CLLocationCoordinate2D(latitude: 30.2, longitude: -89.1), // MS/LA/AR corner, up to TN CLLocationCoordinate2D(latitude: 33.0, longitude: -91.0), // AR/TN border CLLocationCoordinate2D(latitude: 35.0, longitude: -90.3), CLLocationCoordinate2D(latitude: 36.5, longitude: -89.5), // IL eastern border (up to WI) CLLocationCoordinate2D(latitude: 37.0, longitude: -89.1), CLLocationCoordinate2D(latitude: 39.0, longitude: -87.5), CLLocationCoordinate2D(latitude: 41.7, longitude: -87.5), CLLocationCoordinate2D(latitude: 42.5, longitude: -87.5), // WI/MI border at Lake Michigan, north to Lake Superior CLLocationCoordinate2D(latitude: 45.0, longitude: -87.0), CLLocationCoordinate2D(latitude: 46.8, longitude: -87.5), CLLocationCoordinate2D(latitude: 46.5, longitude: -85.0), // Lake Superior north shore CLLocationCoordinate2D(latitude: 48.0, longitude: -89.5), // MN/Canada CLLocationCoordinate2D(latitude: 49.0, longitude: -89.5), // Canada border west to ND CLLocationCoordinate2D(latitude: 49.0, longitude: -97.2), ] // MARK: - East Region // Outer boundary of: MI, IN, OH, KY, TN, MS, AL, GA, FL, SC, NC, VA, WV, MD, DE, PA, NJ, NY, CT, RI, MA, VT, NH, ME static let east: [CLLocationCoordinate2D] = [ // Start at western border (IL/IN, TN/AR, MS/LA line) going south CLLocationCoordinate2D(latitude: 42.5, longitude: -87.5), // WI/IL/IN corner (Lake Michigan) CLLocationCoordinate2D(latitude: 41.7, longitude: -87.5), // IL/IN border CLLocationCoordinate2D(latitude: 39.0, longitude: -87.5), // IN/IL border CLLocationCoordinate2D(latitude: 37.0, longitude: -89.1), // KY/IL/MO corner CLLocationCoordinate2D(latitude: 36.5, longitude: -89.5), // TN/AR/MO corner CLLocationCoordinate2D(latitude: 35.0, longitude: -90.3), // TN/AR/MS corner CLLocationCoordinate2D(latitude: 33.0, longitude: -91.0), // MS/LA/AR corner CLLocationCoordinate2D(latitude: 31.0, longitude: -89.7), // MS/LA border south CLLocationCoordinate2D(latitude: 30.2, longitude: -89.1), // MS Gulf coast // AL/FL Gulf coast CLLocationCoordinate2D(latitude: 30.2, longitude: -87.5), // AL coast CLLocationCoordinate2D(latitude: 30.0, longitude: -85.0), // FL panhandle // FL peninsula CLLocationCoordinate2D(latitude: 29.0, longitude: -83.0), // FL Gulf CLLocationCoordinate2D(latitude: 26.0, longitude: -82.0), // SW FL CLLocationCoordinate2D(latitude: 25.0, longitude: -80.5), // Miami // Atlantic coast north CLLocationCoordinate2D(latitude: 27.0, longitude: -80.0), // FL Atlantic CLLocationCoordinate2D(latitude: 30.5, longitude: -81.2), // Jacksonville CLLocationCoordinate2D(latitude: 32.0, longitude: -80.8), // Savannah CLLocationCoordinate2D(latitude: 34.0, longitude: -78.0), // NC coast CLLocationCoordinate2D(latitude: 36.5, longitude: -76.0), // VA coast CLLocationCoordinate2D(latitude: 38.5, longitude: -75.0), // DE coast CLLocationCoordinate2D(latitude: 40.0, longitude: -74.0), // NJ coast CLLocationCoordinate2D(latitude: 41.0, longitude: -72.0), // CT coast CLLocationCoordinate2D(latitude: 42.0, longitude: -70.5), // MA coast CLLocationCoordinate2D(latitude: 43.5, longitude: -70.0), // ME south CLLocationCoordinate2D(latitude: 45.0, longitude: -67.0), // ME coast CLLocationCoordinate2D(latitude: 47.2, longitude: -68.5), // ME north // Canada border west to Great Lakes CLLocationCoordinate2D(latitude: 47.0, longitude: -69.5), // ME/Canada CLLocationCoordinate2D(latitude: 45.0, longitude: -71.5), // VT/Canada CLLocationCoordinate2D(latitude: 45.0, longitude: -75.0), // NY/Canada CLLocationCoordinate2D(latitude: 43.5, longitude: -79.0), // Lake Ontario CLLocationCoordinate2D(latitude: 42.5, longitude: -79.0), // Lake Erie west CLLocationCoordinate2D(latitude: 41.5, longitude: -83.0), // OH/MI CLLocationCoordinate2D(latitude: 43.0, longitude: -82.5), // MI east CLLocationCoordinate2D(latitude: 46.0, longitude: -84.0), // Upper MI CLLocationCoordinate2D(latitude: 46.5, longitude: -85.0), // Lake Superior CLLocationCoordinate2D(latitude: 46.8, longitude: -87.5), // Upper MI west CLLocationCoordinate2D(latitude: 45.0, longitude: -87.0), // WI/MI (Green Bay) ] } // MARK: - Preview #Preview { struct PreviewWrapper: View { @State private var selected: Set = [.central] var body: some View { VStack(spacing: 20) { RegionMapSelector(selectedRegions: $selected) { region in if selected.contains(region) { selected.remove(region) } else { // Adjacency rule: can't select both East and West 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() } #Preview("Dark Mode") { struct PreviewWrapper: View { @State private var selected: Set = [.west, .central] var body: some View { VStack(spacing: 20) { RegionMapSelector(selectedRegions: $selected) { region in if selected.contains(region) { selected.remove(region) } else { 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: ", "))") .foregroundStyle(.white) } .padding() .background(Color(hex: "1a1a2e")) } } return PreviewWrapper() .preferredColorScheme(.dark) }