# ============================================================================ # COASTAL CAROLINA BASEBALL - DEFENSIVE ANALYTICS APPLICATION # ============================================================================ library(shiny) library(DT) library(ggplot2) library(dplyr) library(plotly) library(gt) library(gtExtras) library(shinyWidgets) library(tidyr) library(purrr) library(stringr) library(fmsb) # ============================================================================ # HELPER FUNCTIONS # ============================================================================ make_curve_segments <- function(x_start, y_start, x_end, y_end, curvature = 0.3, n = 40) { if (n < 2) stop("n must be >= 2") t <- seq(0, 1, length.out = n) cx <- (x_start + x_end) / 2 + curvature * (y_end - y_start) cy <- (y_start + y_end) / 2 - curvature * (x_end - x_start) x <- (1 - t)^2 * x_start + 2 * (1 - t) * t * cx + t^2 * x_end y <- (1 - t)^2 * y_start + 2 * (1 - t) * t * cy + t^2 * y_end idx_from <- seq_len(n - 1) idx_to <- seq_len(n - 1) + 1 tibble::tibble( x = x[idx_from], y = y[idx_from], xend = x[idx_to], yend = y[idx_to] ) } curve1 <- make_curve_segments(89.095, 89.095, -1, 160, curvature = 0.36, n = 60) curve2 <- make_curve_segments(-89.095, 89.095, 1, 160, curvature = -0.36, n = 60) curve3 <- make_curve_segments(233.35, 233.35, -10, 410, curvature = 0.36, n = 60) curve4 <- make_curve_segments(-233.35, 233.35, 10, 410, curvature = -0.36, n = 60) # ============================================================================ # SYNTHETIC DATA GENERATION # ============================================================================ set.seed(42) # Team rosters cc_players <- c("Smith, John", "Johnson, Mike", "Williams, Chris", "Brown, David", "Jones, Tyler", "Garcia, Alex", "Martinez, Ryan", "Davis, Kyle", "Rodriguez, Sam", "Wilson, Jake") opponent_teams <- c("Georgia Southern", "Appalachian State", "Troy", "Texas State", "South Alabama", "Arkansas State", "Louisiana", "ULM") opponent_players <- c("Thompson, Mark", "Anderson, Jake", "Taylor, Ben", "Thomas, Cole", "Jackson, Drew", "White, Nick", "Harris, Luke", "Martin, Josh", "Moore, Zach", "Lee, Grant") positions <- c("RF", "CF", "LF") infield_positions <- c("SS", "1B", "2B", "3B") # Generate Outfield OAA data generate_of_oaa_data <- function(n = 500, team = "CCU") { players <- if(team == "CCU") cc_players else opponent_players tibble( PitchUID = paste0("P", 1:n), Date = sample(seq(as.Date("2025-02-14"), as.Date("2025-05-30"), by = "day"), n, replace = TRUE), obs_player = sample(players[1:4], n, replace = TRUE), hit_location = sample(positions, n, replace = TRUE), Batter = sample(opponent_players, n, replace = TRUE), Pitcher = sample(cc_players[5:8], n, replace = TRUE), Inning = sample(1:9, n, replace = TRUE), BatterTeam = if(team == "CCU") sample(opponent_teams, n, replace = TRUE) else "CCU", PitcherTeam = if(team == "CCU") "CCU" else sample(opponent_teams, n, replace = TRUE), closest_pos_dist = runif(n, 5, 120), HangTime = runif(n, 2.5, 5.5), x_pos = runif(n, 100, 350), z_pos = runif(n, -200, 200), CF_PositionAtReleaseX = runif(n, 280, 320), CF_PositionAtReleaseZ = runif(n, -20, 20), RF_PositionAtReleaseX = runif(n, 250, 290), RF_PositionAtReleaseZ = runif(n, 80, 140), LF_PositionAtReleaseX = runif(n, 250, 290), LF_PositionAtReleaseZ = runif(n, -140, -80), angle_from_home = runif(n, -180, 180), ExitSpeed = runif(n, 70, 110), Angle = runif(n, 15, 45) ) %>% mutate( # Calculate catch probability based on distance and hang time catch_prob = pmax(0, pmin(1, 1 - (closest_pos_dist / 150) + (HangTime - 3) * 0.2 + rnorm(n, 0, 0.1))), # Determine success based on probability with some randomness success_ind = rbinom(n, 1, pmin(0.95, pmax(0.05, catch_prob + rnorm(n, 0, 0.15)))), # Calculate OAA OAA = success_ind - catch_prob, star_group = case_when( catch_prob >= 0.90 ~ "1 Star", catch_prob >= 0.70 ~ "2 Star", catch_prob >= 0.50 ~ "3 Star", catch_prob >= 0.25 ~ "4 Star", TRUE ~ "5 Star" ) ) } # Generate Infield OAA data generate_if_oaa_data <- function(n = 400, team = "CCU") { players <- if(team == "CCU") cc_players else opponent_players tibble( PitchUID = paste0("IF", 1:n), Date = sample(seq(as.Date("2025-02-14"), as.Date("2025-05-30"), by = "day"), n, replace = TRUE), obs_player_name = sample(players[5:8], n, replace = TRUE), obs_player = sample(infield_positions, n, replace = TRUE), Batter = sample(opponent_players, n, replace = TRUE), Pitcher = sample(cc_players[1:4], n, replace = TRUE), Inning = sample(1:9, n, replace = TRUE), BatterTeam = if(team == "CCU") sample(opponent_teams, n, replace = TRUE) else "CCU", Distance = runif(n, 50, 150), Bearing = runif(n, -45, 45), ExitSpeed = runif(n, 60, 110), Angle = runif(n, -10, 30), HangTime = runif(n, 1.5, 4.0), obs_player_bearing = runif(n, -45, 45), obs_player_x = runif(n, 80, 130), obs_player_z = runif(n, -60, 60), Direction = sample(c("Pull", "Center", "Oppo"), n, replace = TRUE) ) %>% mutate( obs_player_bearing_diff = Bearing - obs_player_bearing, dist_from_lead_base = sqrt((Distance * cos(Bearing * pi/180) - 90)^2 + (Distance * sin(Bearing * pi/180))^2), player_angle_rad = atan2(obs_player_z, obs_player_x), # Calculate play probability play_prob = pmax(0, pmin(1, 0.8 - abs(obs_player_bearing_diff) * 0.02 - Distance * 0.003 + rnorm(n, 0, 0.1))), success_ind = rbinom(n, 1, pmin(0.95, pmax(0.05, play_prob + rnorm(n, 0, 0.12)))), OAA = success_ind - play_prob, star_group = case_when( play_prob >= 0.90 ~ "1 Star", play_prob >= 0.70 ~ "2 Star", play_prob >= 0.50 ~ "3 Star", play_prob >= 0.25 ~ "4 Star", TRUE ~ "5 Star" ) ) } # Generate Positioning data generate_positioning_data <- function(n = 800) { tibble( PitchUID = paste0("POS", 1:n), Date = sample(seq(as.Date("2025-02-14"), as.Date("2025-05-30"), by = "day"), n, replace = TRUE), PitcherTeam = sample(c("CCU", opponent_teams), n, replace = TRUE), BatterTeam = sample(c("CCU", opponent_teams), n, replace = TRUE), Pitcher = sample(c(cc_players, opponent_players), n, replace = TRUE), Batter = sample(c(cc_players, opponent_players), n, replace = TRUE), BatterSide = sample(c("Right", "Left"), n, replace = TRUE, prob = c(0.6, 0.4)), PitcherThrows = sample(c("Right", "Left"), n, replace = TRUE, prob = c(0.7, 0.3)), Outs = sample(0:2, n, replace = TRUE), pitch_count = sample(c("0-0", "0-1", "0-2", "1-0", "1-1", "1-2", "2-0", "2-1", "2-2", "3-0", "3-1", "3-2"), n, replace = TRUE), man_on_firstbase = sample(c(0, 1), n, replace = TRUE, prob = c(0.7, 0.3)), man_on_secondbase = sample(c(0, 1), n, replace = TRUE, prob = c(0.8, 0.2)), man_on_thirdbase = sample(c(0, 1), n, replace = TRUE, prob = c(0.9, 0.1)), away_team = sample(opponent_teams, n, replace = TRUE), home_score_before = sample(0:8, n, replace = TRUE), away_score_before = sample(0:8, n, replace = TRUE), `1B_PositionAtReleaseX` = rnorm(n, 85, 8), `1B_PositionAtReleaseZ` = rnorm(n, 45, 10), `2B_PositionAtReleaseX` = rnorm(n, 115, 10), `2B_PositionAtReleaseZ` = rnorm(n, 35, 12), `3B_PositionAtReleaseX` = rnorm(n, 85, 8), `3B_PositionAtReleaseZ` = rnorm(n, -45, 10), `SS_PositionAtReleaseX` = rnorm(n, 115, 10), `SS_PositionAtReleaseZ` = rnorm(n, -35, 12), `LF_PositionAtReleaseX` = rnorm(n, 270, 15), `LF_PositionAtReleaseZ` = rnorm(n, -110, 20), `CF_PositionAtReleaseX` = rnorm(n, 300, 15), `CF_PositionAtReleaseZ` = rnorm(n, 0, 25), `RF_PositionAtReleaseX` = rnorm(n, 270, 15), `RF_PositionAtReleaseZ` = rnorm(n, 110, 20) ) } # Generate Catcher data generate_catcher_data <- function(n = 1000) { catchers <- c("Martinez, Ryan", "Wilson, Jake") tibble( PitchUID = paste0("C", 1:n), Date = sample(seq(as.Date("2025-02-14"), as.Date("2025-05-30"), by = "day"), n, replace = TRUE), Catcher = sample(catchers, n, replace = TRUE), Pitcher = sample(cc_players[1:6], n, replace = TRUE), Batter = sample(opponent_players, n, replace = TRUE), BatterTeam = sample(opponent_teams, n, replace = TRUE), TaggedPitchType = sample(c("Fastball", "Sinker", "Slider", "Curveball", "ChangeUp", "Cutter"), n, replace = TRUE, prob = c(0.35, 0.15, 0.2, 0.12, 0.12, 0.06)), PitchCall = sample(c("BallCalled", "StrikeCalled", "StrikeSwinging", "InPlay", "FoulBall"), n, replace = TRUE, prob = c(0.3, 0.25, 0.15, 0.15, 0.15)), PlateLocSide = rnorm(n, 0, 0.8), PlateLocHeight = rnorm(n, 2.5, 0.6), RelSpeed = rnorm(n, 88, 6), InducedVertBreak = rnorm(n, 12, 5), HorzBreak = rnorm(n, 5, 8), Balls = sample(0:3, n, replace = TRUE), Strikes = sample(0:2, n, replace = TRUE), PopTime = ifelse(runif(n) > 0.85, runif(n, 1.85, 2.15), NA), ThrowSpeed = ifelse(!is.na(PopTime), rnorm(sum(!is.na(PopTime)), 78, 4), NA), ExchangeTime = ifelse(!is.na(PopTime), rnorm(sum(!is.na(PopTime)), 0.75, 0.08), NA), TimeToBase = ifelse(!is.na(PopTime), PopTime - ExchangeTime, NA), BasePositionZ = ifelse(!is.na(PopTime), rnorm(sum(!is.na(PopTime)), 0, 2), NA), BasePositionY = ifelse(!is.na(PopTime), rnorm(sum(!is.na(PopTime)), 5, 1.5), NA), Notes = ifelse(!is.na(PopTime), sample(c("2b out", "2b safe", "3b out", "3b safe"), sum(!is.na(PopTime)), replace = TRUE, prob = c(0.4, 0.3, 0.2, 0.1)), NA) ) %>% mutate( in_zone = as.integer(PlateLocSide >= -0.83 & PlateLocSide <= 0.83 & PlateLocHeight >= 1.5 & PlateLocHeight <= 3.38), is_swing = as.integer(PitchCall %in% c("StrikeSwinging", "InPlay", "FoulBall")), StolenStrike = as.integer(in_zone == 0 & PitchCall == "StrikeCalled"), StrikeLost = as.integer(in_zone == 1 & PitchCall == "BallCalled"), frame = case_when( in_zone == 1 & PitchCall == "BallCalled" ~ "Strike Lost", in_zone == 0 & PitchCall == "StrikeCalled" ~ "Strike Added", TRUE ~ NA_character_ ), frame_numeric = case_when( in_zone == 1 & PitchCall == "BallCalled" ~ -1, in_zone == 0 & PitchCall == "StrikeCalled" ~ 1, TRUE ~ NA_real_ ), Count = paste0(Balls, "-", Strikes) ) } # Initialize datasets OAA_DF <- generate_of_oaa_data(500, "CCU") IF_OAA <- generate_if_oaa_data(400, "CCU") OPP_OAA_DF <- generate_of_oaa_data(500, "OPP") OPP_IF_OAA <- generate_if_oaa_data(400, "OPP") def_pos_data <- generate_positioning_data(800) catcher_data <- generate_catcher_data(1000) # ============================================================================ # VISUALIZATION FUNCTIONS # ============================================================================ # Star graph for outfielders star_graph <- function(player, position, data = OAA_DF) { if (position %in% c("RF", "CF", "LF")) { player_data <- data %>% filter(obs_player == player) %>% filter(hit_location == position) } else { player_data <- data %>% filter(obs_player == player) %>% mutate(hit_location = "All") } if (nrow(player_data) == 0) { return(ggplotly(ggplot() + theme_void() + ggtitle("No data available"))) } OAA <- round(sum(player_data$OAA, na.rm = TRUE), 1) n_plays <- nrow(player_data) expected_success <- round(sum(player_data$catch_prob, na.rm = TRUE), 1) actual_success <- sum(player_data$success_ind, na.rm = TRUE) p <- ggplot(data, aes(closest_pos_dist, HangTime)) + stat_summary_2d(aes(z = catch_prob), fun = mean, bins = 51) + scale_fill_gradientn( colors = c("white", "#ffffb2", "#feb24c", "#fc4e2a", "#800026"), limits = c(0, 1), breaks = c(0.01, 0.25, 0.5, 0.7, 0.9, 1), labels = c("5 Star", "4 Star", "3 Star", "2 Star", "1 Star", " "), name = "Catch Rating", guide = guide_colorbar(barheight = unit(5, "in")) ) + scale_x_continuous("Distance to Ball (ft)", limits = c(0, 140), breaks = seq(0, 140, by = 20)) + scale_y_continuous("Hang Time (sec)", limits = c(2, 6)) + theme_classic() + ggtitle(paste0(player, " - ", position, " (", OAA, " OAA)")) + theme( legend.position = "right", plot.title = element_text(hjust = .5, size = 20), legend.title = element_blank(), panel.grid.minor = element_blank(), axis.title = element_text(size = 12), axis.text = element_text(size = 10) ) point_colors <- ifelse(player_data$success_ind == 1, "green4", "white") p_interactive <- suppressWarnings({ ggplotly(p, tooltip = NULL) %>% add_trace( data = player_data, x = ~closest_pos_dist, y = ~HangTime, type = "scatter", mode = "markers", color = I(point_colors), marker = list(size = 10, line = list(color = "black", width = 1)), text = ~paste0( "Date: ", Date, "
", "Batter: ", Batter, "
", "Pitcher: ", Pitcher, "
", "Inning: ", Inning, "
", "Opportunity Time: ", round(HangTime, 2), " sec
", "Distance Needed: ", round(closest_pos_dist, 1), " ft
", "Catch Probability: ", round(100 * catch_prob, 1), "%
", star_group ), hoverinfo = "text" ) %>% layout( hovermode = "closest", title = list( text = paste0(player, " - ", position, "
OAA: ", OAA, " | Plays: ", n_plays, " | Expected Catches: ", expected_success, " | Actual Catches: ", actual_success, ""), font = list(size = 18) ), margin = list(t = 80) ) }) return(p_interactive) } # Field graph for outfielders field_graph <- function(player, position, data = OAA_DF) { if (position %in% c("RF", "CF", "LF")) { player_data <- data %>% filter(obs_player == player) %>% filter(hit_location == position) } else { player_data <- data %>% filter(obs_player == player) %>% mutate(hit_location = "All") } if (nrow(player_data) == 0) { return(list( ggplotly(ggplot() + theme_void() + ggtitle("No data available")), gt(data.frame(Message = "No data")) %>% tab_header(title = "Play Success by Difficulty") )) } df_filtered <- player_data %>% mutate( avg_y = mean(CF_PositionAtReleaseX, na.rm = TRUE), avg_x = mean(CF_PositionAtReleaseZ, na.rm = TRUE) ) df_table <- df_filtered %>% group_by(star_group) %>% summarize( OAA = round(sum(OAA, na.rm = TRUE), 1), Plays = n(), Successes = sum(success_ind, na.rm = TRUE), .groups = "drop" ) %>% mutate(`Success Rate` = paste0(Successes, "/", Plays, " (", round((Successes/Plays)*100, 1), "%)")) %>% dplyr::select(`Star Group` = star_group, `Success Rate`) %>% t() %>% as.data.frame() %>% `colnames<-`(.[1,]) %>% tibble::as_tibble() %>% dplyr::slice(-1) %>% mutate(OAA = round(sum(df_filtered$OAA, na.rm = TRUE), 2)) %>% dplyr::select(OAA, everything()) %>% gt::gt() %>% gtExtras::gt_theme_guardian() %>% gt::tab_header(title = "Play Success by Difficulty") %>% gt::cols_align(align = "center", columns = gt::everything()) %>% gt::sub_missing(columns = gt::everything(), missing_text = "-") %>% gt::tab_options( heading.background.color = "darkcyan", column_labels.background.color = "darkcyan", table.border.top.color = "peru", table.border.bottom.color = "peru" ) %>% gt::tab_style(style = gt::cell_text(color = "white"), locations = gt::cells_title(groups = "title")) p <- ggplot() + geom_segment(aes(x = 0, y = 0, xend = 318.1981, yend = 318.1981), color = "black") + geom_segment(aes(x = 0, y = 0, xend = -318.1981, yend = 318.1981), color = "black") + geom_segment(aes(x = 63.6396, y = 63.6396, xend = 0, yend = 127.279), color = "black") + geom_segment(aes(x = -63.6396, y = 63.6396, xend = 0, yend = 127.279), color = "black") + geom_segment(data = curve1, aes(x = x, y = y, xend = xend, yend = yend), size = 0.5, color = "black", lineend = "round") + geom_segment(data = curve2, aes(x = x, y = y, xend = xend, yend = yend), size = 0.5, color = "black", lineend = "round") + annotate("text", x = c(-155, 155), y = 135, label = "200", size = 3) + annotate("text", x = c(-190, 190), y = 170, label = "250", size = 3) + annotate("text", x = c(-227, 227), y = 205, label = "300", size = 3) + annotate("text", x = c(-262, 262), y = 242, label = "350", size = 3) + theme_void() + geom_point( data = df_filtered %>% mutate(is_catch = ifelse(success_ind == 1, 'Catch', "Hit")), aes(x = z_pos, y = x_pos, fill = is_catch, text = paste0("Date: ", Date, "
", "Catch Probability: ", round(100 * catch_prob, 1), "%
", star_group)), color = "black", shape = 21, size = 2, alpha = .6 ) + labs(title = paste0(player, " Possible Catches - ", position)) + scale_fill_manual(values = c("Hit" = "white", "Catch" = "green4"), name = " ") + geom_point(data = df_filtered, aes(x = avg_x, y = avg_y), fill = "red", color = "black", size = 3, shape = 21) + coord_cartesian(xlim = c(-330, 330), ylim = c(0, 400)) + coord_equal() p <- ggplotly(p, tooltip = "text") %>% plotly::layout(hovermode = "closest") return(list(p, df_table)) } # Infield star graph infield_star_graph <- function(player, position, data = IF_OAA) { if (position %in% c("SS", "1B", "2B", "3B")) { player_data <- data %>% filter(obs_player_name == player) %>% filter(obs_player == position) } else { player_data <- data %>% filter(obs_player_name == player) %>% mutate(obs_player = "All") } if (nrow(player_data) == 0) { return(ggplotly(ggplot() + theme_void() + ggtitle("No data available"))) } OAA <- round(sum(player_data$OAA, na.rm = TRUE), 1) n_plays <- nrow(player_data) expected_success <- round(sum(player_data$play_prob, na.rm = TRUE), 1) actual_success <- sum(player_data$success_ind, na.rm = TRUE) points_data <- player_data %>% filter(play_prob > 0) %>% filter(play_prob <= 0.5 | between(obs_player_bearing_diff, -12.5, 12.5)) p <- ggplot(data, aes(obs_player_bearing_diff, play_prob)) + stat_summary_2d(aes(z = play_prob), fun = mean, bins = 15) + scale_fill_gradientn( colors = c("white", "#ffffb2", "#feb24c", "#fc4e2a", "#800026"), limits = c(0, 1), breaks = c(0.01, 0.25, 0.5, 0.7, 0.9, 1), labels = c("5 Star", "4 Star", "3 Star", "2 Star", "1 Star", " "), name = "Play Rating" ) + scale_x_continuous("Bearing Difference (degrees)", limits = c(-25, 25), breaks = seq(-25, 25, by = 12.5)) + scale_y_continuous("Play Probability", limits = c(0, 1), labels = scales::percent) + theme_minimal() + theme( legend.position = "right", plot.title = element_text(hjust = 0.5, size = 18, face = "bold"), panel.grid.minor = element_blank(), axis.title = element_text(size = 12), axis.text = element_text(size = 10) ) point_colors <- ifelse(points_data$success_ind == 1, "green4", "white") p_interactive <- suppressWarnings({ ggplotly(p, tooltip = NULL) %>% add_trace( data = points_data, x = ~obs_player_bearing_diff, y = ~play_prob, type = "scatter", mode = "markers", color = I(point_colors), marker = list(size = 10, line = list(color = "black", width = 1)), text = ~paste0( "Date: ", Date, "
", "Batter: ", Batter, "
", "Play Probability: ", round(100 * play_prob, 1), "%
", "Angular Distance Away: ", round(obs_player_bearing_diff, 1), "°
", "Exit Velocity: ", round(ExitSpeed, 1), " mph
", star_group ), hoverinfo = "text" ) %>% layout( hovermode = "closest", title = list( text = paste0(player, " - ", position, "
OAA: ", OAA, " | Plays: ", n_plays, " | Expected: ", expected_success, " | Actual: ", actual_success, ""), font = list(size = 18) ), margin = list(t = 80) ) }) suppressWarnings(p_interactive) } # Infield field graph infield_field_graph <- function(player, position, data = IF_OAA) { if (position %in% c("SS", "1B", "2B", "3B")) { player_data <- data %>% filter(obs_player_name == player) %>% filter(obs_player == position) } else { player_data <- data %>% filter(obs_player_name == player) } if (nrow(player_data) == 0) { return(list( ggplotly(ggplot() + theme_void() + ggtitle("No data available")), gt(data.frame(Message = "No data")) %>% tab_header(title = "Play Success by Difficulty") )) } df_filtered <- player_data %>% mutate( rad = Bearing * pi/180, x_pos = Distance * cos(rad), z_pos = Distance * sin(rad), avg_y = mean(obs_player_x, na.rm = TRUE), avg_x = mean(obs_player_z, na.rm = TRUE) ) df_table <- df_filtered %>% group_by(star_group) %>% summarize( OAA = round(sum(OAA, na.rm = TRUE), 1), Plays = n(), Successes = sum(success_ind, na.rm = TRUE), .groups = "drop" ) %>% mutate(`Success Rate` = paste0(Successes, "/", Plays, " (", round((Successes/Plays)*100, 1), "%)")) %>% dplyr::select(`Star Group` = star_group, `Success Rate`) %>% t() %>% as.data.frame() %>% `colnames<-`(.[1,]) %>% dplyr::slice(-1) %>% mutate(OAA = round(sum(df_filtered$OAA, na.rm = TRUE), 1)) %>% dplyr::select(OAA, everything()) %>% gt::gt() %>% gtExtras::gt_theme_guardian() %>% gt::tab_header(title = "Play Success by Difficulty") %>% gt::cols_align(align = "center", columns = gt::everything()) %>% gt::sub_missing(columns = gt::everything(), missing_text = "-") %>% gt::tab_options( heading.background.color = "darkcyan", column_labels.background.color = "darkcyan" ) %>% gt::tab_style(style = gt::cell_text(color = "white"), locations = gt::cells_title(groups = "title")) p <- ggplot() + geom_segment(aes(x = 0, y = 0, xend = 100, yend = 100), color = "black") + geom_segment(aes(x = 0, y = 0, xend = -100, yend = 100), color = "black") + geom_segment(aes(x = 63.6396, y = 63.6396, xend = 0, yend = 127.279), color = "black") + geom_segment(aes(x = -63.6396, y = 63.6396, xend = 0, yend = 127.279), color = "black") + geom_segment(data = curve1, aes(x = x, y = y, xend = xend, yend = yend), size = 0.5, color = "black") + geom_segment(data = curve2, aes(x = x, y = y, xend = xend, yend = yend), size = 0.5, color = "black") + theme_void() + geom_point( data = df_filtered %>% mutate(is_play = ifelse(success_ind == 1, 'Play Made', "Not Made")), aes(x = z_pos, y = x_pos, fill = is_play, text = paste0("Date: ", Date, "
", "Play Probability: ", round(100 * play_prob, 1), "%
", star_group)), color = "black", shape = 21, size = 2, alpha = .6 ) + labs(title = paste0(player, " Possible Plays - ", position)) + scale_fill_manual(values = c("Not Made" = "white", "Play Made" = "green4"), name = " ") + geom_point(data = df_filtered, aes(x = avg_x, y = avg_y), fill = "red", color = "black", size = 3, shape = 21) + ylim(0, 160) + xlim(-120, 120) + coord_equal() p <- ggplotly(p, tooltip = "text") %>% plotly::layout(hovermode = "closest") return(list(p, df_table)) } # Positioning heatmap infield_positioning_heatmap <- function(team, batter_hand = "No Filter", pitcher_hand = "No Filter", man_on_first = "No Filter", man_on_second = "No Filter", man_on_third = "No Filter") { team_data <- def_pos_data %>% filter(PitcherTeam == team) if (batter_hand != "No Filter") team_data <- team_data %>% filter(BatterSide == batter_hand) if (pitcher_hand != "No Filter") team_data <- team_data %>% filter(PitcherThrows == pitcher_hand) if (man_on_first != "No Filter") team_data <- team_data %>% filter(man_on_firstbase == as.numeric(man_on_first == "Yes")) if (man_on_second != "No Filter") team_data <- team_data %>% filter(man_on_secondbase == as.numeric(man_on_second == "Yes")) if (man_on_third != "No Filter") team_data <- team_data %>% filter(man_on_thirdbase == as.numeric(man_on_third == "Yes")) n_observations <- nrow(team_data) if (n_observations < 10) { return(ggplot() + theme_void() + ggtitle("Insufficient data for selected filters")) } positions_long <- team_data %>% pivot_longer( cols = matches("^(1B|2B|3B|SS|LF|CF|RF)_PositionAtRelease[XZ]$"), names_to = c("position", ".value"), names_pattern = "^(.+)_PositionAtRelease([XZ])$" ) p <- ggplot() + geom_hline(yintercept = seq(0, 400, by = 20), linetype = "dotted", color = "gray80", size = 0.3) + geom_vline(xintercept = seq(-240, 240, by = 20), linetype = "dotted", color = "gray80", size = 0.3) + geom_density_2d_filled(data = positions_long, aes(x = Z, y = X), bins = 35, alpha = 0.5) + scale_fill_manual(values = colorRampPalette(c("#FFFFFF", "#FFF5F0", "#FEE0D2", "#FCBBA1", "#FC9272", "#FB6A4A", "#EF3B2C", "#CB181D", "#99000D"))(35)) + geom_segment(aes(x = 0, y = 0, xend = 233.35, yend = 233.35), color = "black") + geom_segment(aes(x = 0, y = 0, xend = -233.35, yend = 233.35), color = "black") + geom_segment(aes(x = 63.6396, y = 63.6396, xend = 0, yend = 127.279), color = "black") + geom_segment(aes(x = -63.6396, y = 63.6396, xend = 0, yend = 127.279), color = "black") + geom_segment(data = curve1, aes(x = x, y = y, xend = xend, yend = yend), size = 0.5, color = "black") + geom_segment(data = curve2, aes(x = x, y = y, xend = xend, yend = yend), size = 0.5, color = "black") + geom_segment(data = curve3, aes(x = x, y = y, xend = xend, yend = yend), size = 0.5, color = "black") + geom_segment(data = curve4, aes(x = x, y = y, xend = xend, yend = yend), size = 0.5, color = "black") + labs( title = paste0("Starting Fielder Position — ", team), subtitle = paste0("Based on ", n_observations, " observations"), x = "", y = "" ) + ylim(0, 411) + xlim(-250, 250) + coord_equal() + theme_void() + theme( legend.position = "none", plot.title = element_text(hjust = .5, size = 20, face = "bold"), plot.subtitle = element_text(hjust = 0.5) ) return(p) } # ============================================================================ # UI # ============================================================================ ui <- fluidPage( tags$head( tags$style(HTML(" @import url('https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700;800&display=swap'); body { font-family: 'Outfit', sans-serif; background: linear-gradient(135deg, #f5f7fa 0%, #e4e9f2 100%); color: #2c3e50; } .app-header { display: flex; justify-content: center; align-items: center; padding: 25px 40px; background: linear-gradient(135deg, #006F71 0%, #008B8B 50%, #20B2AA 100%); border-bottom: 4px solid #A27752; margin-bottom: 25px; box-shadow: 0 8px 32px rgba(0, 111, 113, 0.3); } .header-title { color: white; font-size: 2.2rem; font-weight: 800; text-shadow: 2px 2px 4px rgba(0,0,0,0.2); letter-spacing: 1px; } .header-subtitle { color: rgba(255,255,255,0.9); font-size: 1rem; font-weight: 400; margin-top: 5px; } .nav-tabs { border: none !important; border-radius: 50px; padding: 8px 16px; margin: 20px auto; max-width: 95%; background: linear-gradient(135deg, #d4edeb 0%, #e8ddd0 50%, #d4edeb 100%); box-shadow: 0 4px 20px rgba(0,139,139,.15), inset 0 2px 4px rgba(255,255,255,.7); display: flex; justify-content: center; flex-wrap: wrap; gap: 8px; } .nav-tabs > li > a { color: #006F71 !important; border: none !important; border-radius: 50px !important; background: transparent !important; font-weight: 600; font-size: 15px; padding: 12px 28px; transition: all 0.3s ease; letter-spacing: 0.3px; } .nav-tabs > li > a:hover { color: #004d4e !important; background: rgba(255,255,255,0.6) !important; transform: translateY(-2px); } .nav-tabs > li.active > a, .nav-tabs > li.active > a:focus, .nav-tabs > li.active > a:hover { background: linear-gradient(135deg, #006F71 0%, #008B8B 50%, #20B2AA 100%) !important; color: #fff !important; text-shadow: 0 1px 2px rgba(0,0,0,0.2); box-shadow: 0 6px 20px rgba(0,139,139,0.4); border: none !important; } .tab-content { background: white; border-radius: 20px; padding: 30px; margin-top: 15px; box-shadow: 0 10px 40px rgba(0,0,0,0.08); border: 1px solid rgba(0,139,139,0.1); } .well { background: linear-gradient(135deg, #f8fffe 0%, #f5f5f0 100%); border-radius: 15px; border: 1px solid rgba(0,139,139,0.15); box-shadow: 0 4px 15px rgba(0,0,0,0.05); padding: 20px; } .control-label { font-weight: 600; color: #006F71; font-size: 13px; text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 8px; } .selectize-input, .form-control { border-radius: 12px !important; border: 2px solid rgba(0,139,139,0.25) !important; padding: 10px 15px !important; font-size: 14px !important; transition: all 0.3s ease; } .selectize-input:focus, .form-control:focus { border-color: #006F71 !important; box-shadow: 0 0 0 3px rgba(0,139,139,0.15) !important; } .section-header { font-size: 1.4rem; font-weight: 700; color: #006F71; margin-bottom: 20px; padding-bottom: 10px; border-bottom: 3px solid #A27752; } .metric-card { background: linear-gradient(135deg, #006F71 0%, #008B8B 100%); border-radius: 15px; padding: 20px; color: white; text-align: center; box-shadow: 0 6px 20px rgba(0,139,139,0.3); margin-bottom: 15px; } .metric-value { font-size: 2.5rem; font-weight: 800; } .metric-label { font-size: 0.9rem; opacity: 0.9; text-transform: uppercase; letter-spacing: 1px; } .btn-primary { background: linear-gradient(135deg, #006F71 0%, #008B8B 100%) !important; border: none !important; border-radius: 12px !important; font-weight: 600 !important; padding: 12px 25px !important; transition: all 0.3s ease !important; } .btn-primary:hover { transform: translateY(-2px); box-shadow: 0 6px 20px rgba(0,139,139,0.4) !important; } .gt_table { border-radius: 12px !important; overflow: hidden; } hr { border-top: 2px solid rgba(162, 119, 82, 0.3); margin: 30px 0; } ")) ), # Header div(class = "app-header", div(style = "text-align: center;", div(class = "header-title", "Defensive Analytics Dashboard"), div(class = "header-subtitle", "Coastal Carolina Baseball • Spring 2025") ) ), # Main tabs tabsetPanel( id = "main_tabs", # ==================== TAB 1: TEAM OAA ==================== tabPanel( "Team OAA", br(), fluidRow( column(12, h3(class = "section-header", "Outfielder Outs Above Average")) ), fluidRow( column(3, wellPanel( selectInput("of_player", "Select Player:", choices = unique(OAA_DF$obs_player)), selectInput("of_position", "Position:", choices = c("All", "RF", "CF", "LF")), hr(), h5("Quick Stats"), uiOutput("of_quick_stats") )), column(9, plotlyOutput("of_star_graph", height = "500px"), br(), fluidRow( column(6, plotlyOutput("of_field_graph", height = "450px")), column(6, gt_output("of_success_table")) ) ) ), hr(), fluidRow( column(12, h3(class = "section-header", "Infielder Outs Above Average")) ), fluidRow( column(3, wellPanel( selectInput("if_player", "Select Player:", choices = unique(IF_OAA$obs_player_name)), selectInput("if_position", "Position:", choices = c("All", "SS", "2B", "3B", "1B")) )), column(9, plotlyOutput("if_star_graph", height = "500px"), br(), fluidRow( column(6, plotlyOutput("if_field_graph", height = "400px")), column(6, gt_output("if_success_table")) ) ) ), hr(), fluidRow( column(12, h3(class = "section-header", "Team OAA Leaderboard")), column(12, DT::dataTableOutput("team_oaa_leaderboard")) ) ), # ==================== TAB 2: OPPONENT OAA ==================== tabPanel( "Opponent OAA", br(), fluidRow( column(12, h3(class = "section-header", "Opponent Outfield Defense Analysis")) ), fluidRow( column(3, wellPanel( selectInput("opp_team_filter", "Opponent Team:", choices = c("All", unique(OPP_OAA_DF$PitcherTeam))), selectInput("opp_of_player", "Select Player:", choices = unique(OPP_OAA_DF$obs_player)), selectInput("opp_of_position", "Position:", choices = c("All", "RF", "CF", "LF")) )), column(9, plotlyOutput("opp_of_star_graph", height = "500px"), br(), fluidRow( column(6, plotlyOutput("opp_of_field_graph", height = "450px")), column(6, gt_output("opp_of_success_table")) ) ) ), hr(), fluidRow( column(12, h3(class = "section-header", "Opponent Infield Defense Analysis")) ), fluidRow( column(3, wellPanel( selectInput("opp_if_player", "Select Player:", choices = unique(OPP_IF_OAA$obs_player_name)), selectInput("opp_if_position", "Position:", choices = c("All", "SS", "2B", "3B", "1B")) )), column(9, plotlyOutput("opp_if_star_graph", height = "500px") ) ), hr(), fluidRow( column(12, h3(class = "section-header", "Opponent OAA Summary")), column(12, DT::dataTableOutput("opp_oaa_leaderboard")) ) ), # ==================== TAB 3: OPPONENT POSITIONING ==================== tabPanel( "Opponent Positioning", br(), fluidRow( column(12, h3(class = "section-header", "Defensive Positioning Analysis")) ), fluidRow( column(3, wellPanel( selectInput("pos_team", "Select Team:", choices = c("CCU", unique(def_pos_data$PitcherTeam[def_pos_data$PitcherTeam != "CCU"]))), hr(), h5("Situational Filters", style = "font-weight: 600; color: #006F71;"), selectInput("pos_batter_hand", "Batter Hand:", choices = c("No Filter", "Right", "Left")), selectInput("pos_pitcher_hand", "Pitcher Hand:", choices = c("No Filter", "Right", "Left")), hr(), h5("Baserunners", style = "font-weight: 600; color: #006F71;"), selectInput("pos_first", "Runner on 1st:", choices = c("No Filter", "Yes", "No")), selectInput("pos_second", "Runner on 2nd:", choices = c("No Filter", "Yes", "No")), selectInput("pos_third", "Runner on 3rd:", choices = c("No Filter", "Yes", "No")) )), column(9, plotOutput("positioning_heatmap", height = "650px") ) ), hr(), fluidRow( column(12, h3(class = "section-header", "Average Positioning by Situation")), column(12, gt_output("positioning_summary")) ) ), # ==================== TAB 4: CATCHING ANALYSIS ==================== tabPanel( "Catching Analysis", br(), fluidRow( column(12, h3(class = "section-header", "Catcher Framing & Throwing")) ), fluidRow( column(3, wellPanel( selectInput("catcher_select", "Select Catcher:", choices = unique(catcher_data$Catcher)), hr(), h5("Framing Filters", style = "font-weight: 600; color: #006F71;"), pickerInput("pitch_type_filter", "Pitch Types:", choices = unique(catcher_data$TaggedPitchType), selected = unique(catcher_data$TaggedPitchType), multiple = TRUE, options = list(`actions-box` = TRUE)), numericInput("min_pitches_catcher", "Min Pitches:", value = 50, min = 1, max = 500) )), column(9, fluidRow( column(4, div(class = "metric-card", div(class = "metric-value", textOutput("catcher_net_strikes")), div(class = "metric-label", "Net Strikes") )), column(4, div(class = "metric-card", div(class = "metric-value", textOutput("catcher_frame_rate")), div(class = "metric-label", "Frame Rate") )), column(4, div(class = "metric-card", div(class = "metric-value", textOutput("catcher_avg_pop")), div(class = "metric-label", "Avg Pop Time") )) ), br(), fluidRow( column(6, h4("Framing Heatmap"), plotOutput("catcher_framing_heatmap", height = "500px")), column(6, h4("Framing Points"), plotlyOutput("catcher_framing_points", height = "500px")) ) ) ), hr(), fluidRow( column(6, h4("Throwing Accuracy Map"), plotOutput("catcher_throw_map", height = "500px")), column(6, h4("Pop Time Distribution"), plotlyOutput("catcher_pop_dist", height = "250px"), h4("Throw Velocity Trend"), plotlyOutput("catcher_velo_trend", height = "250px")) ), hr(), fluidRow( column(12, h3(class = "section-header", "Catcher Leaderboard")), column(12, DT::dataTableOutput("catcher_leaderboard")) ) ) ) ) # ============================================================================ # SERVER # ============================================================================ server <- function(input, output, session) { # ==================== TAB 1: TEAM OAA ==================== output$of_quick_stats <- renderUI({ player_data <- OAA_DF %>% filter(obs_player == input$of_player) if (input$of_position != "All") { player_data <- player_data %>% filter(hit_location == input$of_position) } total_oaa <- round(sum(player_data$OAA, na.rm = TRUE), 1) plays <- nrow(player_data) success_rate <- round(mean(player_data$success_ind) * 100, 1) tagList( div(class = "metric-card", style = "padding: 12px;", div(class = "metric-value", style = "font-size: 1.8rem;", total_oaa), div(class = "metric-label", "Total OAA") ), div(class = "metric-card", style = "padding: 12px; background: linear-gradient(135deg, #A27752 0%, #c49a6c 100%);", div(class = "metric-value", style = "font-size: 1.8rem;", plays), div(class = "metric-label", "Total Plays") ), div(class = "metric-card", style = "padding: 12px; background: linear-gradient(135deg, #2c3e50 0%, #4a6278 100%);", div(class = "metric-value", style = "font-size: 1.8rem;", paste0(success_rate, "%")), div(class = "metric-label", "Success Rate") ) ) }) output$of_star_graph <- renderPlotly({ star_graph(input$of_player, input$of_position, OAA_DF) }) of_field_results <- reactive({ field_graph(input$of_player, input$of_position, OAA_DF) }) output$of_field_graph <- renderPlotly({ of_field_results()[[1]] }) output$of_success_table <- render_gt({ of_field_results()[[2]] }) output$if_star_graph <- renderPlotly({ infield_star_graph(input$if_player, input$if_position, IF_OAA) }) if_field_results <- reactive({ infield_field_graph(input$if_player, input$if_position, IF_OAA) }) output$if_field_graph <- renderPlotly({ if_field_results()[[1]] }) output$if_success_table <- render_gt({ if_field_results()[[2]] }) output$team_oaa_leaderboard <- DT::renderDataTable({ of_summary <- OAA_DF %>% group_by(Player = obs_player) %>% summarise( `OF OAA` = round(sum(OAA, na.rm = TRUE), 1), `OF Plays` = n(), `OF Success %` = round(mean(success_ind) * 100, 1), .groups = "drop" ) if_summary <- IF_OAA %>% group_by(Player = obs_player_name) %>% summarise( `IF OAA` = round(sum(OAA, na.rm = TRUE), 1), `IF Plays` = n(), `IF Success %` = round(mean(success_ind) * 100, 1), .groups = "drop" ) full_join(of_summary, if_summary, by = "Player") %>% mutate( `Total OAA` = coalesce(`OF OAA`, 0) + coalesce(`IF OAA`, 0) ) %>% arrange(desc(`Total OAA`)) %>% DT::datatable( options = list(pageLength = 15, scrollX = TRUE), rownames = FALSE ) }) # ==================== TAB 2: OPPONENT OAA ==================== output$opp_of_star_graph <- renderPlotly({ star_graph(input$opp_of_player, input$opp_of_position, OPP_OAA_DF) }) opp_of_field_results <- reactive({ field_graph(input$opp_of_player, input$opp_of_position, OPP_OAA_DF) }) output$opp_of_field_graph <- renderPlotly({ opp_of_field_results()[[1]] }) output$opp_of_success_table <- render_gt({ opp_of_field_results()[[2]] }) output$opp_if_star_graph <- renderPlotly({ infield_star_graph(input$opp_if_player, input$opp_if_position, OPP_IF_OAA) }) output$opp_oaa_leaderboard <- DT::renderDataTable({ opp_summary <- OPP_OAA_DF %>% group_by(Player = obs_player, Team = PitcherTeam) %>% summarise( OAA = round(sum(OAA, na.rm = TRUE), 1), Plays = n(), `Success %` = round(mean(success_ind) * 100, 1), .groups = "drop" ) %>% arrange(desc(OAA)) DT::datatable(opp_summary, options = list(pageLength = 15), rownames = FALSE) }) # ==================== TAB 3: POSITIONING ==================== output$positioning_heatmap <- renderPlot({ infield_positioning_heatmap( team = input$pos_team, batter_hand = input$pos_batter_hand, pitcher_hand = input$pos_pitcher_hand, man_on_first = input$pos_first, man_on_second = input$pos_second, man_on_third = input$pos_third ) }) output$positioning_summary <- render_gt({ team_data <- def_pos_data %>% filter(PitcherTeam == input$pos_team) team_data %>% summarise( `Avg 1B X` = round(mean(`1B_PositionAtReleaseX`, na.rm = TRUE), 1), `Avg 1B Z` = round(mean(`1B_PositionAtReleaseZ`, na.rm = TRUE), 1), `Avg SS X` = round(mean(`SS_PositionAtReleaseX`, na.rm = TRUE), 1), `Avg SS Z` = round(mean(`SS_PositionAtReleaseZ`, na.rm = TRUE), 1), `Avg CF X` = round(mean(`CF_PositionAtReleaseX`, na.rm = TRUE), 1), `Avg CF Z` = round(mean(`CF_PositionAtReleaseZ`, na.rm = TRUE), 1), Observations = n() ) %>% gt() %>% tab_header(title = paste("Average Positioning -", input$pos_team)) %>% cols_align(align = "center") %>% tab_options(heading.background.color = "darkcyan") %>% tab_style(style = cell_text(color = "white"), locations = cells_title()) }) # ==================== TAB 4: CATCHING ==================== catcher_filtered <- reactive({ catcher_data %>% filter(Catcher == input$catcher_select) %>% filter(TaggedPitchType %in% input$pitch_type_filter) }) output$catcher_net_strikes <- renderText({ df <- catcher_filtered() net <- sum(df$frame_numeric, na.rm = TRUE) sprintf("%+d", net) }) output$catcher_frame_rate <- renderText({ df <- catcher_filtered() %>% filter(!is.na(frame_numeric)) if (nrow(df) == 0) return("N/A") added <- sum(df$frame_numeric == 1, na.rm = TRUE) total <- sum(df$frame_numeric != 0, na.rm = TRUE) if (total == 0) return("N/A") paste0(round(added / total * 100, 1), "%") }) output$catcher_avg_pop <- renderText({ df <- catcher_filtered() %>% filter(!is.na(PopTime)) if (nrow(df) == 0) return("N/A") paste0(round(mean(df$PopTime, na.rm = TRUE), 2), "s") }) output$catcher_framing_heatmap <- renderPlot({ df <- catcher_filtered() %>% filter(!is.na(frame), !is.na(PlateLocSide), !is.na(PlateLocHeight)) %>% filter(frame %in% c("Strike Added", "Strike Lost")) if (nrow(df) < 10) { return(ggplot() + theme_void() + ggtitle("Insufficient data")) } ggplot(df, aes(PlateLocSide, PlateLocHeight)) + stat_density_2d(aes(fill = after_stat(density)), geom = "raster", contour = FALSE, alpha = 0.8) + scale_fill_gradientn(colours = c("white", "lightblue", "#FF9999", "red", "darkred")) + annotate("rect", xmin = -0.83, xmax = 0.83, ymin = 1.5, ymax = 3.38, fill = NA, color = "black", linewidth = 1.5) + coord_fixed() + xlim(-2, 2) + ylim(0, 4.5) + facet_wrap(~frame, nrow = 1) + theme_void() + theme(strip.text = element_text(size = 16, face = "bold"), legend.position = "none") }) output$catcher_framing_points <- renderPlotly({ df <- catcher_filtered() %>% filter(!is.na(frame), !is.na(PlateLocSide), !is.na(PlateLocHeight), frame %in% c("Strike Added", "Strike Lost")) if (nrow(df) < 10) { return(ggplotly(ggplot() + theme_void())) } pitch_colors <- c("Fastball" = "#FA8072", "Sinker" = "#fdae61", "Slider" = "#A020F0", "Curveball" = "#2c7bb6", "ChangeUp" = "#90EE90", "Cutter" = "red") p <- ggplot(df, aes(PlateLocSide, PlateLocHeight, color = TaggedPitchType, text = paste0("Pitch: ", TaggedPitchType, "
Frame: ", frame))) + geom_point(alpha = 0.7, size = 2) + annotate("rect", xmin = -0.83, xmax = 0.83, ymin = 1.5, ymax = 3.38, fill = NA, color = "black", linewidth = 1) + coord_fixed(xlim = c(-2, 2), ylim = c(0, 4.5)) + facet_wrap(~frame, nrow = 1) + scale_color_manual(values = pitch_colors, na.value = "grey60") + theme_minimal() + theme(strip.text = element_text(face = "bold")) ggplotly(p, tooltip = "text") }) output$catcher_throw_map <- renderPlot({ df <- catcher_filtered() %>% filter(!is.na(Notes), Notes %in% c("2b out", "2b safe", "3b out", "3b safe")) if (nrow(df) == 0) { return(ggplot() + theme_void() + ggtitle("No throwing data available")) } ggplot(df) + geom_polygon(data = data.frame(x = c(-10,10,10,-10), y = c(0.25,0.25,8,8)), aes(x = x, y = y), fill = '#14a6a8', color = '#14a6a8') + geom_polygon(data = data.frame(x = c(-10,10,10,-10), y = c(8,8,9,9)), aes(x = x, y = y), fill = 'yellow', color = 'yellow') + geom_polygon(data = data.frame(x = c(-10,10,10,-10), y = c(-2,-2,0.25,0.25)), aes(x = x, y = y), fill = 'brown', color = 'brown') + geom_polygon(data = data.frame(x = c(-10,10,10,-10), y = c(-5,-5,-2,-2)), aes(x = x, y = y), fill = 'darkgreen', color = 'darkgreen') + geom_polygon(data = data.frame(x = c(-1,1,1,-1), y = c(0,0,0.45,0.45)), aes(x = x, y = y), fill = 'white', color = 'black') + geom_point(aes(x = BasePositionZ, y = BasePositionY, fill = Notes), color = 'white', pch = 21, size = 4) + scale_fill_manual(values = c('2b safe' = 'red', '2b out' = '#339a1d', '3b safe' = '#ff6b6b', '3b out' = '#1a5d1a')) + scale_x_continuous(limits = c(-10, 10)) + scale_y_continuous(limits = c(-5, 9)) + coord_fixed() + theme_void() + theme(legend.position = "bottom") + ggtitle(paste(input$catcher_select, "- Throwing Report")) }) output$catcher_pop_dist <- renderPlotly({ df <- catcher_filtered() %>% filter(!is.na(PopTime)) if (nrow(df) == 0) return(ggplotly(ggplot() + theme_void())) p <- ggplot(df, aes(PopTime)) + geom_histogram(bins = 15, fill = "darkcyan", alpha = 0.7, color = "white") + geom_vline(xintercept = mean(df$PopTime), color = "peru", linetype = "dashed", size = 1) + labs(x = "Pop Time (s)", y = "Count") + theme_minimal() ggplotly(p) }) output$catcher_velo_trend <- renderPlotly({ df <- catcher_filtered() %>% filter(!is.na(ThrowSpeed)) %>% arrange(Date) %>% mutate(throw_num = row_number()) if (nrow(df) == 0) return(ggplotly(ggplot() + theme_void())) p <- ggplot(df, aes(throw_num, ThrowSpeed)) + geom_point(color = "darkcyan", size = 2) + geom_smooth(method = "loess", color = "peru", se = TRUE, fill = "peru", alpha = 0.3) + labs(x = "Throw #", y = "Velocity (MPH)") + theme_minimal() ggplotly(p) }) output$catcher_leaderboard <- DT::renderDataTable({ catcher_data %>% group_by(Catcher) %>% summarise( `Pitches Caught` = n(), `Strikes Added` = sum(frame_numeric == 1, na.rm = TRUE), `Strikes Lost` = sum(frame_numeric == -1, na.rm = TRUE), `Net Strikes` = sum(frame_numeric, na.rm = TRUE), `Frame Rate` = round(sum(frame_numeric == 1, na.rm = TRUE) / max(1, sum(!is.na(frame_numeric) & frame_numeric != 0)) * 100, 1), `Avg Pop Time` = round(mean(PopTime, na.rm = TRUE), 2), `Avg Throw Velo` = round(mean(ThrowSpeed, na.rm = TRUE), 1), .groups = "drop" ) %>% arrange(desc(`Net Strikes`)) %>% DT::datatable(options = list(pageLength = 10), rownames = FALSE) }) # ============================================================================ # RUN APP # ============================================================================ shinyApp(ui = ui, server = server)