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"""