shot chart and navbar
CI Health Check & Quality Gate / Code, dbt & Live App Health Check (pull_request) Successful in 1m11s
CI Health Check & Quality Gate / Code, dbt & Live App Health Check (pull_request) Successful in 1m11s
This commit is contained in:
1 parent
e7c8e10071
commit
11a5cc0f18
15 files changed
+2277
-1454
No files matched your search
@@ -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]
|
||||
Reference in new issue
Block a user