Sys.setenv(RETICULATE_PYTHON = "/usr/bin/python3") library(shiny) library(DT) library(dplyr) library(readxl) library(scales) library(readr) library(tidyverse) library(bslib) library(htmltools) library(shinyWidgets) library(ggplot2) library(png) library(arrow) library(grid) library(magick) library(gridExtra) library(gt) library(gtExtras) library(httr) library(ggnewscale) library(fmsb) library(plotly) library(tidymodels) library(xgboost) library(mgcv) library(ggradar) library(cowplot) library(reticulate) PASSWORD <- Sys.getenv("password") download_private_parquet <- function(repo_id, filename, max_retries = 3) { url <- paste0("https://huggingface.co/datasets/", repo_id, "/resolve/main/", filename) api_key <- Sys.getenv("HF_Token") if (api_key == "") stop("API key is not set.") for (attempt in 1:max_retries) { tryCatch({ response <- GET(url, add_headers(Authorization = paste("Bearer", api_key)), timeout(60)) if (status_code(response) == 200) { temp_file <- tempfile(fileext = ".parquet") writeBin(content(response, "raw"), temp_file) data <- read_parquet(temp_file) return(data) } else { warning(paste("Attempt", attempt, "failed with status:", status_code(response))) } }, error = function(e) { warning(paste("Attempt", attempt, "failed:", e$message)) if (attempt < max_retries) Sys.sleep(2) }) } stop(paste("Failed to download dataset after", max_retries, "attempts")) } clean_bind <- function(data) { data <- data %>% mutate( Date = as.Date(Date), across(any_of(c("Time", "Tilt", "UTCTime", "LocalDateTime", "UTCDateTime", "CatchPositionX", "CatchPositionY", "CatchPositionZ", "ThrowPositionX", "ThrowPositionY", "ThrowPositionZ", "BasePositionX", "BasePositionY", "BasePositionZ")), as.character), across(starts_with("ThrowTrajectory"), as.character) ) if ("UTCDate" %in% names(data)) data$UTCDate <- as.Date(data$UTCDate) data } download_master_dataset <- function(repo_id, folder, token = Sys.getenv("HF_Token")) { hf <- reticulate::import("huggingface_hub") api <- hf$HfApi() files <- api$list_repo_files(repo_id = repo_id, repo_type = "dataset", token = token) pq_files <- files[grepl(paste0("^", folder, "/.*\\.parquet$"), files)] if (length(pq_files) == 0) return(data.frame()) all_data <- lapply(pq_files, function(f) { url <- paste0("https://huggingface.co/datasets/", repo_id, "/resolve/main/", f) resp <- httr::GET(url, httr::add_headers(Authorization = paste("Bearer", token))) if (httr::status_code(resp) == 200) { tmp <- tempfile(fileext = ".parquet") writeBin(httr::content(resp, as = "raw"), tmp) d <- arrow::read_parquet(tmp) file.remove(tmp) d } else NULL }) combined <- bind_rows(Filter(Negate(is.null), lapply(all_data, clean_bind))) if ("PitchUID" %in% names(combined)) { combined <- combined %>% distinct(PitchUID, .keep_all = TRUE) } combined } download_private_csv <- function(repo_id, filename, max_retries = 3) { url <- paste0("https://huggingface.co/datasets/", repo_id, "/resolve/main/", filename) api_key <- Sys.getenv("HF_Token") if (api_key == "") stop("API key is not set.") for (attempt in 1:max_retries) { tryCatch({ response <- GET(url, add_headers(Authorization = paste("Bearer", api_key)), timeout(60)) if (status_code(response) == 200) { temp_file <- tempfile(fileext = ".csv") writeBin(content(response, "raw"), temp_file) data <- readr::read_csv(temp_file, show_col_types = FALSE) return(data) } else { warning(paste("Attempt", attempt, "failed with status:", status_code(response))) } }, error = function(e) { warning(paste("Attempt", attempt, "failed:", e$message)) if (attempt < max_retries) Sys.sleep(2) }) } stop(paste("Failed to download dataset after", max_retries, "attempts")) } download_private_rds <- function(repo_id, filename, max_retries = 3) { url <- paste0("https://huggingface.co/datasets/", repo_id, "/resolve/main/", filename) api_key <- Sys.getenv("HF_Token") if (api_key == "") stop("API key is not set.") for (attempt in 1:max_retries) { tryCatch({ response <- GET(url, add_headers(Authorization = paste("Bearer", api_key)), timeout(60)) if (status_code(response) == 200) { temp_file <- tempfile(fileext = ".rds") writeBin(content(response, "raw"), temp_file) data <- readRDS(temp_file) return(data) } else { warning(paste("Attempt", attempt, "failed with status:", status_code(response))) } }, error = function(e) { warning(paste("Attempt", attempt, "failed:", e$message)) if (attempt < max_retries) Sys.sleep(2) }) } stop(paste("Failed to download dataset after", max_retries, "attempts")) } OAA_Model <- download_private_rds("CoastalBaseball/HitterAppFiles", "OAA_MODEL.rds") pitch_colors <- c( "Fastball" = "#FA8072", "Four-Seam" = "#FA8072", "Sinker" = "#fdae61", "Slider" = "#A020F0", "Sweeper" = "magenta", "Curveball" = "#2c7bb6", "ChangeUp" = "#90EE90", "Splitter" = "#90EE32", "Cutter" = "red" ) # ---------- Processing helpers ---------- process_dataset <- function(df) { # --- Name normalization --- if ("Batter" %in% names(df)) { df <- df %>% mutate(Batter = stringr::str_replace(Batter, "^\\s*(\\w+)\\s*,\\s*(\\w+)\\s*$", "\\2 \\1")) } if ("Pitcher" %in% names(df)) { df <- df %>% mutate(Pitcher = stringr::str_replace(Pitcher, "^\\s*(\\w+)\\s*,\\s*(\\w+)\\s*$", "\\2 \\1")) } # --- Dedup --- df <- df %>% distinct() if ("PitchUID" %in% names(df)) { df <- df %>% distinct(PitchUID, .keep_all = TRUE) } else { uniq_cols <- intersect(c("GameID","BatterId","PitcherId","Inning","PAofInning","PitchofPA","PitchNo"), names(df)) if (length(uniq_cols) >= 4) df <- df %>% distinct(across(all_of(uniq_cols)), .keep_all = TRUE) } # --- Date parsing (handles multiple formats) --- if ("Date" %in% names(df)) { df$Date <- suppressWarnings(as.Date(df$Date, format = "%Y-%m-%d")) if (all(is.na(df$Date))) df$Date <- suppressWarnings(as.Date(df$Date, format = "%m/%d/%Y")) if (all(is.na(df$Date))) df$Date <- suppressWarnings(as.Date(df$Date, format = "%m/%d/%y")) if (all(is.na(df$Date))) df$Date <- suppressWarnings(as.Date(df$Date)) } # --- Normalize pitch type --- if ("TaggedPitchType" %in% names(df)) { df$TaggedPitchType <- gsub("^Changeup$", "ChangeUp", df$TaggedPitchType) } # --- Ensure numeric PlateLocHeight/PlateLocSide --- if ("PlateLocSide" %in% names(df)) df$PlateLocSide <- as.numeric(df$PlateLocSide) if ("PlateLocHeight" %in% names(df)) df$PlateLocHeight <- as.numeric(df$PlateLocHeight) # ============================================================ # UNIT DETECTION: Normalize PlateLocHeight/PlateLocSide to FEET # ============================================================ # TrackMan standard: feet (PlateLocHeight ~1.5-4.0, PlateLocSide ~-2.0 to 2.0) # Some datasets (e.g., CoastalHitters2026.parquet) arrive in inches # (PlateLocHeight ~18-48, PlateLocSide ~-24 to 24) # # Detection: if median |PlateLocHeight| > 10, data is in inches -> convert to feet # All indicator thresholds below assume FEET. if ("PlateLocHeight" %in% names(df)) { plh_median <- median(abs(df$PlateLocHeight), na.rm = TRUE) if (!is.na(plh_median) && plh_median > 10) { cat(" [Hitter Unit Detection] PlateLocHeight median abs =", round(plh_median, 1), "-> already in INCHES, converting to feet\n") df$PlateLocHeight <- df$PlateLocHeight / 12 df$PlateLocSide <- df$PlateLocSide / 12 } else { cat(" [Hitter Unit Detection] PlateLocHeight median abs =", round(plh_median, 1), "-> in FEET (standard TrackMan)\n") } } # --- Filter missing data --- if ("RelSpeed" %in% names(df)) df <- df %>% filter(!is.na(RelSpeed)) if ("Date" %in% names(df)) df <- df %>% filter(!is.na(Date)) # --- Ensure TaggedPitchType exists --- 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_ } # --- Filter undefined --- df <- df %>% filter((is.na(PitchCall) | PitchCall != "Undefined") | (is.na(TaggedPitchType) | TaggedPitchType != "Undefined")) # ============================================================ # ALL INDICATORS (feet-based thresholds) # ============================================================ df <- df %>% mutate( # Clean EV outliers ExitSpeed = ifelse(!is.na(ExitSpeed) & !is.na(Angle) & ExitSpeed > 120 & Angle < -10, NA, ExitSpeed), # Core indicators WhiffIndicator = ifelse(PitchCall == "StrikeSwinging", 1, 0), StrikeZoneIndicator = ifelse( !is.na(PlateLocSide) & !is.na(PlateLocHeight) & PlateLocSide >= -0.83 & PlateLocSide <= 0.83 & PlateLocHeight >= 1.5 & PlateLocHeight <= 3.5, 1, 0), SwingIndicator = ifelse(PitchCall %in% c("StrikeSwinging", "FoulBallNotFieldable", "FoulBall", "InPlay"), 1, 0), # Ball in play (excluding bunts) BIPind = ifelse(PitchCall == "InPlay" & (is.na(TaggedHitType) | TaggedHitType != "Bunt"), 1, 0), # At-bat and plate appearance 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), FPindicator = ifelse(Balls == 0 & Strikes == 0, 1, 0), HBPIndicator = ifelse(PitchCall == "HitByPitch", 1, 0), WalkIndicator = ifelse(KorBB == "Walk", 1, 0), # ============================================================ # BATTED BALL QUALITY INDICATORS (these were missing!) # ============================================================ # Launch Angle 10-30 (sweet spot) LA1030ind = ifelse(PitchCall == "InPlay" & !is.na(Angle) & Angle >= 10 & Angle <= 30, 1, 0), # Solid Contact (92+ EV with 8+ LA, or 95+ EV with 0+ LA) SCind = ifelse(PitchCall == "InPlay" & !is.na(ExitSpeed) & !is.na(Angle) & ((ExitSpeed > 95 & Angle >= 0 & Angle <= 35) | (ExitSpeed > 92 & Angle >= 8 & Angle <= 35)), 1, 0), # Hard Hit (95+ EV) HHind = ifelse(PitchCall == "InPlay" & !is.na(ExitSpeed) & ExitSpeed >= 95, 1, 0), # Also keep HHindicator for backward compatibility HHindicator = HHind, # Barrel (95+ EV, 10-32 LA) Barrelind = ifelse(PitchCall == "InPlay" & !is.na(ExitSpeed) & !is.na(Angle) & ExitSpeed >= 95 & Angle >= 10 & Angle <= 32, 1, 0), # Hit type indicators GBindicator = ifelse(PitchCall == "InPlay" & !is.na(TaggedHitType) & TaggedHitType == "GroundBall", 1, 0), LDind = ifelse(PitchCall == "InPlay" & !is.na(TaggedHitType) & TaggedHitType == "LineDrive", 1, 0), FBind = ifelse(PitchCall == "InPlay" & !is.na(TaggedHitType) & TaggedHitType == "FlyBall", 1, 0), Popind = ifelse(PitchCall == "InPlay" & !is.na(TaggedHitType) & TaggedHitType == "Popup", 1, 0), # Zone-based indicators Zwhiffind = ifelse(WhiffIndicator == 1 & StrikeZoneIndicator == 1, 1, 0), Zswing = ifelse(StrikeZoneIndicator == 1 & SwingIndicator == 1, 1, 0), Chaseindicator = ifelse(SwingIndicator == 1 & StrikeZoneIndicator == 0, 1, 0), OutofZone = ifelse(StrikeZoneIndicator == 0, 1, 0), # On-base and total bases OnBaseindicator = ifelse( PlayResult %in% c("Single", "Double", "Triple", "HomeRun") | KorBB == "Walk" | PitchCall == "HitByPitch", 1, 0), totalbases = case_when( PlayResult == "Single" ~ 1, PlayResult == "Double" ~ 2, PlayResult == "Triple" ~ 3, PlayResult == "HomeRun" ~ 4, TRUE ~ 0 ), # Backfill PlayResult PlayResult = ifelse(is.na(PlayResult) | PlayResult == "Undefined", PitchCall, PlayResult) ) # --- Factor pitch types --- if ("TaggedPitchType" %in% names(df)) { df$TaggedPitchType <- factor(df$TaggedPitchType, levels = c("Fastball","Sinker","Cutter","Curveball", "Slider","ChangeUp","Splitter","Knuckleball","Other")) } df } # ---------- xBA Model Functions (DEFINE BEFORE USING) ---------- train_xba_model <- function(data) { suppressPackageStartupMessages({ if (!require("mgcv", quietly = TRUE)) { stop("Package 'mgcv' is required but not installed. Please install it with: install.packages('mgcv')") } }) # Filter for InPlay and remove outliers inplay_data <- data %>% filter(PitchCall == "InPlay") %>% filter(is.na(TaggedHitType) | TaggedHitType != "Bunt") %>% filter(!is.na(ExitSpeed), !is.na(Angle)) %>% filter(ExitSpeed >= 20, ExitSpeed <= 124) %>% filter(Angle >= -90, Angle <= 90) %>% mutate(Hit = if_else( PlayResult %in% c("Single", "Double", "Triple", "HomeRun"), 1, 0 )) if (nrow(inplay_data) < 100) { warning("Insufficient data for xBA model training (need at least 100 in-play events)") return(NULL) } # Fit GAM model tryCatch({ m_gam <- mgcv::gam( Hit ~ s(ExitSpeed, Angle), data = inplay_data, family = binomial() ) return(m_gam) }, error = function(e) { warning(paste("Error training xBA model:", e$message)) return(NULL) }) } add_xba_predictions <- function(data, model) { if (is.null(model)) { return(data %>% mutate(xBA = NA_real_)) } # Add xBA predictions for in-play events data <- data %>% mutate( xBA = if_else( PitchCall == "InPlay" & !is.na(ExitSpeed) & !is.na(Angle) & ExitSpeed >= 20 & ExitSpeed <= 124 & Angle >= -90 & Angle <= 90, predict(model, newdata = ., type = "response"), NA_real_ ) ) return(data) } calculate_hitter_xba_stats <- function(data) { data %>% filter(PitchCall == "InPlay", !is.na(xBA)) %>% group_by(Batter) %>% summarize( BIP_xBA = n(), Hits = sum(HitIndicator, na.rm = TRUE), BA = if_else(BIP_xBA > 0, Hits / BIP_xBA, NA_real_), xBA = mean(xBA, na.rm = TRUE), .groups = "drop" ) } # ---------- Load External Datasets ---------- bio <- download_private_csv("CoastalBaseball/HitterAppFiles", "CCU_Hitter_Bio1.csv") SBC_2025 <- download_private_parquet("CoastalBaseball/HitterAppFiles", "SBC_2025.parquet") P5_2025 <- download_private_parquet("CoastalBaseball/HitterAppFiles", "P5_2025.parquet") IF_OAA <- download_private_parquet("CoastalBaseball/HitterAppFiles", "IF_OAA_DATA.parquet") %>% mutate(obs_player_name = stringr::str_replace_all(obs_player_name, "(\\w+), (\\w+)", "\\2 \\1")) OAA_DF <- as.data.frame(download_private_parquet("CoastalBaseball/HitterAppFiles", "CC_OAA25.parquet")) %>% mutate(obs_player = stringr::str_replace_all(obs_player, "(\\w+), (\\w+)", "\\2 \\1")) college_positions_cc <- as.data.frame(download_private_parquet("CoastalBaseball/HitterAppFiles", "college_positions_cc.parquet")) def_pos_data <- download_private_parquet("CoastalBaseball/HitterAppFiles", "DefensivePositioningData.parquet") P5_2025 <- process_dataset(P5_2025) SBC_2025 <- process_dataset(SBC_2025) sec_teams <- c("ALA_CRI","ARK_RAZ","AUB_TIG","FLA_GAT","GEO_BUL","KEN_WIL","LSU_TIG","OLE_REB", "MSU_BDG","MIZ_TIG","SOU_GAM","TEN_VOL","TEX_AGG","VAN_COM","OKL_SOO","TEX_LON") SEC_2025 <- P5_2025 %>% filter(PitcherTeam %in% sec_teams) # Load Spring 2025 spring_data <- download_private_parquet("CoastalBaseball/HitterAppFiles", "CCUBatters251.parquet") spring_data$Season <- "Spring 2025" # Load Fall 2025 fall_data <- download_private_parquet("CoastalBaseball/HitterAppFiles", "fall_data_batter.parquet") fall_data$Season <- "Fall 2025" # FIX COLUMN NAME DIFFERENCE if ("Top.Bottom" %in% names(fall_data)) { names(fall_data)[names(fall_data) == "Top.Bottom"] <- "Top/Bottom" } # CONVERT ALL POTENTIALLY PROBLEMATIC COLUMNS TO CHARACTER problem_columns <- c( "Time", "Tilt", "UTCTime", "UTCDate", "LocalDateTime", "UTCDateTime", "HomeTeamForeignID", "AwayTeamForeignID", "GameForeignID", "PitcherId", "BatterId", "CatcherId" ) for (col in problem_columns) { if (col %in% names(spring_data)) { spring_data[[col]] <- as.character(spring_data[[col]]) } if (col %in% names(fall_data)) { fall_data[[col]] <- as.character(fall_data[[col]]) } } # Process datasets spring_data <- process_dataset(spring_data) fall_data <- process_dataset(fall_data) spring_data <- spring_data %>% mutate(SpinAxis = suppressWarnings(as.numeric(SpinAxis))) fall_data <- fall_data %>% mutate(SpinAxis = suppressWarnings(as.numeric(SpinAxis))) prespring_data <- download_private_parquet("CoastalBaseball/HitterAppFiles", "prespring_data_batter.parquet") prespring_data$Season <- "Preseason Spring 2026" # Fix column name if needed if ("Top.Bottom" %in% names(prespring_data)) { names(prespring_data)[names(prespring_data) == "Top.Bottom"] <- "Top/Bottom" } # Convert problematic columns to character (use your existing problem_columns variable) for (col in problem_columns) { if (col %in% names(prespring_data)) { prespring_data[[col]] <- as.character(prespring_data[[col]]) } } # Process the dataset prespring_data <- process_dataset(prespring_data) prespring_data <- prespring_data %>% mutate(SpinAxis = suppressWarnings(as.numeric(SpinAxis))) # Load Spring 2026 spring26_data <- download_master_dataset("CoastalBaseball/2026MasterDataset", "coastal_hitters") spring26_data$Season <- "Spring 2026" if ("Top.Bottom" %in% names(spring26_data)) { names(spring26_data)[names(spring26_data) == "Top.Bottom"] <- "Top/Bottom" } for (col in problem_columns) { if (col %in% names(spring26_data)) { spring26_data[[col]] <- as.character(spring26_data[[col]]) } } spring26_data <- process_dataset(spring26_data) spring26_data <- spring26_data %>% mutate(SpinAxis = suppressWarnings(as.numeric(SpinAxis))) normalize_columns <- function(df) { if ("PlayResult" %in% names(df)) { df$PlayResult <- as.character(df$PlayResult) } if ("MeasuredDuration" %in% names(df)) { df$MeasuredDuration <- as.character(df$MeasuredDuration) } df <- df %>% mutate( across(any_of(c( "CatchPositionX", "CatchPositionY", "CatchPositionZ", "ThrowPositionX", "ThrowPositionY", "ThrowPositionZ", "BasePositionX", "BasePositionY", "BasePositionZ", "ThrowTrajectoryXc0", "ThrowTrajectoryXc1", "ThrowTrajectoryXc2", "ThrowTrajectoryYc0", "ThrowTrajectoryYc1", "ThrowTrajectoryYc2", "ThrowTrajectoryZc0", "ThrowTrajectoryZc1", "ThrowTrajectoryZc2", "PitchReleaseConfidence", "PitchLocationConfidence", "PitchMovementConfidence", "HitLaunchConfidence", "HitLandingConfidence", "CatcherThrowCatchConfidence", "CatcherThrowReleaseConfidence", "CatcherThrowLocationConfidence", "BatSpeed", "VerticalAttackAngle", "HorizontalAttackAngle" )), as.character) ) df } all_seasonal_data <- bind_rows( normalize_columns(prespring_data), normalize_columns(spring26_data), normalize_columns(spring_data), normalize_columns(fall_data) ) message("Unique seasons in all_seasonal_data: ", paste(unique(all_seasonal_data$Season), collapse = ", ")) # ---------- TRAIN xBA MODEL ON LARGER DATASET ---------- message("Training xBA model on comprehensive dataset...") # Ensure all time columns are character in P5 and SBC datasets P5_2025_clean <- P5_2025 %>% mutate(across(any_of(problem_columns), as.character)) SBC_2025_clean <- SBC_2025 %>% mutate(across(any_of(problem_columns), as.character)) # Combine CCU data with P5 and SBC for better model training xba_training_data <- bind_rows( normalize_columns(all_seasonal_data), # Your Spring + Fall data (already cleaned) normalize_columns(P5_2025_clean), # Power 5 data (now cleaned) normalize_columns(SBC_2025_clean) # Sun Belt data (now cleaned) ) message("Training xBA model on ", nrow(xba_training_data), " total pitches...") xBA_Model <- train_xba_model(xba_training_data) if (!is.null(xBA_Model)) { inplay_count <- xba_training_data %>% filter(PitchCall == "InPlay", !is.na(ExitSpeed), !is.na(Angle), ExitSpeed >= 20, ExitSpeed <= 124, Angle >= -90, Angle <= 90) %>% nrow() message("xBA model trained successfully with ", inplay_count, " in-play events") message("Training data sources: CCU (Spring + Fall), P5, and SBC conferences") } else { warning("xBA model training failed - xBA predictions will not be available") } # ---------- ADD xBA PREDICTIONS TO ALL DATASETS ---------- message("Adding xBA predictions to datasets...") all_seasonal_data <- add_xba_predictions(all_seasonal_data, xBA_Model) P5_2025 <- add_xba_predictions(P5_2025, xBA_Model) SBC_2025 <- add_xba_predictions(SBC_2025, xBA_Model) SEC_2025 <- add_xba_predictions(SEC_2025, xBA_Model) # Set main data objects data <- all_seasonal_data data2 <- all_seasonal_data BP_data <- download_private_csv("CoastalBaseball/HitterAppFiles", "CCUBP2025.csv") # ---------- BP processing ---------- if (nrow(BP_data) > 0) { BP_data <- BP_data %>% mutate( Batter = stringr::str_replace_all(Batter, "(\\w+), (\\w+)", "\\2 \\1") ) %>% distinct() # Remove duplicate pitches if PitchUID exists if ("PitchUID" %in% names(BP_data)) { BP_data <- BP_data %>% distinct(PitchUID, .keep_all = TRUE) } # Robust date parsing BP_data$Date <- suppressWarnings( as.Date(BP_data$Date, tryFormats = c("%m/%d/%y", "%Y-%m-%d")) ) BP_data <- BP_data %>% filter(!is.na(Date)) # Only proceed if rows remain after filtering if (nrow(BP_data) > 0) { BP_data <- BP_data %>% mutate( # Ball in play indicator BIPind = as.integer( !is.na(ExitSpeed) | !is.na(Angle) | !is.na(Distance) ), # Clean impossible EV readings ExitSpeed = ifelse( !is.na(ExitSpeed) & !is.na(Angle) & ExitSpeed > 120 & Angle < -10, NA, ExitSpeed ), # Launch angle 10–30 LA1030ind = as.integer( BIPind == 1 & !is.na(Angle) & Angle >= 10 & Angle <= 30 ), # Barrel Barrelind = as.integer( BIPind == 1 & !is.na(ExitSpeed) & !is.na(Angle) & ExitSpeed >= 95 & Angle >= 10 & Angle <= 32 ), # Hard hit HHind = as.integer( BIPind == 1 & !is.na(ExitSpeed) & ExitSpeed >= 95 ), # Solid contact SCind = as.integer( BIPind == 1 & !is.na(ExitSpeed) & !is.na(Angle) & ( (ExitSpeed >= 100 & Angle >= 0 & Angle < 8) | (ExitSpeed >= 95 & Angle >= 8 & Angle <= 35) ) ), # Batted ball types GBindicator = as.integer( BIPind == 1 & !is.na(TaggedHitType) & TaggedHitType == "GroundBall" ), LDind = as.integer( BIPind == 1 & !is.na(TaggedHitType) & TaggedHitType == "LineDrive" ), FBind = as.integer( BIPind == 1 & !is.na(TaggedHitType) & TaggedHitType == "FlyBall" ), Popind = as.integer( BIPind == 1 & !is.na(TaggedHitType) & TaggedHitType == "Popup" ) ) } # Standardize pitch type levels if ("TaggedPitchType" %in% names(BP_data)) { BP_data$TaggedPitchType <- factor( BP_data$TaggedPitchType, levels = c( "Fastball","Sinker","Cutter","Curveball", "Slider","ChangeUp","Splitter","Knuckleball","Other" ) ) } } else { BP_data <- data.frame() } # ---------- Small helpers ---------- get_bp_weeks <- function(bp_data) { if (!nrow(bp_data)) return(character(0)) bp_data %>% mutate(Week = format(Date, "%Y-%m-%d")) %>% distinct(Week) %>% arrange(desc(Week)) %>% pull(Week) } get_bp_sessions <- function(bp_data) { if (!nrow(bp_data)) return(character(0)) bp_data %>% distinct(Date) %>% arrange(desc(Date)) %>% mutate(Session = format(Date, "%Y-%m-%d")) %>% pull(Session) } bio_lookup <- setNames(bio$Batter, gsub("^(.*),\\s*(.*)$", "\\2 \\1", bio$Batter)) # ---------- Plot/table creators ---------- create_bp_heatmap <- function(batter_name, bp_data, metric = "Hard-Hit (95+)") { tm_data <- bp_data %>% filter(Batter == batter_name, BIPind == 1, !is.na(PlateLocSide), !is.na(PlateLocHeight)) if (!nrow(tm_data)) { return(ggplot() + theme_void() + ggtitle(paste("No BP data for", batter_name)) + theme(plot.title = element_text(hjust=0.5, size=14, face="bold"))) } # Metric filters if (metric == "Hard-Hit (95+)") { tm_data <- tm_data %>% filter(!is.na(ExitSpeed), ExitSpeed >= 95) title_metric <- "Hard Hit (95+ mph)" } else if (metric == "Medium (90-95)") { tm_data <- tm_data %>% filter(!is.na(ExitSpeed), ExitSpeed >= 90, ExitSpeed < 95) title_metric <- "Medium Contact (90-95 mph)" } else if (metric == "Soft (Under 90)") { tm_data <- tm_data %>% filter(!is.na(ExitSpeed), ExitSpeed < 90) title_metric <- "Soft Contact (Under 90 mph)" } if (!nrow(tm_data)) { return(ggplot() + theme_void() + ggtitle(paste("No", metric, "data for", batter_name)) + theme(plot.title = element_text(hjust=0.5, size=14, face="bold"))) } ggplot(tm_data, aes(x=PlateLocSide, y=PlateLocHeight)) + stat_density_2d(aes(fill=after_stat(density)), geom="raster", contour=FALSE) + scale_fill_gradientn(colours=c("white","#0551bc","#02fbff","#03ff00","#fbff00", "#ffa503","#ff1f02","#dc1100"), name="Density") + annotate("rect", xmin=-0.8303, xmax=0.8303, ymin=1.6, ymax=3.5, fill=NA, color="black", linewidth=1) + 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=.8) + coord_fixed(ratio=1) + xlim(-2,2) + ylim(0,4.5) + ggtitle(paste("BP Heatmap:", title_metric)) + theme_void() + theme(legend.position="right", plot.margin=margin(3,3,3,3), plot.title=element_text(hjust=0.5, size=14, face="bold")) } 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() + ggtitle(paste("No BP spray data for", batter_name)) + theme(plot.title = element_text(hjust=0.5, size=14, face="bold"))) } chart_data %>% ggplot(aes(x,y)) + coord_fixed() + 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=85.095,y=85.095,xend=0,yend=160, curvature=0.36, linewidth=.5, color="black") + annotate("curve", x=-85.095,y=85.095,xend=0,yend=160, curvature=-0.36, linewidth=.5, color="black") + annotate("curve", x=-247.487,y=247.487,xend=247.487,yend=247.487, curvature=-0.65, linewidth=.5, color="black") + geom_point(aes(fill=ExitSpeed), size=4, shape=21, color="black", stroke=.5) + scale_fill_gradient(low="#E1463E", high="#00840D", name="Exit Velo", na.value="grey50") + theme_void() + ylim(0,435) + ggtitle(paste("BP Spray Chart:", batter_name)) + theme(legend.position="right", plot.title=element_text(hjust=0.5, size=14, face="bold")) } create_bp_zone_chart <- 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() + theme_void() + ggtitle(paste("No BP zone data for", batter_name)) + theme(plot.title = element_text(hjust=0.5, size=14, face="bold"))) } ggplot(zone_data, aes(x=PlateLocSide, y=PlateLocHeight)) + geom_point(aes(fill=ExitSpeed), size=4, shape=21, color="black", stroke=.5, alpha=.8) + scale_fill_gradient(low="#E1463E", high="#00840D", name="Exit Velo", na.value="grey50") + annotate("rect", xmin=-0.8303, xmax=0.8303, ymin=1.6, ymax=3.5, fill=NA, color="black", linewidth=1) + 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=.8) + coord_fixed(ratio=1) + xlim(-2,2) + ylim(0,4.5) + ggtitle(paste("BP Zone Plot:", batter_name)) + theme_void() + theme(legend.position="right", plot.title=element_text(hjust=0.5, size=14, face="bold")) } 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() + theme_void() + ggtitle(paste("No BP contact data for", batter_name)) + theme(plot.title = element_text(hjust=0.5, size=14, 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") + annotate("segment", x=8.5,y=8.5,xend=8.5,yend=17, color="black") + annotate("segment", x=-8.5,y=8.5,xend=-8.5,yend=17, color="black") + annotate("segment", x=-8.5,y=8.5,xend=0,yend=0, color="black") + annotate("segment", x=8.5,y=8.5,xend=0,yend=0, color="black") + annotate("rect", xmin=20,xmax=48,ymin=-20,ymax=40, fill=NA, color="black") + annotate("rect", xmin=-48,xmax=-20,ymin=-20,ymax=40, fill=NA, color="black") + annotate("text", x=ifelse(batter_side=="Right",-34,34), y=10, label=ifelse(batter_side=="Right","R","L"), size=8, fontface="bold") + xlim(-50,50) + ylim(-20,50) + geom_point(aes(fill=ExitSpeed), color="black", stroke=.5, shape=21, alpha=.85, size=3) + geom_smooth(aes(color="Optimal Contact"), method="lm", level=0, se=FALSE) + scale_fill_gradient(name="Exit Velo", low="#E1463E", high="#00840D") + scale_color_manual(name=NULL, values=c("Optimal Contact"="black")) + coord_fixed() + ggtitle(paste("BP Contact Points:", batter_name)) + theme_void() + theme(legend.position="right", plot.title=element_text(hjust=0.5, size=14, face="bold")) } get_last_n_games <- function(df, batter, n = 15) { df %>% dplyr::filter(Batter == batter) %>% dplyr::arrange(dplyr::desc(as.Date(Date))) %>% dplyr::distinct(Date, .keep_all = TRUE) %>% dplyr::slice_head(n = n) %>% dplyr::pull(Date) } # Add zone bins with handedness normalization add_zone_bins <- function(df) { df %>% dplyr::mutate( x_rel = dplyr::if_else(BatterSide == "Left", -PlateLocSide, PlateLocSide), y_rel = PlateLocHeight ) %>% { x_breaks <- c(-0.95, -0.3166667, 0.3166667, 0.95) y_breaks <- c(1.6, 2.2333333, 2.8666667, 3.5) dplyr::mutate(., xbin = cut(x_rel, breaks = x_breaks, labels = FALSE, include.lowest = TRUE), ybin = cut(y_rel, breaks = y_breaks, labels = FALSE, include.lowest = TRUE), zone_name = dplyr::case_when( ybin == 1 & xbin == 1 ~ "Down-In", ybin == 1 & xbin == 2 ~ "Down-Middle", ybin == 1 & xbin == 3 ~ "Down-Away", ybin == 2 & xbin == 1 ~ "Middle-In", ybin == 2 & xbin == 2 ~ "Heart", ybin == 2 & xbin == 3 ~ "Middle-Away", ybin == 3 & xbin == 1 ~ "Up-In", ybin == 3 & xbin == 2 ~ "Up-Middle", ybin == 3 & xbin == 3 ~ "Up-Away", TRUE ~ NA_character_ ) ) } } make_color_fn <- function(low, high) { scales::col_numeric(palette = c("#E1463E", "white", "#00840D"), domain = c(low, high)) } make_color_fn_reverse <- function(low, high) { scales::col_numeric(palette = c("#00840D", "white", "#E1463E"), domain = c(low, high)) } create_last15_table <- function(player_name, team_data) { last_games <- get_last_n_games(team_data, player_name, 15) # Last 15 games summary last15_data <- team_data %>% filter(Batter == player_name, Date %in% last_games) %>% summarize( Period = "Last 15 Games", PA = sum(PAindicator, na.rm = TRUE), H = sum(HitIndicator, na.rm = TRUE), BB = sum(WalkIndicator, na.rm = TRUE), K = sum(KorBB == "Strikeout", na.rm = TRUE), AB = sum(ABindicator, na.rm = TRUE), TB = sum(totalbases, na.rm = TRUE), AVG = ifelse(AB > 0, H / AB, NA_real_), OBP = ifelse(PA > 0, (H + BB) / PA, NA_real_), SLG = ifelse(AB > 0, TB / AB, NA_real_), OPS = OBP + SLG, .groups = "drop" ) # Overall season season_data <- team_data %>% filter(Batter == player_name) %>% summarize( Period = "Overall Season", PA = sum(PAindicator, na.rm = TRUE), H = sum(HitIndicator, na.rm = TRUE), BB = sum(WalkIndicator, na.rm = TRUE), K = sum(KorBB == "Strikeout", na.rm = TRUE), AB = sum(ABindicator, na.rm = TRUE), TB = sum(totalbases, na.rm = TRUE), AVG = ifelse(AB > 0, H / AB, NA_real_), OBP = ifelse(PA > 0, (H + BB) / PA, NA_real_), SLG = ifelse(AB > 0, TB / AB, NA_real_), OPS = OBP + SLG, .groups = "drop" ) combined <- bind_rows(last15_data, season_data) if (!nrow(combined)) { return(gt::gt(data.frame(Note = "No data available"))) } gt::gt(combined) %>% gtExtras::gt_theme_guardian() %>% gt::tab_header(title = paste("Recent Performance:", player_name)) %>% gt::fmt_number(columns = c(AVG, OBP, SLG, OPS), decimals = 3) %>% gt::cols_align(align = "center", columns = gt::everything()) %>% gt::sub_missing(columns = gt::everything(), missing_text = "-") %>% gt::tab_options( heading.background.color = "darkcyan", column_labels.background.color = "darkcyan", table.border.top.color = "peru", table.border.bottom.color = "peru" ) %>% gt::tab_style( style = gt::cell_text(color = "white"), locations = gt::cells_title(groups = "title") ) %>% gt::tab_style( style = list(gt::cell_fill(color = "#f0f8e8"), gt::cell_text(weight = "bold")), locations = gt::cells_body(rows = Period == "Last 15 Games") ) %>% gt::tab_style( style = list(gt::cell_fill(color = "#fff3cd"), gt::cell_text(weight = "bold")), locations = gt::cells_body(rows = Period == "Overall Season") ) } create_advanced_numbers_table <- function(player_name, team_data) { df <- team_data %>% filter(Batter == player_name, PitcherThrows %in% c("Left", "Right")) %>% calculate_rv100() if (!nrow(df)) { return(gt::gt(data.frame(Note = "No data available"))) } # Create pitch families df <- df %>% mutate(PitchFamily = case_when( TaggedPitchType %in% c("Fastball", "Four-Seam", "FourSeamFastBall","OneSeamFastBall","Sinker") ~ "Fastballs", TaggedPitchType %in% c("Cutter", "Slider", "Sweeper", "Curveball") ~ "Breaking Balls", TaggedPitchType %in% c("ChangeUp", "Splitter") ~ "Offspeed", TRUE ~ "Other" )) %>% filter(PitchFamily != "Other") if (!nrow(df)) { return(gt::gt(data.frame(Note = "No data available after filtering"))) } # Group by pitch family hitter_summary_group <- df %>% group_by(PitchFamily) %>% summarise( P = n(), `RV` = sum(hitter_rv, na.rm = TRUE), `RV/100` = 100 * mean(hitter_rv, na.rm = TRUE), AVG = sum(HitIndicator, na.rm = TRUE) / sum(ABindicator, na.rm = TRUE), xBA = mean(xBA, na.rm = TRUE), OBP = sum(OnBaseindicator, na.rm = TRUE) / sum(PAindicator, na.rm = TRUE), SLG = sum(totalbases, na.rm = TRUE) / sum(ABindicator, na.rm = TRUE), OPS = OBP + SLG, `10-30` = sum(LA1030ind, na.rm = TRUE) / sum(BIPind, na.rm = TRUE) * 100, SC = sum(SCind, na.rm = TRUE) / sum(BIPind, na.rm = TRUE) * 100, `Chase` = sum(Chaseindicator, na.rm = TRUE) / sum(OutofZone, na.rm = TRUE) * 100, `Whiff` = sum(WhiffIndicator, na.rm = TRUE) / sum(SwingIndicator, na.rm = TRUE) * 100, `Z-Con` = 100 * (1 - sum(Zwhiffind, na.rm = TRUE) / sum(Zswing, na.rm = TRUE)), `AvgEV` = mean(ExitSpeed[PitchCall == "InPlay"], na.rm = TRUE), `EV90` = quantile(ExitSpeed, 0.9, na.rm = TRUE), `HH` = sum(HHind, na.rm = TRUE) / sum(BIPind, na.rm = TRUE) * 100, `GB` = sum(GBindicator, na.rm = TRUE) / sum(BIPind, na.rm = TRUE) * 100, .groups = "drop" ) %>% rename(` ` = PitchFamily) # Count situations hitters_count <- df %>% filter(Balls > Strikes) %>% summarise( ` ` = "Hitter's Count", P = n(), `RV` = sum(hitter_rv, na.rm = TRUE), `RV/100` = 100 * mean(hitter_rv, na.rm = TRUE), AVG = sum(HitIndicator, na.rm = TRUE) / sum(ABindicator, na.rm = TRUE), xBA = mean(xBA, na.rm = TRUE), OBP = sum(OnBaseindicator, na.rm = TRUE) / sum(PAindicator, na.rm = TRUE), SLG = sum(totalbases, na.rm = TRUE) / sum(ABindicator, na.rm = TRUE), OPS = OBP + SLG, `10-30` = sum(LA1030ind, na.rm = TRUE) / sum(BIPind, na.rm = TRUE) * 100, SC = sum(SCind, na.rm = TRUE) / sum(BIPind, na.rm = TRUE) * 100, `Chase` = sum(Chaseindicator, na.rm = TRUE) / sum(OutofZone, na.rm = TRUE) * 100, `Whiff` = sum(WhiffIndicator, na.rm = TRUE) / sum(SwingIndicator, na.rm = TRUE) * 100, `Z-Con` = 100 * (1 - sum(Zwhiffind, na.rm = TRUE) / sum(Zswing, na.rm = TRUE)), `AvgEV` = mean(ExitSpeed[PitchCall == "InPlay"], na.rm = TRUE), `EV90` = quantile(ExitSpeed, 0.9, na.rm = TRUE), `HH` = sum(HHind, na.rm = TRUE) / sum(BIPind, na.rm = TRUE) * 100, `GB` = sum(GBindicator, na.rm = TRUE) / sum(BIPind, na.rm = TRUE) * 100 ) pitchers_count <- df %>% filter(Strikes > Balls) %>% summarise( ` ` = "Pitcher's Count", P = n(), `RV` = sum(hitter_rv, na.rm = TRUE), `RV/100` = 100 * mean(hitter_rv, na.rm = TRUE), AVG = sum(HitIndicator, na.rm = TRUE) / sum(ABindicator, na.rm = TRUE), xBA = mean(xBA, na.rm = TRUE), OBP = sum(OnBaseindicator, na.rm = TRUE) / sum(PAindicator, na.rm = TRUE), SLG = sum(totalbases, na.rm = TRUE) / sum(ABindicator, na.rm = TRUE), OPS = OBP + SLG, `10-30` = sum(LA1030ind, na.rm = TRUE) / sum(BIPind, na.rm = TRUE) * 100, SC = sum(SCind, na.rm = TRUE) / sum(BIPind, na.rm = TRUE) * 100, `Chase` = sum(Chaseindicator, na.rm = TRUE) / sum(OutofZone, na.rm = TRUE) * 100, `Whiff` = sum(WhiffIndicator, na.rm = TRUE) / sum(SwingIndicator, na.rm = TRUE) * 100, `Z-Con` = 100 * (1 - sum(Zwhiffind, na.rm = TRUE) / sum(Zswing, na.rm = TRUE)), `AvgEV` = mean(ExitSpeed[PitchCall == "InPlay"], na.rm = TRUE), `EV90` = quantile(ExitSpeed, 0.9, na.rm = TRUE), `HH` = sum(HHind, na.rm = TRUE) / sum(BIPind, na.rm = TRUE) * 100, `GB` = sum(GBindicator, na.rm = TRUE) / sum(BIPind, na.rm = TRUE) * 100 ) first_pitch <- df %>% filter(Balls == 0, Strikes == 0) %>% summarise( ` ` = "First Pitch", P = n(), `RV` = sum(hitter_rv, na.rm = TRUE), `RV/100` = 100 * mean(hitter_rv, na.rm = TRUE), AVG = sum(HitIndicator, na.rm = TRUE) / sum(ABindicator, na.rm = TRUE), xBA = mean(xBA, na.rm = TRUE), OBP = sum(OnBaseindicator, na.rm = TRUE) / sum(PAindicator, na.rm = TRUE), SLG = sum(totalbases, na.rm = TRUE) / sum(ABindicator, na.rm = TRUE), OPS = OBP + SLG, `10-30` = sum(LA1030ind, na.rm = TRUE) / sum(BIPind, na.rm = TRUE) * 100, SC = sum(SCind, na.rm = TRUE) / sum(BIPind, na.rm = TRUE) * 100, `Chase` = sum(Chaseindicator, na.rm = TRUE) / sum(OutofZone, na.rm = TRUE) * 100, `Whiff` = sum(WhiffIndicator, na.rm = TRUE) / sum(SwingIndicator, na.rm = TRUE) * 100, `Z-Con` = 100 * (1 - sum(Zwhiffind, na.rm = TRUE) / sum(Zswing, na.rm = TRUE)), `AvgEV` = mean(ExitSpeed[PitchCall == "InPlay"], na.rm = TRUE), `EV90` = quantile(ExitSpeed, 0.9, na.rm = TRUE), `HH` = sum(HHind, na.rm = TRUE) / sum(BIPind, na.rm = TRUE) * 100, `GB` = sum(GBindicator, na.rm = TRUE) / sum(BIPind, na.rm = TRUE) * 100 ) # Overall summary hitter_summary <- df %>% summarise( ` ` = "Total", P = n(), `RV` = sum(hitter_rv, na.rm = TRUE), `RV/100` = 100 * mean(hitter_rv, na.rm = TRUE), AVG = sum(HitIndicator, na.rm = TRUE) / sum(ABindicator, na.rm = TRUE), xBA = mean(xBA, na.rm = TRUE), OBP = sum(OnBaseindicator, na.rm = TRUE) / sum(PAindicator, na.rm = TRUE), SLG = sum(totalbases, na.rm = TRUE) / sum(ABindicator, na.rm = TRUE), OPS = OBP + SLG, `10-30` = sum(LA1030ind, na.rm = TRUE) / sum(BIPind, na.rm = TRUE) * 100, SC = sum(SCind, na.rm = TRUE) / sum(BIPind, na.rm = TRUE) * 100, `Chase` = sum(Chaseindicator, na.rm = TRUE) / sum(OutofZone, na.rm = TRUE) * 100, `Whiff` = sum(WhiffIndicator, na.rm = TRUE) / sum(SwingIndicator, na.rm = TRUE) * 100, `Z-Con` = 100 * (1 - sum(Zwhiffind, na.rm = TRUE) / sum(Zswing, na.rm = TRUE)), `AvgEV` = mean(ExitSpeed[PitchCall == "InPlay"], na.rm = TRUE), `EV90` = quantile(ExitSpeed, 0.9, na.rm = TRUE), `HH` = sum(HHind, na.rm = TRUE) / sum(BIPind, na.rm = TRUE) * 100, `GB` = sum(GBindicator, na.rm = TRUE) / sum(BIPind, na.rm = TRUE) * 100 ) # By hand hitter_summary_hand <- df %>% group_by(PitcherThrows) %>% summarise( P = n(), `RV` = sum(hitter_rv, na.rm = TRUE), `RV/100` = 100 * mean(hitter_rv, na.rm = TRUE), AVG = sum(HitIndicator, na.rm = TRUE) / sum(ABindicator, na.rm = TRUE), xBA = mean(xBA, na.rm = TRUE), OBP = sum(OnBaseindicator, na.rm = TRUE) / sum(PAindicator, na.rm = TRUE), SLG = sum(totalbases, na.rm = TRUE) / sum(ABindicator, na.rm = TRUE), OPS = OBP + SLG, `10-30` = sum(LA1030ind, na.rm = TRUE) / sum(BIPind, na.rm = TRUE) * 100, SC = sum(SCind, na.rm = TRUE) / sum(BIPind, na.rm = TRUE) * 100, `Chase` = sum(Chaseindicator, na.rm = TRUE) / sum(OutofZone, na.rm = TRUE) * 100, `Whiff` = sum(WhiffIndicator, na.rm = TRUE) / sum(SwingIndicator, na.rm = TRUE) * 100, `Z-Con` = 100 * (1 - sum(Zwhiffind, na.rm = TRUE) / sum(Zswing, na.rm = TRUE)), `AvgEV` = mean(ExitSpeed[PitchCall == "InPlay"], na.rm = TRUE), `EV90` = quantile(ExitSpeed, 0.9, na.rm = TRUE), `HH` = sum(HHind, na.rm = TRUE) / sum(BIPind, na.rm = TRUE) * 100, `GB` = sum(GBindicator, na.rm = TRUE) / sum(BIPind, na.rm = TRUE) * 100, .groups = "drop" ) %>% rename(` ` = PitcherThrows) # Combine and ensure all numeric columns are numeric final_table <- bind_rows( hitter_summary_group, hitters_count, pitchers_count, first_pitch, hitter_summary_hand, hitter_summary ) %>% arrange(factor(` `, levels = c("Fastballs", "Breaking Balls", "Offspeed", "Hitter's Count", "Pitcher's Count", "First Pitch", "Left", "Right", "Total"))) %>% # CRITICAL FIX: Ensure all numeric columns are actually numeric mutate(across(c(P, RV, `RV/100`, AVG, xBA, OBP, SLG, OPS, `10-30`, SC, Chase, Whiff, `Z-Con`, AvgEV, EV90, HH, GB), as.numeric)) # Create gt table final_table %>% gt::gt() %>% gtExtras::gt_theme_guardian() %>% gt::fmt_number(columns = c(AVG, xBA, OBP, SLG, OPS), decimals = 3) %>% gt::fmt_number(columns = c(`RV`,`RV/100`, `10-30`, SC, Chase, Whiff, `Z-Con`, AvgEV, EV90, HH, GB), decimals = 1) %>% gt::cols_align(align = "center", columns = gt::everything()) %>% gt::sub_missing(columns = gt::everything(), missing_text = "-") %>% gt::tab_options( table.font.size = gt::px(14), data_row.padding = gt::px(6), heading.background.color = "darkcyan", column_labels.background.color = "darkcyan", column_labels.font.size = gt::px(14), table.border.top.color = "peru", table.border.bottom.color = "peru" ) %>% gt::data_color(columns = AVG, fn = scales::col_numeric(palette = c("#E1463E", "white", "#00840D"), domain = NULL)) %>% gt::data_color(columns = xBA, fn = scales::col_numeric(palette = c("#E1463E", "white", "#00840D"), domain = NULL)) %>% gt::data_color(columns = OBP, fn = scales::col_numeric(palette = c("#E1463E", "white", "#00840D"), domain = NULL)) %>% gt::data_color(columns = SLG, fn = scales::col_numeric(palette = c("#E1463E", "white", "#00840D"), domain = NULL)) %>% gt::data_color(columns = OPS, fn = scales::col_numeric(palette = c("#E1463E", "white", "#00840D"), domain = NULL)) %>% gt::data_color( columns = `RV/100`, fn = scales::col_numeric( palette = c("#8B0000", "#E1463E", "#FF6B6B", "white", "#90EE90", "#00840D", "#006400"), domain = c(-8, -4, -2, 0, 2, 5, 8) ) ) %>% gt::data_color(columns = AvgEV, fn = scales::col_numeric(palette = c("#E1463E", "white", "#00840D"), domain = NULL)) %>% gt::data_color(columns = EV90, fn = scales::col_numeric(palette = c("#E1463E", "white", "#00840D"), domain = NULL)) %>% gt::data_color(columns = HH, fn = scales::col_numeric(palette = c("#E1463E", "white", "#00840D"), domain = NULL)) %>% gt::data_color(columns = `Z-Con`, fn = scales::col_numeric(palette = c("#E1463E", "white", "#00840D"), domain = NULL)) %>% gt::data_color(columns = `10-30`, fn = scales::col_numeric(palette = c("#E1463E", "white", "#00840D"), domain = NULL)) %>% gt::data_color(columns = SC, fn = scales::col_numeric(palette = c("#E1463E", "white", "#00840D"), domain = NULL)) %>% gt::data_color(columns = Chase, fn = scales::col_numeric(palette = c("#00840D", "white", "#E1463E"), domain = NULL)) %>% gt::data_color(columns = Whiff, fn = scales::col_numeric(palette = c("#00840D", "white", "#E1463E"), domain = NULL)) %>% gt::data_color(columns = GB, fn = scales::col_numeric(palette = c("#00840D", "white", "#E1463E"), domain = NULL)) } create_fb_splits_table <- function(player_name, team_data) { df <- team_data %>% filter(Batter == player_name, TaggedPitchType %in% c("Fastball", "Four-Seam", "Sinker")) %>% calculate_rv100() %>% add_zone_bins() %>% mutate( fb_ride = InducedVertBreak >= 18, fb_cut = HorzBreak >= -5 & HorzBreak <= 5, fb_soft = RelSpeed <= 88, fb_hard = RelSpeed >= 92, fb_elevated = PlateLocHeight >= 2.8667, fb_sink = InducedVertBreak < 9 & abs(HorzBreak) >= 10, fb_deadzone = InducedVertBreak >= 10 & InducedVertBreak <= 16 & abs(HorzBreak) >= 8 & abs(HorzBreak) <= 15, is_heart = zone_name == "Heart", is_down = ybin == 1, is_in = xbin == 1 ) if (!nrow(df)) { return(gt::gt(data.frame(Note = "No fastball data"))) } calc_stats <- function(data) { data %>% summarise( P = n(), `RV` = sum(hitter_rv, na.rm = TRUE), `RV/100` = 100 * mean(hitter_rv, na.rm = TRUE), BA = sum(HitIndicator, na.rm = TRUE) / sum(ABindicator, na.rm = TRUE), SLG = sum(totalbases, na.rm = TRUE) / sum(ABindicator, na.rm = TRUE), `Whiff%` = sum(WhiffIndicator, na.rm = TRUE) / sum(SwingIndicator, na.rm = TRUE) * 100, `Chase%` = sum(Chaseindicator, na.rm = TRUE) / sum(OutofZone, na.rm = TRUE) * 100, `Z-Whiff` = sum(Zwhiffind, na.rm = TRUE) / sum(Zswing, na.rm = TRUE) * 100, EV = mean(ExitSpeed[PitchCall == "InPlay"], na.rm = TRUE), SC = sum(SCind, na.rm = TRUE) / sum(BIPind, na.rm = TRUE) * 100, `10-30` = sum(LA1030ind, na.rm = TRUE) / sum(BIPind, na.rm = TRUE) * 100, `CalledStrike%` = sum(PitchCall == "StrikeCalled", na.rm = TRUE) / n() * 100 ) } all_fb <- calc_stats(df) %>% mutate(` ` = "All FB") lefty <- calc_stats(df %>% filter(PitcherThrows == "Left")) %>% mutate(` ` = "Lefty") righty <- calc_stats(df %>% filter(PitcherThrows == "Right")) %>% mutate(` ` = "Righty") ride <- calc_stats(df %>% filter(fb_ride)) %>% mutate(` ` = "Ride (18+ IVB)") cut <- calc_stats(df %>% filter(fb_cut)) %>% mutate(` ` = "Cut (-5 to 5 HB)") soft <- calc_stats(df %>% filter(fb_soft)) %>% mutate(` ` = "Soft (≤88)") hard <- calc_stats(df %>% filter(fb_hard)) %>% mutate(` ` = "Hard (≥92)") elevated <- calc_stats(df %>% filter(fb_elevated)) %>% mutate(` ` = "Elevated") sink <- calc_stats(df %>% filter(fb_sink)) %>% mutate(` ` = "Sink") deadzone <- calc_stats(df %>% filter(fb_deadzone)) %>% mutate(` ` = "Deadzone") heart <- calc_stats(df %>% filter(is_heart)) %>% mutate(` ` = "Heart") down <- calc_stats(df %>% filter(is_down)) %>% mutate(` ` = "Down") in_zone <- calc_stats(df %>% filter(is_in)) %>% mutate(` ` = "In") final_table <- bind_rows(all_fb, lefty, righty, ride, cut, soft, hard, elevated, sink, deadzone, heart, down, in_zone) %>% select(` `, everything()) %>% # Ensure all numeric columns are numeric mutate(across(c(P, RV, `RV/100`, BA, SLG, `Whiff%`, `Chase%`, `Z-Whiff`, EV, SC, `10-30`, `CalledStrike%`), as.numeric)) final_table %>% gt::gt() %>% gtExtras::gt_theme_guardian() %>% gt::tab_header(title = "Fastball Splits") %>% gt::fmt_number(columns = c(BA, SLG), decimals = 3) %>% gt::fmt_number(columns = c(`RV`, `RV/100`, `Whiff%`, `Chase%`, `Z-Whiff`, EV, SC, `10-30`, `CalledStrike%`), decimals = 1) %>% gt::cols_align(align = "center", columns = gt::everything()) %>% gt::sub_missing(columns = gt::everything(), missing_text = "-") %>% gt::tab_options( heading.background.color = "darkcyan", column_labels.background.color = "darkcyan", table.border.top.color = "peru", table.border.bottom.color = "peru" ) %>% gt::tab_style( style = gt::cell_text(color = "white"), locations = gt::cells_title(groups = "title") ) %>% gt::data_color(columns = BA, fn = scales::col_numeric(palette = c("#E1463E", "white", "#00840D"), domain = NULL)) %>% gt::data_color(columns = SLG, fn = scales::col_numeric(palette = c("#E1463E", "white", "#00840D"), domain = NULL)) %>% gt::data_color( columns = `RV/100`, fn = scales::col_numeric( palette = c("#E1463E", "white", "#90EE90", "#00840D"), domain = c(-3, 0, 2, 5) ) ) %>% gt::data_color(columns = EV, fn = scales::col_numeric(palette = c("#E1463E", "white", "#00840D"), domain = NULL)) %>% gt::data_color(columns = SC, fn = scales::col_numeric(palette = c("#E1463E", "white", "#00840D"), domain = NULL)) %>% gt::data_color(columns = `10-30`, fn = scales::col_numeric(palette = c("#E1463E", "white", "#00840D"), domain = NULL)) %>% gt::data_color(columns = `Whiff%`, fn = scales::col_numeric(palette = c("#00840D", "white", "#E1463E"), domain = NULL)) %>% gt::data_color(columns = `Chase%`, fn = scales::col_numeric(palette = c("#00840D", "white", "#E1463E"), domain = NULL)) %>% gt::data_color(columns = `Z-Whiff`, fn = scales::col_numeric(palette = c("#00840D", "white", "#E1463E"), domain = NULL)) } create_offspeed_splits_table <- function(player_name, team_data) { df <- team_data %>% filter(Batter == player_name, !TaggedPitchType %in% c("Fastball", "Four-Seam", "Sinker", "Cutter", "Other")) %>% calculate_rv100() %>% mutate( is_breaker = TaggedPitchType %in% c("Slider", "Curveball", "Sweeper"), is_change_split = TaggedPitchType %in% c("ChangeUp", "Splitter"), os_hard = is_breaker & RelSpeed >= 84, os_soft = is_breaker & RelSpeed < 76, os_gyro = InducedVertBreak >= -3 & InducedVertBreak <= 3 & HorzBreak >= -4 & HorzBreak <= 4, os_downer = is_breaker & InducedVertBreak <= -8, os_sweep = is_breaker & abs(HorzBreak) >= 12 ) if (!nrow(df)) { return(gt::gt(data.frame(Note = "No offspeed data"))) } calc_stats <- function(data) { data %>% summarise( P = n(), `RV` = sum(hitter_rv, na.rm = TRUE), `RV/100` = 100 * mean(hitter_rv, na.rm = TRUE), BA = sum(HitIndicator, na.rm = TRUE) / sum(ABindicator, na.rm = TRUE), SLG = sum(totalbases, na.rm = TRUE) / sum(ABindicator, na.rm = TRUE), `Whiff%` = sum(WhiffIndicator, na.rm = TRUE) / sum(SwingIndicator, na.rm = TRUE) * 100, `Chase%` = sum(Chaseindicator, na.rm = TRUE) / sum(OutofZone, na.rm = TRUE) * 100, `Z-Whiff` = sum(Zwhiffind, na.rm = TRUE) / sum(Zswing, na.rm = TRUE) * 100, EV = mean(ExitSpeed[PitchCall == "InPlay"], na.rm = TRUE), SC = sum(SCind, na.rm = TRUE) / sum(BIPind, na.rm = TRUE) * 100, `10-30` = sum(LA1030ind, na.rm = TRUE) / sum(BIPind, na.rm = TRUE) * 100, `CalledStrike%` = sum(PitchCall == "StrikeCalled", na.rm = TRUE) / n() * 100 ) } all_os <- calc_stats(df) %>% mutate(` ` = "All Offspeed") rh_break <- calc_stats(df %>% filter(is_breaker, PitcherThrows == "Right")) %>% mutate(` ` = "RH Break") lh_break <- calc_stats(df %>% filter(is_breaker, PitcherThrows == "Left")) %>% mutate(` ` = "LH Break") rh_ch <- calc_stats(df %>% filter(is_change_split, PitcherThrows == "Right")) %>% mutate(` ` = "RH CH/SPL") lh_ch <- calc_stats(df %>% filter(is_change_split, PitcherThrows == "Left")) %>% mutate(` ` = "LH CH/SPL") sl <- calc_stats(df %>% filter(TaggedPitchType == "Slider")) %>% mutate(` ` = "SL") cb <- calc_stats(df %>% filter(TaggedPitchType == "Curveball")) %>% mutate(` ` = "CB") hard <- calc_stats(df %>% filter(os_hard)) %>% mutate(` ` = "Hard (≥84)") soft <- calc_stats(df %>% filter(os_soft)) %>% mutate(` ` = "Soft (<76)") gyro <- calc_stats(df %>% filter(os_gyro)) %>% mutate(` ` = "Gyro") downer <- calc_stats(df %>% filter(os_downer)) %>% mutate(` ` = "Downer") sweep <- calc_stats(df %>% filter(os_sweep)) %>% mutate(` ` = "Sweep (12+ HB)") final_table <- bind_rows(all_os, rh_break, lh_break, rh_ch, lh_ch, sl, cb, hard, soft, gyro, downer, sweep) %>% select(` `, everything()) %>% # Ensure all numeric columns are numeric mutate(across(c(P, RV, `RV/100`, BA, SLG, `Whiff%`, `Chase%`, `Z-Whiff`, EV, SC, `10-30`, `CalledStrike%`), as.numeric)) final_table %>% gt::gt() %>% gtExtras::gt_theme_guardian() %>% gt::tab_header(title = "Offspeed Splits") %>% gt::fmt_number(columns = c(BA, SLG), decimals = 3) %>% gt::fmt_number(columns = c(`RV`, `RV/100`, `Whiff%`, `Chase%`, `Z-Whiff`, EV, SC, `10-30`, `CalledStrike%`), decimals = 1) %>% gt::cols_align(align = "center", columns = gt::everything()) %>% gt::sub_missing(columns = gt::everything(), missing_text = "-") %>% gt::tab_options( heading.background.color = "darkcyan", column_labels.background.color = "darkcyan", table.border.top.color = "peru", table.border.bottom.color = "peru" ) %>% gt::tab_style( style = gt::cell_text(color = "white"), locations = gt::cells_title(groups = "title") ) %>% gt::data_color(columns = BA, fn = scales::col_numeric(palette = c("#E1463E", "white", "#00840D"), domain = NULL)) %>% gt::data_color(columns = SLG, fn = scales::col_numeric(palette = c("#E1463E", "white", "#00840D"), domain = NULL)) %>% gt::data_color( columns = `RV/100`, fn = scales::col_numeric( palette = c("#E1463E", "white", "#90EE90", "#00840D"), domain = c(-3, 0, 2, 5) ) ) %>% gt::data_color(columns = EV, fn = scales::col_numeric(palette = c("#E1463E", "white", "#00840D"), domain = NULL)) %>% gt::data_color(columns = SC, fn = scales::col_numeric(palette = c("#E1463E", "white", "#00840D"), domain = NULL)) %>% gt::data_color(columns = `10-30`, fn = scales::col_numeric(palette = c("#E1463E", "white", "#00840D"), domain = NULL)) %>% gt::data_color(columns = `Whiff%`, fn = scales::col_numeric(palette = c("#00840D", "white", "#E1463E"), domain = NULL)) %>% gt::data_color(columns = `Chase%`, fn = scales::col_numeric(palette = c("#00840D", "white", "#E1463E"), domain = NULL)) %>% gt::data_color(columns = `Z-Whiff`, fn = scales::col_numeric(palette = c("#00840D", "white", "#E1463E"), domain = NULL)) } create_spray_chart_plotly <- function(batter_name, team_data) { chart_data <- team_data %>% filter(Batter == batter_name, !is.na(Distance), !is.na(Bearing), PitchCall == "InPlay") %>% mutate( event_type = case_when( PlayResult == "Single" ~ "1B", PlayResult == "Double" ~ "2B", PlayResult == "Triple" ~ "3B", PlayResult == "HomeRun" ~ "HR", PlayResult %in% c("Error", "FieldersChoice") ~ "ROE/FC", TRUE ~ "OUT" ), Bearing2 = Bearing * pi / 180, x = Distance * sin(Bearing2), y = Distance * cos(Bearing2), hover_text = paste0( "", event_type, "
", "Date: ", format(Date, "%m/%d/%y"), "
", "EV: ", ifelse(!is.na(ExitSpeed), paste0(round(ExitSpeed, 1), " mph"), "N/A"), "
", "LA: ", ifelse(!is.na(Angle), paste0(round(Angle, 1), "°"), "N/A"), "
", "Dist: ", ifelse(!is.na(Distance), paste0(round(Distance), " ft"), "N/A"), "
", "xBA: ", ifelse(!is.na(xBA), sprintf("%.3f", xBA), "N/A"), "
", "vs ", PitcherThrows, "HP" ) ) if (!nrow(chart_data)) { return(plotly::plot_ly() %>% plotly::layout(title = paste("No hit data for", batter_name))) } event_colors <- c("1B" = "#E41A1C", "2B" = "#FFD700", "3B" = "#CD853F", "HR" = "#008B8B", "ROE/FC" = "#FF8C00", "OUT" = "#A9A9A9") # Field geometry foul_line_left <- data.frame(x = c(0, -247.487), y = c(0, 247.487)) foul_line_right <- data.frame(x = c(0, 247.487), y = c(0, 247.487)) infield <- data.frame(x = c(0, 63.6396, 0, -63.6396, 0), y = c(0, 63.6396, 127.279, 63.6396, 0)) theta <- seq(-pi/4, pi/4, length.out = 50) outfield_arc <- data.frame(x = 350 * sin(theta), y = 350 * cos(theta)) grass_arc <- data.frame(x = 160 * sin(theta), y = 160 * cos(theta)) p <- plot_ly() %>% add_trace(data = foul_line_left, x = ~x, y = ~y, type = 'scatter', mode = 'lines', line = list(color = 'black', width = 1), showlegend = FALSE, hoverinfo = 'none') %>% add_trace(data = foul_line_right, x = ~x, y = ~y, type = 'scatter', mode = 'lines', line = list(color = 'black', width = 1), showlegend = FALSE, hoverinfo = 'none') %>% add_trace(data = infield, x = ~x, y = ~y, type = 'scatter', mode = 'lines', line = list(color = 'black', width = 1), showlegend = FALSE, hoverinfo = 'none') %>% add_trace(data = outfield_arc, x = ~x, y = ~y, type = 'scatter', mode = 'lines', line = list(color = 'black', width = 0.5), showlegend = FALSE, hoverinfo = 'none') %>% add_trace(data = grass_arc, x = ~x, y = ~y, type = 'scatter', mode = 'lines', line = list(color = 'black', width = 0.5, dash = 'dot'), showlegend = FALSE, hoverinfo = 'none') for (evt in unique(chart_data$event_type)) { evt_data <- chart_data %>% filter(event_type == evt) p <- p %>% add_trace( data = evt_data, x = ~x, y = ~y, type = 'scatter', mode = 'markers', marker = list(size = 10, color = event_colors[evt], line = list(color = 'black', width = 1)), text = ~hover_text, hoverinfo = 'text', name = paste0(evt, " (", nrow(evt_data), ")") ) } p %>% layout( title = list(text = paste("Spray Chart:", batter_name), x = 0.5), xaxis = list(title = "", showgrid = FALSE, zeroline = FALSE, showticklabels = FALSE, scaleanchor = "y", scaleratio = 1, range = c(-350, 350)), yaxis = list(title = "", showgrid = FALSE, zeroline = FALSE, showticklabels = FALSE, range = c(-20, 435)), legend = list(orientation = "h", x = 0.5, xanchor = "center", y = -0.05), plot_bgcolor = 'white', paper_bgcolor = 'white', hovermode = 'closest' ) } create_schill_basic_spray <- function(batter_name, team_data) { # ------------------------------------------------------------------- # 1. Filter and prep base data # ------------------------------------------------------------------- chart_data <- team_data %>% dplyr::filter( Batter == batter_name, !is.na(Distance), !is.na(Bearing), PitchCall == "InPlay", PitcherThrows %in% c("Right", "Left") ) %>% dplyr::mutate( event_type = dplyr::case_when( PlayResult == "Single" ~ "1B", PlayResult == "Double" ~ "2B", PlayResult == "Triple" ~ "3B", PlayResult == "HomeRun" ~ "HR", PlayResult %in% c("Error", "FieldersChoice") ~ "ROE", TRUE ~ "OUT" ), Bearing2 = Bearing * pi / 180, x = Distance * sin(Bearing2), y = Distance * cos(Bearing2), hand_label = ifelse(PitcherThrows == "Right", "vs RHP", "vs LHP"), # Ground ball definition (keep as you had it) is_groundball = (!is.na(Angle) & Angle < 10) | (Distance < 150 & (is.na(Angle) | Angle < 15)), # ---------------------------------------------------------------- # 6 spray zones (LF line → RF line) – similar to first chart # angles in degrees, 0 = CF, negative = LF, positive = RF # ---------------------------------------------------------------- zone = dplyr::case_when( Bearing < -30 ~ "LF Line", Bearing >= -30 & Bearing < -15 ~ "LF Gap", Bearing >= -15 & Bearing < 0 ~ "Left-CF", Bearing >= 0 & Bearing < 15 ~ "Right-CF", Bearing >= 15 & Bearing < 30 ~ "RF Gap", Bearing >= 30 ~ "RF Line", TRUE ~ "Other" ) ) if (!nrow(chart_data)) { return( ggplot2::ggplot() + ggplot2::theme_void() + ggplot2::ggtitle(paste("No hit data for", batter_name)) + ggplot2::theme( plot.title = ggplot2::element_text(hjust = 0.5, size = 14, face = "bold") ) ) } # ------------------------------------------------------------------- # 2. Ground-ball subset # ------------------------------------------------------------------- gb_data <- chart_data %>% dplyr::filter(is_groundball) if (nrow(gb_data) == 0) { gb_data <- chart_data %>% dplyr::slice(0) %>% dplyr::mutate(is_groundball = TRUE) } # ------------------------------------------------------------------- # 3. Zone percentages by hand (fixed math) # ------------------------------------------------------------------- zone_levels <- c("LF Line", "LF Gap", "Left-CF", "Right-CF", "RF Gap", "RF Line") zone_stats <- gb_data %>% dplyr::mutate(zone = factor(zone, levels = zone_levels)) %>% dplyr::filter(!is.na(zone)) %>% dplyr::group_by(hand_label) %>% dplyr::mutate(total_gb = dplyr::n()) %>% dplyr::ungroup() %>% dplyr::count(hand_label, zone, total_gb, name = "count") %>% dplyr::group_by(hand_label) %>% dplyr::mutate( pct = round(100 * count / total_gb) ) %>% dplyr::ungroup() # ------------------------------------------------------------------- # 4. Label positions – all on one horizontal band outside infield # ------------------------------------------------------------------- zone_positions <- data.frame( zone = factor(zone_levels, levels = zone_levels), x_pos = c(-130, -80, -35, 35, 80, 130), y_pos = 135 # same y for all labels → straight line above infield ) zone_labels <- dplyr::inner_join(zone_stats, zone_positions, by = "zone") # ------------------------------------------------------------------- # 5. Colors and totals # ------------------------------------------------------------------- event_colors <- c("1B" = "#228B22", "2B" = "#0000CD", "3B" = "#CD853F", "HR" = "#008B8B", "ROE" = "#FFD700", "OUT" = "#808080") gb_totals <- gb_data %>% dplyr::group_by(hand_label) %>% dplyr::summarise(total = dplyr::n(), .groups = "drop") %>% dplyr::mutate(label = paste0("GB: ", total)) full_totals <- chart_data %>% dplyr::group_by(hand_label) %>% dplyr::summarise( BIP = dplyr::n(), Hits = sum(event_type %in% c("1B", "2B", "3B", "HR")), BA = round(Hits / BIP, 3), .groups = "drop" ) %>% dplyr::mutate( label = paste0("BIP: ", BIP, " | BA: ", sprintf('%.3f', BA)) ) p_top <- ggplot(gb_data, aes(x, y)) + # Infield diamond geom_segment(aes(x = 0, y = 0, xend = 63.6396, yend = 63.6396), color = "black", linewidth = 0.8) + geom_segment(aes(x = 0, y = 0, xend = -63.6396, yend = 63.6396), color = "black", linewidth = 0.8) + geom_segment(aes(x = 63.6396, y = 63.6396, xend = 0, yend = 127.279), color = "black", linewidth = 0.8) + geom_segment(aes(x = -63.6396, y = 63.6396, xend = 0, yend = 127.279), color = "black", linewidth = 0.8) + # Extended foul lines geom_segment(aes(x = 0, y = 0, xend = -150, yend = 150), color = "gray50", linewidth = 0.5) + geom_segment(aes(x = 0, y = 0, xend = 150, yend = 150), color = "gray50", linewidth = 0.5) + # Infield grass arc annotate("curve", x = -113.14, y = 113.14, xend = 113.14, yend = 113.14, curvature = -0.45, linewidth = 0.5, color = "gray50", linetype = "dashed") + geom_segment( data = gb_data, aes(x = 0, y = 0, xend = x, yend = y, color = event_type), inherit.aes = FALSE, linetype = "dashed", alpha = 0.35, linewidth = 0.3 ) + # Data points geom_point(aes(fill = event_type), shape = 21, size = 3, color = "black", stroke = 0.5) + # Zone labels geom_label(data = zone_labels, aes(x = x_pos, y = y_pos, label = paste0(pct, "%")), inherit.aes = FALSE, size = 4, fontface = "bold", fill = "white", alpha = 0.8, label.size = 0) + # GB total label geom_text(data = gb_totals, aes(x = 0, y = -10, label = label), inherit.aes = FALSE, size = 3.5, fontface = "bold") + scale_fill_manual(values = event_colors, name = "Result") + scale_color_manual(values = event_colors, guide = "none") + # match trails to points facet_wrap(~hand_label, ncol = 2) + coord_fixed(xlim = c(-160, 160), ylim = c(-20, 165)) + theme_void() + theme( legend.position = "none", strip.text = element_text(size = 12, face = "bold", color = "darkred"), plot.title = element_text(hjust = 0.5, size = 12, face = "bold") ) + labs(title = "Ground Balls - Zone Distribution") # ------------------------------------------------------------------- # 7. BOTTOM – full field spray (unchanged) # ------------------------------------------------------------------- p_bottom <- ggplot2::ggplot(chart_data, ggplot2::aes(x, y)) + ggplot2::geom_segment(ggplot2::aes(x = 0, y = 0, xend = 247.487, yend = 247.487), color = "black") + ggplot2::geom_segment(ggplot2::aes(x = 0, y = 0, xend = -247.487, yend = 247.487), color = "black") + ggplot2::geom_segment(ggplot2::aes(x = 63.6396, y = 63.6396, xend = 0, yend = 127.279), color = "black") + ggplot2::geom_segment(ggplot2::aes(x = -63.6396, y = 63.6396, xend = 0, yend = 127.279), 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_segment( ggplot2::aes(xend = 0, yend = 0, color = event_type), linetype = "dashed", alpha = 0.4, linewidth = 0.3 ) + ggplot2::geom_point( ggplot2::aes(fill = event_type), shape = 21, size = 3.5, color = "black", stroke = 0.5 ) + ggplot2::geom_text( data = full_totals, ggplot2::aes(x = 0, y = -20, label = label), inherit.aes = FALSE, size = 3.5, fontface = "bold" ) + ggplot2::scale_fill_manual(values = event_colors, name = "Result") + ggplot2::scale_color_manual(values = event_colors, guide = "none") + ggplot2::facet_wrap(~hand_label, ncol = 2) + ggplot2::coord_fixed(xlim = c(-280, 280), ylim = c(-40, 380)) + ggplot2::theme_void() + ggplot2::theme( legend.position = "bottom", strip.text = ggplot2::element_text(size = 12, face = "bold", color = "darkred"), plot.title = ggplot2::element_text(hjust = 0.5, size = 12, face = "bold"), legend.title = ggplot2::element_blank() ) + ggplot2::labs(title = "Full Field Spray Chart") # ------------------------------------------------------------------- # 8. Combine # ------------------------------------------------------------------- gridExtra::grid.arrange( p_top, p_bottom, nrow = 2, heights = c(1, 1.2), top = grid::textGrob( paste("Schill Style Spray:", batter_name), gp = grid::gpar(fontsize = 16, font = 2) ) ) } create_sector_spray_chart <- function(batter_name, team_data) { chart_data <- team_data %>% filter( Batter == batter_name, !is.na(Distance), !is.na(Bearing), PitchCall == "InPlay" ) %>% mutate( Bearing2 = Bearing * pi / 180, x = Distance * sin(Bearing2), y = Distance * cos(Bearing2) ) # 5 angle sectors from -45 to +45 (equal width) # boundaries: -45, -27, -9, 9, 27, 45 angle_breaks_deg <- seq(-45, 45, length.out = 6) angle_breaks_rad <- angle_breaks_deg * pi / 180 chart_data <- chart_data %>% mutate( angle_sector = case_when( Bearing >= angle_breaks_deg[1] & Bearing < angle_breaks_deg[2] ~ 1L, Bearing >= angle_breaks_deg[2] & Bearing < angle_breaks_deg[3] ~ 2L, Bearing >= angle_breaks_deg[3] & Bearing < angle_breaks_deg[4] ~ 3L, Bearing >= angle_breaks_deg[4] & Bearing < angle_breaks_deg[5] ~ 4L, Bearing >= angle_breaks_deg[5] & Bearing < angle_breaks_deg[6] ~ 5L, TRUE ~ NA_integer_ ), # 2 distance rings: infield (<150), outfield (>=150) dist_sector = case_when( Distance < 150 ~ 1L, # infield Distance >= 150 ~ 2L, # outfield TRUE ~ NA_integer_ ) ) %>% # Only keep balls that fall in our 10 sectors filter(!is.na(angle_sector), !is.na(dist_sector)) if (!nrow(chart_data)) { return( ggplot() + theme_void() + ggtitle(paste("No hit data for", batter_name)) ) } # Denominator = all BIP inside our 10 sectors total_bip <- nrow(chart_data) # ---------------------------------------------------------- # 2. Sector stats (percentage of total BIP) # ---------------------------------------------------------- sector_stats <- chart_data %>% group_by(angle_sector, dist_sector) %>% summarise(count = n(), .groups = "drop") %>% mutate(pct = round(100 * count / total_bip)) # ---------------------------------------------------------- # 3. Create 5×2 sector polygons # ---------------------------------------------------------- create_sector <- function(r_inner, r_outer, theta_start, theta_end, n = 40) { theta_seq <- seq(theta_start, theta_end, length.out = n) inner_x <- r_inner * sin(theta_seq) inner_y <- r_inner * cos(theta_seq) outer_x <- r_outer * sin(rev(theta_seq)) outer_y <- r_outer * cos(rev(theta_seq)) data.frame(x = c(inner_x, outer_x), y = c(inner_y, outer_y)) } # 2 rings: 0–150 (infield), 150–400 (outfield) dist_breaks <- c(0, 150, 400) sectors <- list() sector_id <- 1 for (i in 1:5) { # angle index for (j in 1:2) { # distance index poly <- create_sector( dist_breaks[j], dist_breaks[j + 1], angle_breaks_rad[i], angle_breaks_rad[i + 1] ) poly$sector_id <- sector_id poly$angle_idx <- i poly$dist_idx <- j sectors[[sector_id]] <- poly sector_id <- sector_id + 1 } } sector_df <- bind_rows(sectors) # ---------------------------------------------------------- # 4. Sector centers & merge stats # ---------------------------------------------------------- sector_centers <- sector_df %>% group_by(sector_id, angle_idx, dist_idx) %>% summarise(cx = mean(x), cy = mean(y), .groups = "drop") %>% left_join( sector_stats %>% rename(angle_idx = angle_sector, dist_idx = dist_sector), by = c("angle_idx", "dist_idx") ) sector_df <- sector_df %>% left_join(sector_centers %>% select(sector_id, pct, count), by = "sector_id") # ---------------------------------------------------------- # 5. Plot 5 OF + 5 IF sectors # ---------------------------------------------------------- ggplot() + geom_polygon( data = sector_df, aes(x = x, y = y, group = sector_id, fill = pct), color = "black", linewidth = 0.5 ) + geom_text( data = sector_centers %>% filter(!is.na(pct) & pct > 0), aes(x = cx, y = cy, label = paste0(pct, "%")), size = 4, fontface = "bold" ) + # foul lines geom_segment(aes(x = 0, y = 0, xend = 247.487, yend = 247.487), color = "black", linewidth = 1) + geom_segment(aes(x = 0, y = 0, xend = -247.487, yend = 247.487), color = "black", linewidth = 1) + annotate( "text", x = 260, y = 360, label = paste("BIP:", total_bip), size = 4, hjust = 0, fontface = "bold" ) + scale_fill_gradientn( colors = c( "white", # 0% "#e5fbe5", # ~2% "#90EE90", # ~5% "#66c266", # ~10% "#228B22", # ~15% "#145a14", # ~20% "#006400" # ~25%+ ), values = scales::rescale(c(0, 3, 5, 10, 15, 20, 25)), limits = c(0, NA), na.value = "white", name = "% of BIP" ) + coord_fixed(xlim = c(-320, 320), ylim = c(-20, 420)) + theme_void() + theme( legend.position = "right", plot.title = element_text(hjust = 0.5, size = 16, face = "bold") ) + labs(title = paste("Sector Spray Chart:", batter_name)) } # 4. TRAJECTORY SPRAY CHART create_trajectory_spray_chart <- function(batter_name, team_data) { chart_data <- team_data %>% filter(Batter == batter_name, !is.na(Distance), !is.na(Bearing), PitchCall == "InPlay") %>% mutate( event_type = case_when( PlayResult == "Single" ~ "1B", PlayResult == "Double" ~ "2B", PlayResult == "Triple" ~ "3B", PlayResult == "HomeRun" ~ "HR", PlayResult %in% c("Error", "FieldersChoice") ~ "ROE", TRUE ~ "OUT" ), is_hit = event_type %in% c("1B", "2B", "3B", "HR"), Bearing2 = Bearing * pi / 180, x = Distance * sin(Bearing2), y = Distance * cos(Bearing2), row_id = row_number() ) if (!nrow(chart_data)) { return(ggplot() + theme_void() + ggtitle(paste("No hit data for", batter_name))) } # Create curves create_curve_df <- function(x_end, y_end, is_hit, id, n = 20) { t <- seq(0, 1, length.out = n) arc_height <- if (is_hit) 0.35 else 0.15 cx <- x_end * 0.5 cy <- y_end * 0.5 + sqrt(x_end^2 + y_end^2) * arc_height x <- (1-t)^2 * 0 + 2*(1-t)*t * cx + t^2 * x_end y <- (1-t)^2 * 0 + 2*(1-t)*t * cy + t^2 * y_end data.frame(x = x, y = y, traj_id = id) } trajectories <- do.call(rbind, lapply(1:nrow(chart_data), function(i) { row <- chart_data[i, ] df <- create_curve_df(row$x, row$y, row$is_hit, row$row_id) df$event_type <- row$event_type df })) hit_colors <- c("1B" = "#E41A1C", "2B" = "#377EB8", "3B" = "#4DAF4A", "HR" = "#984EA3", "ROE" = "#FF7F00", "OUT" = "#999999") ggplot() + annotate("polygon", x = c(0, -350, -350, 350, 350, 0), y = c(0, 350, 420, 420, 350, 0), fill = "#90EE90", alpha = 0.3) + annotate("polygon", x = c(0, -90, -90, 90, 90, 0), y = c(0, 90, 160, 160, 90, 0), fill = "#D2B48C", alpha = 0.4) + geom_segment(aes(x = 0, y = 0, xend = 320, yend = 320), color = "black", linewidth = 1) + geom_segment(aes(x = 0, y = 0, xend = -320, yend = 320), color = "black", linewidth = 1) + geom_segment(aes(x = 63.6396, y = 63.6396, xend = 0, yend = 127.279), color = "black") + geom_segment(aes(x = -63.6396, y = 63.6396, xend = 0, yend = 127.279), color = "black") + annotate("point", x = 0, y = 0, shape = 23, size = 4, fill = "white", color = "black") + geom_path(data = trajectories, aes(x = x, y = y, group = traj_id, color = event_type), linewidth = 0.6, alpha = 0.7) + geom_point(data = chart_data, aes(x = x, y = y, fill = event_type), shape = 21, size = 4, color = "black", stroke = 0.5) + scale_color_manual(values = hit_colors, guide = "none") + scale_fill_manual(values = hit_colors, name = "Result") + coord_fixed(xlim = c(-350, 350), ylim = c(-30, 420)) + theme_void() + theme(legend.position = "right", plot.title = element_text(hjust = 0.5, size = 16, face = "bold"), panel.background = element_rect(fill = "white", color = NA)) + labs(title = paste("Trajectory Spray Chart:", batter_name)) } # 5. TWO-STRIKE SPRAY CHART create_2k_spray_chart <- function(batter_name, team_data) { chart_data <- team_data %>% filter(Batter == batter_name, !is.na(Distance), !is.na(Bearing), PitchCall == "InPlay") %>% mutate( count_situation = ifelse(Strikes == 2, "2 Strikes", "0-1 Strikes"), event_type = case_when( PlayResult == "Single" ~ "1B", PlayResult == "Double" ~ "2B", PlayResult == "Triple" ~ "3B", PlayResult == "HomeRun" ~ "HR", PlayResult %in% c("Error", "FieldersChoice") ~ "ROE", TRUE ~ "OUT" ), Bearing2 = Bearing * pi / 180, x = Distance * sin(Bearing2), y = Distance * cos(Bearing2) ) if (!nrow(chart_data)) { return(ggplot() + theme_void() + ggtitle(paste("No hit data for", batter_name))) } situation_stats <- chart_data %>% group_by(count_situation) %>% summarize( BIP = n(), Hits = sum(event_type %in% c("1B", "2B", "3B", "HR")), BA = round(Hits / BIP, 3), Avg_EV = round(mean(ExitSpeed, na.rm = TRUE), 1), .groups = "drop" ) %>% mutate(label = paste0("BIP: ", BIP, " | BA: ", sprintf("%.3f", BA), " | EV: ", Avg_EV)) event_colors <- c("1B" = "#228B22", "2B" = "#0000CD", "3B" = "#CD853F", "HR" = "#008B8B", "ROE" = "#FFD700", "OUT" = "#808080") ggplot(chart_data, aes(x, y)) + geom_segment(aes(x = 0, y = 0, xend = 247.487, yend = 247.487), color = "black") + geom_segment(aes(x = 0, y = 0, xend = -247.487, yend = 247.487), color = "black") + geom_segment(aes(x = 63.6396, y = 63.6396, xend = 0, yend = 127.279), color = "black") + geom_segment(aes(x = -63.6396, y = 63.6396, xend = 0, yend = 127.279), 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_segment(aes(xend = 0, yend = 0, color = event_type), linetype = "dashed", alpha = 0.3, linewidth = 0.3) + geom_point(aes(fill = event_type), shape = 21, size = 3, color = "black", stroke = 0.5) + geom_text(data = situation_stats, aes(x = 0, y = -15, label = label), size = 3.5, fontface = "bold") + scale_fill_manual(values = event_colors, name = "Result") + scale_color_manual(values = event_colors, guide = "none") + facet_wrap(~count_situation, ncol = 2) + coord_fixed(xlim = c(-280, 280), ylim = c(-40, 380)) + theme_void() + theme(legend.position = "bottom", strip.text = element_text(size = 14, face = "bold"), plot.title = element_text(hjust = 0.5, size = 16, face = "bold")) + labs(title = paste("2-Strike Spray Chart:", batter_name)) } # At-Bat breakdown for Game View create_at_bats_plot <- function(data, player, game_key, pitch_colors = NULL) { df <- data %>% filter(Batter == player) if ("game" %in% names(df)) { df <- df %>% filter(game == game_key) } else if ("Date" %in% names(df)) { df <- df %>% mutate(.game = format(Date, "%Y-%m-%d")) %>% filter(.game == game_key) } plot_data <- df %>% dplyr::filter(!is.na(TaggedPitchType), TaggedPitchType != "Other") %>% dplyr::arrange(Date, Inning, PAofInning, PitchofPA) %>% dplyr::group_by(Date, Inning, PAofInning) %>% dplyr::mutate(pa_number = dplyr::cur_group_id()) %>% dplyr::ungroup() %>% dplyr::mutate( PitchCall_display = dplyr::case_when( PitchCall == "StrikeSwinging" ~ "Whiff", PitchCall == "InPlay" ~ "In Play", PitchCall %in% c("FoulBall", "FoulBallNotFieldable", "FoulBallFieldable") ~ "Foul", PitchCall %in% c("BallCalled", "BallinDirt", "BallIntentional") ~ "Ball", PitchCall == "StrikeCalled" ~ "CS", PitchCall == "HitByPitch" ~ "HBP", TRUE ~ PitchCall ) ) if (!nrow(plot_data)) { return(ggplot() + theme_void() + ggtitle(paste("No PA data for", player, "in this game")) + theme(plot.title = element_text(hjust = 0.5, size = 14, face = "bold"))) } p <- ggplot2::ggplot(plot_data, ggplot2::aes(PlateLocSide, PlateLocHeight)) + ggplot2::geom_rect(xmin = -0.95, xmax = 0.95, ymin = 1.6, ymax = 3.5, fill = NA, color = "black", linewidth = 1) + ggplot2::geom_point(ggplot2::aes(fill = TaggedPitchType), alpha = 1, shape = 21, color = "black", stroke = 0.5, size = 6) + ggplot2::geom_text(ggplot2::aes(label = PitchofPA), vjust = 0.5, size = 3.5, color = "white") + ggplot2::geom_text(ggplot2::aes(x = 1.5, y = 4.2 - (PitchofPA * 0.15), label = dplyr::if_else(PitchCall == "InPlay", paste0(PitchofPA, ": ", PlayResult), paste0(PitchofPA, ": ", PitchCall_display))), inherit.aes = FALSE, size = 2.5, hjust = 0) + ggplot2::geom_text(ggplot2::aes(x = 1.5, y = 4.2 - (PitchofPA * 0.15) - 0.12, label = dplyr::if_else(PitchCall == "InPlay" & !is.na(ExitSpeed), paste0(round(ExitSpeed), " EV"), "")), inherit.aes = FALSE, size = 2.5, hjust = 0) + ggplot2::geom_text(ggplot2::aes( x = dplyr::if_else(BatterSide == "Right", -1.5, 1.5), y = 2.5, label = dplyr::if_else(BatterSide == "Right", "R", "L")), size = 4, fontface = "bold") + ggplot2::facet_wrap(~ pa_number, ncol = 5) + ggplot2::theme_void() + ggplot2::scale_x_continuous("", limits = c(-2, 2.5)) + ggplot2::scale_y_continuous("", limits = c(0.5, 4.5)) + ggplot2::coord_fixed() if (!is.null(pitch_colors)) { p <- p + ggplot2::scale_fill_manual(values = pitch_colors, name = "Pitch Type") } else { p <- p + ggplot2::guides(fill = ggplot2::guide_legend(title = "Pitch Type")) } p + ggplot2::ggtitle(paste("At-Bat Breakdown:", player)) + ggplot2::theme( panel.background = ggplot2::element_rect(fill = "#ffffff"), legend.position = "top", plot.title = ggplot2::element_text(size = 14, hjust = 0.5, face = "bold"), strip.text = ggplot2::element_text(size = 10, face = "bold"), strip.placement = "outside", strip.background = ggplot2::element_blank() ) } create_spray_chart <- function(batter_name, team_data) { chart_data <- team_data %>% filter(Batter == batter_name, !is.na(Distance), !is.na(Bearing), PitchCall == "InPlay") %>% mutate( event_type = case_when( PlayResult == "Single" ~ "Single", PlayResult == "Double" ~ "Double", PlayResult == "Triple" ~ "Triple", PlayResult == "HomeRun" ~ "Home Run", PlayResult %in% c("Error","FieldersChoice") ~ "Error/FC", TRUE ~ "Out" ), Bearing2 = Bearing * pi/180, x = Distance*sin(Bearing2), y = Distance*cos(Bearing2) ) if (!nrow(chart_data)) { return(ggplot() + theme_void() + ggtitle(paste("No hit data for", batter_name)) + theme(plot.title = element_text(hjust=0.5, size=14, face="bold"))) } chart_data %>% ggplot(aes(x,y, fill=event_type)) + scale_fill_manual(values=c("Single"="red","Double"="gold","Triple"="peru","Home Run"="darkcyan", "Error/FC"="orange","Out"="grey70"), name=NULL) + coord_fixed() + geom_segment(aes(x=0,y=0,xend=247.487,yend=247.487), color="black") + geom_segment(aes(x=0,y=0,xend=-247.487,yend=247.487), color="black") + geom_segment(aes(x=63.6396,y=63.6396,xend=0,yend=127.279), color="black") + geom_segment(aes(x=-63.6396,y=63.6396,xend=0,yend=127.279), color="black") + geom_curve(aes(x=85.095,y=85.095,xend=0,yend=160), curvature=.36, linewidth=.5, color="black") + geom_curve(aes(x=-85.095,y=85.095,xend=0,yend=160), curvature=-.36, linewidth=.5, color="black") + geom_curve(aes(x=-247.487,y=247.487,xend=247.487,yend=247.487), curvature=-.65, linewidth=.5, color="black") + geom_point(size=4, shape=21, color="black", stroke=.5) + theme_void() + ylim(0,435) + ggtitle(paste("Spray Chart:", batter_name)) + theme(legend.position="right", legend.title=element_blank(), plot.title=element_text(hjust=0.5, size=14, face="bold")) } create_quality_table <- function(team_df, player) { df <- team_df %>% filter(Batter == player) Pitches <- nrow(df) PA <- sum(df$PAindicator, na.rm = TRUE) BBE <- sum(df$PitchCall == "InPlay", na.rm = TRUE) safe_div <- function(num, den) ifelse(den > 0, num/den, NA_real_) tibble::tibble( Pitches = Pitches, PA = PA, `Batted Balls` = BBE, `SwSpot%` = 100 * safe_div(sum(df$PitchCall == "InPlay" & df$Angle >= 8 & df$Angle <= 32, na.rm = TRUE), sum(df$PitchCall == "InPlay", na.rm = TRUE)), `Z-Con%` = 100 * (1 - mean(df$Zwhiffind, na.rm = TRUE)) ) } create_player_header <- function(df, player) { to_first_last <- function(x) sub("^\\s*([^,]+),\\s*(.+)\\s*$", "\\2 \\1", x) norm <- function(x) { x <- ifelse(grepl(",", x), to_first_last(x), x); trimws(gsub("\\s+", " ", x)) } row <- df %>% mutate(.key = norm(Batter)) %>% filter(.key == norm(player)) if (!nrow(row)) return(grid::textGrob(paste("No bio found for", player), gp=gpar(fontface="bold", cex=1.2))) row <- row[1,] safe_read_grob <- function(path_or_url) { if (is.null(path_or_url) || is.na(path_or_url) || !nzchar(path_or_url)) return(nullGrob()) im <- try(magick::image_read(path_or_url), silent = TRUE) if (inherits(im, "try-error")) im <- magick::image_blank(400, 400, color = "white") rasterGrob(as.raster(im), interpolate = TRUE) } headshot_g <- safe_read_grob(row$Headshot) state_g <- safe_read_grob(row$State) if (!inherits(state_g, "grob")) state_g <- nullGrob() state_g <- editGrob(state_g, gp = gpar(alpha = 0.55)) col_primary <- "#24384A"; col_muted <- "#6B7785" player_name <- norm(row$Batter) position <- if ("Position" %in% names(row)) { ifelse(nzchar(row$Position), row$Position, "N/A") } else { "N/A" } jersey <- if ("Number" %in% names(row) && nzchar(row$Number)) { paste0("# ", row$Number) } else { "" } title_text <- paste(c(player_name, jersey, if (position != "N/A") paste("•", position) else NULL), collapse = " ") title_g <- textGrob(title_text, x=0, y=unit(1,"npc"), just=c("left","top"), gp=gpar(fontface="bold", cex=2.2, col=col_primary)) val <- function(n) { if (n %in% names(row) && nzchar(row[[n]])) row[[n]] else "N/A" } subline_g <- textGrob(paste(val("Class"), "•", val("Hometown")), x=0, just="left", gp=gpar(fontface="italic", cex=1.25, col=col_muted)) bats_throws <- textGrob(paste0("Bats: ", val("Bats"), " | Throws: ", val("Throws")), x=0, just="left", gp=gpar(cex=1.2, col=col_primary)) weight_text <- if (val("Weight") == "N/A") "N/A" else paste0(val("Weight"), " lbs") phys_g <- textGrob(paste0(val("Height"), " • ", weight_text, " • Born: ", val("Birthday")), x=0, just="left", gp=gpar(cex=1.2, col=col_primary)) text_block <- arrangeGrob(title_g, subline_g, bats_throws, phys_g, ncol=1, heights=unit.c(unit(1.5,"lines"), unit(1.2,"lines"), unit(1.2,"lines"), unit(1.2,"lines"))) arrangeGrob( grobs = list(grobTree(nullGrob(), headshot_g), text_block, grobTree(nullGrob(), state_g)), ncol = 3, widths = unit.c(unit(150,"pt"), unit(1,"null"), unit(300,"pt")), heights = unit(160,"pt") ) } create_bb_profile_table <- function(team_df, player) { safe_div <- function(num, den) ifelse(den > 0, num/den, NA_real_) pct1 <- function(x) sprintf("%.1f", 100*x) df <- team_df %>% mutate(Family = case_when( TaggedPitchType %in% c("Fastball","Four-Seam","Cutter","Sinker") ~ "Fastballs", TaggedPitchType %in% c("Slider","Sweeper","Curveball") ~ "Breaking Balls", TRUE ~ "Offspeed" )) %>% filter(Batter == player) # Fallback if AutoHitType is missing; use TaggedHitType if (!"AutoHitType" %in% names(df)) { df <- df %>% mutate(AutoHitType = ifelse(!is.na(TaggedHitType), TaggedHitType, NA)) } summarize_block <- function(x) { BBE <- sum(x$PitchCall == "InPlay", na.rm = TRUE) tibble::tibble( BBE = BBE, `GB%` = pct1(safe_div(sum(x$AutoHitType == "GroundBall" & x$PitchCall == "InPlay", na.rm = TRUE), BBE)), `Air%` = pct1(1 - safe_div(sum(x$AutoHitType == "GroundBall" & x$PitchCall == "InPlay", na.rm = TRUE), BBE)), `LD%` = pct1(safe_div(sum(x$AutoHitType == "LineDrive" & x$PitchCall == "InPlay", na.rm = TRUE), BBE)), `FB%` = pct1(safe_div(sum(x$AutoHitType == "FlyBall" & x$PitchCall == "InPlay", na.rm = TRUE), BBE)), `PU%` = pct1(safe_div(sum(x$AutoHitType == "Popup" & x$PitchCall == "InPlay", na.rm = TRUE), BBE)), `Pull%` = pct1(safe_div(sum(ifelse(x$BatterSide == "Right", x$Bearing < -15, x$Bearing > 15) & x$PitchCall == "InPlay", na.rm = TRUE), BBE)), `Straight%` = pct1(safe_div(sum(x$Bearing >= -15 & x$Bearing <= 15 & x$PitchCall == "InPlay", na.rm = TRUE), BBE)), `Oppo%` = pct1(safe_div(sum(ifelse(x$BatterSide == "Right", x$Bearing > 15, x$Bearing < -15) & x$PitchCall == "InPlay", na.rm = TRUE), BBE)) ) } fam <- df %>% group_by(Family) %>% group_modify(~summarize_block(.x)) %>% ungroup() %>% rename(` ` = Family) overall <- summarize_block(df) %>% mutate(` ` = "-") by_hand <- df %>% group_by(PitcherThrows) %>% group_modify(~summarize_block(.x)) %>% ungroup() %>% rename(` ` = PitcherThrows) bind_rows(fam, overall, by_hand) } create_player_table_split <- function(player_name, team_data) { tm_data <- team_data %>% filter(Batter == player_name) if (!nrow(tm_data)) return(NULL) formatted_name <- stringr::str_replace_all(player_name, "(\\w+), (\\w+)", "\\2 \\1") handedness <- unique(tm_data$BatterSide)[1] if (is.na(handedness)) handedness <- "Unknown" calculate_stats <- function(data) { data %>% summarise( AVG = ifelse(sum(ABindicator, na.rm = TRUE) > 0, sum(HitIndicator, na.rm = TRUE)/sum(ABindicator, na.rm = TRUE), NA), OBP = ifelse(sum(PAindicator, na.rm = TRUE) > 0, sum(OnBaseindicator, na.rm = TRUE)/sum(PAindicator, na.rm = TRUE), NA), SLG = ifelse(sum(ABindicator, na.rm = TRUE) > 0, sum(totalbases, na.rm = TRUE)/sum(ABindicator, na.rm = TRUE), NA), OPS = ifelse(!is.na(OBP) & !is.na(SLG), OBP + SLG, NA), CHASE = ifelse(sum(OutofZone, na.rm = TRUE) > 0, sum(Chaseindicator, na.rm = TRUE)/sum(OutofZone, na.rm = TRUE) * 100, NA), WHIFF = ifelse(sum(SwingIndicator, na.rm = TRUE) > 0, sum(WhiffIndicator, na.rm = TRUE)/sum(SwingIndicator, na.rm = TRUE) * 100, NA), AVG_EV = mean(ExitSpeed[PitchCall == "InPlay" & TaggedHitType != "Bunt"], na.rm = TRUE), EV90 = stats::quantile(ExitSpeed[PitchCall == "InPlay"], 0.9, na.rm = TRUE), HH = ifelse(sum(BIPind, na.rm = TRUE) > 0, sum(HHind, na.rm = TRUE)/sum(BIPind, na.rm = TRUE) * 100, NA), GB = ifelse(sum(BIPind, na.rm = TRUE) > 0, sum(GBindicator, na.rm = TRUE)/sum(BIPind, na.rm = TRUE) * 100, NA), LAUNCH_ANGLE = mean(Angle[PitchCall == "InPlay"], na.rm = TRUE), FIRST_PITCH_SWING = ifelse(sum(FPindicator, na.rm = TRUE) > 0, sum(FPindicator == 1 & SwingIndicator == 1, na.rm = TRUE)/sum(FPindicator, na.rm = TRUE) * 100, NA), .groups = "drop" ) } create_single_table <- function(data, pitcher_hand) { if (!nrow(data)) return(NULL) pitch_stats <- data %>% mutate(pitch_group = case_when( TaggedPitchType %in% c("Fastball","Sinker") ~ "Fastballs", TaggedPitchType %in% c("ChangeUp","Changeup","Splitter") ~ "Offspeed", TaggedPitchType %in% c("Slider","Curveball","Cutter") ~ "Breaking Balls", TRUE ~ "Other" )) %>% filter(pitch_group != "Other") %>% group_by(pitch_group) %>% calculate_stats() %>% mutate(CATEGORY = pitch_group) %>% select(CATEGORY, everything(), -pitch_group) count_stats <- data %>% mutate(count_situation = case_when( Balls == 0 & Strikes == 0 ~ "First Pitch", Balls > Strikes ~ "Hitter's Count", Strikes > Balls ~ "Pitcher's Count", Balls == Strikes ~ "Even Count", TRUE ~ "Other" )) %>% filter(count_situation != "Other") %>% group_by(count_situation) %>% calculate_stats() %>% mutate(CATEGORY = count_situation) %>% select(CATEGORY, everything(), -count_situation) overall_stats <- data %>% calculate_stats() %>% mutate(CATEGORY = "Overall") all_stats <- bind_rows(pitch_stats, count_stats, overall_stats) %>% arrange(factor(CATEGORY, levels = c("Breaking Balls","Fastballs","Offspeed", "First Pitch","Hitter's Count","Pitcher's Count","Even Count","Overall"))) %>% mutate(across(c(AVG,OBP,SLG,OPS,CHASE,WHIFF,AVG_EV,EV90,HH,GB,LAUNCH_ANGLE,FIRST_PITCH_SWING), ~ case_when(is.infinite(.x) ~ NA_real_, is.nan(.x) ~ NA_real_, TRUE ~ .x))) overall_k_bb <- data %>% summarise( K_RATE = ifelse(sum(PAindicator, na.rm = TRUE) > 0, sum(KorBB == "Strikeout", na.rm = TRUE)/sum(PAindicator, na.rm = TRUE) * 100, 0), BB_RATE = ifelse(sum(PAindicator, na.rm = TRUE) > 0, sum(WalkIndicator, na.rm = TRUE)/sum(PAindicator, na.rm = TRUE) * 100, 0), TOTAL_PA = sum(PAindicator, na.rm = TRUE) ) gt::gt(all_stats) %>% gt::fmt_number(columns = c(AVG,OBP,SLG,OPS), decimals = 3) %>% gt::fmt_number(columns = c(CHASE,WHIFF,AVG_EV,EV90,HH,GB,LAUNCH_ANGLE,FIRST_PITCH_SWING), decimals = 1) %>% gt::tab_header(title = gt::md(paste0("**vs ", pitcher_hand, "HP**")), subtitle = paste0("PAs: ", overall_k_bb$TOTAL_PA, " | K%: ", round(overall_k_bb$K_RATE,1), "% | BB%: ", round(overall_k_bb$BB_RATE,1), "%")) %>% gt::cols_label(CATEGORY="Category", AVG="AVG", OBP="OBP", SLG="SLG", OPS="OPS", CHASE="Chase%", WHIFF="Whiff%", AVG_EV="Avg EV", EV90="90th%", HH="HH%", GB="GB%", LAUNCH_ANGLE="LA", FIRST_PITCH_SWING="1st Pitch%") %>% gt::tab_style(style = list(gt::cell_fill(color="#f8f9fa"), gt::cell_text(weight="bold")), locations = gt::cells_body(rows = CATEGORY %in% c("Breaking Balls","Fastballs","Offspeed"))) %>% gt::tab_style(style = list(gt::cell_fill(color="#f0f8e8"), gt::cell_text(weight="bold")), locations = gt::cells_body(rows = CATEGORY %in% c("First Pitch","Hitter's Count","Pitcher's Count","Even Count"))) %>% gt::tab_style(style = list(gt::cell_fill(color="#fff3cd"), gt::cell_text(weight="bold")), locations = gt::cells_body(rows = CATEGORY == "Overall")) %>% gt::cols_align(align="center", columns = gt::everything()) %>% gt::sub_missing(columns = gt::everything(), missing_text = "-") %>% gt::tab_options(table.font.size = gt::px(10), heading.title.font.size = gt::px(13), heading.subtitle.font.size = gt::px(11), table.width = gt::pct(100), column_labels.font.size = gt::px(10)) %>% gt::data_color(columns = c(AVG,OBP,SLG,OPS,AVG_EV,EV90,HH,FIRST_PITCH_SWING), colors = scales::col_numeric(palette = c("#E1463E","white","#00840D"), domain=NULL)) %>% gt::data_color(columns = c(CHASE,WHIFF,GB), colors = scales::col_numeric(palette = c("#00840D","white","#E1463E"), domain=NULL)) } rhp_table <- if (nrow(filter(tm_data, PitcherThrows == "Right"))) { create_single_table(filter(tm_data, PitcherThrows=="Right"), "R") } else NULL lhp_table <- if (nrow(filter(tm_data, PitcherThrows == "Left"))) { create_single_table(filter(tm_data, PitcherThrows=="Left"), "L") } else NULL list(name = formatted_name, handedness = handedness, rhp_table = rhp_table, lhp_table = lhp_table) } create_bp_player_stats_table <- function(batter_name, bp_data) { filtered <- bp_data %>% dplyr::filter(Batter == batter_name) bip_n <- sum(filtered$BIPind, na.rm = TRUE) if (nrow(filtered) == 0 || bip_n == 0) { return( gt::gt(data.frame(Note = paste("No BP data for", batter_name))) ) } stats <- filtered %>% dplyr::summarize( BBE = bip_n, `Avg EV` = round(mean(ExitSpeed[BIPind == 1], na.rm = TRUE), 1), `Max EV` = round(max(ExitSpeed[BIPind == 1], na.rm = TRUE), 1), `Avg LA` = round(mean(Angle[BIPind == 1], na.rm = TRUE), 1), `SC%` = round(sum(SCind, na.rm = TRUE) / bip_n * 100, 1), `10-30%` = round(sum(LA1030ind, na.rm = TRUE) / bip_n * 100, 1), `HH%` = round(sum(HHind, na.rm = TRUE) / bip_n * 100, 1), `Barrel%` = round(sum(Barrelind, na.rm = TRUE) / bip_n * 100, 1) ) stats %>% gt::gt() %>% gtExtras::gt_theme_guardian() %>% gt::tab_header( title = paste("BP Stats:", batter_name) ) %>% gt::cols_align( align = "center", columns = gt::everything() ) %>% gt::tab_options( heading.background.color = "darkcyan", column_labels.background.color = "darkcyan" ) } result_shapes <- c( "Ball" = 1, # open circle "CS" = 5, # open diamond "Foul" = 2, # open triangle "HBP" = 10, # circled plus "Other" = 16, # filled circle "Out" = 4, # X "Whiff" = 8, # asterisk "In Play" = 15 # filled square ) map_pitch_call_display_weekly <- function(pc, pr) { dplyr::case_when( pc == "StrikeSwinging" ~ "Whiff", pc == "InPlay" & pr %in% c("Out","FieldersChoice","Error") ~ "Out", pc == "InPlay" ~ "In Play", pc %in% c("FoulBall","FoulBallNotFieldable","FoulBallFieldable") ~ "Foul", pc %in% c("BallCalled","BallinDirt","BallIntentional") ~ "Ball", pc == "StrikeCalled" ~ "CS", pc == "HitByPitch" ~ "HBP", TRUE ~ "Other" ) } # --- Compute summary stats for weekly report header --- compute_weekly_game_stats <- function(batter_df) { batter_df %>% dplyr::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" ) } # --- Build stats header grob --- create_stats_header_grob <- function(stats_df) { stat_names <- names(stats_df) stat_vals <- as.character(unlist(stats_df[1, ])) stat_vals[stat_vals == "NaN"] <- "-" n <- length(stat_names) x_positions <- seq(0.02, 0.98, length.out = n) label_grobs <- mapply(function(nm, val, xp) { grid::grobTree( grid::textGrob(nm, x = xp, y = 0.72, gp = grid::gpar(fontsize = 8, fontface = "bold", col = "gray30")), grid::textGrob(val, x = xp, y = 0.28, gp = grid::gpar(fontsize = 11, fontface = "bold", col = "#006F71")) ) }, stat_names, stat_vals, x_positions, SIMPLIFY = FALSE) bg <- grid::rectGrob(gp = grid::gpar(fill = "#f0f0f0", col = "gray70", lwd = 1)) do.call(grid::grobTree, c(list(bg), label_grobs)) } # --- Build legend grobs --- create_shape_legend_grob <- function() { shape_legend_df <- data.frame( result = factor(names(result_shapes), levels = names(result_shapes)), x = seq_along(result_shapes), y = 1 ) p <- ggplot(shape_legend_df, aes(x, y, shape = result)) + geom_point(size = 3.5, stroke = 1.1) + scale_shape_manual(values = result_shapes, name = "Result") + theme_void() + theme(legend.position = "left", legend.text = element_text(size = 8), legend.title = element_text(size = 9, face = "bold")) cowplot::get_legend(p) } create_pitch_color_legend_grob <- function() { display_pitches <- c( "Fastball" = "#3465cb", "Sinker" = "#e5e501", "Slider" = "#65aa02", "Sweeper" = "#dc4476", "Curveball" = "#d73813", "ChangeUp" = "#980099", "Splitter" = "#23a999", "Cutter" = "#ff9903", "Slurve" = "#9370DB" ) pt_df <- data.frame(pitch = names(display_pitches), x = seq_along(display_pitches), y = 1) p <- ggplot(pt_df, aes(x, y, color = pitch)) + geom_point(size = 3.5, shape = 16) + scale_color_manual(values = display_pitches, name = "Pitch Type") + theme_void() + theme(legend.position = "left", legend.text = element_text(size = 8), legend.title = element_text(size = 9, face = "bold")) cowplot::get_legend(p) } # --- Single-game AB panel for weekly report --- create_weekly_ab_plot <- function(data, player, game_date, opponent_label = NULL) { df <- data %>% dplyr::filter(Batter == player, format(Date, "%Y-%m-%d") == game_date) %>% dplyr::filter(!is.na(TaggedPitchType), TaggedPitchType != "Other") %>% dplyr::arrange(Inning, PAofInning, PitchofPA) %>% dplyr::group_by(Inning, PAofInning) %>% dplyr::mutate(pa_number = dplyr::cur_group_id()) %>% dplyr::ungroup() %>% dplyr::mutate( result_display = map_pitch_call_display_weekly(PitchCall, PlayResult) ) if (!nrow(df)) { lbl <- ifelse(!is.null(opponent_label), paste0(game_date, " vs. ", opponent_label), game_date) return(ggplot() + theme_void() + ggtitle(paste(lbl, "- No PAs")) + theme(plot.title = element_text(hjust = 0.5, size = 10, face = "bold"))) } # Per-PA text annotations (pitch-by-pitch sidebar) pa_text <- df %>% dplyr::group_by(pa_number) %>% dplyr::summarise( annotation = paste0( PitchofPA, ": ", dplyr::if_else( PitchCall == "InPlay", paste0(PlayResult, dplyr::if_else(!is.na(ExitSpeed), paste0("\n", round(ExitSpeed), " EV"), "")), map_pitch_call_display_weekly(PitchCall, PlayResult) ), collapse = "\n"), .groups = "drop" ) title_label <- if (!is.null(opponent_label)) { paste0(format(as.Date(game_date), "%m/%d/%y"), " vs. ", opponent_label) } else { format(as.Date(game_date), "%m/%d/%y") } p <- ggplot(df, aes(x = PlateLocSide, y = PlateLocHeight)) + # Strike zone annotate("rect", xmin = -0.8303, xmax = 0.8303, ymin = 1.6, ymax = 3.5, fill = NA, color = "black", linewidth = 0.8) + # Home plate 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) + # Pitch points: shape by result, color by pitch type geom_point(aes(shape = result_display, color = TaggedPitchType), size = 4.5, stroke = 1.2) + # Pitch number label geom_text(aes(label = PitchofPA), size = 2.2, vjust = -1.4, fontface = "bold") + # Sidebar annotation geom_text(data = pa_text, aes(x = 1.5, y = 3.0, label = annotation), inherit.aes = FALSE, hjust = 0, size = 1.7, lineheight = 0.9) + scale_shape_manual(values = result_shapes, name = "Result", drop = FALSE) + scale_color_manual(values = pitch_colors, name = "Pitch Type", na.value = "grey50") + facet_wrap(~ pa_number, ncol = 5) + coord_fixed(ratio = 1, xlim = c(-1.8, 2.8), ylim = c(0, 4.2)) + ggtitle(title_label) + theme_void() + theme( strip.text = element_text(size = 8, face = "bold"), plot.title = element_text(hjust = 0.5, size = 11, face = "bold"), legend.position = "none", panel.spacing = unit(0.2, "lines") ) p } # --- Compact heatmap for report (larger) --- create_report_heatmap <- function(player_name, team_data, metric = "Solid Contact", title_short = metric) { tm <- team_data %>% dplyr::filter(Batter == player_name, !is.na(PlateLocSide), !is.na(PlateLocHeight)) if (metric == "Solid Contact") { tm <- tm %>% dplyr::filter(PitchCall == "InPlay", !is.na(ExitSpeed), !is.na(Angle), (ExitSpeed >= 92 & Angle >= 8) | (ExitSpeed >= 95 & Angle >= 0)) } else if (metric == "Whiffs") { tm <- tm %>% dplyr::filter(WhiffIndicator == 1) } else if (metric == "FB Whiffs") { tm <- tm %>% dplyr::filter(WhiffIndicator == 1, TaggedPitchType %in% c("Fastball","Four-Seam","Sinker")) } if (nrow(tm) < 3) { return(ggplot() + theme_void() + ggtitle(title_short) + theme(plot.title = element_text(hjust = 0.5, size = 12, face = "bold"))) } ggplot(tm, aes(x = PlateLocSide, y = PlateLocHeight)) + stat_density_2d(aes(fill = after_stat(density)), geom = "raster", contour = FALSE) + scale_fill_gradientn( colours = c("white","#0551bc","#02fbff","#03ff00","#fbff00", "#ffa503","#ff1f02","#dc1100"), name = "Density") + annotate("rect", xmin = -0.8303, xmax = 0.8303, ymin = 1.6, ymax = 3.5, 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(-2, 2) + ylim(0, 4.5) + ggtitle(title_short) + theme_void() + theme(legend.position = "none", plot.title = element_text(hjust = 0.5, size = 12, face = "bold")) } # --- PDF generator (grid-based) --- generate_weekly_report_pdf <- function(player_name, team_data, n_games = 3, output_path = NULL, notes_text = "") { if (is.null(output_path)) output_path <- tempfile(fileext = ".pdf") batter_data <- team_data %>% dplyr::filter(Batter == player_name) game_dates <- batter_data %>% dplyr::distinct(Date) %>% dplyr::arrange(dplyr::desc(Date)) %>% dplyr::slice_head(n = n_games) %>% dplyr::arrange(Date) %>% dplyr::pull(Date) if (length(game_dates) == 0) { pdf(output_path, width = 14, height = 10) grid::grid.newpage() grid::grid.text(paste("No game data for", player_name), gp = grid::gpar(fontsize = 20)) dev.off() return(output_path) } # --- Summary stats across the selected games --- games_data <- batter_data %>% dplyr::filter(Date %in% game_dates) stats_df <- compute_weekly_game_stats(games_data) stats_grob <- create_stats_header_grob(stats_df) # --- Build AB plots per game --- ab_plots <- lapply(game_dates, function(gd) { gd_str <- format(gd, "%Y-%m-%d") rows <- team_data %>% dplyr::filter(Batter == player_name, Date == gd) opp <- unique(rows$PitcherTeam) opp <- opp[!is.na(opp)] opp_label <- if (length(opp)) opp[1] else NULL create_weekly_ab_plot(team_data, player_name, gd_str, opp_label) }) # --- Heatmaps --- hm_solid <- create_report_heatmap(player_name, team_data, "Solid Contact", "Solid Contact") hm_whiff <- create_report_heatmap(player_name, team_data, "Whiffs", "Whiffs") hm_fb <- create_report_heatmap(player_name, team_data, "FB Whiffs", "FB Whiffs") # --- Notes --- notes_grob <- grid::grobTree( grid::rectGrob(gp = grid::gpar(fill = "white", col = "black", lwd = 1)), grid::textGrob( paste0("Notes:\n", notes_text), x = 0.05, y = 0.95, hjust = 0, vjust = 1, gp = grid::gpar(fontsize = 10) ) ) # --- Legends --- shape_leg_grob <- create_shape_legend_grob() pt_leg_grob <- create_pitch_color_legend_grob() # --- Assemble with grid --- title_grob <- grid::textGrob( paste(player_name, "- Weekly Hitter Report"), gp = grid::gpar(fontsize = 18, fontface = "bold", col = "#006F71") ) legends_row <- gridExtra::arrangeGrob(shape_leg_grob, pt_leg_grob, ncol = 2) ab_grobs <- lapply(ab_plots, ggplotGrob) ab_stack <- do.call(gridExtra::arrangeGrob, c(ab_grobs, list(ncol = 1))) season_label <- grid::textGrob("Season Heatmaps", gp = grid::gpar(fontsize = 14, fontface = "bold")) heatmap_row <- gridExtra::arrangeGrob( ggplotGrob(hm_solid), ggplotGrob(hm_whiff), ggplotGrob(hm_fb), notes_grob, ncol = 4, widths = c(1, 1, 1, 1) ) n_ab <- length(ab_grobs) ab_h <- min(n_ab * 2.8, 8.5) stats_h <- 0.7 title_h <- 0.6 legend_h <- 0.7 label_h <- 0.5 heatmap_h <- 3.5 page_h <- title_h + stats_h + legend_h + ab_h + label_h + heatmap_h + 0.5 full_page <- gridExtra::arrangeGrob( title_grob, stats_grob, legends_row, ab_stack, season_label, heatmap_row, ncol = 1, heights = grid::unit(c(title_h, stats_h, legend_h, ab_h, label_h, heatmap_h), "inches") ) pdf(output_path, width = 14, height = max(page_h, 10), paper = "special") grid::grid.newpage() grid::grid.draw(full_page) dev.off() return(output_path) } create_whiff_movement_chart <- function(batter_name, team_data) { whiff_data <- team_data %>% filter(Batter == batter_name, WhiffIndicator == 1, !is.na(HorzBreak), !is.na(InducedVertBreak), !is.na(TaggedPitchType), TaggedPitchType != "Undefined") if (!nrow(whiff_data)) { return(ggplot() + theme_void() + ggtitle(paste("No whiff data for", batter_name)) + theme(plot.title = element_text(hjust=0.5, size=14, face="bold"))) } velo_data <- whiff_data %>% group_by(TaggedPitchType) %>% summarize(mean_hb = mean(HorzBreak, na.rm = TRUE), mean_ivb = mean(InducedVertBreak, na.rm = TRUE), mean_velo = round(mean(RelSpeed, na.rm = TRUE), 1), count = n(), .groups = "drop") %>% filter(count >= 2) whiff_data %>% ggplot(aes(x=HorzBreak, y=InducedVertBreak)) + geom_vline(xintercept = 0, color="black", linewidth=.8) + geom_hline(yintercept = 0, color="black", linewidth=.8) + geom_point(aes(fill=TaggedPitchType), shape=21, size=4, color="black", stroke=.4, alpha=1) + {if (nrow(velo_data) > 0) { list( geom_point(data=velo_data, aes(x=mean_hb, y=mean_ivb, fill=TaggedPitchType), alpha=1.5, shape=21, color="black", stroke=.4, size=8), geom_text(data=velo_data, aes(x=mean_hb, y=mean_ivb, label=mean_velo), color="black", size=4, hjust=.5) ) }} + scale_x_continuous("HB", limits=c(-27.5,27.5), breaks=seq(-20,20,10)) + scale_y_continuous("IVB", limits=c(-27.5,27.5), breaks=seq(-20,20,10)) + scale_fill_manual(values = pitch_colors) + scale_color_manual(values = pitch_colors) + coord_equal() + theme_void() + ggtitle(paste("Pitch Movement on Whiffs:", batter_name)) + theme(plot.background=element_rect(fill="white", color=NA), panel.background=element_rect(fill="white", color=NA), axis.text=element_text(color="white", size=10), axis.title=element_text(color="white", size=12), plot.title=element_text(hjust=0.5, size=14, face="bold"), legend.background=element_rect(fill="white"), legend.text=element_text(color="black", size=10), legend.title=element_blank(), panel.border=element_rect(color="black", fill=NA, linewidth=2), axis.text.x=element_text(size=12), axis.text.y=element_text(size=12), axis.title.x=element_text(size=12), axis.title.y=element_text(size=12)) } create_radial_grid <- function( max_ev = 120, ev_step = 2.5, angle_step = 1 ) { expand.grid( ExitSpeed = seq(0, max_ev, by = ev_step), Angle = seq(-90, 90, by = angle_step) ) %>% dplyr::mutate( Angle_rad = Angle * pi / 180, radial_x = ExitSpeed * cos(Angle_rad), radial_y = ExitSpeed * sin(Angle_rad), ## Background bins (placeholder logic) xwOBA = dplyr::case_when( ExitSpeed >= 95 & Angle >= 10 & Angle <= 30 ~ 1, TRUE ~ 0 ) ) } grid_df <- create_radial_grid() radial_chart_ev <- function(batter_name, team_data) { df <- team_data %>% filter(Batter == batter_name, PitchCall == "InPlay", !is.na(ExitSpeed), !is.na(Angle)) %>% mutate( event_type = case_when( PlayResult == "Single" ~ "1B", PlayResult == "Double" ~ "2B", PlayResult == "Triple" ~ "3B", PlayResult == "HomeRun" ~ "HR", PlayResult %in% c("Error", "FieldersChoice") ~ "ROE/FC", TRUE ~ "OUT" ), Angle_rad = Angle * pi / 180, radial_x = ExitSpeed * cos(Angle_rad), radial_y = ExitSpeed * sin(Angle_rad) ) if (nrow(df) < 5) { return(ggplot() + theme_void() + ggtitle(paste("Not enough batted-ball data for", batter_name)) + theme(plot.title = element_text(hjust = 0.5, size = 14, face = "bold"))) } event_colors <- c("1B" = "#E41A1C", "2B" = "#FFD700", "3B" = "#CD853F", "HR" = "#008B8B", "ROE/FC" = "#FF8C00", "OUT" = "#A9A9A9") avg_ev <- round(mean(df$ExitSpeed, na.rm = TRUE), 1) avg_la <- round(mean(df$Angle, na.rm = TRUE), 1) max_ev <- round(max(df$ExitSpeed, na.rm = TRUE), 1) barrel_pct <- round(sum(df$ExitSpeed >= 95 & df$Angle >= 10 & df$Angle <= 30, na.rm = TRUE) / nrow(df) * 100, 1) ggplot() + annotate("path", x = 60 * cos(seq(-pi/2, pi/2, length.out = 100)), y = 60 * sin(seq(-pi/2, pi/2, length.out = 100)), color = "grey70", linetype = "dashed", linewidth = 0.3) + annotate("path", x = 80 * cos(seq(-pi/2, pi/2, length.out = 100)), y = 80 * sin(seq(-pi/2, pi/2, length.out = 100)), color = "grey70", linetype = "dashed", linewidth = 0.3) + annotate("path", x = 95 * cos(seq(-pi/2, pi/2, length.out = 100)), y = 95 * sin(seq(-pi/2, pi/2, length.out = 100)), color = "#00840D", linetype = "solid", linewidth = 0.5) + annotate("path", x = 110 * cos(seq(-pi/2, pi/2, length.out = 100)), y = 110 * sin(seq(-pi/2, pi/2, length.out = 100)), color = "grey70", linetype = "dashed", linewidth = 0.3) + annotate("segment", x = 0, y = 0, xend = 120, yend = 0, color = "grey50", linewidth = 0.5) + annotate("segment", x = 0, y = 0, xend = 120 * cos(10 * pi / 180), yend = 120 * sin(10 * pi / 180), color = "grey70", linetype = "dashed", linewidth = 0.3) + annotate("segment", x = 0, y = 0, xend = 120 * cos(30 * pi / 180), yend = 120 * sin(30 * pi / 180), color = "grey70", linetype = "dashed", linewidth = 0.3) + annotate("text", x = 62, y = -8, label = "60", size = 2.5, color = "grey50") + annotate("text", x = 82, y = -8, label = "80", size = 2.5, color = "grey50") + annotate("text", x = 97, y = -8, label = "95", size = 2.5, color = "#00840D", fontface = "bold") + annotate("text", x = 112, y = -8, label = "110", size = 2.5, color = "grey50") + annotate("text", x = 115 * cos(10 * pi / 180), y = 115 * sin(10 * pi / 180), label = "10°", size = 2.5, color = "grey50") + annotate("text", x = 115 * cos(30 * pi / 180), y = 115 * sin(30 * pi / 180), label = "30°", size = 2.5, color = "grey50") + geom_point(data = df, aes(x = radial_x, y = radial_y, fill = event_type), shape = 21, size = 4, color = "black", stroke = 0.5, alpha = 0.8) + annotate("rect", xmin = -10, xmax = 50, ymin = 85, ymax = 115, fill = "white", color = "black", alpha = 0.9) + annotate("text", x = 20, y = 110, label = paste("Avg EV:", avg_ev), size = 3, fontface = "bold") + annotate("text", x = 20, y = 103, label = paste("Max EV:", max_ev), size = 3) + annotate("text", x = 20, y = 96, label = paste("Avg LA:", avg_la, "°"), size = 3) + annotate("text", x = 20, y = 89, label = paste("Barrel%:", barrel_pct, "%"), size = 3) + scale_fill_manual(values = event_colors, name = "Result") + coord_fixed(xlim = c(-20, 125), ylim = c(-30, 120)) + theme_void() + ggtitle(paste("Radial EV/LA Chart:", batter_name)) + theme( plot.title = element_text(hjust = 0.5, size = 14, face = "bold"), legend.position = "right", plot.background = element_rect(fill = "white", color = NA), panel.background = element_rect(fill = "white", color = NA) ) } create_bp_radial_chart <- function(batter_name, bp_data) { df <- bp_data %>% filter(Batter == batter_name, BIPind == 1, !is.na(ExitSpeed), !is.na(Angle)) %>% mutate( Angle_rad = Angle * pi / 180, radial_x = ExitSpeed * cos(Angle_rad), radial_y = ExitSpeed * sin(Angle_rad) ) if (nrow(df) < 3) { return(ggplot() + theme_void() + ggtitle(paste("Not enough BP data for", batter_name)) + theme(plot.title = element_text(hjust = 0.5, size = 14, face = "bold"))) } avg_ev <- round(mean(df$ExitSpeed, na.rm = TRUE), 1) avg_la <- round(mean(df$Angle, na.rm = TRUE), 1) max_ev <- round(max(df$ExitSpeed, na.rm = TRUE), 1) barrel_pct <- round(sum(df$ExitSpeed >= 95 & df$Angle >= 10 & df$Angle <= 30, na.rm = TRUE) / nrow(df) * 100, 1) ggplot() + annotate("path", x = 60 * cos(seq(-pi/2, pi/2, length.out = 100)), y = 60 * sin(seq(-pi/2, pi/2, length.out = 100)), color = "grey70", linetype = "dashed", linewidth = 0.3) + annotate("path", x = 80 * cos(seq(-pi/2, pi/2, length.out = 100)), y = 80 * sin(seq(-pi/2, pi/2, length.out = 100)), color = "grey70", linetype = "dashed", linewidth = 0.3) + annotate("path", x = 95 * cos(seq(-pi/2, pi/2, length.out = 100)), y = 95 * sin(seq(-pi/2, pi/2, length.out = 100)), color = "#00840D", linetype = "solid", linewidth = 0.5) + annotate("path", x = 110 * cos(seq(-pi/2, pi/2, length.out = 100)), y = 110 * sin(seq(-pi/2, pi/2, length.out = 100)), color = "grey70", linetype = "dashed", linewidth = 0.3) + annotate("segment", x = 0, y = 0, xend = 120, yend = 0, color = "grey50", linewidth = 0.5) + annotate("segment", x = 0, y = 0, xend = 120 * cos(10 * pi / 180), yend = 120 * sin(10 * pi / 180), color = "grey70", linetype = "dashed", linewidth = 0.3) + annotate("segment", x = 0, y = 0, xend = 120 * cos(30 * pi / 180), yend = 120 * sin(30 * pi / 180), color = "grey70", linetype = "dashed", linewidth = 0.3) + annotate("text", x = 62, y = -8, label = "60", size = 2.5, color = "grey50") + annotate("text", x = 82, y = -8, label = "80", size = 2.5, color = "grey50") + annotate("text", x = 97, y = -8, label = "95", size = 2.5, color = "#00840D", fontface = "bold") + annotate("text", x = 112, y = -8, label = "110", size = 2.5, color = "grey50") + geom_point(data = df, aes(x = radial_x, y = radial_y, fill = ExitSpeed), shape = 21, size = 4, color = "black", stroke = 0.5, alpha = 0.8) + annotate("rect", xmin = -10, xmax = 50, ymin = 85, ymax = 115, fill = "white", color = "black", alpha = 0.9) + annotate("text", x = 20, y = 110, label = paste("Avg EV:", avg_ev), size = 3, fontface = "bold") + annotate("text", x = 20, y = 103, label = paste("Max EV:", max_ev), size = 3) + annotate("text", x = 20, y = 96, label = paste("Avg LA:", avg_la, "°"), size = 3) + annotate("text", x = 20, y = 89, label = paste("Barrel%:", barrel_pct, "%"), size = 3) + scale_fill_gradient(low = "#E1463E", high = "#00840D", name = "Exit Velo") + coord_fixed(xlim = c(-20, 125), ylim = c(-30, 120)) + theme_void() + ggtitle(paste("BP Radial Chart:", batter_name)) + theme( plot.title = element_text(hjust = 0.5, size = 14, face = "bold"), legend.position = "right", plot.background = element_rect(fill = "white", color = NA), panel.background = element_rect(fill = "white", color = NA) ) } create_contact_map <- function(batter_name, team_data) { batter_data <- team_data %>% filter(Batter == batter_name, !is.na(ExitSpeed), !is.na(ContactPositionZ), !is.na(ContactPositionX), !is.na(ContactPositionY), !is.na(PitcherThrows)) %>% mutate(ContactPositionX = ContactPositionX*12, ContactPositionY = ContactPositionY*12, ContactPositionZ = ContactPositionZ*12) if (!nrow(batter_data)) { return(ggplot() + theme_void() + ggtitle(paste("No contact position data for", batter_name))) } batter_data <- batter_data %>% group_by(Date, Inning, PAofInning) %>% mutate(pa_number = cur_group_id()) %>% ungroup() ggplot(batter_data, aes(x=ContactPositionZ, y=ContactPositionX)) + geom_segment(aes(x=-8.5,y=17,xend=8.5,yend=17), inherit.aes=FALSE, color="black") + geom_segment(aes(x=8.5,y=8.5,xend=8.5,yend=17), inherit.aes=FALSE, color="black") + geom_segment(aes(x=-8.5,y=8.5,xend=-8.5,yend=17), inherit.aes=FALSE, color="black") + geom_segment(aes(x=-8.5,y=8.5,xend=0,yend=0), inherit.aes=FALSE, color="black") + geom_segment(aes(x=8.5,y=8.5,xend=0,yend=0), inherit.aes=FALSE, color="black") + geom_rect(aes(xmin=20,xmax=48,ymin=-20,ymax=40), fill=NA, color="black") + geom_rect(aes(xmin=-48,xmax=-20,ymin=-20,ymax=40), fill=NA, color="black") + geom_text(aes(x=ifelse(BatterSide=="Right",-34,34), y=10, label=ifelse(BatterSide=="Right","R","L")), size=8, fontface="bold") + xlim(-50,50) + ylim(-20,50) + geom_point(aes(fill=ExitSpeed), color="black", stroke=.5, shape=21, alpha=.85, size=3) + geom_smooth(aes(color="Optimal Contact Point"), method="lm", level=0, se=FALSE) + scale_fill_gradient(name="Exit Velo", low="#E1463E", high="#00840D", limits = c(min(batter_data$ExitSpeed, na.rm=TRUE), max(batter_data$ExitSpeed, na.rm=TRUE))) + scale_color_manual(name=NULL, values=c("Optimal Contact Point"="black")) + coord_fixed() + ggtitle(paste("Contact Points:", batter_name)) + theme_void() + facet_wrap(~ BatterSide) + theme(legend.position="right", strip.text = element_blank(), plot.title=element_text(hjust=0.5, size=14, face="bold")) } create_heatmap <- function(player_name, metric, team_data) { tm_data <- team_data %>% filter(Batter == player_name, !is.na(PlateLocSide), !is.na(PlateLocHeight)) # REMOVED: Pitcher hand filtering - now handled by filtered_data() reactive if (!nrow(tm_data)) { return(ggplot() + theme_void() + ggtitle(paste("No data available for", player_name)) + theme(plot.title = element_text(hjust=0.5, size=14, face="bold"))) } # Metric filters if (metric == "Solid Contact") { tm_data <- tm_data %>% filter(PitchCall=="InPlay", !is.na(ExitSpeed), !is.na(Angle), (ExitSpeed >= 92 & Angle >= 8) | (ExitSpeed >= 95 & Angle >= 0)) } else if (metric == "Hard-Hit (95+)") { tm_data <- tm_data %>% filter(PitchCall=="InPlay", !is.na(ExitSpeed), ExitSpeed >= 95) } else if (metric == "Whiffs") { tm_data <- tm_data %>% filter(WhiffIndicator == 1) } else if (metric == "Swings") { tm_data <- tm_data %>% filter(SwingIndicator == 1) } else if (metric == "10-30 LA") { tm_data <- tm_data %>% filter(PitchCall=="InPlay", !is.na(Angle), Angle >= 10, Angle <= 30) } else if (metric == "Hits") { tm_data <- tm_data %>% filter(PlayResult %in% c("Single","Double","Triple","HomeRun")) } else if (metric == "XBH") { tm_data <- tm_data %>% filter(PlayResult %in% c("Double","Triple","HomeRun")) } else if (metric == "All Balls in Play") { tm_data <- tm_data %>% filter(PitchCall == "InPlay") } if (!nrow(tm_data)) { return(ggplot() + theme_void() + ggtitle(paste("No data for", metric, "-", player_name)) + theme(plot.title = element_text(hjust=0.5, size=14, face="bold"))) } ggplot(tm_data, aes(x=PlateLocSide, y=PlateLocHeight)) + stat_density_2d(aes(fill=after_stat(density)), geom="raster", contour=FALSE) + scale_fill_gradientn(colours=c("white","#0551bc","#02fbff","#03ff00","#fbff00", "#ffa503","#ff1f02","#dc1100"), name="Density") + annotate("rect", xmin=-0.8303, xmax=0.8303, ymin=1.6, ymax=3.5, fill=NA, color="black", linewidth=1) + 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=.8) + coord_fixed(ratio=1) + xlim(-2,2) + ylim(0,4.5) + ggtitle(paste("Heatmap:", metric, "-", player_name)) + theme_void() + theme(legend.position="right", plot.margin=margin(3,3,3,3), plot.title=element_text(hjust=0.5, size=14, face="bold")) } create_batter_performance_chart <- function(batter_name, team_data) { filtered_data <- team_data %>% filter(Batter == batter_name) if (!nrow(filtered_data)) { return(ggplot() + theme_void() + ggtitle(paste("No data available for", batter_name)) + theme(plot.title = element_text(hjust=0.5, size=14, face="bold"))) } batter_side <- unique(filtered_data$BatterSide)[1] filtered_data <- filtered_data %>% mutate(x_rel = ifelse(BatterSide=="Left", -PlateLocSide, PlateLocSide), y=PlateLocHeight) x_breaks <- c(-0.95, -0.3166667, 0.3166667, 0.95) y_breaks <- c(1.6, 2.2333333, 2.8666667, 3.5) zone_cells <- expand.grid(xbin=1:3, ybin=1:3, KEEP.OUT.ATTRS=FALSE, stringsAsFactors=FALSE) %>% mutate(xmin = x_breaks[xbin], xmax = x_breaks[xbin+1], ymin = y_breaks[ybin], ymax = y_breaks[ybin+1], zone_name = paste0(c("Down","Middle","Up")[ybin], " - ", c("In","Middle","Away")[xbin])) stats_by_zone <- filtered_data %>% tidyr::crossing(zone_cells) %>% filter(x_rel >= xmin, x_rel <= xmax, y >= ymin, y <= ymax) %>% group_by(PitcherThrows, xbin, ybin, xmin, xmax, ymin, ymax, zone_name) %>% summarise( ab = sum(ABindicator, na.rm=TRUE), pa = sum(PAindicator, na.rm=TRUE), swings = sum(SwingIndicator, na.rm=TRUE), whiffs = sum(WhiffIndicator, na.rm=TRUE), avg = ifelse(ab > 0, sum(HitIndicator, na.rm=TRUE)/ab, NA_real_), slg = ifelse(ab > 0, sum(totalbases, na.rm=TRUE)/ab, NA_real_), obp = ifelse(pa > 0, sum(OnBaseindicator, na.rm=TRUE)/pa, NA_real_), ops = ifelse(!is.na(slg) & !is.na(obp), slg + obp, NA_real_), avg_ev = mean(ExitSpeed[PitchCall == "InPlay"], na.rm = TRUE), whiff_pct = ifelse(swings > 0, 100 * whiffs / swings, NA_real_), .groups = "drop" ) plot_data <- zone_cells %>% tidyr::crossing(PitcherThrows = unique(filtered_data$PitcherThrows)) %>% left_join(stats_by_zone, by = c("PitcherThrows","xbin","ybin","xmin","xmax","ymin","ymax","zone_name")) if (!nrow(plot_data) || all(is.na(plot_data$ops))) { return(ggplot() + theme_void() + ggtitle(paste("Insufficient data for", batter_name)) + theme(plot.title = element_text(hjust=0.5, size=14, face="bold"))) } lim_max <- max(plot_data$ops, na.rm=TRUE) if (is.na(lim_max) || lim_max == 0) lim_max <- 1.0 mid_pt <- 0.700 ggplot(plot_data, aes(fill=ops)) + geom_rect(aes(xmin=xmin, xmax=xmax, ymin=ymin, ymax=ymax), color="black", linewidth=.5, alpha=.85) + geom_text(aes(x=(xmin+xmax)/2, y=(ymin+ymax)/2 + ((ymax-ymin)*.25), label=ifelse(!is.na(ops), sprintf("%.3f", ops), "")), size=5, fontface="bold") + geom_text(aes(x=(xmin+xmax)/2, y=(ymin+ymax)/2 - ((ymax-ymin)*.05), label=ifelse(!is.na(avg), sprintf("AVG %.3f", avg), "")), size=3.2) + geom_text(aes(x=(xmin+xmax)/2, y=(ymin+ymax)/2 - ((ymax-ymin)*.20), label=ifelse(!is.na(avg_ev) & !is.nan(avg_ev), sprintf("EV %.1f", avg_ev), "")), size=3.2) + geom_text(aes(x=(xmin+xmax)/2, y=(ymin+ymax)/2 - ((ymax-ymin)*.35), label=ifelse(!is.na(whiff_pct), sprintf("Whiff %.1f%%", whiff_pct), "")), size=3.2) + scale_fill_gradient2(low="#E1463E", mid="white", high="#00840D", midpoint=mid_pt, limits=c(0,lim_max), na.value="grey90", name="OPS") + annotate("rect", xmin=-0.95, xmax=0.95, ymin=1.6, ymax=3.5, fill=NA, color="black", linewidth=1.2) + coord_fixed(xlim=c(-1.05,1.05), ylim=c(1.5,3.6), expand=FALSE) + facet_wrap(~PitcherThrows, nrow=1, labeller = labeller(PitcherThrows = c(Right="vs RHP", Left="vs LHP"))) + ggtitle(paste("Performance by Location:", batter_name)) + theme_void() + theme(plot.title=element_text(hjust=0.5, size=14, face="bold"), legend.position="right", strip.text=element_text(size=10, face="bold")) } pa_colors <- setNames(colorRampPalette(c("#eaeaea","#bdbdbd","#8c8c8c","#5a5a5a","#2c2c2c"))(50), as.character(1:50)) ## -- Function to prepare data for OAA model find_closest_OF <- function(RF_col_x, RF_col_z, LF_col_x, LF_col_z, CF_col_x, CF_col_z, ball_x, ball_z) { dist_RF <- sqrt((ball_x - RF_col_x)^2 + (ball_z - RF_col_z)^2) dist_LF <- sqrt((ball_x - LF_col_x)^2 + (ball_z - LF_col_z)^2) dist_CF <- sqrt((ball_x - CF_col_x)^2 + (ball_z - CF_col_z)^2) distances <- c(RF = dist_RF, CF = dist_CF, LF = dist_LF) closest_pos <- names(which.min(distances)) return(closest_pos) } prepare_data_oaa <- function(data) { data <- data %>% mutate( rad = Bearing * pi/180, x_pos = Distance * cos(rad), z_pos = Distance * sin(rad)) data <- data %>% mutate(Time = as.character(Time), UTCTime = as.character(UTCTime), LocalDateTime = as.character(LocalDateTime), UTCDateTime = as.character(UTCDateTime)) %>% left_join(college_positions_cc %>% rename(pos_source_file = source_file, pos_PlayResult = PlayResult, pos_PitchCall = PitchCall, pos_PitchNo = PitchNo, pos_PlayID = PlayID, pos_Date = Date, pos_Time = Time, pos_PitcherTeam = PitcherTeam, pos_BatterTeam = BatterTeam, pos_UTCDate = UTCDate, pos_UTCTime = UTCTime, pos_GameUID = GameUID, pos_LocalDateTime = LocalDateTime, pos_UTCDateTime = UTCDateTime) %>% group_by(pos_GameUID, pos_BatterTeam) %>% fill(all_of(ends_with("_Name")), .direction = "downup") %>% ungroup(), by = c("PitchUID")) %>% drop_na(CF_PositionAtReleaseZ) %>% distinct(PitchUID, .keep_all = TRUE) data <- data %>% mutate( dist_RF = sqrt((x_pos - RF_PositionAtReleaseX)^2 + (z_pos - RF_PositionAtReleaseZ)^2), dist_CF = sqrt((x_pos - CF_PositionAtReleaseX)^2 + (z_pos - CF_PositionAtReleaseZ)^2), dist_LF = sqrt((x_pos - LF_PositionAtReleaseX)^2 + (z_pos - LF_PositionAtReleaseZ)^2), hit_location = case_when( dist_CF <= dist_RF & dist_CF <= dist_LF ~ "CF", dist_RF <= dist_CF & dist_RF <= dist_LF ~ "RF", dist_LF <= dist_CF & dist_LF <= dist_RF ~ "LF", TRUE ~ NA_character_ ), closest_pos_dist = case_when( hit_location == "CF" ~ dist_CF, hit_location == "RF" ~ dist_RF, hit_location == "LF" ~ dist_LF, TRUE ~ NA_real_ ), angle_from_home = case_when( hit_location == "CF" ~ atan2(z_pos - CF_PositionAtReleaseZ, x_pos - CF_PositionAtReleaseX) * 180 / pi, hit_location == "RF" ~ atan2(z_pos - RF_PositionAtReleaseZ, x_pos - RF_PositionAtReleaseX) * 180 / pi, hit_location == "LF" ~ atan2(z_pos - LF_PositionAtReleaseZ, x_pos - LF_PositionAtReleaseX) * 180 / pi, TRUE ~ NA_real_ ), angle_from_home = case_when( (angle_from_home > 0 & angle_from_home <= 180) ~ abs(angle_from_home - 180), (angle_from_home >= -180 & angle_from_home <= 0) ~ - (angle_from_home + 180), TRUE ~ NA_real_ ), obs_player = case_when( hit_location == "CF" ~ CF_Name, hit_location == "LF" ~ LF_Name, hit_location == "RF" ~ RF_Name, TRUE ~ NA_character_ ) ) return(data) } hitter_game_safe <- function(hitter, game_key, data){ df <- data %>% filter(Batter == hitter) # pick a grouping key for "game" if ("game" %in% names(df)) { df <- df %>% filter(game == game_key) } else if ("Date" %in% names(df)) { df <- df %>% mutate(.game = format(Date, "%Y-%m-%d")) %>% filter(.game == game_key) } if (!nrow(df)) return(gt(data.frame(Note = "No game data"))) fly_balls <- prepare_data_oaa(df) fly_balls <- fly_balls %>% mutate(across(where(is.logical), as.numeric)) complete_idx <- complete.cases(fly_balls[, c("HangTime", "Distance", "Bearing", "angle_from_home", "closest_pos_dist")]) fly_balls$catch_probability <- NA_real_ if (any(complete_idx)) { preds <- predict(OAA_Model, new_data = fly_balls[complete_idx, ], type = "prob") fly_balls$catch_probability[complete_idx] <- preds$.pred_1 } fly_balls <- fly_balls %>% mutate( catch_probability = ifelse( (PitchCall %in% c("HomeRun", "FoulBallNotFieldable") | Angle <= 10), NA_real_, catch_probability), catch_probability = round(catch_probability, 2), catch_probability = ifelse(is.na(catch_probability), " ", as.character(catch_probability)) ) %>% dplyr::select(PitchUID, catch_probability) df <- df %>% arrange(Date, Inning, PAofInning, PitchofPA) %>% group_by(Date, Inning, PAofInning) %>% mutate(PA_id = cur_group_id()) %>% ungroup() %>% mutate( last_pitch_of_pa = ifelse(lead(PA_id) != PA_id | is.na(lead(PA_id)), 1, 0), ExitSpeed = ifelse(PitchCall == "InPlay", round(ExitSpeed), NA), Angle = ifelse(PitchCall == "InPlay", round(Angle), NA), `Pull?` = dplyr::case_when( PitchCall != "InPlay" ~ NA_character_, BatterSide == "Right" & Bearing < -15 ~ "Yes", BatterSide == "Left" & Bearing > 15 ~ "Yes", TRUE ~ "No" ), RelSpeed = round(RelSpeed, 1), `In Zone?` = ifelse(StrikeZoneIndicator == 1, "Yes", "No"), Throws = ifelse(PitcherThrows == "Right", "R", "L"), hits = ifelse(BatterSide == "Right", "R", "L") ) %>% mutate(event = dplyr::coalesce(PlayResult, PitchCall)) %>% left_join(fly_balls, by = "PitchUID") %>% distinct(PitchUID, .keep_all = TRUE) out <- df %>% drop_na(event) %>% select(PA_id, Pitcher, Throws, hits, Balls, Strikes, TaggedPitchType, RelSpeed, `In Zone?`, event, ExitSpeed, Angle, `Pull?`, `Catch Probability` = catch_probability ) %>% rename(`PA #`=`PA_id`, `Pitch Type`=TaggedPitchType, velo=RelSpeed, EV=ExitSpeed, LA=Angle) gt(out) %>% gt_theme_espn() %>% cols_align("center", columns = everything()) %>% data_color(columns = EV, colors = scales::col_numeric(c("#ffcccc","white","lightgreen"), domain=c(50,120), na.color="white")) %>% data_color(columns = `PA #`, colors = scales::col_factor(pa_colors, domain = names(pa_colors), na.color="white")) %>% tab_style(style = cell_fill(color = "lightgreen"), locations = cells_body(columns = `In Zone?`, rows = `In Zone?` == "Yes")) %>% tab_style(style = cell_fill(color = "#ffcccc"), locations = cells_body(columns = `In Zone?`, rows = `In Zone?` == "No")) %>% tab_style(style = cell_fill(color = "lightgreen"), locations = cells_body(columns = LA, rows = LA >= 8 & LA <= 32)) %>% tab_style(style = cell_fill(color = "#ffcccc"), locations = cells_body(columns = LA, rows = LA < 8 | LA > 32)) %>% fmt_missing(columns = everything(), missing_text = "-") %>% tab_header(title = paste0(hitter, " — ", game_key)) %>% tab_options(heading.align = "center") } create_percentile_chart <- function(batter_rows, pool_rows, batter_name) { summarize_block <- function(df) { df %>% group_by(Batter) %>% summarize( K_pct = sum(KorBB == "Strikeout", na.rm=TRUE)/sum(PAindicator, na.rm=TRUE) * 100, BB_pct = sum(WalkIndicator, na.rm=TRUE)/sum(PAindicator, na.rm=TRUE) * 100, AVG = sum(HitIndicator, na.rm=TRUE)/sum(ABindicator, na.rm=TRUE), OBP = sum(OnBaseindicator, na.rm=TRUE)/sum(PAindicator, na.rm=TRUE), SLG = sum(totalbases, na.rm=TRUE)/sum(ABindicator, na.rm=TRUE), OPS = OBP + SLG, Avg_EV = mean(ExitSpeed[PitchCall == "InPlay" & TaggedHitType != "Bunt"], na.rm=TRUE), Max_EV = suppressWarnings(max(ExitSpeed[PitchCall == "InPlay"], na.rm=TRUE)), SC_pct = sum(SCind, na.rm=TRUE)/sum(BIPind, na.rm=TRUE) * 100, LA1030_pct = sum(LA1030ind, na.rm=TRUE)/sum(BIPind, na.rm=TRUE) * 100, HH_pct = sum(HHind, na.rm=TRUE)/sum(BIPind, na.rm=TRUE) * 100, Barrel_pct = sum(Barrelind, na.rm=TRUE)/sum(BIPind, na.rm=TRUE) * 100, Whiff_pct = sum(WhiffIndicator, na.rm=TRUE)/sum(SwingIndicator, na.rm=TRUE) * 100, Z_Whiff_pct = sum(Zwhiffind, na.rm=TRUE)/sum(Zswing, na.rm=TRUE) * 100, Chase_pct = sum(Chaseindicator, na.rm = TRUE) / sum(OutofZone, na.rm = TRUE) * 100, xBA = mean(xBA[PitchCall == "InPlay" & TaggedHitType != "Bunt"], na.rm=TRUE), .groups = "drop" ) %>% filter(!is.na(AVG) & !is.infinite(AVG)) } pool_summary <- summarize_block(pool_rows) batter_summary <- summarize_block(batter_rows) %>% filter(Batter == batter_name) if (!nrow(batter_summary) || !nrow(pool_summary)) { return(ggplot() + theme_void() + ggtitle(paste("No data available for", batter_name))) } metrics <- c("K %","BB %","AVG","OBP","SLG","OPS","xBA","Avg EV","Max EV","SC%","10-30%","HH%","Barrel%","Whiff%","Z Whiff%","Chase%") batter_vals <- c(batter_summary$K_pct, batter_summary$BB_pct, batter_summary$AVG, batter_summary$OBP, batter_summary$SLG, batter_summary$OPS, batter_summary$xBA, batter_summary$Avg_EV, batter_summary$Max_EV, batter_summary$SC_pct, batter_summary$LA1030_pct, batter_summary$HH_pct, batter_summary$Barrel_pct, batter_summary$Whiff_pct, batter_summary$Z_Whiff_pct, batter_summary$Chase_pct) pool_lists <- list(pool_summary$K_pct, pool_summary$BB_pct, pool_summary$AVG, pool_summary$OBP, pool_summary$SLG, pool_summary$OPS, pool_summary$xBA, pool_summary$Avg_EV, pool_summary$Max_EV, pool_summary$SC_pct, pool_summary$LA1030_pct, pool_summary$HH_pct, pool_summary$Barrel_pct, pool_summary$Whiff_pct, pool_summary$Z_Whiff_pct, pool_summary$Chase_pct) lower_better <- c("K %","Whiff%","Z Whiff%","Chase%") percentiles <- sapply(seq_along(metrics), function(i) { v <- batter_vals[i]; pop <- pool_lists[[i]] if (all(is.na(pop)) || is.na(v)) return(NA_real_) if (metrics[i] %in% lower_better) { mean(pop >= v, na.rm=TRUE) * 100 } else { mean(pop <= v, na.rm=TRUE) * 100 } }) formatted_values <- sapply(seq_along(metrics), function(i) { if (metrics[i] %in% c("AVG","OBP","SLG","OPS","xBA")) { sprintf("%.3f", batter_vals[i]) } else { sprintf("%.1f", batter_vals[i]) } }) chart_data <- data.frame( Metric = factor(metrics, levels = rev(metrics)), Value = formatted_values, Percentile = percentiles, Fill = colorRampPalette(c("#E1463E","white","#00840D"))(100)[pmax(1, pmin(100, round(ifelse(is.na(percentiles), 1, percentiles))))] ) ggplot(chart_data, aes(y=Metric)) + geom_tile(aes(x=50, width=100), fill="#c7dcdc", alpha=.3, height=.8) + geom_tile(aes(x=Percentile/2, width=Percentile, fill=Fill), height=.7) + geom_text(aes(x=-3, label=Metric), hjust=1, size=4, color="black") + geom_text(aes(x=103, label=Value), hjust=0, size=4, color="black") + geom_point(aes(x=Percentile), color="black", size=8, shape=21, stroke=2, fill=chart_data$Fill) + geom_text(aes(x=Percentile, label=round(Percentile)), size=3, color="black", fontface="bold") + scale_x_continuous(limits=c(-16,113), expand=c(0,0)) + scale_fill_identity() + theme_void() + ggtitle(paste("Player Percentiles:", batter_name)) + theme(plot.title = element_text(hjust=0.5, face="bold", size=16), panel.background=element_rect(fill="white"), axis.text=element_blank(), axis.title=element_blank(), panel.grid=element_blank(), plot.margin=margin(20,20,20,20)) } format_stats_table <- function(dt_table, data_df) { rank_colors <- colorRampPalette(c("#E1463E","white","#00840D"))(18) rank_colors2 <- colorRampPalette(c("#00840D","white","#E1463E"))(18) color_rank <- function(column) { values <- data_df[[column]] unique_values <- sort(unique(values), decreasing = TRUE) value_to_color <- setNames(rank_colors[seq_along(unique_values)], unique_values) value_to_color[as.character(values)] } color_rank2 <- function(column) { values <- data_df[[column]] unique_values<- sort(unique(values), decreasing = TRUE) value_to_color <- setNames(rank_colors2[seq_along(unique_values)], unique_values) value_to_color[as.character(values)] } dt_table %>% formatStyle("OPS", backgroundColor = styleEqual(data_df$`OPS`, color_rank2("OPS"))) %>% formatStyle("K %", backgroundColor = styleEqual(data_df$`K %`, color_rank("K %"))) %>% formatStyle("BB %", backgroundColor = styleEqual(data_df$`BB %`, color_rank2("BB %"))) %>% formatStyle("Free%", backgroundColor = styleEqual(data_df$`Free%`, color_rank2("Free%"))) %>% formatStyle("AVG", backgroundColor = styleEqual(data_df$`AVG`, color_rank2("AVG"))) %>% formatStyle("OBP", backgroundColor = styleEqual(data_df$`OBP`, color_rank2("OBP"))) %>% formatStyle("SLG", backgroundColor = styleEqual(data_df$`SLG`, color_rank2("SLG"))) %>% formatStyle("Avg EV", backgroundColor = styleEqual(data_df$`Avg EV`, color_rank2("Avg EV"))) %>% formatStyle("Max EV", backgroundColor = styleEqual(data_df$`Max EV`, color_rank2("Max EV"))) %>% formatStyle("SC%", backgroundColor = styleEqual(data_df$`SC%`, color_rank2("SC%"))) %>% formatStyle("10-30%", backgroundColor = styleEqual(data_df$`10-30%`, color_rank2("10-30%"))) %>% formatStyle("HH%", backgroundColor = styleEqual(data_df$`HH%`, color_rank2("HH%"))) %>% formatStyle("Barrel%", backgroundColor = styleEqual(data_df$`Barrel%`, color_rank2("Barrel%"))) %>% formatStyle("Whiff%", backgroundColor = styleEqual(data_df$`Whiff%`, color_rank("Whiff%"))) %>% formatStyle("Z Whiff%", backgroundColor = styleEqual(data_df$`Z Whiff%`, color_rank("Z Whiff%"))) %>% formatStyle("GB%", backgroundColor = styleEqual(data_df$`GB%`, color_rank("GB%"))) %>% formatStyle("LD%", backgroundColor = styleEqual(data_df$`LD%`, color_rank2("LD%"))) %>% formatStyle("FB%", backgroundColor = styleEqual(data_df$`FB%`, color_rank2("FB%"))) %>% formatStyle("Pop%", backgroundColor = styleEqual(data_df$`Pop%`, color_rank("Pop%"))) } # -------- DEFENSE FUNCTIONS ------- #-- star graph -- star_graph <- function(player, position){ if (position %in% c("RF", "CF", "LF")) { player_data <- OAA_DF %>% filter(obs_player == player) %>% filter(hit_location == position) %>% mutate(star_group = case_when( catch_prob >= 0.90 ~ "1 Star", catch_prob >= 0.70 ~ "2 Star", catch_prob >= 0.50 ~ "3 Star", catch_prob >= 0.25 ~ "4 Star", catch_prob >= 0.00000001 ~ "5 Star" )) %>% dplyr::select(obs_player, hit_location, success_ind, catch_prob, closest_pos_dist, HangTime, OAA, Date, Batter, Pitcher, Inning, star_group) } else { player_data <- OAA_DF %>% filter(obs_player == player) %>% mutate(hit_location = "All") %>% mutate(star_group = case_when( catch_prob >= 0.90 ~ "1 Star", catch_prob >= 0.70 ~ "2 Star", catch_prob >= 0.50 ~ "3 Star", catch_prob >= 0.25 ~ "4 Star", catch_prob >= 0.00000001 ~ "5 Star" )) %>% dplyr::select(obs_player, hit_location, success_ind, catch_prob, closest_pos_dist, HangTime, OAA, Date, Batter, Pitcher, Inning, star_group) } OAA <- round(sum(player_data$OAA, na.rm = TRUE), 1) n_plays <- nrow(player_data) expected_success <- round(sum(player_data$catch_prob, na.rm = TRUE), 1) actual_success <- sum(as.numeric(as.character(player_data$success_ind)), na.rm = TRUE) p <- ggplot(OAA_DF, aes(closest_pos_dist, HangTime)) + stat_summary_2d(aes(z = catch_prob), fun = mean, bins = 51) + scale_fill_gradientn( colors = c("white", "#ffffb2", "#feb24c", "#fc4e2a", "#800026"), limits = c(0, 1), breaks = c(0.01, 0.25, 0.5, 0.7, 0.9, 1), labels = c("5 Star", "4 Star", "3 Star", "2 Star", "1 Star", " "), name = "Catch Rating", guide = guide_colorbar(barheight = unit(5, "in")) ) + scale_x_continuous("Distance to Ball (ft)", limits = c(0, 140), breaks = seq(0, 140, by = 20)) + scale_y_continuous("Hang Time (sec)", limits = c(2, 6)) + theme_classic() + ggtitle(paste0(player, " - ", position, " (", OAA, " OAA)")) + theme( legend.position = "right", plot.title = element_text(hjust = .5, size = 20), legend.title = element_blank(), panel.grid.minor = element_blank(), axis.title = element_text(size = 12), axis.text = element_text(size = 10) ) point_colors <- ifelse(player_data$success_ind == 1, "green4", "white") p_interactive <- suppressWarnings({ ggplotly(p, tooltip = NULL) %>% add_trace( data = player_data, x = ~closest_pos_dist, y = ~HangTime, type = "scatter", mode = "markers", color = I(point_colors), marker = list( size = 10, line = list(color = "black", width = 1) ), text = ~paste0( "Date: ", Date, "
", "Batter: ", Batter, "
", "Pitcher: ", Pitcher, "
", "Inning: ", Inning, "
", "Opportunity Time: ", round(HangTime, 2), " sec
", "Distance Needed: ", round(closest_pos_dist, 1), " ft
", "Catch Probability: ", round(100 * catch_prob, 1), "%
", star_group ), hoverinfo = "text" ) %>% layout( hovermode = "closest", title = list( text = paste0( player, " - ", position, "
OAA: ", OAA, " | Plays: ", n_plays, " | Expected Catches: ", expected_success, " | Actual Catches: ", actual_success, "" ), font = list(size = 18) ), margin = list(t = 80) ) }) return(p_interactive) } # -- field graph -- make_curve_segments <- function(x_start, y_start, x_end, y_end, curvature = 0.3, n = 40) { if (n < 2) stop("n must be >= 2") t <- seq(0, 1, length.out = n) cx <- (x_start + x_end) / 2 + curvature * (y_end - y_start) cy <- (y_start + y_end) / 2 - curvature * (x_end - x_start) x <- (1 - t)^2 * x_start + 2 * (1 - t) * t * cx + t^2 * x_end y <- (1 - t)^2 * y_start + 2 * (1 - t) * t * cy + t^2 * y_end idx_from <- seq_len(n - 1) idx_to <- seq_len(n - 1) + 1 tibble::tibble( x = x[idx_from], y = y[idx_from], xend = x[idx_to], yend = y[idx_to] ) } curve1 <- make_curve_segments(89.095, 89.095, -1, 160, curvature = 0.36, n = 60) curve2 <- make_curve_segments(-89.095, 89.095, 1, 160, curvature = -0.36, n = 60) field_graph <- function(player, position){ if (position %in% c("RF", "CF", "LF")) { player_data <- OAA_DF %>% filter(obs_player == player) %>% filter(hit_location == position) } else { player_data <- OAA_DF %>% filter(obs_player == player) %>% mutate(hit_location = "All") } df_filtered <- player_data %>% mutate(star_group = case_when( catch_prob >= 0.90 ~ "1 Star", catch_prob >= 0.70 ~ "2 Star", catch_prob >= 0.50 ~ "3 Star", catch_prob >= 0.25 ~ "4 Star", catch_prob >= 0.00000001 ~ "5 Star" ), avg_y = case_when( position == "CF" ~ mean(CF_PositionAtReleaseX, na.rm = TRUE), position == "RF" ~ mean(RF_PositionAtReleaseX, na.rm = TRUE), position == "LF" ~ mean(LF_PositionAtReleaseX, na.rm = TRUE), TRUE ~ NA_real_ ), avg_x = case_when( position == "CF" ~ mean(CF_PositionAtReleaseZ, na.rm = TRUE), position == "RF" ~ mean(RF_PositionAtReleaseZ, na.rm = TRUE), position == "LF" ~ mean(LF_PositionAtReleaseZ, na.rm = TRUE), TRUE ~ NA_real_ )) %>% dplyr::select(obs_player, hit_location, star_group, catch_prob, avg_x, avg_y, ends_with("AtReleaseX"), ends_with("AtReleaseZ"), success_ind, Date, HangTime, closest_pos_dist, x_pos, z_pos, angle_from_home, OAA) df_table <- df_filtered %>% group_by(star_group) %>% summarize(OAA = round(sum(OAA, na.rm = TRUE), 1), Plays = n(), Successes = sum((as.numeric(success_ind) - 1), na.rm = TRUE), .groups = "drop") %>% mutate(`Success Rate` = paste0(Successes, "/", Plays, " (", round((Successes/Plays)*100, 2), "%)")) %>% dplyr::select(`Star Group` = star_group, `Success Rate`) %>% t() %>% as.data.frame() %>% `colnames<-`(.[1,]) %>% tibble::as_tibble() %>% dplyr::slice(-1) %>% mutate(OAA = round(sum(df_filtered$OAA, na.rm = TRUE), 2)) %>% dplyr::select(OAA, everything()) %>% gt::gt() %>% gtExtras::gt_theme_guardian() %>% gt::tab_header(title = "Play Success by Difficulty") %>% gt::cols_align(align = "center", columns = gt::everything()) %>% gt::sub_missing(columns = gt::everything(), missing_text = "-") %>% gt::tab_options( heading.background.color = "darkcyan", column_labels.background.color = "darkcyan", table.border.top.color = "peru", table.border.bottom.color = "peru" ) %>% gt::tab_style( style = gt::cell_text(color = "white"), locations = gt::cells_title(groups = "title")) p <- ggplot() + geom_segment(aes(x = 0, y = 0, xend = 318.1981, yend = 318.1981), color = "black") + geom_segment(aes(x = 0, y = 0, xend = -318.1981, yend = 318.1981), color = "black") + geom_segment(aes(x = 63.6396, y = 63.6396, xend = 0, yend = 127.279), color = "black") + geom_segment(aes(x = -63.6396, y = 63.6396, xend = 0, yend = 127.279), color = "black") + geom_segment(aes(x = -1, y = 160, xend = 1, yend = 160), color = "black") + geom_segment(data = curve1, aes(x = x, y = y, xend = xend, yend = yend), size = 0.5, color = "black", lineend = "round") + geom_segment(data = curve2, aes(x = x, y = y, xend = xend, yend = yend), size = 0.5, color = "black", lineend = "round") + annotate("text", x = c(-155, 155), y = 135, label = "200", size = 3) + annotate("text", x = c(-190, 190), y = 170, label = "250", size = 3) + annotate("text", x = c(-227, 227), y = 205, label = "300", size = 3) + annotate("text", x = c(-262, 262), y = 242, label = "350", size = 3) + annotate("text", x = c(-297, 297), y = 277, label = "400", size = 3) + annotate("text", x = c(-333, 333), y = 313, label = "450", size = 3) + theme_void() + geom_point( data = df_filtered %>% mutate(is_catch = ifelse(success_ind == 1, 'Catch', "Hit")), aes( x = z_pos, y = x_pos, fill = is_catch, text = paste0( "Date: ", Date, "
", "Opportunity Time: ", round(HangTime, 2), " sec
", "Distance Needed: ", round(closest_pos_dist, 1), " ft
", "Catch Probability: ", round(100 * catch_prob, 1), "%
", "", star_group ) ), color = "black", shape = 21, size = 2, alpha = .6 ) + labs(title = paste0(player, " Possible Catches - ", position)) + scale_fill_manual( values = c("Hit" = "white", "Catch" = "green4"), labels = c("Hit" = "Hit", "Catch" = "Catch"), name = " " ) + geom_point(data = df_filtered, aes(x = avg_x, y = avg_y), fill = "red", color = "black", size = 3, shape = 21) + coord_cartesian(xlim = c(-330, 330), ylim = c(0, 400)) + coord_equal() p <- ggplotly(p, tooltip = "text") %>% plotly::layout(hovermode = "closest") return(list(p, df_table)) } # -- oaa radar chart -- direction_oaa <- function(player, position) { direction_levels <- c( "Back Right", "Back", "Back Left", "Left", "In Left", "In", "In Right", "Right" ) if (position %in% c("RF", "CF", "LF")) { player_data <- OAA_DF %>% filter(obs_player == player) %>% filter(hit_location == position) } else { player_data <- OAA_DF %>% filter(obs_player == player) %>% mutate(hit_location = "All") } player_data <- player_data %>% mutate( direction = case_when( angle_from_home <= -157.5 | angle_from_home >= 157.5 ~ "Back", angle_from_home <= -112.5 & angle_from_home > -157.5 ~ "Back Right", angle_from_home <= -67.5 & angle_from_home > -112.5 ~ "Right", angle_from_home <= -22.5 & angle_from_home > -67.5 ~ "In Right", angle_from_home > -22.5 & angle_from_home < 22.5 ~ "In", angle_from_home >= 22.5 & angle_from_home < 67.5 ~ "In Left", angle_from_home >= 67.5 & angle_from_home < 112.5 ~ "Left", angle_from_home >= 112.5 & angle_from_home < 157.5 ~ "Back Left" ), direction = factor(direction, levels = direction_levels) ) %>% dplyr::select(Direction = direction, OAA) %>% group_by(Direction) %>% summarize( OAA = sum(OAA, na.rm = TRUE), Plays = n(), .groups = "drop" ) %>% complete(Direction = direction_levels, fill = list(OAA = 0, Plays = 0)) %>% arrange(factor(Direction, levels = direction_levels)) view(player_data) player_data_table <- player_data %>% t() %>% as.data.frame() %>% `colnames<-`(.[1,]) %>% dplyr::slice(-1) %>% tibble::rownames_to_column(var = "Metric") %>% dplyr::select(Metric, Back, `Back Left`, Left, `In Left`, In, `In Right`, Right, `Back Right`) %>% mutate(across(Back:`Back Right`, as.numeric)) %>% gt::gt() %>% gtExtras::gt_theme_guardian() %>% gt::tab_header(title = "Directional OAA") %>% gt::fmt_number( columns = c(Back, `Back Left`, Left, `In Left`, In, `In Right`, Right, `Back Right`), rows = Metric == "OAA", decimals = 2 ) %>% gt::fmt_number( columns = c(Back, `Back Left`, Left, `In Left`, In, `In Right`, Right, `Back Right`), rows = Metric != "OAA", decimals = 0 ) %>% gt::cols_align(align = "center", columns = gt::everything()) %>% gt::sub_missing(columns = gt::everything(), missing_text = "-") %>% gt::tab_options( heading.background.color = "darkcyan", column_labels.background.color = "darkcyan", table.border.top.color = "peru", table.border.bottom.color = "peru" ) %>% gt::tab_style( style = gt::cell_text(color = "white"), locations = gt::cells_title(groups = "title") ) axis_labels <- paste0(player_data$Direction, " (", player_data$Plays, ")") radar_data <- player_data %>% dplyr::select(Direction, OAA) %>% pivot_wider(names_from = Direction, values_from = OAA) player_matrix <- rbind( max = rep(5, length(direction_levels)), min = rep(-5, length(direction_levels)), radar_data[1, direction_levels] ) rownames(player_matrix) <- c("max", "min", player) par(mar = c(1, 2, 4, 2)) p <- radarchart( player_matrix, axistype = 1, pcol = "#0d56ab", pfcol = "#0d56ab33", plwd = 3, plty = 1, cglcol = "grey75", cglty = 1, cglwd = 0.8, caxislabels = c(" ", " ", " "), calcex = 0.7, vlcex = 0.75, seg = 2, vlabels = axis_labels ) text(-.05, 0.2, "-5 or Worse", cex = 0.7) text(-.1, 0.55, "0", cex = 0.7) text(-.2, .95, "+5 or Better", cex = 0.7) title(paste0(player, " - ", position, " OAA by Direction")) return(list(p, player_data_table)) } infield_star_graph <- function(player, position){ if (position %in% c("SS", "1B", "2B", "3B")) { player_data <- IF_OAA %>% filter(obs_player_name == player) %>% filter(obs_player == position) %>% mutate(star_group = case_when( play_prob >= 0.90 ~ "1 Star", play_prob >= 0.70 ~ "2 Star", play_prob >= 0.50 ~ "3 Star", play_prob >= 0.25 ~ "4 Star", play_prob >= 0.00000001 ~ "5 Star" )) %>% dplyr::select(obs_player_name, obs_player, success_ind, play_prob, obs_player_bearing_diff, HangTime, OAA, Distance, Bearing, Direction, obs_player_bearing, ExitSpeed, Angle, dist_from_lead_base, player_angle_rad, Date, Pitcher, Inning, Batter, star_group) } else { player_data <- IF_OAA %>% filter(obs_player_name == player) %>% mutate(obs_player = "All") %>% mutate(star_group = case_when( play_prob >= 0.90 ~ "1 Star", play_prob >= 0.70 ~ "2 Star", play_prob >= 0.50 ~ "3 Star", play_prob >= 0.25 ~ "4 Star", play_prob >= 0.00000001 ~ "5 Star" )) %>% dplyr::select(obs_player_name, obs_player, success_ind, play_prob, obs_player_bearing_diff, HangTime, OAA, Distance, Bearing, Direction, obs_player_bearing, ExitSpeed, Angle, dist_from_lead_base, player_angle_rad, Date, Pitcher, Inning, Batter, star_group) } OAA <- round(sum(player_data$OAA, na.rm = TRUE), 1) n_plays <- nrow(player_data) expected_success <- round(sum(player_data$play_prob, na.rm = TRUE), 1) actual_success <- sum(player_data$success_ind, na.rm = TRUE) points_data <- player_data %>% filter(play_prob > 0) %>% filter(play_prob <= 0.5 | between(obs_player_bearing_diff, -12.5, 12.5)) p <- ggplot(IF_OAA, aes(obs_player_bearing_diff, play_prob)) + stat_summary_2d(aes(z = play_prob), fun = mean, bins = 15) + scale_fill_gradientn( colors = c("white", "#ffffb2", "#feb24c", "#fc4e2a", "#800026"), limits = c(0, 1), breaks = c(0.01, 0.25, 0.5, 0.7, 0.9, 1), labels = c("5 Star", "4 Star", "3 Star", "2 Star", "1 Star", " "), name = "Catch Rating", guide = guide_colorbar(barheight = unit(5, "in")) ) + scale_x_continuous("Bearing Difference (degrees)", limits = c(-25, 25), breaks = seq(-25, 25, by = 12.5)) + scale_y_continuous("Play Probability", limits = c(0, 1), labels = scales::percent) + theme_minimal() + theme( legend.position = "right", plot.title = element_text(hjust = 0.5, size = 18, face = "bold"), plot.subtitle = element_text(hjust = 0.5, size = 12, color = "gray40"), panel.grid.minor = element_blank(), panel.grid.major = element_line(color = "gray90"), axis.title = element_text(size = 12), axis.text = element_text(size = 10), legend.title = element_text(face = "bold") ) point_colors <- ifelse(points_data$success_ind == 1, "green4", "white") p_interactive <- suppressWarnings({ ggplotly(p, tooltip = NULL) %>% add_trace( data = points_data, x = ~obs_player_bearing_diff, y = ~play_prob, type = "scatter", mode = "markers", color = I(point_colors), marker = list( size = 10, line = list(color = "black", width = 1) ), text = ~paste0( "Date: ", Date, "
", "Batter: ", Batter, "
", "Pitcher: ", Pitcher, "
", "Inning: ", Inning, "
", "Play Probability: ", round(100 * play_prob, 1), "%
", "Angular Distance Away: ", round(obs_player_bearing_diff, 1), "º
", "Exit Velocity: ", round(ExitSpeed, 1), " mph
", "Distance: ", round(Distance, 1), " ft
", star_group ), hoverinfo = "text" ) %>% layout( hovermode = "closest", title = list( text = paste0( player, " - ", position, "
OAA: ", OAA, " | Plays: ", n_plays, " | Expected Plays Made: ", expected_success, " | Actual Plays Made: ", actual_success, "" ), font = list(size = 18) ), margin = list(t = 80) ) }) suppressWarnings(p_interactive) } infield_field_graph <- function(player, position){ if (position %in% c("SS", "1B", "2B", "3B")) { player_data <- IF_OAA %>% filter(obs_player_name == player) %>% filter(obs_player == position) } else { player_data <- IF_OAA %>% filter(obs_player_name == player) } df_filtered <- player_data %>% mutate(star_group = case_when( play_prob >= 0.90 ~ "1 Star", play_prob >= 0.70 ~ "2 Star", play_prob >= 0.50 ~ "3 Star", play_prob >= 0.25 ~ "4 Star", play_prob >= 0.00000001 ~ "5 Star" ), rad = Bearing * pi/180, x_pos = Distance * cos(rad), z_pos = Distance * sin(rad)) %>% group_by(obs_player) %>% mutate( avg_y = mean(obs_player_x, na.rm = TRUE), avg_x = mean(obs_player_z, na.rm = TRUE)) %>% ungroup() %>% dplyr::select(obs_player_name, obs_player, success_ind, play_prob, obs_player_bearing_diff, HangTime, OAA, Distance, Bearing, Direction, HangTime, ExitSpeed, Angle, dist_from_lead_base, player_angle_rad, avg_y, avg_x, star_group, x_pos, z_pos, Date, Batter, Pitcher) df_table <- df_filtered %>% group_by(star_group) %>% summarize(OAA = round(sum(OAA, na.rm = TRUE), 1), Plays = n(), Successes = sum((success_ind), na.rm = TRUE), .groups = "drop") %>% mutate(`Success Rate` = paste0(Successes, "/", Plays, " (", round((Successes/Plays)*100, 1), "%)")) %>% dplyr::select(`Star Group` = star_group, `Success Rate`) %>% t() %>% as.data.frame() %>% `colnames<-`(.[1,]) %>% dplyr::slice(-1) %>% mutate(OAA = round(sum(df_filtered$OAA, na.rm = TRUE), 1)) %>% dplyr::select(OAA, everything()) %>% gt::gt() %>% gtExtras::gt_theme_guardian() %>% gt::tab_header(title = "Play Success by Difficulty") %>% gt::cols_align(align = "center", columns = gt::everything()) %>% gt::sub_missing(columns = gt::everything(), missing_text = "-") %>% gt::tab_options( heading.background.color = "darkcyan", column_labels.background.color = "darkcyan", table.border.top.color = "peru", table.border.bottom.color = "peru" ) %>% gt::tab_style( style = gt::cell_text(color = "white"), locations = gt::cells_title(groups = "title")) p <- ggplot() + geom_segment(aes(x = 0, y = 0, xend = 100, yend = 100), color = "black") + geom_segment(aes(x = 0, y = 0, xend = -100, yend = 100), color = "black") + geom_segment(aes(x = 63.6396, y = 63.6396, xend = 0, yend = 127.279), color = "black") + geom_segment(aes(x = -63.6396, y = 63.6396, xend = 0, yend = 127.279), color = "black") + geom_segment(aes(x = -1, y = 160, xend = 1, yend = 160), color = "black") + geom_segment(data = curve1, aes(x = x, y = y, xend = xend, yend = yend), size = 0.5, color = "black", lineend = "round") + geom_segment(data = curve2, aes(x = x, y = y, xend = xend, yend = yend), size = 0.5, color = "black", lineend = "round") + annotate("text", x = c(-155, 155), y = 135, label = "200", size = 3) + annotate("text", x = c(-190, 190), y = 170, label = "250", size = 3) + annotate("text", x = c(-227, 227), y = 205, label = "300", size = 3) + annotate("text", x = c(-262, 262), y = 242, label = "350", size = 3) + annotate("text", x = c(-297, 297), y = 277, label = "400", size = 3) + annotate("text", x = c(-333, 333), y = 313, label = "450", size = 3) + theme_void() + geom_point( data = df_filtered %>% mutate(is_catch = ifelse(success_ind == 1, 'Play Was Made', "Play Was Not Made")), aes( x = z_pos, y = x_pos, fill = is_catch, text = paste0( "Date: ", Date, "
", "Pitcher: ", Pitcher, "
", "Batter: ", Batter, "
", "Play Probability: ", round(100 * play_prob, 1), "%
", "Angular Distance Away (degrees)", round(obs_player_bearing_diff, 1), "º
", "Exit Velocity: ", round(ExitSpeed, 1), " sec
", "Distance: ", round(Distance, 1), " ft
", "", star_group ) ), color = "black", shape = 21, size = 2, alpha = .6 ) + labs(title = paste0(player, " Possible Plays - ", position)) + scale_fill_manual( values = c("Play Was Not Made" = "white", "Play Was Made" = "green4"), labels = c("Play Was Not Made" = "Play Was Not Made", "Play Was Made" = "Play Was Made"), name = " " ) + geom_point(data = df_filtered, aes(x = avg_x, y = avg_y), fill = "red", color = "black", size = 3, shape = 21) + ylim(0, 160) + xlim(-120, 120) + coord_equal() p <- ggplotly(p, tooltip = "text") %>% plotly::layout(hovermode = "closest") return(list(p, df_table)) } curve3 <- make_curve_segments(233.35, 233.35, -10, 410, curvature = 0.36, n = 60) curve4 <- make_curve_segments(-233.35, 233.35, 10, 410, curvature = -0.36, n = 60) infield_positioning_heatmap <- function(team, man_on_first = "No Filter", man_on_second = "No Filter", man_on_third = "No Filter", BatterHand = "No Filter", PitcherHand = "No Filter", scorediff = "No Filter", Date1 = "2025-02-14", Date2 = "2025-06-22", obs_pitcher = "No Filter", Hitter = "No Filter", Count = "No Filter", obs_outs = "No Filter", Opponent = "No Filter") { team_data <- def_pos_data %>% filter(PitcherTeam == team) %>% mutate(scorediff_fieldpov = ifelse( team == away_team, away_score_before - home_score_before, home_score_before - away_score_before )) if (man_on_first != "No Filter") { team_data <- team_data %>% filter(man_on_firstbase == man_on_first) } if (man_on_second != "No Filter") { team_data <- team_data %>% filter(man_on_secondbase == man_on_second) } if (man_on_third != "No Filter") { team_data <- team_data %>% filter(man_on_thirdbase == man_on_third) } if (BatterHand != "No Filter") { team_data <- team_data %>% filter(BatterSide == BatterHand) } if (PitcherHand != "No Filter") { team_data <- team_data %>% filter(PitcherThrows == PitcherHand) } if (scorediff != "No Filter") { team_data <- team_data %>% filter(scorediff_fieldpov == scorediff) } team_data <- team_data %>% filter(Date >= Date1 & Date <= Date2) if (obs_pitcher != "No Filter") { team_data <- team_data %>% filter(Pitcher == obs_pitcher) } if (Hitter != "No Filter") { team_data <- team_data %>% filter(Batter == Hitter) } if (Count != "No Filter") { team_data <- team_data %>% filter(pitch_count == Count) } if (obs_outs != "No Filter") { team_data <- team_data %>% filter(Outs == obs_outs) } if (Opponent != "No Filter") { team_data <- team_data %>% filter(BatterTeam == Opponent) } n_observations <- nrow(team_data) positions_long <- team_data %>% pivot_longer( cols = matches("^(1B|2B|3B|SS|LF|CF|RF)_PositionAtRelease[XZ]$"), names_to = c("position", ".value"), names_pattern = "^(.+)_PositionAtRelease([XZ])$" ) positions_table <- positions_long %>% group_by(position) %>% summarize(median_x = median(X, na.rm = TRUE), median_z = median(Z, na.rm = TRUE)) p <- ggplot() + geom_hline(yintercept = seq(0, 400, by = 20), linetype = "dotted", color = "gray80", size = 0.3) + geom_vline(xintercept = seq(-240, 240, by = 20), linetype = "dotted", color = "gray80", size = 0.3) + geom_density_2d_filled(data = positions_long, aes(x = Z, y = X), bins = 35, alpha = 0.5) + scale_fill_manual(values = colorRampPalette(c("#FFFFFF", "#A6CEE3", "#1F78B4", "#FB9A99", "#E31A1C", "#67000D"))(35)) + geom_segment(aes(x = 0, y = 0, xend = 233.35, yend = 233.35), color = "black") + geom_segment(aes(x = 0, y = 0, xend = 233.35, yend = 233.35), color = "black") + geom_segment(aes(x = 0, y = 0, xend = -233.35, yend = 233.35), color = "black") + geom_segment(aes(x = 63.6396, y = 63.6396, xend = 0, yend = 127.279), color = "black") + geom_segment(aes(x = -63.6396, y = 63.6396, xend = 0, yend = 127.279), color = "black") + geom_segment(aes(x = -1, y = 160, xend = 1, yend = 160), color = "black") + geom_segment(data = curve1, aes(x = x, y = y, xend = xend, yend = yend), size = 0.5, color = "black", lineend = "round") + geom_segment(data = curve2, aes(x = x, y = y, xend = xend, yend = yend), size = 0.5, color = "black", lineend = "round") + geom_segment(data = curve3, aes(x = x, y = y, xend = xend, yend = yend), size = 0.5, color = "black", lineend = "round") + geom_segment(data = curve4, aes(x = x, y = y, xend = xend, yend = yend), size = 0.5, color = "black", lineend = "round") + geom_point(data = positions_table, aes(median_z, median_x), size = 3.5, alpha = 1, color = "black", fill = "black", shape = 21, stroke = 2.6) + labs( title = paste0("Starting Fielder Position — ", team), subtitle = paste0("Graph created from ", n_observations, " available observations of data"), x = "", y = "" ) + ylim(0, 411) + xlim(-250, 250) + coord_equal() + theme_void() + theme( legend.position = "none", plot.title = element_text(hjust = .5, size = 20), plot.subtitle = element_text(hjust = 0.5), legend.title = element_blank(), panel.grid.minor = element_blank(), axis.title = element_text(size = 12), axis.text = element_blank(), axis.ticks = element_blank() ) return(p) } assign_zones <- function(row) { plate_x <- row['PlateLocSide'] plate_z <- row['PlateLocHeight'] if (is.na(plate_x) || is.na(plate_z)) return('NA') if (-8.5 < plate_x & plate_x < -17/6 & 34 < plate_z & plate_z < 42) return('1') else if (-17/6 < plate_x & plate_x < 17/6 & 34 < plate_z & plate_z < 42) return('2') else if (17/6 < plate_x & plate_x < 8.5 & 34 < plate_z & plate_z < 42) return('3') else if (-8.5 < plate_x & plate_x < -17/6 & 26 < plate_z & plate_z < 34) return('4') else if (-17/6 < plate_x & plate_x < 17/6 & 26 < plate_z & plate_z < 34) return('5') else if (17/6 < plate_x & plate_x < 8.5 & 26 < plate_z & plate_z < 34) return('6') else if (-8.5 < plate_x & plate_x < -17/6 & 18 < plate_z & plate_z < 26) return('7') else if (-17/6 < plate_x & plate_x < 17/6 & 18 < plate_z & plate_z < 26) return('8') else if (17/6 < plate_x & plate_x < 8.5 & 18 < plate_z & plate_z < 26) return('9') else if (-13.33 < plate_x & plate_x < -8.5 & 30 < plate_z & plate_z < 48) return('10') else if (-13.33 < plate_x & plate_x < 0 & 42 < plate_z & plate_z < 46) return('10') else if (8.5 < plate_x & plate_x < 13.33 & 30 < plate_z & plate_z < 46) return('11') else if (0 < plate_x & plate_x < 13.33 & 42 < plate_z & plate_z < 46) return('11') else if (-13.33 < plate_x & plate_x < -8.5 & 12 < plate_z & plate_z < 30) return('12') else if (-13.33 < plate_x & plate_x < 0 & 12 < plate_z & plate_z < 18) return('12') else if (8.5 < plate_x & plate_x < 13.33 & 12 < plate_z & plate_z < 30) return('13') else if (0 < plate_x & plate_x < 13.33 & 12 < plate_z & plate_z < 18) return('13') else return('20') } zone_coordinates <- list( `1` = list(x = c(-8.5, -17/6, -17/6, -8.5), y = c(34, 34, 42, 42)), `2` = list(x = c(-17/6, -17/6, 17/6, 17/6), y = c(34, 42, 42, 34)), `3` = list(x = c(17/6, 17/6, 8.5, 8.5), y = c(34, 42, 42, 34)), `4` = list(x = c(-8.5, -17/6, -17/6, -8.5), y = c(26, 26, 34, 34)), `5` = list(x = c(-17/6, -17/6, 17/6, 17/6), y = c(26, 34, 34, 26)), `6` = list(x = c(17/6, 17/6, 8.5, 8.5), y = c(26, 34, 34, 26)), `7` = list(x = c(-8.5, -17/6, -17/6, -8.5), y = c(18, 18, 26, 26)), `8` = list(x = c(-17/6, -17/6, 17/6, 17/6), y = c(18, 26, 26, 18)), `9` = list(x = c(17/6, 17/6, 8.5, 8.5), y = c(18, 26, 26, 18)), `10` = list(x = c(-13.33, -13.33, 0, 0, -8.5, -8.5, -13.33), y = c(30, 48, 48, 42, 42, 30, 30)), `11` = list(x = c(13.33, 13.33, 0, 0, 8.5, 8.5, 13.33), y = c(30, 48, 48, 42, 42, 30, 30)), `12` = list(x = c(-13.33, -13.33, 0, 0, -8.5, -8.5, -13.33), y = c(30, 12, 12, 18, 18, 30, 30)), `13` = list(x = c(13.33, 13.33, 0, 0, 8.5, 8.5, 13.33), y = c(30, 12, 12, 18, 18, 30, 30)) ) get_zone_polygons_df <- function() { zone_polys <- lapply(names(zone_coordinates), function(z) { data.frame( zone = as.character(z), x = zone_coordinates[[z]]$x, y = zone_coordinates[[z]]$y ) }) %>% dplyr::bind_rows() zone_centers <- zone_polys %>% dplyr::group_by(zone) %>% dplyr::summarise(cx = mean(x, na.rm = TRUE), cy = mean(y, na.rm = TRUE), .groups = "drop") list(polys = zone_polys, centers = zone_centers) } # ---- RV/100 Calculation ---- weights_vec <- c( "Ball" = -0.0892681735045099, "Called Strike" = 0.121194020810818, "Double" = -0.859369443968486, "Field Out" = 0.327827741132251, "Foul Ball" = 0.0709399019613328, "HBP" = -0.423817625210998, "Home Run" = -1.42174059796598, "Single" = -0.556264454815573, "Triple" = -1.09689513374671, "Whiff" = 0.174095217814355, "NA" = 0.0110712767697598 ) map_dre_event <- function(PitchCall, PlayResult) { dplyr::case_when( PitchCall %in% c("BallCalled","BallinDirt") ~ "Ball", PitchCall == "StrikeCalled" ~ "Called Strike", PitchCall == "StrikeSwinging" ~ "Whiff", PitchCall %in% c("FoulBallNotFieldable","FoulBallFieldable") ~ "Foul Ball", PitchCall == "HitByPitch" ~ "HBP", PitchCall == "InPlay" & PlayResult == "Single" ~ "Single", PitchCall == "InPlay" & PlayResult == "Double" ~ "Double", PitchCall == "InPlay" & PlayResult == "Triple" ~ "Triple", PitchCall == "InPlay" & PlayResult == "HomeRun" ~ "Home Run", PitchCall == "InPlay" & PlayResult == "Out" ~ "Field Out", TRUE ~ "NA" ) } calculate_rv100 <- function(df) { df %>% mutate( dre_event = map_dre_event(PitchCall, PlayResult), rv_pitcher = as.numeric(weights_vec[dre_event]), rv_pitcher = ifelse(is.na(rv_pitcher), weights_vec["NA"], rv_pitcher), hitter_rv = -rv_pitcher ) } # CALCULATE GRADE METRICS (MUST BE AFTER calculate_rv100 IS DEFINED) grade_metrics <- bind_rows(P5_2025, SBC_2025) %>% filter(!is.na(TaggedPitchType)) %>% calculate_rv100() %>% group_by(Batter) %>% summarise( sdrv = 100 * mean(hitter_rv, na.rm = TRUE), xba = mean(xBA[PitchCall %in% "InPlay"], na.rm = TRUE), iso = (sum(totalbases, na.rm = TRUE) / sum(ABindicator, na.rm = TRUE)) - (sum(HitIndicator, na.rm = TRUE) / sum(ABindicator, na.rm = TRUE)), xpf = sum(totalbases[PitchCall %in% "InPlay"], na.rm = TRUE) / sum(HitIndicator[PitchCall %in% "InPlay"], na.rm = TRUE), zone_con = 100 * (1 - sum(Zwhiffind, na.rm = TRUE) / sum(Zswing, na.rm = TRUE)), k_pct = 100 * sum(KorBB %in% "Strikeout", na.rm = TRUE) / sum(PAindicator, na.rm = TRUE), ev90 = quantile(ExitSpeed[PitchCall %in% "InPlay"], 0.9, na.rm = TRUE), con2k = 100 * (1 - sum(WhiffIndicator[Strikes %in% 2], na.rm = TRUE) / sum(SwingIndicator[Strikes %in% 2], na.rm = TRUE)), .groups = "drop" ) %>% summarise( sdrv_mean = mean(sdrv, na.rm = TRUE), sdrv_sd = sd(sdrv, na.rm = TRUE), xba_mean = mean(xba, na.rm = TRUE), xba_sd = sd(xba, na.rm = TRUE), iso_mean = mean(iso, na.rm = TRUE), iso_sd = sd(iso, na.rm = TRUE), xpf_mean = mean(xpf, na.rm = TRUE), xpf_sd = sd(xpf, na.rm = TRUE), zone_con_mean = mean(zone_con, na.rm = TRUE), zone_con_sd = sd(zone_con, na.rm = TRUE), k_mean = mean(k_pct, na.rm = TRUE), k_sd = sd(k_pct, na.rm = TRUE), ev90_mean = mean(ev90, na.rm = TRUE), ev90_sd = sd(ev90, na.rm = TRUE), con2k_mean = mean(con2k, na.rm = TRUE), con2k_sd = sd(con2k, na.rm = TRUE) ) create_hitting_grades_radar <- function(batter_name, team_data) { # Calculate hitting grades batter_split <- team_data %>% filter(!is.na(TaggedPitchType), Batter == batter_name) %>% calculate_rv100() %>% group_by(PitcherThrows) %>% summarise( xBA = mean(xBA[PitchCall == "InPlay"], na.rm = TRUE), ISO = (sum(totalbases, na.rm = TRUE) / sum(ABindicator, na.rm = TRUE)) - (sum(HitIndicator, na.rm = TRUE) / sum(ABindicator, na.rm = TRUE)), xPowerFactor = sum(totalbases[PitchCall == "InPlay"], na.rm = TRUE) / sum(HitIndicator[PitchCall == "InPlay"], na.rm = TRUE), sdrv = 100 * mean(hitter_rv, na.rm = TRUE), zone_con = 100 * (1 - sum(Zwhiffind, na.rm = TRUE) / sum(Zswing, na.rm = TRUE)), `K%` = 100 * sum(KorBB == "Strikeout", na.rm = TRUE) / sum(PAindicator, na.rm = TRUE), ev90 = quantile(ExitSpeed[PitchCall == "InPlay"], 0.9, na.rm = TRUE), con2k = 100 * (1 - sum(WhiffIndicator[Strikes == 2], na.rm = TRUE) / sum(SwingIndicator[Strikes == 2], na.rm = TRUE)), .groups = "drop" ) %>% rowwise() %>% mutate( `Swing Decisions` = ((sdrv - grade_metrics$sdrv_mean) / grade_metrics$sdrv_sd) * 10 + 50, `Game Power` = (((xPowerFactor - grade_metrics$xpf_mean) / grade_metrics$xpf_sd) + ((ISO - grade_metrics$iso_mean) / grade_metrics$iso_sd)) * 5 + 50, `Raw Power` = ((ev90 - grade_metrics$ev90_mean) / grade_metrics$ev90_sd) * 10 + 50, `Avoid K` = (((grade_metrics$k_mean - `K%`) / grade_metrics$k_sd) + ((con2k - grade_metrics$con2k_mean) / grade_metrics$con2k_sd)) * 5 + 50, Contact = (((zone_con - grade_metrics$zone_con_mean) / grade_metrics$zone_con_sd) + ((xBA - grade_metrics$xba_mean) / grade_metrics$xba_sd)) * 5 + 50, group = ifelse(PitcherThrows == "Right", "vs RHP", "vs LHP") ) %>% ungroup() %>% mutate(across(c(`Swing Decisions`, `Game Power`, `Raw Power`, `Avoid K`, Contact), ~round(pmax(pmin(., 80), 20)))) if (!nrow(batter_split)) { return(ggplot() + theme_void() + ggtitle(paste("No data available for", batter_name)) + theme(plot.title = element_text(hjust = 0.5, size = 14, face = "bold"))) } ggradar( batter_split %>% select(group, `Swing Decisions`, `Raw Power`, `Game Power`, `Avoid K`, Contact), values.radar = c("20", "50", "80"), grid.min = 20, grid.mid = 50, grid.max = 80, fill = TRUE, fill.alpha = 0.3, group.point.size = 3, group.colours = c("#0d56ab", "#fa5e02"), # Blue for LHP, Orange for RHP background.circle.colour = "white", gridline.mid.colour = "grey80", gridline.min.linetype = "dashed", gridline.max.linetype = "solid", axis.label.size = 4, grid.label.size = 4, legend.position = "bottom", legend.text.size = 12, legend.title = " " ) + ggtitle(paste("Grading Radar:", batter_name)) + theme(plot.title = element_text(hjust = 0.5, size = 14, face = "bold")) } # ============================================ # SECTION 1: HELPER FUNCTIONS # ============================================ get_swing_direction <- function(haa, batter_side) { case_when( is.na(haa) | is.na(batter_side) ~ "Unknown", batter_side == "Right" & haa > 5 ~ "Pull", batter_side == "Right" & haa < -5 ~ "Oppo", batter_side == "Left" & haa < -5 ~ "Pull", batter_side == "Left" & haa > 5 ~ "Oppo", TRUE ~ "Middle" ) } pitch_colors <- c( "Fastball" = "#D22D49", "Sinker" = "#FE9D00", "Cutter" = "#933F2C", "Slider" = "#EEE716", "Sweeper" = "#EEE716", "Curveball" = "#00D1ED", "ChangeUp" = "#1DBE3A", "Splitter" = "#3BACAC", "Knuckle Curve" = "#00D1ED", "Other" = "gray50" ) # ============================================ # CHART 1: Simple Attack Angle Visualization # Labels at top, 5-20° ideal zone # ============================================ create_simple_aa_viz <- function(batter_name, swing_data, swing_index = NULL) { if (!is.null(swing_index) && swing_index != "All" && nrow(swing_data) >= as.integer(swing_index)) { row_data <- swing_data[as.integer(swing_index), ] aa <- round(row_data$VerticalAttackAngle, 1) title_suffix <- paste0(" - Swing #", swing_index) n_swings <- 1 } else { df <- swing_data %>% filter(!is.na(VerticalAttackAngle)) if (nrow(df) < 1) { return(ggplot() + theme_void() + ggtitle("No attack angle data") + theme(plot.title = element_text(hjust = 0.5, size = 14, face = "bold"))) } aa <- round(mean(df$VerticalAttackAngle, na.rm = TRUE), 1) title_suffix <- "" n_swings <- nrow(df) } contact_x <- 0 contact_y <- 0 attack_line_length <- 3 aa_x_start <- contact_x - attack_line_length * cos(aa * pi / 180) aa_y_start <- contact_y - attack_line_length * sin(aa * pi / 180) ref_5_x <- contact_x - 2.5 * cos(5 * pi / 180) ref_5_y <- contact_y - 2.5 * sin(5 * pi / 180) ref_20_x <- contact_x - 2.5 * cos(20 * pi / 180) ref_20_y <- contact_y - 2.5 * sin(20 * pi / 180) aa_in_ideal <- aa >= 5 & aa <= 20 aa_color <- ifelse(aa_in_ideal, "#2E8B57", "#8B0000") ideal_color <- "#2E8B57" ref_color <- "gray70" ggplot() + geom_segment(aes(x = -4, y = 0, xend = 4, yend = 0), color = "black", linewidth = 1) + annotate("polygon", x = c(contact_x, ref_5_x, ref_20_x, contact_x), y = c(contact_y, ref_5_y, ref_20_y, contact_y), fill = ideal_color, alpha = 0.15) + geom_segment(aes(x = contact_x - 2.8, y = 0, xend = contact_x, yend = 0), color = ref_color, linewidth = 0.6, linetype = "dotted") + geom_segment(aes(x = ref_5_x, y = ref_5_y, xend = contact_x, yend = contact_y), color = ref_color, linewidth = 0.6, linetype = "dashed") + geom_segment(aes(x = ref_20_x, y = ref_20_y, xend = contact_x, yend = contact_y), color = ref_color, linewidth = 0.6, linetype = "dashed") + geom_segment(aes(x = contact_x - 2.5 * cos(30 * pi/180), y = contact_y - 2.5 * sin(30 * pi/180), xend = contact_x, yend = contact_y), color = ref_color, linewidth = 0.4, linetype = "dotted") + geom_segment(aes(x = aa_x_start, y = aa_y_start, xend = contact_x, yend = contact_y), color = aa_color, linewidth = 3) + geom_point(aes(x = contact_x, y = contact_y), fill = "white", color = "black", shape = 21, size = 8, stroke = 2) + annotate("text", x = -3, y = 0.15, label = "0°", size = 3, color = "gray50") + annotate("text", x = ref_5_x - 0.3, y = ref_5_y - 0.1, label = "5°", size = 3, color = "gray50") + annotate("text", x = ref_20_x - 0.3, y = ref_20_y - 0.1, label = "20°", size = 3, color = "gray50") + annotate("text", x = contact_x - 2.7 * cos(30 * pi/180) - 0.2, y = contact_y - 2.7 * sin(30 * pi/180), label = "30°", size = 3, color = "gray50") + # Label at TOP instead of on arrow annotate("label", x = 0, y = 2.2, label = paste0("Attack Angle: ", aa, "°"), size = 5, fontface = "bold", fill = aa_color, color = "white") + annotate("text", x = -3.5, y = -1.2, label = "Ideal AA: 5° - 20°", size = 3.5, color = ideal_color, fontface = "italic") + labs(title = paste0(batter_name, " - Attack Angle", title_suffix), subtitle = paste0("n=", n_swings, " swings")) + coord_fixed(ratio = 1) + xlim(-4.5, 4) + ylim(-1.5, 2.8) + theme_minimal() + theme(plot.title = element_text(hjust = 0.5, size = 16, face = "bold"), plot.subtitle = element_text(hjust = 0.5, size = 11, color = "gray40"), axis.text = element_blank(), axis.ticks = element_blank(), axis.title = element_blank(), panel.grid = element_blank()) } # ============================================ # CHART 2: Attack Angle + Launch Angle # 10-30° LA zone green, labels at top # ============================================ create_aa_la_viz <- function(batter_name, swing_data, swing_index = NULL) { if (!is.null(swing_index) && swing_index != "All" && nrow(swing_data) >= as.integer(swing_index)) { row_data <- swing_data[as.integer(swing_index), ] aa <- round(row_data$VerticalAttackAngle, 1) la <- round(row_data$Angle, 1) bat_speed <- round(row_data$BatSpeed, 1) exit_velo <- ifelse(!is.na(row_data$ExitSpeed), round(row_data$ExitSpeed, 1), NA) title <- paste0(batter_name, " - AA & LA") n_swings <- 1 } else { df <- swing_data %>% filter(!is.na(VerticalAttackAngle), !is.na(Angle)) if (nrow(df) < 1) { return(ggplot() + theme_void() + ggtitle("No AA/LA data") + theme(plot.title = element_text(hjust = 0.5, size = 14, face = "bold"))) } aa <- round(mean(df$VerticalAttackAngle, na.rm = TRUE), 1) la <- round(mean(df$Angle, na.rm = TRUE), 1) bat_speed <- round(mean(df$BatSpeed, na.rm = TRUE), 1) exit_velo <- round(mean(df$ExitSpeed, na.rm = TRUE), 1) title <- paste0(batter_name, " - Attack Angle & Launch Angle") n_swings <- nrow(df) } if (is.na(la)) la <- 0 ideal_color <- "#2E8B57" main_color <- "#8B0000" ref_color <- "gray70" contact_x <- 0; contact_y <- 0 attack_line_length <- 3; launch_line_length <- 3.5 aa_x_start <- contact_x - attack_line_length * cos(aa * pi / 180) aa_y_start <- contact_y - attack_line_length * sin(aa * pi / 180) la_x_end <- contact_x + launch_line_length * cos(la * pi / 180) la_y_end <- contact_y + launch_line_length * sin(la * pi / 180) # Reference lines ref_aa_5_x <- contact_x - 2.5 * cos(5 * pi / 180) ref_aa_5_y <- contact_y - 2.5 * sin(5 * pi / 180) ref_aa_20_x <- contact_x - 2.5 * cos(20 * pi / 180) ref_aa_20_y <- contact_y - 2.5 * sin(20 * pi / 180) ref_la_10_x <- contact_x + 3 * cos(10 * pi / 180) ref_la_10_y <- contact_y + 3 * sin(10 * pi / 180) ref_la_30_x <- contact_x + 3 * cos(30 * pi / 180) ref_la_30_y <- contact_y + 3 * sin(30 * pi / 180) aa_in_ideal <- aa >= 5 & aa <= 20 la_in_ideal <- la >= 10 & la <= 30 aa_color <- ifelse(aa_in_ideal, ideal_color, main_color) la_color <- ifelse(la_in_ideal, ideal_color, main_color) ggplot() + geom_segment(aes(x = -4, y = 0, xend = 5, yend = 0), color = "black", linewidth = 1) + # AA ideal zone (5-20°) annotate("polygon", x = c(contact_x, ref_aa_5_x, ref_aa_20_x, contact_x), y = c(contact_y, ref_aa_5_y, ref_aa_20_y, contact_y), fill = ideal_color, alpha = 0.15) + # LA ideal zone (10-30°) annotate("polygon", x = c(contact_x, ref_la_10_x, ref_la_30_x, contact_x), y = c(contact_y, ref_la_10_y, ref_la_30_y, contact_y), fill = ideal_color, alpha = 0.15) + # AA references geom_segment(aes(x = contact_x - 2.8, y = 0, xend = contact_x, yend = 0), color = ref_color, linewidth = 0.5, linetype = "dotted") + geom_segment(aes(x = ref_aa_5_x, y = ref_aa_5_y, xend = contact_x, yend = contact_y), color = ref_color, linewidth = 0.5, linetype = "dashed") + geom_segment(aes(x = ref_aa_20_x, y = ref_aa_20_y, xend = contact_x, yend = contact_y), color = ref_color, linewidth = 0.5, linetype = "dashed") + # LA references geom_segment(aes(x = contact_x, y = contact_y, xend = ref_la_10_x, yend = ref_la_10_y), color = ref_color, linewidth = 0.5, linetype = "dashed") + geom_segment(aes(x = contact_x, y = contact_y, xend = ref_la_30_x, yend = ref_la_30_y), color = ref_color, linewidth = 0.5, linetype = "dashed") + geom_segment(aes(x = contact_x, y = contact_y, xend = contact_x + 3 * cos(40 * pi/180), yend = contact_y + 3 * sin(40 * pi/180)), color = ref_color, linewidth = 0.4, linetype = "dotted") + # Actual AA line geom_segment(aes(x = aa_x_start, y = aa_y_start, xend = contact_x, yend = contact_y), color = aa_color, linewidth = 3) + # Actual LA line geom_segment(aes(x = contact_x, y = contact_y, xend = la_x_end, yend = la_y_end), color = la_color, linewidth = 3) + geom_point(aes(x = contact_x, y = contact_y), fill = "white", color = "black", shape = 21, size = 8, stroke = 2) + # Reference labels annotate("text", x = -3, y = 0.15, label = "0°", size = 2.5, color = "gray50") + annotate("text", x = ref_aa_5_x - 0.25, y = ref_aa_5_y, label = "5°", size = 2.5, color = "gray50") + annotate("text", x = ref_aa_20_x - 0.25, y = ref_aa_20_y, label = "20°", size = 2.5, color = "gray50") + annotate("text", x = ref_la_10_x + 0.25, y = ref_la_10_y, label = "10°", size = 2.5, color = "gray50") + annotate("text", x = ref_la_30_x + 0.25, y = ref_la_30_y, label = "30°", size = 2.5, color = "gray50") + annotate("text", x = 3.3 * cos(40 * pi/180), y = 3.3 * sin(40 * pi/180), label = "40°", size = 2.5, color = "gray50") + # Labels at TOP annotate("label", x = -2.5, y = 2.8, label = paste0("AA: ", aa, "°"), size = 4.5, fontface = "bold", fill = aa_color, color = "white") + annotate("label", x = 2.5, y = 2.8, label = paste0("LA: ", la, "°"), size = 4.5, fontface = "bold", fill = la_color, color = "white") + # Ideal text annotate("text", x = -3.5, y = -1.2, label = "Ideal AA: 5°-20°", size = 3, color = ideal_color, fontface = "italic") + annotate("text", x = 3.5, y = -1.2, label = "Ideal LA: 10°-30°", size = 3, color = ideal_color, fontface = "italic") + labs(title = title, subtitle = paste0("n=", n_swings, " swings")) + coord_fixed(ratio = 1) + xlim(-4.5, 5) + ylim(-1.5, 3.5) + theme_minimal() + theme(plot.title = element_text(hjust = 0.5, size = 16, face = "bold"), plot.subtitle = element_text(hjust = 0.5, size = 11, color = "gray40"), axis.text = element_blank(), axis.ticks = element_blank(), axis.title = element_blank(), panel.grid = element_blank()) } # ============================================ # CHART 3: Attack Angle + VAA # VAA from right (incoming pitch), labels at top # ============================================ create_aa_vaa_viz <- function(batter_name, swing_data, swing_index = NULL) { if (!is.null(swing_index) && swing_index != "All" && nrow(swing_data) >= as.integer(swing_index)) { row_data <- swing_data[as.integer(swing_index), ] aa <- round(row_data$VerticalAttackAngle, 1) vaa <- round(row_data$VertApprAngle, 1) title <- paste0(batter_name, " - AA vs VAA") n_swings <- 1 } else { df <- swing_data %>% filter(!is.na(VerticalAttackAngle), !is.na(VertApprAngle)) if (nrow(df) < 1) { return(ggplot() + theme_void() + ggtitle("No AA/VAA data") + theme(plot.title = element_text(hjust = 0.5, size = 14, face = "bold"))) } aa <- round(mean(df$VerticalAttackAngle, na.rm = TRUE), 1) vaa <- round(mean(df$VertApprAngle, na.rm = TRUE), 1) title <- paste0(batter_name, " - Attack Angle vs Pitch VAA") n_swings <- nrow(df) } if (is.na(vaa)) vaa <- -6 aa_color <- "#2E8B57" vaa_color <- "#4169E1" ideal_color <- "#2E8B57" ref_color <- "gray70" contact_x <- 0; contact_y <- 0; line_length <- 3 aa_x_start <- contact_x - line_length * cos(aa * pi / 180) aa_y_start <- contact_y - line_length * sin(aa * pi / 180) vaa_abs <- abs(vaa) vaa_x_start <- contact_x + line_length * cos(vaa_abs * pi / 180) vaa_y_start <- contact_y + line_length * sin(vaa_abs * pi / 180) ref_aa_5_x <- contact_x - 2.5 * cos(5 * pi / 180) ref_aa_5_y <- contact_y - 2.5 * sin(5 * pi / 180) ref_aa_20_x <- contact_x - 2.5 * cos(20 * pi / 180) ref_aa_20_y <- contact_y - 2.5 * sin(20 * pi / 180) ref_vaa_5_x <- contact_x + 2.5 * cos(5 * pi / 180) ref_vaa_5_y <- contact_y + 2.5 * sin(5 * pi / 180) ref_vaa_10_x <- contact_x + 2.5 * cos(10 * pi / 180) ref_vaa_10_y <- contact_y + 2.5 * sin(10 * pi / 180) ref_vaa_15_x <- contact_x + 2.5 * cos(15 * pi / 180) ref_vaa_15_y <- contact_y + 2.5 * sin(15 * pi / 180) aa_in_ideal <- aa >= 5 & aa <= 20 display_aa_color <- ifelse(aa_in_ideal, aa_color, "#8B0000") ggplot() + geom_segment(aes(x = -4, y = 0, xend = 4, yend = 0), color = "black", linewidth = 1.2) + # AA ideal zone annotate("polygon", x = c(contact_x, ref_aa_5_x, ref_aa_20_x, contact_x), y = c(contact_y, ref_aa_5_y, ref_aa_20_y, contact_y), fill = ideal_color, alpha = 0.15) + # AA refs geom_segment(aes(x = ref_aa_5_x, y = ref_aa_5_y, xend = contact_x, yend = contact_y), color = ref_color, linewidth = 0.5, linetype = "dashed") + geom_segment(aes(x = ref_aa_20_x, y = ref_aa_20_y, xend = contact_x, yend = contact_y), color = ref_color, linewidth = 0.5, linetype = "dashed") + # VAA refs geom_segment(aes(x = ref_vaa_5_x, y = ref_vaa_5_y, xend = contact_x, yend = contact_y), color = ref_color, linewidth = 0.5, linetype = "dotted") + geom_segment(aes(x = ref_vaa_10_x, y = ref_vaa_10_y, xend = contact_x, yend = contact_y), color = ref_color, linewidth = 0.5, linetype = "dotted") + geom_segment(aes(x = ref_vaa_15_x, y = ref_vaa_15_y, xend = contact_x, yend = contact_y), color = ref_color, linewidth = 0.5, linetype = "dotted") + # AA line geom_segment(aes(x = aa_x_start, y = aa_y_start, xend = contact_x, yend = contact_y), color = display_aa_color, linewidth = 3) + # VAA line with arrow geom_segment(aes(x = vaa_x_start, y = vaa_y_start, xend = contact_x, yend = contact_y), color = vaa_color, linewidth = 2.5, arrow = arrow(length = unit(0.15, "inches"), type = "closed", ends = "last")) + geom_point(aes(x = contact_x, y = contact_y), fill = "white", color = "black", shape = 21, size = 8, stroke = 2) + # Ref labels annotate("text", x = -3.1, y = 0.2, label = "0°", size = 2.5, color = "gray50") + annotate("text", x = ref_aa_5_x - 0.3, y = ref_aa_5_y, label = "5°", size = 2.5, color = "gray50") + annotate("text", x = ref_aa_20_x - 0.3, y = ref_aa_20_y, label = "20°", size = 2.5, color = "gray50") + annotate("text", x = 3.1, y = 0.2, label = "0°", size = 2.5, color = "gray50") + annotate("text", x = ref_vaa_5_x + 0.35, y = ref_vaa_5_y, label = "-5°", size = 2.5, color = "gray50") + annotate("text", x = ref_vaa_10_x + 0.4, y = ref_vaa_10_y, label = "-10°", size = 2.5, color = "gray50") + annotate("text", x = ref_vaa_15_x + 0.4, y = ref_vaa_15_y, label = "-15°", size = 2.5, color = "gray50") + # Labels at TOP annotate("label", x = -2.5, y = 2.5, label = paste0("AA: ", aa, "°"), size = 4.5, fontface = "bold", fill = display_aa_color, color = "white") + annotate("label", x = 2.5, y = 2.5, label = paste0("VAA: ", vaa, "°"), size = 4.5, fontface = "bold", fill = vaa_color, color = "white") + # Axis labels annotate("text", x = -3.5, y = -1.3, label = "ATTACK ANGLE", size = 3, color = aa_color, fontface = "bold") + annotate("text", x = 3.5, y = -1.3, label = "PITCH VAA", size = 3, color = vaa_color, fontface = "bold") + labs(title = title, subtitle = paste0("n=", n_swings, " swings")) + coord_fixed(ratio = 1) + xlim(-4.5, 4.5) + ylim(-1.8, 3) + theme_minimal() + theme(plot.title = element_text(hjust = 0.5, size = 16, face = "bold"), plot.subtitle = element_text(hjust = 0.5, size = 11, color = "gray40"), axis.text = element_blank(), axis.ticks = element_blank(), axis.title = element_blank(), panel.grid = element_blank()) } # ============================================ # CHART 4: Attack Direction (HAA) - Overhead View # ============================================ create_attack_dir_viz <- function(batter_name, swing_data, swing_index = NULL) { batter_side <- swing_data$BatterSide[1] if (is.na(batter_side)) batter_side <- "Right" if (!is.null(swing_index) && swing_index != "All" && nrow(swing_data) >= as.integer(swing_index)) { row_data <- swing_data[as.integer(swing_index), ] avg_haa <- round(row_data$HorizontalAttackAngle, 1) title_suffix <- paste0(" - Swing #", swing_index) n_swings <- 1 } else { df <- swing_data %>% filter(!is.na(HorizontalAttackAngle)) if (nrow(df) < 1) { return(ggplot() + theme_void() + ggtitle("No attack direction data") + theme(plot.title = element_text(hjust = 0.5, size = 14, face = "bold"))) } avg_haa <- round(mean(df$HorizontalAttackAngle, na.rm = TRUE), 1) title_suffix <- "" n_swings <- nrow(df) } df_dir <- swing_data %>% filter(!is.na(HorizontalAttackAngle)) %>% mutate(swing_direction = get_swing_direction(HorizontalAttackAngle, BatterSide)) pull_pct <- round(sum(df_dir$swing_direction == "Pull") / nrow(df_dir) * 100, 0) mid_pct <- round(sum(df_dir$swing_direction == "Middle") / nrow(df_dir) * 100, 0) oppo_pct <- round(sum(df_dir$swing_direction == "Oppo") / nrow(df_dir) * 100, 0) swing_dir <- get_swing_direction(avg_haa, batter_side) display_haa <- ifelse(batter_side == "Left", -avg_haa, avg_haa) dir_color <- case_when(swing_dir == "Pull" ~ "#E41A1C", swing_dir == "Oppo" ~ "#377EB8", TRUE ~ "#4DAF4A") plate_x <- c(-0.708, 0.708, 0.708, 0, -0.708) plate_y <- c(0, 0, -0.5, -1, -0.5) contact_x <- 0; contact_y <- 1.5; bat_length <- 2.5 bat_angle_rad <- (90 - display_haa) * pi / 180 bat_x_end <- contact_x + bat_length * cos(bat_angle_rad) bat_y_end <- contact_y + bat_length * sin(bat_angle_rad) ref_length <- 3 pull_angle <- ifelse(batter_side == "Right", 45, 135) * pi / 180 oppo_angle <- ifelse(batter_side == "Right", 135, 45) * pi / 180 center_angle <- 90 * pi / 180 ggplot() + annotate("segment", x = 0, y = 0, xend = -4, yend = 4, color = "gray70", linewidth = 0.5) + annotate("segment", x = 0, y = 0, xend = 4, yend = 4, color = "gray70", linewidth = 0.5) + annotate("polygon", x = plate_x, y = plate_y, fill = "white", color = "black", linewidth = 1) + annotate("rect", xmin = -3.5, xmax = -0.8, ymin = -1.5, ymax = 1.5, fill = NA, color = "black", linewidth = 0.8) + annotate("rect", xmin = 0.8, xmax = 3.5, ymin = -1.5, ymax = 1.5, fill = NA, color = "black", linewidth = 0.8) + annotate("text", x = ifelse(batter_side == "Right", -2.1, 2.1), y = 0, label = ifelse(batter_side == "Right", "R", "L"), size = 10, fontface = "bold", color = "gray50") + annotate("segment", x = contact_x, y = contact_y, xend = contact_x + ref_length * cos(pull_angle), yend = contact_y + ref_length * sin(pull_angle), color = "#E41A1C", linewidth = 0.8, linetype = "dashed", alpha = 0.5) + annotate("segment", x = contact_x, y = contact_y, xend = contact_x + ref_length * cos(oppo_angle), yend = contact_y + ref_length * sin(oppo_angle), color = "#377EB8", linewidth = 0.8, linetype = "dashed", alpha = 0.5) + annotate("segment", x = contact_x, y = contact_y, xend = contact_x + ref_length * cos(center_angle), yend = contact_y + ref_length * sin(center_angle), color = "#4DAF4A", linewidth = 0.8, linetype = "dashed", alpha = 0.5) + annotate("label", x = ifelse(batter_side == "Right", -3.2, 3.2), y = 4.2, label = paste0("PULL\n", pull_pct, "%"), size = 3.5, fill = "#E41A1C", color = "white", fontface = "bold") + annotate("label", x = ifelse(batter_side == "Right", 3.2, -3.2), y = 4.2, label = paste0("OPPO\n", oppo_pct, "%"), size = 3.5, fill = "#377EB8", color = "white", fontface = "bold") + annotate("label", x = 0, y = 4.8, label = paste0("CENTER\n", mid_pct, "%"), size = 3.5, fill = "#4DAF4A", color = "white", fontface = "bold") + annotate("segment", x = contact_x - (bat_x_end - contact_x) * 0.3, y = contact_y - (bat_y_end - contact_y) * 0.3, xend = bat_x_end, yend = bat_y_end, color = dir_color, linewidth = 4, arrow = arrow(length = unit(0.2, "inches"), type = "closed")) + geom_point(aes(x = contact_x, y = contact_y), fill = "white", color = "black", shape = 21, size = 8, stroke = 2) + annotate("label", x = 0, y = -2.5, label = paste0("Avg HAA: ", avg_haa, "° (", swing_dir, ")"), size = 5, fontface = "bold", fill = dir_color, color = "white", label.padding = unit(0.5, "lines")) + coord_fixed(xlim = c(-5, 5), ylim = c(-3, 5.5)) + labs(title = paste0(batter_name, " - Attack Direction", title_suffix), subtitle = paste0("n=", n_swings, " swings")) + theme_void() + theme(plot.title = element_text(hjust = 0.5, size = 16, face = "bold"), plot.subtitle = element_text(hjust = 0.5, size = 11, color = "gray40")) } # ============================================ # SINGLE SWING DETAIL CHARTS # ============================================ # Trajectory - FLIPPED (pitch from RIGHT), with red contact point create_single_trajectory_viz <- function(pitch_data) { if (nrow(pitch_data) < 1 || is.na(pitch_data$Extension[1])) { return(ggplot() + theme_void() + ggtitle("No trajectory data") + theme(plot.title = element_text(hjust = 0.5, size = 14, face = "bold"))) } row <- pitch_data[1, ] ptype <- ifelse(!is.na(row$TaggedPitchType), row$TaggedPitchType, "Other") y0 <- 60.5 - row$Extension z0 <- row$RelHeight vy0_val <- ifelse(!is.na(row$vy0), row$vy0, -130) vz0_val <- ifelse(!is.na(row$vz0), row$vz0, 2) ay_val <- ifelse(!is.na(row$ay0), row$ay0, 25) az_val <- ifelse(!is.na(row$az0), row$az0, -20) a <- 0.5 * ay_val; b <- vy0_val; c <- y0 disc <- b^2 - 4*a*c if (is.na(disc) || disc < 0) { return(ggplot() + theme_void() + ggtitle("Cannot compute trajectory") + theme(plot.title = element_text(hjust = 0.5, size = 14, face = "bold"))) } t <- (-b - sqrt(disc)) / (2*a) if (t < 0) t <- (-b + sqrt(disc)) / (2*a) if (is.na(t) || t <= 0) { return(ggplot() + theme_void() + ggtitle("Invalid trajectory") + theme(plot.title = element_text(hjust = 0.5, size = 14, face = "bold"))) } t_vals <- seq(0, t, length.out = 100) y_vals <- y0 + vy0_val * t_vals + 0.5 * ay_val * t_vals^2 z_vals <- z0 + vz0_val * t_vals + 0.5 * az_val * t_vals^2 # FLIP: x = distance from plate (0 = plate, 60.5 = rubber) traj_df <- data.frame( Distance = y_vals, # Flipped: 0 at plate Height = z_vals, PitchType = ptype ) # Final contact point final_z <- z_vals[length(z_vals)] contact_df <- data.frame(Distance = 0, Height = final_z) # Decision point decision_time <- 0.155 decision_df <- NULL if (decision_time <= t) { decision_y <- y0 + vy0_val * decision_time + 0.5 * ay_val * decision_time^2 decision_z <- z0 + vz0_val * decision_time + 0.5 * az_val * decision_time^2 decision_df <- data.frame(Distance = decision_y, Height = decision_z) } release_df <- data.frame(Distance = y0, Height = z0) p_color <- ifelse(ptype %in% names(pitch_colors), pitch_colors[[ptype]], "gray50") p <- ggplot(traj_df, aes(x = Distance, y = Height)) + geom_line(linewidth = 2.5, color = p_color, alpha = 0.9) + # Strike zone at x=0 annotate("rect", xmin = -0.2, xmax = 0.4, ymin = 1.5, ymax = 3.5, color = "black", fill = NA, linewidth = 1.5) + geom_vline(xintercept = 0, linetype = "dashed", color = "gray40", linewidth = 0.8) + # Release point geom_point(data = release_df, aes(x = Distance, y = Height), size = 5, shape = 21, fill = p_color, color = "black", stroke = 1) + # RED contact point geom_point(data = contact_df, aes(x = Distance, y = Height), size = 4, shape = 21, fill = "darkred", color = "black", stroke = 1) + scale_x_reverse(breaks = seq(0, 60, 10), limits = c(62, -2)) + # REVERSED scale_y_continuous(breaks = seq(0, 8, 1), limits = c(0, 8)) + labs(title = paste0("Pitch Trajectory: ", ptype), subtitle = "(Diamond = Decision Point)", x = "Distance to Plate (ft)", y = "Height (ft)") + theme_minimal() + theme(plot.title = element_text(hjust = 0.5, size = 12, face = "bold"), plot.subtitle = element_text(hjust = 0.5, size = 9, color = "gray50"), panel.grid.major = element_line(color = "gray90"), panel.grid.minor = element_blank(), panel.border = element_rect(color = "black", fill = NA, linewidth = 1)) if (!is.null(decision_df)) { p <- p + geom_point(data = decision_df, aes(x = Distance, y = Height), size = 4, shape = 23, fill = p_color, color = "black", stroke = 1) } p } # Zone with red contact point create_single_zone_viz <- function(pitch_data) { if (nrow(pitch_data) < 1 || is.na(pitch_data$PlateLocSide[1]) || is.na(pitch_data$PlateLocHeight[1])) { return(ggplot() + theme_void() + ggtitle("No location data") + theme(plot.title = element_text(hjust = 0.5, size = 14, face = "bold"))) } row <- pitch_data[1, ] ptype <- ifelse(!is.na(row$TaggedPitchType), row$TaggedPitchType, "Other") pitch_num <- ifelse(!is.na(row$PitchofPA), row$PitchofPA, "") ggplot() + annotate("rect", xmin = -0.8303, xmax = 0.8303, ymin = 1.5, ymax = 3.3775, alpha = 0, linewidth = 1, color = "black") + annotate("segment", x = -0.708, y = 0.15, xend = 0.708, yend = 0.15, linewidth = 0.8, color = "black") + annotate("segment", x = -0.708, y = 0.30, xend = -0.708, yend = 0.15, linewidth = 0.8, color = "black") + annotate("segment", x = 0.708, y = 0.30, xend = 0.708, yend = 0.15, linewidth = 0.8, color = "black") + annotate("segment", x = -0.708, y = 0.30, xend = 0.000, yend = 0.50, linewidth = 0.8, color = "black") + annotate("segment", x = 0.708, y = 0.30, xend = 0.000, yend = 0.50, linewidth = 0.8, color = "black") + # RED contact/pitch point geom_point(aes(x = row$PlateLocSide, y = row$PlateLocHeight), fill = "darkred", shape = 21, color = "black", stroke = 0.5, size = 6) + geom_text(aes(x = row$PlateLocSide, y = row$PlateLocHeight, label = pitch_num), vjust = 0.5, size = 2.5, color = "white", fontface = "bold") + coord_fixed(xlim = c(-2, 2), ylim = c(0, 4.5)) + labs(title = paste0("Location: ", ptype), subtitle = paste0("Pitch #", pitch_num, " of PA")) + theme_void() + theme(plot.title = element_text(hjust = 0.5, size = 12, face = "bold"), plot.subtitle = element_text(hjust = 0.5, size = 9, color = "gray50")) } # Contact Point - Fixed scales create_single_contact_viz <- function(pitch_data) { row <- pitch_data[1, ] if (is.na(row$ContactPositionX) || is.na(row$ContactPositionY) || is.na(row$ContactPositionZ)) { 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("Contact Point") + annotate("text", x = 0, y = 20, label = "No contact data", size = 4, color = "gray50") + theme_void() + theme(plot.title = element_text(hjust = 0.5, size = 12, face = "bold")) ) } contact_x <- row$ContactPositionX * 12 contact_y <- row$ContactPositionY * 12 batter_side <- ifelse(!is.na(row$BatterSide), row$BatterSide, "Right") exit_velo <- ifelse(!is.na(row$ExitSpeed), round(row$ExitSpeed, 1), NA) depth <- round(contact_y - 17, 1) ggplot() + annotate("segment", x = -8.5, y = 17, xend = 8.5, yend = 17, color = "black", linewidth = 1) + annotate("segment", x = 8.5, y = 8.5, xend = 8.5, yend = 17, color = "black", linewidth = 1) + annotate("segment", x = -8.5, y = 8.5, xend = -8.5, yend = 17, color = "black", linewidth = 1) + annotate("segment", x = -8.5, y = 8.5, xend = 0, yend = 0, color = "black", linewidth = 1) + annotate("segment", x = 8.5, y = 8.5, xend = 0, yend = 0, color = "black", linewidth = 1) + annotate("rect", xmin = 20, xmax = 48, ymin = -5, ymax = 35, fill = NA, color = "black", linewidth = 0.8) + annotate("rect", xmin = -48, xmax = -20, ymin = -5, ymax = 35, fill = NA, color = "black", linewidth = 0.8) + annotate("text", x = ifelse(batter_side == "Right", -34, 34), y = 15, label = ifelse(batter_side == "Right", "R", "L"), size = 6, fontface = "bold", color = "gray50") + annotate("segment", x = -15, y = 17, xend = 15, yend = 17, color = "blue", linewidth = 0.8, linetype = "dashed") + annotate("text", x = 16, y = 17, label = "Front", hjust = 0, size = 2.5, color = "blue") + geom_point(aes(x = contact_x, y = contact_y), fill = "darkred", color = "black", shape = 21, size = 4, stroke = 0.5) + annotate("text", x = 0, y = -12, label = paste0("Depth: ", depth, " in (", ifelse(depth > 0, "Front", "Back"), ")"), size = 3.5, fontface = "bold") + xlim(-50, 50) + ylim(-18, 50) + coord_fixed() + labs(title = "Contact Point", subtitle = ifelse(!is.na(exit_velo), paste0("EV: ", exit_velo, " mph"), "")) + theme_void() + theme(plot.title = element_text(hjust = 0.5, size = 12, face = "bold"), plot.subtitle = element_text(hjust = 0.5, size = 9, color = "gray50")) } # Spray Chart - Fixed scales create_single_spray_viz <- function(pitch_data) { row <- pitch_data[1, ] p <- ggplot() + 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") + coord_fixed(xlim = c(-360, 360), ylim = c(-20, 410), expand = FALSE) if (is.na(row$Distance) || is.na(row$Bearing) || row$PitchCall != "InPlay") { p <- p + annotate("text", x = 0, y = 200, label = "No batted ball", size = 4, color = "gray50") + labs(title = "Spray Chart", subtitle = "Ball not in play") + theme_void() + theme(plot.title = element_text(hjust = 0.5, size = 12, face = "bold"), plot.subtitle = element_text(hjust = 0.5, size = 9, color = "gray50")) return(p) } bearing_rad <- row$Bearing * pi / 180 spray_x <- row$Distance * sin(bearing_rad) spray_y <- row$Distance * cos(bearing_rad) hit_type <- ifelse(!is.na(row$TaggedHitType), row$TaggedHitType, "") distance <- round(row$Distance, 0) p + geom_point(aes(x = spray_x, y = spray_y), fill = "darkred", color = "black", shape = 21, size = 3, stroke = 0.4) + annotate("text", x = spray_x, y = spray_y + 25, label = paste0(distance, " ft"), size = 3, fontface = "bold") + labs(title = "Spray Chart", subtitle = paste0(hit_type, " - ", distance, " ft")) + theme_void() + theme(plot.title = element_text(hjust = 0.5, size = 12, face = "bold"), plot.subtitle = element_text(hjust = 0.5, size = 9, color = "gray50"), plot.margin = margin(5, 5, 5, 5)) } # Pitch Details Table (horizontal) create_pitch_details_table <- function(pitch_data) { row <- pitch_data[1, ] # Get all values pitcher <- ifelse(!is.na(row$Pitcher), row$Pitcher, "-") throws <- ifelse(!is.na(row$PitcherThrows), substr(row$PitcherThrows, 1, 1), "-") ptype <- ifelse(!is.na(row$TaggedPitchType), row$TaggedPitchType, "-") velo <- ifelse(!is.na(row$RelSpeed), round(row$RelSpeed, 1), "-") spin <- ifelse(!is.na(row$SpinRate), round(row$SpinRate, 0), "-") ivb <- ifelse(!is.na(row$InducedVertBreak), round(row$InducedVertBreak, 1), "-") hb <- ifelse(!is.na(row$HorzBreak), round(row$HorzBreak, 1), "-") vaa <- ifelse(!is.na(row$VertApprAngle), round(row$VertApprAngle, 1), "-") haa <- ifelse(!is.na(row$HorzApprAngle), round(row$HorzApprAngle, 1), "-") rel_h <- ifelse(!is.na(row$RelHeight), round(row$RelHeight, 2), "-") rel_s <- ifelse(!is.na(row$RelSide), round(row$RelSide, 2), "-") ext <- ifelse(!is.na(row$Extension), round(row$Extension, 1), "-") # Bat tracking bat_spd <- ifelse(!is.na(row$BatSpeed), round(row$BatSpeed, 1), "-") aa <- ifelse(!is.na(row$VerticalAttackAngle), round(row$VerticalAttackAngle, 1), "-") swing_haa <- ifelse(!is.na(row$HorizontalAttackAngle), round(row$HorizontalAttackAngle, 1), "-") ev <- ifelse(!is.na(row$ExitSpeed), round(row$ExitSpeed, 1), "-") la <- ifelse(!is.na(row$Angle), round(row$Angle, 1), "-") dist <- ifelse(!is.na(row$Distance), round(row$Distance, 0), "-") # Create table data table_df <- data.frame( Pitcher = paste0(pitcher, " (", throws, ")"), Pitch = ptype, Velo = velo, Spin = spin, IVB = ivb, HB = hb, VAA = vaa, HAA = haa, `Rel H` = rel_h, `Rel S` = rel_s, Ext = ext, `Bat Spd` = bat_spd, AA = aa, `Swing HAA` = swing_haa, EV = ev, LA = la, Dist = dist, check.names = FALSE ) table_df %>% gt::gt() %>% gt::tab_header(title = "Pitch & Swing Details") %>% gt::cols_align(align = "center", columns = everything()) %>% gt::tab_options( table.font.size = gt::px(11), heading.title.font.size = gt::px(14), heading.background.color = "#006F71", column_labels.background.color = "#006F71", column_labels.font.weight = "bold", table.border.top.color = "black", table.border.bottom.color = "black" ) %>% gt::tab_style(style = gt::cell_text(color = "white"), locations = gt::cells_title(groups = "title")) %>% gt::tab_style(style = gt::cell_text(color = "white"), locations = gt::cells_column_labels()) } # ============================================ # HEATMAPS # ============================================ create_bt_batspeed_heatmap <- function(batter_name, team_data, count_filter = NULL) { df <- team_data %>% filter(Batter == batter_name, !is.na(BatSpeed), !is.na(PlateLocSide), !is.na(PlateLocHeight)) if (!is.null(count_filter) && count_filter != "All") df <- df %>% filter(paste0(Balls, "-", Strikes) == count_filter) if (nrow(df) < 5) return(ggplot() + theme_void() + ggtitle("Insufficient data") + theme(plot.title = element_text(hjust = 0.5))) avg_bs <- round(mean(df$BatSpeed, na.rm = TRUE), 1) ggplot(df, aes(x = PlateLocSide, y = PlateLocHeight)) + stat_summary_2d(aes(z = BatSpeed), fun = mean, bins = 8) + scale_fill_gradientn(colours = c("#0551bc", "#02fbff", "#03ff00", "#fbff00", "#ffa503", "#ff1f02", "#dc1100"), name = "Bat Speed\n(mph)") + annotate("rect", xmin = -0.8303, xmax = 0.8303, ymin = 1.6, ymax = 3.5, fill = NA, color = "black", linewidth = 1) + 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.8) + coord_fixed(ratio = 1) + xlim(-2, 2) + ylim(0, 4.5) + ggtitle(paste0("Bat Speed - ", batter_name, " (Avg: ", avg_bs, " mph)")) + theme_void() + theme(legend.position = "right", plot.title = element_text(hjust = 0.5, size = 14, face = "bold")) } create_bt_aa_heatmap <- function(batter_name, team_data, count_filter = NULL) { df <- team_data %>% filter(Batter == batter_name, !is.na(VerticalAttackAngle), !is.na(PlateLocSide), !is.na(PlateLocHeight)) if (!is.null(count_filter) && count_filter != "All") df <- df %>% filter(paste0(Balls, "-", Strikes) == count_filter) if (nrow(df) < 5) return(ggplot() + theme_void() + ggtitle("Insufficient data") + theme(plot.title = element_text(hjust = 0.5))) avg_aa <- round(mean(df$VerticalAttackAngle, na.rm = TRUE), 1) ggplot(df, aes(x = PlateLocSide, y = PlateLocHeight)) + stat_summary_2d(aes(z = VerticalAttackAngle), fun = mean, bins = 8) + scale_fill_gradientn(colours = c("#0551bc", "#02fbff", "#03ff00", "#fbff00", "#ffa503", "#ff1f02", "#dc1100"), name = "Attack\nAngle (°)") + annotate("rect", xmin = -0.8303, xmax = 0.8303, ymin = 1.6, ymax = 3.5, fill = NA, color = "black", linewidth = 1) + 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.8) + coord_fixed(ratio = 1) + xlim(-2, 2) + ylim(0, 4.5) + ggtitle(paste0("Attack Angle - ", batter_name, " (Avg: ", avg_aa, "°)")) + theme_void() + theme(legend.position = "right", plot.title = element_text(hjust = 0.5, size = 14, face = "bold")) } create_bt_haa_heatmap <- function(batter_name, team_data, count_filter = NULL) { df <- team_data %>% filter(Batter == batter_name, !is.na(HorizontalAttackAngle), !is.na(PlateLocSide), !is.na(PlateLocHeight)) if (!is.null(count_filter) && count_filter != "All") df <- df %>% filter(paste0(Balls, "-", Strikes) == count_filter) if (nrow(df) < 5) return(ggplot() + theme_void() + ggtitle("Insufficient data") + theme(plot.title = element_text(hjust = 0.5))) avg_haa <- round(mean(df$HorizontalAttackAngle, na.rm = TRUE), 1) ggplot(df, aes(x = PlateLocSide, y = PlateLocHeight)) + stat_summary_2d(aes(z = HorizontalAttackAngle), fun = mean, bins = 8) + scale_fill_gradientn(colours = c("#377EB8", "#02fbff", "#03ff00", "#fbff00", "#ffa503", "#E41A1C"), name = "HAA (°)") + annotate("rect", xmin = -0.8303, xmax = 0.8303, ymin = 1.6, ymax = 3.5, fill = NA, color = "black", linewidth = 1) + 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.8) + coord_fixed(ratio = 1) + xlim(-2, 2) + ylim(0, 4.5) + ggtitle(paste0("Attack Direction - ", batter_name, " (Avg: ", avg_haa, "°)")) + theme_void() + theme(legend.position = "right", plot.title = element_text(hjust = 0.5, size = 14, face = "bold")) } # ============================================ # GT TABLE # ============================================ create_bt_table <- function(batter_name, team_data) { df <- team_data %>% filter(Batter == batter_name, !is.na(BatSpeed)) %>% mutate(swing_direction = get_swing_direction(HorizontalAttackAngle, BatterSide)) if (nrow(df) < 3) return(gt::gt(data.frame(Note = "Insufficient data"))) overall <- df %>% summarize(` ` = "Overall", Swings = n(), `Avg BS` = round(mean(BatSpeed, na.rm = TRUE), 1), `Max BS` = round(max(BatSpeed, na.rm = TRUE), 1), `Avg AA` = round(mean(VerticalAttackAngle, na.rm = TRUE), 1), `Avg HAA` = round(mean(HorizontalAttackAngle, na.rm = TRUE), 1), `Avg VAA` = round(mean(VertApprAngle, na.rm = TRUE), 1), `Pull%` = round(sum(swing_direction == "Pull", na.rm = TRUE) / n() * 100, 1), `Mid%` = round(sum(swing_direction == "Middle", na.rm = TRUE) / n() * 100, 1), `Oppo%` = round(sum(swing_direction == "Oppo", na.rm = TRUE) / n() * 100, 1), `Avg LA` = round(mean(Angle, na.rm = TRUE), 1), .groups = "drop") by_count <- df %>% mutate(ct = case_when(Balls > Strikes ~ "Hitter's", Strikes > Balls ~ "Pitcher's", Balls == 0 & Strikes == 0 ~ "0-0", TRUE ~ "Even")) %>% group_by(ct) %>% summarize(Swings = n(), `Avg BS` = round(mean(BatSpeed, na.rm = TRUE), 1), `Max BS` = round(max(BatSpeed, na.rm = TRUE), 1), `Avg AA` = round(mean(VerticalAttackAngle, na.rm = TRUE), 1), `Avg HAA` = round(mean(HorizontalAttackAngle, na.rm = TRUE), 1), `Avg VAA` = round(mean(VertApprAngle, na.rm = TRUE), 1), `Pull%` = round(sum(swing_direction == "Pull", na.rm = TRUE) / n() * 100, 1), `Mid%` = round(sum(swing_direction == "Middle", na.rm = TRUE) / n() * 100, 1), `Oppo%` = round(sum(swing_direction == "Oppo", na.rm = TRUE) / n() * 100, 1), `Avg LA` = round(mean(Angle, na.rm = TRUE), 1), .groups = "drop") %>% rename(` ` = ct) by_hand <- df %>% filter(PitcherThrows %in% c("Right", "Left")) %>% group_by(PitcherThrows) %>% summarize(Swings = n(), `Avg BS` = round(mean(BatSpeed, na.rm = TRUE), 1), `Max BS` = round(max(BatSpeed, na.rm = TRUE), 1), `Avg AA` = round(mean(VerticalAttackAngle, na.rm = TRUE), 1), `Avg HAA` = round(mean(HorizontalAttackAngle, na.rm = TRUE), 1), `Avg VAA` = round(mean(VertApprAngle, na.rm = TRUE), 1), `Pull%` = round(sum(swing_direction == "Pull", na.rm = TRUE) / n() * 100, 1), `Mid%` = round(sum(swing_direction == "Middle", na.rm = TRUE) / n() * 100, 1), `Oppo%` = round(sum(swing_direction == "Oppo", na.rm = TRUE) / n() * 100, 1), `Avg LA` = round(mean(Angle, na.rm = TRUE), 1), .groups = "drop") %>% mutate(` ` = paste0("vs ", PitcherThrows)) %>% select(-PitcherThrows) bind_rows(overall, by_count, by_hand) %>% gt::gt() %>% gtExtras::gt_theme_guardian() %>% gt::tab_header(title = paste("Bat Tracking Summary:", batter_name)) %>% gt::fmt_number(columns = c(`Avg BS`, `Max BS`, `Avg AA`, `Avg HAA`, `Avg VAA`, `Pull%`, `Mid%`, `Oppo%`, `Avg LA`), decimals = 1) %>% gt::cols_align(align = "center", columns = everything()) %>% gt::sub_missing(columns = everything(), missing_text = "-") %>% gt::tab_options(heading.background.color = "darkcyan", column_labels.background.color = "darkcyan") %>% gt::tab_style(style = gt::cell_text(color = "white"), locations = gt::cells_title(groups = "title")) %>% gt::data_color(columns = `Avg BS`, fn = scales::col_numeric(palette = c("#E1463E", "white", "#00840D"), domain = NULL)) } create_hitting_grades_table <- function(batter_name, team_data) { # Calculate hitting grades batter_split <- team_data %>% filter(!is.na(TaggedPitchType), Batter == batter_name) %>% calculate_rv100() %>% group_by(PitcherThrows) %>% summarise( xBA = mean(xBA[PitchCall == "InPlay"], na.rm = TRUE), ISO = (sum(totalbases, na.rm = TRUE) / sum(ABindicator, na.rm = TRUE)) - (sum(HitIndicator, na.rm = TRUE) / sum(ABindicator, na.rm = TRUE)), xPowerFactor = sum(totalbases[PitchCall == "InPlay"], na.rm = TRUE) / sum(HitIndicator[PitchCall == "InPlay"], na.rm = TRUE), sdrv = 100 * mean(hitter_rv, na.rm = TRUE), zone_con = 100 * (1 - sum(Zwhiffind, na.rm = TRUE) / sum(Zswing, na.rm = TRUE)), `K%` = 100 * sum(KorBB == "Strikeout", na.rm = TRUE) / sum(PAindicator, na.rm = TRUE), ev90 = quantile(ExitSpeed[PitchCall == "InPlay"], 0.9, na.rm = TRUE), con2k = 100 * (1 - sum(WhiffIndicator[Strikes == 2], na.rm = TRUE) / sum(SwingIndicator[Strikes == 2], na.rm = TRUE)), .groups = "drop" ) %>% rowwise() %>% mutate( `Swing Decisions` = ((sdrv - grade_metrics$sdrv_mean) / grade_metrics$sdrv_sd) * 10 + 50, `Game Power` = (((xPowerFactor - grade_metrics$xpf_mean) / grade_metrics$xpf_sd) + ((ISO - grade_metrics$iso_mean) / grade_metrics$iso_sd)) * 5 + 50, `Raw Power` = ((ev90 - grade_metrics$ev90_mean) / grade_metrics$ev90_sd) * 10 + 50, `Avoid K` = (((grade_metrics$k_mean - `K%`) / grade_metrics$k_sd) + ((con2k - grade_metrics$con2k_mean) / grade_metrics$con2k_sd)) * 5 + 50, Contact = (((zone_con - grade_metrics$zone_con_mean) / grade_metrics$zone_con_sd) + ((xBA - grade_metrics$xba_mean) / grade_metrics$xba_sd)) * 5 + 50 ) %>% ungroup() %>% mutate(across(c(`Swing Decisions`, `Game Power`, `Raw Power`, `Avoid K`, Contact), ~round(pmax(pmin(., 80), 20)))) %>% select(PitcherThrows, `Swing Decisions`, `Raw Power`, `Game Power`, `Avoid K`, Contact) %>% rename(` ` = PitcherThrows) if (!nrow(batter_split)) { return(gt::gt(data.frame(Note = "No data available"))) } # Create gt table with color coding batter_split %>% gt::gt() %>% gtExtras::gt_theme_guardian() %>% gt::tab_header(title = paste("20-80 Grades", batter_name)) %>% gt::cols_align(align = "center", columns = gt::everything()) %>% gt::tab_options( heading.background.color = "darkcyan", column_labels.background.color = "darkcyan", table.border.top.color = "peru", table.border.bottom.color = "peru" ) %>% gt::tab_style( style = gt::cell_text(color = "white"), locations = gt::cells_title(groups = "title") ) %>% gt::data_color( columns = c(`Swing Decisions`, `Raw Power`, `Game Power`, `Avoid K`, Contact), fn = scales::col_numeric( palette = c("#E1463E", "white", "#00840D"), domain = c(20, 80) ) ) } parse_game_day <- function(df, tz = "America/New_York") { stopifnot("Date" %in% names(df)) to_posix <- function(x) { if (inherits(x, c("POSIXct","POSIXlt"))) return(x) if (inherits(x, "Date")) return(as.POSIXct(x, tz = tz)) x <- as.character(x) suppressWarnings({ p <- as.POSIXct(x, tz = tz, tryFormats = c( "%Y-%m-%d %H:%M:%S", "%Y/%m/%d %H:%M:%S", "%Y-%m-%d", "%Y/%m/%d", "%m/%d/%Y %H:%M:%S", "%m/%d/%Y", "%m/%d/%y %H:%M:%S", "%m/%d/%y" )) }) if (all(is.na(p))) { if (requireNamespace("lubridate", quietly = TRUE)) { p <- lubridate::parse_date_time( x, orders = c("Ymd HMS","Ymd","mdY HMS","mdY","mdy HMS","mdy","ymd HMS","ymd"), tz = tz, truncated = 3 ) } } p } df$Date <- to_posix(df$Date) return(df) } 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; } .btn-primary { background-color: #006F71 !important; border-color: #006F71 !important; color: white !important; font-weight: bold; width: 100%; margin-top: 10px; box-shadow: 0 2px 5px #006F71; transition: background-color 0.3s ease; } .btn-primary:hover { background-color: #006F71 !important; border-color: #A27752 !important; } .form-control { border-radius: 4px; border: 1.5px solid #006F71 !important; color: #006F71; font-weight: 600; } ")), div(class = "login-container", tags$img(src="https://upload.wikimedia.org/wikipedia/en/thumb/e/ef/Coastal_Carolina_Chanticleers_logo.svg/1200px-Coastal_Carolina_Chanticleers_logo.svg.png", height="150px"), passwordInput("password", "Password:"), actionButton("login", "Login"), textOutput("wrong_pass") ) ) app_ui <- fluidPage( tags$head( tags$style(HTML(" body, table, .gt_table { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; } .app-header { display:flex; justify-content:space-between; align-items:center; padding:20px 40px; background:#ffffff; border-bottom:3px solid darkcyan; margin-bottom:20px; } .header-logo-left, .header-logo-right { width:120px; height:auto; } .header-logo-center { max-width:400px; height:auto; } @media (max-width:768px){ .app-header{ flex-direction:column; padding:15px 20px; } .header-logo-left, .header-logo-right { width:80px; } .header-logo-center { max-width:250px; margin:10px 0; } } .nav-tabs{ border:none !important; border-radius:50px; padding:6px 12px; margin:20px auto 0; max-width:100%; background:linear-gradient(135deg, #d4edeb 0%, #e8ddd0 50%, #d4edeb 100%); box-shadow:0 4px 16px rgba(0,139,139,.12), inset 0 2px 4px rgba(255,255,255,.6); border:1px solid rgba(0,139,139,.2); position:relative; overflow-x:auto; -webkit-overflow-scrolling:touch; display:flex; justify-content:center; align-items:center; flex-wrap:wrap; gap:6px; } .nav-tabs::-webkit-scrollbar{ height:0; } .nav-tabs::before{ content:''; position:absolute; inset:0; pointer-events:none; border-radius:50px; background:linear-gradient(135deg, rgba(255,255,255,.4), transparent); } .nav-tabs>li>a{ color:darkcyan !important; border:none !important; border-radius:50px !important; background:transparent !important; font-weight:700; font-size:14.5px; padding:10px 22px; white-space:nowrap; letter-spacing:.2px; transition:all .2s ease; } .nav-tabs>li>a:hover{ color:#006666 !important; background:rgba(255,255,255,.5) !important; transform:translateY(-1px); } .nav-tabs>li.active>a, .nav-tabs>li.active>a:focus, .nav-tabs>li.active>a:hover{ background:linear-gradient(135deg,#008b8b 0%,#20b2aa 30%,#00ced1 50%,#20b2aa 70%,#008b8b 100%) !important; color:#fff !important; text-shadow:0 1px 2px rgba(0,0,0,.2); box-shadow:0 4px 16px rgba(0,139,139,.4), inset 0 2px 8px rgba(255,255,255,.4), inset 0 -2px 6px rgba(0,0,0,.2); border:1px solid rgba(255,255,255,.3) !important; } .nav-tabs>li>a:focus{ outline:3px solid rgba(205,133,63,.6); outline-offset:2px; } .tab-content{ background:linear-gradient(135deg, rgba(255,255,255,.95), rgba(248,249,250,.95)); border-radius:20px; padding:25px; margin-top:14px; box-shadow:0 15px 40px rgba(0,139,139,.1); backdrop-filter:blur(15px); border:1px solid rgba(0,139,139,.1); position:relative; overflow:hidden; } .tab-content::before{ content:''; position:absolute; left:0; right:0; top:0; height:4px; background:linear-gradient(90deg, darkcyan, peru, darkcyan); background-size:200% 100%; animation:shimmer 3s linear infinite; } @keyframes shimmer { 0%{background-position:-200% 0;} 100%{background-position:200% 0;} } .well { background:#ffffff; border-radius:12px; border:1px solid rgba(0,139,139,.15); box-shadow:0 6px 18px rgba(0,0,0,.06); } .control-label{ font-weight:700; color:#0a6a6a; } .selectize-input, .form-control{ border-radius:10px; border:1px solid rgba(0,139,139,.35); } .datatables, .dataTables_wrapper .dataTables_filter input{ border-radius:10px; } .gt_table{ border-radius:12px; overflow:hidden; border:1px solid rgba(0,139,139,.15); } ")) ), div(class = "app-header", tags$img(src = "https://i.imgur.com/7vx5Ci8.png", class = "header-logo-left", alt = "Logo Left"), tags$img(src = "https://i.imgur.com/Sq0CZ0F.png", class = "header-logo-center", alt = "Main Logo"), tags$img(src = "https://i.imgur.com/VbrN5WV.png", class = "header-logo-right", alt = "Logo Right") ), tabsetPanel(id = "tabs", tabPanel("Dashboard", fluidRow( column( width = 3, wellPanel( checkboxGroupInput("seasonSelect", "Season", choices = c("Spring 2026", "Preseason Spring 2026", "Spring 2025", "Fall 2025"), selected = c("Spring 2026", "Preseason Spring 2026"), inline = FALSE), hr(), selectInput("playerSelect", "Player", choices = sort(unique(na.omit(all_seasonal_data$Batter))), selected = sort(unique(na.omit(all_seasonal_data$Batter)))[1], multiple = FALSE), dateRangeInput( "dateRange", "Date range", start = suppressWarnings(min(all_seasonal_data$Date, na.rm = TRUE)), end = suppressWarnings(max(all_seasonal_data$Date, na.rm = TRUE)), min = suppressWarnings(min(all_seasonal_data$Date, na.rm = TRUE)), max = suppressWarnings(max(all_seasonal_data$Date, na.rm = TRUE)) ), checkboxGroupInput("BatterHand", "Batter side", choices = c("Right","Left"), selected = c("Right","Left"), inline = TRUE), checkboxGroupInput("PitcherHand", "Pitcher throws", choices = c("Right","Left"), selected = c("Right","Left"), inline = TRUE), { pt_choices <- sort(unique(na.omit(as.character(all_seasonal_data$TaggedPitchType)))) selectizeInput("pitchtype", "Pitch types", choices = pt_choices, selected = pt_choices, multiple = TRUE, options = list(plugins = list("remove_button"))) }, { vmin <- floor(suppressWarnings(min(all_seasonal_data$RelSpeed, na.rm = TRUE))) if (!is.finite(vmin)) vmin <- 50 vmax <- ceiling(suppressWarnings(max(all_seasonal_data$RelSpeed, na.rm = TRUE))) if (!is.finite(vmax)) vmax <- 105 sliderInput("veloRange", "Velo (mph)", min=vmin, max=vmax, value=c(vmin, vmax), step=1) }, { ivbmin <- floor(suppressWarnings(min(all_seasonal_data$InducedVertBreak, na.rm = TRUE))) if (!is.finite(ivbmin)) ivbmin <- -30 ivbmax <- ceiling(suppressWarnings(max(all_seasonal_data$InducedVertBreak, na.rm = TRUE))) if (!is.finite(ivbmax)) ivbmax <- 30 sliderInput("ivbrange", "IVB", min=ivbmin, max=ivbmax, value=c(ivbmin, ivbmax), step=1) }, { hbmin <- floor(suppressWarnings(min(all_seasonal_data$HorzBreak, na.rm = TRUE))) if (!is.finite(hbmin)) hbmin <- -30 hbmax <- ceiling(suppressWarnings(max(all_seasonal_data$HorzBreak, na.rm = TRUE))) if (!is.finite(hbmax)) hbmax <- 30 sliderInput("hbrange", "HB", min=hbmin, max=hbmax, value=c(hbmin, hbmax), step=1) } ) ), column( width = 9, uiOutput("selectedSeasonsDisplay"), plotOutput("player_header_plot", height = 170), br(), gt_output("last15_games_table"), br(), fluidRow( column(6, plotOutput("hitting_grades_radar", height = 420) ), column(6, gt_output("hitting_grades_table"), tags$div( style = "margin-top: 10px; font-size: 11px;", tags$strong("Swing Decisions:"), " Run Value of swing/take choices", tags$br(), tags$strong("Raw Power:"), " 90th percentile exit velocity", tags$br(), tags$strong("Game Power:"), " ISO + xPowerFactor (XBH production)", tags$br(), tags$strong("Avoid K:"), " K% + 2-strike contact rate", tags$br(), tags$strong("Contact:"), " Zone contact% + xBA quality" ) ) ), hr(), plotOutput("performance_zone_plot", height = 380), br(), h3("Advanced Numbers"), gt_output("adv_table"), br(), h3("Splits"), gt_output("fb_splits_table"), br(), gt_output("offspeed_splits_table") ) ) ), tabPanel("Spray & Batted Ball", fluidRow( column(3, wellPanel( h4("Spray Chart Options", style = "color: darkcyan;"), selectInput("spray_type", "Chart Type", choices = c( "Interactive (Hover)" = "plotly", "Schill Style" = "schill_basic", "Sector Zones" = "sector", "Trajectories" = "trajectory", "2-Strike Comparison" = "2k", "Classic" = "classic" ), selected = "plotly" ) ) ), column(9, conditionalPanel( condition = "input.spray_type == 'plotly'", plotlyOutput("spray_chart_plotly", height = 520) ), conditionalPanel( condition = "input.spray_type == 'schill_basic'", plotOutput("spray_chart_schill_basic", height = 600) ), conditionalPanel( condition = "input.spray_type == 'sector'", plotOutput("spray_chart_sector", height = 480) ), conditionalPanel( condition = "input.spray_type == 'trajectory'", plotOutput("spray_chart_trajectory", height = 480) ), conditionalPanel( condition = "input.spray_type == '2k'", plotOutput("spray_chart_2k", height = 480) ), conditionalPanel( condition = "input.spray_type == 'classic'", plotOutput("spray_chart_classic", height = 480) ) ) ), hr(), fluidRow( column(6, gt_output("bb_profile_table")), column(6, tableOutput("quality_table")) ) ), tabPanel("Contact / Radial", fluidRow( column(6, wellPanel( h4("Contact Map"), plotOutput("contact_map", height = 420) ) ), column(6, plotOutput("radial_chart", height = 420) ) ) ), tabPanel("Heatmaps", fluidRow( column(3, wellPanel( selectInput("heat_metric", "Metric", choices = c("Solid Contact", "Hard-Hit (95+)", "Whiffs", "Swings", "10-30 LA", "Hits", "XBH", "All Balls in Play"), selected = "Hard-Hit (95+)"), hr(), p("Note: Use the Dashboard filters on the left to filter by pitcher hand, pitch type, velocity, etc.", style = "font-size: 11px; color: #666; font-style: italic;") ) ), column(9, plotOutput("heatmap_plot", height = 480)) ) ), tabPanel("Percentiles", fluidRow( column(3, wellPanel( radioButtons("pool_select", "Compare against", choices = c("Sun Belt (2025)" = "SBC", "Power 5 (2025)" = "P5", "SEC (2025)" = "SEC", "Team (filtered)" = "TEAM"), selected = "SBC") ) ), column(9, plotOutput("percentiles_plot", height = 540)) ) ), tabPanel("Whiff Movement", plotOutput("whiff_movement_plot", height = 520) ), tabPanel( "Bat Tracking", fluidRow( column( 3, wellPanel( h4("Bat Tracking Controls", style = "color: darkcyan; margin-top: 0;"), hr(), selectInput( "bt_swing_select", "View Swing", choices = c("All Swings" = "All"), selected = "All" ), helpText("Select 'All Swings' for aggregate view, or choose a specific swing."), hr(), h5("Filters", style = "color: darkcyan;"), selectInput( "bt_count_filter", "Count", choices = c( "All", "0-0", "0-1", "0-2", "1-0", "1-1", "1-2", "2-0", "2-1", "2-2", "3-0", "3-1", "3-2" ), selected = "All" ), selectizeInput( "bt_pitch_type", "Pitch Type", choices = c( "Fastball", "Sinker", "Cutter", "Slider", "Curveball", "ChangeUp", "Splitter" ), selected = c( "Fastball", "Sinker", "Cutter", "Slider", "Curveball", "ChangeUp", "Splitter" ), multiple = TRUE, options = list(plugins = list("remove_button")) ), checkboxGroupInput( "bt_pitcher_hand", "Pitcher Throws", choices = c("Right", "Left"), selected = c("Right", "Left"), inline = TRUE ), checkboxGroupInput( "bt_result_filter", "Swing Result", choices = c("InPlay", "StrikeSwinging", "FoulBall"), selected = c("InPlay", "StrikeSwinging", "FoulBall") ) ) ), column( 9, fluidRow( column(6, plotOutput("bt_simple_aa_viz", height = 300)), column(6, plotOutput("bt_attack_dir_viz", height = 300)) ), fluidRow( column(6, plotOutput("bt_aa_la_viz", height = 350)), column(6, plotOutput("bt_aa_vaa_viz", height = 350)) ), conditionalPanel( condition = "input.bt_swing_select != 'All'", hr(), h4("Single Swing Details", style = "text-align: center; color: darkcyan;"), gt_output("bt_pitch_details_table"), br(), fluidRow( column(6, plotOutput("bt_single_trajectory", height = 280)), column(6, plotOutput("bt_single_zone", height = 280)) ), fluidRow( column(6, plotOutput("bt_single_contact", height = 280)), column(6, plotOutput("bt_single_spray", height = 280)) ) ), hr(), h4("Bat Tracking Summary", style = "text-align: center; color: darkcyan;"), gt_output("bt_comprehensive_table"), hr(), h4("Zone Heatmaps", style = "text-align: center; color: darkcyan;"), fluidRow( column(4, plotOutput("bt_heatmap_batspeed", height = 320)), column(4, plotOutput("bt_heatmap_aa", height = 320)), column(4, plotOutput("bt_heatmap_haa", height = 320)) ), hr(), h4("Team Bat Tracking Leaderboard", style = "text-align: center; color: darkcyan;"), DT::dataTableOutput("bt_leaderboard_table") ) ) ), tabPanel("Fielding & Baserunning", sidebarLayout( sidebarPanel( selectInput("position", "Select Position:", choices = NULL) ), mainPanel( conditionalPanel( condition = "output.is_outfielder", plotlyOutput("star_graph", height = 520), plotlyOutput("field_graph_plot", height = 520), gt_output("field_graph_table"), plotOutput("direction_oaa_plot", height = 520), gt_output("direction_oaa_table") ), conditionalPanel( condition = "output.is_infielder", plotlyOutput("infield_star_graph", height = 520), plotlyOutput("infield_field_graph_plot", height = 520), gt_output("infield_field_graph_table") ) ) ) ), tabPanel("Team Defensive Positioning", fluidRow( column(2, selectInput("team_1", "Team:", choices = c(unique(def_pos_data$PitcherTeam)), selected = "Coastal Carolina")), column(2, selectInput("man_on_first_1", "Runner on 1st:", choices = c("No Filter", TRUE, FALSE), selected = "No Filter")), column(2, selectInput("man_on_second_1", "Runner on 2nd:", choices = c("No Filter", TRUE, FALSE), selected = "No Filter")), column(2, selectInput("man_on_third_1", "Runner on 3rd:", choices = c("No Filter", TRUE, FALSE), selected = "No Filter")), column(2, selectInput("BatterHand_1", "Bat Hand:", choices = c("No Filter", "Right", "Left"), selected = "No Filter")) ), fluidRow( column(2, selectInput("PitcherHand_1", "Pitch Hand:", choices = c("No Filter", "Right", "Left"), selected = "No Filter")), column(2, selectInput("scorediff_1", "Score Diff:", choices = c("No Filter", -4:4), selected = "No Filter")), column(2, dateInput("Date1_1", "From:", value = "2025-02-14")), column(2, dateInput("Date2_1", "To:", value = "2025-06-22")), column(2, selectInput("obs_pitcher_1", "Pitcher:", choices = c("No Filter", unique(def_pos_data$Pitcher)), selected = "No Filter")) ), fluidRow( column(2, selectInput("Hitter_1", "Batter:", choices = c("No Filter", unique(def_pos_data$Batter)), selected = "No Filter")), column(2, selectInput("Count_1", "Count:", choices = c("No Filter", unique(def_pos_data$pitch_count)), selected = "No Filter")), column(2, selectInput("obs_outs_1", "Outs:", choices = c("No Filter", 0, 1, 2), selected = "No Filter")), column(2, selectInput("Opponent_1", "Opponent:", choices = c("No Filter", unique(def_pos_data$BatterTeam)), selected = "No Filter")) ), fluidRow( column(12, plotOutput("first_def_pos_plot", height = "90vh") ) ) ), tabPanel("Game View", fluidRow( column(3, wellPanel( uiOutput("game_controls") ) ), column(9, h4("At-Bat Breakdown"), plotOutput("at_bats_plot", height = 600) ) ), hr(), fluidRow( column(12, h4("Pitch-by-Pitch Details"), gt_output("hitter_game_table") ) ), hr(), h3("Weekly Hitter Report", style = "text-align: center; color: darkcyan;"), fluidRow( column(3, wellPanel( numericInput("weekly_n_games", "Number of recent games:", value = 3, min = 1, max = 7), textAreaInput("weekly_notes", "Notes:", value = "", rows = 4, placeholder = "Add scouting notes here..."), downloadButton("download_weekly_report", "Download Weekly Report (PDF)", style = "background-color: darkcyan; color: white; width: 100%;") ) ), column(9, plotOutput("weekly_report_preview", height = "900px") ) ) ), tabPanel("BP", fluidRow( column(3, wellPanel( selectizeInput( "bp_player", "BP Player", choices = if(nrow(BP_data) > 0) sort(unique(na.omit(BP_data$Batter))) else character(0), selected = if(nrow(BP_data) > 0 && length(unique(na.omit(BP_data$Batter))) > 0) sort(unique(na.omit(BP_data$Batter)))[1] else NULL ), hr(), selectizeInput( "bp_players_filter", "Show Players in Leaderboard", choices = if(nrow(BP_data) > 0) sort(unique(na.omit(BP_data$Batter))) else character(0), selected = if(nrow(BP_data) > 0) sort(unique(na.omit(BP_data$Batter))) else character(0), multiple = TRUE, options = list(plugins = list("remove_button")) ), hr(), dateRangeInput( "bp_dateRange", "BP date range", start = if(nrow(BP_data)) suppressWarnings(min(BP_data$Date, na.rm = TRUE)) else as.Date("2025-01-01"), end = if(nrow(BP_data)) suppressWarnings(max(BP_data$Date, na.rm = TRUE)) else as.Date("2025-12-31") ), checkboxGroupInput( "bp_columns", "BP columns to show", choices = c("BBE","Avg EV","Max EV","SC%","10-30%","HH%","Barrel%","GB%","LD%","FB%","Pop%", "Pull%","Straight%","Oppo%","Avg Distance","Max Distance","Avg LA","Avg Spin","Neg%", "0-10%", "10-20%", "20-30%", "30+%"), selected = c("BBE","Avg EV","Avg LA","SC%","10-30%","HH%","Barrel%"), inline = FALSE ) ) ), column(9, # ------------------ Stats on top ------------------ fluidRow( column(12, gt_output("bp_player_stats_table")) ), hr(), # ------------------ Plots ------------------ fluidRow( column(6, plotOutput("bp_spray_plot", height = 360)), column(6, plotOutput("bp_zone_plot", height = 360)) ), br(), fluidRow( column(6, plotOutput("bp_radial_plot", height = 400)), column(6, plotOutput("bp_contact_plot", height = 360)) ), hr(), h4("Exit Velocity Heatmaps", style = "text-align: center; color: darkcyan;"), fluidRow( column(4, plotOutput("bp_heatmap_hard", height = 320)), column(4, plotOutput("bp_heatmap_medium", height = 320)), column(4, plotOutput("bp_heatmap_soft", height = 320)) ) ) ), hr(), # ------------------ Leaderboards ------------------ fluidRow( column(7, DT::dataTableOutput("bp_leaderboard_table")), column(5, DT::dataTableOutput("bp_team_summary_table")) ) ), tabPanel("Daily EV Tracker", fluidRow( column(3, wellPanel( h4("Date Selection", style = "color: darkcyan;"), dateRangeInput( "daily_ev_dateRange", "Date range", start = if (nrow(BP_data)) suppressWarnings(min(BP_data$Date, na.rm = TRUE)) else as.Date("2025-01-01"), end = if (nrow(BP_data)) suppressWarnings(max(BP_data$Date, na.rm = TRUE)) else as.Date("2025-12-31") ), hr(), h4("Player Selection", style = "color: darkcyan;"), selectizeInput("daily_ev_players", "Select Players", choices = if(nrow(BP_data) > 0) { sort(unique(na.omit(BP_data$Batter))) } else { character(0) }, selected = if(nrow(BP_data) > 0) { sort(unique(na.omit(BP_data$Batter))) } else { character(0) }, multiple = TRUE, options = list(plugins = list("remove_button"))), hr(), h4("Display Options", style = "color: darkcyan;"), radioButtons("ev_metric", "EV Metric to Show:", choices = c("Average EV" = "avg", "Maximum EV" = "max", "Hard Hit %" = "hh_pct"), selected = "avg"), hr(), checkboxInput("show_team_row", "Show Team Average Row", value = TRUE) ) ), column(9, h3("Daily Exit Velocity Tracker", style = "text-align: center; color: darkcyan;"), br(), DT::dataTableOutput("daily_ev_pivot_table", width = "100%") ) ) ), tabPanel("Leaderboard (Team)", fluidRow( column(3, wellPanel( checkboxGroupInput("leaderboard_seasonSelect", # FIXED: Changed from "seasonSelect" "Season", choices = c("Spring 2026", "Preseason Spring 2026", "Spring 2025", "Fall 2025"), selected = c("Spring 2026", "Preseason Spring 2026"), inline = FALSE), hr(), selectizeInput("leaderboard_players", "Select Players", choices = sort(unique(na.omit(all_seasonal_data$Batter))), selected = sort(unique(na.omit(all_seasonal_data$Batter))), multiple = TRUE, options = list(plugins = list("remove_button"))), hr(), checkboxGroupInput("leaderboard_PitcherHand", "Pitcher throws", choices = c("Right","Left"), selected = c("Right","Left"), inline = TRUE), hr(), { pt_choices <- sort(unique(na.omit(as.character(all_seasonal_data$TaggedPitchType)))) selectizeInput("leaderboard_pitchtype", "Pitch types", choices = pt_choices, selected = pt_choices, multiple = TRUE, options = list(plugins = list("remove_button"))) }, hr(), checkboxGroupInput("team_columns", "Select Columns to Display", choices = c("Pitches", "PA", "AB", "BBE", "AVG", "OBP", "SLG", "OPS", "K %", "BB %", "Free%", "Avg EV", "Max EV", "SC%", "10-30%", "HH%", "Barrel%", "Whiff%", "Z Whiff%", "Chase%", "GB%", "LD%", "FB%", "Pop%", "Pull%", "Middle%", "Oppo%", "Pull FB%", "Neg%", "0-10%", "10-20%", "20-30%", "30+%"), selected = c("OPS", "K %", "BB %", "AVG", "Avg EV", "SC%", "HH%", "Whiff%"), inline = FALSE) ) ), column(9, uiOutput("leaderboard_selectedSeasonsDisplay"), DT::dataTableOutput("statistics_table"), br(), h4("Team Total", style = "text-align: center; color: darkcyan;"), DT::dataTableOutput("team_total_table") ) ) ) ) ) # ---------- Root UI wrapper (login gating) ---------- ui <- fluidPage(uiOutput("mainUI")) server <- function(input, output, session){ # --- auth --- authed <- reactiveVal(FALSE) observeEvent(input$login, { if (!nzchar(PASSWORD)) { authed(TRUE) output$wrong_pass <- renderText("") } else if (identical(input$password, PASSWORD)) { authed(TRUE) output$wrong_pass <- renderText("") } else { authed(FALSE) output$wrong_pass <- renderText("Incorrect password.") } }) output$mainUI <- renderUI({ if (isTRUE(authed())) app_ui else login_ui }) # --- helpers defined once (BP table coloring) --- if (!exists("format_bp_leaderboard", mode = "function")) { format_bp_leaderboard <- function(dt_table, data_df) { rank_colors <- colorRampPalette(c("#E1463E","white","#00840D"))(100) rank_colors2 <- colorRampPalette(c("#00840D","white","#E1463E"))(100) apply_color <- function(obj, column, reverse = FALSE) { if (!column %in% names(data_df)) return(obj) vals <- suppressWarnings(as.numeric(data_df[[column]])) vals <- vals[is.finite(vals)] if (!length(vals)) return(obj) cols <- if (reverse) rank_colors2 else rank_colors rng <- range(vals, na.rm = TRUE) if (!is.finite(rng[1]) || !is.finite(rng[2])) return(obj) if (identical(rng[1], rng[2])) { return(formatStyle(obj, column, backgroundColor = styleEqual(rng[1], cols[75]))) } cuts <- seq(rng[1], rng[2], length.out = 99) formatStyle(obj, column, backgroundColor = styleInterval(cuts, cols)) } good_cols <- c("EV","Avg EV","Max EV","MaxEV","SC%","10-30%","95+%","HH%","Barrel%","LD%","FB%","DIST","Avg LA","Avg Distance","Max Distance", "0-10%", "10-20%", "20-30%") bad_cols <- c("LA","GB%","Pop%","Neg%","30+%") out <- dt_table for (cl in intersect(good_cols, names(data_df))) { out <- apply_color(out, cl, FALSE) } for (cl in intersect(bad_cols, names(data_df))) { out <- apply_color(out, cl, TRUE) } out } } if (!exists("gt_theme_espn", mode = "function")) { gt_theme_espn <- function(x) x } filtered_data <- reactive({ req(input$dateRange, input$BatterHand, input$PitcherHand, input$pitchtype, input$veloRange, input$ivbrange, input$hbrange, input$seasonSelect) df <- all_seasonal_data %>% filter( Season %in% input$seasonSelect, Date >= as.Date(input$dateRange[1]) & Date <= as.Date(input$dateRange[2]), BatterSide %in% input$BatterHand, PitcherThrows %in% input$PitcherHand, TaggedPitchType %in% input$pitchtype, RelSpeed >= input$veloRange[1] & RelSpeed <= input$veloRange[2], InducedVertBreak >= input$ivbrange[1] & InducedVertBreak <= input$ivbrange[2], HorzBreak >= input$hbrange[1] & HorzBreak <= input$hbrange[2] ) # Add xBA predictions df <- add_xba_predictions(df, xBA_Model) return(df) }) output$selectedSeasonsDisplay <- renderUI({ req(input$seasonSelect) div( style = "background-color: #f0f8ff; padding: 10px; border-radius: 8px; margin-bottom: 15px; border-left: 4px solid darkcyan;", strong("Selected Seasons: "), paste(input$seasonSelect, collapse = ", ") ) }) leaderboard_filtered_data <- reactive({ req(input$leaderboard_seasonSelect, input$leaderboard_players, input$leaderboard_PitcherHand, input$leaderboard_pitchtype) df <- all_seasonal_data %>% filter(Season %in% input$leaderboard_seasonSelect, Batter %in% input$leaderboard_players, PitcherThrows %in% input$leaderboard_PitcherHand, TaggedPitchType %in% input$leaderboard_pitchtype) # Add xBA predictions df <- add_xba_predictions(df, xBA_Model) return(df) }) bp_leaderboard_filtered <- reactive({ if (!nrow(BP_data)) return(BP_data) df <- BP_data # Apply date range filter if (!is.null(input$bp_dateRange) && all(!is.na(input$bp_dateRange))) { df <- df %>% filter(Date >= as.Date(input$bp_dateRange[1]) & Date <= as.Date(input$bp_dateRange[2])) } # Apply player filter if available if (!is.null(input$bp_players_filter) && length(input$bp_players_filter) > 0) { df <- df %>% filter(Batter %in% input$bp_players_filter) } df }) # BP leaderboard data (per batter) bp_leaderboard_data <- reactive({ filtered <- bp_leaderboard_filtered() if (!nrow(filtered)) return(tibble::tibble(Message = "No BP data available")) all_stats <- filtered %>% group_by(Batter) %>% summarize( BBE = sum(BIPind, na.rm = TRUE), `Avg EV` = ifelse(sum(BIPind == 1 & !is.na(ExitSpeed)) > 0, round(mean(ExitSpeed[BIPind == 1], na.rm = TRUE), 1), NA), `Max EV` = ifelse(sum(BIPind == 1 & !is.na(ExitSpeed)) > 0, round(max(ExitSpeed[BIPind == 1], na.rm = TRUE), 1), NA), `SC%` = ifelse(sum(BIPind, na.rm = TRUE) > 0, round(sum(SCind, na.rm = TRUE) / sum(BIPind, na.rm = TRUE) * 100, 1), NA), `10-30%` = ifelse(sum(BIPind, na.rm = TRUE) > 0, round(sum(LA1030ind, na.rm = TRUE) / sum(BIPind, na.rm = TRUE) * 100, 1), NA), `HH%` = ifelse(sum(BIPind, na.rm = TRUE) > 0, round(sum(HHind, na.rm = TRUE) / sum(BIPind, na.rm = TRUE) * 100, 1), NA), `Barrel%` = ifelse(sum(BIPind, na.rm = TRUE) > 0, round(sum(Barrelind, na.rm = TRUE) / sum(BIPind, na.rm = TRUE) * 100, 1), NA), `GB%` = ifelse(sum(BIPind, na.rm = TRUE) > 0, round(sum(GBindicator, na.rm = TRUE) / sum(BIPind, na.rm = TRUE) * 100, 1), NA), `LD%` = ifelse(sum(BIPind, na.rm = TRUE) > 0, round(sum(LDind, na.rm = TRUE) / sum(BIPind, na.rm = TRUE) * 100, 1), NA), `FB%` = ifelse(sum(BIPind, na.rm = TRUE) > 0, round(sum(FBind, na.rm = TRUE) / sum(BIPind, na.rm = TRUE) * 100, 1), NA), `Pop%` = ifelse(sum(BIPind, na.rm = TRUE) > 0, round(sum(Popind, na.rm = TRUE) / sum(BIPind, na.rm = TRUE) * 100, 1), NA), `Pull%` = ifelse(sum(BIPind, na.rm = TRUE) > 0, round(sum(ifelse(BatterSide == "Right", Bearing < -15, Bearing > 15) & BIPind == 1, na.rm = TRUE) / sum(BIPind, na.rm = TRUE) * 100, 1), NA), `Straight%` = ifelse(sum(BIPind, na.rm = TRUE) > 0, round(sum(Bearing >= -15 & Bearing <= 15 & BIPind == 1, na.rm = TRUE) / sum(BIPind, na.rm = TRUE) * 100, 1), NA), `Oppo%` = ifelse(sum(BIPind, na.rm = TRUE) > 0, round(sum(ifelse(BatterSide == "Right", Bearing > 15, Bearing < -15) & BIPind == 1, na.rm = TRUE) / sum(BIPind, na.rm = TRUE) * 100, 1), NA), `Avg Distance` = ifelse(sum(BIPind == 1 & !is.na(Distance)) > 0, round(mean(Distance[BIPind == 1], na.rm = TRUE), 1), NA), `Max Distance` = ifelse(sum(BIPind == 1 & !is.na(Distance)) > 0, round(max(Distance[BIPind == 1], na.rm = TRUE), 1), NA), `Avg LA` = ifelse(sum(BIPind == 1 & !is.na(Angle)) > 0, round(mean(Angle[BIPind == 1], na.rm = TRUE), 1), NA), `Avg Spin` = ifelse(sum(BIPind == 1 & !is.na(HitSpinRate)) > 0, round(mean(HitSpinRate[BIPind == 1], na.rm = TRUE), 0), NA), `Neg%` = ifelse(BBE > 0, round(sum(Angle[BIPind == 1] < 0, na.rm = TRUE) / BBE * 100, 1), NA_real_), `0-10%` = ifelse(BBE > 0, round(sum(Angle[BIPind == 1] >= 0 & Angle[BIPind == 1] < 10, na.rm = TRUE) / BBE * 100, 1), NA_real_), `10-20%` = ifelse(BBE > 0, round(sum(Angle[BIPind == 1] >= 10 & Angle[BIPind == 1] < 20, na.rm = TRUE) / BBE * 100, 1), NA_real_), `20-30%` = ifelse(BBE > 0, round(sum(Angle[BIPind == 1] >= 20 & Angle[BIPind == 1] < 30, na.rm = TRUE) / BBE * 100, 1), NA_real_), `30+%` = ifelse(BBE > 0, round(sum(Angle[BIPind == 1] >= 30, na.rm = TRUE) / BBE * 100, 1), NA_real_), .groups = "drop" ) %>% arrange(desc(BBE)) if (is.null(input$bp_columns) || !length(input$bp_columns)) { return(all_stats %>% select(Batter, BBE, `Avg EV`, `Avg LA`, `SC%`, `10-30%`, `HH%`, `Barrel%`)) } selected_cols <- unique(c("Batter", input$bp_columns)) all_stats %>% select(any_of(selected_cols)) }) # convenience selected_player <- reactive({ req(input$playerSelect); input$playerSelect }) output$leaderboard_selectedSeasonsDisplay <- renderUI({ req(input$leaderboard_seasonSelect) div( style = "background-color: #f0f8ff; padding: 10px; border-radius: 8px; margin-bottom: 15px; border-left: 4px solid darkcyan;", strong("Selected Seasons: "), paste(input$leaderboard_seasonSelect, collapse = ", ") ) }) is_outfielder <- reactive({ req(selected_player()) selected_player() %in% OAA_DF$obs_player }) is_infielder <- reactive({ req(selected_player()) selected_player() %in% IF_OAA$obs_player_name }) bt_filtered_data <- reactive({ req(selected_player()) df <- filtered_data() %>% filter(Batter == selected_player(), !is.na(BatSpeed)) if (input$bt_count_filter != "All") { df <- df %>% filter(paste0(Balls, "-", Strikes) == input$bt_count_filter) } if (length(input$bt_pitch_type) > 0) { df <- df %>% filter(TaggedPitchType %in% input$bt_pitch_type) } if (length(input$bt_pitcher_hand) > 0) { df <- df %>% filter(PitcherThrows %in% input$bt_pitcher_hand) } if (length(input$bt_result_filter) > 0) { result_patterns <- c() if ("InPlay" %in% input$bt_result_filter) result_patterns <- c(result_patterns, "InPlay") if ("StrikeSwinging" %in% input$bt_result_filter) result_patterns <- c(result_patterns, "StrikeSwinging") if ("FoulBall" %in% input$bt_result_filter) result_patterns <- c( result_patterns, "FoulBall", "FoulBallNotFieldable", "FoulBallFieldable" ) df <- df %>% filter(PitchCall %in% result_patterns) } df }) observeEvent( list( selected_player(), input$bt_count_filter, input$bt_pitch_type, input$bt_pitcher_hand, input$bt_result_filter ), { df <- bt_filtered_data() if (nrow(df) > 0) { df <- df %>% arrange(Date, Inning, PAofInning, PitchofPA) %>% mutate( swing_label = paste0( row_number(), ": ", format(Date, "%m/%d"), " - ", TaggedPitchType, " (", ifelse( PitchCall == "InPlay", paste0(round(ExitSpeed, 0), " EV"), PitchCall ), ")" ) ) swing_choices <- c( "All Swings" = "All", setNames(1:nrow(df), df$swing_label) ) updateSelectInput( session, "bt_swing_select", choices = swing_choices, selected = "All" ) } else { updateSelectInput( session, "bt_swing_select", choices = c("All Swings" = "All"), selected = "All" ) } } ) output$bt_simple_aa_viz <- renderPlot({ df <- bt_filtered_data() if (nrow(df) < 1) return(NULL) create_simple_aa_viz(selected_player(), df, input$bt_swing_select) }) output$bt_attack_dir_viz <- renderPlot({ df <- bt_filtered_data() if (nrow(df) < 1) return(NULL) create_attack_dir_viz(selected_player(), df, input$bt_swing_select) }) output$bt_aa_la_viz <- renderPlot({ df <- bt_filtered_data() if (nrow(df) < 1) return(NULL) create_aa_la_viz(selected_player(), df, input$bt_swing_select) }) output$bt_aa_vaa_viz <- renderPlot({ df <- bt_filtered_data() if (nrow(df) < 1) return(NULL) create_aa_vaa_viz(selected_player(), df, input$bt_swing_select) }) output$bt_pitch_details_table <- gt::render_gt({ if (input$bt_swing_select == "All") return(NULL) df <- bt_filtered_data() swing_idx <- as.integer(input$bt_swing_select) if (nrow(df) < swing_idx) return(NULL) create_pitch_details_table(df[swing_idx, , drop = FALSE]) }) output$bt_single_trajectory <- renderPlot({ if (input$bt_swing_select == "All") return(NULL) df <- bt_filtered_data() swing_idx <- as.integer(input$bt_swing_select) if (nrow(df) < swing_idx) return(NULL) create_single_trajectory_viz(df[swing_idx, , drop = FALSE]) }) output$bt_single_zone <- renderPlot({ if (input$bt_swing_select == "All") return(NULL) df <- bt_filtered_data() swing_idx <- as.integer(input$bt_swing_select) if (nrow(df) < swing_idx) return(NULL) create_single_zone_viz(df[swing_idx, , drop = FALSE]) }) output$bt_single_contact <- renderPlot({ if (input$bt_swing_select == "All") return(NULL) df <- bt_filtered_data() swing_idx <- as.integer(input$bt_swing_select) if (nrow(df) < swing_idx) return(NULL) create_single_contact_viz(df[swing_idx, , drop = FALSE]) }) output$bt_single_spray <- renderPlot({ if (input$bt_swing_select == "All") return(NULL) df <- bt_filtered_data() swing_idx <- as.integer(input$bt_swing_select) if (nrow(df) < swing_idx) return(NULL) create_single_spray_viz(df[swing_idx, , drop = FALSE]) }) output$bt_comprehensive_table <- gt::render_gt({ df <- bt_filtered_data() if (nrow(df) < 3) return(gt::gt(data.frame(Note = "Insufficient data"))) create_bt_table(selected_player(), df) }) output$bt_heatmap_batspeed <- renderPlot({ create_bt_batspeed_heatmap( selected_player(), bt_filtered_data(), input$bt_count_filter ) }) output$bt_heatmap_aa <- renderPlot({ create_bt_aa_heatmap( selected_player(), bt_filtered_data(), input$bt_count_filter ) }) output$bt_heatmap_haa <- renderPlot({ create_bt_haa_heatmap( selected_player(), bt_filtered_data(), input$bt_count_filter ) }) output$bt_leaderboard_table <- DT::renderDataTable({ df <- filtered_data() %>% filter(!is.na(BatSpeed)) %>% mutate(swing_direction = get_swing_direction(HorizontalAttackAngle, BatterSide)) if (nrow(df) < 3) { return( DT::datatable( data.frame(Message = "Insufficient data"), options = list(dom = "t") ) ) } leaderboard <- df %>% group_by(Batter) %>% summarize( Swings = n(), `Avg BS` = round(mean(BatSpeed, na.rm = TRUE), 1), `Max BS` = round(max(BatSpeed, na.rm = TRUE), 1), `Avg AA` = round(mean(VerticalAttackAngle, na.rm = TRUE), 1), `Avg HAA` = round(mean(HorizontalAttackAngle, na.rm = TRUE), 1), `Pull%` = round(mean(swing_direction == "Pull") * 100, 1), `Mid%` = round(mean(swing_direction == "Middle") * 100, 1), `Oppo%` = round(mean(swing_direction == "Oppo") * 100, 1), `Avg LA` = round(mean(Angle, na.rm = TRUE), 1), .groups = "drop" ) %>% filter(Swings >= 5) %>% arrange(desc(`Avg BS`)) DT::datatable( leaderboard, options = list( paging = TRUE, ordering = TRUE, searching = TRUE, scrollX = TRUE, pageLength = 15 ), rownames = FALSE ) %>% DT::formatRound( c( "Avg BS", "Max BS", "Avg AA", "Avg HAA", "Pull%", "Mid%", "Oppo%", "Avg LA" ), 1 ) }) # ---------- Outputs ---------- # Header output$player_header_plot <- renderPlot({ req(selected_player()) grid::grid.newpage() grid::grid.draw(create_player_header(bio, selected_player())) }) # Dashboard: performance by location output$performance_zone_plot <- renderPlot({ req(selected_player()) create_batter_performance_chart(selected_player(), filtered_data()) }) # Dashboard split tables (gt) output$split_table_rhp <- gt::render_gt({ req(selected_player()) split <- create_player_table_split(selected_player(), filtered_data()) if (!is.null(split$rhp_table)) split$rhp_table else gt::gt(tibble::tibble(Note = "No RHP data")) }) output$split_table_lhp <- gt::render_gt({ req(selected_player()) split <- create_player_table_split(selected_player(), filtered_data()) if (!is.null(split$lhp_table)) split$lhp_table else gt::gt(tibble::tibble(Note = "No LHP data")) }) # Spray & Zone tab output$spray_chart <- renderPlot({ req(selected_player()) create_spray_chart(selected_player(), filtered_data()) }) output$bb_profile_table <- gt::render_gt({ req(selected_player()) df_filtered <- filtered_data() bb_table <- create_bb_profile_table(df_filtered, selected_player()) gt::gt(bb_table) %>% gt::tab_header(title = paste("Batted Ball Profile:", selected_player())) %>% gt::cols_align(align = "center", columns = gt::everything()) %>% gt::tab_options(table.font.size = gt::px(12)) }) # Contact / Radial tab output$contact_map <- renderPlot({ req(selected_player()) create_contact_map(selected_player(), filtered_data()) }) output$radial_chart <- renderPlot({ req(selected_player()) radial_chart_ev(selected_player(), filtered_data()) }) output$quality_table <- renderTable({ req(selected_player()) as.data.frame(create_quality_table(filtered_data(), selected_player())) }, striped = TRUE, bordered = TRUE, digits = 1) # Heatmap tab output$heatmap_plot <- renderPlot({ req(selected_player(), input$heat_metric) create_heatmap(selected_player(), input$heat_metric, filtered_data()) }) # Percentiles tab output$percentiles_plot <- renderPlot({ req(selected_player(), input$pool_select) batter_rows <- filtered_data() %>% filter(Batter == selected_player()) pool_rows <- switch(input$pool_select, "SBC" = SBC_2025, "P5" = P5_2025, "SEC" = SEC_2025, "TEAM" = filtered_data(), SBC_2025) create_percentile_chart(batter_rows, pool_rows, selected_player()) }) # Whiff movement tab output$whiff_movement_plot <- renderPlot({ req(selected_player()) create_whiff_movement_chart(selected_player(), filtered_data()) }) #defense output$field_graph_plot <- renderPlotly({ req(selected_player(), input$position) result <- field_graph(selected_player(), input$position) result[[1]] }) output$field_graph_table <- gt::render_gt({ req(selected_player(), input$position) result <- field_graph(selected_player(), input$position) result[[2]] }) output$star_graph <- suppressWarnings(renderPlotly({ req(selected_player(), input$position) star_graph(selected_player(), input$position) })) output$direction_oaa_plot <- renderPlot({ req(selected_player(), input$position) result <- direction_oaa(selected_player(), input$position) result[[1]] }) output$direction_oaa_table <- render_gt({ req(selected_player(), input$position) result <- direction_oaa(selected_player(), input$position) result[[2]] }) output$infield_field_graph_plot <- renderPlotly({ req(selected_player(), input$position) result <- infield_field_graph(selected_player(), input$position) result[[1]] }) output$infield_field_graph_table <- gt::render_gt({ req(selected_player(), input$position) result <- infield_field_graph(selected_player(), input$position) result[[2]] }) output$infield_star_graph <- suppressWarnings(renderPlotly({ req(selected_player(), input$position) infield_star_graph(selected_player(), input$position) })) output$first_def_pos_plot <- renderPlot({ infield_positioning_heatmap( team = input$team_1, man_on_first = input$man_on_first_1, man_on_second = input$man_on_second_1, man_on_third = input$man_on_third_1, BatterHand = input$BatterHand_1, PitcherHand = input$PitcherHand_1, scorediff = input$scorediff_1, Date1 = input$Date1_1, Date2 = input$Date2_1, obs_pitcher = input$obs_pitcher_1, Hitter = input$Hitter_1, Count = input$Count_1, obs_outs = input$obs_outs_1, Opponent = input$Opponent_1 ) }) output$second_def_pos_plot <- renderPlot({ infield_positioning_heatmap( team = input$team_2, man_on_first = input$man_on_first_2, man_on_second = input$man_on_second_2, man_on_third = input$man_on_third_2, BatterHand = input$BatterHand_2, PitcherHand = input$PitcherHand_2, scorediff = input$scorediff_2, Date1 = input$Date1_2, Date2 = input$Date2_2, obs_pitcher = input$obs_pitcher_2, Hitter = input$Hitter_2, Count = input$Count_2, obs_outs = input$obs_outs_2, Opponent = input$Opponent_2 ) }) # --- BP visuals --- output$bp_spray_plot <- renderPlot({ req(input$bp_player) create_bp_spray_chart(input$bp_player, bp_leaderboard_filtered()) }) output$bp_zone_plot <- renderPlot({ req(input$bp_player) create_bp_zone_chart(input$bp_player, bp_leaderboard_filtered()) }) output$bp_contact_plot <- renderPlot({ req(input$bp_player) create_bp_contact_map(input$bp_player, bp_leaderboard_filtered()) }) # --- BP tables --- output$bp_leaderboard_table <- DT::renderDataTable({ data_to_show <- bp_leaderboard_data() if (!nrow(data_to_show) || "Message" %in% names(data_to_show)) { return(DT::datatable(data_to_show, options = list(dom = 't'))) } dt <- DT::datatable( data_to_show, options = list(paging = TRUE, ordering = TRUE, searching = TRUE, scrollX = TRUE, pageLength = 10), rownames = FALSE ) num1 <- intersect(c('Avg EV','Max EV','Avg Distance','Max Distance','Avg LA','Avg Spin'), names(data_to_show)) if (length(num1)) dt <- formatRound(dt, num1, digits = 1) num2 <- intersect(c('SC%','10-30%','HH%','Barrel%','GB%','LD%','FB%','Pop%','Pull%','Straight%','Oppo%','Neg%','0-10%','10-20%','20-30%','30+%'), names(data_to_show)) if (length(num2)) dt <- formatRound(dt, num2, digits = 1) format_bp_leaderboard(dt, data_to_show) }) output$bp_team_summary_table <- DT::renderDataTable({ filtered <- bp_leaderboard_filtered() if (!nrow(filtered)) { return(DT::datatable(data.frame(Message = "No data available"), options = list(dom = 't'))) } team_bp <- filtered %>% summarize( Batter = "TEAM TOTAL", BBE = sum(BIPind, na.rm = TRUE), `Avg EV` = ifelse(sum(BIPind == 1 & !is.na(ExitSpeed)) > 0, round(mean(ExitSpeed[BIPind == 1], na.rm = TRUE), 1), NA), `Max EV` = ifelse(sum(BIPind == 1 & !is.na(ExitSpeed)) > 0, round(max(ExitSpeed[BIPind == 1], na.rm = TRUE), 1), NA), `SC%` = ifelse(sum(BIPind, na.rm = TRUE) > 0, round(sum(SCind, na.rm = TRUE) / sum(BIPind, na.rm = TRUE) * 100, 1), NA), `10-30%` = ifelse(sum(BIPind, na.rm = TRUE) > 0, round(sum(LA1030ind,na.rm = TRUE) / sum(BIPind, na.rm = TRUE) * 100, 1), NA), `HH%` = ifelse(sum(BIPind, na.rm = TRUE) > 0, round(sum(HHind, na.rm = TRUE) / sum(BIPind, na.rm = TRUE) * 100, 1), NA), `Barrel%` = ifelse(sum(BIPind, na.rm = TRUE) > 0, round(sum(Barrelind,na.rm = TRUE) / sum(BIPind, na.rm = TRUE) * 100, 1), NA), `GB%` = ifelse(sum(BIPind, na.rm = TRUE) > 0, round(sum(GBindicator, na.rm = TRUE) / sum(BIPind, na.rm = TRUE) * 100, 1), NA), `LD%` = ifelse(sum(BIPind, na.rm = TRUE) > 0, round(sum(LDind, na.rm = TRUE) / sum(BIPind, na.rm = TRUE) * 100, 1), NA), `FB%` = ifelse(sum(BIPind, na.rm = TRUE) > 0, round(sum(FBind, na.rm = TRUE) / sum(BIPind, na.rm = TRUE) * 100, 1), NA), `Pop%` = ifelse(sum(BIPind, na.rm = TRUE) > 0, round(sum(Popind, na.rm = TRUE) / sum(BIPind, na.rm = TRUE) * 100, 1), NA), `Pull%` = ifelse(sum(BIPind, na.rm = TRUE) > 0, round(sum(ifelse(BatterSide == "Right", Bearing < -15, Bearing > 15) & BIPind == 1, na.rm = TRUE) / sum(BIPind, na.rm = TRUE) * 100, 1), NA), `Straight%` = ifelse(sum(BIPind, na.rm = TRUE) > 0, round(sum(Bearing >= -15 & Bearing <= 15 & BIPind == 1, na.rm = TRUE) / sum(BIPind, na.rm = TRUE) * 100, 1), NA), `Oppo%` = ifelse(sum(BIPind, na.rm = TRUE) > 0, round(sum(ifelse(BatterSide == "Right", Bearing > 15, Bearing < -15) & BIPind == 1, na.rm = TRUE) / sum(BIPind, na.rm = TRUE) * 100, 1), NA), `Avg Distance` = ifelse(sum(BIPind == 1 & !is.na(Distance)) > 0, round(mean(Distance[BIPind == 1], na.rm = TRUE), 1), NA), `Max Distance` = ifelse(sum(BIPind == 1 & !is.na(Distance)) > 0, round(max(Distance[BIPind == 1], na.rm = TRUE), 1), NA), `Avg LA` = ifelse(sum(BIPind == 1 & !is.na(Angle)) > 0, round(mean(Angle[BIPind == 1], na.rm = TRUE), 1), NA), `Avg Spin` = ifelse(sum(BIPind == 1 & !is.na(HitSpinRate)) > 0, round(mean(HitSpinRate[BIPind == 1], na.rm = TRUE), 0), NA), `Neg%` = ifelse(BBE > 0, sum(Angle[PitchCall == "InPlay"] < 0, na.rm = TRUE) / BBE * 100, NA_real_), `0-10%` = ifelse(BBE > 0, sum(Angle[PitchCall == "InPlay"] >= 0 & Angle[PitchCall == "InPlay"] < 10, na.rm = TRUE) / BBE * 100, NA_real_), `10-20%` = ifelse(BBE > 0, sum(Angle[PitchCall == "InPlay"] >= 10 & Angle[PitchCall == "InPlay"] < 20, na.rm = TRUE) / BBE * 100, NA_real_), `20-30%` = ifelse(BBE > 0, sum(Angle[PitchCall == "InPlay"] >= 20 & Angle[PitchCall == "InPlay"] < 30, na.rm = TRUE) / BBE * 100, NA_real_), `30+%` = ifelse(BBE > 0, sum(Angle[PitchCall == "InPlay"] >= 30, na.rm = TRUE) / BBE * 100, NA_real_) ) chosen <- unique(c("Batter", input$bp_columns)) team_bp <- dplyr::select(team_bp, dplyr::any_of(chosen)) DT::datatable(team_bp, options = list(paging = FALSE, ordering = FALSE, searching = FALSE, dom = 't')) }) output$statistics_table <- DT::renderDataTable({ df <- leaderboard_filtered_data() if (!nrow(df)) return(DT::datatable(data.frame(Message = "No data for current filters"), options = list(dom='t'))) # Calculate ALL possible statistics summary_df <- df %>% group_by(Batter) %>% summarize( Pitches = n(), PA = sum(PAindicator, na.rm = TRUE), AB = sum(ABindicator, na.rm = TRUE), H = sum(HitIndicator, na.rm = TRUE), TB = sum(totalbases, na.rm = TRUE), BB = sum(WalkIndicator, na.rm = TRUE), HBP = sum(HBPIndicator, na.rm = TRUE), K = sum(KorBB == "Strikeout", na.rm = TRUE), BBE = sum(BIPind, na.rm = TRUE), Swings = sum(SwingIndicator, na.rm = TRUE), ZSwings = sum(Zswing, na.rm = TRUE), ZWhiff = sum(Zwhiffind, na.rm = TRUE), Whiff = sum(WhiffIndicator, na.rm = TRUE), Chase = sum(Chaseindicator, na.rm = TRUE), OutZone = sum(OutofZone, na.rm = TRUE), `Avg EV` = mean(ExitSpeed[PitchCall == "InPlay" & TaggedHitType != "Bunt"], na.rm = TRUE), `Max EV` = suppressWarnings(max(ExitSpeed[PitchCall == "InPlay"], na.rm = TRUE)), SC = sum(SCind, na.rm = TRUE), LA1030 = sum(LA1030ind, na.rm = TRUE), HH = sum(HHind, na.rm = TRUE), Barrel = sum(Barrelind, na.rm = TRUE), GB = sum(GBindicator, na.rm = TRUE), LD = sum(LDind, na.rm = TRUE), FB_count = sum(FBind, na.rm = TRUE), Pop = sum(Popind, na.rm = TRUE), Pull = sum(ifelse(BatterSide == "Right", Bearing < -15, Bearing > 15) & PitchCall == "InPlay", na.rm = TRUE), Middle = sum(Bearing >= -15 & Bearing <= 15 & PitchCall == "InPlay", na.rm = TRUE), Oppo = sum(ifelse(BatterSide == "Right", Bearing > 15, Bearing < -15) & PitchCall == "InPlay", na.rm = TRUE), Pull_FB = sum(ifelse(BatterSide == "Right", Bearing < -15, Bearing > 15) & TaggedHitType == "FlyBall" & PitchCall == "InPlay", na.rm = TRUE), `Neg%` = ifelse(BBE > 0, sum(Angle[PitchCall == "InPlay"] < 0, na.rm = TRUE) / BBE * 100, NA_real_), `0-10%` = ifelse(BBE > 0, sum(Angle[PitchCall == "InPlay"] >= 0 & Angle[PitchCall == "InPlay"] < 10, na.rm = TRUE) / BBE * 100, NA_real_), `10-20%` = ifelse(BBE > 0, sum(Angle[PitchCall == "InPlay"] >= 10 & Angle[PitchCall == "InPlay"] < 20, na.rm = TRUE) / BBE * 100, NA_real_), `20-30%` = ifelse(BBE > 0, sum(Angle[PitchCall == "InPlay"] >= 20 & Angle[PitchCall == "InPlay"] < 30, na.rm = TRUE) / BBE * 100, NA_real_), `30+%` = ifelse(BBE > 0, sum(Angle[PitchCall == "InPlay"] >= 30, na.rm = TRUE) / BBE * 100, NA_real_), .groups = "drop" ) %>% mutate( `AVG` = ifelse(AB > 0, H/AB, NA_real_), `OBP` = ifelse(PA > 0, (H + BB + HBP)/PA, NA_real_), `SLG` = ifelse(AB > 0, TB/AB, NA_real_), `OPS` = ifelse(!is.na(`OBP`) & !is.na(`SLG`), `OBP` + `SLG`, NA_real_), `K %` = ifelse(PA > 0, K/PA * 100, NA_real_), `BB %` = ifelse(PA > 0, BB/PA * 100, NA_real_), `Free%` = ifelse(PA > 0, (BB + HBP)/PA * 100, NA_real_), `SC%` = ifelse(BBE > 0, SC/BBE * 100, NA_real_), `10-30%` = ifelse(BBE > 0, LA1030/BBE * 100, NA_real_), `HH%` = ifelse(BBE > 0, HH/BBE * 100, NA_real_), `Barrel%` = ifelse(BBE > 0, Barrel/BBE * 100, NA_real_), `Whiff%` = ifelse(Swings > 0, Whiff/Swings * 100, NA_real_), `Z Whiff%` = ifelse(ZSwings > 0, ZWhiff/ZSwings * 100, NA_real_), `Chase%` = ifelse(OutZone > 0, Chase/OutZone * 100, NA_real_), `GB%` = ifelse(BBE > 0, GB/BBE * 100, NA_real_), `LD%` = ifelse(BBE > 0, LD/BBE * 100, NA_real_), `FB%` = ifelse(BBE > 0, FB_count/BBE * 100, NA_real_), `Pop%` = ifelse(BBE > 0, Pop/BBE * 100, NA_real_), `Pull%` = ifelse(BBE > 0, Pull/BBE * 100, NA_real_), `Middle%` = ifelse(BBE > 0, Middle/BBE * 100, NA_real_), `Oppo%` = ifelse(BBE > 0, Oppo/BBE * 100, NA_real_), `Pull FB%` = ifelse(FB_count > 0, Pull_FB/FB_count * 100, NA_real_) ) %>% mutate(across(everything(), ~ ifelse(is.nan(.x) | is.infinite(.x), NA, .x))) # Select only the columns the user wants, maintaining order if (is.null(input$team_columns) || !length(input$team_columns)) { # Default selection if nothing chosen selected_cols <- c("Batter", "OPS", "K %", "BB %", "AVG", "Avg EV", "SC%", "HH%", "Whiff%") } else { selected_cols <- c("Batter", input$team_columns) } display_df <- summary_df %>% select(any_of(selected_cols)) # Create datatable dt <- DT::datatable( display_df, options = list(scrollX = TRUE, paging = FALSE, ordering = TRUE, searching = TRUE), rownames = FALSE ) # Format numeric columns num_cols_3dec <- intersect(c("AVG", "OBP", "SLG", "OPS"), names(display_df)) if (length(num_cols_3dec)) dt <- DT::formatRound(dt, num_cols_3dec, 3) num_cols_1dec <- intersect(c("Avg EV", "Max EV"), names(display_df)) if (length(num_cols_1dec)) dt <- DT::formatRound(dt, num_cols_1dec, 1) pct_cols <- intersect(c("SC%", "10-30%", "HH%", "Barrel%", "Whiff%", "Z Whiff%", "Chase%", "GB%", "LD%", "FB%", "Pop%", "K %", "BB %", "Free%", "Pull%", "Middle%", "Oppo%", "Pull FB%","Neg%", "0-10%", "10-20%", "20-30%", "30+%"), names(display_df)) if (length(pct_cols)) dt <- DT::formatRound(dt, pct_cols, 1) # Apply color formatting rank_colors <- colorRampPalette(c("#E1463E","white","#00840D"))(100) rank_colors2 <- colorRampPalette(c("#00840D","white","#E1463E"))(100) apply_color <- function(obj, column, reverse = FALSE) { if (!column %in% names(display_df)) return(obj) vals <- suppressWarnings(as.numeric(display_df[[column]])) vals <- vals[is.finite(vals)] if (!length(vals)) return(obj) cols <- if (reverse) rank_colors2 else rank_colors rng <- range(vals, na.rm = TRUE) if (!is.finite(rng[1]) || !is.finite(rng[2])) return(obj) if (identical(rng[1], rng[2])) { return(DT::formatStyle(obj, column, backgroundColor = DT::styleEqual(rng[1], cols[50]))) } cuts <- seq(rng[1], rng[2], length.out = 99) DT::formatStyle(obj, column, backgroundColor = DT::styleInterval(cuts, cols)) } # Good = higher is better (green) good_cols <- c("OPS","BB %","Free%","AVG","OBP","SLG","Avg EV","Max EV","SC%", "10-30%","HH%","Barrel%","LD%","FB%","Middle%","0-10%", "10-20%", "20-30%") # Bad = higher is worse (red) bad_cols <- c("K %","Whiff%","Z Whiff%","Chase%","GB%","Pop%","Neg%", "30+%") for (cl in intersect(good_cols, names(display_df))) { dt <- apply_color(dt, cl, FALSE) } for (cl in intersect(bad_cols, names(display_df))) { dt <- apply_color(dt, cl, TRUE) } dt }) output$bp_heatmap_hard <- renderPlot({ req(input$bp_player) create_bp_heatmap(input$bp_player, bp_leaderboard_filtered(), "Hard-Hit (95+)") }) output$bp_heatmap_medium <- renderPlot({ req(input$bp_player) create_bp_heatmap(input$bp_player, bp_leaderboard_filtered(), "Medium (90-95)") }) output$bp_heatmap_soft <- renderPlot({ req(input$bp_player) create_bp_heatmap(input$bp_player, bp_leaderboard_filtered(), "Soft (Under 90)") }) output$team_total_table <- DT::renderDataTable({ df <- leaderboard_filtered_data() if (!nrow(df)) return(DT::datatable(data.frame(Message = "No data"), options = list(dom='t'))) # Calculate team totals team_total <- df %>% summarize( Batter = "TEAM TOTAL", Pitches = n(), PA = sum(PAindicator, na.rm = TRUE), AB = sum(ABindicator, na.rm = TRUE), H = sum(HitIndicator, na.rm = TRUE), TB = sum(totalbases, na.rm = TRUE), BB = sum(WalkIndicator, na.rm = TRUE), HBP = sum(HBPIndicator, na.rm = TRUE), K = sum(KorBB == "Strikeout", na.rm = TRUE), BBE = sum(BIPind, na.rm = TRUE), Swings = sum(SwingIndicator, na.rm = TRUE), ZSwings = sum(Zswing, na.rm = TRUE), ZWhiff = sum(Zwhiffind, na.rm = TRUE), Whiff = sum(WhiffIndicator, na.rm = TRUE), Chase = sum(Chaseindicator, na.rm = TRUE), OutZone = sum(OutofZone, na.rm = TRUE), `Avg EV` = mean(ExitSpeed[PitchCall == "InPlay" & TaggedHitType != "Bunt"], na.rm = TRUE), `Max EV` = suppressWarnings(max(ExitSpeed[PitchCall == "InPlay"], na.rm = TRUE)), SC = sum(SCind, na.rm = TRUE), LA1030 = sum(LA1030ind, na.rm = TRUE), HH = sum(HHind, na.rm = TRUE), Barrel = sum(Barrelind, na.rm = TRUE), GB = sum(GBindicator, na.rm = TRUE), LD = sum(LDind, na.rm = TRUE), FB_count = sum(FBind, na.rm = TRUE), Pop = sum(Popind, na.rm = TRUE), Pull = sum(ifelse(BatterSide == "Right", Bearing < -15, Bearing > 15) & PitchCall == "InPlay", na.rm = TRUE), Middle = sum(Bearing >= -15 & Bearing <= 15 & PitchCall == "InPlay", na.rm = TRUE), Oppo = sum(ifelse(BatterSide == "Right", Bearing > 15, Bearing < -15) & PitchCall == "InPlay", na.rm = TRUE), Pull_FB = sum(ifelse(BatterSide == "Right", Bearing < -15, Bearing > 15) & TaggedHitType == "FlyBall" & PitchCall == "InPlay", na.rm = TRUE) ) %>% mutate( `AVG` = ifelse(AB > 0, H/AB, NA_real_), `OBP` = ifelse(PA > 0, (H + BB + HBP)/PA, NA_real_), `SLG` = ifelse(AB > 0, TB/AB, NA_real_), `OPS` = ifelse(!is.na(`OBP`) & !is.na(`SLG`), `OBP` + `SLG`, NA_real_), `K %` = ifelse(PA > 0, K/PA * 100, NA_real_), `BB %` = ifelse(PA > 0, BB/PA * 100, NA_real_), `Free%` = ifelse(PA > 0, (BB + HBP)/PA * 100, NA_real_), `SC%` = ifelse(BBE > 0, SC/BBE * 100, NA_real_), `10-30%` = ifelse(BBE > 0, LA1030/BBE * 100, NA_real_), `HH%` = ifelse(BBE > 0, HH/BBE * 100, NA_real_), `Barrel%` = ifelse(BBE > 0, Barrel/BBE * 100, NA_real_), `Whiff%` = ifelse(Swings > 0, Whiff/Swings * 100, NA_real_), `Z Whiff%` = ifelse(ZSwings > 0, ZWhiff/ZSwings * 100, NA_real_), `Chase%` = ifelse(OutZone > 0, Chase/OutZone * 100, NA_real_), `GB%` = ifelse(BBE > 0, GB/BBE * 100, NA_real_), `LD%` = ifelse(BBE > 0, LD/BBE * 100, NA_real_), `FB%` = ifelse(BBE > 0, FB_count/BBE * 100, NA_real_), `Pop%` = ifelse(BBE > 0, Pop/BBE * 100, NA_real_), `Pull%` = ifelse(BBE > 0, Pull/BBE * 100, NA_real_), `Middle%` = ifelse(BBE > 0, Middle/BBE * 100, NA_real_), `Oppo%` = ifelse(BBE > 0, Oppo/BBE * 100, NA_real_), `Pull FB%` = ifelse(FB_count > 0, Pull_FB/FB_count * 100, NA_real_) ) # Select same columns as main table if (is.null(input$team_columns) || !length(input$team_columns)) { selected_cols <- c("Batter", "OPS", "K %", "BB %", "AVG", "Avg EV", "SC%", "HH%", "Whiff%") } else { selected_cols <- c("Batter", input$team_columns) } team_display <- team_total %>% select(any_of(selected_cols)) # Format the team total table dt <- DT::datatable(team_display, options = list(dom = 't', ordering = FALSE, searching = FALSE), rownames = FALSE) # Apply same formatting as main table num_cols_3dec <- intersect(c("AVG", "OBP", "SLG", "OPS"), names(team_display)) if (length(num_cols_3dec)) dt <- DT::formatRound(dt, num_cols_3dec, 3) num_cols_1dec <- intersect(c("Avg EV", "Max EV"), names(team_display)) if (length(num_cols_1dec)) dt <- DT::formatRound(dt, num_cols_1dec, 1) pct_cols <- intersect(c("SC%", "10-30%", "HH%", "Barrel%", "Whiff%", "Z Whiff%", "Chase%", "GB%", "LD%", "FB%", "Pop%", "K %", "BB %", "Free%", "Pull%", "Middle%", "Oppo%", "Pull FB%"), names(team_display)) if (length(pct_cols)) dt <- DT::formatRound(dt, pct_cols, 1) # Style the row with a distinct background dt <- DT::formatStyle(dt, 'Batter', backgroundColor = '#d4edeb', fontWeight = 'bold') dt }) game_choices <- reactive({ fd <- filtered_data() %>% filter(Batter == input$playerSelect) if (!nrow(fd)) return(character(0)) if ("game" %in% names(fd)) { sort(unique(fd$game)) } else if ("Date" %in% names(fd)) { sort(unique(format(fd$Date, "%Y-%m-%d"))) } else { character(0) } }) output$game_controls <- renderUI({ req(game_choices()) selectInput("game_id", "Game", choices = game_choices(), selected = tail(game_choices(), 1)) }) output$hitter_game_table <- gt::render_gt({ req(input$playerSelect, input$game_id) hitter_game_safe(input$playerSelect, input$game_id, filtered_data()) }) output$last15_games_table <- gt::render_gt({ req(selected_player()) create_last15_table(selected_player(), filtered_data()) }) output$adv_table <- gt::render_gt({ req(selected_player()) create_advanced_numbers_table(selected_player(), filtered_data()) }) # FB Splits Table output$fb_splits_table <- gt::render_gt({ req(selected_player()) create_fb_splits_table(selected_player(), filtered_data()) }) # Offspeed Splits Table output$offspeed_splits_table <- gt::render_gt({ req(selected_player()) create_offspeed_splits_table(selected_player(), filtered_data()) }) # At-Bat Breakdown (for Game View) output$at_bats_plot <- renderPlot({ req(selected_player(), input$game_id) create_at_bats_plot(filtered_data(), selected_player(), input$game_id, pitch_colors) }) output$spray_chart_plotly <- renderPlotly({ req(selected_player()) create_spray_chart_plotly(selected_player(), filtered_data()) }) output$spray_chart_splits <- renderPlot({ req(selected_player()) create_spray_chart_splits(selected_player(), filtered_data()) }) output$spray_chart_sector <- renderPlot({ req(selected_player()) create_sector_spray_chart(selected_player(), filtered_data()) }) output$spray_chart_trajectory <- renderPlot({ req(selected_player()) create_trajectory_spray_chart(selected_player(), filtered_data()) }) output$spray_chart_2k <- renderPlot({ req(selected_player()) create_2k_spray_chart(selected_player(), filtered_data()) }) output$spray_chart_schill_basic <- renderPlot({ req(selected_player()) create_schill_basic_spray(selected_player(), filtered_data()) }) output$spray_chart_classic <- renderPlot({ req(selected_player()) create_spray_chart(selected_player(), filtered_data()) }) daily_ev_filtered <- reactive({ if (!nrow(BP_data)) return(BP_data) df <- BP_data # Apply date filter if (!is.null(input$daily_ev_dateRange) && all(!is.na(input$daily_ev_dateRange))) { df <- df %>% filter(Date >= as.Date(input$daily_ev_dateRange[1]) & Date <= as.Date(input$daily_ev_dateRange[2])) } # Apply player filter if (!is.null(input$daily_ev_players) && length(input$daily_ev_players) > 0) { df <- df %>% filter(Batter %in% input$daily_ev_players) } df }) # Daily EV summary data daily_ev_summary <- reactive({ filtered <- daily_ev_filtered() if (!nrow(filtered)) return(tibble::tibble(Message = "No data available")) filtered %>% filter(BIPind == 1, !is.na(ExitSpeed)) %>% group_by(Batter, Date) %>% summarize( BBE = n(), `Avg EV` = round(mean(ExitSpeed, na.rm = TRUE), 1), `Max EV` = round(max(ExitSpeed, na.rm = TRUE), 1), `Min EV` = round(min(ExitSpeed, na.rm = TRUE), 1), `HH Count` = sum(ExitSpeed >= 95, na.rm = TRUE), `HH%` = round(sum(ExitSpeed >= 95, na.rm = TRUE) / n() * 100, 1), `95+ Count` = sum(ExitSpeed >= 95, na.rm = TRUE), `100+ Count` = sum(ExitSpeed >= 100, na.rm = TRUE), .groups = "drop" ) %>% arrange(Date, Batter) }) # Daily EV plot output$daily_ev_plot <- renderPlot({ summary_data <- daily_ev_summary() if (!nrow(summary_data) || "Message" %in% names(summary_data)) { return(ggplot() + theme_minimal() + ggtitle("No data available - select players and date range") + theme(plot.title = element_text(hjust = 0.5, size = 14))) } req(input$daily_ev_players) # Determine what to plot metric_col <- if (input$show_max_ev) "Max EV" else "Avg EV" y_label <- if (input$show_max_ev) "Max Exit Velocity (mph)" else "Average Exit Velocity (mph)" p <- ggplot(summary_data, aes(x = Date, y = .data[[metric_col]], color = Batter, group = Batter)) + geom_line(size = 1.2, alpha = 0.7) + geom_point(aes(size = BBE), alpha = 0.8) + scale_size_continuous(name = "Batted Balls", range = c(3, 8)) + scale_color_brewer(palette = "Set2", name = "Player") + labs( title = paste("Daily", y_label, "Tracking"), x = "Date", y = y_label ) + theme_minimal() + theme( plot.title = element_text(hjust = 0.5, size = 16, face = "bold", color = "darkcyan"), axis.title = element_text(size = 12, face = "bold"), axis.text = element_text(size = 10), legend.position = "right", legend.title = element_text(face = "bold"), panel.grid.major = element_line(color = "grey90"), panel.grid.minor = element_line(color = "grey95") ) # Add trend lines if requested if (input$show_trend && nrow(summary_data) > 2) { p <- p + geom_smooth(method = "lm", se = FALSE, linetype = "dashed", size = 0.8, alpha = 0.5) } # Add reference line at 95 mph p <- p + geom_hline(yintercept = 95, linetype = "dotted", color = "#00840D", size = 1, alpha = 0.7) + annotate("text", x = min(summary_data$Date), y = 96, label = "Hard Hit (95+)", hjust = 0, color = "#00840D", size = 3.5, fontface = "bold") p }) # Daily EV table output$daily_ev_table <- DT::renderDataTable({ summary_data <- daily_ev_summary() if (!nrow(summary_data) || "Message" %in% names(summary_data)) { return(DT::datatable(summary_data, options = list(dom = 't'))) } # Format the table dt <- DT::datatable( summary_data, options = list( pageLength = 25, scrollX = TRUE, order = list(list(1, 'desc')), columnDefs = list( list(className = 'dt-center', targets = '_all') ) ), rownames = FALSE ) %>% DT::formatRound(c('Avg EV', 'Max EV', 'Min EV', 'HH%'), 1) %>% DT::formatDate('Date', method = 'toLocaleDateString') # Color code the metrics rank_colors <- colorRampPalette(c("#E1463E", "white", "#00840D"))(100) apply_color <- function(obj, column) { if (!column %in% names(summary_data)) return(obj) vals <- suppressWarnings(as.numeric(summary_data[[column]])) vals <- vals[is.finite(vals)] if (!length(vals)) return(obj) rng <- range(vals, na.rm = TRUE) if (!is.finite(rng[1]) || !is.finite(rng[2])) return(obj) if (identical(rng[1], rng[2])) { return(DT::formatStyle(obj, column, backgroundColor = DT::styleEqual(rng[1], rank_colors[50]))) } cuts <- seq(rng[1], rng[2], length.out = 99) DT::formatStyle(obj, column, backgroundColor = DT::styleInterval(cuts, rank_colors)) } # Apply colors to EV metrics dt <- apply_color(dt, "Avg EV") dt <- apply_color(dt, "Max EV") dt <- apply_color(dt, "HH%") # Highlight high hard-hit counts dt <- DT::formatStyle(dt, '95+ Count', backgroundColor = DT::styleInterval(c(0, 2, 4, 6), c('white', '#e8f5e9', '#c8e6c9', '#a5d6a7', '#81c784'))) dt }) output$weekly_report_preview <- renderPlot({ req(selected_player()) fd <- filtered_data() n_games <- if (!is.null(input$weekly_n_games) && !is.na(input$weekly_n_games)) input$weekly_n_games else 3 batter_data <- fd %>% filter(Batter == selected_player()) game_dates <- batter_data %>% distinct(Date) %>% arrange(desc(Date)) %>% slice_head(n = n_games) %>% arrange(Date) %>% pull(Date) if (length(game_dates) == 0) return(NULL) # --- Summary stats --- games_data <- batter_data %>% filter(Date %in% game_dates) stats_df <- compute_weekly_game_stats(games_data) stats_grob <- create_stats_header_grob(stats_df) # --- AB plots --- ab_plots <- lapply(game_dates, function(gd) { gd_str <- format(gd, "%Y-%m-%d") rows <- fd %>% filter(Batter == selected_player(), Date == gd) opp <- unique(rows$PitcherTeam) opp <- opp[!is.na(opp)] opp_label <- if (length(opp)) opp[1] else NULL create_weekly_ab_plot(fd, selected_player(), gd_str, opp_label) }) # --- Heatmaps --- hm_solid <- create_report_heatmap(selected_player(), fd, "Solid Contact", "Solid Contact") hm_whiff <- create_report_heatmap(selected_player(), fd, "Whiffs", "Whiffs") hm_fb <- create_report_heatmap(selected_player(), fd, "FB Whiffs", "FB Whiffs") # --- Notes --- notes_grob <- grid::grobTree( grid::rectGrob(gp = grid::gpar(fill = "white", col = "black", lwd = 1)), grid::textGrob( paste0("Notes:\n", ifelse(is.null(input$weekly_notes), "", input$weekly_notes)), x = 0.05, y = 0.95, hjust = 0, vjust = 1, gp = grid::gpar(fontsize = 10) ) ) # --- Legends --- shape_leg_grob <- create_shape_legend_grob() pt_leg_grob <- create_pitch_color_legend_grob() legends_row <- gridExtra::arrangeGrob(shape_leg_grob, pt_leg_grob, ncol = 2) # --- Assemble --- ab_grobs <- lapply(ab_plots, ggplotGrob) ab_stack <- do.call(gridExtra::arrangeGrob, c(ab_grobs, list(ncol = 1))) n_ab <- length(ab_grobs) title_g <- grid::textGrob( paste(selected_player(), "- Weekly Hitter Report"), gp = grid::gpar(fontsize = 18, fontface = "bold", col = "#006F71") ) season_label <- grid::textGrob("Season Heatmaps", gp = grid::gpar(fontsize = 14, fontface = "bold")) heatmap_row <- gridExtra::arrangeGrob( ggplotGrob(hm_solid), ggplotGrob(hm_whiff), ggplotGrob(hm_fb), notes_grob, ncol = 4, widths = c(1, 1, 1, 1) ) gridExtra::grid.arrange( title_g, stats_grob, legends_row, ab_stack, season_label, heatmap_row, ncol = 1, heights = c(0.5, 0.6, 0.6, n_ab * 2.8, 0.4, 3.5) ) }) output$download_weekly_report <- downloadHandler( filename = function() { paste0(gsub(" ", "_", selected_player()), "_Weekly_Report_", format(Sys.Date(), "%Y%m%d"), ".pdf") }, content = function(file) { fd <- filtered_data() n_games <- if (!is.null(input$weekly_n_games) && !is.na(input$weekly_n_games)) input$weekly_n_games else 3 notes <- ifelse(is.null(input$weekly_notes), "", input$weekly_notes) generate_weekly_report_pdf( player_name = selected_player(), team_data = fd, n_games = n_games, output_path = file, notes_text = notes ) } ) output$hitting_grades_radar <- renderPlot({ req(selected_player()) create_hitting_grades_radar(selected_player(), filtered_data()) }) output$hitting_grades_table <- gt::render_gt({ req(selected_player()) create_hitting_grades_table(selected_player(), filtered_data()) }) daily_ev_filtered <- reactive({ if (!nrow(BP_data)) return(BP_data) df <- BP_data # Apply date filter if (!is.null(input$daily_ev_dateRange) && all(!is.na(input$daily_ev_dateRange))) { df <- df %>% filter(Date >= as.Date(input$daily_ev_dateRange[1]) & Date <= as.Date(input$daily_ev_dateRange[2])) } # Apply player filter if (!is.null(input$daily_ev_players) && length(input$daily_ev_players) > 0) { df <- df %>% filter(Batter %in% input$daily_ev_players) } df }) #IF defense output$is_outfielder <- reactive({ is_outfielder() }) output$is_infielder <- reactive({ is_infielder() }) outputOptions(output, "is_outfielder", suspendWhenHidden = FALSE) outputOptions(output, "is_infielder", suspendWhenHidden = FALSE) observeEvent(selected_player(), { if (selected_player() %in% OAA_DF$obs_player) { updateSelectInput(session, "position", choices = c("LF", "CF", "RF", "ALL")) } else if (selected_player() %in% IF_OAA$obs_player_name) { updateSelectInput(session, "position", choices = c("SS", "2B", "3B", "1B", "ALL")) } else { updateSelectInput(session, "position", choices = NULL) } }) # =============================== # NOTE: Add these outputs to your server function # =============================== # Bat Tracking Summary Table output$bat_tracking_summary_table <- gt::render_gt({ req(selected_player()) create_bat_tracking_summary(selected_player(), filtered_data()) }) # Attack Angle Zone Heatmap output$aa_zone_heatmap <- renderPlot({ req(selected_player(), input$bat_metric) create_aa_zone_heatmap( selected_player(), filtered_data(), metric = input$bat_metric ) }) # Bat Speed vs Attack Angle Scatter output$batspeed_aa_scatter <- renderPlot({ req(selected_player()) create_batspeed_aa_scatter(selected_player(), filtered_data()) }) # Bat Contact Map output$bat_contact_map <- renderPlot({ req(selected_player()) create_bat_contact_map(selected_player(), filtered_data()) }) # Attack Angle by Pitch Height output$aa_by_height_plot <- renderPlot({ req(selected_player()) create_aa_by_pitch_height(selected_player(), filtered_data()) }) # HAA Spray Overlay output$haa_spray_overlay <- renderPlot({ req(selected_player()) create_haa_spray_overlay(selected_player(), filtered_data()) }) # Bat Tracking Leaderboard output$bat_tracking_leaderboard_table <- DT::renderDataTable({ req(input$seasonSelect) data_to_show <- create_bat_tracking_leaderboard(filtered_data()) if (!nrow(data_to_show) || "Message" %in% names(data_to_show)) { return(DT::datatable(data_to_show, options = list(dom = "t"))) } dt <- DT::datatable( data_to_show, options = list( paging = TRUE, ordering = TRUE, searching = TRUE, scrollX = TRUE, pageLength = 15 ), rownames = FALSE ) %>% DT::formatRound( c("Avg BS", "Max BS", "Avg AA", "Avg HAA", "AA 8-15", "Hit%"), 1 ) # Color scale rank_colors <- colorRampPalette(c("#E1463E", "white", "#00840D"))(100) apply_color <- function(obj, column, reverse = FALSE) { if (!column %in% names(data_to_show)) return(obj) vals <- suppressWarnings(as.numeric(data_to_show[[column]])) vals <- vals[is.finite(vals)] if (!length(vals)) return(obj) cols <- if (reverse) rev(rank_colors) else rank_colors rng <- range(vals, na.rm = TRUE) if (!is.finite(rng[1]) || !is.finite(rng[2])) return(obj) if (identical(rng[1], rng[2])) { return( DT::formatStyle( obj, column, backgroundColor = DT::styleEqual(rng[1], cols[50]) ) ) } cuts <- seq(rng[1], rng[2], length.out = 99) DT::formatStyle( obj, column, backgroundColor = DT::styleInterval(cuts, cols) ) } dt <- apply_color(dt, "Avg BS") dt <- apply_color(dt, "Max BS") dt <- apply_color(dt, "AA 8-15") dt <- apply_color(dt, "Hit%") dt }) output$bp_radial_plot <- renderPlot({ req(input$bp_player) create_bp_radial_chart(input$bp_player, bp_leaderboard_filtered()) }) output$bp_player_stats_table <- gt::render_gt({ req(input$bp_player) create_bp_player_stats_table(input$bp_player, bp_leaderboard_filtered()) }) # Create pivot table output$daily_ev_pivot_table <- DT::renderDataTable({ filtered <- daily_ev_filtered() if (!nrow(filtered)) { return(DT::datatable(data.frame(Message = "No data available"), options = list(dom = 't'))) } # Calculate metrics by player and date daily_stats <- filtered %>% filter(BIPind == 1, !is.na(ExitSpeed)) %>% group_by(Batter, Date) %>% summarize( avg_ev = round(mean(ExitSpeed, na.rm = TRUE), 1), max_ev = round(max(ExitSpeed, na.rm = TRUE), 1), hh_pct = round(sum(ExitSpeed >= 95, na.rm = TRUE) / n() * 100, 1), .groups = "drop" ) if (!nrow(daily_stats)) { return(DT::datatable(data.frame(Message = "No batted ball data for selected dates/players"), options = list(dom = 't'))) } # Determine which metric to show metric_col <- switch(input$ev_metric, "avg" = "avg_ev", "max" = "max_ev", "hh_pct" = "hh_pct", "avg_ev") # Create pivot table (wide format) pivot_data <- daily_stats %>% select(Batter, Date, !!sym(metric_col)) %>% tidyr::pivot_wider(names_from = Date, values_from = !!sym(metric_col)) %>% arrange(Batter) # Add team average row if requested if (input$show_team_row) { team_avg <- daily_stats %>% group_by(Date) %>% summarize(!!sym(metric_col) := round(mean(!!sym(metric_col), na.rm = TRUE), 1), .groups = "drop") %>% tidyr::pivot_wider(names_from = Date, values_from = !!sym(metric_col)) %>% mutate(Batter = "TEAM", .before = 1) pivot_data <- bind_rows(pivot_data, team_avg) } # Format date columns to show as MM-DD date_cols <- names(pivot_data)[-1] for (col in date_cols) { new_name <- format(as.Date(col), "%m-%d") names(pivot_data)[names(pivot_data) == col] <- new_name } # Create DT table dt <- DT::datatable( pivot_data, options = list( pageLength = 25, scrollX = TRUE, scrollY = "600px", paging = FALSE, ordering = TRUE, columnDefs = list( list(className = 'dt-left', targets = 0), list(className = 'dt-center', targets = 1:(ncol(pivot_data)-1)) ) ), rownames = FALSE, class = 'cell-border stripe' ) %>% DT::formatStyle( 'Batter', fontWeight = 'bold', backgroundColor = '#f0f8ff' ) # Apply color gradient to EV cells all_cols <- names(pivot_data)[-1] # All columns except Batter if (input$ev_metric %in% c("avg", "max")) { # For EV metrics: green = high (good), red = low (bad) for (col in all_cols) { dt <- DT::formatStyle( dt, col, backgroundColor = DT::styleInterval( c(85, 90, 95, 100), c('#ffcccc', '#ffe6cc', 'white', '#c8e6c9', '#81c784') ), fontWeight = DT::styleInterval(95, c('normal', 'bold')) ) } } else if (input$ev_metric == "hh_pct") { # For Hard Hit %: green = high (good), red = low (bad) for (col in all_cols) { dt <- DT::formatStyle( dt, col, backgroundColor = DT::styleInterval( c(30, 50, 70, 85), c('#ffcccc', '#ffe6cc', 'white', '#c8e6c9', '#81c784') ), fontWeight = DT::styleInterval(70, c('normal', 'bold')) ) } } # Highlight TEAM row if present if (input$show_team_row) { dt <- DT::formatStyle( dt, 'Batter', target = 'row', fontWeight = DT::styleEqual('TEAM', 'bold'), backgroundColor = DT::styleEqual('TEAM', '#006F71'), color = DT::styleEqual('TEAM', 'white') ) } dt }) } shinyApp(ui, server)