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 = """