Spaces:
Build error
Build error
| # ============================================================================ | |
| # 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, "<br>", | |
| "Batter: ", Batter, "<br>", | |
| "Pitcher: ", Pitcher, "<br>", | |
| "Inning: ", Inning, "<br>", | |
| "Opportunity Time: ", round(HangTime, 2), " sec<br>", | |
| "Distance Needed: ", round(closest_pos_dist, 1), " ft<br>", | |
| "Catch Probability: ", round(100 * catch_prob, 1), "%<br>", | |
| star_group | |
| ), | |
| hoverinfo = "text" | |
| ) %>% | |
| layout( | |
| hovermode = "closest", | |
| title = list( | |
| text = paste0(player, " - ", position, | |
| "<br><sup>OAA: ", OAA, " | Plays: ", n_plays, | |
| " | Expected Catches: ", expected_success, " | Actual Catches: ", actual_success, "</sup>"), | |
| 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, "<br>", | |
| "Catch Probability: ", round(100 * catch_prob, 1), "%<br>", 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, "<br>", | |
| "Batter: ", Batter, "<br>", | |
| "Play Probability: ", round(100 * play_prob, 1), "%<br>", | |
| "Angular Distance Away: ", round(obs_player_bearing_diff, 1), "°<br>", | |
| "Exit Velocity: ", round(ExitSpeed, 1), " mph<br>", | |
| star_group | |
| ), | |
| hoverinfo = "text" | |
| ) %>% | |
| layout( | |
| hovermode = "closest", | |
| title = list( | |
| text = paste0(player, " - ", position, | |
| "<br><sup>OAA: ", OAA, " | Plays: ", n_plays, | |
| " | Expected: ", expected_success, " | Actual: ", actual_success, "</sup>"), | |
| 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, "<br>", "Play Probability: ", round(100 * play_prob, 1), "%<br>", 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, "<br>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) |