diff --git "a/app.R" "b/app.R"
new file mode 100644--- /dev/null
+++ "b/app.R"
@@ -0,0 +1,2195 @@
+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 <- download_private_parquet("CoastalBaseball/DefenseAppDataset", "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 broader matching for PitchCall values
+Catcher2026 <- Catcher2026 %>%
+ mutate(
+ frame = case_when(
+ is_swing == 1 ~ NA_character_,
+ grepl("ball", PitchCall, ignore.case = TRUE) & in_zone == 1 ~ "Strike Lost",
+ grepl("strike", PitchCall, ignore.case = TRUE) & !grepl("foul|swing", PitchCall, ignore.case = TRUE) & in_zone == 0 ~ "Strike Added",
+ TRUE ~ NA_character_
+ )
+ )
+
+# ============================================================
+# 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")
+
+.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,"
Batter: ",Batter,"
Pitcher: ",Pitcher,
+ "
Inning: ",Inning,"
Opportunity Time: ",round(HangTime,2)," sec",
+ "
Distance Needed: ",round(closest_pos_dist,1)," ft",
+ "
Catch Probability: ",round(100*catch_prob,1),"%
",star_group),
+ hoverinfo = "text") %>%
+ layout(hovermode = "closest",
+ title = list(text = paste0(player," - ",position,
+ "
OAA: ",OAA," | Plays: ",n_plays,
+ " | Expected Catches: ",exp_s," | Actual Catches: ",act_s,""),
+ 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,
+ "
Opportunity Time: ",round(HangTime,2)," sec",
+ "
Pitcher: ",Pitcher,
+ "
Batter: ",Batter,
+ "
Distance Needed: ",round(closest_pos_dist,1)," ft",
+ "
Catch Probability: ",round(100*catch_prob,1),"%
",star_group)),
+ color="black",shape=21,size=2,alpha=.6) +
+ labs(title = paste0(player," Possible Catches - ",position)) +
+ scale_fill_manual(values=c("Hit"="white","Catch"="green4"),name=" ") +
+ geom_point(data=df_f,aes(x=avg_x,y=avg_y,
+ text=paste0("Avg Position","
X: ",round(avg_x,1),"
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,"
Batter: ",Batter,"
Pitcher: ",Pitcher,
+ "
Inning: ",Inning,"
Play Probability: ",round(100*play_prob,1),"%",
+ "
Angular Distance Away: ",round(obs_player_bearing_diff,1),"\u00ba",
+ "
Exit Velocity: ",round(ExitSpeed,1)," mph",
+ "
Distance: ",round(Distance,1)," ft
",star_group),
+ hoverinfo = "text") %>%
+ layout(hovermode = "closest",
+ title = list(text = paste0(player," - ",position,
+ "
OAA: ",OAA," | Plays: ",n_plays,
+ " | Expected Plays Made: ",exp_s,
+ " | Actual Plays Made: ",act_s,""),
+ 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,"
Pitcher: ",Pitcher,"
Batter: ",Batter,
+ "
Play Probability: ",round(100*play_prob,1),"%",
+ "
Exit Velocity: ",round(ExitSpeed,1)," mph",
+ "
Distance: ",round(Distance,1)," ft
",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)"),
+ selected = "Points", width = "100%"))
+ ),
+ fluidRow(
+ column(6,
+ h4("Strikes Added", class = "section-header"),
+ plotlyOutput("recv_strikes_added", height = "480px")),
+ column(6,
+ h4("Strikes Lost", class = "section-header"),
+ plotlyOutput("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(6,
+ selectInput("throw_view_mode", "View Mode:",
+ choices = c("Points (by Result)","Heatmap","Color by Throw Speed",
+ "Release Point (Side)","Release Point (Overhead)"),
+ selected = "Points (by 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 (Side View)", class = "section-header"),
+ plotlyOutput("throw_release_side", height = "420px")),
+ column(6,
+ h4("Release Point (Overhead)", class = "section-header"),
+ plotlyOutput("throw_release_overhead", height = "420px"))
+ ),
+ 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%"))
+ ),
+ 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"),
+ plotlyOutput("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 %>% filter(is_swing == 0)
+ })
+
+ 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"))
+ )
+ })
+
+
+ make_recv_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=12, face="bold"))))
+
+ if (view_mode == "Catch Position (Overhead)") {
+ data <- data %>% filter(!is.na(CatchPositionX) & !is.na(CatchPositionZ))
+ if (nrow(data) == 0) return(ggplotly(ggplot() + theme_void() + ggtitle("No catch position data")))
+ data <- data %>% mutate(row_num = row_number())
+ bb_l <- data.frame(x=c(-3.5,-3.5,-0.708,-0.708), y=c(-4.5,0.15,0.15,-4.5))
+ bb_r <- data.frame(x=c(0.708,0.708,3.5,3.5), y=c(-4.5,0.15,0.15,-4.5))
+ p <- ggplot(data, aes(x = as.numeric(CatchPositionX), y = as.numeric(CatchPositionZ))) +
+ geom_polygon(data=bb_l, aes(x=x,y=y), fill="gray90", color="black", linewidth=.4, inherit.aes=FALSE) +
+ geom_polygon(data=bb_r, aes(x=x,y=y), fill="gray90", color="black", linewidth=.4, inherit.aes=FALSE) +
+ geom_polygon(data=data.frame(x=c(-.708,.708,.708,0,-.708), y=c(.15,.15,.3,.5,.3)),
+ aes(x=x,y=y), fill="gray95", color="black", linewidth=.6, inherit.aes=FALSE) +
+ geom_point(aes(fill=TaggedPitchType, text=paste0("#",row_num,"
Pitch: ",TaggedPitchType,
+ "
Pitcher: ",Pitcher,"
Batter: ",Batter,"
Date: ",Date)),
+ shape=21, size=4, color="black", stroke=.5, alpha=.9) +
+ scale_fill_manual(values=pitch_colors, na.value="grey60", name="Pitch Type") +
+ coord_equal() + xlim(-4, 4) + ylim(-5, 3) +
+ labs(title=paste(title_text, "- Catch Position (Overhead)"), x="X (ft)", y="Z (ft)") +
+ theme_classic() + theme(plot.title=element_text(hjust=.5,size=11,face="bold"), legend.position="bottom")
+ return(ggplotly(p, tooltip="text"))
+ }
+
+ if (view_mode == "Heatmap") {
+ data <- data %>% filter(!is.na(PlateLocSide) & !is.na(PlateLocHeight))
+ if (nrow(data) < 3) return(ggplotly(ggplot() + theme_void() + ggtitle(paste(title_text, "- Not enough data"))))
+ p <- ggplot(data, aes(x=PlateLocSide, y=PlateLocHeight)) +
+ geom_density_2d_filled(alpha=0.7) +
+ scale_fill_viridis_d(option="inferno", name="Density") +
+ sz_segments() +
+ geom_polygon(data=hp_polygon, aes(x=x,y=y), fill=NA, color="white", linewidth=.8, inherit.aes=FALSE) +
+ coord_fixed(ratio=1) + xlim(-2,2) + ylim(0,4.5) + ggtitle(title_text) +
+ theme_void() + theme(legend.position="none", plot.margin=margin(3,3,3,3),
+ plot.title=element_text(hjust=0.5, size=12, face="bold"))
+ return(ggplotly(p))
+ }
+
+ # Default: Points
+ data <- data %>% mutate(row_num = row_number())
+ apt <- unique(data$TaggedPitchType[!is.na(data$TaggedPitchType)])
+ p <- ggplot(data, aes(PlateLocSide, PlateLocHeight)) +
+ bz_segments() + sz_segments() +
+ geom_polygon(data=hp_polygon, aes(x=x,y=y), fill=NA, color="gray40", linewidth=.4, inherit.aes=FALSE) +
+ geom_point(aes(fill=TaggedPitchType, text=paste0("#",row_num,"
Pitch: ",TaggedPitchType,
+ "
Pitcher: ",Pitcher,"
Batter: ",Batter,
+ "
Velo: ",round(RelSpeed,1)," mph","
Count: ",Balls,"-",Strikes,
+ "
RV: ",round(mean_DRE,3),"
Date: ",Date)),
+ shape=21, size=5, color="black", stroke=.6, alpha=.95) +
+ geom_text(aes(label=row_num), size=2.0, fontface="bold", color="white") +
+ 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=title_text) + 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=12,face="bold"), legend.position="bottom",
+ legend.title=element_text(size=8,face="bold"))
+ ggplotly(p, tooltip="text")
+ }
+
+ output$recv_strikes_added <- renderPlotly({
+ d <- recv_data() %>% filter(frame == "Strike Added")
+ make_recv_plot(d, "Strikes Added", input$recv_view_mode)
+ })
+
+ output$recv_strikes_lost <- renderPlotly({
+ d <- recv_data() %>% filter(frame == "Strike Lost")
+ make_recv_plot(d, "Strikes Lost", input$recv_view_mode)
+ })
+
+ 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"))
+ )
+ })
+
+ 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"))))
+ }
+
+ grass <- data.frame(x=c(-10,10,10,-10), y=c(.25,.25,8,8))
+ sky <- data.frame(x=c(-10,10,10,-10), y=c(8,8,9,9))
+ dirt <- data.frame(x=c(-10,10,10,-10), y=c(-2,-2,.25,.25))
+ ground <- data.frame(x=c(-10,10,10,-10), y=c(-5,-5,-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) == 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_density_2d_filled(data=data, aes(x=BasePositionZ,y=BasePositionY), alpha=0.6) +
+ scale_fill_viridis_d(option="inferno", name="Density") +
+ scale_x_continuous(limits=c(-10,10)) + scale_y_continuous(limits=c(-5,9)) +
+ 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))
+ }
+
+ 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,"
Speed: ",round(ThrowSpeed,1)," mph",
+ "
Pop Time: ",round(PopTime,3),"s",
+ "
Exchange: ",round(ExchangeTime,3),"s",
+ "
Pitcher: ",Pitcher,"
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(-10,10)) + scale_y_continuous(limits=c(-5,9)) +
+ 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,"
Speed: ",round(ThrowSpeed,1)," mph",
+ "
Pop: ",round(PopTime,3),"s",
+ "
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,"
Speed: ",round(ThrowSpeed,1)," mph",
+ "
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,"
Speed: ",round(ThrowSpeed,1)," mph",
+ "
Pop: ",round(PopTime,3),"s",
+ "
Exchange: ",round(ExchangeTime,3),"s",
+ "
TTB: ",round(TimeToBase,3),"s",
+ "
Pitcher: ",Pitcher,"
Date: ",Date)),
+ size=4, alpha=.95) +
+ scale_color_manual(values=throw_color_map) +
+ scale_x_continuous(limits=c(-10,10)) + scale_y_continuous(limits=c(-5,9)) +
+ 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_side <- renderPlotly({
+ td <- throw_data() %>%
+ filter(!is.na(ThrowPositionX) & !is.na(ThrowPositionY))
+ if (nrow(td) == 0) return(ggplotly(ggplot() + theme_void() + ggtitle("No release point data") +
+ theme(plot.title=element_text(hjust=.5,size=11,face="bold"))))
+ throw_color_map <- c('2B Out'='#339a1d','2B Safe'='red','3B Out'='#1a5d1a','3B Safe'='#ff6b6b')
+ p <- ggplot(td, aes(x=as.numeric(ThrowPositionX), y=as.numeric(ThrowPositionY),
+ text=paste0("Result: ",throw_label,"
Speed: ",round(ThrowSpeed,1)," mph",
+ "
Pop: ",round(PopTime,3),"s","
Date: ",Date))) +
+ geom_point(aes(color=throw_label), size=4, alpha=.85) +
+ scale_color_manual(values=throw_color_map, name="Result") +
+ labs(title=paste0(input$ct_catcher," - Release Point (Side)"), x="X (ft)", y="Y (ft)") +
+ theme_minimal() +
+ theme(plot.title=element_text(hjust=.5,size=12,face='bold'), legend.position="bottom")
+ ggplotly(p, tooltip="text")
+ })
+
+ output$throw_release_overhead <- renderPlotly({
+ td <- throw_data() %>%
+ filter(!is.na(ThrowPositionX) & !is.na(ThrowPositionZ))
+ if (nrow(td) == 0) return(ggplotly(ggplot() + theme_void() + ggtitle("No release point data") +
+ theme(plot.title=element_text(hjust=.5,size=11,face="bold"))))
+ throw_color_map <- c('2B Out'='#339a1d','2B Safe'='red','3B Out'='#1a5d1a','3B Safe'='#ff6b6b')
+ p <- ggplot(td, aes(x=as.numeric(ThrowPositionX), y=as.numeric(ThrowPositionZ),
+ text=paste0("Result: ",throw_label,"
Speed: ",round(ThrowSpeed,1)," mph",
+ "
Date: ",Date))) +
+ geom_point(aes(color=throw_label), size=4, alpha=.85) +
+ scale_color_manual(values=throw_color_map, name="Result") +
+ labs(title=paste0(input$ct_catcher," - Release Point (Overhead)"), x="X (ft)", y="Z (ft)") +
+ theme_minimal() +
+ theme(plot.title=element_text(hjust=.5,size=12,face='bold'), legend.position="bottom")
+ ggplotly(p, tooltip="text")
+ })
+
+ 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))
+ })
+
+ 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 <- ggplot() +
+ 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) +
+ geom_density_2d_filled(data=bd, aes(x=plot_x, y=plot_y), alpha=0.7) +
+ scale_fill_viridis_d(option="inferno", name="Density") +
+ coord_fixed() + labs(title="Blocking (Overhead) - Heatmap", x=NULL, y=NULL) +
+ theme_void(base_size=9) +
+ theme(plot.title=element_text(size=11,face="bold",hjust=.5), legend.position="bottom")
+ return(ggplotly(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,"
Pitch: ",TaggedPitchType,
+ "
Type: ",block_type,
+ "
Pitcher: ",Pitcher,"
Batter: ",Batter,
+ "
Velo: ",round(RelSpeed,1)," mph",
+ "
RV: ",round(mean_DRE,3),
+ "
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) +
+ geom_text(aes(label=row_num), size=2.0, fontface="bold", color="white") +
+ 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 <- renderPlotly({
+ bd <- block_data() %>%
+ filter(!is.na(PlateLocSide) & !is.na(PlateLocHeight)) %>%
+ mutate(row_num = row_number())
+
+ view_mode <- input$block_view_mode
+
+ if (nrow(bd) == 0) {
+ return(ggplotly(ggplot() + theme_void() + ggtitle("Blocking (Zone)") +
+ theme(plot.title=element_text(hjust=.5,size=11,face="bold"))))
+ }
+
+ if (view_mode == "Heatmap" && nrow(bd) >= 3) {
+ p <- ggplot(bd, aes(x=PlateLocSide, y=PlateLocHeight)) +
+ geom_density_2d_filled(alpha=0.7) +
+ scale_fill_viridis_d(option="inferno", name="Density") +
+ sz_segments() +
+ geom_polygon(data=hp_polygon, aes(x=x,y=y), fill=NA, color="white", linewidth=.8, inherit.aes=FALSE) +
+ coord_fixed(ratio=1) + xlim(-2.5,2.5) + ylim(-1,4.5) +
+ ggtitle("Blocking (Zone) - Heatmap") +
+ theme_void() + theme(legend.position="none", plot.margin=margin(3,3,3,3),
+ plot.title=element_text(hjust=0.5, size=11, face="bold"))
+ return(ggplotly(p))
+ }
+
+ sl <- unique(bd$block_type)
+ sv <- c("Block"=21,"PB/WP"=24)[sl]
+
+ p <- ggplot(bd, aes(PlateLocSide, PlateLocHeight,
+ text=paste0("#",row_num,"
Pitch: ",TaggedPitchType,
+ "
Type: ",block_type,
+ "
Pitcher: ",Pitcher,"
Batter: ",Batter,
+ "
Velo: ",round(RelSpeed,1)," mph",
+ "
RV: ",round(mean_DRE,3),
+ "
Date: ",Date))) +
+ bz_segments() + sz_segments() +
+ geom_polygon(data=hp_polygon, 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) +
+ geom_text(aes(label=row_num), size=2.0, fontface="bold", color="white") +
+ scale_fill_manual(values=pitch_colors, na.value="grey60", name="Pitch Type") +
+ scale_shape_manual(values=sv, name=NULL) +
+ coord_equal(xlim=c(-2.5,2.5), ylim=c(-1,4.5)) +
+ labs(title="Blocking (Zone)") + theme_void() +
+ theme(plot.title=element_text(hjust=.5,size=11,face="bold"),
+ legend.position="bottom", legend.box="vertical")
+ ggplotly(p, tooltip="text")
+ })
+
+ 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)
\ No newline at end of file