mirror of
https://git.turbo-data.com/nprasad2077/Streamlit_NBA.git
synced 2026-11-04 08:24:50 +00:00
shot chart and navbar
This commit is contained in:
1 parent
e7c8e10071
commit
11a5cc0f18
15 files changed
+2277
-1454
No files matched your search
@@ -35,7 +35,7 @@ jobs:
|
||||
run: |
|
||||
export PATH="$HOME/.local/bin:/root/.local/bin:$PATH"
|
||||
echo "==> Validating Python scripts compilation..."
|
||||
uv run python -m py_compile streamlit_app/app.py scripts/db_storage.py
|
||||
uv run python -m py_compile streamlit_app/app.py streamlit_app/utils/*.py streamlit_app/app_pages/*.py tests/test_streamlit_app.py scripts/db_storage.py
|
||||
echo "✓ Python scripts compiled without errors"
|
||||
|
||||
- name: dbt Manifest & Model Validation
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
[theme]
|
||||
primaryColor = "#3b82f6"
|
||||
backgroundColor = "#0e1117"
|
||||
secondaryBackgroundColor = "#1e293b"
|
||||
textColor = "#f8fafc"
|
||||
primaryColor = "#E09F3E"
|
||||
backgroundColor = "#0B0F17"
|
||||
secondaryBackgroundColor = "#151D2A"
|
||||
textColor = "#F1F5F9"
|
||||
|
||||
[server]
|
||||
maxUploadSize = 5
|
||||
+73
-1449
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,175 @@
|
||||
import plotly.express as px
|
||||
import plotly.graph_objects as go
|
||||
import streamlit as st
|
||||
|
||||
from utils.charts import AMBER, MADE_EMERALD, MISS_CRIMSON, apply_arena_theme
|
||||
from utils.db import query
|
||||
|
||||
st.title(":material/trending_up: Era Trends")
|
||||
|
||||
tab_scoring, tab_drama, tab_three = st.tabs(
|
||||
[
|
||||
":material/sports_basketball: Scoring & Pace",
|
||||
":material/theater_comedy: Game Character",
|
||||
":material/my_location: Three-Point Era",
|
||||
]
|
||||
)
|
||||
|
||||
with tab_scoring:
|
||||
pace = query(
|
||||
"""
|
||||
SELECT s.season_display AS season, s.season_start_year,
|
||||
round(avg(g.matchup_pace), 1) AS avg_pace,
|
||||
round(avg(g.total_points), 1) AS avg_points,
|
||||
round(avg(g.home_effective_fg_pct + g.visitor_effective_fg_pct) / 2 * 100, 1) AS avg_efg_pct
|
||||
FROM main_intermediate.int_games_enriched g
|
||||
JOIN main_marts.dim_seasons s ON g.season_start_year = s.season_start_year
|
||||
GROUP BY s.season_display, s.season_start_year
|
||||
ORDER BY s.season_start_year
|
||||
"""
|
||||
)
|
||||
|
||||
fig = go.Figure()
|
||||
fig.add_trace(
|
||||
go.Scatter(
|
||||
x=pace["season"],
|
||||
y=pace["avg_points"],
|
||||
name="Avg Points/Game",
|
||||
mode="lines+markers",
|
||||
line=dict(color=AMBER, width=3),
|
||||
)
|
||||
)
|
||||
fig.add_trace(
|
||||
go.Scatter(
|
||||
x=pace["season"],
|
||||
y=pace["avg_pace"],
|
||||
name="Pace",
|
||||
mode="lines+markers",
|
||||
yaxis="y2",
|
||||
line=dict(color=MADE_EMERALD, width=2, dash="dot"),
|
||||
)
|
||||
)
|
||||
fig.update_layout(
|
||||
title="Scoring & Pace Evolution",
|
||||
yaxis=dict(title="Avg Points/Game"),
|
||||
yaxis2=dict(title="Pace (possessions/48min)", overlaying="y", side="right"),
|
||||
)
|
||||
apply_arena_theme(fig, height=450)
|
||||
st.plotly_chart(fig, width="stretch")
|
||||
|
||||
pace_clean = pace.dropna(subset=["avg_efg_pct"])
|
||||
fig_eff = px.area(
|
||||
pace_clean,
|
||||
x="season",
|
||||
y="avg_efg_pct",
|
||||
title="League eFG% Trend",
|
||||
color_discrete_sequence=[MADE_EMERALD],
|
||||
)
|
||||
fig_eff.update_layout(yaxis_title="eFG%")
|
||||
apply_arena_theme(fig_eff, height=350)
|
||||
st.plotly_chart(fig_eff, width="stretch")
|
||||
|
||||
with tab_drama:
|
||||
drama = query(
|
||||
"""
|
||||
SELECT s.season_display AS season,
|
||||
round(100.0 * sum(CASE WHEN g.is_overtime THEN 1 ELSE 0 END) / count(*), 1) AS ot_pct,
|
||||
round(100.0 * sum(CASE WHEN g.point_differential <= 5 THEN 1 ELSE 0 END) / count(*), 1) AS clutch_pct,
|
||||
round(100.0 * sum(CASE WHEN g.point_differential >= 20 THEN 1 ELSE 0 END) / count(*), 1) AS blowout_pct
|
||||
FROM main_intermediate.int_games_enriched g
|
||||
JOIN main_marts.dim_seasons s ON g.season_start_year = s.season_start_year
|
||||
GROUP BY s.season_display, s.season_start_year
|
||||
ORDER BY s.season_start_year
|
||||
"""
|
||||
)
|
||||
fig = px.area(
|
||||
drama,
|
||||
x="season",
|
||||
y=["clutch_pct", "blowout_pct", "ot_pct"],
|
||||
title="Game Drama: Clutch (≤5pt margin) vs Blowouts (20+) vs Overtime",
|
||||
color_discrete_sequence=[MADE_EMERALD, MISS_CRIMSON, AMBER],
|
||||
)
|
||||
fig.update_layout(yaxis_title="% of Games", legend_title="Category")
|
||||
apply_arena_theme(fig, height=450)
|
||||
st.plotly_chart(fig, width="stretch")
|
||||
|
||||
home = query(
|
||||
"""
|
||||
SELECT s.season_display AS season,
|
||||
round(100.0 * sum(CASE WHEN g.is_home_team_winner THEN 1 ELSE 0 END) / count(*), 1) AS home_win_pct
|
||||
FROM main_marts.fct_game_results g
|
||||
JOIN main_marts.dim_seasons s ON g.season_key = s.season_key
|
||||
GROUP BY s.season_display, s.season_start_year
|
||||
ORDER BY s.season_start_year
|
||||
"""
|
||||
)
|
||||
fig2 = px.line(
|
||||
home,
|
||||
x="season",
|
||||
y="home_win_pct",
|
||||
title="Home Court Advantage Over Time",
|
||||
markers=True,
|
||||
color_discrete_sequence=[AMBER],
|
||||
)
|
||||
fig2.update_layout(yaxis_range=[40, 70])
|
||||
fig2.add_hline(y=50, line_dash="dash", line_color="gray", annotation_text="50%")
|
||||
apply_arena_theme(fig2, height=400)
|
||||
st.plotly_chart(fig2, width="stretch")
|
||||
|
||||
with tab_three:
|
||||
three_pt = query(
|
||||
"""
|
||||
SELECT s.season_display AS season, s.season_start_year,
|
||||
round(avg(three_point_rate) * 100, 1) AS avg_three_rate,
|
||||
round(avg(CASE WHEN three_point_fg_pct > 0 THEN three_point_fg_pct ELSE NULL END) * 100, 1) AS avg_three_pct,
|
||||
round(avg(three_point_attempts), 1) AS avg_3pa
|
||||
FROM main_marts.fct_player_game_shooting sh
|
||||
JOIN main_marts.dim_seasons s ON sh.season_key = s.season_key
|
||||
WHERE sh.fg_attempts >= 5
|
||||
GROUP BY s.season_display, s.season_start_year
|
||||
ORDER BY s.season_start_year
|
||||
"""
|
||||
)
|
||||
|
||||
if not three_pt.empty:
|
||||
fig = go.Figure()
|
||||
fig.add_trace(
|
||||
go.Bar(
|
||||
x=three_pt["season"],
|
||||
y=three_pt["avg_three_rate"],
|
||||
name="3PA Rate (%)",
|
||||
marker_color=AMBER,
|
||||
opacity=0.75,
|
||||
)
|
||||
)
|
||||
fig.add_trace(
|
||||
go.Scatter(
|
||||
x=three_pt["season"],
|
||||
y=three_pt["avg_three_pct"],
|
||||
name="3P Accuracy (%)",
|
||||
mode="lines+markers",
|
||||
yaxis="y2",
|
||||
line=dict(color=MADE_EMERALD, width=3),
|
||||
)
|
||||
)
|
||||
fig.update_layout(
|
||||
title="The Three-Point Revolution: Volume vs Accuracy",
|
||||
yaxis=dict(title="3PA Rate (% of all FGA)"),
|
||||
yaxis2=dict(title="3P FG%", overlaying="y", side="right", range=[30, 50]),
|
||||
barmode="overlay",
|
||||
)
|
||||
apply_arena_theme(fig, height=450)
|
||||
st.plotly_chart(fig, width="stretch")
|
||||
|
||||
fig2 = px.area(
|
||||
three_pt,
|
||||
x="season",
|
||||
y="avg_3pa",
|
||||
title="Avg Three-Point Attempts per Player-Game",
|
||||
color_discrete_sequence=[AMBER],
|
||||
)
|
||||
fig2.update_layout(yaxis_title="3PA per Game")
|
||||
apply_arena_theme(fig2, height=350)
|
||||
st.plotly_chart(fig2, width="stretch")
|
||||
else:
|
||||
st.info("Three-point shooting data not available.")
|
||||
@@ -0,0 +1,292 @@
|
||||
import plotly.express as px
|
||||
import streamlit as st
|
||||
from streamlit_echarts import st_echarts
|
||||
|
||||
from utils.charts import AMBER, MISS_CRIMSON, apply_arena_theme
|
||||
from utils.db import get_selected_season, query
|
||||
|
||||
SEASON, SEASON_LABEL = get_selected_season()
|
||||
|
||||
st.title(f":material/swords: Matchup Center — {SEASON_LABEL}")
|
||||
|
||||
teams = query("SELECT DISTINCT team FROM main_intermediate.int_team_performance ORDER BY team")
|
||||
team_list = teams["team"].tolist()
|
||||
|
||||
col1, col2, col3 = st.columns([2, 2, 1.2])
|
||||
with col1:
|
||||
team_a = st.selectbox("Team A", team_list, index=0)
|
||||
with col2:
|
||||
team_b = st.selectbox("Team B", team_list, index=min(1, len(team_list) - 1))
|
||||
with col3:
|
||||
scope = (
|
||||
st.segmented_control(
|
||||
"Scope",
|
||||
["This Season", "All-Time"],
|
||||
default="This Season",
|
||||
)
|
||||
or "This Season"
|
||||
)
|
||||
|
||||
if team_a == team_b:
|
||||
st.warning("Select two different teams.")
|
||||
else:
|
||||
if scope == "This Season":
|
||||
h2h_agg = query(
|
||||
"""
|
||||
SELECT team, count(*) AS games,
|
||||
sum(CASE WHEN game_result = 'W' THEN 1 ELSE 0 END) AS wins,
|
||||
round(avg(points), 1) AS avg_pts,
|
||||
round(avg(offensive_rating), 1) AS off_rtg,
|
||||
round(avg(defensive_rating), 1) AS def_rtg,
|
||||
round(avg(net_rating), 1) AS net_rtg,
|
||||
round(avg(pace), 1) AS pace,
|
||||
round(avg(effective_fg_pct) * 100, 1) AS efg_pct,
|
||||
round(avg(turnover_rate) * 100, 1) AS tov_rate
|
||||
FROM main_intermediate.int_team_performance
|
||||
WHERE ((team = ? AND opponent_team = ?) OR (team = ? AND opponent_team = ?))
|
||||
AND season_start_year = ?
|
||||
GROUP BY team
|
||||
""",
|
||||
[team_a, team_b, team_b, team_a, SEASON],
|
||||
)
|
||||
else:
|
||||
h2h_agg = query(
|
||||
"""
|
||||
SELECT team, count(*) AS games,
|
||||
sum(CASE WHEN game_result = 'W' THEN 1 ELSE 0 END) AS wins,
|
||||
round(avg(points), 1) AS avg_pts,
|
||||
round(avg(offensive_rating), 1) AS off_rtg,
|
||||
round(avg(defensive_rating), 1) AS def_rtg,
|
||||
round(avg(net_rating), 1) AS net_rtg,
|
||||
round(avg(pace), 1) AS pace,
|
||||
round(avg(effective_fg_pct) * 100, 1) AS efg_pct,
|
||||
round(avg(turnover_rate) * 100, 1) AS tov_rate
|
||||
FROM main_intermediate.int_team_performance
|
||||
WHERE ((team = ? AND opponent_team = ?) OR (team = ? AND opponent_team = ?))
|
||||
GROUP BY team
|
||||
""",
|
||||
[team_a, team_b, team_b, team_a],
|
||||
)
|
||||
|
||||
if h2h_agg.empty or len(h2h_agg) < 2:
|
||||
st.info("No matchup history found between these teams for this scope.")
|
||||
else:
|
||||
row_a = h2h_agg[h2h_agg["team"] == team_a].iloc[0]
|
||||
row_b = h2h_agg[h2h_agg["team"] == team_b].iloc[0]
|
||||
|
||||
c1, c2, c3, c4, c5 = st.columns(5)
|
||||
c1.metric(f"{team_a} Wins", int(row_a["wins"]))
|
||||
c2.metric(f"{team_a} PPG", row_a["avg_pts"])
|
||||
c3.metric("Games", int(row_a["games"]))
|
||||
c4.metric(f"{team_b} PPG", row_b["avg_pts"])
|
||||
c5.metric(f"{team_b} Wins", int(row_b["wins"]))
|
||||
|
||||
tab_compare, tab_games, tab_players = st.tabs(
|
||||
[
|
||||
":material/radar: Comparison",
|
||||
":material/list_alt: Game Log",
|
||||
":material/group: Key Players",
|
||||
]
|
||||
)
|
||||
|
||||
with tab_compare:
|
||||
vals_a = [
|
||||
float(row_a["avg_pts"]),
|
||||
float(row_a["off_rtg"]),
|
||||
float(row_a["pace"]),
|
||||
float(row_a["efg_pct"]),
|
||||
130 - float(row_a["def_rtg"]),
|
||||
20 - float(row_a["tov_rate"]),
|
||||
]
|
||||
vals_b = [
|
||||
float(row_b["avg_pts"]),
|
||||
float(row_b["off_rtg"]),
|
||||
float(row_b["pace"]),
|
||||
float(row_b["efg_pct"]),
|
||||
130 - float(row_b["def_rtg"]),
|
||||
20 - float(row_b["tov_rate"]),
|
||||
]
|
||||
|
||||
radar_option = {
|
||||
"legend": {"data": [team_a, team_b], "top": "bottom"},
|
||||
"color": [AMBER, MISS_CRIMSON],
|
||||
"radar": {
|
||||
"indicator": [
|
||||
{"name": "Points", "max": max(vals_a[0], vals_b[0]) * 1.15},
|
||||
{"name": "Off Rating", "max": max(vals_a[1], vals_b[1]) * 1.1},
|
||||
{"name": "Pace", "max": max(vals_a[2], vals_b[2]) * 1.1},
|
||||
{"name": "eFG%", "max": max(vals_a[3], vals_b[3]) * 1.15},
|
||||
{"name": "Defense (inv)", "max": max(vals_a[4], vals_b[4]) * 1.15},
|
||||
{"name": "Ball Security (inv)", "max": max(vals_a[5], vals_b[5]) * 1.15},
|
||||
],
|
||||
"shape": "polygon",
|
||||
"splitArea": {
|
||||
"areaStyle": {
|
||||
"color": ["#0B0F17", "#151D2A", "#1E293B", "#0B0F17"]
|
||||
}
|
||||
},
|
||||
"axisLine": {"lineStyle": {"color": "rgba(200,200,200,0.3)"}},
|
||||
"splitLine": {"lineStyle": {"color": "rgba(200,200,200,0.2)"}},
|
||||
},
|
||||
"series": [
|
||||
{
|
||||
"type": "radar",
|
||||
"data": [
|
||||
{
|
||||
"value": vals_a,
|
||||
"name": team_a,
|
||||
"lineStyle": {"color": AMBER, "width": 2.5},
|
||||
"areaStyle": {"color": AMBER, "opacity": 0.25},
|
||||
"itemStyle": {"color": AMBER},
|
||||
},
|
||||
{
|
||||
"value": vals_b,
|
||||
"name": team_b,
|
||||
"lineStyle": {"color": MISS_CRIMSON, "width": 2.5},
|
||||
"areaStyle": {"color": MISS_CRIMSON, "opacity": 0.25},
|
||||
"itemStyle": {"color": MISS_CRIMSON},
|
||||
},
|
||||
],
|
||||
}
|
||||
],
|
||||
}
|
||||
st_echarts(options=radar_option, height="450px")
|
||||
|
||||
st.dataframe(
|
||||
h2h_agg.set_index("team")[
|
||||
[
|
||||
"games",
|
||||
"wins",
|
||||
"avg_pts",
|
||||
"off_rtg",
|
||||
"def_rtg",
|
||||
"net_rtg",
|
||||
"pace",
|
||||
"efg_pct",
|
||||
"tov_rate",
|
||||
]
|
||||
],
|
||||
width="stretch",
|
||||
)
|
||||
|
||||
with tab_games:
|
||||
if scope == "This Season":
|
||||
game_log = query(
|
||||
"""
|
||||
SELECT g.game_date::date::varchar AS date,
|
||||
ht.team_abbr AS home, vt.team_abbr AS away,
|
||||
g.home_points, g.visitor_points, wt.team_abbr AS winner,
|
||||
g.point_differential AS margin,
|
||||
g.game_competitiveness_tier AS type
|
||||
FROM main_marts.fct_game_results g
|
||||
JOIN main_marts.dim_teams ht ON g.home_team_key = ht.team_key
|
||||
JOIN main_marts.dim_teams vt ON g.visitor_team_key = vt.team_key
|
||||
JOIN main_marts.dim_teams wt ON g.winning_team_key = wt.team_key
|
||||
JOIN main_marts.dim_seasons s ON g.season_key = s.season_key
|
||||
WHERE ((ht.team_abbr = ? AND vt.team_abbr = ?) OR (ht.team_abbr = ? AND vt.team_abbr = ?))
|
||||
AND s.season_start_year = ?
|
||||
ORDER BY g.game_date DESC
|
||||
""",
|
||||
[team_a, team_b, team_b, team_a, SEASON],
|
||||
)
|
||||
else:
|
||||
game_log = query(
|
||||
"""
|
||||
SELECT g.game_date::date::varchar AS date,
|
||||
ht.team_abbr AS home, vt.team_abbr AS away,
|
||||
g.home_points, g.visitor_points, wt.team_abbr AS winner,
|
||||
g.point_differential AS margin,
|
||||
g.game_competitiveness_tier AS type
|
||||
FROM main_marts.fct_game_results g
|
||||
JOIN main_marts.dim_teams ht ON g.home_team_key = ht.team_key
|
||||
JOIN main_marts.dim_teams vt ON g.visitor_team_key = vt.team_key
|
||||
JOIN main_marts.dim_teams wt ON g.winning_team_key = wt.team_key
|
||||
WHERE ((ht.team_abbr = ? AND vt.team_abbr = ?) OR (ht.team_abbr = ? AND vt.team_abbr = ?))
|
||||
ORDER BY g.game_date DESC
|
||||
""",
|
||||
[team_a, team_b, team_b, team_a],
|
||||
)
|
||||
|
||||
if not game_log.empty:
|
||||
game_log["winner_color"] = game_log["winner"].apply(
|
||||
lambda w: team_a if w == team_a else team_b
|
||||
)
|
||||
fig = px.bar(
|
||||
game_log,
|
||||
x="date",
|
||||
y="margin",
|
||||
color="winner_color",
|
||||
color_discrete_map={team_a: AMBER, team_b: MISS_CRIMSON},
|
||||
title="Game Margins (color = winner)",
|
||||
)
|
||||
fig.update_layout(xaxis_title="", yaxis_title="Point Margin")
|
||||
apply_arena_theme(fig, height=350)
|
||||
st.plotly_chart(fig, width="stretch")
|
||||
|
||||
st.dataframe(
|
||||
game_log.drop(columns=["winner_color"]), hide_index=True, width="stretch"
|
||||
)
|
||||
|
||||
with tab_players:
|
||||
if scope == "This Season":
|
||||
players_in_matchup = query(
|
||||
"""
|
||||
SELECT pp.player_name, pp.team, count(*) AS games,
|
||||
round(avg(pp.points), 1) AS ppg,
|
||||
round(avg(pp.assists), 1) AS apg,
|
||||
round(avg(pp.total_rebounds), 1) AS rpg,
|
||||
round(avg(pp.box_plus_minus), 2) AS bpm
|
||||
FROM main_intermediate.int_player_performance pp
|
||||
JOIN main_intermediate.int_team_performance tp
|
||||
ON pp.game_id = tp.game_id AND pp.team = tp.team
|
||||
WHERE ((tp.team = ? AND tp.opponent_team = ?)
|
||||
OR (tp.team = ? AND tp.opponent_team = ?))
|
||||
AND tp.season_start_year = ?
|
||||
AND pp.minutes_played >= 15
|
||||
GROUP BY pp.player_name, pp.team
|
||||
HAVING count(*) >= 2
|
||||
ORDER BY ppg DESC LIMIT 15
|
||||
""",
|
||||
[team_a, team_b, team_b, team_a, SEASON],
|
||||
)
|
||||
else:
|
||||
players_in_matchup = query(
|
||||
"""
|
||||
SELECT pp.player_name, pp.team, count(*) AS games,
|
||||
round(avg(pp.points), 1) AS ppg,
|
||||
round(avg(pp.assists), 1) AS apg,
|
||||
round(avg(pp.total_rebounds), 1) AS rpg,
|
||||
round(avg(pp.box_plus_minus), 2) AS bpm
|
||||
FROM main_intermediate.int_player_performance pp
|
||||
JOIN main_intermediate.int_team_performance tp
|
||||
ON pp.game_id = tp.game_id AND pp.team = tp.team
|
||||
WHERE ((tp.team = ? AND tp.opponent_team = ?)
|
||||
OR (tp.team = ? AND tp.opponent_team = ?))
|
||||
AND pp.minutes_played >= 15
|
||||
GROUP BY pp.player_name, pp.team
|
||||
HAVING count(*) >= 2
|
||||
ORDER BY ppg DESC LIMIT 15
|
||||
""",
|
||||
[team_a, team_b, team_b, team_a],
|
||||
)
|
||||
|
||||
if not players_in_matchup.empty:
|
||||
col1, col2 = st.columns(2)
|
||||
with col1:
|
||||
st.subheader(f"{team_a} Performers")
|
||||
team_a_players = players_in_matchup[players_in_matchup["team"] == team_a]
|
||||
st.dataframe(
|
||||
team_a_players[["player_name", "games", "ppg", "apg", "rpg", "bpm"]],
|
||||
hide_index=True,
|
||||
width="stretch",
|
||||
)
|
||||
with col2:
|
||||
st.subheader(f"{team_b} Performers")
|
||||
team_b_players = players_in_matchup[players_in_matchup["team"] == team_b]
|
||||
st.dataframe(
|
||||
team_b_players[["player_name", "games", "ppg", "apg", "rpg", "bpm"]],
|
||||
hide_index=True,
|
||||
width="stretch",
|
||||
)
|
||||
else:
|
||||
st.info("No player data available for this matchup scope.")
|
||||
@@ -0,0 +1,201 @@
|
||||
import plotly.express as px
|
||||
import streamlit as st
|
||||
from streamlit_echarts import st_echarts
|
||||
|
||||
from utils.charts import AMBER, apply_arena_theme
|
||||
from utils.db import get_selected_season, query
|
||||
|
||||
SEASON, SEASON_LABEL = get_selected_season()
|
||||
|
||||
st.title(f":material/dashboard: League Pulse — {SEASON_LABEL}")
|
||||
|
||||
# --- KPIs with delta vs prior season ---
|
||||
summary = query(
|
||||
"""
|
||||
SELECT count(*) AS total_games,
|
||||
sum(CASE WHEN is_overtime THEN 1 ELSE 0 END) AS ot_games,
|
||||
round(avg(total_points), 1) AS avg_pts,
|
||||
round(avg(point_differential), 1) AS avg_margin,
|
||||
round(100.0 * sum(CASE WHEN is_home_team_winner THEN 1 ELSE 0 END) / count(*), 1) AS home_win_pct,
|
||||
min(game_date) AS first_game,
|
||||
max(game_date) AS last_game
|
||||
FROM main_marts.fct_game_results
|
||||
WHERE season_key IN (SELECT season_key FROM main_marts.dim_seasons WHERE season_start_year = ?)
|
||||
""",
|
||||
[SEASON],
|
||||
)
|
||||
|
||||
prior = query(
|
||||
"""
|
||||
SELECT round(avg(total_points), 1) AS avg_pts,
|
||||
round(avg(point_differential), 1) AS avg_margin,
|
||||
round(100.0 * sum(CASE WHEN is_home_team_winner THEN 1 ELSE 0 END) / count(*), 1) AS home_win_pct
|
||||
FROM main_marts.fct_game_results
|
||||
WHERE season_key IN (SELECT season_key FROM main_marts.dim_seasons WHERE season_start_year = ?)
|
||||
""",
|
||||
[SEASON - 1],
|
||||
)
|
||||
|
||||
has_prior = not prior.empty and prior["avg_pts"][0] is not None
|
||||
c1, c2, c3, c4, c5 = st.columns(5)
|
||||
c1.metric("Total Games", f"{summary['total_games'][0]:,}")
|
||||
c2.metric(
|
||||
"Avg Points/Game",
|
||||
summary["avg_pts"][0],
|
||||
delta=f"{summary['avg_pts'][0] - prior['avg_pts'][0]:+.1f}" if has_prior else None,
|
||||
)
|
||||
c3.metric(
|
||||
"Avg Margin",
|
||||
summary["avg_margin"][0],
|
||||
delta=f"{summary['avg_margin'][0] - prior['avg_margin'][0]:+.1f}" if has_prior else None,
|
||||
)
|
||||
c4.metric(
|
||||
"Home Win %",
|
||||
f"{summary['home_win_pct'][0]}%",
|
||||
delta=f"{summary['home_win_pct'][0] - prior['home_win_pct'][0]:+.1f}%" if has_prior else None,
|
||||
)
|
||||
c5.metric("OT Games", f"{summary['ot_games'][0]:,}")
|
||||
|
||||
st.caption(f"Season: {summary['first_game'][0]} → {summary['last_game'][0]} | Deltas vs prior season")
|
||||
|
||||
# --- Row 2: Competitiveness donut + Top standings ---
|
||||
col1, col2 = st.columns(2)
|
||||
|
||||
with col1:
|
||||
comp = query(
|
||||
"""
|
||||
SELECT game_competitiveness_tier AS tier, count(*) AS games
|
||||
FROM main_marts.fct_game_results
|
||||
WHERE season_key IN (SELECT season_key FROM main_marts.dim_seasons WHERE season_start_year = ?)
|
||||
GROUP BY tier ORDER BY games DESC
|
||||
""",
|
||||
[SEASON],
|
||||
)
|
||||
fig = px.pie(
|
||||
comp,
|
||||
names="tier",
|
||||
values="games",
|
||||
title="Game Competitiveness",
|
||||
hole=0.4,
|
||||
color_discrete_sequence=px.colors.qualitative.Set2,
|
||||
)
|
||||
fig.update_traces(textinfo="percent+label")
|
||||
apply_arena_theme(fig)
|
||||
st.plotly_chart(fig, width="stretch")
|
||||
|
||||
with col2:
|
||||
standings = query(
|
||||
"""
|
||||
SELECT team, count(*) AS games,
|
||||
sum(CASE WHEN game_result = 'W' THEN 1 ELSE 0 END) AS wins,
|
||||
sum(CASE WHEN game_result = 'L' THEN 1 ELSE 0 END) AS losses,
|
||||
round(100.0 * sum(CASE WHEN game_result = 'W' THEN 1 ELSE 0 END) / count(*), 1) AS win_pct,
|
||||
round(avg(net_rating), 1) AS net_rtg
|
||||
FROM main_intermediate.int_team_performance
|
||||
WHERE season_start_year = ?
|
||||
GROUP BY team ORDER BY win_pct DESC LIMIT 8
|
||||
""",
|
||||
[SEASON],
|
||||
)
|
||||
st.subheader(":material/emoji_events: Top Teams")
|
||||
st.dataframe(
|
||||
standings,
|
||||
column_config={
|
||||
"win_pct": st.column_config.ProgressColumn(
|
||||
"Win%", min_value=0, max_value=100, format="%.1f%%"
|
||||
),
|
||||
},
|
||||
hide_index=True,
|
||||
width="stretch",
|
||||
)
|
||||
|
||||
# --- Row 3: Weekly scoring trend ---
|
||||
weekly = query(
|
||||
"""
|
||||
SELECT date_trunc('week', game_date)::date::varchar AS week,
|
||||
count(*) AS games,
|
||||
round(avg(total_points), 1) AS avg_pts,
|
||||
round(avg(point_differential), 1) AS avg_margin
|
||||
FROM main_marts.fct_game_results
|
||||
WHERE season_key IN (SELECT season_key FROM main_marts.dim_seasons WHERE season_start_year = ?)
|
||||
GROUP BY week ORDER BY week
|
||||
""",
|
||||
[SEASON],
|
||||
)
|
||||
fig_weekly = px.area(
|
||||
weekly,
|
||||
x="week",
|
||||
y="avg_pts",
|
||||
title="Weekly Scoring Trend",
|
||||
color_discrete_sequence=[AMBER],
|
||||
)
|
||||
fig_weekly.update_layout(xaxis_title="Week", yaxis_title="Avg Total Points")
|
||||
apply_arena_theme(fig_weekly, height=350)
|
||||
st.plotly_chart(fig_weekly, width="stretch")
|
||||
|
||||
# --- Row 4: Calendar heatmap + Season highlights ---
|
||||
col1, col2 = st.columns([2, 1])
|
||||
|
||||
with col1:
|
||||
cal_data = query(
|
||||
"""
|
||||
SELECT game_date::varchar AS day, count(*) AS games
|
||||
FROM main_marts.fct_game_results
|
||||
WHERE season_key IN (SELECT season_key FROM main_marts.dim_seasons WHERE season_start_year = ?)
|
||||
GROUP BY game_date ORDER BY game_date
|
||||
""",
|
||||
[SEASON],
|
||||
)
|
||||
if not cal_data.empty:
|
||||
cal_list = cal_data.values.tolist()
|
||||
cal_option = {
|
||||
"tooltip": {"position": "top", "formatter": "{c0} games on {b0}"},
|
||||
"visualMap": {
|
||||
"min": 0,
|
||||
"max": int(cal_data["games"].max()),
|
||||
"calculable": True,
|
||||
"orient": "horizontal",
|
||||
"left": "center",
|
||||
"top": "top",
|
||||
"inRange": {"color": ["#151D2A", "#9be9a8", "#40c463", "#30a14e", "#10B981"]},
|
||||
},
|
||||
"calendar": {
|
||||
"range": [cal_list[0][0][:7], cal_list[-1][0][:7]],
|
||||
"cellSize": ["auto", 15],
|
||||
},
|
||||
"series": [{"type": "heatmap", "coordinateSystem": "calendar", "data": cal_list}],
|
||||
}
|
||||
st_echarts(options=cal_option, height="200px")
|
||||
|
||||
with col2:
|
||||
st.subheader(":material/local_fire_department: Top Performances")
|
||||
highlights = query(
|
||||
"""
|
||||
SELECT player_name AS player, team,
|
||||
game_date::date::varchar AS date, points AS pts,
|
||||
assists AS ast, total_rebounds AS reb
|
||||
FROM main_intermediate.int_player_performance
|
||||
WHERE season_start_year = ?
|
||||
ORDER BY points DESC LIMIT 5
|
||||
""",
|
||||
[SEASON],
|
||||
)
|
||||
st.dataframe(highlights, hide_index=True, width="stretch")
|
||||
|
||||
# --- Row 5: Recent games ---
|
||||
st.subheader("Recent Games")
|
||||
games = query(
|
||||
"""
|
||||
SELECT g.game_date, ht.team_abbr AS home, vt.team_abbr AS away,
|
||||
g.home_points, g.visitor_points, wt.team_abbr AS winner,
|
||||
g.point_differential AS margin, g.game_competitiveness_tier AS type
|
||||
FROM main_marts.fct_game_results g
|
||||
LEFT JOIN main_marts.dim_teams ht ON g.home_team_key = ht.team_key
|
||||
LEFT JOIN main_marts.dim_teams vt ON g.visitor_team_key = vt.team_key
|
||||
LEFT JOIN main_marts.dim_teams wt ON g.winning_team_key = wt.team_key
|
||||
WHERE g.season_key IN (SELECT season_key FROM main_marts.dim_seasons WHERE season_start_year = ?)
|
||||
ORDER BY g.game_date DESC LIMIT 25
|
||||
""",
|
||||
[SEASON],
|
||||
)
|
||||
st.dataframe(games, hide_index=True, width="stretch")
|
||||
@@ -0,0 +1,329 @@
|
||||
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",
|
||||
)
|
||||
@@ -0,0 +1,155 @@
|
||||
import plotly.express as px
|
||||
import streamlit as st
|
||||
|
||||
from utils.charts import AMBER, MADE_EMERALD, MISS_CRIMSON, apply_arena_theme
|
||||
from utils.db import get_selected_season, query
|
||||
|
||||
SEASON, SEASON_LABEL = get_selected_season()
|
||||
|
||||
st.title(f":material/timer: Momentum — {SEASON_LABEL}")
|
||||
|
||||
tab_overview, tab_teams, tab_comebacks = st.tabs(
|
||||
[
|
||||
":material/bar_chart: Quarter Scoring",
|
||||
":material/emoji_events: Team by Quarter",
|
||||
":material/replay: Comebacks",
|
||||
]
|
||||
)
|
||||
|
||||
QUARTER_COLORS = {
|
||||
"Q1": "#38BDF8",
|
||||
"Q2": MADE_EMERALD,
|
||||
"Q3": AMBER,
|
||||
"Q4": MISS_CRIMSON,
|
||||
}
|
||||
|
||||
with tab_overview:
|
||||
quarter_avg = query(
|
||||
"""
|
||||
SELECT q.period,
|
||||
round(avg(q.points_scored), 1) AS avg_pts,
|
||||
round(avg(abs(q.period_point_differential)), 1) AS avg_abs_diff,
|
||||
round(stddev(q.points_scored), 1) AS scoring_volatility
|
||||
FROM main_marts.fct_quarter_scoring q
|
||||
JOIN main_marts.dim_seasons s ON q.season_key = s.season_key
|
||||
WHERE s.season_start_year = ? AND q.period IN ('Q1','Q2','Q3','Q4')
|
||||
GROUP BY q.period ORDER BY q.period
|
||||
""",
|
||||
[SEASON],
|
||||
)
|
||||
|
||||
col1, col2 = st.columns(2)
|
||||
with col1:
|
||||
fig = px.bar(
|
||||
quarter_avg,
|
||||
x="period",
|
||||
y="avg_pts",
|
||||
title="Avg Points per Quarter",
|
||||
color="period",
|
||||
color_discrete_map=QUARTER_COLORS,
|
||||
labels={"period": "Quarter", "avg_pts": "Avg Points"},
|
||||
)
|
||||
fig.update_layout(showlegend=False)
|
||||
apply_arena_theme(fig)
|
||||
st.plotly_chart(fig, width="stretch")
|
||||
with col2:
|
||||
fig2 = px.bar(
|
||||
quarter_avg,
|
||||
x="period",
|
||||
y="avg_abs_diff",
|
||||
title="Avg Quarter Lopsidedness (|differential|)",
|
||||
color="period",
|
||||
color_discrete_map=QUARTER_COLORS,
|
||||
labels={"period": "Quarter", "avg_abs_diff": "Avg |Differential|"},
|
||||
)
|
||||
fig2.update_layout(showlegend=False)
|
||||
apply_arena_theme(fig2)
|
||||
st.plotly_chart(fig2, width="stretch")
|
||||
|
||||
st.caption("Q4 has lower scoring and tighter margins — games tighten up in crunch time.")
|
||||
|
||||
with tab_teams:
|
||||
quarter_sel = (
|
||||
st.segmented_control(
|
||||
"Quarter",
|
||||
["Q1", "Q2", "Q3", "Q4"],
|
||||
default="Q1",
|
||||
)
|
||||
or "Q1"
|
||||
)
|
||||
|
||||
team_quarter = query(
|
||||
"""
|
||||
SELECT t.team_abbr AS team,
|
||||
round(avg(q.points_scored), 1) AS avg_pts,
|
||||
round(avg(q.period_point_differential), 2) AS avg_diff,
|
||||
count(*) AS games
|
||||
FROM main_marts.fct_quarter_scoring q
|
||||
JOIN main_marts.dim_teams t ON q.team_key = t.team_key
|
||||
JOIN main_marts.dim_seasons s ON q.season_key = s.season_key
|
||||
WHERE s.season_start_year = ? AND q.period = ?
|
||||
GROUP BY t.team_abbr HAVING count(*) >= 10
|
||||
ORDER BY avg_diff DESC
|
||||
""",
|
||||
[SEASON, quarter_sel],
|
||||
)
|
||||
|
||||
fig = px.bar(
|
||||
team_quarter,
|
||||
x="avg_pts",
|
||||
y="team",
|
||||
orientation="h",
|
||||
color="avg_diff",
|
||||
color_continuous_scale="RdYlGn",
|
||||
title=f"{quarter_sel} Leaders — sorted by differential (color = +/-)",
|
||||
)
|
||||
fig.update_layout(yaxis=dict(autorange="reversed"))
|
||||
apply_arena_theme(fig, height=600)
|
||||
st.plotly_chart(fig, width="stretch")
|
||||
|
||||
with tab_comebacks:
|
||||
comebacks = query(
|
||||
"""
|
||||
WITH q3_trailing AS (
|
||||
SELECT q.game_id, q.team_key,
|
||||
sum(q.period_point_differential) AS through_q3_diff
|
||||
FROM main_marts.fct_quarter_scoring q
|
||||
JOIN main_marts.dim_seasons s ON q.season_key = s.season_key
|
||||
WHERE s.season_start_year = ? AND q.period IN ('Q1','Q2','Q3')
|
||||
GROUP BY q.game_id, q.team_key
|
||||
HAVING sum(q.period_point_differential) < -10
|
||||
),
|
||||
q4_results AS (
|
||||
SELECT q.game_id, q.team_key, q.period_point_differential AS q4_diff
|
||||
FROM main_marts.fct_quarter_scoring q
|
||||
JOIN main_marts.dim_seasons s ON q.season_key = s.season_key
|
||||
WHERE s.season_start_year = ? AND q.period = 'Q4'
|
||||
)
|
||||
SELECT t.team_abbr AS team,
|
||||
count(*) AS trailing_10_after_q3,
|
||||
sum(CASE WHEN c.through_q3_diff + r.q4_diff > 0 THEN 1 ELSE 0 END) AS comebacks,
|
||||
round(100.0 * sum(CASE WHEN c.through_q3_diff + r.q4_diff > 0 THEN 1 ELSE 0 END) / count(*), 1) AS comeback_pct
|
||||
FROM q3_trailing c
|
||||
JOIN q4_results r ON c.game_id = r.game_id AND c.team_key = r.team_key
|
||||
JOIN main_marts.dim_teams t ON c.team_key = t.team_key
|
||||
GROUP BY t.team_abbr HAVING count(*) >= 3
|
||||
ORDER BY comeback_pct DESC
|
||||
""",
|
||||
[SEASON, SEASON],
|
||||
)
|
||||
|
||||
if not comebacks.empty:
|
||||
fig = px.bar(
|
||||
comebacks,
|
||||
x="team",
|
||||
y="comeback_pct",
|
||||
color="comebacks",
|
||||
color_continuous_scale="Greens",
|
||||
title="Comeback Rate (Down 10+ After Q3)",
|
||||
)
|
||||
fig.update_layout(xaxis_title="", yaxis_title="Comeback %")
|
||||
apply_arena_theme(fig)
|
||||
st.plotly_chart(fig, width="stretch")
|
||||
st.dataframe(comebacks, hide_index=True, width="stretch")
|
||||
else:
|
||||
st.info("No comeback data available for this season.")
|
||||
@@ -0,0 +1,384 @@
|
||||
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.")
|
||||
@@ -0,0 +1,283 @@
|
||||
import plotly.express as px
|
||||
import plotly.graph_objects as go
|
||||
import streamlit as st
|
||||
|
||||
from utils.charts import AMBER, MADE_EMERALD, MISS_CRIMSON, apply_arena_theme
|
||||
from utils.db import get_selected_season, query
|
||||
|
||||
SEASON, SEASON_LABEL = get_selected_season()
|
||||
|
||||
st.title(f":material/shield: Team Intelligence — {SEASON_LABEL}")
|
||||
|
||||
ratings = query(
|
||||
"""
|
||||
SELECT team, count(*) AS games,
|
||||
sum(CASE WHEN game_result = 'W' THEN 1 ELSE 0 END) AS wins,
|
||||
sum(CASE WHEN game_result = 'L' THEN 1 ELSE 0 END) AS losses,
|
||||
round(100.0 * sum(CASE WHEN game_result = 'W' THEN 1 ELSE 0 END) / count(*), 1) AS win_pct,
|
||||
round(avg(offensive_rating), 1) AS off_rtg,
|
||||
round(avg(defensive_rating), 1) AS def_rtg,
|
||||
round(avg(net_rating), 1) AS net_rtg,
|
||||
round(avg(pace), 1) AS pace,
|
||||
round(avg(effective_fg_pct) * 100, 1) AS efg_pct,
|
||||
round(avg(turnover_rate) * 100, 1) AS tov_rate,
|
||||
round(avg(offensive_rebound_rate) * 100, 1) AS orb_rate,
|
||||
round(avg(free_throw_rate) * 100, 1) AS ft_rate
|
||||
FROM main_intermediate.int_team_performance
|
||||
WHERE season_start_year = ?
|
||||
GROUP BY team ORDER BY net_rtg DESC
|
||||
""",
|
||||
[SEASON],
|
||||
)
|
||||
|
||||
tab_ratings, tab_factors, tab_drilldown = st.tabs(
|
||||
[
|
||||
":material/bar_chart: Ratings & Standings",
|
||||
":material/grid_view: Four Factors & Styles",
|
||||
":material/manage_search: Team Drill-Down",
|
||||
]
|
||||
)
|
||||
|
||||
with tab_ratings:
|
||||
fig = px.scatter(
|
||||
ratings,
|
||||
x="off_rtg",
|
||||
y="def_rtg",
|
||||
text="team",
|
||||
size="win_pct",
|
||||
color="net_rtg",
|
||||
color_continuous_scale="RdYlGn",
|
||||
title="Offensive vs Defensive Rating (size = Win%)",
|
||||
)
|
||||
fig.update_traces(textposition="top center", marker=dict(sizemin=8))
|
||||
avg_off = ratings["off_rtg"].mean()
|
||||
avg_def = ratings["def_rtg"].mean()
|
||||
fig.add_hline(y=avg_def, line_dash="dash", line_color="gray", opacity=0.4)
|
||||
fig.add_vline(x=avg_off, line_dash="dash", line_color="gray", opacity=0.4)
|
||||
fig.update_layout(
|
||||
xaxis_title="Offensive Rating → (higher = better)",
|
||||
yaxis_title="← Defensive Rating (lower = better)",
|
||||
yaxis=dict(autorange="reversed"),
|
||||
)
|
||||
apply_arena_theme(fig, height=550)
|
||||
st.plotly_chart(fig, width="stretch")
|
||||
|
||||
win_sorted = ratings.sort_values("win_pct", ascending=True)
|
||||
fig_win = px.bar(
|
||||
win_sorted,
|
||||
x="win_pct",
|
||||
y="team",
|
||||
orientation="h",
|
||||
color="net_rtg",
|
||||
color_continuous_scale="RdYlGn",
|
||||
title="Win % (color = Net Rating)",
|
||||
)
|
||||
fig_win.update_layout(yaxis_title="", xaxis_title="Win %")
|
||||
fig_win.add_vline(x=50, line_dash="dash", line_color="gray", opacity=0.5)
|
||||
apply_arena_theme(fig_win, height=600)
|
||||
st.plotly_chart(fig_win, width="stretch")
|
||||
|
||||
st.dataframe(
|
||||
ratings,
|
||||
column_config={
|
||||
"win_pct": st.column_config.ProgressColumn(
|
||||
"Win%", min_value=0, max_value=100, format="%.1f%%"
|
||||
),
|
||||
"efg_pct": st.column_config.ProgressColumn(
|
||||
"eFG%", min_value=45, max_value=60, format="%.1f%%"
|
||||
),
|
||||
},
|
||||
hide_index=True,
|
||||
width="stretch",
|
||||
)
|
||||
|
||||
with tab_factors:
|
||||
st.subheader("Four Factors")
|
||||
st.caption(
|
||||
"The four key drivers of team success: eFG%, Turnover Rate, Offensive Rebound Rate, Free Throw Rate"
|
||||
)
|
||||
|
||||
col1, col2 = st.columns(2)
|
||||
with col1:
|
||||
top10 = ratings.nlargest(10, "efg_pct")
|
||||
fig = px.bar(
|
||||
top10,
|
||||
x="efg_pct",
|
||||
y="team",
|
||||
orientation="h",
|
||||
color="efg_pct",
|
||||
color_continuous_scale="Greens",
|
||||
title="eFG% (Top 10)",
|
||||
)
|
||||
fig.update_layout(yaxis=dict(autorange="reversed"), showlegend=False)
|
||||
apply_arena_theme(fig, height=350)
|
||||
st.plotly_chart(fig, width="stretch")
|
||||
with col2:
|
||||
top10 = ratings.nsmallest(10, "tov_rate")
|
||||
fig = px.bar(
|
||||
top10,
|
||||
x="tov_rate",
|
||||
y="team",
|
||||
orientation="h",
|
||||
color="tov_rate",
|
||||
color_continuous_scale="Reds_r",
|
||||
title="Turnover Rate (Top 10 — lowest)",
|
||||
)
|
||||
fig.update_layout(yaxis=dict(autorange="reversed"), showlegend=False)
|
||||
apply_arena_theme(fig, height=350)
|
||||
st.plotly_chart(fig, width="stretch")
|
||||
|
||||
col3, col4 = st.columns(2)
|
||||
with col3:
|
||||
top10 = ratings.nlargest(10, "orb_rate")
|
||||
fig = px.bar(
|
||||
top10,
|
||||
x="orb_rate",
|
||||
y="team",
|
||||
orientation="h",
|
||||
color="orb_rate",
|
||||
color_continuous_scale="Purples",
|
||||
title="Offensive Rebound Rate (Top 10)",
|
||||
)
|
||||
fig.update_layout(yaxis=dict(autorange="reversed"), showlegend=False)
|
||||
apply_arena_theme(fig, height=350)
|
||||
st.plotly_chart(fig, width="stretch")
|
||||
with col4:
|
||||
top10 = ratings.nlargest(10, "ft_rate")
|
||||
fig = px.bar(
|
||||
top10,
|
||||
x="ft_rate",
|
||||
y="team",
|
||||
orientation="h",
|
||||
color="ft_rate",
|
||||
color_continuous_scale="Oranges",
|
||||
title="Free Throw Rate (Top 10)",
|
||||
)
|
||||
fig.update_layout(yaxis=dict(autorange="reversed"), showlegend=False)
|
||||
apply_arena_theme(fig, height=350)
|
||||
st.plotly_chart(fig, width="stretch")
|
||||
|
||||
st.subheader("Team Styles")
|
||||
col1, col2 = st.columns(2)
|
||||
with col1:
|
||||
styles = query(
|
||||
"""
|
||||
SELECT shot_selection_style, ball_movement_style, count(*) AS games,
|
||||
round(avg(CASE WHEN game_result = 'W' THEN 1.0 ELSE 0.0 END) * 100, 1) AS win_pct
|
||||
FROM main_intermediate.int_team_performance
|
||||
WHERE season_start_year = ?
|
||||
GROUP BY shot_selection_style, ball_movement_style
|
||||
ORDER BY win_pct DESC
|
||||
""",
|
||||
[SEASON],
|
||||
)
|
||||
fig = px.sunburst(
|
||||
styles,
|
||||
path=["shot_selection_style", "ball_movement_style"],
|
||||
values="games",
|
||||
color="win_pct",
|
||||
color_continuous_scale="RdYlGn",
|
||||
title="Offense: Shot Selection × Ball Movement",
|
||||
)
|
||||
apply_arena_theme(fig)
|
||||
st.plotly_chart(fig, width="stretch")
|
||||
|
||||
with col2:
|
||||
defense = query(
|
||||
"""
|
||||
SELECT defensive_activity, ball_movement_style, count(*) AS games,
|
||||
round(avg(CASE WHEN game_result = 'W' THEN 1.0 ELSE 0.0 END) * 100, 1) AS win_pct
|
||||
FROM main_intermediate.int_team_performance
|
||||
WHERE season_start_year = ?
|
||||
GROUP BY defensive_activity, ball_movement_style
|
||||
ORDER BY win_pct DESC
|
||||
""",
|
||||
[SEASON],
|
||||
)
|
||||
fig = px.sunburst(
|
||||
defense,
|
||||
path=["defensive_activity", "ball_movement_style"],
|
||||
values="games",
|
||||
color="win_pct",
|
||||
color_continuous_scale="RdYlGn",
|
||||
title="Defense: Activity × Ball Movement",
|
||||
)
|
||||
apply_arena_theme(fig)
|
||||
st.plotly_chart(fig, width="stretch")
|
||||
|
||||
with tab_drilldown:
|
||||
team_list = ratings["team"].tolist()
|
||||
selected_team = st.selectbox("Select Team", team_list)
|
||||
|
||||
trajectory = query(
|
||||
"""
|
||||
SELECT game_date::date::varchar AS game_date,
|
||||
round(net_rating, 1) AS net_rtg,
|
||||
round(avg(net_rating) OVER (
|
||||
ORDER BY game_date ROWS BETWEEN 9 PRECEDING AND CURRENT ROW
|
||||
), 1) AS rolling_net_rtg,
|
||||
round(avg(offensive_rating) OVER (
|
||||
ORDER BY game_date ROWS BETWEEN 9 PRECEDING AND CURRENT ROW
|
||||
), 1) AS rolling_off,
|
||||
round(avg(defensive_rating) OVER (
|
||||
ORDER BY game_date ROWS BETWEEN 9 PRECEDING AND CURRENT ROW
|
||||
), 1) AS rolling_def
|
||||
FROM main_intermediate.int_team_performance
|
||||
WHERE season_start_year = ? AND team = ?
|
||||
ORDER BY game_date
|
||||
""",
|
||||
[SEASON, selected_team],
|
||||
)
|
||||
|
||||
if not trajectory.empty:
|
||||
fig = go.Figure()
|
||||
fig.add_trace(
|
||||
go.Scatter(
|
||||
x=trajectory["game_date"],
|
||||
y=trajectory["rolling_net_rtg"],
|
||||
name="Net Rating",
|
||||
mode="lines",
|
||||
line=dict(color=AMBER, width=3),
|
||||
)
|
||||
)
|
||||
fig.add_trace(
|
||||
go.Scatter(
|
||||
x=trajectory["game_date"],
|
||||
y=trajectory["rolling_off"],
|
||||
name="Offense",
|
||||
mode="lines",
|
||||
line=dict(color=MADE_EMERALD, width=1.5, dash="dot"),
|
||||
)
|
||||
)
|
||||
fig.add_trace(
|
||||
go.Scatter(
|
||||
x=trajectory["game_date"],
|
||||
y=trajectory["rolling_def"],
|
||||
name="Defense",
|
||||
mode="lines",
|
||||
line=dict(color=MISS_CRIMSON, width=1.5, dash="dot"),
|
||||
)
|
||||
)
|
||||
fig.add_hline(y=0, line_dash="dash", line_color="gray", opacity=0.4)
|
||||
fig.update_layout(
|
||||
title=f"{selected_team} — 10-Game Rolling Ratings",
|
||||
xaxis_title="Date",
|
||||
yaxis_title="Rating",
|
||||
)
|
||||
apply_arena_theme(fig, height=400)
|
||||
st.plotly_chart(fig, width="stretch")
|
||||
|
||||
st.subheader(f"{selected_team} Game Log")
|
||||
game_log = query(
|
||||
"""
|
||||
SELECT game_date::date::varchar AS date, opponent_team AS opp, game_result AS result,
|
||||
points AS pts, round(offensive_rating, 1) AS off_rtg,
|
||||
round(defensive_rating, 1) AS def_rtg, round(net_rating, 1) AS net_rtg,
|
||||
round(pace, 1) AS pace
|
||||
FROM main_intermediate.int_team_performance
|
||||
WHERE season_start_year = ? AND team = ?
|
||||
ORDER BY game_date DESC
|
||||
""",
|
||||
[SEASON, selected_team],
|
||||
)
|
||||
st.dataframe(game_log, hide_index=True, width="stretch")
|
||||
@@ -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]
|
||||
@@ -0,0 +1,48 @@
|
||||
from pathlib import Path
|
||||
import unittest
|
||||
from streamlit.testing.v1 import AppTest
|
||||
|
||||
APP_PATH = str(Path(__file__).resolve().parent.parent / "streamlit_app" / "app.py")
|
||||
|
||||
|
||||
class TestStreamlitNBAApp(unittest.TestCase):
|
||||
def test_entrypoint_and_all_pages_render(self):
|
||||
at = AppTest.from_file(APP_PATH, default_timeout=15).run()
|
||||
self.assertFalse(at.exception, f"Entrypoint failed: {at.exception}")
|
||||
self.assertIn("League Pulse", at.title[0].value)
|
||||
|
||||
pages = [
|
||||
("app_pages/shot_charts.py", "Shot Studio"),
|
||||
("app_pages/teams.py", "Team Intelligence"),
|
||||
("app_pages/player_lab.py", "Player Lab"),
|
||||
("app_pages/head_to_head.py", "Matchup Center"),
|
||||
("app_pages/game_trends.py", "Era Trends"),
|
||||
("app_pages/quarter_analysis.py", "Momentum"),
|
||||
]
|
||||
for page_path, expected_title in pages:
|
||||
at.switch_page(page_path).run(timeout=15)
|
||||
self.assertFalse(at.exception, f"Page {page_path} failed: {at.exception}")
|
||||
self.assertIn(expected_title, at.title[0].value)
|
||||
|
||||
def test_shot_studio_filters_and_metrics(self):
|
||||
at = AppTest.from_file(APP_PATH, default_timeout=15).run()
|
||||
at.switch_page("app_pages/shot_charts.py").run(timeout=15)
|
||||
self.assertFalse(at.exception)
|
||||
|
||||
# Verify 4 summary KPI metrics are rendered on the Shot Chart tab
|
||||
metric_labels = [m.label for m in at.metric]
|
||||
self.assertIn("Field Goal Attempts", metric_labels)
|
||||
self.assertIn("FG% / eFG%", metric_labels)
|
||||
self.assertIn("3-Point Accuracy", metric_labels)
|
||||
self.assertIn("Points Per Shot", metric_labels)
|
||||
|
||||
# Filter to Q4 shots and Made outcome via segmented_control
|
||||
at.button_group(key="shot_period_filter").select("Q4").run(timeout=15)
|
||||
self.assertFalse(at.exception)
|
||||
at.button_group(key="shot_outcome_filter").select("Made").run(timeout=15)
|
||||
self.assertFalse(at.exception)
|
||||
self.assertEqual(at.metric[1].value, "100.0%")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user