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