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"""
{z['zone']} {int(z['makes'])}/{int(z['attempts'])} ({vol_share}%)
{z['player_fg_pct']}% {z['vs_league']:+.1f}% vs Lg
Player: {z['player_fg_pct']}% │ League Avg: {z['league_fg_pct']}%
""" ) 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.")