Compare commits
4
Commits
95bb2d704f
...
visuals
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
11a5cc0f18 | ||
|
|
e7c8e10071 | ||
|
|
f42688a244 | ||
|
|
c13bfa0082 |
No files matched your search
@@ -2,7 +2,7 @@ name: NBA Data Pipeline
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 6 * * *'
|
||||
- cron: '0 20 * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
|
||||
@@ -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
|
||||
|
||||
+3
-1
@@ -196,4 +196,6 @@ AGENTS.md
|
||||
.agents
|
||||
.claude
|
||||
|
||||
.env.old
|
||||
.env.old
|
||||
|
||||
.opencode
|
||||
+26
-13
@@ -1,10 +1,13 @@
|
||||
# Codebase Context — Streamlit_NBA
|
||||
Updated: 2026-10-27
|
||||
|
||||
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`)
|
||||
@@ -13,6 +16,7 @@ Streamlit_NBA is an end-to-end NBA analytics data warehouse and interactive visu
|
||||
- **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.
|
||||
@@ -24,6 +28,7 @@ Streamlit_NBA is an end-to-end NBA analytics data warehouse and interactive visu
|
||||
- `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`).
|
||||
@@ -31,23 +36,25 @@ Streamlit_NBA is an end-to-end NBA analytics data warehouse and interactive visu
|
||||
- `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`) 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.
|
||||
|
||||
- **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 connection to `dbt_nba.duckdb`; queries cached via `@st.cache_data(ttl=3600)`.
|
||||
- Seven dashboard modules: Overview (`app.py:151`), Teams (`app.py:297`), Player Lab (`app.py:493`), Shot Charts (`app.py:769`), Head to Head (`app.py:1052`), Game Trends (`app.py:1231`), Quarter Analysis (`app.py:1364`).
|
||||
- `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**:
|
||||
1. Triggered via Gitea Actions cron `0 6 * * *` (`.gitea/workflows/pipeline.yaml:4-6`).
|
||||
2. Runs `scripts/pipeline.sh` (`scripts/pipeline.sh:22-45`).
|
||||
3. Pulls Postgres source into `data/DB/dbt_nba.duckdb` via DuckDB CLI (`scripts/extract.sql:18-24`).
|
||||
4. Executes `dbt deps` and `dbt build --full-refresh` (`scripts/pipeline.sh:33-34`).
|
||||
5. Copies DB to `dbt_nba/reports/sources/nba/dbt_nba.duckdb` for Evidence BI (`scripts/pipeline.sh:37`).
|
||||
|
||||
- **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`).
|
||||
@@ -55,14 +62,16 @@ Streamlit_NBA is an end-to-end NBA analytics data warehouse and interactive visu
|
||||
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`
|
||||
- **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`
|
||||
@@ -75,12 +84,14 @@ Streamlit_NBA is an end-to-end NBA analytics data warehouse and interactive visu
|
||||
- **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) and `dev` (active development); conventional commit prefixes (`feat:`, `fix:`, `ci:`). Current working branch: `update`.
|
||||
- **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`).
|
||||
@@ -89,12 +100,14 @@ Streamlit_NBA is an end-to-end NBA analytics data warehouse and interactive visu
|
||||
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` and `scripts/db_storage.py`.
|
||||
- **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,8 +1,8 @@
|
||||
[theme]
|
||||
primaryColor = "#3b82f6"
|
||||
backgroundColor = "#0e1117"
|
||||
secondaryBackgroundColor = "#1e293b"
|
||||
textColor = "#f8fafc"
|
||||
primaryColor = "#E09F3E"
|
||||
backgroundColor = "#0B0F17"
|
||||
secondaryBackgroundColor = "#151D2A"
|
||||
textColor = "#F1F5F9"
|
||||
|
||||
[server]
|
||||
maxUploadSize = 5
|
||||
+73
-1449
File diff suppressed because it is too large.
Load diff
@@ -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.")
|
||||
@@ -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.")
|
||||
@@ -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")
|
||||
@@ -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",
|
||||
)
|
||||
@@ -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.")
|
||||
@@ -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.")
|
||||
@@ -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")
|
||||
@@ -0,0 +1 @@
|
||||
"""Shared database, court geometry, and chart theming utilities for the Streamlit NBA app."""
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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]
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user