Spaces:
Sleeping
Sleeping
| 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) |