TimStats commited on
Commit
6a1d4a4
·
verified ·
1 Parent(s): 3d49390

Update app.R

Browse files
Files changed (1) hide show
  1. app.R +186 -364
app.R CHANGED
@@ -1,6 +1,5 @@
1
  # app.R
2
  library(shiny)
3
- #library(tidyverse)
4
  library(ggplot2)
5
  library(dplyr)
6
  library(patchwork)
@@ -14,72 +13,18 @@ library(bslib)
14
  library(jsonlite)
15
  library(arrow)
16
 
17
- check_patreon_access <- function(email) {
18
- campaign_id <- Sys.getenv("PATREON_CAMPAIGN_ID")
19
- access_token <- Sys.getenv("PATREON_ACCESS_TOKEN")
20
-
21
- base_url <- paste0("https://www.patreon.com/api/oauth2/v2/campaigns/", campaign_id, "/members")
22
-
23
- params <- list(
24
- `include` = "currently_entitled_tiers",
25
- `fields[member]` = "patron_status,email",
26
- `fields[tier]` = "title"
27
- )
28
-
29
- response <- GET(
30
- base_url,
31
- query = params,
32
- add_headers(
33
- `Authorization` = paste("Bearer", access_token),
34
- `User-Agent` = "R/httr"
35
- )
36
- )
37
-
38
- content <- fromJSON(rawToChar(response$content))
39
-
40
- # Check in data$attributes for matching email
41
- matching_row <- which(content$data$attributes$email == email)
42
-
43
- if (length(matching_row) > 0) {
44
- # Get patron status
45
- patron_status <- content$data$attributes$patron_status[matching_row]
46
-
47
- if (patron_status == "active_patron") {
48
- # Get tier info
49
- tier_data <- content$data$relationships$currently_entitled_tiers$data[[matching_row]]
50
-
51
- tier_id <- tier_data$id
52
-
53
- result <- tier_id %in% c("25062087", "25062090")
54
-
55
- return(result)
56
- }
57
- }
58
-
59
- return(FALSE)
60
- }
61
-
62
  download_private_parquet <- function(repo_id, filename) {
63
- library(httr)
64
- library(arrow)
65
-
66
- # Create the direct download URL based on your example
67
  url <- paste0("https://huggingface.co/datasets/", repo_id, "/resolve/main/", filename, "?download=true")
68
-
69
- # Create a temporary file
70
  temp_file <- tempfile(fileext = ".parquet")
71
 
72
- # Download directly to file
73
  response <- GET(
74
  url,
75
  add_headers(Authorization = paste("Bearer", Sys.getenv("GETCSV"))),
76
  write_disk(temp_file, overwrite = TRUE)
77
  )
78
 
79
- # Check if download was successful
80
  if (status_code(response) == 200) {
81
  tryCatch({
82
- # Read the parquet file
83
  data <- read_parquet(temp_file)
84
  file.remove(temp_file)
85
  return(data)
@@ -96,27 +41,22 @@ download_private_parquet <- function(repo_id, filename) {
96
  font_add_google("Roboto Condensed")
97
 
98
  is_barrel <- function(df) {
99
- df$barrel <- with(df, ifelse(hit_angle <= 50 & hit_speed >= 97 & hit_speed * 1.5 -
100
- hit_angle >= 117 & hit_speed + hit_angle >= 123, 1, 0))
101
- return(df)
102
  }
103
 
104
  apply_percentile_calcs <- function(data) {
105
- # List of columns to apply percent_rank
106
  percent_rank_cols <- c("Z-Con%", "Z-Swing%", "O-Con%", "Avg EV", "Max EV", "EV90", "Barrel%", "Swing%", "wOBA",
107
- "wOBACON","xwOBA","xDamage")
108
-
109
- # List of columns to apply inverse percent_rank
110
  inverse_percent_rank_cols <- c("Chase%", "Whiff%", "stdev(LA)", "SwStr%")
111
 
112
- # Create an empty list to store results
113
  percentile_list <- list()
114
 
115
- # Calculate regular percentiles
116
- for(col in percent_rank_cols) {
117
  percentile_list[[col]] <- data.frame(
118
- `Batter Name` = data[["Batter Name"]], # Using [[ ]] to preserve exact column name
119
- `Batter ID` = data[["Batter ID"]], # Using [[ ]] to preserve exact column name
120
  metric = col,
121
  percentile = round(percent_rank(data[[col]]) * 100),
122
  value = data[[col]],
@@ -124,11 +64,10 @@ apply_percentile_calcs <- function(data) {
124
  )
125
  }
126
 
127
- # Calculate inverse percentiles
128
- for(col in inverse_percent_rank_cols) {
129
  percentile_list[[col]] <- data.frame(
130
- `Batter Name` = data[["Batter Name"]], # Using [[ ]] to preserve exact column name
131
- `Batter ID` = data[["Batter ID"]], # Using [[ ]] to preserve exact column name
132
  metric = col,
133
  percentile = round((1 - percent_rank(data[[col]])) * 100),
134
  value = data[[col]],
@@ -136,80 +75,60 @@ apply_percentile_calcs <- function(data) {
136
  )
137
  }
138
 
139
- # Combine all results into one data frame
140
  result <- do.call(rbind, percentile_list)
141
-
142
- # Reset row names
143
  rownames(result) <- NULL
144
-
145
  return(result)
146
  }
147
 
148
  get_player_image <- function(player_id) {
149
- # Try MLB silo image first
150
  silo_url <- sprintf("https://img.mlbstatic.com/mlb-photos/image/upload/w_200,q_auto:best/v1/people/%s/headshot/silo/current", player_id)
151
 
152
- # Check if silo works
153
  silo_result <- tryCatch({
154
  response <- httr::HEAD(silo_url)
155
  httr::status_code(response) == 200
156
  }, error = function(e) FALSE)
157
 
158
- # If silo fails, use MiLB with correct formatting
159
  if (!silo_result) {
160
  return(sprintf("https://img.mlbstatic.com/mlb-photos/image/upload/c_fill,g_auto,b_white,ar_1:1/w_180/v1/people/%s/headshot/milb/current", player_id))
161
  }
162
 
163
- # Return silo if it worked
164
  return(silo_url)
165
  }
166
 
167
  get_player_info <- function(player_id, season, level = "MLB") {
168
- # Initialize return values
169
  team <- "MLB"
170
  position <- NA
171
 
172
- # If MLB level, use original endpoint
173
- if(level == "MLB") {
174
- url <- paste0("https://statsapi.mlb.com/api/v1/people/", player_id,
175
  "/stats?stats=season&season=", season, "&group=hitting")
176
-
177
  response <- httr::GET(url)
178
  data <- httr::content(response, "parsed")
179
 
180
- if(length(data$stats) > 0 && length(data$stats[[1]]$splits) > 0) {
181
  team <- data$stats[[1]]$splits[[length(data$stats[[1]]$splits)]]$team$name
182
  }
183
  } else {
184
- # For minor leagues, get player info from sports endpoint
185
- sport_code <- if(level == "AAA") "11" else "14" # 11 for AAA, 14 for FSL
186
  url <- paste0("https://statsapi.mlb.com/api/v1/sports/", sport_code, "/players?season=", season)
187
-
188
  response <- httr::GET(url)
189
- # Convert response to data frame
190
  players_df <- jsonlite::fromJSON(rawToChar(response$content), flatten = TRUE)$people
191
-
192
- # Find player directly
193
  found_player <- players_df[players_df$id == player_id, ]
194
 
195
- if(nrow(found_player) > 0) {
196
  team_id <- found_player$currentTeam.id
197
-
198
- # Get parent org using team id
199
  team_url <- paste0("https://statsapi.mlb.com/api/v1/teams/", team_id, "?season=", season)
200
  team_response <- httr::GET(team_url)
201
  team_data <- jsonlite::fromJSON(rawToChar(team_response$content))
202
-
203
  team <- team_data$teams$parentOrgName
204
  }
205
  }
206
 
207
- # Get position info (same for all levels)
208
  url2 <- paste0("https://statsapi.mlb.com/api/v1/people/", player_id)
209
  response2 <- httr::GET(url2)
210
  data2 <- httr::content(response2, "parsed")
211
 
212
- if(length(data2$people) > 0) {
213
  full_position <- data2$people[[1]]$primaryPosition$name
214
  position <- case_when(
215
  full_position == "First Base" ~ "1B",
@@ -229,10 +148,7 @@ get_player_info <- function(player_id, season, level = "MLB") {
229
  )
230
  }
231
 
232
- return(list(
233
- team = team,
234
- position = position
235
- ))
236
  }
237
 
238
  download_private_csv <- function(repo_id, filename) {
@@ -242,13 +158,7 @@ download_private_csv <- function(repo_id, filename) {
242
  if (status_code(response) == 200) {
243
  content <- content(response, "text")
244
  con <- textConnection(content)
245
-
246
- # Try different read options
247
- data <- read.csv(con,
248
- header = TRUE,
249
- check.names = FALSE, # This prevents R from modifying column names
250
- fileEncoding = "UTF-8",
251
- stringsAsFactors = FALSE)
252
  close(con)
253
  return(data)
254
  } else {
@@ -256,49 +166,46 @@ download_private_csv <- function(repo_id, filename) {
256
  }
257
  }
258
 
 
259
  MLB25 <- download_private_parquet("TimStats/StatcastDataAll", "MLB25.parquet")
260
  MLB25$level <- "MLB"
261
  AAA25 <- download_private_parquet("TimStats/StatcastDataAll", "AAA25.parquet")
262
  AAA25$level <- "AAA"
263
  FSL25 <- download_private_parquet("TimStats/StatcastDataAll", "FSL25.parquet")
264
  FSL25$level <- "FSL"
265
- #ST <- read.csv("SpringT25.csv", header = TRUE, check.names = FALSE, fileEncoding = "UTF-8")
266
 
267
  MLB26 <- download_private_parquet("TimStats/StatcastDataAll", "MLB26.parquet")
268
  MLB26$level <- "MLB"
269
  AAA26 <- download_private_parquet("TimStats/StatcastDataAll", "AAA26.parquet")
270
  AAA26$level <- "AAA"
271
- #FSL26 <- download_private_parquet("TimStats/StatcastDataAll", "FSL26.parquet")
272
- #FSL26$level <- "FSL"
273
 
274
-
275
- #names(ST)
276
  MLB <- download_private_parquet("TimStats/StatcastDataAll", "MLB.parquet")
277
  MLB$level <- "MLB"
278
  AAA <- download_private_parquet("TimStats/StatcastDataAll", "AAA.parquet")
279
  AAA$level <- "AAA"
280
  FSLAll <- download_private_parquet("TimStats/StatcastDataAll", "FSL.parquet")
281
  FSLAll$level <- "FSL"
282
- MLB <- rbind(MLB,MLB25,MLB26)
 
283
  print("aaa")
284
- AAA <- rbind(AAA,AAA25,AAA26)
285
  print("fsl")
286
- FSL <- rbind(FSLAll,FSL25)
287
 
288
-
289
  temp_players <- MLB %>% filter(season == 2026)
290
- MLBC <- rbind(MLB,AAA,FSLAll)
 
291
  data <- is_barrel(MLBC) %>%
292
  mutate(
293
- BBE = case_when(description %in% c('In play, run(s)','In play, out(s)','In play, no out') ~ TRUE, TRUE ~ FALSE),
294
- Swing = case_when(description %in% c('Foul','Foul Bunt','Foul Pitchout','Foul Tip',
295
- 'In play, run(s)','In play, out(s)','In play, no out',
296
- 'Swinging Strike','Swinging Strike (Blocked)',
297
  'Missed Bunt') ~ TRUE, TRUE ~ FALSE),
298
- Contact = case_when(description %in% c('In play, run(s)','In play, out(s)','In play, no out',
299
- 'Foul','Foul Bunt','Foul Pitchout') ~ TRUE, TRUE ~ FALSE),
300
- Whiff = case_when(description %in% c('Swinging Strike','Swinging Strike (Blocked)',
301
- 'Missed Bunt','Foul Tip') ~ TRUE, TRUE ~ FALSE),
302
  IZ = ifelse(zone <= 9, TRUE, FALSE),
303
  Single = case_when(result == "Single" & BBE == TRUE ~ TRUE, TRUE ~ FALSE),
304
  Double = case_when(result == "Double" & BBE == TRUE ~ TRUE, TRUE ~ FALSE),
@@ -306,208 +213,131 @@ data <- is_barrel(MLBC) %>%
306
  `Home Run` = case_when(result == "Home Run" & BBE == TRUE ~ TRUE, TRUE ~ FALSE),
307
  Walk = case_when(balls >= 4 & result == "Walk" ~ TRUE, TRUE ~ FALSE),
308
  HBP = case_when(description == "Hit By Pitch" & result == "Hit By Pitch" ~ TRUE, TRUE ~ FALSE),
309
- Strikeout = case_when(strikes >= 3 & result %in% c("Strikeout",'Stikeout Double Play') ~ TRUE, TRUE ~ FALSE),
310
- Sac = case_when(BBE == TRUE & result %in% c('Sac Fly','Sac Bunt',
311
- 'Sac Fly Double Play','Sac Bunt Double Play') ~ TRUE, TRUE ~ FALSE),
312
  IBB = case_when(pitchNum == 1 & result == "Intent Walk" ~ TRUE, TRUE ~ FALSE),
313
  AB = Strikeout + BBE - Sac,
314
  PA = AB + Walk + HBP + IBB
315
  ) %>%
316
- group_by(`Batter Name`,`Batter ID`,season,level) %>%
317
  summarise(
318
- BIP = sum(BBE,na.rm = TRUE),
319
- wOBA = round((sum(Single,na.rm = TRUE) * .882 + sum(Double, na.rm = TRUE) * 1.254 +
320
- sum(Triple,na.rm = TRUE) * 1.59 + sum(`Home Run`,na.rm = TRUE) * 2.05 +
321
- sum(Walk,na.rm = TRUE) * .689 + sum(HBP,na.rm = TRUE) * .720) /
322
- (sum(PA,na.rm = TRUE) - sum(IBB,na.rm = TRUE)), 3),
323
- wOBACON = round((sum(Single,na.rm = TRUE) * .882 + sum(Double, na.rm = TRUE) * 1.254 +
324
- sum(Triple,na.rm = TRUE) * 1.59 + sum(`Home Run`,na.rm = TRUE) * 2.05 )/
325
- sum(BBE,na.rm = TRUE), 3),
326
- xwOBA = round(mean(expected_woba,na.rm = TRUE), 3),
327
- xDamage = round(mean(expected_woba[BBE == TRUE],na.rm = TRUE), 3),
328
- `Avg EV` = round(mean(hit_speed,na.rm = TRUE), 1),
329
- EV90 = round(quantile(hit_speed,0.9,na.rm = TRUE), 1),
330
- `Max EV` = round(max(hit_speed,na.rm = TRUE), 1),
331
- 'stdev(LA)' = round(sd(hit_angle,na.rm = TRUE), 1),
332
- 'Barrel%' = round(100 * mean(barrel[Swing == TRUE],na.rm = TRUE), 1),
333
- "Z-Con%" = round(100 * mean(Contact[IZ == TRUE & Swing == TRUE],na.rm = TRUE), 1),
334
- "Z-Swing%" = round(100 * mean(Swing[IZ == TRUE],na.rm = TRUE), 1),
335
- "O-Con%" = round(100 * mean(Contact[IZ == FALSE & Swing == TRUE],na.rm = TRUE), 1),
336
- "Chase%" = round(100 * mean(Swing[IZ == FALSE],na.rm = TRUE), 1),
337
- "Whiff%" = round(100 * mean(Whiff[Swing == TRUE],na.rm = TRUE), 1),
338
- "Swing%" = round(100 * mean(Swing,na.rm = TRUE), 1),
339
- "SwStr%" = round(100 * mean(Whiff,na.rm = TRUE), 1)
340
  )
341
 
342
- # UI definition
343
  ui <- fluidPage(
344
  theme = bs_theme(bg = "#ffffff", fg = "#333333", primary = "#428bca"),
345
- tagList(
346
- tags$head(
347
- tags$link(href = "https://fonts.googleapis.com/css2?family=Roboto+Condensed:wght@400;700&display=swap",
348
- rel = "stylesheet"),
349
- tags$style(HTML("
350
- * { font-family: 'Roboto Condensed', sans-serif !important; }
351
- .login-screen {
352
- max-width: 400px;
353
- margin: 100px auto;
354
- padding: 20px;
355
- border: 1px solid #ddd;
356
- border-radius: 5px;
357
- box-shadow: 0 0 10px rgba(0,0,0,0.1);
358
- }
359
- .login-header {
360
- text-align: center;
361
- margin-bottom: 20px;
362
- }
363
- "))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
364
  ),
365
-
366
- # Use uiOutput to conditionally render either login or main page
367
- uiOutput("page")
 
 
368
  )
369
  )
370
 
371
- # Server logic
372
- server <- function(input, output,session) {
373
 
374
- # Reactive value to track authentication status
375
- credentials <- reactiveValues(logged_in = FALSE)
376
-
377
- # Render login page or main content based on auth status
378
- output$page <- renderUI({
379
- # if (!credentials$logged_in) {
380
- if (FALSE) {
381
- # Login page
382
- div(class = "login-screen",
383
- div(class = "login-header",
384
- h2("Baseball Stats Visualization"),
385
- p("Please log in with your Patreon email")
386
- ),
387
- textInput("email", "Email:"),
388
- actionButton("login", "Login", class = "btn-primary"),
389
- p(style = "margin-top: 20px; text-align: center;",
390
- "Access requires Veteran or Hall of Fame tier on Patreon")
391
- )
392
- } else {
393
- # Main application UI (your existing UI)
394
- fluidPage(
395
- titlePanel(NULL, windowTitle = "Baseball Stats Visualization"),
396
- sidebarLayout(
397
- sidebarPanel(
398
- selectInput("szn", "Season:", c(2026,2025, 2024, 2023, 2022, 2021, 2021)),
399
- selectInput("level", "Level:", c("MLB", "AAA", "FSL")),
400
- selectInput("type", "Player Type:", c("Batter", "Pitcher")),
401
- selectInput("player", "Player:", choices = unique(temp_players$`Batter Name`)),
402
- checkboxInput("use_custom_team", "Use Custom Team", FALSE),
403
- conditionalPanel(
404
- condition = "input.use_custom_team == true",
405
- selectInput(
406
- inputId = "team",
407
- label = "Select Team",
408
- choices = c(
409
- "MLB" = "MLB",
410
- "Angels" = "LAA",
411
- "Angels" = "LAA",
412
- "Astros" = "HOU",
413
- "Athletics" = "OAK",
414
- "Blue Jays" = "TOR",
415
- "Braves" = "ATL",
416
- "Brewers" = "MIL",
417
- "Cardinals" = "STL",
418
- "Cubs" = "CHC",
419
- "D-backs" = "ARI",
420
- "Dodgers" = "LAD",
421
- "Giants" = "SF",
422
- "Guardians" = "CLE",
423
- "Mariners" = "SEA",
424
- "Marlins" = "MIA",
425
- "Mets" = "NYM",
426
- "Nationals" = "WSH",
427
- "Orioles" = "BAL",
428
- "Padres" = "SD",
429
- "Phillies" = "PHI",
430
- "Pirates" = "PIT",
431
- "Rangers" = "TEX",
432
- "Rays" = "TB",
433
- "Red Sox" = "BOS",
434
- "Reds" = "CIN",
435
- "Rockies" = "COL",
436
- "Royals" = "KC",
437
- "Tigers" = "DET",
438
- "Twins" = "MIN",
439
- "White Sox" = "CHW",
440
- "Yankees" = "NYY",
441
- # MLB option at the top
442
- "MLB" = "MLB"
443
- ),
444
- selected = "MLB"
445
- )
446
- )
447
- ),
448
- mainPanel(
449
- div(class = "plot-container",
450
- plotOutput("statsPlot")
451
- )
452
- )
453
- )
454
- )
455
- }
456
- })
457
-
458
- # Handle login button click
459
- observeEvent(input$login, {
460
- # Show loading message
461
- # showModal(modalDialog(
462
- # "Checking credentials...",
463
- # footer = NULL
464
- # ))
465
-
466
- # Check Patreon access
467
- #has_access <- check_patreon_access(input$email)
468
- has_access <- TRUE
469
- # Remove loading message
470
- # removeModal()
471
-
472
- if (has_access) {
473
- credentials$logged_in <- TRUE
474
- } else {
475
- showModal(modalDialog(
476
- title = "Access Denied",
477
- "This email does not have access. Please make sure you're using the email associated with your Patreon account and you have an active Veteran or Hall of Fame tier subscription.",
478
- easyClose = TRUE
479
- ))
480
- }
481
- })
482
- observe({
483
- req(credentials$logged_in)
484
-
485
- observeEvent(c(input$szn,input$level), {
486
- # Filter data based on selected season
487
- filtered_data <- MLBC[MLBC$season == input$szn & MLBC$level == input$level,]
488
-
489
  updateSelectInput(session,
490
  inputId = "player",
491
  choices = unique(filtered_data$`Batter Name`))
492
  })
493
 
494
- })
495
- # Create reactive value to store team
496
  team_value <- reactiveVal("MLB")
497
  position_value <- reactiveVal("")
498
- # Watch for player or season changes to update team
499
  observeEvent(c(input$player, input$szn), {
500
- if (!input$use_custom_team && !is.null(input$player)) {
501
-
502
  player_id <- MLBC %>%
503
  filter(`Batter Name` == input$player) %>%
504
  pull(`Batter ID`) %>%
505
  unique() %>%
506
  first()
507
-
508
  if (!is.null(player_id)) {
509
- player_info <- get_player_info(player_id, input$szn, input$level)
510
-
511
  team_abb <- switch(player_info$team,
512
  "Los Angeles Angels" = "LAA",
513
  "Houston Astros" = "HOU",
@@ -539,8 +369,8 @@ server <- function(input, output,session) {
539
  "Minnesota Twins" = "MIN",
540
  "Chicago White Sox" = "CHW",
541
  "New York Yankees" = "NYY",
542
- "MLB")
543
- if(is.na(team_abb)){
544
  team_abb <- "MLB"
545
  }
546
  team_value(team_abb)
@@ -548,7 +378,7 @@ server <- function(input, output,session) {
548
  }
549
  }
550
  })
551
-
552
  current_team <- reactive({
553
  if (input$use_custom_team) {
554
  return(input$team)
@@ -556,57 +386,49 @@ server <- function(input, output,session) {
556
  return(team_value())
557
  }
558
  })
559
-
560
  output$statsPlot <- renderPlot({
561
  req(position_value())
562
- req(credentials$logged_in)
563
- # showtext::showtext_begin()
564
- # on.exit(showtext::showtext_end())
565
-
566
- data <- data %>% filter(season == input$szn,level == input$level)
567
-
568
  BBE <- MLBC %>%
569
- filter(season == input$szn) %>%
570
  filter(level == input$level) %>%
571
  filter(`Batter Name` == input$player) %>%
572
  mutate(
573
- BBE = case_when(description %in% c('In play, run(s)','In play, out(s)','In play, no out') ~ TRUE, TRUE ~ FALSE)
574
  )
575
-
576
-
577
- indv <- data %>% filter(`Batter Name` == input$player,level == input$level)
578
- #if(indv[1,5] >= 149){
579
- if(input$szn == 2026){
580
- qual <- data %>% filter(BIP > 10)
581
- }
582
- else{
583
- qual <- data %>% filter(BIP > 249)
584
  }
585
- data <- rbind(indv,qual)
586
- data <- unique(data)
587
- #}
588
- current_data <- apply_percentile_calcs(data %>% select(-BIP)) %>%
589
  filter(`Batter.Name` == input$player) %>%
590
  mutate(metric = factor(metric, levels = c(
591
  "wOBA", "wOBACON", "xwOBA", "xDamage",
592
- "Avg EV", "EV90", "Max EV",
593
  "stdev(LA)", "Barrel%",
594
- "Z-Con%", "Z-Swing%", "O-Con%",
595
  "Chase%", "Whiff%", "Swing%", "SwStr%"
596
  ))) %>%
597
  arrange(metric)
598
- #current_data <- data
599
  pos <- position_value()
600
-
601
- BBE <- sum(BBE$BBE,na.rm = TRUE)
602
-
603
- # Add Roboto Condensed Condensed font
604
- #font_add_google("Roboto Condensed Condensed", "Roboto Condensed")
605
- #showtext_auto()
606
-
607
- # Color function
608
- current_data$color <- scales::gradient_n_pal(c("#325aa1","#90A4AE", "#D82129"))(current_data$percentile/100)
609
-
610
  # Labels plot
611
  labels_plot <- ggplot() +
612
  annotate("text", x = c(10, 50, 90), y = 1.2,
@@ -614,17 +436,17 @@ server <- function(input, output,session) {
614
  color = c("#3661ad", "#90A4AE", "#DC3545"),
615
  family = "Roboto Condensed", size = 6) +
616
  annotate("text", x = c(10, 50, 90), y = .5,
617
- label = "",
618
  color = c("#3661ad", "#90A4AE", "#DC3545"), size = 12) +
619
  scale_x_continuous(limits = c(-16, 113), expand = c(0, 0)) +
620
  scale_y_continuous(limits = c(0.5, 1.5)) +
621
  theme_void()
622
-
623
  # Main plot
624
  main_plot <- ggplot(current_data, aes(y = factor(metric, levels = rev(metric)))) +
625
  geom_tile(aes(x = 50, width = 100),
626
  fill = "#c7dcdc", alpha = 0.3, height = 0.25) +
627
- geom_tile(aes(x = percentile/2, width = percentile, fill = color),
628
  height = 0.7) +
629
  annotate("segment", x = c(10, 50, 90), xend = c(10, 50, 90),
630
  y = 0, yend = 16.35,
@@ -654,12 +476,12 @@ server <- function(input, output,session) {
654
  axis.text = element_blank(),
655
  axis.title = element_blank(),
656
  panel.grid = element_blank(),
657
- plot.margin = margin(t = 0, r = 0, b = -20, l = 0), # Reduced bottom margin
658
  text = element_text(family = "Roboto Condensed")
659
  )
660
-
661
- # Load and process team logo
662
- if(current_team() == "MLB"){
663
  logo_url <- "https://a.espncdn.com/combiner/i?img=/i/teamlogos/leagues/500/mlb.png?w=400&h=400&transparent=true"
664
  } else {
665
  logo_url <- sprintf("https://a.espncdn.com/combiner/i?img=/i/teamlogos/mlb/500/%s.png&h=200&w=200",
@@ -667,26 +489,27 @@ server <- function(input, output,session) {
667
  }
668
  logo_img <- image_read(logo_url)
669
  logo_raster <- as.raster(logo_img)
670
-
671
-
672
- #player_url <- sprintf(paste0("https://img.mlbstatic.com/mlb-photos/image/upload/d_headshot_silo_generic.png,ar_1:1,b_auto:border,c_pad,q_auto:best/w_60/v1/people/427012/headshot/milb/current"))
673
- player_url <- get_player_image(current_data[1,2])
674
  player_img <- image_read(player_url)
675
  player_raster <- as.raster(player_img)
676
- # Create title with logo
 
677
  title_grob <- textGrob(paste0(input$player, " - ", pos,
678
- "\n BBE - ", BBE, "\n",input$level,
679
- " Percentile Rankings - ",input$szn),
680
  gp = gpar(fontsize = 25, fontface = "bold",
681
  fontfamily = "Roboto Condensed"))
682
- logo_grob <- rasterGrob(logo_raster, x = 0, width = unit(.5, "npc"),hjust = 0)
683
- player_grob <- rasterGrob(player_raster, x = 0.5, width = unit(.5, "npc"),hjust = 0)
684
- title_with_logo <- arrangeGrob(logo_grob, title_grob,player_grob, ncol = 3,
685
- widths = c(.25,.5,.25))
686
- caption_grob <- textGrob("Viz by: @TimStats | tim-stats.com | Data: MLB",gp = gpar(fontsize = 15, fontface = "bold",
687
- fontfamily = "Roboto Condensed"))
688
-
689
- # Final arrangement with logo in title
 
690
  final_plot <- grid.arrange(
691
  title_with_logo,
692
  labels_plot,
@@ -694,15 +517,14 @@ server <- function(input, output,session) {
694
  caption_grob,
695
  heights = c(0.15, 0.05, 0.75, 0.05)
696
  )
697
-
698
  grid.arrange(
699
  gtable_add_padding(
700
  final_plot,
701
- padding = unit(c(20, 20, 20, 20), "points") # top, right, bottom, left margins
702
  )
703
  )
704
- }, height = 1000, width = 1000, res = 97,pointsize = 12)
705
  }
706
 
707
- # Run the app
708
  shinyApp(ui = ui, server = server)
 
1
  # app.R
2
  library(shiny)
 
3
  library(ggplot2)
4
  library(dplyr)
5
  library(patchwork)
 
13
  library(jsonlite)
14
  library(arrow)
15
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
16
  download_private_parquet <- function(repo_id, filename) {
 
 
 
 
17
  url <- paste0("https://huggingface.co/datasets/", repo_id, "/resolve/main/", filename, "?download=true")
 
 
18
  temp_file <- tempfile(fileext = ".parquet")
19
 
 
20
  response <- GET(
21
  url,
22
  add_headers(Authorization = paste("Bearer", Sys.getenv("GETCSV"))),
23
  write_disk(temp_file, overwrite = TRUE)
24
  )
25
 
 
26
  if (status_code(response) == 200) {
27
  tryCatch({
 
28
  data <- read_parquet(temp_file)
29
  file.remove(temp_file)
30
  return(data)
 
41
  font_add_google("Roboto Condensed")
42
 
43
  is_barrel <- function(df) {
44
+ df$barrel <- with(df, ifelse(hit_angle <= 50 & hit_speed >= 97 & hit_speed * 1.5 -
45
+ hit_angle >= 117 & hit_speed + hit_angle >= 123, 1, 0))
46
+ return(df)
47
  }
48
 
49
  apply_percentile_calcs <- function(data) {
 
50
  percent_rank_cols <- c("Z-Con%", "Z-Swing%", "O-Con%", "Avg EV", "Max EV", "EV90", "Barrel%", "Swing%", "wOBA",
51
+ "wOBACON", "xwOBA", "xDamage")
 
 
52
  inverse_percent_rank_cols <- c("Chase%", "Whiff%", "stdev(LA)", "SwStr%")
53
 
 
54
  percentile_list <- list()
55
 
56
+ for (col in percent_rank_cols) {
 
57
  percentile_list[[col]] <- data.frame(
58
+ `Batter Name` = data[["Batter Name"]],
59
+ `Batter ID` = data[["Batter ID"]],
60
  metric = col,
61
  percentile = round(percent_rank(data[[col]]) * 100),
62
  value = data[[col]],
 
64
  )
65
  }
66
 
67
+ for (col in inverse_percent_rank_cols) {
 
68
  percentile_list[[col]] <- data.frame(
69
+ `Batter Name` = data[["Batter Name"]],
70
+ `Batter ID` = data[["Batter ID"]],
71
  metric = col,
72
  percentile = round((1 - percent_rank(data[[col]])) * 100),
73
  value = data[[col]],
 
75
  )
76
  }
77
 
 
78
  result <- do.call(rbind, percentile_list)
 
 
79
  rownames(result) <- NULL
 
80
  return(result)
81
  }
82
 
83
  get_player_image <- function(player_id) {
 
84
  silo_url <- sprintf("https://img.mlbstatic.com/mlb-photos/image/upload/w_200,q_auto:best/v1/people/%s/headshot/silo/current", player_id)
85
 
 
86
  silo_result <- tryCatch({
87
  response <- httr::HEAD(silo_url)
88
  httr::status_code(response) == 200
89
  }, error = function(e) FALSE)
90
 
 
91
  if (!silo_result) {
92
  return(sprintf("https://img.mlbstatic.com/mlb-photos/image/upload/c_fill,g_auto,b_white,ar_1:1/w_180/v1/people/%s/headshot/milb/current", player_id))
93
  }
94
 
 
95
  return(silo_url)
96
  }
97
 
98
  get_player_info <- function(player_id, season, level = "MLB") {
 
99
  team <- "MLB"
100
  position <- NA
101
 
102
+ if (level == "MLB") {
103
+ url <- paste0("https://statsapi.mlb.com/api/v1/people/", player_id,
 
104
  "/stats?stats=season&season=", season, "&group=hitting")
 
105
  response <- httr::GET(url)
106
  data <- httr::content(response, "parsed")
107
 
108
+ if (length(data$stats) > 0 && length(data$stats[[1]]$splits) > 0) {
109
  team <- data$stats[[1]]$splits[[length(data$stats[[1]]$splits)]]$team$name
110
  }
111
  } else {
112
+ sport_code <- if (level == "AAA") "11" else "14"
 
113
  url <- paste0("https://statsapi.mlb.com/api/v1/sports/", sport_code, "/players?season=", season)
 
114
  response <- httr::GET(url)
 
115
  players_df <- jsonlite::fromJSON(rawToChar(response$content), flatten = TRUE)$people
 
 
116
  found_player <- players_df[players_df$id == player_id, ]
117
 
118
+ if (nrow(found_player) > 0) {
119
  team_id <- found_player$currentTeam.id
 
 
120
  team_url <- paste0("https://statsapi.mlb.com/api/v1/teams/", team_id, "?season=", season)
121
  team_response <- httr::GET(team_url)
122
  team_data <- jsonlite::fromJSON(rawToChar(team_response$content))
 
123
  team <- team_data$teams$parentOrgName
124
  }
125
  }
126
 
 
127
  url2 <- paste0("https://statsapi.mlb.com/api/v1/people/", player_id)
128
  response2 <- httr::GET(url2)
129
  data2 <- httr::content(response2, "parsed")
130
 
131
+ if (length(data2$people) > 0) {
132
  full_position <- data2$people[[1]]$primaryPosition$name
133
  position <- case_when(
134
  full_position == "First Base" ~ "1B",
 
148
  )
149
  }
150
 
151
+ return(list(team = team, position = position))
 
 
 
152
  }
153
 
154
  download_private_csv <- function(repo_id, filename) {
 
158
  if (status_code(response) == 200) {
159
  content <- content(response, "text")
160
  con <- textConnection(content)
161
+ data <- read.csv(con, header = TRUE, check.names = FALSE, fileEncoding = "UTF-8", stringsAsFactors = FALSE)
 
 
 
 
 
 
162
  close(con)
163
  return(data)
164
  } else {
 
166
  }
167
  }
168
 
169
+ # ---- Data Loading ----
170
  MLB25 <- download_private_parquet("TimStats/StatcastDataAll", "MLB25.parquet")
171
  MLB25$level <- "MLB"
172
  AAA25 <- download_private_parquet("TimStats/StatcastDataAll", "AAA25.parquet")
173
  AAA25$level <- "AAA"
174
  FSL25 <- download_private_parquet("TimStats/StatcastDataAll", "FSL25.parquet")
175
  FSL25$level <- "FSL"
 
176
 
177
  MLB26 <- download_private_parquet("TimStats/StatcastDataAll", "MLB26.parquet")
178
  MLB26$level <- "MLB"
179
  AAA26 <- download_private_parquet("TimStats/StatcastDataAll", "AAA26.parquet")
180
  AAA26$level <- "AAA"
 
 
181
 
 
 
182
  MLB <- download_private_parquet("TimStats/StatcastDataAll", "MLB.parquet")
183
  MLB$level <- "MLB"
184
  AAA <- download_private_parquet("TimStats/StatcastDataAll", "AAA.parquet")
185
  AAA$level <- "AAA"
186
  FSLAll <- download_private_parquet("TimStats/StatcastDataAll", "FSL.parquet")
187
  FSLAll$level <- "FSL"
188
+
189
+ MLB <- rbind(MLB, MLB25, MLB26)
190
  print("aaa")
191
+ AAA <- rbind(AAA, AAA25, AAA26)
192
  print("fsl")
193
+ FSL <- rbind(FSLAll, FSL25)
194
 
 
195
  temp_players <- MLB %>% filter(season == 2026)
196
+ MLBC <- rbind(MLB, AAA, FSLAll)
197
+
198
  data <- is_barrel(MLBC) %>%
199
  mutate(
200
+ BBE = case_when(description %in% c('In play, run(s)', 'In play, out(s)', 'In play, no out') ~ TRUE, TRUE ~ FALSE),
201
+ Swing = case_when(description %in% c('Foul', 'Foul Bunt', 'Foul Pitchout', 'Foul Tip',
202
+ 'In play, run(s)', 'In play, out(s)', 'In play, no out',
203
+ 'Swinging Strike', 'Swinging Strike (Blocked)',
204
  'Missed Bunt') ~ TRUE, TRUE ~ FALSE),
205
+ Contact = case_when(description %in% c('In play, run(s)', 'In play, out(s)', 'In play, no out',
206
+ 'Foul', 'Foul Bunt', 'Foul Pitchout') ~ TRUE, TRUE ~ FALSE),
207
+ Whiff = case_when(description %in% c('Swinging Strike', 'Swinging Strike (Blocked)',
208
+ 'Missed Bunt', 'Foul Tip') ~ TRUE, TRUE ~ FALSE),
209
  IZ = ifelse(zone <= 9, TRUE, FALSE),
210
  Single = case_when(result == "Single" & BBE == TRUE ~ TRUE, TRUE ~ FALSE),
211
  Double = case_when(result == "Double" & BBE == TRUE ~ TRUE, TRUE ~ FALSE),
 
213
  `Home Run` = case_when(result == "Home Run" & BBE == TRUE ~ TRUE, TRUE ~ FALSE),
214
  Walk = case_when(balls >= 4 & result == "Walk" ~ TRUE, TRUE ~ FALSE),
215
  HBP = case_when(description == "Hit By Pitch" & result == "Hit By Pitch" ~ TRUE, TRUE ~ FALSE),
216
+ Strikeout = case_when(strikes >= 3 & result %in% c("Strikeout", 'Stikeout Double Play') ~ TRUE, TRUE ~ FALSE),
217
+ Sac = case_when(BBE == TRUE & result %in% c('Sac Fly', 'Sac Bunt',
218
+ 'Sac Fly Double Play', 'Sac Bunt Double Play') ~ TRUE, TRUE ~ FALSE),
219
  IBB = case_when(pitchNum == 1 & result == "Intent Walk" ~ TRUE, TRUE ~ FALSE),
220
  AB = Strikeout + BBE - Sac,
221
  PA = AB + Walk + HBP + IBB
222
  ) %>%
223
+ group_by(`Batter Name`, `Batter ID`, season, level) %>%
224
  summarise(
225
+ BIP = sum(BBE, na.rm = TRUE),
226
+ wOBA = round((sum(Single, na.rm = TRUE) * .882 + sum(Double, na.rm = TRUE) * 1.254 +
227
+ sum(Triple, na.rm = TRUE) * 1.59 + sum(`Home Run`, na.rm = TRUE) * 2.05 +
228
+ sum(Walk, na.rm = TRUE) * .689 + sum(HBP, na.rm = TRUE) * .720) /
229
+ (sum(PA, na.rm = TRUE) - sum(IBB, na.rm = TRUE)), 3),
230
+ wOBACON = round((sum(Single, na.rm = TRUE) * .882 + sum(Double, na.rm = TRUE) * 1.254 +
231
+ sum(Triple, na.rm = TRUE) * 1.59 + sum(`Home Run`, na.rm = TRUE) * 2.05) /
232
+ sum(BBE, na.rm = TRUE), 3),
233
+ xwOBA = round(mean(expected_woba, na.rm = TRUE), 3),
234
+ xDamage = round(mean(expected_woba[BBE == TRUE], na.rm = TRUE), 3),
235
+ `Avg EV` = round(mean(hit_speed, na.rm = TRUE), 1),
236
+ EV90 = round(quantile(hit_speed, 0.9, na.rm = TRUE), 1),
237
+ `Max EV` = round(max(hit_speed, na.rm = TRUE), 1),
238
+ 'stdev(LA)' = round(sd(hit_angle, na.rm = TRUE), 1),
239
+ 'Barrel%' = round(100 * mean(barrel[Swing == TRUE], na.rm = TRUE), 1),
240
+ "Z-Con%" = round(100 * mean(Contact[IZ == TRUE & Swing == TRUE], na.rm = TRUE), 1),
241
+ "Z-Swing%" = round(100 * mean(Swing[IZ == TRUE], na.rm = TRUE), 1),
242
+ "O-Con%" = round(100 * mean(Contact[IZ == FALSE & Swing == TRUE], na.rm = TRUE), 1),
243
+ "Chase%" = round(100 * mean(Swing[IZ == FALSE], na.rm = TRUE), 1),
244
+ "Whiff%" = round(100 * mean(Whiff[Swing == TRUE], na.rm = TRUE), 1),
245
+ "Swing%" = round(100 * mean(Swing, na.rm = TRUE), 1),
246
+ "SwStr%" = round(100 * mean(Whiff, na.rm = TRUE), 1)
247
  )
248
 
249
+ # ---- UI ----
250
  ui <- fluidPage(
251
  theme = bs_theme(bg = "#ffffff", fg = "#333333", primary = "#428bca"),
252
+ tags$head(
253
+ tags$link(href = "https://fonts.googleapis.com/css2?family=Roboto+Condensed:wght@400;700&display=swap",
254
+ rel = "stylesheet"),
255
+ tags$style(HTML("
256
+ * { font-family: 'Roboto Condensed', sans-serif !important; }
257
+ "))
258
+ ),
259
+ titlePanel(NULL, windowTitle = "Baseball Stats Visualization"),
260
+ sidebarLayout(
261
+ sidebarPanel(
262
+ selectInput("szn", "Season:", c(2026, 2025, 2024, 2023, 2022, 2021)),
263
+ selectInput("level", "Level:", c("MLB", "AAA", "FSL")),
264
+ selectInput("type", "Player Type:", c("Batter", "Pitcher")),
265
+ selectInput("player", "Player:", choices = unique(temp_players$`Batter Name`)),
266
+ checkboxInput("use_custom_team", "Use Custom Team", FALSE),
267
+ conditionalPanel(
268
+ condition = "input.use_custom_team == true",
269
+ selectInput(
270
+ inputId = "team",
271
+ label = "Select Team",
272
+ choices = c(
273
+ "MLB" = "MLB",
274
+ "Angels" = "LAA",
275
+ "Astros" = "HOU",
276
+ "Athletics" = "OAK",
277
+ "Blue Jays" = "TOR",
278
+ "Braves" = "ATL",
279
+ "Brewers" = "MIL",
280
+ "Cardinals" = "STL",
281
+ "Cubs" = "CHC",
282
+ "D-backs" = "ARI",
283
+ "Dodgers" = "LAD",
284
+ "Giants" = "SF",
285
+ "Guardians" = "CLE",
286
+ "Mariners" = "SEA",
287
+ "Marlins" = "MIA",
288
+ "Mets" = "NYM",
289
+ "Nationals" = "WSH",
290
+ "Orioles" = "BAL",
291
+ "Padres" = "SD",
292
+ "Phillies" = "PHI",
293
+ "Pirates" = "PIT",
294
+ "Rangers" = "TEX",
295
+ "Rays" = "TB",
296
+ "Red Sox" = "BOS",
297
+ "Reds" = "CIN",
298
+ "Rockies" = "COL",
299
+ "Royals" = "KC",
300
+ "Tigers" = "DET",
301
+ "Twins" = "MIN",
302
+ "White Sox" = "CHW",
303
+ "Yankees" = "NYY"
304
+ ),
305
+ selected = "MLB"
306
+ )
307
+ )
308
  ),
309
+ mainPanel(
310
+ div(class = "plot-container",
311
+ plotOutput("statsPlot")
312
+ )
313
+ )
314
  )
315
  )
316
 
317
+ # ---- Server ----
318
+ server <- function(input, output, session) {
319
 
320
+ observeEvent(c(input$szn, input$level), {
321
+ filtered_data <- MLBC[MLBC$season == as.numeric(input$szn) & MLBC$level == input$level, ]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
322
  updateSelectInput(session,
323
  inputId = "player",
324
  choices = unique(filtered_data$`Batter Name`))
325
  })
326
 
 
 
327
  team_value <- reactiveVal("MLB")
328
  position_value <- reactiveVal("")
329
+
330
  observeEvent(c(input$player, input$szn), {
331
+ if (!input$use_custom_team && !is.null(input$player) && input$player != "") {
 
332
  player_id <- MLBC %>%
333
  filter(`Batter Name` == input$player) %>%
334
  pull(`Batter ID`) %>%
335
  unique() %>%
336
  first()
337
+
338
  if (!is.null(player_id)) {
339
+ player_info <- get_player_info(player_id, as.numeric(input$szn), input$level)
340
+
341
  team_abb <- switch(player_info$team,
342
  "Los Angeles Angels" = "LAA",
343
  "Houston Astros" = "HOU",
 
369
  "Minnesota Twins" = "MIN",
370
  "Chicago White Sox" = "CHW",
371
  "New York Yankees" = "NYY",
372
+ "MLB")
373
+ if (is.na(team_abb) || is.null(team_abb)) {
374
  team_abb <- "MLB"
375
  }
376
  team_value(team_abb)
 
378
  }
379
  }
380
  })
381
+
382
  current_team <- reactive({
383
  if (input$use_custom_team) {
384
  return(input$team)
 
386
  return(team_value())
387
  }
388
  })
389
+
390
  output$statsPlot <- renderPlot({
391
  req(position_value())
392
+ req(input$player)
393
+
394
+ szn_num <- as.numeric(input$szn)
395
+
396
+ plot_data <- data %>% filter(season == szn_num, level == input$level)
397
+
398
  BBE <- MLBC %>%
399
+ filter(season == szn_num) %>%
400
  filter(level == input$level) %>%
401
  filter(`Batter Name` == input$player) %>%
402
  mutate(
403
+ BBE = case_when(description %in% c('In play, run(s)', 'In play, out(s)', 'In play, no out') ~ TRUE, TRUE ~ FALSE)
404
  )
405
+
406
+ indv <- plot_data %>% filter(`Batter Name` == input$player, level == input$level)
407
+
408
+ if (szn_num == 2026) {
409
+ qual <- plot_data %>% filter(BIP > 10)
410
+ } else {
411
+ qual <- plot_data %>% filter(BIP > 249)
 
 
412
  }
413
+
414
+ plot_data <- unique(rbind(indv, qual))
415
+
416
+ current_data <- apply_percentile_calcs(plot_data %>% select(-BIP)) %>%
417
  filter(`Batter.Name` == input$player) %>%
418
  mutate(metric = factor(metric, levels = c(
419
  "wOBA", "wOBACON", "xwOBA", "xDamage",
420
+ "Avg EV", "EV90", "Max EV",
421
  "stdev(LA)", "Barrel%",
422
+ "Z-Con%", "Z-Swing%", "O-Con%",
423
  "Chase%", "Whiff%", "Swing%", "SwStr%"
424
  ))) %>%
425
  arrange(metric)
426
+
427
  pos <- position_value()
428
+ BBE_count <- sum(BBE$BBE, na.rm = TRUE)
429
+
430
+ current_data$color <- scales::gradient_n_pal(c("#325aa1", "#90A4AE", "#D82129"))(current_data$percentile / 100)
431
+
 
 
 
 
 
 
432
  # Labels plot
433
  labels_plot <- ggplot() +
434
  annotate("text", x = c(10, 50, 90), y = 1.2,
 
436
  color = c("#3661ad", "#90A4AE", "#DC3545"),
437
  family = "Roboto Condensed", size = 6) +
438
  annotate("text", x = c(10, 50, 90), y = .5,
439
+ label = "\u25B2",
440
  color = c("#3661ad", "#90A4AE", "#DC3545"), size = 12) +
441
  scale_x_continuous(limits = c(-16, 113), expand = c(0, 0)) +
442
  scale_y_continuous(limits = c(0.5, 1.5)) +
443
  theme_void()
444
+
445
  # Main plot
446
  main_plot <- ggplot(current_data, aes(y = factor(metric, levels = rev(metric)))) +
447
  geom_tile(aes(x = 50, width = 100),
448
  fill = "#c7dcdc", alpha = 0.3, height = 0.25) +
449
+ geom_tile(aes(x = percentile / 2, width = percentile, fill = color),
450
  height = 0.7) +
451
  annotate("segment", x = c(10, 50, 90), xend = c(10, 50, 90),
452
  y = 0, yend = 16.35,
 
476
  axis.text = element_blank(),
477
  axis.title = element_blank(),
478
  panel.grid = element_blank(),
479
+ plot.margin = margin(t = 0, r = 0, b = -20, l = 0),
480
  text = element_text(family = "Roboto Condensed")
481
  )
482
+
483
+ # Logo
484
+ if (current_team() == "MLB") {
485
  logo_url <- "https://a.espncdn.com/combiner/i?img=/i/teamlogos/leagues/500/mlb.png?w=400&h=400&transparent=true"
486
  } else {
487
  logo_url <- sprintf("https://a.espncdn.com/combiner/i?img=/i/teamlogos/mlb/500/%s.png&h=200&w=200",
 
489
  }
490
  logo_img <- image_read(logo_url)
491
  logo_raster <- as.raster(logo_img)
492
+
493
+ # Player headshot
494
+ player_url <- get_player_image(current_data[1, 2])
 
495
  player_img <- image_read(player_url)
496
  player_raster <- as.raster(player_img)
497
+
498
+ # Title grobs
499
  title_grob <- textGrob(paste0(input$player, " - ", pos,
500
+ "\n BBE - ", BBE_count, "\n", input$level,
501
+ " Percentile Rankings - ", input$szn),
502
  gp = gpar(fontsize = 25, fontface = "bold",
503
  fontfamily = "Roboto Condensed"))
504
+ logo_grob <- rasterGrob(logo_raster, x = 0, width = unit(.5, "npc"), hjust = 0)
505
+ player_grob <- rasterGrob(player_raster, x = 0.5, width = unit(.5, "npc"), hjust = 0)
506
+ title_with_logo <- arrangeGrob(logo_grob, title_grob, player_grob, ncol = 3,
507
+ widths = c(.25, .5, .25))
508
+ caption_grob <- textGrob("Viz by: @TimStats | tim-stats.com | Data: MLB",
509
+ gp = gpar(fontsize = 15, fontface = "bold",
510
+ fontfamily = "Roboto Condensed"))
511
+
512
+ # Final arrangement
513
  final_plot <- grid.arrange(
514
  title_with_logo,
515
  labels_plot,
 
517
  caption_grob,
518
  heights = c(0.15, 0.05, 0.75, 0.05)
519
  )
520
+
521
  grid.arrange(
522
  gtable_add_padding(
523
  final_plot,
524
+ padding = unit(c(20, 20, 20, 20), "points")
525
  )
526
  )
527
+ }, height = 1000, width = 1000, res = 97, pointsize = 12)
528
  }
529
 
 
530
  shinyApp(ui = ui, server = server)