diff --git "a/app.R" "b/app.R" new file mode 100644--- /dev/null +++ "b/app.R" @@ -0,0 +1,7719 @@ +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) \ No newline at end of file