nesticot's picture
Update app.py
df32dbb verified
from shiny import App, reactive, render, ui
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import matplotlib
matplotlib.use('Agg') # Use non-interactive backend
import io
import base64
from PIL import Image, ImageDraw, ImageFont
import requests
import polars as pl
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from matplotlib import gridspec
import seaborn as sns
import csv
df_rankings = pd.read_csv("hf://datasets/TJStatsApps/scouting_reports/top_100_rankings_app.csv", quoting=csv.QUOTE_MINIMAL,encoding='latin1').replace(0, None)
df_rankings = df_rankings[df_rankings['rank']<=100]
df_scout_batters = pd.read_csv("hf://datasets/TJStatsApps/scouting_reports/scouting_reports_batters.csv", quoting=csv.QUOTE_MINIMAL).replace(0, None)
df_scout_batters['last_updated'] = pd.Timestamp.today().strftime('%Y-%m-%d %H:%M:%S')
df_scout_pitchers = pd.read_csv("hf://datasets/TJStatsApps/scouting_reports/scouting_reports.csv", quoting=csv.QUOTE_MINIMAL).replace(0, None)
df_scout_pitchers['last_updated'] = pd.Timestamp.today().strftime('%Y-%m-%d %H:%M:%S')
df_scout = pd.concat([df_scout_batters, df_scout_pitchers], ignore_index=True)
df_scout = df_rankings.merge(df_scout, on='player_id')
player_url = f"http://statsapi.mlb.com/api/v1/people?personIds={str([int(x) for x in df_rankings['player_id']]).strip('[]').replace(' ', '')}&hydrate=currentTeam&appContext=minorLeague"
player_data = requests.get(url=player_url).json()['people']
# Extract relevant data
fullName_list = [x['fullName'] for x in player_data]
firstName_list = [x['firstName'] for x in player_data]
useName_list = [x['useName'] for x in player_data]
lastName_list = [x['lastName'] for x in player_data]
id_list = [x['id'] for x in player_data]
position_list = [x['primaryPosition']['abbreviation'] if 'primaryPosition' in x else None for x in player_data]
team_list = [x['currentTeam']['id'] if 'currentTeam' in x else None for x in player_data]
parent_org_list = [x['currentTeam']['parentOrgId'] if 'parentOrgId' in x['currentTeam'] else None for x in player_data]
weight_list = [x['weight'] if 'weight' in x else None for x in player_data]
height_list = [x['height'] if 'height' in x else None for x in player_data]
age_list = [x['currentAge'] if 'currentAge' in x else None for x in player_data]
birthDate_list = [x['birthDate'] if 'birthDate' in x else None for x in player_data]
bat_list = [x['batSide']['code'] if 'batSide' in x else None for x in player_data]
throw_list = [x['pitchHand']['code'] if 'pitchHand' in x else None for x in player_data]
df_players = pd.DataFrame(data={
'player_id': id_list,
'first_name': firstName_list,
'use_name': useName_list,
'last_name': lastName_list,
'name': fullName_list,
'position': position_list,
'team': team_list,
'parent_org_id': parent_org_list,
'weight': weight_list,
'height': height_list,
'age': age_list,
'birthDate': birthDate_list,
'bat_side': bat_list,
'throw_side': throw_list
})
df_players['name'] = df_players['use_name'] + ' ' + df_players['last_name']
df_players['logo'] = df_players['parent_org_id'].apply(lambda team_id: f'https://www.mlbstatic.com/team-logos/{team_id}.svg')
df_players['headshot'] = df_players['player_id'].apply(lambda player_id: f'https://img.mlbstatic.com/mlb-photos/image/upload/c_fill,g_auto/w_640/v1/people/{player_id}/headshot/milb/current.png')
df_scout = df_scout.merge(df_players, on='player_id', how='left', suffixes=('', '_y'))
# df_scout = pd.concat([df_scout_batters, df_scout_pitchers], ignore_index=True)
df_scout['player_id'] = df_scout['player_id'].astype(int)
import pandas as pd
import matplotlib.pyplot as plt
import requests
from PIL import Image
from io import BytesIO
import cairosvg
# --- Utilities ---
def get_index_of_latest_draft(drafts):
"""Return index of latest draft year."""
if not isinstance(drafts, list) or not drafts:
return -1
try:
return max(range(len(drafts)), key=lambda i: int(drafts[i].get('year', -1)))
except (ValueError, TypeError):
return -1
def get_value(data, key):
return data.get(key)
# --- Season Index Helpers ---
DEFAULT_YEARS = ['2026','2025','2024','2023','2022']
def get_available_seasons(stats):
"""Return all seasons available in stats[0]['splits'], sorted descending."""
try:
return sorted({split.get('season') for split in stats[0].get('splits', []) if split.get('season')}, reverse=True)
except Exception:
return []
def find_season_index(stats, target_seasons=None):
"""Find index of first occurrence of any target season, preferring newest available season first."""
try:
available_seasons = get_available_seasons(stats)
if not available_seasons:
return -1
# Combine available seasons with default target seasons
search_order = available_seasons + (target_seasons or DEFAULT_YEARS)
splits = stats[0].get('splits', [])
for season in search_order:
for i, split in enumerate(splits):
if split.get('season') == season:
return i
return -1
except Exception:
return -1
def find_all_season_indices(stats, target_seasons=None):
"""Return all indices of splits for the first matching season in search_order."""
try:
available_seasons = get_available_seasons(stats)
search_order = available_seasons + (target_seasons or DEFAULT_YEARS)
splits = stats[0].get('splits', [])
for season in search_order:
indices = [i for i, s in enumerate(splits) if s.get('season') == season]
if indices:
return indices
return []
except Exception:
return []
# --- Stats Extraction ---
def get_latest_stats(stats, target_seasons=DEFAULT_YEARS):
"""Return a DataFrame with the latest season stats."""
if not stats or not isinstance(stats, list):
return pd.DataFrame()
latest_index = find_season_index(stats, target_seasons)
idx_yby = next((i for i, s in enumerate(stats) if s.get('type', {}).get('displayName') == 'yearByYear'), None)
idx_ybya = next((i for i, s in enumerate(stats) if s.get('type', {}).get('displayName') == 'yearByYearAdvanced'), None)
if latest_index == -1 or idx_yby is None or idx_ybya is None:
# Return empty stats if no season found
empty_stats = {
'season': None,
'plateAppearances': None,
'totalBases': None,
'leftOnBase': None,
'sacBunts': None,
'sacFlies': None,
'babip': None,
'extraBaseHits': None,
'hitByPitch': None,
'gidp': None,
'gidpOpp': None,
'numberOfPitches': None,
'pitchesPerPlateAppearance': None,
'walksPerPlateAppearance': None,
'strikeoutsPerPlateAppearance': None,
'homeRunsPerPlateAppearance': None,
'walksPerStrikeout': None,
'iso': None,
'reachedOnError': None,
'walkOffs': None,
'flyOuts': None,
'totalSwings': None,
'swingAndMisses': None,
'ballsInPlay': None,
'popOuts': None,
'lineOuts': None,
'groundOuts': None,
'flyHits': None,
'popHits': None,
'lineHits': None,
'groundHits': None,
'whiffRate': None,
'contactRate': None,
# Add new stats with None as default
'gamesPlayed': None,
'groundOuts': None,
'airOuts': None,
'runs': None,
'doubles': None,
'triples': None,
'homeRuns': None,
'strikeOuts': None,
'baseOnBalls': None,
'intentionalWalks': None,
'hits': None,
'hitByPitch': None,
'avg': None,
'atBats': None,
'obp': None,
'slg': None,
'ops': None,
'caughtStealing': None,
'stolenBases': None,
'stolenBasePercentage': None,
'groundIntoDoublePlay': None,
'numberOfPitches': None,
'plateAppearances': None,
'totalBases': None,
'rbi': None,
'leftOnBase': None,
'sacBunts': None,
'sacFlies': None,
'babip': None,
'groundOutsToAirouts': None,
'catchersInterference': None,
'atBatsPerHomeRun': None,
}
# df = pd.DataFrame(stats_latest, index=[0])
# return df
# empty_stats = {k: None for k in [
# 'season','gamesPlayed','plateAppearances','totalSwings','swingAndMisses','ballsInPlay','whiffRate',
# 'contactRate','swingRate','numberOfPitches','lineHits',
# 'lineOuts','flyHits','flyOuts','popHits','popOuts','airPercent',
# 'strikeoutPercentage','walkPercentage','strikeoutMinusWalkPercentage','levels','iso','ops'
# ]}
return pd.DataFrame([empty_stats])
latest_season = stats[0]['splits'][latest_index]['season']
split_yby = next((s for s in stats[idx_yby]['splits'] if s.get('season') == latest_season), {})
split_ybya = next((s for s in stats[idx_ybya]['splits'] if s.get('season') == latest_season), {})
stat_yby = split_yby.get('stat', {})
stat_ybya = split_ybya.get('stat', {})
# Merge stats, advanced overrides base
merged_stats = {k: stat_ybya.get(k, stat_yby.get(k)) for k in set(stat_yby) | set(stat_ybya)}
merged_stats['season'] = latest_season
# Compute derived stats
pa = merged_stats.get('plateAppearances')
swings = merged_stats.get('totalSwings')
misses = merged_stats.get('swingAndMisses')
balls_in_play = merged_stats.get('ballsInPlay')
merged_stats['whiffRate'] = misses / swings if swings else None
merged_stats['contactRate'] = 1 - merged_stats['whiffRate'] if swings else None
merged_stats['swingRate'] = swings / merged_stats.get('numberOfPitches') if swings and merged_stats.get('numberOfPitches') else None
try:
air_cols = ['lineHits', 'lineOuts', 'flyHits', 'flyOuts','popHits','popOuts']
merged_stats['airPercent'] = sum(merged_stats.get(c, 0) for c in air_cols) / balls_in_play if balls_in_play else None
except Exception:
merged_stats['airPercent'] = None
# Compute levels
indices = find_all_season_indices(stats, target_seasons)
levels = set(stats[0]['splits'][i]['sport']['abbreviation'] for i in indices)
merged_stats['levels'] = ", ".join(levels).replace(', MLB', '').replace(', Minors', '')
return pd.DataFrame([merged_stats])
def get_pitcher_stats(stats):
"""Extract pitcher stats for the first available target season."""
idx = find_season_index(stats)
if idx == -1:
# Return empty stats if no season found
empty_stats = {k: None for k in [
'season','gamesPlayed','gamesStarted','strikeOuts','baseOnBalls','homeRuns','era',
'inningsPitched','whip','battersFaced','strikes','numberOfPitches','babip',
'totalSwings','swingAndMisses','ballsInPlay','whiffRate','strikePercentage',
'strikeoutPercentage','walkPercentage','strikeoutMinusWalkPercentage','levels'
]}
return pd.DataFrame([empty_stats])
try:
split_basic = stats[0]['splits'][idx]['stat']
split_adv = stats[1]['splits'][idx]['stat']
df = pd.DataFrame([{
'season': stats[0]['splits'][idx]['season'],
'gamesPlayed': split_basic.get('gamesPlayed'),
'gamesStarted': split_basic.get('gamesStarted'),
'strikeOuts': split_basic.get('strikeOuts'),
'baseOnBalls': split_basic.get('baseOnBalls'),
'homeRuns': split_basic.get('homeRuns'),
'era': split_basic.get('era'),
'inningsPitched': split_basic.get('inningsPitched'),
'whip': split_basic.get('whip'),
'battersFaced': split_basic.get('battersFaced'),
'strikes': split_basic.get('strikes'),
'numberOfPitches': split_basic.get('numberOfPitches'),
'babip': split_adv.get('babip'),
'totalSwings': split_adv.get('totalSwings'),
'swingAndMisses': split_adv.get('swingAndMisses'),
'ballsInPlay': split_adv.get('ballsInPlay'),
}])
df['whiffRate'] = df['swingAndMisses'] / df['totalSwings']
df['strikePercentage'] = df['strikes'] / df['numberOfPitches']
df['strikeoutPercentage'] = df['strikeOuts'] / df['battersFaced']
df['walkPercentage'] = df['baseOnBalls'] / df['battersFaced']
df['strikeoutMinusWalkPercentage'] = (df['strikeOuts'] - df['baseOnBalls']) / df['battersFaced']
indices = find_all_season_indices(stats)
df['levels'] = ", ".join(sorted({stats[0]['splits'][i]['sport']['abbreviation'] for i in indices}))
df['levels'] = df['levels'].str.replace(', MLB','').str.replace(', Minors','')
return df
except (KeyError, IndexError, TypeError):
return pd.DataFrame([])
# --- Stats Table ---
def stats_table(df: pd.DataFrame, ax: plt.Axes):
stat_cols = ['gamesPlayed','plateAppearances','ops','iso','homeRuns','stolenBases',
'strikeoutsPerPlateAppearance','walksPerPlateAppearance','whiffRate','swingRate','airPercent']
col_labels = ["GP", "PA", "OPS",'ISO', "HR", "SB", "K%", "BB%", "Whiff%", "Swing%",'Air%']
formats = {
'gamesPlayed': "d",
'plateAppearances': "d",
'ops': ".3f",
'iso': ".3f",
'homeRuns': "d",
'stolenBases': "d",
'strikeoutsPerPlateAppearance': ".1%",
'walksPerPlateAppearance': ".1%",
'whiffRate': ".1%",
'swingRate': ".1%",
'airPercent': ".1%"
}
# Fill missing columns with None if not present
for col in stat_cols:
if col not in df.columns:
df[col] = None
def format_value(value, col_name):
fmt = formats[col_name]
if value is not None:
try:
if fmt.endswith("f"):
return f"{float(value):{fmt}}"
elif fmt.endswith("%"):
return f"{float(value):{fmt}}"
else:
return f"{int(value):{fmt}}"
except Exception:
return "—"
else:
return "—"
df_table = df[stat_cols]
formatted_values = df_table.apply(lambda row: [format_value(row[col], col) for col in stat_cols], axis=1).tolist()
table = ax.table(cellText=formatted_values, colLabels=col_labels, cellLoc='center', bbox=[0,0,1,0.6])
table.auto_set_font_size(False)
table.set_fontsize(10)
for i in range(len(col_labels)):
table.get_celld()[(0,i)].get_text().set_fontweight('bold')
season_text = f"{df['season'][0]} Season Stats\nLevels: {df['levels'][0]}" if df['season'][0] else "\nNo MiLB Data"
ax.text(0.5, 1, season_text, ha='center', va='top', fontsize=12, fontstyle='italic')
def stats_table_pitcher(df, ax):
"""Display pitcher stats as a formatted table."""
formats = {
'gamesPlayed': "d",
'inningsPitched': ".1f",
'era': ".2f",
'whip': ".2f",
'strikeoutPercentage': ".1%",
'walkPercentage': ".1%",
'strikeoutMinusWalkPercentage': ".1%",
'whiffRate': ".1%",
'strikePercentage': ".1%",
}
column_names = {
'gamesPlayed': "GP",
'inningsPitched': "IP",
'era': "ERA",
'whip': "WHIP",
'strikeoutPercentage': "K%",
'walkPercentage': "BB%",
'strikeoutMinusWalkPercentage': "K-BB%",
'whiffRate': "Whiff%",
'strikePercentage': "Strike%",
}
df_table = df[list(column_names.keys())]
def format_value(value, col):
fmt = formats[col]
if value is None:
return "—"
if fmt.endswith("%"):
return f"{float(value):{fmt}}"
if fmt.endswith("f"):
return f"{float(value):{fmt}}"
return f"{int(value):{fmt}}"
table_data = df_table.apply(lambda row: [format_value(row[col], col) for col in df_table.columns], axis=1).tolist()
table = ax.table(
colLabels=list(column_names.values()),
cellText=table_data,
cellLoc='center',
bbox=[0, 0, 1, 0.6]
)
table.auto_set_font_size(False)
table.set_fontsize(10)
table.scale(1, 0.5)
title = f"{df['season'][0]} Season Stats\nLevels: {df['levels'][0]}" if df['season'][0] else "\nNo MiLB Data"
ax.text(0.5, 1, title, ha='center', va='top', fontsize=12, fontstyle='italic')
ax.axis('off')
# --- Player Bio ---
def bio_plot(df: pd.DataFrame, ax: plt.Axes):
ax.text(0.5,1, df.get('fullName',[None])[0], ha='center', va='top', fontsize=20)
ax.text(0.5,0.7, f"{df.get('position',[''])[0]}, Age: {df.get('currentAge',[''])[0]}, B/T: {df.get('batSide',[''])[0]}/{df.get('pitchHand',[''])[0]}, {df.get('height',[''])[0]}/{df.get('weight',[''])[0]}", ha='center', va='top', fontsize=10)
ax.text(0.5,0.52, f"DOB: {df.get('birthDate',[''])[0]}, {df.get('birthCity',[''])[0]}, {df.get('birthCountry',[''])[0]}", ha='center', va='top', fontsize=10)
if df.get('draftsYear',[None])[0]:
ax.text(0.5,0.34, f"Drafted: {df.get('draftsYear',[None])[0]}, Rd. {df.get('draftsRound',[None])[0]}, Pick: {df.get('draftsRoundPickNumber',[None])[0]}", ha='center', va='top', fontsize=10)
ax.text(0.5,0.16, f"School: {df.get('draftsSchool',[''])[0]}", ha='center', va='top', fontsize=10)
# --- Team Logo ---
def plot_logo(df_bio: pd.DataFrame, ax: plt.Axes):
try:
logo_url = f'https://www.mlbstatic.com/team-logos/{df_bio["currentTeamId"][0]}.svg'
response = requests.get(logo_url)
png_data = cairosvg.svg2png(bytestring=response.content, output_width=300, output_height=300)
img = Image.open(BytesIO(png_data))
ax.imshow(img, extent=[0,1,0,1], origin='upper')
except Exception:
ax.axis('off')
ax.axis('off')
# --- Player Headshot ---
def player_headshot(player_id, ax: plt.Axes):
url = f'https://img.mlbstatic.com/mlb-photos/image/upload/c_fill,g_auto/w_640/v1/people/{player_id}/headshot/milb/current.png'
try:
img = Image.open(BytesIO(requests.get(url).content))
ax.imshow(img, extent=[1/6, 5/6, 0, 1], origin='upper')
except Exception:
ax.axis('off')
ax.axis('off')
import requests
import pandas as pd
import matplotlib.pyplot as plt
from matplotlib import gridspec
# import datetime
def fetch_player_data(player_id, season="2025"):
"""Fetch player data from MLB API."""
url = (
f"https://statsapi.mlb.com/api/v1/people/{player_id}"
f"?hydrate=draft,currentTeam,team,stats(type=[yearByYear,yearByYearAdvanced,"
f"careerRegularSeason,careerAdvanced,availableStats](team(league)),season={season},leagueListId=mlb_milb)&site=en&appContext=minorLeague"
)
r = requests.get(url)
data = r.json()
person = data.get("people", [None])[0]
return person
def parse_draft(drafts):
"""Return the latest draft info or None."""
if not drafts:
return None, None, None, None
if isinstance(drafts, list):
draft = drafts[get_index_of_latest_draft(drafts)]
else:
draft = drafts
return (
get_value(draft, "pickRound"),
get_value(draft, "roundPickNumber"),
get_value(draft, "year"),
get_value(draft.get("school", {}), "name")
)
def parse_player_bio(person):
"""Extract key bio fields into a DataFrame."""
if not person:
return pd.DataFrame()
current_team = person.get("currentTeam")
if current_team:
currentTeamName = current_team.get("parentOrgName") or current_team.get("name")
currentTeamId = current_team.get("parentOrgId") or current_team.get("id")
else:
currentTeamName, currentTeamId = None, None
draftsRound, draftsRoundPickNumber, draftsYear, draftsSchool = parse_draft(person.get("drafts"))
df_bio = pd.DataFrame([{
"id": person.get("id"),
"fullName": person.get("fullName"),
"firstName": person.get("firstName"),
"lastName": person.get("lastName"),
"birthDate": person.get("birthDate"),
"currentAge": person.get("currentAge"),
"birthCity": person.get("birthCity"),
"birthProvince": person.get("birthProvince"),
"birthCountry": person.get("birthCountry"),
"height": person.get("height"),
"weight": person.get("weight"),
"currentTeam": currentTeamName,
"currentTeamId": currentTeamId,
"batSide": get_value(person.get('batSide', {}), 'code'),
"pitchHand": get_value(person.get('pitchHand', {}), 'code'),
"draftsRound": draftsRound,
"draftsRoundPickNumber": draftsRoundPickNumber,
"draftsYear": draftsYear,
"draftsSchool": draftsSchool,
"position": get_value(person.get('primaryPosition', {}), 'abbreviation')
}])
return df_bio
def format_scout_df(df_scout_player):
"""Prepare scouting DataFrame for plotting."""
df = df_scout_player.dropna(axis=1, how="all").copy()
grade_columns = [col.split("_")[0] for col in df.columns if "_fv" in col]
# Combine PV/FV grades
for col in grade_columns:
df[col.capitalize()] = df[f"{col}_pv"].astype(int).astype(str) + "/" + df[f"{col}_fv"].astype(int).astype(str)
# Format FV
df["fv"] = pd.to_numeric(df["fv"], errors="coerce")
df["FV"] = df["fv"].apply(lambda x: f"{int(x-1)}+" if x % 5 == 1 else f"{int(x)}")
df["ETA"] = df["eta"].astype(int)
grade_columns = [col.split("_")[0].capitalize() for col in df.columns if "_fv" in col]
df_plot = df[["ETA","FV"] + grade_columns]
return df_plot
def generate_player_card(df_bio, df_stats, df_scout_plot, save_path):
"""Generate a player card using matplotlib."""
fig = plt.figure(figsize=(10,6), dpi=600)
plt.rcParams.update({'figure.autolayout': True})
fig.set_facecolor('white')
gs = gridspec.GridSpec(5, 5,
height_ratios=[1,10,10,10,4],
width_ratios=[1,6,20,6,1])
gs.update(hspace=0.2, wspace=0.2)
ax_header = fig.add_subplot(gs[0,:])
ax_headshot = fig.add_subplot(gs[1,1])
ax_bio = fig.add_subplot(gs[1,2])
ax_logo = fig.add_subplot(gs[1,3])
ax_stats = fig.add_subplot(gs[2,1:4])
ax_scout = fig.add_subplot(gs[3,1:4])
ax_foot = fig.add_subplot(gs[-1,:])
# Draw elements
player_headshot(df_bio['id'][0], ax_headshot)
plot_logo(df_bio=df_bio, ax=ax_logo)
bio_plot(df=df_bio, ax=ax_bio)
stats_table(df=df_stats, ax=ax_stats)
# Scouting table
scout_table = ax_scout.table(
colLabels=df_scout_plot.columns,
cellText=df_scout_plot.values,
cellLoc='center',
bbox=[0,0,1,0.6]
)
scout_table.auto_set_font_size(False)
scout_table.set_fontsize(10)
scout_table.scale(1, 0.5)
new_column_names = [x.title() for x in df_scout_plot.columns[2:]]
new_column_names = ['ETA', 'FV'] + new_column_names
for i, col_name in enumerate(new_column_names):
if col_name == "Decisions":
col_name = "Swing Decisions"
elif col_name == "Power":
col_name = "Game Power"
# Replace spaces with newlines for display, but keep LaTeX bold
if " " in col_name:
base = col_name.replace(" ", "\n")
# Ensure the bold formatting is preserved
scout_table.get_celld()[(0, i)].get_text().set_text(base)
else:
scout_table.get_celld()[(0, i)].get_text().set_text(col_name)
# Set fontweight to bold for header
scout_table.get_celld()[(0, i)].get_text().set_fontweight('bold')
ax_scout.text(s="\nScouting Grades", x=0.5, y=1, ha='center', va='top', fontsize=12, fontstyle='italic')
# Footer
import datetime
last_updated = datetime.datetime.now().strftime("%Y-%m-%d")
ax_foot.text(
s='By: Thomas Nestico\n @TJStats',
x=0.06,
y=0.25,
ha='left',
va='bottom',
fontsize=10
)
ax_foot.text(
s='Data: MLB\nImages: MLB',
x=0.94,
y=0.25,
ha='right',
va='bottom',
fontsize=10
)
ax_foot.text(
s=f'Updated: {last_updated}',
x=0.5,
y=0.25,
ha='center',
va='bottom',
fontsize=8
)
# Turn off axes
for ax in [ax_header, ax_headshot, ax_bio, ax_logo, ax_stats, ax_scout, ax_foot]:
ax.axis('off')
if save_path:
plt.savefig(save_path, bbox_inches='tight')
plt.close(fig)
return fig
def generate_player_card_pitcher(df_bio, df_stats, df_scout_plot, save_path):
"""Generate a player card using matplotlib."""
fig = plt.figure(figsize=(10,6), dpi=600)
plt.rcParams.update({'figure.autolayout': True})
fig.set_facecolor('white')
gs = gridspec.GridSpec(5, 5,
height_ratios=[1,10,10,10,4],
width_ratios=[1,6,20,6,1])
gs.update(hspace=0.2, wspace=0.2)
ax_header = fig.add_subplot(gs[0,:])
ax_headshot = fig.add_subplot(gs[1,1])
ax_bio = fig.add_subplot(gs[1,2])
ax_logo = fig.add_subplot(gs[1,3])
ax_stats = fig.add_subplot(gs[2,1:4])
ax_scout = fig.add_subplot(gs[3,1:4])
ax_foot = fig.add_subplot(gs[-1,:])
# Draw elements
player_headshot(df_bio['id'][0], ax_headshot)
plot_logo(df_bio=df_bio, ax=ax_logo)
bio_plot(df=df_bio, ax=ax_bio)
stats_table_pitcher(df=df_stats, ax=ax_stats)
# Scouting table
scout_table = ax_scout.table(
colLabels=df_scout_plot.columns,
cellText=df_scout_plot.values,
cellLoc='center',
bbox=[0,0,1,0.6]
)
scout_table.auto_set_font_size(False)
scout_table.set_fontsize(10)
scout_table.scale(1, 0.5)
new_column_names = [x.title() for x in df_scout_plot.columns[2:]]
new_column_names = ['ETA', 'FV'] + new_column_names
for i, col_name in enumerate(new_column_names):
# Replace spaces with newlines for display, but keep LaTeX bold
if " " in col_name:
base = col_name.replace(" ", "\n")
# Ensure the bold formatting is preserved
scout_table.get_celld()[(0, i)].get_text().set_text(base)
else:
scout_table.get_celld()[(0, i)].get_text().set_text(col_name)
# Set fontweight to bold for header
scout_table.get_celld()[(0, i)].get_text().set_fontweight('bold')
ax_scout.text(s="\nScouting Grades", x=0.5, y=1, ha='center', va='top', fontsize=12, fontstyle='italic')
# Footer
import datetime
last_updated = datetime.datetime.now().strftime("%Y-%m-%d")
ax_foot.text(
s='By: Thomas Nestico\n @TJStats',
x=0.06,
y=0.25,
ha='left',
va='bottom',
fontsize=10
)
ax_foot.text(
s='Data: MLB\nImages: MLB',
x=0.94,
y=0.25,
ha='right',
va='bottom',
fontsize=10
)
ax_foot.text(
s=f'Updated: {last_updated}',
x=0.5,
y=0.25,
ha='center',
va='bottom',
fontsize=8
)
# Turn off axes
for ax in [ax_header, ax_headshot, ax_bio, ax_logo, ax_stats, ax_scout, ax_foot]:
ax.axis('off')
if save_path:
plt.savefig(save_path, bbox_inches='tight')
plt.close(fig)
return fig
# Example usage for multiple players
# Generate sample prospect data
def generate_prospect_data():
df_scout[['player_id','name','position','team','age','height','weight','bat_side','throw_side']]
prospects = []
for i in range(len(df_scout)):
name = df_scout['name'].iloc[i]
position = df_scout['position'].iloc[i]
team = df_scout['team'].iloc[i]
age = df_scout['age'].iloc[i]
height = df_scout['height'].iloc[i]
weight = df_scout['weight'].iloc[i]
bat_side = df_scout['bat_side'].iloc[i]
throw_side = df_scout['throw_side'].iloc[i]
prospects.append({
"Rank": i + 1,
"Name": name,
"Team": team,
"Age": age,
"Position": position,
"Height": height,
"Weight": weight,
"Bat Side": bat_side,
"Throw Side": throw_side,
})
return pd.DataFrame(prospects)
default_headshot = "https://img.mlbstatic.com/mlb-photos/image/upload/d_people:generic:headshot:67:current.png/w_213,q_auto:best/v1/people/000000/headshot/67/current"
def get_valid_headshot(url: str) -> str:
"""Return url if it exists (status 200), else return default_logo."""
try:
r = requests.head(url, allow_redirects=True, timeout=3)
if r.status_code == 200:
return url
except requests.RequestException:
pass
return default_headshot
app_ui = ui.page_fluid(
ui.card(
ui.h1("TJStats Top 100 Prospects"),
ui.row(
ui.column(3,
ui.h6(
"By: Thomas Nestico (",
ui.a(
"@TJStats",
href="https://twitter.com/TJStats", # change to your actual URL
target="_blank", # open in new tab
style="text-decoration: none; color: #007bff;" # optional styling
),
")")),
ui.column(2,
ui.h6("Data: MLB")),
ui.column(4,
ui.h6(
ui.tags.a(
"Support me on Patreon for more baseball content",
href="https://www.patreon.com/TJ_Stats",
target="_blank"
),
),
)
),
# ui.card_header("TJStats Top 100 Prospects"),
ui.output_ui("prospects_table_with_images"),
),
)
def server(input, output, session):
# Load prospect data
prospects_df = df_scout.copy()
# Reactive value to store selected prospect
selected_player_id = reactive.value(None)
@render.ui
def prospects_table_with_images():
df = prospects_df.copy()
# Create HTML table with images
table_rows = []
# Header row with explicit column widths
header = """
<tr style="background-color: #f8f9fa; font-weight: bold; position: sticky; top: 0; z-index: 10;">
<th style="width: 60px; padding: 12px; text-align: center; border: 1px solid #dee2e6;">Rank</th>
<th style="width: 50px; padding: 12px; text-align: center; border: 1px solid #dee2e6;"></th>
<th style="width: 200px; padding: 12px; text-align: left; border: 1px solid #dee2e6;">Name</th>
<th style="width: 70px; padding: 12px; text-align: center; border: 1px solid #dee2e6;">Team</th>
<th style="width: 100px; padding: 12px; text-align: center; border: 1px solid #dee2e6;">Position</th>
<th style="width: 80px; padding: 12px; text-align: center; border: 1px solid #dee2e6;">FV</th>
<th style="width: 70px; padding: 12px; text-align: center; border: 1px solid #dee2e6;">ETA</th>
<th style="width: 50px; padding: 12px; text-align: center; border: 1px solid #dee2e6;">Age</th>
<th style="width: 70px; padding: 12px; text-align: center; border: 1px solid #dee2e6;">Height</th>
<th style="width: 70px; padding: 12px; text-align: center; border: 1px solid #dee2e6;">Weight</th>
<th style="width: 60px; padding: 12px; text-align: center; border: 1px solid #dee2e6;">B/T</th>
</tr>
"""
table_rows.append(header)
# Data rows
for idx, row in df.iterrows():
# Headshot
headshot_src = get_valid_headshot(row.get("headshot"))
headshot_html = f"""
<a href="{headshot_src}" target="_blank">
<img src="{headshot_src}" class="headshot" alt="{row['name']} headshot" style="border: 2px solid #ddd;">
</a>
"""
# Logo
logo_src = row.get("logo")
logo_html = ""
if logo_src:
logo_html = f"""
<a href="{logo_src}" target="_blank">
<img src="{logo_src}" class="logo" alt="{row.get('team', 'Team')} logo">
</a>
"""
# FV background
fv = int(row['fv']) if pd.notna(row['fv']) else 0
if fv >= 65:
fv_bg_color = "#DC267F"
elif fv >= 60:
fv_bg_color = "#E76BA8"
elif fv >= 55:
fv_bg_color = "#EF9BC4"
elif fv >= 50:
fv_bg_color = "#F7CEE2"
else:
fv_bg_color = "#ffffff"
# Position background
position_colors = {
"C": "#785EF0", "1B": "#DC267F", "2B": "#FE6100", "3B": "#41AC99",
"SS": "#FFB000", "LF": "#D5E472", "CF": "#222DA1", "RF": "#39540F",
"OF": "#222DA1", "DH": "#3F518A", "P": "#648FFF","IF":"#a12222"
}
pos_bg_color = position_colors.get(row['position'], "#e9ecef")
display_position = row['position']
if display_position == "P" and pd.notna(row.get('throw_side')):
display_position = f"{row['throw_side']}H{display_position}"
# Row HTML
row_html = f"""
<tr onclick="Shiny.setInputValue('table_row_click', '{row['player_id']}', {{priority: 'event'}});"
style="cursor: pointer; border: 1px solid #dee2e6;"
onmouseover="this.style.backgroundColor='#f5f5f5';"
onmouseout="this.style.backgroundColor='white';">
<td style="width: 60px; padding: 8px; text-align: center; border: 1px solid #dee2e6; font-weight: 500;">{row['rank']}</td>
<td style="width: 60px; padding: 8px; text-align: center; border: 1px solid #dee2e6;">{headshot_html}</td>
<td style="width: 200px; padding: 8px; border: 1px solid #dee2e6; font-weight: 500;">{row['name']}</td>
<td style="width: 80px; padding: 8px; text-align: center; border: 1px solid #dee2e6;">{logo_html}</td>
<td style="width: 100px; padding: 8px; text-align: center; border: 1px solid #dee2e6;">
<span style="display: inline-block; min-width: 50px; background-color: {pos_bg_color};
color: white; padding: clamp(2px, 0.5vw, 6px) clamp(4px, 1vw, 10px);
border-radius: 12px; font-size: clamp(12px, 2vw, 24px); font-weight: bold;">
{display_position}
</span>
</td>
<td style="width: 80px; padding: 8px; text-align: center; border: 1px solid #dee2e6;">
<span style="background-color: {fv_bg_color}; min-width: 40px;
color: black; padding: clamp(2px, 0.5vw, 6px) clamp(4px, 1vw, 10px);
border-radius: 12px; font-size: clamp(12px, 2vw, 24px); font-weight: bold;">
{int(row['fv'])}
</span>
</td>
<td style="width: 80px; padding: 8px; text-align: center; border: 1px solid #dee2e6;">{int(row['eta'])}</td>
<td style="width: 60px; padding: 8px; text-align: center; border: 1px solid #dee2e6;">{row['age']}</td>
<td style="width: 80px; padding: 8px; text-align: center; border: 1px solid #dee2e6;">{row['height']}</td>
<td style="width: 80px; padding: 8px; text-align: center; border: 1px solid #dee2e6;">{int(row['weight'])}</td>
<td style="width: 70px; padding: 8px; text-align: center; border: 1px solid #dee2e6;">{row['bat_side']}/{row['throw_side']}</td>
</tr>
"""
table_rows.append(row_html)
# Combine table and CSS
table_html = f"""
<div style="max-height: 75vh; overflow-y: auto; overflow-x: hidden; border: 1px solid #dee2e6; border-radius: 8px;">
<table style="width: 100%; border-collapse: collapse; table-layout: auto; font-size: clamp(12px, 1.8vw, 28px);">
{''.join(table_rows)}
</table>
</div>
<style>
/* Headshots circular */
table img.headshot {{
border-radius: 50% !important;
object-fit: cover !important;
width: 50px;
height: 50px;
}}
/* Logos rectangular */
table img.logo {{
border-radius: 0 !important;
object-fit: contain;
width: 50px;
height: 50px;
}}
/* Tablets / small laptops */
@media (max-width: 768px) {{
table {{
font-size: clamp(10px, 2.5vw, 20px);
}}
table img.headshot {{
width: 40px;
height: 40px;
}}
table img.logo {{
width: 40px;
height: 40px;
}}
th, td {{
padding: 6px;
}}
td span {{
font-size: clamp(10px, 2.2vw, 18px) !important;
padding: 2px 6px !important;
min-width: 35px !important;
border-radius: 10px !important;
}}
td:nth-child(9), th:nth-child(9), /* Height */
td:nth-child(10), th:nth-child(10) /* Weight */ {{
display: none;
}}
}}
/* Phones */
@media (max-width: 480px) {{
table {{
font-size: clamp(9px, 3vw, 16px);
table-layout: fixed; /* Prevent horizontal scrolling */
width: 100% !important;
}}
th, td {{
padding: 4px;
word-break: break-word; /* Wrap long text */
}}
th:first-child, td:first-child, /* Rank */
th:nth-child(4), td:nth-child(4),
th:nth-child(5), td:nth-child(5),
th:nth-child(6), td:nth-child(6){{
white-space: nowrap;
}}
/* Force column 3 (Name) to wrap only at spaces, not mid-word */
td:nth-child(3), th:nth-child(3) {{
white-space: normal !important;
word-break: normal !important;
overflow-wrap: break-word !important;
}}
th:nth-child(1), td:nth-child(1) {{
width: 50px !important;
min-width: 50px !important;
max-width: 50px !important;
}}
th:nth-child(5), td:nth-child(5) {{
width: 80px !important;
min-width: 80px !important;
max-width: 80px !important;
}}
table img.headshot {{
width: 30px;
height: 30px;
}}
table img.logo {{
width: 30px;
height: 30px;
}}
td span {{
font-size: clamp(9px, 2.5vw, 14px) !important;
padding: 2px 4px !important;
border-radius: 8px !important;
}}
/* Hide less critical columns */
td:nth-child(2), th:nth-child(2), /* Age */
td:nth-child(7), th:nth-child(7), /* Age */
td:nth-child(8), th:nth-child(8), /* Age */
td:nth-child(9), th:nth-child(9), /* Height */
td:nth-child(10), th:nth-child(10), /* Weight */
td:nth-child(11), th:nth-child(11) /* B/T */ {{
display: none;
}}
}}
</style>
"""
return ui.HTML(table_html)
# Handle table row clicks - now storing player_id
@reactive.effect
@reactive.event(input.table_row_click)
def _():
if input.table_row_click():
selected_player_id.set(int(input.table_row_click()))
# Handle table row clicks and show modal - this is the key modal functionality
@reactive.effect
@reactive.event(input.table_row_click)
def handle_selection():
if input.table_row_click():
player_id = int(input.table_row_click())
selected_player_id.set(player_id)
# Get player name for modal title
player_data = prospects_df[prospects_df['player_id'] == player_id].iloc[0]
player_name = player_data['name']
static_text = player_data['notes']
good_text = player_data['the_good']
bad_text = player_data['the_bad']
# Show modal with chart
ui.modal_show(
ui.modal(
ui.h3(f"{player_data['rank']:.0f}) {player_name}", style="font-weight: bold;"),
ui.output_ui("modal_prospect_chart"),
ui.h4('The Good'),
ui.p(good_text.strip() if 'good_text' in locals() else "No notes available."),
ui.h4('The Bad'),
ui.p(bad_text.strip() if 'bad_text' in locals() else "No notes available."),
# title=f"{player_data['rank']:.0f}) {player_name}",
size="l",
easy_close=True,
footer=ui.modal_button("Close", class_="btn-secondary")
)
)
# Create a reactive calculation that handles the loading state
@reactive.calc
def chart_content():
if not selected_player_id():
return None
try:
print("Generating modal chart for player ID:", selected_player_id())
player_id = int(selected_player_id())
person = fetch_player_data(player_id)
df_bio = parse_player_bio(person)
stats = get_value(person, "stats")
df_stats = get_latest_stats(stats)
df_scout_player = df_scout[df_scout['player_id'] == df_bio['id'][0]].reset_index(drop=True)
print("Scouting data found:", df_scout_player)
df_scout_plot = format_scout_df(df_scout_player)
if df_bio['position'][0] in ['P','SP','RP']:
df_stats = get_pitcher_stats(stats)
card = generate_player_card_pitcher(df_bio, df_stats, df_scout_plot, save_path=False)
else:
df_stats = get_latest_stats(stats)
card = generate_player_card(df_bio, df_stats, df_scout_plot, save_path=False)
# Convert plot to base64 string
img_buffer = io.BytesIO()
card.savefig(img_buffer, format='png', dpi=150, bbox_inches='tight',
facecolor='white', edgecolor='none')
img_buffer.seek(0)
img_str = base64.b64encode(img_buffer.getvalue()).decode()
plt.close(card) # Important: close the figure to free memory
return img_str
except Exception as e:
print(f"Error generating chart: {e}")
return "error"
@render.ui
def modal_prospect_chart():
if not selected_player_id():
return ui.div("No prospect selected")
# Trigger dependency on selected player
player_id = selected_player_id()
try:
# Get the chart content (this will be None initially while calculating)
chart_data = chart_content()
if chart_data is None:
# Show white placeholder with spinner while loading
return ui.div(
ui.div(
# White background placeholder
ui.div(
style="width: 100%; max-width: 800px; height: 600px; background-color: white; border-radius: 8px; border: 1px solid #e0e0e0; display: flex; align-items: center; justify-content: center;"
),
# Loading spinner overlay
ui.div(
ui.HTML('<div class="spinner-border text-primary" role="status" style="width: 3rem; height: 3rem;"></div>'),
ui.p("Generating player card...", style="margin-top: 1rem; color: #666;"),
style="position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); display: flex; flex-direction: column; align-items: center;"
),
style="position: relative; text-align: center; padding: 10px;"
),
style="text-align: center;"
)
elif chart_data == "error":
return ui.div(
ui.p("Error generating modal chart"),
style="color: red; text-align: center; padding: 20px;"
)
else:
# Show actual chart
return ui.div(
ui.img(src=f"data:image/png;base64,{chart_data}",
style="width: 100%; max-width: 800px; height: auto; border-radius: 8px;"),
style="text-align: center; padding: 10px;"
)
except Exception:
# Show loading state if there's any issue
return ui.div(
ui.div(
ui.div(
style="width: 100%; max-width: 800px; height: 600px; background-color: white; border-radius: 8px; border: 1px solid #e0e0e0;"
),
ui.div(
ui.HTML('<div class="spinner-border text-primary" role="status" style="width: 3rem; height: 3rem;"></div>'),
ui.p("Loading...", style="margin-top: 1rem; color: #666;"),
style="position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); display: flex; flex-direction: column; align-items: center;"
),
style="position: relative; text-align: center; padding: 10px;"
)
)
app = App(app_ui, server)