Spaces:
Running
Running
| 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") | |
| ) | |
| } | |
| angle_to_clock <- function(angle) { | |
| if (is.na(angle) || !is.finite(angle)) return("-") | |
| angle <- angle %% 360 | |
| total_minutes <- (angle / 360) * 720 | |
| hours <- floor(total_minutes / 60) | |
| minutes <- round(total_minutes %% 60) | |
| if (minutes == 60) { hours <- hours + 1; minutes <- 0 } | |
| hours <- hours %% 12 | |
| if (hours == 0) hours <- 12 | |
| sprintf("%d:%02d", hours, minutes) | |
| } | |
| # Process bullpen CSV data | |
| process_bullpen_dataset <- function(df) { | |
| if ("Pitcher" %in% names(df)) { | |
| df <- df %>% mutate(Pitcher = stringr::str_replace( | |
| Pitcher, "^\\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) | |
| num_cols <- c("PlateLocSide", "PlateLocHeight", "RelSpeed", "SpinRate", | |
| "InducedVertBreak", "HorzBreak", "RelHeight", "RelSide", | |
| "Extension", "VertApprAngle", "HorzApprAngle", | |
| "SpinAxis", "SpinAxis3dTransverseAngle", | |
| "SpinAxis3dLongitudinalAngle", "SpinAxis3dActiveSpinRate", | |
| "SpinAxis3dSpinEfficiency") | |
| for (col in num_cols) { | |
| if (col %in% names(df)) df[[col]] <- suppressWarnings(as.numeric(df[[col]])) | |
| } | |
| if ("RelSpeed" %in% names(df)) df <- df %>% filter(!is.na(RelSpeed)) | |
| if (!"TaggedPitchType" %in% names(df)) { | |
| alt <- intersect(c("pitch_type", "PitchType", "TaggedPitch", "AutoPitchType"), names(df)) | |
| if (length(alt)) df$TaggedPitchType <- df[[alt[1]]] else df$TaggedPitchType <- NA_character_ | |
| } | |
| df <- df %>% | |
| mutate(TaggedPitchType = case_when( | |
| TaggedPitchType %in% c("FourSeamFastball", "FourSeamFastBall", "4-Seam", | |
| "Four-Seam Fastball", "4-Seam Fastball") ~ "Four-Seam", | |
| TRUE ~ TaggedPitchType | |
| )) | |
| # Ensure PitchCall exists (bullpen CSVs often have it empty or missing entirely) | |
| if (!"PitchCall" %in% names(df)) df$PitchCall <- NA_character_ | |
| df$PitchCall[is.na(df$PitchCall) | df$PitchCall == ""] <- "Unknown" | |
| # Ensure SpinAxis columns exist (create as NA if missing) | |
| if (!"SpinAxis" %in% names(df)) df$SpinAxis <- NA_real_ | |
| if (!"SpinAxis3dTransverseAngle" %in% names(df)) df$SpinAxis3dTransverseAngle <- NA_real_ | |
| if (!"SpinAxis3dLongitudinalAngle" %in% names(df)) df$SpinAxis3dLongitudinalAngle <- NA_real_ | |
| if (!"SpinAxis3dActiveSpinRate" %in% names(df)) df$SpinAxis3dActiveSpinRate <- NA_real_ | |
| if (!"SpinAxis3dSpinEfficiency" %in% names(df)) df$SpinAxis3dSpinEfficiency <- NA_real_ | |
| # Zone indicator only — no game-level stats needed for bullpen | |
| df <- df %>% | |
| mutate( | |
| in_zone = as.integer( | |
| !is.na(PlateLocSide) & !is.na(PlateLocHeight) & | |
| PlateLocSide >= -0.83 & PlateLocSide <= 0.83 & | |
| PlateLocHeight >= 1.5 & PlateLocHeight <= 3.38 | |
| ) | |
| ) | |
| df | |
| } | |
| # Calculate bullpen summary table | |
| calculate_bullpen_summary <- function(pitcher_df) { | |
| types <- pitcher_df %>% | |
| filter(!is.na(TaggedPitchType), TaggedPitchType != "", | |
| TaggedPitchType != "Undefined", TaggedPitchType != "Other") %>% | |
| pull(TaggedPitchType) %>% unique() | |
| if (length(types) == 0) types <- unique(pitcher_df$TaggedPitchType) | |
| observed_tilt <- pitcher_df %>% | |
| filter(TaggedPitchType %in% types) %>% | |
| group_by(TaggedPitchType) %>% | |
| summarise(obs_axis = round(median(SpinAxis, na.rm = TRUE), 1), .groups = "drop") %>% | |
| mutate(`Obs Tilt` = sapply(obs_axis, angle_to_clock)) %>% | |
| select(TaggedPitchType, `Obs Tilt`) | |
| measured_tilt <- pitcher_df %>% | |
| mutate( | |
| SpinAxis3dTransverseAngle = SpinAxis3dTransverseAngle + 180, | |
| SpinAxis3dTransverseAngle = ifelse(SpinAxis3dTransverseAngle > 360, | |
| SpinAxis3dTransverseAngle - 360, | |
| SpinAxis3dTransverseAngle) | |
| ) %>% | |
| filter(TaggedPitchType %in% types) %>% | |
| group_by(TaggedPitchType) %>% | |
| summarise(meas_axis = round(median(SpinAxis3dTransverseAngle, na.rm = TRUE), 1), .groups = "drop") %>% | |
| mutate(`Meas Tilt` = sapply(meas_axis, angle_to_clock)) %>% | |
| select(TaggedPitchType, `Meas Tilt`) | |
| summary_df <- pitcher_df %>% | |
| filter(TaggedPitchType %in% types) %>% | |
| group_by(TaggedPitchType) %>% | |
| summarise( | |
| `#` = n(), | |
| Velo = round(mean(RelSpeed, na.rm = TRUE), 1), | |
| `Max Velo` = round(max(RelSpeed, na.rm = TRUE), 1), | |
| IVB = round(mean(InducedVertBreak, na.rm = TRUE), 1), | |
| HB = round(mean(HorzBreak, na.rm = TRUE), 1), | |
| `Total Spin` = round(mean(SpinRate, na.rm = TRUE), 0), | |
| `Spin Eff%` = round(100 * mean(SpinAxis3dSpinEfficiency, na.rm = TRUE), 0), | |
| `Gyro` = round(mean(SpinAxis3dLongitudinalAngle, na.rm = TRUE), 0), | |
| RelH = round(mean(RelHeight, na.rm = TRUE), 2), | |
| RelS = round(mean(RelSide, na.rm = TRUE), 2), | |
| Ext = round(mean(Extension, na.rm = TRUE), 2), | |
| `Zone%` = round(100 * mean(in_zone, na.rm = TRUE), 1), | |
| .groups = "drop" | |
| ) %>% | |
| rename(Pitch = TaggedPitchType) %>% | |
| arrange(desc(`#`)) | |
| summary_df <- summary_df %>% | |
| left_join(observed_tilt, by = c("Pitch" = "TaggedPitchType")) %>% | |
| left_join(measured_tilt, by = c("Pitch" = "TaggedPitchType")) | |
| summary_df <- summary_df %>% | |
| select(Pitch, `#`, Velo, `Max Velo`, IVB, HB, `Total Spin`, | |
| `Spin Eff%`, `Gyro`, `Obs Tilt`, `Meas Tilt`, RelH, RelS, Ext, `Zone%`) | |
| # Add totals row | |
| all_data <- pitcher_df %>% filter(TaggedPitchType %in% types) | |
| obs_axis_all <- round(median(all_data$SpinAxis, na.rm = TRUE), 1) | |
| meas_raw <- all_data$SpinAxis3dTransverseAngle + 180 | |
| meas_raw <- ifelse(meas_raw > 360, meas_raw - 360, meas_raw) | |
| meas_axis_all <- round(median(meas_raw, na.rm = TRUE), 1) | |
| totals_row <- data.frame( | |
| Pitch = "Total", | |
| `#` = sum(summary_df$`#`, na.rm = TRUE), | |
| Velo = round(mean(all_data$RelSpeed, na.rm = TRUE), 1), | |
| `Max Velo` = round(max(all_data$RelSpeed, na.rm = TRUE), 1), | |
| IVB = round(mean(all_data$InducedVertBreak, na.rm = TRUE), 1), | |
| HB = round(mean(all_data$HorzBreak, na.rm = TRUE), 1), | |
| `Total Spin` = round(mean(all_data$SpinRate, na.rm = TRUE), 0), | |
| `Spin Eff%` = round(100 * mean(all_data$SpinAxis3dSpinEfficiency, na.rm = TRUE), 0), | |
| `Gyro` = round(mean(all_data$SpinAxis3dLongitudinalAngle, na.rm = TRUE), 0), | |
| `Obs Tilt` = angle_to_clock(obs_axis_all), | |
| `Meas Tilt` = angle_to_clock(meas_axis_all), | |
| RelH = round(mean(all_data$RelHeight, na.rm = TRUE), 2), | |
| RelS = round(mean(all_data$RelSide, na.rm = TRUE), 2), | |
| Ext = round(mean(all_data$Extension, na.rm = TRUE), 2), | |
| `Zone%` = round(100 * mean(all_data$in_zone, na.rm = TRUE), 1), | |
| check.names = FALSE, | |
| stringsAsFactors = FALSE | |
| ) | |
| summary_df <- dplyr::bind_rows(summary_df, totals_row) | |
| summary_df | |
| } | |
| # Bullpen location plot (no shape manual - simplified) | |
| create_bullpen_location_plot <- function(pitcher_df, pitch_colors) { | |
| filter_types <- pitcher_df %>% | |
| filter(!is.na(TaggedPitchType), TaggedPitchType != "", | |
| TaggedPitchType != "Undefined", TaggedPitchType != "Other") %>% | |
| pull(TaggedPitchType) %>% unique() | |
| df <- pitcher_df %>% | |
| filter(TaggedPitchType %in% filter_types, | |
| !is.na(PlateLocSide), !is.na(PlateLocHeight)) | |
| if (nrow(df) == 0) { | |
| return(ggplot() + theme_void() + labs(title = "Pitch Locations") + | |
| 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) + | |
| geom_point(aes(fill = TaggedPitchType), size = 3.5, shape = 21, | |
| color = "black", stroke = 0.5, alpha = 0.85) + | |
| scale_fill_manual(values = pitch_colors, name = "Pitch") + | |
| coord_fixed(xlim = c(-2.2, 2.2), ylim = c(0, 4.2)) + | |
| labs(title = "Pitch Locations") + | |
| theme_minimal() + | |
| theme( | |
| plot.title = element_text(size = 12, face = "bold", hjust = 0.5), | |
| legend.position = "bottom", | |
| legend.title = element_text(size = 8, face = "bold"), | |
| legend.text = element_text(size = 7), | |
| axis.text = element_blank(), axis.title = element_blank(), | |
| axis.ticks = element_blank(), panel.grid = element_blank(), | |
| plot.margin = margin(2, 2, 2, 2) | |
| ) | |
| } | |
| # Bullpen movement plot | |
| create_bullpen_movement_plot <- function(pitcher_df, pitcher_name, pitch_colors) { | |
| df <- pitcher_df %>% filter(!is.na(TaggedPitchType), | |
| TaggedPitchType != "Other", | |
| TaggedPitchType != "Undefined") | |
| 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_fixed(ratio = 1, xlim = c(-27.5, 27.5), ylim = c(-27.5, 27.5)) + | |
| labs(title = "Movement Profile", x = "Horizontal Break (in)", y = "Induced Vertical Break (in)") + | |
| theme_minimal(base_size = 11) + | |
| theme( | |
| plot.title = element_text(size = 12, face = "bold", hjust = 0.5), | |
| legend.position = "none", | |
| panel.grid.minor = element_blank(), | |
| plot.margin = margin(4, 4, 4, 4) | |
| ) | |
| } | |
| # Bullpen release point plot | |
| create_bullpen_release_plot <- function(pitcher_df, pitcher_name, pitch_colors) { | |
| df <- pitcher_df %>% | |
| filter(!is.na(RelSide), !is.na(RelHeight), | |
| !is.na(TaggedPitchType), TaggedPitchType != "Other", | |
| TaggedPitchType != "Undefined") | |
| if (nrow(df) == 0) { | |
| return(ggplot() + theme_void() + ggtitle("Release Points") + | |
| theme(plot.title = element_text(size = 12, face = "bold", hjust = 0.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 = 5, shape = 21, color = "black", stroke = 0.3, alpha = 1) + | |
| annotate("text", x = -5, y = 8, label = "\u2190 1B", size = 3, hjust = 0) + | |
| annotate("text", x = 5, y = 8, label = "3B \u2192", 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_fixed(ratio = 1, xlim = c(-4, 4), ylim = c(0, 8), clip = "off") + | |
| labs(title = "Release Points", 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", | |
| plot.margin = margin(4, 4, 4, 4) | |
| ) | |
| } | |
| # Main bullpen PDF generator (LANDSCAPE) | |
| create_bullpen_pdf_report <- function(bp_data, pitcher_name, output_file, intent_level = "High") { | |
| 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" | |
| ) | |
| pitcher_df <- bp_data %>% filter(Pitcher == pitcher_name) | |
| if (nrow(pitcher_df) == 0) { | |
| pdf(output_file, width = 11, height = 7) | |
| grid::grid.newpage() | |
| grid::grid.text(paste("No data found for", pitcher_name), | |
| gp = grid::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 "N/A" | |
| }, error = function(e) "N/A") | |
| summary_table <- calculate_bullpen_summary(pitcher_df) | |
| loc_plot <- create_bullpen_location_plot(pitcher_df, pitch_colors) | |
| mov_plot <- create_bullpen_movement_plot(pitcher_df, pitcher_name, pitch_colors) | |
| rel_plot <- create_bullpen_release_plot(pitcher_df, pitcher_name, pitch_colors) | |
| # LANDSCAPE PDF - compact height | |
| pdf(output_file, width = 11, height = 7) | |
| on.exit(try(dev.off(), silent = TRUE), add = TRUE) | |
| grid::grid.newpage() | |
| # ===== HEADER BAR ===== | |
| grid::grid.rect(x = 0, y = 0.945, width = 1, height = 0.055, | |
| just = c("left", "bottom"), | |
| gp = grid::gpar(fill = "#006F71", col = NA)) | |
| grid::grid.text("Bullpen Report", x = 0.02, y = 0.972, just = "left", | |
| gp = grid::gpar(col = "white", fontface = "bold", cex = 1.2)) | |
| # Logo | |
| try({ | |
| logo_img <- magick::image_read("https://i.imgur.com/zjTu3JS.png") | |
| logo_img <- magick::image_resize(logo_img, "x140") | |
| logo_grob <- grid::rasterGrob(as.raster(logo_img), interpolate = TRUE) | |
| grid::pushViewport(grid::viewport(x = 0.988, y = 0.972, | |
| width = 0.10, height = 0.048, | |
| just = c("right", "center"))) | |
| grid::grid.draw(logo_grob) | |
| grid::popViewport() | |
| }, silent = TRUE) | |
| # ===== INFO LINE ===== | |
| intent_color <- switch(intent_level, | |
| "Low" = "#2980B9", | |
| "Medium" = "#F39C12", | |
| "High" = "#E74C3C", | |
| "black" | |
| ) | |
| grid::grid.text(paste0(game_date, " | ", pitcher_name, " | Intent: "), | |
| x = 0.02, y = 0.92, just = "left", | |
| gp = grid::gpar(cex = 0.85, fontface = "bold", col = "black")) | |
| # Measure text width for intent label positioning | |
| info_base <- paste0(game_date, " | ", pitcher_name, " | Intent: ") | |
| info_grob <- grid::textGrob(info_base, gp = grid::gpar(cex = 0.85, fontface = "bold")) | |
| info_width <- grid::convertWidth(grid::grobWidth(info_grob), "npc", valueOnly = TRUE) | |
| grid::grid.text(intent_level, | |
| x = 0.02 + info_width, y = 0.92, just = "left", | |
| gp = grid::gpar(cex = 0.90, fontface = "bold", col = intent_color)) | |
| # ===== SUMMARY TABLE ===== | |
| if (nrow(summary_table) > 0) { | |
| headers <- names(summary_table) | |
| num_cols <- length(headers) | |
| pitch_w <- 0.08 | |
| remaining_w <- (0.96 - pitch_w) / (num_cols - 1) | |
| col_widths <- c(pitch_w, rep(remaining_w, num_cols - 1)) | |
| x_start <- 0.5 - sum(col_widths) / 2 | |
| x_pos <- c(x_start, x_start + cumsum(col_widths[-length(col_widths)])) | |
| row_h <- 0.038 | |
| y_top <- 0.89 | |
| header_cex <- 0.65 | |
| cell_cex <- 0.65 | |
| # Draw headers | |
| for (i in seq_along(headers)) { | |
| grid::grid.rect(x = x_pos[i], y = y_top, width = col_widths[i] * 0.985, height = row_h, | |
| just = c("left", "top"), | |
| gp = grid::gpar(fill = "#006F71", col = "black", lwd = 0.5)) | |
| grid::grid.text(headers[i], | |
| x = x_pos[i] + col_widths[i] * 0.49, y = y_top - row_h * 0.5, | |
| gp = grid::gpar(col = "white", cex = header_cex, fontface = "bold")) | |
| } | |
| # Draw data rows | |
| for (r in seq_len(nrow(summary_table))) { | |
| y_row <- y_top - r * row_h | |
| is_totals <- (r == nrow(summary_table) && summary_table$Pitch[r] == "Total") | |
| for (i in seq_along(headers)) { | |
| val <- as.character(summary_table[[i]][r]) | |
| if (is.na(val) || val == "NaN" || val == "Inf" || val == "-Inf") val <- "-" | |
| bg <- ifelse(r %% 2 == 0, "#f7f7f7", "white") | |
| txt_col <- "black" | |
| font_face <- "plain" | |
| # Totals row styling | |
| if (is_totals) { | |
| bg <- "#e0e0e0" | |
| font_face <- "bold" | |
| } | |
| # Pitch column coloring | |
| if (i == 1 && !is_totals) { | |
| pitch_name <- summary_table$Pitch[r] | |
| if (!is.na(pitch_name) && pitch_name %in% names(pitch_colors)) { | |
| bg <- pitch_colors[[pitch_name]] | |
| rgb_vals <- grDevices::col2rgb(bg) / 255 | |
| luminance <- 0.2126 * rgb_vals[1] + 0.7152 * rgb_vals[2] + 0.0722 * rgb_vals[3] | |
| txt_col <- ifelse(luminance < 0.5, "white", "black") | |
| } | |
| font_face <- "bold" | |
| } else if (i == 1 && is_totals) { | |
| font_face <- "bold" | |
| } | |
| grid::grid.rect(x = x_pos[i], y = y_row, width = col_widths[i] * 0.985, height = row_h, | |
| 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_h * 0.5, | |
| gp = grid::gpar(cex = cell_cex, col = txt_col, fontface = font_face)) | |
| } | |
| } | |
| table_bottom <- y_top - (nrow(summary_table) + 1) * row_h | |
| } else { | |
| table_bottom <- 0.85 | |
| } | |
| # ===== THREE CHARTS ===== | |
| chart_top <- table_bottom - 0.025 | |
| chart_h <- min(0.42, chart_top - 0.05) # cap height so charts don't stretch | |
| chart_w <- 0.32 | |
| grid::pushViewport(grid::viewport(x = 0.18, y = chart_top, width = chart_w, height = chart_h, | |
| just = c("center", "top"))) | |
| tryCatch(print(loc_plot, newpage = FALSE), error = function(e) NULL) | |
| grid::popViewport() | |
| grid::pushViewport(grid::viewport(x = 0.50, y = chart_top, width = chart_w, height = chart_h, | |
| just = c("center", "top"))) | |
| tryCatch(print(mov_plot, newpage = FALSE), error = function(e) NULL) | |
| grid::popViewport() | |
| grid::pushViewport(grid::viewport(x = 0.82, y = chart_top, width = chart_w, height = chart_h, | |
| just = c("center", "top"))) | |
| tryCatch(print(rel_plot, newpage = FALSE), error = function(e) NULL) | |
| grid::popViewport() | |
| grid::grid.text("Data: TrackMan | Report Generated: Coastal Carolina Baseball Analytics", | |
| x = 0.5, y = 0.02, gp = grid::gpar(cex = 0.65, col = "grey50")) | |
| invisible(output_file) | |
| } | |
| 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" = "magenta", | |
| "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(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_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_compute_ground_intersection <- function(df) { | |
| df %>% mutate( | |
| .a = 0.5 * az0, .b = vz0, .c = z0, | |
| .disc = .b^2 - 4 * .a * .c, | |
| .t1 = ifelse(.disc >= 0, (-.b + sqrt(pmax(.disc,0)))/(2*.a), NA_real_), | |
| .t2 = ifelse(.disc >= 0, (-.b - sqrt(pmax(.disc,0)))/(2*.a), NA_real_), | |
| t_ground = pmin(ifelse(.t1>0,.t1,Inf), ifelse(.t2>0,.t2,Inf)), | |
| t_ground = ifelse(is.finite(t_ground), t_ground, NA_real_), | |
| x_ground = x0 + vx0*t_ground + 0.5*ax0*t_ground^2, | |
| y_ground = y0 + vy0*t_ground + 0.5*ay0*t_ground^2, | |
| in_dirt = !is.na(t_ground) | |
| ) %>% select(-starts_with(".a"), -starts_with(".b"), -starts_with(".c"), | |
| -starts_with(".disc"), -starts_with(".t1"), -starts_with(".t2")) | |
| } | |
| 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") | |
| .classify_block_type <- function(notes) { | |
| dplyr::case_when( | |
| grepl("^block$", notes, ignore.case = TRUE) ~ "Block", | |
| grepl("pbwp|pb/wp|pb\\+wp|wild\\s*pitch|passed\\s*ball|wp|pb", notes, ignore.case = TRUE) ~ "PB/WP", | |
| grepl("block", notes, ignore.case = TRUE) ~ "Block", | |
| TRUE ~ "Block") | |
| } | |
| .is_block_event <- function(notes) { | |
| grepl("block|pbwp|pb/wp|pb\\+wp|wild\\s*pitch|passed\\s*ball|\\bwp\\b|\\bpb\\b", notes, ignore.case = TRUE) | |
| } | |
| # ===================================================================== | |
| # BLOCKING PLOT — ground intersection (numbered points inside, black border) | |
| # ===================================================================== | |
| catcher_create_blocking_plot <- function(catcher_data, catcher_name) { | |
| req <- c("x0","vx0","ax0","y0","vy0","ay0","z0","vz0","az0") | |
| block_df <- catcher_data %>% filter(Catcher == catcher_name, .is_block_event(Notes)) %>% | |
| filter(!is.na(x0) & !is.na(z0) & !is.na(az0)) | |
| if (all(req %in% names(block_df)) && nrow(block_df) > 0) { | |
| block_df <- catcher_compute_ground_intersection(block_df) | |
| } else { return(ggplot() + theme_void() + ggtitle("Blocking (Overhead View)") + | |
| theme(plot.title = element_text(hjust=.5, size=9, face="bold"))) } | |
| if (nrow(block_df)==0) return(ggplot() + theme_void() + ggtitle("Ground Intersection") + | |
| theme(plot.title = element_text(hjust=.5, size=9, face="bold"))) | |
| block_df <- block_df %>% mutate(plot_x=x_ground, plot_y=y_ground, | |
| block_type=.classify_block_type(Notes), row_num=row_number()) | |
| plate <- data.frame(x=c(-.708,.708,.708,0,-.708), y=c(1.417,1.417,.708,0,.708)) | |
| bi<-1; bo<-4; bymin<--2.5; bymax<-3.7 | |
| xr <- range(c(block_df$plot_x,-bo,bo),na.rm=TRUE); yr <- range(c(block_df$plot_y,bymin,bymax),na.rm=TRUE) | |
| xl <- c(xr[1]-1, xr[2]+1); yl <- c(yr[1]-1, yr[2]+1) | |
| xs <- diff(xl); ys <- diff(yl) | |
| if(ys > xs*1.8){ n<-ys/1.5; e<-(n-xs)/2; xl<-xl+c(-e,e) } | |
| else if(xs > ys*1.8){ n<-xs/1.5; e<-(n-ys)/2; yl<-yl+c(-e,e) } | |
| tc <- block_df %>% count(TaggedPitchType) %>% mutate(label=paste0(TaggedPitchType,": ",n)) | |
| cl <- setNames(tc$label, tc$TaggedPitchType) | |
| sl <- unique(block_df$block_type) | |
| sv <- c("Block"=21,"PB/WP"=24)[sl]; slb <- c("Block"="Block","PB/WP"="PB/WP")[sl] | |
| ggplot(block_df, aes(plot_x,plot_y)) + | |
| annotate("rect",xmin=bi,xmax=bo,ymin=bymin,ymax=bymax,fill=NA,color="black",linewidth=.8) + | |
| annotate("rect",xmin=-bo,xmax=-bi,ymin=bymin,ymax=bymax,fill=NA,color="black",linewidth=.8) + | |
| geom_polygon(data=plate,aes(x,y),fill=NA,color="black",linewidth=.8,inherit.aes=FALSE) + | |
| geom_point(aes(fill=TaggedPitchType,shape=block_type),size=3,color="black",stroke=.6) + | |
| geom_text(aes(label=row_num),size=2.0,fontface="bold",color="white") + | |
| scale_fill_manual(values=pitch_colors,na.value="grey60",name="PitchType",labels=cl) + | |
| scale_shape_manual(values=sv,name=NULL,labels=slb) + | |
| coord_fixed(xlim=xl,ylim=yl) + labs(title="Blocking (Overhead View)",x=NULL,y=NULL) + | |
| theme_void(base_size=9) + | |
| theme(plot.title=element_text(size=9,face="bold",hjust=.5), | |
| legend.position="bottom",legend.box="vertical",legend.spacing.y=unit(2,"pt"), | |
| legend.title=element_text(size=7,face="bold"),legend.text=element_text(size=6), | |
| legend.key.size=unit(.5,"lines"),plot.margin=margin(2,2,2,2)) + | |
| guides(fill=guide_legend(order=1,nrow=2,override.aes=list(size=3,shape=21)), | |
| shape=guide_legend(order=2,nrow=1,override.aes=list(size=3,fill="#4472C4"))) | |
| } | |
| # ===================================================================== | |
| # BLOCKING ZONE PLOT (numbered points inside, black border) | |
| # ===================================================================== | |
| catcher_create_blocking_zone_plot <- function(catcher_data, catcher_name) { | |
| block_df <- catcher_data %>% filter(Catcher==catcher_name, .is_block_event(Notes)) %>% | |
| filter(!is.na(PlateLocSide) & !is.na(PlateLocHeight)) | |
| if(nrow(block_df)==0) return(ggplot2::ggplot()+ggplot2::theme_void()+ggplot2::ggtitle("Blocking (Zone)")+ | |
| ggplot2::theme(plot.title=ggplot2::element_text(hjust=.5,size=8,face="bold"))) | |
| block_df <- block_df %>% mutate(block_type=.classify_block_type(Notes), row_num=row_number()) | |
| szr<-data.frame(xmin=-.83083,xmax=.83083,ymin=1.5,ymax=3.3775) | |
| bzr<-data.frame(xmin=-.9975,xmax=.9975,ymin=1.3775,ymax=3.5) | |
| hp<-data.frame(x=c(-.708,.708,.708,0,-.708),y=c(.15,.15,.3,.5,.3)) | |
| xp<-.5;yp<-.5 | |
| xl<-c(min(-1.8,min(block_df$PlateLocSide,na.rm=T)-xp),max(1.8,max(block_df$PlateLocSide,na.rm=T)+xp)) | |
| yl<-c(min(-.5,min(block_df$PlateLocHeight,na.rm=T)-yp),max(4.5,max(block_df$PlateLocHeight,na.rm=T)+yp)) | |
| sl<-unique(block_df$block_type);sv<-c("Block"=21,"PB/WP"=24)[sl];slb<-c("Block"="Block","PB/WP"="PB/WP")[sl] | |
| tc<-block_df%>%count(TaggedPitchType)%>%mutate(label=paste0(TaggedPitchType,": ",n)) | |
| cl<-setNames(tc$label,tc$TaggedPitchType) | |
| ggplot2::ggplot(block_df,ggplot2::aes(PlateLocSide,PlateLocHeight))+ | |
| ggplot2::geom_rect(data=bzr,ggplot2::aes(xmin=xmin,xmax=xmax,ymin=ymin,ymax=ymax),fill=NA,color="gray50",linewidth=.6,linetype="dotted",inherit.aes=FALSE)+ | |
| ggplot2::geom_rect(data=szr,ggplot2::aes(xmin=xmin,xmax=xmax,ymin=ymin,ymax=ymax),fill=NA,color="black",linewidth=.8,inherit.aes=FALSE)+ | |
| ggplot2::geom_polygon(data=hp,ggplot2::aes(x=x,y=y),fill=NA,color="gray40",linewidth=.5,inherit.aes=FALSE)+ | |
| ggplot2::geom_point(ggplot2::aes(fill=TaggedPitchType,shape=block_type),size=3,color="black",stroke=.6,na.rm=TRUE)+ | |
| ggplot2::geom_text(ggplot2::aes(label=row_num),size=2.0,fontface="bold",color="white",na.rm=TRUE)+ | |
| ggplot2::scale_fill_manual(values=pitch_colors,na.value="grey60",name="Pitch Type",labels=cl)+ | |
| ggplot2::scale_shape_manual(values=sv,name=NULL,labels=slb)+ | |
| ggplot2::coord_equal(xlim=xl,ylim=yl)+ggplot2::labs(title="Blocking (Zone)")+ggplot2::theme_void()+ | |
| ggplot2::theme(plot.title=ggplot2::element_text(hjust=.5,size=8,face="bold"),plot.margin=ggplot2::margin(2,2,2,2), | |
| legend.position="bottom",legend.box="vertical",legend.spacing.y=ggplot2::unit(2,"pt"), | |
| legend.title=ggplot2::element_text(size=7,face="bold"),legend.text=ggplot2::element_text(size=6), | |
| legend.key.size=ggplot2::unit(.5,"lines"))+ | |
| ggplot2::guides(fill=ggplot2::guide_legend(order=1,nrow=2,override.aes=list(size=3,shape=21)), | |
| shape=ggplot2::guide_legend(order=2,nrow=1,override.aes=list(size=3,fill="#4472C4"))) | |
| } | |
| # ===================================================================== | |
| # FRAMING PLOTS — numbered points inside circles with black border | |
| # Returns list(p1, p2, legend) | |
| # ===================================================================== | |
| catcher_create_framing_plots <- function(catcher_data, catcher_name) { | |
| df <- dplyr::filter(catcher_data, Catcher==catcher_name, is_swing==0) | |
| sa <- df %>% filter(frame=="Strike Added") %>% mutate(row_num=row_number()) | |
| sl <- df %>% filter(frame=="Strike Lost") %>% mutate(row_num=row_number()) | |
| apt <- unique(c(sa$TaggedPitchType, sl$TaggedPitchType)); apt <- apt[!is.na(apt)] | |
| szr<-data.frame(xmin=-.83083,xmax=.83083,ymin=1.5,ymax=3.3775) | |
| bzr<-data.frame(xmin=-.9975,xmax=.9975,ymin=1.3775,ymax=3.5) | |
| hp<-data.frame(x=c(-.60,.60,.60,0,-.60),y=c(.15,.15,.27,.42,.27)) | |
| mp <- function(data,title){ | |
| if(!nrow(data)) return(ggplot2::ggplot()+ggplot2::theme_void()+ggplot2::ggtitle(title)+ | |
| ggplot2::theme(plot.title=ggplot2::element_text(hjust=.5,size=10,face="bold"))) | |
| ggplot2::ggplot(data,ggplot2::aes(PlateLocSide,PlateLocHeight))+ | |
| ggplot2::geom_rect(data=bzr,ggplot2::aes(xmin=xmin,xmax=xmax,ymin=ymin,ymax=ymax),fill=NA,color="gray50",linewidth=.5,linetype="dotted",inherit.aes=FALSE)+ | |
| ggplot2::geom_rect(data=szr,ggplot2::aes(xmin=xmin,xmax=xmax,ymin=ymin,ymax=ymax),fill=NA,color="black",linewidth=.7,inherit.aes=FALSE)+ | |
| ggplot2::geom_polygon(data=hp,ggplot2::aes(x=x,y=y),fill=NA,color="gray40",linewidth=.4,inherit.aes=FALSE)+ | |
| ggplot2::geom_point(ggplot2::aes(fill=TaggedPitchType),shape=21,size=5,color="black",stroke=.6,alpha=.95,na.rm=TRUE)+ | |
| ggplot2::geom_text(ggplot2::aes(label=row_num),size=2.0,fontface="bold",color="white",na.rm=TRUE)+ | |
| ggplot2::scale_fill_manual(values=pitch_colors,na.value="grey60",name="Pitch Type",drop=FALSE,limits=apt)+ | |
| ggplot2::coord_equal()+ggplot2::scale_x_continuous(limits=c(-1.8,1.8))+ggplot2::scale_y_continuous(limits=c(0,4.5))+ | |
| ggplot2::labs(title=title)+ggplot2::theme_classic()+ | |
| ggplot2::theme(axis.title=ggplot2::element_blank(),axis.text=ggplot2::element_blank(), | |
| axis.ticks=ggplot2::element_blank(),axis.line=ggplot2::element_blank(), | |
| panel.grid=ggplot2::element_blank(), | |
| plot.title=ggplot2::element_text(hjust=.5,size=10,face="bold"), | |
| plot.margin=ggplot2::margin(2,2,2,2),legend.position="none") | |
| } | |
| lp <- ggplot2::ggplot(data.frame(x=seq_along(apt),y=seq_along(apt), | |
| pt=factor(apt,levels=apt)),ggplot2::aes(x,y,fill=pt))+ | |
| ggplot2::geom_point(shape=21,size=3,color="black",stroke=.5)+ | |
| ggplot2::scale_fill_manual(values=pitch_colors[apt],name="Pitch Type")+ggplot2::theme_void()+ | |
| ggplot2::theme(legend.position="bottom",legend.title=ggplot2::element_text(size=8,face="bold"), | |
| legend.text=ggplot2::element_text(size=7),legend.key.size=ggplot2::unit(.6,"lines"), | |
| legend.box="horizontal")+ | |
| ggplot2::guides(fill=ggplot2::guide_legend(nrow=1,override.aes=list(shape=21))) | |
| tmp<-ggplot2::ggplot_gtable(ggplot2::ggplot_build(lp)) | |
| li<-which(sapply(tmp$grobs,function(g) g$name)=="guide-box") | |
| lg<-if(length(li)>0) tmp$grobs[[li[1]]] else grid::nullGrob() | |
| list(p1=mp(sa,"Strikes Stolen"), p2=mp(sl,"Strikes Lost"), legend=lg) | |
| } | |
| 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) { | |
| td <- catcher_data %>% | |
| filter(Catcher == catcher_name) %>% | |
| filter(tolower(Notes) %in% c('2b out','2b safe','3b out','3b safe')) | |
| if(!nrow(td)) return(ggplot()+theme_void()+ggtitle("No throwing data available")+ | |
| theme(plot.title=element_text(hjust=.5,size=11,face="bold"))) | |
| ggplot(td)+ | |
| geom_polygon(data=data.frame(x=c(-10,10,10,-10),y=c(.25,.25,8,8)),aes(x,y),fill='darkcyan',color='darkcyan')+ | |
| geom_polygon(data=data.frame(x=c(-10,10,10,-10),y=c(8,8,9,9)),aes(x,y),fill='yellow',color='yellow')+ | |
| geom_polygon(data=data.frame(x=c(-10,10,10,-10),y=c(-2,-2,.25,.25)),aes(x,y),fill='brown',color='brown')+ | |
| geom_polygon(data=data.frame(x=c(-10,10,10,-10),y=c(-5,-5,-2,-2)),aes(x,y),fill='darkgreen',color='darkgreen')+ | |
| geom_polygon(data=data.frame(x=c(-1,1,1,-1),y=c(0,0,.45,.45)),aes(x,y),fill='white',color='black')+ | |
| geom_polygon(data=data.frame(x=c(-1,0,0,-1),y=c(0,0,.45,.45)),aes(x,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)}) | |
| tt<-paste(catcher_name,"- Catcher Report"); st<-if(!is.na(game_date)) paste("Game Date:",game_date) else "" | |
| ig<-nullGrob() | |
| if(!is.null(bio_data)&&nrow(bio_data)>0){ | |
| cb<-bio_data%>%filter(Catcher==catcher_name) | |
| if(nrow(cb)>0&&"Headshot"%in%names(cb)){u<-cb$Headshot[1] | |
| if(!is.na(u)&&nzchar(u)){im<-try(magick::image_read(u),silent=TRUE) | |
| if(!inherits(im,"try-error")) ig<-rasterGrob(as.raster(im),interpolate=TRUE)}}} | |
| ll<-rasterGrob(as.raster(magick::image_resize(magick::image_read("https://i.ibb.co/gLfTW4Fz/t-GPe-TPu.png"),"x120"))) | |
| rl<-rasterGrob(as.raster(magick::image_resize(magick::image_read("https://i.imgur.com/zjTu3JS.png"),"x120"))) | |
| tg<-textGrob(tt,gp=gpar(fontsize=18,fontface="bold",col="#006F71")) | |
| sg<-textGrob(st,gp=gpar(fontsize=11)) | |
| arrangeGrob(arrangeGrob(ll,tg,rl,ncol=3,widths=c(.15,.7,.15)),sg,ncol=1,heights=c(.7,.3)) | |
| } | |
| # ===================================================================== | |
| # PDF GENERATION | |
| # ===================================================================== | |
| 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); game_key <- format(game_day,"%Y-%m-%d") | |
| req9p <- c("x0","vx0","ax0","y0","vy0","ay0","z0","vz0","az0") | |
| has_9p <- all(req9p %in% names(catcher_df)) | |
| # ---- Stats ---- | |
| rs <- catcher_df %>% summarise(ss=sum(StolenStrike,na.rm=T),sl=sum(StrikeLost,na.rm=T), | |
| gpm=sum(StolenStrike,na.rm=T)-sum(StrikeLost,na.rm=T),.groups="drop") | |
| ocn <- game_df%>%filter(CatcherTeam!="COA_CHA")%>%pull(Catcher)%>%na.omit()%>%{names(sort(table(.),decreasing=TRUE))[1]} | |
| ors <- game_df%>%filter(Catcher==ocn)%>%summarise(ss=sum(StolenStrike,na.rm=T),sl=sum(StrikeLost,na.rm=T), | |
| gpm=sum(StolenStrike,na.rm=T)-sum(StrikeLost,na.rm=T),.groups="drop") | |
| # ---- Framing log (ALL rows, no cap) with Inning and Velo ---- | |
| has_velo_f <- "RelSpeed" %in% names(catcher_df) | |
| has_inning_f <- "Inning" %in% names(catcher_df) | |
| pitch_log <- catcher_df %>% filter(StolenStrike==1|StrikeLost==1) %>% mutate(row_num=row_number()) %>% | |
| mutate(Pitch=TaggedPitchType, Actual=ifelse(StrikeZoneIndicator==1,"STRIKE","BALL"), | |
| Velo=if(has_velo_f) round(RelSpeed,1) else NA_real_, | |
| Inn=if(has_inning_f) as.character(Inning) else NA_character_) %>% | |
| select(row_num,PitchNo,Pitch,Pitcher,Batter,PitchCall,Actual,Velo,Inn) | |
| # ---- Throwing log ---- | |
| throw_log <- catcher_df%>%filter(Notes%in%c('2b out','2b safe','3b out','3b safe'))%>% | |
| select(PitchNo,Pitcher,Catcher,ThrowSpeed,PopTime,ExchangeTime,Notes) | |
| # ---- Blocking log ---- | |
| block_log <- catcher_df%>%filter(.is_block_event(Notes)) | |
| if(has_9p&&nrow(block_log)>0){ | |
| block_log<-block_log%>%filter(!is.na(x0)&!is.na(z0)&!is.na(az0))%>%catcher_compute_ground_intersection() | |
| } else { block_log<-block_log%>%mutate(x_ground=NA_real_,y_ground=NA_real_,t_ground=NA_real_,in_dirt=FALSE) } | |
| hcc<-all(c("Balls","Strikes")%in%names(block_log)); hvc<-"RelSpeed"%in%names(block_log); hic<-"Inning"%in%names(block_log) | |
| block_log<-block_log%>%mutate(block_type=.classify_block_type(Notes),row_num=row_number(), | |
| Count=if(hcc) paste0(Balls,"-",Strikes) else NA_character_, | |
| Velo=if(hvc) round(RelSpeed,1) else NA_real_, | |
| Inn=if(hic) as.character(Inning) else NA_character_)%>% | |
| select(row_num,PitchNo,Pitcher,Catcher,Batter,TaggedPitchType,Notes,block_type,Count,Velo,Inn,x_ground,y_ground,in_dirt) | |
| # ---- Grobs ---- | |
| fp<-catcher_create_framing_plots(game_df,catcher_name) | |
| fg1<-ggplotGrob(fp$p1); fg2<-ggplotGrob(fp$p2); flg<-fp$legend | |
| tg<-ggplotGrob(catcher_create_throwing_plot(game_df,catcher_name)) | |
| bg<-ggplotGrob(catcher_create_blocking_plot(game_df,catcher_name)) | |
| bzg<-ggplotGrob(catcher_create_blocking_zone_plot(game_df,catcher_name)) | |
| # ---- Render PDF ---- | |
| n_framing_rows <- nrow(pitch_log) | |
| rh_f <- .011 # framing table row height | |
| needs_page2 <- n_framing_rows > 12 # if >12, push throwing down, blocking to page 2 | |
| pdf(output_file, width=10.5, height=15) | |
| tryCatch({ | |
| grid::grid.newpage() | |
| # HEADER | |
| pushViewport(viewport(x=.5,y=.98,width=.94,height=.055,just=c("center","top"))) | |
| grid.draw(catcher_create_simple_header(catcher_name,game_key,bio_data)); popViewport() | |
| # RECEIVING | |
| grid.text("Receiving",x=.5,y=.915,gp=gpar(fontface="bold",cex=1.1,col="#006F71")) | |
| hr<-c("CCU Strikes Stolen","CCU Strikes Lost","CCU Game +/-"); vr<-c(rs$ss,rs$sl,rs$gpm) | |
| cw<-.18; x0t<-.5-(3*cw)/2; yh<-.905; yv<-.890 | |
| for(i in 1:3){xi<-x0t+(i-1)*cw | |
| grid.rect(x=xi,y=yh,width=cw*.985,height=.013,just=c("left","top"),gp=gpar(fill="#006F71",col="black",lwd=.5)) | |
| grid.text(hr[i],x=xi+cw*.49,y=yh-.0065,gp=gpar(col="white",cex=.60,fontface="bold")) | |
| grid.rect(x=xi,y=yv,width=cw*.985,height=.013,just=c("left","top"),gp=gpar(fill="white",col="black",lwd=.4)) | |
| grid.text(as.character(vr[i]),x=xi+cw*.49,y=yv-.0065,gp=gpar(cex=.60))} | |
| ho<-c("Opp Strikes Stolen","Opp Strikes Lost","Opp Game +/-"); vo<-c(ors$ss,ors$sl,ors$gpm) | |
| yho<-.872; yvo<-.857 | |
| for(i in 1:3){xi<-x0t+(i-1)*cw | |
| grid.rect(x=xi,y=yho,width=cw*.985,height=.013,just=c("left","top"),gp=gpar(fill="#006F71",col="black",lwd=.5)) | |
| grid.text(ho[i],x=xi+cw*.49,y=yho-.0065,gp=gpar(col="white",cex=.60,fontface="bold")) | |
| grid.rect(x=xi,y=yvo,width=cw*.985,height=.013,just=c("left","top"),gp=gpar(fill="white",col="black",lwd=.4)) | |
| grid.text(as.character(vo[i]),x=xi+cw*.49,y=yvo-.0065,gp=gpar(cex=.60))} | |
| # FRAMING SHARED LEGEND | |
| pushViewport(viewport(x=.5,y=.843,width=.60,height=.015,just=c("center","top"))) | |
| grid.draw(flg); popViewport() | |
| # FRAMING PLOTS | |
| pushViewport(viewport(x=.25,y=.825,width=.48,height=.26,just=c("center","top"))) | |
| grid.draw(fg1); popViewport() | |
| pushViewport(viewport(x=.75,y=.825,width=.48,height=.26,just=c("center","top"))) | |
| grid.draw(fg2); popViewport() | |
| # ======== FRAMING TABLE ======== | |
| yft<-.545 | |
| if(n_framing_rows > 0){ | |
| hfv<-!all(is.na(pitch_log$Velo)); hfi<-!all(is.na(pitch_log$Inn)) | |
| hf<-c("#","PitchNo","Pitch","Pitcher","Batter","PitchCall","Actual") | |
| wf<-c(.035,.055,.08,.13,.13,.10,.08) | |
| if(hfv){ hf<-c(hf,"Velo"); wf<-c(wf,.05) } | |
| if(hfi){ hf<-c(hf,"Inn"); wf<-c(wf,.035) } | |
| xs<-.5-sum(wf)/2; xp<-c(xs,xs+cumsum(wf[-length(wf)])); yt<-yft | |
| for(i in seq_along(hf)){ | |
| grid.rect(x=xp[i],y=yt,width=wf[i]*.985,height=rh_f,just=c("left","top"),gp=gpar(fill="#006F71",col="black",lwd=.4)) | |
| grid.text(hf[i],x=xp[i]+wf[i]*.49,y=yt-rh_f*.5,gp=gpar(col="white",cex=.52,fontface="bold"))} | |
| mr<-min(30,n_framing_rows) | |
| for(r in 1:mr){yr<-yt-r*rh_f | |
| rv<-c(pitch_log$row_num[r],pitch_log$PitchNo[r],pitch_log$Pitch[r], | |
| pitch_log$Pitcher[r],pitch_log$Batter[r],pitch_log$PitchCall[r],pitch_log$Actual[r]) | |
| if(hfv) rv<-c(rv,ifelse(is.na(pitch_log$Velo[r]),"\u2014",pitch_log$Velo[r])) | |
| if(hfi) rv<-c(rv,ifelse(is.na(pitch_log$Inn[r]),"\u2014",pitch_log$Inn[r])) | |
| fc<-ifelse(pitch_log$Actual[r]=="STRIKE","#90EE90","#FFB6C1") | |
| for(i in seq_along(rv)){ | |
| bf<-if(i==7) fc else ifelse(r%%2==0,"#f7f7f7","white") | |
| grid.rect(x=xp[i],y=yr,width=wf[i]*.985,height=rh_f,just=c("left","top"),gp=gpar(fill=bf,col="grey80",lwd=.3)) | |
| grid.text(as.character(rv[i]),x=xp[i]+wf[i]*.49,y=yr-rh_f*.5,gp=gpar(cex=.48))}} | |
| # Calculate where the framing table ends | |
| framing_tbl_bottom <- yt - (mr + 1) * rh_f | |
| } else { | |
| framing_tbl_bottom <- yft - .02 | |
| } | |
| # ======== THROWING SECTION ======== | |
| # Position dynamically below framing table | |
| yts <- framing_tbl_bottom - .015 | |
| grid.text("Throwing",x=.5,y=yts,gp=gpar(fontface="bold",cex=1.1,col="#006F71")) | |
| pushViewport(viewport(x=.28,y=yts-.015,width=.50,height=.16,just=c("center","top"))) | |
| grid.draw(tg); popViewport() | |
| ytt<-yts-.035 | |
| if(nrow(throw_log)>0){ | |
| ht<-c("PitchNo","Pitcher","ThrowSpd","PopTime","Exch","Notes") | |
| wt<-c(.05,.10,.06,.06,.06,.06); twt<-sum(wt); xst<-.78-twt/2 | |
| xpt<-c(xst,xst+cumsum(wt[-length(wt)])); rht<-.011; ytt2<-ytt | |
| for(i in seq_along(ht)){ | |
| grid.rect(x=xpt[i],y=ytt2,width=wt[i]*.985,height=rht,just=c("left","top"),gp=gpar(fill="#006F71",col="black",lwd=.4)) | |
| grid.text(ht[i],x=xpt[i]+wt[i]*.49,y=ytt2-rht*.5,gp=gpar(col="white",cex=.50,fontface="bold"))} | |
| mrt<-min(6,nrow(throw_log)) | |
| for(r in 1:mrt){yrt<-ytt2-r*rht | |
| rvt<-c(throw_log$PitchNo[r],throw_log$Pitcher[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(rvt)){bf<-ifelse(r%%2==0,"#f7f7f7","white") | |
| grid.rect(x=xpt[i],y=yrt,width=wt[i]*.985,height=rht,just=c("left","top"),gp=gpar(fill=bf,col="grey80",lwd=.3)) | |
| grid.text(as.character(rvt[i]),x=xpt[i]+wt[i]*.49,y=yrt-rht*.5,gp=gpar(cex=.48))}} | |
| } else { grid.text("No throwing data",x=.78,y=ytt-.04,gp=gpar(cex=.7,col="grey50",fontface="italic")) } | |
| # ======== BLOCKING SECTION ======== | |
| if (!needs_page2) { | |
| # Blocking fits on page 1 below throwing | |
| throw_bottom <- yts - .19 | |
| ybs <- throw_bottom - .01 | |
| } else { | |
| # Blocking goes on page 2 | |
| ybs <- NULL | |
| } | |
| .draw_blocking_section <- function(ybs_pos) { | |
| grid.text("Blocking",x=.5,y=ybs_pos,gp=gpar(fontface="bold",cex=1.1,col="#006F71")) | |
| pushViewport(viewport(x=.17,y=ybs_pos-.012,width=.32,height=.155,just=c("center","top"))) | |
| grid.draw(bzg); popViewport() | |
| pushViewport(viewport(x=.47,y=ybs_pos-.012,width=.32,height=.155,just=c("center","top"))) | |
| grid.draw(bg); popViewport() | |
| ybt<-ybs_pos-.025 | |
| if(nrow(block_log)>0){ | |
| hc<-!all(is.na(block_log$Count)); hv<-!all(is.na(block_log$Velo)); hi<-!all(is.na(block_log$Inn)) | |
| hl<-c("#"); wl<-c(.02) | |
| hl<-c(hl,"Pitcher"); wl<-c(wl,.07) | |
| hl<-c(hl,"Batter"); wl<-c(wl,.07) | |
| hl<-c(hl,"Pitch"); wl<-c(wl,.05) | |
| hl<-c(hl,"Type"); wl<-c(wl,.035) | |
| if(hv){ hl<-c(hl,"Velo"); wl<-c(wl,.03) } | |
| if(hc){ hl<-c(hl,"Count"); wl<-c(wl,.03) } | |
| if(hi){ hl<-c(hl,"Inn"); wl<-c(wl,.025) } | |
| twb<-sum(wl); xsb<-.97-twb; xpb<-c(xsb,xsb+cumsum(wl[-length(wl)])) | |
| rhb<-.010; ytb<-ybt | |
| for(i in seq_along(hl)){ | |
| grid.rect(x=xpb[i],y=ytb,width=wl[i]*.985,height=rhb,just=c("left","top"),gp=gpar(fill="#006F71",col="black",lwd=.4)) | |
| grid.text(hl[i],x=xpb[i]+wl[i]*.49,y=ytb-rhb*.5,gp=gpar(col="white",cex=.43,fontface="bold"))} | |
| mrb<-min(14,nrow(block_log)) | |
| for(r in 1:mrb){yrb<-ytb-r*rhb | |
| vs<-c(block_log$row_num[r],block_log$Pitcher[r],block_log$Batter[r], | |
| block_log$TaggedPitchType[r],block_log$block_type[r]) | |
| if(hv) vs<-c(vs,ifelse(is.na(block_log$Velo[r]),"\u2014",block_log$Velo[r])) | |
| if(hc) vs<-c(vs,ifelse(is.na(block_log$Count[r]),"\u2014",block_log$Count[r])) | |
| if(hi) vs<-c(vs,ifelse(is.na(block_log$Inn[r]),"\u2014",block_log$Inn[r])) | |
| for(i in seq_along(vs)){bf<-ifelse(r%%2==0,"#f7f7f7","white") | |
| grid.rect(x=xpb[i],y=yrb,width=wl[i]*.985,height=rhb,just=c("left","top"),gp=gpar(fill=bf,col="grey80",lwd=.3)) | |
| grid.text(as.character(vs[i]),x=xpb[i]+wl[i]*.49,y=yrb-rhb*.5,gp=gpar(cex=.42))}} | |
| } else { grid.text("No blocking data",x=.85,y=ybt-.04,gp=gpar(cex=.7,col="grey50",fontface="italic")) } | |
| } | |
| if (!needs_page2) { | |
| # Draw blocking on page 1 | |
| .draw_blocking_section(ybs) | |
| } | |
| # FOOTER (page 1) | |
| grid.text("Data: TrackMan | Report Generated: Coastal Carolina Baseball Analytics", | |
| x=0.5,y=0.008,gp=gpar(cex=0.70,col="grey50")) | |
| # ======== PAGE 2 (if needed) ======== | |
| if (needs_page2) { | |
| grid::grid.newpage() | |
| # Page 2 header | |
| pushViewport(viewport(x=.5,y=.98,width=.94,height=.055,just=c("center","top"))) | |
| grid.draw(catcher_create_simple_header(catcher_name,game_key,bio_data)); popViewport() | |
| # Draw blocking section at the top of page 2 | |
| .draw_blocking_section(.90) | |
| # Footer (page 2) | |
| grid.text("Data: TrackMan | Report Generated: Coastal Carolina Baseball Analytics", | |
| x=0.5,y=0.008,gp=gpar(cex=0.70,col="grey50")) | |
| } | |
| },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) | |
| } | |
| # ===================================================================== | |
| # ===================== 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 | To generate a bullpen report first select the bullpen option then upload a portable trackman CSV") | |
| ), | |
| uiOutput("leaderboard_ui"), | |
| fluidRow( | |
| column( | |
| 4, | |
| div(class = "main-panel", | |
| div(class = "upload-box", | |
| h3("Upload Game Data", style = "color: #006F71; margin-top: 0;"), | |
| 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", | |
| "Bullpen"="bullpen"), | |
| selected = "hitter", inline = TRUE), | |
| conditionalPanel( | |
| condition = "input.report_type != 'bullpen'", | |
| fileInput("game_csv", NULL, accept = c(".csv","text/csv"), | |
| buttonLabel = "Choose Game CSV...", placeholder = "No file selected") | |
| ), | |
| conditionalPanel( | |
| condition = "input.report_type == 'bullpen'", | |
| fileInput("bullpen_csv", "Bullpen CSV", accept = c(".csv","text/csv"), | |
| buttonLabel = "Choose Bullpen CSV...", placeholder = "Upload bullpen pitching data"), | |
| p("Upload a bullpen-session TrackMan CSV (no game data needed)", | |
| style = "font-size: 0.85em; color: #666; margin-top: -10px;") | |
| ), | |
| 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") | |
| ) | |
| ) | |
| ) | |
| ) | |
| 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) | |
| data_bullpen <- 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$bullpen_csv, { | |
| req(input$bullpen_csv) | |
| tryCatch({ | |
| df <- read.csv(input$bullpen_csv$datapath, stringsAsFactors = FALSE) | |
| data_bullpen(process_bullpen_dataset(df)) | |
| showNotification("Bullpen data loaded successfully!", type = "message", duration = 3) | |
| }, error = function(e) { | |
| showNotification(paste("Error loading bullpen CSV:", e$message), type = "error", duration = 6) | |
| data_bullpen(NULL) | |
| }) | |
| }) | |
| 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)) | |
| 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 == "bullpen") { | |
| df <- data_bullpen() | |
| if (is.null(df)) return(div(p("Please upload a bullpen 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 bullpen data", | |
| style="color:#cc6600;font-weight:bold;"))) | |
| tagList( | |
| selectInput("bullpen_pitcher_name", "Select Pitcher", | |
| choices = pitchers, selected = pitchers[1], width = "100%"), | |
| radioButtons("bullpen_intent", "Intent Level", | |
| choices = c("Low", "Medium", "High"), | |
| selected = "High", inline = TRUE) | |
| ) | |
| } 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") | |
| } else if (input$report_type == "bullpen") { | |
| downloadButton("download_bullpen", "Download Bullpen 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 == "bullpen") { | |
| df <- data_bullpen() | |
| if (is.null(df)) return(NULL) | |
| pitchers <- sort(unique(na.omit(df$Pitcher))) | |
| if (length(pitchers) <= 1) return(NULL) | |
| tagList( | |
| br(), | |
| div(style="text-align:center;padding:10px;background:#f0f8f8;border-radius:6px;margin-top:10px;", | |
| p(strong("Pitchers Found: ", length(pitchers)), | |
| style="color:#006F71;margin:5px 0;"), | |
| downloadButton("download_all_bullpen", "Download All Bullpen 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 == "bullpen") { | |
| df <- data_bullpen(); req(df, input$bullpen_pitcher_name) | |
| pitcher_df <- df %>% filter(Pitcher == input$bullpen_pitcher_name) | |
| if (!nrow(pitcher_df)) return(NULL) | |
| stats <- pitcher_df %>% | |
| summarise( | |
| pitches = n(), | |
| avg_velo = round(mean(RelSpeed, na.rm = TRUE), 1), | |
| max_velo = round(max(RelSpeed, na.rm = TRUE), 1), | |
| pitch_types = n_distinct(TaggedPitchType[!is.na(TaggedPitchType) & TaggedPitchType != "Undefined"]) | |
| ) | |
| intent <- if (!is.null(input$bullpen_intent)) input$bullpen_intent else "High" | |
| div(class = "status-box", | |
| h4("\u2713 Ready to Generate Bullpen Report", style = "margin-top: 0; color: #006F71;"), | |
| p(strong("Pitcher: "), input$bullpen_pitcher_name), | |
| p(strong("Intent Level: "), intent), | |
| p(strong("Total Pitches: "), stats$pitches), | |
| p(strong("Pitch Types: "), stats$pitch_types), | |
| p(strong("Avg Velo: "), stats$avg_velo, " mph | ", | |
| strong("Max Velo: "), stats$max_velo, " mph")) | |
| } 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", | |
| 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)) | |
| ), | |
| h3(class = "leaderboard-title", icon("trophy"), " Game Leaders"), | |
| 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 == "bullpen") { | |
| df <- data_bullpen() | |
| if (is.null(df)) return(div(style = "text-align:center;padding:60px;color:#999;", | |
| h4("Upload a bullpen CSV to preview"))) | |
| req(input$bullpen_pitcher_name) | |
| tagList( | |
| h4("Bullpen Movement Profile", style = "color: #006F71;"), | |
| plotOutput("preview_bullpen_movement", height = "380px"), | |
| br(), | |
| h4("Bullpen Pitch Locations", style = "color: #006F71;"), | |
| plotOutput("preview_bullpen_location", 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$preview_bullpen_movement <- renderPlot({ | |
| df <- data_bullpen(); req(df, input$bullpen_pitcher_name) | |
| pitcher_df <- df %>% filter(Pitcher == input$bullpen_pitcher_name) | |
| bp_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" | |
| ) | |
| create_bullpen_movement_plot(pitcher_df, input$bullpen_pitcher_name, bp_pitch_colors) | |
| }, res = 120) | |
| output$preview_bullpen_location <- renderPlot({ | |
| df <- data_bullpen(); req(df, input$bullpen_pitcher_name) | |
| pitcher_df <- df %>% filter(Pitcher == input$bullpen_pitcher_name) | |
| bp_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" | |
| ) | |
| create_bullpen_location_plot(pitcher_df, bp_pitch_colors) | |
| }, res = 120) | |
| output$download_bullpen <- downloadHandler( | |
| filename = function() { | |
| df <- data_bullpen(); req(df, input$bullpen_pitcher_name) | |
| pitcher_clean <- gsub(" ", "_", input$bullpen_pitcher_name) | |
| date_str <- tryCatch(format(unique(df$Date)[1], "%Y%m%d"), error = function(e) "undated") | |
| paste0(pitcher_clean, "_", date_str, "_Bullpen_Report.pdf") | |
| }, | |
| content = function(file) { | |
| df <- data_bullpen(); req(df, input$bullpen_pitcher_name) | |
| intent <- if (!is.null(input$bullpen_intent)) input$bullpen_intent else "High" | |
| withProgress(message = 'Generating Bullpen PDF', value = 0, { | |
| incProgress(.3, detail = "Processing data...") | |
| incProgress(.4, detail = "Creating visualizations...") | |
| create_bullpen_pdf_report(df, input$bullpen_pitcher_name, file, intent) | |
| incProgress(.3, detail = "Finalizing report...") | |
| }) | |
| showNotification("Bullpen report generated!", type = "message", duration = 3) | |
| }, | |
| contentType = "application/pdf" | |
| ) | |
| output$download_all_bullpen <- downloadHandler( | |
| filename = function() { | |
| df <- data_bullpen(); req(df) | |
| date_str <- tryCatch(format(unique(df$Date)[1], "%Y%m%d"), error = function(e) "undated") | |
| paste0("Bullpen_Reports_", date_str, ".zip") | |
| }, | |
| content = function(file) { | |
| df <- data_bullpen(); req(df) | |
| intent <- if (!is.null(input$bullpen_intent)) input$bullpen_intent else "High" | |
| pitchers <- sort(unique(na.omit(df$Pitcher))) | |
| if (!length(pitchers)) { | |
| showNotification("No pitchers found", type = "error", duration = 5) | |
| return(NULL) | |
| } | |
| withProgress(message = 'Generating Bullpen 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)) | |
| date_str <- tryCatch(format(unique(df$Date)[1], "%Y%m%d"), error = function(e) "undated") | |
| out <- file.path(tmp, paste0(gsub(" ", "_", ply), "_", date_str, "_Bullpen_Report.pdf")) | |
| try(create_bullpen_pdf_report(df, ply, out, intent), 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("Bullpen reports ZIP ready!", type = "message", duration = 5) | |
| }, | |
| contentType = "application/zip" | |
| ) | |
| 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) |