Spaces:
Running
Running
Commit ·
3e6f1d3
1
Parent(s): 10cc4da
Add analytics, confidence meter, enhanced H2H, daily MVP refresh
Browse files- claude.md +377 -0
- explain.md +445 -0
- generate_starters.py +55 -0
- server.py +218 -2
- src/feature_engineering.py +1 -0
- web/package-lock.json +410 -3
- web/package.json +2 -1
- web/src/App.jsx +4 -1
- web/src/api.js +7 -0
- web/src/icons.jsx +11 -1
- web/src/index.css +123 -0
- web/src/pages/Analytics.jsx +338 -0
- web/src/pages/HeadToHead.jsx +133 -4
- web/src/pages/LiveGames.jsx +67 -4
claude.md
ADDED
|
@@ -0,0 +1,377 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# NBA Sage - Complete Codebase Context for AI Assistants
|
| 2 |
+
|
| 3 |
+
> **Purpose**: This document provides comprehensive context for AI assistants to understand the NBA Sage prediction system. Read this entire document before making any code modifications.
|
| 4 |
+
|
| 5 |
+
---
|
| 6 |
+
|
| 7 |
+
## 🏗️ Project Architecture Overview
|
| 8 |
+
|
| 9 |
+
```
|
| 10 |
+
NBA ML/
|
| 11 |
+
├── server.py # Main production server (Flask + React)
|
| 12 |
+
├── api/api.py # Development API server
|
| 13 |
+
│
|
| 14 |
+
├── src/ # Core Python modules
|
| 15 |
+
│ ├── prediction_pipeline.py # Main prediction orchestrator
|
| 16 |
+
│ ├── feature_engineering.py # ELO + feature generation
|
| 17 |
+
│ ├── data_collector.py # Historical NBA API data
|
| 18 |
+
│ ├── live_data_collector.py # Real-time game data
|
| 19 |
+
│ ├── injury_collector.py # Player injury tracking
|
| 20 |
+
│ ├── prediction_tracker.py # ChromaDB prediction storage
|
| 21 |
+
│ ├── auto_trainer.py # Automated training scheduler
|
| 22 |
+
│ ├── continuous_learner.py # Incremental model updates
|
| 23 |
+
│ ├── preprocessing.py # Data preprocessing
|
| 24 |
+
│ ├── config.py # Global configuration
|
| 25 |
+
│ └── models/
|
| 26 |
+
│ ├── game_predictor.py # XGBoost+LightGBM ensemble
|
| 27 |
+
│ ├── mvp_predictor.py # MVP prediction model
|
| 28 |
+
│ └── championship_predictor.py
|
| 29 |
+
│
|
| 30 |
+
├── web/ # React Frontend
|
| 31 |
+
│ └── src/
|
| 32 |
+
│ ├── App.jsx # Main app with sidebar navigation
|
| 33 |
+
│ ├── pages/ # LiveGames, Predictions, MVP, etc.
|
| 34 |
+
│ ├── api.js # API client
|
| 35 |
+
│ └── index.css # Comprehensive CSS design system
|
| 36 |
+
│
|
| 37 |
+
├── data/
|
| 38 |
+
│ ├── api_data/ # Cached NBA API responses (parquet)
|
| 39 |
+
│ ├── processed/ # Processed datasets (joblib)
|
| 40 |
+
│ └── raw/ # Raw game data
|
| 41 |
+
│
|
| 42 |
+
└── models/
|
| 43 |
+
└── game_predictor.joblib # Trained ML model
|
| 44 |
+
```
|
| 45 |
+
|
| 46 |
+
---
|
| 47 |
+
|
| 48 |
+
## 🔄 Data Flow
|
| 49 |
+
|
| 50 |
+
```
|
| 51 |
+
NBA API ──► Data Collectors ──► Feature Engineering ──► ML Training
|
| 52 |
+
│
|
| 53 |
+
▼
|
| 54 |
+
Live API ──► Live Collector ──► Prediction Pipeline ──► Flask API ──► React UI
|
| 55 |
+
│
|
| 56 |
+
▼
|
| 57 |
+
Prediction Tracker (ChromaDB)
|
| 58 |
+
```
|
| 59 |
+
|
| 60 |
+
---
|
| 61 |
+
|
| 62 |
+
## 📦 Core Components Deep Dive
|
| 63 |
+
|
| 64 |
+
### 1. `server.py` - Production Server (39KB, 929 lines)
|
| 65 |
+
|
| 66 |
+
**Critical for Hugging Face deployment. Combines Flask API + React static serving.**
|
| 67 |
+
|
| 68 |
+
**Key Sections:**
|
| 69 |
+
- **Cache Configuration (lines 30-40)**: In-memory caching for rosters, predictions, live games
|
| 70 |
+
- **Startup Cache Warming (lines 140-225)**: `warm_starter_cache()` fetches all 30 team rosters on startup
|
| 71 |
+
- **Background Scheduler (lines 340-370)**: APScheduler jobs for ELO updates, retraining, prediction sync
|
| 72 |
+
- **API Endpoints (lines 400-860)**: All REST endpoints for frontend
|
| 73 |
+
|
| 74 |
+
**Important Functions:**
|
| 75 |
+
```python
|
| 76 |
+
warm_starter_cache() # Fetches real NBA API data for all teams
|
| 77 |
+
startup_cache_warming() # Runs synchronously on server start
|
| 78 |
+
auto_retrain_model() # Smart retraining after all daily games complete
|
| 79 |
+
sync_prediction_results() # Updates prediction correctness from final scores
|
| 80 |
+
update_elo_ratings() # Daily ELO recalculation
|
| 81 |
+
```
|
| 82 |
+
|
| 83 |
+
**Endpoints:**
|
| 84 |
+
- `GET /api/live-games` - Today's games with predictions
|
| 85 |
+
- `GET /api/roster/<team>` - Team's projected starting 5
|
| 86 |
+
- `GET /api/accuracy` - Model accuracy statistics
|
| 87 |
+
- `GET /api/mvp` - MVP race standings
|
| 88 |
+
- `GET /api/championship` - Championship odds
|
| 89 |
+
|
| 90 |
+
---
|
| 91 |
+
|
| 92 |
+
### 2. `prediction_pipeline.py` - Prediction Orchestrator (41KB, 765 lines)
|
| 93 |
+
|
| 94 |
+
**The heart of the system. Orchestrates all predictions.**
|
| 95 |
+
|
| 96 |
+
**Key Properties:**
|
| 97 |
+
```python
|
| 98 |
+
self.live_collector # LiveDataCollector instance
|
| 99 |
+
self.injury_collector # InjuryCollector instance
|
| 100 |
+
self.feature_gen # FeatureGenerator instance
|
| 101 |
+
self.tracker # PredictionTracker (ChromaDB)
|
| 102 |
+
self._game_model # Lazy-loaded GamePredictor
|
| 103 |
+
```
|
| 104 |
+
|
| 105 |
+
**Important Methods:**
|
| 106 |
+
|
| 107 |
+
| Method | Purpose |
|
| 108 |
+
|--------|---------|
|
| 109 |
+
| `predict_game(home, away)` | Generate single game prediction |
|
| 110 |
+
| `get_upcoming_games(days)` | Fetch future NBA schedule |
|
| 111 |
+
| `get_mvp_race()` | Calculate MVP standings from live stats |
|
| 112 |
+
| `get_championship_odds()` | Calculate championship probabilities |
|
| 113 |
+
| `get_team_roster(team)` | Fast fallback roster data |
|
| 114 |
+
|
| 115 |
+
**⚠️ CRITICAL: Prediction Algorithm (lines 349-504)**
|
| 116 |
+
|
| 117 |
+
The `predict_game()` method uses a **formula-based approach**, NOT the trained ML model:
|
| 118 |
+
|
| 119 |
+
```python
|
| 120 |
+
# Weights in predict_game():
|
| 121 |
+
home_talent = (
|
| 122 |
+
0.40 * home_win_pct + # Current season record
|
| 123 |
+
0.30 * home_form + # Last 10 games
|
| 124 |
+
0.20 * home_elo_strength + # Historical ELO
|
| 125 |
+
0.10 * 0.5 # Baseline
|
| 126 |
+
)
|
| 127 |
+
# Plus: +3.5% home court, -2% per injury point
|
| 128 |
+
# Uses Log5 formula for head-to-head probability
|
| 129 |
+
```
|
| 130 |
+
|
| 131 |
+
The trained `GamePredictor` model exists but is NOT called for live predictions.
|
| 132 |
+
|
| 133 |
+
---
|
| 134 |
+
|
| 135 |
+
### 3. `feature_engineering.py` - Feature Generation (29KB, 696 lines)
|
| 136 |
+
|
| 137 |
+
**Contains ELO system and all feature generation logic.**
|
| 138 |
+
|
| 139 |
+
**Classes:**
|
| 140 |
+
|
| 141 |
+
| Class | Purpose | Key Methods |
|
| 142 |
+
|-------|---------|-------------|
|
| 143 |
+
| `ELOCalculator` | ELO rating system | `update_ratings()`, `calculate_game_features()` |
|
| 144 |
+
| `EraNormalizer` | Z-score normalization across seasons | `fit_season()`, `transform()` |
|
| 145 |
+
| `StatLoader` | Load all stat types | `get_team_season_stats()`, `get_team_top_players_stats()` |
|
| 146 |
+
| `FeatureGenerator` | Main feature orchestrator | `generate_game_features()`, `generate_features_for_dataset()` |
|
| 147 |
+
|
| 148 |
+
**ELO Configuration:**
|
| 149 |
+
```python
|
| 150 |
+
initial_rating = 1500
|
| 151 |
+
k_factor = 20
|
| 152 |
+
home_advantage = 100 # ELO points for home court
|
| 153 |
+
regression_factor = 0.25 # Season regression to mean
|
| 154 |
+
```
|
| 155 |
+
|
| 156 |
+
**Feature Types Generated:**
|
| 157 |
+
- ELO features (team_elo, opponent_elo, elo_diff, elo_expected_win)
|
| 158 |
+
- Rolling averages (5, 10, 20 game windows)
|
| 159 |
+
- Rest days, back-to-back detection
|
| 160 |
+
- Season record features
|
| 161 |
+
- Head-to-head history
|
| 162 |
+
|
| 163 |
+
---
|
| 164 |
+
|
| 165 |
+
### 4. `data_collector.py` - Historical Data (27KB, 650 lines)
|
| 166 |
+
|
| 167 |
+
**Collects comprehensive NBA data from official API.**
|
| 168 |
+
|
| 169 |
+
**Classes:**
|
| 170 |
+
| Class | Data Collected |
|
| 171 |
+
|-------|---------------|
|
| 172 |
+
| `GameDataCollector` | Game results per season |
|
| 173 |
+
| `TeamDataCollector` | Team stats (basic, advanced, clutch, hustle, defense) |
|
| 174 |
+
| `PlayerDataCollector` | Player stats |
|
| 175 |
+
| `CacheManager` | Parquet file caching |
|
| 176 |
+
|
| 177 |
+
**Key Features:**
|
| 178 |
+
- Exponential backoff retry for rate limiting
|
| 179 |
+
- Per-season parquet caching
|
| 180 |
+
- Checkpoint system for resumable collection
|
| 181 |
+
|
| 182 |
+
---
|
| 183 |
+
|
| 184 |
+
### 5. `live_data_collector.py` - Real-Time Data (9KB, 236 lines)
|
| 185 |
+
|
| 186 |
+
**Uses `nba_api.live` endpoints for real-time game data.**
|
| 187 |
+
|
| 188 |
+
**Key Methods:**
|
| 189 |
+
```python
|
| 190 |
+
get_live_scoreboard() # Today's games with live scores
|
| 191 |
+
get_game_boxscore(id) # Detailed box score
|
| 192 |
+
get_games_by_status() # Filter: NOT_STARTED, IN_PROGRESS, FINAL
|
| 193 |
+
```
|
| 194 |
+
|
| 195 |
+
**Data Fields Returned:**
|
| 196 |
+
- game_id, game_code
|
| 197 |
+
- home_team, away_team (tricodes)
|
| 198 |
+
- home_score, away_score
|
| 199 |
+
- period, clock
|
| 200 |
+
- status
|
| 201 |
+
|
| 202 |
+
---
|
| 203 |
+
|
| 204 |
+
### 6. `prediction_tracker.py` - Persistence (20KB, 508 lines)
|
| 205 |
+
|
| 206 |
+
**Stores predictions and tracks accuracy using ChromaDB Cloud.**
|
| 207 |
+
|
| 208 |
+
**Features:**
|
| 209 |
+
- ChromaDB Cloud integration (with local JSON fallback)
|
| 210 |
+
- Prediction storage before games start
|
| 211 |
+
- Result updating after games complete
|
| 212 |
+
- Comprehensive accuracy statistics
|
| 213 |
+
|
| 214 |
+
**Key Methods:**
|
| 215 |
+
```python
|
| 216 |
+
save_prediction(game_id, prediction) # Store pre-game prediction
|
| 217 |
+
update_result(game_id, winner, scores) # Update with final result
|
| 218 |
+
get_accuracy_stats() # Overall, by confidence, by team
|
| 219 |
+
get_pending_predictions() # Awaiting results
|
| 220 |
+
```
|
| 221 |
+
|
| 222 |
+
---
|
| 223 |
+
|
| 224 |
+
### 7. `models/game_predictor.py` - ML Model (12KB, 332 lines)
|
| 225 |
+
|
| 226 |
+
**XGBoost + LightGBM ensemble classifier.**
|
| 227 |
+
|
| 228 |
+
**Architecture:**
|
| 229 |
+
```
|
| 230 |
+
Input Features ──┬──► XGBoost ──┐
|
| 231 |
+
│ │──► Weighted Average ──► Win Probability
|
| 232 |
+
└──► LightGBM ─┘
|
| 233 |
+
(50/50 weight)
|
| 234 |
+
```
|
| 235 |
+
|
| 236 |
+
**Key Methods:**
|
| 237 |
+
```python
|
| 238 |
+
train(X_train, y_train, X_val, y_val) # Train both models
|
| 239 |
+
predict_proba(X) # Get [loss_prob, win_prob]
|
| 240 |
+
predict_with_confidence(X) # Detailed prediction info
|
| 241 |
+
explain_prediction(X) # Feature importance for prediction
|
| 242 |
+
save() / load() # Persist to models/game_predictor.joblib
|
| 243 |
+
```
|
| 244 |
+
|
| 245 |
+
**⚠️ NOTE: Model exists but `predict_game()` doesn't use it!**
|
| 246 |
+
|
| 247 |
+
---
|
| 248 |
+
|
| 249 |
+
### 8. `auto_trainer.py` & `continuous_learner.py` - Auto Training
|
| 250 |
+
|
| 251 |
+
**AutoTrainer** (Singleton scheduler):
|
| 252 |
+
- Runs background loop checking for tasks
|
| 253 |
+
- Ingests completed games every hour
|
| 254 |
+
- Smart retraining: only after ALL daily games complete
|
| 255 |
+
- If new accuracy < old accuracy, reverts model
|
| 256 |
+
|
| 257 |
+
**ContinuousLearner** (Update workflow):
|
| 258 |
+
```
|
| 259 |
+
ingest_completed_games() ──► update_features() ──► retrain_model()
|
| 260 |
+
```
|
| 261 |
+
|
| 262 |
+
---
|
| 263 |
+
|
| 264 |
+
## 🗄️ Database & Storage
|
| 265 |
+
|
| 266 |
+
### ChromaDB Cloud
|
| 267 |
+
- **Purpose**: Persistent prediction storage
|
| 268 |
+
- **Credentials**: Set via environment variables (`CHROMA_TENANT`, `CHROMA_DATABASE`, `CHROMA_API_KEY`)
|
| 269 |
+
- **Fallback**: `data/processed/predictions_local.json`
|
| 270 |
+
|
| 271 |
+
### Parquet Files
|
| 272 |
+
- `data/api_data/*.parquet` - Cached API responses
|
| 273 |
+
- `data/api_data/all_games_summary.parquet` - Consolidated game history (41K+ games)
|
| 274 |
+
|
| 275 |
+
### Joblib Files
|
| 276 |
+
- `models/game_predictor.joblib` - Trained ML model
|
| 277 |
+
- `data/processed/game_dataset.joblib` - Processed training data
|
| 278 |
+
|
| 279 |
+
---
|
| 280 |
+
|
| 281 |
+
## 🌐 Frontend Architecture
|
| 282 |
+
|
| 283 |
+
**React + Vite with custom CSS design system.**
|
| 284 |
+
|
| 285 |
+
**Pages:**
|
| 286 |
+
| Page | File | Purpose |
|
| 287 |
+
|------|------|---------|
|
| 288 |
+
| Live Games | `LiveGames.jsx` | Today's games, live scores, predictions |
|
| 289 |
+
| Predictions | `Predictions.jsx` | Upcoming games with predictions |
|
| 290 |
+
| Head to Head | `HeadToHead.jsx` | Compare two teams |
|
| 291 |
+
| Accuracy | `Accuracy.jsx` | Model performance stats |
|
| 292 |
+
| MVP Race | `MvpRace.jsx` | Current MVP standings |
|
| 293 |
+
| Championship | `Championship.jsx` | Championship odds |
|
| 294 |
+
|
| 295 |
+
**Key Frontend Components:**
|
| 296 |
+
- `TeamLogo.jsx` - Official NBA team logos
|
| 297 |
+
- `api.js` - API client with base URL handling
|
| 298 |
+
- `index.css` - Complete design system (27KB)
|
| 299 |
+
|
| 300 |
+
---
|
| 301 |
+
|
| 302 |
+
## 🔧 Configuration (`src/config.py`)
|
| 303 |
+
|
| 304 |
+
**Critical Settings:**
|
| 305 |
+
```python
|
| 306 |
+
# NBA Teams mapping (team_id -> tricode)
|
| 307 |
+
NBA_TEAMS = {1610612737: "ATL", 1610612738: "BOS", ...}
|
| 308 |
+
|
| 309 |
+
# Data paths
|
| 310 |
+
API_CACHE_DIR = Path("data/api_data")
|
| 311 |
+
PROCESSED_DATA_DIR = Path("data/processed")
|
| 312 |
+
MODELS_DIR = Path("models")
|
| 313 |
+
|
| 314 |
+
# Feature engineering
|
| 315 |
+
FEATURE_CONFIG = {
|
| 316 |
+
"rolling_windows": [5, 10, 20],
|
| 317 |
+
"min_games_for_features": 5
|
| 318 |
+
}
|
| 319 |
+
|
| 320 |
+
# ELO system
|
| 321 |
+
ELO_CONFIG = {
|
| 322 |
+
"initial_rating": 1500,
|
| 323 |
+
"k_factor": 20,
|
| 324 |
+
"home_advantage": 100
|
| 325 |
+
}
|
| 326 |
+
```
|
| 327 |
+
|
| 328 |
+
---
|
| 329 |
+
|
| 330 |
+
## ⚠️ Known Issues & Technical Debt
|
| 331 |
+
|
| 332 |
+
1. **ML Model Not Used**: `predict_game()` uses formula, not trained `GamePredictor`
|
| 333 |
+
2. **Season Hardcoding**: Some places use `2025-26` explicitly
|
| 334 |
+
3. **Fallback Data**: Pipeline has hardcoded rosters as backup
|
| 335 |
+
4. **Function Order**: `warm_starter_cache()` must be defined before scheduler calls it
|
| 336 |
+
|
| 337 |
+
---
|
| 338 |
+
|
| 339 |
+
## 🚀 Deployment Notes
|
| 340 |
+
|
| 341 |
+
**Hugging Face Spaces:**
|
| 342 |
+
- Uses persistent `/data` directory for storage
|
| 343 |
+
- Dockerfile copies `models/` and `data/api_data/`
|
| 344 |
+
- Git LFS for large files (`.joblib`, `.parquet`)
|
| 345 |
+
- Port 7860 for HF Spaces
|
| 346 |
+
|
| 347 |
+
**Environment Variables:**
|
| 348 |
+
```
|
| 349 |
+
CHROMA_TENANT, CHROMA_DATABASE, CHROMA_API_KEY # ChromaDB
|
| 350 |
+
NBA_ML_DATA_DIR, NBA_ML_MODELS_DIR # Override paths
|
| 351 |
+
```
|
| 352 |
+
|
| 353 |
+
---
|
| 354 |
+
|
| 355 |
+
## 📋 Quick Reference: Common Tasks
|
| 356 |
+
|
| 357 |
+
**Add new API endpoint:**
|
| 358 |
+
1. Add route in `server.py` (production) AND `api/api.py` (development)
|
| 359 |
+
2. Add frontend call in `web/src/api.js`
|
| 360 |
+
3. Create/update page component in `web/src/pages/`
|
| 361 |
+
|
| 362 |
+
**Modify prediction algorithm:**
|
| 363 |
+
1. Edit `PredictionPipeline.predict_game()` in `prediction_pipeline.py`
|
| 364 |
+
2. Consider blending with `GamePredictor` model
|
| 365 |
+
|
| 366 |
+
**Update ML model:**
|
| 367 |
+
1. Retrain via `ContinuousLearner.retrain_model()`
|
| 368 |
+
2. Or trigger via `POST /api/admin/retrain`
|
| 369 |
+
|
| 370 |
+
**Add new feature:**
|
| 371 |
+
1. Add to `FeatureGenerator` in `feature_engineering.py`
|
| 372 |
+
2. Update preprocessing pipeline
|
| 373 |
+
3. Retrain model
|
| 374 |
+
|
| 375 |
+
---
|
| 376 |
+
|
| 377 |
+
*Last updated: January 2026*
|
explain.md
ADDED
|
@@ -0,0 +1,445 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# NBA Sage - Technical Explanation
|
| 2 |
+
|
| 3 |
+
> **An AI-powered NBA game prediction system with real-time data, machine learning, and a modern web interface.**
|
| 4 |
+
|
| 5 |
+
---
|
| 6 |
+
|
| 7 |
+
## 🎯 What Does This Project Do?
|
| 8 |
+
|
| 9 |
+
NBA Sage is a full-stack application that:
|
| 10 |
+
|
| 11 |
+
1. **Predicts NBA game outcomes** before they happen
|
| 12 |
+
2. **Shows live scores** with real-time updates
|
| 13 |
+
3. **Tracks prediction accuracy** over time
|
| 14 |
+
4. **Calculates MVP race standings** based on current stats
|
| 15 |
+
5. **Estimates championship odds** for all 30 teams
|
| 16 |
+
|
| 17 |
+
---
|
| 18 |
+
|
| 19 |
+
## 🏆 Key Features
|
| 20 |
+
|
| 21 |
+
| Feature | Description |
|
| 22 |
+
|---------|-------------|
|
| 23 |
+
| **Live Game Dashboard** | Real-time scores, game status, win probabilities |
|
| 24 |
+
| **Win Predictions** | Probability % for each team to win |
|
| 25 |
+
| **Starting 5 Lineups** | Projected starters with PPG stats from NBA API |
|
| 26 |
+
| **MVP Race** | Top 10 MVP candidates with scores |
|
| 27 |
+
| **Championship Odds** | All 30 teams ranked by title probability |
|
| 28 |
+
| **Model Accuracy** | Track how well predictions perform over time |
|
| 29 |
+
|
| 30 |
+
---
|
| 31 |
+
|
| 32 |
+
## 🛠️ Technology Stack
|
| 33 |
+
|
| 34 |
+
### Backend (Python)
|
| 35 |
+
| Technology | Purpose |
|
| 36 |
+
|------------|---------|
|
| 37 |
+
| **Flask** | REST API framework |
|
| 38 |
+
| **nba_api** | Official NBA data (stats.nba.com) |
|
| 39 |
+
| **XGBoost + LightGBM** | Machine learning ensemble model |
|
| 40 |
+
| **APScheduler** | Background job scheduling |
|
| 41 |
+
| **ChromaDB Cloud** | Persistent prediction storage |
|
| 42 |
+
| **Pandas/NumPy** | Data processing |
|
| 43 |
+
|
| 44 |
+
### Frontend (React)
|
| 45 |
+
| Technology | Purpose |
|
| 46 |
+
|------------|---------|
|
| 47 |
+
| **React 18** | UI framework |
|
| 48 |
+
| **Vite** | Build tool & dev server |
|
| 49 |
+
| **Custom CSS** | Modern design system |
|
| 50 |
+
|
| 51 |
+
### Infrastructure
|
| 52 |
+
| Technology | Purpose |
|
| 53 |
+
|------------|---------|
|
| 54 |
+
| **Docker** | Container deployment |
|
| 55 |
+
| **Hugging Face Spaces** | Cloud hosting |
|
| 56 |
+
| **Git LFS** | Large file versioning |
|
| 57 |
+
|
| 58 |
+
---
|
| 59 |
+
|
| 60 |
+
## 🔬 How Predictions Work
|
| 61 |
+
|
| 62 |
+
### The Prediction Algorithm
|
| 63 |
+
|
| 64 |
+
Predictions are made using a **multi-factor formula**:
|
| 65 |
+
|
| 66 |
+
```
|
| 67 |
+
Win Probability = Log5 Formula of:
|
| 68 |
+
├── 40% - Current Season Record (Win %)
|
| 69 |
+
├── 30% - Recent Form (Last 10 games performance)
|
| 70 |
+
├── 20% - ELO Rating (Historical team strength)
|
| 71 |
+
└── 10% - Baseline
|
| 72 |
+
|
| 73 |
+
Adjustments Applied:
|
| 74 |
+
├── +3.5% for Home Court Advantage
|
| 75 |
+
└── -2% per Injury Impact Point
|
| 76 |
+
```
|
| 77 |
+
|
| 78 |
+
### ELO Rating System
|
| 79 |
+
|
| 80 |
+
ELO is a chess-inspired rating system adapted for NBA:
|
| 81 |
+
|
| 82 |
+
- **Starting rating**: 1500 (average team)
|
| 83 |
+
- **K-factor**: 20 (how much ratings change per game)
|
| 84 |
+
- **Home advantage**: +100 ELO points equivalent
|
| 85 |
+
- **Season regression**: Ratings regress 25% to mean each season
|
| 86 |
+
|
| 87 |
+
**How it works:**
|
| 88 |
+
- Win against better team → Big ELO gain
|
| 89 |
+
- Win against weaker team → Small ELO gain
|
| 90 |
+
- Lose against better team → Small ELO loss
|
| 91 |
+
- Lose against weaker team → Big ELO loss
|
| 92 |
+
|
| 93 |
+
---
|
| 94 |
+
|
| 95 |
+
## 📊 Data Sources
|
| 96 |
+
|
| 97 |
+
### Real-Time Data
|
| 98 |
+
- **NBA Live API** (`nba_api.live`)
|
| 99 |
+
- Live scores updated every 30 seconds
|
| 100 |
+
- Game status (scheduled, in progress, final)
|
| 101 |
+
- Box scores and player stats
|
| 102 |
+
|
| 103 |
+
### Historical Data
|
| 104 |
+
- **NBA Stats API** (`nba_api.stats`)
|
| 105 |
+
- 23 years of game data (2003-2026)
|
| 106 |
+
- Team statistics (basic, advanced, clutch, hustle)
|
| 107 |
+
- Player statistics
|
| 108 |
+
- Current season stats for predictions
|
| 109 |
+
|
| 110 |
+
### Data Storage
|
| 111 |
+
- **Parquet files**: Cached API responses (~140 files)
|
| 112 |
+
- **ChromaDB Cloud**: Prediction history and accuracy tracking
|
| 113 |
+
- **Joblib files**: Trained ML model and processed datasets
|
| 114 |
+
|
| 115 |
+
---
|
| 116 |
+
|
| 117 |
+
## 🧠 Machine Learning Components
|
| 118 |
+
|
| 119 |
+
### Trained Model: XGBoost + LightGBM Ensemble
|
| 120 |
+
|
| 121 |
+
Two gradient boosting models trained on 41,000+ historical games:
|
| 122 |
+
|
| 123 |
+
```
|
| 124 |
+
Game Features ──┬──► XGBoost (50%) ──┐
|
| 125 |
+
│ │──► Ensemble Prediction
|
| 126 |
+
└──► LightGBM (50%) ─┘
|
| 127 |
+
```
|
| 128 |
+
|
| 129 |
+
**Features Used:**
|
| 130 |
+
- ELO ratings and differentials
|
| 131 |
+
- Rolling averages (5, 10, 20 game windows)
|
| 132 |
+
- Rest days and back-to-back games
|
| 133 |
+
- Home/away status
|
| 134 |
+
- Season record statistics
|
| 135 |
+
|
| 136 |
+
### Training Pipeline
|
| 137 |
+
|
| 138 |
+
```
|
| 139 |
+
Data Collection ──► Feature Engineering ──► Model Training ──► Evaluation
|
| 140 |
+
│ │ │
|
| 141 |
+
▼ ▼ ▼
|
| 142 |
+
NBA API Data ELO Calculation XGBoost+LightGBM
|
| 143 |
+
Era Normalization
|
| 144 |
+
Rolling Windows
|
| 145 |
+
```
|
| 146 |
+
|
| 147 |
+
### Auto-Training System
|
| 148 |
+
|
| 149 |
+
The system automatically retrains itself:
|
| 150 |
+
|
| 151 |
+
1. **Ingests completed games** every hour
|
| 152 |
+
2. **Waits for all daily games** to complete
|
| 153 |
+
3. **Compares new model accuracy** to existing
|
| 154 |
+
4. **Only updates if improved** (prevents regression)
|
| 155 |
+
|
| 156 |
+
---
|
| 157 |
+
|
| 158 |
+
## 🌐 System Architecture
|
| 159 |
+
|
| 160 |
+
```
|
| 161 |
+
┌─────────────────────────────────────────────────────────────────┐
|
| 162 |
+
│ React Frontend │
|
| 163 |
+
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ ��
|
| 164 |
+
│ │LiveGames │ │Predictions│ │MVP Race │ │ Accuracy │ │
|
| 165 |
+
│ └────▲─────┘ └────▲─────┘ └────▲─────┘ └────▲─────┘ │
|
| 166 |
+
└───────┼────────────┼────────────┼────────────┼──────────────────┘
|
| 167 |
+
│ │ │ │
|
| 168 |
+
└────────────┴─────┬──────┴────────────┘
|
| 169 |
+
│ REST API
|
| 170 |
+
▼
|
| 171 |
+
┌─────────────────────────────────────────────────────────────────┐
|
| 172 |
+
│ Flask Server │
|
| 173 |
+
│ ┌────────────────┐ ┌────────────────┐ ┌────────────────┐ │
|
| 174 |
+
│ │ Endpoints │ │ Caching │ │ Scheduler │ │
|
| 175 |
+
│ │ /api/live │ │ In-Memory │ │ APScheduler │ │
|
| 176 |
+
│ │ /api/roster │ │ 1-hour rosters│ │ Auto-retrain │ │
|
| 177 |
+
│ └────────┬───────┘ └────────────────┘ └────────────────┘ │
|
| 178 |
+
│ │ │
|
| 179 |
+
│ ▼ │
|
| 180 |
+
│ ┌────────────────────────────────────────────────────────┐ │
|
| 181 |
+
│ │ Prediction Pipeline │ │
|
| 182 |
+
│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ │
|
| 183 |
+
│ │ │Live Collector│ │Feature Gen │ │ ELO System │ │ │
|
| 184 |
+
│ │ └─────────────┘ └─────────────┘ └─────────────┘ │ │
|
| 185 |
+
│ └────────────────────────────────────────────────────────┘ │
|
| 186 |
+
└─────────────────────────────────────────────────────────────────┘
|
| 187 |
+
│
|
| 188 |
+
▼
|
| 189 |
+
┌─────────────────────────────────────────────────────────────────┐
|
| 190 |
+
│ External Services │
|
| 191 |
+
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
|
| 192 |
+
│ │ NBA API │ │ ChromaDB │ │ Hugging Face│ │
|
| 193 |
+
│ │ stats.nba │ │ Cloud │ │ Spaces │ │
|
| 194 |
+
│ └─────────────┘ └─────────────┘ └─────────────┘ │
|
| 195 |
+
└─────────────────────────────────────────────────────────────────┘
|
| 196 |
+
```
|
| 197 |
+
|
| 198 |
+
---
|
| 199 |
+
|
| 200 |
+
## 📁 Project Structure
|
| 201 |
+
|
| 202 |
+
```
|
| 203 |
+
NBA ML/
|
| 204 |
+
├── server.py # Production server (Hugging Face)
|
| 205 |
+
├── api/api.py # Development server
|
| 206 |
+
│
|
| 207 |
+
├── src/ # Core logic
|
| 208 |
+
│ ├── prediction_pipeline.py # Main orchestrator
|
| 209 |
+
│ ├── feature_engineering.py # ELO + features
|
| 210 |
+
│ ├── data_collector.py # Historical data
|
| 211 |
+
│ ├── live_data_collector.py # Real-time data
|
| 212 |
+
│ ├── prediction_tracker.py # Accuracy tracking
|
| 213 |
+
│ └── models/
|
| 214 |
+
│ └── game_predictor.py # ML model
|
| 215 |
+
│
|
| 216 |
+
├── web/ # React frontend
|
| 217 |
+
│ └── src/
|
| 218 |
+
│ ├── App.jsx
|
| 219 |
+
│ ├── pages/ # UI pages
|
| 220 |
+
│ └── index.css # Design system
|
| 221 |
+
│
|
| 222 |
+
├── data/
|
| 223 |
+
│ └── api_data/ # 140+ parquet files
|
| 224 |
+
│
|
| 225 |
+
└── models/
|
| 226 |
+
└── game_predictor.joblib # Trained model (9.6KB)
|
| 227 |
+
```
|
| 228 |
+
|
| 229 |
+
---
|
| 230 |
+
|
| 231 |
+
## 🚀 Deployment
|
| 232 |
+
|
| 233 |
+
### Local Development
|
| 234 |
+
```bash
|
| 235 |
+
# Backend
|
| 236 |
+
python api/api.py # Runs on localhost:8000
|
| 237 |
+
|
| 238 |
+
# Frontend
|
| 239 |
+
cd web && npm run dev # Runs on localhost:5173
|
| 240 |
+
```
|
| 241 |
+
|
| 242 |
+
### Production (Hugging Face Spaces)
|
| 243 |
+
```bash
|
| 244 |
+
# Docker container
|
| 245 |
+
python server.py # Serves both API + React on port 7860
|
| 246 |
+
```
|
| 247 |
+
|
| 248 |
+
---
|
| 249 |
+
|
| 250 |
+
## 📈 Performance & Accuracy
|
| 251 |
+
|
| 252 |
+
### Prediction Accuracy
|
| 253 |
+
- **Overall**: Tracked via ChromaDB Cloud
|
| 254 |
+
- **By Confidence**: High/Medium/Low confidence splits
|
| 255 |
+
- **By Team**: Per-team prediction accuracy
|
| 256 |
+
|
| 257 |
+
### Speed Optimizations
|
| 258 |
+
- **In-memory caching**: Roster data cached for 1 hour
|
| 259 |
+
- **Startup warming**: All 30 teams pre-loaded on server start
|
| 260 |
+
- **Background refresh**: Cache updated every 2 hours
|
| 261 |
+
|
| 262 |
+
---
|
| 263 |
+
|
| 264 |
+
## 🔮 Future Improvements
|
| 265 |
+
|
| 266 |
+
1. **Integrate ML model** into live predictions (currently formula-based)
|
| 267 |
+
2. **Add player-level features** (injuries, rest days per player)
|
| 268 |
+
3. **Implement spread predictions** (margin of victory)
|
| 269 |
+
4. **Add playoff predictions** with series outcomes
|
| 270 |
+
|
| 271 |
+
---
|
| 272 |
+
|
| 273 |
+
## 📊 Stats at a Glance
|
| 274 |
+
|
| 275 |
+
| Metric | Value |
|
| 276 |
+
|--------|-------|
|
| 277 |
+
| Historical games | 41,000+ |
|
| 278 |
+
| Seasons covered | 23 (2003-2026) |
|
| 279 |
+
| Teams tracked | 30 |
|
| 280 |
+
| ML model type | XGBoost + LightGBM |
|
| 281 |
+
| API endpoints | 10+ |
|
| 282 |
+
| Frontend pages | 6 |
|
| 283 |
+
---
|
| 284 |
+
|
| 285 |
+
## 📋 Complete ML Feature List (90+ Features)
|
| 286 |
+
|
| 287 |
+
The model uses approximately **90 features** organized into these categories:
|
| 288 |
+
|
| 289 |
+
### 1️⃣ ELO Rating Features (5 features)
|
| 290 |
+
| Feature | Description |
|
| 291 |
+
|---------|-------------|
|
| 292 |
+
| `team_elo` | Team's current ELO rating |
|
| 293 |
+
| `opponent_elo` | Opponent's current ELO rating |
|
| 294 |
+
| `elo_diff` | Difference between team and opponent ELO |
|
| 295 |
+
| `elo_win_prob` | Expected win probability from ELO |
|
| 296 |
+
| `home_elo_boost` | ELO boost for home court (100 points) |
|
| 297 |
+
|
| 298 |
+
### 2️⃣ Basic Stats - Rolling Averages (21 features)
|
| 299 |
+
For each of 7 stats × 3 windows (5, 10, 20 games):
|
| 300 |
+
|
| 301 |
+
| Base Stat | Windows |
|
| 302 |
+
|-----------|---------|
|
| 303 |
+
| `PTS` (Points) | `PTS_last5`, `PTS_last10`, `PTS_last20` |
|
| 304 |
+
| `AST` (Assists) | `AST_last5`, `AST_last10`, `AST_last20` |
|
| 305 |
+
| `REB` (Rebounds) | `REB_last5`, `REB_last10`, `REB_last20` |
|
| 306 |
+
| `FG_PCT` (Field Goal %) | `FG_PCT_last5`, `FG_PCT_last10`, `FG_PCT_last20` |
|
| 307 |
+
| `FG3_PCT` (3-Point %) | `FG3_PCT_last5`, `FG3_PCT_last10`, `FG3_PCT_last20` |
|
| 308 |
+
| `FT_PCT` (Free Throw %) | `FT_PCT_last5`, `FT_PCT_last10`, `FT_PCT_last20` |
|
| 309 |
+
| `PLUS_MINUS` (Point Diff) | `PLUS_MINUS_last5`, `PLUS_MINUS_last10`, `PLUS_MINUS_last20` |
|
| 310 |
+
|
| 311 |
+
### 3️⃣ Season Statistics (9 features)
|
| 312 |
+
| Feature | Description |
|
| 313 |
+
|---------|-------------|
|
| 314 |
+
| `PTS_season_avg` | Season average points |
|
| 315 |
+
| `AST_season_avg` | Season average assists |
|
| 316 |
+
| `REB_season_avg` | Season average rebounds |
|
| 317 |
+
| `FG_PCT_season_avg` | Season field goal % |
|
| 318 |
+
| `FG3_PCT_season_avg` | Season 3-point % |
|
| 319 |
+
| `FT_PCT_season_avg` | Season free throw % |
|
| 320 |
+
| `PLUS_MINUS_season_avg` | Season point differential |
|
| 321 |
+
| `win_pct_season` | Season win percentage |
|
| 322 |
+
| `games_played` | Games played in season |
|
| 323 |
+
|
| 324 |
+
### 4️⃣ Defensive Features (4 features)
|
| 325 |
+
| Feature | Description |
|
| 326 |
+
|---------|-------------|
|
| 327 |
+
| `STL_last10` | Steals per game (last 10) |
|
| 328 |
+
| `BLK_last10` | Blocks per game (last 10) |
|
| 329 |
+
| `DREB_last10` | Defensive rebounds (last 10) |
|
| 330 |
+
| `pts_allowed_last10` | Points allowed (last 10) |
|
| 331 |
+
|
| 332 |
+
### 5️⃣ Momentum Features (6 features)
|
| 333 |
+
| Feature | Description |
|
| 334 |
+
|---------|-------------|
|
| 335 |
+
| `wins_last5` | Wins in last 5 games (0-5) |
|
| 336 |
+
| `wins_last10` | Wins in last 10 games (0-10) |
|
| 337 |
+
| `hot_streak` | 1 if 4+ wins in last 5 |
|
| 338 |
+
| `cold_streak` | 1 if 1 or fewer wins in last 5 |
|
| 339 |
+
| `plus_minus_last5` | Point differential trend |
|
| 340 |
+
| `form_trend` | Comparison of last 3 vs previous 3 |
|
| 341 |
+
|
| 342 |
+
### 6️⃣ Rest & Fatigue Features (4 features)
|
| 343 |
+
| Feature | Description |
|
| 344 |
+
|---------|-------------|
|
| 345 |
+
| `days_rest` | Days since last game |
|
| 346 |
+
| `back_to_back` | 1 if playing consecutive days |
|
| 347 |
+
| `well_rested` | 1 if 3+ days rest |
|
| 348 |
+
| `games_last_week` | Games played in last 7 days |
|
| 349 |
+
|
| 350 |
+
### 7️⃣ Form Index Features (3 features)
|
| 351 |
+
| Feature | Description |
|
| 352 |
+
|---------|-------------|
|
| 353 |
+
| `form_index` | Exponentially-weighted recent performance (0-1) |
|
| 354 |
+
| `form_trend` | Trend direction (improving/declining) |
|
| 355 |
+
| `form_plus_minus` | Weighted point differential |
|
| 356 |
+
|
| 357 |
+
### 8️⃣ Basic Stat Columns (17 raw features)
|
| 358 |
+
```python
|
| 359 |
+
BASIC_STATS = [
|
| 360 |
+
"PTS", "AST", "REB", "STL", "BLK", "TOV",
|
| 361 |
+
"FGM", "FGA", "FG_PCT",
|
| 362 |
+
"FG3M", "FG3A", "FG3_PCT",
|
| 363 |
+
"FTM", "FTA", "FT_PCT",
|
| 364 |
+
"OREB", "DREB"
|
| 365 |
+
]
|
| 366 |
+
```
|
| 367 |
+
|
| 368 |
+
### 9️⃣ Advanced Team Stats (11 features)
|
| 369 |
+
```python
|
| 370 |
+
ADVANCED_STATS = [
|
| 371 |
+
"E_OFF_RATING", # Offensive Rating
|
| 372 |
+
"E_DEF_RATING", # Defensive Rating
|
| 373 |
+
"E_NET_RATING", # Net Rating
|
| 374 |
+
"E_PACE", # Pace (possessions per game)
|
| 375 |
+
"E_AST_RATIO", # Assist Ratio
|
| 376 |
+
"E_OREB_PCT", # Offensive Rebound %
|
| 377 |
+
"E_DREB_PCT", # Defensive Rebound %
|
| 378 |
+
"E_REB_PCT", # Total Rebound %
|
| 379 |
+
"E_TM_TOV_PCT", # Team Turnover %
|
| 380 |
+
"E_EFG_PCT", # Effective FG%
|
| 381 |
+
"E_TS_PCT" # True Shooting %
|
| 382 |
+
]
|
| 383 |
+
```
|
| 384 |
+
|
| 385 |
+
### 🔟 Clutch Stats (4 features)
|
| 386 |
+
```python
|
| 387 |
+
CLUTCH_STATS = [
|
| 388 |
+
"CLUTCH_PTS", # Points in clutch time
|
| 389 |
+
"CLUTCH_FG_PCT", # FG% in clutch
|
| 390 |
+
"CLUTCH_FG3_PCT", # 3PT% in clutch
|
| 391 |
+
"CLUTCH_PLUS_MINUS" # +/- in clutch
|
| 392 |
+
]
|
| 393 |
+
```
|
| 394 |
+
|
| 395 |
+
### 1️⃣1️⃣ Hustle Stats (5 features)
|
| 396 |
+
```python
|
| 397 |
+
HUSTLE_STATS = [
|
| 398 |
+
"DEFLECTIONS", # Passes deflected
|
| 399 |
+
"LOOSE_BALLS_RECOVERED", # Loose balls recovered
|
| 400 |
+
"CHARGES_DRAWN", # Offensive fouls drawn
|
| 401 |
+
"CONTESTED_SHOTS", # Shots contested
|
| 402 |
+
"SCREEN_ASSISTS" # Screen assists
|
| 403 |
+
]
|
| 404 |
+
```
|
| 405 |
+
|
| 406 |
+
### 1️⃣2️⃣ Top Player Stats (6 features)
|
| 407 |
+
| Feature | Description |
|
| 408 |
+
|---------|-------------|
|
| 409 |
+
| `top_players_avg_pts` | Avg points of top 5 players |
|
| 410 |
+
| `top_players_avg_ast` | Avg assists of top 5 players |
|
| 411 |
+
| `top_players_avg_reb` | Avg rebounds of top 5 players |
|
| 412 |
+
| `top_players_avg_stl` | Avg steals of top 5 players |
|
| 413 |
+
| `top_players_avg_blk` | Avg blocks of top 5 players |
|
| 414 |
+
| `star_concentration` | % of scoring from top player |
|
| 415 |
+
|
| 416 |
+
### 1️⃣3️⃣ Game Context (1 feature)
|
| 417 |
+
| Feature | Description |
|
| 418 |
+
|---------|-------------|
|
| 419 |
+
| `is_home` | 1 if home team, 0 if away |
|
| 420 |
+
|
| 421 |
+
---
|
| 422 |
+
|
| 423 |
+
## 📊 Feature Summary
|
| 424 |
+
|
| 425 |
+
| Category | Feature Count |
|
| 426 |
+
|----------|---------------|
|
| 427 |
+
| ELO Ratings | 5 |
|
| 428 |
+
| Rolling Averages (5/10/20) | 21 |
|
| 429 |
+
| Season Statistics | 9 |
|
| 430 |
+
| Defensive Stats | 4 |
|
| 431 |
+
| Momentum Features | 6 |
|
| 432 |
+
| Rest/Fatigue | 4 |
|
| 433 |
+
| Form Index | 3 |
|
| 434 |
+
| Advanced Team Stats | 11 |
|
| 435 |
+
| Clutch Stats | 4 |
|
| 436 |
+
| Hustle Stats | 5 |
|
| 437 |
+
| Top Player Stats | 6 |
|
| 438 |
+
| Game Context | 1 |
|
| 439 |
+
| **TOTAL** | **~79 core features** |
|
| 440 |
+
|
| 441 |
+
*Plus Z-score normalized versions of stats for era adjustment = **90+ total features***
|
| 442 |
+
|
| 443 |
+
---
|
| 444 |
+
|
| 445 |
+
*Built with Python, React, and a passion for basketball analytics* 🏀
|
generate_starters.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Generate starters cache from NBA API"""
|
| 2 |
+
from nba_api.stats.endpoints import leaguedashplayerstats
|
| 3 |
+
import pandas as pd
|
| 4 |
+
import json
|
| 5 |
+
import sys
|
| 6 |
+
|
| 7 |
+
# Fix encoding for Windows console
|
| 8 |
+
sys.stdout.reconfigure(encoding='utf-8', errors='replace')
|
| 9 |
+
|
| 10 |
+
print("Fetching player stats from NBA API...")
|
| 11 |
+
stats = leaguedashplayerstats.LeagueDashPlayerStats(
|
| 12 |
+
season='2025-26',
|
| 13 |
+
per_mode_detailed='PerGame',
|
| 14 |
+
timeout=120
|
| 15 |
+
)
|
| 16 |
+
df = stats.get_data_frames()[0]
|
| 17 |
+
print(f'Got {len(df)} players')
|
| 18 |
+
|
| 19 |
+
# Build starters for each team (top 5 by minutes)
|
| 20 |
+
all_starters = {}
|
| 21 |
+
teams = df['TEAM_ABBREVIATION'].unique()
|
| 22 |
+
|
| 23 |
+
for team in sorted(teams):
|
| 24 |
+
team_players = df[df['TEAM_ABBREVIATION'] == team].sort_values('MIN', ascending=False).head(5)
|
| 25 |
+
starters = []
|
| 26 |
+
for _, p in team_players.iterrows():
|
| 27 |
+
# Infer position from stats
|
| 28 |
+
reb = float(p.get('REB', 0) or 0)
|
| 29 |
+
ast = float(p.get('AST', 0) or 0)
|
| 30 |
+
blk = float(p.get('BLK', 0) or 0)
|
| 31 |
+
if ast > 5 and reb < 6:
|
| 32 |
+
pos = "G"
|
| 33 |
+
elif reb > 8 or blk > 1.5:
|
| 34 |
+
pos = "C"
|
| 35 |
+
elif reb > 5:
|
| 36 |
+
pos = "F"
|
| 37 |
+
else:
|
| 38 |
+
pos = "G-F"
|
| 39 |
+
|
| 40 |
+
starters.append({
|
| 41 |
+
'name': p['PLAYER_NAME'],
|
| 42 |
+
'position': pos,
|
| 43 |
+
'pts': round(float(p['PTS']), 1),
|
| 44 |
+
'reb': round(float(p['REB']), 1),
|
| 45 |
+
'ast': round(float(p['AST']), 1),
|
| 46 |
+
'min': round(float(p['MIN']), 1)
|
| 47 |
+
})
|
| 48 |
+
all_starters[team] = starters
|
| 49 |
+
print(f'{team}: {len(starters)} players')
|
| 50 |
+
|
| 51 |
+
# Save to file
|
| 52 |
+
output_path = 'data/api_data/starters_cache.json'
|
| 53 |
+
with open(output_path, 'w', encoding='utf-8') as f:
|
| 54 |
+
json.dump(all_starters, f, indent=2, ensure_ascii=False)
|
| 55 |
+
print(f'\nSaved {len(all_starters)} teams to {output_path}')
|
server.py
CHANGED
|
@@ -28,8 +28,8 @@ from apscheduler.triggers.cron import CronTrigger
|
|
| 28 |
# CACHE CONFIGURATION - For lightning-fast responses
|
| 29 |
# =============================================================================
|
| 30 |
cache = {
|
| 31 |
-
"mvp": {"data": None, "timestamp": None, "ttl":
|
| 32 |
-
"championship": {"data": None, "timestamp": None, "ttl":
|
| 33 |
"teams": {"data": None, "timestamp": None, "ttl": 3600}, # 1 hour cache
|
| 34 |
"rosters": {}, # Per-team roster cache: {team_abbrev: {"data": [...], "timestamp": datetime}}
|
| 35 |
"roster_ttl": 3600, # 1 hour cache for rosters
|
|
@@ -666,6 +666,100 @@ def predict_game():
|
|
| 666 |
except Exception as e:
|
| 667 |
return jsonify({"error": str(e)}), 500
|
| 668 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 669 |
@app.route("/api/accuracy")
|
| 670 |
def get_accuracy():
|
| 671 |
"""Get comprehensive model accuracy statistics."""
|
|
@@ -721,6 +815,128 @@ def get_accuracy():
|
|
| 721 |
logger.error(f"Error in get_accuracy: {e}")
|
| 722 |
return jsonify({"stats": {}, "recent_predictions": [], "error": str(e)})
|
| 723 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 724 |
@app.route("/api/mvp")
|
| 725 |
def get_mvp_race():
|
| 726 |
"""Get current MVP race standings with caching."""
|
|
|
|
| 28 |
# CACHE CONFIGURATION - For lightning-fast responses
|
| 29 |
# =============================================================================
|
| 30 |
cache = {
|
| 31 |
+
"mvp": {"data": None, "timestamp": None, "ttl": 86400}, # 24 hours - daily refresh
|
| 32 |
+
"championship": {"data": None, "timestamp": None, "ttl": 86400}, # 24 hours - daily refresh
|
| 33 |
"teams": {"data": None, "timestamp": None, "ttl": 3600}, # 1 hour cache
|
| 34 |
"rosters": {}, # Per-team roster cache: {team_abbrev: {"data": [...], "timestamp": datetime}}
|
| 35 |
"roster_ttl": 3600, # 1 hour cache for rosters
|
|
|
|
| 666 |
except Exception as e:
|
| 667 |
return jsonify({"error": str(e)}), 500
|
| 668 |
|
| 669 |
+
@app.route("/api/team-stats")
|
| 670 |
+
def get_team_stats():
|
| 671 |
+
"""Get detailed team statistics for head-to-head comparison."""
|
| 672 |
+
team = request.args.get("team", "").upper()
|
| 673 |
+
|
| 674 |
+
if not team:
|
| 675 |
+
return jsonify({"error": "Missing team parameter"}), 400
|
| 676 |
+
|
| 677 |
+
try:
|
| 678 |
+
from nba_api.stats.endpoints import teamgamelog, commonteamroster
|
| 679 |
+
from nba_api.stats.static import teams as nba_teams
|
| 680 |
+
import time
|
| 681 |
+
|
| 682 |
+
# Find team ID
|
| 683 |
+
team_info = next((t for t in nba_teams.get_teams() if t['abbreviation'] == team), None)
|
| 684 |
+
if not team_info:
|
| 685 |
+
return jsonify({"error": f"Team not found: {team}"}), 404
|
| 686 |
+
|
| 687 |
+
team_id = team_info['id']
|
| 688 |
+
|
| 689 |
+
# Get team game log for current season
|
| 690 |
+
time.sleep(0.6) # Rate limit
|
| 691 |
+
game_log = teamgamelog.TeamGameLog(team_id=team_id, season='2024-25')
|
| 692 |
+
games_df = game_log.get_data_frames()[0]
|
| 693 |
+
|
| 694 |
+
if len(games_df) == 0:
|
| 695 |
+
return jsonify({
|
| 696 |
+
"team": team,
|
| 697 |
+
"record": {"wins": 0, "losses": 0},
|
| 698 |
+
"stats": {},
|
| 699 |
+
"recent_form": [],
|
| 700 |
+
"key_players": []
|
| 701 |
+
})
|
| 702 |
+
|
| 703 |
+
# Calculate stats
|
| 704 |
+
wins = len(games_df[games_df['WL'] == 'W'])
|
| 705 |
+
losses = len(games_df[games_df['WL'] == 'L'])
|
| 706 |
+
|
| 707 |
+
# Scoring stats
|
| 708 |
+
avg_pts = games_df['PTS'].mean()
|
| 709 |
+
avg_pts_allowed = games_df['PTS'].mean() - games_df['PLUS_MINUS'].mean() # Approximate
|
| 710 |
+
avg_reb = games_df['REB'].mean()
|
| 711 |
+
avg_ast = games_df['AST'].mean()
|
| 712 |
+
avg_fg_pct = games_df['FG_PCT'].mean() * 100
|
| 713 |
+
avg_fg3_pct = games_df['FG3_PCT'].mean() * 100
|
| 714 |
+
|
| 715 |
+
# Recent form (last 5 games)
|
| 716 |
+
recent_games = games_df.head(5)
|
| 717 |
+
recent_form = []
|
| 718 |
+
for _, game in recent_games.iterrows():
|
| 719 |
+
recent_form.append({
|
| 720 |
+
"date": game['GAME_DATE'],
|
| 721 |
+
"opponent": game['MATCHUP'].split()[-1],
|
| 722 |
+
"result": game['WL'],
|
| 723 |
+
"score": f"{int(game['PTS'])} pts",
|
| 724 |
+
"plus_minus": int(game['PLUS_MINUS'])
|
| 725 |
+
})
|
| 726 |
+
|
| 727 |
+
# Get key players from starters cache
|
| 728 |
+
key_players = []
|
| 729 |
+
if team in starters_cache:
|
| 730 |
+
starters = starters_cache[team].get('starters', [])[:3] # Top 3 by minutes
|
| 731 |
+
key_players = [{"name": p['name'], "position": p.get('position', 'G')} for p in starters]
|
| 732 |
+
|
| 733 |
+
return jsonify({
|
| 734 |
+
"team": team,
|
| 735 |
+
"record": {
|
| 736 |
+
"wins": wins,
|
| 737 |
+
"losses": losses,
|
| 738 |
+
"pct": round(wins / max(wins + losses, 1), 3)
|
| 739 |
+
},
|
| 740 |
+
"stats": {
|
| 741 |
+
"ppg": round(avg_pts, 1),
|
| 742 |
+
"opp_ppg": round(avg_pts_allowed, 1),
|
| 743 |
+
"rpg": round(avg_reb, 1),
|
| 744 |
+
"apg": round(avg_ast, 1),
|
| 745 |
+
"fg_pct": round(avg_fg_pct, 1),
|
| 746 |
+
"fg3_pct": round(avg_fg3_pct, 1)
|
| 747 |
+
},
|
| 748 |
+
"recent_form": recent_form,
|
| 749 |
+
"key_players": key_players
|
| 750 |
+
})
|
| 751 |
+
|
| 752 |
+
except Exception as e:
|
| 753 |
+
logger.error(f"Error getting team stats for {team}: {e}")
|
| 754 |
+
# Return fallback data
|
| 755 |
+
return jsonify({
|
| 756 |
+
"team": team,
|
| 757 |
+
"record": {"wins": 0, "losses": 0, "pct": 0},
|
| 758 |
+
"stats": {"ppg": 0, "opp_ppg": 0, "rpg": 0, "apg": 0, "fg_pct": 0, "fg3_pct": 0},
|
| 759 |
+
"recent_form": [],
|
| 760 |
+
"key_players": []
|
| 761 |
+
})
|
| 762 |
+
|
| 763 |
@app.route("/api/accuracy")
|
| 764 |
def get_accuracy():
|
| 765 |
"""Get comprehensive model accuracy statistics."""
|
|
|
|
| 815 |
logger.error(f"Error in get_accuracy: {e}")
|
| 816 |
return jsonify({"stats": {}, "recent_predictions": [], "error": str(e)})
|
| 817 |
|
| 818 |
+
@app.route("/api/analytics")
|
| 819 |
+
def get_analytics():
|
| 820 |
+
"""Get analytics data for charts and visualizations."""
|
| 821 |
+
if not pipeline:
|
| 822 |
+
return jsonify({"error": "Pipeline not ready"})
|
| 823 |
+
|
| 824 |
+
try:
|
| 825 |
+
# Get prediction history
|
| 826 |
+
recent = pipeline.prediction_tracker.get_recent_predictions(100)
|
| 827 |
+
completed = [p for p in recent if p.get("is_correct", -1) != -1]
|
| 828 |
+
|
| 829 |
+
# 1. Accuracy Trend (last 7 days)
|
| 830 |
+
from collections import defaultdict
|
| 831 |
+
daily_stats = defaultdict(lambda: {"correct": 0, "total": 0})
|
| 832 |
+
|
| 833 |
+
for pred in completed:
|
| 834 |
+
date = pred.get("game_date", "")[:10] # YYYY-MM-DD
|
| 835 |
+
if date:
|
| 836 |
+
daily_stats[date]["total"] += 1
|
| 837 |
+
if pred.get("is_correct") == 1:
|
| 838 |
+
daily_stats[date]["correct"] += 1
|
| 839 |
+
|
| 840 |
+
accuracy_trend = []
|
| 841 |
+
for date in sorted(daily_stats.keys())[-7:]:
|
| 842 |
+
stats = daily_stats[date]
|
| 843 |
+
acc = (stats["correct"] / stats["total"] * 100) if stats["total"] > 0 else 0
|
| 844 |
+
accuracy_trend.append({
|
| 845 |
+
"date": date[5:], # MM-DD format
|
| 846 |
+
"accuracy": round(acc, 1),
|
| 847 |
+
"predictions": stats["total"]
|
| 848 |
+
})
|
| 849 |
+
|
| 850 |
+
# 2. Accuracy by Team
|
| 851 |
+
team_stats = defaultdict(lambda: {"correct": 0, "total": 0})
|
| 852 |
+
for pred in completed:
|
| 853 |
+
winner = pred.get("predicted_winner", "")
|
| 854 |
+
if winner:
|
| 855 |
+
team_stats[winner]["total"] += 1
|
| 856 |
+
if pred.get("is_correct") == 1:
|
| 857 |
+
team_stats[winner]["correct"] += 1
|
| 858 |
+
|
| 859 |
+
team_accuracy = []
|
| 860 |
+
for team, stats in sorted(team_stats.items(), key=lambda x: x[1]["total"], reverse=True)[:10]:
|
| 861 |
+
acc = (stats["correct"] / stats["total"] * 100) if stats["total"] > 0 else 0
|
| 862 |
+
team_accuracy.append({
|
| 863 |
+
"team": team,
|
| 864 |
+
"correct": stats["correct"],
|
| 865 |
+
"total": stats["total"],
|
| 866 |
+
"accuracy": round(acc, 1)
|
| 867 |
+
})
|
| 868 |
+
|
| 869 |
+
# 3. Confidence Distribution
|
| 870 |
+
high = medium = low = 0
|
| 871 |
+
for pred in completed:
|
| 872 |
+
conf = max(pred.get("home_win_prob", 0.5), pred.get("away_win_prob", 0.5))
|
| 873 |
+
if conf > 0.7:
|
| 874 |
+
high += 1
|
| 875 |
+
elif conf > 0.6:
|
| 876 |
+
medium += 1
|
| 877 |
+
else:
|
| 878 |
+
low += 1
|
| 879 |
+
|
| 880 |
+
confidence_distribution = [
|
| 881 |
+
{"name": "High (>70%)", "value": high, "color": "#4ade80"},
|
| 882 |
+
{"name": "Medium (60-70%)", "value": medium, "color": "#facc15"},
|
| 883 |
+
{"name": "Low (<60%)", "value": low, "color": "#f87171"},
|
| 884 |
+
]
|
| 885 |
+
|
| 886 |
+
# 4. Calibration Data
|
| 887 |
+
calibration_buckets = defaultdict(lambda: {"correct": 0, "total": 0})
|
| 888 |
+
for pred in completed:
|
| 889 |
+
conf = max(pred.get("home_win_prob", 0.5), pred.get("away_win_prob", 0.5))
|
| 890 |
+
bucket = int(conf * 100 // 5) * 5 # 5% buckets
|
| 891 |
+
if 55 <= bucket <= 85:
|
| 892 |
+
calibration_buckets[bucket]["total"] += 1
|
| 893 |
+
if pred.get("is_correct") == 1:
|
| 894 |
+
calibration_buckets[bucket]["correct"] += 1
|
| 895 |
+
|
| 896 |
+
calibration = []
|
| 897 |
+
for bucket in sorted(calibration_buckets.keys()):
|
| 898 |
+
stats = calibration_buckets[bucket]
|
| 899 |
+
actual = (stats["correct"] / stats["total"] * 100) if stats["total"] > 0 else bucket
|
| 900 |
+
calibration.append({
|
| 901 |
+
"predicted": bucket,
|
| 902 |
+
"actual": round(actual, 1)
|
| 903 |
+
})
|
| 904 |
+
|
| 905 |
+
# 5. Overall Stats
|
| 906 |
+
correct_count = len([p for p in completed if p.get("is_correct") == 1])
|
| 907 |
+
total_count = len(completed)
|
| 908 |
+
overall = {
|
| 909 |
+
"total_predictions": total_count,
|
| 910 |
+
"correct": correct_count,
|
| 911 |
+
"accuracy": round(correct_count / total_count * 100, 1) if total_count > 0 else 0,
|
| 912 |
+
"avg_confidence": round(sum(max(p.get("home_win_prob", 0.5), p.get("away_win_prob", 0.5))
|
| 913 |
+
for p in completed) / len(completed) * 100, 1) if completed else 0
|
| 914 |
+
}
|
| 915 |
+
|
| 916 |
+
# 6. Recent Predictions for table
|
| 917 |
+
recent_display = []
|
| 918 |
+
for pred in completed[:10]:
|
| 919 |
+
recent_display.append({
|
| 920 |
+
"date": pred.get("game_date", "")[:10],
|
| 921 |
+
"matchup": f"{pred.get('away_team', '?')} @ {pred.get('home_team', '?')}",
|
| 922 |
+
"prediction": pred.get("predicted_winner", "?"),
|
| 923 |
+
"confidence": round(max(pred.get("home_win_prob", 0.5), pred.get("away_win_prob", 0.5)) * 100, 0),
|
| 924 |
+
"correct": pred.get("is_correct") == 1
|
| 925 |
+
})
|
| 926 |
+
|
| 927 |
+
return jsonify({
|
| 928 |
+
"accuracy_trend": accuracy_trend,
|
| 929 |
+
"team_accuracy": team_accuracy,
|
| 930 |
+
"confidence_distribution": confidence_distribution,
|
| 931 |
+
"calibration": calibration,
|
| 932 |
+
"overall": overall,
|
| 933 |
+
"recent_predictions": recent_display
|
| 934 |
+
})
|
| 935 |
+
|
| 936 |
+
except Exception as e:
|
| 937 |
+
logger.error(f"Error in get_analytics: {e}")
|
| 938 |
+
return jsonify({"error": str(e)})
|
| 939 |
+
|
| 940 |
@app.route("/api/mvp")
|
| 941 |
def get_mvp_race():
|
| 942 |
"""Get current MVP race standings with caching."""
|
src/feature_engineering.py
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
|
|
| 1 |
"""
|
| 2 |
NBA ML Prediction System - Comprehensive Feature Engineering
|
| 3 |
=============================================================
|
|
|
|
| 1 |
+
|
| 2 |
"""
|
| 3 |
NBA ML Prediction System - Comprehensive Feature Engineering
|
| 4 |
=============================================================
|
web/package-lock.json
CHANGED
|
@@ -9,7 +9,8 @@
|
|
| 9 |
"version": "0.0.0",
|
| 10 |
"dependencies": {
|
| 11 |
"react": "^19.2.0",
|
| 12 |
-
"react-dom": "^19.2.0"
|
|
|
|
| 13 |
},
|
| 14 |
"devDependencies": {
|
| 15 |
"@eslint/js": "^9.39.1",
|
|
@@ -1006,6 +1007,42 @@
|
|
| 1006 |
"@jridgewell/sourcemap-codec": "^1.4.14"
|
| 1007 |
}
|
| 1008 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1009 |
"node_modules/@rolldown/pluginutils": {
|
| 1010 |
"version": "1.0.0-beta.53",
|
| 1011 |
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.53.tgz",
|
|
@@ -1363,6 +1400,18 @@
|
|
| 1363 |
"win32"
|
| 1364 |
]
|
| 1365 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1366 |
"node_modules/@types/babel__core": {
|
| 1367 |
"version": "7.20.5",
|
| 1368 |
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
|
|
@@ -1408,6 +1457,69 @@
|
|
| 1408 |
"@babel/types": "^7.28.2"
|
| 1409 |
}
|
| 1410 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1411 |
"node_modules/@types/estree": {
|
| 1412 |
"version": "1.0.8",
|
| 1413 |
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
|
@@ -1426,7 +1538,7 @@
|
|
| 1426 |
"version": "19.2.8",
|
| 1427 |
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.8.tgz",
|
| 1428 |
"integrity": "sha512-3MbSL37jEchWZz2p2mjntRZtPt837ij10ApxKfgmXCTuHWagYg7iA5bqPw6C8BMPfwidlvfPI/fxOc42HLhcyg==",
|
| 1429 |
-
"
|
| 1430 |
"license": "MIT",
|
| 1431 |
"dependencies": {
|
| 1432 |
"csstype": "^3.2.2"
|
|
@@ -1442,6 +1554,12 @@
|
|
| 1442 |
"@types/react": "^19.2.0"
|
| 1443 |
}
|
| 1444 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1445 |
"node_modules/@vitejs/plugin-react": {
|
| 1446 |
"version": "5.1.2",
|
| 1447 |
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.2.tgz",
|
|
@@ -1636,6 +1754,15 @@
|
|
| 1636 |
"url": "https://github.com/chalk/chalk?sponsor=1"
|
| 1637 |
}
|
| 1638 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1639 |
"node_modules/color-convert": {
|
| 1640 |
"version": "2.0.1",
|
| 1641 |
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
|
@@ -1689,9 +1816,130 @@
|
|
| 1689 |
"version": "3.2.3",
|
| 1690 |
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
| 1691 |
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
| 1692 |
-
"
|
| 1693 |
"license": "MIT"
|
| 1694 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1695 |
"node_modules/debug": {
|
| 1696 |
"version": "4.4.3",
|
| 1697 |
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
|
@@ -1710,6 +1958,12 @@
|
|
| 1710 |
}
|
| 1711 |
}
|
| 1712 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1713 |
"node_modules/deep-is": {
|
| 1714 |
"version": "0.1.4",
|
| 1715 |
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
|
|
@@ -1724,6 +1978,16 @@
|
|
| 1724 |
"dev": true,
|
| 1725 |
"license": "ISC"
|
| 1726 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1727 |
"node_modules/esbuild": {
|
| 1728 |
"version": "0.27.2",
|
| 1729 |
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz",
|
|
@@ -1973,6 +2237,12 @@
|
|
| 1973 |
"node": ">=0.10.0"
|
| 1974 |
}
|
| 1975 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1976 |
"node_modules/fast-deep-equal": {
|
| 1977 |
"version": "3.1.3",
|
| 1978 |
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
|
@@ -2151,6 +2421,16 @@
|
|
| 2151 |
"node": ">= 4"
|
| 2152 |
}
|
| 2153 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2154 |
"node_modules/import-fresh": {
|
| 2155 |
"version": "3.3.1",
|
| 2156 |
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
|
@@ -2178,6 +2458,15 @@
|
|
| 2178 |
"node": ">=0.8.19"
|
| 2179 |
}
|
| 2180 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2181 |
"node_modules/is-extglob": {
|
| 2182 |
"version": "2.1.1",
|
| 2183 |
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
|
|
@@ -2558,6 +2847,36 @@
|
|
| 2558 |
"react": "^19.2.3"
|
| 2559 |
}
|
| 2560 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2561 |
"node_modules/react-refresh": {
|
| 2562 |
"version": "0.18.0",
|
| 2563 |
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz",
|
|
@@ -2568,6 +2887,57 @@
|
|
| 2568 |
"node": ">=0.10.0"
|
| 2569 |
}
|
| 2570 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2571 |
"node_modules/resolve-from": {
|
| 2572 |
"version": "4.0.0",
|
| 2573 |
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
|
|
@@ -2698,6 +3068,12 @@
|
|
| 2698 |
"node": ">=8"
|
| 2699 |
}
|
| 2700 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2701 |
"node_modules/tinyglobby": {
|
| 2702 |
"version": "0.2.15",
|
| 2703 |
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
|
|
@@ -2769,6 +3145,37 @@
|
|
| 2769 |
"punycode": "^2.1.0"
|
| 2770 |
}
|
| 2771 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2772 |
"node_modules/vite": {
|
| 2773 |
"version": "7.3.1",
|
| 2774 |
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
|
|
|
|
| 9 |
"version": "0.0.0",
|
| 10 |
"dependencies": {
|
| 11 |
"react": "^19.2.0",
|
| 12 |
+
"react-dom": "^19.2.0",
|
| 13 |
+
"recharts": "^3.6.0"
|
| 14 |
},
|
| 15 |
"devDependencies": {
|
| 16 |
"@eslint/js": "^9.39.1",
|
|
|
|
| 1007 |
"@jridgewell/sourcemap-codec": "^1.4.14"
|
| 1008 |
}
|
| 1009 |
},
|
| 1010 |
+
"node_modules/@reduxjs/toolkit": {
|
| 1011 |
+
"version": "2.11.2",
|
| 1012 |
+
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz",
|
| 1013 |
+
"integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==",
|
| 1014 |
+
"license": "MIT",
|
| 1015 |
+
"dependencies": {
|
| 1016 |
+
"@standard-schema/spec": "^1.0.0",
|
| 1017 |
+
"@standard-schema/utils": "^0.3.0",
|
| 1018 |
+
"immer": "^11.0.0",
|
| 1019 |
+
"redux": "^5.0.1",
|
| 1020 |
+
"redux-thunk": "^3.1.0",
|
| 1021 |
+
"reselect": "^5.1.0"
|
| 1022 |
+
},
|
| 1023 |
+
"peerDependencies": {
|
| 1024 |
+
"react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
|
| 1025 |
+
"react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
|
| 1026 |
+
},
|
| 1027 |
+
"peerDependenciesMeta": {
|
| 1028 |
+
"react": {
|
| 1029 |
+
"optional": true
|
| 1030 |
+
},
|
| 1031 |
+
"react-redux": {
|
| 1032 |
+
"optional": true
|
| 1033 |
+
}
|
| 1034 |
+
}
|
| 1035 |
+
},
|
| 1036 |
+
"node_modules/@reduxjs/toolkit/node_modules/immer": {
|
| 1037 |
+
"version": "11.1.3",
|
| 1038 |
+
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.3.tgz",
|
| 1039 |
+
"integrity": "sha512-6jQTc5z0KJFtr1UgFpIL3N9XSC3saRaI9PwWtzM2pSqkNGtiNkYY2OSwkOGDK2XcTRcLb1pi/aNkKZz0nxVH4Q==",
|
| 1040 |
+
"license": "MIT",
|
| 1041 |
+
"funding": {
|
| 1042 |
+
"type": "opencollective",
|
| 1043 |
+
"url": "https://opencollective.com/immer"
|
| 1044 |
+
}
|
| 1045 |
+
},
|
| 1046 |
"node_modules/@rolldown/pluginutils": {
|
| 1047 |
"version": "1.0.0-beta.53",
|
| 1048 |
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.53.tgz",
|
|
|
|
| 1400 |
"win32"
|
| 1401 |
]
|
| 1402 |
},
|
| 1403 |
+
"node_modules/@standard-schema/spec": {
|
| 1404 |
+
"version": "1.1.0",
|
| 1405 |
+
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
| 1406 |
+
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
|
| 1407 |
+
"license": "MIT"
|
| 1408 |
+
},
|
| 1409 |
+
"node_modules/@standard-schema/utils": {
|
| 1410 |
+
"version": "0.3.0",
|
| 1411 |
+
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
|
| 1412 |
+
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
|
| 1413 |
+
"license": "MIT"
|
| 1414 |
+
},
|
| 1415 |
"node_modules/@types/babel__core": {
|
| 1416 |
"version": "7.20.5",
|
| 1417 |
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
|
|
|
|
| 1457 |
"@babel/types": "^7.28.2"
|
| 1458 |
}
|
| 1459 |
},
|
| 1460 |
+
"node_modules/@types/d3-array": {
|
| 1461 |
+
"version": "3.2.2",
|
| 1462 |
+
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
|
| 1463 |
+
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
|
| 1464 |
+
"license": "MIT"
|
| 1465 |
+
},
|
| 1466 |
+
"node_modules/@types/d3-color": {
|
| 1467 |
+
"version": "3.1.3",
|
| 1468 |
+
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
|
| 1469 |
+
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
|
| 1470 |
+
"license": "MIT"
|
| 1471 |
+
},
|
| 1472 |
+
"node_modules/@types/d3-ease": {
|
| 1473 |
+
"version": "3.0.2",
|
| 1474 |
+
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
|
| 1475 |
+
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
|
| 1476 |
+
"license": "MIT"
|
| 1477 |
+
},
|
| 1478 |
+
"node_modules/@types/d3-interpolate": {
|
| 1479 |
+
"version": "3.0.4",
|
| 1480 |
+
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
|
| 1481 |
+
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
|
| 1482 |
+
"license": "MIT",
|
| 1483 |
+
"dependencies": {
|
| 1484 |
+
"@types/d3-color": "*"
|
| 1485 |
+
}
|
| 1486 |
+
},
|
| 1487 |
+
"node_modules/@types/d3-path": {
|
| 1488 |
+
"version": "3.1.1",
|
| 1489 |
+
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
|
| 1490 |
+
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
|
| 1491 |
+
"license": "MIT"
|
| 1492 |
+
},
|
| 1493 |
+
"node_modules/@types/d3-scale": {
|
| 1494 |
+
"version": "4.0.9",
|
| 1495 |
+
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
|
| 1496 |
+
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
|
| 1497 |
+
"license": "MIT",
|
| 1498 |
+
"dependencies": {
|
| 1499 |
+
"@types/d3-time": "*"
|
| 1500 |
+
}
|
| 1501 |
+
},
|
| 1502 |
+
"node_modules/@types/d3-shape": {
|
| 1503 |
+
"version": "3.1.8",
|
| 1504 |
+
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz",
|
| 1505 |
+
"integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==",
|
| 1506 |
+
"license": "MIT",
|
| 1507 |
+
"dependencies": {
|
| 1508 |
+
"@types/d3-path": "*"
|
| 1509 |
+
}
|
| 1510 |
+
},
|
| 1511 |
+
"node_modules/@types/d3-time": {
|
| 1512 |
+
"version": "3.0.4",
|
| 1513 |
+
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
|
| 1514 |
+
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
|
| 1515 |
+
"license": "MIT"
|
| 1516 |
+
},
|
| 1517 |
+
"node_modules/@types/d3-timer": {
|
| 1518 |
+
"version": "3.0.2",
|
| 1519 |
+
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
|
| 1520 |
+
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
|
| 1521 |
+
"license": "MIT"
|
| 1522 |
+
},
|
| 1523 |
"node_modules/@types/estree": {
|
| 1524 |
"version": "1.0.8",
|
| 1525 |
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
|
|
|
| 1538 |
"version": "19.2.8",
|
| 1539 |
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.8.tgz",
|
| 1540 |
"integrity": "sha512-3MbSL37jEchWZz2p2mjntRZtPt837ij10ApxKfgmXCTuHWagYg7iA5bqPw6C8BMPfwidlvfPI/fxOc42HLhcyg==",
|
| 1541 |
+
"devOptional": true,
|
| 1542 |
"license": "MIT",
|
| 1543 |
"dependencies": {
|
| 1544 |
"csstype": "^3.2.2"
|
|
|
|
| 1554 |
"@types/react": "^19.2.0"
|
| 1555 |
}
|
| 1556 |
},
|
| 1557 |
+
"node_modules/@types/use-sync-external-store": {
|
| 1558 |
+
"version": "0.0.6",
|
| 1559 |
+
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
|
| 1560 |
+
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
|
| 1561 |
+
"license": "MIT"
|
| 1562 |
+
},
|
| 1563 |
"node_modules/@vitejs/plugin-react": {
|
| 1564 |
"version": "5.1.2",
|
| 1565 |
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.2.tgz",
|
|
|
|
| 1754 |
"url": "https://github.com/chalk/chalk?sponsor=1"
|
| 1755 |
}
|
| 1756 |
},
|
| 1757 |
+
"node_modules/clsx": {
|
| 1758 |
+
"version": "2.1.1",
|
| 1759 |
+
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
| 1760 |
+
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
|
| 1761 |
+
"license": "MIT",
|
| 1762 |
+
"engines": {
|
| 1763 |
+
"node": ">=6"
|
| 1764 |
+
}
|
| 1765 |
+
},
|
| 1766 |
"node_modules/color-convert": {
|
| 1767 |
"version": "2.0.1",
|
| 1768 |
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
|
|
|
| 1816 |
"version": "3.2.3",
|
| 1817 |
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
| 1818 |
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
| 1819 |
+
"devOptional": true,
|
| 1820 |
"license": "MIT"
|
| 1821 |
},
|
| 1822 |
+
"node_modules/d3-array": {
|
| 1823 |
+
"version": "3.2.4",
|
| 1824 |
+
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
|
| 1825 |
+
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
|
| 1826 |
+
"license": "ISC",
|
| 1827 |
+
"dependencies": {
|
| 1828 |
+
"internmap": "1 - 2"
|
| 1829 |
+
},
|
| 1830 |
+
"engines": {
|
| 1831 |
+
"node": ">=12"
|
| 1832 |
+
}
|
| 1833 |
+
},
|
| 1834 |
+
"node_modules/d3-color": {
|
| 1835 |
+
"version": "3.1.0",
|
| 1836 |
+
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
|
| 1837 |
+
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
|
| 1838 |
+
"license": "ISC",
|
| 1839 |
+
"engines": {
|
| 1840 |
+
"node": ">=12"
|
| 1841 |
+
}
|
| 1842 |
+
},
|
| 1843 |
+
"node_modules/d3-ease": {
|
| 1844 |
+
"version": "3.0.1",
|
| 1845 |
+
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
|
| 1846 |
+
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
|
| 1847 |
+
"license": "BSD-3-Clause",
|
| 1848 |
+
"engines": {
|
| 1849 |
+
"node": ">=12"
|
| 1850 |
+
}
|
| 1851 |
+
},
|
| 1852 |
+
"node_modules/d3-format": {
|
| 1853 |
+
"version": "3.1.2",
|
| 1854 |
+
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
|
| 1855 |
+
"integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
|
| 1856 |
+
"license": "ISC",
|
| 1857 |
+
"engines": {
|
| 1858 |
+
"node": ">=12"
|
| 1859 |
+
}
|
| 1860 |
+
},
|
| 1861 |
+
"node_modules/d3-interpolate": {
|
| 1862 |
+
"version": "3.0.1",
|
| 1863 |
+
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
|
| 1864 |
+
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
|
| 1865 |
+
"license": "ISC",
|
| 1866 |
+
"dependencies": {
|
| 1867 |
+
"d3-color": "1 - 3"
|
| 1868 |
+
},
|
| 1869 |
+
"engines": {
|
| 1870 |
+
"node": ">=12"
|
| 1871 |
+
}
|
| 1872 |
+
},
|
| 1873 |
+
"node_modules/d3-path": {
|
| 1874 |
+
"version": "3.1.0",
|
| 1875 |
+
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
|
| 1876 |
+
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
|
| 1877 |
+
"license": "ISC",
|
| 1878 |
+
"engines": {
|
| 1879 |
+
"node": ">=12"
|
| 1880 |
+
}
|
| 1881 |
+
},
|
| 1882 |
+
"node_modules/d3-scale": {
|
| 1883 |
+
"version": "4.0.2",
|
| 1884 |
+
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
|
| 1885 |
+
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
|
| 1886 |
+
"license": "ISC",
|
| 1887 |
+
"dependencies": {
|
| 1888 |
+
"d3-array": "2.10.0 - 3",
|
| 1889 |
+
"d3-format": "1 - 3",
|
| 1890 |
+
"d3-interpolate": "1.2.0 - 3",
|
| 1891 |
+
"d3-time": "2.1.1 - 3",
|
| 1892 |
+
"d3-time-format": "2 - 4"
|
| 1893 |
+
},
|
| 1894 |
+
"engines": {
|
| 1895 |
+
"node": ">=12"
|
| 1896 |
+
}
|
| 1897 |
+
},
|
| 1898 |
+
"node_modules/d3-shape": {
|
| 1899 |
+
"version": "3.2.0",
|
| 1900 |
+
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
|
| 1901 |
+
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
|
| 1902 |
+
"license": "ISC",
|
| 1903 |
+
"dependencies": {
|
| 1904 |
+
"d3-path": "^3.1.0"
|
| 1905 |
+
},
|
| 1906 |
+
"engines": {
|
| 1907 |
+
"node": ">=12"
|
| 1908 |
+
}
|
| 1909 |
+
},
|
| 1910 |
+
"node_modules/d3-time": {
|
| 1911 |
+
"version": "3.1.0",
|
| 1912 |
+
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
|
| 1913 |
+
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
|
| 1914 |
+
"license": "ISC",
|
| 1915 |
+
"dependencies": {
|
| 1916 |
+
"d3-array": "2 - 3"
|
| 1917 |
+
},
|
| 1918 |
+
"engines": {
|
| 1919 |
+
"node": ">=12"
|
| 1920 |
+
}
|
| 1921 |
+
},
|
| 1922 |
+
"node_modules/d3-time-format": {
|
| 1923 |
+
"version": "4.1.0",
|
| 1924 |
+
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
|
| 1925 |
+
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
|
| 1926 |
+
"license": "ISC",
|
| 1927 |
+
"dependencies": {
|
| 1928 |
+
"d3-time": "1 - 3"
|
| 1929 |
+
},
|
| 1930 |
+
"engines": {
|
| 1931 |
+
"node": ">=12"
|
| 1932 |
+
}
|
| 1933 |
+
},
|
| 1934 |
+
"node_modules/d3-timer": {
|
| 1935 |
+
"version": "3.0.1",
|
| 1936 |
+
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
|
| 1937 |
+
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
|
| 1938 |
+
"license": "ISC",
|
| 1939 |
+
"engines": {
|
| 1940 |
+
"node": ">=12"
|
| 1941 |
+
}
|
| 1942 |
+
},
|
| 1943 |
"node_modules/debug": {
|
| 1944 |
"version": "4.4.3",
|
| 1945 |
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
|
|
|
| 1958 |
}
|
| 1959 |
}
|
| 1960 |
},
|
| 1961 |
+
"node_modules/decimal.js-light": {
|
| 1962 |
+
"version": "2.5.1",
|
| 1963 |
+
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
|
| 1964 |
+
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
|
| 1965 |
+
"license": "MIT"
|
| 1966 |
+
},
|
| 1967 |
"node_modules/deep-is": {
|
| 1968 |
"version": "0.1.4",
|
| 1969 |
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
|
|
|
|
| 1978 |
"dev": true,
|
| 1979 |
"license": "ISC"
|
| 1980 |
},
|
| 1981 |
+
"node_modules/es-toolkit": {
|
| 1982 |
+
"version": "1.44.0",
|
| 1983 |
+
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.44.0.tgz",
|
| 1984 |
+
"integrity": "sha512-6penXeZalaV88MM3cGkFZZfOoLGWshWWfdy0tWw/RlVVyhvMaWSBTOvXNeiW3e5FwdS5ePW0LGEu17zT139ktg==",
|
| 1985 |
+
"license": "MIT",
|
| 1986 |
+
"workspaces": [
|
| 1987 |
+
"docs",
|
| 1988 |
+
"benchmarks"
|
| 1989 |
+
]
|
| 1990 |
+
},
|
| 1991 |
"node_modules/esbuild": {
|
| 1992 |
"version": "0.27.2",
|
| 1993 |
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz",
|
|
|
|
| 2237 |
"node": ">=0.10.0"
|
| 2238 |
}
|
| 2239 |
},
|
| 2240 |
+
"node_modules/eventemitter3": {
|
| 2241 |
+
"version": "5.0.4",
|
| 2242 |
+
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
|
| 2243 |
+
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
|
| 2244 |
+
"license": "MIT"
|
| 2245 |
+
},
|
| 2246 |
"node_modules/fast-deep-equal": {
|
| 2247 |
"version": "3.1.3",
|
| 2248 |
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
|
|
|
| 2421 |
"node": ">= 4"
|
| 2422 |
}
|
| 2423 |
},
|
| 2424 |
+
"node_modules/immer": {
|
| 2425 |
+
"version": "10.2.0",
|
| 2426 |
+
"resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
|
| 2427 |
+
"integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==",
|
| 2428 |
+
"license": "MIT",
|
| 2429 |
+
"funding": {
|
| 2430 |
+
"type": "opencollective",
|
| 2431 |
+
"url": "https://opencollective.com/immer"
|
| 2432 |
+
}
|
| 2433 |
+
},
|
| 2434 |
"node_modules/import-fresh": {
|
| 2435 |
"version": "3.3.1",
|
| 2436 |
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
|
|
|
| 2458 |
"node": ">=0.8.19"
|
| 2459 |
}
|
| 2460 |
},
|
| 2461 |
+
"node_modules/internmap": {
|
| 2462 |
+
"version": "2.0.3",
|
| 2463 |
+
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
|
| 2464 |
+
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
|
| 2465 |
+
"license": "ISC",
|
| 2466 |
+
"engines": {
|
| 2467 |
+
"node": ">=12"
|
| 2468 |
+
}
|
| 2469 |
+
},
|
| 2470 |
"node_modules/is-extglob": {
|
| 2471 |
"version": "2.1.1",
|
| 2472 |
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
|
|
|
|
| 2847 |
"react": "^19.2.3"
|
| 2848 |
}
|
| 2849 |
},
|
| 2850 |
+
"node_modules/react-is": {
|
| 2851 |
+
"version": "19.2.3",
|
| 2852 |
+
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.3.tgz",
|
| 2853 |
+
"integrity": "sha512-qJNJfu81ByyabuG7hPFEbXqNcWSU3+eVus+KJs+0ncpGfMyYdvSmxiJxbWR65lYi1I+/0HBcliO029gc4F+PnA==",
|
| 2854 |
+
"license": "MIT",
|
| 2855 |
+
"peer": true
|
| 2856 |
+
},
|
| 2857 |
+
"node_modules/react-redux": {
|
| 2858 |
+
"version": "9.2.0",
|
| 2859 |
+
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
|
| 2860 |
+
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
|
| 2861 |
+
"license": "MIT",
|
| 2862 |
+
"dependencies": {
|
| 2863 |
+
"@types/use-sync-external-store": "^0.0.6",
|
| 2864 |
+
"use-sync-external-store": "^1.4.0"
|
| 2865 |
+
},
|
| 2866 |
+
"peerDependencies": {
|
| 2867 |
+
"@types/react": "^18.2.25 || ^19",
|
| 2868 |
+
"react": "^18.0 || ^19",
|
| 2869 |
+
"redux": "^5.0.0"
|
| 2870 |
+
},
|
| 2871 |
+
"peerDependenciesMeta": {
|
| 2872 |
+
"@types/react": {
|
| 2873 |
+
"optional": true
|
| 2874 |
+
},
|
| 2875 |
+
"redux": {
|
| 2876 |
+
"optional": true
|
| 2877 |
+
}
|
| 2878 |
+
}
|
| 2879 |
+
},
|
| 2880 |
"node_modules/react-refresh": {
|
| 2881 |
"version": "0.18.0",
|
| 2882 |
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz",
|
|
|
|
| 2887 |
"node": ">=0.10.0"
|
| 2888 |
}
|
| 2889 |
},
|
| 2890 |
+
"node_modules/recharts": {
|
| 2891 |
+
"version": "3.6.0",
|
| 2892 |
+
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.6.0.tgz",
|
| 2893 |
+
"integrity": "sha512-L5bjxvQRAe26RlToBAziKUB7whaGKEwD3znoM6fz3DrTowCIC/FnJYnuq1GEzB8Zv2kdTfaxQfi5GoH0tBinyg==",
|
| 2894 |
+
"license": "MIT",
|
| 2895 |
+
"workspaces": [
|
| 2896 |
+
"www"
|
| 2897 |
+
],
|
| 2898 |
+
"dependencies": {
|
| 2899 |
+
"@reduxjs/toolkit": "1.x.x || 2.x.x",
|
| 2900 |
+
"clsx": "^2.1.1",
|
| 2901 |
+
"decimal.js-light": "^2.5.1",
|
| 2902 |
+
"es-toolkit": "^1.39.3",
|
| 2903 |
+
"eventemitter3": "^5.0.1",
|
| 2904 |
+
"immer": "^10.1.1",
|
| 2905 |
+
"react-redux": "8.x.x || 9.x.x",
|
| 2906 |
+
"reselect": "5.1.1",
|
| 2907 |
+
"tiny-invariant": "^1.3.3",
|
| 2908 |
+
"use-sync-external-store": "^1.2.2",
|
| 2909 |
+
"victory-vendor": "^37.0.2"
|
| 2910 |
+
},
|
| 2911 |
+
"engines": {
|
| 2912 |
+
"node": ">=18"
|
| 2913 |
+
},
|
| 2914 |
+
"peerDependencies": {
|
| 2915 |
+
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
| 2916 |
+
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
| 2917 |
+
"react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
| 2918 |
+
}
|
| 2919 |
+
},
|
| 2920 |
+
"node_modules/redux": {
|
| 2921 |
+
"version": "5.0.1",
|
| 2922 |
+
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
|
| 2923 |
+
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
|
| 2924 |
+
"license": "MIT"
|
| 2925 |
+
},
|
| 2926 |
+
"node_modules/redux-thunk": {
|
| 2927 |
+
"version": "3.1.0",
|
| 2928 |
+
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
|
| 2929 |
+
"integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
|
| 2930 |
+
"license": "MIT",
|
| 2931 |
+
"peerDependencies": {
|
| 2932 |
+
"redux": "^5.0.0"
|
| 2933 |
+
}
|
| 2934 |
+
},
|
| 2935 |
+
"node_modules/reselect": {
|
| 2936 |
+
"version": "5.1.1",
|
| 2937 |
+
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
|
| 2938 |
+
"integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==",
|
| 2939 |
+
"license": "MIT"
|
| 2940 |
+
},
|
| 2941 |
"node_modules/resolve-from": {
|
| 2942 |
"version": "4.0.0",
|
| 2943 |
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
|
|
|
|
| 3068 |
"node": ">=8"
|
| 3069 |
}
|
| 3070 |
},
|
| 3071 |
+
"node_modules/tiny-invariant": {
|
| 3072 |
+
"version": "1.3.3",
|
| 3073 |
+
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
|
| 3074 |
+
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
|
| 3075 |
+
"license": "MIT"
|
| 3076 |
+
},
|
| 3077 |
"node_modules/tinyglobby": {
|
| 3078 |
"version": "0.2.15",
|
| 3079 |
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
|
|
|
|
| 3145 |
"punycode": "^2.1.0"
|
| 3146 |
}
|
| 3147 |
},
|
| 3148 |
+
"node_modules/use-sync-external-store": {
|
| 3149 |
+
"version": "1.6.0",
|
| 3150 |
+
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
|
| 3151 |
+
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
|
| 3152 |
+
"license": "MIT",
|
| 3153 |
+
"peerDependencies": {
|
| 3154 |
+
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
| 3155 |
+
}
|
| 3156 |
+
},
|
| 3157 |
+
"node_modules/victory-vendor": {
|
| 3158 |
+
"version": "37.3.6",
|
| 3159 |
+
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz",
|
| 3160 |
+
"integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==",
|
| 3161 |
+
"license": "MIT AND ISC",
|
| 3162 |
+
"dependencies": {
|
| 3163 |
+
"@types/d3-array": "^3.0.3",
|
| 3164 |
+
"@types/d3-ease": "^3.0.0",
|
| 3165 |
+
"@types/d3-interpolate": "^3.0.1",
|
| 3166 |
+
"@types/d3-scale": "^4.0.2",
|
| 3167 |
+
"@types/d3-shape": "^3.1.0",
|
| 3168 |
+
"@types/d3-time": "^3.0.0",
|
| 3169 |
+
"@types/d3-timer": "^3.0.0",
|
| 3170 |
+
"d3-array": "^3.1.6",
|
| 3171 |
+
"d3-ease": "^3.0.1",
|
| 3172 |
+
"d3-interpolate": "^3.0.1",
|
| 3173 |
+
"d3-scale": "^4.0.2",
|
| 3174 |
+
"d3-shape": "^3.1.0",
|
| 3175 |
+
"d3-time": "^3.0.0",
|
| 3176 |
+
"d3-timer": "^3.0.1"
|
| 3177 |
+
}
|
| 3178 |
+
},
|
| 3179 |
"node_modules/vite": {
|
| 3180 |
"version": "7.3.1",
|
| 3181 |
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
|
web/package.json
CHANGED
|
@@ -11,7 +11,8 @@
|
|
| 11 |
},
|
| 12 |
"dependencies": {
|
| 13 |
"react": "^19.2.0",
|
| 14 |
-
"react-dom": "^19.2.0"
|
|
|
|
| 15 |
},
|
| 16 |
"devDependencies": {
|
| 17 |
"@eslint/js": "^9.39.1",
|
|
|
|
| 11 |
},
|
| 12 |
"dependencies": {
|
| 13 |
"react": "^19.2.0",
|
| 14 |
+
"react-dom": "^19.2.0",
|
| 15 |
+
"recharts": "^3.6.0"
|
| 16 |
},
|
| 17 |
"devDependencies": {
|
| 18 |
"@eslint/js": "^9.39.1",
|
web/src/App.jsx
CHANGED
|
@@ -4,7 +4,7 @@ import './index.css'
|
|
| 4 |
// Icons
|
| 5 |
import {
|
| 6 |
IconLive, IconTarget, IconChart, IconTrophy, IconCrown,
|
| 7 |
-
IconVs, IconRefresh
|
| 8 |
} from './icons'
|
| 9 |
|
| 10 |
// Pages
|
|
@@ -14,6 +14,7 @@ import Accuracy from './pages/Accuracy'
|
|
| 14 |
import MvpRace from './pages/MvpRace'
|
| 15 |
import Championship from './pages/Championship'
|
| 16 |
import HeadToHead from './pages/HeadToHead'
|
|
|
|
| 17 |
|
| 18 |
// Chevron icon for collapse toggle
|
| 19 |
function IconChevron({ className = '', direction = 'left' }) {
|
|
@@ -51,6 +52,7 @@ function App() {
|
|
| 51 |
title: 'Analysis',
|
| 52 |
items: [
|
| 53 |
{ id: 'h2h', name: 'Head to Head', icon: IconVs },
|
|
|
|
| 54 |
{ id: 'accuracy', name: 'Model Accuracy', icon: IconChart },
|
| 55 |
]
|
| 56 |
},
|
|
@@ -68,6 +70,7 @@ function App() {
|
|
| 68 |
case 'live': return <LiveGames />
|
| 69 |
case 'predictions': return <Predictions />
|
| 70 |
case 'accuracy': return <Accuracy />
|
|
|
|
| 71 |
case 'mvp': return <MvpRace />
|
| 72 |
case 'championship': return <Championship />
|
| 73 |
case 'h2h': return <HeadToHead />
|
|
|
|
| 4 |
// Icons
|
| 5 |
import {
|
| 6 |
IconLive, IconTarget, IconChart, IconTrophy, IconCrown,
|
| 7 |
+
IconVs, IconRefresh, IconBarChart
|
| 8 |
} from './icons'
|
| 9 |
|
| 10 |
// Pages
|
|
|
|
| 14 |
import MvpRace from './pages/MvpRace'
|
| 15 |
import Championship from './pages/Championship'
|
| 16 |
import HeadToHead from './pages/HeadToHead'
|
| 17 |
+
import Analytics from './pages/Analytics'
|
| 18 |
|
| 19 |
// Chevron icon for collapse toggle
|
| 20 |
function IconChevron({ className = '', direction = 'left' }) {
|
|
|
|
| 52 |
title: 'Analysis',
|
| 53 |
items: [
|
| 54 |
{ id: 'h2h', name: 'Head to Head', icon: IconVs },
|
| 55 |
+
{ id: 'analytics', name: 'Analytics', icon: IconBarChart },
|
| 56 |
{ id: 'accuracy', name: 'Model Accuracy', icon: IconChart },
|
| 57 |
]
|
| 58 |
},
|
|
|
|
| 70 |
case 'live': return <LiveGames />
|
| 71 |
case 'predictions': return <Predictions />
|
| 72 |
case 'accuracy': return <Accuracy />
|
| 73 |
+
case 'analytics': return <Analytics />
|
| 74 |
case 'mvp': return <MvpRace />
|
| 75 |
case 'championship': return <Championship />
|
| 76 |
case 'h2h': return <HeadToHead />
|
web/src/api.js
CHANGED
|
@@ -85,3 +85,10 @@ export async function getTeams() {
|
|
| 85 |
export async function healthCheck() {
|
| 86 |
return fetchAPI('/health');
|
| 87 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 85 |
export async function healthCheck() {
|
| 86 |
return fetchAPI('/health');
|
| 87 |
}
|
| 88 |
+
|
| 89 |
+
/**
|
| 90 |
+
* Get detailed team statistics for head-to-head comparison
|
| 91 |
+
*/
|
| 92 |
+
export async function getTeamStats(team) {
|
| 93 |
+
return fetchAPI(`/team-stats?team=${team}`);
|
| 94 |
+
}
|
web/src/icons.jsx
CHANGED
|
@@ -155,8 +155,18 @@ export function IconClock({ className = '' }) {
|
|
| 155 |
);
|
| 156 |
}
|
| 157 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 158 |
export default {
|
| 159 |
IconLive, IconTarget, IconChart, IconTrophy, IconCrown, IconUsers,
|
| 160 |
IconPerson, IconVs, IconStandings, IconRefresh, IconSearch, IconCheck,
|
| 161 |
-
IconX, IconBasketball, IconCalendar, IconClock
|
| 162 |
};
|
|
|
|
| 155 |
);
|
| 156 |
}
|
| 157 |
|
| 158 |
+
export function IconBarChart({ className = '' }) {
|
| 159 |
+
return (
|
| 160 |
+
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
| 161 |
+
<rect x="3" y="12" width="4" height="9" rx="1" />
|
| 162 |
+
<rect x="10" y="6" width="4" height="15" rx="1" />
|
| 163 |
+
<rect x="17" y="3" width="4" height="18" rx="1" />
|
| 164 |
+
</svg>
|
| 165 |
+
);
|
| 166 |
+
}
|
| 167 |
+
|
| 168 |
export default {
|
| 169 |
IconLive, IconTarget, IconChart, IconTrophy, IconCrown, IconUsers,
|
| 170 |
IconPerson, IconVs, IconStandings, IconRefresh, IconSearch, IconCheck,
|
| 171 |
+
IconX, IconBasketball, IconCalendar, IconClock, IconBarChart
|
| 172 |
};
|
web/src/index.css
CHANGED
|
@@ -731,6 +731,129 @@ h4 {
|
|
| 731 |
color: var(--accent-primary);
|
| 732 |
}
|
| 733 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 734 |
/* =========================================================================
|
| 735 |
Stats Grid
|
| 736 |
========================================================================= */
|
|
|
|
| 731 |
color: var(--accent-primary);
|
| 732 |
}
|
| 733 |
|
| 734 |
+
/* Confidence Meter */
|
| 735 |
+
.confidence-meter-container {
|
| 736 |
+
margin-top: var(--space-2);
|
| 737 |
+
}
|
| 738 |
+
|
| 739 |
+
.confidence-meter {
|
| 740 |
+
position: relative;
|
| 741 |
+
display: flex;
|
| 742 |
+
flex-direction: column;
|
| 743 |
+
align-items: center;
|
| 744 |
+
}
|
| 745 |
+
|
| 746 |
+
.confidence-meter svg {
|
| 747 |
+
display: block;
|
| 748 |
+
}
|
| 749 |
+
|
| 750 |
+
.confidence-value {
|
| 751 |
+
position: absolute;
|
| 752 |
+
top: 28px;
|
| 753 |
+
font-size: 1.25rem;
|
| 754 |
+
font-weight: 700;
|
| 755 |
+
text-shadow: 0 0 10px currentColor;
|
| 756 |
+
}
|
| 757 |
+
|
| 758 |
+
.confidence-label {
|
| 759 |
+
font-size: 0.625rem;
|
| 760 |
+
font-weight: 600;
|
| 761 |
+
text-transform: uppercase;
|
| 762 |
+
letter-spacing: 0.1em;
|
| 763 |
+
margin-top: -4px;
|
| 764 |
+
}
|
| 765 |
+
|
| 766 |
+
/* Stats Comparison (Head to Head) */
|
| 767 |
+
.stats-comparison {
|
| 768 |
+
display: flex;
|
| 769 |
+
flex-direction: column;
|
| 770 |
+
gap: var(--space-3);
|
| 771 |
+
}
|
| 772 |
+
|
| 773 |
+
.stat-comparison-row {
|
| 774 |
+
display: grid;
|
| 775 |
+
grid-template-columns: 1fr auto 1fr;
|
| 776 |
+
gap: var(--space-3);
|
| 777 |
+
align-items: center;
|
| 778 |
+
}
|
| 779 |
+
|
| 780 |
+
.stat-bar-container {
|
| 781 |
+
display: flex;
|
| 782 |
+
align-items: center;
|
| 783 |
+
gap: var(--space-2);
|
| 784 |
+
height: 24px;
|
| 785 |
+
}
|
| 786 |
+
|
| 787 |
+
.stat-bar-container.right {
|
| 788 |
+
justify-content: flex-end;
|
| 789 |
+
}
|
| 790 |
+
|
| 791 |
+
.stat-bar {
|
| 792 |
+
height: 8px;
|
| 793 |
+
border-radius: 4px;
|
| 794 |
+
transition: width 0.3s ease;
|
| 795 |
+
}
|
| 796 |
+
|
| 797 |
+
.stat-bar.left {
|
| 798 |
+
margin-left: auto;
|
| 799 |
+
}
|
| 800 |
+
|
| 801 |
+
.stat-value-label {
|
| 802 |
+
font-size: 0.875rem;
|
| 803 |
+
font-weight: 600;
|
| 804 |
+
min-width: 40px;
|
| 805 |
+
}
|
| 806 |
+
|
| 807 |
+
.stat-value-label.left {
|
| 808 |
+
text-align: right;
|
| 809 |
+
}
|
| 810 |
+
|
| 811 |
+
.stat-value-label.right {
|
| 812 |
+
text-align: left;
|
| 813 |
+
}
|
| 814 |
+
|
| 815 |
+
.stat-label-center {
|
| 816 |
+
font-size: 0.75rem;
|
| 817 |
+
font-weight: 600;
|
| 818 |
+
text-transform: uppercase;
|
| 819 |
+
letter-spacing: 0.05em;
|
| 820 |
+
color: var(--text-muted);
|
| 821 |
+
min-width: 70px;
|
| 822 |
+
text-align: center;
|
| 823 |
+
}
|
| 824 |
+
|
| 825 |
+
/* Recent Form */
|
| 826 |
+
.recent-form {
|
| 827 |
+
display: flex;
|
| 828 |
+
justify-content: center;
|
| 829 |
+
}
|
| 830 |
+
|
| 831 |
+
.form-badges {
|
| 832 |
+
display: flex;
|
| 833 |
+
gap: var(--space-1);
|
| 834 |
+
}
|
| 835 |
+
|
| 836 |
+
.form-badge {
|
| 837 |
+
width: 22px;
|
| 838 |
+
height: 22px;
|
| 839 |
+
display: flex;
|
| 840 |
+
align-items: center;
|
| 841 |
+
justify-content: center;
|
| 842 |
+
font-size: 0.625rem;
|
| 843 |
+
font-weight: 700;
|
| 844 |
+
border-radius: 4px;
|
| 845 |
+
}
|
| 846 |
+
|
| 847 |
+
.form-badge.win {
|
| 848 |
+
background: rgba(0, 210, 106, 0.15);
|
| 849 |
+
color: var(--accent-success);
|
| 850 |
+
}
|
| 851 |
+
|
| 852 |
+
.form-badge.loss {
|
| 853 |
+
background: rgba(255, 59, 59, 0.15);
|
| 854 |
+
color: var(--accent-danger);
|
| 855 |
+
}
|
| 856 |
+
|
| 857 |
/* =========================================================================
|
| 858 |
Stats Grid
|
| 859 |
========================================================================= */
|
web/src/pages/Analytics.jsx
ADDED
|
@@ -0,0 +1,338 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState, useEffect } from 'react'
|
| 2 |
+
import {
|
| 3 |
+
LineChart, Line, BarChart, Bar, PieChart, Pie, Cell,
|
| 4 |
+
XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer,
|
| 5 |
+
AreaChart, Area
|
| 6 |
+
} from 'recharts'
|
| 7 |
+
|
| 8 |
+
// API Base URL
|
| 9 |
+
const API_BASE = import.meta.env.DEV ? 'http://localhost:8000' : '';
|
| 10 |
+
|
| 11 |
+
function Analytics() {
|
| 12 |
+
const [data, setData] = useState(null)
|
| 13 |
+
const [loading, setLoading] = useState(true)
|
| 14 |
+
|
| 15 |
+
useEffect(() => {
|
| 16 |
+
fetchAnalytics()
|
| 17 |
+
}, [])
|
| 18 |
+
|
| 19 |
+
const fetchAnalytics = async () => {
|
| 20 |
+
try {
|
| 21 |
+
const response = await fetch(`${API_BASE}/api/analytics`)
|
| 22 |
+
const result = await response.json()
|
| 23 |
+
setData(result)
|
| 24 |
+
} catch (err) {
|
| 25 |
+
console.error('Failed to fetch analytics:', err)
|
| 26 |
+
} finally {
|
| 27 |
+
setLoading(false)
|
| 28 |
+
}
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
if (loading) {
|
| 32 |
+
return (
|
| 33 |
+
<div className="loading">
|
| 34 |
+
<div className="spinner"></div>
|
| 35 |
+
<p className="loading-text">Loading analytics...</p>
|
| 36 |
+
</div>
|
| 37 |
+
)
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
// Chart colors
|
| 41 |
+
const COLORS = ['#4ade80', '#facc15', '#f87171', '#60a5fa', '#a78bfa']
|
| 42 |
+
const GRADIENT_COLORS = {
|
| 43 |
+
primary: '#4ade80',
|
| 44 |
+
secondary: '#60a5fa',
|
| 45 |
+
accent: '#a78bfa'
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
// Mock data if API fails
|
| 49 |
+
const accuracyTrend = data?.accuracy_trend || [
|
| 50 |
+
{ date: 'Jan 15', accuracy: 62, predictions: 8 },
|
| 51 |
+
{ date: 'Jan 16', accuracy: 75, predictions: 12 },
|
| 52 |
+
{ date: 'Jan 17', accuracy: 58, predictions: 10 },
|
| 53 |
+
{ date: 'Jan 18', accuracy: 70, predictions: 14 },
|
| 54 |
+
{ date: 'Jan 19', accuracy: 65, predictions: 11 },
|
| 55 |
+
{ date: 'Jan 20', accuracy: 72, predictions: 9 },
|
| 56 |
+
{ date: 'Jan 21', accuracy: 68, predictions: 7 },
|
| 57 |
+
]
|
| 58 |
+
|
| 59 |
+
const teamAccuracy = data?.team_accuracy || [
|
| 60 |
+
{ team: 'BOS', correct: 12, total: 15, accuracy: 80 },
|
| 61 |
+
{ team: 'OKC', correct: 10, total: 14, accuracy: 71 },
|
| 62 |
+
{ team: 'CLE', correct: 9, total: 13, accuracy: 69 },
|
| 63 |
+
{ team: 'DEN', correct: 8, total: 12, accuracy: 67 },
|
| 64 |
+
{ team: 'LAL', correct: 7, total: 11, accuracy: 64 },
|
| 65 |
+
{ team: 'MIL', correct: 6, total: 10, accuracy: 60 },
|
| 66 |
+
{ team: 'NYK', correct: 5, total: 9, accuracy: 56 },
|
| 67 |
+
{ team: 'PHX', correct: 4, total: 8, accuracy: 50 },
|
| 68 |
+
]
|
| 69 |
+
|
| 70 |
+
const confidenceDistribution = data?.confidence_distribution || [
|
| 71 |
+
{ name: 'High (>70%)', value: 45, color: '#4ade80' },
|
| 72 |
+
{ name: 'Medium (60-70%)', value: 35, color: '#facc15' },
|
| 73 |
+
{ name: 'Low (<60%)', value: 20, color: '#f87171' },
|
| 74 |
+
]
|
| 75 |
+
|
| 76 |
+
const calibrationData = data?.calibration || [
|
| 77 |
+
{ predicted: 55, actual: 52 },
|
| 78 |
+
{ predicted: 60, actual: 58 },
|
| 79 |
+
{ predicted: 65, actual: 63 },
|
| 80 |
+
{ predicted: 70, actual: 68 },
|
| 81 |
+
{ predicted: 75, actual: 72 },
|
| 82 |
+
{ predicted: 80, actual: 76 },
|
| 83 |
+
{ predicted: 85, actual: 82 },
|
| 84 |
+
]
|
| 85 |
+
|
| 86 |
+
const overallStats = data?.overall || {
|
| 87 |
+
total_predictions: 156,
|
| 88 |
+
correct: 102,
|
| 89 |
+
accuracy: 65.4,
|
| 90 |
+
avg_confidence: 67.2
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
return (
|
| 94 |
+
<div className="animate-fadeIn">
|
| 95 |
+
<div className="page-header">
|
| 96 |
+
<h1 className="page-title">Analytics Dashboard</h1>
|
| 97 |
+
<p className="page-description">
|
| 98 |
+
Model performance and NBA statistics at a glance
|
| 99 |
+
</p>
|
| 100 |
+
</div>
|
| 101 |
+
|
| 102 |
+
{/* Stats Overview */}
|
| 103 |
+
<div className="stats-grid" style={{ marginBottom: 'var(--space-6)' }}>
|
| 104 |
+
<div className="stat-card">
|
| 105 |
+
<div className="stat-value accent">{overallStats.total_predictions}</div>
|
| 106 |
+
<div className="stat-label">Total Predictions</div>
|
| 107 |
+
</div>
|
| 108 |
+
<div className="stat-card">
|
| 109 |
+
<div className="stat-value" style={{ color: 'var(--accent-success)' }}>
|
| 110 |
+
{overallStats.correct}
|
| 111 |
+
</div>
|
| 112 |
+
<div className="stat-label">Correct Predictions</div>
|
| 113 |
+
</div>
|
| 114 |
+
<div className="stat-card">
|
| 115 |
+
<div className="stat-value accent">{overallStats.accuracy}%</div>
|
| 116 |
+
<div className="stat-label">Overall Accuracy</div>
|
| 117 |
+
</div>
|
| 118 |
+
<div className="stat-card">
|
| 119 |
+
<div className="stat-value" style={{ color: 'var(--accent-secondary)' }}>
|
| 120 |
+
{overallStats.avg_confidence}%
|
| 121 |
+
</div>
|
| 122 |
+
<div className="stat-label">Avg Confidence</div>
|
| 123 |
+
</div>
|
| 124 |
+
</div>
|
| 125 |
+
|
| 126 |
+
{/* Charts Grid */}
|
| 127 |
+
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 'var(--space-6)' }}>
|
| 128 |
+
|
| 129 |
+
{/* Accuracy Trend */}
|
| 130 |
+
<div className="card">
|
| 131 |
+
<div className="card-header">
|
| 132 |
+
<span className="card-title">Accuracy Trend (Last 7 Days)</span>
|
| 133 |
+
</div>
|
| 134 |
+
<div style={{ height: '300px' }}>
|
| 135 |
+
<ResponsiveContainer width="100%" height="100%">
|
| 136 |
+
<AreaChart data={accuracyTrend}>
|
| 137 |
+
<defs>
|
| 138 |
+
<linearGradient id="colorAccuracy" x1="0" y1="0" x2="0" y2="1">
|
| 139 |
+
<stop offset="5%" stopColor={GRADIENT_COLORS.primary} stopOpacity={0.3} />
|
| 140 |
+
<stop offset="95%" stopColor={GRADIENT_COLORS.primary} stopOpacity={0} />
|
| 141 |
+
</linearGradient>
|
| 142 |
+
</defs>
|
| 143 |
+
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.1)" />
|
| 144 |
+
<XAxis dataKey="date" stroke="rgba(255,255,255,0.5)" fontSize={12} />
|
| 145 |
+
<YAxis domain={[0, 100]} stroke="rgba(255,255,255,0.5)" fontSize={12} />
|
| 146 |
+
<Tooltip
|
| 147 |
+
contentStyle={{
|
| 148 |
+
background: 'rgba(0,0,0,0.8)',
|
| 149 |
+
border: '1px solid rgba(255,255,255,0.2)',
|
| 150 |
+
borderRadius: '8px'
|
| 151 |
+
}}
|
| 152 |
+
/>
|
| 153 |
+
<Area
|
| 154 |
+
type="monotone"
|
| 155 |
+
dataKey="accuracy"
|
| 156 |
+
stroke={GRADIENT_COLORS.primary}
|
| 157 |
+
strokeWidth={2}
|
| 158 |
+
fill="url(#colorAccuracy)"
|
| 159 |
+
/>
|
| 160 |
+
</AreaChart>
|
| 161 |
+
</ResponsiveContainer>
|
| 162 |
+
</div>
|
| 163 |
+
</div>
|
| 164 |
+
|
| 165 |
+
{/* Confidence Distribution */}
|
| 166 |
+
<div className="card">
|
| 167 |
+
<div className="card-header">
|
| 168 |
+
<span className="card-title">Prediction Confidence Distribution</span>
|
| 169 |
+
</div>
|
| 170 |
+
<div style={{ height: '300px' }}>
|
| 171 |
+
<ResponsiveContainer width="100%" height="100%">
|
| 172 |
+
<PieChart>
|
| 173 |
+
<Pie
|
| 174 |
+
data={confidenceDistribution}
|
| 175 |
+
cx="50%"
|
| 176 |
+
cy="50%"
|
| 177 |
+
innerRadius={60}
|
| 178 |
+
outerRadius={100}
|
| 179 |
+
paddingAngle={5}
|
| 180 |
+
dataKey="value"
|
| 181 |
+
label={({ name, percent }) => `${name}: ${(percent * 100).toFixed(0)}%`}
|
| 182 |
+
labelLine={false}
|
| 183 |
+
>
|
| 184 |
+
{confidenceDistribution.map((entry, index) => (
|
| 185 |
+
<Cell key={`cell-${index}`} fill={entry.color} />
|
| 186 |
+
))}
|
| 187 |
+
</Pie>
|
| 188 |
+
<Tooltip />
|
| 189 |
+
</PieChart>
|
| 190 |
+
</ResponsiveContainer>
|
| 191 |
+
</div>
|
| 192 |
+
</div>
|
| 193 |
+
|
| 194 |
+
{/* Team Accuracy */}
|
| 195 |
+
<div className="card">
|
| 196 |
+
<div className="card-header">
|
| 197 |
+
<span className="card-title">Accuracy by Team</span>
|
| 198 |
+
</div>
|
| 199 |
+
<div style={{ height: '300px' }}>
|
| 200 |
+
<ResponsiveContainer width="100%" height="100%">
|
| 201 |
+
<BarChart data={teamAccuracy} layout="vertical">
|
| 202 |
+
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.1)" />
|
| 203 |
+
<XAxis type="number" domain={[0, 100]} stroke="rgba(255,255,255,0.5)" fontSize={12} />
|
| 204 |
+
<YAxis dataKey="team" type="category" stroke="rgba(255,255,255,0.5)" fontSize={12} width={40} />
|
| 205 |
+
<Tooltip
|
| 206 |
+
contentStyle={{
|
| 207 |
+
background: 'rgba(0,0,0,0.8)',
|
| 208 |
+
border: '1px solid rgba(255,255,255,0.2)',
|
| 209 |
+
borderRadius: '8px'
|
| 210 |
+
}}
|
| 211 |
+
formatter={(value) => [`${value}%`, 'Accuracy']}
|
| 212 |
+
/>
|
| 213 |
+
<Bar dataKey="accuracy" fill={GRADIENT_COLORS.secondary} radius={[0, 4, 4, 0]} />
|
| 214 |
+
</BarChart>
|
| 215 |
+
</ResponsiveContainer>
|
| 216 |
+
</div>
|
| 217 |
+
</div>
|
| 218 |
+
|
| 219 |
+
{/* Calibration Chart */}
|
| 220 |
+
<div className="card">
|
| 221 |
+
<div className="card-header">
|
| 222 |
+
<span className="card-title">Prediction Calibration</span>
|
| 223 |
+
<span style={{ fontSize: '0.75rem', color: 'var(--text-muted)' }}>
|
| 224 |
+
Predicted vs Actual Win %
|
| 225 |
+
</span>
|
| 226 |
+
</div>
|
| 227 |
+
<div style={{ height: '300px' }}>
|
| 228 |
+
<ResponsiveContainer width="100%" height="100%">
|
| 229 |
+
<LineChart data={calibrationData}>
|
| 230 |
+
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.1)" />
|
| 231 |
+
<XAxis
|
| 232 |
+
dataKey="predicted"
|
| 233 |
+
stroke="rgba(255,255,255,0.5)"
|
| 234 |
+
fontSize={12}
|
| 235 |
+
label={{ value: 'Predicted %', position: 'bottom', fill: 'rgba(255,255,255,0.5)' }}
|
| 236 |
+
/>
|
| 237 |
+
<YAxis
|
| 238 |
+
stroke="rgba(255,255,255,0.5)"
|
| 239 |
+
fontSize={12}
|
| 240 |
+
domain={[50, 90]}
|
| 241 |
+
/>
|
| 242 |
+
<Tooltip
|
| 243 |
+
contentStyle={{
|
| 244 |
+
background: 'rgba(0,0,0,0.8)',
|
| 245 |
+
border: '1px solid rgba(255,255,255,0.2)',
|
| 246 |
+
borderRadius: '8px'
|
| 247 |
+
}}
|
| 248 |
+
/>
|
| 249 |
+
<Legend />
|
| 250 |
+
{/* Perfect calibration line */}
|
| 251 |
+
<Line
|
| 252 |
+
type="monotone"
|
| 253 |
+
dataKey="predicted"
|
| 254 |
+
stroke="rgba(255,255,255,0.3)"
|
| 255 |
+
strokeDasharray="5 5"
|
| 256 |
+
name="Perfect"
|
| 257 |
+
dot={false}
|
| 258 |
+
/>
|
| 259 |
+
<Line
|
| 260 |
+
type="monotone"
|
| 261 |
+
dataKey="actual"
|
| 262 |
+
stroke={GRADIENT_COLORS.accent}
|
| 263 |
+
strokeWidth={2}
|
| 264 |
+
name="Actual"
|
| 265 |
+
dot={{ fill: GRADIENT_COLORS.accent, r: 4 }}
|
| 266 |
+
/>
|
| 267 |
+
</LineChart>
|
| 268 |
+
</ResponsiveContainer>
|
| 269 |
+
</div>
|
| 270 |
+
</div>
|
| 271 |
+
</div>
|
| 272 |
+
|
| 273 |
+
{/* Recent Predictions Table */}
|
| 274 |
+
<div className="card" style={{ marginTop: 'var(--space-6)' }}>
|
| 275 |
+
<div className="card-header">
|
| 276 |
+
<span className="card-title">Recent Predictions</span>
|
| 277 |
+
</div>
|
| 278 |
+
<div style={{ overflowX: 'auto' }}>
|
| 279 |
+
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
| 280 |
+
<thead>
|
| 281 |
+
<tr style={{ borderBottom: '1px solid rgba(255,255,255,0.1)' }}>
|
| 282 |
+
<th style={tableHeaderStyle}>Date</th>
|
| 283 |
+
<th style={tableHeaderStyle}>Matchup</th>
|
| 284 |
+
<th style={tableHeaderStyle}>Prediction</th>
|
| 285 |
+
<th style={tableHeaderStyle}>Confidence</th>
|
| 286 |
+
<th style={tableHeaderStyle}>Result</th>
|
| 287 |
+
</tr>
|
| 288 |
+
</thead>
|
| 289 |
+
<tbody>
|
| 290 |
+
{(data?.recent_predictions || [
|
| 291 |
+
{ date: 'Jan 21', matchup: 'LAL @ BOS', prediction: 'BOS', confidence: 72, correct: true },
|
| 292 |
+
{ date: 'Jan 21', matchup: 'MIL @ PHX', prediction: 'MIL', confidence: 65, correct: false },
|
| 293 |
+
{ date: 'Jan 20', matchup: 'DEN @ GSW', prediction: 'DEN', confidence: 68, correct: true },
|
| 294 |
+
{ date: 'Jan 20', matchup: 'OKC @ CLE', prediction: 'OKC', confidence: 71, correct: true },
|
| 295 |
+
{ date: 'Jan 19', matchup: 'NYK @ MIA', prediction: 'NYK', confidence: 58, correct: false },
|
| 296 |
+
]).map((pred, idx) => (
|
| 297 |
+
<tr key={idx} style={{ borderBottom: '1px solid rgba(255,255,255,0.05)' }}>
|
| 298 |
+
<td style={tableCellStyle}>{pred.date}</td>
|
| 299 |
+
<td style={tableCellStyle}>{pred.matchup}</td>
|
| 300 |
+
<td style={tableCellStyle}><strong>{pred.prediction}</strong></td>
|
| 301 |
+
<td style={tableCellStyle}>
|
| 302 |
+
<span style={{
|
| 303 |
+
color: pred.confidence > 70 ? '#4ade80' : pred.confidence > 60 ? '#facc15' : '#f87171'
|
| 304 |
+
}}>
|
| 305 |
+
{pred.confidence}%
|
| 306 |
+
</span>
|
| 307 |
+
</td>
|
| 308 |
+
<td style={tableCellStyle}>
|
| 309 |
+
<span className={`badge ${pred.correct ? 'badge-success' : 'badge-danger'}`}>
|
| 310 |
+
{pred.correct ? '✓' : '✗'}
|
| 311 |
+
</span>
|
| 312 |
+
</td>
|
| 313 |
+
</tr>
|
| 314 |
+
))}
|
| 315 |
+
</tbody>
|
| 316 |
+
</table>
|
| 317 |
+
</div>
|
| 318 |
+
</div>
|
| 319 |
+
</div>
|
| 320 |
+
)
|
| 321 |
+
}
|
| 322 |
+
|
| 323 |
+
const tableHeaderStyle = {
|
| 324 |
+
textAlign: 'left',
|
| 325 |
+
padding: 'var(--space-3) var(--space-4)',
|
| 326 |
+
fontSize: '0.75rem',
|
| 327 |
+
fontWeight: '600',
|
| 328 |
+
textTransform: 'uppercase',
|
| 329 |
+
letterSpacing: '0.05em',
|
| 330 |
+
color: 'var(--text-muted)'
|
| 331 |
+
}
|
| 332 |
+
|
| 333 |
+
const tableCellStyle = {
|
| 334 |
+
padding: 'var(--space-3) var(--space-4)',
|
| 335 |
+
fontSize: '0.875rem'
|
| 336 |
+
}
|
| 337 |
+
|
| 338 |
+
export default Analytics
|
web/src/pages/HeadToHead.jsx
CHANGED
|
@@ -1,13 +1,59 @@
|
|
| 1 |
import { useState, useEffect } from 'react'
|
| 2 |
-
import { getTeams, predictGame } from '../api'
|
| 3 |
import { TeamLogo, getTeamName } from '../teamLogos'
|
| 4 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
function HeadToHead() {
|
| 6 |
const [teams, setTeams] = useState([])
|
| 7 |
const [team1, setTeam1] = useState('LAL')
|
| 8 |
const [team2, setTeam2] = useState('BOS')
|
| 9 |
const [comparison, setComparison] = useState(null)
|
|
|
|
| 10 |
const [loading, setLoading] = useState(false)
|
|
|
|
| 11 |
|
| 12 |
useEffect(() => {
|
| 13 |
getTeams().then(data => {
|
|
@@ -19,6 +65,7 @@ function HeadToHead() {
|
|
| 19 |
if (!team1 || !team2 || team1 === team2) return
|
| 20 |
|
| 21 |
setLoading(true)
|
|
|
|
| 22 |
try {
|
| 23 |
// Get predictions for both scenarios
|
| 24 |
const [homeGame, awayGame] = await Promise.all([
|
|
@@ -32,13 +79,25 @@ function HeadToHead() {
|
|
| 32 |
homeGame, // Team1 hosting Team2
|
| 33 |
awayGame, // Team2 hosting Team1
|
| 34 |
})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
} catch (err) {
|
| 36 |
console.error('Comparison failed:', err)
|
| 37 |
} finally {
|
| 38 |
setLoading(false)
|
|
|
|
| 39 |
}
|
| 40 |
}
|
| 41 |
|
|
|
|
|
|
|
|
|
|
| 42 |
return (
|
| 43 |
<div className="animate-fadeIn">
|
| 44 |
<div className="page-header">
|
|
@@ -99,17 +158,28 @@ function HeadToHead() {
|
|
| 99 |
{/* Comparison Results */}
|
| 100 |
{comparison && (
|
| 101 |
<div className="animate-slideUp">
|
| 102 |
-
{/*
|
| 103 |
<div className="stats-grid" style={{ marginBottom: 'var(--space-6)' }}>
|
| 104 |
<div className="stat-card" style={{ textAlign: 'center' }}>
|
| 105 |
<TeamLogo abbrev={comparison.team1} size="lg" style={{ margin: '0 auto var(--space-3)' }} />
|
| 106 |
<div style={{ fontSize: '1.125rem', fontWeight: '600', marginBottom: 'var(--space-2)' }}>
|
| 107 |
-
{comparison.team1}
|
| 108 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 109 |
<div className="stat-value accent">
|
| 110 |
{comparison.homeGame?.home_elo?.toFixed(0) || 'N/A'}
|
| 111 |
</div>
|
| 112 |
<div className="stat-label">ELO Rating</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 113 |
</div>
|
| 114 |
|
| 115 |
<div className="stat-card" style={{ textAlign: 'center', display: 'flex', flexDirection: 'column', justifyContent: 'center' }}>
|
|
@@ -125,15 +195,74 @@ function HeadToHead() {
|
|
| 125 |
<div className="stat-card" style={{ textAlign: 'center' }}>
|
| 126 |
<TeamLogo abbrev={comparison.team2} size="lg" style={{ margin: '0 auto var(--space-3)' }} />
|
| 127 |
<div style={{ fontSize: '1.125rem', fontWeight: '600', marginBottom: 'var(--space-2)' }}>
|
| 128 |
-
{comparison.team2}
|
| 129 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 130 |
<div className="stat-value accent">
|
| 131 |
{comparison.homeGame?.away_elo?.toFixed(0) || 'N/A'}
|
| 132 |
</div>
|
| 133 |
<div className="stat-label">ELO Rating</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 134 |
</div>
|
| 135 |
</div>
|
| 136 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 137 |
{/* Scenario Cards */}
|
| 138 |
<h3 style={{ marginBottom: 'var(--space-4)' }}>Win Probabilities by Venue</h3>
|
| 139 |
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 'var(--space-4)' }}>
|
|
|
|
| 1 |
import { useState, useEffect } from 'react'
|
| 2 |
+
import { getTeams, predictGame, getTeamStats } from '../api'
|
| 3 |
import { TeamLogo, getTeamName } from '../teamLogos'
|
| 4 |
|
| 5 |
+
// Stats Comparison Bar Component
|
| 6 |
+
function StatBar({ label, value1, value2, team1, team2, higherIsBetter = true }) {
|
| 7 |
+
const max = Math.max(value1, value2);
|
| 8 |
+
const width1 = max > 0 ? (value1 / max) * 100 : 50;
|
| 9 |
+
const width2 = max > 0 ? (value2 / max) * 100 : 50;
|
| 10 |
+
|
| 11 |
+
const team1Better = higherIsBetter ? value1 >= value2 : value1 <= value2;
|
| 12 |
+
|
| 13 |
+
return (
|
| 14 |
+
<div className="stat-comparison-row">
|
| 15 |
+
<div className="stat-bar-container">
|
| 16 |
+
<div className="stat-bar left" style={{ width: `${width1}%`, background: team1Better ? 'var(--accent-success)' : 'var(--bg-tertiary)' }} />
|
| 17 |
+
<span className="stat-value-label left" style={{ color: team1Better ? 'var(--accent-success)' : 'var(--text-secondary)' }}>{value1}</span>
|
| 18 |
+
</div>
|
| 19 |
+
<div className="stat-label-center">{label}</div>
|
| 20 |
+
<div className="stat-bar-container right">
|
| 21 |
+
<span className="stat-value-label right" style={{ color: !team1Better ? 'var(--accent-success)' : 'var(--text-secondary)' }}>{value2}</span>
|
| 22 |
+
<div className="stat-bar right" style={{ width: `${width2}%`, background: !team1Better ? 'var(--accent-success)' : 'var(--bg-tertiary)' }} />
|
| 23 |
+
</div>
|
| 24 |
+
</div>
|
| 25 |
+
);
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
// Recent Form Display
|
| 29 |
+
function RecentForm({ form }) {
|
| 30 |
+
if (!form || form.length === 0) return null;
|
| 31 |
+
|
| 32 |
+
return (
|
| 33 |
+
<div className="recent-form">
|
| 34 |
+
<div className="form-badges">
|
| 35 |
+
{form.map((game, i) => (
|
| 36 |
+
<span
|
| 37 |
+
key={i}
|
| 38 |
+
className={`form-badge ${game.result === 'W' ? 'win' : 'loss'}`}
|
| 39 |
+
title={`${game.result} vs ${game.opponent} (${game.score})`}
|
| 40 |
+
>
|
| 41 |
+
{game.result}
|
| 42 |
+
</span>
|
| 43 |
+
))}
|
| 44 |
+
</div>
|
| 45 |
+
</div>
|
| 46 |
+
);
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
function HeadToHead() {
|
| 50 |
const [teams, setTeams] = useState([])
|
| 51 |
const [team1, setTeam1] = useState('LAL')
|
| 52 |
const [team2, setTeam2] = useState('BOS')
|
| 53 |
const [comparison, setComparison] = useState(null)
|
| 54 |
+
const [teamStats, setTeamStats] = useState({ team1: null, team2: null })
|
| 55 |
const [loading, setLoading] = useState(false)
|
| 56 |
+
const [statsLoading, setStatsLoading] = useState(false)
|
| 57 |
|
| 58 |
useEffect(() => {
|
| 59 |
getTeams().then(data => {
|
|
|
|
| 65 |
if (!team1 || !team2 || team1 === team2) return
|
| 66 |
|
| 67 |
setLoading(true)
|
| 68 |
+
setStatsLoading(true)
|
| 69 |
try {
|
| 70 |
// Get predictions for both scenarios
|
| 71 |
const [homeGame, awayGame] = await Promise.all([
|
|
|
|
| 79 |
homeGame, // Team1 hosting Team2
|
| 80 |
awayGame, // Team2 hosting Team1
|
| 81 |
})
|
| 82 |
+
|
| 83 |
+
// Fetch detailed team stats in parallel
|
| 84 |
+
const [stats1, stats2] = await Promise.all([
|
| 85 |
+
getTeamStats(team1),
|
| 86 |
+
getTeamStats(team2)
|
| 87 |
+
])
|
| 88 |
+
|
| 89 |
+
setTeamStats({ team1: stats1, team2: stats2 })
|
| 90 |
} catch (err) {
|
| 91 |
console.error('Comparison failed:', err)
|
| 92 |
} finally {
|
| 93 |
setLoading(false)
|
| 94 |
+
setStatsLoading(false)
|
| 95 |
}
|
| 96 |
}
|
| 97 |
|
| 98 |
+
const stats1 = teamStats.team1;
|
| 99 |
+
const stats2 = teamStats.team2;
|
| 100 |
+
|
| 101 |
return (
|
| 102 |
<div className="animate-fadeIn">
|
| 103 |
<div className="page-header">
|
|
|
|
| 158 |
{/* Comparison Results */}
|
| 159 |
{comparison && (
|
| 160 |
<div className="animate-slideUp">
|
| 161 |
+
{/* Team Records Header */}
|
| 162 |
<div className="stats-grid" style={{ marginBottom: 'var(--space-6)' }}>
|
| 163 |
<div className="stat-card" style={{ textAlign: 'center' }}>
|
| 164 |
<TeamLogo abbrev={comparison.team1} size="lg" style={{ margin: '0 auto var(--space-3)' }} />
|
| 165 |
<div style={{ fontSize: '1.125rem', fontWeight: '600', marginBottom: 'var(--space-2)' }}>
|
| 166 |
+
{getTeamName(comparison.team1)}
|
| 167 |
</div>
|
| 168 |
+
{stats1?.record && (
|
| 169 |
+
<div style={{ fontSize: '0.875rem', color: 'var(--text-secondary)', marginBottom: 'var(--space-2)' }}>
|
| 170 |
+
{stats1.record.wins}-{stats1.record.losses}
|
| 171 |
+
</div>
|
| 172 |
+
)}
|
| 173 |
<div className="stat-value accent">
|
| 174 |
{comparison.homeGame?.home_elo?.toFixed(0) || 'N/A'}
|
| 175 |
</div>
|
| 176 |
<div className="stat-label">ELO Rating</div>
|
| 177 |
+
{stats1?.recent_form && (
|
| 178 |
+
<div style={{ marginTop: 'var(--space-3)' }}>
|
| 179 |
+
<div style={{ fontSize: '0.625rem', color: 'var(--text-muted)', marginBottom: 'var(--space-1)' }}>LAST 5</div>
|
| 180 |
+
<RecentForm form={stats1.recent_form} />
|
| 181 |
+
</div>
|
| 182 |
+
)}
|
| 183 |
</div>
|
| 184 |
|
| 185 |
<div className="stat-card" style={{ textAlign: 'center', display: 'flex', flexDirection: 'column', justifyContent: 'center' }}>
|
|
|
|
| 195 |
<div className="stat-card" style={{ textAlign: 'center' }}>
|
| 196 |
<TeamLogo abbrev={comparison.team2} size="lg" style={{ margin: '0 auto var(--space-3)' }} />
|
| 197 |
<div style={{ fontSize: '1.125rem', fontWeight: '600', marginBottom: 'var(--space-2)' }}>
|
| 198 |
+
{getTeamName(comparison.team2)}
|
| 199 |
</div>
|
| 200 |
+
{stats2?.record && (
|
| 201 |
+
<div style={{ fontSize: '0.875rem', color: 'var(--text-secondary)', marginBottom: 'var(--space-2)' }}>
|
| 202 |
+
{stats2.record.wins}-{stats2.record.losses}
|
| 203 |
+
</div>
|
| 204 |
+
)}
|
| 205 |
<div className="stat-value accent">
|
| 206 |
{comparison.homeGame?.away_elo?.toFixed(0) || 'N/A'}
|
| 207 |
</div>
|
| 208 |
<div className="stat-label">ELO Rating</div>
|
| 209 |
+
{stats2?.recent_form && (
|
| 210 |
+
<div style={{ marginTop: 'var(--space-3)' }}>
|
| 211 |
+
<div style={{ fontSize: '0.625rem', color: 'var(--text-muted)', marginBottom: 'var(--space-1)' }}>LAST 5</div>
|
| 212 |
+
<RecentForm form={stats2.recent_form} />
|
| 213 |
+
</div>
|
| 214 |
+
)}
|
| 215 |
</div>
|
| 216 |
</div>
|
| 217 |
|
| 218 |
+
{/* Stats Comparison */}
|
| 219 |
+
{stats1?.stats && stats2?.stats && (
|
| 220 |
+
<div className="card" style={{ marginBottom: 'var(--space-6)' }}>
|
| 221 |
+
<h3 style={{ marginBottom: 'var(--space-4)', textAlign: 'center' }}>Season Statistics</h3>
|
| 222 |
+
<div className="stats-comparison">
|
| 223 |
+
<StatBar label="PPG" value1={stats1.stats.ppg} value2={stats2.stats.ppg} team1={team1} team2={team2} />
|
| 224 |
+
<StatBar label="Opp PPG" value1={stats1.stats.opp_ppg} value2={stats2.stats.opp_ppg} team1={team1} team2={team2} higherIsBetter={false} />
|
| 225 |
+
<StatBar label="RPG" value1={stats1.stats.rpg} value2={stats2.stats.rpg} team1={team1} team2={team2} />
|
| 226 |
+
<StatBar label="APG" value1={stats1.stats.apg} value2={stats2.stats.apg} team1={team1} team2={team2} />
|
| 227 |
+
<StatBar label="FG%" value1={stats1.stats.fg_pct} value2={stats2.stats.fg_pct} team1={team1} team2={team2} />
|
| 228 |
+
<StatBar label="3P%" value1={stats1.stats.fg3_pct} value2={stats2.stats.fg3_pct} team1={team1} team2={team2} />
|
| 229 |
+
</div>
|
| 230 |
+
</div>
|
| 231 |
+
)}
|
| 232 |
+
|
| 233 |
+
{/* Key Players */}
|
| 234 |
+
{(stats1?.key_players?.length > 0 || stats2?.key_players?.length > 0) && (
|
| 235 |
+
<div className="card" style={{ marginBottom: 'var(--space-6)' }}>
|
| 236 |
+
<h3 style={{ marginBottom: 'var(--space-4)', textAlign: 'center' }}>Key Players</h3>
|
| 237 |
+
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 'var(--space-4)' }}>
|
| 238 |
+
<div>
|
| 239 |
+
<div style={{ textAlign: 'center', marginBottom: 'var(--space-2)' }}>
|
| 240 |
+
<TeamLogo abbrev={team1} size="sm" />
|
| 241 |
+
<span style={{ marginLeft: 'var(--space-2)', fontWeight: '600' }}>{team1}</span>
|
| 242 |
+
</div>
|
| 243 |
+
{stats1?.key_players?.map((player, i) => (
|
| 244 |
+
<div key={i} style={{ display: 'flex', justifyContent: 'space-between', padding: 'var(--space-2) 0', borderBottom: 'var(--border-subtle)' }}>
|
| 245 |
+
<span>{player.name}</span>
|
| 246 |
+
<span style={{ color: 'var(--text-muted)' }}>{player.position}</span>
|
| 247 |
+
</div>
|
| 248 |
+
))}
|
| 249 |
+
</div>
|
| 250 |
+
<div>
|
| 251 |
+
<div style={{ textAlign: 'center', marginBottom: 'var(--space-2)' }}>
|
| 252 |
+
<TeamLogo abbrev={team2} size="sm" />
|
| 253 |
+
<span style={{ marginLeft: 'var(--space-2)', fontWeight: '600' }}>{team2}</span>
|
| 254 |
+
</div>
|
| 255 |
+
{stats2?.key_players?.map((player, i) => (
|
| 256 |
+
<div key={i} style={{ display: 'flex', justifyContent: 'space-between', padding: 'var(--space-2) 0', borderBottom: 'var(--border-subtle)' }}>
|
| 257 |
+
<span>{player.name}</span>
|
| 258 |
+
<span style={{ color: 'var(--text-muted)' }}>{player.position}</span>
|
| 259 |
+
</div>
|
| 260 |
+
))}
|
| 261 |
+
</div>
|
| 262 |
+
</div>
|
| 263 |
+
</div>
|
| 264 |
+
)}
|
| 265 |
+
|
| 266 |
{/* Scenario Cards */}
|
| 267 |
<h3 style={{ marginBottom: 'var(--space-4)' }}>Win Probabilities by Venue</h3>
|
| 268 |
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 'var(--space-4)' }}>
|
web/src/pages/LiveGames.jsx
CHANGED
|
@@ -3,6 +3,67 @@ import { getLiveGames } from '../api'
|
|
| 3 |
import { TeamLogo } from '../teamLogos'
|
| 4 |
import { IconRefresh } from '../icons'
|
| 5 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
// Fetch roster for a team - use relative URL in production
|
| 7 |
const API_BASE = import.meta.env.DEV ? 'http://localhost:8000' : '';
|
| 8 |
async function getTeamRoster(teamAbbrev) {
|
|
@@ -199,15 +260,17 @@ function GameCard({ game, isLive, isFinal, showLineups }) {
|
|
| 199 |
)}
|
| 200 |
</div>
|
| 201 |
|
| 202 |
-
{/* Center */}
|
| 203 |
<div className="game-center">
|
| 204 |
{!isFinal && prediction.predicted_winner && (
|
| 205 |
<div className="prediction-indicator">
|
| 206 |
<div className="prediction-label">Prediction</div>
|
| 207 |
<div className="prediction-pick">{prediction.predicted_winner}</div>
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
<
|
|
|
|
|
|
|
| 211 |
</div>
|
| 212 |
)}
|
| 213 |
{isFinal && (
|
|
|
|
| 3 |
import { TeamLogo } from '../teamLogos'
|
| 4 |
import { IconRefresh } from '../icons'
|
| 5 |
|
| 6 |
+
// Confidence Meter Component - Visual gauge showing prediction confidence
|
| 7 |
+
function ConfidenceMeter({ value }) {
|
| 8 |
+
// Value is expected to be between 0.5 and 1.0 (50% to 100%)
|
| 9 |
+
const percentage = Math.round(value * 100);
|
| 10 |
+
// Normalize to 0-100 scale for display (50% = 0%, 100% = 100%)
|
| 11 |
+
const normalizedValue = Math.max(0, (value - 0.5) * 2);
|
| 12 |
+
|
| 13 |
+
// Color based on confidence level
|
| 14 |
+
const getColor = () => {
|
| 15 |
+
if (percentage >= 70) return '#22c55e'; // Green - High confidence
|
| 16 |
+
if (percentage >= 60) return '#eab308'; // Yellow - Medium confidence
|
| 17 |
+
return '#ef4444'; // Red - Low confidence
|
| 18 |
+
};
|
| 19 |
+
|
| 20 |
+
const getLabel = () => {
|
| 21 |
+
if (percentage >= 70) return 'HIGH';
|
| 22 |
+
if (percentage >= 60) return 'MEDIUM';
|
| 23 |
+
return 'LOW';
|
| 24 |
+
};
|
| 25 |
+
|
| 26 |
+
// SVG arc calculation
|
| 27 |
+
const radius = 40;
|
| 28 |
+
const strokeWidth = 8;
|
| 29 |
+
const circumference = Math.PI * radius; // Half circle
|
| 30 |
+
const fillLength = normalizedValue * circumference;
|
| 31 |
+
|
| 32 |
+
return (
|
| 33 |
+
<div className="confidence-meter">
|
| 34 |
+
<svg width="100" height="60" viewBox="0 0 100 60">
|
| 35 |
+
{/* Background arc */}
|
| 36 |
+
<path
|
| 37 |
+
d="M 10 50 A 40 40 0 0 1 90 50"
|
| 38 |
+
fill="none"
|
| 39 |
+
stroke="rgba(255,255,255,0.1)"
|
| 40 |
+
strokeWidth={strokeWidth}
|
| 41 |
+
strokeLinecap="round"
|
| 42 |
+
/>
|
| 43 |
+
{/* Filled arc */}
|
| 44 |
+
<path
|
| 45 |
+
d="M 10 50 A 40 40 0 0 1 90 50"
|
| 46 |
+
fill="none"
|
| 47 |
+
stroke={getColor()}
|
| 48 |
+
strokeWidth={strokeWidth}
|
| 49 |
+
strokeLinecap="round"
|
| 50 |
+
strokeDasharray={`${fillLength} ${circumference}`}
|
| 51 |
+
style={{
|
| 52 |
+
filter: `drop-shadow(0 0 6px ${getColor()})`,
|
| 53 |
+
transition: 'stroke-dasharray 0.5s ease-out'
|
| 54 |
+
}}
|
| 55 |
+
/>
|
| 56 |
+
</svg>
|
| 57 |
+
<div className="confidence-value" style={{ color: getColor() }}>
|
| 58 |
+
{percentage}%
|
| 59 |
+
</div>
|
| 60 |
+
<div className="confidence-label" style={{ color: getColor() }}>
|
| 61 |
+
{getLabel()}
|
| 62 |
+
</div>
|
| 63 |
+
</div>
|
| 64 |
+
);
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
// Fetch roster for a team - use relative URL in production
|
| 68 |
const API_BASE = import.meta.env.DEV ? 'http://localhost:8000' : '';
|
| 69 |
async function getTeamRoster(teamAbbrev) {
|
|
|
|
| 260 |
)}
|
| 261 |
</div>
|
| 262 |
|
| 263 |
+
{/* Center - Prediction with Confidence Meter */}
|
| 264 |
<div className="game-center">
|
| 265 |
{!isFinal && prediction.predicted_winner && (
|
| 266 |
<div className="prediction-indicator">
|
| 267 |
<div className="prediction-label">Prediction</div>
|
| 268 |
<div className="prediction-pick">{prediction.predicted_winner}</div>
|
| 269 |
+
|
| 270 |
+
{/* Confidence Meter */}
|
| 271 |
+
<div className="confidence-meter-container">
|
| 272 |
+
<ConfidenceMeter value={Math.max(homeProb, awayProb)} />
|
| 273 |
+
</div>
|
| 274 |
</div>
|
| 275 |
)}
|
| 276 |
{isFinal && (
|