library(shiny) library(plotly) library(gridlayout) library(bslib) library(DT) library(rsconnect) library(baseballr) library(dplyr) library(tidyverse) library(rvest) library(ggplot2) library(janitor) library(ggthemes) library(ggpubr) library(jsonlite) library(utils) library(grid) library(gridExtra) library(png) library(xgboost) library(httr) library(jpeg) library(zoo) # For rolling mean calculation pdf(file = NULL) Sys.setenv(TZ='EST') # Helper functions download_and_process_image <- function(url) { tryCatch({ response <- GET(url) content_type <- http_type(response) if (content_type %in% c("image/png", "image/jpeg")) { temp_file <- tempfile(fileext = ifelse(content_type == "image/png", ".png", ".jpg")) writeBin(content(response, "raw"), temp_file) if (content_type == "image/png") { img <- readPNG(temp_file) } else { img <- readJPEG(temp_file) } return(list(img = img, type = content_type)) } else { warning(paste("Unsupported image type:", content_type)) return(NULL) } }, error = function(e) { warning(paste("Error processing image:", e$message)) return(NULL) }) } is_barrel <- function(df) { df$barrel <- with(df, ifelse(hit_angle <= 50 & hit_speed >= 97 & hit_speed * 1.5 - hit_angle >= 117 & hit_speed + hit_angle >= 123, 1, 0)) return(df) } VAA <- function(milbtotal){ milbtotal <- milbtotal %>% mutate(VAA = -atan((vz0+(az*(-sqrt((vy0*vy0)-(2*ay*(y0-(17/12))))-vy0)/ ay))/(-sqrt((vy0*vy0)-(2*ay*(y0-(17/12))))))*(180/pi)) } pitcher_summary <- function(game_pk,date){ gdate <- as.Date.character(date) gdate <- as.Date(gdate) tmilb <- mlb_pbp(game_pk) tmilb <- tmilb %>% filter(type == "pitch") tmilb <- tmilb %>% select(matchup.batter.fullName,matchup.batter.id,matchup.pitcher.fullName, matchup.pitcher.id,result.event,details.description,details.type.description, result.description,pitchData.startSpeed,pitchData.plateTime,pitchData.zone, pitchData.breaks.spinRate,pitchData.extension, pitchData.coordinates.pX, pitchData.coordinates.pZ,pitchData.coordinates.x0, pitchData.coordinates.y0, pitchData.coordinates.z0,pitchData.coordinates.aX,pitchData.coordinates.aY, pitchData.coordinates.aZ,pitchData.coordinates.vX0,pitchData.coordinates.vZ0, pitchData.coordinates.vY0,pitchData.coordinates.pfxX,pitchData.coordinates.pfxZ, pitchData.breaks.breakVerticalInduced,pitchData.breaks.breakHorizontal, hitData.launchSpeed,hitData.launchAngle,hitData.totalDistance,details.isInPlay, last.pitch.of.ab,pitchData.breaks.spinDirection,matchup.pitchHand.code) colnames(tmilb) <- c("Batter Name","Batter ID","Pitcher Name","Pitcher ID", "result","description","pitch_name","des","start_speed", "plateTime","zone","spin_rate","extension","px","pz","x0", "y0","z0","ax","ay","az","vx0","vz0","vy0","pfxX","pfxZ", "IVB","HB","hit_speed","hit_angle","hit_distance","inPlay", "lastPitch","spinDirection","phand") tmilb <- is_barrel(tmilb) tmilb <- tmilb %>% mutate(is_strike_swinging = ifelse(description == "Swinging Strike" | description == "Foul Tip",TRUE,FALSE)) tmilb <- tmilb %>% mutate(date = gdate) return(tmilb) } break_plot <- function(game){ ggplot(game, aes(x = HB, y = IVB, color = pitch_name)) + geom_point(size = 2) + geom_vline(xintercept = 0, color = "lightblue", linewidth = 1, linetype = 4) + geom_hline(yintercept = 0, color = "lightblue", linewidth = 1, linetype = 4) + labs(x = "Horizontal Break (in)", y = "Induced Vertical Break (in)", title = "Pitch Movement") + xlim(-25, 25) + ylim(-25, 25) + # scale_x_continuous(breaks = seq(-20, 20, by = 20)) + # scale_y_continuous(breaks = seq(-20, 20, by = 20)) + theme_minimal() + theme( legend.position = "bottom", plot.title = element_text(hjust = 0.5, face = "bold"), panel.grid.minor = element_line(color = "gray", size = 0.25, linetype = 1), aspect.ratio = 1 # This ensures the plot is square ) + guides(color = guide_legend(title = "Pitch Type", nrow = 1)) } pitch_plot <- function(game){ ggplot(game, aes(x = px, y = pz, color = pitch_name)) + geom_point(size = 3.5) + geom_segment(aes(x = -0.71, xend = 0.71, y = 1.5, yend = 1.5)) + geom_segment(aes(x = -0.71, xend = 0.71, y = 3.6, yend = 3.6)) + geom_segment(aes(x = 0.71, xend = 0.71, y = 1.5, yend = 3.6)) + geom_segment(aes(x = -0.71, xend = -0.71, y = 1.5, yend = 3.6)) + labs(x = NULL, y = NULL, title = "Pitch Location") + xlim(-3, 3) + ylim(0.2, 4) + coord_fixed(ratio = 1) + theme_minimal() + theme( legend.position = "bottom", plot.title = element_text(hjust = 0.5, face = "bold"), axis.text = element_blank(), axis.ticks = element_blank() ) + guides(color = guide_legend(title = "Pitch Type", nrow = 1)) } # Load models # FB <- xgb.load('FB.model') # Off <- xgb.load('Off.model') # Break <- xgb.load('Break.model') model <- xgb.load('TimStuff2.model') calculate_VAA <- function(vz0, ay, az, vy0, y0) { -atan((vz0+(az*(-sqrt((vy0*vy0)-(2*ay*(y0-(17/12))))-vy0)/ ay))/(-sqrt((vy0*vy0)-(2*ay*(y0-(17/12))))))*(180/pi) } calculate_EAA <- function(extension) { extension / 6.3 } calculate_SADiff <- function(pfxX, pfxZ, spinDirection) { inSA <- atan2(pfxZ, pfxX) * 180/pi + 90 inSA <- ifelse(inSA < 0, inSA + 360, inSA) SADiff <- spinDirection - inSA SADiff <- ifelse(SADiff > 180, SADiff - 360, SADiff) SADiff <- ifelse(SADiff < -180, SADiff + 360, SADiff) return(SADiff) } scale_TimStuff <- function(raw_score, model_mean, model_sd) { scaled_score <- (raw_score - model_mean) / model_sd result <- 100 - (scaled_score * 10) return(result) } calculate_primary <- function(data){ data <- data %>% group_by(pitch_name, `Pitcher Name`, `Pitcher ID`, date) %>% mutate( pitch_count = n(), avg_start_speed = mean(start_speed, na.rm = TRUE), avg_IVB = mean(IVB, na.rm = TRUE), avg_HB = mean(HB, na.rm = TRUE) ) %>% ungroup() %>% group_by(`Pitcher Name`, `Pitcher ID`, date) %>% mutate( is_highest_occurrence = case_when( pitch_count == max(pitch_count) ~ 1, TRUE ~ 0 ) ) %>% mutate( is_highest_occurrence = case_when( is_highest_occurrence == 1 & pitch_count == max(pitch_count[is_highest_occurrence == 1]) & avg_start_speed == max(avg_start_speed[is_highest_occurrence == 1]) ~ 1, TRUE ~ 0 ) ) %>% mutate( primary_speed = avg_start_speed[is_highest_occurrence == 1][1], primary_IVB = avg_IVB[is_highest_occurrence == 1][1], primary_HB = avg_HB[is_highest_occurrence == 1][1] ) %>% ungroup() } calculate_timstuff <- function(game) { game <- calculate_primary(game) game <- game %>% mutate(VAA = calculate_VAA(vz0, ay, az, vy0, y0), EAA = calculate_EAA(extension), SADiff = calculate_SADiff(pfxX, pfxZ, spinDirection), team_fielding_id = ifelse(description %in% c("Called Strike", "Swinging Strike", "Swinging Strike (Blocked)"), 1, 0), swing = ifelse(description %in% c("Foul", "Foul Pitchout", "In play, no out", "In play, out(s)", "In play, run(s)", "Swinging Strike", "Swinging Strike (Blocked)", "Foul Tip"), 1, 0), is_strike_swinging = ifelse(is_strike_swinging, 1, 0), Pitch = pitch_name, ishandL = ifelse(phand == "L",1,0)) feature_vars <- c("ishandL","start_speed", "IVB", "HB", "EAA", "x0", "z0", "spin_rate","SADiff","primary_speed","primary_IVB","primary_HB") complete_rows <- complete.cases(game[, feature_vars]) game_complete <- game[complete_rows, ] game_na <- game[!complete_rows,] game_na$TimStuff <- NA rhp <- game_complete rhp$TimStuff <- scale_TimStuff(predict(model, as.matrix(cbind(rhp$ishandL,rhp$start_speed, rhp$IVB, rhp$HB, rhp$EAA, rhp$x0, rhp$z0, rhp$spin_rate, rhp$SADiff,rhp$primary_speed,rhp$primary_IVB,rhp$primary_HB))), -0.00249975, 0.007566558) game_complete <- rbind(rhp,game_na) return(game_complete) } summary_table <- function(game) { rows <- nrow(game) game <- calculate_primary(game) game <- calculate_timstuff(game) sumtable <- game %>% mutate(team_fielding_id = ifelse(description == "Called Strike" | description == "Swinging Strike" | description == "Swinging Strike (Blocked)", 1, 0)) %>% mutate(swing = ifelse(description == "Foul" | description == "Foul Pitchout" | description == "In play, no out" | description == "In play, out(s)" | description == "In play, run(s)" | description == "Swinging Strike" | description == "swinging Strike (Blocked)" | description == "Foul Tip", 1, 0)) %>% mutate(is_strike_swinging = ifelse(is_strike_swinging == TRUE, 1, 0)) %>% mutate(Pitch = pitch_name) %>% rowwise() %>% group_by(Pitch) %>% summarize( Pitches = n(), 'Pitch%' = round(sum(Pitches)/sum(rows) * 100, digits = 1), 'Avg. Velo' = round(mean(start_speed, na.rm = TRUE), digits = 1), 'Spin Rate' = round(mean(spin_rate, na.rm = TRUE), digits = 0), 'Extension' = round(mean(extension, na.rm = TRUE), digits = 1), 'IVB' = round(mean(IVB, na.rm = TRUE), digits = 1), 'HB' = round(mean(HB, na.rm = TRUE), digits = 1), 'VAA' = round(mean(VAA, na.rm = TRUE), digits = 1), 'CSW%' = round(sum(team_fielding_id, na.rm = TRUE) / sum(!is.na(team_fielding_id)) * 100, digits = 1), 'Whiff%' = round(sum(is_strike_swinging, na.rm = TRUE) / sum(swing, na.rm = TRUE) * 100, digits = 1), 'TimStuff+' = round(mean(TimStuff, na.rm = TRUE), digits = 0) ) %>% arrange(-Pitches) result <- sumtable %>% select(Pitch, Pitches, `Pitch%`, `Avg. Velo`, `Spin Rate`, Extension,IVB, HB, VAA, `CSW%`, `Whiff%`, `TimStuff+`) %>% rename( "Type" = Pitch, "#" = Pitches, "Velo" = `Avg. Velo`, "Spin" = `Spin Rate`, "Ext" = Extension, "Use%" = `Pitch%` ) %>% mutate( "Use%" = paste0(`Use%`, "%"), "Spin" = format(round(Spin), big.mark = ","), Velo = round(Velo, 1), Ext = round(Ext, 1), IVB = round(IVB, 1), HB = round(HB, 1), VAA = round(VAA, 1), `CSW%` = paste0(`CSW%`, "%"), `Whiff%` = paste0(`Whiff%`, "%") ) %>% arrange(desc(`#`)) return(result) } # Initialize schedule data mlbid <- mlb_schedule(season = 2024, level_ids = "1") mlbteamH <- mlbid %>% select(teams_home_team_name) mlbteamH <- distinct(mlbteamH) mlbteamA <- mlbid %>% select(teams_away_team_name) mlbteamA <- distinct(mlbteamA) aaaid <- mlb_schedule(season = 2024, level_ids = "11") aaateamH <- aaaid %>% select(teams_home_team_name) aaateamH <- distinct(aaateamH) aaateamA <- aaaid %>% select(teams_away_team_name) aaateamA <- distinct(aaateamA) fslid <- mlb_schedule(season = 2024, level_ids = "14") fslid <- fslid %>% filter(teams_home_team_name == "Daytona Tortugas" | teams_home_team_name == "Jupiter Hammerheads" | teams_home_team_name == "Palm Beach Cardinals" | teams_home_team_name == "St. Lucie Mets" | teams_home_team_name == "Bradenton Marauders" | teams_home_team_name == "Clearwater Threshers" | teams_home_team_name == "Dunedin Blue Jays" | teams_home_team_name == "Fort Myers Mighty Mussels" | teams_home_team_name == "Lakeland Flying Tigers" | teams_home_team_name == "Tampa Tarpons") fslteamH <- fslid %>% select(teams_home_team_name) fslteamH <- distinct(fslteamH) fslteamA <- fslid %>% select(teams_away_team_name) fslteamA <- distinct(fslteamA) sbid <- mlb_schedule(season = 2024, level_ids = "22") sbteamH <- sbid %>% select(teams_home_team_name) sbteamH <- distinct(sbteamH) sbteamA <- sbid %>% select(teams_away_team_name) sbteamA <- distinct(sbteamA) # UI Definition ui <- fluidPage( theme = bs_theme(version = 5, bootswatch = "flatly"), titlePanel("2024 MLB/AAA/FSL Summary Cards"), sidebarLayout( sidebarPanel( width = 3, dateInput("date", "Date:", value = Sys.Date()), selectizeInput("level", "Level:", c("MLB", "AAA", "FSL", "College (Statcast Parks Only)"), options = list( placeholder = 'Select a level', onInitialize = I('function() { this.setValue(""); }') )), selectizeInput("homeT", "Home Team:", NULL), selectizeInput("awayT", "Away Team:", NULL), selectizeInput("gamenum", "Game Number:", c("1", "2")), actionButton("update", "Find Pitcher", icon("magnifying-glass"), class = "btn-primary btn-block"), selectizeInput("pitcher", "Pitcher Name:", NULL), #textInput("title", "Card Title"), actionButton("update1", "Make Card", icon("plus"), class = "btn-success btn-block"), downloadButton("downloadPlot", "Download Card", class = "btn-info btn-block") ), mainPanel( plotOutput("combinedPlot", height = "900px", width = "100%") ) ) ) server <- function(input, output, session) { observeEvent(input$level, { if(input$level == "AAA"){ updateSelectizeInput(session, "homeT", "Home Team:", choices = aaateamH[,1]) updateSelectizeInput(session, "awayT", "Away Team:", choices = aaateamA[,1]) } if(input$level == "FSL"){ updateSelectizeInput(session, "homeT", "Home Team:", choices = fslteamH[,1]) updateSelectizeInput(session, "awayT", "Away Team:", choices = fslteamA[,1]) } if(input$level == "MLB"){ updateSelectizeInput(session, "homeT", "Home Team:", choices = mlbteamH[,1]) updateSelectizeInput(session, "awayT", "Away Team:", choices = mlbteamA[,1]) } if(input$level == "College (Statcast Parks Only)"){ updateSelectizeInput(session, "homeT", "Home Team:", choices = sbteamH[,1]) updateSelectizeInput(session, "awayT", "Away Team:", choices = sbteamA[,1]) } }) game_data <- reactiveVal() observeEvent(input$update, { tryCatch({ if(input$level == "AAA"){ pname <- aaaid %>% filter(date == input$date) %>% filter(teams_home_team_name == input$homeT) %>% filter(teams_away_team_name == input$awayT) %>% filter(game_number == input$gamenum) pname <- pitcher_summary(pname[,6], input$date) } if(input$level == "FSL"){ pname <- fslid %>% filter(date == input$date) %>% filter(teams_home_team_name == input$homeT) %>% filter(teams_away_team_name == input$awayT) %>% filter(game_number == input$gamenum) pname <- pitcher_summary(pname[,6], input$date) } if(input$level == "MLB"){ pname <- mlbid %>% filter(date == as.character.Date(input$date)) %>% filter(teams_home_team_name == input$homeT) %>% filter(teams_away_team_name == input$awayT) %>% filter(game_number == input$gamenum) pname <- pitcher_summary(pname[,6], input$date) } if(input$level == "College (Statcast Parks Only)"){ pname <- sbid %>% filter(date == input$date) %>% filter(teams_home_team_name == input$homeT) %>% filter(teams_away_team_name == input$awayT) %>% filter(game_number == input$gamenum) pname <- pitcher_summary(pname[,6], input$date) } if(nrow(pname) == 0) { showNotification("No pitchers found for the selected game.", type = "warning") } else { updateSelectizeInput(session, "pitcher", "Pitcher:", choices = unique(pname$`Pitcher Name`)) game_data(pname) } }, error = function(e) { showNotification(paste("Error finding pitchers:", e$message), type = "error") }) }) rolling_timstuff <- reactive({ req(input$update1, game_data()) game <- game_data() %>% filter(`Pitcher Name` == input$pitcher) game <- calculate_timstuff(game) game %>% arrange(date) %>% group_by(pitch_name) %>% mutate(rolling_timstuff = rollmean(TimStuff, k = 5, fill = NA, align = "right"), pitch_number = row_number()) %>% ungroup() # Make sure to ungroup after the grouping operations }) combinedPlot <- reactiveVal() observeEvent(input$update1, { req(game_data()) tryCatch({ game <- game_data() %>% filter(`Pitcher Name` == input$pitcher) if(nrow(game) == 0) { showNotification("No data available for the selected pitcher.", type = "warning") return() } break_plot <- break_plot(game) + theme(legend.position = "none") pitch_plot <- pitch_plot(game) + theme(legend.position = "none") # Create a formatted table table_data <- summary_table(game) num_rows <- nrow(table_data) table_plot <- tableGrob(table_data, rows = NULL, theme = ttheme_minimal( core = list(fg_params = list(hjust = 0.5, x = 0.5), bg_params = list(fill = "white")), colhead = list(fg_params = list(hjust = 0.5, x = 0.5, fontface = "bold"), bg_params = list(fill = "#f0f0f0")), rowhead = list(fg_params = list(hjust = 0.5, x = 0.5), bg_params = list(fill = "white")) )) # Set a fixed total height for the table, adjusting row heights based on number of pitches total_height <- unit(1, "npc") row_height <- total_height / (num_rows + 1) # +1 for header row table_plot$heights <- unit(rep(row_height, num_rows + 1), "npc") # Adjust column widths table_plot$widths <- unit(c(0.1, 0.06, 0.06, 0.08, 0.1, 0.06, 0.08, 0.08, 0.08, 0.1, 0.1, 0.1), "npc") # Add alternating row colors for(i in seq(2, nrow(table_plot), 2)) { table_plot$grobs[[i]]$gp$fill <- "#f9f9f9" } id <- as.character(game$`Pitcher ID`[1]) mlb_url <- paste0("https://midfield.mlbstatic.com/v1/people/", id, "/mlb/300?circle=false") milb_url <- paste0("https://midfield.mlbstatic.com/v1/people/", id, "/milb/300?circle=false") img_result <- download_and_process_image(mlb_url) if (is.null(img_result)) { img_result <- download_and_process_image(milb_url) } if (!is.null(img_result)) { img_grob <- rasterGrob(img_result$img, interpolate = TRUE) } else { img_grob <- textGrob("Image not available", gp = gpar(col = "red", fontsize = 20)) } # Create the rolling TimStuff+ graph rolling_data <- rolling_timstuff() timstuff_plot <- ggplot(rolling_data, aes(x = pitch_number, y = rolling_timstuff, color = pitch_name)) + geom_line(size = 1) + geom_point(size = 1) + theme_minimal() + labs(title = "5-Pitch Rolling TimStuff+", x = "Pitch Number", y = "TimStuff+") + ylim(70, 130) + scale_x_continuous(breaks = seq(5, 55, by = 5), limits = c(5,NA)) + theme( plot.title = element_text(hjust = 0.5, face = "bold"), legend.position = "none", panel.grid.major.x = element_line(color = "gray", size = 0.5) ) # Create title and data source text title_text <- textGrob( paste(input$pitcher, format(input$date, "%m/%d/%y"), "Summary Card by @TimStats"), gp = gpar(fontsize = 16, fontface = "bold") ) data_source_text <- textGrob( "Data: MLB", gp = gpar(fontsize = 8), x = unit(1, "npc") - unit(2, "mm"), y = unit(2, "mm"), just = c("right", "bottom") ) # Create a horizontal legend legend <- get_legend( ggplot(rolling_data, aes(x = pitch_number, y = rolling_timstuff, color = pitch_name)) + geom_point(size = 5) + theme(legend.position = "bottom", legend.title = element_blank(), text = element_text(size = 12.5), #legend.key.size = unit(.5,"cm"), legend.box = "horizontal" ) + guides(color = guide_legend(nrow = 1)) ) # Combine all plots combined <- grid.arrange( arrangeGrob( arrangeGrob( img_grob, title_text, ncol = 1, heights = c(4, 1) ), pitch_plot, ncol = 2, widths = c(1, 1) ), arrangeGrob( break_plot, timstuff_plot, ncol = 2, widths = c(1, 1) ), #arrangeGrob( legend, #), #arrangeGrob( table_plot, #), data_source_text, nrow = 5, heights = c(1.2, 1.2, 0.05, 1.1, 0.05) # Adjusted these values ) combinedPlot(combined) output$combinedPlot <- renderPlot({ grid.draw(combinedPlot()) }) }, error = function(e) { showNotification(paste("Error generating card:", e$message), type = "error") }) }) output$downloadPlot <- downloadHandler( filename = function() { paste("baseball_card_", Sys.Date(), ".png", sep = "") }, content = function(file) { ggsave(file, plot = combinedPlot(), width = 18, height = 12, dpi = 300) } ) } shinyApp(ui, server)