library(shiny) library(shinydashboard) library(DT) library(dplyr) library(ggplot2) library(tidyr) library(stringr) library(scales) library(maps) library(mapproj) library(arrow) library(readr) library(xgboost) # Load data TM25 <- read_parquet("TM25.parquet") ccbl_data <- read_parquet("ccbl_data.parquet") # Function to calculate Stuff+ using your models calculate_stuff_plus <- function(data) { # Initialize Stuff+ column first data$StuffPlus <- NA # Check if required columns exist (updated list) required_cols <- c("RelSpeed", "SpinRate", "RelHeight", "RelSide", "Extension", "InducedVertBreak", "HorzBreak", "PitcherThrows") missing_cols <- required_cols[!required_cols %in% names(data)] if(length(missing_cols) > 0) { warning("Missing required columns for Stuff+ calculation: ", paste(missing_cols, collapse = ", ")) return(data) } # Check if models are loaded and xgboost is available if(!exists("RHPFBStuff") || !exists("LHPFBStuff") || !exists("RHPBBStuff") || !exists("LHPBBStuff")) { warning("Stuff+ models not found") return(data) } if(!requireNamespace("xgboost", quietly = TRUE)) { warning("xgboost package not available for Stuff+ calculation") return(data) } tryCatch({ # Create missing columns that the models expect data$SeamShiftedWakeEffect <- ifelse(is.na(data$SeamShiftedWakeEffect), 0, data$SeamShiftedWakeEffect) data$UniquenessScore <- ifelse(is.na(data$UniquenessScore), 0, data$UniquenessScore) # Define the exact feature columns expected by the models model_features <- c("RelSpeed", "SpinRate", "RelHeight", "RelSide", "Extension", "InducedVertBreak", "HorzBreak", "SeamShiftedWakeEffect", "UniquenessScore") # Create prediction matrix for fastballs fb_data <- data %>% filter(TaggedPitchType %in% c("Fastball", "FourSeamFastBall", "FourSeamFastB", "Sinker", "TwoSeamFastBall")) %>% filter(!is.na(RelSpeed), !is.na(SpinRate), !is.na(RelHeight), !is.na(RelSide), !is.na(Extension), !is.na(InducedVertBreak), !is.na(HorzBreak), !is.na(PitcherThrows)) # Create prediction matrix for breaking balls bb_data <- data %>% filter(TaggedPitchType %in% c("Slider", "Curveball", "Changeup", "ChangeUp", "Sweeper", "Cutter", "Splitter")) %>% filter(!is.na(RelSpeed), !is.na(SpinRate), !is.na(RelHeight), !is.na(RelSide), !is.na(Extension), !is.na(InducedVertBreak), !is.na(HorzBreak), !is.na(PitcherThrows)) # Calculate Fastball Stuff+ for RHP if(nrow(fb_data %>% filter(PitcherThrows == "Right")) > 0) { rhp_fb <- fb_data %>% filter(PitcherThrows == "Right") # Create matrix with all model features rhp_fb_matrix <- as.matrix(rhp_fb[, model_features]) # Apply the same preprocessing as used in model training (center and scale) # Get preprocessing info from the model if available if(!is.null(RHPFBStuff$preProcess)) { rhp_fb_matrix_processed <- predict(RHPFBStuff$preProcess, rhp_fb_matrix) } else { # Manual centering and scaling if preProcess object not available rhp_fb_matrix_processed <- scale(rhp_fb_matrix, center = TRUE, scale = TRUE) } # Convert to xgb.DMatrix rhp_fb_dmatrix <- xgboost::xgb.DMatrix(data = rhp_fb_matrix_processed) # Predict using the model directly (not finalModel) if(!is.null(RHPFBStuff$finalModel)) { rhp_fb_pred <- predict(RHPFBStuff$finalModel, rhp_fb_dmatrix) } else { rhp_fb_pred <- predict(RHPFBStuff, rhp_fb_dmatrix) } # Convert to Stuff+ scale (models predict delta_RE, so negative values are better) rhp_fb_stuffplus <- 100 + (rhp_fb_pred * -20) # Find indices in original data rhp_fb_indices <- which(data$TaggedPitchType %in% c("Fastball", "FourSeamFastBall", "FourSeamFastB", "Sinker", "TwoSeamFastBall") & data$PitcherThrows == "Right" & !is.na(data$RelSpeed) & !is.na(data$SpinRate) & !is.na(data$RelHeight) & !is.na(data$RelSide) & !is.na(data$Extension) & !is.na(data$InducedVertBreak) & !is.na(data$HorzBreak) & !is.na(data$PitcherThrows)) if(length(rhp_fb_stuffplus) == length(rhp_fb_indices)) { data$StuffPlus[rhp_fb_indices] <- rhp_fb_stuffplus } } # Calculate Fastball Stuff+ for LHP if(nrow(fb_data %>% filter(PitcherThrows == "Left")) > 0) { lhp_fb <- fb_data %>% filter(PitcherThrows == "Left") lhp_fb_matrix <- as.matrix(lhp_fb[, model_features]) if(!is.null(LHPFBStuff$preProcess)) { lhp_fb_matrix_processed <- predict(LHPFBStuff$preProcess, lhp_fb_matrix) } else { lhp_fb_matrix_processed <- scale(lhp_fb_matrix, center = TRUE, scale = TRUE) } lhp_fb_dmatrix <- xgboost::xgb.DMatrix(data = lhp_fb_matrix_processed) if(!is.null(LHPFBStuff$finalModel)) { lhp_fb_pred <- predict(LHPFBStuff$finalModel, lhp_fb_dmatrix) } else { lhp_fb_pred <- predict(LHPFBStuff, lhp_fb_dmatrix) } lhp_fb_stuffplus <- 100 + (lhp_fb_pred * -20) lhp_fb_indices <- which(data$TaggedPitchType %in% c("Fastball", "FourSeamFastBall", "FourSeamFastB", "Sinker", "TwoSeamFastBall") & data$PitcherThrows == "Left" & !is.na(data$RelSpeed) & !is.na(data$SpinRate) & !is.na(data$RelHeight) & !is.na(data$RelSide) & !is.na(data$Extension) & !is.na(data$InducedVertBreak) & !is.na(data$HorzBreak) & !is.na(data$PitcherThrows)) if(length(lhp_fb_stuffplus) == length(lhp_fb_indices)) { data$StuffPlus[lhp_fb_indices] <- lhp_fb_stuffplus } } # Calculate Breaking Ball Stuff+ for RHP if(nrow(bb_data %>% filter(PitcherThrows == "Right")) > 0) { rhp_bb <- bb_data %>% filter(PitcherThrows == "Right") rhp_bb_matrix <- as.matrix(rhp_bb[, model_features]) if(!is.null(RHPBBStuff$preProcess)) { rhp_bb_matrix_processed <- predict(RHPBBStuff$preProcess, rhp_bb_matrix) } else { rhp_bb_matrix_processed <- scale(rhp_bb_matrix, center = TRUE, scale = TRUE) } rhp_bb_dmatrix <- xgboost::xgb.DMatrix(data = rhp_bb_matrix_processed) if(!is.null(RHPBBStuff$finalModel)) { rhp_bb_pred <- predict(RHPBBStuff$finalModel, rhp_bb_dmatrix) } else { rhp_bb_pred <- predict(RHPBBStuff, rhp_bb_dmatrix) } rhp_bb_stuffplus <- 100 + (rhp_bb_pred * -20) rhp_bb_indices <- which(data$TaggedPitchType %in% c("Slider", "Curveball", "Changeup", "ChangeUp", "Sweeper", "Cutter", "Splitter") & data$PitcherThrows == "Right" & !is.na(data$RelSpeed) & !is.na(data$SpinRate) & !is.na(data$RelHeight) & !is.na(data$RelSide) & !is.na(data$Extension) & !is.na(data$InducedVertBreak) & !is.na(data$HorzBreak) & !is.na(data$PitcherThrows)) if(length(rhp_bb_stuffplus) == length(rhp_bb_indices)) { data$StuffPlus[rhp_bb_indices] <- rhp_bb_stuffplus } } # Calculate Breaking Ball Stuff+ for LHP if(nrow(bb_data %>% filter(PitcherThrows == "Left")) > 0) { lhp_bb <- bb_data %>% filter(PitcherThrows == "Left") lhp_bb_matrix <- as.matrix(lhp_bb[, model_features]) if(!is.null(LHPBBStuff$preProcess)) { lhp_bb_matrix_processed <- predict(LHPBBStuff$preProcess, lhp_bb_matrix) } else { lhp_bb_matrix_processed <- scale(lhp_bb_matrix, center = TRUE, scale = TRUE) } lhp_bb_dmatrix <- xgboost::xgb.DMatrix(data = lhp_bb_matrix_processed) if(!is.null(LHPBBStuff$finalModel)) { lhp_bb_pred <- predict(LHPBBStuff$finalModel, lhp_bb_dmatrix) } else { lhp_bb_pred <- predict(LHPBBStuff, lhp_bb_dmatrix) } lhp_bb_stuffplus <- 100 + (lhp_bb_pred * -20) lhp_bb_indices <- which(data$TaggedPitchType %in% c("Slider", "Curveball", "Changeup", "ChangeUp", "Sweeper", "Cutter", "Splitter") & data$PitcherThrows == "Left" & !is.na(data$RelSpeed) & !is.na(data$SpinRate) & !is.na(data$RelHeight) & !is.na(data$RelSide) & !is.na(data$Extension) & !is.na(data$InducedVertBreak) & !is.na(data$HorzBreak) & !is.na(data$PitcherThrows)) if(length(lhp_bb_stuffplus) == length(lhp_bb_indices)) { data$StuffPlus[lhp_bb_indices] <- lhp_bb_stuffplus } } }, error = function(e) { warning("Error in Stuff+ calculation: ", e$message) # Print more detailed error info for debugging cat("Detailed error in Stuff+ calculation:\n") cat("Error message:", e$message, "\n") if(exists("rhp_fb_matrix")) { cat("RHP FB matrix dimensions:", dim(rhp_fb_matrix), "\n") cat("RHP FB matrix colnames:", colnames(rhp_fb_matrix), "\n") } }) return(data) } # Function to calculate run values (from your TM25 processing code) calculate_run_values <- function(data) { # Add count state columns data$begin_Strike <- data$Strikes data$begin_Ball <- data$Balls data$begin_Outs <- data$Outs data$end_Strike <- data$Strikes data$end_Ball <- data$Balls data$end_Outs <- data$Outs # Update end states based on pitch outcomes for (i in 1:nrow(data)) { if (!is.na(data$OutsOnPlay[i]) && data$OutsOnPlay[i] == 1) { data$end_Outs[i] <- data$begin_Outs[i] + 1 } if (data$PitchCall[i] == "StrikeCalled") { data$end_Strike[i] <- data$end_Strike[i] + 1 } else if (data$PitchCall[i] %in% c("BallCalled", "BallinDirt", "BallIntentional")) { data$end_Ball[i] <- data$end_Ball[i] + 1 } else if (data$PitchCall[i] == "BattersInterference") { data$end_Ball[i] <- 0 data$end_Strike[i] <- 0 } else if (data$PitchCall[i] %in% c("FoulBall", "FoulBallFieldable", "FoulBallNotFieldable")) { if (data$begin_Strike[i] < 2) { data$end_Strike[i] <- data$end_Strike[i] + 1 } } else if (data$PitchCall[i] %in% c("HitByPitch", "InPlay")) { data$end_Strike[i] <- 0 data$end_Ball[i] <- 0 } else if (data$PitchCall[i] == "StrikeSwinging") { data$end_Strike[i] <- data$end_Strike[i] + 1 } if (data$end_Strike[i] == 3) { data$end_Strike[i] <- 0 data$end_Ball[i] <- 0 data$end_Outs[i] <- data$end_Outs[i] + 1 } if (data$end_Ball[i] == 4) { data$end_Ball[i] <- 0 data$end_Strike[i] <- 0 } } # Create run expectancy table simple_RE <- data %>% group_by(GameID, Inning, Top.Bottom) %>% mutate( cumulative_runs = cumsum(RunsScored), total_runs = max(cumulative_runs), runs_remaining = total_runs - cumulative_runs ) %>% ungroup() %>% filter(begin_Ball < 4, begin_Strike < 3, begin_Outs < 3) %>% group_by(begin_Ball, begin_Strike, begin_Outs) %>% summarise( run_expectancy = round(mean(runs_remaining, na.rm = TRUE), 3), .groups = 'drop' ) # Join beginning state RE data <- left_join(data, simple_RE, by = c("begin_Strike", "begin_Ball", "begin_Outs")) # Rename for end state join simple_RE_end <- simple_RE %>% rename( end_Strike = begin_Strike, end_Ball = begin_Ball, end_Outs = begin_Outs, end_run_expectancy = run_expectancy ) # Join end state RE data <- left_join(data, simple_RE_end, by = c("end_Strike", "end_Ball", "end_Outs")) # Handle end of inning (3 outs = 0 run expectancy) data$end_run_expectancy <- ifelse(data$end_Outs >= 3, 0, data$end_run_expectancy) # Calculate delta run expectancy data$delta_RE <- data$end_run_expectancy - data$run_expectancy + data$RunsScored return(data) } TMcolors <- c( "Fastball" = "dodgerblue2", "FourSeamFastBall" = "dodgerblue2", "FourSeamFastB" = "dodgerblue2", "OneSeamFastBall" = "yellow", "TwoSeamFastBall" = "yellow", "Sinker" = "yellow", "Cutter" = "orange", "Changeup" = "#01FF70", "ChangeUp" = "#01FF70", "Slider" = "tomato", "Sweeper" = "gold3", "Curveball" = "#B10DC9", "Splitter" = "#01FF70", "Knuckleball" = "lightblue", "Other" = "gray60" ) ui <- dashboardPage( dashboardHeader(title = "NCAA Pitcher Dashboard"), dashboardSidebar( sidebarMenu( menuItem("Percentiles", tabName = "pitcher", icon = icon("baseball-ball")), menuItem("Pitch Movement and Arsenal", tabName = "movement", icon = icon("chart-line")), menuItem("Seam Orientation", tabName = "seam", icon = icon("circle-dot")), menuItem("Location and Usage", tabName = "location", icon = icon("crosshairs")) ) ), dashboardBody( tabItems( # Original Pitcher Analysis Tab tabItem(tabName = "pitcher", fluidRow( box( title = "Controls", status = "primary", solidHeader = TRUE, width = 12, color = "teal", fluidRow( column(4, selectInput("league", "Select League:", choices = c("Cape Cod League" = "ccbl", "NCAA 2025" = "ncaa2025", "NCAA 2024" = "ncaa2024"), selected = "ccbl" ) ), column(4, selectInput("pitcher", "Select Pitcher:", choices = NULL, selected = NULL ) ), column(4, br(), actionButton("update", "Update Analysis", class = "btn-primary") ) ) ) ), fluidRow( box( title = "Pitcher Percentiles", status = "success", solidHeader = TRUE, width = 12, plotOutput("percentile_plot", height = "550px") ) ), fluidRow( box( title = "Pitcher Statistics", status = "info", solidHeader = TRUE, width = 12, DT::dataTableOutput("pitcher_stats") ) ) ), # Enhanced Pitch Movement Tab tabItem(tabName = "movement", fluidRow( box( title = "Movement Controls", status = "primary", solidHeader = TRUE, width = 12, color = "teal", fluidRow( column(3, selectInput("movement_league", "Select League:", choices = c("Cape Cod League" = "ccbl", "NCAA 2025" = "ncaa2025", "NCAA 2024" = "ncaa2024"), selected = "ccbl" ) ), column(3, selectInput("movement_pitcher", "Select Pitcher:", choices = NULL, selected = NULL ) ), column(3, selectInput("plot_type", "Plot Type:", choices = c("Movement Profile" = "movement", "Pitch Breaks" = "breaks"), selected = "movement" ) ), column(3, br(), actionButton("update_movement", "Update Movement", class = "btn-primary") ) ) ) ), fluidRow( box( title = "Pitch Visualization", status = "success", solidHeader = TRUE, width = 12, color = "teal", plotOutput("movement_plot", height = "700px") ) ), fluidRow( box( title = "Release Points", status = "info", solidHeader = TRUE, width = 6, color = "teal", plotOutput("release_plot", height = "550px") ) ), fluidRow( box( title = "Pitch Arsenal Summary", status = "info", solidHeader = TRUE, width = 12, color = "teal", DT::dataTableOutput("movement_stats") ) ) ), # Seam Orientation Tab tabItem(tabName = "seam", fluidRow( box( title = "Seam Orientation Controls", status = "primary", solidHeader = TRUE, width = 12, color = "teal", fluidRow( column(3, h5("Data Source: Cape Cod League Only"), p("Seam orientation analysis requires 3D spin axis data only available in CCBL dataset.", style = "color: #666; font-style: italic;") ), column(3, selectInput("seam_pitcher", "Select Pitcher:", choices = NULL, selected = NULL ) ), column(3, checkboxInput("show_seam_curve", "Show Seam Curve", value = TRUE), numericInput("point_size", "Point Size:", value = 3, min = 0.5, max = 3, step = 0.1) ), column(3, numericInput("point_alpha", "Point Transparency:", value = 1.5, min = 0.1, max = 2, step = 0.05), selectInput("filter_pitch_type", "Filter by Pitch Type:", choices = c("All Pitches" = "all"), selected = "all"), br(), actionButton("update_seam", "Update Chart", class = "btn-primary") ) ) ) ), fluidRow( box( title = "Seam Orientation Chart", status = "success", solidHeader = TRUE, width = 12, color = "teal", plotOutput("seam_plot", height = "700px") ) ), fluidRow( box( title = "Seam Orientation Metrics", status = "info", solidHeader = TRUE, width = 12, color = "teal", DT::dataTableOutput("seam_metrics_table") ) ) ), # Location and Usage Tab (Fixed) tabItem(tabName = "location", fluidRow( box( title = "Chart Controls", status = "primary", solidHeader = TRUE, width = 12, color = "teal", fluidRow( column(6, selectInput("location_league", "Select League:", choices = c("Cape Cod League" = "ccbl", "NCAA 2025" = "ncaa2025", "NCAA 2024" = "ncaa2024"), selected = "ccbl") ), column(6, selectInput("location_pitcher", "Select Pitcher:", choices = NULL) ) ) ) ), fluidRow( box(title = "Pitch Location Heatmaps", width = 6, status = "info", solidHeader = TRUE, plotOutput("location_plot", height = "450px")), box(title = "Pitch Usage by Count", width = 6, status = "warning", solidHeader = TRUE, plotOutput("count_usage_plot", height = "450px")) ), fluidRow( box( title = "Location Summary Statistics", status = "success", solidHeader = TRUE, width = 12, color = "teal", DT::dataTableOutput("location_stats") ) ) ) ) ) ) # Define server logic server <- function(input, output, session) { # Data preprocessing function - UPDATED to include Stuff+ calculation preprocess_data <- function(data, league_type) { if (league_type == "ccbl") { # Cape Cod League preprocessing data <- data %>% filter(!TaggedPitchType %in% c("Knuckleball", "Undefined", "Other")) %>% mutate( RelSpeed = as.numeric(RelSpeed), SpinRate = as.numeric(SpinRate), InducedVertBreak = as.numeric(InducedVertBreak), HorzBreak = as.numeric(HorzBreak), Extension = as.numeric(Extension), ExitSpeed = as.numeric(ExitSpeed), Angle = as.numeric(Angle), RelSide = as.numeric(RelSide), RelHeight = as.numeric(RelHeight), PlateLocSide = as.numeric(PlateLocSide), PlateLocHeight = as.numeric(PlateLocHeight), is_csw = ifelse(PitchCall %in% c("StrikeSwinging", "StrikeCalled"), 1, 0), is_swing = ifelse(PitchCall %in% c("StrikeSwinging", "FoulBallNotFieldable", "InPlay", "FoulBallFieldable", "FoulBall"), 1, 0), is_whiff = ifelse(PitchCall == "StrikeSwinging" & is_swing == 1, 1, ifelse(is_swing == 1, 0, NA)), in_zone = case_when( PlateLocSide >= -0.95 & PlateLocSide <= 0.95 & PlateLocHeight >= 1.6 & PlateLocHeight <= 3.5 ~ 1, TRUE ~ 0 ), in_zone_whiff = ifelse(PitchCall == "StrikeSwinging" & in_zone == 1, 1, ifelse(is_swing == 1 & in_zone == 1, 0, NA)), chase = ifelse(is_swing == 1 & in_zone == 0, 1, ifelse(is_swing == 0 & in_zone == 0, 0, NA)), is_hit = ifelse(PlayResult %in% c("Single", "Double", "Triple", "HomeRun"), 1, ifelse(PlayResult %in% c("FieldersChoice", "Error", "Out", "Sacrifice"), 0, NA)), is_hard_hit = ifelse(PitchCall == "InPlay" & ExitSpeed >= 95, 1, ifelse(PitchCall == "InPlay" & ExitSpeed < 95, 0, NA)), is_barrel = ifelse(!is.na(ExitSpeed) & !is.na(Angle) & PitchCall == "InPlay" & (ExitSpeed * 1.5 - Angle) >= 117 & (ExitSpeed + Angle) >= 124 & Angle <= 50 & ExitSpeed >= 98, 1, ifelse(PitchCall == "InPlay", 0, NA)), is_plate_appearance = ifelse( PitchCall %in% c("InPlay", "HitByPitch") | KorBB %in% c("Strikeout", "Walk") | PlayResult %in% c("StrikeoutSwinging", "StrikeoutLooking"), 1, 0 ), is_at_bat = case_when( PitchCall == "InPlay" & !PlayResult %in% c("StolenBase", "Sacrifice", "CaughtStealing", "Undefined") ~ 1, KorBB == "Strikeout" ~ 1, PlayResult %in% c("StrikeoutSwinging", "StrikeoutLooking") ~ 1, TRUE ~ 0 ), is_walk = case_when( is_plate_appearance == 1 & KorBB == "Walk" ~ 1, is_plate_appearance == 1 & KorBB != "Walk" ~ 0, TRUE ~ NA_real_ ), Pitcher = str_replace_all(Pitcher, "(\\w+), (\\w+)", "\\2 \\1"), is_k = case_when( is_at_bat == 1 & KorBB == "Strikeout" ~ 1, is_at_bat == 1 & KorBB != "Strikeout" ~ 0, PlayResult %in% c("StrikeoutSwinging", "StrikeoutLooking") ~ 1, TRUE ~ NA_real_ ) ) } else if (league_type %in% c("ncaa2025", "ncaa2024")) { # NCAA preprocessing (both 2024 and 2025) data <- data %>% mutate( RelSide = as.numeric(RelSide), RelHeight = as.numeric(RelHeight), PlateLocSide = as.numeric(PlateLocSide), PlateLocHeight = as.numeric(PlateLocHeight), is_csw = case_when( PitchCall %in% c("StrikeSwinging", "StrikeCalled") ~ 1, TRUE ~ 0 ), is_swing = case_when( PitchCall %in% c("StrikeSwinging", "FoulBallNotFieldable", "InPlay", "FoulBallFieldable", "FoulBall") ~ 1, TRUE ~ 0 ), is_whiff = case_when( PitchCall == "StrikeSwinging" & is_swing == 1 ~ 1, PitchCall != "StrikeSwinging" & is_swing == 1 ~ 0, TRUE ~ NA_real_ ), in_zone = case_when( PlateLocSide >= -0.95 & PlateLocSide <= 0.95 & PlateLocHeight >= 1.6 & PlateLocHeight <= 3.5 ~ 1, TRUE ~ 0 ), in_zone_whiff = case_when( PitchCall == "StrikeSwinging" & in_zone == 1 ~ 1, is_swing == 1 & in_zone == 1 ~ 0, TRUE ~ NA_real_ ), chase = case_when( is_swing == 1 & in_zone == 0 ~ 1, is_swing == 0 & in_zone == 0 ~ 0, TRUE ~ NA_real_ ), is_hit = case_when( PlayResult %in% c("Single", "Double", "Triple", "HomeRun") ~ 1, PlayResult %in% c("FieldersChoice", "Error", "Out", "Sacrifice") ~ 0, TRUE ~ NA_real_ ), is_hard_hit = case_when( ExitSpeed >= 95 & PitchCall == "InPlay" ~ 1, ExitSpeed < 95 & PitchCall == "InPlay" ~ 0, TRUE ~ NA_real_ ), is_barrel = case_when( !is.na(ExitSpeed) & !is.na(Angle) & PitchCall == "InPlay" & (ExitSpeed * 1.5 - Angle) >= 117 & (ExitSpeed + Angle) >= 124 & Angle <= 50 & ExitSpeed >= 98 ~ 1, is.na(ExitSpeed) | is.na(Angle) ~ NA_real_, PitchCall == "InPlay" ~ 0 ), is_plate_appearance = ifelse( PitchCall %in% c("InPlay", "HitByPitch") | KorBB %in% c("Strikeout", "Walk") | PlayResult %in% c("StrikeoutSwinging", "StrikeoutLooking"), 1, 0 ), is_at_bat = case_when( PitchCall == "InPlay" & !PlayResult %in% c("StolenBase", "Sacrifice", "CaughtStealing", "Undefined") ~ 1, KorBB == "Strikeout" ~ 1, PlayResult %in% c("StrikeoutSwinging", "StrikeoutLooking") ~ 1, TRUE ~ 0 ), is_walk = case_when( is_plate_appearance == 1 & KorBB == "Walk" ~ 1, is_plate_appearance == 1 & KorBB != "Walk" ~ 0, TRUE ~ NA_real_ ), is_k = case_when( is_at_bat == 1 & KorBB == "Strikeout" ~ 1, is_at_bat == 1 & KorBB != "Strikeout" ~ 0, PlayResult %in% c("StrikeoutSwinging", "StrikeoutLooking") ~ 1, TRUE ~ NA_real_ ) ) } # CRITICAL FIX: Calculate Stuff+ for the processed data tryCatch({ data <- calculate_stuff_plus(data) }, error = function(e) { warning("Error calculating Stuff+: ", e$message) # Ensure StuffPlus column exists even if calculation fails if(!"StuffPlus" %in% names(data)) { data$StuffPlus <- NA } }) # Ensure StuffPlus column exists regardless if(!"StuffPlus" %in% names(data)) { data$StuffPlus <- NA } return(data) } # Create summary stats for percentiles - UPDATED to include Stuff+ create_summary_stats <- function(data) { # Check if AutoHitType column exists, if not create a simplified GB% calculation if(!"AutoHitType" %in% names(data)) { gb_calc <- quote(100 * mean(Angle[PitchCall == "InPlay"] < 10, na.rm = TRUE)) } else { gb_calc <- quote(100 * (sum((AutoHitType == "GroundBall" & PitchCall == "InPlay"), na.rm = TRUE) / sum(PitchCall == "InPlay", na.rm = TRUE))) } summary_data <- data %>% group_by(Pitcher) %>% summarise( `FB Velo` = mean(RelSpeed[TaggedPitchType == "Fastball"], na.rm = TRUE), `FB IVB` = mean(InducedVertBreak[TaggedPitchType == "Fastball"], na.rm = TRUE), `Avg EV` = mean(ExitSpeed, na.rm = TRUE), `Hard-Hit%` = 100 * mean(is_hard_hit, na.rm = TRUE), `GB%` = !!gb_calc, `Zone%` = round(mean(in_zone, na.rm = TRUE) * 100, 1), `Chase%` = 100 * mean(chase, na.rm = TRUE), `Whiff%` = 100 * mean(is_whiff, na.rm = TRUE), `Z-Whiff%` = 100 * mean(in_zone_whiff, na.rm = TRUE), `K%` = 100 * mean(is_k, na.rm = TRUE), `BB%` = 100 * mean(is_walk, na.rm = TRUE), Extension = mean(Extension, na.rm = TRUE), `Stuff+` = mean(StuffPlus, na.rm = TRUE), # ADD STUFF+ HERE .groups = "drop" ) # Filter out rows with all NA values, but be more lenient summary_data %>% filter(rowSums(is.na(.) | is.infinite(as.matrix(.))) < ncol(.) - 1) # Allow if at least one valid metric } # Location heatmap function locations <- function(data, player) { # Filter and check for required columns if(!"PlateLocSide" %in% names(data) || !"PlateLocHeight" %in% names(data)) { return(ggplot() + annotate("text", x = 0, y = 0, label = "Missing PlateLocSide or\nPlateLocHeight columns", size = 4) + theme_void()) } filtered_data <- data %>% filter(Pitcher == player, TaggedPitchType != "Other", !is.na(TaggedPitchType)) %>% filter(!is.na(PlateLocSide), !is.na(PlateLocHeight), !is.na(BatterSide)) %>% filter(is.finite(PlateLocSide), is.finite(PlateLocHeight)) if(nrow(filtered_data) == 0) { return(ggplot() + annotate("text", x = 0, y = 0, label = "No location data\navailable for this pitcher", size = 4) + theme_void()) } # Calculate pitch usage and filter pitch_usage_data <- filtered_data %>% group_by(TaggedPitchType) %>% summarise(pitch_count = n(), .groups = "drop") %>% mutate(usage_pct = 100 * pitch_count / nrow(filtered_data)) %>% filter(usage_pct >= 2) if(nrow(pitch_usage_data) == 0) { return(ggplot() + annotate("text", x = 0, y = 0, label = "No pitches used ≥2%\nof the time", size = 4) + theme_void()) } # Filter to qualifying pitch types player_data <- filtered_data %>% filter(TaggedPitchType %in% pitch_usage_data$TaggedPitchType) %>% mutate( BatterSide = case_when( BatterSide %in% c("Right", "R") ~ "Vs Right", BatterSide %in% c("Left", "L") ~ "Vs Left", TRUE ~ paste("Vs", BatterSide) ), TaggedPitchType = factor(TaggedPitchType) ) if(nrow(player_data) == 0) { return(ggplot() + annotate("text", x = 0, y = 0, label = "No qualifying pitch data\navailable", size = 4) + theme_void()) } # Check if we have enough data points for density calculation min_points_check <- player_data %>% group_by(TaggedPitchType, BatterSide) %>% summarise(n_points = n(), .groups = "drop") %>% filter(n_points >= 3) # Need at least 3 points for density if(nrow(min_points_check) == 0) { return(ggplot() + annotate("text", x = 0, y = 0, label = "Insufficient data points\nfor density calculation", size = 4) + theme_void()) } # Create the plot p <- player_data %>% ggplot(aes(PlateLocSide, PlateLocHeight)) + # Only add density if we have sufficient data {if(nrow(player_data) >= 10) stat_density_2d( aes(fill = after_stat(ndensity)), geom = "raster", contour = FALSE, n = 20 ) } + # Add individual points if density fails or not enough data {if(nrow(player_data) < 10) geom_point(alpha = 0.6, size = 2) } + facet_grid(BatterSide ~ TaggedPitchType, labeller = label_value) + geom_rect( xmin = -8.5/12, xmax = 8.5/12, # Convert to feet (assuming inches input) ymin = 18/12, ymax = 42/12, fill = NA, color = "black", linewidth = 0.4 ) + coord_fixed() + scale_x_continuous("", limits = c(-2, 2)) + scale_y_continuous("", limits = c(0, 5)) + theme_void() + {if(nrow(player_data) >= 10) scale_fill_gradientn( colors = c("#315aa1", "grey80", "#d72029"), values = scales::rescale(c(0, 0.5, 1)), limits = c(0, 1), guide = "none" ) } + theme( strip.text.x = element_text(size = 12, face = "bold"), strip.text.y.right = element_text(size = 12, angle = 0, hjust = 0, face = "bold"), strip.placement = "outside", strip.background = element_blank(), panel.background = element_rect(fill = "#ffffff", color = NA), legend.position = "none", plot.title = element_text(hjust = 0.5, size = 14, face = "bold") ) + labs(title = paste(player, "- Pitch Locations")) return(p) } # Count usage function (reverted to original pie chart style) count_usage <- function(data, player) { # Define pitch colors for use in the function pitch_colors <- c( "Fastball" = "dodgerblue2", "FourSeamFastBall" = "dodgerblue2", "FourSeamFastB" = "dodgerblue2", "OneSeamFastBall" = "yellow", "TwoSeamFastBall" = "yellow", "Sinker" = "yellow", "Cutter" = "orange", "Changeup" = "#01FF70", "ChangeUp" = "#01FF70", "Slider" = "tomato", "Sweeper" = "gold3", "Curveball" = "#B10DC9", "Splitter" = "#01FF70", "Knuckleball" = "lightblue", "Other" = "gray60", "Undefined" = "gray40" ) data <- data %>% filter(Pitcher == player, TaggedPitchType != "Other") %>% group_by(TaggedPitchType) %>% mutate(n = 100 * n() / nrow(data %>% filter(Pitcher == player))) %>% filter(n >= 2) %>% ungroup() if(nrow(data) == 0) { return(ggplot() + annotate("text", x = 0, y = 0, label = "No count data\navailable for this pitcher", size = 4) + theme_void()) } data %>% mutate( count = case_when( Balls == 0 & Strikes == 0 ~ "0-0", Balls == 3 & Strikes == 2 ~ "3-2", Balls > Strikes ~ "Behind", Strikes > Balls ~ "Ahead", Balls == Strikes ~ "Tied" ), BatterSide = ifelse(BatterSide == "Right", "Vs Right", "Vs Left") ) %>% 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) %>% arrange(desc(percentage)) %>% ggplot(aes(x = "", y = percentage, fill = TaggedPitchType)) + geom_bar(width = 1, stat = "identity") + coord_polar("y", start = 0) + facet_grid(BatterSide ~ count, labeller = label_value) + theme_minimal() + labs(title = paste(player, "- Pitch Usage by Count"), fill = "Pitch Type") + scale_fill_manual(values = pitch_colors) + theme(plot.title = element_text(hjust = 0.5, size = 12), axis.text = element_blank(), axis.title = element_blank(), axis.ticks = element_blank(), strip.text = element_text(size = 12), legend.position = "bottom" ) } # Seam orientation functions filtered_seam_data <- reactive({ req(input$seam_pitcher) base_data <- ccbl_data %>% filter(!is.na(SpinAxis3dSeamOrientationBallAngleHorizontalAmb1), !is.na(SpinAxis3dSeamOrientationBallAngleVerticalAmb1), Pitcher == input$seam_pitcher) # Apply pitch type filter if not "all" if(input$filter_pitch_type != "all") { base_data <- base_data %>% filter(TaggedPitchType == input$filter_pitch_type) } base_data }) seam_curve_data <- reactive({ if (!input$show_seam_curve) return(NULL) # Defined seam control points seam_points <- data.frame( lon = c(-157, -90, 0, 90, 157, 90, 0, -90, -157), lat = c(0, 45, 22.5, 45, 0, -45, -22.5, -45, 0) ) # Create parametric interpolation t <- seq_along(seam_points$lon) interp_n <- 300 t_interp <- seq(min(t), max(t), length.out = interp_n) lon_spline <- spline(t, seam_points$lon, xout = t_interp)$y lat_spline <- spline(t, seam_points$lat, xout = t_interp)$y data.frame(lon = lon_spline, lat = lat_spline) }) # Release point visualization function create_release_plot <- function(game_data, pitcher_name, league_type) { # Filter the game data for the selected pitcher pitcher_data <- game_data %>% filter(Pitcher == pitcher_name) %>% filter(!is.na(RelSide), !is.na(RelHeight), !is.na(TaggedPitchType)) %>% # Group fastball types together mutate( pitch_group = case_when( TaggedPitchType %in% c("Fastball", "FourSeamFastBall", "FourSeamFastB", "Four-Seam", "4-Seam") ~ "Fastball", TaggedPitchType %in% c("OneSeamFastBall", "TwoSeamFastBall", "Sinker", "Two-Seam", "One-Seam") ~ "Sinker", TaggedPitchType %in% c("ChangeUp", "Changeup") ~ "Changeup", TRUE ~ TaggedPitchType ) ) if(nrow(pitcher_data) == 0) { return(ggplot() + annotate("text", x = 0, y = 4, label = "No release point data\navailable for this pitcher", size = 4) + theme_void()) } # Get unique pitch groups present in data pitch_groups <- unique(pitcher_data$pitch_group) # Filter colors to only include pitch types present in data pitch_colors_used <- TMcolors[names(TMcolors) %in% pitch_groups] if(length(pitch_colors_used) == 0) { pitch_colors_used <- rainbow(length(pitch_groups)) names(pitch_colors_used) <- pitch_groups } # Create the release point plot ggplot(pitcher_data, aes(x = RelSide, y = RelHeight, fill = pitch_group)) + # Add mound and rubber annotate("rect", xmin = -5, xmax = 5, ymin = 0, ymax = 0.83, fill = "#632b11") + annotate("rect", xmin = -0.5, xmax = 0.5, ymin = 0.8, ymax = 0.95, fill = "white", color = "black", linewidth = 0.5) + geom_point(size = 3, alpha = 1.5, shape = 21, color = "black", stroke = 0.5) + xlim(-5, 5) + ylim(0, 8) + annotate("text", x = -4, y = 7.5, label = "← 1B", face = "bold", color = "black", size = 6, hjust = 0.5) + annotate("text", x = 4, y = 7.5, label = "3B →", face = "bold", color = "black", size = 6, hjust = 0.5) + labs(x = "Rel Side", y = "Rel Height", title = paste(pitcher_name, "Release Heights")) + scale_fill_manual(values = pitch_colors_used) + theme_minimal() + theme( plot.title = element_text(hjust = 0.5, face = "bold", color = "black", size = 15), axis.title = element_text(size = 10), axis.text = element_text(size = 10), legend.position = "bottom", legend.text = element_text(color = "black", size = 12), legend.title = element_blank(), panel.border = element_rect(color = "black", fill = NA, size = 0.5), legend.key.size = unit(0.2, "cm"), legend.margin = margin(0, 0, 0, 0) ) } # Pitch break visualization function create_pitch_break_plot <- function(game_data, pitcher_name, league_type) { # Filter the game data for the selected pitcher pitcher_data <- game_data %>% filter(Pitcher == pitcher_name) %>% filter(!is.na(HorzBreak), !is.na(InducedVertBreak), !is.na(TaggedPitchType)) %>% # Group fastball types together mutate( pitch_group = case_when( TaggedPitchType %in% c("Fastball", "FourSeamFastBall", "FourSeamFastB", "Four-Seam", "4-Seam") ~ "Fastball", TaggedPitchType %in% c("OneSeamFastBall", "TwoSeamFastBall", "Sinker", "Two-Seam", "One-Seam") ~ "Sinker", TaggedPitchType %in% c("ChangeUp", "Changeup") ~ "Changeup", TRUE ~ TaggedPitchType ) ) p_throws <- "R" # Default to right-handed # Apply handedness adjustment to horizontal break if (p_throws == "R") { pitcher_data$adj_HorzBreak <- pitcher_data$HorzBreak * -1 } else { pitcher_data$adj_HorzBreak <- pitcher_data$HorzBreak } # Pitch colors pitch_colors <- c( "Fastball" = "dodgerblue2", "FourSeamFastBall" = "dodgerblue2", "FourSeamFastB" = "dodgerblue2", "OneSeamFastBall" = "yellow", "TwoSeamFastBall" = "yellow", "Sinker" = "yellow", "Cutter" = "orange", "Changeup" = "#01FF70", "ChangeUp" = "#01FF70", "Slider" = "tomato", "Sweeper" = "gold3", "Curveball" = "#B10DC9", "Splitter" = "#01FF70", "Knuckleball" = "lightblue", "Other" = "gray60", "Undefined" = "gray40" ) # Get unique pitch groups present in data pitch_groups <- unique(pitcher_data$pitch_group) # Filter colors to only include pitch types present in data pitch_colors_filtered <- pitch_colors[names(pitch_colors) %in% pitch_groups] if(length(pitch_colors_filtered) == 0) { pitch_colors_filtered <- rainbow(length(pitch_groups)) names(pitch_colors_filtered) <- pitch_groups } if(nrow(pitcher_data) == 0) { return(ggplot() + annotate("text", x = 0, y = 0, label = "No pitch break data available for this pitcher", size = 6) + theme_void()) } # Create the base plot p <- ggplot(pitcher_data, aes(x = adj_HorzBreak, y = InducedVertBreak)) + geom_hline(yintercept = 0, color = "#808080", alpha = 0.5, linetype = "dashed", size = 0.8) + geom_vline(xintercept = 0, color = "#808080", alpha = 0.5, linetype = "dashed", size = 0.8) + geom_point(aes(fill = pitch_group), shape = 21, size = 4, color = "black", stroke = 0.5, alpha = 1) + scale_fill_manual(values = pitch_colors_filtered) + scale_x_continuous(limits = c(-25, 25), breaks = seq(-20, 20, 10)) + scale_y_continuous(limits = c(-25, 25), breaks = seq(-20, 20, 10)) + coord_equal() + labs(x = "Horizontal Break (in)", y = "Induced Vertical Break (in)", title = "Pitch Breaks") + theme_minimal() + theme( legend.position = "none", plot.title = element_text(hjust = 0.5, face = "bold", color = "black", size = 16), aspect.ratio = 1, plot.background = element_rect(fill = "white", color = NA), panel.background = element_rect(fill = "white", color = NA), axis.text = element_text(color = "black", size = 10), axis.title = element_text(color = "black", size = 12), panel.border = element_rect(color = "black", fill = NA, size = 1) ) # Add handedness-specific annotations if (p_throws == "R") { p <- p + annotate("text", x = -24.2, y = -24.2, label = "← Glove Side", hjust = 0, vjust = 0, fontface = "italic", size = 3) + annotate("text", x = 24.2, y = -24.2, label = "Arm Side →", hjust = 1, vjust = 0, fontface = "italic", size = 3) } return(p) } # Original pitch movement visualization function create_pitch_movement_plot <- function(game_data, pitcher_name, league_type) { # Filter the game data for the selected pitcher pitcher_data <- game_data %>% filter(Pitcher == pitcher_name) %>% filter(!is.na(HorzBreak), !is.na(InducedVertBreak), !is.na(TaggedPitchType)) %>% # Group fastball types together mutate( pitch_group = case_when( TaggedPitchType %in% c("Fastball", "FourSeamFastBall", "FourSeamFastB", "Four-Seam", "4-Seam") ~ "Fastball", TaggedPitchType %in% c("OneSeamFastBall", "TwoSeamFastBall", "Sinker", "Two-Seam", "One-Seam") ~ "Sinker", TaggedPitchType %in% c("ChangeUp", "Changeup") ~ "Changeup", TRUE ~ TaggedPitchType ) ) # Calculate average locations, usage, and velocity for each pitch group avg_locations <- pitcher_data %>% group_by(pitch_group) %>% summarise( avg_HB = mean(HorzBreak, na.rm = TRUE), avg_IVB = mean(InducedVertBreak, na.rm = TRUE), avg_velo = round(mean(RelSpeed, na.rm = TRUE), 1), count = n(), .groups = 'drop' ) %>% mutate( usage = paste0(round(count / sum(count) * 100, 1), "%") ) # Determine league name for title league_name <- case_when( league_type == "ccbl" ~ "Cape Cod League", league_type == "ncaa2025" ~ "NCAA 2025", league_type == "ncaa2024" ~ "NCAA 2024", TRUE ~ "Unknown League" ) # Pitch colors for movement chart pitch_colors <- c( "Fastball" = "dodgerblue2", "FourSeamFastBall" = "dodgerblue2", "FourSeamFastB" = "dodgerblue2", "OneSeamFastBall" = "yellow", "TwoSeamFastBall" = "yellow", "Sinker" = "yellow", "Cutter" = "orange", "Changeup" = "#01FF70", "ChangeUp" = "#01FF70", "Slider" = "tomato", "Sweeper" = "gold3", "Curveball" = "#B10DC9", "Splitter" = "#01FF70", "Knuckleball" = "lightblue", "Other" = "gray60", "Undefined" = "gray40" ) # Get unique pitch groups present in data pitch_groups <- unique(pitcher_data$pitch_group) # Filter colors to only include pitch types present in data pitch_colors_filtered <- pitch_colors[names(pitch_colors) %in% pitch_groups] if(length(pitch_colors_filtered) == 0) { pitch_colors_filtered <- rainbow(length(pitch_groups)) names(pitch_colors_filtered) <- pitch_groups } if(nrow(pitcher_data) == 0 || nrow(avg_locations) == 0) { return(ggplot() + annotate("text", x = 0, y = 0, label = "No pitch movement data available for this pitcher", size = 6) + theme_void()) } ggplot(pitcher_data, aes(x = HorzBreak, y = InducedVertBreak)) + geom_vline(xintercept = 0, color = "black", size = 0.8, linetype = 1) + geom_hline(yintercept = 0, color = "black", size = 0.8, linetype = 1) + geom_point(aes(fill = pitch_group), shape = 21, size = 4, color = "black", stroke = 0.4, alpha = 1) + geom_point(data = avg_locations, aes(x = avg_HB, y = avg_IVB, fill = pitch_group), color = "black", size = 8, stroke = 2, shape = 21, alpha = 0.95) + geom_label(data = avg_locations, aes(x = avg_HB, y = avg_IVB, label = paste0(avg_velo, " mph")), color = "black", fill = "white", size = 5, fontface = "bold", alpha = 0.9) + scale_fill_manual(values = pitch_colors_filtered) + scale_color_manual(values = pitch_colors_filtered) + labs(x = "HB", y = "IVB", title = paste(pitcher_name, "- Pitch Movement -", league_name)) + xlim(-25, 25) + ylim(-25, 25) + theme_void() + theme( legend.position = "right", plot.title = element_text(hjust = 0.5, face = "bold", color = "black", size = 20), aspect.ratio = 1, plot.background = element_rect(fill = "white", color = NA), panel.background = element_rect(fill = "white", color = NA), axis.text = element_text(color = "white", size = 10), axis.title = element_text(color = "white", size = 12), legend.background = element_rect(fill = "white"), legend.text = element_text(color = "black", size = 10), legend.title = element_blank(), panel.border = element_rect(color = "black", fill = NA, size = 2) ) } # Reactive data for all tabs - UPDATED to use preprocessing with Stuff+ current_data <- reactive({ if (input$league == "ccbl") { preprocess_data(ccbl_data, "ccbl") } else if (input$league == "ncaa2025") { preprocess_data(TM25, "ncaa2025") } else if (input$league == "ncaa2024") { preprocess_data(NCAA, "ncaa2024") } }) current_movement_data <- reactive({ if (input$movement_league == "ccbl") { preprocess_data(ccbl_data, "ccbl") } else if (input$movement_league == "ncaa2025") { preprocess_data(TM25, "ncaa2025") } else if (input$movement_league == "ncaa2024") { preprocess_data(NCAA, "ncaa2024") } }) current_location_data <- reactive({ switch(input$location_league, "ccbl" = preprocess_data(ccbl_data, "ccbl"), "ncaa2025" = preprocess_data(TM25, "ncaa2025"), "ncaa2024" = preprocess_data(NCAA, "ncaa2024")) }) summary_stats <- reactive({ create_summary_stats(current_data()) }) # Update pitcher choices based on league selection for main tab observe({ data <- current_data() pitcher_choices <- sort(unique(data$Pitcher)) pitcher_choices <- pitcher_choices[!is.na(pitcher_choices)] updateSelectInput(session, "pitcher", choices = pitcher_choices, selected = pitcher_choices[1] ) }) # Update pitcher choices based on league selection for movement tab observe({ data <- current_movement_data() pitcher_choices <- sort(unique(data$Pitcher)) pitcher_choices <- pitcher_choices[!is.na(pitcher_choices)] updateSelectInput(session, "movement_pitcher", choices = pitcher_choices, selected = pitcher_choices[1] ) }) # Update pitcher choices for location tab observe({ data <- current_location_data() pitchers <- sort(unique(data$Pitcher[!is.na(data$Pitcher)])) updateSelectInput(session, "location_pitcher", choices = pitchers, selected = pitchers[1]) }) # Update pitcher choices for seam orientation tab (CCBL only) observe({ seam_pitcher_choices <- ccbl_data %>% filter(!is.na(SpinAxis3dSeamOrientationBallAngleHorizontalAmb1), !is.na(SpinAxis3dSeamOrientationBallAngleVerticalAmb1), !is.na(Pitcher)) %>% pull(Pitcher) %>% unique() %>% sort() updateSelectInput(session, "seam_pitcher", choices = seam_pitcher_choices, selected = seam_pitcher_choices[1] ) }) # Update pitch type filter based on selected pitcher observe({ req(input$seam_pitcher) pitcher_pitch_types <- ccbl_data %>% filter(!is.na(SpinAxis3dSeamOrientationBallAngleHorizontalAmb1), !is.na(SpinAxis3dSeamOrientationBallAngleVerticalAmb1), Pitcher == input$seam_pitcher, !is.na(TaggedPitchType)) %>% pull(TaggedPitchType) %>% unique() %>% sort() pitch_choices <- c("All Pitches" = "all") if(length(pitcher_pitch_types) > 0) { pitch_choices <- c(pitch_choices, setNames(pitcher_pitch_types, pitcher_pitch_types)) } updateSelectInput(session, "filter_pitch_type", choices = pitch_choices, selected = "all" ) }) # Pitcher percentiles function - UPDATED to include Stuff+ pitcher_percentiles <- function(pitcher_name, data, summary_df, league_type) { # Check if AutoHitType column exists for GB% calculation if(!"AutoHitType" %in% names(data)) { gb_calc <- quote(100 * mean(Angle[PitchCall == "InPlay"] < 10, na.rm = TRUE)) } else { gb_calc <- quote(100 * (sum((AutoHitType == "GroundBall" & PitchCall == "InPlay"), na.rm = TRUE) / sum(PitchCall == "InPlay", na.rm = TRUE))) } # Get individual pitcher stats pitcher_split <- data %>% filter(Pitcher == pitcher_name) %>% summarise( `FB Velo` = mean(RelSpeed[TaggedPitchType == "Fastball"], na.rm = TRUE), `FB IVB` = mean(InducedVertBreak[TaggedPitchType == "Fastball"], na.rm = TRUE), `Avg EV` = mean(ExitSpeed, na.rm = TRUE), `Hard-Hit%` = 100 * mean(is_hard_hit, na.rm = TRUE), `GB%` = !!gb_calc, `Zone%` = round(mean(in_zone, na.rm = TRUE) * 100, 1), `Chase%` = 100 * mean(chase, na.rm = TRUE), `Whiff%` = 100 * mean(is_whiff, na.rm = TRUE), `Z-Whiff%` = 100 * mean(in_zone_whiff, na.rm = TRUE), `K%` = 100 * mean(is_k, na.rm = TRUE), `BB%` = 100 * mean(is_walk, na.rm = TRUE), Extension = mean(Extension, na.rm = TRUE), `Stuff+` = mean(StuffPlus, na.rm = TRUE), # ADD STUFF+ HERE TOO .groups = "drop" ) if(nrow(pitcher_split) == 0) { return(ggplot() + annotate("text", x = 0.5, y = 0.5, label = "No data available for this pitcher") + theme_void()) } # Convert to long format for easier processing pitcher_long <- pitcher_split %>% pivot_longer(cols = everything(), names_to = "Metric", values_to = "Value") %>% filter(!is.na(Value) & !is.infinite(Value)) if(nrow(pitcher_long) == 0) { return(ggplot() + annotate("text", x = 0.5, y = 0.5, label = "No valid metrics available for this pitcher") + theme_void()) } # Calculate percentiles for each metric pitcher_long$Percentile <- sapply(1:nrow(pitcher_long), function(i) { metric_name <- pitcher_long$Metric[i] pitcher_value <- pitcher_long$Value[i] if(metric_name %in% names(summary_df)) { league_values <- summary_df[[metric_name]] league_values <- league_values[!is.na(league_values) & !is.infinite(league_values)] if(length(league_values) > 1) { # Higher is better for these metrics if (metric_name %in% c("FB Velo", "FB IVB", "K%", "Whiff%", "Z-Whiff%", "Extension", "GB%", "Zone%", "Chase%", "Stuff+")) { percentile <- round(mean(league_values <= pitcher_value, na.rm = TRUE) * 100) } else { # Lower is better for these metrics percentile <- round(mean(league_values >= pitcher_value, na.rm = TRUE) * 100) } return(max(1, min(99, percentile))) } } return(50) }) # Filter valid metrics metrics <- pitcher_long %>% filter(!is.na(Percentile) & !is.na(Value)) if(nrow(metrics) == 0) { return(ggplot() + annotate("text", x = 0.5, y = 0.5, label = "No valid metrics available") + theme_void()) } # Updated custom order to include Stuff+ custom_order <- c("FB Velo", "FB IVB", "Avg EV", "Hard-Hit%", "GB%", "Zone%", "Chase%", "Whiff%", "Z-Whiff%", "K%", "BB%", "Extension", "Stuff+") metrics$Metric <- factor(metrics$Metric, levels = custom_order) metrics <- metrics %>% arrange(Metric) %>% filter(!is.na(Metric)) if(nrow(metrics) == 0) { return(ggplot() + annotate("text", x = 0.5, y = 0.5, label = "No valid metrics available") + theme_void()) } metrics$color <- gradient_n_pal(c("#3661ad","#90A4AE", "#d82029"))(metrics$Percentile/100) # Determine league name for subtitle league_name <- case_when( league_type == "ccbl" ~ "Cape Cod League", league_type == "ncaa2025" ~ "NCAA 2025", league_type == "ncaa2024" ~ "NCAA 2024", TRUE ~ "Unknown League" ) ggplot(metrics, aes(y = factor(Metric, levels = rev(levels(Metric))))) + geom_tile(aes(x = 50, width = 100), fill = "#c7dcdc", alpha = 0.3, height = 0.25) + geom_tile(aes(x = Percentile/2, width = Percentile, fill = color), height = 0.7) + annotate("segment", x = c(10, 50, 90), xend = c(10, 50, 90), y = 0, yend = nrow(metrics) + 0.5, color = c("white"), size = 1.5, alpha = 0.5) + geom_text(aes(x = -3, label = Metric), hjust = 1, size = 4) + geom_text(aes(x = 103, label = sprintf("%.1f", Value)), hjust = 0, size = 4) + geom_point(aes(x = Percentile, color = "white", fill = color), size = 10, shape = 21, stroke = 2) + geom_text(aes(x = Percentile, label = Percentile), size = 4, color = "white", fontface = "bold") + scale_x_continuous(limits = c(-16, 113), expand = c(0, 0)) + scale_fill_identity() + scale_color_identity() + labs(title = paste("Pitcher Percentiles:", pitcher_name), subtitle = paste("League:", league_name), caption = "Made by Isaac Groffman | Inspired by Baseball Savant") + theme_minimal() + theme( axis.text = element_blank(), axis.title = element_blank(), panel.grid = element_blank(), plot.title = element_text(hjust = 0.5, size = 16, face = "bold"), plot.subtitle = element_text(hjust = 0.5, size = 12), plot.caption = element_text(hjust = 1, size = 10, color = "gray60", margin = margin(t = 10)) ) } # OUTPUT RENDERERS # Generate percentile plot output$percentile_plot <- renderPlot({ req(input$pitcher) data <- current_data() summary_df <- summary_stats() pitcher_percentiles(input$pitcher, data, summary_df, input$league) }) # Generate location plot output$location_plot <- renderPlot({ req(input$location_pitcher) data <- current_location_data() locations(data, input$location_pitcher) }) # Generate count usage plot output$count_usage_plot <- renderPlot({ req(input$location_pitcher) data <- current_location_data() count_usage(data, input$location_pitcher) }) # Generate movement plot output$movement_plot <- renderPlot({ req(input$movement_pitcher) data <- current_movement_data() if (input$plot_type == "movement") { create_pitch_movement_plot(data, input$movement_pitcher, input$movement_league) } else if (input$plot_type == "breaks") { create_pitch_break_plot(data, input$movement_pitcher, input$movement_league) } }) # Generate release point plot output$release_plot <- renderPlot({ req(input$movement_pitcher) data <- current_movement_data() create_release_plot(data, input$movement_pitcher, input$movement_league) }) # Generate seam orientation plot output$seam_plot <- renderPlot({ req(input$seam_pitcher) data <- filtered_seam_data() if (nrow(data) == 0) { return(ggplot() + annotate("text", x = 0, y = 0, label = "No seam orientation data available for this pitcher", size = 6) + theme_void()) } # Get world map data for Mollweide background world_map <- map_data("world") p <- ggplot(data, aes(x = SpinAxis3dSeamOrientationBallAngleHorizontalAmb1, y = SpinAxis3dSeamOrientationBallAngleVerticalAmb1, color = TaggedPitchType)) + # Add world map as background geom_polygon(data = world_map, aes(x = long, y = lat, group = group), fill = "lightgray", color = "white", alpha = 0.3, size = 0.1, inherit.aes = FALSE) + scale_color_manual(values = TMcolors, na.value = "gray60") + geom_point(size = input$point_size, alpha = input$point_alpha, stroke = 0.5, color = "black", shape = 21, aes(fill = TaggedPitchType)) + geom_point(aes(x = SpinAxis3dSeamOrientationBallAngleHorizontalAmb2, y = SpinAxis3dSeamOrientationBallAngleVerticalAmb2, fill = TaggedPitchType), size = input$point_size, alpha = input$point_alpha, stroke = 0.5, color = "black", shape = 21) + geom_point(aes(x = SpinAxis3dSeamOrientationBallAngleHorizontalAmb3, y = SpinAxis3dSeamOrientationBallAngleVerticalAmb3, fill = TaggedPitchType), size = input$point_size, alpha = input$point_alpha, stroke = 0.5, color = "black", shape = 21) + geom_point(aes(x = SpinAxis3dSeamOrientationBallAngleHorizontalAmb4, y = SpinAxis3dSeamOrientationBallAngleVerticalAmb4, fill = TaggedPitchType), size = input$point_size, alpha = input$point_alpha, stroke = 0.5, color = "black", shape = 21) + scale_fill_manual(values = TMcolors, na.value = "gray60") + coord_map("mollweide") + scale_x_continuous(limits = c(-180, 180), breaks = seq(-180, 180, 45)) + scale_y_continuous(limits = c(-90, 90), breaks = seq(-90, 90, 30)) + # Add seam orientation guide annotations with boxes annotate("label", x = 0, y = 67.5, label = "2S", color = "blue", size = 6, fontface = "bold", fill = "white", label.padding = unit(0.3, "lines"), label.r = unit(0.15, "lines")) + annotate("label", x = 95, y = 52, label = "1S", color = "red", size = 6, fontface = "bold", fill = "white", label.padding = unit(0.3, "lines"), label.r = unit(0.15, "lines")) + annotate("label", x = 90, y = 0, label = "2S", color = "blue", size = 6, fontface = "bold", fill = "white", label.padding = unit(0.3, "lines"), label.r = unit(0.15, "lines")) + annotate("label", x = 95, y = -52, label = "1S", color = "red", size = 6, fontface = "bold", fill = "white", label.padding = unit(0.3, "lines"), label.r = unit(0.15, "lines")) + annotate("label", x = 0, y = -67.5, label = "2S", color = "blue", size = 6, fontface = "bold", fill = "white", label.padding = unit(0.3, "lines"), label.r = unit(0.15, "lines")) + annotate("label", x = -95, y = -52, label = "1S", color = "red", size = 6, fontface = "bold", fill = "white", label.padding = unit(0.3, "lines"), label.r = unit(0.15, "lines")) + annotate("label", x = -90, y = 0, label = "2S", color = "blue", size = 6, fontface = "bold", fill = "white", label.padding = unit(0.3, "lines"), label.r = unit(0.15, "lines")) + annotate("label", x = -95, y = 52, label = "1S", color = "red", size = 6, fontface = "bold", fill = "white", label.padding = unit(0.3, "lines"), label.r = unit(0.15, "lines")) + annotate("label", x = 180, y = 0, label = "4S", color = "darkgreen", size = 6, fontface = "bold", fill = "white", label.padding = unit(0.3, "lines"), label.r = unit(0.15, "lines")) + annotate("label", x = -180, y = 0, label = "4S", color = "darkgreen", size = 6, fontface = "bold", fill = "white", label.padding = unit(0.3, "lines"), label.r = unit(0.15, "lines")) + annotate("label", x = 0, y = 0, label = "4S", color = "darkgreen", size = 6, fontface = "bold", fill = "white", label.padding = unit(0.3, "lines"), label.r = unit(0.15, "lines")) + labs( title = paste0("Seam Orientation: ", input$seam_pitcher, if(input$filter_pitch_type != "all") paste0(" (", input$filter_pitch_type, ")") else ""), subtitle = "Using TrackMan 3D Spin (4S = 4-Seam, 2S = 2-Seam, 1S = 1-Seam)", x = "Horizontal Angle (°)", y = "Vertical Angle (°)", color = "Pitch Type" ) + theme_minimal() + theme( plot.title = element_text(size = 16, face = "bold"), plot.subtitle = element_text(size = 12), axis.title = element_text(size = 12), legend.title = element_text(size = 11), legend.text = element_text(size = 10) ) # Add seam curve if selected if (input$show_seam_curve) { curve_data <- seam_curve_data() if (!is.null(curve_data)) { p <- p + geom_path(data = curve_data, aes(x = lon, y = lat), color = "red", linewidth = 1.5, inherit.aes = FALSE) } } p }) # Generate seam orientation metrics table output$seam_metrics_table <- DT::renderDataTable({ req(input$seam_pitcher) data <- filtered_seam_data() if (nrow(data) == 0) { empty_df <- data.frame( `Pitch Type` = character(0), Count = numeric(0), `Gyro Angle` = character(0), `Spin Efficiency` = character(0), `Active Spin` = character(0), `Total Spin` = character(0), `Avg Tilt` = character(0), `Spin Axis X` = character(0), `Spin Axis Y` = character(0), `Spin Axis Z` = character(0), `Transverse Angle` = character(0), check.names = FALSE ) return(DT::datatable(empty_df, options = list(pageLength = 15, dom = 't'), rownames = FALSE)) } seam_metrics <- data %>% filter(!is.na(TaggedPitchType)) %>% group_by(`Pitch Type` = TaggedPitchType) %>% summarise( Count = n(), `Gyro Angle` = sprintf("%.1f°", mean(SpinAxis3dLongitudinalAngle, na.rm = TRUE)), `Spin Efficiency` = sprintf("%.1f%%", mean(SpinAxis3dSpinEfficiency * 100, na.rm = TRUE)), `Active Spin` = sprintf("%.0f rpm", mean(SpinAxis3dActiveSpinRate, na.rm = TRUE)), `Total Spin` = sprintf("%.0f rpm", mean(SpinRate, na.rm = TRUE)), `Avg Tilt` = if_else( all(is.na(SpinAxis3dTilt)), "N/A", paste0(first(na.omit(SpinAxis3dTilt))) ), `Spin Axis X` = sprintf("%.3f", mean(SpinAxis3dVectorX, na.rm = TRUE)), `Spin Axis Y` = sprintf("%.3f", mean(SpinAxis3dVectorY, na.rm = TRUE)), `Spin Axis Z` = sprintf("%.3f", mean(SpinAxis3dVectorZ, na.rm = TRUE)), `Transverse Angle` = sprintf("%.1f°", mean(SpinAxis3dTransverseAngle, na.rm = TRUE)), .groups = "drop" ) %>% arrange(desc(Count)) DT::datatable(seam_metrics, options = list( pageLength = 15, dom = 't', scrollX = TRUE, columnDefs = list( list(className = 'dt-center', targets = 1:10) ) ), rownames = FALSE) %>% DT::formatStyle(columns = names(seam_metrics), fontSize = '11px') %>% DT::formatStyle( columns = "Count", backgroundColor = "#f8f9fa" ) %>% DT::formatStyle( columns = c("Gyro Angle", "Spin Efficiency", "Avg Tilt"), backgroundColor = "#e8f4f8" ) %>% DT::formatStyle( columns = c("Active Spin", "Total Spin"), backgroundColor = "#e6f3ff" ) %>% DT::formatStyle( columns = c("Spin Axis X", "Spin Axis Y", "Spin Axis Z"), backgroundColor = "#fff3cd" ) }, server = FALSE) # Generate pitcher statistics table - UPDATED to include Stuff+ output$pitcher_stats <- DT::renderDataTable({ req(input$pitcher) data <- current_data() # Check if AutoHitType column exists for GB% calculation in table if(!"AutoHitType" %in% names(data)) { gb_calc_table <- quote(sprintf("%.1f", 100 * mean(Angle[PitchCall == "InPlay"] < 10, na.rm = TRUE))) } else { gb_calc_table <- quote(sprintf("%.1f", 100 * (sum((AutoHitType == "GroundBall" & PitchCall == "InPlay"), na.rm = TRUE) / sum(PitchCall == "InPlay", na.rm = TRUE)))) } pitcher_stats <- data %>% filter(Pitcher == input$pitcher) %>% summarise( `FB Velo` = sprintf("%.1f", mean(RelSpeed[TaggedPitchType == "Fastball"], na.rm = TRUE)), `Avg EV` = sprintf("%.1f", mean(ExitSpeed, na.rm = TRUE)), `Barrel%` = sprintf("%.1f", 100 * mean(is_barrel, na.rm = TRUE)), `Hard-Hit%` = sprintf("%.1f", 100 * mean(is_hard_hit, na.rm = TRUE)), `GB%` = !!gb_calc_table, `Zone%` = sprintf("%.1f", round(mean(in_zone, na.rm = TRUE) * 100, 1)), `Chase%` = sprintf("%.1f", 100 * mean(chase, na.rm = TRUE)), `Whiff%` = sprintf("%.1f", 100 * mean(is_whiff, na.rm = TRUE)), `Z-Whiff%` = sprintf("%.1f", 100 * mean(in_zone_whiff, na.rm = TRUE)), `K%` = sprintf("%.1f", 100 * mean(is_k, na.rm = TRUE)), `BB%` = sprintf("%.1f", 100 * mean(is_walk, na.rm = TRUE)), Extension = sprintf("%.1f", mean(Extension, na.rm = TRUE)), `Stuff+` = sprintf("%.1f", mean(StuffPlus, na.rm = TRUE)), # ADD STUFF+ TO TABLE .groups = "drop" ) %>% pivot_longer(cols = everything(), names_to = "Metric", values_to = "Value") DT::datatable(pitcher_stats, options = list(pageLength = 15, dom = 't'), rownames = FALSE) %>% DT::formatStyle(columns = c("Metric", "Value"), fontSize = '14px') }) # Generate movement statistics table - UPDATED to include Stuff+ output$movement_stats <- DT::renderDataTable({ req(input$movement_pitcher) data <- current_movement_data() movement_stats <- data %>% filter(Pitcher == input$movement_pitcher) %>% filter(!is.na(HorzBreak), !is.na(InducedVertBreak), !is.na(TaggedPitchType)) %>% mutate( pitch_group = case_when( TaggedPitchType %in% c("Fastball", "FourSeamFastBall", "FourSeamFastB", "Four-Seam", "4-Seam") ~ "Fastball", TaggedPitchType %in% c("OneSeamFastBall", "TwoSeamFastBall", "Sinker", "Two-Seam", "One-Seam") ~ "Sinker", TaggedPitchType %in% c("ChangeUp", "Changeup") ~ "Changeup", TRUE ~ TaggedPitchType ) ) %>% group_by(`Pitch Type` = pitch_group) %>% summarise( Count = n(), `Usage%` = sprintf("%.1f%%", (n() / nrow(filter(data, Pitcher == input$movement_pitcher))) * 100), `Ext.` = sprintf("%.1f", mean(Extension, na.rm = TRUE)), `Avg Velo` = sprintf("%.1f mph", mean(RelSpeed, na.rm = TRUE)), `90th Velo` = sprintf("%.1f mph", quantile(RelSpeed, 0.9, na.rm = TRUE)), `Max Velo` = sprintf("%.1f mph", max(RelSpeed, na.rm = TRUE)), `Avg IVB` = sprintf("%.1f in", mean(InducedVertBreak, na.rm = TRUE)), `Avg HB` = sprintf("%.1f in", mean(HorzBreak, na.rm = TRUE)), `Avg Spin` = sprintf("%.0f rpm", mean(SpinRate, na.rm = TRUE)), `Rel Height` = sprintf("%.1f", mean(RelHeight, na.rm = TRUE)), `Zone%` = sprintf("%.1f%%", round(mean(in_zone, na.rm = TRUE) * 100, 1)), `Whiff%` = sprintf("%.1f%%", round(mean(is_whiff, na.rm = TRUE) * 100, 1)), `Chase%` = sprintf("%.1f%%", round(mean(chase, na.rm = TRUE) * 100, 1)), `Stuff+` = sprintf("%.1f", mean(StuffPlus, na.rm = TRUE)), # ADD STUFF+ TO MOVEMENT TABLE .groups = "drop" ) %>% arrange(desc(Count)) DT::datatable(movement_stats, options = list(pageLength = 15, dom = 't'), rownames = FALSE) %>% DT::formatStyle(columns = names(movement_stats), fontSize = '12px') }) # Generate location statistics table - UPDATED to include Stuff+ output$location_stats <- DT::renderDataTable({ req(input$location_pitcher) data <- current_location_data() location_stats <- data %>% filter(Pitcher == input$location_pitcher) %>% filter(!is.na(PlateLocSide), !is.na(PlateLocHeight), !is.na(TaggedPitchType)) %>% mutate( pitch_group = case_when( TaggedPitchType %in% c("Fastball", "FourSeamFastBall", "FourSeamFastB", "Four-Seam", "4-Seam") ~ "Fastball", TaggedPitchType %in% c("OneSeamFastBall", "TwoSeamFastBall", "Sinker", "Two-Seam", "One-Seam") ~ "Sinker", TaggedPitchType %in% c("ChangeUp", "Changeup") ~ "Changeup", TRUE ~ TaggedPitchType ) ) %>% group_by(`Pitch Type` = pitch_group) %>% summarise( Count = n(), `Usage%` = sprintf("%.1f%%", (n() / nrow(filter(data, Pitcher == input$location_pitcher))) * 100), `Avg Horizontal` = sprintf("%.2f ft", mean(PlateLocSide, na.rm = TRUE)), `Avg Vertical` = sprintf("%.2f ft", mean(PlateLocHeight, na.rm = TRUE)), `Zone%` = sprintf("%.1f%%", round(mean(in_zone, na.rm = TRUE) * 100, 1)), `Strike%` = sprintf("%.1f%%", round(mean(is_csw, na.rm = TRUE) * 100, 1)), `Whiff%` = sprintf("%.1f%%", round(mean(is_whiff, na.rm = TRUE) * 100, 1)), `Chase%` = sprintf("%.1f%%", round(mean(chase, na.rm = TRUE) * 100, 1)), `Stuff+` = sprintf("%.1f", mean(StuffPlus, na.rm = TRUE)), # ADD STUFF+ TO LOCATION TABLE .groups = "drop" ) %>% arrange(desc(Count)) DT::datatable(location_stats, options = list(pageLength = 15, dom = 't'), rownames = FALSE) %>% DT::formatStyle(columns = names(location_stats), fontSize = '12px') }) } # Run the application shinyApp(ui = ui, server = server)