library(shiny) library(dplyr) library(ggplot2) library(grid) library(gridExtra) library(gt) library(gtExtras) library(stringr) library(zip) library(png) library(workflows) library(parsnip) library(recipes) library(arrow) library(xgboost) library(tidymodels) library(httr) library(ggforce) PASSWORD <- Sys.getenv("password") if (!requireNamespace("magick", quietly = TRUE)) { message("Note: Install 'magick' to enable player headshots in reports") } team_meta <- tryCatch({ read.csv("TMB (1).csv", stringsAsFactors = FALSE) }, error = function(e) { message("TMB (1).csv not found - logos will not display") NULL }) # -------------------- GLOBAL CSS -------------------- app_css <- " body { background-color: #f5f5f5; font-family: 'Segoe UI', Arial, sans-serif; } .header { background: linear-gradient(135deg, #006F71 0%, #00a8a8 100%); color: white; padding: 30px; text-align: center; box-shadow: 0 4px 6px rgba(0,0,0,0.1); margin-bottom: 30px; } .header h1 { margin: 0; font-size: 2.5em; font-weight: bold; } .header p { margin: 10px 0 0 0; font-size: 1.1em; opacity: 0.9; } .main-panel { background: white; border-radius: 12px; padding: 30px; box-shadow: 0 2px 8px rgba(0,0,0,0.1); } .upload-box { border: 2px dashed #006F71; border-radius: 8px; padding: 30px; text-align: center; background: #f9fcfc; transition: all 0.3s; } .upload-box:hover { border-color: #00a8a8; background: #f0f8f8; } .btn-primary { background-color: #006F71 !important; border: none !important; padding: 12px 30px; font-size: 16px; font-weight: bold; border-radius: 6px; transition: all 0.3s; width: 100%; } .btn-primary:hover { background-color: #00a8a8 !important; transform: translateY(-2px); box-shadow: 0 4px 12px rgba(0,111,113,0.3); } .btn-secondary { background-color: #00a8a8 !important; border: none !important; padding: 12px 30px; font-size: 16px; font-weight: bold; border-radius: 6px; transition: all 0.3s; width: 100%; margin-top: 10px; } .btn-secondary:hover { background-color: #008a8a !important; transform: translateY(-2px); box-shadow: 0 4px 12px rgba(0,138,138,0.3); } .status-box { background: #e8f5f5; border-left: 4px solid #006F71; padding: 15px; margin: 20px 0; border-radius: 4px; } .plot-container, .html-widget, .plotly, .shiny-plot-output { width: 100% !important; overflow: visible !important; } .tall-plot { height: 440px !important; } @media (max-width: 992px) { .tall-plot { height: 360px !important; } } /* LEADERBOARD STYLES */ .leaderboard-section { background: white; border-radius: 12px; padding: 20px; margin-bottom: 25px; box-shadow: 0 2px 8px rgba(0,0,0,0.1); } .leaderboard-title { font-size: 1.4em; font-weight: bold; color: #006F71; margin-bottom: 15px; text-align: center; } .leaderboard-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 15px; } .leaderboard-column { background: #f9fcfc; border-radius: 8px; padding: 10px; } .leaderboard-column-header { font-weight: bold; color: #006F71; border-bottom: 2px solid #006F71; padding-bottom: 8px; margin-bottom: 10px; display: flex; justify-content: space-between; } .leaderboard-row { display: flex; align-items: center; padding: 6px 0; border-bottom: 1px solid #e0e0e0; } .leaderboard-row:last-child { border-bottom: none; } .leaderboard-logo { width: 28px; height: 28px; object-fit: contain; margin-right: 8px; } .leaderboard-name { flex: 1; font-size: 0.9em; } .leaderboard-value { font-weight: bold; color: #333; } .game-info-bar { display: flex; justify-content: space-between; align-items: center; background: linear-gradient(135deg, #006F71 0%, #00a8a8 100%); color: white; padding: 12px 20px; border-radius: 8px; margin-bottom: 15px; font-size: 0.95em; } .game-info-item { display: flex; flex-direction: column; align-items: center; } .game-info-label { font-size: 0.75em; opacity: 0.85; text-transform: uppercase; } .game-info-value { font-weight: bold; font-size: 1.1em; } .game-score { font-size: 1.2em; font-weight: bold; } " download_private_rds <- function(repo_id, filename) { url <- paste0("https://huggingface.co/datasets/", repo_id, "/resolve/main/", filename) api_key <- Sys.getenv("HFToken") if (api_key == "") { stop("API key is not set.") } response <- GET(url, add_headers(Authorization = paste("Bearer", api_key))) if (status_code(response) == 200) { temp_file <- tempfile(fileext = ".rds") writeBin(content(response, "raw"), temp_file) data <- readRDS(temp_file) return(data) } else { stop(paste("Failed to download dataset. Status code:", status_code(response))) } } stuffplus_recipe <- download_private_rds("CoastalBaseball/PitcherAppFiles", "stuffplus_recipe.rds") stuffplus_model <- xgb.load("stuffplus_xgb.json") message(class(stuffplus_model)) parse_flexible_date <- function(x) { if (inherits(x, "Date")) return(x) x <- as.character(x) # Try yyyy-mm-dd first (TrackMan default) d <- suppressWarnings(as.Date(x, format = "%Y-%m-%d")) if (!all(is.na(d))) return(d) # Try mm/dd/yyyy d <- suppressWarnings(as.Date(x, format = "%m/%d/%Y")) if (!all(is.na(d))) return(d) # Try mm/dd/yy d <- suppressWarnings(as.Date(x, format = "%m/%d/%y")) if (!all(is.na(d))) return(d) # Fallback suppressWarnings(as.Date(x)) } process_dataset <- function(df) { if ("Batter" %in% names(df)) { df <- df %>% mutate(Batter = stringr::str_replace(Batter, "^\\s*(\\w+)\\s*,\\s*(\\w+)\\s*$", "\\2 \\1")) } df <- df %>% distinct() if ("PitchUID" %in% names(df)) df <- df %>% distinct(PitchUID, .keep_all = TRUE) if ("Date" %in% names(df)) { df$Date <- parse_flexible_date(df$Date) } if ("PlateLocSide" %in% names(df)) df$PlateLocSide <- as.numeric(df$PlateLocSide) if ("PlateLocHeight" %in% names(df)) df$PlateLocHeight <- as.numeric(df$PlateLocHeight) if ("RelSpeed" %in% names(df)) df <- df %>% filter(!is.na(RelSpeed)) if (!"TaggedPitchType" %in% names(df)) { alt <- intersect(c("pitch_type","PitchType","TaggedPitch","TaggedPitchName"), names(df)) if (length(alt)) df$TaggedPitchType <- df[[alt[1]]] else df$TaggedPitchType <- NA_character_ } df <- df %>% mutate( ExitSpeed = ifelse(!is.na(ExitSpeed) & !is.na(Angle) & (ExitSpeed > 120 & Angle < -10 | ExitSpeed < 70), NA, ExitSpeed), WhiffIndicator = ifelse(PitchCall == "StrikeSwinging", 1, 0), StrikeZoneIndicator = ifelse( PlateLocSide >= -0.83 & PlateLocSide <= 0.83 & PlateLocHeight >= 1.5 & PlateLocHeight <= 3.38, 1, 0 ), SwingIndicator = ifelse(PitchCall %in% c("StrikeSwinging","FoulBallNotFieldable","FoulBall","InPlay"), 1, 0), BIPind = ifelse(PitchCall == "InPlay" & TaggedHitType != "Bunt", 1, 0), ABindicator = ifelse(PlayResult %in% c("Error","FieldersChoice","Out","Single","Double","Triple","HomeRun") | KorBB == "Strikeout", 1, 0), HitIndicator = ifelse(PlayResult %in% c("Single","Double","Triple","HomeRun"), 1, 0), PAindicator = ifelse(PitchCall %in% c("InPlay","HitByPitch","CatchersInterference") | KorBB %in% c("Walk","Strikeout"), 1, 0), HBPIndicator = ifelse(PitchCall == "HitByPitch", 1, 0), WalkIndicator = ifelse(KorBB == "Walk", 1, 0), totalbases = dplyr::case_when( PlayResult == "Single" ~ 1, PlayResult == "Double" ~ 2, PlayResult == "Triple" ~ 3, PlayResult == "HomeRun" ~ 4, TRUE ~ 0 ), HHind = ifelse(PitchCall == "InPlay" & ExitSpeed >= 95, 1, 0), Chaseindicator = ifelse(SwingIndicator == 1 & StrikeZoneIndicator == 0, 1, 0), Zwhiffind = ifelse(WhiffIndicator == 1 & StrikeZoneIndicator == 1, 1, 0), Zswing = ifelse(StrikeZoneIndicator == 1 & SwingIndicator == 1, 1, 0) ) df } process_bp_dataset <- function(df) { # Process BP data with different structure if ("Batter" %in% names(df)) { df <- df %>% mutate(Batter = stringr::str_replace(Batter, "^\\s*(\\w+)\\s*,\\s*(\\w+)\\s*$", "\\2 \\1")) } df <- df %>% distinct() if ("PitchUID" %in% names(df)) df <- df %>% distinct(PitchUID, .keep_all = TRUE) if ("Date" %in% names(df)) { df$Date <- parse_flexible_date(df$Date) } # Convert numeric columns if ("PlateLocSide" %in% names(df)) df$PlateLocSide <- as.numeric(df$PlateLocSide) if ("PlateLocHeight" %in% names(df)) df$PlateLocHeight <- as.numeric(df$PlateLocHeight) if ("ExitSpeed" %in% names(df)) df$ExitSpeed <- as.numeric(df$ExitSpeed) if ("Angle" %in% names(df)) df$Angle <- as.numeric(df$Angle) if ("Distance" %in% names(df)) df$Distance <- as.numeric(df$Distance) if ("Bearing" %in% names(df)) df$Bearing <- as.numeric(df$Bearing) if ("ContactPositionX" %in% names(df)) df$ContactPositionX <- as.numeric(df$ContactPositionX) if ("ContactPositionY" %in% names(df)) df$ContactPositionY <- as.numeric(df$ContactPositionY) if ("ContactPositionZ" %in% names(df)) df$ContactPositionZ <- as.numeric(df$ContactPositionZ) # Create BP-specific indicators df <- df %>% mutate( # Filter out bad exit velo data ExitSpeed = ifelse(!is.na(ExitSpeed) & !is.na(Angle) & (ExitSpeed > 120 & Angle < -10 | ExitSpeed < 70), NA, ExitSpeed), # Ball in play indicator BIPind = ifelse(!is.na(ExitSpeed) | !is.na(Angle) | !is.na(Distance), 1, 0), # Launch angle zones LA1030ind = ifelse(BIPind == 1 & !is.na(Angle) & Angle >= 10 & Angle <= 30, 1, 0), # Barrels Barrelind = ifelse(BIPind == 1 & !is.na(ExitSpeed) & !is.na(Angle) & ExitSpeed >= 95 & Angle >= 10 & Angle <= 32, 1, 0), # Hard hits HHind = ifelse(BIPind == 1 & !is.na(ExitSpeed) & ExitSpeed >= 95, 1, 0), # Solid contact SCind = ifelse(BIPind == 1 & !is.na(ExitSpeed) & !is.na(Angle) & ((ExitSpeed > 95 & Angle >= 0 & Angle <= 35) | (ExitSpeed > 92 & Angle >= 8 & Angle <= 35)), 1, 0), # Hit type indicators GBindicator = ifelse(BIPind == 1 & !is.na(TaggedHitType) & TaggedHitType == "GroundBall", 1, 0), LDind = ifelse(BIPind == 1 & !is.na(TaggedHitType) & TaggedHitType == "LineDrive", 1, 0), FBind = ifelse(BIPind == 1 & !is.na(TaggedHitType) & TaggedHitType == "FlyBall", 1, 0), Popind = ifelse(BIPind == 1 & !is.na(TaggedHitType) & TaggedHitType == "Popup", 1, 0) ) df } create_bp_spray_chart <- function(batter_name, bp_data) { chart_data <- bp_data %>% filter(Batter == batter_name, BIPind == 1, !is.na(Distance), !is.na(Bearing)) %>% mutate( Bearing2 = Bearing * pi/180, x = Distance * sin(Bearing2), y = Distance * cos(Bearing2) ) if (!nrow(chart_data)) { return( ggplot() + theme_void() + coord_fixed(xlim = c(-360, 360), ylim = c(-20, 410), expand = FALSE) + annotate("segment", x = 0, y = 0, xend = 247.487, yend = 247.487, color = "gray70") + annotate("segment", x = 0, y = 0, xend = -247.487, yend = 247.487, color = "gray70") + ggtitle(paste("BP Spray Chart:", batter_name)) + annotate("text", x = 0, y = 200, label = "No spray data available", size = 5, color = "gray50") + theme(plot.title = element_text(hjust=0.5, size=10, face="bold")) ) } ggplot(chart_data, aes(x, y)) + coord_fixed(xlim = c(-360, 360), ylim = c(-20, 410), expand = FALSE) + annotate("segment", x = 0, y = 0, xend = 247.487, yend = 247.487, color = "black") + annotate("segment", x = 0, y = 0, xend = -247.487, yend = 247.487, color = "black") + annotate("segment", x = 63.6396, y = 63.6396, xend = 0, yend = 127.279, color = "black") + annotate("segment", x = -63.6396, y = 63.6396, xend = 0, yend = 127.279, color = "black") + annotate("curve", x = 89.095, y = 89.095, xend = 0, yend = 160, curvature = 0.36, linewidth = 0.5, color = "black") + annotate("curve", x = -89.095, y = 89.095, xend = 0, yend = 160, curvature = -0.36, linewidth = 0.5, color = "black") + annotate("curve", x = -247.487, y = 247.487, xend = 247.487, yend = 247.487, curvature = -0.65, linewidth = 0.5, color = "black") + geom_point(aes(fill = ExitSpeed), size = 3, shape = 21, color = "black", stroke = 0.4, alpha = 0.85) + scale_fill_gradient(low = "blue", high = "red", name = "Exit Velo", na.value = "grey50") + theme_void() + ggtitle(paste("BP Spray Chart:", batter_name)) + theme( legend.position = "right", plot.title = element_text(hjust = 0.5, size = 10, face = "bold"), plot.margin = margin(3, 3, 3, 3), legend.title = element_text(size = 8), legend.text = element_text(size = 7), legend.key.height = unit(0.5, "cm"), legend.key.width = unit(0.3, "cm") ) } create_bp_zone_plot <- function(batter_name, bp_data) { zone_data <- bp_data %>% filter(Batter == batter_name, BIPind == 1, !is.na(PlateLocSide), !is.na(PlateLocHeight)) if (!nrow(zone_data)) { return( ggplot() + annotate("rect", xmin = -0.8303, xmax = 0.8303, ymin = 1.5, ymax = 3.38, alpha = 0, size = .5, color = "gray70") + annotate("path", x = c(-0.708, 0.708, 0.708, 0, -0.708, -0.708), y = c(0.15, 0.15, 0.3, 0.5, 0.3, 0.15), color = "gray70", linewidth = 0.5) + coord_fixed(ratio = 1, xlim = c(-2, 2), ylim = c(0, 4.5)) + theme_void() + ggtitle(paste("BP Zone Plot:", batter_name)) + annotate("text", x = 0, y = 2.5, label = "No zone data available", size = 5, color = "gray50") + theme(plot.title = element_text(hjust = 0.5, size = 10, face = "bold")) ) } ggplot(zone_data, aes(x = PlateLocSide, y = PlateLocHeight)) + geom_point(aes(fill = ExitSpeed), size = 3, shape = 21, color = "black", stroke = 0.4, alpha = 0.8) + scale_fill_gradient(low = "blue", high = "red", name = "Exit Velo", na.value = "grey50") + annotate("rect", xmin = -0.8303, xmax = 0.8303, ymin = 1.5, ymax = 3.38, fill = NA, color = "black", linewidth = 0.8) + annotate("path", x = c(-0.708, 0.708, 0.708, 0, -0.708, -0.708), y = c(0.15, 0.15, 0.3, 0.5, 0.3, 0.15), color = "black", linewidth = 0.6) + coord_fixed(ratio = 1, xlim = c(-2, 2), ylim = c(0, 4.5)) + ggtitle(paste("BP Zone Plot:", batter_name)) + theme_void() + theme( legend.position = "right", plot.title = element_text(hjust = 0.5, size = 10, face = "bold"), plot.margin = margin(3, 3, 3, 3), legend.title = element_text(size = 8), legend.text = element_text(size = 7), legend.key.height = unit(0.5, "cm"), legend.key.width = unit(0.3, "cm") ) } create_bp_contact_map <- function(batter_name, bp_data) { contact_data <- bp_data %>% filter(Batter == batter_name, BIPind == 1, !is.na(ExitSpeed), !is.na(ContactPositionZ), !is.na(ContactPositionX), !is.na(ContactPositionY)) %>% mutate( ContactPositionX = ContactPositionX * 12, ContactPositionY = ContactPositionY * 12, ContactPositionZ = ContactPositionZ * 12 ) if (!nrow(contact_data)) { return( ggplot() + annotate("segment", x = -8.5, y = 17, xend = 8.5, yend = 17, color = "gray70", linewidth = 0.5) + annotate("segment", x = 8.5, y = 8.5, xend = 8.5, yend = 17, color = "gray70", linewidth = 0.5) + annotate("segment", x = -8.5, y = 8.5, xend = -8.5, yend = 17, color = "gray70", linewidth = 0.5) + annotate("rect", xmin = 20, xmax = 48, ymin = -20, ymax = 40, fill = NA, color = "gray70", linewidth = 0.5) + annotate("rect", xmin = -48, xmax = -20, ymin = -20, ymax = 40, fill = NA, color = "gray70", linewidth = 0.5) + xlim(-50, 50) + ylim(-20, 50) + coord_fixed() + theme_void() + ggtitle(paste("BP Contact Points:", batter_name)) + annotate("text", x = 0, y = 20, label = "No contact data available", size = 5, color = "gray50") + theme(plot.title = element_text(hjust = 0.5, size = 10, face = "bold")) ) } batter_side <- unique(contact_data$BatterSide)[1] if (is.na(batter_side)) batter_side <- "Right" ggplot(contact_data, aes(x = ContactPositionZ, y = ContactPositionX)) + annotate("segment", x = -8.5, y = 17, xend = 8.5, yend = 17, color = "black", linewidth = 0.5) + annotate("segment", x = 8.5, y = 8.5, xend = 8.5, yend = 17, color = "black", linewidth = 0.5) + annotate("segment", x = -8.5, y = 8.5, xend = -8.5, yend = 17, color = "black", linewidth = 0.5) + annotate("segment", x = -8.5, y = 8.5, xend = 0, yend = 0, color = "black", linewidth = 0.5) + annotate("segment", x = 8.5, y = 8.5, xend = 0, yend = 0, color = "black", linewidth = 0.5) + annotate("rect", xmin = 20, xmax = 48, ymin = -20, ymax = 40, fill = NA, color = "black", linewidth = 0.5) + annotate("rect", xmin = -48, xmax = -20, ymin = -20, ymax = 40, fill = NA, color = "black", linewidth = 0.5) + annotate("text", x = ifelse(batter_side == "Right", -34, 34), y = 10, label = ifelse(batter_side == "Right", "R", "L"), size = 7, fontface = "bold") + xlim(-50, 50) + ylim(-20, 50) + geom_point(aes(fill = ExitSpeed), color = "black", stroke = 0.4, shape = 21, alpha = 0.85, size = 2.5) + scale_fill_gradient(name = "Exit Velo", low = "blue", high = "red") + coord_fixed() + ggtitle(paste("BP Contact Points:", batter_name)) + theme_void() + theme( legend.position = "right", plot.title = element_text(hjust = 0.5, size = 10, face = "bold"), plot.margin = margin(3, 3, 3, 3), legend.title = element_text(size = 8), legend.text = element_text(size = 7), legend.key.height = unit(0.5, "cm"), legend.key.width = unit(0.3, "cm") ) } create_bp_pdf <- function(bp_data, batter_name, output_file) { if (length(dev.list()) > 0) try(dev.off(), silent = TRUE) batter_df <- filter(bp_data, Batter == batter_name) # Calculate stats in the order: BBE, Avg EV, Avg LA, Max EV, SC%, 10-30%, HH%, Barrel% stats <- batter_df %>% summarise( BBE = sum(BIPind, na.rm = TRUE), `Avg EV` = round(mean(ExitSpeed[BIPind == 1], na.rm = TRUE), 1), `Avg LA` = round(mean(Angle[BIPind == 1], na.rm = TRUE), 1), `Max EV` = round(max(ExitSpeed[BIPind == 1], na.rm = TRUE), 1), `SC%` = round(sum(SCind, na.rm = TRUE) / sum(BIPind, na.rm = TRUE) * 100, 1), `10-30%` = round(sum(LA1030ind, na.rm = TRUE) / sum(BIPind, na.rm = TRUE) * 100, 1), `HH%` = round(sum(HHind, na.rm = TRUE) / sum(BIPind, na.rm = TRUE) * 100, 1), `Barrel%` = round(sum(Barrelind, na.rm = TRUE) / sum(BIPind, na.rm = TRUE) * 100, 1), .groups = "drop" ) # Create plots (smaller sizes) spray_plot <- create_bp_spray_chart(batter_name, bp_data) zone_plot <- create_bp_zone_plot(batter_name, bp_data) contact_plot <- create_bp_contact_map(batter_name, bp_data) # Create PDF pdf(output_file, width = 11, height = 8.5) on.exit(try(dev.off(), silent = TRUE), add = TRUE) grid::grid.newpage() # Title section grid::pushViewport(grid::viewport(x = 0.5, y = 0.97, width = 1, height = 0.05, just = c("center", "top"))) grid::grid.text("BP Report", gp = grid::gpar(fontface = "bold", cex = 1.3, col = "#006F71")) grid::popViewport() grid::pushViewport(grid::viewport(x = 0.5, y = 0.93, width = 1, height = 0.04, just = c("center", "top"))) grid::grid.text(batter_name, gp = grid::gpar(fontface = "bold", cex = 1.6, col = "black")) grid::popViewport() # Stats table with NO color coding (all white) headers <- c("BBE", "Avg EV", "Avg LA", "Max EV", "SC%", "10-30%", "HH%", "Barrel%") values <- c(stats$BBE, stats$`Avg EV`, stats$`Avg LA`, stats$`Max EV`, stats$`SC%`, stats$`10-30%`, stats$`HH%`, stats$`Barrel%`) col_w <- 0.09 x0 <- 0.5 - (length(headers) * col_w) / 2 yh <- 0.87 yv <- 0.85 for (i in seq_along(headers)) { xi <- x0 + (i - 1) * col_w # Header with teal background grid::grid.rect(x = xi, y = yh, width = col_w * 0.985, height = 0.018, just = c("left", "top"), gp = grid::gpar(fill = "#006F71", col = "black", lwd = 0.5)) grid::grid.text(headers[i], x = xi + col_w * 0.49, y = yh - 0.009, gp = grid::gpar(col = "white", cex = 0.70, fontface = "bold")) # Value cell - NO color coding, all white val <- values[i] grid::grid.rect(x = xi, y = yv, width = col_w * 0.985, height = 0.018, just = c("left", "top"), gp = grid::gpar(fill = "white", col = "black", lwd = 0.4)) grid::grid.text(ifelse(is.finite(val), as.character(val), "-"), x = xi + col_w * 0.49, y = yv - 0.009, gp = grid::gpar(cex = 0.70)) } # Spray chart (left) - SMALLER grid::pushViewport(grid::viewport(x = 0.25, y = 0.77, width = 0.40, height = 0.38, just = c("center", "top"))) print(spray_plot, newpage = FALSE) grid::popViewport() # Zone plot (right) - SMALLER grid::pushViewport(grid::viewport(x = 0.75, y = 0.77, width = 0.40, height = 0.38, just = c("center", "top"))) print(zone_plot, newpage = FALSE) grid::popViewport() # Contact map (bottom center) - SMALLER grid::pushViewport(grid::viewport(x = 0.5, y = 0.35, width = 0.42, height = 0.20, just = c("center", "top"))) print(contact_plot, newpage = FALSE) grid::popViewport() invisible(output_file) } parse_game_day <- function(df, tz = "America/New_York") { stopifnot("Date" %in% names(df)) if (inherits(df$Date, "Date")) { dates <- df$Date[!is.na(df$Date)] if (length(dates) > 0) { tab <- sort(table(dates), decreasing = TRUE) return(as.Date(names(tab)[1])) } } as.Date(df$Date[1]) } create_at_bats_plot <- function(batter_data, player_name, game_key, pitch_colors, max_lines_per_col = 16L) { df <- dplyr::filter(batter_data, Batter == player_name) if (!nrow(df)) { return(ggplot2::ggplot() + ggplot2::theme_void() + ggplot2::ggtitle(paste("No data for", player_name)) + ggplot2::theme(plot.title = ggplot2::element_text(hjust = 0.5, size = 14, face = "bold"))) } plot_data <- df %>% arrange(PitchNo) %>% mutate( pa_break = (PitchofPA == 1), pa_number = cumsum(pa_break) ) %>% ungroup() %>% mutate( PlayResult = na_if(str_squish(PlayResult), "Undefined"), PitchCall_display = dplyr::case_when( PitchCall == "StrikeSwinging" ~ "Whiff", PitchCall == "StrikeCalled" ~ "CS", PitchCall %in% c("FoulBall","FoulBallNotFieldable","FoulBallFieldable") ~ "Foul", PitchCall %in% c("BallCalled","BallinDirt","BallIntentional") ~ "Ball", PitchCall == "HitByPitch" ~ "HBP", PitchCall == "InPlay" ~ "In Play", TRUE ~ coalesce(PitchCall, "—") ), BIP_display = dplyr::case_when( PlayResult %in% c("Single","Double","Triple","HomeRun") ~ dplyr::recode(PlayResult, Single="1B", Double="2B", Triple="3B", HomeRun="HR"), PlayResult == "FieldersChoice" ~ "FC", PlayResult %in% c("Out","Error","Sacrifice","SacrificeFly") ~ PlayResult, TRUE ~ NA_character_ ), PlayResult_clean = dplyr::case_when( PitchCall_display == "In Play" ~ coalesce(BIP_display, "Out"), TRUE ~ PitchCall_display ) ) %>% dplyr::group_by(pa_number) %>% dplyr::mutate( line_idx = row_number(), col_idx = ((line_idx - 1L) %/% max_lines_per_col) + 1L, row_idx = ((line_idx - 1L) %% max_lines_per_col) + 1L ) %>% dplyr::ungroup() %>% dplyr::mutate( text_x = (20 + (col_idx - 1L) * 12) / 12, text_y_main = (50 - (row_idx * 3)) / 12, text_y_ev = (50 - (row_idx * 3) - 3) / 12 ) used_second_col <- any(plot_data$col_idx > 1) x_max <- if (used_second_col) ((35 + 12) / 12) else 35/12 y_min_needed <- suppressWarnings(min(c(-1/12, min(plot_data$text_y_ev, na.rm = TRUE) - 0.05), na.rm = TRUE)) ggplot2::ggplot(plot_data, ggplot2::aes(PlateLocSide, PlateLocHeight)) + ggplot2::annotate("rect", xmin = -0.8303, xmax = 0.8303, ymin = 1.5, ymax = 3.3775, alpha = 0, size = .5, color = "black") + ggplot2::annotate("segment", x = -0.708, y = 0.15, xend = 0.708, yend = 0.15, size = .5, color = "black") + ggplot2::annotate("segment", x = -0.708, y = 0.30, xend = -0.708, yend = 0.15, size = .5, color = "black") + ggplot2::annotate("segment", x = 0.708, y = 0.30, xend = 0.708, yend = 0.15, size = .5, color = "black") + ggplot2::annotate("segment", x = -0.708, y = 0.30, xend = 0.000, yend = 0.50, size = .5, color = "black") + ggplot2::annotate("segment", x = 0.708, y = 0.30, xend = 0.000, yend = 0.50, size = .5, color = "black") + ggplot2::geom_point(ggplot2::aes(fill = TaggedPitchType), alpha = 1, shape = 21, color = "black", stroke = 0.5, size = 4) + ggplot2::geom_text(ggplot2::aes(label = PitchofPA), vjust = 0.5, size = 2.2, color = "white", fontface = "bold") + geom_text( aes(x = text_x, y = text_y_main, label = paste(PitchofPA, ":", PlayResult_clean)), inherit.aes = FALSE, size = 2.1, hjust = 0 ) + geom_text( aes(x = text_x, y = text_y_ev, label = ifelse(PitchCall_display == "In Play" & !is.na(ExitSpeed), paste0(round(ExitSpeed), " EV"), "")), inherit.aes = FALSE, size = 2.0, hjust = 0 ) + ggplot2::facet_wrap(~ pa_number, ncol = 5) + ggplot2::theme_void() + ggplot2::scale_x_continuous(NULL, limits = c(-20/12, x_max)) + ggplot2::scale_y_continuous(NULL, limits = c(y_min_needed, 60/12)) + ggplot2::coord_fixed(ratio = 1.3, clip = "off") + ggplot2::scale_fill_manual(values = c( "Fastball" = "#FA8072", "FourSeamFastBall" = "#FA8072","Four-Seam" = "#FA8072", "Sinker" = "#fdae61", "Slider" = "#A020F0", "Sweeper" = "magenta", "Curveball" = "#2c7bb6", "ChangeUp" = "#90EE90", "Splitter" = "#90EE32", "Cutter" = "red" ), name = "Pitch Type") + ggplot2::theme( panel.background = ggplot2::element_rect(fill = "#ffffff", color = NA), legend.position = "top", strip.text = ggplot2::element_text(size = 1, vjust = 1), strip.placement = "outside", strip.background = ggplot2::element_blank(), plot.margin = ggplot2::margin(6, 18, 6, 6), panel.spacing = grid::unit(8, "pt") ) } create_report_spray_chart <- function(game_data, player_name) { spray_data <- game_data %>% dplyr::filter(Batter == player_name) %>% dplyr::arrange(PitchNo) %>% dplyr::mutate(PitchNumber = dplyr::row_number()) %>% dplyr::filter(!is.na(Distance), !is.na(Bearing), PitchCall == "InPlay", !PitchCall %in% c("FoulBall","FoulBallNotFieldable","FoulBallFieldable")) %>% dplyr::mutate( Bearing2 = Bearing * pi/180, x = Distance * sin(Bearing2), y = Distance * cos(Bearing2) ) if (!nrow(spray_data)) { return( ggplot2::ggplot() + ggplot2::coord_fixed(xlim = c(-360, 360), ylim = c(-20, 410), expand = FALSE) + ggplot2::annotate("segment", x = 0, y = 0, xend = 247.487, yend = 247.487, color = "gray70") + ggplot2::annotate("segment", x = 0, y = 0, xend = -247.487, yend = 247.487, color = "gray70") + ggplot2::annotate("segment", x = 63.6396, y = 63.6396, xend = 0, yend = 127.279, color = "gray70") + ggplot2::annotate("segment", x = -63.6396, y = 63.6396, xend = 0, yend = 127.279, color = "gray70") + ggplot2::annotate("curve", x = 89.095, y = 89.095, xend = 0, yend = 160, curvature = 0.36, linewidth = 0.5, color = "gray70") + ggplot2::annotate("curve", x = -89.095, y = 89.095, xend = 0, yend = 160, curvature = -0.36, linewidth = 0.5, color = "gray70") + ggplot2::annotate("curve", x = -247.487, y = 247.487, xend = 247.487, yend = 247.487, curvature = -0.65, linewidth = 0.5, color = "gray70") + ggplot2::ggtitle(paste(player_name, "- Spray Chart")) + ggplot2::theme_void() + ggplot2::theme( plot.margin = ggplot2::margin(5, 5, 5, 5), plot.title = ggplot2::element_text(hjust = 0.5, size = 9, face = "bold") ) ) } ggplot2::ggplot(spray_data, ggplot2::aes(x, y)) + ggplot2::coord_fixed(xlim = c(-360, 360), ylim = c(-20, 410), expand = FALSE) + ggplot2::annotate("segment", x = 0, y = 0, xend = 247.487, yend = 247.487, color = "black") + ggplot2::annotate("segment", x = 0, y = 0, xend = -247.487, yend = 247.487, color = "black") + ggplot2::annotate("segment", x = 63.6396, y = 63.6396, xend = 0, yend = 127.279, color = "black") + ggplot2::annotate("segment", x = -63.6396, y = 63.6396, xend = 0, yend = 127.279, color = "black") + ggplot2::annotate("curve", x = 89.095, y = 89.095, xend = 0, yend = 160, curvature = 0.36, linewidth = 0.5, color = "black") + ggplot2::annotate("curve", x = -89.095, y = 89.095, xend = 0, yend = 160, curvature = -0.36, linewidth = 0.5, color = "black") + ggplot2::annotate("curve", x = -247.487, y = 247.487, xend = 247.487, yend = 247.487, curvature = -0.65, linewidth = 0.5, color = "black") + ggplot2::geom_point(size = 2.8, shape = 21, color = "black", fill = "darkred", stroke = 0.4, alpha = 0.85) + ggplot2::geom_text(ggplot2::aes(label = PitchNumber), size = 1.8, color = "white", fontface = "bold") + ggplot2::ggtitle(paste(player_name, "- Spray Chart")) + ggplot2::theme_void() + ggplot2::theme( plot.margin = ggplot2::margin(5, 5, 5, 5), plot.title = ggplot2::element_text(hjust = 0.5, size = 9, face = "bold") ) } create_report_contact_chart <- function(game_data, player_name) { contact_data <- game_data %>% filter(Batter == player_name) %>% arrange(PitchNo) %>% mutate(PitchNumber = row_number()) %>% filter(!is.na(ExitSpeed), !is.na(ContactPositionZ), !is.na(ContactPositionX), !is.na(ContactPositionY), PitchCall == "InPlay", !PitchCall %in% c("FoulBall", "FoulBallNotFieldable", "FoulBallFieldable")) %>% mutate(ContactPositionX = ContactPositionX*12, ContactPositionY = ContactPositionY*12, ContactPositionZ = ContactPositionZ*12) if (!nrow(contact_data)) { return( ggplot() + annotate("segment", x = -8.5, y = 17, xend = 8.5, yend = 17, color = "gray70", linewidth = 0.5) + annotate("segment", x = 8.5, y = 8.5, xend = 8.5, yend = 17, color = "gray70", linewidth = 0.5) + annotate("segment", x = -8.5, y = 8.5, xend = -8.5, yend = 17, color = "gray70", linewidth = 0.5) + annotate("segment", x = -8.5, y = 8.5, xend = 0, yend = 0, color = "gray70", linewidth = 0.5) + annotate("segment", x = 8.5, y = 8.5, xend = 0, yend = 0, color = "gray70", linewidth = 0.5) + annotate("rect", xmin = 20, xmax = 48, ymin = -20, ymax = 40, fill = NA, color = "gray70", linewidth = 0.5) + annotate("rect", xmin = -48, xmax = -20, ymin = -20, ymax = 40, fill = NA, color = "gray70", linewidth = 0.5) + xlim(-50, 50) + ylim(-20, 50) + coord_fixed() + ggtitle(paste(player_name, "- Contact Points")) + theme_void() + theme( plot.margin = margin(2, 2, 2, 2), plot.title = element_text(hjust = 0.5, size = 9, face = "bold") ) ) } batter_side <- contact_data$BatterSide[1]; if (is.na(batter_side)) batter_side <- "Right" ggplot(contact_data, aes(x = ContactPositionZ, y = ContactPositionX)) + annotate("segment", x = -8.5, y = 17, xend = 8.5, yend = 17, color = "black", linewidth = 0.5) + annotate("segment", x = 8.5, y = 8.5, xend = 8.5, yend = 17, color = "black", linewidth = 0.5) + annotate("segment", x = -8.5, y = 8.5, xend = -8.5, yend = 17, color = "black", linewidth = 0.5) + annotate("segment", x = -8.5, y = 8.5, xend = 0, yend = 0, color = "black", linewidth = 0.5) + annotate("segment", x = 8.5, y = 8.5, xend = 0, yend = 0, color = "black", linewidth = 0.5) + annotate("rect", xmin = 20, xmax = 48, ymin = -20, ymax = 40, fill = NA, color = "black", linewidth = 0.5) + annotate("rect", xmin = -48, xmax = -20, ymin = -20, ymax = 40, fill = NA, color = "black", linewidth = 0.5) + annotate("text", x = ifelse(batter_side == "Right", -34, 34), y = 10, label = ifelse(batter_side == "Right", "R", "L"), size = 3.5, fontface = "bold") + xlim(-50, 50) + ylim(-20, 50) + geom_point(aes(fill = ExitSpeed), color = "black", stroke = .25, shape = 21, alpha = .85, size = 2.8) + geom_text(aes(label = PitchNumber), size = 1.7, color = "white", fontface = "bold") + scale_fill_gradient(name = "Exit Velo", low = "#E1463E", high = "#00840D") + coord_fixed() + ggtitle(paste(player_name, "- Contact Points")) + theme_void() + theme( legend.position = "right", plot.margin = margin(2, 2, 2, 2), plot.title = element_text(hjust = 0.5, size = 9, face = "bold"), legend.title = element_text(size = 7), legend.text = element_text(size = 6), legend.key.height = unit(0.4, "cm"), legend.key.width = unit(0.3, "cm") ) } calculate_leaderboards <- function(df, team_meta_df = team_meta) { format_name <- function(name) { if (is.na(name)) return(name) stringr::str_replace(name, "^\\s*(\\w+)\\s*,\\s*(\\w+)\\s*$", "\\2 \\1") } get_logo <- function(team_abbr) { if (is.null(team_meta_df) || is.null(team_abbr) || is.na(team_abbr)) return("") team_abbr <- trimws(as.character(team_abbr)) if (team_abbr == "") return("") tmb_abbrs <- trimws(as.character(team_meta_df$team_abbr)) idx <- which(tmb_abbrs == team_abbr) if (length(idx) > 0) return(team_meta_df$BTeamLogo[idx[1]]) idx <- which(tolower(tmb_abbrs) == tolower(team_abbr)) if (length(idx) > 0) return(team_meta_df$BTeamLogo[idx[1]]) "" } get_team_name <- function(abbr) { if (is.null(team_meta_df) || is.null(abbr) || is.na(abbr)) return(as.character(abbr)) abbr <- trimws(as.character(abbr)) if (abbr == "") return(abbr) tmb_abbrs <- trimws(as.character(team_meta_df$team_abbr)) idx <- which(tmb_abbrs == abbr) if (length(idx) > 0) return(team_meta_df$BTeamName[idx[1]]) idx <- which(tolower(tmb_abbrs) == tolower(abbr)) if (length(idx) > 0) return(team_meta_df$BTeamName[idx[1]]) abbr } # Game Info stadium <- if ("Stadium" %in% names(df)) unique(na.omit(df$Stadium))[1] else "Unknown" level <- if ("Level" %in% names(df)) unique(na.omit(df$Level))[1] else "" league <- if ("League" %in% names(df)) unique(na.omit(df$League))[1] else "" game_date <- if ("Date" %in% names(df)) { raw_date <- unique(na.omit(df$Date))[1] parsed <- tryCatch(parse_flexible_date(raw_date), error = function(e) NA) if (is.na(parsed)) "N/A" else format(parsed, "%m/%d/%Y") } else "N/A" # Calculate final score from RunsScored teams <- unique(c(df$BatterTeam, df$PitcherTeam)) teams <- teams[!is.na(teams)] score_info <- df %>% filter(!is.na(RunsScored), RunsScored > 0) %>% group_by(BatterTeam) %>% summarise(Runs = sum(RunsScored, na.rm = TRUE), .groups = "drop") %>% arrange(desc(Runs)) if (nrow(score_info) >= 2) { team1 <- score_info$BatterTeam[1] runs1 <- score_info$Runs[1] team2 <- score_info$BatterTeam[2] runs2 <- score_info$Runs[2] final_score <- paste0(get_team_name(team1), " ", runs1, " - ", get_team_name(team2), " ", runs2) } else { final_score <- "Score N/A" } game_info <- list( stadium = stadium, level = level, league = league, date = game_date, final_score = final_score ) top_ev <- df %>% filter(!is.na(ExitSpeed), !is.na(Batter)) %>% select(Batter, BatterTeam, ExitSpeed) %>% arrange(desc(ExitSpeed)) %>% head(5) %>% rename(MaxEV = ExitSpeed) %>% mutate(Batter = sapply(Batter, format_name), Logo = sapply(BatterTeam, get_logo)) top_dist <- df %>% filter(!is.na(Distance), !is.na(Batter), Distance > 0) %>% select(Batter, BatterTeam, Distance) %>% arrange(desc(Distance)) %>% head(5) %>% rename(MaxDist = Distance) %>% mutate(Batter = sapply(Batter, format_name), Logo = sapply(BatterTeam, get_logo)) top_velo <- df %>% filter(!is.na(RelSpeed), !is.na(Pitcher)) %>% select(Pitcher, PitcherTeam, RelSpeed) %>% arrange(desc(RelSpeed)) %>% head(5) %>% rename(MaxVelo = RelSpeed) %>% mutate(Pitcher = sapply(Pitcher, format_name), Logo = sapply(PitcherTeam, get_logo)) top_whiffs <- df %>% filter(!is.na(Pitcher)) %>% group_by(Pitcher, PitcherTeam) %>% summarise(Whiffs = sum(PitchCall == "StrikeSwinging", na.rm = TRUE), .groups = "drop") %>% arrange(desc(Whiffs)) %>% head(5) %>% mutate(Pitcher = sapply(Pitcher, format_name), Logo = sapply(PitcherTeam, get_logo)) list( game_info = game_info, exit_velo = top_ev, distance = top_dist, pitch_velo = top_velo, whiffs = top_whiffs ) } create_simple_header <- function(player_name, game_date, bio_data = NULL) { library(grid) library(gridExtra) # Load logos left_logo <- tryCatch({ rasterGrob(as.raster(magick::image_resize( magick::image_read("https://i.ibb.co/gLfTW4Fz/t-GPe-TPu.png"), "x120" )), interpolate = TRUE) }, error = function(e) nullGrob()) right_logo <- tryCatch({ rasterGrob(as.raster(magick::image_resize( magick::image_read("https://i.imgur.com/zjTu3JS.png"), "x120" )), interpolate = TRUE) }, error = function(e) nullGrob()) # Title text title_text <- paste(player_name, "-", format(game_date, "%m/%d/%y"), "- Hitter Report") title_grob <- textGrob( title_text, gp = gpar(fontsize = 18, fontface = "bold", col = "#006F71") ) # Layout with logos arrangeGrob( arrangeGrob(left_logo, title_grob, right_logo, ncol = 3, widths = c(.15, .7, .15)), ncol = 1 ) } # Helper function to check if bat tracking data is available has_bat_tracking <- function(df) { bat_cols <- c("BatSpeed", "VerticalAttackAngle", "HorizontalAttackAngle") cols_present <- bat_cols %in% names(df) if (!all(cols_present)) return(FALSE) # Check if there's at least some non-NA data in any of these columns any_data <- any( !is.na(df$BatSpeed) | !is.na(df$VerticalAttackAngle) | !is.na(df$HorizontalAttackAngle) ) return(any_data) } create_postgame_pdf <- function(game_df, player_name, output_file, bio_data = NULL) { if (length(dev.list()) > 0) { try(dev.off(), silent = TRUE) } pitch_colors <- c( "Fastball" = "#FA8072", "Four-Seam" = "#FA8072", "FourSeamFastBall" = "#FA8072", "Sinker" = "#fdae61", "Slider" = "#A020F0", "Sweeper" = "magenta", "Curveball" = "#2c7bb6", "ChangeUp" = "#90EE90", "Splitter" = "#90EE32", "Cutter" = "red" ) batter_df <- dplyr::filter(game_df, Batter == player_name) game_day <- parse_game_day(batter_df, tz = "America/New_York") game_key <- format(game_day, "%Y-%m-%d") # Check if bat tracking data is available bat_tracking_available <- has_bat_tracking(batter_df) game_stats <- batter_df %>% summarise( PA = sum(PAindicator, na.rm = TRUE), H = sum(HitIndicator, na.rm = TRUE), XBH = sum(PlayResult %in% c("Double","Triple","HomeRun"), na.rm = TRUE), BB = sum(WalkIndicator, na.rm = TRUE), K = sum(KorBB == "Strikeout", na.rm = TRUE), Chase = sum(Chaseindicator, na.rm = TRUE), Whiffs = sum(WhiffIndicator, na.rm = TRUE), `IZ Whiffs` = sum(Zwhiffind, na.rm = TRUE), BIP = sum(BIPind, na.rm = TRUE), `Avg EV` = round(mean(ExitSpeed[PitchCall == "InPlay"], na.rm = TRUE), 1), `Avg LA` = round(mean(Angle[PitchCall == "InPlay"], na.rm = TRUE), 1), HH = sum(HHind, na.rm = TRUE), .groups = "drop" ) pitch_sequence <- batter_df %>% arrange(PitchNo) %>% mutate(PitchNumber = row_number()) %>% select(PitchNumber, dplyr::everything()) at_bats_plot <- create_at_bats_plot(game_df, player_name, game_key, pitch_colors) + theme( legend.position = "top", plot.margin = margin(2,2,2,2), axis.title = element_blank(), axis.text = element_blank(), axis.ticks = element_blank(), strip.text = element_text(size = 9), legend.title = element_text(size = 9), legend.text = element_text(size = 8) ) spray_plot <- create_report_spray_chart(game_df, player_name) contact_plot <- create_report_contact_chart(game_df, player_name) # Build pitch log with conditional bat tracking columns # New order: Inning, Pitcher, Count, Pitch, Velo, IVB, HB, VAA, EV, LA, Dist, Bat Speed, AA, HAA pitch_log <- pitch_sequence %>% filter(PitchCall == "InPlay") %>% mutate( Throws = ifelse(PitcherThrows == "Right", "R", "L"), event = dplyr::case_when( !is.na(PlayResult) & PlayResult != "Undefined" ~ PlayResult, TRUE ~ "Out" ), Count = paste0(Balls, "-", Strikes), EV = round(ExitSpeed), LA = round(Angle), Dist = ifelse(!is.na(Distance), round(Distance), NA), Velo = round(RelSpeed, 1), # Pitch movement metrics IVB = ifelse("InducedVertBreak" %in% names(.) & !is.na(InducedVertBreak), round(InducedVertBreak, 1), NA), HB = ifelse("HorzBreak" %in% names(.) & !is.na(HorzBreak), round(HorzBreak, 1), NA), # Vertical Approach Angle (pitch) VAA = ifelse("VertApprAngle" %in% names(.) & !is.na(VertApprAngle), round(VertApprAngle, 1), NA) ) # Add bat tracking columns if available if (bat_tracking_available) { pitch_log <- pitch_log %>% mutate( BatSpd = ifelse("BatSpeed" %in% names(.) & !is.na(BatSpeed), round(BatSpeed, 1), NA), AA = ifelse("VerticalAttackAngle" %in% names(.) & !is.na(VerticalAttackAngle), round(VerticalAttackAngle, 1), NA), HAA = ifelse("HorizontalAttackAngle" %in% names(.) & !is.na(HorizontalAttackAngle), round(HorizontalAttackAngle, 1), NA) ) } # Select columns based on availability if (bat_tracking_available) { pitch_log <- pitch_log %>% select(PitchNumber, Inning, Pitcher, Count, TaggedPitchType, Velo, IVB, HB, VAA, event, EV, LA, Dist, BatSpd, AA, HAA) } else { pitch_log <- pitch_log %>% select(PitchNumber, Inning, Pitcher, Count, TaggedPitchType, Velo, IVB, HB, VAA, event, EV, LA, Dist) } chart_y <- 0.36 chart_h <- 0.22 plot_w <- 0.35 table_title_y <- 0.13 table_y <- 0.11 # Updated draw function with bat tracking support draw_pitch_table <- function(df, y_top, row_height = 0.0135, cex = 0.58, include_bat_tracking = FALSE) { if (include_bat_tracking) { # Headers with bat tracking: #, Inn, Pitcher, Count, Pitch, Velo, IVB, HB, VAA, Event, EV, LA, Dist, BatSpd, AA, HAA headers <- c("#", "Inn", "Pitcher", "Count", "Pitch", "Velo", "IVB", "HB", "VAA", "Event", "EV", "LA", "Dist", "BatSpd", "AA", "HAA") widths <- c(0.025, 0.03, 0.12, 0.04, 0.065, 0.04, 0.04, 0.04, 0.04, 0.065, 0.035, 0.035, 0.04, 0.045, 0.04, 0.04) } else { # Headers without bat tracking: #, Inn, Pitcher, Count, Pitch, Velo, IVB, HB, VAA, Event, EV, LA, Dist headers <- c("#", "Inn", "Pitcher", "Count", "Pitch", "Velo", "IVB", "HB", "VAA", "Event", "EV", "LA", "Dist") widths <- c(0.03, 0.035, 0.15, 0.05, 0.08, 0.05, 0.05, 0.05, 0.05, 0.08, 0.045, 0.045, 0.05) } x_start <- 0.5 - sum(widths)/2 x_pos <- c(x_start, x_start + cumsum(widths[-length(widths)])) # Draw headers for (i in seq_along(headers)) { grid.rect(x = x_pos[i], y = y_top, width = widths[i]*0.985, height = row_height, just = c("left","top"), gp = gpar(fill = "#006F71", col = "black", lwd = 0.4)) grid.text(headers[i], x = x_pos[i] + widths[i]*0.49, y = y_top - row_height*0.5, gp = gpar(col = "white", cex = cex, fontface = "bold")) } # Draw rows for (r in seq_len(nrow(df))) { y_row <- y_top - r*row_height if (include_bat_tracking) { row_vals <- c( df$PitchNumber[r], ifelse(is.na(df$Inning[r]), "-", df$Inning[r]), df$Pitcher[r], df$Count[r], df$TaggedPitchType[r], ifelse(is.na(df$Velo[r]), "-", df$Velo[r]), ifelse(is.na(df$IVB[r]), "-", df$IVB[r]), ifelse(is.na(df$HB[r]), "-", df$HB[r]), ifelse(is.na(df$VAA[r]), "-", df$VAA[r]), df$event[r], ifelse(is.na(df$EV[r]), "-", df$EV[r]), ifelse(is.na(df$LA[r]), "-", df$LA[r]), ifelse(is.na(df$Dist[r]), "-", df$Dist[r]), ifelse(is.na(df$BatSpd[r]), "-", df$BatSpd[r]), ifelse(is.na(df$AA[r]), "-", df$AA[r]), ifelse(is.na(df$HAA[r]), "-", df$HAA[r]) ) } else { row_vals <- c( df$PitchNumber[r], ifelse(is.na(df$Inning[r]), "-", df$Inning[r]), df$Pitcher[r], df$Count[r], df$TaggedPitchType[r], ifelse(is.na(df$Velo[r]), "-", df$Velo[r]), ifelse(is.na(df$IVB[r]), "-", df$IVB[r]), ifelse(is.na(df$HB[r]), "-", df$HB[r]), ifelse(is.na(df$VAA[r]), "-", df$VAA[r]), df$event[r], ifelse(is.na(df$EV[r]), "-", df$EV[r]), ifelse(is.na(df$LA[r]), "-", df$LA[r]), ifelse(is.na(df$Dist[r]), "-", df$Dist[r]) ) } for (i in seq_along(row_vals)) { grid.rect(x = x_pos[i], y = y_row, width = widths[i]*0.985, height = row_height, just = c("left","top"), gp = gpar(fill = ifelse(r %% 2 == 0, "#f7f7f7", "white"), col = "grey80", lwd = 0.3)) grid.text(as.character(row_vals[i]), x = x_pos[i] + widths[i]*0.49, y = y_row - row_height*0.5, gp = gpar(cex = cex)) } } } pdf(output_file, width = 10.5, height = 13) on.exit(try(dev.off(), silent = TRUE), add = TRUE) grid::grid.newpage() pushViewport(viewport(x = 0.5, y = 0.98, width = 0.94, height = 0.08, just = c("center","top"))) grid.draw(create_simple_header(player_name, game_day, bio_data)) popViewport() grid.text("Game Line", x = 0.5, y = 0.89, gp = gpar(fontface = "bold", cex = 1.2)) headers <- c("PA","H","XBH","BB","K","Chase","Whiffs","IZ Whiffs","BIP","Avg EV","Avg LA","HH") values <- c(game_stats$PA, game_stats$H, game_stats$XBH, game_stats$BB, game_stats$K, game_stats$Chase, game_stats$Whiffs, game_stats$`IZ Whiffs`, game_stats$BIP, game_stats$`Avg EV`, game_stats$`Avg LA`, game_stats$HH) col_w <- 0.065; x0 <- 0.5 - (length(headers)*col_w)/2; yh <- 0.865; yv <- 0.840 for (i in seq_along(headers)) { xi <- x0 + (i-1)*col_w grid.rect(x = xi, y = yh, width = col_w*0.985, height = 0.022, just = c("left","top"), gp = gpar(fill = "#006F71", col = "black", lwd = 0.5)) grid.text(headers[i], x = xi + col_w*0.49, y = yh - 0.011, gp = gpar(col = "white", cex = 0.72, fontface = "bold")) grid.rect(x = xi, y = yv, width = col_w*0.985, height = 0.022, just = c("left","top"), gp = gpar(fill = "white", col = "black", lwd = 0.4)) grid.text(as.character(values[i]), x = xi + col_w*0.49, y = yv - 0.011, gp = gpar(cex = 0.72)) } pushViewport(viewport(x = 0.5, y = 0.795, width = 0.96, height = 0.44, just = c("center","top"))) print(at_bats_plot, newpage = FALSE) popViewport() pushViewport(viewport(x = 0.27, y = 0.36, width = 0.35, height = 0.22, just = c("center","top"))) print(spray_plot, newpage = FALSE) popViewport() pushViewport(viewport(x = 0.73, y = 0.36, width = 0.35, height = 0.22, just = c("center","top"))) print(contact_plot, newpage = FALSE) popViewport() grid.text(paste(player_name, "-", game_key, "- Batted Ball Log"), x = 0.5, y = 0.13, gp = gpar(fontface = "bold", cex = 0.98)) rows_total <- nrow(pitch_log) max_rows_first <- floor((0.11 - 0.02) / 0.0130) rows_first <- min(rows_total, max_rows_first) if (rows_first > 0) { draw_pitch_table(pitch_log[1:rows_first, , drop = FALSE], y_top = 0.11, row_height = 0.0130, cex = 0.52, include_bat_tracking = bat_tracking_available) } next_row <- rows_first + 1 if (next_row <= rows_total) { grid::grid.newpage() draw_pitch_table(pitch_log[next_row:rows_total, , drop = FALSE], y_top = 0.97, row_height = 0.0175, cex = 0.56, include_bat_tracking = bat_tracking_available) } } # - Header stat tiles: bigger + more readable + moved slightly DOWN # - Tables: bigger numbers # - Pitch columns always ordered by pitch usage (Pitch Count desc) # - Table stack spacing fixed so "Release Data" title does NOT overlap Velo table # ============================================================ library(ggplot2) library(dplyr) library(grid) library(stringr) # ===================================================================== # PITCH COLORS # ===================================================================== tableau_pitch_colors <- c( "Sinker" = "#76b8b2", "Slider" = "#f38e2c", "Cutter" = "#edca49", "Sweeper" = "#E67E22", "Fastball" = "#4f79a7", "Four-Seam" = "#4f79a7", "FourSeamFastBall" = "#4f79a7", "4-Seam Fastball" = "#4f79a7", "Curveball" = "#5aa150", "ChangeUp" = "#e1575a", "Changeup" = "#e1575a", "Splitter" = "#b07ba1", "Knuckle Curve" = "#5aa150", "Two-Seam" = "#76b8b2", "TwoSeamFastBall" = "#76b8b2", "Other" = "#95A5A6", "Undefined" = "#95A5A6" ) TMB <- read.csv("TMB (1).csv", stringsAsFactors = FALSE) # ===================================================================== # DATA PROCESSING # ===================================================================== process_tableau_pitcher_data <- function(df) { if (!"Pitcher" %in% names(df)) { alt <- intersect(c("PitcherName","pitcher","Pitcher_LastFirst","PlayerName"), names(df)) if (length(alt)) df$Pitcher <- df[[alt[1]]] else df$Pitcher <- NA_character_ } df <- df %>% mutate(Pitcher = str_replace(coalesce(Pitcher, ""), "^\\s*(\\w+)\\s*,\\s*(\\w+)\\s*$", "\\2 \\1")) # Clean up TaggedPitchType if ("TaggedPitchType" %in% names(df)) { # First, ensure fallback columns exist (create as NA if not) if (!"AutoPitchType" %in% names(df)) df$AutoPitchType <- NA_character_ if (!"PitchType" %in% names(df)) df$PitchType <- NA_character_ df <- df %>% mutate( TaggedPitchType = case_when( is.na(TaggedPitchType) | TaggedPitchType == "" | TaggedPitchType == "Undefined" ~ case_when( !is.na(AutoPitchType) & AutoPitchType != "" & AutoPitchType != "Undefined" ~ AutoPitchType, !is.na(PitchType) & PitchType != "" & PitchType != "Undefined" ~ PitchType, TRUE ~ "Undefined" ), TRUE ~ TaggedPitchType ) ) } # Normalize common FB spellings (for colors + grouping) df <- df %>% mutate( TaggedPitchType = case_when( TaggedPitchType %in% c("FourSeamFastball","FourSeamFastBall","4-Seam","4-Seam Fast Ball","Four-Seam Fastball","4-Seam Fastball") ~ "Four-Seam", TRUE ~ TaggedPitchType ) ) # Indicators df %>% mutate( StrikeZoneIndicator = ifelse(PlateLocSide >= -0.8333 & PlateLocSide <= 0.8333 & PlateLocHeight >= 1.5 & PlateLocHeight <= 3.5, 1, 0), EdgeHeightIndicator = ifelse((PlateLocHeight > 14/12 & PlateLocHeight < 22/12) | (PlateLocHeight > 38/12 & PlateLocHeight < 46/12), 1, 0), EdgeZoneHtIndicator = ifelse(PlateLocHeight > 16/12 & PlateLocHeight < 45.2/12, 1, 0), EdgeZoneWIndicator = ifelse(PlateLocSide > -13.4/12 & PlateLocSide < 13.4/12, 1, 0), EdgeWidthIndicator = ifelse((PlateLocSide > -13.3/12 & PlateLocSide < -6.7/12) | (PlateLocSide < 13.3/12 & PlateLocSide > 6.7/12), 1, 0), EdgeIndicator = ifelse((EdgeHeightIndicator == 1 & EdgeZoneWIndicator == 1) | (EdgeWidthIndicator == 1 & EdgeZoneHtIndicator == 1), 1, 0), QualityPitchIndicator = ifelse(StrikeZoneIndicator == 1 | EdgeIndicator == 1, 1, 0), StrikeIndicator = ifelse(PitchCall %in% c("StrikeSwinging","StrikeCalled","FoulBallNotFieldable","FoulBall","InPlay"), 1, 0), WhiffIndicator = ifelse(PitchCall == "StrikeSwinging", 1, 0), SwingIndicator = ifelse(PitchCall %in% c("StrikeSwinging","FoulBallNotFieldable","FoulBall","InPlay"), 1, 0), FPindicator = ifelse(Balls == 0 & Strikes == 0, 1, 0), FPSindicator = ifelse(PitchCall %in% c("StrikeCalled","StrikeSwinging","FoulBallNotFieldable","FoulBall","InPlay") & FPindicator == 1, 1, 0), EarlyIndicator = ifelse( ((Balls == 0 & Strikes == 0 & PitchCall == "InPlay") | (Balls == 1 & Strikes == 0 & PitchCall == "InPlay") | (Balls == 0 & Strikes == 1 & PitchCall == "InPlay") | (Balls == 1 & Strikes == 1 & PitchCall == "InPlay")), 1, 0), AheadIndicator = ifelse( ((Balls == 0 & Strikes == 1) & (PitchCall %in% c("StrikeCalled", "StrikeSwinging", "FoulBallNotFieldable",'FoulBall'))) | ((Balls == 1 & Strikes == 1) & (PitchCall %in% c("StrikeCalled", "StrikeSwinging", "FoulBallNotFieldable",'FoulBall'))), 1, 0), ABindicator = ifelse(PlayResult %in% c("Error","FieldersChoice","Out","Single","Double","Triple","HomeRun") | KorBB == "Strikeout", 1, 0), HitIndicator = ifelse(PlayResult %in% c("Single","Double","Triple","HomeRun"), 1, 0), PAindicator = ifelse(PitchCall %in% c("InPlay","HitByPitch","CatchersInterference") | KorBB %in% c("Walk","Strikeout"), 1, 0), LeadOffIndicator = ifelse((PAofInning == 1 & (PlayResult != "Undefined" | KorBB != "Undefined")) | PitchCall == "HitByPitch", 1, 0), OutIndicator = ifelse((PlayResult %in% c("Out","FieldersChoice") | KorBB == "Strikeout") & PitchCall != "HitByPitch", 1, 0), LOOindicator = ifelse(LeadOffIndicator == 1 & OutIndicator == 1, 1, 0), HBPIndicator = ifelse(PitchCall == "HitByPitch", 1, 0), WalkIndicator = ifelse(KorBB == "Walk", 1, 0), LHHindicator = ifelse(BatterSide == "Left", 1, 0), RHHindicator = ifelse(BatterSide == "Right", 1, 0) ) } # ===================================================================== # HEADER STATS # ===================================================================== calculate_tableau_header_stats <- function(pitcher_df) { ab_data <- pitcher_df %>% filter(ABindicator == 1) %>% group_by(Inning, Batter, PAofInning) %>% slice_tail(n = 1) %>% ungroup() at_bats <- nrow(ab_data) hits <- sum(ab_data$HitIndicator, na.rm = TRUE) xbh <- sum(ab_data$PlayResult %in% c("Double","Triple","HomeRun"), na.rm = TRUE) runs <- sum(pitcher_df$RunsScored, na.rm = TRUE) pa_data <- pitcher_df %>% filter(PAindicator == 1) %>% group_by(Inning, Batter, PAofInning) %>% slice_tail(n = 1) %>% ungroup() bb <- sum(pa_data$WalkIndicator, na.rm = TRUE) hbp <- sum(pa_data$HBPIndicator, na.rm = TRUE) so <- sum(pa_data$KorBB == "Strikeout", na.rm = TRUE) avg <- ifelse(at_bats > 0, round(hits / at_bats, 3), 0) total_pitches <- nrow(pitcher_df) strike_pct <- ifelse(total_pitches > 0, round(100 * sum(pitcher_df$StrikeIndicator, na.rm = TRUE) / total_pitches, 0), 0) fp_pitches <- sum(pitcher_df$FPindicator, na.rm = TRUE) fp_k_pct <- ifelse(fp_pitches > 0, round(100 * sum(pitcher_df$FPSindicator, na.rm = TRUE) / fp_pitches, 0), 0) ea_pct <- round( (sum(pitcher_df$EarlyIndicator, na.rm = TRUE) + sum(pitcher_df$AheadIndicator, na.rm = TRUE)) / sum(pitcher_df$PAindicator, na.rm = TRUE) * 100, 1 ) comp_pct <- ifelse(total_pitches > 0, round(100 * sum(pitcher_df$QualityPitchIndicator, na.rm = TRUE) / total_pitches, 0), 0) leadoff_opps <- sum(pitcher_df$LeadOffIndicator, na.rm = TRUE) loo_pct <- ifelse(leadoff_opps > 0, round(100 * sum(pitcher_df$LOOindicator, na.rm = TRUE) / leadoff_opps, 0), 0) list( at_bats = at_bats, hits = hits, xbh = xbh, runs = runs, bb_hbp = bb + hbp, so = so, avg = sprintf("%.3f", avg), strike_pct = paste0(strike_pct, "%"), fp_k_pct = paste0(fp_k_pct, "%"), ea_pct = paste0(ea_pct, "%"), comp_pct = paste0(comp_pct, "%"), loo_pct = paste0(loo_pct, "%") ) } # ===================================================================== # TABLE CALCS # ===================================================================== get_valid_pitch_types <- function(pitcher_df) { valid_types <- pitcher_df %>% filter(!is.na(TaggedPitchType), TaggedPitchType != "") %>% pull(TaggedPitchType) %>% unique() if (length(valid_types) == 1 && valid_types[1] == "Undefined") return(valid_types) filter_types <- valid_types[valid_types != "Undefined" & valid_types != "Other"] if (length(filter_types) == 0) return("Undefined") filter_types } calculate_tableau_location_data <- function(pitcher_df) { filter_types <- get_valid_pitch_types(pitcher_df) pitcher_df %>% filter(TaggedPitchType %in% filter_types) %>% group_by(TaggedPitchType) %>% summarise( `Zone%` = paste0(round(100 * sum(StrikeZoneIndicator, na.rm = TRUE) / n(), 0), "%"), `Edge%` = paste0(round(100 * sum(EdgeIndicator, na.rm = TRUE) / n(), 0), "%"), `Strike%` = paste0(round(100 * sum(StrikeIndicator, na.rm = TRUE) / n(), 0), "%"), `Whiff%` = ifelse(sum(SwingIndicator, na.rm = TRUE) > 0, paste0(round(100 * sum(WhiffIndicator, na.rm = TRUE) / sum(SwingIndicator, na.rm = TRUE), 0), "%"), "0%"), .groups = "drop" ) } calculate_tableau_pitch_usage <- function(pitcher_df) { lhh_pitches <- sum(pitcher_df$LHHindicator, na.rm = TRUE) rhh_pitches <- sum(pitcher_df$RHHindicator, na.rm = TRUE) filter_types <- get_valid_pitch_types(pitcher_df) pitcher_df %>% filter(TaggedPitchType %in% filter_types) %>% group_by(TaggedPitchType) %>% summarise( `Pitch Count` = n(), `Usage vs. LHH` = paste0(round(100 * sum(LHHindicator, na.rm = TRUE) / max(1, lhh_pitches), 0), "%"), `Usage vs. RHH` = paste0(round(100 * sum(RHHindicator, na.rm = TRUE) / max(1, rhh_pitches), 0), "%"), .groups = "drop" ) } calculate_tableau_velo_movement <- function(pitcher_df) { filter_types <- get_valid_pitch_types(pitcher_df) pitcher_df %>% filter(TaggedPitchType %in% filter_types) %>% group_by(TaggedPitchType) %>% summarise( `Avg. Velo` = round(mean(RelSpeed, na.rm = TRUE), 1), `Max. Velo` = round(max(RelSpeed, na.rm = TRUE), 1), `Avg. Spin` = format(round(mean(SpinRate, na.rm = TRUE), 0), big.mark = ","), `Max. Spin` = format(round(max(SpinRate, na.rm = TRUE), 0), big.mark = ","), `Avg. IVB` = round(mean(InducedVertBreak, na.rm = TRUE), 0), `Avg. HB` = round(mean(HorzBreak, na.rm = TRUE), 0), .groups = "drop" ) } calculate_tableau_release_data <- function(pitcher_df) { primary <- pitcher_df %>% filter(TaggedPitchType %in% c("Fastball","Sinker","Four-Seam","FourSeamFastBall")) fb_ht <- if (nrow(primary) > 0) mean(primary$RelHeight, na.rm = TRUE) else NA fb_side <- if (nrow(primary) > 0) mean(primary$RelSide, na.rm = TRUE) else NA filter_types <- get_valid_pitch_types(pitcher_df) pitcher_df %>% filter(TaggedPitchType %in% filter_types) %>% group_by(TaggedPitchType) %>% summarise( `Avg. Rel Ht` = round(mean(RelHeight, na.rm = TRUE), 2), `Rel Ht vs. FB` = ifelse(is.na(fb_ht), NA, round((mean(RelHeight, na.rm = TRUE) - fb_ht) * 12, 0)), `Avg. Rel Side` = round(mean(RelSide, na.rm = TRUE), 2), `Rel Side vs. FB` = ifelse(is.na(fb_side), NA, round((mean(RelSide, na.rm = TRUE) - fb_side) * 12, 0)), `Avg. Ext` = round(mean(Extension, na.rm = TRUE), 2), .groups = "drop" ) } # ===================================================================== # PLOTS # ===================================================================== create_tableau_location_plot <- function(pitcher_df, pitch_colors) { filter_types <- get_valid_pitch_types(pitcher_df) df <- pitcher_df %>% filter(TaggedPitchType %in% filter_types, !is.na(PlateLocSide), !is.na(PlateLocHeight)) %>% mutate( ResultDisplay = case_when( PitchCall %in% c("BallCalled","BallinDirt") ~ "Ball", PlayResult == "Double" ~ "2B", PitchCall %in% c("FoulBall","FoulBallNotFieldable") ~ "Foul", PitchCall == "HitByPitch" ~ "HBP", PlayResult %in% c("Sacrifice","SacrificeFly") ~ "Sac", PlayResult == "Single" ~ "1B", PitchCall == "StrikeCalled" ~ "Called", PitchCall == "StrikeSwinging" ~ "Whiff", PlayResult == "Triple" ~ "3B", PlayResult == "HomeRun" ~ "HR", PlayResult == "Out" ~ "Out", PlayResult == "Error" ~ "Error", TRUE ~ "Other" ) ) if (nrow(df) == 0) { return(ggplot() + theme_void() + labs(title = "Location Report") + theme(plot.title = element_text(size = 12, face = "bold", hjust = 0.5))) } zone_left <- -0.8333; zone_right <- 0.8333 zone_bottom <- 1.5; zone_top <- 3.5 shadow_left <- -1.1; shadow_right <- 1.1 shadow_bottom <- 1.2; shadow_top <- 3.8 zone_width <- (zone_right - zone_left) / 3 zone_height <- (zone_top - zone_bottom) / 3 ggplot(df, aes(x = PlateLocSide, y = PlateLocHeight)) + annotate("rect", xmin = shadow_left, xmax = shadow_right, ymin = shadow_bottom, ymax = shadow_top, fill = NA, color = "gray30", linetype = "dashed", linewidth = 0.5) + annotate("rect", xmin = zone_left, xmax = zone_right, ymin = zone_bottom, ymax = zone_top, fill = NA, color = "#E74C3C", linewidth = 1) + annotate("segment", x = zone_left + zone_width, xend = zone_left + zone_width, y = zone_bottom, yend = zone_top, color = "gray50", linetype = "dashed", linewidth = 0.3) + annotate("segment", x = zone_left + 2*zone_width, xend = zone_left + 2*zone_width, y = zone_bottom, yend = zone_top, color = "gray50", linetype = "dashed", linewidth = 0.3) + annotate("segment", x = zone_left, xend = zone_right, y = zone_bottom + zone_height, yend = zone_bottom + zone_height, color = "gray50", linetype = "dashed", linewidth = 0.3) + annotate("segment", x = zone_left, xend = zone_right, y = zone_bottom + 2*zone_height, yend = zone_bottom + 2*zone_height, color = "gray50", linetype = "dashed", linewidth = 0.3) + annotate("polygon", x = c(-0.708, 0.708, 0.708, 0, -0.708), y = c(0.15, 0.15, 0.30, 0.50, 0.30), fill = NA, color = "black", linewidth = 0.5) + annotate("segment", x = 0, xend = 0, y = 0.5, yend = shadow_top + 0.2, color = "gray60", linetype = "dotted", linewidth = 0.3) + geom_point(aes(color = TaggedPitchType, shape = ResultDisplay), size = 2.6, stroke = 0.85) + scale_color_manual(values = pitch_colors, name = "Pitch") + scale_shape_manual( values = c("Ball" = 1, "2B" = 18, "Foul" = 2, "HBP" = 10, "Sac" = 3, "1B" = 19, "Called" = 5, "Whiff" = 8, "3B" = 17, "HR" =15, "Out" = 4, "Error" = 0, "Other" = 16), name = "Result" ) + coord_fixed(xlim = c(-2.2, 2.2), ylim = c(0, 4.2)) + labs(title = "Location Report") + theme_minimal() + theme( plot.title = element_text(size = 12, face = "bold", hjust = 0.5), legend.position = "left", legend.title = element_text(size = 7, face = "bold"), legend.text = element_text(size = 6), legend.key.size = unit(0.45, "cm"), legend.spacing.y = unit(0.08, "cm"), legend.margin = margin(0, 0, 0, 0), legend.box.margin = margin(0, -4, 0, -6), axis.text = element_blank(), axis.title = element_blank(), axis.ticks = element_blank(), panel.grid = element_blank(), plot.margin = margin(2, 2, 2, 2) ) + guides( color = guide_legend(override.aes = list(size = 3), ncol = 1), shape = guide_legend(override.aes = list(size = 3), ncol = 1) ) } create_tableau_movement_plot <- function(pitcher_df, pitch_colors) { filter_types <- get_valid_pitch_types(pitcher_df) df <- pitcher_df %>% dplyr::filter( TaggedPitchType %in% filter_types, !is.na(HorzBreak), !is.na(InducedVertBreak) ) if (nrow(df) == 0) { return( ggplot() + theme_void() + labs(title = "Movement Profile") + theme(plot.title = element_text(size = 12, face = "bold", hjust = 0.5)) ) } ggplot(df, aes(x = HorzBreak, y = InducedVertBreak)) + # Center lines: GRAY + DASHED geom_vline(xintercept = 0, color = "gray60", linetype = "dashed", linewidth = 0.5) + geom_hline(yintercept = 0, color = "gray60", linetype = "dashed", linewidth = 0.5) + # Points geom_point( aes(fill = TaggedPitchType), size = 2.6, shape = 21, color = "black", stroke = 0.9 ) + scale_fill_manual(values = pitch_colors, drop = FALSE) + scale_x_continuous( limits = c(-30, 30), breaks = c(-30, -20, -10, 0, 10, 20, 30), expand = expansion(mult = 0.02) ) + scale_y_continuous( limits = c(-30, 30), breaks = c(-30, -20, -10, 0, 10, 20, 30), expand = expansion(mult = 0.02) ) + coord_fixed(ratio = 1) + labs(title = "Movement Profile", x = "Horz Break", y = "Induced Vert Break") + theme_minimal() + theme( plot.title = element_text(size = 12, face = "bold", hjust = 0.5), legend.position = "none", axis.title = element_text(size = 10), axis.text = element_text(size = 9), plot.margin = margin(4, 4, 4, 6) ) } create_tableau_release_plot <- function(pitcher_df, pitch_colors) { filter_types <- get_valid_pitch_types(pitcher_df) df <- pitcher_df %>% filter(TaggedPitchType %in% filter_types, !is.na(RelSide), !is.na(RelHeight)) if (nrow(df) == 0) { return(ggplot() + theme_void() + labs(title = "Release Plot") + theme(plot.title = element_text(size = 10, face = "bold", hjust = 0.5))) } mound_theta <- seq(0, pi, length.out = 100) mound_radius <- 3 mound_df <- data.frame( x = mound_radius * cos(mound_theta), y = mound_radius * sin(mound_theta) * 0.35 ) ggplot(df, aes(x = RelSide, y = RelHeight)) + geom_polygon(data = mound_df, aes(x = x, y = y), fill = "#C0392B", color = NA, inherit.aes = FALSE) + annotate("rect", xmin = -0.5, xmax = 0.5, ymin = 0.85, ymax = 1.05, fill = "white", color = "gray40", linewidth = 0.3) + geom_vline(xintercept = 0, color = "gray60", linetype = "dashed", linewidth = 0.3) + geom_point(aes(fill = TaggedPitchType), size = 2.6, alpha = 1, shape = 21, color = "black", stroke = 0.9) + scale_fill_manual(values = pitch_colors, drop = FALSE) + coord_cartesian(xlim = c(-4, 4), ylim = c(0, 7)) + labs(title = "Release Plot", x = "Rel Side", y = "Rel Height") + theme_minimal() + theme( plot.title = element_text(size = 12, face = "bold", hjust = 0.5), legend.position = "none", axis.title = element_text(size = 10), axis.text = element_text(size = 9), plot.margin = margin(2, 2, 2, 2) ) } # ===================================================================== # TABLE DRAWING (BIGGER NUMBERS) # ===================================================================== draw_tableau_table_fill <- function( title, data, rows, pitch_types, pitch_colors, x, y, width, height ) { if (is.null(pitch_types) || length(pitch_types) == 0) pitch_types <- "Undefined" n_cols <- length(pitch_types) n_rows <- length(rows) + 1 title_h <- min(0.05, height * 0.18) table_top <- y - title_h table_h <- height - title_h col_w <- width / n_cols row_h <- table_h / n_rows # Bigger text than before (without exploding) header_cex <- max(0.85, min(1.45, row_h * 26)) body_cex <- max(0.82, min(1.35, row_h * 24)) label_cex <- max(0.82, min(1.35, row_h * 24)) title_cex <- max(1.00, min(1.45, row_h * 28)) grid.text(title, x = x + width/2, y = y, gp = gpar(fontface = "bold", cex = title_cex, col = "#006F71")) # Column headers for (i in seq_along(pitch_types)) { pt <- pitch_types[i] col_color <- if (pt %in% names(pitch_colors)) pitch_colors[[pt]] else "#95A5A6" grid.rect(x = x + (i-1)*col_w, y = table_top, width = col_w * 0.98, height = row_h * 0.95, just = c("left", "top"), gp = gpar(fill = col_color, col = "gray30", lwd = 0.6)) pt_short <- pt pt_short <- gsub("ChangeUp|Changeup", "CH", pt_short) pt_short <- gsub("Fastball|Four-Seam|FourSeamFastBall|Four-Seam Fastball|4-Seam Fastball|Four-Seam", "FB", pt_short) pt_short <- gsub("Curveball", "CB", pt_short) pt_short <- gsub("Slider", "SL", pt_short) pt_short <- gsub("Sinker", "SI", pt_short) pt_short <- gsub("Cutter", "CT", pt_short) pt_short <- gsub("Splitter", "SP", pt_short) pt_short <- gsub("Sweeper", "SW", pt_short) grid.text(pt_short, x = x + (i-1)*col_w + col_w/2, y = table_top - row_h*0.55, gp = gpar(col = "white", cex = header_cex, fontface = "bold")) } # Data rows row_names <- names(rows) col_names <- as.character(rows) for (r in seq_along(col_names)) { disp <- row_names[r] coln <- col_names[r] y_row_top <- table_top - r*row_h grid.text(disp, x = x - 0.010, y = y_row_top - row_h*0.55, just = "right", gp = gpar(cex = label_cex, fontface = "bold")) for (i in seq_along(pitch_types)) { pt <- pitch_types[i] idx <- which(data$TaggedPitchType == pt) val <- "-" if (length(idx) > 0 && coln %in% names(data)) { val <- as.character(data[[coln]][idx[1]]) } grid.rect(x = x + (i-1)*col_w, y = y_row_top, width = col_w * 0.98, height = row_h * 0.95, just = c("left", "top"), gp = gpar(fill = "white", col = "gray40", lwd = 0.6)) grid.text(val, x = x + (i-1)*col_w + col_w/2, y = y_row_top - row_h*0.55, gp = gpar(cex = body_cex, fontface = "plain")) } } } get_team_meta <- function(team_abbr, TMB) { if (is.null(TMB) || !all(c("team_abbr","BTeamName","BTeamPrimColor","BTeamSecondColor") %in% names(TMB))) { return(list(name = team_abbr, prim = "black", sec = "black")) } row <- TMB[TMB$team_abbr == team_abbr, , drop = FALSE] if (nrow(row) == 0) { return(list(name = team_abbr, prim = "black", sec = "black")) } list( name = as.character(row$BTeamName[1]), prim = as.character(row$BTeamPrimColor[1]), sec = as.character(row$BTeamSecondColor[1]) ) } draw_info_row_colored_team <- function(game_date, pitcher_name, away_team_abbr, TMB, x = 0.02, y = 0.935, base_cex = 0.8, base_col = "black", fontface = "bold", max_width = 0.96) { tm <- get_team_meta(away_team_abbr, TMB) part1 <- paste0(game_date, " | ", pitcher_name, " vs ") part2 <- tm$name gp1 <- grid::gpar(cex = base_cex, fontface = fontface, col = base_col) gp2 <- grid::gpar(cex = base_cex, fontface = fontface, col = tm$prim) g1 <- grid::textGrob(part1, gp = gp1, just = "left") g2 <- grid::textGrob(part2, gp = gp2, just = "left") w1 <- grid::convertWidth(grid::grobWidth(g1), "npc", valueOnly = TRUE) w2 <- grid::convertWidth(grid::grobWidth(g2), "npc", valueOnly = TRUE) padding <- 0.004 total_width <- x + w1 + padding + w2 # Shrink text if it would overflow if (total_width > max_width) { shrink <- max_width / total_width gp1$cex <- base_cex * shrink gp2$cex <- base_cex * shrink } grid::grid.text(part1, x = x, y = y, just = "left", gp = gp1) grid::grid.text(part2, x = x + w1 + padding, y = y, just = "left", gp = gp2) invisible(tm) } # ===================================================================== # MAIN PDF FUNCTION (SINGLE, CORRECT) # ===================================================================== create_tableau_pitcher_pdf <- function(game_df, pitcher_name, output_file) { if (length(dev.list()) > 0) try(dev.off(), silent = TRUE) pitcher_df <- process_tableau_pitcher_data(game_df) %>% filter(Pitcher == pitcher_name) if (nrow(pitcher_df) == 0) { pdf(output_file, width = 8.5, height = 11) grid.newpage() grid.text(paste("No data found for", pitcher_name), gp = gpar(fontsize = 16, fontface = "bold")) dev.off() return(output_file) } game_date <- tryCatch({ d <- unique(pitcher_df$Date)[1] parsed <- parse_flexible_date(d) if (!is.na(parsed)) format(parsed, "%m/%d/%Y") else "NA" }, error = function(e) "NA") batter_teams <- unique(pitcher_df$BatterTeam) batter_teams <- batter_teams[!is.na(batter_teams)] away_team <- if (length(batter_teams) > 0) batter_teams[1] else "Unknown" # Stats + tables stats <- calculate_tableau_header_stats(pitcher_df) loc_data <- calculate_tableau_location_data(pitcher_df) usage_data <- calculate_tableau_pitch_usage(pitcher_df) velo_data <- calculate_tableau_velo_movement(pitcher_df) rel_data <- calculate_tableau_release_data(pitcher_df) # Pitch columns ordered by usage (Pitch Count desc), then append any missing pitch types pitch_types <- usage_data %>% arrange(desc(`Pitch Count`)) %>% pull(TaggedPitchType) %>% unique() pitch_types <- pitch_types[!is.na(pitch_types) & pitch_types != ""] extras <- unique(c(loc_data$TaggedPitchType, velo_data$TaggedPitchType, rel_data$TaggedPitchType)) extras <- extras[!is.na(extras) & extras != "" & !extras %in% pitch_types] pitch_types <- c(pitch_types, extras) if (length(pitch_types) == 0) pitch_types <- "Undefined" # Plots loc_plot <- create_tableau_location_plot(pitcher_df, tableau_pitch_colors) mov_plot <- create_tableau_movement_plot(pitcher_df, tableau_pitch_colors) rel_plot <- create_tableau_release_plot(pitcher_df, tableau_pitch_colors) # PDF pdf(output_file, width = 8.5, height = 11) on.exit(try(dev.off(), silent = TRUE), add = TRUE) grid.newpage() # ============================================================ # HEADER BAR + INFO # ============================================================ grid.rect(x = 0, y = 0.955, width = 1, height = 0.045, just = c("left", "bottom"), gp = gpar(fill = "#006F71", col = NA)) logo_url <- "https://i.imgur.com/zjTu3JS.png" logo_grob <- NULL try({ logo_img <- magick::image_read(logo_url) # Optional: add transparency if the logo has a white background you want removed # logo_img <- magick::image_transparent(logo_img, "white", fuzz = 10) # Scale to a consistent height in pixels (keeps it crisp) logo_img <- magick::image_resize(logo_img, "x140") logo_grob <- grid::rasterGrob(as.raster(logo_img), interpolate = TRUE) }, silent = TRUE) if (!is.null(logo_grob)) { # Place in the top-right corner of the HEADER BAR # x=0.985 means right edge is near page edge; y=0.977 centers within the bar pushViewport(viewport(x = 0.988, y = 0.977, width = 0.10, height = 0.040, just = c("right", "center"))) grid.draw(logo_grob) popViewport() } grid.text("Pitcher Post-Game Report", x = 0.02, y = 0.977, just = "left", gp = gpar(col = "white", fontface = "bold", cex = 1.2)) info_y <- 0.935 grid.text( paste0(game_date, " | ", pitcher_name, " vs ", away_team), x = 0.02, y = info_y, just = "left", gp = gpar(cex = 0.8, fontface = "bold", col = "black") ) # ============================================================ # HEADER STAT TILES (BIGGER + MORE READABLE + MOVED DOWN) # ============================================================ stat_labels <- c("At Bats","H","XBH","R","BB/HBP","SO","AVG","Strike%","1st P K%","E+A%","Comp%","LOO%") stat_values <- c(stats$at_bats, stats$hits, stats$xbh, stats$runs, stats$bb_hbp, stats$so, stats$avg, stats$strike_pct, stats$fp_k_pct, stats$ea_pct, stats$comp_pct, stats$loo_pct) label_colors <- c("#fe0100", "#0000ff", "#0000ff", "#01ab01", "#01ab01","#01ab01", "#01abff", "#ffaa01", "#ffaa01", "#ffaa01", "#ffaa01", "#ffaa01") # moved DOWN a bit vs prior (and slightly bigger) tiles_y <- 0.880 tile_w <- 0.074 tile_h <- 0.060 tile_gap <- 0.008 x_start <- 0.02 band_h <- tile_h * 0.55 for (i in seq_along(stat_labels)) { x_pos <- x_start + (i - 1) * (tile_w + tile_gap) # Outer border grid.rect( x = x_pos, y = tiles_y, width = tile_w, height = tile_h, just = c("left", "center"), gp = gpar(fill = NA, col = label_colors[i], lwd = 2.2) ) # Top colored band grid.rect( x = x_pos, y = tiles_y + (tile_h/2) - (band_h/2), width = tile_w, height = band_h, just = c("left", "center"), gp = gpar(fill = label_colors[i], col = NA) ) # Bottom white area grid.rect( x = x_pos, y = tiles_y - (tile_h/2) + ((tile_h - band_h)/2), width = tile_w, height = (tile_h - band_h), just = c("left", "center"), gp = gpar(fill = "white", col = "black", lwd = 0.8) ) # Label (white, bigger) grid.text( stat_labels[i], x = x_pos + tile_w/2, y = tiles_y + (tile_h/2) - (band_h/2), gp = gpar(col = "white", cex = 0.82, fontface = "bold") ) # Value (big, very readable) grid.text( as.character(stat_values[i]), x = x_pos + tile_w/2, y = tiles_y - (tile_h/2) + ((tile_h - band_h)/2), gp = gpar(col = "black", cex = 1.20, fontface = "bold") ) } # ============================================================ # CHARTS (kept same formatting; nudged slightly down) # ============================================================ pushViewport(viewport(x = 0.23, y = 0.662, width = 0.42, height = 0.30)) print(loc_plot, newpage = FALSE) popViewport() pushViewport(viewport(x = 0.22, y = 0.355, width = 0.41, height = 0.29)) print(mov_plot, newpage = FALSE) popViewport() pushViewport(viewport(x = 0.23, y = 0.14, width = 0.42, height = 0.26)) print(rel_plot, newpage = FALSE) popViewport() # ============================================================ # TABLES (spacing fixed: Release title no overlap) # ============================================================ table_x <- 0.58 table_w <- 0.41 draw_tableau_table_fill( title = "Location Data", data = loc_data, rows = c("Zone%"="Zone%", "Edge%"="Edge%", "Strike%"="Strike%", "Whiff%"="Whiff%"), pitch_types = pitch_types, pitch_colors = tableau_pitch_colors, x = table_x, y = 0.835, width = table_w, height = 0.16 ) draw_tableau_table_fill( title = "Pitch Usage", data = usage_data, rows = c("Usage vs. LHH"="Usage vs. LHH", "Usage vs. RHH"="Usage vs. RHH", "Pitch Count"="Pitch Count"), pitch_types = pitch_types, pitch_colors = tableau_pitch_colors, x = table_x, y = 0.645, width = table_w, height = 0.15 ) draw_tableau_table_fill( title = "Velo & Movement", data = velo_data, rows = c("Avg. Velo"="Avg. Velo", "Max Velo"="Max. Velo", "Avg. Spin"="Avg. Spin", "Max Spin"="Max. Spin", "Avg. IVB"="Avg. IVB", "Avg. HB"="Avg. HB"), pitch_types = pitch_types, pitch_colors = tableau_pitch_colors, x = table_x, y = 0.47, width = table_w, height = 0.21 ) draw_tableau_table_fill( title = "Release Data", data = rel_data, rows = c("Rel Ht"="Avg. Rel Ht", "Rel Ht vs FB (in)"="Rel Ht vs. FB", "Rel Side"="Avg. Rel Side", "Rel Side vs FB (in)"="Rel Side vs. FB", "Ext"="Avg. Ext"), pitch_types = pitch_types, pitch_colors = tableau_pitch_colors, x = table_x, y = 0.235, width = table_w, height = 0.19 ) invisible(output_file) } # ===================================================================== # ===================== CATCHER CODE (wrapped) ======================= # ===================================================================== catcher_process_dataset <- function(df) { if ("Catcher" %in% names(df)) { df <- df %>% mutate(Catcher = stringr::str_replace(Catcher, "^\\s*(\\w+)\\s*,\\s*(\\w+)\\s*$", "\\2 \\1")) } df <- df %>% distinct() if ("PitchUID" %in% names(df)) df <- df %>% distinct(PitchUID, .keep_all = TRUE) if ("Date" %in% names(df)) { df$Date <- parse_flexible_date(df$Date) } if ("PlateLocSide" %in% names(df)) df$PlateLocSide <- as.numeric(df$PlateLocSide) if ("PlateLocHeight" %in% names(df)) df$PlateLocHeight <- as.numeric(df$PlateLocHeight) if ("BasePositionZ" %in% names(df)) df$BasePositionZ <- as.numeric(df$BasePositionZ) if ("BasePositionY" %in% names(df)) df$BasePositionY <- as.numeric(df$BasePositionY) BALL_CALLS <- c("BallCalled", "BallinDirt", "BallIntentional") STRIKE_CALLS <- c("StrikeCalled") SWING_CALLS <- c("StrikeSwinging", "InPlay", "FoulBall", "FoulBallFieldable", "FoulBallNotFieldable") df %>% mutate( PitchCall = trimws(gsub("\\s+", "", PitchCall)), in_zone = as.integer(!is.na(PlateLocSide) & !is.na(PlateLocHeight) & PlateLocSide >= -0.83 & PlateLocSide <= 0.83 & PlateLocHeight >= 1.5 & PlateLocHeight <= 3.38), is_swing = as.integer(PitchCall %in% SWING_CALLS), StrikeZoneIndicator = in_zone, StolenStrike = as.integer(in_zone == 0 & PitchCall %in% STRIKE_CALLS), StrikeLost = as.integer(in_zone == 1 & PitchCall %in% BALL_CALLS), frame = dplyr::case_when( in_zone == 1 & PitchCall %in% BALL_CALLS ~ "Strike Lost", in_zone == 0 & PitchCall %in% STRIKE_CALLS ~ "Strike Added", TRUE ~ NA_character_ ), frame_numeric = dplyr::case_when( in_zone == 1 & PitchCall %in% BALL_CALLS ~ -1, in_zone == 0 & PitchCall %in% STRIKE_CALLS ~ 1, TRUE ~ NA_real_ ) ) } catcher_parse_game_day <- function(df, tz = "America/New_York") { stopifnot("Date" %in% names(df)) if (inherits(df$Date, "Date")) { dates <- df$Date[!is.na(df$Date)] if (length(dates) > 0) { tab <- sort(table(dates), decreasing = TRUE) return(as.Date(names(tab)[1])) } } as.Date(df$Date[1]) } catcher_create_framing_plots <- function(catcher_data, catcher_name) { df <- dplyr::filter(catcher_data, Catcher == catcher_name, is_swing == 0) strikes_added <- df %>% filter(frame == "Strike Added") strikes_lost <- df %>% filter(frame == "Strike Lost") pitch_colors <- c( "Fastball"="#FA8072", "FourSeamFastBall" = "#FA8072","Four-Seam"="#FA8072","Sinker"="#fdae61","Slider"="#A020F0", "Sweeper"="magenta","Curveball"="#2c7bb6","ChangeUp"="#90EE90","Splitter"="#90EE32","Cutter"="red" ) zone <- data.frame(xmin = -0.83, xmax = 0.83, ymin = 1.5, ymax = 3.38) make_plot <- function(data, title, show_legend = FALSE){ if (!nrow(data)) return(ggplot2::ggplot() + ggplot2::theme_void() + ggplot2::ggtitle(title)) gg <- ggplot2::ggplot(data, ggplot2::aes(PlateLocSide, PlateLocHeight)) + ggplot2::annotate("rect", xmin = zone$xmin, xmax = zone$xmax, ymin = zone$ymin, ymax = zone$ymax, fill = NA, color = "black", size = 0.5 ) + ggplot2::annotate("segment", x=-0.83, y=0.05, xend=0.83, yend=0.05, size=0.5, color="black") + ggplot2::annotate("segment", x=-0.83, y=0.20, xend=-0.83, yend=0.05, size=0.5, color="black") + ggplot2::annotate("segment", x= 0.83, y=0.20, xend= 0.83, yend=0.05, size=0.5, color="black") + ggplot2::annotate("segment", x=-0.83, y=0.20, xend=0, yend=0.40, size=0.5, color="black") + ggplot2::annotate("segment", x= 0.83, y=0.20, xend=0, yend=0.40, size=0.5, color="black") + ggplot2::geom_point(ggplot2::aes(color = TaggedPitchType), size = 3, alpha = 0.95, na.rm = TRUE) + ggplot2::scale_color_manual(values = pitch_colors, na.value = "grey60", name = "Pitch Type") + ggplot2::coord_fixed() + ggplot2::xlim(-2, 2) + ggplot2::ylim(0, 4) + ggplot2::labs(title = title) + ggplot2::theme_void() + ggplot2::theme( plot.title = ggplot2::element_text(size = 11, face = "bold", hjust = 0.5), legend.position = if (show_legend) "bottom" else "none", legend.title = ggplot2::element_text(size = 9, face = "bold"), legend.text = ggplot2::element_text(size = 8), legend.key.size = ggplot2::unit(0.5, "lines"), legend.box = "horizontal" ) gg } list( p1 = make_plot(strikes_added, "Strikes Stolen", show_legend = FALSE), p2 = make_plot(strikes_lost, "Strikes Lost", show_legend = TRUE) ) } catcher_create_framing_plot <- function(catcher_data, catcher_name) { plots <- catcher_create_framing_plots(catcher_data, catcher_name) gridExtra::grid.arrange(plots$p1, plots$p2, ncol = 2) } catcher_create_throwing_plot <- function(catcher_data, catcher_name) { throws_data <- catcher_data %>% filter(Catcher == catcher_name) %>% filter(Notes %in% c('2b out','2b safe','3b out','3b safe')) if (!nrow(throws_data)) return(ggplot() + theme_void() + ggtitle("No throwing data available") + theme(plot.title = element_text(hjust = 0.5, size = 11, face = "bold"))) ggplot(throws_data) + geom_polygon(data=data.frame(x=c(-10,10,10,-10), y=c(0.25,0.25,8,8)), aes(x=x,y=y), fill='#14a6a8', color='#14a6a8') + geom_polygon(data=data.frame(x=c(-10,10,10,-10), y=c(8,8,9,9)), aes(x=x,y=y), fill='yellow', color='yellow') + geom_polygon(data=data.frame(x=c(-10,10,10,-10), y=c(-2,-2,0.25,0.25)), aes(x=x,y=y), fill='brown', color='brown') + geom_polygon(data=data.frame(x=c(-10,10,10,-10), y=c(-5,-5,-2,-2)), aes(x=x,y=y), fill='darkgreen', color='darkgreen') + geom_polygon(data=data.frame(x=c(-1,1,1,-1), y=c(0,0,0.45,0.45)), aes(x=x,y=y), fill='white', color='black') + geom_polygon(data=data.frame(x=c(-1,0,0,-1), y=c(0,0,0.45,0.45)), aes(x=x,y=y), fill='lightgrey', color='black') + geom_point(aes(x=BasePositionZ, y=BasePositionY, fill=Notes), color='white', pch=21, alpha=.99, size=3.5) + scale_fill_manual(values=c('2b safe'='red','2b out'='#339a1d','3b safe'='#ff6b6b','3b out'='#1a5d1a')) + scale_x_continuous(limits=c(-10,10)) + scale_y_continuous(limits=c(-5,9)) + theme_bw() + coord_fixed() + theme(legend.position="bottom", axis.title=element_blank(), axis.text=element_blank(), axis.ticks=element_blank(), panel.grid=element_blank(), plot.title=element_text(size=11, face='bold', hjust=.5)) + ggtitle(paste(catcher_name, "- Throwing Report")) } catcher_create_simple_header <- function(catcher_name, game_date, bio_data = NULL) { suppressPackageStartupMessages({ library(grid) library(gridExtra) library(magick) library(dplyr) }) title_text <- paste(catcher_name, "- Catcher Report") subhead_text <- if (!is.na(game_date)) paste("Game Date:", game_date) else "" ## ---------- HEADSHOT (OPTIONAL) ---------- img_grob <- nullGrob() if (!is.null(bio_data) && nrow(bio_data) > 0) { catcher_bio <- bio_data %>% filter(Catcher == catcher_name) if (nrow(catcher_bio) > 0 && "Headshot" %in% names(catcher_bio)) { url <- catcher_bio$Headshot[1] if (!is.na(url) && nzchar(url)) { img <- try(magick::image_read(url), silent = TRUE) if (!inherits(img, "try-error")) { img_grob <- rasterGrob(as.raster(img), interpolate = TRUE) } } } } ## ---------- LOGOS ---------- left_logo <- rasterGrob(as.raster(magick::image_resize( magick::image_read("https://i.ibb.co/gLfTW4Fz/t-GPe-TPu.png"), "x120" ))) right_logo <- rasterGrob(as.raster(magick::image_resize( magick::image_read("https://i.imgur.com/zjTu3JS.png"), "x120" ))) ## ---------- TEXT ---------- title_grob <- textGrob( title_text, gp = gpar(fontsize = 18, fontface = "bold", col = "#006F71") ) sub_grob <- textGrob( subhead_text, gp = gpar(fontsize = 11) ) ## ---------- LAYOUT ---------- arrangeGrob( arrangeGrob(left_logo, title_grob, right_logo, ncol = 3, widths = c(.15, .7, .15)), sub_grob, ncol = 1, heights = c(.7, .3) ) } catcher_create_catcher_pdf <- function(game_df, catcher_name, output_file, bio_data = NULL) { if (length(dev.list()) > 0) { try(dev.off(), silent = TRUE) } catcher_df <- dplyr::filter(game_df, Catcher == catcher_name) game_day <- catcher_parse_game_day(catcher_df, tz = "America/New_York") game_key <- format(game_day, "%Y-%m-%d") receiving_stats <- catcher_df %>% summarise(`CCU Strikes Stolen` = sum(StolenStrike, na.rm = TRUE), `CCU Strikes Lost` = sum(StrikeLost, na.rm = TRUE), `CCU Game +/-` = sum(StolenStrike, na.rm = TRUE) - sum(StrikeLost, na.rm = TRUE), .groups = "drop") opp_catcher_name <- game_df %>% filter(CatcherTeam != "COA_CHA") %>% pull(Catcher) %>% na.omit() %>% { names(sort(table(.), decreasing = TRUE))[1] } opp_receiving_stats <- game_df %>% filter(Catcher == opp_catcher_name) %>% summarise( `Opp Strikes Stolen` = sum(StolenStrike, na.rm = TRUE), `Opp Strikes Lost` = sum(StrikeLost, na.rm = TRUE), `Opp Game +/-` = sum(StolenStrike, na.rm = TRUE) - sum(StrikeLost, na.rm = TRUE), .groups = "drop" ) pitch_log <- catcher_df %>% filter(StolenStrike == 1 | StrikeLost == 1) %>% select(PitchNo, Pitcher, Catcher, Batter, TaggedPitchType, PitchCall, StrikeZoneIndicator) %>% mutate(Pitch = TaggedPitchType, Actual = ifelse(StrikeZoneIndicator == 1, "STRIKE", "BALL")) %>% select(PitchNo, Pitch, Pitcher, Catcher, Batter, PitchCall, Actual) throw_log <- catcher_df %>% filter(Notes %in% c('2b out','2b safe','3b out','3b safe')) %>% select(PitchNo, Pitcher, Catcher, ThrowSpeed, PopTime, ExchangeTime, Notes) framing_plots <- catcher_create_framing_plots(game_df, catcher_name) framing_p1_grob <- ggplotGrob(framing_plots$p1) framing_p2_grob <- ggplotGrob(framing_plots$p2) throwing_grob <- ggplotGrob(catcher_create_throwing_plot(game_df, catcher_name)) pdf(output_file, width = 10.5, height = 13) tryCatch({ grid::grid.newpage() pushViewport(viewport(x=.5, y=.98, width=.94, height=.08, just=c("center","top"))) grid.draw(catcher_create_simple_header(catcher_name, game_key, bio_data)); popViewport() grid.text("Receiving", x=.5, y=.89, gp=gpar(fontface="bold", cex=1.2, col="#006F71")) headers_r <- c("CCU Strikes Stolen","CCU Strikes Lost","CCU Game +/-") values_r <- c(receiving_stats$`CCU Strikes Stolen`, receiving_stats$`CCU Strikes Lost`, receiving_stats$`CCU Game +/-`) col_w <- .18; x0 <- .5 - (length(headers_r)*col_w)/2; yh <- .868; yv <- .846 for (i in seq_along(headers_r)) { xi <- x0 + (i-1)*col_w grid.rect(x=xi, y=yh, width=col_w*.985, height=.018, just=c("left","top"), gp=gpar(fill="#006F71", col="black", lwd=.5)) grid.text(headers_r[i], x=xi + col_w*.49, y=yh - .009, gp=gpar(col="white", cex=.7, fontface="bold")) grid.rect(x=xi, y=yv, width=col_w*.985, height=.018, just=c("left","top"), gp=gpar(fill="white", col="black", lwd=.4)) grid.text(as.character(values_r[i]), x=xi + col_w*.49, y=yv - .009, gp=gpar(cex=.7)) } headers_opp <- c("Opp Strikes Stolen","Opp Strikes Lost","Opp Game +/-") values_opp <- c(opp_receiving_stats$`Opp Strikes Stolen`, opp_receiving_stats$`Opp Strikes Lost`, opp_receiving_stats$`Opp Game +/-`) yh_opp <- .820; yv_opp <- .798 for (i in seq_along(headers_opp)) { xi <- x0 + (i-1)*col_w grid.rect(x=xi, y=yh_opp, width=col_w*.985, height=.018, just=c("left","top"), gp=gpar(fill="#006F71", col="black", lwd=.5)) grid.text(headers_opp[i], x=xi + col_w*.49, y=yh_opp - .009, gp=gpar(col="white", cex=.7, fontface="bold")) grid.rect(x=xi, y=yv_opp, width=col_w*.985, height=.018, just=c("left","top"), gp=gpar(fill="white", col="black", lwd=.4)) grid.text(as.character(values_opp[i]), x=xi + col_w*.49, y=yv_opp - .009, gp=gpar(cex=.7)) } pushViewport(viewport(x=.25, y=.77, width=.47, height=.28, just=c("center","top"))); grid.draw(framing_p1_grob); popViewport() pushViewport(viewport(x=.75, y=.77, width=.47, height=.28, just=c("center","top"))); grid.draw(framing_p2_grob); popViewport() y_framing_table_start <- .46 if (nrow(pitch_log) > 0) { headers_f <- c("PitchNo","Pitch","Pitcher","Catcher","Batter","PitchCall","Actual") widths_f <- c(.08,.10,.15,.15,.15,.12,.10) x_start <- .5 - sum(widths_f)/2 x_pos <- c(x_start, x_start + cumsum(widths_f[-length(widths_f)])) row_h <- .013; y_top <- y_framing_table_start for(i in seq_along(headers_f)){ grid.rect(x=x_pos[i], y=y_top, width=widths_f[i]*.985, height=row_h, just=c("left","top"), gp=gpar(fill="#006F71", col="black", lwd=.4)) grid.text(headers_f[i], x=x_pos[i]+widths_f[i]*.49, y=y_top - row_h*.5, gp=gpar(col="white", cex=.58, fontface="bold")) } max_rows <- min(8, nrow(pitch_log)) for(r in 1:max_rows){ y_row <- y_top - r*row_h row_vals <- c(pitch_log$PitchNo[r], pitch_log$Pitch[r], pitch_log$Pitcher[r], pitch_log$Catcher[r], pitch_log$Batter[r], pitch_log$PitchCall[r], pitch_log$Actual[r]) fill_color <- ifelse(pitch_log$Actual[r] == "STRIKE", "#90EE90", "#FFB6C1") for(i in seq_along(row_vals)){ bg_fill <- if (i == 7) fill_color else ifelse(r %% 2 == 0, "#f7f7f7", "white") grid.rect(x=x_pos[i], y=y_row, width=widths_f[i]*.985, height=row_h, just=c("left","top"), gp=gpar(fill=bg_fill, col="grey80", lwd=.3)) grid.text(as.character(row_vals[i]), x=x_pos[i]+widths_f[i]*.49, y=y_row - row_h*.5, gp=gpar(cex=.55)) } } } y_throwing_start <- .35 pushViewport(viewport(x=.5, y=y_throwing_start - .02, width=.55, height=.22, just=c("center","top"))); grid.draw(throwing_grob); popViewport() y_throwing_table_start <- .10 if (nrow(throw_log) > 0) { headers_t <- c("PitchNo","Pitcher","Catcher","ThrowSpeed","PopTime","ExchangeTime","Notes") widths_t <- c(.08,.18,.15,.12,.10,.13,.10) x_start_t <- .5 - sum(widths_t)/2 x_pos_t <- c(x_start_t, x_start_t + cumsum(widths_t[-length(widths_t)])) row_ht <- .012; y_top_t <- y_throwing_table_start for(i in seq_along(headers_t)){ grid.rect(x=x_pos_t[i], y=y_top_t, width=widths_t[i]*.985, height=row_ht, just=c("left","top"), gp=gpar(fill="#006F71", col="black", lwd=.4)) grid.text(headers_t[i], x=x_pos_t[i]+widths_t[i]*.49, y=y_top_t - row_ht*.5, gp=gpar(col="white", cex=.55, fontface="bold")) } max_rows_t <- min(5, nrow(throw_log)) for(r in 1:max_rows_t){ y_row_t <- y_top_t - r*row_ht row_vals_t <- c(throw_log$PitchNo[r], throw_log$Pitcher[r], throw_log$Catcher[r], round(throw_log$ThrowSpeed[r],1), round(throw_log$PopTime[r],2), round(throw_log$ExchangeTime[r],2), throw_log$Notes[r]) for(i in seq_along(row_vals_t)){ bg_fill <- ifelse(r %% 2 == 0, "#f7f7f7", "white") grid.rect(x=x_pos_t[i], y=y_row_t, width=widths_t[i]*.985, height=row_ht, just=c("left","top"), gp=gpar(fill=bg_fill, col="grey80", lwd=.3)) grid.text(as.character(row_vals_t[i]), x=x_pos_t[i]+widths_t[i]*.49, y=y_row_t - row_ht*.5, gp=gpar(cex=.55)) } } } }, error=function(e){ message("Error creating PDF: ", e$message) }, finally={ dev.off() }) if (!file.exists(output_file)) stop("PDF file was not created successfully") return(output_file) grid::grid.text("Data: TrackMan | Report Generated: Coastal Carolina Baseball Analytics", x = 0.5, y = 0.02, gp = grid::gpar(cex = 0.75, col = "grey50")) } # ===================================================================== # ===================== PITCHER CODE (UPDATED) ====================== # ===================================================================== draw_boxed_shared_legend <- function(labels, colors, x_center = 0.5, y_top = 0.755, n_cols = 6, cell_height = 0.036, box_width = 0.82, title = "Pitch Type", pad_h = 0.016, pad_v = 0.018, border_col = "#bfbfbf", cex_labels = 0.82) { labels <- as.character(labels) colors <- as.character(colors) if (!length(labels)) { grid::grid.text("No legend (no pitch types)", x = x_center, y = y_top - 0.02) return(invisible(NULL)) } n_cols <- max(1L, min(n_cols, length(labels))) n_items <- length(labels) n_rows <- ceiling(n_items / n_cols) title_h <- cell_height * 0.9 box_h <- pad_v + title_h + (n_rows * cell_height) + pad_v box_w <- box_width grid::grid.rect(x = x_center, y = y_top - box_h/2, width = box_w, height = box_h, just = c("center", "center"), gp = grid::gpar(fill = "white", col = border_col, lwd = 0.8)) grid::grid.text(title, x = x_center, y = y_top - pad_v, just = c("center", "top"), gp = grid::gpar(fontface = "bold", cex = 0.92, col = "#006F71")) inner_top_y <- y_top - pad_v - title_h grid::pushViewport( grid::viewport( x = x_center, y = inner_top_y - ((n_rows * cell_height)/2), width = box_w - 2*pad_h, height = n_rows * cell_height, just = c("center","center"), layout = grid::grid.layout(nrow = n_rows, ncol = n_cols) ) ) idx <- 1L for (r in seq_len(n_rows)) { for (c in seq_len(n_cols)) { if (idx > n_items) break lab <- labels[idx] col <- colors[idx] grid::pushViewport(grid::viewport(layout.pos.row = r, layout.pos.col = c)) grid::pushViewport(grid::viewport( layout = grid::grid.layout( nrow = 1, ncol = 2, widths = grid::unit.c(grid::unit(0.40, "npc"), grid::unit(0.60, "npc")) ) )) grid::pushViewport(grid::viewport(layout.pos.row = 1, layout.pos.col = 1)) grid::grid.rect(x = 0.5, y = 0.5, width = grid::unit(0.55, "npc"), height = grid::unit(0.55, "npc"), just = c("center","center"), gp = grid::gpar(fill = col, col = "black", lwd = 0.4)) grid::popViewport() grid::pushViewport(grid::viewport(layout.pos.row = 1, layout.pos.col = 2)) grid::grid.text(lab, x = 0.5, y = 0.5, just = c("center","center"), gp = grid::gpar(cex = cex_labels)) grid::popViewport() grid::popViewport() grid::popViewport() idx <- idx + 1L } } grid::popViewport() } draw_simple_table <- function(df, y_top, col_headers = colnames(df), col_widths = NULL, row_height = 0.018, header_bg = "#006F71", header_cex = 0.60, cell_cex = 0.58, header_fg = "white", zebra = TRUE) { if (is.null(df) || !ncol(df)) { grid::textGrob("No data", gp = grid::gpar(col = "red")) return(invisible(NULL)) } df[] <- lapply(df, function(x) ifelse(is.na(x), "", as.character(x))) if (is.null(col_headers) || length(col_headers) != ncol(df)) { col_headers <- colnames(df) } if (is.null(col_widths)) { col_widths <- rep(1 / ncol(df), ncol(df)) } x_start <- 0.5 - sum(col_widths)/2 x_pos <- c(x_start, x_start + cumsum(col_widths[-length(col_widths)])) for (i in seq_along(col_headers)) { grid::grid.rect(x = x_pos[i], y = y_top, width = col_widths[i]*0.985, height = row_height, just = c("left","top"), gp = grid::gpar(fill = header_bg, col = "black", lwd = 0.5)) grid::grid.text(col_headers[i], x = x_pos[i] + col_widths[i]*0.49, y = y_top - row_height*0.5, gp = grid::gpar(col = header_fg, cex = header_cex, fontface = "bold")) } if (nrow(df) == 0) return(invisible(NULL)) for (r in seq_len(nrow(df))) { y_row <- y_top - r*row_height for (i in seq_along(col_headers)) { val <- df[[i]][r] bg <- if (zebra && (r %% 2 == 0)) "#f7f7f7" else "white" grid::grid.rect(x = x_pos[i], y = y_row, width = col_widths[i]*0.985, height = row_height, just = c("left","top"), gp = grid::gpar(fill = bg, col = "grey80", lwd = 0.3)) grid::grid.text(val, x = x_pos[i] + col_widths[i]*0.49, y = y_row - row_height*0.5, gp = grid::gpar(cex = cell_cex)) } } } .pitcher_game_line_headers <- c("Date","BF","K","BB","HBP","H","XBH","Strike %","Whiff %") .pitcher_game_line_widths <- c(0.13,0.07,0.06,0.06,0.07,0.06,0.07,0.11,0.11) .pitcher_char_headers <- c("Pitch","Total","Avg Velo","Max Velo","Avg Spin","Max Spin", "Avg IVB","Avg HB","RelHt","Ext","Strike %","Whiff %") .pitcher_char_widths <- c(0.12,0.07,0.09,0.09,0.09,0.09,0.085,0.085,0.07,0.07,0.085,0.085) create_pitcher_game_line <- function(game_data) { game_data %>% summarise( Date = format(unique(Date)[1], "%m/%d/%y"), BF = n_distinct(paste(Inning, Batter, PAofInning)), K = sum(KorBB == "Strikeout", na.rm = TRUE), BB = sum(WalkIndicator, na.rm = TRUE), HBP = sum(HBPIndicator, na.rm = TRUE), H = sum(PlayResult %in% c('Single','Double','Triple','HomeRun'), na.rm = TRUE), XBH = sum(PlayResult %in% c('Double','Triple','HomeRun'), na.rm = TRUE), `Strike %` = round(mean(PitchCall %in% c("StrikeCalled","StrikeSwinging","FoulBall","FoulBallNotFieldable","InPlay"), na.rm = TRUE) * 100, 1), `Whiff %` = round(sum(WhiffIndicator, na.rm = TRUE) / sum(SwingIndicator, na.rm = TRUE) * 100, 1) ) } create_pitcher_pitch_char <- function(game_data) { game_data %>% filter(TaggedPitchType != "Other", !is.na(TaggedPitchType)) %>% group_by(Pitch = TaggedPitchType) %>% summarise( Total = n(), `Avg Velo` = round(mean(RelSpeed, na.rm = TRUE), 1), `Max Velo` = round(max(RelSpeed, na.rm = TRUE), 1), `Avg Spin` = round(mean(SpinRate, na.rm = TRUE), 0), `Max Spin` = round(max(SpinRate, na.rm = TRUE), 1), `Avg IVB` = round(mean(InducedVertBreak, na.rm = TRUE), 1), `Avg HB` = round(mean(HorzBreak, na.rm = TRUE), 1), RelHt = round(mean(RelHeight, na.rm = TRUE), 1), Ext = round(mean(Extension, na.rm = TRUE), 1), `Strike %` = round(mean(PitchCall %in% c("StrikeCalled","StrikeSwinging","FoulBall","FoulBallNotFieldable","InPlay"), na.rm = TRUE) * 100, 1), `Whiff %` = round(sum(WhiffIndicator, na.rm = TRUE) / sum(SwingIndicator, na.rm = TRUE) * 100, 1), .groups = "drop" ) %>% arrange(desc(Total)) } .pitch_theme <- theme_minimal(base_size = 12) + theme( plot.title = element_text(size = 16, face = "bold", hjust = 0.5), panel.grid.minor = element_blank() ) create_pitcher_movement_plot <- function(game_data, pitcher_name, pitch_colors) { df <- game_data %>% filter(!is.na(TaggedPitchType), TaggedPitchType != "Other") if (nrow(df) == 0) return(ggplot() + theme_void() + ggtitle("Pitch Movement")) centers <- df %>% group_by(TaggedPitchType) %>% summarise( mean_velo = round(mean(RelSpeed, na.rm = TRUE)), mean_hb = median(HorzBreak, na.rm = TRUE), mean_ivb = median(InducedVertBreak, na.rm = TRUE), .groups = "drop" ) ggplot(df, aes(x = HorzBreak, y = InducedVertBreak)) + geom_vline(xintercept = 0, color = "black", linewidth = 0.5) + geom_hline(yintercept = 0, color = "black", linewidth = 0.5) + geom_point(aes(fill = TaggedPitchType), alpha = 0.85, shape = 21, color = "black", stroke = 0.4, size = 4.5) + geom_point(data = centers, aes(x = mean_hb, y = mean_ivb, fill = TaggedPitchType), alpha = 1, shape = 21, color = "black", stroke = 0.5, size = 8) + geom_text(data = centers, aes(x = mean_hb, y = mean_ivb, label = mean_velo), color = "black", size = 4, vjust = 0.5, fontface = "bold") + scale_fill_manual(values = pitch_colors) + coord_cartesian(xlim = c(-27.5, 27.5), ylim = c(-27.5, 27.5)) + labs(title = "Pitch Movement", x = "Horizontal Break (in)", y = "Induced Vertical Break (in)") + .pitch_theme + theme(legend.position = "none") } create_pitcher_location_plot <- function(game_data, pitch_colors) { df <- game_data %>% filter(!is.na(TaggedPitchType), TaggedPitchType != "Other") if (!nrow(df)) { return( ggplot() + annotate("rect", xmin = -0.8303, xmax = 0.8303, ymin = 1.5, ymax = 3.3775, alpha = 0, size = .5, color = "black") + theme_void() + ggtitle("Pitch Locations") + theme(plot.title = element_text(size=16, face="bold", hjust=.5)) ) } ggplot2::ggplot(df, ggplot2::aes(PlateLocSide, PlateLocHeight)) + ggplot2::annotate("rect", xmin = -0.8303, xmax = 0.8303, ymin = 1.5, ymax = 3.3775, alpha = 0, size = .5, color = "black") + ggplot2::annotate("segment", x = -0.708, y = 0.15, xend = 0.708, yend = 0.15, size = .5, color = "black") + ggplot2::annotate("segment", x = -0.708, y = 0.30, xend = -0.708, yend = 0.15, size = .5, color = "black") + ggplot2::annotate("segment", x = 0.708, y = 0.30, xend = 0.708, yend = 0.15, size = .5, color = "black") + ggplot2::annotate("segment", x = -0.708, y = 0.30, xend = 0.000, yend = 0.50, size = .5, color = "black") + ggplot2::annotate("segment", x = 0.708, y = 0.30, xend = 0.000, yend = 0.50, size = .5, color = "black") + ggplot2::geom_point(ggplot2::aes(fill = TaggedPitchType), alpha = 0.95, shape = 21, color = "black", stroke = 0.4, size = 4) + scale_fill_manual(values = pitch_colors, name = "Pitch Type") + coord_fixed(xlim = c(-2, 2), ylim = c(0, 4)) + labs(title = "Pitch Locations", x = NULL, y = NULL) + theme_void() + theme( plot.title = element_text(size = 12, face = "bold", hjust = .5), legend.position = "none", plot.margin = margin(6, 6, 6, 6) ) } create_pitcher_release_plot <- function(game_data, pitch_colors) { df <- game_data %>% filter(!is.na(RelSide), !is.na(RelHeight), TaggedPitchType != "Other") if (!nrow(df)) return(ggplot() + theme_void() + ggtitle("Release Points") + theme(plot.title = element_text(size=16, face="bold", hjust=.5))) avg_release <- df %>% group_by(TaggedPitchType) %>% summarise(RelSide = mean(RelSide, na.rm = TRUE), RelHeight = mean(RelHeight, na.rm = TRUE), .groups = "drop") ggplot() + geom_point(data = df, aes(RelSide, RelHeight, fill = TaggedPitchType), size = 4, shape = 21, color = "black", alpha = 0.85, stroke = 0.25) + geom_point(data = avg_release, aes(RelSide, RelHeight, fill = TaggedPitchType), size = 4.5, shape = 21, color = "black", stroke = 0.3, alpha = 1) + annotate("text", x = -5, y = 8, label = "← 1B", size = 3, hjust = 0) + annotate("text", x = 5, y = 8, label = "3B →", size = 3, hjust = 1) + geom_rect(aes(xmin = -5, xmax = 5, ymin = 0, ymax = 0.83), fill = "#632b11", inherit.aes = FALSE) + geom_rect(aes(xmin = -0.5, xmax = 0.5, ymin = 0.8, ymax = 0.95), fill = "white", color = "black", linewidth = 0.4, inherit.aes = FALSE) + scale_fill_manual(values = pitch_colors, name = "Pitch Type") + coord_cartesian(xlim = c(-5, 5), ylim = c(0, 8)) + labs(title = "Release Points", x = "Release Side (ft)", y = "Release Height (ft)") + .pitch_theme + theme(legend.position = "none") } create_relside_height_plot <- function(data, player_name, pitch_colors) { data <- normalize_columns(data) pitcher_data <- data %>% filter(Pitcher == player_name, !is.na(TaggedPitchType), TaggedPitchType != "Other", !is.na(RelSide), !is.na(RelHeight)) if (nrow(pitcher_data) == 0) { return(ggplot() + theme_void() + ggtitle("Release Side vs Release Height") + theme(plot.title = element_text(size = 12, face = "bold", hjust = 0.5))) } avg_release <- pitcher_data %>% group_by(TaggedPitchType) %>% summarise( RelSide = mean(RelSide, na.rm = TRUE), RelHeight = mean(RelHeight, na.rm = TRUE), .groups = "drop" ) ggplot(pitcher_data, aes(RelSide, RelHeight)) + geom_point(aes(fill = TaggedPitchType), size = 4, shape = 21, color = "black", alpha = 0.85, stroke = 0.25) + geom_point(data = avg_release, aes(RelSide, RelHeight, fill = TaggedPitchType), size = 4.5, shape = 21, color = "black", stroke = 0.3, alpha = 1) + annotate("text", x = -4.7, y = 8, label = "\u2190 3B", size = 3, hjust = 0) + annotate("text", x = 4.7, y = 8, label = "1B \u2192", size = 3, hjust = 1) + geom_rect(aes(xmin = -3.5, xmax = 3.5, ymin = 0, ymax = 0.83), fill = "#632b11", inherit.aes = FALSE) + geom_rect(aes(xmin = -0.7, xmax = 0.7, ymin = 0.8, ymax = 0.95), fill = "white", color = "black", linewidth = 0.4, inherit.aes = FALSE) + scale_fill_manual(values = pitch_colors, name = "Pitch Type") + coord_cartesian(xlim = c(-4.3, 4.3), ylim = c(0, 9)) + labs(title = "Release Height + Release Side", x = "Release Side (ft)", y = "Release Height (ft)") + theme_minimal() + theme(plot.title = element_text(hjust = 0.5, size = 14, face = "bold"), legend.position = "none") } create_pitcher_pdf <- function(game_df, pitcher_name, output_file, pitch_colors) { ensure_cols <- function(df, cols) { if (is.null(df)) df <- data.frame() for (c in cols) if (!c %in% names(df)) df[[c]] <- NA if (!ncol(df)) df <- as.data.frame(setNames(replicate(length(cols), character(0), simplify = FALSE), cols)) df[, cols, drop = FALSE] } draw_simple_table <- function(df, y_top, col_headers = colnames(df), col_widths = NULL, row_height = 0.018, header_bg = "#006F71", header_cex = 0.60, cell_cex = 0.58, header_fg = "white", zebra = TRUE) { if (is.null(df) || !ncol(df)) { grid::grid.text("No data", y = y_top - 0.012, gp = grid::gpar(col = "red")) return(invisible(NULL)) } df[] <- lapply(df, function(x) ifelse(is.na(x), "", as.character(x))) if (is.null(col_headers) || length(col_headers) != ncol(df)) col_headers <- colnames(df) if (is.null(col_widths)) col_widths <- rep(1 / ncol(df), ncol(df)) x_start <- 0.5 - sum(col_widths) / 2 x_pos <- c(x_start, x_start + cumsum(col_widths[-length(col_widths)])) for (i in seq_along(col_headers)) { grid::grid.rect(x = x_pos[i], y = y_top, width = col_widths[i] * 0.985, height = row_height, just = c("left", "top"), gp = grid::gpar(fill = header_bg, col = "black", lwd = 0.5)) grid::grid.text(col_headers[i], x = x_pos[i] + col_widths[i] * 0.49, y = y_top - row_height * 0.5, gp = grid::gpar(col = header_fg, cex = header_cex, fontface = "bold")) } if (nrow(df) == 0) return(invisible(NULL)) for (r in seq_len(nrow(df))) { y_row <- y_top - r * row_height for (i in seq_along(col_headers)) { val <- df[[i]][r] bg <- if (zebra && (r %% 2 == 0)) "#f7f7f7" else "white" grid::grid.rect(x = x_pos[i], y = y_row, width = col_widths[i] * 0.985, height = row_height, just = c("left", "top"), gp = grid::gpar(fill = bg, col = "grey80", lwd = 0.3)) grid::grid.text(val, x = x_pos[i] + col_widths[i] * 0.49, y = y_row - row_height * 0.5, gp = grid::gpar(cex = cell_cex)) } } } draw_boxed_shared_legend <- function(labels, colors, x_center = 0.5, y_top = 0.755, n_cols = 6, cell_height = 0.036, box_width = 0.82, title = "Pitch Type", pad_h = 0.016, pad_v = 0.018, border_col = "#bfbfbf", cex_labels = 0.82) { labels <- as.character(labels) colors <- as.character(colors) if (!length(labels)) { grid::grid.text("No legend (no pitch types)", x = x_center, y = y_top - 0.02) return(invisible(NULL)) } n_cols <- max(1L, min(n_cols, length(labels))) n_items <- length(labels) n_rows <- ceiling(n_items / n_cols) title_h <- cell_height * 0.9 box_h <- pad_v + title_h + (n_rows * cell_height) + pad_v box_w <- box_width grid::grid.rect(x = x_center, y = y_top - box_h/2, width = box_w, height = box_h, just = c("center", "center"), gp = grid::gpar(fill = "white", col = border_col, lwd = 0.8)) grid::grid.text(title, x = x_center, y = y_top - pad_v, just = c("center", "top"), gp = grid::gpar(fontface = "bold", cex = 0.92, col = "#006F71")) inner_top_y <- y_top - pad_v - title_h grid::pushViewport( grid::viewport( x = x_center, y = inner_top_y - ((n_rows * cell_height)/2), width = box_w - 2*pad_h, height = n_rows * cell_height, just = c("center","center"), layout = grid::grid.layout(nrow = n_rows, ncol = n_cols) ) ) idx <- 1L for (r in seq_len(n_rows)) { for (c in seq_len(n_cols)) { if (idx > n_items) break lab <- labels[idx] col <- colors[idx] grid::pushViewport(grid::viewport(layout.pos.row = r, layout.pos.col = c)) grid::pushViewport(grid::viewport( layout = grid::grid.layout( nrow = 1, ncol = 2, widths = grid::unit.c(grid::unit(0.40, "npc"), grid::unit(0.60, "npc")) ) )) grid::pushViewport(grid::viewport(layout.pos.row = 1, layout.pos.col = 1)) grid::grid.rect(x = 0.5, y = 0.5, width = grid::unit(0.55, "npc"), height = grid::unit(0.55, "npc"), just = c("center","center"), gp = grid::gpar(fill = col, col = "black", lwd = 0.4)) grid::popViewport() grid::pushViewport(grid::viewport(layout.pos.row = 1, layout.pos.col = 2)) grid::grid.text(lab, x = 0.5, y = 0.5, just = c("center","center"), gp = grid::gpar(cex = cex_labels)) grid::popViewport() grid::popViewport() grid::popViewport() idx <- idx + 1L } } grid::popViewport() } safe_blank_plot <- function(title_txt) { ggplot2::ggplot() + ggplot2::theme_void() + ggplot2::ggtitle(title_txt) + ggplot2::theme(plot.title = ggplot2::element_text(hjust = 0.5, face = "bold")) } dfp <- game_df if (!"Pitcher" %in% names(dfp)) { alt <- intersect(c("PitcherName","pitcher","Pitcher_LastFirst","PlayerName"), names(dfp)) if (length(alt)) dfp$Pitcher <- dfp[[alt[1]]] else dfp$Pitcher <- NA_character_ } dfp <- dfp %>% dplyr::mutate(Pitcher = stringr::str_replace(coalesce(Pitcher, ""), "^\\s*(\\w+)\\s*,\\s*(\\w+)\\s*$", "\\2 \\1")) %>% dplyr::filter(Pitcher == pitcher_name) if (is.null(dfp) || nrow(dfp) == 0) { grDevices::pdf(output_file, width = 11, height = 8.5) on.exit(try(grDevices::dev.off(), silent = TRUE), add = TRUE) grid::grid.newpage() grid::grid.text(paste("No data for", pitcher_name), x = 0.5, y = 0.5, gp = grid::gpar(fontface = "bold", cex = 1.6, col = "#006F71")) return(invisible(output_file)) } game_day <- tryCatch(parse_game_day(dfp), error = function(e) Sys.Date()) game_headers <- c("Date","BF","K","BB","HBP","H","XBH","Strike %","Whiff %") game_widths <- c(0.13,0.07,0.06,0.06,0.07,0.06,0.07,0.11,0.11) char_headers <- c("Pitch","Total","Avg Velo","Max Velo","Avg Spin","Max Spin", "Avg IVB","Avg HB","RelHt","Ext","Strike %","Whiff %") char_widths <- c(0.12,0.07,0.09,0.09,0.09,0.09,0.085,0.085,0.07,0.07,0.085,0.085) game_line_df <- tryCatch(create_pitcher_game_line(dfp), error = function(e) data.frame()) game_line_df <- ensure_cols(game_line_df, game_headers) pitch_char_df <- tryCatch(create_pitcher_pitch_char(dfp), error = function(e) data.frame()) pitch_char_df <- ensure_cols(pitch_char_df, char_headers) present_types <- dfp %>% dplyr::filter(!is.na(TaggedPitchType), TaggedPitchType != "Other") %>% dplyr::count(TaggedPitchType, name = "N") %>% dplyr::arrange(dplyr::desc(N), TaggedPitchType) legend_labels <- present_types$TaggedPitchType legend_labels <- legend_labels[legend_labels %in% names(pitch_colors)] legend_colors <- unname(pitch_colors[legend_labels]) movement_plot <- tryCatch( create_pitcher_movement_plot(dfp, pitcher_name, pitch_colors) + ggplot2::theme(legend.position = "none"), error = function(e) safe_blank_plot("Pitch Movement") ) location_plot <- tryCatch( create_pitcher_location_plot(dfp, pitch_colors) + ggplot2::theme(legend.position = "none"), error = function(e) safe_blank_plot("Pitch Locations") ) release_plot <- tryCatch( create_pitcher_release_plot(dfp, pitch_colors) + ggplot2::theme(legend.position = "none"), error = function(e) safe_blank_plot("Release Points") ) grDevices::pdf(output_file, width = 11, height = 8.5) on.exit(try(grDevices::dev.off(), silent = TRUE), add = TRUE) grid::grid.newpage() grid::pushViewport(grid::viewport(x = 0.5, y = 0.965, width = 1, height = 0.10, just = c("center","top"))) grid::grid.text(paste(pitcher_name, "- Pitcher Report -", format(game_day, "%m/%d/%y")), gp = grid::gpar(fontface = "bold", cex = 1.9, col = "#006F71")) grid::popViewport() grid::grid.text("Game Line", x = 0.5, y = 0.86, gp = grid::gpar(fontface = "bold", cex = 1.05, col = "#006F71")) try(draw_simple_table( df = game_line_df, y_top = 0.84, col_headers = game_headers, col_widths = game_widths, row_height = 0.027, header_cex = 0.72, cell_cex = 0.68 ), silent = TRUE) grid::grid.text("Pitch Characteristics", x = 0.5, y = 0.76, gp = grid::gpar(fontface = "bold", cex = 1.05, col = "#006F71")) try(draw_simple_table( df = pitch_char_df, y_top = 0.74, col_headers = char_headers, col_widths = char_widths, row_height = 0.024, header_cex = 0.66, cell_cex = 0.62 ), silent = TRUE) try(draw_boxed_shared_legend( labels = legend_labels, colors = legend_colors, x_center = 0.5, y_top = 0.58, n_cols = 6, cell_height = 0.036, box_width = 0.82, title = "Pitch Type", pad_h = 0.016, pad_v = 0.018, border_col = "#bfbfbf", cex_labels = 0.82 ), silent = TRUE) grid::pushViewport(grid::viewport(x = 0.18, y = 0.45, width = 0.32, height = 0.40, just = c("center","top"))) try(print(movement_plot, newpage = FALSE), silent = TRUE); grid::popViewport() grid::pushViewport(grid::viewport(x = 0.50, y = 0.45, width = 0.32, height = 0.40, just = c("center","top"))) try(print(location_plot, newpage = FALSE), silent = TRUE); grid::popViewport() grid::pushViewport(grid::viewport(x = 0.82, y = 0.45, width = 0.32, height = 0.40, just = c("center","top"))) try(print(release_plot, newpage = FALSE), silent = TRUE); grid::popViewport() invisible(output_file) } umpire_process_data <- function(df) { df <- df %>% filter(PitchCall %in% c("StrikeCalled", "BallCalled", "BallinDirt")) if ("Date" %in% names(df)) { df$Date <- parse_flexible_date(df$Date) } if ("PlateLocSide" %in% names(df)) df$PlateLocSide <- as.numeric(df$PlateLocSide) if ("PlateLocHeight" %in% names(df)) df$PlateLocHeight <- as.numeric(df$PlateLocHeight) df } umpire_create_report_pdf <- function(data, output_file, left_logo_path = NULL, right_logo_path = NULL, matchup_title = NULL, umpire_name = NULL, rows_per_page = 30) { suppressPackageStartupMessages({ library(dplyr); library(grid); library(gridExtra); library(ggplot2); library(stringr) }) `%||%` <- function(a, b) if (!is.null(a)) a else b raw_strike_miss <- data %>% filter(PitchCall == "StrikeCalled") %>% filter(PlateLocSide < -0.83083 | PlateLocSide > 0.83083 | PlateLocHeight > 3.37750 | PlateLocHeight < 1.5) %>% nrow() raw_ball_miss <- data %>% filter(PitchCall %in% c("BallCalled", "BallinDirt")) %>% filter(PlateLocSide > -0.83083 & PlateLocSide < 0.83083 & PlateLocHeight < 3.37750 & PlateLocHeight > 1.5) %>% nrow() total_called <- nrow(data) total_missed <- raw_strike_miss + raw_ball_miss correct <- total_called - total_missed overall_pct <- paste0(sprintf("%.0f", 100 * (correct / total_called)), "%") buffer_strike_miss <- data %>% filter(PitchCall == "StrikeCalled") %>% filter(PlateLocSide < -0.9975 | PlateLocSide > 0.9975 | PlateLocHeight > 3.5 | PlateLocHeight < 1.3775) %>% nrow() buffer_total_missed <- raw_ball_miss + buffer_strike_miss buffer_correct <- total_called - buffer_total_missed buffer_pct <- paste0(sprintf("%.0f", 100 * (buffer_correct / total_called)), "%") game_date <- suppressWarnings(format(max(as.Date(data$Date)), "%b %d, %Y")) opp_team <- data %>% filter(BatterTeam != "COA_CHA") %>% pull(BatterTeam) %>% unique() %>% head(1) if (length(opp_team) == 0 || is.na(opp_team)) opp_team <- "Opponent" title_text <- matchup_title %||% sprintf("%s vs Coastal Carolina", opp_team) subhead_text <- if (!is.null(umpire_name) && !is.na(umpire_name) && nzchar(umpire_name)) { paste("Umpire Report —", umpire_name) } else "Umpire Report" strike_zone_rect <- data.frame(xmin = -0.83083, xmax = 0.83083, ymin = 1.5, ymax = 3.37750) buffer_zone_rect <- data.frame(xmin = -0.9975, xmax = 0.9975, ymin = 1.3775, ymax = 3.5) home_plate <- data.frame( x = c(-0.708, 0.708, 0.708, 0.000, -0.708), y = c( 0.150, 0.150, 0.300, 0.500, 0.300) ) # --------------------------------------------------------------- # BUILD MISSED CALLS TABLE EARLY (before plots) so we can number them # --------------------------------------------------------------- MissedCalls <- dplyr::bind_rows( data %>% filter(PitchCall %in% c("BallCalled", "BallinDirt"), PlateLocSide > -0.83083, PlateLocSide < 0.83083, PlateLocHeight < 3.37750, PlateLocHeight > 1.5), data %>% filter(PitchCall == "StrikeCalled") %>% filter(PlateLocSide < -0.83083 | PlateLocSide > 0.83083 | PlateLocHeight > 3.37750 | PlateLocHeight < 1.5) ) %>% arrange(PitchNo) %>% mutate(CallNo = row_number()) %>% mutate( Side = paste0(sprintf("%.0f", abs(PlateLocSide * 12)), '"'), Height = paste0(sprintf("%.0f", PlateLocHeight * 12), '"') ) # Keep PlateLocSide/PlateLocHeight/BatterSide/BatterTeam available for plot filtering # but select display columns for the table at the end # --------------------------------------------------------------- # BASE ZONE HELPER # --------------------------------------------------------------- base_zone <- function() { ggplot() + geom_rect(data = buffer_zone_rect, aes(xmin = xmin, xmax = xmax, ymin = ymin, ymax = ymax), fill = NA, color = "gray50", linewidth = 0.6, linetype = "dotted") + geom_rect(data = strike_zone_rect, aes(xmin = xmin, xmax = xmax, ymin = ymin, ymax = ymax), fill = NA, color = "black", linewidth = 0.8) + geom_polygon(data = home_plate, aes(x = x, y = y), fill = NA, color = "gray40", linewidth = 0.5) + coord_equal() + scale_x_continuous(limits = c(-1.8, 1.8)) + scale_y_continuous(limits = c(0, 4.5)) + theme_classic() + theme( axis.title = element_blank(), axis.text = element_blank(), axis.ticks = element_blank(), axis.line = element_blank(), panel.grid = element_blank(), legend.position = "none", plot.title = element_text(hjust = 0.5, size = 8, face = "bold"), plot.margin = margin(2, 2, 2, 2) ) } # --------------------------------------------------------------- # PLOT HELPERS — now use MissedCalls with CallNo labels # --------------------------------------------------------------- umpire_create_ball_plot <- function(mc, side_label, title_label) { pts <- mc %>% filter(BatterSide == side_label, PitchCall %in% c("BallCalled", "BallinDirt"), PlateLocSide > -0.83083, PlateLocSide < 0.83083, PlateLocHeight < 3.37750, PlateLocHeight > 1.5) base_zone() + geom_point(data = pts, aes(x = PlateLocSide, y = PlateLocHeight), pch = 21, fill = "#006F71", color = "black", size = 5) + geom_text(data = pts, aes(x = PlateLocSide, y = PlateLocHeight, label = CallNo), color = "white", size = 2.2, fontface = "bold") + ggtitle(title_label) } umpire_create_strike_plot <- function(mc, side_label, title_label) { pts <- mc %>% filter(BatterSide == side_label, PitchCall == "StrikeCalled") %>% filter(PlateLocSide < -0.83083 | PlateLocSide > 0.83083 | PlateLocHeight > 3.37750 | PlateLocHeight < 1.5) base_zone() + geom_point(data = pts, aes(x = PlateLocSide, y = PlateLocHeight), pch = 21, fill = "#006F71", color = "black", size = 5) + geom_text(data = pts, aes(x = PlateLocSide, y = PlateLocHeight, label = CallNo), color = "white", size = 2.2, fontface = "bold") + ggtitle(title_label) } umpire_create_ball_plot_team <- function(mc, is_ccu, title_label) { pts <- mc %>% filter(if (is_ccu) BatterTeam == "COA_CHA" else BatterTeam != "COA_CHA", PitchCall %in% c("BallCalled", "BallinDirt"), PlateLocSide > -0.83083, PlateLocSide < 0.83083, PlateLocHeight < 3.37750, PlateLocHeight > 1.5) base_zone() + geom_point(data = pts, aes(x = PlateLocSide, y = PlateLocHeight), pch = 21, fill = "#006F71", color = "black", size = 5) + geom_text(data = pts, aes(x = PlateLocSide, y = PlateLocHeight, label = CallNo), color = "white", size = 2.2, fontface = "bold") + ggtitle(title_label) } umpire_create_strike_plot_team <- function(mc, is_ccu, title_label) { pts <- mc %>% filter(if (is_ccu) BatterTeam == "COA_CHA" else BatterTeam != "COA_CHA", PitchCall == "StrikeCalled") %>% filter(PlateLocSide < -0.83083 | PlateLocSide > 0.83083 | PlateLocHeight > 3.37750 | PlateLocHeight < 1.5) base_zone() + geom_point(data = pts, aes(x = PlateLocSide, y = PlateLocHeight), pch = 21, fill = "#006F71", color = "black", size = 5) + geom_text(data = pts, aes(x = PlateLocSide, y = PlateLocHeight, label = CallNo), color = "white", size = 2.2, fontface = "bold") + ggtitle(title_label) } # --------------------------------------------------------------- # CREATE ALL PLOTS (pass MissedCalls instead of data) # --------------------------------------------------------------- plot_ball_lhb <- umpire_create_ball_plot(MissedCalls, "Left", "Ball Called v LHB") plot_ball_rhb <- umpire_create_ball_plot(MissedCalls, "Right", "Ball Called v RHB") plot_strike_lhb <- umpire_create_strike_plot(MissedCalls, "Left", "Strike Called v LHB") plot_strike_rhb <- umpire_create_strike_plot(MissedCalls, "Right", "Strike Called v RHB") plot_ball_ccu <- umpire_create_ball_plot_team(MissedCalls, TRUE, "Ball Called v CCU Hitters") plot_ball_opp <- umpire_create_ball_plot_team(MissedCalls, FALSE, "Ball Called v Opp Hitters") plot_strike_ccu <- umpire_create_strike_plot_team(MissedCalls, TRUE, "Strike Called v CCU Hitters") plot_strike_opp <- umpire_create_strike_plot_team(MissedCalls, FALSE, "Strike Called v Opp Hitters") # --------------------------------------------------------------- # PREPARE DISPLAY TABLE (select only display columns, CallNo first) # --------------------------------------------------------------- MissedCallsDisplay <- MissedCalls %>% select(dplyr::any_of(c("CallNo","PitchNo","Inning","Top/Bottom","TopBottom", "Batter","PitchCall","Side","Height","BatterTeam"))) green <- "#006F71" ttheme_green <- gridExtra::ttheme_minimal( core = list(fg_params = list(hjust = 0.5, x = 0.5, fontsize = 8), bg_params = list(fill = "white")), colhead = list(fg_params = list(hjust = 0.5, x = 0.5, col = "white", fontsize = 8, fontface = "bold"), bg_params = list(fill = green)) ) ttheme_green_small <- gridExtra::ttheme_minimal( core = list(fg_params = list(hjust = 0.5, x = 0.5, fontsize = 6.5), bg_params = list(fill = "white")), colhead = list(fg_params = list(hjust = 0.5, x = 0.5, col = "white", fontsize = 7, fontface = "bold"), bg_params = list(fill = green)) ) raw_table <- data.frame( "Strikes Missed" = raw_strike_miss, "Balls Missed" = raw_ball_miss, "Called" = total_called, "Missed" = total_missed, "Overall %" = overall_pct, check.names = FALSE ) buffer_table <- data.frame( "Strikes Missed" = buffer_strike_miss, "Called" = total_called, "Missed" = buffer_total_missed, "Overall %" = buffer_pct, check.names = FALSE ) draw_header <- function() { suppressPackageStartupMessages({ library(grid) library(magick) }) draw_logo_url <- function(url, x, just) { img <- try( magick::image_read(url), silent = TRUE ) if (inherits(img, "try-error")) return(NULL) img <- magick::image_resize(img, "x130") grid.draw( grid::rasterGrob( as.raster(img), interpolate = TRUE, vp = viewport( x = x, y = 0.96, width = 0.13, height = 0.08, just = c(just, "center") ) ) ) } ## --- EMBEDDED LOGO LINKS --- left_logo_url <- "https://i.imgur.com/zjTu3JS.png" right_logo_url <- "https://i.ibb.co/Q3kFXXd9/8acd1b8a-7920-403a-8e9d-86742634effb.png" draw_logo_url(left_logo_url, 0.05, "left") draw_logo_url(right_logo_url, 0.95, "right") ## --- HEADER TEXT --- grid.text( title_text, y = 0.975, gp = gpar(fontsize = 16, fontface = "bold") ) grid.text( subhead_text, y = 0.948, gp = gpar(fontsize = 12, fontface = "bold") ) if (!is.na(game_date)) { grid.text( game_date, y = 0.925, gp = gpar(fontsize = 9) ) } } grDevices::pdf(output_file, width = 8.5, height = 11) grid.newpage() draw_header() pushViewport(viewport(x = 0.5, y = 0.885, width = 0.70, height = 0.045)) grid.table(raw_table, rows = NULL, theme = ttheme_green) popViewport() pushViewport(viewport(x = 0.28, y = 0.70, width = 0.50, height = 0.30)); print(plot_ball_lhb, newpage = FALSE); popViewport() pushViewport(viewport(x = 0.72, y = 0.70, width = 0.50, height = 0.30)); print(plot_ball_rhb, newpage = FALSE); popViewport() pushViewport(viewport(x = 0.28, y = 0.48, width = 0.50, height = 0.30)); print(plot_strike_lhb, newpage = FALSE); popViewport() pushViewport(viewport(x = 0.72, y = 0.48, width = 0.50, height = 0.30)); print(plot_strike_rhb, newpage = FALSE); popViewport() grid.text("Adjusted Score", y = 0.25, gp = gpar(fontsize = 11, fontface = "bold")) pushViewport(viewport(x = 0.5, y = 0.215, width = 0.58, height = 0.045)) grid.table(buffer_table, rows = NULL, theme = ttheme_green) popViewport() grid.newpage() grid::grid.text("Data: TrackMan | Report Generated: Coastal Carolina Baseball Analytics", x = 0.5, y = 0.02, gp = grid::gpar(cex = 0.75, col = "grey50")) pushViewport(viewport(x = 0.28, y = 0.81, width = 0.47, height = 0.27)); print(plot_ball_ccu, newpage = FALSE); popViewport() pushViewport(viewport(x = 0.72, y = 0.81, width = 0.47, height = 0.27)); print(plot_ball_opp, newpage = FALSE); popViewport() pushViewport(viewport(x = 0.28, y = 0.60, width = 0.47, height = 0.27)); print(plot_strike_ccu, newpage = FALSE); popViewport() pushViewport(viewport(x = 0.72, y = 0.60, width = 0.47, height = 0.27)); print(plot_strike_opp, newpage = FALSE); popViewport() if (nrow(MissedCallsDisplay) > 0) { # First page can fit 15 rows in the bottom half first_page_rows <- 15 remaining_page_rows <- rows_per_page # 30 rows per full page if (nrow(MissedCallsDisplay) <= first_page_rows) { # Fits on current page pushViewport(viewport(x = 0.5, y = 0.30, width = 0.92, height = 0.50)) grid.table(MissedCallsDisplay, rows = NULL, theme = ttheme_green_small) popViewport() } else { # First batch on current page first_chunk <- MissedCallsDisplay[1:first_page_rows, , drop = FALSE] pushViewport(viewport(x = 0.5, y = 0.30, width = 0.92, height = 0.50)) grid.table(first_chunk, rows = NULL, theme = ttheme_green_small) popViewport() # Remaining rows paginated onto new pages remaining <- MissedCallsDisplay[(first_page_rows + 1):nrow(MissedCallsDisplay), , drop = FALSE] remaining_chunks <- split(remaining, ceiling(seq_len(nrow(remaining)) / remaining_page_rows)) for (i in seq_along(remaining_chunks)) { grid.newpage() grid.text("Missed Calls (continued)", x = 0.5, y = 0.96, gp = gpar(fontsize = 12, fontface = "bold", col = "#006F71")) pushViewport(viewport(x = 0.5, y = 0.75, width = 0.92, height = 0.88)) grid.table(remaining_chunks[[i]], rows = NULL, theme = ttheme_green_small) popViewport() } } } grDevices::dev.off() invisible(output_file) } # Advanced Pitcher Functions `%||%` <- function(a, b) if (!is.null(a)) a else b safe_color_at <- function(mat, r, c, default = "#FFFFFF") { if (is.null(mat) || is.null(dim(mat))) return(default) nr <- nrow(mat); nc <- ncol(mat) if (length(r) != 1 || length(c) != 1) return(default) if (is.na(r) || is.na(c)) return(default) if (r < 1 || c < 1 || r > nr || c > nc) return(default) mat[r, c] } sync_color_matrix_to_df <- function(colmat, df, fill = "#FFFFFF") { nr <- max(1, nrow(df)); nc <- max(1, ncol(df)) out <- matrix(fill, nrow = nr, ncol = nc) if (!is.null(colmat) && !is.null(dim(colmat))) { r_take <- min(nrow(colmat), nr) c_take <- min(ncol(colmat), nc) out[seq_len(r_take), seq_len(c_take)] <- colmat[seq_len(r_take), seq_len(c_take), drop = FALSE] } out } has_col_index <- function(idx) { is.numeric(idx) && length(idx) == 1 && !is.na(idx) && is.finite(idx) && idx >= 1 } reference_data_for_stuff <- tryCatch({ message("Loading reference data for Stuff+ standardization...") ref_list <- list( spring = arrow::read_parquet("CCUPitcher25.parquet"), p5 = arrow::read_parquet("P5_2025.parquet"), sbc = arrow::read_parquet("SBC_2025.parquet") ) # FIX: Convert all potentially mismatched columns to character to prevent bind_rows errors convert_problem_cols <- function(df) { # Date columns if ("Date" %in% names(df)) df$Date <- as.character(df$Date) if ("UTCDate" %in% names(df)) df$UTCDate <- as.character(df$UTCDate) if ("UTCDateTime" %in% names(df)) df$UTCDateTime <- as.character(df$UTCDateTime) if ("LocalDateTime" %in% names(df)) df$LocalDateTime <- as.character(df$LocalDateTime) # ID columns that may have mixed types if ("HomeTeamForeignID" %in% names(df)) df$HomeTeamForeignID <- as.character(df$HomeTeamForeignID) if ("AwayTeamForeignID" %in% names(df)) df$AwayTeamForeignID <- as.character(df$AwayTeamForeignID) if ("GameUID" %in% names(df)) df$GameUID <- as.character(df$GameUID) if ("PitchUID" %in% names(df)) df$PitchUID <- as.character(df$PitchUID) if ("PlayID" %in% names(df)) df$PlayID <- as.character(df$PlayID) df } ref_list$spring <- convert_problem_cols(ref_list$spring) ref_list$p5 <- convert_problem_cols(ref_list$p5) ref_list$sbc <- convert_problem_cols(ref_list$sbc) ref_list }, error = function(e) { message("Reference data files not found. Stuff+ will use local standardization.") message("ERROR: ", e$message) NULL }) preflight_predictor_check <- function(model, newdata) { if (!inherits(model, "workflow")) return(invisible(NULL)) rec <- try(workflows::extract_recipe(model), silent = TRUE) if (inherits(rec, "try-error") || is.null(rec)) return(invisible(NULL)) s <- try(summary(rec), silent = TRUE) if (inherits(s, "try-error") || is.null(s)) return(invisible(NULL)) needed <- s$variable[s$role == "predictor"] missing <- setdiff(needed, names(newdata)) if (length(missing)) { message("Stuff+ missing RAW predictors (recipe roles): ", paste(missing, collapse = ", ")) } invisible(NULL) } ensure_stuff_inputs <- function(df) { df <- df %>% mutate(RelSide = case_when( PitcherThrows == "Right" ~ RelSide, PitcherThrows == "Left" ~ -RelSide, PitcherThrows %in% c("Both", "Undefined") & RelSide > 0 ~ RelSide, PitcherThrows %in% c("Both", "Undefined") & RelSide < 0 ~ -RelSide), ax0 = case_when( PitcherThrows == "Right" ~ ax0, PitcherThrows == "Left" ~ -ax0, PitcherThrows %in% c("Both", "Undefined") & ax0 > 0 ~ ax0, PitcherThrows %in% c("Both", "Undefined") & ax0 < 0 ~ -ax0), PlateLocHeight = PlateLocHeight*12, PlateLocSide = PlateLocSide*12, ax0 = -ax0) %>% group_by(Pitcher, GameID) %>% mutate( primary_pitch = case_when( any(TaggedPitchType == "Fastball") ~ "Fastball", any(TaggedPitchType == "Sinker") ~ "Sinker", TRUE ~ names(sort(table(TaggedPitchType), decreasing = TRUE))[1] ) ) %>% group_by(Pitcher, GameID, primary_pitch) %>% mutate( primary_az0 = mean(az0[TaggedPitchType == primary_pitch], na.rm = TRUE), primary_velo = mean(RelSpeed[TaggedPitchType == primary_pitch], na.rm = TRUE) ) %>% ungroup() %>% mutate(az0_diff = az0 - primary_az0, velo_diff = RelSpeed - primary_velo) df } standardize_stuffplus_to_league <- function(data, league_comparison_data) { data <- ensure_stuff_inputs(data) league_comparison_data <- ensure_stuff_inputs(league_comparison_data) common_cols <- intersect(names(data), names(league_comparison_data)) for (col in common_cols) { type1 <- class(data[[col]])[1] type2 <- class(league_comparison_data[[col]])[1] if (type1 != type2) { message("Converting mismatched column '", col, "': ", type1, " vs ", type2) data[[col]] <- as.character(data[[col]]) league_comparison_data[[col]] <- as.character(league_comparison_data[[col]]) } } df_processed <- bake(stuffplus_recipe, new_data = data) df_matrix <- as.matrix(df_processed) data$raw_stuff <- predict(stuffplus_model, df_matrix) data <- data %>% mutate(data_ind = 1) df_processed <- bake(stuffplus_recipe, new_data = league_comparison_data) df_matrix <- as.matrix(df_processed) league_comparison_data$raw_stuff <- predict(stuffplus_model, df_matrix) league_comparison_data <- league_comparison_data %>% mutate(data_ind = 0) stuff_df <- bind_rows(data, league_comparison_data) stuff_df <- stuff_df %>% mutate(stuff_plus = ((raw_stuff - mean(raw_stuff, na.rm = TRUE)) / sd(raw_stuff, na.rm = TRUE)) * 10 + 100) %>% filter(data_ind == 1) %>% dplyr::select(-data_ind) return(stuff_df) } sec_averages <- list( overall = list( chase = 26.2, k_rate = 26.4, bb_rate = 10, iz_whiff = 20, miss_rate = 28.9, fb_velo_l = 91.1, fb_velo_r = 93, strike_rate = 62.6, zone_rate = 46 ), fb_sinker = list(spin = 2267, zone = 50, strike = 64.4, iz_whiff = 17.9, whiff = 22.5, chase = 23.5), slider = list(velo_l = 81.6, velo_r = 83, zone = 42, spin = 2440, strike = 61.4, iz_whiff = 22.1, whiff = 37.5, chase = 28.6), curveball = list(velo_l = 78.2, velo_r = 79.1, zone = 40.6, spin = 2442, strike = 57.6, iz_whiff = 22.3, whiff = 38.1, chase = 24.4), changeup = list(velo_l = 81.8, velo_r = 84.1, zone = 37.1, spin = 1708, strike = 58.6, iz_whiff = 27.6, whiff = 37.7, chase = 31.2), cutter = list(velo_l = 86, velo_r = 86.8, zone = 46.9, spin = 2387, strike = 64.7, iz_whiff = 19.8, whiff = 30.4, chase = 28.9) ) sec_extension_benchmark <- function(pt) { if (pt %in% c("Fastball","Four-Seam","Four Seam","Fourseam","FourSeamFastBall","Sinker","Two-Seam","2-Seam")) return(5.83) if (pt %in% c("Slider","Sweeper")) return(5.54) if (pt %in% c("Curveball","Knuckle Curve")) return(5.47) if (pt %in% c("ChangeUp","Splitter")) return(5.98) NA_real_ } get_gradient_color <- function(value, benchmark, metric_type = "higher_better", range_pct = 0.25) { if (is.na(value) || is.na(benchmark) || is.null(value) || is.null(benchmark)) return("#FFFFFF") if (is.nan(value) || is.infinite(value)) return("#FFFFFF") pal <- scales::gradient_n_pal(c("#E1463E", "white", "#00840D")) range_val <- benchmark * range_pct min_val <- benchmark - range_val max_val <- benchmark + range_val normalized <- if (metric_type == "higher_better") { (value - min_val) / (max_val - min_val) } else { (max_val - value) / (max_val - min_val) } normalized <- pmax(0, pmin(1, normalized)) pal(normalized) } advanced_normalize_columns <- function(df) { if ("RelSpeed" %in% names(df)) df <- df %>% filter(!is.na(RelSpeed)) if (!"WhiffIndicator" %in% names(df)) { df$WhiffIndicator <- ifelse(df$PitchCall == "StrikeSwinging", 1, 0) } if (!"SwingIndicator" %in% names(df)) { df$SwingIndicator <- ifelse(df$PitchCall %in% c("StrikeSwinging","FoulBallNotFieldable","FoulBall","InPlay"), 1, 0) } if (!"StrikeZoneIndicator" %in% names(df)) { df$StrikeZoneIndicator <- ifelse( df$PlateLocSide >= -0.83 & df$PlateLocSide <= 0.83 & df$PlateLocHeight >= 1.5 & df$PlateLocHeight <= 3.38, 1, 0 ) } if (!"WalkIndicator" %in% names(df)) { df$WalkIndicator <- ifelse(df$KorBB == "Walk", 1, 0) } if (!"HBPIndicator" %in% names(df)) { df$HBPIndicator <- ifelse(df$PitchCall == "HitByPitch", 1, 0) } df } normalize_columns <- advanced_normalize_columns process_pitcher_indicators <- function(df) { # Ensure basic columns exist df <- df %>% mutate( # Outs on play - you may need to adjust based on your data structure OutsOnPlay = case_when( PlayResult == "Out" ~ 1, PlayResult == "FieldersChoice" ~ 1, PlayResult == "Sacrifice" ~ 1, PlayResult == "SacrificeFly" ~ 1, KorBB == "Strikeout" ~ 1, # Double play - adjust if you have this info TRUE ~ 0 ), # Runs scored - you may already have this column RunsScored = if ("RunsScored" %in% names(df)) RunsScored else 0, # Hit indicator is_hit = as.integer(PlayResult %in% c("Single", "Double", "Triple", "HomeRun")), # On base indicator (hits + walks + HBP) on_base = as.integer( PlayResult %in% c("Single", "Double", "Triple", "HomeRun") | KorBB == "Walk" | PitchCall == "HitByPitch" ), # Total bases for SLG total_bases = case_when( PlayResult == "Single" ~ 1, PlayResult == "Double" ~ 2, PlayResult == "Triple" ~ 3, PlayResult == "HomeRun" ~ 4, TRUE ~ 0 ), # SLG is total_bases per AB - we'll calculate per PA for simplicity # You may want to exclude walks/HBP from denominator for true SLG slg = total_bases, # Strikeout indicator (per PA) is_k = as.integer(KorBB == "Strikeout"), # Walk indicator (per PA) is_walk = as.integer(KorBB == "Walk"), # CSW (Called Strike + Whiff) indicator is_csw = as.integer(PitchCall %in% c("StrikeCalled", "StrikeSwinging")), # Chase indicator (swing outside zone) chase = as.integer( PitchCall %in% c("StrikeSwinging", "FoulBall", "FoulBallNotFieldable", "FoulBallFieldable", "InPlay") & (PlateLocSide < -0.83 | PlateLocSide > 0.83 | PlateLocHeight < 1.5 | PlateLocHeight > 3.38) ), # In zone indicator in_zone = as.integer( PlateLocSide >= -0.83 & PlateLocSide <= 0.83 & PlateLocHeight >= 1.5 & PlateLocHeight <= 3.38 ), # Whiff indicator is_whiff = as.integer(PitchCall == "StrikeSwinging"), # Put away indicator (strikeout with 2 strikes) is_put_away = as.integer(KorBB == "Strikeout" & Strikes == 2), # PA indicator for rate calculations PAindicator = as.integer( !is.na(KorBB) | PlayResult %in% c("Single", "Double", "Triple", "HomeRun", "Out", "FieldersChoice", "Error", "Sacrifice", "SacrificeFly") | PitchCall == "HitByPitch" ) ) df } create_advanced_pitcher_summary <- function(data, player_name) { data <- normalize_columns(data) data <- process_pitcher_indicators(data) pitcher_data <- data %>% dplyr::filter(Pitcher == player_name) # Calculate PA-level stats pa_data <- pitcher_data %>% filter(PAindicator == 1) %>% group_by(Inning, Batter, PAofInning) %>% slice_tail(n = 1) %>% # Get final pitch of each PA ungroup() summary_stats <- pitcher_data %>% dplyr::summarise( IP = { total_outs <- sum(OutsOnPlay, na.rm = TRUE) full_innings <- floor(total_outs / 3) remainder_outs <- total_outs %% 3 full_innings + remainder_outs / 10 }, R = sum(RunsScored, na.rm = TRUE), BF = n_distinct(paste(Inning, Batter, PAofInning)), K = sum(KorBB == "Strikeout", na.rm = TRUE), BB = sum(WalkIndicator, na.rm = TRUE), H = sum(PlayResult %in% c("Single","Double","Triple","HomeRun"), na.rm = TRUE), `Strike%` = round(100 * mean(is_csw | PitchCall %in% c("FoulBall","FoulBallNotFieldable","InPlay"), na.rm = TRUE), 1), `CSW%` = round(100 * mean(is_csw, na.rm = TRUE), 1), `Whiff%` = ifelse(sum(SwingIndicator, na.rm = TRUE) > 0, round(100 * sum(is_whiff, na.rm = TRUE) / sum(SwingIndicator, na.rm = TRUE), 1), 0), `Zone%` = round(100 * mean(in_zone, na.rm = TRUE), 1), .groups = "drop" ) headers <- names(summary_stats) vals <- as.numeric(summary_stats[1, ]) color_for <- function(h, v) { if (h == "Strike%") return(get_gradient_color(v, sec_averages$overall$strike_rate, "higher_better", 0.15)) if (h == "Whiff%") return(get_gradient_color(v, sec_averages$overall$miss_rate, "higher_better", 0.25)) if (h == "Zone%") return(get_gradient_color(v, sec_averages$overall$zone_rate, "higher_better", 0.20)) "#FFFFFF" } colors <- mapply(color_for, headers, vals, USE.NAMES = FALSE) list(stats = summary_stats, colors = colors) } create_advanced_pitch_characteristics <- function(data, player_name) { data <- normalize_columns(data) pitcher_data <- data %>% dplyr::filter(Pitcher == player_name, !is.na(TaggedPitchType), TaggedPitchType != "Other") pitcher_hand <- if (nrow(pitcher_data) > 0) { h <- pitcher_data$PitcherThrows[1]; if (is.na(h)) "Right" else h } else "Right" if (!is.null(stuffplus_model) && nrow(pitcher_data) > 0) { # If reference data exists, use the league-standardization path if (!is.null(reference_data_for_stuff)) { combined <- dplyr::bind_rows( reference_data_for_stuff$spring, reference_data_for_stuff$p5, reference_data_for_stuff$sbc ) pitcher_data <- standardize_stuffplus_to_league(pitcher_data, combined) } else { message("WARNING: No reference data available. Using local standardization.") pitcher_data$raw_stuff <- tryCatch({ df_processed <- bake(stuffplus_recipe, new_data = pitcher_data) df_matrix <- as.matrix(df_processed) pitcher_data$raw_stuff <- predict(stuffplus_model, df_matrix) }, error = function(e) { message("Stuff+ prediction error: ", e$message) rep(NA_real_, nrow(pitcher_data)) }) finite_raw <- pitcher_data$raw_stuff[is.finite(pitcher_data$raw_stuff)] if (length(finite_raw) == 0) { pitcher_data$stuff_plus <- NA_real_ } else { q <- quantile(finite_raw, probs = c(0.01, 0.99), na.rm = TRUE) lo <- q[1]; hi <- q[2] pitcher_data$raw_stuff_winz <- pmin(pmax(pitcher_data$raw_stuff, lo), hi) raw_mean <- mean(pitcher_data$raw_stuff_winz, na.rm = TRUE) raw_sd <- sd(pitcher_data$raw_stuff_winz, na.rm = TRUE) if (!is.finite(raw_sd) || raw_sd == 0) raw_sd <- 1e-8 pitcher_data$stuff_plus <- ((pitcher_data$raw_stuff_winz - raw_mean) / raw_sd) * 10 + 100 } } } else { if (is.null(stuffplus_model)) message("Stuff+ model not loaded") if (nrow(pitcher_data) == 0) message("No pitcher data for Stuff+ prediction") pitcher_data$raw_stuff <- NA_real_ pitcher_data$stuff_plus <- NA_real_ } # Handle case where pitcher_data is empty after filtering if (nrow(pitcher_data) == 0) { empty_df <- data.frame( Pitch = character(0), Count = integer(0), `Usage%` = numeric(0), `Avg Velo` = numeric(0), `Max Velo` = numeric(0), `Avg Spin` = numeric(0), `Avg IVB` = numeric(0), `Avg HB` = numeric(0), `VAA` = numeric(0), `HAA` = numeric(0), `hRel` = numeric(0), `vRel` = numeric(0), `Ext` = numeric(0), `Strike%` = numeric(0), `Whiff%` = numeric(0), `Zone%` = numeric(0), `Stuff+` = numeric(0), check.names = FALSE, stringsAsFactors = FALSE ) return(list(stats = empty_df, colors = matrix("#FFFFFF", nrow = 0, ncol = 17))) } pitch_stats <- pitcher_data %>% dplyr::group_by(Pitch = TaggedPitchType) %>% dplyr::summarise( Count = dplyr::n(), `Usage%` = round(100 * dplyr::n() / nrow(pitcher_data), 1), `Avg Velo` = round(mean(RelSpeed, na.rm = TRUE), 1), `Max Velo` = round(max(RelSpeed, na.rm = TRUE), 1), `Avg Spin` = round(mean(SpinRate, na.rm = TRUE), 0), `Avg IVB` = round(mean(InducedVertBreak, na.rm = TRUE), 1), `Avg HB` = round(mean(HorzBreak, na.rm = TRUE), 1), `VAA` = round(mean(VertApprAngle, na.rm = TRUE), 1), `HAA` = round(mean(HorzApprAngle, na.rm = TRUE), 1), `hRel` = round(mean(RelSide, na.rm = TRUE), 1), `vRel` = round(mean(RelHeight, na.rm = TRUE), 1), `Ext` = round(mean(Extension, na.rm = TRUE), 2), `Strike%` = round(100 * sum(!PitchCall %in% c("BallCalled","BallinDirt","BallIntentional")) / dplyr::n(), 1), `Whiff%` = ifelse(sum(SwingIndicator, na.rm = TRUE) > 0, round(100 * sum(WhiffIndicator, na.rm = TRUE) / sum(SwingIndicator, na.rm = TRUE), 1), 0), `Zone%` = round(100 * sum(StrikeZoneIndicator, na.rm = TRUE) / dplyr::n(), 1), `Stuff+` = round(mean(stuff_plus, na.rm = TRUE), 1), .groups = "drop" ) %>% dplyr::arrange(dplyr::desc(`Usage%`)) # CRITICAL: Remove duplicate columns FIRST before creating color matrix dup_mask <- duplicated(names(pitch_stats)) if (any(dup_mask)) { message("Removing ", sum(dup_mask), " duplicate columns from pitch_stats") pitch_stats <- pitch_stats[, !dup_mask, drop = FALSE] } # NOW get final dimensions and create color matrix num_rows <- nrow(pitch_stats) num_cols <- ncol(pitch_stats) color_matrix <- matrix("#FFFFFF", nrow = max(1, num_rows), ncol = max(1, num_cols)) if (num_rows == 0) { return(list(stats = pitch_stats, colors = color_matrix)) } # Get column indices AFTER deduplication col_idx <- function(nm) { idx <- match(nm, names(pitch_stats)) if (is.na(idx)) return(NA_integer_) idx } c_avg_velo <- col_idx("Avg Velo") c_max_velo <- col_idx("Max Velo") c_spin <- col_idx("Avg Spin") c_strk <- col_idx("Strike%") c_whiff <- col_idx("Whiff%") c_zone <- col_idx("Zone%") c_stuff <- col_idx("Stuff+") c_ext <- col_idx("Ext") # Helper to safely check index validity valid_idx <- function(idx) { !is.na(idx) && is.numeric(idx) && length(idx) == 1 && idx >= 1 && idx <= num_cols } for (i in seq_len(num_rows)) { pt <- pitch_stats$Pitch[i] sec_ref <- if (pt %in% c("Fastball","Four-Seam", "FourSeamFastBall","Sinker","Two-Seam","2-Seam")) { sec_averages$fb_sinker } else if (pt %in% c("Cutter")) { sec_averages$cutter } else if (pt %in% c("Slider","Sweeper")) { sec_averages$slider } else if (pt %in% c("Curveball","Knuckle Curve")) { sec_averages$curveball } else if (pt %in% c("ChangeUp","Splitter")) { sec_averages$changeup } else NULL if (!is.null(sec_ref)) { velo_bench <- NA_real_ if (tolower(pt) %in% c("fastball","four-seam","FourSeamFastBall","four seam","fourseam","sinker","two-seam","2-seam")) { velo_bench <- if (identical(pitcher_hand, "Left")) sec_averages$overall$fb_velo_l else sec_averages$overall$fb_velo_r } else { vb_l <- sec_ref$velo_l %||% NA_real_ vb_r <- sec_ref$velo_r %||% NA_real_ velo_bench <- if (identical(pitcher_hand, "Left")) vb_l else vb_r } if (!is.na(velo_bench) && valid_idx(c_avg_velo)) color_matrix[i, c_avg_velo] <- get_gradient_color(pitch_stats$`Avg Velo`[i], velo_bench, "higher_better", 0.05) if (!is.na(velo_bench) && valid_idx(c_max_velo)) color_matrix[i, c_max_velo] <- get_gradient_color(pitch_stats$`Max Velo`[i], velo_bench, "higher_better", 0.05) } if (!is.null(sec_ref) && valid_idx(c_spin) && !is.null(sec_ref$spin)) color_matrix[i, c_spin] <- get_gradient_color(pitch_stats$`Avg Spin`[i], sec_ref$spin, "higher_better", 0.20) if (!is.null(sec_ref) && valid_idx(c_strk) && !is.null(sec_ref$strike)) color_matrix[i, c_strk] <- get_gradient_color(pitch_stats$`Strike%`[i], sec_ref$strike, "higher_better", 0.15) if (!is.null(sec_ref) && valid_idx(c_whiff) && !is.null(sec_ref$whiff)) color_matrix[i, c_whiff] <- get_gradient_color(pitch_stats$`Whiff%`[i], sec_ref$whiff, "higher_better", 0.30) if (!is.null(sec_ref) && valid_idx(c_zone) && !is.null(sec_ref$zone)) color_matrix[i, c_zone] <- get_gradient_color(pitch_stats$`Zone%`[i], sec_ref$zone, "higher_better", 0.20) if (valid_idx(c_stuff)) { val <- pitch_stats$`Stuff+`[i] if (is.finite(val)) color_matrix[i, c_stuff] <- get_gradient_color(val, 100, "higher_better", 0.20) } if (valid_idx(c_ext)) { ext_bench <- sec_extension_benchmark(pt) if (is.finite(ext_bench)) { color_matrix[i, c_ext] <- get_gradient_color(pitch_stats$`Ext`[i], ext_bench, "higher_better", 0.08) } } } list(stats = pitch_stats, colors = color_matrix) } create_relside_height_plot <- function(data, player_name, pitch_colors) { data <- normalize_columns(data) df <- data %>% dplyr::filter( Pitcher == player_name, !is.na(RelSide), !is.na(RelHeight), !is.na(TaggedPitchType), TaggedPitchType != "Other" ) title_text <- if (!is.null(player_name) && nzchar(player_name)) { paste("Raw Release Points (Pitcher View):", player_name) } else { "Release Points" } if (nrow(df) == 0) { # Empty data, but keep axes / labels / mound so the graphic is stable avg_release <- df } else { avg_release <- df %>% dplyr::group_by(TaggedPitchType) %>% dplyr::summarise( RelSide = mean(RelSide, na.rm = TRUE), RelHeight = mean(RelHeight, na.rm = TRUE), .groups = "drop" ) } ggplot() + # Individual pitches (smaller semi-transparent dots) geom_point( data = df, aes(RelSide, RelHeight, fill = TaggedPitchType), size = 3, shape = 21, color = "black", alpha = 0.8, stroke = 0.2, na.rm = TRUE ) + # Averages per pitch type (larger dots) geom_point( data = avg_release, aes(RelSide, RelHeight, fill = TaggedPitchType), size = 5, shape = 21, color = "black", stroke = 0.25, alpha = 1, na.rm = TRUE ) + xlim(-5, 5) + ylim(0, 8) + annotate("text", x = -5, y = 8, label = "← 1B", size = 3, hjust = 0) + annotate("text", x = 5, y = 8, label = "3B →", size = 3, hjust = 1) + geom_rect( aes(xmin = -5, xmax = 5, ymin = 0, ymax = 0.83), fill = "#632b11", inherit.aes = FALSE ) + geom_rect( aes(xmin = -0.5, xmax = 0.5, ymin = 0.8, ymax = 0.95), fill = "white", color = "black", linewidth = 0.4, inherit.aes = FALSE ) + scale_fill_manual(values = pitch_colors, name = "Pitch Type") + labs( title = title_text, x = "Release Side (ft)", y = "Release Height (ft)" ) + theme_minimal(base_size = 11) + theme( plot.title = element_text(size = 12, face = "bold", hjust = 0.5), legend.position = "none", strip.text = element_text(size = 9, face = "bold"), strip.placement = "outside" ) } create_movement_plot <- create_pitcher_movement_plot create_velocity_distribution_plot <- function(data, player_name, pitch_colors) { data <- normalize_columns(data) pitcher_data <- data %>% filter(Pitcher == player_name, !is.na(TaggedPitchType), TaggedPitchType != "Other", !is.na(RelSpeed)) if (nrow(pitcher_data) == 0) { return(ggplot() + theme_void() + ggtitle("Velocity Distribution") + theme(plot.title = element_text(size = 14, face = "bold", hjust = 0.5))) } pitch_order <- pitcher_data %>% count(TaggedPitchType, sort = TRUE) %>% pull(TaggedPitchType) pitcher_data <- pitcher_data %>% mutate(TaggedPitchType = factor(TaggedPitchType, levels = pitch_order)) pitch_means <- pitcher_data %>% group_by(TaggedPitchType) %>% summarise(mean_velo = mean(RelSpeed, na.rm = TRUE), .groups = "drop") ggplot(pitcher_data, aes(x = RelSpeed, fill = TaggedPitchType)) + geom_density(alpha = 0.7, color = "black", size = 0.3) + geom_vline(data = pitch_means, aes(xintercept = mean_velo), linetype = "dashed", size = 0.8) + facet_wrap(~ TaggedPitchType, ncol = 1, strip.position = "left") + scale_fill_manual(values = pitch_colors) + labs(title = "Velocity Distribution by Pitch Type", x = "Velocity (mph)", y = "") + theme_minimal(base_size = 11) + theme( plot.title = element_text(size = 14, face = "bold", hjust = 0.5), legend.position = "none", strip.text.y.left = element_text(angle = 0, hjust = 1, face = "bold", size = 10), strip.placement = "outside", panel.grid.major.y = element_blank(), panel.grid.minor = element_blank(), axis.text.y = element_blank(), axis.ticks.y = element_blank() ) } create_release_point_plot <- function(data, player_name, pitch_colors) { data <- normalize_columns(data) pitcher_data <- data %>% filter(Pitcher == player_name, !is.na(TaggedPitchType), TaggedPitchType != "Other", !is.na(RelSide), !is.na(RelHeight)) if (nrow(pitcher_data) == 0) { return(ggplot() + theme_void() + ggtitle("Release Points") + theme(plot.title = element_text(size = 14, face = "bold", hjust = 0.5))) } avg_release <- pitcher_data %>% group_by(TaggedPitchType) %>% summarise( avg_rel_side = mean(RelSide, na.rm = TRUE), avg_rel_height = mean(RelHeight, na.rm = TRUE), .groups = "drop" ) ggplot(pitcher_data, aes(x = RelSide, y = RelHeight, fill = TaggedPitchType)) + geom_point(alpha = 0.6, shape = 21, color = "black", stroke = 0.4, size = 3) + geom_point(data = avg_release, aes(x = avg_rel_side, y = avg_rel_height, fill = TaggedPitchType), shape = 21, color = "black", stroke = 1, size = 6, alpha = 1) + scale_fill_manual(values = pitch_colors, name = "Pitch Type") + labs( title = "Release Points", x = "Horizontal Release (ft)", y = "Vertical Release (ft)" ) + theme_minimal(base_size = 11) + theme( plot.title = element_text(hjust = 0.5, size = 14, face = "bold"), legend.position = "none", panel.grid.minor = element_blank() ) } create_count_usage_plot <- function(data, pitcher_name, pitch_colors) { data <- normalize_columns(data) df <- data %>% dplyr::filter(Pitcher == pitcher_name, !is.na(TaggedPitchType), TaggedPitchType != "Other") if (nrow(df) == 0) { return( ggplot() + theme_void() + ggtitle("Count Usage") + theme(plot.title = element_text(size = 14, face = "bold", hjust = 0.5)) ) } plot_df <- df %>% dplyr::mutate( count = dplyr::case_when( Balls == 0 & Strikes == 0 ~ "0-0", Strikes == 2 ~ "2 Strikes", Balls > Strikes ~ "Behind", Strikes > Balls ~ "Ahead", TRUE ~ NA_character_ ), BatterSide = ifelse(BatterSide == "Right", "Vs Right", "Vs Left") ) %>% dplyr::filter(!is.na(count)) %>% dplyr::group_by(count, BatterSide) %>% dplyr::mutate(total_count = dplyr::n()) %>% dplyr::group_by(count, TaggedPitchType, BatterSide) %>% dplyr::summarise( n = dplyr::n(), total = dplyr::first(total_count), .groups = "drop" ) %>% dplyr::mutate( percentage = ifelse(total > 0, 100 * n / total, 0), pct_label = ifelse(percentage >= 2, paste0(round(percentage), "%"), "") ) %>% dplyr::filter(total > 0) plot_df <- plot_df %>% group_by(count, BatterSide) %>% arrange(TaggedPitchType) %>% mutate( ymax = cumsum(percentage), ymin = ymax - percentage, label_pos = (ymin + ymax) / 2 ) %>% ungroup() plot_df$count <- factor(plot_df$count, levels = c("0-0", "Ahead", "Behind", "2 Strikes")) plot_df$BatterSide <- factor(plot_df$BatterSide, levels = c("Vs Left", "Vs Right")) ggplot(plot_df, aes(x = 1, y = percentage, fill = TaggedPitchType)) + geom_bar(width = 1, stat = "identity", color = "white") + coord_polar(theta = "y", start = 0) + facet_grid(BatterSide ~ count, labeller = label_value, drop = FALSE) + ggtitle("Count Usage") + scale_fill_manual(values = pitch_colors, na.translate = FALSE) + theme_minimal() + theme( plot.title = element_text(hjust = 0.5, size = 14, face = "bold"), axis.text = element_blank(), axis.title = element_blank(), axis.ticks = element_blank(), strip.text = element_text(size = 12), legend.position = "none" ) } .add_zones <- function() { rule_xmin <- -0.83; rule_xmax <- 0.83 rule_ymin <- 1.50; rule_ymax <- 3.38 two_xmin <- -0.95; two_xmax <- 0.95 two_ymin <- 1.40; two_ymax <- 3.50 list( annotate("rect", xmin = rule_xmin, xmax = rule_xmax, ymin = rule_ymin, ymax = rule_ymax, fill = NA, color = "black", size = 0.6, linetype = "solid"), annotate("rect", xmin = two_xmin, xmax = two_xmax, ymin = two_ymin, ymax = two_ymax, fill = NA, color = "grey30", size = 0.6, linetype = "dashed") ) } create_location_by_result_plot <- function(data, player_name, batter_side, pitch_colors) { data <- normalize_columns(data) # Fixed facet levels result_levels <- c( "Whiffs", "Balls Called", "Strikes Called", "Hard Hits (95+)", "2 Strikes" ) pitcher_data <- data %>% dplyr::filter( Pitcher == player_name, BatterSide == batter_side, !is.na(TaggedPitchType), TaggedPitchType != "Other", !is.na(PlateLocSide), !is.na(PlateLocHeight) ) %>% dplyr::mutate( ResultType = dplyr::case_when( PitchCall == "StrikeSwinging" ~ "Whiffs", PitchCall %in% c("BallCalled", "BallinDirt") ~ "Balls Called", PitchCall == "StrikeCalled" ~ "Strikes Called", PitchCall == "InPlay" & !is.na(ExitSpeed) & ExitSpeed >= 95 ~ "Hard Hits (95+)", Strikes == 2 ~ "2 Strikes", TRUE ~ NA_character_ ) ) %>% dplyr::filter(!is.na(ResultType)) if (nrow(pitcher_data) == 0) { # Dummy data frame so we still draw 5 facets + zones plot_df <- data.frame( PlateLocSide = NA_real_, PlateLocHeight = NA_real_, TaggedPitchType = factor(NA_character_), ResultType = factor(result_levels, levels = result_levels) ) } else { pitcher_data$ResultType <- factor(pitcher_data$ResultType, levels = result_levels) plot_df <- pitcher_data } ggplot(plot_df, aes(x = PlateLocSide, y = PlateLocHeight, fill = TaggedPitchType)) + geom_point( alpha = 0.8, shape = 21, color = "black", stroke = 0.5, size = 3, na.rm = TRUE ) + facet_wrap( ~ ResultType, ncol = 5, labeller = labeller(ResultType = label_value), drop = FALSE # <- keep empty facets ) + .add_zones() + # Home plate + catcher box geom_segment(aes(x = -0.708, y = 0.15, xend = 0.708, yend = 0.15), color = "black", size = 0.5, inherit.aes = FALSE) + geom_segment(aes(x = -0.708, y = 0.30, xend = -0.708, yend = 0.15), color = "black", size = 0.5, inherit.aes = FALSE) + geom_segment(aes(x = 0.708, y = 0.30, xend = 0.708, yend = 0.15), color = "black", size = 0.5, inherit.aes = FALSE) + geom_segment(aes(x = -0.708, y = 0.30, xend = 0.000, yend = 0.50), color = "black", size = 0.5, inherit.aes = FALSE) + geom_segment(aes(x = 0.708, y = 0.30, xend = 0.000, yend = 0.50), color = "black", size = 0.5, inherit.aes = FALSE) + scale_fill_manual(values = pitch_colors, na.translate = FALSE) + scale_x_continuous(limits = c(-2, 2)) + scale_y_continuous(limits = c(0, 4.5)) + coord_fixed() + ggtitle(paste0("Pitch Locations vs ", batter_side, "HB")) + theme_void() + theme( plot.title = element_text(size = 12, face = "bold", hjust = 0.5), legend.position = "none", strip.text = element_text(size = 9, face = "bold"), strip.placement = "outside" ) } create_location_by_side_plot <- function(data, player_name, batter_side, pitch_colors) { create_location_by_result_plot(data, player_name, batter_side, pitch_colors) } create_location_by_side_plot <- function(data, player_name, batter_side, pitch_colors) { create_location_by_result_plot(data, player_name, batter_side, pitch_colors) } get_team_logo_path <- function(team_name, logo_dir = "logos") { if (is.null(team_name) || is.na(team_name) || !nzchar(team_name)) return(NULL) # Normalize team name for file matching team_clean <- tolower(gsub("[^a-zA-Z0-9]", "_", team_name)) team_nospace <- tolower(gsub("[^a-zA-Z0-9]", "", team_name)) # Check multiple possible paths possible_paths <- c( file.path(logo_dir, paste0(team_clean, ".png")), file.path(logo_dir, paste0(team_nospace, ".png")), file.path(logo_dir, paste0(team_name, ".png")), file.path(logo_dir, paste0(tolower(team_name), ".png")), # Common abbreviations file.path(logo_dir, "coastal_carolina.png"), file.path(logo_dir, "ccu.png") ) for (path in possible_paths) { if (file.exists(path)) { return(path) } } return(NULL) } # Function to add logo to the report add_team_logo <- function(logo_path, x, y, width, height) { if (is.null(logo_path) || !file.exists(logo_path)) { return(invisible(NULL)) } tryCatch({ # Read the PNG image img <- png::readPNG(logo_path) # Create a raster grob and draw it grid::grid.raster( img, x = x, y = y, width = width, height = height, just = c("center", "center") ) }, error = function(e) { message("Could not load logo: ", e$message) invisible(NULL) }) } create_advanced_pitcher_pdf <- function(game_df, pitcher_name, output_file, logo_dir = "logos") { if (length(dev.list()) > 0) try(dev.off(), silent = TRUE) pitch_colors <- c( "Fastball" = "#3465cb", "Four-Seam" = "#3465cb", "FourSeamFastBall" = "#3465cb", "4-Seam Fastball" = "#3465cb", "FF" = "#3465cb", "Sinker" = "#e5e501", "TwoSeamFastBall" = "#e5e501", "Two-Seam" = "#e5e501", "2-Seam Fastball" = "#e5e501", "SI" = "#e5e501", "Slider" = "#65aa02", "SL" = "#65aa02", "Sweeper" = "#dc4476", "SW" = "#dc4476", "Curveball" = "#d73813", "CB" = "#d73813", "Knuckle Curve" = "#d73813", "KC" = "#d73813", "ChangeUp" = "#980099", "Changeup" = "#980099", "CH" = "#980099", "Splitter" = "#23a999", "FS" = "#23a999", "SP" = "#23a999", "Cutter" = "#ff9903", "FC" = "#ff9903", "Slurve" = "#9370DB", "Other" = "gray50" ) .text_on_fill <- function(hex) { if (is.na(hex) || !nzchar(hex)) return("black") tryCatch({ rgb <- grDevices::col2rgb(hex) / 255 L <- 0.2126*rgb[1] + 0.7152*rgb[2] + 0.0722*rgb[3] ifelse(L < 0.5, "white", "black") }, error = function(e) "black") } get_cell_value <- function(df, colname, row_idx) { if (is.null(df) || !is.data.frame(df)) return(NA) if (is.null(colname) || !nzchar(colname)) return(NA) if (!(colname %in% names(df))) return(NA) if (row_idx < 1 || row_idx > nrow(df)) return(NA) tryCatch(df[[colname]][row_idx], error = function(e) NA) } pitcher_df <- dplyr::filter(game_df, Pitcher == pitcher_name) if (nrow(pitcher_df) == 0) { pdf(output_file, width = 11, height = 14) grid::grid.newpage() grid::grid.text(paste("No data available for", pitcher_name), gp = grid::gpar(fontsize = 16, fontface = "bold")) dev.off() return(output_file) } # Get pitcher's team for logo pitcher_team <- NULL if ("PitcherTeam" %in% names(pitcher_df)) { pitcher_team <- pitcher_df$PitcherTeam[1] } else if ("Team" %in% names(pitcher_df)) { pitcher_team <- pitcher_df$Team[1] } else if ("HomeTeam" %in% names(pitcher_df)) { # Try to determine team from context pitcher_team <- pitcher_df$HomeTeam[1] } # Get logo path logo_path <- get_team_logo_path(pitcher_team, logo_dir) game_day <- tryCatch(parse_game_day(pitcher_df), error = function(e) Sys.Date()) # Get summary stats summary_result <- tryCatch( create_advanced_pitcher_summary(pitcher_df, pitcher_name), error = function(e) { message("Error in summary: ", e$message) list(stats = data.frame(IP=0, R=0, BF=0, K=0, BB=0, H=0, check.names=FALSE), colors = rep("#FFFFFF", 6)) } ) summary_stats <- summary_result$stats summary_colors <- summary_result$colors # Get pitch characteristics pitch_result <- tryCatch( create_advanced_pitch_characteristics(pitcher_df, pitcher_name), error = function(e) { message("Error in pitch characteristics: ", e$message) list( stats = data.frame(Pitch = "-", Count = 0, `Usage%` = NA_real_, check.names = FALSE), colors = matrix("#FFFFFF", nrow = 1, ncol = 3) ) } ) pitch_char <- pitch_result$stats pitch_colors_matrix <- pitch_result$colors # ===== ADD THIS DEBUGGING BLOCK ===== message("========== PITCH CHARACTERISTICS DEBUG ==========") message("pitch_char class: ", class(pitch_char)) message("pitch_char dimensions: ", nrow(pitch_char), " rows x ", ncol(pitch_char), " cols") message("pitch_char column names: ", paste(names(pitch_char), collapse = ", ")) if (nrow(pitch_char) > 0) { message("First row Pitch value: ", pitch_char$Pitch[1]) message("First row data:") print(pitch_char[1, , drop = FALSE]) } else { message("WARNING: pitch_char has 0 rows!") } message("pitch_colors_matrix dimensions: ", nrow(pitch_colors_matrix), " x ", ncol(pitch_colors_matrix)) message("==================================================") # ===== END DEBUG BLOCK ===== # Handle empty pitch_char if (is.null(pitch_char) || nrow(pitch_char) == 0) { pitch_char <- data.frame( Pitch = "-", Count = 0, `Usage%` = NA_real_, `Avg Velo` = NA_real_, `Max Velo` = NA_real_, `Avg Spin` = NA_real_, `Avg IVB` = NA_real_, `Avg HB` = NA_real_, `VAA` = NA_real_, `HAA` = NA_real_, `hRel` = NA_real_, `vRel` = NA_real_, `Ext` = NA_real_, `Strike%` = NA_real_, `Whiff%` = NA_real_, `Zone%` = NA_real_, `Stuff+` = NA_real_, check.names = FALSE ) pitch_colors_matrix <- matrix("#FFFFFF", nrow = 1, ncol = ncol(pitch_char)) } # Limit rows max_rows_to_show <- min(nrow(pitch_char), 9) if (nrow(pitch_char) > max_rows_to_show) { pitch_char <- pitch_char[1:max_rows_to_show, , drop = FALSE] } num_rows <- nrow(pitch_char) num_cols <- ncol(pitch_char) # Rebuild color matrix new_color_matrix <- matrix("#FFFFFF", nrow = num_rows, ncol = num_cols) if (!is.null(pitch_colors_matrix) && is.matrix(pitch_colors_matrix)) { rows_to_copy <- min(nrow(pitch_colors_matrix), num_rows) cols_to_copy <- min(ncol(pitch_colors_matrix), num_cols) if (rows_to_copy > 0 && cols_to_copy > 0) { new_color_matrix[1:rows_to_copy, 1:cols_to_copy] <- pitch_colors_matrix[1:rows_to_copy, 1:cols_to_copy] } } pitch_colors_matrix <- new_color_matrix # Create plots movement_plot <- tryCatch( create_movement_plot(pitcher_df, pitcher_name, pitch_colors), error = function(e) ggplot2::ggplot() + ggplot2::theme_void() + ggplot2::ggtitle("Movement Plot Error") ) velo_plot <- tryCatch( create_velocity_distribution_plot(pitcher_df, pitcher_name, pitch_colors), error = function(e) ggplot2::ggplot() + ggplot2::theme_void() + ggplot2::ggtitle("Velocity Plot Error") ) location_lhb <- tryCatch( create_location_by_result_plot(pitcher_df, pitcher_name, "Left", pitch_colors), error = function(e) ggplot2::ggplot() + ggplot2::theme_void() + ggplot2::ggtitle("LHB Location Error") ) location_rhb <- tryCatch( create_location_by_result_plot(pitcher_df, pitcher_name, "Right", pitch_colors), error = function(e) ggplot2::ggplot() + ggplot2::theme_void() + ggplot2::ggtitle("RHB Location Error") ) count_plot <- tryCatch( create_count_usage_plot(pitcher_df, pitcher_name, pitch_colors), error = function(e) ggplot2::ggplot() + ggplot2::theme_void() + ggplot2::ggtitle("Count Usage Error") ) relside_height_plot <- tryCatch({ create_relside_height_plot( data = pitcher_df, player_name = pitcher_name, pitch_colors = pitch_colors ) }, error = function(e) { message("RelSide/Height plot error: ", e$message) ggplot2::ggplot() + ggplot2::theme_void() + ggplot2::ggtitle("RelSide/Height Error") }) # Start PDF pdf(output_file, width = 11, height = 14) on.exit(try(dev.off(), silent = TRUE), add = TRUE) grid::grid.newpage() header_y_top <- 0.98 charts_y_top <- 0.85 charts_height <- 0.30 charts_y_bottom <- charts_y_top - charts_height # Row 2 (Count + Release) count_y_top <- charts_y_bottom - 0.02 count_height <- 0.18 # keep count usage height release_height <- 0.24 # make release plot TALLER than count row2_bottom <- count_y_top - max(count_height, release_height) count_y_bottom <- count_y_top - count_height base_loc_top <- 0.30 table_margin <- 0.03 min_row_h <- 0.0125 max_row_h <- 0.0180 # Pitch characteristics table starts just below row 2 y_top_char_orig <- row2_bottom - 0.011 rows_including_header <- num_rows + 1 available_for_table_orig <- y_top_char_orig - (base_loc_top + table_margin) row_h_char <- min(max_row_h, max(min_row_h, available_for_table_orig / max(1, rows_including_header))) y_loc_top <- y_top_char_orig - rows_including_header * row_h_char - (table_margin * 0.5) table_lower_offset <- 0.025 y_top_char <- y_top_char_orig - table_lower_offset available_for_table <- y_top_char - (base_loc_top + table_margin) row_h_char <- min(max_row_h, max(min_row_h, available_for_table / max(1, rows_including_header))) # ===== HEADER WITH LOGO ===== grid::pushViewport(grid::viewport(x = 0.5, y = header_y_top, width = 1, height = 0.06, just = c("center","top"))) # Add team logo on the left if available if (!is.null(logo_path) && file.exists(logo_path)) { add_team_logo(logo_path, x = 0.08, y = 0.5, width = 0.06, height = grid::unit(0.8, "npc")) } # Title in center grid::grid.text(paste(pitcher_name, "- Advanced Pitcher Report"), x = 0.5, y = 0.5, gp = grid::gpar(fontface = "bold", cex = 1.8, col = "red")) # Add team logo on the right if available (mirror) if (!is.null(logo_path) && file.exists(logo_path)) { add_team_logo(logo_path, x = 0.92, y = 0.5, width = 0.06, height = grid::unit(0.8, "npc")) } grid::popViewport() # Summary section grid::grid.text("Summary", x = 0.5, y = 0.92, gp = grid::gpar(fontface = "bold", cex = 1.1, col = "red")) summary_headers <- names(summary_stats) summary_values <- as.numeric(summary_stats[1, ]) summary_widths <- rep(0.06, length(summary_headers)) x_start <- 0.5 - sum(summary_widths)/2 x_pos <- c(x_start, x_start + cumsum(summary_widths[-length(summary_widths)])) y_top <- 0.905 row_h <- 0.020 for (i in seq_along(summary_headers)) { grid::grid.rect(x = x_pos[i], y = y_top, width = summary_widths[i]*0.985, height = row_h, just = c("left","top"), gp = grid::gpar(fill = "red", col = "black", lwd = 0.5)) grid::grid.text(summary_headers[i], x = x_pos[i] + summary_widths[i]*0.49, y = y_top - row_h*0.5, gp = grid::gpar(col = "white", cex = 0.62, fontface = "bold")) fill_col <- if (i <= length(summary_colors)) summary_colors[i] else "#FFFFFF" grid::grid.rect(x = x_pos[i], y = y_top - row_h, width = summary_widths[i]*0.985, height = row_h, just = c("left","top"), gp = grid::gpar(fill = fill_col, col = "black", lwd = 0.4)) grid::grid.text(ifelse(is.finite(summary_values[i]), sprintf("%.1f", summary_values[i]), "-"), x = x_pos[i] + summary_widths[i]*0.49, y = y_top - row_h*1.5, gp = grid::gpar(cex = 0.62)) } # Row 1: Movement plot (left) | Velocity distribution (right) grid::pushViewport(grid::viewport(x = 0.25, y = charts_y_top, width = 0.45, height = charts_height, just = c("center","top"))) tryCatch(print(movement_plot, newpage = FALSE), error = function(e) NULL) grid::popViewport() grid::pushViewport(grid::viewport(x = 0.75, y = charts_y_top, width = 0.45, height = charts_height, just = c("center","top"))) tryCatch(print(velo_plot, newpage = FALSE), error = function(e) NULL) grid::popViewport() # Row 2: Count plot (left) | ENLARGED Release side plot (right) # Count plot on left grid::pushViewport(grid::viewport(x = 0.27, y = count_y_top, width = 0.50, height = count_height, just = c("center","top"))) tryCatch(print(count_plot, newpage = FALSE), error = function(e) NULL) grid::popViewport() grid::pushViewport(grid::viewport( x = 0.77, y = count_y_top, width = 0.44, height = release_height, # ← use release_height here just = c("center","top") )) tryCatch(print(relside_height_plot, newpage = FALSE), error = function(e) NULL) grid::popViewport() # Pitch Characteristics table grid::grid.text("Pitch Characteristics", x = 0.5, y = y_top_char + 0.015, gp = grid::gpar(fontface = "bold", cex = 1.1, col = "red")) char_headers <- names(pitch_char) num_char_cols <- length(char_headers) if (num_char_cols > 1) { char_widths <- c(0.10, rep((1 - 0.10 - 0.06) / (num_char_cols - 1), num_char_cols - 1)) } else { char_widths <- c(0.10) } x_start_char <- 0.5 - sum(char_widths)/2 x_pos_char <- c(x_start_char) if (length(char_widths) > 1) { x_pos_char <- c(x_start_char, x_start_char + cumsum(char_widths[-length(char_widths)])) } # Draw header row for (i in seq_along(char_headers)) { if (i > length(x_pos_char) || i > length(char_widths)) break grid::grid.rect(x = x_pos_char[i], y = y_top_char, width = char_widths[i]*0.985, height = row_h_char, just = c("left","top"), gp = grid::gpar(fill = "red", col = "black", lwd = 0.5)) grid::grid.text(char_headers[i], x = x_pos_char[i] + char_widths[i]*0.49, y = y_top_char - row_h_char*0.5, gp = grid::gpar(col = "white", cex = 0.50, fontface = "bold")) } i_col_pitch <- match("Pitch", char_headers) has_pitchcol <- !is.na(i_col_pitch) && i_col_pitch >= 1 # Draw data rows for (r in seq_len(num_rows)) { y_row <- y_top_char - r * row_h_char pitch_name <- if (has_pitchcol) as.character(get_cell_value(pitch_char, "Pitch", r)) else NA_character_ for (i in seq_along(char_headers)) { if (i > length(x_pos_char) || i > length(char_widths)) break if (i > num_cols) break colname <- char_headers[i] bg <- "#FFFFFF" if (r >= 1 && r <= nrow(pitch_colors_matrix) && i >= 1 && i <= ncol(pitch_colors_matrix)) { bg <- pitch_colors_matrix[r, i] if (is.na(bg) || !nzchar(bg)) bg <- "#FFFFFF" } if (has_pitchcol && identical(colname, "Pitch") && !is.na(pitch_name) && pitch_name %in% names(pitch_colors)) { bg <- pitch_colors[[pitch_name]] } grid::grid.rect(x = x_pos_char[i], y = y_row, width = char_widths[i]*0.985, height = row_h_char, just = c("left","top"), gp = grid::gpar(fill = bg, col = "grey80", lwd = 0.3)) val <- get_cell_value(pitch_char, colname, r) display_val <- if (is.numeric(val)) { if (is.na(val) || is.nan(val) || is.infinite(val)) "-" else sprintf("%.1f", val) } else if (is.character(val) || is.factor(val)) { v <- as.character(val) ifelse(nzchar(v), v, "-") } else "-" txt_col <- if (has_pitchcol && identical(colname, "Pitch")) .text_on_fill(bg) else "black" grid::grid.text(display_val, x = x_pos_char[i] + char_widths[i]*0.49, y = y_row - row_h_char*0.5, gp = grid::gpar( cex = 0.50, col = txt_col, fontface = if (has_pitchcol && identical(colname, "Pitch")) "bold" else "plain" )) } } # Location plots grid::pushViewport(grid::viewport(x = 0.25, y = y_loc_top, width = 0.45, height = 0.24, just = c("center","top"))) tryCatch(print(location_lhb, newpage = FALSE), error = function(e) NULL) grid::popViewport() grid::pushViewport(grid::viewport(x = 0.75, y = y_loc_top, width = 0.45, height = 0.24, just = c("center","top"))) tryCatch(print(location_rhb, newpage = FALSE), error = function(e) NULL) grid::popViewport() grid::grid.text("Data: TrackMan | Report Generated: Coastal Carolina Baseball", x = 0.5, y = 0.02, gp = grid::gpar(cex = 0.75, col = "grey50")) invisible(output_file) } # ===================================================================== # =========================== UI ================================ # ===================================================================== login_ui <- fluidPage( tags$style(HTML(" body { background-color: #f0f4f8; font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; color: #006F71; } .login-container { max-width: 360px; margin: 120px auto; background: #A27752; padding: 30px 25px; border-radius: 8px; box-shadow: 0 4px 15px #A1A1A4; text-align: center; color: white; } .login-message { margin-bottom: 20px; font-size: 14px; color: #ffffff; font-weight: 600; } .btn-primary { background-color: #006F71 !important; border-color: #006F71 !important; color: white !important; font-weight: bold; width: 100%; margin-top: 10px; box-shadow: 0 2px 5px #006F71; transition: background-color 0.3s ease; } .btn-primary:hover { background-color: #006F71 !important; border-color: #A27752 !important; } .form-control { border-radius: 4px; border: 1.5px solid #006F71 !important; color: #006F71; font-weight: 600; } ")), div(class = "login-container", tags$img(src = "https://upload.wikimedia.org/wikipedia/en/thumb/e/ef/Coastal_Carolina_Chanticleers_logo.svg/1200px-Coastal_Carolina_Chanticleers_logo.svg.png", height = "150px"), passwordInput("password", "Password:"), actionButton("login", "Login"), textOutput("wrong_pass") ) ) app_ui <- fluidPage( tags$head(tags$style(HTML(app_css))), div(class = "header", h1("Postgame Report Generator"), p("Upload a Trackman CSV to generate postgame reports") ), uiOutput("leaderboard_ui"), fluidRow( column( 4, div(class = "main-panel", div(class = "upload-box", h3("Upload Game Data", style = "color: #006F71; margin-top: 0;"), fileInput("game_csv", NULL, accept = c(".csv","text/csv"), buttonLabel = "Choose CSV...", placeholder = "No file selected"), radioButtons("report_type", "Report Type", c("Hitter"="hitter", "Pitcher"="pitcher", "Advanced Pitcher"="advanced_pitcher", "Matt Williams Report"="tableau_pitcher", "Catcher"="catcher", "Umpire"="umpire", "BP Report"="bp"), selected = "hitter", inline = TRUE), hr(), h4("Optional: Bio CSV", style = "color: #006F71; font-size: 1em;"), conditionalPanel("input.report_type == 'hitter'", fileInput("bio_csv_hitter", "Player Bio (optional)", accept = c(".csv","text/csv"), buttonLabel = "Choose Bio CSV...", placeholder = "Optional"), p("Upload CCU_Hitter_Bio.csv to add headshots", style = "font-size: 0.85em; color: #666; margin-top: -10px;") ), conditionalPanel("input.report_type == 'catcher'", fileInput("bio_csv_catcher", "Catcher Bio (optional)", accept = c(".csv","text/csv"), buttonLabel = "Choose Bio CSV...", placeholder = "Optional") ) ), uiOutput("selector_ui"), hr(), uiOutput("download_ui"), uiOutput("bulk_ui"), uiOutput("status_message") ) ), column( 8, div(class = "main-panel", h3("Report Preview", style = "color: #006F71; margin-top: 0;"), uiOutput("preview_content") ) ) ) ) # ===================================================================== # =========================== SERVER ============================= # ===================================================================== ui <- fluidPage( uiOutput("page") ) server <- function(input, output, session) { logged_in <- reactiveVal(FALSE) output$page <- renderUI({ if (logged_in()) { app_ui } else { login_ui } }) observeEvent(input$login, { if (input$password == PASSWORD) { logged_in(TRUE) output$wrong_pass <- renderText("") } else { output$wrong_pass <- renderText("Incorrect password, please try again.") } }) data_hitter <- reactiveVal(NULL) data_catcher <- reactiveVal(NULL) bio_hitter <- reactiveVal(NULL) bio_catch <- reactiveVal(NULL) data_umpire <- reactiveVal(NULL) data_bp <- reactiveVal(NULL) observeEvent( list(data_hitter(), input$report_type), { req(data_hitter()) req(input$report_type == "umpire") data_umpire(umpire_process_data(data_hitter())) }, ignoreInit = TRUE ) data_pitcher <- reactive({ df <- data_hitter() if (is.null(df)) return(NULL) if (!"Pitcher" %in% names(df)) { alt <- intersect(c("PitcherName","pitcher","Pitcher_LastFirst","PlayerName"), names(df)) if (length(alt)) df$Pitcher <- df[[alt[1]]] else df$Pitcher <- NA_character_ } df %>% mutate( Pitcher = stringr::str_replace(coalesce(Pitcher, ""), "^\\s*(\\w+)\\s*,\\s*(\\w+)\\s*$", "\\2 \\1") ) }) observeEvent(input$game_csv, { req(input$game_csv) tryCatch({ df <- read.csv(input$game_csv$datapath, stringsAsFactors = FALSE) data_hitter(process_dataset(df)) data_catcher(catcher_process_dataset(df)) showNotification("Game data loaded successfully!", type = "message", duration = 3) }, error = function(e) { showNotification(paste("Error loading CSV:", e$message), type = "error", duration = 6) data_hitter(NULL); data_catcher(NULL) }) }) observeEvent(input$game_csv, { req(input$game_csv) tryCatch({ df <- read.csv(input$game_csv$datapath, stringsAsFactors = FALSE) # Existing code for hitter/pitcher/catcher/umpire... data_hitter(process_dataset(df)) data_catcher(catcher_process_dataset(df)) # ADD THIS NEW LINE: data_bp(process_bp_dataset(df)) showNotification("Game data loaded successfully!", type = "message", duration = 3) }, error = function(e) { showNotification(paste("Error loading CSV:", e$message), type = "error", duration = 6) data_hitter(NULL); data_catcher(NULL); data_bp(NULL) }) }) observeEvent(input$bio_csv_hitter, { req(input$bio_csv_hitter) tryCatch({ bio <- read.csv(input$bio_csv_hitter$datapath, stringsAsFactors = FALSE) if ("Batter" %in% names(bio)) { bio <- bio %>% mutate(Batter = stringr::str_replace(Batter, "^\\s*(\\w+)\\s*,\\s*(\\w+)\\s*$", "\\2 \\1")) } bio_hitter(bio) showNotification("Player bio loaded", type = "message", duration = 3) }, error = function(e) { showNotification(paste("Player bio error:", e$message), type = "warning", duration = 6) bio_hitter(NULL) }) }) observeEvent(input$bio_csv_catcher, { req(input$bio_csv_catcher) tryCatch({ bio <- read.csv(input$bio_csv_catcher$datapath, stringsAsFactors = FALSE) if ("Catcher" %in% names(bio)) { bio <- bio %>% mutate(Catcher = stringr::str_replace(Catcher, "^\\s*(\\w+)\\s*,\\s*(\\w+)\\s*$", "\\2 \\1")) } bio_catch(bio) showNotification("Catcher bio loaded", type = "message", duration = 3) }, error = function(e) { showNotification(paste("Catcher bio error:", e$message), type = "warning", duration = 6) bio_catch(NULL) }) }) output$selector_ui <- renderUI({ if (input$report_type == "hitter") { df <- data_hitter() if (is.null(df)) return(div(p("Please upload a CSV to begin", style = "color:#666;font-style:italic;text-align:center;"))) players <- sort(unique(na.omit(df$Batter))) if (!length(players)) return(div(p("No players found in uploaded data", style="color:#cc6600;font-weight:bold;"))) selectInput("player_name", "Select Player", choices = players, selected = players[1], width = "100%") } else if (input$report_type == "pitcher") { df <- data_pitcher() if (is.null(df)) return(div(p("Please upload a CSV to begin", style = "color:#666;font-style:italic;text-align:center;"))) pitchers <- sort(unique(na.omit(df$Pitcher))) if (!length(pitchers)) return(div(p("No pitchers found in uploaded data", style="color:#cc6600;font-weight:bold;"))) selectInput("pitcher_name", "Select Pitcher", choices = pitchers, selected = pitchers[1], width = "100%") } else if (input$report_type == "catcher") { df <- data_catcher() if (is.null(df)) return(div(p("Please upload a CSV to begin", style = "color:#666;font-style:italic;text-align:center;"))) catchers <- sort(unique(na.omit(df$Catcher))) if (!length(catchers)) return(div(p("No catchers found in uploaded data", style="color:#cc6600;font-weight:bold;"))) selectInput("catcher_name", "Select Catcher", choices = catchers, selected = catchers[1], width = "100%") } else if (input$report_type == "bp") { df <- data_bp() if (is.null(df)) return(div(p("Please upload a BP CSV to begin", style = "color:#666;font-style:italic;text-align:center;"))) players <- sort(unique(na.omit(df$Batter))) if (!length(players)) return(div(p("No players found in BP data", style="color:#cc6600;font-weight:bold;"))) selectInput("bp_player_name", "Select Player", choices = players, selected = players[1], width = "100%") } else if (input$report_type == "tableau_pitcher") { df <- data_pitcher() if (is.null(df)) return(div(p("Please upload a CSV to begin", style = "color:#666;font-style:italic;text-align:center;"))) pitchers <- sort(unique(na.omit(df$Pitcher))) if (!length(pitchers)) return(div(p("No pitchers found", style="color:#cc6600;font-weight:bold;"))) selectInput("tableau_pitcher_name", "Select Pitcher", choices = pitchers, selected = pitchers[1], width = "100%") } else if (input$report_type == "advanced_pitcher") { df <- data_pitcher() if (is.null(df)) return(div(p("Please upload a CSV to begin", style = "color:#666;font-style:italic;text-align:center;"))) pitchers <- sort(unique(na.omit(df$Pitcher))) if (!length(pitchers)) return(div(p("No pitchers found in uploaded data", style="color:#cc6600;font-weight:bold;"))) tagList( selectInput("advanced_pitcher_name", "Select Pitcher", choices = pitchers, selected = pitchers[1], width = "100%"), div(class = "status-box", p(strong("Advanced Report Features:"), style = "margin-top: 0; color: #006F71;"), tags$ul( tags$li("Stuff+ Model Predictions"), tags$li("SEC Benchmarking with Color Coding"), tags$li("Enhanced Count Usage (Ahead/Behind/2-Strike)"), tags$li("Location by Result Type Visualization") )) ) } else if (input$report_type == "umpire") { df <- data_umpire() if (is.null(df)) return(div(p("Please upload a CSV to begin", style = "color:#666;font-style:italic;text-align:center;"))) game_date <- format(max(df$Date, na.rm = TRUE), '%B %d, %Y') tagList( textInput("umpire_name", "Umpire Name (optional)", value = "", placeholder = "Enter umpire name...", width = "100%"), div(class = "status-box", h4("Game Date: ", game_date, style = "margin: 0; color: #006F71;")) ) } }) output$download_ui <- renderUI({ if (input$report_type == "hitter") { downloadButton("download_hitter", "Download Hitter PDF", class = "btn-primary") } else if (input$report_type == "pitcher") { downloadButton("download_pitcher", "Download Pitcher PDF", class = "btn-primary") } else if (input$report_type == "catcher") { downloadButton("download_catcher", "Download Catcher PDF", class = "btn-primary") } else if (input$report_type == "advanced_pitcher") { downloadButton("download_advanced_pitcher", "Download Advanced Pitcher PDF", class = "btn-primary") } else if (input$report_type == "umpire") { downloadButton("download_umpire", "Download Umpire PDF", class = "btn-primary") } else if (input$report_type == "tableau_pitcher") { downloadButton("download_tableau_pitcher", "Download Tableau Pitcher PDF", class = "btn-primary") } else if (input$report_type == "bp") { downloadButton("download_bp", "Download BP Report PDF", class = "btn-primary") } }) output$bulk_ui <- renderUI({ if (input$report_type == "hitter") { df <- data_hitter(); if (is.null(df) || !"BatterTeam" %in% names(df)) return(NULL) coastal_players <- df %>% filter(BatterTeam == "COA_CHA") %>% pull(Batter) %>% unique() %>% na.omit() if (!length(coastal_players)) return(NULL) tagList( br(), div(style="text-align:center;padding:10px;background:#f0f8f8;border-radius:6px;margin-top:10px;", p(strong("Coastal Carolina Players Found: ", length(coastal_players)), style="color:#006F71;margin:5px 0;"), downloadButton("download_all_coastal_hitters", "Download All Coastal Hitter Reports (ZIP)", class="btn-secondary") ) ) } else if (input$report_type == "pitcher") { df <- data_pitcher(); if (is.null(df) || !"PitcherTeam" %in% names(df)) return(NULL) coastal_pitchers <- df %>% filter(PitcherTeam == "COA_CHA") %>% pull(Pitcher) %>% unique() %>% na.omit() if (!length(coastal_pitchers)) return(NULL) tagList( br(), div(style="text-align:center;padding:10px;background:#f0f8f8;border-radius:6px;margin-top:10px;", p(strong("Coastal Carolina Pitchers Found: ", length(coastal_pitchers)), style="color:#006F71;margin:5px 0;"), downloadButton("download_all_coastal_pitchers", "Download All Coastal Pitcher Reports (ZIP)", class="btn-secondary") ) ) } else if (input$report_type == "advanced_pitcher") { df <- data_pitcher() if (is.null(df) || !"PitcherTeam" %in% names(df)) return(NULL) coastal_pitchers <- df %>% filter(PitcherTeam == "COA_CHA") %>% pull(Pitcher) %>% unique() %>% na.omit() if (!length(coastal_pitchers)) return(NULL) tagList( br(), div(style="text-align:center;padding:10px;background:#f0f8f8;border-radius:6px;margin-top:10px;", p(strong("Coastal Carolina Pitchers Found: ", length(coastal_pitchers)), style="color:#006F71;margin:5px 0;"), downloadButton("download_all_coastal_advanced_pitchers", "Download All Advanced Pitcher Reports (ZIP)", class="btn-secondary") ) ) } else if (input$report_type == "tableau_pitcher") { df <- data_pitcher(); if (is.null(df) || !"PitcherTeam" %in% names(df)) return(NULL) coastal_pitchers <- df %>% filter(PitcherTeam == "COA_CHA") %>% pull(Pitcher) %>% unique() %>% na.omit() if (!length(coastal_pitchers)) return(NULL) tagList( br(), div(style="text-align:center;padding:10px;background:#f0f8f8;border-radius:6px;margin-top:10px;", p(strong("Coastal Carolina Pitchers Found: ", length(coastal_pitchers)), style="color:#006F71;margin:5px 0;"), downloadButton("download_all_coastal_tableau_pitchers", "Download All Coastal Tableau Reports (ZIP)", class="btn-secondary") ) ) } else if (input$report_type == "catcher") { df <- data_catcher(); if (is.null(df) || !"CatcherTeam" %in% names(df)) return(NULL) cts <- df %>% filter(CatcherTeam == "COA_CHA") %>% pull(Catcher) %>% unique() %>% na.omit() if (!length(cts)) return(NULL) tagList( br(), div(style="text-align:center;padding:10px;background:#f0f8f8;border-radius:6px;margin-top:10px;", p(strong("Coastal Carolina Catchers Found: ", length(cts)), style="color:#006F71;margin:5px 0;"), downloadButton("download_all_ccu_catchers", "Download All CCU Catcher Reports (ZIP)", class="btn-secondary") ) ) } }) output$download_pitcher <- downloadHandler( filename = function() { df <- data_pitcher(); req(df, input$pitcher_name) pitcher_clean <- gsub(" ", "_", input$pitcher_name) date_str <- format(parse_game_day(df %>% filter(Pitcher == input$pitcher_name)), "%Y%m%d") paste0(pitcher_clean, "_", date_str, "_Pitcher_Report.pdf") }, content = function(file) { df <- data_pitcher(); req(df, input$pitcher_name) pitch_colors <- c("Fastball"="#FA8072","FourSeamFastBall"="#FA8072", "Four-Seam"="#FA8072","Sinker"="#fdae61", "Slider"="#A020F0","Sweeper"="magenta","Curveball"="#2c7bb6", "ChangeUp"="#90EE90","Splitter"="#90EE32","Cutter"="red") withProgress(message='Generating Pitcher PDF', value=0, { incProgress(.3, detail="Processing data...") incProgress(.4, detail="Creating visualizations...") create_pitcher_pdf(df, input$pitcher_name, file, pitch_colors) incProgress(.3, detail="Finalizing report...") }) showNotification("Pitcher report generated!", type="message", duration=3) }, contentType = "application/pdf" ) output$download_bp <- downloadHandler( filename = function() { df <- data_bp(); req(df, input$bp_player_name) player_clean <- gsub(" ", "_", input$bp_player_name) paste0(player_clean, "_BP_Report.pdf") }, content = function(file) { df <- data_bp(); req(df, input$bp_player_name) withProgress(message='Generating BP Report PDF', value=0, { incProgress(.3, detail="Processing data...") incProgress(.4, detail="Creating visualizations...") create_bp_pdf(df, input$bp_player_name, file) incProgress(.3, detail="Finalizing report...") }) showNotification("BP Report generated!", type="message", duration=3) }, contentType = "application/pdf" ) output$download_all_coastal_pitchers <- downloadHandler( filename = function() { df <- data_pitcher(); req(df) paste0("Coastal_Pitcher_Reports_", format(parse_game_day(df), "%Y%m%d"), ".zip") }, content = function(file) { df <- data_pitcher(); req(df) pitch_colors <- c( "Fastball"="#FA8072","Four-Seam"="#FA8072", "FourSeamFastBall" = "#FA8072", "Sinker"="#fdae61", "Slider"="#A020F0","Sweeper"="magenta","Curveball"="#2c7bb6", "ChangeUp"="#90EE90","Splitter"="#90EE32","Cutter"="red" ) pitchers <- df %>% dplyr::filter(PitcherTeam == "COA_CHA") %>% dplyr::pull(Pitcher) %>% unique() %>% na.omit() %>% sort() if (!length(pitchers)) { showNotification("No Coastal pitchers found", type="error", duration=5) return(NULL) } withProgress(message='Generating Coastal Pitcher Reports', value=0, { tmp <- tempdir(); pdfs <- character(0); total <- length(pitchers) for (i in seq_along(pitchers)) { ply <- pitchers[i]; incProgress(1/total, detail=paste("Report for", ply)) out <- file.path(tmp, paste0(gsub(" ","_",ply), "_", format(parse_game_day(df), "%Y%m%d"), "_Pitcher_Report.pdf")) try(create_pitcher_pdf(df, ply, out, pitch_colors), silent = TRUE) if (file.exists(out)) pdfs <- c(pdfs, out) } if (!length(pdfs)) { showNotification("Failed to generate reports", type="error", duration=5) return(NULL) } zip::zip(zipfile=file, files=basename(pdfs), root=tmp); unlink(pdfs) }) showNotification("Coastal pitcher ZIP ready!", type="message", duration=5) }, contentType = "application/zip" ) output$status_message <- renderUI({ if (input$report_type == "hitter") { df <- data_hitter(); req(df, input$player_name) player_df <- df %>% filter(Batter == input$player_name) if (!nrow(player_df)) return(NULL) game_date <- parse_game_day(player_df) div(class = "status-box", h4("✓ Ready to Generate Hitter Report", style = "margin-top: 0; color: #006F71;"), p(strong("Player: "), input$player_name), p(strong("Game Date: "), format(game_date, "%B %d, %Y")), p(strong("Total Pitches: "), nrow(player_df))) } else if (input$report_type == "pitcher") { df <- data_pitcher(); req(df, input$pitcher_name) pitcher_df <- df %>% filter(Pitcher == input$pitcher_name) if (!nrow(pitcher_df)) return(NULL) game_date <- parse_game_day(pitcher_df) stats <- pitcher_df %>% summarise(pitches=n(), k=sum(KorBB=="Strikeout",na.rm=TRUE), bb=sum(WalkIndicator,na.rm=TRUE)) div(class="status-box", h4("✓ Ready to Generate Pitcher Report", style="margin-top:0;color:#006F71;"), p(strong("Pitcher: "), input$pitcher_name), p(strong("Game Date: "), format(game_date, "%B %d, %Y")), p(strong("Total Pitches: "), stats$pitches), p(strong("Strikeouts: "), stats$k, " | ", strong("Walks: "), stats$bb)) } else if (input$report_type == "advanced_pitcher") { df <- data_pitcher() req(df, input$advanced_pitcher_name) pitcher_df <- df %>% filter(Pitcher == input$advanced_pitcher_name) if (!nrow(pitcher_df)) return(NULL) game_date <- parse_game_day(pitcher_df) stats <- pitcher_df %>% summarise( pitches = n(), k = sum(KorBB=="Strikeout", na.rm=TRUE), bb = sum(WalkIndicator, na.rm=TRUE) ) div(class="status-box", h4("✓ Ready to Generate Advanced Pitcher Report", style="margin-top:0;color:#006F71;"), p(strong("Pitcher: "), input$advanced_pitcher_name), p(strong("Game Date: "), format(game_date, "%B %d, %Y")), p(strong("Total Pitches: "), stats$pitches), p(strong("Strikeouts: "), stats$k, " | ", strong("Walks: "), stats$bb), p(strong("Stuff+ Model: "), ifelse(!is.null(stuffplus_model), "✓ Loaded", "✗ Not Available"), style = ifelse(!is.null(stuffplus_model), "color: green;", "color: orange;")) ) } else if (input$report_type == "catcher") { df <- data_catcher(); req(df, input$catcher_name) catcher_df <- df %>% filter(Catcher == input$catcher_name) if (!nrow(catcher_df)) return(NULL) game_date <- catcher_parse_game_day(catcher_df) receiving_stats <- catcher_df %>% summarise(strikes_added=sum(StolenStrike,na.rm=TRUE), strikes_lost=sum(StrikeLost,na.rm=TRUE)) throwing_stats <- catcher_df %>% filter(Notes %in% c('2b out','2b safe','3b out','3b safe','2B out','2B safe','3B out','3B safe')) %>% summarise(throws=n()) div(class = "status-box", h4("✓ Ready to Generate Catcher Report", style = "margin-top: 0; color: #006F71;"), p(strong("Catcher: "), input$catcher_name), p(strong("Game Date: "), format(game_date, "%B %d, %Y")), p(strong("Total Pitches: "), nrow(catcher_df)), p(strong("Strikes Stolen: "), receiving_stats$strikes_added, " | ", strong("Strikes Lost: "), receiving_stats$strikes_lost), p(strong("Throws Recorded: "), throwing_stats$throws)) } else if (input$report_type == "bp") { df <- data_bp(); req(df, input$bp_player_name) player_df <- df %>% filter(Batter == input$bp_player_name) if (!nrow(player_df)) return(NULL) stats <- player_df %>% summarise( bbe = sum(BIPind, na.rm = TRUE), avg_ev = round(mean(ExitSpeed[BIPind == 1], na.rm = TRUE), 1), max_ev = round(max(ExitSpeed[BIPind == 1], na.rm = TRUE), 1) ) div(class = "status-box", h4("✓ Ready to Generate BP Report", style = "margin-top: 0; color: #006F71;"), p(strong("Player: "), input$bp_player_name), p(strong("Batted Ball Events: "), stats$bbe), p(strong("Avg Exit Velo: "), stats$avg_ev, " mph"), p(strong("Max Exit Velo: "), stats$max_ev, " mph")) } }) output$leaderboard_ui <- renderUI({ df <- data_hitter() if (is.null(df)) return(NULL) leaders <- calculate_leaderboards(df) gi <- leaders$game_info make_column <- function(title, data, name_col, value_col, unit) { if (nrow(data) == 0) { return(div(class = "leaderboard-column", div(class = "leaderboard-column-header", span(title), span(unit)), div("No data available", style = "color: #999; padding: 10px;"))) } rows <- lapply(seq_len(nrow(data)), function(i) { logo_html <- if (nzchar(data$Logo[i])) { tags$img(src = data$Logo[i], class = "leaderboard-logo", onerror = "this.style.display='none'") } else tags$span(style = "width: 28px; display: inline-block;") div(class = "leaderboard-row", logo_html, span(class = "leaderboard-name", data[[name_col]][i]), span(class = "leaderboard-value", round(data[[value_col]][i], 1))) }) div(class = "leaderboard-column", div(class = "leaderboard-column-header", span(title), span(unit)), rows) } div(class = "leaderboard-section", # Game Info Bar div(class = "game-info-bar", div(class = "game-info-item", span(class = "game-info-label", "Date"), span(class = "game-info-value", gi$date)), div(class = "game-info-item", span(class = "game-info-label", "Stadium"), span(class = "game-info-value", gi$stadium)), div(class = "game-info-item", span(class = "game-info-label", "Level"), span(class = "game-info-value", gi$level)), div(class = "game-info-item", span(class = "game-info-label", "League"), span(class = "game-info-value", gi$league)), div(class = "game-info-item", span(class = "game-info-label", "Final Score"), span(class = "game-score", gi$final_score)) ), # Leaders Title h3(class = "leaderboard-title", icon("trophy"), " Game Leaders"), # Leaders Grid div(class = "leaderboard-grid", make_column("Top Exit Velocity", leaders$exit_velo, "Batter", "MaxEV", "MPH"), make_column("Top Distances", leaders$distance, "Batter", "MaxDist", "Ft."), make_column("Top Pitch Velocity", leaders$pitch_velo, "Pitcher", "MaxVelo", "MPH"), make_column("Swing & Misses", leaders$whiffs, "Pitcher", "Whiffs", "#"))) }) output$preview_content <- renderUI({ if (input$report_type == "hitter") { df <- data_hitter() if (is.null(df)) return(div(style = "text-align:center;padding:60px;color:#999;", h4("No data to preview"))) req(input$player_name) tagList( h4("At-Bat Visualization", style = "color: #006F71;"), div(class = "tall-plot", plotOutput("preview_plot_hitter", height = "460px")) ) } else if (input$report_type == "pitcher") { df <- data_pitcher() if (is.null(df)) return(div(style="text-align:center;padding:60px;color:#999;", h4("No data to preview"))) req(input$pitcher_name) tagList( h4("Pitch Movement", style="color:#006F71;"), plotOutput("preview_movement", height="380px"), br(), h4("Pitch Locations", style="color:#006F71;"), plotOutput("preview_location", height="380px"), br(), h4("Release Points", style="color:#006F71;"), plotOutput("preview_release", height="380px") ) } else if (input$report_type == "advanced_pitcher") { df <- data_pitcher() if (is.null(df)) return(div(style="text-align:center;padding:60px;color:#999;", h4("No data to preview"))) req(input$advanced_pitcher_name) tagList( h4("Advanced Pitch Movement", style="color:#006F71;"), plotOutput("preview_advanced_movement", height="380px"), br(), h4("Count Usage (Ahead/Behind)", style="color:#006F71;"), plotOutput("preview_advanced_count", height="380px") ) } else if (input$report_type == "tableau_pitcher") { df <- data_pitcher() if (is.null(df)) return(div(style = "text-align:center;padding:60px;color:#999;", h4("No data to preview"))) req(input$tableau_pitcher_name) tagList( h4("Location Report Preview", style = "color:#006F71;"), plotOutput("preview_tableau_location", height = "380px"), br(), h4("Movement Profile Preview", style = "color:#006F71;"), plotOutput("preview_tableau_movement", height = "380px") ) } else if (input$report_type == "catcher") { df <- data_catcher() if (is.null(df)) return(div(style="text-align:center;padding:60px;color:#999;", h4("No data to preview"))) req(input$catcher_name) tagList( h4("Framing Visualization", style = "color: #006F71;"), plotOutput("preview_framing", height = "350px"), br(), h4("Throwing Accuracy", style = "color: #006F71;"), plotOutput("preview_throwing", height = "400px") ) } else if (input$report_type == "bp") { df <- data_bp() if (is.null(df)) return(div(style = "text-align:center;padding:60px;color:#999;", h4("No data to preview"))) req(input$bp_player_name) tagList( h4("BP Spray Chart", style = "color: #006F71;"), plotOutput("preview_bp_spray", height = "400px"), br(), h4("BP Zone Plot", style = "color: #006F71;"), plotOutput("preview_bp_zone", height = "400px") ) } }) output$preview_plot_hitter <- renderPlot({ df <- data_hitter(); req(df, input$player_name) player_df <- df %>% filter(Batter == input$player_name) validate(need(nrow(player_df) > 0, "No rows for selected player")) game_date <- parse_game_day(player_df) game_key <- format(game_date, "%Y-%m-%d") pitch_colors <- c( "Fastball" = "#FA8072", "Four-Seam" = "#FA8072", "FourSeamFastBall" = "#FA8072","Sinker" = "#fdae61", "Slider" = "#A020F0", "Sweeper" = "magenta", "Curveball" = "#2c7bb6", "ChangeUp" = "#90EE90", "Splitter" = "#90EE32", "Cutter" = "red" ) create_at_bats_plot(df, input$player_name, game_key, pitch_colors) }, res = 96) output$preview_movement <- renderPlot({ df <- data_pitcher(); req(df, input$pitcher_name) pitcher_df <- df %>% filter(Pitcher == input$pitcher_name) pitch_colors <- c("Fastball"="#FA8072","Four-Seam"="#FA8072","FourSeamFastBall" = "#FA8072","Sinker"="#fdae61","Slider"="#A020F0","Sweeper"="magenta","Curveball"="#2c7bb6","ChangeUp"="#90EE90","Splitter"="#90EE32","Cutter"="red") create_pitcher_movement_plot(pitcher_df, input$pitcher_name, pitch_colors) }, res=120) output$preview_location <- renderPlot({ df <- data_pitcher(); req(df, input$pitcher_name) pitcher_df <- df %>% filter(Pitcher == input$pitcher_name) pitch_colors <- c("Fastball"="#FA8072","Four-Seam"="#FA8072","FourSeamFastBall" = "#FA8072","Sinker"="#fdae61","Slider"="#A020F0","Sweeper"="magenta","Curveball"="#2c7bb6","ChangeUp"="#90EE90","Splitter"="#90EE32","Cutter"="red") create_pitcher_location_plot(pitcher_df, pitch_colors) }, res=120) output$preview_bp_spray <- renderPlot({ df <- data_bp(); req(df, input$bp_player_name) create_bp_spray_chart(input$bp_player_name, df) }, res = 96) output$preview_tableau_location <- renderPlot({ df <- data_pitcher(); req(df, input$tableau_pitcher_name) pitcher_df <- process_tableau_pitcher_data(df) %>% filter(Pitcher == input$tableau_pitcher_name) create_tableau_location_plot(pitcher_df, tableau_pitch_colors) }, res = 120) output$preview_tableau_movement <- renderPlot({ df <- data_pitcher(); req(df, input$tableau_pitcher_name) pitcher_df <- process_tableau_pitcher_data(df) %>% filter(Pitcher == input$tableau_pitcher_name) create_tableau_movement_plot(pitcher_df, tableau_pitch_colors) }, res = 120) output$preview_bp_zone <- renderPlot({ df <- data_bp(); req(df, input$bp_player_name) create_bp_zone_plot(input$bp_player_name, df) }, res = 96) output$preview_release <- renderPlot({ df <- data_pitcher(); req(df, input$pitcher_name) pitcher_df <- df %>% filter(Pitcher == input$pitcher_name) pitch_colors <- c("Fastball"="#FA8072","Four-Seam"="#FA8072","FourSeamFastBall" = "#FA8072","Sinker"="#fdae61","Slider"="#A020F0","Sweeper"="magenta","Curveball"="#2c7bb6","ChangeUp"="#90EE90","Splitter"="#90EE32","Cutter"="red") create_pitcher_release_plot(pitcher_df, pitch_colors) }, res=120) output$preview_framing <- renderPlot({ df <- data_catcher(); req(df, input$catcher_name) catcher_create_framing_plot(df, input$catcher_name) }, res = 96) output$preview_throwing <- renderPlot({ df <- data_catcher(); req(df, input$catcher_name) catcher_create_throwing_plot(df, input$catcher_name) }, res = 96) output$preview_advanced_movement <- renderPlot({ df <- data_pitcher() req(df, input$advanced_pitcher_name) pitcher_df <- df %>% filter(Pitcher == input$advanced_pitcher_name) pitch_colors <- c("Fastball"="#FA8072","Four-Seam"="#FA8072","FourSeamFastBall" = "#FA8072","Sinker"="#fdae61", "Slider"="#A020F0","Sweeper"="magenta","Curveball"="#2c7bb6", "ChangeUp"="#90EE90","Splitter"="#90EE32","Cutter"="red") create_movement_plot(pitcher_df, input$advanced_pitcher_name, pitch_colors) }, res=120) output$preview_advanced_count <- renderPlot({ df <- data_pitcher() req(df, input$advanced_pitcher_name) pitcher_df <- df %>% filter(Pitcher == input$advanced_pitcher_name) pitch_colors <- c("Fastball"="#FA8072","Four-Seam"="#FA8072","FourSeamFastBall" = "#FA8072","Sinker"="#fdae61", "Slider"="#A020F0","Sweeper"="magenta","Curveball"="#2c7bb6", "ChangeUp"="#90EE90","Splitter"="#90EE32","Cutter"="red") create_count_usage_plot(pitcher_df, input$advanced_pitcher_name, pitch_colors) }, res=120) output$download_hitter <- downloadHandler( filename = function() { df <- data_hitter(); req(df, input$player_name) player_clean <- gsub(" ", "_", input$player_name) date_str <- format(parse_game_day(df %>% filter(Batter == input$player_name)), "%Y%m%d") paste0(player_clean, "_", date_str, "_Report.pdf") }, content = function(file) { df <- data_hitter(); req(df, input$player_name) withProgress(message='Generating Hitter PDF', value=0, { incProgress(.3, detail="Processing data...") incProgress(.4, detail="Creating visualizations...") create_postgame_pdf(df, input$player_name, file, bio_hitter()) incProgress(.3, detail="Finalizing report...") }) showNotification("Hitter report generated!", type="message", duration=3) }, contentType = "application/pdf" ) output$download_catcher <- downloadHandler( filename = function() { df <- data_catcher(); req(df, input$catcher_name) catcher_clean <- gsub(" ", "_", input$catcher_name) date_str <- format(catcher_parse_game_day(df %>% filter(Catcher == input$catcher_name)), "%Y%m%d") paste0(catcher_clean, "_", date_str, "_Catcher_Report.pdf") }, content = function(file) { df <- data_catcher(); req(df, input$catcher_name) withProgress(message='Generating Catcher PDF', value=0, { incProgress(.4, detail="Building visualizations...") catcher_create_catcher_pdf(df, input$catcher_name, file, bio_catch()) incProgress(.6, detail="Finalizing...") }) showNotification("Catcher report generated!", type="message", duration=3) }, contentType = "application/pdf" ) output$download_umpire <- downloadHandler( filename = function() { df <- data_umpire() req(df) date_str <- format(max(df$Date, na.rm = TRUE), "%Y%m%d") ump_name <- if (!is.null(input$umpire_name) && nzchar(input$umpire_name)) { paste0(gsub(" ", "_", input$umpire_name), "_") } else "" paste0(ump_name, "Umpire_Report_", date_str, ".pdf") }, content = function(file) { df <- data_umpire() req(df) withProgress(message = 'Generating Umpire PDF', value = 0, { incProgress(.5, detail = "Creating visualizations...") umpire_create_report_pdf(df, file, umpire_name = input$umpire_name) incProgress(.5, detail = "Finalizing report...") }) showNotification("Umpire report generated!", type = "message", duration = 3) }, contentType = "application/pdf" ) output$download_advanced_pitcher <- downloadHandler( filename = function() { df <- data_pitcher() req(df, input$advanced_pitcher_name) pitcher_clean <- gsub(" ", "_", input$advanced_pitcher_name) date_str <- format(parse_game_day(df %>% filter(Pitcher == input$advanced_pitcher_name)), "%Y%m%d") paste0(pitcher_clean, "_", date_str, "_Advanced_Pitcher_Report.pdf") }, content = function(file) { df <- data_pitcher() req(df, input$advanced_pitcher_name) withProgress(message='Generating Advanced Pitcher PDF', value=0, { incProgress(.3, detail="Processing data with Stuff+ model...") incProgress(.4, detail="Creating advanced visualizations...") create_advanced_pitcher_pdf(df, input$advanced_pitcher_name, file) incProgress(.3, detail="Finalizing report...") }) showNotification("Advanced Pitcher report generated!", type="message", duration=3) }, contentType = "application/pdf" ) output$download_all_coastal_advanced_pitchers <- downloadHandler( filename = function() { df <- data_pitcher() req(df) paste0("Coastal_Advanced_Pitcher_Reports_", format(parse_game_day(df), "%Y%m%d"), ".zip") }, content = function(file) { df <- data_pitcher() req(df) pitchers <- df %>% filter(PitcherTeam == "COA_CHA") %>% pull(Pitcher) %>% unique() %>% na.omit() %>% sort() if (!length(pitchers)) { showNotification("No Coastal pitchers found", type="error", duration=5) return(NULL) } withProgress(message='Generating Advanced Pitcher Reports', value=0, { tmp <- tempdir() pdfs <- character(0) total <- length(pitchers) for (i in seq_along(pitchers)) { ply <- pitchers[i] incProgress(1/total, detail=paste("Advanced report for", ply)) out <- file.path(tmp, paste0(gsub(" ","_",ply), "_", format(parse_game_day(df), "%Y%m%d"), "_Advanced_Pitcher_Report.pdf")) try(create_advanced_pitcher_pdf(df, ply, out), silent = TRUE) if (file.exists(out)) pdfs <- c(pdfs, out) } if (!length(pdfs)) { showNotification("Failed to generate reports", type="error", duration=5) return(NULL) } zip::zip(zipfile=file, files=basename(pdfs), root=tmp) unlink(pdfs) }) showNotification("Advanced Pitcher ZIP ready!", type="message", duration=5) }, contentType = "application/zip" ) output$download_tableau_pitcher <- downloadHandler( filename = function() { df <- data_pitcher(); req(df, input$tableau_pitcher_name) pitcher_clean <- gsub(" ", "_", input$tableau_pitcher_name) date_str <- format(parse_game_day(df %>% filter(Pitcher == input$tableau_pitcher_name)), "%Y%m%d") paste0(pitcher_clean, "_", date_str, "_Tableau_Report.pdf") }, content = function(file) { df <- data_pitcher(); req(df, input$tableau_pitcher_name) withProgress(message = 'Generating Tableau Pitcher PDF', value = 0, { incProgress(.5, detail = "Creating visualizations...") create_tableau_pitcher_pdf(df, input$tableau_pitcher_name, file) incProgress(.5, detail = "Finalizing...") }) showNotification("Tableau Pitcher report generated!", type = "message", duration = 3) }, contentType = "application/pdf" ) output$download_all_coastal_hitters <- downloadHandler( filename = function() { df <- data_hitter(); req(df) date <- parse_game_day(df) paste0("Coastal_Carolina_Hitter_Reports_", format(date, "%Y%m%d"), ".zip") }, content = function(file) { df <- data_hitter(); req(df) players <- df %>% filter(BatterTeam=="COA_CHA") %>% pull(Batter) %>% unique() %>% na.omit() %>% sort() if (!length(players)) { showNotification("No Coastal Carolina players found", type="error", duration=5); return(NULL) } withProgress(message='Generating Coastal Hitter Reports', value=0, { tmp <- tempdir(); pdfs <- character(0); total <- length(players) for (i in seq_along(players)) { ply <- players[i]; incProgress(1/total, detail=paste("Report for", ply)) out <- file.path(tmp, paste0(gsub(" ","_",ply), "_", format(parse_game_day(df), "%Y%m%d"), "_Report.pdf")) try(create_postgame_pdf(df, ply, out, bio_hitter()), silent = TRUE) if (file.exists(out)) pdfs <- c(pdfs, out) } if (!length(pdfs)) { showNotification("Failed to generate any hitter reports", type="error", duration=5); return(NULL) } zip::zip(zipfile=file, files=basename(pdfs), root=tmp); unlink(pdfs) }) showNotification("Coastal hitter ZIP ready!", type="message", duration=5) }, contentType = "application/zip" ) output$download_all_coastal_tableau_pitchers <- downloadHandler( filename = function() { df <- data_pitcher(); req(df) paste0("Coastal_Tableau_Pitcher_Reports_", format(parse_game_day(df), "%Y%m%d"), ".zip") }, content = function(file) { df <- data_pitcher(); req(df) pitchers <- df %>% filter(PitcherTeam == "COA_CHA") %>% pull(Pitcher) %>% unique() %>% na.omit() %>% sort() if (!length(pitchers)) { showNotification("No Coastal pitchers found", type="error", duration=5) return(NULL) } withProgress(message='Generating Coastal Tableau Pitcher Reports', value=0, { tmp <- tempdir(); pdfs <- character(0); total <- length(pitchers) for (i in seq_along(pitchers)) { ply <- pitchers[i]; incProgress(1/total, detail=paste("Report for", ply)) out <- file.path(tmp, paste0(gsub(" ","_",ply), "_", format(parse_game_day(df), "%Y%m%d"), "_Tableau_Report.pdf")) try(create_tableau_pitcher_pdf(df, ply, out), silent = TRUE) if (file.exists(out)) pdfs <- c(pdfs, out) } if (!length(pdfs)) { showNotification("Failed to generate reports", type="error", duration=5) return(NULL) } zip::zip(zipfile=file, files=basename(pdfs), root=tmp); unlink(pdfs) }) showNotification("Coastal Tableau pitcher ZIP ready!", type="message", duration=5) }, contentType = "application/zip" ) output$download_all_ccu_catchers <- downloadHandler( filename = function() { df <- data_catcher(); req(df) date <- catcher_parse_game_day(df) paste0("CCU_Catcher_Reports_", format(date, "%Y%m%d"), ".zip") }, content = function(file) { df <- data_catcher(); req(df) ccu_catchers <- df %>% filter(CatcherTeam=="COA_CHA") %>% pull(Catcher) %>% unique() %>% na.omit() %>% sort() if (!length(ccu_catchers)) { showNotification("No CCU catchers found", type="error", duration=5); return(NULL) } withProgress(message='Generating CCU Catcher Reports', value=0, { tmp <- tempdir(); pdfs <- character(0); total <- length(ccu_catchers) for (i in seq_along(ccu_catchers)) { ct <- ccu_catchers[i]; incProgress(1/total, detail=paste("Report for", ct)) out <- file.path(tmp, paste0(gsub(" ","_",ct), "_", format(catcher_parse_game_day(df), "%Y%m%d"), "_Catcher_Report.pdf")) try(catcher_create_catcher_pdf(df, ct, out, bio_catch()), silent = TRUE) if (file.exists(out)) pdfs <- c(pdfs, out) } if (!length(pdfs)) { showNotification("Failed to generate any catcher reports", type="error", duration=5); return(NULL) } zip::zip(zipfile=file, files=basename(pdfs), root=tmp); unlink(pdfs) }) showNotification("✅ CCU catcher ZIP ready!", type="message", duration=5) }, contentType = "application/zip" ) } shinyApp(ui = ui, server = server)