Replace abstract colored rectangles with actual North America map using MapKit. Regions now follow state borders with tappable overlays for West/Central/East selection. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
393 lines
16 KiB
Swift
393 lines
16 KiB
Swift
//
|
|
// 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<Region>
|
|
let onToggle: (Region) -> Void
|
|
|
|
@Environment(\.colorScheme) private var colorScheme
|
|
|
|
// 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
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
.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
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
)
|
|
|
|
VStack(alignment: .leading, spacing: 1) {
|
|
Text(region.shortName)
|
|
.font(.system(size: 11, weight: isSelected ? .bold : .medium))
|
|
.foregroundStyle(isSelected ? regionColor(region) : Theme.textPrimary(colorScheme))
|
|
|
|
Text(states)
|
|
.font(.system(size: 9))
|
|
.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(.system(size: Theme.FontSize.micro))
|
|
.foregroundStyle(Theme.textMuted(colorScheme))
|
|
} else {
|
|
Spacer()
|
|
|
|
Button {
|
|
selectedRegions.removeAll()
|
|
} label: {
|
|
Text("Clear")
|
|
.font(.system(size: Theme.FontSize.micro))
|
|
.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<Region> = [.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<Region> = [.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)
|
|
}
|