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