TJStatsApps/mlb_spring_statcast_cards
0
1import polars as pl2import api_scraper3import pandas as pd4scrape = api_scraper.MLB_Scrape()5 6# import df_update7# update = df_update.df_update()8from matplotlib.colors import LinearSegmentedColormap, Normalize9import numpy as np10import requests11from io import BytesIO12from PIL import Image13from matplotlib.gridspec import GridSpec14 15import matplotlib.pyplot as plt16import matplotlib.patches as patches17import PIL18 19level_dict = {'1':'MLB',20 '11':'AAA',21 '14':'A',}22 23 24 25def player_bio(pitcher_id: str, ax: plt.Axes, sport_id: int, year_input: int):26 """27 Display the player's bio information on the given axis.28 Parameters29 ----------30 pitcher_id : str31 The player's ID.32 ax : plt.Axes33 The axis to display the bio information on.34 sport_id : int35 The sport ID (1 for MLB, other for minor leagues).36 year_input : int37 The season year.38 """39 # Construct the URL to fetch player data40 url = f"https://statsapi.mlb.com/api/v1/people?personIds={pitcher_id}&hydrate=currentTeam"41 42 # Send a GET request to the URL and parse the JSON response43 data = requests.get(url).json()44 45 # Extract player information from the JSON data46 player_name = data['people'][0]['fullName']47 position = data['people'][0]['primaryPosition']['abbreviation']48 bat_side = data['people'][0]['batSide']['code']49 pitcher_hand = data['people'][0]['pitchHand']['code']50 age = data['people'][0]['currentAge']51 height = data['people'][0]['height']52 weight = data['people'][0]['weight']53 54 # Display the player's name, handedness, age, height, and weight on the axis55 ax.text(0.5, 1, f'{player_name}', va='top', ha='center', fontsize=30)56 ax.text(0.5, 0.65, f'{position}, B/T: {bat_side}/{pitcher_hand}, Age: {age}, {height}/{weight}', va='top', ha='center', fontsize=20)57 if position == 'P':58 ax.text(0.5, 0.38, f'Season Pitching Percentiles', va='top', ha='center', fontsize=16)59 else:60 ax.text(0.5, 0.41, f'Season Batting Percentiles', va='top', ha='center', fontsize=16)61 62 # Make API call to retrieve sports information63 response = requests.get(url='https://statsapi.mlb.com/api/v1/sports').json()64 65 # Convert the JSON response into a Polars DataFrame66 df_sport_id = pl.DataFrame(response['sports']) 67 abb = df_sport_id.filter(pl.col('id') == sport_id)['abbreviation'][0]68 69 # Display the season and sport abbreviation70 ax.text(0.5, 0.20, f'{year_input} {abb} Spring Training', va='top', ha='center', fontsize=14, fontstyle='italic')71 72 # Turn off the axis73 ax.axis('off')74 75 76df_teams = scrape.get_teams()77team_dict = dict(zip(df_teams['team_id'],df_teams['parent_org_abbreviation']))78 79 80# List of MLB teams and their corresponding ESPN logo URLs81mlb_teams = [82 {"team": "AZ", "logo_url": "https://a.espncdn.com/combiner/i?img=/i/teamlogos/mlb/500/scoreboard/ari.png&h=500&w=500"},83 {"team": "ATH", "logo_url": "https://a.espncdn.com/combiner/i?img=/i/teamlogos/mlb/500/scoreboard/oak.png&h=500&w=500"},84 {"team": "ATL", "logo_url": "https://a.espncdn.com/combiner/i?img=/i/teamlogos/mlb/500/scoreboard/atl.png&h=500&w=500"},85 {"team": "BAL", "logo_url": "https://a.espncdn.com/combiner/i?img=/i/teamlogos/mlb/500/scoreboard/bal.png&h=500&w=500"},86 {"team": "BOS", "logo_url": "https://a.espncdn.com/combiner/i?img=/i/teamlogos/mlb/500/scoreboard/bos.png&h=500&w=500"},87 {"team": "CHC", "logo_url": "https://a.espncdn.com/combiner/i?img=/i/teamlogos/mlb/500/scoreboard/chc.png&h=500&w=500"},88 {"team": "CWS", "logo_url": "https://a.espncdn.com/combiner/i?img=/i/teamlogos/mlb/500/scoreboard/chw.png&h=500&w=500"},89 {"team": "CIN", "logo_url": "https://a.espncdn.com/combiner/i?img=/i/teamlogos/mlb/500/scoreboard/cin.png&h=500&w=500"},90 {"team": "CLE", "logo_url": "https://a.espncdn.com/combiner/i?img=/i/teamlogos/mlb/500/scoreboard/cle.png&h=500&w=500"},91 {"team": "COL", "logo_url": "https://a.espncdn.com/combiner/i?img=/i/teamlogos/mlb/500/scoreboard/col.png&h=500&w=500"},92 {"team": "DET", "logo_url": "https://a.espncdn.com/combiner/i?img=/i/teamlogos/mlb/500/scoreboard/det.png&h=500&w=500"},93 {"team": "HOU", "logo_url": "https://a.espncdn.com/combiner/i?img=/i/teamlogos/mlb/500/scoreboard/hou.png&h=500&w=500"},94 {"team": "KC", "logo_url": "https://a.espncdn.com/combiner/i?img=/i/teamlogos/mlb/500/scoreboard/kc.png&h=500&w=500"},95 {"team": "LAA", "logo_url": "https://a.espncdn.com/combiner/i?img=/i/teamlogos/mlb/500/scoreboard/laa.png&h=500&w=500"},96 {"team": "LAD", "logo_url": "https://a.espncdn.com/combiner/i?img=/i/teamlogos/mlb/500/scoreboard/lad.png&h=500&w=500"},97 {"team": "MIA", "logo_url": "https://a.espncdn.com/combiner/i?img=/i/teamlogos/mlb/500/scoreboard/mia.png&h=500&w=500"},98 {"team": "MIL", "logo_url": "https://a.espncdn.com/combiner/i?img=/i/teamlogos/mlb/500/scoreboard/mil.png&h=500&w=500"},99 {"team": "MIN", "logo_url": "https://a.espncdn.com/combiner/i?img=/i/teamlogos/mlb/500/scoreboard/min.png&h=500&w=500"},100 {"team": "NYM", "logo_url": "https://a.espncdn.com/combiner/i?img=/i/teamlogos/mlb/500/scoreboard/nym.png&h=500&w=500"},101 {"team": "NYY", "logo_url": "https://a.espncdn.com/combiner/i?img=/i/teamlogos/mlb/500/scoreboard/nyy.png&h=500&w=500"},102 {"team": "PHI", "logo_url": "https://a.espncdn.com/combiner/i?img=/i/teamlogos/mlb/500/scoreboard/phi.png&h=500&w=500"},103 {"team": "PIT", "logo_url": "https://a.espncdn.com/combiner/i?img=/i/teamlogos/mlb/500/scoreboard/pit.png&h=500&w=500"},104 {"team": "SD", "logo_url": "https://a.espncdn.com/combiner/i?img=/i/teamlogos/mlb/500/scoreboard/sd.png&h=500&w=500"},105 {"team": "SF", "logo_url": "https://a.espncdn.com/combiner/i?img=/i/teamlogos/mlb/500/scoreboard/sf.png&h=500&w=500"},106 {"team": "SEA", "logo_url": "https://a.espncdn.com/combiner/i?img=/i/teamlogos/mlb/500/scoreboard/sea.png&h=500&w=500"},107 {"team": "STL", "logo_url": "https://a.espncdn.com/combiner/i?img=/i/teamlogos/mlb/500/scoreboard/stl.png&h=500&w=500"},108 {"team": "TB", "logo_url": "https://a.espncdn.com/combiner/i?img=/i/teamlogos/mlb/500/scoreboard/tb.png&h=500&w=500"},109 {"team": "TEX", "logo_url": "https://a.espncdn.com/combiner/i?img=/i/teamlogos/mlb/500/scoreboard/tex.png&h=500&w=500"},110 {"team": "TOR", "logo_url": "https://a.espncdn.com/combiner/i?img=/i/teamlogos/mlb/500/scoreboard/tor.png&h=500&w=500"},111 {"team": "WSH", "logo_url": "https://a.espncdn.com/combiner/i?img=/i/teamlogos/mlb/500/scoreboard/wsh.png&h=500&w=500"},112 {"team": "ZZZ", "logo_url": "https://a.espncdn.com/combiner/i?img=/i/teamlogos/leagues/500/mlb.png&w=500&h=500"} 113]114 115df_image = pd.DataFrame(mlb_teams)116image_dict = df_image.set_index('team')['logo_url'].to_dict()117image_dict_flip = df_image.set_index('logo_url')['team'].to_dict()118 119# level_dict = {'1':'MLB',120# '11':'AAA'}121 122level_dict = {'1':'MLB',123 # '11':'AAA',124 # '14':'A (FSL)'125 }126 127 128level_dict_file = {'1':'mlb',129 # '11':'aaa',130 # '14':'a'131 }132 133 134 135year_list = [2025]136 137 138from shiny import App, reactive, ui, render139from shiny.ui import h2, tags140 141# Define the UI layout for the app142app_ui = ui.page_fluid(143 144 145 ui.tags.div(146 {"style": "width:90%;margin: 0 auto;max-width: 1600px;"},147 ui.tags.style(148 """149 h4 {150 margin-top: 1em;font-size:35px;151 }152 h2{153 font-size:25px;154 }155 """156 ),157 158 ui.tags.h4("TJStats"),159 ui.tags.i("Baseball Analytics and Visualizations"),160 ui.markdown("""<a href='https://x.com/TJStats'>Follow me on Twitter</a><sup>1</sup>"""),161 ui.markdown("""<a href='https://www.patreon.com/tj_stats'>Support me on Patreon for Access to 2024 Apps</a><sup>1</sup>"""),162 163 ui.markdown("### MiLB Statcast Batting Summaries"),164 ui.markdown("""This Shiny App allows you to generate Baseball Savant-style percentile bars for MiLB players in the 2024 Season. 165 Currently, MiLB Statcast is only available for AAA and A (Florida State League) levels."""),166 167 ui.layout_sidebar(168 ui.panel_sidebar(169 # Row for selecting season and level170 ui.row(171 ui.column(6, ui.input_select('year_input', 'Select Season', year_list, selected=2024)),172 ui.column(6, ui.input_select('level_input', 'Select Level', level_dict)),173 ),174 # Row for the action button to get player list175 ui.row(ui.input_action_button("player_button", "Get Player List", class_="btn-primary")),176 # Row for selecting the player177 ui.row(ui.column(12, ui.output_ui('player_select_ui', 'Select Player'))),178 179 ui.row(180 ui.column(6, ui.input_switch("switch", "Custom Team?", False)),181 ui.column(6, ui.input_select('logo_select', 'Select Custom Logo', image_dict_flip, multiple=False))182 ),183 184 # Row for the action button to generate plot185 ui.row(ui.input_action_button("generate_plot", "Generate Plot", class_="btn-primary")),186 width=3,187 ),188 189 ui.panel_main(190 ui.navset_tab(191 # Tab for game summary plot192 ui.nav("Batter Summary",193 ui.output_text("status_batter"),194 ui.output_plot('batter_plot', width='1200px', height='1200px')195 ),196 ui.nav("Pitcher Summary",197 ui.output_text("status_pitcher"),198 ui.output_plot('pitcher_plot', width='1200px', height='1200px')199 )200 ,id="tabset"201 )202 )203 )204)205)206 207def server(input, output, session):208 @render.ui209 @reactive.event(input.player_button,input.tabset, ignore_none=False)210 def player_select_ui():211 if input.tabset() == "Batter Summary":212 #Get the list of pitchers for the selected level and season213 # df_pitcher_info = scrape.get_players(sport_id=int(input.level_input()), season=int(input.year_input())).filter(214 # ~pl.col("position").is_in(['P'])).sort("name")215 216 217 218 # Create a dictionary of pitcher IDs and names219 # batter_dict_pos = dict(zip(df_pitcher_info['player_id'], df_pitcher_info['position']))220 221 year = int(input.year_input())222 sport_id = int(input.level_input())223 batter_summary = pl.read_parquet(f"hf://datasets/TJStatsApps/mlb_data/summary/batter_summary_{level_dict_file[str(sport_id)]}_{year}_spring.parquet").sort('batter_name',descending=False)224 batter_summary = batter_summary.filter(pl.col('pa')>0)225 # Map elements in Polars DataFrame from a dictionary226 # batter_summary = batter_summary.with_columns(227 # pl.col("batter_id").map_elements(lambda x: batter_dict_pos.get(x, x)).alias("position")228 # )229 230 231 # batter_dict_pos = dict(zip(batter_summary['batter_id'], batter_summary['batter_name']))232 # Create a dictionary of pitcher IDs and names233 batter_dict = dict(zip(batter_summary['batter_id'], batter_summary['batter_name'] + ' - ' + batter_summary['batter_id']))234 235 # Return a select input for choosing a pitcher236 return ui.input_select("batter_id", "Select Batter", batter_dict, selectize=True)237 238 if input.tabset() == "Pitcher Summary":239 #Get the list of pitchers for the selected level and season240 df_pitcher_info = scrape.get_players(sport_id=int(input.level_input()), season=int(input.year_input())).filter(241 pl.col("position").is_in(['P','TWP'])).sort("name")242 243 244 245 # Create a dictionary of pitcher IDs and names246 batter_dict_pos = dict(zip(df_pitcher_info['player_id'], df_pitcher_info['position']))247 248 year = int(input.year_input())249 sport_id = int(input.level_input())250 batter_summary = pl.read_parquet(f"hf://datasets/TJStatsApps/mlb_data/summary/pitcher_summary_{level_dict_file[str(sport_id)]}_{year}_spring.parquet").sort('pitcher_name',descending=False)251 # Map elements in Polars DataFrame from a dictionary252 batter_summary = batter_summary.with_columns(253 pl.col("pitcher_id").map_elements(lambda x: batter_dict_pos.get(x, x)).alias("position")254 )255 256 257 batter_dict_pos = dict(zip(batter_summary['pitcher_id'], batter_summary['pitcher_name']))258 # Create a dictionary of pitcher IDs and names259 batter_dict = dict(zip(batter_summary['pitcher_id'], batter_summary['pitcher_name'] + ' - ' + batter_summary['position']))260 261 # Return a select input for choosing a pitcher262 return ui.input_select("pitcher_id", "Select Batter", batter_dict, selectize=True) 263 264 265 266 @output267 @render.plot268 @reactive.event(input.generate_plot, ignore_none=False) 269 def batter_plot(): 270 271 272 merged_dict = {273 "woba_percent": { "format": '.3f', "percentile_flip": False, "stat_title": "wOBA" },274 "xwoba_percent": { "format": '.3f', "percentile_flip": False, "stat_title": "xwOBA" },275 "launch_speed": { "format": '.1f', "percentile_flip": False, "stat_title": "Average EV"},276 "launch_speed_90": { "format": '.1f', "percentile_flip": False, "stat_title": "90th% EV"},277 "max_launch_speed": { "format": '.1f', "percentile_flip": False, "stat_title": "Max EV"},278 "barrel_percent": { "format": '.1%', "percentile_flip": False, "stat_title": "Barrel%" },279 "hard_hit_percent": { "format": '.1%', "percentile_flip": False, "stat_title": "Hard-Hit%" },280 "sweet_spot_percent": { "format": '.1%', "percentile_flip": False, "stat_title": "LA Sweet-Spot%" },281 "zone_percent": { "format": '.1%', "percentile_flip": False, "stat_title": "Zone%" }, 282 "zone_swing_percent": { "format": '.1%', "percentile_flip": False, "stat_title": "Z-Swing%" },283 "chase_percent": { "format": '.1%', "percentile_flip": True, "stat_title": "O-Swing%" },284 "whiff_rate": { "format": '.1%', "percentile_flip": True, "stat_title": "Whiff%" },285 "k_percent": { "format": '.1%', "percentile_flip": True, "stat_title": "K%" },286 "bb_percent": { "format": '.1%', "percentile_flip": False, "stat_title": "BB%" },287 "pull_percent": { "format": '.1%', "percentile_flip": False, "stat_title": "Pull%" },288 "pulled_fly_ball_percent": { "format": '.1%', "percentile_flip": False, "stat_title": "Pull FB%" },289 }290 # Show progress/loading notification291 with ui.Progress(min=0, max=1) as p:292 293 def draw_baseball_savant_percentiles(new_player_metrics, new_player_percentiles, colors=None,294 sport_id=None,295 year_input=None):296 """297 Draw Baseball Savant-style percentile bars with proper alignment and scaling.298 299 :param new_player_metrics: DataFrame containing new player metrics.300 :param new_player_percentiles: DataFrame containing new player percentiles.301 :param colors: List of colors for bars (optional, red/blue default).302 """303 # Extract player information304 batter_id = new_player_metrics['batter_id'][0]305 player_name = batter_name_id[batter_id]306 stats = [merged_dict[x]['stat_title'] for x in merged_dict.keys()]307 308 # Calculate percentiles and values309 percentiles = [int((1 - x) * 100) if merged_dict[stat]["percentile_flip"] else int(x * 100) for x, stat in zip(new_player_percentiles.select(merged_dict.keys()).to_numpy()[0], merged_dict.keys())]310 percentiles = np.clip(percentiles, 1, 100)311 values = [str(f'{x:{merged_dict[stat]["format"]}}').strip('%') for x, stat in zip(new_player_metrics.select(merged_dict.keys()).to_numpy()[0], merged_dict.keys())]312 313 314 315 # Create a custom colormap316 color_list = ['#3661AD', '#B4CFD1', '#D82129']317 cmap = LinearSegmentedColormap.from_list("custom_cmap", color_list)318 norm = Normalize(vmin=0.1, vmax=0.9)319 norm_percentiles = norm(percentiles / 100)320 colors = [cmap(p) for p in norm_percentiles]321 322 # Figure setup323 num_stats = len(stats)324 bar_height = 4.5325 spacing = 1326 fig_height = (bar_height + spacing) * num_stats327 fig = plt.figure(figsize=(12, 12))328 gs = GridSpec(6, 5, height_ratios=[0.1, 1.5, 0.9, 0.9, 7.6, 0.1], width_ratios=[0.2, 1.5, 7, 1.5, 0.2])329 330 # Define subplots331 ax_title = fig.add_subplot(gs[1, 2])332 ax_table = fig.add_subplot(gs[2, :])333 ax_fv_table = fig.add_subplot(gs[3, :])334 ax_fv_table.axis('off')335 ax = fig.add_subplot(gs[4, :])336 ax_logo = fig.add_subplot(gs[1, 3])337 338 ax.set_xlim(-1, 99)339 ax.set_ylim(-1, 99)340 ax.set_aspect("equal")341 ax.axis("off")342 343 # Draw each bar344 for i, (stat, percentile, value, color) in enumerate(zip(stats, percentiles, values, colors)):345 y = fig_height - (i + 1) * (bar_height + spacing)346 ax.add_patch(patches.Rectangle((0, y + bar_height / 4), 100, bar_height / 2, color="#C7DCDC", lw=0))347 ax.add_patch(patches.Rectangle((0, y), percentile, bar_height, color=color, lw=0))348 circle_y = y + bar_height - bar_height / 2349 circle = plt.Circle((percentile, circle_y), bar_height / 2, color=color, ec='white', lw=1.5, zorder=10)350 ax.add_patch(circle)351 fs = 14352 ax.text(percentile, circle_y, f"{percentile}", ha="center", va="center", fontsize=10, color='white', zorder=10, fontweight='bold')353 ax.text(-5, y + bar_height / 2, stat, ha="right", va="center", fontsize=fs)354 ax.text(115, y + bar_height / 2, str(value), ha="right", va="center", fontsize=fs, zorder=5)355 if i < len(stats) and i > 0:356 ax.hlines(y=y + bar_height + spacing / 2, color='#399098', linestyle=(0, (5, 5)), linewidth=1, xmin=-33, xmax=0)357 ax.hlines(y=y + bar_height + spacing / 2, color='#399098', linestyle=(0, (5, 5)), linewidth=1, xmin=100, xmax=115)358 359 # Draw vertical lines for 10%, 50%, and 90% with labels360 for x, label, align, color in zip([10, 50, 90], ["Poor", "Average", "Great"], ['center', 'center', 'center'], color_list):361 ax.axvline(x=x, ymin=0, ymax=1, color='#FFF', linestyle='-', lw=1, zorder=1, alpha=0.5)362 ax.text(x, fig_height + 4, label, ha=align, va='center', fontsize=12, fontweight='bold', color=color)363 triangle = patches.RegularPolygon((x, fig_height + 1), 3, radius=1, orientation=0, color=color, zorder=2)364 ax.add_patch(triangle)365 366 # # Title367 # ax_title.set_ylim(0, 1)368 # ax_title.text(0.5, 0.5, f"{player_name} - {player_position_dict[batter_id]}\nPercentile Rankings - 2024 AAA", ha="center", va="center", fontsize=24)369 # ax_title.axis("off")370 player_bio(batter_id, ax=ax_title, sport_id=sport_id, year_input=year_input)371 372 373 # Get team logo URL374 375 # Add team logo376 #response = requests.get(logo_url)377 if input.switch():378 response = requests.get(input.logo_select())379 else:380 logo_url = image_dict[team_dict[player_team_dict[batter_id]]]381 response = requests.get(logo_url)382 383 img = Image.open(BytesIO(response.content))384 385 ax_logo.set_xlim(0, 1.3)386 ax_logo.set_ylim(0, 1)387 ax_logo.imshow(img, extent=[0, 1, 0, 1], origin='upper') 388 ax_logo.axis("off")389 ax.axis('equal')390 391 # Metrics data table392 metrics_data = {393 "Pitches": new_player_metrics['pitches'][0],394 "PA": new_player_metrics['pa'][0],395 "BIP": new_player_metrics['bip'][0],396 "HR": f"{new_player_metrics['home_run'][0]:.0f}",397 "AVG": f"{new_player_metrics['avg'][0]:.3f}",398 "OBP": f"{new_player_metrics['obp'][0]:.3f}",399 "SLG": f"{new_player_metrics['slg'][0]:.3f}",400 "OPS": f"{new_player_metrics['obp'][0] + new_player_metrics['slg'][0]:.3f}",401 }402 df_table = pd.DataFrame(metrics_data, index=[0])403 ax_table.axis('off')404 table = ax_table.table(cellText=df_table.values, colLabels=df_table.columns, cellLoc='center', loc='bottom', bbox=[0.07, 0, 0.86, 1])405 for key, cell in table.get_celld().items():406 if key[0] == 0:407 cell.set_text_props(fontweight='bold')408 table.auto_set_font_size(False)409 table.set_fontsize(12)410 table.scale(1, 1.5)411 412 # Additional subplots for spacing413 ax_top = fig.add_subplot(gs[0, :])414 ax_bot = fig.add_subplot(gs[-1, :])415 ax_top.axis('off')416 ax_bot.axis('off')417 ax_bot.text(0.05, 2, "By: Thomas Nestico (@TJStats)", ha="left", va="center", fontsize=14)418 ax_bot.text(0.95, 2, "Data: MLB, Fangraphs", ha="right", va="center", fontsize=14)419 fig.subplots_adjust(left=0.01, right=0.99, top=0.99, bottom=0.01)420 421 # Player headshot422 ax_headshot = fig.add_subplot(gs[1, 1])423 try:424 url = f'https://img.mlbstatic.com/mlb-photos/image/upload/w_640,d_people:generic:headshot:silo:current.png,q_auto:best,f_auto/v1/people/{batter_id}/headshot/silo/current'425 response = requests.get(url)426 img = Image.open(BytesIO(response.content))427 ax_headshot.set_xlim(0, 1.3)428 ax_headshot.set_ylim(0, 1)429 ax_headshot.imshow(img, extent=[0.3, 1.3, 0, 1], origin='upper')430 except PIL.UnidentifiedImageError:431 ax_headshot.axis('off')432 #return433 ax_headshot.axis('off')434 ax_table.set_title('Season Summary', style='italic')435 436 # Fangraphs scouting grades table437 print(batter_id)438 439 if batter_id not in dict_mlb_fg.keys():440 ax_fv_table.text(x=0.5, y=0.5, s='No Scouting Data', style='italic', ha='center', va='center', fontsize=20, bbox=dict(facecolor='white', alpha=1, pad=10))441 return442 df_fv_table = df_prospects[(df_prospects['minorMasterId'] == dict_mlb_fg[batter_id])][['cFV', 'Hit', 'Game', 'Raw', 'Spd', 'Fld']].reset_index(drop=True)443 ax_fv_table.axis('off')444 if df_fv_table.empty:445 ax_fv_table.text(x=0.5, y=0.5, s='No Scouting Data', style='italic', ha='center', va='center', fontsize=20, bbox=dict(facecolor='white', alpha=1, pad=10))446 return447 df_fv_table.columns = ['FV', 'Hit', 'Game', 'Raw', 'Spd', 'Fld']448 table_fv = ax_fv_table.table(cellText=df_fv_table.values, colLabels=df_fv_table.columns, cellLoc='center', loc='bottom', bbox=[0.07, 0, 0.86, 1])449 for key, cell in table_fv.get_celld().items():450 if key[0] == 0:451 cell.set_text_props(fontweight='bold')452 table_fv.auto_set_font_size(False)453 table_fv.set_fontsize(12)454 table_fv.scale(1, 1.5)455 ax_fv_table.set_title('Fangraphs Scouting Grades', style='italic')456 457 458 459 #plt.show()460 461 462 def calculate_new_player_percentiles(player_id, new_player_metrics, player_summary_filtered):463 """464 Calculate percentiles for a new player's metrics.465 466 :param player_id: ID of the player.467 :param new_player_metrics: DataFrame containing new player metrics.468 :param player_summary_filtered: Filtered player summary DataFrame.469 :return: DataFrame containing new player percentiles.470 """471 filtered_summary_clone = player_summary_filtered[['batter_id'] + stat_list].filter(pl.col('batter_id') != player_id).clone()472 combined_data = pl.concat([filtered_summary_clone, new_player_metrics], how="vertical").to_pandas()473 combined_percentiles = pl.DataFrame(pd.concat([combined_data['batter_id'], combined_data[stat_list].rank(pct=True)], axis=1))474 new_player_percentiles = combined_percentiles.filter(pl.col('batter_id') == player_id)475 return new_player_percentiles476 477 478 479 p.set(message="Generating plot", detail="This may take a while...")480 481 482 p.set(0.3, "Gathering data...")483 484 # Example: New player's metrics485 year = int(input.year_input())486 sport_id = int(input.level_input())487 batter_id = int(input.batter_id())488 489 490 df_player = scrape.get_players(sport_id=sport_id,season=year,game_type=['S'])491 492 493 494 495 496 497 # batter_name_id = dict(zip(df_player['player_id'],df_player['name']))498 player_team_dict = dict(zip(df_player['player_id'],df_player['team']))499 # player_position_dict = dict(zip(df_player['player_id'],df_player['position']))500 501 502 batter_summary = pl.read_parquet(f"hf://datasets/TJStatsApps/mlb_data/summary/batter_summary_{level_dict_file[str(sport_id)]}_{year}_spring.parquet").sort('batter_name',descending=False)503 504 batter_name_id = dict(zip(batter_summary['batter_id'],batter_summary['batter_name']))505 # player_team_dict = dict(zip(batter_summary['batter_id'],batter_summary['batter_team'])) 506 507 df_prospects = pd.read_csv(f'data/prospects/prospects_{year}.csv')508 df_rosters = pd.read_csv(f'data/rosters/fangraphs_rosters_{year}.csv')509 df_small = df_rosters[['minorbamid','minormasterid']].dropna()510 dict_mlb_fg=dict(zip(df_small['minorbamid'].astype(int),df_small['minormasterid']))511 512 513 514 515 batter_summary_filter = batter_summary.filter((pl.col('pa') >= 20) & (pl.col('launch_speed') >= 0))516 stat_list = batter_summary.columns[2:]517 batter_summary_filter_pd = batter_summary_filter.to_pandas()518 new_player_metrics = batter_summary.filter(pl.col('batter_id') == batter_id)[['batter_id'] + stat_list]519 520 # Get percentiles for the new player521 new_player_percentiles = calculate_new_player_percentiles(batter_id, new_player_metrics, batter_summary_filter)522 523 p.set(0.6, "Creating plot...")524 # Draw Baseball Savant-style percentile bars525 draw_baseball_savant_percentiles(new_player_metrics=new_player_metrics, 526 new_player_percentiles=new_player_percentiles,527 sport_id=sport_id,528 year_input=year)529 530 @output531 @render.plot532 @reactive.event(input.generate_plot, ignore_none=False) 533 def pitcher_plot(): 534 merged_dict = {535 "avg_start_speed_ff": { "format": '.1f', "percentile_flip": False, "stat_title": "Fastball Velocity" },536 "extension": { "format": '.1f', "percentile_flip": False, "stat_title": "Extension" },537 "woba_percent": { "format": '.3f', "percentile_flip": True, "stat_title": "wOBA" },538 "xwoba_percent": { "format": '.3f', "percentile_flip": True, "stat_title": "xwOBA" },539 "launch_speed": { "format": '.1f', "percentile_flip": True, "stat_title": "Average EV"},540 "barrel_percent": { "format": '.1%', "percentile_flip": True, "stat_title": "Barrel%" },541 "hard_hit_percent": { "format": '.1%', "percentile_flip": True, "stat_title": "Hard-Hit%" },542 "whiff_rate": { "format": '.1%', "percentile_flip": False, "stat_title": "Whiff%" },543 "zone_contact_percent": { "format": '.1%', "percentile_flip": True, "stat_title": "Z-Contact%" }, 544 "zone_percent": { "format": '.1%', "percentile_flip": False, "stat_title": "Zone%" }, 545 "chase_percent": { "format": '.1%', "percentile_flip": False, "stat_title": "O-Swing%" },546 "csw_percent": { "format": '.1%', "percentile_flip": False, "stat_title": "CSW%" },547 "k_percent": { "format": '.1%', "percentile_flip": False, "stat_title": "K%" },548 "bb_percent": { "format": '.1%', "percentile_flip": True, "stat_title": "BB%" },549 "k_minus_bb_percent": { "format": '.1%', "percentile_flip": False, "stat_title": "K - BB%" },550 "ground_ball_percent": { "format": '.1%', "percentile_flip": False, "stat_title": "GB%" },551 }552 553 with ui.Progress(min=0, max=1) as p: 554 555 def draw_baseball_savant_percentiles(new_player_metrics, new_player_percentiles, colors=None,556 sport_id=None,557 year_input=None):558 """559 Draw Baseball Savant-style percentile bars with proper alignment and scaling.560 561 :param new_player_metrics: DataFrame containing new player metrics.562 :param new_player_percentiles: DataFrame containing new player percentiles.563 :param colors: List of colors for bars (optional, red/blue default).564 """565 # Extract player information566 pitcher_id = new_player_metrics['pitcher_id'][0]567 player_name = pitcher_name_id[pitcher_id]568 stats = [merged_dict[x]['stat_title'] for x in merged_dict.keys()]569 570 # Calculate percentiles and values571 percentiles = [int((1 - x) * 100) if merged_dict[stat]["percentile_flip"] else int(x * 100) for x, stat in zip(new_player_percentiles.select(merged_dict.keys()).to_numpy()[0], merged_dict.keys())]572 percentiles = np.clip(percentiles, 1, 100)573 values = [str(f'{x:{merged_dict[stat]["format"]}}').strip('%') for x, stat in zip(new_player_metrics.select(merged_dict.keys()).to_numpy()[0], merged_dict.keys())]574 575 # Get team logo URL576 logo_url = image_dict[team_dict[player_team_dict[pitcher_id]]]577 578 # Create a custom colormap579 color_list = ['#3661AD', '#B4CFD1', '#D82129']580 cmap = LinearSegmentedColormap.from_list("custom_cmap", color_list)581 norm = Normalize(vmin=0.1, vmax=0.9)582 norm_percentiles = norm(percentiles / 100)583 colors = [cmap(p) for p in norm_percentiles]584 585 # Figure setup586 num_stats = len(stats)587 bar_height = 4.4588 spacing = 0.7589 fig_height = (bar_height + spacing) * num_stats590 fig = plt.figure(figsize=(12, 12))591 gs = GridSpec(7, 5, height_ratios=[0.05, 1.5, 0.75, 0.75,0.75, 7.7, 0.1], width_ratios=[0.2, 1.5, 7, 1.5, 0.2])592 593 # Define subplots594 ax_title = fig.add_subplot(gs[1, 2])595 ax_table = fig.add_subplot(gs[2, :])596 ax_fv_table = fig.add_subplot(gs[3, :])597 ax_fv_table.axis('off')598 ax_stuff = fig.add_subplot(gs[4, :])599 ax = fig.add_subplot(gs[5, :])600 ax_logo = fig.add_subplot(gs[1, 3])601 602 ax.set_xlim(-1, 99)603 ax.set_ylim(-1, 99)604 ax.set_aspect("equal")605 ax.axis("off")606 607 # Draw each bar608 for i, (stat, percentile, value, color) in enumerate(zip(stats, percentiles, values, colors)):609 y = fig_height - (i + 1) * (bar_height + spacing)610 ax.add_patch(patches.Rectangle((0, y + bar_height / 4), 100, bar_height / 2, color="#C7DCDC", lw=0))611 ax.add_patch(patches.Rectangle((0, y), percentile, bar_height, color=color, lw=0))612 circle_y = y + bar_height - bar_height / 2613 circle = plt.Circle((percentile, circle_y), bar_height / 2, color=color, ec='white', lw=1.5, zorder=10)614 ax.add_patch(circle)615 fs = 14616 ax.text(percentile, circle_y, f"{percentile}", ha="center", va="center", fontsize=10, color='white', zorder=10, fontweight='bold')617 ax.text(-5, y + bar_height / 2, stat, ha="right", va="center", fontsize=fs)618 ax.text(115, y + bar_height / 2, str(value), ha="right", va="center", fontsize=fs, zorder=5)619 if i < len(stats) and i > 0:620 ax.hlines(y=y + bar_height + spacing / 2, color='#399098', linestyle=(0, (5, 5)), linewidth=1, xmin=-33, xmax=0)621 ax.hlines(y=y + bar_height + spacing / 2, color='#399098', linestyle=(0, (5, 5)), linewidth=1, xmin=100, xmax=115)622 623 # Draw vertical lines for 10%, 50%, and 90% with labels624 for x, label, align, color in zip([10, 50, 90], ["Poor", "Average", "Great"], ['center', 'center', 'center'], color_list):625 ax.axvline(x=x, ymin=0, ymax=1, color='#FFF', linestyle='-', lw=1, zorder=1, alpha=0.5)626 ax.text(x, fig_height + 4, label, ha=align, va='center', fontsize=12, fontweight='bold', color=color)627 triangle = patches.RegularPolygon((x, fig_height + 1), 3, radius=1, orientation=0, color=color, zorder=2)628 ax.add_patch(triangle)629 630 # # Title631 # ax_title.set_ylim(0, 1)632 # ax_title.text(0.5, 0.5, f"{player_name} - {player_position_dict[pitcher_id]}\nPercentile Rankings - 2024 AAA", ha="center", va="center", fontsize=24)633 # ax_title.axis("off")634 player_bio(pitcher_id, ax=ax_title, sport_id=sport_id, year_input=year_input)635 636 # Add team logo637 #response = requests.get(logo_url)638 #######if input.switch():639 ######## response = requests.get(input.logo_select())640 ######else:641 response = requests.get(logo_url)642 img = Image.open(BytesIO(response.content))643 ax_logo.imshow(img)644 ax_logo.axis("off")645 ax.axis('equal')646 lg_dict = {647 11:'all',648 14:10649 }650 levelt = {651 11:1,652 14:4653 }654 655 656 fg_api = f'https://www.fangraphs.com/api/leaders/minor-league/data?pos=all&level={levelt[sport_id]}&lg={lg_dict[sport_id]}&stats=pit&qual=0&type=2&team=&season=2024&seasonEnd=2024&org=&ind=0&splitTeam=false'657 response = requests.get(fg_api)658 data = response.json()659 df_fg = pl.DataFrame(data)660 if pitcher_id not in dict_mlb_fg.keys():661 #ax_fv_table.text(x=0.5, y=0.5, s='No Scouting Data', style='italic', ha='center', va='center', fontsize=20, bbox=dict(facecolor='white', alpha=1, pad=10))662 metrics_data = {663 "Pitches": new_player_metrics['pitches'][0],664 "PA": new_player_metrics['pa'][0],665 "BIP": new_player_metrics['bip'][0],666 "HR": f"{new_player_metrics['home_run'][0]:.0f}",667 "K": f"{new_player_metrics['k'][0]:.0f}",668 "BB": f"{new_player_metrics['bb'][0]:.0f}",669 }670 else:671 df_fg_filter = df_fg.filter(pl.col('minormasterid') == dict_mlb_fg[pitcher_id])672 # Metrics data table673 metrics_data = {674 "G": f"{df_fg_filter['G'][0]:.0f}",675 "IP": f"{df_fg_filter['IP'][0]:.1f}",676 "Pitches": f"{new_player_metrics['pitches'][0]:.0f}",677 "PA": f"{df_fg_filter['TBF'][0]:.0f}",678 "BIP": new_player_metrics['bip'][0],679 "ERA": f"{df_fg_filter['ERA'][0]:.2f}",680 "FIP": f"{df_fg_filter['FIP'][0]:.2f}",681 "WHIP": f"{df_fg_filter['WHIP'][0]:.2f}",682 }683 df_table = pd.DataFrame(metrics_data, index=[0])684 ax_table.axis('off')685 table = ax_table.table(cellText=df_table.values, colLabels=df_table.columns, cellLoc='center', loc='bottom', bbox=[0.07, 0, 0.86, 1])686 for key, cell in table.get_celld().items():687 if key[0] == 0:688 cell.set_text_props(fontweight='bold')689 table.auto_set_font_size(False)690 table.set_fontsize(12)691 table.scale(1, 1.5)692 693 # Additional subplots for spacing694 ax_top = fig.add_subplot(gs[0, :])695 ax_bot = fig.add_subplot(gs[-1, :])696 ax_top.axis('off')697 ax_bot.axis('off')698 ax_bot.text(0.05, 2, "By: Thomas Nestico (@TJStats)", ha="left", va="center", fontsize=14)699 ax_bot.text(0.95, 2, "Data: MLB, Fangraphs", ha="right", va="center", fontsize=14)700 701 702 # Player headshot703 ax_headshot = fig.add_subplot(gs[1, 1])704 try:705 url = f'https://img.mlbstatic.com/mlb-photos/image/upload/c_fill,g_auto/w_640/v1/people/{pitcher_id}/headshot/milb/current.png'706 response = requests.get(url)707 img = Image.open(BytesIO(response.content))708 ax_headshot.set_xlim(0, 1)709 ax_headshot.set_ylim(0, 1)710 ax_headshot.imshow(img, extent=[0, 1, 0, 1], origin='upper')711 except PIL.UnidentifiedImageError:712 ax_headshot.axis('off')713 #return714 ax_headshot.axis('off')715 ax_table.set_title('Season Summary', style='italic')716 717 # Fangraphs scouting grades table718 print(pitcher_id)719 720 if pitcher_id not in dict_mlb_fg.keys():721 ax_fv_table.text(x=0.5, y=0.5, s='No Scouting Data', style='italic', ha='center', va='center', fontsize=20, bbox=dict(facecolor='white', alpha=1, pad=10))722 #return723 df_fv_table = df_prospects[(df_prospects['minorMasterId'] == dict_mlb_fg[pitcher_id])][['cFV','FB', 'SL', 'CB', 'CH', 'SPL', 'CT','CMD']].dropna(axis=1).reset_index(drop=True)724 ax_fv_table.axis('off')725 if df_fv_table.empty:726 ax_fv_table.text(x=0.5, y=0.5, s='No Scouting Data', style='italic', ha='center', va='center', fontsize=20, bbox=dict(facecolor='white', alpha=1, pad=10))727 #return728 else:729 df_fv_table.columns = ['FV']+[x.upper() for x in df_fv_table.columns[1:]]730 table_fv = ax_fv_table.table(cellText=df_fv_table.values, colLabels=df_fv_table.columns, cellLoc='center', loc='bottom', bbox=[0.07, 0, 0.86, 1])731 for key, cell in table_fv.get_celld().items():732 if key[0] == 0:733 cell.set_text_props(fontweight='bold')734 table_fv.auto_set_font_size(False)735 table_fv.set_fontsize(12)736 table_fv.scale(1, 1.5)737 ax_fv_table.set_title('Fangraphs Scouting Grades', style='italic')738 739 740 # df_stuff_filter = df_stuff.filter(pl.col('pitcher_id')==pitcher_id) 741 742 stuff_table = ax_stuff.table(cellText=[df_stuff_filter['tj_stuff_plus']], 743 colLabels=df_stuff_filter['pitch_type'], 744 cellLoc='center', 745 loc='center', bbox=[0.07, 0, 0.86, 1])746 stuff_table.auto_set_font_size(False)747 stuff_table.set_fontsize(12)748 stuff_table.scale(1, 1.5)749 ax_stuff.axis('off')750 ax_stuff.set_title('tjStuff+', style='italic')751 for key, cell in stuff_table.get_celld().items():752 if key[0] == 0:753 cell.set_text_props(fontweight='bold')754 755 # Color the stuff_table values based on the cmap defined756 for (i, j), cell in stuff_table.get_celld().items():757 if i == 0:758 cell.set_text_props(fontweight='bold')759 else:760 norm = Normalize(vmin=90, vmax=110)761 value = float(cell.get_text().get_text())762 color = cmap(norm(value))763 cell.set_facecolor(color)764 #cell.set_text_props(color='white' if value < 100 else 'black')765 766 767 768 769 770 fig.subplots_adjust(left=0.01, right=0.99, top=0.99, bottom=0.01)771 772 773 774 775 776 def calculate_new_player_percentiles(player_id, new_player_metrics, player_summary_filtered):777 """778 Calculate percentiles for a new player's metrics.779 780 :param player_id: ID of the player.781 :param new_player_metrics: DataFrame containing new player metrics.782 :param player_summary_filtered: Filtered player summary DataFrame.783 :return: DataFrame containing new player percentiles.784 """785 filtered_summary_clone = player_summary_filtered[['pitcher_id'] + stat_list].filter(pl.col('pitcher_id') != player_id).clone()786 combined_data = pl.concat([filtered_summary_clone, new_player_metrics], how="vertical").to_pandas()787 combined_percentiles = pl.DataFrame(pd.concat([combined_data['pitcher_id'], combined_data[stat_list].rank(pct=True)], axis=1))788 new_player_percentiles = combined_percentiles.filter(pl.col('pitcher_id') == player_id)789 return new_player_percentiles790 791 p.set(message="Generating plot", detail="This may take a while...")792 793 794 p.set(0.3, "Gathering data...")795 796 797 df_teams = scrape.get_teams()798 team_dict = dict(zip(df_teams['team_id'],df_teams['parent_org_abbreviation']))799 800 # Example: New player's metrics801 # Example: New player's metrics802 year = int(input.year_input())803 sport_id = int(input.level_input())804 pitcher_id = int(input.pitcher_id())805 806 df_player = scrape.get_players(sport_id=sport_id,season=2024)807 pitcher_name_id = dict(zip(df_player['player_id'],df_player['name']))808 player_team_dict = dict(zip(df_player['player_id'],df_player['team']))809 player_position_dict = dict(zip(df_player['player_id'],df_player['position']))810 player_position_dict = dict(zip(df_player['player_id'],df_player['position']))811 812 813 814 815 pitcher_summary = pl.read_parquet(f"hf://datasets/TJStatsApps/mlb_data/summary/pitcher_summary_{level_dict_file[str(sport_id)]}_{year}_spring.parquet").sort('batter_name',descending=False)816 df_prospects = pd.read_csv(f'data/prospects/prospects_{year}.csv')817 df_rosters = pd.read_csv(f'data/rosters/fangraphs_rosters_{year}.csv')818 df_small = df_rosters[['minorbamid','minormasterid']].dropna()819 dict_mlb_fg=dict(zip(df_small['minorbamid'].astype(int),df_small['minormasterid']))820 821 df_stuff = pl.read_csv(f'data/stuff/stuff_{level_dict_file[str(sport_id)]}_{year}.csv')822 # Filter out the "All" row823 filtered_df = df_stuff.filter(pl.col("pitch_type") != "All")824 825 filtered_all_df = df_stuff.filter(pl.col("pitch_type") == "All")826 # Calculate total pitches for each pitcher and proportion of each pitch type827 result_df = (828 filtered_df829 .with_columns([830 # Total pitches for each pitcher831 pl.col("pitches").sum().over("pitcher_id").alias("total_pitches"),832 # Proportion of pitches833 (pl.col("pitches") / pl.col("pitches").sum().over("pitcher_id")).alias("pitch_proportion"),834 ])835 ).filter(pl.col("pitch_proportion") > 0.05)836 837 df_stuff = pl.concat([filtered_all_df.with_columns(838 [pl.col("pitches").sum().over("pitcher_id").alias("total_pitches"),839 (pl.col("pitches") / pl.col("pitches").sum().over("pitcher_id")).alias("pitch_proportion")]840 ), result_df])841 842 843 844 845 df_stuff_filter = df_stuff.filter(pl.col('pitcher_id')==pitcher_id) 846 847 pitcher_summary_filter = pitcher_summary.filter((pl.col('pa') >= 300) & (pl.col('launch_speed') >= 0))848 stat_list = pitcher_summary.columns[2:]849 pitcher_summary_filter_pd = pitcher_summary_filter.to_pandas()850 new_player_metrics = pitcher_summary.filter(pl.col('pitcher_id') == pitcher_id)[['pitcher_id'] + stat_list]851 852 # Get percentiles for the new player853 new_player_percentiles = calculate_new_player_percentiles(pitcher_id, new_player_metrics, pitcher_summary_filter)854 855 p.set(0.6, "Creating plot...")856 # Draw Baseball Savant-style percentile bars857 draw_baseball_savant_percentiles(new_player_metrics=new_player_metrics, 858 new_player_percentiles=new_player_percentiles,859 sport_id=sport_id,860 year_input=year)861 862 863app = App(app_ui, server)