Sys.setenv(RETICULATE_PYTHON = "/usr/bin/python3") library(shiny) library(shinydashboard) library(shinyWidgets) library(ggplot2) library(dplyr) library(tidyr) library(gt) library(grid) library(scales) library(png) library(purrr) library(httr) library(arrow) library(readr) library(reticulate) PASSWORD <- Sys.getenv("password") has_ggimage <- requireNamespace("ggimage", quietly = TRUE) if (has_ggimage) library(ggimage) repo_id <- "CoastalBaseball/AdvancePitcher" hf_token <- Sys.getenv("Advance_Reader") if (identical(hf_token, "")) { stop("HF token not found. Add a Space secret named 'Advance_Reader'.") } hf_cache_dir <- function() { # If HF persistent storage exists, use it; otherwise use a stable local cache folder d <- if (dir.exists("/data")) "/data/hf_cache" else "/tmp/hf_cache" dir.create(d, recursive = TRUE, showWarnings = FALSE) d } hf_download_file_cached <- function(repo_id, filename) { url <- paste0("https://huggingface.co/datasets/", repo_id, "/resolve/main/", filename) dest <- file.path(hf_cache_dir(), filename) if (file.exists(dest) && file.info(dest)$size > 0) return(dest) cat("Downloading", filename, "from HF...\n") resp <- GET(url, add_headers(Authorization = paste("Bearer", hf_token))) if (status_code(resp) != 200) { stop(paste0("Failed to download ", filename, " (status ", status_code(resp), ").")) } writeBin(content(resp, "raw"), dest) dest } download_private_csv_cached <- function(repo_id, filename) { path <- hf_download_file_cached(repo_id, filename) readr::read_csv(path, show_col_types = FALSE) } # Small helper: collect arrow queries to a real data.frame when you need it collect_df <- function(x) { as.data.frame(dplyr::collect(x)) } download_private_parquet <- function(repo_id, filename) { 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.") } response <- GET(url, add_headers(Authorization = paste("Bearer", api_key))) if (status_code(response) == 200) { temp_file <- tempfile(fileext = ".parquet") writeBin(content(response, "raw"), temp_file) data <- read_parquet(temp_file) return(data) } else { stop(paste("Failed to download dataset. Status code:", status_code(response))) } } 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 }) bind_rows(Filter(Negate(is.null), all_data)) } # ============================================================================ # LOAD TRACKMAN DATA (HF parquet) AS LAZY ARROW DATASET # ============================================================================ cat("Loading TrackMan data from Hugging Face (lazy Arrow dataset)...\n") parquet_path <- hf_download_file_cached(repo_id, "new_college_data25.parquet") # TM2025 is a *lazy* Arrow Dataset (fast startup, low RAM) TM2025 <- arrow::open_dataset(parquet_path, format = "parquet") # tm_data stays lazy too; transformations happen in Arrow until you collect() tm_data_lazy <- TM2025 %>% mutate( PlateLocSide = PlateLocSide / 12, PlateLocHeight = PlateLocHeight / 12 ) cat("TM2025 loaded (lazy). Parquet cached at:", parquet_path, "\n") cat("Loading merged_teams.csv...\n") teams_data <- tryCatch({ td <- readr::read_csv("merged_teams.csv", show_col_types = FALSE) # Clean up - remove rows with empty team names td <- td %>% dplyr::filter(!is.na(team_name) & team_name != "") cat("Loaded", nrow(td), "teams\n") td }, error = function(e) { cat("WARNING: Could not load merged_teams.csv:", e$message, "\n") # Return empty frame with expected columns data.frame( trackman_abbr = character(), team_name = character(), team_logo = character(), league = character(), primary_color = character(), secondary_color = character(), conference_logo = character(), stringsAsFactors = FALSE ) }) #Load 2026 Data from the Master Dataset data_2026 <- download_master_dataset("CoastalBaseball/2026MasterDataset", "pbp") # ============================================================================ # LOAD BATTER / PITCHER HEIGHTS (small CSV) # ============================================================================ cat("Loading batter_heights.csv from Hugging Face...\n") pitcher_heights <- tryCatch({ heights_df <- as.data.frame(download_private_csv_cached(repo_id, "batter_heights.csv")) names(heights_df) <- c("Name", "Ht") heights_df %>% mutate( Ht_clean = gsub('"', "", Ht), feet = suppressWarnings(as.numeric(gsub("'.*", "", Ht_clean))), inches = suppressWarnings(as.numeric(gsub(".*'\\s*", "", Ht_clean))), inches = ifelse(is.na(inches), 0, inches), height_inches = feet * 12 + inches ) %>% select(Name, Ht, height_inches) }, error = function(e) { cat("WARNING: Could not load batter heights:", e$message, "\n") NULL }) if (!is.null(pitcher_heights)) { cat("Loaded", nrow(pitcher_heights), "pitcher heights\n") } # ---- Height lookup helpers ---- get_pitcher_height <- function(name) { if (is.null(pitcher_heights)) return(72) h <- pitcher_heights %>% filter(Name == name) %>% pull(height_inches) if (length(h) == 0 || is.na(h[1])) return(72) h[1] } get_pitcher_height_display <- function(name) { if (is.null(pitcher_heights)) return("") row <- pitcher_heights %>% filter(Name == name) if (nrow(row) == 0) return("") row$Ht[1] } # ============================================================================ # COLLECT DATA FOR APP USE # We need to collect() the lazy dataset for functions that need full data # But we do it ONCE and keep only needed columns # ============================================================================ cat("Collecting data with required columns only...\n") # Define columns needed for the app required_cols <- c( # Core identifiers "Pitcher", "Batter", "PitcherThrows", "BatterSide", "PitcherTeam", "Date", "GameUID", "GameID", "Inning", # Pitch info "TaggedPitchType", "PitchCall", "PlayResult", "KorBB", "PitchofPA", "Count", # Count columns "Balls", "Strikes", "Outs", "PAofInning", "OutsOnPlay", "RunsScored", # Location "PlateLocSide", "PlateLocHeight", "RelSide", "RelHeight", "Extension", # Movement & velocity "HorzBreak", "InducedVertBreak", "VertBreak", "RelSpeed", "SpinRate", "SpinAxis", # Approach angles "VertApprAngle", "HorzApprAngle", # Contact "ExitSpeed", "Angle", "Direction", "HitType", "Distance", "Bearing", # Indicators (if they exist) "WhiffIndicator", "SwingIndicator", "StrikeZoneIndicator", # Advanced metrics (if they exist) "stuff_plus", "woba", "wobacon" ) # Get available columns from the lazy dataset available_cols <- names(TM2025) cols_to_select <- intersect(required_cols, available_cols) cat("Selecting", length(cols_to_select), "of", length(available_cols), "columns\n") # Collect only needed columns tm_data <- tm_data_lazy %>% select(all_of(cols_to_select)) %>% collect_df() cols_to_select_2026 <- intersect(required_cols, names(data_2026)) data_2026 <- data_2026 %>% mutate( PlateLocSide = PlateLocSide / 12, PlateLocHeight = PlateLocHeight / 12 ) %>% dplyr::select(all_of(cols_to_select_2026)) %>% collect_df() cat("Collected", nrow(tm_data), "rows and", ncol(tm_data), "columns\n") cat("Collected", nrow(data_2026), "rows and", ncol(data_2026), "columns\n") # Clean up lazy objects rm(tm_data_lazy, TM2025) gc() # ---- SEC Benchmarks ---- sec_averages <- list( overall = list( chase = 26.2, k_rate = 26.4, bb_rate = 10, iz_whiff = 20, miss_rate = 28.9, fb_velo_l = 91.1, fb_velo_r = 93, strike_rate = 62.6, zone_rate = 46 ), fb_sinker = list(spin = 2267, zone = 50, strike = 64.4, iz_whiff = 17.9, whiff = 22.5, chase = 23.5), slider = list(velo_l = 81.6, velo_r = 83, zone = 42, spin = 2440, strike = 61.4, iz_whiff = 22.1, whiff = 37.5, chase = 28.6), curveball = list(velo_l = 78.2, velo_r = 79.1, zone = 40.6, spin = 2442, strike = 57.6, iz_whiff = 22.3, whiff = 38.1, chase = 24.4), changeup = list(velo_l = 81.8, velo_r = 84.1, zone = 37.1, spin = 1708, strike = 58.6, iz_whiff = 27.6, whiff = 37.7, chase = 31.2), cutter = list(velo_l = 86, velo_r = 86.8, zone = 46.9, spin = 2387, strike = 64.7, iz_whiff = 19.8, whiff = 30.4, chase = 28.9) ) sec_extension_benchmark <- function(pt) { if (pt %in% c("Fastball","Four-Seam","FourSeamFastBall","Sinker","TwoSeamFastBall","Two-Seam")) return(5.83) if (pt %in% c("Slider","Sweeper")) return(5.54) if (pt %in% c("Curveball","Knuckle Curve")) return(5.47) if (pt %in% c("ChangeUp","Splitter")) return(5.98) if (pt %in% c("Cutter")) return(5.70) NA_real_ } # ---- Gradient Color Function for SEC Benchmarking ---- get_gradient_color <- function(value, benchmark, metric_type = "higher_better", range_pct = 0.25) { if (is.na(value) || is.na(benchmark) || is.null(value) || is.null(benchmark)) return("#FFFFFF") if (is.nan(value) || is.infinite(value)) return("#FFFFFF") pal <- scales::gradient_n_pal(c("#E1463E", "white", "#00840D")) range_val <- benchmark * range_pct min_val <- benchmark - range_val max_val <- benchmark + range_val normalized <- if (metric_type == "higher_better") { (value - min_val) / (max_val - min_val) } else { (max_val - value) / (max_val - min_val) } normalized <- pmax(0, pmin(1, normalized)) pal(normalized) } # ---- Pitch Family Classification ---- classify_pitch_family <- function(pitch_type) { case_when( pitch_type %in% c("Fastball", "Four-Seam", "Sinker", "FourSeamFastBall", "TwoSeamFastBall", "FF", "SI", "FB") ~ "FB", pitch_type %in% c("Cutter", "Curveball", "Slider", "Sweeper", "Slurve", "CU", "SL", "FC", "SW", "KC") ~ "BB", pitch_type %in% c("Changeup", "ChangeUp", "Splitter", "CH", "FS") ~ "OS", TRUE ~ "Other" ) } clean_data <- function(data) { # These columns are needed for various calculations if (!"Strikes" %in% names(data)) { cat("Adding default Strikes column...\n") data$Strikes <- NA_integer_ } if (!"Balls" %in% names(data)) { cat("Adding default Balls column...\n") data$Balls <- NA_integer_ } if (!"GameID" %in% names(data)) { cat("Creating GameID from GameUID...\n") # Use GameUID if available, otherwise create from Date+Pitcher if ("GameUID" %in% names(data)) { data$GameID <- data$GameUID } else if ("Date" %in% names(data)) { data$GameID <- paste(data$Date, data$Pitcher, sep = "_") } else { data$GameID <- seq_len(nrow(data)) } } if (!"Count" %in% names(data)) { cat("Creating Count from Balls and Strikes...\n") data$Count <- paste0(data$Balls, "-", data$Strikes) } if (!"WhiffIndicator" %in% names(data)) { cat("Creating WhiffIndicator from PitchCall...\n") data$WhiffIndicator <- as.integer(data$PitchCall == "StrikeSwinging") } if (!"SwingIndicator" %in% names(data)) { cat("Creating SwingIndicator from PitchCall...\n") data$SwingIndicator <- as.integer(data$PitchCall %in% c("StrikeSwinging", "FoulBall", "InPlay")) } if (!"StrikeZoneIndicator" %in% names(data)) { cat("Creating StrikeZoneIndicator from location...\n") data$StrikeZoneIndicator <- as.integer( abs(data$PlateLocSide) <= 0.83 & data$PlateLocHeight >= 1.5 & data$PlateLocHeight <= 3.5 ) } # ---- Join pitcher heights to data if available ---- if (!is.null(pitcher_heights)) { data <- data %>% left_join(pitcher_heights %>% select(Name, height_inches), by = c("Pitcher" = "Name")) %>% mutate(height_inches = ifelse(is.na(height_inches), 72, height_inches)) cat("Joined pitcher heights to TrackMan data\n") } else { data$height_inches <- 72 } # ---- Calculate Arm Angle using height ---- if (!"arm_angle_savant" %in% names(data)) { cat("Calculating arm angles...\n") data <- data %>% mutate( RelSide_in = 12 * as.numeric(RelSide), RelHeight_in = 12 * as.numeric(RelHeight), shoulder_pos = 0.70 * height_inches, Adj = pmax(RelHeight_in - shoulder_pos, 1e-6), Opp = abs(RelSide_in), arm_angle_deg = atan2(Opp, Adj) * 180 / pi, arm_angle_savant = pmin(pmax(90 - arm_angle_deg, 0), 90) ) %>% select(-RelSide_in, -RelHeight_in, -shoulder_pos, -Adj, -Opp, -arm_angle_deg) cat("Arm angle calculation complete\n") } # Verify key columns exist key_cols <- c("Pitcher", "Batter", "TaggedPitchType", "PlateLocSide", "PlateLocHeight", "RelSide", "RelHeight", "HorzBreak", "InducedVertBreak", "RelSpeed", "WhiffIndicator", "SwingIndicator", "StrikeZoneIndicator", "stuff_plus", "arm_angle_savant", "PitcherThrows", "BatterSide") missing_cols <- setdiff(key_cols, names(data)) if (length(missing_cols) > 0) { cat("Note: Missing columns:", paste(missing_cols, collapse = ", "), "\n") } return(data) } tm_data <- clean_data(tm_data) data_2026 <- clean_data(data_2026) tm_data$Date <- as.Date(tm_data$Date) data_2026$Date <- as.Date(data_2026$Date) cat("tm_data is cleaned and has ", nrow(tm_data), " rows", '\n') cat("data_2026 is cleaned and has ", nrow(data_2026), " rows") # ============================================================================ # PITCH TYPE MATCHUP MATRIX FUNCTIONS # ============================================================================ # Note: Matchup matrix functionality has been moved to a separate app # to reduce memory usage cat("Data loading complete.\n") # ============================================================================ # PITCH COLORS - Consistent throughout app # ============================================================================ pitch_colors <- c( "Fastball" = "#3465cb", "Four-Seam" = "#3465cb", "FourSeamFastBall" = "#3465cb", "4-Seam Fastball" = "#3465cb", "FF" = "#3465cb", "Sinker" = "#e5e501", "TwoSeamFastBall" = "#e5e501", "Two-Seam" = "#e5e501", "2-Seam Fastball" = "#e5e501", "SI" = "#e5e501", "Slider" = "#65aa02", "SL" = "#65aa02", "Sweeper" = "#dc4476", "SW" = "#dc4476", "Curveball" = "#d73813", "CB" = "#d73813", "Knuckle Curve" = "#d73813", "KC" = "#d73813", "ChangeUp" = "#980099", "Changeup" = "#980099", "CH" = "#980099", "Splitter" = "#23a999", "FS" = "#23a999", "SP" = "#23a999", "Cutter" = "#ff9903", "FC" = "#ff9903", "Slurve" = "#9370DB", "Other" = "gray50" ) # ============================================================================ # HELPER FUNCTIONS FOR ARM ANGLE AND MOVEMENT # ============================================================================ # Get pitcher image URL based on arm angle get_pitcher_image_url <- function(arm_angle, is_lefty) { if (is_lefty) { if (arm_angle >= 40) { return("https://i.imgur.com/WYChFiq.png") } else if (arm_angle >= 10) { return("https://i.imgur.com/KeeUG6z.png") } else { return("https://i.imgur.com/HewvuXT.png") } } else { if (arm_angle >= 40) { return("https://i.imgur.com/84v0KDV.png") } else if (arm_angle >= 10) { return("https://i.imgur.com/RLlIVrc.png") } else { return("https://i.imgur.com/cjpIEME.png") } } } # Calculate shoulder Y position shoulder_y_from_height <- function(height_inches = 72, shoulder_scale = 0.70) { (height_inches * shoulder_scale) / 12 } # Calculate ray end point for arm angle visualization ray_end_for_type <- function(mean_rel_side, mean_rel_height, length_units, shoulder_y) { dx <- abs(mean_rel_side) dy <- max(mean_rel_height - shoulder_y, 0) mag <- sqrt(dx^2 + dy^2) if (!is.finite(mag) || mag == 0) return(c(0, 0)) ux <- dx / mag uy <- dy / mag x_sign <- ifelse(mean_rel_side < 0, -1, 1) c(x_sign * ux * length_units, uy * length_units) } # Build expected movement grid by release height build_expected_movement_grid <- function(data, bin_size = 0.10) { if (is.null(data)) return(NULL) data %>% filter(!is.na(TaggedPitchType), !is.na(RelHeight), !is.na(HorzBreak), !is.na(InducedVertBreak), TaggedPitchType != "Other") %>% mutate(RH_bin = round(RelHeight / bin_size) * bin_size) %>% group_by(TaggedPitchType, RH_bin) %>% summarise( exp_hb = median(HorzBreak, na.rm = TRUE), exp_ivb = median(InducedVertBreak, na.rm = TRUE), n = n(), .groups = "drop" ) %>% filter(n >= 10) } # Final garbage collection before app starts gc() cat("Startup memory optimization complete\n") # ============================================================================ # VISUALIZATION FUNCTIONS # ============================================================================ # 1. ARM SLOT PLOT - Compact with pitcher figure and arm angle rays create_pitcher_arm_angle_plot <- function(data, pitcher_name) { if (is.null(data)) { return(ggplot() + theme_void() + annotate("text", x = 0.5, y = 0.5, label = "No data available")) } pitcher_data <- data %>% filter(Pitcher == pitcher_name, !is.na(RelSide), !is.na(RelHeight), !is.na(TaggedPitchType), TaggedPitchType != "Other") if (nrow(pitcher_data) < 10) { return(ggplot() + theme_void() + annotate("text", x = 0.5, y = 0.5, label = "Insufficient data")) } # Detect handedness pitcher_hand <- pitcher_data$PitcherThrows[1] is_lefty <- !is.na(pitcher_hand) && pitcher_hand == "Left" # Get overall arm angle overall_arm_angle <- mean(pitcher_data$arm_angle_savant, na.rm = TRUE) if (is.na(overall_arm_angle)) overall_arm_angle <- 45 # Categorize arm slot type - added High 3/4 arm_slot_category <- if (overall_arm_angle < 10) "Sidearm" else if (overall_arm_angle < 40) "Low 3/4" else if (overall_arm_angle < 47) "3/4" else if (overall_arm_angle < 60) "High 3/4" else "Overhand" # Calculate averages per pitch type for arm angle rays avg <- pitcher_data %>% filter(!is.na(arm_angle_savant)) %>% group_by(TaggedPitchType) %>% summarise( RelSide = mean(RelSide, na.rm = TRUE), RelHeight = mean(RelHeight, na.rm = TRUE), arm_angle = mean(arm_angle_savant, na.rm = TRUE), n = n(), .groups = "drop" ) %>% filter(n >= 5) if (nrow(avg) == 0) { return(ggplot() + theme_void() + annotate("text", x = 0.5, y = 0.5, label = "No arm angle data")) } # Get pitcher image URL image_url <- get_pitcher_image_url(overall_arm_angle, is_lefty) # Figure positioning depends on arm angle type # Different figures have different poses - need to adjust x and y for each # Key: figures need to be LOW enough to touch the mound if (overall_arm_angle >= 47) { # Overhand / High 3/4 - upright figure, shift for handedness img_center_x <- if (is_lefty) -0.35 else 0.35 img_center_y <- 2.20 shoulder_y_fixed <- 4.0 fig_size <- 0.285 } else if (overall_arm_angle >= 40) { # 3/4 - slightly more shift than overhand img_center_x <- if (is_lefty) -0.40 else 0.40 img_center_y <- 2.20 shoulder_y_fixed <- 4.0 fig_size <- 0.285 } else if (overall_arm_angle >= 10) { # Low 3/4 - significant adjustment for proper alignment img_center_x <- if (is_lefty) -1.07 else 1.07 # More shifted for better alignment img_center_y <- 2.55 # Higher up shoulder_y_fixed <- 4.0 fig_size <- 0.264 # Slightly taller } else { # Sidearm - dramatic lean img_center_x <- if (is_lefty) -0.9 else 0.9 img_center_y <- 3.0 shoulder_y_fixed <- 4.0 fig_size <- 0.34 } # Create image data frame img_df <- data.frame(x = img_center_x, y = img_center_y, image = image_url) # Calculate segment coordinates for arm angle rays # All rays originate from the fixed shoulder position # Catcher's view: RHP releases on catcher's left (negative X), LHP on right (positive X) seg <- avg %>% mutate( # Release point X: RHP arm on left side (negative), LHP arm on right side (positive) x_rel = if (is_lefty) abs(RelSide) else -abs(RelSide), y_rel = RelHeight, # All rays start from the same fixed shoulder point xs = 0, ys = shoulder_y_fixed, xe = x_rel, ye = y_rel ) # Build plot with extended coordinates for wider release points p <- ggplot(seg) + # Mound/dirt rectangle - extend wider for ±4.5ft annotate("rect", xmin = -4.5, xmax = 4.5, ymin = 0, ymax = 0.6, fill = "#8B4513", alpha = 0.7) + # Teal background behind pitcher annotate("rect", xmin = -4.5, xmax = 4.5, ymin = 0.6, ymax = 4.3, fill = "#006F71", alpha = 0.9) + # Yellow stripe at top of background annotate("rect", xmin = -4.5, xmax = 4.5, ymin = 4.3, ymax = 4.5, fill = "yellow", alpha = 0.9) + # Rubber annotate("rect", xmin = -0.5, xmax = 0.5, ymin = 0.5, ymax = 0.65, fill = "white", color = "black", linewidth = 0.5) # Add pitcher figure - use ggimage if available, otherwise draw simple shape if (has_ggimage) { tryCatch({ p <- p + ggimage::geom_image( data = img_df, aes(x = x, y = y, image = image), size = fig_size ) }, error = function(e) { message("Could not load pitcher image: ", e$message) # Fallback: draw simple pitcher silhouette p <- p + annotate("point", x = img_center_x, y = img_center_y + 1.2, size = 8, color = "gray30") + # Head annotate("segment", x = img_center_x, xend = img_center_x, y = img_center_y + 0.9, yend = img_center_y - 0.3, linewidth = 3, color = "gray30") # Body }) } else { # Draw simple pitcher silhouette without ggimage p <- p + annotate("point", x = img_center_x, y = img_center_y + 1.2, size = 8, color = "gray30") + annotate("segment", x = img_center_x, xend = img_center_x, y = img_center_y + 0.9, yend = img_center_y - 0.3, linewidth = 3, color = "gray30") } # Add shoulder point (small gray dot at fixed shoulder position) p <- p + annotate("point", x = 0, y = shoulder_y_fixed, color = "gray40", size = 2, shape = 16) # Add arm angle rays - THIN LINES colored by pitch type if (nrow(seg) > 0) { p <- p + geom_segment( data = seg, aes(x = xs, y = ys, xend = xe, yend = ye, color = TaggedPitchType), linewidth = 1.5, alpha = 0.9 ) + # Release points - small dots with black outline geom_point( data = seg, aes(x = xe, y = ye, fill = TaggedPitchType), shape = 21, size = 3, stroke = 0.5, color = "black", alpha = 0.9 ) } p <- p + # Labels annotate("text", x = -3.5, y = 6.8, label = "3B", size = 2.5, fontface = "bold") + annotate("text", x = 3.5, y = 6.8, label = "1B", size = 2.5, fontface = "bold") + # Arm angle label at bottom annotate("label", x = 0, y = 0.15, label = paste0(round(overall_arm_angle, 1), "° - ", arm_slot_category), fill = "#e65100", color = "white", fontface = "bold", size = 2.5, label.padding = unit(0.15, "lines")) + # Scales - extended to ±4.5 for wider release points xlim(-4.5, 4.5) + ylim(0, 7) + scale_color_manual(values = pitch_colors, na.value = "gray50") + scale_fill_manual(values = pitch_colors, na.value = "gray50") + labs(title = ("Arm Angles"), x = "Horizontal (ft)", y = "Vertical (ft)") + theme_void() + theme( plot.title = element_text(size = 9, hjust = 0.5, face = "bold"), axis.text = element_text(size = 6), axis.title = element_text(size = 7), panel.background = element_rect(fill = "white", color = NA), plot.background = element_rect(fill = "white", color = NA), legend.position = "none", plot.margin = margin(2, 2, 2, 2) ) return(p) } create_mini_arm_angle <- function(data, pitcher_name) { pitcher_data <- data %>% dplyr::filter(Pitcher == pitcher_name, !is.na(RelSide), !is.na(RelHeight), !is.na(TaggedPitchType), TaggedPitchType != "Other") if (nrow(pitcher_data) < 10) return(ggplot() + theme_void()) pitcher_hand <- pitcher_data$PitcherThrows[1] is_lefty <- !is.na(pitcher_hand) && pitcher_hand == "Left" overall_arm_angle <- mean(pitcher_data$arm_angle_savant, na.rm = TRUE) if (is.na(overall_arm_angle)) overall_arm_angle <- 45 arm_slot_category <- if (overall_arm_angle < 10) "Side" else if (overall_arm_angle < 40) "L3/4" else if (overall_arm_angle < 47) "3/4" else if (overall_arm_angle < 60) "H3/4" else "OH" avg <- pitcher_data %>% dplyr::filter(!is.na(arm_angle_savant)) %>% dplyr::group_by(TaggedPitchType) %>% dplyr::summarise( RelSide = mean(RelSide, na.rm = TRUE), RelHeight = mean(RelHeight, na.rm = TRUE), arm_angle = mean(arm_angle_savant, na.rm = TRUE), n = dplyr::n(), .groups = "drop" ) %>% dplyr::filter(n >= 5) if (nrow(avg) == 0) return(ggplot() + theme_void()) image_url <- get_pitcher_image_url(overall_arm_angle, is_lefty) shoulder_y_fixed <- 4.0 if (overall_arm_angle >= 47) { img_center_x <- if (is_lefty) -0.35 else 0.35 img_center_y <- 2.20; fig_size <- 0.30 } else if (overall_arm_angle >= 40) { img_center_x <- if (is_lefty) -0.40 else 0.40 img_center_y <- 2.20; fig_size <- 0.30 } else if (overall_arm_angle >= 10) { img_center_x <- if (is_lefty) -1.07 else 1.07 img_center_y <- 2.55; fig_size <- 0.27 } else { img_center_x <- if (is_lefty) -0.9 else 0.9 img_center_y <- 3.0; fig_size <- 0.34 } img_df <- data.frame(x = img_center_x, y = img_center_y, image = image_url) seg <- avg %>% dplyr::mutate( x_rel = if (is_lefty) abs(RelSide) else -abs(RelSide), y_rel = RelHeight, xs = 0, ys = shoulder_y_fixed, xe = x_rel, ye = y_rel ) p <- ggplot(seg) + annotate("rect", xmin = -4.5, xmax = 4.5, ymin = 0, ymax = 0.6, fill = "#8B4513", alpha = 0.7) + annotate("rect", xmin = -4.5, xmax = 4.5, ymin = 0.6, ymax = 4.3, fill = "#006F71", alpha = 0.9) + annotate("rect", xmin = -4.5, xmax = 4.5, ymin = 4.3, ymax = 4.5, fill = "yellow", alpha = 0.9) + annotate("rect", xmin = -0.5, xmax = 0.5, ymin = 0.5, ymax = 0.65, fill = "white", color = "black", linewidth = 0.3) if (has_ggimage) { tryCatch({ p <- p + ggimage::geom_image(data = img_df, aes(x = x, y = y, image = image), size = fig_size) }, error = function(e) NULL) } p <- p + annotate("point", x = 0, y = shoulder_y_fixed, color = "gray40", size = 1, shape = 16) if (nrow(seg) > 0) { p <- p + geom_segment(data = seg, aes(x = xs, y = ys, xend = xe, yend = ye, color = TaggedPitchType), linewidth = 0.8, alpha = 0.9) + geom_point(data = seg, aes(x = xe, y = ye, fill = TaggedPitchType), shape = 21, size = 1.5, stroke = 0.3, color = "black", alpha = 0.9) } p <- p + annotate("label", x = 0, y = 0.15, label = paste0(round(overall_arm_angle, 1), "\u00b0 ", arm_slot_category), fill = "#006F71", color = "white", fontface = "bold", size = 1.8, label.padding = unit(0.1, "lines")) + xlim(-4.5, 4.5) + ylim(0, 7) + scale_color_manual(values = pitch_colors, na.value = "gray50") + scale_fill_manual(values = pitch_colors, na.value = "gray50") + theme_void() + theme(legend.position = "none", plot.margin = margin(0, 0, 0, 0), plot.background = element_rect(fill = "white", color = NA)) return(p) } # 3. MOVEMENT CHART - MUCH BIGGER with expected movement diamonds create_pitcher_movement_plot <- function(data, pitcher_name, expected_grid = NULL, show_arm_angle_rays = TRUE, show_arm_angle_annotation = TRUE, marker_style = "scaled_usage", # "scaled_usage", "average", "none" show_individual_pitches = TRUE, show_expected_diamonds = TRUE, selected_pitch_types = NULL) { if (is.null(data)) { return(ggplot() + theme_void() + annotate("text", x = 0, y = 0, label = "No data available")) } pitcher_data <- data %>% filter(Pitcher == pitcher_name, !is.na(HorzBreak), !is.na(InducedVertBreak), !is.na(TaggedPitchType), TaggedPitchType != "Other", !is.na(RelSpeed)) # Filter by selected pitch types if provided if (!is.null(selected_pitch_types) && length(selected_pitch_types) > 0) { pitcher_data <- pitcher_data %>% filter(TaggedPitchType %in% selected_pitch_types) } if (nrow(pitcher_data) < 5) { return(ggplot() + theme_void() + annotate("text", x = 0, y = 0, label = "Insufficient data")) } # Get pitcher hand pitcher_throws <- pitcher_data$PitcherThrows[1] if (is.na(pitcher_throws)) pitcher_throws <- "Right" is_lefty <- pitcher_throws == "Left" # Get height and calculate shoulder position h_in <- if ("height_inches" %in% names(pitcher_data)) { med_h <- median(pitcher_data$height_inches, na.rm = TRUE) if (is.na(med_h)) 72 else med_h } else 72 shoulder_pos <- h_in * 0.70 # Calculate averages by pitch type p_mean <- pitcher_data %>% group_by(TaggedPitchType) %>% summarize( HorzBreak = mean(HorzBreak, na.rm = TRUE), InducedVertBreak = mean(InducedVertBreak, na.rm = TRUE), mean_velo = round(mean(RelSpeed, na.rm = TRUE), 1), mean_rel_height = mean(RelHeight, na.rm = TRUE), mean_rel_side = mean(RelSide, na.rm = TRUE), mean_arm_angle = mean(arm_angle_savant, na.rm = TRUE), usage = n(), .groups = "drop" ) %>% mutate( usage_pct = round(usage / sum(usage) * 100, 1), scaled_usage = (usage - min(usage)) / max((max(usage) - min(usage)), 1) * (12 - 8) + 8, RH_bin = round(mean_rel_height / 0.10) * 0.10 ) %>% filter(usage >= 3) # Add expected movement from grid if available p_mean$exp_hb <- NA_real_ p_mean$exp_ivb <- NA_real_ p_mean$has_expected <- FALSE if (!is.null(expected_grid) && nrow(expected_grid) > 0) { for (i in seq_len(nrow(p_mean))) { pt <- p_mean$TaggedPitchType[i] rh <- p_mean$RH_bin[i] match_row <- expected_grid %>% filter(TaggedPitchType == pt, abs(RH_bin - rh) < 0.15) if (nrow(match_row) > 0) { match_row <- match_row %>% mutate(dist = abs(RH_bin - rh)) %>% arrange(dist) %>% slice(1) p_mean$exp_hb[i] <- if (is_lefty) -match_row$exp_hb[1] else match_row$exp_hb[1] p_mean$exp_ivb[i] <- match_row$exp_ivb[1] p_mean$has_expected[i] <- TRUE } } } # Get arm angle data arm_angle_savant <- median(pitcher_data$arm_angle_savant, na.rm = TRUE) if (is.na(arm_angle_savant)) arm_angle_savant <- 45 # Get arm angle category arm_angle_type <- if (arm_angle_savant >= 60) "Overhand" else if (arm_angle_savant >= 47) "High 3/4" else if (arm_angle_savant >= 40) "3/4" else if (arm_angle_savant >= 10) "Low 3/4" else "Sidearm" # ===== ARM ANGLE RAYS CALCULATION - FROM CENTER (0,0) OUTWARD ===== # Rays start at origin (0,0) and extend outward based on release position # PITCHER'S PERSPECTIVE: RHP rays on RIGHT side, LHP rays on LEFT side # (opposite of batter's view arm angle visualization) if (show_arm_angle_rays && nrow(p_mean) > 0) { ray_length <- 18 # Extended ray length in chart units (inches) arm_rays <- p_mean %>% mutate( # Calculate direction from origin to release point # PITCHER'S PERSPECTIVE (flipped from batter's view): # For RHP: release is on pitcher's right (POSITIVE x) # For LHP: release is on pitcher's left (NEGATIVE x) rel_x_raw = ifelse(is_lefty, -abs(mean_rel_side), abs(mean_rel_side)), rel_y_raw = mean_rel_height - (shoulder_pos / 12), # Height relative to shoulder # Normalize to unit vector rel_mag = sqrt(rel_x_raw^2 + rel_y_raw^2), rel_mag = ifelse(rel_mag == 0 | is.na(rel_mag), 1, rel_mag), # Direction components (normalized) dir_x = rel_x_raw / rel_mag, dir_y = rel_y_raw / rel_mag, # Ray start at origin (0,0) - center of chart ray_xs = 0, ray_ys = 0, # Ray end - extend outward from origin ray_xe = dir_x * ray_length, ray_ye = dir_y * ray_length ) } # Create circle background - 32 inch radius circle_df <- data.frame( x = 32 * cos(seq(0, 2*pi, length.out = 100)), y = 32 * sin(seq(0, 2*pi, length.out = 100)) ) # Build base plot p <- ggplot(pitcher_data, aes(x = HorzBreak, y = InducedVertBreak)) + # Light teal background circle geom_polygon(data = circle_df, aes(x = x, y = y), fill = "#e5f3f3", color = "#e5f3f3", inherit.aes = FALSE) + # Grid circles - 12", 24", 32" geom_path(data = data.frame(x = 12 * cos(seq(0, 2*pi, length.out = 100)), y = 12 * sin(seq(0, 2*pi, length.out = 100))), aes(x = x, y = y), linetype = "dashed", color = "gray60", linewidth = 0.3, inherit.aes = FALSE) + geom_path(data = data.frame(x = 24 * cos(seq(0, 2*pi, length.out = 100)), y = 24 * sin(seq(0, 2*pi, length.out = 100))), aes(x = x, y = y), linetype = "dashed", color = "gray60", linewidth = 0.3, inherit.aes = FALSE) + geom_path(data = data.frame(x = 32 * cos(seq(0, 2*pi, length.out = 100)), y = 32 * sin(seq(0, 2*pi, length.out = 100))), aes(x = x, y = y), linetype = "solid", color = "gray50", linewidth = 0.5, inherit.aes = FALSE) + # Axis lines geom_segment(x = 0, y = -34, xend = 0, yend = 34, linewidth = 0.5, color = "grey55") + geom_segment(x = -34, y = 0, xend = 34, yend = 0, linewidth = 0.5, color = "grey55") + # ALL CLOCK LABELS around the circle annotate('text', x = 0, y = 36, label = '12', size = 4.5, fontface = "bold") + annotate('text', x = 18, y = 31, label = '1', size = 4, fontface = "bold") + annotate('text', x = 31, y = 18, label = '2', size = 4, fontface = "bold") + annotate('text', x = 36, y = 0, label = '3', size = 4.5, fontface = "bold") + annotate('text', x = 31, y = -18, label = '4', size = 4, fontface = "bold") + annotate('text', x = 18, y = -31, label = '5', size = 4, fontface = "bold") + annotate('text', x = 0, y = -36, label = '6', size = 4.5, fontface = "bold") + annotate('text', x = -18, y = -31, label = '7', size = 4, fontface = "bold") + annotate('text', x = -31, y = -18, label = '8', size = 4, fontface = "bold") + annotate('text', x = -36, y = 0, label = '9', size = 4.5, fontface = "bold") + annotate('text', x = -31, y = 18, label = '10', size = 4, fontface = "bold") + annotate('text', x = -18, y = 31, label = '11', size = 4, fontface = "bold") + # Break distance annotations annotate('text', x = 12, y = -2, label = '12"', size = 3, color = "gray40") + annotate('text', x = 24, y = -2, label = '24"', size = 3, color = "gray40") + annotate('text', y = 12, x = -2, label = '12"', size = 3, color = "gray40") + annotate('text', y = 24, x = -2, label = '24"', size = 3, color = "gray40") # ===== ADD ARM ANGLE RAYS FROM CENTER (0,0) ===== if (show_arm_angle_rays && exists("arm_rays") && nrow(arm_rays) > 0) { # Add arm angle rays colored by pitch type - emanating from center p <- p + geom_segment( data = arm_rays, aes(x = ray_xs, y = ray_ys, xend = ray_xe, yend = ray_ye, color = TaggedPitchType), linewidth = 2, alpha = 0.85, inherit.aes = FALSE ) + # Small dots at the end of rays geom_point( data = arm_rays, aes(x = ray_xe, y = ray_ye, fill = TaggedPitchType), shape = 21, size = 3, stroke = 0.5, color = "black", alpha = 0.9, inherit.aes = FALSE ) } # ===== ADD INDIVIDUAL PITCH POINTS ===== if (show_individual_pitches) { p <- p + geom_point(aes(fill = TaggedPitchType), shape = 21, size = 3.5, color = "black", stroke = 0.3, alpha = 1) } # ===== ADD EXPECTED MOVEMENT DIAMONDS ===== if (show_expected_diamonds) { exp_data <- p_mean %>% filter(has_expected == TRUE) if (nrow(exp_data) > 0) { p <- p + geom_point(data = exp_data, aes(x = exp_hb, y = exp_ivb, fill = TaggedPitchType), shape = 23, size = 7, stroke = 2, color = "black", alpha = 0.8, inherit.aes = FALSE) } } # ===== ADD MARKERS BASED ON STYLE ===== if (marker_style == "scaled_usage") { # Scaled usage markers with velocity labels p <- p + geom_point(data = p_mean, aes(x = HorzBreak, y = InducedVertBreak, fill = TaggedPitchType, size = scaled_usage), shape = 21, color = "black", stroke = 2, alpha = 0.9, inherit.aes = FALSE) + geom_label(data = p_mean, aes(x = HorzBreak, y = InducedVertBreak, label = mean_velo), color = "black", size = 3, fontface = "bold", fill = "white", label.size = 0.3, inherit.aes = FALSE) + scale_size_identity() } else if (marker_style == "average") { # Fixed size average markers with velocity labels p <- p + geom_point(data = p_mean, aes(x = HorzBreak, y = InducedVertBreak, fill = TaggedPitchType), shape = 21, size = 10, color = "black", stroke = 2, alpha = 0.9, inherit.aes = FALSE) + geom_label(data = p_mean, aes(x = HorzBreak, y = InducedVertBreak, label = mean_velo), color = "black", size = 3, fontface = "bold", fill = "white", label.size = 0.3, inherit.aes = FALSE) } # If marker_style == "none", no markers are added # ===== FINISH PLOT ===== p <- p + coord_fixed(xlim = c(-40, 40), ylim = c(-40, 40), clip = "off") + scale_fill_manual(values = pitch_colors, drop = FALSE) + scale_color_manual(values = pitch_colors, drop = FALSE) + labs(title = "Pitch Movement", x = "", y = "") + theme_minimal() + theme( plot.background = element_rect(fill = "white", color = NA), panel.background = element_blank(), axis.text = element_blank(), axis.ticks = element_blank(), axis.title = element_blank(), plot.title = element_text(hjust = 0.5, size = 16, face = "bold"), legend.position = "none", panel.grid = element_blank(), plot.margin = margin(20, 5, 5, 5) ) # ===== ADD ARM ANGLE ANNOTATION AT TOP ===== if (show_arm_angle_annotation) { p <- p + annotate("text", x = 0, y = 43, label = paste0(round(arm_angle_savant, 0), "° Arm Angle - ", arm_angle_type), size = 5, color = "#731209", fontface = "bold") } return(p) } # 4. COUNT USAGE PIE CHARTS create_count_usage_pies <- function(data, pitcher_name) { if (is.null(data)) { return(ggplot() + theme_void() + annotate("text", x = 0.5, y = 0.5, label = "No data")) } df <- data %>% filter(Pitcher == pitcher_name, TaggedPitchType != "Other") %>% group_by(TaggedPitchType) %>% mutate(n = 100 * n() / nrow(data %>% filter(Pitcher == pitcher_name))) %>% filter(n >= 2) %>% ungroup() if (nrow(df) == 0) { return( ggplot() + annotate("text", x = 0, y = 0, label = "No count data\navailable for this pitcher", size = 4) + theme_void() ) } plot_df <- df %>% mutate( count = case_when( Balls == 0 & Strikes == 0 ~ "1P", Strikes == 2 ~ "2K", Balls == 3 & Strikes == 2 ~ "Full", Balls > Strikes ~ "Behind", TRUE ~ "Even" ), BatterSide = ifelse(BatterSide == "Right", "Vs Right", "Vs Left") ) %>% filter(count %in% c("1P", "Even", "Behind", "2K", "Full")) %>% group_by(count, BatterSide) %>% mutate(total_count = n()) %>% group_by(count, TaggedPitchType, BatterSide) %>% summarise(n = n(), total = first(total_count), .groups = "drop") %>% mutate( percentage = n / total * 100, pct_label = ifelse(percentage >= 5, scales::percent(percentage / 100, accuracy = 1), "") ) %>% arrange(desc(percentage)) # Set factor order for counts plot_df$count <- factor(plot_df$count, levels = c("1P", "Even", "Behind", "2K", "Full")) p <- ggplot(plot_df, aes(x = "", y = percentage, fill = TaggedPitchType)) + geom_bar(width = 1, stat = "identity", color = "white", linewidth = 0.3) + geom_text(aes(label = pct_label), position = position_stack(vjust = 0.5), size = 2.5, fontface = "bold") + coord_polar("y", start = 0) + facet_grid(BatterSide ~ count, labeller = label_value) + scale_fill_manual(values = pitch_colors, drop = FALSE) + labs(title = ("Pitch Usage by Count"), fill = "Pitch Type") + theme_void() + theme( plot.title = element_text(hjust = 0.5, size = 11, face = "bold"), strip.text = element_text(size = 9, face = "bold"), legend.position = "none", # Remove legend to give more room for notes plot.background = element_rect(fill = "white", color = NA), panel.background = element_rect(fill = "white", color = NA) ) return(p) } # 5. PITCH CHARACTERISTICS GT TABLE WITH SEC BENCHMARKING create_pitch_characteristics_gt <- function(data, pitcher_name) { if (is.null(data)) { return(gt::gt(data.frame(Message = "No data available"))) } pitcher_data <- data %>% filter(Pitcher == pitcher_name, !is.na(TaggedPitchType), TaggedPitchType != "Other") if (nrow(pitcher_data) < 10) { return(gt::gt(data.frame(Message = "Insufficient data"))) } # Get pitcher hand for velo benchmarks pitcher_hand <- if (nrow(pitcher_data) > 0 && "PitcherThrows" %in% names(pitcher_data)) { h <- pitcher_data$PitcherThrows[1] if (is.na(h)) "Right" else h } else "Right" total_pitches <- nrow(pitcher_data) # Check which columns exist has_stuff_plus <- "stuff_plus" %in% names(pitcher_data) has_vaa <- "VertApprAngle" %in% names(pitcher_data) has_haa <- "HorzApprAngle" %in% names(pitcher_data) pitch_stats <- pitcher_data %>% group_by(Pitch = TaggedPitchType) %>% summarise( Count = n(), `Usage%` = round(100 * n() / total_pitches, 1), `Avg Velo` = round(mean(RelSpeed, na.rm = TRUE), 1), `Max Velo` = round(max(RelSpeed, na.rm = TRUE), 1), `Avg Spin` = round(mean(SpinRate, na.rm = TRUE), 0), `Avg IVB` = round(mean(InducedVertBreak, na.rm = TRUE), 1), `Avg HB` = round(mean(HorzBreak, na.rm = TRUE), 1), VAA = if (has_vaa) round(mean(VertApprAngle, na.rm = TRUE), 1) else NA_real_, HAA = if (has_haa) round(mean(HorzApprAngle, na.rm = TRUE), 1) else NA_real_, hRel = round(mean(RelSide, na.rm = TRUE), 2), vRel = round(mean(RelHeight, na.rm = TRUE), 2), Ext = round(mean(Extension, na.rm = TRUE), 2), # Calculate Strike% directly from PitchCall `Strike%` = round(100 * mean(!PitchCall %in% c("BallCalled", "BallinDirt", "BallIntentional"), na.rm = TRUE), 1), # Calculate Whiff% directly from PitchCall n_swings = sum(PitchCall %in% c("StrikeSwinging", "FoulBall", "FoulBallNotFieldable", "FoulBallFieldable", "InPlay"), na.rm = TRUE), n_whiffs = sum(PitchCall == "StrikeSwinging", na.rm = TRUE), `Whiff%` = ifelse(n_swings > 0, round(100 * n_whiffs / n_swings, 1), 0), # Calculate Zone% directly from PlateLocSide/Height `Zone%` = round(100 * mean(PlateLocSide >= -0.83 & PlateLocSide <= 0.83 & PlateLocHeight >= 1.5 & PlateLocHeight <= 3.5, na.rm = TRUE), 1), `Stuff+` = if (has_stuff_plus) round(mean(stuff_plus, na.rm = TRUE), 0) else NA_real_, .groups = "drop" ) %>% select(-n_swings, -n_whiffs) %>% filter(Count >= 5) %>% arrange(desc(`Usage%`)) num_rows <- nrow(pitch_stats) if (num_rows == 0) { return(gt::gt(data.frame(Message = "No pitch data found"))) } # Build GT table gt_tbl <- pitch_stats %>% gt::gt() %>% gt::tab_header(title = gt::md("**Pitch Characteristics (vs SEC Benchmarks)**")) %>% gt::cols_align(align = "center", columns = everything()) %>% gt::cols_align(align = "left", columns = "Pitch") %>% gt::sub_missing(missing_text = "-") %>% gt::tab_options( table.font.size = gt::px(11), data_row.padding = gt::px(4), column_labels.font.weight = "bold", heading.title.font.size = gt::px(14) ) # Apply SEC benchmark coloring row by row for (i in seq_len(num_rows)) { pt <- pitch_stats$Pitch[i] # Get SEC reference for this pitch type sec_ref <- if (pt %in% c("Fastball", "Four-Seam", "FourSeamFastBall", "Sinker", "Two-Seam", "TwoSeamFastBall")) { sec_averages$fb_sinker } else if (pt == "Cutter") { sec_averages$cutter } else if (pt %in% c("Slider", "Sweeper")) { sec_averages$slider } else if (pt %in% c("Curveball", "Knuckle Curve")) { sec_averages$curveball } else if (pt %in% c("ChangeUp", "Splitter")) { sec_averages$changeup } else NULL if (!is.null(sec_ref)) { # Velocity benchmark velo_bench <- NA_real_ if (pt %in% c("Fastball", "Four-Seam", "FourSeamFastBall", "Sinker", "Two-Seam", "TwoSeamFastBall")) { velo_bench <- if (pitcher_hand == "Left") sec_averages$overall$fb_velo_l else sec_averages$overall$fb_velo_r } else { vb_l <- sec_ref$velo_l vb_r <- sec_ref$velo_r if (!is.null(vb_l) && !is.null(vb_r)) { velo_bench <- if (pitcher_hand == "Left") vb_l else vb_r } } # Apply colors if (!is.na(velo_bench)) { avg_velo_color <- get_gradient_color(pitch_stats$`Avg Velo`[i], velo_bench, "higher_better", 0.05) max_velo_color <- get_gradient_color(pitch_stats$`Max Velo`[i], velo_bench, "higher_better", 0.05) gt_tbl <- gt_tbl %>% gt::tab_style(style = list(gt::cell_fill(color = avg_velo_color)), locations = gt::cells_body(columns = "Avg Velo", rows = i)) %>% gt::tab_style(style = list(gt::cell_fill(color = max_velo_color)), locations = gt::cells_body(columns = "Max Velo", rows = i)) } # Spin if (!is.null(sec_ref$spin)) { spin_color <- get_gradient_color(pitch_stats$`Avg Spin`[i], sec_ref$spin, "higher_better", 0.20) gt_tbl <- gt_tbl %>% gt::tab_style(style = list(gt::cell_fill(color = spin_color)), locations = gt::cells_body(columns = "Avg Spin", rows = i)) } # Strike% if (!is.null(sec_ref$strike)) { strike_color <- get_gradient_color(pitch_stats$`Strike%`[i], sec_ref$strike, "higher_better", 0.15) gt_tbl <- gt_tbl %>% gt::tab_style(style = list(gt::cell_fill(color = strike_color)), locations = gt::cells_body(columns = "Strike%", rows = i)) } # Whiff% if (!is.null(sec_ref$whiff)) { whiff_color <- get_gradient_color(pitch_stats$`Whiff%`[i], sec_ref$whiff, "higher_better", 0.30) gt_tbl <- gt_tbl %>% gt::tab_style(style = list(gt::cell_fill(color = whiff_color)), locations = gt::cells_body(columns = "Whiff%", rows = i)) } # Zone% if (!is.null(sec_ref$zone)) { zone_color <- get_gradient_color(pitch_stats$`Zone%`[i], sec_ref$zone, "higher_better", 0.20) gt_tbl <- gt_tbl %>% gt::tab_style(style = list(gt::cell_fill(color = zone_color)), locations = gt::cells_body(columns = "Zone%", rows = i)) } } # Extension benchmark ext_bench <- sec_extension_benchmark(pt) if (!is.na(ext_bench)) { ext_color <- get_gradient_color(pitch_stats$Ext[i], ext_bench, "higher_better", 0.08) gt_tbl <- gt_tbl %>% gt::tab_style(style = list(gt::cell_fill(color = ext_color)), locations = gt::cells_body(columns = "Ext", rows = i)) } # Stuff+ (benchmark = 100) if ("Stuff+" %in% names(pitch_stats)) { stuff_val <- pitch_stats$`Stuff+`[i] if (!is.na(stuff_val) && is.finite(stuff_val)) { stuff_color <- get_gradient_color(stuff_val, 100, "higher_better", 0.20) gt_tbl <- gt_tbl %>% gt::tab_style(style = list(gt::cell_fill(color = stuff_color)), locations = gt::cells_body(columns = "Stuff+", rows = i)) } } # Color pitch name column pitch_col <- if (pt %in% names(pitch_colors)) pitch_colors[[pt]] else "#A9A9A9" gt_tbl <- gt_tbl %>% gt::tab_style( style = list(gt::cell_fill(color = pitch_col), gt::cell_text(weight = "bold")), locations = gt::cells_body(columns = "Pitch", rows = i) ) } return(gt_tbl) } # ---- 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("FoulBall", "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 %in% c("Out", "FieldersChoice", "Error") ~ "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 ) } # 6. LOCATION HEATMAPS BY RESULT create_location_by_result_faceted <- function(data, pitcher_name, pitch_group, batter_side) { if (is.null(data)) { return(ggplot() + theme_void()) } # Fastballs: 4-seam, 2-seam, sinker fb_pitches <- c("Fastball", "Sinker", "FourSeamFastBall", "TwoSeamFastBall", "Four-Seam", "Two-Seam") # Offspeed/Breaking: slider, cutter, curveball, sweeper, changeup, splitter os_pitches <- c("Slider", "Cutter", "Curveball", "Sweeper", "Slurve", "ChangeUp", "Changeup", "Splitter", "Knuckle Curve") selected_pitches <- switch(pitch_group, "Fastballs" = fb_pitches, "FB" = fb_pitches, "Offspeed" = os_pitches, "OS" = os_pitches, "Breaking" = os_pitches, fb_pitches) df <- data %>% filter(Pitcher == pitcher_name, TaggedPitchType %in% selected_pitches, BatterSide == batter_side, !is.na(PlateLocSide), !is.na(PlateLocHeight)) if (nrow(df) < 10) { return(ggplot() + theme_void() + annotate("text", x = 0, y = 2.5, label = "Low n", size = 3)) } df <- df %>% mutate( is_whiff = PitchCall == "StrikeSwinging", is_ball = PitchCall %in% c("BallCalled", "BallinDirt"), is_strike = PitchCall == "StrikeCalled", is_hard_hit = !is.na(ExitSpeed) & ExitSpeed >= 95, is_2strike = Strikes == 2 ) result_types <- c("Whiffs", "Balls Called", "Strikes Called", "Hard Hits (95+)", "2 Strikes") plot_data <- bind_rows( df %>% filter(is_whiff) %>% mutate(result_type = "Whiffs"), df %>% filter(is_ball) %>% mutate(result_type = "Balls Called"), df %>% filter(is_strike) %>% mutate(result_type = "Strikes Called"), df %>% filter(is_hard_hit) %>% mutate(result_type = "Hard Hits (95+)"), df %>% filter(is_2strike) %>% mutate(result_type = "2 Strikes") ) %>% mutate(result_type = factor(result_type, levels = result_types)) if (nrow(plot_data) < 5) { return(ggplot() + theme_void() + annotate("text", x = 0, y = 2.5, label = "Low n", size = 3)) } sz_data <- data.frame( xmin = -0.83, xmax = 0.83, ymin = 1.5, ymax = 3.5 ) p <- ggplot(plot_data, aes(x = PlateLocSide, y = PlateLocHeight)) + stat_density_2d(aes(fill = after_stat(density)), geom = "raster", contour = FALSE, h = c(0.4, 0.4), n = 50) + scale_fill_gradient(low = "white", high = "#D73027", na.value = "white") + geom_rect(data = sz_data, aes(xmin = xmin, xmax = xmax, ymin = ymin, ymax = ymax), inherit.aes = FALSE, fill = NA, color = "black", linewidth = 0.8) + facet_wrap(~result_type, nrow = 1) + coord_fixed(xlim = c(-1.5, 1.5), ylim = c(0.5, 4.5)) + theme_minimal() + theme( legend.position = "none", strip.text = element_text(size = 8, face = "bold"), axis.text = element_blank(), axis.title = element_blank(), panel.grid = element_blank(), plot.background = element_rect(fill = "white", color = NA) ) return(p) } # ============================================================================ # COMPREHENSIVE PITCHER PDF REPORT FUNCTIONS # ============================================================================ # SEC Benchmarks for coloring sec_benchmarks <- list( era = 4.75, fip = 4.22, whip = 1.37, k_pct = 26, bb_pct = 10, ba_against = 0.244, slg_against = 0.396, fb_strike = 64, bb_strike = 60, os_strike = 59, zone_pct = 45.9, whiff_pct = 28.9, strike_pct = 62.6 ) # Color function for stats (green = good, red = bad) get_stat_color <- function(value, benchmark, higher_is_better = TRUE, range_pct = 0.25) { if (is.na(value) || is.na(benchmark)) return("#FFFFFF") if (is.nan(value) || is.infinite(value)) return("#FFFFFF") pal <- scales::gradient_n_pal(c("#E1463E", "white", "#00840D")) range_val <- benchmark * range_pct min_val <- benchmark - range_val max_val <- benchmark + range_val if (higher_is_better) { normalized <- (value - min_val) / (max_val - min_val) } else { normalized <- (max_val - value) / (max_val - min_val) } normalized <- pmax(0, pmin(1, normalized)) pal(normalized) } # Process pitcher indicators for advanced stats process_pitcher_indicators <- function(df) { df %>% mutate( OutsOnPlay = case_when( PlayResult == "Out" ~ 1, PlayResult == "FieldersChoice" ~ 1, PlayResult == "Sacrifice" ~ 1, PlayResult == "SacrificeFly" ~ 1, KorBB == "Strikeout" ~ 1, grepl("DoublePlay", PlayResult, ignore.case = TRUE) ~ 2, TRUE ~ 0 ), # Note: is_hit and is_ab already exist in the dataset total_bases = case_when( PlayResult == "Single" ~ 1, PlayResult == "Double" ~ 2, PlayResult == "Triple" ~ 3, PlayResult == "HomeRun" ~ 4, TRUE ~ 0 ), is_k = as.integer(KorBB == "Strikeout"), is_walk = as.integer(KorBB == "Walk"), is_hbp = as.integer(PitchCall == "HitByPitch"), is_put_away = as.integer(KorBB == "Strikeout" & Strikes == 2), in_zone = as.integer(PlateLocSide >= -0.83 & PlateLocSide <= 0.83 & PlateLocHeight >= 1.5 & PlateLocHeight <= 3.5), is_swing = as.integer(PitchCall %in% c("StrikeSwinging", "FoulBall", "FoulBallNotFieldable", "FoulBallFieldable", "InPlay")), is_whiff = as.integer(PitchCall == "StrikeSwinging"), is_strike = as.integer(!PitchCall %in% c("BallCalled", "BallinDirt", "BallIntentional")), pitch_family = case_when( TaggedPitchType %in% c("Fastball", "Four-Seam", "FourSeamFastBall", "Sinker", "TwoSeamFastBall", "Two-Seam") ~ "FB", TaggedPitchType %in% c("Slider", "Curveball", "Cutter", "Sweeper", "Slurve", "Knuckle Curve") ~ "BB", TaggedPitchType %in% c("ChangeUp", "Changeup", "Splitter") ~ "OS", TRUE ~ "Other" ) ) } # Calculate comprehensive pitcher stats calculate_advanced_pitcher_stats <- function(data, pitcher_name) { df <- data %>% filter(Pitcher == pitcher_name) %>% process_pitcher_indicators() if (nrow(df) < 20) return(NULL) # Get PA-level data pa_data <- df %>% filter(!is.na(KorBB) | PlayResult %in% c("Single", "Double", "Triple", "HomeRun", "Out", "FieldersChoice", "Error") | PitchCall == "HitByPitch") %>% group_by(GameID, Inning, PAofInning) %>% slice_tail(n = 1) %>% ungroup() # Basic counting stats - FIXED IP calculation total_outs <- sum(df$OutsOnPlay, na.rm = TRUE) full_innings <- floor(total_outs / 3) partial_outs <- total_outs %% 3 # IP format: X.0, X.1, or X.2 only (not X.3+) ip_display <- full_innings + partial_outs / 10 # Actual innings for rate calculations ip_actual <- total_outs / 3 n_games <- n_distinct(df$GameID) n_pitches <- nrow(df) # Most recent outing recent_game <- df %>% filter(GameID == max(GameID, na.rm = TRUE)) recent_pitches <- nrow(recent_game) # Get last 3 outings with dates and pitch counts game_summary <- df %>% group_by(GameID, Date) %>% summarise(pitches = n(), .groups = "drop") %>% arrange(desc(Date)) %>% head(3) last_3_outings <- if (nrow(game_summary) > 0) { paste(sapply(1:nrow(game_summary), function(i) { date_val <- game_summary$Date[i] date_str <- tryCatch({ if (inherits(date_val, "Date") || inherits(date_val, "POSIXt")) { format(date_val, "%m/%d") } else if (is.character(date_val) || is.numeric(date_val)) { format(as.Date(date_val), "%m/%d") } else { "?" } }, error = function(e) "?") paste0(game_summary$pitches[i], " (", date_str, ")") }), collapse = ", ") } else { "-" } # PA-based stats n_pa <- nrow(pa_data) n_k <- sum(pa_data$is_k, na.rm = TRUE) n_bb <- sum(pa_data$is_walk, na.rm = TRUE) n_hbp <- sum(pa_data$is_hbp, na.rm = TRUE) n_hr <- sum(pa_data$PlayResult == "HomeRun", na.rm = TRUE) # Runs (from RunsScored column if available) n_runs <- if ("RunsScored" %in% names(df)) sum(df$RunsScored, na.rm = TRUE) else n_hr # Split stats - calculate BA and SLG from PlayResult vs_lhh <- pa_data %>% filter(BatterSide == "Left") vs_rhh <- pa_data %>% filter(BatterSide == "Right") # Calculate hits and ABs from PlayResult (don't rely on pre-existing columns) calculate_ba_slg <- function(pa_df) { if (nrow(pa_df) == 0) return(list(ba = NA, slg = NA)) # Hits: Single, Double, Triple, HomeRun hits <- sum(pa_df$PlayResult %in% c("Single", "Double", "Triple", "HomeRun"), na.rm = TRUE) # At-bats: exclude walks and HBP ab <- sum(pa_df$PlayResult %in% c("Single", "Double", "Triple", "HomeRun", "Out", "FieldersChoice", "Error") | pa_df$KorBB == "Strikeout", na.rm = TRUE) # Total bases tb <- sum(pa_df$PlayResult == "Single", na.rm = TRUE) + 2 * sum(pa_df$PlayResult == "Double", na.rm = TRUE) + 3 * sum(pa_df$PlayResult == "Triple", na.rm = TRUE) + 4 * sum(pa_df$PlayResult == "HomeRun", na.rm = TRUE) ba <- if (ab > 0) hits / ab else NA slg <- if (ab > 0) tb / ab else NA list(ba = ba, slg = slg, hits = hits, ab = ab, tb = tb) } lhh_stats <- calculate_ba_slg(vs_lhh) rhh_stats <- calculate_ba_slg(vs_rhh) ba_vs_l <- lhh_stats$ba ba_vs_r <- rhh_stats$ba slg_vs_l <- lhh_stats$slg slg_vs_r <- rhh_stats$slg # Total hits for WHIP all_stats <- calculate_ba_slg(pa_data) n_hits <- all_stats$hits # Pitch-level stats n_swings <- sum(df$is_swing, na.rm = TRUE) n_whiffs <- sum(df$is_whiff, na.rm = TRUE) n_strikes <- sum(df$is_strike, na.rm = TRUE) n_in_zone <- sum(df$in_zone, na.rm = TRUE) # By pitch family fb_data <- df %>% filter(pitch_family == "FB") bb_data <- df %>% filter(pitch_family == "BB") os_data <- df %>% filter(pitch_family == "OS") fb_strike_pct <- if (nrow(fb_data) > 0) 100 * sum(fb_data$is_strike, na.rm = TRUE) / nrow(fb_data) else NA bb_strike_pct <- if (nrow(bb_data) > 0) 100 * sum(bb_data$is_strike, na.rm = TRUE) / nrow(bb_data) else NA os_strike_pct <- if (nrow(os_data) > 0) 100 * sum(os_data$is_strike, na.rm = TRUE) / nrow(os_data) else NA # Calculate rates k_pct <- if (n_pa > 0) 100 * n_k / n_pa else 0 bb_pct <- if (n_pa > 0) 100 * n_bb / n_pa else 0 whiff_pct <- if (n_swings > 0) 100 * n_whiffs / n_swings else 0 zone_pct <- if (n_pitches > 0) 100 * n_in_zone / n_pitches else 0 strike_pct <- if (n_pitches > 0) 100 * n_strikes / n_pitches else 0 # RA9 (not ERA - can't determine earned runs from TrackMan), WHIP, FIP ra9 <- if (ip_actual > 0) 9 * n_runs / ip_actual else NA whip <- if (ip_actual > 0) (n_bb + n_hits) / ip_actual else NA # FIP with correct constant (4.08) and formula fip_constant <- 4.08 fip <- if (ip_actual > 0) ((13 * n_hr + 3 * (n_bb + n_hbp) - 2 * n_k) / ip_actual) + fip_constant else NA # Avg IP per outing (using display format) avg_outs_per_game <- if (n_games > 0) total_outs / n_games else 0 avg_ip_full <- floor(avg_outs_per_game / 3) avg_ip_partial <- round(avg_outs_per_game %% 3) / 10 avg_ip_display <- avg_ip_full + avg_ip_partial avg_pitches <- if (n_games > 0) n_pitches / n_games else 0 list( ip = ip_display, n_games = n_games, avg_ip = avg_ip_display, avg_pitches = round(avg_pitches, 0), recent_pitches = recent_pitches, last_3_outings = last_3_outings, ra9 = round(ra9, 2), whip = round(whip, 2), fip = round(fip, 2), k_pct = round(k_pct, 1), bb_pct = round(bb_pct, 1), ba_vs_l = round(ba_vs_l, 3), ba_vs_r = round(ba_vs_r, 3), slg_vs_l = round(slg_vs_l, 3), slg_vs_r = round(slg_vs_r, 3), whiff_pct = round(whiff_pct, 1), zone_pct = round(zone_pct, 1), strike_pct = round(strike_pct, 1), fb_strike_pct = round(fb_strike_pct, 1), bb_strike_pct = round(bb_strike_pct, 1), os_strike_pct = round(os_strike_pct, 1), pitcher_hand = df$PitcherThrows[1] ) } # Create sequencing matrix create_sequencing_matrix <- function(data, pitcher_name, batter_side = NULL) { df <- data %>% filter(Pitcher == pitcher_name, !is.na(TaggedPitchType), TaggedPitchType != "Other") if (!is.null(batter_side)) { df <- df %>% filter(BatterSide == batter_side) } if (nrow(df) < 50) { return(ggplot() + theme_void() + annotate("text", x = 0.5, y = 0.5, label = "Low n")) } # Create previous pitch column df <- df %>% arrange(GameID, Inning, PAofInning, PitchofPA) %>% group_by(GameID, Inning, PAofInning) %>% mutate(prev_pitch = lag(TaggedPitchType)) %>% ungroup() %>% filter(!is.na(prev_pitch)) # Calculate transition matrix trans_matrix <- df %>% count(prev_pitch, TaggedPitchType) %>% group_by(prev_pitch) %>% mutate(pct = round(100 * n / sum(n), 0)) %>% ungroup() title_suffix <- if (!is.null(batter_side)) paste0(" vs ", batter_side, "HH") else "" ggplot(trans_matrix, aes(x = TaggedPitchType, y = prev_pitch, fill = pct)) + geom_tile(color = "white") + geom_text(aes(label = paste0(pct, "%")), size = 2.5) + scale_fill_gradient(low = "white", high = "#2c7bb6", name = "%") + labs(title = paste0("Pitch Sequencing", title_suffix), x = "Current Pitch", y = "Previous Pitch") + theme_minimal() + theme( plot.title = element_text(size = 10, face = "bold", hjust = 0.5), axis.text.x = element_text(angle = 45, hjust = 1, size = 7), axis.text.y = element_text(size = 7), legend.position = "none" ) } # Create spray chart for hits against - SECTOR VERSION create_hits_spray_chart <- function(data, pitcher_name, batter_side) { df <- data %>% filter(Pitcher == pitcher_name, BatterSide == batter_side, PitchCall == "InPlay", !is.na(Bearing), !is.na(Distance)) %>% mutate( Bearing2 = Bearing * pi / 180, x = Distance * sin(Bearing2), y = Distance * cos(Bearing2) ) if (nrow(df) < 5) { return(ggplot() + theme_void() + annotate("text", x = 0, y = 200, label = paste0("Low n vs ", batter_side, "HH"), size = 3)) } # 5 angle sectors from -45 to +45 angle_breaks_deg <- seq(-45, 45, length.out = 6) angle_breaks_rad <- angle_breaks_deg * pi / 180 df <- df %>% 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_ ), dist_sector = case_when( Distance < 150 ~ 1L, Distance >= 150 ~ 2L, TRUE ~ NA_integer_ ) ) %>% filter(!is.na(angle_sector), !is.na(dist_sector)) if (nrow(df) == 0) { return(ggplot() + theme_void() + annotate("text", x = 0, y = 200, label = paste0("No BIP vs ", batter_side, "HH"), size = 3)) } total_bip <- nrow(df) # Sector stats sector_stats <- df %>% group_by(angle_sector, dist_sector) %>% summarise(count = n(), .groups = "drop") %>% mutate(pct = round(100 * count / total_bip)) # Create 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)) } dist_breaks <- c(0, 150, 400) sectors <- list() sector_id <- 1 for (i in 1:5) { for (j in 1:2) { 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) # 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") # Plot ggplot() + geom_polygon( data = sector_df, aes(x = x, y = y, group = sector_id, fill = pct), color = "black", linewidth = 0.3 ) + geom_text( data = sector_centers %>% filter(!is.na(pct) & pct > 0), aes(x = cx, y = cy, label = paste0(pct, "%")), size = 2.5, fontface = "bold" ) + # Foul lines annotate("segment", x = 0, y = 0, xend = 247.487, yend = 247.487, color = "black", linewidth = 0.5) + annotate("segment", x = 0, y = 0, xend = -247.487, yend = 247.487, color = "black", linewidth = 0.5) + annotate("text", x = 200, y = 350, label = paste("BIP:", total_bip), size = 2.5, hjust = 0, fontface = "bold") + scale_fill_gradientn( colors = c("white", "#e5fbe5", "#90EE90", "#66c266", "#228B22", "#145a14", "#006400"), values = scales::rescale(c(0, 3, 5, 10, 15, 20, 25)), limits = c(0, NA), na.value = "white", name = "% BIP" ) + coord_fixed(xlim = c(-280, 280), ylim = c(-20, 380)) + labs(title = paste0("vs ", batter_side, "HH")) + theme_void() + theme( legend.position = "none", plot.title = element_text(hjust = 0.5, size = 8, face = "bold") ) } create_pitch_group_heatmap <- function(data, pitcher_name, pitch_group, metric_type, batter_side = NULL) { # ---- pitch groups ---- fb_pitches <- c("Fastball", "Sinker", "FourSeamFastBall", "TwoSeamFastBall", "Four-Seam", "Two-Seam") bb_pitches <- c("Slider", "Curveball", "Cutter", "Sweeper", "Slurve", "Knuckle Curve") os_pitches <- c("ChangeUp", "Changeup", "Splitter") all_pitches <- c(fb_pitches, bb_pitches, os_pitches) selected_pitches <- switch( pitch_group, "FB" = fb_pitches, "BB" = bb_pitches, "OS" = os_pitches, "All" = all_pitches, fb_pitches ) df <- data %>% dplyr::filter( Pitcher == pitcher_name, TaggedPitchType %in% selected_pitches, !is.na(PlateLocSide), !is.na(PlateLocHeight) ) if (!is.null(batter_side) && batter_side != "All") { df <- df %>% dplyr::filter(BatterSide == batter_side) } # ---- filter by metric ---- if (metric_type %in% c("Whiff", "Whf")) { df <- df %>% dplyr::filter(PitchCall == "StrikeSwinging") } else if (metric_type %in% c("Damage", "Dmg")) { df <- df %>% dplyr::filter(!is.na(ExitSpeed), ExitSpeed >= 95) } # ---- base strike zone (ALWAYS drawn) ---- base_zone <- ggplot() + annotate("rect", xmin = -0.83, xmax = 0.83, ymin = 1.5, 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(xlim = c(-2, 2), ylim = c(0, 4.5)) + theme_void() + theme( legend.position = "none", plot.title = element_blank(), plot.margin = margin(2, 2, 2, 2) ) # ---- if NO pitches: blank zone only ---- if (nrow(df) == 0) return(base_zone) # ---- small N: show pitch locations (no density) ---- if (nrow(df) < 5) { return( base_zone + geom_point(data = df, aes(x = PlateLocSide, y = PlateLocHeight), alpha = 0.85, size = 0.9) ) } # ---- enough N: density + (optional) points ---- base_zone + stat_density_2d( data = df, aes(x = PlateLocSide, y = PlateLocHeight, fill = after_stat(density)), geom = "raster", contour = FALSE ) + scale_fill_gradientn(colours = c("white", "blue", "#FF9999", "red", "darkred"), guide = "none") + geom_point(data = df, aes(x = PlateLocSide, y = PlateLocHeight), alpha = 0.25, size = 0.6) } # Enhanced pitch characteristics with velocity range, putaway%, release point create_enhanced_pitch_characteristics <- function(data, pitcher_name) { df <- data %>% dplyr::filter(Pitcher == pitcher_name, !is.na(TaggedPitchType), TaggedPitchType != "Other") %>% process_pitcher_indicators() if (nrow(df) < 10) return(NULL) total_pitches <- nrow(df) pitcher_hand <- df$PitcherThrows[1] has_stuff_plus <- "stuff_plus" %in% names(df) has_vaa <- "VertApprAngle" %in% names(df) # wobacon with any case wobacon_col <- names(df)[tolower(names(df)) == "wobacon"] has_wobacon <- length(wobacon_col) > 0 if (has_wobacon) wobacon_col <- wobacon_col[1] # woba column (overall wOBA, not just on contact) woba_col <- names(df)[tolower(names(df)) == "woba"] has_woba <- length(woba_col) > 0 if (has_woba) woba_col <- woba_col[1] # safe helper for whiff% with denominator whiff_pct_safe <- function(swing, whiff) { ifelse(swing > 0, round(100 * whiff / swing, 1), 0) } pitch_stats <- df %>% dplyr::group_by(Pitch = TaggedPitchType) %>% dplyr::summarise( Count = dplyr::n(), `Usage%` = round(100 * dplyr::n() / total_pitches, 1), AvgVelo = round(mean(RelSpeed, na.rm = TRUE), 1), MaxVelo = round(max(RelSpeed, na.rm = TRUE), 1), # Velo range (10th-90th percentile) VeloRng = paste0(round(stats::quantile(RelSpeed, 0.10, na.rm = TRUE), 0), "-", round(stats::quantile(RelSpeed, 0.90, na.rm = TRUE), 0)), Spin = round(mean(SpinRate, na.rm = TRUE), 0), IVB = round(mean(InducedVertBreak, na.rm = TRUE), 1), HB = round(mean(HorzBreak, na.rm = TRUE), 1), VAA = if (has_vaa) round(mean(VertApprAngle, na.rm = TRUE), 2) else NA_real_, Ext = round(mean(Extension, na.rm = TRUE), 2), hRel = round(mean(RelSide, na.rm = TRUE), 2), vRel = round(mean(RelHeight, na.rm = TRUE), 2), `Strike%` = round(100 * sum(is_strike, na.rm = TRUE) / dplyr::n(), 1), `Whiff%` = whiff_pct_safe(sum(is_swing, na.rm = TRUE), sum(is_whiff, na.rm = TRUE)), `Zone%` = round(100 * sum(in_zone, na.rm = TRUE) / dplyr::n(), 1), `Putaway%` = ifelse(sum(Strikes == 2, na.rm = TRUE) > 0, round(100 * sum(is_put_away, na.rm = TRUE) / sum(Strikes == 2, na.rm = TRUE), 1), 0), # Whiff% split by batter side (uses swings as denominator within side) `Whiff% vL` = whiff_pct_safe( sum(is_swing[BatterSide == "Left"], na.rm = TRUE), sum(is_whiff[BatterSide == "Left"], na.rm = TRUE) ), `Whiff% vR` = whiff_pct_safe( sum(is_swing[BatterSide == "Right"], na.rm = TRUE), sum(is_whiff[BatterSide == "Right"], na.rm = TRUE) ), AvgEV = round(mean(ExitSpeed[PitchCall == "InPlay"], na.rm = TRUE), 1), # overall wOBAcon (in-play only) wOBAcon_val = if (has_wobacon) { round(mean(.data[[wobacon_col]][PitchCall == "InPlay"], na.rm = TRUE), 3) } else NA_real_, # wOBA overall and splits (all pitches, not just in-play) wOBA_val = if("woba" %in% names(.data)) round(mean(woba, na.rm = TRUE), 3) else NA_real_, wOBA_vL_val = if (has_woba) { round(mean(.data[[woba_col]][BatterSide == "Left"], na.rm = TRUE), 3) } else NA_real_, wOBA_vR_val = if (has_woba) { round(mean(.data[[woba_col]][BatterSide == "Right"], na.rm = TRUE), 3) } else NA_real_, `Stuff+` = if (has_stuff_plus) round(mean(stuff_plus, na.rm = TRUE), 0) else NA_real_, Notes = "", .groups = "drop" ) %>% dplyr::rename( wOBAcon = wOBAcon_val, wOBA = wOBA_val, `wOBA vL` = wOBA_vL_val, `wOBA vR` = wOBA_vR_val ) %>% dplyr::filter(Count >= 5) %>% dplyr::arrange(dplyr::desc(`Usage%`)) # Totals row totals <- df %>% dplyr::summarise( Pitch = "TOTAL", Count = dplyr::n(), `Usage%` = 100, AvgVelo = round(mean(RelSpeed, na.rm = TRUE), 1), MaxVelo = round(max(RelSpeed, na.rm = TRUE), 1), VeloRng = "-", Spin = round(mean(SpinRate, na.rm = TRUE), 0), IVB = round(mean(InducedVertBreak, na.rm = TRUE), 1), HB = round(mean(HorzBreak, na.rm = TRUE), 1), VAA = if (has_vaa) round(mean(VertApprAngle, na.rm = TRUE), 2) else NA_real_, Ext = round(mean(Extension, na.rm = TRUE), 2), hRel = round(mean(RelSide, na.rm = TRUE), 2), vRel = round(mean(RelHeight, na.rm = TRUE), 2), `Strike%` = round(100 * sum(is_strike, na.rm = TRUE) / dplyr::n(), 1), `Whiff%` = whiff_pct_safe(sum(is_swing, na.rm = TRUE), sum(is_whiff, na.rm = TRUE)), `Zone%` = round(100 * sum(in_zone, na.rm = TRUE) / dplyr::n(), 1), `Putaway%` = ifelse(sum(Strikes == 2, na.rm = TRUE) > 0, round(100 * sum(is_put_away, na.rm = TRUE) / sum(Strikes == 2, na.rm = TRUE), 1), 0), # totals splits `Whiff% vL` = whiff_pct_safe( sum(is_swing[BatterSide == "Left"], na.rm = TRUE), sum(is_whiff[BatterSide == "Left"], na.rm = TRUE) ), `Whiff% vR` = whiff_pct_safe( sum(is_swing[BatterSide == "Right"], na.rm = TRUE), sum(is_whiff[BatterSide == "Right"], na.rm = TRUE) ), AvgEV = round(mean(ExitSpeed[PitchCall == "InPlay"], na.rm = TRUE), 1), wOBAcon = if (has_wobacon) round(mean(.data[[wobacon_col]][PitchCall == "InPlay"], na.rm = TRUE), 3) else NA_real_, # wOBA overall and splits for totals wOBA = if (has_woba) round(mean(.data[[woba_col]], na.rm = TRUE), 3) else NA_real_, `wOBA vL` = if (has_woba) round(mean(.data[[woba_col]][BatterSide == "Left"], na.rm = TRUE), 3) else NA_real_, `wOBA vR` = if (has_woba) round(mean(.data[[woba_col]][BatterSide == "Right"], na.rm = TRUE), 3) else NA_real_, `Stuff+` = if (has_stuff_plus) round(mean(stuff_plus, na.rm = TRUE), 0) else NA_real_, Notes = "" ) pitch_stats <- dplyr::bind_rows(pitch_stats, totals) list(stats = pitch_stats, pitcher_hand = pitcher_hand) } # ============================================================================ # NEW: ROLLING DATA HELPER (for individual pitcher PDF charts) # ============================================================================ create_rolling_data_single <- function(data, pitcher_name, stat_type) { fb_pitches <- c("Fastball", "Sinker", "FourSeamFastBall", "TwoSeamFastBall", "Four-Seam", "Two-Seam") rolling_data <- data %>% filter(Pitcher == pitcher_name) %>% arrange(Date, GameUID, PitchofPA) %>% group_by(GameUID) %>% mutate(pitch_in_game = row_number()) %>% ungroup() if (stat_type == "velo") { raw_data <- rolling_data %>% filter(TaggedPitchType %in% fb_pitches, !is.na(RelSpeed)) %>% select(pitch_in_game, value = RelSpeed) } else { return(data.frame(pitch_num = integer(), value = numeric())) } if (nrow(raw_data) == 0) return(data.frame(pitch_num = integer(), value = numeric())) # Bucket by 10s: center of bucket (5, 15, 25, etc.) result <- raw_data %>% mutate(pitch_bucket = floor((pitch_in_game - 1) / 10) * 10 + 5) %>% group_by(pitch_num = pitch_bucket) %>% summarise(value = mean(value, na.rm = TRUE), n = n(), .groups = "drop") %>% filter(n >= 5) result } # NEW: Monthly FB Velo helper create_monthly_fb_velo <- function(data, pitcher_name) { fb_pitches <- c("Fastball", "Sinker", "FourSeamFastBall", "TwoSeamFastBall", "Four-Seam", "Two-Seam") monthly_data <- data %>% filter(Pitcher == pitcher_name, TaggedPitchType %in% fb_pitches, !is.na(RelSpeed), !is.na(Date)) %>% mutate( Date = as.Date(Date), month_label = format(Date, "%b %Y"), month_date = as.Date(paste0(format(Date, "%Y-%m"), "-01")) ) %>% group_by(month_date, month_label) %>% summarise(avg_velo = mean(RelSpeed, na.rm = TRUE), n = n(), .groups = "drop") %>% filter(n >= 5) %>% arrange(month_date) monthly_data } # NEW: Time Through Order (TTO) wOBA chart create_tto_chart <- function(data, pitcher_name) { has_woba <- "woba" %in% names(data) df <- data %>% filter(Pitcher == pitcher_name, !is.na(Batter), !is.na(Date)) %>% arrange(Date, GameUID, Inning, PAofInning) if (nrow(df) < 50) { return(ggplot() + theme_void() + annotate("text", x = 0.5, y = 0.5, label = "Insufficient data", size = 3)) } # For each game, track how many times each batter has been faced tto_data <- df %>% group_by(GameUID, Batter) %>% mutate( # Each unique PA for this batter in this game pa_num_in_game = cumsum(!duplicated(paste(Inning, PAofInning))) ) %>% ungroup() %>% # Keep only last pitch of each PA (the PA result) filter( KorBB %in% c("Strikeout", "Walk") | PitchCall == "HitByPitch" | PitchCall == "InPlay" ) %>% group_by(GameUID, Batter) %>% mutate(times_faced = row_number()) %>% ungroup() # Calculate wOBA or a proxy by times faced if (has_woba) { tto_summary <- tto_data %>% group_by(times_faced) %>% summarise( avg_woba = mean(woba, na.rm = TRUE), n_pa = n(), .groups = "drop" ) %>% filter(n_pa >= 5, times_faced <= 5) } else { # Use OBP-like proxy: hits + walks + HBP / PA tto_summary <- tto_data %>% mutate( reached = as.integer( PlayResult %in% c("Single", "Double", "Triple", "HomeRun") | KorBB == "Walk" | PitchCall == "HitByPitch" ) ) %>% group_by(times_faced) %>% summarise( avg_woba = mean(reached, na.rm = TRUE), n_pa = n(), .groups = "drop" ) %>% filter(n_pa >= 5, times_faced <= 5) } if (nrow(tto_summary) < 2) { return(ggplot() + theme_void() + annotate("text", x = 0.5, y = 0.5, label = "Insufficient TTO data", size = 3)) } y_label <- if (has_woba) "wOBA" else "OBP" ggplot(tto_summary, aes(x = factor(times_faced), y = avg_woba)) + geom_col(fill = "#006F71", width = 0.6, alpha = 0.85) + geom_text(aes(label = sprintf("%.3f", avg_woba)), vjust = -0.5, size = 2.5, fontface = "bold") + geom_text(aes(label = paste0("n=", n_pa), y = 0), vjust = 1.5, size = 2, color = "gray50") + labs( title = paste0(y_label, " by Times Through Order"), x = "Times Faced", y = y_label ) + scale_y_continuous(expand = expansion(mult = c(0.15, 0.15))) + theme_minimal() + theme( plot.title = element_text(hjust = 0.5, face = "bold", size = 9, color = "#006F71"), axis.text = element_text(size = 7), axis.title = element_text(size = 7), panel.grid.minor = element_blank(), panel.grid.major.x = element_blank(), plot.margin = margin(2, 2, 2, 2) ) } # NEW: Pitch count by inning (single row heatmap for one pitcher) create_pitcher_inning_row <- function(data, pitcher_name) { inning_data <- data %>% filter(Pitcher == pitcher_name, !is.na(Inning)) %>% mutate(Inning = ifelse(Inning > 9, "X", as.character(Inning))) %>% group_by(Inning) %>% summarise(n = n(), .groups = "drop") all_innings <- c("1", "2", "3", "4", "5", "6", "7", "8", "9", "X") inning_data <- data.frame(Inning = all_innings) %>% left_join(inning_data, by = "Inning") %>% mutate(n = ifelse(is.na(n), 0, n)) inning_data$Inning <- factor(inning_data$Inning, levels = all_innings) ggplot(inning_data, aes(x = Inning, y = 1, fill = n)) + geom_tile(color = "white", linewidth = 0.5) + geom_text(aes(label = ifelse(n > 0, n, "")), size = 2.5, fontface = "bold") + scale_fill_gradient2(low = "#E1463E", mid = "#FFFFCC", high = "#00840D", midpoint = max(inning_data$n[inning_data$n > 0], na.rm = TRUE) / 2, guide = "none") + scale_x_discrete(position = "top") + labs(x = NULL, y = NULL, title = "Pitch Count by Inning") + theme_void() + theme( plot.title = element_text(hjust = 0.5, face = "bold", size = 8, color = "#006F71"), axis.text.x = element_text(size = 6, face = "bold"), plot.margin = margin(2, 2, 2, 2) ) } # Main PDF creation function - WITH NOTES SUPPORT create_comprehensive_pitcher_pdf <- function(data, pitcher_name, output_file, delivery_notes = "", count_usage_notes = "", pitch_notes = list(), overall_notes = "", vs_lhh_notes = "", vs_rhh_notes = "", series_title = "", pitcher_role = "", matchup_matrix = NULL) { if (length(dev.list()) > 0) try(dev.off(), silent = TRUE) # Filter pitcher data pitcher_df <- data %>% filter(Pitcher == pitcher_name) if (nrow(pitcher_df) < 20) { pdf(output_file, width = 22, height = 28) grid::grid.newpage() grid::grid.text(paste("Insufficient data for", pitcher_name), gp = grid::gpar(fontsize = 20, fontface = "bold")) dev.off() return(output_file) } # Get advanced stats stats <- calculate_advanced_pitcher_stats(data, pitcher_name) if (is.null(stats)) { pdf(output_file, width = 22, height = 28) grid::grid.newpage() grid::grid.text(paste("Could not calculate stats for", pitcher_name), gp = grid::gpar(fontsize = 20, fontface = "bold")) dev.off() return(output_file) } # Get pitch characteristics pitch_result <- create_enhanced_pitch_characteristics(data, pitcher_name) pitch_char <- if (!is.null(pitch_result)) pitch_result$stats else NULL # Create plots arm_angle_plot <- tryCatch( create_pitcher_arm_angle_plot(data, pitcher_name), error = function(e) ggplot() + theme_void() + ggtitle("Arm Angle Error") ) movement_plot <- tryCatch( create_pitcher_movement_plot(data, current_pitcher_reactive(), exp_movement_grid()), error = function(e) ggplot() + theme_void() + ggtitle("Movement Error") ) count_plot <- tryCatch( create_count_usage_pies(data, pitcher_name), error = function(e) ggplot() + theme_void() + ggtitle("Count Usage Error") ) seq_lhh <- tryCatch( create_sequencing_matrix(data, pitcher_name, "Left"), error = function(e) ggplot() + theme_void() + ggtitle("Seq vs LHH Error") ) seq_rhh <- tryCatch( create_sequencing_matrix(data, pitcher_name, "Right"), error = function(e) ggplot() + theme_void() + ggtitle("Seq vs RHH Error") ) spray_lhh <- tryCatch( create_hits_spray_chart(data, pitcher_name, "Left"), error = function(e) ggplot() + theme_void() + ggtitle("Spray LHH Error") ) spray_rhh <- tryCatch( create_hits_spray_chart(data, pitcher_name, "Right"), error = function(e) ggplot() + theme_void() + ggtitle("Spray RHH Error") ) # Create heatmaps heatmaps <- list() for (pg in c("FB", "BB", "OS")) { for (mt in c("All", "Whf", "Dmg")) { for (side in c("Left", "Right")) { key <- paste0(pg, "_", mt, "_", side) heatmaps[[key]] <- tryCatch( create_pitch_group_heatmap(data, pitcher_name, pg, mt, side), error = function(e) ggplot() + theme_void() ) } } } # Start PDF pdf(output_file, width = 22, height = 28) on.exit(try(dev.off(), silent = TRUE), add = TRUE) grid::grid.newpage() # ===== HEADER ===== # Add series title if provided if (nchar(series_title) > 0) { grid::grid.rect(x = 0.5, y = 0.995, width = 1, height = 0.012, gp = grid::gpar(fill = "#1976D2", col = NA), just = c("center", "top")) grid::grid.text(series_title, x = 0.5, y = 0.989, gp = grid::gpar(fontsize = 14, fontface = "bold", col = "white")) } # Pitcher name header header_y <- if (nchar(series_title) > 0) 0.982 else 0.985 grid::grid.rect(x = 0.5, y = header_y, width = 1, height = 0.025, gp = grid::gpar(fill = "#006F71", col = NA), just = c("center", "top")) # Get pitcher info pitcher_df <- data %>% filter(Pitcher == pitcher_name) pitcher_throws <- if (nrow(pitcher_df) > 0 && "PitcherThrows" %in% names(pitcher_df)) { pitcher_df$PitcherThrows[1] } else { "Right" } is_lefty <- !is.na(pitcher_throws) && pitcher_throws == "Left" hand_label <- if (is_lefty) "LHP" else "RHP" # Get pitcher height pitcher_height_str <- get_pitcher_height_display(pitcher_name) height_label <- if (nchar(pitcher_height_str) > 0) paste0(" | ", pitcher_height_str) else "" # Combine pitcher name with role if provided display_name <- if (nchar(pitcher_role) > 0) { paste0(pitcher_role, " - ", pitcher_name) } else { pitcher_name } # Name color - red for lefties name_color <- if (is_lefty) "#FF4444" else "white" # Draw pitcher name grid::grid.text(display_name, x = 0.5, y = header_y - 0.010, gp = grid::gpar(fontsize = 24, fontface = "bold", col = name_color)) # Draw handedness and height on right side of header grid::grid.text(paste0(hand_label, height_label), x = 0.95, y = header_y - 0.012, gp = grid::gpar(fontsize = 12, fontface = "bold", col = "white"), just = "right") # ===== ROW 1: Basic Stats ===== row1_y <- 0.955 row1_labels <- c("IP", "G", "IP/G", "P/G", "Last 3 Outings") row1_values <- c(sprintf("%.1f", stats$ip), stats$n_games, sprintf("%.1f", stats$avg_ip), stats$avg_pitches, stats$last_3_outings) n_cols1 <- length(row1_labels) total_width1 <- 0.70 # Wider to fit last 3 outings col_widths1 <- c(0.08, 0.08, 0.08, 0.08, 0.38) # Custom widths start_x1 <- 0.15 row_h <- 0.012 x_pos <- start_x1 for (i in seq_along(row1_labels)) { x_center <- x_pos + col_widths1[i] / 2 grid::grid.rect(x = x_center, y = row1_y, width = col_widths1[i], height = row_h, gp = grid::gpar(fill = "#006F71", col = "black", lwd = 0.5), just = c("center", "top")) grid::grid.text(row1_labels[i], x = x_center, y = row1_y - row_h * 0.5, gp = grid::gpar(fontsize = if(i == 5) 8 else 10, fontface = "bold", col = "white")) grid::grid.rect(x = x_center, y = row1_y - row_h, width = col_widths1[i], height = row_h, gp = grid::gpar(fill = "white", col = "black", lwd = 0.5), just = c("center", "top")) grid::grid.text(as.character(row1_values[i]), x = x_center, y = row1_y - row_h * 1.5, gp = grid::gpar(fontsize = if(i == 5) 8 else 10)) x_pos <- x_pos + col_widths1[i] } # ===== ROW 2: Advanced Stats ===== row2_y <- 0.925 row2_labels <- c("RA9", "FIP", "WHIP", "K%", "BB%", "BA L", "BA R", "SLG L", "SLG R") row2_values <- c(stats$ra9, stats$fip, stats$whip, stats$k_pct, stats$bb_pct, stats$ba_vs_l, stats$ba_vs_r, stats$slg_vs_l, stats$slg_vs_r) row2_benchmarks <- c(sec_benchmarks$era, sec_benchmarks$fip, sec_benchmarks$whip, sec_benchmarks$k_pct, sec_benchmarks$bb_pct, sec_benchmarks$ba_against, sec_benchmarks$ba_against, sec_benchmarks$slg_against, sec_benchmarks$slg_against) row2_higher_better <- c(FALSE, FALSE, FALSE, TRUE, FALSE, FALSE, FALSE, FALSE, FALSE) n_cols2 <- length(row2_labels) total_width2 <- 0.80 col_w2 <- total_width2 / n_cols2 start_x2 <- 0.10 for (i in seq_along(row2_labels)) { x_pos <- start_x2 + (i - 0.5) * col_w2 fill_col <- get_stat_color(row2_values[i], row2_benchmarks[i], row2_higher_better[i], 0.30) grid::grid.rect(x = x_pos, y = row2_y, width = col_w2, height = 0.012, gp = grid::gpar(fill = "#006F71", col = "black", lwd = 0.5), just = c("center", "top")) grid::grid.text(row2_labels[i], x = x_pos, y = row2_y - 0.006, gp = grid::gpar(fontsize = 9, fontface = "bold", col = "white")) grid::grid.rect(x = x_pos, y = row2_y - 0.012, width = col_w2, height = 0.014, gp = grid::gpar(fill = fill_col, col = "black", lwd = 0.5), just = c("center", "top")) val_text <- if (is.na(row2_values[i])) "-" else if (i <= 3) sprintf("%.2f", row2_values[i]) else if (i <= 5) paste0(row2_values[i], "%") else sprintf(".%03d", round(row2_values[i] * 1000)) grid::grid.text(val_text, x = x_pos, y = row2_y - 0.019, gp = grid::gpar(fontsize = 9)) } # ===== ROW 3: Pitch-level Stats ===== row3_y <- 0.895 row3_labels <- c("Whiff%", "Zone%", "Strike%", "FB Strike%", "BB Strike%", "OS Strike%") row3_values <- c(stats$whiff_pct, stats$zone_pct, stats$strike_pct, stats$fb_strike_pct, stats$bb_strike_pct, stats$os_strike_pct) row3_benchmarks <- c(sec_benchmarks$whiff_pct, sec_benchmarks$zone_pct, sec_benchmarks$strike_pct, sec_benchmarks$fb_strike, sec_benchmarks$bb_strike, sec_benchmarks$os_strike) n_cols3 <- length(row3_labels) total_width3 <- 0.70 col_w3 <- total_width3 / n_cols3 start_x3 <- 0.15 for (i in seq_along(row3_labels)) { x_pos <- start_x3 + (i - 0.5) * col_w3 fill_col <- get_stat_color(row3_values[i], row3_benchmarks[i], TRUE, 0.20) grid::grid.rect(x = x_pos, y = row3_y, width = col_w3, height = 0.012, gp = grid::gpar(fill = "#006F71", col = "black", lwd = 0.5), just = c("center", "top")) grid::grid.text(row3_labels[i], x = x_pos, y = row3_y - 0.006, gp = grid::gpar(fontsize = 9, fontface = "bold", col = "white")) grid::grid.rect(x = x_pos, y = row3_y - 0.012, width = col_w3, height = 0.014, gp = grid::gpar(fill = fill_col, col = "black", lwd = 0.5), just = c("center", "top")) val_text <- if (is.na(row3_values[i])) "-" else paste0(row3_values[i], "%") grid::grid.text(val_text, x = x_pos, y = row3_y - 0.019, gp = grid::gpar(fontsize = 9)) } # ===== CHARTS ROW 1: Arm Angle, Movement, Count Usage ===== chart_row1_y <- 0.865 chart_height1 <- 0.20 # Arm angle grid::pushViewport(grid::viewport(x = 0.11, y = chart_row1_y, width = 0.19, height = chart_height1 * 0.80, just = c("center", "top"))) tryCatch(print(arm_angle_plot, newpage = FALSE), error = function(e) NULL) grid::popViewport() # Delivery Notes box under arm angle - ALWAYS show the box delivery_notes_y <- chart_row1_y - chart_height1 * 0.80 - 0.005 notes_height <- 0.045 grid::grid.rect(x = 0.12, y = delivery_notes_y, width = 0.18, height = notes_height, gp = grid::gpar(fill = "white", col = "#006F71", lwd = 1), just = c("center", "top")) grid::grid.text("Delivery Notes", x = 0.12, y = delivery_notes_y - 0.005, gp = grid::gpar(fontsize = 8, fontface = "bold", col = "#006F71")) if (nchar(delivery_notes) > 0) { # Wrap text for notes wrapped_notes <- strwrap(delivery_notes, width = 30) for (j in seq_along(wrapped_notes)) { grid::grid.text(wrapped_notes[j], x = 0.11, y = delivery_notes_y - 0.015 - (j - 1) * 0.009, gp = grid::gpar(fontsize = 7)) } } # Movement - HUGE grid::pushViewport(grid::viewport(x = 0.46, y = chart_row1_y, width = 0.58, height = chart_height1 + 0.03, just = c("center", "top"))) tryCatch(print(movement_plot, newpage = FALSE), error = function(e) NULL) grid::popViewport() # Count usage pies - lowered position grid::pushViewport(grid::viewport(x = 0.85, y = chart_row1_y - 0.02, width = 0.28, height = chart_height1 * 0.55, just = c("center", "top"))) tryCatch(print(count_plot, newpage = FALSE), error = function(e) NULL) grid::popViewport() # ===== TIME THROUGH ORDER CHART (replaces pitch usage table) ===== tto_chart <- tryCatch( create_tto_chart(data, pitcher_name), error = function(e) ggplot() + theme_void() + ggtitle("TTO Error") ) usage_table_y <- 0.735 usage_table_center <- 0.85 pitch_df <- data %>% filter(Pitcher == pitcher_name, !is.na(TaggedPitchType), TaggedPitchType != "Other") main_pitches <- pitch_df %>% count(TaggedPitchType) %>% mutate(pct = n / sum(n) * 100) %>% filter(pct >= 3) %>% arrange(desc(pct)) %>% head(4) %>% pull(TaggedPitchType) # ===== TTO CHART (replaces old pitch usage table) ===== grid::pushViewport(grid::viewport(x = usage_table_center, y = usage_table_y + 0.01, width = 0.26, height = 0.10, just = c("center", "top"))) tryCatch(print(tto_chart, newpage = FALSE), error = function(e) NULL) grid::popViewport() # ===== CHARTS ROW 2: Sequencing and Spray Charts ===== chart_row2_y <- 0.645 chart_height2 <- 0.11 grid::pushViewport(grid::viewport(x = 0.17, y = chart_row2_y, width = 0.26, height = chart_height2, just = c("center", "top"))) tryCatch(print(seq_lhh, newpage = FALSE), error = function(e) NULL) grid::popViewport() grid::pushViewport(grid::viewport(x = 0.43, y = chart_row2_y, width = 0.26, height = chart_height2, just = c("center", "top"))) tryCatch(print(seq_rhh, newpage = FALSE), error = function(e) NULL) grid::popViewport() grid::pushViewport(grid::viewport(x = 0.67, y = chart_row2_y, width = 0.18, height = chart_height2, just = c("center", "top"))) tryCatch(print(spray_lhh, newpage = FALSE), error = function(e) NULL) grid::popViewport() grid::pushViewport(grid::viewport(x = 0.85, y = chart_row2_y, width = 0.18, height = chart_height2, just = c("center", "top"))) tryCatch(print(spray_rhh, newpage = FALSE), error = function(e) NULL) grid::popViewport() # ===== PITCH CHARACTERISTICS TABLE ===== table_y <- 0.525 if (!is.null(pitch_char) && nrow(pitch_char) > 0) { headers <- names(pitch_char) num_cols <- length(headers) num_rows <- min(nrow(pitch_char), 7) base_width <- 0.92 / (num_cols - 1 + 2.5) col_widths <- sapply(headers, function(h) { if (h == "Notes") base_width * 2.5 else base_width }) x_starts_tbl <- 0.04 + cumsum(c(0, col_widths[-length(col_widths)])) row_h <- 0.016 for (j in seq_along(headers)) { grid::grid.rect(x = x_starts_tbl[j], y = table_y, width = col_widths[j] * 0.98, height = row_h, gp = grid::gpar(fill = "#006F71", col = "black", lwd = 0.5), just = c("left", "top")) grid::grid.text(headers[j], x = x_starts_tbl[j] + col_widths[j] * 0.49, y = table_y - row_h * 0.5, gp = grid::gpar(fontsize = 8, fontface = "bold", col = "white")) } rate_cols <- c("Strike%", "Whiff%", "Zone%", "Putaway%") velo_cols <- c("AvgVelo", "MaxVelo") # SEC benchmarks by pitch type for AvgEV and wOBAcon (lower is better for pitcher) ev_benchmarks <- list( "Fastball" = 88.6, "Four-Seam" = 88.6, "FourSeamFastBall" = 88.6, "Sinker" = 87.9, "TwoSeamFastBall" = 87.9, "Slider" = 85.3, "Sweeper" = 85.5, "Changeup" = 85.6, "ChangeUp" = 85.6, "Curveball" = 85.4, "Splitter" = 85.2, "Cutter" = 86.5 ) wobacon_benchmarks <- list( "Fastball" = 0.402, "Four-Seam" = 0.402, "FourSeamFastBall" = 0.402, "Sinker" = 0.377, "TwoSeamFastBall" = 0.377, "Slider" = 0.393, "Sweeper" = 0.395, "Changeup" = 0.389, "ChangeUp" = 0.389, "Curveball" = 0.372, "Splitter" = 0.358, "Cutter" = 0.390 ) for (i in seq_len(num_rows)) { y_row <- table_y - i * row_h pitch_type <- pitch_char$Pitch[i] velo_bench <- if (pitch_type %in% c("Fastball", "Sinker", "Four-Seam", "Two-Seam")) 93 else if (pitch_type == "Slider") 83 else if (pitch_type == "Curveball") 79 else if (pitch_type %in% c("ChangeUp", "Changeup")) 84 else if (pitch_type == "Cutter") 87 else 85 spin_bench <- if (pitch_type %in% c("Fastball", "Sinker", "Four-Seam", "Two-Seam")) 2267 else if (pitch_type == "Slider") 2440 else if (pitch_type == "Curveball") 2442 else if (pitch_type %in% c("ChangeUp", "Changeup")) 1708 else if (pitch_type == "Cutter") 2387 else 2200 ext_bench <- if (pitch_type %in% c("Fastball", "Sinker", "Four-Seam", "Two-Seam")) 5.83 else if (pitch_type == "Slider") 5.54 else if (pitch_type == "Curveball") 5.47 else if (pitch_type %in% c("ChangeUp", "Changeup")) 5.98 else if (pitch_type == "Cutter") 5.70 else 5.60 # Get SEC benchmarks for this pitch type ev_bench <- ev_benchmarks[[pitch_type]] %||% 86.5 wobacon_bench <- wobacon_benchmarks[[pitch_type]] %||% 0.390 for (j in seq_along(headers)) { val <- pitch_char[[headers[j]]][i] bg <- "#FFFFFF" if (headers[j] == "Pitch" && as.character(val) %in% names(pitch_colors)) { bg <- pitch_colors[[as.character(val)]] } if (headers[j] %in% velo_cols && is.numeric(val) && !is.na(val)) { bg <- get_stat_color(val, velo_bench, TRUE, 0.08) } if (headers[j] == "Spin" && is.numeric(val) && !is.na(val)) { bg <- get_stat_color(val, spin_bench, TRUE, 0.15) } if (headers[j] == "Ext" && is.numeric(val) && !is.na(val)) { bg <- get_stat_color(val, ext_bench, TRUE, 0.10) } if (headers[j] %in% rate_cols && is.numeric(val) && !is.na(val)) { benchmark <- switch(headers[j], "Strike%" = 62, "Whiff%" = 29, "Zone%" = 46, "Putaway%" = 25, NA) if (!is.na(benchmark)) { bg <- get_stat_color(val, benchmark, TRUE, 0.25) } } # Whiff% vL and vR coloring - higher is better for pitcher if (headers[j] %in% c("Whiff% vL", "Whiff% vR") && is.numeric(val) && !is.na(val)) { bg <- get_stat_color(val, 29, TRUE, 0.25) # TRUE = higher is better } # AvgEV coloring - lower is better for pitcher (inverse) if (headers[j] == "AvgEV" && is.numeric(val) && !is.na(val) && pitch_type != "TOTAL") { bg <- get_stat_color(val, ev_bench, FALSE, 0.06) # FALSE = lower is better } # wOBAcon coloring - lower is better for pitcher (inverse) if (headers[j] == "wOBAcon" && is.numeric(val) && !is.na(val) && pitch_type != "TOTAL") { bg <- get_stat_color(val, wobacon_bench, FALSE, 0.15) # FALSE = lower is better } # wOBA overall and splits coloring - lower is better for pitcher (inverse) if (headers[j] %in% c("wOBA", "wOBA vL", "wOBA vR") && is.numeric(val) && !is.na(val) && pitch_type != "TOTAL") { bg <- get_stat_color(val, 0.320, FALSE, 0.15) # FALSE = lower is better, SEC avg wOBA ~0.320 } if (headers[j] == "Stuff+" && is.numeric(val) && !is.na(val)) { bg <- get_stat_color(val, 100, TRUE, 0.15) } if (headers[j] == "Notes") { bg <- "#f5f5f5" } grid::grid.rect(x = x_starts_tbl[j], y = y_row, width = col_widths[j] * 0.98, height = row_h, gp = grid::gpar(fill = bg, col = "gray70", lwd = 0.3), just = c("left", "top")) # Check for pitch-specific notes display_val <- if (headers[j] == "Notes" && pitch_type %in% names(pitch_notes)) { pitch_notes[[pitch_type]] } else if (headers[j] %in% c("wOBAcon", "wOBA", "wOBA vL", "wOBA vR") && is.numeric(val) && !is.na(val)) { # Format wOBA/wOBAcon as .XXX (e.g., .402 not 0.40) if (val < 1) { sub("^0", "", sprintf("%.3f", val)) # Remove leading zero: 0.402 -> .402 } else { sprintf("%.3f", val) } } else if (is.numeric(val)) { if (is.na(val)) "-" else if (abs(val) < 10 && val != round(val)) sprintf("%.2f", val) else sprintf("%.1f", val) } else as.character(val) if (headers[j] != "Notes" || (headers[j] == "Notes" && pitch_type %in% names(pitch_notes))) { grid::grid.text(display_val, x = x_starts_tbl[j] + col_widths[j] * 0.49, y = y_row - row_h * 0.5, gp = grid::gpar(fontsize = 7.5)) } } } } # ===== HEATMAPS SECTION - Clean Layout (INCLUDES PILL STAT INDICATORS) ===== hm_section_y <- 0.34 # Section header grid::grid.rect(x = 0.5, y = hm_section_y + 0.032, width = 0.98, height = 0.015, gp = grid::gpar(fill = "#006F71", col = NA)) grid::grid.text("Location Analysis", x = 0.5, y = hm_section_y + 0.032, gp = grid::gpar(fontsize = 10, fontface = "bold", col = "white")) # Colors for heatmaps + column pills hm_colors <- c("white", "blue", "#FF9999", "red", "darkred") fb_pill <- "#FA8072" bb_pill <- "#A020F0" os_pill <- "#228B22" # Dimensions / spacing hm_height <- 0.048 hm_width <- 0.052 hm_gap <- 0.003 pill_w <- 0.022 pill_h <- 0.009 row_pill_w <- 0.018 row_pill_h <- 0.008 # Even spacing for 4 main sections section_centers <- seq(0.16, 0.78, length.out = 4) sec1_center <- section_centers[1] # vs LHH sec2_center <- section_centers[2] # vs RHH sec3_center <- section_centers[3] # LHH by Count sec4_center <- section_centers[4] # RHH by Count nitro_center <- 0.93 # Row y positions (3 rows) row_y <- c( hm_section_y - 0.020, hm_section_y - 0.020 - hm_height - hm_gap, hm_section_y - 0.020 - 2 * (hm_height + hm_gap) ) # Row label pills for sections 1/2 row_labels_1 <- c("All", "Whf", "Dmg") row_colors_1 <- c("#006F71", "#00840D", "#E1463E") # Row label pills for count sections 3/4 row_labels_count <- c("1st", "Bhd", "2K") row_keys_count <- c("1st", "Behind", "2K") row_colors_count <- c("#4169E1", "#FF8C00", "#DC143C") # Pitch groups (columns in count grids: FB/BB/OS) pitch_groups <- list( FB = c("Fastball", "Sinker", "Four-Seam", "FourSeamFastBall", "TwoSeamFastBall"), BB = c("Slider", "Curveball", "Sweeper", "Slurve", "Cutter"), OS = c("Changeup", "ChangeUp", "Splitter") ) # ------------------------ # Helper: pitch abbrev # ------------------------ get_pt_short <- function(pt) { dplyr::case_when( pt %in% c("Fastball", "Four-Seam", "FourSeamFastBall") ~ "FB", pt %in% c("Sinker", "TwoSeamFastBall") ~ "SI", pt %in% c("Slider", "Sweeper") ~ "SL", pt == "Curveball" ~ "CB", pt %in% c("Changeup", "ChangeUp") ~ "CH", pt == "Splitter" ~ "SP", pt == "Cutter" ~ "CT", TRUE ~ substr(as.character(pt), 1, 2) ) } # ------------------------ # UNIFIED HEATMAP FUNCTION - Uses stat_density_2d # ------------------------ create_density_heatmap <- function(data, min_pitches = 5) { # Base plot with zone base_plot <- ggplot() + annotate("rect", xmin = -0.8303, xmax = 0.8303, ymin = 1.5, ymax = 3.5, fill = NA, color = "black", linewidth = 0.5) + 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.4) + coord_fixed(xlim = c(-2, 2), ylim = c(0, 4.5)) + theme_void() + theme( legend.position = "none", plot.margin = margin(0, 0, 0, 0) ) # If no data or too few pitches, return blank zone if (is.null(data) || nrow(data) < min_pitches) { return(base_plot) } # Filter to valid location data plot_data <- data %>% dplyr::filter(!is.na(PlateLocSide), !is.na(PlateLocHeight)) if (nrow(plot_data) < min_pitches) { return(base_plot) } # If small sample, use points instead of density if (nrow(plot_data) < 15) { p <- base_plot + geom_point(data = plot_data, aes(x = PlateLocSide, y = PlateLocHeight), color = "red", alpha = 0.7, size = 1.5) return(p) } # Full density heatmap for larger samples p <- ggplot(plot_data, aes(x = PlateLocSide, y = PlateLocHeight)) + stat_density_2d(aes(fill = after_stat(density)), geom = "raster", contour = FALSE, n = 100) + scale_fill_gradientn( colours = c("white", "blue", "#FF9999", "red", "darkred"), name = "Density" ) + annotate("rect", xmin = -0.8303, xmax = 0.8303, ymin = 1.5, ymax = 3.5, fill = NA, color = "black", linewidth = 0.5) + 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.4) + coord_fixed(xlim = c(-2, 2), ylim = c(0, 4.5)) + theme_void() + theme( legend.position = "none", plot.margin = margin(0, 0, 0, 0) ) return(p) } # ------------------------ # PILL STAT INDICATORS (vL / vR) # ------------------------ pill_y <- hm_section_y + 0.015 # 2K putaway by side putaway_lhh <- pitch_df %>% dplyr::filter(Strikes == 2, BatterSide == "Left") %>% dplyr::group_by(TaggedPitchType) %>% dplyr::summarise( n_2k = dplyr::n(), n_putaway = sum(KorBB == "Strikeout", na.rm = TRUE), putaway_pct = 100 * n_putaway / n_2k, .groups = "drop" ) %>% dplyr::filter(n_2k >= 5) %>% dplyr::arrange(dplyr::desc(putaway_pct)) putaway_rhh <- pitch_df %>% dplyr::filter(Strikes == 2, BatterSide == "Right") %>% dplyr::group_by(TaggedPitchType) %>% dplyr::summarise( n_2k = dplyr::n(), n_putaway = sum(KorBB == "Strikeout", na.rm = TRUE), putaway_pct = 100 * n_putaway / n_2k, .groups = "drop" ) %>% dplyr::filter(n_2k >= 5) %>% dplyr::arrange(dplyr::desc(putaway_pct)) grid::grid.text("2K Out:", x = 0.10, y = pill_y, just = "left", gp = grid::gpar(fontsize = 6, fontface = "bold", col = "#006F71")) if (nrow(putaway_lhh) > 0) { pt_short <- get_pt_short(putaway_lhh$TaggedPitchType[1]) grid::grid.roundrect(x = 0.155, y = pill_y, width = 0.055, height = 0.010, r = unit(0.3, "snpc"), gp = grid::gpar(fill = "#CD853F", col = NA)) grid::grid.text(paste0("vL ", pt_short, " ", sprintf("%.0f%%", putaway_lhh$putaway_pct[1])), x = 0.155, y = pill_y, gp = grid::gpar(fontsize = 5, fontface = "bold", col = "white")) } if (nrow(putaway_rhh) > 0) { pt_short <- get_pt_short(putaway_rhh$TaggedPitchType[1]) grid::grid.roundrect(x = 0.215, y = pill_y, width = 0.055, height = 0.010, r = unit(0.3, "snpc"), gp = grid::gpar(fill = "#CD853F", col = NA)) grid::grid.text(paste0("vR ", pt_short, " ", sprintf("%.0f%%", putaway_rhh$putaway_pct[1])), x = 0.215, y = pill_y, gp = grid::gpar(fontsize = 5, fontface = "bold", col = "white")) } # 1st pitch usage by side first_lhh <- pitch_df %>% dplyr::filter(PitchofPA == 1, BatterSide == "Left") %>% dplyr::count(TaggedPitchType, name = "n") %>% dplyr::mutate(pct = 100 * n / sum(n)) %>% dplyr::arrange(dplyr::desc(pct)) first_rhh <- pitch_df %>% dplyr::filter(PitchofPA == 1, BatterSide == "Right") %>% dplyr::count(TaggedPitchType, name = "n") %>% dplyr::mutate(pct = 100 * n / sum(n)) %>% dplyr::arrange(dplyr::desc(pct)) grid::grid.text("1st:", x = 0.27, y = pill_y, just = "left", gp = grid::gpar(fontsize = 6, fontface = "bold", col = "#006F71")) if (nrow(first_lhh) > 0) { pt_short <- get_pt_short(first_lhh$TaggedPitchType[1]) grid::grid.roundrect(x = 0.315, y = pill_y, width = 0.055, height = 0.010, r = unit(0.3, "snpc"), gp = grid::gpar(fill = "#4169E1", col = NA)) grid::grid.text(paste0("vL ", pt_short, " ", sprintf("%.0f%%", first_lhh$pct[1])), x = 0.315, y = pill_y, gp = grid::gpar(fontsize = 5, fontface = "bold", col = "white")) } if (nrow(first_rhh) > 0) { pt_short <- get_pt_short(first_rhh$TaggedPitchType[1]) grid::grid.roundrect(x = 0.375, y = pill_y, width = 0.055, height = 0.010, r = unit(0.3, "snpc"), gp = grid::gpar(fill = "#4169E1", col = NA)) grid::grid.text(paste0("vR ", pt_short, " ", sprintf("%.0f%%", first_rhh$pct[1])), x = 0.375, y = pill_y, gp = grid::gpar(fontsize = 5, fontface = "bold", col = "white")) } # Hardest hit (avg EV) by side hardest_lhh <- pitch_df %>% dplyr::filter(PitchCall == "InPlay", !is.na(ExitSpeed), BatterSide == "Left") %>% dplyr::group_by(TaggedPitchType) %>% dplyr::summarise(avg_ev = mean(ExitSpeed, na.rm = TRUE), n_bip = dplyr::n(), .groups = "drop") %>% dplyr::filter(n_bip >= 3) %>% dplyr::arrange(dplyr::desc(avg_ev)) hardest_rhh <- pitch_df %>% dplyr::filter(PitchCall == "InPlay", !is.na(ExitSpeed), BatterSide == "Right") %>% dplyr::group_by(TaggedPitchType) %>% dplyr::summarise(avg_ev = mean(ExitSpeed, na.rm = TRUE), n_bip = dplyr::n(), .groups = "drop") %>% dplyr::filter(n_bip >= 3) %>% dplyr::arrange(dplyr::desc(avg_ev)) grid::grid.text("Hardest:", x = 0.43, y = pill_y, just = "left", gp = grid::gpar(fontsize = 6, fontface = "bold", col = "#006F71")) if (nrow(hardest_lhh) > 0) { pt_short <- get_pt_short(hardest_lhh$TaggedPitchType[1]) grid::grid.roundrect(x = 0.49, y = pill_y, width = 0.055, height = 0.010, r = unit(0.3, "snpc"), gp = grid::gpar(fill = "#E1463E", col = NA)) grid::grid.text(paste0("vL ", pt_short, " ", sprintf("%.0f", hardest_lhh$avg_ev[1])), x = 0.49, y = pill_y, gp = grid::gpar(fontsize = 5, fontface = "bold", col = "white")) } if (nrow(hardest_rhh) > 0) { pt_short <- get_pt_short(hardest_rhh$TaggedPitchType[1]) grid::grid.roundrect(x = 0.55, y = pill_y, width = 0.055, height = 0.010, r = unit(0.3, "snpc"), gp = grid::gpar(fill = "#E1463E", col = NA)) grid::grid.text(paste0("vR ", pt_short, " ", sprintf("%.0f", hardest_rhh$avg_ev[1])), x = 0.55, y = pill_y, gp = grid::gpar(fontsize = 5, fontface = "bold", col = "white")) } # ------------------------ # Helpers: section header + row pills # ------------------------ draw_section_header <- function(x_center, label, label_col) { col_x <- c(x_center - hm_width - hm_gap, x_center, x_center + hm_width + hm_gap) grid::grid.text(label, x = x_center, y = hm_section_y - 0.002, gp = grid::gpar(fontsize = 7, fontface = "bold", col = label_col)) pills <- list(list(fb_pill, "FB"), list(bb_pill, "BB"), list(os_pill, "OS")) for (i in 1:3) { grid::grid.roundrect(x = col_x[i], y = hm_section_y - 0.011, width = pill_w, height = pill_h, r = unit(0.3, "snpc"), gp = grid::gpar(fill = pills[[i]][[1]], col = NA)) grid::grid.text(pills[[i]][[2]], x = col_x[i], y = hm_section_y - 0.011, gp = grid::gpar(fontsize = 5.5, fontface = "bold", col = "white")) } col_x } draw_row_pills <- function(first_col_x, labels, fills) { for (r in 1:3) { x_pill <- first_col_x - hm_width/2 - 0.014 y_pill <- row_y[r] - hm_height/2 grid::grid.roundrect(x = x_pill, y = y_pill, width = row_pill_w, height = row_pill_h, r = unit(0.3, "snpc"), gp = grid::gpar(fill = fills[r], col = NA)) grid::grid.text(labels[r], x = x_pill, y = y_pill, gp = grid::gpar(fontsize = 5, fontface = "bold", col = "white")) } } # Define all section column positions sec1_col_x <- draw_section_header(sec1_center, "vs LHH", "#E53935") sec2_col_x <- draw_section_header(sec2_center, "vs RHH", "#1E88E5") sec3_col_x <- draw_section_header(sec3_center, "LHH by Count", "#E53935") sec4_col_x <- draw_section_header(sec4_center, "RHH by Count", "#1E88E5") # Draw row pills for each section draw_row_pills(sec1_col_x[1], row_labels_1, row_colors_1) draw_row_pills(sec2_col_x[1], row_labels_1, row_colors_1) draw_row_pills(sec3_col_x[1], row_labels_count, row_colors_count) draw_row_pills(sec4_col_x[1], row_labels_count, row_colors_count) # ------------------------ # Count filters # ------------------------ count_filters <- list( "1st" = function(d) dplyr::filter(d, PitchofPA == 1), "Behind" = function(d) dplyr::filter(d, Balls > Strikes), "2K" = function(d) dplyr::filter(d, Strikes == 2) ) # Metric filters for whiff/damage metric_filters <- list( "All" = function(d) d, "Whiff" = function(d) dplyr::filter(d, PitchCall %in% c("StrikeSwinging", "SwingingStrike")), "Damage" = function(d) dplyr::filter(d, PitchCall == "InPlay", ExitSpeed >= 95) ) # ------------------------ # SECTION 1: vs LHH (All / Whiff / Damage) # ------------------------ hm_metrics <- c("All", "Whiff", "Damage") hm_groups <- c("FB", "BB", "OS") for (row_idx in 1:3) { for (col_idx in 1:3) { # Filter data for this cell cell_data <- pitch_df %>% dplyr::filter( TaggedPitchType %in% pitch_groups[[col_idx]], BatterSide == "Left", !is.na(PlateLocSide), !is.na(PlateLocHeight) ) # Apply metric filter cell_data <- metric_filters[[hm_metrics[row_idx]]](cell_data) # Create heatmap p <- create_density_heatmap(cell_data) grid::pushViewport(grid::viewport( x = sec1_col_x[col_idx], y = row_y[row_idx], width = hm_width, height = hm_height, just = c("center", "top") )) tryCatch(print(p, newpage = FALSE), error = function(e) NULL) grid::popViewport() } } # ------------------------ # SECTION 2: vs RHH (All / Whiff / Damage) # ------------------------ for (row_idx in 1:3) { for (col_idx in 1:3) { # Filter data for this cell cell_data <- pitch_df %>% dplyr::filter( TaggedPitchType %in% pitch_groups[[col_idx]], BatterSide == "Right", !is.na(PlateLocSide), !is.na(PlateLocHeight) ) # Apply metric filter cell_data <- metric_filters[[hm_metrics[row_idx]]](cell_data) # Create heatmap p <- create_density_heatmap(cell_data) grid::pushViewport(grid::viewport( x = sec2_col_x[col_idx], y = row_y[row_idx], width = hm_width, height = hm_height, just = c("center", "top") )) tryCatch(print(p, newpage = FALSE), error = function(e) NULL) grid::popViewport() } } # ------------------------ # SECTION 3: LHH by Count (1st / Behind / 2K) # ------------------------ for (row_idx in 1:3) { for (col_idx in 1:3) { # Filter data for this cell cell_data <- pitch_df %>% dplyr::filter( TaggedPitchType %in% pitch_groups[[col_idx]], BatterSide == "Left", !is.na(PlateLocSide), !is.na(PlateLocHeight) ) # Apply count filter cell_data <- count_filters[[row_keys_count[row_idx]]](cell_data) # Create heatmap p <- create_density_heatmap(cell_data) grid::pushViewport(grid::viewport( x = sec3_col_x[col_idx], y = row_y[row_idx], width = hm_width, height = hm_height, just = c("center", "top") )) tryCatch(print(p, newpage = FALSE), error = function(e) NULL) grid::popViewport() } } # ------------------------ # SECTION 4: RHH by Count (1st / Behind / 2K) # ------------------------ for (row_idx in 1:3) { for (col_idx in 1:3) { # Filter data for this cell cell_data <- pitch_df %>% dplyr::filter( TaggedPitchType %in% pitch_groups[[col_idx]], BatterSide == "Right", !is.na(PlateLocSide), !is.na(PlateLocHeight) ) # Apply count filter cell_data <- count_filters[[row_keys_count[row_idx]]](cell_data) # Create heatmap p <- create_density_heatmap(cell_data) grid::pushViewport(grid::viewport( x = sec4_col_x[col_idx], y = row_y[row_idx], width = hm_width, height = hm_height, just = c("center", "top") )) tryCatch(print(p, newpage = FALSE), error = function(e) NULL) grid::popViewport() } } # ===== SECTION 5: NITRO ZONES (smaller, far right) ===== nitro_width <- 0.055 nitro_height <- 0.060 grid::grid.text("Nitro Zones", x = nitro_center, y = hm_section_y - 0.002, gp = grid::gpar(fontsize = 8, fontface = "bold", col = "#E1463E")) grid::grid.text("(90th %ile EV)", x = nitro_center, y = hm_section_y - 0.010, gp = grid::gpar(fontsize = 7, col = "#E1463E")) nitro_y_lhh <- hm_section_y - 0.025 nitro_y_rhh <- hm_section_y - 0.095 # Labels grid::grid.roundrect(x = nitro_center - nitro_width/2 - 0.012, y = nitro_y_lhh - nitro_height/2, width = 0.020, height = 0.008, r = unit(0.3, "snpc"), gp = grid::gpar(fill = "#E53935", col = NA)) grid::grid.text("LHH", x = nitro_center - nitro_width/2 - 0.012, y = nitro_y_lhh - nitro_height/2, gp = grid::gpar(fontsize = 5, fontface = "bold", col = "white")) grid::grid.roundrect(x = nitro_center - nitro_width/2 - 0.012, y = nitro_y_rhh - nitro_height/2, width = 0.020, height = 0.008, r = unit(0.3, "snpc"), gp = grid::gpar(fill = "#1E88E5", col = NA)) grid::grid.text("RHH", x = nitro_center - nitro_width/2 - 0.012, y = nitro_y_rhh - nitro_height/2, gp = grid::gpar(fontsize = 5, fontface = "bold", col = "white")) for (side in c("Left", "Right")) { nitro_data <- pitch_df %>% dplyr::filter( BatterSide == side, PitchCall == "InPlay", !is.na(ExitSpeed), !is.na(PlateLocSide), !is.na(PlateLocHeight) ) if (nrow(nitro_data) >= 8) { ev_90 <- quantile(nitro_data$ExitSpeed, 0.90, na.rm = TRUE) nitro_pts <- nitro_data %>% dplyr::filter(ExitSpeed >= ev_90) y_pos <- if (side == "Left") nitro_y_lhh else nitro_y_rhh p <- ggplot(nitro_pts, aes(x = PlateLocSide, y = PlateLocHeight)) + annotate("rect", xmin = -0.83, xmax = 0.83, ymin = 1.5, ymax = 3.5, fill = "#FFF5F5", color = "black", linewidth = 0.5) + 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.4) + geom_point(aes(size = ExitSpeed), color = "#DC143C", alpha = 0.8) + scale_size_continuous(range = c(1.2, 3.5), guide = "none") + coord_fixed(xlim = c(-2, 2), ylim = c(0, 4.5)) + theme_void() + theme(plot.margin = margin(0,0,0,0)) grid::pushViewport(grid::viewport(x = nitro_center, y = y_pos, width = nitro_width, height = nitro_height, just = c("center", "top"))) tryCatch(print(p, newpage = FALSE), error = function(e) NULL) grid::popViewport() } } # ===== SECTION 5: NITRO ZONES (smaller, far right) ===== nitro_width <- 0.055 nitro_height <- 0.060 grid::grid.text("Nitro Zones", x = nitro_center, y = hm_section_y - 0.002, gp = grid::gpar(fontsize = 8, fontface = "bold", col = "#E1463E")) grid::grid.text("(90th %ile EV)", x = nitro_center, y = hm_section_y - 0.010, gp = grid::gpar(fontsize = 7, col = "#E1463E")) nitro_y_lhh <- hm_section_y - 0.025 nitro_y_rhh <- hm_section_y - 0.095 # Labels grid::grid.roundrect(x = nitro_center - nitro_width/2 - 0.012, y = nitro_y_lhh - nitro_height/2, width = 0.020, height = 0.008, r = unit(0.3, "snpc"), gp = grid::gpar(fill = "#E53935", col = NA)) grid::grid.text("LHH", x = nitro_center - nitro_width/2 - 0.012, y = nitro_y_lhh - nitro_height/2, gp = grid::gpar(fontsize = 5, fontface = "bold", col = "white")) grid::grid.roundrect(x = nitro_center - nitro_width/2 - 0.012, y = nitro_y_rhh - nitro_height/2, width = 0.020, height = 0.008, r = unit(0.3, "snpc"), gp = grid::gpar(fill = "#1E88E5", col = NA)) grid::grid.text("RHH", x = nitro_center - nitro_width/2 - 0.012, y = nitro_y_rhh - nitro_height/2, gp = grid::gpar(fontsize = 5, fontface = "bold", col = "white")) for (side in c("Left", "Right")) { nitro_data <- pitch_df %>% filter(BatterSide == side, PitchCall == "InPlay", !is.na(ExitSpeed), !is.na(PlateLocSide), !is.na(PlateLocHeight)) if (nrow(nitro_data) >= 8) { ev_90 <- quantile(nitro_data$ExitSpeed, 0.90, na.rm = TRUE) nitro_pts <- nitro_data %>% filter(ExitSpeed >= ev_90) y_pos <- if (side == "Left") nitro_y_lhh else nitro_y_rhh p <- ggplot(nitro_pts, aes(x = PlateLocSide, y = PlateLocHeight)) + annotate("rect", xmin = -0.83, xmax = 0.83, ymin = 1.5, ymax = 3.5, fill = "#FFF5F5", color = "black", linewidth = 0.5) + 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.4) + geom_point(aes(size = ExitSpeed), color = "#DC143C", alpha = 0.8) + scale_size_continuous(range = c(1.2, 3.5), guide = "none") + coord_fixed(xlim = c(-2, 2), ylim = c(0, 4.5)) + theme_void() + theme(plot.margin = margin(0,0,0,0)) grid::pushViewport(grid::viewport(x = nitro_center, y = y_pos, width = nitro_width, height = nitro_height, just = c("center", "top"))) tryCatch(print(p, newpage = FALSE), error = function(e) NULL) grid::popViewport() } } # Horizontal divider before charts grid::grid.lines(x = c(0.02, 0.98), y = c(hm_section_y - 0.185, hm_section_y - 0.185), gp = grid::gpar(col = "#006F71", lwd = 1.5)) # ===== CHARTS SECTION: Inning Row + Stacked Velo Charts ===== charts_y <- hm_section_y - 0.20 chart_width <- 0.45 fb_pitches <- c("Fastball", "Sinker", "FourSeamFastBall", "TwoSeamFastBall", "Four-Seam", "Two-Seam") # ===== PITCH COUNT BY INNING ROW (Left, top) ===== inning_row_plot <- tryCatch( create_pitcher_inning_row(data, pitcher_name), error = function(e) ggplot() + theme_void() ) grid::pushViewport(grid::viewport(x = 0.27, y = charts_y, width = chart_width, height = 0.025, just = c("center", "top"))) tryCatch(print(inning_row_plot, newpage = FALSE), error = function(e) NULL) grid::popViewport() # ===== PITCH USAGE BY INNING STACKED BAR (Left, below inning row) ===== inning_data <- pitcher_df %>% dplyr::filter(!is.na(Inning), !is.na(TaggedPitchType), TaggedPitchType != "Other") %>% dplyr::mutate(Inning = ifelse(Inning > 9, "X", as.character(Inning))) %>% dplyr::group_by(Inning, TaggedPitchType) %>% dplyr::summarise(n = dplyr::n(), .groups = "drop") %>% dplyr::group_by(Inning) %>% dplyr::mutate(pct = 100 * n / sum(n)) %>% dplyr::ungroup() if (nrow(inning_data) > 0) { all_innings <- c("1", "2", "3", "4", "5", "6", "7", "8", "9", "X") inning_data$Inning <- factor(inning_data$Inning, levels = all_innings) inning_plot <- ggplot(inning_data, aes(x = Inning, y = pct, fill = TaggedPitchType)) + geom_col(position = "stack", width = 0.8) + scale_fill_manual(values = pitch_colors, name = NULL) + labs(x = NULL, y = "Usage %", title = "Pitch Usage by Inning") + scale_y_continuous(expand = expansion(mult = c(0, 0.02))) + theme_minimal() + theme( legend.position = "bottom", legend.key.size = unit(0.3, "cm"), legend.text = element_text(size = 5), plot.title = element_text(hjust = 0.5, face = "bold", size = 9, color = "#006F71"), axis.text = element_text(size = 6), axis.title.y = element_text(size = 6), panel.grid.minor = element_blank(), plot.margin = margin(2, 2, 2, 2) ) + guides(fill = guide_legend(nrow = 1)) grid::pushViewport(grid::viewport(x = 0.27, y = charts_y - 0.028, width = chart_width, height = 0.065, just = c("center", "top"))) tryCatch(print(inning_plot, newpage = FALSE), error = function(e) NULL) grid::popViewport() } # ===== FB VELO BY PITCH # IN GAME (Right, top) ===== velo_by_pitch <- tryCatch( create_rolling_data_single(data, pitcher_name, "velo"), error = function(e) data.frame(pitch_num = integer(), value = numeric()) ) if (nrow(velo_by_pitch) > 0) { velo_pitch_plot <- ggplot(velo_by_pitch, aes(x = pitch_num, y = value)) + geom_line(color = "#006F71", linewidth = 1.2) + geom_point(color = "#006F71", size = 2) + geom_smooth(method = "loess", se = FALSE, color = "#E53935", linewidth = 0.8, linetype = "dashed", span = 0.75) + labs(x = "Pitch # in Game", y = "FB Velo (mph)", title = "FB Velo by Pitch Count") + scale_x_continuous(breaks = seq(0, 120, 20)) + theme_minimal() + theme( plot.title = element_text(hjust = 0.5, face = "bold", size = 9, color = "#006F71"), axis.text = element_text(size = 6), axis.title = element_text(size = 6), panel.grid.minor = element_blank(), plot.margin = margin(2, 2, 2, 2) ) grid::pushViewport(grid::viewport(x = 0.73, y = charts_y, width = chart_width, height = 0.045, just = c("center", "top"))) tryCatch(print(velo_pitch_plot, newpage = FALSE), error = function(e) NULL) grid::popViewport() } # ===== FB VELO BY MONTH (Right, bottom - stacked below pitch count velo) ===== monthly_velo <- tryCatch( create_monthly_fb_velo(data, pitcher_name), error = function(e) data.frame(month_date = as.Date(character()), avg_velo = numeric()) ) if (nrow(monthly_velo) > 0) { monthly_velo_plot <- ggplot(monthly_velo, aes(x = month_date, y = avg_velo)) + geom_line(color = "#CD853F", linewidth = 1.2) + geom_point(color = "#CD853F", size = 2.5) + geom_text(aes(label = sprintf("%.1f", avg_velo)), vjust = -1, size = 2, fontface = "bold") + labs(x = NULL, y = "FB Velo (mph)", title = "Avg FB Velo by Month") + scale_x_date(date_labels = "%b", date_breaks = "1 month") + theme_minimal() + theme( plot.title = element_text(hjust = 0.5, face = "bold", size = 9, color = "#CD853F"), axis.text = element_text(size = 6), axis.title = element_text(size = 6), panel.grid.minor = element_blank(), plot.margin = margin(2, 2, 2, 2) ) grid::pushViewport(grid::viewport(x = 0.73, y = charts_y - 0.048, width = chart_width, height = 0.045, just = c("center", "top"))) tryCatch(print(monthly_velo_plot, newpage = FALSE), error = function(e) NULL) grid::popViewport() } # ===== MATCHUP MATRIX (if provided) ===== if (!is.null(matchup_matrix) && !is.null(matchup_matrix$data) && nrow(matchup_matrix$data) > 0) { # Start new page for matchup matrix grid::grid.newpage() # Header grid::grid.rect(x = 0.5, y = 0.97, width = 1, height = 0.04, gp = grid::gpar(fill = "#006F71", col = NA)) grid::grid.text(paste0("Hitter Matchup Analysis: ", pitcher_name), x = 0.5, y = 0.97, gp = grid::gpar(fontsize = 16, fontface = "bold", col = "white")) # Draw matrix table df <- matchup_matrix$data pitch_types <- matchup_matrix$pitch_types # Add notes if available if (!is.null(matchup_matrix$notes)) { for (h_name in names(matchup_matrix$notes)) { if (h_name %in% df$Hitter) { df$Notes[df$Hitter == h_name] <- matchup_matrix$notes[[h_name]] } } } # Table layout n_rows <- nrow(df) n_cols <- 4 + length(pitch_types) + 1 # Hitter, Hand, BA, SLG, pitch types, Notes table_top <- 0.92 row_height <- min(0.025, 0.6 / (n_rows + 1)) # Header + data rows col_width <- 0.9 / n_cols start_x <- 0.05 # Column headers headers <- c("Hitter", "H", "BA", "SLG", pitch_types, "Notes") for (j in seq_along(headers)) { x_pos <- start_x + (j - 0.5) * col_width grid::grid.rect(x = x_pos, y = table_top, width = col_width * 0.95, height = row_height, gp = grid::gpar(fill = "#006F71", col = "white")) grid::grid.text(headers[j], x = x_pos, y = table_top, gp = grid::gpar(fontsize = 8, fontface = "bold", col = "white")) } # Data rows for (i in 1:n_rows) { y_pos <- table_top - i * row_height row_bg <- if (i %% 2 == 0) "#f5f5f5" else "white" # Get row data hitter <- df$Hitter[i] hand <- df$Hand[i] ba <- if ("BA" %in% names(df)) df$BA[i] else "-" slg <- if ("SLG" %in% names(df)) df$SLG[i] else "-" notes <- if ("Notes" %in% names(df)) df$Notes[i] else "" row_values <- c(hitter, hand, ba, slg) for (pt in pitch_types) { rv_val <- if (pt %in% names(df)) df[[pt]][i] else "-" row_values <- c(row_values, rv_val) } row_values <- c(row_values, notes) for (j in seq_along(row_values)) { x_pos <- start_x + (j - 0.5) * col_width # Color coding for RV/100 columns cell_bg <- row_bg if (j > 4 && j <= (4 + length(pitch_types))) { val <- row_values[j] if (!is.na(val) && val != "-") { if (grepl("^\\+", val)) cell_bg <- "#D9EF8B" # Positive = hitter advantage else if (grepl("^-", val)) cell_bg <- "#FDAE61" # Negative = pitcher advantage } } grid::grid.rect(x = x_pos, y = y_pos, width = col_width * 0.95, height = row_height, gp = grid::gpar(fill = cell_bg, col = "#ddd")) # Truncate long text display_text <- if (nchar(row_values[j]) > 20) paste0(substr(row_values[j], 1, 18), "...") else row_values[j] grid::grid.text(display_text, x = x_pos, y = y_pos, gp = grid::gpar(fontsize = 7)) } } # Legend grid::grid.text("RV/100: Green = Hitter Advantage | Orange = Pitcher Advantage | Based on similar pitches (velo/movement/spin)", x = 0.5, y = table_top - (n_rows + 1.5) * row_height, gp = grid::gpar(fontsize = 7, col = "gray50")) } # ===== FOOTER ===== grid::grid.text("Data: TrackMan | Coastal Carolina Baseball", x = 0.5, y = 0.01, gp = grid::gpar(fontsize = 8, col = "gray50")) invisible(output_file) } get_team_info <- function(team_name_input) { if (is.null(team_name_input) || team_name_input == "" || nrow(teams_data) == 0) { return(list( trackman_abbr = "", team_name = "", team_logo = "", league = "", primary_color = "#006F71", secondary_color = "#A27752", conference_logo = "" )) } row <- teams_data %>% dplyr::filter(team_name == team_name_input) if (nrow(row) == 0) { return(list( trackman_abbr = "", team_name = team_name_input, team_logo = "", league = "", primary_color = "#006F71", secondary_color = "#A27752", conference_logo = "" )) } list( trackman_abbr = row$trackman_abbr[1], team_name = row$team_name[1], team_logo = ifelse(is.na(row$team_logo[1]), "", row$team_logo[1]), league = ifelse(is.na(row$league[1]), "", row$league[1]), primary_color = ifelse(is.na(row$primary_color[1]), "#006F71", row$primary_color[1]), secondary_color = ifelse(is.na(row$secondary_color[1]), "#A27752", row$secondary_color[1]), conference_logo = ifelse(is.na(row$conference_logo[1]), "", row$conference_logo[1]) ) } create_condensed_pitcher_report <- function(data, pitcher_names, output_file, report_title = "Opposing Pitchers Report", pitcher_notes = list(), team_info = NULL) { if (length(pitcher_names) == 0) return(NULL) # ---- Extract team theming ---- if (is.null(team_info)) { team_info <- list( team_name = "", team_logo = "", league = "", primary_color = "#006F71", secondary_color = "#A27752", conference_logo = "", record = "" ) } # Team colors for theming tc_primary <- team_info$primary_color tc_secondary <- team_info$secondary_color # Validate hex colors is_valid_hex <- function(col) { grepl("^#[0-9A-Fa-f]{6}$", col) } if (!is_valid_hex(tc_primary)) tc_primary <- "#006F71" if (!is_valid_hex(tc_secondary)) tc_secondary <- "#A27752" # ---- Helper: gradient color (red-white-green) ---- get_pill_color <- function(value, benchmark, higher_is_better = TRUE, range_pct = 0.25) { if (is.na(value) || is.na(benchmark)) return("#CCCCCC") range_val <- benchmark * range_pct min_val <- benchmark - range_val max_val <- benchmark + range_val normalized <- if (higher_is_better) { (value - min_val) / (max_val - min_val) } else { (max_val - value) / (max_val - min_val) } normalized <- pmax(0, pmin(1, normalized)) scales::gradient_n_pal(c("#E1463E", "#FFFFFF", "#00840D"))(normalized) } # ---- Helper: mini pie chart ---- create_mini_pie <- function(pitch_data) { if (is.null(pitch_data) || nrow(pitch_data) == 0) return(ggplot() + theme_void()) pie_df <- pitch_data %>% dplyr::count(TaggedPitchType, name = "n") %>% dplyr::mutate(pct = 100 * n / sum(n)) %>% dplyr::filter(pct >= 3) %>% dplyr::arrange(dplyr::desc(pct)) %>% dplyr::mutate(pct_label = ifelse(pct >= 8, paste0(round(pct), "%"), "")) if (nrow(pie_df) == 0) return(ggplot() + theme_void()) ggplot(pie_df, aes(x = "", y = pct, fill = TaggedPitchType)) + geom_bar(width = 1, stat = "identity", color = "white", linewidth = 0.15) + geom_text(aes(label = pct_label), position = position_stack(vjust = 0.5), size = 1.1, fontface = "bold") + coord_polar("y", start = 0) + scale_fill_manual(values = pitch_colors, drop = FALSE) + theme_void() + theme(legend.position = "none", plot.margin = margin(0,0,0,0)) } # ---- Helper: condensed movement chart ---- create_mini_movement <- function(data, pitcher_name, expected_grid = NULL) { pitcher_data <- data %>% dplyr::filter(Pitcher == pitcher_name, !is.na(HorzBreak), !is.na(InducedVertBreak), !is.na(TaggedPitchType), TaggedPitchType != "Other", !is.na(RelSpeed)) %>% dplyr::mutate( RelSide = as.numeric(RelSide), RelHeight = as.numeric(RelHeight), HorzBreak = as.numeric(HorzBreak), InducedVertBreak = as.numeric(InducedVertBreak), RelSpeed = as.numeric(RelSpeed) ) if (nrow(pitcher_data) < 5) { return(ggplot() + theme_void() + annotate("text", x=0, y=0, label="Low n", size=2)) } pitcher_throws <- pitcher_data$PitcherThrows[1] if (is.na(pitcher_throws)) pitcher_throws <- "Right" is_lefty <- pitcher_throws == "Left" h_in <- if ("height_inches" %in% names(pitcher_data)) { med_h <- median(pitcher_data$height_inches, na.rm = TRUE) if (is.na(med_h)) 72 else med_h } else 72 shoulder_pos <- h_in * 0.70 p_mean <- pitcher_data %>% dplyr::group_by(TaggedPitchType) %>% dplyr::summarize( HorzBreak = mean(HorzBreak, na.rm=TRUE), InducedVertBreak = mean(InducedVertBreak, na.rm=TRUE), mean_velo = round(mean(RelSpeed, na.rm=TRUE), 1), mean_rel_height = mean(RelHeight, na.rm=TRUE), mean_rel_side = mean(RelSide, na.rm=TRUE), usage = dplyr::n(), .groups = "drop" ) %>% dplyr::mutate( scaled_usage = pmin((usage - min(usage)) / max((max(usage) - min(usage)), 1) * (5 - 3) + 3, 6), RH_bin = round(mean_rel_height / 0.10) * 0.10 ) %>% dplyr::filter(usage >= 3) # Expected movement p_mean$exp_hb <- NA_real_; p_mean$exp_ivb <- NA_real_; p_mean$has_expected <- FALSE if (!is.null(expected_grid) && nrow(expected_grid) > 0) { for (i in seq_len(nrow(p_mean))) { match_row <- expected_grid %>% dplyr::filter(TaggedPitchType == p_mean$TaggedPitchType[i], abs(RH_bin - p_mean$RH_bin[i]) < 0.15) if (nrow(match_row) > 0) { match_row <- match_row %>% dplyr::mutate(dist = abs(RH_bin - p_mean$RH_bin[i])) %>% dplyr::arrange(dist) %>% dplyr::slice(1) p_mean$exp_hb[i] <- if (is_lefty) -match_row$exp_hb[1] else match_row$exp_hb[1] p_mean$exp_ivb[i] <- match_row$exp_ivb[1] p_mean$has_expected[i] <- TRUE } } } arm_angle_savant <- median(pitcher_data$arm_angle_savant, na.rm = TRUE) if (is.na(arm_angle_savant)) arm_angle_savant <- 45 arm_angle_type <- if (arm_angle_savant >= 60) "OH" else if (arm_angle_savant >= 47) "H3/4" else if (arm_angle_savant >= 40) "3/4" else if (arm_angle_savant >= 10) "L3/4" else "Side" # Arm angle rays ray_length <- 14 arm_rays <- p_mean %>% dplyr::mutate( rel_x_raw = ifelse(is_lefty, -abs(mean_rel_side), abs(mean_rel_side)), rel_y_raw = mean_rel_height - (shoulder_pos / 12), rel_mag = pmax(sqrt(rel_x_raw^2 + rel_y_raw^2), 0.01), dir_x = rel_x_raw / rel_mag, dir_y = rel_y_raw / rel_mag, ray_xs = 0, ray_ys = 0, ray_xe = dir_x * ray_length, ray_ye = dir_y * ray_length ) circle_df <- data.frame(x = 28*cos(seq(0, 2*base::pi, length.out=100)), y = 28*sin(seq(0, 2*base::pi, length.out=100))) p <- ggplot(pitcher_data, aes(x = HorzBreak, y = InducedVertBreak)) + geom_polygon(data=circle_df, aes(x=x, y=y), fill="#e5f3f3", color="#e5f3f3", inherit.aes=FALSE) + geom_path(data=data.frame(x=12*cos(seq(0, 2*base::pi, length.out=100)), y=12*sin(seq(0, 2*base::pi, length.out=100))), aes(x=x,y=y), linetype="dashed", color="gray70", linewidth=0.15, inherit.aes=FALSE) + geom_path(data=data.frame(x=24*cos(seq(0, 2*base::pi, length.out=100)), y=24*sin(seq(0, 2*base::pi, length.out=100))), aes(x=x,y=y), linetype="dashed", color="gray70", linewidth=0.15, inherit.aes=FALSE) + geom_segment(x=0, y=-30, xend=0, yend=30, linewidth=0.2, color="grey60") + geom_segment(x=-30, y=0, xend=30, yend=0, linewidth=0.2, color="grey60") + annotate('text', x=0, y=31, label='12', size=1.8, fontface="bold") + annotate('text', x=31, y=0, label='3', size=1.8, fontface="bold") + annotate('text', x=0, y=-31, label='6', size=1.8, fontface="bold") + annotate('text', x=-31, y=0, label='9', size=1.8, fontface="bold") + annotate('text', x=12, y=-1.5, label='12"', size=1.2, color="gray50") + annotate('text', x=24, y=-1.5, label='24"', size=1.2, color="gray50") if (nrow(arm_rays) > 0) { p <- p + geom_segment(data=arm_rays, aes(x=ray_xs,y=ray_ys,xend=ray_xe,yend=ray_ye,color=TaggedPitchType), linewidth=0.8, alpha=0.5, inherit.aes=FALSE) + geom_point(data=arm_rays, aes(x=ray_xe,y=ray_ye,fill=TaggedPitchType), shape=21, size=0.8, stroke=0.15, color="black", alpha=0.6, inherit.aes=FALSE) } p <- p + geom_point(aes(fill=TaggedPitchType), shape=21, size=0.8, color="black", stroke=0.1, alpha=0.6) exp_data <- p_mean %>% dplyr::filter(has_expected == TRUE) if (nrow(exp_data) > 0) { p <- p + geom_point(data=exp_data, aes(x=exp_hb, y=exp_ivb, fill=TaggedPitchType), shape=23, size=2.5, stroke=0.7, color="black", alpha=0.6, inherit.aes=FALSE) } p <- p + geom_point(data=p_mean, aes(x=HorzBreak, y=InducedVertBreak, fill=TaggedPitchType, size=scaled_usage), shape=21, color="black", stroke=0.8, alpha=0.85, inherit.aes=FALSE) + geom_label(data=p_mean, aes(x=HorzBreak, y=InducedVertBreak, label=mean_velo), color="black", size=1.2, fontface="bold", fill="white", linewidth=0.1, label.padding=unit(0.06, "lines"), inherit.aes=FALSE) + scale_size_identity() p <- p + coord_fixed(xlim=c(-34, 34), ylim=c(-34, 34), clip="on") + scale_fill_manual(values=pitch_colors, drop=FALSE) + scale_color_manual(values=pitch_colors, drop=FALSE) + annotate("text", x=0, y=37, label=paste0(round(arm_angle_savant,0), "\u00b0 ", arm_angle_type), size=1.8, color=tc_primary, fontface="bold") + theme_void() + theme(plot.background=element_rect(fill="white", color=NA), legend.position="none", plot.margin=margin(0,0,0,0)) return(p) } # ---- Helper: mini heatmap ---- create_mini_heatmap <- function(data, min_pitches = 5) { base_plot <- ggplot() + annotate("rect", xmin=-0.83, xmax=0.83, ymin=1.5, ymax=3.5, fill=NA, color="black", linewidth=0.3) + 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.25) + coord_fixed(xlim=c(-2,2), ylim=c(0,4.5)) + theme_void() + theme(legend.position="none", plot.margin=margin(0,0,0,0)) if (is.null(data) || nrow(data) < min_pitches) return(base_plot) plot_data <- data %>% dplyr::filter(!is.na(PlateLocSide), !is.na(PlateLocHeight)) if (nrow(plot_data) < min_pitches) return(base_plot) if (nrow(plot_data) < 15) { return(base_plot + geom_point(data=plot_data, aes(x=PlateLocSide, y=PlateLocHeight), color=tc_primary, alpha=0.7, size=0.6)) } ggplot(plot_data, aes(x=PlateLocSide, y=PlateLocHeight)) + stat_density_2d(aes(fill=after_stat(density)), geom="raster", contour=FALSE, n=80) + scale_fill_gradientn(colours=c("white","#0551bc","#02fbff","#03ff00","#fbff00", "#ffa503","#ff1f02","#dc1100"), guide="none") + annotate("rect", xmin=-0.83, xmax=0.83, ymin=1.5, ymax=3.5, fill=NA, color="black", linewidth=0.3) + 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.25) + coord_fixed(xlim=c(-2,2), ylim=c(0,4.5)) + theme_void() + theme(legend.position="none", plot.margin=margin(0,0,0,0)) } # ---- Helper: mini whiff heatmap (new) ---- create_mini_whiff_heatmap <- function(data, side, min_pitches = 5) { whiff_data <- data %>% dplyr::filter( BatterSide == side, PitchCall == "StrikeSwinging", !is.na(PlateLocSide), !is.na(PlateLocHeight) ) create_mini_heatmap(whiff_data, min_pitches) } # ---- Helper: mini nitro zone plot ---- create_mini_nitro <- function(pitch_data, side) { nitro_data <- pitch_data %>% dplyr::filter( BatterSide == side, PitchCall == "InPlay", !is.na(ExitSpeed), !is.na(PlateLocSide), !is.na(PlateLocHeight) ) base_plot <- ggplot() + annotate("rect", xmin=-0.83, xmax=0.83, ymin=1.5, ymax=3.5, fill="#FFF5F5", color="black", linewidth=0.3) + 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.25) + coord_fixed(xlim=c(-2,2), ylim=c(0,4.5)) + theme_void() + theme(legend.position="none", plot.margin=margin(0,0,0,0)) if (nrow(nitro_data) < 8) return(base_plot) ev_90 <- quantile(nitro_data$ExitSpeed, 0.90, na.rm = TRUE) nitro_pts <- nitro_data %>% dplyr::filter(ExitSpeed >= ev_90) if (nrow(nitro_pts) < 2) return(base_plot) base_plot + geom_point(data = nitro_pts, aes(x = PlateLocSide, y = PlateLocHeight, size = ExitSpeed), color = "#DC143C", alpha = 0.8) + scale_size_continuous(range = c(0.8, 2.5), guide = "none") } # ---- Helper: pitch abbreviation ---- get_pt_short <- function(pt) { dplyr::case_when( pt %in% c("Fastball","Four-Seam","FourSeamFastBall") ~ "FB", pt %in% c("Sinker","TwoSeamFastBall","Two-Seam") ~ "SI", pt %in% c("Slider","Sweeper") ~ "SL", pt == "Curveball" ~ "CB", pt %in% c("Changeup","ChangeUp") ~ "CH", pt == "Splitter" ~ "SP", pt == "Cutter" ~ "CT", pt == "Slurve" ~ "SV", pt == "Knuckle Curve" ~ "KC", TRUE ~ "Ot" ) } fb_pitches <- c("Fastball","Sinker","FourSeamFastBall","TwoSeamFastBall","Four-Seam","Two-Seam") bb_pitches <- c("Slider","Curveball","Cutter","Sweeper","Slurve","Knuckle Curve") os_pitches <- c("ChangeUp","Changeup","Splitter") expected_grid <- tryCatch(build_expected_movement_grid(data), error=function(e) NULL) # ---- Helper: resolve a URL to a downloadable image URL ---- # Handles Wikipedia Commons "File:" page URLs by converting to Special:FilePath resolve_image_url <- function(url) { if (is.null(url) || is.na(url) || nchar(url) == 0) return("") # If it's a commons.wikimedia.org/wiki/File: URL, convert to Special:FilePath if (grepl("commons\\.wikimedia\\.org/wiki/File:", url)) { # Extract filename from URL like: https://commons.wikimedia.org/wiki/File:Big_Ten_Conference_logo.svg filename <- sub(".*wiki/File:", "", url) filename <- utils::URLdecode(filename) # Use Special:FilePath which redirects to the actual image # Add width parameter to get a PNG thumbnail (works even for SVGs) resolved <- paste0("https://commons.wikimedia.org/wiki/Special:FilePath/", utils::URLencode(filename), "?width=200") return(resolved) } # If it's already a direct upload.wikimedia.org URL, use as-is return(url) } # ---- Helper: download an image URL to a raster for grid rendering ---- download_logo_raster <- function(url, label = "logo") { if (is.null(url) || nchar(url) == 0) return(NULL) resolved_url <- resolve_image_url(url) if (nchar(resolved_url) == 0) return(NULL) tryCatch({ tmp_file <- tempfile(fileext = ".png") # Follow redirects (important for Special:FilePath) resp <- httr::GET(resolved_url, httr::timeout(8), httr::config(followlocation = TRUE)) if (httr::status_code(resp) == 200) { content_type <- httr::headers(resp)[["content-type"]] raw_content <- httr::content(resp, "raw") writeBin(raw_content, tmp_file) # Try reading as PNG first raster <- tryCatch(png::readPNG(tmp_file), error = function(e) NULL) if (!is.null(raster)) return(raster) # If PNG failed, try JPEG if (requireNamespace("jpeg", quietly = TRUE)) { raster <- tryCatch(jpeg::readJPEG(tmp_file), error = function(e) NULL) if (!is.null(raster)) return(raster) } cat("Could not decode ", label, " image (content-type: ", content_type, ")\n") } else { cat("Failed to download ", label, " (status ", httr::status_code(resp), ")\n") } NULL }, error = function(e) { cat("Error downloading ", label, ": ", e$message, "\n") NULL }) } # ---- Download team logo and conference logo ---- team_logo_raster <- download_logo_raster(team_info$team_logo, "team logo") conf_logo_raster <- download_logo_raster(team_info$conference_logo, "conference logo") # ============================================================================ # PORTRAIT MODE: 11 x 17 (tabloid portrait) # ============================================================================ pdf(output_file, width = 11, height = 17) on.exit(try(dev.off(), silent = TRUE), add = TRUE) n_pitchers <- length(pitcher_names) pitchers_page1 <- 4 pitchers_later <- 5 if (n_pitchers <= pitchers_page1) { n_pages <- 1 } else { n_pages <- 1 + ceiling((n_pitchers - pitchers_page1) / pitchers_later) } # Safety: ensure we never skip pitchers pitchers_rendered <- 0 for (page_num in 1:n_pages) { grid::grid.newpage() if (page_num == 1) { start_idx <- 1 end_idx <- min(pitchers_page1, n_pitchers) } else { start_idx <- pitchers_page1 + (page_num - 2) * pitchers_later + 1 end_idx <- min(start_idx + pitchers_later - 1, n_pitchers) } # Safety: skip if start_idx somehow exceeds total if (start_idx > n_pitchers) next end_idx <- min(end_idx, n_pitchers) page_pitchers <- pitcher_names[start_idx:end_idx] n_on_page <- length(page_pitchers) pitchers_rendered <- pitchers_rendered + n_on_page # ===== PAGE HEADER - TEAM BRANDED ===== header_h <- 0.028 # Primary color header bar grid::grid.rect(x=0.5, y=1 - header_h/2, width=1, height=header_h, gp=grid::gpar(fill=tc_primary, col=NA)) # Secondary color accent stripe at bottom of header grid::grid.rect(x=0.5, y=1 - header_h - 0.001, width=1, height=0.003, gp=grid::gpar(fill=tc_secondary, col=NA)) # Team logo - LEFT CORNER of header (bigger and more visible) logo_x_offset <- 0.040 if (!is.null(team_logo_raster)) { tryCatch({ grid::grid.raster(team_logo_raster, x = logo_x_offset, y = 1 - header_h/2, width = unit(0.035, "npc"), height = unit(header_h * 0.90, "npc"), just = c("center", "center")) logo_x_offset <- 0.065 # shift conference logo to the right of team logo }, error = function(e) NULL) } # Conference logo - TOP LEFT, next to team logo if (!is.null(conf_logo_raster)) { tryCatch({ grid::grid.raster(conf_logo_raster, x = logo_x_offset + 0.022, y = 1 - header_h/2, width = unit(0.025, "npc"), height = unit(header_h * 0.75, "npc"), just = c("center", "center")) }, error = function(e) NULL) } # Title text - centered title_text <- if (nchar(team_info$team_name) > 0) { paste0(team_info$team_name, " - Opposing Pitcher Report") } else { report_title } grid::grid.text(title_text, x=0.5, y=1 - header_h * 0.35, gp=grid::gpar(fontsize=11, fontface="bold", col="white")) # Record + conference subtitle text (centered) subtitle_parts <- c() if (nchar(team_info$record) > 0) subtitle_parts <- c(subtitle_parts, team_info$record) if (nchar(team_info$league) > 0) subtitle_parts <- c(subtitle_parts, team_info$league) subtitle_text <- paste(subtitle_parts, collapse = " | ") if (length(subtitle_parts) > 0) { grid::grid.text(subtitle_text, x=0.5, y=1 - header_h * 0.72, gp=grid::gpar(fontsize=7, col="white")) } # Date + page number (right side) grid::grid.text(format(Sys.Date(), "%B %d, %Y"), x=0.92, y=1 - header_h * 0.35, just="right", gp=grid::gpar(fontsize=6, col="white")) grid::grid.text(paste0("Page ", page_num, " of ", n_pages), x=0.92, y=1 - header_h * 0.72, just="right", gp=grid::gpar(fontsize=5, col="white")) # ===== TOP CHARTS (Page 1 only) ===== charts_bottom <- 1 - header_h - 0.004 if (page_num == 1) { chart_section_height <- 0.22 chart_top <- charts_bottom - 0.002 chart_center_y <- chart_top - chart_section_height / 2 # --- LEFT: Pitch Count Bar Chart --- as_of_date <- Sys.Date() usage_data <- data %>% dplyr::filter(Pitcher %in% pitcher_names) %>% dplyr::mutate(Date = as.Date(Date)) %>% dplyr::filter(Date <= as_of_date) %>% dplyr::group_by(Pitcher, Date) %>% dplyr::summarise(pitches = dplyr::n(), .groups="drop") %>% dplyr::group_by(Pitcher) %>% dplyr::summarise( last_1_week = sum(pitches[Date > (as_of_date - 7)], na.rm=TRUE), last_2_weeks = sum(pitches[Date > (as_of_date - 14)], na.rm=TRUE), .groups="drop" ) %>% tidyr::pivot_longer(cols=c(last_1_week, last_2_weeks), names_to="period", values_to="pitches") if (nrow(usage_data) > 0) { pitcher_order <- usage_data %>% dplyr::filter(period=="last_2_weeks") %>% dplyr::arrange(dplyr::desc(pitches)) %>% dplyr::pull(Pitcher) usage_data$Pitcher <- factor(usage_data$Pitcher, levels=rev(pitcher_order)) usage_data$period <- factor(usage_data$period, levels=c("last_1_week","last_2_weeks"), labels=c("Last 1 Week","Last 2 Weeks")) usage_plot <- ggplot(usage_data, aes(x=pitches, y=Pitcher, fill=period)) + geom_col(position=position_dodge(width=0.8), width=0.7) + geom_text(aes(label=ifelse(pitches>0, pitches, "")), position=position_dodge(width=0.8), hjust=-0.2, size=1.8, fontface="bold") + scale_fill_manual(values=c("Last 1 Week"=tc_primary, "Last 2 Weeks"=tc_secondary)) + labs(x="Pitches", y=NULL, fill=NULL) + theme_minimal() + theme(legend.position="top", legend.key.size=unit(0.25,"cm"), legend.text=element_text(size=5), axis.text.y=element_text(size=5, face="bold"), axis.text.x=element_text(size=4.5), panel.grid.major.y=element_blank(), plot.margin=margin(2,6,2,2)) + scale_x_continuous(expand=expansion(mult=c(0, 0.20))) grid::pushViewport(grid::viewport(x=0.25, y=chart_center_y, width=0.46, height=chart_section_height, just=c("center","center"))) tryCatch(print(usage_plot, newpage=FALSE), error=function(e) NULL) grid::popViewport() } # --- RIGHT: Usage by Inning Heatmap --- inning_data <- data %>% dplyr::filter(Pitcher %in% pitcher_names, !is.na(Inning), !is.na(TaggedPitchType), TaggedPitchType != "Other") %>% dplyr::mutate(Inning = ifelse(Inning > 9, "X", as.character(Inning))) %>% dplyr::group_by(Pitcher, Inning) %>% dplyr::summarise(n=dplyr::n(), .groups="drop") if (nrow(inning_data) > 0) { all_innings <- c("1","2","3","4","5","6","7","8","9","X") complete_grid <- expand.grid(Pitcher=pitcher_names, Inning=all_innings, stringsAsFactors=FALSE) inning_data <- complete_grid %>% dplyr::left_join(inning_data, by=c("Pitcher","Inning")) %>% dplyr::mutate(n = ifelse(is.na(n), 0, n)) inning_data$Inning <- factor(inning_data$Inning, levels=all_innings) inning_data$Pitcher <- factor(inning_data$Pitcher, levels=rev(pitcher_names)) inning_plot <- ggplot(inning_data, aes(x=Inning, y=Pitcher, fill=n)) + geom_tile(color="white", linewidth=0.3) + geom_text(aes(label=ifelse(n>0, n, "")), size=1.8, fontface="bold") + scale_fill_gradient2(low="red", mid="white", high="#00840D", midpoint=max(inning_data$n[inning_data$n>0], na.rm=TRUE)/2, guide="none") + scale_x_discrete(position="top") + labs(x=NULL, y=NULL) + theme_minimal() + theme(axis.text.x=element_text(size=5, face="bold"), axis.text.y=element_text(size=4.5), panel.grid=element_blank(), plot.margin=margin(2,2,2,2)) grid::pushViewport(grid::viewport(x=0.75, y=chart_center_y, width=0.46, height=chart_section_height, just=c("center","center"))) tryCatch(print(inning_plot, newpage=FALSE), error=function(e) NULL) grid::popViewport() } charts_bottom <- chart_top - chart_section_height - 0.002 } # ===== COLUMN HEADERS ROW - TEAM COLORED ===== col_header_y <- charts_bottom - 0.001 col_header_h <- 0.012 grid::grid.rect(x=0.5, y=col_header_y, width=0.98, height=col_header_h, gp=grid::gpar(fill=tc_secondary, col=NA), just=c("center","top")) hf <- grid::gpar(fontsize=5, fontface="bold", col="white") grid::grid.text("#/Cl", x=0.02, y=col_header_y - col_header_h/2, just="left", gp=hf) grid::grid.text("Pitcher Name", x=0.065, y=col_header_y - col_header_h/2, just="left", gp=hf) grid::grid.text("RA9", x=0.165, y=col_header_y - col_header_h/2, gp=hf) grid::grid.text("IP", x=0.205, y=col_header_y - col_header_h/2, gp=hf) grid::grid.text("FIP", x=0.245, y=col_header_y - col_header_h/2, gp=hf) grid::grid.text("K%", x=0.285, y=col_header_y - col_header_h/2, gp=hf) grid::grid.text("BB%", x=0.325, y=col_header_y - col_header_h/2, gp=hf) grid::grid.text("Stuff+",x=0.365, y=col_header_y - col_header_h/2, gp=hf) grid::grid.text("Movement", x=0.46, y=col_header_y - col_header_h/2, gp=hf) # Right section sub-headers pie_header_gp <- grid::gpar(fontsize=4, fontface="bold", col="white") grid::grid.text("Overall", x=0.555, y=col_header_y - col_header_h/2, gp=pie_header_gp) grid::grid.text("1st", x=0.595, y=col_header_y - col_header_h/2, gp=pie_header_gp) grid::grid.text("2K", x=0.635, y=col_header_y - col_header_h/2, gp=pie_header_gp) # Heatmap headers hm_header_x <- c(0.680, 0.725, 0.770) hm_pill_colors <- c("#3465cb","#65aa02","#980099") hm_pill_labels <- c("FB","BB","OS") for (ci in 1:3) { grid::grid.roundrect(x=hm_header_x[ci], y=col_header_y - col_header_h/2, width=0.026, height=col_header_h*0.7, r=unit(0.3,"snpc"), gp=grid::gpar(fill=hm_pill_colors[ci], col=NA)) grid::grid.text(hm_pill_labels[ci], x=hm_header_x[ci], y=col_header_y - col_header_h/2, gp=grid::gpar(fontsize=3.5, fontface="bold", col="white")) } # Whiff header grid::grid.roundrect(x=0.825, y=col_header_y - col_header_h/2, width=0.040, height=col_header_h*0.7, r=unit(0.3,"snpc"), gp=grid::gpar(fill="#FF6600", col=NA)) grid::grid.text("Whiff", x=0.825, y=col_header_y - col_header_h/2, gp=grid::gpar(fontsize=3.5, fontface="bold", col="white")) # Nitro Zones header grid::grid.roundrect(x=0.885, y=col_header_y - col_header_h/2, width=0.040, height=col_header_h*0.7, r=unit(0.3,"snpc"), gp=grid::gpar(fill="#DC143C", col=NA)) grid::grid.text("Nitro", x=0.885, y=col_header_y - col_header_h/2, gp=grid::gpar(fontsize=3.5, fontface="bold", col="white")) # ===== PITCHER BLOCKS ===== footer_h <- 0.005 available_top <- col_header_y - col_header_h available_bottom <- footer_h + 0.003 available_height <- available_top - available_bottom max_pitchers_this_page <- if (page_num == 1) pitchers_page1 else pitchers_later block_height <- available_height / max_pitchers_this_page current_y <- available_top for (p_idx in seq_along(page_pitchers)) { pitcher_name <- page_pitchers[p_idx] p_data <- data %>% dplyr::filter(Pitcher == pitcher_name) %>% process_pitcher_indicators() if (nrow(p_data) < 10) { current_y <- current_y - block_height; next } stats <- calculate_advanced_pitcher_stats(data, pitcher_name) if (is.null(stats)) { current_y <- current_y - block_height; next } block_top <- current_y block_bottom <- current_y - block_height + 0.001 block_mid <- (block_top + block_bottom) / 2 bg_color <- if (p_idx %% 2 == 1) "#FFFFFF" else "#F5F5F5" grid::grid.rect(x=0.5, y=block_mid, width=0.98, height=block_height - 0.001, gp=grid::gpar(fill=bg_color, col="#D0D0D0", lwd=0.5)) # Vertical divider lines grid::grid.lines(x=c(0.40, 0.40), y=c(block_top, block_bottom), gp=grid::gpar(col="#D0D0D0", lwd=0.5)) grid::grid.lines(x=c(0.535, 0.535), y=c(block_top, block_bottom), gp=grid::gpar(col="#D0D0D0", lwd=0.5)) # ---- LEFT SECTION: Name, Stats, Arsenal WITH Stuff+/Whiff%/Strike%, # then Spray Charts, then Go/No Zones ---- pitcher_num <- start_idx + p_idx - 1 hand_color <- if (!is.null(stats$pitcher_hand) && stats$pitcher_hand == "Left") "#E53935" else "#1E88E5" hand_label <- if (!is.null(stats$pitcher_hand) && stats$pitcher_hand == "Left") "L" else "R" p_number <- pitcher_notes[[pitcher_name]]$number %||% as.character(pitcher_num) p_class <- pitcher_notes[[pitcher_name]]$class %||% "" # Number badge - team colored grid::grid.rect(x=0.025, y=block_top - 0.008, width=0.023, height=0.013, gp=grid::gpar(fill=tc_primary, col=NA)) grid::grid.text(p_number, x=0.025, y=block_top - 0.008, gp=grid::gpar(fontsize=7, fontface="bold", col="white")) # Hand badge grid::grid.rect(x=0.048, y=block_top - 0.008, width=0.011, height=0.011, gp=grid::gpar(fill=hand_color, col=NA)) grid::grid.text(hand_label, x=0.048, y=block_top - 0.008, gp=grid::gpar(fontsize=4.5, fontface="bold", col="white")) # Name name_parts <- strsplit(pitcher_name, ", ")[[1]] display_name <- if (length(name_parts)==2) paste(name_parts[2], name_parts[1]) else pitcher_name grid::grid.text(display_name, x=0.062, y=block_top - 0.008, just="left", gp=grid::gpar(fontsize=7, fontface="bold", col="#333")) if (nchar(p_class) > 0) { grid::grid.text(p_class, x=0.062, y=block_top - 0.015, just="left", gp=grid::gpar(fontsize=4.5, fontface="italic", col="#666")) } # Stat pills - team colored headers stat_y <- block_top - 0.009 stat_positions <- c(0.164, 0.204, 0.244, 0.284, 0.324, 0.364) stat_values <- c( if (!is.na(stats$ra9)) sprintf("%.1f", stats$ra9) else "-", if (!is.na(stats$ip)) sprintf("%.1f", stats$ip) else "-", if (!is.na(stats$fip)) sprintf("%.2f", stats$fip) else "-", if (!is.na(stats$k_pct)) paste0(round(stats$k_pct), "%") else "-", if (!is.na(stats$bb_pct)) paste0(sprintf("%.1f", stats$bb_pct), "%") else "-", "100" ) stat_benchmarks <- c(4.75, 15, 4.22, 26, 10, 100) stat_higher <- c(FALSE, TRUE, FALSE, TRUE, FALSE, TRUE) stat_range <- c(0.30, 0.50, 0.30, 0.30, 0.30, 0.15) if ("stuff_plus" %in% names(p_data)) { sp_val <- mean(p_data$stuff_plus, na.rm=TRUE) if (!is.na(sp_val)) stat_values[6] <- as.character(round(sp_val)) } stat_nums <- suppressWarnings(as.numeric(gsub("%", "", stat_values))) for (s_idx in seq_along(stat_positions)) { pill_bg <- if (!is.na(stat_nums[s_idx])) { get_pill_color(stat_nums[s_idx], stat_benchmarks[s_idx], stat_higher[s_idx], stat_range[s_idx]) } else "#EEEEEE" grid::grid.roundrect(x=stat_positions[s_idx], y=stat_y, width=0.033, height=0.012, r=unit(0.3,"snpc"), gp=grid::gpar(fill=pill_bg, col="#BBBBBB", lwd=0.3)) grid::grid.text(stat_values[s_idx], x=stat_positions[s_idx], y=stat_y, gp=grid::gpar(fontsize=5.5, fontface="bold")) } # ---- ARSENAL with Stuff+, Whiff%, Strike% per pitch ---- arsenal_y <- block_top - 0.022 pitch_summary <- p_data %>% dplyr::mutate( is_swing_ps = as.integer(PitchCall %in% c("StrikeSwinging", "FoulBall", "FoulBallNotFieldable", "FoulBallFieldable", "InPlay")), is_whiff_ps = as.integer(PitchCall == "StrikeSwinging"), is_strike_ps = as.integer(!PitchCall %in% c("BallCalled", "BallinDirt", "BallIntentional")) ) %>% dplyr::group_by(TaggedPitchType) %>% dplyr::summarise(n=dplyr::n(), velo_low=round(stats::quantile(RelSpeed,0.1,na.rm=TRUE),0), velo_high=round(stats::quantile(RelSpeed,0.9,na.rm=TRUE),0), top_velo=round(max(RelSpeed,na.rm=TRUE),0), stuff_avg = if ("stuff_plus" %in% names(.data)) round(mean(stuff_plus, na.rm=TRUE),0) else NA_integer_, whiff_pct = ifelse(sum(is_swing_ps, na.rm=TRUE) > 0, round(100 * sum(is_whiff_ps, na.rm=TRUE) / sum(is_swing_ps, na.rm=TRUE), 0), 0L), strike_pct = round(100 * sum(is_strike_ps, na.rm=TRUE) / dplyr::n(), 0), .groups="drop") %>% dplyr::mutate(pct=round(100*n/sum(n),0)) %>% dplyr::arrange(dplyr::desc(pct)) %>% head(5) pitch_col_width <- 0.076 for (i in 1:min(nrow(pitch_summary), 5)) { pt <- pitch_summary$TaggedPitchType[i] px <- 0.015 + (i-1) * pitch_col_width pt_center_x <- px + 0.020 pt_short <- get_pt_short(pt) pill_color <- pitch_colors[[pt]] %||% "gray50" # Pitch type pill grid::grid.roundrect(x=pt_center_x, y=arsenal_y, width=0.028, height=0.009, r=unit(0.3,"snpc"), gp=grid::gpar(fill=pill_color, col=NA)) grid::grid.text(pt_short, x=pt_center_x, y=arsenal_y, gp=grid::gpar(fontsize=4.5, fontface="bold", col="white")) # Velo range grid::grid.text(paste0(pitch_summary$velo_low[i],"-",pitch_summary$velo_high[i]), x=pt_center_x, y=arsenal_y - 0.008, gp=grid::gpar(fontsize=3.8, col="#333")) # Top velo grid::grid.text(paste0("T",pitch_summary$top_velo[i]), x=pt_center_x, y=arsenal_y - 0.014, gp=grid::gpar(fontsize=3.8, fontface="bold", col=tc_primary)) # ---- Per-pitch pills: S+, Whiff%, Strike% (vertically aligned) ---- # All three pills centered on pt_center_x, stacked vertically pill_w <- 0.032 pill_h <- 0.007 # Row 1: Stuff+ (if available) stuff_y <- arsenal_y - 0.022 if (!is.na(pitch_summary$stuff_avg[i])) { stuff_col <- get_pill_color(pitch_summary$stuff_avg[i], 100, TRUE, 0.15) grid::grid.roundrect(x=pt_center_x, y=stuff_y, width=pill_w, height=pill_h, r=unit(0.3,"snpc"), gp=grid::gpar(fill=stuff_col, col=pill_color, lwd=0.4)) grid::grid.text(paste0("S+", pitch_summary$stuff_avg[i]), x=pt_center_x, y=stuff_y, gp=grid::gpar(fontsize=3.2, fontface="bold")) } # Row 2: Whiff% whiff_y <- arsenal_y - 0.030 whiff_col <- get_pill_color(pitch_summary$whiff_pct[i], 29, TRUE, 0.30) grid::grid.roundrect(x=pt_center_x, y=whiff_y, width=pill_w, height=pill_h, r=unit(0.3,"snpc"), gp=grid::gpar(fill=whiff_col, col=pill_color, lwd=0.4)) grid::grid.text(paste0("W", pitch_summary$whiff_pct[i], "%"), x=pt_center_x, y=whiff_y, gp=grid::gpar(fontsize=3.2, fontface="bold")) # Row 3: Strike% strike_y <- arsenal_y - 0.038 str_col <- get_pill_color(pitch_summary$strike_pct[i], 62, TRUE, 0.20) grid::grid.roundrect(x=pt_center_x, y=strike_y, width=pill_w, height=pill_h, r=unit(0.3,"snpc"), gp=grid::gpar(fill=str_col, col=pill_color, lwd=0.4)) grid::grid.text(paste0("K", pitch_summary$strike_pct[i], "%"), x=pt_center_x, y=strike_y, gp=grid::gpar(fontsize=3.2, fontface="bold")) # Row 4: Shape note (if provided) - in pitch color shape_note <- pitcher_notes[[pitcher_name]]$shape_notes[[pt]] %||% "" if (nchar(shape_note) > 0) { shape_y <- arsenal_y - 0.045 grid::grid.roundrect(x=pt_center_x, y=shape_y, width=pill_w, height=pill_h, r=unit(0.3,"snpc"), gp=grid::gpar(fill=pill_color, col=NA)) # Truncate to fit display_note <- if (nchar(shape_note) > 6) paste0(substr(shape_note, 1, 5), ".") else shape_note grid::grid.text(display_note, x=pt_center_x, y=shape_y, gp=grid::gpar(fontsize=3, fontface="bold", col="white")) } } # ---- SPRAY CHARTS (moved down to make room for expanded arsenal) ---- spray_y_top <- arsenal_y - 0.052 # Was -0.042, now lower to clear pills+shape spray_h <- 0.048 spray_w <- 0.095 spray_center_y <- spray_y_top - spray_h / 2 # vL spray chart spray_lhh_x <- 0.100 spray_lhh_plot <- tryCatch( create_hits_spray_chart(data, pitcher_name, "Left") + labs(title = NULL) + theme(plot.margin = margin(0,0,0,0)), error = function(e) ggplot() + theme_void() ) grid::grid.text("vL", x=spray_lhh_x - spray_w/2 - 0.008, y=spray_center_y, gp=grid::gpar(fontsize=3.5, fontface="bold", col="#E53935")) grid::pushViewport(grid::viewport(x=spray_lhh_x, y=spray_center_y, width=spray_w, height=spray_h, just=c("center","center"))) tryCatch(print(spray_lhh_plot, newpage=FALSE), error=function(e) NULL) grid::popViewport() # vR spray chart spray_rhh_x <- 0.280 spray_rhh_plot <- tryCatch( create_hits_spray_chart(data, pitcher_name, "Right") + labs(title = NULL) + theme(plot.margin = margin(0,0,0,0)), error = function(e) ggplot() + theme_void() ) grid::grid.text("vR", x=spray_rhh_x - spray_w/2 - 0.008, y=spray_center_y, gp=grid::gpar(fontsize=3.5, fontface="bold", col="#1E88E5")) grid::pushViewport(grid::viewport(x=spray_rhh_x, y=spray_center_y, width=spray_w, height=spray_h, just=c("center","center"))) tryCatch(print(spray_rhh_plot, newpage=FALSE), error=function(e) NULL) grid::popViewport() # ---- GO ZONES / NO ZONES (moved down below spray charts) ---- zone_y_top <- spray_y_top - spray_h - 0.004 zone_box_h <- 0.026 zone_box_w <- 0.19 go_zone_x <- 0.115 # Was 0.100, moved slightly left no_zone_x <- 0.225 # Was 0.280, moved slightly left zone_center_y <- zone_y_top - zone_box_h/2 # Go Zones box grid::grid.rect(x=go_zone_x, y=zone_center_y, width=zone_box_w, height=zone_box_h, gp=grid::gpar(fill="white", col="#00840D", lwd=0.8)) grid::grid.text("Go Zones", x=go_zone_x, y=zone_y_top - 0.001, gp=grid::gpar(fontsize=5.5, fontface="bold", col="#00840D")) go_rhh <- pitcher_notes[[pitcher_name]]$go_zones_rhh %||% "" go_lhh <- pitcher_notes[[pitcher_name]]$go_zones_lhh %||% "" note_y_start <- zone_y_top - 0.008 if (nchar(go_rhh) > 0) { grid::grid.text(paste0("RHH: ", go_rhh), x=go_zone_x - zone_box_w/2 + 0.005, y=note_y_start, just="left", gp=grid::gpar(fontsize=3.5, col="#333")) } else { grid::grid.text("RHH:", x=go_zone_x - zone_box_w/2 + 0.005, y=note_y_start, just="left", gp=grid::gpar(fontsize=3.5, col="#999")) } if (nchar(go_lhh) > 0) { grid::grid.text(paste0("LHH: ", go_lhh), x=go_zone_x - zone_box_w/2 + 0.005, y=note_y_start - 0.006, just="left", gp=grid::gpar(fontsize=3.5, col="#333")) } else { grid::grid.text("LHH:", x=go_zone_x - zone_box_w/2 + 0.005, y=note_y_start - 0.006, just="left", gp=grid::gpar(fontsize=3.5, col="#999")) } # No Zones box grid::grid.rect(x=no_zone_x, y=zone_center_y, width=zone_box_w, height=zone_box_h, gp=grid::gpar(fill="white", col="#E1463E", lwd=0.8)) grid::grid.text("No Zones", x=no_zone_x, y=zone_y_top - 0.001, gp=grid::gpar(fontsize=5.5, fontface="bold", col="#E1463E")) no_rhh <- pitcher_notes[[pitcher_name]]$no_zones_rhh %||% "" no_lhh <- pitcher_notes[[pitcher_name]]$no_zones_lhh %||% "" if (nchar(no_rhh) > 0) { grid::grid.text(paste0("RHH: ", no_rhh), x=no_zone_x - zone_box_w/2 + 0.005, y=note_y_start, just="left", gp=grid::gpar(fontsize=3.5, col="#333")) } else { grid::grid.text("RHH:", x=no_zone_x - zone_box_w/2 + 0.005, y=note_y_start, just="left", gp=grid::gpar(fontsize=3.5, col="#999")) } if (nchar(no_lhh) > 0) { grid::grid.text(paste0("LHH: ", no_lhh), x=no_zone_x - zone_box_w/2 + 0.005, y=note_y_start - 0.006, just="left", gp=grid::gpar(fontsize=3.5, col="#333")) } else { grid::grid.text("LHH:", x=no_zone_x - zone_box_w/2 + 0.005, y=note_y_start - 0.006, just="left", gp=grid::gpar(fontsize=3.5, col="#999")) } mvmt_center_x <- 0.47 mvmt_width <- 0.12 mvmt_height <- block_height * 0.50 # Smaller to fit arm angle below mvmt_center_y <- block_top - 0.005 - mvmt_height / 2 # At top of block mvmt_plot <- tryCatch( create_mini_movement(data, pitcher_name, expected_grid), error=function(e) { ggplot() + theme_void() } ) tryCatch({ mvmt_grob <- ggplot2::ggplotGrob(mvmt_plot) grid::pushViewport(grid::viewport(x=mvmt_center_x, y=mvmt_center_y, width=mvmt_width, height=mvmt_height, just=c("center","center"), clip="on")) grid::grid.draw(mvmt_grob) grid::popViewport() }, error=function(e) { grid::pushViewport(grid::viewport(x=mvmt_center_x, y=mvmt_center_y, width=mvmt_width, height=mvmt_height, just=c("center","center"))) grid::grid.text("Mvmt Error", gp=grid::gpar(fontsize=6)) grid::popViewport() }) # ---- ARM ANGLE VISUALIZATION (below movement chart) ---- arm_angle_height <- block_height * 0.38 arm_angle_center_y <- mvmt_center_y - mvmt_height/2 - 0.003 - arm_angle_height/2 arm_angle_plot <- tryCatch( create_mini_arm_angle(data, pitcher_name), error = function(e) ggplot() + theme_void() ) grid::pushViewport(grid::viewport(x=mvmt_center_x, y=arm_angle_center_y, width=mvmt_width * 0.85, height=arm_angle_height, just=c("center","center"))) tryCatch(print(arm_angle_plot, newpage=FALSE), error=function(e) NULL) grid::popViewport() # ---- RIGHT SECTION: Pies + Heatmaps + Whiff + Nitro ---- # Notes box is now HORIZONTAL across the bottom of this section right_start <- 0.535 # Reserve space at bottom for notes bar notes_bar_h <- 0.053 right_chart_bottom <- block_bottom + notes_bar_h + 0.004 # Pie charts pie_w <- 0.035 pie_h <- (block_height - notes_bar_h - 0.014) / 2 * 0.55 pie_cols <- c(0.555, 0.595, 0.635) pie_row1_y <- block_top - 0.006 pie_row2_y <- block_top - 0.006 - pie_h - 0.003 grid::grid.text("vL", x=0.538, y=pie_row1_y - pie_h/2, gp=grid::gpar(fontsize=3.5, fontface="bold", col="#E53935")) grid::grid.text("vR", x=0.538, y=pie_row2_y - pie_h/2, gp=grid::gpar(fontsize=3.5, fontface="bold", col="#1E88E5")) lhh_all <- p_data %>% dplyr::filter(BatterSide=="Left", !is.na(TaggedPitchType), TaggedPitchType!="Other") rhh_all <- p_data %>% dplyr::filter(BatterSide=="Right", !is.na(TaggedPitchType), TaggedPitchType!="Other") pie_list <- list( list(lhh_all, pie_cols[1], pie_row1_y-pie_h/2), list(lhh_all %>% dplyr::filter(PitchofPA==1), pie_cols[2], pie_row1_y-pie_h/2), list(lhh_all %>% dplyr::filter(Strikes==2), pie_cols[3], pie_row1_y-pie_h/2), list(rhh_all, pie_cols[1], pie_row2_y-pie_h/2), list(rhh_all %>% dplyr::filter(PitchofPA==1), pie_cols[2], pie_row2_y-pie_h/2), list(rhh_all %>% dplyr::filter(Strikes==2), pie_cols[3], pie_row2_y-pie_h/2) ) for (pie_item in pie_list) { pp <- create_mini_pie(pie_item[[1]]) grid::pushViewport(grid::viewport(x=pie_item[[2]], y=pie_item[[3]], width=pie_w, height=pie_h, just=c("center","center"))) tryCatch(print(pp, newpage=FALSE), error=function(e) NULL) grid::popViewport() } # Location Heatmaps (FB/BB/OS) hm_w <- 0.038 hm_h <- (block_height - notes_bar_h - 0.014) / 2 * 0.60 hm_cols <- c(0.680, 0.725, 0.770) hm_row1_y <- block_top - 0.005 hm_row2_y <- block_top - 0.005 - hm_h - 0.003 grid::grid.text("LHH", x=0.658, y=hm_row1_y-hm_h/2, gp=grid::gpar(fontsize=3, fontface="bold", col="#E53935")) grid::grid.text("RHH", x=0.658, y=hm_row2_y-hm_h/2, gp=grid::gpar(fontsize=3, fontface="bold", col="#1E88E5")) pitch_groups_list <- list(FB=fb_pitches, BB=bb_pitches, OS=os_pitches) for (col_idx in 1:3) { for (side_idx in 1:2) { side <- c("Left","Right")[side_idx] y_pos <- c(hm_row1_y, hm_row2_y)[side_idx] cell_data <- p_data %>% dplyr::filter(TaggedPitchType %in% pitch_groups_list[[col_idx]], BatterSide==side, !is.na(PlateLocSide), !is.na(PlateLocHeight)) hm_plot <- create_mini_heatmap(cell_data) grid::pushViewport(grid::viewport(x=hm_cols[col_idx], y=y_pos-hm_h/2, width=hm_w, height=hm_h, just=c("center","center"))) tryCatch(print(hm_plot, newpage=FALSE), error=function(e) NULL) grid::popViewport() } } # ---- WHIFF HEATMAPS ---- whiff_x <- 0.825 whiff_w <- 0.042 whiff_h <- (block_height - notes_bar_h - 0.014) / 2 * 0.58 whiff_row1_y <- block_top - 0.005 whiff_row2_y <- block_top - 0.005 - whiff_h - 0.003 grid::grid.text("L", x=whiff_x, y=whiff_row1_y + 0.003, gp=grid::gpar(fontsize=2.5, fontface="bold", col="#E53935")) grid::grid.text("R", x=whiff_x, y=whiff_row2_y + 0.003, gp=grid::gpar(fontsize=2.5, fontface="bold", col="#1E88E5")) # LHH whiff heatmap whiff_lhh_plot <- tryCatch( create_mini_whiff_heatmap(p_data, "Left"), error = function(e) ggplot() + theme_void() ) grid::pushViewport(grid::viewport(x=whiff_x, y=whiff_row1_y - whiff_h/2, width=whiff_w, height=whiff_h, just=c("center","center"))) tryCatch(print(whiff_lhh_plot, newpage=FALSE), error=function(e) NULL) grid::popViewport() # RHH whiff heatmap whiff_rhh_plot <- tryCatch( create_mini_whiff_heatmap(p_data, "Right"), error = function(e) ggplot() + theme_void() ) grid::pushViewport(grid::viewport(x=whiff_x, y=whiff_row2_y - whiff_h/2, width=whiff_w, height=whiff_h, just=c("center","center"))) tryCatch(print(whiff_rhh_plot, newpage=FALSE), error=function(e) NULL) grid::popViewport() # ---- NITRO ZONES ---- nitro_x <- 0.885 nitro_w <- 0.042 nitro_h <- (block_height - notes_bar_h - 0.014) / 2 * 0.55 nitro_row1_y <- block_top - 0.005 nitro_row2_y <- block_top - 0.005 - nitro_h - 0.003 grid::grid.text("L", x=nitro_x, y=nitro_row1_y + 0.003, gp=grid::gpar(fontsize=2.5, fontface="bold", col="#E53935")) grid::grid.text("R", x=nitro_x, y=nitro_row2_y + 0.003, gp=grid::gpar(fontsize=2.5, fontface="bold", col="#1E88E5")) nitro_lhh_plot <- tryCatch( create_mini_nitro(p_data, "Left"), error = function(e) ggplot() + theme_void() ) grid::pushViewport(grid::viewport(x=nitro_x, y=nitro_row1_y - nitro_h/2, width=nitro_w, height=nitro_h, just=c("center","center"))) tryCatch(print(nitro_lhh_plot, newpage=FALSE), error=function(e) NULL) grid::popViewport() nitro_rhh_plot <- tryCatch( create_mini_nitro(p_data, "Right"), error = function(e) ggplot() + theme_void() ) grid::pushViewport(grid::viewport(x=nitro_x, y=nitro_row2_y - nitro_h/2, width=nitro_w, height=nitro_h, just=c("center","center"))) tryCatch(print(nitro_rhh_plot, newpage=FALSE), error=function(e) NULL) grid::popViewport() # ---- PILL STATS (right of nitro zones) ---- pill_stats_x <- 0.945 pill_stats_top <- block_top - 0.008 # Overall extension overall_ext <- mean(p_data$Extension, na.rm = TRUE) ext_str <- if (!is.na(overall_ext)) sprintf("%.1f", overall_ext) else "-" grid::grid.text("Ext", x=pill_stats_x, y=pill_stats_top, gp=grid::gpar(fontsize=4, fontface="bold", col="#006F71")) ext_col <- get_pill_color(overall_ext, 5.80, TRUE, 0.10) grid::grid.roundrect(x=pill_stats_x, y=pill_stats_top - 0.010, width=0.035, height=0.012, r=unit(0.3,"snpc"), gp=grid::gpar(fill=ext_col, col="#BBBBBB", lwd=0.3)) grid::grid.text(paste0(ext_str, "ft"), x=pill_stats_x, y=pill_stats_top - 0.010, gp=grid::gpar(fontsize=4.5, fontface="bold")) # wOBA vs L has_woba_col <- "woba" %in% names(p_data) woba_vl <- if (has_woba_col) mean(p_data$woba[p_data$BatterSide == "Left"], na.rm = TRUE) else NA_real_ woba_vr <- if (has_woba_col) mean(p_data$woba[p_data$BatterSide == "Right"], na.rm = TRUE) else NA_real_ grid::grid.text("wOBA vL", x=pill_stats_x, y=pill_stats_top - 0.028, gp=grid::gpar(fontsize=3.5, fontface="bold", col="#E53935")) if (!is.na(woba_vl)) { woba_vl_col <- get_pill_color(woba_vl, 0.320, FALSE, 0.20) grid::grid.roundrect(x=pill_stats_x, y=pill_stats_top - 0.038, width=0.035, height=0.012, r=unit(0.3,"snpc"), gp=grid::gpar(fill=woba_vl_col, col="#BBBBBB", lwd=0.3)) grid::grid.text(sprintf("%.3f", woba_vl), x=pill_stats_x, y=pill_stats_top - 0.038, gp=grid::gpar(fontsize=4.5, fontface="bold")) } # wOBA vs R grid::grid.text("wOBA vR", x=pill_stats_x, y=pill_stats_top - 0.055, gp=grid::gpar(fontsize=3.5, fontface="bold", col="#1E88E5")) if (!is.na(woba_vr)) { woba_vr_col <- get_pill_color(woba_vr, 0.320, FALSE, 0.20) grid::grid.roundrect(x=pill_stats_x, y=pill_stats_top - 0.065, width=0.035, height=0.012, r=unit(0.3,"snpc"), gp=grid::gpar(fill=woba_vr_col, col="#BBBBBB", lwd=0.3)) grid::grid.text(sprintf("%.3f", woba_vr), x=pill_stats_x, y=pill_stats_top - 0.065, gp=grid::gpar(fontsize=4.5, fontface="bold")) } notes_bar_h <- 0.050 notes_left <- 0.560 # Shifted right (was 0.535) notes_right <- 0.920 # Narrower on right to leave room for pill stats notes_bar_w <- notes_right - notes_left notes_bar_center_x <- (notes_left + notes_right) / 2 notes_bar_top <- block_bottom + notes_bar_h + 0.006 notes_bar_center_y <- notes_bar_top - notes_bar_h / 2 # Notes box with team-colored border grid::grid.rect(x=notes_bar_center_x, y=notes_bar_center_y, width=notes_bar_w, height=notes_bar_h, gp=grid::gpar(fill="white", col=tc_primary, lwd=0.8)) # "Notes:" label centered at top grid::grid.text("Notes:", x=notes_bar_center_x, y=notes_bar_top - 0.005, gp=grid::gpar(fontsize=5, fontface="bold", col=tc_primary)) n_note_lines <- 6 line_spacing_v <- (notes_bar_h - 0.010) / n_note_lines for (line_idx in 1:n_note_lines) { line_y <- notes_bar_top - 0.008 - line_idx * line_spacing_v grid::grid.lines(x = c(notes_left + 0.005, notes_right - 0.005), y = c(line_y, line_y), gp = grid::gpar(col = "#E0E0E0", lwd = 0.3)) } current_y <- block_bottom } # Footer - team branded grid::grid.rect(x=0.5, y=0.001, width=1, height=0.004, gp=grid::gpar(fill=tc_secondary, col=NA)) grid::grid.text("Data: TrackMan | Coastal Carolina Baseball Analytics", x=0.5, y=0.005, gp=grid::gpar(fontsize=4.5, col="gray50")) } invisible(output_file) } # 7. CALCULATE PITCHER STATS calculate_pitcher_stats <- function(data, pitcher_name, date_range = NULL, batter_sides = NULL) { if (is.null(data)) return(NULL) df <- data %>% filter(Pitcher == pitcher_name) if (!is.null(date_range) && length(date_range) == 2) { df <- df %>% filter(Date >= date_range[1], Date <= date_range[2]) } if (!is.null(batter_sides) && length(batter_sides) > 0) { df <- df %>% filter(BatterSide %in% batter_sides) } if (nrow(df) < 10) return(NULL) pa_ending_events <- df %>% filter( KorBB %in% c("Strikeout", "Walk") | PitchCall == "HitByPitch" | PitchCall == "InPlay" ) n_pa <- nrow(pa_ending_events) n_k <- sum(pa_ending_events$KorBB == "Strikeout", na.rm = TRUE) n_bb <- sum(pa_ending_events$KorBB == "Walk", na.rm = TRUE) n_hbp <- sum(pa_ending_events$PitchCall == "HitByPitch", na.rm = TRUE) overall <- df %>% summarise( throws = first(PitcherThrows), n_pitches = n(), avg_velo = mean(RelSpeed, na.rm = TRUE), strike_pct = 100 * mean(PitchCall %in% c("StrikeCalled", "StrikeSwinging", "FoulBall", "FoulBallNotFieldable", "FoulBallFieldable", "InPlay"), na.rm = TRUE), zone_pct = 100 * mean(PlateLocSide >= -0.83 & PlateLocSide <= 0.83 & PlateLocHeight >= 1.5 & PlateLocHeight <= 3.5, na.rm = TRUE), whiff_pct = 100 * sum(PitchCall == "StrikeSwinging", na.rm = TRUE) / max(sum(PitchCall %in% c("StrikeSwinging", "FoulBall", "FoulBallNotFieldable", "FoulBallFieldable", "InPlay"), na.rm = TRUE), 1), z_whiff_pct = 100 * sum(PitchCall == "StrikeSwinging" & PlateLocSide >= -0.83 & PlateLocSide <= 0.83 & PlateLocHeight >= 1.5 & PlateLocHeight <= 3.5, na.rm = TRUE) / max(sum(PitchCall %in% c("StrikeSwinging", "FoulBall", "FoulBallNotFieldable", "FoulBallFieldable", "InPlay") & PlateLocSide >= -0.83 & PlateLocSide <= 0.83 & PlateLocHeight >= 1.5 & PlateLocHeight <= 3.5, na.rm = TRUE), 1), chase_pct = 100 * sum(PitchCall %in% c("StrikeSwinging", "FoulBall", "FoulBallNotFieldable", "FoulBallFieldable", "InPlay") & !(PlateLocSide >= -0.83 & PlateLocSide <= 0.83 & PlateLocHeight >= 1.5 & PlateLocHeight <= 3.5), na.rm = TRUE) / max(sum(!(PlateLocSide >= -0.83 & PlateLocSide <= 0.83 & PlateLocHeight >= 1.5 & PlateLocHeight <= 3.5), na.rm = TRUE), 1) ) %>% mutate( k_pct = if (n_pa > 0) 100 * n_k / n_pa else 0, bb_pct = if (n_pa > 0) 100 * n_bb / n_pa else 0, bb_hbp_pct = if (n_pa > 0) 100 * (n_bb + n_hbp) / n_pa else 0, n_pa = n_pa ) by_pitch <- df %>% filter(!is.na(TaggedPitchType), TaggedPitchType != "Other") %>% group_by(TaggedPitchType) %>% summarise( n = n(), velo_avg = mean(RelSpeed, na.rm = TRUE), velo_p20 = quantile(RelSpeed, 0.20, na.rm = TRUE), velo_p90 = quantile(RelSpeed, 0.90, na.rm = TRUE), .groups = "drop" ) vs_lhh <- df %>% filter(BatterSide == "Left") %>% summarise( ba = sum(PlayResult %in% c("Single", "Double", "Triple", "HomeRun"), na.rm = TRUE) / max(sum(PlayResult %in% c("Single", "Double", "Triple", "HomeRun", "Out", "FieldersChoice") | KorBB == "Strikeout", na.rm = TRUE), 1) ) vs_rhh <- df %>% filter(BatterSide == "Right") %>% summarise( ba = sum(PlayResult %in% c("Single", "Double", "Triple", "HomeRun"), na.rm = TRUE) / max(sum(PlayResult %in% c("Single", "Double", "Triple", "HomeRun", "Out", "FieldersChoice") | KorBB == "Strikeout", na.rm = TRUE), 1) ) return(list( overall = overall, by_pitch = by_pitch, vs_lhh = vs_lhh, vs_rhh = vs_rhh )) } # ---- Get unique pitchers and hitters ---- all_pitchers_2025 <- sort(unique(tm_data$Pitcher[!is.na(tm_data$Pitcher) & tm_data$Pitcher != ""])) all_pitchers_2026 <- sort(unique(data_2026$Pitcher[!is.na(data_2026$Pitcher) & data_2026$Pitcher != ""])) all_pitchers_combined <- sort(unique(c(all_pitchers_2025, all_pitchers_2026))) all_hitters_2025 <- sort(unique(tm_data$Batter[!is.na(tm_data$Batter) & tm_data$Batter != ""])) all_hitters_2026 <- sort(unique(data_2026$Batter[!is.na(data_2026$Batter) & data_2026$Batter != ""])) all_hitters_combined <- sort(unique(c(all_hitters_2025, all_hitters_2026))) # Start with combined as default all_pitchers <- all_pitchers_combined all_hitters <- all_hitters_combined cat("Found", length(all_pitchers_2025), "pitchers in 2025,", length(all_pitchers_2026), "in 2026,", length(all_pitchers_combined), "combined\n") cat("Data preprocessing complete!\n\n") # ============================================================================ # UI - MODERN TEAL/BRONZE STYLING # ============================================================================ 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; } /* Header styling */ .app-header { display:flex; justify-content:space-between; align-items:center; padding:15px 30px; background:#ffffff; border-bottom:3px solid darkcyan; margin-bottom:15px; } .header-title { font-size: 28px; font-weight: bold; color: darkcyan; } .header-subtitle { font-size: 14px; color: #666; } /* Global data source filter styling */ .data-source-filter { display: flex; align-items: center; gap: 8px; background: linear-gradient(135deg, #e0f7fa, #b2ebf2); padding: 8px 16px; border-radius: 25px; border: 2px solid darkcyan; } .data-source-filter label.control-label { display: none; } .data-source-filter .checkbox-inline { margin: 0 4px; font-weight: 700; color: #006F71; font-size: 13px; } .data-source-label { font-weight: 700; color: darkcyan; font-size: 13px; white-space: nowrap; } /* Tab styling */ .nav-tabs{ border:none !important; border-radius:50px; padding:6px 12px; margin:10px auto; 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; display:flex; justify-content:center; align-items:center; flex-wrap:wrap; gap:6px; } .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:14px; padding:10px 20px; white-space:nowrap; 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); border:1px solid rgba(255,255,255,.3) !important; } /* Tab content styling */ .tab-content{ background:linear-gradient(135deg, rgba(255,255,255,.95), rgba(248,249,250,.95)); border-radius:20px; padding:20px; margin-top:10px; box-shadow:0 15px 40px rgba(0,139,139,.1); 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 and box styling */ .well { background:#ffffff; border-radius:12px; border:1px solid rgba(0,139,139,.15); box-shadow:0 6px 18px rgba(0,0,0,.06); padding: 15px; } .control-label{ font-weight:700; color:#0a6a6a; } .selectize-input, .form-control{ border-radius:10px; border:1px solid rgba(0,139,139,.35); } .gt_table{ border-radius:12px; overflow:hidden; border:1px solid rgba(0,139,139,.15); } /* Chart box styling */ .chart-box { background: white; border-radius: 12px; border: 1px solid rgba(0,139,139,.2); padding: 10px; margin-bottom: 10px; box-shadow: 0 4px 12px rgba(0,0,0,.05); } .chart-box-title { font-weight: 700; color: darkcyan; font-size: 13px; margin-bottom: 8px; text-align: center; border-bottom: 2px solid rgba(0,139,139,.2); padding-bottom: 5px; } /* Compact chart styling */ .compact-chart { margin: 0; padding: 0; } /* Stats header */ .stats-header { background: linear-gradient(135deg, darkcyan, #20b2aa); color: white; padding: 12px 20px; border-radius: 12px; margin-bottom: 15px; } .stats-header h3 { margin: 0; font-weight: bold; } .stats-header p { margin: 0; opacity: 0.9; font-size: 13px; } /* Stat boxes */ .stat-box { background: white; border-radius: 10px; padding: 10px 15px; text-align: center; border: 1px solid rgba(0,139,139,.2); box-shadow: 0 2px 8px rgba(0,0,0,.05); } .stat-value { font-size: 22px; font-weight: bold; color: darkcyan; } .stat-label { font-size: 11px; color: #666; text-transform: uppercase; } /* Notes inputs */ .pdf-notes-input textarea { font-size: 16px !important; border-radius: 10px; border: 1px solid rgba(0,139,139,.35); } .pitch-notes-input textarea { font-size: 14px !important; border-radius: 10px; border: 1px solid rgba(0,139,139,.35); } .series-title-input input { font-size: 18px !important; font-weight: bold; } /* Button styling */ .btn-teal { background: linear-gradient(135deg, darkcyan, #20b2aa); color: white; border: none; border-radius: 25px; padding: 10px 25px; font-weight: 600; box-shadow: 0 4px 12px rgba(0,139,139,.3); transition: all 0.2s; } .btn-teal:hover { transform: translateY(-2px); box-shadow: 0 6px 16px rgba(0,139,139,.4); color: white; } .btn-bronze { background: linear-gradient(135deg, peru, #daa520); color: white; border: none; border-radius: 25px; padding: 10px 25px; font-weight: 600; box-shadow: 0 4px 12px rgba(205,133,63,.3); } .btn-bronze:hover { transform: translateY(-2px); box-shadow: 0 6px 16px rgba(205,133,63,.4); color: white; } /* Input row styling */ .input-row { background: #f8f9fa; border-radius: 12px; padding: 12px 15px; margin-bottom: 15px; border: 1px solid rgba(0,139,139,.15); } /* Movement plot customization panel */ .mvmt-options { background: linear-gradient(135deg, #f0f7f7, #e8f4f4); padding: 12px; border-radius: 10px; margin-bottom: 10px; border: 1px solid rgba(0,139,139,.2); } .mvmt-options .checkbox { margin-bottom: 5px; } .mvmt-options label { font-size: 12px; font-weight: 600; color: #006F71; } /* Pitch type filter inline checkboxes */ .mvmt-options .checkbox-inline { margin-right: 15px; padding: 4px 10px; border-radius: 15px; background: #f0f0f0; transition: all 0.2s; } .mvmt-options .checkbox-inline:hover { background: #e0e0e0; } .mvmt-options .checkbox-inline input[type='checkbox'] { margin-right: 5px; } ")) ), # App Header with Global Data Source Filter div(class = "app-header", div( div(class = "header-title", "Pitcher Scouting Reports"), div(class = "header-subtitle", "Coastal Carolina Baseball Analytics") ), # ===== GLOBAL DATA SOURCE FILTER ===== div(class = "data-source-filter", span(class = "data-source-label", "Data:"), checkboxGroupInput("data_source", label = NULL, choices = c("2025" = "2025", "2026" = "2026"), selected = c("2025", "2026"), inline = TRUE), actionButton("refresh_data", "Refresh", icon = icon("refresh"), class = "btn-info btn-sm", style = "margin-left: 10px;") ) ), # Main Content - Full Width with Tabs tabsetPanel( id = "main_tabs", type = "tabs", # ===== PITCHER REPORTS TAB (Combined) ===== tabPanel("Pitcher Reports", value = "pitcher_reports", # Main Pitcher Selection Row - ALWAYS VISIBLE div(class = "input-row", style = "background: linear-gradient(135deg, #e0f7fa, #b2ebf2); padding: 15px; border-radius: 12px; margin-bottom: 15px;", fluidRow( column(3, selectInput("main_pitcher", "Select Pitcher:", choices = c("Loading..." = ""), width = "100%") ), column(2, checkboxGroupInput("batter_side_filter", "vs Batter:", choices = c("L" = "Left", "R" = "Right"), selected = c("Left", "Right"), inline = TRUE) ), column(2, dateInput("date_filter_start", "From Date:", value = NULL, width = "100%") ), column(2, dateInput("date_filter_end", "To Date:", value = NULL, width = "100%") ), column(3, div(style = "padding-top: 25px;", downloadButton("download_single_pdf", "Download PDF", class = "btn-teal", style = "width: 100%;")) ) ) ), div(class = "chart-box", div(class = "chart-box-title", style = "font-size: 14px; background: linear-gradient(90deg, darkcyan, peru); color: white; padding: 8px; margin: -10px -10px 10px -10px; border-radius: 10px 10px 0 0;", "Series Scouting Report Settings (Optional - for multi-pitcher reports)"), fluidRow( column(3, div(class = "series-title-input", textInput("series_title", "Series Title:", value = "", placeholder = "e.g., ECU @ COA 3/9-3/14", width = "100%")) ), column(3, selectInput("opponent_team", "Opponent Team:", choices = c("-- Select Team --" = ""), width = "100%") ), column(3, textInput("opponent_record", "Opponent Record:", placeholder = "e.g., 25-10 (12-6)", width = "100%") ), column(3, # Preview of selected team colors uiOutput("team_color_preview") ) ), # ---- Auto-populated Pitcher Roster ---- fluidRow( column(12, div(style = "margin-top: 10px; border-top: 1px solid #ddd; padding-top: 10px;", div(style = "display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px;", h5("Pitcher Roster", style = "color: darkcyan; margin: 0;"), div(style = "display: flex; gap: 8px; align-items: center;", # Manual add pitcher dropdown selectizeInput("add_pitcher_manual", NULL, choices = NULL, multiple = FALSE, options = list(placeholder = "Add a pitcher...", maxOptions = 100), width = "250px"), actionButton("btn_add_pitcher", "Add", style = "background: darkcyan; color: white; border: none; padding: 4px 12px; border-radius: 4px;"), actionButton("btn_clear_roster", "Clear All", style = "background: #E53935; color: white; border: none; padding: 4px 12px; border-radius: 4px;") ) ), # Dynamic roster table uiOutput("pitcher_roster_ui"), # Info text div(id = "roster_info_text", style = "color: #888; font-size: 11px; font-style: italic; margin-top: 5px;", "Select an opponent team to auto-populate pitchers from 2026 data, or add pitchers manually.") ) ) ), # ---- Existing pitcher roles section (for scouting notes) ---- fluidRow( column(12, h5("Pitcher Roles", style = "color: darkcyan; margin-bottom: 5px; margin-top: 10px;"), uiOutput("pitcher_roles_ui") ) ) ), # Pitcher Header Stats div(class = "stats-header", style = "padding: 8px 15px; margin-top: 15px;", fluidRow( column(6, h4(textOutput("charts_pitcher_name"), style = "margin: 0;")), column(6, align = "right", span(textOutput("charts_pitcher_hand"), style = "font-weight: bold;") ) ) ), # Stats GT Table (3 rows like PDF) div(class = "chart-box", style = "padding: 8px; margin-bottom: 10px;", gt::gt_output("pitcher_stats_gt") ), # Row 1: Arm Angle + Movement Chart fluidRow( column(4, div(class = "chart-box", div(class = "chart-box-title", "Arm Angle"), plotOutput("pdf_arm_angle_preview", height = 250) ), div(class = "chart-box", div(class = "chart-box-title", "Delivery Notes"), div(class = "pdf-notes-input", textAreaInput("pdf_delivery_notes", label = NULL, placeholder = "Mechanics, tendencies, arm action...", rows = 3, width = "100%")) ) ), column(8, div(class = "chart-box", div(class = "chart-box-title", "Pitch Movement"), # ===== MOVEMENT PLOT CUSTOMIZATION OPTIONS ===== div(class = "mvmt-options", fluidRow( column(3, checkboxInput("mvmt_show_arm_rays", "Arm Angle Rays", value = TRUE) ), column(3, checkboxInput("mvmt_show_arm_annotation", "Arm Angle Label", value = TRUE) ), column(3, selectInput("mvmt_marker_style", "Markers:", choices = c("Scaled by Usage" = "scaled_usage", "Average (Fixed)" = "average", "None" = "none"), selected = "scaled_usage", width = "100%") ), column(3, checkboxInput("mvmt_show_pitches", "Individual Pitches", value = TRUE) ) ), fluidRow( column(3, checkboxInput("mvmt_show_expected", "Expected Movement", value = TRUE) ), column(9, div(style = "font-size: 10px; color: #666; padding-top: 8px;", "Diamonds = expected movement for release height | Rays show release point by pitch type") ) ), # Pitch Type Filter Row fluidRow( column(12, div(style = "margin-top: 8px; padding-top: 8px; border-top: 1px solid rgba(0,139,139,.2);", tags$label("Pitch Types:", style = "font-weight: 700; color: #006F71; margin-right: 10px;"), uiOutput("mvmt_pitch_type_filter_ui", inline = TRUE) ) ) ) ), # The movement plot plotOutput("pdf_movement_preview", height = "600px", width = "100%") ) ) ), div(class = "chart-box", div(class = "chart-box-title", style = "background: linear-gradient(90deg, #2E7D32, #E1463E); color: white; padding: 8px; margin: -10px -10px 10px -10px; border-radius: 10px 10px 0 0;", "Go Zones / No Zones"), p(style = "color: #666; font-size: 12px; margin-bottom: 10px;", "Zone approach notes for each batter handedness (used in condensed reports)"), fluidRow( column(6, div(style = "border: 2px solid #00840D; border-radius: 8px; padding: 10px; margin-bottom: 10px;", h5("Go Zones", style = "color: #00840D; font-weight: bold; margin-top: 0;"), textInput("go_zones_rhh", "RHH:", placeholder = "e.g., Middle Away", width = "100%"), textInput("go_zones_lhh", "LHH:", placeholder = "e.g., Away", width = "100%") ) ), column(6, div(style = "border: 2px solid #E1463E; border-radius: 8px; padding: 10px; margin-bottom: 10px;", h5("No Zones", style = "color: #E1463E; font-weight: bold; margin-top: 0;"), textInput("no_zones_rhh", "RHH:", placeholder = "e.g., FB U/A, SL", width = "100%"), textInput("no_zones_lhh", "LHH:", placeholder = "e.g., FB U/A, SL", width = "100%") ) ) ) ), # Scouting Notes Section div(class = "chart-box", div(class = "chart-box-title", "Scouting Notes"), fluidRow( column(3, div(class = "pdf-notes-input", textAreaInput("pdf_matchup_notes", "Matchup Notes:", placeholder = "Key matchup tendencies, game plan...", rows = 3, width = "100%")) ), column(3, div(class = "pdf-notes-input", textAreaInput("pdf_stamina_notes", "Stamina/Usage Notes:", placeholder = "Pitch count limits, fatigue patterns...", rows = 3, width = "100%")) ), column(3, div(class = "pdf-notes-input", textAreaInput("pdf_vs_lhh_notes", "vs LHH Notes:", placeholder = "Approach vs left-handed hitters...", rows = 3, width = "100%")) ), column(3, div(class = "pdf-notes-input", textAreaInput("pdf_vs_rhh_notes", "vs RHH Notes:", placeholder = "Approach vs right-handed hitters...", rows = 3, width = "100%")) ) ) ), # Download Buttons div(style = "text-align: center; margin: 20px 0;", actionButton("save_pitcher_notes", "Save Notes", class = "btn-teal", style = "margin-right: 15px;"), downloadButton("download_pdf_with_notes", "Download Current PDF", class = "btn-bronze", style = "margin-right: 15px;"), downloadButton("download_series_zip", "Download All (ZIP)", class = "btn-teal", style = "margin-right: 15px;"), downloadButton("download_condensed_report", "Condensed Report (1-Page)", class = "btn-bronze") ), ), # ===== BULLPEN REPORT TAB ===== tabPanel("Bullpen Report", value = "bullpen", # Header div(class = "stats-header", style = "background: linear-gradient(135deg, #1565C0, #42A5F5);", h3("Auto Bullpen Report"), p("Generate bullpen usage and role reports for upcoming series") ), # Settings Row div(class = "input-row", fluidRow( column(3, textInput("bullpen_title", "Report Title:", placeholder = "e.g., Auto Bullpen Report: 2/13 - 2/16", width = "100%")), column(3, selectizeInput("bullpen_pitchers", "Select Bullpen Arms:", choices = NULL, multiple = TRUE, options = list(placeholder = "Select pitchers..."), width = "100%")), column(2, dateInput("bullpen_date", "As of Date:", value = Sys.Date(), width = "100%")), column(2, selectInput("rolling_stat1", "Rolling Chart 1:", choices = c("Velo" = "velo", "Strike%" = "strike", "wOBA" = "woba", "Stuff+" = "stuff_plus"), selected = "velo", width = "100%")), column(2, selectInput("rolling_stat2", "Rolling Chart 2:", choices = c("Velo" = "velo", "Strike%" = "strike", "wOBA" = "woba", "Stuff+" = "stuff_plus"), selected = "strike", width = "100%")) ), fluidRow( column(12, style = "text-align: center; margin-top: 10px;", downloadButton("download_bullpen_pdf", "Download Report", class = "btn-teal")) ) ), # Pitcher Roles Table div(class = "chart-box", div(class = "chart-box-title", "Bullpen Roles & Assignments"), uiOutput("bullpen_roles_table_ui") ), # Usage Charts Row fluidRow( column(6, div(class = "chart-box", div(class = "chart-box-title", "Pitch Count Usage (Last 1 Week vs Last 2 Weeks)"), plotOutput("bullpen_usage_chart", height = 280) ) ), column(6, div(class = "chart-box", div(class = "chart-box-title", "Usage by Inning"), plotOutput("bullpen_inning_heatmap", height = 280) ) ) ), # Rolling Line Charts Row fluidRow( column(6, div(class = "chart-box", div(class = "chart-box-title", textOutput("rolling_chart1_title")), plotOutput("bullpen_rolling_chart1", height = 280) ) ), column(6, div(class = "chart-box", div(class = "chart-box-title", textOutput("rolling_chart2_title")), plotOutput("bullpen_rolling_chart2", height = 280) ) ) ), # Stats Summary div(class = "chart-box", div(class = "chart-box-title", "Bullpen Summary Stats"), gt::gt_output("bullpen_summary_table") ) ) ) ) ui <- fluidPage(uiOutput("mainUI")) # ============================================================================ # SERVER # ============================================================================ server <- function(input, output, session) { data_2026_rv <- reactiveVal(data_2026) 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 }) #============== #MAKE THE 2026 DATA REFRESHABLE #=============== observeEvent(input$refresh_data, { tryCatch({ showNotification("Refreshing data from HuggingFace...", type = "message", duration = 3) new_data <- download_private_parquet("CoastalBaseball/2026MasterDataset", "pbp_2026_master.parquet") new_data <- new_data %>% mutate( PlateLocSide = PlateLocSide / 12, PlateLocHeight = PlateLocHeight / 12 ) %>% dplyr::select(any_of(cols_to_select)) %>% collect_df() new_data <- clean_data(new_data) new_data$Date <- as.Date(new_data$Date) data_2026_rv(new_data) all_pitchers_2026 <<- sort(unique(new_data$Pitcher[!is.na(new_data$Pitcher) & new_data$Pitcher != ""])) all_pitchers_combined <<- sort(unique(c(all_pitchers_2025, all_pitchers_2026))) showNotification(paste0("Data refreshed! ", nrow(new_data), " rows loaded."), type = "message") }, error = function(e) { showNotification(paste("Refresh failed:", e$message), type = "error") }) }) # ========================================================================= # GLOBAL DATA SOURCE REACTIVE - the single source of truth for all data # ========================================================================= exp_movement_grid <- reactive({ tryCatch( build_expected_movement_grid(base_data()), error = function(e) { cat("Warning: Could not build expected movement grid -", e$message, "\n"); NULL } ) }) base_data <- reactive({ sources <- input$data_source if (is.null(sources) || length(sources) == 0) { return(bind_rows(tm_data %>% mutate(Date = as.Date(Date)), data_2026_rv() %>% mutate(Date = as.Date(Date)))) } if (all(c("2025", "2026") %in% sources)) { bind_rows(tm_data %>% mutate(Date = as.Date(Date)), data_2026_rv() %>% mutate(Date = as.Date(Date))) } else if ("2025" %in% sources) { tm_data } else if ("2026" %in% sources) { data_2026_rv() } else { bind_rows(tm_data %>% mutate(Date = as.Date(Date)), data_2026_rv() %>% mutate(Date = as.Date(Date))) } }) # Reactive pitcher list that updates when data source changes available_pitchers <- reactive({ current_2026 <- data_2026_rv() sources <- input$data_source p_2025 <- sort(unique(tm_data$Pitcher[!is.na(tm_data$Pitcher) & tm_data$Pitcher != ""])) p_2026 <- sort(unique(current_2026$Pitcher[!is.na(current_2026$Pitcher) & current_2026$Pitcher != ""])) p_combined <- sort(unique(c(p_2025, p_2026))) if (is.null(sources) || length(sources) == 0) return(p_combined) if (all(c("2025", "2026") %in% sources)) p_combined else if ("2025" %in% sources) p_2025 else if ("2026" %in% sources) p_2026 else p_combined }) # ===== UPDATE PITCHER DROPDOWNS WHEN DATA SOURCE CHANGES ===== observeEvent(available_pitchers(), { req(authed()) pitchers <- available_pitchers() req(length(pitchers) > 0) # Preserve current selection if still valid current_main <- input$main_pitcher new_main <- if (!is.null(current_main) && current_main %in% pitchers) current_main else pitchers[1] updateSelectInput(session, "main_pitcher", choices = pitchers, selected = new_main) updateSelectizeInput(session, "series_pitchers", choices = pitchers) updateSelectizeInput(session, "bullpen_pitchers", choices = pitchers) }) # ===== UPDATE INPUTS ON FIRST AUTH ===== observeEvent(authed(), { req(authed()) pitchers <- available_pitchers() req(length(pitchers) > 0) cat("Updating pitcher dropdowns with", length(pitchers), "pitchers\n") updateSelectInput(session, "main_pitcher", choices = pitchers, selected = pitchers[1]) updateSelectizeInput(session, "series_pitchers", choices = pitchers) updateSelectizeInput(session, "bullpen_pitchers", choices = pitchers) }, ignoreInit = TRUE, once = TRUE) current_pitcher_reactive <- reactive({ req(authed()) if (!is.null(input$active_pitcher) && nchar(trimws(input$active_pitcher)) > 0) { return(input$active_pitcher) } if (!is.null(input$main_pitcher) && nchar(trimws(input$main_pitcher)) > 0) { return(input$main_pitcher) } pitchers <- available_pitchers() if (length(pitchers) > 0) { return(pitchers[1]) } NULL }) # ===== FILTERED DATA (uses base_data instead of tm_data) ===== filtered_data <- reactive({ data <- base_data() batter_sides <- input$batter_side_filter if (!is.null(batter_sides) && length(batter_sides) > 0 && "BatterSide" %in% names(data)) { data <- data %>% filter(BatterSide %in% batter_sides) } start_date <- input$date_filter_start end_date <- input$date_filter_end if (!is.null(start_date) && !is.na(start_date) && "Date" %in% names(data)) { data <- data %>% filter(as.Date(Date) >= start_date) } if (!is.null(end_date) && !is.na(end_date) && "Date" %in% names(data)) { data <- data %>% filter(as.Date(Date) <= end_date) } data }) # ===== UPDATE DATE FILTERS BASED ON PITCHER'S DATA ===== observe({ current_pitcher <- current_pitcher_reactive() req(current_pitcher) # NEW: Get the full dataset date range for min/max constraints all_dates <- base_data() %>% filter(!is.na(Date)) %>% summarise( global_min = min(as.Date(Date), na.rm = TRUE), global_max = max(as.Date(Date), na.rm = TRUE) ) pitcher_dates <- base_data() %>% filter(Pitcher == current_pitcher, !is.na(Date)) %>% summarise( min_date = min(as.Date(Date), na.rm = TRUE), max_date = max(as.Date(Date), na.rm = TRUE) ) if (nrow(pitcher_dates) > 0 && !is.na(pitcher_dates$min_date[1])) { updateDateInput(session, "date_filter_start", value = pitcher_dates$min_date[1], min = all_dates$global_min[1], max = all_dates$global_max[1]) updateDateInput(session, "date_filter_end", value = pitcher_dates$max_date[1], min = all_dates$global_min[1], max = all_dates$global_max[1]) } }) # ===== PITCHER STATS REACTIVE ===== pitcher_stats <- reactive({ current_pitcher <- current_pitcher_reactive() req(current_pitcher) data <- filtered_data() req(data) req(nrow(data) > 0) calculate_pitcher_stats(data, current_pitcher) }) # ===== ADVANCED STATS REACTIVE (uses base_data) ===== advanced_stats <- reactive({ current_pitcher <- current_pitcher_reactive() req(current_pitcher) tryCatch( calculate_advanced_pitcher_stats(base_data(), current_pitcher), error = function(e) NULL ) }) # ===== HEADER OUTPUTS ===== output$current_pitcher_header <- renderText({ current_pitcher <- current_pitcher_reactive() if (is.null(current_pitcher)) "Select a Pitcher" else current_pitcher }) output$charts_pitcher_name <- renderText({ current_pitcher <- current_pitcher_reactive() if (is.null(current_pitcher)) "Select a Pitcher" else current_pitcher }) output$charts_pitcher_hand <- renderText({ current_pitcher <- current_pitcher_reactive() req(current_pitcher) data <- filtered_data() pitcher_data <- data %>% filter(Pitcher == current_pitcher) if (nrow(pitcher_data) > 0 && "PitcherThrows" %in% names(pitcher_data)) { paste0(pitcher_data$PitcherThrows[1], "-Handed Pitcher") } else { "" } }) observe({ req(authed()) if (nrow(teams_data) > 0) { team_choices <- c("-- Select Team --" = "", sort(teams_data$team_name)) updateSelectInput(session, "opponent_team", choices = team_choices) } }) # Add team color preview output: output$team_color_preview <- renderUI({ team_name <- input$opponent_team if (is.null(team_name) || team_name == "") { return(div(style = "padding-top: 25px; color: gray; font-style: italic;", "Select a team to preview colors and logos")) } info <- get_team_info(team_name) div(style = "display: flex; align-items: center; gap: 15px; padding-top: 10px;", # Team logo preview if (nchar(info$team_logo) > 0) { tags$img(src = info$team_logo, height = "40px", onerror = "this.style.display='none'") }, # Color swatches div(style = paste0("width: 30px; height: 30px; border-radius: 4px; border: 1px solid #ccc; background: ", info$primary_color, ";")), div(style = paste0("width: 30px; height: 30px; border-radius: 4px; border: 1px solid #ccc; background: ", info$secondary_color, ";")), # Info text div( div(style = "font-weight: bold; font-size: 13px;", info$team_name), div(style = "font-size: 11px; color: #666;", paste0(info$league, " | ", info$trackman_abbr)) ) ) }) pitcher_roster <- reactiveVal(data.frame( Pitcher = character(), Number = character(), Class = character(), Hand = character(), Pitches_2026 = integer(), stringsAsFactors = FALSE )) # Populate the "Add pitcher manually" dropdown with all pitchers in the data observe({ req(authed()) d <- base_data() if (!is.null(d) && nrow(d) > 0) { all_pitchers <- sort(unique(d$Pitcher[!is.na(d$Pitcher)])) updateSelectizeInput(session, "add_pitcher_manual", choices = c("Select pitcher..." = "", all_pitchers), server = TRUE) } }) # AUTO-POPULATE: When opponent team changes, find their 2026 pitchers observeEvent(input$opponent_team, { team_name <- input$opponent_team if (is.null(team_name) || team_name == "") return() info <- get_team_info(team_name) if (nchar(info$trackman_abbr) == 0) { showNotification(paste0("Could not find TrackMan abbreviation for ", team_name), type = "warning", duration = 4) return() } d <- base_data() if (is.null(d) || nrow(d) == 0) return() team_abbr <- info$trackman_abbr team_pitchers_data <- d %>% dplyr::filter( PitcherTeam == team_abbr, !is.na(Pitcher), !is.na(Date) ) %>% dplyr::mutate(Year = as.integer(format(as.Date(Date), "%Y"))) %>% dplyr::filter(Year == 2026) if (nrow(team_pitchers_data) == 0) { showNotification(paste0("No 2026 pitch data found for ", team_name, " (", team_abbr, ")"), type = "warning", duration = 5) return() } roster_summary <- team_pitchers_data %>% dplyr::group_by(Pitcher) %>% dplyr::summarise( Pitches_2026 = dplyr::n(), Hand = dplyr::first(PitcherThrows[!is.na(PitcherThrows)]), .groups = "drop" ) %>% dplyr::arrange(dplyr::desc(Pitches_2026)) %>% dplyr::mutate( Number = "", Class = "", Hand = ifelse(is.na(Hand), "R", Hand) ) %>% dplyr::select(Pitcher, Number, Class, Hand, Pitches_2026) pitcher_roster(as.data.frame(roster_summary)) showNotification( paste0("Found ", nrow(roster_summary), " pitchers for ", team_name, " in 2026 data"), type = "message", duration = 4 ) }, ignoreInit = TRUE) # ADD PITCHER MANUALLY observeEvent(input$btn_add_pitcher, { pitcher_to_add <- input$add_pitcher_manual if (is.null(pitcher_to_add) || pitcher_to_add == "") return() current <- pitcher_roster() if (pitcher_to_add %in% current$Pitcher) { showNotification(paste0(pitcher_to_add, " is already in the roster"), type = "warning", duration = 3) return() } d <- base_data() p_info <- data.frame(Pitcher = pitcher_to_add, Number = "", Class = "", Hand = "R", Pitches_2026 = 0L, stringsAsFactors = FALSE) if (!is.null(d) && nrow(d) > 0) { p_data <- d %>% dplyr::filter(Pitcher == pitcher_to_add) if (nrow(p_data) > 0) { p_info$Hand <- dplyr::first(p_data$PitcherThrows[!is.na(p_data$PitcherThrows)]) %||% "R" p_data_2026 <- p_data %>% dplyr::filter(as.integer(format(as.Date(Date), "%Y")) == 2026) p_info$Pitches_2026 <- nrow(p_data_2026) } } pitcher_roster(rbind(current, p_info)) updateSelectizeInput(session, "add_pitcher_manual", selected = "") }) # CLEAR ALL PITCHERS observeEvent(input$btn_clear_roster, { pitcher_roster(data.frame( Pitcher = character(), Number = character(), Class = character(), Hand = character(), Pitches_2026 = integer(), stringsAsFactors = FALSE )) }) # RENDER THE PITCHER ROSTER TABLE output$pitcher_roster_ui <- renderUI({ roster <- pitcher_roster() if (nrow(roster) == 0) { return(div(style = "color: #999; padding: 10px; text-align: center; border: 1px dashed #ccc; border-radius: 6px;", "No pitchers in roster. Select an opponent team or add pitchers manually.")) } # Get base data to find each pitcher's pitch types bd <- tryCatch(base_data(), error = function(e) NULL) header <- div(style = "display: grid; grid-template-columns: 40px 50px 2fr 80px 80px 60px 60px 50px; gap: 4px; padding: 4px 0; border-bottom: 2px solid darkcyan; font-weight: bold; font-size: 12px; color: darkcyan;", div(""), div("Hand"), div("Pitcher Name"), div("Number"), div("Class"), div("Pitches"), div("Order"), div("") ) rows <- lapply(seq_len(nrow(roster)), function(i) { p <- roster[i, ] p_safe <- gsub("[^a-zA-Z0-9]", "_", p$Pitcher) hand_color <- if (p$Hand == "Left") "#E53935" else "#1E88E5" hand_label <- if (p$Hand == "Left") "LHP" else "RHP" # Get this pitcher's pitch types from the data pitcher_pitches <- character(0) if (!is.null(bd) && nrow(bd) > 0) { p_data <- bd %>% dplyr::filter(Pitcher == p$Pitcher, !is.na(TaggedPitchType), TaggedPitchType != "Other") if (nrow(p_data) > 0) { pitch_counts <- p_data %>% dplyr::count(TaggedPitchType, sort = TRUE) %>% dplyr::mutate(pct = 100 * n / sum(n)) %>% dplyr::filter(pct >= 3) pitcher_pitches <- pitch_counts$TaggedPitchType } } # Build per-pitch shape note inputs shape_inputs <- if (length(pitcher_pitches) > 0) { pitch_divs <- lapply(pitcher_pitches, function(pt) { pt_safe <- gsub("[^a-zA-Z0-9]", "_", pt) pt_short <- dplyr::case_when( pt == "Fastball" ~ "FB", pt == "Sinker" ~ "SI", pt == "Slider" ~ "SL", pt == "Curveball" ~ "CB", pt == "ChangeUp" ~ "CH", pt == "Cutter" ~ "CT", pt == "Sweeper" ~ "SW", pt == "Splitter" ~ "SP", pt == "Knuckle Curve" ~ "KC", pt == "FourSeamFastBall" ~ "FF", pt == "TwoSeamFastBall" ~ "2S", TRUE ~ substr(pt, 1, 2) ) pt_color <- pitch_colors[[pt]] if (is.null(pt_color)) pt_color <- "#888888" div(style = "display: flex; align-items: center; gap: 4px; margin-bottom: 2px;", span(pt_short, style = paste0("background: ", pt_color, "; color: white; padding: 1px 6px; ", "border-radius: 3px; font-size: 10px; font-weight: bold; ", "min-width: 28px; text-align: center; display: inline-block;")), textInput(paste0("roster_shape_", p_safe, "_", pt_safe), NULL, value = "", placeholder = "1-2 words", width = "110px") ) }) div(style = "display: flex; flex-wrap: wrap; gap: 2px 12px;", pitch_divs) } else { div(style = "color: #aaa; font-size: 10px; font-style: italic;", "No pitch data available") } tagList( # Main pitcher row div(style = paste0("display: grid; grid-template-columns: 40px 50px 2fr 80px 80px 60px 60px 50px; gap: 4px; padding: 6px 0 2px 0; align-items: center;", if (i %% 2 == 0) " background: #f9f9f9;" else ""), div(actionButton(paste0("rm_pitcher_", i), "\u2716", style = "background: transparent; color: #E53935; border: 1px solid #E53935; border-radius: 50%; width: 24px; height: 24px; padding: 0; font-size: 12px; cursor: pointer;")), div(span(hand_label, style = paste0("background: ", hand_color, "; color: white; padding: 2px 6px; border-radius: 3px; font-size: 10px; font-weight: bold;"))), div(style = "font-weight: 600; font-size: 13px;", p$Pitcher), div(textInput(paste0("roster_num_", p_safe), NULL, value = p$Number, placeholder = "#", width = "70px")), div(selectInput(paste0("roster_class_", p_safe), NULL, choices = c("Class" = "", "Fr." = "Fr.", "So." = "So.", "Jr." = "Jr.", "Sr." = "Sr.", "Gr." = "Gr.", "R-Fr." = "R-Fr.", "R-So." = "R-So.", "R-Jr." = "R-Jr.", "R-Sr." = "R-Sr."), selected = p$Class, width = "75px")), div(style = "text-align: center; font-size: 12px; color: #666;", if (p$Pitches_2026 > 0) as.character(p$Pitches_2026) else "-"), div(style = "display: flex; gap: 2px;", if (i > 1) actionButton(paste0("mv_up_", i), "\u25B2", style = "background: transparent; border: 1px solid #ccc; border-radius: 3px; width: 22px; height: 20px; padding: 0; font-size: 9px; cursor: pointer;"), if (i < nrow(roster)) actionButton(paste0("mv_down_", i), "\u25BC", style = "background: transparent; border: 1px solid #ccc; border-radius: 3px; width: 22px; height: 20px; padding: 0; font-size: 9px; cursor: pointer;") ), div("") ), # Sub-row: Per-pitch shape notes + Go/No zones div(style = paste0("display: grid; grid-template-columns: 40px 1fr 1fr 1fr; gap: 8px; padding: 0 0 8px 0; border-bottom: 1px solid #eee; align-items: start;", if (i %% 2 == 0) " background: #f9f9f9;" else ""), div(""), # Per-pitch shape notes div(style = "margin-top: -6px;", tags$label(style = "font-size: 10px; color: #555; font-weight: 600; margin-bottom: 2px; display: block;", "\U0001F3AF Pitch Shape Notes"), shape_inputs ), # Go Zones div(style = "margin-top: -6px;", tags$label(style = "font-size: 10px; color: #00840D; font-weight: 600; margin-bottom: 1px; display: block;", "\u2705 Go Zones"), textInput(paste0("roster_go_", p_safe), NULL, value = "", placeholder = "RHH: mid-away, LHH: down-in", width = "100%") ), # No Zones div(style = "margin-top: -6px;", tags$label(style = "font-size: 10px; color: #E1463E; font-weight: 600; margin-bottom: 1px; display: block;", "\u26D4 No Zones"), textInput(paste0("roster_no_", p_safe), NULL, value = "", placeholder = "RHH: FB up/away, LHH: SL", width = "100%") ) ) ) }) n <- nrow(roster) for (i in seq_len(n)) { local({ idx <- i observeEvent(input[[paste0("rm_pitcher_", idx)]], { current <- pitcher_roster() if (idx <= nrow(current)) pitcher_roster(current[-idx, , drop = FALSE]) }, ignoreInit = TRUE, once = TRUE) }) } for (i in 2:max(n, 2)) { local({ idx <- i observeEvent(input[[paste0("mv_up_", idx)]], { current <- pitcher_roster() if (idx <= nrow(current) && idx > 1) { swap <- current[c(idx-1, idx), ] current[c(idx-1, idx), ] <- swap[c(2, 1), ] pitcher_roster(current) } }, ignoreInit = TRUE, once = TRUE) }) } for (i in seq_len(max(n - 1, 1))) { local({ idx <- i observeEvent(input[[paste0("mv_down_", idx)]], { current <- pitcher_roster() if (idx < nrow(current)) { swap <- current[c(idx, idx+1), ] current[c(idx, idx+1), ] <- swap[c(2, 1), ] pitcher_roster(current) } }, ignoreInit = TRUE, once = TRUE) }) } count_text <- div(style = "display: flex; justify-content: space-between; margin-top: 6px; font-size: 11px; color: #666;", span(paste0(nrow(roster), " pitcher(s) in roster")), span(paste0("Pages needed: ", ceiling(nrow(roster) / 5))) ) tagList(header, rows, count_text) }) # Keep series_pitchers in sync with roster (backward compat) observe({ roster <- pitcher_roster() if (nrow(roster) > 0) { updateSelectizeInput(session, "series_pitchers", choices = roster$Pitcher, selected = roster$Pitcher) } }) # ===== STAT BOX OUTPUTS ===== output$stat_pitches <- renderText({ stats <- tryCatch(pitcher_stats(), error = function(e) NULL) if (!is.null(stats) && !is.null(stats$overall)) as.character(stats$overall$n_pitches) else "-" }) output$stat_strike <- renderText({ stats <- tryCatch(pitcher_stats(), error = function(e) NULL) if (!is.null(stats) && !is.null(stats$overall)) paste0(round(stats$overall$strike_pct, 1), "%") else "-" }) output$stat_whiff <- renderText({ adv_stats <- advanced_stats() if (!is.null(adv_stats)) paste0(adv_stats$whiff_pct, "%") else "-" }) output$stat_zone <- renderText({ adv_stats <- advanced_stats() if (!is.null(adv_stats)) paste0(adv_stats$zone_pct, "%") else "-" }) output$stat_k <- renderText({ adv_stats <- advanced_stats() if (!is.null(adv_stats)) paste0(adv_stats$k_pct, "%") else "-" }) output$stat_bb <- renderText({ adv_stats <- advanced_stats() if (!is.null(adv_stats)) paste0(adv_stats$bb_pct, "%") else "-" }) output$stat_ip <- renderText({ adv_stats <- advanced_stats() if (!is.null(adv_stats)) sprintf("%.1f", adv_stats$ip) else "-" }) output$stat_games <- renderText({ adv_stats <- advanced_stats() if (!is.null(adv_stats)) as.character(adv_stats$n_games) else "-" }) output$stat_ra9 <- renderText({ adv_stats <- advanced_stats() if (!is.null(adv_stats)) as.character(adv_stats$ra9) else "-" }) output$stat_k9 <- renderText({ adv_stats <- advanced_stats() if (!is.null(adv_stats) && !is.null(adv_stats$ip) && adv_stats$ip > 0) { as.character(round(adv_stats$k_pct * 9 / 100, 1)) } else "-" }) output$stat_bb9 <- renderText({ adv_stats <- advanced_stats() if (!is.null(adv_stats)) { as.character(round(adv_stats$bb_pct * 9 / 100, 1)) } else "-" }) output$stat_fb_velo <- renderText({ current_pitcher <- current_pitcher_reactive() req(current_pitcher) fb_data <- base_data() %>% filter(Pitcher == current_pitcher, TaggedPitchType %in% c("Fastball", "Four-Seam", "FourSeamFastBall", "Sinker", "Two-Seam", "TwoSeamFastBall"), !is.na(RelSpeed)) if (nrow(fb_data) > 0) { paste0(round(mean(fb_data$RelSpeed, na.rm = TRUE), 1)) } else "-" }) output$stat_k_pct <- renderText({ adv_stats <- advanced_stats() if (!is.null(adv_stats)) paste0(adv_stats$k_pct, "%") else "-" }) output$stat_bb_pct <- renderText({ adv_stats <- advanced_stats() if (!is.null(adv_stats)) paste0(adv_stats$bb_pct, "%") else "-" }) output$stat_csw <- renderText({ current_pitcher <- current_pitcher_reactive() req(current_pitcher) p_data <- base_data() %>% filter(Pitcher == current_pitcher) if (nrow(p_data) > 0) { csw <- sum(p_data$PitchCall %in% c("StrikeCalled", "StrikeSwinging"), na.rm = TRUE) / nrow(p_data) * 100 paste0(round(csw, 1), "%") } else "-" }) output$stat_fip <- renderText({ adv_stats <- advanced_stats() if (!is.null(adv_stats)) as.character(adv_stats$fip) else "-" }) output$stat_whip <- renderText({ adv_stats <- advanced_stats() if (!is.null(adv_stats)) as.character(adv_stats$whip) else "-" }) output$stat_ba_l <- renderText({ adv_stats <- advanced_stats() if (!is.null(adv_stats)) as.character(adv_stats$ba_vs_l) else "-" }) output$stat_ba_r <- renderText({ adv_stats <- advanced_stats() if (!is.null(adv_stats)) as.character(adv_stats$ba_vs_r) else "-" }) output$mvmt_pitch_type_filter_ui <- renderUI({ current_pitcher <- if (!is.null(input$active_pitcher) && input$active_pitcher != "") { input$active_pitcher } else { current_pitcher_reactive() } if (is.null(current_pitcher) || current_pitcher == "") { return(span("Select a pitcher", style = "color: gray; font-style: italic;")) } data <- filtered_data() pitcher_pitches <- data %>% filter(Pitcher == current_pitcher, !is.na(TaggedPitchType), TaggedPitchType != "Other", !is.na(HorzBreak), !is.na(InducedVertBreak)) %>% count(TaggedPitchType) %>% filter(n >= 3) %>% arrange(desc(n)) if (nrow(pitcher_pitches) == 0) { return(span("No pitch data", style = "color: gray;")) } choices <- setNames(pitcher_pitches$TaggedPitchType, paste0(pitcher_pitches$TaggedPitchType, " (", pitcher_pitches$n, ")")) selected <- pitcher_pitches$TaggedPitchType checkboxGroupInput("mvmt_pitch_types", label = NULL, choices = choices, selected = selected, inline = TRUE) }) # ===== PITCHER STATS GT TABLE ===== output$pitcher_stats_gt <- gt::render_gt({ adv_stats <- advanced_stats() if (is.null(adv_stats)) { return(gt::gt(data.frame(Message = "Select a pitcher")) %>% gt::tab_options(table.font.size = gt::px(11))) } combined <- data.frame( Stat = c("IP", "G", "IP/G", "P/G", "Last 3", "RA9", "FIP", "WHIP", "K%", "BB%", "BA L", "BA R", "SLG L", "SLG R", "Whiff%", "Zone%", "Strike%", "FB Str%", "BB Str%", "OS Str%"), Value = c(adv_stats$ip, adv_stats$n_games, adv_stats$avg_ip, adv_stats$avg_pitches, adv_stats$last_3_outings, adv_stats$ra9, adv_stats$fip, adv_stats$whip, paste0(adv_stats$k_pct, "%"), paste0(adv_stats$bb_pct, "%"), adv_stats$ba_vs_l, adv_stats$ba_vs_r, adv_stats$slg_vs_l, adv_stats$slg_vs_r, paste0(adv_stats$whiff_pct, "%"), paste0(adv_stats$zone_pct, "%"), paste0(adv_stats$strike_pct, "%"), paste0(adv_stats$fb_strike_pct, "%"), paste0(adv_stats$bb_strike_pct, "%"), paste0(adv_stats$os_strike_pct, "%")) ) wide_df <- as.data.frame(t(combined$Value)) names(wide_df) <- combined$Stat gt::gt(wide_df) %>% gt::tab_style(style = list(gt::cell_fill(color = "#006F71"), gt::cell_text(color = "white", weight = "bold", size = gt::px(10))), locations = gt::cells_column_labels()) %>% gt::cols_align(align = "center") %>% gt::tab_options(table.font.size = gt::px(11), data_row.padding = gt::px(8), column_labels.padding = gt::px(4), table.width = gt::pct(100)) }) # ===== HEATMAP OUTPUTS (use base_data) ===== output$hm_fb_lhh <- renderPlot({ req(current_pitcher_reactive()) create_pitch_group_heatmap(base_data(), current_pitcher_reactive(), "FB", "All", "Left") }, bg = "transparent") output$hm_fb_rhh <- renderPlot({ req(current_pitcher_reactive()) create_pitch_group_heatmap(base_data(), current_pitcher_reactive(), "FB", "All", "Right") }, bg = "transparent") output$hm_os_lhh <- renderPlot({ req(current_pitcher_reactive()) create_pitch_group_heatmap(base_data(), current_pitcher_reactive(), "OS", "All", "Left") }, bg = "transparent") output$hm_os_rhh <- renderPlot({ req(current_pitcher_reactive()) create_pitch_group_heatmap(base_data(), current_pitcher_reactive(), "OS", "All", "Right") }, bg = "transparent") # ===== FACETED HEATMAP OUTPUTS (use base_data) ===== output$faceted_hm_fb_lhh <- renderPlot({ req(current_pitcher_reactive()) create_location_by_result_faceted(base_data(), current_pitcher_reactive(), "Fastballs", "Left") }, bg = "transparent") output$faceted_hm_fb_rhh <- renderPlot({ req(current_pitcher_reactive()) create_location_by_result_faceted(base_data(), current_pitcher_reactive(), "Fastballs", "Right") }, bg = "transparent") output$faceted_hm_os_lhh <- renderPlot({ req(current_pitcher_reactive()) create_location_by_result_faceted(base_data(), current_pitcher_reactive(), "Offspeed", "Left") }, bg = "transparent") output$faceted_hm_os_rhh <- renderPlot({ req(current_pitcher_reactive()) create_location_by_result_faceted(base_data(), current_pitcher_reactive(), "Offspeed", "Right") }, bg = "transparent") # Series pitchers dropdown observe({ req(authed()) updateSelectizeInput(session, "series_pitchers", choices = available_pitchers()) }) # ===== SERIES PITCHERS SYNC ===== observeEvent(input$series_pitchers, { pitchers <- input$series_pitchers if (!is.null(pitchers) && length(pitchers) > 0) { updateSelectInput(session, "active_pitcher", choices = pitchers, selected = pitchers[1]) } }) # ===== NOTES STORAGE FOR MULTIPLE PITCHERS ===== pitcher_notes_storage <- reactiveVal(list()) pitcher_roles <- reactiveVal(list()) # Save notes for current pitcher observeEvent(input$save_pitcher_notes, { current_pitcher <- current_pitcher_reactive() if (is.null(current_pitcher) || current_pitcher == "") { showNotification("No pitcher selected", type = "error") return() } storage <- pitcher_notes_storage() # Get pitcher number and class from dynamic inputs p_safe <- gsub("[^a-zA-Z0-9]", "_", current_pitcher) p_number <- input[[paste0("num_", p_safe)]] %||% "" p_class <- input[[paste0("class_", p_safe)]] %||% "" storage[[current_pitcher]] <- list( number = p_number, class = p_class, delivery_notes = input$pdf_delivery_notes, matchup_notes = input$pdf_matchup_notes, stamina_notes = input$pdf_stamina_notes, vs_lhh_notes = input$pdf_vs_lhh_notes, vs_rhh_notes = input$pdf_vs_rhh_notes, go_zones_rhh = input$go_zones_rhh, go_zones_lhh = input$go_zones_lhh, no_zones_rhh = input$no_zones_rhh, no_zones_lhh = input$no_zones_lhh, pitch_notes = get_dynamic_pitch_notes(), shape_notes = get_shape_notes() ) pitcher_notes_storage(storage) showNotification(paste("Notes saved for", current_pitcher), type = "message", duration = 2) }) # Helper function to get pitch notes from dynamic inputs get_dynamic_pitch_notes <- function() { notes <- list() current_pitcher <- current_pitcher_reactive() if (is.null(current_pitcher) || current_pitcher == "") return(notes) pitcher_pitches <- base_data() %>% filter(Pitcher == current_pitcher, !is.na(TaggedPitchType), TaggedPitchType != "Other") %>% count(TaggedPitchType) %>% filter(n >= 10) %>% arrange(desc(n)) %>% pull(TaggedPitchType) for (pt in pitcher_pitches) { input_id <- paste0("pdf_note_", gsub("[^a-zA-Z]", "", tolower(pt))) if (!is.null(input[[input_id]])) { notes[[pt]] <- input[[input_id]] } } notes } # Helper function to get shape notes from dynamic inputs get_shape_notes <- function() { notes <- list() current_pitcher <- current_pitcher_reactive() if (is.null(current_pitcher) || current_pitcher == "") return(notes) pitcher_pitches <- base_data() %>% filter(Pitcher == current_pitcher, !is.na(TaggedPitchType), TaggedPitchType != "Other") %>% count(TaggedPitchType) %>% filter(n >= 10) %>% arrange(desc(n)) %>% pull(TaggedPitchType) for (pt in pitcher_pitches) { input_id <- paste0("shape_note_", gsub("[^a-zA-Z]", "", tolower(pt))) if (!is.null(input[[input_id]])) { notes[[pt]] <- input[[input_id]] } } notes } # Load notes when switching pitchers observeEvent(input$active_pitcher, { current_pitcher <- input$active_pitcher if (is.null(current_pitcher) || current_pitcher == "") return() storage <- pitcher_notes_storage() if (current_pitcher %in% names(storage)) { saved <- storage[[current_pitcher]] updateTextAreaInput(session, "pdf_delivery_notes", value = saved$delivery_notes %||% "") updateTextAreaInput(session, "pdf_matchup_notes", value = saved$matchup_notes %||% "") updateTextAreaInput(session, "pdf_stamina_notes", value = saved$stamina_notes %||% "") updateTextAreaInput(session, "pdf_vs_lhh_notes", value = saved$vs_lhh_notes %||% "") updateTextAreaInput(session, "pdf_vs_rhh_notes", value = saved$vs_rhh_notes %||% "") # Load Go Zones / No Zones updateTextInput(session, "go_zones_rhh", value = saved$go_zones_rhh %||% "") updateTextInput(session, "go_zones_lhh", value = saved$go_zones_lhh %||% "") updateTextInput(session, "no_zones_rhh", value = saved$no_zones_rhh %||% "") updateTextInput(session, "no_zones_lhh", value = saved$no_zones_lhh %||% "") } else { updateTextAreaInput(session, "pdf_delivery_notes", value = "") updateTextAreaInput(session, "pdf_matchup_notes", value = "") updateTextAreaInput(session, "pdf_stamina_notes", value = "") updateTextAreaInput(session, "pdf_vs_lhh_notes", value = "") updateTextAreaInput(session, "pdf_vs_rhh_notes", value = "") updateTextInput(session, "go_zones_rhh", value = "") updateTextInput(session, "go_zones_lhh", value = "") updateTextInput(session, "no_zones_rhh", value = "") updateTextInput(session, "no_zones_lhh", value = "") } }) output$pitcher_roles_ui <- renderUI({ pitchers <- input$series_pitchers if (is.null(pitchers) || length(pitchers) == 0) { return(p("Select pitchers above to assign numbers and classes", style = "color: gray;")) } class_choices <- c("", "FR", "SO", "JR", "SR", "GR", "RS FR", "RS SO", "RS JR", "RS SR") # Load saved values storage <- pitcher_notes_storage() fluidRow( lapply(pitchers, function(p) { saved <- storage[[p]] saved_number <- saved$number %||% "" saved_class <- saved$class %||% "" p_safe <- gsub("[^a-zA-Z0-9]", "_", p) column(2, div(style = "border: 1px solid #ddd; border-radius: 6px; padding: 6px; margin-bottom: 5px; background: #fafafa;", tags$label(substr(p, 1, 18), style = "font-size: 11px; font-weight: bold; display: block; margin-bottom: 3px; color: #333;"), fluidRow( column(5, textInput(paste0("num_", p_safe), label = NULL, value = saved_number, placeholder = "#", width = "100%") ), column(7, selectInput(paste0("class_", p_safe), label = NULL, choices = class_choices, selected = saved_class, width = "100%") ) ) ) ) }) ) }) # ===== PITCH TYPE FILTER UI ===== output$pitch_type_filter_ui <- renderUI({ current_pitcher <- if (!is.null(input$active_pitcher) && input$active_pitcher != "") { input$active_pitcher } else if (!is.null(input$series_pitchers) && length(input$series_pitchers) > 0) { input$series_pitchers[1] } else { NULL } if (is.null(current_pitcher)) { return(p("Select a pitcher to filter pitch types", style = "color: gray; font-style: italic;")) } pitcher_pitches <- base_data() %>% filter(Pitcher == current_pitcher, !is.na(TaggedPitchType), TaggedPitchType != "Other") %>% count(TaggedPitchType) %>% arrange(desc(n)) if (nrow(pitcher_pitches) == 0) { return(p("No pitch data available", style = "color: gray;")) } choices <- setNames(pitcher_pitches$TaggedPitchType, paste0(pitcher_pitches$TaggedPitchType, " (", pitcher_pitches$n, ")")) selected <- pitcher_pitches %>% filter(n >= 10) %>% pull(TaggedPitchType) checkboxGroupInput("pitch_type_filter", NULL, choices = choices, selected = selected, inline = TRUE) }) # ===== DYNAMIC PITCH NOTES UI ===== output$dynamic_pitch_notes_ui <- renderUI({ current_pitcher <- if (!is.null(input$active_pitcher) && input$active_pitcher != "") { input$active_pitcher } else { current_pitcher_reactive() } if (is.null(current_pitcher) || current_pitcher == "") { return(p("Select a pitcher to see pitch-specific notes", style = "color: gray;")) } pitcher_pitches <- base_data() %>% filter(Pitcher == current_pitcher, !is.na(TaggedPitchType), TaggedPitchType != "Other") %>% count(TaggedPitchType) %>% filter(n >= 10) %>% arrange(desc(n)) %>% pull(TaggedPitchType) if (length(pitcher_pitches) == 0) { return(p("No pitch data available", style = "color: gray;")) } col_width <- max(2, floor(12 / length(pitcher_pitches))) storage <- pitcher_notes_storage() saved_notes <- if (current_pitcher %in% names(storage)) storage[[current_pitcher]]$pitch_notes else list() fluidRow( lapply(pitcher_pitches, function(pt) { input_id <- paste0("pdf_note_", gsub("[^a-zA-Z]", "", tolower(pt))) saved_value <- saved_notes[[pt]] %||% "" column(col_width, div(class = "pitch-notes-input", textAreaInput(input_id, paste0(pt, " Notes:"), value = saved_value, placeholder = paste0(pt, " notes..."), rows = 3, width = "100%")) ) }) ) }) # ===== SHAPE NOTES UI FOR CONDENSED REPORT ===== output$shape_notes_ui <- renderUI({ current_pitcher <- if (!is.null(input$active_pitcher) && input$active_pitcher != "") { input$active_pitcher } else { current_pitcher_reactive() } if (is.null(current_pitcher) || current_pitcher == "") { return(p("Select a pitcher to add shape notes", style = "color: gray; font-style: italic;")) } pitcher_pitches <- base_data() %>% filter(Pitcher == current_pitcher, !is.na(TaggedPitchType), TaggedPitchType != "Other") %>% group_by(TaggedPitchType) %>% summarise( n = n(), velo_low = round(quantile(RelSpeed, 0.1, na.rm = TRUE), 0), velo_high = round(quantile(RelSpeed, 0.9, na.rm = TRUE), 0), .groups = "drop" ) %>% filter(n >= 10) %>% arrange(desc(n)) if (nrow(pitcher_pitches) == 0) { return(p("No pitch data available", style = "color: gray;")) } storage <- pitcher_notes_storage() saved_shape_notes <- if (current_pitcher %in% names(storage)) storage[[current_pitcher]]$shape_notes else list() tagList( fluidRow( column(2, tags$strong("Pitch Type", style = "font-size: 12px;")), column(2, tags$strong("Velo Range", style = "font-size: 12px;")), column(8, tags$strong("Shape Notes", style = "font-size: 12px;")) ), hr(style = "margin: 5px 0;"), lapply(1:nrow(pitcher_pitches), function(i) { pt <- pitcher_pitches$TaggedPitchType[i] input_id <- paste0("shape_note_", gsub("[^a-zA-Z]", "", tolower(pt))) saved_value <- saved_shape_notes[[pt]] %||% "" pt_color <- pitch_colors[[pt]] %||% "gray50" fluidRow( style = "margin-bottom: 8px; padding: 5px; background: #f8f9fa; border-radius: 5px;", column(2, div(style = paste0("background: ", pt_color, "; color: white; padding: 4px 8px; border-radius: 4px; text-align: center; font-weight: bold; font-size: 11px;"), pt) ), column(2, div(style = "padding-top: 5px; font-size: 12px; color: #333;", paste0(pitcher_pitches$velo_low[i], "-", pitcher_pitches$velo_high[i], " mph")) ), column(8, textInput(input_id, label = NULL, value = saved_value, placeholder = paste0("e.g., RIDE, SWEEP, 12-6, FADE+SINK..."), width = "100%") ) ) }) ) }) output$pitcher_report_date <- renderText({ format(Sys.Date(), "%B %d, %Y") }) output$pitcher_hand_display <- renderText({ req(current_pitcher_reactive()) req(current_pitcher_reactive() != "") data <- tryCatch(filtered_data(), error = function(e) NULL) if (is.null(data)) return("Unknown") pitcher_data <- data %>% filter(Pitcher == current_pitcher_reactive()) if (nrow(pitcher_data) > 0 && "PitcherThrows" %in% names(pitcher_data) && !is.na(pitcher_data$PitcherThrows[1])) { paste0(pitcher_data$PitcherThrows[1], "HP") } else { "Unknown" } }) # ===== VALUE BOXES ===== output$vb_pitches <- renderValueBox({ stats <- tryCatch(pitcher_stats(), error = function(e) NULL) valueBox( if (!is.null(stats) && !is.null(stats$overall)) stats$overall$n_pitches else "-", "Pitches", icon = icon("baseball"), color = "teal" ) }) output$vb_strike_pct <- renderValueBox({ stats <- tryCatch(pitcher_stats(), error = function(e) NULL) valueBox( if (!is.null(stats) && !is.null(stats$overall)) paste0(round(stats$overall$strike_pct, 1), "%") else "-", "Strike%", icon = icon("bullseye"), color = "green" ) }) output$vb_whiff_pct <- renderValueBox({ stats <- tryCatch(pitcher_stats(), error = function(e) NULL) valueBox( if (!is.null(stats) && !is.null(stats$overall)) paste0(round(stats$overall$whiff_pct, 1), "%") else "-", "Whiff%", icon = icon("wind"), color = "orange" ) }) output$vb_zone_pct <- renderValueBox({ stats <- tryCatch(pitcher_stats(), error = function(e) NULL) valueBox( if (!is.null(stats) && !is.null(stats$overall)) paste0(round(stats$overall$zone_pct, 1), "%") else "-", "Zone%", icon = icon("crosshairs"), color = "blue" ) }) output$vb_k_pct <- renderValueBox({ stats <- tryCatch(pitcher_stats(), error = function(e) NULL) valueBox( if (!is.null(stats) && !is.null(stats$overall)) paste0(round(stats$overall$k_pct, 1), "%") else "-", "K%", icon = icon("k"), color = "red" ) }) output$vb_bb_pct <- renderValueBox({ stats <- tryCatch(pitcher_stats(), error = function(e) NULL) valueBox( if (!is.null(stats) && !is.null(stats$overall)) paste0(round(stats$overall$bb_pct, 1), "%") else "-", "BB%", icon = icon("person-walking"), color = "yellow" ) }) # ===== MAIN REPORT PLOTS ===== output$pitcher_report_arm_slot <- renderPlot({ req(current_pitcher_reactive()) req(nchar(current_pitcher_reactive()) > 0) data <- filtered_data() req(data) create_pitcher_arm_angle_plot(data, current_pitcher_reactive()) }, bg = "transparent") output$pitcher_report_movement <- renderPlot({ req(current_pitcher_reactive()) req(nchar(current_pitcher_reactive()) > 0) data <- filtered_data() req(data) create_pitcher_movement_plot(data, current_pitcher_reactive(), exp_movement_grid()) }, bg = "transparent") output$pitcher_report_count_usage <- renderPlot({ req(current_pitcher_reactive()) req(nchar(current_pitcher_reactive()) > 0) data <- filtered_data() req(data) create_count_usage_pies(data, current_pitcher_reactive()) }, bg = "transparent") output$pitcher_report_characteristics_table <- gt::render_gt({ req(current_pitcher_reactive()) req(nchar(current_pitcher_reactive()) > 0) data <- filtered_data() req(data) create_pitch_characteristics_gt(data, current_pitcher_reactive()) }) # ===== LOCATION HEATMAPS ===== output$pitcher_report_fb_lhh <- renderPlot({ req(current_pitcher_reactive()) req(nchar(current_pitcher_reactive()) > 0) data <- filtered_data() req(data) create_location_by_result_faceted(data, current_pitcher_reactive(), "Fastballs", "Left") }, bg = "transparent") output$pitcher_report_fb_rhh <- renderPlot({ req(current_pitcher_reactive()) req(nchar(current_pitcher_reactive()) > 0) data <- filtered_data() req(data) create_location_by_result_faceted(data, current_pitcher_reactive(), "Fastballs", "Right") }, bg = "transparent") output$pitcher_report_os_lhh <- renderPlot({ req(current_pitcher_reactive()) req(nchar(current_pitcher_reactive()) > 0) data <- filtered_data() req(data) create_location_by_result_faceted(data, current_pitcher_reactive(), "Offspeed", "Left") }, bg = "transparent") output$pitcher_report_os_rhh <- renderPlot({ req(current_pitcher_reactive()) req(nchar(current_pitcher_reactive()) > 0) data <- filtered_data() req(data) create_location_by_result_faceted(data, current_pitcher_reactive(), "Offspeed", "Right") }, bg = "transparent") # ===== PDF PREVIEW PLOTS ===== output$pdf_movement_preview <- renderPlot({ current_pitcher <- if (!is.null(input$active_pitcher) && input$active_pitcher != "") { input$active_pitcher } else { current_pitcher_reactive() } req(current_pitcher) req(nchar(current_pitcher) > 0) data <- filtered_data() req(data) show_arm_rays <- if (!is.null(input$mvmt_show_arm_rays)) input$mvmt_show_arm_rays else TRUE show_arm_annotation <- if (!is.null(input$mvmt_show_arm_annotation)) input$mvmt_show_arm_annotation else TRUE marker_style <- if (!is.null(input$mvmt_marker_style)) input$mvmt_marker_style else "scaled_usage" show_pitches <- if (!is.null(input$mvmt_show_pitches)) input$mvmt_show_pitches else TRUE show_expected <- if (!is.null(input$mvmt_show_expected)) input$mvmt_show_expected else TRUE selected_pitch_types <- input$mvmt_pitch_types create_pitcher_movement_plot( data = data, pitcher_name = current_pitcher, expected_grid = exp_movement_grid(), show_arm_angle_rays = show_arm_rays, show_arm_angle_annotation = show_arm_annotation, marker_style = marker_style, show_individual_pitches = show_pitches, show_expected_diamonds = show_expected, selected_pitch_types = selected_pitch_types ) }, bg = "transparent") output$pdf_arm_angle_preview <- renderPlot({ current_pitcher <- if (!is.null(input$active_pitcher) && input$active_pitcher != "") { input$active_pitcher } else { current_pitcher_reactive() } req(current_pitcher) req(nchar(current_pitcher) > 0) data <- filtered_data() req(data) create_pitcher_arm_angle_plot(data, current_pitcher) }, bg = "transparent") output$pdf_count_usage_preview <- renderPlot({ current_pitcher <- if (!is.null(input$active_pitcher) && input$active_pitcher != "") { input$active_pitcher } else { current_pitcher_reactive() } req(current_pitcher) req(nchar(current_pitcher) > 0) data <- filtered_data() req(data) create_count_usage_pies(data, current_pitcher) }, bg = "transparent") output$pdf_pitch_characteristics_preview <- gt::render_gt({ current_pitcher <- if (!is.null(input$active_pitcher) && input$active_pitcher != "") { input$active_pitcher } else { current_pitcher_reactive() } req(current_pitcher) req(nchar(current_pitcher) > 0) data <- filtered_data() req(data) create_pitch_characteristics_gt(data, current_pitcher) }) # ===== SEQUENCING PREVIEWS ===== output$pdf_seq_lhh_preview <- renderPlot({ current_pitcher <- if (!is.null(input$active_pitcher) && input$active_pitcher != "") { input$active_pitcher } else { current_pitcher_reactive() } req(current_pitcher) req(nchar(current_pitcher) > 0) data <- filtered_data() req(data) create_sequencing_matrix(data, current_pitcher, "Left") }, bg = "transparent") output$pdf_seq_rhh_preview <- renderPlot({ current_pitcher <- if (!is.null(input$active_pitcher) && input$active_pitcher != "") { input$active_pitcher } else { current_pitcher_reactive() } req(current_pitcher) req(nchar(current_pitcher) > 0) data <- filtered_data() req(data) create_sequencing_matrix(data, current_pitcher, "Right") }, bg = "transparent") # ===== HEATMAP PREVIEWS (use filtered_data) ===== output$pdf_hm_fb_lhh <- renderPlot({ current_pitcher <- if (!is.null(input$active_pitcher) && input$active_pitcher != "") input$active_pitcher else current_pitcher_reactive() req(current_pitcher) create_pitch_group_heatmap(filtered_data(), current_pitcher, "FB", "All", "Left") }, bg = "transparent") output$pdf_hm_bb_lhh <- renderPlot({ current_pitcher <- if (!is.null(input$active_pitcher) && input$active_pitcher != "") input$active_pitcher else current_pitcher_reactive() req(current_pitcher) create_pitch_group_heatmap(filtered_data(), current_pitcher, "BB", "All", "Left") }, bg = "transparent") output$pdf_hm_os_lhh <- renderPlot({ current_pitcher <- if (!is.null(input$active_pitcher) && input$active_pitcher != "") input$active_pitcher else current_pitcher_reactive() req(current_pitcher) create_pitch_group_heatmap(filtered_data(), current_pitcher, "OS", "All", "Left") }, bg = "transparent") output$pdf_hm_fb_rhh <- renderPlot({ current_pitcher <- if (!is.null(input$active_pitcher) && input$active_pitcher != "") input$active_pitcher else current_pitcher_reactive() req(current_pitcher) create_pitch_group_heatmap(filtered_data(), current_pitcher, "FB", "All", "Right") }, bg = "transparent") output$pdf_hm_bb_rhh <- renderPlot({ current_pitcher <- if (!is.null(input$active_pitcher) && input$active_pitcher != "") input$active_pitcher else current_pitcher_reactive() req(current_pitcher) create_pitch_group_heatmap(filtered_data(), current_pitcher, "BB", "All", "Right") }, bg = "transparent") output$pdf_hm_os_rhh <- renderPlot({ current_pitcher <- if (!is.null(input$active_pitcher) && input$active_pitcher != "") input$active_pitcher else current_pitcher_reactive() req(current_pitcher) create_pitch_group_heatmap(filtered_data(), current_pitcher, "OS", "All", "Right") }, bg = "transparent") # ===== DAMAGE AND WHIFF HEATMAPS ===== output$pdf_damage_lhh <- renderPlot({ current_pitcher <- if (!is.null(input$active_pitcher) && input$active_pitcher != "") input$active_pitcher else current_pitcher_reactive() req(current_pitcher) create_pitch_group_heatmap(filtered_data(), current_pitcher, "All", "Dmg", "Left") }, bg = "transparent") output$pdf_damage_rhh <- renderPlot({ current_pitcher <- if (!is.null(input$active_pitcher) && input$active_pitcher != "") input$active_pitcher else current_pitcher_reactive() req(current_pitcher) create_pitch_group_heatmap(filtered_data(), current_pitcher, "All", "Dmg", "Right") }, bg = "transparent") output$pdf_whiff_lhh <- renderPlot({ current_pitcher <- if (!is.null(input$active_pitcher) && input$active_pitcher != "") input$active_pitcher else current_pitcher_reactive() req(current_pitcher) create_pitch_group_heatmap(filtered_data(), current_pitcher, "All", "Whf", "Left") }, bg = "transparent") output$pdf_whiff_rhh <- renderPlot({ current_pitcher <- if (!is.null(input$active_pitcher) && input$active_pitcher != "") input$active_pitcher else current_pitcher_reactive() req(current_pitcher) create_pitch_group_heatmap(filtered_data(), current_pitcher, "All", "Whf", "Right") }, bg = "transparent") # ===== COUNT-BASED HEATMAPS UI ===== output$pdf_count_heatmaps_ui <- renderUI({ current_pitcher <- if (!is.null(input$active_pitcher) && input$active_pitcher != "") input$active_pitcher else current_pitcher_reactive() if (is.null(current_pitcher) || current_pitcher == "") return(NULL) pitcher_data <- base_data() %>% filter(Pitcher == current_pitcher, !is.na(TaggedPitchType), TaggedPitchType != "Other") if (nrow(pitcher_data) < 50) return(p("Not enough data for count heatmaps")) top_pitches <- pitcher_data %>% count(TaggedPitchType) %>% arrange(desc(n)) %>% head(2) %>% pull(TaggedPitchType) pitch_short <- sapply(top_pitches, function(p) { case_when( p %in% c("Fastball", "Four-Seam") ~ "FB", p == "Sinker" ~ "SI", p %in% c("Slider", "Sweeper") ~ "SL", p == "Curveball" ~ "CB", p %in% c("Changeup", "ChangeUp") ~ "CH", p == "Cutter" ~ "CT", TRUE ~ substr(p, 1, 2) ) }) tagList( lapply(seq_along(top_pitches), function(i) { pt <- top_pitches[i] ps <- pitch_short[i] fluidRow( column(4, div(style = "text-align: center; font-size: 10px; font-weight: bold;", paste0("Ahead ", ps)), plotOutput(paste0("pdf_count_hm_ahead_", i), height = 100) ), column(4, div(style = "text-align: center; font-size: 10px; font-weight: bold;", paste0("Early ", ps)), plotOutput(paste0("pdf_count_hm_early_", i), height = 100) ), column(4, div(style = "text-align: center; font-size: 10px; font-weight: bold;", paste0("2-Strike ", ps)), plotOutput(paste0("pdf_count_hm_2k_", i), height = 100) ) ) }) ) }) # Dynamic count heatmap outputs (use base_data) observe({ current_pitcher <- if (!is.null(input$active_pitcher) && input$active_pitcher != "") input$active_pitcher else current_pitcher_reactive() if (is.null(current_pitcher) || current_pitcher == "") return() pitcher_data <- base_data() %>% filter(Pitcher == current_pitcher, !is.na(TaggedPitchType), TaggedPitchType != "Other") if (nrow(pitcher_data) < 50) return() top_pitches <- pitcher_data %>% count(TaggedPitchType) %>% arrange(desc(n)) %>% head(2) %>% pull(TaggedPitchType) for (i in seq_along(top_pitches)) { local({ idx <- i pt <- top_pitches[idx] output[[paste0("pdf_count_hm_ahead_", idx)]] <- renderPlot({ p_data <- pitcher_data %>% filter(TaggedPitchType == pt, (Balls == 0 & Strikes >= 1) | (Balls == 1 & Strikes == 2), !is.na(PlateLocSide), !is.na(PlateLocHeight)) if (nrow(p_data) < 10) { return(ggplot() + theme_void() + annotate("text", x = 0, y = 2, label = "Low n", size = 3)) } ggplot(p_data, aes(x = PlateLocSide, y = PlateLocHeight)) + stat_density_2d(aes(fill = after_stat(density)), geom = "raster", contour = FALSE) + scale_fill_gradientn(colours = c("white", "blue", "#FF9999", "red", "darkred")) + annotate("rect", xmin = -0.83, xmax = 0.83, ymin = 1.5, ymax = 3.5, fill = NA, color = "black", linewidth = 0.6) + 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.5) + coord_fixed(xlim = c(-2, 2), ylim = c(0, 4.5)) + theme_void() + theme(legend.position = "none") }, bg = "transparent") output[[paste0("pdf_count_hm_early_", idx)]] <- renderPlot({ p_data <- pitcher_data %>% filter(TaggedPitchType == pt, Balls <= 1 & Strikes <= 1, !is.na(PlateLocSide), !is.na(PlateLocHeight)) if (nrow(p_data) < 10) { return(ggplot() + theme_void() + annotate("text", x = 0, y = 2, label = "Low n", size = 3)) } ggplot(p_data, aes(x = PlateLocSide, y = PlateLocHeight)) + stat_density_2d(aes(fill = after_stat(density)), geom = "raster", contour = FALSE) + scale_fill_gradientn(colours = c("white", "blue", "#FF9999", "red", "darkred")) + annotate("rect", xmin = -0.83, xmax = 0.83, ymin = 1.5, ymax = 3.5, fill = NA, color = "black", linewidth = 0.6) + 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.5) + coord_fixed(xlim = c(-2, 2), ylim = c(0, 4.5)) + theme_void() + theme(legend.position = "none") }, bg = "transparent") output[[paste0("pdf_count_hm_2k_", idx)]] <- renderPlot({ p_data <- pitcher_data %>% filter(TaggedPitchType == pt, Strikes == 2, !is.na(PlateLocSide), !is.na(PlateLocHeight)) if (nrow(p_data) < 10) { return(ggplot() + theme_void() + annotate("text", x = 0, y = 2, label = "Low n", size = 3)) } ggplot(p_data, aes(x = PlateLocSide, y = PlateLocHeight)) + stat_density_2d(aes(fill = after_stat(density)), geom = "raster", contour = FALSE) + scale_fill_gradientn(colours = c("white", "blue", "#FF9999", "red", "darkred")) + annotate("rect", xmin = -0.83, xmax = 0.83, ymin = 1.5, ymax = 3.5, fill = NA, color = "black", linewidth = 0.6) + 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.5) + coord_fixed(xlim = c(-2, 2), ylim = c(0, 4.5)) + theme_void() + theme(legend.position = "none") }, bg = "transparent") }) } }) # ===== SINGLE PDF DOWNLOAD (uses base_data) ===== output$download_single_pdf <- downloadHandler( filename = function() { current_pitcher <- current_pitcher_reactive() req(current_pitcher) pitcher_clean <- gsub(" ", "_", current_pitcher) paste0(pitcher_clean, "_Scouting_Report_", format(Sys.Date(), "%Y%m%d"), ".pdf") }, content = function(file) { current_pitcher <- current_pitcher_reactive() req(current_pitcher) withProgress(message = "Generating PDF...", value = 0, { tryCatch({ create_comprehensive_pitcher_pdf(base_data(), current_pitcher, file) incProgress(1) }, error = function(e) { showNotification(paste("Error:", e$message), type = "error") }) }) }, contentType = "application/pdf" ) # ===== PDF DOWNLOAD WITH NOTES (uses base_data) ===== output$download_pdf_with_notes <- downloadHandler( filename = function() { current_pitcher <- if (!is.null(input$active_pitcher) && input$active_pitcher != "") input$active_pitcher else current_pitcher_reactive() req(current_pitcher) pitcher_clean <- gsub(" ", "_", current_pitcher) paste0(pitcher_clean, "_Scouting_Report_", format(Sys.Date(), "%Y%m%d"), ".pdf") }, content = function(file) { current_pitcher <- if (!is.null(input$active_pitcher) && input$active_pitcher != "") input$active_pitcher else current_pitcher_reactive() req(current_pitcher) pitch_notes <- get_dynamic_pitch_notes() delivery_notes <- if (!is.null(input$pdf_delivery_notes)) input$pdf_delivery_notes else "" count_usage_notes <- if (!is.null(input$pdf_count_usage_notes)) input$pdf_count_usage_notes else "" overall_notes <- if (!is.null(input$pdf_overall_notes)) input$pdf_overall_notes else "" vs_lhh_notes <- if (!is.null(input$pdf_vs_lhh_notes)) input$pdf_vs_lhh_notes else "" vs_rhh_notes <- if (!is.null(input$pdf_vs_rhh_notes)) input$pdf_vs_rhh_notes else "" series_title <- if (!is.null(input$series_title)) input$series_title else "" role_input_id <- paste0("role_", gsub("[^a-zA-Z0-9]", "_", current_pitcher)) pitcher_role <- if (!is.null(input[[role_input_id]])) input[[role_input_id]] else "" withProgress(message = 'Generating PDF with Notes', value = 0, { incProgress(0.2, detail = "Processing data...") incProgress(0.3, detail = "Creating visualizations...") create_comprehensive_pitcher_pdf( data = base_data(), pitcher_name = current_pitcher, output_file = file, delivery_notes = delivery_notes, count_usage_notes = count_usage_notes, pitch_notes = pitch_notes, overall_notes = overall_notes, vs_lhh_notes = vs_lhh_notes, vs_rhh_notes = vs_rhh_notes, series_title = series_title, pitcher_role = pitcher_role, matchup_matrix = NULL ) incProgress(0.5, detail = "Finalizing report...") }) showNotification("PDF with Notes generated!", type = "message", duration = 3) }, contentType = "application/pdf" ) # ===== SERIES ZIP DOWNLOAD (uses base_data) ===== output$download_series_zip <- downloadHandler( filename = function() { series_title <- if (!is.null(input$series_title) && nchar(input$series_title) > 0) { gsub("[^a-zA-Z0-9]", "_", input$series_title) } else { "Scouting_Reports" } paste0(series_title, "_", format(Sys.Date(), "%Y%m%d"), ".zip") }, content = function(file) { pitchers <- input$series_pitchers if (is.null(pitchers) || length(pitchers) == 0) { pitchers <- current_pitcher_reactive() } if (is.null(pitchers) || length(pitchers) == 0) { showNotification("No pitchers selected for series", type = "error") return(NULL) } series_title <- if (!is.null(input$series_title)) input$series_title else "" storage <- pitcher_notes_storage() temp_dir <- tempdir() pdf_files <- c() withProgress(message = 'Generating Series PDFs', value = 0, { for (i in seq_along(pitchers)) { pitcher <- pitchers[i] incProgress(1/length(pitchers), detail = paste("Creating", pitcher, "...")) if (pitcher %in% names(storage)) { saved <- storage[[pitcher]] delivery_notes <- saved$delivery_notes %||% "" count_usage_notes <- saved$count_usage_notes %||% "" overall_notes <- saved$overall_notes %||% "" vs_lhh_notes <- saved$vs_lhh_notes %||% "" vs_rhh_notes <- saved$vs_rhh_notes %||% "" pitch_notes <- saved$pitch_notes %||% list() } else { delivery_notes <- "" count_usage_notes <- "" overall_notes <- "" vs_lhh_notes <- "" vs_rhh_notes <- "" pitch_notes <- list() } role_input_id <- paste0("role_", gsub("[^a-zA-Z0-9]", "_", pitcher)) pitcher_role <- if (!is.null(input[[role_input_id]])) input[[role_input_id]] else "" pitcher_clean <- gsub(" ", "_", pitcher) role_prefix <- if (nchar(pitcher_role) > 0) paste0(pitcher_role, "_") else "" pdf_filename <- file.path(temp_dir, paste0(role_prefix, pitcher_clean, "_Report.pdf")) tryCatch({ create_comprehensive_pitcher_pdf( data = base_data(), pitcher_name = pitcher, output_file = pdf_filename, delivery_notes = delivery_notes, count_usage_notes = count_usage_notes, pitch_notes = pitch_notes, overall_notes = overall_notes, vs_lhh_notes = vs_lhh_notes, vs_rhh_notes = vs_rhh_notes, series_title = series_title, pitcher_role = pitcher_role ) pdf_files <- c(pdf_files, pdf_filename) }, error = function(e) { showNotification(paste("Error creating PDF for", pitcher, ":", e$message), type = "warning") }) } }) if (length(pdf_files) > 0) { zip(file, pdf_files, flags = "-j") showNotification(paste("Created ZIP with", length(pdf_files), "reports"), type = "message", duration = 3) } else { showNotification("No PDFs were created", type = "error") } }, contentType = "application/zip" ) output$download_condensed_report <- downloadHandler( filename = function() { series_title <- if (!is.null(input$series_title) && nchar(input$series_title) > 0) { gsub("[^a-zA-Z0-9]", "_", input$series_title) } else { "Pitcher_Report" } paste0(series_title, "_Condensed_", format(Sys.Date(), "%Y%m%d"), ".pdf") }, content = function(file) { roster <- pitcher_roster() if (nrow(roster) == 0) { showNotification("No pitchers in roster. Select an opponent team or add pitchers manually.", type = "error") return(NULL) } pitchers <- roster$Pitcher # Helper: parse "RHH: xxx, LHH: yyy" format into list(rhh, lhh) parse_zone_text <- function(raw_text) { rhh <- ""; lhh <- "" if (nchar(raw_text) > 0) { if (grepl("RHH:", raw_text, ignore.case = TRUE)) { rhh <- trimws(sub(".*RHH:\\s*([^,]+).*", "\\1", raw_text, ignore.case = TRUE)) } if (grepl("LHH:", raw_text, ignore.case = TRUE)) { lhh <- trimws(sub(".*LHH:\\s*(.+)$", "\\1", raw_text, ignore.case = TRUE)) } if (nchar(rhh) == 0 && nchar(lhh) == 0 && nchar(raw_text) > 0) { rhh <- raw_text; lhh <- raw_text } } list(rhh = rhh, lhh = lhh) } # Helper: map pitch abbreviation to full name pitch_abbrev_to_full <- function(abbrev) { dplyr::case_when( toupper(abbrev) %in% c("FB", "FF", "FASTBALL") ~ "Fastball", toupper(abbrev) %in% c("SI", "SNK", "SINKER") ~ "Sinker", toupper(abbrev) %in% c("SL", "SLIDER") ~ "Slider", toupper(abbrev) %in% c("CB", "CU", "CURVE", "CURVEBALL") ~ "Curveball", toupper(abbrev) %in% c("CH", "CHANGE", "CHANGEUP") ~ "ChangeUp", toupper(abbrev) %in% c("CT", "CUT", "FC", "CUTTER") ~ "Cutter", toupper(abbrev) %in% c("SW", "SWEEP", "SWEEPER") ~ "Sweeper", toupper(abbrev) %in% c("SP", "SPLIT", "SPLITTER") ~ "Splitter", toupper(abbrev) %in% c("KC", "KNUCKLE") ~ "Knuckle Curve", TRUE ~ abbrev ) } # Get base data to find each pitcher's pitch types for per-pitch shape notes bd <- tryCatch(base_data(), error = function(e) NULL) withProgress(message = 'Generating Condensed Report...', value = 0, { pitcher_notes <- list() storage <- pitcher_notes_storage() for (i in seq_len(nrow(roster))) { pname <- roster$Pitcher[i] saved <- storage[[pname]] if (is.null(saved)) saved <- list() p_safe <- gsub("[^a-zA-Z0-9]", "_", pname) roster_num <- input[[paste0("roster_num_", p_safe)]] roster_class <- input[[paste0("roster_class_", p_safe)]] # Read roster-level go/no zone inputs roster_go_raw <- input[[paste0("roster_go_", p_safe)]] %||% "" roster_no_raw <- input[[paste0("roster_no_", p_safe)]] %||% "" go_parsed <- parse_zone_text(roster_go_raw) no_parsed <- parse_zone_text(roster_no_raw) # Read per-pitch shape notes from roster UI # Input IDs are: roster_shape_{p_safe}_{pt_safe} roster_shape_notes <- saved$shape_notes %||% list() if (!is.null(bd) && nrow(bd) > 0) { p_data <- bd %>% dplyr::filter(Pitcher == pname, !is.na(TaggedPitchType), TaggedPitchType != "Other") if (nrow(p_data) > 0) { pitch_types <- p_data %>% dplyr::count(TaggedPitchType, sort = TRUE) %>% dplyr::mutate(pct = 100 * n / sum(n)) %>% dplyr::filter(pct >= 3) %>% dplyr::pull(TaggedPitchType) for (pt in pitch_types) { pt_safe <- gsub("[^a-zA-Z0-9]", "_", pt) shape_val <- input[[paste0("roster_shape_", p_safe, "_", pt_safe)]] %||% "" if (nchar(shape_val) > 0) { roster_shape_notes[[pt]] <- shape_val } } } } pitcher_notes[[pname]] <- list( number = roster_num %||% saved$number %||% input[[paste0("num_", p_safe)]] %||% "", class = roster_class %||% saved$class %||% input[[paste0("class_", p_safe)]] %||% "", matchup = saved$matchup_notes %||% "", vs_lhh = saved$vs_lhh_notes %||% "", vs_rhh = saved$vs_rhh_notes %||% "", go_zones_rhh = if (nchar(go_parsed$rhh) > 0) go_parsed$rhh else (saved$go_zones_rhh %||% ""), go_zones_lhh = if (nchar(go_parsed$lhh) > 0) go_parsed$lhh else (saved$go_zones_lhh %||% ""), no_zones_rhh = if (nchar(no_parsed$rhh) > 0) no_parsed$rhh else (saved$no_zones_rhh %||% ""), no_zones_lhh = if (nchar(no_parsed$lhh) > 0) no_parsed$lhh else (saved$no_zones_lhh %||% ""), shape_notes = roster_shape_notes ) incProgress(0.3 / length(pitchers)) } report_title <- if (!is.null(input$series_title) && nchar(input$series_title) > 0) { input$series_title } else { "Opposing Pitchers Report" } team_info <- NULL if (!is.null(input$opponent_team) && nchar(input$opponent_team) > 0) { team_info <- get_team_info(input$opponent_team) team_info$record <- input$opponent_record %||% "" } incProgress(0.3, detail = "Creating condensed report...") tryCatch({ create_condensed_pitcher_report( data = base_data(), pitcher_names = pitchers, output_file = file, report_title = report_title, pitcher_notes = pitcher_notes, team_info = team_info ) incProgress(0.4, detail = "Complete!") showNotification("Condensed report generated!", type = "message", duration = 3) }, error = function(e) { showNotification(paste("Error creating report:", e$message), type = "error") }) }) }, contentType = "application/pdf" ) # ===== PDF REPORTS TAB OUTPUTS ===== output$pdf_pitcher_name <- renderText({ if (is.null(current_pitcher_reactive()) || current_pitcher_reactive() == "") "None selected" else current_pitcher_reactive() }) # Download handler for comprehensive PDF from sidebar output$download_comprehensive_pdf <- downloadHandler( filename = function() { req(current_pitcher_reactive()) pitcher_clean <- gsub(" ", "_", current_pitcher_reactive()) paste0(pitcher_clean, "_Comprehensive_Report_", format(Sys.Date(), "%Y%m%d"), ".pdf") }, content = function(file) { req(current_pitcher_reactive()) withProgress(message = 'Generating Comprehensive PDF', value = 0, { incProgress(0.2, detail = "Processing data...") create_comprehensive_pitcher_pdf(base_data(), current_pitcher_reactive(), file) incProgress(0.8, detail = "Finalizing report...") }) showNotification("Comprehensive Pitcher PDF generated!", type = "message", duration = 3) }, contentType = "application/pdf" ) # Download handler for comprehensive PDF from main tab output$download_comprehensive_pdf_main <- downloadHandler( filename = function() { req(current_pitcher_reactive()) pitcher_clean <- gsub(" ", "_", current_pitcher_reactive()) paste0(pitcher_clean, "_Comprehensive_Report_", format(Sys.Date(), "%Y%m%d"), ".pdf") }, content = function(file) { req(current_pitcher_reactive()) withProgress(message = 'Generating Comprehensive PDF', value = 0, { incProgress(0.2, detail = "Processing data...") create_comprehensive_pitcher_pdf(base_data(), current_pitcher_reactive(), file) incProgress(0.8, detail = "Finalizing report...") }) showNotification("Comprehensive Pitcher PDF generated!", type = "message", duration = 3) }, contentType = "application/pdf" ) # ===== BULLPEN REPORT SERVER ===== observe({ req(authed()) updateSelectizeInput(session, "bullpen_pitchers", choices = available_pitchers(), server = TRUE) }) pitcher_colors <- c("#E41A1C", "#377EB8", "#4DAF4A", "#984EA3", "#FF7F00", "#FFFF33", "#A65628", "#F781BF", "#999999", "#66C2A5") get_pitcher_color <- function(pitcher, pitchers) { idx <- which(pitchers == pitcher) if (length(idx) > 0) pitcher_colors[((idx - 1) %% length(pitcher_colors)) + 1] else "#333333" } # Calculate comprehensive bullpen stats (uses base_data) bullpen_stats_reactive <- reactive({ req(input$bullpen_pitchers) pitchers <- input$bullpen_pitchers fb_pitches <- c("Fastball", "Sinker", "FourSeamFastBall", "TwoSeamFastBall", "Four-Seam", "Two-Seam") bb_pitches <- c("Slider", "Curveball", "Cutter", "Sweeper", "Slurve") os_pitches <- c("ChangeUp", "Changeup", "Splitter") cur_data <- base_data() pitch_per_game <- cur_data %>% filter(Pitcher %in% pitchers) %>% group_by(Pitcher, Date, GameUID) %>% summarise(game_pitches = n(), .groups = "drop") %>% group_by(Pitcher) %>% summarise( pitch_20 = round(quantile(game_pitches, 0.20, na.rm = TRUE), 0), pitch_80 = round(quantile(game_pitches, 0.80, na.rm = TRUE), 0), .groups = "drop" ) fb_velo <- cur_data %>% filter(Pitcher %in% pitchers, TaggedPitchType %in% fb_pitches, !is.na(RelSpeed)) %>% group_by(Pitcher) %>% summarise( FB_velo_20 = round(quantile(RelSpeed, 0.20, na.rm = TRUE), 1), FB_velo_80 = round(quantile(RelSpeed, 0.80, na.rm = TRUE), 1), .groups = "drop" ) woba_splits <- cur_data %>% filter(Pitcher %in% pitchers) %>% group_by(Pitcher, BatterSide) %>% summarise( woba_val = if("woba" %in% names(cur_data())) round(mean(woba, na.rm = TRUE), 3) else NA_real_, .groups = "drop" ) %>% pivot_wider(names_from = BatterSide, values_from = woba_val, names_prefix = "wOBA_vs_") main_stats <- cur_data %>% filter(Pitcher %in% pitchers) %>% mutate( is_fb = TaggedPitchType %in% fb_pitches, is_bb = TaggedPitchType %in% bb_pitches, is_os = TaggedPitchType %in% os_pitches, is_strike = PitchCall %in% c("StrikeCalled", "StrikeSwinging", "FoulBall", "InPlay") ) %>% group_by(Pitcher) %>% summarise( n_pitches = n(), FB_pct = round(100 * sum(is_fb) / n(), 1), BB_pct = round(100 * sum(is_bb) / n(), 1), OS_pct = round(100 * sum(is_os) / n(), 1), IP = round(sum(OutsOnPlay, na.rm = TRUE) / 3, 1), n_k = sum(KorBB == "Strikeout", na.rm = TRUE), n_bb = sum(KorBB == "Walk", na.rm = TRUE), n_pa = n_distinct(PAofInning), K_pct = round(100 * n_k / n_pa, 1), BB_pct_rate = round(100 * n_bb / n_pa, 1), Strike_pct = round(100 * sum(is_strike) / n(), 1), n_fb = sum(is_fb), n_bb_pit = sum(is_bb), n_os = sum(is_os), FB_Strike_pct = round(100 * sum(is_strike & is_fb) / max(n_fb, 1), 1), BB_Strike_pct = round(100 * sum(is_strike & is_bb) / max(n_bb_pit, 1), 1), OS_Strike_pct = round(100 * sum(is_strike & is_os) / max(n_os, 1), 1), .groups = "drop" ) %>% select(-n_fb, -n_bb_pit, -n_os, -n_pa, -n_k, -n_bb) stats <- main_stats %>% left_join(pitch_per_game, by = "Pitcher") %>% left_join(fb_velo, by = "Pitcher") %>% left_join(woba_splits, by = "Pitcher") stats$Color <- sapply(stats$Pitcher, get_pitcher_color, pitchers = pitchers) stats }) # Bullpen roles table UI output$bullpen_roles_table_ui <- renderUI({ req(input$bullpen_pitchers) pitchers <- input$bullpen_pitchers stats <- bullpen_stats_reactive() tagList( tags$table(class = "table table-bordered table-sm", style = "width: 100%; font-size: 11px;", tags$thead( tags$tr( tags$th("Pitcher", style = "background: #006F71; color: white;"), tags$th("Role", style = "background: #006F71; color: white;"), tags$th("Matchup", style = "background: #006F71; color: white;"), tags$th("P/G", style = "background: #006F71; color: white;"), tags$th("FB Velo", style = "background: #006F71; color: white;"), tags$th("Str%", style = "background: #006F71; color: white;"), tags$th("FB%", style = "background: #006F71; color: white;"), tags$th("BB%", style = "background: #006F71; color: white;"), tags$th("OS%", style = "background: #006F71; color: white;"), tags$th("wOBA vL", style = "background: #006F71; color: white;"), tags$th("wOBA vR", style = "background: #006F71; color: white;"), tags$th("Notes", style = "background: #006F71; color: white;") ) ), tags$tbody( lapply(seq_along(pitchers), function(i) { p <- pitchers[i] p_stats <- stats %>% filter(Pitcher == p) p_color <- get_pitcher_color(p, pitchers) pitch_range <- if(nrow(p_stats) > 0) paste0(p_stats$pitch_20, "-", p_stats$pitch_80) else "" fb_range <- if(nrow(p_stats) > 0) paste0(p_stats$FB_velo_20, "-", p_stats$FB_velo_80) else "" strike_pct <- if(nrow(p_stats) > 0 && !is.na(p_stats$Strike_pct)) paste0(p_stats$Strike_pct, "%") else "" fb_pct <- if(nrow(p_stats) > 0 && !is.na(p_stats$FB_pct)) paste0(p_stats$FB_pct, "%") else "" bb_pct <- if(nrow(p_stats) > 0 && !is.na(p_stats$BB_pct)) paste0(p_stats$BB_pct, "%") else "" os_pct <- if(nrow(p_stats) > 0 && !is.na(p_stats$OS_pct)) paste0(p_stats$OS_pct, "%") else "" woba_vl <- if(nrow(p_stats) > 0 && "wOBA_vs_Left" %in% names(p_stats) && !is.na(p_stats$wOBA_vs_Left)) sprintf("%.3f", p_stats$wOBA_vs_Left) else "" woba_vr <- if(nrow(p_stats) > 0 && "wOBA_vs_Right" %in% names(p_stats) && !is.na(p_stats$wOBA_vs_Right)) sprintf("%.3f", p_stats$wOBA_vs_Right) else "" tags$tr( tags$td(tags$strong(p, style = paste0("color: ", p_color, ";"))), tags$td(textInput(paste0("bp_role_", i), label = NULL, value = "", placeholder = "Role", width = "100%")), tags$td(selectInput(paste0("bp_matchup_", i), label = NULL, choices = c("Both", "RHH", "LHH"), selected = "Both", width = "80px")), tags$td(pitch_range, style = "text-align: center;"), tags$td(fb_range, style = "text-align: center;"), tags$td(strike_pct, style = "text-align: center;"), tags$td(fb_pct, style = "text-align: center;"), tags$td(bb_pct, style = "text-align: center;"), tags$td(os_pct, style = "text-align: center;"), tags$td(woba_vl, style = "text-align: center;"), tags$td(woba_vr, style = "text-align: center;"), tags$td(textInput(paste0("bp_notes_", i), label = NULL, value = "", placeholder = "Notes...", width = "100%")) ) }) ) ) ) }) # Bullpen usage chart (uses base_data) output$bullpen_usage_chart <- renderPlot({ req(input$bullpen_pitchers) as_of_date <- input$bullpen_date pitchers <- input$bullpen_pitchers usage_data <- base_data() %>% filter(Pitcher %in% pitchers) %>% mutate(Date = as.Date(Date)) %>% filter(Date <= as_of_date) %>% group_by(Pitcher, Date) %>% summarise(pitches = n(), .groups = "drop") %>% group_by(Pitcher) %>% summarise( last_1_week = sum(pitches[Date > (as_of_date - 7)], na.rm = TRUE), last_2_weeks = sum(pitches[Date > (as_of_date - 14)], na.rm = TRUE), .groups = "drop" ) %>% pivot_longer(cols = c(last_1_week, last_2_weeks), names_to = "period", values_to = "pitches") pitcher_order <- usage_data %>% filter(period == "last_2_weeks") %>% arrange(desc(pitches)) %>% pull(Pitcher) usage_data$Pitcher <- factor(usage_data$Pitcher, levels = rev(pitcher_order)) usage_data$period <- factor(usage_data$period, levels = c("last_1_week", "last_2_weeks"), labels = c("Last 1 Week", "Last 2 Weeks")) ggplot(usage_data, aes(x = pitches, y = Pitcher, fill = period)) + geom_col(position = position_dodge(width = 0.8), width = 0.7) + geom_text(aes(label = ifelse(pitches > 0, pitches, "")), position = position_dodge(width = 0.8), hjust = -0.2, size = 4, fontface = "bold") + scale_fill_manual(values = c("Last 1 Week" = "#006F71", "Last 2 Weeks" = "#CD853F")) + labs(x = "Pitches", y = NULL, fill = NULL, title = "Pitch Count Usage") + theme_minimal() + theme( legend.position = "top", axis.text.y = element_text(size = 12, face = "bold"), plot.title = element_text(hjust = 0.5, face = "bold", color = "#006F71"), panel.grid.major.y = element_blank() ) + scale_x_continuous(expand = expansion(mult = c(0, 0.15))) }) # Bullpen inning heatmap (uses base_data) output$bullpen_inning_heatmap <- renderPlot({ req(input$bullpen_pitchers) pitchers <- input$bullpen_pitchers inning_data <- base_data() %>% filter(Pitcher %in% pitchers, !is.na(Inning)) %>% mutate(Inning = ifelse(Inning > 9, "X", as.character(Inning))) %>% group_by(Pitcher, Inning) %>% summarise(pitches = n(), .groups = "drop") all_innings <- c("1", "2", "3", "4", "5", "6", "7", "8", "9", "X") complete_grid <- expand.grid(Pitcher = pitchers, Inning = all_innings, stringsAsFactors = FALSE) inning_data <- complete_grid %>% left_join(inning_data, by = c("Pitcher", "Inning")) %>% mutate(pitches = ifelse(is.na(pitches), 0, pitches)) inning_data$Inning <- factor(inning_data$Inning, levels = all_innings) inning_data$Pitcher <- factor(inning_data$Pitcher, levels = rev(pitchers)) ggplot(inning_data, aes(x = Inning, y = Pitcher, fill = pitches)) + geom_tile(color = "white", linewidth = 0.8) + geom_text(aes(label = ifelse(pitches > 0, pitches, "")), size = 3.5, fontface = "bold") + scale_fill_gradient2(low = "#E1463E", mid = "#FFFFCC", high = "#00840D", midpoint = median(inning_data$pitches[inning_data$pitches > 0], na.rm = TRUE), name = "Pitches") + scale_x_discrete(position = "top") + labs(x = NULL, y = NULL, title = "Usage by Inning") + theme_minimal() + theme( axis.text.x = element_text(size = 12, face = "bold"), axis.text.y = element_text(size = 11, face = "bold"), legend.position = "none", panel.grid = element_blank(), plot.title = element_text(hjust = 0.5, face = "bold", color = "#006F71") ) }) # Rolling chart titles output$rolling_chart1_title <- renderText({ stat_names <- c("velo" = "Rolling Velocity by Pitch #", "strike" = "Rolling Strike% by Pitch #", "woba" = "Rolling wOBA by Pitch #", "stuff_plus" = "Rolling Stuff+ by Pitch #") stat_names[input$rolling_stat1] }) output$rolling_chart2_title <- renderText({ stat_names <- c("velo" = "Rolling Velocity by Pitch #", "strike" = "Rolling Strike% by Pitch #", "woba" = "Rolling wOBA by Pitch #", "stuff_plus" = "Rolling Stuff+ by Pitch #") stat_names[input$rolling_stat2] }) # Helper function to create rolling stat data (uses base_data) create_rolling_data <- function(pitchers, stat_type) { fb_pitches <- c("Fastball", "Sinker", "FourSeamFastBall", "TwoSeamFastBall", "Four-Seam", "Two-Seam") rolling_data <- base_data() %>% filter(Pitcher %in% pitchers) %>% arrange(Pitcher, Date, GameUID, PitchofPA) %>% group_by(Pitcher, GameUID) %>% mutate(pitch_in_game = row_number()) %>% ungroup() if (stat_type == "velo") { raw_data <- rolling_data %>% filter(TaggedPitchType %in% fb_pitches, !is.na(RelSpeed)) %>% select(Pitcher, pitch_in_game, value = RelSpeed) } else if (stat_type == "strike") { raw_data <- rolling_data %>% mutate(value = as.numeric(PitchCall %in% c("StrikeCalled", "StrikeSwinging", "FoulBall", "InPlay")) * 100) %>% select(Pitcher, pitch_in_game, value) } else if (stat_type == "woba") { if ("woba" %in% names(rolling_data)) { raw_data <- rolling_data %>% filter(!is.na(woba)) %>% select(Pitcher, pitch_in_game, value = woba) } else { return(data.frame(Pitcher = character(), pitch_num = integer(), value = numeric())) } } else if (stat_type == "stuff_plus") { if ("stuff_plus" %in% names(rolling_data)) { raw_data <- rolling_data %>% filter(!is.na(stuff_plus)) %>% select(Pitcher, pitch_in_game, value = stuff_plus) } else { return(data.frame(Pitcher = character(), pitch_num = integer(), value = numeric())) } } else { return(data.frame(Pitcher = character(), pitch_num = integer(), value = numeric())) } if (nrow(raw_data) == 0) { return(data.frame(Pitcher = character(), pitch_num = integer(), value = numeric())) } result <- raw_data %>% group_by(Pitcher, pitch_num = pitch_in_game) %>% summarise(value = mean(value, na.rm = TRUE), n_games = n(), .groups = "drop") %>% filter(n_games >= 2) result } # Function to calculate stats by pitch count buckets (uses base_data) create_pitch_bucket_stats <- function(pitchers) { fb_pitches <- c("Fastball", "Sinker", "FourSeamFastBall", "TwoSeamFastBall", "Four-Seam", "Two-Seam") pitch_data <- base_data() %>% filter(Pitcher %in% pitchers) %>% arrange(Pitcher, Date, GameUID, PitchofPA) %>% group_by(Pitcher, GameUID) %>% mutate(pitch_in_game = row_number()) %>% ungroup() %>% mutate( pitch_bucket = paste0(floor((pitch_in_game - 1) / 10) * 10 + 1, "-", floor((pitch_in_game - 1) / 10) * 10 + 10), bucket_order = floor((pitch_in_game - 1) / 10) ) bucket_stats <- pitch_data %>% group_by(Pitcher, pitch_bucket, bucket_order) %>% summarise( Pitches = n(), Games = n_distinct(GameUID), `FB Velo` = round(mean(RelSpeed[TaggedPitchType %in% fb_pitches], na.rm = TRUE), 1), `Strike%` = round(100 * sum(PitchCall %in% c("StrikeCalled", "StrikeSwinging", "FoulBall", "InPlay")) / n(), 1), `Whiff%` = round(100 * sum(PitchCall == "StrikeSwinging", na.rm = TRUE) / sum(PitchCall %in% c("StrikeSwinging", "FoulBall", "InPlay"), na.rm = TRUE), 1), `Zone%` = round(100 * mean(PlateLocSide >= -0.83 & PlateLocSide <= 0.83 & PlateLocHeight >= 1.5 & PlateLocHeight <= 3.5, na.rm = TRUE), 1), .groups = "drop" ) %>% arrange(Pitcher, bucket_order) %>% select(-bucket_order) bucket_stats %>% mutate(across(where(is.numeric), ~ifelse(is.nan(.), NA, .))) } # Rolling chart 1 output$bullpen_rolling_chart1 <- renderPlot({ req(input$bullpen_pitchers) pitchers <- input$bullpen_pitchers stat_type <- input$rolling_stat1 rolling_data <- create_rolling_data(pitchers, stat_type) if (is.null(rolling_data) || nrow(rolling_data) == 0) { return(ggplot() + theme_void() + annotate("text", x = 0.5, y = 0.5, label = "Insufficient data")) } max_pitch <- min(max(rolling_data$pitch_num, na.rm = TRUE), 120) ggplot(rolling_data, aes(x = pitch_num, y = value, color = Pitcher)) + geom_line(linewidth = 1.2, alpha = 0.8) + scale_color_manual(values = setNames(sapply(pitchers, get_pitcher_color, pitchers = pitchers), pitchers)) + labs(x = "Pitch # in Game", y = NULL) + scale_x_continuous(limits = c(1, max_pitch), breaks = seq(10, max_pitch, 10)) + theme_minimal() + theme(legend.position = "none", plot.title = element_text(hjust = 0.5, face = "bold", color = "#006F71"), panel.grid.minor = element_blank()) }) # Rolling chart 2 output$bullpen_rolling_chart2 <- renderPlot({ req(input$bullpen_pitchers) pitchers <- input$bullpen_pitchers stat_type <- input$rolling_stat2 rolling_data <- create_rolling_data(pitchers, stat_type) if (is.null(rolling_data) || nrow(rolling_data) == 0) { return(ggplot() + theme_void() + annotate("text", x = 0.5, y = 0.5, label = "Insufficient data")) } max_pitch <- min(max(rolling_data$pitch_num, na.rm = TRUE), 120) ggplot(rolling_data, aes(x = pitch_num, y = value, color = Pitcher)) + geom_line(linewidth = 1.2, alpha = 0.8) + scale_color_manual(values = setNames(sapply(pitchers, get_pitcher_color, pitchers = pitchers), pitchers)) + labs(x = "Pitch # in Game", y = NULL) + scale_x_continuous(limits = c(1, max_pitch), breaks = seq(10, max_pitch, 10)) + theme_minimal() + theme(legend.position = "none", plot.title = element_text(hjust = 0.5, face = "bold", color = "#006F71"), panel.grid.minor = element_blank()) }) # Bullpen summary stats table output$bullpen_summary_table <- gt::render_gt({ req(input$bullpen_pitchers) stats <- bullpen_stats_reactive() display_stats <- stats %>% select(Pitcher, FB_pct, BB_pct, OS_pct, IP, K_pct, BB_pct_rate, Strike_pct, FB_Strike_pct, BB_Strike_pct, OS_Strike_pct) display_stats %>% gt() %>% tab_header(title = "Bullpen Performance Stats") %>% cols_label( Pitcher = "Pitcher", FB_pct = "FB%", BB_pct = "BB%", OS_pct = "OS%", IP = "IP", K_pct = "K%", BB_pct_rate = "BB%", Strike_pct = "Str%", FB_Strike_pct = "FB Str%", BB_Strike_pct = "BB Str%", OS_Strike_pct = "OS Str%" ) %>% tab_style(style = cell_fill(color = "#006F71"), locations = cells_column_labels()) %>% tab_style(style = cell_text(color = "white", weight = "bold"), locations = cells_column_labels()) %>% fmt_number(columns = c(FB_pct, BB_pct, OS_pct, K_pct, BB_pct_rate, Strike_pct, FB_Strike_pct, BB_Strike_pct, OS_Strike_pct), decimals = 1) %>% data_color(columns = K_pct, fn = scales::col_numeric(palette = c("#E1463E", "white", "#00840D"), domain = c(15, 35))) %>% data_color(columns = BB_pct_rate, fn = scales::col_numeric(palette = c("#00840D", "white", "#E1463E"), domain = c(5, 15))) %>% data_color(columns = Strike_pct, fn = scales::col_numeric(palette = c("#E1463E", "white", "#00840D"), domain = c(55, 70))) }) # Download bullpen PDF (uses base_data) output$download_bullpen_pdf <- downloadHandler( filename = function() { paste0("Bullpen_Report_", format(input$bullpen_date, "%Y%m%d"), ".pdf") }, content = function(file) { req(input$bullpen_pitchers) withProgress(message = "Generating Bullpen Report...", value = 0, { incProgress(0.2, detail = "Calculating stats...") pitchers <- input$bullpen_pitchers stats <- bullpen_stats_reactive() as_of_date <- input$bullpen_date cur_data <- base_data() pdf(file, width = 11, height = 14) grid::grid.newpage() # Header grid::grid.rect(x = 0.5, y = 0.97, width = 1, height = 0.05, gp = grid::gpar(fill = "#006F71", col = NA)) grid::grid.text(if(nchar(input$bullpen_title) > 0) input$bullpen_title else "Auto Bullpen Report", x = 0.5, y = 0.965, gp = grid::gpar(fontsize = 22, fontface = "bold", col = "white")) grid::grid.text(paste("As of", format(as_of_date, "%m/%d/%y")), x = 0.5, y = 0.935, gp = grid::gpar(fontsize = 10, col = "white")) incProgress(0.3, detail = "Drawing tables...") table_y <- 0.90 row_h <- 0.018 col_widths <- c(0.10, 0.10, 0.05, 0.06, 0.07, 0.05, 0.05, 0.05, 0.05, 0.06, 0.06, 0.20) x_starts <- cumsum(c(0.05, col_widths[-length(col_widths)])) headers <- c("Pitcher", "Role", "Match", "P/G", "FB Velo", "Str%", "FB%", "BB%", "OS%", "wOBA vL", "wOBA vR", "Notes") for (j in seq_along(headers)) { grid::grid.rect(x = x_starts[j] + col_widths[j]/2, y = table_y, width = col_widths[j], height = row_h, gp = grid::gpar(fill = "#006F71", col = "black"), just = c("center", "top")) grid::grid.text(headers[j], x = x_starts[j] + col_widths[j]/2, y = table_y - row_h/2, gp = grid::gpar(fontsize = 6, fontface = "bold", col = "white")) } for (i in seq_along(pitchers)) { p <- pitchers[i] p_stats <- stats %>% filter(Pitcher == p) p_color <- get_pitcher_color(p, pitchers) y_row <- table_y - i * row_h role <- input[[paste0("bp_role_", i)]] %||% "" matchup <- input[[paste0("bp_matchup_", i)]] %||% "Both" notes <- input[[paste0("bp_notes_", i)]] %||% "" pitch_range <- if(nrow(p_stats) > 0) paste0(p_stats$pitch_20, "-", p_stats$pitch_80) else "" fb_range <- if(nrow(p_stats) > 0) paste0(p_stats$FB_velo_20, "-", p_stats$FB_velo_80) else "" strike_pct <- if(nrow(p_stats) > 0 && !is.na(p_stats$Strike_pct)) paste0(p_stats$Strike_pct, "%") else "" fb_pct <- if(nrow(p_stats) > 0 && !is.na(p_stats$FB_pct)) paste0(p_stats$FB_pct, "%") else "" bb_pct <- if(nrow(p_stats) > 0 && !is.na(p_stats$BB_pct)) paste0(p_stats$BB_pct, "%") else "" os_pct <- if(nrow(p_stats) > 0 && !is.na(p_stats$OS_pct)) paste0(p_stats$OS_pct, "%") else "" woba_vl <- if(nrow(p_stats) > 0 && "wOBA_vs_Left" %in% names(p_stats) && !is.na(p_stats$wOBA_vs_Left)) sprintf("%.3f", p_stats$wOBA_vs_Left) else "" woba_vr <- if(nrow(p_stats) > 0 && "wOBA_vs_Right" %in% names(p_stats) && !is.na(p_stats$wOBA_vs_Right)) sprintf("%.3f", p_stats$wOBA_vs_Right) else "" row_vals <- c(p, role, matchup, pitch_range, fb_range, strike_pct, fb_pct, bb_pct, os_pct, woba_vl, woba_vr, notes) for (j in seq_along(row_vals)) { bg <- if (i %% 2 == 0) "#f8f9fa" else "white" grid::grid.rect(x = x_starts[j] + col_widths[j]/2, y = y_row, width = col_widths[j], height = row_h, gp = grid::gpar(fill = bg, col = "gray70"), just = c("center", "top")) txt_col <- if(j == 1) p_color else "black" grid::grid.text(row_vals[j], x = x_starts[j] + col_widths[j]/2, y = y_row - row_h/2, gp = grid::gpar(fontsize = 5.5, col = txt_col, fontface = if(j==1) "bold" else "plain")) } } incProgress(0.5, detail = "Creating charts...") chart_y <- table_y - (length(pitchers) + 2) * row_h # Usage bar chart usage_data <- cur_data %>% filter(Pitcher %in% pitchers) %>% mutate(Date = as.Date(Date)) %>% filter(Date <= as_of_date) %>% group_by(Pitcher, Date) %>% summarise(pitches = n(), .groups = "drop") %>% group_by(Pitcher) %>% summarise( last_1_week = sum(pitches[Date > (as_of_date - 7)], na.rm = TRUE), last_2_weeks = sum(pitches[Date > (as_of_date - 14)], na.rm = TRUE), .groups = "drop" ) %>% pivot_longer(cols = c(last_1_week, last_2_weeks), names_to = "period", values_to = "pitches") usage_data$Pitcher <- factor(usage_data$Pitcher, levels = rev(pitchers)) usage_data$period <- factor(usage_data$period, levels = c("last_1_week", "last_2_weeks"), labels = c("Last 1 Week", "Last 2 Weeks")) usage_plot <- ggplot(usage_data, aes(x = pitches, y = Pitcher, fill = period)) + geom_col(position = position_dodge(width = 0.8), width = 0.7) + geom_text(aes(label = ifelse(pitches > 0, pitches, "")), position = position_dodge(width = 0.8), hjust = -0.2, size = 2.5) + scale_fill_manual(values = c("Last 1 Week" = "#006F71", "Last 2 Weeks" = "#CD853F")) + labs(x = "Pitches", y = NULL, fill = NULL, title = "Pitch Usage") + theme_minimal() + theme(legend.position = "top", plot.title = element_text(hjust = 0.5, face = "bold", size = 10), axis.text = element_text(size = 7)) grid::pushViewport(grid::viewport(x = 0.26, y = chart_y, width = 0.42, height = 0.22, just = c("center", "top"))) print(usage_plot, newpage = FALSE) grid::popViewport() # Inning heatmap inning_data <- cur_data %>% filter(Pitcher %in% pitchers, !is.na(Inning)) %>% mutate(Inning = ifelse(Inning > 9, "X", as.character(Inning))) %>% group_by(Pitcher, Inning) %>% summarise(pitches = n(), .groups = "drop") all_innings <- c("1", "2", "3", "4", "5", "6", "7", "8", "9", "X") complete_grid <- expand.grid(Pitcher = pitchers, Inning = all_innings, stringsAsFactors = FALSE) inning_data <- complete_grid %>% left_join(inning_data, by = c("Pitcher", "Inning")) %>% mutate(pitches = ifelse(is.na(pitches), 0, pitches)) inning_data$Inning <- factor(inning_data$Inning, levels = all_innings) inning_data$Pitcher <- factor(inning_data$Pitcher, levels = rev(pitchers)) inning_plot <- ggplot(inning_data, aes(x = Inning, y = Pitcher, fill = pitches)) + geom_tile(color = "white", linewidth = 0.5) + geom_text(aes(label = ifelse(pitches > 0, pitches, "")), size = 2) + scale_fill_gradient2(low = "#E1463E", mid = "#FFFFCC", high = "#00840D", midpoint = max(inning_data$pitches)/2, guide = "none") + scale_x_discrete(position = "top") + labs(x = NULL, y = NULL, title = "Usage by Inning") + theme_minimal() + theme(plot.title = element_text(hjust = 0.5, face = "bold", size = 10), panel.grid = element_blank(), axis.text = element_text(size = 7)) grid::pushViewport(grid::viewport(x = 0.74, y = chart_y, width = 0.42, height = 0.22, just = c("center", "top"))) print(inning_plot, newpage = FALSE) grid::popViewport() incProgress(0.7, detail = "Creating rolling charts...") rolling_y <- chart_y - 0.25 max_pitch_num <- cur_data %>% filter(Pitcher %in% pitchers) %>% group_by(Pitcher, GameUID) %>% summarise(max_p = n(), .groups = "drop") %>% summarise(max_overall = max(max_p)) %>% pull(max_overall) max_pitch_num <- min(max_pitch_num, 120) stat_names <- c("velo" = "Rolling Velocity by Pitch #", "strike" = "Rolling Strike% by Pitch #", "woba" = "Rolling wOBA by Pitch #", "stuff_plus" = "Rolling Stuff+ by Pitch #") # Rolling chart 1 stat_type1 <- input$rolling_stat1 rolling_data1 <- create_rolling_data(pitchers, stat_type1) if (!is.null(rolling_data1) && nrow(rolling_data1) > 0) { rolling_plot1 <- ggplot(rolling_data1, aes(x = pitch_num, y = value, color = Pitcher)) + geom_line(linewidth = 1, alpha = 0.8) + scale_color_manual(values = setNames(sapply(pitchers, get_pitcher_color, pitchers = pitchers), pitchers)) + labs(x = "Pitch # in Game", y = NULL, title = stat_names[stat_type1]) + scale_x_continuous(limits = c(1, max_pitch_num), breaks = seq(10, max_pitch_num, 10)) + theme_minimal() + theme(legend.position = "none", plot.title = element_text(hjust = 0.5, face = "bold", size = 10), axis.text = element_text(size = 7)) grid::pushViewport(grid::viewport(x = 0.26, y = rolling_y, width = 0.42, height = 0.20, just = c("center", "top"))) print(rolling_plot1, newpage = FALSE) grid::popViewport() } # Rolling chart 2 stat_type2 <- input$rolling_stat2 rolling_data2 <- create_rolling_data(pitchers, stat_type2) if (!is.null(rolling_data2) && nrow(rolling_data2) > 0) { rolling_plot2 <- ggplot(rolling_data2, aes(x = pitch_num, y = value, color = Pitcher)) + geom_line(linewidth = 1, alpha = 0.8) + scale_color_manual(values = setNames(sapply(pitchers, get_pitcher_color, pitchers = pitchers), pitchers)) + labs(x = "Pitch # in Game", y = NULL, title = stat_names[stat_type2]) + scale_x_continuous(limits = c(1, max_pitch_num), breaks = seq(10, max_pitch_num, 10)) + theme_minimal() + theme(legend.position = "none", plot.title = element_text(hjust = 0.5, face = "bold", size = 10), axis.text = element_text(size = 7)) grid::pushViewport(grid::viewport(x = 0.74, y = rolling_y, width = 0.42, height = 0.20, just = c("center", "top"))) print(rolling_plot2, newpage = FALSE) grid::popViewport() } incProgress(0.85, detail = "Creating pitch bucket stats table...") bucket_stats <- create_pitch_bucket_stats(pitchers) if (!is.null(bucket_stats) && nrow(bucket_stats) > 0) { bucket_table_y <- rolling_y - 0.23 grid::grid.rect(x = 0.5, y = bucket_table_y + 0.015, width = 0.90, height = 0.018, gp = grid::gpar(fill = "#006F71", col = NA)) grid::grid.text("Stats by Pitch Count (Per Game)", x = 0.5, y = bucket_table_y + 0.015, gp = grid::gpar(fontsize = 10, fontface = "bold", col = "white")) bucket_headers <- c("Pitcher", "Pitches", "Bucket", "Games", "FB Velo", "Strike%", "Whiff%", "Zone%") bucket_col_widths <- c(0.12, 0.06, 0.08, 0.06, 0.08, 0.08, 0.08, 0.08) bucket_x_starts <- cumsum(c(0.18, bucket_col_widths[-length(bucket_col_widths)])) bucket_row_h <- 0.014 for (j in seq_along(bucket_headers)) { grid::grid.rect(x = bucket_x_starts[j] + bucket_col_widths[j]/2, y = bucket_table_y - 0.005, width = bucket_col_widths[j], height = bucket_row_h, gp = grid::gpar(fill = "#006F71", col = "black"), just = c("center", "top")) grid::grid.text(bucket_headers[j], x = bucket_x_starts[j] + bucket_col_widths[j]/2, y = bucket_table_y - 0.005 - bucket_row_h/2, gp = grid::gpar(fontsize = 6, fontface = "bold", col = "white")) } max_rows <- min(nrow(bucket_stats), 20) for (i in seq_len(max_rows)) { y_row <- bucket_table_y - 0.005 - i * bucket_row_h p <- bucket_stats$Pitcher[i] p_color <- get_pitcher_color(p, pitchers) row_vals <- c( p, as.character(bucket_stats$Pitches[i]), bucket_stats$pitch_bucket[i], as.character(bucket_stats$Games[i]), ifelse(is.na(bucket_stats$`FB Velo`[i]), "-", sprintf("%.1f", bucket_stats$`FB Velo`[i])), ifelse(is.na(bucket_stats$`Strike%`[i]), "-", sprintf("%.1f%%", bucket_stats$`Strike%`[i])), ifelse(is.na(bucket_stats$`Whiff%`[i]), "-", sprintf("%.1f%%", bucket_stats$`Whiff%`[i])), ifelse(is.na(bucket_stats$`Zone%`[i]), "-", sprintf("%.1f%%", bucket_stats$`Zone%`[i])) ) for (j in seq_along(row_vals)) { bg <- if (i %% 2 == 0) "#f8f9fa" else "white" grid::grid.rect(x = bucket_x_starts[j] + bucket_col_widths[j]/2, y = y_row, width = bucket_col_widths[j], height = bucket_row_h, gp = grid::gpar(fill = bg, col = "gray70"), just = c("center", "top")) txt_col <- if(j == 1) p_color else "black" grid::grid.text(row_vals[j], x = bucket_x_starts[j] + bucket_col_widths[j]/2, y = y_row - bucket_row_h/2, gp = grid::gpar(fontsize = 5, col = txt_col, fontface = if(j==1) "bold" else "plain")) } } } dev.off() incProgress(1, detail = "Done!") }) showNotification("Bullpen Report generated!", type = "message", duration = 3) }, contentType = "application/pdf" ) } # ============================================================================ # RUN APP # ============================================================================ shinyApp(ui = ui, server = server)