nesticot commited on
Commit
b52d11d
·
verified ·
1 Parent(s): 8a87107

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +685 -676
app.py CHANGED
@@ -1,676 +1,685 @@
1
- import polars as pl
2
- import numpy as np
3
- import pandas as pd
4
- import api_scraper
5
- scrape = api_scraper.MLB_Scrape()
6
- from functions import df_update
7
- from functions import pitch_summary_functions
8
- update = df_update.df_update()
9
- import requests
10
- import joblib
11
- from matplotlib.gridspec import GridSpec
12
- from shiny import App, reactive, ui, render
13
- from shiny.ui import h2, tags
14
- import matplotlib.pyplot as plt
15
- import matplotlib.gridspec as gridspec
16
- import seaborn as sns
17
- from functions.pitch_summary_functions import *
18
- from functions.df_update import *
19
- from shiny import App, reactive, ui, render
20
- from shiny.ui import h2, tags
21
- from functions.heat_map_functions import *
22
-
23
- colour_palette = ['#FFB000','#648FFF','#785EF0',
24
- '#DC267F','#FE6100','#3D1EB2','#894D80','#16AA02','#B5592B','#A3C1ED']
25
-
26
-
27
- year_list = [2017,2018,2019,2020,2021,2022,2023,2024,2025]
28
-
29
-
30
-
31
- level_dict = {'1':'MLB',
32
- '11':'AAA',
33
- '12':'AA',
34
- '13':'A+',
35
- '14':'A',
36
- '17':'AFL',
37
- '22':'College',
38
- '21':'Prospects',
39
- '51':'International' }
40
-
41
- function_dict={
42
- 'velocity_kdes':'Velocity Distributions',
43
- 'break_plot':'Pitch Movement',
44
- 'tj_stuff_roling':'Rolling tjStuff+ by Pitch',
45
- 'tj_stuff_roling_game':'Rolling tjStuff+ by Game',
46
- 'location_plot_lhb':'Locations vs LHB',
47
- 'location_plot_rhb':'Locations vs RHB',
48
- }
49
-
50
-
51
- split_dict = {'all':'All',
52
- 'left':'LHH',
53
- 'right':'RHH'}
54
-
55
- split_dict_hand = {'all':['L','R'],
56
- 'left':['L'],
57
- 'right':['R']}
58
-
59
-
60
- type_dict = {'R':'Regular Season',
61
- 'S':'Spring',
62
- 'P':'Playoffs' }
63
-
64
- format_dict = {
65
- 'pitch_percent': '{:.1%}',
66
- 'pitches': '{:.0f}',
67
- 'heart_zone_percent': '{:.1%}',
68
- 'shadow_zone_percent': '{:.1%}',
69
- 'chase_zone_percent': '{:.1%}',
70
- 'waste_zone_percent': '{:.1%}',
71
- 'csw_percent': '{:.1%}',
72
- 'whiff_rate': '{:.1%}',
73
- 'zone_whiff_percent': '{:.1%}',
74
- 'chase_percent': '{:.1%}',
75
- 'bip': '{:.0f}',
76
- 'xwoba_percent_contact': '{:.3f}'
77
- }
78
-
79
- format_dict = {
80
- 'pitch_percent': '{:.1%}',
81
- 'pitches': '{:.0f}',
82
- 'heart_zone_percent': '{:.1%}',
83
- 'shadow_zone_percent': '{:.1%}',
84
- 'chase_zone_percent': '{:.1%}',
85
- 'waste_zone_percent': '{:.1%}',
86
- 'csw_percent': '{:.1%}',
87
- 'whiff_rate': '{:.1%}',
88
- 'zone_whiff_percent': '{:.1%}',
89
- 'chase_percent': '{:.1%}',
90
- 'bip': '{:.0f}',
91
- 'xwoba_percent_contact': '{:.3f}'
92
- }
93
- label_translation_dict = {
94
- 'pitch_percent': 'Pitch%',
95
- 'pitches': 'Pitches',
96
- 'heart_zone_percent': 'Heart%',
97
- 'shadow_zone_percent': 'Shado%',
98
- 'chase_zone_percent': 'Chas%',
99
- 'waste_zone_percent': 'Waste%',
100
- 'csw_percent': 'CSW%',
101
- 'whiff_rate': 'Whiff%',
102
- 'zone_whiff_percent': 'Z-Whiff%',
103
- 'chase_percent': 'O-Swing%',
104
- 'bip': 'BBE',
105
- 'xwoba_percent_contact': 'xwOBACON'
106
- }
107
-
108
- cmap_sum22 = matplotlib.colors.LinearSegmentedColormap.from_list("", ['#648FFF','#FFB000',])
109
- cmap_sum = matplotlib.colors.LinearSegmentedColormap.from_list("", ['#648FFF','#FFFFFF','#FFB000',])
110
- cmap_sum2 = matplotlib.colors.LinearSegmentedColormap.from_list("", ['#FFFFFF','#FFB000','#FE6100'])
111
- cmap_sum_r = matplotlib.colors.LinearSegmentedColormap.from_list("", ['#FFB000','#FFFFFF','#648FFF',])
112
-
113
-
114
- import requests
115
-
116
- import os
117
- CAMPAIGN_ID = os.getenv("CAMPAIGN_ID")
118
- ACCESS_TOKEN = os.getenv("ACCESS_TOKEN")
119
- BACKUP_PW = os.getenv("BACKUP_PW")
120
- ADMIN_PW = os.getenv("ADMIN_PW")
121
-
122
- url = f"https://www.patreon.com/api/oauth2/v2/campaigns/{CAMPAIGN_ID}/members"
123
-
124
- headers = {
125
- "Authorization": f"Bearer {ACCESS_TOKEN}"
126
- }
127
-
128
- # Simple parameters, requesting the member's email and currently entitled tiers
129
- params = {
130
- "fields[member]": "full_name,email", # Request the member's email
131
- "include": "currently_entitled_tiers", # Include the currently entitled tiers
132
- "page[size]": 1000 # Fetch up to 1000 patrons per request
133
- }
134
-
135
- response = requests.get(url, headers=headers, params=params)
136
-
137
-
138
- VALID_PASSWORDS = []
139
- if response.status_code == 200:
140
- data = response.json()
141
- for patron in data['data']:
142
- try:
143
- tiers = patron['relationships']['currently_entitled_tiers']['data']
144
- if any(tier['id'] == '9078921' for tier in tiers):
145
- full_name = patron['attributes']['email']
146
- VALID_PASSWORDS.append(full_name)
147
- except KeyError:
148
- continue
149
- VALID_PASSWORDS.append(BACKUP_PW)
150
- VALID_PASSWORDS.append(ADMIN_PW)
151
- # VALID_PASSWORDS.append('')
152
-
153
- from shiny import App, reactive, ui, render
154
- from shiny.ui import h2, tags
155
-
156
- # Define the login UI
157
- login_ui = ui.page_fluid(
158
- ui.card(
159
- ui.h2([
160
- "TJStats Pitching Heat Maps App ",
161
- ui.tags.a("(@TJStats)", href="https://twitter.com/TJStats", target="_blank")
162
- ]),
163
- ui.p(
164
- "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 ",
165
- ui.tags.a("Patreon post", href="https://www.patreon.com/posts/117909954", target="_blank"),
166
- "."
167
- ),
168
- ui.input_password("password", "Enter Patreon Email (or Password from Link):", width="25%"),
169
- ui.tags.input(
170
- type="checkbox",
171
- id="authenticated",
172
- value=False,
173
- disabled=True
174
- ),
175
- ui.input_action_button("login", "Login", class_="btn-primary"),
176
- ui.output_text("login_message"),
177
- )
178
- )
179
-
180
-
181
- main_ui = ui.page_sidebar(
182
- ui.sidebar(
183
- # Row for selecting season and level
184
- ui.row(
185
- ui.column(4, ui.input_select('year_input', 'Select Season', year_list, selected=2024)),
186
- ui.column(4, ui.input_select('level_input', 'Select Level', level_dict)),
187
- ui.column(4, ui.input_select('type_input', 'Select Type', type_dict,selected='R'))
188
- ),
189
- # Row for the action button to get player list
190
- ui.row(ui.input_action_button("player_button", "Get Player List", class_="btn-primary")),
191
- # Row for selecting the player
192
- ui.row(ui.column(12, ui.output_ui('player_select_ui', 'Select Player'))),
193
-
194
-
195
- ui.row(ui.input_action_button("get_pitches", "Get Pitch Types", class_="btn-secondary")),
196
-
197
-
198
- # Rows for selecting plots and split options
199
- ui.row(ui.column(12, ui.output_ui('pitch_type_ui', 'Select Pitch Type'))),
200
- ui.row(ui.column(12, ui.input_select('plot_type', 'Select Plot', ['Pitch%','Whiff%','xwOBACON']))),
201
- ui.row(ui.column(12, ui.output_ui('date_id', 'Select Date'))),
202
-
203
- # Row for the action button to generate plot
204
- ui.row(ui.input_action_button("generate_plot", "Generate Plot", class_="btn-primary")),
205
- width="400px" # Added this parameter to control sidebar width
206
- ),
207
-
208
- # Main content (former panel_main content)
209
- ui.navset_tab(
210
- # Tab for game summary plot
211
- ui.nav("Pitching Summary",
212
- ui.output_text("status"),
213
- ui.output_plot('plot', width='1440px', height=f'{900/1600*1440}px')
214
- ),
215
- )
216
- )
217
-
218
-
219
- # Combined UI with conditional panel
220
- app_ui = ui.page_fluid(
221
- ui.tags.head(
222
- ui.tags.script(src="script.js")
223
- ),
224
-
225
- ui.panel_conditional(
226
- "!input.authenticated",
227
- login_ui
228
- ),
229
- ui.panel_conditional(
230
- "input.authenticated",
231
- main_ui
232
- )
233
- )
234
-
235
-
236
- def server(input, output, session):
237
-
238
- @reactive.Effect
239
- @reactive.event(input.login)
240
- def check_password():
241
- if input.password() in VALID_PASSWORDS:
242
- ui.update_checkbox("authenticated", value=True)
243
- ui.update_text("login_message", value="")
244
- else:
245
- ui.update_text("login_message", value="Invalid password!")
246
- ui.update_text("password", value="")
247
-
248
- @output
249
- @render.text
250
- def login_message():
251
- return ""
252
-
253
- # Instead of using @reactive.calc with @reactive.event
254
- cached_data_value = reactive.value(None) # Initialize with None
255
-
256
- @reactive.calc
257
- @reactive.event(input.date_id,input.pitcher_id)
258
- def cached_data():
259
-
260
- if not hasattr(input, 'pitcher_id') or input.pitcher_id() is None or not hasattr(input, 'date_id') or input.date_id() is None:
261
- return # Exit early if required inputs aren't ready
262
- year_input = int(input.year_input())
263
- sport_id = int(input.level_input())
264
- player_input = int(input.pitcher_id())
265
- start_date = str(input.date_id()[0])
266
- end_date = str(input.date_id()[1])
267
- # Simulate an expensive data operation
268
- game_list = scrape.get_player_games_list(sport_id = sport_id,
269
- season = year_input,
270
- player_id = player_input,
271
- start_date = start_date,
272
- end_date = end_date,
273
- game_type = [input.type_input()])
274
-
275
- data_list = scrape.get_data(game_list_input = game_list[:])
276
- df = (update.update(scrape.get_data_df(data_list = data_list).filter(
277
- (pl.col("pitcher_id") == player_input)&
278
- (pl.col("is_pitch") == True)
279
-
280
-
281
- ))).with_columns(
282
- pl.col('pitch_type').count().over('pitch_type').alias('pitch_count')
283
- )
284
- return df
285
-
286
-
287
- @render.ui
288
- @reactive.event(input.player_button, input.year_input, input.level_input, input.type_input,ignore_none=False)
289
- def player_select_ui():
290
- # Get the list of pitchers for the selected level and season
291
- df_pitcher_info = scrape.get_players(sport_id=int(input.level_input()), season=int(input.year_input()), game_type = [input.type_input()]).filter(
292
- pl.col("position").is_in(['P','TWP'])).sort("name")
293
-
294
- # Create a dictionary of pitcher IDs and names
295
- pitcher_dict = dict(zip(df_pitcher_info['player_id'], df_pitcher_info['name']))
296
-
297
- # Return a select input for choosing a pitcher
298
- return ui.input_select("pitcher_id", "Select Pitcher", pitcher_dict, selectize=True)
299
-
300
- is_loading = reactive.value(False)
301
- data_result = reactive.value(None)
302
-
303
- @reactive.effect
304
- @reactive.event(input.get_pitches)
305
- def load_data():
306
- is_loading.set(True)
307
- data_result.set(None) # Clear any previous data
308
- try:
309
- # This will fetch the data
310
- result = cached_data()
311
- data_result.set(result)
312
- except Exception as e:
313
- # Handle any errors
314
- print(f"Error loading data: {e}")
315
- finally:
316
- is_loading.set(False)
317
-
318
- @output
319
- @render.ui
320
- def pitch_type_ui():
321
- # Make sure to add dependencies on both values
322
- input.get_pitches()
323
- loading = is_loading()
324
- data = data_result()
325
-
326
- # If loading, show spinner
327
- if loading:
328
- return ui.div(
329
- ui.span("Loading pitch types... ", class_="me-2"),
330
- ui.tags.div(class_="spinner-border spinner-border-sm text-primary"),
331
- style="padding: 10px; background-color: #f8f9fa; border-radius: 5px;"
332
- )
333
-
334
- # If data is loaded, show dropdown
335
- elif data is not None:
336
- df = data
337
- df = df.clone() if hasattr(df, 'clone') else df.copy()
338
- pitch_dict = dict(zip(df['pitch_type'], df['pitch_description']))
339
- return ui.input_select(
340
- "pitch_type_input",
341
- "Select Pitch Type",
342
- pitch_dict,
343
- selectize=True
344
- )
345
-
346
- # Initial state or after reset
347
- else:
348
- return ui.div(
349
- ui.p("Click 'Get Pitch Types' to load the dropdown.", class_="text-muted"),
350
- style="text-align: center; padding: 10px;"
351
- ) # Empty div with instructions
352
- @render.ui
353
- @reactive.event(input.player_button, input.year_input, input.level_input, input.type_input,ignore_none=False)
354
- def date_id():
355
- # Create a date range input for selecting the date range within the selected year
356
- return ui.input_date_range("date_id", "Select Date Range",
357
- start=f"{int(input.year_input())}-01-01",
358
- end=f"{int(input.year_input())}-12-31",
359
- min=f"{int(input.year_input())}-01-01",
360
- max=f"{int(input.year_input())}-12-31")
361
-
362
-
363
-
364
- @output
365
- @render.text
366
- def status():
367
- # Only show status when generating
368
- if input.generate == 0:
369
- return ""
370
- return ""
371
-
372
- @output
373
- @render.plot
374
- @reactive.event(input.generate_plot, ignore_none=False)
375
- def plot():
376
- # Show progress/loading notification
377
- with ui.Progress(min=0, max=1) as p:
378
- p.set(message="Generating plot", detail="This may take a while...")
379
-
380
-
381
- p.set(0.3, "Gathering data...")
382
- year_input = int(input.year_input())
383
- sport_id = int(input.level_input())
384
- player_input = int(input.pitcher_id())
385
- start_date = str(input.date_id()[0])
386
- end_date = str(input.date_id()[1])
387
-
388
-
389
- print(year_input, sport_id, player_input, start_date, end_date)
390
-
391
- df = cached_data()
392
- df = df.clone()
393
-
394
- pitch_input = input.pitch_type_input()
395
-
396
- df_plot = pitch_heat_map(pitch_input, df)
397
- pivot_table_l = pitch_prop(df=df_plot, hand = 'L')
398
- pivot_table_r = pitch_prop(df=df_plot, hand = 'R')
399
-
400
-
401
- table_left = df_update().update_summary_select(df=df_plot.filter(pl.col('batter_hand') == 'L'), selection=['pitcher_hand'])
402
- table_left = table_left.with_columns(
403
- (pl.col('pitches')/len(df.filter(pl.col('batter_hand') == 'L'))).alias('pitch_percent')
404
- )
405
-
406
- table_right = df_update().update_summary_select(df=df_plot.filter(pl.col('batter_hand') == 'R'), selection=['pitcher_hand'])
407
- table_right = table_right.with_columns(
408
- (pl.col('pitches')/len(df.filter(pl.col('batter_hand') == 'R'))).alias('pitch_percent')
409
- )
410
- try:
411
- normalize = mcolors.Normalize(vmin=table_left['pitch_percent']*0.5,
412
- vmax=table_left['pitch_percent']*1.5) # Define the range of values
413
-
414
-
415
- df_colour_left = pd.DataFrame(data=[[get_color(x,normalize,cmap_sum2) for x in pivot_table_l[0]],
416
- [get_color(x,normalize,cmap_sum2) for x in pivot_table_l[1]],
417
- [get_color(x,normalize,cmap_sum2) for x in pivot_table_l[2]]])
418
- df_colour_left[0] = '#ffffff'
419
- except ValueError:
420
- normalize = mcolors.Normalize(vmin=0,
421
- vmax=1) # Define the range of values
422
- df_colour_left = pd.DataFrame(data=[['#ffffff','#ffffff','#ffffff','#ffffff'],
423
- ['#ffffff','#ffffff','#ffffff','#ffffff'],
424
- ['#ffffff','#ffffff','#ffffff','#ffffff']])
425
-
426
- try:
427
- normalize = mcolors.Normalize(vmin=table_right['pitch_percent']*0.5,
428
- vmax=table_right['pitch_percent']*1.5) # Define the range of values
429
-
430
-
431
- df_colour_right = pd.DataFrame(data=[[get_color(x,normalize,cmap_sum2) for x in pivot_table_r[0]],
432
- [get_color(x,normalize,cmap_sum2) for x in pivot_table_r[1]],
433
- [get_color(x,normalize,cmap_sum2) for x in pivot_table_r[2]]])
434
- df_colour_right[0] = '#ffffff'
435
-
436
- except ValueError:
437
- normalize = mcolors.Normalize(vmin=0,
438
- vmax=1) # Define the range of values
439
- df_colour_right = pd.DataFrame(data=[['#ffffff','#ffffff','#ffffff','#ffffff'],
440
- ['#ffffff','#ffffff','#ffffff','#ffffff'],
441
- ['#ffffff','#ffffff','#ffffff','#ffffff']])
442
-
443
- table_left = table_left.select(
444
- 'pitch_percent',
445
- 'pitches',
446
- 'heart_zone_percent',
447
- 'shadow_zone_percent',
448
- 'chase_zone_percent',
449
- 'waste_zone_percent',
450
- 'csw_percent',
451
- 'whiff_rate',
452
- 'zone_whiff_percent',
453
- 'chase_percent',
454
- 'bip',
455
- 'xwoba_percent_contact').to_pandas().T
456
-
457
- table_right = table_right.select(
458
- 'pitch_percent',
459
- 'pitches',
460
- 'heart_zone_percent',
461
- 'shadow_zone_percent',
462
- 'chase_zone_percent',
463
- 'waste_zone_percent',
464
- 'csw_percent',
465
- 'whiff_rate',
466
- 'zone_whiff_percent',
467
- 'chase_percent',
468
- 'bip',
469
- 'xwoba_percent_contact').to_pandas().T
470
-
471
- table_right = table_right.replace({'nan%':'—'})
472
- table_right = table_right.replace({'nan':'—'})
473
-
474
-
475
-
476
-
477
-
478
- p.set(0.6, "Creating plot...")
479
-
480
- import matplotlib.pyplot as plt
481
- fig = plt.figure(figsize=(16, 9))
482
- fig.set_facecolor('white')
483
- sns.set_theme(style="whitegrid", palette=colour_palette)
484
- gs = GridSpec(3, 5, height_ratios=[2,9,1],width_ratios=[1,9,1,9,1])
485
- gs.update(hspace=0.2, wspace=0.3)
486
-
487
- # Add subplots to the grid
488
- ax_header = fig.add_subplot(gs[0, :])
489
- ax_left = fig.add_subplot(gs[1, 1])
490
- ax_right = fig.add_subplot(gs[1, 3])
491
-
492
- axfooter = fig.add_subplot(gs[-1, :])
493
-
494
-
495
- if input.plot_type() == 'Pitch%':
496
- heat_map_plot(df=df_plot,
497
- ax=ax_left,
498
- cmap=cmap_sum2,
499
- hand='L')
500
-
501
- heat_map_plot(df=df_plot,
502
- ax=ax_right,
503
- cmap=cmap_sum2,
504
- hand='R')
505
-
506
-
507
- if input.plot_type() == 'Whiff%':
508
- heat_map_plot_hex_whiff(df=df_plot,
509
- ax=ax_left,
510
- cmap=cmap_sum,
511
- hand='L')
512
-
513
- heat_map_plot_hex_whiff(df=df_plot,
514
- ax=ax_right,
515
- cmap=cmap_sum,
516
- hand='R')
517
-
518
- if input.plot_type() == 'xwOBACON':
519
- heat_map_plot_hex_damage(df=df_plot,
520
- ax=ax_left,
521
- cmap=cmap_sum,
522
- hand='L')
523
-
524
- heat_map_plot_hex_damage(df=df_plot,
525
- ax=ax_right,
526
- cmap=cmap_sum,
527
- hand='R')
528
-
529
-
530
- # Load the image
531
- img = mpimg.imread('images/left.png')
532
- imagebox = OffsetImage(img, zoom=0.58) # adjust zoom as needed
533
- ab = AnnotationBbox(imagebox, (1.25, -0.5), box_alignment=(0, 0), frameon=False)
534
- ax_left.add_artist(ab)
535
-
536
-
537
- # Load the image
538
- img = mpimg.imread('images/right.png')
539
- imagebox = OffsetImage(img, zoom=0.58) # adjust zoom as needed
540
- # Create an AnnotationBbox
541
- ab = AnnotationBbox(imagebox, (-1.25, -0.5), box_alignment=(1, 0), frameon=False)
542
-
543
- ax_right.add_artist(ab)
544
-
545
-
546
- table_plot(ax=ax_left,
547
- table=table_left,
548
- hand='L')
549
-
550
- table_plot_pivot(ax=ax_left,
551
- pivot_table=pivot_table_l,
552
- df_colour=df_colour_left)
553
-
554
-
555
- table_plot(ax=ax_right,
556
- table=table_right,
557
- hand='R')
558
-
559
- table_plot_pivot(ax=ax_right,
560
- pivot_table=pivot_table_r,
561
- df_colour=df_colour_right)
562
-
563
-
564
- from matplotlib.cm import ScalarMappable
565
- from matplotlib.colors import Normalize
566
- # Create a ScalarMappable with the same colormap and normalization
567
- if input.plot_type() == 'Pitch%':
568
- sm = ScalarMappable(cmap=cmap_sum2, norm=Normalize(vmin=0, vmax=1))
569
-
570
- cbar = fig.colorbar(sm, ax=axfooter, orientation='horizontal',aspect=100)
571
- cbar.set_ticks([])
572
-
573
- cbar.set_ticks([sm.norm.vmin, sm.norm.vmax])
574
-
575
- cbar.ax.set_xticklabels(['Least', 'Most'])
576
- cbar.ax.tick_params(labeltop=True, labelbottom=False, labelsize=14)
577
- labels = cbar.ax.get_xticklabels()
578
-
579
- labels[0].set_horizontalalignment('left')
580
- labels[-1].set_horizontalalignment('right')
581
- labels = cbar.ax.get_xticklabels()
582
-
583
-
584
- cbar.ax.set_xticklabels(labels)
585
- cbar.ax.tick_params(length=0)
586
-
587
- if input.plot_type() == 'Whiff%':
588
- sm = ScalarMappable(cmap=cmap_sum, norm=Normalize(vmin=0.15, vmax=0.35))
589
-
590
- cbar = fig.colorbar(sm, ax=axfooter, orientation='horizontal',aspect=100)
591
- cbar.set_ticks([])
592
-
593
- cbar.set_ticks([sm.norm.vmin, sm.norm.vmax])
594
-
595
- cbar.ax.set_xticklabels(['15%', '35%'])
596
- cbar.ax.tick_params(labeltop=True, labelbottom=False, labelsize=14)
597
- labels = cbar.ax.get_xticklabels()
598
-
599
- labels[0].set_horizontalalignment('left')
600
- labels[-1].set_horizontalalignment('right')
601
- labels = cbar.ax.get_xticklabels()
602
-
603
-
604
- cbar.ax.set_xticklabels(labels)
605
- cbar.ax.tick_params(length=0)
606
-
607
-
608
- if input.plot_type() == 'xwOBACON':
609
- sm = ScalarMappable(cmap=cmap_sum_r, norm=Normalize(vmin=0.25, vmax=0.5))
610
-
611
- cbar = fig.colorbar(sm, ax=axfooter, orientation='horizontal',aspect=100)
612
- cbar.set_ticks([])
613
-
614
- cbar.set_ticks([sm.norm.vmin, sm.norm.vmax])
615
-
616
- cbar.ax.set_xticklabels(['.000', '.500'])
617
- cbar.ax.tick_params(labeltop=True, labelbottom=False, labelsize=14)
618
- labels = cbar.ax.get_xticklabels()
619
-
620
- labels[0].set_horizontalalignment('left')
621
- labels[-1].set_horizontalalignment('right')
622
- labels = cbar.ax.get_xticklabels()
623
-
624
-
625
- cbar.ax.set_xticklabels(labels)
626
- cbar.ax.tick_params(length=0)
627
-
628
-
629
- axfooter.text(x=0.02,y=1,s='By: Thomas Nestico\n @TJStats',fontname='Calibri',ha='left',fontsize=14,va='top')
630
- axfooter.text(x=1-0.02,y=1,s='Data: MLB',ha='right',fontname='Calibri',fontsize=14,va='top')
631
-
632
- axfooter.axis('off')
633
-
634
- # Display the image on the axis
635
- ax_header.set_xlim(-12,12)
636
- ax_header.set_ylim(0, 2)
637
-
638
-
639
- if input.plot_type() == 'Pitch%':
640
- 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')
641
- if input.plot_type() == 'Whiff%':
642
- 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')
643
- if input.plot_type() == 'xwOBACON':
644
- 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')
645
-
646
- ax_header.text(x=0,y=0.7,s=f"{year_input} {level_dict[str(sport_id)]} Season",ha='center',fontsize=16,va='top')
647
- 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')
648
-
649
- ax_header.axis('off')
650
-
651
-
652
- import urllib
653
- import urllib.request
654
- import urllib.error
655
- from urllib.error import HTTPError
656
-
657
-
658
- plot_header(pitcher_id=player_input,
659
- ax=ax_header,
660
- df_team=scrape.get_teams(),
661
- df_players=scrape.get_players(sport_id,year_input),
662
- sport_id=sport_id,)
663
-
664
-
665
-
666
-
667
-
668
-
669
- fig.subplots_adjust(left=0.03, right=0.97, top=0.97, bottom=0.03)
670
-
671
-
672
-
673
-
674
- app = App(app_ui, server)
675
-
676
-
 
 
 
 
 
 
 
 
 
 
1
+ import polars as pl
2
+ import numpy as np
3
+ import pandas as pd
4
+ import api_scraper
5
+ scrape = api_scraper.MLB_Scrape()
6
+ from functions import df_update
7
+ from functions import pitch_summary_functions
8
+ update = df_update.df_update()
9
+ import requests
10
+ import joblib
11
+ from matplotlib.gridspec import GridSpec
12
+ from shiny import App, reactive, ui, render
13
+ from shiny.ui import h2, tags
14
+ import matplotlib.pyplot as plt
15
+ import matplotlib.gridspec as gridspec
16
+ import seaborn as sns
17
+ from functions.pitch_summary_functions import *
18
+ from functions.df_update import *
19
+ from shiny import App, reactive, ui, render
20
+ from shiny.ui import h2, tags
21
+ from functions.heat_map_functions import *
22
+
23
+ colour_palette = ['#FFB000','#648FFF','#785EF0',
24
+ '#DC267F','#FE6100','#3D1EB2','#894D80','#16AA02','#B5592B','#A3C1ED']
25
+
26
+
27
+ year_list = [2017,2018,2019,2020,2021,2022,2023,2024,2025]
28
+
29
+
30
+
31
+ level_dict = {'1':'MLB',
32
+ '11':'AAA',
33
+ '12':'AA',
34
+ '13':'A+',
35
+ '14':'A',
36
+ '17':'AFL',
37
+ '22':'College',
38
+ '21':'Prospects',
39
+ '51':'International' }
40
+
41
+ function_dict={
42
+ 'velocity_kdes':'Velocity Distributions',
43
+ 'break_plot':'Pitch Movement',
44
+ 'tj_stuff_roling':'Rolling tjStuff+ by Pitch',
45
+ 'tj_stuff_roling_game':'Rolling tjStuff+ by Game',
46
+ 'location_plot_lhb':'Locations vs LHB',
47
+ 'location_plot_rhb':'Locations vs RHB',
48
+ }
49
+
50
+
51
+ split_dict = {'all':'All',
52
+ 'left':'LHH',
53
+ 'right':'RHH'}
54
+
55
+ split_dict_hand = {'all':['L','R'],
56
+ 'left':['L'],
57
+ 'right':['R']}
58
+
59
+
60
+ type_dict = {'R':'Regular Season',
61
+ 'S':'Spring',
62
+ 'P':'Playoffs' }
63
+
64
+ format_dict = {
65
+ 'pitch_percent': '{:.1%}',
66
+ 'pitches': '{:.0f}',
67
+ 'heart_zone_percent': '{:.1%}',
68
+ 'shadow_zone_percent': '{:.1%}',
69
+ 'chase_zone_percent': '{:.1%}',
70
+ 'waste_zone_percent': '{:.1%}',
71
+ 'csw_percent': '{:.1%}',
72
+ 'whiff_rate': '{:.1%}',
73
+ 'zone_whiff_percent': '{:.1%}',
74
+ 'chase_percent': '{:.1%}',
75
+ 'bip': '{:.0f}',
76
+ 'xwoba_percent_contact': '{:.3f}'
77
+ }
78
+
79
+ format_dict = {
80
+ 'pitch_percent': '{:.1%}',
81
+ 'pitches': '{:.0f}',
82
+ 'heart_zone_percent': '{:.1%}',
83
+ 'shadow_zone_percent': '{:.1%}',
84
+ 'chase_zone_percent': '{:.1%}',
85
+ 'waste_zone_percent': '{:.1%}',
86
+ 'csw_percent': '{:.1%}',
87
+ 'whiff_rate': '{:.1%}',
88
+ 'zone_whiff_percent': '{:.1%}',
89
+ 'chase_percent': '{:.1%}',
90
+ 'bip': '{:.0f}',
91
+ 'xwoba_percent_contact': '{:.3f}'
92
+ }
93
+ label_translation_dict = {
94
+ 'pitch_percent': 'Pitch%',
95
+ 'pitches': 'Pitches',
96
+ 'heart_zone_percent': 'Heart%',
97
+ 'shadow_zone_percent': 'Shado%',
98
+ 'chase_zone_percent': 'Chas%',
99
+ 'waste_zone_percent': 'Waste%',
100
+ 'csw_percent': 'CSW%',
101
+ 'whiff_rate': 'Whiff%',
102
+ 'zone_whiff_percent': 'Z-Whiff%',
103
+ 'chase_percent': 'O-Swing%',
104
+ 'bip': 'BBE',
105
+ 'xwoba_percent_contact': 'xwOBACON'
106
+ }
107
+
108
+ cmap_sum22 = matplotlib.colors.LinearSegmentedColormap.from_list("", ['#648FFF','#FFB000',])
109
+ cmap_sum = matplotlib.colors.LinearSegmentedColormap.from_list("", ['#648FFF','#FFFFFF','#FFB000',])
110
+ cmap_sum2 = matplotlib.colors.LinearSegmentedColormap.from_list("", ['#FFFFFF','#FFB000','#FE6100'])
111
+ cmap_sum_r = matplotlib.colors.LinearSegmentedColormap.from_list("", ['#FFB000','#FFFFFF','#648FFF',])
112
+
113
+
114
+ import requests
115
+
116
+ import os
117
+ CAMPAIGN_ID = os.getenv("CAMPAIGN_ID")
118
+ ACCESS_TOKEN = os.getenv("ACCESS_TOKEN")
119
+ BACKUP_PW = os.getenv("BACKUP_PW")
120
+ ADMIN_PW = os.getenv("ADMIN_PW")
121
+
122
+ url = f"https://www.patreon.com/api/oauth2/v2/campaigns/{CAMPAIGN_ID}/members"
123
+
124
+ headers = {
125
+ "Authorization": f"Bearer {ACCESS_TOKEN}"
126
+ }
127
+
128
+ # Simple parameters, requesting the member's email and currently entitled tiers
129
+ params = {
130
+ "fields[member]": "full_name,email", # Request the member's email
131
+ "include": "currently_entitled_tiers", # Include the currently entitled tiers
132
+ "page[size]": 1000 # Fetch up to 1000 patrons per request
133
+ }
134
+
135
+ response = requests.get(url, headers=headers, params=params)
136
+
137
+
138
+ VALID_PASSWORDS = []
139
+ if response.status_code == 200:
140
+ data = response.json()
141
+ for patron in data['data']:
142
+ try:
143
+ tiers = patron['relationships']['currently_entitled_tiers']['data']
144
+ if any(tier['id'] == '9078921' for tier in tiers):
145
+ full_name = patron['attributes']['email']
146
+ VALID_PASSWORDS.append(full_name)
147
+ except KeyError:
148
+ continue
149
+ VALID_PASSWORDS.append(BACKUP_PW)
150
+ VALID_PASSWORDS.append(ADMIN_PW)
151
+ # VALID_PASSWORDS.append('')
152
+
153
+ from shiny import App, reactive, ui, render
154
+ from shiny.ui import h2, tags
155
+
156
+ # Define the login UI
157
+ login_ui = ui.page_fluid(
158
+ ui.card(
159
+ ui.h2([
160
+ "TJStats Pitching Heat Maps App ",
161
+ ui.tags.a("(@TJStats)", href="https://twitter.com/TJStats", target="_blank")
162
+ ]),
163
+ ui.p(
164
+ "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 ",
165
+ ui.tags.a("Patreon post", href="https://www.patreon.com/posts/117909954", target="_blank"),
166
+ "."
167
+ ),
168
+ ui.input_password("password", "Enter Patreon Email (or Password from Link):", width="25%"),
169
+ ui.tags.input(
170
+ type="checkbox",
171
+ id="authenticated",
172
+ value=False,
173
+ disabled=True
174
+ ),
175
+ ui.input_action_button("login", "Login", class_="btn-primary"),
176
+ ui.output_text("login_message"),
177
+ )
178
+ )
179
+
180
+
181
+ main_ui = ui.page_sidebar(
182
+ ui.sidebar(
183
+ # Row for selecting season and level
184
+ ui.row(
185
+ ui.column(4, ui.input_select('year_input', 'Select Season', year_list, selected=2024)),
186
+ ui.column(4, ui.input_select('level_input', 'Select Level', level_dict)),
187
+ ui.column(4, ui.input_select('type_input', 'Select Type', type_dict,selected='R'))
188
+ ),
189
+ # Row for the action button to get player list
190
+ ui.row(ui.input_action_button("player_button", "Get Player List", class_="btn-primary")),
191
+ # Row for selecting the player
192
+ ui.row(ui.column(12, ui.output_ui('player_select_ui', 'Select Player'))),
193
+
194
+
195
+ ui.row(ui.input_action_button("get_pitches", "Get Pitch Types", class_="btn-secondary")),
196
+
197
+
198
+ # Rows for selecting plots and split options
199
+ ui.row(ui.column(12, ui.output_ui('pitch_type_ui', 'Select Pitch Type'))),
200
+ ui.row(ui.column(6, ui.input_select('plot_type', 'Select Plot', ['Pitch%','Whiff%','xwOBACON'])),
201
+ ui.column(6, ui.input_switch('scatter_switch', 'Show Pitches', value=False))),
202
+ ui.row(ui.column(12, ui.output_ui('date_id', 'Select Date'))),
203
+
204
+ # Row for the action button to generate plot
205
+ ui.row(ui.input_action_button("generate_plot", "Generate Plot", class_="btn-primary")),
206
+ width="400px" # Added this parameter to control sidebar width
207
+ ),
208
+
209
+ # Main content (former panel_main content)
210
+ ui.navset_tab(
211
+ # Tab for game summary plot
212
+ ui.nav("Pitching Summary",
213
+ ui.output_text("status"),
214
+ ui.output_plot('plot', width='1440px', height=f'{900/1600*1440}px')
215
+ ),
216
+ )
217
+ )
218
+
219
+
220
+ # Combined UI with conditional panel
221
+ app_ui = ui.page_fluid(
222
+ ui.tags.head(
223
+ ui.tags.script(src="script.js")
224
+ ),
225
+
226
+ ui.panel_conditional(
227
+ "!input.authenticated",
228
+ login_ui
229
+ ),
230
+ ui.panel_conditional(
231
+ "input.authenticated",
232
+ main_ui
233
+ )
234
+ )
235
+
236
+
237
+ def server(input, output, session):
238
+
239
+ @reactive.Effect
240
+ @reactive.event(input.login)
241
+ def check_password():
242
+ if input.password() in VALID_PASSWORDS:
243
+ ui.update_checkbox("authenticated", value=True)
244
+ ui.update_text("login_message", value="")
245
+ else:
246
+ ui.update_text("login_message", value="Invalid password!")
247
+ ui.update_text("password", value="")
248
+
249
+ @output
250
+ @render.text
251
+ def login_message():
252
+ return ""
253
+
254
+ # Instead of using @reactive.calc with @reactive.event
255
+ cached_data_value = reactive.value(None) # Initialize with None
256
+
257
+ @reactive.calc
258
+ @reactive.event(input.date_id,input.pitcher_id)
259
+ def cached_data():
260
+
261
+ if not hasattr(input, 'pitcher_id') or input.pitcher_id() is None or not hasattr(input, 'date_id') or input.date_id() is None:
262
+ return # Exit early if required inputs aren't ready
263
+ year_input = int(input.year_input())
264
+ sport_id = int(input.level_input())
265
+ player_input = int(input.pitcher_id())
266
+ start_date = str(input.date_id()[0])
267
+ end_date = str(input.date_id()[1])
268
+ # Simulate an expensive data operation
269
+ game_list = scrape.get_player_games_list(sport_id = sport_id,
270
+ season = year_input,
271
+ player_id = player_input,
272
+ start_date = start_date,
273
+ end_date = end_date,
274
+ game_type = [input.type_input()])
275
+
276
+ data_list = scrape.get_data(game_list_input = game_list[:])
277
+ df = (update.update(scrape.get_data_df(data_list = data_list).filter(
278
+ (pl.col("pitcher_id") == player_input)&
279
+ (pl.col("is_pitch") == True)
280
+
281
+
282
+ ))).with_columns(
283
+ pl.col('pitch_type').count().over('pitch_type').alias('pitch_count')
284
+ )
285
+ return df
286
+
287
+
288
+ @render.ui
289
+ @reactive.event(input.player_button, input.year_input, input.level_input, input.type_input,ignore_none=False)
290
+ def player_select_ui():
291
+ # Get the list of pitchers for the selected level and season
292
+ df_pitcher_info = scrape.get_players(sport_id=int(input.level_input()), season=int(input.year_input()), game_type = [input.type_input()]).filter(
293
+ pl.col("position").is_in(['P','TWP'])).sort("name")
294
+
295
+ # Create a dictionary of pitcher IDs and names
296
+ pitcher_dict = dict(zip(df_pitcher_info['player_id'], df_pitcher_info['name']))
297
+
298
+ # Return a select input for choosing a pitcher
299
+ return ui.input_select("pitcher_id", "Select Pitcher", pitcher_dict, selectize=True)
300
+
301
+ is_loading = reactive.value(False)
302
+ data_result = reactive.value(None)
303
+
304
+ @reactive.effect
305
+ @reactive.event(input.get_pitches)
306
+ def load_data():
307
+ is_loading.set(True)
308
+ data_result.set(None) # Clear any previous data
309
+ try:
310
+ # This will fetch the data
311
+ result = cached_data()
312
+ data_result.set(result)
313
+ except Exception as e:
314
+ # Handle any errors
315
+ print(f"Error loading data: {e}")
316
+ finally:
317
+ is_loading.set(False)
318
+
319
+ @output
320
+ @render.ui
321
+ def pitch_type_ui():
322
+ # Make sure to add dependencies on both values
323
+ input.get_pitches()
324
+ loading = is_loading()
325
+ data = data_result()
326
+
327
+ # If loading, show spinner
328
+ if loading:
329
+ return ui.div(
330
+ ui.span("Loading pitch types... ", class_="me-2"),
331
+ ui.tags.div(class_="spinner-border spinner-border-sm text-primary"),
332
+ style="padding: 10px; background-color: #f8f9fa; border-radius: 5px;"
333
+ )
334
+
335
+ # If data is loaded, show dropdown
336
+ elif data is not None:
337
+ df = data
338
+ df = df.clone() if hasattr(df, 'clone') else df.copy()
339
+ pitch_dict = dict(zip(df['pitch_type'], df['pitch_description']))
340
+ return ui.input_select(
341
+ "pitch_type_input",
342
+ "Select Pitch Type",
343
+ pitch_dict,
344
+ selectize=True
345
+ )
346
+
347
+ # Initial state or after reset
348
+ else:
349
+ return ui.div(
350
+ ui.p("Click 'Get Pitch Types' to load the dropdown.", class_="text-muted"),
351
+ style="text-align: center; padding: 10px;"
352
+ ) # Empty div with instructions
353
+ @render.ui
354
+ @reactive.event(input.player_button, input.year_input, input.level_input, input.type_input,ignore_none=False)
355
+ def date_id():
356
+ # Create a date range input for selecting the date range within the selected year
357
+ return ui.input_date_range("date_id", "Select Date Range",
358
+ start=f"{int(input.year_input())}-01-01",
359
+ end=f"{int(input.year_input())}-12-31",
360
+ min=f"{int(input.year_input())}-01-01",
361
+ max=f"{int(input.year_input())}-12-31")
362
+
363
+
364
+
365
+ @output
366
+ @render.text
367
+ def status():
368
+ # Only show status when generating
369
+ if input.generate == 0:
370
+ return ""
371
+ return ""
372
+
373
+ @output
374
+ @render.plot
375
+ @reactive.event(input.generate_plot, ignore_none=False)
376
+ def plot():
377
+ # Show progress/loading notification
378
+ with ui.Progress(min=0, max=1) as p:
379
+ p.set(message="Generating plot", detail="This may take a while...")
380
+
381
+
382
+ p.set(0.3, "Gathering data...")
383
+ year_input = int(input.year_input())
384
+ sport_id = int(input.level_input())
385
+ player_input = int(input.pitcher_id())
386
+ start_date = str(input.date_id()[0])
387
+ end_date = str(input.date_id()[1])
388
+
389
+ scatter_bool = input.scatter_switch()
390
+
391
+
392
+ print(year_input, sport_id, player_input, start_date, end_date)
393
+
394
+ df = cached_data()
395
+ df = df.clone()
396
+
397
+ pitch_input = input.pitch_type_input()
398
+
399
+ df_plot = pitch_heat_map(pitch_input, df)
400
+ pivot_table_l = pitch_prop(df=df_plot, hand = 'L')
401
+ pivot_table_r = pitch_prop(df=df_plot, hand = 'R')
402
+
403
+
404
+ table_left = df_update().update_summary_select(df=df_plot.filter(pl.col('batter_hand') == 'L'), selection=['pitcher_hand'])
405
+ table_left = table_left.with_columns(
406
+ (pl.col('pitches')/len(df.filter(pl.col('batter_hand') == 'L'))).alias('pitch_percent')
407
+ )
408
+
409
+ table_right = df_update().update_summary_select(df=df_plot.filter(pl.col('batter_hand') == 'R'), selection=['pitcher_hand'])
410
+ table_right = table_right.with_columns(
411
+ (pl.col('pitches')/len(df.filter(pl.col('batter_hand') == 'R'))).alias('pitch_percent')
412
+ )
413
+ try:
414
+ normalize = mcolors.Normalize(vmin=table_left['pitch_percent']*0.5,
415
+ vmax=table_left['pitch_percent']*1.5) # Define the range of values
416
+
417
+
418
+ df_colour_left = pd.DataFrame(data=[[get_color(x,normalize,cmap_sum2) for x in pivot_table_l[0]],
419
+ [get_color(x,normalize,cmap_sum2) for x in pivot_table_l[1]],
420
+ [get_color(x,normalize,cmap_sum2) for x in pivot_table_l[2]]])
421
+ df_colour_left[0] = '#ffffff'
422
+ except ValueError:
423
+ normalize = mcolors.Normalize(vmin=0,
424
+ vmax=1) # Define the range of values
425
+ df_colour_left = pd.DataFrame(data=[['#ffffff','#ffffff','#ffffff','#ffffff'],
426
+ ['#ffffff','#ffffff','#ffffff','#ffffff'],
427
+ ['#ffffff','#ffffff','#ffffff','#ffffff']])
428
+
429
+ try:
430
+ normalize = mcolors.Normalize(vmin=table_right['pitch_percent']*0.5,
431
+ vmax=table_right['pitch_percent']*1.5) # Define the range of values
432
+
433
+
434
+ df_colour_right = pd.DataFrame(data=[[get_color(x,normalize,cmap_sum2) for x in pivot_table_r[0]],
435
+ [get_color(x,normalize,cmap_sum2) for x in pivot_table_r[1]],
436
+ [get_color(x,normalize,cmap_sum2) for x in pivot_table_r[2]]])
437
+ df_colour_right[0] = '#ffffff'
438
+
439
+ except ValueError:
440
+ normalize = mcolors.Normalize(vmin=0,
441
+ vmax=1) # Define the range of values
442
+ df_colour_right = pd.DataFrame(data=[['#ffffff','#ffffff','#ffffff','#ffffff'],
443
+ ['#ffffff','#ffffff','#ffffff','#ffffff'],
444
+ ['#ffffff','#ffffff','#ffffff','#ffffff']])
445
+
446
+ table_left = table_left.select(
447
+ 'pitch_percent',
448
+ 'pitches',
449
+ 'heart_zone_percent',
450
+ 'shadow_zone_percent',
451
+ 'chase_zone_percent',
452
+ 'waste_zone_percent',
453
+ 'csw_percent',
454
+ 'whiff_rate',
455
+ 'zone_whiff_percent',
456
+ 'chase_percent',
457
+ 'bip',
458
+ 'xwoba_percent_contact').to_pandas().T
459
+
460
+ table_right = table_right.select(
461
+ 'pitch_percent',
462
+ 'pitches',
463
+ 'heart_zone_percent',
464
+ 'shadow_zone_percent',
465
+ 'chase_zone_percent',
466
+ 'waste_zone_percent',
467
+ 'csw_percent',
468
+ 'whiff_rate',
469
+ 'zone_whiff_percent',
470
+ 'chase_percent',
471
+ 'bip',
472
+ 'xwoba_percent_contact').to_pandas().T
473
+
474
+ table_right = table_right.replace({'nan%':'—'})
475
+ table_right = table_right.replace({'nan':'—'})
476
+
477
+
478
+
479
+
480
+
481
+ p.set(0.6, "Creating plot...")
482
+
483
+ import matplotlib.pyplot as plt
484
+ fig = plt.figure(figsize=(16, 9))
485
+ fig.set_facecolor('white')
486
+ sns.set_theme(style="whitegrid", palette=colour_palette)
487
+ gs = GridSpec(3, 5, height_ratios=[2,9,1],width_ratios=[1,9,1,9,1])
488
+ gs.update(hspace=0.2, wspace=0.3)
489
+
490
+ # Add subplots to the grid
491
+ ax_header = fig.add_subplot(gs[0, :])
492
+ ax_left = fig.add_subplot(gs[1, 1])
493
+ ax_right = fig.add_subplot(gs[1, 3])
494
+
495
+ axfooter = fig.add_subplot(gs[-1, :])
496
+
497
+
498
+ if input.plot_type() == 'Pitch%':
499
+ heat_map_plot(df=df_plot,
500
+ ax=ax_left,
501
+ cmap=cmap_sum2,
502
+ hand='L',
503
+ scatter=scatter_bool)
504
+
505
+ heat_map_plot(df=df_plot,
506
+ ax=ax_right,
507
+ cmap=cmap_sum2,
508
+ hand='R',
509
+ scatter=scatter_bool))
510
+
511
+
512
+ if input.plot_type() == 'Whiff%':
513
+ heat_map_plot_hex_whiff(df=df_plot,
514
+ ax=ax_left,
515
+ cmap=cmap_sum,
516
+ hand='L',
517
+ scatter=scatter_bool))
518
+
519
+ heat_map_plot_hex_whiff(df=df_plot,
520
+ ax=ax_right,
521
+ cmap=cmap_sum,
522
+ hand='R',
523
+ scatter=scatter_bool))
524
+
525
+ if input.plot_type() == 'xwOBACON':
526
+ heat_map_plot_hex_damage(df=df_plot,
527
+ ax=ax_left,
528
+ cmap=cmap_sum,
529
+ hand='L',
530
+ scatter=scatter_bool))
531
+
532
+ heat_map_plot_hex_damage(df=df_plot,
533
+ ax=ax_right,
534
+ cmap=cmap_sum,
535
+ hand='R',
536
+ scatter=scatter_bool))
537
+
538
+
539
+ # Load the image
540
+ img = mpimg.imread('images/left.png')
541
+ imagebox = OffsetImage(img, zoom=0.58) # adjust zoom as needed
542
+ ab = AnnotationBbox(imagebox, (1.25, -0.5), box_alignment=(0, 0), frameon=False)
543
+ ax_left.add_artist(ab)
544
+
545
+
546
+ # Load the image
547
+ img = mpimg.imread('images/right.png')
548
+ imagebox = OffsetImage(img, zoom=0.58) # adjust zoom as needed
549
+ # Create an AnnotationBbox
550
+ ab = AnnotationBbox(imagebox, (-1.25, -0.5), box_alignment=(1, 0), frameon=False)
551
+
552
+ ax_right.add_artist(ab)
553
+
554
+
555
+ table_plot(ax=ax_left,
556
+ table=table_left,
557
+ hand='L')
558
+
559
+ table_plot_pivot(ax=ax_left,
560
+ pivot_table=pivot_table_l,
561
+ df_colour=df_colour_left)
562
+
563
+
564
+ table_plot(ax=ax_right,
565
+ table=table_right,
566
+ hand='R')
567
+
568
+ table_plot_pivot(ax=ax_right,
569
+ pivot_table=pivot_table_r,
570
+ df_colour=df_colour_right)
571
+
572
+
573
+ from matplotlib.cm import ScalarMappable
574
+ from matplotlib.colors import Normalize
575
+ # Create a ScalarMappable with the same colormap and normalization
576
+ if input.plot_type() == 'Pitch%':
577
+ sm = ScalarMappable(cmap=cmap_sum2, norm=Normalize(vmin=0, vmax=1))
578
+
579
+ cbar = fig.colorbar(sm, ax=axfooter, orientation='horizontal',aspect=100)
580
+ cbar.set_ticks([])
581
+
582
+ cbar.set_ticks([sm.norm.vmin, sm.norm.vmax])
583
+
584
+ cbar.ax.set_xticklabels(['Least', 'Most'])
585
+ cbar.ax.tick_params(labeltop=True, labelbottom=False, labelsize=14)
586
+ labels = cbar.ax.get_xticklabels()
587
+
588
+ labels[0].set_horizontalalignment('left')
589
+ labels[-1].set_horizontalalignment('right')
590
+ labels = cbar.ax.get_xticklabels()
591
+
592
+
593
+ cbar.ax.set_xticklabels(labels)
594
+ cbar.ax.tick_params(length=0)
595
+
596
+ if input.plot_type() == 'Whiff%':
597
+ sm = ScalarMappable(cmap=cmap_sum, norm=Normalize(vmin=0.15, vmax=0.35))
598
+
599
+ cbar = fig.colorbar(sm, ax=axfooter, orientation='horizontal',aspect=100)
600
+ cbar.set_ticks([])
601
+
602
+ cbar.set_ticks([sm.norm.vmin, sm.norm.vmax])
603
+
604
+ cbar.ax.set_xticklabels(['15%', '35%'])
605
+ cbar.ax.tick_params(labeltop=True, labelbottom=False, labelsize=14)
606
+ labels = cbar.ax.get_xticklabels()
607
+
608
+ labels[0].set_horizontalalignment('left')
609
+ labels[-1].set_horizontalalignment('right')
610
+ labels = cbar.ax.get_xticklabels()
611
+
612
+
613
+ cbar.ax.set_xticklabels(labels)
614
+ cbar.ax.tick_params(length=0)
615
+
616
+
617
+ if input.plot_type() == 'xwOBACON':
618
+ sm = ScalarMappable(cmap=cmap_sum_r, norm=Normalize(vmin=0.25, vmax=0.5))
619
+
620
+ cbar = fig.colorbar(sm, ax=axfooter, orientation='horizontal',aspect=100)
621
+ cbar.set_ticks([])
622
+
623
+ cbar.set_ticks([sm.norm.vmin, sm.norm.vmax])
624
+
625
+ cbar.ax.set_xticklabels(['.000', '.500'])
626
+ cbar.ax.tick_params(labeltop=True, labelbottom=False, labelsize=14)
627
+ labels = cbar.ax.get_xticklabels()
628
+
629
+ labels[0].set_horizontalalignment('left')
630
+ labels[-1].set_horizontalalignment('right')
631
+ labels = cbar.ax.get_xticklabels()
632
+
633
+
634
+ cbar.ax.set_xticklabels(labels)
635
+ cbar.ax.tick_params(length=0)
636
+
637
+
638
+ axfooter.text(x=0.02,y=1,s='By: Thomas Nestico\n @TJStats',fontname='Calibri',ha='left',fontsize=14,va='top')
639
+ axfooter.text(x=1-0.02,y=1,s='Data: MLB',ha='right',fontname='Calibri',fontsize=14,va='top')
640
+
641
+ axfooter.axis('off')
642
+
643
+ # Display the image on the axis
644
+ ax_header.set_xlim(-12,12)
645
+ ax_header.set_ylim(0, 2)
646
+
647
+
648
+ if input.plot_type() == 'Pitch%':
649
+ 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')
650
+ if input.plot_type() == 'Whiff%':
651
+ 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')
652
+ if input.plot_type() == 'xwOBACON':
653
+ 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')
654
+
655
+ ax_header.text(x=0,y=0.7,s=f"{year_input} {level_dict[str(sport_id)]} Season",ha='center',fontsize=16,va='top')
656
+ 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')
657
+
658
+ ax_header.axis('off')
659
+
660
+
661
+ import urllib
662
+ import urllib.request
663
+ import urllib.error
664
+ from urllib.error import HTTPError
665
+
666
+
667
+ plot_header(pitcher_id=player_input,
668
+ ax=ax_header,
669
+ df_team=scrape.get_teams(),
670
+ df_players=scrape.get_players(sport_id,year_input),
671
+ sport_id=sport_id,)
672
+
673
+
674
+
675
+
676
+
677
+
678
+ fig.subplots_adjust(left=0.03, right=0.97, top=0.97, bottom=0.03)
679
+
680
+
681
+
682
+
683
+ app = App(app_ui, server)
684
+
685
+