Sys.setenv(RETICULATE_PYTHON = "/usr/bin/python3")
library(shiny)
library(DT)
library(dplyr)
library(readxl)
library(scales)
library(readr)
library(tidyverse)
library(bslib)
library(htmltools)
library(shinyWidgets)
library(ggplot2)
library(png)
library(arrow)
library(grid)
library(magick)
library(gridExtra)
library(gt)
library(gtExtras)
library(httr)
library(ggnewscale)
library(fmsb)
library(plotly)
library(tidymodels)
library(xgboost)
library(mgcv)
library(ggradar)
library(cowplot)
library(reticulate)
PASSWORD <- Sys.getenv("password")
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) {
temp_file <- tempfile(fileext = ".parquet")
writeBin(content(response, "raw"), temp_file)
data <- read_parquet(temp_file)
return(data)
} 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)
)
if ("UTCDate" %in% names(data)) data$UTCDate <- as.Date(data$UTCDate)
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_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) {
temp_file <- tempfile(fileext = ".csv")
writeBin(content(response, "raw"), temp_file)
data <- readr::read_csv(temp_file, show_col_types = FALSE)
return(data)
} 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) {
temp_file <- tempfile(fileext = ".rds")
writeBin(content(response, "raw"), temp_file)
data <- readRDS(temp_file)
return(data)
} 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"))
}
OAA_Model <- download_private_rds("CoastalBaseball/HitterAppFiles", "OAA_MODEL.rds")
pitch_colors <- c(
"Fastball" = "#FA8072", "Four-Seam" = "#FA8072", "Sinker" = "#fdae61",
"Slider" = "#A020F0", "Sweeper" = "magenta", "Curveball" = "#2c7bb6",
"ChangeUp" = "#90EE90", "Splitter" = "#90EE32", "Cutter" = "red"
)
# ---------- Processing helpers ----------
process_dataset <- function(df) {
# --- Name normalization ---
if ("Batter" %in% names(df)) {
df <- df %>% mutate(Batter = stringr::str_replace(Batter, "^\\s*(\\w+)\\s*,\\s*(\\w+)\\s*$", "\\2 \\1"))
}
if ("Pitcher" %in% names(df)) {
df <- df %>% mutate(Pitcher = stringr::str_replace(Pitcher, "^\\s*(\\w+)\\s*,\\s*(\\w+)\\s*$", "\\2 \\1"))
}
# --- Dedup ---
df <- df %>% distinct()
if ("PitchUID" %in% names(df)) {
df <- df %>% distinct(PitchUID, .keep_all = TRUE)
} else {
uniq_cols <- intersect(c("GameID","BatterId","PitcherId","Inning","PAofInning","PitchofPA","PitchNo"), names(df))
if (length(uniq_cols) >= 4) df <- df %>% distinct(across(all_of(uniq_cols)), .keep_all = TRUE)
}
# --- Date parsing (handles multiple formats) ---
if ("Date" %in% names(df)) {
df$Date <- suppressWarnings(as.Date(df$Date, format = "%Y-%m-%d"))
if (all(is.na(df$Date))) df$Date <- suppressWarnings(as.Date(df$Date, format = "%m/%d/%Y"))
if (all(is.na(df$Date))) df$Date <- suppressWarnings(as.Date(df$Date, format = "%m/%d/%y"))
if (all(is.na(df$Date))) df$Date <- suppressWarnings(as.Date(df$Date))
}
# --- Normalize pitch type ---
if ("TaggedPitchType" %in% names(df)) {
df$TaggedPitchType <- gsub("^Changeup$", "ChangeUp", df$TaggedPitchType)
}
# --- Ensure numeric PlateLocHeight/PlateLocSide ---
if ("PlateLocSide" %in% names(df)) df$PlateLocSide <- as.numeric(df$PlateLocSide)
if ("PlateLocHeight" %in% names(df)) df$PlateLocHeight <- as.numeric(df$PlateLocHeight)
# ============================================================
# UNIT DETECTION: Normalize PlateLocHeight/PlateLocSide to FEET
# ============================================================
# TrackMan standard: feet (PlateLocHeight ~1.5-4.0, PlateLocSide ~-2.0 to 2.0)
# Some datasets (e.g., CoastalHitters2026.parquet) arrive in inches
# (PlateLocHeight ~18-48, PlateLocSide ~-24 to 24)
#
# Detection: if median |PlateLocHeight| > 10, data is in inches -> convert to feet
# All indicator thresholds below assume FEET.
if ("PlateLocHeight" %in% names(df)) {
plh_median <- median(abs(df$PlateLocHeight), na.rm = TRUE)
if (!is.na(plh_median) && plh_median > 10) {
cat(" [Hitter Unit Detection] PlateLocHeight median abs =", round(plh_median, 1),
"-> already in INCHES, converting to feet\n")
df$PlateLocHeight <- df$PlateLocHeight / 12
df$PlateLocSide <- df$PlateLocSide / 12
} else {
cat(" [Hitter Unit Detection] PlateLocHeight median abs =", round(plh_median, 1),
"-> in FEET (standard TrackMan)\n")
}
}
# --- Filter missing data ---
if ("RelSpeed" %in% names(df)) df <- df %>% filter(!is.na(RelSpeed))
if ("Date" %in% names(df)) df <- df %>% filter(!is.na(Date))
# --- Ensure TaggedPitchType exists ---
if (!"TaggedPitchType" %in% names(df)) {
alt <- intersect(c("pitch_type","PitchType","TaggedPitch","TaggedPitchName"), names(df))
if (length(alt)) df$TaggedPitchType <- df[[alt[1]]] else df$TaggedPitchType <- NA_character_
}
# --- Filter undefined ---
df <- df %>%
filter((is.na(PitchCall) | PitchCall != "Undefined") |
(is.na(TaggedPitchType) | TaggedPitchType != "Undefined"))
# ============================================================
# ALL INDICATORS (feet-based thresholds)
# ============================================================
df <- df %>%
mutate(
# Clean EV outliers
ExitSpeed = ifelse(!is.na(ExitSpeed) & !is.na(Angle) & ExitSpeed > 120 & Angle < -10, NA, ExitSpeed),
# Core indicators
WhiffIndicator = ifelse(PitchCall == "StrikeSwinging", 1, 0),
StrikeZoneIndicator = ifelse(
!is.na(PlateLocSide) & !is.na(PlateLocHeight) &
PlateLocSide >= -0.83 & PlateLocSide <= 0.83 &
PlateLocHeight >= 1.5 & PlateLocHeight <= 3.5, 1, 0),
SwingIndicator = ifelse(PitchCall %in% c("StrikeSwinging", "FoulBallNotFieldable", "FoulBall", "InPlay"), 1, 0),
# Ball in play (excluding bunts)
BIPind = ifelse(PitchCall == "InPlay" &
(is.na(TaggedHitType) | TaggedHitType != "Bunt"), 1, 0),
# At-bat and plate appearance
ABindicator = ifelse(
PlayResult %in% c("Error", "FieldersChoice", "Out", "Single", "Double", "Triple", "HomeRun") |
KorBB == "Strikeout", 1, 0),
HitIndicator = ifelse(PlayResult %in% c("Single", "Double", "Triple", "HomeRun"), 1, 0),
PAindicator = ifelse(
PitchCall %in% c("InPlay", "HitByPitch", "CatchersInterference") |
KorBB %in% c("Walk", "Strikeout"), 1, 0),
FPindicator = ifelse(Balls == 0 & Strikes == 0, 1, 0),
HBPIndicator = ifelse(PitchCall == "HitByPitch", 1, 0),
WalkIndicator = ifelse(KorBB == "Walk", 1, 0),
# ============================================================
# BATTED BALL QUALITY INDICATORS (these were missing!)
# ============================================================
# Launch Angle 10-30 (sweet spot)
LA1030ind = ifelse(PitchCall == "InPlay" & !is.na(Angle) & Angle >= 10 & Angle <= 30, 1, 0),
# Solid Contact (92+ EV with 8+ LA, or 95+ EV with 0+ LA)
SCind = ifelse(PitchCall == "InPlay" & !is.na(ExitSpeed) & !is.na(Angle) &
((ExitSpeed > 95 & Angle >= 0 & Angle <= 35) |
(ExitSpeed > 92 & Angle >= 8 & Angle <= 35)), 1, 0),
# Hard Hit (95+ EV)
HHind = ifelse(PitchCall == "InPlay" & !is.na(ExitSpeed) & ExitSpeed >= 95, 1, 0),
# Also keep HHindicator for backward compatibility
HHindicator = HHind,
# Barrel (95+ EV, 10-32 LA)
Barrelind = ifelse(PitchCall == "InPlay" & !is.na(ExitSpeed) & !is.na(Angle) &
ExitSpeed >= 95 & Angle >= 10 & Angle <= 32, 1, 0),
# Hit type indicators
GBindicator = ifelse(PitchCall == "InPlay" & !is.na(TaggedHitType) & TaggedHitType == "GroundBall", 1, 0),
LDind = ifelse(PitchCall == "InPlay" & !is.na(TaggedHitType) & TaggedHitType == "LineDrive", 1, 0),
FBind = ifelse(PitchCall == "InPlay" & !is.na(TaggedHitType) & TaggedHitType == "FlyBall", 1, 0),
Popind = ifelse(PitchCall == "InPlay" & !is.na(TaggedHitType) & TaggedHitType == "Popup", 1, 0),
# Zone-based indicators
Zwhiffind = ifelse(WhiffIndicator == 1 & StrikeZoneIndicator == 1, 1, 0),
Zswing = ifelse(StrikeZoneIndicator == 1 & SwingIndicator == 1, 1, 0),
Chaseindicator = ifelse(SwingIndicator == 1 & StrikeZoneIndicator == 0, 1, 0),
OutofZone = ifelse(StrikeZoneIndicator == 0, 1, 0),
# On-base and total bases
OnBaseindicator = ifelse(
PlayResult %in% c("Single", "Double", "Triple", "HomeRun") |
KorBB == "Walk" | PitchCall == "HitByPitch", 1, 0),
totalbases = case_when(
PlayResult == "Single" ~ 1,
PlayResult == "Double" ~ 2,
PlayResult == "Triple" ~ 3,
PlayResult == "HomeRun" ~ 4,
TRUE ~ 0
),
# Backfill PlayResult
PlayResult = ifelse(is.na(PlayResult) | PlayResult == "Undefined", PitchCall, PlayResult)
)
# --- Factor pitch types ---
if ("TaggedPitchType" %in% names(df)) {
df$TaggedPitchType <- factor(df$TaggedPitchType,
levels = c("Fastball","Sinker","Cutter","Curveball",
"Slider","ChangeUp","Splitter","Knuckleball","Other"))
}
df
}
# ---------- xBA Model Functions (DEFINE BEFORE USING) ----------
train_xba_model <- function(data) {
suppressPackageStartupMessages({
if (!require("mgcv", quietly = TRUE)) {
stop("Package 'mgcv' is required but not installed. Please install it with: install.packages('mgcv')")
}
})
# Filter for InPlay and remove outliers
inplay_data <- data %>%
filter(PitchCall == "InPlay") %>%
filter(is.na(TaggedHitType) | TaggedHitType != "Bunt") %>%
filter(!is.na(ExitSpeed), !is.na(Angle)) %>%
filter(ExitSpeed >= 20, ExitSpeed <= 124) %>%
filter(Angle >= -90, Angle <= 90) %>%
mutate(Hit = if_else(
PlayResult %in% c("Single", "Double", "Triple", "HomeRun"), 1, 0
))
if (nrow(inplay_data) < 100) {
warning("Insufficient data for xBA model training (need at least 100 in-play events)")
return(NULL)
}
# Fit GAM model
tryCatch({
m_gam <- mgcv::gam(
Hit ~ s(ExitSpeed, Angle),
data = inplay_data,
family = binomial()
)
return(m_gam)
}, error = function(e) {
warning(paste("Error training xBA model:", e$message))
return(NULL)
})
}
add_xba_predictions <- function(data, model) {
if (is.null(model)) {
return(data %>% mutate(xBA = NA_real_))
}
# Add xBA predictions for in-play events
data <- data %>%
mutate(
xBA = if_else(
PitchCall == "InPlay" & !is.na(ExitSpeed) & !is.na(Angle) &
ExitSpeed >= 20 & ExitSpeed <= 124 & Angle >= -90 & Angle <= 90,
predict(model, newdata = ., type = "response"),
NA_real_
)
)
return(data)
}
calculate_hitter_xba_stats <- function(data) {
data %>%
filter(PitchCall == "InPlay", !is.na(xBA)) %>%
group_by(Batter) %>%
summarize(
BIP_xBA = n(),
Hits = sum(HitIndicator, na.rm = TRUE),
BA = if_else(BIP_xBA > 0, Hits / BIP_xBA, NA_real_),
xBA = mean(xBA, na.rm = TRUE),
.groups = "drop"
)
}
# ---------- Load External Datasets ----------
bio <- download_private_csv("CoastalBaseball/HitterAppFiles", "CCU_Hitter_Bio1.csv")
SBC_2025 <- download_private_parquet("CoastalBaseball/HitterAppFiles", "SBC_2025.parquet")
P5_2025 <- download_private_parquet("CoastalBaseball/HitterAppFiles", "P5_2025.parquet")
IF_OAA <- download_private_parquet("CoastalBaseball/HitterAppFiles", "IF_OAA_DATA.parquet") %>%
mutate(obs_player_name = stringr::str_replace_all(obs_player_name, "(\\w+), (\\w+)", "\\2 \\1"))
OAA_DF <- as.data.frame(download_private_parquet("CoastalBaseball/HitterAppFiles", "CC_OAA25.parquet")) %>%
mutate(obs_player = stringr::str_replace_all(obs_player, "(\\w+), (\\w+)", "\\2 \\1"))
college_positions_cc <- as.data.frame(download_private_parquet("CoastalBaseball/HitterAppFiles", "college_positions_cc.parquet"))
def_pos_data <- download_private_parquet("CoastalBaseball/HitterAppFiles", "DefensivePositioningData.parquet")
P5_2025 <- process_dataset(P5_2025)
SBC_2025 <- process_dataset(SBC_2025)
sec_teams <- c("ALA_CRI","ARK_RAZ","AUB_TIG","FLA_GAT","GEO_BUL","KEN_WIL","LSU_TIG","OLE_REB",
"MSU_BDG","MIZ_TIG","SOU_GAM","TEN_VOL","TEX_AGG","VAN_COM","OKL_SOO","TEX_LON")
SEC_2025 <- P5_2025 %>% filter(PitcherTeam %in% sec_teams)
# Load Spring 2025
spring_data <- download_private_parquet("CoastalBaseball/HitterAppFiles", "CCUBatters251.parquet")
spring_data$Season <- "Spring 2025"
# Load Fall 2025
fall_data <- download_private_parquet("CoastalBaseball/HitterAppFiles", "fall_data_batter.parquet")
fall_data$Season <- "Fall 2025"
# FIX COLUMN NAME DIFFERENCE
if ("Top.Bottom" %in% names(fall_data)) {
names(fall_data)[names(fall_data) == "Top.Bottom"] <- "Top/Bottom"
}
# CONVERT ALL POTENTIALLY PROBLEMATIC COLUMNS TO CHARACTER
problem_columns <- c(
"Time", "Tilt", "UTCTime", "UTCDate", "LocalDateTime", "UTCDateTime",
"HomeTeamForeignID", "AwayTeamForeignID", "GameForeignID",
"PitcherId", "BatterId", "CatcherId"
)
for (col in problem_columns) {
if (col %in% names(spring_data)) {
spring_data[[col]] <- as.character(spring_data[[col]])
}
if (col %in% names(fall_data)) {
fall_data[[col]] <- as.character(fall_data[[col]])
}
}
# Process datasets
spring_data <- process_dataset(spring_data)
fall_data <- process_dataset(fall_data)
spring_data <- spring_data %>%
mutate(SpinAxis = suppressWarnings(as.numeric(SpinAxis)))
fall_data <- fall_data %>%
mutate(SpinAxis = suppressWarnings(as.numeric(SpinAxis)))
prespring_data <- download_private_parquet("CoastalBaseball/HitterAppFiles", "prespring_data_batter.parquet")
prespring_data$Season <- "Preseason Spring 2026"
# Fix column name if needed
if ("Top.Bottom" %in% names(prespring_data)) {
names(prespring_data)[names(prespring_data) == "Top.Bottom"] <- "Top/Bottom"
}
# Convert problematic columns to character (use your existing problem_columns variable)
for (col in problem_columns) {
if (col %in% names(prespring_data)) {
prespring_data[[col]] <- as.character(prespring_data[[col]])
}
}
# Process the dataset
prespring_data <- process_dataset(prespring_data)
prespring_data <- prespring_data %>%
mutate(SpinAxis = suppressWarnings(as.numeric(SpinAxis)))
# Load Spring 2026
spring26_data <- download_master_dataset("CoastalBaseball/2026MasterDataset", "coastal_hitters")
spring26_data$Season <- "Spring 2026"
if ("Top.Bottom" %in% names(spring26_data)) {
names(spring26_data)[names(spring26_data) == "Top.Bottom"] <- "Top/Bottom"
}
for (col in problem_columns) {
if (col %in% names(spring26_data)) {
spring26_data[[col]] <- as.character(spring26_data[[col]])
}
}
spring26_data <- process_dataset(spring26_data)
spring26_data <- spring26_data %>%
mutate(SpinAxis = suppressWarnings(as.numeric(SpinAxis)))
normalize_columns <- function(df) {
if ("PlayResult" %in% names(df)) {
df$PlayResult <- as.character(df$PlayResult)
}
if ("MeasuredDuration" %in% names(df)) {
df$MeasuredDuration <- as.character(df$MeasuredDuration)
}
df <- df %>%
mutate(
across(any_of(c(
"CatchPositionX", "CatchPositionY", "CatchPositionZ",
"ThrowPositionX", "ThrowPositionY", "ThrowPositionZ",
"BasePositionX", "BasePositionY", "BasePositionZ",
"ThrowTrajectoryXc0", "ThrowTrajectoryXc1", "ThrowTrajectoryXc2",
"ThrowTrajectoryYc0", "ThrowTrajectoryYc1", "ThrowTrajectoryYc2",
"ThrowTrajectoryZc0", "ThrowTrajectoryZc1", "ThrowTrajectoryZc2",
"PitchReleaseConfidence", "PitchLocationConfidence", "PitchMovementConfidence",
"HitLaunchConfidence", "HitLandingConfidence", "CatcherThrowCatchConfidence",
"CatcherThrowReleaseConfidence", "CatcherThrowLocationConfidence",
"BatSpeed", "VerticalAttackAngle", "HorizontalAttackAngle"
)), as.character)
)
df
}
all_seasonal_data <- bind_rows(
normalize_columns(prespring_data),
normalize_columns(spring26_data),
normalize_columns(spring_data),
normalize_columns(fall_data)
)
message("Unique seasons in all_seasonal_data: ", paste(unique(all_seasonal_data$Season), collapse = ", "))
# ---------- TRAIN xBA MODEL ON LARGER DATASET ----------
message("Training xBA model on comprehensive dataset...")
# Ensure all time columns are character in P5 and SBC datasets
P5_2025_clean <- P5_2025 %>%
mutate(across(any_of(problem_columns), as.character))
SBC_2025_clean <- SBC_2025 %>%
mutate(across(any_of(problem_columns), as.character))
# Combine CCU data with P5 and SBC for better model training
xba_training_data <- bind_rows(
normalize_columns(all_seasonal_data), # Your Spring + Fall data (already cleaned)
normalize_columns(P5_2025_clean), # Power 5 data (now cleaned)
normalize_columns(SBC_2025_clean) # Sun Belt data (now cleaned)
)
message("Training xBA model on ", nrow(xba_training_data), " total pitches...")
xBA_Model <- train_xba_model(xba_training_data)
if (!is.null(xBA_Model)) {
inplay_count <- xba_training_data %>%
filter(PitchCall == "InPlay", !is.na(ExitSpeed), !is.na(Angle),
ExitSpeed >= 20, ExitSpeed <= 124, Angle >= -90, Angle <= 90) %>%
nrow()
message("xBA model trained successfully with ", inplay_count, " in-play events")
message("Training data sources: CCU (Spring + Fall), P5, and SBC conferences")
} else {
warning("xBA model training failed - xBA predictions will not be available")
}
# ---------- ADD xBA PREDICTIONS TO ALL DATASETS ----------
message("Adding xBA predictions to datasets...")
all_seasonal_data <- add_xba_predictions(all_seasonal_data, xBA_Model)
P5_2025 <- add_xba_predictions(P5_2025, xBA_Model)
SBC_2025 <- add_xba_predictions(SBC_2025, xBA_Model)
SEC_2025 <- add_xba_predictions(SEC_2025, xBA_Model)
# Set main data objects
data <- all_seasonal_data
data2 <- all_seasonal_data
BP_data <- download_private_csv("CoastalBaseball/HitterAppFiles", "CCUBP2025.csv")
# ---------- BP processing ----------
if (nrow(BP_data) > 0) {
BP_data <- BP_data %>%
mutate(
Batter = stringr::str_replace_all(Batter, "(\\w+), (\\w+)", "\\2 \\1")
) %>%
distinct()
# Remove duplicate pitches if PitchUID exists
if ("PitchUID" %in% names(BP_data)) {
BP_data <- BP_data %>% distinct(PitchUID, .keep_all = TRUE)
}
# Robust date parsing
BP_data$Date <- suppressWarnings(
as.Date(BP_data$Date, tryFormats = c("%m/%d/%y", "%Y-%m-%d"))
)
BP_data <- BP_data %>% filter(!is.na(Date))
# Only proceed if rows remain after filtering
if (nrow(BP_data) > 0) {
BP_data <- BP_data %>%
mutate(
# Ball in play indicator
BIPind = as.integer(
!is.na(ExitSpeed) | !is.na(Angle) | !is.na(Distance)
),
# Clean impossible EV readings
ExitSpeed = ifelse(
!is.na(ExitSpeed) &
!is.na(Angle) &
ExitSpeed > 120 &
Angle < -10,
NA,
ExitSpeed
),
# Launch angle 10–30
LA1030ind = as.integer(
BIPind == 1 &
!is.na(Angle) &
Angle >= 10 &
Angle <= 30
),
# Barrel
Barrelind = as.integer(
BIPind == 1 &
!is.na(ExitSpeed) &
!is.na(Angle) &
ExitSpeed >= 95 &
Angle >= 10 &
Angle <= 32
),
# Hard hit
HHind = as.integer(
BIPind == 1 &
!is.na(ExitSpeed) &
ExitSpeed >= 95
),
# Solid contact
SCind = as.integer(
BIPind == 1 &
!is.na(ExitSpeed) &
!is.na(Angle) &
(
(ExitSpeed >= 100 & Angle >= 0 & Angle < 8) |
(ExitSpeed >= 95 & Angle >= 8 & Angle <= 35)
)
),
# Batted ball types
GBindicator = as.integer(
BIPind == 1 &
!is.na(TaggedHitType) &
TaggedHitType == "GroundBall"
),
LDind = as.integer(
BIPind == 1 &
!is.na(TaggedHitType) &
TaggedHitType == "LineDrive"
),
FBind = as.integer(
BIPind == 1 &
!is.na(TaggedHitType) &
TaggedHitType == "FlyBall"
),
Popind = as.integer(
BIPind == 1 &
!is.na(TaggedHitType) &
TaggedHitType == "Popup"
)
)
}
# Standardize pitch type levels
if ("TaggedPitchType" %in% names(BP_data)) {
BP_data$TaggedPitchType <- factor(
BP_data$TaggedPitchType,
levels = c(
"Fastball","Sinker","Cutter","Curveball",
"Slider","ChangeUp","Splitter","Knuckleball","Other"
)
)
}
} else {
BP_data <- data.frame()
}
# ---------- Small helpers ----------
get_bp_weeks <- function(bp_data) {
if (!nrow(bp_data)) return(character(0))
bp_data %>% mutate(Week = format(Date, "%Y-%m-%d")) %>% distinct(Week) %>% arrange(desc(Week)) %>% pull(Week)
}
get_bp_sessions <- function(bp_data) {
if (!nrow(bp_data)) return(character(0))
bp_data %>% distinct(Date) %>% arrange(desc(Date)) %>% mutate(Session = format(Date, "%Y-%m-%d")) %>% pull(Session)
}
bio_lookup <- setNames(bio$Batter, gsub("^(.*),\\s*(.*)$", "\\2 \\1", bio$Batter))
# ---------- Plot/table creators ----------
create_bp_heatmap <- function(batter_name, bp_data, metric = "Hard-Hit (95+)") {
tm_data <- bp_data %>%
filter(Batter == batter_name, BIPind == 1, !is.na(PlateLocSide), !is.na(PlateLocHeight))
if (!nrow(tm_data)) {
return(ggplot() + theme_void() + ggtitle(paste("No BP data for", batter_name)) +
theme(plot.title = element_text(hjust=0.5, size=14, face="bold")))
}
# Metric filters
if (metric == "Hard-Hit (95+)") {
tm_data <- tm_data %>% filter(!is.na(ExitSpeed), ExitSpeed >= 95)
title_metric <- "Hard Hit (95+ mph)"
} else if (metric == "Medium (90-95)") {
tm_data <- tm_data %>% filter(!is.na(ExitSpeed), ExitSpeed >= 90, ExitSpeed < 95)
title_metric <- "Medium Contact (90-95 mph)"
} else if (metric == "Soft (Under 90)") {
tm_data <- tm_data %>% filter(!is.na(ExitSpeed), ExitSpeed < 90)
title_metric <- "Soft Contact (Under 90 mph)"
}
if (!nrow(tm_data)) {
return(ggplot() + theme_void() + ggtitle(paste("No", metric, "data for", batter_name)) +
theme(plot.title = element_text(hjust=0.5, size=14, face="bold")))
}
ggplot(tm_data, aes(x=PlateLocSide, y=PlateLocHeight)) +
stat_density_2d(aes(fill=after_stat(density)), geom="raster", contour=FALSE) +
scale_fill_gradientn(colours=c("white","#0551bc","#02fbff","#03ff00","#fbff00", "#ffa503","#ff1f02","#dc1100"), name="Density") +
annotate("rect", xmin=-0.8303, xmax=0.8303, ymin=1.6, ymax=3.5, fill=NA, color="black", linewidth=1) +
annotate("path", x=c(-0.708,0.708,0.708,0,-0.708,-0.708),
y=c(0.15,0.15,0.3,0.5,0.3,0.15), color="black", linewidth=.8) +
coord_fixed(ratio=1) + xlim(-2,2) + ylim(0,4.5) +
ggtitle(paste("BP Heatmap:", title_metric)) +
theme_void() + theme(legend.position="right", plot.margin=margin(3,3,3,3),
plot.title=element_text(hjust=0.5, size=14, face="bold"))
}
create_bp_spray_chart <- function(batter_name, bp_data) {
chart_data <- bp_data %>%
filter(Batter == batter_name, BIPind == 1, !is.na(Distance), !is.na(Bearing)) %>%
mutate(Bearing2 = Bearing * pi/180, x = Distance*sin(Bearing2), y = Distance*cos(Bearing2))
if (!nrow(chart_data)) {
return(ggplot() + theme_void() + ggtitle(paste("No BP spray data for", batter_name)) +
theme(plot.title = element_text(hjust=0.5, size=14, face="bold")))
}
chart_data %>%
ggplot(aes(x,y)) +
coord_fixed() +
annotate("segment", x=0,y=0,xend=247.487,yend=247.487, color="black") +
annotate("segment", x=0,y=0,xend=-247.487,yend=247.487, color="black") +
annotate("segment", x=63.6396,y=63.6396,xend=0,yend=127.279, color="black") +
annotate("segment", x=-63.6396,y=63.6396,xend=0,yend=127.279, color="black") +
annotate("curve", x=85.095,y=85.095,xend=0,yend=160, curvature=0.36, linewidth=.5, color="black") +
annotate("curve", x=-85.095,y=85.095,xend=0,yend=160, curvature=-0.36, linewidth=.5, color="black") +
annotate("curve", x=-247.487,y=247.487,xend=247.487,yend=247.487, curvature=-0.65, linewidth=.5, color="black") +
geom_point(aes(fill=ExitSpeed), size=4, shape=21, color="black", stroke=.5) +
scale_fill_gradient(low="#E1463E", high="#00840D", name="Exit Velo", na.value="grey50") +
theme_void() + ylim(0,435) + ggtitle(paste("BP Spray Chart:", batter_name)) +
theme(legend.position="right", plot.title=element_text(hjust=0.5, size=14, face="bold"))
}
create_bp_zone_chart <- function(batter_name, bp_data) {
zone_data <- bp_data %>%
filter(Batter == batter_name, BIPind == 1, !is.na(PlateLocSide), !is.na(PlateLocHeight))
if (!nrow(zone_data)) {
return(ggplot() + theme_void() + ggtitle(paste("No BP zone data for", batter_name)) +
theme(plot.title = element_text(hjust=0.5, size=14, face="bold")))
}
ggplot(zone_data, aes(x=PlateLocSide, y=PlateLocHeight)) +
geom_point(aes(fill=ExitSpeed), size=4, shape=21, color="black", stroke=.5, alpha=.8) +
scale_fill_gradient(low="#E1463E", high="#00840D", name="Exit Velo", na.value="grey50") +
annotate("rect", xmin=-0.8303, xmax=0.8303, ymin=1.6, ymax=3.5, fill=NA, color="black", linewidth=1) +
annotate("path", x=c(-0.708,0.708,0.708,0,-0.708,-0.708),
y=c(0.15,0.15,0.3,0.5,0.3,0.15), color="black", linewidth=.8) +
coord_fixed(ratio=1) + xlim(-2,2) + ylim(0,4.5) +
ggtitle(paste("BP Zone Plot:", batter_name)) +
theme_void() + theme(legend.position="right", plot.title=element_text(hjust=0.5, size=14, face="bold"))
}
create_bp_contact_map <- function(batter_name, bp_data) {
contact_data <- bp_data %>%
filter(Batter == batter_name, BIPind == 1, !is.na(ExitSpeed),
!is.na(ContactPositionZ), !is.na(ContactPositionX), !is.na(ContactPositionY)) %>%
mutate(ContactPositionX = ContactPositionX*12,
ContactPositionY = ContactPositionY*12,
ContactPositionZ = ContactPositionZ*12)
if (!nrow(contact_data)) {
return(ggplot() + theme_void() + ggtitle(paste("No BP contact data for", batter_name)) +
theme(plot.title = element_text(hjust=0.5, size=14, face="bold")))
}
batter_side <- unique(contact_data$BatterSide)[1]
if (is.na(batter_side)) batter_side <- "Right"
ggplot(contact_data, aes(x=ContactPositionZ, y=ContactPositionX)) +
annotate("segment", x=-8.5,y=17,xend=8.5,yend=17, color="black") +
annotate("segment", x=8.5,y=8.5,xend=8.5,yend=17, color="black") +
annotate("segment", x=-8.5,y=8.5,xend=-8.5,yend=17, color="black") +
annotate("segment", x=-8.5,y=8.5,xend=0,yend=0, color="black") +
annotate("segment", x=8.5,y=8.5,xend=0,yend=0, color="black") +
annotate("rect", xmin=20,xmax=48,ymin=-20,ymax=40, fill=NA, color="black") +
annotate("rect", xmin=-48,xmax=-20,ymin=-20,ymax=40, fill=NA, color="black") +
annotate("text", x=ifelse(batter_side=="Right",-34,34), y=10, label=ifelse(batter_side=="Right","R","L"),
size=8, fontface="bold") +
xlim(-50,50) + ylim(-20,50) +
geom_point(aes(fill=ExitSpeed), color="black", stroke=.5, shape=21, alpha=.85, size=3) +
geom_smooth(aes(color="Optimal Contact"), method="lm", level=0, se=FALSE) +
scale_fill_gradient(name="Exit Velo", low="#E1463E", high="#00840D") +
scale_color_manual(name=NULL, values=c("Optimal Contact"="black")) +
coord_fixed() + ggtitle(paste("BP Contact Points:", batter_name)) +
theme_void() + theme(legend.position="right",
plot.title=element_text(hjust=0.5, size=14, face="bold"))
}
get_last_n_games <- function(df, batter, n = 15) {
df %>%
dplyr::filter(Batter == batter) %>%
dplyr::arrange(dplyr::desc(as.Date(Date))) %>%
dplyr::distinct(Date, .keep_all = TRUE) %>%
dplyr::slice_head(n = n) %>%
dplyr::pull(Date)
}
# Add zone bins with handedness normalization
add_zone_bins <- function(df) {
df %>%
dplyr::mutate(
x_rel = dplyr::if_else(BatterSide == "Left", -PlateLocSide, PlateLocSide),
y_rel = PlateLocHeight
) %>%
{
x_breaks <- c(-0.95, -0.3166667, 0.3166667, 0.95)
y_breaks <- c(1.6, 2.2333333, 2.8666667, 3.5)
dplyr::mutate(.,
xbin = cut(x_rel, breaks = x_breaks, labels = FALSE, include.lowest = TRUE),
ybin = cut(y_rel, breaks = y_breaks, labels = FALSE, include.lowest = TRUE),
zone_name = dplyr::case_when(
ybin == 1 & xbin == 1 ~ "Down-In",
ybin == 1 & xbin == 2 ~ "Down-Middle",
ybin == 1 & xbin == 3 ~ "Down-Away",
ybin == 2 & xbin == 1 ~ "Middle-In",
ybin == 2 & xbin == 2 ~ "Heart",
ybin == 2 & xbin == 3 ~ "Middle-Away",
ybin == 3 & xbin == 1 ~ "Up-In",
ybin == 3 & xbin == 2 ~ "Up-Middle",
ybin == 3 & xbin == 3 ~ "Up-Away",
TRUE ~ NA_character_
)
)
}
}
make_color_fn <- function(low, high) {
scales::col_numeric(palette = c("#E1463E", "white", "#00840D"), domain = c(low, high))
}
make_color_fn_reverse <- function(low, high) {
scales::col_numeric(palette = c("#00840D", "white", "#E1463E"), domain = c(low, high))
}
create_last15_table <- function(player_name, team_data) {
last_games <- get_last_n_games(team_data, player_name, 15)
# Last 15 games summary
last15_data <- team_data %>%
filter(Batter == player_name, Date %in% last_games) %>%
summarize(
Period = "Last 15 Games",
PA = sum(PAindicator, na.rm = TRUE),
H = sum(HitIndicator, na.rm = TRUE),
BB = sum(WalkIndicator, na.rm = TRUE),
K = sum(KorBB == "Strikeout", na.rm = TRUE),
AB = sum(ABindicator, na.rm = TRUE),
TB = sum(totalbases, na.rm = TRUE),
AVG = ifelse(AB > 0, H / AB, NA_real_),
OBP = ifelse(PA > 0, (H + BB) / PA, NA_real_),
SLG = ifelse(AB > 0, TB / AB, NA_real_),
OPS = OBP + SLG,
.groups = "drop"
)
# Overall season
season_data <- team_data %>%
filter(Batter == player_name) %>%
summarize(
Period = "Overall Season",
PA = sum(PAindicator, na.rm = TRUE),
H = sum(HitIndicator, na.rm = TRUE),
BB = sum(WalkIndicator, na.rm = TRUE),
K = sum(KorBB == "Strikeout", na.rm = TRUE),
AB = sum(ABindicator, na.rm = TRUE),
TB = sum(totalbases, na.rm = TRUE),
AVG = ifelse(AB > 0, H / AB, NA_real_),
OBP = ifelse(PA > 0, (H + BB) / PA, NA_real_),
SLG = ifelse(AB > 0, TB / AB, NA_real_),
OPS = OBP + SLG,
.groups = "drop"
)
combined <- bind_rows(last15_data, season_data)
if (!nrow(combined)) {
return(gt::gt(data.frame(Note = "No data available")))
}
gt::gt(combined) %>%
gtExtras::gt_theme_guardian() %>%
gt::tab_header(title = paste("Recent Performance:", player_name)) %>%
gt::fmt_number(columns = c(AVG, OBP, SLG, OPS), decimals = 3) %>%
gt::cols_align(align = "center", columns = gt::everything()) %>%
gt::sub_missing(columns = gt::everything(), missing_text = "-") %>%
gt::tab_options(
heading.background.color = "darkcyan",
column_labels.background.color = "darkcyan",
table.border.top.color = "peru",
table.border.bottom.color = "peru"
) %>%
gt::tab_style(
style = gt::cell_text(color = "white"),
locations = gt::cells_title(groups = "title")
) %>%
gt::tab_style(
style = list(gt::cell_fill(color = "#f0f8e8"), gt::cell_text(weight = "bold")),
locations = gt::cells_body(rows = Period == "Last 15 Games")
) %>%
gt::tab_style(
style = list(gt::cell_fill(color = "#fff3cd"), gt::cell_text(weight = "bold")),
locations = gt::cells_body(rows = Period == "Overall Season")
)
}
create_advanced_numbers_table <- function(player_name, team_data) {
df <- team_data %>%
filter(Batter == player_name, PitcherThrows %in% c("Left", "Right")) %>%
calculate_rv100()
if (!nrow(df)) {
return(gt::gt(data.frame(Note = "No data available")))
}
# Create pitch families
df <- df %>%
mutate(PitchFamily = case_when(
TaggedPitchType %in% c("Fastball", "Four-Seam", "FourSeamFastBall","OneSeamFastBall","Sinker") ~ "Fastballs",
TaggedPitchType %in% c("Cutter", "Slider", "Sweeper", "Curveball") ~ "Breaking Balls",
TaggedPitchType %in% c("ChangeUp", "Splitter") ~ "Offspeed",
TRUE ~ "Other"
)) %>%
filter(PitchFamily != "Other")
if (!nrow(df)) {
return(gt::gt(data.frame(Note = "No data available after filtering")))
}
# Group by pitch family
hitter_summary_group <- df %>%
group_by(PitchFamily) %>%
summarise(
P = n(),
`RV` = sum(hitter_rv, na.rm = TRUE),
`RV/100` = 100 * mean(hitter_rv, na.rm = TRUE),
AVG = sum(HitIndicator, na.rm = TRUE) / sum(ABindicator, na.rm = TRUE),
xBA = mean(xBA, na.rm = TRUE),
OBP = sum(OnBaseindicator, na.rm = TRUE) / sum(PAindicator, na.rm = TRUE),
SLG = sum(totalbases, na.rm = TRUE) / sum(ABindicator, na.rm = TRUE),
OPS = OBP + SLG,
`10-30` = sum(LA1030ind, na.rm = TRUE) / sum(BIPind, na.rm = TRUE) * 100,
SC = sum(SCind, na.rm = TRUE) / sum(BIPind, na.rm = TRUE) * 100,
`Chase` = sum(Chaseindicator, na.rm = TRUE) / sum(OutofZone, na.rm = TRUE) * 100,
`Whiff` = sum(WhiffIndicator, na.rm = TRUE) / sum(SwingIndicator, na.rm = TRUE) * 100,
`Z-Con` = 100 * (1 - sum(Zwhiffind, na.rm = TRUE) / sum(Zswing, na.rm = TRUE)),
`AvgEV` = mean(ExitSpeed[PitchCall == "InPlay"], na.rm = TRUE),
`EV90` = quantile(ExitSpeed, 0.9, na.rm = TRUE),
`HH` = sum(HHind, na.rm = TRUE) / sum(BIPind, na.rm = TRUE) * 100,
`GB` = sum(GBindicator, na.rm = TRUE) / sum(BIPind, na.rm = TRUE) * 100,
.groups = "drop"
) %>%
rename(` ` = PitchFamily)
# Count situations
hitters_count <- df %>%
filter(Balls > Strikes) %>%
summarise(
` ` = "Hitter's Count",
P = n(),
`RV` = sum(hitter_rv, na.rm = TRUE),
`RV/100` = 100 * mean(hitter_rv, na.rm = TRUE),
AVG = sum(HitIndicator, na.rm = TRUE) / sum(ABindicator, na.rm = TRUE),
xBA = mean(xBA, na.rm = TRUE),
OBP = sum(OnBaseindicator, na.rm = TRUE) / sum(PAindicator, na.rm = TRUE),
SLG = sum(totalbases, na.rm = TRUE) / sum(ABindicator, na.rm = TRUE),
OPS = OBP + SLG,
`10-30` = sum(LA1030ind, na.rm = TRUE) / sum(BIPind, na.rm = TRUE) * 100,
SC = sum(SCind, na.rm = TRUE) / sum(BIPind, na.rm = TRUE) * 100,
`Chase` = sum(Chaseindicator, na.rm = TRUE) / sum(OutofZone, na.rm = TRUE) * 100,
`Whiff` = sum(WhiffIndicator, na.rm = TRUE) / sum(SwingIndicator, na.rm = TRUE) * 100,
`Z-Con` = 100 * (1 - sum(Zwhiffind, na.rm = TRUE) / sum(Zswing, na.rm = TRUE)),
`AvgEV` = mean(ExitSpeed[PitchCall == "InPlay"], na.rm = TRUE),
`EV90` = quantile(ExitSpeed, 0.9, na.rm = TRUE),
`HH` = sum(HHind, na.rm = TRUE) / sum(BIPind, na.rm = TRUE) * 100,
`GB` = sum(GBindicator, na.rm = TRUE) / sum(BIPind, na.rm = TRUE) * 100
)
pitchers_count <- df %>%
filter(Strikes > Balls) %>%
summarise(
` ` = "Pitcher's Count",
P = n(),
`RV` = sum(hitter_rv, na.rm = TRUE),
`RV/100` = 100 * mean(hitter_rv, na.rm = TRUE),
AVG = sum(HitIndicator, na.rm = TRUE) / sum(ABindicator, na.rm = TRUE),
xBA = mean(xBA, na.rm = TRUE),
OBP = sum(OnBaseindicator, na.rm = TRUE) / sum(PAindicator, na.rm = TRUE),
SLG = sum(totalbases, na.rm = TRUE) / sum(ABindicator, na.rm = TRUE),
OPS = OBP + SLG,
`10-30` = sum(LA1030ind, na.rm = TRUE) / sum(BIPind, na.rm = TRUE) * 100,
SC = sum(SCind, na.rm = TRUE) / sum(BIPind, na.rm = TRUE) * 100,
`Chase` = sum(Chaseindicator, na.rm = TRUE) / sum(OutofZone, na.rm = TRUE) * 100,
`Whiff` = sum(WhiffIndicator, na.rm = TRUE) / sum(SwingIndicator, na.rm = TRUE) * 100,
`Z-Con` = 100 * (1 - sum(Zwhiffind, na.rm = TRUE) / sum(Zswing, na.rm = TRUE)),
`AvgEV` = mean(ExitSpeed[PitchCall == "InPlay"], na.rm = TRUE),
`EV90` = quantile(ExitSpeed, 0.9, na.rm = TRUE),
`HH` = sum(HHind, na.rm = TRUE) / sum(BIPind, na.rm = TRUE) * 100,
`GB` = sum(GBindicator, na.rm = TRUE) / sum(BIPind, na.rm = TRUE) * 100
)
first_pitch <- df %>%
filter(Balls == 0, Strikes == 0) %>%
summarise(
` ` = "First Pitch",
P = n(),
`RV` = sum(hitter_rv, na.rm = TRUE),
`RV/100` = 100 * mean(hitter_rv, na.rm = TRUE),
AVG = sum(HitIndicator, na.rm = TRUE) / sum(ABindicator, na.rm = TRUE),
xBA = mean(xBA, na.rm = TRUE),
OBP = sum(OnBaseindicator, na.rm = TRUE) / sum(PAindicator, na.rm = TRUE),
SLG = sum(totalbases, na.rm = TRUE) / sum(ABindicator, na.rm = TRUE),
OPS = OBP + SLG,
`10-30` = sum(LA1030ind, na.rm = TRUE) / sum(BIPind, na.rm = TRUE) * 100,
SC = sum(SCind, na.rm = TRUE) / sum(BIPind, na.rm = TRUE) * 100,
`Chase` = sum(Chaseindicator, na.rm = TRUE) / sum(OutofZone, na.rm = TRUE) * 100,
`Whiff` = sum(WhiffIndicator, na.rm = TRUE) / sum(SwingIndicator, na.rm = TRUE) * 100,
`Z-Con` = 100 * (1 - sum(Zwhiffind, na.rm = TRUE) / sum(Zswing, na.rm = TRUE)),
`AvgEV` = mean(ExitSpeed[PitchCall == "InPlay"], na.rm = TRUE),
`EV90` = quantile(ExitSpeed, 0.9, na.rm = TRUE),
`HH` = sum(HHind, na.rm = TRUE) / sum(BIPind, na.rm = TRUE) * 100,
`GB` = sum(GBindicator, na.rm = TRUE) / sum(BIPind, na.rm = TRUE) * 100
)
# Overall summary
hitter_summary <- df %>%
summarise(
` ` = "Total",
P = n(),
`RV` = sum(hitter_rv, na.rm = TRUE),
`RV/100` = 100 * mean(hitter_rv, na.rm = TRUE),
AVG = sum(HitIndicator, na.rm = TRUE) / sum(ABindicator, na.rm = TRUE),
xBA = mean(xBA, na.rm = TRUE),
OBP = sum(OnBaseindicator, na.rm = TRUE) / sum(PAindicator, na.rm = TRUE),
SLG = sum(totalbases, na.rm = TRUE) / sum(ABindicator, na.rm = TRUE),
OPS = OBP + SLG,
`10-30` = sum(LA1030ind, na.rm = TRUE) / sum(BIPind, na.rm = TRUE) * 100,
SC = sum(SCind, na.rm = TRUE) / sum(BIPind, na.rm = TRUE) * 100,
`Chase` = sum(Chaseindicator, na.rm = TRUE) / sum(OutofZone, na.rm = TRUE) * 100,
`Whiff` = sum(WhiffIndicator, na.rm = TRUE) / sum(SwingIndicator, na.rm = TRUE) * 100,
`Z-Con` = 100 * (1 - sum(Zwhiffind, na.rm = TRUE) / sum(Zswing, na.rm = TRUE)),
`AvgEV` = mean(ExitSpeed[PitchCall == "InPlay"], na.rm = TRUE),
`EV90` = quantile(ExitSpeed, 0.9, na.rm = TRUE),
`HH` = sum(HHind, na.rm = TRUE) / sum(BIPind, na.rm = TRUE) * 100,
`GB` = sum(GBindicator, na.rm = TRUE) / sum(BIPind, na.rm = TRUE) * 100
)
# By hand
hitter_summary_hand <- df %>%
group_by(PitcherThrows) %>%
summarise(
P = n(),
`RV` = sum(hitter_rv, na.rm = TRUE),
`RV/100` = 100 * mean(hitter_rv, na.rm = TRUE),
AVG = sum(HitIndicator, na.rm = TRUE) / sum(ABindicator, na.rm = TRUE),
xBA = mean(xBA, na.rm = TRUE),
OBP = sum(OnBaseindicator, na.rm = TRUE) / sum(PAindicator, na.rm = TRUE),
SLG = sum(totalbases, na.rm = TRUE) / sum(ABindicator, na.rm = TRUE),
OPS = OBP + SLG,
`10-30` = sum(LA1030ind, na.rm = TRUE) / sum(BIPind, na.rm = TRUE) * 100,
SC = sum(SCind, na.rm = TRUE) / sum(BIPind, na.rm = TRUE) * 100,
`Chase` = sum(Chaseindicator, na.rm = TRUE) / sum(OutofZone, na.rm = TRUE) * 100,
`Whiff` = sum(WhiffIndicator, na.rm = TRUE) / sum(SwingIndicator, na.rm = TRUE) * 100,
`Z-Con` = 100 * (1 - sum(Zwhiffind, na.rm = TRUE) / sum(Zswing, na.rm = TRUE)),
`AvgEV` = mean(ExitSpeed[PitchCall == "InPlay"], na.rm = TRUE),
`EV90` = quantile(ExitSpeed, 0.9, na.rm = TRUE),
`HH` = sum(HHind, na.rm = TRUE) / sum(BIPind, na.rm = TRUE) * 100,
`GB` = sum(GBindicator, na.rm = TRUE) / sum(BIPind, na.rm = TRUE) * 100,
.groups = "drop"
) %>%
rename(` ` = PitcherThrows)
# Combine and ensure all numeric columns are numeric
final_table <- bind_rows(
hitter_summary_group,
hitters_count,
pitchers_count,
first_pitch,
hitter_summary_hand,
hitter_summary
) %>%
arrange(factor(` `, levels = c("Fastballs", "Breaking Balls", "Offspeed",
"Hitter's Count", "Pitcher's Count", "First Pitch",
"Left", "Right", "Total"))) %>%
# CRITICAL FIX: Ensure all numeric columns are actually numeric
mutate(across(c(P, RV, `RV/100`, AVG, xBA, OBP, SLG, OPS,
`10-30`, SC, Chase, Whiff, `Z-Con`, AvgEV, EV90, HH, GB),
as.numeric))
# Create gt table
final_table %>%
gt::gt() %>%
gtExtras::gt_theme_guardian() %>%
gt::fmt_number(columns = c(AVG, xBA, OBP, SLG, OPS), decimals = 3) %>%
gt::fmt_number(columns = c(`RV`,`RV/100`, `10-30`, SC, Chase, Whiff, `Z-Con`, AvgEV, EV90, HH, GB), decimals = 1) %>%
gt::cols_align(align = "center", columns = gt::everything()) %>%
gt::sub_missing(columns = gt::everything(), missing_text = "-") %>%
gt::tab_options(
table.font.size = gt::px(14),
data_row.padding = gt::px(6),
heading.background.color = "darkcyan",
column_labels.background.color = "darkcyan",
column_labels.font.size = gt::px(14),
table.border.top.color = "peru",
table.border.bottom.color = "peru"
) %>%
gt::data_color(columns = AVG, fn = scales::col_numeric(palette = c("#E1463E", "white", "#00840D"), domain = NULL)) %>%
gt::data_color(columns = xBA, fn = scales::col_numeric(palette = c("#E1463E", "white", "#00840D"), domain = NULL)) %>%
gt::data_color(columns = OBP, fn = scales::col_numeric(palette = c("#E1463E", "white", "#00840D"), domain = NULL)) %>%
gt::data_color(columns = SLG, fn = scales::col_numeric(palette = c("#E1463E", "white", "#00840D"), domain = NULL)) %>%
gt::data_color(columns = OPS, fn = scales::col_numeric(palette = c("#E1463E", "white", "#00840D"), domain = NULL)) %>%
gt::data_color(
columns = `RV/100`,
fn = scales::col_numeric(
palette = c("#8B0000", "#E1463E", "#FF6B6B", "white", "#90EE90", "#00840D", "#006400"),
domain = c(-8, -4, -2, 0, 2, 5, 8)
)
) %>%
gt::data_color(columns = AvgEV, fn = scales::col_numeric(palette = c("#E1463E", "white", "#00840D"), domain = NULL)) %>%
gt::data_color(columns = EV90, fn = scales::col_numeric(palette = c("#E1463E", "white", "#00840D"), domain = NULL)) %>%
gt::data_color(columns = HH, fn = scales::col_numeric(palette = c("#E1463E", "white", "#00840D"), domain = NULL)) %>%
gt::data_color(columns = `Z-Con`, fn = scales::col_numeric(palette = c("#E1463E", "white", "#00840D"), domain = NULL)) %>%
gt::data_color(columns = `10-30`, fn = scales::col_numeric(palette = c("#E1463E", "white", "#00840D"), domain = NULL)) %>%
gt::data_color(columns = SC, fn = scales::col_numeric(palette = c("#E1463E", "white", "#00840D"), domain = NULL)) %>%
gt::data_color(columns = Chase, fn = scales::col_numeric(palette = c("#00840D", "white", "#E1463E"), domain = NULL)) %>%
gt::data_color(columns = Whiff, fn = scales::col_numeric(palette = c("#00840D", "white", "#E1463E"), domain = NULL)) %>%
gt::data_color(columns = GB, fn = scales::col_numeric(palette = c("#00840D", "white", "#E1463E"), domain = NULL))
}
create_fb_splits_table <- function(player_name, team_data) {
df <- team_data %>%
filter(Batter == player_name, TaggedPitchType %in% c("Fastball", "Four-Seam", "Sinker")) %>%
calculate_rv100() %>%
add_zone_bins() %>%
mutate(
fb_ride = InducedVertBreak >= 18,
fb_cut = HorzBreak >= -5 & HorzBreak <= 5,
fb_soft = RelSpeed <= 88,
fb_hard = RelSpeed >= 92,
fb_elevated = PlateLocHeight >= 2.8667,
fb_sink = InducedVertBreak < 9 & abs(HorzBreak) >= 10,
fb_deadzone = InducedVertBreak >= 10 & InducedVertBreak <= 16 & abs(HorzBreak) >= 8 & abs(HorzBreak) <= 15,
is_heart = zone_name == "Heart",
is_down = ybin == 1,
is_in = xbin == 1
)
if (!nrow(df)) {
return(gt::gt(data.frame(Note = "No fastball data")))
}
calc_stats <- function(data) {
data %>%
summarise(
P = n(),
`RV` = sum(hitter_rv, na.rm = TRUE),
`RV/100` = 100 * mean(hitter_rv, na.rm = TRUE),
BA = sum(HitIndicator, na.rm = TRUE) / sum(ABindicator, na.rm = TRUE),
SLG = sum(totalbases, na.rm = TRUE) / sum(ABindicator, na.rm = TRUE),
`Whiff%` = sum(WhiffIndicator, na.rm = TRUE) / sum(SwingIndicator, na.rm = TRUE) * 100,
`Chase%` = sum(Chaseindicator, na.rm = TRUE) / sum(OutofZone, na.rm = TRUE) * 100,
`Z-Whiff` = sum(Zwhiffind, na.rm = TRUE) / sum(Zswing, na.rm = TRUE) * 100,
EV = mean(ExitSpeed[PitchCall == "InPlay"], na.rm = TRUE),
SC = sum(SCind, na.rm = TRUE) / sum(BIPind, na.rm = TRUE) * 100,
`10-30` = sum(LA1030ind, na.rm = TRUE) / sum(BIPind, na.rm = TRUE) * 100,
`CalledStrike%` = sum(PitchCall == "StrikeCalled", na.rm = TRUE) / n() * 100
)
}
all_fb <- calc_stats(df) %>% mutate(` ` = "All FB")
lefty <- calc_stats(df %>% filter(PitcherThrows == "Left")) %>% mutate(` ` = "Lefty")
righty <- calc_stats(df %>% filter(PitcherThrows == "Right")) %>% mutate(` ` = "Righty")
ride <- calc_stats(df %>% filter(fb_ride)) %>% mutate(` ` = "Ride (18+ IVB)")
cut <- calc_stats(df %>% filter(fb_cut)) %>% mutate(` ` = "Cut (-5 to 5 HB)")
soft <- calc_stats(df %>% filter(fb_soft)) %>% mutate(` ` = "Soft (≤88)")
hard <- calc_stats(df %>% filter(fb_hard)) %>% mutate(` ` = "Hard (≥92)")
elevated <- calc_stats(df %>% filter(fb_elevated)) %>% mutate(` ` = "Elevated")
sink <- calc_stats(df %>% filter(fb_sink)) %>% mutate(` ` = "Sink")
deadzone <- calc_stats(df %>% filter(fb_deadzone)) %>% mutate(` ` = "Deadzone")
heart <- calc_stats(df %>% filter(is_heart)) %>% mutate(` ` = "Heart")
down <- calc_stats(df %>% filter(is_down)) %>% mutate(` ` = "Down")
in_zone <- calc_stats(df %>% filter(is_in)) %>% mutate(` ` = "In")
final_table <- bind_rows(all_fb, lefty, righty, ride, cut, soft, hard, elevated, sink, deadzone, heart, down, in_zone) %>%
select(` `, everything()) %>%
# Ensure all numeric columns are numeric
mutate(across(c(P, RV, `RV/100`, BA, SLG, `Whiff%`, `Chase%`, `Z-Whiff`, EV, SC, `10-30`, `CalledStrike%`), as.numeric))
final_table %>%
gt::gt() %>%
gtExtras::gt_theme_guardian() %>%
gt::tab_header(title = "Fastball Splits") %>%
gt::fmt_number(columns = c(BA, SLG), decimals = 3) %>%
gt::fmt_number(columns = c(`RV`, `RV/100`, `Whiff%`, `Chase%`, `Z-Whiff`, EV, SC, `10-30`, `CalledStrike%`), decimals = 1) %>%
gt::cols_align(align = "center", columns = gt::everything()) %>%
gt::sub_missing(columns = gt::everything(), missing_text = "-") %>%
gt::tab_options(
heading.background.color = "darkcyan",
column_labels.background.color = "darkcyan",
table.border.top.color = "peru",
table.border.bottom.color = "peru"
) %>%
gt::tab_style(
style = gt::cell_text(color = "white"),
locations = gt::cells_title(groups = "title")
) %>%
gt::data_color(columns = BA, fn = scales::col_numeric(palette = c("#E1463E", "white", "#00840D"), domain = NULL)) %>%
gt::data_color(columns = SLG, fn = scales::col_numeric(palette = c("#E1463E", "white", "#00840D"), domain = NULL)) %>%
gt::data_color(
columns = `RV/100`,
fn = scales::col_numeric(
palette = c("#E1463E", "white", "#90EE90", "#00840D"),
domain = c(-3, 0, 2, 5)
)
) %>%
gt::data_color(columns = EV, fn = scales::col_numeric(palette = c("#E1463E", "white", "#00840D"), domain = NULL)) %>%
gt::data_color(columns = SC, fn = scales::col_numeric(palette = c("#E1463E", "white", "#00840D"), domain = NULL)) %>%
gt::data_color(columns = `10-30`, fn = scales::col_numeric(palette = c("#E1463E", "white", "#00840D"), domain = NULL)) %>%
gt::data_color(columns = `Whiff%`, fn = scales::col_numeric(palette = c("#00840D", "white", "#E1463E"), domain = NULL)) %>%
gt::data_color(columns = `Chase%`, fn = scales::col_numeric(palette = c("#00840D", "white", "#E1463E"), domain = NULL)) %>%
gt::data_color(columns = `Z-Whiff`, fn = scales::col_numeric(palette = c("#00840D", "white", "#E1463E"), domain = NULL))
}
create_offspeed_splits_table <- function(player_name, team_data) {
df <- team_data %>%
filter(Batter == player_name,
!TaggedPitchType %in% c("Fastball", "Four-Seam", "Sinker", "Cutter", "Other")) %>%
calculate_rv100() %>%
mutate(
is_breaker = TaggedPitchType %in% c("Slider", "Curveball", "Sweeper"),
is_change_split = TaggedPitchType %in% c("ChangeUp", "Splitter"),
os_hard = is_breaker & RelSpeed >= 84,
os_soft = is_breaker & RelSpeed < 76,
os_gyro = InducedVertBreak >= -3 & InducedVertBreak <= 3 & HorzBreak >= -4 & HorzBreak <= 4,
os_downer = is_breaker & InducedVertBreak <= -8,
os_sweep = is_breaker & abs(HorzBreak) >= 12
)
if (!nrow(df)) {
return(gt::gt(data.frame(Note = "No offspeed data")))
}
calc_stats <- function(data) {
data %>%
summarise(
P = n(),
`RV` = sum(hitter_rv, na.rm = TRUE),
`RV/100` = 100 * mean(hitter_rv, na.rm = TRUE),
BA = sum(HitIndicator, na.rm = TRUE) / sum(ABindicator, na.rm = TRUE),
SLG = sum(totalbases, na.rm = TRUE) / sum(ABindicator, na.rm = TRUE),
`Whiff%` = sum(WhiffIndicator, na.rm = TRUE) / sum(SwingIndicator, na.rm = TRUE) * 100,
`Chase%` = sum(Chaseindicator, na.rm = TRUE) / sum(OutofZone, na.rm = TRUE) * 100,
`Z-Whiff` = sum(Zwhiffind, na.rm = TRUE) / sum(Zswing, na.rm = TRUE) * 100,
EV = mean(ExitSpeed[PitchCall == "InPlay"], na.rm = TRUE),
SC = sum(SCind, na.rm = TRUE) / sum(BIPind, na.rm = TRUE) * 100,
`10-30` = sum(LA1030ind, na.rm = TRUE) / sum(BIPind, na.rm = TRUE) * 100,
`CalledStrike%` = sum(PitchCall == "StrikeCalled", na.rm = TRUE) / n() * 100
)
}
all_os <- calc_stats(df) %>% mutate(` ` = "All Offspeed")
rh_break <- calc_stats(df %>% filter(is_breaker, PitcherThrows == "Right")) %>% mutate(` ` = "RH Break")
lh_break <- calc_stats(df %>% filter(is_breaker, PitcherThrows == "Left")) %>% mutate(` ` = "LH Break")
rh_ch <- calc_stats(df %>% filter(is_change_split, PitcherThrows == "Right")) %>% mutate(` ` = "RH CH/SPL")
lh_ch <- calc_stats(df %>% filter(is_change_split, PitcherThrows == "Left")) %>% mutate(` ` = "LH CH/SPL")
sl <- calc_stats(df %>% filter(TaggedPitchType == "Slider")) %>% mutate(` ` = "SL")
cb <- calc_stats(df %>% filter(TaggedPitchType == "Curveball")) %>% mutate(` ` = "CB")
hard <- calc_stats(df %>% filter(os_hard)) %>% mutate(` ` = "Hard (≥84)")
soft <- calc_stats(df %>% filter(os_soft)) %>% mutate(` ` = "Soft (<76)")
gyro <- calc_stats(df %>% filter(os_gyro)) %>% mutate(` ` = "Gyro")
downer <- calc_stats(df %>% filter(os_downer)) %>% mutate(` ` = "Downer")
sweep <- calc_stats(df %>% filter(os_sweep)) %>% mutate(` ` = "Sweep (12+ HB)")
final_table <- bind_rows(all_os, rh_break, lh_break, rh_ch, lh_ch, sl, cb, hard, soft, gyro, downer, sweep) %>%
select(` `, everything()) %>%
# Ensure all numeric columns are numeric
mutate(across(c(P, RV, `RV/100`, BA, SLG, `Whiff%`, `Chase%`, `Z-Whiff`, EV, SC, `10-30`, `CalledStrike%`), as.numeric))
final_table %>%
gt::gt() %>%
gtExtras::gt_theme_guardian() %>%
gt::tab_header(title = "Offspeed Splits") %>%
gt::fmt_number(columns = c(BA, SLG), decimals = 3) %>%
gt::fmt_number(columns = c(`RV`, `RV/100`, `Whiff%`, `Chase%`, `Z-Whiff`, EV, SC, `10-30`, `CalledStrike%`), decimals = 1) %>%
gt::cols_align(align = "center", columns = gt::everything()) %>%
gt::sub_missing(columns = gt::everything(), missing_text = "-") %>%
gt::tab_options(
heading.background.color = "darkcyan",
column_labels.background.color = "darkcyan",
table.border.top.color = "peru",
table.border.bottom.color = "peru"
) %>%
gt::tab_style(
style = gt::cell_text(color = "white"),
locations = gt::cells_title(groups = "title")
) %>%
gt::data_color(columns = BA, fn = scales::col_numeric(palette = c("#E1463E", "white", "#00840D"), domain = NULL)) %>%
gt::data_color(columns = SLG, fn = scales::col_numeric(palette = c("#E1463E", "white", "#00840D"), domain = NULL)) %>%
gt::data_color(
columns = `RV/100`,
fn = scales::col_numeric(
palette = c("#E1463E", "white", "#90EE90", "#00840D"),
domain = c(-3, 0, 2, 5)
)
) %>%
gt::data_color(columns = EV, fn = scales::col_numeric(palette = c("#E1463E", "white", "#00840D"), domain = NULL)) %>%
gt::data_color(columns = SC, fn = scales::col_numeric(palette = c("#E1463E", "white", "#00840D"), domain = NULL)) %>%
gt::data_color(columns = `10-30`, fn = scales::col_numeric(palette = c("#E1463E", "white", "#00840D"), domain = NULL)) %>%
gt::data_color(columns = `Whiff%`, fn = scales::col_numeric(palette = c("#00840D", "white", "#E1463E"), domain = NULL)) %>%
gt::data_color(columns = `Chase%`, fn = scales::col_numeric(palette = c("#00840D", "white", "#E1463E"), domain = NULL)) %>%
gt::data_color(columns = `Z-Whiff`, fn = scales::col_numeric(palette = c("#00840D", "white", "#E1463E"), domain = NULL))
}
create_spray_chart_plotly <- function(batter_name, team_data) {
chart_data <- team_data %>%
filter(Batter == batter_name, !is.na(Distance), !is.na(Bearing), PitchCall == "InPlay") %>%
mutate(
event_type = case_when(
PlayResult == "Single" ~ "1B",
PlayResult == "Double" ~ "2B",
PlayResult == "Triple" ~ "3B",
PlayResult == "HomeRun" ~ "HR",
PlayResult %in% c("Error", "FieldersChoice") ~ "ROE/FC",
TRUE ~ "OUT"
),
Bearing2 = Bearing * pi / 180,
x = Distance * sin(Bearing2),
y = Distance * cos(Bearing2),
hover_text = paste0(
"", event_type, "
",
"Date: ", format(Date, "%m/%d/%y"), "
",
"EV: ", ifelse(!is.na(ExitSpeed), paste0(round(ExitSpeed, 1), " mph"), "N/A"), "
",
"LA: ", ifelse(!is.na(Angle), paste0(round(Angle, 1), "°"), "N/A"), "
",
"Dist: ", ifelse(!is.na(Distance), paste0(round(Distance), " ft"), "N/A"), "
",
"xBA: ", ifelse(!is.na(xBA), sprintf("%.3f", xBA), "N/A"), "
",
"vs ", PitcherThrows, "HP"
)
)
if (!nrow(chart_data)) {
return(plotly::plot_ly() %>%
plotly::layout(title = paste("No hit data for", batter_name)))
}
event_colors <- c("1B" = "#E41A1C", "2B" = "#FFD700", "3B" = "#CD853F",
"HR" = "#008B8B", "ROE/FC" = "#FF8C00", "OUT" = "#A9A9A9")
# Field geometry
foul_line_left <- data.frame(x = c(0, -247.487), y = c(0, 247.487))
foul_line_right <- data.frame(x = c(0, 247.487), y = c(0, 247.487))
infield <- data.frame(x = c(0, 63.6396, 0, -63.6396, 0), y = c(0, 63.6396, 127.279, 63.6396, 0))
theta <- seq(-pi/4, pi/4, length.out = 50)
outfield_arc <- data.frame(x = 350 * sin(theta), y = 350 * cos(theta))
grass_arc <- data.frame(x = 160 * sin(theta), y = 160 * cos(theta))
p <- plot_ly() %>%
add_trace(data = foul_line_left, x = ~x, y = ~y, type = 'scatter', mode = 'lines',
line = list(color = 'black', width = 1), showlegend = FALSE, hoverinfo = 'none') %>%
add_trace(data = foul_line_right, x = ~x, y = ~y, type = 'scatter', mode = 'lines',
line = list(color = 'black', width = 1), showlegend = FALSE, hoverinfo = 'none') %>%
add_trace(data = infield, x = ~x, y = ~y, type = 'scatter', mode = 'lines',
line = list(color = 'black', width = 1), showlegend = FALSE, hoverinfo = 'none') %>%
add_trace(data = outfield_arc, x = ~x, y = ~y, type = 'scatter', mode = 'lines',
line = list(color = 'black', width = 0.5), showlegend = FALSE, hoverinfo = 'none') %>%
add_trace(data = grass_arc, x = ~x, y = ~y, type = 'scatter', mode = 'lines',
line = list(color = 'black', width = 0.5, dash = 'dot'), showlegend = FALSE, hoverinfo = 'none')
for (evt in unique(chart_data$event_type)) {
evt_data <- chart_data %>% filter(event_type == evt)
p <- p %>%
add_trace(
data = evt_data, x = ~x, y = ~y,
type = 'scatter', mode = 'markers',
marker = list(size = 10, color = event_colors[evt], line = list(color = 'black', width = 1)),
text = ~hover_text, hoverinfo = 'text',
name = paste0(evt, " (", nrow(evt_data), ")")
)
}
p %>%
layout(
title = list(text = paste("Spray Chart:", batter_name), x = 0.5),
xaxis = list(title = "", showgrid = FALSE, zeroline = FALSE, showticklabels = FALSE,
scaleanchor = "y", scaleratio = 1, range = c(-350, 350)),
yaxis = list(title = "", showgrid = FALSE, zeroline = FALSE, showticklabels = FALSE, range = c(-20, 435)),
legend = list(orientation = "h", x = 0.5, xanchor = "center", y = -0.05),
plot_bgcolor = 'white', paper_bgcolor = 'white', hovermode = 'closest'
)
}
create_schill_basic_spray <- function(batter_name, team_data) {
# -------------------------------------------------------------------
# 1. Filter and prep base data
# -------------------------------------------------------------------
chart_data <- team_data %>%
dplyr::filter(
Batter == batter_name,
!is.na(Distance),
!is.na(Bearing),
PitchCall == "InPlay",
PitcherThrows %in% c("Right", "Left")
) %>%
dplyr::mutate(
event_type = dplyr::case_when(
PlayResult == "Single" ~ "1B",
PlayResult == "Double" ~ "2B",
PlayResult == "Triple" ~ "3B",
PlayResult == "HomeRun" ~ "HR",
PlayResult %in% c("Error", "FieldersChoice") ~ "ROE",
TRUE ~ "OUT"
),
Bearing2 = Bearing * pi / 180,
x = Distance * sin(Bearing2),
y = Distance * cos(Bearing2),
hand_label = ifelse(PitcherThrows == "Right", "vs RHP", "vs LHP"),
# Ground ball definition (keep as you had it)
is_groundball = (!is.na(Angle) & Angle < 10) |
(Distance < 150 & (is.na(Angle) | Angle < 15)),
# ----------------------------------------------------------------
# 6 spray zones (LF line → RF line) – similar to first chart
# angles in degrees, 0 = CF, negative = LF, positive = RF
# ----------------------------------------------------------------
zone = dplyr::case_when(
Bearing < -30 ~ "LF Line",
Bearing >= -30 & Bearing < -15 ~ "LF Gap",
Bearing >= -15 & Bearing < 0 ~ "Left-CF",
Bearing >= 0 & Bearing < 15 ~ "Right-CF",
Bearing >= 15 & Bearing < 30 ~ "RF Gap",
Bearing >= 30 ~ "RF Line",
TRUE ~ "Other"
)
)
if (!nrow(chart_data)) {
return(
ggplot2::ggplot() +
ggplot2::theme_void() +
ggplot2::ggtitle(paste("No hit data for", batter_name)) +
ggplot2::theme(
plot.title = ggplot2::element_text(hjust = 0.5, size = 14, face = "bold")
)
)
}
# -------------------------------------------------------------------
# 2. Ground-ball subset
# -------------------------------------------------------------------
gb_data <- chart_data %>% dplyr::filter(is_groundball)
if (nrow(gb_data) == 0) {
gb_data <- chart_data %>%
dplyr::slice(0) %>%
dplyr::mutate(is_groundball = TRUE)
}
# -------------------------------------------------------------------
# 3. Zone percentages by hand (fixed math)
# -------------------------------------------------------------------
zone_levels <- c("LF Line", "LF Gap", "Left-CF",
"Right-CF", "RF Gap", "RF Line")
zone_stats <- gb_data %>%
dplyr::mutate(zone = factor(zone, levels = zone_levels)) %>%
dplyr::filter(!is.na(zone)) %>%
dplyr::group_by(hand_label) %>%
dplyr::mutate(total_gb = dplyr::n()) %>%
dplyr::ungroup() %>%
dplyr::count(hand_label, zone, total_gb, name = "count") %>%
dplyr::group_by(hand_label) %>%
dplyr::mutate(
pct = round(100 * count / total_gb)
) %>%
dplyr::ungroup()
# -------------------------------------------------------------------
# 4. Label positions – all on one horizontal band outside infield
# -------------------------------------------------------------------
zone_positions <- data.frame(
zone = factor(zone_levels, levels = zone_levels),
x_pos = c(-130, -80, -35, 35, 80, 130),
y_pos = 135 # same y for all labels → straight line above infield
)
zone_labels <- dplyr::inner_join(zone_stats, zone_positions, by = "zone")
# -------------------------------------------------------------------
# 5. Colors and totals
# -------------------------------------------------------------------
event_colors <- c("1B" = "#228B22",
"2B" = "#0000CD",
"3B" = "#CD853F",
"HR" = "#008B8B",
"ROE" = "#FFD700",
"OUT" = "#808080")
gb_totals <- gb_data %>%
dplyr::group_by(hand_label) %>%
dplyr::summarise(total = dplyr::n(), .groups = "drop") %>%
dplyr::mutate(label = paste0("GB: ", total))
full_totals <- chart_data %>%
dplyr::group_by(hand_label) %>%
dplyr::summarise(
BIP = dplyr::n(),
Hits = sum(event_type %in% c("1B", "2B", "3B", "HR")),
BA = round(Hits / BIP, 3),
.groups = "drop"
) %>%
dplyr::mutate(
label = paste0("BIP: ", BIP, " | BA: ", sprintf('%.3f', BA))
)
p_top <- ggplot(gb_data, aes(x, y)) +
# Infield diamond
geom_segment(aes(x = 0, y = 0, xend = 63.6396, yend = 63.6396), color = "black", linewidth = 0.8) +
geom_segment(aes(x = 0, y = 0, xend = -63.6396, yend = 63.6396), color = "black", linewidth = 0.8) +
geom_segment(aes(x = 63.6396, y = 63.6396, xend = 0, yend = 127.279), color = "black", linewidth = 0.8) +
geom_segment(aes(x = -63.6396, y = 63.6396, xend = 0, yend = 127.279), color = "black", linewidth = 0.8) +
# Extended foul lines
geom_segment(aes(x = 0, y = 0, xend = -150, yend = 150), color = "gray50", linewidth = 0.5) +
geom_segment(aes(x = 0, y = 0, xend = 150, yend = 150), color = "gray50", linewidth = 0.5) +
# Infield grass arc
annotate("curve", x = -113.14, y = 113.14, xend = 113.14, yend = 113.14,
curvature = -0.45, linewidth = 0.5, color = "gray50", linetype = "dashed") +
geom_segment(
data = gb_data,
aes(x = 0, y = 0, xend = x, yend = y, color = event_type),
inherit.aes = FALSE,
linetype = "dashed",
alpha = 0.35,
linewidth = 0.3
) +
# Data points
geom_point(aes(fill = event_type),
shape = 21, size = 3, color = "black", stroke = 0.5) +
# Zone labels
geom_label(data = zone_labels,
aes(x = x_pos, y = y_pos, label = paste0(pct, "%")),
inherit.aes = FALSE, size = 4, fontface = "bold",
fill = "white", alpha = 0.8, label.size = 0) +
# GB total label
geom_text(data = gb_totals,
aes(x = 0, y = -10, label = label),
inherit.aes = FALSE, size = 3.5, fontface = "bold") +
scale_fill_manual(values = event_colors, name = "Result") +
scale_color_manual(values = event_colors, guide = "none") + # match trails to points
facet_wrap(~hand_label, ncol = 2) +
coord_fixed(xlim = c(-160, 160), ylim = c(-20, 165)) +
theme_void() +
theme(
legend.position = "none",
strip.text = element_text(size = 12, face = "bold", color = "darkred"),
plot.title = element_text(hjust = 0.5, size = 12, face = "bold")
) +
labs(title = "Ground Balls - Zone Distribution")
# -------------------------------------------------------------------
# 7. BOTTOM – full field spray (unchanged)
# -------------------------------------------------------------------
p_bottom <- ggplot2::ggplot(chart_data, ggplot2::aes(x, y)) +
ggplot2::geom_segment(ggplot2::aes(x = 0, y = 0, xend = 247.487, yend = 247.487),
color = "black") +
ggplot2::geom_segment(ggplot2::aes(x = 0, y = 0, xend = -247.487, yend = 247.487),
color = "black") +
ggplot2::geom_segment(ggplot2::aes(x = 63.6396, y = 63.6396, xend = 0, yend = 127.279),
color = "black") +
ggplot2::geom_segment(ggplot2::aes(x = -63.6396, y = 63.6396, xend = 0, yend = 127.279),
color = "black") +
ggplot2::annotate("curve",
x = -247.487, y = 247.487,
xend = 247.487, yend = 247.487,
curvature = -0.65, linewidth = 0.5,
color = "black") +
ggplot2::geom_segment(
ggplot2::aes(xend = 0, yend = 0, color = event_type),
linetype = "dashed", alpha = 0.4, linewidth = 0.3
) +
ggplot2::geom_point(
ggplot2::aes(fill = event_type),
shape = 21, size = 3.5, color = "black", stroke = 0.5
) +
ggplot2::geom_text(
data = full_totals,
ggplot2::aes(x = 0, y = -20, label = label),
inherit.aes = FALSE,
size = 3.5,
fontface = "bold"
) +
ggplot2::scale_fill_manual(values = event_colors, name = "Result") +
ggplot2::scale_color_manual(values = event_colors, guide = "none") +
ggplot2::facet_wrap(~hand_label, ncol = 2) +
ggplot2::coord_fixed(xlim = c(-280, 280), ylim = c(-40, 380)) +
ggplot2::theme_void() +
ggplot2::theme(
legend.position = "bottom",
strip.text = ggplot2::element_text(size = 12, face = "bold", color = "darkred"),
plot.title = ggplot2::element_text(hjust = 0.5, size = 12, face = "bold"),
legend.title = ggplot2::element_blank()
) +
ggplot2::labs(title = "Full Field Spray Chart")
# -------------------------------------------------------------------
# 8. Combine
# -------------------------------------------------------------------
gridExtra::grid.arrange(
p_top,
p_bottom,
nrow = 2,
heights = c(1, 1.2),
top = grid::textGrob(
paste("Schill Style Spray:", batter_name),
gp = grid::gpar(fontsize = 16, font = 2)
)
)
}
create_sector_spray_chart <- function(batter_name, team_data) {
chart_data <- team_data %>%
filter(
Batter == batter_name,
!is.na(Distance),
!is.na(Bearing),
PitchCall == "InPlay"
) %>%
mutate(
Bearing2 = Bearing * pi / 180,
x = Distance * sin(Bearing2),
y = Distance * cos(Bearing2)
)
# 5 angle sectors from -45 to +45 (equal width)
# boundaries: -45, -27, -9, 9, 27, 45
angle_breaks_deg <- seq(-45, 45, length.out = 6)
angle_breaks_rad <- angle_breaks_deg * pi / 180
chart_data <- chart_data %>%
mutate(
angle_sector = case_when(
Bearing >= angle_breaks_deg[1] & Bearing < angle_breaks_deg[2] ~ 1L,
Bearing >= angle_breaks_deg[2] & Bearing < angle_breaks_deg[3] ~ 2L,
Bearing >= angle_breaks_deg[3] & Bearing < angle_breaks_deg[4] ~ 3L,
Bearing >= angle_breaks_deg[4] & Bearing < angle_breaks_deg[5] ~ 4L,
Bearing >= angle_breaks_deg[5] & Bearing < angle_breaks_deg[6] ~ 5L,
TRUE ~ NA_integer_
),
# 2 distance rings: infield (<150), outfield (>=150)
dist_sector = case_when(
Distance < 150 ~ 1L, # infield
Distance >= 150 ~ 2L, # outfield
TRUE ~ NA_integer_
)
) %>%
# Only keep balls that fall in our 10 sectors
filter(!is.na(angle_sector), !is.na(dist_sector))
if (!nrow(chart_data)) {
return(
ggplot() +
theme_void() +
ggtitle(paste("No hit data for", batter_name))
)
}
# Denominator = all BIP inside our 10 sectors
total_bip <- nrow(chart_data)
# ----------------------------------------------------------
# 2. Sector stats (percentage of total BIP)
# ----------------------------------------------------------
sector_stats <- chart_data %>%
group_by(angle_sector, dist_sector) %>%
summarise(count = n(), .groups = "drop") %>%
mutate(pct = round(100 * count / total_bip))
# ----------------------------------------------------------
# 3. Create 5×2 sector polygons
# ----------------------------------------------------------
create_sector <- function(r_inner, r_outer, theta_start, theta_end, n = 40) {
theta_seq <- seq(theta_start, theta_end, length.out = n)
inner_x <- r_inner * sin(theta_seq)
inner_y <- r_inner * cos(theta_seq)
outer_x <- r_outer * sin(rev(theta_seq))
outer_y <- r_outer * cos(rev(theta_seq))
data.frame(x = c(inner_x, outer_x), y = c(inner_y, outer_y))
}
# 2 rings: 0–150 (infield), 150–400 (outfield)
dist_breaks <- c(0, 150, 400)
sectors <- list()
sector_id <- 1
for (i in 1:5) { # angle index
for (j in 1:2) { # distance index
poly <- create_sector(
dist_breaks[j], dist_breaks[j + 1],
angle_breaks_rad[i], angle_breaks_rad[i + 1]
)
poly$sector_id <- sector_id
poly$angle_idx <- i
poly$dist_idx <- j
sectors[[sector_id]] <- poly
sector_id <- sector_id + 1
}
}
sector_df <- bind_rows(sectors)
# ----------------------------------------------------------
# 4. Sector centers & merge stats
# ----------------------------------------------------------
sector_centers <- sector_df %>%
group_by(sector_id, angle_idx, dist_idx) %>%
summarise(cx = mean(x), cy = mean(y), .groups = "drop") %>%
left_join(
sector_stats %>%
rename(angle_idx = angle_sector, dist_idx = dist_sector),
by = c("angle_idx", "dist_idx")
)
sector_df <- sector_df %>%
left_join(sector_centers %>% select(sector_id, pct, count),
by = "sector_id")
# ----------------------------------------------------------
# 5. Plot 5 OF + 5 IF sectors
# ----------------------------------------------------------
ggplot() +
geom_polygon(
data = sector_df,
aes(x = x, y = y, group = sector_id, fill = pct),
color = "black",
linewidth = 0.5
) +
geom_text(
data = sector_centers %>% filter(!is.na(pct) & pct > 0),
aes(x = cx, y = cy, label = paste0(pct, "%")),
size = 4,
fontface = "bold"
) +
# foul lines
geom_segment(aes(x = 0, y = 0, xend = 247.487, yend = 247.487),
color = "black", linewidth = 1) +
geom_segment(aes(x = 0, y = 0, xend = -247.487, yend = 247.487),
color = "black", linewidth = 1) +
annotate(
"text",
x = 260, y = 360,
label = paste("BIP:", total_bip),
size = 4, hjust = 0, fontface = "bold"
) +
scale_fill_gradientn(
colors = c(
"white", # 0%
"#e5fbe5", # ~2%
"#90EE90", # ~5%
"#66c266", # ~10%
"#228B22", # ~15%
"#145a14", # ~20%
"#006400" # ~25%+
),
values = scales::rescale(c(0, 3, 5, 10, 15, 20, 25)),
limits = c(0, NA),
na.value = "white",
name = "% of BIP"
) +
coord_fixed(xlim = c(-320, 320), ylim = c(-20, 420)) +
theme_void() +
theme(
legend.position = "right",
plot.title = element_text(hjust = 0.5, size = 16, face = "bold")
) +
labs(title = paste("Sector Spray Chart:", batter_name))
}
# 4. TRAJECTORY SPRAY CHART
create_trajectory_spray_chart <- function(batter_name, team_data) {
chart_data <- team_data %>%
filter(Batter == batter_name, !is.na(Distance), !is.na(Bearing), PitchCall == "InPlay") %>%
mutate(
event_type = case_when(
PlayResult == "Single" ~ "1B",
PlayResult == "Double" ~ "2B",
PlayResult == "Triple" ~ "3B",
PlayResult == "HomeRun" ~ "HR",
PlayResult %in% c("Error", "FieldersChoice") ~ "ROE",
TRUE ~ "OUT"
),
is_hit = event_type %in% c("1B", "2B", "3B", "HR"),
Bearing2 = Bearing * pi / 180,
x = Distance * sin(Bearing2),
y = Distance * cos(Bearing2),
row_id = row_number()
)
if (!nrow(chart_data)) {
return(ggplot() + theme_void() + ggtitle(paste("No hit data for", batter_name)))
}
# Create curves
create_curve_df <- function(x_end, y_end, is_hit, id, n = 20) {
t <- seq(0, 1, length.out = n)
arc_height <- if (is_hit) 0.35 else 0.15
cx <- x_end * 0.5
cy <- y_end * 0.5 + sqrt(x_end^2 + y_end^2) * arc_height
x <- (1-t)^2 * 0 + 2*(1-t)*t * cx + t^2 * x_end
y <- (1-t)^2 * 0 + 2*(1-t)*t * cy + t^2 * y_end
data.frame(x = x, y = y, traj_id = id)
}
trajectories <- do.call(rbind, lapply(1:nrow(chart_data), function(i) {
row <- chart_data[i, ]
df <- create_curve_df(row$x, row$y, row$is_hit, row$row_id)
df$event_type <- row$event_type
df
}))
hit_colors <- c("1B" = "#E41A1C", "2B" = "#377EB8", "3B" = "#4DAF4A",
"HR" = "#984EA3", "ROE" = "#FF7F00", "OUT" = "#999999")
ggplot() +
annotate("polygon", x = c(0, -350, -350, 350, 350, 0), y = c(0, 350, 420, 420, 350, 0),
fill = "#90EE90", alpha = 0.3) +
annotate("polygon", x = c(0, -90, -90, 90, 90, 0), y = c(0, 90, 160, 160, 90, 0),
fill = "#D2B48C", alpha = 0.4) +
geom_segment(aes(x = 0, y = 0, xend = 320, yend = 320), color = "black", linewidth = 1) +
geom_segment(aes(x = 0, y = 0, xend = -320, yend = 320), color = "black", linewidth = 1) +
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") +
annotate("point", x = 0, y = 0, shape = 23, size = 4, fill = "white", color = "black") +
geom_path(data = trajectories, aes(x = x, y = y, group = traj_id, color = event_type),
linewidth = 0.6, alpha = 0.7) +
geom_point(data = chart_data, aes(x = x, y = y, fill = event_type),
shape = 21, size = 4, color = "black", stroke = 0.5) +
scale_color_manual(values = hit_colors, guide = "none") +
scale_fill_manual(values = hit_colors, name = "Result") +
coord_fixed(xlim = c(-350, 350), ylim = c(-30, 420)) +
theme_void() +
theme(legend.position = "right", plot.title = element_text(hjust = 0.5, size = 16, face = "bold"),
panel.background = element_rect(fill = "white", color = NA)) +
labs(title = paste("Trajectory Spray Chart:", batter_name))
}
# 5. TWO-STRIKE SPRAY CHART
create_2k_spray_chart <- function(batter_name, team_data) {
chart_data <- team_data %>%
filter(Batter == batter_name, !is.na(Distance), !is.na(Bearing), PitchCall == "InPlay") %>%
mutate(
count_situation = ifelse(Strikes == 2, "2 Strikes", "0-1 Strikes"),
event_type = case_when(
PlayResult == "Single" ~ "1B",
PlayResult == "Double" ~ "2B",
PlayResult == "Triple" ~ "3B",
PlayResult == "HomeRun" ~ "HR",
PlayResult %in% c("Error", "FieldersChoice") ~ "ROE",
TRUE ~ "OUT"
),
Bearing2 = Bearing * pi / 180,
x = Distance * sin(Bearing2),
y = Distance * cos(Bearing2)
)
if (!nrow(chart_data)) {
return(ggplot() + theme_void() + ggtitle(paste("No hit data for", batter_name)))
}
situation_stats <- chart_data %>%
group_by(count_situation) %>%
summarize(
BIP = n(), Hits = sum(event_type %in% c("1B", "2B", "3B", "HR")),
BA = round(Hits / BIP, 3), Avg_EV = round(mean(ExitSpeed, na.rm = TRUE), 1), .groups = "drop"
) %>%
mutate(label = paste0("BIP: ", BIP, " | BA: ", sprintf("%.3f", BA), " | EV: ", Avg_EV))
event_colors <- c("1B" = "#228B22", "2B" = "#0000CD", "3B" = "#CD853F",
"HR" = "#008B8B", "ROE" = "#FFD700", "OUT" = "#808080")
ggplot(chart_data, aes(x, y)) +
geom_segment(aes(x = 0, y = 0, xend = 247.487, yend = 247.487), color = "black") +
geom_segment(aes(x = 0, y = 0, xend = -247.487, yend = 247.487), 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") +
annotate("curve", x = -247.487, y = 247.487, xend = 247.487, yend = 247.487,
curvature = -0.65, linewidth = 0.5, color = "black") +
geom_segment(aes(xend = 0, yend = 0, color = event_type), linetype = "dashed", alpha = 0.3, linewidth = 0.3) +
geom_point(aes(fill = event_type), shape = 21, size = 3, color = "black", stroke = 0.5) +
geom_text(data = situation_stats, aes(x = 0, y = -15, label = label), size = 3.5, fontface = "bold") +
scale_fill_manual(values = event_colors, name = "Result") +
scale_color_manual(values = event_colors, guide = "none") +
facet_wrap(~count_situation, ncol = 2) +
coord_fixed(xlim = c(-280, 280), ylim = c(-40, 380)) +
theme_void() +
theme(legend.position = "bottom", strip.text = element_text(size = 14, face = "bold"),
plot.title = element_text(hjust = 0.5, size = 16, face = "bold")) +
labs(title = paste("2-Strike Spray Chart:", batter_name))
}
# At-Bat breakdown for Game View
create_at_bats_plot <- function(data, player, game_key, pitch_colors = NULL) {
df <- data %>% filter(Batter == player)
if ("game" %in% names(df)) {
df <- df %>% filter(game == game_key)
} else if ("Date" %in% names(df)) {
df <- df %>% mutate(.game = format(Date, "%Y-%m-%d")) %>% filter(.game == game_key)
}
plot_data <- df %>%
dplyr::filter(!is.na(TaggedPitchType), TaggedPitchType != "Other") %>%
dplyr::arrange(Date, Inning, PAofInning, PitchofPA) %>%
dplyr::group_by(Date, Inning, PAofInning) %>%
dplyr::mutate(pa_number = dplyr::cur_group_id()) %>%
dplyr::ungroup() %>%
dplyr::mutate(
PitchCall_display = dplyr::case_when(
PitchCall == "StrikeSwinging" ~ "Whiff",
PitchCall == "InPlay" ~ "In Play",
PitchCall %in% c("FoulBall", "FoulBallNotFieldable", "FoulBallFieldable") ~ "Foul",
PitchCall %in% c("BallCalled", "BallinDirt", "BallIntentional") ~ "Ball",
PitchCall == "StrikeCalled" ~ "CS",
PitchCall == "HitByPitch" ~ "HBP",
TRUE ~ PitchCall
)
)
if (!nrow(plot_data)) {
return(ggplot() + theme_void() +
ggtitle(paste("No PA data for", player, "in this game")) +
theme(plot.title = element_text(hjust = 0.5, size = 14, face = "bold")))
}
p <- ggplot2::ggplot(plot_data, ggplot2::aes(PlateLocSide, PlateLocHeight)) +
ggplot2::geom_rect(xmin = -0.95, xmax = 0.95, ymin = 1.6, ymax = 3.5,
fill = NA, color = "black", linewidth = 1) +
ggplot2::geom_point(ggplot2::aes(fill = TaggedPitchType),
alpha = 1, shape = 21, color = "black", stroke = 0.5, size = 6) +
ggplot2::geom_text(ggplot2::aes(label = PitchofPA),
vjust = 0.5, size = 3.5, color = "white") +
ggplot2::geom_text(ggplot2::aes(x = 1.5, y = 4.2 - (PitchofPA * 0.15),
label = dplyr::if_else(PitchCall == "InPlay",
paste0(PitchofPA, ": ", PlayResult),
paste0(PitchofPA, ": ", PitchCall_display))),
inherit.aes = FALSE, size = 2.5, hjust = 0) +
ggplot2::geom_text(ggplot2::aes(x = 1.5, y = 4.2 - (PitchofPA * 0.15) - 0.12,
label = dplyr::if_else(PitchCall == "InPlay" & !is.na(ExitSpeed),
paste0(round(ExitSpeed), " EV"), "")),
inherit.aes = FALSE, size = 2.5, hjust = 0) +
ggplot2::geom_text(ggplot2::aes(
x = dplyr::if_else(BatterSide == "Right", -1.5, 1.5),
y = 2.5,
label = dplyr::if_else(BatterSide == "Right", "R", "L")),
size = 4, fontface = "bold") +
ggplot2::facet_wrap(~ pa_number, ncol = 5) +
ggplot2::theme_void() +
ggplot2::scale_x_continuous("", limits = c(-2, 2.5)) +
ggplot2::scale_y_continuous("", limits = c(0.5, 4.5)) +
ggplot2::coord_fixed()
if (!is.null(pitch_colors)) {
p <- p + ggplot2::scale_fill_manual(values = pitch_colors, name = "Pitch Type")
} else {
p <- p + ggplot2::guides(fill = ggplot2::guide_legend(title = "Pitch Type"))
}
p + ggplot2::ggtitle(paste("At-Bat Breakdown:", player)) +
ggplot2::theme(
panel.background = ggplot2::element_rect(fill = "#ffffff"),
legend.position = "top",
plot.title = ggplot2::element_text(size = 14, hjust = 0.5, face = "bold"),
strip.text = ggplot2::element_text(size = 10, face = "bold"),
strip.placement = "outside",
strip.background = ggplot2::element_blank()
)
}
create_spray_chart <- function(batter_name, team_data) {
chart_data <- team_data %>%
filter(Batter == batter_name, !is.na(Distance), !is.na(Bearing), PitchCall == "InPlay") %>%
mutate(
event_type = case_when(
PlayResult == "Single" ~ "Single",
PlayResult == "Double" ~ "Double",
PlayResult == "Triple" ~ "Triple",
PlayResult == "HomeRun" ~ "Home Run",
PlayResult %in% c("Error","FieldersChoice") ~ "Error/FC",
TRUE ~ "Out"
),
Bearing2 = Bearing * pi/180, x = Distance*sin(Bearing2), y = Distance*cos(Bearing2)
)
if (!nrow(chart_data)) {
return(ggplot() + theme_void() + ggtitle(paste("No hit data for", batter_name)) +
theme(plot.title = element_text(hjust=0.5, size=14, face="bold")))
}
chart_data %>%
ggplot(aes(x,y, fill=event_type)) +
scale_fill_manual(values=c("Single"="red","Double"="gold","Triple"="peru","Home Run"="darkcyan",
"Error/FC"="orange","Out"="grey70"), name=NULL) +
coord_fixed() +
geom_segment(aes(x=0,y=0,xend=247.487,yend=247.487), color="black") +
geom_segment(aes(x=0,y=0,xend=-247.487,yend=247.487), 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_curve(aes(x=85.095,y=85.095,xend=0,yend=160), curvature=.36, linewidth=.5, color="black") +
geom_curve(aes(x=-85.095,y=85.095,xend=0,yend=160), curvature=-.36, linewidth=.5, color="black") +
geom_curve(aes(x=-247.487,y=247.487,xend=247.487,yend=247.487), curvature=-.65, linewidth=.5, color="black") +
geom_point(size=4, shape=21, color="black", stroke=.5) +
theme_void() + ylim(0,435) + ggtitle(paste("Spray Chart:", batter_name)) +
theme(legend.position="right", legend.title=element_blank(),
plot.title=element_text(hjust=0.5, size=14, face="bold"))
}
create_quality_table <- function(team_df, player) {
df <- team_df %>% filter(Batter == player)
Pitches <- nrow(df)
PA <- sum(df$PAindicator, na.rm = TRUE)
BBE <- sum(df$PitchCall == "InPlay", na.rm = TRUE)
safe_div <- function(num, den) ifelse(den > 0, num/den, NA_real_)
tibble::tibble(
Pitches = Pitches,
PA = PA,
`Batted Balls` = BBE,
`SwSpot%` = 100 * safe_div(sum(df$PitchCall == "InPlay" & df$Angle >= 8 & df$Angle <= 32, na.rm = TRUE),
sum(df$PitchCall == "InPlay", na.rm = TRUE)),
`Z-Con%` = 100 * (1 - mean(df$Zwhiffind, na.rm = TRUE))
)
}
create_player_header <- function(df, player) {
to_first_last <- function(x) sub("^\\s*([^,]+),\\s*(.+)\\s*$", "\\2 \\1", x)
norm <- function(x) { x <- ifelse(grepl(",", x), to_first_last(x), x); trimws(gsub("\\s+", " ", x)) }
row <- df %>% mutate(.key = norm(Batter)) %>% filter(.key == norm(player))
if (!nrow(row)) return(grid::textGrob(paste("No bio found for", player), gp=gpar(fontface="bold", cex=1.2)))
row <- row[1,]
safe_read_grob <- function(path_or_url) {
if (is.null(path_or_url) || is.na(path_or_url) || !nzchar(path_or_url)) return(nullGrob())
im <- try(magick::image_read(path_or_url), silent = TRUE)
if (inherits(im, "try-error")) im <- magick::image_blank(400, 400, color = "white")
rasterGrob(as.raster(im), interpolate = TRUE)
}
headshot_g <- safe_read_grob(row$Headshot)
state_g <- safe_read_grob(row$State)
if (!inherits(state_g, "grob")) state_g <- nullGrob()
state_g <- editGrob(state_g, gp = gpar(alpha = 0.55))
col_primary <- "#24384A"; col_muted <- "#6B7785"
player_name <- norm(row$Batter)
position <- if ("Position" %in% names(row)) {
ifelse(nzchar(row$Position), row$Position, "N/A")
} else {
"N/A"
}
jersey <- if ("Number" %in% names(row) && nzchar(row$Number)) {
paste0("# ", row$Number)
} else {
""
}
title_text <- paste(c(player_name, jersey, if (position != "N/A") paste("•", position) else NULL), collapse = " ")
title_g <- textGrob(title_text, x=0, y=unit(1,"npc"), just=c("left","top"), gp=gpar(fontface="bold", cex=2.2, col=col_primary))
val <- function(n) {
if (n %in% names(row) && nzchar(row[[n]])) row[[n]] else "N/A"
}
subline_g <- textGrob(paste(val("Class"), "•", val("Hometown")), x=0, just="left", gp=gpar(fontface="italic", cex=1.25, col=col_muted))
bats_throws <- textGrob(paste0("Bats: ", val("Bats"), " | Throws: ", val("Throws")), x=0, just="left", gp=gpar(cex=1.2, col=col_primary))
weight_text <- if (val("Weight") == "N/A") "N/A" else paste0(val("Weight"), " lbs")
phys_g <- textGrob(paste0(val("Height"), " • ", weight_text, " • Born: ", val("Birthday")),
x=0, just="left", gp=gpar(cex=1.2, col=col_primary))
text_block <- arrangeGrob(title_g, subline_g, bats_throws, phys_g, ncol=1,
heights=unit.c(unit(1.5,"lines"), unit(1.2,"lines"), unit(1.2,"lines"), unit(1.2,"lines")))
arrangeGrob(
grobs = list(grobTree(nullGrob(), headshot_g), text_block, grobTree(nullGrob(), state_g)),
ncol = 3,
widths = unit.c(unit(150,"pt"), unit(1,"null"), unit(300,"pt")),
heights = unit(160,"pt")
)
}
create_bb_profile_table <- function(team_df, player) {
safe_div <- function(num, den) ifelse(den > 0, num/den, NA_real_)
pct1 <- function(x) sprintf("%.1f", 100*x)
df <- team_df %>%
mutate(Family = case_when(
TaggedPitchType %in% c("Fastball","Four-Seam","Cutter","Sinker") ~ "Fastballs",
TaggedPitchType %in% c("Slider","Sweeper","Curveball") ~ "Breaking Balls",
TRUE ~ "Offspeed"
)) %>%
filter(Batter == player)
# Fallback if AutoHitType is missing; use TaggedHitType
if (!"AutoHitType" %in% names(df)) {
df <- df %>% mutate(AutoHitType = ifelse(!is.na(TaggedHitType), TaggedHitType, NA))
}
summarize_block <- function(x) {
BBE <- sum(x$PitchCall == "InPlay", na.rm = TRUE)
tibble::tibble(
BBE = BBE,
`GB%` = pct1(safe_div(sum(x$AutoHitType == "GroundBall" & x$PitchCall == "InPlay", na.rm = TRUE), BBE)),
`Air%` = pct1(1 - safe_div(sum(x$AutoHitType == "GroundBall" & x$PitchCall == "InPlay", na.rm = TRUE), BBE)),
`LD%` = pct1(safe_div(sum(x$AutoHitType == "LineDrive" & x$PitchCall == "InPlay", na.rm = TRUE), BBE)),
`FB%` = pct1(safe_div(sum(x$AutoHitType == "FlyBall" & x$PitchCall == "InPlay", na.rm = TRUE), BBE)),
`PU%` = pct1(safe_div(sum(x$AutoHitType == "Popup" & x$PitchCall == "InPlay", na.rm = TRUE), BBE)),
`Pull%` = pct1(safe_div(sum(ifelse(x$BatterSide == "Right", x$Bearing < -15, x$Bearing > 15) & x$PitchCall == "InPlay", na.rm = TRUE), BBE)),
`Straight%` = pct1(safe_div(sum(x$Bearing >= -15 & x$Bearing <= 15 & x$PitchCall == "InPlay", na.rm = TRUE), BBE)),
`Oppo%` = pct1(safe_div(sum(ifelse(x$BatterSide == "Right", x$Bearing > 15, x$Bearing < -15) & x$PitchCall == "InPlay", na.rm = TRUE), BBE))
)
}
fam <- df %>% group_by(Family) %>% group_modify(~summarize_block(.x)) %>% ungroup() %>% rename(` ` = Family)
overall <- summarize_block(df) %>% mutate(` ` = "-")
by_hand <- df %>% group_by(PitcherThrows) %>% group_modify(~summarize_block(.x)) %>% ungroup() %>% rename(` ` = PitcherThrows)
bind_rows(fam, overall, by_hand)
}
create_player_table_split <- function(player_name, team_data) {
tm_data <- team_data %>% filter(Batter == player_name)
if (!nrow(tm_data)) return(NULL)
formatted_name <- stringr::str_replace_all(player_name, "(\\w+), (\\w+)", "\\2 \\1")
handedness <- unique(tm_data$BatterSide)[1]
if (is.na(handedness)) handedness <- "Unknown"
calculate_stats <- function(data) {
data %>%
summarise(
AVG = ifelse(sum(ABindicator, na.rm = TRUE) > 0, sum(HitIndicator, na.rm = TRUE)/sum(ABindicator, na.rm = TRUE), NA),
OBP = ifelse(sum(PAindicator, na.rm = TRUE) > 0, sum(OnBaseindicator, na.rm = TRUE)/sum(PAindicator, na.rm = TRUE), NA),
SLG = ifelse(sum(ABindicator, na.rm = TRUE) > 0, sum(totalbases, na.rm = TRUE)/sum(ABindicator, na.rm = TRUE), NA),
OPS = ifelse(!is.na(OBP) & !is.na(SLG), OBP + SLG, NA),
CHASE = ifelse(sum(OutofZone, na.rm = TRUE) > 0, sum(Chaseindicator, na.rm = TRUE)/sum(OutofZone, na.rm = TRUE) * 100, NA),
WHIFF = ifelse(sum(SwingIndicator, na.rm = TRUE) > 0, sum(WhiffIndicator, na.rm = TRUE)/sum(SwingIndicator, na.rm = TRUE) * 100, NA),
AVG_EV = mean(ExitSpeed[PitchCall == "InPlay" & TaggedHitType != "Bunt"], na.rm = TRUE),
EV90 = stats::quantile(ExitSpeed[PitchCall == "InPlay"], 0.9, na.rm = TRUE),
HH = ifelse(sum(BIPind, na.rm = TRUE) > 0, sum(HHind, na.rm = TRUE)/sum(BIPind, na.rm = TRUE) * 100, NA),
GB = ifelse(sum(BIPind, na.rm = TRUE) > 0, sum(GBindicator, na.rm = TRUE)/sum(BIPind, na.rm = TRUE) * 100, NA),
LAUNCH_ANGLE = mean(Angle[PitchCall == "InPlay"], na.rm = TRUE),
FIRST_PITCH_SWING = ifelse(sum(FPindicator, na.rm = TRUE) > 0,
sum(FPindicator == 1 & SwingIndicator == 1, na.rm = TRUE)/sum(FPindicator, na.rm = TRUE) * 100, NA),
.groups = "drop"
)
}
create_single_table <- function(data, pitcher_hand) {
if (!nrow(data)) return(NULL)
pitch_stats <- data %>%
mutate(pitch_group = case_when(
TaggedPitchType %in% c("Fastball","Sinker") ~ "Fastballs",
TaggedPitchType %in% c("ChangeUp","Changeup","Splitter") ~ "Offspeed",
TaggedPitchType %in% c("Slider","Curveball","Cutter") ~ "Breaking Balls",
TRUE ~ "Other"
)) %>%
filter(pitch_group != "Other") %>%
group_by(pitch_group) %>% calculate_stats() %>% mutate(CATEGORY = pitch_group) %>%
select(CATEGORY, everything(), -pitch_group)
count_stats <- data %>%
mutate(count_situation = case_when(
Balls == 0 & Strikes == 0 ~ "First Pitch",
Balls > Strikes ~ "Hitter's Count",
Strikes > Balls ~ "Pitcher's Count",
Balls == Strikes ~ "Even Count",
TRUE ~ "Other"
)) %>%
filter(count_situation != "Other") %>%
group_by(count_situation) %>% calculate_stats() %>% mutate(CATEGORY = count_situation) %>%
select(CATEGORY, everything(), -count_situation)
overall_stats <- data %>% calculate_stats() %>% mutate(CATEGORY = "Overall")
all_stats <- bind_rows(pitch_stats, count_stats, overall_stats) %>%
arrange(factor(CATEGORY, levels = c("Breaking Balls","Fastballs","Offspeed",
"First Pitch","Hitter's Count","Pitcher's Count","Even Count","Overall"))) %>%
mutate(across(c(AVG,OBP,SLG,OPS,CHASE,WHIFF,AVG_EV,EV90,HH,GB,LAUNCH_ANGLE,FIRST_PITCH_SWING),
~ case_when(is.infinite(.x) ~ NA_real_, is.nan(.x) ~ NA_real_, TRUE ~ .x)))
overall_k_bb <- data %>%
summarise(
K_RATE = ifelse(sum(PAindicator, na.rm = TRUE) > 0,
sum(KorBB == "Strikeout", na.rm = TRUE)/sum(PAindicator, na.rm = TRUE) * 100, 0),
BB_RATE = ifelse(sum(PAindicator, na.rm = TRUE) > 0,
sum(WalkIndicator, na.rm = TRUE)/sum(PAindicator, na.rm = TRUE) * 100, 0),
TOTAL_PA = sum(PAindicator, na.rm = TRUE)
)
gt::gt(all_stats) %>%
gt::fmt_number(columns = c(AVG,OBP,SLG,OPS), decimals = 3) %>%
gt::fmt_number(columns = c(CHASE,WHIFF,AVG_EV,EV90,HH,GB,LAUNCH_ANGLE,FIRST_PITCH_SWING), decimals = 1) %>%
gt::tab_header(title = gt::md(paste0("**vs ", pitcher_hand, "HP**")),
subtitle = paste0("PAs: ", overall_k_bb$TOTAL_PA, " | K%: ", round(overall_k_bb$K_RATE,1),
"% | BB%: ", round(overall_k_bb$BB_RATE,1), "%")) %>%
gt::cols_label(CATEGORY="Category", AVG="AVG", OBP="OBP", SLG="SLG", OPS="OPS",
CHASE="Chase%", WHIFF="Whiff%", AVG_EV="Avg EV", EV90="90th%",
HH="HH%", GB="GB%", LAUNCH_ANGLE="LA", FIRST_PITCH_SWING="1st Pitch%") %>%
gt::tab_style(style = list(gt::cell_fill(color="#f8f9fa"), gt::cell_text(weight="bold")),
locations = gt::cells_body(rows = CATEGORY %in% c("Breaking Balls","Fastballs","Offspeed"))) %>%
gt::tab_style(style = list(gt::cell_fill(color="#f0f8e8"), gt::cell_text(weight="bold")),
locations = gt::cells_body(rows = CATEGORY %in% c("First Pitch","Hitter's Count","Pitcher's Count","Even Count"))) %>%
gt::tab_style(style = list(gt::cell_fill(color="#fff3cd"), gt::cell_text(weight="bold")),
locations = gt::cells_body(rows = CATEGORY == "Overall")) %>%
gt::cols_align(align="center", columns = gt::everything()) %>%
gt::sub_missing(columns = gt::everything(), missing_text = "-") %>%
gt::tab_options(table.font.size = gt::px(10), heading.title.font.size = gt::px(13),
heading.subtitle.font.size = gt::px(11), table.width = gt::pct(100),
column_labels.font.size = gt::px(10)) %>%
gt::data_color(columns = c(AVG,OBP,SLG,OPS,AVG_EV,EV90,HH,FIRST_PITCH_SWING),
colors = scales::col_numeric(palette = c("#E1463E","white","#00840D"), domain=NULL)) %>%
gt::data_color(columns = c(CHASE,WHIFF,GB),
colors = scales::col_numeric(palette = c("#00840D","white","#E1463E"), domain=NULL))
}
rhp_table <- if (nrow(filter(tm_data, PitcherThrows == "Right"))) {
create_single_table(filter(tm_data, PitcherThrows=="Right"), "R")
} else NULL
lhp_table <- if (nrow(filter(tm_data, PitcherThrows == "Left"))) {
create_single_table(filter(tm_data, PitcherThrows=="Left"), "L")
} else NULL
list(name = formatted_name, handedness = handedness, rhp_table = rhp_table, lhp_table = lhp_table)
}
create_bp_player_stats_table <- function(batter_name, bp_data) {
filtered <- bp_data %>%
dplyr::filter(Batter == batter_name)
bip_n <- sum(filtered$BIPind, na.rm = TRUE)
if (nrow(filtered) == 0 || bip_n == 0) {
return(
gt::gt(data.frame(Note = paste("No BP data for", batter_name)))
)
}
stats <- filtered %>%
dplyr::summarize(
BBE = bip_n,
`Avg EV` = round(mean(ExitSpeed[BIPind == 1], na.rm = TRUE), 1),
`Max EV` = round(max(ExitSpeed[BIPind == 1], na.rm = TRUE), 1),
`Avg LA` = round(mean(Angle[BIPind == 1], na.rm = TRUE), 1),
`SC%` = round(sum(SCind, na.rm = TRUE) / bip_n * 100, 1),
`10-30%` = round(sum(LA1030ind, na.rm = TRUE) / bip_n * 100, 1),
`HH%` = round(sum(HHind, na.rm = TRUE) / bip_n * 100, 1),
`Barrel%` = round(sum(Barrelind, na.rm = TRUE) / bip_n * 100, 1)
)
stats %>%
gt::gt() %>%
gtExtras::gt_theme_guardian() %>%
gt::tab_header(
title = paste("BP Stats:", batter_name)
) %>%
gt::cols_align(
align = "center",
columns = gt::everything()
) %>%
gt::tab_options(
heading.background.color = "darkcyan",
column_labels.background.color = "darkcyan"
)
}
result_shapes <- c(
"Ball" = 1, # open circle
"CS" = 5, # open diamond
"Foul" = 2, # open triangle
"HBP" = 10, # circled plus
"Other" = 16, # filled circle
"Out" = 4, # X
"Whiff" = 8, # asterisk
"In Play" = 15 # filled square
)
map_pitch_call_display_weekly <- function(pc, pr) {
dplyr::case_when(
pc == "StrikeSwinging" ~ "Whiff",
pc == "InPlay" & pr %in% c("Out","FieldersChoice","Error") ~ "Out",
pc == "InPlay" ~ "In Play",
pc %in% c("FoulBall","FoulBallNotFieldable","FoulBallFieldable") ~ "Foul",
pc %in% c("BallCalled","BallinDirt","BallIntentional") ~ "Ball",
pc == "StrikeCalled" ~ "CS",
pc == "HitByPitch" ~ "HBP",
TRUE ~ "Other"
)
}
# --- Compute summary stats for weekly report header ---
compute_weekly_game_stats <- function(batter_df) {
batter_df %>%
dplyr::summarise(
PA = sum(PAindicator, na.rm = TRUE),
H = sum(HitIndicator, na.rm = TRUE),
XBH = sum(PlayResult %in% c("Double","Triple","HomeRun"), na.rm = TRUE),
BB = sum(WalkIndicator, na.rm = TRUE),
K = sum(KorBB == "Strikeout", na.rm = TRUE),
Chase = sum(Chaseindicator, na.rm = TRUE),
Whiffs = sum(WhiffIndicator, na.rm = TRUE),
`IZ Whiffs` = sum(Zwhiffind, na.rm = TRUE),
BIP = sum(BIPind, na.rm = TRUE),
`Avg EV` = round(mean(ExitSpeed[PitchCall == "InPlay"], na.rm = TRUE), 1),
`Avg LA` = round(mean(Angle[PitchCall == "InPlay"], na.rm = TRUE), 1),
HH = sum(HHind, na.rm = TRUE),
.groups = "drop"
)
}
# --- Build stats header grob ---
create_stats_header_grob <- function(stats_df) {
stat_names <- names(stats_df)
stat_vals <- as.character(unlist(stats_df[1, ]))
stat_vals[stat_vals == "NaN"] <- "-"
n <- length(stat_names)
x_positions <- seq(0.02, 0.98, length.out = n)
label_grobs <- mapply(function(nm, val, xp) {
grid::grobTree(
grid::textGrob(nm, x = xp, y = 0.72, gp = grid::gpar(fontsize = 8, fontface = "bold", col = "gray30")),
grid::textGrob(val, x = xp, y = 0.28, gp = grid::gpar(fontsize = 11, fontface = "bold", col = "#006F71"))
)
}, stat_names, stat_vals, x_positions, SIMPLIFY = FALSE)
bg <- grid::rectGrob(gp = grid::gpar(fill = "#f0f0f0", col = "gray70", lwd = 1))
do.call(grid::grobTree, c(list(bg), label_grobs))
}
# --- Build legend grobs ---
create_shape_legend_grob <- function() {
shape_legend_df <- data.frame(
result = factor(names(result_shapes), levels = names(result_shapes)),
x = seq_along(result_shapes), y = 1
)
p <- ggplot(shape_legend_df, aes(x, y, shape = result)) +
geom_point(size = 3.5, stroke = 1.1) +
scale_shape_manual(values = result_shapes, name = "Result") +
theme_void() +
theme(legend.position = "left",
legend.text = element_text(size = 8),
legend.title = element_text(size = 9, face = "bold"))
cowplot::get_legend(p)
}
create_pitch_color_legend_grob <- function() {
display_pitches <- c(
"Fastball" = "#3465cb", "Sinker" = "#e5e501", "Slider" = "#65aa02",
"Sweeper" = "#dc4476", "Curveball" = "#d73813", "ChangeUp" = "#980099",
"Splitter" = "#23a999", "Cutter" = "#ff9903", "Slurve" = "#9370DB"
)
pt_df <- data.frame(pitch = names(display_pitches), x = seq_along(display_pitches), y = 1)
p <- ggplot(pt_df, aes(x, y, color = pitch)) +
geom_point(size = 3.5, shape = 16) +
scale_color_manual(values = display_pitches, name = "Pitch Type") +
theme_void() +
theme(legend.position = "left",
legend.text = element_text(size = 8),
legend.title = element_text(size = 9, face = "bold"))
cowplot::get_legend(p)
}
# --- Single-game AB panel for weekly report ---
create_weekly_ab_plot <- function(data, player, game_date, opponent_label = NULL) {
df <- data %>%
dplyr::filter(Batter == player, format(Date, "%Y-%m-%d") == game_date) %>%
dplyr::filter(!is.na(TaggedPitchType), TaggedPitchType != "Other") %>%
dplyr::arrange(Inning, PAofInning, PitchofPA) %>%
dplyr::group_by(Inning, PAofInning) %>%
dplyr::mutate(pa_number = dplyr::cur_group_id()) %>%
dplyr::ungroup() %>%
dplyr::mutate(
result_display = map_pitch_call_display_weekly(PitchCall, PlayResult)
)
if (!nrow(df)) {
lbl <- ifelse(!is.null(opponent_label),
paste0(game_date, " vs. ", opponent_label),
game_date)
return(ggplot() + theme_void() +
ggtitle(paste(lbl, "- No PAs")) +
theme(plot.title = element_text(hjust = 0.5, size = 10, face = "bold")))
}
# Per-PA text annotations (pitch-by-pitch sidebar)
pa_text <- df %>%
dplyr::group_by(pa_number) %>%
dplyr::summarise(
annotation = paste0(
PitchofPA, ": ",
dplyr::if_else(
PitchCall == "InPlay",
paste0(PlayResult,
dplyr::if_else(!is.na(ExitSpeed),
paste0("\n", round(ExitSpeed), " EV"), "")),
map_pitch_call_display_weekly(PitchCall, PlayResult)
),
collapse = "\n"),
.groups = "drop"
)
title_label <- if (!is.null(opponent_label)) {
paste0(format(as.Date(game_date), "%m/%d/%y"), " vs. ", opponent_label)
} else {
format(as.Date(game_date), "%m/%d/%y")
}
p <- ggplot(df, aes(x = PlateLocSide, y = PlateLocHeight)) +
# Strike zone
annotate("rect", xmin = -0.8303, xmax = 0.8303, ymin = 1.6, ymax = 3.5,
fill = NA, color = "black", linewidth = 0.8) +
# Home plate
annotate("path",
x = c(-0.708, 0.708, 0.708, 0, -0.708, -0.708),
y = c(0.15, 0.15, 0.3, 0.5, 0.3, 0.15),
color = "black", linewidth = 0.6) +
# Pitch points: shape by result, color by pitch type
geom_point(aes(shape = result_display, color = TaggedPitchType),
size = 4.5, stroke = 1.2) +
# Pitch number label
geom_text(aes(label = PitchofPA), size = 2.2, vjust = -1.4, fontface = "bold") +
# Sidebar annotation
geom_text(data = pa_text,
aes(x = 1.5, y = 3.0, label = annotation),
inherit.aes = FALSE, hjust = 0, size = 1.7, lineheight = 0.9) +
scale_shape_manual(values = result_shapes, name = "Result", drop = FALSE) +
scale_color_manual(values = pitch_colors, name = "Pitch Type", na.value = "grey50") +
facet_wrap(~ pa_number, ncol = 5) +
coord_fixed(ratio = 1, xlim = c(-1.8, 2.8), ylim = c(0, 4.2)) +
ggtitle(title_label) +
theme_void() +
theme(
strip.text = element_text(size = 8, face = "bold"),
plot.title = element_text(hjust = 0.5, size = 11, face = "bold"),
legend.position = "none",
panel.spacing = unit(0.2, "lines")
)
p
}
# --- Compact heatmap for report (larger) ---
create_report_heatmap <- function(player_name, team_data, metric = "Solid Contact",
title_short = metric) {
tm <- team_data %>%
dplyr::filter(Batter == player_name,
!is.na(PlateLocSide), !is.na(PlateLocHeight))
if (metric == "Solid Contact") {
tm <- tm %>% dplyr::filter(PitchCall == "InPlay", !is.na(ExitSpeed), !is.na(Angle),
(ExitSpeed >= 92 & Angle >= 8) | (ExitSpeed >= 95 & Angle >= 0))
} else if (metric == "Whiffs") {
tm <- tm %>% dplyr::filter(WhiffIndicator == 1)
} else if (metric == "FB Whiffs") {
tm <- tm %>% dplyr::filter(WhiffIndicator == 1,
TaggedPitchType %in% c("Fastball","Four-Seam","Sinker"))
}
if (nrow(tm) < 3) {
return(ggplot() + theme_void() +
ggtitle(title_short) +
theme(plot.title = element_text(hjust = 0.5, size = 12, face = "bold")))
}
ggplot(tm, aes(x = PlateLocSide, y = PlateLocHeight)) +
stat_density_2d(aes(fill = after_stat(density)), geom = "raster", contour = FALSE) +
scale_fill_gradientn(
colours = c("white","#0551bc","#02fbff","#03ff00","#fbff00",
"#ffa503","#ff1f02","#dc1100"),
name = "Density") +
annotate("rect", xmin = -0.8303, xmax = 0.8303, ymin = 1.6, ymax = 3.5,
fill = NA, color = "black", linewidth = 0.8) +
annotate("path",
x = c(-0.708, 0.708, 0.708, 0, -0.708, -0.708),
y = c(0.15, 0.15, 0.3, 0.5, 0.3, 0.15),
color = "black", linewidth = 0.6) +
coord_fixed(ratio = 1) + xlim(-2, 2) + ylim(0, 4.5) +
ggtitle(title_short) +
theme_void() +
theme(legend.position = "none",
plot.title = element_text(hjust = 0.5, size = 12, face = "bold"))
}
# --- PDF generator (grid-based) ---
generate_weekly_report_pdf <- function(player_name, team_data, n_games = 3,
output_path = NULL, notes_text = "") {
if (is.null(output_path)) output_path <- tempfile(fileext = ".pdf")
batter_data <- team_data %>% dplyr::filter(Batter == player_name)
game_dates <- batter_data %>%
dplyr::distinct(Date) %>%
dplyr::arrange(dplyr::desc(Date)) %>%
dplyr::slice_head(n = n_games) %>%
dplyr::arrange(Date) %>%
dplyr::pull(Date)
if (length(game_dates) == 0) {
pdf(output_path, width = 14, height = 10)
grid::grid.newpage()
grid::grid.text(paste("No game data for", player_name),
gp = grid::gpar(fontsize = 20))
dev.off()
return(output_path)
}
# --- Summary stats across the selected games ---
games_data <- batter_data %>% dplyr::filter(Date %in% game_dates)
stats_df <- compute_weekly_game_stats(games_data)
stats_grob <- create_stats_header_grob(stats_df)
# --- Build AB plots per game ---
ab_plots <- lapply(game_dates, function(gd) {
gd_str <- format(gd, "%Y-%m-%d")
rows <- team_data %>% dplyr::filter(Batter == player_name, Date == gd)
opp <- unique(rows$PitcherTeam)
opp <- opp[!is.na(opp)]
opp_label <- if (length(opp)) opp[1] else NULL
create_weekly_ab_plot(team_data, player_name, gd_str, opp_label)
})
# --- Heatmaps ---
hm_solid <- create_report_heatmap(player_name, team_data, "Solid Contact", "Solid Contact")
hm_whiff <- create_report_heatmap(player_name, team_data, "Whiffs", "Whiffs")
hm_fb <- create_report_heatmap(player_name, team_data, "FB Whiffs", "FB Whiffs")
# --- Notes ---
notes_grob <- grid::grobTree(
grid::rectGrob(gp = grid::gpar(fill = "white", col = "black", lwd = 1)),
grid::textGrob(
paste0("Notes:\n", notes_text),
x = 0.05, y = 0.95, hjust = 0, vjust = 1,
gp = grid::gpar(fontsize = 10)
)
)
# --- Legends ---
shape_leg_grob <- create_shape_legend_grob()
pt_leg_grob <- create_pitch_color_legend_grob()
# --- Assemble with grid ---
title_grob <- grid::textGrob(
paste(player_name, "- Weekly Hitter Report"),
gp = grid::gpar(fontsize = 18, fontface = "bold", col = "#006F71")
)
legends_row <- gridExtra::arrangeGrob(shape_leg_grob, pt_leg_grob, ncol = 2)
ab_grobs <- lapply(ab_plots, ggplotGrob)
ab_stack <- do.call(gridExtra::arrangeGrob, c(ab_grobs, list(ncol = 1)))
season_label <- grid::textGrob("Season Heatmaps",
gp = grid::gpar(fontsize = 14, fontface = "bold"))
heatmap_row <- gridExtra::arrangeGrob(
ggplotGrob(hm_solid), ggplotGrob(hm_whiff), ggplotGrob(hm_fb), notes_grob,
ncol = 4, widths = c(1, 1, 1, 1)
)
n_ab <- length(ab_grobs)
ab_h <- min(n_ab * 2.8, 8.5)
stats_h <- 0.7
title_h <- 0.6
legend_h <- 0.7
label_h <- 0.5
heatmap_h <- 3.5
page_h <- title_h + stats_h + legend_h + ab_h + label_h + heatmap_h + 0.5
full_page <- gridExtra::arrangeGrob(
title_grob, stats_grob, legends_row, ab_stack, season_label, heatmap_row,
ncol = 1,
heights = grid::unit(c(title_h, stats_h, legend_h, ab_h, label_h, heatmap_h), "inches")
)
pdf(output_path, width = 14, height = max(page_h, 10), paper = "special")
grid::grid.newpage()
grid::grid.draw(full_page)
dev.off()
return(output_path)
}
create_whiff_movement_chart <- function(batter_name, team_data) {
whiff_data <- team_data %>%
filter(Batter == batter_name, WhiffIndicator == 1, !is.na(HorzBreak), !is.na(InducedVertBreak),
!is.na(TaggedPitchType), TaggedPitchType != "Undefined")
if (!nrow(whiff_data)) {
return(ggplot() + theme_void() + ggtitle(paste("No whiff data for", batter_name)) +
theme(plot.title = element_text(hjust=0.5, size=14, face="bold")))
}
velo_data <- whiff_data %>%
group_by(TaggedPitchType) %>%
summarize(mean_hb = mean(HorzBreak, na.rm = TRUE),
mean_ivb = mean(InducedVertBreak, na.rm = TRUE),
mean_velo = round(mean(RelSpeed, na.rm = TRUE), 1),
count = n(), .groups = "drop") %>%
filter(count >= 2)
whiff_data %>%
ggplot(aes(x=HorzBreak, y=InducedVertBreak)) +
geom_vline(xintercept = 0, color="black", linewidth=.8) +
geom_hline(yintercept = 0, color="black", linewidth=.8) +
geom_point(aes(fill=TaggedPitchType), shape=21, size=4, color="black", stroke=.4, alpha=1) +
{if (nrow(velo_data) > 0) {
list(
geom_point(data=velo_data, aes(x=mean_hb, y=mean_ivb, fill=TaggedPitchType),
alpha=1.5, shape=21, color="black", stroke=.4, size=8),
geom_text(data=velo_data, aes(x=mean_hb, y=mean_ivb, label=mean_velo),
color="black", size=4, hjust=.5)
)
}} +
scale_x_continuous("HB", limits=c(-27.5,27.5), breaks=seq(-20,20,10)) +
scale_y_continuous("IVB", limits=c(-27.5,27.5), breaks=seq(-20,20,10)) +
scale_fill_manual(values = pitch_colors) +
scale_color_manual(values = pitch_colors) +
coord_equal() + theme_void() +
ggtitle(paste("Pitch Movement on Whiffs:", batter_name)) +
theme(plot.background=element_rect(fill="white", color=NA),
panel.background=element_rect(fill="white", color=NA),
axis.text=element_text(color="white", size=10),
axis.title=element_text(color="white", size=12),
plot.title=element_text(hjust=0.5, size=14, face="bold"),
legend.background=element_rect(fill="white"),
legend.text=element_text(color="black", size=10),
legend.title=element_blank(),
panel.border=element_rect(color="black", fill=NA, linewidth=2),
axis.text.x=element_text(size=12), axis.text.y=element_text(size=12),
axis.title.x=element_text(size=12), axis.title.y=element_text(size=12))
}
create_radial_grid <- function(
max_ev = 120,
ev_step = 2.5,
angle_step = 1
) {
expand.grid(
ExitSpeed = seq(0, max_ev, by = ev_step),
Angle = seq(-90, 90, by = angle_step)
) %>%
dplyr::mutate(
Angle_rad = Angle * pi / 180,
radial_x = ExitSpeed * cos(Angle_rad),
radial_y = ExitSpeed * sin(Angle_rad),
## Background bins (placeholder logic)
xwOBA = dplyr::case_when(
ExitSpeed >= 95 & Angle >= 10 & Angle <= 30 ~ 1,
TRUE ~ 0
)
)
}
grid_df <- create_radial_grid()
radial_chart_ev <- function(batter_name, team_data) {
df <- team_data %>%
filter(Batter == batter_name, PitchCall == "InPlay", !is.na(ExitSpeed), !is.na(Angle)) %>%
mutate(
event_type = case_when(
PlayResult == "Single" ~ "1B",
PlayResult == "Double" ~ "2B",
PlayResult == "Triple" ~ "3B",
PlayResult == "HomeRun" ~ "HR",
PlayResult %in% c("Error", "FieldersChoice") ~ "ROE/FC",
TRUE ~ "OUT"
),
Angle_rad = Angle * pi / 180,
radial_x = ExitSpeed * cos(Angle_rad),
radial_y = ExitSpeed * sin(Angle_rad)
)
if (nrow(df) < 5) {
return(ggplot() + theme_void() +
ggtitle(paste("Not enough batted-ball data for", batter_name)) +
theme(plot.title = element_text(hjust = 0.5, size = 14, face = "bold")))
}
event_colors <- c("1B" = "#E41A1C", "2B" = "#FFD700", "3B" = "#CD853F",
"HR" = "#008B8B", "ROE/FC" = "#FF8C00", "OUT" = "#A9A9A9")
avg_ev <- round(mean(df$ExitSpeed, na.rm = TRUE), 1)
avg_la <- round(mean(df$Angle, na.rm = TRUE), 1)
max_ev <- round(max(df$ExitSpeed, na.rm = TRUE), 1)
barrel_pct <- round(sum(df$ExitSpeed >= 95 & df$Angle >= 10 & df$Angle <= 30, na.rm = TRUE) / nrow(df) * 100, 1)
ggplot() +
annotate("path", x = 60 * cos(seq(-pi/2, pi/2, length.out = 100)),
y = 60 * sin(seq(-pi/2, pi/2, length.out = 100)),
color = "grey70", linetype = "dashed", linewidth = 0.3) +
annotate("path", x = 80 * cos(seq(-pi/2, pi/2, length.out = 100)),
y = 80 * sin(seq(-pi/2, pi/2, length.out = 100)),
color = "grey70", linetype = "dashed", linewidth = 0.3) +
annotate("path", x = 95 * cos(seq(-pi/2, pi/2, length.out = 100)),
y = 95 * sin(seq(-pi/2, pi/2, length.out = 100)),
color = "#00840D", linetype = "solid", linewidth = 0.5) +
annotate("path", x = 110 * cos(seq(-pi/2, pi/2, length.out = 100)),
y = 110 * sin(seq(-pi/2, pi/2, length.out = 100)),
color = "grey70", linetype = "dashed", linewidth = 0.3) +
annotate("segment", x = 0, y = 0, xend = 120, yend = 0, color = "grey50", linewidth = 0.5) +
annotate("segment", x = 0, y = 0,
xend = 120 * cos(10 * pi / 180), yend = 120 * sin(10 * pi / 180),
color = "grey70", linetype = "dashed", linewidth = 0.3) +
annotate("segment", x = 0, y = 0,
xend = 120 * cos(30 * pi / 180), yend = 120 * sin(30 * pi / 180),
color = "grey70", linetype = "dashed", linewidth = 0.3) +
annotate("text", x = 62, y = -8, label = "60", size = 2.5, color = "grey50") +
annotate("text", x = 82, y = -8, label = "80", size = 2.5, color = "grey50") +
annotate("text", x = 97, y = -8, label = "95", size = 2.5, color = "#00840D", fontface = "bold") +
annotate("text", x = 112, y = -8, label = "110", size = 2.5, color = "grey50") +
annotate("text", x = 115 * cos(10 * pi / 180), y = 115 * sin(10 * pi / 180),
label = "10°", size = 2.5, color = "grey50") +
annotate("text", x = 115 * cos(30 * pi / 180), y = 115 * sin(30 * pi / 180),
label = "30°", size = 2.5, color = "grey50") +
geom_point(data = df, aes(x = radial_x, y = radial_y, fill = event_type),
shape = 21, size = 4, color = "black", stroke = 0.5, alpha = 0.8) +
annotate("rect", xmin = -10, xmax = 50, ymin = 85, ymax = 115,
fill = "white", color = "black", alpha = 0.9) +
annotate("text", x = 20, y = 110, label = paste("Avg EV:", avg_ev), size = 3, fontface = "bold") +
annotate("text", x = 20, y = 103, label = paste("Max EV:", max_ev), size = 3) +
annotate("text", x = 20, y = 96, label = paste("Avg LA:", avg_la, "°"), size = 3) +
annotate("text", x = 20, y = 89, label = paste("Barrel%:", barrel_pct, "%"), size = 3) +
scale_fill_manual(values = event_colors, name = "Result") +
coord_fixed(xlim = c(-20, 125), ylim = c(-30, 120)) +
theme_void() +
ggtitle(paste("Radial EV/LA Chart:", batter_name)) +
theme(
plot.title = element_text(hjust = 0.5, size = 14, face = "bold"),
legend.position = "right",
plot.background = element_rect(fill = "white", color = NA),
panel.background = element_rect(fill = "white", color = NA)
)
}
create_bp_radial_chart <- function(batter_name, bp_data) {
df <- bp_data %>%
filter(Batter == batter_name, BIPind == 1, !is.na(ExitSpeed), !is.na(Angle)) %>%
mutate(
Angle_rad = Angle * pi / 180,
radial_x = ExitSpeed * cos(Angle_rad),
radial_y = ExitSpeed * sin(Angle_rad)
)
if (nrow(df) < 3) {
return(ggplot() + theme_void() +
ggtitle(paste("Not enough BP data for", batter_name)) +
theme(plot.title = element_text(hjust = 0.5, size = 14, face = "bold")))
}
avg_ev <- round(mean(df$ExitSpeed, na.rm = TRUE), 1)
avg_la <- round(mean(df$Angle, na.rm = TRUE), 1)
max_ev <- round(max(df$ExitSpeed, na.rm = TRUE), 1)
barrel_pct <- round(sum(df$ExitSpeed >= 95 & df$Angle >= 10 & df$Angle <= 30, na.rm = TRUE) / nrow(df) * 100, 1)
ggplot() +
annotate("path", x = 60 * cos(seq(-pi/2, pi/2, length.out = 100)),
y = 60 * sin(seq(-pi/2, pi/2, length.out = 100)),
color = "grey70", linetype = "dashed", linewidth = 0.3) +
annotate("path", x = 80 * cos(seq(-pi/2, pi/2, length.out = 100)),
y = 80 * sin(seq(-pi/2, pi/2, length.out = 100)),
color = "grey70", linetype = "dashed", linewidth = 0.3) +
annotate("path", x = 95 * cos(seq(-pi/2, pi/2, length.out = 100)),
y = 95 * sin(seq(-pi/2, pi/2, length.out = 100)),
color = "#00840D", linetype = "solid", linewidth = 0.5) +
annotate("path", x = 110 * cos(seq(-pi/2, pi/2, length.out = 100)),
y = 110 * sin(seq(-pi/2, pi/2, length.out = 100)),
color = "grey70", linetype = "dashed", linewidth = 0.3) +
annotate("segment", x = 0, y = 0, xend = 120, yend = 0, color = "grey50", linewidth = 0.5) +
annotate("segment", x = 0, y = 0,
xend = 120 * cos(10 * pi / 180), yend = 120 * sin(10 * pi / 180),
color = "grey70", linetype = "dashed", linewidth = 0.3) +
annotate("segment", x = 0, y = 0,
xend = 120 * cos(30 * pi / 180), yend = 120 * sin(30 * pi / 180),
color = "grey70", linetype = "dashed", linewidth = 0.3) +
annotate("text", x = 62, y = -8, label = "60", size = 2.5, color = "grey50") +
annotate("text", x = 82, y = -8, label = "80", size = 2.5, color = "grey50") +
annotate("text", x = 97, y = -8, label = "95", size = 2.5, color = "#00840D", fontface = "bold") +
annotate("text", x = 112, y = -8, label = "110", size = 2.5, color = "grey50") +
geom_point(data = df, aes(x = radial_x, y = radial_y, fill = ExitSpeed),
shape = 21, size = 4, color = "black", stroke = 0.5, alpha = 0.8) +
annotate("rect", xmin = -10, xmax = 50, ymin = 85, ymax = 115,
fill = "white", color = "black", alpha = 0.9) +
annotate("text", x = 20, y = 110, label = paste("Avg EV:", avg_ev), size = 3, fontface = "bold") +
annotate("text", x = 20, y = 103, label = paste("Max EV:", max_ev), size = 3) +
annotate("text", x = 20, y = 96, label = paste("Avg LA:", avg_la, "°"), size = 3) +
annotate("text", x = 20, y = 89, label = paste("Barrel%:", barrel_pct, "%"), size = 3) +
scale_fill_gradient(low = "#E1463E", high = "#00840D", name = "Exit Velo") +
coord_fixed(xlim = c(-20, 125), ylim = c(-30, 120)) +
theme_void() +
ggtitle(paste("BP Radial Chart:", batter_name)) +
theme(
plot.title = element_text(hjust = 0.5, size = 14, face = "bold"),
legend.position = "right",
plot.background = element_rect(fill = "white", color = NA),
panel.background = element_rect(fill = "white", color = NA)
)
}
create_contact_map <- function(batter_name, team_data) {
batter_data <- team_data %>%
filter(Batter == batter_name, !is.na(ExitSpeed), !is.na(ContactPositionZ),
!is.na(ContactPositionX), !is.na(ContactPositionY), !is.na(PitcherThrows)) %>%
mutate(ContactPositionX = ContactPositionX*12, ContactPositionY = ContactPositionY*12, ContactPositionZ = ContactPositionZ*12)
if (!nrow(batter_data)) {
return(ggplot() + theme_void() + ggtitle(paste("No contact position data for", batter_name)))
}
batter_data <- batter_data %>% group_by(Date, Inning, PAofInning) %>% mutate(pa_number = cur_group_id()) %>% ungroup()
ggplot(batter_data, aes(x=ContactPositionZ, y=ContactPositionX)) +
geom_segment(aes(x=-8.5,y=17,xend=8.5,yend=17), inherit.aes=FALSE, color="black") +
geom_segment(aes(x=8.5,y=8.5,xend=8.5,yend=17), inherit.aes=FALSE, color="black") +
geom_segment(aes(x=-8.5,y=8.5,xend=-8.5,yend=17), inherit.aes=FALSE, color="black") +
geom_segment(aes(x=-8.5,y=8.5,xend=0,yend=0), inherit.aes=FALSE, color="black") +
geom_segment(aes(x=8.5,y=8.5,xend=0,yend=0), inherit.aes=FALSE, color="black") +
geom_rect(aes(xmin=20,xmax=48,ymin=-20,ymax=40), fill=NA, color="black") +
geom_rect(aes(xmin=-48,xmax=-20,ymin=-20,ymax=40), fill=NA, color="black") +
geom_text(aes(x=ifelse(BatterSide=="Right",-34,34), y=10, label=ifelse(BatterSide=="Right","R","L")), size=8, fontface="bold") +
xlim(-50,50) + ylim(-20,50) +
geom_point(aes(fill=ExitSpeed), color="black", stroke=.5, shape=21, alpha=.85, size=3) +
geom_smooth(aes(color="Optimal Contact Point"), method="lm", level=0, se=FALSE) +
scale_fill_gradient(name="Exit Velo", low="#E1463E", high="#00840D",
limits = c(min(batter_data$ExitSpeed, na.rm=TRUE), max(batter_data$ExitSpeed, na.rm=TRUE))) +
scale_color_manual(name=NULL, values=c("Optimal Contact Point"="black")) +
coord_fixed() +
ggtitle(paste("Contact Points:", batter_name)) +
theme_void() + facet_wrap(~ BatterSide) +
theme(legend.position="right", strip.text = element_blank(),
plot.title=element_text(hjust=0.5, size=14, face="bold"))
}
create_heatmap <- function(player_name, metric, team_data) {
tm_data <- team_data %>% filter(Batter == player_name, !is.na(PlateLocSide), !is.na(PlateLocHeight))
# REMOVED: Pitcher hand filtering - now handled by filtered_data() reactive
if (!nrow(tm_data)) {
return(ggplot() + theme_void() + ggtitle(paste("No data available for", player_name)) +
theme(plot.title = element_text(hjust=0.5, size=14, face="bold")))
}
# Metric filters
if (metric == "Solid Contact") {
tm_data <- tm_data %>% filter(PitchCall=="InPlay", !is.na(ExitSpeed), !is.na(Angle),
(ExitSpeed >= 92 & Angle >= 8) | (ExitSpeed >= 95 & Angle >= 0))
} else if (metric == "Hard-Hit (95+)") {
tm_data <- tm_data %>% filter(PitchCall=="InPlay", !is.na(ExitSpeed), ExitSpeed >= 95)
} else if (metric == "Whiffs") {
tm_data <- tm_data %>% filter(WhiffIndicator == 1)
} else if (metric == "Swings") {
tm_data <- tm_data %>% filter(SwingIndicator == 1)
} else if (metric == "10-30 LA") {
tm_data <- tm_data %>% filter(PitchCall=="InPlay", !is.na(Angle), Angle >= 10, Angle <= 30)
} else if (metric == "Hits") {
tm_data <- tm_data %>% filter(PlayResult %in% c("Single","Double","Triple","HomeRun"))
} else if (metric == "XBH") {
tm_data <- tm_data %>% filter(PlayResult %in% c("Double","Triple","HomeRun"))
} else if (metric == "All Balls in Play") {
tm_data <- tm_data %>% filter(PitchCall == "InPlay")
}
if (!nrow(tm_data)) {
return(ggplot() + theme_void() + ggtitle(paste("No data for", metric, "-", player_name)) +
theme(plot.title = element_text(hjust=0.5, size=14, face="bold")))
}
ggplot(tm_data, aes(x=PlateLocSide, y=PlateLocHeight)) +
stat_density_2d(aes(fill=after_stat(density)), geom="raster", contour=FALSE) +
scale_fill_gradientn(colours=c("white","#0551bc","#02fbff","#03ff00","#fbff00", "#ffa503","#ff1f02","#dc1100"), name="Density") +
annotate("rect", xmin=-0.8303, xmax=0.8303, ymin=1.6, ymax=3.5, fill=NA, color="black", linewidth=1) +
annotate("path", x=c(-0.708,0.708,0.708,0,-0.708,-0.708),
y=c(0.15,0.15,0.3,0.5,0.3,0.15), color="black", linewidth=.8) +
coord_fixed(ratio=1) + xlim(-2,2) + ylim(0,4.5) +
ggtitle(paste("Heatmap:", metric, "-", player_name)) +
theme_void() + theme(legend.position="right", plot.margin=margin(3,3,3,3),
plot.title=element_text(hjust=0.5, size=14, face="bold"))
}
create_batter_performance_chart <- function(batter_name, team_data) {
filtered_data <- team_data %>% filter(Batter == batter_name)
if (!nrow(filtered_data)) {
return(ggplot() + theme_void() + ggtitle(paste("No data available for", batter_name)) +
theme(plot.title = element_text(hjust=0.5, size=14, face="bold")))
}
batter_side <- unique(filtered_data$BatterSide)[1]
filtered_data <- filtered_data %>% mutate(x_rel = ifelse(BatterSide=="Left", -PlateLocSide, PlateLocSide), y=PlateLocHeight)
x_breaks <- c(-0.95, -0.3166667, 0.3166667, 0.95)
y_breaks <- c(1.6, 2.2333333, 2.8666667, 3.5)
zone_cells <- expand.grid(xbin=1:3, ybin=1:3, KEEP.OUT.ATTRS=FALSE, stringsAsFactors=FALSE) %>%
mutate(xmin = x_breaks[xbin], xmax = x_breaks[xbin+1], ymin = y_breaks[ybin], ymax = y_breaks[ybin+1],
zone_name = paste0(c("Down","Middle","Up")[ybin], " - ", c("In","Middle","Away")[xbin]))
stats_by_zone <- filtered_data %>%
tidyr::crossing(zone_cells) %>%
filter(x_rel >= xmin, x_rel <= xmax, y >= ymin, y <= ymax) %>%
group_by(PitcherThrows, xbin, ybin, xmin, xmax, ymin, ymax, zone_name) %>%
summarise(
ab = sum(ABindicator, na.rm=TRUE),
pa = sum(PAindicator, na.rm=TRUE),
swings = sum(SwingIndicator, na.rm=TRUE),
whiffs = sum(WhiffIndicator, na.rm=TRUE),
avg = ifelse(ab > 0, sum(HitIndicator, na.rm=TRUE)/ab, NA_real_),
slg = ifelse(ab > 0, sum(totalbases, na.rm=TRUE)/ab, NA_real_),
obp = ifelse(pa > 0, sum(OnBaseindicator, na.rm=TRUE)/pa, NA_real_),
ops = ifelse(!is.na(slg) & !is.na(obp), slg + obp, NA_real_),
avg_ev = mean(ExitSpeed[PitchCall == "InPlay"], na.rm = TRUE),
whiff_pct = ifelse(swings > 0, 100 * whiffs / swings, NA_real_),
.groups = "drop"
)
plot_data <- zone_cells %>% tidyr::crossing(PitcherThrows = unique(filtered_data$PitcherThrows)) %>%
left_join(stats_by_zone, by = c("PitcherThrows","xbin","ybin","xmin","xmax","ymin","ymax","zone_name"))
if (!nrow(plot_data) || all(is.na(plot_data$ops))) {
return(ggplot() + theme_void() + ggtitle(paste("Insufficient data for", batter_name)) +
theme(plot.title = element_text(hjust=0.5, size=14, face="bold")))
}
lim_max <- max(plot_data$ops, na.rm=TRUE)
if (is.na(lim_max) || lim_max == 0) lim_max <- 1.0
mid_pt <- 0.700
ggplot(plot_data, aes(fill=ops)) +
geom_rect(aes(xmin=xmin, xmax=xmax, ymin=ymin, ymax=ymax), color="black", linewidth=.5, alpha=.85) +
geom_text(aes(x=(xmin+xmax)/2, y=(ymin+ymax)/2 + ((ymax-ymin)*.25),
label=ifelse(!is.na(ops), sprintf("%.3f", ops), "")), size=5, fontface="bold") +
geom_text(aes(x=(xmin+xmax)/2, y=(ymin+ymax)/2 - ((ymax-ymin)*.05),
label=ifelse(!is.na(avg), sprintf("AVG %.3f", avg), "")), size=3.2) +
geom_text(aes(x=(xmin+xmax)/2, y=(ymin+ymax)/2 - ((ymax-ymin)*.20),
label=ifelse(!is.na(avg_ev) & !is.nan(avg_ev), sprintf("EV %.1f", avg_ev), "")), size=3.2) +
geom_text(aes(x=(xmin+xmax)/2, y=(ymin+ymax)/2 - ((ymax-ymin)*.35),
label=ifelse(!is.na(whiff_pct), sprintf("Whiff %.1f%%", whiff_pct), "")), size=3.2) +
scale_fill_gradient2(low="#E1463E", mid="white", high="#00840D",
midpoint=mid_pt, limits=c(0,lim_max), na.value="grey90", name="OPS") +
annotate("rect", xmin=-0.95, xmax=0.95, ymin=1.6, ymax=3.5, fill=NA, color="black", linewidth=1.2) +
coord_fixed(xlim=c(-1.05,1.05), ylim=c(1.5,3.6), expand=FALSE) +
facet_wrap(~PitcherThrows, nrow=1, labeller = labeller(PitcherThrows = c(Right="vs RHP", Left="vs LHP"))) +
ggtitle(paste("Performance by Location:", batter_name)) +
theme_void() + theme(plot.title=element_text(hjust=0.5, size=14, face="bold"),
legend.position="right", strip.text=element_text(size=10, face="bold"))
}
pa_colors <- setNames(colorRampPalette(c("#eaeaea","#bdbdbd","#8c8c8c","#5a5a5a","#2c2c2c"))(50), as.character(1:50))
## -- Function to prepare data for OAA model
find_closest_OF <- function(RF_col_x, RF_col_z, LF_col_x, LF_col_z, CF_col_x, CF_col_z, ball_x, ball_z) {
dist_RF <- sqrt((ball_x - RF_col_x)^2 + (ball_z - RF_col_z)^2)
dist_LF <- sqrt((ball_x - LF_col_x)^2 + (ball_z - LF_col_z)^2)
dist_CF <- sqrt((ball_x - CF_col_x)^2 + (ball_z - CF_col_z)^2)
distances <- c(RF = dist_RF, CF = dist_CF, LF = dist_LF)
closest_pos <- names(which.min(distances))
return(closest_pos)
}
prepare_data_oaa <- function(data) {
data <- data %>% mutate(
rad = Bearing * pi/180,
x_pos = Distance * cos(rad),
z_pos = Distance * sin(rad))
data <- data %>%
mutate(Time = as.character(Time),
UTCTime = as.character(UTCTime),
LocalDateTime = as.character(LocalDateTime),
UTCDateTime = as.character(UTCDateTime)) %>%
left_join(college_positions_cc %>%
rename(pos_source_file = source_file,
pos_PlayResult = PlayResult,
pos_PitchCall = PitchCall,
pos_PitchNo = PitchNo,
pos_PlayID = PlayID,
pos_Date = Date,
pos_Time = Time,
pos_PitcherTeam = PitcherTeam,
pos_BatterTeam = BatterTeam,
pos_UTCDate = UTCDate,
pos_UTCTime = UTCTime,
pos_GameUID = GameUID,
pos_LocalDateTime = LocalDateTime,
pos_UTCDateTime = UTCDateTime) %>%
group_by(pos_GameUID, pos_BatterTeam) %>%
fill(all_of(ends_with("_Name")), .direction = "downup") %>%
ungroup(),
by = c("PitchUID")) %>%
drop_na(CF_PositionAtReleaseZ) %>%
distinct(PitchUID, .keep_all = TRUE)
data <- data %>%
mutate(
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_
)
)
return(data)
}
hitter_game_safe <- function(hitter, game_key, data){
df <- data %>% filter(Batter == hitter)
# pick a grouping key for "game"
if ("game" %in% names(df)) {
df <- df %>% filter(game == game_key)
} else if ("Date" %in% names(df)) {
df <- df %>% mutate(.game = format(Date, "%Y-%m-%d")) %>% filter(.game == game_key)
}
if (!nrow(df)) return(gt(data.frame(Note = "No game data")))
fly_balls <- prepare_data_oaa(df)
fly_balls <- fly_balls %>%
mutate(across(where(is.logical), as.numeric))
complete_idx <- complete.cases(fly_balls[, c("HangTime", "Distance", "Bearing", "angle_from_home", "closest_pos_dist")])
fly_balls$catch_probability <- NA_real_
if (any(complete_idx)) {
preds <- predict(OAA_Model,
new_data = fly_balls[complete_idx, ],
type = "prob")
fly_balls$catch_probability[complete_idx] <- preds$.pred_1
}
fly_balls <- fly_balls %>%
mutate(
catch_probability = ifelse(
(PitchCall %in% c("HomeRun", "FoulBallNotFieldable") | Angle <= 10),
NA_real_,
catch_probability),
catch_probability = round(catch_probability, 2),
catch_probability = ifelse(is.na(catch_probability), " ", as.character(catch_probability))
) %>%
dplyr::select(PitchUID, catch_probability)
df <- df %>%
arrange(Date, Inning, PAofInning, PitchofPA) %>%
group_by(Date, Inning, PAofInning) %>% mutate(PA_id = cur_group_id()) %>% ungroup() %>%
mutate(
last_pitch_of_pa = ifelse(lead(PA_id) != PA_id | is.na(lead(PA_id)), 1, 0),
ExitSpeed = ifelse(PitchCall == "InPlay", round(ExitSpeed), NA),
Angle = ifelse(PitchCall == "InPlay", round(Angle), NA),
`Pull?` = dplyr::case_when(
PitchCall != "InPlay" ~ NA_character_,
BatterSide == "Right" & Bearing < -15 ~ "Yes",
BatterSide == "Left" & Bearing > 15 ~ "Yes",
TRUE ~ "No"
),
RelSpeed = round(RelSpeed, 1),
`In Zone?` = ifelse(StrikeZoneIndicator == 1, "Yes", "No"),
Throws = ifelse(PitcherThrows == "Right", "R", "L"),
hits = ifelse(BatterSide == "Right", "R", "L")
) %>%
mutate(event = dplyr::coalesce(PlayResult, PitchCall)) %>%
left_join(fly_balls, by = "PitchUID") %>%
distinct(PitchUID, .keep_all = TRUE)
out <- df %>%
drop_na(event) %>%
select(PA_id, Pitcher, Throws, hits, Balls, Strikes, TaggedPitchType, RelSpeed,
`In Zone?`, event, ExitSpeed, Angle, `Pull?`,
`Catch Probability` = catch_probability
) %>%
rename(`PA #`=`PA_id`, `Pitch Type`=TaggedPitchType, velo=RelSpeed, EV=ExitSpeed, LA=Angle)
gt(out) %>%
gt_theme_espn() %>%
cols_align("center", columns = everything()) %>%
data_color(columns = EV, colors = scales::col_numeric(c("#ffcccc","white","lightgreen"), domain=c(50,120), na.color="white")) %>%
data_color(columns = `PA #`, colors = scales::col_factor(pa_colors, domain = names(pa_colors), na.color="white")) %>%
tab_style(style = cell_fill(color = "lightgreen"), locations = cells_body(columns = `In Zone?`, rows = `In Zone?` == "Yes")) %>%
tab_style(style = cell_fill(color = "#ffcccc"), locations = cells_body(columns = `In Zone?`, rows = `In Zone?` == "No")) %>%
tab_style(style = cell_fill(color = "lightgreen"), locations = cells_body(columns = LA, rows = LA >= 8 & LA <= 32)) %>%
tab_style(style = cell_fill(color = "#ffcccc"), locations = cells_body(columns = LA, rows = LA < 8 | LA > 32)) %>%
fmt_missing(columns = everything(), missing_text = "-") %>%
tab_header(title = paste0(hitter, " — ", game_key)) %>%
tab_options(heading.align = "center")
}
create_percentile_chart <- function(batter_rows, pool_rows, batter_name) {
summarize_block <- function(df) {
df %>%
group_by(Batter) %>%
summarize(
K_pct = sum(KorBB == "Strikeout", na.rm=TRUE)/sum(PAindicator, na.rm=TRUE) * 100,
BB_pct = sum(WalkIndicator, na.rm=TRUE)/sum(PAindicator, na.rm=TRUE) * 100,
AVG = sum(HitIndicator, na.rm=TRUE)/sum(ABindicator, na.rm=TRUE),
OBP = sum(OnBaseindicator, na.rm=TRUE)/sum(PAindicator, na.rm=TRUE),
SLG = sum(totalbases, na.rm=TRUE)/sum(ABindicator, na.rm=TRUE),
OPS = OBP + SLG,
Avg_EV = mean(ExitSpeed[PitchCall == "InPlay" & TaggedHitType != "Bunt"], na.rm=TRUE),
Max_EV = suppressWarnings(max(ExitSpeed[PitchCall == "InPlay"], na.rm=TRUE)),
SC_pct = sum(SCind, na.rm=TRUE)/sum(BIPind, na.rm=TRUE) * 100,
LA1030_pct = sum(LA1030ind, na.rm=TRUE)/sum(BIPind, na.rm=TRUE) * 100,
HH_pct = sum(HHind, na.rm=TRUE)/sum(BIPind, na.rm=TRUE) * 100,
Barrel_pct = sum(Barrelind, na.rm=TRUE)/sum(BIPind, na.rm=TRUE) * 100,
Whiff_pct = sum(WhiffIndicator, na.rm=TRUE)/sum(SwingIndicator, na.rm=TRUE) * 100,
Z_Whiff_pct = sum(Zwhiffind, na.rm=TRUE)/sum(Zswing, na.rm=TRUE) * 100,
Chase_pct = sum(Chaseindicator, na.rm = TRUE) / sum(OutofZone, na.rm = TRUE) * 100,
xBA = mean(xBA[PitchCall == "InPlay" & TaggedHitType != "Bunt"], na.rm=TRUE),
.groups = "drop"
) %>%
filter(!is.na(AVG) & !is.infinite(AVG))
}
pool_summary <- summarize_block(pool_rows)
batter_summary <- summarize_block(batter_rows) %>% filter(Batter == batter_name)
if (!nrow(batter_summary) || !nrow(pool_summary)) {
return(ggplot() + theme_void() + ggtitle(paste("No data available for", batter_name)))
}
metrics <- c("K %","BB %","AVG","OBP","SLG","OPS","xBA","Avg EV","Max EV","SC%","10-30%","HH%","Barrel%","Whiff%","Z Whiff%","Chase%")
batter_vals <- c(batter_summary$K_pct, batter_summary$BB_pct, batter_summary$AVG,
batter_summary$OBP, batter_summary$SLG, batter_summary$OPS,
batter_summary$xBA, batter_summary$Avg_EV, batter_summary$Max_EV,
batter_summary$SC_pct, batter_summary$LA1030_pct, batter_summary$HH_pct,
batter_summary$Barrel_pct, batter_summary$Whiff_pct, batter_summary$Z_Whiff_pct,
batter_summary$Chase_pct)
pool_lists <- list(pool_summary$K_pct, pool_summary$BB_pct, pool_summary$AVG,
pool_summary$OBP, pool_summary$SLG, pool_summary$OPS,
pool_summary$xBA, pool_summary$Avg_EV, pool_summary$Max_EV,
pool_summary$SC_pct, pool_summary$LA1030_pct, pool_summary$HH_pct,
pool_summary$Barrel_pct, pool_summary$Whiff_pct, pool_summary$Z_Whiff_pct,
pool_summary$Chase_pct)
lower_better <- c("K %","Whiff%","Z Whiff%","Chase%")
percentiles <- sapply(seq_along(metrics), function(i) {
v <- batter_vals[i]; pop <- pool_lists[[i]]
if (all(is.na(pop)) || is.na(v)) return(NA_real_)
if (metrics[i] %in% lower_better) {
mean(pop >= v, na.rm=TRUE) * 100
} else {
mean(pop <= v, na.rm=TRUE) * 100
}
})
formatted_values <- sapply(seq_along(metrics), function(i) {
if (metrics[i] %in% c("AVG","OBP","SLG","OPS","xBA")) {
sprintf("%.3f", batter_vals[i])
} else {
sprintf("%.1f", batter_vals[i])
}
})
chart_data <- data.frame(
Metric = factor(metrics, levels = rev(metrics)),
Value = formatted_values,
Percentile = percentiles,
Fill = colorRampPalette(c("#E1463E","white","#00840D"))(100)[pmax(1, pmin(100, round(ifelse(is.na(percentiles), 1, percentiles))))]
)
ggplot(chart_data, aes(y=Metric)) +
geom_tile(aes(x=50, width=100), fill="#c7dcdc", alpha=.3, height=.8) +
geom_tile(aes(x=Percentile/2, width=Percentile, fill=Fill), height=.7) +
geom_text(aes(x=-3, label=Metric), hjust=1, size=4, color="black") +
geom_text(aes(x=103, label=Value), hjust=0, size=4, color="black") +
geom_point(aes(x=Percentile), color="black", size=8, shape=21, stroke=2, fill=chart_data$Fill) +
geom_text(aes(x=Percentile, label=round(Percentile)), size=3, color="black", fontface="bold") +
scale_x_continuous(limits=c(-16,113), expand=c(0,0)) +
scale_fill_identity() + theme_void() + ggtitle(paste("Player Percentiles:", batter_name)) +
theme(plot.title = element_text(hjust=0.5, face="bold", size=16),
panel.background=element_rect(fill="white"),
axis.text=element_blank(), axis.title=element_blank(), panel.grid=element_blank(),
plot.margin=margin(20,20,20,20))
}
format_stats_table <- function(dt_table, data_df) {
rank_colors <- colorRampPalette(c("#E1463E","white","#00840D"))(18)
rank_colors2 <- colorRampPalette(c("#00840D","white","#E1463E"))(18)
color_rank <- function(column) {
values <- data_df[[column]]
unique_values <- sort(unique(values), decreasing = TRUE)
value_to_color <- setNames(rank_colors[seq_along(unique_values)], unique_values)
value_to_color[as.character(values)]
}
color_rank2 <- function(column) {
values <- data_df[[column]]
unique_values<- sort(unique(values), decreasing = TRUE)
value_to_color <- setNames(rank_colors2[seq_along(unique_values)], unique_values)
value_to_color[as.character(values)]
}
dt_table %>%
formatStyle("OPS", backgroundColor = styleEqual(data_df$`OPS`, color_rank2("OPS"))) %>%
formatStyle("K %", backgroundColor = styleEqual(data_df$`K %`, color_rank("K %"))) %>%
formatStyle("BB %", backgroundColor = styleEqual(data_df$`BB %`, color_rank2("BB %"))) %>%
formatStyle("Free%", backgroundColor = styleEqual(data_df$`Free%`, color_rank2("Free%"))) %>%
formatStyle("AVG", backgroundColor = styleEqual(data_df$`AVG`, color_rank2("AVG"))) %>%
formatStyle("OBP", backgroundColor = styleEqual(data_df$`OBP`, color_rank2("OBP"))) %>%
formatStyle("SLG", backgroundColor = styleEqual(data_df$`SLG`, color_rank2("SLG"))) %>%
formatStyle("Avg EV", backgroundColor = styleEqual(data_df$`Avg EV`, color_rank2("Avg EV"))) %>%
formatStyle("Max EV", backgroundColor = styleEqual(data_df$`Max EV`, color_rank2("Max EV"))) %>%
formatStyle("SC%", backgroundColor = styleEqual(data_df$`SC%`, color_rank2("SC%"))) %>%
formatStyle("10-30%", backgroundColor = styleEqual(data_df$`10-30%`, color_rank2("10-30%"))) %>%
formatStyle("HH%", backgroundColor = styleEqual(data_df$`HH%`, color_rank2("HH%"))) %>%
formatStyle("Barrel%", backgroundColor = styleEqual(data_df$`Barrel%`, color_rank2("Barrel%"))) %>%
formatStyle("Whiff%", backgroundColor = styleEqual(data_df$`Whiff%`, color_rank("Whiff%"))) %>%
formatStyle("Z Whiff%", backgroundColor = styleEqual(data_df$`Z Whiff%`, color_rank("Z Whiff%"))) %>%
formatStyle("GB%", backgroundColor = styleEqual(data_df$`GB%`, color_rank("GB%"))) %>%
formatStyle("LD%", backgroundColor = styleEqual(data_df$`LD%`, color_rank2("LD%"))) %>%
formatStyle("FB%", backgroundColor = styleEqual(data_df$`FB%`, color_rank2("FB%"))) %>%
formatStyle("Pop%", backgroundColor = styleEqual(data_df$`Pop%`, color_rank("Pop%")))
}
# -------- DEFENSE FUNCTIONS -------
#-- star graph --
star_graph <- function(player, position){
if (position %in% c("RF", "CF", "LF")) {
player_data <- OAA_DF %>%
filter(obs_player == player) %>%
filter(hit_location == position) %>%
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"
)) %>%
dplyr::select(obs_player, hit_location, success_ind, catch_prob, closest_pos_dist,
HangTime, OAA, Date, Batter, Pitcher, Inning, star_group)
} else {
player_data <- OAA_DF %>%
filter(obs_player == player) %>%
mutate(hit_location = "All") %>%
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"
)) %>%
dplyr::select(obs_player, hit_location, success_ind, catch_prob, closest_pos_dist,
HangTime, OAA, Date, Batter, Pitcher, Inning, star_group)
}
OAA <- round(sum(player_data$OAA, na.rm = TRUE), 1)
n_plays <- nrow(player_data)
expected_success <- round(sum(player_data$catch_prob, na.rm = TRUE), 1)
actual_success <- sum(as.numeric(as.character(player_data$success_ind)), na.rm = TRUE)
p <- ggplot(OAA_DF, aes(closest_pos_dist, HangTime)) +
stat_summary_2d(aes(z = catch_prob), fun = mean, bins = 51) +
scale_fill_gradientn(
colors = c("white", "#ffffb2", "#feb24c", "#fc4e2a", "#800026"),
limits = c(0, 1),
breaks = c(0.01, 0.25, 0.5, 0.7, 0.9, 1),
labels = c("5 Star", "4 Star", "3 Star", "2 Star", "1 Star", " "),
name = "Catch Rating",
guide = guide_colorbar(barheight = unit(5, "in"))
) +
scale_x_continuous("Distance to Ball (ft)", limits = c(0, 140), breaks = seq(0, 140, by = 20)) +
scale_y_continuous("Hang Time (sec)", limits = c(2, 6)) +
theme_classic() +
ggtitle(paste0(player, " - ", position, " (", OAA, " OAA)")) +
theme(
legend.position = "right",
plot.title = element_text(hjust = .5, size = 20),
legend.title = element_blank(),
panel.grid.minor = element_blank(),
axis.title = element_text(size = 12),
axis.text = element_text(size = 10)
)
point_colors <- ifelse(player_data$success_ind == 1, "green4", "white")
p_interactive <- suppressWarnings({
ggplotly(p, tooltip = NULL) %>%
add_trace(
data = player_data,
x = ~closest_pos_dist,
y = ~HangTime,
type = "scatter",
mode = "markers",
color = I(point_colors),
marker = list(
size = 10,
line = list(color = "black", width = 1)
),
text = ~paste0(
"Date: ", Date, "
",
"Batter: ", Batter, "
",
"Pitcher: ", Pitcher, "
",
"Inning: ", Inning, "
",
"Opportunity Time: ", round(HangTime, 2), " sec
",
"Distance Needed: ", round(closest_pos_dist, 1), " ft
",
"Catch Probability: ", round(100 * catch_prob, 1), "%
",
star_group
),
hoverinfo = "text"
) %>%
layout(
hovermode = "closest",
title = list(
text = paste0(
player, " - ", position,
"
OAA: ", OAA, " | Plays: ", n_plays,
" | Expected Catches: ", expected_success, " | Actual Catches: ", actual_success, ""
),
font = list(size = 18)
),
margin = list(t = 80)
)
})
return(p_interactive)
}
# -- field graph --
make_curve_segments <- function(x_start, y_start, x_end, y_end, curvature = 0.3, n = 40) {
if (n < 2) stop("n must be >= 2")
t <- seq(0, 1, length.out = n)
cx <- (x_start + x_end) / 2 + curvature * (y_end - y_start)
cy <- (y_start + y_end) / 2 - curvature * (x_end - x_start)
x <- (1 - t)^2 * x_start + 2 * (1 - t) * t * cx + t^2 * x_end
y <- (1 - t)^2 * y_start + 2 * (1 - t) * t * cy + t^2 * y_end
idx_from <- seq_len(n - 1)
idx_to <- seq_len(n - 1) + 1
tibble::tibble(
x = x[idx_from],
y = y[idx_from],
xend = x[idx_to],
yend = y[idx_to]
)
}
curve1 <- make_curve_segments(89.095, 89.095, -1, 160, curvature = 0.36, n = 60)
curve2 <- make_curve_segments(-89.095, 89.095, 1, 160, curvature = -0.36, n = 60)
field_graph <- function(player, position){
if (position %in% c("RF", "CF", "LF")) {
player_data <- OAA_DF %>%
filter(obs_player == player) %>%
filter(hit_location == position)
} else {
player_data <- OAA_DF %>%
filter(obs_player == player) %>%
mutate(hit_location = "All")
}
df_filtered <- player_data %>%
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 = TRUE),
position == "RF" ~ mean(RF_PositionAtReleaseX, na.rm = TRUE),
position == "LF" ~ mean(LF_PositionAtReleaseX, na.rm = TRUE),
TRUE ~ NA_real_
),
avg_x = case_when(
position == "CF" ~ mean(CF_PositionAtReleaseZ, na.rm = TRUE),
position == "RF" ~ mean(RF_PositionAtReleaseZ, na.rm = TRUE),
position == "LF" ~ mean(LF_PositionAtReleaseZ, na.rm = TRUE),
TRUE ~ NA_real_
)) %>%
dplyr::select(obs_player, hit_location, star_group, catch_prob,
avg_x, avg_y, ends_with("AtReleaseX"), ends_with("AtReleaseZ"),
success_ind, Date, HangTime, closest_pos_dist, x_pos, z_pos,
angle_from_home, OAA)
df_table <- df_filtered %>%
group_by(star_group) %>%
summarize(OAA = round(sum(OAA, na.rm = TRUE), 1),
Plays = n(),
Successes = sum((as.numeric(success_ind) - 1), na.rm = TRUE),
.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_filtered$OAA, na.rm = TRUE), 2)) %>%
dplyr::select(OAA, everything()) %>%
gt::gt() %>%
gtExtras::gt_theme_guardian() %>%
gt::tab_header(title = "Play Success by Difficulty") %>%
gt::cols_align(align = "center", columns = gt::everything()) %>%
gt::sub_missing(columns = gt::everything(), missing_text = "-") %>%
gt::tab_options(
heading.background.color = "darkcyan",
column_labels.background.color = "darkcyan",
table.border.top.color = "peru",
table.border.bottom.color = "peru"
) %>%
gt::tab_style(
style = gt::cell_text(color = "white"),
locations = gt::cells_title(groups = "title"))
p <- ggplot() +
geom_segment(aes(x = 0, y = 0, xend = 318.1981, yend = 318.1981), color = "black") +
geom_segment(aes(x = 0, y = 0, xend = -318.1981, yend = 318.1981), color = "black") +
geom_segment(aes(x = 63.6396, y = 63.6396, xend = 0, yend = 127.279), color = "black") +
geom_segment(aes(x = -63.6396, y = 63.6396, xend = 0, yend = 127.279), color = "black") +
geom_segment(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_filtered %>% 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
",
"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"),
labels = c("Hit" = "Hit", "Catch" = "Catch"),
name = " "
) +
geom_point(data = df_filtered, aes(x = avg_x, y = avg_y),
fill = "red", color = "black", size = 3, shape = 21) +
coord_cartesian(xlim = c(-330, 330), ylim = c(0, 400)) +
coord_equal()
p <- ggplotly(p, tooltip = "text") %>%
plotly::layout(hovermode = "closest")
return(list(p, df_table))
}
# -- oaa radar chart --
direction_oaa <- function(player, position) {
direction_levels <- c(
"Back Right",
"Back",
"Back Left",
"Left",
"In Left",
"In",
"In Right",
"Right"
)
if (position %in% c("RF", "CF", "LF")) {
player_data <- OAA_DF %>%
filter(obs_player == player) %>%
filter(hit_location == position)
} else {
player_data <- OAA_DF %>%
filter(obs_player == player) %>%
mutate(hit_location = "All")
}
player_data <- player_data %>%
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 = direction_levels)
) %>%
dplyr::select(Direction = direction, OAA) %>%
group_by(Direction) %>%
summarize(
OAA = sum(OAA, na.rm = TRUE),
Plays = n(),
.groups = "drop"
) %>%
complete(Direction = direction_levels, fill = list(OAA = 0, Plays = 0)) %>%
arrange(factor(Direction, levels = direction_levels))
view(player_data)
player_data_table <- player_data %>%
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() %>%
gtExtras::gt_theme_guardian() %>%
gt::tab_header(title = "Directional OAA") %>%
gt::fmt_number(
columns = c(Back, `Back Left`, Left, `In Left`, In, `In Right`, Right, `Back Right`),
rows = Metric == "OAA",
decimals = 2
) %>%
gt::fmt_number(
columns = c(Back, `Back Left`, Left, `In Left`, In, `In Right`, Right, `Back Right`),
rows = Metric != "OAA",
decimals = 0
) %>%
gt::cols_align(align = "center", columns = gt::everything()) %>%
gt::sub_missing(columns = gt::everything(), missing_text = "-") %>%
gt::tab_options(
heading.background.color = "darkcyan",
column_labels.background.color = "darkcyan",
table.border.top.color = "peru",
table.border.bottom.color = "peru"
) %>%
gt::tab_style(
style = gt::cell_text(color = "white"),
locations = gt::cells_title(groups = "title")
)
axis_labels <- paste0(player_data$Direction, " (", player_data$Plays, ")")
radar_data <- player_data %>%
dplyr::select(Direction, OAA) %>%
pivot_wider(names_from = Direction, values_from = OAA)
player_matrix <- rbind(
max = rep(5, length(direction_levels)),
min = rep(-5, length(direction_levels)),
radar_data[1, direction_levels]
)
rownames(player_matrix) <- c("max", "min", player)
par(mar = c(1, 2, 4, 2))
p <- radarchart(
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 = 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(player, " - ", position, " OAA by Direction"))
return(list(p, player_data_table))
}
infield_star_graph <- function(player, position){
if (position %in% c("SS", "1B", "2B", "3B")) {
player_data <- IF_OAA %>%
filter(obs_player_name == player) %>%
filter(obs_player == position) %>%
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"
)) %>%
dplyr::select(obs_player_name, obs_player, success_ind, play_prob, obs_player_bearing_diff,
HangTime, OAA, Distance, Bearing, Direction, obs_player_bearing,
ExitSpeed, Angle, dist_from_lead_base, player_angle_rad,
Date, Pitcher, Inning, Batter, star_group)
} else {
player_data <- IF_OAA %>%
filter(obs_player_name == player) %>%
mutate(obs_player = "All") %>%
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"
)) %>%
dplyr::select(obs_player_name, obs_player, success_ind, play_prob, obs_player_bearing_diff,
HangTime, OAA, Distance, Bearing, Direction, obs_player_bearing,
ExitSpeed, Angle, dist_from_lead_base, player_angle_rad,
Date, Pitcher, Inning, Batter, star_group)
}
OAA <- round(sum(player_data$OAA, na.rm = TRUE), 1)
n_plays <- nrow(player_data)
expected_success <- round(sum(player_data$play_prob, na.rm = TRUE), 1)
actual_success <- sum(player_data$success_ind, na.rm = TRUE)
points_data <- player_data %>%
filter(play_prob > 0) %>%
filter(play_prob <= 0.5 | between(obs_player_bearing_diff, -12.5, 12.5))
p <- ggplot(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 = "Catch Rating",
guide = guide_colorbar(barheight = unit(5, "in"))
) +
scale_x_continuous("Bearing Difference (degrees)",
limits = c(-25, 25), breaks = seq(-25, 25, by = 12.5)) +
scale_y_continuous("Play Probability", limits = c(0, 1), labels = scales::percent) +
theme_minimal() +
theme(
legend.position = "right",
plot.title = element_text(hjust = 0.5, size = 18, face = "bold"),
plot.subtitle = element_text(hjust = 0.5, size = 12, color = "gray40"),
panel.grid.minor = element_blank(),
panel.grid.major = element_line(color = "gray90"),
axis.title = element_text(size = 12),
axis.text = element_text(size = 10),
legend.title = element_text(face = "bold")
)
point_colors <- ifelse(points_data$success_ind == 1, "green4", "white")
p_interactive <- suppressWarnings({
ggplotly(p, tooltip = NULL) %>%
add_trace(
data = points_data,
x = ~obs_player_bearing_diff,
y = ~play_prob,
type = "scatter",
mode = "markers",
color = I(point_colors),
marker = list(
size = 10,
line = list(color = "black", width = 1)
),
text = ~paste0(
"Date: ", Date, "
",
"Batter: ", Batter, "
",
"Pitcher: ", Pitcher, "
",
"Inning: ", Inning, "
",
"Play Probability: ", round(100 * play_prob, 1), "%
",
"Angular Distance Away: ", round(obs_player_bearing_diff, 1), "º
",
"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: ", expected_success, " | Actual Plays Made: ", actual_success, ""
),
font = list(size = 18)
),
margin = list(t = 80)
)
})
suppressWarnings(p_interactive)
}
infield_field_graph <- function(player, position){
if (position %in% c("SS", "1B", "2B", "3B")) {
player_data <- IF_OAA %>%
filter(obs_player_name == player) %>%
filter(obs_player == position)
} else {
player_data <- IF_OAA %>%
filter(obs_player_name == player)
}
df_filtered <- player_data %>%
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 = TRUE),
avg_x = mean(obs_player_z, na.rm = TRUE)) %>%
ungroup() %>%
dplyr::select(obs_player_name, obs_player, success_ind, play_prob, obs_player_bearing_diff,
HangTime, OAA, Distance, Bearing, Direction, HangTime, ExitSpeed, Angle, dist_from_lead_base,
player_angle_rad, avg_y, avg_x, star_group, x_pos, z_pos, Date, Batter, Pitcher)
df_table <- df_filtered %>%
group_by(star_group) %>%
summarize(OAA = round(sum(OAA, na.rm = TRUE), 1),
Plays = n(),
Successes = sum((success_ind), na.rm = TRUE),
.groups = "drop") %>%
mutate(`Success Rate` = paste0(Successes, "/", Plays, " (", round((Successes/Plays)*100, 1), "%)")) %>%
dplyr::select(`Star Group` = star_group, `Success Rate`) %>%
t() %>%
as.data.frame() %>%
`colnames<-`(.[1,]) %>%
dplyr::slice(-1) %>%
mutate(OAA = round(sum(df_filtered$OAA, na.rm = TRUE), 1)) %>%
dplyr::select(OAA, everything()) %>%
gt::gt() %>%
gtExtras::gt_theme_guardian() %>%
gt::tab_header(title = "Play Success by Difficulty") %>%
gt::cols_align(align = "center", columns = gt::everything()) %>%
gt::sub_missing(columns = gt::everything(), missing_text = "-") %>%
gt::tab_options(
heading.background.color = "darkcyan",
column_labels.background.color = "darkcyan",
table.border.top.color = "peru",
table.border.bottom.color = "peru"
) %>%
gt::tab_style(
style = gt::cell_text(color = "white"),
locations = gt::cells_title(groups = "title"))
p <- ggplot() +
geom_segment(aes(x = 0, y = 0, xend = 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") +
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_filtered %>% 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), "%
",
"Angular Distance Away (degrees)", round(obs_player_bearing_diff, 1), "º
",
"Exit Velocity: ", round(ExitSpeed, 1), " sec
",
"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"),
labels = c("Play Was Not Made" = "Play Was Not Made", "Play Was Made" = "Play Was Made"),
name = " "
) +
geom_point(data = df_filtered, aes(x = avg_x, y = avg_y),
fill = "red", color = "black", size = 3, shape = 21) +
ylim(0, 160) +
xlim(-120, 120) +
coord_equal()
p <- ggplotly(p, tooltip = "text") %>%
plotly::layout(hovermode = "closest")
return(list(p, df_table))
}
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)
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 = "2025-06-22",
obs_pitcher = "No Filter",
Hitter = "No Filter",
Count = "No Filter",
obs_outs = "No Filter",
Opponent = "No Filter") {
team_data <- 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") {
team_data <- team_data %>% filter(man_on_firstbase == man_on_first)
}
if (man_on_second != "No Filter") {
team_data <- team_data %>% filter(man_on_secondbase == man_on_second)
}
if (man_on_third != "No Filter") {
team_data <- team_data %>% filter(man_on_thirdbase == man_on_third)
}
if (BatterHand != "No Filter") {
team_data <- team_data %>% filter(BatterSide == BatterHand)
}
if (PitcherHand != "No Filter") {
team_data <- team_data %>% filter(PitcherThrows == PitcherHand)
}
if (scorediff != "No Filter") {
team_data <- team_data %>% filter(scorediff_fieldpov == scorediff)
}
team_data <- team_data %>% filter(Date >= Date1 & Date <= Date2)
if (obs_pitcher != "No Filter") {
team_data <- team_data %>% filter(Pitcher == obs_pitcher)
}
if (Hitter != "No Filter") {
team_data <- team_data %>% filter(Batter == Hitter)
}
if (Count != "No Filter") {
team_data <- team_data %>% filter(pitch_count == Count)
}
if (obs_outs != "No Filter") {
team_data <- team_data %>% filter(Outs == obs_outs)
}
if (Opponent != "No Filter") {
team_data <- team_data %>% filter(BatterTeam == Opponent)
}
n_observations <- nrow(team_data)
positions_long <- team_data %>%
pivot_longer(
cols = matches("^(1B|2B|3B|SS|LF|CF|RF)_PositionAtRelease[XZ]$"),
names_to = c("position", ".value"),
names_pattern = "^(.+)_PositionAtRelease([XZ])$"
)
positions_table <- positions_long %>%
group_by(position) %>%
summarize(median_x = median(X, na.rm = TRUE),
median_z = median(Z, na.rm = TRUE))
p <- ggplot() +
geom_hline(yintercept = seq(0, 400, by = 20), linetype = "dotted", color = "gray80", size = 0.3) +
geom_vline(xintercept = seq(-240, 240, by = 20), linetype = "dotted", color = "gray80", size = 0.3) +
geom_density_2d_filled(data = positions_long, aes(x = Z, y = X), bins = 35, alpha = 0.5) +
scale_fill_manual(values = colorRampPalette(c("#FFFFFF", "#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 = 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 = positions_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 — ", team),
subtitle = paste0("Graph created from ", n_observations, " 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()
)
return(p)
}
assign_zones <- function(row) {
plate_x <- row['PlateLocSide']
plate_z <- row['PlateLocHeight']
if (is.na(plate_x) || is.na(plate_z)) return('NA')
if (-8.5 < plate_x & plate_x < -17/6 & 34 < plate_z & plate_z < 42) return('1')
else if (-17/6 < plate_x & plate_x < 17/6 & 34 < plate_z & plate_z < 42) return('2')
else if (17/6 < plate_x & plate_x < 8.5 & 34 < plate_z & plate_z < 42) return('3')
else if (-8.5 < plate_x & plate_x < -17/6 & 26 < plate_z & plate_z < 34) return('4')
else if (-17/6 < plate_x & plate_x < 17/6 & 26 < plate_z & plate_z < 34) return('5')
else if (17/6 < plate_x & plate_x < 8.5 & 26 < plate_z & plate_z < 34) return('6')
else if (-8.5 < plate_x & plate_x < -17/6 & 18 < plate_z & plate_z < 26) return('7')
else if (-17/6 < plate_x & plate_x < 17/6 & 18 < plate_z & plate_z < 26) return('8')
else if (17/6 < plate_x & plate_x < 8.5 & 18 < plate_z & plate_z < 26) return('9')
else if (-13.33 < plate_x & plate_x < -8.5 & 30 < plate_z & plate_z < 48) return('10')
else if (-13.33 < plate_x & plate_x < 0 & 42 < plate_z & plate_z < 46) return('10')
else if (8.5 < plate_x & plate_x < 13.33 & 30 < plate_z & plate_z < 46) return('11')
else if (0 < plate_x & plate_x < 13.33 & 42 < plate_z & plate_z < 46) return('11')
else if (-13.33 < plate_x & plate_x < -8.5 & 12 < plate_z & plate_z < 30) return('12')
else if (-13.33 < plate_x & plate_x < 0 & 12 < plate_z & plate_z < 18) return('12')
else if (8.5 < plate_x & plate_x < 13.33 & 12 < plate_z & plate_z < 30) return('13')
else if (0 < plate_x & plate_x < 13.33 & 12 < plate_z & plate_z < 18) return('13')
else return('20')
}
zone_coordinates <- list(
`1` = list(x = c(-8.5, -17/6, -17/6, -8.5), y = c(34, 34, 42, 42)),
`2` = list(x = c(-17/6, -17/6, 17/6, 17/6), y = c(34, 42, 42, 34)),
`3` = list(x = c(17/6, 17/6, 8.5, 8.5), y = c(34, 42, 42, 34)),
`4` = list(x = c(-8.5, -17/6, -17/6, -8.5), y = c(26, 26, 34, 34)),
`5` = list(x = c(-17/6, -17/6, 17/6, 17/6), y = c(26, 34, 34, 26)),
`6` = list(x = c(17/6, 17/6, 8.5, 8.5), y = c(26, 34, 34, 26)),
`7` = list(x = c(-8.5, -17/6, -17/6, -8.5), y = c(18, 18, 26, 26)),
`8` = list(x = c(-17/6, -17/6, 17/6, 17/6), y = c(18, 26, 26, 18)),
`9` = list(x = c(17/6, 17/6, 8.5, 8.5), y = c(18, 26, 26, 18)),
`10` = list(x = c(-13.33, -13.33, 0, 0, -8.5, -8.5, -13.33), y = c(30, 48, 48, 42, 42, 30, 30)),
`11` = list(x = c(13.33, 13.33, 0, 0, 8.5, 8.5, 13.33), y = c(30, 48, 48, 42, 42, 30, 30)),
`12` = list(x = c(-13.33, -13.33, 0, 0, -8.5, -8.5, -13.33), y = c(30, 12, 12, 18, 18, 30, 30)),
`13` = list(x = c(13.33, 13.33, 0, 0, 8.5, 8.5, 13.33), y = c(30, 12, 12, 18, 18, 30, 30))
)
get_zone_polygons_df <- function() {
zone_polys <- lapply(names(zone_coordinates), function(z) {
data.frame(
zone = as.character(z),
x = zone_coordinates[[z]]$x,
y = zone_coordinates[[z]]$y
)
}) %>% dplyr::bind_rows()
zone_centers <- zone_polys %>%
dplyr::group_by(zone) %>%
dplyr::summarise(cx = mean(x, na.rm = TRUE), cy = mean(y, na.rm = TRUE), .groups = "drop")
list(polys = zone_polys, centers = zone_centers)
}
# ---- RV/100 Calculation ----
weights_vec <- c(
"Ball" = -0.0892681735045099,
"Called Strike" = 0.121194020810818,
"Double" = -0.859369443968486,
"Field Out" = 0.327827741132251,
"Foul Ball" = 0.0709399019613328,
"HBP" = -0.423817625210998,
"Home Run" = -1.42174059796598,
"Single" = -0.556264454815573,
"Triple" = -1.09689513374671,
"Whiff" = 0.174095217814355,
"NA" = 0.0110712767697598
)
map_dre_event <- function(PitchCall, PlayResult) {
dplyr::case_when(
PitchCall %in% c("BallCalled","BallinDirt") ~ "Ball",
PitchCall == "StrikeCalled" ~ "Called Strike",
PitchCall == "StrikeSwinging" ~ "Whiff",
PitchCall %in% c("FoulBallNotFieldable","FoulBallFieldable") ~ "Foul Ball",
PitchCall == "HitByPitch" ~ "HBP",
PitchCall == "InPlay" & PlayResult == "Single" ~ "Single",
PitchCall == "InPlay" & PlayResult == "Double" ~ "Double",
PitchCall == "InPlay" & PlayResult == "Triple" ~ "Triple",
PitchCall == "InPlay" & PlayResult == "HomeRun" ~ "Home Run",
PitchCall == "InPlay" & PlayResult == "Out" ~ "Field Out",
TRUE ~ "NA"
)
}
calculate_rv100 <- function(df) {
df %>%
mutate(
dre_event = map_dre_event(PitchCall, PlayResult),
rv_pitcher = as.numeric(weights_vec[dre_event]),
rv_pitcher = ifelse(is.na(rv_pitcher), weights_vec["NA"], rv_pitcher),
hitter_rv = -rv_pitcher
)
}
# CALCULATE GRADE METRICS (MUST BE AFTER calculate_rv100 IS DEFINED)
grade_metrics <- bind_rows(P5_2025, SBC_2025) %>%
filter(!is.na(TaggedPitchType)) %>%
calculate_rv100() %>%
group_by(Batter) %>%
summarise(
sdrv = 100 * mean(hitter_rv, na.rm = TRUE),
xba = mean(xBA[PitchCall %in% "InPlay"], na.rm = TRUE),
iso = (sum(totalbases, na.rm = TRUE) / sum(ABindicator, na.rm = TRUE)) -
(sum(HitIndicator, na.rm = TRUE) / sum(ABindicator, na.rm = TRUE)),
xpf = sum(totalbases[PitchCall %in% "InPlay"], na.rm = TRUE) /
sum(HitIndicator[PitchCall %in% "InPlay"], na.rm = TRUE),
zone_con = 100 * (1 - sum(Zwhiffind, na.rm = TRUE) / sum(Zswing, na.rm = TRUE)),
k_pct = 100 * sum(KorBB %in% "Strikeout", na.rm = TRUE) / sum(PAindicator, na.rm = TRUE),
ev90 = quantile(ExitSpeed[PitchCall %in% "InPlay"], 0.9, na.rm = TRUE),
con2k = 100 * (1 - sum(WhiffIndicator[Strikes %in% 2], na.rm = TRUE) /
sum(SwingIndicator[Strikes %in% 2], na.rm = TRUE)),
.groups = "drop"
) %>%
summarise(
sdrv_mean = mean(sdrv, na.rm = TRUE),
sdrv_sd = sd(sdrv, na.rm = TRUE),
xba_mean = mean(xba, na.rm = TRUE),
xba_sd = sd(xba, na.rm = TRUE),
iso_mean = mean(iso, na.rm = TRUE),
iso_sd = sd(iso, na.rm = TRUE),
xpf_mean = mean(xpf, na.rm = TRUE),
xpf_sd = sd(xpf, na.rm = TRUE),
zone_con_mean = mean(zone_con, na.rm = TRUE),
zone_con_sd = sd(zone_con, na.rm = TRUE),
k_mean = mean(k_pct, na.rm = TRUE),
k_sd = sd(k_pct, na.rm = TRUE),
ev90_mean = mean(ev90, na.rm = TRUE),
ev90_sd = sd(ev90, na.rm = TRUE),
con2k_mean = mean(con2k, na.rm = TRUE),
con2k_sd = sd(con2k, na.rm = TRUE)
)
create_hitting_grades_radar <- function(batter_name, team_data) {
# Calculate hitting grades
batter_split <- team_data %>%
filter(!is.na(TaggedPitchType), Batter == batter_name) %>%
calculate_rv100() %>%
group_by(PitcherThrows) %>%
summarise(
xBA = mean(xBA[PitchCall == "InPlay"], na.rm = TRUE),
ISO = (sum(totalbases, na.rm = TRUE) / sum(ABindicator, na.rm = TRUE)) -
(sum(HitIndicator, na.rm = TRUE) / sum(ABindicator, na.rm = TRUE)),
xPowerFactor = sum(totalbases[PitchCall == "InPlay"], na.rm = TRUE) /
sum(HitIndicator[PitchCall == "InPlay"], na.rm = TRUE),
sdrv = 100 * mean(hitter_rv, na.rm = TRUE),
zone_con = 100 * (1 - sum(Zwhiffind, na.rm = TRUE) / sum(Zswing, na.rm = TRUE)),
`K%` = 100 * sum(KorBB == "Strikeout", na.rm = TRUE) / sum(PAindicator, na.rm = TRUE),
ev90 = quantile(ExitSpeed[PitchCall == "InPlay"], 0.9, na.rm = TRUE),
con2k = 100 * (1 - sum(WhiffIndicator[Strikes == 2], na.rm = TRUE) /
sum(SwingIndicator[Strikes == 2], na.rm = TRUE)),
.groups = "drop"
) %>%
rowwise() %>%
mutate(
`Swing Decisions` = ((sdrv - grade_metrics$sdrv_mean) / grade_metrics$sdrv_sd) * 10 + 50,
`Game Power` = (((xPowerFactor - grade_metrics$xpf_mean) / grade_metrics$xpf_sd) +
((ISO - grade_metrics$iso_mean) / grade_metrics$iso_sd)) * 5 + 50,
`Raw Power` = ((ev90 - grade_metrics$ev90_mean) / grade_metrics$ev90_sd) * 10 + 50,
`Avoid K` = (((grade_metrics$k_mean - `K%`) / grade_metrics$k_sd) +
((con2k - grade_metrics$con2k_mean) / grade_metrics$con2k_sd)) * 5 + 50,
Contact = (((zone_con - grade_metrics$zone_con_mean) / grade_metrics$zone_con_sd) +
((xBA - grade_metrics$xba_mean) / grade_metrics$xba_sd)) * 5 + 50,
group = ifelse(PitcherThrows == "Right", "vs RHP", "vs LHP")
) %>%
ungroup() %>%
mutate(across(c(`Swing Decisions`, `Game Power`, `Raw Power`, `Avoid K`, Contact),
~round(pmax(pmin(., 80), 20))))
if (!nrow(batter_split)) {
return(ggplot() + theme_void() +
ggtitle(paste("No data available for", batter_name)) +
theme(plot.title = element_text(hjust = 0.5, size = 14, face = "bold")))
}
ggradar(
batter_split %>%
select(group, `Swing Decisions`, `Raw Power`, `Game Power`, `Avoid K`, Contact),
values.radar = c("20", "50", "80"),
grid.min = 20,
grid.mid = 50,
grid.max = 80,
fill = TRUE,
fill.alpha = 0.3,
group.point.size = 3,
group.colours = c("#0d56ab", "#fa5e02"), # Blue for LHP, Orange for RHP
background.circle.colour = "white",
gridline.mid.colour = "grey80",
gridline.min.linetype = "dashed",
gridline.max.linetype = "solid",
axis.label.size = 4,
grid.label.size = 4,
legend.position = "bottom",
legend.text.size = 12,
legend.title = " "
) +
ggtitle(paste("Grading Radar:", batter_name)) +
theme(plot.title = element_text(hjust = 0.5, size = 14, face = "bold"))
}
# ============================================
# SECTION 1: HELPER FUNCTIONS
# ============================================
get_swing_direction <- function(haa, batter_side) {
case_when(
is.na(haa) | is.na(batter_side) ~ "Unknown",
batter_side == "Right" & haa > 5 ~ "Pull",
batter_side == "Right" & haa < -5 ~ "Oppo",
batter_side == "Left" & haa < -5 ~ "Pull",
batter_side == "Left" & haa > 5 ~ "Oppo",
TRUE ~ "Middle"
)
}
pitch_colors <- c(
"Fastball" = "#D22D49", "Sinker" = "#FE9D00", "Cutter" = "#933F2C",
"Slider" = "#EEE716", "Sweeper" = "#EEE716", "Curveball" = "#00D1ED",
"ChangeUp" = "#1DBE3A", "Splitter" = "#3BACAC", "Knuckle Curve" = "#00D1ED",
"Other" = "gray50"
)
# ============================================
# CHART 1: Simple Attack Angle Visualization
# Labels at top, 5-20° ideal zone
# ============================================
create_simple_aa_viz <- function(batter_name, swing_data, swing_index = NULL) {
if (!is.null(swing_index) && swing_index != "All" && nrow(swing_data) >= as.integer(swing_index)) {
row_data <- swing_data[as.integer(swing_index), ]
aa <- round(row_data$VerticalAttackAngle, 1)
title_suffix <- paste0(" - Swing #", swing_index)
n_swings <- 1
} else {
df <- swing_data %>% filter(!is.na(VerticalAttackAngle))
if (nrow(df) < 1) {
return(ggplot() + theme_void() + ggtitle("No attack angle data") +
theme(plot.title = element_text(hjust = 0.5, size = 14, face = "bold")))
}
aa <- round(mean(df$VerticalAttackAngle, na.rm = TRUE), 1)
title_suffix <- ""
n_swings <- nrow(df)
}
contact_x <- 0
contact_y <- 0
attack_line_length <- 3
aa_x_start <- contact_x - attack_line_length * cos(aa * pi / 180)
aa_y_start <- contact_y - attack_line_length * sin(aa * pi / 180)
ref_5_x <- contact_x - 2.5 * cos(5 * pi / 180)
ref_5_y <- contact_y - 2.5 * sin(5 * pi / 180)
ref_20_x <- contact_x - 2.5 * cos(20 * pi / 180)
ref_20_y <- contact_y - 2.5 * sin(20 * pi / 180)
aa_in_ideal <- aa >= 5 & aa <= 20
aa_color <- ifelse(aa_in_ideal, "#2E8B57", "#8B0000")
ideal_color <- "#2E8B57"
ref_color <- "gray70"
ggplot() +
geom_segment(aes(x = -4, y = 0, xend = 4, yend = 0), color = "black", linewidth = 1) +
annotate("polygon",
x = c(contact_x, ref_5_x, ref_20_x, contact_x),
y = c(contact_y, ref_5_y, ref_20_y, contact_y),
fill = ideal_color, alpha = 0.15) +
geom_segment(aes(x = contact_x - 2.8, y = 0, xend = contact_x, yend = 0),
color = ref_color, linewidth = 0.6, linetype = "dotted") +
geom_segment(aes(x = ref_5_x, y = ref_5_y, xend = contact_x, yend = contact_y),
color = ref_color, linewidth = 0.6, linetype = "dashed") +
geom_segment(aes(x = ref_20_x, y = ref_20_y, xend = contact_x, yend = contact_y),
color = ref_color, linewidth = 0.6, linetype = "dashed") +
geom_segment(aes(x = contact_x - 2.5 * cos(30 * pi/180), y = contact_y - 2.5 * sin(30 * pi/180),
xend = contact_x, yend = contact_y),
color = ref_color, linewidth = 0.4, linetype = "dotted") +
geom_segment(aes(x = aa_x_start, y = aa_y_start, xend = contact_x, yend = contact_y),
color = aa_color, linewidth = 3) +
geom_point(aes(x = contact_x, y = contact_y),
fill = "white", color = "black", shape = 21, size = 8, stroke = 2) +
annotate("text", x = -3, y = 0.15, label = "0°", size = 3, color = "gray50") +
annotate("text", x = ref_5_x - 0.3, y = ref_5_y - 0.1, label = "5°", size = 3, color = "gray50") +
annotate("text", x = ref_20_x - 0.3, y = ref_20_y - 0.1, label = "20°", size = 3, color = "gray50") +
annotate("text", x = contact_x - 2.7 * cos(30 * pi/180) - 0.2,
y = contact_y - 2.7 * sin(30 * pi/180), label = "30°", size = 3, color = "gray50") +
# Label at TOP instead of on arrow
annotate("label", x = 0, y = 2.2,
label = paste0("Attack Angle: ", aa, "°"),
size = 5, fontface = "bold", fill = aa_color, color = "white") +
annotate("text", x = -3.5, y = -1.2,
label = "Ideal AA: 5° - 20°", size = 3.5, color = ideal_color, fontface = "italic") +
labs(title = paste0(batter_name, " - Attack Angle", title_suffix),
subtitle = paste0("n=", n_swings, " swings")) +
coord_fixed(ratio = 1) +
xlim(-4.5, 4) + ylim(-1.5, 2.8) +
theme_minimal() +
theme(plot.title = element_text(hjust = 0.5, size = 16, face = "bold"),
plot.subtitle = element_text(hjust = 0.5, size = 11, color = "gray40"),
axis.text = element_blank(), axis.ticks = element_blank(),
axis.title = element_blank(), panel.grid = element_blank())
}
# ============================================
# CHART 2: Attack Angle + Launch Angle
# 10-30° LA zone green, labels at top
# ============================================
create_aa_la_viz <- function(batter_name, swing_data, swing_index = NULL) {
if (!is.null(swing_index) && swing_index != "All" && nrow(swing_data) >= as.integer(swing_index)) {
row_data <- swing_data[as.integer(swing_index), ]
aa <- round(row_data$VerticalAttackAngle, 1)
la <- round(row_data$Angle, 1)
bat_speed <- round(row_data$BatSpeed, 1)
exit_velo <- ifelse(!is.na(row_data$ExitSpeed), round(row_data$ExitSpeed, 1), NA)
title <- paste0(batter_name, " - AA & LA")
n_swings <- 1
} else {
df <- swing_data %>% filter(!is.na(VerticalAttackAngle), !is.na(Angle))
if (nrow(df) < 1) {
return(ggplot() + theme_void() + ggtitle("No AA/LA data") +
theme(plot.title = element_text(hjust = 0.5, size = 14, face = "bold")))
}
aa <- round(mean(df$VerticalAttackAngle, na.rm = TRUE), 1)
la <- round(mean(df$Angle, na.rm = TRUE), 1)
bat_speed <- round(mean(df$BatSpeed, na.rm = TRUE), 1)
exit_velo <- round(mean(df$ExitSpeed, na.rm = TRUE), 1)
title <- paste0(batter_name, " - Attack Angle & Launch Angle")
n_swings <- nrow(df)
}
if (is.na(la)) la <- 0
ideal_color <- "#2E8B57"
main_color <- "#8B0000"
ref_color <- "gray70"
contact_x <- 0; contact_y <- 0
attack_line_length <- 3; launch_line_length <- 3.5
aa_x_start <- contact_x - attack_line_length * cos(aa * pi / 180)
aa_y_start <- contact_y - attack_line_length * sin(aa * pi / 180)
la_x_end <- contact_x + launch_line_length * cos(la * pi / 180)
la_y_end <- contact_y + launch_line_length * sin(la * pi / 180)
# Reference lines
ref_aa_5_x <- contact_x - 2.5 * cos(5 * pi / 180)
ref_aa_5_y <- contact_y - 2.5 * sin(5 * pi / 180)
ref_aa_20_x <- contact_x - 2.5 * cos(20 * pi / 180)
ref_aa_20_y <- contact_y - 2.5 * sin(20 * pi / 180)
ref_la_10_x <- contact_x + 3 * cos(10 * pi / 180)
ref_la_10_y <- contact_y + 3 * sin(10 * pi / 180)
ref_la_30_x <- contact_x + 3 * cos(30 * pi / 180)
ref_la_30_y <- contact_y + 3 * sin(30 * pi / 180)
aa_in_ideal <- aa >= 5 & aa <= 20
la_in_ideal <- la >= 10 & la <= 30
aa_color <- ifelse(aa_in_ideal, ideal_color, main_color)
la_color <- ifelse(la_in_ideal, ideal_color, main_color)
ggplot() +
geom_segment(aes(x = -4, y = 0, xend = 5, yend = 0), color = "black", linewidth = 1) +
# AA ideal zone (5-20°)
annotate("polygon",
x = c(contact_x, ref_aa_5_x, ref_aa_20_x, contact_x),
y = c(contact_y, ref_aa_5_y, ref_aa_20_y, contact_y),
fill = ideal_color, alpha = 0.15) +
# LA ideal zone (10-30°)
annotate("polygon",
x = c(contact_x, ref_la_10_x, ref_la_30_x, contact_x),
y = c(contact_y, ref_la_10_y, ref_la_30_y, contact_y),
fill = ideal_color, alpha = 0.15) +
# AA references
geom_segment(aes(x = contact_x - 2.8, y = 0, xend = contact_x, yend = 0),
color = ref_color, linewidth = 0.5, linetype = "dotted") +
geom_segment(aes(x = ref_aa_5_x, y = ref_aa_5_y, xend = contact_x, yend = contact_y),
color = ref_color, linewidth = 0.5, linetype = "dashed") +
geom_segment(aes(x = ref_aa_20_x, y = ref_aa_20_y, xend = contact_x, yend = contact_y),
color = ref_color, linewidth = 0.5, linetype = "dashed") +
# LA references
geom_segment(aes(x = contact_x, y = contact_y, xend = ref_la_10_x, yend = ref_la_10_y),
color = ref_color, linewidth = 0.5, linetype = "dashed") +
geom_segment(aes(x = contact_x, y = contact_y, xend = ref_la_30_x, yend = ref_la_30_y),
color = ref_color, linewidth = 0.5, linetype = "dashed") +
geom_segment(aes(x = contact_x, y = contact_y,
xend = contact_x + 3 * cos(40 * pi/180), yend = contact_y + 3 * sin(40 * pi/180)),
color = ref_color, linewidth = 0.4, linetype = "dotted") +
# Actual AA line
geom_segment(aes(x = aa_x_start, y = aa_y_start, xend = contact_x, yend = contact_y),
color = aa_color, linewidth = 3) +
# Actual LA line
geom_segment(aes(x = contact_x, y = contact_y, xend = la_x_end, yend = la_y_end),
color = la_color, linewidth = 3) +
geom_point(aes(x = contact_x, y = contact_y),
fill = "white", color = "black", shape = 21, size = 8, stroke = 2) +
# Reference labels
annotate("text", x = -3, y = 0.15, label = "0°", size = 2.5, color = "gray50") +
annotate("text", x = ref_aa_5_x - 0.25, y = ref_aa_5_y, label = "5°", size = 2.5, color = "gray50") +
annotate("text", x = ref_aa_20_x - 0.25, y = ref_aa_20_y, label = "20°", size = 2.5, color = "gray50") +
annotate("text", x = ref_la_10_x + 0.25, y = ref_la_10_y, label = "10°", size = 2.5, color = "gray50") +
annotate("text", x = ref_la_30_x + 0.25, y = ref_la_30_y, label = "30°", size = 2.5, color = "gray50") +
annotate("text", x = 3.3 * cos(40 * pi/180), y = 3.3 * sin(40 * pi/180), label = "40°", size = 2.5, color = "gray50") +
# Labels at TOP
annotate("label", x = -2.5, y = 2.8,
label = paste0("AA: ", aa, "°"), size = 4.5, fontface = "bold", fill = aa_color, color = "white") +
annotate("label", x = 2.5, y = 2.8,
label = paste0("LA: ", la, "°"), size = 4.5, fontface = "bold", fill = la_color, color = "white") +
# Ideal text
annotate("text", x = -3.5, y = -1.2, label = "Ideal AA: 5°-20°", size = 3, color = ideal_color, fontface = "italic") +
annotate("text", x = 3.5, y = -1.2, label = "Ideal LA: 10°-30°", size = 3, color = ideal_color, fontface = "italic") +
labs(title = title, subtitle = paste0("n=", n_swings, " swings")) +
coord_fixed(ratio = 1) +
xlim(-4.5, 5) + ylim(-1.5, 3.5) +
theme_minimal() +
theme(plot.title = element_text(hjust = 0.5, size = 16, face = "bold"),
plot.subtitle = element_text(hjust = 0.5, size = 11, color = "gray40"),
axis.text = element_blank(), axis.ticks = element_blank(),
axis.title = element_blank(), panel.grid = element_blank())
}
# ============================================
# CHART 3: Attack Angle + VAA
# VAA from right (incoming pitch), labels at top
# ============================================
create_aa_vaa_viz <- function(batter_name, swing_data, swing_index = NULL) {
if (!is.null(swing_index) && swing_index != "All" && nrow(swing_data) >= as.integer(swing_index)) {
row_data <- swing_data[as.integer(swing_index), ]
aa <- round(row_data$VerticalAttackAngle, 1)
vaa <- round(row_data$VertApprAngle, 1)
title <- paste0(batter_name, " - AA vs VAA")
n_swings <- 1
} else {
df <- swing_data %>% filter(!is.na(VerticalAttackAngle), !is.na(VertApprAngle))
if (nrow(df) < 1) {
return(ggplot() + theme_void() + ggtitle("No AA/VAA data") +
theme(plot.title = element_text(hjust = 0.5, size = 14, face = "bold")))
}
aa <- round(mean(df$VerticalAttackAngle, na.rm = TRUE), 1)
vaa <- round(mean(df$VertApprAngle, na.rm = TRUE), 1)
title <- paste0(batter_name, " - Attack Angle vs Pitch VAA")
n_swings <- nrow(df)
}
if (is.na(vaa)) vaa <- -6
aa_color <- "#2E8B57"
vaa_color <- "#4169E1"
ideal_color <- "#2E8B57"
ref_color <- "gray70"
contact_x <- 0; contact_y <- 0; line_length <- 3
aa_x_start <- contact_x - line_length * cos(aa * pi / 180)
aa_y_start <- contact_y - line_length * sin(aa * pi / 180)
vaa_abs <- abs(vaa)
vaa_x_start <- contact_x + line_length * cos(vaa_abs * pi / 180)
vaa_y_start <- contact_y + line_length * sin(vaa_abs * pi / 180)
ref_aa_5_x <- contact_x - 2.5 * cos(5 * pi / 180)
ref_aa_5_y <- contact_y - 2.5 * sin(5 * pi / 180)
ref_aa_20_x <- contact_x - 2.5 * cos(20 * pi / 180)
ref_aa_20_y <- contact_y - 2.5 * sin(20 * pi / 180)
ref_vaa_5_x <- contact_x + 2.5 * cos(5 * pi / 180)
ref_vaa_5_y <- contact_y + 2.5 * sin(5 * pi / 180)
ref_vaa_10_x <- contact_x + 2.5 * cos(10 * pi / 180)
ref_vaa_10_y <- contact_y + 2.5 * sin(10 * pi / 180)
ref_vaa_15_x <- contact_x + 2.5 * cos(15 * pi / 180)
ref_vaa_15_y <- contact_y + 2.5 * sin(15 * pi / 180)
aa_in_ideal <- aa >= 5 & aa <= 20
display_aa_color <- ifelse(aa_in_ideal, aa_color, "#8B0000")
ggplot() +
geom_segment(aes(x = -4, y = 0, xend = 4, yend = 0), color = "black", linewidth = 1.2) +
# AA ideal zone
annotate("polygon",
x = c(contact_x, ref_aa_5_x, ref_aa_20_x, contact_x),
y = c(contact_y, ref_aa_5_y, ref_aa_20_y, contact_y),
fill = ideal_color, alpha = 0.15) +
# AA refs
geom_segment(aes(x = ref_aa_5_x, y = ref_aa_5_y, xend = contact_x, yend = contact_y),
color = ref_color, linewidth = 0.5, linetype = "dashed") +
geom_segment(aes(x = ref_aa_20_x, y = ref_aa_20_y, xend = contact_x, yend = contact_y),
color = ref_color, linewidth = 0.5, linetype = "dashed") +
# VAA refs
geom_segment(aes(x = ref_vaa_5_x, y = ref_vaa_5_y, xend = contact_x, yend = contact_y),
color = ref_color, linewidth = 0.5, linetype = "dotted") +
geom_segment(aes(x = ref_vaa_10_x, y = ref_vaa_10_y, xend = contact_x, yend = contact_y),
color = ref_color, linewidth = 0.5, linetype = "dotted") +
geom_segment(aes(x = ref_vaa_15_x, y = ref_vaa_15_y, xend = contact_x, yend = contact_y),
color = ref_color, linewidth = 0.5, linetype = "dotted") +
# AA line
geom_segment(aes(x = aa_x_start, y = aa_y_start, xend = contact_x, yend = contact_y),
color = display_aa_color, linewidth = 3) +
# VAA line with arrow
geom_segment(aes(x = vaa_x_start, y = vaa_y_start, xend = contact_x, yend = contact_y),
color = vaa_color, linewidth = 2.5,
arrow = arrow(length = unit(0.15, "inches"), type = "closed", ends = "last")) +
geom_point(aes(x = contact_x, y = contact_y),
fill = "white", color = "black", shape = 21, size = 8, stroke = 2) +
# Ref labels
annotate("text", x = -3.1, y = 0.2, label = "0°", size = 2.5, color = "gray50") +
annotate("text", x = ref_aa_5_x - 0.3, y = ref_aa_5_y, label = "5°", size = 2.5, color = "gray50") +
annotate("text", x = ref_aa_20_x - 0.3, y = ref_aa_20_y, label = "20°", size = 2.5, color = "gray50") +
annotate("text", x = 3.1, y = 0.2, label = "0°", size = 2.5, color = "gray50") +
annotate("text", x = ref_vaa_5_x + 0.35, y = ref_vaa_5_y, label = "-5°", size = 2.5, color = "gray50") +
annotate("text", x = ref_vaa_10_x + 0.4, y = ref_vaa_10_y, label = "-10°", size = 2.5, color = "gray50") +
annotate("text", x = ref_vaa_15_x + 0.4, y = ref_vaa_15_y, label = "-15°", size = 2.5, color = "gray50") +
# Labels at TOP
annotate("label", x = -2.5, y = 2.5,
label = paste0("AA: ", aa, "°"), size = 4.5, fontface = "bold", fill = display_aa_color, color = "white") +
annotate("label", x = 2.5, y = 2.5,
label = paste0("VAA: ", vaa, "°"), size = 4.5, fontface = "bold", fill = vaa_color, color = "white") +
# Axis labels
annotate("text", x = -3.5, y = -1.3, label = "ATTACK ANGLE", size = 3, color = aa_color, fontface = "bold") +
annotate("text", x = 3.5, y = -1.3, label = "PITCH VAA", size = 3, color = vaa_color, fontface = "bold") +
labs(title = title, subtitle = paste0("n=", n_swings, " swings")) +
coord_fixed(ratio = 1) +
xlim(-4.5, 4.5) + ylim(-1.8, 3) +
theme_minimal() +
theme(plot.title = element_text(hjust = 0.5, size = 16, face = "bold"),
plot.subtitle = element_text(hjust = 0.5, size = 11, color = "gray40"),
axis.text = element_blank(), axis.ticks = element_blank(),
axis.title = element_blank(), panel.grid = element_blank())
}
# ============================================
# CHART 4: Attack Direction (HAA) - Overhead View
# ============================================
create_attack_dir_viz <- function(batter_name, swing_data, swing_index = NULL) {
batter_side <- swing_data$BatterSide[1]
if (is.na(batter_side)) batter_side <- "Right"
if (!is.null(swing_index) && swing_index != "All" && nrow(swing_data) >= as.integer(swing_index)) {
row_data <- swing_data[as.integer(swing_index), ]
avg_haa <- round(row_data$HorizontalAttackAngle, 1)
title_suffix <- paste0(" - Swing #", swing_index)
n_swings <- 1
} else {
df <- swing_data %>% filter(!is.na(HorizontalAttackAngle))
if (nrow(df) < 1) {
return(ggplot() + theme_void() + ggtitle("No attack direction data") +
theme(plot.title = element_text(hjust = 0.5, size = 14, face = "bold")))
}
avg_haa <- round(mean(df$HorizontalAttackAngle, na.rm = TRUE), 1)
title_suffix <- ""
n_swings <- nrow(df)
}
df_dir <- swing_data %>%
filter(!is.na(HorizontalAttackAngle)) %>%
mutate(swing_direction = get_swing_direction(HorizontalAttackAngle, BatterSide))
pull_pct <- round(sum(df_dir$swing_direction == "Pull") / nrow(df_dir) * 100, 0)
mid_pct <- round(sum(df_dir$swing_direction == "Middle") / nrow(df_dir) * 100, 0)
oppo_pct <- round(sum(df_dir$swing_direction == "Oppo") / nrow(df_dir) * 100, 0)
swing_dir <- get_swing_direction(avg_haa, batter_side)
display_haa <- ifelse(batter_side == "Left", -avg_haa, avg_haa)
dir_color <- case_when(swing_dir == "Pull" ~ "#E41A1C", swing_dir == "Oppo" ~ "#377EB8", TRUE ~ "#4DAF4A")
plate_x <- c(-0.708, 0.708, 0.708, 0, -0.708)
plate_y <- c(0, 0, -0.5, -1, -0.5)
contact_x <- 0; contact_y <- 1.5; bat_length <- 2.5
bat_angle_rad <- (90 - display_haa) * pi / 180
bat_x_end <- contact_x + bat_length * cos(bat_angle_rad)
bat_y_end <- contact_y + bat_length * sin(bat_angle_rad)
ref_length <- 3
pull_angle <- ifelse(batter_side == "Right", 45, 135) * pi / 180
oppo_angle <- ifelse(batter_side == "Right", 135, 45) * pi / 180
center_angle <- 90 * pi / 180
ggplot() +
annotate("segment", x = 0, y = 0, xend = -4, yend = 4, color = "gray70", linewidth = 0.5) +
annotate("segment", x = 0, y = 0, xend = 4, yend = 4, color = "gray70", linewidth = 0.5) +
annotate("polygon", x = plate_x, y = plate_y, fill = "white", color = "black", linewidth = 1) +
annotate("rect", xmin = -3.5, xmax = -0.8, ymin = -1.5, ymax = 1.5, fill = NA, color = "black", linewidth = 0.8) +
annotate("rect", xmin = 0.8, xmax = 3.5, ymin = -1.5, ymax = 1.5, fill = NA, color = "black", linewidth = 0.8) +
annotate("text", x = ifelse(batter_side == "Right", -2.1, 2.1), y = 0,
label = ifelse(batter_side == "Right", "R", "L"), size = 10, fontface = "bold", color = "gray50") +
annotate("segment", x = contact_x, y = contact_y,
xend = contact_x + ref_length * cos(pull_angle), yend = contact_y + ref_length * sin(pull_angle),
color = "#E41A1C", linewidth = 0.8, linetype = "dashed", alpha = 0.5) +
annotate("segment", x = contact_x, y = contact_y,
xend = contact_x + ref_length * cos(oppo_angle), yend = contact_y + ref_length * sin(oppo_angle),
color = "#377EB8", linewidth = 0.8, linetype = "dashed", alpha = 0.5) +
annotate("segment", x = contact_x, y = contact_y,
xend = contact_x + ref_length * cos(center_angle), yend = contact_y + ref_length * sin(center_angle),
color = "#4DAF4A", linewidth = 0.8, linetype = "dashed", alpha = 0.5) +
annotate("label", x = ifelse(batter_side == "Right", -3.2, 3.2), y = 4.2,
label = paste0("PULL\n", pull_pct, "%"), size = 3.5, fill = "#E41A1C", color = "white", fontface = "bold") +
annotate("label", x = ifelse(batter_side == "Right", 3.2, -3.2), y = 4.2,
label = paste0("OPPO\n", oppo_pct, "%"), size = 3.5, fill = "#377EB8", color = "white", fontface = "bold") +
annotate("label", x = 0, y = 4.8,
label = paste0("CENTER\n", mid_pct, "%"), size = 3.5, fill = "#4DAF4A", color = "white", fontface = "bold") +
annotate("segment", x = contact_x - (bat_x_end - contact_x) * 0.3, y = contact_y - (bat_y_end - contact_y) * 0.3,
xend = bat_x_end, yend = bat_y_end, color = dir_color, linewidth = 4,
arrow = arrow(length = unit(0.2, "inches"), type = "closed")) +
geom_point(aes(x = contact_x, y = contact_y), fill = "white", color = "black", shape = 21, size = 8, stroke = 2) +
annotate("label", x = 0, y = -2.5, label = paste0("Avg HAA: ", avg_haa, "° (", swing_dir, ")"),
size = 5, fontface = "bold", fill = dir_color, color = "white", label.padding = unit(0.5, "lines")) +
coord_fixed(xlim = c(-5, 5), ylim = c(-3, 5.5)) +
labs(title = paste0(batter_name, " - Attack Direction", title_suffix), subtitle = paste0("n=", n_swings, " swings")) +
theme_void() +
theme(plot.title = element_text(hjust = 0.5, size = 16, face = "bold"),
plot.subtitle = element_text(hjust = 0.5, size = 11, color = "gray40"))
}
# ============================================
# SINGLE SWING DETAIL CHARTS
# ============================================
# Trajectory - FLIPPED (pitch from RIGHT), with red contact point
create_single_trajectory_viz <- function(pitch_data) {
if (nrow(pitch_data) < 1 || is.na(pitch_data$Extension[1])) {
return(ggplot() + theme_void() + ggtitle("No trajectory data") +
theme(plot.title = element_text(hjust = 0.5, size = 14, face = "bold")))
}
row <- pitch_data[1, ]
ptype <- ifelse(!is.na(row$TaggedPitchType), row$TaggedPitchType, "Other")
y0 <- 60.5 - row$Extension
z0 <- row$RelHeight
vy0_val <- ifelse(!is.na(row$vy0), row$vy0, -130)
vz0_val <- ifelse(!is.na(row$vz0), row$vz0, 2)
ay_val <- ifelse(!is.na(row$ay0), row$ay0, 25)
az_val <- ifelse(!is.na(row$az0), row$az0, -20)
a <- 0.5 * ay_val; b <- vy0_val; c <- y0
disc <- b^2 - 4*a*c
if (is.na(disc) || disc < 0) {
return(ggplot() + theme_void() + ggtitle("Cannot compute trajectory") +
theme(plot.title = element_text(hjust = 0.5, size = 14, face = "bold")))
}
t <- (-b - sqrt(disc)) / (2*a)
if (t < 0) t <- (-b + sqrt(disc)) / (2*a)
if (is.na(t) || t <= 0) {
return(ggplot() + theme_void() + ggtitle("Invalid trajectory") +
theme(plot.title = element_text(hjust = 0.5, size = 14, face = "bold")))
}
t_vals <- seq(0, t, length.out = 100)
y_vals <- y0 + vy0_val * t_vals + 0.5 * ay_val * t_vals^2
z_vals <- z0 + vz0_val * t_vals + 0.5 * az_val * t_vals^2
# FLIP: x = distance from plate (0 = plate, 60.5 = rubber)
traj_df <- data.frame(
Distance = y_vals, # Flipped: 0 at plate
Height = z_vals,
PitchType = ptype
)
# Final contact point
final_z <- z_vals[length(z_vals)]
contact_df <- data.frame(Distance = 0, Height = final_z)
# Decision point
decision_time <- 0.155
decision_df <- NULL
if (decision_time <= t) {
decision_y <- y0 + vy0_val * decision_time + 0.5 * ay_val * decision_time^2
decision_z <- z0 + vz0_val * decision_time + 0.5 * az_val * decision_time^2
decision_df <- data.frame(Distance = decision_y, Height = decision_z)
}
release_df <- data.frame(Distance = y0, Height = z0)
p_color <- ifelse(ptype %in% names(pitch_colors), pitch_colors[[ptype]], "gray50")
p <- ggplot(traj_df, aes(x = Distance, y = Height)) +
geom_line(linewidth = 2.5, color = p_color, alpha = 0.9) +
# Strike zone at x=0
annotate("rect", xmin = -0.2, xmax = 0.4, ymin = 1.5, ymax = 3.5, color = "black", fill = NA, linewidth = 1.5) +
geom_vline(xintercept = 0, linetype = "dashed", color = "gray40", linewidth = 0.8) +
# Release point
geom_point(data = release_df, aes(x = Distance, y = Height),
size = 5, shape = 21, fill = p_color, color = "black", stroke = 1) +
# RED contact point
geom_point(data = contact_df, aes(x = Distance, y = Height),
size = 4, shape = 21, fill = "darkred", color = "black", stroke = 1) +
scale_x_reverse(breaks = seq(0, 60, 10), limits = c(62, -2)) + # REVERSED
scale_y_continuous(breaks = seq(0, 8, 1), limits = c(0, 8)) +
labs(title = paste0("Pitch Trajectory: ", ptype),
subtitle = "(Diamond = Decision Point)",
x = "Distance to Plate (ft)", y = "Height (ft)") +
theme_minimal() +
theme(plot.title = element_text(hjust = 0.5, size = 12, face = "bold"),
plot.subtitle = element_text(hjust = 0.5, size = 9, color = "gray50"),
panel.grid.major = element_line(color = "gray90"),
panel.grid.minor = element_blank(),
panel.border = element_rect(color = "black", fill = NA, linewidth = 1))
if (!is.null(decision_df)) {
p <- p + geom_point(data = decision_df, aes(x = Distance, y = Height),
size = 4, shape = 23, fill = p_color, color = "black", stroke = 1)
}
p
}
# Zone with red contact point
create_single_zone_viz <- function(pitch_data) {
if (nrow(pitch_data) < 1 || is.na(pitch_data$PlateLocSide[1]) || is.na(pitch_data$PlateLocHeight[1])) {
return(ggplot() + theme_void() + ggtitle("No location data") +
theme(plot.title = element_text(hjust = 0.5, size = 14, face = "bold")))
}
row <- pitch_data[1, ]
ptype <- ifelse(!is.na(row$TaggedPitchType), row$TaggedPitchType, "Other")
pitch_num <- ifelse(!is.na(row$PitchofPA), row$PitchofPA, "")
ggplot() +
annotate("rect", xmin = -0.8303, xmax = 0.8303, ymin = 1.5, ymax = 3.3775, alpha = 0, linewidth = 1, color = "black") +
annotate("segment", x = -0.708, y = 0.15, xend = 0.708, yend = 0.15, linewidth = 0.8, color = "black") +
annotate("segment", x = -0.708, y = 0.30, xend = -0.708, yend = 0.15, linewidth = 0.8, color = "black") +
annotate("segment", x = 0.708, y = 0.30, xend = 0.708, yend = 0.15, linewidth = 0.8, color = "black") +
annotate("segment", x = -0.708, y = 0.30, xend = 0.000, yend = 0.50, linewidth = 0.8, color = "black") +
annotate("segment", x = 0.708, y = 0.30, xend = 0.000, yend = 0.50, linewidth = 0.8, color = "black") +
# RED contact/pitch point
geom_point(aes(x = row$PlateLocSide, y = row$PlateLocHeight),
fill = "darkred", shape = 21, color = "black", stroke = 0.5, size = 6) +
geom_text(aes(x = row$PlateLocSide, y = row$PlateLocHeight, label = pitch_num),
vjust = 0.5, size = 2.5, color = "white", fontface = "bold") +
coord_fixed(xlim = c(-2, 2), ylim = c(0, 4.5)) +
labs(title = paste0("Location: ", ptype), subtitle = paste0("Pitch #", pitch_num, " of PA")) +
theme_void() +
theme(plot.title = element_text(hjust = 0.5, size = 12, face = "bold"),
plot.subtitle = element_text(hjust = 0.5, size = 9, color = "gray50"))
}
# Contact Point - Fixed scales
create_single_contact_viz <- function(pitch_data) {
row <- pitch_data[1, ]
if (is.na(row$ContactPositionX) || is.na(row$ContactPositionY) || is.na(row$ContactPositionZ)) {
return(
ggplot() +
annotate("segment", x = -8.5, y = 17, xend = 8.5, yend = 17, color = "gray70", linewidth = 0.5) +
annotate("segment", x = 8.5, y = 8.5, xend = 8.5, yend = 17, color = "gray70", linewidth = 0.5) +
annotate("segment", x = -8.5, y = 8.5, xend = -8.5, yend = 17, color = "gray70", linewidth = 0.5) +
annotate("segment", x = -8.5, y = 8.5, xend = 0, yend = 0, color = "gray70", linewidth = 0.5) +
annotate("segment", x = 8.5, y = 8.5, xend = 0, yend = 0, color = "gray70", linewidth = 0.5) +
annotate("rect", xmin = 20, xmax = 48, ymin = -20, ymax = 40, fill = NA, color = "gray70", linewidth = 0.5) +
annotate("rect", xmin = -48, xmax = -20, ymin = -20, ymax = 40, fill = NA, color = "gray70", linewidth = 0.5) +
xlim(-50, 50) + ylim(-20, 50) + coord_fixed() +
ggtitle("Contact Point") +
annotate("text", x = 0, y = 20, label = "No contact data", size = 4, color = "gray50") +
theme_void() + theme(plot.title = element_text(hjust = 0.5, size = 12, face = "bold"))
)
}
contact_x <- row$ContactPositionX * 12
contact_y <- row$ContactPositionY * 12
batter_side <- ifelse(!is.na(row$BatterSide), row$BatterSide, "Right")
exit_velo <- ifelse(!is.na(row$ExitSpeed), round(row$ExitSpeed, 1), NA)
depth <- round(contact_y - 17, 1)
ggplot() +
annotate("segment", x = -8.5, y = 17, xend = 8.5, yend = 17, color = "black", linewidth = 1) +
annotate("segment", x = 8.5, y = 8.5, xend = 8.5, yend = 17, color = "black", linewidth = 1) +
annotate("segment", x = -8.5, y = 8.5, xend = -8.5, yend = 17, color = "black", linewidth = 1) +
annotate("segment", x = -8.5, y = 8.5, xend = 0, yend = 0, color = "black", linewidth = 1) +
annotate("segment", x = 8.5, y = 8.5, xend = 0, yend = 0, color = "black", linewidth = 1) +
annotate("rect", xmin = 20, xmax = 48, ymin = -5, ymax = 35, fill = NA, color = "black", linewidth = 0.8) +
annotate("rect", xmin = -48, xmax = -20, ymin = -5, ymax = 35, fill = NA, color = "black", linewidth = 0.8) +
annotate("text", x = ifelse(batter_side == "Right", -34, 34), y = 15,
label = ifelse(batter_side == "Right", "R", "L"), size = 6, fontface = "bold", color = "gray50") +
annotate("segment", x = -15, y = 17, xend = 15, yend = 17, color = "blue", linewidth = 0.8, linetype = "dashed") +
annotate("text", x = 16, y = 17, label = "Front", hjust = 0, size = 2.5, color = "blue") +
geom_point(aes(x = contact_x, y = contact_y), fill = "darkred", color = "black", shape = 21, size = 4, stroke = 0.5) +
annotate("text", x = 0, y = -12, label = paste0("Depth: ", depth, " in (", ifelse(depth > 0, "Front", "Back"), ")"),
size = 3.5, fontface = "bold") +
xlim(-50, 50) + ylim(-18, 50) + coord_fixed() +
labs(title = "Contact Point", subtitle = ifelse(!is.na(exit_velo), paste0("EV: ", exit_velo, " mph"), "")) +
theme_void() +
theme(plot.title = element_text(hjust = 0.5, size = 12, face = "bold"),
plot.subtitle = element_text(hjust = 0.5, size = 9, color = "gray50"))
}
# Spray Chart - Fixed scales
create_single_spray_viz <- function(pitch_data) {
row <- pitch_data[1, ]
p <- ggplot() +
annotate("segment", x = 0, y = 0, xend = 247.487, yend = 247.487, color = "black") +
annotate("segment", x = 0, y = 0, xend = -247.487, yend = 247.487, color = "black") +
annotate("segment", x = 63.6396, y = 63.6396, xend = 0, yend = 127.279, color = "black") +
annotate("segment", x = -63.6396, y = 63.6396, xend = 0, yend = 127.279, color = "black") +
annotate("curve", x = 89.095, y = 89.095, xend = 0, yend = 160, curvature = 0.36, linewidth = 0.5, color = "black") +
annotate("curve", x = -89.095, y = 89.095, xend = 0, yend = 160, curvature = -0.36, linewidth = 0.5, color = "black") +
annotate("curve", x = -247.487, y = 247.487, xend = 247.487, yend = 247.487, curvature = -0.65, linewidth = 0.5, color = "black") +
coord_fixed(xlim = c(-360, 360), ylim = c(-20, 410), expand = FALSE)
if (is.na(row$Distance) || is.na(row$Bearing) || row$PitchCall != "InPlay") {
p <- p +
annotate("text", x = 0, y = 200, label = "No batted ball", size = 4, color = "gray50") +
labs(title = "Spray Chart", subtitle = "Ball not in play") +
theme_void() +
theme(plot.title = element_text(hjust = 0.5, size = 12, face = "bold"),
plot.subtitle = element_text(hjust = 0.5, size = 9, color = "gray50"))
return(p)
}
bearing_rad <- row$Bearing * pi / 180
spray_x <- row$Distance * sin(bearing_rad)
spray_y <- row$Distance * cos(bearing_rad)
hit_type <- ifelse(!is.na(row$TaggedHitType), row$TaggedHitType, "")
distance <- round(row$Distance, 0)
p +
geom_point(aes(x = spray_x, y = spray_y), fill = "darkred", color = "black", shape = 21, size = 3, stroke = 0.4) +
annotate("text", x = spray_x, y = spray_y + 25, label = paste0(distance, " ft"), size = 3, fontface = "bold") +
labs(title = "Spray Chart", subtitle = paste0(hit_type, " - ", distance, " ft")) +
theme_void() +
theme(plot.title = element_text(hjust = 0.5, size = 12, face = "bold"),
plot.subtitle = element_text(hjust = 0.5, size = 9, color = "gray50"),
plot.margin = margin(5, 5, 5, 5))
}
# Pitch Details Table (horizontal)
create_pitch_details_table <- function(pitch_data) {
row <- pitch_data[1, ]
# Get all values
pitcher <- ifelse(!is.na(row$Pitcher), row$Pitcher, "-")
throws <- ifelse(!is.na(row$PitcherThrows), substr(row$PitcherThrows, 1, 1), "-")
ptype <- ifelse(!is.na(row$TaggedPitchType), row$TaggedPitchType, "-")
velo <- ifelse(!is.na(row$RelSpeed), round(row$RelSpeed, 1), "-")
spin <- ifelse(!is.na(row$SpinRate), round(row$SpinRate, 0), "-")
ivb <- ifelse(!is.na(row$InducedVertBreak), round(row$InducedVertBreak, 1), "-")
hb <- ifelse(!is.na(row$HorzBreak), round(row$HorzBreak, 1), "-")
vaa <- ifelse(!is.na(row$VertApprAngle), round(row$VertApprAngle, 1), "-")
haa <- ifelse(!is.na(row$HorzApprAngle), round(row$HorzApprAngle, 1), "-")
rel_h <- ifelse(!is.na(row$RelHeight), round(row$RelHeight, 2), "-")
rel_s <- ifelse(!is.na(row$RelSide), round(row$RelSide, 2), "-")
ext <- ifelse(!is.na(row$Extension), round(row$Extension, 1), "-")
# Bat tracking
bat_spd <- ifelse(!is.na(row$BatSpeed), round(row$BatSpeed, 1), "-")
aa <- ifelse(!is.na(row$VerticalAttackAngle), round(row$VerticalAttackAngle, 1), "-")
swing_haa <- ifelse(!is.na(row$HorizontalAttackAngle), round(row$HorizontalAttackAngle, 1), "-")
ev <- ifelse(!is.na(row$ExitSpeed), round(row$ExitSpeed, 1), "-")
la <- ifelse(!is.na(row$Angle), round(row$Angle, 1), "-")
dist <- ifelse(!is.na(row$Distance), round(row$Distance, 0), "-")
# Create table data
table_df <- data.frame(
Pitcher = paste0(pitcher, " (", throws, ")"),
Pitch = ptype,
Velo = velo,
Spin = spin,
IVB = ivb,
HB = hb,
VAA = vaa,
HAA = haa,
`Rel H` = rel_h,
`Rel S` = rel_s,
Ext = ext,
`Bat Spd` = bat_spd,
AA = aa,
`Swing HAA` = swing_haa,
EV = ev,
LA = la,
Dist = dist,
check.names = FALSE
)
table_df %>%
gt::gt() %>%
gt::tab_header(title = "Pitch & Swing Details") %>%
gt::cols_align(align = "center", columns = everything()) %>%
gt::tab_options(
table.font.size = gt::px(11),
heading.title.font.size = gt::px(14),
heading.background.color = "#006F71",
column_labels.background.color = "#006F71",
column_labels.font.weight = "bold",
table.border.top.color = "black",
table.border.bottom.color = "black"
) %>%
gt::tab_style(style = gt::cell_text(color = "white"),
locations = gt::cells_title(groups = "title")) %>%
gt::tab_style(style = gt::cell_text(color = "white"),
locations = gt::cells_column_labels())
}
# ============================================
# HEATMAPS
# ============================================
create_bt_batspeed_heatmap <- function(batter_name, team_data, count_filter = NULL) {
df <- team_data %>% filter(Batter == batter_name, !is.na(BatSpeed), !is.na(PlateLocSide), !is.na(PlateLocHeight))
if (!is.null(count_filter) && count_filter != "All") df <- df %>% filter(paste0(Balls, "-", Strikes) == count_filter)
if (nrow(df) < 5) return(ggplot() + theme_void() + ggtitle("Insufficient data") + theme(plot.title = element_text(hjust = 0.5)))
avg_bs <- round(mean(df$BatSpeed, na.rm = TRUE), 1)
ggplot(df, aes(x = PlateLocSide, y = PlateLocHeight)) +
stat_summary_2d(aes(z = BatSpeed), fun = mean, bins = 8) +
scale_fill_gradientn(colours = c("#0551bc", "#02fbff", "#03ff00", "#fbff00", "#ffa503", "#ff1f02", "#dc1100"), name = "Bat Speed\n(mph)") +
annotate("rect", xmin = -0.8303, xmax = 0.8303, ymin = 1.6, ymax = 3.5, fill = NA, color = "black", linewidth = 1) +
annotate("path", x = c(-0.708, 0.708, 0.708, 0, -0.708, -0.708), y = c(0.15, 0.15, 0.3, 0.5, 0.3, 0.15), color = "black", linewidth = 0.8) +
coord_fixed(ratio = 1) + xlim(-2, 2) + ylim(0, 4.5) +
ggtitle(paste0("Bat Speed - ", batter_name, " (Avg: ", avg_bs, " mph)")) +
theme_void() + theme(legend.position = "right", plot.title = element_text(hjust = 0.5, size = 14, face = "bold"))
}
create_bt_aa_heatmap <- function(batter_name, team_data, count_filter = NULL) {
df <- team_data %>% filter(Batter == batter_name, !is.na(VerticalAttackAngle), !is.na(PlateLocSide), !is.na(PlateLocHeight))
if (!is.null(count_filter) && count_filter != "All") df <- df %>% filter(paste0(Balls, "-", Strikes) == count_filter)
if (nrow(df) < 5) return(ggplot() + theme_void() + ggtitle("Insufficient data") + theme(plot.title = element_text(hjust = 0.5)))
avg_aa <- round(mean(df$VerticalAttackAngle, na.rm = TRUE), 1)
ggplot(df, aes(x = PlateLocSide, y = PlateLocHeight)) +
stat_summary_2d(aes(z = VerticalAttackAngle), fun = mean, bins = 8) +
scale_fill_gradientn(colours = c("#0551bc", "#02fbff", "#03ff00", "#fbff00", "#ffa503", "#ff1f02", "#dc1100"), name = "Attack\nAngle (°)") +
annotate("rect", xmin = -0.8303, xmax = 0.8303, ymin = 1.6, ymax = 3.5, fill = NA, color = "black", linewidth = 1) +
annotate("path", x = c(-0.708, 0.708, 0.708, 0, -0.708, -0.708), y = c(0.15, 0.15, 0.3, 0.5, 0.3, 0.15), color = "black", linewidth = 0.8) +
coord_fixed(ratio = 1) + xlim(-2, 2) + ylim(0, 4.5) +
ggtitle(paste0("Attack Angle - ", batter_name, " (Avg: ", avg_aa, "°)")) +
theme_void() + theme(legend.position = "right", plot.title = element_text(hjust = 0.5, size = 14, face = "bold"))
}
create_bt_haa_heatmap <- function(batter_name, team_data, count_filter = NULL) {
df <- team_data %>% filter(Batter == batter_name, !is.na(HorizontalAttackAngle), !is.na(PlateLocSide), !is.na(PlateLocHeight))
if (!is.null(count_filter) && count_filter != "All") df <- df %>% filter(paste0(Balls, "-", Strikes) == count_filter)
if (nrow(df) < 5) return(ggplot() + theme_void() + ggtitle("Insufficient data") + theme(plot.title = element_text(hjust = 0.5)))
avg_haa <- round(mean(df$HorizontalAttackAngle, na.rm = TRUE), 1)
ggplot(df, aes(x = PlateLocSide, y = PlateLocHeight)) +
stat_summary_2d(aes(z = HorizontalAttackAngle), fun = mean, bins = 8) +
scale_fill_gradientn(colours = c("#377EB8", "#02fbff", "#03ff00", "#fbff00", "#ffa503", "#E41A1C"), name = "HAA (°)") +
annotate("rect", xmin = -0.8303, xmax = 0.8303, ymin = 1.6, ymax = 3.5, fill = NA, color = "black", linewidth = 1) +
annotate("path", x = c(-0.708, 0.708, 0.708, 0, -0.708, -0.708), y = c(0.15, 0.15, 0.3, 0.5, 0.3, 0.15), color = "black", linewidth = 0.8) +
coord_fixed(ratio = 1) + xlim(-2, 2) + ylim(0, 4.5) +
ggtitle(paste0("Attack Direction - ", batter_name, " (Avg: ", avg_haa, "°)")) +
theme_void() + theme(legend.position = "right", plot.title = element_text(hjust = 0.5, size = 14, face = "bold"))
}
# ============================================
# GT TABLE
# ============================================
create_bt_table <- function(batter_name, team_data) {
df <- team_data %>% filter(Batter == batter_name, !is.na(BatSpeed)) %>%
mutate(swing_direction = get_swing_direction(HorizontalAttackAngle, BatterSide))
if (nrow(df) < 3) return(gt::gt(data.frame(Note = "Insufficient data")))
overall <- df %>% summarize(` ` = "Overall", Swings = n(), `Avg BS` = round(mean(BatSpeed, na.rm = TRUE), 1),
`Max BS` = round(max(BatSpeed, na.rm = TRUE), 1), `Avg AA` = round(mean(VerticalAttackAngle, na.rm = TRUE), 1),
`Avg HAA` = round(mean(HorizontalAttackAngle, na.rm = TRUE), 1), `Avg VAA` = round(mean(VertApprAngle, na.rm = TRUE), 1),
`Pull%` = round(sum(swing_direction == "Pull", na.rm = TRUE) / n() * 100, 1),
`Mid%` = round(sum(swing_direction == "Middle", na.rm = TRUE) / n() * 100, 1),
`Oppo%` = round(sum(swing_direction == "Oppo", na.rm = TRUE) / n() * 100, 1),
`Avg LA` = round(mean(Angle, na.rm = TRUE), 1), .groups = "drop")
by_count <- df %>% mutate(ct = case_when(Balls > Strikes ~ "Hitter's", Strikes > Balls ~ "Pitcher's",
Balls == 0 & Strikes == 0 ~ "0-0", TRUE ~ "Even")) %>% group_by(ct) %>%
summarize(Swings = n(), `Avg BS` = round(mean(BatSpeed, na.rm = TRUE), 1), `Max BS` = round(max(BatSpeed, na.rm = TRUE), 1),
`Avg AA` = round(mean(VerticalAttackAngle, na.rm = TRUE), 1), `Avg HAA` = round(mean(HorizontalAttackAngle, na.rm = TRUE), 1),
`Avg VAA` = round(mean(VertApprAngle, na.rm = TRUE), 1), `Pull%` = round(sum(swing_direction == "Pull", na.rm = TRUE) / n() * 100, 1),
`Mid%` = round(sum(swing_direction == "Middle", na.rm = TRUE) / n() * 100, 1),
`Oppo%` = round(sum(swing_direction == "Oppo", na.rm = TRUE) / n() * 100, 1),
`Avg LA` = round(mean(Angle, na.rm = TRUE), 1), .groups = "drop") %>% rename(` ` = ct)
by_hand <- df %>% filter(PitcherThrows %in% c("Right", "Left")) %>% group_by(PitcherThrows) %>%
summarize(Swings = n(), `Avg BS` = round(mean(BatSpeed, na.rm = TRUE), 1), `Max BS` = round(max(BatSpeed, na.rm = TRUE), 1),
`Avg AA` = round(mean(VerticalAttackAngle, na.rm = TRUE), 1), `Avg HAA` = round(mean(HorizontalAttackAngle, na.rm = TRUE), 1),
`Avg VAA` = round(mean(VertApprAngle, na.rm = TRUE), 1), `Pull%` = round(sum(swing_direction == "Pull", na.rm = TRUE) / n() * 100, 1),
`Mid%` = round(sum(swing_direction == "Middle", na.rm = TRUE) / n() * 100, 1),
`Oppo%` = round(sum(swing_direction == "Oppo", na.rm = TRUE) / n() * 100, 1),
`Avg LA` = round(mean(Angle, na.rm = TRUE), 1), .groups = "drop") %>%
mutate(` ` = paste0("vs ", PitcherThrows)) %>% select(-PitcherThrows)
bind_rows(overall, by_count, by_hand) %>% gt::gt() %>% gtExtras::gt_theme_guardian() %>%
gt::tab_header(title = paste("Bat Tracking Summary:", batter_name)) %>%
gt::fmt_number(columns = c(`Avg BS`, `Max BS`, `Avg AA`, `Avg HAA`, `Avg VAA`, `Pull%`, `Mid%`, `Oppo%`, `Avg LA`), decimals = 1) %>%
gt::cols_align(align = "center", columns = everything()) %>% gt::sub_missing(columns = everything(), missing_text = "-") %>%
gt::tab_options(heading.background.color = "darkcyan", column_labels.background.color = "darkcyan") %>%
gt::tab_style(style = gt::cell_text(color = "white"), locations = gt::cells_title(groups = "title")) %>%
gt::data_color(columns = `Avg BS`, fn = scales::col_numeric(palette = c("#E1463E", "white", "#00840D"), domain = NULL))
}
create_hitting_grades_table <- function(batter_name, team_data) {
# Calculate hitting grades
batter_split <- team_data %>%
filter(!is.na(TaggedPitchType), Batter == batter_name) %>%
calculate_rv100() %>%
group_by(PitcherThrows) %>%
summarise(
xBA = mean(xBA[PitchCall == "InPlay"], na.rm = TRUE),
ISO = (sum(totalbases, na.rm = TRUE) / sum(ABindicator, na.rm = TRUE)) -
(sum(HitIndicator, na.rm = TRUE) / sum(ABindicator, na.rm = TRUE)),
xPowerFactor = sum(totalbases[PitchCall == "InPlay"], na.rm = TRUE) /
sum(HitIndicator[PitchCall == "InPlay"], na.rm = TRUE),
sdrv = 100 * mean(hitter_rv, na.rm = TRUE),
zone_con = 100 * (1 - sum(Zwhiffind, na.rm = TRUE) / sum(Zswing, na.rm = TRUE)),
`K%` = 100 * sum(KorBB == "Strikeout", na.rm = TRUE) / sum(PAindicator, na.rm = TRUE),
ev90 = quantile(ExitSpeed[PitchCall == "InPlay"], 0.9, na.rm = TRUE),
con2k = 100 * (1 - sum(WhiffIndicator[Strikes == 2], na.rm = TRUE) /
sum(SwingIndicator[Strikes == 2], na.rm = TRUE)),
.groups = "drop"
) %>%
rowwise() %>%
mutate(
`Swing Decisions` = ((sdrv - grade_metrics$sdrv_mean) / grade_metrics$sdrv_sd) * 10 + 50,
`Game Power` = (((xPowerFactor - grade_metrics$xpf_mean) / grade_metrics$xpf_sd) +
((ISO - grade_metrics$iso_mean) / grade_metrics$iso_sd)) * 5 + 50,
`Raw Power` = ((ev90 - grade_metrics$ev90_mean) / grade_metrics$ev90_sd) * 10 + 50,
`Avoid K` = (((grade_metrics$k_mean - `K%`) / grade_metrics$k_sd) +
((con2k - grade_metrics$con2k_mean) / grade_metrics$con2k_sd)) * 5 + 50,
Contact = (((zone_con - grade_metrics$zone_con_mean) / grade_metrics$zone_con_sd) +
((xBA - grade_metrics$xba_mean) / grade_metrics$xba_sd)) * 5 + 50
) %>%
ungroup() %>%
mutate(across(c(`Swing Decisions`, `Game Power`, `Raw Power`, `Avoid K`, Contact),
~round(pmax(pmin(., 80), 20)))) %>%
select(PitcherThrows, `Swing Decisions`, `Raw Power`, `Game Power`, `Avoid K`, Contact) %>%
rename(` ` = PitcherThrows)
if (!nrow(batter_split)) {
return(gt::gt(data.frame(Note = "No data available")))
}
# Create gt table with color coding
batter_split %>%
gt::gt() %>%
gtExtras::gt_theme_guardian() %>%
gt::tab_header(title = paste("20-80 Grades", batter_name)) %>%
gt::cols_align(align = "center", columns = gt::everything()) %>%
gt::tab_options(
heading.background.color = "darkcyan",
column_labels.background.color = "darkcyan",
table.border.top.color = "peru",
table.border.bottom.color = "peru"
) %>%
gt::tab_style(
style = gt::cell_text(color = "white"),
locations = gt::cells_title(groups = "title")
) %>%
gt::data_color(
columns = c(`Swing Decisions`, `Raw Power`, `Game Power`, `Avoid K`, Contact),
fn = scales::col_numeric(
palette = c("#E1463E", "white", "#00840D"),
domain = c(20, 80)
)
)
}
parse_game_day <- function(df, tz = "America/New_York") {
stopifnot("Date" %in% names(df))
to_posix <- function(x) {
if (inherits(x, c("POSIXct","POSIXlt"))) return(x)
if (inherits(x, "Date")) return(as.POSIXct(x, tz = tz))
x <- as.character(x)
suppressWarnings({
p <- as.POSIXct(x, tz = tz, tryFormats = c(
"%Y-%m-%d %H:%M:%S", "%Y/%m/%d %H:%M:%S",
"%Y-%m-%d", "%Y/%m/%d",
"%m/%d/%Y %H:%M:%S", "%m/%d/%Y",
"%m/%d/%y %H:%M:%S", "%m/%d/%y"
))
})
if (all(is.na(p))) {
if (requireNamespace("lubridate", quietly = TRUE)) {
p <- lubridate::parse_date_time(
x,
orders = c("Ymd HMS","Ymd","mdY HMS","mdY","mdy HMS","mdy","ymd HMS","ymd"),
tz = tz, truncated = 3
)
}
}
p
}
df$Date <- to_posix(df$Date)
return(df)
}
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 0.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")
)
)
app_ui <- fluidPage(
tags$head(
tags$style(HTML("
body, table, .gt_table { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; }
.app-header { display:flex; justify-content:space-between; align-items:center; padding:20px 40px; background:#ffffff; 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::before{ content:''; position:absolute; inset:0; pointer-events:none; border-radius:50px; background:linear-gradient(135deg, rgba(255,255,255,.4), transparent); }
.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; }
.nav-tabs>li>a:focus{ outline:3px solid rgba(205,133,63,.6); outline-offset:2px; }
.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:#ffffff; 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); }
.datatables, .dataTables_wrapper .dataTables_filter input{ border-radius:10px; }
.gt_table{ border-radius:12px; overflow:hidden; border:1px solid rgba(0,139,139,.15); }
"))
),
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",
tabPanel("Dashboard",
fluidRow(
column(
width = 3,
wellPanel(
checkboxGroupInput("seasonSelect",
"Season",
choices = c("Spring 2026", "Preseason Spring 2026", "Spring 2025", "Fall 2025"),
selected = c("Spring 2026", "Preseason Spring 2026"),
inline = FALSE),
hr(),
selectInput("playerSelect", "Player",
choices = sort(unique(na.omit(all_seasonal_data$Batter))),
selected = sort(unique(na.omit(all_seasonal_data$Batter)))[1],
multiple = FALSE),
dateRangeInput(
"dateRange", "Date range",
start = suppressWarnings(min(all_seasonal_data$Date, na.rm = TRUE)),
end = suppressWarnings(max(all_seasonal_data$Date, na.rm = TRUE)),
min = suppressWarnings(min(all_seasonal_data$Date, na.rm = TRUE)),
max = suppressWarnings(max(all_seasonal_data$Date, na.rm = TRUE))
),
checkboxGroupInput("BatterHand", "Batter side",
choices = c("Right","Left"), selected = c("Right","Left"), inline = TRUE),
checkboxGroupInput("PitcherHand", "Pitcher throws",
choices = c("Right","Left"), selected = c("Right","Left"), inline = TRUE),
{
pt_choices <- sort(unique(na.omit(as.character(all_seasonal_data$TaggedPitchType))))
selectizeInput("pitchtype", "Pitch types", choices = pt_choices,
selected = pt_choices, multiple = TRUE, options = list(plugins = list("remove_button")))
},
{
vmin <- floor(suppressWarnings(min(all_seasonal_data$RelSpeed, na.rm = TRUE)))
if (!is.finite(vmin)) vmin <- 50
vmax <- ceiling(suppressWarnings(max(all_seasonal_data$RelSpeed, na.rm = TRUE)))
if (!is.finite(vmax)) vmax <- 105
sliderInput("veloRange", "Velo (mph)", min=vmin, max=vmax, value=c(vmin, vmax), step=1)
},
{
ivbmin <- floor(suppressWarnings(min(all_seasonal_data$InducedVertBreak, na.rm = TRUE)))
if (!is.finite(ivbmin)) ivbmin <- -30
ivbmax <- ceiling(suppressWarnings(max(all_seasonal_data$InducedVertBreak, na.rm = TRUE)))
if (!is.finite(ivbmax)) ivbmax <- 30
sliderInput("ivbrange", "IVB", min=ivbmin, max=ivbmax, value=c(ivbmin, ivbmax), step=1)
},
{
hbmin <- floor(suppressWarnings(min(all_seasonal_data$HorzBreak, na.rm = TRUE)))
if (!is.finite(hbmin)) hbmin <- -30
hbmax <- ceiling(suppressWarnings(max(all_seasonal_data$HorzBreak, na.rm = TRUE)))
if (!is.finite(hbmax)) hbmax <- 30
sliderInput("hbrange", "HB", min=hbmin, max=hbmax, value=c(hbmin, hbmax), step=1)
}
)
),
column(
width = 9,
uiOutput("selectedSeasonsDisplay"),
plotOutput("player_header_plot", height = 170),
br(),
gt_output("last15_games_table"),
br(),
fluidRow(
column(6,
plotOutput("hitting_grades_radar", height = 420)
),
column(6,
gt_output("hitting_grades_table"),
tags$div(
style = "margin-top: 10px; font-size: 11px;",
tags$strong("Swing Decisions:"), " Run Value of swing/take choices", tags$br(),
tags$strong("Raw Power:"), " 90th percentile exit velocity", tags$br(),
tags$strong("Game Power:"), " ISO + xPowerFactor (XBH production)", tags$br(),
tags$strong("Avoid K:"), " K% + 2-strike contact rate", tags$br(),
tags$strong("Contact:"), " Zone contact% + xBA quality"
)
)
),
hr(),
plotOutput("performance_zone_plot", height = 380),
br(),
h3("Advanced Numbers"),
gt_output("adv_table"),
br(),
h3("Splits"),
gt_output("fb_splits_table"),
br(),
gt_output("offspeed_splits_table")
)
)
),
tabPanel("Spray & Batted Ball",
fluidRow(
column(3,
wellPanel(
h4("Spray Chart Options", style = "color: darkcyan;"),
selectInput("spray_type", "Chart Type",
choices = c(
"Interactive (Hover)" = "plotly",
"Schill Style" = "schill_basic",
"Sector Zones" = "sector",
"Trajectories" = "trajectory",
"2-Strike Comparison" = "2k",
"Classic" = "classic"
),
selected = "plotly"
)
)
),
column(9,
conditionalPanel(
condition = "input.spray_type == 'plotly'",
plotlyOutput("spray_chart_plotly", height = 520)
),
conditionalPanel(
condition = "input.spray_type == 'schill_basic'",
plotOutput("spray_chart_schill_basic", height = 600)
),
conditionalPanel(
condition = "input.spray_type == 'sector'",
plotOutput("spray_chart_sector", height = 480)
),
conditionalPanel(
condition = "input.spray_type == 'trajectory'",
plotOutput("spray_chart_trajectory", height = 480)
),
conditionalPanel(
condition = "input.spray_type == '2k'",
plotOutput("spray_chart_2k", height = 480)
),
conditionalPanel(
condition = "input.spray_type == 'classic'",
plotOutput("spray_chart_classic", height = 480)
)
)
),
hr(),
fluidRow(
column(6, gt_output("bb_profile_table")),
column(6, tableOutput("quality_table"))
)
),
tabPanel("Contact / Radial",
fluidRow(
column(6,
wellPanel(
h4("Contact Map"),
plotOutput("contact_map", height = 420)
)
),
column(6,
plotOutput("radial_chart", height = 420)
)
)
),
tabPanel("Heatmaps",
fluidRow(
column(3,
wellPanel(
selectInput("heat_metric", "Metric",
choices = c("Solid Contact",
"Hard-Hit (95+)",
"Whiffs", "Swings", "10-30 LA",
"Hits", "XBH", "All Balls in Play"),
selected = "Hard-Hit (95+)"),
hr(),
p("Note: Use the Dashboard filters on the left to filter by pitcher hand, pitch type, velocity, etc.",
style = "font-size: 11px; color: #666; font-style: italic;")
)
),
column(9, plotOutput("heatmap_plot", height = 480))
)
),
tabPanel("Percentiles",
fluidRow(
column(3,
wellPanel(
radioButtons("pool_select", "Compare against",
choices = c("Sun Belt (2025)" = "SBC",
"Power 5 (2025)" = "P5",
"SEC (2025)" = "SEC",
"Team (filtered)" = "TEAM"),
selected = "SBC")
)
),
column(9, plotOutput("percentiles_plot", height = 540))
)
),
tabPanel("Whiff Movement",
plotOutput("whiff_movement_plot", height = 520)
),
tabPanel(
"Bat Tracking",
fluidRow(
column(
3,
wellPanel(
h4("Bat Tracking Controls", style = "color: darkcyan; margin-top: 0;"),
hr(),
selectInput(
"bt_swing_select",
"View Swing",
choices = c("All Swings" = "All"),
selected = "All"
),
helpText("Select 'All Swings' for aggregate view, or choose a specific swing."),
hr(),
h5("Filters", style = "color: darkcyan;"),
selectInput(
"bt_count_filter",
"Count",
choices = c(
"All", "0-0", "0-1", "0-2",
"1-0", "1-1", "1-2",
"2-0", "2-1", "2-2",
"3-0", "3-1", "3-2"
),
selected = "All"
),
selectizeInput(
"bt_pitch_type",
"Pitch Type",
choices = c(
"Fastball", "Sinker", "Cutter",
"Slider", "Curveball", "ChangeUp", "Splitter"
),
selected = c(
"Fastball", "Sinker", "Cutter",
"Slider", "Curveball", "ChangeUp", "Splitter"
),
multiple = TRUE,
options = list(plugins = list("remove_button"))
),
checkboxGroupInput(
"bt_pitcher_hand",
"Pitcher Throws",
choices = c("Right", "Left"),
selected = c("Right", "Left"),
inline = TRUE
),
checkboxGroupInput(
"bt_result_filter",
"Swing Result",
choices = c("InPlay", "StrikeSwinging", "FoulBall"),
selected = c("InPlay", "StrikeSwinging", "FoulBall")
)
)
),
column(
9,
fluidRow(
column(6, plotOutput("bt_simple_aa_viz", height = 300)),
column(6, plotOutput("bt_attack_dir_viz", height = 300))
),
fluidRow(
column(6, plotOutput("bt_aa_la_viz", height = 350)),
column(6, plotOutput("bt_aa_vaa_viz", height = 350))
),
conditionalPanel(
condition = "input.bt_swing_select != 'All'",
hr(),
h4("Single Swing Details", style = "text-align: center; color: darkcyan;"),
gt_output("bt_pitch_details_table"),
br(),
fluidRow(
column(6, plotOutput("bt_single_trajectory", height = 280)),
column(6, plotOutput("bt_single_zone", height = 280))
),
fluidRow(
column(6, plotOutput("bt_single_contact", height = 280)),
column(6, plotOutput("bt_single_spray", height = 280))
)
),
hr(),
h4("Bat Tracking Summary", style = "text-align: center; color: darkcyan;"),
gt_output("bt_comprehensive_table"),
hr(),
h4("Zone Heatmaps", style = "text-align: center; color: darkcyan;"),
fluidRow(
column(4, plotOutput("bt_heatmap_batspeed", height = 320)),
column(4, plotOutput("bt_heatmap_aa", height = 320)),
column(4, plotOutput("bt_heatmap_haa", height = 320))
),
hr(),
h4("Team Bat Tracking Leaderboard", style = "text-align: center; color: darkcyan;"),
DT::dataTableOutput("bt_leaderboard_table")
)
)
),
tabPanel("Fielding & Baserunning",
sidebarLayout(
sidebarPanel(
selectInput("position", "Select Position:", choices = NULL)
),
mainPanel(
conditionalPanel(
condition = "output.is_outfielder",
plotlyOutput("star_graph", height = 520),
plotlyOutput("field_graph_plot", height = 520),
gt_output("field_graph_table"),
plotOutput("direction_oaa_plot", height = 520),
gt_output("direction_oaa_table")
),
conditionalPanel(
condition = "output.is_infielder",
plotlyOutput("infield_star_graph", height = 520),
plotlyOutput("infield_field_graph_plot", height = 520),
gt_output("infield_field_graph_table")
)
)
)
),
tabPanel("Team Defensive Positioning",
fluidRow(
column(2, selectInput("team_1", "Team:", choices = c(unique(def_pos_data$PitcherTeam)), selected = "Coastal Carolina")),
column(2, selectInput("man_on_first_1", "Runner on 1st:", choices = c("No Filter", TRUE, FALSE), selected = "No Filter")),
column(2, selectInput("man_on_second_1", "Runner on 2nd:", choices = c("No Filter", TRUE, FALSE), selected = "No Filter")),
column(2, selectInput("man_on_third_1", "Runner on 3rd:", choices = c("No Filter", TRUE, FALSE), selected = "No Filter")),
column(2, selectInput("BatterHand_1", "Bat Hand:", choices = c("No Filter", "Right", "Left"), selected = "No Filter"))
),
fluidRow(
column(2, selectInput("PitcherHand_1", "Pitch Hand:", choices = c("No Filter", "Right", "Left"), selected = "No Filter")),
column(2, selectInput("scorediff_1", "Score Diff:", choices = c("No Filter", -4:4), selected = "No Filter")),
column(2, dateInput("Date1_1", "From:", value = "2025-02-14")),
column(2, dateInput("Date2_1", "To:", value = "2025-06-22")),
column(2, selectInput("obs_pitcher_1", "Pitcher:", choices = c("No Filter", unique(def_pos_data$Pitcher)), selected = "No Filter"))
),
fluidRow(
column(2, selectInput("Hitter_1", "Batter:", choices = c("No Filter", unique(def_pos_data$Batter)), selected = "No Filter")),
column(2, selectInput("Count_1", "Count:", choices = c("No Filter", unique(def_pos_data$pitch_count)), selected = "No Filter")),
column(2, selectInput("obs_outs_1", "Outs:", choices = c("No Filter", 0, 1, 2), selected = "No Filter")),
column(2, selectInput("Opponent_1", "Opponent:", choices = c("No Filter", unique(def_pos_data$BatterTeam)), selected = "No Filter"))
),
fluidRow(
column(12,
plotOutput("first_def_pos_plot", height = "90vh")
)
)
),
tabPanel("Game View",
fluidRow(
column(3,
wellPanel(
uiOutput("game_controls")
)
),
column(9,
h4("At-Bat Breakdown"),
plotOutput("at_bats_plot", height = 600)
)
),
hr(),
fluidRow(
column(12,
h4("Pitch-by-Pitch Details"),
gt_output("hitter_game_table")
)
),
hr(),
h3("Weekly Hitter Report", style = "text-align: center; color: darkcyan;"),
fluidRow(
column(3,
wellPanel(
numericInput("weekly_n_games", "Number of recent games:", value = 3, min = 1, max = 7),
textAreaInput("weekly_notes", "Notes:", value = "", rows = 4,
placeholder = "Add scouting notes here..."),
downloadButton("download_weekly_report", "Download Weekly Report (PDF)",
style = "background-color: darkcyan; color: white; width: 100%;")
)
),
column(9,
plotOutput("weekly_report_preview", height = "900px")
)
)
),
tabPanel("BP",
fluidRow(
column(3,
wellPanel(
selectizeInput(
"bp_player", "BP Player",
choices = if(nrow(BP_data) > 0) sort(unique(na.omit(BP_data$Batter))) else character(0),
selected = if(nrow(BP_data) > 0 && length(unique(na.omit(BP_data$Batter))) > 0) sort(unique(na.omit(BP_data$Batter)))[1] else NULL
),
hr(),
selectizeInput(
"bp_players_filter", "Show Players in Leaderboard",
choices = if(nrow(BP_data) > 0) sort(unique(na.omit(BP_data$Batter))) else character(0),
selected = if(nrow(BP_data) > 0) sort(unique(na.omit(BP_data$Batter))) else character(0),
multiple = TRUE,
options = list(plugins = list("remove_button"))
),
hr(),
dateRangeInput(
"bp_dateRange", "BP date range",
start = if(nrow(BP_data)) suppressWarnings(min(BP_data$Date, na.rm = TRUE)) else as.Date("2025-01-01"),
end = if(nrow(BP_data)) suppressWarnings(max(BP_data$Date, na.rm = TRUE)) else as.Date("2025-12-31")
),
checkboxGroupInput(
"bp_columns", "BP columns to show",
choices = c("BBE","Avg EV","Max EV","SC%","10-30%","HH%","Barrel%","GB%","LD%","FB%","Pop%",
"Pull%","Straight%","Oppo%","Avg Distance","Max Distance","Avg LA","Avg Spin","Neg%",
"0-10%", "10-20%", "20-30%", "30+%"),
selected = c("BBE","Avg EV","Avg LA","SC%","10-30%","HH%","Barrel%"),
inline = FALSE
)
)
),
column(9,
# ------------------ Stats on top ------------------
fluidRow(
column(12, gt_output("bp_player_stats_table"))
),
hr(),
# ------------------ Plots ------------------
fluidRow(
column(6, plotOutput("bp_spray_plot", height = 360)),
column(6, plotOutput("bp_zone_plot", height = 360))
),
br(),
fluidRow(
column(6, plotOutput("bp_radial_plot", height = 400)),
column(6, plotOutput("bp_contact_plot", height = 360))
),
hr(),
h4("Exit Velocity Heatmaps", style = "text-align: center; color: darkcyan;"),
fluidRow(
column(4, plotOutput("bp_heatmap_hard", height = 320)),
column(4, plotOutput("bp_heatmap_medium", height = 320)),
column(4, plotOutput("bp_heatmap_soft", height = 320))
)
)
),
hr(),
# ------------------ Leaderboards ------------------
fluidRow(
column(7, DT::dataTableOutput("bp_leaderboard_table")),
column(5, DT::dataTableOutput("bp_team_summary_table"))
)
),
tabPanel("Daily EV Tracker",
fluidRow(
column(3,
wellPanel(
h4("Date Selection", style = "color: darkcyan;"),
dateRangeInput(
"daily_ev_dateRange", "Date range",
start = if (nrow(BP_data)) suppressWarnings(min(BP_data$Date, na.rm = TRUE)) else as.Date("2025-01-01"),
end = if (nrow(BP_data)) suppressWarnings(max(BP_data$Date, na.rm = TRUE)) else as.Date("2025-12-31")
),
hr(),
h4("Player Selection", style = "color: darkcyan;"),
selectizeInput("daily_ev_players", "Select Players",
choices = if(nrow(BP_data) > 0) {
sort(unique(na.omit(BP_data$Batter)))
} else {
character(0)
},
selected = if(nrow(BP_data) > 0) {
sort(unique(na.omit(BP_data$Batter)))
} else {
character(0)
},
multiple = TRUE,
options = list(plugins = list("remove_button"))),
hr(),
h4("Display Options", style = "color: darkcyan;"),
radioButtons("ev_metric", "EV Metric to Show:",
choices = c("Average EV" = "avg",
"Maximum EV" = "max",
"Hard Hit %" = "hh_pct"),
selected = "avg"),
hr(),
checkboxInput("show_team_row", "Show Team Average Row", value = TRUE)
)
),
column(9,
h3("Daily Exit Velocity Tracker", style = "text-align: center; color: darkcyan;"),
br(),
DT::dataTableOutput("daily_ev_pivot_table", width = "100%")
)
)
),
tabPanel("Leaderboard (Team)",
fluidRow(
column(3,
wellPanel(
checkboxGroupInput("leaderboard_seasonSelect", # FIXED: Changed from "seasonSelect"
"Season",
choices = c("Spring 2026", "Preseason Spring 2026", "Spring 2025", "Fall 2025"),
selected = c("Spring 2026", "Preseason Spring 2026"),
inline = FALSE),
hr(),
selectizeInput("leaderboard_players", "Select Players",
choices = sort(unique(na.omit(all_seasonal_data$Batter))),
selected = sort(unique(na.omit(all_seasonal_data$Batter))),
multiple = TRUE,
options = list(plugins = list("remove_button"))),
hr(),
checkboxGroupInput("leaderboard_PitcherHand", "Pitcher throws",
choices = c("Right","Left"),
selected = c("Right","Left"),
inline = TRUE),
hr(),
{
pt_choices <- sort(unique(na.omit(as.character(all_seasonal_data$TaggedPitchType))))
selectizeInput("leaderboard_pitchtype", "Pitch types",
choices = pt_choices,
selected = pt_choices,
multiple = TRUE,
options = list(plugins = list("remove_button")))
},
hr(),
checkboxGroupInput("team_columns", "Select Columns to Display",
choices = c("Pitches", "PA", "AB", "BBE", "AVG", "OBP", "SLG", "OPS",
"K %", "BB %", "Free%", "Avg EV", "Max EV", "SC%", "10-30%",
"HH%", "Barrel%", "Whiff%", "Z Whiff%", "Chase%", "GB%", "LD%",
"FB%", "Pop%", "Pull%", "Middle%", "Oppo%", "Pull FB%",
"Neg%", "0-10%", "10-20%", "20-30%", "30+%"),
selected = c("OPS", "K %", "BB %", "AVG", "Avg EV", "SC%", "HH%", "Whiff%"),
inline = FALSE)
)
),
column(9,
uiOutput("leaderboard_selectedSeasonsDisplay"),
DT::dataTableOutput("statistics_table"),
br(),
h4("Team Total", style = "text-align: center; color: darkcyan;"),
DT::dataTableOutput("team_total_table")
)
)
)
)
)
# ---------- Root UI wrapper (login gating) ----------
ui <- fluidPage(uiOutput("mainUI"))
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
})
# --- helpers defined once (BP table coloring) ---
if (!exists("format_bp_leaderboard", mode = "function")) {
format_bp_leaderboard <- function(dt_table, data_df) {
rank_colors <- colorRampPalette(c("#E1463E","white","#00840D"))(100)
rank_colors2 <- colorRampPalette(c("#00840D","white","#E1463E"))(100)
apply_color <- function(obj, column, reverse = FALSE) {
if (!column %in% names(data_df)) return(obj)
vals <- suppressWarnings(as.numeric(data_df[[column]]))
vals <- vals[is.finite(vals)]
if (!length(vals)) return(obj)
cols <- if (reverse) rank_colors2 else rank_colors
rng <- range(vals, na.rm = TRUE)
if (!is.finite(rng[1]) || !is.finite(rng[2])) return(obj)
if (identical(rng[1], rng[2])) {
return(formatStyle(obj, column, backgroundColor = styleEqual(rng[1], cols[75])))
}
cuts <- seq(rng[1], rng[2], length.out = 99)
formatStyle(obj, column, backgroundColor = styleInterval(cuts, cols))
}
good_cols <- c("EV","Avg EV","Max EV","MaxEV","SC%","10-30%","95+%","HH%","Barrel%","LD%","FB%","DIST","Avg LA","Avg Distance","Max Distance", "0-10%", "10-20%", "20-30%")
bad_cols <- c("LA","GB%","Pop%","Neg%","30+%")
out <- dt_table
for (cl in intersect(good_cols, names(data_df))) {
out <- apply_color(out, cl, FALSE)
}
for (cl in intersect(bad_cols, names(data_df))) {
out <- apply_color(out, cl, TRUE)
}
out
}
}
if (!exists("gt_theme_espn", mode = "function")) {
gt_theme_espn <- function(x) x
}
filtered_data <- reactive({
req(input$dateRange, input$BatterHand, input$PitcherHand, input$pitchtype,
input$veloRange, input$ivbrange, input$hbrange, input$seasonSelect)
df <- all_seasonal_data %>%
filter(
Season %in% input$seasonSelect,
Date >= as.Date(input$dateRange[1]) & Date <= as.Date(input$dateRange[2]),
BatterSide %in% input$BatterHand,
PitcherThrows %in% input$PitcherHand,
TaggedPitchType %in% input$pitchtype,
RelSpeed >= input$veloRange[1] & RelSpeed <= input$veloRange[2],
InducedVertBreak >= input$ivbrange[1] & InducedVertBreak <= input$ivbrange[2],
HorzBreak >= input$hbrange[1] & HorzBreak <= input$hbrange[2]
)
# Add xBA predictions
df <- add_xba_predictions(df, xBA_Model)
return(df)
})
output$selectedSeasonsDisplay <- renderUI({
req(input$seasonSelect)
div(
style = "background-color: #f0f8ff; padding: 10px; border-radius: 8px;
margin-bottom: 15px; border-left: 4px solid darkcyan;",
strong("Selected Seasons: "),
paste(input$seasonSelect, collapse = ", ")
)
})
leaderboard_filtered_data <- reactive({
req(input$leaderboard_seasonSelect, input$leaderboard_players, input$leaderboard_PitcherHand, input$leaderboard_pitchtype)
df <- all_seasonal_data %>%
filter(Season %in% input$leaderboard_seasonSelect,
Batter %in% input$leaderboard_players,
PitcherThrows %in% input$leaderboard_PitcherHand,
TaggedPitchType %in% input$leaderboard_pitchtype)
# Add xBA predictions
df <- add_xba_predictions(df, xBA_Model)
return(df)
})
bp_leaderboard_filtered <- reactive({
if (!nrow(BP_data)) return(BP_data)
df <- BP_data
# Apply date range filter
if (!is.null(input$bp_dateRange) && all(!is.na(input$bp_dateRange))) {
df <- df %>% filter(Date >= as.Date(input$bp_dateRange[1]) & Date <= as.Date(input$bp_dateRange[2]))
}
# Apply player filter if available
if (!is.null(input$bp_players_filter) && length(input$bp_players_filter) > 0) {
df <- df %>% filter(Batter %in% input$bp_players_filter)
}
df
})
# BP leaderboard data (per batter)
bp_leaderboard_data <- reactive({
filtered <- bp_leaderboard_filtered()
if (!nrow(filtered)) return(tibble::tibble(Message = "No BP data available"))
all_stats <- filtered %>%
group_by(Batter) %>%
summarize(
BBE = sum(BIPind, na.rm = TRUE),
`Avg EV` = ifelse(sum(BIPind == 1 & !is.na(ExitSpeed)) > 0, round(mean(ExitSpeed[BIPind == 1], na.rm = TRUE), 1), NA),
`Max EV` = ifelse(sum(BIPind == 1 & !is.na(ExitSpeed)) > 0, round(max(ExitSpeed[BIPind == 1], na.rm = TRUE), 1), NA),
`SC%` = ifelse(sum(BIPind, na.rm = TRUE) > 0, round(sum(SCind, na.rm = TRUE) / sum(BIPind, na.rm = TRUE) * 100, 1), NA),
`10-30%` = ifelse(sum(BIPind, na.rm = TRUE) > 0, round(sum(LA1030ind, na.rm = TRUE) / sum(BIPind, na.rm = TRUE) * 100, 1), NA),
`HH%` = ifelse(sum(BIPind, na.rm = TRUE) > 0, round(sum(HHind, na.rm = TRUE) / sum(BIPind, na.rm = TRUE) * 100, 1), NA),
`Barrel%` = ifelse(sum(BIPind, na.rm = TRUE) > 0, round(sum(Barrelind, na.rm = TRUE) / sum(BIPind, na.rm = TRUE) * 100, 1), NA),
`GB%` = ifelse(sum(BIPind, na.rm = TRUE) > 0, round(sum(GBindicator, na.rm = TRUE) / sum(BIPind, na.rm = TRUE) * 100, 1), NA),
`LD%` = ifelse(sum(BIPind, na.rm = TRUE) > 0, round(sum(LDind, na.rm = TRUE) / sum(BIPind, na.rm = TRUE) * 100, 1), NA),
`FB%` = ifelse(sum(BIPind, na.rm = TRUE) > 0, round(sum(FBind, na.rm = TRUE) / sum(BIPind, na.rm = TRUE) * 100, 1), NA),
`Pop%` = ifelse(sum(BIPind, na.rm = TRUE) > 0, round(sum(Popind, na.rm = TRUE) / sum(BIPind, na.rm = TRUE) * 100, 1), NA),
`Pull%` = ifelse(sum(BIPind, na.rm = TRUE) > 0,
round(sum(ifelse(BatterSide == "Right", Bearing < -15, Bearing > 15) & BIPind == 1, na.rm = TRUE) /
sum(BIPind, na.rm = TRUE) * 100, 1), NA),
`Straight%` = ifelse(sum(BIPind, na.rm = TRUE) > 0,
round(sum(Bearing >= -15 & Bearing <= 15 & BIPind == 1, na.rm = TRUE) /
sum(BIPind, na.rm = TRUE) * 100, 1), NA),
`Oppo%` = ifelse(sum(BIPind, na.rm = TRUE) > 0,
round(sum(ifelse(BatterSide == "Right", Bearing > 15, Bearing < -15) & BIPind == 1, na.rm = TRUE) /
sum(BIPind, na.rm = TRUE) * 100, 1), NA),
`Avg Distance` = ifelse(sum(BIPind == 1 & !is.na(Distance)) > 0, round(mean(Distance[BIPind == 1], na.rm = TRUE), 1), NA),
`Max Distance` = ifelse(sum(BIPind == 1 & !is.na(Distance)) > 0,
round(max(Distance[BIPind == 1], na.rm = TRUE), 1), NA),
`Avg LA` = ifelse(sum(BIPind == 1 & !is.na(Angle)) > 0, round(mean(Angle[BIPind == 1], na.rm = TRUE), 1), NA),
`Avg Spin` = ifelse(sum(BIPind == 1 & !is.na(HitSpinRate)) > 0, round(mean(HitSpinRate[BIPind == 1], na.rm = TRUE), 0), NA),
`Neg%` = ifelse(BBE > 0, round(sum(Angle[BIPind == 1] < 0, na.rm = TRUE) / BBE * 100, 1), NA_real_),
`0-10%` = ifelse(BBE > 0, round(sum(Angle[BIPind == 1] >= 0 & Angle[BIPind == 1] < 10, na.rm = TRUE) / BBE * 100, 1), NA_real_),
`10-20%` = ifelse(BBE > 0, round(sum(Angle[BIPind == 1] >= 10 & Angle[BIPind == 1] < 20, na.rm = TRUE) / BBE * 100, 1), NA_real_),
`20-30%` = ifelse(BBE > 0, round(sum(Angle[BIPind == 1] >= 20 & Angle[BIPind == 1] < 30, na.rm = TRUE) / BBE * 100, 1), NA_real_),
`30+%` = ifelse(BBE > 0, round(sum(Angle[BIPind == 1] >= 30, na.rm = TRUE) / BBE * 100, 1), NA_real_),
.groups = "drop"
) %>% arrange(desc(BBE))
if (is.null(input$bp_columns) || !length(input$bp_columns)) {
return(all_stats %>% select(Batter, BBE, `Avg EV`, `Avg LA`, `SC%`, `10-30%`, `HH%`, `Barrel%`))
}
selected_cols <- unique(c("Batter", input$bp_columns))
all_stats %>% select(any_of(selected_cols))
})
# convenience
selected_player <- reactive({ req(input$playerSelect); input$playerSelect })
output$leaderboard_selectedSeasonsDisplay <- renderUI({
req(input$leaderboard_seasonSelect)
div(
style = "background-color: #f0f8ff; padding: 10px; border-radius: 8px;
margin-bottom: 15px; border-left: 4px solid darkcyan;",
strong("Selected Seasons: "),
paste(input$leaderboard_seasonSelect, collapse = ", ")
)
})
is_outfielder <- reactive({
req(selected_player())
selected_player() %in% OAA_DF$obs_player
})
is_infielder <- reactive({
req(selected_player())
selected_player() %in% IF_OAA$obs_player_name
})
bt_filtered_data <- reactive({
req(selected_player())
df <- filtered_data() %>%
filter(Batter == selected_player(), !is.na(BatSpeed))
if (input$bt_count_filter != "All") {
df <- df %>%
filter(paste0(Balls, "-", Strikes) == input$bt_count_filter)
}
if (length(input$bt_pitch_type) > 0) {
df <- df %>%
filter(TaggedPitchType %in% input$bt_pitch_type)
}
if (length(input$bt_pitcher_hand) > 0) {
df <- df %>%
filter(PitcherThrows %in% input$bt_pitcher_hand)
}
if (length(input$bt_result_filter) > 0) {
result_patterns <- c()
if ("InPlay" %in% input$bt_result_filter)
result_patterns <- c(result_patterns, "InPlay")
if ("StrikeSwinging" %in% input$bt_result_filter)
result_patterns <- c(result_patterns, "StrikeSwinging")
if ("FoulBall" %in% input$bt_result_filter)
result_patterns <- c(
result_patterns,
"FoulBall",
"FoulBallNotFieldable",
"FoulBallFieldable"
)
df <- df %>% filter(PitchCall %in% result_patterns)
}
df
})
observeEvent(
list(
selected_player(),
input$bt_count_filter,
input$bt_pitch_type,
input$bt_pitcher_hand,
input$bt_result_filter
),
{
df <- bt_filtered_data()
if (nrow(df) > 0) {
df <- df %>%
arrange(Date, Inning, PAofInning, PitchofPA) %>%
mutate(
swing_label = paste0(
row_number(), ": ",
format(Date, "%m/%d"), " - ",
TaggedPitchType, " (",
ifelse(
PitchCall == "InPlay",
paste0(round(ExitSpeed, 0), " EV"),
PitchCall
),
")"
)
)
swing_choices <- c(
"All Swings" = "All",
setNames(1:nrow(df), df$swing_label)
)
updateSelectInput(
session,
"bt_swing_select",
choices = swing_choices,
selected = "All"
)
} else {
updateSelectInput(
session,
"bt_swing_select",
choices = c("All Swings" = "All"),
selected = "All"
)
}
}
)
output$bt_simple_aa_viz <- renderPlot({
df <- bt_filtered_data()
if (nrow(df) < 1) return(NULL)
create_simple_aa_viz(selected_player(), df, input$bt_swing_select)
})
output$bt_attack_dir_viz <- renderPlot({
df <- bt_filtered_data()
if (nrow(df) < 1) return(NULL)
create_attack_dir_viz(selected_player(), df, input$bt_swing_select)
})
output$bt_aa_la_viz <- renderPlot({
df <- bt_filtered_data()
if (nrow(df) < 1) return(NULL)
create_aa_la_viz(selected_player(), df, input$bt_swing_select)
})
output$bt_aa_vaa_viz <- renderPlot({
df <- bt_filtered_data()
if (nrow(df) < 1) return(NULL)
create_aa_vaa_viz(selected_player(), df, input$bt_swing_select)
})
output$bt_pitch_details_table <- gt::render_gt({
if (input$bt_swing_select == "All") return(NULL)
df <- bt_filtered_data()
swing_idx <- as.integer(input$bt_swing_select)
if (nrow(df) < swing_idx) return(NULL)
create_pitch_details_table(df[swing_idx, , drop = FALSE])
})
output$bt_single_trajectory <- renderPlot({
if (input$bt_swing_select == "All") return(NULL)
df <- bt_filtered_data()
swing_idx <- as.integer(input$bt_swing_select)
if (nrow(df) < swing_idx) return(NULL)
create_single_trajectory_viz(df[swing_idx, , drop = FALSE])
})
output$bt_single_zone <- renderPlot({
if (input$bt_swing_select == "All") return(NULL)
df <- bt_filtered_data()
swing_idx <- as.integer(input$bt_swing_select)
if (nrow(df) < swing_idx) return(NULL)
create_single_zone_viz(df[swing_idx, , drop = FALSE])
})
output$bt_single_contact <- renderPlot({
if (input$bt_swing_select == "All") return(NULL)
df <- bt_filtered_data()
swing_idx <- as.integer(input$bt_swing_select)
if (nrow(df) < swing_idx) return(NULL)
create_single_contact_viz(df[swing_idx, , drop = FALSE])
})
output$bt_single_spray <- renderPlot({
if (input$bt_swing_select == "All") return(NULL)
df <- bt_filtered_data()
swing_idx <- as.integer(input$bt_swing_select)
if (nrow(df) < swing_idx) return(NULL)
create_single_spray_viz(df[swing_idx, , drop = FALSE])
})
output$bt_comprehensive_table <- gt::render_gt({
df <- bt_filtered_data()
if (nrow(df) < 3) return(gt::gt(data.frame(Note = "Insufficient data")))
create_bt_table(selected_player(), df)
})
output$bt_heatmap_batspeed <- renderPlot({
create_bt_batspeed_heatmap(
selected_player(),
bt_filtered_data(),
input$bt_count_filter
)
})
output$bt_heatmap_aa <- renderPlot({
create_bt_aa_heatmap(
selected_player(),
bt_filtered_data(),
input$bt_count_filter
)
})
output$bt_heatmap_haa <- renderPlot({
create_bt_haa_heatmap(
selected_player(),
bt_filtered_data(),
input$bt_count_filter
)
})
output$bt_leaderboard_table <- DT::renderDataTable({
df <- filtered_data() %>%
filter(!is.na(BatSpeed)) %>%
mutate(swing_direction = get_swing_direction(HorizontalAttackAngle, BatterSide))
if (nrow(df) < 3) {
return(
DT::datatable(
data.frame(Message = "Insufficient data"),
options = list(dom = "t")
)
)
}
leaderboard <- df %>%
group_by(Batter) %>%
summarize(
Swings = n(),
`Avg BS` = round(mean(BatSpeed, na.rm = TRUE), 1),
`Max BS` = round(max(BatSpeed, na.rm = TRUE), 1),
`Avg AA` = round(mean(VerticalAttackAngle, na.rm = TRUE), 1),
`Avg HAA` = round(mean(HorizontalAttackAngle, na.rm = TRUE), 1),
`Pull%` = round(mean(swing_direction == "Pull") * 100, 1),
`Mid%` = round(mean(swing_direction == "Middle") * 100, 1),
`Oppo%` = round(mean(swing_direction == "Oppo") * 100, 1),
`Avg LA` = round(mean(Angle, na.rm = TRUE), 1),
.groups = "drop"
) %>%
filter(Swings >= 5) %>%
arrange(desc(`Avg BS`))
DT::datatable(
leaderboard,
options = list(
paging = TRUE,
ordering = TRUE,
searching = TRUE,
scrollX = TRUE,
pageLength = 15
),
rownames = FALSE
) %>%
DT::formatRound(
c(
"Avg BS", "Max BS", "Avg AA", "Avg HAA",
"Pull%", "Mid%", "Oppo%", "Avg LA"
),
1
)
})
# ---------- Outputs ----------
# Header
output$player_header_plot <- renderPlot({
req(selected_player())
grid::grid.newpage()
grid::grid.draw(create_player_header(bio, selected_player()))
})
# Dashboard: performance by location
output$performance_zone_plot <- renderPlot({
req(selected_player())
create_batter_performance_chart(selected_player(), filtered_data())
})
# Dashboard split tables (gt)
output$split_table_rhp <- gt::render_gt({
req(selected_player())
split <- create_player_table_split(selected_player(), filtered_data())
if (!is.null(split$rhp_table)) split$rhp_table else gt::gt(tibble::tibble(Note = "No RHP data"))
})
output$split_table_lhp <- gt::render_gt({
req(selected_player())
split <- create_player_table_split(selected_player(), filtered_data())
if (!is.null(split$lhp_table)) split$lhp_table else gt::gt(tibble::tibble(Note = "No LHP data"))
})
# Spray & Zone tab
output$spray_chart <- renderPlot({
req(selected_player())
create_spray_chart(selected_player(), filtered_data())
})
output$bb_profile_table <- gt::render_gt({
req(selected_player())
df_filtered <- filtered_data()
bb_table <- create_bb_profile_table(df_filtered, selected_player())
gt::gt(bb_table) %>%
gt::tab_header(title = paste("Batted Ball Profile:", selected_player())) %>%
gt::cols_align(align = "center", columns = gt::everything()) %>%
gt::tab_options(table.font.size = gt::px(12))
})
# Contact / Radial tab
output$contact_map <- renderPlot({
req(selected_player())
create_contact_map(selected_player(), filtered_data())
})
output$radial_chart <- renderPlot({
req(selected_player())
radial_chart_ev(selected_player(), filtered_data())
})
output$quality_table <- renderTable({
req(selected_player())
as.data.frame(create_quality_table(filtered_data(), selected_player()))
}, striped = TRUE, bordered = TRUE, digits = 1)
# Heatmap tab
output$heatmap_plot <- renderPlot({
req(selected_player(), input$heat_metric)
create_heatmap(selected_player(), input$heat_metric, filtered_data())
})
# Percentiles tab
output$percentiles_plot <- renderPlot({
req(selected_player(), input$pool_select)
batter_rows <- filtered_data() %>% filter(Batter == selected_player())
pool_rows <- switch(input$pool_select,
"SBC" = SBC_2025,
"P5" = P5_2025,
"SEC" = SEC_2025,
"TEAM" = filtered_data(),
SBC_2025)
create_percentile_chart(batter_rows, pool_rows, selected_player())
})
# Whiff movement tab
output$whiff_movement_plot <- renderPlot({
req(selected_player())
create_whiff_movement_chart(selected_player(), filtered_data())
})
#defense
output$field_graph_plot <- renderPlotly({
req(selected_player(), input$position)
result <- field_graph(selected_player(), input$position)
result[[1]]
})
output$field_graph_table <- gt::render_gt({
req(selected_player(), input$position)
result <- field_graph(selected_player(), input$position)
result[[2]]
})
output$star_graph <- suppressWarnings(renderPlotly({
req(selected_player(), input$position)
star_graph(selected_player(), input$position)
}))
output$direction_oaa_plot <- renderPlot({
req(selected_player(), input$position)
result <- direction_oaa(selected_player(), input$position)
result[[1]]
})
output$direction_oaa_table <- render_gt({
req(selected_player(), input$position)
result <- direction_oaa(selected_player(), input$position)
result[[2]]
})
output$infield_field_graph_plot <- renderPlotly({
req(selected_player(), input$position)
result <- infield_field_graph(selected_player(), input$position)
result[[1]]
})
output$infield_field_graph_table <- gt::render_gt({
req(selected_player(), input$position)
result <- infield_field_graph(selected_player(), input$position)
result[[2]]
})
output$infield_star_graph <- suppressWarnings(renderPlotly({
req(selected_player(), input$position)
infield_star_graph(selected_player(), input$position)
}))
output$first_def_pos_plot <- renderPlot({
infield_positioning_heatmap(
team = input$team_1,
man_on_first = input$man_on_first_1,
man_on_second = input$man_on_second_1,
man_on_third = input$man_on_third_1,
BatterHand = input$BatterHand_1,
PitcherHand = input$PitcherHand_1,
scorediff = input$scorediff_1,
Date1 = input$Date1_1,
Date2 = input$Date2_1,
obs_pitcher = input$obs_pitcher_1,
Hitter = input$Hitter_1,
Count = input$Count_1,
obs_outs = input$obs_outs_1,
Opponent = input$Opponent_1
)
})
output$second_def_pos_plot <- renderPlot({
infield_positioning_heatmap(
team = input$team_2,
man_on_first = input$man_on_first_2,
man_on_second = input$man_on_second_2,
man_on_third = input$man_on_third_2,
BatterHand = input$BatterHand_2,
PitcherHand = input$PitcherHand_2,
scorediff = input$scorediff_2,
Date1 = input$Date1_2,
Date2 = input$Date2_2,
obs_pitcher = input$obs_pitcher_2,
Hitter = input$Hitter_2,
Count = input$Count_2,
obs_outs = input$obs_outs_2,
Opponent = input$Opponent_2
)
})
# --- BP visuals ---
output$bp_spray_plot <- renderPlot({
req(input$bp_player)
create_bp_spray_chart(input$bp_player, bp_leaderboard_filtered())
})
output$bp_zone_plot <- renderPlot({
req(input$bp_player)
create_bp_zone_chart(input$bp_player, bp_leaderboard_filtered())
})
output$bp_contact_plot <- renderPlot({
req(input$bp_player)
create_bp_contact_map(input$bp_player, bp_leaderboard_filtered())
})
# --- BP tables ---
output$bp_leaderboard_table <- DT::renderDataTable({
data_to_show <- bp_leaderboard_data()
if (!nrow(data_to_show) || "Message" %in% names(data_to_show)) {
return(DT::datatable(data_to_show, options = list(dom = 't')))
}
dt <- DT::datatable(
data_to_show,
options = list(paging = TRUE, ordering = TRUE, searching = TRUE, scrollX = TRUE, pageLength = 10),
rownames = FALSE
)
num1 <- intersect(c('Avg EV','Max EV','Avg Distance','Max Distance','Avg LA','Avg Spin'), names(data_to_show))
if (length(num1)) dt <- formatRound(dt, num1, digits = 1)
num2 <- intersect(c('SC%','10-30%','HH%','Barrel%','GB%','LD%','FB%','Pop%','Pull%','Straight%','Oppo%','Neg%','0-10%','10-20%','20-30%','30+%'),
names(data_to_show))
if (length(num2)) dt <- formatRound(dt, num2, digits = 1)
format_bp_leaderboard(dt, data_to_show)
})
output$bp_team_summary_table <- DT::renderDataTable({
filtered <- bp_leaderboard_filtered()
if (!nrow(filtered)) {
return(DT::datatable(data.frame(Message = "No data available"), options = list(dom = 't')))
}
team_bp <- filtered %>%
summarize(
Batter = "TEAM TOTAL",
BBE = sum(BIPind, na.rm = TRUE),
`Avg EV` = ifelse(sum(BIPind == 1 & !is.na(ExitSpeed)) > 0, round(mean(ExitSpeed[BIPind == 1], na.rm = TRUE), 1), NA),
`Max EV` = ifelse(sum(BIPind == 1 & !is.na(ExitSpeed)) > 0, round(max(ExitSpeed[BIPind == 1], na.rm = TRUE), 1), NA),
`SC%` = ifelse(sum(BIPind, na.rm = TRUE) > 0, round(sum(SCind, na.rm = TRUE) / sum(BIPind, na.rm = TRUE) * 100, 1), NA),
`10-30%` = ifelse(sum(BIPind, na.rm = TRUE) > 0, round(sum(LA1030ind,na.rm = TRUE) / sum(BIPind, na.rm = TRUE) * 100, 1), NA),
`HH%` = ifelse(sum(BIPind, na.rm = TRUE) > 0, round(sum(HHind, na.rm = TRUE) / sum(BIPind, na.rm = TRUE) * 100, 1), NA),
`Barrel%` = ifelse(sum(BIPind, na.rm = TRUE) > 0, round(sum(Barrelind,na.rm = TRUE) / sum(BIPind, na.rm = TRUE) * 100, 1), NA),
`GB%` = ifelse(sum(BIPind, na.rm = TRUE) > 0, round(sum(GBindicator, na.rm = TRUE) / sum(BIPind, na.rm = TRUE) * 100, 1), NA),
`LD%` = ifelse(sum(BIPind, na.rm = TRUE) > 0, round(sum(LDind, na.rm = TRUE) / sum(BIPind, na.rm = TRUE) * 100, 1), NA),
`FB%` = ifelse(sum(BIPind, na.rm = TRUE) > 0, round(sum(FBind, na.rm = TRUE) / sum(BIPind, na.rm = TRUE) * 100, 1), NA),
`Pop%` = ifelse(sum(BIPind, na.rm = TRUE) > 0, round(sum(Popind, na.rm = TRUE) / sum(BIPind, na.rm = TRUE) * 100, 1), NA),
`Pull%` = ifelse(sum(BIPind, na.rm = TRUE) > 0, round(sum(ifelse(BatterSide == "Right", Bearing < -15, Bearing > 15) & BIPind == 1, na.rm = TRUE) / sum(BIPind, na.rm = TRUE) * 100, 1), NA),
`Straight%` = ifelse(sum(BIPind, na.rm = TRUE) > 0, round(sum(Bearing >= -15 & Bearing <= 15 & BIPind == 1, na.rm = TRUE) / sum(BIPind, na.rm = TRUE) * 100, 1), NA),
`Oppo%` = ifelse(sum(BIPind, na.rm = TRUE) > 0, round(sum(ifelse(BatterSide == "Right", Bearing > 15, Bearing < -15) & BIPind == 1, na.rm = TRUE) / sum(BIPind, na.rm = TRUE) * 100, 1), NA),
`Avg Distance` = ifelse(sum(BIPind == 1 & !is.na(Distance)) > 0, round(mean(Distance[BIPind == 1], na.rm = TRUE), 1), NA),
`Max Distance` = ifelse(sum(BIPind == 1 & !is.na(Distance)) > 0,
round(max(Distance[BIPind == 1], na.rm = TRUE), 1), NA),
`Avg LA` = ifelse(sum(BIPind == 1 & !is.na(Angle)) > 0, round(mean(Angle[BIPind == 1], na.rm = TRUE), 1), NA),
`Avg Spin` = ifelse(sum(BIPind == 1 & !is.na(HitSpinRate)) > 0, round(mean(HitSpinRate[BIPind == 1], na.rm = TRUE), 0), NA),
`Neg%` = ifelse(BBE > 0, sum(Angle[PitchCall == "InPlay"] < 0, na.rm = TRUE) / BBE * 100, NA_real_),
`0-10%` = ifelse(BBE > 0, sum(Angle[PitchCall == "InPlay"] >= 0 & Angle[PitchCall == "InPlay"] < 10, na.rm = TRUE) / BBE * 100, NA_real_),
`10-20%` = ifelse(BBE > 0, sum(Angle[PitchCall == "InPlay"] >= 10 & Angle[PitchCall == "InPlay"] < 20, na.rm = TRUE) / BBE * 100, NA_real_),
`20-30%` = ifelse(BBE > 0, sum(Angle[PitchCall == "InPlay"] >= 20 & Angle[PitchCall == "InPlay"] < 30, na.rm = TRUE) / BBE * 100, NA_real_),
`30+%` = ifelse(BBE > 0, sum(Angle[PitchCall == "InPlay"] >= 30, na.rm = TRUE) / BBE * 100, NA_real_)
)
chosen <- unique(c("Batter", input$bp_columns))
team_bp <- dplyr::select(team_bp, dplyr::any_of(chosen))
DT::datatable(team_bp, options = list(paging = FALSE, ordering = FALSE, searching = FALSE, dom = 't'))
})
output$statistics_table <- DT::renderDataTable({
df <- leaderboard_filtered_data()
if (!nrow(df)) return(DT::datatable(data.frame(Message = "No data for current filters"), options = list(dom='t')))
# Calculate ALL possible statistics
summary_df <- df %>%
group_by(Batter) %>%
summarize(
Pitches = n(),
PA = sum(PAindicator, na.rm = TRUE),
AB = sum(ABindicator, na.rm = TRUE),
H = sum(HitIndicator, na.rm = TRUE),
TB = sum(totalbases, na.rm = TRUE),
BB = sum(WalkIndicator, na.rm = TRUE),
HBP = sum(HBPIndicator, na.rm = TRUE),
K = sum(KorBB == "Strikeout", na.rm = TRUE),
BBE = sum(BIPind, na.rm = TRUE),
Swings = sum(SwingIndicator, na.rm = TRUE),
ZSwings = sum(Zswing, na.rm = TRUE),
ZWhiff = sum(Zwhiffind, na.rm = TRUE),
Whiff = sum(WhiffIndicator, na.rm = TRUE),
Chase = sum(Chaseindicator, na.rm = TRUE),
OutZone = sum(OutofZone, na.rm = TRUE),
`Avg EV` = mean(ExitSpeed[PitchCall == "InPlay" & TaggedHitType != "Bunt"], na.rm = TRUE),
`Max EV` = suppressWarnings(max(ExitSpeed[PitchCall == "InPlay"], na.rm = TRUE)),
SC = sum(SCind, na.rm = TRUE),
LA1030 = sum(LA1030ind, na.rm = TRUE),
HH = sum(HHind, na.rm = TRUE),
Barrel = sum(Barrelind, na.rm = TRUE),
GB = sum(GBindicator, na.rm = TRUE),
LD = sum(LDind, na.rm = TRUE),
FB_count = sum(FBind, na.rm = TRUE),
Pop = sum(Popind, na.rm = TRUE),
Pull = sum(ifelse(BatterSide == "Right", Bearing < -15, Bearing > 15) & PitchCall == "InPlay", na.rm = TRUE),
Middle = sum(Bearing >= -15 & Bearing <= 15 & PitchCall == "InPlay", na.rm = TRUE),
Oppo = sum(ifelse(BatterSide == "Right", Bearing > 15, Bearing < -15) & PitchCall == "InPlay", na.rm = TRUE),
Pull_FB = sum(ifelse(BatterSide == "Right", Bearing < -15, Bearing > 15) & TaggedHitType == "FlyBall" & PitchCall == "InPlay", na.rm = TRUE),
`Neg%` = ifelse(BBE > 0, sum(Angle[PitchCall == "InPlay"] < 0, na.rm = TRUE) / BBE * 100, NA_real_),
`0-10%` = ifelse(BBE > 0, sum(Angle[PitchCall == "InPlay"] >= 0 & Angle[PitchCall == "InPlay"] < 10, na.rm = TRUE) / BBE * 100, NA_real_),
`10-20%` = ifelse(BBE > 0, sum(Angle[PitchCall == "InPlay"] >= 10 & Angle[PitchCall == "InPlay"] < 20, na.rm = TRUE) / BBE * 100, NA_real_),
`20-30%` = ifelse(BBE > 0, sum(Angle[PitchCall == "InPlay"] >= 20 & Angle[PitchCall == "InPlay"] < 30, na.rm = TRUE) / BBE * 100, NA_real_),
`30+%` = ifelse(BBE > 0, sum(Angle[PitchCall == "InPlay"] >= 30, na.rm = TRUE) / BBE * 100, NA_real_),
.groups = "drop"
) %>%
mutate(
`AVG` = ifelse(AB > 0, H/AB, NA_real_),
`OBP` = ifelse(PA > 0, (H + BB + HBP)/PA, NA_real_),
`SLG` = ifelse(AB > 0, TB/AB, NA_real_),
`OPS` = ifelse(!is.na(`OBP`) & !is.na(`SLG`), `OBP` + `SLG`, NA_real_),
`K %` = ifelse(PA > 0, K/PA * 100, NA_real_),
`BB %` = ifelse(PA > 0, BB/PA * 100, NA_real_),
`Free%` = ifelse(PA > 0, (BB + HBP)/PA * 100, NA_real_),
`SC%` = ifelse(BBE > 0, SC/BBE * 100, NA_real_),
`10-30%` = ifelse(BBE > 0, LA1030/BBE * 100, NA_real_),
`HH%` = ifelse(BBE > 0, HH/BBE * 100, NA_real_),
`Barrel%` = ifelse(BBE > 0, Barrel/BBE * 100, NA_real_),
`Whiff%` = ifelse(Swings > 0, Whiff/Swings * 100, NA_real_),
`Z Whiff%` = ifelse(ZSwings > 0, ZWhiff/ZSwings * 100, NA_real_),
`Chase%` = ifelse(OutZone > 0, Chase/OutZone * 100, NA_real_),
`GB%` = ifelse(BBE > 0, GB/BBE * 100, NA_real_),
`LD%` = ifelse(BBE > 0, LD/BBE * 100, NA_real_),
`FB%` = ifelse(BBE > 0, FB_count/BBE * 100, NA_real_),
`Pop%` = ifelse(BBE > 0, Pop/BBE * 100, NA_real_),
`Pull%` = ifelse(BBE > 0, Pull/BBE * 100, NA_real_),
`Middle%` = ifelse(BBE > 0, Middle/BBE * 100, NA_real_),
`Oppo%` = ifelse(BBE > 0, Oppo/BBE * 100, NA_real_),
`Pull FB%` = ifelse(FB_count > 0, Pull_FB/FB_count * 100, NA_real_)
) %>%
mutate(across(everything(), ~ ifelse(is.nan(.x) | is.infinite(.x), NA, .x)))
# Select only the columns the user wants, maintaining order
if (is.null(input$team_columns) || !length(input$team_columns)) {
# Default selection if nothing chosen
selected_cols <- c("Batter", "OPS", "K %", "BB %", "AVG", "Avg EV", "SC%", "HH%", "Whiff%")
} else {
selected_cols <- c("Batter", input$team_columns)
}
display_df <- summary_df %>% select(any_of(selected_cols))
# Create datatable
dt <- DT::datatable(
display_df,
options = list(scrollX = TRUE, paging = FALSE, ordering = TRUE, searching = TRUE),
rownames = FALSE
)
# Format numeric columns
num_cols_3dec <- intersect(c("AVG", "OBP", "SLG", "OPS"), names(display_df))
if (length(num_cols_3dec)) dt <- DT::formatRound(dt, num_cols_3dec, 3)
num_cols_1dec <- intersect(c("Avg EV", "Max EV"), names(display_df))
if (length(num_cols_1dec)) dt <- DT::formatRound(dt, num_cols_1dec, 1)
pct_cols <- intersect(c("SC%", "10-30%", "HH%", "Barrel%", "Whiff%", "Z Whiff%",
"Chase%", "GB%", "LD%", "FB%", "Pop%", "K %", "BB %", "Free%",
"Pull%", "Middle%", "Oppo%", "Pull FB%","Neg%", "0-10%", "10-20%", "20-30%", "30+%"), names(display_df))
if (length(pct_cols)) dt <- DT::formatRound(dt, pct_cols, 1)
# Apply color formatting
rank_colors <- colorRampPalette(c("#E1463E","white","#00840D"))(100)
rank_colors2 <- colorRampPalette(c("#00840D","white","#E1463E"))(100)
apply_color <- function(obj, column, reverse = FALSE) {
if (!column %in% names(display_df)) return(obj)
vals <- suppressWarnings(as.numeric(display_df[[column]]))
vals <- vals[is.finite(vals)]
if (!length(vals)) return(obj)
cols <- if (reverse) rank_colors2 else rank_colors
rng <- range(vals, na.rm = TRUE)
if (!is.finite(rng[1]) || !is.finite(rng[2])) return(obj)
if (identical(rng[1], rng[2])) {
return(DT::formatStyle(obj, column, backgroundColor = DT::styleEqual(rng[1], cols[50])))
}
cuts <- seq(rng[1], rng[2], length.out = 99)
DT::formatStyle(obj, column, backgroundColor = DT::styleInterval(cuts, cols))
}
# Good = higher is better (green)
good_cols <- c("OPS","BB %","Free%","AVG","OBP","SLG","Avg EV","Max EV","SC%",
"10-30%","HH%","Barrel%","LD%","FB%","Middle%","0-10%", "10-20%", "20-30%")
# Bad = higher is worse (red)
bad_cols <- c("K %","Whiff%","Z Whiff%","Chase%","GB%","Pop%","Neg%", "30+%")
for (cl in intersect(good_cols, names(display_df))) {
dt <- apply_color(dt, cl, FALSE)
}
for (cl in intersect(bad_cols, names(display_df))) {
dt <- apply_color(dt, cl, TRUE)
}
dt
})
output$bp_heatmap_hard <- renderPlot({
req(input$bp_player)
create_bp_heatmap(input$bp_player, bp_leaderboard_filtered(), "Hard-Hit (95+)")
})
output$bp_heatmap_medium <- renderPlot({
req(input$bp_player)
create_bp_heatmap(input$bp_player, bp_leaderboard_filtered(), "Medium (90-95)")
})
output$bp_heatmap_soft <- renderPlot({
req(input$bp_player)
create_bp_heatmap(input$bp_player, bp_leaderboard_filtered(), "Soft (Under 90)")
})
output$team_total_table <- DT::renderDataTable({
df <- leaderboard_filtered_data()
if (!nrow(df)) return(DT::datatable(data.frame(Message = "No data"), options = list(dom='t')))
# Calculate team totals
team_total <- df %>%
summarize(
Batter = "TEAM TOTAL",
Pitches = n(),
PA = sum(PAindicator, na.rm = TRUE),
AB = sum(ABindicator, na.rm = TRUE),
H = sum(HitIndicator, na.rm = TRUE),
TB = sum(totalbases, na.rm = TRUE),
BB = sum(WalkIndicator, na.rm = TRUE),
HBP = sum(HBPIndicator, na.rm = TRUE),
K = sum(KorBB == "Strikeout", na.rm = TRUE),
BBE = sum(BIPind, na.rm = TRUE),
Swings = sum(SwingIndicator, na.rm = TRUE),
ZSwings = sum(Zswing, na.rm = TRUE),
ZWhiff = sum(Zwhiffind, na.rm = TRUE),
Whiff = sum(WhiffIndicator, na.rm = TRUE),
Chase = sum(Chaseindicator, na.rm = TRUE),
OutZone = sum(OutofZone, na.rm = TRUE),
`Avg EV` = mean(ExitSpeed[PitchCall == "InPlay" & TaggedHitType != "Bunt"], na.rm = TRUE),
`Max EV` = suppressWarnings(max(ExitSpeed[PitchCall == "InPlay"], na.rm = TRUE)),
SC = sum(SCind, na.rm = TRUE),
LA1030 = sum(LA1030ind, na.rm = TRUE),
HH = sum(HHind, na.rm = TRUE),
Barrel = sum(Barrelind, na.rm = TRUE),
GB = sum(GBindicator, na.rm = TRUE),
LD = sum(LDind, na.rm = TRUE),
FB_count = sum(FBind, na.rm = TRUE),
Pop = sum(Popind, na.rm = TRUE),
Pull = sum(ifelse(BatterSide == "Right", Bearing < -15, Bearing > 15) & PitchCall == "InPlay", na.rm = TRUE),
Middle = sum(Bearing >= -15 & Bearing <= 15 & PitchCall == "InPlay", na.rm = TRUE),
Oppo = sum(ifelse(BatterSide == "Right", Bearing > 15, Bearing < -15) & PitchCall == "InPlay", na.rm = TRUE),
Pull_FB = sum(ifelse(BatterSide == "Right", Bearing < -15, Bearing > 15) & TaggedHitType == "FlyBall" & PitchCall == "InPlay", na.rm = TRUE)
) %>%
mutate(
`AVG` = ifelse(AB > 0, H/AB, NA_real_),
`OBP` = ifelse(PA > 0, (H + BB + HBP)/PA, NA_real_),
`SLG` = ifelse(AB > 0, TB/AB, NA_real_),
`OPS` = ifelse(!is.na(`OBP`) & !is.na(`SLG`), `OBP` + `SLG`, NA_real_),
`K %` = ifelse(PA > 0, K/PA * 100, NA_real_),
`BB %` = ifelse(PA > 0, BB/PA * 100, NA_real_),
`Free%` = ifelse(PA > 0, (BB + HBP)/PA * 100, NA_real_),
`SC%` = ifelse(BBE > 0, SC/BBE * 100, NA_real_),
`10-30%` = ifelse(BBE > 0, LA1030/BBE * 100, NA_real_),
`HH%` = ifelse(BBE > 0, HH/BBE * 100, NA_real_),
`Barrel%` = ifelse(BBE > 0, Barrel/BBE * 100, NA_real_),
`Whiff%` = ifelse(Swings > 0, Whiff/Swings * 100, NA_real_),
`Z Whiff%` = ifelse(ZSwings > 0, ZWhiff/ZSwings * 100, NA_real_),
`Chase%` = ifelse(OutZone > 0, Chase/OutZone * 100, NA_real_),
`GB%` = ifelse(BBE > 0, GB/BBE * 100, NA_real_),
`LD%` = ifelse(BBE > 0, LD/BBE * 100, NA_real_),
`FB%` = ifelse(BBE > 0, FB_count/BBE * 100, NA_real_),
`Pop%` = ifelse(BBE > 0, Pop/BBE * 100, NA_real_),
`Pull%` = ifelse(BBE > 0, Pull/BBE * 100, NA_real_),
`Middle%` = ifelse(BBE > 0, Middle/BBE * 100, NA_real_),
`Oppo%` = ifelse(BBE > 0, Oppo/BBE * 100, NA_real_),
`Pull FB%` = ifelse(FB_count > 0, Pull_FB/FB_count * 100, NA_real_)
)
# Select same columns as main table
if (is.null(input$team_columns) || !length(input$team_columns)) {
selected_cols <- c("Batter", "OPS", "K %", "BB %", "AVG", "Avg EV", "SC%", "HH%", "Whiff%")
} else {
selected_cols <- c("Batter", input$team_columns)
}
team_display <- team_total %>% select(any_of(selected_cols))
# Format the team total table
dt <- DT::datatable(team_display,
options = list(dom = 't', ordering = FALSE, searching = FALSE),
rownames = FALSE)
# Apply same formatting as main table
num_cols_3dec <- intersect(c("AVG", "OBP", "SLG", "OPS"), names(team_display))
if (length(num_cols_3dec)) dt <- DT::formatRound(dt, num_cols_3dec, 3)
num_cols_1dec <- intersect(c("Avg EV", "Max EV"), names(team_display))
if (length(num_cols_1dec)) dt <- DT::formatRound(dt, num_cols_1dec, 1)
pct_cols <- intersect(c("SC%", "10-30%", "HH%", "Barrel%", "Whiff%", "Z Whiff%",
"Chase%", "GB%", "LD%", "FB%", "Pop%", "K %", "BB %", "Free%",
"Pull%", "Middle%", "Oppo%", "Pull FB%"), names(team_display))
if (length(pct_cols)) dt <- DT::formatRound(dt, pct_cols, 1)
# Style the row with a distinct background
dt <- DT::formatStyle(dt, 'Batter',
backgroundColor = '#d4edeb',
fontWeight = 'bold')
dt
})
game_choices <- reactive({
fd <- filtered_data() %>% filter(Batter == input$playerSelect)
if (!nrow(fd)) return(character(0))
if ("game" %in% names(fd)) {
sort(unique(fd$game))
} else if ("Date" %in% names(fd)) {
sort(unique(format(fd$Date, "%Y-%m-%d")))
} else {
character(0)
}
})
output$game_controls <- renderUI({
req(game_choices())
selectInput("game_id", "Game", choices = game_choices(), selected = tail(game_choices(), 1))
})
output$hitter_game_table <- gt::render_gt({
req(input$playerSelect, input$game_id)
hitter_game_safe(input$playerSelect, input$game_id, filtered_data())
})
output$last15_games_table <- gt::render_gt({
req(selected_player())
create_last15_table(selected_player(), filtered_data())
})
output$adv_table <- gt::render_gt({
req(selected_player())
create_advanced_numbers_table(selected_player(), filtered_data())
})
# FB Splits Table
output$fb_splits_table <- gt::render_gt({
req(selected_player())
create_fb_splits_table(selected_player(), filtered_data())
})
# Offspeed Splits Table
output$offspeed_splits_table <- gt::render_gt({
req(selected_player())
create_offspeed_splits_table(selected_player(), filtered_data())
})
# At-Bat Breakdown (for Game View)
output$at_bats_plot <- renderPlot({
req(selected_player(), input$game_id)
create_at_bats_plot(filtered_data(), selected_player(), input$game_id, pitch_colors)
})
output$spray_chart_plotly <- renderPlotly({
req(selected_player())
create_spray_chart_plotly(selected_player(), filtered_data())
})
output$spray_chart_splits <- renderPlot({
req(selected_player())
create_spray_chart_splits(selected_player(), filtered_data())
})
output$spray_chart_sector <- renderPlot({
req(selected_player())
create_sector_spray_chart(selected_player(), filtered_data())
})
output$spray_chart_trajectory <- renderPlot({
req(selected_player())
create_trajectory_spray_chart(selected_player(), filtered_data())
})
output$spray_chart_2k <- renderPlot({
req(selected_player())
create_2k_spray_chart(selected_player(), filtered_data())
})
output$spray_chart_schill_basic <- renderPlot({
req(selected_player())
create_schill_basic_spray(selected_player(), filtered_data())
})
output$spray_chart_classic <- renderPlot({
req(selected_player())
create_spray_chart(selected_player(), filtered_data())
})
daily_ev_filtered <- reactive({
if (!nrow(BP_data)) return(BP_data)
df <- BP_data
# Apply date filter
if (!is.null(input$daily_ev_dateRange) && all(!is.na(input$daily_ev_dateRange))) {
df <- df %>% filter(Date >= as.Date(input$daily_ev_dateRange[1]) & Date <= as.Date(input$daily_ev_dateRange[2]))
}
# Apply player filter
if (!is.null(input$daily_ev_players) && length(input$daily_ev_players) > 0) {
df <- df %>% filter(Batter %in% input$daily_ev_players)
}
df
})
# Daily EV summary data
daily_ev_summary <- reactive({
filtered <- daily_ev_filtered()
if (!nrow(filtered)) return(tibble::tibble(Message = "No data available"))
filtered %>%
filter(BIPind == 1, !is.na(ExitSpeed)) %>%
group_by(Batter, Date) %>%
summarize(
BBE = n(),
`Avg EV` = round(mean(ExitSpeed, na.rm = TRUE), 1),
`Max EV` = round(max(ExitSpeed, na.rm = TRUE), 1),
`Min EV` = round(min(ExitSpeed, na.rm = TRUE), 1),
`HH Count` = sum(ExitSpeed >= 95, na.rm = TRUE),
`HH%` = round(sum(ExitSpeed >= 95, na.rm = TRUE) / n() * 100, 1),
`95+ Count` = sum(ExitSpeed >= 95, na.rm = TRUE),
`100+ Count` = sum(ExitSpeed >= 100, na.rm = TRUE),
.groups = "drop"
) %>%
arrange(Date, Batter)
})
# Daily EV plot
output$daily_ev_plot <- renderPlot({
summary_data <- daily_ev_summary()
if (!nrow(summary_data) || "Message" %in% names(summary_data)) {
return(ggplot() + theme_minimal() +
ggtitle("No data available - select players and date range") +
theme(plot.title = element_text(hjust = 0.5, size = 14)))
}
req(input$daily_ev_players)
# Determine what to plot
metric_col <- if (input$show_max_ev) "Max EV" else "Avg EV"
y_label <- if (input$show_max_ev) "Max Exit Velocity (mph)" else "Average Exit Velocity (mph)"
p <- ggplot(summary_data, aes(x = Date, y = .data[[metric_col]], color = Batter, group = Batter)) +
geom_line(size = 1.2, alpha = 0.7) +
geom_point(aes(size = BBE), alpha = 0.8) +
scale_size_continuous(name = "Batted Balls", range = c(3, 8)) +
scale_color_brewer(palette = "Set2", name = "Player") +
labs(
title = paste("Daily", y_label, "Tracking"),
x = "Date",
y = y_label
) +
theme_minimal() +
theme(
plot.title = element_text(hjust = 0.5, size = 16, face = "bold", color = "darkcyan"),
axis.title = element_text(size = 12, face = "bold"),
axis.text = element_text(size = 10),
legend.position = "right",
legend.title = element_text(face = "bold"),
panel.grid.major = element_line(color = "grey90"),
panel.grid.minor = element_line(color = "grey95")
)
# Add trend lines if requested
if (input$show_trend && nrow(summary_data) > 2) {
p <- p + geom_smooth(method = "lm", se = FALSE, linetype = "dashed", size = 0.8, alpha = 0.5)
}
# Add reference line at 95 mph
p <- p + geom_hline(yintercept = 95, linetype = "dotted", color = "#00840D", size = 1, alpha = 0.7) +
annotate("text", x = min(summary_data$Date), y = 96, label = "Hard Hit (95+)",
hjust = 0, color = "#00840D", size = 3.5, fontface = "bold")
p
})
# Daily EV table
output$daily_ev_table <- DT::renderDataTable({
summary_data <- daily_ev_summary()
if (!nrow(summary_data) || "Message" %in% names(summary_data)) {
return(DT::datatable(summary_data, options = list(dom = 't')))
}
# Format the table
dt <- DT::datatable(
summary_data,
options = list(
pageLength = 25,
scrollX = TRUE,
order = list(list(1, 'desc')),
columnDefs = list(
list(className = 'dt-center', targets = '_all')
)
),
rownames = FALSE
) %>%
DT::formatRound(c('Avg EV', 'Max EV', 'Min EV', 'HH%'), 1) %>%
DT::formatDate('Date', method = 'toLocaleDateString')
# Color code the metrics
rank_colors <- colorRampPalette(c("#E1463E", "white", "#00840D"))(100)
apply_color <- function(obj, column) {
if (!column %in% names(summary_data)) return(obj)
vals <- suppressWarnings(as.numeric(summary_data[[column]]))
vals <- vals[is.finite(vals)]
if (!length(vals)) return(obj)
rng <- range(vals, na.rm = TRUE)
if (!is.finite(rng[1]) || !is.finite(rng[2])) return(obj)
if (identical(rng[1], rng[2])) {
return(DT::formatStyle(obj, column, backgroundColor = DT::styleEqual(rng[1], rank_colors[50])))
}
cuts <- seq(rng[1], rng[2], length.out = 99)
DT::formatStyle(obj, column, backgroundColor = DT::styleInterval(cuts, rank_colors))
}
# Apply colors to EV metrics
dt <- apply_color(dt, "Avg EV")
dt <- apply_color(dt, "Max EV")
dt <- apply_color(dt, "HH%")
# Highlight high hard-hit counts
dt <- DT::formatStyle(dt, '95+ Count',
backgroundColor = DT::styleInterval(c(0, 2, 4, 6),
c('white', '#e8f5e9', '#c8e6c9', '#a5d6a7', '#81c784')))
dt
})
output$weekly_report_preview <- renderPlot({
req(selected_player())
fd <- filtered_data()
n_games <- if (!is.null(input$weekly_n_games) && !is.na(input$weekly_n_games)) input$weekly_n_games else 3
batter_data <- fd %>% filter(Batter == selected_player())
game_dates <- batter_data %>%
distinct(Date) %>%
arrange(desc(Date)) %>%
slice_head(n = n_games) %>%
arrange(Date) %>%
pull(Date)
if (length(game_dates) == 0) return(NULL)
# --- Summary stats ---
games_data <- batter_data %>% filter(Date %in% game_dates)
stats_df <- compute_weekly_game_stats(games_data)
stats_grob <- create_stats_header_grob(stats_df)
# --- AB plots ---
ab_plots <- lapply(game_dates, function(gd) {
gd_str <- format(gd, "%Y-%m-%d")
rows <- fd %>% filter(Batter == selected_player(), Date == gd)
opp <- unique(rows$PitcherTeam)
opp <- opp[!is.na(opp)]
opp_label <- if (length(opp)) opp[1] else NULL
create_weekly_ab_plot(fd, selected_player(), gd_str, opp_label)
})
# --- Heatmaps ---
hm_solid <- create_report_heatmap(selected_player(), fd, "Solid Contact", "Solid Contact")
hm_whiff <- create_report_heatmap(selected_player(), fd, "Whiffs", "Whiffs")
hm_fb <- create_report_heatmap(selected_player(), fd, "FB Whiffs", "FB Whiffs")
# --- Notes ---
notes_grob <- grid::grobTree(
grid::rectGrob(gp = grid::gpar(fill = "white", col = "black", lwd = 1)),
grid::textGrob(
paste0("Notes:\n", ifelse(is.null(input$weekly_notes), "", input$weekly_notes)),
x = 0.05, y = 0.95, hjust = 0, vjust = 1,
gp = grid::gpar(fontsize = 10)
)
)
# --- Legends ---
shape_leg_grob <- create_shape_legend_grob()
pt_leg_grob <- create_pitch_color_legend_grob()
legends_row <- gridExtra::arrangeGrob(shape_leg_grob, pt_leg_grob, ncol = 2)
# --- Assemble ---
ab_grobs <- lapply(ab_plots, ggplotGrob)
ab_stack <- do.call(gridExtra::arrangeGrob, c(ab_grobs, list(ncol = 1)))
n_ab <- length(ab_grobs)
title_g <- grid::textGrob(
paste(selected_player(), "- Weekly Hitter Report"),
gp = grid::gpar(fontsize = 18, fontface = "bold", col = "#006F71")
)
season_label <- grid::textGrob("Season Heatmaps",
gp = grid::gpar(fontsize = 14, fontface = "bold"))
heatmap_row <- gridExtra::arrangeGrob(
ggplotGrob(hm_solid), ggplotGrob(hm_whiff), ggplotGrob(hm_fb), notes_grob,
ncol = 4, widths = c(1, 1, 1, 1)
)
gridExtra::grid.arrange(
title_g, stats_grob, legends_row, ab_stack, season_label, heatmap_row,
ncol = 1,
heights = c(0.5, 0.6, 0.6, n_ab * 2.8, 0.4, 3.5)
)
})
output$download_weekly_report <- downloadHandler(
filename = function() {
paste0(gsub(" ", "_", selected_player()), "_Weekly_Report_",
format(Sys.Date(), "%Y%m%d"), ".pdf")
},
content = function(file) {
fd <- filtered_data()
n_games <- if (!is.null(input$weekly_n_games) && !is.na(input$weekly_n_games)) input$weekly_n_games else 3
notes <- ifelse(is.null(input$weekly_notes), "", input$weekly_notes)
generate_weekly_report_pdf(
player_name = selected_player(),
team_data = fd,
n_games = n_games,
output_path = file,
notes_text = notes
)
}
)
output$hitting_grades_radar <- renderPlot({
req(selected_player())
create_hitting_grades_radar(selected_player(), filtered_data())
})
output$hitting_grades_table <- gt::render_gt({
req(selected_player())
create_hitting_grades_table(selected_player(), filtered_data())
})
daily_ev_filtered <- reactive({
if (!nrow(BP_data)) return(BP_data)
df <- BP_data
# Apply date filter
if (!is.null(input$daily_ev_dateRange) && all(!is.na(input$daily_ev_dateRange))) {
df <- df %>% filter(Date >= as.Date(input$daily_ev_dateRange[1]) & Date <= as.Date(input$daily_ev_dateRange[2]))
}
# Apply player filter
if (!is.null(input$daily_ev_players) && length(input$daily_ev_players) > 0) {
df <- df %>% filter(Batter %in% input$daily_ev_players)
}
df
})
#IF defense
output$is_outfielder <- reactive({ is_outfielder() })
output$is_infielder <- reactive({ is_infielder() })
outputOptions(output, "is_outfielder", suspendWhenHidden = FALSE)
outputOptions(output, "is_infielder", suspendWhenHidden = FALSE)
observeEvent(selected_player(), {
if (selected_player() %in% OAA_DF$obs_player) {
updateSelectInput(session, "position",
choices = c("LF", "CF", "RF", "ALL"))
} else if (selected_player() %in% IF_OAA$obs_player_name) {
updateSelectInput(session, "position",
choices = c("SS", "2B", "3B", "1B", "ALL"))
} else {
updateSelectInput(session, "position", choices = NULL)
}
})
# ===============================
# NOTE: Add these outputs to your server function
# ===============================
# Bat Tracking Summary Table
output$bat_tracking_summary_table <- gt::render_gt({
req(selected_player())
create_bat_tracking_summary(selected_player(), filtered_data())
})
# Attack Angle Zone Heatmap
output$aa_zone_heatmap <- renderPlot({
req(selected_player(), input$bat_metric)
create_aa_zone_heatmap(
selected_player(),
filtered_data(),
metric = input$bat_metric
)
})
# Bat Speed vs Attack Angle Scatter
output$batspeed_aa_scatter <- renderPlot({
req(selected_player())
create_batspeed_aa_scatter(selected_player(), filtered_data())
})
# Bat Contact Map
output$bat_contact_map <- renderPlot({
req(selected_player())
create_bat_contact_map(selected_player(), filtered_data())
})
# Attack Angle by Pitch Height
output$aa_by_height_plot <- renderPlot({
req(selected_player())
create_aa_by_pitch_height(selected_player(), filtered_data())
})
# HAA Spray Overlay
output$haa_spray_overlay <- renderPlot({
req(selected_player())
create_haa_spray_overlay(selected_player(), filtered_data())
})
# Bat Tracking Leaderboard
output$bat_tracking_leaderboard_table <- DT::renderDataTable({
req(input$seasonSelect)
data_to_show <- create_bat_tracking_leaderboard(filtered_data())
if (!nrow(data_to_show) || "Message" %in% names(data_to_show)) {
return(DT::datatable(data_to_show, options = list(dom = "t")))
}
dt <- DT::datatable(
data_to_show,
options = list(
paging = TRUE,
ordering = TRUE,
searching = TRUE,
scrollX = TRUE,
pageLength = 15
),
rownames = FALSE
) %>%
DT::formatRound(
c("Avg BS", "Max BS", "Avg AA", "Avg HAA", "AA 8-15", "Hit%"),
1
)
# Color scale
rank_colors <- colorRampPalette(c("#E1463E", "white", "#00840D"))(100)
apply_color <- function(obj, column, reverse = FALSE) {
if (!column %in% names(data_to_show)) return(obj)
vals <- suppressWarnings(as.numeric(data_to_show[[column]]))
vals <- vals[is.finite(vals)]
if (!length(vals)) return(obj)
cols <- if (reverse) rev(rank_colors) else rank_colors
rng <- range(vals, na.rm = TRUE)
if (!is.finite(rng[1]) || !is.finite(rng[2])) return(obj)
if (identical(rng[1], rng[2])) {
return(
DT::formatStyle(
obj,
column,
backgroundColor = DT::styleEqual(rng[1], cols[50])
)
)
}
cuts <- seq(rng[1], rng[2], length.out = 99)
DT::formatStyle(
obj,
column,
backgroundColor = DT::styleInterval(cuts, cols)
)
}
dt <- apply_color(dt, "Avg BS")
dt <- apply_color(dt, "Max BS")
dt <- apply_color(dt, "AA 8-15")
dt <- apply_color(dt, "Hit%")
dt
})
output$bp_radial_plot <- renderPlot({
req(input$bp_player)
create_bp_radial_chart(input$bp_player, bp_leaderboard_filtered())
})
output$bp_player_stats_table <- gt::render_gt({
req(input$bp_player)
create_bp_player_stats_table(input$bp_player, bp_leaderboard_filtered())
})
# Create pivot table
output$daily_ev_pivot_table <- DT::renderDataTable({
filtered <- daily_ev_filtered()
if (!nrow(filtered)) {
return(DT::datatable(data.frame(Message = "No data available"), options = list(dom = 't')))
}
# Calculate metrics by player and date
daily_stats <- filtered %>%
filter(BIPind == 1, !is.na(ExitSpeed)) %>%
group_by(Batter, Date) %>%
summarize(
avg_ev = round(mean(ExitSpeed, na.rm = TRUE), 1),
max_ev = round(max(ExitSpeed, na.rm = TRUE), 1),
hh_pct = round(sum(ExitSpeed >= 95, na.rm = TRUE) / n() * 100, 1),
.groups = "drop"
)
if (!nrow(daily_stats)) {
return(DT::datatable(data.frame(Message = "No batted ball data for selected dates/players"),
options = list(dom = 't')))
}
# Determine which metric to show
metric_col <- switch(input$ev_metric,
"avg" = "avg_ev",
"max" = "max_ev",
"hh_pct" = "hh_pct",
"avg_ev")
# Create pivot table (wide format)
pivot_data <- daily_stats %>%
select(Batter, Date, !!sym(metric_col)) %>%
tidyr::pivot_wider(names_from = Date, values_from = !!sym(metric_col)) %>%
arrange(Batter)
# Add team average row if requested
if (input$show_team_row) {
team_avg <- daily_stats %>%
group_by(Date) %>%
summarize(!!sym(metric_col) := round(mean(!!sym(metric_col), na.rm = TRUE), 1), .groups = "drop") %>%
tidyr::pivot_wider(names_from = Date, values_from = !!sym(metric_col)) %>%
mutate(Batter = "TEAM", .before = 1)
pivot_data <- bind_rows(pivot_data, team_avg)
}
# Format date columns to show as MM-DD
date_cols <- names(pivot_data)[-1]
for (col in date_cols) {
new_name <- format(as.Date(col), "%m-%d")
names(pivot_data)[names(pivot_data) == col] <- new_name
}
# Create DT table
dt <- DT::datatable(
pivot_data,
options = list(
pageLength = 25,
scrollX = TRUE,
scrollY = "600px",
paging = FALSE,
ordering = TRUE,
columnDefs = list(
list(className = 'dt-left', targets = 0),
list(className = 'dt-center', targets = 1:(ncol(pivot_data)-1))
)
),
rownames = FALSE,
class = 'cell-border stripe'
) %>%
DT::formatStyle(
'Batter',
fontWeight = 'bold',
backgroundColor = '#f0f8ff'
)
# Apply color gradient to EV cells
all_cols <- names(pivot_data)[-1] # All columns except Batter
if (input$ev_metric %in% c("avg", "max")) {
# For EV metrics: green = high (good), red = low (bad)
for (col in all_cols) {
dt <- DT::formatStyle(
dt, col,
backgroundColor = DT::styleInterval(
c(85, 90, 95, 100),
c('#ffcccc', '#ffe6cc', 'white', '#c8e6c9', '#81c784')
),
fontWeight = DT::styleInterval(95, c('normal', 'bold'))
)
}
} else if (input$ev_metric == "hh_pct") {
# For Hard Hit %: green = high (good), red = low (bad)
for (col in all_cols) {
dt <- DT::formatStyle(
dt, col,
backgroundColor = DT::styleInterval(
c(30, 50, 70, 85),
c('#ffcccc', '#ffe6cc', 'white', '#c8e6c9', '#81c784')
),
fontWeight = DT::styleInterval(70, c('normal', 'bold'))
)
}
}
# Highlight TEAM row if present
if (input$show_team_row) {
dt <- DT::formatStyle(
dt, 'Batter',
target = 'row',
fontWeight = DT::styleEqual('TEAM', 'bold'),
backgroundColor = DT::styleEqual('TEAM', '#006F71'),
color = DT::styleEqual('TEAM', 'white')
)
}
dt
})
}
shinyApp(ui, server)