Update src/streamlit_app.py
Browse files- src/streamlit_app.py +95 -54
src/streamlit_app.py
CHANGED
|
@@ -8,14 +8,14 @@ from datetime import datetime
|
|
| 8 |
import plotly.express as px
|
| 9 |
import plotly.graph_objects as go
|
| 10 |
|
| 11 |
-
# Import BRScraper
|
| 12 |
try:
|
| 13 |
-
from
|
| 14 |
BRSCRAPER_AVAILABLE = True
|
| 15 |
except ImportError:
|
| 16 |
BRSCRAPER_AVAILABLE = False
|
| 17 |
# Display error message if BRScraper is not found
|
| 18 |
-
st.error("BRScraper not found. Please install with: pip install
|
| 19 |
|
| 20 |
# Page configuration
|
| 21 |
st.set_page_config(
|
|
@@ -62,7 +62,7 @@ def get_available_seasons(num_seasons=6):
|
|
| 62 |
end_year = latest_season_end_year - i
|
| 63 |
start_year = end_year - 1
|
| 64 |
# Use en-dash for consistency with BBR format
|
| 65 |
-
seasons_list.append(f"{start_year}–{
|
| 66 |
return sorted(seasons_list, reverse=True) # Sort to show most recent first
|
| 67 |
|
| 68 |
@st.cache_data(ttl=3600)
|
|
@@ -79,11 +79,11 @@ def get_player_index_brscraper():
|
|
| 79 |
# Example: '2024–25' -> 2025
|
| 80 |
latest_season_end_year = int(get_available_seasons(1)[0].split('–')[1])
|
| 81 |
|
| 82 |
-
# Use
|
| 83 |
-
# BRScraper's get_stats returns a '
|
| 84 |
-
df =
|
| 85 |
|
| 86 |
-
if df.empty or '
|
| 87 |
st.warning(f"BRScraper could not fetch player list for {latest_season_end_year}. Falling back to common players.")
|
| 88 |
# Fallback to a hardcoded list of common players for demo
|
| 89 |
common_players = [
|
|
@@ -95,7 +95,7 @@ def get_player_index_brscraper():
|
|
| 95 |
]
|
| 96 |
return pd.DataFrame({'name': common_players})
|
| 97 |
|
| 98 |
-
player_names = df['
|
| 99 |
return pd.DataFrame({'name': player_names})
|
| 100 |
|
| 101 |
except Exception as e:
|
|
@@ -117,22 +117,23 @@ def get_player_career_stats_brscraper(player_name):
|
|
| 117 |
return pd.DataFrame()
|
| 118 |
|
| 119 |
try:
|
| 120 |
-
# BRScraper's
|
| 121 |
-
df =
|
| 122 |
|
| 123 |
if df.empty:
|
| 124 |
return pd.DataFrame()
|
| 125 |
|
| 126 |
# Standardize column names from BRScraper output to app's expected format
|
|
|
|
| 127 |
column_mapping = {
|
| 128 |
-
'
|
| 129 |
'G': 'GP', 'GS': 'GS', 'MP': 'MIN',
|
| 130 |
-
'
|
| 131 |
'TRB': 'REB', 'AST': 'AST', 'STL': 'STL', 'BLK': 'BLK', 'TOV': 'TO',
|
| 132 |
'PF': 'PF', 'PTS': 'PTS',
|
| 133 |
-
'
|
| 134 |
-
'FG': 'FGM', 'FGA': 'FGA', '
|
| 135 |
-
'
|
| 136 |
'FT': 'FTM', 'FTA': 'FTA', 'ORB': 'OREB', 'DRB': 'DREB'
|
| 137 |
}
|
| 138 |
|
|
@@ -146,8 +147,8 @@ def get_player_career_stats_brscraper(player_name):
|
|
| 146 |
df['Season'] = df['Season'].astype(str).str.replace('-', '–')
|
| 147 |
|
| 148 |
# Convert numeric columns
|
| 149 |
-
# Exclude 'Season', 'TEAM_ABBREVIATION', 'POSITION' as they are strings
|
| 150 |
-
non_numeric_cols = {'Season', 'TEAM_ABBREVIATION', 'POSITION'}
|
| 151 |
for col in df.columns:
|
| 152 |
if col not in non_numeric_cols:
|
| 153 |
df[col] = pd.to_numeric(df[col], errors="coerce")
|
|
@@ -164,51 +165,62 @@ def get_player_career_stats_brscraper(player_name):
|
|
| 164 |
@st.cache_data(ttl=300)
|
| 165 |
def get_team_season_stats_brscraper(year):
|
| 166 |
"""
|
| 167 |
-
Uses BRScraper to get team stats for a given season year.
|
| 168 |
Applies column renaming and numeric conversion.
|
| 169 |
"""
|
| 170 |
if not BRSCRAPER_AVAILABLE:
|
| 171 |
return pd.DataFrame()
|
| 172 |
|
| 173 |
try:
|
| 174 |
-
# BRScraper's
|
| 175 |
-
|
|
|
|
| 176 |
|
| 177 |
if df.empty:
|
| 178 |
return pd.DataFrame()
|
| 179 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 180 |
# Standardize column names from BRScraper output
|
|
|
|
| 181 |
column_mapping = {
|
| 182 |
'G': 'GP', 'MP': 'MIN',
|
| 183 |
-
'
|
| 184 |
'TRB': 'REB', 'AST': 'AST', 'STL': 'STL', 'BLK': 'BLK', 'TOV': 'TO',
|
| 185 |
'PF': 'PF', 'PTS': 'PTS',
|
| 186 |
-
'
|
| 187 |
-
'FG': 'FGM', 'FGA': 'FGA', '
|
| 188 |
-
'
|
| 189 |
'FT': 'FTM', 'FTA': 'FTA', 'ORB': 'OREB', 'DRB': 'DREB',
|
| 190 |
-
'
|
| 191 |
}
|
| 192 |
|
| 193 |
for old_col, new_col in column_mapping.items():
|
| 194 |
-
if old_col in
|
| 195 |
-
|
| 196 |
|
| 197 |
# Convert numeric columns
|
| 198 |
# Exclude 'Team' and 'RANK' as they are strings/identifiers
|
| 199 |
non_numeric_cols = {"Team", "RANK"}
|
| 200 |
-
for col in
|
| 201 |
if col not in non_numeric_cols:
|
| 202 |
-
|
| 203 |
|
| 204 |
# Ensure 'Team' column is present and clean it (remove asterisks)
|
| 205 |
-
if 'Team' in
|
| 206 |
-
|
| 207 |
else:
|
| 208 |
st.warning(f"Could not find 'Team' column in BRScraper output for year {year}.")
|
| 209 |
return pd.DataFrame()
|
| 210 |
|
| 211 |
-
return
|
| 212 |
|
| 213 |
except Exception as e:
|
| 214 |
st.error(f"Error fetching team stats for {year} with BRScraper: {e}")
|
|
@@ -305,8 +317,10 @@ def create_radar_chart(player_stats, categories):
|
|
| 305 |
# Main App Structure
|
| 306 |
# —————————————————————————————————————————————————————————————————————————————
|
| 307 |
def main():
|
|
|
|
|
|
|
| 308 |
if not BRSCRAPER_AVAILABLE:
|
| 309 |
-
st.warning("⚠️ BRScraper is not installed. Some features may be limited. Install with: `pip install
|
| 310 |
|
| 311 |
st.markdown('<h1 class="main-header">🏀 NBA Analytics Hub (BBR Edition)</h1>', unsafe_allow_html=True)
|
| 312 |
st.sidebar.title("Navigation")
|
|
@@ -333,7 +347,7 @@ def player_vs_player():
|
|
| 333 |
st.markdown('<h2 class="section-header">Player vs Player Comparison</h2>', unsafe_allow_html=True)
|
| 334 |
|
| 335 |
if not BRSCRAPER_AVAILABLE:
|
| 336 |
-
st.error("BRScraper is required for this feature. Please install
|
| 337 |
return
|
| 338 |
|
| 339 |
idx = get_player_index_brscraper()
|
|
@@ -349,7 +363,7 @@ def player_vs_player():
|
|
| 349 |
return
|
| 350 |
|
| 351 |
stats_tabs = st.tabs(["Basic Stats", "Advanced Stats", "Visualizations"])
|
| 352 |
-
all_player_season_data = []
|
| 353 |
players_with_no_data = []
|
| 354 |
|
| 355 |
with st.spinner("Fetching player data..."):
|
|
@@ -357,6 +371,8 @@ def player_vs_player():
|
|
| 357 |
df_player_career = get_player_career_stats_brscraper(player_name)
|
| 358 |
|
| 359 |
if not df_player_career.empty:
|
|
|
|
|
|
|
| 360 |
filtered_df = df_player_career[df_player_career['Season'].isin(selected_seasons)].copy()
|
| 361 |
|
| 362 |
if not filtered_df.empty:
|
|
@@ -366,17 +382,21 @@ def player_vs_player():
|
|
| 366 |
else:
|
| 367 |
players_with_no_data.append(player_name)
|
| 368 |
|
|
|
|
| 369 |
if players_with_no_data:
|
| 370 |
-
st.info(f"No data found for the selected seasons ({', '.join(selected_seasons)}) for: {', '.join(players_with_no_data)}.")
|
| 371 |
|
| 372 |
if not all_player_season_data:
|
| 373 |
-
st.error("No data available for any of the selected players and seasons to display.")
|
| 374 |
return
|
| 375 |
|
|
|
|
| 376 |
comparison_df_raw = pd.concat(all_player_season_data, ignore_index=True)
|
| 377 |
|
| 378 |
with stats_tabs[0]:
|
| 379 |
st.subheader("Basic Statistics")
|
|
|
|
|
|
|
| 380 |
if len(selected_seasons) > 1:
|
| 381 |
basic_display_df = comparison_df_raw.groupby('Player').mean(numeric_only=True).reset_index()
|
| 382 |
basic_cols = ['Player', 'GP', 'MIN', 'PTS', 'REB', 'AST', 'STL', 'BLK', 'FG_PCT', 'FT_PCT', 'FG3_PCT']
|
|
@@ -393,6 +413,7 @@ def player_vs_player():
|
|
| 393 |
advanced_df = comparison_df_raw.copy()
|
| 394 |
|
| 395 |
# Calculate TS% (True Shooting Percentage)
|
|
|
|
| 396 |
advanced_df['FGA'] = pd.to_numeric(advanced_df['FGA'], errors='coerce').fillna(0)
|
| 397 |
advanced_df['FTA'] = pd.to_numeric(advanced_df['FTA'], errors='coerce').fillna(0)
|
| 398 |
advanced_df['PTS'] = pd.to_numeric(advanced_df['PTS'], errors='coerce').fillna(0)
|
|
@@ -418,14 +439,16 @@ def player_vs_player():
|
|
| 418 |
st.subheader("Player Comparison Charts")
|
| 419 |
|
| 420 |
if not comparison_df_raw.empty:
|
| 421 |
-
metrics = ['PTS', 'REB', 'AST', 'FG_PCT', '
|
| 422 |
available_metrics = [m for m in metrics if m in comparison_df_raw.columns]
|
| 423 |
|
| 424 |
if available_metrics:
|
| 425 |
selected_metric = st.selectbox("Select Metric to Visualize", available_metrics)
|
| 426 |
|
| 427 |
if selected_metric:
|
|
|
|
| 428 |
if len(selected_players) == 1 and len(selected_seasons) > 1:
|
|
|
|
| 429 |
player_trend_df = comparison_df_raw[comparison_df_raw['Player'] == selected_players[0]].sort_values(by='Season')
|
| 430 |
fig = px.line(
|
| 431 |
player_trend_df,
|
|
@@ -435,6 +458,7 @@ def player_vs_player():
|
|
| 435 |
markers=True
|
| 436 |
)
|
| 437 |
else:
|
|
|
|
| 438 |
avg_comparison_df = comparison_df_raw.groupby('Player')[available_metrics].mean(numeric_only=True).reset_index()
|
| 439 |
fig = px.bar(
|
| 440 |
avg_comparison_df,
|
|
@@ -445,12 +469,13 @@ def player_vs_player():
|
|
| 445 |
)
|
| 446 |
st.plotly_chart(fig, use_container_width=True)
|
| 447 |
|
| 448 |
-
# Radar chart
|
| 449 |
radar_metrics_for_chart = ['PTS', 'REB', 'AST', 'STL', 'BLK']
|
| 450 |
radar_metrics_for_chart = [m for m in radar_metrics_for_chart if m in comparison_df_raw.columns]
|
| 451 |
|
| 452 |
if len(radar_metrics_for_chart) >= 3:
|
| 453 |
radar_data = {}
|
|
|
|
| 454 |
if len(selected_seasons) > 1:
|
| 455 |
radar_source_df = comparison_df_raw.groupby('Player')[radar_metrics_for_chart].mean(numeric_only=True).reset_index()
|
| 456 |
else:
|
|
@@ -458,13 +483,14 @@ def player_vs_player():
|
|
| 458 |
|
| 459 |
scaled_radar_df = radar_source_df.copy()
|
| 460 |
|
|
|
|
| 461 |
for col in radar_metrics_for_chart:
|
| 462 |
min_val = scaled_radar_df[col].min()
|
| 463 |
max_val = scaled_radar_df[col].max()
|
| 464 |
if max_val > min_val:
|
| 465 |
scaled_radar_df[col] = ((scaled_radar_df[col] - min_val) / (max_val - min_val)) * 100
|
| 466 |
else:
|
| 467 |
-
scaled_radar_df[col] = 0
|
| 468 |
|
| 469 |
for _, row in scaled_radar_df.iterrows():
|
| 470 |
radar_data[row['Player']] = {
|
|
@@ -474,6 +500,15 @@ def player_vs_player():
|
|
| 474 |
if radar_data:
|
| 475 |
radar_fig = create_radar_chart(radar_data, radar_metrics_for_chart)
|
| 476 |
st.plotly_chart(radar_fig, use_container_width=True)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 477 |
|
| 478 |
def team_vs_team():
|
| 479 |
st.markdown('<h2 class="section-header">Team vs Team Analysis</h2>', unsafe_allow_html=True)
|
|
@@ -488,15 +523,15 @@ def team_vs_team():
|
|
| 488 |
return
|
| 489 |
|
| 490 |
selected_season_str = st.selectbox("Select Season", available_seasons, index=0)
|
| 491 |
-
#
|
| 492 |
year_for_team_stats = int(selected_season_str.split('–')[1])
|
| 493 |
|
| 494 |
-
tm_df = get_team_season_stats_brscraper(year_for_team_stats)
|
| 495 |
if tm_df.empty:
|
| 496 |
-
st.info(f"No team data available for the {selected_season_str} season.")
|
| 497 |
return
|
| 498 |
|
| 499 |
-
teams = tm_df['Team'].unique().tolist()
|
| 500 |
selected_teams = st.multiselect("Select Teams (up to 4)", teams, max_selections=4)
|
| 501 |
|
| 502 |
if st.button("Run Comparison"):
|
|
@@ -509,45 +544,51 @@ def team_vs_team():
|
|
| 509 |
|
| 510 |
with st.spinner("Fetching team data..."):
|
| 511 |
for t in selected_teams:
|
| 512 |
-
df = tm_df[tm_df.Team == t].copy()
|
| 513 |
if not df.empty:
|
| 514 |
-
|
| 515 |
-
|
|
|
|
|
|
|
| 516 |
else:
|
| 517 |
teams_with_no_data.append(t)
|
| 518 |
|
| 519 |
if teams_with_no_data:
|
| 520 |
-
st.info(f"No data found for: {', '.join(teams_with_no_data)}")
|
| 521 |
|
| 522 |
if not stats:
|
| 523 |
-
st.error("No data available for the selected teams.")
|
| 524 |
return
|
| 525 |
|
| 526 |
comp = pd.DataFrame(stats)
|
| 527 |
-
|
|
|
|
| 528 |
if col in comp.columns:
|
| 529 |
comp[col] = pd.to_numeric(comp[col], errors='coerce')
|
| 530 |
|
| 531 |
st.subheader("Team Statistics Comparison")
|
| 532 |
-
cols = ['Team', 'Season', 'PTS', 'REB', 'AST', 'STL', 'BLK', 'FG_PCT', '
|
| 533 |
display_cols = [col for col in cols if col in comp.columns]
|
| 534 |
st.dataframe(comp[display_cols].round(2), use_container_width=True)
|
| 535 |
|
| 536 |
st.subheader("Team Performance Visualization")
|
| 537 |
-
metric_options = ['PTS', 'REB', 'AST', 'FG_PCT', '
|
| 538 |
available_metrics = [m for m in metric_options if m in comp.columns]
|
| 539 |
|
| 540 |
if available_metrics:
|
| 541 |
selected_metric = st.selectbox("Select Metric", available_metrics)
|
|
|
|
| 542 |
fig = px.bar(
|
| 543 |
comp,
|
| 544 |
x='Team',
|
| 545 |
y=selected_metric,
|
| 546 |
-
color='Team',
|
| 547 |
title=f"Team {selected_metric} Comparison ({selected_season_str} Season)",
|
| 548 |
barmode='group'
|
| 549 |
)
|
| 550 |
st.plotly_chart(fig, use_container_width=True)
|
|
|
|
|
|
|
| 551 |
|
| 552 |
def awards_predictor():
|
| 553 |
st.markdown('<h2 class="section-header">NBA Awards Predictor</h2>', unsafe_allow_html=True)
|
|
|
|
| 8 |
import plotly.express as px
|
| 9 |
import plotly.graph_objects as go
|
| 10 |
|
| 11 |
+
# Import BRScraper
|
| 12 |
try:
|
| 13 |
+
from BRScraper import nba
|
| 14 |
BRSCRAPER_AVAILABLE = True
|
| 15 |
except ImportError:
|
| 16 |
BRSCRAPER_AVAILABLE = False
|
| 17 |
# Display error message if BRScraper is not found
|
| 18 |
+
st.error("BRScraper not found. Please install with: `pip install BRScraper`")
|
| 19 |
|
| 20 |
# Page configuration
|
| 21 |
st.set_page_config(
|
|
|
|
| 62 |
end_year = latest_season_end_year - i
|
| 63 |
start_year = end_year - 1
|
| 64 |
# Use en-dash for consistency with BBR format
|
| 65 |
+
seasons_list.append(f"{start_year}–{end_year}")
|
| 66 |
return sorted(seasons_list, reverse=True) # Sort to show most recent first
|
| 67 |
|
| 68 |
@st.cache_data(ttl=3600)
|
|
|
|
| 79 |
# Example: '2024–25' -> 2025
|
| 80 |
latest_season_end_year = int(get_available_seasons(1)[0].split('–')[1])
|
| 81 |
|
| 82 |
+
# Use nba.get_stats to get a list of players for the latest season
|
| 83 |
+
# BRScraper's get_stats returns a 'Player' column
|
| 84 |
+
df = nba.get_stats(latest_season_end_year, info='per_game', rename=False)
|
| 85 |
|
| 86 |
+
if df.empty or 'Player' not in df.columns:
|
| 87 |
st.warning(f"BRScraper could not fetch player list for {latest_season_end_year}. Falling back to common players.")
|
| 88 |
# Fallback to a hardcoded list of common players for demo
|
| 89 |
common_players = [
|
|
|
|
| 95 |
]
|
| 96 |
return pd.DataFrame({'name': common_players})
|
| 97 |
|
| 98 |
+
player_names = df['Player'].unique().tolist()
|
| 99 |
return pd.DataFrame({'name': player_names})
|
| 100 |
|
| 101 |
except Exception as e:
|
|
|
|
| 117 |
return pd.DataFrame()
|
| 118 |
|
| 119 |
try:
|
| 120 |
+
# BRScraper's nba.get_player_stats returns a DataFrame with career stats
|
| 121 |
+
df = nba.get_player_stats(player_name)
|
| 122 |
|
| 123 |
if df.empty:
|
| 124 |
return pd.DataFrame()
|
| 125 |
|
| 126 |
# Standardize column names from BRScraper output to app's expected format
|
| 127 |
+
# BRScraper's nba.get_player_stats uses column names like 'FG%', 'TRB', 'Tm', 'Age'
|
| 128 |
column_mapping = {
|
| 129 |
+
'Season': 'Season', # BRScraper returns 'Season'
|
| 130 |
'G': 'GP', 'GS': 'GS', 'MP': 'MIN',
|
| 131 |
+
'FG%': 'FG_PCT', '3P%': 'FG3_PCT', 'FT%': 'FT_PCT',
|
| 132 |
'TRB': 'REB', 'AST': 'AST', 'STL': 'STL', 'BLK': 'BLK', 'TOV': 'TO',
|
| 133 |
'PF': 'PF', 'PTS': 'PTS',
|
| 134 |
+
'Age': 'AGE', 'Tm': 'TEAM_ABBREVIATION', 'Lg': 'LEAGUE_ID', 'Pos': 'POSITION',
|
| 135 |
+
'FG': 'FGM', 'FGA': 'FGA', '3P': 'FG3M', '3PA': 'FG3A',
|
| 136 |
+
'2P': 'FGM2', '2PA': 'FGA2', '2P%': 'FG2_PCT', 'eFG%': 'EFG_PCT',
|
| 137 |
'FT': 'FTM', 'FTA': 'FTA', 'ORB': 'OREB', 'DRB': 'DREB'
|
| 138 |
}
|
| 139 |
|
|
|
|
| 147 |
df['Season'] = df['Season'].astype(str).str.replace('-', '–')
|
| 148 |
|
| 149 |
# Convert numeric columns
|
| 150 |
+
# Exclude 'Season', 'TEAM_ABBREVIATION', 'LEAGUE_ID', 'POSITION' as they are strings
|
| 151 |
+
non_numeric_cols = {'Season', 'TEAM_ABBREVIATION', 'LEAGUE_ID', 'POSITION'}
|
| 152 |
for col in df.columns:
|
| 153 |
if col not in non_numeric_cols:
|
| 154 |
df[col] = pd.to_numeric(df[col], errors="coerce")
|
|
|
|
| 165 |
@st.cache_data(ttl=300)
|
| 166 |
def get_team_season_stats_brscraper(year):
|
| 167 |
"""
|
| 168 |
+
Uses BRScraper to get per-game team stats for a given season year.
|
| 169 |
Applies column renaming and numeric conversion.
|
| 170 |
"""
|
| 171 |
if not BRSCRAPER_AVAILABLE:
|
| 172 |
return pd.DataFrame()
|
| 173 |
|
| 174 |
try:
|
| 175 |
+
# BRScraper's nba.get_stats returns a DataFrame with team stats for the year
|
| 176 |
+
# This function returns both player and team stats, so we need to filter for teams
|
| 177 |
+
df = nba.get_stats(year, info='per_game', rename=False)
|
| 178 |
|
| 179 |
if df.empty:
|
| 180 |
return pd.DataFrame()
|
| 181 |
|
| 182 |
+
# Filter for team rows. Team rows typically have 'Player' as NaN.
|
| 183 |
+
# Also, remove any rows that are just headers (e.g., 'Rk' == 'Rk')
|
| 184 |
+
team_df = df[df['Player'].isna()].copy()
|
| 185 |
+
team_df = team_df[team_df['Rk'].astype(str).str.lower() != 'rk'].copy() # Remove header rows if they slipped through
|
| 186 |
+
|
| 187 |
+
if team_df.empty:
|
| 188 |
+
st.warning(f"Could not reliably identify team rows for year {year}. Returning empty DataFrame.")
|
| 189 |
+
return pd.DataFrame()
|
| 190 |
+
|
| 191 |
# Standardize column names from BRScraper output
|
| 192 |
+
# BRScraper's nba.get_stats for teams uses 'Tm', 'W/L%', etc.
|
| 193 |
column_mapping = {
|
| 194 |
'G': 'GP', 'MP': 'MIN',
|
| 195 |
+
'FG%': 'FG_PCT', '3P%': 'FG3_PCT', 'FT%': 'FT_PCT',
|
| 196 |
'TRB': 'REB', 'AST': 'AST', 'STL': 'STL', 'BLK': 'BLK', 'TOV': 'TO',
|
| 197 |
'PF': 'PF', 'PTS': 'PTS',
|
| 198 |
+
'Rk': 'RANK', 'W': 'WINS', 'L': 'LOSSES', 'W/L%': 'WIN_LOSS_PCT',
|
| 199 |
+
'FG': 'FGM', 'FGA': 'FGA', '3P': 'FG3M', '3PA': 'FG3A',
|
| 200 |
+
'2P': 'FGM2', '2PA': 'FGA2', '2P%': 'FG2_PCT', 'eFG%': 'EFG_PCT',
|
| 201 |
'FT': 'FTM', 'FTA': 'FTA', 'ORB': 'OREB', 'DRB': 'DREB',
|
| 202 |
+
'Tm': 'Team' # BRScraper returns 'Tm' for team abbreviation
|
| 203 |
}
|
| 204 |
|
| 205 |
for old_col, new_col in column_mapping.items():
|
| 206 |
+
if old_col in team_df.columns:
|
| 207 |
+
team_df = team_df.rename(columns={old_col: new_col})
|
| 208 |
|
| 209 |
# Convert numeric columns
|
| 210 |
# Exclude 'Team' and 'RANK' as they are strings/identifiers
|
| 211 |
non_numeric_cols = {"Team", "RANK"}
|
| 212 |
+
for col in team_df.columns:
|
| 213 |
if col not in non_numeric_cols:
|
| 214 |
+
team_df[col] = pd.to_numeric(team_df[col], errors="coerce")
|
| 215 |
|
| 216 |
# Ensure 'Team' column is present and clean it (remove asterisks)
|
| 217 |
+
if 'Team' in team_df.columns:
|
| 218 |
+
team_df['Team'] = team_df['Team'].astype(str).str.replace('*', '', regex=False).str.strip()
|
| 219 |
else:
|
| 220 |
st.warning(f"Could not find 'Team' column in BRScraper output for year {year}.")
|
| 221 |
return pd.DataFrame()
|
| 222 |
|
| 223 |
+
return team_df
|
| 224 |
|
| 225 |
except Exception as e:
|
| 226 |
st.error(f"Error fetching team stats for {year} with BRScraper: {e}")
|
|
|
|
| 317 |
# Main App Structure
|
| 318 |
# —————————————————————————————————————————————————————————————————————————————
|
| 319 |
def main():
|
| 320 |
+
# Check if BRScraper is available at the start of main
|
| 321 |
+
# This flag is set globally based on the import attempt
|
| 322 |
if not BRSCRAPER_AVAILABLE:
|
| 323 |
+
st.warning("⚠️ BRScraper is not installed. Some features may be limited. Install with: `pip install BRScraper`")
|
| 324 |
|
| 325 |
st.markdown('<h1 class="main-header">🏀 NBA Analytics Hub (BBR Edition)</h1>', unsafe_allow_html=True)
|
| 326 |
st.sidebar.title("Navigation")
|
|
|
|
| 347 |
st.markdown('<h2 class="section-header">Player vs Player Comparison</h2>', unsafe_allow_html=True)
|
| 348 |
|
| 349 |
if not BRSCRAPER_AVAILABLE:
|
| 350 |
+
st.error("BRScraper is required for this feature. Please install BRScraper.")
|
| 351 |
return
|
| 352 |
|
| 353 |
idx = get_player_index_brscraper()
|
|
|
|
| 363 |
return
|
| 364 |
|
| 365 |
stats_tabs = st.tabs(["Basic Stats", "Advanced Stats", "Visualizations"])
|
| 366 |
+
all_player_season_data = [] # To store individual season rows for each player
|
| 367 |
players_with_no_data = []
|
| 368 |
|
| 369 |
with st.spinner("Fetching player data..."):
|
|
|
|
| 371 |
df_player_career = get_player_career_stats_brscraper(player_name)
|
| 372 |
|
| 373 |
if not df_player_career.empty:
|
| 374 |
+
# Filter for selected seasons. The player_season_stats function
|
| 375 |
+
# already ensures the 'Season' column uses en-dashes.
|
| 376 |
filtered_df = df_player_career[df_player_career['Season'].isin(selected_seasons)].copy()
|
| 377 |
|
| 378 |
if not filtered_df.empty:
|
|
|
|
| 382 |
else:
|
| 383 |
players_with_no_data.append(player_name)
|
| 384 |
|
| 385 |
+
# Report on players with no data for selected seasons
|
| 386 |
if players_with_no_data:
|
| 387 |
+
st.info(f"No data found for the selected seasons ({', '.join(selected_seasons)}) for: {', '.join(players_with_no_data)}. This might be because the season hasn't started or data is not yet available, or the player name was not found by BRScraper.")
|
| 388 |
|
| 389 |
if not all_player_season_data:
|
| 390 |
+
st.error("No data available for any of the selected players and seasons to display. Please adjust your selections.")
|
| 391 |
return
|
| 392 |
|
| 393 |
+
# Concatenate all collected season data into one DataFrame
|
| 394 |
comparison_df_raw = pd.concat(all_player_season_data, ignore_index=True)
|
| 395 |
|
| 396 |
with stats_tabs[0]:
|
| 397 |
st.subheader("Basic Statistics")
|
| 398 |
+
# Group by player and average for the basic stats table if multiple seasons are selected
|
| 399 |
+
# Otherwise, show individual season stats if only one season is selected
|
| 400 |
if len(selected_seasons) > 1:
|
| 401 |
basic_display_df = comparison_df_raw.groupby('Player').mean(numeric_only=True).reset_index()
|
| 402 |
basic_cols = ['Player', 'GP', 'MIN', 'PTS', 'REB', 'AST', 'STL', 'BLK', 'FG_PCT', 'FT_PCT', 'FG3_PCT']
|
|
|
|
| 413 |
advanced_df = comparison_df_raw.copy()
|
| 414 |
|
| 415 |
# Calculate TS% (True Shooting Percentage)
|
| 416 |
+
# Ensure FGA and FTA are numeric and not zero to avoid division by zero
|
| 417 |
advanced_df['FGA'] = pd.to_numeric(advanced_df['FGA'], errors='coerce').fillna(0)
|
| 418 |
advanced_df['FTA'] = pd.to_numeric(advanced_df['FTA'], errors='coerce').fillna(0)
|
| 419 |
advanced_df['PTS'] = pd.to_numeric(advanced_df['PTS'], errors='coerce').fillna(0)
|
|
|
|
| 439 |
st.subheader("Player Comparison Charts")
|
| 440 |
|
| 441 |
if not comparison_df_raw.empty:
|
| 442 |
+
metrics = ['PTS', 'REB', 'AST', 'FG_PCT', '3P_PCT', 'FT_PCT', 'STL', 'BLK']
|
| 443 |
available_metrics = [m for m in metrics if m in comparison_df_raw.columns]
|
| 444 |
|
| 445 |
if available_metrics:
|
| 446 |
selected_metric = st.selectbox("Select Metric to Visualize", available_metrics)
|
| 447 |
|
| 448 |
if selected_metric:
|
| 449 |
+
# Determine if we are showing a trend for one player or comparison for multiple
|
| 450 |
if len(selected_players) == 1 and len(selected_seasons) > 1:
|
| 451 |
+
# Show trend over seasons for one player
|
| 452 |
player_trend_df = comparison_df_raw[comparison_df_raw['Player'] == selected_players[0]].sort_values(by='Season')
|
| 453 |
fig = px.line(
|
| 454 |
player_trend_df,
|
|
|
|
| 458 |
markers=True
|
| 459 |
)
|
| 460 |
else:
|
| 461 |
+
# Average over selected seasons for multiple players for bar chart
|
| 462 |
avg_comparison_df = comparison_df_raw.groupby('Player')[available_metrics].mean(numeric_only=True).reset_index()
|
| 463 |
fig = px.bar(
|
| 464 |
avg_comparison_df,
|
|
|
|
| 469 |
)
|
| 470 |
st.plotly_chart(fig, use_container_width=True)
|
| 471 |
|
| 472 |
+
# Radar chart for multi-metric comparison
|
| 473 |
radar_metrics_for_chart = ['PTS', 'REB', 'AST', 'STL', 'BLK']
|
| 474 |
radar_metrics_for_chart = [m for m in radar_metrics_for_chart if m in comparison_df_raw.columns]
|
| 475 |
|
| 476 |
if len(radar_metrics_for_chart) >= 3:
|
| 477 |
radar_data = {}
|
| 478 |
+
# Use the averaged data for radar chart if multiple seasons
|
| 479 |
if len(selected_seasons) > 1:
|
| 480 |
radar_source_df = comparison_df_raw.groupby('Player')[radar_metrics_for_chart].mean(numeric_only=True).reset_index()
|
| 481 |
else:
|
|
|
|
| 483 |
|
| 484 |
scaled_radar_df = radar_source_df.copy()
|
| 485 |
|
| 486 |
+
# Simple min-max scaling for radar chart visualization (0-100)
|
| 487 |
for col in radar_metrics_for_chart:
|
| 488 |
min_val = scaled_radar_df[col].min()
|
| 489 |
max_val = scaled_radar_df[col].max()
|
| 490 |
if max_val > min_val:
|
| 491 |
scaled_radar_df[col] = ((scaled_radar_df[col] - min_val) / (max_val - min_val)) * 100
|
| 492 |
else:
|
| 493 |
+
scaled_radar_df[col] = 0 # Default if all values are the same
|
| 494 |
|
| 495 |
for _, row in scaled_radar_df.iterrows():
|
| 496 |
radar_data[row['Player']] = {
|
|
|
|
| 500 |
if radar_data:
|
| 501 |
radar_fig = create_radar_chart(radar_data, radar_metrics_for_chart)
|
| 502 |
st.plotly_chart(radar_fig, use_container_width=True)
|
| 503 |
+
else:
|
| 504 |
+
st.info("Could not generate radar chart data.")
|
| 505 |
+
else:
|
| 506 |
+
st.info("Select at least 3 common metrics for a radar chart (e.g., PTS, REB, AST, STL, BLK).")
|
| 507 |
+
else:
|
| 508 |
+
st.info("No common metrics available for visualization.")
|
| 509 |
+
else:
|
| 510 |
+
st.info("No data available for visualizations.")
|
| 511 |
+
|
| 512 |
|
| 513 |
def team_vs_team():
|
| 514 |
st.markdown('<h2 class="section-header">Team vs Team Analysis</h2>', unsafe_allow_html=True)
|
|
|
|
| 523 |
return
|
| 524 |
|
| 525 |
selected_season_str = st.selectbox("Select Season", available_seasons, index=0)
|
| 526 |
+
# Extract the end year from the season string (e.g., "2024–25" -> 2025)
|
| 527 |
year_for_team_stats = int(selected_season_str.split('–')[1])
|
| 528 |
|
| 529 |
+
tm_df = get_team_season_stats_brscraper(year_for_team_stats) # Use BRScraper for team stats
|
| 530 |
if tm_df.empty:
|
| 531 |
+
st.info(f"No team data available for the {selected_season_str} season. This might be because the season hasn't started or data is not yet available, or BRScraper could not fetch it.")
|
| 532 |
return
|
| 533 |
|
| 534 |
+
teams = tm_df['Team'].unique().tolist() # Use 'Team' column from BRScraper output
|
| 535 |
selected_teams = st.multiselect("Select Teams (up to 4)", teams, max_selections=4)
|
| 536 |
|
| 537 |
if st.button("Run Comparison"):
|
|
|
|
| 544 |
|
| 545 |
with st.spinner("Fetching team data..."):
|
| 546 |
for t in selected_teams:
|
| 547 |
+
df = tm_df[tm_df.Team == t].copy() # Filter by 'Team' column
|
| 548 |
if not df.empty:
|
| 549 |
+
# For team stats, we usually get one row per team per season from team_per_game
|
| 550 |
+
# So, no need for .mean() here, just take the row.
|
| 551 |
+
df['Season'] = selected_season_str # Add 'Season' column
|
| 552 |
+
stats.append(df.iloc[0].to_dict()) # Convert the single row to dict
|
| 553 |
else:
|
| 554 |
teams_with_no_data.append(t)
|
| 555 |
|
| 556 |
if teams_with_no_data:
|
| 557 |
+
st.info(f"No data found for the selected season ({selected_season_str}) for: {', '.join(teams_with_no_data)}. This might be because the season hasn't started or data is not yet available.")
|
| 558 |
|
| 559 |
if not stats:
|
| 560 |
+
st.error("No data available for the selected teams to display. Please adjust your selections.")
|
| 561 |
return
|
| 562 |
|
| 563 |
comp = pd.DataFrame(stats)
|
| 564 |
+
# Ensure numeric columns are actually numeric for display and calculations
|
| 565 |
+
for col in ['PTS', 'REB', 'AST', 'STL', 'BLK', 'FG_PCT', '3P_PCT', 'FT_PCT']:
|
| 566 |
if col in comp.columns:
|
| 567 |
comp[col] = pd.to_numeric(comp[col], errors='coerce')
|
| 568 |
|
| 569 |
st.subheader("Team Statistics Comparison")
|
| 570 |
+
cols = ['Team', 'Season', 'PTS', 'REB', 'AST', 'STL', 'BLK', 'FG_PCT', '3P_PCT', 'FT_PCT']
|
| 571 |
display_cols = [col for col in cols if col in comp.columns]
|
| 572 |
st.dataframe(comp[display_cols].round(2), use_container_width=True)
|
| 573 |
|
| 574 |
st.subheader("Team Performance Visualization")
|
| 575 |
+
metric_options = ['PTS', 'REB', 'AST', 'FG_PCT', '3P_PCT', 'FT_PCT']
|
| 576 |
available_metrics = [m for m in metric_options if m in comp.columns]
|
| 577 |
|
| 578 |
if available_metrics:
|
| 579 |
selected_metric = st.selectbox("Select Metric", available_metrics)
|
| 580 |
+
|
| 581 |
fig = px.bar(
|
| 582 |
comp,
|
| 583 |
x='Team',
|
| 584 |
y=selected_metric,
|
| 585 |
+
color='Team', # Color by team for clarity
|
| 586 |
title=f"Team {selected_metric} Comparison ({selected_season_str} Season)",
|
| 587 |
barmode='group'
|
| 588 |
)
|
| 589 |
st.plotly_chart(fig, use_container_width=True)
|
| 590 |
+
else:
|
| 591 |
+
st.info("No common metrics available for visualization.")
|
| 592 |
|
| 593 |
def awards_predictor():
|
| 594 |
st.markdown('<h2 class="section-header">NBA Awards Predictor</h2>', unsafe_allow_html=True)
|