import polars as pl import numpy as np import pandas as pd import api_scraper scrape = api_scraper.MLB_Scrape() from functions import df_update from functions import pitch_summary_functions update = df_update.df_update() import requests import joblib from matplotlib.gridspec import GridSpec from shiny import App, reactive, ui, render from shiny.ui import h2, tags import matplotlib.pyplot as plt import matplotlib.gridspec as gridspec import seaborn as sns from functions.pitch_summary_functions import * from functions.df_update import * from shiny import App, reactive, ui, render from shiny.ui import h2, tags from functions.heat_map_functions import * colour_palette = ['#FFB000','#648FFF','#785EF0', '#DC267F','#FE6100','#3D1EB2','#894D80','#16AA02','#B5592B','#A3C1ED'] year_list = [2017,2018,2019,2020,2021,2022,2023,2024,2025] level_dict = {'1':'MLB', '11':'AAA', '12':'AA', '13':'A+', '14':'A', '17':'AFL', '22':'College', '21':'Prospects', '51':'International' } function_dict={ 'velocity_kdes':'Velocity Distributions', 'break_plot':'Pitch Movement', 'tj_stuff_roling':'Rolling tjStuff+ by Pitch', 'tj_stuff_roling_game':'Rolling tjStuff+ by Game', 'location_plot_lhb':'Locations vs LHB', 'location_plot_rhb':'Locations vs RHB', } split_dict = {'all':'All', 'left':'LHH', 'right':'RHH'} split_dict_hand = {'all':['L','R'], 'left':['L'], 'right':['R']} type_dict = {'R':'Regular Season', 'S':'Spring', 'P':'Playoffs' } format_dict = { 'pitch_percent': '{:.1%}', 'pitches': '{:.0f}', 'heart_zone_percent': '{:.1%}', 'shadow_zone_percent': '{:.1%}', 'chase_zone_percent': '{:.1%}', 'waste_zone_percent': '{:.1%}', 'csw_percent': '{:.1%}', 'whiff_rate': '{:.1%}', 'zone_whiff_percent': '{:.1%}', 'chase_percent': '{:.1%}', 'bip': '{:.0f}', 'xwoba_percent_contact': '{:.3f}' } format_dict = { 'pitch_percent': '{:.1%}', 'pitches': '{:.0f}', 'heart_zone_percent': '{:.1%}', 'shadow_zone_percent': '{:.1%}', 'chase_zone_percent': '{:.1%}', 'waste_zone_percent': '{:.1%}', 'csw_percent': '{:.1%}', 'whiff_rate': '{:.1%}', 'zone_whiff_percent': '{:.1%}', 'chase_percent': '{:.1%}', 'bip': '{:.0f}', 'xwoba_percent_contact': '{:.3f}' } label_translation_dict = { 'pitch_percent': 'Pitch%', 'pitches': 'Pitches', 'heart_zone_percent': 'Heart%', 'shadow_zone_percent': 'Shado%', 'chase_zone_percent': 'Chas%', 'waste_zone_percent': 'Waste%', 'csw_percent': 'CSW%', 'whiff_rate': 'Whiff%', 'zone_whiff_percent': 'Z-Whiff%', 'chase_percent': 'O-Swing%', 'bip': 'BBE', 'xwoba_percent_contact': 'xwOBACON' } cmap_sum22 = matplotlib.colors.LinearSegmentedColormap.from_list("", ['#648FFF','#FFB000',]) cmap_sum = matplotlib.colors.LinearSegmentedColormap.from_list("", ['#648FFF','#FFFFFF','#FFB000',]) cmap_sum2 = matplotlib.colors.LinearSegmentedColormap.from_list("", ['#FFFFFF','#FFB000','#FE6100']) cmap_sum_r = matplotlib.colors.LinearSegmentedColormap.from_list("", ['#FFB000','#FFFFFF','#648FFF',]) import requests import os CAMPAIGN_ID = os.getenv("CAMPAIGN_ID") ACCESS_TOKEN = os.getenv("ACCESS_TOKEN") BACKUP_PW = os.getenv("BACKUP_PW") ADMIN_PW = os.getenv("ADMIN_PW") url = f"https://www.patreon.com/api/oauth2/v2/campaigns/{CAMPAIGN_ID}/members" headers = { "Authorization": f"Bearer {ACCESS_TOKEN}" } # Simple parameters, requesting the member's email and currently entitled tiers params = { "fields[member]": "full_name,email", # Request the member's email "include": "currently_entitled_tiers", # Include the currently entitled tiers "page[size]": 10000 # Fetch up to 1000 patrons per request } response = requests.get(url, headers=headers, params=params) VALID_PASSWORDS = [] if response.status_code == 200: data = response.json() for patron in data['data']: try: tiers = patron['relationships']['currently_entitled_tiers']['data'] if any(tier['id'] == '9078921' for tier in tiers): full_name = patron['attributes']['email'] VALID_PASSWORDS.append(full_name) except KeyError: continue VALID_PASSWORDS.append(BACKUP_PW) VALID_PASSWORDS.append(ADMIN_PW) # VALID_PASSWORDS.append('') from shiny import App, reactive, ui, render from shiny.ui import h2, tags # Define the login UI login_ui = ui.page_fluid( ui.card( ui.h2([ "TJStats Pitching Heat Maps App ", ui.tags.a("(@TJStats)", href="https://twitter.com/TJStats", target="_blank") ]), ui.p( "This App is available to Superstar Patrons. Please enter your Patreon email address in the box below. If you're having trouble, please refer to the ", ui.tags.a("Patreon post", href="https://www.patreon.com/posts/117909954", target="_blank"), "." ), ui.input_password("password", "Enter Patreon Email (or Password from Link):", width="25%"), ui.tags.input( type="checkbox", id="authenticated", value=False, disabled=True ), ui.input_action_button("login", "Login", class_="btn-primary"), ui.output_text("login_message"), ) ) main_ui = ui.page_sidebar( ui.sidebar( # Row for selecting season and level ui.row( ui.column(4, ui.input_select('year_input', 'Select Season', year_list, selected=2025)), ui.column(4, ui.input_select('level_input', 'Select Level', level_dict)), ui.column(4, ui.input_select('type_input', 'Select Type', type_dict,selected='R')) ), # Row for the action button to get player list ui.row(ui.input_action_button("player_button", "Get Player List", class_="btn-primary")), # Row for selecting the player ui.row(ui.column(12, ui.output_ui('player_select_ui', 'Select Player'))), ui.row(ui.input_action_button("get_pitches", "Get Pitch Types", class_="btn-secondary")), # Rows for selecting plots and split options ui.row(ui.column(12, ui.output_ui('pitch_type_ui', 'Select Pitch Type'))), ui.row(ui.column(6, ui.input_select('plot_type', 'Select Plot', ['Pitch%','Whiff%','xwOBACON'])), ui.column(6, ui.input_switch('scatter_switch', 'Show Pitches', value=False))), ui.row(ui.column(12, ui.output_ui('date_id', 'Select Date'))), # Row for the action button to generate plot ui.row(ui.input_action_button("generate_plot", "Generate Plot", class_="btn-primary")), width="400px" # Added this parameter to control sidebar width ), # Main content (former panel_main content) ui.navset_tab( # Tab for game summary plot ui.nav("Pitching Summary", ui.output_text("status"), ui.output_plot('plot', width='1440px', height=f'{900/1600*1440}px') ), ) ) # Combined UI with conditional panel app_ui = ui.page_fluid( ui.tags.head( ui.tags.script(src="script.js") ), ui.panel_conditional( "!input.authenticated", login_ui ), ui.panel_conditional( "input.authenticated", main_ui ) ) def server(input, output, session): @reactive.Effect @reactive.event(input.login) def check_password(): if input.password() in VALID_PASSWORDS: ui.update_checkbox("authenticated", value=True) ui.update_text("login_message", value="") else: ui.update_text("login_message", value="Invalid password!") ui.update_text("password", value="") @output @render.text def login_message(): return "" # Instead of using @reactive.calc with @reactive.event cached_data_value = reactive.value(None) # Initialize with None @reactive.calc @reactive.event(input.date_id,input.pitcher_id) def cached_data(): if not hasattr(input, 'pitcher_id') or input.pitcher_id() is None or not hasattr(input, 'date_id') or input.date_id() is None: return # Exit early if required inputs aren't ready year_input = int(input.year_input()) sport_id = int(input.level_input()) player_input = int(input.pitcher_id()) start_date = str(input.date_id()[0]) end_date = str(input.date_id()[1]) # Simulate an expensive data operation game_list = scrape.get_player_games_list(sport_id = sport_id, season = year_input, player_id = player_input, start_date = start_date, end_date = end_date, game_type = [input.type_input()]) data_list = scrape.get_data(game_list_input = game_list[:]) df = (update.update(scrape.get_data_df(data_list = data_list).filter( (pl.col("pitcher_id") == player_input)& (pl.col("is_pitch") == True) ))).with_columns( pl.col('pitch_type').count().over('pitch_type').alias('pitch_count') ) return df @render.ui @reactive.event(input.player_button, input.year_input, input.level_input, input.type_input,ignore_none=False) def player_select_ui(): # Get the list of pitchers for the selected level and season df_pitcher_info = scrape.get_players(sport_id=int(input.level_input()), season=int(input.year_input()), game_type = [input.type_input()]).filter( pl.col("position").is_in(['P','TWP'])).sort("name") # Create a dictionary of pitcher IDs and names pitcher_dict = dict(zip(df_pitcher_info['player_id'], df_pitcher_info['name'])) # Return a select input for choosing a pitcher return ui.input_select("pitcher_id", "Select Pitcher", pitcher_dict, selectize=True) is_loading = reactive.value(False) data_result = reactive.value(None) @reactive.effect @reactive.event(input.get_pitches) def load_data(): is_loading.set(True) data_result.set(None) # Clear any previous data try: # This will fetch the data result = cached_data() data_result.set(result) except Exception as e: # Handle any errors print(f"Error loading data: {e}") finally: is_loading.set(False) @output @render.ui def pitch_type_ui(): # Make sure to add dependencies on both values input.get_pitches() loading = is_loading() data = data_result() # If loading, show spinner if loading: return ui.div( ui.span("Loading pitch types... ", class_="me-2"), ui.tags.div(class_="spinner-border spinner-border-sm text-primary"), style="padding: 10px; background-color: #f8f9fa; border-radius: 5px;" ) # If data is loaded, show dropdown elif data is not None: df = data df = df.clone() if hasattr(df, 'clone') else df.copy() pitch_dict = dict(zip(df['pitch_type'], df['pitch_description'])) return ui.input_select( "pitch_type_input", "Select Pitch Type", pitch_dict, selectize=True ) # Initial state or after reset else: return ui.div( ui.p("Click 'Get Pitch Types' to load the dropdown.", class_="text-muted"), style="text-align: center; padding: 10px;" ) # Empty div with instructions @render.ui @reactive.event(input.player_button, input.year_input, input.level_input, input.type_input,ignore_none=False) def date_id(): # Create a date range input for selecting the date range within the selected year return ui.input_date_range("date_id", "Select Date Range", start=f"{int(input.year_input())}-01-01", end=f"{int(input.year_input())}-12-31", min=f"{int(input.year_input())}-01-01", max=f"{int(input.year_input())}-12-31") @output @render.text def status(): # Only show status when generating if input.generate == 0: return "" return "" @output @render.plot @reactive.event(input.generate_plot, ignore_none=False) def plot(): # Show progress/loading notification with ui.Progress(min=0, max=1) as p: p.set(message="Generating plot", detail="This may take a while...") p.set(0.3, "Gathering data...") year_input = int(input.year_input()) sport_id = int(input.level_input()) player_input = int(input.pitcher_id()) start_date = str(input.date_id()[0]) end_date = str(input.date_id()[1]) scatter_bool = input.scatter_switch() print(year_input, sport_id, player_input, start_date, end_date) df = cached_data() df = df.clone() pitch_input = input.pitch_type_input() df_plot = pitch_heat_map(pitch_input, df) pivot_table_l = pitch_prop(df=df_plot, hand = 'L') pivot_table_r = pitch_prop(df=df_plot, hand = 'R') table_left = df_update().update_summary_select(df=df_plot.filter(pl.col('batter_hand') == 'L'), selection=['pitcher_hand']) table_left = table_left.with_columns( (pl.col('pitches')/len(df.filter(pl.col('batter_hand') == 'L'))).alias('pitch_percent') ) table_right = df_update().update_summary_select(df=df_plot.filter(pl.col('batter_hand') == 'R'), selection=['pitcher_hand']) table_right = table_right.with_columns( (pl.col('pitches')/len(df.filter(pl.col('batter_hand') == 'R'))).alias('pitch_percent') ) try: normalize = mcolors.Normalize(vmin=table_left['pitch_percent']*0.5, vmax=table_left['pitch_percent']*1.5) # Define the range of values df_colour_left = pd.DataFrame(data=[[get_color(x,normalize,cmap_sum2) for x in pivot_table_l[0]], [get_color(x,normalize,cmap_sum2) for x in pivot_table_l[1]], [get_color(x,normalize,cmap_sum2) for x in pivot_table_l[2]]]) df_colour_left[0] = '#ffffff' except ValueError: normalize = mcolors.Normalize(vmin=0, vmax=1) # Define the range of values df_colour_left = pd.DataFrame(data=[['#ffffff','#ffffff','#ffffff','#ffffff'], ['#ffffff','#ffffff','#ffffff','#ffffff'], ['#ffffff','#ffffff','#ffffff','#ffffff']]) try: normalize = mcolors.Normalize(vmin=table_right['pitch_percent']*0.5, vmax=table_right['pitch_percent']*1.5) # Define the range of values df_colour_right = pd.DataFrame(data=[[get_color(x,normalize,cmap_sum2) for x in pivot_table_r[0]], [get_color(x,normalize,cmap_sum2) for x in pivot_table_r[1]], [get_color(x,normalize,cmap_sum2) for x in pivot_table_r[2]]]) df_colour_right[0] = '#ffffff' except ValueError: normalize = mcolors.Normalize(vmin=0, vmax=1) # Define the range of values df_colour_right = pd.DataFrame(data=[['#ffffff','#ffffff','#ffffff','#ffffff'], ['#ffffff','#ffffff','#ffffff','#ffffff'], ['#ffffff','#ffffff','#ffffff','#ffffff']]) table_left = table_left.select( 'pitch_percent', 'pitches', 'heart_zone_percent', 'shadow_zone_percent', 'chase_zone_percent', 'waste_zone_percent', 'csw_percent', 'whiff_rate', 'zone_whiff_percent', 'chase_percent', 'bip', 'xwoba_percent_contact').to_pandas().T table_right = table_right.select( 'pitch_percent', 'pitches', 'heart_zone_percent', 'shadow_zone_percent', 'chase_zone_percent', 'waste_zone_percent', 'csw_percent', 'whiff_rate', 'zone_whiff_percent', 'chase_percent', 'bip', 'xwoba_percent_contact').to_pandas().T table_right = table_right.replace({'nan%':'—'}) table_right = table_right.replace({'nan':'—'}) p.set(0.6, "Creating plot...") import matplotlib.pyplot as plt fig = plt.figure(figsize=(16, 9)) fig.set_facecolor('white') sns.set_theme(style="whitegrid", palette=colour_palette) gs = GridSpec(3, 5, height_ratios=[2,9,1],width_ratios=[1,9,1,9,1]) gs.update(hspace=0.2, wspace=0.3) # Add subplots to the grid ax_header = fig.add_subplot(gs[0, :]) ax_left = fig.add_subplot(gs[1, 1]) ax_right = fig.add_subplot(gs[1, 3]) axfooter = fig.add_subplot(gs[-1, :]) if input.plot_type() == 'Pitch%': heat_map_plot(df=df_plot, ax=ax_left, cmap=cmap_sum2, hand='L', scatter=scatter_bool) heat_map_plot(df=df_plot, ax=ax_right, cmap=cmap_sum2, hand='R', scatter=scatter_bool) if input.plot_type() == 'Whiff%': heat_map_plot_hex_whiff(df=df_plot, ax=ax_left, cmap=cmap_sum, hand='L', scatter=scatter_bool) heat_map_plot_hex_whiff(df=df_plot, ax=ax_right, cmap=cmap_sum, hand='R', scatter=scatter_bool) if input.plot_type() == 'xwOBACON': print(df_plot.filter((pl.col('launch_speed')>0)).select(['batter_name','launch_speed','launch_angle','woba_pred_contact'])) heat_map_plot_hex_damage(df=df_plot, ax=ax_left, cmap=cmap_sum, hand='L', scatter=scatter_bool) heat_map_plot_hex_damage(df=df_plot, ax=ax_right, cmap=cmap_sum, hand='R', scatter=scatter_bool) # Load the image img = mpimg.imread('images/left.png') imagebox = OffsetImage(img, zoom=0.58) # adjust zoom as needed ab = AnnotationBbox(imagebox, (1.25, -0.5), box_alignment=(0, 0), frameon=False) ax_left.add_artist(ab) # Load the image img = mpimg.imread('images/right.png') imagebox = OffsetImage(img, zoom=0.58) # adjust zoom as needed # Create an AnnotationBbox ab = AnnotationBbox(imagebox, (-1.25, -0.5), box_alignment=(1, 0), frameon=False) ax_right.add_artist(ab) table_plot(ax=ax_left, table=table_left, hand='L') table_plot_pivot(ax=ax_left, pivot_table=pivot_table_l, df_colour=df_colour_left) table_plot(ax=ax_right, table=table_right, hand='R') table_plot_pivot(ax=ax_right, pivot_table=pivot_table_r, df_colour=df_colour_right) from matplotlib.cm import ScalarMappable from matplotlib.colors import Normalize # Create a ScalarMappable with the same colormap and normalization if input.plot_type() == 'Pitch%': sm = ScalarMappable(cmap=cmap_sum2, norm=Normalize(vmin=0, vmax=1)) cbar = fig.colorbar(sm, ax=axfooter, orientation='horizontal',aspect=100) cbar.set_ticks([]) cbar.set_ticks([sm.norm.vmin, sm.norm.vmax]) cbar.ax.set_xticklabels(['Least', 'Most']) cbar.ax.tick_params(labeltop=True, labelbottom=False, labelsize=14) labels = cbar.ax.get_xticklabels() labels[0].set_horizontalalignment('left') labels[-1].set_horizontalalignment('right') labels = cbar.ax.get_xticklabels() cbar.ax.set_xticklabels(labels) cbar.ax.tick_params(length=0) if input.plot_type() == 'Whiff%': sm = ScalarMappable(cmap=cmap_sum, norm=Normalize(vmin=0.15, vmax=0.35)) cbar = fig.colorbar(sm, ax=axfooter, orientation='horizontal',aspect=100) cbar.set_ticks([]) cbar.set_ticks([sm.norm.vmin, sm.norm.vmax]) cbar.ax.set_xticklabels(['15%', '35%']) cbar.ax.tick_params(labeltop=True, labelbottom=False, labelsize=14) labels = cbar.ax.get_xticklabels() labels[0].set_horizontalalignment('left') labels[-1].set_horizontalalignment('right') labels = cbar.ax.get_xticklabels() cbar.ax.set_xticklabels(labels) cbar.ax.tick_params(length=0) if input.plot_type() == 'xwOBACON': sm = ScalarMappable(cmap=cmap_sum_r, norm=Normalize(vmin=0.25, vmax=0.5)) cbar = fig.colorbar(sm, ax=axfooter, orientation='horizontal',aspect=100) cbar.set_ticks([]) cbar.set_ticks([sm.norm.vmin, sm.norm.vmax]) cbar.ax.set_xticklabels(['.000', '.500']) cbar.ax.tick_params(labeltop=True, labelbottom=False, labelsize=14) labels = cbar.ax.get_xticklabels() labels[0].set_horizontalalignment('left') labels[-1].set_horizontalalignment('right') labels = cbar.ax.get_xticklabels() cbar.ax.set_xticklabels(labels) cbar.ax.tick_params(length=0) axfooter.text(x=0.02,y=1,s='By: Thomas Nestico\n @TJStats',fontname='Calibri',ha='left',fontsize=14,va='top') axfooter.text(x=1-0.02,y=1,s='Data: MLB',ha='right',fontname='Calibri',fontsize=14,va='top') axfooter.axis('off') # Display the image on the axis ax_header.set_xlim(-12,12) ax_header.set_ylim(0, 2) if input.plot_type() == 'Pitch%': ax_header.text(x=0,y=2,s=f"{df_plot['pitcher_name'][0]} - {df_plot['pitcher_hand'][0]}HP\n{df_plot['pitch_description'][0]} Pitch Frequency",ha='center',fontsize=24,va='top') if input.plot_type() == 'Whiff%': ax_header.text(x=0,y=2,s=f"{df_plot['pitcher_name'][0]} - {df_plot['pitcher_hand'][0]}HP\n{df_plot['pitch_description'][0]} Whiff%",ha='center',fontsize=24,va='top') if input.plot_type() == 'xwOBACON': ax_header.text(x=0,y=2,s=f"{df_plot['pitcher_name'][0]} - {df_plot['pitcher_hand'][0]}HP\n{df_plot['pitch_description'][0]} xwOBACON",ha='center',fontsize=24,va='top') ax_header.text(x=0,y=0.7,s=f"{year_input} {level_dict[str(sport_id)]} Season",ha='center',fontsize=16,va='top') ax_header.text(x=0,y=0.3,s=f"{df_plot['game_date'][0]} to {df_plot['game_date'][-1]}",ha='center',fontsize=16,va='top',fontstyle='italic') ax_header.axis('off') import urllib import urllib.request import urllib.error from urllib.error import HTTPError plot_header(pitcher_id=player_input, ax=ax_header, df_team=scrape.get_teams(), df_players=scrape.get_players(sport_id,year_input), sport_id=sport_id,) fig.subplots_adjust(left=0.03, right=0.97, top=0.97, bottom=0.03) app = App(app_ui, server)