diff --git "a/app.R" "b/app.R" new file mode 100644--- /dev/null +++ "b/app.R" @@ -0,0 +1,9597 @@ +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_gradient(low = "blue", high = "red", name = "Exit Velo", na.value = "grey50") + + 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