20 Commits
Author SHA1 Message Date
nprasad2077 11a5cc0f18 shot chart and navbar
CI Health Check & Quality Gate / Code, dbt & Live App Health Check (pull_request) Successful in 1m11s
2026-10-28 02:37:56 -05:00
nprasad2077 e7c8e10071 pipeline
CI Health Check & Quality Gate / Code, dbt & Live App Health Check (pull_request) Successful in 55s
2026-10-28 01:44:43 -05:00
nprasad2077 f42688a244 pipeline
CI Health Check & Quality Gate / Code, dbt & Live App Health Check (pull_request) Successful in 1m2s
2026-10-28 01:41:31 -05:00
nprasad2077 c13bfa0082 pipeline
CI Health Check & Quality Gate / Code, dbt & Live App Health Check (pull_request) Successful in 1m9s
2026-10-28 01:38:26 -05:00
nprasad2077 95bb2d704f Update README.md
CI Health Check & Quality Gate / Code, dbt & Live App Health Check (push) Successful in 51s
2026-10-27 06:20:42 +00:00
nprasad2077 cda34a5556 Merge pull request 'season fix' (#13) from update into main
CI Health Check & Quality Gate / Code, dbt & Live App Health Check (push) Successful in 52s
Reviewed-on: #13
2026-10-27 06:13:39 +00:00
nprasad2077 306617b55c season fix
CI Health Check & Quality Gate / Code, dbt & Live App Health Check (pull_request) Successful in 58s
2026-10-27 01:13:05 -05:00
nprasad2077 cf27e5ab2c Merge pull request 'fix' (#12) from update into main
CI Health Check & Quality Gate / Code, dbt & Live App Health Check (push) Successful in 1m9s
Reviewed-on: #12
2026-10-27 05:54:58 +00:00
nprasad2077 1aad667b39 fix
CI Health Check & Quality Gate / Code, dbt & Live App Health Check (pull_request) Successful in 1m40s
2026-10-27 00:52:01 -05:00
nprasad2077 ab163fa1a6 Merge pull request 'ci: upgrade test workflow into automated health check & quality gate' (#11) from dev into main
CI Health Check & Quality Gate / Code, dbt & Live App Health Check (push) Successful in 37s
Reviewed-on: #11
2026-09-18 05:07:34 +00:00
nprasad2077 1b5247b19d Merge pull request 'README update' (#10) from dev into main
test / test (push) Successful in 6s
Reviewed-on: #10
2026-09-18 05:02:41 +00:00
nprasad2077 709e14fdac Merge pull request 'README update' (#9) from dev into main
test / test (push) Successful in 42s
Reviewed-on: #9
2026-09-18 00:41:41 +00:00
nprasad2077 a85a931133 Merge pull request 'feat(ui): responsive sidebar and full-width navigation cards' (#8) from dev into main
test / test (push) Successful in 7s
Reviewed-on: #8
2026-09-18 00:27:15 +00:00
nprasad2077 a87aca36b8 Merge pull request 'complete' (#7) from dev into main
test / test (push) Successful in 8s
Reviewed-on: #7
2026-09-18 00:16:05 +00:00
nprasad2077 3c38855045 Merge pull request 'complete' (#6) from dev into main
test / test (push) Successful in 8s
Reviewed-on: #6
2026-09-18 00:03:21 +00:00
nprasad2077 0cf9e7d8ce Merge pull request 'self host' (#5) from dev into main
test / test (push) Successful in 6s
Reviewed-on: #5
2026-09-17 23:30:28 +00:00
nprasad2077 717384ebfc Merge pull request 'fix(ci): install uv via shell installer in Gitea runner' (#4) from dev into main
test / test (push) Successful in 6s
Reviewed-on: #4
2026-09-17 22:43:21 +00:00
nprasad2077 22346b5748 Merge pull request 'complete app' (#3) from dev into main
test / test (push) Successful in 6s
Reviewed-on: #3
2026-09-17 22:40:14 +00:00
nprasad2077 4356b9b672 Merge pull request 'test actions' (#2) from dev into main
test / test (push) Successful in 5s
Reviewed-on: #2
2026-09-17 21:17:32 +00:00
nprasad2077 ba5d747371 Merge pull request 'init' (#1) from dev into main
Reviewed-on: #1
2026-09-17 20:07:01 +00:00
23 changed files with 2499 additions and 1521 deletions

No files matched your search

+4 -6
View File
@@ -2,7 +2,7 @@ name: NBA Data Pipeline
on:
schedule:
- cron: '0 6 * * *'
- cron: '0 20 * * *'
workflow_dispatch:
jobs:
@@ -22,12 +22,10 @@ jobs:
echo "$HOME/.local/bin" >> $GITHUB_PATH
echo "/root/.local/bin" >> $GITHUB_PATH
- name: Install DuckDB CLI
- name: Install Project Dependencies
run: |
curl -fsSL https://github.com/duckdb/duckdb/releases/latest/download/duckdb_cli-linux-amd64.zip -o duckdb.zip
unzip -o duckdb.zip
mv duckdb /usr/local/bin/
rm -f duckdb.zip
export PATH="$HOME/.local/bin:/root/.local/bin:$PATH"
uv sync
- name: Run ETL Pipeline & Upload to Cloudflare R2
env:
+1 -1
View File
@@ -35,7 +35,7 @@ jobs:
run: |
export PATH="$HOME/.local/bin:/root/.local/bin:$PATH"
echo "==> Validating Python scripts compilation..."
uv run python -m py_compile streamlit_app/app.py scripts/db_storage.py
uv run python -m py_compile streamlit_app/app.py streamlit_app/utils/*.py streamlit_app/app_pages/*.py tests/test_streamlit_app.py scripts/db_storage.py
echo "✓ Python scripts compiled without errors"
- name: dbt Manifest & Model Validation
+2
View File
@@ -197,3 +197,5 @@ AGENTS.md
.claude
.env.old
.opencode
+113
View File
@@ -0,0 +1,113 @@
# Codebase Context — Streamlit_NBA
Updated: 2026-10-28
## Overview
Streamlit_NBA is an end-to-end NBA analytics data warehouse and interactive visualization platform. It extracts raw game, team, and player box score and shot telemetry from PostgreSQL into DuckDB, transforms and models the data through a 3-layer medallion star schema (staging views → intermediate tables → dimension/incremental fact marts) using dbt-duckdb, and serves analytical insights via a 7-page Streamlit application and an Evidence BI report suite. Production DuckDB database artifacts are synced to and from Cloudflare R2 / S3 object storage with automated CI/CD and daily scheduled ETL builds orchestrated through Gitea Actions.
## Stack
- **Languages**: Python 3.12 (pinned in `pyproject.toml:4`, `Dockerfile:1`), SQL / Jinja (dbt SQL dialect for DuckDB), JavaScript / Markdown (Evidence BI)
- **Data Warehouse & Transformation**: dbt (`dbt-duckdb`, dbt Core 1.12.5, dbt adapter 1.11.0 in `pyproject.toml:6`), DuckDB (`duckdb>=1.3.0` in `pyproject.toml:8`), `dbt-labs/dbt_utils` 1.3.1 (`dbt_nba/packages.yml:2-3`)
- **Dashboards & Visualization**: Streamlit (`streamlit>=1.45.1` in `pyproject.toml:7`), Plotly (`plotly>=6.1.2` in `pyproject.toml:9`), `streamlit-echarts>=0.4.0` (`pyproject.toml:10`), Evidence BI (`@evidence-dev/evidence: ^40.1.8` in `dbt_nba/reports/package.json:20`)
- **Storage & Infrastructure**: Cloudflare R2 / S3-compatible Object Storage via `boto3>=1.34.0` (`pyproject.toml:11`, `scripts/db_storage.py:33-53`), Docker (`Dockerfile:1-36`)
- **Package & Runtime Management**: `uv` (`pyproject.toml`, `uv.lock`), npm (`dbt_nba/reports/package-lock.json`)
- **CI/CD**: Gitea Actions (`.gitea/workflows/pipeline.yaml`, `.gitea/workflows/test.yml`)
## Layout
- `streamlit_app/`: Frontend application code (`streamlit_app/app.py:1-1464` containing all 7 analytical views and navigation).
- `dbt_nba/`: Core dbt transformation project:
- `models/staging/`: 10 views cleaning raw extracts and deriving dynamic metric thresholds.
- `models/intermediate/`: 4 tables performing entity resolution, team abbreviation conforming, and performance joins.
- `models/marts/dimensions/`: 7 dimension tables (arenas, dates, player archetypes, players, seasons, shot zones, teams).
- `models/marts/facts/`: 6 incremental fact tables with 30-day idempotent refresh windows.
- `seeds/`: Reference CSVs for team and arena name/abbreviation history across seasons (`seeds/team_maps.csv`, `seeds/arena_maps.csv`).
- `reports/`: Evidence BI frontend project (Node.js/npm) with DuckDB connector.
- `scripts/`: Operational automation:
- `pipeline.sh`: Full extraction from Postgres, dbt rebuild, and R2 artifact upload.
- `extract.sql`: DuckDB script attaching to Postgres and pulling source tables into `main.*`.
- `extract_data.py`: Python script orchestrating SQL extraction from Postgres into DuckDB.
- `db_storage.py`: CLI manager for R2 database upload, download, and hash/status checking.
- `run_app.sh`: Streamlit launcher with auto-download fallback from R2.
- `data/`: Local storage directory for DuckDB database files (`data/DB/dbt_nba.duckdb`) and court assets (`shot_chart.png`, `shot_chart.svg`).
- `.gitea/workflows/`: CI/CD definitions for scheduled daily ETL (`pipeline.yaml`) and quality checks (`test.yml`).
- `docs/`: Historical documentation, prompts, and tournament records (`docs/init_prompt.md`, `docs/all_nba_cup_games_history.md`).
## Architecture
- **Raw Ingestion Layer**: DuckDB PostgreSQL extension (`scripts/extract.sql:5-26`, invoked via `scripts/extract_data.py`) attaches to the live PostgreSQL source (`POSTGRES_URL`) and copies 7 tables (`games`, `line_scores`, `player_game_basic_stats`, `player_game_adv_stats`, `player_shot_charts`, `team_game_basic_stats`, `team_game_adv_stats`) into `main` schema.
- **Staging Layer (Views)**: `dbt_nba/dbt_project.yml:22-25` materializes models into `main_staging`. Includes percentile breakpoints (`dbt_nba/models/staging/stg_season_thresholds.sql`) and dynamic tiering (`dbt_nba/models/staging/stg_player_game_adv_stats_extended.sql:1-40`).
- **Intermediate Layer (Tables)**: `dbt_nba/dbt_project.yml:27-30` materializes into `main_intermediate`. Handles temporal team mapping (`dbt_nba/models/intermediate/int_games_enriched.sql:15-35`) and unification of field goal shots with box-score free throws (`dbt_nba/models/intermediate/int_player_shots_enriched.sql:1-50`).
- **Marts Layer (Star Schema)**: `dbt_nba/dbt_project.yml:32-44` materializes into `main_marts`:
- 7 Dimensions: `dim_teams`, `dim_players`, `dim_seasons`, `dim_dates`, `dim_arenas`, `dim_shot_zones`, `dim_player_game_archetypes`.
- 6 Facts (Incremental): Surrogate keys generated via `dbt_utils.generate_surrogate_key` (`dbt_nba/models/marts/facts/fct_player_game_stats.sql:10-25`), using 30-day lookback windows for idempotency.
- **Application & Presentation**:
- `streamlit_app/app.py:70-83`: `get_connection()` establishes persistent read-only DuckDB connection using DuckDB's Python API, falling back between local dev path (`dbt_nba/reports/sources/nba/dbt_nba.duckdb`) and data storage target (`data/DB/dbt_nba.duckdb`).
- Evidence BI queries `sources/nba/dbt_nba.duckdb` directly through SQL source models.
## Entrypoints & flows
- **Daily Automated ETL Pipeline** (`.gitea/workflows/pipeline.yaml:26-38`, `scripts/pipeline.sh:1-44`):
1. Triggered on schedule (`cron: '0 6 * * *'`) or manual dispatch.
2. Runs `scripts/extract_data.py` executing `scripts/extract.sql` to pull raw Postgres tables into local `data/DB/dbt_nba.duckdb`.
3. Executes `uv run dbt deps` and `uv run dbt build --full-refresh` (`scripts/pipeline.sh:34`).
4. Runs 38 data and referential integrity tests in dbt.
5. Copies refreshed DuckDB file to Evidence BI path (`scripts/pipeline.sh:37`).
6. Uploads the finalized DuckDB database file to Cloudflare R2 bucket via `scripts/db_storage.py upload` (`scripts/pipeline.sh:41`).
- **Local Application Startup**:
1. User/container launches `./scripts/run_app.sh` or Docker container entrypoint (`Dockerfile:36`).
2. Verifies local DuckDB file; if missing or if `--sync` flag is provided, downloads latest snapshot from Cloudflare R2 (`scripts/run_app.sh:23-38`).
3. Launches Streamlit application (`streamlit run streamlit_app/app.py`).
## Data & integrations
- **Source Database**: PostgreSQL database (`POSTGRES_URL` in `.env`), read-only extraction.
- **Analytical Storage**: DuckDB (`dbt_nba.duckdb`) containing 6 schemas: `main` (raw tables), `main_staging` (views), `main_intermediate` (tables), `main_marts` (star schema), `main_seeds` (reference CSVs), `main_test_results` (audit tables).
- **Object Storage**: Cloudflare R2 (or any S3-compatible store: SeaweedFS, MinIO, Hetzner S3) configured via `S3_ENDPOINT_URL`, `S3_BUCKET_NAME`, `S3_ACCESS_KEY_ID`, `S3_SECRET_ACCESS_KEY`, `S3_REGION`, `S3_DB_KEY` in `.env`.
- **Authentication**: Zero authentication on public Streamlit app; API/DB credentials stored in `.env` and Gitea Action secrets.
## Commands
- **Install dependencies**: `uv sync` (Python) / `npm --prefix dbt_nba/reports install` (Evidence BI)
- **Compile Python scripts**: `uv run python -m py_compile streamlit_app/app.py scripts/db_storage.py scripts/extract_data.py`
- **Validate dbt models**: `uv run dbt parse --project-dir dbt_nba --profiles-dir dbt_nba`
- **Install dbt dependencies**: `uv run dbt deps --project-dir dbt_nba --profiles-dir dbt_nba`
- **Run dbt tests**: `uv run dbt test --project-dir dbt_nba --profiles-dir dbt_nba`
- **Run full dbt build**: `uv run dbt build --project-dir dbt_nba --profiles-dir dbt_nba`
- **Check R2 storage status**: `uv run python scripts/db_storage.py status`
- **Download database from R2**: `uv run python scripts/db_storage.py download`
- **Upload database to R2**: `uv run python scripts/db_storage.py upload`
- **Run Streamlit app**: `./scripts/run_app.sh` (or `uv run streamlit run streamlit_app/app.py`)
- **Run full ETL pipeline**: `./scripts/pipeline.sh`
- **Build Docker image**: `docker build -t streamlit-nba .`
## Conventions
- **Team Abbreviation Conforming**: Historical franchise relocations and name changes handled via `dbt_nba/seeds/team_maps.csv` using `start_year`/`end_year` filtering in intermediate SQL models.
- **dbt Testing & Severity**: Schema tests configured with `severity: warn` and `store_failures: true` storing failures into `main_test_results` (`dbt_nba/dbt_project.yml:45-50`).
- **Branch & Commit Strategy**: Git branches `main` (production deployment), `dev` (active development), and working branch `update`; conventional commit prefixes (`feat:`, `fix:`, `ci:`).
- **Streamlit Caching**: Data queries decorated with `@st.cache_data(ttl=3600)`, database connection with `@st.cache_resource` (`streamlit_app/app.py:70-83`).
## Testing
- **dbt Schema & Data Tests**: 38 data tests defined across models in `dbt_nba/models/schema.yml` and `_int_models.yml` (testing unique constraints, non-null values, referential integrity). Executed via `uv run dbt test --project-dir dbt_nba --profiles-dir dbt_nba`.
- **CI Quality Gate**: `.gitea/workflows/test.yml:11-75` runs on every push and pull request to `main` and `dev`:
1. Syntax & bytecode verification (`python -m py_compile`).
2. dbt package and manifest validation (`dbt parse`).
3. Cloudflare R2 storage reachability probe.
4. Live production app endpoint healthcheck (`https://streamlit.turbo-data.com/_stcore/health`).
## Open questions
- **Postgres Source Availability for Local Dev**: Local execution of `scripts/pipeline.sh` requires live `POSTGRES_URL`; developers without Postgres access rely on pre-built DuckDB downloads from Cloudflare R2 via `db_storage.py download`.
- **Evidence BI Production Deployment**: Evidence project in `dbt_nba/reports` has build scripts (`npm run build`) and queries local DuckDB, but whether it is independently hosted alongside Streamlit or used strictly for static export is not defined in Gitea workflows.
## Starting points
- **Adding or modifying a Streamlit chart/page**: Start in `streamlit_app/app.py` around lines 150-1464; check available columns in `main_marts` or `main_intermediate`.
- **Adding or updating a dbt metric / table**: Start in `dbt_nba/models/`; update corresponding `.yml` test definitions in `dbt_nba/models/schema.yml` or `_int_models.yml`.
- **Fixing team or franchise mappings**: Edit `dbt_nba/seeds/team_maps.csv` or `arena_maps.csv`, then run `dbt seed`.
- **Adjusting ETL pipeline or object storage**: Inspect `scripts/pipeline.sh`, `scripts/extract_data.py`, and `scripts/db_storage.py`.
- **Modifying CI/CD workflows**: Inspect `.gitea/workflows/pipeline.yaml` (daily pipeline) and `test.yml` (health check).
+1 -1
View File
@@ -1,6 +1,6 @@
# 🏀 NBA Analytics — Streamlit & dbt Data Warehouse
[![NBA Data Pipeline](https://git.turbo-data.com/nprasad2077/Streamlit_NBA/actions/workflows/pipeline.yaml/badge.svg?branch=main)](https://git.turbo-data.com/nprasad2077/Streamlit_NBA/actions?workflow=pipeline.yaml)
[![NBA Data Pipeline](https://git.turbo-data.com/nprasad2077/Streamlit_NBA/actions/workflows/pipeline.yaml/badge.svg?branch=main)](https://git.turbo-data.com/nprasad2077/Streamlit_NBA/actions?workflow=pipeline.yaml)[![CI Health Check & Quality Gate](https://git.turbo-data.com/nprasad2077/Streamlit_NBA/actions/workflows/test.yml/badge.svg?branch=main)](https://git.turbo-data.com/nprasad2077/Streamlit_NBA/actions?workflow=test.yml)
[![Live Dashboard](https://img.shields.io/badge/Live%20Dashboard-streamlit.turbo--data.com-FF6B00?logo=streamlit)](https://streamlit.turbo-data.com)
A modern NBA analytics data platform powered by **dbt**, **DuckDB**, and **Streamlit**. Transforms raw basketball event data into a dimensional star schema (32,000+ games, 480,000+ shots from 2002 to 2026), persisting artifacts to **Cloudflare R2 Object Storage** with automated **Gitea Actions** CI/CD and self-hosted **Coolify** deployment.
+43 -43
View File
@@ -3,95 +3,95 @@ ARCO Arena (II),Sacramento,California,North Natomas,1988-11-08,2016-10-30
AT&T Center,San Antonio,Texas,East Side,2006-01-01,2023-09-21
AccorHotels Arena,Paris,France,Bercy (12th arrondissement),2015-10-01,2020-06-01
Air Canada Centre,Toronto,Ontario,Downtown/Harbourfront,1999-02-20,2018-06-30
Alamodome,San Antonio,Texas,Downtown,1993-05-15,2026-06-30
Amalie Arena,Tampa,Florida,Channelside,1996-10-20,2026-06-30
Alamodome,San Antonio,Texas,Downtown,1993-05-15,2030-06-30
Amalie Arena,Tampa,Florida,Channelside,1996-10-20,2030-06-30
America West Arena,Phoenix,Arizona,Downtown,1992-06-06,2006-09-30
American Airlines Center,Dallas,Texas,Victory Park,2001-07-17,2026-06-30
American Airlines Center,Dallas,Texas,Victory Park,2001-07-17,2030-06-30
AmericanAirlines Arena,Miami,Florida,Downtown/Biscayne Bay,1999-12-31,2021-06-01
Amway Arena,Orlando,Florida,Downtown,1989-01-29,2010-10-01
Amway Center,Orlando,Florida,Downtown,2010-10-01,2023-12-19
BMO Harris Bradley Center,Milwaukee,Wisconsin,Downtown,2012-05-21,2018-09-17
Ball Arena,Denver,Colorado,Central Platte Valley/LoDo,2020-10-22,2026-06-30
Ball Arena,Denver,Colorado,Central Platte Valley/LoDo,2020-10-22,2030-06-30
Bankers Life Fieldhouse,Indianapolis,Indiana,Downtown,2011-12-01,2021-09-26
Barclays Center,Brooklyn,New York,Downtown Brooklyn/Prospect Heights,2012-09-21,2026-06-30
Barclays Center,Brooklyn,New York,Downtown Brooklyn/Prospect Heights,2012-09-21,2030-06-30
Bradley Center,Milwaukee,Wisconsin,Downtown,1988-10-01,2012-05-20
Capital One Arena,Washington,District of Columbia,Chinatown/Penn Quarter,2017-08-09,2026-06-30
Capital One Arena,Washington,District of Columbia,Chinatown/Penn Quarter,2017-08-09,2030-06-30
Charlotte Bobcats Arena,Charlotte,North Carolina,Uptown (First Ward),2005-10-21,2008-08-01
Charlotte Coliseum,Charlotte,North Carolina,Tyvola Road,1988-08-11,2005-10-26
Chase Center,San Francisco,California,Mission Bay,2019-09-06,2026-06-30
Chase Center,San Francisco,California,Mission Bay,2019-09-06,2030-06-30
Chesapeake Energy Arena,Oklahoma City,Oklahoma,Downtown,2011-01-01,2021-04-01
Compaq Center,Houston,Texas,Greenway Plaza,1998-01-01,2003-10-01
Conseco Fieldhouse,Indianapolis,Indiana,Downtown,1999-11-06,2011-11-30
Continental Airlines Arena,East Rutherford,New Jersey,Meadowlands Sports Complex,1996-01-04,2007-06-30
Crypto.com Arena,Los Angeles,California,South Park/Downtown,2021-12-25,2026-06-30
Delta Center,Salt Lake City,Utah,West Downtown,1991-10-04,2026-06-30
Crypto.com Arena,Los Angeles,California,South Park/Downtown,2021-12-25,2030-06-30
Delta Center,Salt Lake City,Utah,West Downtown,1991-10-04,2030-06-30
EnergySolutions Arena,Salt Lake City,Utah,West Downtown,2006-11-20,2015-10-25
FTX Arena,Miami,Florida,Downtown/Biscayne Bay,2021-06-01,2023-01-01
FedEx Forum,Memphis,Tennessee,Downtown (near Beale Street),2004-09-06,2026-06-30
FedExForum,Memphis,Tennessee,Downtown (near Beale Street),2004-09-06,2026-06-30
FedEx Forum,Memphis,Tennessee,Downtown (near Beale Street),2004-09-06,2030-06-30
FedExForum,Memphis,Tennessee,Downtown (near Beale Street),2004-09-06,2030-06-30
First Union Center,Philadelphia,Pennsylvania,South Philadelphia Sports Complex,1998-09-01,2003-06-30
Fiserv Forum,Milwaukee,Wisconsin,Deer District/Downtown,2018-08-26,2026-06-30
Fiserv Forum,Milwaukee,Wisconsin,Deer District/Downtown,2018-08-26,2030-06-30
FleetCenter,Boston,Massachusetts,West End,1995-09-30,2005-06-30
Footprint Center,Phoenix,Arizona,Downtown,2021-07-16,2025-02-17
Ford Center,Oklahoma City,Oklahoma,Downtown,2002-06-08,2010-12-31
Frost Bank Center,San Antonio,Texas,East Side,2023-09-22,2026-06-30
Gainbridge Fieldhouse,Indianapolis,Indiana,Downtown,2021-09-27,2026-06-30
Golden 1 Center,Sacramento,California,Downtown Commons (DoCo),2016-10-04,2026-06-30
Frost Bank Center,San Antonio,Texas,East Side,2023-09-22,2030-06-30
Gainbridge Fieldhouse,Indianapolis,Indiana,Downtown,2021-09-27,2030-06-30
Golden 1 Center,Sacramento,California,Downtown Commons (DoCo),2016-10-04,2030-06-30
Gund Arena,Cleveland,Ohio,Gateway District/Downtown,1994-10-01,2005-08-01
Intuit Dome,Inglewood,California,Hollywood Park,2024-08-15,2026-06-30
Intuit Dome,Inglewood,California,Hollywood Park,2024-08-15,2030-06-30
Izod Center,East Rutherford,New Jersey,Meadowlands Sports Complex,2007-07-01,2015-01-15
Kaseya Center,Miami,Florida,Downtown/Biscayne Bay,2023-04-01,2026-06-30
Kaseya Center,Miami,Florida,Downtown/Biscayne Bay,2023-04-01,2030-06-30
KeyArena at Seattle Center,Seattle,Washington,Seattle Center/Lower Queen Anne,1995-10-26,2008-04-13
Kia Center,Orlando,Florida,Downtown,2023-12-20,2026-06-30
LLoyd Noble Center,Norman,Oklahoma,University of Oklahoma Campus,1975-11-01,2026-06-30
Little Caesars Arena,Detroit,Michigan,Midtown/District Detroit,2017-09-05,2026-06-30
Kia Center,Orlando,Florida,Downtown,2023-12-20,2030-06-30
LLoyd Noble Center,Norman,Oklahoma,University of Oklahoma Campus,1975-11-01,2030-06-30
Little Caesars Arena,Detroit,Michigan,Midtown/District Detroit,2017-09-05,2030-06-30
MCI Center,Washington,District of Columbia,Chinatown/Penn Quarter,1997-12-02,2006-02-28
Madison Square Garden (IV),New York,New York,Midtown Manhattan (Penn Station),1968-02-11,2026-06-30
Mexico City Arena,Mexico City,Mexico,Azcapotzalco,2012-02-25,2026-06-30
Moda Center,Portland,Oregon,Rose Quarter/Lloyd District,2013-08-13,2026-06-30
Moody Center,Austin,Texas,UT Campus,2022-04-20,2026-06-30
Mortgage Matchup Center,Phoenix,Arizona,Downtown,2025-10-02,2026-06-30
Madison Square Garden (IV),New York,New York,Midtown Manhattan (Penn Station),1968-02-11,2030-06-30
Mexico City Arena,Mexico City,Mexico,Azcapotzalco,2012-02-25,2030-06-30
Moda Center,Portland,Oregon,Rose Quarter/Lloyd District,2013-08-13,2030-06-30
Moody Center,Austin,Texas,UT Campus,2022-04-20,2030-06-30
Mortgage Matchup Center,Phoenix,Arizona,Downtown,2025-10-02,2030-06-30
New Orleans Arena,New Orleans,Louisiana,Central Business District,1999-10-01,2014-02-04
Oakland Arena,Oakland,California,Coliseum Industrial,2019-06-14,2026-06-30
Oakland Arena,Oakland,California,Coliseum Industrial,2019-06-14,2030-06-30
Oklahoma City Arena,Oklahoma City,Oklahoma,Downtown,2010-01-01,2010-12-31
Oracle Arena,Oakland,California,Coliseum Industrial,2006-10-20,2019-06-13
Paycom Center,Oklahoma City,Oklahoma,Downtown,2021-07-27,2026-06-30
Paycom Center,Oklahoma City,Oklahoma,Downtown,2021-07-27,2030-06-30
Pepsi Center,Denver,Colorado,Central Platte Valley/LoDo,1999-10-01,2020-10-21
Pete Maravich Assembly Center,Baton Rouge,Louisiana,LSU Campus,1972-01-15,2026-06-30
Pete Maravich Assembly Center,Baton Rouge,Louisiana,LSU Campus,1972-01-15,2030-06-30
Philips Arena,Atlanta,Georgia,Centennial Olympic Park District,1999-09-01,2018-08-28
Phoenix Suns Arena,Phoenix,Arizona,Downtown,2020-11-01,2021-07-15
Power Balance Pavilion,Sacramento,California,North Natomas,2011-03-01,2012-12-31
Prudential Center,Newark,New Jersey,Downtown Newark,2007-10-25,2026-06-30
Prudential Center,Newark,New Jersey,Downtown Newark,2007-10-25,2030-06-30
Pyramid Arena,Memphis,Tennessee,Downtown (Riverfront),1991-11-09,2004-12-31
Quicken Loans Arena,Cleveland,Ohio,Gateway District/Downtown,2005-08-01,2019-03-31
Rocket Arena,Cleveland,Ohio,Gateway District/Downtown,2025-02-18,2026-06-30
Rocket Arena,Cleveland,Ohio,Gateway District/Downtown,2025-02-18,2030-06-30
Rocket Mortgage Fieldhouse,Cleveland,Ohio,Gateway District/Downtown,2019-04-01,2025-02-17
Rose Garden Arena,Portland,Oregon,Rose Quarter/Lloyd District,1995-10-12,2013-08-12
SBC Center,San Antonio,Texas,East Side,2002-10-18,2005-12-31
STAPLES Center,Los Angeles,California,South Park/Downtown,1999-10-17,2021-12-24
Saitama Super Arena,Saitama,Japan,Chuo-ku/Shin-Toshin,2000-09-01,2026-06-30
Scotiabank Arena,Toronto,Ontario,Downtown/Harbourfront,2018-07-01,2026-06-30
Saitama Super Arena,Saitama,Japan,Chuo-ku/Shin-Toshin,2000-09-01,2030-06-30
Scotiabank Arena,Toronto,Ontario,Downtown/Harbourfront,2018-07-01,2030-06-30
Sleep Train Arena,Sacramento,California,North Natomas,2013-01-01,2016-10-30
Smoothie King Center,New Orleans,Louisiana,Central Business District,2014-02-05,2026-06-30
Spectrum Center,Charlotte,North Carolina,Uptown (First Ward),2016-08-17,2026-06-30
State Farm Arena,Atlanta,Georgia,Centennial Olympic Park District,2018-08-29,2026-06-30
T-Mobile Arena,Las Vegas,Nevada,Las Vegas Strip,2016-04-06,2026-06-30
Smoothie King Center,New Orleans,Louisiana,Central Business District,2014-02-05,2030-06-30
Spectrum Center,Charlotte,North Carolina,Uptown (First Ward),2016-08-17,2030-06-30
State Farm Arena,Atlanta,Georgia,Centennial Olympic Park District,2018-08-29,2030-06-30
T-Mobile Arena,Las Vegas,Nevada,Las Vegas Strip,2016-04-06,2030-06-30
TD Banknorth Garden,Boston,Massachusetts,West End,2005-07-01,2009-07-15
TD Garden,Boston,Massachusetts,West End,2009-07-16,2026-06-30
TD Garden,Boston,Massachusetts,West End,2009-07-16,2030-06-30
TD Waterhouse Centre,Orlando,Florida,Downtown,1999-10-01,2002-12-31
Talking Stick Resort Arena,Phoenix,Arizona,Downtown,2015-09-01,2020-10-31
Target Center,Minneapolis,Minnesota,Warehouse District/North Loop,1990-10-16,2026-06-30
Target Center,Minneapolis,Minnesota,Warehouse District/North Loop,1990-10-16,2030-06-30
The Arena in Oakland,Oakland,California,Coliseum Industrial,1997-10-01,2004-12-31
The O2 Arena,London,United Kingdom,Greenwich Peninsula,2007-06-24,2026-06-30
The O2 Arena,London,United Kingdom,Greenwich Peninsula,2007-06-24,2030-06-30
The Palace of Auburn Hills,Auburn Hills,Michigan,Auburn Hills (I-75 corridor),1988-10-01,2017-10-12
Time Warner Cable Arena,Charlotte,North Carolina,Uptown (First Ward),2008-08-01,2016-08-16
Toyota Center,Houston,Texas,Downtown/East Downtown,2003-10-06,2026-06-30
Toyota Center,Houston,Texas,Downtown/East Downtown,2003-10-06,2030-06-30
US Airways Center,Phoenix,Arizona,Downtown,2006-10-01,2015-08-31
UW–Milwaukee Panther Arena,Milwaukee,Wisconsin,Downtown,1950-08-04,2026-06-30
United Center,Chicago,Illinois,Near West Side,1994-08-18,2026-06-30
UW–Milwaukee Panther Arena,Milwaukee,Wisconsin,Downtown,1950-08-04,2030-06-30
United Center,Chicago,Illinois,Near West Side,1994-08-18,2030-06-30
Verizon Center,Washington,District of Columbia,Chinatown/Penn Quarter,2006-03-01,2017-08-08
Vivint Arena,Salt Lake City,Utah,West Downtown,2020-01-01,2023-06-30
Vivint Smart Home Arena,Salt Lake City,Utah,West Downtown,2015-10-26,2019-12-31
Wachovia Center,Philadelphia,Pennsylvania,South Philadelphia Sports Complex,2003-07-01,2010-06-30
Wells Fargo Center,Philadelphia,Pennsylvania,South Philadelphia Sports Complex,2010-07-01,2025-08-31
Xfinity Mobile Arena,Philadelphia,Pennsylvania,South Philadelphia Sports Complex,2025-09-01,2026-06-30
Xfinity Mobile Arena,Philadelphia,Pennsylvania,South Philadelphia Sports Complex,2025-09-01,2030-06-30
1 arena_name city state_or_country location start_date end_date
3 AT&T Center San Antonio Texas East Side 2006-01-01 2023-09-21
4 AccorHotels Arena Paris France Bercy (12th arrondissement) 2015-10-01 2020-06-01
5 Air Canada Centre Toronto Ontario Downtown/Harbourfront 1999-02-20 2018-06-30
6 Alamodome San Antonio Texas Downtown 1993-05-15 2026-06-30 2030-06-30
7 Amalie Arena Tampa Florida Channelside 1996-10-20 2026-06-30 2030-06-30
8 America West Arena Phoenix Arizona Downtown 1992-06-06 2006-09-30
9 American Airlines Center Dallas Texas Victory Park 2001-07-17 2026-06-30 2030-06-30
10 AmericanAirlines Arena Miami Florida Downtown/Biscayne Bay 1999-12-31 2021-06-01
11 Amway Arena Orlando Florida Downtown 1989-01-29 2010-10-01
12 Amway Center Orlando Florida Downtown 2010-10-01 2023-12-19
13 BMO Harris Bradley Center Milwaukee Wisconsin Downtown 2012-05-21 2018-09-17
14 Ball Arena Denver Colorado Central Platte Valley/LoDo 2020-10-22 2026-06-30 2030-06-30
15 Bankers Life Fieldhouse Indianapolis Indiana Downtown 2011-12-01 2021-09-26
16 Barclays Center Brooklyn New York Downtown Brooklyn/Prospect Heights 2012-09-21 2026-06-30 2030-06-30
17 Bradley Center Milwaukee Wisconsin Downtown 1988-10-01 2012-05-20
18 Capital One Arena Washington District of Columbia Chinatown/Penn Quarter 2017-08-09 2026-06-30 2030-06-30
19 Charlotte Bobcats Arena Charlotte North Carolina Uptown (First Ward) 2005-10-21 2008-08-01
20 Charlotte Coliseum Charlotte North Carolina Tyvola Road 1988-08-11 2005-10-26
21 Chase Center San Francisco California Mission Bay 2019-09-06 2026-06-30 2030-06-30
22 Chesapeake Energy Arena Oklahoma City Oklahoma Downtown 2011-01-01 2021-04-01
23 Compaq Center Houston Texas Greenway Plaza 1998-01-01 2003-10-01
24 Conseco Fieldhouse Indianapolis Indiana Downtown 1999-11-06 2011-11-30
25 Continental Airlines Arena East Rutherford New Jersey Meadowlands Sports Complex 1996-01-04 2007-06-30
26 Crypto.com Arena Los Angeles California South Park/Downtown 2021-12-25 2026-06-30 2030-06-30
27 Delta Center Salt Lake City Utah West Downtown 1991-10-04 2026-06-30 2030-06-30
28 EnergySolutions Arena Salt Lake City Utah West Downtown 2006-11-20 2015-10-25
29 FTX Arena Miami Florida Downtown/Biscayne Bay 2021-06-01 2023-01-01
30 FedEx Forum Memphis Tennessee Downtown (near Beale Street) 2004-09-06 2026-06-30 2030-06-30
31 FedExForum Memphis Tennessee Downtown (near Beale Street) 2004-09-06 2026-06-30 2030-06-30
32 First Union Center Philadelphia Pennsylvania South Philadelphia Sports Complex 1998-09-01 2003-06-30
33 Fiserv Forum Milwaukee Wisconsin Deer District/Downtown 2018-08-26 2026-06-30 2030-06-30
34 FleetCenter Boston Massachusetts West End 1995-09-30 2005-06-30
35 Footprint Center Phoenix Arizona Downtown 2021-07-16 2025-02-17
36 Ford Center Oklahoma City Oklahoma Downtown 2002-06-08 2010-12-31
37 Frost Bank Center San Antonio Texas East Side 2023-09-22 2026-06-30 2030-06-30
38 Gainbridge Fieldhouse Indianapolis Indiana Downtown 2021-09-27 2026-06-30 2030-06-30
39 Golden 1 Center Sacramento California Downtown Commons (DoCo) 2016-10-04 2026-06-30 2030-06-30
40 Gund Arena Cleveland Ohio Gateway District/Downtown 1994-10-01 2005-08-01
41 Intuit Dome Inglewood California Hollywood Park 2024-08-15 2026-06-30 2030-06-30
42 Izod Center East Rutherford New Jersey Meadowlands Sports Complex 2007-07-01 2015-01-15
43 Kaseya Center Miami Florida Downtown/Biscayne Bay 2023-04-01 2026-06-30 2030-06-30
44 KeyArena at Seattle Center Seattle Washington Seattle Center/Lower Queen Anne 1995-10-26 2008-04-13
45 Kia Center Orlando Florida Downtown 2023-12-20 2026-06-30 2030-06-30
46 LLoyd Noble Center Norman Oklahoma University of Oklahoma Campus 1975-11-01 2026-06-30 2030-06-30
47 Little Caesars Arena Detroit Michigan Midtown/District Detroit 2017-09-05 2026-06-30 2030-06-30
48 MCI Center Washington District of Columbia Chinatown/Penn Quarter 1997-12-02 2006-02-28
49 Madison Square Garden (IV) New York New York Midtown Manhattan (Penn Station) 1968-02-11 2026-06-30 2030-06-30
50 Mexico City Arena Mexico City Mexico Azcapotzalco 2012-02-25 2026-06-30 2030-06-30
51 Moda Center Portland Oregon Rose Quarter/Lloyd District 2013-08-13 2026-06-30 2030-06-30
52 Moody Center Austin Texas UT Campus 2022-04-20 2026-06-30 2030-06-30
53 Mortgage Matchup Center Phoenix Arizona Downtown 2025-10-02 2026-06-30 2030-06-30
54 New Orleans Arena New Orleans Louisiana Central Business District 1999-10-01 2014-02-04
55 Oakland Arena Oakland California Coliseum Industrial 2019-06-14 2026-06-30 2030-06-30
56 Oklahoma City Arena Oklahoma City Oklahoma Downtown 2010-01-01 2010-12-31
57 Oracle Arena Oakland California Coliseum Industrial 2006-10-20 2019-06-13
58 Paycom Center Oklahoma City Oklahoma Downtown 2021-07-27 2026-06-30 2030-06-30
59 Pepsi Center Denver Colorado Central Platte Valley/LoDo 1999-10-01 2020-10-21
60 Pete Maravich Assembly Center Baton Rouge Louisiana LSU Campus 1972-01-15 2026-06-30 2030-06-30
61 Philips Arena Atlanta Georgia Centennial Olympic Park District 1999-09-01 2018-08-28
62 Phoenix Suns Arena Phoenix Arizona Downtown 2020-11-01 2021-07-15
63 Power Balance Pavilion Sacramento California North Natomas 2011-03-01 2012-12-31
64 Prudential Center Newark New Jersey Downtown Newark 2007-10-25 2026-06-30 2030-06-30
65 Pyramid Arena Memphis Tennessee Downtown (Riverfront) 1991-11-09 2004-12-31
66 Quicken Loans Arena Cleveland Ohio Gateway District/Downtown 2005-08-01 2019-03-31
67 Rocket Arena Cleveland Ohio Gateway District/Downtown 2025-02-18 2026-06-30 2030-06-30
68 Rocket Mortgage Fieldhouse Cleveland Ohio Gateway District/Downtown 2019-04-01 2025-02-17
69 Rose Garden Arena Portland Oregon Rose Quarter/Lloyd District 1995-10-12 2013-08-12
70 SBC Center San Antonio Texas East Side 2002-10-18 2005-12-31
71 STAPLES Center Los Angeles California South Park/Downtown 1999-10-17 2021-12-24
72 Saitama Super Arena Saitama Japan Chuo-ku/Shin-Toshin 2000-09-01 2026-06-30 2030-06-30
73 Scotiabank Arena Toronto Ontario Downtown/Harbourfront 2018-07-01 2026-06-30 2030-06-30
74 Sleep Train Arena Sacramento California North Natomas 2013-01-01 2016-10-30
75 Smoothie King Center New Orleans Louisiana Central Business District 2014-02-05 2026-06-30 2030-06-30
76 Spectrum Center Charlotte North Carolina Uptown (First Ward) 2016-08-17 2026-06-30 2030-06-30
77 State Farm Arena Atlanta Georgia Centennial Olympic Park District 2018-08-29 2026-06-30 2030-06-30
78 T-Mobile Arena Las Vegas Nevada Las Vegas Strip 2016-04-06 2026-06-30 2030-06-30
79 TD Banknorth Garden Boston Massachusetts West End 2005-07-01 2009-07-15
80 TD Garden Boston Massachusetts West End 2009-07-16 2026-06-30 2030-06-30
81 TD Waterhouse Centre Orlando Florida Downtown 1999-10-01 2002-12-31
82 Talking Stick Resort Arena Phoenix Arizona Downtown 2015-09-01 2020-10-31
83 Target Center Minneapolis Minnesota Warehouse District/North Loop 1990-10-16 2026-06-30 2030-06-30
84 The Arena in Oakland Oakland California Coliseum Industrial 1997-10-01 2004-12-31
85 The O2 Arena London United Kingdom Greenwich Peninsula 2007-06-24 2026-06-30 2030-06-30
86 The Palace of Auburn Hills Auburn Hills Michigan Auburn Hills (I-75 corridor) 1988-10-01 2017-10-12
87 Time Warner Cable Arena Charlotte North Carolina Uptown (First Ward) 2008-08-01 2016-08-16
88 Toyota Center Houston Texas Downtown/East Downtown 2003-10-06 2026-06-30 2030-06-30
89 US Airways Center Phoenix Arizona Downtown 2006-10-01 2015-08-31
90 UW–Milwaukee Panther Arena Milwaukee Wisconsin Downtown 1950-08-04 2026-06-30 2030-06-30
91 United Center Chicago Illinois Near West Side 1994-08-18 2026-06-30 2030-06-30
92 Verizon Center Washington District of Columbia Chinatown/Penn Quarter 2006-03-01 2017-08-08
93 Vivint Arena Salt Lake City Utah West Downtown 2020-01-01 2023-06-30
94 Vivint Smart Home Arena Salt Lake City Utah West Downtown 2015-10-26 2019-12-31
95 Wachovia Center Philadelphia Pennsylvania South Philadelphia Sports Complex 2003-07-01 2010-06-30
96 Wells Fargo Center Philadelphia Pennsylvania South Philadelphia Sports Complex 2010-07-01 2025-08-31
97 Xfinity Mobile Arena Philadelphia Pennsylvania South Philadelphia Sports Complex 2025-09-01 2026-06-30 2030-06-30
+30 -30
View File
@@ -1,39 +1,39 @@
team_abbr,full_name,start_year,end_year,is_current,source,notes
ATL,Atlanta Hawks,1968,2026,TRUE,all,No changes since 1968
BOS,Boston Celtics,1946,2026,TRUE,all,No changes since 1946
BRK,Brooklyn Nets,2012,2026,TRUE,bref,Basketball-Reference abbreviation for Brooklyn
ATL,Atlanta Hawks,1968,2030,TRUE,all,No changes since 1968
BOS,Boston Celtics,1946,2030,TRUE,all,No changes since 1946
BRK,Brooklyn Nets,2012,2030,TRUE,bref,Basketball-Reference abbreviation for Brooklyn
CHA,Charlotte Bobcats,2004,2014,FALSE,bref,Renamed to Hornets in 2014; ESPN/NBA.com also use CHA for current Hornets
CHH,Charlotte Hornets,1988,2002,FALSE,bref,Original Hornets; relocated to New Orleans after 2001-02
CHI,Chicago Bulls,1966,2026,TRUE,all,No changes since 1966
CHO,Charlotte Hornets,2014,2026,TRUE,bref,Basketball-Reference abbreviation; reclaimed original Hornets history
CLE,Cleveland Cavaliers,1970,2026,TRUE,all,No changes since 1970
DAL,Dallas Mavericks,1980,2026,TRUE,all,Expansion team 1980-81 season
DEN,Denver Nuggets,1976,2026,TRUE,all,Joined NBA in 1976 from ABA
DET,Detroit Pistons,1957,2026,TRUE,all,In Detroit since 1957
GSW,Golden State Warriors,1971,2026,TRUE,all,Name changed from San Francisco Warriors in 1971
HOU,Houston Rockets,1971,2026,TRUE,all,Moved from San Diego in 1971
IND,Indiana Pacers,1976,2026,TRUE,all,Joined NBA in 1976 from ABA
LAC,Los Angeles Clippers,1984,2026,TRUE,all,Relocated from San Diego
LAL,Los Angeles Lakers,1960,2026,TRUE,all,Moved from Minneapolis in 1960
MEM,Memphis Grizzlies,2001,2026,TRUE,all,Relocated from Vancouver
MIA,Miami Heat,1988,2026,TRUE,all,Expansion team 1988-89 season
MIL,Milwaukee Bucks,1968,2026,TRUE,all,No changes since 1968
MIN,Minnesota Timberwolves,1989,2026,TRUE,all,Expansion team 1989-90 season
CHI,Chicago Bulls,1966,2030,TRUE,all,No changes since 1966
CHO,Charlotte Hornets,2014,2030,TRUE,bref,Basketball-Reference abbreviation; reclaimed original Hornets history
CLE,Cleveland Cavaliers,1970,2030,TRUE,all,No changes since 1970
DAL,Dallas Mavericks,1980,2030,TRUE,all,Expansion team 1980-81 season
DEN,Denver Nuggets,1976,2030,TRUE,all,Joined NBA in 1976 from ABA
DET,Detroit Pistons,1957,2030,TRUE,all,In Detroit since 1957
GSW,Golden State Warriors,1971,2030,TRUE,all,Name changed from San Francisco Warriors in 1971
HOU,Houston Rockets,1971,2030,TRUE,all,Moved from San Diego in 1971
IND,Indiana Pacers,1976,2030,TRUE,all,Joined NBA in 1976 from ABA
LAC,Los Angeles Clippers,1984,2030,TRUE,all,Relocated from San Diego
LAL,Los Angeles Lakers,1960,2030,TRUE,all,Moved from Minneapolis in 1960
MEM,Memphis Grizzlies,2001,2030,TRUE,all,Relocated from Vancouver
MIA,Miami Heat,1988,2030,TRUE,all,Expansion team 1988-89 season
MIL,Milwaukee Bucks,1968,2030,TRUE,all,No changes since 1968
MIN,Minnesota Timberwolves,1989,2030,TRUE,all,Expansion team 1989-90 season
NJN,New Jersey Nets,1977,2012,FALSE,all,Moved to Brooklyn after 2011-12 season
NOH,New Orleans Hornets,2002,2005,FALSE,bref,Relocated from Charlotte; excludes NOK years; renamed Pelicans in 2013
NOK,New Orleans/Oklahoma City Hornets,2005,2007,FALSE,bref,Hurricane Katrina temporary relocation; split home games
NOP,New Orleans Pelicans,2013,2026,TRUE,all,Renamed from Hornets
NYK,New York Knicks,1946,2026,TRUE,all,No changes since 1946
OKC,Oklahoma City Thunder,2008,2026,TRUE,all,Relocated from Seattle
ORL,Orlando Magic,1989,2026,TRUE,all,Expansion team 1989-90 season
PHI,Philadelphia 76ers,1963,2026,TRUE,all,Moved from Syracuse in 1963
PHO,Phoenix Suns,1968,2026,TRUE,bref,Basketball-Reference abbreviation
POR,Portland Trail Blazers,1970,2026,TRUE,all,No changes since 1970
SAC,Sacramento Kings,1985,2026,TRUE,all,Relocated from Kansas City
SAS,San Antonio Spurs,1976,2026,TRUE,all,Joined NBA in 1976 from ABA
NOP,New Orleans Pelicans,2013,2030,TRUE,all,Renamed from Hornets
NYK,New York Knicks,1946,2030,TRUE,all,No changes since 1946
OKC,Oklahoma City Thunder,2008,2030,TRUE,all,Relocated from Seattle
ORL,Orlando Magic,1989,2030,TRUE,all,Expansion team 1989-90 season
PHI,Philadelphia 76ers,1963,2030,TRUE,all,Moved from Syracuse in 1963
PHO,Phoenix Suns,1968,2030,TRUE,bref,Basketball-Reference abbreviation
POR,Portland Trail Blazers,1970,2030,TRUE,all,No changes since 1970
SAC,Sacramento Kings,1985,2030,TRUE,all,Relocated from Kansas City
SAS,San Antonio Spurs,1976,2030,TRUE,all,Joined NBA in 1976 from ABA
SDC,San Diego Clippers,1978,1984,FALSE,bref,Moved to Los Angeles after 1983-84 season
SEA,Seattle SuperSonics,1967,2008,FALSE,all,Relocated to Oklahoma City after 2007-08 season
TOR,Toronto Raptors,1995,2026,TRUE,all,Expansion team 1995-96 season
UTA,Utah Jazz,1979,2026,TRUE,all,Moved from New Orleans in 1979
TOR,Toronto Raptors,1995,2030,TRUE,all,Expansion team 1995-96 season
UTA,Utah Jazz,1979,2030,TRUE,all,Moved from New Orleans in 1979
VAN,Vancouver Grizzlies,1995,2001,FALSE,all,Expansion team 1995-96; relocated to Memphis after 2000-01
WAS,Washington Wizards,1997,2026,TRUE,all,Renamed from Bullets
WAS,Washington Wizards,1997,2030,TRUE,all,Renamed from Bullets
1 team_abbr full_name start_year end_year is_current source notes
2 ATL Atlanta Hawks 1968 2026 2030 TRUE all No changes since 1968
3 BOS Boston Celtics 1946 2026 2030 TRUE all No changes since 1946
4 BRK Brooklyn Nets 2012 2026 2030 TRUE bref Basketball-Reference abbreviation for Brooklyn
5 CHA Charlotte Bobcats 2004 2014 FALSE bref Renamed to Hornets in 2014; ESPN/NBA.com also use CHA for current Hornets
6 CHH Charlotte Hornets 1988 2002 FALSE bref Original Hornets; relocated to New Orleans after 2001-02
7 CHI Chicago Bulls 1966 2026 2030 TRUE all No changes since 1966
8 CHO Charlotte Hornets 2014 2026 2030 TRUE bref Basketball-Reference abbreviation; reclaimed original Hornets history
9 CLE Cleveland Cavaliers 1970 2026 2030 TRUE all No changes since 1970
10 DAL Dallas Mavericks 1980 2026 2030 TRUE all Expansion team 1980-81 season
11 DEN Denver Nuggets 1976 2026 2030 TRUE all Joined NBA in 1976 from ABA
12 DET Detroit Pistons 1957 2026 2030 TRUE all In Detroit since 1957
13 GSW Golden State Warriors 1971 2026 2030 TRUE all Name changed from San Francisco Warriors in 1971
14 HOU Houston Rockets 1971 2026 2030 TRUE all Moved from San Diego in 1971
15 IND Indiana Pacers 1976 2026 2030 TRUE all Joined NBA in 1976 from ABA
16 LAC Los Angeles Clippers 1984 2026 2030 TRUE all Relocated from San Diego
17 LAL Los Angeles Lakers 1960 2026 2030 TRUE all Moved from Minneapolis in 1960
18 MEM Memphis Grizzlies 2001 2026 2030 TRUE all Relocated from Vancouver
19 MIA Miami Heat 1988 2026 2030 TRUE all Expansion team 1988-89 season
20 MIL Milwaukee Bucks 1968 2026 2030 TRUE all No changes since 1968
21 MIN Minnesota Timberwolves 1989 2026 2030 TRUE all Expansion team 1989-90 season
22 NJN New Jersey Nets 1977 2012 FALSE all Moved to Brooklyn after 2011-12 season
23 NOH New Orleans Hornets 2002 2005 FALSE bref Relocated from Charlotte; excludes NOK years; renamed Pelicans in 2013
24 NOK New Orleans/Oklahoma City Hornets 2005 2007 FALSE bref Hurricane Katrina temporary relocation; split home games
25 NOP New Orleans Pelicans 2013 2026 2030 TRUE all Renamed from Hornets
26 NYK New York Knicks 1946 2026 2030 TRUE all No changes since 1946
27 OKC Oklahoma City Thunder 2008 2026 2030 TRUE all Relocated from Seattle
28 ORL Orlando Magic 1989 2026 2030 TRUE all Expansion team 1989-90 season
29 PHI Philadelphia 76ers 1963 2026 2030 TRUE all Moved from Syracuse in 1963
30 PHO Phoenix Suns 1968 2026 2030 TRUE bref Basketball-Reference abbreviation
31 POR Portland Trail Blazers 1970 2026 2030 TRUE all No changes since 1970
32 SAC Sacramento Kings 1985 2026 2030 TRUE all Relocated from Kansas City
33 SAS San Antonio Spurs 1976 2026 2030 TRUE all Joined NBA in 1976 from ABA
34 SDC San Diego Clippers 1978 1984 FALSE bref Moved to Los Angeles after 1983-84 season
35 SEA Seattle SuperSonics 1967 2008 FALSE all Relocated to Oklahoma City after 2007-08 season
36 TOR Toronto Raptors 1995 2026 2030 TRUE all Expansion team 1995-96 season
37 UTA Utah Jazz 1979 2026 2030 TRUE all Moved from New Orleans in 1979
38 VAN Vancouver Grizzlies 1995 2001 FALSE all Expansion team 1995-96; relocated to Memphis after 2000-01
39 WAS Washington Wizards 1997 2026 2030 TRUE all Renamed from Bullets
+42
View File
@@ -0,0 +1,42 @@
#!/usr/bin/env python3
"""
Extract source tables from Postgres into DuckDB using the DuckDB Python library.
Reads SQL commands from scripts/extract.sql with environment variable interpolation.
"""
import os
import sys
from pathlib import Path
import duckdb
def main():
repo_root = Path(__file__).resolve().parent.parent
db_path = repo_root / "data" / "DB" / "dbt_nba.duckdb"
sql_path = repo_root / "scripts" / "extract.sql"
postgres_url = os.getenv("POSTGRES_URL")
if not postgres_url:
print("ERROR: POSTGRES_URL environment variable is not set", file=sys.stderr)
print(f"Please export POSTGRES_URL or configure it in {repo_root}/.env", file=sys.stderr)
sys.exit(1)
db_path.parent.mkdir(parents=True, exist_ok=True)
if db_path.exists():
db_path.unlink()
with open(sql_path, "r", encoding="utf-8") as f:
sql = f.read()
# Replace variable
sql = sql.replace("${POSTGRES_URL}", postgres_url)
print(f"==> Connecting to DuckDB at {db_path}...")
con = duckdb.connect(str(db_path))
try:
print("==> Executing extraction queries...")
con.execute(sql)
print("==> Extraction complete successfully!")
finally:
con.close()
if __name__ == "__main__":
main()
+1 -1
View File
@@ -23,7 +23,7 @@ echo "==> [1/4] Extracting from Postgres into DuckDB..."
mkdir -p "$(dirname "$DB_PATH")"
mkdir -p "$(dirname "$REPORTS_DB")"
rm -f "$DB_PATH"
envsubst < "$REPO_ROOT/scripts/extract.sql" | duckdb "$DB_PATH"
uv run --project "$REPO_ROOT" python "$REPO_ROOT/scripts/extract_data.py"
echo "==> [2/4] Running dbt build --full-refresh..."
export DBT_PROJECT_DIR="$REPO_ROOT/dbt_nba"
+4 -4
View File
@@ -1,8 +1,8 @@
[theme]
primaryColor = "#3b82f6"
backgroundColor = "#0e1117"
secondaryBackgroundColor = "#1e293b"
textColor = "#f8fafc"
primaryColor = "#E09F3E"
backgroundColor = "#0B0F17"
secondaryBackgroundColor = "#151D2A"
textColor = "#F1F5F9"
[server]
maxUploadSize = 5
+59 -1435
View File
File diff suppressed because it is too large. Load diff
+175
View File
@@ -0,0 +1,175 @@
import plotly.express as px
import plotly.graph_objects as go
import streamlit as st
from utils.charts import AMBER, MADE_EMERALD, MISS_CRIMSON, apply_arena_theme
from utils.db import query
st.title(":material/trending_up: Era Trends")
tab_scoring, tab_drama, tab_three = st.tabs(
[
":material/sports_basketball: Scoring & Pace",
":material/theater_comedy: Game Character",
":material/my_location: Three-Point Era",
]
)
with tab_scoring:
pace = query(
"""
SELECT s.season_display AS season, s.season_start_year,
round(avg(g.matchup_pace), 1) AS avg_pace,
round(avg(g.total_points), 1) AS avg_points,
round(avg(g.home_effective_fg_pct + g.visitor_effective_fg_pct) / 2 * 100, 1) AS avg_efg_pct
FROM main_intermediate.int_games_enriched g
JOIN main_marts.dim_seasons s ON g.season_start_year = s.season_start_year
GROUP BY s.season_display, s.season_start_year
ORDER BY s.season_start_year
"""
)
fig = go.Figure()
fig.add_trace(
go.Scatter(
x=pace["season"],
y=pace["avg_points"],
name="Avg Points/Game",
mode="lines+markers",
line=dict(color=AMBER, width=3),
)
)
fig.add_trace(
go.Scatter(
x=pace["season"],
y=pace["avg_pace"],
name="Pace",
mode="lines+markers",
yaxis="y2",
line=dict(color=MADE_EMERALD, width=2, dash="dot"),
)
)
fig.update_layout(
title="Scoring & Pace Evolution",
yaxis=dict(title="Avg Points/Game"),
yaxis2=dict(title="Pace (possessions/48min)", overlaying="y", side="right"),
)
apply_arena_theme(fig, height=450)
st.plotly_chart(fig, width="stretch")
pace_clean = pace.dropna(subset=["avg_efg_pct"])
fig_eff = px.area(
pace_clean,
x="season",
y="avg_efg_pct",
title="League eFG% Trend",
color_discrete_sequence=[MADE_EMERALD],
)
fig_eff.update_layout(yaxis_title="eFG%")
apply_arena_theme(fig_eff, height=350)
st.plotly_chart(fig_eff, width="stretch")
with tab_drama:
drama = query(
"""
SELECT s.season_display AS season,
round(100.0 * sum(CASE WHEN g.is_overtime THEN 1 ELSE 0 END) / count(*), 1) AS ot_pct,
round(100.0 * sum(CASE WHEN g.point_differential <= 5 THEN 1 ELSE 0 END) / count(*), 1) AS clutch_pct,
round(100.0 * sum(CASE WHEN g.point_differential >= 20 THEN 1 ELSE 0 END) / count(*), 1) AS blowout_pct
FROM main_intermediate.int_games_enriched g
JOIN main_marts.dim_seasons s ON g.season_start_year = s.season_start_year
GROUP BY s.season_display, s.season_start_year
ORDER BY s.season_start_year
"""
)
fig = px.area(
drama,
x="season",
y=["clutch_pct", "blowout_pct", "ot_pct"],
title="Game Drama: Clutch (≤5pt margin) vs Blowouts (20+) vs Overtime",
color_discrete_sequence=[MADE_EMERALD, MISS_CRIMSON, AMBER],
)
fig.update_layout(yaxis_title="% of Games", legend_title="Category")
apply_arena_theme(fig, height=450)
st.plotly_chart(fig, width="stretch")
home = query(
"""
SELECT s.season_display AS season,
round(100.0 * sum(CASE WHEN g.is_home_team_winner THEN 1 ELSE 0 END) / count(*), 1) AS home_win_pct
FROM main_marts.fct_game_results g
JOIN main_marts.dim_seasons s ON g.season_key = s.season_key
GROUP BY s.season_display, s.season_start_year
ORDER BY s.season_start_year
"""
)
fig2 = px.line(
home,
x="season",
y="home_win_pct",
title="Home Court Advantage Over Time",
markers=True,
color_discrete_sequence=[AMBER],
)
fig2.update_layout(yaxis_range=[40, 70])
fig2.add_hline(y=50, line_dash="dash", line_color="gray", annotation_text="50%")
apply_arena_theme(fig2, height=400)
st.plotly_chart(fig2, width="stretch")
with tab_three:
three_pt = query(
"""
SELECT s.season_display AS season, s.season_start_year,
round(avg(three_point_rate) * 100, 1) AS avg_three_rate,
round(avg(CASE WHEN three_point_fg_pct > 0 THEN three_point_fg_pct ELSE NULL END) * 100, 1) AS avg_three_pct,
round(avg(three_point_attempts), 1) AS avg_3pa
FROM main_marts.fct_player_game_shooting sh
JOIN main_marts.dim_seasons s ON sh.season_key = s.season_key
WHERE sh.fg_attempts >= 5
GROUP BY s.season_display, s.season_start_year
ORDER BY s.season_start_year
"""
)
if not three_pt.empty:
fig = go.Figure()
fig.add_trace(
go.Bar(
x=three_pt["season"],
y=three_pt["avg_three_rate"],
name="3PA Rate (%)",
marker_color=AMBER,
opacity=0.75,
)
)
fig.add_trace(
go.Scatter(
x=three_pt["season"],
y=three_pt["avg_three_pct"],
name="3P Accuracy (%)",
mode="lines+markers",
yaxis="y2",
line=dict(color=MADE_EMERALD, width=3),
)
)
fig.update_layout(
title="The Three-Point Revolution: Volume vs Accuracy",
yaxis=dict(title="3PA Rate (% of all FGA)"),
yaxis2=dict(title="3P FG%", overlaying="y", side="right", range=[30, 50]),
barmode="overlay",
)
apply_arena_theme(fig, height=450)
st.plotly_chart(fig, width="stretch")
fig2 = px.area(
three_pt,
x="season",
y="avg_3pa",
title="Avg Three-Point Attempts per Player-Game",
color_discrete_sequence=[AMBER],
)
fig2.update_layout(yaxis_title="3PA per Game")
apply_arena_theme(fig2, height=350)
st.plotly_chart(fig2, width="stretch")
else:
st.info("Three-point shooting data not available.")
+292
View File
@@ -0,0 +1,292 @@
import plotly.express as px
import streamlit as st
from streamlit_echarts import st_echarts
from utils.charts import AMBER, MISS_CRIMSON, apply_arena_theme
from utils.db import get_selected_season, query
SEASON, SEASON_LABEL = get_selected_season()
st.title(f":material/swords: Matchup Center — {SEASON_LABEL}")
teams = query("SELECT DISTINCT team FROM main_intermediate.int_team_performance ORDER BY team")
team_list = teams["team"].tolist()
col1, col2, col3 = st.columns([2, 2, 1.2])
with col1:
team_a = st.selectbox("Team A", team_list, index=0)
with col2:
team_b = st.selectbox("Team B", team_list, index=min(1, len(team_list) - 1))
with col3:
scope = (
st.segmented_control(
"Scope",
["This Season", "All-Time"],
default="This Season",
)
or "This Season"
)
if team_a == team_b:
st.warning("Select two different teams.")
else:
if scope == "This Season":
h2h_agg = query(
"""
SELECT team, count(*) AS games,
sum(CASE WHEN game_result = 'W' THEN 1 ELSE 0 END) AS wins,
round(avg(points), 1) AS avg_pts,
round(avg(offensive_rating), 1) AS off_rtg,
round(avg(defensive_rating), 1) AS def_rtg,
round(avg(net_rating), 1) AS net_rtg,
round(avg(pace), 1) AS pace,
round(avg(effective_fg_pct) * 100, 1) AS efg_pct,
round(avg(turnover_rate) * 100, 1) AS tov_rate
FROM main_intermediate.int_team_performance
WHERE ((team = ? AND opponent_team = ?) OR (team = ? AND opponent_team = ?))
AND season_start_year = ?
GROUP BY team
""",
[team_a, team_b, team_b, team_a, SEASON],
)
else:
h2h_agg = query(
"""
SELECT team, count(*) AS games,
sum(CASE WHEN game_result = 'W' THEN 1 ELSE 0 END) AS wins,
round(avg(points), 1) AS avg_pts,
round(avg(offensive_rating), 1) AS off_rtg,
round(avg(defensive_rating), 1) AS def_rtg,
round(avg(net_rating), 1) AS net_rtg,
round(avg(pace), 1) AS pace,
round(avg(effective_fg_pct) * 100, 1) AS efg_pct,
round(avg(turnover_rate) * 100, 1) AS tov_rate
FROM main_intermediate.int_team_performance
WHERE ((team = ? AND opponent_team = ?) OR (team = ? AND opponent_team = ?))
GROUP BY team
""",
[team_a, team_b, team_b, team_a],
)
if h2h_agg.empty or len(h2h_agg) < 2:
st.info("No matchup history found between these teams for this scope.")
else:
row_a = h2h_agg[h2h_agg["team"] == team_a].iloc[0]
row_b = h2h_agg[h2h_agg["team"] == team_b].iloc[0]
c1, c2, c3, c4, c5 = st.columns(5)
c1.metric(f"{team_a} Wins", int(row_a["wins"]))
c2.metric(f"{team_a} PPG", row_a["avg_pts"])
c3.metric("Games", int(row_a["games"]))
c4.metric(f"{team_b} PPG", row_b["avg_pts"])
c5.metric(f"{team_b} Wins", int(row_b["wins"]))
tab_compare, tab_games, tab_players = st.tabs(
[
":material/radar: Comparison",
":material/list_alt: Game Log",
":material/group: Key Players",
]
)
with tab_compare:
vals_a = [
float(row_a["avg_pts"]),
float(row_a["off_rtg"]),
float(row_a["pace"]),
float(row_a["efg_pct"]),
130 - float(row_a["def_rtg"]),
20 - float(row_a["tov_rate"]),
]
vals_b = [
float(row_b["avg_pts"]),
float(row_b["off_rtg"]),
float(row_b["pace"]),
float(row_b["efg_pct"]),
130 - float(row_b["def_rtg"]),
20 - float(row_b["tov_rate"]),
]
radar_option = {
"legend": {"data": [team_a, team_b], "top": "bottom"},
"color": [AMBER, MISS_CRIMSON],
"radar": {
"indicator": [
{"name": "Points", "max": max(vals_a[0], vals_b[0]) * 1.15},
{"name": "Off Rating", "max": max(vals_a[1], vals_b[1]) * 1.1},
{"name": "Pace", "max": max(vals_a[2], vals_b[2]) * 1.1},
{"name": "eFG%", "max": max(vals_a[3], vals_b[3]) * 1.15},
{"name": "Defense (inv)", "max": max(vals_a[4], vals_b[4]) * 1.15},
{"name": "Ball Security (inv)", "max": max(vals_a[5], vals_b[5]) * 1.15},
],
"shape": "polygon",
"splitArea": {
"areaStyle": {
"color": ["#0B0F17", "#151D2A", "#1E293B", "#0B0F17"]
}
},
"axisLine": {"lineStyle": {"color": "rgba(200,200,200,0.3)"}},
"splitLine": {"lineStyle": {"color": "rgba(200,200,200,0.2)"}},
},
"series": [
{
"type": "radar",
"data": [
{
"value": vals_a,
"name": team_a,
"lineStyle": {"color": AMBER, "width": 2.5},
"areaStyle": {"color": AMBER, "opacity": 0.25},
"itemStyle": {"color": AMBER},
},
{
"value": vals_b,
"name": team_b,
"lineStyle": {"color": MISS_CRIMSON, "width": 2.5},
"areaStyle": {"color": MISS_CRIMSON, "opacity": 0.25},
"itemStyle": {"color": MISS_CRIMSON},
},
],
}
],
}
st_echarts(options=radar_option, height="450px")
st.dataframe(
h2h_agg.set_index("team")[
[
"games",
"wins",
"avg_pts",
"off_rtg",
"def_rtg",
"net_rtg",
"pace",
"efg_pct",
"tov_rate",
]
],
width="stretch",
)
with tab_games:
if scope == "This Season":
game_log = query(
"""
SELECT g.game_date::date::varchar AS date,
ht.team_abbr AS home, vt.team_abbr AS away,
g.home_points, g.visitor_points, wt.team_abbr AS winner,
g.point_differential AS margin,
g.game_competitiveness_tier AS type
FROM main_marts.fct_game_results g
JOIN main_marts.dim_teams ht ON g.home_team_key = ht.team_key
JOIN main_marts.dim_teams vt ON g.visitor_team_key = vt.team_key
JOIN main_marts.dim_teams wt ON g.winning_team_key = wt.team_key
JOIN main_marts.dim_seasons s ON g.season_key = s.season_key
WHERE ((ht.team_abbr = ? AND vt.team_abbr = ?) OR (ht.team_abbr = ? AND vt.team_abbr = ?))
AND s.season_start_year = ?
ORDER BY g.game_date DESC
""",
[team_a, team_b, team_b, team_a, SEASON],
)
else:
game_log = query(
"""
SELECT g.game_date::date::varchar AS date,
ht.team_abbr AS home, vt.team_abbr AS away,
g.home_points, g.visitor_points, wt.team_abbr AS winner,
g.point_differential AS margin,
g.game_competitiveness_tier AS type
FROM main_marts.fct_game_results g
JOIN main_marts.dim_teams ht ON g.home_team_key = ht.team_key
JOIN main_marts.dim_teams vt ON g.visitor_team_key = vt.team_key
JOIN main_marts.dim_teams wt ON g.winning_team_key = wt.team_key
WHERE ((ht.team_abbr = ? AND vt.team_abbr = ?) OR (ht.team_abbr = ? AND vt.team_abbr = ?))
ORDER BY g.game_date DESC
""",
[team_a, team_b, team_b, team_a],
)
if not game_log.empty:
game_log["winner_color"] = game_log["winner"].apply(
lambda w: team_a if w == team_a else team_b
)
fig = px.bar(
game_log,
x="date",
y="margin",
color="winner_color",
color_discrete_map={team_a: AMBER, team_b: MISS_CRIMSON},
title="Game Margins (color = winner)",
)
fig.update_layout(xaxis_title="", yaxis_title="Point Margin")
apply_arena_theme(fig, height=350)
st.plotly_chart(fig, width="stretch")
st.dataframe(
game_log.drop(columns=["winner_color"]), hide_index=True, width="stretch"
)
with tab_players:
if scope == "This Season":
players_in_matchup = query(
"""
SELECT pp.player_name, pp.team, count(*) AS games,
round(avg(pp.points), 1) AS ppg,
round(avg(pp.assists), 1) AS apg,
round(avg(pp.total_rebounds), 1) AS rpg,
round(avg(pp.box_plus_minus), 2) AS bpm
FROM main_intermediate.int_player_performance pp
JOIN main_intermediate.int_team_performance tp
ON pp.game_id = tp.game_id AND pp.team = tp.team
WHERE ((tp.team = ? AND tp.opponent_team = ?)
OR (tp.team = ? AND tp.opponent_team = ?))
AND tp.season_start_year = ?
AND pp.minutes_played >= 15
GROUP BY pp.player_name, pp.team
HAVING count(*) >= 2
ORDER BY ppg DESC LIMIT 15
""",
[team_a, team_b, team_b, team_a, SEASON],
)
else:
players_in_matchup = query(
"""
SELECT pp.player_name, pp.team, count(*) AS games,
round(avg(pp.points), 1) AS ppg,
round(avg(pp.assists), 1) AS apg,
round(avg(pp.total_rebounds), 1) AS rpg,
round(avg(pp.box_plus_minus), 2) AS bpm
FROM main_intermediate.int_player_performance pp
JOIN main_intermediate.int_team_performance tp
ON pp.game_id = tp.game_id AND pp.team = tp.team
WHERE ((tp.team = ? AND tp.opponent_team = ?)
OR (tp.team = ? AND tp.opponent_team = ?))
AND pp.minutes_played >= 15
GROUP BY pp.player_name, pp.team
HAVING count(*) >= 2
ORDER BY ppg DESC LIMIT 15
""",
[team_a, team_b, team_b, team_a],
)
if not players_in_matchup.empty:
col1, col2 = st.columns(2)
with col1:
st.subheader(f"{team_a} Performers")
team_a_players = players_in_matchup[players_in_matchup["team"] == team_a]
st.dataframe(
team_a_players[["player_name", "games", "ppg", "apg", "rpg", "bpm"]],
hide_index=True,
width="stretch",
)
with col2:
st.subheader(f"{team_b} Performers")
team_b_players = players_in_matchup[players_in_matchup["team"] == team_b]
st.dataframe(
team_b_players[["player_name", "games", "ppg", "apg", "rpg", "bpm"]],
hide_index=True,
width="stretch",
)
else:
st.info("No player data available for this matchup scope.")
+201
View File
@@ -0,0 +1,201 @@
import plotly.express as px
import streamlit as st
from streamlit_echarts import st_echarts
from utils.charts import AMBER, apply_arena_theme
from utils.db import get_selected_season, query
SEASON, SEASON_LABEL = get_selected_season()
st.title(f":material/dashboard: League Pulse — {SEASON_LABEL}")
# --- KPIs with delta vs prior season ---
summary = query(
"""
SELECT count(*) AS total_games,
sum(CASE WHEN is_overtime THEN 1 ELSE 0 END) AS ot_games,
round(avg(total_points), 1) AS avg_pts,
round(avg(point_differential), 1) AS avg_margin,
round(100.0 * sum(CASE WHEN is_home_team_winner THEN 1 ELSE 0 END) / count(*), 1) AS home_win_pct,
min(game_date) AS first_game,
max(game_date) AS last_game
FROM main_marts.fct_game_results
WHERE season_key IN (SELECT season_key FROM main_marts.dim_seasons WHERE season_start_year = ?)
""",
[SEASON],
)
prior = query(
"""
SELECT round(avg(total_points), 1) AS avg_pts,
round(avg(point_differential), 1) AS avg_margin,
round(100.0 * sum(CASE WHEN is_home_team_winner THEN 1 ELSE 0 END) / count(*), 1) AS home_win_pct
FROM main_marts.fct_game_results
WHERE season_key IN (SELECT season_key FROM main_marts.dim_seasons WHERE season_start_year = ?)
""",
[SEASON - 1],
)
has_prior = not prior.empty and prior["avg_pts"][0] is not None
c1, c2, c3, c4, c5 = st.columns(5)
c1.metric("Total Games", f"{summary['total_games'][0]:,}")
c2.metric(
"Avg Points/Game",
summary["avg_pts"][0],
delta=f"{summary['avg_pts'][0] - prior['avg_pts'][0]:+.1f}" if has_prior else None,
)
c3.metric(
"Avg Margin",
summary["avg_margin"][0],
delta=f"{summary['avg_margin'][0] - prior['avg_margin'][0]:+.1f}" if has_prior else None,
)
c4.metric(
"Home Win %",
f"{summary['home_win_pct'][0]}%",
delta=f"{summary['home_win_pct'][0] - prior['home_win_pct'][0]:+.1f}%" if has_prior else None,
)
c5.metric("OT Games", f"{summary['ot_games'][0]:,}")
st.caption(f"Season: {summary['first_game'][0]} → {summary['last_game'][0]} | Deltas vs prior season")
# --- Row 2: Competitiveness donut + Top standings ---
col1, col2 = st.columns(2)
with col1:
comp = query(
"""
SELECT game_competitiveness_tier AS tier, count(*) AS games
FROM main_marts.fct_game_results
WHERE season_key IN (SELECT season_key FROM main_marts.dim_seasons WHERE season_start_year = ?)
GROUP BY tier ORDER BY games DESC
""",
[SEASON],
)
fig = px.pie(
comp,
names="tier",
values="games",
title="Game Competitiveness",
hole=0.4,
color_discrete_sequence=px.colors.qualitative.Set2,
)
fig.update_traces(textinfo="percent+label")
apply_arena_theme(fig)
st.plotly_chart(fig, width="stretch")
with col2:
standings = query(
"""
SELECT team, count(*) AS games,
sum(CASE WHEN game_result = 'W' THEN 1 ELSE 0 END) AS wins,
sum(CASE WHEN game_result = 'L' THEN 1 ELSE 0 END) AS losses,
round(100.0 * sum(CASE WHEN game_result = 'W' THEN 1 ELSE 0 END) / count(*), 1) AS win_pct,
round(avg(net_rating), 1) AS net_rtg
FROM main_intermediate.int_team_performance
WHERE season_start_year = ?
GROUP BY team ORDER BY win_pct DESC LIMIT 8
""",
[SEASON],
)
st.subheader(":material/emoji_events: Top Teams")
st.dataframe(
standings,
column_config={
"win_pct": st.column_config.ProgressColumn(
"Win%", min_value=0, max_value=100, format="%.1f%%"
),
},
hide_index=True,
width="stretch",
)
# --- Row 3: Weekly scoring trend ---
weekly = query(
"""
SELECT date_trunc('week', game_date)::date::varchar AS week,
count(*) AS games,
round(avg(total_points), 1) AS avg_pts,
round(avg(point_differential), 1) AS avg_margin
FROM main_marts.fct_game_results
WHERE season_key IN (SELECT season_key FROM main_marts.dim_seasons WHERE season_start_year = ?)
GROUP BY week ORDER BY week
""",
[SEASON],
)
fig_weekly = px.area(
weekly,
x="week",
y="avg_pts",
title="Weekly Scoring Trend",
color_discrete_sequence=[AMBER],
)
fig_weekly.update_layout(xaxis_title="Week", yaxis_title="Avg Total Points")
apply_arena_theme(fig_weekly, height=350)
st.plotly_chart(fig_weekly, width="stretch")
# --- Row 4: Calendar heatmap + Season highlights ---
col1, col2 = st.columns([2, 1])
with col1:
cal_data = query(
"""
SELECT game_date::varchar AS day, count(*) AS games
FROM main_marts.fct_game_results
WHERE season_key IN (SELECT season_key FROM main_marts.dim_seasons WHERE season_start_year = ?)
GROUP BY game_date ORDER BY game_date
""",
[SEASON],
)
if not cal_data.empty:
cal_list = cal_data.values.tolist()
cal_option = {
"tooltip": {"position": "top", "formatter": "{c0} games on {b0}"},
"visualMap": {
"min": 0,
"max": int(cal_data["games"].max()),
"calculable": True,
"orient": "horizontal",
"left": "center",
"top": "top",
"inRange": {"color": ["#151D2A", "#9be9a8", "#40c463", "#30a14e", "#10B981"]},
},
"calendar": {
"range": [cal_list[0][0][:7], cal_list[-1][0][:7]],
"cellSize": ["auto", 15],
},
"series": [{"type": "heatmap", "coordinateSystem": "calendar", "data": cal_list}],
}
st_echarts(options=cal_option, height="200px")
with col2:
st.subheader(":material/local_fire_department: Top Performances")
highlights = query(
"""
SELECT player_name AS player, team,
game_date::date::varchar AS date, points AS pts,
assists AS ast, total_rebounds AS reb
FROM main_intermediate.int_player_performance
WHERE season_start_year = ?
ORDER BY points DESC LIMIT 5
""",
[SEASON],
)
st.dataframe(highlights, hide_index=True, width="stretch")
# --- Row 5: Recent games ---
st.subheader("Recent Games")
games = query(
"""
SELECT g.game_date, ht.team_abbr AS home, vt.team_abbr AS away,
g.home_points, g.visitor_points, wt.team_abbr AS winner,
g.point_differential AS margin, g.game_competitiveness_tier AS type
FROM main_marts.fct_game_results g
LEFT JOIN main_marts.dim_teams ht ON g.home_team_key = ht.team_key
LEFT JOIN main_marts.dim_teams vt ON g.visitor_team_key = vt.team_key
LEFT JOIN main_marts.dim_teams wt ON g.winning_team_key = wt.team_key
WHERE g.season_key IN (SELECT season_key FROM main_marts.dim_seasons WHERE season_start_year = ?)
ORDER BY g.game_date DESC LIMIT 25
""",
[SEASON],
)
st.dataframe(games, hide_index=True, width="stretch")
+329
View File
@@ -0,0 +1,329 @@
import plotly.express as px
import streamlit as st
from streamlit_echarts import st_echarts
from utils.charts import apply_arena_theme
from utils.db import get_selected_season, query
SEASON, SEASON_LABEL = get_selected_season()
st.title(f":material/science: Player Lab — {SEASON_LABEL}")
min_games = st.sidebar.slider("Min Games", 5, 60, 20, key="player_min_games")
players = query(
"""
SELECT player_name, team, count(*) AS games,
round(avg(points), 1) AS ppg,
round(avg(assists), 1) AS apg,
round(avg(total_rebounds), 1) AS rpg,
round(avg(steals), 1) AS spg,
round(avg(blocks), 1) AS bpg,
round(avg(box_plus_minus), 2) AS bpm,
round(avg(usage_pct), 1) AS usage,
round(avg(true_shooting_pct) * 100, 1) AS ts_pct,
round(avg(net_rating), 1) AS net_rtg,
sum(CASE WHEN is_double_double THEN 1 ELSE 0 END) AS double_doubles,
sum(CASE WHEN is_triple_double THEN 1 ELSE 0 END) AS triple_doubles
FROM main_intermediate.int_player_performance
WHERE season_start_year = ? AND minutes_played >= 20
GROUP BY player_name, team
HAVING count(*) >= ?
ORDER BY bpm DESC
""",
[SEASON, min_games],
)
tab_board, tab_compare, tab_archetypes = st.tabs(
[
":material/leaderboard: Leaderboard",
":material/compare_arrows: Comparison",
":material/hub: Archetypes",
]
)
with tab_board:
if not players.empty:
fig = px.scatter(
players,
x="usage",
y="ts_pct",
size="ppg",
hover_name="player_name",
color="bpm",
color_continuous_scale="RdYlGn",
title="Usage vs True Shooting % (size = PPG, color = BPM)",
)
fig.update_layout(xaxis_title="Usage %", yaxis_title="True Shooting %")
fig.add_hline(y=players["ts_pct"].median(), line_dash="dot", line_color="gray", opacity=0.4)
fig.add_vline(x=players["usage"].median(), line_dash="dot", line_color="gray", opacity=0.4)
apply_arena_theme(fig, height=500)
st.plotly_chart(fig, width="stretch")
col1, col2 = st.columns(2)
with col1:
fig2 = px.scatter(
players,
x="ppg",
y="apg",
hover_name="player_name",
color="bpm",
color_continuous_scale="RdYlGn",
size="usage",
title="Scoring vs Playmaking",
)
apply_arena_theme(fig2)
st.plotly_chart(fig2, width="stretch")
with col2:
top_scorers = players.nlargest(15, "ppg")
fig3 = px.bar(
top_scorers,
x="ppg",
y="player_name",
orientation="h",
color="ts_pct",
color_continuous_scale="RdYlGn",
title="Top 15 Scorers (color = TS%)",
)
fig3.update_layout(yaxis=dict(autorange="reversed"))
apply_arena_theme(fig3, height=450)
st.plotly_chart(fig3, width="stretch")
st.dataframe(
players,
column_config={
"ts_pct": st.column_config.ProgressColumn(
"TS%", min_value=40, max_value=75, format="%.1f%%"
),
"usage": st.column_config.ProgressColumn(
"USG%", min_value=5, max_value=40, format="%.1f%%"
),
"bpm": st.column_config.NumberColumn("BPM", format="%.2f"),
},
width="stretch",
hide_index=True,
)
with tab_compare:
player_list = players["player_name"].tolist()
selected_players = st.multiselect(
"Select players to compare", player_list, default=player_list[:3]
)
if selected_players:
comp = players[players["player_name"].isin(selected_players)]
categories = ["ppg", "apg", "rpg", "spg", "bpg", "usage", "ts_pct"]
labels = ["PPG", "APG", "RPG", "SPG", "BPG", "USG%", "TS%"]
RADAR_COLORS = [
"#E09F3E",
"#10B981",
"#38BDF8",
"#F43F5E",
"#A855F7",
"#42D4F4",
"#F032E6",
"#BFEF45",
"#FABED4",
"#469990",
]
radar_indicators = [
{"name": labels[i], "max": float(players[c].max() * 1.15)}
for i, c in enumerate(categories)
]
radar_data = [
{
"value": [float(row[c]) for c in categories],
"name": row["player_name"],
"lineStyle": {"color": RADAR_COLORS[i % len(RADAR_COLORS)], "width": 2.5},
"areaStyle": {"color": RADAR_COLORS[i % len(RADAR_COLORS)], "opacity": 0.3},
"itemStyle": {"color": RADAR_COLORS[i % len(RADAR_COLORS)]},
}
for i, (_, row) in enumerate(comp.iterrows())
]
echarts_option = {
"legend": {"data": [d["name"] for d in radar_data], "top": "bottom"},
"color": RADAR_COLORS[: len(radar_data)],
"radar": {
"indicator": radar_indicators,
"shape": "polygon",
"splitArea": {
"areaStyle": {
"color": ["#0B0F17", "#151D2A", "#1E293B", "#0B0F17", "#151D2A"]
}
},
"axisLine": {"lineStyle": {"color": "rgba(200, 200, 200, 0.3)"}},
"splitLine": {"lineStyle": {"color": "rgba(200, 200, 200, 0.2)"}},
},
"series": [{"type": "radar", "data": radar_data}],
}
st_echarts(options=echarts_option, height="500px")
st.dataframe(
comp.set_index("player_name")[categories + ["bpm", "net_rtg", "games"]].rename(
columns=dict(zip(categories, labels))
),
width="stretch",
)
st.subheader("Parallel Coordinates (Top 15 BPM)")
top_parallel = players.nlargest(15, "bpm")
parallel_dims = ["ppg", "apg", "rpg", "usage", "ts_pct", "bpm", "net_rtg"]
parallel_labels = ["PPG", "APG", "RPG", "USG%", "TS%", "BPM", "Net Rtg"]
parallel_option = {
"parallelAxis": [
{
"dim": i,
"name": parallel_labels[i],
"min": float(top_parallel[parallel_dims[i]].min()),
"max": float(top_parallel[parallel_dims[i]].max()),
}
for i in range(len(parallel_dims))
],
"tooltip": {"trigger": "item"},
"series": [
{
"type": "parallel",
"lineStyle": {"width": 2, "opacity": 0.6},
"data": [
[float(row[c]) for c in parallel_dims]
for _, row in top_parallel.iterrows()
],
}
],
}
st_echarts(options=parallel_option, height="400px")
with tab_archetypes:
archetype_players = query(
"""
WITH player_archetype_counts AS (
SELECT player_name, team, usage_tier, impact_tier, shooting_efficiency_tier,
count(*) AS games_in_archetype
FROM main_intermediate.int_player_performance
WHERE season_start_year = ? AND minutes_played >= 20
AND usage_tier != 'Insufficient Minutes'
GROUP BY player_name, team, usage_tier, impact_tier, shooting_efficiency_tier
),
player_modal AS (
SELECT *, ROW_NUMBER() OVER (
PARTITION BY player_name, team ORDER BY games_in_archetype DESC
) AS rn
FROM player_archetype_counts
)
SELECT player_name, team, usage_tier, impact_tier, shooting_efficiency_tier,
games_in_archetype
FROM player_modal
WHERE rn = 1
""",
[SEASON],
)
if not archetype_players.empty:
archetypes = (
archetype_players.groupby(["usage_tier", "impact_tier", "shooting_efficiency_tier"])
.agg(players=("player_name", "count"))
.reset_index()
.query("players >= 2")
.sort_values("players", ascending=False)
)
fig_arch = px.sunburst(
archetypes,
path=["usage_tier", "impact_tier", "shooting_efficiency_tier"],
values="players",
title="Player Archetype Hierarchy (click segments, then filter below)",
color="players",
color_continuous_scale="Blues",
)
apply_arena_theme(fig_arch, height=600)
st.plotly_chart(fig_arch, width="stretch")
st.subheader(":material/manage_search: Drill Into Archetype")
usage_tiers = ["All"] + sorted(archetype_players["usage_tier"].unique().tolist())
sel_usage = st.selectbox("Usage Tier", usage_tiers, key="arch_usage")
filtered = archetype_players
if sel_usage != "All":
filtered = filtered[filtered["usage_tier"] == sel_usage]
impact_tiers = ["All"] + sorted(filtered["impact_tier"].unique().tolist())
sel_impact = st.selectbox("Impact Tier", impact_tiers, key="arch_impact")
if sel_impact != "All":
filtered = filtered[filtered["impact_tier"] == sel_impact]
eff_tiers = ["All"] + sorted(filtered["shooting_efficiency_tier"].unique().tolist())
sel_eff = st.selectbox("Shooting Efficiency Tier", eff_tiers, key="arch_eff")
if sel_eff != "All":
filtered = filtered[filtered["shooting_efficiency_tier"] == sel_eff]
st.caption(f"Showing {len(filtered)} players matching selection")
st.dataframe(
filtered[
[
"player_name",
"team",
"usage_tier",
"impact_tier",
"shooting_efficiency_tier",
"games_in_archetype",
]
]
.sort_values("games_in_archetype", ascending=False)
.reset_index(drop=True),
width="stretch",
hide_index=True,
)
st.subheader("Player Specialists")
specialists = query(
"""
SELECT player_name, team, count(*) AS games,
round(avg(points), 1) AS ppg,
round(avg(box_plus_minus), 2) AS bpm,
round(100.0 * sum(CASE WHEN is_versatile THEN 1 ELSE 0 END) / count(*), 1) AS pct_versatile,
round(100.0 * sum(CASE WHEN is_defensive_specialist THEN 1 ELSE 0 END) / count(*), 1) AS pct_def_spec,
round(100.0 * sum(CASE WHEN is_three_and_d THEN 1 ELSE 0 END) / count(*), 1) AS pct_three_and_d
FROM main_intermediate.int_player_performance
WHERE season_start_year = ? AND minutes_played >= 20
GROUP BY player_name, team
HAVING count(*) >= ?
""",
[SEASON, min_games],
)
col1, col2, col3 = st.columns(3)
with col1:
versatile = specialists[specialists["pct_versatile"] >= 25].sort_values(
"pct_versatile", ascending=False
)
st.metric("Versatile", len(versatile))
st.caption("≥25% of games with double-double stats")
st.dataframe(
versatile[["player_name", "team", "ppg", "pct_versatile"]],
hide_index=True,
width="stretch",
)
with col2:
def_spec = specialists[specialists["pct_def_spec"] >= 25].sort_values(
"pct_def_spec", ascending=False
)
st.metric("Defensive Specialists", len(def_spec))
st.caption("≥25% of games as defensive specialist")
st.dataframe(
def_spec[["player_name", "team", "ppg", "pct_def_spec"]],
hide_index=True,
width="stretch",
)
with col3:
three_d = specialists[specialists["pct_three_and_d"] >= 25].sort_values(
"pct_three_and_d", ascending=False
)
st.metric("Three-and-D", len(three_d))
st.caption("≥25% of games as 3-and-D player")
st.dataframe(
three_d[["player_name", "team", "ppg", "pct_three_and_d"]],
hide_index=True,
width="stretch",
)
+155
View File
@@ -0,0 +1,155 @@
import plotly.express as px
import streamlit as st
from utils.charts import AMBER, MADE_EMERALD, MISS_CRIMSON, apply_arena_theme
from utils.db import get_selected_season, query
SEASON, SEASON_LABEL = get_selected_season()
st.title(f":material/timer: Momentum — {SEASON_LABEL}")
tab_overview, tab_teams, tab_comebacks = st.tabs(
[
":material/bar_chart: Quarter Scoring",
":material/emoji_events: Team by Quarter",
":material/replay: Comebacks",
]
)
QUARTER_COLORS = {
"Q1": "#38BDF8",
"Q2": MADE_EMERALD,
"Q3": AMBER,
"Q4": MISS_CRIMSON,
}
with tab_overview:
quarter_avg = query(
"""
SELECT q.period,
round(avg(q.points_scored), 1) AS avg_pts,
round(avg(abs(q.period_point_differential)), 1) AS avg_abs_diff,
round(stddev(q.points_scored), 1) AS scoring_volatility
FROM main_marts.fct_quarter_scoring q
JOIN main_marts.dim_seasons s ON q.season_key = s.season_key
WHERE s.season_start_year = ? AND q.period IN ('Q1','Q2','Q3','Q4')
GROUP BY q.period ORDER BY q.period
""",
[SEASON],
)
col1, col2 = st.columns(2)
with col1:
fig = px.bar(
quarter_avg,
x="period",
y="avg_pts",
title="Avg Points per Quarter",
color="period",
color_discrete_map=QUARTER_COLORS,
labels={"period": "Quarter", "avg_pts": "Avg Points"},
)
fig.update_layout(showlegend=False)
apply_arena_theme(fig)
st.plotly_chart(fig, width="stretch")
with col2:
fig2 = px.bar(
quarter_avg,
x="period",
y="avg_abs_diff",
title="Avg Quarter Lopsidedness (|differential|)",
color="period",
color_discrete_map=QUARTER_COLORS,
labels={"period": "Quarter", "avg_abs_diff": "Avg |Differential|"},
)
fig2.update_layout(showlegend=False)
apply_arena_theme(fig2)
st.plotly_chart(fig2, width="stretch")
st.caption("Q4 has lower scoring and tighter margins — games tighten up in crunch time.")
with tab_teams:
quarter_sel = (
st.segmented_control(
"Quarter",
["Q1", "Q2", "Q3", "Q4"],
default="Q1",
)
or "Q1"
)
team_quarter = query(
"""
SELECT t.team_abbr AS team,
round(avg(q.points_scored), 1) AS avg_pts,
round(avg(q.period_point_differential), 2) AS avg_diff,
count(*) AS games
FROM main_marts.fct_quarter_scoring q
JOIN main_marts.dim_teams t ON q.team_key = t.team_key
JOIN main_marts.dim_seasons s ON q.season_key = s.season_key
WHERE s.season_start_year = ? AND q.period = ?
GROUP BY t.team_abbr HAVING count(*) >= 10
ORDER BY avg_diff DESC
""",
[SEASON, quarter_sel],
)
fig = px.bar(
team_quarter,
x="avg_pts",
y="team",
orientation="h",
color="avg_diff",
color_continuous_scale="RdYlGn",
title=f"{quarter_sel} Leaders — sorted by differential (color = +/-)",
)
fig.update_layout(yaxis=dict(autorange="reversed"))
apply_arena_theme(fig, height=600)
st.plotly_chart(fig, width="stretch")
with tab_comebacks:
comebacks = query(
"""
WITH q3_trailing AS (
SELECT q.game_id, q.team_key,
sum(q.period_point_differential) AS through_q3_diff
FROM main_marts.fct_quarter_scoring q
JOIN main_marts.dim_seasons s ON q.season_key = s.season_key
WHERE s.season_start_year = ? AND q.period IN ('Q1','Q2','Q3')
GROUP BY q.game_id, q.team_key
HAVING sum(q.period_point_differential) < -10
),
q4_results AS (
SELECT q.game_id, q.team_key, q.period_point_differential AS q4_diff
FROM main_marts.fct_quarter_scoring q
JOIN main_marts.dim_seasons s ON q.season_key = s.season_key
WHERE s.season_start_year = ? AND q.period = 'Q4'
)
SELECT t.team_abbr AS team,
count(*) AS trailing_10_after_q3,
sum(CASE WHEN c.through_q3_diff + r.q4_diff > 0 THEN 1 ELSE 0 END) AS comebacks,
round(100.0 * sum(CASE WHEN c.through_q3_diff + r.q4_diff > 0 THEN 1 ELSE 0 END) / count(*), 1) AS comeback_pct
FROM q3_trailing c
JOIN q4_results r ON c.game_id = r.game_id AND c.team_key = r.team_key
JOIN main_marts.dim_teams t ON c.team_key = t.team_key
GROUP BY t.team_abbr HAVING count(*) >= 3
ORDER BY comeback_pct DESC
""",
[SEASON, SEASON],
)
if not comebacks.empty:
fig = px.bar(
comebacks,
x="team",
y="comeback_pct",
color="comebacks",
color_continuous_scale="Greens",
title="Comeback Rate (Down 10+ After Q3)",
)
fig.update_layout(xaxis_title="", yaxis_title="Comeback %")
apply_arena_theme(fig)
st.plotly_chart(fig, width="stretch")
st.dataframe(comebacks, hide_index=True, width="stretch")
else:
st.info("No comeback data available for this season.")
+384
View File
@@ -0,0 +1,384 @@
import plotly.express as px
import streamlit as st
from utils.charts import (
AMBER,
ARENA_DIVERGING_SCALE,
MADE_EMERALD,
MISS_CRIMSON,
apply_arena_theme,
)
from utils.court import build_shot_chart_figure
from utils.db import get_selected_season, query
SEASON, SEASON_LABEL = get_selected_season()
st.title(f":material/my_location: Shot Studio — {SEASON_LABEL}")
tab_chart, tab_zones, tab_clutch = st.tabs(
[
":material/my_location: Shot Chart",
":material/bar_chart: Zone & Profile Analysis",
":material/timer: Clutch Shooting",
]
)
# --- TAB 1: Shot Chart ---
with tab_chart:
top_shooters = query(
"""
SELECT p.player_name, count(*) AS shots
FROM main_marts.fct_player_shots s
JOIN main_marts.dim_players p ON s.player_key = p.player_key
JOIN main_marts.dim_seasons sea ON s.season_key = sea.season_key
WHERE sea.season_start_year = ? AND s.shot_source = 'shot_chart'
GROUP BY p.player_name HAVING count(*) >= 50
ORDER BY shots DESC
""",
[SEASON],
)
if not top_shooters.empty:
ctrl1, ctrl2, ctrl3 = st.columns([1.35, 1.15, 0.9])
with ctrl1:
selected_player = st.selectbox(
"Player Selection",
top_shooters["player_name"].tolist(),
key="shot_studio_player",
)
with ctrl2:
period_filter = st.segmented_control(
"Period Filter",
["All", "Q1", "Q2", "Q3", "Q4", "OT"],
default="All",
key="shot_period_filter",
)
with ctrl3:
outcome_filter = st.segmented_control(
"Shot Outcome",
["All", "Made", "Missed"],
default="All",
key="shot_outcome_filter",
)
shot_data = query(
"""
SELECT s.shot_x_coordinate AS x, s.shot_y_coordinate AS y,
s.is_made, s.shot_distance_zone, s.distance_ft, s.quarter_number,
s.is_three_pointer, s.shot_point_value
FROM main_marts.fct_player_shots s
JOIN main_marts.dim_players p ON s.player_key = p.player_key
JOIN main_marts.dim_seasons sea ON s.season_key = sea.season_key
WHERE sea.season_start_year = ? AND s.shot_source = 'shot_chart'
AND p.player_name = ?
""",
[SEASON, selected_player],
)
# Apply interactive period & outcome filters
filtered_shots = shot_data
if period_filter and period_filter != "All":
q_map = {"Q1": 1, "Q2": 2, "Q3": 3, "Q4": 4, "OT": 5}
filtered_shots = filtered_shots[
filtered_shots["quarter_number"] == q_map[period_filter]
]
if outcome_filter == "Made":
filtered_shots = filtered_shots[filtered_shots["is_made"]]
elif outcome_filter == "Missed":
filtered_shots = filtered_shots[~filtered_shots["is_made"]]
if not filtered_shots.empty:
total_fga = len(filtered_shots)
total_fgm = int(filtered_shots["is_made"].sum())
fg_pct = round(100.0 * total_fgm / total_fga, 1)
threes = filtered_shots[filtered_shots["is_three_pointer"]]
three_fgm = int(threes["is_made"].sum()) if not threes.empty else 0
three_pct = round(100.0 * three_fgm / len(threes), 1) if not threes.empty else 0.0
efg_pct = round(100.0 * (total_fgm + 0.5 * three_fgm) / total_fga, 1)
total_pts = int(
(filtered_shots["is_made"].astype(int) * filtered_shots["shot_point_value"]).sum()
)
pps = round(total_pts / total_fga, 2)
# Player Shooting Summary Strip
k1, k2, k3, k4 = st.columns(4)
with k1:
with st.container(border=True):
st.metric("Field Goal Attempts", f"{total_fga:,}", f"{total_fgm:,} Made")
with k2:
with st.container(border=True):
st.metric("FG% / eFG%", f"{fg_pct}%", f"{efg_pct}% eFG")
with k3:
with st.container(border=True):
st.metric("3-Point Accuracy", f"{three_pct}%", f"{len(threes):,} 3PA")
with k4:
with st.container(border=True):
st.metric("Points Per Shot", f"{pps:.2f}", f"{total_pts:,} Pts")
col_court, col_zones = st.columns([1.48, 1])
with col_court:
with st.container(border=True):
fig = build_shot_chart_figure(filtered_shots, selected_player)
st.plotly_chart(fig, width="stretch")
st.caption(
"Calibrated NBA Half-Court • Rim: (240, 52.5) • "
"3PT Arc: 23.75 ft • Corner 3: 22.0 ft"
)
with col_zones:
with st.container(border=True):
st.subheader(":material/analytics: Zone Efficiency vs League")
st.caption("Ordered by court distance • White marker = League Avg FG%")
zone_comparison = query(
"""
WITH league AS (
SELECT s.shot_distance_zone,
round(100.0 * sum(s.is_made::int) / count(*), 1) AS league_fg_pct
FROM main_marts.fct_player_shots s
JOIN main_marts.dim_seasons sea ON s.season_key = sea.season_key
WHERE sea.season_start_year = ? AND s.shot_source = 'shot_chart'
GROUP BY s.shot_distance_zone
),
player AS (
SELECT s.shot_distance_zone, count(*) AS attempts,
sum(s.is_made::int) AS makes,
round(100.0 * sum(s.is_made::int) / count(*), 1) AS player_fg_pct
FROM main_marts.fct_player_shots s
JOIN main_marts.dim_players p ON s.player_key = p.player_key
JOIN main_marts.dim_seasons sea ON s.season_key = sea.season_key
WHERE sea.season_start_year = ? AND s.shot_source = 'shot_chart'
AND p.player_name = ?
GROUP BY s.shot_distance_zone
)
SELECT p.shot_distance_zone AS zone, p.attempts, p.makes, p.player_fg_pct,
l.league_fg_pct, round(p.player_fg_pct - l.league_fg_pct, 1) AS vs_league,
sz.zone_order
FROM player p
JOIN league l ON p.shot_distance_zone = l.shot_distance_zone
LEFT JOIN main_marts.dim_shot_zones sz
ON p.shot_distance_zone = sz.shot_distance_zone
ORDER BY sz.zone_order
""",
[SEASON, SEASON, selected_player],
)
if not zone_comparison.empty:
total_season_fga = int(zone_comparison["attempts"].sum()) or 1
cards_html = []
for _, z in zone_comparison.iterrows():
is_pos = z["vs_league"] >= 0
delta_col = MADE_EMERALD if is_pos else MISS_CRIMSON
delta_bg = (
"rgba(16, 185, 129, 0.14)"
if is_pos
else "rgba(244, 63, 94, 0.14)"
)
bar_col = (
MADE_EMERALD
if is_pos
else AMBER
if z["vs_league"] >= -1.5
else MISS_CRIMSON
)
vol_share = round(100.0 * z["attempts"] / total_season_fga, 1)
player_w = min(100.0, float(z["player_fg_pct"]))
league_w = min(100.0, float(z["league_fg_pct"]))
cards_html.append(
f"""
<div style="background: rgba(11, 15, 23, 0.65); border: 1px solid #26354A;
border-radius: 8px; padding: 9px 12px; margin-bottom: 8px;
font-family: -apple-system, BlinkMacSystemFont, 'Inter', sans-serif;">
<div style="display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 5px;">
<div>
<span style="font-size: 13px; font-weight: 600; color: #F1F5F9; margin-right: 6px;">{z['zone']}</span>
<span style="font-family: monospace; font-size: 11px; color: #94A3B8;">
{int(z['makes'])}/{int(z['attempts'])} ({vol_share}%)
</span>
</div>
<div style="display: flex; align-items: center; gap: 6px; font-family: monospace;">
<span style="font-size: 14px; font-weight: 700; color: #F1F5F9;">{z['player_fg_pct']}%</span>
<span style="font-size: 11px; font-weight: 600; padding: 1.5px 6px; border-radius: 4px;
color: {delta_col}; background: {delta_bg};">
{z['vs_league']:+.1f}% vs Lg
</span>
</div>
</div>
<div style="height: 6px; background: #1E293B; border-radius: 3px; position: relative;">
<div style="width: {player_w}%; height: 100%; background: {bar_col}; border-radius: 3px;"></div>
<div style="position: absolute; top: -3px; left: {league_w}%; width: 2px; height: 12px;
background: #F1F5F9; box-shadow: 0 0 4px #000;"
title="League Avg: {z['league_fg_pct']}%"></div>
</div>
<div style="display: flex; justify-content: space-between; margin-top: 4px;
font-family: monospace; font-size: 10.5px; color: #64748B;">
<span>Player: {z['player_fg_pct']}%</span>
<span>│ League Avg: {z['league_fg_pct']}%</span>
</div>
</div>
"""
)
st.html("".join(cards_html))
else:
st.info("No shots match the selected period/outcome filters.")
else:
st.info("No spatial shot chart data available for this season.")
# --- TAB 2: Zone & Profile Analysis ---
with tab_zones:
st.subheader("League Zone Efficiency")
zone_data = query(
"""
SELECT sz.shot_distance_zone, sz.zone_group, sz.zone_order,
count(*) AS attempts, sum(s.is_made::int) AS makes,
round(100.0 * sum(s.is_made::int) / count(*), 1) AS fg_pct,
round(avg(s.shot_point_value * s.is_made::int), 3) AS pts_per_shot
FROM main_marts.fct_player_shots s
JOIN main_marts.dim_shot_zones sz ON s.shot_distance_zone = sz.shot_distance_zone
JOIN main_marts.dim_seasons sea ON s.season_key = sea.season_key
WHERE sea.season_start_year = ?
GROUP BY sz.shot_distance_zone, sz.zone_group, sz.zone_order
ORDER BY sz.zone_order
""",
[SEASON],
)
col1, col2 = st.columns(2)
with col1:
fig = px.bar(
zone_data,
x="shot_distance_zone",
y="attempts",
color="fg_pct",
color_continuous_scale=ARENA_DIVERGING_SCALE,
title="Volume by Zone (color = FG%)",
)
apply_arena_theme(fig)
st.plotly_chart(fig, width="stretch")
with col2:
fig = px.bar(
zone_data,
x="shot_distance_zone",
y="pts_per_shot",
color="zone_group",
color_discrete_sequence=[AMBER, MADE_EMERALD, "#38BDF8", MISS_CRIMSON],
title="Points per Shot by Zone",
)
apply_arena_theme(fig)
st.plotly_chart(fig, width="stretch")
st.subheader("Player Shot Profiles")
profiles = query(
"""
SELECT p.player_name,
count(*) AS games,
round(avg(s.three_point_rate) * 100, 1) AS three_rate,
round(avg(s.at_rim_rate) * 100, 1) AS rim_rate,
round(avg(s.mid_range_rate) * 100, 1) AS mid_rate,
round(avg(s.avg_fg_distance_ft), 1) AS avg_dist,
round(avg(s.true_shooting_pct) * 100, 1) AS ts_pct
FROM main_marts.fct_player_game_shooting s
JOIN main_marts.dim_players p ON s.player_key = p.player_key
JOIN main_marts.dim_seasons sea ON s.season_key = sea.season_key
WHERE sea.season_start_year = ? AND s.fg_attempts >= 10
GROUP BY p.player_name HAVING count(*) >= 20
ORDER BY ts_pct DESC
""",
[SEASON],
)
if not profiles.empty:
fig = px.scatter(
profiles,
x="three_rate",
y="rim_rate",
size="ts_pct",
hover_name="player_name",
color="ts_pct",
color_continuous_scale=ARENA_DIVERGING_SCALE,
title="Shot Profile Clustering: Three-Point Rate vs Rim Rate (size/color = TS%)",
)
fig.update_layout(xaxis_title="Three-Point Rate %", yaxis_title="At-Rim Rate %")
apply_arena_theme(fig, height=500)
st.plotly_chart(fig, width="stretch")
profile_types = query(
"""
SELECT shot_profile_type, count(*) AS player_games,
round(avg(true_shooting_pct) * 100, 1) AS avg_ts,
round(avg(total_points), 1) AS avg_pts
FROM main_marts.fct_player_game_shooting s
JOIN main_marts.dim_seasons sea ON s.season_key = sea.season_key
WHERE sea.season_start_year = ?
GROUP BY shot_profile_type ORDER BY player_games DESC
""",
[SEASON],
)
if not profile_types.empty:
fig = px.treemap(
profile_types,
path=["shot_profile_type"],
values="player_games",
color="avg_ts",
color_continuous_scale=ARENA_DIVERGING_SCALE,
title="Shot Profile Types (size = frequency, color = TS%)",
)
apply_arena_theme(fig)
st.plotly_chart(fig, width="stretch")
# --- TAB 3: Clutch Shooting ---
with tab_clutch:
clutch = query(
"""
SELECT p.player_name,
count(*) AS clutch_attempts,
sum(s.is_made::int) AS clutch_makes,
round(100.0 * sum(s.is_made::int) / count(*), 1) AS clutch_pct,
round(avg(s.score_margin_at_shot), 1) AS avg_margin
FROM main_marts.fct_player_shots s
JOIN main_marts.dim_players p ON s.player_key = p.player_key
JOIN main_marts.dim_seasons sea ON s.season_key = sea.season_key
WHERE s.is_clutch_shot AND sea.season_start_year = ?
GROUP BY p.player_name HAVING count(*) >= 15
ORDER BY clutch_makes DESC LIMIT 30
""",
[SEASON],
)
if not clutch.empty:
fig = px.scatter(
clutch,
x="clutch_attempts",
y="clutch_pct",
size="clutch_makes",
hover_name="player_name",
color="clutch_pct",
color_continuous_scale=ARENA_DIVERGING_SCALE,
title="Clutch Shooting: Volume vs Accuracy",
)
fig.add_hline(
y=clutch["clutch_pct"].median(),
line_dash="dash",
line_color=AMBER,
annotation_text="Median",
)
fig.update_layout(xaxis_title="Clutch Attempts", yaxis_title="Clutch FG%")
apply_arena_theme(fig, height=500)
st.plotly_chart(fig, width="stretch")
st.subheader("Clutch Leaders")
st.dataframe(
clutch[["player_name", "clutch_attempts", "clutch_makes", "clutch_pct", "avg_margin"]],
hide_index=True,
width="stretch",
column_config={
"clutch_pct": st.column_config.ProgressColumn(
"FG%", min_value=25, max_value=65, format="%.1f%%"
),
},
)
else:
st.info("No clutch shooting data available for this season.")
+283
View File
@@ -0,0 +1,283 @@
import plotly.express as px
import plotly.graph_objects as go
import streamlit as st
from utils.charts import AMBER, MADE_EMERALD, MISS_CRIMSON, apply_arena_theme
from utils.db import get_selected_season, query
SEASON, SEASON_LABEL = get_selected_season()
st.title(f":material/shield: Team Intelligence — {SEASON_LABEL}")
ratings = query(
"""
SELECT team, count(*) AS games,
sum(CASE WHEN game_result = 'W' THEN 1 ELSE 0 END) AS wins,
sum(CASE WHEN game_result = 'L' THEN 1 ELSE 0 END) AS losses,
round(100.0 * sum(CASE WHEN game_result = 'W' THEN 1 ELSE 0 END) / count(*), 1) AS win_pct,
round(avg(offensive_rating), 1) AS off_rtg,
round(avg(defensive_rating), 1) AS def_rtg,
round(avg(net_rating), 1) AS net_rtg,
round(avg(pace), 1) AS pace,
round(avg(effective_fg_pct) * 100, 1) AS efg_pct,
round(avg(turnover_rate) * 100, 1) AS tov_rate,
round(avg(offensive_rebound_rate) * 100, 1) AS orb_rate,
round(avg(free_throw_rate) * 100, 1) AS ft_rate
FROM main_intermediate.int_team_performance
WHERE season_start_year = ?
GROUP BY team ORDER BY net_rtg DESC
""",
[SEASON],
)
tab_ratings, tab_factors, tab_drilldown = st.tabs(
[
":material/bar_chart: Ratings & Standings",
":material/grid_view: Four Factors & Styles",
":material/manage_search: Team Drill-Down",
]
)
with tab_ratings:
fig = px.scatter(
ratings,
x="off_rtg",
y="def_rtg",
text="team",
size="win_pct",
color="net_rtg",
color_continuous_scale="RdYlGn",
title="Offensive vs Defensive Rating (size = Win%)",
)
fig.update_traces(textposition="top center", marker=dict(sizemin=8))
avg_off = ratings["off_rtg"].mean()
avg_def = ratings["def_rtg"].mean()
fig.add_hline(y=avg_def, line_dash="dash", line_color="gray", opacity=0.4)
fig.add_vline(x=avg_off, line_dash="dash", line_color="gray", opacity=0.4)
fig.update_layout(
xaxis_title="Offensive Rating → (higher = better)",
yaxis_title="← Defensive Rating (lower = better)",
yaxis=dict(autorange="reversed"),
)
apply_arena_theme(fig, height=550)
st.plotly_chart(fig, width="stretch")
win_sorted = ratings.sort_values("win_pct", ascending=True)
fig_win = px.bar(
win_sorted,
x="win_pct",
y="team",
orientation="h",
color="net_rtg",
color_continuous_scale="RdYlGn",
title="Win % (color = Net Rating)",
)
fig_win.update_layout(yaxis_title="", xaxis_title="Win %")
fig_win.add_vline(x=50, line_dash="dash", line_color="gray", opacity=0.5)
apply_arena_theme(fig_win, height=600)
st.plotly_chart(fig_win, width="stretch")
st.dataframe(
ratings,
column_config={
"win_pct": st.column_config.ProgressColumn(
"Win%", min_value=0, max_value=100, format="%.1f%%"
),
"efg_pct": st.column_config.ProgressColumn(
"eFG%", min_value=45, max_value=60, format="%.1f%%"
),
},
hide_index=True,
width="stretch",
)
with tab_factors:
st.subheader("Four Factors")
st.caption(
"The four key drivers of team success: eFG%, Turnover Rate, Offensive Rebound Rate, Free Throw Rate"
)
col1, col2 = st.columns(2)
with col1:
top10 = ratings.nlargest(10, "efg_pct")
fig = px.bar(
top10,
x="efg_pct",
y="team",
orientation="h",
color="efg_pct",
color_continuous_scale="Greens",
title="eFG% (Top 10)",
)
fig.update_layout(yaxis=dict(autorange="reversed"), showlegend=False)
apply_arena_theme(fig, height=350)
st.plotly_chart(fig, width="stretch")
with col2:
top10 = ratings.nsmallest(10, "tov_rate")
fig = px.bar(
top10,
x="tov_rate",
y="team",
orientation="h",
color="tov_rate",
color_continuous_scale="Reds_r",
title="Turnover Rate (Top 10 — lowest)",
)
fig.update_layout(yaxis=dict(autorange="reversed"), showlegend=False)
apply_arena_theme(fig, height=350)
st.plotly_chart(fig, width="stretch")
col3, col4 = st.columns(2)
with col3:
top10 = ratings.nlargest(10, "orb_rate")
fig = px.bar(
top10,
x="orb_rate",
y="team",
orientation="h",
color="orb_rate",
color_continuous_scale="Purples",
title="Offensive Rebound Rate (Top 10)",
)
fig.update_layout(yaxis=dict(autorange="reversed"), showlegend=False)
apply_arena_theme(fig, height=350)
st.plotly_chart(fig, width="stretch")
with col4:
top10 = ratings.nlargest(10, "ft_rate")
fig = px.bar(
top10,
x="ft_rate",
y="team",
orientation="h",
color="ft_rate",
color_continuous_scale="Oranges",
title="Free Throw Rate (Top 10)",
)
fig.update_layout(yaxis=dict(autorange="reversed"), showlegend=False)
apply_arena_theme(fig, height=350)
st.plotly_chart(fig, width="stretch")
st.subheader("Team Styles")
col1, col2 = st.columns(2)
with col1:
styles = query(
"""
SELECT shot_selection_style, ball_movement_style, count(*) AS games,
round(avg(CASE WHEN game_result = 'W' THEN 1.0 ELSE 0.0 END) * 100, 1) AS win_pct
FROM main_intermediate.int_team_performance
WHERE season_start_year = ?
GROUP BY shot_selection_style, ball_movement_style
ORDER BY win_pct DESC
""",
[SEASON],
)
fig = px.sunburst(
styles,
path=["shot_selection_style", "ball_movement_style"],
values="games",
color="win_pct",
color_continuous_scale="RdYlGn",
title="Offense: Shot Selection × Ball Movement",
)
apply_arena_theme(fig)
st.plotly_chart(fig, width="stretch")
with col2:
defense = query(
"""
SELECT defensive_activity, ball_movement_style, count(*) AS games,
round(avg(CASE WHEN game_result = 'W' THEN 1.0 ELSE 0.0 END) * 100, 1) AS win_pct
FROM main_intermediate.int_team_performance
WHERE season_start_year = ?
GROUP BY defensive_activity, ball_movement_style
ORDER BY win_pct DESC
""",
[SEASON],
)
fig = px.sunburst(
defense,
path=["defensive_activity", "ball_movement_style"],
values="games",
color="win_pct",
color_continuous_scale="RdYlGn",
title="Defense: Activity × Ball Movement",
)
apply_arena_theme(fig)
st.plotly_chart(fig, width="stretch")
with tab_drilldown:
team_list = ratings["team"].tolist()
selected_team = st.selectbox("Select Team", team_list)
trajectory = query(
"""
SELECT game_date::date::varchar AS game_date,
round(net_rating, 1) AS net_rtg,
round(avg(net_rating) OVER (
ORDER BY game_date ROWS BETWEEN 9 PRECEDING AND CURRENT ROW
), 1) AS rolling_net_rtg,
round(avg(offensive_rating) OVER (
ORDER BY game_date ROWS BETWEEN 9 PRECEDING AND CURRENT ROW
), 1) AS rolling_off,
round(avg(defensive_rating) OVER (
ORDER BY game_date ROWS BETWEEN 9 PRECEDING AND CURRENT ROW
), 1) AS rolling_def
FROM main_intermediate.int_team_performance
WHERE season_start_year = ? AND team = ?
ORDER BY game_date
""",
[SEASON, selected_team],
)
if not trajectory.empty:
fig = go.Figure()
fig.add_trace(
go.Scatter(
x=trajectory["game_date"],
y=trajectory["rolling_net_rtg"],
name="Net Rating",
mode="lines",
line=dict(color=AMBER, width=3),
)
)
fig.add_trace(
go.Scatter(
x=trajectory["game_date"],
y=trajectory["rolling_off"],
name="Offense",
mode="lines",
line=dict(color=MADE_EMERALD, width=1.5, dash="dot"),
)
)
fig.add_trace(
go.Scatter(
x=trajectory["game_date"],
y=trajectory["rolling_def"],
name="Defense",
mode="lines",
line=dict(color=MISS_CRIMSON, width=1.5, dash="dot"),
)
)
fig.add_hline(y=0, line_dash="dash", line_color="gray", opacity=0.4)
fig.update_layout(
title=f"{selected_team} — 10-Game Rolling Ratings",
xaxis_title="Date",
yaxis_title="Rating",
)
apply_arena_theme(fig, height=400)
st.plotly_chart(fig, width="stretch")
st.subheader(f"{selected_team} Game Log")
game_log = query(
"""
SELECT game_date::date::varchar AS date, opponent_team AS opp, game_result AS result,
points AS pts, round(offensive_rating, 1) AS off_rtg,
round(defensive_rating, 1) AS def_rtg, round(net_rating, 1) AS net_rtg,
round(pace, 1) AS pace
FROM main_intermediate.int_team_performance
WHERE season_start_year = ? AND team = ?
ORDER BY game_date DESC
""",
[SEASON, selected_team],
)
st.dataframe(game_log, hide_index=True, width="stretch")
+1
View File
@@ -0,0 +1 @@
"""Shared database, court geometry, and chart theming utilities for the Streamlit NBA app."""
+41
View File
@@ -0,0 +1,41 @@
import plotly.graph_objects as go
# Arena Nocturne Design Tokens
AMBER = "#E09F3E"
OBSIDIAN = "#0B0F17"
SURFACE = "#151D2A"
SURFACE_SUBTLE = "#1E293B"
BORDER = "#26354A"
MADE_EMERALD = "#10B981"
MISS_CRIMSON = "#F43F5E"
TEXT_WHITE = "#F1F5F9"
TEXT_CHALK = "#94A3B8"
COURT_FLOOR = "#0D141E"
COURT_LINE = "#64748B"
# Diverging & sequential scales aligned to Arena Nocturne
ARENA_DIVERGING_SCALE = [
[0.0, "#F43F5E"],
[0.5, "#E09F3E"],
[1.0, "#10B981"],
]
def apply_arena_theme(fig: go.Figure, height: int | None = None) -> go.Figure:
"""Apply consistent Arena Nocturne styling to a Plotly figure."""
layout_updates = dict(
paper_bgcolor="rgba(0,0,0,0)",
plot_bgcolor="rgba(13, 20, 30, 0.55)",
font=dict(color=TEXT_WHITE),
xaxis=dict(gridcolor=SURFACE_SUBTLE, zerolinecolor=BORDER),
yaxis=dict(gridcolor=SURFACE_SUBTLE, zerolinecolor=BORDER),
hoverlabel=dict(
bgcolor=SURFACE,
bordercolor=AMBER,
font=dict(color=TEXT_WHITE),
),
)
if height is not None:
layout_updates["height"] = height
fig.update_layout(**layout_updates)
return fig
+228
View File
@@ -0,0 +1,228 @@
import numpy as np
import pandas as pd
import plotly.graph_objects as go
from utils.charts import (
AMBER,
BORDER,
COURT_FLOOR,
COURT_LINE,
MADE_EMERALD,
MISS_CRIMSON,
SURFACE,
TEXT_WHITE,
)
def add_nba_half_court(fig: go.Figure) -> go.Figure:
"""Add empirically calibrated NBA half-court markings to a Plotly figure.
Coordinate system (Basketball-Reference half-court pixel units, 1 ft ≈ 10 units):
- Rim center: (240.0, 52.5)
- Court width: 50 ft = 500 units (-10 to 490, centered at x=240)
- Half-court length: 47 ft = 470 units (y=0 baseline to y=470 half-court line)
- Paint / key: 16 ft wide (x=160..320), 19 ft deep (y=0..190)
- Restricted area: 4 ft radius (r=40) around rim
- Corner 3-point lines: 22 ft from basket center (x=20 and x=460), y=0..142.0
- Above-the-break 3-point arc: 23.75 ft radius (r=237.5) around rim
"""
# Outer half-court boundary
fig.add_shape(
type="rect", x0=-10, x1=490, y0=0, y1=470,
line=dict(color=BORDER, width=2),
)
# Paint / Key (16ft wide, 19ft deep) with Hardwood Amber subtle tint
fig.add_shape(
type="rect", x0=160, x1=320, y0=0, y1=190,
line=dict(color=COURT_LINE, width=1.8),
fillcolor="rgba(224, 159, 62, 0.07)",
)
# Free throw circle (top half solid, bottom half dashed)
theta_ft = np.linspace(0, np.pi, 50)
fig.add_trace(
go.Scatter(
x=240 + 60 * np.cos(theta_ft),
y=190 + 60 * np.sin(theta_ft),
mode="lines",
line=dict(color=COURT_LINE, width=1.8),
showlegend=False,
hoverinfo="skip",
)
)
fig.add_trace(
go.Scatter(
x=240 + 60 * np.cos(-theta_ft),
y=190 + 60 * np.sin(-theta_ft),
mode="lines",
line=dict(color=COURT_LINE, width=1.2, dash="dash"),
showlegend=False,
hoverinfo="skip",
)
)
# Restricted area arc (4ft radius = 40 units)
rim_x, rim_y = 240.0, 52.5
theta_ra = np.linspace(0, np.pi, 30)
fig.add_trace(
go.Scatter(
x=rim_x + 40 * np.cos(theta_ra),
y=rim_y + 40 * np.sin(theta_ra),
mode="lines",
line=dict(color=COURT_LINE, width=1.8),
showlegend=False,
hoverinfo="skip",
)
)
# Calibrated 3-point line:
# Straight corner lines at x=20 and x=460 (22.0 ft from basket center)
# intersecting the 23.75 ft (r=237.5) arc at y = 52.5 + sqrt(237.5^2 - 220^2) ≈ 142.0
r_3pt = 237.5
dx_corner = 220.0
theta_3_start = np.arccos(dx_corner / r_3pt)
corner_x_left = rim_x - dx_corner
corner_x_right = rim_x + dx_corner
corner_y = rim_y + r_3pt * np.sin(theta_3_start)
fig.add_shape(
type="line", x0=corner_x_left, x1=corner_x_left, y0=0, y1=corner_y,
line=dict(color=COURT_LINE, width=2.2),
)
fig.add_shape(
type="line", x0=corner_x_right, x1=corner_x_right, y0=0, y1=corner_y,
line=dict(color=COURT_LINE, width=2.2),
)
theta_3 = np.linspace(theta_3_start, np.pi - theta_3_start, 80)
fig.add_trace(
go.Scatter(
x=rim_x + r_3pt * np.cos(theta_3),
y=rim_y + r_3pt * np.sin(theta_3),
mode="lines",
line=dict(color=COURT_LINE, width=2.2),
showlegend=False,
hoverinfo="skip",
)
)
# Center court circle arc at half-court line (y=470)
theta_cc = np.linspace(np.pi, 2 * np.pi, 40)
fig.add_trace(
go.Scatter(
x=rim_x + 60 * np.cos(theta_cc),
y=470 + 60 * np.sin(theta_cc),
mode="lines",
line=dict(color=BORDER, width=1.8),
showlegend=False,
hoverinfo="skip",
)
)
# Backboard & Rim (Hardwood Amber landmarks rendered on top)
fig.add_shape(
type="line", x0=210, x1=270, y0=40, y1=40,
line=dict(color=AMBER, width=3),
)
fig.add_shape(
type="circle", x0=232, x1=248, y0=44.5, y1=60.5,
line=dict(color=AMBER, width=2.5),
fillcolor="rgba(11, 15, 23, 0.85)",
)
return fig
def build_shot_chart_figure(shot_data: pd.DataFrame, player_name: str) -> go.Figure:
"""Create the Arena Nocturne half-court spatial telemetry figure for a player's shots."""
fig = go.Figure()
missed = shot_data[~shot_data["is_made"]]
made = shot_data[shot_data["is_made"]]
if not missed.empty:
fig.add_trace(
go.Scatter(
x=missed["x"],
y=missed["y"],
mode="markers",
name=f"Missed ({len(missed):,})",
marker=dict(
size=6.5,
color="rgba(244, 63, 94, 0.20)",
line=dict(width=1.2, color=MISS_CRIMSON),
),
customdata=np.stack(
[missed["shot_distance_zone"], missed["distance_ft"], missed["quarter_number"]],
axis=-1,
),
hovertemplate=(
"<b>Missed FG</b><br>"
"Zone: %{customdata[0]}<br>"
"Distance: %{customdata[1]} ft<br>"
"Period: Q%{customdata[2]}<extra></extra>"
),
)
)
if not made.empty:
fig.add_trace(
go.Scatter(
x=made["x"],
y=made["y"],
mode="markers",
name=f"Made ({len(made):,})",
marker=dict(
size=7,
color=MADE_EMERALD,
opacity=0.88,
line=dict(width=0.7, color="#064E3B"),
),
customdata=np.stack(
[made["shot_distance_zone"], made["distance_ft"], made["quarter_number"]],
axis=-1,
),
hovertemplate=(
"<b>Made FG</b><br>"
"Zone: %{customdata[0]}<br>"
"Distance: %{customdata[1]} ft<br>"
"Period: Q%{customdata[2]}<extra></extra>"
),
)
)
add_nba_half_court(fig)
fig.update_layout(
title=dict(
text=f"{player_name} — Half-Court Spatial Telemetry ({len(shot_data):,} FGA)",
font=dict(size=15, color=TEXT_WHITE),
),
height=580,
xaxis=dict(showgrid=False, zeroline=False, showticklabels=False, range=[-20, 500]),
yaxis=dict(
showgrid=False,
zeroline=False,
showticklabels=False,
range=[-15, 480],
scaleanchor="x",
),
paper_bgcolor="rgba(0,0,0,0)",
plot_bgcolor=COURT_FLOOR,
font=dict(color=TEXT_WHITE),
legend=dict(
orientation="h",
yanchor="bottom",
y=1.01,
xanchor="right",
x=1,
font=dict(size=12, color=TEXT_WHITE),
),
hoverlabel=dict(
bgcolor=SURFACE,
bordercolor=AMBER,
font=dict(color=TEXT_WHITE),
),
margin=dict(l=10, r=10, t=45, b=10),
)
return fig
+62
View File
@@ -0,0 +1,62 @@
from pathlib import Path
import duckdb
import pandas as pd
import streamlit as st
def get_db_path() -> Path:
"""Resolve local DuckDB file path across reports and data/DB locations."""
repo_root = Path(__file__).resolve().parent.parent.parent
candidates = [
repo_root / "dbt_nba" / "reports" / "sources" / "nba" / "dbt_nba.duckdb",
repo_root / "data" / "DB" / "dbt_nba.duckdb",
Path("dbt_nba/reports/sources/nba/dbt_nba.duckdb"),
Path("data/DB/dbt_nba.duckdb"),
]
for path in candidates:
if path.exists():
return path.resolve()
return candidates[0]
@st.cache_resource
def get_connection():
"""Persistent read-only DuckDB connection."""
db_path = get_db_path()
if not db_path.exists():
st.error(
f"Database file not found at `{db_path}`. "
"Run `./scripts/run_app.sh --sync` or `./scripts/pipeline.sh` to initialize."
)
st.stop()
return duckdb.connect(str(db_path), read_only=True)
@st.cache_data(ttl=3600)
def query(sql: str, params: list | None = None) -> pd.DataFrame:
"""Execute SQL (optionally parameterized) and return cached DataFrame."""
conn = get_connection()
if params:
return conn.execute(sql, params).df()
return conn.execute(sql).df()
@st.cache_data(ttl=3600)
def get_seasons() -> pd.DataFrame:
"""Return available seasons ordered most recent first."""
return query(
"SELECT season_start_year, season_display "
"FROM main_marts.dim_seasons "
"ORDER BY season_start_year DESC"
)
def get_selected_season() -> tuple[int, str]:
"""Return (season_start_year, season_display) from session state."""
seasons = get_seasons()
season_options = seasons["season_start_year"].tolist()
season_labels = dict(zip(seasons["season_start_year"], seasons["season_display"]))
if "season" not in st.session_state or st.session_state["season"] not in season_labels:
st.session_state["season"] = season_options[0]
season = st.session_state["season"]
return season, season_labels[season]
+48
View File
@@ -0,0 +1,48 @@
from pathlib import Path
import unittest
from streamlit.testing.v1 import AppTest
APP_PATH = str(Path(__file__).resolve().parent.parent / "streamlit_app" / "app.py")
class TestStreamlitNBAApp(unittest.TestCase):
def test_entrypoint_and_all_pages_render(self):
at = AppTest.from_file(APP_PATH, default_timeout=15).run()
self.assertFalse(at.exception, f"Entrypoint failed: {at.exception}")
self.assertIn("League Pulse", at.title[0].value)
pages = [
("app_pages/shot_charts.py", "Shot Studio"),
("app_pages/teams.py", "Team Intelligence"),
("app_pages/player_lab.py", "Player Lab"),
("app_pages/head_to_head.py", "Matchup Center"),
("app_pages/game_trends.py", "Era Trends"),
("app_pages/quarter_analysis.py", "Momentum"),
]
for page_path, expected_title in pages:
at.switch_page(page_path).run(timeout=15)
self.assertFalse(at.exception, f"Page {page_path} failed: {at.exception}")
self.assertIn(expected_title, at.title[0].value)
def test_shot_studio_filters_and_metrics(self):
at = AppTest.from_file(APP_PATH, default_timeout=15).run()
at.switch_page("app_pages/shot_charts.py").run(timeout=15)
self.assertFalse(at.exception)
# Verify 4 summary KPI metrics are rendered on the Shot Chart tab
metric_labels = [m.label for m in at.metric]
self.assertIn("Field Goal Attempts", metric_labels)
self.assertIn("FG% / eFG%", metric_labels)
self.assertIn("3-Point Accuracy", metric_labels)
self.assertIn("Points Per Shot", metric_labels)
# Filter to Q4 shots and Made outcome via segmented_control
at.button_group(key="shot_period_filter").select("Q4").run(timeout=15)
self.assertFalse(at.exception)
at.button_group(key="shot_outcome_filter").select("Made").run(timeout=15)
self.assertFalse(at.exception)
self.assertEqual(at.metric[1].value, "100.0%")
if __name__ == "__main__":
unittest.main()