diff --git "a/app.R" "b/app.R" deleted file mode 100644--- "a/app.R" +++ /dev/null @@ -1,9599 +0,0 @@ -library(shiny) -library(dplyr) -library(ggplot2) -library(grid) -library(gridExtra) -library(stringr) -library(zip) -library(png) -library(workflows) -library(parsnip) -library(recipes) -library(arrow) -library(xgboost) -library(tidymodels) -library(httr) -library(ggforce) -library(jsonlite) # for parse_bat_tracking_json() -library(readr) # read_csv() for gzipped scrape downloads -library(DT) # data preview / retag / scrape tables -library(shinyBS) # bsModal pitch-edit dialog -library(reticulate) # huggingface_hub upload in the Scraping tab - -PASSWORD <- Sys.getenv("password") - -if (!requireNamespace("magick", quietly = TRUE)) { - message("Note: Install 'magick' to enable player headshots in reports") -} - -team_master <- tryCatch({ - read.csv("team_master.csv", stringsAsFactors = FALSE) -}, error = function(e) { - message("team_master.csv not found") - 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; -} - - /* ---- ported data-processing styles ---- */ -/* Bat tracking upload box styling */ - .bat-tracking-box { - background: linear-gradient(135deg, #e8f4f8 0%, #f0e6d3 100%); - border: 2px dashed darkcyan; - border-radius: 15px; - padding: 20px; - margin-top: 15px; - } -/* Catcher notes styling */ - .catcher-notes-input-box { - background: linear-gradient(135deg, #e8f4f8 0%, #f0e6d3 100%); - border: 2px solid darkcyan; - border-radius: 15px; - padding: 20px; - } -/* Download section styling */ - .download-option-box { - background: linear-gradient(135deg, #e8f4f8 0%, #f0e6d3 100%); - border: 1px solid rgba(0,139,139,.2); - border-radius: 12px; - padding: 20px; - margin-bottom: 15px; - } -.catcher-note-entry { - background: #fff; - border: 1px solid rgba(0,139,139,.15); - border-radius: 10px; - padding: 12px 16px; - margin-bottom: 8px; - display: flex; - justify-content: space-between; - align-items: center; - } -.catcher-note-entry:hover { - border-color: darkcyan; - box-shadow: 0 2px 8px rgba(0,139,139,.12); - } -.merge-status-box { - background: #f8f9fa; - border-left: 4px solid darkcyan; - padding: 15px; - border-radius: 0 10px 10px 0; - margin-top: 15px; - } -.merge-success { - border-left-color: #28a745; - background: #d4edda; - } -.merge-warning { - border-left-color: #ffc107; - background: #fff3cd; - } -.merge-error { - border-left-color: #dc3545; - background: #f8d7da; - } -" - -stuffplus_recipe <- readRDS("stuffplus_recipe.rds") - -stuffplus_model <- xgb.load("stuffplus_xgb.json") - -message(class(stuffplus_model)) - -# ============================================================================ -# DATA PROCESSING PIPELINE (ported from the standalone uploader app) -# Cleans raw TrackMan CSV/parquet uploads and appends Stuff+ so the report -# generators below can consume processed data directly. -# read_uploaded_file -> convert_date_columns -> (bat-tracking merge) -> -# drop columns -> clean_college_data -> predict_stuffplus -# ============================================================================ - -# Maximum rows allowed for upload -MAX_UPLOAD_ROWS <- 5000 - -# Run-value table used by clean_college_data() (joined on event_type). -rv <- tryCatch({ - read.csv("non_context_run_values.csv", stringsAsFactors = FALSE) -}, error = function(e) { - message("non_context_run_values.csv not found - clean_college_data() run values will be unavailable") - NULL -}) - -# Spin-axis (and GameDate) columns offered up for optional removal -columns_to_remove <- c( - "SpinAxis3dTransverseAngle", "SpinAxis3dLongitudinalAngle", "SpinAxis3dActiveSpinRate", - "SpinAxis3dSpinEfficiency", "SpinAxis3dTilt", "SpinAxis3dVectorX", "SpinAxis3dVectorY", - "SpinAxis3dVectorZ", "SpinAxis3dSeamOrientationRotationX", "SpinAxis3dSeamOrientationRotationY", - "SpinAxis3dSeamOrientationRotationZ", "SpinAxis3dSeamOrientationBallYAmb1", - "SpinAxis3dSeamOrientationBallAngleHorizontalAmb1", "SpinAxis3dSeamOrientationBallZAmb1", - "SpinAxis3dSeamOrientationBallAngleVerticalAmb2", "SpinAxis3dSeamOrientationBallZAmb2", - "SpinAxis3dSeamOrientationBallXAmb4", "SpinAxis3dSeamOrientationBallYAmb4", - "SpinAxis3dSeamOrientationBallAngleHorizontalAmb2", "SpinAxis3dSeamOrientationBallAngleVerticalAmb1", - "SpinAxis3dSeamOrientationBallXAmb1", "SpinAxis3dSeamOrientationBallYAmb2", - "SpinAxis3dSeamOrientationBallAngleHorizontalAmb4", "SpinAxis3dSeamOrientationBallAngleVerticalAmb4", - "SpinAxis3dSeamOrientationBallXAmb2", "SpinAxis3dSeamOrientationBallAngleVerticalAmb3", - "SpinAxis3dSeamOrientationBallAngleHorizontalAmb3", "SpinAxis3dSeamOrientationBallXAmb3", - "SpinAxis3dSeamOrientationBallYAmb3", "SpinAxis3dSeamOrientationBallZAmb3", - "SpinAxis3dSeamOrientationBallZAmb4", "GameDate" -) - - -convert_date_format <- function(date_string, output_format = "yyyy") { - if (is.null(date_string) || length(date_string) == 0) return(NA_character_) - if (inherits(date_string, "Date") || inherits(date_string, "POSIXct")) { - if (is.na(date_string)) return(NA_character_) - parsed_date <- as.Date(date_string) - if (output_format == "mdyy") { - return(gsub("/0", "/", gsub("^0", "", format(parsed_date, "%m/%d/%y")))) - } else { - return(format(parsed_date, "%Y-%m-%d")) - } - } - if (is.na(date_string) || identical(as.character(date_string), "")) { - return(NA_character_) - } - - date_string <- as.character(date_string) - - parsed_date <- NULL - - if (grepl("^\\d{4}-\\d{2}-\\d{2}$", date_string)) { - parsed_date <- tryCatch({ - as.Date(date_string, format = "%Y-%m-%d") - }, error = function(e) NULL) - } - - if (is.null(parsed_date) && grepl("^\\d{1,2}/\\d{1,2}/\\d{4}$", date_string)) { - parsed_date <- tryCatch({ - as.Date(date_string, format = "%m/%d/%Y") - }, error = function(e) NULL) - } - - if (is.null(parsed_date) && grepl("^\\d{1,2}/\\d{1,2}/\\d{2}$", date_string)) { - parsed_date <- tryCatch({ - as.Date(date_string, format = "%m/%d/%y") - }, error = function(e) NULL) - } - - if (!is.null(parsed_date) && !is.na(parsed_date)) { - if (output_format == "mdyy") { - return(format(parsed_date, "%m/%d/%y") %>% - gsub("^0", "", .) %>% - gsub("/0", "/", .)) - } else { - return(format(parsed_date, "%Y-%m-%d")) - } - } - - return(date_string) -} - -convert_date_columns <- function(df, output_format = "yyyy") { - date_columns <- c("Date", "GameDate", "UTCDate", "LocalDateTime") - - for (col in date_columns) { - if (col %in% names(df)) { - col_data <- df[[col]] - - if (inherits(col_data, "Date") || inherits(col_data, "POSIXct")) { - if (output_format == "mdyy") { - df[[col]] <- ifelse(is.na(col_data), NA_character_, - gsub("/0", "/", gsub("^0", "", format(as.Date(col_data), "%m/%d/%y")))) - } else { - df[[col]] <- ifelse(is.na(col_data), NA_character_, - format(as.Date(col_data), "%Y-%m-%d")) - } - } else { - df[[col]] <- sapply(df[[col]], function(x) convert_date_format(x, output_format), USE.NAMES = FALSE) - } - } - } - - return(df) -} - -# Read uploaded file: CSV or Parquet, with row-limit enforcement -read_uploaded_file <- function(filepath, filename, header = TRUE, sep = ",", quote = '"') { - ext <- tolower(tools::file_ext(filename)) - - if (ext == "parquet") { - df <- as.data.frame(arrow::read_parquet(filepath)) - } else { - df <- read.csv(filepath, - header = header, - sep = sep, - quote = quote, - stringsAsFactors = FALSE) - } - - if (nrow(df) > MAX_UPLOAD_ROWS) { - stop(paste0("File contains ", format(nrow(df), big.mark = ","), " rows which exceeds the ", - format(MAX_UPLOAD_ROWS, big.mark = ","), " row limit. Please upload a smaller file.")) - } - - return(df) -} - -# Function to parse bat tracking JSON -parse_bat_tracking_json <- function(json_path) { - tryCatch({ - json_data <- fromJSON(json_path, simplifyVector = FALSE) - - game_reference <- json_data$GameReference - session_id <- json_data$SessionId - - plays <- json_data$Plays - - if (length(plays) == 0) { - return(list( - success = TRUE, - data = NULL, - game_reference = game_reference, - message = "JSON parsed but contains no bat tracking plays (empty Plays array)" - )) - } - - bat_tracking_df <- data.frame( - PitchUID = sapply(plays, function(p) p$PitchUID), - BatSpeed_Sensor = sapply(plays, function(p) p$BatSpeed), - VerticalAttackAngle_Sensor = sapply(plays, function(p) p$VerticalAttackAngle), - HorizontalAttackAngle_Sensor = sapply(plays, function(p) p$HorizontalAttackAngle), - BatTracking_PlayId = sapply(plays, function(p) p$PlayId), - BatTracking_Time = sapply(plays, function(p) p$Time), - stringsAsFactors = FALSE - ) - - return(list( - success = TRUE, - data = bat_tracking_df, - game_reference = game_reference, - session_id = session_id, - plays_count = length(plays), - message = paste("Successfully parsed", length(plays), "bat tracking play(s)") - )) - - }, error = function(e) { - return(list( - success = FALSE, - data = NULL, - message = paste("Error parsing JSON:", e$message) - )) - }) -} - -merge_with_bat_tracking <- function(csv_data, bat_tracking_data) { - if (is.null(bat_tracking_data) || nrow(bat_tracking_data) == 0) { - return(list( - data = csv_data, - matched = 0, - total_bat = 0, - message = "No bat tracking data to merge" - )) - } - - if (!"PitchUID" %in% names(csv_data)) { - return(list( - data = csv_data, - matched = 0, - total_bat = nrow(bat_tracking_data), - message = "CSV does not contain PitchUID column - cannot merge" - )) - } - - merged_data <- csv_data %>% - left_join(bat_tracking_data, by = "PitchUID") - - matched_count <- sum(!is.na(merged_data$BatSpeed_Sensor)) - - if ("BatSpeed" %in% names(merged_data)) { - merged_data <- merged_data %>% - mutate(BatSpeed = ifelse(is.na(BatSpeed) & !is.na(BatSpeed_Sensor), - BatSpeed_Sensor, BatSpeed)) - } - - if ("VerticalAttackAngle" %in% names(merged_data)) { - merged_data <- merged_data %>% - mutate(VerticalAttackAngle = ifelse(is.na(VerticalAttackAngle) & !is.na(VerticalAttackAngle_Sensor), - VerticalAttackAngle_Sensor, VerticalAttackAngle)) - } - - if ("HorizontalAttackAngle" %in% names(merged_data)) { - merged_data <- merged_data %>% - mutate(HorizontalAttackAngle = ifelse(is.na(HorizontalAttackAngle) & !is.na(HorizontalAttackAngle_Sensor), - HorizontalAttackAngle_Sensor, HorizontalAttackAngle)) - } - - return(list( - data = merged_data, - matched = matched_count, - total_bat = nrow(bat_tracking_data), - message = paste("Merged successfully:", matched_count, "of", nrow(bat_tracking_data), "bat tracking records matched") - )) -} - - -clean_college_data <- function(data, teams = NA){ - - data <- data %>% - mutate(PlayResult = ifelse(PlayResult %in% c("HomeRun", "homerun"), "Homerun", PlayResult), - Batter = sub("(.*),\\s*(.*)", "\\2 \\1", Batter), - Pitcher = sub("(.*),\\s*(.*)", "\\2 \\1", Pitcher), - Catcher = sub("(.*),\\s*(.*)", "\\2 \\1", Catcher)) - - - col <- colnames(data) - - if ("Top/Bottom" %in% col){ - data <- data %>% - rename(`Top.Bottom` = `Top/Bottom`) - } - - numeric_columns <- c("PitchNo", "PAofInning", "PitchofPA", "PitcherId", "BatterId", "Inning", "Outs", "Balls", - "Strikes", "OutsOnPlay", "RunsScored", "RelSpeed", "VertRelAngle", "HorzRelAngle", "SpinRate", - "SpinAxis", "RelHeight", "RelSide", "Extension", "VertBreak", "InducedVertBreak", "HorzBreak", - "PlateLocHeight", "PlateLocSide", "ZoneSpeed", "VertApprAngle", "HorzApprAngle", "ZoneTime", - "ExitSpeed", "Angle", "Direction", "HitSpinRate", "Distance", "Bearing", "HangTime", - "LastTrackedDistance", "pfxx", "pfxz", "x0", "y0", "z0", "vx0", "vz0", "vy0", "ax0", "ay0", - "az0", "EffectiveVelo", "MaxHeight", "SpeedDrop", "ContactPositionX", "ContactPositionY", - "ContactPositionZ", "HomeTeamForeignID", "AwayTeamForeignID", "CatcherId", "ThrowSpeed", - "PopTime", "ExchangeTime", "TimeToBase") - - data <- data %>% - mutate(across(any_of(numeric_columns), as.numeric), - PlateLocHeight = if ("PlateLocHeight" %in% names(.)) 12 * PlateLocHeight else PlateLocHeight, - PlateLocSide = if ("PlateLocSide" %in% names(.)) 12 * PlateLocSide else PlateLocSide) - - - data <- data %>% - mutate(TaggedPitchType = case_when( - TaggedPitchType == "FourSeamFastBall" ~ "Fastball", - TaggedPitchType %in% c("TwoSeamFastBall", "OneSeamFastBall") ~ "Sinker", - TaggedPitchType == "ChangeUp" ~ "Changeup", - TaggedPitchType == "Undefined" ~ "Other", - T ~ TaggedPitchType - )) - - data <- data %>% - mutate( - is_csw = case_when( - PitchCall %in% c("StrikeSwinging", "StrikeCalled") ~ 1, - TRUE ~ 0 - ), - is_swing = case_when( - PitchCall %in% c("StrikeSwinging", "FoulBallNotFieldable", "InPlay", - "FoulBallFieldable", "FoulBall") ~ 1, - TRUE ~ 0 - ), - is_whiff = case_when( - PitchCall == "StrikeSwinging" & is_swing == 1 ~ 1, - PitchCall != "StrikeSwinging" & is_swing == 1 ~ 0, - TRUE ~ NA_real_ - ), - in_zone = case_when( - PlateLocSide > 9.975 | PlateLocSide < -9.975 | - PlateLocHeight > 40 | PlateLocHeight < 20 ~ 0, - TRUE ~ 1 - ), - chase = case_when( - is_swing == 1 & in_zone == 0 ~ 1, - is_swing == 0 & in_zone == 0 ~ 0, - TRUE ~ NA_real_ - ), - in_zone_whiff = case_when( - is_swing == 1 & in_zone == 1 & is_whiff == 1 ~ 1, - is_swing == 1 & in_zone == 1 & is_whiff == 0 ~ 0, - TRUE ~ NA_real_ - ), - is_hit = case_when( - PlayResult %in% c("Single", "Double", "Triple", "Homerun", "HomeRun") & PitchCall == "InPlay" ~ 1, - !PlayResult %in% c("Single", "Double", "Triple", "Homerun", "HomeRun") & PitchCall == "InPlay" ~ 0, - KorBB == "Strikeout" ~ 0, - PlayResult %in% c("StrikeoutSwinging", "StrikeoutLooking") ~ 0, - TRUE ~ NA_real_ - ), - slg = case_when( - PitchCall == "InPlay" & PlayResult == "Single" ~ 1, - PitchCall == "InPlay" & PlayResult == "Double" ~ 2, - PitchCall == "InPlay" & PlayResult == "Triple" ~ 3, - PitchCall == "InPlay" & PlayResult %in% c("Homerun", "HomeRun") ~ 4, - !PlayResult %in% c("Single", "Double", "Triple", "Homerun", "HomeRun") & PitchCall == "InPlay" ~ 0, - KorBB == "Strikeout" ~ 0, - PlayResult %in% c("StrikeoutSwinging", "StrikeoutLooking") ~ 0, - TRUE ~ NA_real_ - ), - on_base = case_when( - PitchCall == "InPlay" & PlayResult %in% c("Single", "Double", "Triple", "Homerun", "HomeRun") ~ 1, - PitchCall %in% c("HitByPitch") | KorBB == "Walk" ~ 1, - PitchCall == "InPlay" & PlayResult %in% c("Out", "Error", "FieldersChoice") & PlayResult != "Sacrifice" ~ 0, - KorBB == "Strikeout" ~ 0, - PlayResult %in% c("StrikeoutSwinging", "StrikeoutLooking") ~ 0, - TRUE ~ NA_real_ - ), - is_hard_hit = case_when( - ExitSpeed >= 95 & PitchCall == "InPlay" ~ 1, - ExitSpeed < 95 & PitchCall == "InPlay" ~ 0, - TRUE ~ NA_real_ - ), - woba = case_when( - PitchCall == "InPlay" & PlayResult == "Single" ~ 0.95, - PitchCall == "InPlay" & PlayResult == "Double" ~ 1.24, - PitchCall == "InPlay" & PlayResult == "Triple" ~ 1.47, - PitchCall == "InPlay" & PlayResult %in% c("Homerun", "HomeRun") ~ 1.71, - KorBB == "Walk" ~ 0.82, - PitchCall %in% c("HitByPitch") ~ 0.85, - KorBB == "Strikeout" ~ 0, - PlayResult %in% c("StrikeoutSwinging", "StrikeoutLooking") ~ 0, - PitchCall == "InPlay" & !PlayResult %in% c("Single", "Double" ,"Triple" ,"Homerun", "HomeRun") ~ 0, - TRUE ~ NA_real_ - ), - wobacon = case_when( - PitchCall == "InPlay" & PlayResult == "Single" ~ 0.95, - PitchCall == "InPlay" & PlayResult == "Double" ~ 1.24, - PitchCall == "InPlay" & PlayResult == "Triple" ~ 1.47, - PitchCall == "InPlay" & PlayResult %in% c("Homerun", "HomeRun") ~ 1.71, - PitchCall == "InPlay" & !PlayResult %in% c("Single", "Double" ,"Triple" ,"Homerun", "HomeRun") ~ 0, - TRUE ~ NA_real_ - ), - is_plate_appearance = ifelse( - PitchCall %in% c("InPlay", "HitByPitch") | KorBB %in% c("Strikeout", "Walk") | PlayResult %in% c("StrikeoutSwinging", "StrikeoutLooking"), 1, 0 - ), - is_at_bat = case_when( - PitchCall == "InPlay" & !PlayResult %in% c("StolenBase", "Sacrifice", "CaughtStealing", "Undefined") ~ 1, - KorBB == "Strikeout" ~ 1, - PlayResult %in% c("StrikeoutSwinging", "StrikeoutLooking") ~ 1, - TRUE ~ 0 - ), - is_walk = case_when( - is_plate_appearance == 1 & KorBB == "Walk" ~ 1, - is_plate_appearance == 1 & KorBB != "Walk" ~ 0, - TRUE ~ NA_real_ - ), - is_k = case_when( - is_at_bat == 1 & KorBB == "Strikeout" ~ 1, - is_at_bat == 1 & KorBB != "Strikeout" ~ 0, - PlayResult %in% c("StrikeoutSwinging", "StrikeoutLooking") ~ 1, - TRUE ~ NA_real_ - ), - is_put_away = case_when( - Strikes == 2 & KorBB == "Strikeout" ~ 1, - Strikes == 2 & KorBB != "Strikeout" ~ 0, - Strikes == 2 & PlayResult %in% c("StrikeoutSwinging", "StrikeoutLooking") ~ 1, - TRUE ~ NA_real_ - ), - OutsOnPlay = ifelse(KorBB == "Strikeout" | PlayResult %in% c("StrikeoutSwinging", "StrikeoutLooking"), OutsOnPlay + 1, OutsOnPlay) - ) - - data <- data %>% - mutate(event_type = case_when( - PitchCall %in% c("StrikeSwinging", "StrkeSwinging") ~ "Whiff", - PitchCall %in% c("StriekC", "StrikeCalled") ~ "Called Strike", - PitchCall %in% c("FoulBallFieldable", "FoulBall", "FoulBallNotFieldable", - "FouldBallNotFieldable") ~ "Foul Ball", - PitchCall %in% c("BallCalled", "BallinDirt", "BallIntentional", "BalIntentional") ~ "Ball", - PitchCall == "HitByPitch" ~ "HBP", - PitchCall == "InPlay" & PlayResult %in% c("Out", "FieldersChoice", - "Error", "error", - "Sacrifice") ~ "Field Out", - PitchCall == "InPlay" & PlayResult == "Single" ~ "Single", - PitchCall == "InPlay" & PlayResult == "Double" ~ "Double", - PitchCall == "InPlay" & PlayResult == "Triple" ~ "Triple", - PitchCall == "InPlay" & PlayResult == "Homerun" ~ "Home Run", - T ~ NA - )) %>% - left_join(rv, by = "event_type") - - data <- data %>% - dplyr::select( - -PitchLastMeasuredX, -PitchLastMeasuredY, -PitchLastMeasuredZ, - -starts_with("PitchTrajectory"), - -HitSpinAxis, - -starts_with("HitTrajectory"), - -PitchReleaseConfidence, -PitchLocationConfidence, -PitchMovementConfidence, - -HitLaunchConfidence, -HitLandingConfidence, - -CatcherThrowCatchConfidence, -CatcherThrowReleaseConfidence, -CatcherThrowLocationConfidence, - -PositionAt110X, -PositionAt110Y, -PositionAt110Z - ) - - return(data) -} - - - -predict_stuffplus <- function(data) { - - predict_data <- data %>% - 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_processed <- bake(stuffplus_recipe, new_data = predict_data) - - df_matrix <- as.matrix(df_processed) - - raw_stuff <- predict(stuffplus_model, df_matrix) - - data$raw_stuff <- raw_stuff - - - data <- data %>% - mutate(stuff_plus = ((raw_stuff - 0.004424894) / 0.01010482) * 10 + 100) - - return(data) - -} - -# ---------------------------------------------------------------------------- -# Convenience orchestrator: run the full upload -> clean -> Stuff+ pipeline. -# filepath / filename : the uploaded file (CSV or parquet) -# json_path : optional bat-tracking JSON to merge by PitchUID -# drop_columns : character vector of columns to remove (default = all -# spin-axis + GameDate columns); pass character(0) to keep all -# date_format : "yyyy" or "mdyy" -# run_stuffplus : append Stuff+ predictions (requires stuffplus_* globals) -# Returns the processed data.frame ready for the report generators. -# ---------------------------------------------------------------------------- -process_uploaded_trackman <- function(filepath, filename, - json_path = NULL, - drop_columns = columns_to_remove, - date_format = "yyyy", - run_stuffplus = TRUE) { - - df <- read_uploaded_file(filepath, filename) - df <- convert_date_columns(df, date_format) - - # Optional bat-tracking merge - if (!is.null(json_path)) { - parsed <- parse_bat_tracking_json(json_path) - if (isTRUE(parsed$success) && !is.null(parsed$data)) { - df <- merge_with_bat_tracking(df, parsed$data)$data - } - } - - # Optional column drop - drop_columns <- intersect(drop_columns, names(df)) - if (length(drop_columns) > 0) { - df <- df[, setdiff(names(df), drop_columns), drop = FALSE] - } - - df <- clean_college_data(df) - - if (isTRUE(run_stuffplus)) { - df <- tryCatch( - predict_stuffplus(df), - error = function(e) { - message("predict_stuffplus() skipped: ", conditionMessage(e)) - df - } - ) - } - - df -} - -# ============================================================================ -# END DATA PROCESSING PIPELINE -# ============================================================================ - -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_contact_side <- function(batter_name, bp_data) { - # TrackMan contact coords: X = depth (toward pitcher), Y = height, Z = side - contact_data <- bp_data %>% - filter(Batter == batter_name, BIPind == 1, !is.na(ExitSpeed), - !is.na(ContactPositionX), !is.na(ContactPositionY)) - - # Home plate side illustration (tip toward catcher at depth 0, front edge at 1.417 ft) - plate_df <- data.frame( - x = c(0, 0.708, 1.417, 1.417, 0.708), - y = c(0.25, 0.35, 0.35, 0.15, 0.15) - ) - - base_theme <- theme_minimal() + - theme( - panel.grid = element_blank(), - panel.border = element_rect(color = "black", fill = NA, linewidth = 0.6), - axis.title = element_text(size = 9, face = "bold"), - axis.text = element_text(size = 7, color = "black"), - plot.title = element_text(hjust = 0.5, size = 10, face = "bold"), - legend.position = "right", - 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"), - plot.margin = margin(3, 3, 3, 3) - ) - - if (!nrow(contact_data)) { - return( - ggplot() + - geom_polygon(data = plate_df, aes(x, y), fill = NA, color = "gray70", linewidth = 0.5) + - coord_fixed(xlim = c(-1, 4), ylim = c(0, 5), expand = FALSE) + - labs(x = "Contact Depth (ft in front of plate)", y = "Contact Height (ft)") + - ggtitle(paste("Contact Depth vs Height:", batter_name)) + - annotate("text", x = 1.5, y = 2.5, label = "No contact data available", size = 5, color = "gray50") + - base_theme - ) - } - - ggplot(contact_data, aes(x = ContactPositionX, y = ContactPositionY)) + - geom_polygon(data = plate_df, aes(x, y), fill = "white", color = "black", - linewidth = 0.6, inherit.aes = FALSE) + - geom_point(aes(fill = ExitSpeed), size = 3, shape = 21, color = "black", stroke = 0.4, alpha = 0.85) + - scale_fill_gradient2(low = "blue", mid = "grey85", high = "red", - midpoint = mean(contact_data$ExitSpeed, na.rm = TRUE), - name = "Exit Velo") + - coord_fixed(xlim = c(-1, 4), ylim = c(0, 5), expand = FALSE) + - labs(x = "Contact Depth (ft in front of plate)", y = "Contact Height (ft)") + - ggtitle(paste("Contact Depth vs Height:", batter_name)) + - base_theme -} - -calculate_bp_leaderboard <- function(bp_data) { - bp_data %>% - filter(!is.na(Batter)) %>% - group_by(Batter) %>% - 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(suppressWarnings(max(ExitSpeed[BIPind == 1], na.rm = TRUE)), 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" - ) %>% - mutate(across(where(is.numeric), ~ifelse(is.finite(.x), .x, NA_real_))) %>% - arrange(desc(`Avg EV`)) -} - -bp_leaderboard_colors <- function(x, target = NULL) { - # Red (bad) -> white (group average) -> green (good), scaled within the leaderboard. - # If target is given (e.g. Avg LA), "good" = closest to the target value. - score <- if (!is.null(target)) -abs(x - target) else x - ok <- is.finite(score) - cols <- rep("#FFFFFF", length(x)) - if (sum(ok) >= 2) { - mu <- mean(score[ok]) - dev <- max(abs(score[ok] - mu)) - scaled <- if (dev > 0) 0.5 + 0.5 * (score[ok] - mu) / dev else rep(0.5, sum(ok)) - ramp <- grDevices::colorRamp(c("#F8696B", "#FFFFFF", "#63BE7B")) - rgb_vals <- ramp(scaled) - cols[ok] <- grDevices::rgb(rgb_vals[, 1], rgb_vals[, 2], rgb_vals[, 3], maxColorValue = 255) - } - cols -} - -create_bp_leaderboard_pdf <- function(lb, date_label, output_file) { - if (length(dev.list()) > 0) try(dev.off(), silent = TRUE) - - stat_cols <- c("BBE", "Avg EV", "Avg LA", "Max EV", "10-30%", "HH%", "Barrel%") - color_cols <- setdiff(stat_cols, c("BBE", "Avg LA")) # BBE = volume; Avg LA = direction, not quality - - cell_fill <- matrix("white", nrow = nrow(lb), ncol = length(stat_cols), - dimnames = list(NULL, stat_cols)) - for (cn in color_cols) { - cell_fill[, cn] <- bp_leaderboard_colors(lb[[cn]]) - } - - pdf(output_file, width = 11, height = 8.5) - on.exit(try(dev.off(), silent = TRUE), add = TRUE) - - name_w <- 0.20 - col_w <- 0.082 - row_h <- 0.028 - x0 <- 0.5 - (name_w + length(stat_cols) * col_w) / 2 - rows_per_page <- 25 - n_pages <- max(1, ceiling(nrow(lb) / rows_per_page)) - - draw_cell <- function(x, y, w, fill, label, bold = FALSE, txt_col = "black") { - grid::grid.rect(x = x, y = y, width = w * 0.99, height = row_h, - just = c("left", "top"), - gp = grid::gpar(fill = fill, col = "black", lwd = 0.4)) - grid::grid.text(label, x = x + w * 0.5, y = y - row_h / 2, - gp = grid::gpar(cex = 0.85, col = txt_col, - fontface = if (bold) "bold" else "plain")) - } - - for (pg in seq_len(n_pages)) { - grid::grid.newpage() - grid::grid.text("Daily BP Leaderboard", y = 0.955, - gp = grid::gpar(fontface = "bold", cex = 1.5, col = "#006F71")) - if (!is.null(date_label)) { - grid::grid.text(date_label, y = 0.915, gp = grid::gpar(cex = 1.1, col = "grey30")) - } - if (n_pages > 1) { - grid::grid.text(paste0("Page ", pg, " of ", n_pages), x = 0.95, y = 0.03, - gp = grid::gpar(cex = 0.7, col = "grey50")) - } - - # Footer - grid::grid.text("Coastal Carolina Baseball Analytics", x = 0.5, y = 0.018, - gp = grid::gpar(cex = 0.65, col = "grey55")) - - y_top <- 0.86 - - # Header row - draw_cell(x0, y_top, name_w, "#006F71", "Player", bold = TRUE, txt_col = "white") - for (j in seq_along(stat_cols)) { - draw_cell(x0 + name_w + (j - 1) * col_w, y_top, col_w, "#006F71", - stat_cols[j], bold = TRUE, txt_col = "white") - } - - idx <- (((pg - 1) * rows_per_page) + 1):min(pg * rows_per_page, nrow(lb)) - for (r in seq_along(idx)) { - i <- idx[r] - y_r <- y_top - r * row_h - draw_cell(x0, y_r, name_w, "white", lb$Batter[i], bold = TRUE) - for (j in seq_along(stat_cols)) { - val <- lb[[stat_cols[j]]][i] - draw_cell(x0 + name_w + (j - 1) * col_w, y_r, col_w, - cell_fill[i, stat_cols[j]], - ifelse(is.finite(val), as.character(val), "-")) - } - } - } - - invisible(output_file) -} - -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, 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), - `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" - ) - - # BP session date (most common date in the batter's data) - bp_date <- suppressWarnings(tryCatch(parse_game_day(batter_df), error = function(e) as.Date(NA))) - bp_date_label <- if (!is.na(bp_date)) format(bp_date, "%B %e, %Y") else NULL - - # Create plots - 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) - side_plot <- create_bp_contact_side(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.935, 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() - - # BP date - if (!is.null(bp_date_label)) { - grid::pushViewport(grid::viewport(x = 0.5, y = 0.90, width = 1, height = 0.03, just = c("center", "top"))) - grid::grid.text(bp_date_label, - gp = grid::gpar(cex = 1.0, col = "grey30")) - grid::popViewport() - } - - # Stats table with NO color coding (all white) - BIGGER cells and text - headers <- c("BBE", "Avg EV", "Avg LA", "Max EV", "10-30%", "HH%", "Barrel%") - values <- c(stats$BBE, stats$`Avg EV`, stats$`Avg LA`, stats$`Max EV`, - stats$`10-30%`, stats$`HH%`, stats$`Barrel%`) - - col_w <- 0.10 - cell_h <- 0.027 - x0 <- 0.5 - (length(headers) * col_w) / 2 - yh <- 0.865 - yv <- yh - cell_h - - 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 = cell_h, - 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 - cell_h / 2, - gp = grid::gpar(col = "white", cex = 1.0, 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 = cell_h, - 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 - cell_h / 2, - gp = grid::gpar(cex = 1.05)) - } - - # Row 1: Spray chart (left) + Zone plot (right) - grid::pushViewport(grid::viewport(x = 0.25, y = 0.795, width = 0.42, height = 0.37, just = c("center", "top"))) - print(spray_plot, newpage = FALSE) - grid::popViewport() - - grid::pushViewport(grid::viewport(x = 0.75, y = 0.795, width = 0.42, height = 0.37, just = c("center", "top"))) - print(zone_plot, newpage = FALSE) - grid::popViewport() - - # Row 2: Contact map (left) + Contact depth/height side view (right) - grid::pushViewport(grid::viewport(x = 0.25, y = 0.415, width = 0.44, height = 0.38, just = c("center", "top"))) - print(contact_plot, newpage = FALSE) - grid::popViewport() - - grid::pushViewport(grid::viewport(x = 0.75, y = 0.415, width = 0.44, height = 0.38, just = c("center", "top"))) - print(side_plot, newpage = FALSE) - grid::popViewport() - - # Footer - grid::grid.text("Coastal Carolina Baseball Analytics", x = 0.5, y = 0.018, - gp = grid::gpar(cex = 0.65, col = "grey55")) - - invisible(output_file) -} - -parse_game_day <- function(df, tz = "America/New_York") { - stopifnot("Date" %in% names(df)) - if (inherits(df$Date, "Date")) { - dates <- df$Date[!is.na(df$Date)] - if (length(dates) > 0) { - tab <- sort(table(dates), decreasing = TRUE) - return(as.Date(names(tab)[1])) - } - } - as.Date(df$Date[1]) -} -create_at_bats_plot <- function(batter_data, player_name, game_key, pitch_colors, - max_lines_per_col = 16L) { - df <- dplyr::filter(batter_data, Batter == player_name) - if (!nrow(df)) { - return(ggplot2::ggplot() + ggplot2::theme_void() + - ggplot2::ggtitle(paste("No data for", player_name)) + - ggplot2::theme(plot.title = ggplot2::element_text(hjust = 0.5, size = 14, face = "bold"))) - } - - plot_data <- df %>% - arrange(PitchNo) %>% - mutate( - pa_break = (PitchofPA == 1), - pa_number = cumsum(pa_break) - ) %>% - ungroup() %>% - mutate( - PlayResult = na_if(str_squish(PlayResult), "Undefined"), - PitchCall_display = dplyr::case_when( - PitchCall == "StrikeSwinging" ~ "Whiff", - PitchCall == "StrikeCalled" ~ "CS", - PitchCall %in% c("FoulBall","FoulBallNotFieldable","FoulBallFieldable") ~ "Foul", - PitchCall %in% c("BallCalled","BallinDirt","BallIntentional") ~ "Ball", - PitchCall == "HitByPitch" ~ "HBP", - PitchCall == "InPlay" ~ "In Play", - TRUE ~ coalesce(PitchCall, "—") - ), - BIP_display = dplyr::case_when( - PlayResult %in% c("Single","Double","Triple","HomeRun") ~ - dplyr::recode(PlayResult, Single="1B", Double="2B", Triple="3B", HomeRun="HR"), - PlayResult == "FieldersChoice" ~ "FC", - PlayResult %in% c("Out","Error","Sacrifice","SacrificeFly") ~ PlayResult, - TRUE ~ NA_character_ - ), - PlayResult_clean = dplyr::case_when( - PitchCall_display == "In Play" ~ coalesce(BIP_display, "Out"), - TRUE ~ PitchCall_display - ) - ) %>% - dplyr::group_by(pa_number) %>% - dplyr::mutate( - line_idx = row_number(), - col_idx = ((line_idx - 1L) %/% max_lines_per_col) + 1L, - row_idx = ((line_idx - 1L) %% max_lines_per_col) + 1L - ) %>% - dplyr::ungroup() %>% - dplyr::mutate( - text_x = (20 + (col_idx - 1L) * 12) / 12, - text_y_main = (50 - (row_idx * 3)) / 12, - text_y_ev = (50 - (row_idx * 3) - 3) / 12 - ) - - used_second_col <- any(plot_data$col_idx > 1) - x_max <- if (used_second_col) ((35 + 12) / 12) else 35/12 - y_min_needed <- suppressWarnings(min(c(-1/12, min(plot_data$text_y_ev, na.rm = TRUE) - 0.05), na.rm = TRUE)) - - ggplot2::ggplot(plot_data, ggplot2::aes(PlateLocSide, PlateLocHeight)) + - ggplot2::annotate("rect", xmin = -0.8303, xmax = 0.8303, ymin = 1.5, ymax = 3.3775, - alpha = 0, size = .5, color = "black") + - ggplot2::annotate("segment", x = -0.708, y = 0.15, xend = 0.708, yend = 0.15, size = .5, color = "black") + - ggplot2::annotate("segment", x = -0.708, y = 0.30, xend = -0.708, yend = 0.15, size = .5, color = "black") + - ggplot2::annotate("segment", x = 0.708, y = 0.30, xend = 0.708, yend = 0.15, size = .5, color = "black") + - ggplot2::annotate("segment", x = -0.708, y = 0.30, xend = 0.000, yend = 0.50, size = .5, color = "black") + - ggplot2::annotate("segment", x = 0.708, y = 0.30, xend = 0.000, yend = 0.50, size = .5, color = "black") + - ggplot2::geom_point(ggplot2::aes(fill = TaggedPitchType), - alpha = 1, shape = 21, color = "black", stroke = 0.5, size = 4) + - ggplot2::geom_text(ggplot2::aes(label = PitchofPA), - vjust = 0.5, size = 2.2, color = "white", fontface = "bold") + - geom_text( - aes(x = text_x, y = text_y_main, - label = paste(PitchofPA, ":", PlayResult_clean)), - inherit.aes = FALSE, size = 2.1, hjust = 0 - ) + - geom_text( - aes(x = text_x, y = text_y_ev, - label = ifelse(PitchCall_display == "In Play" & !is.na(ExitSpeed), - paste0(round(ExitSpeed), " EV"), "")), - inherit.aes = FALSE, size = 2.0, hjust = 0 - ) + - ggplot2::facet_wrap(~ pa_number, ncol = 5) + - ggplot2::theme_void() + - ggplot2::scale_x_continuous(NULL, limits = c(-20/12, x_max)) + - ggplot2::scale_y_continuous(NULL, limits = c(y_min_needed, 60/12)) + - ggplot2::coord_fixed(ratio = 1.3, clip = "off") + - ggplot2::scale_fill_manual(values = c( - "Fastball" = "#FA8072", "FourSeamFastBall" = "#FA8072","Four-Seam" = "#FA8072", "Sinker" = "#fdae61", - "Slider" = "#A020F0", "Sweeper" = "magenta", "Curveball" = "#2c7bb6", - "ChangeUp" = "#90EE90", "Splitter" = "#90EE32", "Cutter" = "red" - ), name = "Pitch Type") + - ggplot2::theme( - panel.background = ggplot2::element_rect(fill = "#ffffff", color = NA), - legend.position = "top", - strip.text = ggplot2::element_text(size = 1, vjust = 1), - strip.placement = "outside", - strip.background = ggplot2::element_blank(), - plot.margin = ggplot2::margin(6, 18, 6, 6), - panel.spacing = grid::unit(8, "pt") - ) -} -create_report_spray_chart <- function(game_data, player_name) { - spray_data <- game_data %>% - dplyr::filter(Batter == player_name) %>% - dplyr::arrange(PitchNo) %>% - dplyr::mutate(PitchNumber = dplyr::row_number()) %>% - dplyr::filter(!is.na(Distance), !is.na(Bearing), - PitchCall == "InPlay", - !PitchCall %in% c("FoulBall","FoulBallNotFieldable","FoulBallFieldable")) %>% - dplyr::mutate( - Bearing2 = Bearing * pi/180, - x = Distance * sin(Bearing2), - y = Distance * cos(Bearing2) - ) - if (!nrow(spray_data)) { - return( - ggplot2::ggplot() + - ggplot2::coord_fixed(xlim = c(-360, 360), ylim = c(-20, 410), expand = FALSE) + - ggplot2::annotate("segment", x = 0, y = 0, xend = 247.487, yend = 247.487, color = "gray70") + - ggplot2::annotate("segment", x = 0, y = 0, xend = -247.487, yend = 247.487, color = "gray70") + - ggplot2::annotate("segment", x = 63.6396, y = 63.6396, xend = 0, yend = 127.279, color = "gray70") + - ggplot2::annotate("segment", x = -63.6396, y = 63.6396, xend = 0, yend = 127.279, color = "gray70") + - ggplot2::annotate("curve", x = 89.095, y = 89.095, xend = 0, yend = 160, curvature = 0.36, linewidth = 0.5, color = "gray70") + - ggplot2::annotate("curve", x = -89.095, y = 89.095, xend = 0, yend = 160, curvature = -0.36, linewidth = 0.5, color = "gray70") + - ggplot2::annotate("curve", x = -247.487, y = 247.487, xend = 247.487, yend = 247.487, curvature = -0.65, linewidth = 0.5, color = "gray70") + - ggplot2::ggtitle(paste(player_name, "- Spray Chart")) + - ggplot2::theme_void() + - ggplot2::theme( - plot.margin = ggplot2::margin(5, 5, 5, 5), - plot.title = ggplot2::element_text(hjust = 0.5, size = 9, face = "bold") - ) - ) - } - - ggplot2::ggplot(spray_data, ggplot2::aes(x, y)) + - ggplot2::coord_fixed(xlim = c(-360, 360), ylim = c(-20, 410), expand = FALSE) + - ggplot2::annotate("segment", x = 0, y = 0, xend = 247.487, yend = 247.487, color = "black") + - ggplot2::annotate("segment", x = 0, y = 0, xend = -247.487, yend = 247.487, color = "black") + - ggplot2::annotate("segment", x = 63.6396, y = 63.6396, xend = 0, yend = 127.279, color = "black") + - ggplot2::annotate("segment", x = -63.6396, y = 63.6396, xend = 0, yend = 127.279, color = "black") + - ggplot2::annotate("curve", x = 89.095, y = 89.095, xend = 0, yend = 160, curvature = 0.36, linewidth = 0.5, color = "black") + - ggplot2::annotate("curve", x = -89.095, y = 89.095, xend = 0, yend = 160, curvature = -0.36, linewidth = 0.5, color = "black") + - ggplot2::annotate("curve", x = -247.487, y = 247.487, xend = 247.487, yend = 247.487, curvature = -0.65, linewidth = 0.5, color = "black") + - ggplot2::geom_point(size = 2.8, shape = 21, color = "black", - fill = "darkred", stroke = 0.4, alpha = 0.85) + - ggplot2::geom_text(ggplot2::aes(label = PitchNumber), - size = 1.8, color = "white", fontface = "bold") + - ggplot2::ggtitle(paste(player_name, "- Spray Chart")) + - ggplot2::theme_void() + - ggplot2::theme( - plot.margin = ggplot2::margin(5, 5, 5, 5), - plot.title = ggplot2::element_text(hjust = 0.5, size = 9, face = "bold") - ) -} - -angle_to_clock <- function(angle) { - if (is.na(angle) || !is.finite(angle)) return("-") - angle <- angle %% 360 - total_minutes <- (angle / 360) * 720 - hours <- floor(total_minutes / 60) - minutes <- round(total_minutes %% 60) - if (minutes == 60) { hours <- hours + 1; minutes <- 0 } - hours <- hours %% 12 - if (hours == 0) hours <- 12 - sprintf("%d:%02d", hours, minutes) -} - -# Process bullpen CSV data -process_bullpen_dataset <- function(df) { - if ("Pitcher" %in% names(df)) { - df <- df %>% mutate(Pitcher = stringr::str_replace( - Pitcher, "^\\s*(\\w+)\\s*,\\s*(\\w+)\\s*$", "\\2 \\1")) - } - - df <- df %>% distinct() - if ("PitchUID" %in% names(df)) df <- df %>% distinct(PitchUID, .keep_all = TRUE) - - if ("Date" %in% names(df)) df$Date <- parse_flexible_date(df$Date) - - num_cols <- c("PlateLocSide", "PlateLocHeight", "RelSpeed", "SpinRate", - "InducedVertBreak", "HorzBreak", "RelHeight", "RelSide", - "Extension", "VertApprAngle", "HorzApprAngle", - "SpinAxis", "SpinAxis3dTransverseAngle", - "SpinAxis3dLongitudinalAngle", "SpinAxis3dActiveSpinRate", - "SpinAxis3dSpinEfficiency") - for (col in num_cols) { - if (col %in% names(df)) df[[col]] <- suppressWarnings(as.numeric(df[[col]])) - } - - if ("RelSpeed" %in% names(df)) df <- df %>% filter(!is.na(RelSpeed)) - - if (!"TaggedPitchType" %in% names(df)) { - alt <- intersect(c("pitch_type", "PitchType", "TaggedPitch", "AutoPitchType"), names(df)) - if (length(alt)) df$TaggedPitchType <- df[[alt[1]]] else df$TaggedPitchType <- NA_character_ - } - - df <- df %>% - mutate(TaggedPitchType = case_when( - TaggedPitchType %in% c("FourSeamFastball", "FourSeamFastBall", "4-Seam", - "Four-Seam Fastball", "4-Seam Fastball") ~ "Four-Seam", - TRUE ~ TaggedPitchType - )) - - # Ensure PitchCall exists (bullpen CSVs often have it empty or missing entirely) - if (!"PitchCall" %in% names(df)) df$PitchCall <- NA_character_ - df$PitchCall[is.na(df$PitchCall) | df$PitchCall == ""] <- "Unknown" - - # Ensure SpinAxis columns exist (create as NA if missing) - if (!"SpinAxis" %in% names(df)) df$SpinAxis <- NA_real_ - if (!"SpinAxis3dTransverseAngle" %in% names(df)) df$SpinAxis3dTransverseAngle <- NA_real_ - if (!"SpinAxis3dLongitudinalAngle" %in% names(df)) df$SpinAxis3dLongitudinalAngle <- NA_real_ - if (!"SpinAxis3dActiveSpinRate" %in% names(df)) df$SpinAxis3dActiveSpinRate <- NA_real_ - if (!"SpinAxis3dSpinEfficiency" %in% names(df)) df$SpinAxis3dSpinEfficiency <- NA_real_ - - # Zone indicator only — no game-level stats needed for bullpen - df <- df %>% - mutate( - in_zone = as.integer( - !is.na(PlateLocSide) & !is.na(PlateLocHeight) & - PlateLocSide >= -0.83 & PlateLocSide <= 0.83 & - PlateLocHeight >= 1.5 & PlateLocHeight <= 3.38 - ) - ) - - df -} - -# Calculate bullpen summary table -calculate_bullpen_summary <- function(pitcher_df) { - types <- pitcher_df %>% - filter(!is.na(TaggedPitchType), TaggedPitchType != "", - TaggedPitchType != "Undefined", TaggedPitchType != "Other") %>% - pull(TaggedPitchType) %>% unique() - - if (length(types) == 0) types <- unique(pitcher_df$TaggedPitchType) - - observed_tilt <- pitcher_df %>% - filter(TaggedPitchType %in% types) %>% - group_by(TaggedPitchType) %>% - summarise(obs_axis = round(median(SpinAxis, na.rm = TRUE), 1), .groups = "drop") %>% - mutate(`Obs Tilt` = sapply(obs_axis, angle_to_clock)) %>% - select(TaggedPitchType, `Obs Tilt`) - - measured_tilt <- pitcher_df %>% - mutate( - SpinAxis3dTransverseAngle = SpinAxis3dTransverseAngle + 180, - SpinAxis3dTransverseAngle = ifelse(SpinAxis3dTransverseAngle > 360, - SpinAxis3dTransverseAngle - 360, - SpinAxis3dTransverseAngle) - ) %>% - filter(TaggedPitchType %in% types) %>% - group_by(TaggedPitchType) %>% - summarise(meas_axis = round(median(SpinAxis3dTransverseAngle, na.rm = TRUE), 1), .groups = "drop") %>% - mutate(`Meas Tilt` = sapply(meas_axis, angle_to_clock)) %>% - select(TaggedPitchType, `Meas Tilt`) - - summary_df <- pitcher_df %>% - filter(TaggedPitchType %in% types) %>% - group_by(TaggedPitchType) %>% - summarise( - `#` = n(), - Velo = round(mean(RelSpeed, na.rm = TRUE), 1), - `Max Velo` = round(max(RelSpeed, na.rm = TRUE), 1), - IVB = round(mean(InducedVertBreak, na.rm = TRUE), 1), - HB = round(mean(HorzBreak, na.rm = TRUE), 1), - `Total Spin` = round(mean(SpinRate, na.rm = TRUE), 0), - `Spin Eff%` = round(100 * mean(SpinAxis3dSpinEfficiency, na.rm = TRUE), 0), - `Gyro` = round(mean(SpinAxis3dLongitudinalAngle, na.rm = TRUE), 0), - RelH = round(mean(RelHeight, na.rm = TRUE), 2), - RelS = round(mean(RelSide, na.rm = TRUE), 2), - Ext = round(mean(Extension, na.rm = TRUE), 2), - `Zone%` = round(100 * mean(in_zone, na.rm = TRUE), 1), - .groups = "drop" - ) %>% - rename(Pitch = TaggedPitchType) %>% - arrange(desc(`#`)) - - summary_df <- summary_df %>% - left_join(observed_tilt, by = c("Pitch" = "TaggedPitchType")) %>% - left_join(measured_tilt, by = c("Pitch" = "TaggedPitchType")) - - summary_df <- summary_df %>% - select(Pitch, `#`, Velo, `Max Velo`, IVB, HB, `Total Spin`, - `Spin Eff%`, `Gyro`, `Obs Tilt`, `Meas Tilt`, RelH, RelS, Ext, `Zone%`) - - # Add totals row - all_data <- pitcher_df %>% filter(TaggedPitchType %in% types) - - obs_axis_all <- round(median(all_data$SpinAxis, na.rm = TRUE), 1) - meas_raw <- all_data$SpinAxis3dTransverseAngle + 180 - meas_raw <- ifelse(meas_raw > 360, meas_raw - 360, meas_raw) - meas_axis_all <- round(median(meas_raw, na.rm = TRUE), 1) - - totals_row <- data.frame( - Pitch = "Total", - `#` = sum(summary_df$`#`, na.rm = TRUE), - Velo = round(mean(all_data$RelSpeed, na.rm = TRUE), 1), - `Max Velo` = round(max(all_data$RelSpeed, na.rm = TRUE), 1), - IVB = round(mean(all_data$InducedVertBreak, na.rm = TRUE), 1), - HB = round(mean(all_data$HorzBreak, na.rm = TRUE), 1), - `Total Spin` = round(mean(all_data$SpinRate, na.rm = TRUE), 0), - `Spin Eff%` = round(100 * mean(all_data$SpinAxis3dSpinEfficiency, na.rm = TRUE), 0), - `Gyro` = round(mean(all_data$SpinAxis3dLongitudinalAngle, na.rm = TRUE), 0), - `Obs Tilt` = angle_to_clock(obs_axis_all), - `Meas Tilt` = angle_to_clock(meas_axis_all), - RelH = round(mean(all_data$RelHeight, na.rm = TRUE), 2), - RelS = round(mean(all_data$RelSide, na.rm = TRUE), 2), - Ext = round(mean(all_data$Extension, na.rm = TRUE), 2), - `Zone%` = round(100 * mean(all_data$in_zone, na.rm = TRUE), 1), - check.names = FALSE, - stringsAsFactors = FALSE - ) - - summary_df <- dplyr::bind_rows(summary_df, totals_row) - - summary_df -} - -# Bullpen location plot (no shape manual - simplified) -create_bullpen_location_plot <- function(pitcher_df, pitch_colors) { - filter_types <- pitcher_df %>% - filter(!is.na(TaggedPitchType), TaggedPitchType != "", - TaggedPitchType != "Undefined", TaggedPitchType != "Other") %>% - pull(TaggedPitchType) %>% unique() - - df <- pitcher_df %>% - filter(TaggedPitchType %in% filter_types, - !is.na(PlateLocSide), !is.na(PlateLocHeight)) - - if (nrow(df) == 0) { - return(ggplot() + theme_void() + labs(title = "Pitch Locations") + - theme(plot.title = element_text(size = 12, face = "bold", hjust = 0.5))) - } - - zone_left <- -0.8333; zone_right <- 0.8333 - zone_bottom <- 1.5; zone_top <- 3.5 - shadow_left <- -1.1; shadow_right <- 1.1 - shadow_bottom <- 1.2; shadow_top <- 3.8 - zone_width <- (zone_right - zone_left) / 3 - zone_height <- (zone_top - zone_bottom) / 3 - - ggplot(df, aes(x = PlateLocSide, y = PlateLocHeight)) + - annotate("rect", xmin = shadow_left, xmax = shadow_right, - ymin = shadow_bottom, ymax = shadow_top, - fill = NA, color = "gray30", linetype = "dashed", linewidth = 0.5) + - annotate("rect", xmin = zone_left, xmax = zone_right, - ymin = zone_bottom, ymax = zone_top, - fill = NA, color = "#E74C3C", linewidth = 1) + - annotate("segment", x = zone_left + zone_width, xend = zone_left + zone_width, - y = zone_bottom, yend = zone_top, color = "gray50", linetype = "dashed", linewidth = 0.3) + - annotate("segment", x = zone_left + 2*zone_width, xend = zone_left + 2*zone_width, - y = zone_bottom, yend = zone_top, color = "gray50", linetype = "dashed", linewidth = 0.3) + - annotate("segment", x = zone_left, xend = zone_right, - y = zone_bottom + zone_height, yend = zone_bottom + zone_height, - color = "gray50", linetype = "dashed", linewidth = 0.3) + - annotate("segment", x = zone_left, xend = zone_right, - y = zone_bottom + 2*zone_height, yend = zone_bottom + 2*zone_height, - color = "gray50", linetype = "dashed", linewidth = 0.3) + - annotate("polygon", x = c(-0.708, 0.708, 0.708, 0, -0.708), - y = c(0.15, 0.15, 0.30, 0.50, 0.30), fill = NA, color = "black", linewidth = 0.5) + - geom_point(aes(fill = TaggedPitchType), size = 3, shape = 21, - color = "black", stroke = 0.5, alpha = 0.85) + - scale_fill_manual(values = pitch_colors, name = "Pitch") + - coord_fixed(xlim = c(-2.2, 2.2), ylim = c(0, 4.2)) + - labs(title = "Pitch Locations") + - theme_minimal() + - theme( - plot.title = element_text(size = 12, face = "bold", hjust = 0.5), - legend.position = "bottom", - legend.title = element_text(size = 8, face = "bold"), - legend.text = element_text(size = 7), - axis.text = element_blank(), axis.title = element_blank(), - axis.ticks = element_blank(), panel.grid = element_blank(), - plot.margin = margin(2, 2, 2, 2) - ) -} - -# Bullpen movement plot -create_bullpen_movement_plot <- function(pitcher_df, pitcher_name, pitch_colors) { - df <- pitcher_df %>% filter(!is.na(TaggedPitchType), - TaggedPitchType != "Other", - TaggedPitchType != "Undefined") - if (nrow(df) == 0) return(ggplot() + theme_void() + ggtitle("Pitch Movement")) - - centers <- df %>% group_by(TaggedPitchType) %>% - summarise( - mean_velo = round(mean(RelSpeed, na.rm = TRUE)), - mean_hb = median(HorzBreak, na.rm = TRUE), - mean_ivb = median(InducedVertBreak, na.rm = TRUE), - .groups = "drop" - ) - - ggplot(df, aes(x = HorzBreak, y = InducedVertBreak)) + - geom_vline(xintercept = 0, color = "black", linewidth = 0.5) + - geom_hline(yintercept = 0, color = "black", linewidth = 0.5) + - geom_point(aes(fill = TaggedPitchType), alpha = 0.85, shape = 21, - color = "black", stroke = 0.4, size = 4.5) + - geom_point(data = centers, aes(x = mean_hb, y = mean_ivb, fill = TaggedPitchType), - alpha = 1, shape = 21, color = "black", stroke = 0.5, size = 4) + - geom_text(data = centers, aes(x = mean_hb, y = mean_ivb, label = mean_velo), - color = "black", size = 2, vjust = 0.5, fontface = "bold") + - scale_fill_manual(values = pitch_colors) + - coord_fixed(ratio = 1, xlim = c(-27.5, 27.5), ylim = c(-27.5, 27.5)) + - labs(title = "Movement Profile", x = "Horizontal Break (in)", y = "Induced Vertical Break (in)") + - theme_minimal(base_size = 11) + - theme( - plot.title = element_text(size = 12, face = "bold", hjust = 0.5), - legend.position = "none", - panel.grid.minor = element_blank(), - plot.margin = margin(4, 4, 4, 4) - ) -} - -# Bullpen release point plot -create_bullpen_release_plot <- function(pitcher_df, pitcher_name, pitch_colors) { - df <- pitcher_df %>% - filter(!is.na(RelSide), !is.na(RelHeight), - !is.na(TaggedPitchType), TaggedPitchType != "Other", - TaggedPitchType != "Undefined") - - if (nrow(df) == 0) { - return(ggplot() + theme_void() + ggtitle("Release Points") + - theme(plot.title = element_text(size = 12, face = "bold", hjust = 0.5))) - } - - avg_release <- df %>% - group_by(TaggedPitchType) %>% - summarise(RelSide = mean(RelSide, na.rm = TRUE), - RelHeight = mean(RelHeight, na.rm = TRUE), .groups = "drop") - - ggplot() + - geom_point(data = df, aes(RelSide, RelHeight, fill = TaggedPitchType), - size = 2, shape = 21, color = "black", alpha = 0.85, stroke = 0.25) + - geom_point(data = avg_release, aes(RelSide, RelHeight, fill = TaggedPitchType), - size = 3, shape = 21, color = "black", stroke = 0.3, alpha = 1) + - annotate("text", x = -5, y = 8, label = "\u2190 1B", size = 3, hjust = 0) + - annotate("text", x = 5, y = 8, label = "3B \u2192", size = 3, hjust = 1) + - geom_rect(aes(xmin = -5, xmax = 5, ymin = 0, ymax = 0.83), - fill = "#632b11", inherit.aes = FALSE) + - geom_rect(aes(xmin = -0.5, xmax = 0.5, ymin = 0.8, ymax = 0.95), - fill = "white", color = "black", linewidth = 0.4, inherit.aes = FALSE) + - scale_fill_manual(values = pitch_colors, name = "Pitch Type") + - coord_fixed(ratio = 1, xlim = c(-4, 4), ylim = c(0, 8), clip = "off") + - labs(title = "Release Points", x = "Release Side (ft)", y = "Release Height (ft)") + - theme_minimal(base_size = 11) + - theme( - plot.title = element_text(size = 12, face = "bold", hjust = 0.5), - legend.position = "none", - plot.margin = margin(4, 4, 4, 4) - ) -} - -# Main bullpen PDF generator (LANDSCAPE) -create_bullpen_pdf_report <- function(bp_data, pitcher_name, output_file, intent_level = "High") { - if (length(dev.list()) > 0) try(dev.off(), silent = TRUE) - - pitch_colors <- c( - "Fastball" = "#3465cb", "Four-Seam" = "#3465cb", "FourSeamFastBall" = "#3465cb", - "4-Seam Fastball" = "#3465cb", "FF" = "#3465cb", - "Sinker" = "#e5e501", "TwoSeamFastBall" = "#e5e501", "Two-Seam" = "#e5e501", - "2-Seam Fastball" = "#e5e501", "SI" = "#e5e501", - "Slider" = "#65aa02", "SL" = "#65aa02", - "Sweeper" = "#dc4476", "SW" = "#dc4476", - "Curveball" = "#d73813", "CB" = "#d73813", "Knuckle Curve" = "#d73813", "KC" = "#d73813", - "ChangeUp" = "#980099", "Changeup" = "#980099", "CH" = "#980099", - "Splitter" = "#23a999", "FS" = "#23a999", "SP" = "#23a999", - "Cutter" = "#ff9903", "FC" = "#ff9903", - "Slurve" = "#9370DB", - "Other" = "gray50" - ) - - pitcher_df <- bp_data %>% filter(Pitcher == pitcher_name) - - if (nrow(pitcher_df) == 0) { - pdf(output_file, width = 11, height = 7) - grid::grid.newpage() - grid::grid.text(paste("No data found for", pitcher_name), - gp = grid::gpar(fontsize = 16, fontface = "bold")) - dev.off() - return(output_file) - } - - game_date <- tryCatch({ - d <- unique(pitcher_df$Date)[1] - parsed <- parse_flexible_date(d) - if (!is.na(parsed)) format(parsed, "%m/%d/%Y") else "N/A" - }, error = function(e) "N/A") - - summary_table <- calculate_bullpen_summary(pitcher_df) - loc_plot <- create_bullpen_location_plot(pitcher_df, pitch_colors) - mov_plot <- create_bullpen_movement_plot(pitcher_df, pitcher_name, pitch_colors) - rel_plot <- create_bullpen_release_plot(pitcher_df, pitcher_name, pitch_colors) - - # LANDSCAPE PDF - compact height - pdf(output_file, width = 11, height = 7) - on.exit(try(dev.off(), silent = TRUE), add = TRUE) - grid::grid.newpage() - - # ===== HEADER BAR ===== - grid::grid.rect(x = 0, y = 0.945, width = 1, height = 0.055, - just = c("left", "bottom"), - gp = grid::gpar(fill = "#006F71", col = NA)) - - grid::grid.text("Bullpen Report", x = 0.02, y = 0.972, just = "left", - gp = grid::gpar(col = "white", fontface = "bold", cex = 1.2)) - - # Logo - try({ - logo_img <- magick::image_read("https://i.imgur.com/zjTu3JS.png") - logo_img <- magick::image_resize(logo_img, "x140") - logo_grob <- grid::rasterGrob(as.raster(logo_img), interpolate = TRUE) - grid::pushViewport(grid::viewport(x = 0.988, y = 0.972, - width = 0.10, height = 0.048, - just = c("right", "center"))) - grid::grid.draw(logo_grob) - grid::popViewport() - }, silent = TRUE) - - # ===== INFO LINE ===== - intent_color <- switch(intent_level, - "Low" = "#2980B9", - "Medium" = "#F39C12", - "High" = "#E74C3C", - "black" - ) - - grid::grid.text(paste0(game_date, " | ", pitcher_name, " | Intent: "), - x = 0.02, y = 0.92, just = "left", - gp = grid::gpar(cex = 0.85, fontface = "bold", col = "black")) - - # Measure text width for intent label positioning - info_base <- paste0(game_date, " | ", pitcher_name, " | Intent: ") - info_grob <- grid::textGrob(info_base, gp = grid::gpar(cex = 0.85, fontface = "bold")) - info_width <- grid::convertWidth(grid::grobWidth(info_grob), "npc", valueOnly = TRUE) - - grid::grid.text(intent_level, - x = 0.02 + info_width, y = 0.92, just = "left", - gp = grid::gpar(cex = 0.90, fontface = "bold", col = intent_color)) - - # ===== SUMMARY TABLE ===== - if (nrow(summary_table) > 0) { - headers <- names(summary_table) - num_cols <- length(headers) - - pitch_w <- 0.08 - remaining_w <- (0.96 - pitch_w) / (num_cols - 1) - col_widths <- c(pitch_w, rep(remaining_w, num_cols - 1)) - - x_start <- 0.5 - sum(col_widths) / 2 - x_pos <- c(x_start, x_start + cumsum(col_widths[-length(col_widths)])) - - row_h <- 0.038 - y_top <- 0.89 - header_cex <- 0.65 - cell_cex <- 0.65 - - # Draw headers - for (i in seq_along(headers)) { - grid::grid.rect(x = x_pos[i], y = y_top, width = col_widths[i] * 0.985, height = row_h, - just = c("left", "top"), - gp = grid::gpar(fill = "#006F71", col = "black", lwd = 0.5)) - grid::grid.text(headers[i], - x = x_pos[i] + col_widths[i] * 0.49, y = y_top - row_h * 0.5, - gp = grid::gpar(col = "white", cex = header_cex, fontface = "bold")) - } - - # Draw data rows - for (r in seq_len(nrow(summary_table))) { - y_row <- y_top - r * row_h - is_totals <- (r == nrow(summary_table) && summary_table$Pitch[r] == "Total") - - for (i in seq_along(headers)) { - val <- as.character(summary_table[[i]][r]) - if (is.na(val) || val == "NaN" || val == "Inf" || val == "-Inf") val <- "-" - - bg <- ifelse(r %% 2 == 0, "#f7f7f7", "white") - txt_col <- "black" - font_face <- "plain" - - # Totals row styling - if (is_totals) { - bg <- "#e0e0e0" - font_face <- "bold" - } - - # Pitch column coloring - if (i == 1 && !is_totals) { - pitch_name <- summary_table$Pitch[r] - if (!is.na(pitch_name) && pitch_name %in% names(pitch_colors)) { - bg <- pitch_colors[[pitch_name]] - rgb_vals <- grDevices::col2rgb(bg) / 255 - luminance <- 0.2126 * rgb_vals[1] + 0.7152 * rgb_vals[2] + 0.0722 * rgb_vals[3] - txt_col <- ifelse(luminance < 0.5, "white", "black") - } - font_face <- "bold" - } else if (i == 1 && is_totals) { - font_face <- "bold" - } - - grid::grid.rect(x = x_pos[i], y = y_row, width = col_widths[i] * 0.985, height = row_h, - just = c("left", "top"), - gp = grid::gpar(fill = bg, col = "grey80", lwd = 0.3)) - grid::grid.text(val, - x = x_pos[i] + col_widths[i] * 0.49, y = y_row - row_h * 0.5, - gp = grid::gpar(cex = cell_cex, col = txt_col, fontface = font_face)) - } - } - - table_bottom <- y_top - (nrow(summary_table) + 1) * row_h - } else { - table_bottom <- 0.85 - } - - # ===== THREE CHARTS ===== - chart_top <- table_bottom - 0.025 - chart_h <- min(0.42, chart_top - 0.05) # cap height so charts don't stretch - chart_w <- 0.32 - - grid::pushViewport(grid::viewport(x = 0.18, y = chart_top, width = chart_w, height = chart_h, - just = c("center", "top"))) - tryCatch(print(loc_plot, newpage = FALSE), error = function(e) NULL) - grid::popViewport() - - grid::pushViewport(grid::viewport(x = 0.50, y = chart_top, width = chart_w, height = chart_h, - just = c("center", "top"))) - tryCatch(print(mov_plot, newpage = FALSE), error = function(e) NULL) - grid::popViewport() - - grid::pushViewport(grid::viewport(x = 0.82, y = chart_top, width = chart_w, height = chart_h, - just = c("center", "top"))) - tryCatch(print(rel_plot, newpage = FALSE), error = function(e) NULL) - grid::popViewport() - - grid::grid.text("Data: TrackMan | Report Generated: Coastal Carolina Baseball Analytics", - x = 0.5, y = 0.02, gp = grid::gpar(cex = 0.65, col = "grey50")) - - invisible(output_file) -} - - -create_report_contact_chart <- function(game_data, player_name) { - contact_data <- game_data %>% - filter(Batter == player_name) %>% - arrange(PitchNo) %>% - mutate(PitchNumber = row_number()) %>% - filter(!is.na(ExitSpeed), !is.na(ContactPositionZ), - !is.na(ContactPositionX), !is.na(ContactPositionY), - PitchCall == "InPlay", - !PitchCall %in% c("FoulBall", "FoulBallNotFieldable", "FoulBallFieldable")) %>% - mutate(ContactPositionX = ContactPositionX*12, - ContactPositionY = ContactPositionY*12, - ContactPositionZ = ContactPositionZ*12) - if (!nrow(contact_data)) { - return( - ggplot() + - annotate("segment", x = -8.5, y = 17, xend = 8.5, yend = 17, color = "gray70", linewidth = 0.5) + - annotate("segment", x = 8.5, y = 8.5, xend = 8.5, yend = 17, color = "gray70", linewidth = 0.5) + - annotate("segment", x = -8.5, y = 8.5, xend = -8.5, yend = 17, color = "gray70", linewidth = 0.5) + - annotate("segment", x = -8.5, y = 8.5, xend = 0, yend = 0, color = "gray70", linewidth = 0.5) + - annotate("segment", x = 8.5, y = 8.5, xend = 0, yend = 0, color = "gray70", linewidth = 0.5) + - annotate("rect", xmin = 20, xmax = 48, ymin = -20, ymax = 40, fill = NA, color = "gray70", linewidth = 0.5) + - annotate("rect", xmin = -48, xmax = -20, ymin = -20, ymax = 40, fill = NA, color = "gray70", linewidth = 0.5) + - xlim(-50, 50) + ylim(-20, 50) + - coord_fixed() + - ggtitle(paste(player_name, "- Contact Points")) + - theme_void() + - theme( - plot.margin = margin(2, 2, 2, 2), - plot.title = element_text(hjust = 0.5, size = 9, face = "bold") - ) - ) - } - - batter_side <- contact_data$BatterSide[1]; if (is.na(batter_side)) batter_side <- "Right" - - ggplot(contact_data, aes(x = ContactPositionZ, y = ContactPositionX)) + - annotate("segment", x = -8.5, y = 17, xend = 8.5, yend = 17, color = "black", linewidth = 0.5) + - annotate("segment", x = 8.5, y = 8.5, xend = 8.5, yend = 17, color = "black", linewidth = 0.5) + - annotate("segment", x = -8.5, y = 8.5, xend = -8.5, yend = 17, color = "black", linewidth = 0.5) + - annotate("segment", x = -8.5, y = 8.5, xend = 0, yend = 0, color = "black", linewidth = 0.5) + - annotate("segment", x = 8.5, y = 8.5, xend = 0, yend = 0, color = "black", linewidth = 0.5) + - annotate("rect", xmin = 20, xmax = 48, ymin = -20, ymax = 40, fill = NA, color = "black", linewidth = 0.5) + - annotate("rect", xmin = -48, xmax = -20, ymin = -20, ymax = 40, fill = NA, color = "black", linewidth = 0.5) + - annotate("text", x = ifelse(batter_side == "Right", -34, 34), y = 10, - label = ifelse(batter_side == "Right", "R", "L"), size = 3.5, fontface = "bold") + - xlim(-50, 50) + ylim(-20, 50) + - geom_point(aes(fill = ExitSpeed), color = "black", stroke = .25, shape = 21, alpha = .85, size = 2.8) + - geom_text(aes(label = PitchNumber), size = 1.7, color = "white", fontface = "bold") + - scale_fill_gradient(name = "Exit Velo", low = "#E1463E", high = "#00840D") + - coord_fixed() + - ggtitle(paste(player_name, "- Contact Points")) + - theme_void() + - theme( - legend.position = "right", - plot.margin = margin(2, 2, 2, 2), - plot.title = element_text(hjust = 0.5, size = 9, face = "bold"), - legend.title = element_text(size = 7), - legend.text = element_text(size = 6), - legend.key.height = unit(0.4, "cm"), - legend.key.width = unit(0.3, "cm") - ) -} -calculate_leaderboards <- function(df, team_meta_df = team_meta) { - - format_name <- function(name) { - if (is.na(name)) return(name) - stringr::str_replace(name, "^\\s*(\\w+)\\s*,\\s*(\\w+)\\s*$", "\\2 \\1") - } - -get_logo <- function(team_abbr) { - if (is.null(team_master) || is.null(team_abbr) || is.na(team_abbr)) return("") - team_abbr <- trimws(as.character(team_abbr)) - if (team_abbr == "") return("") - idx <- which(trimws(team_master$tm_abbrev) == team_abbr) - if (length(idx) > 0 && nzchar(team_master$logo_url[idx[1]])) { - return(team_master$logo_url[idx[1]]) - } - "" -} - -get_team_name <- function(abbr) { - if (is.null(team_master) || is.null(abbr) || is.na(abbr)) return(as.character(abbr)) - abbr <- trimws(as.character(abbr)) - if (abbr == "") return(abbr) - idx <- which(trimws(team_master$tm_abbrev) == abbr) - if (length(idx) > 0) return(team_master$team_name[idx[1]]) - abbr -} - - # Game Info - stadium <- if ("Stadium" %in% names(df)) unique(na.omit(df$Stadium))[1] else "Unknown" - level <- if ("Level" %in% names(df)) unique(na.omit(df$Level))[1] else "" - league <- if ("League" %in% names(df)) unique(na.omit(df$League))[1] else "" - - game_date <- if ("Date" %in% names(df)) { - raw_date <- unique(na.omit(df$Date))[1] - parsed <- tryCatch(parse_flexible_date(raw_date), error = function(e) NA) - if (is.na(parsed)) "N/A" else format(parsed, "%m/%d/%Y") - } else "N/A" - - # Calculate final score from RunsScored - teams <- unique(c(df$BatterTeam, df$PitcherTeam)) - teams <- teams[!is.na(teams)] - - score_info <- df %>% - filter(!is.na(RunsScored), RunsScored > 0) %>% - group_by(BatterTeam) %>% - summarise(Runs = sum(RunsScored, na.rm = TRUE), .groups = "drop") %>% - arrange(desc(Runs)) - - if (nrow(score_info) >= 2) { - team1 <- score_info$BatterTeam[1] - runs1 <- score_info$Runs[1] - team2 <- score_info$BatterTeam[2] - runs2 <- score_info$Runs[2] - - - final_score <- paste0(get_team_name(team1), " ", runs1, " - ", - get_team_name(team2), " ", runs2) - } else { - final_score <- "Score N/A" - } - - game_info <- list( - stadium = stadium, - level = level, - league = league, - date = game_date, - final_score = final_score - ) - - top_ev <- df %>% - filter(!is.na(ExitSpeed), !is.na(Batter)) %>% - select(Batter, BatterTeam, ExitSpeed) %>% - arrange(desc(ExitSpeed)) %>% - head(5) %>% - rename(MaxEV = ExitSpeed) %>% - mutate(Batter = sapply(Batter, format_name), - Logo = sapply(BatterTeam, get_logo)) - - top_dist <- df %>% - filter(!is.na(Distance), !is.na(Batter), Distance > 0) %>% - select(Batter, BatterTeam, Distance) %>% - arrange(desc(Distance)) %>% - head(5) %>% - rename(MaxDist = Distance) %>% - mutate(Batter = sapply(Batter, format_name), - Logo = sapply(BatterTeam, get_logo)) - - top_velo <- df %>% - filter(!is.na(RelSpeed), !is.na(Pitcher)) %>% - select(Pitcher, PitcherTeam, RelSpeed) %>% - arrange(desc(RelSpeed)) %>% - head(5) %>% - rename(MaxVelo = RelSpeed) %>% - mutate(Pitcher = sapply(Pitcher, format_name), - Logo = sapply(PitcherTeam, get_logo)) - - top_whiffs <- df %>% - filter(!is.na(Pitcher)) %>% - group_by(Pitcher, PitcherTeam) %>% - summarise(Whiffs = sum(PitchCall == "StrikeSwinging", na.rm = TRUE), .groups = "drop") %>% - arrange(desc(Whiffs)) %>% - head(5) %>% - mutate(Pitcher = sapply(Pitcher, format_name), - Logo = sapply(PitcherTeam, get_logo)) - - list( - game_info = game_info, - exit_velo = top_ev, - distance = top_dist, - pitch_velo = top_velo, - whiffs = top_whiffs - ) -} -create_simple_header <- function(player_name, game_date, bio_data = NULL) { - library(grid) - library(gridExtra) - - # Load logos - left_logo <- tryCatch({ - rasterGrob(as.raster(magick::image_resize( - magick::image_read("https://i.ibb.co/gLfTW4Fz/t-GPe-TPu.png"), "x120" - )), interpolate = TRUE) - }, error = function(e) nullGrob()) - - right_logo <- tryCatch({ - rasterGrob(as.raster(magick::image_resize( - magick::image_read("https://i.imgur.com/zjTu3JS.png"), "x120" - )), interpolate = TRUE) - }, error = function(e) nullGrob()) - - # Title text - title_text <- paste(player_name, "-", format(game_date, "%m/%d/%y"), "- Hitter Report") - - title_grob <- textGrob( - title_text, - gp = gpar(fontsize = 18, fontface = "bold", col = "#006F71") - ) - - # Layout with logos - arrangeGrob( - arrangeGrob(left_logo, title_grob, right_logo, ncol = 3, - widths = c(.15, .7, .15)), - ncol = 1 - ) -} - -# Helper function to check if bat tracking data is available -has_bat_tracking <- function(df) { - bat_cols <- c("BatSpeed", "VerticalAttackAngle", "HorizontalAttackAngle") - cols_present <- bat_cols %in% names(df) - if (!all(cols_present)) return(FALSE) - - # Check if there's at least some non-NA data in any of these columns - any_data <- any( - !is.na(df$BatSpeed) | - !is.na(df$VerticalAttackAngle) | - !is.na(df$HorizontalAttackAngle) - ) - return(any_data) -} - -create_postgame_pdf <- function(game_df, player_name, output_file, bio_data = NULL) { - if (length(dev.list()) > 0) { try(dev.off(), silent = TRUE) } - - pitch_colors <- c( - "Fastball" = "#FA8072", "Four-Seam" = "#FA8072", "FourSeamFastBall" = "#FA8072", "Sinker" = "#fdae61", - "Slider" = "#A020F0", "Sweeper" = "magenta", "Curveball" = "#2c7bb6", - "ChangeUp" = "#90EE90", "Splitter" = "#90EE32", "Cutter" = "red" - ) - - batter_df <- dplyr::filter(game_df, Batter == player_name) - game_day <- parse_game_day(batter_df, tz = "America/New_York") - game_key <- format(game_day, "%Y-%m-%d") - - # Check if bat tracking data is available - bat_tracking_available <- has_bat_tracking(batter_df) - - game_stats <- batter_df %>% - summarise( - PA = sum(PAindicator, na.rm = TRUE), - H = sum(HitIndicator, na.rm = TRUE), - XBH = sum(PlayResult %in% c("Double","Triple","HomeRun"), na.rm = TRUE), - BB = sum(WalkIndicator, na.rm = TRUE), - K = sum(KorBB == "Strikeout", na.rm = TRUE), - Chase = sum(Chaseindicator, na.rm = TRUE), - Whiffs = sum(WhiffIndicator, na.rm = TRUE), - `IZ Whiffs` = sum(Zwhiffind, na.rm = TRUE), - BIP = sum(BIPind, na.rm = TRUE), - `Avg EV` = round(mean(ExitSpeed[PitchCall == "InPlay"], na.rm = TRUE), 1), - `Avg LA` = round(mean(Angle[PitchCall == "InPlay"], na.rm = TRUE), 1), - HH = sum(HHind, na.rm = TRUE), - .groups = "drop" - ) - - pitch_sequence <- batter_df %>% - arrange(PitchNo) %>% - mutate(PitchNumber = row_number()) %>% - select(PitchNumber, dplyr::everything()) - - at_bats_plot <- create_at_bats_plot(game_df, player_name, game_key, pitch_colors) + - theme( - legend.position = "top", plot.margin = margin(2,2,2,2), - axis.title = element_blank(), axis.text = element_blank(), axis.ticks = element_blank(), - strip.text = element_text(size = 9), legend.title = element_text(size = 9), legend.text = element_text(size = 8) - ) - spray_plot <- create_report_spray_chart(game_df, player_name) - contact_plot <- create_report_contact_chart(game_df, player_name) - - # Build pitch log with conditional bat tracking columns - # New order: Inning, Pitcher, Count, Pitch, Velo, IVB, HB, VAA, EV, LA, Dist, Bat Speed, AA, HAA - pitch_log <- pitch_sequence %>% - filter(PitchCall == "InPlay") %>% - mutate( - Throws = ifelse(PitcherThrows == "Right", "R", "L"), - event = dplyr::case_when( - !is.na(PlayResult) & PlayResult != "Undefined" ~ PlayResult, TRUE ~ "Out" - ), - Count = paste0(Balls, "-", Strikes), - EV = round(ExitSpeed), - LA = round(Angle), - Dist = ifelse(!is.na(Distance), round(Distance), NA), - Velo = round(RelSpeed, 1), - # Pitch movement metrics - IVB = ifelse("InducedVertBreak" %in% names(.) & !is.na(InducedVertBreak), - round(InducedVertBreak, 1), NA), - HB = ifelse("HorzBreak" %in% names(.) & !is.na(HorzBreak), - round(HorzBreak, 1), NA), - # Vertical Approach Angle (pitch) - VAA = ifelse("VertApprAngle" %in% names(.) & !is.na(VertApprAngle), - round(VertApprAngle, 1), NA) - ) - - # Add bat tracking columns if available - if (bat_tracking_available) { - pitch_log <- pitch_log %>% - mutate( - BatSpd = ifelse("BatSpeed" %in% names(.) & !is.na(BatSpeed), - round(BatSpeed, 1), NA), - AA = ifelse("VerticalAttackAngle" %in% names(.) & !is.na(VerticalAttackAngle), - round(VerticalAttackAngle, 1), NA), - HAA = ifelse("HorizontalAttackAngle" %in% names(.) & !is.na(HorizontalAttackAngle), - round(HorizontalAttackAngle, 1), NA) - ) - } - - # Select columns based on availability - if (bat_tracking_available) { - pitch_log <- pitch_log %>% - select(PitchNumber, Inning, Pitcher, Count, TaggedPitchType, Velo, IVB, HB, VAA, - event, EV, LA, Dist, BatSpd, AA, HAA) - } else { - pitch_log <- pitch_log %>% - select(PitchNumber, Inning, Pitcher, Count, TaggedPitchType, Velo, IVB, HB, VAA, - event, EV, LA, Dist) - } - - chart_y <- 0.36 - chart_h <- 0.22 - plot_w <- 0.35 - table_title_y <- 0.13 - table_y <- 0.11 - - # Updated draw function with bat tracking support - draw_pitch_table <- function(df, y_top, row_height = 0.0135, cex = 0.58, include_bat_tracking = FALSE) { - if (include_bat_tracking) { - # Headers with bat tracking: #, Inn, Pitcher, Count, Pitch, Velo, IVB, HB, VAA, Event, EV, LA, Dist, BatSpd, AA, HAA - headers <- c("#", "Inn", "Pitcher", "Count", "Pitch", "Velo", "IVB", "HB", "VAA", "Event", "EV", "LA", "Dist", "BatSpd", "AA", "HAA") - widths <- c(0.025, 0.03, 0.12, 0.04, 0.065, 0.04, 0.04, 0.04, 0.04, 0.065, 0.035, 0.035, 0.04, 0.045, 0.04, 0.04) - } else { - # Headers without bat tracking: #, Inn, Pitcher, Count, Pitch, Velo, IVB, HB, VAA, Event, EV, LA, Dist - headers <- c("#", "Inn", "Pitcher", "Count", "Pitch", "Velo", "IVB", "HB", "VAA", "Event", "EV", "LA", "Dist") - widths <- c(0.03, 0.035, 0.15, 0.05, 0.08, 0.05, 0.05, 0.05, 0.05, 0.08, 0.045, 0.045, 0.05) - } - - x_start <- 0.5 - sum(widths)/2 - x_pos <- c(x_start, x_start + cumsum(widths[-length(widths)])) - - # Draw headers - for (i in seq_along(headers)) { - grid.rect(x = x_pos[i], y = y_top, width = widths[i]*0.985, height = row_height, - just = c("left","top"), gp = gpar(fill = "#006F71", col = "black", lwd = 0.4)) - grid.text(headers[i], x = x_pos[i] + widths[i]*0.49, y = y_top - row_height*0.5, - gp = gpar(col = "white", cex = cex, fontface = "bold")) - } - - # Draw rows - for (r in seq_len(nrow(df))) { - y_row <- y_top - r*row_height - - if (include_bat_tracking) { - row_vals <- c( - df$PitchNumber[r], - ifelse(is.na(df$Inning[r]), "-", df$Inning[r]), - df$Pitcher[r], - df$Count[r], - df$TaggedPitchType[r], - ifelse(is.na(df$Velo[r]), "-", df$Velo[r]), - ifelse(is.na(df$IVB[r]), "-", df$IVB[r]), - ifelse(is.na(df$HB[r]), "-", df$HB[r]), - ifelse(is.na(df$VAA[r]), "-", df$VAA[r]), - df$event[r], - ifelse(is.na(df$EV[r]), "-", df$EV[r]), - ifelse(is.na(df$LA[r]), "-", df$LA[r]), - ifelse(is.na(df$Dist[r]), "-", df$Dist[r]), - ifelse(is.na(df$BatSpd[r]), "-", df$BatSpd[r]), - ifelse(is.na(df$AA[r]), "-", df$AA[r]), - ifelse(is.na(df$HAA[r]), "-", df$HAA[r]) - ) - } else { - row_vals <- c( - df$PitchNumber[r], - ifelse(is.na(df$Inning[r]), "-", df$Inning[r]), - df$Pitcher[r], - df$Count[r], - df$TaggedPitchType[r], - ifelse(is.na(df$Velo[r]), "-", df$Velo[r]), - ifelse(is.na(df$IVB[r]), "-", df$IVB[r]), - ifelse(is.na(df$HB[r]), "-", df$HB[r]), - ifelse(is.na(df$VAA[r]), "-", df$VAA[r]), - df$event[r], - ifelse(is.na(df$EV[r]), "-", df$EV[r]), - ifelse(is.na(df$LA[r]), "-", df$LA[r]), - ifelse(is.na(df$Dist[r]), "-", df$Dist[r]) - ) - } - - for (i in seq_along(row_vals)) { - grid.rect(x = x_pos[i], y = y_row, width = widths[i]*0.985, height = row_height, just = c("left","top"), - gp = gpar(fill = ifelse(r %% 2 == 0, "#f7f7f7", "white"), col = "grey80", lwd = 0.3)) - grid.text(as.character(row_vals[i]), - x = x_pos[i] + widths[i]*0.49, y = y_row - row_height*0.5, gp = gpar(cex = cex)) - } - } - } - - pdf(output_file, width = 10.5, height = 13) - on.exit(try(dev.off(), silent = TRUE), add = TRUE) - - grid::grid.newpage() - - pushViewport(viewport(x = 0.5, y = 0.98, width = 0.94, height = 0.08, just = c("center","top"))) - grid.draw(create_simple_header(player_name, game_day, bio_data)) - popViewport() - - grid.text("Game Line", x = 0.5, y = 0.89, gp = gpar(fontface = "bold", cex = 1.2)) - headers <- c("PA","H","XBH","BB","K","Chase","Whiffs","IZ Whiffs","BIP","Avg EV","Avg LA","HH") - values <- c(game_stats$PA, game_stats$H, game_stats$XBH, game_stats$BB, game_stats$K, - game_stats$Chase, game_stats$Whiffs, game_stats$`IZ Whiffs`, game_stats$BIP, - game_stats$`Avg EV`, game_stats$`Avg LA`, game_stats$HH) - col_w <- 0.065; x0 <- 0.5 - (length(headers)*col_w)/2; yh <- 0.865; yv <- 0.840 - for (i in seq_along(headers)) { - xi <- x0 + (i-1)*col_w - grid.rect(x = xi, y = yh, width = col_w*0.985, height = 0.022, just = c("left","top"), - gp = gpar(fill = "#006F71", col = "black", lwd = 0.5)) - grid.text(headers[i], x = xi + col_w*0.49, y = yh - 0.011, - gp = gpar(col = "white", cex = 0.72, fontface = "bold")) - grid.rect(x = xi, y = yv, width = col_w*0.985, height = 0.022, just = c("left","top"), - gp = gpar(fill = "white", col = "black", lwd = 0.4)) - grid.text(as.character(values[i]), x = xi + col_w*0.49, y = yv - 0.011, gp = gpar(cex = 0.72)) - } - - pushViewport(viewport(x = 0.5, y = 0.795, width = 0.96, height = 0.44, just = c("center","top"))) - print(at_bats_plot, newpage = FALSE) - popViewport() - - pushViewport(viewport(x = 0.27, y = 0.36, width = 0.35, height = 0.22, just = c("center","top"))) - print(spray_plot, newpage = FALSE) - popViewport() - - pushViewport(viewport(x = 0.73, y = 0.36, width = 0.35, height = 0.22, just = c("center","top"))) - print(contact_plot, newpage = FALSE) - popViewport() - - grid.text(paste(player_name, "-", game_key, "- Batted Ball Log"), x = 0.5, y = 0.13, - gp = gpar(fontface = "bold", cex = 0.98)) - - rows_total <- nrow(pitch_log) - max_rows_first <- floor((0.11 - 0.02) / 0.0130) - rows_first <- min(rows_total, max_rows_first) - if (rows_first > 0) { - draw_pitch_table(pitch_log[1:rows_first, , drop = FALSE], y_top = 0.11, row_height = 0.0130, - cex = 0.52, include_bat_tracking = bat_tracking_available) - } - next_row <- rows_first + 1 - if (next_row <= rows_total) { - grid::grid.newpage() - draw_pitch_table(pitch_log[next_row:rows_total, , drop = FALSE], y_top = 0.97, row_height = 0.0175, - cex = 0.56, include_bat_tracking = bat_tracking_available) - } -} -# - Header stat tiles: bigger + more readable + moved slightly DOWN -# - Tables: bigger numbers -# - Pitch columns always ordered by pitch usage (Pitch Count desc) -# - Table stack spacing fixed so "Release Data" title does NOT overlap Velo table -# ============================================================ - -library(ggplot2) -library(dplyr) -library(grid) -library(stringr) - -# ===================================================================== -# PITCH COLORS -# ===================================================================== -tableau_pitch_colors <- c( - "Sinker" = "#76b8b2", - "Slider" = "#f38e2c", - "Cutter" = "#edca49", - "Sweeper" = "magenta", - "Fastball" = "#4f79a7", - "Four-Seam" = "#4f79a7", - "FourSeamFastBall" = "#4f79a7", - "4-Seam Fastball" = "#4f79a7", - "Curveball" = "#5aa150", - "ChangeUp" = "#e1575a", - "Changeup" = "#e1575a", - "Splitter" = "#b07ba1", - "Knuckle Curve" = "#5aa150", - "Two-Seam" = "#76b8b2", - "TwoSeamFastBall" = "#76b8b2", - "Other" = "#95A5A6", - "Undefined" = "#95A5A6" -) - - -# ===================================================================== -# 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", - PlayResult == "FieldersChoice" ~ "FieldersChoice", - 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.83083; zone_right <- 0.83083 -zone_bottom <- 1.5; zone_top <- 3.37750 -shadow_left <- -0.9975; shadow_right <- 0.9975 -shadow_bottom <- 1.3775; shadow_top <- 3.5 - 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, "FieldersChoice" = 18,"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")) - } - } -} - -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_abbr <- if (length(batter_teams) > 0) batter_teams[1] else "Unknown" - -away_team_display <- if (!is.null(team_master) && !is.na(away_team_abbr)) { - idx <- which(trimws(team_master$tm_abbrev) == trimws(away_team_abbr)) - if (length(idx) > 0) team_master$team_name[idx[1]] else away_team_abbr -} else away_team_abbr - -pitcher_team_abbr <- if ("PitcherTeam" %in% names(pitcher_df)) pitcher_df$PitcherTeam[1] else "COA_CHA" - -report_color_primary <- "#006F71" # CCU teal default -report_color_secondary <- "#A27752" # CCU gold default - -if (!is.null(team_master) && !is.na(pitcher_team_abbr)) { - idx <- which(trimws(team_master$tm_abbrev) == trimws(pitcher_team_abbr)) - if (length(idx) > 0) { - report_color_primary <- team_master$color_primary[idx[1]] - report_color_secondary <- team_master$color_secondary[idx[1]] - } -} - - # Stats + tables - stats <- calculate_tableau_header_stats(pitcher_df) - loc_data <- calculate_tableau_location_data(pitcher_df) - usage_data <- calculate_tableau_pitch_usage(pitcher_df) - velo_data <- calculate_tableau_velo_movement(pitcher_df) - rel_data <- calculate_tableau_release_data(pitcher_df) - - # Pitch columns ordered by usage (Pitch Count desc), then append any missing pitch types - pitch_types <- usage_data %>% - arrange(desc(`Pitch Count`)) %>% - pull(TaggedPitchType) %>% - unique() - pitch_types <- pitch_types[!is.na(pitch_types) & pitch_types != ""] - extras <- unique(c(loc_data$TaggedPitchType, velo_data$TaggedPitchType, rel_data$TaggedPitchType)) - extras <- extras[!is.na(extras) & extras != "" & !extras %in% pitch_types] - pitch_types <- c(pitch_types, extras) - if (length(pitch_types) == 0) pitch_types <- "Undefined" - - # Plots - loc_plot <- create_tableau_location_plot(pitcher_df, tableau_pitch_colors) - mov_plot <- create_tableau_movement_plot(pitcher_df, tableau_pitch_colors) - rel_plot <- create_tableau_release_plot(pitcher_df, tableau_pitch_colors) - - # PDF - pdf(output_file, width = 8.5, height = 11) - on.exit(try(dev.off(), silent = TRUE), add = TRUE) - grid.newpage() - - # ============================================================ - # HEADER BAR + INFO - # ============================================================ - grid.rect(x = 0, y = 0.955, width = 1, height = 0.045, - just = c("left", "bottom"), - gp = gpar(fill = "#006F71", col = NA)) - - logo_url <- "https://i.imgur.com/zjTu3JS.png" - - logo_grob <- NULL - try({ - logo_img <- magick::image_read(logo_url) - - # Optional: add transparency if the logo has a white background you want removed - # logo_img <- magick::image_transparent(logo_img, "white", fuzz = 10) - - # Scale to a consistent height in pixels (keeps it crisp) - logo_img <- magick::image_resize(logo_img, "x140") - - logo_grob <- grid::rasterGrob(as.raster(logo_img), interpolate = TRUE) - }, silent = TRUE) - - if (!is.null(logo_grob)) { - # Place in the top-right corner of the HEADER BAR - # x=0.985 means right edge is near page edge; y=0.977 centers within the bar - pushViewport(viewport(x = 0.988, y = 0.977, - width = 0.10, height = 0.040, - just = c("right", "center"))) - grid.draw(logo_grob) - popViewport() - } - - - grid.text("Pitcher Post-Game Report", x = 0.02, y = 0.977, just = "left", - gp = gpar(col = "white", fontface = "bold", cex = 1.2)) - - info_y <- 0.935 - -grid.text( - paste0(game_date, " | ", pitcher_name, " vs ", away_team_display), - x = 0.02, y = info_y, just = "left", - gp = gpar(cex = 0.8, fontface = "bold", col = "black") -) - - # ============================================================ - # HEADER STAT TILES (BIGGER + MORE READABLE + MOVED DOWN) - # ============================================================ - stat_labels <- c("At Bats","H","XBH","R","BB/HBP","SO","AVG","Strike%","1st P K%","E+A%","Comp%","LOO%") - stat_values <- c(stats$at_bats, stats$hits, stats$xbh, stats$runs, - stats$bb_hbp, stats$so, stats$avg, - stats$strike_pct, stats$fp_k_pct, stats$ea_pct, - stats$comp_pct, stats$loo_pct) - - label_colors <- c("#fe0100", "#0000ff", "#0000ff", "#01ab01", "#01ab01","#01ab01", - "#01abff", "#ffaa01", "#ffaa01", "#ffaa01", "#ffaa01", "#ffaa01") - - # moved DOWN a bit vs prior (and slightly bigger) - tiles_y <- 0.880 - tile_w <- 0.074 - tile_h <- 0.060 - tile_gap <- 0.008 - x_start <- 0.02 - - band_h <- tile_h * 0.55 - - for (i in seq_along(stat_labels)) { - x_pos <- x_start + (i - 1) * (tile_w + tile_gap) - - # Outer border - grid.rect( - x = x_pos, y = tiles_y, width = tile_w, height = tile_h, - just = c("left", "center"), - gp = gpar(fill = NA, col = label_colors[i], lwd = 2.2) - ) - - # Top colored band - grid.rect( - x = x_pos, y = tiles_y + (tile_h/2) - (band_h/2), - width = tile_w, height = band_h, - just = c("left", "center"), - gp = gpar(fill = label_colors[i], col = NA) - ) - - # Bottom white area - grid.rect( - x = x_pos, y = tiles_y - (tile_h/2) + ((tile_h - band_h)/2), - width = tile_w, height = (tile_h - band_h), - just = c("left", "center"), - gp = gpar(fill = "white", col = "black", lwd = 0.8) - ) - - # Label (white, bigger) - grid.text( - stat_labels[i], - x = x_pos + tile_w/2, - y = tiles_y + (tile_h/2) - (band_h/2), - gp = gpar(col = "white", cex = 0.82, fontface = "bold") - ) - - # Value (big, very readable) - grid.text( - as.character(stat_values[i]), - x = x_pos + tile_w/2, - y = tiles_y - (tile_h/2) + ((tile_h - band_h)/2), - gp = gpar(col = "black", cex = 1.20, fontface = "bold") - ) - } - - # ============================================================ - # CHARTS (kept same formatting; nudged slightly down) - # ============================================================ - pushViewport(viewport(x = 0.23, y = 0.662, width = 0.42, height = 0.30)) - print(loc_plot, newpage = FALSE) - popViewport() - - pushViewport(viewport(x = 0.22, y = 0.355, width = 0.41, height = 0.29)) - print(mov_plot, newpage = FALSE) - popViewport() - - pushViewport(viewport(x = 0.23, y = 0.14, width = 0.42, height = 0.26)) - print(rel_plot, newpage = FALSE) - popViewport() - - # ============================================================ - # TABLES (spacing fixed: Release title no overlap) - # ============================================================ - table_x <- 0.58 - table_w <- 0.41 - - draw_tableau_table_fill( - title = "Location Data", - data = loc_data, - rows = c("Zone%"="Zone%", "Edge%"="Edge%", "Strike%"="Strike%", "Whiff%"="Whiff%"), - pitch_types = pitch_types, - pitch_colors = tableau_pitch_colors, - x = table_x, y = 0.835, width = table_w, height = 0.16 - ) - - draw_tableau_table_fill( - title = "Pitch Usage", - data = usage_data, - rows = c("Usage vs. LHH"="Usage vs. LHH", "Usage vs. RHH"="Usage vs. RHH", "Pitch Count"="Pitch Count"), - pitch_types = pitch_types, - pitch_colors = tableau_pitch_colors, - x = table_x, y = 0.645, width = table_w, height = 0.15 - ) - - - draw_tableau_table_fill( - title = "Velo & Movement", - data = velo_data, - rows = c("Avg. Velo"="Avg. Velo", "Max Velo"="Max. Velo", - "Avg. Spin"="Avg. Spin", "Max Spin"="Max. Spin", - "Avg. IVB"="Avg. IVB", "Avg. HB"="Avg. HB"), - pitch_types = pitch_types, - pitch_colors = tableau_pitch_colors, - x = table_x, y = 0.47, width = table_w, height = 0.21 - ) - - draw_tableau_table_fill( - title = "Release Data", - data = rel_data, - rows = c("Rel Ht"="Avg. Rel Ht", - "Rel Ht vs FB (in)"="Rel Ht vs. FB", - "Rel Side"="Avg. Rel Side", - "Rel Side vs FB (in)"="Rel Side vs. FB", - "Ext"="Avg. Ext"), - pitch_types = pitch_types, - pitch_colors = tableau_pitch_colors, - x = table_x, y = 0.235, width = table_w, height = 0.19 - ) - - invisible(output_file) -} - -catcher_process_dataset <- function(df) { - if ("Catcher" %in% names(df)) { - df <- df %>% mutate(Catcher = stringr::str_replace(Catcher, "^\\s*(\\w+)\\s*,\\s*(\\w+)\\s*$", "\\2 \\1")) - } - df <- df %>% distinct() - if ("PitchUID" %in% names(df)) df <- df %>% distinct(PitchUID, .keep_all = TRUE) - if ("Date" %in% names(df)) { df$Date <- parse_flexible_date(df$Date) } - if ("PlateLocSide" %in% names(df)) df$PlateLocSide <- as.numeric(df$PlateLocSide) - if ("PlateLocHeight" %in% names(df)) df$PlateLocHeight <- as.numeric(df$PlateLocHeight) - if ("BasePositionZ" %in% names(df)) df$BasePositionZ <- as.numeric(df$BasePositionZ) - if ("BasePositionY" %in% names(df)) df$BasePositionY <- as.numeric(df$BasePositionY) - BALL_CALLS <- c("BallCalled", "BallinDirt", "BallIntentional") - STRIKE_CALLS <- c("StrikeCalled") - SWING_CALLS <- c("StrikeSwinging", "InPlay", "FoulBall", "FoulBallFieldable", "FoulBallNotFieldable") - df %>% mutate( - PitchCall = trimws(gsub("\\s+", "", PitchCall)), - in_zone = as.integer(!is.na(PlateLocSide) & !is.na(PlateLocHeight) & - PlateLocSide >= -0.83 & PlateLocSide <= 0.83 & - PlateLocHeight >= 1.5 & PlateLocHeight <= 3.38), - is_swing = as.integer(PitchCall %in% SWING_CALLS), - StrikeZoneIndicator = in_zone, - StolenStrike = as.integer(in_zone == 0 & PitchCall %in% STRIKE_CALLS), - StrikeLost = as.integer(in_zone == 1 & PitchCall %in% BALL_CALLS), - frame = dplyr::case_when( - in_zone == 1 & PitchCall %in% BALL_CALLS ~ "Strike Lost", - in_zone == 0 & PitchCall %in% STRIKE_CALLS ~ "Strike Added", - TRUE ~ NA_character_), - frame_numeric = dplyr::case_when( - in_zone == 1 & PitchCall %in% BALL_CALLS ~ -1, - in_zone == 0 & PitchCall %in% STRIKE_CALLS ~ 1, - TRUE ~ NA_real_)) -} - -catcher_parse_game_day <- function(df, tz = "America/New_York") { - stopifnot("Date" %in% names(df)) - if (inherits(df$Date, "Date")) { - dates <- df$Date[!is.na(df$Date)] - if (length(dates) > 0) { tab <- sort(table(dates), decreasing = TRUE); return(as.Date(names(tab)[1])) } - } - as.Date(df$Date[1]) -} - -catcher_compute_ground_intersection <- function(df) { - df %>% mutate( - .a = 0.5 * az0, .b = vz0, .c = z0, - .disc = .b^2 - 4 * .a * .c, - .t1 = ifelse(.disc >= 0, (-.b + sqrt(pmax(.disc,0)))/(2*.a), NA_real_), - .t2 = ifelse(.disc >= 0, (-.b - sqrt(pmax(.disc,0)))/(2*.a), NA_real_), - t_ground = pmin(ifelse(.t1>0,.t1,Inf), ifelse(.t2>0,.t2,Inf)), - t_ground = ifelse(is.finite(t_ground), t_ground, NA_real_), - x_ground = x0 + vx0*t_ground + 0.5*ax0*t_ground^2, - y_ground = y0 + vy0*t_ground + 0.5*ay0*t_ground^2, - in_dirt = !is.na(t_ground) - ) %>% select(-starts_with(".a"), -starts_with(".b"), -starts_with(".c"), - -starts_with(".disc"), -starts_with(".t1"), -starts_with(".t2")) -} - -pitch_colors <- c( - "Fastball"="#3465cb","Four-Seam"="#3465cb","FourSeamFastBall"="#3465cb", - "4-Seam Fastball"="#3465cb","FF"="#3465cb", - "Sinker"="#e5e501","TwoSeamFastBall"="#e5e501","Two-Seam"="#e5e501", - "2-Seam Fastball"="#e5e501","SI"="#e5e501", - "Slider"="#65aa02","SL"="#65aa02", - "Sweeper"="#dc4476","SW"="#dc4476", - "Curveball"="#d73813","CB"="#d73813","Knuckle Curve"="#d73813","KC"="#d73813", - "ChangeUp"="#980099","Changeup"="#980099","CH"="#980099", - "Splitter"="#23a999","FS"="#23a999","SP"="#23a999", - "Cutter"="#ff9903","FC"="#ff9903", - "Slurve"="#9370DB","Other"="gray50") - -.classify_block_type <- function(notes) { - dplyr::case_when( - grepl("^block$", notes, ignore.case = TRUE) ~ "Block", - grepl("pbwp|pb/wp|pb\\+wp|wild\\s*pitch|passed\\s*ball|wp|pb", notes, ignore.case = TRUE) ~ "PB/WP", - grepl("block", notes, ignore.case = TRUE) ~ "Block", - TRUE ~ "Block") -} -.is_block_event <- function(notes) { - grepl("block|pbwp|pb/wp|pb\\+wp|wild\\s*pitch|passed\\s*ball|\\bwp\\b|\\bpb\\b", notes, ignore.case = TRUE) -} - -# ===================================================================== -# BLOCKING PLOT — ground intersection (numbered points inside, black border) -# ===================================================================== -catcher_create_blocking_plot <- function(catcher_data, catcher_name) { - req <- c("x0","vx0","ax0","y0","vy0","ay0","z0","vz0","az0") - block_df <- catcher_data %>% filter(Catcher == catcher_name, .is_block_event(Notes)) %>% - filter(!is.na(x0) & !is.na(z0) & !is.na(az0)) - if (all(req %in% names(block_df)) && nrow(block_df) > 0) { - block_df <- catcher_compute_ground_intersection(block_df) - } else { return(ggplot() + theme_void() + ggtitle("Blocking (Overhead View)") + - theme(plot.title = element_text(hjust=.5, size=9, face="bold"))) } - if (nrow(block_df)==0) return(ggplot() + theme_void() + ggtitle("Ground Intersection") + - theme(plot.title = element_text(hjust=.5, size=9, face="bold"))) - block_df <- block_df %>% mutate(plot_x=x_ground, plot_y=y_ground, - block_type=.classify_block_type(Notes), row_num=row_number()) - plate <- data.frame(x=c(-.708,.708,.708,0,-.708), y=c(1.417,1.417,.708,0,.708)) - bi<-1; bo<-4; bymin<--2.5; bymax<-3.7 - xr <- range(c(block_df$plot_x,-bo,bo),na.rm=TRUE); yr <- range(c(block_df$plot_y,bymin,bymax),na.rm=TRUE) - xl <- c(xr[1]-1, xr[2]+1); yl <- c(yr[1]-1, yr[2]+1) - xs <- diff(xl); ys <- diff(yl) - if(ys > xs*1.8){ n<-ys/1.5; e<-(n-xs)/2; xl<-xl+c(-e,e) } - else if(xs > ys*1.8){ n<-xs/1.5; e<-(n-ys)/2; yl<-yl+c(-e,e) } - tc <- block_df %>% count(TaggedPitchType) %>% mutate(label=paste0(TaggedPitchType,": ",n)) - cl <- setNames(tc$label, tc$TaggedPitchType) - sl <- unique(block_df$block_type) - sv <- c("Block"=21,"PB/WP"=24)[sl]; slb <- c("Block"="Block","PB/WP"="PB/WP")[sl] - ggplot(block_df, aes(plot_x,plot_y)) + - annotate("rect",xmin=bi,xmax=bo,ymin=bymin,ymax=bymax,fill=NA,color="black",linewidth=.8) + - annotate("rect",xmin=-bo,xmax=-bi,ymin=bymin,ymax=bymax,fill=NA,color="black",linewidth=.8) + - geom_polygon(data=plate,aes(x,y),fill=NA,color="black",linewidth=.8,inherit.aes=FALSE) + - geom_point(aes(fill=TaggedPitchType,shape=block_type),size=3,color="black",stroke=.6) + - geom_text(aes(label=row_num),size=2.0,fontface="bold",color="white") + - scale_fill_manual(values=pitch_colors,na.value="grey60",name="PitchType",labels=cl) + - scale_shape_manual(values=sv,name=NULL,labels=slb) + - coord_fixed(xlim=xl,ylim=yl) + labs(title="Blocking (Overhead View)",x=NULL,y=NULL) + - theme_void(base_size=9) + - theme(plot.title=element_text(size=9,face="bold",hjust=.5), - legend.position="bottom",legend.box="vertical",legend.spacing.y=unit(2,"pt"), - legend.title=element_text(size=7,face="bold"),legend.text=element_text(size=6), - legend.key.size=unit(.5,"lines"),plot.margin=margin(2,2,2,2)) + - guides(fill=guide_legend(order=1,nrow=2,override.aes=list(size=3,shape=21)), - shape=guide_legend(order=2,nrow=1,override.aes=list(size=3,fill="#4472C4"))) -} - -# ===================================================================== -# BLOCKING ZONE PLOT (numbered points inside, black border) -# ===================================================================== -catcher_create_blocking_zone_plot <- function(catcher_data, catcher_name) { - block_df <- catcher_data %>% filter(Catcher==catcher_name, .is_block_event(Notes)) %>% - filter(!is.na(PlateLocSide) & !is.na(PlateLocHeight)) - if(nrow(block_df)==0) return(ggplot2::ggplot()+ggplot2::theme_void()+ggplot2::ggtitle("Blocking (Zone)")+ - ggplot2::theme(plot.title=ggplot2::element_text(hjust=.5,size=8,face="bold"))) - block_df <- block_df %>% mutate(block_type=.classify_block_type(Notes), row_num=row_number()) - szr<-data.frame(xmin=-.83083,xmax=.83083,ymin=1.5,ymax=3.3775) - bzr<-data.frame(xmin=-.9975,xmax=.9975,ymin=1.3775,ymax=3.5) - hp<-data.frame(x=c(-.708,.708,.708,0,-.708),y=c(.15,.15,.3,.5,.3)) - xp<-.5;yp<-.5 - xl<-c(min(-1.8,min(block_df$PlateLocSide,na.rm=T)-xp),max(1.8,max(block_df$PlateLocSide,na.rm=T)+xp)) - yl<-c(min(-.5,min(block_df$PlateLocHeight,na.rm=T)-yp),max(4.5,max(block_df$PlateLocHeight,na.rm=T)+yp)) - sl<-unique(block_df$block_type);sv<-c("Block"=21,"PB/WP"=24)[sl];slb<-c("Block"="Block","PB/WP"="PB/WP")[sl] - tc<-block_df%>%count(TaggedPitchType)%>%mutate(label=paste0(TaggedPitchType,": ",n)) - cl<-setNames(tc$label,tc$TaggedPitchType) - ggplot2::ggplot(block_df,ggplot2::aes(PlateLocSide,PlateLocHeight))+ - ggplot2::geom_rect(data=bzr,ggplot2::aes(xmin=xmin,xmax=xmax,ymin=ymin,ymax=ymax),fill=NA,color="gray50",linewidth=.6,linetype="dotted",inherit.aes=FALSE)+ - ggplot2::geom_rect(data=szr,ggplot2::aes(xmin=xmin,xmax=xmax,ymin=ymin,ymax=ymax),fill=NA,color="black",linewidth=.8,inherit.aes=FALSE)+ - ggplot2::geom_polygon(data=hp,ggplot2::aes(x=x,y=y),fill=NA,color="gray40",linewidth=.5,inherit.aes=FALSE)+ - ggplot2::geom_point(ggplot2::aes(fill=TaggedPitchType,shape=block_type),size=3,color="black",stroke=.6,na.rm=TRUE)+ - ggplot2::geom_text(ggplot2::aes(label=row_num),size=2.0,fontface="bold",color="white",na.rm=TRUE)+ - ggplot2::scale_fill_manual(values=pitch_colors,na.value="grey60",name="Pitch Type",labels=cl)+ - ggplot2::scale_shape_manual(values=sv,name=NULL,labels=slb)+ - ggplot2::coord_equal(xlim=xl,ylim=yl)+ggplot2::labs(title="Blocking (Zone)")+ggplot2::theme_void()+ - ggplot2::theme(plot.title=ggplot2::element_text(hjust=.5,size=8,face="bold"),plot.margin=ggplot2::margin(2,2,2,2), - legend.position="bottom",legend.box="vertical",legend.spacing.y=ggplot2::unit(2,"pt"), - legend.title=ggplot2::element_text(size=7,face="bold"),legend.text=ggplot2::element_text(size=6), - legend.key.size=ggplot2::unit(.5,"lines"))+ - ggplot2::guides(fill=ggplot2::guide_legend(order=1,nrow=2,override.aes=list(size=3,shape=21)), - shape=ggplot2::guide_legend(order=2,nrow=1,override.aes=list(size=3,fill="#4472C4"))) -} - -# ===================================================================== -# FRAMING PLOTS — numbered points inside circles with black border -# Returns list(p1, p2, legend) -# ===================================================================== -catcher_create_framing_plots <- function(catcher_data, catcher_name) { - df <- dplyr::filter(catcher_data, Catcher==catcher_name, is_swing==0) - sa <- df %>% filter(frame=="Strike Added") %>% mutate(row_num=row_number()) - sl <- df %>% filter(frame=="Strike Lost") %>% mutate(row_num=row_number()) - apt <- unique(c(sa$TaggedPitchType, sl$TaggedPitchType)); apt <- apt[!is.na(apt)] - szr<-data.frame(xmin=-.83083,xmax=.83083,ymin=1.5,ymax=3.3775) - bzr<-data.frame(xmin=-.9975,xmax=.9975,ymin=1.3775,ymax=3.5) - hp<-data.frame(x=c(-.60,.60,.60,0,-.60),y=c(.15,.15,.27,.42,.27)) - mp <- function(data,title){ - if(!nrow(data)) return(ggplot2::ggplot()+ggplot2::theme_void()+ggplot2::ggtitle(title)+ - ggplot2::theme(plot.title=ggplot2::element_text(hjust=.5,size=10,face="bold"))) - ggplot2::ggplot(data,ggplot2::aes(PlateLocSide,PlateLocHeight))+ - ggplot2::geom_rect(data=bzr,ggplot2::aes(xmin=xmin,xmax=xmax,ymin=ymin,ymax=ymax),fill=NA,color="gray50",linewidth=.5,linetype="dotted",inherit.aes=FALSE)+ - ggplot2::geom_rect(data=szr,ggplot2::aes(xmin=xmin,xmax=xmax,ymin=ymin,ymax=ymax),fill=NA,color="black",linewidth=.7,inherit.aes=FALSE)+ - ggplot2::geom_polygon(data=hp,ggplot2::aes(x=x,y=y),fill=NA,color="gray40",linewidth=.4,inherit.aes=FALSE)+ - ggplot2::geom_point(ggplot2::aes(fill=TaggedPitchType),shape=21,size=5,color="black",stroke=.6,alpha=.95,na.rm=TRUE)+ - ggplot2::geom_text(ggplot2::aes(label=row_num),size=2.0,fontface="bold",color="white",na.rm=TRUE)+ - ggplot2::scale_fill_manual(values=pitch_colors,na.value="grey60",name="Pitch Type",drop=FALSE,limits=apt)+ - ggplot2::coord_equal()+ggplot2::scale_x_continuous(limits=c(-1.8,1.8))+ggplot2::scale_y_continuous(limits=c(0,4.5))+ - ggplot2::labs(title=title)+ggplot2::theme_classic()+ - ggplot2::theme(axis.title=ggplot2::element_blank(),axis.text=ggplot2::element_blank(), - axis.ticks=ggplot2::element_blank(),axis.line=ggplot2::element_blank(), - panel.grid=ggplot2::element_blank(), - plot.title=ggplot2::element_text(hjust=.5,size=10,face="bold"), - plot.margin=ggplot2::margin(2,2,2,2),legend.position="none") - } - lp <- ggplot2::ggplot(data.frame(x=seq_along(apt),y=seq_along(apt), - pt=factor(apt,levels=apt)),ggplot2::aes(x,y,fill=pt))+ - ggplot2::geom_point(shape=21,size=3,color="black",stroke=.5)+ - ggplot2::scale_fill_manual(values=pitch_colors[apt],name="Pitch Type")+ggplot2::theme_void()+ - ggplot2::theme(legend.position="bottom",legend.title=ggplot2::element_text(size=8,face="bold"), - legend.text=ggplot2::element_text(size=7),legend.key.size=ggplot2::unit(.6,"lines"), - legend.box="horizontal")+ - ggplot2::guides(fill=ggplot2::guide_legend(nrow=1,override.aes=list(shape=21))) - tmp<-ggplot2::ggplot_gtable(ggplot2::ggplot_build(lp)) - li<-which(sapply(tmp$grobs,function(g) g$name)=="guide-box") - lg<-if(length(li)>0) tmp$grobs[[li[1]]] else grid::nullGrob() - list(p1=mp(sa,"Strikes Stolen"), p2=mp(sl,"Strikes Lost"), legend=lg) -} - -catcher_create_framing_plot <- function(catcher_data, catcher_name) { - plots <- catcher_create_framing_plots(catcher_data, catcher_name) - gridExtra::grid.arrange(plots$p1, plots$p2, ncol = 2) -} - -catcher_create_throwing_plot <- function(catcher_data, catcher_name) { - td <- catcher_data %>% - filter(Catcher == catcher_name) %>% - filter(tolower(Notes) %in% c('2b out','2b safe','3b out','3b safe')) - if(!nrow(td)) return(ggplot()+theme_void()+ggtitle("No throwing data available")+ - theme(plot.title=element_text(hjust=.5,size=11,face="bold"))) - ggplot(td)+ - geom_polygon(data=data.frame(x=c(-10,10,10,-10),y=c(.25,.25,8,8)),aes(x,y),fill='darkcyan',color='darkcyan')+ - geom_polygon(data=data.frame(x=c(-10,10,10,-10),y=c(8,8,9,9)),aes(x,y),fill='yellow',color='yellow')+ - geom_polygon(data=data.frame(x=c(-10,10,10,-10),y=c(-2,-2,.25,.25)),aes(x,y),fill='brown',color='brown')+ - geom_polygon(data=data.frame(x=c(-10,10,10,-10),y=c(-5,-5,-2,-2)),aes(x,y),fill='darkgreen',color='darkgreen')+ - geom_polygon(data=data.frame(x=c(-1,1,1,-1),y=c(0,0,.45,.45)),aes(x,y),fill='white',color='black')+ - geom_polygon(data=data.frame(x=c(-1,0,0,-1),y=c(0,0,.45,.45)),aes(x,y),fill='lightgrey',color='black')+ - geom_point(aes(x=BasePositionZ,y=BasePositionY,fill=Notes),color='white',pch=21,alpha=.99,size=3.5)+ - scale_fill_manual(values=c('2b safe'='red','2b out'='#339a1d','3b safe'='#ff6b6b','3b out'='#1a5d1a'))+ - scale_x_continuous(limits=c(-10,10))+scale_y_continuous(limits=c(-5,9))+ - theme_bw()+coord_fixed()+ - theme(legend.position="bottom",axis.title=element_blank(),axis.text=element_blank(), - axis.ticks=element_blank(),panel.grid=element_blank(), - plot.title=element_text(size=11,face='bold',hjust=.5))+ - ggtitle(paste(catcher_name,"- Throwing Report")) -} - -catcher_create_simple_header <- function(catcher_name, game_date, bio_data=NULL) { - suppressPackageStartupMessages({library(grid);library(gridExtra);library(magick);library(dplyr)}) - tt<-paste(catcher_name,"- Catcher Report"); st<-if(!is.na(game_date)) paste("Game Date:",game_date) else "" - ig<-nullGrob() - if(!is.null(bio_data)&&nrow(bio_data)>0){ - cb<-bio_data%>%filter(Catcher==catcher_name) - if(nrow(cb)>0&&"Headshot"%in%names(cb)){u<-cb$Headshot[1] - if(!is.na(u)&&nzchar(u)){im<-try(magick::image_read(u),silent=TRUE) - if(!inherits(im,"try-error")) ig<-rasterGrob(as.raster(im),interpolate=TRUE)}}} - ll<-rasterGrob(as.raster(magick::image_resize(magick::image_read("https://i.ibb.co/gLfTW4Fz/t-GPe-TPu.png"),"x120"))) - rl<-rasterGrob(as.raster(magick::image_resize(magick::image_read("https://i.imgur.com/zjTu3JS.png"),"x120"))) - tg<-textGrob(tt,gp=gpar(fontsize=18,fontface="bold",col="#006F71")) - sg<-textGrob(st,gp=gpar(fontsize=11)) - arrangeGrob(arrangeGrob(ll,tg,rl,ncol=3,widths=c(.15,.7,.15)),sg,ncol=1,heights=c(.7,.3)) -} - -# ===================================================================== -# PDF GENERATION -# ===================================================================== -catcher_create_catcher_pdf <- function(game_df, catcher_name, output_file, bio_data=NULL) { - if(length(dev.list())>0) try(dev.off(),silent=TRUE) - catcher_df <- dplyr::filter(game_df, Catcher==catcher_name) - game_day <- catcher_parse_game_day(catcher_df); game_key <- format(game_day,"%Y-%m-%d") - req9p <- c("x0","vx0","ax0","y0","vy0","ay0","z0","vz0","az0") - has_9p <- all(req9p %in% names(catcher_df)) - - # ---- Stats ---- - rs <- catcher_df %>% summarise(ss=sum(StolenStrike,na.rm=T),sl=sum(StrikeLost,na.rm=T), - gpm=sum(StolenStrike,na.rm=T)-sum(StrikeLost,na.rm=T),.groups="drop") -opp_catchers <- game_df %>% filter(CatcherTeam != "COA_CHA") %>% pull(Catcher) %>% na.omit() - ocn <- if (length(opp_catchers) > 0) names(sort(table(opp_catchers), decreasing = TRUE))[1] else NA_character_ - ors <- if (!is.na(ocn) && length(ocn) > 0) { - game_df %>% filter(Catcher == ocn) %>% - summarise(ss = sum(StolenStrike, na.rm = TRUE), sl = sum(StrikeLost, na.rm = TRUE), - gpm = sum(StolenStrike, na.rm = TRUE) - sum(StrikeLost, na.rm = TRUE), .groups = "drop") - } else { - data.frame(ss = 0L, sl = 0L, gpm = 0L) - } - - # ---- Framing log (ALL rows, no cap) with Inning and Velo ---- - has_velo_f <- "RelSpeed" %in% names(catcher_df) - has_inning_f <- "Inning" %in% names(catcher_df) - pitch_log <- catcher_df %>% filter(StolenStrike==1|StrikeLost==1) %>% mutate(row_num=row_number()) %>% - mutate(Pitch=TaggedPitchType, Actual=ifelse(StrikeZoneIndicator==1,"STRIKE","BALL"), - Velo=if(has_velo_f) round(RelSpeed,1) else NA_real_, - Inn=if(has_inning_f) as.character(Inning) else NA_character_) %>% - select(row_num,PitchNo,Pitch,Pitcher,Batter,PitchCall,Actual,Velo,Inn) - - # ---- Throwing log ---- - throw_log <- catcher_df%>%filter(Notes%in%c('2b out','2b safe','3b out','3b safe'))%>% - select(PitchNo,Pitcher,Catcher,ThrowSpeed,PopTime,ExchangeTime,Notes) - - # ---- Blocking log ---- - block_log <- catcher_df%>%filter(.is_block_event(Notes)) - if(has_9p&&nrow(block_log)>0){ - block_log<-block_log%>%filter(!is.na(x0)&!is.na(z0)&!is.na(az0))%>%catcher_compute_ground_intersection() - } else { block_log<-block_log%>%mutate(x_ground=NA_real_,y_ground=NA_real_,t_ground=NA_real_,in_dirt=FALSE) } - hcc<-all(c("Balls","Strikes")%in%names(block_log)); hvc<-"RelSpeed"%in%names(block_log); hic<-"Inning"%in%names(block_log) - block_log<-block_log%>%mutate(block_type=.classify_block_type(Notes),row_num=row_number(), - Count=if(hcc) paste0(Balls,"-",Strikes) else NA_character_, - Velo=if(hvc) round(RelSpeed,1) else NA_real_, - Inn=if(hic) as.character(Inning) else NA_character_)%>% - select(row_num,PitchNo,Pitcher,Catcher,Batter,TaggedPitchType,Notes,block_type,Count,Velo,Inn,x_ground,y_ground,in_dirt) - - # ---- Grobs ---- - fp<-catcher_create_framing_plots(game_df,catcher_name) - fg1<-ggplotGrob(fp$p1); fg2<-ggplotGrob(fp$p2); flg<-fp$legend - tg<-ggplotGrob(catcher_create_throwing_plot(game_df,catcher_name)) - bg<-ggplotGrob(catcher_create_blocking_plot(game_df,catcher_name)) - bzg<-ggplotGrob(catcher_create_blocking_zone_plot(game_df,catcher_name)) - - # ---- Render PDF ---- - n_framing_rows <- nrow(pitch_log) - rh_f <- .011 # framing table row height - needs_page2 <- n_framing_rows > 12 # if >12, push throwing down, blocking to page 2 - - pdf(output_file, width=10.5, height=15) - tryCatch({ - grid::grid.newpage() - - # HEADER - pushViewport(viewport(x=.5,y=.98,width=.94,height=.055,just=c("center","top"))) - grid.draw(catcher_create_simple_header(catcher_name,game_key,bio_data)); popViewport() - - # RECEIVING - grid.text("Receiving",x=.5,y=.915,gp=gpar(fontface="bold",cex=1.1,col="#006F71")) - hr<-c("CCU Strikes Stolen","CCU Strikes Lost","CCU Game +/-"); vr<-c(rs$ss,rs$sl,rs$gpm) - cw<-.18; x0t<-.5-(3*cw)/2; yh<-.905; yv<-.890 - for(i in 1:3){xi<-x0t+(i-1)*cw - grid.rect(x=xi,y=yh,width=cw*.985,height=.013,just=c("left","top"),gp=gpar(fill="#006F71",col="black",lwd=.5)) - grid.text(hr[i],x=xi+cw*.49,y=yh-.0065,gp=gpar(col="white",cex=.60,fontface="bold")) - grid.rect(x=xi,y=yv,width=cw*.985,height=.013,just=c("left","top"),gp=gpar(fill="white",col="black",lwd=.4)) - grid.text(as.character(vr[i]),x=xi+cw*.49,y=yv-.0065,gp=gpar(cex=.60))} - ho<-c("Opp Strikes Stolen","Opp Strikes Lost","Opp Game +/-"); vo<-c(ors$ss,ors$sl,ors$gpm) - yho<-.872; yvo<-.857 - for(i in 1:3){xi<-x0t+(i-1)*cw - grid.rect(x=xi,y=yho,width=cw*.985,height=.013,just=c("left","top"),gp=gpar(fill="#006F71",col="black",lwd=.5)) - grid.text(ho[i],x=xi+cw*.49,y=yho-.0065,gp=gpar(col="white",cex=.60,fontface="bold")) - grid.rect(x=xi,y=yvo,width=cw*.985,height=.013,just=c("left","top"),gp=gpar(fill="white",col="black",lwd=.4)) - grid.text(as.character(vo[i]),x=xi+cw*.49,y=yvo-.0065,gp=gpar(cex=.60))} - - # FRAMING SHARED LEGEND - pushViewport(viewport(x=.5,y=.843,width=.60,height=.015,just=c("center","top"))) - grid.draw(flg); popViewport() - - # FRAMING PLOTS - pushViewport(viewport(x=.25,y=.825,width=.48,height=.26,just=c("center","top"))) - grid.draw(fg1); popViewport() - pushViewport(viewport(x=.75,y=.825,width=.48,height=.26,just=c("center","top"))) - grid.draw(fg2); popViewport() - - # ======== FRAMING TABLE ======== - yft<-.545 - if(n_framing_rows > 0){ - hfv<-!all(is.na(pitch_log$Velo)); hfi<-!all(is.na(pitch_log$Inn)) - hf<-c("#","PitchNo","Pitch","Pitcher","Batter","PitchCall","Actual") - wf<-c(.035,.055,.08,.13,.13,.10,.08) - if(hfv){ hf<-c(hf,"Velo"); wf<-c(wf,.05) } - if(hfi){ hf<-c(hf,"Inn"); wf<-c(wf,.035) } - xs<-.5-sum(wf)/2; xp<-c(xs,xs+cumsum(wf[-length(wf)])); yt<-yft - for(i in seq_along(hf)){ - grid.rect(x=xp[i],y=yt,width=wf[i]*.985,height=rh_f,just=c("left","top"),gp=gpar(fill="#006F71",col="black",lwd=.4)) - grid.text(hf[i],x=xp[i]+wf[i]*.49,y=yt-rh_f*.5,gp=gpar(col="white",cex=.52,fontface="bold"))} - mr<-min(30,n_framing_rows) - for(r in 1:mr){yr<-yt-r*rh_f - rv<-c(pitch_log$row_num[r],pitch_log$PitchNo[r],pitch_log$Pitch[r], - pitch_log$Pitcher[r],pitch_log$Batter[r],pitch_log$PitchCall[r],pitch_log$Actual[r]) - if(hfv) rv<-c(rv,ifelse(is.na(pitch_log$Velo[r]),"\u2014",pitch_log$Velo[r])) - if(hfi) rv<-c(rv,ifelse(is.na(pitch_log$Inn[r]),"\u2014",pitch_log$Inn[r])) - fc<-ifelse(pitch_log$Actual[r]=="STRIKE","#90EE90","#FFB6C1") - for(i in seq_along(rv)){ - bf<-if(i==7) fc else ifelse(r%%2==0,"#f7f7f7","white") - grid.rect(x=xp[i],y=yr,width=wf[i]*.985,height=rh_f,just=c("left","top"),gp=gpar(fill=bf,col="grey80",lwd=.3)) - grid.text(as.character(rv[i]),x=xp[i]+wf[i]*.49,y=yr-rh_f*.5,gp=gpar(cex=.48))}} - # Calculate where the framing table ends - framing_tbl_bottom <- yt - (mr + 1) * rh_f - } else { - framing_tbl_bottom <- yft - .02 - } - - # ======== THROWING SECTION ======== - # Position dynamically below framing table - yts <- framing_tbl_bottom - .015 - grid.text("Throwing",x=.5,y=yts,gp=gpar(fontface="bold",cex=1.1,col="#006F71")) - pushViewport(viewport(x=.28,y=yts-.015,width=.50,height=.16,just=c("center","top"))) - grid.draw(tg); popViewport() - ytt<-yts-.035 - if(nrow(throw_log)>0){ - ht<-c("PitchNo","Pitcher","ThrowSpd","PopTime","Exch","Notes") - wt<-c(.05,.10,.06,.06,.06,.06); twt<-sum(wt); xst<-.78-twt/2 - xpt<-c(xst,xst+cumsum(wt[-length(wt)])); rht<-.011; ytt2<-ytt - for(i in seq_along(ht)){ - grid.rect(x=xpt[i],y=ytt2,width=wt[i]*.985,height=rht,just=c("left","top"),gp=gpar(fill="#006F71",col="black",lwd=.4)) - grid.text(ht[i],x=xpt[i]+wt[i]*.49,y=ytt2-rht*.5,gp=gpar(col="white",cex=.50,fontface="bold"))} - mrt<-min(6,nrow(throw_log)) - for(r in 1:mrt){yrt<-ytt2-r*rht - rvt<-c(throw_log$PitchNo[r],throw_log$Pitcher[r],round(throw_log$ThrowSpeed[r],1), - round(throw_log$PopTime[r],2),round(throw_log$ExchangeTime[r],2),throw_log$Notes[r]) - for(i in seq_along(rvt)){bf<-ifelse(r%%2==0,"#f7f7f7","white") - grid.rect(x=xpt[i],y=yrt,width=wt[i]*.985,height=rht,just=c("left","top"),gp=gpar(fill=bf,col="grey80",lwd=.3)) - grid.text(as.character(rvt[i]),x=xpt[i]+wt[i]*.49,y=yrt-rht*.5,gp=gpar(cex=.48))}} - } else { grid.text("No throwing data",x=.78,y=ytt-.04,gp=gpar(cex=.7,col="grey50",fontface="italic")) } - - # ======== BLOCKING SECTION ======== - if (!needs_page2) { - # Blocking fits on page 1 below throwing - throw_bottom <- yts - .19 - ybs <- throw_bottom - .01 - } else { - # Blocking goes on page 2 - ybs <- NULL - } - - .draw_blocking_section <- function(ybs_pos) { - grid.text("Blocking",x=.5,y=ybs_pos,gp=gpar(fontface="bold",cex=1.1,col="#006F71")) - pushViewport(viewport(x=.17,y=ybs_pos-.012,width=.32,height=.155,just=c("center","top"))) - grid.draw(bzg); popViewport() - pushViewport(viewport(x=.47,y=ybs_pos-.012,width=.32,height=.155,just=c("center","top"))) - grid.draw(bg); popViewport() - ybt<-ybs_pos-.025 - if(nrow(block_log)>0){ - hc<-!all(is.na(block_log$Count)); hv<-!all(is.na(block_log$Velo)); hi<-!all(is.na(block_log$Inn)) - hl<-c("#"); wl<-c(.02) - hl<-c(hl,"Pitcher"); wl<-c(wl,.07) - hl<-c(hl,"Batter"); wl<-c(wl,.07) - hl<-c(hl,"Pitch"); wl<-c(wl,.05) - hl<-c(hl,"Type"); wl<-c(wl,.035) - if(hv){ hl<-c(hl,"Velo"); wl<-c(wl,.03) } - if(hc){ hl<-c(hl,"Count"); wl<-c(wl,.03) } - if(hi){ hl<-c(hl,"Inn"); wl<-c(wl,.025) } - twb<-sum(wl); xsb<-.97-twb; xpb<-c(xsb,xsb+cumsum(wl[-length(wl)])) - rhb<-.010; ytb<-ybt - for(i in seq_along(hl)){ - grid.rect(x=xpb[i],y=ytb,width=wl[i]*.985,height=rhb,just=c("left","top"),gp=gpar(fill="#006F71",col="black",lwd=.4)) - grid.text(hl[i],x=xpb[i]+wl[i]*.49,y=ytb-rhb*.5,gp=gpar(col="white",cex=.43,fontface="bold"))} - mrb<-min(14,nrow(block_log)) - for(r in 1:mrb){yrb<-ytb-r*rhb - vs<-c(block_log$row_num[r],block_log$Pitcher[r],block_log$Batter[r], - block_log$TaggedPitchType[r],block_log$block_type[r]) - if(hv) vs<-c(vs,ifelse(is.na(block_log$Velo[r]),"\u2014",block_log$Velo[r])) - if(hc) vs<-c(vs,ifelse(is.na(block_log$Count[r]),"\u2014",block_log$Count[r])) - if(hi) vs<-c(vs,ifelse(is.na(block_log$Inn[r]),"\u2014",block_log$Inn[r])) - for(i in seq_along(vs)){bf<-ifelse(r%%2==0,"#f7f7f7","white") - grid.rect(x=xpb[i],y=yrb,width=wl[i]*.985,height=rhb,just=c("left","top"),gp=gpar(fill=bf,col="grey80",lwd=.3)) - grid.text(as.character(vs[i]),x=xpb[i]+wl[i]*.49,y=yrb-rhb*.5,gp=gpar(cex=.42))}} - } else { grid.text("No blocking data",x=.85,y=ybt-.04,gp=gpar(cex=.7,col="grey50",fontface="italic")) } - } - - if (!needs_page2) { - # Draw blocking on page 1 - .draw_blocking_section(ybs) - } - - # FOOTER (page 1) - grid.text("Data: TrackMan | Report Generated: Coastal Carolina Baseball Analytics", - x=0.5,y=0.008,gp=gpar(cex=0.70,col="grey50")) - - # ======== PAGE 2 (if needed) ======== - if (needs_page2) { - grid::grid.newpage() - - # Page 2 header - pushViewport(viewport(x=.5,y=.98,width=.94,height=.055,just=c("center","top"))) - grid.draw(catcher_create_simple_header(catcher_name,game_key,bio_data)); popViewport() - - # Draw blocking section at the top of page 2 - .draw_blocking_section(.90) - - # Footer (page 2) - grid.text("Data: TrackMan | Report Generated: Coastal Carolina Baseball Analytics", - x=0.5,y=0.008,gp=gpar(cex=0.70,col="grey50")) - } - - },error=function(e) message("Error creating PDF: ",e$message), finally=dev.off()) - if(!file.exists(output_file)) stop("PDF file was not created successfully") - return(output_file) -} - -# ===================================================================== -# ===================== PITCHER CODE (UPDATED) ====================== -# ===================================================================== - -draw_boxed_shared_legend <- function(labels, colors, - x_center = 0.5, y_top = 0.755, - n_cols = 6, - cell_height = 0.036, - box_width = 0.82, - title = "Pitch Type", - pad_h = 0.016, pad_v = 0.018, - border_col = "#bfbfbf", - cex_labels = 0.82) { - - labels <- as.character(labels) - colors <- as.character(colors) - if (!length(labels)) { - grid::grid.text("No legend (no pitch types)", x = x_center, y = y_top - 0.02) - return(invisible(NULL)) - } - - n_cols <- max(1L, min(n_cols, length(labels))) - n_items <- length(labels) - n_rows <- ceiling(n_items / n_cols) - - title_h <- cell_height * 0.9 - box_h <- pad_v + title_h + (n_rows * cell_height) + pad_v - box_w <- box_width - - grid::grid.rect(x = x_center, y = y_top - box_h/2, - width = box_w, height = box_h, - just = c("center", "center"), - gp = grid::gpar(fill = "white", col = border_col, lwd = 0.8)) - - grid::grid.text(title, - x = x_center, - y = y_top - pad_v, - just = c("center", "top"), - gp = grid::gpar(fontface = "bold", cex = 0.92, col = "#006F71")) - - inner_top_y <- y_top - pad_v - title_h - grid::pushViewport( - grid::viewport( - x = x_center, - y = inner_top_y - ((n_rows * cell_height)/2), - width = box_w - 2*pad_h, - height = n_rows * cell_height, - just = c("center","center"), - layout = grid::grid.layout(nrow = n_rows, ncol = n_cols) - ) - ) - - idx <- 1L - for (r in seq_len(n_rows)) { - for (c in seq_len(n_cols)) { - if (idx > n_items) break - lab <- labels[idx] - col <- colors[idx] - - grid::pushViewport(grid::viewport(layout.pos.row = r, layout.pos.col = c)) - grid::pushViewport(grid::viewport( - layout = grid::grid.layout( - nrow = 1, ncol = 2, - widths = grid::unit.c(grid::unit(0.40, "npc"), grid::unit(0.60, "npc")) - ) - )) - - grid::pushViewport(grid::viewport(layout.pos.row = 1, layout.pos.col = 1)) - grid::grid.rect(x = 0.5, y = 0.5, - width = grid::unit(0.55, "npc"), - height = grid::unit(0.55, "npc"), - just = c("center","center"), - gp = grid::gpar(fill = col, col = "black", lwd = 0.4)) - grid::popViewport() - - grid::pushViewport(grid::viewport(layout.pos.row = 1, layout.pos.col = 2)) - grid::grid.text(lab, x = 0.5, y = 0.5, just = c("center","center"), - gp = grid::gpar(cex = cex_labels)) - grid::popViewport() - - grid::popViewport() - grid::popViewport() - idx <- idx + 1L - } - } - grid::popViewport() -} - -draw_simple_table <- function(df, y_top, - col_headers = colnames(df), - col_widths = NULL, - row_height = 0.018, - header_bg = "#006F71", - header_cex = 0.60, - cell_cex = 0.58, - header_fg = "white", - zebra = TRUE) { - if (is.null(df) || !ncol(df)) { - grid::textGrob("No data", gp = grid::gpar(col = "red")) - return(invisible(NULL)) - } - df[] <- lapply(df, function(x) ifelse(is.na(x), "", as.character(x))) - - if (is.null(col_headers) || length(col_headers) != ncol(df)) { - col_headers <- colnames(df) - } - if (is.null(col_widths)) { - col_widths <- rep(1 / ncol(df), ncol(df)) - } - - x_start <- 0.5 - sum(col_widths)/2 - x_pos <- c(x_start, x_start + cumsum(col_widths[-length(col_widths)])) - - for (i in seq_along(col_headers)) { - grid::grid.rect(x = x_pos[i], y = y_top, width = col_widths[i]*0.985, height = row_height, - just = c("left","top"), - gp = grid::gpar(fill = header_bg, col = "black", lwd = 0.5)) - grid::grid.text(col_headers[i], x = x_pos[i] + col_widths[i]*0.49, y = y_top - row_height*0.5, - gp = grid::gpar(col = header_fg, cex = header_cex, fontface = "bold")) - } - - if (nrow(df) == 0) return(invisible(NULL)) - for (r in seq_len(nrow(df))) { - y_row <- y_top - r*row_height - for (i in seq_along(col_headers)) { - val <- df[[i]][r] - bg <- if (zebra && (r %% 2 == 0)) "#f7f7f7" else "white" - grid::grid.rect(x = x_pos[i], y = y_row, width = col_widths[i]*0.985, height = row_height, - just = c("left","top"), - gp = grid::gpar(fill = bg, col = "grey80", lwd = 0.3)) - grid::grid.text(val, x = x_pos[i] + col_widths[i]*0.49, y = y_row - row_height*0.5, - gp = grid::gpar(cex = cell_cex)) - } - } -} - -.pitcher_game_line_headers <- c("Date","BF","K","BB","HBP","H","XBH","Strike %","Whiff %") -.pitcher_game_line_widths <- c(0.13,0.07,0.06,0.06,0.07,0.06,0.07,0.11,0.11) - -.pitcher_char_headers <- c("Pitch","Total","Avg Velo","Max Velo","Avg Spin","Max Spin", - "Avg IVB","Avg HB","RelHt","Ext","Strike %","Whiff %") -.pitcher_char_widths <- c(0.12,0.07,0.09,0.09,0.09,0.09,0.085,0.085,0.07,0.07,0.085,0.085) - -create_pitcher_game_line <- function(game_data) { - game_data %>% - summarise( - Date = format(unique(Date)[1], "%m/%d/%y"), - BF = n_distinct(paste(Inning, Batter, PAofInning)), - K = sum(KorBB == "Strikeout", na.rm = TRUE), - BB = sum(WalkIndicator, na.rm = TRUE), - HBP = sum(HBPIndicator, na.rm = TRUE), - H = sum(PlayResult %in% c('Single','Double','Triple','HomeRun'), na.rm = TRUE), - XBH = sum(PlayResult %in% c('Double','Triple','HomeRun'), na.rm = TRUE), - `Strike %` = round(mean(PitchCall %in% c("StrikeCalled","StrikeSwinging","FoulBall","FoulBallNotFieldable","InPlay"), na.rm = TRUE) * 100, 1), - `Whiff %` = round(sum(WhiffIndicator, na.rm = TRUE) / sum(SwingIndicator, na.rm = TRUE) * 100, 1) - ) -} - -create_pitcher_pitch_char <- function(game_data) { - game_data %>% - filter(TaggedPitchType != "Other", !is.na(TaggedPitchType)) %>% - group_by(Pitch = TaggedPitchType) %>% - summarise( - Total = n(), - `Avg Velo` = round(mean(RelSpeed, na.rm = TRUE), 1), - `Max Velo` = round(max(RelSpeed, na.rm = TRUE), 1), - `Avg Spin` = round(mean(SpinRate, na.rm = TRUE), 0), - `Max Spin` = round(max(SpinRate, na.rm = TRUE), 1), - `Avg IVB` = round(mean(InducedVertBreak, na.rm = TRUE), 1), - `Avg HB` = round(mean(HorzBreak, na.rm = TRUE), 1), - RelHt = round(mean(RelHeight, na.rm = TRUE), 1), - Ext = round(mean(Extension, na.rm = TRUE), 1), - `Strike %` = round(mean(PitchCall %in% c("StrikeCalled","StrikeSwinging","FoulBall","FoulBallNotFieldable","InPlay"), na.rm = TRUE) * 100, 1), - `Whiff %` = round(sum(WhiffIndicator, na.rm = TRUE) / sum(SwingIndicator, na.rm = TRUE) * 100, 1), - .groups = "drop" - ) %>% arrange(desc(Total)) -} - -.pitch_theme <- theme_minimal(base_size = 12) + - theme( - plot.title = element_text(size = 16, face = "bold", hjust = 0.5), - panel.grid.minor = element_blank() - ) - -create_pitcher_movement_plot <- function(game_data, pitcher_name, pitch_colors) { - df <- game_data %>% filter(!is.na(TaggedPitchType), TaggedPitchType != "Other") - if (nrow(df) == 0) return(ggplot() + theme_void() + ggtitle("Pitch Movement")) - centers <- df %>% group_by(TaggedPitchType) %>% - summarise( - mean_velo = round(mean(RelSpeed, na.rm = TRUE)), - mean_hb = median(HorzBreak, na.rm = TRUE), - mean_ivb = median(InducedVertBreak, na.rm = TRUE), .groups = "drop" - ) - ggplot(df, aes(x = HorzBreak, y = InducedVertBreak)) + - geom_vline(xintercept = 0, color = "black", linewidth = 0.5) + - geom_hline(yintercept = 0, color = "black", linewidth = 0.5) + - geom_point(aes(fill = TaggedPitchType), alpha = 0.85, shape = 21, color = "black", stroke = 0.4, size = 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_ball_miss <- data %>% - filter(PitchCall %in% c("BallCalled", "BallinDirt")) %>% - 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) -} - - -# ===================================================================== -# COUNT LEVERAGE STATS -# 0-0 Zone% : in-zone rate on first pitch of the PA (0-0 count) -# Ahead Strike% : strike rate when pitcher is ahead (0-1, 0-2, 1-2) -# Kill% : 2-strike putaway rate -- 2K pitches ending PA in a K -# over all 2-strike pitches thrown -# Returned both overall and by pitch type. -# ===================================================================== -create_count_leverage_stats <- function(data, player_name) { - data <- normalize_columns(data) - data <- process_pitcher_indicators(data) - - pd <- data %>% - dplyr::filter(Pitcher == player_name, - !is.na(TaggedPitchType), - TaggedPitchType != "Other") - - empty_out <- list( - overall = data.frame( - Split = "Overall", Pitches = 0L, - `0-0 Zone%` = NA_real_, `Ahead Strike%` = NA_real_, `Kill%` = NA_real_, - check.names = FALSE - ), - by_pitch = data.frame( - Split = character(0), Pitches = integer(0), - `0-0 Zone%` = numeric(0), `Ahead Strike%` = numeric(0), `Kill%` = numeric(0), - check.names = FALSE - ) - ) - - if (nrow(pd) == 0) return(c(empty_out, list(colors_overall = "#FFFFFF", - colors_by_pitch = matrix("#FFFFFF", 0, 5)))) - - # Guard: need Balls/Strikes to do anything count-based - if (!all(c("Balls", "Strikes") %in% names(pd))) { - return(c(empty_out, list(colors_overall = "#FFFFFF", - colors_by_pitch = matrix("#FFFFFF", 0, 5)))) - } - - pd <- pd %>% - dplyr::mutate( - Balls = suppressWarnings(as.numeric(Balls)), - Strikes = suppressWarnings(as.numeric(Strikes)), - is_strike_call = as.integer( - !PitchCall %in% c("BallCalled", "BallinDirt", "BallIntentional", "HitByPitch") - ), - is_first_pitch = as.integer(Balls == 0 & Strikes == 0), - is_ahead = as.integer( - (Balls == 0 & Strikes == 1) | - (Balls == 0 & Strikes == 2) | - (Balls == 1 & Strikes == 2) - ), - is_two_strike = as.integer(Strikes == 2), - is_kill = as.integer(Strikes == 2 & KorBB == "Strikeout") - ) - - calc_block <- function(d) { - n_fp <- sum(d$is_first_pitch, na.rm = TRUE) - n_ahead <- sum(d$is_ahead, na.rm = TRUE) - n_2k <- sum(d$is_two_strike, na.rm = TRUE) - - data.frame( - Pitches = nrow(d), - `0-0 Zone%` = if (n_fp > 0) - round(100 * sum(d$in_zone[d$is_first_pitch == 1], na.rm = TRUE) / n_fp, 1) else NA_real_, - `Ahead Strike%` = if (n_ahead > 0) - round(100 * sum(d$is_strike_call[d$is_ahead == 1], na.rm = TRUE) / n_ahead, 1) else NA_real_, - `Kill%` = if (n_2k > 0) - round(100 * sum(d$is_kill, na.rm = TRUE) / n_2k, 1) else NA_real_, - check.names = FALSE - ) - } - - overall <- cbind(data.frame(Split = "Overall", stringsAsFactors = FALSE), - calc_block(pd)) - - by_pitch <- pd %>% - dplyr::group_split(TaggedPitchType) %>% - lapply(function(d) { - cbind(data.frame(Split = as.character(d$TaggedPitchType[1]), stringsAsFactors = FALSE), - calc_block(d)) - }) %>% - dplyr::bind_rows() %>% - dplyr::arrange(dplyr::desc(Pitches)) - - # ---- color gradients (rough D1 benchmarks; tune as needed) ---- - bench <- list(`0-0 Zone%` = 55, `Ahead Strike%` = 62, `Kill%` = 22) - tol <- list(`0-0 Zone%` = 0.15, `Ahead Strike%` = 0.15, `Kill%` = 0.35) - - color_row <- function(row_df) { - sapply(names(row_df), function(nm) { - if (!nm %in% names(bench)) return("#FFFFFF") - v <- row_df[[nm]] - if (!is.finite(v)) return("#FFFFFF") - get_gradient_color(v, bench[[nm]], "higher_better", tol[[nm]]) - }, USE.NAMES = FALSE) - } - - colors_overall <- color_row(overall) - - colors_by_pitch <- if (nrow(by_pitch) > 0) { - t(sapply(seq_len(nrow(by_pitch)), function(i) color_row(by_pitch[i, , drop = FALSE]))) - } else { - matrix("#FFFFFF", nrow = 0, ncol = ncol(overall)) - } - if (!is.matrix(colors_by_pitch)) { - colors_by_pitch <- matrix(colors_by_pitch, nrow = nrow(by_pitch), byrow = TRUE) - } - - list( - overall = overall, - by_pitch = by_pitch, - colors_overall = colors_overall, - colors_by_pitch = colors_by_pitch - ) -} - - -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" - - # 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), - check.names = FALSE, stringsAsFactors = FALSE - ) - return(list(stats = empty_df, colors = matrix("#FFFFFF", nrow = 0, ncol = 16))) - } - - 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), - .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_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_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 ("Overall" = every pitch to that side) - result_levels <- c( - "Overall", - "Whiffs", - "Balls Called", - "Strikes Called", - "Hard Hits (95+)", - "2 Strikes" - ) - - base_data <- data %>% - dplyr::filter( - Pitcher == player_name, - BatterSide == batter_side, - !is.na(TaggedPitchType), - TaggedPitchType != "Other", - !is.na(PlateLocSide), - !is.na(PlateLocHeight) - ) - - # Result-specific facets - result_data <- base_data %>% - 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)) - - # Overall facet = every pitch, uncategorized - overall_data <- base_data %>% dplyr::mutate(ResultType = "Overall") - - pitcher_data <- dplyr::bind_rows(overall_data, result_data) - - if (nrow(pitcher_data) == 0) { - # Dummy data frame so we still draw all 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 = 6, - 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_release_consistency_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" - ) - - # Ring geometry (inches) - circ <- function(r) { - th <- seq(0, 2 * pi, length.out = 100) - data.frame(x = r * cos(th), y = r * sin(th), r = r) - } - ring_df <- do.call(rbind, lapply(c(3, 6, 9, 12), circ)) - lim <- 13 - - base_plot <- ggplot() + - geom_path(data = ring_df, aes(x, y, group = r), - color = "gray75", linewidth = 0.4) + - geom_path(data = circ(3), aes(x, y), color = "#c0392b", - linewidth = 0.9, linetype = "longdash") + - annotate("text", x = 0, y = 3.6, label = "3\" = hitter-detectable", - size = 3, color = "#c0392b") + - geom_hline(yintercept = 0, color = "gray80", linewidth = 0.3) + - geom_vline(xintercept = 0, color = "gray80", linewidth = 0.3) - - if (nrow(df) == 0) { - return( - base_plot + - coord_fixed(xlim = c(-lim, lim), ylim = c(-lim, lim)) + - labs(x = NULL, y = NULL) + - ggtitle("Release Consistency") + - theme_void(base_size = 12) + - theme(plot.title = element_text(size = 12, face = "bold", hjust = 0.5)) - ) - } - - # Anchor = average release of the fastball if present, else most-thrown pitch - counts <- df %>% dplyr::count(TaggedPitchType, sort = TRUE) - anchor_type <- if (any(counts$TaggedPitchType == "Fastball")) { - "Fastball" - } else { - as.character(counts$TaggedPitchType[1]) - } - anchor <- df %>% - dplyr::filter(TaggedPitchType == anchor_type) %>% - dplyr::summarise(RelSide = mean(RelSide, na.rm = TRUE), - RelHeight = mean(RelHeight, na.rm = TRUE)) - - # Per-pitch averages, in INCHES relative to the anchor - avg <- df %>% - dplyr::group_by(TaggedPitchType) %>% - dplyr::summarise(RelSide = mean(RelSide, na.rm = TRUE), - RelHeight = mean(RelHeight, na.rm = TRUE), - .groups = "drop") %>% - dplyr::mutate(dx = (RelSide - anchor$RelSide) * 12, - dy = (RelHeight - anchor$RelHeight) * 12) - - base_plot + - geom_point(data = avg, aes(dx, dy, fill = TaggedPitchType), - shape = 21, size = 7, color = "black", stroke = 0.6) + - scale_fill_manual(values = pitch_colors, name = "Pitch") + - coord_fixed(xlim = c(-lim, lim), ylim = c(-lim, lim)) + - labs(x = NULL, y = NULL, - caption = sprintf("Center = avg %s release", anchor_type)) + - ggtitle("Release Consistency") + - theme_void(base_size = 12) + - theme(legend.position = "bottom", - plot.title = element_text(size = 12, face = "bold", hjust = 0.5), - plot.caption = element_text(hjust = 0.5, color = "gray45")) -} - -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 pitcher's team colors from team_master -pitcher_team_abbr <- if ("PitcherTeam" %in% names(pitcher_df)) pitcher_df$PitcherTeam[1] else "COA_CHA" - -report_color_primary <- "#006F71" # CCU teal default -report_color_secondary <- "#A27752" # CCU gold default - -if (!is.null(team_master) && !is.na(pitcher_team_abbr)) { - idx <- which(trimws(team_master$tm_abbrev) == trimws(pitcher_team_abbr)) - if (length(idx) > 0) { - report_color_primary <- team_master$color_primary[idx[1]] - report_color_secondary <- team_master$color_secondary[idx[1]] - } -} - - - # 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 - - # Count leverage stats (overall + by pitch) - lev_result <- tryCatch( - create_count_leverage_stats(pitcher_df, pitcher_name), - error = function(e) { - message("Error in count leverage: ", e$message) - NULL - } - ) - - # ===== 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_, - 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") - ) - release_consistency_plot <- tryCatch( - create_release_consistency_plot(pitcher_df, pitcher_name, pitch_colors), - error = function(e) ggplot2::ggplot() + ggplot2::theme_void() + ggplot2::ggtitle("Release Consistency 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") - ) - - 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 = 12, height = 18) - on.exit(try(dev.off(), silent = TRUE), add = TRUE) - grid::grid.newpage() - - header_y_top <- 0.98 - charts_y_top <- 0.86 - charts_height <- 0.22 - charts_y_bottom <- charts_y_top - charts_height - - # --- BIGGER TABLES: taller rows, larger text --- - row_h_char <- 0.0235 # pitch characteristics row height - hdr_cex <- 0.72 # table header text size - cell_cex <- 0.72 # table cell text size - summary_cex <- 0.78 # summary/header block text size - section_cex <- 1.35 # section title size - - rows_including_header <- num_rows + 1 - - # ----- Row 2: Count Leverage TABLE (left) | Release plot (right) ----- - row2_y_top <- charts_y_bottom - 0.018 - release_height <- 0.18 - - lev_rows <- if (!is.null(lev_result)) nrow(lev_result$by_pitch) + 1L else 0L # +1 overall row - lev_row_h <- row_h_char - # Table title sits at row2_y_top; grid starts just under it - y_top_lev <- row2_y_top - 0.026 - lev_block_bottom <- y_top_lev - (lev_rows + 1) * lev_row_h # +1 for header row - - row2_bottom <- min(lev_block_bottom, row2_y_top - release_height) - # Never let the tables push the location charts off the page - row2_bottom <- max(row2_bottom, 0.40) - - # Pitch characteristics table below row 2 - y_top_char <- row2_bottom - 0.038 - char_block_bottom <- y_top_char - rows_including_header * row_h_char - - # Location plots at the BOTTOM of the report - y_loc_top <- char_block_bottom - 0.026 - - # ===== 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")) - } - - grid::grid.text(paste(pitcher_name, "- Advanced Pitcher Report"), - x = 0.5, y = 0.5, - gp = grid::gpar(fontface = "bold", cex = 1.5, col = report_color_primary)) - - - # 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.925, - gp = grid::gpar(fontface = "bold", cex = section_cex, col = report_color_primary)) - - - - summary_headers <- names(summary_stats) - summary_values <- as.numeric(summary_stats[1, ]) - summary_widths <- rep(0.070, 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.022 - - 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 = "black", 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 = summary_cex, 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 = summary_cex)) - } - - # 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(release_consistency_plot, newpage = FALSE), error = function(e) NULL) - grid::popViewport() - - # Row 2: Count Leverage TABLE (left) | Release side plot (right) - if (!is.null(lev_result)) { - lev_tbl <- rbind(lev_result$overall, lev_result$by_pitch) - lev_col_mat <- rbind( - matrix(lev_result$colors_overall, nrow = 1), - lev_result$colors_by_pitch - ) - - lev_headers <- names(lev_tbl) - n_lev_cols <- length(lev_headers) - - # Left half of the page: span roughly x = 0.03 .. 0.51 - lev_total_w <- 0.48 - lev_left <- 0.03 - lev_widths <- c(lev_total_w * 0.26, - rep(lev_total_w * 0.74 / max(1, n_lev_cols - 1), n_lev_cols - 1)) - x_pos_lev <- c(lev_left) - if (length(lev_widths) > 1) { - x_pos_lev <- c(lev_left, lev_left + cumsum(lev_widths[-length(lev_widths)])) - } - - grid::grid.text("Count Leverage", - x = lev_left + lev_total_w / 2, y = row2_y_top, - just = c("center", "top"), - gp = grid::gpar(fontface = "bold", cex = section_cex, col = report_color_primary)) - - # Header row - for (i in seq_along(lev_headers)) { - grid::grid.rect(x = x_pos_lev[i], y = y_top_lev, - width = lev_widths[i] * 0.985, height = lev_row_h, - just = c("left", "top"), - gp = grid::gpar(fill = report_color_primary, col = "black", lwd = 0.5)) - grid::grid.text(lev_headers[i], - x = x_pos_lev[i] + lev_widths[i] * 0.49, - y = y_top_lev - lev_row_h * 0.5, - gp = grid::gpar(col = "white", cex = hdr_cex, fontface = "bold")) - } - - # Data rows - for (r in seq_len(nrow(lev_tbl))) { - y_row_lev <- y_top_lev - r * lev_row_h - is_overall_row <- identical(as.character(lev_tbl$Split[r]), "Overall") - - for (i in seq_along(lev_headers)) { - colname <- lev_headers[i] - - bg <- "#FFFFFF" - if (r <= nrow(lev_col_mat) && i <= ncol(lev_col_mat)) { - cand <- lev_col_mat[r, i] - if (!is.na(cand) && nzchar(cand)) bg <- cand - } - - if (identical(colname, "Split")) { - sp <- as.character(lev_tbl$Split[r]) - if (is_overall_row) { - bg <- "#E8E8E8" - } else if (sp %in% names(pitch_colors)) { - bg <- pitch_colors[[sp]] - } - } - - grid::grid.rect(x = x_pos_lev[i], y = y_row_lev, - width = lev_widths[i] * 0.985, height = lev_row_h, - just = c("left", "top"), - gp = grid::gpar(fill = bg, col = "grey80", lwd = 0.3)) - - val <- lev_tbl[[colname]][r] - display_val <- if (is.numeric(val)) { - if (!is.finite(val)) "-" else if (identical(colname, "Pitches")) sprintf("%d", as.integer(val)) else sprintf("%.1f", val) - } else { - v <- as.character(val); if (nzchar(v)) v else "-" - } - - txt_col <- if (identical(colname, "Split")) .text_on_fill(bg) else "black" - - grid::grid.text(display_val, - x = x_pos_lev[i] + lev_widths[i] * 0.49, - y = y_row_lev - lev_row_h * 0.5, - gp = grid::gpar( - cex = cell_cex, - col = txt_col, - fontface = if (identical(colname, "Split") || is_overall_row) "bold" else "plain" - )) - } - } - } - - grid::pushViewport(grid::viewport( - x = 0.77, y = row2_y_top, - width = 0.44, height = release_height, - 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.018, - gp = grid::gpar(fontface = "bold", cex = section_cex, col = report_color_primary)) - - - 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 = report_color_primary, 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 = hdr_cex, 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 = cell_cex, - col = txt_col, - fontface = if (has_pitchcol && identical(colname, "Pitch")) "bold" else "plain" - )) - } - } - - # Location plots (stacked full-width so all 6 facets stay legible). - # Height is derived from whatever vertical space is actually left so the - # bottom panel never runs off the page on deep pitch mixes. - footer_y <- 0.02 - loc_gap <- 0.010 - loc_avail <- y_loc_top - (footer_y + 0.022) - loc_gap - loc_row_h <- max(0.085, min(0.150, loc_avail / 2)) - - grid::pushViewport(grid::viewport(x = 0.5, y = y_loc_top, - width = 0.96, height = loc_row_h, - just = c("center","top"))) - tryCatch(print(location_lhb, newpage = FALSE), error = function(e) NULL) - grid::popViewport() - - grid::pushViewport(grid::viewport(x = 0.5, y = y_loc_top - loc_row_h - loc_gap, - width = 0.96, height = loc_row_h, - 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", height = "150px"), - passwordInput("password", "Password:"), - actionButton("login", "Login"), - textOutput("wrong_pass") - ) -) - - -app_ui <- fluidPage( - tags$head(tags$style(HTML(app_css))), - div(class = "header", - h1("Postgame Report Generator"), - p("Upload a Trackman CSV to generate postgame reports | To generate a bullpen report first select the bullpen option then upload a portable trackman CSV") - ), - uiOutput("leaderboard_ui"), - tabsetPanel( - id = "main_tabs", - tabPanel("Generate Reports", - br(), - fluidRow( - column( - 4, - div(class = "main-panel", - div(class = "upload-box", - h3("Upload Game Data", style = "color: #006F71; margin-top: 0;"), - radioButtons("report_type", "Report Type", - c("Hitter Report"="hitter", - "Camp Pitcher Report"="pitcher", - "Isaac Pitcher Report"="advanced_pitcher", - "Matt Williams Report"="tableau_pitcher", - "Catcher Report"="catcher", - "Umpire Report"="umpire", - "BP Report"="bp", - "Bullpen Report (Portable) "="bullpen"), - selected = "hitter", inline = TRUE), - - conditionalPanel( - condition = "input.report_type != 'bullpen'", - fileInput("game_csv", NULL, accept = c(".csv","text/csv"), - buttonLabel = "Choose Game CSV...", placeholder = "No file selected") - ), - - conditionalPanel( - condition = "input.report_type == 'bullpen'", - fileInput("bullpen_csv", "Bullpen CSV", accept = c(".csv","text/csv"), - buttonLabel = "Choose Bullpen CSV...", placeholder = "Upload bullpen pitching data"), - p("Upload a bullpen-session TrackMan CSV (no game data needed)", - style = "font-size: 0.85em; color: #666; margin-top: -10px;") - ), - - hr(), - h4("Optional: Bio CSV", style = "color: #006F71; font-size: 1em;"), - conditionalPanel("input.report_type == 'hitter'", - fileInput("bio_csv_hitter", "Player Bio (optional)", accept = c(".csv","text/csv"), - buttonLabel = "Choose Bio CSV...", placeholder = "Optional"), - p("Upload CCU_Hitter_Bio.csv to add headshots", - style = "font-size: 0.85em; color: #666; margin-top: -10px;") - ), - conditionalPanel("input.report_type == 'catcher'", - fileInput("bio_csv_catcher", "Catcher Bio (optional)", accept = c(".csv","text/csv"), - buttonLabel = "Choose Bio CSV...", placeholder = "Optional") - ) - ), - uiOutput("selector_ui"), - hr(), - uiOutput("download_ui"), - uiOutput("bulk_ui"), - uiOutput("status_message") - ) - ), - column( - 8, - div(class = "main-panel", - h3("Report Preview", style = "color: #006F71; margin-top: 0;"), - uiOutput("preview_content") - ) - ) - ) - ), - tabPanel("Data Processing", - br(), - tabsetPanel(id = "proc_tabs", - - # Upload & Process Tab - tabPanel( - "Upload & Process", - fluidRow( - column(6, - h3("1. Upload TrackMan CSV or Parquet"), - fileInput("file", "Choose CSV or Parquet File (max 5,000 rows)", - accept = c(".csv", ".parquet")), - fluidRow( - column(3, - checkboxInput("header", "Header", TRUE) - ), - column(3, - radioButtons("sep", "Separator", - choices = c(Comma = ",", Semicolon = ";", Tab = "\t"), - selected = ",", inline = TRUE) - ), - column(3, - radioButtons("quote", "Quote", - choices = c(None = "", "Double Quote" = '"', "Single Quote" = "'"), - selected = '"', inline = TRUE) - ), - column(3, - radioButtons("date_format", "Date Output Format", - choices = c("YYYY-MM-DD" = "yyyy", "M/D/YY" = "mdyy"), - selected = "yyyy") - ) - ), - p(style = "color: #666; font-size: 12px;", - "CSV options (Header, Separator, Quote) are ignored for Parquet files."), - verbatimTextOutput("csv_status") - ), - column(6, - div(class = "bat-tracking-box", - h3("2. Upload Bat Tracking JSON (Optional)", style = "margin-top: 0;"), - fileInput("json_file", "Choose Bat Tracking JSON File", accept = c(".json")), - p(style = "color: #666; font-size: 12px;", - "Upload the corresponding _battracking.json file to merge bat speed and attack angle data."), - verbatimTextOutput("json_status"), - uiOutput("merge_status_ui") - ) - ) - ), - - hr(), - - fluidRow( - column(8, - h3("3. Columns to Remove"), - p("Select which columns to remove from your dataset:"), - checkboxGroupInput("columns_to_remove", "Remove These Columns:", - choices = columns_to_remove, - selected = columns_to_remove) - ), - column(4, - h3("Quick Actions"), - br(), - actionButton("select_all_cols", "Select All", class = "btn-primary"), - br(), br(), - actionButton("deselect_all_cols", "Deselect All", class = "btn-default"), - br(), br(), - actionButton("select_spinaxis", "Select SpinAxis3d Columns", class = "btn-info"), - br(), br(), - h4("Processing Summary"), - verbatimTextOutput("process_summary") - ) - ) - ), - - # Bat Tracking Details Tab - tabPanel( - "Bat Tracking Data", - fluidRow( - column(12, - h3("Bat Tracking Merge Details"), - uiOutput("bat_tracking_details"), - hr(), - h4("Pitches with Bat Tracking Data"), - DT::dataTableOutput("bat_tracking_table") - ) - ) - ), - - # Preview Data Tab - tabPanel( - "Preview Data", - fluidRow( - column(12, - h3("Data Preview"), - DT::dataTableOutput("preview") - ) - ) - ), - - # Pitch Movement Chart Tab - tabPanel( - "Pitch Movement Chart", - fluidRow( - column(3, - selectInput("pitcher_select", "Select Pitcher:", - choices = NULL, selected = NULL) - ), - column(3, - h4("Selection Mode:"), - radioButtons("selection_mode", "", - choices = list("Single Click" = "single", "Drag Select" = "drag"), - selected = "single", inline = TRUE) - ), - column(6, - conditionalPanel( - condition = "input.selection_mode == 'drag'", - h4("Bulk Edit:"), - fluidRow( - column(8, - selectInput("bulk_pitch_type", "Change all selected to:", - choices = c("Fastball", "Sinker", "Cutter", "Slider", - "Curveball", "ChangeUp", "Splitter", "Knuckleball", "Sweeper", "Other"), - selected = "Fastball") - ), - column(4, - br(), - actionButton("apply_bulk_change", "Apply to Selected", class = "btn-success") - ) - ) - ) - ) - ), - - fluidRow( - column(8, - h3("Interactive Pitch Movement Analysis"), - plotOutput("movement_plot", height = "600px", - click = "plot_click", - brush = brushOpts(id = "plot_brush"), - hover = hoverOpts(id = "plot_hover", delay = 100)), - - h4("Instructions:"), - p(strong("Single Click Mode:"), "Click on any point to edit one pitch type at a time via popup modal."), - p(strong("Drag Select Mode:"), "Click and drag to select multiple points, then use the dropdown to change them all at once."), - conditionalPanel( - condition = "input.selection_mode == 'drag'", - div(style = "background-color: #f0f8ff; padding: 10px; border-radius: 5px; margin: 10px 0; border-left: 4px solid darkcyan;", - h4("Selected Points:", style = "margin-top: 0; color: darkcyan;"), - textOutput("selection_info") - ) - ), - verbatimTextOutput("hover_info"), - verbatimTextOutput("click_info") - ), - - column(4, - h3("Pitch Metrics Summary"), - DT::dataTableOutput("movement_stats") - ) - ), - - # ── Rule-Based Pitch Retagging Panel ── - hr(), - fluidRow( - column(12, - div( - style = "background: linear-gradient(135deg, #e8f4f8 0%, #f0e6d3 100%); border: 2px solid darkcyan; border-radius: 15px; padding: 20px; margin-top: 10px;", - h3("Rule-Based Pitch Retagging", style = "margin-top: 0; color: darkcyan; border-bottom: 2px solid darkcyan; padding-bottom: 8px;"), - p(style = "color: #666; font-size: 13px; margin-bottom: 15px;", - "Set filters to match pitches, then retag them all at once. ", - "Only pitches matching ALL non-empty filters for the selected pitcher will be changed."), - - fluidRow( - # Column 1: Source pitch type + date range - column(3, - h4("Pitch Type & Date", style = "color: peru; margin-top: 0;"), - selectInput("retag_from_type", "Current Pitch Type:", - choices = c("(Any)" = "", "Fastball", "Sinker", "Cutter", - "Slider", "Curveball", "ChangeUp", "Splitter", - "Knuckleball", "Sweeper", "Other"), - selected = ""), - dateRangeInput("retag_dates", "Date Range (optional):", - start = NA, end = NA), - textInput("retag_pitch_no_range", "Pitch Numbers (e.g. 1-50):", value = "") - ), - - # Column 2: Velocity & spin filters - column(3, - h4("Velocity & Spin", style = "color: peru; margin-top: 0;"), - fluidRow( - column(6, numericInput("retag_velo_min", "Velo Min:", value = NA)), - column(6, numericInput("retag_velo_max", "Velo Max:", value = NA)) - ), - fluidRow( - column(6, numericInput("retag_spin_min", "Spin Min:", value = NA)), - column(6, numericInput("retag_spin_max", "Spin Max:", value = NA)) - ) - ), - - # Column 3: Movement filters - column(3, - h4("Movement (inches)", style = "color: peru; margin-top: 0;"), - fluidRow( - column(6, numericInput("retag_ivb_min", "IVB Min:", value = NA)), - column(6, numericInput("retag_ivb_max", "IVB Max:", value = NA)) - ), - fluidRow( - column(6, numericInput("retag_hb_min", "HB Min:", value = NA)), - column(6, numericInput("retag_hb_max", "HB Max:", value = NA)) - ), - fluidRow( - column(6, numericInput("retag_vb_min", "VBreak Min:", value = NA)), - column(6, numericInput("retag_vb_max", "VBreak Max:", value = NA)) - ) - ), - - # Column 4: Target type + actions - column(3, - h4("Retag To", style = "color: peru; margin-top: 0;"), - selectInput("retag_to_type", "New Pitch Type:", - choices = c("Fastball", "Sinker", "Cutter", "Slider", - "Curveball", "ChangeUp", "Splitter", - "Knuckleball", "Sweeper", "Other"), - selected = "Sweeper"), - br(), - actionButton("retag_preview_btn", "Preview Matches", - class = "btn-info", style = "width: 100%;"), - br(), br(), - actionButton("retag_apply_btn", "Apply Retag", - class = "btn-success", style = "width: 100%; font-weight: bold;"), - br(), br(), - actionButton("retag_clear_btn", "Clear Filters", - class = "btn-default", style = "width: 100%;") - ) - ), - - # Preview results area - div( - style = "margin-top: 15px;", - uiOutput("retag_preview_summary"), - DT::dataTableOutput("retag_preview_table") - ) - ) - ) - ) - ), - - # ══════════════════════════════════════════════════════════════ - # Catcher Notes Tab - # ══════════════════════════════════════════════════════════════ - tabPanel( - "Catcher Notes", - fluidRow( - column(5, - div(class = "catcher-notes-input-box", - h3("Add Catcher Note", style = "margin-top: 0; color: darkcyan; border-bottom: 2px solid darkcyan; padding-bottom: 8px;"), - p(style = "color: #666; font-size: 13px; margin-bottom: 15px;", - "Log catcher events (throws, wild pitches, passed balls). ", - "Each note is matched to the pitch row by Catcher, Batter, Inning, and Count, ", - "then written into a CatcherNotes column on download."), - - fluidRow( - column(6, selectInput("cn_catcher", "Catcher:", choices = NULL)), - column(6, selectInput("cn_batter", "Batter:", choices = NULL)) - ), - - fluidRow( - column(4, numericInput("cn_inning", "Inning:", value = 1, min = 1, max = 20, step = 1)), - column(4, numericInput("cn_balls", "Balls:", value = 0, min = 0, max = 3, step = 1)), - column(4, numericInput("cn_strikes", "Strikes:", value = 0, min = 0, max = 2, step = 1)) - ), - - selectInput("cn_result", "Result:", - choices = c("2B Out", "2B Safe", "3B Out", "3B Safe", - "Wild Pitch", "Passed Ball", - "Pickoff Attempt", "Pickoff Out", - "Blocked Ball", "Other"), - selected = "2B Out"), - - conditionalPanel( - condition = "input.cn_result == 'Other'", - textInput("cn_custom_result", "Custom Note:", placeholder = "Describe the event...") - ), - - br(), - actionButton("cn_add_btn", "Add Note", class = "btn-success", - style = "width: 100%; font-weight: bold; font-size: 15px;"), - br(), br(), - uiOutput("cn_match_feedback") - ) - ), - - column(7, - h3("Logged Catcher Notes"), - p(style = "color: #666; font-size: 13px;", - "These notes will be merged into the CatcherNotes column when you download the data."), - DT::dataTableOutput("cn_notes_table"), - br(), - fluidRow( - column(6, - actionButton("cn_clear_all_btn", "Clear All Notes", class = "btn-danger", - style = "width: 100%;") - ), - column(6, - verbatimTextOutput("cn_summary") - ) - ) - ) - ) - ), - - tabPanel( - "Download", - fluidRow( - column(12, - h3("Download Processed Data"), - - div(class = "download-option-box", - h4("Processing Mode", style = "color: darkcyan; margin-top: 0;"), - radioButtons("download_mode", NULL, - choices = c( - "Raw Edited CSV (as-is)" = "raw", - "Full Processing (clean + indicators + Stuff+)" = "full" - ), - selected = "raw", inline = TRUE), - conditionalPanel( - condition = "input.download_mode == 'full'", - div(style = "background: #d4edda; border-left: 4px solid #28a745; padding: 12px; border-radius: 0 8px 8px 0; margin: 10px 0;", - p(style = "margin: 0; color: #155724; font-size: 13px;", - tags$b("Full Processing will:"), - "Normalize names & pitch types, convert PlateLocHeight/Side to inches, ", - "add swing/whiff/chase/zone flags, compute wOBA/SLG/OBP indicators, ", - "join run values, predict Stuff+, and trim to ~140 columns.") - ) - ), - conditionalPanel( - condition = "input.download_mode == 'raw'", - div(style = "background: #e8f4f8; border-left: 4px solid darkcyan; padding: 12px; border-radius: 0 8px 8px 0; margin: 10px 0;", - p(style = "margin: 0; color: #006F71; font-size: 13px;", - tags$b("Raw Edited:"), - "Downloads your current data with any pitch retags, column removals, ", - "bat tracking merges, and catcher notes applied — no additional processing.") - ) - ), - hr(), - fluidRow( - column(5, - textInput("download_filename", "File Name (without extension):", - value = paste0("app_ready_COA_", Sys.Date())) - ), - column(3, - radioButtons("download_format", "Export Format:", - choices = c("CSV" = "csv", "Parquet" = "parquet"), - selected = "csv", inline = TRUE) - ), - hr(), - radioButtons("download_team_filter", "Team Filter:", - choices = c( - "All Data" = "all", - "Coastal Pitchers Only (PitcherTeam = COA_CHA)" = "coastal_pitchers", - "Coastal Hitters Only (BatterTeam = COA_CHA)" = "coastal_hitters" - ), - selected = "all"), - hr(), - column(4, - br(), - downloadButton("downloadData", "Download", class = "btn-success btn-lg") - ) - ) - ), - - br(), - h4("Data Summary:"), - verbatimTextOutput("data_summary") - ) - ) - ), - #Scrape Tab - tabPanel( - "Scraping", - fluidRow( - column(2, - h4("Data Source", style = "color: darkcyan; border-bottom: 2px solid darkcyan; padding-bottom: 6px;"), - radioButtons("scrape_source", NULL, - choices = c("TrackMan PBP" = "pbp", - "TrackMan Positional" = "pos", - "NCAA Scoreboard" = "ncaa"), - selected = "pbp") - ), - - column(4, - h3("Controls"), - - dateInput("start_date", "Start Date:", value = Sys.Date() - 1), - dateInput("end_date", "End Date:", value = Sys.Date() - 1), - - uiOutput("scrape_options"), - - br(), - actionButton("scrape_btn", "Scrape Data", class = "btn-primary"), - br(), br(), - downloadButton("download_scrape", "Download CSV"), - actionButton("upload_hf_btn", "Upload to HF Dataset", class = "btn-download") - ), - - column(6, - h3("Progress"), - verbatimTextOutput("scrape_status"), - hr(), - h3("Data Preview"), - DT::dataTableOutput("scrape_preview") - ) - ) - ) - ), - bsModal("pitchEditModal", "Edit Pitch Type", "triggerModal", size = "medium", - div(style = "padding: 20px;", - h4("Selected Pitch Details:", style = "color: darkcyan;"), - verbatimTextOutput("selected_pitch_info"), - br(), - selectInput("modal_new_pitch_type", "Change Pitch Type To:", - choices = c("Fastball", "Sinker", "Cutter", "Slider", - "Curveball", "ChangeUp", "Splitter", "Knuckleball", "Sweeper", "Other"), - selected = "Fastball"), - br(), - actionButton("update_pitch", "Update Pitch Type", class = "btn-primary btn-lg"), - actionButton("cancel_edit", "Cancel", class = "btn-default") - ) - ) - ) - ) -) - - -ui <- fluidPage( - uiOutput("page") -) - - -server <- function(input, output, session) { - - logged_in <- reactiveVal(FALSE) - - output$page <- renderUI({ - if (logged_in()) { - app_ui - } else { - login_ui - } - }) - - observeEvent(input$login, { - if (input$password == PASSWORD) { - logged_in(TRUE) - output$wrong_pass <- renderText("") - } else { - output$wrong_pass <- renderText("Incorrect password, please try again.") - } - }) - - data_hitter <- reactiveVal(NULL) - data_catcher <- reactiveVal(NULL) - bio_hitter <- reactiveVal(NULL) - bio_catch <- reactiveVal(NULL) - data_umpire <- reactiveVal(NULL) - data_bp <- reactiveVal(NULL) - data_bullpen <- reactiveVal(NULL) - - observeEvent( - list(data_hitter(), input$report_type), - { - req(data_hitter()) - req(input$report_type == "umpire") - - data_umpire(umpire_process_data(data_hitter())) - }, - ignoreInit = TRUE - ) - - data_pitcher <- reactive({ - df <- data_hitter() - if (is.null(df)) return(NULL) - if (!"Pitcher" %in% names(df)) { - alt <- intersect(c("PitcherName","pitcher","Pitcher_LastFirst","PlayerName"), names(df)) - if (length(alt)) df$Pitcher <- df[[alt[1]]] else df$Pitcher <- NA_character_ - } - df %>% mutate( - Pitcher = stringr::str_replace(coalesce(Pitcher, ""), "^\\s*(\\w+)\\s*,\\s*(\\w+)\\s*$", "\\2 \\1") - ) - }) - - observeEvent(input$bullpen_csv, { - req(input$bullpen_csv) - tryCatch({ - df <- read.csv(input$bullpen_csv$datapath, stringsAsFactors = FALSE) - data_bullpen(process_bullpen_dataset(df)) - showNotification("Bullpen data loaded successfully!", type = "message", duration = 3) - }, error = function(e) { - showNotification(paste("Error loading bullpen CSV:", e$message), type = "error", duration = 6) - data_bullpen(NULL) - }) - }) - - observeEvent(input$game_csv, { - req(input$game_csv) - tryCatch({ - df <- read.csv(input$game_csv$datapath, stringsAsFactors = FALSE) - data_hitter(process_dataset(df)) - data_catcher(catcher_process_dataset(df)) - data_bp(process_bp_dataset(df)) - showNotification("Game data loaded successfully!", type = "message", duration = 3) - }, error = function(e) { - showNotification(paste("Error loading CSV:", e$message), type = "error", duration = 6) - data_hitter(NULL); data_catcher(NULL); data_bp(NULL) - }) - }) - - observeEvent(input$bio_csv_hitter, { - req(input$bio_csv_hitter) - tryCatch({ - bio <- read.csv(input$bio_csv_hitter$datapath, stringsAsFactors = FALSE) - if ("Batter" %in% names(bio)) { - bio <- bio %>% mutate(Batter = stringr::str_replace(Batter, "^\\s*(\\w+)\\s*,\\s*(\\w+)\\s*$", "\\2 \\1")) - } - bio_hitter(bio) - showNotification("Player bio loaded", type = "message", duration = 3) - }, error = function(e) { - showNotification(paste("Player bio error:", e$message), type = "warning", duration = 6) - bio_hitter(NULL) - }) - }) - - observeEvent(input$bio_csv_catcher, { - req(input$bio_csv_catcher) - tryCatch({ - bio <- read.csv(input$bio_csv_catcher$datapath, stringsAsFactors = FALSE) - if ("Catcher" %in% names(bio)) { - bio <- bio %>% mutate(Catcher = stringr::str_replace(Catcher, "^\\s*(\\w+)\\s*,\\s*(\\w+)\\s*$", "\\2 \\1")) - } - bio_catch(bio) - showNotification("Catcher bio loaded", type = "message", duration = 3) - }, error = function(e) { - showNotification(paste("Catcher bio error:", e$message), type = "warning", duration = 6) - bio_catch(NULL) - }) - }) - - output$selector_ui <- renderUI({ - if (input$report_type == "hitter") { - df <- data_hitter() - if (is.null(df)) return(div(p("Please upload a CSV to begin", style = "color:#666;font-style:italic;text-align:center;"))) - players <- sort(unique(na.omit(df$Batter))) - if (!length(players)) return(div(p("No players found in uploaded data", style="color:#cc6600;font-weight:bold;"))) - selectInput("player_name", "Select Player", choices = players, selected = players[1], width = "100%") - - } else if (input$report_type == "pitcher") { - df <- data_pitcher() - if (is.null(df)) return(div(p("Please upload a CSV to begin", style = "color:#666;font-style:italic;text-align:center;"))) - pitchers <- sort(unique(na.omit(df$Pitcher))) - if (!length(pitchers)) return(div(p("No pitchers found in uploaded data", style="color:#cc6600;font-weight:bold;"))) - selectInput("pitcher_name", "Select Pitcher", choices = pitchers, selected = pitchers[1], width = "100%") - - } else if (input$report_type == "catcher") { - df <- data_catcher() - if (is.null(df)) return(div(p("Please upload a CSV to begin", style = "color:#666;font-style:italic;text-align:center;"))) - catchers <- sort(unique(na.omit(df$Catcher))) - if (!length(catchers)) return(div(p("No catchers found in uploaded data", style="color:#cc6600;font-weight:bold;"))) - selectInput("catcher_name", "Select Catcher", choices = catchers, selected = catchers[1], width = "100%") - - } else if (input$report_type == "bp") { - df <- data_bp() - if (is.null(df)) return(div(p("Please upload a BP CSV to begin", - style = "color:#666;font-style:italic;text-align:center;"))) - players <- sort(unique(na.omit(df$Batter))) - if (!length(players)) return(div(p("No players found in BP data", - style="color:#cc6600;font-weight:bold;"))) - selectInput("bp_player_name", "Select Player", choices = players, selected = players[1], width = "100%") - - } else if (input$report_type == "bullpen") { - df <- data_bullpen() - if (is.null(df)) return(div(p("Please upload a bullpen CSV to begin", - style = "color:#666;font-style:italic;text-align:center;"))) - pitchers <- sort(unique(na.omit(df$Pitcher))) - if (!length(pitchers)) return(div(p("No pitchers found in bullpen data", - style="color:#cc6600;font-weight:bold;"))) - tagList( - selectInput("bullpen_pitcher_name", "Select Pitcher", - choices = pitchers, selected = pitchers[1], width = "100%"), - radioButtons("bullpen_intent", "Intent Level", - choices = c("Low", "Medium", "High"), - selected = "High", inline = TRUE) - ) - - } else if (input$report_type == "tableau_pitcher") { - df <- data_pitcher() - if (is.null(df)) return(div(p("Please upload a CSV to begin", style = "color:#666;font-style:italic;text-align:center;"))) - pitchers <- sort(unique(na.omit(df$Pitcher))) - if (!length(pitchers)) return(div(p("No pitchers found", style="color:#cc6600;font-weight:bold;"))) - selectInput("tableau_pitcher_name", "Select Pitcher", choices = pitchers, selected = pitchers[1], width = "100%") - - } else if (input$report_type == "advanced_pitcher") { - df <- data_pitcher() - if (is.null(df)) return(div(p("Please upload a CSV to begin", style = "color:#666;font-style:italic;text-align:center;"))) - pitchers <- sort(unique(na.omit(df$Pitcher))) - if (!length(pitchers)) return(div(p("No pitchers found in uploaded data", style="color:#cc6600;font-weight:bold;"))) - tagList( - selectInput("advanced_pitcher_name", "Select Pitcher", choices = pitchers, selected = pitchers[1], width = "100%"), - div(class = "status-box", - p(strong("Advanced Report Features:"), style = "margin-top: 0; color: #006F71;"), - tags$ul( - tags$li("Stuff+ Model Predictions"), - tags$li("SEC Benchmarking with Color Coding"), - tags$li("Enhanced Count Usage (Ahead/Behind/2-Strike)"), - tags$li("Location by Result Type Visualization") - )) - ) - - } else if (input$report_type == "umpire") { - df <- data_umpire() - if (is.null(df)) return(div(p("Please upload a CSV to begin", style = "color:#666;font-style:italic;text-align:center;"))) - game_date <- format(max(df$Date, na.rm = TRUE), '%B %d, %Y') - tagList( - textInput("umpire_name", "Umpire Name (optional)", - value = "", - placeholder = "Enter umpire name...", - width = "100%"), - div(class = "status-box", - h4("Game Date: ", game_date, style = "margin: 0; color: #006F71;")) - ) - } - }) - - output$download_ui <- renderUI({ - if (input$report_type == "hitter") { - downloadButton("download_hitter", "Download Hitter PDF", class = "btn-primary") - - } else if (input$report_type == "pitcher") { - downloadButton("download_pitcher", "Download Pitcher PDF", class = "btn-primary") - - } else if (input$report_type == "catcher") { - downloadButton("download_catcher", "Download Catcher PDF", class = "btn-primary") - - } else if (input$report_type == "advanced_pitcher") { - downloadButton("download_advanced_pitcher", "Download Advanced Pitcher PDF", class = "btn-primary") - - } else if (input$report_type == "umpire") { - downloadButton("download_umpire", "Download Umpire PDF", class = "btn-primary") - - } else if (input$report_type == "tableau_pitcher") { - downloadButton("download_tableau_pitcher", "Download Tableau Pitcher PDF", class = "btn-primary") - - } else if (input$report_type == "bp") { - downloadButton("download_bp", "Download BP Report PDF", class = "btn-primary") - - } else if (input$report_type == "bullpen") { - downloadButton("download_bullpen", "Download Bullpen Report PDF", class = "btn-primary") - } - }) - - output$bulk_ui <- renderUI({ - if (input$report_type == "hitter") { - df <- data_hitter(); if (is.null(df) || !"BatterTeam" %in% names(df)) return(NULL) - coastal_players <- df %>% filter(BatterTeam == "COA_CHA") %>% pull(Batter) %>% unique() %>% na.omit() - if (!length(coastal_players)) return(NULL) - tagList( - br(), - div(style="text-align:center;padding:10px;background:#f0f8f8;border-radius:6px;margin-top:10px;", - p(strong("Coastal Carolina Players Found: ", length(coastal_players)), - style="color:#006F71;margin:5px 0;"), - downloadButton("download_all_coastal_hitters", "Download All Coastal Hitter Reports (ZIP)", class="btn-secondary") - ) - ) - } else if (input$report_type == "pitcher") { - df <- data_pitcher(); if (is.null(df) || !"PitcherTeam" %in% names(df)) return(NULL) - coastal_pitchers <- df %>% filter(PitcherTeam == "COA_CHA") %>% pull(Pitcher) %>% unique() %>% na.omit() - if (!length(coastal_pitchers)) return(NULL) - tagList( - br(), - div(style="text-align:center;padding:10px;background:#f0f8f8;border-radius:6px;margin-top:10px;", - p(strong("Coastal Carolina Pitchers Found: ", length(coastal_pitchers)), - style="color:#006F71;margin:5px 0;"), - downloadButton("download_all_coastal_pitchers", "Download All Coastal Pitcher Reports (ZIP)", class="btn-secondary") - ) - ) - } else if (input$report_type == "advanced_pitcher") { - df <- data_pitcher() - if (is.null(df) || !"PitcherTeam" %in% names(df)) return(NULL) - coastal_pitchers <- df %>% filter(PitcherTeam == "COA_CHA") %>% pull(Pitcher) %>% unique() %>% na.omit() - if (!length(coastal_pitchers)) return(NULL) - tagList( - br(), - div(style="text-align:center;padding:10px;background:#f0f8f8;border-radius:6px;margin-top:10px;", - p(strong("Coastal Carolina Pitchers Found: ", length(coastal_pitchers)), - style="color:#006F71;margin:5px 0;"), - downloadButton("download_all_coastal_advanced_pitchers", "Download All Advanced Pitcher Reports (ZIP)", class="btn-secondary") - ) - ) - } else if (input$report_type == "tableau_pitcher") { - df <- data_pitcher(); if (is.null(df) || !"PitcherTeam" %in% names(df)) return(NULL) - coastal_pitchers <- df %>% filter(PitcherTeam == "COA_CHA") %>% pull(Pitcher) %>% unique() %>% na.omit() - if (!length(coastal_pitchers)) return(NULL) - tagList( - br(), - div(style="text-align:center;padding:10px;background:#f0f8f8;border-radius:6px;margin-top:10px;", - p(strong("Coastal Carolina Pitchers Found: ", length(coastal_pitchers)), - style="color:#006F71;margin:5px 0;"), - downloadButton("download_all_coastal_tableau_pitchers", "Download All Coastal Tableau Reports (ZIP)", class="btn-secondary") - ) - ) - } else if (input$report_type == "bullpen") { - df <- data_bullpen() - if (is.null(df)) return(NULL) - pitchers <- sort(unique(na.omit(df$Pitcher))) - if (length(pitchers) <= 1) return(NULL) - tagList( - br(), - div(style="text-align:center;padding:10px;background:#f0f8f8;border-radius:6px;margin-top:10px;", - p(strong("Pitchers Found: ", length(pitchers)), - style="color:#006F71;margin:5px 0;"), - downloadButton("download_all_bullpen", "Download All Bullpen Reports (ZIP)", class="btn-secondary") - ) - ) - } else if (input$report_type == "bp") { - df <- data_bp() - if (is.null(df)) return(NULL) - players <- sort(unique(na.omit(df$Batter))) - if (length(players) <= 1) return(NULL) - tagList( - br(), - div(style="text-align:center;padding:10px;background:#f0f8f8;border-radius:6px;margin-top:10px;", - p(strong("Players Found: ", length(players)), - style="color:#006F71;margin:5px 0;"), - downloadButton("download_all_bp", "Download All BP 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_bp <- downloadHandler( - filename = function() { - df <- data_bp(); req(df) - date_str <- tryCatch(format(parse_game_day(df), "%Y%m%d"), - error = function(e) format(Sys.Date(), "%Y%m%d")) - paste0("BP_Reports_", date_str, ".zip") - }, - content = function(file) { - df <- data_bp(); req(df) - players <- sort(unique(na.omit(df$Batter))) - if (!length(players)) { - showNotification("No players found in BP data", type="error", duration=5) - return(NULL) - } - withProgress(message='Generating All BP 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), "_BP_Report.pdf")) - try(create_bp_pdf(df, ply, out), silent = TRUE) - if (file.exists(out)) pdfs <- c(pdfs, out) - } - if (!length(pdfs)) { - showNotification("Failed to generate BP reports", type="error", duration=5) - return(NULL) - } - zip::zip(zipfile=file, files=basename(pdfs), root=tmp); unlink(pdfs) - }) - showNotification("BP report ZIP ready!", type="message", duration=5) - }, - contentType = "application/zip" - ) - - output$download_bp_leaderboard <- downloadHandler( - filename = function() { - sel <- input$bp_leaderboard_date - date_str <- if (!is.null(sel) && sel != "All Dates") { - gsub("-", "", sel) - } else if (is.null(sel)) { - df <- data_bp(); req(df) - tryCatch(format(parse_game_day(df), "%Y%m%d"), - error = function(e) format(Sys.Date(), "%Y%m%d")) - } else "All_Dates" - paste0("BP_Leaderboard_", date_str, ".pdf") - }, - content = function(file) { - lb <- bp_leaderboard_data() - req(nrow(lb) > 0) - sel <- input$bp_leaderboard_date - date_label <- if (!is.null(sel) && sel != "All Dates") { - format(as.Date(sel), "%B %e, %Y") - } else if (is.null(sel)) { - df <- data_bp() - tryCatch(format(parse_game_day(df), "%B %e, %Y"), error = function(e) NULL) - } else "All Dates" - withProgress(message='Generating Leaderboard PDF', value=0.5, { - create_bp_leaderboard_pdf(lb, date_label, file) - }) - showNotification("Leaderboard PDF ready!", 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), - { - lev <- tryCatch(create_count_leverage_stats(pitcher_df, input$advanced_pitcher_name), - error = function(e) NULL) - if (!is.null(lev) && nrow(lev$overall) > 0) { - o <- lev$overall - fmt <- function(v) if (is.finite(v)) paste0(sprintf("%.1f", v), "%") else "-" - p(strong("Count Leverage: "), - "0-0 Zone ", fmt(o$`0-0 Zone%`[1]), " | ", - "Ahead Strike ", fmt(o$`Ahead Strike%`[1]), " | ", - "Kill ", fmt(o$`Kill%`[1])) - } else NULL - } - ) - } else if (input$report_type == "bullpen") { - df <- data_bullpen(); req(df, input$bullpen_pitcher_name) - pitcher_df <- df %>% filter(Pitcher == input$bullpen_pitcher_name) - if (!nrow(pitcher_df)) return(NULL) - - stats <- pitcher_df %>% - summarise( - pitches = n(), - avg_velo = round(mean(RelSpeed, na.rm = TRUE), 1), - max_velo = round(max(RelSpeed, na.rm = TRUE), 1), - pitch_types = n_distinct(TaggedPitchType[!is.na(TaggedPitchType) & TaggedPitchType != "Undefined"]) - ) - - intent <- if (!is.null(input$bullpen_intent)) input$bullpen_intent else "High" - - div(class = "status-box", - h4("\u2713 Ready to Generate Bullpen Report", style = "margin-top: 0; color: #006F71;"), - p(strong("Pitcher: "), input$bullpen_pitcher_name), - p(strong("Intent Level: "), intent), - p(strong("Total Pitches: "), stats$pitches), - p(strong("Pitch Types: "), stats$pitch_types), - p(strong("Avg Velo: "), stats$avg_velo, " mph | ", - strong("Max Velo: "), stats$max_velo, " mph")) - } else if (input$report_type == "catcher") { - df <- data_catcher(); req(df, input$catcher_name) - catcher_df <- df %>% filter(Catcher == input$catcher_name) - if (!nrow(catcher_df)) return(NULL) - game_date <- catcher_parse_game_day(catcher_df) - receiving_stats <- catcher_df %>% summarise(strikes_added=sum(StolenStrike,na.rm=TRUE), - strikes_lost=sum(StrikeLost,na.rm=TRUE)) - throwing_stats <- catcher_df %>% filter(Notes %in% c('2b out','2b safe','3b out','3b safe','2B out','2B safe','3B out','3B safe')) %>% summarise(throws=n()) - div(class = "status-box", - h4("✓ Ready to Generate Catcher Report", style = "margin-top: 0; color: #006F71;"), - p(strong("Catcher: "), input$catcher_name), - p(strong("Game Date: "), format(game_date, "%B %d, %Y")), - p(strong("Total Pitches: "), nrow(catcher_df)), - p(strong("Strikes Stolen: "), receiving_stats$strikes_added, " | ", - strong("Strikes Lost: "), receiving_stats$strikes_lost), - p(strong("Throws Recorded: "), throwing_stats$throws)) - } else if (input$report_type == "bp") { - df <- data_bp(); req(df, input$bp_player_name) - player_df <- df %>% filter(Batter == input$bp_player_name) - if (!nrow(player_df)) return(NULL) - - stats <- player_df %>% - summarise( - bbe = sum(BIPind, na.rm = TRUE), - avg_ev = round(mean(ExitSpeed[BIPind == 1], na.rm = TRUE), 1), - max_ev = round(max(ExitSpeed[BIPind == 1], na.rm = TRUE), 1) - ) - - div(class = "status-box", - h4("✓ Ready to Generate BP Report", style = "margin-top: 0; color: #006F71;"), - p(strong("Player: "), input$bp_player_name), - p(strong("Batted Ball Events: "), stats$bbe), - p(strong("Avg Exit Velo: "), stats$avg_ev, " mph"), - p(strong("Max Exit Velo: "), stats$max_ev, " mph")) - } - }) - - output$leaderboard_ui <- renderUI({ - df <- data_hitter() - if (is.null(df)) return(NULL) - - leaders <- calculate_leaderboards(df) - gi <- leaders$game_info - - make_column <- function(title, data, name_col, value_col, unit) { - if (nrow(data) == 0) { - return(div(class = "leaderboard-column", - div(class = "leaderboard-column-header", span(title), span(unit)), - div("No data available", style = "color: #999; padding: 10px;"))) - } - - rows <- lapply(seq_len(nrow(data)), function(i) { - logo_html <- if (nzchar(data$Logo[i])) { - tags$img(src = data$Logo[i], class = "leaderboard-logo", - onerror = "this.style.display='none'") - } else tags$span(style = "width: 28px; display: inline-block;") - - div(class = "leaderboard-row", logo_html, - span(class = "leaderboard-name", data[[name_col]][i]), - span(class = "leaderboard-value", round(data[[value_col]][i], 1))) - }) - div(class = "leaderboard-column", - div(class = "leaderboard-column-header", span(title), span(unit)), - rows) - } - - div(class = "leaderboard-section", - div(class = "game-info-bar", - div(class = "game-info-item", - span(class = "game-info-label", "Date"), - span(class = "game-info-value", gi$date)), - div(class = "game-info-item", - span(class = "game-info-label", "Stadium"), - span(class = "game-info-value", gi$stadium)), - div(class = "game-info-item", - span(class = "game-info-label", "Level"), - span(class = "game-info-value", gi$level)), - div(class = "game-info-item", - span(class = "game-info-label", "League"), - span(class = "game-info-value", gi$league)), - div(class = "game-info-item", - span(class = "game-info-label", "Final Score"), - span(class = "game-score", gi$final_score)) - ), - h3(class = "leaderboard-title", icon("trophy"), " Game Leaders"), - div(class = "leaderboard-grid", - make_column("Top Exit Velocity", leaders$exit_velo, "Batter", "MaxEV", "MPH"), - make_column("Top Distances", leaders$distance, "Batter", "MaxDist", "Ft."), - make_column("Top Pitch Velocity", leaders$pitch_velo, "Pitcher", "MaxVelo", "MPH"), - make_column("Swing & Misses", leaders$whiffs, "Pitcher", "Whiffs", "#"))) - }) - - output$preview_content <- renderUI({ - if (input$report_type == "hitter") { - df <- data_hitter() - if (is.null(df)) return(div(style = "text-align:center;padding:60px;color:#999;", h4("No data to preview"))) - req(input$player_name) - tagList( - h4("At-Bat Visualization", style = "color: #006F71;"), - div(class = "tall-plot", plotOutput("preview_plot_hitter", height = "460px")) - ) - - } else if (input$report_type == "pitcher") { - df <- data_pitcher() - if (is.null(df)) return(div(style="text-align:center;padding:60px;color:#999;", h4("No data to preview"))) - req(input$pitcher_name) - tagList( - h4("Pitch Movement", style="color:#006F71;"), - plotOutput("preview_movement", height="380px"), - br(), - h4("Pitch Locations", style="color:#006F71;"), - plotOutput("preview_location", height="380px"), - br(), - h4("Release Points", style="color:#006F71;"), - plotOutput("preview_release", height="380px") - ) - - } else if (input$report_type == "bullpen") { - df <- data_bullpen() - if (is.null(df)) return(div(style = "text-align:center;padding:60px;color:#999;", - h4("Upload a bullpen CSV to preview"))) - req(input$bullpen_pitcher_name) - tagList( - h4("Bullpen Movement Profile", style = "color: #006F71;"), - plotOutput("preview_bullpen_movement", height = "380px"), - br(), - h4("Bullpen Pitch Locations", style = "color: #006F71;"), - plotOutput("preview_bullpen_location", height = "380px") - ) - - } else if (input$report_type == "advanced_pitcher") { - df <- data_pitcher() - if (is.null(df)) return(div(style="text-align:center;padding:60px;color:#999;", h4("No data to preview"))) - req(input$advanced_pitcher_name) - tagList( - h4("Advanced Pitch Movement", style="color:#006F71;"), - plotOutput("preview_advanced_movement", height="380px"), - br(), - h4("Count Usage (Ahead/Behind)", style="color:#006F71;"), - plotOutput("preview_advanced_count", height="380px") - ) - - } else if (input$report_type == "tableau_pitcher") { - df <- data_pitcher() - if (is.null(df)) return(div(style = "text-align:center;padding:60px;color:#999;", h4("No data to preview"))) - req(input$tableau_pitcher_name) - tagList( - h4("Location Report Preview", style = "color:#006F71;"), - plotOutput("preview_tableau_location", height = "380px"), - br(), - h4("Movement Profile Preview", style = "color:#006F71;"), - plotOutput("preview_tableau_movement", height = "380px") - ) - - } else if (input$report_type == "catcher") { - df <- data_catcher() - if (is.null(df)) return(div(style="text-align:center;padding:60px;color:#999;", h4("No data to preview"))) - req(input$catcher_name) - tagList( - h4("Framing Visualization", style = "color: #006F71;"), - plotOutput("preview_framing", height = "350px"), - br(), - h4("Throwing Accuracy", style = "color: #006F71;"), - plotOutput("preview_throwing", height = "400px") - ) - - } else if (input$report_type == "bp") { - df <- data_bp() - if (is.null(df)) return(div(style = "text-align:center;padding:60px;color:#999;", - h4("No data to preview"))) - req(input$bp_player_name) - - bp_dates <- if ("Date" %in% names(df)) sort(unique(na.omit(as.Date(df$Date))), decreasing = TRUE) else as.Date(character(0)) - date_choices <- format(bp_dates, "%Y-%m-%d") - lb_title <- if (length(bp_dates) == 1) { - paste0("Daily BP Leaderboard \u2014 ", format(bp_dates, "%B %e, %Y")) - } else "Daily BP Leaderboard" - - tagList( - h4(lb_title, style = "color: #006F71;"), - if (length(date_choices) > 1) - selectInput("bp_leaderboard_date", "BP Date", - choices = c(date_choices, "All Dates"), - selected = date_choices[1], width = "220px"), - DT::dataTableOutput("bp_leaderboard"), - div(style = "margin-top:8px;", - downloadButton("download_bp_leaderboard", "Download Leaderboard PDF", class = "btn-secondary")), - br(), - 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) - - bp_leaderboard_data <- reactive({ - df <- data_bp(); req(df) - sel <- input$bp_leaderboard_date - if (!is.null(sel) && sel != "All Dates" && "Date" %in% names(df)) { - df <- df %>% filter(!is.na(Date) & format(as.Date(Date), "%Y-%m-%d") == sel) - } - calculate_bp_leaderboard(df) - }) - - output$bp_leaderboard <- DT::renderDataTable({ - lb <- bp_leaderboard_data() - # jsonlite (loaded after shiny) masks shiny::validate, so namespace explicitly - shiny::validate(shiny::need(nrow(lb) > 0, "No BP data for this date")) - - # Red = bad, white = group average, green = good - # BBE and Avg LA stay uncolored - volume and direction, not quality - stat_cols <- c("BBE", "Avg EV", "Avg LA", "Max EV", "10-30%", "HH%", "Barrel%") - color_cols <- setdiff(stat_cols, c("BBE", "Avg LA")) - col_colors <- lapply(color_cols, function(cn) bp_leaderboard_colors(lb[[cn]])) - names(col_colors) <- color_cols - - dt <- DT::datatable( - lb, rownames = FALSE, - options = list(pageLength = 50, dom = "t", ordering = TRUE, - columnDefs = list(list(className = "dt-center", - targets = seq_along(stat_cols)))), - class = "compact stripe hover" - ) %>% - DT::formatStyle("Batter", fontWeight = "bold") - - for (cn in color_cols) { - vals <- lb[[cn]] - uv <- unique(vals[!is.na(vals)]) - if (!length(uv)) next - uc <- col_colors[[cn]][match(uv, vals)] - dt <- dt %>% DT::formatStyle(cn, backgroundColor = DT::styleEqual(uv, uc)) - } - dt - }) - - output$preview_tableau_location <- renderPlot({ - df <- data_pitcher(); req(df, input$tableau_pitcher_name) - pitcher_df <- process_tableau_pitcher_data(df) %>% filter(Pitcher == input$tableau_pitcher_name) - create_tableau_location_plot(pitcher_df, tableau_pitch_colors) - }, res = 120) - - output$preview_tableau_movement <- renderPlot({ - df <- data_pitcher(); req(df, input$tableau_pitcher_name) - pitcher_df <- process_tableau_pitcher_data(df) %>% filter(Pitcher == input$tableau_pitcher_name) - create_tableau_movement_plot(pitcher_df, tableau_pitch_colors) - }, res = 120) - - output$preview_bp_zone <- renderPlot({ - df <- data_bp(); req(df, input$bp_player_name) - create_bp_zone_plot(input$bp_player_name, df) - }, res = 96) - - output$preview_release <- renderPlot({ - df <- data_pitcher(); req(df, input$pitcher_name) - pitcher_df <- df %>% filter(Pitcher == input$pitcher_name) - pitch_colors <- c("Fastball"="#FA8072","Four-Seam"="#FA8072","FourSeamFastBall" = "#FA8072","Sinker"="#fdae61","Slider"="#A020F0","Sweeper"="magenta","Curveball"="#2c7bb6","ChangeUp"="#90EE90","Splitter"="#90EE32","Cutter"="red") - create_pitcher_release_plot(pitcher_df, pitch_colors) - }, res=120) - - output$preview_framing <- renderPlot({ - df <- data_catcher(); req(df, input$catcher_name) - catcher_create_framing_plot(df, input$catcher_name) - }, res = 96) - - output$preview_throwing <- renderPlot({ - df <- data_catcher(); req(df, input$catcher_name) - catcher_create_throwing_plot(df, input$catcher_name) - }, res = 96) - - output$preview_advanced_movement <- renderPlot({ - df <- data_pitcher() - req(df, input$advanced_pitcher_name) - pitcher_df <- df %>% filter(Pitcher == input$advanced_pitcher_name) - pitch_colors <- c("Fastball"="#FA8072","Four-Seam"="#FA8072","FourSeamFastBall" = "#FA8072","Sinker"="#fdae61", - "Slider"="#A020F0","Sweeper"="magenta","Curveball"="#2c7bb6", - "ChangeUp"="#90EE90","Splitter"="#90EE32","Cutter"="red") - create_movement_plot(pitcher_df, input$advanced_pitcher_name, pitch_colors) - }, res=120) - - output$preview_advanced_count <- renderPlot({ - df <- data_pitcher() - req(df, input$advanced_pitcher_name) - pitcher_df <- df %>% filter(Pitcher == input$advanced_pitcher_name) - pitch_colors <- c("Fastball"="#FA8072","Four-Seam"="#FA8072","FourSeamFastBall" = "#FA8072","Sinker"="#fdae61", - "Slider"="#A020F0","Sweeper"="magenta","Curveball"="#2c7bb6", - "ChangeUp"="#90EE90","Splitter"="#90EE32","Cutter"="red") - create_count_usage_plot(pitcher_df, input$advanced_pitcher_name, pitch_colors) - }, res=120) - - output$preview_bullpen_movement <- renderPlot({ - df <- data_bullpen(); req(df, input$bullpen_pitcher_name) - pitcher_df <- df %>% filter(Pitcher == input$bullpen_pitcher_name) - bp_pitch_colors <- c( - "Fastball" = "#3465cb", "Four-Seam" = "#3465cb", "FourSeamFastBall" = "#3465cb", - "4-Seam Fastball" = "#3465cb", "FF" = "#3465cb", - "Sinker" = "#e5e501", "TwoSeamFastBall" = "#e5e501", "Two-Seam" = "#e5e501", - "2-Seam Fastball" = "#e5e501", "SI" = "#e5e501", - "Slider" = "#65aa02", "SL" = "#65aa02", - "Sweeper" = "#dc4476", "SW" = "#dc4476", - "Curveball" = "#d73813", "CB" = "#d73813", "Knuckle Curve" = "#d73813", "KC" = "#d73813", - "ChangeUp" = "#980099", "Changeup" = "#980099", "CH" = "#980099", - "Splitter" = "#23a999", "FS" = "#23a999", "SP" = "#23a999", - "Cutter" = "#ff9903", "FC" = "#ff9903", - "Slurve" = "#9370DB", "Other" = "gray50" - ) - create_bullpen_movement_plot(pitcher_df, input$bullpen_pitcher_name, bp_pitch_colors) - }, res = 120) - - output$preview_bullpen_location <- renderPlot({ - df <- data_bullpen(); req(df, input$bullpen_pitcher_name) - pitcher_df <- df %>% filter(Pitcher == input$bullpen_pitcher_name) - bp_pitch_colors <- c( - "Fastball" = "#3465cb", "Four-Seam" = "#3465cb", "FourSeamFastBall" = "#3465cb", - "4-Seam Fastball" = "#3465cb", "FF" = "#3465cb", - "Sinker" = "#e5e501", "TwoSeamFastBall" = "#e5e501", "Two-Seam" = "#e5e501", - "2-Seam Fastball" = "#e5e501", "SI" = "#e5e501", - "Slider" = "#65aa02", "SL" = "#65aa02", - "Sweeper" = "#dc4476", "SW" = "#dc4476", - "Curveball" = "#d73813", "CB" = "#d73813", "Knuckle Curve" = "#d73813", "KC" = "#d73813", - "ChangeUp" = "#980099", "Changeup" = "#980099", "CH" = "#980099", - "Splitter" = "#23a999", "FS" = "#23a999", "SP" = "#23a999", - "Cutter" = "#ff9903", "FC" = "#ff9903", - "Slurve" = "#9370DB", "Other" = "gray50" - ) - create_bullpen_location_plot(pitcher_df, bp_pitch_colors) - }, res = 120) - - output$download_bullpen <- downloadHandler( - filename = function() { - df <- data_bullpen(); req(df, input$bullpen_pitcher_name) - pitcher_clean <- gsub(" ", "_", input$bullpen_pitcher_name) - date_str <- tryCatch(format(unique(df$Date)[1], "%Y%m%d"), error = function(e) "undated") - paste0(pitcher_clean, "_", date_str, "_Bullpen_Report.pdf") - }, - content = function(file) { - df <- data_bullpen(); req(df, input$bullpen_pitcher_name) - intent <- if (!is.null(input$bullpen_intent)) input$bullpen_intent else "High" - withProgress(message = 'Generating Bullpen PDF', value = 0, { - incProgress(.3, detail = "Processing data...") - incProgress(.4, detail = "Creating visualizations...") - create_bullpen_pdf_report(df, input$bullpen_pitcher_name, file, intent) - incProgress(.3, detail = "Finalizing report...") - }) - showNotification("Bullpen report generated!", type = "message", duration = 3) - }, - contentType = "application/pdf" - ) - - output$download_all_bullpen <- downloadHandler( - filename = function() { - df <- data_bullpen(); req(df) - date_str <- tryCatch(format(unique(df$Date)[1], "%Y%m%d"), error = function(e) "undated") - paste0("Bullpen_Reports_", date_str, ".zip") - }, - content = function(file) { - df <- data_bullpen(); req(df) - intent <- if (!is.null(input$bullpen_intent)) input$bullpen_intent else "High" - pitchers <- sort(unique(na.omit(df$Pitcher))) - if (!length(pitchers)) { - showNotification("No pitchers found", type = "error", duration = 5) - return(NULL) - } - withProgress(message = 'Generating Bullpen Reports', value = 0, { - tmp <- tempdir(); pdfs <- character(0); total <- length(pitchers) - for (i in seq_along(pitchers)) { - ply <- pitchers[i]; incProgress(1/total, detail = paste("Report for", ply)) - date_str <- tryCatch(format(unique(df$Date)[1], "%Y%m%d"), error = function(e) "undated") - out <- file.path(tmp, paste0(gsub(" ", "_", ply), "_", date_str, "_Bullpen_Report.pdf")) - try(create_bullpen_pdf_report(df, ply, out, intent), silent = TRUE) - if (file.exists(out)) pdfs <- c(pdfs, out) - } - if (!length(pdfs)) { - showNotification("Failed to generate reports", type = "error", duration = 5) - return(NULL) - } - zip::zip(zipfile = file, files = basename(pdfs), root = tmp); unlink(pdfs) - }) - showNotification("Bullpen reports ZIP ready!", type = "message", duration = 5) - }, - contentType = "application/zip" - ) - - output$download_hitter <- downloadHandler( - filename = function() { - df <- data_hitter(); req(df, input$player_name) - player_clean <- gsub(" ", "_", input$player_name) - date_str <- format(parse_game_day(df %>% filter(Batter == input$player_name)), "%Y%m%d") - paste0(player_clean, "_", date_str, "_Report.pdf") - }, - content = function(file) { - df <- data_hitter(); req(df, input$player_name) - withProgress(message='Generating Hitter PDF', value=0, { - incProgress(.3, detail="Processing data...") - incProgress(.4, detail="Creating visualizations...") - create_postgame_pdf(df, input$player_name, file, bio_hitter()) - incProgress(.3, detail="Finalizing report...") - }) - showNotification("Hitter report generated!", type="message", duration=3) - }, - contentType = "application/pdf" - ) - - output$download_catcher <- downloadHandler( - filename = function() { - df <- data_catcher(); req(df, input$catcher_name) - catcher_clean <- gsub(" ", "_", input$catcher_name) - date_str <- format(catcher_parse_game_day(df %>% filter(Catcher == input$catcher_name)), "%Y%m%d") - paste0(catcher_clean, "_", date_str, "_Catcher_Report.pdf") - }, - content = function(file) { - df <- data_catcher(); req(df, input$catcher_name) - withProgress(message='Generating Catcher PDF', value=0, { - incProgress(.4, detail="Building visualizations...") - catcher_create_catcher_pdf(df, input$catcher_name, file, bio_catch()) - incProgress(.6, detail="Finalizing...") - }) - showNotification("Catcher report generated!", type="message", duration=3) - }, - contentType = "application/pdf" - ) - - output$download_umpire <- downloadHandler( - filename = function() { - df <- data_umpire() - req(df) - date_str <- format(max(df$Date, na.rm = TRUE), "%Y%m%d") - ump_name <- if (!is.null(input$umpire_name) && nzchar(input$umpire_name)) { - paste0(gsub(" ", "_", input$umpire_name), "_") - } else "" - paste0(ump_name, "Umpire_Report_", date_str, ".pdf") - }, - content = function(file) { - df <- data_umpire() - req(df) - withProgress(message = 'Generating Umpire PDF', value = 0, { - incProgress(.5, detail = "Creating visualizations...") - umpire_create_report_pdf(df, file, umpire_name = input$umpire_name) - incProgress(.5, detail = "Finalizing report...") - }) - showNotification("Umpire report generated!", type = "message", duration = 3) - }, - contentType = "application/pdf" - ) - - output$download_advanced_pitcher <- downloadHandler( - filename = function() { - df <- data_pitcher() - req(df, input$advanced_pitcher_name) - pitcher_clean <- gsub(" ", "_", input$advanced_pitcher_name) - date_str <- format(parse_game_day(df %>% filter(Pitcher == input$advanced_pitcher_name)), "%Y%m%d") - paste0(pitcher_clean, "_", date_str, "_Advanced_Pitcher_Report.pdf") - }, - content = function(file) { - df <- data_pitcher() - req(df, input$advanced_pitcher_name) - withProgress(message='Generating Advanced Pitcher PDF', value=0, { - incProgress(.3, detail="Processing data with Stuff+ model...") - incProgress(.4, detail="Creating advanced visualizations...") - create_advanced_pitcher_pdf(df, input$advanced_pitcher_name, file) - incProgress(.3, detail="Finalizing report...") - }) - showNotification("Advanced Pitcher report generated!", type="message", duration=3) - }, - contentType = "application/pdf" - ) - - output$download_all_coastal_advanced_pitchers <- downloadHandler( - filename = function() { - df <- data_pitcher() - req(df) - paste0("Coastal_Advanced_Pitcher_Reports_", format(parse_game_day(df), "%Y%m%d"), ".zip") - }, - content = function(file) { - df <- data_pitcher() - req(df) - pitchers <- df %>% filter(PitcherTeam == "COA_CHA") %>% pull(Pitcher) %>% unique() %>% na.omit() %>% sort() - if (!length(pitchers)) { - showNotification("No Coastal pitchers found", type="error", duration=5) - return(NULL) - } - withProgress(message='Generating Advanced Pitcher Reports', value=0, { - tmp <- tempdir() - pdfs <- character(0) - total <- length(pitchers) - for (i in seq_along(pitchers)) { - ply <- pitchers[i] - incProgress(1/total, detail=paste("Advanced report for", ply)) - out <- file.path(tmp, paste0(gsub(" ","_",ply), "_", - format(parse_game_day(df), "%Y%m%d"), - "_Advanced_Pitcher_Report.pdf")) - try(create_advanced_pitcher_pdf(df, ply, out), silent = TRUE) - if (file.exists(out)) pdfs <- c(pdfs, out) - } - if (!length(pdfs)) { - showNotification("Failed to generate reports", type="error", duration=5) - return(NULL) - } - zip::zip(zipfile=file, files=basename(pdfs), root=tmp) - unlink(pdfs) - }) - showNotification("Advanced Pitcher ZIP ready!", type="message", duration=5) - }, - contentType = "application/zip" - ) - - output$download_tableau_pitcher <- downloadHandler( - filename = function() { - df <- data_pitcher(); req(df, input$tableau_pitcher_name) - pitcher_clean <- gsub(" ", "_", input$tableau_pitcher_name) - date_str <- format(parse_game_day(df %>% filter(Pitcher == input$tableau_pitcher_name)), "%Y%m%d") - paste0(pitcher_clean, "_", date_str, "_Tableau_Report.pdf") - }, - content = function(file) { - df <- data_pitcher(); req(df, input$tableau_pitcher_name) - withProgress(message = 'Generating Tableau Pitcher PDF', value = 0, { - incProgress(.5, detail = "Creating visualizations...") - create_tableau_pitcher_pdf(df, input$tableau_pitcher_name, file) - incProgress(.5, detail = "Finalizing...") - }) - showNotification("Tableau Pitcher report generated!", type = "message", duration = 3) - }, - contentType = "application/pdf" - ) - - output$download_all_coastal_hitters <- downloadHandler( - filename = function() { - df <- data_hitter(); req(df) - date <- parse_game_day(df) - paste0("Coastal_Carolina_Hitter_Reports_", format(date, "%Y%m%d"), ".zip") - }, - content = function(file) { - df <- data_hitter(); req(df) - players <- df %>% filter(BatterTeam=="COA_CHA") %>% pull(Batter) %>% unique() %>% na.omit() %>% sort() - if (!length(players)) { showNotification("No Coastal Carolina players found", type="error", duration=5); return(NULL) } - withProgress(message='Generating Coastal Hitter Reports', value=0, { - tmp <- tempdir(); pdfs <- character(0); total <- length(players) - for (i in seq_along(players)) { - ply <- players[i]; incProgress(1/total, detail=paste("Report for", ply)) - out <- file.path(tmp, paste0(gsub(" ","_",ply), "_", format(parse_game_day(df), "%Y%m%d"), "_Report.pdf")) - try(create_postgame_pdf(df, ply, out, bio_hitter()), silent = TRUE) - if (file.exists(out)) pdfs <- c(pdfs, out) - } - if (!length(pdfs)) { showNotification("Failed to generate any hitter reports", type="error", duration=5); return(NULL) } - zip::zip(zipfile=file, files=basename(pdfs), root=tmp); unlink(pdfs) - }) - showNotification("Coastal hitter ZIP ready!", type="message", duration=5) - }, - contentType = "application/zip" - ) - - output$download_all_coastal_tableau_pitchers <- downloadHandler( - filename = function() { - df <- data_pitcher(); req(df) - paste0("Coastal_Tableau_Pitcher_Reports_", format(parse_game_day(df), "%Y%m%d"), ".zip") - }, - content = function(file) { - df <- data_pitcher(); req(df) - pitchers <- df %>% filter(PitcherTeam == "COA_CHA") %>% pull(Pitcher) %>% unique() %>% na.omit() %>% sort() - if (!length(pitchers)) { - showNotification("No Coastal pitchers found", type="error", duration=5) - return(NULL) - } - withProgress(message='Generating Coastal Tableau Pitcher Reports', value=0, { - tmp <- tempdir(); pdfs <- character(0); total <- length(pitchers) - for (i in seq_along(pitchers)) { - ply <- pitchers[i]; incProgress(1/total, detail=paste("Report for", ply)) - out <- file.path(tmp, paste0(gsub(" ","_",ply), "_", - format(parse_game_day(df), "%Y%m%d"), - "_Tableau_Report.pdf")) - try(create_tableau_pitcher_pdf(df, ply, out), silent = TRUE) - if (file.exists(out)) pdfs <- c(pdfs, out) - } - if (!length(pdfs)) { - showNotification("Failed to generate reports", type="error", duration=5) - return(NULL) - } - zip::zip(zipfile=file, files=basename(pdfs), root=tmp); unlink(pdfs) - }) - showNotification("Coastal Tableau pitcher ZIP ready!", type="message", duration=5) - }, - contentType = "application/zip" - ) - - output$download_all_ccu_catchers <- downloadHandler( - filename = function() { - df <- data_catcher(); req(df) - date <- catcher_parse_game_day(df) - paste0("CCU_Catcher_Reports_", format(date, "%Y%m%d"), ".zip") - }, - content = function(file) { - df <- data_catcher(); req(df) - ccu_catchers <- df %>% filter(CatcherTeam=="COA_CHA") %>% pull(Catcher) %>% unique() %>% na.omit() %>% sort() - if (!length(ccu_catchers)) { showNotification("No CCU catchers found", type="error", duration=5); return(NULL) } - withProgress(message='Generating CCU Catcher Reports', value=0, { - tmp <- tempdir(); pdfs <- character(0); total <- length(ccu_catchers) - for (i in seq_along(ccu_catchers)) { - ct <- ccu_catchers[i]; incProgress(1/total, detail=paste("Report for", ct)) - out <- file.path(tmp, paste0(gsub(" ","_",ct), "_", format(catcher_parse_game_day(df), "%Y%m%d"), "_Catcher_Report.pdf")) - try(catcher_create_catcher_pdf(df, ct, out, bio_catch()), silent = TRUE) - if (file.exists(out)) pdfs <- c(pdfs, out) - } - if (!length(pdfs)) { showNotification("Failed to generate any catcher reports", type="error", duration=5); return(NULL) } - zip::zip(zipfile=file, files=basename(pdfs), root=tmp); unlink(pdfs) - }) - showNotification("CCU catcher ZIP ready!", type="message", duration=5) - }, - contentType = "application/zip" - ) - - - - # ===== ported data-processing / scraping / retag server logic ===== - uploaded_file_type <- reactiveVal("csv") - - # Reactive values - processed_data <- reactiveVal(NULL) - plot_data <- reactiveVal(NULL) - selected_pitch <- reactiveVal(NULL) - selected_points <- reactiveVal(NULL) - csv_data_raw <- reactiveVal(NULL) - bat_tracking_parsed <- reactiveVal(NULL) - merge_result <- reactiveVal(NULL) - scraped_data <- reactiveVal(NULL) - scrape_polling <- reactiveVal(FALSE) - scrape_status_msg <- reactiveVal("Ready.") - - # Catcher Notes: stored as a list of data frames, each row is one note - catcher_notes_list <- reactiveVal(data.frame( - NoteID = integer(0), - Catcher = character(0), - Batter = character(0), - Inning = integer(0), - Balls = integer(0), - Strikes = integer(0), - Result = character(0), - MatchedRow = integer(0), - stringsAsFactors = FALSE - )) - - # Handle column selection buttons - observeEvent(input$select_all_cols, { - updateCheckboxGroupInput(session, "columns_to_remove", - selected = columns_to_remove) - }) - - observeEvent(input$deselect_all_cols, { - updateCheckboxGroupInput(session, "columns_to_remove", selected = character(0)) - }) - - observeEvent(input$select_spinaxis, { - spinaxis_cols <- columns_to_remove[grepl("SpinAxis3d", columns_to_remove)] - updateCheckboxGroupInput(session, "columns_to_remove", selected = spinaxis_cols) - }) - - # Shared helper: process raw data into processed_data / plot_data - run_processing <- function(df) { - # Merge bat tracking if available - if (!is.null(bat_tracking_parsed()) && !is.null(bat_tracking_parsed()$data)) { - result <- merge_with_bat_tracking(df, bat_tracking_parsed()$data) - merge_result(result) - df <- result$data - } - - selected_cols_to_remove <- input$columns_to_remove %||% character(0) - processed_df <- df - - if (length(selected_cols_to_remove) > 0) { - columns_to_drop <- intersect(names(df), selected_cols_to_remove) - if (length(columns_to_drop) > 0) { - processed_df <- processed_df %>% select(-all_of(columns_to_drop)) - } - } - - processed_df <- processed_df %>% distinct() - - processed_data(processed_df) - plot_data(processed_df) - - # Update catcher notes dropdowns - update_catcher_note_choices(processed_df) - - return(processed_df) - } - - # Helper to populate Catcher Notes dropdowns from current data - update_catcher_note_choices <- function(df) { - if (!is.null(df)) { - if ("Catcher" %in% names(df)) { - catchers <- sort(unique(df$Catcher[!is.na(df$Catcher) & df$Catcher != ""])) - updateSelectInput(session, "cn_catcher", choices = catchers, - selected = if (length(catchers) > 0) catchers[1] else NULL) - } - if ("Batter" %in% names(df)) { - batters <- sort(unique(df$Batter[!is.na(df$Batter) & df$Batter != ""])) - updateSelectInput(session, "cn_batter", choices = batters, - selected = if (length(batters) > 0) batters[1] else NULL) - } - } - } - - # Re-process data when date format changes - observeEvent(input$date_format, { - req(input$file) - - tryCatch({ - df <- read_uploaded_file(input$file$datapath, input$file$name, - input$header, input$sep, input$quote) - - df <- convert_date_columns(df, input$date_format) - csv_data_raw(df) - run_processing(df) - - showNotification( - paste("Date format updated to:", if (input$date_format == "mdyy") "M/D/YY" else "YYYY-MM-DD"), - type = "message", duration = 3 - ) - - }, error = function(e) { - showNotification(paste("Error updating date format:", e$message), type = "error") - }) - }, ignoreInit = TRUE) - - # Process uploaded file (CSV or Parquet) - observeEvent(input$file, { - req(input$file) - - tryCatch({ - ext <- tolower(tools::file_ext(input$file$name)) - uploaded_file_type(ext) - - df <- read_uploaded_file(input$file$datapath, input$file$name, - input$header, input$sep, input$quote) - - df <- convert_date_columns(df, input$date_format) - csv_data_raw(df) - - processed_df <- run_processing(df) - - # Update pitcher choices - if ("Pitcher" %in% names(processed_df)) { - pitcher_choices <- sort(unique(processed_df$Pitcher[!is.na(processed_df$Pitcher)])) - updateSelectInput(session, "pitcher_select", choices = pitcher_choices, selected = pitcher_choices[1]) - } - - # Auto-populate download filename from uploaded file - base_name <- tools::file_path_sans_ext(input$file$name) - updateTextInput(session, "download_filename", value = paste0(base_name, "_processed")) - - format_label <- if (ext == "parquet") "Parquet" else "CSV" - showNotification( - paste0(format_label, " loaded: ", nrow(df), " rows x ", ncol(df), " columns"), - type = "message", duration = 3 - ) - - }, error = function(e) { - showNotification(paste("Error processing file:", e$message), type = "error") - }) - }) - - # Process uploaded JSON file - observeEvent(input$json_file, { - req(input$json_file) - - tryCatch({ - parsed <- parse_bat_tracking_json(input$json_file$datapath) - bat_tracking_parsed(parsed) - - if (!is.null(csv_data_raw()) && parsed$success && !is.null(parsed$data)) { - result <- merge_with_bat_tracking(csv_data_raw(), parsed$data) - merge_result(result) - - df <- result$data - selected_cols_to_remove <- input$columns_to_remove %||% character(0) - - if (length(selected_cols_to_remove) > 0) { - columns_to_drop <- intersect(names(df), selected_cols_to_remove) - if (length(columns_to_drop) > 0) { - df <- df %>% select(-all_of(columns_to_drop)) - } - } - - df <- df %>% distinct() - - processed_data(df) - plot_data(df) - - showNotification(result$message, type = "message", duration = 5) - } - - }, error = function(e) { - showNotification(paste("Error processing JSON:", e$message), type = "error") - }) - }) - - # CSV/Parquet status output - output$csv_status <- renderText({ - if (is.null(input$file)) { - return("No file uploaded yet. Accepts CSV or Parquet (max 5,000 rows).") - } - - if (is.null(csv_data_raw())) { - return("Processing file...") - } - - df <- csv_data_raw() - ext <- uploaded_file_type() - format_label <- if (ext == "parquet") "Parquet" else "CSV" - game_id <- if ("GameID" %in% names(df)) unique(df$GameID)[1] else "Unknown" - date_fmt <- if (input$date_format == "mdyy") "M/D/YY" else "YYYY-MM-DD" - - paste( - paste0("\u2713 ", format_label, " loaded successfully!"), - paste(" Game ID:", game_id), - paste(" Rows:", nrow(df)), - paste(" Columns:", ncol(df)), - paste("\u2713 Date format:", date_fmt), - sep = "\n" - ) - }) - - # JSON status output - output$json_status <- renderText({ - if (is.null(input$json_file)) { - return("No JSON file uploaded yet.") - } - - parsed <- bat_tracking_parsed() - if (is.null(parsed)) { - return("Processing JSON...") - } - - if (!parsed$success) { - return(paste("\u2717", parsed$message)) - } - - paste( - "\u2713 JSON parsed successfully!", - paste(" Game Reference:", parsed$game_reference), - paste(" Plays found:", parsed$plays_count %||% 0), - sep = "\n" - ) - }) - - # Merge status UI - output$merge_status_ui <- renderUI({ - result <- merge_result() - parsed <- bat_tracking_parsed() - csv <- csv_data_raw() - - if (is.null(parsed) || is.null(csv)) { - return(NULL) - } - - if (!parsed$success) { - return(div(class = "merge-status-box merge-error", - h4("Merge Status", style = "margin-top: 0; color: #721c24;"), - p(parsed$message) - )) - } - - if (is.null(parsed$data) || is.null(result)) { - csv_game <- if ("GameID" %in% names(csv)) unique(csv$GameID)[1] else NULL - json_game <- parsed$game_reference - - if (!is.null(csv_game) && !is.null(json_game) && csv_game != json_game) { - return(div(class = "merge-status-box merge-warning", - h4("\u26A0 Game ID Mismatch", style = "margin-top: 0; color: #856404;"), - p(paste("CSV Game:", csv_game)), - p(paste("JSON Game:", json_game)), - p("Files may be from different games!") - )) - } - - return(div(class = "merge-status-box merge-warning", - h4("No Data to Merge", style = "margin-top: 0; color: #856404;"), - p(parsed$message) - )) - } - - csv_game <- if ("GameID" %in% names(csv)) unique(csv$GameID)[1] else NULL - json_game <- parsed$game_reference - game_match <- is.null(csv_game) || is.null(json_game) || csv_game == json_game - - if (result$matched > 0) { - div(class = "merge-status-box merge-success", - h4("\u2713 Merge Successful!", style = "margin-top: 0; color: #155724;"), - p(paste("Matched:", result$matched, "of", result$total_bat, "bat tracking records")), - if (!game_match) p(style = "color: #856404;", "\u26A0 Note: Game IDs differ but PitchUIDs matched") - ) - } else { - div(class = "merge-status-box merge-warning", - h4("\u26A0 No Matches Found", style = "margin-top: 0; color: #856404;"), - p(paste("0 of", result$total_bat, "bat tracking records matched")), - if (!game_match) p(paste("Game ID mismatch: CSV =", csv_game, ", JSON =", json_game)) - ) - } - }) - - # Bat tracking details - output$bat_tracking_details <- renderUI({ - parsed <- bat_tracking_parsed() - result <- merge_result() - - if (is.null(parsed)) { - return(div( - p("No bat tracking JSON file uploaded."), - p("Upload a _battracking.json file in the 'Upload & Process' tab to see bat tracking data here.") - )) - } - - if (!parsed$success) { - return(div(class = "alert alert-danger", parsed$message)) - } - - if (is.null(parsed$data)) { - return(div(class = "alert alert-warning", - h4("Empty Bat Tracking File"), - p(parsed$message), - p("The JSON file was valid but contained no swing data in the Plays array.") - )) - } - - div( - div(class = "row", - div(class = "col-md-4", - div(class = "well", - h4("Game Reference"), - p(parsed$game_reference) - ) - ), - div(class = "col-md-4", - div(class = "well", - h4("Total Swings Tracked"), - p(style = "font-size: 24px; font-weight: bold; color: darkcyan;", parsed$plays_count) - ) - ), - div(class = "col-md-4", - div(class = "well", - h4("Matched to CSV"), - p(style = "font-size: 24px; font-weight: bold; color: #28a745;", - if (!is.null(result)) result$matched else "N/A") - ) - ) - ) - ) - }) - - # Bat tracking table - output$bat_tracking_table <- DT::renderDataTable({ - df <- processed_data() - - if (is.null(df)) { - return(NULL) - } - - if ("BatSpeed_Sensor" %in% names(df)) { - bat_rows <- df %>% - filter(!is.na(BatSpeed_Sensor)) %>% - select( - any_of(c("PitchNo", "Time", "Pitcher", "Batter", "TaggedPitchType", "PitchCall", - "RelSpeed", "ExitSpeed", "Angle", - "BatSpeed", "BatSpeed_Sensor", - "VerticalAttackAngle", "VerticalAttackAngle_Sensor", - "HorizontalAttackAngle", "HorizontalAttackAngle_Sensor")) - ) - - if (nrow(bat_rows) == 0) { - return(NULL) - } - - DT::datatable(bat_rows, - options = list(scrollX = TRUE, pageLength = 10), - rownames = FALSE) %>% - DT::formatRound(columns = intersect(names(bat_rows), - c("BatSpeed_Sensor", "VerticalAttackAngle_Sensor", - "HorizontalAttackAngle_Sensor", "RelSpeed", - "ExitSpeed", "Angle")), - digits = 1) - } else { - return(NULL) - } - }) - - # Processing summary - output$process_summary <- renderText({ - if (is.null(input$file)) { - return("No file uploaded yet.") - } - - if (is.null(processed_data())) { - return("Processing...") - } - - df <- processed_data() - original_df <- csv_data_raw() - selected_cols_to_remove <- input$columns_to_remove %||% character(0) - removed_cols <- intersect(selected_cols_to_remove, names(original_df)) - result <- merge_result() - ext <- uploaded_file_type() - format_label <- if (ext == "parquet") "Parquet" else "CSV" - - removed_cols_text <- if (length(removed_cols) > 0) { - paste("\u2713 Removed columns:", length(removed_cols)) - } else { - "\u2713 Removed columns: 0" - } - - bat_tracking_text <- if (!is.null(result) && result$matched > 0) { - paste("\u2713 Bat tracking merged:", result$matched, "pitches") - } else if (!is.null(bat_tracking_parsed())) { - "\u26A0 Bat tracking: No matches" - } else { - "\u25CB Bat tracking: Not uploaded" - } - - notes <- catcher_notes_list() - notes_text <- if (nrow(notes) > 0) { - paste("\u2713 Catcher notes:", nrow(notes), "logged") - } else { - "\u25CB Catcher notes: None" - } - - summary_text <- paste( - paste0("\u2713 ", format_label, " file processed successfully!"), - paste("\u2713 Original columns:", ncol(original_df)), - paste("\u2713 Final columns:", ncol(df)), - paste("\u2713 Rows processed:", nrow(df)), - removed_cols_text, - bat_tracking_text, - notes_text, - "\u2713 Duplicates removed", - paste("\u2713 Date format:", if (input$date_format == "mdyy") "M/D/YY" else "YYYY-MM-DD"), - sep = "\n" - ) - - return(summary_text) - }) - - # Preview table - output$preview <- DT::renderDataTable({ - req(processed_data()) - - DT::datatable(processed_data(), - options = list(scrollX = TRUE, pageLength = 10), - filter = "top") - }) - - # Movement plot - output$movement_plot <- renderPlot({ - req(plot_data(), input$pitcher_select) - - pitcher_data <- plot_data() %>% - filter(Pitcher == input$pitcher_select) %>% - filter(!is.na(TaggedPitchType), TaggedPitchType != "Other", - !is.na(HorzBreak), !is.na(InducedVertBreak), !is.na(RelSpeed)) %>% - mutate(pitch_id = row_number()) - - if (nrow(pitcher_data) == 0) { - plot.new() - text(0.5, 0.5, "No data available for selected pitcher", cex = 1.5) - return() - } - - pitcher_data$color <- pitch_colors[pitcher_data$TaggedPitchType] - pitcher_data$color[is.na(pitcher_data$color)] <- "#D3D3D3" - - par(mar = c(5, 5, 4, 8), xpd = TRUE) - plot(pitcher_data$HorzBreak, pitcher_data$InducedVertBreak, - col = pitcher_data$color, - pch = 19, cex = 1.5, - xlim = c(-25, 25), ylim = c(-25, 25), - xlab = "Horizontal Break (inches)", - ylab = "Induced Vertical Break (inches)", - main = paste("Pitch Movement Chart -", input$pitcher_select)) - - grid(nx = NULL, ny = NULL, col = "lightgray", lty = 1, lwd = 0.5) - abline(h = 0, col = "gray", lty = 2, lwd = 1) - abline(v = 0, col = "gray", lty = 2, lwd = 1) - - for (r in c(6, 12, 18, 24)) { - circle_x <- r * cos(seq(0, 2*pi, length.out = 100)) - circle_y <- r * sin(seq(0, 2*pi, length.out = 100)) - lines(circle_x, circle_y, col = "lightgray", lty = 3) - } - - if (input$selection_mode == "drag" && !is.null(selected_points())) { - sel_points <- selected_points() - points(sel_points$HorzBreak, sel_points$InducedVertBreak, - pch = 21, cex = 2, col = "red", lwd = 3) - } - - unique_pitches <- unique(pitcher_data$TaggedPitchType) - unique_colors <- pitch_colors[unique_pitches] - legend("topright", inset = c(-0.15, 0), - legend = unique_pitches, - col = unique_colors, - pch = 19, - cex = 0.8, - title = "Pitch Type") - }) - - # Handle plot clicks (single mode only) - observeEvent(input$plot_click, { - req(plot_data(), input$pitcher_select, input$plot_click) - - if (input$selection_mode != "single") return() - - pitcher_data <- plot_data() %>% - filter(Pitcher == input$pitcher_select) %>% - filter(!is.na(TaggedPitchType), TaggedPitchType != "Other", - !is.na(HorzBreak), !is.na(InducedVertBreak), !is.na(RelSpeed)) %>% - mutate(pitch_id = row_number()) - - if (nrow(pitcher_data) == 0) return() - - click_x <- input$plot_click$x - click_y <- input$plot_click$y - - distances <- sqrt((pitcher_data$HorzBreak - click_x)^2 + - (pitcher_data$InducedVertBreak - click_y)^2) - - closest_idx <- which.min(distances) - - if (min(distances) <= 2) { - clicked_pitch <- pitcher_data[closest_idx, ] - - full_data <- plot_data() %>% filter(Pitcher == input$pitcher_select) - original_row <- which(full_data$HorzBreak == clicked_pitch$HorzBreak & - full_data$InducedVertBreak == clicked_pitch$InducedVertBreak & - full_data$RelSpeed == clicked_pitch$RelSpeed)[1] - - selected_pitch(list( - pitcher = input$pitcher_select, - row_in_pitcher_data = original_row, - data = clicked_pitch, - original_type = clicked_pitch$TaggedPitchType - )) - - updateSelectInput(session, "modal_new_pitch_type", - selected = clicked_pitch$TaggedPitchType) - - showModal(modalDialog( - title = "Edit Pitch Type", - div(style = "padding: 20px;", - h4("Selected Pitch Details:", style = "color: darkcyan;"), - verbatimTextOutput("selected_pitch_info"), - br(), - selectInput("modal_new_pitch_type", "Change Pitch Type To:", - choices = c("Fastball", "Sinker", "Cutter", "Slider", - "Curveball", "ChangeUp", "Splitter", "Knuckleball", "Sweeper","Other"), - selected = clicked_pitch$TaggedPitchType), - br(), - actionButton("update_pitch", "Update Pitch Type", class = "btn-primary btn-lg"), - actionButton("cancel_edit", "Cancel", class = "btn-default") - ), - footer = NULL, - size = "m", - easyClose = TRUE - )) - } - }) - - # Handle brush selection (drag mode) - observeEvent(input$plot_brush, { - req(plot_data(), input$pitcher_select, input$plot_brush) - - if (input$selection_mode != "drag") return() - - pitcher_data <- plot_data() %>% - filter(Pitcher == input$pitcher_select) %>% - filter(!is.na(TaggedPitchType), TaggedPitchType != "Other", - !is.na(HorzBreak), !is.na(InducedVertBreak), !is.na(RelSpeed)) - - if (nrow(pitcher_data) == 0) return() - - brush <- input$plot_brush - brushed_points <- pitcher_data %>% - filter( - HorzBreak >= brush$xmin & HorzBreak <= brush$xmax & - InducedVertBreak >= brush$ymin & InducedVertBreak <= brush$ymax - ) - - if (nrow(brushed_points) > 0) { - selected_points(brushed_points) - } else { - selected_points(NULL) - } - }) - - # Apply bulk change - observeEvent(input$apply_bulk_change, { - req(selected_points(), input$bulk_pitch_type) - - sel_points <- selected_points() - - if (nrow(sel_points) == 0) { - showNotification("No points selected", type = "warning") - return() - } - - current_data <- plot_data() - - for (i in 1:nrow(sel_points)) { - point <- sel_points[i, ] - current_data <- current_data %>% - mutate(TaggedPitchType = ifelse( - Pitcher == input$pitcher_select & - abs(HorzBreak - point$HorzBreak) < 0.01 & - abs(InducedVertBreak - point$InducedVertBreak) < 0.01 & - abs(RelSpeed - point$RelSpeed) < 0.01, - input$bulk_pitch_type, - TaggedPitchType - )) - } - - plot_data(current_data) - processed_data(current_data) - selected_points(NULL) - - showNotification( - paste("Updated", nrow(sel_points), "pitches to", input$bulk_pitch_type), - type = "message", duration = 3 - ) - }) - - # Selection info for drag mode - output$selection_info <- renderText({ - if (input$selection_mode == "drag" && !is.null(selected_points())) { - sel_points <- selected_points() - pitch_counts <- table(sel_points$TaggedPitchType) - paste(nrow(sel_points), "points selected:", - paste(names(pitch_counts), "(", pitch_counts, ")", collapse = ", ")) - } else { - "No points selected. Click and drag to select multiple pitches." - } - }) - - # Hover info - output$hover_info <- renderText({ - req(input$plot_hover, plot_data(), input$pitcher_select) - - pitcher_data <- plot_data() %>% - filter(Pitcher == input$pitcher_select) %>% - filter(!is.na(TaggedPitchType), TaggedPitchType != "Other", - !is.na(HorzBreak), !is.na(InducedVertBreak), !is.na(RelSpeed)) - - if (nrow(pitcher_data) == 0) return("") - - hover_x <- input$plot_hover$x - hover_y <- input$plot_hover$y - - distances <- sqrt((pitcher_data$HorzBreak - hover_x)^2 + - (pitcher_data$InducedVertBreak - hover_y)^2) - - if (min(distances) <= 2) { - closest_idx <- which.min(distances) - hover_pitch <- pitcher_data[closest_idx, ] - - bat_info <- "" - if ("BatSpeed_Sensor" %in% names(hover_pitch) && !is.na(hover_pitch$BatSpeed_Sensor)) { - bat_info <- paste(" | Bat Speed:", round(hover_pitch$BatSpeed_Sensor, 1), "mph") - } - - paste("Hovering over:", - paste("Type:", hover_pitch$TaggedPitchType), - paste("Velocity:", round(hover_pitch$RelSpeed, 1), "mph"), - paste("HB:", round(hover_pitch$HorzBreak, 1), "in"), - paste("IVB:", round(hover_pitch$InducedVertBreak, 1), "in"), - bat_info, - sep = " | ") - } else { - "" - } - }) - - # Movement stats table - output$movement_stats <- DT::renderDataTable({ - req(plot_data(), input$pitcher_select) - - data <- plot_data() - - movement_stats <- data %>% - filter(Pitcher == input$pitcher_select) %>% - filter(!is.na(HorzBreak), !is.na(InducedVertBreak), !is.na(TaggedPitchType)) %>% - mutate( - pitch_group = case_when( - TaggedPitchType %in% c("Fastball", "FourSeamFastBall", "FourSeamFastB", "Four-Seam", "4-Seam") ~ "Fastball", - TaggedPitchType %in% c("OneSeamFastBall", "TwoSeamFastBall", "Sinker", "Two-Seam", "One-Seam") ~ "Sinker", - TaggedPitchType %in% c("ChangeUp", "Changeup") ~ "Changeup", - TRUE ~ TaggedPitchType - ), - in_zone = ifelse("StrikeZoneIndicator" %in% names(.), StrikeZoneIndicator, - ifelse(!is.na(PlateLocSide) & !is.na(PlateLocHeight) & - PlateLocSide >= -0.95 & PlateLocSide <= 0.95 & - PlateLocHeight >= 1.6 & PlateLocHeight <= 3.5, 1, 0)), - is_whiff = ifelse("WhiffIndicator" %in% names(.), WhiffIndicator, - ifelse(!is.na(PitchCall) & PitchCall == "StrikeSwinging", 1, 0)), - chase = ifelse("Chaseindicator" %in% names(.), Chaseindicator, - ifelse(!is.na(PitchCall) & !is.na(PlateLocSide) & !is.na(PlateLocHeight) & - PitchCall %in% c("StrikeSwinging", "FoulBallNotFieldable", "FoulBall", "InPlay") & - (PlateLocSide < -0.95 | PlateLocSide > 0.95 | PlateLocHeight < 1.6 | PlateLocHeight > 3.5), 1, 0)) - ) - - total_pitches <- nrow(movement_stats) - - has_bat_speed <- "BatSpeed_Sensor" %in% names(movement_stats) - - summary_stats <- movement_stats %>% - group_by(`Pitch Type` = pitch_group) %>% - summarise( - Count = n(), - `Usage%` = sprintf("%.1f%%", (n() / total_pitches) * 100), - `Avg Velo` = sprintf("%.1f", mean(RelSpeed, na.rm = TRUE)), - `Max Velo` = sprintf("%.1f", max(RelSpeed, na.rm = TRUE)), - `Avg IVB` = sprintf("%.1f", mean(InducedVertBreak, na.rm = TRUE)), - `Avg HB` = sprintf("%.1f", mean(HorzBreak, na.rm = TRUE)), - `Avg Spin` = ifelse("SpinRate" %in% names(movement_stats), - sprintf("%.0f", mean(SpinRate, na.rm = TRUE)), - "\u2014"), - `Avg Bat Speed` = if (has_bat_speed) { - bat_vals <- BatSpeed_Sensor[!is.na(BatSpeed_Sensor)] - if (length(bat_vals) > 0) sprintf("%.1f", mean(bat_vals)) else "\u2014" - } else "\u2014", - `Zone%` = sprintf("%.1f%%", round(mean(in_zone, na.rm = TRUE) * 100, 1)), - `Whiff%` = sprintf("%.1f%%", round(mean(is_whiff, na.rm = TRUE) * 100, 1)), - .groups = "drop" - ) %>% - arrange(desc(Count)) - - DT::datatable(summary_stats, - options = list(pageLength = 15, dom = 't', scrollX = TRUE), - rownames = FALSE) %>% - DT::formatStyle(columns = names(summary_stats), fontSize = '12px') - }) - - # Selected pitch info in modal - output$selected_pitch_info <- renderText({ - pitch_info <- selected_pitch() - if (!is.null(pitch_info)) { - pitch_data <- pitch_info$data - - info_lines <- c( - paste("Pitcher:", pitch_info$pitcher), - if ("PitchNo" %in% names(pitch_data) && !is.na(pitch_data$PitchNo)) paste("Pitch No:", pitch_data$PitchNo) else NULL, - if ("Batter" %in% names(pitch_data) && !is.na(pitch_data$Batter)) paste("Batter:", pitch_data$Batter) else NULL, - paste("Current Type:", pitch_data$TaggedPitchType), - paste("Velocity:", round(pitch_data$RelSpeed, 1), "mph"), - paste("Horizontal Break:", round(pitch_data$HorzBreak, 1), "inches"), - paste("Induced Vertical Break:", round(pitch_data$InducedVertBreak, 1), "inches") - ) - - if ("SpinRate" %in% names(pitch_data) && !is.na(pitch_data$SpinRate)) { - info_lines <- c(info_lines, paste("Spin Rate:", round(pitch_data$SpinRate, 0), "rpm")) - } - - if ("BatSpeed_Sensor" %in% names(pitch_data) && !is.na(pitch_data$BatSpeed_Sensor)) { - info_lines <- c(info_lines, - paste("Bat Speed:", round(pitch_data$BatSpeed_Sensor, 1), "mph"), - paste("Vertical Attack Angle:", round(pitch_data$VerticalAttackAngle_Sensor, 1), "\u00B0"), - paste("Horizontal Attack Angle:", round(pitch_data$HorizontalAttackAngle_Sensor, 1), "\u00B0")) - } - - if ("Date" %in% names(pitch_data) && !is.na(pitch_data$Date)) { - info_lines <- c(info_lines, paste("Date:", pitch_data$Date)) - } - - return(paste(info_lines, collapse = "\n")) - } else { - return("No pitch selected") - } - }) - - # Update pitch type - observeEvent(input$update_pitch, { - pitch_info <- selected_pitch() - - if (!is.null(pitch_info)) { - current_data <- plot_data() - - target_pitcher <- pitch_info$pitcher - target_hb <- pitch_info$data$HorzBreak - target_ivb <- pitch_info$data$InducedVertBreak - target_velo <- pitch_info$data$RelSpeed - - current_data <- current_data %>% - mutate(TaggedPitchType = ifelse( - Pitcher == target_pitcher & - abs(HorzBreak - target_hb) < 0.01 & - abs(InducedVertBreak - target_ivb) < 0.01 & - abs(RelSpeed - target_velo) < 0.01, - input$modal_new_pitch_type, - TaggedPitchType - )) - - plot_data(current_data) - processed_data(current_data) - - removeModal() - - showNotification( - paste("Updated pitch from", pitch_info$original_type, "to", input$modal_new_pitch_type), - type = "message", duration = 3 - ) - - selected_pitch(NULL) - } - }) - - # Cancel edit - observeEvent(input$cancel_edit, { - removeModal() - selected_pitch(NULL) - }) - - # ══════════════════════════════════════════════════════════════ - # Rule-Based Pitch Retagging — Server Logic - # ══════════════════════════════════════════════════════════════ - - # Reactive: compute the set of pitches matching all active filters - retag_matched <- reactive({ - req(plot_data(), input$pitcher_select) - - df <- plot_data() %>% - filter(Pitcher == input$pitcher_select) - - # Filter: Current pitch type - if (!is.null(input$retag_from_type) && input$retag_from_type != "") { - df <- df %>% filter(TaggedPitchType == input$retag_from_type) - } - - # Filter: Date range - if ("Date" %in% names(df)) { - d_start <- input$retag_dates[1] - d_end <- input$retag_dates[2] - if (!is.na(d_start) && !is.na(d_end)) { - df <- df %>% - filter(!is.na(Date)) %>% - mutate(.tmp_date = as.Date(Date)) %>% - filter(.tmp_date >= as.Date(d_start), .tmp_date <= as.Date(d_end)) %>% - select(-.tmp_date) - } - } - - # Filter: Pitch number range (supports "1-50" or single number) - if (!is.null(input$retag_pitch_no_range) && nzchar(trimws(input$retag_pitch_no_range))) { - pn_text <- trimws(input$retag_pitch_no_range) - if (grepl("^\\d+-\\d+$", pn_text)) { - parts <- as.numeric(strsplit(pn_text, "-")[[1]]) - if ("PitchNo" %in% names(df)) { - df <- df %>% filter(!is.na(PitchNo), PitchNo >= parts[1], PitchNo <= parts[2]) - } - } else if (grepl("^\\d+$", pn_text)) { - if ("PitchNo" %in% names(df)) { - df <- df %>% filter(PitchNo == as.numeric(pn_text)) - } - } - } - - # Filter: Velocity - if (!is.na(input$retag_velo_min)) df <- df %>% filter(!is.na(RelSpeed), RelSpeed >= input$retag_velo_min) - if (!is.na(input$retag_velo_max)) df <- df %>% filter(!is.na(RelSpeed), RelSpeed <= input$retag_velo_max) - - # Filter: Spin Rate - if ("SpinRate" %in% names(df)) { - if (!is.na(input$retag_spin_min)) df <- df %>% filter(!is.na(SpinRate), SpinRate >= input$retag_spin_min) - if (!is.na(input$retag_spin_max)) df <- df %>% filter(!is.na(SpinRate), SpinRate <= input$retag_spin_max) - } - - # Filter: Induced Vertical Break - if (!is.na(input$retag_ivb_min)) df <- df %>% filter(!is.na(InducedVertBreak), InducedVertBreak >= input$retag_ivb_min) - if (!is.na(input$retag_ivb_max)) df <- df %>% filter(!is.na(InducedVertBreak), InducedVertBreak <= input$retag_ivb_max) - - # Filter: Horizontal Break - if (!is.na(input$retag_hb_min)) df <- df %>% filter(!is.na(HorzBreak), HorzBreak >= input$retag_hb_min) - if (!is.na(input$retag_hb_max)) df <- df %>% filter(!is.na(HorzBreak), HorzBreak <= input$retag_hb_max) - - # Filter: Vertical Break - if ("VertBreak" %in% names(df)) { - if (!is.na(input$retag_vb_min)) df <- df %>% filter(!is.na(VertBreak), VertBreak >= input$retag_vb_min) - if (!is.na(input$retag_vb_max)) df <- df %>% filter(!is.na(VertBreak), VertBreak <= input$retag_vb_max) - } - - df - }) - - # Preview button: show what would be retagged - observeEvent(input$retag_preview_btn, { - matched <- retag_matched() - - output$retag_preview_summary <- renderUI({ - n <- nrow(matched) - if (n == 0) { - div(class = "merge-status-box merge-warning", - style = "margin-bottom: 10px;", - p(style = "margin: 0; font-weight: 600; color: #856404;", - paste0("No pitches match these filters for ", input$pitcher_select, "."))) - } else { - type_counts <- table(matched$TaggedPitchType) - type_str <- paste(names(type_counts), "(", type_counts, ")", collapse = ", ") - div(class = "merge-status-box merge-success", - style = "margin-bottom: 10px;", - p(style = "margin: 0; font-weight: 600; color: #155724;", - paste0(n, " pitches matched \u2014 will retag to ", input$retag_to_type)), - p(style = "margin: 4px 0 0 0; color: #155724; font-size: 13px;", type_str)) - } - }) - - output$retag_preview_table <- DT::renderDataTable({ - if (nrow(matched) == 0) return(NULL) - - preview_cols <- intersect( - c("PitchNo", "Date", "Batter", "TaggedPitchType", "RelSpeed", - "SpinRate", "InducedVertBreak", "HorzBreak", "VertBreak", - "PlateLocSide", "PlateLocHeight", "PitchCall"), - names(matched) - ) - - DT::datatable( - matched %>% select(all_of(preview_cols)), - options = list(scrollX = TRUE, pageLength = 8, dom = "tip"), - rownames = FALSE - ) %>% - DT::formatRound( - columns = intersect(preview_cols, c("RelSpeed", "InducedVertBreak", "HorzBreak", "VertBreak")), - digits = 1 - ) - }) - }) - - # Apply retag: actually change TaggedPitchType - observeEvent(input$retag_apply_btn, { - matched <- retag_matched() - - if (nrow(matched) == 0) { - showNotification("No pitches match the current filters.", type = "warning") - return() - } - - current_data <- plot_data() - - # Build composite key from available columns to uniquely identify pitches - key_cols <- intersect(c("PitchUID", "PitchNo", "Date", "Pitcher", "RelSpeed", - "HorzBreak", "InducedVertBreak"), names(current_data)) - - make_key <- function(df) { - do.call(paste, c(df[key_cols], sep = "||")) - } - - matched_keys <- make_key(matched) - - current_data <- current_data %>% - mutate(.retag_key = make_key(cur_data())) %>% - mutate(TaggedPitchType = ifelse( - .retag_key %in% matched_keys & Pitcher == input$pitcher_select, - input$retag_to_type, - TaggedPitchType - )) %>% - select(-.retag_key) - - plot_data(current_data) - processed_data(current_data) - - n_matched <- nrow(matched) - showNotification( - paste0("Retagged ", n_matched, " pitches to ", input$retag_to_type), - type = "message", duration = 4 - ) - - # Update preview to show completion - output$retag_preview_summary <- renderUI({ - div(class = "merge-status-box merge-success", - style = "margin-bottom: 10px;", - p(style = "margin: 0; font-weight: 600; color: #155724;", - paste0("\u2713 Done! ", n_matched, " pitches retagged to ", input$retag_to_type, "."))) - }) - output$retag_preview_table <- DT::renderDataTable({ NULL }) - }) - - # Clear all retag filters - observeEvent(input$retag_clear_btn, { - updateSelectInput(session, "retag_from_type", selected = "") - updateDateRangeInput(session, "retag_dates", start = NA, end = NA) - updateTextInput(session, "retag_pitch_no_range", value = "") - updateNumericInput(session, "retag_velo_min", value = NA) - updateNumericInput(session, "retag_velo_max", value = NA) - updateNumericInput(session, "retag_spin_min", value = NA) - updateNumericInput(session, "retag_spin_max", value = NA) - updateNumericInput(session, "retag_ivb_min", value = NA) - updateNumericInput(session, "retag_ivb_max", value = NA) - updateNumericInput(session, "retag_hb_min", value = NA) - updateNumericInput(session, "retag_hb_max", value = NA) - updateNumericInput(session, "retag_vb_min", value = NA) - updateNumericInput(session, "retag_vb_max", value = NA) - - output$retag_preview_summary <- renderUI({ NULL }) - output$retag_preview_table <- DT::renderDataTable({ NULL }) - }) - - # ══════════════════════════════════════════════════════════════ - # End Rule-Based Retagging - # ══════════════════════════════════════════════════════════════ - - # ══════════════════════════════════════════════════════════════ - # Catcher Notes — Server Logic - # ══════════════════════════════════════════════════════════════ - - # Add a catcher note - observeEvent(input$cn_add_btn, { - req(processed_data(), input$cn_catcher, input$cn_batter) - - df <- processed_data() - - # Determine the result text - result_text <- input$cn_result - if (result_text == "Other" && !is.null(input$cn_custom_result) && nzchar(trimws(input$cn_custom_result))) { - result_text <- trimws(input$cn_custom_result) - } - - # Find the matching row(s): Catcher + Batter + Inning + Balls + Strikes - # We match the LAST pitch in that count for that matchup in that inning - # (the event most likely happened on the final pitch of that count) - has_catcher <- "Catcher" %in% names(df) - has_batter <- "Batter" %in% names(df) - has_inning <- "Inning" %in% names(df) - has_balls <- "Balls" %in% names(df) - has_strikes <- "Strikes" %in% names(df) - - candidates <- df - if (has_catcher) candidates <- candidates %>% filter(Catcher == input$cn_catcher) - if (has_batter) candidates <- candidates %>% filter(Batter == input$cn_batter) - if (has_inning) candidates <- candidates %>% filter(Inning == input$cn_inning) - if (has_balls) candidates <- candidates %>% filter(Balls == input$cn_balls) - if (has_strikes) candidates <- candidates %>% filter(Strikes == input$cn_strikes) - - # Get the row index in the full dataframe for the last matching pitch - if (nrow(candidates) > 0) { - # Find which rows in the full df match - match_idx <- which( - (if (has_catcher) df$Catcher == input$cn_catcher else TRUE) & - (if (has_batter) df$Batter == input$cn_batter else TRUE) & - (if (has_inning) df$Inning == input$cn_inning else TRUE) & - (if (has_balls) df$Balls == input$cn_balls else TRUE) & - (if (has_strikes) df$Strikes == input$cn_strikes else TRUE) - ) - matched_row <- max(match_idx) # last pitch at that count - } else { - matched_row <- NA_integer_ - } - - # Build the new note - notes <- catcher_notes_list() - new_id <- if (nrow(notes) == 0) 1L else max(notes$NoteID) + 1L - - new_note <- data.frame( - NoteID = new_id, - Catcher = input$cn_catcher, - Batter = input$cn_batter, - Inning = as.integer(input$cn_inning), - Balls = as.integer(input$cn_balls), - Strikes = as.integer(input$cn_strikes), - Result = result_text, - MatchedRow = matched_row, - stringsAsFactors = FALSE - ) - - catcher_notes_list(bind_rows(notes, new_note)) - - # Show match feedback - output$cn_match_feedback <- renderUI({ - if (!is.na(matched_row)) { - pitch_info <- df[matched_row, ] - detail_parts <- c() - if ("PitchNo" %in% names(pitch_info) && !is.na(pitch_info$PitchNo)) - detail_parts <- c(detail_parts, paste("Pitch #", pitch_info$PitchNo)) - if ("PitchCall" %in% names(pitch_info) && !is.na(pitch_info$PitchCall)) - detail_parts <- c(detail_parts, pitch_info$PitchCall) - if ("Pitcher" %in% names(pitch_info) && !is.na(pitch_info$Pitcher)) - detail_parts <- c(detail_parts, paste("vs", pitch_info$Pitcher)) - - div(class = "merge-status-box merge-success", - style = "margin-top: 10px;", - p(style = "margin: 0; font-weight: 600; color: #155724;", - paste0("\u2713 Matched to row ", matched_row)), - if (length(detail_parts) > 0) - p(style = "margin: 4px 0 0 0; color: #155724; font-size: 13px;", - paste(detail_parts, collapse = " | ")) - ) - } else { - div(class = "merge-status-box merge-warning", - style = "margin-top: 10px;", - p(style = "margin: 0; font-weight: 600; color: #856404;", - paste0("\u26A0 No matching pitch found for ", input$cn_catcher, - " / ", input$cn_batter, " / Inn ", input$cn_inning, - " / ", input$cn_balls, "-", input$cn_strikes)), - p(style = "margin: 4px 0 0 0; color: #856404; font-size: 13px;", - "Note saved anyway \u2014 it will appear in CatcherNotes column as unmatched.") - ) - } - }) - - showNotification( - paste0("Added: ", result_text, " (", input$cn_catcher, " / ", input$cn_batter, - " / Inn ", input$cn_inning, " / ", input$cn_balls, "-", input$cn_strikes, ")"), - type = "message", duration = 3 - ) - }) - - # Render the notes table - output$cn_notes_table <- DT::renderDataTable({ - notes <- catcher_notes_list() - if (nrow(notes) == 0) return(NULL) - - display_notes <- notes %>% - mutate( - Count = paste0(Balls, "-", Strikes), - Match = ifelse(is.na(MatchedRow), "\u2717 No match", paste0("\u2713 Row ", MatchedRow)) - ) %>% - select(NoteID, Catcher, Batter, Inning, Count, Result, Match) - - DT::datatable( - display_notes, - options = list( - scrollX = TRUE, pageLength = 15, dom = "tip", - columnDefs = list(list(className = "dt-center", targets = "_all")) - ), - rownames = FALSE, - selection = "single", - callback = DT::JS(" - table.on('click', 'tr', function() { - var data = table.row(this).data(); - if (data) { - Shiny.setInputValue('cn_delete_row', data[0], {priority: 'event'}); - } - }); - ") - ) %>% - DT::formatStyle("Match", - color = DT::styleEqual(c("\u2717 No match"), c("#dc3545")), - fontWeight = "bold" - ) - }) - - # Delete a single note by clicking its row - observeEvent(input$cn_delete_row, { - notes <- catcher_notes_list() - note_id <- as.integer(input$cn_delete_row) - - if (note_id %in% notes$NoteID) { - showModal(modalDialog( - title = "Delete Catcher Note?", - p(paste("Remove note #", note_id, "?")), - footer = tagList( - actionButton("cn_confirm_delete", "Delete", class = "btn-danger"), - modalButton("Cancel") - ), - size = "s", easyClose = TRUE - )) - } - }) - - observeEvent(input$cn_confirm_delete, { - notes <- catcher_notes_list() - note_id <- as.integer(input$cn_delete_row) - catcher_notes_list(notes %>% filter(NoteID != note_id)) - removeModal() - showNotification(paste("Deleted note #", note_id), type = "message", duration = 2) - }) - - # Clear all notes - observeEvent(input$cn_clear_all_btn, { - showModal(modalDialog( - title = "Clear All Catcher Notes?", - p("This will remove all logged catcher notes. This cannot be undone."), - footer = tagList( - actionButton("cn_confirm_clear_all", "Clear All", class = "btn-danger"), - modalButton("Cancel") - ), - size = "s", easyClose = TRUE - )) - }) - - observeEvent(input$cn_confirm_clear_all, { - catcher_notes_list(data.frame( - NoteID = integer(0), Catcher = character(0), Batter = character(0), - Inning = integer(0), Balls = integer(0), Strikes = integer(0), - Result = character(0), MatchedRow = integer(0), stringsAsFactors = FALSE - )) - removeModal() - output$cn_match_feedback <- renderUI({ NULL }) - showNotification("All catcher notes cleared.", type = "message", duration = 2) - }) - - # Catcher notes summary - output$cn_summary <- renderText({ - notes <- catcher_notes_list() - if (nrow(notes) == 0) return("No notes logged yet.") - - n_matched <- sum(!is.na(notes$MatchedRow)) - n_unmatched <- sum(is.na(notes$MatchedRow)) - result_counts <- table(notes$Result) - result_str <- paste(names(result_counts), "(", result_counts, ")", collapse = ", ") - - paste( - paste("Total notes:", nrow(notes)), - paste("Matched:", n_matched, "| Unmatched:", n_unmatched), - paste("Results:", result_str), - sep = "\n" - ) - }) - - build_download_data <- function() { - df <- processed_data() - if (is.null(df)) return(NULL) - - # ── Full processing mode: clean + stuff+ ── - if (!is.null(input$download_mode) && input$download_mode == "full") { - df <- tryCatch({ - showNotification("Running clean_college_data()...", type = "message", duration = 2) - cleaned <- clean_college_data(df) - - showNotification("Predicting Stuff+...", type = "message", duration = 2) - cleaned <- predict_stuffplus(cleaned) - - showNotification( - paste0("Full processing complete: ", nrow(cleaned), " rows × ", ncol(cleaned), " columns"), - type = "message", duration = 4 - ) - cleaned - }, error = function(e) { - showNotification(paste("Processing error:", e$message), type = "error", duration = 8) - processed_data() - }) - } - - if (!is.null(input$download_team_filter) && input$download_team_filter != "all") { - if (input$download_team_filter == "coastal_pitchers" && "PitcherTeam" %in% names(df)) { - df <- df %>% filter(PitcherTeam == "COA_CHA") - } else if (input$download_team_filter == "coastal_hitters" && "BatterTeam" %in% names(df)) { - df <- df %>% filter(BatterTeam == "COA_CHA") - } - } - - # ── Merge catcher notes into existing Notes column ── - notes <- catcher_notes_list() - - if (nrow(notes) == 0) return(df) - - # Initialize Notes column if not present - if (!"Notes" %in% names(df)) { - df$Notes <- NA_character_ - } - - # For each note, append the result into the matched row's Notes - for (i in seq_len(nrow(notes))) { - row_idx <- notes$MatchedRow[i] - result <- notes$Result[i] - - if (!is.na(row_idx) && row_idx >= 1 && row_idx <= nrow(df)) { - existing <- df$Notes[row_idx] - if (is.na(existing) || existing == "") { - df$Notes[row_idx] <- result - } else { - df$Notes[row_idx] <- paste(existing, result, sep = " | ") - } - } - } - - # Append unmatched notes to last row - unmatched <- notes %>% filter(is.na(MatchedRow)) - if (nrow(unmatched) > 0) { - unmatched_texts <- paste0( - unmatched$Result, " (", unmatched$Catcher, "/", unmatched$Batter, - " Inn", unmatched$Inning, " ", unmatched$Balls, "-", unmatched$Strikes, ")" - ) - last_row <- nrow(df) - existing <- df$Notes[last_row] - unmatched_str <- paste0("[UNMATCHED] ", paste(unmatched_texts, collapse = "; ")) - if (is.na(existing) || existing == "") { - df$Notes[last_row] <- unmatched_str - } else { - df$Notes[last_row] <- paste(existing, unmatched_str, sep = " | ") - } - } - - return(df) - } - - - # ══════════════════════════════════════════════════════════════ - # End Catcher Notes - # ══════════════════════════════════════════════════════════════ - - # Click info output - output$click_info <- renderText({ - if (!is.null(selected_pitch())) { - pitch_info <- selected_pitch() - paste("Last selected pitch:", pitch_info$original_type, - "| Position: (", round(pitch_info$data$HorzBreak, 1), ",", - round(pitch_info$data$InducedVertBreak, 1), ")") - } else { - "No point selected yet. Click on a point in the chart above to edit its pitch type." - } - }) - - # Data summary for download page - output$data_summary <- renderText({ - req(processed_data()) - df <- processed_data() - result <- merge_result() - notes <- catcher_notes_list() - - bat_tracking_summary <- if (!is.null(result) && result$matched > 0) { - paste("Bat tracking data:", result$matched, "pitches with swing metrics") - } else { - "Bat tracking data: None" - } - - notes_summary <- if (nrow(notes) > 0) { - n_matched <- sum(!is.na(notes$MatchedRow)) - paste0("Catcher notes: ", nrow(notes), " total (", n_matched, " matched to rows)") - } else { - "Catcher notes: None" - } - - summary_text <- paste( - paste("Total rows:", nrow(df)), - paste("Total columns:", ncol(df)), - paste("Date range:", - if ("Date" %in% names(df) && !all(is.na(df$Date))) { - paste(min(as.Date(df$Date), na.rm = TRUE), "to", max(as.Date(df$Date), na.rm = TRUE)) - } else { - "Date column not available" - }), - paste("Unique pitchers:", - if ("Pitcher" %in% names(df)) { - length(unique(df$Pitcher[!is.na(df$Pitcher)])) - } else { - "Pitcher column not available" - }), - paste("Pitch types:", - if ("TaggedPitchType" %in% names(df)) { - paste(sort(unique(df$TaggedPitchType[!is.na(df$TaggedPitchType)])), collapse = ", ") - } else { - "TaggedPitchType column not available" - }), - bat_tracking_summary, - notes_summary, - paste("Source format:", toupper(uploaded_file_type())), - paste("Date format:", if (input$date_format == "mdyy") "M/D/YY" else "YYYY-MM-DD"), - sep = "\n" - ) - - return(summary_text) - }) - - # Download handler: CSV or Parquet with custom filename — NOW includes catcher notes - output$downloadData <- downloadHandler( - filename = function() { - base_name <- gsub("[^A-Za-z0-9_\\-]", "_", input$download_filename) - if (nchar(trimws(base_name)) == 0) base_name <- paste0("app_ready_COA_", Sys.Date()) - - ext <- input$download_format - paste0(base_name, ".", ext) - }, - content = function(file) { - download_df <- build_download_data() - - if (input$download_format == "parquet") { - arrow::write_parquet(download_df, file) - } else { - write.csv(download_df, file, row.names = FALSE) - } - } - ) - - - - #SCRAPER STUFF - - output$scrape_options <- renderUI({ - switch(input$scrape_source, - "pbp" = tagList( - p("Scrapes TrackMan play-by-play data from FTP.") - ), - "pos" = tagList( - p("Scrapes TrackMan player positioning data from FTP.") - ), - "ncaa" = tagList( - selectInput("ncaa_division", "Division:", choices = c("D1", "D2", "D3")), - p("Scrapes NCAA scoreboard data via API.") - ) - ) - }) - - # Scrape button -observeEvent(input$scrape_btn, { - scrape_status_msg("Triggering scrape on GitHub...") - - gh_token <- Sys.getenv("GITHUB_TOKEN") - gh_repo <- Sys.getenv("GITHUB_REPO") - - result <- tryCatch({ - httr::POST( - paste0("https://api.github.com/repos/", gh_repo, "/actions/workflows/scrape.yml/dispatches"), - httr::add_headers( - Authorization = paste("Bearer", gh_token), - Accept = "application/vnd.github.v3+json" - ), - body = jsonlite::toJSON(list( - ref = "main", - inputs = list( - start_date = as.character(input$start_date), - end_date = as.character(input$end_date), - data_type = input$scrape_source - ) - ), auto_unbox = TRUE), - encode = "raw" - ) - }, error = function(e) { - scrape_status_msg(paste("Failed:", e$message)) - return(NULL) - }) - - if (is.null(result)) return() - - if (httr::status_code(result) == 204) { - scrape_status_msg("Scrape triggered! Waiting for GitHub to finish...") - scrape_polling(TRUE) - } else { - scrape_status_msg(paste("GitHub API error:", httr::status_code(result))) - } -}) - -# Poll GitHub every 15 seconds to check if done -shiny::observe({ - req(scrape_polling()) - - invalidateLater(15000, session) - - gh_token <- Sys.getenv("GITHUB_TOKEN") - gh_repo <- Sys.getenv("GITHUB_REPO") - - resp <- tryCatch({ - httr::GET( - paste0("https://api.github.com/repos/", gh_repo, "/actions/runs?per_page=1"), - httr::add_headers( - Authorization = paste("Bearer", gh_token), - Accept = "application/vnd.github.v3+json" - ) - ) - }, error = function(e) { NULL }) - - if (is.null(resp)) return() - - runs <- jsonlite::fromJSON(httr::content(resp, as = "text", encoding = "UTF-8")) - - if (length(runs$workflow_runs) == 0) return() - - latest <- runs$workflow_runs[1, ] - status <- latest$status - conclusion <- latest$conclusion - - if (status == "completed") { - scrape_polling(FALSE) - - if (conclusion == "success") { - scrape_status_msg("GitHub finished! Fetching data...") - - filename <- paste0(input$scrape_source, "_", input$start_date, "_to_", input$end_date, ".csv.gz") - url <- paste0("https://api.github.com/repos/", gh_repo, "/contents/data/", filename) - - data <- tryCatch({ - file_resp <- httr::GET( - url, - httr::add_headers( - Authorization = paste("Bearer", gh_token), - Accept = "application/vnd.github.v3.raw" - ) - ) - - if (httr::status_code(file_resp) == 200) { - tmp <- tempfile(fileext = ".csv.gz") - writeBin(httr::content(file_resp, as = "raw"), tmp) - read_csv(gzfile(tmp)) - } else { - NULL - } - }, error = function(e) { NULL }) - - if (!is.null(data) && nrow(data) > 0) { - - if (input$scrape_source == "pbp") { - scrape_status_msg("Processing data...") - - data <- tryCatch({ - d <- clean_college_data(data) - d <- predict_stuffplus(d) - d - }, error = function(e) { - scrape_status_msg(paste("Processing error:", e$message)) - data - }) - } - - scraped_data(data) - scrape_status_msg(paste0("Done! ", nrow(data), " rows \u00D7 ", ncol(data), " columns.")) - } else { - scrape_status_msg("Scrape finished but couldn't fetch the file. Try 'Fetch Results' manually.") - } - - } else { - scrape_status_msg(paste("GitHub Action failed:", conclusion)) - } - - } else { - scrape_status_msg(paste0("GitHub is running... (status: ", status, ")")) - } -}) - - # Status text - output$scrape_status <- renderText({ scrape_status_msg() }) - - # Preview table - output$scrape_preview <- DT::renderDataTable({ - req(scraped_data()) - DT::datatable(scraped_data(), options = list(scrollX = TRUE, pageLength = 10)) - }) - - # Download - output$download_scrape <- downloadHandler( - filename = function() { - label <- switch(input$scrape_source, "pbp" = "pbp", "pos" = "positional", "ncaa" = "ncaa") - paste0("trackman_", label, "_", - format(input$start_date, "%Y%m%d"), "_to_", - format(input$end_date, "%Y%m%d"), ".csv") - }, - content = function(file) { - req(scraped_data()) - write.csv(scraped_data(), file, row.names = FALSE) - } - ) - - -observeEvent(input$upload_hf_btn, { - req(scraped_data()) - - hf_token <- Sys.getenv("HF_WRITE_TOKEN") - repo_id <- "CoastalBaseball/2026MasterDataset" - timestamp <- format(Sys.time(), "%Y%m%d_%H%M%S") - - upload_to_hf <- function(new_data, folder, index_file, label) { - scrape_status_msg(paste0("Checking existing UIDs for ", label, "...")) - - existing_uids <- tryCatch({ - tmp_idx <- tempfile(fileext = ".csv.gz") - resp <- httr::GET( - paste0("https://huggingface.co/datasets/", repo_id, "/resolve/main/", index_file), - httr::add_headers(Authorization = paste("Bearer", hf_token)), - httr::write_disk(tmp_idx, overwrite = TRUE) - ) - if (httr::status_code(resp) == 200) { - d <- read.csv(gzfile(tmp_idx), stringsAsFactors = FALSE) - file.remove(tmp_idx) - d$PitchUID - } else { - file.remove(tmp_idx) - character(0) - } - }, error = function(e) { character(0) }) - - scraped_rows <- nrow(new_data) - - if (length(existing_uids) > 0 && "PitchUID" %in% names(new_data)) { - new_only <- new_data %>% filter(!PitchUID %in% existing_uids) - } else { - new_only <- new_data - } - - new_rows <- nrow(new_only) - total_after <- length(existing_uids) + new_rows - - if (new_rows == 0) { - return(paste0(label, ": ", scraped_rows, " rows scraped, 0 new rows added (", length(existing_uids), " total)")) - } - - scrape_status_msg(paste0("Uploading ", new_rows, " new rows for ", label, "...")) - - hf <- reticulate::import("huggingface_hub") - api <- hf$HfApi() - - tmp_data <- tempfile(fileext = ".parquet") - arrow::write_parquet(new_only, tmp_data) - - api$upload_file( - path_or_fileobj = tmp_data, - path_in_repo = paste0(folder, "/", timestamp, ".parquet"), - repo_id = repo_id, - repo_type = "dataset", - token = hf_token - ) - file.remove(tmp_data) - - scrape_status_msg(paste0("Updating ", label, " index...")) - - all_uids <- data.frame(PitchUID = c(existing_uids, new_only$PitchUID)) - tmp_idx <- tempfile(fileext = ".csv.gz") - gz <- gzfile(tmp_idx, "w") - write.csv(all_uids, gz, row.names = FALSE) - close(gz) - - api$upload_file( - path_or_fileobj = tmp_idx, - path_in_repo = index_file, - repo_id = repo_id, - repo_type = "dataset", - token = hf_token - ) - file.remove(tmp_idx) - - rm(new_only, all_uids); gc() - paste0(label, ": ", scraped_rows, " rows scraped, ", new_rows, " new rows added (", total_after, " total)") - } - - if (input$scrape_source == "pbp") { - msg1 <- upload_to_hf(scraped_data(), "pbp", "pbp_uid_index.csv.gz", "Master Dataset") - gc() - - cp <- scraped_data() %>% filter(PitcherTeam == "COA_CHA") - msg2 <- if (nrow(cp) > 0) { - upload_to_hf(cp, "coastal_pitchers", "coastal_pitchers_uid_index.csv.gz", "Coastal Pitchers") - } else { "Coastal Pitchers: No matching rows" } - rm(cp); gc() - - ch <- scraped_data() %>% filter(BatterTeam == "COA_CHA") - msg3 <- if (nrow(ch) > 0) { - upload_to_hf(ch, "coastal_hitters", "coastal_hitters_uid_index.csv.gz", "Coastal Hitters") - } else { "Coastal Hitters: No matching rows" } - rm(ch); gc() - - scrape_status_msg(paste(msg1, msg2, msg3, sep = "\n")) - - } else if (input$scrape_source == "pos") { - msg1 <- upload_to_hf(scraped_data(), "pos", "pos_uid_index.csv.gz", "Positional Dataset") - scrape_status_msg(msg1) - - } else if (input$scrape_source == "ncaa") { - msg1 <- upload_to_hf(scraped_data(), "ncaa_pbp", "ncaa_pbp_uid_index.csv.gz", "NCAA PBP Dataset") - scrape_status_msg(msg1) - } -}) - -} - -shinyApp(ui = ui, server = server) \ No newline at end of file