Remove CFB/NASCAR/PGA and streamline to 8 supported sports
- Remove College Football, NASCAR, and PGA from scraper and app - Clean all data files (stadiums, games, pipeline reports) - Update Sport.swift enum and all UI components - Add sportstime.py CLI tool for pipeline management - Add DATA_SCRAPING.md documentation - Add WNBA/MLS/NWSL implementation documentation - Scraper now supports: NBA, MLB, NHL, NFL, WNBA, MLS, NWSL, CBB Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -12,6 +12,9 @@ enum Sport: String, Codable, CaseIterable, Identifiable {
|
||||
case nhl = "NHL"
|
||||
case nfl = "NFL"
|
||||
case mls = "MLS"
|
||||
case wnba = "WNBA"
|
||||
case nwsl = "NWSL"
|
||||
case cbb = "CBB"
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
@@ -22,6 +25,9 @@ enum Sport: String, Codable, CaseIterable, Identifiable {
|
||||
case .nhl: return "National Hockey League"
|
||||
case .nfl: return "National Football League"
|
||||
case .mls: return "Major League Soccer"
|
||||
case .wnba: return "Women's National Basketball Association"
|
||||
case .nwsl: return "National Women's Soccer League"
|
||||
case .cbb: return "College Basketball"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,6 +38,9 @@ enum Sport: String, Codable, CaseIterable, Identifiable {
|
||||
case .nhl: return "hockey.puck.fill"
|
||||
case .nfl: return "football.fill"
|
||||
case .mls: return "soccerball"
|
||||
case .wnba: return "basketball.fill"
|
||||
case .nwsl: return "soccerball"
|
||||
case .cbb: return "basketball.fill"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,6 +51,9 @@ enum Sport: String, Codable, CaseIterable, Identifiable {
|
||||
case .nhl: return .blue
|
||||
case .nfl: return .brown
|
||||
case .mls: return .green
|
||||
case .wnba: return .purple
|
||||
case .nwsl: return .teal
|
||||
case .cbb: return .mint
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,6 +65,9 @@ enum Sport: String, Codable, CaseIterable, Identifiable {
|
||||
case .nhl: return (10, 6) // October - June (wraps)
|
||||
case .nfl: return (9, 2) // September - February (wraps)
|
||||
case .mls: return (2, 12) // February - December
|
||||
case .wnba: return (5, 10) // May - October
|
||||
case .nwsl: return (3, 11) // March - November
|
||||
case .cbb: return (11, 4) // November - April (wraps, March Madness)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,8 +85,19 @@ enum Sport: String, Codable, CaseIterable, Identifiable {
|
||||
}
|
||||
}
|
||||
|
||||
/// Currently supported sports for MVP
|
||||
/// Currently supported sports
|
||||
static var supported: [Sport] {
|
||||
[.mlb, .nba, .nhl]
|
||||
[.mlb, .nba, .nfl, .nhl, .mls, .wnba, .nwsl, .cbb]
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Array Chunking
|
||||
|
||||
extension Array {
|
||||
/// Splits array into chunks of specified size
|
||||
func chunked(into size: Int) -> [[Element]] {
|
||||
stride(from: 0, to: count, by: size).map {
|
||||
Array(self[$0..<Swift.min($0 + size, count)])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ enum PlanningMode: String, Codable, CaseIterable, Identifiable {
|
||||
|
||||
var description: String {
|
||||
switch self {
|
||||
case .dateRange: return "Find games within a date range"
|
||||
case .dateRange: return "Shows a curated sample of possible routes — use filters to find your ideal trip"
|
||||
case .gameFirst: return "Build trip around specific games"
|
||||
case .locations: return "Plan route between locations"
|
||||
}
|
||||
|
||||
@@ -126,6 +126,9 @@ enum Theme {
|
||||
static let nhlBlue = Color(hex: "003087")
|
||||
static let nflBrown = Color(hex: "8B5A2B")
|
||||
static let mlsGreen = Color(hex: "00A651")
|
||||
static let wnbaPurple = Color(hex: "FF6F20") // WNBA orange
|
||||
static let nwslTeal = Color(hex: "009688") // NWSL teal
|
||||
static let cbbMint = Color(hex: "3EB489") // College Basketball mint
|
||||
|
||||
// MARK: - Dark Mode Colors
|
||||
|
||||
|
||||
@@ -215,6 +215,9 @@ extension Sport {
|
||||
case .nhl: return Theme.nhlBlue
|
||||
case .nfl: return Theme.nflBrown
|
||||
case .mls: return Theme.mlsGreen
|
||||
case .wnba: return Theme.wnbaPurple
|
||||
case .nwsl: return Theme.nwslTeal
|
||||
case .cbb: return Theme.cbbMint
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -169,19 +169,40 @@ struct HomeView: View {
|
||||
// MARK: - Quick Actions
|
||||
|
||||
private var quickActions: some View {
|
||||
VStack(alignment: .leading, spacing: Theme.Spacing.sm) {
|
||||
let sports = Sport.supported
|
||||
let rows = sports.chunked(into: 4)
|
||||
|
||||
return VStack(alignment: .leading, spacing: Theme.Spacing.sm) {
|
||||
Text("Quick Start")
|
||||
.font(.system(size: Theme.FontSize.sectionTitle, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(Theme.textPrimary(colorScheme))
|
||||
|
||||
HStack(spacing: Theme.Spacing.sm) {
|
||||
ForEach(Sport.supported) { sport in
|
||||
QuickSportButton(sport: sport) {
|
||||
selectedSport = sport
|
||||
showNewTrip = true
|
||||
VStack(spacing: Theme.Spacing.md) {
|
||||
ForEach(Array(rows.enumerated()), id: \.offset) { _, row in
|
||||
HStack(spacing: Theme.Spacing.sm) {
|
||||
ForEach(row) { sport in
|
||||
QuickSportButton(sport: sport) {
|
||||
selectedSport = sport
|
||||
showNewTrip = true
|
||||
}
|
||||
}
|
||||
// Fill remaining space if row has fewer than 4 items
|
||||
if row.count < 4 {
|
||||
ForEach(0..<(4 - row.count), id: \.self) { _ in
|
||||
Color.clear.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, Theme.Spacing.md)
|
||||
.padding(.vertical, Theme.Spacing.md)
|
||||
.background(Theme.cardBackground(colorScheme))
|
||||
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.large))
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: Theme.CornerRadius.large)
|
||||
.stroke(Theme.surfaceGlow(colorScheme), lineWidth: 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -339,30 +360,23 @@ struct QuickSportButton: View {
|
||||
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
VStack(spacing: Theme.Spacing.xs) {
|
||||
VStack(spacing: 6) {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(sport.themeColor.opacity(0.15))
|
||||
.frame(width: 44, height: 44)
|
||||
.frame(width: 48, height: 48)
|
||||
|
||||
Image(systemName: sport.iconName)
|
||||
.font(.title2)
|
||||
.font(.system(size: 20))
|
||||
.foregroundStyle(sport.themeColor)
|
||||
}
|
||||
|
||||
Text(sport.rawValue)
|
||||
.font(.system(size: Theme.FontSize.micro, weight: .medium))
|
||||
.font(.system(size: 10, weight: .medium))
|
||||
.foregroundStyle(Theme.textSecondary(colorScheme))
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, Theme.Spacing.sm)
|
||||
.background(Theme.cardBackground(colorScheme))
|
||||
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium))
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)
|
||||
.stroke(Theme.surfaceGlow(colorScheme), lineWidth: 1)
|
||||
}
|
||||
.scaleEffect(isPressed ? 0.95 : 1.0)
|
||||
.scaleEffect(isPressed ? 0.9 : 1.0)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.simultaneousGesture(
|
||||
|
||||
@@ -244,13 +244,7 @@ struct SettingsView: View {
|
||||
// MARK: - Helpers
|
||||
|
||||
private func sportColor(for sport: Sport) -> Color {
|
||||
switch sport {
|
||||
case .mlb: return .red
|
||||
case .nba: return .orange
|
||||
case .nhl: return .blue
|
||||
case .nfl: return .green
|
||||
case .mls: return .purple
|
||||
}
|
||||
sport.themeColor
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -58,8 +58,24 @@ final class TripCreationViewModel {
|
||||
}
|
||||
|
||||
// Dates
|
||||
var startDate: Date = Date()
|
||||
var endDate: Date = Date().addingTimeInterval(86400 * 7)
|
||||
var startDate: Date = Date() {
|
||||
didSet {
|
||||
// Clear cached games when start date changes
|
||||
if !Calendar.current.isDate(startDate, inSameDayAs: oldValue) {
|
||||
availableGames = []
|
||||
games = []
|
||||
}
|
||||
}
|
||||
}
|
||||
var endDate: Date = Date().addingTimeInterval(86400 * 7) {
|
||||
didSet {
|
||||
// Clear cached games when end date changes
|
||||
if !Calendar.current.isDate(endDate, inSameDayAs: oldValue) {
|
||||
availableGames = []
|
||||
games = []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Trip duration for game-first mode (days before/after selected games)
|
||||
var tripBufferDays: Int = 2
|
||||
|
||||
@@ -524,22 +524,36 @@ struct TripCreationView: View {
|
||||
}
|
||||
|
||||
private var sportsSection: some View {
|
||||
ThemedSection(title: "Sports") {
|
||||
HStack(spacing: Theme.Spacing.sm) {
|
||||
ForEach(Sport.supported) { sport in
|
||||
SportSelectionChip(
|
||||
sport: sport,
|
||||
isSelected: viewModel.selectedSports.contains(sport),
|
||||
onTap: {
|
||||
if viewModel.selectedSports.contains(sport) {
|
||||
viewModel.selectedSports.remove(sport)
|
||||
} else {
|
||||
viewModel.selectedSports.insert(sport)
|
||||
let sports = Sport.supported
|
||||
let rows = sports.chunked(into: 4)
|
||||
|
||||
return ThemedSection(title: "Sports") {
|
||||
VStack(spacing: Theme.Spacing.sm) {
|
||||
ForEach(Array(rows.enumerated()), id: \.offset) { _, row in
|
||||
HStack(spacing: Theme.Spacing.sm) {
|
||||
ForEach(row) { sport in
|
||||
SportSelectionChip(
|
||||
sport: sport,
|
||||
isSelected: viewModel.selectedSports.contains(sport),
|
||||
onTap: {
|
||||
if viewModel.selectedSports.contains(sport) {
|
||||
viewModel.selectedSports.remove(sport)
|
||||
} else {
|
||||
viewModel.selectedSports.insert(sport)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
// Fill remaining space if row has fewer than 4 items
|
||||
if row.count < 4 {
|
||||
ForEach(0..<(4 - row.count), id: \.self) { _ in
|
||||
Color.clear.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.vertical, Theme.Spacing.xs)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2139,27 +2153,45 @@ struct SportSelectionChip: View {
|
||||
let isSelected: Bool
|
||||
let onTap: () -> Void
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
@State private var isPressed = false
|
||||
|
||||
var body: some View {
|
||||
Button(action: onTap) {
|
||||
VStack(spacing: Theme.Spacing.xs) {
|
||||
VStack(spacing: 6) {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(isSelected ? sport.themeColor : sport.themeColor.opacity(0.15))
|
||||
.frame(width: 44, height: 44)
|
||||
.frame(width: 48, height: 48)
|
||||
.overlay {
|
||||
if isSelected {
|
||||
Circle()
|
||||
.stroke(sport.themeColor.opacity(0.3), lineWidth: 3)
|
||||
.frame(width: 54, height: 54)
|
||||
}
|
||||
}
|
||||
|
||||
Image(systemName: sport.iconName)
|
||||
.font(.title3)
|
||||
.font(.system(size: 20))
|
||||
.foregroundStyle(isSelected ? .white : sport.themeColor)
|
||||
}
|
||||
|
||||
Text(sport.rawValue)
|
||||
.font(.system(size: Theme.FontSize.micro, weight: .medium))
|
||||
.font(.system(size: 10, weight: isSelected ? .semibold : .medium))
|
||||
.foregroundStyle(isSelected ? Theme.textPrimary(colorScheme) : Theme.textSecondary(colorScheme))
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.scaleEffect(isPressed ? 0.9 : 1.0)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.simultaneousGesture(
|
||||
DragGesture(minimumDistance: 0)
|
||||
.onChanged { _ in
|
||||
withAnimation(Theme.Animation.spring) { isPressed = true }
|
||||
}
|
||||
.onEnded { _ in
|
||||
withAnimation(Theme.Animation.spring) { isPressed = false }
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// GameDAGRouter.swift
|
||||
// SportsTime
|
||||
//
|
||||
// Time-expanded DAG + Beam Search algorithm for route finding.
|
||||
// DAG-based route finding with multi-dimensional diversity.
|
||||
//
|
||||
// Key insight: This is NOT "which subset of N games should I attend?"
|
||||
// This IS: "what time-respecting paths exist through a graph of games?"
|
||||
@@ -10,11 +10,14 @@
|
||||
// The algorithm:
|
||||
// 1. Bucket games by calendar day
|
||||
// 2. Build directed edges where time moves forward AND driving is feasible
|
||||
// 3. Beam search: keep top K paths at each depth
|
||||
// 4. Dominance pruning: discard inferior paths
|
||||
// 3. Generate routes via beam search
|
||||
// 4. Diversity pruning: ensure routes span full range of games, cities, miles, and days
|
||||
//
|
||||
// Complexity: O(days × beamWidth × avgNeighbors) ≈ 900 operations for 5-day, 78-game scenario
|
||||
// (vs 2^78 for naive subset enumeration)
|
||||
// The diversity system ensures users see:
|
||||
// - Short trips (2-3 cities) AND long trips (5+ cities)
|
||||
// - Quick trips (2-3 games) AND packed trips (5+ games)
|
||||
// - Low mileage AND high mileage options
|
||||
// - Short duration AND long duration trips
|
||||
//
|
||||
|
||||
import Foundation
|
||||
@@ -24,12 +27,11 @@ enum GameDAGRouter {
|
||||
|
||||
// MARK: - Configuration
|
||||
|
||||
/// Default beam width - how many partial routes to keep at each step
|
||||
/// Increased to ensure we preserve diverse route lengths (short and long trips)
|
||||
private static let defaultBeamWidth = 50
|
||||
/// Default beam width during expansion
|
||||
private static let defaultBeamWidth = 100
|
||||
|
||||
/// Maximum options to return (increased to provide more diverse trip lengths)
|
||||
private static let maxOptions = 50
|
||||
/// Maximum options to return (diverse sample)
|
||||
private static let maxOptions = 75
|
||||
|
||||
/// Buffer time after game ends before we can depart (hours)
|
||||
private static let gameEndBufferHours: Double = 3.0
|
||||
@@ -37,21 +39,55 @@ enum GameDAGRouter {
|
||||
/// Maximum days ahead to consider for next game (1 = next day only, 5 = allows multi-day drives)
|
||||
private static let maxDayLookahead = 5
|
||||
|
||||
// MARK: - Route Profile
|
||||
|
||||
/// Captures the key metrics of a route for diversity analysis
|
||||
private struct RouteProfile {
|
||||
let route: [Game]
|
||||
let gameCount: Int
|
||||
let cityCount: Int
|
||||
let totalMiles: Double
|
||||
let tripDays: Int
|
||||
|
||||
// Bucket indices for stratified sampling
|
||||
var gameBucket: Int { min(gameCount - 1, 5) } // 1, 2, 3, 4, 5, 6+
|
||||
var cityBucket: Int { min(cityCount - 1, 5) } // 1, 2, 3, 4, 5, 6+
|
||||
var milesBucket: Int {
|
||||
switch totalMiles {
|
||||
case ..<500: return 0 // Short
|
||||
case 500..<1000: return 1 // Medium
|
||||
case 1000..<2000: return 2 // Long
|
||||
case 2000..<3000: return 3 // Very long
|
||||
default: return 4 // Epic
|
||||
}
|
||||
}
|
||||
var daysBucket: Int { min(tripDays - 1, 6) } // 1-7+ days
|
||||
|
||||
/// Composite key for exact deduplication
|
||||
var uniqueKey: String {
|
||||
route.map { $0.id.uuidString }.joined(separator: "-")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Public API
|
||||
|
||||
/// Finds best routes through the game graph using DAG + beam search.
|
||||
/// Finds routes through the game graph with multi-dimensional diversity.
|
||||
///
|
||||
/// This replaces the exponential GeographicRouteExplorer with a polynomial-time algorithm.
|
||||
/// Returns a curated sample that spans the full range of:
|
||||
/// - Number of games (2-game quickies to 6+ game marathons)
|
||||
/// - Number of cities (2-city to 6+ city routes)
|
||||
/// - Total miles (short drives to cross-country epics)
|
||||
/// - Trip duration (weekend getaways to week-long adventures)
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - games: All games to consider, in any order (will be sorted internally)
|
||||
/// - games: All games to consider
|
||||
/// - stadiums: Dictionary mapping stadium IDs to Stadium objects
|
||||
/// - constraints: Driving constraints (number of drivers, max hours per day)
|
||||
/// - anchorGameIds: Games that MUST appear in every valid route (for Scenario B)
|
||||
/// - constraints: Driving constraints (max hours per day)
|
||||
/// - anchorGameIds: Games that MUST appear in every valid route
|
||||
/// - 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 during expansion
|
||||
///
|
||||
/// - Returns: Array of valid game combinations, sorted by score (most games, least driving)
|
||||
/// - Returns: Array of diverse route options
|
||||
///
|
||||
static func findRoutes(
|
||||
games: [Game],
|
||||
@@ -65,21 +101,18 @@ enum GameDAGRouter {
|
||||
// Edge cases
|
||||
guard !games.isEmpty else { return [] }
|
||||
if games.count == 1 {
|
||||
// Single game - just return it if it satisfies anchors
|
||||
if anchorGameIds.isEmpty || anchorGameIds.contains(games[0].id) {
|
||||
return [games]
|
||||
}
|
||||
return []
|
||||
}
|
||||
if games.count == 2 {
|
||||
// Two games - check if both are reachable
|
||||
let sorted = games.sorted { $0.startTime < $1.startTime }
|
||||
if canTransition(from: sorted[0], to: sorted[1], stadiums: stadiums, constraints: constraints) {
|
||||
if anchorGameIds.isSubset(of: Set(sorted.map { $0.id })) {
|
||||
return [sorted]
|
||||
}
|
||||
}
|
||||
// Can't connect them - return individual games if they satisfy anchors
|
||||
if anchorGameIds.isEmpty {
|
||||
return [[sorted[0]], [sorted[1]]]
|
||||
}
|
||||
@@ -95,18 +128,9 @@ enum GameDAGRouter {
|
||||
|
||||
guard !sortedDays.isEmpty else { return [] }
|
||||
|
||||
|
||||
// Step 3: Initialize beam with first day's games
|
||||
// Step 3: Initialize beam with first few days' games as starting points
|
||||
var beam: [[Game]] = []
|
||||
if let firstDayGames = buckets[sortedDays[0]] {
|
||||
for game in firstDayGames {
|
||||
beam.append([game])
|
||||
}
|
||||
}
|
||||
|
||||
// Also include option to skip first day entirely and start later
|
||||
// (handled by having multiple starting points in beam)
|
||||
for dayIndex in sortedDays.dropFirst().prefix(maxDayLookahead - 1) {
|
||||
for dayIndex in sortedDays.prefix(maxDayLookahead) {
|
||||
if let dayGames = buckets[dayIndex] {
|
||||
for game in dayGames {
|
||||
beam.append([game])
|
||||
@@ -114,9 +138,8 @@ enum GameDAGRouter {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Step 4: Expand beam day by day
|
||||
for (_, dayIndex) in sortedDays.dropFirst().enumerated() {
|
||||
for dayIndex in sortedDays.dropFirst() {
|
||||
let todaysGames = buckets[dayIndex] ?? []
|
||||
var nextBeam: [[Game]] = []
|
||||
|
||||
@@ -124,36 +147,34 @@ enum GameDAGRouter {
|
||||
guard let lastGame = path.last else { continue }
|
||||
let lastGameDay = dayIndexFor(lastGame.startTime, referenceDate: sortedGames[0].startTime)
|
||||
|
||||
// Only consider games on this day or within lookahead
|
||||
// Skip if this day is too far ahead for this route
|
||||
if dayIndex > lastGameDay + maxDayLookahead {
|
||||
// This path is too far behind, keep it as-is
|
||||
nextBeam.append(path)
|
||||
continue
|
||||
}
|
||||
|
||||
// Try adding each of today's games
|
||||
for candidate in todaysGames {
|
||||
// Check for repeat city violation during route building
|
||||
// Check for repeat city violation
|
||||
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
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if canTransition(from: lastGame, to: candidate, stadiums: stadiums, constraints: constraints) {
|
||||
let newPath = path + [candidate]
|
||||
nextBeam.append(newPath)
|
||||
nextBeam.append(path + [candidate])
|
||||
}
|
||||
}
|
||||
|
||||
// Also keep the path without adding a game today (allows off-days)
|
||||
// Keep the path without adding a game today
|
||||
nextBeam.append(path)
|
||||
}
|
||||
|
||||
// Dominance pruning + beam truncation
|
||||
beam = pruneAndTruncate(nextBeam, beamWidth: beamWidth, stadiums: stadiums)
|
||||
// Diversity-aware pruning during expansion
|
||||
beam = diversityPrune(nextBeam, stadiums: stadiums, targetCount: beamWidth)
|
||||
}
|
||||
|
||||
// Step 5: Filter routes that contain all anchors
|
||||
@@ -162,21 +183,15 @@ enum GameDAGRouter {
|
||||
return anchorGameIds.isSubset(of: pathGameIds)
|
||||
}
|
||||
|
||||
// Step 6: Ensure geographic diversity in results
|
||||
// Group routes by their primary region (city with most games)
|
||||
// Then pick the best route from each region
|
||||
// Step 6: Final diversity selection
|
||||
let finalRoutes = selectDiverseRoutes(routesWithAnchors, stadiums: stadiums, maxCount: maxOptions)
|
||||
|
||||
print("🔍 DAG: Input games=\(games.count), beam final=\(beam.count), withAnchors=\(routesWithAnchors.count), final=\(finalRoutes.count)")
|
||||
if let best = finalRoutes.first {
|
||||
print("🔍 DAG: Best route has \(best.count) games")
|
||||
}
|
||||
print("🔍 DAG: Input games=\(games.count), beam=\(beam.count), withAnchors=\(routesWithAnchors.count), final=\(finalRoutes.count)")
|
||||
|
||||
return finalRoutes
|
||||
}
|
||||
|
||||
/// Compatibility wrapper that matches GeographicRouteExplorer's interface.
|
||||
/// This allows drop-in replacement in ScenarioAPlanner and ScenarioBPlanner.
|
||||
static func findAllSensibleRoutes(
|
||||
from games: [Game],
|
||||
stadiums: [UUID: Stadium],
|
||||
@@ -184,9 +199,7 @@ enum GameDAGRouter {
|
||||
allowRepeatCities: Bool = true,
|
||||
stopBuilder: ([Game], [UUID: Stadium]) -> [ItineraryStop]
|
||||
) -> [[Game]] {
|
||||
// Use default driving constraints
|
||||
let constraints = DrivingConstraints.default
|
||||
|
||||
return findRoutes(
|
||||
games: games,
|
||||
stadiums: stadiums,
|
||||
@@ -196,9 +209,236 @@ enum GameDAGRouter {
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Multi-Dimensional Diversity Selection
|
||||
|
||||
/// Selects routes that maximize diversity across all dimensions.
|
||||
/// Uses stratified sampling to ensure representation of:
|
||||
/// - Short trips (2-3 games) AND long trips (5+ games)
|
||||
/// - Few cities (2-3) AND many cities (5+)
|
||||
/// - Low mileage AND high mileage
|
||||
/// - Short duration AND long duration
|
||||
private static func selectDiverseRoutes(
|
||||
_ routes: [[Game]],
|
||||
stadiums: [UUID: Stadium],
|
||||
maxCount: Int
|
||||
) -> [[Game]] {
|
||||
guard !routes.isEmpty else { return [] }
|
||||
|
||||
// Build profiles for all routes
|
||||
let profiles = routes.map { route in
|
||||
buildProfile(for: route, stadiums: stadiums)
|
||||
}
|
||||
|
||||
// Remove duplicates
|
||||
var uniqueProfiles: [RouteProfile] = []
|
||||
var seenKeys = Set<String>()
|
||||
for profile in profiles {
|
||||
if !seenKeys.contains(profile.uniqueKey) {
|
||||
seenKeys.insert(profile.uniqueKey)
|
||||
uniqueProfiles.append(profile)
|
||||
}
|
||||
}
|
||||
|
||||
// Stratified selection: ensure representation across all buckets
|
||||
var selected: [RouteProfile] = []
|
||||
var selectedKeys = Set<String>()
|
||||
|
||||
// Pass 1: Ensure at least one route per game count bucket (2, 3, 4, 5, 6+)
|
||||
let byGames = Dictionary(grouping: uniqueProfiles) { $0.gameBucket }
|
||||
for bucket in byGames.keys.sorted() {
|
||||
if selected.count >= maxCount { break }
|
||||
if let candidates = byGames[bucket]?.sorted(by: { $0.totalMiles < $1.totalMiles }) {
|
||||
if let best = candidates.first, !selectedKeys.contains(best.uniqueKey) {
|
||||
selected.append(best)
|
||||
selectedKeys.insert(best.uniqueKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Pass 2: Ensure at least one route per city count bucket (2, 3, 4, 5, 6+)
|
||||
let byCities = Dictionary(grouping: uniqueProfiles) { $0.cityBucket }
|
||||
for bucket in byCities.keys.sorted() {
|
||||
if selected.count >= maxCount { break }
|
||||
if let candidates = byCities[bucket]?.filter({ !selectedKeys.contains($0.uniqueKey) }) {
|
||||
if let best = candidates.sorted(by: { $0.totalMiles < $1.totalMiles }).first {
|
||||
selected.append(best)
|
||||
selectedKeys.insert(best.uniqueKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Pass 3: Ensure at least one route per mileage bucket
|
||||
let byMiles = Dictionary(grouping: uniqueProfiles) { $0.milesBucket }
|
||||
for bucket in byMiles.keys.sorted() {
|
||||
if selected.count >= maxCount { break }
|
||||
if let candidates = byMiles[bucket]?.filter({ !selectedKeys.contains($0.uniqueKey) }) {
|
||||
if let best = candidates.sorted(by: { $0.gameCount > $1.gameCount }).first {
|
||||
selected.append(best)
|
||||
selectedKeys.insert(best.uniqueKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Pass 4: Ensure at least one route per duration bucket
|
||||
let byDays = Dictionary(grouping: uniqueProfiles) { $0.daysBucket }
|
||||
for bucket in byDays.keys.sorted() {
|
||||
if selected.count >= maxCount { break }
|
||||
if let candidates = byDays[bucket]?.filter({ !selectedKeys.contains($0.uniqueKey) }) {
|
||||
if let best = candidates.sorted(by: { $0.gameCount > $1.gameCount }).first {
|
||||
selected.append(best)
|
||||
selectedKeys.insert(best.uniqueKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Pass 5: Fill remaining slots with diverse combinations
|
||||
// Create composite buckets for more granular diversity
|
||||
let remaining = uniqueProfiles.filter { !selectedKeys.contains($0.uniqueKey) }
|
||||
let byComposite = Dictionary(grouping: remaining) { profile in
|
||||
"\(profile.gameBucket)-\(profile.cityBucket)-\(profile.milesBucket)"
|
||||
}
|
||||
|
||||
// Round-robin from composite buckets
|
||||
var compositeKeys = Array(byComposite.keys).sorted()
|
||||
var indices: [String: Int] = [:]
|
||||
while selected.count < maxCount && !compositeKeys.isEmpty {
|
||||
var addedAny = false
|
||||
for key in compositeKeys {
|
||||
if selected.count >= maxCount { break }
|
||||
let idx = indices[key] ?? 0
|
||||
if let candidates = byComposite[key], idx < candidates.count {
|
||||
let profile = candidates[idx]
|
||||
if !selectedKeys.contains(profile.uniqueKey) {
|
||||
selected.append(profile)
|
||||
selectedKeys.insert(profile.uniqueKey)
|
||||
addedAny = true
|
||||
}
|
||||
indices[key] = idx + 1
|
||||
}
|
||||
}
|
||||
if !addedAny { break }
|
||||
}
|
||||
|
||||
// Pass 6: If still need more, add remaining sorted by efficiency
|
||||
if selected.count < maxCount {
|
||||
let stillRemaining = uniqueProfiles
|
||||
.filter { !selectedKeys.contains($0.uniqueKey) }
|
||||
.sorted { efficiency(for: $0) > efficiency(for: $1) }
|
||||
|
||||
for profile in stillRemaining.prefix(maxCount - selected.count) {
|
||||
selected.append(profile)
|
||||
}
|
||||
}
|
||||
|
||||
return selected.map { $0.route }
|
||||
}
|
||||
|
||||
/// Diversity-aware pruning during beam expansion.
|
||||
/// Keeps routes that span the diversity space rather than just high-scoring ones.
|
||||
private static func diversityPrune(
|
||||
_ paths: [[Game]],
|
||||
stadiums: [UUID: Stadium],
|
||||
targetCount: Int
|
||||
) -> [[Game]] {
|
||||
// Remove exact duplicates first
|
||||
var uniquePaths: [[Game]] = []
|
||||
var seen = Set<String>()
|
||||
for path in paths {
|
||||
let key = path.map { $0.id.uuidString }.joined(separator: "-")
|
||||
if !seen.contains(key) {
|
||||
seen.insert(key)
|
||||
uniquePaths.append(path)
|
||||
}
|
||||
}
|
||||
|
||||
guard uniquePaths.count > targetCount else { return uniquePaths }
|
||||
|
||||
// Build profiles
|
||||
let profiles = uniquePaths.map { buildProfile(for: $0, stadiums: stadiums) }
|
||||
|
||||
// Group by game count to ensure length diversity
|
||||
let byGames = Dictionary(grouping: profiles) { $0.gameBucket }
|
||||
let slotsPerBucket = max(2, targetCount / max(1, byGames.count))
|
||||
|
||||
var selected: [RouteProfile] = []
|
||||
var selectedKeys = Set<String>()
|
||||
|
||||
// Take from each game count bucket
|
||||
for bucket in byGames.keys.sorted() {
|
||||
if let candidates = byGames[bucket] {
|
||||
// Within bucket, prioritize geographic diversity
|
||||
let byCities = Dictionary(grouping: candidates) { $0.cityBucket }
|
||||
var bucketSelected = 0
|
||||
|
||||
for cityBucket in byCities.keys.sorted() {
|
||||
if bucketSelected >= slotsPerBucket { break }
|
||||
if let cityCandidates = byCities[cityBucket] {
|
||||
for profile in cityCandidates.prefix(2) {
|
||||
if !selectedKeys.contains(profile.uniqueKey) {
|
||||
selected.append(profile)
|
||||
selectedKeys.insert(profile.uniqueKey)
|
||||
bucketSelected += 1
|
||||
if bucketSelected >= slotsPerBucket { break }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fill remaining with efficiency-sorted paths
|
||||
if selected.count < targetCount {
|
||||
let remaining = profiles.filter { !selectedKeys.contains($0.uniqueKey) }
|
||||
.sorted { efficiency(for: $0) > efficiency(for: $1) }
|
||||
|
||||
for profile in remaining.prefix(targetCount - selected.count) {
|
||||
selected.append(profile)
|
||||
}
|
||||
}
|
||||
|
||||
return selected.map { $0.route }
|
||||
}
|
||||
|
||||
/// Builds a profile for a route.
|
||||
private static func buildProfile(for route: [Game], stadiums: [UUID: Stadium]) -> RouteProfile {
|
||||
let gameCount = route.count
|
||||
let cities = Set(route.compactMap { stadiums[$0.stadiumId]?.city })
|
||||
let cityCount = cities.count
|
||||
|
||||
// Calculate total miles
|
||||
var totalMiles: Double = 0
|
||||
for i in 0..<(route.count - 1) {
|
||||
totalMiles += estimateDistanceMiles(from: route[i], to: route[i + 1], stadiums: stadiums)
|
||||
}
|
||||
|
||||
// Calculate trip duration in days
|
||||
let tripDays: Int
|
||||
if let firstGame = route.first, let lastGame = route.last {
|
||||
let calendar = Calendar.current
|
||||
let days = calendar.dateComponents([.day], from: firstGame.startTime, to: lastGame.startTime).day ?? 1
|
||||
tripDays = max(1, days + 1)
|
||||
} else {
|
||||
tripDays = 1
|
||||
}
|
||||
|
||||
return RouteProfile(
|
||||
route: route,
|
||||
gameCount: gameCount,
|
||||
cityCount: cityCount,
|
||||
totalMiles: totalMiles,
|
||||
tripDays: tripDays
|
||||
)
|
||||
}
|
||||
|
||||
/// Calculates efficiency score (games per hour of driving).
|
||||
private static func efficiency(for profile: RouteProfile) -> Double {
|
||||
let drivingHours = profile.totalMiles / 60.0 // 60 mph average
|
||||
guard drivingHours > 0 else { return Double(profile.gameCount) * 100 }
|
||||
return Double(profile.gameCount) / drivingHours
|
||||
}
|
||||
|
||||
// MARK: - Day Bucketing
|
||||
|
||||
/// Groups games by calendar day index (0 = first day of trip, 1 = second day, etc.)
|
||||
private static func bucketByDay(games: [Game]) -> [Int: [Game]] {
|
||||
guard let firstGame = games.first else { return [:] }
|
||||
let referenceDate = firstGame.startTime
|
||||
@@ -211,24 +451,15 @@ enum GameDAGRouter {
|
||||
return buckets
|
||||
}
|
||||
|
||||
/// Calculates the day index for a date relative to a reference date.
|
||||
private static func dayIndexFor(_ date: Date, referenceDate: Date) -> Int {
|
||||
let calendar = Calendar.current
|
||||
let refDay = calendar.startOfDay(for: referenceDate)
|
||||
let dateDay = calendar.startOfDay(for: date)
|
||||
let components = calendar.dateComponents([.day], from: refDay, to: dateDay)
|
||||
return components.day ?? 0
|
||||
return calendar.dateComponents([.day], from: refDay, to: dateDay).day ?? 0
|
||||
}
|
||||
|
||||
// MARK: - Transition Feasibility
|
||||
|
||||
/// Determines if we can travel from game A to game B.
|
||||
///
|
||||
/// Requirements:
|
||||
/// 1. B starts after A (time moves forward)
|
||||
/// 2. We have enough days between games to complete the drive
|
||||
/// 3. We can arrive at B before B starts
|
||||
///
|
||||
private static func canTransition(
|
||||
from: Game,
|
||||
to: Game,
|
||||
@@ -236,289 +467,62 @@ enum GameDAGRouter {
|
||||
constraints: DrivingConstraints
|
||||
) -> Bool {
|
||||
// Time must move forward
|
||||
guard to.startTime > from.startTime else {
|
||||
return false
|
||||
}
|
||||
guard to.startTime > from.startTime else { return false }
|
||||
|
||||
// Same stadium = always feasible (no driving needed)
|
||||
// Same stadium = always feasible
|
||||
if from.stadiumId == to.stadiumId { return true }
|
||||
|
||||
// Get stadiums
|
||||
guard let fromStadium = stadiums[from.stadiumId],
|
||||
let toStadium = stadiums[to.stadiumId] else {
|
||||
// Missing stadium info - can't calculate distance, reject to be safe
|
||||
print("⚠️ DAG: Stadium lookup failed - from:\(stadiums[from.stadiumId] != nil) to:\(stadiums[to.stadiumId] != nil)")
|
||||
return false
|
||||
}
|
||||
|
||||
let fromCoord = fromStadium.coordinate
|
||||
let toCoord = toStadium.coordinate
|
||||
|
||||
// Calculate driving time
|
||||
let distanceMiles = TravelEstimator.haversineDistanceMiles(
|
||||
from: CLLocationCoordinate2D(latitude: fromCoord.latitude, longitude: fromCoord.longitude),
|
||||
to: CLLocationCoordinate2D(latitude: toCoord.latitude, longitude: toCoord.longitude)
|
||||
from: CLLocationCoordinate2D(latitude: fromStadium.coordinate.latitude, longitude: fromStadium.coordinate.longitude),
|
||||
to: CLLocationCoordinate2D(latitude: toStadium.coordinate.latitude, longitude: toStadium.coordinate.longitude)
|
||||
) * 1.3 // Road routing factor
|
||||
|
||||
let drivingHours = distanceMiles / 60.0 // Average 60 mph
|
||||
let drivingHours = distanceMiles / 60.0
|
||||
|
||||
// Calculate available driving time between games
|
||||
// After game A ends (+ buffer), how much time until game B starts (- buffer)?
|
||||
// Calculate available time
|
||||
let departureTime = from.startTime.addingTimeInterval(gameEndBufferHours * 3600)
|
||||
let deadline = to.startTime.addingTimeInterval(-3600) // 1 hour buffer before game
|
||||
let availableSeconds = deadline.timeIntervalSince(departureTime)
|
||||
let availableHours = availableSeconds / 3600.0
|
||||
let availableHours = deadline.timeIntervalSince(departureTime) / 3600.0
|
||||
|
||||
// Calculate how many driving days we have
|
||||
// Each day can have maxDailyDrivingHours of driving
|
||||
// Calculate driving days available
|
||||
let calendar = Calendar.current
|
||||
let fromDay = calendar.startOfDay(for: from.startTime)
|
||||
let toDay = calendar.startOfDay(for: to.startTime)
|
||||
let daysBetween = calendar.dateComponents([.day], from: fromDay, to: toDay).day ?? 0
|
||||
let daysBetween = calendar.dateComponents(
|
||||
[.day],
|
||||
from: calendar.startOfDay(for: from.startTime),
|
||||
to: calendar.startOfDay(for: to.startTime)
|
||||
).day ?? 0
|
||||
|
||||
// Available driving hours = days between * max per day
|
||||
// (If games are same day, daysBetween = 0, but we might still have hours available)
|
||||
let maxDrivingHoursAvailable: Double
|
||||
if daysBetween == 0 {
|
||||
// Same day - only have hours between games
|
||||
maxDrivingHoursAvailable = max(0, availableHours)
|
||||
} else {
|
||||
// Multi-day - can drive each day
|
||||
maxDrivingHoursAvailable = Double(daysBetween) * constraints.maxDailyDrivingHours
|
||||
}
|
||||
let maxDrivingHoursAvailable = daysBetween == 0
|
||||
? max(0, availableHours)
|
||||
: Double(daysBetween) * constraints.maxDailyDrivingHours
|
||||
|
||||
// Check if we have enough driving time
|
||||
guard drivingHours <= maxDrivingHoursAvailable else {
|
||||
return false
|
||||
}
|
||||
|
||||
// Also verify we can arrive before game starts (sanity check)
|
||||
guard availableHours >= drivingHours else {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
return drivingHours <= maxDrivingHoursAvailable && drivingHours <= availableHours
|
||||
}
|
||||
|
||||
// MARK: - Geographic Diversity
|
||||
// MARK: - Distance Estimation
|
||||
|
||||
/// Selects diverse routes from the candidate set.
|
||||
/// 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(
|
||||
_ routes: [[Game]],
|
||||
stadiums: [UUID: Stadium],
|
||||
maxCount: Int
|
||||
) -> [[Game]] {
|
||||
guard !routes.isEmpty else { return [] }
|
||||
|
||||
// Group routes by city count (route length)
|
||||
var routesByLength: [Int: [[Game]]] = [:]
|
||||
for route in routes {
|
||||
let cityCount = Set(route.compactMap { stadiums[$0.stadiumId]?.city }).count
|
||||
routesByLength[cityCount, default: []].append(route)
|
||||
}
|
||||
|
||||
// Sort routes within each length by score
|
||||
for (length, lengthRoutes) in routesByLength {
|
||||
routesByLength[length] = lengthRoutes.sorted {
|
||||
scorePath($0, stadiums: stadiums) > scorePath($1, stadiums: stadiums)
|
||||
}
|
||||
}
|
||||
|
||||
// Allocate slots to each length category
|
||||
// Goal: ensure at least 1 route per length category if available
|
||||
let sortedLengths = routesByLength.keys.sorted()
|
||||
let minPerLength = max(1, maxCount / max(1, sortedLengths.count))
|
||||
|
||||
var selectedRoutes: [[Game]] = []
|
||||
var selectedIds = Set<String>()
|
||||
|
||||
// First pass: take best route(s) from each length category
|
||||
for length in sortedLengths {
|
||||
if selectedRoutes.count >= maxCount { break }
|
||||
if let lengthRoutes = routesByLength[length] {
|
||||
let toTake = min(minPerLength, lengthRoutes.count, maxCount - selectedRoutes.count)
|
||||
for route in lengthRoutes.prefix(toTake) {
|
||||
let key = route.map { $0.id.uuidString }.joined(separator: "-")
|
||||
if !selectedIds.contains(key) {
|
||||
selectedRoutes.append(route)
|
||||
selectedIds.insert(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
/// Gets the primary city for a route (where most games are played).
|
||||
private static func getPrimaryCity(for route: [Game], stadiums: [UUID: Stadium]) -> String {
|
||||
var cityCounts: [String: Int] = [:]
|
||||
for game in route {
|
||||
let city = stadiums[game.stadiumId]?.city ?? "Unknown"
|
||||
cityCounts[city, default: 0] += 1
|
||||
}
|
||||
return cityCounts.max(by: { $0.value < $1.value })?.key ?? "Unknown"
|
||||
}
|
||||
|
||||
// MARK: - Scoring and Pruning
|
||||
|
||||
/// Scores a path. Higher = better.
|
||||
/// Prefers: more games, less driving, geographic coherence
|
||||
private static func scorePath(_ path: [Game], stadiums: [UUID: Stadium]) -> Double {
|
||||
// Handle empty or single-game paths
|
||||
guard path.count > 1 else {
|
||||
return Double(path.count) * 100.0
|
||||
}
|
||||
|
||||
let gameCount = Double(path.count)
|
||||
|
||||
// Calculate total driving
|
||||
var totalDriving: Double = 0
|
||||
for i in 0..<(path.count - 1) {
|
||||
totalDriving += estimateDrivingHours(from: path[i], to: path[i + 1], stadiums: stadiums)
|
||||
}
|
||||
|
||||
// Score: heavily weight game count, penalize driving
|
||||
return gameCount * 100.0 - totalDriving * 2.0
|
||||
}
|
||||
|
||||
/// Estimates driving hours between two games.
|
||||
private static func estimateDrivingHours(
|
||||
private static func estimateDistanceMiles(
|
||||
from: Game,
|
||||
to: Game,
|
||||
stadiums: [UUID: Stadium]
|
||||
) -> Double {
|
||||
// Same stadium = 0 driving
|
||||
if from.stadiumId == to.stadiumId { return 0 }
|
||||
|
||||
guard let fromStadium = stadiums[from.stadiumId],
|
||||
let toStadium = stadiums[to.stadiumId] else {
|
||||
return 5.0 // Fallback: assume 5 hours
|
||||
return 300 // Fallback estimate
|
||||
}
|
||||
|
||||
let fromCoord = fromStadium.coordinate
|
||||
let toCoord = toStadium.coordinate
|
||||
|
||||
let distanceMiles = TravelEstimator.haversineDistanceMiles(
|
||||
from: CLLocationCoordinate2D(latitude: fromCoord.latitude, longitude: fromCoord.longitude),
|
||||
to: CLLocationCoordinate2D(latitude: toCoord.latitude, longitude: toCoord.longitude)
|
||||
return TravelEstimator.haversineDistanceMiles(
|
||||
from: CLLocationCoordinate2D(latitude: fromStadium.coordinate.latitude, longitude: fromStadium.coordinate.longitude),
|
||||
to: CLLocationCoordinate2D(latitude: toStadium.coordinate.latitude, longitude: toStadium.coordinate.longitude)
|
||||
) * 1.3
|
||||
|
||||
return distanceMiles / 60.0
|
||||
}
|
||||
|
||||
/// 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(
|
||||
_ paths: [[Game]],
|
||||
beamWidth: Int,
|
||||
stadiums: [UUID: Stadium]
|
||||
) -> [[Game]] {
|
||||
// Remove exact duplicates
|
||||
var uniquePaths: [[Game]] = []
|
||||
var seen = Set<String>()
|
||||
|
||||
for path in paths {
|
||||
let key = path.map { $0.id.uuidString }.joined(separator: "-")
|
||||
if !seen.contains(key) {
|
||||
seen.insert(key)
|
||||
uniquePaths.append(path)
|
||||
}
|
||||
}
|
||||
|
||||
// Group paths by unique city count (route length)
|
||||
// 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))
|
||||
|
||||
var pruned: [[Game]] = []
|
||||
|
||||
// First pass: take minimum from each length group
|
||||
for length in sortedLengths {
|
||||
if let lengthPaths = pathsByLength[length] {
|
||||
let toTake = min(minPerLength, lengthPaths.count)
|
||||
pruned.append(contentsOf: lengthPaths.prefix(toTake))
|
||||
}
|
||||
}
|
||||
|
||||
// Second pass: fill remaining slots with best paths overall
|
||||
if pruned.count < beamWidth {
|
||||
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
|
||||
return Array(pruned.prefix(beamWidth))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1286,5 +1286,789 @@
|
||||
"WPG"
|
||||
],
|
||||
"year_opened": null
|
||||
},
|
||||
{
|
||||
"canonical_id": "stadium_wnba_gateway_center_arena",
|
||||
"name": "Gateway Center Arena",
|
||||
"city": "College Park",
|
||||
"state": "GA",
|
||||
"latitude": 33.6534,
|
||||
"longitude": -84.448,
|
||||
"capacity": 3500,
|
||||
"sport": "WNBA",
|
||||
"primary_team_abbrevs": [
|
||||
"ATL"
|
||||
],
|
||||
"year_opened": null
|
||||
},
|
||||
{
|
||||
"canonical_id": "stadium_wnba_wintrust_arena",
|
||||
"name": "Wintrust Arena",
|
||||
"city": "Chicago",
|
||||
"state": "IL",
|
||||
"latitude": 41.8622,
|
||||
"longitude": -87.6164,
|
||||
"capacity": 10387,
|
||||
"sport": "WNBA",
|
||||
"primary_team_abbrevs": [
|
||||
"CHI"
|
||||
],
|
||||
"year_opened": null
|
||||
},
|
||||
{
|
||||
"canonical_id": "stadium_wnba_mohegan_sun_arena",
|
||||
"name": "Mohegan Sun Arena",
|
||||
"city": "Uncasville",
|
||||
"state": "CT",
|
||||
"latitude": 41.4946,
|
||||
"longitude": -72.0874,
|
||||
"capacity": 10000,
|
||||
"sport": "WNBA",
|
||||
"primary_team_abbrevs": [
|
||||
"CON"
|
||||
],
|
||||
"year_opened": null
|
||||
},
|
||||
{
|
||||
"canonical_id": "stadium_wnba_college_park_center",
|
||||
"name": "College Park Center",
|
||||
"city": "Arlington",
|
||||
"state": "TX",
|
||||
"latitude": 32.7298,
|
||||
"longitude": -97.1137,
|
||||
"capacity": 7000,
|
||||
"sport": "WNBA",
|
||||
"primary_team_abbrevs": [
|
||||
"DAL"
|
||||
],
|
||||
"year_opened": null
|
||||
},
|
||||
{
|
||||
"canonical_id": "stadium_wnba_chase_center",
|
||||
"name": "Chase Center",
|
||||
"city": "San Francisco",
|
||||
"state": "CA",
|
||||
"latitude": 37.768,
|
||||
"longitude": -122.3879,
|
||||
"capacity": 18064,
|
||||
"sport": "WNBA",
|
||||
"primary_team_abbrevs": [
|
||||
"GSV"
|
||||
],
|
||||
"year_opened": null
|
||||
},
|
||||
{
|
||||
"canonical_id": "stadium_wnba_gainbridge_fieldhouse",
|
||||
"name": "Gainbridge Fieldhouse",
|
||||
"city": "Indianapolis",
|
||||
"state": "IN",
|
||||
"latitude": 39.764,
|
||||
"longitude": -86.1555,
|
||||
"capacity": 17274,
|
||||
"sport": "WNBA",
|
||||
"primary_team_abbrevs": [
|
||||
"IND"
|
||||
],
|
||||
"year_opened": null
|
||||
},
|
||||
{
|
||||
"canonical_id": "stadium_wnba_michelob_ultra_arena",
|
||||
"name": "Michelob Ultra Arena",
|
||||
"city": "Las Vegas",
|
||||
"state": "NV",
|
||||
"latitude": 36.0929,
|
||||
"longitude": -115.1757,
|
||||
"capacity": 12000,
|
||||
"sport": "WNBA",
|
||||
"primary_team_abbrevs": [
|
||||
"LVA"
|
||||
],
|
||||
"year_opened": null
|
||||
},
|
||||
{
|
||||
"canonical_id": "stadium_wnba_cryptocom_arena",
|
||||
"name": "Crypto.com Arena",
|
||||
"city": "Los Angeles",
|
||||
"state": "CA",
|
||||
"latitude": 34.043,
|
||||
"longitude": -118.2673,
|
||||
"capacity": 19068,
|
||||
"sport": "WNBA",
|
||||
"primary_team_abbrevs": [
|
||||
"LAS"
|
||||
],
|
||||
"year_opened": null
|
||||
},
|
||||
{
|
||||
"canonical_id": "stadium_wnba_target_center",
|
||||
"name": "Target Center",
|
||||
"city": "Minneapolis",
|
||||
"state": "MN",
|
||||
"latitude": 44.9795,
|
||||
"longitude": -93.2761,
|
||||
"capacity": 17500,
|
||||
"sport": "WNBA",
|
||||
"primary_team_abbrevs": [
|
||||
"MIN"
|
||||
],
|
||||
"year_opened": null
|
||||
},
|
||||
{
|
||||
"canonical_id": "stadium_wnba_barclays_center",
|
||||
"name": "Barclays Center",
|
||||
"city": "Brooklyn",
|
||||
"state": "NY",
|
||||
"latitude": 40.6826,
|
||||
"longitude": -73.9754,
|
||||
"capacity": 17732,
|
||||
"sport": "WNBA",
|
||||
"primary_team_abbrevs": [
|
||||
"NYL"
|
||||
],
|
||||
"year_opened": null
|
||||
},
|
||||
{
|
||||
"canonical_id": "stadium_wnba_footprint_center",
|
||||
"name": "Footprint Center",
|
||||
"city": "Phoenix",
|
||||
"state": "AZ",
|
||||
"latitude": 33.4457,
|
||||
"longitude": -112.0712,
|
||||
"capacity": 17000,
|
||||
"sport": "WNBA",
|
||||
"primary_team_abbrevs": [
|
||||
"PHX"
|
||||
],
|
||||
"year_opened": null
|
||||
},
|
||||
{
|
||||
"canonical_id": "stadium_wnba_climate_pledge_arena",
|
||||
"name": "Climate Pledge Arena",
|
||||
"city": "Seattle",
|
||||
"state": "WA",
|
||||
"latitude": 47.6221,
|
||||
"longitude": -122.354,
|
||||
"capacity": 17100,
|
||||
"sport": "WNBA",
|
||||
"primary_team_abbrevs": [
|
||||
"SEA"
|
||||
],
|
||||
"year_opened": null
|
||||
},
|
||||
{
|
||||
"canonical_id": "stadium_wnba_entertainment__sports_arena",
|
||||
"name": "Entertainment & Sports Arena",
|
||||
"city": "Washington",
|
||||
"state": "DC",
|
||||
"latitude": 38.8701,
|
||||
"longitude": -76.9728,
|
||||
"capacity": 4200,
|
||||
"sport": "WNBA",
|
||||
"primary_team_abbrevs": [
|
||||
"WAS"
|
||||
],
|
||||
"year_opened": null
|
||||
},
|
||||
{
|
||||
"canonical_id": "stadium_mls_mercedes-benz_stadium",
|
||||
"name": "Mercedes-Benz Stadium",
|
||||
"city": "Atlanta",
|
||||
"state": "GA",
|
||||
"latitude": 33.7553,
|
||||
"longitude": -84.4006,
|
||||
"capacity": 71000,
|
||||
"sport": "MLS",
|
||||
"primary_team_abbrevs": [
|
||||
"ATL"
|
||||
],
|
||||
"year_opened": null
|
||||
},
|
||||
{
|
||||
"canonical_id": "stadium_mls_q2_stadium",
|
||||
"name": "Q2 Stadium",
|
||||
"city": "Austin",
|
||||
"state": "TX",
|
||||
"latitude": 30.3876,
|
||||
"longitude": -97.72,
|
||||
"capacity": 20738,
|
||||
"sport": "MLS",
|
||||
"primary_team_abbrevs": [
|
||||
"ATX"
|
||||
],
|
||||
"year_opened": null
|
||||
},
|
||||
{
|
||||
"canonical_id": "stadium_mls_bank_of_america_stadium",
|
||||
"name": "Bank of America Stadium",
|
||||
"city": "Charlotte",
|
||||
"state": "NC",
|
||||
"latitude": 35.2258,
|
||||
"longitude": -80.8528,
|
||||
"capacity": 74867,
|
||||
"sport": "MLS",
|
||||
"primary_team_abbrevs": [
|
||||
"CLT"
|
||||
],
|
||||
"year_opened": null
|
||||
},
|
||||
{
|
||||
"canonical_id": "stadium_mls_soldier_field",
|
||||
"name": "Soldier Field",
|
||||
"city": "Chicago",
|
||||
"state": "IL",
|
||||
"latitude": 41.8623,
|
||||
"longitude": -87.6167,
|
||||
"capacity": 61500,
|
||||
"sport": "MLS",
|
||||
"primary_team_abbrevs": [
|
||||
"CHI"
|
||||
],
|
||||
"year_opened": null
|
||||
},
|
||||
{
|
||||
"canonical_id": "stadium_mls_tql_stadium",
|
||||
"name": "TQL Stadium",
|
||||
"city": "Cincinnati",
|
||||
"state": "OH",
|
||||
"latitude": 39.1113,
|
||||
"longitude": -84.5212,
|
||||
"capacity": 26000,
|
||||
"sport": "MLS",
|
||||
"primary_team_abbrevs": [
|
||||
"CIN"
|
||||
],
|
||||
"year_opened": null
|
||||
},
|
||||
{
|
||||
"canonical_id": "stadium_mls_dicks_sporting_goods_park",
|
||||
"name": "Dicks Sporting Goods Park",
|
||||
"city": "Commerce City",
|
||||
"state": "CO",
|
||||
"latitude": 39.8056,
|
||||
"longitude": -104.8919,
|
||||
"capacity": 18061,
|
||||
"sport": "MLS",
|
||||
"primary_team_abbrevs": [
|
||||
"COL"
|
||||
],
|
||||
"year_opened": null
|
||||
},
|
||||
{
|
||||
"canonical_id": "stadium_mls_lowercom_field",
|
||||
"name": "Lower.com Field",
|
||||
"city": "Columbus",
|
||||
"state": "OH",
|
||||
"latitude": 39.9689,
|
||||
"longitude": -83.0173,
|
||||
"capacity": 20371,
|
||||
"sport": "MLS",
|
||||
"primary_team_abbrevs": [
|
||||
"CLB"
|
||||
],
|
||||
"year_opened": null
|
||||
},
|
||||
{
|
||||
"canonical_id": "stadium_mls_toyota_stadium",
|
||||
"name": "Toyota Stadium",
|
||||
"city": "Frisco",
|
||||
"state": "TX",
|
||||
"latitude": 33.1546,
|
||||
"longitude": -96.8353,
|
||||
"capacity": 20500,
|
||||
"sport": "MLS",
|
||||
"primary_team_abbrevs": [
|
||||
"DAL"
|
||||
],
|
||||
"year_opened": null
|
||||
},
|
||||
{
|
||||
"canonical_id": "stadium_mls_audi_field",
|
||||
"name": "Audi Field",
|
||||
"city": "Washington",
|
||||
"state": "DC",
|
||||
"latitude": 38.8686,
|
||||
"longitude": -77.0128,
|
||||
"capacity": 20000,
|
||||
"sport": "MLS",
|
||||
"primary_team_abbrevs": [
|
||||
"DCU"
|
||||
],
|
||||
"year_opened": null
|
||||
},
|
||||
{
|
||||
"canonical_id": "stadium_mls_shell_energy_stadium",
|
||||
"name": "Shell Energy Stadium",
|
||||
"city": "Houston",
|
||||
"state": "TX",
|
||||
"latitude": 29.7523,
|
||||
"longitude": -95.3522,
|
||||
"capacity": 22039,
|
||||
"sport": "MLS",
|
||||
"primary_team_abbrevs": [
|
||||
"HOU"
|
||||
],
|
||||
"year_opened": null
|
||||
},
|
||||
{
|
||||
"canonical_id": "stadium_mls_dignity_health_sports_park",
|
||||
"name": "Dignity Health Sports Park",
|
||||
"city": "Carson",
|
||||
"state": "CA",
|
||||
"latitude": 33.8644,
|
||||
"longitude": -118.2611,
|
||||
"capacity": 27000,
|
||||
"sport": "MLS",
|
||||
"primary_team_abbrevs": [
|
||||
"LAG"
|
||||
],
|
||||
"year_opened": null
|
||||
},
|
||||
{
|
||||
"canonical_id": "stadium_mls_bmo_stadium",
|
||||
"name": "BMO Stadium",
|
||||
"city": "Los Angeles",
|
||||
"state": "CA",
|
||||
"latitude": 34.0128,
|
||||
"longitude": -118.2841,
|
||||
"capacity": 22000,
|
||||
"sport": "MLS",
|
||||
"primary_team_abbrevs": [
|
||||
"LAFC"
|
||||
],
|
||||
"year_opened": null
|
||||
},
|
||||
{
|
||||
"canonical_id": "stadium_mls_chase_stadium",
|
||||
"name": "Chase Stadium",
|
||||
"city": "Fort Lauderdale",
|
||||
"state": "FL",
|
||||
"latitude": 26.1902,
|
||||
"longitude": -80.163,
|
||||
"capacity": 21550,
|
||||
"sport": "MLS",
|
||||
"primary_team_abbrevs": [
|
||||
"MIA"
|
||||
],
|
||||
"year_opened": null
|
||||
},
|
||||
{
|
||||
"canonical_id": "stadium_mls_allianz_field",
|
||||
"name": "Allianz Field",
|
||||
"city": "St. Paul",
|
||||
"state": "MN",
|
||||
"latitude": 44.9532,
|
||||
"longitude": -93.1653,
|
||||
"capacity": 19400,
|
||||
"sport": "MLS",
|
||||
"primary_team_abbrevs": [
|
||||
"MIN"
|
||||
],
|
||||
"year_opened": null
|
||||
},
|
||||
{
|
||||
"canonical_id": "stadium_mls_stade_saputo",
|
||||
"name": "Stade Saputo",
|
||||
"city": "Montreal",
|
||||
"state": "QC",
|
||||
"latitude": 45.5628,
|
||||
"longitude": -73.553,
|
||||
"capacity": 19619,
|
||||
"sport": "MLS",
|
||||
"primary_team_abbrevs": [
|
||||
"MTL"
|
||||
],
|
||||
"year_opened": null
|
||||
},
|
||||
{
|
||||
"canonical_id": "stadium_mls_geodis_park",
|
||||
"name": "Geodis Park",
|
||||
"city": "Nashville",
|
||||
"state": "TN",
|
||||
"latitude": 36.1303,
|
||||
"longitude": -86.7663,
|
||||
"capacity": 30000,
|
||||
"sport": "MLS",
|
||||
"primary_team_abbrevs": [
|
||||
"NSH"
|
||||
],
|
||||
"year_opened": null
|
||||
},
|
||||
{
|
||||
"canonical_id": "stadium_mls_gillette_stadium",
|
||||
"name": "Gillette Stadium",
|
||||
"city": "Foxborough",
|
||||
"state": "MA",
|
||||
"latitude": 42.0909,
|
||||
"longitude": -71.2643,
|
||||
"capacity": 65878,
|
||||
"sport": "MLS",
|
||||
"primary_team_abbrevs": [
|
||||
"NER"
|
||||
],
|
||||
"year_opened": null
|
||||
},
|
||||
{
|
||||
"canonical_id": "stadium_mls_yankee_stadium",
|
||||
"name": "Yankee Stadium",
|
||||
"city": "New York",
|
||||
"state": "NY",
|
||||
"latitude": 40.8296,
|
||||
"longitude": -73.9262,
|
||||
"capacity": 46537,
|
||||
"sport": "MLS",
|
||||
"primary_team_abbrevs": [
|
||||
"NYC"
|
||||
],
|
||||
"year_opened": null
|
||||
},
|
||||
{
|
||||
"canonical_id": "stadium_mls_red_bull_arena",
|
||||
"name": "Red Bull Arena",
|
||||
"city": "Harrison",
|
||||
"state": "NJ",
|
||||
"latitude": 40.7368,
|
||||
"longitude": -74.1503,
|
||||
"capacity": 25000,
|
||||
"sport": "MLS",
|
||||
"primary_team_abbrevs": [
|
||||
"RBNY"
|
||||
],
|
||||
"year_opened": null
|
||||
},
|
||||
{
|
||||
"canonical_id": "stadium_mls_interco_stadium",
|
||||
"name": "InterCo Stadium",
|
||||
"city": "Orlando",
|
||||
"state": "FL",
|
||||
"latitude": 28.5411,
|
||||
"longitude": -81.3899,
|
||||
"capacity": 25500,
|
||||
"sport": "MLS",
|
||||
"primary_team_abbrevs": [
|
||||
"ORL"
|
||||
],
|
||||
"year_opened": null
|
||||
},
|
||||
{
|
||||
"canonical_id": "stadium_mls_subaru_park",
|
||||
"name": "Subaru Park",
|
||||
"city": "Chester",
|
||||
"state": "PA",
|
||||
"latitude": 39.8328,
|
||||
"longitude": -75.3789,
|
||||
"capacity": 18500,
|
||||
"sport": "MLS",
|
||||
"primary_team_abbrevs": [
|
||||
"PHI"
|
||||
],
|
||||
"year_opened": null
|
||||
},
|
||||
{
|
||||
"canonical_id": "stadium_mls_providence_park",
|
||||
"name": "Providence Park",
|
||||
"city": "Portland",
|
||||
"state": "OR",
|
||||
"latitude": 45.5217,
|
||||
"longitude": -122.6917,
|
||||
"capacity": 25218,
|
||||
"sport": "MLS",
|
||||
"primary_team_abbrevs": [
|
||||
"POR"
|
||||
],
|
||||
"year_opened": null
|
||||
},
|
||||
{
|
||||
"canonical_id": "stadium_mls_america_first_field",
|
||||
"name": "America First Field",
|
||||
"city": "Sandy",
|
||||
"state": "UT",
|
||||
"latitude": 40.5828,
|
||||
"longitude": -111.8933,
|
||||
"capacity": 20213,
|
||||
"sport": "MLS",
|
||||
"primary_team_abbrevs": [
|
||||
"RSL"
|
||||
],
|
||||
"year_opened": null
|
||||
},
|
||||
{
|
||||
"canonical_id": "stadium_mls_paypal_park",
|
||||
"name": "PayPal Park",
|
||||
"city": "San Jose",
|
||||
"state": "CA",
|
||||
"latitude": 37.3513,
|
||||
"longitude": -121.9253,
|
||||
"capacity": 18000,
|
||||
"sport": "MLS",
|
||||
"primary_team_abbrevs": [
|
||||
"SJE"
|
||||
],
|
||||
"year_opened": null
|
||||
},
|
||||
{
|
||||
"canonical_id": "stadium_mls_lumen_field",
|
||||
"name": "Lumen Field",
|
||||
"city": "Seattle",
|
||||
"state": "WA",
|
||||
"latitude": 47.5952,
|
||||
"longitude": -122.3316,
|
||||
"capacity": 68740,
|
||||
"sport": "MLS",
|
||||
"primary_team_abbrevs": [
|
||||
"SEA"
|
||||
],
|
||||
"year_opened": null
|
||||
},
|
||||
{
|
||||
"canonical_id": "stadium_mls_childrens_mercy_park",
|
||||
"name": "Childrens Mercy Park",
|
||||
"city": "Kansas City",
|
||||
"state": "KS",
|
||||
"latitude": 39.1218,
|
||||
"longitude": -94.8234,
|
||||
"capacity": 18467,
|
||||
"sport": "MLS",
|
||||
"primary_team_abbrevs": [
|
||||
"SKC"
|
||||
],
|
||||
"year_opened": null
|
||||
},
|
||||
{
|
||||
"canonical_id": "stadium_mls_citypark",
|
||||
"name": "CityPark",
|
||||
"city": "St. Louis",
|
||||
"state": "MO",
|
||||
"latitude": 38.6322,
|
||||
"longitude": -90.2094,
|
||||
"capacity": 22500,
|
||||
"sport": "MLS",
|
||||
"primary_team_abbrevs": [
|
||||
"STL"
|
||||
],
|
||||
"year_opened": null
|
||||
},
|
||||
{
|
||||
"canonical_id": "stadium_mls_bmo_field",
|
||||
"name": "BMO Field",
|
||||
"city": "Toronto",
|
||||
"state": "ON",
|
||||
"latitude": 43.6332,
|
||||
"longitude": -79.4186,
|
||||
"capacity": 30000,
|
||||
"sport": "MLS",
|
||||
"primary_team_abbrevs": [
|
||||
"TOR"
|
||||
],
|
||||
"year_opened": null
|
||||
},
|
||||
{
|
||||
"canonical_id": "stadium_mls_bc_place",
|
||||
"name": "BC Place",
|
||||
"city": "Vancouver",
|
||||
"state": "BC",
|
||||
"latitude": 49.2768,
|
||||
"longitude": -123.1118,
|
||||
"capacity": 54320,
|
||||
"sport": "MLS",
|
||||
"primary_team_abbrevs": [
|
||||
"VAN"
|
||||
],
|
||||
"year_opened": null
|
||||
},
|
||||
{
|
||||
"canonical_id": "stadium_mls_snapdragon_stadium",
|
||||
"name": "Snapdragon Stadium",
|
||||
"city": "San Diego",
|
||||
"state": "CA",
|
||||
"latitude": 32.7839,
|
||||
"longitude": -117.1224,
|
||||
"capacity": 35000,
|
||||
"sport": "MLS",
|
||||
"primary_team_abbrevs": [
|
||||
"SDG"
|
||||
],
|
||||
"year_opened": null
|
||||
},
|
||||
{
|
||||
"canonical_id": "stadium_nwsl_bmo_stadium",
|
||||
"name": "BMO Stadium",
|
||||
"city": "Los Angeles",
|
||||
"state": "CA",
|
||||
"latitude": 34.0128,
|
||||
"longitude": -118.2841,
|
||||
"capacity": 22000,
|
||||
"sport": "NWSL",
|
||||
"primary_team_abbrevs": [
|
||||
"ANG"
|
||||
],
|
||||
"year_opened": null
|
||||
},
|
||||
{
|
||||
"canonical_id": "stadium_nwsl_paypal_park",
|
||||
"name": "PayPal Park",
|
||||
"city": "San Jose",
|
||||
"state": "CA",
|
||||
"latitude": 37.3513,
|
||||
"longitude": -121.9253,
|
||||
"capacity": 18000,
|
||||
"sport": "NWSL",
|
||||
"primary_team_abbrevs": [
|
||||
"BAY"
|
||||
],
|
||||
"year_opened": null
|
||||
},
|
||||
{
|
||||
"canonical_id": "stadium_nwsl_seatgeek_stadium",
|
||||
"name": "SeatGeek Stadium",
|
||||
"city": "Chicago",
|
||||
"state": "IL",
|
||||
"latitude": 41.6462,
|
||||
"longitude": -87.7304,
|
||||
"capacity": 20000,
|
||||
"sport": "NWSL",
|
||||
"primary_team_abbrevs": [
|
||||
"CHI"
|
||||
],
|
||||
"year_opened": null
|
||||
},
|
||||
{
|
||||
"canonical_id": "stadium_nwsl_shell_energy_stadium",
|
||||
"name": "Shell Energy Stadium",
|
||||
"city": "Houston",
|
||||
"state": "TX",
|
||||
"latitude": 29.7523,
|
||||
"longitude": -95.3522,
|
||||
"capacity": 22039,
|
||||
"sport": "NWSL",
|
||||
"primary_team_abbrevs": [
|
||||
"HOU"
|
||||
],
|
||||
"year_opened": null
|
||||
},
|
||||
{
|
||||
"canonical_id": "stadium_nwsl_cpkc_stadium",
|
||||
"name": "CPKC Stadium",
|
||||
"city": "Kansas City",
|
||||
"state": "KS",
|
||||
"latitude": 39.0851,
|
||||
"longitude": -94.5582,
|
||||
"capacity": 11500,
|
||||
"sport": "NWSL",
|
||||
"primary_team_abbrevs": [
|
||||
"KCC"
|
||||
],
|
||||
"year_opened": null
|
||||
},
|
||||
{
|
||||
"canonical_id": "stadium_nwsl_red_bull_arena",
|
||||
"name": "Red Bull Arena",
|
||||
"city": "Harrison",
|
||||
"state": "NJ",
|
||||
"latitude": 40.7368,
|
||||
"longitude": -74.1503,
|
||||
"capacity": 25000,
|
||||
"sport": "NWSL",
|
||||
"primary_team_abbrevs": [
|
||||
"NJY"
|
||||
],
|
||||
"year_opened": null
|
||||
},
|
||||
{
|
||||
"canonical_id": "stadium_nwsl_wakemed_soccer_park",
|
||||
"name": "WakeMed Soccer Park",
|
||||
"city": "Cary",
|
||||
"state": "NC",
|
||||
"latitude": 35.8589,
|
||||
"longitude": -78.7989,
|
||||
"capacity": 10000,
|
||||
"sport": "NWSL",
|
||||
"primary_team_abbrevs": [
|
||||
"NCC"
|
||||
],
|
||||
"year_opened": null
|
||||
},
|
||||
{
|
||||
"canonical_id": "stadium_nwsl_interco_stadium",
|
||||
"name": "InterCo Stadium",
|
||||
"city": "Orlando",
|
||||
"state": "FL",
|
||||
"latitude": 28.5411,
|
||||
"longitude": -81.3899,
|
||||
"capacity": 25500,
|
||||
"sport": "NWSL",
|
||||
"primary_team_abbrevs": [
|
||||
"ORL"
|
||||
],
|
||||
"year_opened": null
|
||||
},
|
||||
{
|
||||
"canonical_id": "stadium_nwsl_providence_park",
|
||||
"name": "Providence Park",
|
||||
"city": "Portland",
|
||||
"state": "OR",
|
||||
"latitude": 45.5217,
|
||||
"longitude": -122.6917,
|
||||
"capacity": 25218,
|
||||
"sport": "NWSL",
|
||||
"primary_team_abbrevs": [
|
||||
"POR"
|
||||
],
|
||||
"year_opened": null
|
||||
},
|
||||
{
|
||||
"canonical_id": "stadium_nwsl_lumen_field",
|
||||
"name": "Lumen Field",
|
||||
"city": "Seattle",
|
||||
"state": "WA",
|
||||
"latitude": 47.5952,
|
||||
"longitude": -122.3316,
|
||||
"capacity": 68740,
|
||||
"sport": "NWSL",
|
||||
"primary_team_abbrevs": [
|
||||
"RGN"
|
||||
],
|
||||
"year_opened": null
|
||||
},
|
||||
{
|
||||
"canonical_id": "stadium_nwsl_snapdragon_stadium",
|
||||
"name": "Snapdragon Stadium",
|
||||
"city": "San Diego",
|
||||
"state": "CA",
|
||||
"latitude": 32.7839,
|
||||
"longitude": -117.1224,
|
||||
"capacity": 35000,
|
||||
"sport": "NWSL",
|
||||
"primary_team_abbrevs": [
|
||||
"SDW"
|
||||
],
|
||||
"year_opened": null
|
||||
},
|
||||
{
|
||||
"canonical_id": "stadium_nwsl_america_first_field",
|
||||
"name": "America First Field",
|
||||
"city": "Sandy",
|
||||
"state": "UT",
|
||||
"latitude": 40.5828,
|
||||
"longitude": -111.8933,
|
||||
"capacity": 20213,
|
||||
"sport": "NWSL",
|
||||
"primary_team_abbrevs": [
|
||||
"UTA"
|
||||
],
|
||||
"year_opened": null
|
||||
},
|
||||
{
|
||||
"canonical_id": "stadium_nwsl_audi_field",
|
||||
"name": "Audi Field",
|
||||
"city": "Washington",
|
||||
"state": "DC",
|
||||
"latitude": 38.8686,
|
||||
"longitude": -77.0128,
|
||||
"capacity": 20000,
|
||||
"sport": "NWSL",
|
||||
"primary_team_abbrevs": [
|
||||
"WSH"
|
||||
],
|
||||
"year_opened": null
|
||||
}
|
||||
]
|
||||
@@ -1102,5 +1102,677 @@
|
||||
"division_id": "nhl_central",
|
||||
"primary_color": null,
|
||||
"secondary_color": null
|
||||
},
|
||||
{
|
||||
"canonical_id": "team_wnba_atl",
|
||||
"name": "Atlanta Dream",
|
||||
"abbreviation": "ATL",
|
||||
"sport": "WNBA",
|
||||
"city": "College Park",
|
||||
"stadium_canonical_id": "stadium_wnba_gateway_center_arena",
|
||||
"conference_id": null,
|
||||
"division_id": null,
|
||||
"primary_color": null,
|
||||
"secondary_color": null
|
||||
},
|
||||
{
|
||||
"canonical_id": "team_wnba_chi",
|
||||
"name": "Chicago Sky",
|
||||
"abbreviation": "CHI",
|
||||
"sport": "WNBA",
|
||||
"city": "Chicago",
|
||||
"stadium_canonical_id": "stadium_wnba_wintrust_arena",
|
||||
"conference_id": null,
|
||||
"division_id": null,
|
||||
"primary_color": null,
|
||||
"secondary_color": null
|
||||
},
|
||||
{
|
||||
"canonical_id": "team_wnba_con",
|
||||
"name": "Connecticut Sun",
|
||||
"abbreviation": "CON",
|
||||
"sport": "WNBA",
|
||||
"city": "Uncasville",
|
||||
"stadium_canonical_id": "stadium_wnba_mohegan_sun_arena",
|
||||
"conference_id": null,
|
||||
"division_id": null,
|
||||
"primary_color": null,
|
||||
"secondary_color": null
|
||||
},
|
||||
{
|
||||
"canonical_id": "team_wnba_dal",
|
||||
"name": "Dallas Wings",
|
||||
"abbreviation": "DAL",
|
||||
"sport": "WNBA",
|
||||
"city": "Arlington",
|
||||
"stadium_canonical_id": "stadium_wnba_college_park_center",
|
||||
"conference_id": null,
|
||||
"division_id": null,
|
||||
"primary_color": null,
|
||||
"secondary_color": null
|
||||
},
|
||||
{
|
||||
"canonical_id": "team_wnba_gsv",
|
||||
"name": "Golden State Valkyries",
|
||||
"abbreviation": "GSV",
|
||||
"sport": "WNBA",
|
||||
"city": "San Francisco",
|
||||
"stadium_canonical_id": "stadium_wnba_chase_center",
|
||||
"conference_id": null,
|
||||
"division_id": null,
|
||||
"primary_color": null,
|
||||
"secondary_color": null
|
||||
},
|
||||
{
|
||||
"canonical_id": "team_wnba_ind",
|
||||
"name": "Indiana Fever",
|
||||
"abbreviation": "IND",
|
||||
"sport": "WNBA",
|
||||
"city": "Indianapolis",
|
||||
"stadium_canonical_id": "stadium_wnba_gainbridge_fieldhouse",
|
||||
"conference_id": null,
|
||||
"division_id": null,
|
||||
"primary_color": null,
|
||||
"secondary_color": null
|
||||
},
|
||||
{
|
||||
"canonical_id": "team_wnba_lva",
|
||||
"name": "Las Vegas Aces",
|
||||
"abbreviation": "LVA",
|
||||
"sport": "WNBA",
|
||||
"city": "Las Vegas",
|
||||
"stadium_canonical_id": "stadium_wnba_michelob_ultra_arena",
|
||||
"conference_id": null,
|
||||
"division_id": null,
|
||||
"primary_color": null,
|
||||
"secondary_color": null
|
||||
},
|
||||
{
|
||||
"canonical_id": "team_wnba_las",
|
||||
"name": "Los Angeles Sparks",
|
||||
"abbreviation": "LAS",
|
||||
"sport": "WNBA",
|
||||
"city": "Los Angeles",
|
||||
"stadium_canonical_id": "stadium_wnba_cryptocom_arena",
|
||||
"conference_id": null,
|
||||
"division_id": null,
|
||||
"primary_color": null,
|
||||
"secondary_color": null
|
||||
},
|
||||
{
|
||||
"canonical_id": "team_wnba_min",
|
||||
"name": "Minnesota Lynx",
|
||||
"abbreviation": "MIN",
|
||||
"sport": "WNBA",
|
||||
"city": "Minneapolis",
|
||||
"stadium_canonical_id": "stadium_wnba_target_center",
|
||||
"conference_id": null,
|
||||
"division_id": null,
|
||||
"primary_color": null,
|
||||
"secondary_color": null
|
||||
},
|
||||
{
|
||||
"canonical_id": "team_wnba_nyl",
|
||||
"name": "New York Liberty",
|
||||
"abbreviation": "NYL",
|
||||
"sport": "WNBA",
|
||||
"city": "Brooklyn",
|
||||
"stadium_canonical_id": "stadium_wnba_barclays_center",
|
||||
"conference_id": null,
|
||||
"division_id": null,
|
||||
"primary_color": null,
|
||||
"secondary_color": null
|
||||
},
|
||||
{
|
||||
"canonical_id": "team_wnba_phx",
|
||||
"name": "Phoenix Mercury",
|
||||
"abbreviation": "PHX",
|
||||
"sport": "WNBA",
|
||||
"city": "Phoenix",
|
||||
"stadium_canonical_id": "stadium_wnba_footprint_center",
|
||||
"conference_id": null,
|
||||
"division_id": null,
|
||||
"primary_color": null,
|
||||
"secondary_color": null
|
||||
},
|
||||
{
|
||||
"canonical_id": "team_wnba_sea",
|
||||
"name": "Seattle Storm",
|
||||
"abbreviation": "SEA",
|
||||
"sport": "WNBA",
|
||||
"city": "Seattle",
|
||||
"stadium_canonical_id": "stadium_wnba_climate_pledge_arena",
|
||||
"conference_id": null,
|
||||
"division_id": null,
|
||||
"primary_color": null,
|
||||
"secondary_color": null
|
||||
},
|
||||
{
|
||||
"canonical_id": "team_wnba_was",
|
||||
"name": "Washington Mystics",
|
||||
"abbreviation": "WAS",
|
||||
"sport": "WNBA",
|
||||
"city": "Washington",
|
||||
"stadium_canonical_id": "stadium_wnba_entertainment__sports_arena",
|
||||
"conference_id": null,
|
||||
"division_id": null,
|
||||
"primary_color": null,
|
||||
"secondary_color": null
|
||||
},
|
||||
{
|
||||
"canonical_id": "team_mls_atl",
|
||||
"name": "Atlanta United FC",
|
||||
"abbreviation": "ATL",
|
||||
"sport": "MLS",
|
||||
"city": "Atlanta",
|
||||
"stadium_canonical_id": "stadium_mls_mercedes-benz_stadium",
|
||||
"conference_id": null,
|
||||
"division_id": null,
|
||||
"primary_color": null,
|
||||
"secondary_color": null
|
||||
},
|
||||
{
|
||||
"canonical_id": "team_mls_atx",
|
||||
"name": "Austin FC",
|
||||
"abbreviation": "ATX",
|
||||
"sport": "MLS",
|
||||
"city": "Austin",
|
||||
"stadium_canonical_id": "stadium_mls_q2_stadium",
|
||||
"conference_id": null,
|
||||
"division_id": null,
|
||||
"primary_color": null,
|
||||
"secondary_color": null
|
||||
},
|
||||
{
|
||||
"canonical_id": "team_mls_clt",
|
||||
"name": "Charlotte FC",
|
||||
"abbreviation": "CLT",
|
||||
"sport": "MLS",
|
||||
"city": "Charlotte",
|
||||
"stadium_canonical_id": "stadium_mls_bank_of_america_stadium",
|
||||
"conference_id": null,
|
||||
"division_id": null,
|
||||
"primary_color": null,
|
||||
"secondary_color": null
|
||||
},
|
||||
{
|
||||
"canonical_id": "team_mls_chi",
|
||||
"name": "Chicago Fire FC",
|
||||
"abbreviation": "CHI",
|
||||
"sport": "MLS",
|
||||
"city": "Chicago",
|
||||
"stadium_canonical_id": "stadium_mls_soldier_field",
|
||||
"conference_id": null,
|
||||
"division_id": null,
|
||||
"primary_color": null,
|
||||
"secondary_color": null
|
||||
},
|
||||
{
|
||||
"canonical_id": "team_mls_cin",
|
||||
"name": "FC Cincinnati",
|
||||
"abbreviation": "CIN",
|
||||
"sport": "MLS",
|
||||
"city": "Cincinnati",
|
||||
"stadium_canonical_id": "stadium_mls_tql_stadium",
|
||||
"conference_id": null,
|
||||
"division_id": null,
|
||||
"primary_color": null,
|
||||
"secondary_color": null
|
||||
},
|
||||
{
|
||||
"canonical_id": "team_mls_col",
|
||||
"name": "Colorado Rapids",
|
||||
"abbreviation": "COL",
|
||||
"sport": "MLS",
|
||||
"city": "Commerce City",
|
||||
"stadium_canonical_id": "stadium_mls_dicks_sporting_goods_park",
|
||||
"conference_id": null,
|
||||
"division_id": null,
|
||||
"primary_color": null,
|
||||
"secondary_color": null
|
||||
},
|
||||
{
|
||||
"canonical_id": "team_mls_clb",
|
||||
"name": "Columbus Crew",
|
||||
"abbreviation": "CLB",
|
||||
"sport": "MLS",
|
||||
"city": "Columbus",
|
||||
"stadium_canonical_id": "stadium_mls_lowercom_field",
|
||||
"conference_id": null,
|
||||
"division_id": null,
|
||||
"primary_color": null,
|
||||
"secondary_color": null
|
||||
},
|
||||
{
|
||||
"canonical_id": "team_mls_dal",
|
||||
"name": "FC Dallas",
|
||||
"abbreviation": "DAL",
|
||||
"sport": "MLS",
|
||||
"city": "Frisco",
|
||||
"stadium_canonical_id": "stadium_mls_toyota_stadium",
|
||||
"conference_id": null,
|
||||
"division_id": null,
|
||||
"primary_color": null,
|
||||
"secondary_color": null
|
||||
},
|
||||
{
|
||||
"canonical_id": "team_mls_dcu",
|
||||
"name": "D.C. United",
|
||||
"abbreviation": "DCU",
|
||||
"sport": "MLS",
|
||||
"city": "Washington",
|
||||
"stadium_canonical_id": "stadium_mls_audi_field",
|
||||
"conference_id": null,
|
||||
"division_id": null,
|
||||
"primary_color": null,
|
||||
"secondary_color": null
|
||||
},
|
||||
{
|
||||
"canonical_id": "team_mls_hou",
|
||||
"name": "Houston Dynamo FC",
|
||||
"abbreviation": "HOU",
|
||||
"sport": "MLS",
|
||||
"city": "Houston",
|
||||
"stadium_canonical_id": "stadium_mls_shell_energy_stadium",
|
||||
"conference_id": null,
|
||||
"division_id": null,
|
||||
"primary_color": null,
|
||||
"secondary_color": null
|
||||
},
|
||||
{
|
||||
"canonical_id": "team_mls_lag",
|
||||
"name": "LA Galaxy",
|
||||
"abbreviation": "LAG",
|
||||
"sport": "MLS",
|
||||
"city": "Carson",
|
||||
"stadium_canonical_id": "stadium_mls_dignity_health_sports_park",
|
||||
"conference_id": null,
|
||||
"division_id": null,
|
||||
"primary_color": null,
|
||||
"secondary_color": null
|
||||
},
|
||||
{
|
||||
"canonical_id": "team_mls_lafc",
|
||||
"name": "Los Angeles FC",
|
||||
"abbreviation": "LAFC",
|
||||
"sport": "MLS",
|
||||
"city": "Los Angeles",
|
||||
"stadium_canonical_id": "stadium_mls_bmo_stadium",
|
||||
"conference_id": null,
|
||||
"division_id": null,
|
||||
"primary_color": null,
|
||||
"secondary_color": null
|
||||
},
|
||||
{
|
||||
"canonical_id": "team_mls_mia",
|
||||
"name": "Inter Miami CF",
|
||||
"abbreviation": "MIA",
|
||||
"sport": "MLS",
|
||||
"city": "Fort Lauderdale",
|
||||
"stadium_canonical_id": "stadium_mls_chase_stadium",
|
||||
"conference_id": null,
|
||||
"division_id": null,
|
||||
"primary_color": null,
|
||||
"secondary_color": null
|
||||
},
|
||||
{
|
||||
"canonical_id": "team_mls_min",
|
||||
"name": "Minnesota United FC",
|
||||
"abbreviation": "MIN",
|
||||
"sport": "MLS",
|
||||
"city": "St. Paul",
|
||||
"stadium_canonical_id": "stadium_mls_allianz_field",
|
||||
"conference_id": null,
|
||||
"division_id": null,
|
||||
"primary_color": null,
|
||||
"secondary_color": null
|
||||
},
|
||||
{
|
||||
"canonical_id": "team_mls_mtl",
|
||||
"name": "CF Montreal",
|
||||
"abbreviation": "MTL",
|
||||
"sport": "MLS",
|
||||
"city": "Montreal",
|
||||
"stadium_canonical_id": "stadium_mls_stade_saputo",
|
||||
"conference_id": null,
|
||||
"division_id": null,
|
||||
"primary_color": null,
|
||||
"secondary_color": null
|
||||
},
|
||||
{
|
||||
"canonical_id": "team_mls_nsh",
|
||||
"name": "Nashville SC",
|
||||
"abbreviation": "NSH",
|
||||
"sport": "MLS",
|
||||
"city": "Nashville",
|
||||
"stadium_canonical_id": "stadium_mls_geodis_park",
|
||||
"conference_id": null,
|
||||
"division_id": null,
|
||||
"primary_color": null,
|
||||
"secondary_color": null
|
||||
},
|
||||
{
|
||||
"canonical_id": "team_mls_ner",
|
||||
"name": "New England Revolution",
|
||||
"abbreviation": "NER",
|
||||
"sport": "MLS",
|
||||
"city": "Foxborough",
|
||||
"stadium_canonical_id": "stadium_mls_gillette_stadium",
|
||||
"conference_id": null,
|
||||
"division_id": null,
|
||||
"primary_color": null,
|
||||
"secondary_color": null
|
||||
},
|
||||
{
|
||||
"canonical_id": "team_mls_nyc",
|
||||
"name": "New York City FC",
|
||||
"abbreviation": "NYC",
|
||||
"sport": "MLS",
|
||||
"city": "New York",
|
||||
"stadium_canonical_id": "stadium_mls_yankee_stadium",
|
||||
"conference_id": null,
|
||||
"division_id": null,
|
||||
"primary_color": null,
|
||||
"secondary_color": null
|
||||
},
|
||||
{
|
||||
"canonical_id": "team_mls_rbny",
|
||||
"name": "New York Red Bulls",
|
||||
"abbreviation": "RBNY",
|
||||
"sport": "MLS",
|
||||
"city": "Harrison",
|
||||
"stadium_canonical_id": "stadium_mls_red_bull_arena",
|
||||
"conference_id": null,
|
||||
"division_id": null,
|
||||
"primary_color": null,
|
||||
"secondary_color": null
|
||||
},
|
||||
{
|
||||
"canonical_id": "team_mls_orl",
|
||||
"name": "Orlando City SC",
|
||||
"abbreviation": "ORL",
|
||||
"sport": "MLS",
|
||||
"city": "Orlando",
|
||||
"stadium_canonical_id": "stadium_mls_interco_stadium",
|
||||
"conference_id": null,
|
||||
"division_id": null,
|
||||
"primary_color": null,
|
||||
"secondary_color": null
|
||||
},
|
||||
{
|
||||
"canonical_id": "team_mls_phi",
|
||||
"name": "Philadelphia Union",
|
||||
"abbreviation": "PHI",
|
||||
"sport": "MLS",
|
||||
"city": "Chester",
|
||||
"stadium_canonical_id": "stadium_mls_subaru_park",
|
||||
"conference_id": null,
|
||||
"division_id": null,
|
||||
"primary_color": null,
|
||||
"secondary_color": null
|
||||
},
|
||||
{
|
||||
"canonical_id": "team_mls_por",
|
||||
"name": "Portland Timbers",
|
||||
"abbreviation": "POR",
|
||||
"sport": "MLS",
|
||||
"city": "Portland",
|
||||
"stadium_canonical_id": "stadium_mls_providence_park",
|
||||
"conference_id": null,
|
||||
"division_id": null,
|
||||
"primary_color": null,
|
||||
"secondary_color": null
|
||||
},
|
||||
{
|
||||
"canonical_id": "team_mls_rsl",
|
||||
"name": "Real Salt Lake",
|
||||
"abbreviation": "RSL",
|
||||
"sport": "MLS",
|
||||
"city": "Sandy",
|
||||
"stadium_canonical_id": "stadium_mls_america_first_field",
|
||||
"conference_id": null,
|
||||
"division_id": null,
|
||||
"primary_color": null,
|
||||
"secondary_color": null
|
||||
},
|
||||
{
|
||||
"canonical_id": "team_mls_sje",
|
||||
"name": "San Jose Earthquakes",
|
||||
"abbreviation": "SJE",
|
||||
"sport": "MLS",
|
||||
"city": "San Jose",
|
||||
"stadium_canonical_id": "stadium_mls_paypal_park",
|
||||
"conference_id": null,
|
||||
"division_id": null,
|
||||
"primary_color": null,
|
||||
"secondary_color": null
|
||||
},
|
||||
{
|
||||
"canonical_id": "team_mls_sea",
|
||||
"name": "Seattle Sounders FC",
|
||||
"abbreviation": "SEA",
|
||||
"sport": "MLS",
|
||||
"city": "Seattle",
|
||||
"stadium_canonical_id": "stadium_mls_lumen_field",
|
||||
"conference_id": null,
|
||||
"division_id": null,
|
||||
"primary_color": null,
|
||||
"secondary_color": null
|
||||
},
|
||||
{
|
||||
"canonical_id": "team_mls_skc",
|
||||
"name": "Sporting Kansas City",
|
||||
"abbreviation": "SKC",
|
||||
"sport": "MLS",
|
||||
"city": "Kansas City",
|
||||
"stadium_canonical_id": "stadium_mls_childrens_mercy_park",
|
||||
"conference_id": null,
|
||||
"division_id": null,
|
||||
"primary_color": null,
|
||||
"secondary_color": null
|
||||
},
|
||||
{
|
||||
"canonical_id": "team_mls_stl",
|
||||
"name": "St. Louis City SC",
|
||||
"abbreviation": "STL",
|
||||
"sport": "MLS",
|
||||
"city": "St. Louis",
|
||||
"stadium_canonical_id": "stadium_mls_citypark",
|
||||
"conference_id": null,
|
||||
"division_id": null,
|
||||
"primary_color": null,
|
||||
"secondary_color": null
|
||||
},
|
||||
{
|
||||
"canonical_id": "team_mls_tor",
|
||||
"name": "Toronto FC",
|
||||
"abbreviation": "TOR",
|
||||
"sport": "MLS",
|
||||
"city": "Toronto",
|
||||
"stadium_canonical_id": "stadium_mls_bmo_field",
|
||||
"conference_id": null,
|
||||
"division_id": null,
|
||||
"primary_color": null,
|
||||
"secondary_color": null
|
||||
},
|
||||
{
|
||||
"canonical_id": "team_mls_van",
|
||||
"name": "Vancouver Whitecaps FC",
|
||||
"abbreviation": "VAN",
|
||||
"sport": "MLS",
|
||||
"city": "Vancouver",
|
||||
"stadium_canonical_id": "stadium_mls_bc_place",
|
||||
"conference_id": null,
|
||||
"division_id": null,
|
||||
"primary_color": null,
|
||||
"secondary_color": null
|
||||
},
|
||||
{
|
||||
"canonical_id": "team_mls_sdg",
|
||||
"name": "San Diego FC",
|
||||
"abbreviation": "SDG",
|
||||
"sport": "MLS",
|
||||
"city": "San Diego",
|
||||
"stadium_canonical_id": "stadium_mls_snapdragon_stadium",
|
||||
"conference_id": null,
|
||||
"division_id": null,
|
||||
"primary_color": null,
|
||||
"secondary_color": null
|
||||
},
|
||||
{
|
||||
"canonical_id": "team_nwsl_ang",
|
||||
"name": "Angel City FC",
|
||||
"abbreviation": "ANG",
|
||||
"sport": "NWSL",
|
||||
"city": "Los Angeles",
|
||||
"stadium_canonical_id": "stadium_nwsl_bmo_stadium",
|
||||
"conference_id": null,
|
||||
"division_id": null,
|
||||
"primary_color": null,
|
||||
"secondary_color": null
|
||||
},
|
||||
{
|
||||
"canonical_id": "team_nwsl_bay",
|
||||
"name": "Bay FC",
|
||||
"abbreviation": "BAY",
|
||||
"sport": "NWSL",
|
||||
"city": "San Jose",
|
||||
"stadium_canonical_id": "stadium_nwsl_paypal_park",
|
||||
"conference_id": null,
|
||||
"division_id": null,
|
||||
"primary_color": null,
|
||||
"secondary_color": null
|
||||
},
|
||||
{
|
||||
"canonical_id": "team_nwsl_chi",
|
||||
"name": "Chicago Red Stars",
|
||||
"abbreviation": "CHI",
|
||||
"sport": "NWSL",
|
||||
"city": "Chicago",
|
||||
"stadium_canonical_id": "stadium_nwsl_seatgeek_stadium",
|
||||
"conference_id": null,
|
||||
"division_id": null,
|
||||
"primary_color": null,
|
||||
"secondary_color": null
|
||||
},
|
||||
{
|
||||
"canonical_id": "team_nwsl_hou",
|
||||
"name": "Houston Dash",
|
||||
"abbreviation": "HOU",
|
||||
"sport": "NWSL",
|
||||
"city": "Houston",
|
||||
"stadium_canonical_id": "stadium_nwsl_shell_energy_stadium",
|
||||
"conference_id": null,
|
||||
"division_id": null,
|
||||
"primary_color": null,
|
||||
"secondary_color": null
|
||||
},
|
||||
{
|
||||
"canonical_id": "team_nwsl_kcc",
|
||||
"name": "Kansas City Current",
|
||||
"abbreviation": "KCC",
|
||||
"sport": "NWSL",
|
||||
"city": "Kansas City",
|
||||
"stadium_canonical_id": "stadium_nwsl_cpkc_stadium",
|
||||
"conference_id": null,
|
||||
"division_id": null,
|
||||
"primary_color": null,
|
||||
"secondary_color": null
|
||||
},
|
||||
{
|
||||
"canonical_id": "team_nwsl_njy",
|
||||
"name": "NJ/NY Gotham FC",
|
||||
"abbreviation": "NJY",
|
||||
"sport": "NWSL",
|
||||
"city": "Harrison",
|
||||
"stadium_canonical_id": "stadium_nwsl_red_bull_arena",
|
||||
"conference_id": null,
|
||||
"division_id": null,
|
||||
"primary_color": null,
|
||||
"secondary_color": null
|
||||
},
|
||||
{
|
||||
"canonical_id": "team_nwsl_ncc",
|
||||
"name": "North Carolina Courage",
|
||||
"abbreviation": "NCC",
|
||||
"sport": "NWSL",
|
||||
"city": "Cary",
|
||||
"stadium_canonical_id": "stadium_nwsl_wakemed_soccer_park",
|
||||
"conference_id": null,
|
||||
"division_id": null,
|
||||
"primary_color": null,
|
||||
"secondary_color": null
|
||||
},
|
||||
{
|
||||
"canonical_id": "team_nwsl_orl",
|
||||
"name": "Orlando Pride",
|
||||
"abbreviation": "ORL",
|
||||
"sport": "NWSL",
|
||||
"city": "Orlando",
|
||||
"stadium_canonical_id": "stadium_nwsl_interco_stadium",
|
||||
"conference_id": null,
|
||||
"division_id": null,
|
||||
"primary_color": null,
|
||||
"secondary_color": null
|
||||
},
|
||||
{
|
||||
"canonical_id": "team_nwsl_por",
|
||||
"name": "Portland Thorns FC",
|
||||
"abbreviation": "POR",
|
||||
"sport": "NWSL",
|
||||
"city": "Portland",
|
||||
"stadium_canonical_id": "stadium_nwsl_providence_park",
|
||||
"conference_id": null,
|
||||
"division_id": null,
|
||||
"primary_color": null,
|
||||
"secondary_color": null
|
||||
},
|
||||
{
|
||||
"canonical_id": "team_nwsl_rgn",
|
||||
"name": "Seattle Reign FC",
|
||||
"abbreviation": "RGN",
|
||||
"sport": "NWSL",
|
||||
"city": "Seattle",
|
||||
"stadium_canonical_id": "stadium_nwsl_lumen_field",
|
||||
"conference_id": null,
|
||||
"division_id": null,
|
||||
"primary_color": null,
|
||||
"secondary_color": null
|
||||
},
|
||||
{
|
||||
"canonical_id": "team_nwsl_sdw",
|
||||
"name": "San Diego Wave FC",
|
||||
"abbreviation": "SDW",
|
||||
"sport": "NWSL",
|
||||
"city": "San Diego",
|
||||
"stadium_canonical_id": "stadium_nwsl_snapdragon_stadium",
|
||||
"conference_id": null,
|
||||
"division_id": null,
|
||||
"primary_color": null,
|
||||
"secondary_color": null
|
||||
},
|
||||
{
|
||||
"canonical_id": "team_nwsl_uta",
|
||||
"name": "Utah Royals FC",
|
||||
"abbreviation": "UTA",
|
||||
"sport": "NWSL",
|
||||
"city": "Sandy",
|
||||
"stadium_canonical_id": "stadium_nwsl_america_first_field",
|
||||
"conference_id": null,
|
||||
"division_id": null,
|
||||
"primary_color": null,
|
||||
"secondary_color": null
|
||||
},
|
||||
{
|
||||
"canonical_id": "team_nwsl_wsh",
|
||||
"name": "Washington Spirit",
|
||||
"abbreviation": "WSH",
|
||||
"sport": "NWSL",
|
||||
"city": "Washington",
|
||||
"stadium_canonical_id": "stadium_nwsl_audi_field",
|
||||
"conference_id": null,
|
||||
"division_id": null,
|
||||
"primary_color": null,
|
||||
"secondary_color": null
|
||||
}
|
||||
]
|
||||
Reference in New Issue
Block a user