Spaces:
Sleeping
Sleeping
Download app.R from CoastalBaseball/DefensiveApp: direct link, hf CLI and curl.
- Browser
- Download file 122 kB
-
https://huggingface.co/spaces/CoastalBaseball/DefensiveApp/resolve/main/app.R
- Command line
-
hf download hf://spaces/CoastalBaseball/DefensiveApp/app.R
-
curl -L -o app.R https://huggingface.co/spaces/CoastalBaseball/DefensiveApp/resolve/main/app.R
122 kB
| Sys.setenv(RETICULATE_PYTHON = "/usr/bin/python3") | |
| library(shiny) | |
| library(DT) | |
| library(htmltools) | |
| library(gt) | |
| library(gtExtras) | |
| library(plotly) | |
| library(tidymodels) | |
| library(xgboost) | |
| library(arrow) | |
| library(reticulate) | |
| library(recipes) | |
| library(httr) | |
| library(fmsb) | |
| library(dplyr) | |
| library(tidyr) | |
| library(stringr) | |
| library(ggplot2) | |
| library(scales) | |
| # ============================================================ | |
| # READ IN DATA | |
| # ============================================================ | |
| download_private_parquet <- function(repo_id, filename, max_retries = 3) { | |
| url <- paste0("https://huggingface.co/datasets/", repo_id, "/resolve/main/", filename) | |
| api_key <- Sys.getenv("HF_Token") | |
| if (api_key == "") stop("API key is not set.") | |
| for (attempt in 1:max_retries) { | |
| tryCatch({ | |
| response <- GET(url, add_headers(Authorization = paste("Bearer", api_key)), timeout(60)) | |
| if (status_code(response) == 200) { | |
| tmp <- tempfile(fileext = ".parquet") | |
| writeBin(content(response, "raw"), tmp) | |
| return(read_parquet(tmp)) | |
| } else warning(paste("Attempt", attempt, "failed with status:", status_code(response))) | |
| }, error = function(e) { | |
| warning(paste("Attempt", attempt, "failed:", e$message)) | |
| if (attempt < max_retries) Sys.sleep(2) | |
| }) | |
| } | |
| stop(paste("Failed to download dataset after", max_retries, "attempts")) | |
| } | |
| download_private_csv <- function(repo_id, filename, max_retries = 3) { | |
| url <- paste0("https://huggingface.co/datasets/", repo_id, "/resolve/main/", filename) | |
| api_key <- Sys.getenv("HF_Token") | |
| if (api_key == "") stop("API key is not set.") | |
| for (attempt in 1:max_retries) { | |
| tryCatch({ | |
| response <- GET(url, add_headers(Authorization = paste("Bearer", api_key)), timeout(60)) | |
| if (status_code(response) == 200) { | |
| tmp <- tempfile(fileext = ".csv") | |
| writeBin(content(response, "raw"), tmp) | |
| return(readr::read_csv(tmp, show_col_types = FALSE)) | |
| } else warning(paste("Attempt", attempt, "failed with status:", status_code(response))) | |
| }, error = function(e) { | |
| warning(paste("Attempt", attempt, "failed:", e$message)) | |
| if (attempt < max_retries) Sys.sleep(2) | |
| }) | |
| } | |
| stop(paste("Failed to download dataset after", max_retries, "attempts")) | |
| } | |
| clean_bind <- function(data) { | |
| data <- data %>% | |
| mutate( | |
| Date = as.Date(Date), | |
| across(any_of(c("Time","Tilt","UTCTime","LocalDateTime","UTCDateTime", | |
| "CatchPositionX","CatchPositionY","CatchPositionZ", | |
| "ThrowPositionX","ThrowPositionY","ThrowPositionZ", | |
| "BasePositionX","BasePositionY","BasePositionZ")), as.character), | |
| across(starts_with("ThrowTrajectory"), as.character), | |
| across(ends_with("PositionAtReleaseX"), as.numeric), | |
| across(ends_with("PositionAtReleaseZ"), as.numeric), | |
| across(ends_with("_Id"), as.character)) | |
| if ("UTCDate" %in% names(data)) data$UTCDate <- as.Date(data$UTCDate) | |
| if ("BatSpeed" %in% names(data)) data$BatSpeed <- as.numeric(data$BatSpeed) | |
| if ("VerticalAttackAngle" %in% names(data)) data$VerticalAttackAngle <- as.numeric(data$VerticalAttackAngle) | |
| if ("HorizontalAttackAngle" %in% names(data)) data$HorizontalAttackAngle <- as.numeric(data$HorizontalAttackAngle) | |
| if ("PitchNo" %in% names(data)) data$PitchNo <- as.numeric(data$PitchNo) | |
| if ("FHC" %in% names(data)) data$FHC <- as.numeric(data$FHC) | |
| data | |
| } | |
| download_master_dataset <- function(repo_id, folder, token = Sys.getenv("HF_Token")) { | |
| hf <- reticulate::import("huggingface_hub") | |
| api <- hf$HfApi() | |
| files <- api$list_repo_files(repo_id = repo_id, repo_type = "dataset", token = token) | |
| pq_files <- files[grepl(paste0("^", folder, "/.*\\.parquet$"), files)] | |
| if (length(pq_files) == 0) return(data.frame()) | |
| all_data <- lapply(pq_files, function(f) { | |
| url <- paste0("https://huggingface.co/datasets/", repo_id, "/resolve/main/", f) | |
| resp <- httr::GET(url, httr::add_headers(Authorization = paste("Bearer", token))) | |
| if (httr::status_code(resp) == 200) { | |
| tmp <- tempfile(fileext = ".parquet") | |
| writeBin(httr::content(resp, as = "raw"), tmp) | |
| d <- arrow::read_parquet(tmp); file.remove(tmp); d | |
| } else NULL | |
| }) | |
| combined <- bind_rows(Filter(Negate(is.null), lapply(all_data, clean_bind))) | |
| if ("PitchUID" %in% names(combined)) combined <- combined %>% distinct(PitchUID, .keep_all = TRUE) | |
| combined | |
| } | |
| download_private_xgb <- function(repo_id, filename, max_retries = 3) { | |
| url <- paste0("https://huggingface.co/datasets/", repo_id, "/resolve/main/", filename) | |
| api_key <- Sys.getenv("HF_Token") | |
| if (api_key == "") stop("API key is not set.") | |
| for (attempt in 1:max_retries) { | |
| tryCatch({ | |
| response <- GET(url, add_headers(Authorization = paste("Bearer", api_key)), timeout(60)) | |
| if (status_code(response) == 200) { | |
| tmp <- tempfile(fileext = ".json") | |
| writeBin(content(response, "raw"), tmp) | |
| return(xgb.load(tmp)) | |
| } else warning(paste("Attempt", attempt, "failed with status:", status_code(response))) | |
| }, error = function(e) { | |
| warning(paste("Attempt", attempt, "failed:", e$message)) | |
| if (attempt < max_retries) Sys.sleep(2) | |
| }) | |
| } | |
| stop(paste("Failed to download dataset after", max_retries, "attempts")) | |
| } | |
| download_private_rds <- function(repo_id, filename, max_retries = 3) { | |
| url <- paste0("https://huggingface.co/datasets/", repo_id, "/resolve/main/", filename) | |
| api_key <- Sys.getenv("HF_Token") | |
| if (api_key == "") stop("API key is not set.") | |
| for (attempt in 1:max_retries) { | |
| tryCatch({ | |
| response <- GET(url, add_headers(Authorization = paste("Bearer", api_key)), timeout(60)) | |
| if (status_code(response) == 200) { | |
| tmp <- tempfile(fileext = ".rds") | |
| writeBin(content(response, "raw"), tmp) | |
| return(readRDS(tmp)) | |
| } else warning(paste("Attempt", attempt, "failed:", e$message)) | |
| }, error = function(e) { | |
| warning(paste("Attempt", attempt, "failed:", e$message)) | |
| if (attempt < max_retries) Sys.sleep(2) | |
| }) | |
| } | |
| stop(paste("Failed to download dataset after", max_retries, "attempts")) | |
| } | |
| # Load models & recipes | |
| IF_OAA_recipe <- download_private_rds("CoastalBaseball/DefenseAppDataset", "new_if_oaa_recipe.rds") | |
| IF_OAA_model <- download_private_xgb("CoastalBaseball/DefenseAppDataset", "new_if_oaa_model.json") | |
| OF_OAA_recipe <- download_private_rds("CoastalBaseball/DefenseAppDataset", "new_oaa_recipe.rds") | |
| OF_OAA_model <- download_private_xgb("CoastalBaseball/DefenseAppDataset", "new_oaa_model.json") | |
| # Load pre-computed 2025 data | |
| IF_OAA_25 <- download_private_parquet("CoastalBaseball/DefenseAppDataset", "IF_OAA_DATA.parquet") %>% | |
| mutate(obs_player_name = str_replace_all(obs_player_name, "(\\w+), (\\w+)", "\\2 \\1"), | |
| play_pred = as.numeric(as.character(play_pred)), | |
| Date = as.Date(Date), | |
| Inning = as.numeric(Inning)) | |
| if ("obs_player_bearing_diff" %in% names(IF_OAA_25)) { | |
| IF_OAA_25$obs_player_direction_diff <- IF_OAA_25$obs_player_bearing_diff | |
| } | |
| OF_OAA_25 <- as.data.frame(download_private_parquet("CoastalBaseball/DefenseAppDataset", "CC_OAA25.parquet")) %>% | |
| mutate(obs_player = str_replace_all(obs_player, "(\\w+), (\\w+)", "\\2 \\1"), | |
| pred_catch = as.numeric(as.character(pred_catch)), | |
| Date = as.Date(Date), | |
| Inning = as.numeric(Inning)) | |
| # Load 2026 raw data | |
| spring26_pbp <- download_master_dataset("CoastalBaseball/2026MasterDataset", "pbp") | |
| spring26_pos <- download_master_dataset("CoastalBaseball/2026MasterDataset", "pos") | |
| spring26_ncaa <- download_master_dataset("CoastalBaseball/2026MasterDataset", "ncaa_pbp") | |
| college_join <- download_private_csv("CoastalBaseball/DefenseAppDataset", "college_join.csv") | |
| # Load Catcher 2026 data | |
| Catcher2026 <- read_parquet("Catcher2026.parquet") | |
| Catcher2026 <- Catcher2026 %>% | |
| mutate( | |
| Date = as.Date(Date), | |
| across(any_of(c("CatchPositionX","CatchPositionY","CatchPositionZ", | |
| "ThrowPositionX","ThrowPositionY","ThrowPositionZ", | |
| "BasePositionX","BasePositionY","BasePositionZ")), as.numeric), | |
| across(starts_with("ThrowTrajectory"), as.numeric), | |
| across(any_of(c("ThrowSpeed","PopTime","ExchangeTime","TimeToBase")), as.numeric), | |
| across(any_of(c("x0","y0","z0","vx0","vy0","vz0","ax0","ay0","az0")), as.numeric), | |
| Notes = as.character(Notes), | |
| is_swing = as.numeric(is_swing), | |
| in_zone = as.numeric(in_zone) | |
| ) | |
| # Classify framing: Strike Added / Strike Lost | |
| # Use plate location geometry (converted to feet) to determine zone status | |
| # Zone: |PlateLocSide/12| <= 0.83083 AND PlateLocHeight/12 between 1.5 and 3.3775 | |
| CALLED_STRIKE <- c("StrikeCalled","Called Strike","strike_called") | |
| CALLED_BALL <- c("BallCalled","Ball","ball_called","BallinDirt","BallIntentional", | |
| "HitByPitch","AutomaticBall") | |
| Catcher2026 <- Catcher2026 %>% | |
| mutate( | |
| pls_ft = as.numeric(PlateLocSide) / 12, | |
| plh_ft = as.numeric(PlateLocHeight) / 12, | |
| loc_in_zone = abs(pls_ft) <= 0.83083 & plh_ft >= 1.5 & plh_ft <= 3.3775, | |
| frame = case_when( | |
| is.na(pls_ft) | is.na(plh_ft) ~ NA_character_, | |
| !loc_in_zone & PitchCall %in% CALLED_STRIKE ~ "Strike Added", | |
| loc_in_zone & PitchCall %in% CALLED_BALL ~ "Strike Lost", | |
| TRUE ~ NA_character_ | |
| ), | |
| frame_numeric = case_when( | |
| frame == "Strike Added" ~ 1, | |
| frame == "Strike Lost" ~ -1, | |
| TRUE ~ NA_real_ | |
| ) | |
| ) %>% | |
| dplyr::select(-pls_ft, -plh_ft, -loc_in_zone) | |
| # ============================================================ | |
| # PREPARE DEFENSE DATA | |
| # ============================================================ | |
| join_pbp_pos <- function(pbp_df, pos_df) { | |
| df <- left_join(pbp_df, | |
| pos_df %>% | |
| rename(pos_PitchNo = PitchNo, pos_PlayID = PlayID, | |
| pos_PlayResult = PlayResult, pos_PitchCall = PitchCall) %>% | |
| dplyr::select(-any_of(c("Date","Time","PitcherTeam","BatterTeam", | |
| "GameUID","UTCDate","UTCTime","LocalDateTime","UTCDateTime"))), | |
| by = "PitchUID") %>% | |
| group_by(GameUID, PitcherTeam) %>% | |
| fill(all_of(ends_with("_Name")), .direction = "downup") %>% | |
| ungroup() %>% | |
| drop_na("LF_Name") | |
| df | |
| } | |
| prefixes <- c("St\\.", "Mc", "De", "Di", "Van", "Von") | |
| join_pbppos_ncaa <- function(trackman_df, ncaa_df) { | |
| trackman_df <- trackman_df %>% | |
| mutate(Inning = as.character(Inning), | |
| Date = as.character(Date)) | |
| ncaa_df <- ncaa_df %>% | |
| mutate(inning = as.character(inning), | |
| Date = as.character(game_date)) | |
| trackman_df <- trackman_df %>% | |
| mutate(HomeTeam = ifelse(HomeTeam == "AUB_PRC", "AUB_TIG", HomeTeam), | |
| HomeTeam = ifelse(HomeTeam == "WVN_MN2", "WES_MOU", HomeTeam), | |
| Date = as.character(Date)) | |
| trackman_df <- trackman_df %>% | |
| mutate( | |
| last_name = if_else( | |
| str_detect(Batter, paste0("\\b(", paste(prefixes, collapse = "|"), ")\\s+[A-Z][a-z]+$")), | |
| str_extract(Batter, paste0("(", paste(prefixes, collapse = "|"), ")\\s+[A-Z][a-z]+$")), | |
| str_extract(Batter, "[^ ]+$")), | |
| last_name = tolower(last_name)) | |
| ncaa_df <- ncaa_df %>% | |
| dplyr::select(., -any_of(c("HomeTeam", "AwayTeam"))) %>% | |
| left_join(college_join, by = "home_team") %>% | |
| left_join(college_join %>% rename(away_team = home_team, AwayTeam = HomeTeam), by = "away_team") | |
| ncaa_df <- ncaa_df %>% | |
| filter(sub_fl == "0") %>% | |
| filter(!str_detect(tmp_text, "wild pitch|passed ball|stole|caught stealing|picked off|No play|challenge|Batting Starts|Batting ends|Ejected|confirmed|balk|Challenged|overturned|upheld|suspended|resumed|challenged|challenge,")) %>% | |
| filter(!str_starts(tmp_text, "NA")) %>% | |
| mutate( | |
| tmp_text = str_trim(tmp_text, side = "left"), | |
| last_name_pbp = str_extract(tmp_text, "^[^ ,]+(?:\\s[A-Z][a-z]+)?"), | |
| last_name_pbp = str_replace(last_name_pbp, "^[a-zA-Z]\\.?\\s+", ""), | |
| last_name_pbp = tolower(last_name_pbp), | |
| game_date = as.Date(game_date)) %>% | |
| drop_na(bat_text) | |
| hits_df <- left_join(trackman_df, | |
| ncaa_df %>% | |
| dplyr::select(-BatterTeam, -PitcherTeam) %>% | |
| distinct(last_name_pbp, inning, Date, HomeTeam, AwayTeam, .keep_all = TRUE), | |
| by = c("last_name" = "last_name_pbp", "Inning" = "inning", "Date", "HomeTeam", "AwayTeam"), | |
| relationship = "many-to-one") %>% | |
| distinct(PitchUID, .keep_all = TRUE) %>% | |
| drop_na(away_score_after) | |
| hits_df | |
| } | |
| of_oaa_setup <- function(data) { | |
| d <- data %>% | |
| filter(TaggedHitType %in% c("FlyBall","LineDrive","Popup") | | |
| AutoHitType %in% c("FlyBall","LineDrive","Popup")) %>% | |
| mutate( | |
| across(ends_with("PositionAtReleaseX"), as.numeric), | |
| across(ends_with("PositionAtReleaseZ"), as.numeric), | |
| rad = Bearing * pi/180, | |
| x_pos = Distance * cos(rad), z_pos = Distance * sin(rad), | |
| dist_RF = sqrt((x_pos - RF_PositionAtReleaseX)^2 + (z_pos - RF_PositionAtReleaseZ)^2), | |
| dist_CF = sqrt((x_pos - CF_PositionAtReleaseX)^2 + (z_pos - CF_PositionAtReleaseZ)^2), | |
| dist_LF = sqrt((x_pos - LF_PositionAtReleaseX)^2 + (z_pos - LF_PositionAtReleaseZ)^2), | |
| hit_location = case_when( | |
| dist_CF <= dist_RF & dist_CF <= dist_LF ~ "CF", | |
| dist_RF <= dist_CF & dist_RF <= dist_LF ~ "RF", | |
| dist_LF <= dist_CF & dist_LF <= dist_RF ~ "LF", TRUE ~ NA_character_), | |
| closest_pos_dist = case_when( | |
| hit_location == "CF" ~ dist_CF, hit_location == "RF" ~ dist_RF, | |
| hit_location == "LF" ~ dist_LF, TRUE ~ NA_real_), | |
| angle_from_home = case_when( | |
| hit_location == "CF" ~ atan2(z_pos - CF_PositionAtReleaseZ, x_pos - CF_PositionAtReleaseX) * 180/pi, | |
| hit_location == "RF" ~ atan2(z_pos - RF_PositionAtReleaseZ, x_pos - RF_PositionAtReleaseX) * 180/pi, | |
| hit_location == "LF" ~ atan2(z_pos - LF_PositionAtReleaseZ, x_pos - LF_PositionAtReleaseX) * 180/pi, | |
| TRUE ~ NA_real_), | |
| angle_from_home = case_when( | |
| (angle_from_home > 0 & angle_from_home <= 180) ~ abs(angle_from_home - 180), | |
| (angle_from_home >= -180 & angle_from_home <= 0) ~ -(angle_from_home + 180), | |
| TRUE ~ NA_real_), | |
| obs_player = case_when( | |
| hit_location == "CF" ~ CF_Name, hit_location == "LF" ~ LF_Name, | |
| hit_location == "RF" ~ RF_Name, TRUE ~ NA_character_), | |
| success_ind = as.numeric(OutsOnPlay > 0), | |
| feet_per_second = closest_pos_dist / HangTime) %>% | |
| filter(feet_per_second <= 30, closest_pos_dist < 200, | |
| event_type != "Home Run") | |
| d <- d %>% mutate(success_ind = factor(success_ind)) | |
| processed <- bake(OF_OAA_recipe, new_data = d) %>% | |
| dplyr::select(-any_of("success_ind")) %>% | |
| mutate(across(where(is.factor), as.numeric), across(where(is.character), as.numeric)) | |
| dmat <- xgb.DMatrix(data = as.matrix(processed)) | |
| probs <- predict(OF_OAA_model, dmat) | |
| d <- cbind(d, catch_prob = probs) | |
| d %>% mutate( | |
| obs_player = str_replace_all(obs_player, "(\\w+), (\\w+)", "\\2 \\1"), | |
| catch_prob = 1 - catch_prob, | |
| pred_catch = ifelse(catch_prob >= .5, 1, 0), | |
| OAA = (as.numeric(success_ind) - 1) - catch_prob) %>% | |
| dplyr::select(OAA, success_ind, catch_prob, pred_catch, closest_pos_dist, | |
| x_pos, z_pos, Distance, Bearing, HangTime, angle_from_home, | |
| obs_player, feet_per_second, hit_location, Date, | |
| Inning, Batter, Pitcher, HomeTeam, AwayTeam, | |
| PitcherTeam, ends_with("AtReleaseX"), ends_with("AtReleaseZ")) | |
| } | |
| if_oaa_setup <- function(data) { | |
| d <- data %>% | |
| filter(TaggedHitType == "GroundBall") %>% | |
| mutate(across(starts_with("HitTr"), as.numeric)) %>% | |
| filter(!is.na(Direction)) %>% | |
| mutate( | |
| extract_raw = case_when( | |
| str_detect(bat_text, "grounded out,") ~ | |
| str_extract(bat_text, "(?<=,\\s)\\w+(?=\\s+to\\s+\\w+)"), | |
| TRUE ~ str_extract(bat_text, "(?<=\\b(?:to|down the|through the|up the|by)\\s)\\w+(?:\\s\\w+)?")), | |
| extracted_word = case_when( | |
| str_detect(extract_raw, "^\\w{2}(\\s|$)") ~ word(extract_raw, 1), TRUE ~ extract_raw), | |
| bearing_1b = atan2(`1B_PositionAtReleaseZ`, `1B_PositionAtReleaseX`) * 180/pi, | |
| bearing_1b = case_when(bearing_1b <= -180 ~ bearing_1b+360, bearing_1b > 180 ~ bearing_1b-360, TRUE ~ bearing_1b), | |
| bearing_2b = atan2(`2B_PositionAtReleaseZ`, `2B_PositionAtReleaseX`) * 180/pi, | |
| bearing_2b = case_when(bearing_2b <= -180 ~ bearing_2b+360, bearing_2b > 180 ~ bearing_2b-360, TRUE ~ bearing_2b), | |
| bearing_3b = atan2(`3B_PositionAtReleaseZ`, `3B_PositionAtReleaseX`) * 180/pi, | |
| bearing_3b = case_when(bearing_3b <= -180 ~ bearing_3b+360, bearing_3b > 180 ~ bearing_3b-360, TRUE ~ bearing_3b), | |
| bearing_ss = atan2(`SS_PositionAtReleaseZ`, `SS_PositionAtReleaseX`) * 180/pi, | |
| bearing_ss = case_when(bearing_ss <= -180 ~ bearing_ss+360, bearing_ss > 180 ~ bearing_ss-360, TRUE ~ bearing_ss), | |
| diff_1b = abs(Bearing - bearing_1b), diff_2b = abs(Bearing - bearing_2b), | |
| diff_3b = abs(Bearing - bearing_3b), diff_ss = abs(Bearing - bearing_ss)) %>% | |
| filter(!str_detect(bat_text, "interference|homered")) %>% | |
| filter(!(extracted_word %in% c("p","p unassisted","pitcher","c"))) %>% | |
| mutate( | |
| obs_player = case_when( | |
| extracted_word %in% c("1b","first base") ~ "1B", extracted_word %in% c("2b","second base") ~ "2B", | |
| extracted_word %in% c("3b","third base") ~ "3B", extracted_word %in% c("SS","shortstop") ~ "SS", | |
| pmin(diff_1b,diff_2b,diff_3b,diff_ss) == diff_1b ~ "1B", | |
| pmin(diff_1b,diff_2b,diff_3b,diff_ss) == diff_2b ~ "2B", | |
| pmin(diff_1b,diff_2b,diff_3b,diff_ss) == diff_3b ~ "3B", | |
| pmin(diff_1b,diff_2b,diff_3b,diff_ss) == diff_ss ~ "SS", TRUE ~ NA), | |
| obs_player_name = case_when( | |
| obs_player == "SS" ~ SS_Name, obs_player == "2B" ~ `2B_Name`, | |
| obs_player == "3B" ~ `3B_Name`, obs_player == "1B" ~ `1B_Name`, TRUE ~ NA), | |
| obs_player_z = case_when(obs_player=="1B"~`1B_PositionAtReleaseZ`,obs_player=="2B"~`2B_PositionAtReleaseZ`, | |
| obs_player=="3B"~`3B_PositionAtReleaseZ`,obs_player=="SS"~`SS_PositionAtReleaseZ`), | |
| obs_player_x = case_when(obs_player=="1B"~`1B_PositionAtReleaseX`,obs_player=="2B"~`2B_PositionAtReleaseX`, | |
| obs_player=="3B"~`3B_PositionAtReleaseX`,obs_player=="SS"~`SS_PositionAtReleaseX`), | |
| obs_player_bearing = case_when(obs_player=="1B"~bearing_1b,obs_player=="2B"~bearing_2b, | |
| obs_player=="3B"~bearing_3b,obs_player=="SS"~bearing_ss), | |
| obs_player_direction_diff = obs_player_bearing - Direction, | |
| obs_player_bearing_diff = obs_player_direction_diff, | |
| dist_from_first = sqrt((obs_player_z - 63.64)^2 + (obs_player_x + 63.64)^2), | |
| dist_from_second = sqrt((obs_player_z)^2 + (obs_player_x - 127.28)^2), | |
| dist_from_third = sqrt((obs_player_z + 63.64)^2 + (obs_player_x - 63.64)^2), | |
| dist_from_lead_base = case_when(!is.na(r1_name) & Outs < 2 ~ dist_from_second, TRUE ~ dist_from_first), | |
| success_ind = case_when( | |
| pos_PlayResult %in% c("Single","Double","Error","Sacrifice","Triple","HomeRun") ~ 0, | |
| str_detect(bat_text, "singled|doubled|tripled") ~ 0, | |
| outs_on_play > 0 ~ 1, TRUE ~ 1), | |
| player_angle_rad = atan2(obs_player_x, obs_player_z)) %>% | |
| filter(!is.na(obs_player)) | |
| processed <- bake(IF_OAA_recipe, new_data = d) %>% | |
| dplyr::select(-any_of("success_ind")) %>% | |
| mutate(across(where(is.factor), as.numeric), across(where(is.character), as.numeric)) | |
| dmat <- xgb.DMatrix(data = as.matrix(processed)) | |
| probs <- predict(IF_OAA_model, dmat) | |
| d <- cbind(d, play_prob = probs) | |
| d %>% mutate( | |
| obs_player_name = str_replace_all(obs_player_name, "(\\w+), (\\w+)", "\\2 \\1"), | |
| Date = as.Date(Date), | |
| Inning = as.numeric(Inning), | |
| play_prob = 1 - play_prob, | |
| play_pred = ifelse(play_prob >= .5, 1, 0), | |
| OAA = (as.numeric(success_ind)) - play_prob) %>% | |
| dplyr::select(obs_player_name, obs_player, success_ind, play_prob, play_pred, OAA, | |
| obs_player_direction_diff, obs_player_direction_diff, obs_player_bearing, | |
| obs_player_x, obs_player_z, | |
| HangTime, Distance, Bearing, Direction, ExitSpeed, Angle, | |
| dist_from_lead_base, player_angle_rad, | |
| Date, Pitcher, Inning, Batter, HomeTeam, AwayTeam, | |
| PitcherTeam, ends_with("AtReleaseX"), ends_with("AtReleaseZ"), | |
| everything()) | |
| } | |
| # Prepare data | |
| pbp_pos_spring26 <- join_pbp_pos(spring26_pbp, spring26_pos) | |
| pbp_pos_ncaa_spring26 <- join_pbppos_ncaa(pbp_pos_spring26, spring26_ncaa) | |
| OF_OAA_26 <- of_oaa_setup(pbp_pos_spring26) | |
| IF_OAA_26 <- if_oaa_setup(pbp_pos_ncaa_spring26) | |
| # Defensive positioning data | |
| def_pos_data <- pbp_pos_ncaa_spring26 | |
| # ============================================================ | |
| # LOAD BASERUNNING HUSTLE DATA | |
| # ============================================================ | |
| baserunning_raw <- read.csv("Coastal Carolina Baseball Hustle% 2025(Sheet1).csv", | |
| stringsAsFactors = FALSE, check.names = FALSE) | |
| baserunning_raw <- baserunning_raw[, !grepl("^$|^Unnamed", names(baserunning_raw))] | |
| colnames(baserunning_raw) <- c("Player", "MaxSpeedRaw", "AvgSpeedRaw", "HustlePctRaw", | |
| "Speed75Raw", "Speed80Raw", "Speed90Raw", "NoHR_FlyoutRaw") | |
| baserunning <- baserunning_raw %>% | |
| mutate(across(where(is.character), str_trim)) %>% | |
| mutate( | |
| MaxSpeed = as.numeric(str_remove(MaxSpeedRaw, " MPH")), | |
| AvgSpeed = as.numeric(str_remove(AvgSpeedRaw, " MPH")), | |
| HustlePct = as.numeric(str_remove(HustlePctRaw, "%")), | |
| Speed75 = as.numeric(str_remove(Speed75Raw, " MPH")), | |
| Speed80 = as.numeric(str_remove(Speed80Raw, " MPH")), | |
| Speed90 = as.numeric(str_remove(Speed90Raw, " MPH")), | |
| NoHR_Flyout = as.numeric(str_remove(NoHR_FlyoutRaw, "%")) | |
| ) %>% | |
| dplyr::select(Player, MaxSpeed, AvgSpeed, HustlePct, Speed75, Speed80, Speed90, NoHR_Flyout) %>% | |
| filter(!is.na(Player) & Player != "") | |
| # ============================================================ | |
| # PITCH COLORS & CATCHER HELPERS | |
| # ============================================================ | |
| pitch_colors <- c( | |
| "Fastball"="#3465cb","Four-Seam"="#3465cb","FourSeamFastBall"="#3465cb", | |
| "4-Seam Fastball"="#3465cb","FF"="#3465cb", | |
| "Sinker"="#e5e501","TwoSeamFastBall"="#e5e501","Two-Seam"="#e5e501", | |
| "2-Seam Fastball"="#e5e501","SI"="#e5e501", | |
| "Slider"="#65aa02","SL"="#65aa02", | |
| "Sweeper"="#dc4476","SW"="#dc4476", | |
| "Curveball"="#d73813","CB"="#d73813","Knuckle Curve"="#d73813","KC"="#d73813", | |
| "ChangeUp"="#980099","Changeup"="#980099","CH"="#980099", | |
| "Splitter"="#23a999","FS"="#23a999","SP"="#23a999", | |
| "Cutter"="#ff9903","FC"="#ff9903", | |
| "Slurve"="#9370DB","Other"="gray50") | |
| throw_color_map <- c('2B Out'='#339a1d','2B Safe'='red','3B Out'='#1a5d1a','3B Safe'='#ff6b6b') | |
| .classify_block_type <- function(notes) { | |
| dplyr::case_when( | |
| grepl("^block$", notes, ignore.case = TRUE) ~ "Block", | |
| grepl("pbwp|pb/wp|pb\\+wp|wild\\s*pitch|passed\\s*ball|wp|pb", notes, ignore.case = TRUE) ~ "PB/WP", | |
| grepl("block", notes, ignore.case = TRUE) ~ "Block", | |
| TRUE ~ "Block") | |
| } | |
| .is_block_event <- function(notes) { | |
| grepl("block|pbwp|pb/wp|pb\\+wp|wild\\s*pitch|passed\\s*ball|\\bwp\\b|\\bpb\\b", notes, ignore.case = TRUE) | |
| } | |
| .is_throw_event <- function(notes) { | |
| grepl("2b\\s*(out|safe)|3b\\s*(out|safe)", notes, ignore.case = TRUE) | |
| } | |
| .extract_throw_label <- function(notes) { | |
| n <- tolower(trimws(notes)) | |
| dplyr::case_when( | |
| grepl("2b\\s*out", n) ~ "2B Out", | |
| grepl("2b\\s*safe", n) ~ "2B Safe", | |
| grepl("3b\\s*out", n) ~ "3B Out", | |
| grepl("3b\\s*safe", n) ~ "3B Safe", | |
| TRUE ~ NA_character_ | |
| ) | |
| } | |
| catcher_compute_ground_intersection <- function(df) { | |
| df %>% mutate( | |
| .a = 0.5 * az0, .b = vz0, .c = z0, | |
| .disc = .b^2 - 4 * .a * .c, | |
| .t1 = ifelse(.disc >= 0, (-.b + sqrt(pmax(.disc,0)))/(2*.a), NA_real_), | |
| .t2 = ifelse(.disc >= 0, (-.b - sqrt(pmax(.disc,0)))/(2*.a), NA_real_), | |
| t_ground = pmin(ifelse(.t1>0,.t1,Inf), ifelse(.t2>0,.t2,Inf)), | |
| t_ground = ifelse(is.finite(t_ground), t_ground, NA_real_), | |
| x_ground = x0 + vx0*t_ground + 0.5*ax0*t_ground^2, | |
| y_ground = y0 + vy0*t_ground + 0.5*ay0*t_ground^2, | |
| in_dirt = !is.na(t_ground) | |
| ) %>% dplyr::select(-starts_with(".a"), -starts_with(".b"), -starts_with(".c"), | |
| -starts_with(".disc"), -starts_with(".t1"), -starts_with(".t2")) | |
| } | |
| # Helper: draw strike zone using geom_segment (plotly-compatible, no geom_rect) | |
| sz_segments <- function() { | |
| list( | |
| geom_segment(aes(x=-.83083,xend=.83083,y=1.5,yend=1.5), color="black", linewidth=.7, inherit.aes=FALSE), | |
| geom_segment(aes(x=-.83083,xend=.83083,y=3.3775,yend=3.3775), color="black", linewidth=.7, inherit.aes=FALSE), | |
| geom_segment(aes(x=-.83083,xend=-.83083,y=1.5,yend=3.3775), color="black", linewidth=.7, inherit.aes=FALSE), | |
| geom_segment(aes(x=.83083,xend=.83083,y=1.5,yend=3.3775), color="black", linewidth=.7, inherit.aes=FALSE) | |
| ) | |
| } | |
| bz_segments <- function() { | |
| list( | |
| geom_segment(aes(x=-.9975,xend=.9975,y=1.3775,yend=1.3775), color="gray50", linewidth=.5, linetype="dotted", inherit.aes=FALSE), | |
| geom_segment(aes(x=-.9975,xend=.9975,y=3.5,yend=3.5), color="gray50", linewidth=.5, linetype="dotted", inherit.aes=FALSE), | |
| geom_segment(aes(x=-.9975,xend=-.9975,y=1.3775,yend=3.5), color="gray50", linewidth=.5, linetype="dotted", inherit.aes=FALSE), | |
| geom_segment(aes(x=.9975,xend=.9975,y=1.3775,yend=3.5), color="gray50", linewidth=.5, linetype="dotted", inherit.aes=FALSE) | |
| ) | |
| } | |
| hp_polygon <- data.frame(x=c(-.60,.60,.60,0,-.60), y=c(.15,.15,.27,.42,.27)) | |
| # ============================================================ | |
| # FIELD DRAWING HELPERS | |
| # ============================================================ | |
| make_curve_segments <- function(x_start, y_start, x_end, y_end, curvature = 0.3, n = 40) { | |
| 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 | |
| i <- seq_len(n-1) | |
| tibble(x = x[i], y = y[i], xend = x[i+1], yend = y[i+1]) | |
| } | |
| 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) | |
| # ============================================================ | |
| # VISUALIZATION FUNCTIONS (OF/IF) | |
| # ============================================================ | |
| star_graph <- function(player, position, data) { | |
| if (position %in% c("RF","CF","LF")) { | |
| pd <- data %>% filter(obs_player == player, hit_location == position) | |
| } else { | |
| pd <- data %>% filter(obs_player == player) %>% mutate(hit_location = "All") | |
| } | |
| pd <- pd %>% mutate(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", | |
| catch_prob >= 0.00000001 ~ "5 Star")) | |
| OAA <- round(sum(pd$OAA, na.rm = TRUE), 1) | |
| n_plays <- nrow(pd) | |
| exp_s <- round(sum(pd$catch_prob, na.rm = TRUE), 1) | |
| act_s <- sum(as.numeric(as.character(pd$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,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)) | |
| pt_colors <- ifelse(pd$success_ind == 1, "green4", "white") | |
| suppressWarnings({ | |
| ggplotly(p, tooltip = NULL) %>% | |
| add_trace(data = pd, x = ~closest_pos_dist, y = ~HangTime, | |
| type = "scatter", mode = "markers", color = I(pt_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: ",exp_s," | Actual Catches: ",act_s,"</sup>"), | |
| font = list(size = 18)), margin = list(t = 80)) | |
| }) | |
| } | |
| field_graph <- function(player, position, oaa_df) { | |
| if (position %in% c("RF","CF","LF")) { | |
| pd <- oaa_df %>% filter(obs_player == player, hit_location == position) | |
| } else { | |
| pd <- oaa_df %>% filter(obs_player == player) %>% mutate(hit_location = "All") | |
| } | |
| df_f <- pd %>% mutate( | |
| 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", | |
| catch_prob >= 0.00000001 ~ "5 Star"), | |
| avg_y = case_when(position=="CF"~mean(CF_PositionAtReleaseX,na.rm=T), | |
| position=="RF"~mean(RF_PositionAtReleaseX,na.rm=T), | |
| position=="LF"~mean(LF_PositionAtReleaseX,na.rm=T),TRUE~NA_real_), | |
| avg_x = case_when(position=="CF"~mean(CF_PositionAtReleaseZ,na.rm=T), | |
| position=="RF"~mean(RF_PositionAtReleaseZ,na.rm=T), | |
| position=="LF"~mean(LF_PositionAtReleaseZ,na.rm=T),TRUE~NA_real_)) | |
| df_table <- df_f %>% group_by(star_group) %>% | |
| summarize(OAA = round(sum(OAA,na.rm=T),1), Plays = n(), | |
| Successes = sum((as.numeric(success_ind)-1),na.rm=T), .groups="drop") %>% | |
| mutate(`Success Rate` = paste0(Successes,"/",Plays," (",round((Successes/Plays)*100,2),"%)")) %>% | |
| 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_f$OAA,na.rm=T),2)) %>% dplyr::select(OAA, everything()) %>% | |
| gt() %>% gt_theme_guardian() %>% tab_header(title = "Play Success by Difficulty") %>% | |
| cols_align(align = "center", columns = everything()) %>% | |
| sub_missing(columns = everything(), missing_text = "-") %>% | |
| tab_options(heading.background.color = "darkcyan", column_labels.background.color = "darkcyan", | |
| table.border.top.color = "peru", table.border.bottom.color = "peru") %>% | |
| tab_style(style = cell_text(color = "white"), locations = 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(aes(x=-1,y=160,xend=1,yend=160),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) + | |
| annotate("text",x=c(-297,297),y=277,label="400",size=3) + | |
| annotate("text",x=c(-333,333),y=313,label="450",size=3) + | |
| theme_void() + | |
| geom_point(data = df_f %>% mutate(is_catch = ifelse(success_ind==1,'Catch',"Hit")), | |
| aes(x=z_pos,y=x_pos,fill=is_catch, | |
| text=paste0("Date: ",Date, | |
| "<br>Opportunity Time: ",round(HangTime,2)," sec", | |
| "<br>Pitcher: ",Pitcher, | |
| "<br>Batter: ",Batter, | |
| "<br>Distance Needed: ",round(closest_pos_dist,1)," ft", | |
| "<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_f,aes(x=avg_x,y=avg_y, | |
| text=paste0("Avg Position","<br>X: ",round(avg_x,1),"<br>Y: ",round(avg_y,1))), | |
| 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") | |
| list(p, df_table) | |
| } | |
| direction_oaa <- function(player, position, oaa_data) { | |
| dir_levels <- c("Back","Back Left","Left","In Left","In","In Right", "Right", "Back Right") | |
| if (position %in% c("RF","CF","LF")) { | |
| pd <- oaa_data %>% filter(obs_player == player, hit_location == position) | |
| } else { | |
| pd <- oaa_data %>% filter(obs_player == player) %>% mutate(hit_location = "All") | |
| } | |
| pd <- pd %>% mutate( | |
| direction = case_when( | |
| angle_from_home <= -157.5 | angle_from_home >= 157.5 ~ "Back", | |
| angle_from_home <= -112.5 & angle_from_home > -157.5 ~ "Back Right", | |
| angle_from_home <= -67.5 & angle_from_home > -112.5 ~ "Right", | |
| angle_from_home <= -22.5 & angle_from_home > -67.5 ~ "In Right", | |
| angle_from_home > -22.5 & angle_from_home < 22.5 ~ "In", | |
| angle_from_home >= 22.5 & angle_from_home < 67.5 ~ "In Left", | |
| angle_from_home >= 67.5 & angle_from_home < 112.5 ~ "Left", | |
| angle_from_home >= 112.5 & angle_from_home < 157.5 ~ "Back Left"), | |
| direction = factor(direction, levels = dir_levels)) %>% | |
| dplyr::select(Direction = direction, OAA) %>% | |
| group_by(Direction) %>% | |
| summarize(OAA = sum(OAA,na.rm=T), Plays = n(), .groups = "drop") %>% | |
| complete(Direction = dir_levels, fill = list(OAA = 0, Plays = 0)) %>% | |
| arrange(factor(Direction, levels = dir_levels)) | |
| tbl <- pd %>% t() %>% as.data.frame() %>% `colnames<-`(.[1,]) %>% | |
| dplyr::slice(-1) %>% tibble::rownames_to_column(var = "Metric") %>% | |
| dplyr::select(Metric, Back, `Back Left`, Left, `In Left`, In, `In Right`, Right, `Back Right`) %>% | |
| mutate(across(Back:`Back Right`, as.numeric)) %>% | |
| gt() %>% gt_theme_guardian() %>% tab_header(title = "Directional OAA") %>% | |
| fmt_number(columns = c(Back,`Back Left`,Left,`In Left`,In,`In Right`,Right,`Back Right`), | |
| rows = Metric == "OAA", decimals = 2) %>% | |
| fmt_number(columns = c(Back,`Back Left`,Left,`In Left`,In,`In Right`,Right,`Back Right`), | |
| rows = Metric != "OAA", decimals = 0) %>% | |
| cols_align(align = "center", columns = everything()) %>% | |
| sub_missing(columns = everything(), missing_text = "-") %>% | |
| tab_options(heading.background.color = "darkcyan", column_labels.background.color = "darkcyan", | |
| table.border.top.color = "peru", table.border.bottom.color = "peru") %>% | |
| tab_style(style = cell_text(color = "white"), locations = cells_title(groups = "title")) | |
| axis_labels <- paste0(pd$Direction, " (", pd$Plays, ")") | |
| radar_data <- pd %>% dplyr::select(Direction, OAA) %>% pivot_wider(names_from = Direction, values_from = OAA) | |
| player_matrix <- rbind(max = rep(5, length(dir_levels)), min = rep(-5, length(dir_levels)), | |
| radar_data[1, dir_levels]) | |
| rownames(player_matrix) <- c("max","min",player) | |
| list(player_matrix = player_matrix, axis_labels = axis_labels, | |
| player = player, position = position, table = tbl) | |
| } | |
| infield_star_graph <- function(player, position, IF_OAA) { | |
| if (position %in% c("SS","1B","2B","3B")) { | |
| pd <- IF_OAA %>% filter(obs_player_name == player, obs_player == position) | |
| } else { | |
| pd <- IF_OAA %>% filter(obs_player_name == player) %>% mutate(obs_player = "All") | |
| } | |
| pd <- pd %>% mutate( | |
| 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", | |
| play_prob>=0.00000001~"5 Star")) | |
| OAA <- round(sum(pd$OAA,na.rm=T),1) | |
| n_plays <- nrow(pd) | |
| exp_s <- round(sum(pd$play_prob,na.rm=T),1) | |
| act_s <- sum(as.numeric(as.character(pd$success_ind)),na.rm=T) | |
| points_data <- pd %>% filter(play_prob > 0) %>% | |
| filter(play_prob <= 0.5 | between(obs_player_bearing_diff, -12.5, 12.5)) | |
| p <- ggplot(IF_OAA, | |
| 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", guide = guide_colorbar(barheight = unit(5,"in"))) + | |
| scale_x_continuous("Bearing Difference (degrees)", limits = c(-25,25), breaks = seq(-25,25,12.5)) + | |
| scale_y_continuous("Play Probability", limits = c(0,1), labels = percent) + | |
| theme_minimal() + | |
| theme(legend.position = "right", plot.title = element_text(hjust = 0.5, size = 18, face = "bold"), | |
| panel.grid.minor = element_blank(), panel.grid.major = element_line(color = "gray90"), | |
| axis.title = element_text(size = 12), axis.text = element_text(size = 10)) | |
| pt_colors <- ifelse(points_data$success_ind == 1, "green4", "white") | |
| suppressWarnings({ | |
| ggplotly(p, tooltip = NULL) %>% | |
| add_trace(data = points_data, x = ~obs_player_bearing_diff, y = ~play_prob, | |
| type = "scatter", mode = "markers", color = I(pt_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>Play Probability: ",round(100*play_prob,1),"%", | |
| "<br>Angular Distance Away: ",round(obs_player_bearing_diff,1),"\u00ba", | |
| "<br>Exit Velocity: ",round(ExitSpeed,1)," mph", | |
| "<br>Distance: ",round(Distance,1)," ft<br>",star_group), | |
| hoverinfo = "text") %>% | |
| layout(hovermode = "closest", | |
| title = list(text = paste0(player," - ",position, | |
| "<br><sup>OAA: ",OAA," | Plays: ",n_plays, | |
| " | Expected Plays Made: ",exp_s, | |
| " | Actual Plays Made: ",act_s,"</sup>"), | |
| font = list(size = 18)), margin = list(t = 80)) | |
| }) | |
| } | |
| infield_field_graph <- function(player, position, IF_OAA) { | |
| if (position %in% c("SS","1B","2B","3B")) { | |
| pd <- IF_OAA %>% filter(obs_player_name == player, obs_player == position) | |
| } else { | |
| pd <- IF_OAA %>% filter(obs_player_name == player) | |
| } | |
| df_f <- pd %>% mutate( | |
| 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", | |
| play_prob>=0.00000001~"5 Star"), | |
| rad = Bearing*pi/180, x_pos = Distance*cos(rad), z_pos = Distance*sin(rad)) %>% | |
| group_by(obs_player) %>% | |
| mutate(avg_y = mean(obs_player_x,na.rm=T), avg_x = mean(obs_player_z,na.rm=T)) %>% ungroup() | |
| df_table <- df_f %>% group_by(star_group) %>% | |
| summarize(OAA=round(sum(OAA,na.rm=T),1), Plays=n(), | |
| Successes=sum(as.numeric(as.character(success_ind)),na.rm=T), .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_f$OAA,na.rm=T),1)) %>% dplyr::select(OAA, everything()) %>% | |
| gt() %>% gt_theme_guardian() %>% tab_header(title = "Play Success by Difficulty") %>% | |
| cols_align(align = "center", columns = everything()) %>% | |
| sub_missing(columns = everything(), missing_text = "-") %>% | |
| tab_options(heading.background.color = "darkcyan", column_labels.background.color = "darkcyan", | |
| table.border.top.color = "peru", table.border.bottom.color = "peru") %>% | |
| tab_style(style = cell_text(color = "white"), locations = 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(aes(x=-1,y=160,xend=1,yend=160),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") + | |
| theme_void() + | |
| geom_point(data = df_f %>% mutate(is_catch = ifelse(success_ind==1,'Play Was Made',"Play Was Not Made")), | |
| aes(x=z_pos,y=x_pos,fill=is_catch, | |
| text=paste0("Date: ",Date,"<br>Pitcher: ",Pitcher,"<br>Batter: ",Batter, | |
| "<br>Play Probability: ",round(100*play_prob,1),"%", | |
| "<br>Exit Velocity: ",round(ExitSpeed,1)," mph", | |
| "<br>Distance: ",round(Distance,1)," ft<br>",star_group)), | |
| color="black",shape=21,size=2,alpha=.6) + | |
| labs(title = paste0(player," Possible Plays - ",position)) + | |
| scale_fill_manual(values=c("Play Was Not Made"="white","Play Was Made"="green4"),name=" ") + | |
| geom_point(data=df_f,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") | |
| list(p, df_table) | |
| } | |
| infield_positioning_heatmap <- function(team, man_on_first = "No Filter", man_on_second = "No Filter", | |
| man_on_third = "No Filter", BatterHand = "No Filter", | |
| PitcherHand = "No Filter", scorediff = "No Filter", | |
| Date1 = "2025-02-14", Date2 = "2026-06-22", | |
| obs_pitcher = "No Filter", Hitter = "No Filter", | |
| Count = "No Filter", obs_outs = "No Filter", | |
| Opponent = "No Filter") { | |
| td <- def_pos_data %>% filter(PitcherTeam == team) %>% | |
| mutate(scorediff_fieldpov = ifelse(team == away_team, | |
| away_score_before - home_score_before, | |
| home_score_before - away_score_before)) | |
| if (man_on_first != "No Filter") td <- td %>% filter(man_on_firstbase == man_on_first) | |
| if (man_on_second != "No Filter") td <- td %>% filter(man_on_secondbase == man_on_second) | |
| if (man_on_third != "No Filter") td <- td %>% filter(man_on_thirdbase == man_on_third) | |
| if (BatterHand != "No Filter") td <- td %>% filter(BatterSide == BatterHand) | |
| if (PitcherHand != "No Filter") td <- td %>% filter(PitcherThrows == PitcherHand) | |
| if (scorediff != "No Filter") td <- td %>% filter(scorediff_fieldpov == as.numeric(scorediff)) | |
| td <- td %>% filter(Date >= as.Date(Date1) & Date <= as.Date(Date2)) | |
| if (obs_pitcher != "No Filter") td <- td %>% filter(Pitcher == obs_pitcher) | |
| if (Hitter != "No Filter") td <- td %>% filter(Batter == Hitter) | |
| if (Count != "No Filter") td <- td %>% filter(pitch_count == Count) | |
| if (obs_outs != "No Filter") td <- td %>% filter(Outs == as.numeric(obs_outs)) | |
| if (Opponent != "No Filter") td <- td %>% filter(BatterTeam == Opponent) | |
| n_obs <- nrow(td) | |
| pos_long <- td %>% pivot_longer( | |
| cols = matches("^(1B|2B|3B|SS|LF|CF|RF)_PositionAtRelease[XZ]$"), | |
| names_to = c("position",".value"), | |
| names_pattern = "^(.+)_PositionAtRelease([XZ])$") | |
| pos_table <- pos_long %>% group_by(position) %>% | |
| summarize(median_x = median(X,na.rm=T), median_z = median(Z,na.rm=T)) | |
| p <- ggplot() + | |
| geom_hline(yintercept = seq(0,400,20), linetype = "dotted", color = "gray80", size = 0.3) + | |
| geom_vline(xintercept = seq(-240,240,20), linetype = "dotted", color = "gray80", size = 0.3) + | |
| geom_density_2d_filled(data = pos_long, aes(x = Z, y = X), bins = 35, alpha = 0.5) + | |
| scale_fill_manual(values = colorRampPalette(c("#FFFFFF","#A6CEE3","#1F78B4", | |
| "#FB9A99","#E31A1C","#67000D"))(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(aes(x=-1,y=160,xend=1,yend=160),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") + | |
| geom_segment(data=curve3,aes(x=x,y=y,xend=xend,yend=yend),size=0.5,color="black",lineend="round") + | |
| geom_segment(data=curve4,aes(x=x,y=y,xend=xend,yend=yend),size=0.5,color="black",lineend="round") + | |
| geom_point(data=pos_table,aes(median_z,median_x),size=3.5,alpha=1,color="black",fill="black",shape=21,stroke=2.6) + | |
| labs(title=paste0("Starting Fielder Position \u2014 ",team), | |
| subtitle=paste0("Graph created from ",n_obs," available observations of data"),x="",y="") + | |
| ylim(0,411) + xlim(-250,250) + coord_equal() + | |
| theme_void() + | |
| theme(legend.position="none", plot.title=element_text(hjust=.5,size=20), | |
| plot.subtitle=element_text(hjust=0.5), legend.title=element_blank(), | |
| panel.grid.minor=element_blank(), axis.title=element_text(size=12), | |
| axis.text=element_blank(), axis.ticks=element_blank()) | |
| p | |
| } | |
| # ============================================================ | |
| # PASSWORD | |
| # ============================================================ | |
| PASSWORD <- Sys.getenv("password") | |
| # ============================================================ | |
| # LOGIN UI | |
| # ============================================================ | |
| login_ui <- fluidPage( | |
| tags$style(HTML(" | |
| body{background-color:#f0f4f8;font-family:'Segoe UI',Tahoma,Geneva,Verdana,sans-serif;color:#006F71;} | |
| .login-container{max-width:360px;margin:120px auto;background:#A27752;padding:30px 25px;border-radius:8px; | |
| box-shadow:0 4px 15px #A1A1A4;text-align:center;color:white;} | |
| .btn-primary{background-color:#006F71!important;border-color:#006F71!important;color:white!important; | |
| font-weight:bold;width:100%;margin-top:10px;box-shadow:0 2px 5px #006F71;transition:background-color .3s ease;} | |
| .btn-primary:hover{background-color:#006F71!important;border-color:#A27752!important;} | |
| .form-control{border-radius:4px;border:1.5px solid #006F71!important;color:#006F71;font-weight:600;} | |
| ")), | |
| div(class = "login-container", | |
| tags$img(src="https://upload.wikimedia.org/wikipedia/en/thumb/e/ef/Coastal_Carolina_Chanticleers_logo.svg/1200px-Coastal_Carolina_Chanticleers_logo.svg.png",height="150px"), | |
| passwordInput("password","Password:"), | |
| actionButton("login","Login"), | |
| textOutput("wrong_pass")) | |
| ) | |
| # ============================================================ | |
| # MAIN APP UI | |
| # ============================================================ | |
| app_ui <- fluidPage( | |
| tags$head(tags$style(HTML(" | |
| body,table,.gt_table{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;} | |
| .app-header{display:flex;justify-content:space-between;align-items:center;padding:20px 40px;background:#fff;border-bottom:3px solid darkcyan;margin-bottom:20px;} | |
| .header-logo-left,.header-logo-right{width:120px;height:auto;} | |
| .header-logo-center{max-width:400px;height:auto;} | |
| @media(max-width:768px){.app-header{flex-direction:column;padding:15px 20px;}.header-logo-left,.header-logo-right{width:80px;}.header-logo-center{max-width:250px;margin:10px 0;}} | |
| .nav-tabs{border:none!important;border-radius:50px;padding:6px 12px;margin:20px auto 0;max-width:100%; | |
| background:linear-gradient(135deg,#d4edeb 0%,#e8ddd0 50%,#d4edeb 100%);box-shadow:0 4px 16px rgba(0,139,139,.12),inset 0 2px 4px rgba(255,255,255,.6); | |
| border:1px solid rgba(0,139,139,.2);position:relative;overflow-x:auto;-webkit-overflow-scrolling:touch;display:flex;justify-content:center;align-items:center;flex-wrap:wrap;gap:6px;} | |
| .nav-tabs::-webkit-scrollbar{height:0;} | |
| .nav-tabs>li>a{color:darkcyan!important;border:none!important;border-radius:50px!important;background:transparent!important;font-weight:700;font-size:14.5px;padding:10px 22px;white-space:nowrap;letter-spacing:.2px;transition:all .2s ease;} | |
| .nav-tabs>li>a:hover{color:#006666!important;background:rgba(255,255,255,.5)!important;transform:translateY(-1px);} | |
| .nav-tabs>li.active>a,.nav-tabs>li.active>a:focus,.nav-tabs>li.active>a:hover{ | |
| background:linear-gradient(135deg,#008b8b 0%,#20b2aa 30%,#00ced1 50%,#20b2aa 70%,#008b8b 100%)!important; | |
| color:#fff!important;text-shadow:0 1px 2px rgba(0,0,0,.2);box-shadow:0 4px 16px rgba(0,139,139,.4),inset 0 2px 8px rgba(255,255,255,.4),inset 0 -2px 6px rgba(0,0,0,.2); | |
| border:1px solid rgba(255,255,255,.3)!important;} | |
| .tab-content{background:linear-gradient(135deg,rgba(255,255,255,.95),rgba(248,249,250,.95));border-radius:20px;padding:25px;margin-top:14px;box-shadow:0 15px 40px rgba(0,139,139,.1);backdrop-filter:blur(15px);border:1px solid rgba(0,139,139,.1);position:relative;overflow:hidden;} | |
| .tab-content::before{content:'';position:absolute;left:0;right:0;top:0;height:4px;background:linear-gradient(90deg,darkcyan,peru,darkcyan);background-size:200% 100%;animation:shimmer 3s linear infinite;} | |
| @keyframes shimmer{0%{background-position:-200% 0;}100%{background-position:200% 0;}} | |
| .well{background:#fff;border-radius:12px;border:1px solid rgba(0,139,139,.15);box-shadow:0 6px 18px rgba(0,0,0,.06);} | |
| .control-label{font-weight:700;color:#0a6a6a;} | |
| .selectize-input,.form-control{border-radius:10px;border:1px solid rgba(0,139,139,.35);} | |
| .gt_table{border-radius:12px;overflow:hidden;border:1px solid rgba(0,139,139,.15);} | |
| .section-header{color:darkcyan;border-bottom:2px solid peru;padding-bottom:6px;margin-bottom:16px;} | |
| .stat-box{display:inline-block;background:linear-gradient(135deg,#008b8b,#20b2aa);color:white;border-radius:12px;padding:12px 20px;margin:4px 6px;text-align:center;min-width:120px;box-shadow:0 4px 12px rgba(0,139,139,.25);} | |
| .stat-box .stat-value{font-size:22px;font-weight:800;line-height:1.2;} | |
| .stat-box .stat-label{font-size:11px;font-weight:600;opacity:.85;text-transform:uppercase;letter-spacing:.5px;} | |
| .stat-box-negative{background:linear-gradient(135deg,#c0392b,#e74c3c);} | |
| .stat-box-neutral{background:linear-gradient(135deg,#7f8c8d,#95a5a6);} | |
| "))), | |
| div(class="app-header", | |
| tags$img(src="https://i.imgur.com/7vx5Ci8.png",class="header-logo-left",alt="Logo Left"), | |
| tags$img(src="https://i.imgur.com/Sq0CZ0F.png",class="header-logo-center",alt="Main Logo"), | |
| tags$img(src="https://i.imgur.com/VbrN5WV.png",class="header-logo-right",alt="Logo Right")), | |
| tabsetPanel(id = "tabs", | |
| # ============================================= | |
| # TAB 1: Outfield OAA | |
| # ============================================= | |
| tabPanel("Outfield OAA", | |
| sidebarLayout( | |
| sidebarPanel(width = 3, | |
| selectInput("of_player","Player:",choices = NULL), | |
| hr(), | |
| checkboxGroupInput("of_year_select","Year:",choices=c("2025","2026"),selected=c("2025","2026")), | |
| hr(), | |
| selectInput("of_position","Position:",choices=c("LF","CF","RF","ALL"),selected="CF")), | |
| mainPanel(width = 9, | |
| h4("Star Graph (Catch Difficulty)", class = "section-header"), | |
| plotlyOutput("of_star_graph", height = "500px"), | |
| hr(), | |
| h4("Field Graph (Catch Locations)", class = "section-header"), | |
| plotlyOutput("of_field_graph_plot", height = "500px"), | |
| gt_output("of_field_graph_table"), | |
| hr(), | |
| h4("Directional OAA", class = "section-header"), | |
| plotOutput("of_direction_radar", height = "400px"), | |
| gt_output("of_direction_oaa_table")) | |
| ) | |
| ), | |
| # ============================================= | |
| # TAB 2: Infield OAA | |
| # ============================================= | |
| tabPanel("Infield OAA", | |
| sidebarLayout( | |
| sidebarPanel(width = 3, | |
| selectInput("if_player","Player:",choices = NULL), | |
| hr(), | |
| checkboxGroupInput("if_year_select","Year:",choices=c("2025","2026"),selected=c("2026")), | |
| hr(), | |
| selectInput("if_position","Position:",choices=c("SS","2B","3B","1B","ALL"),selected="SS")), | |
| mainPanel(width = 9, | |
| h4("Star Graph (Play Difficulty)", class = "section-header"), | |
| plotlyOutput("if_star_graph", height = "500px"), | |
| hr(), | |
| h4("Field Graph (Play Locations)", class = "section-header"), | |
| plotlyOutput("if_field_graph_plot", height = "500px"), | |
| gt_output("if_field_graph_table")) | |
| ) | |
| ), | |
| # ============================================= | |
| # TAB 3: Catching (Receiving / Throwing / Blocking) | |
| # ============================================= | |
| tabPanel("Catching", | |
| sidebarLayout( | |
| sidebarPanel(width = 3, | |
| selectInput("ct_catcher", "Catcher:", choices = NULL), | |
| hr(), | |
| selectInput("ct_batter_hand", "Batter Hand:", | |
| choices = c("All","Right","Left"), selected = "All"), | |
| selectInput("ct_pitcher_hand", "Pitcher Hand:", | |
| choices = c("All","Right","Left"), selected = "All"), | |
| selectInput("ct_pitch_type", "Pitch Type:", | |
| choices = c("All"), selected = "All"), | |
| selectInput("ct_count", "Count:", | |
| choices = c("All"), selected = "All") | |
| ), | |
| mainPanel(width = 9, | |
| tabsetPanel(id = "ct_subtabs", | |
| # ------- RECEIVING SUB-TAB ------- | |
| tabPanel("Receiving", | |
| br(), | |
| uiOutput("recv_header_stats"), | |
| hr(), | |
| fluidRow( | |
| column(6, | |
| selectInput("recv_view_mode", "View Mode:", | |
| choices = c("Points","Heatmap","Catch Position (Overhead)","Catch Depth (Side)"), | |
| selected = "Points", width = "100%")), | |
| column(3, | |
| checkboxInput("recv_catchers_view", "Catcher's View", value = FALSE)) | |
| ), | |
| fluidRow( | |
| column(6, | |
| h4("Strikes Added", class = "section-header"), | |
| plotOutput("recv_strikes_added", height = "480px")), | |
| column(6, | |
| h4("Strikes Lost", class = "section-header"), | |
| plotOutput("recv_strikes_lost", height = "480px")) | |
| ), | |
| hr(), | |
| h4("Framing Log", class = "section-header"), | |
| DTOutput("recv_log_table") | |
| ), | |
| # ------- THROWING SUB-TAB ------- | |
| tabPanel("Throwing", | |
| br(), | |
| uiOutput("throw_header_stats"), | |
| hr(), | |
| fluidRow( | |
| column(4, | |
| selectInput("throw_view_mode", "Base Arrival View:", | |
| choices = c("Points (by Result)","Heatmap","Color by Throw Speed"), | |
| selected = "Points (by Result)", width = "100%")), | |
| column(4, | |
| selectInput("throw_release_color", "Release Point Color By:", | |
| choices = c("Result","Throw Speed"), | |
| selected = "Result", width = "100%")) | |
| ), | |
| fluidRow( | |
| column(6, | |
| h4("Outs", class = "section-header"), | |
| plotlyOutput("throw_outs_plot", height = "520px")), | |
| column(6, | |
| h4("Safe", class = "section-header"), | |
| plotlyOutput("throw_safe_plot", height = "520px")) | |
| ), | |
| hr(), | |
| fluidRow( | |
| column(6, | |
| h4("Release Point (Front View)", class = "section-header"), | |
| plotlyOutput("throw_release_plot", height = "480px")), | |
| column(6, | |
| h4("Release Point (Side View)", class = "section-header"), | |
| plotlyOutput("throw_release_side_plot", height = "480px")) | |
| ), | |
| hr(), | |
| h4("Throwing Log", class = "section-header"), | |
| DTOutput("throw_log_table") | |
| ), | |
| # ------- BLOCKING SUB-TAB ------- | |
| tabPanel("Blocking", | |
| br(), | |
| uiOutput("block_header_stats"), | |
| hr(), | |
| fluidRow( | |
| column(6, | |
| selectInput("block_view_mode", "View Mode:", | |
| choices = c("Points","Heatmap"), | |
| selected = "Points", width = "100%")), | |
| column(3, | |
| checkboxInput("block_catchers_view", "Catcher's View", value = FALSE)) | |
| ), | |
| fluidRow( | |
| column(6, | |
| h4("Ground Intersection (Overhead)", class = "section-header"), | |
| plotlyOutput("block_ground_plot", height = "500px")), | |
| column(6, | |
| h4("Blocking (Zone View)", class = "section-header"), | |
| plotOutput("block_zone_plot", height = "500px")) | |
| ), | |
| hr(), | |
| h4("Blocking Log", class = "section-header"), | |
| DTOutput("block_log_table") | |
| ) | |
| ) | |
| ) | |
| ) | |
| ), | |
| # ============================================= | |
| # TAB 4: 6th Tool Fielding (placeholder) | |
| # ============================================= | |
| tabPanel("6th Tool Fielding", h4("Coming Soon", style="text-align:center;color:darkcyan;padding:60px;")), | |
| # ============================================= | |
| # TAB 5: Baserunning | |
| # ============================================= | |
| tabPanel("Baserunning", | |
| fluidRow( | |
| column(3, | |
| selectInput("br_player", "Player:", choices = NULL)), | |
| column(3, | |
| selectInput("br_metric", "Rank By:", | |
| choices = c("Hustle %" = "HustlePct", | |
| "Max Sprint Speed" = "MaxSpeed", | |
| "Avg Sprint Speed" = "AvgSpeed", | |
| "No HR/Flyout %" = "NoHR_Flyout"), | |
| selected = "HustlePct")) | |
| ), | |
| hr(), | |
| h4("Team Hustle Leaderboard", class = "section-header"), | |
| gt_output("br_leaderboard"), | |
| hr(), | |
| fluidRow( | |
| column(6, | |
| h4("Sprint Speed Comparison", class = "section-header"), | |
| plotlyOutput("br_speed_chart", height = "420px")), | |
| column(6, | |
| h4("Hustle % Rankings", class = "section-header"), | |
| plotlyOutput("br_hustle_chart", height = "420px")) | |
| ), | |
| hr(), | |
| fluidRow( | |
| column(6, | |
| h4("Player Speed Profile (Team Percentiles)", class = "section-header"), | |
| plotOutput("br_radar", height = "420px")), | |
| column(6, | |
| h4("Hustle Threshold Breakdown", class = "section-header"), | |
| plotlyOutput("br_threshold_chart", height = "420px")) | |
| ) | |
| ), | |
| # ============================================= | |
| # TAB 6: Team Defensive Positioning | |
| # ============================================= | |
| tabPanel("Team Defensive Positioning", | |
| fluidRow( | |
| column(2, selectInput("dp_team","Team:",choices=NULL,selected=NULL)), | |
| column(2, selectInput("dp_man_on_first","Runner on 1st:",choices=c("No Filter","TRUE","FALSE"),selected="No Filter")), | |
| column(2, selectInput("dp_man_on_second","Runner on 2nd:",choices=c("No Filter","TRUE","FALSE"),selected="No Filter")), | |
| column(2, selectInput("dp_man_on_third","Runner on 3rd:",choices=c("No Filter","TRUE","FALSE"),selected="No Filter")), | |
| column(2, selectInput("dp_batter_hand","Bat Hand:",choices=c("No Filter","Right","Left"),selected="No Filter"))), | |
| fluidRow( | |
| column(2, selectInput("dp_pitcher_hand","Pitch Hand:",choices=c("No Filter","Right","Left"),selected="No Filter")), | |
| column(2, selectInput("dp_scorediff","Score Diff:",choices=c("No Filter",-4:4),selected="No Filter")), | |
| column(2, dateInput("dp_date_from","From:",value="2025-02-14")), | |
| column(2, dateInput("dp_date_to","To:",value="2026-06-22")), | |
| column(2, selectInput("dp_pitcher","Pitcher:",choices=c("No Filter"),selected="No Filter"))), | |
| fluidRow( | |
| column(2, selectInput("dp_hitter","Batter:",choices=c("No Filter"),selected="No Filter")), | |
| column(2, selectInput("dp_count","Count:",choices=c("No Filter"),selected="No Filter")), | |
| column(2, selectInput("dp_outs","Outs:",choices=c("No Filter",0,1,2),selected="No Filter")), | |
| column(2, selectInput("dp_opponent","Opponent:",choices=c("No Filter"),selected="No Filter"))), | |
| hr(), | |
| plotOutput("dp_heatmap", height = "700px") | |
| ), | |
| # ============================================= | |
| # TAB 7: OAA Leaderboard | |
| # ============================================= | |
| tabPanel("OAA Leaderboard", | |
| fluidRow( | |
| column(3, | |
| checkboxGroupInput("lb_year_select", "Year:", | |
| choices = c("2025", "2026"), selected = c("2025", "2026"), inline = TRUE)), | |
| column(6, | |
| checkboxGroupInput("lb_position_select", "Position:", | |
| choices = c("LF","CF","RF","SS","2B","3B","1B"), | |
| selected = c("LF","CF","RF","SS","2B","3B","1B"), inline = TRUE)) | |
| ), | |
| hr(), | |
| gt_output("lb_table") | |
| ) | |
| ) | |
| ) | |
| # ============================================================ | |
| # ROOT UI | |
| # ============================================================ | |
| ui <- fluidPage(uiOutput("mainUI")) | |
| # ============================================================ | |
| # SERVER | |
| # ============================================================ | |
| server <- function(input, output, session) { | |
| # ---- Auth ---- | |
| authed <- reactiveVal(FALSE) | |
| observeEvent(input$login, { | |
| if (!nzchar(PASSWORD)) { authed(TRUE); output$wrong_pass <- renderText("") } | |
| else if (identical(input$password, PASSWORD)) { authed(TRUE); output$wrong_pass <- renderText("") } | |
| else { authed(FALSE); output$wrong_pass <- renderText("Incorrect password.") } | |
| }) | |
| output$mainUI <- renderUI({ if (isTRUE(authed())) app_ui else login_ui }) | |
| # ============================================================ | |
| # REACTIVE: Active OF data based on year selection | |
| # ============================================================ | |
| of_data <- reactive({ | |
| req(authed()) | |
| req(input$of_year_select) | |
| yrs <- input$of_year_select | |
| if (all(c("2025","2026") %in% yrs)) { | |
| bind_rows(OF_OAA_25, OF_OAA_26) | |
| } else if ("2025" %in% yrs) { | |
| OF_OAA_25 | |
| } else { | |
| OF_OAA_26 | |
| } | |
| }) | |
| # ============================================================ | |
| # REACTIVE: Active IF data based on year selection | |
| # ============================================================ | |
| if_data <- reactive({ | |
| req(authed()) | |
| req(input$if_year_select) | |
| yrs <- input$if_year_select | |
| if (all(c("2025","2026") %in% yrs)) { | |
| bind_rows(IF_OAA_25, IF_OAA_26) | |
| } else if ("2025" %in% yrs) { | |
| IF_OAA_25 | |
| } else { | |
| IF_OAA_26 | |
| } | |
| }) | |
| # ============================================================ | |
| # REACTIVE: Filtered catcher data | |
| # ============================================================ | |
| ct_filtered <- reactive({ | |
| req(authed(), input$ct_catcher) | |
| d <- Catcher2026 %>% filter(Catcher == input$ct_catcher) | |
| if (input$ct_batter_hand != "All") d <- d %>% filter(BatterSide == input$ct_batter_hand) | |
| if (input$ct_pitcher_hand != "All") d <- d %>% filter(PitcherThrows == input$ct_pitcher_hand) | |
| if (input$ct_pitch_type != "All") d <- d %>% filter(TaggedPitchType == input$ct_pitch_type) | |
| if (input$ct_count != "All") { | |
| cnt <- input$ct_count | |
| b <- as.numeric(substr(cnt,1,1)) | |
| s <- as.numeric(substr(cnt,3,3)) | |
| d <- d %>% filter(Balls == b, Strikes == s) | |
| } | |
| d | |
| }) | |
| # ============================================================ | |
| # POPULATE CATCHER DROPDOWNS | |
| # ============================================================ | |
| observeEvent(authed(), { | |
| req(isTRUE(authed())) | |
| catchers <- sort(unique(Catcher2026$Catcher[!is.na(Catcher2026$Catcher) & Catcher2026$Catcher != ""])) | |
| updateSelectInput(session, "ct_catcher", choices = catchers, | |
| selected = if (length(catchers) > 0) catchers[1] else NULL) | |
| ptypes <- sort(unique(Catcher2026$TaggedPitchType[!is.na(Catcher2026$TaggedPitchType)])) | |
| updateSelectInput(session, "ct_pitch_type", choices = c("All", ptypes)) | |
| counts <- sort(unique(paste0(Catcher2026$Balls, "-", Catcher2026$Strikes))) | |
| updateSelectInput(session, "ct_count", choices = c("All", counts)) | |
| }, ignoreInit = TRUE) | |
| # ============================================================ | |
| # POPULATE OF PLAYER DROPDOWN | |
| # ============================================================ | |
| observeEvent(list(authed(), input$of_year_select, input$of_position), { | |
| req(isTRUE(authed()), input$of_year_select, input$of_position) | |
| d <- of_data() | |
| pos <- input$of_position | |
| if (pos %in% c("LF","CF","RF")) { | |
| players <- sort(unique(d$obs_player[d$hit_location == pos])) | |
| } else { | |
| players <- sort(unique(d$obs_player)) | |
| } | |
| updateSelectInput(session, "of_player", choices = players, | |
| selected = if (length(players) > 0) players[1] else NULL) | |
| }, ignoreInit = TRUE) | |
| # ============================================================ | |
| # POPULATE IF PLAYER DROPDOWN | |
| # ============================================================ | |
| observeEvent(list(authed(), input$if_year_select, input$if_position), { | |
| req(isTRUE(authed()), input$if_year_select, input$if_position) | |
| d <- if_data() | |
| pos <- input$if_position | |
| if (pos %in% c("SS","2B","3B","1B")) { | |
| players <- sort(unique(d$obs_player_name[d$obs_player == pos])) | |
| } else { | |
| players <- sort(unique(d$obs_player_name)) | |
| } | |
| updateSelectInput(session, "if_player", choices = players, | |
| selected = if (length(players) > 0) players[1] else NULL) | |
| }, ignoreInit = TRUE) | |
| # ============================================================ | |
| # POPULATE DEFENSIVE POSITIONING DROPDOWNS | |
| # ============================================================ | |
| observeEvent(authed(), { | |
| req(isTRUE(authed())) | |
| teams <- sort(unique(def_pos_data$PitcherTeam)) | |
| updateSelectInput(session, "dp_team", choices = teams, | |
| selected = if ("CCU_CHN" %in% teams) "CCU_CHN" else teams[1]) | |
| }, ignoreInit = TRUE) | |
| observeEvent(list(authed(), input$dp_team), { | |
| req(isTRUE(authed()), input$dp_team) | |
| td <- def_pos_data %>% filter(PitcherTeam == input$dp_team) | |
| pitchers <- sort(unique(td$Pitcher)) | |
| batters <- sort(unique(td$Batter)) | |
| opponents <- sort(unique(td$BatterTeam)) | |
| counts <- sort(unique(td$pitch_count)) | |
| updateSelectInput(session, "dp_pitcher", choices = c("No Filter", pitchers)) | |
| updateSelectInput(session, "dp_hitter", choices = c("No Filter", batters)) | |
| updateSelectInput(session, "dp_opponent", choices = c("No Filter", opponents)) | |
| updateSelectInput(session, "dp_count", choices = c("No Filter", counts)) | |
| }, ignoreInit = TRUE) | |
| # ============================================================ | |
| # OUTFIELD OAA OUTPUTS | |
| # ============================================================ | |
| output$of_star_graph <- renderPlotly({ | |
| req(input$of_player, input$of_position) | |
| d <- of_data() | |
| pos <- ifelse(input$of_position == "ALL", "ALL", input$of_position) | |
| star_graph(input$of_player, pos, d) | |
| }) | |
| of_field_result <- reactive({ | |
| req(input$of_player, input$of_position) | |
| d <- of_data() | |
| pos <- ifelse(input$of_position == "ALL", "ALL", input$of_position) | |
| field_graph(input$of_player, pos, d) | |
| }) | |
| output$of_field_graph_plot <- renderPlotly({ | |
| of_field_result()[[1]] | |
| }) | |
| output$of_field_graph_table <- render_gt({ | |
| of_field_result()[[2]] | |
| }) | |
| of_direction_result <- reactive({ | |
| req(input$of_player, input$of_position) | |
| d <- of_data() | |
| pos <- ifelse(input$of_position == "ALL", "ALL", input$of_position) | |
| direction_oaa(input$of_player, pos, d) | |
| }) | |
| output$of_direction_radar <- renderPlot({ | |
| res <- of_direction_result() | |
| par(mar = c(1, 2, 4, 2)) | |
| radarchart(res$player_matrix, axistype = 1, pcol = "#0d56ab", pfcol = "#0d56ab33", | |
| plwd = 3, plty = 1, cglcol = "grey75", cglty = 1, cglwd = 0.8, | |
| caxislabels = c(" "," "," "), calcex = 0.7, vlcex = 0.75, seg = 2, | |
| vlabels = res$axis_labels) | |
| text(-.05, 0.2, "-5 or Worse", cex = 0.7) | |
| text(-.1, 0.55, "0", cex = 0.7) | |
| text(-.2, .95, "+5 or Better", cex = 0.7) | |
| title(paste0(res$player, " - ", res$position, " OAA by Direction")) | |
| }) | |
| output$of_direction_oaa_table <- render_gt({ | |
| of_direction_result()$table | |
| }) | |
| # ============================================================ | |
| # INFIELD OAA OUTPUTS | |
| # ============================================================ | |
| output$if_star_graph <- renderPlotly({ | |
| req(input$if_player, input$if_position) | |
| d <- if_data() | |
| pos <- ifelse(input$if_position == "ALL", "ALL", input$if_position) | |
| infield_star_graph(input$if_player, pos, d) | |
| }) | |
| if_field_result <- reactive({ | |
| req(input$if_player, input$if_position) | |
| d <- if_data() | |
| pos <- ifelse(input$if_position == "ALL", "ALL", input$if_position) | |
| infield_field_graph(input$if_player, pos, d) | |
| }) | |
| output$if_field_graph_plot <- renderPlotly({ | |
| if_field_result()[[1]] | |
| }) | |
| output$if_field_graph_table <- render_gt({ | |
| if_field_result()[[2]] | |
| }) | |
| # ============================================================ | |
| # DEFENSIVE POSITIONING OUTPUT | |
| # ============================================================ | |
| output$dp_heatmap <- renderPlot({ | |
| req(input$dp_team) | |
| infield_positioning_heatmap( | |
| team = input$dp_team, | |
| man_on_first = input$dp_man_on_first, | |
| man_on_second = input$dp_man_on_second, | |
| man_on_third = input$dp_man_on_third, | |
| BatterHand = input$dp_batter_hand, | |
| PitcherHand = input$dp_pitcher_hand, | |
| scorediff = input$dp_scorediff, | |
| Date1 = as.character(input$dp_date_from), | |
| Date2 = as.character(input$dp_date_to), | |
| obs_pitcher = input$dp_pitcher, | |
| Hitter = input$dp_hitter, | |
| Count = input$dp_count, | |
| obs_outs = input$dp_outs, | |
| Opponent = input$dp_opponent) | |
| }) | |
| # ============================================================ | |
| # OAA LEADERBOARD | |
| # ============================================================ | |
| lb_data <- reactive({ | |
| req(isTRUE(authed()), input$lb_year_select, input$lb_position_select) | |
| yrs <- input$lb_year_select | |
| pos <- input$lb_position_select | |
| of_positions <- intersect(pos, c("LF","CF","RF")) | |
| if_positions <- intersect(pos, c("SS","2B","3B","1B")) | |
| of_combined <- NULL | |
| if (length(of_positions) > 0) { | |
| of_pool <- if (all(c("2025","2026") %in% yrs)) { | |
| bind_rows(OF_OAA_25, OF_OAA_26) | |
| } else if ("2025" %in% yrs) { OF_OAA_25 } else { OF_OAA_26 } | |
| of_combined <- of_pool %>% | |
| filter(hit_location %in% of_positions) %>% | |
| mutate(year = format(as.Date(Date), "%Y")) %>% | |
| filter(year %in% yrs) %>% | |
| group_by(Player = obs_player, Position = hit_location) %>% | |
| summarize(OAA = round(sum(OAA, na.rm = TRUE), 2), | |
| Plays = n(), .groups = "drop") | |
| } | |
| if_combined <- NULL | |
| if (length(if_positions) > 0) { | |
| if_pool <- if (all(c("2025","2026") %in% yrs)) { | |
| bind_rows(IF_OAA_25, IF_OAA_26) | |
| } else if ("2025" %in% yrs) { IF_OAA_25 } else { IF_OAA_26 } | |
| if_combined <- if_pool %>% | |
| filter(obs_player %in% if_positions) %>% | |
| mutate(year = format(as.Date(Date), "%Y")) %>% | |
| filter(year %in% yrs) %>% | |
| group_by(Player = obs_player_name, Position = obs_player) %>% | |
| summarize(OAA = round(sum(OAA, na.rm = TRUE), 2), | |
| Plays = n(), .groups = "drop") | |
| } | |
| bind_rows(of_combined, if_combined) %>% | |
| arrange(desc(OAA)) | |
| }) | |
| output$lb_table <- render_gt({ | |
| req(nrow(lb_data()) > 0) | |
| lb_data() %>% | |
| mutate(Rank = row_number()) %>% | |
| dplyr::select(Rank, Player, Position, OAA, Plays) %>% | |
| gt() %>% | |
| gt_theme_guardian() %>% | |
| tab_header(title = "OAA Leaderboard") %>% | |
| cols_align(align = "center", columns = everything()) %>% | |
| cols_align(align = "left", columns = Player) %>% | |
| fmt_number(columns = OAA, decimals = 2) %>% | |
| tab_options( | |
| heading.background.color = "darkcyan", | |
| column_labels.background.color = "darkcyan", | |
| table.border.top.color = "peru", | |
| table.border.bottom.color = "peru") %>% | |
| tab_style(style = cell_text(color = "white"), | |
| locations = cells_title(groups = "title")) | |
| }) | |
| # ============================================================ | |
| # BASERUNNING TAB | |
| # ============================================================ | |
| observeEvent(authed(), { | |
| req(isTRUE(authed())) | |
| updateSelectInput(session, "br_player", | |
| choices = sort(baserunning$Player), | |
| selected = baserunning$Player[1]) | |
| }, ignoreInit = TRUE) | |
| output$br_leaderboard <- render_gt({ | |
| req(isTRUE(authed()), input$br_metric) | |
| baserunning %>% | |
| arrange(desc(.data[[input$br_metric]])) %>% | |
| mutate(Rank = row_number()) %>% | |
| dplyr::select(Rank, Player, | |
| `Max Speed (MPH)` = MaxSpeed, | |
| `Avg Speed (MPH)` = AvgSpeed, | |
| `Hustle %` = HustlePct, | |
| `75% Threshold` = Speed75, | |
| `80% Threshold` = Speed80, | |
| `90% Threshold` = Speed90, | |
| `No HR/Flyout %` = NoHR_Flyout) %>% | |
| gt() %>% | |
| gt_theme_guardian() %>% | |
| tab_header(title = "Baserunning Hustle Leaderboard") %>% | |
| fmt_number(columns = c(`Max Speed (MPH)`, `Avg Speed (MPH)`, | |
| `75% Threshold`, `80% Threshold`, `90% Threshold`), | |
| decimals = 2) %>% | |
| fmt_number(columns = c(`Hustle %`, `No HR/Flyout %`), decimals = 2) %>% | |
| cols_align(align = "center", columns = everything()) %>% | |
| cols_align(align = "left", columns = Player) %>% | |
| data_color(columns = `Hustle %`, | |
| palette = c("white", "#008b8b"), domain = c(80, 95)) %>% | |
| data_color(columns = `Max Speed (MPH)`, | |
| palette = c("white", "#cd853f"), domain = c(18, 21)) %>% | |
| data_color(columns = `No HR/Flyout %`, | |
| palette = c("white", "#008b8b"), domain = c(85, 100)) %>% | |
| data_color(columns = `Avg Speed (MPH)`, | |
| palette = c("white", "#20b2aa"), domain = c(15, 19)) %>% | |
| tab_options( | |
| heading.background.color = "darkcyan", | |
| column_labels.background.color = "darkcyan", | |
| table.border.top.color = "peru", | |
| table.border.bottom.color = "peru") %>% | |
| tab_style(style = cell_text(color = "white"), | |
| locations = cells_title(groups = "title")) | |
| }) | |
| output$br_speed_chart <- renderPlotly({ | |
| req(isTRUE(authed())) | |
| d <- baserunning %>% | |
| arrange(desc(MaxSpeed)) %>% | |
| mutate(Player = factor(Player, levels = rev(Player))) %>% | |
| pivot_longer(cols = c(MaxSpeed, AvgSpeed), | |
| names_to = "Type", values_to = "Speed") %>% | |
| mutate(Type = ifelse(Type == "MaxSpeed", | |
| "Max Sprint Speed", "Avg Sprint Speed")) | |
| p <- ggplot(d, aes(x = Player, y = Speed, fill = Type)) + | |
| geom_col(position = position_dodge(width = 0.7), | |
| width = 0.6, color = "black", size = 0.3) + | |
| scale_fill_manual(values = c("Max Sprint Speed" = "darkcyan", | |
| "Avg Sprint Speed" = "peru")) + | |
| coord_flip() + | |
| labs(x = NULL, y = "Speed (MPH)", fill = NULL) + | |
| theme_minimal() + | |
| theme(legend.position = "top", | |
| panel.grid.major.y = element_blank(), | |
| plot.background = element_rect(fill = "transparent", color = NA)) | |
| ggplotly(p, tooltip = c("y", "fill")) %>% | |
| layout(legend = list(orientation = "h", x = 0.15, y = 1.08)) | |
| }) | |
| output$br_hustle_chart <- renderPlotly({ | |
| req(isTRUE(authed())) | |
| d <- baserunning %>% | |
| arrange(HustlePct) %>% | |
| mutate(Player = factor(Player, levels = Player)) | |
| p <- ggplot(d, aes(x = Player, y = HustlePct)) + | |
| geom_segment(aes(xend = Player, y = 75, yend = HustlePct), | |
| color = "darkcyan", size = 1.2) + | |
| geom_point(size = 4, color = "peru") + | |
| geom_text(aes(label = paste0(round(HustlePct, 1), "%")), | |
| hjust = -0.3, size = 3, color = "darkcyan", fontface = "bold") + | |
| coord_flip() + | |
| scale_y_continuous(limits = c(75, 100), | |
| labels = function(x) paste0(x, "%")) + | |
| labs(x = NULL, y = "Hustle %") + | |
| theme_minimal() + | |
| theme(panel.grid.major.y = element_blank(), | |
| plot.background = element_rect(fill = "transparent", color = NA)) | |
| ggplotly(p, tooltip = c("y")) %>% | |
| layout(showlegend = FALSE) | |
| }) | |
| output$br_radar <- renderPlot({ | |
| req(isTRUE(authed()), input$br_player) | |
| pct_data <- baserunning %>% | |
| mutate(across(c(MaxSpeed, AvgSpeed, HustlePct, Speed75, | |
| Speed80, Speed90, NoHR_Flyout), | |
| ~ percent_rank(.) * 100, | |
| .names = "pct_{.col}")) | |
| player_row <- pct_data %>% filter(Player == input$br_player) | |
| if (nrow(player_row) == 0) return(NULL) | |
| radar_cols <- c("pct_MaxSpeed", "pct_AvgSpeed", "pct_HustlePct", | |
| "pct_Speed75", "pct_Speed80", "pct_Speed90", "pct_NoHR_Flyout") | |
| labels <- c("Max Speed", "Avg Speed", "Hustle %", | |
| "75% Threshold", "80% Threshold", "90% Threshold", "No HR/Flyout %") | |
| radar_matrix <- rbind( | |
| max = rep(100, length(radar_cols)), | |
| min = rep(0, length(radar_cols)), | |
| player_row[, radar_cols] | |
| ) | |
| colnames(radar_matrix) <- labels | |
| par(mar = c(1, 2, 3, 2)) | |
| radarchart(as.data.frame(radar_matrix), | |
| axistype = 1, | |
| pcol = "darkcyan", | |
| pfcol = scales::alpha("darkcyan", 0.3), | |
| plwd = 3, plty = 1, | |
| cglcol = "grey75", cglty = 1, cglwd = 0.8, | |
| vlcex = 0.85, seg = 4, | |
| caxislabels = c("0th", "25th", "50th", "75th", "100th")) | |
| title(paste0(input$br_player, " \u2014 Speed & Hustle Profile"), | |
| cex.main = 1.3, col.main = "darkcyan") | |
| }) | |
| output$br_threshold_chart <- renderPlotly({ | |
| req(isTRUE(authed()), input$br_player) | |
| d <- baserunning %>% | |
| filter(Player == input$br_player) %>% | |
| pivot_longer(cols = c(Speed75, Speed80, Speed90), | |
| names_to = "Threshold", values_to = "Speed") %>% | |
| mutate(Threshold = case_when( | |
| Threshold == "Speed75" ~ "75% Hustle", | |
| Threshold == "Speed80" ~ "80% Hustle", | |
| Threshold == "Speed90" ~ "90% Hustle"), | |
| Threshold = factor(Threshold, levels = c("75% Hustle", "80% Hustle", "90% Hustle"))) | |
| team_avg <- baserunning %>% | |
| summarize(Speed75 = mean(Speed75, na.rm = TRUE), | |
| Speed80 = mean(Speed80, na.rm = TRUE), | |
| Speed90 = mean(Speed90, na.rm = TRUE)) %>% | |
| pivot_longer(cols = everything(), | |
| names_to = "Threshold", values_to = "TeamAvg") %>% | |
| mutate(Threshold = case_when( | |
| Threshold == "Speed75" ~ "75% Hustle", | |
| Threshold == "Speed80" ~ "80% Hustle", | |
| Threshold == "Speed90" ~ "90% Hustle"), | |
| Threshold = factor(Threshold, levels = c("75% Hustle", "80% Hustle", "90% Hustle"))) | |
| d <- left_join(d, team_avg, by = "Threshold") | |
| p <- ggplot(d, aes(x = Threshold)) + | |
| geom_col(aes(y = Speed, fill = "Player"), | |
| width = 0.5, color = "black", size = 0.3) + | |
| geom_point(aes(y = TeamAvg, color = "Team Avg"), | |
| size = 5, shape = 18) + | |
| geom_text(aes(y = Speed, label = paste0(round(Speed, 2), " MPH")), | |
| vjust = -0.5, size = 3.5, fontface = "bold", color = "darkcyan") + | |
| scale_fill_manual(values = c("Player" = "darkcyan"), name = NULL) + | |
| scale_color_manual(values = c("Team Avg" = "peru"), name = NULL) + | |
| scale_y_continuous(limits = c(0, max(baserunning$Speed90, na.rm = TRUE) + 2), | |
| labels = function(x) paste0(x, " MPH")) + | |
| labs(x = NULL, y = "Speed (MPH)", | |
| title = paste0(input$br_player, " vs. Team Average")) + | |
| theme_minimal() + | |
| theme(legend.position = "top", | |
| plot.title = element_text(hjust = 0.5, color = "darkcyan", face = "bold"), | |
| panel.grid.major.x = element_blank(), | |
| plot.background = element_rect(fill = "transparent", color = NA)) | |
| ggplotly(p, tooltip = c("y")) %>% | |
| layout(legend = list(orientation = "h", x = 0.25, y = 1.08)) | |
| }) | |
| # ============================================================ | |
| # CATCHING TAB — RECEIVING | |
| # ============================================================ | |
| recv_data <- reactive({ | |
| d <- ct_filtered() | |
| d <- d %>% mutate( | |
| PlateLocSide = as.numeric(PlateLocSide) / 12, | |
| PlateLocHeight = as.numeric(PlateLocHeight) / 12 | |
| ) | |
| d | |
| }) | |
| output$recv_header_stats <- renderUI({ | |
| d <- recv_data() | |
| sa <- d %>% filter(frame == "Strike Added") | |
| sl <- d %>% filter(frame == "Strike Lost") | |
| n_sa <- nrow(sa) | |
| n_sl <- nrow(sl) | |
| net <- n_sa - n_sl | |
| rv_sa <- round(sum(sa$mean_DRE, na.rm = TRUE), 2) | |
| rv_sl <- round(sum(sl$mean_DRE, na.rm = TRUE), 2) | |
| rv_net <- round(rv_sa + rv_sl, 2) | |
| net_class <- if (net > 0) "" else if (net < 0) " stat-box-negative" else " stat-box-neutral" | |
| rv_class <- if (rv_net < 0) "" else if (rv_net > 0) " stat-box-negative" else " stat-box-neutral" | |
| n_pitches <- nrow(d) | |
| rv_per100 <- if (n_pitches > 0) round(rv_net / n_pitches * 100, 2) else 0 | |
| div(style = "text-align:center;margin-bottom:10px;", | |
| div(class = "stat-box", div(class = "stat-value", n_sa), div(class = "stat-label", "Strikes Added")), | |
| div(class = "stat-box stat-box-negative", div(class = "stat-value", n_sl), div(class = "stat-label", "Strikes Lost")), | |
| div(class = paste0("stat-box", net_class), div(class = "stat-value", ifelse(net > 0, paste0("+", net), net)), div(class = "stat-label", "Net Strikes")), | |
| div(class = "stat-box", div(class = "stat-value", rv_net), div(class = "stat-label", "Framing RV")), | |
| div(class = "stat-box", div(class = "stat-value", rv_per100), div(class = "stat-label", "RV/100 Pitches")) | |
| ) | |
| }) | |
| # Zone plot geometry | |
| szr <- data.frame(xmin=-.83083, xmax=.83083, ymin=1.5, ymax=3.3775) | |
| bzr <- data.frame(xmin=-.9975, xmax=.9975, ymin=1.3775, ymax=3.5) | |
| hp_recv <- data.frame(x=c(-.60,.60,.60,0,-.60), y=c(.15,.15,.27,.42,.27)) | |
| # Catcher's view: rotate 180 — tip points down, flat edge on top | |
| hp_center_y <- mean(c(.15, .42)) | |
| hp_catcher <- data.frame(x=hp_recv$x * -1, y=2*hp_center_y - hp_recv$y) | |
| # Plotly zone shapes for catch position overhead | |
| plotly_zone_shapes <- function() { | |
| list( | |
| list(type="rect", x0=-.83083, x1=.83083, y0=1.5, y1=3.3775, | |
| line=list(color="black", width=2), fillcolor="rgba(0,0,0,0)", layer="above"), | |
| list(type="rect", x0=-.9975, x1=.9975, y0=1.3775, y1=3.5, | |
| line=list(color="gray", width=1, dash="dot"), fillcolor="rgba(0,0,0,0)", layer="above"), | |
| list(type="path", | |
| path="M -0.60 0.15 L 0.60 0.15 L 0.60 0.27 L 0 0.42 L -0.60 0.27 Z", | |
| line=list(color="gray40", width=1.5), fillcolor="rgba(0,0,0,0)", layer="above") | |
| ) | |
| } | |
| # Core zone plot function (ggplot, NO plotly) | |
| make_zone_plot <- function(data, title, flip=FALSE) { | |
| if (!nrow(data)) return( | |
| ggplot() + theme_void() + ggtitle(title) + | |
| theme(plot.title = element_text(hjust=.5, size=10, face="bold"))) | |
| f <- if (flip) -1 else 1 | |
| data <- data %>% mutate(PlateLocSide = PlateLocSide * f) | |
| szr_f <- szr | |
| bzr_f <- bzr | |
| hp_f <- if (flip) hp_catcher else hp_recv | |
| apt <- unique(data$TaggedPitchType[!is.na(data$TaggedPitchType)]) | |
| ggplot(data, aes(PlateLocSide, PlateLocHeight)) + | |
| geom_rect(data=bzr_f, aes(xmin=xmin,xmax=xmax,ymin=ymin,ymax=ymax), | |
| fill=NA, color="gray50", linewidth=.5, linetype="dotted", inherit.aes=FALSE) + | |
| geom_rect(data=szr_f, aes(xmin=xmin,xmax=xmax,ymin=ymin,ymax=ymax), | |
| fill=NA, color="black", linewidth=.7, inherit.aes=FALSE) + | |
| geom_polygon(data=hp_f, aes(x=x,y=y), | |
| fill=NA, color="gray40", linewidth=.4, inherit.aes=FALSE) + | |
| geom_point(aes(fill=TaggedPitchType), shape=21, size=5, | |
| color="black", stroke=.6, alpha=.95, na.rm=TRUE) + | |
| scale_fill_manual(values=pitch_colors, na.value="grey60", | |
| name="Pitch Type", drop=FALSE, limits=apt) + | |
| coord_equal() + | |
| scale_x_continuous(limits=c(-1.8,1.8)) + | |
| scale_y_continuous(limits=c(0,4.5)) + | |
| labs(title=if(flip) paste(title,"(Catcher's View)") else title) + theme_classic() + | |
| theme(axis.title=element_blank(), axis.text=element_blank(), | |
| axis.ticks=element_blank(), axis.line=element_blank(), | |
| panel.grid=element_blank(), | |
| plot.title=element_text(hjust=.5, size=10, face="bold"), | |
| plot.margin=margin(2,2,2,2), legend.position="none") | |
| } | |
| # Heatmap zone plot function (ggplot, NO plotly) | |
| make_zone_heatmap <- function(data, title, flip=FALSE) { | |
| if (nrow(data) < 3) return( | |
| ggplot() + theme_void() + ggtitle(paste(title, "- Not enough data")) + | |
| theme(plot.title = element_text(hjust=.5, size=10, face="bold"))) | |
| f <- if (flip) -1 else 1 | |
| data <- data %>% mutate(PlateLocSide = PlateLocSide * f) | |
| hp_f <- if (flip) hp_catcher else hp_recv | |
| ggplot(data, aes(x=PlateLocSide, y=PlateLocHeight)) + | |
| stat_density_2d(aes(fill=after_stat(density)), geom="raster", contour=FALSE) + | |
| scale_fill_gradientn(colours=c("white","#0551bc","#02fbff","#03ff00", | |
| "#fbff00","#ffa503","#ff1f02","#dc1100"), | |
| name="Density") + | |
| geom_rect(data=bzr, aes(xmin=xmin,xmax=xmax,ymin=ymin,ymax=ymax), | |
| fill=NA, color="gray50", linewidth=.5, linetype="dotted", inherit.aes=FALSE) + | |
| geom_rect(data=szr, aes(xmin=xmin,xmax=xmax,ymin=ymin,ymax=ymax), | |
| fill=NA, color="black", linewidth=1, inherit.aes=FALSE) + | |
| geom_polygon(data=hp_f, aes(x=x,y=y), | |
| fill=NA, color="black", linewidth=.8, inherit.aes=FALSE) + | |
| coord_fixed(ratio=1) + xlim(-2,2) + ylim(0,4.5) + | |
| ggtitle(if(flip) paste(title,"(Catcher's View)") else title) + theme_void() + | |
| theme(legend.position="right", plot.margin=margin(3,3,3,3), | |
| plot.title=element_text(hjust=0.5, size=12, face="bold")) | |
| } | |
| output$recv_strikes_added <- renderPlot({ | |
| d <- recv_data() %>% filter(frame == "Strike Added") %>% mutate(row_num = row_number()) | |
| vm <- input$recv_view_mode | |
| flip <- isTRUE(input$recv_catchers_view) | |
| f <- if (flip) -1 else 1 | |
| if (vm == "Heatmap") { | |
| make_zone_heatmap(d, "Strikes Added", flip) | |
| } else if (vm == "Catch Position (Overhead)") { | |
| d2 <- d %>% filter(!is.na(CatchPositionX) & !is.na(CatchPositionZ)) %>% | |
| mutate(cpx = as.numeric(CatchPositionX) * f, cpz = as.numeric(CatchPositionZ)) | |
| if (nrow(d2) == 0) return(ggplot() + theme_void() + ggtitle("No catch position data")) | |
| plate_oh <- data.frame(x=c(-.708,.708,.708,0,-.708)*f, y=c(.5,.5,0,-.25,0)) | |
| bb_left <- data.frame(xmin=-4.208*f, xmax=-0.708*f, ymin=-3, ymax=3) | |
| bb_right <- data.frame(xmin=0.708*f, xmax=4.208*f, ymin=-3, ymax=3) | |
| if (flip) { bb_left <- data.frame(xmin=pmin(bb_left$xmin,bb_left$xmax),xmax=pmax(bb_left$xmin,bb_left$xmax),ymin=-3,ymax=3) | |
| bb_right <- data.frame(xmin=pmin(bb_right$xmin,bb_right$xmax),xmax=pmax(bb_right$xmin,bb_right$xmax),ymin=-3,ymax=3) } | |
| apt <- unique(d2$TaggedPitchType[!is.na(d2$TaggedPitchType)]) | |
| ttl <- if(flip) "Strikes Added - Catch Position (Catcher's View)" else "Strikes Added - Catch Position (Overhead)" | |
| ggplot(d2, aes(x=cpx, y=cpz)) + | |
| geom_rect(data=bb_left, aes(xmin=xmin,xmax=xmax,ymin=ymin,ymax=ymax), | |
| fill=NA, color="gray50", linewidth=.6, inherit.aes=FALSE) + | |
| geom_rect(data=bb_right, aes(xmin=xmin,xmax=xmax,ymin=ymin,ymax=ymax), | |
| fill=NA, color="gray50", linewidth=.6, inherit.aes=FALSE) + | |
| geom_polygon(data=plate_oh, aes(x=x,y=y), fill="gray95", color="black", linewidth=.8, inherit.aes=FALSE) + | |
| geom_point(aes(fill=TaggedPitchType), shape=21, size=4, color="black", stroke=.6, alpha=.95) + | |
| scale_fill_manual(values=pitch_colors, na.value="grey60", name="Pitch Type", drop=FALSE, limits=apt) + | |
| coord_fixed() + labs(title=ttl, x="Side (ft)", y="Depth (ft)") + | |
| theme_minimal() + theme(plot.title=element_text(hjust=.5, size=10, face="bold"), | |
| legend.position="bottom") | |
| } else if (vm == "Catch Depth (Side)") { | |
| d2 <- d %>% filter(!is.na(CatchPositionZ) & !is.na(CatchPositionY)) %>% | |
| mutate(cpz = as.numeric(CatchPositionZ), cpy = as.numeric(CatchPositionY)) | |
| if (nrow(d2) == 0) return(ggplot() + theme_void() + ggtitle("No catch position data")) | |
| apt <- unique(d2$TaggedPitchType[!is.na(d2$TaggedPitchType)]) | |
| ggplot(d2, aes(x=cpz, y=cpy)) + | |
| geom_hline(yintercept=c(1.5, 3.3775), color="black", linewidth=.6, linetype="dashed") + | |
| annotate("text", x=min(d2$cpz, na.rm=TRUE)-0.1, y=1.5, label="Zone Bottom", hjust=1, size=2.5, color="gray40") + | |
| annotate("text", x=min(d2$cpz, na.rm=TRUE)-0.1, y=3.3775, label="Zone Top", hjust=1, size=2.5, color="gray40") + | |
| geom_vline(xintercept=0, color="gray50", linewidth=.4, linetype="dotted") + | |
| geom_point(aes(fill=TaggedPitchType), shape=21, size=4, color="black", stroke=.6, alpha=.95) + | |
| scale_fill_manual(values=pitch_colors, na.value="grey60", name="Pitch Type", drop=FALSE, limits=apt) + | |
| labs(title="Strikes Added - Catch Depth (Side)", x="Depth / Z (ft)", y="Height / Y (ft)") + | |
| theme_minimal() + theme(plot.title=element_text(hjust=.5, size=10, face="bold"), | |
| legend.position="bottom") | |
| } else { | |
| make_zone_plot(d, "Strikes Added", flip) | |
| } | |
| }) | |
| output$recv_strikes_lost <- renderPlot({ | |
| d <- recv_data() %>% filter(frame == "Strike Lost") %>% mutate(row_num = row_number()) | |
| vm <- input$recv_view_mode | |
| flip <- isTRUE(input$recv_catchers_view) | |
| f <- if (flip) -1 else 1 | |
| if (vm == "Heatmap") { | |
| make_zone_heatmap(d, "Strikes Lost", flip) | |
| } else if (vm == "Catch Position (Overhead)") { | |
| d2 <- d %>% filter(!is.na(CatchPositionX) & !is.na(CatchPositionZ)) %>% | |
| mutate(cpx = as.numeric(CatchPositionX) * f, cpz = as.numeric(CatchPositionZ)) | |
| if (nrow(d2) == 0) return(ggplot() + theme_void() + ggtitle("No catch position data")) | |
| plate_oh <- data.frame(x=c(-.708,.708,.708,0,-.708)*f, y=c(.5,.5,0,-.25,0)) | |
| bb_left <- data.frame(xmin=-4.208*f, xmax=-0.708*f, ymin=-3, ymax=3) | |
| bb_right <- data.frame(xmin=0.708*f, xmax=4.208*f, ymin=-3, ymax=3) | |
| if (flip) { bb_left <- data.frame(xmin=pmin(bb_left$xmin,bb_left$xmax),xmax=pmax(bb_left$xmin,bb_left$xmax),ymin=-3,ymax=3) | |
| bb_right <- data.frame(xmin=pmin(bb_right$xmin,bb_right$xmax),xmax=pmax(bb_right$xmin,bb_right$xmax),ymin=-3,ymax=3) } | |
| apt <- unique(d2$TaggedPitchType[!is.na(d2$TaggedPitchType)]) | |
| ttl <- if(flip) "Strikes Lost - Catch Position (Catcher's View)" else "Strikes Lost - Catch Position (Overhead)" | |
| ggplot(d2, aes(x=cpx, y=cpz)) + | |
| geom_rect(data=bb_left, aes(xmin=xmin,xmax=xmax,ymin=ymin,ymax=ymax), | |
| fill=NA, color="gray50", linewidth=.6, inherit.aes=FALSE) + | |
| geom_rect(data=bb_right, aes(xmin=xmin,xmax=xmax,ymin=ymin,ymax=ymax), | |
| fill=NA, color="gray50", linewidth=.6, inherit.aes=FALSE) + | |
| geom_polygon(data=plate_oh, aes(x=x,y=y), fill="gray95", color="black", linewidth=.8, inherit.aes=FALSE) + | |
| geom_point(aes(fill=TaggedPitchType), shape=21, size=4, color="black", stroke=.6, alpha=.95) + | |
| scale_fill_manual(values=pitch_colors, na.value="grey60", name="Pitch Type", drop=FALSE, limits=apt) + | |
| coord_fixed() + labs(title=ttl, x="Side (ft)", y="Depth (ft)") + | |
| theme_minimal() + theme(plot.title=element_text(hjust=.5, size=10, face="bold"), | |
| legend.position="bottom") | |
| } else if (vm == "Catch Depth (Side)") { | |
| d2 <- d %>% filter(!is.na(CatchPositionZ) & !is.na(CatchPositionY)) %>% | |
| mutate(cpz = as.numeric(CatchPositionZ), cpy = as.numeric(CatchPositionY)) | |
| if (nrow(d2) == 0) return(ggplot() + theme_void() + ggtitle("No catch position data")) | |
| apt <- unique(d2$TaggedPitchType[!is.na(d2$TaggedPitchType)]) | |
| ggplot(d2, aes(x=cpz, y=cpy)) + | |
| geom_hline(yintercept=c(1.5, 3.3775), color="black", linewidth=.6, linetype="dashed") + | |
| annotate("text", x=min(d2$cpz, na.rm=TRUE)-0.1, y=1.5, label="Zone Bottom", hjust=1, size=2.5, color="gray40") + | |
| annotate("text", x=min(d2$cpz, na.rm=TRUE)-0.1, y=3.3775, label="Zone Top", hjust=1, size=2.5, color="gray40") + | |
| geom_vline(xintercept=0, color="gray50", linewidth=.4, linetype="dotted") + | |
| geom_point(aes(fill=TaggedPitchType), shape=21, size=4, color="black", stroke=.6, alpha=.95) + | |
| scale_fill_manual(values=pitch_colors, na.value="grey60", name="Pitch Type", drop=FALSE, limits=apt) + | |
| labs(title="Strikes Lost - Catch Depth (Side)", x="Depth / Z (ft)", y="Height / Y (ft)") + | |
| theme_minimal() + theme(plot.title=element_text(hjust=.5, size=10, face="bold"), | |
| legend.position="bottom") | |
| } else { | |
| make_zone_plot(d, "Strikes Lost", flip) | |
| } | |
| }) | |
| output$recv_log_table <- renderDT({ | |
| d <- recv_data() %>% | |
| filter(!is.na(frame)) %>% | |
| dplyr::select(Date, Inning, Pitcher, Batter, TaggedPitchType, | |
| PlateLocSide, PlateLocHeight, PitchCall, frame, | |
| RelSpeed, mean_DRE) %>% | |
| mutate(PlateLocSide = round(as.numeric(PlateLocSide), 2), | |
| PlateLocHeight = round(as.numeric(PlateLocHeight), 2), | |
| RelSpeed = round(as.numeric(RelSpeed), 1), | |
| mean_DRE = round(mean_DRE, 3)) %>% | |
| rename(Type = TaggedPitchType, Side = PlateLocSide, Height = PlateLocHeight, | |
| Call = PitchCall, Frame = frame, Velo = RelSpeed, RV = mean_DRE) | |
| datatable(d, options = list(pageLength = 15, scrollX = TRUE), rownames = FALSE) | |
| }) | |
| # ============================================================ | |
| # CATCHING TAB — THROWING | |
| # ============================================================ | |
| throw_data <- reactive({ | |
| d <- ct_filtered() | |
| d <- d %>% mutate( | |
| notes_parts = strsplit(as.character(Notes), "\\|"), | |
| has_throw = sapply(notes_parts, function(parts) any(grepl("2b\\s*(out|safe)|3b\\s*(out|safe)", trimws(parts), ignore.case = TRUE))), | |
| throw_label = sapply(notes_parts, function(parts) { | |
| for (p in trimws(parts)) { | |
| lp <- tolower(p) | |
| if (grepl("2b\\s*out", lp)) return("2B Out") | |
| if (grepl("2b\\s*safe", lp)) return("2B Safe") | |
| if (grepl("3b\\s*out", lp)) return("3B Out") | |
| if (grepl("3b\\s*safe", lp)) return("3B Safe") | |
| } | |
| return(NA_character_) | |
| }) | |
| ) %>% | |
| filter(has_throw == TRUE) %>% | |
| mutate( | |
| throw_result = ifelse(grepl("Out", throw_label), "Out", "Safe"), | |
| throw_base = ifelse(grepl("2B", throw_label), "2B", "3B") | |
| ) %>% | |
| dplyr::select(-notes_parts, -has_throw) | |
| d | |
| }) | |
| output$throw_header_stats <- renderUI({ | |
| td <- throw_data() | |
| if (nrow(td) == 0) return(div(style="text-align:center;color:gray;", "No throwing data available")) | |
| n_total <- nrow(td) | |
| n_out <- sum(td$throw_result == "Out", na.rm = TRUE) | |
| n_safe <- sum(td$throw_result == "Safe", na.rm = TRUE) | |
| cs_pct <- round(100 * n_out / n_total, 1) | |
| avg_ts <- round(mean(td$ThrowSpeed, na.rm = TRUE), 1) | |
| max_ts <- round(max(td$ThrowSpeed, na.rm = TRUE), 1) | |
| avg_pop <- round(mean(td$PopTime, na.rm = TRUE), 3) | |
| avg_exch <- round(mean(td$ExchangeTime, na.rm = TRUE), 3) | |
| avg_ttb <- round(mean(td$TimeToBase, na.rm = TRUE), 3) | |
| rv_total <- round(sum(td$mean_DRE, na.rm = TRUE), 2) | |
| div(style = "text-align:center;margin-bottom:10px;", | |
| div(class = "stat-box", div(class = "stat-value", paste0(cs_pct, "%")), div(class = "stat-label", paste0("CS% (", n_out, "/", n_total, ")"))), | |
| div(class = "stat-box", div(class = "stat-value", avg_ts), div(class = "stat-label", "Avg Throw Velo")), | |
| div(class = "stat-box", div(class = "stat-value", max_ts), div(class = "stat-label", "Max Throw Velo")), | |
| div(class = "stat-box", div(class = "stat-value", avg_pop), div(class = "stat-label", "Avg Pop Time")), | |
| div(class = "stat-box", div(class = "stat-value", avg_exch), div(class = "stat-label", "Avg Exchange")), | |
| div(class = "stat-box", div(class = "stat-value", avg_ttb), div(class = "stat-label", "Avg Time to Base")), | |
| div(class = "stat-box", div(class = "stat-value", rv_total), div(class = "stat-label", "Throwing RV")), | |
| div(class = "stat-box", div(class = "stat-value", if (n_total > 0) round(rv_total / n_total * 100, 2) else 0), div(class = "stat-label", "RV/100")), | |
| div(class = "stat-box", div(class = "stat-value", sum(!is.na(td$ThrowPositionX))), div(class = "stat-label", "Tracked Throws")), | |
| div(class = "stat-box", div(class = "stat-value", round(mean(as.numeric(td$ThrowPositionY), na.rm=TRUE), 2)), div(class = "stat-label", "Avg Release Height")), | |
| div(class = "stat-box", div(class = "stat-value", round(mean(as.numeric(td$ThrowPositionX), na.rm=TRUE), 2)), div(class = "stat-label", "Avg Release Side")) | |
| ) | |
| }) | |
| make_throw_base_plot <- function(data, title_text, view_mode) { | |
| if (nrow(data) == 0) { | |
| return(ggplotly(ggplot() + theme_void() + ggtitle(title_text) + | |
| theme(plot.title = element_text(hjust=.5, size=11, face="bold")))) | |
| } | |
| xmax_d <- max(abs(data$BasePositionZ), 10, na.rm=TRUE) + 2 | |
| ymax_d <- max(data$BasePositionY, 9, na.rm=TRUE) + 1 | |
| ymin_d <- min(data$BasePositionY, -5, na.rm=TRUE) - 1 | |
| grass <- data.frame(x=c(-xmax_d,xmax_d,xmax_d,-xmax_d), y=c(.25,.25,8,8)) | |
| sky <- data.frame(x=c(-xmax_d,xmax_d,xmax_d,-xmax_d), y=c(8,8,ymax_d,ymax_d)) | |
| dirt <- data.frame(x=c(-xmax_d,xmax_d,xmax_d,-xmax_d), y=c(-2,-2,.25,.25)) | |
| ground <- data.frame(x=c(-xmax_d,xmax_d,xmax_d,-xmax_d), y=c(ymin_d,ymin_d,-2,-2)) | |
| base_w <- data.frame(x=c(-1,1,1,-1), y=c(0,0,.45,.45)) | |
| base_b <- data.frame(x=c(-1,0,0,-1), y=c(0,0,.45,.45)) | |
| if (view_mode == "Heatmap") { | |
| data <- data %>% filter(!is.na(BasePositionZ) & !is.na(BasePositionY)) | |
| if (nrow(data) < 3) return(plot_ly() %>% layout(title="Not enough data for heatmap")) | |
| p <- plot_ly(data, x=~BasePositionZ, y=~BasePositionY, type="histogram2dcontour", | |
| colorscale=list(c(0,"rgba(0,0,0,0)"),c(0.25,"#02fbff"),c(0.5,"#03ff00"),c(0.75,"#fbff00"),c(1,"#dc1100")), | |
| showscale=FALSE) %>% | |
| layout(title=list(text=title_text, font=list(size=14)), | |
| xaxis=list(title="", range=c(-xmax_d,xmax_d)), | |
| yaxis=list(title="", range=c(ymin_d,ymax_d), scaleanchor="x"), | |
| shapes=list( | |
| list(type="rect", x0=-xmax_d,x1=xmax_d,y0=0.25,y1=8, fillcolor="rgba(0,139,139,0.15)", line=list(width=0), layer="below"), | |
| list(type="rect", x0=-xmax_d,x1=xmax_d,y0=8,y1=ymax_d, fillcolor="rgba(255,255,0,0.2)", line=list(width=0), layer="below"), | |
| list(type="rect", x0=-xmax_d,x1=xmax_d,y0=-2,y1=0.25, fillcolor="rgba(139,69,19,0.2)", line=list(width=0), layer="below"), | |
| list(type="rect", x0=-1,x1=1,y0=0,y1=0.45, fillcolor="white", line=list(color="black",width=1), layer="above"))) | |
| return(p) | |
| } | |
| if (view_mode == "Color by Throw Speed") { | |
| data <- data %>% filter(!is.na(BasePositionZ) & !is.na(BasePositionY) & !is.na(ThrowSpeed)) | |
| if (nrow(data) == 0) return(ggplotly(ggplot() + theme_void() + ggtitle("No data"))) | |
| p <- ggplot() + | |
| geom_polygon(data=grass, aes(x,y), fill='darkcyan', color='darkcyan') + | |
| geom_polygon(data=sky, aes(x,y), fill='yellow', color='yellow') + | |
| geom_polygon(data=dirt, aes(x,y), fill='brown', color='brown') + | |
| geom_polygon(data=ground, aes(x,y), fill='darkgreen', color='darkgreen') + | |
| geom_polygon(data=base_w, aes(x,y), fill='white', color='black') + | |
| geom_polygon(data=base_b, aes(x,y), fill='lightgrey', color='black') + | |
| geom_point(data=data, aes(x=BasePositionZ, y=BasePositionY, color=ThrowSpeed, | |
| text=paste0("Result: ",throw_label,"<br>Speed: ",round(ThrowSpeed,1)," mph", | |
| "<br>Pop Time: ",round(PopTime,3),"s", | |
| "<br>Exchange: ",round(ExchangeTime,3),"s", | |
| "<br>Pitcher: ",Pitcher,"<br>Date: ",Date)), | |
| size=3.5, alpha=.95) + | |
| scale_color_gradientn(colors=c("#0551bc","#02fbff","#03ff00","#fbff00","#ff1f02"), | |
| name="Throw Speed (mph)") + | |
| scale_x_continuous(limits=c(-xmax_d,xmax_d)) + scale_y_continuous(limits=c(ymin_d,ymax_d)) + | |
| theme_bw() + coord_fixed() + | |
| theme(legend.position="bottom", axis.title=element_blank(), axis.text=element_blank(), | |
| axis.ticks=element_blank(), panel.grid=element_blank(), | |
| plot.title=element_text(size=11,face='bold',hjust=.5)) + | |
| ggtitle(title_text) | |
| return(ggplotly(p, tooltip="text")) | |
| } | |
| if (view_mode == "Release Point (Side)") { | |
| data <- data %>% filter(!is.na(ThrowPositionX) & !is.na(ThrowPositionY)) | |
| if (nrow(data) == 0) return(ggplotly(ggplot() + theme_void() + ggtitle("No release point data"))) | |
| p <- ggplot(data, aes(x=as.numeric(ThrowPositionX), y=as.numeric(ThrowPositionY), | |
| text=paste0("Result: ",throw_label,"<br>Speed: ",round(ThrowSpeed,1)," mph", | |
| "<br>Pop: ",round(PopTime,3),"s", | |
| "<br>Date: ",Date))) + | |
| geom_point(aes(color=throw_label), size=4, alpha=.85) + | |
| scale_color_manual(values=throw_color_map, name="Result") + | |
| labs(title=paste(title_text, "- Release Point (Side)"), x="X (ft)", y="Y (ft)") + | |
| theme_minimal() + coord_equal() + | |
| theme(plot.title=element_text(hjust=.5,size=11,face='bold'), legend.position="bottom") | |
| return(ggplotly(p, tooltip="text")) | |
| } | |
| if (view_mode == "Release Point (Overhead)") { | |
| data <- data %>% filter(!is.na(ThrowPositionX) & !is.na(ThrowPositionZ)) | |
| if (nrow(data) == 0) return(ggplotly(ggplot() + theme_void() + ggtitle("No release point data"))) | |
| p <- ggplot(data, aes(x=as.numeric(ThrowPositionX), y=as.numeric(ThrowPositionZ), | |
| text=paste0("Result: ",throw_label,"<br>Speed: ",round(ThrowSpeed,1)," mph", | |
| "<br>Date: ",Date))) + | |
| geom_point(aes(color=throw_label), size=4, alpha=.85) + | |
| scale_color_manual(values=throw_color_map, name="Result") + | |
| labs(title=paste(title_text, "- Release Point (Overhead)"), x="X (ft)", y="Z (ft)") + | |
| theme_minimal() + coord_equal() + | |
| theme(plot.title=element_text(hjust=.5,size=11,face='bold'), legend.position="bottom") | |
| return(ggplotly(p, tooltip="text")) | |
| } | |
| # Default: Points by Result | |
| p <- ggplot() + | |
| geom_polygon(data=grass, aes(x,y), fill='darkcyan', color='darkcyan') + | |
| geom_polygon(data=sky, aes(x,y), fill='yellow', color='yellow') + | |
| geom_polygon(data=dirt, aes(x,y), fill='brown', color='brown') + | |
| geom_polygon(data=ground, aes(x,y), fill='darkgreen', color='darkgreen') + | |
| geom_polygon(data=base_w, aes(x,y), fill='white', color='black') + | |
| geom_polygon(data=base_b, aes(x,y), fill='lightgrey', color='black') + | |
| geom_point(data=data, aes(x=BasePositionZ, y=BasePositionY, color=throw_label, | |
| text=paste0("Result: ",throw_label,"<br>Speed: ",round(ThrowSpeed,1)," mph", | |
| "<br>Pop: ",round(PopTime,3),"s", | |
| "<br>Exchange: ",round(ExchangeTime,3),"s", | |
| "<br>TTB: ",round(TimeToBase,3),"s", | |
| "<br>Pitcher: ",Pitcher,"<br>Date: ",Date)), | |
| size=4, alpha=.95) + | |
| scale_color_manual(values=throw_color_map) + | |
| scale_x_continuous(limits=c(-xmax_d,xmax_d)) + scale_y_continuous(limits=c(ymin_d,ymax_d)) + | |
| theme_bw() + coord_fixed() + | |
| theme(legend.position="bottom", axis.title=element_blank(), axis.text=element_blank(), | |
| axis.ticks=element_blank(), panel.grid=element_blank(), | |
| plot.title=element_text(size=11,face='bold',hjust=.5)) + | |
| ggtitle(title_text) | |
| ggplotly(p, tooltip="text") | |
| } | |
| output$throw_outs_plot <- renderPlotly({ | |
| td <- throw_data() %>% filter(throw_result == "Out") | |
| make_throw_base_plot(td, paste0(input$ct_catcher, " - Outs"), input$throw_view_mode) | |
| }) | |
| output$throw_safe_plot <- renderPlotly({ | |
| td <- throw_data() %>% filter(throw_result == "Safe") | |
| make_throw_base_plot(td, paste0(input$ct_catcher, " - Safe"), input$throw_view_mode) | |
| }) | |
| output$throw_release_plot <- renderPlotly({ | |
| td <- throw_data() %>% | |
| filter(!is.na(ThrowPositionX) & !is.na(ThrowPositionY)) | |
| if (nrow(td) == 0) { | |
| return(plot_ly() %>% layout(title="No release point data", | |
| xaxis=list(visible=FALSE), yaxis=list(visible=FALSE))) | |
| } | |
| td <- td %>% mutate(tpx = as.numeric(ThrowPositionX), tpy = as.numeric(ThrowPositionY)) | |
| color_by <- input$throw_release_color | |
| if (color_by == "Throw Speed") { | |
| p <- plot_ly(td, x=~tpx, y=~tpy, type="scatter", mode="markers", | |
| marker=list(size=12, color=~ThrowSpeed, | |
| colorscale=list(c(0,"#0551bc"),c(0.25,"#02fbff"),c(0.5,"#03ff00"),c(0.75,"#fbff00"),c(1,"#ff1f02")), | |
| colorbar=list(title="Velo (mph)"), | |
| line=list(color="black", width=1)), | |
| text=~paste0("Result: ",throw_label,"<br>Speed: ",round(ThrowSpeed,1)," mph", | |
| "<br>Pop: ",round(PopTime,3),"s","<br>Exchange: ",round(ExchangeTime,3),"s", | |
| "<br>Date: ",Date), | |
| hoverinfo="text") | |
| } else { | |
| td$pt_color <- sapply(td$throw_label, function(tl) { | |
| if (!is.na(tl) && tl %in% names(throw_color_map)) throw_color_map[tl] else "gray" | |
| }) | |
| p <- plot_ly(td, x=~tpx, y=~tpy, type="scatter", mode="markers", | |
| marker=list(size=12, color=~pt_color, line=list(color="black", width=1)), | |
| text=~paste0("Result: ",throw_label,"<br>Speed: ",round(ThrowSpeed,1)," mph", | |
| "<br>Pop: ",round(PopTime,3),"s","<br>Exchange: ",round(ExchangeTime,3),"s", | |
| "<br>Date: ",Date), | |
| hoverinfo="text", name=~throw_label) | |
| } | |
| p %>% layout( | |
| title=list(text=paste0(input$ct_catcher," - Catcher Release Point"), font=list(size=15)), | |
| xaxis=list(title="Release X (ft)", range=c(-8,8), zeroline=TRUE, | |
| zerolinecolor="gray80", gridcolor="gray90"), | |
| yaxis=list(title="Release Y (ft)", range=c(-8,8), zeroline=TRUE, | |
| zerolinecolor="gray80", gridcolor="gray90", scaleanchor="x"), | |
| showlegend=(color_by == "Result")) | |
| }) | |
| output$throw_release_side_plot <- renderPlotly({ | |
| td <- throw_data() %>% | |
| filter(!is.na(ThrowPositionY) & !is.na(ThrowPositionZ)) | |
| if (nrow(td) == 0) { | |
| return(plot_ly() %>% layout(title="No release point data", | |
| xaxis=list(visible=FALSE), yaxis=list(visible=FALSE))) | |
| } | |
| td <- td %>% mutate(tpy = as.numeric(ThrowPositionY), tpz = as.numeric(ThrowPositionZ)) | |
| color_by <- input$throw_release_color | |
| if (color_by == "Throw Speed") { | |
| p <- plot_ly(td, x=~tpz, y=~tpy, type="scatter", mode="markers", | |
| marker=list(size=12, color=~ThrowSpeed, | |
| colorscale=list(c(0,"#0551bc"),c(0.25,"#02fbff"),c(0.5,"#03ff00"),c(0.75,"#fbff00"),c(1,"#ff1f02")), | |
| colorbar=list(title="Velo (mph)"), | |
| line=list(color="black", width=1)), | |
| text=~paste0("Result: ",throw_label,"<br>Speed: ",round(ThrowSpeed,1)," mph", | |
| "<br>Pop: ",round(PopTime,3),"s","<br>Date: ",Date), | |
| hoverinfo="text") | |
| } else { | |
| td$pt_color <- sapply(td$throw_label, function(tl) { | |
| if (!is.na(tl) && tl %in% names(throw_color_map)) throw_color_map[tl] else "gray" | |
| }) | |
| p <- plot_ly(td, x=~tpz, y=~tpy, type="scatter", mode="markers", | |
| marker=list(size=12, color=~pt_color, line=list(color="black", width=1)), | |
| text=~paste0("Result: ",throw_label,"<br>Speed: ",round(ThrowSpeed,1)," mph", | |
| "<br>Pop: ",round(PopTime,3),"s","<br>Date: ",Date), | |
| hoverinfo="text", name=~throw_label) | |
| } | |
| p %>% layout( | |
| title=list(text=paste0(input$ct_catcher," - Release Point (Side)"), font=list(size=15)), | |
| xaxis=list(title="Release Z / Side (ft)", range=c(-8,8), zeroline=TRUE, | |
| zerolinecolor="gray80", gridcolor="gray90"), | |
| yaxis=list(title="Release Y / Height (ft)", range=c(-8,8), zeroline=TRUE, | |
| zerolinecolor="gray80", gridcolor="gray90", scaleanchor="x"), | |
| showlegend=(color_by == "Result")) | |
| }) | |
| output$throw_log_table <- renderDT({ | |
| td <- throw_data() %>% | |
| dplyr::select(Date, Inning, Pitcher, Batter, throw_label, ThrowSpeed, PopTime, | |
| ExchangeTime, TimeToBase, mean_DRE) %>% | |
| mutate(ThrowSpeed = round(ThrowSpeed, 1), | |
| PopTime = round(PopTime, 3), | |
| ExchangeTime = round(ExchangeTime, 3), | |
| TimeToBase = round(TimeToBase, 3), | |
| mean_DRE = round(mean_DRE, 3)) %>% | |
| rename(Result = throw_label, Velo = ThrowSpeed, Pop = PopTime, | |
| Exchange = ExchangeTime, TTB = TimeToBase, RV = mean_DRE) | |
| datatable(td, options = list(pageLength = 15, scrollX = TRUE), rownames = FALSE) | |
| }) | |
| # ============================================================ | |
| # CATCHING TAB — BLOCKING | |
| # ============================================================ | |
| block_data <- reactive({ | |
| d <- ct_filtered() | |
| d %>% filter(sapply(strsplit(as.character(Notes), "\\|"), function(parts) { | |
| any(.is_block_event(trimws(parts))) | |
| })) %>% mutate( | |
| block_type = .classify_block_type(Notes), | |
| PlateLocSide = as.numeric(PlateLocSide) / 12, | |
| PlateLocHeight = as.numeric(PlateLocHeight) / 12 | |
| ) | |
| }) | |
| output$block_header_stats <- renderUI({ | |
| bd <- block_data() | |
| if (nrow(bd) == 0) return(div(style="text-align:center;color:gray;", "No blocking data available")) | |
| n_total <- nrow(bd) | |
| n_block <- sum(bd$block_type == "Block", na.rm = TRUE) | |
| n_pbwp <- sum(bd$block_type == "PB/WP", na.rm = TRUE) | |
| block_pct <- round(100 * n_block / n_total, 1) | |
| rv_total <- round(sum(bd$mean_DRE, na.rm = TRUE), 2) | |
| rv_block <- round(sum(bd$mean_DRE[bd$block_type == "Block"], na.rm = TRUE), 2) | |
| rv_pbwp <- round(sum(bd$mean_DRE[bd$block_type == "PB/WP"], na.rm = TRUE), 2) | |
| div(style = "text-align:center;margin-bottom:10px;", | |
| div(class = "stat-box", div(class = "stat-value", n_total), div(class = "stat-label", "Opportunities")), | |
| div(class = "stat-box", div(class = "stat-value", n_block), div(class = "stat-label", "Blocks")), | |
| div(class = "stat-box stat-box-negative", div(class = "stat-value", n_pbwp), div(class = "stat-label", "PB/WP")), | |
| div(class = "stat-box", div(class = "stat-value", paste0(block_pct, "%")), div(class = "stat-label", "Block %")), | |
| div(class = "stat-box", div(class = "stat-value", rv_total), div(class = "stat-label", "Blocking RV")), | |
| div(class = "stat-box", div(class = "stat-value", if (n_total > 0) round(rv_total / n_total * 100, 2) else 0), div(class = "stat-label", "RV/100")) | |
| ) | |
| }) | |
| output$block_ground_plot <- renderPlotly({ | |
| bd <- block_data() | |
| view_mode <- input$block_view_mode | |
| req_cols <- c("x0","vx0","ax0","y0","vy0","ay0","z0","vz0","az0") | |
| bd <- bd %>% filter(!is.na(x0) & !is.na(z0) & !is.na(az0)) | |
| if (!all(req_cols %in% names(bd)) || nrow(bd) == 0) { | |
| return(ggplotly(ggplot() + theme_void() + ggtitle("Ground Intersection") + | |
| theme(plot.title=element_text(hjust=.5,size=11,face="bold")))) | |
| } | |
| bd <- catcher_compute_ground_intersection(bd) | |
| bd <- bd %>% filter(!is.na(x_ground) & !is.na(y_ground)) %>% | |
| mutate(plot_x = x_ground, plot_y = y_ground, row_num = row_number()) | |
| if (nrow(bd) == 0) { | |
| return(ggplotly(ggplot() + theme_void() + ggtitle("No ground intersection data"))) | |
| } | |
| plate <- data.frame(x=c(-.708,.708,.708,0,-.708), y=c(1.417,1.417,.708,0,.708)) | |
| bi <- 1; bo <- 4; bymin <- -2.5; bymax <- 3.7 | |
| if (view_mode == "Heatmap" && nrow(bd) >= 3) { | |
| p <- plot_ly(bd, x=~plot_x, y=~plot_y, type="histogram2dcontour", | |
| colorscale=list(c(0,"white"),c(0.25,"#0551bc"),c(0.5,"#03ff00"),c(0.75,"#fbff00"),c(1,"#dc1100")), | |
| showscale=FALSE) %>% | |
| layout(title=list(text="Blocking (Overhead) - Heatmap", font=list(size=14)), | |
| xaxis=list(title=""), yaxis=list(title="", scaleanchor="x"), | |
| shapes=list( | |
| list(type="rect", x0=bi,x1=bo,y0=bymin,y1=bymax, line=list(color="black",width=2), fillcolor="rgba(0,0,0,0)"), | |
| list(type="rect", x0=-bo,x1=-bi,y0=bymin,y1=bymax, line=list(color="black",width=2), fillcolor="rgba(0,0,0,0)"), | |
| list(type="path", | |
| path="M -0.708 1.417 L 0.708 1.417 L 0.708 0.708 L 0 0 L -0.708 0.708 Z", | |
| line=list(color="black",width=2), fillcolor="rgba(0,0,0,0)"))) | |
| return(p) | |
| } | |
| # Points mode | |
| sl <- unique(bd$block_type) | |
| sv <- c("Block"=21,"PB/WP"=24)[sl] | |
| p <- ggplot(bd, aes(plot_x, plot_y, | |
| text=paste0("#",row_num,"<br>Pitch: ",TaggedPitchType, | |
| "<br>Type: ",block_type, | |
| "<br>Pitcher: ",Pitcher,"<br>Batter: ",Batter, | |
| "<br>Velo: ",round(RelSpeed,1)," mph", | |
| "<br>RV: ",round(mean_DRE,3), | |
| "<br>Date: ",Date))) + | |
| geom_segment(aes(x=bi,xend=bo,y=bymin,yend=bymin), color="black", linewidth=.8, inherit.aes=FALSE) + | |
| geom_segment(aes(x=bi,xend=bo,y=bymax,yend=bymax), color="black", linewidth=.8, inherit.aes=FALSE) + | |
| geom_segment(aes(x=bi,xend=bi,y=bymin,yend=bymax), color="black", linewidth=.8, inherit.aes=FALSE) + | |
| geom_segment(aes(x=bo,xend=bo,y=bymin,yend=bymax), color="black", linewidth=.8, inherit.aes=FALSE) + | |
| geom_segment(aes(x=-bi,xend=-bo,y=bymin,yend=bymin), color="black", linewidth=.8, inherit.aes=FALSE) + | |
| geom_segment(aes(x=-bi,xend=-bo,y=bymax,yend=bymax), color="black", linewidth=.8, inherit.aes=FALSE) + | |
| geom_segment(aes(x=-bi,xend=-bi,y=bymin,yend=bymax), color="black", linewidth=.8, inherit.aes=FALSE) + | |
| geom_segment(aes(x=-bo,xend=-bo,y=bymin,yend=bymax), color="black", linewidth=.8, inherit.aes=FALSE) + | |
| geom_polygon(data=plate, aes(x,y), fill=NA, color="black", linewidth=.8, inherit.aes=FALSE) + | |
| geom_point(aes(fill=TaggedPitchType, shape=block_type), size=4, color="black", stroke=.6) + | |
| scale_fill_manual(values=pitch_colors, na.value="grey60", name="Pitch Type") + | |
| scale_shape_manual(values=sv, name=NULL) + | |
| coord_fixed() + labs(title="Blocking (Overhead View)", x=NULL, y=NULL) + | |
| theme_void(base_size=9) + | |
| theme(plot.title=element_text(size=11,face="bold",hjust=.5), | |
| legend.position="bottom", legend.box="vertical") | |
| ggplotly(p, tooltip="text") | |
| }) | |
| output$block_zone_plot <- renderPlot({ | |
| bd <- block_data() %>% | |
| filter(!is.na(PlateLocSide) & !is.na(PlateLocHeight)) %>% | |
| mutate(row_num = row_number()) | |
| view_mode <- input$block_view_mode | |
| flip <- isTRUE(input$block_catchers_view) | |
| f <- if (flip) -1 else 1 | |
| if (nrow(bd) == 0) return( | |
| ggplot() + theme_void() + ggtitle("Blocking (Zone)") + | |
| theme(plot.title = element_text(hjust=.5, size=10, face="bold"))) | |
| if (view_mode == "Heatmap" && nrow(bd) >= 3) { | |
| return(make_zone_heatmap(bd, "Blocking (Zone) - Heatmap", flip)) | |
| } | |
| bd <- bd %>% mutate(PlateLocSide = PlateLocSide * f) | |
| hp_f <- if (flip) hp_catcher else hp_recv | |
| sl <- unique(bd$block_type) | |
| sv <- c("Block"=21, "PB/WP"=24)[sl] | |
| slb <- c("Block"="Block", "PB/WP"="PB/WP")[sl] | |
| tc <- bd %>% count(TaggedPitchType) %>% mutate(label=paste0(TaggedPitchType,": ",n)) | |
| cl <- setNames(tc$label, tc$TaggedPitchType) | |
| ttl <- if(flip) "Blocking (Zone) - Catcher's View" else "Blocking (Zone)" | |
| ggplot(bd, aes(PlateLocSide, PlateLocHeight)) + | |
| geom_rect(data=bzr, aes(xmin=xmin,xmax=xmax,ymin=ymin,ymax=ymax), | |
| fill=NA, color="gray50", linewidth=.6, linetype="dotted", inherit.aes=FALSE) + | |
| geom_rect(data=szr, aes(xmin=xmin,xmax=xmax,ymin=ymin,ymax=ymax), | |
| fill=NA, color="black", linewidth=.8, inherit.aes=FALSE) + | |
| geom_polygon(data=hp_f, aes(x=x,y=y), | |
| fill=NA, color="gray40", linewidth=.5, inherit.aes=FALSE) + | |
| geom_point(aes(fill=TaggedPitchType, shape=block_type), size=4, color="black", stroke=.6, na.rm=TRUE) + | |
| scale_fill_manual(values=pitch_colors, na.value="grey60", name="Pitch Type", labels=cl) + | |
| scale_shape_manual(values=sv, name=NULL, labels=slb) + | |
| coord_equal(xlim=c(-2.5,2.5), ylim=c(-1,4.5)) + | |
| labs(title=ttl) + theme_void() + | |
| theme(plot.title=element_text(hjust=.5, size=10, face="bold"), | |
| legend.position="bottom", legend.box="vertical", | |
| legend.spacing.y=unit(2,"pt"), | |
| legend.title=element_text(size=7, face="bold"), | |
| legend.text=element_text(size=6), | |
| legend.key.size=unit(.5,"lines")) + | |
| guides(fill=guide_legend(order=1, nrow=2, override.aes=list(size=3, shape=21)), | |
| shape=guide_legend(order=2, nrow=1, override.aes=list(size=3, fill="#4472C4"))) | |
| }) | |
| output$block_log_table <- renderDT({ | |
| bd <- block_data() %>% | |
| dplyr::select(Date, Inning, Pitcher, Batter, TaggedPitchType, | |
| PlateLocSide, PlateLocHeight, block_type, RelSpeed, mean_DRE) %>% | |
| mutate(PlateLocSide = round(as.numeric(PlateLocSide), 2), | |
| PlateLocHeight = round(as.numeric(PlateLocHeight), 2), | |
| RelSpeed = round(as.numeric(RelSpeed), 1), | |
| mean_DRE = round(mean_DRE, 3)) %>% | |
| rename(Type = TaggedPitchType, Side = PlateLocSide, Height = PlateLocHeight, | |
| Result = block_type, Velo = RelSpeed, RV = mean_DRE) | |
| datatable(bd, options = list(pageLength = 15, scrollX = TRUE), rownames = FALSE) | |
| }) | |
| } | |
| shinyApp(ui, server) |