CI Health Check & Quality Gate / Code, dbt & Live App Health Check (pull_request) Successful in 1m11s
385 lines
17 KiB
Python
385 lines
17 KiB
Python
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.")
|