shot chart and navbar #17

Merged
nprasad2077 merged 1 commits from visuals into main 2026-10-28 07:38:29 +00:00
15 changed files with 2277 additions and 1454 deletions
+1 -1
View File
@@ -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
+4 -4
View File
@@ -1,8 +1,8 @@
[theme]
primaryColor = "#3b82f6"
backgroundColor = "#0e1117"
secondaryBackgroundColor = "#1e293b"
textColor = "#f8fafc"
primaryColor = "#E09F3E"
backgroundColor = "#0B0F17"
secondaryBackgroundColor = "#151D2A"
textColor = "#F1F5F9"
[server]
maxUploadSize = 5
+57 -1433
View File
File diff suppressed because it is too large Load Diff
+175
View File
@@ -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.")
+292
View File
@@ -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.")
+201
View File
@@ -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")
+329
View File
@@ -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",
)
+155
View File
@@ -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.")
+384
View File
@@ -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.")
+283
View File
@@ -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")
+1
View File
@@ -0,0 +1 @@
"""Shared database, court geometry, and chart theming utilities for the Streamlit NBA app."""
+41
View File
@@ -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
+228
View File
@@ -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
+62
View File
@@ -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]
+48
View File
@@ -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()