igroffman's picture
Update app.R
e1ecfe3 verified
library(shiny)
library(dplyr)
library(ggplot2)
library(grid)
library(gridExtra)
library(gt)
library(gtExtras)
library(stringr)
library(zip)
library(png)
library(workflows)
library(parsnip)
library(recipes)
library(arrow)
library(xgboost)
library(tidymodels)
library(httr)
library(ggforce)
PASSWORD <- Sys.getenv("password")
if (!requireNamespace("magick", quietly = TRUE)) {
message("Note: Install 'magick' to enable player headshots in reports")
}
team_meta <- tryCatch({
read.csv("TMB (1).csv", stringsAsFactors = FALSE)
}, error = function(e) {
message("TMB (1).csv not found - logos will not display")
NULL
})
# -------------------- GLOBAL CSS --------------------
app_css <- "
body { background-color: #f5f5f5; font-family: 'Segoe UI', Arial, sans-serif; }
.header {
background: linear-gradient(135deg, #006F71 0%, #00a8a8 100%);
color: white; padding: 30px; text-align: center;
box-shadow: 0 4px 6px rgba(0,0,0,0.1); margin-bottom: 30px;
}
.header h1 { margin: 0; font-size: 2.5em; font-weight: bold; }
.header p { margin: 10px 0 0 0; font-size: 1.1em; opacity: 0.9; }
.main-panel {
background: white; border-radius: 12px; padding: 30px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
.upload-box {
border: 2px dashed #006F71; border-radius: 8px; padding: 30px;
text-align: center; background: #f9fcfc; transition: all 0.3s;
}
.upload-box:hover { border-color: #00a8a8; background: #f0f8f8; }
.btn-primary {
background-color: #006F71 !important; border: none !important; padding: 12px 30px;
font-size: 16px; font-weight: bold; border-radius: 6px; transition: all 0.3s;
width: 100%;
}
.btn-primary:hover {
background-color: #00a8a8 !important; transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0,111,113,0.3);
}
.btn-secondary {
background-color: #00a8a8 !important; border: none !important; padding: 12px 30px;
font-size: 16px; font-weight: bold; border-radius: 6px; transition: all 0.3s;
width: 100%; margin-top: 10px;
}
.btn-secondary:hover {
background-color: #008a8a !important; transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0,138,138,0.3);
}
.status-box {
background: #e8f5f5; border-left: 4px solid #006F71; padding: 15px;
margin: 20px 0; border-radius: 4px;
}
.plot-container, .html-widget, .plotly, .shiny-plot-output {
width: 100% !important;
overflow: visible !important;
}
.tall-plot { height: 440px !important; }
@media (max-width: 992px) { .tall-plot { height: 360px !important; } }
/* LEADERBOARD STYLES */
.leaderboard-section {
background: white;
border-radius: 12px;
padding: 20px;
margin-bottom: 25px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
.leaderboard-title {
font-size: 1.4em;
font-weight: bold;
color: #006F71;
margin-bottom: 15px;
text-align: center;
}
.leaderboard-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 15px;
}
.leaderboard-column {
background: #f9fcfc;
border-radius: 8px;
padding: 10px;
}
.leaderboard-column-header {
font-weight: bold;
color: #006F71;
border-bottom: 2px solid #006F71;
padding-bottom: 8px;
margin-bottom: 10px;
display: flex;
justify-content: space-between;
}
.leaderboard-row {
display: flex;
align-items: center;
padding: 6px 0;
border-bottom: 1px solid #e0e0e0;
}
.leaderboard-row:last-child { border-bottom: none; }
.leaderboard-logo {
width: 28px;
height: 28px;
object-fit: contain;
margin-right: 8px;
}
.leaderboard-name {
flex: 1;
font-size: 0.9em;
}
.leaderboard-value {
font-weight: bold;
color: #333;
}
.game-info-bar {
display: flex;
justify-content: space-between;
align-items: center;
background: linear-gradient(135deg, #006F71 0%, #00a8a8 100%);
color: white;
padding: 12px 20px;
border-radius: 8px;
margin-bottom: 15px;
font-size: 0.95em;
}
.game-info-item {
display: flex;
flex-direction: column;
align-items: center;
}
.game-info-label {
font-size: 0.75em;
opacity: 0.85;
text-transform: uppercase;
}
.game-info-value {
font-weight: bold;
font-size: 1.1em;
}
.game-score {
font-size: 1.2em;
font-weight: bold;
}
"
download_private_rds <- function(repo_id, filename) {
url <- paste0("https://huggingface.co/datasets/", repo_id, "/resolve/main/", filename)
api_key <- Sys.getenv("HFToken")
if (api_key == "") {
stop("API key is not set.")
}
response <- GET(url, add_headers(Authorization = paste("Bearer", api_key)))
if (status_code(response) == 200) {
temp_file <- tempfile(fileext = ".rds")
writeBin(content(response, "raw"), temp_file)
data <- readRDS(temp_file)
return(data)
} else {
stop(paste("Failed to download dataset. Status code:", status_code(response)))
}
}
stuffplus_recipe <- download_private_rds("CoastalBaseball/PitcherAppFiles", "stuffplus_recipe.rds")
stuffplus_model <- xgb.load("stuffplus_xgb.json")
message(class(stuffplus_model))
parse_flexible_date <- function(x) {
if (inherits(x, "Date")) return(x)
x <- as.character(x)
# Try yyyy-mm-dd first (TrackMan default)
d <- suppressWarnings(as.Date(x, format = "%Y-%m-%d"))
if (!all(is.na(d))) return(d)
# Try mm/dd/yyyy
d <- suppressWarnings(as.Date(x, format = "%m/%d/%Y"))
if (!all(is.na(d))) return(d)
# Try mm/dd/yy
d <- suppressWarnings(as.Date(x, format = "%m/%d/%y"))
if (!all(is.na(d))) return(d)
# Fallback
suppressWarnings(as.Date(x))
}
process_dataset <- function(df) {
if ("Batter" %in% names(df)) {
df <- df %>% mutate(Batter = stringr::str_replace(Batter, "^\\s*(\\w+)\\s*,\\s*(\\w+)\\s*$", "\\2 \\1"))
}
df <- df %>% distinct()
if ("PitchUID" %in% names(df)) df <- df %>% distinct(PitchUID, .keep_all = TRUE)
if ("Date" %in% names(df)) {
df$Date <- parse_flexible_date(df$Date)
}
if ("PlateLocSide" %in% names(df)) df$PlateLocSide <- as.numeric(df$PlateLocSide)
if ("PlateLocHeight" %in% names(df)) df$PlateLocHeight <- as.numeric(df$PlateLocHeight)
if ("RelSpeed" %in% names(df)) df <- df %>% filter(!is.na(RelSpeed))
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_
}
df <- df %>%
mutate(
ExitSpeed = ifelse(!is.na(ExitSpeed) & !is.na(Angle) &
(ExitSpeed > 120 & Angle < -10 | ExitSpeed < 70),
NA, ExitSpeed),
WhiffIndicator = ifelse(PitchCall == "StrikeSwinging", 1, 0),
StrikeZoneIndicator = ifelse(
PlateLocSide >= -0.83 & PlateLocSide <= 0.83 &
PlateLocHeight >= 1.5 & PlateLocHeight <= 3.38, 1, 0
),
SwingIndicator = ifelse(PitchCall %in% c("StrikeSwinging","FoulBallNotFieldable","FoulBall","InPlay"), 1, 0),
BIPind = ifelse(PitchCall == "InPlay" & TaggedHitType != "Bunt", 1, 0),
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),
HBPIndicator = ifelse(PitchCall == "HitByPitch", 1, 0),
WalkIndicator = ifelse(KorBB == "Walk", 1, 0),
totalbases = dplyr::case_when(
PlayResult == "Single" ~ 1,
PlayResult == "Double" ~ 2,
PlayResult == "Triple" ~ 3,
PlayResult == "HomeRun" ~ 4,
TRUE ~ 0
),
HHind = ifelse(PitchCall == "InPlay" & ExitSpeed >= 95, 1, 0),
Chaseindicator = ifelse(SwingIndicator == 1 & StrikeZoneIndicator == 0, 1, 0),
Zwhiffind = ifelse(WhiffIndicator == 1 & StrikeZoneIndicator == 1, 1, 0),
Zswing = ifelse(StrikeZoneIndicator == 1 & SwingIndicator == 1, 1, 0)
)
df
}
process_bp_dataset <- function(df) {
# Process BP data with different structure
if ("Batter" %in% names(df)) {
df <- df %>% mutate(Batter = stringr::str_replace(Batter, "^\\s*(\\w+)\\s*,\\s*(\\w+)\\s*$", "\\2 \\1"))
}
df <- df %>% distinct()
if ("PitchUID" %in% names(df)) df <- df %>% distinct(PitchUID, .keep_all = TRUE)
if ("Date" %in% names(df)) {
df$Date <- parse_flexible_date(df$Date)
}
# Convert numeric columns
if ("PlateLocSide" %in% names(df)) df$PlateLocSide <- as.numeric(df$PlateLocSide)
if ("PlateLocHeight" %in% names(df)) df$PlateLocHeight <- as.numeric(df$PlateLocHeight)
if ("ExitSpeed" %in% names(df)) df$ExitSpeed <- as.numeric(df$ExitSpeed)
if ("Angle" %in% names(df)) df$Angle <- as.numeric(df$Angle)
if ("Distance" %in% names(df)) df$Distance <- as.numeric(df$Distance)
if ("Bearing" %in% names(df)) df$Bearing <- as.numeric(df$Bearing)
if ("ContactPositionX" %in% names(df)) df$ContactPositionX <- as.numeric(df$ContactPositionX)
if ("ContactPositionY" %in% names(df)) df$ContactPositionY <- as.numeric(df$ContactPositionY)
if ("ContactPositionZ" %in% names(df)) df$ContactPositionZ <- as.numeric(df$ContactPositionZ)
# Create BP-specific indicators
df <- df %>%
mutate(
# Filter out bad exit velo data
ExitSpeed = ifelse(!is.na(ExitSpeed) & !is.na(Angle) &
(ExitSpeed > 120 & Angle < -10 | ExitSpeed < 70), NA, ExitSpeed),
# Ball in play indicator
BIPind = ifelse(!is.na(ExitSpeed) | !is.na(Angle) | !is.na(Distance), 1, 0),
# Launch angle zones
LA1030ind = ifelse(BIPind == 1 & !is.na(Angle) & Angle >= 10 & Angle <= 30, 1, 0),
# Barrels
Barrelind = ifelse(BIPind == 1 & !is.na(ExitSpeed) & !is.na(Angle) &
ExitSpeed >= 95 & Angle >= 10 & Angle <= 32, 1, 0),
# Hard hits
HHind = ifelse(BIPind == 1 & !is.na(ExitSpeed) & ExitSpeed >= 95, 1, 0),
# Solid contact
SCind = ifelse(BIPind == 1 & !is.na(ExitSpeed) & !is.na(Angle) &
((ExitSpeed > 95 & Angle >= 0 & Angle <= 35) |
(ExitSpeed > 92 & Angle >= 8 & Angle <= 35)), 1, 0),
# Hit type indicators
GBindicator = ifelse(BIPind == 1 & !is.na(TaggedHitType) & TaggedHitType == "GroundBall", 1, 0),
LDind = ifelse(BIPind == 1 & !is.na(TaggedHitType) & TaggedHitType == "LineDrive", 1, 0),
FBind = ifelse(BIPind == 1 & !is.na(TaggedHitType) & TaggedHitType == "FlyBall", 1, 0),
Popind = ifelse(BIPind == 1 & !is.na(TaggedHitType) & TaggedHitType == "Popup", 1, 0)
)
df
}
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() +
coord_fixed(xlim = c(-360, 360), ylim = c(-20, 410), expand = FALSE) +
annotate("segment", x = 0, y = 0, xend = 247.487, yend = 247.487, color = "gray70") +
annotate("segment", x = 0, y = 0, xend = -247.487, yend = 247.487, color = "gray70") +
ggtitle(paste("BP Spray Chart:", batter_name)) +
annotate("text", x = 0, y = 200, label = "No spray data available", size = 5, color = "gray50") +
theme(plot.title = element_text(hjust=0.5, size=10, face="bold"))
)
}
ggplot(chart_data, aes(x, y)) +
coord_fixed(xlim = c(-360, 360), ylim = c(-20, 410), expand = FALSE) +
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") +
geom_point(aes(fill = ExitSpeed), size = 3, shape = 21, color = "black", stroke = 0.4, alpha = 0.85) +
scale_fill_gradient(low = "blue", high = "red", name = "Exit Velo", na.value = "grey50") +
theme_void() +
ggtitle(paste("BP Spray Chart:", batter_name)) +
theme(
legend.position = "right",
plot.title = element_text(hjust = 0.5, size = 10, face = "bold"),
plot.margin = margin(3, 3, 3, 3),
legend.title = element_text(size = 8),
legend.text = element_text(size = 7),
legend.key.height = unit(0.5, "cm"),
legend.key.width = unit(0.3, "cm")
)
}
create_bp_zone_plot <- 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() +
annotate("rect", xmin = -0.8303, xmax = 0.8303, ymin = 1.5, ymax = 3.38,
alpha = 0, size = .5, color = "gray70") +
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 = "gray70", linewidth = 0.5) +
coord_fixed(ratio = 1, xlim = c(-2, 2), ylim = c(0, 4.5)) +
theme_void() +
ggtitle(paste("BP Zone Plot:", batter_name)) +
annotate("text", x = 0, y = 2.5, label = "No zone data available", size = 5, color = "gray50") +
theme(plot.title = element_text(hjust = 0.5, size = 10, face = "bold"))
)
}
ggplot(zone_data, aes(x = PlateLocSide, y = PlateLocHeight)) +
geom_point(aes(fill = ExitSpeed), size = 3, shape = 21, color = "black", stroke = 0.4, alpha = 0.8) +
scale_fill_gradient(low = "blue", high = "red", name = "Exit Velo", na.value = "grey50") +
annotate("rect", xmin = -0.8303, xmax = 0.8303, ymin = 1.5, ymax = 3.38,
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 = c(-2, 2), ylim = c(0, 4.5)) +
ggtitle(paste("BP Zone Plot:", batter_name)) +
theme_void() +
theme(
legend.position = "right",
plot.title = element_text(hjust = 0.5, size = 10, face = "bold"),
plot.margin = margin(3, 3, 3, 3),
legend.title = element_text(size = 8),
legend.text = element_text(size = 7),
legend.key.height = unit(0.5, "cm"),
legend.key.width = unit(0.3, "cm")
)
}
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() +
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("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() +
theme_void() +
ggtitle(paste("BP Contact Points:", batter_name)) +
annotate("text", x = 0, y = 20, label = "No contact data available", size = 5, color = "gray50") +
theme(plot.title = element_text(hjust = 0.5, size = 10, 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", linewidth = 0.5) +
annotate("segment", x = 8.5, y = 8.5, xend = 8.5, yend = 17, color = "black", linewidth = 0.5) +
annotate("segment", x = -8.5, y = 8.5, xend = -8.5, yend = 17, color = "black", linewidth = 0.5) +
annotate("segment", x = -8.5, y = 8.5, xend = 0, yend = 0, color = "black", linewidth = 0.5) +
annotate("segment", x = 8.5, y = 8.5, xend = 0, yend = 0, color = "black", linewidth = 0.5) +
annotate("rect", xmin = 20, xmax = 48, ymin = -20, ymax = 40, fill = NA, color = "black", linewidth = 0.5) +
annotate("rect", xmin = -48, xmax = -20, ymin = -20, ymax = 40, fill = NA, color = "black", linewidth = 0.5) +
annotate("text", x = ifelse(batter_side == "Right", -34, 34), y = 10,
label = ifelse(batter_side == "Right", "R", "L"), size = 7, fontface = "bold") +
xlim(-50, 50) + ylim(-20, 50) +
geom_point(aes(fill = ExitSpeed), color = "black", stroke = 0.4, shape = 21, alpha = 0.85, size = 2.5) +
scale_fill_gradient(name = "Exit Velo", low = "blue", high = "red") +
coord_fixed() +
ggtitle(paste("BP Contact Points:", batter_name)) +
theme_void() +
theme(
legend.position = "right",
plot.title = element_text(hjust = 0.5, size = 10, face = "bold"),
plot.margin = margin(3, 3, 3, 3),
legend.title = element_text(size = 8),
legend.text = element_text(size = 7),
legend.key.height = unit(0.5, "cm"),
legend.key.width = unit(0.3, "cm")
)
}
create_bp_pdf <- function(bp_data, batter_name, output_file) {
if (length(dev.list()) > 0) try(dev.off(), silent = TRUE)
batter_df <- filter(bp_data, Batter == batter_name)
# Calculate stats in the order: BBE, Avg EV, Avg LA, Max EV, SC%, 10-30%, HH%, Barrel%
stats <- batter_df %>%
summarise(
BBE = sum(BIPind, na.rm = TRUE),
`Avg EV` = round(mean(ExitSpeed[BIPind == 1], na.rm = TRUE), 1),
`Avg LA` = round(mean(Angle[BIPind == 1], na.rm = TRUE), 1),
`Max EV` = round(max(ExitSpeed[BIPind == 1], na.rm = TRUE), 1),
`SC%` = round(sum(SCind, na.rm = TRUE) / sum(BIPind, na.rm = TRUE) * 100, 1),
`10-30%` = round(sum(LA1030ind, na.rm = TRUE) / sum(BIPind, na.rm = TRUE) * 100, 1),
`HH%` = round(sum(HHind, na.rm = TRUE) / sum(BIPind, na.rm = TRUE) * 100, 1),
`Barrel%` = round(sum(Barrelind, na.rm = TRUE) / sum(BIPind, na.rm = TRUE) * 100, 1),
.groups = "drop"
)
# Create plots (smaller sizes)
spray_plot <- create_bp_spray_chart(batter_name, bp_data)
zone_plot <- create_bp_zone_plot(batter_name, bp_data)
contact_plot <- create_bp_contact_map(batter_name, bp_data)
# Create PDF
pdf(output_file, width = 11, height = 8.5)
on.exit(try(dev.off(), silent = TRUE), add = TRUE)
grid::grid.newpage()
# Title section
grid::pushViewport(grid::viewport(x = 0.5, y = 0.97, width = 1, height = 0.05, just = c("center", "top")))
grid::grid.text("BP Report",
gp = grid::gpar(fontface = "bold", cex = 1.3, col = "#006F71"))
grid::popViewport()
grid::pushViewport(grid::viewport(x = 0.5, y = 0.93, width = 1, height = 0.04, just = c("center", "top")))
grid::grid.text(batter_name,
gp = grid::gpar(fontface = "bold", cex = 1.6, col = "black"))
grid::popViewport()
# Stats table with NO color coding (all white)
headers <- c("BBE", "Avg EV", "Avg LA", "Max EV", "SC%", "10-30%", "HH%", "Barrel%")
values <- c(stats$BBE, stats$`Avg EV`, stats$`Avg LA`, stats$`Max EV`,
stats$`SC%`, stats$`10-30%`, stats$`HH%`, stats$`Barrel%`)
col_w <- 0.09
x0 <- 0.5 - (length(headers) * col_w) / 2
yh <- 0.87
yv <- 0.85
for (i in seq_along(headers)) {
xi <- x0 + (i - 1) * col_w
# Header with teal background
grid::grid.rect(x = xi, y = yh, width = col_w * 0.985, height = 0.018,
just = c("left", "top"),
gp = grid::gpar(fill = "#006F71", col = "black", lwd = 0.5))
grid::grid.text(headers[i],
x = xi + col_w * 0.49, y = yh - 0.009,
gp = grid::gpar(col = "white", cex = 0.70, fontface = "bold"))
# Value cell - NO color coding, all white
val <- values[i]
grid::grid.rect(x = xi, y = yv, width = col_w * 0.985, height = 0.018,
just = c("left", "top"),
gp = grid::gpar(fill = "white", col = "black", lwd = 0.4))
grid::grid.text(ifelse(is.finite(val), as.character(val), "-"),
x = xi + col_w * 0.49, y = yv - 0.009,
gp = grid::gpar(cex = 0.70))
}
# Spray chart (left) - SMALLER
grid::pushViewport(grid::viewport(x = 0.25, y = 0.77, width = 0.40, height = 0.38, just = c("center", "top")))
print(spray_plot, newpage = FALSE)
grid::popViewport()
# Zone plot (right) - SMALLER
grid::pushViewport(grid::viewport(x = 0.75, y = 0.77, width = 0.40, height = 0.38, just = c("center", "top")))
print(zone_plot, newpage = FALSE)
grid::popViewport()
# Contact map (bottom center) - SMALLER
grid::pushViewport(grid::viewport(x = 0.5, y = 0.35, width = 0.42, height = 0.20, just = c("center", "top")))
print(contact_plot, newpage = FALSE)
grid::popViewport()
invisible(output_file)
}
parse_game_day <- function(df, tz = "America/New_York") {
stopifnot("Date" %in% names(df))
if (inherits(df$Date, "Date")) {
dates <- df$Date[!is.na(df$Date)]
if (length(dates) > 0) {
tab <- sort(table(dates), decreasing = TRUE)
return(as.Date(names(tab)[1]))
}
}
as.Date(df$Date[1])
}
create_at_bats_plot <- function(batter_data, player_name, game_key, pitch_colors,
max_lines_per_col = 16L) {
df <- dplyr::filter(batter_data, Batter == player_name)
if (!nrow(df)) {
return(ggplot2::ggplot() + ggplot2::theme_void() +
ggplot2::ggtitle(paste("No data for", player_name)) +
ggplot2::theme(plot.title = ggplot2::element_text(hjust = 0.5, size = 14, face = "bold")))
}
plot_data <- df %>%
arrange(PitchNo) %>%
mutate(
pa_break = (PitchofPA == 1),
pa_number = cumsum(pa_break)
) %>%
ungroup() %>%
mutate(
PlayResult = na_if(str_squish(PlayResult), "Undefined"),
PitchCall_display = dplyr::case_when(
PitchCall == "StrikeSwinging" ~ "Whiff",
PitchCall == "StrikeCalled" ~ "CS",
PitchCall %in% c("FoulBall","FoulBallNotFieldable","FoulBallFieldable") ~ "Foul",
PitchCall %in% c("BallCalled","BallinDirt","BallIntentional") ~ "Ball",
PitchCall == "HitByPitch" ~ "HBP",
PitchCall == "InPlay" ~ "In Play",
TRUE ~ coalesce(PitchCall, "—")
),
BIP_display = dplyr::case_when(
PlayResult %in% c("Single","Double","Triple","HomeRun") ~
dplyr::recode(PlayResult, Single="1B", Double="2B", Triple="3B", HomeRun="HR"),
PlayResult == "FieldersChoice" ~ "FC",
PlayResult %in% c("Out","Error","Sacrifice","SacrificeFly") ~ PlayResult,
TRUE ~ NA_character_
),
PlayResult_clean = dplyr::case_when(
PitchCall_display == "In Play" ~ coalesce(BIP_display, "Out"),
TRUE ~ PitchCall_display
)
) %>%
dplyr::group_by(pa_number) %>%
dplyr::mutate(
line_idx = row_number(),
col_idx = ((line_idx - 1L) %/% max_lines_per_col) + 1L,
row_idx = ((line_idx - 1L) %% max_lines_per_col) + 1L
) %>%
dplyr::ungroup() %>%
dplyr::mutate(
text_x = (20 + (col_idx - 1L) * 12) / 12,
text_y_main = (50 - (row_idx * 3)) / 12,
text_y_ev = (50 - (row_idx * 3) - 3) / 12
)
used_second_col <- any(plot_data$col_idx > 1)
x_max <- if (used_second_col) ((35 + 12) / 12) else 35/12
y_min_needed <- suppressWarnings(min(c(-1/12, min(plot_data$text_y_ev, na.rm = TRUE) - 0.05), na.rm = TRUE))
ggplot2::ggplot(plot_data, ggplot2::aes(PlateLocSide, PlateLocHeight)) +
ggplot2::annotate("rect", xmin = -0.8303, xmax = 0.8303, ymin = 1.5, ymax = 3.3775,
alpha = 0, size = .5, color = "black") +
ggplot2::annotate("segment", x = -0.708, y = 0.15, xend = 0.708, yend = 0.15, size = .5, color = "black") +
ggplot2::annotate("segment", x = -0.708, y = 0.30, xend = -0.708, yend = 0.15, size = .5, color = "black") +
ggplot2::annotate("segment", x = 0.708, y = 0.30, xend = 0.708, yend = 0.15, size = .5, color = "black") +
ggplot2::annotate("segment", x = -0.708, y = 0.30, xend = 0.000, yend = 0.50, size = .5, color = "black") +
ggplot2::annotate("segment", x = 0.708, y = 0.30, xend = 0.000, yend = 0.50, size = .5, color = "black") +
ggplot2::geom_point(ggplot2::aes(fill = TaggedPitchType),
alpha = 1, shape = 21, color = "black", stroke = 0.5, size = 4) +
ggplot2::geom_text(ggplot2::aes(label = PitchofPA),
vjust = 0.5, size = 2.2, color = "white", fontface = "bold") +
geom_text(
aes(x = text_x, y = text_y_main,
label = paste(PitchofPA, ":", PlayResult_clean)),
inherit.aes = FALSE, size = 2.1, hjust = 0
) +
geom_text(
aes(x = text_x, y = text_y_ev,
label = ifelse(PitchCall_display == "In Play" & !is.na(ExitSpeed),
paste0(round(ExitSpeed), " EV"), "")),
inherit.aes = FALSE, size = 2.0, hjust = 0
) +
ggplot2::facet_wrap(~ pa_number, ncol = 5) +
ggplot2::theme_void() +
ggplot2::scale_x_continuous(NULL, limits = c(-20/12, x_max)) +
ggplot2::scale_y_continuous(NULL, limits = c(y_min_needed, 60/12)) +
ggplot2::coord_fixed(ratio = 1.3, clip = "off") +
ggplot2::scale_fill_manual(values = c(
"Fastball" = "#FA8072", "FourSeamFastBall" = "#FA8072","Four-Seam" = "#FA8072", "Sinker" = "#fdae61",
"Slider" = "#A020F0", "Sweeper" = "magenta", "Curveball" = "#2c7bb6",
"ChangeUp" = "#90EE90", "Splitter" = "#90EE32", "Cutter" = "red"
), name = "Pitch Type") +
ggplot2::theme(
panel.background = ggplot2::element_rect(fill = "#ffffff", color = NA),
legend.position = "top",
strip.text = ggplot2::element_text(size = 1, vjust = 1),
strip.placement = "outside",
strip.background = ggplot2::element_blank(),
plot.margin = ggplot2::margin(6, 18, 6, 6),
panel.spacing = grid::unit(8, "pt")
)
}
create_report_spray_chart <- function(game_data, player_name) {
spray_data <- game_data %>%
dplyr::filter(Batter == player_name) %>%
dplyr::arrange(PitchNo) %>%
dplyr::mutate(PitchNumber = dplyr::row_number()) %>%
dplyr::filter(!is.na(Distance), !is.na(Bearing),
PitchCall == "InPlay",
!PitchCall %in% c("FoulBall","FoulBallNotFieldable","FoulBallFieldable")) %>%
dplyr::mutate(
Bearing2 = Bearing * pi/180,
x = Distance * sin(Bearing2),
y = Distance * cos(Bearing2)
)
if (!nrow(spray_data)) {
return(
ggplot2::ggplot() +
ggplot2::coord_fixed(xlim = c(-360, 360), ylim = c(-20, 410), expand = FALSE) +
ggplot2::annotate("segment", x = 0, y = 0, xend = 247.487, yend = 247.487, color = "gray70") +
ggplot2::annotate("segment", x = 0, y = 0, xend = -247.487, yend = 247.487, color = "gray70") +
ggplot2::annotate("segment", x = 63.6396, y = 63.6396, xend = 0, yend = 127.279, color = "gray70") +
ggplot2::annotate("segment", x = -63.6396, y = 63.6396, xend = 0, yend = 127.279, color = "gray70") +
ggplot2::annotate("curve", x = 89.095, y = 89.095, xend = 0, yend = 160, curvature = 0.36, linewidth = 0.5, color = "gray70") +
ggplot2::annotate("curve", x = -89.095, y = 89.095, xend = 0, yend = 160, curvature = -0.36, linewidth = 0.5, color = "gray70") +
ggplot2::annotate("curve", x = -247.487, y = 247.487, xend = 247.487, yend = 247.487, curvature = -0.65, linewidth = 0.5, color = "gray70") +
ggplot2::ggtitle(paste(player_name, "- Spray Chart")) +
ggplot2::theme_void() +
ggplot2::theme(
plot.margin = ggplot2::margin(5, 5, 5, 5),
plot.title = ggplot2::element_text(hjust = 0.5, size = 9, face = "bold")
)
)
}
ggplot2::ggplot(spray_data, ggplot2::aes(x, y)) +
ggplot2::coord_fixed(xlim = c(-360, 360), ylim = c(-20, 410), expand = FALSE) +
ggplot2::annotate("segment", x = 0, y = 0, xend = 247.487, yend = 247.487, color = "black") +
ggplot2::annotate("segment", x = 0, y = 0, xend = -247.487, yend = 247.487, color = "black") +
ggplot2::annotate("segment", x = 63.6396, y = 63.6396, xend = 0, yend = 127.279, color = "black") +
ggplot2::annotate("segment", x = -63.6396, y = 63.6396, xend = 0, yend = 127.279, color = "black") +
ggplot2::annotate("curve", x = 89.095, y = 89.095, xend = 0, yend = 160, curvature = 0.36, linewidth = 0.5, color = "black") +
ggplot2::annotate("curve", x = -89.095, y = 89.095, xend = 0, yend = 160, curvature = -0.36, linewidth = 0.5, 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_point(size = 2.8, shape = 21, color = "black",
fill = "darkred", stroke = 0.4, alpha = 0.85) +
ggplot2::geom_text(ggplot2::aes(label = PitchNumber),
size = 1.8, color = "white", fontface = "bold") +
ggplot2::ggtitle(paste(player_name, "- Spray Chart")) +
ggplot2::theme_void() +
ggplot2::theme(
plot.margin = ggplot2::margin(5, 5, 5, 5),
plot.title = ggplot2::element_text(hjust = 0.5, size = 9, face = "bold")
)
}
create_report_contact_chart <- function(game_data, player_name) {
contact_data <- game_data %>%
filter(Batter == player_name) %>%
arrange(PitchNo) %>%
mutate(PitchNumber = row_number()) %>%
filter(!is.na(ExitSpeed), !is.na(ContactPositionZ),
!is.na(ContactPositionX), !is.na(ContactPositionY),
PitchCall == "InPlay",
!PitchCall %in% c("FoulBall", "FoulBallNotFieldable", "FoulBallFieldable")) %>%
mutate(ContactPositionX = ContactPositionX*12,
ContactPositionY = ContactPositionY*12,
ContactPositionZ = ContactPositionZ*12)
if (!nrow(contact_data)) {
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(paste(player_name, "- Contact Points")) +
theme_void() +
theme(
plot.margin = margin(2, 2, 2, 2),
plot.title = element_text(hjust = 0.5, size = 9, face = "bold")
)
)
}
batter_side <- 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", linewidth = 0.5) +
annotate("segment", x = 8.5, y = 8.5, xend = 8.5, yend = 17, color = "black", linewidth = 0.5) +
annotate("segment", x = -8.5, y = 8.5, xend = -8.5, yend = 17, color = "black", linewidth = 0.5) +
annotate("segment", x = -8.5, y = 8.5, xend = 0, yend = 0, color = "black", linewidth = 0.5) +
annotate("segment", x = 8.5, y = 8.5, xend = 0, yend = 0, color = "black", linewidth = 0.5) +
annotate("rect", xmin = 20, xmax = 48, ymin = -20, ymax = 40, fill = NA, color = "black", linewidth = 0.5) +
annotate("rect", xmin = -48, xmax = -20, ymin = -20, ymax = 40, fill = NA, color = "black", linewidth = 0.5) +
annotate("text", x = ifelse(batter_side == "Right", -34, 34), y = 10,
label = ifelse(batter_side == "Right", "R", "L"), size = 3.5, fontface = "bold") +
xlim(-50, 50) + ylim(-20, 50) +
geom_point(aes(fill = ExitSpeed), color = "black", stroke = .25, shape = 21, alpha = .85, size = 2.8) +
geom_text(aes(label = PitchNumber), size = 1.7, color = "white", fontface = "bold") +
scale_fill_gradient(name = "Exit Velo", low = "#E1463E", high = "#00840D") +
coord_fixed() +
ggtitle(paste(player_name, "- Contact Points")) +
theme_void() +
theme(
legend.position = "right",
plot.margin = margin(2, 2, 2, 2),
plot.title = element_text(hjust = 0.5, size = 9, face = "bold"),
legend.title = element_text(size = 7),
legend.text = element_text(size = 6),
legend.key.height = unit(0.4, "cm"),
legend.key.width = unit(0.3, "cm")
)
}
calculate_leaderboards <- function(df, team_meta_df = team_meta) {
format_name <- function(name) {
if (is.na(name)) return(name)
stringr::str_replace(name, "^\\s*(\\w+)\\s*,\\s*(\\w+)\\s*$", "\\2 \\1")
}
get_logo <- function(team_abbr) {
if (is.null(team_meta_df) || is.null(team_abbr) || is.na(team_abbr)) return("")
team_abbr <- trimws(as.character(team_abbr))
if (team_abbr == "") return("")
tmb_abbrs <- trimws(as.character(team_meta_df$team_abbr))
idx <- which(tmb_abbrs == team_abbr)
if (length(idx) > 0) return(team_meta_df$BTeamLogo[idx[1]])
idx <- which(tolower(tmb_abbrs) == tolower(team_abbr))
if (length(idx) > 0) return(team_meta_df$BTeamLogo[idx[1]])
""
}
get_team_name <- function(abbr) {
if (is.null(team_meta_df) || is.null(abbr) || is.na(abbr)) return(as.character(abbr))
abbr <- trimws(as.character(abbr))
if (abbr == "") return(abbr)
tmb_abbrs <- trimws(as.character(team_meta_df$team_abbr))
idx <- which(tmb_abbrs == abbr)
if (length(idx) > 0) return(team_meta_df$BTeamName[idx[1]])
idx <- which(tolower(tmb_abbrs) == tolower(abbr))
if (length(idx) > 0) return(team_meta_df$BTeamName[idx[1]])
abbr
}
# Game Info
stadium <- if ("Stadium" %in% names(df)) unique(na.omit(df$Stadium))[1] else "Unknown"
level <- if ("Level" %in% names(df)) unique(na.omit(df$Level))[1] else ""
league <- if ("League" %in% names(df)) unique(na.omit(df$League))[1] else ""
game_date <- if ("Date" %in% names(df)) {
raw_date <- unique(na.omit(df$Date))[1]
parsed <- tryCatch(parse_flexible_date(raw_date), error = function(e) NA)
if (is.na(parsed)) "N/A" else format(parsed, "%m/%d/%Y")
} else "N/A"
# Calculate final score from RunsScored
teams <- unique(c(df$BatterTeam, df$PitcherTeam))
teams <- teams[!is.na(teams)]
score_info <- df %>%
filter(!is.na(RunsScored), RunsScored > 0) %>%
group_by(BatterTeam) %>%
summarise(Runs = sum(RunsScored, na.rm = TRUE), .groups = "drop") %>%
arrange(desc(Runs))
if (nrow(score_info) >= 2) {
team1 <- score_info$BatterTeam[1]
runs1 <- score_info$Runs[1]
team2 <- score_info$BatterTeam[2]
runs2 <- score_info$Runs[2]
final_score <- paste0(get_team_name(team1), " ", runs1, " - ",
get_team_name(team2), " ", runs2)
} else {
final_score <- "Score N/A"
}
game_info <- list(
stadium = stadium,
level = level,
league = league,
date = game_date,
final_score = final_score
)
top_ev <- df %>%
filter(!is.na(ExitSpeed), !is.na(Batter)) %>%
select(Batter, BatterTeam, ExitSpeed) %>%
arrange(desc(ExitSpeed)) %>%
head(5) %>%
rename(MaxEV = ExitSpeed) %>%
mutate(Batter = sapply(Batter, format_name),
Logo = sapply(BatterTeam, get_logo))
top_dist <- df %>%
filter(!is.na(Distance), !is.na(Batter), Distance > 0) %>%
select(Batter, BatterTeam, Distance) %>%
arrange(desc(Distance)) %>%
head(5) %>%
rename(MaxDist = Distance) %>%
mutate(Batter = sapply(Batter, format_name),
Logo = sapply(BatterTeam, get_logo))
top_velo <- df %>%
filter(!is.na(RelSpeed), !is.na(Pitcher)) %>%
select(Pitcher, PitcherTeam, RelSpeed) %>%
arrange(desc(RelSpeed)) %>%
head(5) %>%
rename(MaxVelo = RelSpeed) %>%
mutate(Pitcher = sapply(Pitcher, format_name),
Logo = sapply(PitcherTeam, get_logo))
top_whiffs <- df %>%
filter(!is.na(Pitcher)) %>%
group_by(Pitcher, PitcherTeam) %>%
summarise(Whiffs = sum(PitchCall == "StrikeSwinging", na.rm = TRUE), .groups = "drop") %>%
arrange(desc(Whiffs)) %>%
head(5) %>%
mutate(Pitcher = sapply(Pitcher, format_name),
Logo = sapply(PitcherTeam, get_logo))
list(
game_info = game_info,
exit_velo = top_ev,
distance = top_dist,
pitch_velo = top_velo,
whiffs = top_whiffs
)
}
create_simple_header <- function(player_name, game_date, bio_data = NULL) {
library(grid)
library(gridExtra)
# Load logos
left_logo <- tryCatch({
rasterGrob(as.raster(magick::image_resize(
magick::image_read("https://i.ibb.co/gLfTW4Fz/t-GPe-TPu.png"), "x120"
)), interpolate = TRUE)
}, error = function(e) nullGrob())
right_logo <- tryCatch({
rasterGrob(as.raster(magick::image_resize(
magick::image_read("https://i.imgur.com/zjTu3JS.png"), "x120"
)), interpolate = TRUE)
}, error = function(e) nullGrob())
# Title text
title_text <- paste(player_name, "-", format(game_date, "%m/%d/%y"), "- Hitter Report")
title_grob <- textGrob(
title_text,
gp = gpar(fontsize = 18, fontface = "bold", col = "#006F71")
)
# Layout with logos
arrangeGrob(
arrangeGrob(left_logo, title_grob, right_logo, ncol = 3,
widths = c(.15, .7, .15)),
ncol = 1
)
}
# Helper function to check if bat tracking data is available
has_bat_tracking <- function(df) {
bat_cols <- c("BatSpeed", "VerticalAttackAngle", "HorizontalAttackAngle")
cols_present <- bat_cols %in% names(df)
if (!all(cols_present)) return(FALSE)
# Check if there's at least some non-NA data in any of these columns
any_data <- any(
!is.na(df$BatSpeed) |
!is.na(df$VerticalAttackAngle) |
!is.na(df$HorizontalAttackAngle)
)
return(any_data)
}
create_postgame_pdf <- function(game_df, player_name, output_file, bio_data = NULL) {
if (length(dev.list()) > 0) { try(dev.off(), silent = TRUE) }
pitch_colors <- c(
"Fastball" = "#FA8072", "Four-Seam" = "#FA8072", "FourSeamFastBall" = "#FA8072", "Sinker" = "#fdae61",
"Slider" = "#A020F0", "Sweeper" = "magenta", "Curveball" = "#2c7bb6",
"ChangeUp" = "#90EE90", "Splitter" = "#90EE32", "Cutter" = "red"
)
batter_df <- dplyr::filter(game_df, Batter == player_name)
game_day <- parse_game_day(batter_df, tz = "America/New_York")
game_key <- format(game_day, "%Y-%m-%d")
# Check if bat tracking data is available
bat_tracking_available <- has_bat_tracking(batter_df)
game_stats <- batter_df %>%
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"
)
pitch_sequence <- batter_df %>%
arrange(PitchNo) %>%
mutate(PitchNumber = row_number()) %>%
select(PitchNumber, dplyr::everything())
at_bats_plot <- create_at_bats_plot(game_df, player_name, game_key, pitch_colors) +
theme(
legend.position = "top", plot.margin = margin(2,2,2,2),
axis.title = element_blank(), axis.text = element_blank(), axis.ticks = element_blank(),
strip.text = element_text(size = 9), legend.title = element_text(size = 9), legend.text = element_text(size = 8)
)
spray_plot <- create_report_spray_chart(game_df, player_name)
contact_plot <- create_report_contact_chart(game_df, player_name)
# Build pitch log with conditional bat tracking columns
# New order: Inning, Pitcher, Count, Pitch, Velo, IVB, HB, VAA, EV, LA, Dist, Bat Speed, AA, HAA
pitch_log <- pitch_sequence %>%
filter(PitchCall == "InPlay") %>%
mutate(
Throws = ifelse(PitcherThrows == "Right", "R", "L"),
event = dplyr::case_when(
!is.na(PlayResult) & PlayResult != "Undefined" ~ PlayResult, TRUE ~ "Out"
),
Count = paste0(Balls, "-", Strikes),
EV = round(ExitSpeed),
LA = round(Angle),
Dist = ifelse(!is.na(Distance), round(Distance), NA),
Velo = round(RelSpeed, 1),
# Pitch movement metrics
IVB = ifelse("InducedVertBreak" %in% names(.) & !is.na(InducedVertBreak),
round(InducedVertBreak, 1), NA),
HB = ifelse("HorzBreak" %in% names(.) & !is.na(HorzBreak),
round(HorzBreak, 1), NA),
# Vertical Approach Angle (pitch)
VAA = ifelse("VertApprAngle" %in% names(.) & !is.na(VertApprAngle),
round(VertApprAngle, 1), NA)
)
# Add bat tracking columns if available
if (bat_tracking_available) {
pitch_log <- pitch_log %>%
mutate(
BatSpd = ifelse("BatSpeed" %in% names(.) & !is.na(BatSpeed),
round(BatSpeed, 1), NA),
AA = ifelse("VerticalAttackAngle" %in% names(.) & !is.na(VerticalAttackAngle),
round(VerticalAttackAngle, 1), NA),
HAA = ifelse("HorizontalAttackAngle" %in% names(.) & !is.na(HorizontalAttackAngle),
round(HorizontalAttackAngle, 1), NA)
)
}
# Select columns based on availability
if (bat_tracking_available) {
pitch_log <- pitch_log %>%
select(PitchNumber, Inning, Pitcher, Count, TaggedPitchType, Velo, IVB, HB, VAA,
event, EV, LA, Dist, BatSpd, AA, HAA)
} else {
pitch_log <- pitch_log %>%
select(PitchNumber, Inning, Pitcher, Count, TaggedPitchType, Velo, IVB, HB, VAA,
event, EV, LA, Dist)
}
chart_y <- 0.36
chart_h <- 0.22
plot_w <- 0.35
table_title_y <- 0.13
table_y <- 0.11
# Updated draw function with bat tracking support
draw_pitch_table <- function(df, y_top, row_height = 0.0135, cex = 0.58, include_bat_tracking = FALSE) {
if (include_bat_tracking) {
# Headers with bat tracking: #, Inn, Pitcher, Count, Pitch, Velo, IVB, HB, VAA, Event, EV, LA, Dist, BatSpd, AA, HAA
headers <- c("#", "Inn", "Pitcher", "Count", "Pitch", "Velo", "IVB", "HB", "VAA", "Event", "EV", "LA", "Dist", "BatSpd", "AA", "HAA")
widths <- c(0.025, 0.03, 0.12, 0.04, 0.065, 0.04, 0.04, 0.04, 0.04, 0.065, 0.035, 0.035, 0.04, 0.045, 0.04, 0.04)
} else {
# Headers without bat tracking: #, Inn, Pitcher, Count, Pitch, Velo, IVB, HB, VAA, Event, EV, LA, Dist
headers <- c("#", "Inn", "Pitcher", "Count", "Pitch", "Velo", "IVB", "HB", "VAA", "Event", "EV", "LA", "Dist")
widths <- c(0.03, 0.035, 0.15, 0.05, 0.08, 0.05, 0.05, 0.05, 0.05, 0.08, 0.045, 0.045, 0.05)
}
x_start <- 0.5 - sum(widths)/2
x_pos <- c(x_start, x_start + cumsum(widths[-length(widths)]))
# Draw headers
for (i in seq_along(headers)) {
grid.rect(x = x_pos[i], y = y_top, width = widths[i]*0.985, height = row_height,
just = c("left","top"), gp = gpar(fill = "#006F71", col = "black", lwd = 0.4))
grid.text(headers[i], x = x_pos[i] + widths[i]*0.49, y = y_top - row_height*0.5,
gp = gpar(col = "white", cex = cex, fontface = "bold"))
}
# Draw rows
for (r in seq_len(nrow(df))) {
y_row <- y_top - r*row_height
if (include_bat_tracking) {
row_vals <- c(
df$PitchNumber[r],
ifelse(is.na(df$Inning[r]), "-", df$Inning[r]),
df$Pitcher[r],
df$Count[r],
df$TaggedPitchType[r],
ifelse(is.na(df$Velo[r]), "-", df$Velo[r]),
ifelse(is.na(df$IVB[r]), "-", df$IVB[r]),
ifelse(is.na(df$HB[r]), "-", df$HB[r]),
ifelse(is.na(df$VAA[r]), "-", df$VAA[r]),
df$event[r],
ifelse(is.na(df$EV[r]), "-", df$EV[r]),
ifelse(is.na(df$LA[r]), "-", df$LA[r]),
ifelse(is.na(df$Dist[r]), "-", df$Dist[r]),
ifelse(is.na(df$BatSpd[r]), "-", df$BatSpd[r]),
ifelse(is.na(df$AA[r]), "-", df$AA[r]),
ifelse(is.na(df$HAA[r]), "-", df$HAA[r])
)
} else {
row_vals <- c(
df$PitchNumber[r],
ifelse(is.na(df$Inning[r]), "-", df$Inning[r]),
df$Pitcher[r],
df$Count[r],
df$TaggedPitchType[r],
ifelse(is.na(df$Velo[r]), "-", df$Velo[r]),
ifelse(is.na(df$IVB[r]), "-", df$IVB[r]),
ifelse(is.na(df$HB[r]), "-", df$HB[r]),
ifelse(is.na(df$VAA[r]), "-", df$VAA[r]),
df$event[r],
ifelse(is.na(df$EV[r]), "-", df$EV[r]),
ifelse(is.na(df$LA[r]), "-", df$LA[r]),
ifelse(is.na(df$Dist[r]), "-", df$Dist[r])
)
}
for (i in seq_along(row_vals)) {
grid.rect(x = x_pos[i], y = y_row, width = widths[i]*0.985, height = row_height, just = c("left","top"),
gp = gpar(fill = ifelse(r %% 2 == 0, "#f7f7f7", "white"), col = "grey80", lwd = 0.3))
grid.text(as.character(row_vals[i]),
x = x_pos[i] + widths[i]*0.49, y = y_row - row_height*0.5, gp = gpar(cex = cex))
}
}
}
pdf(output_file, width = 10.5, height = 13)
on.exit(try(dev.off(), silent = TRUE), add = TRUE)
grid::grid.newpage()
pushViewport(viewport(x = 0.5, y = 0.98, width = 0.94, height = 0.08, just = c("center","top")))
grid.draw(create_simple_header(player_name, game_day, bio_data))
popViewport()
grid.text("Game Line", x = 0.5, y = 0.89, gp = gpar(fontface = "bold", cex = 1.2))
headers <- c("PA","H","XBH","BB","K","Chase","Whiffs","IZ Whiffs","BIP","Avg EV","Avg LA","HH")
values <- c(game_stats$PA, game_stats$H, game_stats$XBH, game_stats$BB, game_stats$K,
game_stats$Chase, game_stats$Whiffs, game_stats$`IZ Whiffs`, game_stats$BIP,
game_stats$`Avg EV`, game_stats$`Avg LA`, game_stats$HH)
col_w <- 0.065; x0 <- 0.5 - (length(headers)*col_w)/2; yh <- 0.865; yv <- 0.840
for (i in seq_along(headers)) {
xi <- x0 + (i-1)*col_w
grid.rect(x = xi, y = yh, width = col_w*0.985, height = 0.022, just = c("left","top"),
gp = gpar(fill = "#006F71", col = "black", lwd = 0.5))
grid.text(headers[i], x = xi + col_w*0.49, y = yh - 0.011,
gp = gpar(col = "white", cex = 0.72, fontface = "bold"))
grid.rect(x = xi, y = yv, width = col_w*0.985, height = 0.022, just = c("left","top"),
gp = gpar(fill = "white", col = "black", lwd = 0.4))
grid.text(as.character(values[i]), x = xi + col_w*0.49, y = yv - 0.011, gp = gpar(cex = 0.72))
}
pushViewport(viewport(x = 0.5, y = 0.795, width = 0.96, height = 0.44, just = c("center","top")))
print(at_bats_plot, newpage = FALSE)
popViewport()
pushViewport(viewport(x = 0.27, y = 0.36, width = 0.35, height = 0.22, just = c("center","top")))
print(spray_plot, newpage = FALSE)
popViewport()
pushViewport(viewport(x = 0.73, y = 0.36, width = 0.35, height = 0.22, just = c("center","top")))
print(contact_plot, newpage = FALSE)
popViewport()
grid.text(paste(player_name, "-", game_key, "- Batted Ball Log"), x = 0.5, y = 0.13,
gp = gpar(fontface = "bold", cex = 0.98))
rows_total <- nrow(pitch_log)
max_rows_first <- floor((0.11 - 0.02) / 0.0130)
rows_first <- min(rows_total, max_rows_first)
if (rows_first > 0) {
draw_pitch_table(pitch_log[1:rows_first, , drop = FALSE], y_top = 0.11, row_height = 0.0130,
cex = 0.52, include_bat_tracking = bat_tracking_available)
}
next_row <- rows_first + 1
if (next_row <= rows_total) {
grid::grid.newpage()
draw_pitch_table(pitch_log[next_row:rows_total, , drop = FALSE], y_top = 0.97, row_height = 0.0175,
cex = 0.56, include_bat_tracking = bat_tracking_available)
}
}
# - Header stat tiles: bigger + more readable + moved slightly DOWN
# - Tables: bigger numbers
# - Pitch columns always ordered by pitch usage (Pitch Count desc)
# - Table stack spacing fixed so "Release Data" title does NOT overlap Velo table
# ============================================================
library(ggplot2)
library(dplyr)
library(grid)
library(stringr)
# =====================================================================
# PITCH COLORS
# =====================================================================
tableau_pitch_colors <- c(
"Sinker" = "#76b8b2",
"Slider" = "#f38e2c",
"Cutter" = "#edca49",
"Sweeper" = "#E67E22",
"Fastball" = "#4f79a7",
"Four-Seam" = "#4f79a7",
"FourSeamFastBall" = "#4f79a7",
"4-Seam Fastball" = "#4f79a7",
"Curveball" = "#5aa150",
"ChangeUp" = "#e1575a",
"Changeup" = "#e1575a",
"Splitter" = "#b07ba1",
"Knuckle Curve" = "#5aa150",
"Two-Seam" = "#76b8b2",
"TwoSeamFastBall" = "#76b8b2",
"Other" = "#95A5A6",
"Undefined" = "#95A5A6"
)
TMB <- read.csv("TMB (1).csv", stringsAsFactors = FALSE)
# =====================================================================
# DATA PROCESSING
# =====================================================================
process_tableau_pitcher_data <- function(df) {
if (!"Pitcher" %in% names(df)) {
alt <- intersect(c("PitcherName","pitcher","Pitcher_LastFirst","PlayerName"), names(df))
if (length(alt)) df$Pitcher <- df[[alt[1]]] else df$Pitcher <- NA_character_
}
df <- df %>%
mutate(Pitcher = str_replace(coalesce(Pitcher, ""), "^\\s*(\\w+)\\s*,\\s*(\\w+)\\s*$", "\\2 \\1"))
# Clean up TaggedPitchType
if ("TaggedPitchType" %in% names(df)) {
# First, ensure fallback columns exist (create as NA if not)
if (!"AutoPitchType" %in% names(df)) df$AutoPitchType <- NA_character_
if (!"PitchType" %in% names(df)) df$PitchType <- NA_character_
df <- df %>%
mutate(
TaggedPitchType = case_when(
is.na(TaggedPitchType) | TaggedPitchType == "" | TaggedPitchType == "Undefined" ~
case_when(
!is.na(AutoPitchType) & AutoPitchType != "" & AutoPitchType != "Undefined" ~ AutoPitchType,
!is.na(PitchType) & PitchType != "" & PitchType != "Undefined" ~ PitchType,
TRUE ~ "Undefined"
),
TRUE ~ TaggedPitchType
)
)
}
# Normalize common FB spellings (for colors + grouping)
df <- df %>%
mutate(
TaggedPitchType = case_when(
TaggedPitchType %in% c("FourSeamFastball","FourSeamFastBall","4-Seam","4-Seam Fast Ball","Four-Seam Fastball","4-Seam Fastball") ~ "Four-Seam",
TRUE ~ TaggedPitchType
)
)
# Indicators
df %>%
mutate(
StrikeZoneIndicator = ifelse(PlateLocSide >= -0.8333 & PlateLocSide <= 0.8333 &
PlateLocHeight >= 1.5 & PlateLocHeight <= 3.5, 1, 0),
EdgeHeightIndicator = ifelse((PlateLocHeight > 14/12 & PlateLocHeight < 22/12) |
(PlateLocHeight > 38/12 & PlateLocHeight < 46/12), 1, 0),
EdgeZoneHtIndicator = ifelse(PlateLocHeight > 16/12 & PlateLocHeight < 45.2/12, 1, 0),
EdgeZoneWIndicator = ifelse(PlateLocSide > -13.4/12 & PlateLocSide < 13.4/12, 1, 0),
EdgeWidthIndicator = ifelse((PlateLocSide > -13.3/12 & PlateLocSide < -6.7/12) |
(PlateLocSide < 13.3/12 & PlateLocSide > 6.7/12), 1, 0),
EdgeIndicator = ifelse((EdgeHeightIndicator == 1 & EdgeZoneWIndicator == 1) |
(EdgeWidthIndicator == 1 & EdgeZoneHtIndicator == 1), 1, 0),
QualityPitchIndicator = ifelse(StrikeZoneIndicator == 1 | EdgeIndicator == 1, 1, 0),
StrikeIndicator = ifelse(PitchCall %in% c("StrikeSwinging","StrikeCalled","FoulBallNotFieldable","FoulBall","InPlay"), 1, 0),
WhiffIndicator = ifelse(PitchCall == "StrikeSwinging", 1, 0),
SwingIndicator = ifelse(PitchCall %in% c("StrikeSwinging","FoulBallNotFieldable","FoulBall","InPlay"), 1, 0),
FPindicator = ifelse(Balls == 0 & Strikes == 0, 1, 0),
FPSindicator = ifelse(PitchCall %in% c("StrikeCalled","StrikeSwinging","FoulBallNotFieldable","FoulBall","InPlay") & FPindicator == 1, 1, 0),
EarlyIndicator = ifelse(
((Balls == 0 & Strikes == 0 & PitchCall == "InPlay") |
(Balls == 1 & Strikes == 0 & PitchCall == "InPlay") |
(Balls == 0 & Strikes == 1 & PitchCall == "InPlay") |
(Balls == 1 & Strikes == 1 & PitchCall == "InPlay")), 1, 0),
AheadIndicator = ifelse(
((Balls == 0 & Strikes == 1) & (PitchCall %in% c("StrikeCalled", "StrikeSwinging", "FoulBallNotFieldable",'FoulBall'))) |
((Balls == 1 & Strikes == 1) & (PitchCall %in% c("StrikeCalled", "StrikeSwinging", "FoulBallNotFieldable",'FoulBall'))), 1, 0),
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),
LeadOffIndicator = ifelse((PAofInning == 1 & (PlayResult != "Undefined" | KorBB != "Undefined")) | PitchCall == "HitByPitch", 1, 0),
OutIndicator = ifelse((PlayResult %in% c("Out","FieldersChoice") | KorBB == "Strikeout") & PitchCall != "HitByPitch", 1, 0),
LOOindicator = ifelse(LeadOffIndicator == 1 & OutIndicator == 1, 1, 0),
HBPIndicator = ifelse(PitchCall == "HitByPitch", 1, 0),
WalkIndicator = ifelse(KorBB == "Walk", 1, 0),
LHHindicator = ifelse(BatterSide == "Left", 1, 0),
RHHindicator = ifelse(BatterSide == "Right", 1, 0)
)
}
# =====================================================================
# HEADER STATS
# =====================================================================
calculate_tableau_header_stats <- function(pitcher_df) {
ab_data <- pitcher_df %>%
filter(ABindicator == 1) %>%
group_by(Inning, Batter, PAofInning) %>%
slice_tail(n = 1) %>%
ungroup()
at_bats <- nrow(ab_data)
hits <- sum(ab_data$HitIndicator, na.rm = TRUE)
xbh <- sum(ab_data$PlayResult %in% c("Double","Triple","HomeRun"), na.rm = TRUE)
runs <- sum(pitcher_df$RunsScored, na.rm = TRUE)
pa_data <- pitcher_df %>%
filter(PAindicator == 1) %>%
group_by(Inning, Batter, PAofInning) %>%
slice_tail(n = 1) %>%
ungroup()
bb <- sum(pa_data$WalkIndicator, na.rm = TRUE)
hbp <- sum(pa_data$HBPIndicator, na.rm = TRUE)
so <- sum(pa_data$KorBB == "Strikeout", na.rm = TRUE)
avg <- ifelse(at_bats > 0, round(hits / at_bats, 3), 0)
total_pitches <- nrow(pitcher_df)
strike_pct <- ifelse(total_pitches > 0,
round(100 * sum(pitcher_df$StrikeIndicator, na.rm = TRUE) / total_pitches, 0), 0)
fp_pitches <- sum(pitcher_df$FPindicator, na.rm = TRUE)
fp_k_pct <- ifelse(fp_pitches > 0,
round(100 * sum(pitcher_df$FPSindicator, na.rm = TRUE) / fp_pitches, 0), 0)
ea_pct <- round(
(sum(pitcher_df$EarlyIndicator, na.rm = TRUE) + sum(pitcher_df$AheadIndicator, na.rm = TRUE)) /
sum(pitcher_df$PAindicator, na.rm = TRUE) * 100,
1
)
comp_pct <- ifelse(total_pitches > 0,
round(100 * sum(pitcher_df$QualityPitchIndicator, na.rm = TRUE) / total_pitches, 0), 0)
leadoff_opps <- sum(pitcher_df$LeadOffIndicator, na.rm = TRUE)
loo_pct <- ifelse(leadoff_opps > 0,
round(100 * sum(pitcher_df$LOOindicator, na.rm = TRUE) / leadoff_opps, 0), 0)
list(
at_bats = at_bats, hits = hits, xbh = xbh, runs = runs,
bb_hbp = bb + hbp, so = so,
avg = sprintf("%.3f", avg),
strike_pct = paste0(strike_pct, "%"),
fp_k_pct = paste0(fp_k_pct, "%"),
ea_pct = paste0(ea_pct, "%"),
comp_pct = paste0(comp_pct, "%"),
loo_pct = paste0(loo_pct, "%")
)
}
# =====================================================================
# TABLE CALCS
# =====================================================================
get_valid_pitch_types <- function(pitcher_df) {
valid_types <- pitcher_df %>%
filter(!is.na(TaggedPitchType), TaggedPitchType != "") %>%
pull(TaggedPitchType) %>%
unique()
if (length(valid_types) == 1 && valid_types[1] == "Undefined") return(valid_types)
filter_types <- valid_types[valid_types != "Undefined" & valid_types != "Other"]
if (length(filter_types) == 0) return("Undefined")
filter_types
}
calculate_tableau_location_data <- function(pitcher_df) {
filter_types <- get_valid_pitch_types(pitcher_df)
pitcher_df %>%
filter(TaggedPitchType %in% filter_types) %>%
group_by(TaggedPitchType) %>%
summarise(
`Zone%` = paste0(round(100 * sum(StrikeZoneIndicator, na.rm = TRUE) / n(), 0), "%"),
`Edge%` = paste0(round(100 * sum(EdgeIndicator, na.rm = TRUE) / n(), 0), "%"),
`Strike%` = paste0(round(100 * sum(StrikeIndicator, na.rm = TRUE) / n(), 0), "%"),
`Whiff%` = ifelse(sum(SwingIndicator, na.rm = TRUE) > 0,
paste0(round(100 * sum(WhiffIndicator, na.rm = TRUE) / sum(SwingIndicator, na.rm = TRUE), 0), "%"),
"0%"),
.groups = "drop"
)
}
calculate_tableau_pitch_usage <- function(pitcher_df) {
lhh_pitches <- sum(pitcher_df$LHHindicator, na.rm = TRUE)
rhh_pitches <- sum(pitcher_df$RHHindicator, na.rm = TRUE)
filter_types <- get_valid_pitch_types(pitcher_df)
pitcher_df %>%
filter(TaggedPitchType %in% filter_types) %>%
group_by(TaggedPitchType) %>%
summarise(
`Pitch Count` = n(),
`Usage vs. LHH` = paste0(round(100 * sum(LHHindicator, na.rm = TRUE) / max(1, lhh_pitches), 0), "%"),
`Usage vs. RHH` = paste0(round(100 * sum(RHHindicator, na.rm = TRUE) / max(1, rhh_pitches), 0), "%"),
.groups = "drop"
)
}
calculate_tableau_velo_movement <- function(pitcher_df) {
filter_types <- get_valid_pitch_types(pitcher_df)
pitcher_df %>%
filter(TaggedPitchType %in% filter_types) %>%
group_by(TaggedPitchType) %>%
summarise(
`Avg. Velo` = round(mean(RelSpeed, na.rm = TRUE), 1),
`Max. Velo` = round(max(RelSpeed, na.rm = TRUE), 1),
`Avg. Spin` = format(round(mean(SpinRate, na.rm = TRUE), 0), big.mark = ","),
`Max. Spin` = format(round(max(SpinRate, na.rm = TRUE), 0), big.mark = ","),
`Avg. IVB` = round(mean(InducedVertBreak, na.rm = TRUE), 0),
`Avg. HB` = round(mean(HorzBreak, na.rm = TRUE), 0),
.groups = "drop"
)
}
calculate_tableau_release_data <- function(pitcher_df) {
primary <- pitcher_df %>%
filter(TaggedPitchType %in% c("Fastball","Sinker","Four-Seam","FourSeamFastBall"))
fb_ht <- if (nrow(primary) > 0) mean(primary$RelHeight, na.rm = TRUE) else NA
fb_side <- if (nrow(primary) > 0) mean(primary$RelSide, na.rm = TRUE) else NA
filter_types <- get_valid_pitch_types(pitcher_df)
pitcher_df %>%
filter(TaggedPitchType %in% filter_types) %>%
group_by(TaggedPitchType) %>%
summarise(
`Avg. Rel Ht` = round(mean(RelHeight, na.rm = TRUE), 2),
`Rel Ht vs. FB` = ifelse(is.na(fb_ht), NA, round((mean(RelHeight, na.rm = TRUE) - fb_ht) * 12, 0)),
`Avg. Rel Side` = round(mean(RelSide, na.rm = TRUE), 2),
`Rel Side vs. FB` = ifelse(is.na(fb_side), NA, round((mean(RelSide, na.rm = TRUE) - fb_side) * 12, 0)),
`Avg. Ext` = round(mean(Extension, na.rm = TRUE), 2),
.groups = "drop"
)
}
# =====================================================================
# PLOTS
# =====================================================================
create_tableau_location_plot <- function(pitcher_df, pitch_colors) {
filter_types <- get_valid_pitch_types(pitcher_df)
df <- pitcher_df %>%
filter(TaggedPitchType %in% filter_types,
!is.na(PlateLocSide), !is.na(PlateLocHeight)) %>%
mutate(
ResultDisplay = case_when(
PitchCall %in% c("BallCalled","BallinDirt") ~ "Ball",
PlayResult == "Double" ~ "2B",
PitchCall %in% c("FoulBall","FoulBallNotFieldable") ~ "Foul",
PitchCall == "HitByPitch" ~ "HBP",
PlayResult %in% c("Sacrifice","SacrificeFly") ~ "Sac",
PlayResult == "Single" ~ "1B",
PitchCall == "StrikeCalled" ~ "Called",
PitchCall == "StrikeSwinging" ~ "Whiff",
PlayResult == "Triple" ~ "3B",
PlayResult == "HomeRun" ~ "HR",
PlayResult == "Out" ~ "Out",
PlayResult == "Error" ~ "Error",
TRUE ~ "Other"
)
)
if (nrow(df) == 0) {
return(ggplot() + theme_void() +
labs(title = "Location Report") +
theme(plot.title = element_text(size = 12, face = "bold", hjust = 0.5)))
}
zone_left <- -0.8333; zone_right <- 0.8333
zone_bottom <- 1.5; zone_top <- 3.5
shadow_left <- -1.1; shadow_right <- 1.1
shadow_bottom <- 1.2; shadow_top <- 3.8
zone_width <- (zone_right - zone_left) / 3
zone_height <- (zone_top - zone_bottom) / 3
ggplot(df, aes(x = PlateLocSide, y = PlateLocHeight)) +
annotate("rect", xmin = shadow_left, xmax = shadow_right,
ymin = shadow_bottom, ymax = shadow_top,
fill = NA, color = "gray30", linetype = "dashed", linewidth = 0.5) +
annotate("rect", xmin = zone_left, xmax = zone_right,
ymin = zone_bottom, ymax = zone_top,
fill = NA, color = "#E74C3C", linewidth = 1) +
annotate("segment", x = zone_left + zone_width, xend = zone_left + zone_width,
y = zone_bottom, yend = zone_top, color = "gray50", linetype = "dashed", linewidth = 0.3) +
annotate("segment", x = zone_left + 2*zone_width, xend = zone_left + 2*zone_width,
y = zone_bottom, yend = zone_top, color = "gray50", linetype = "dashed", linewidth = 0.3) +
annotate("segment", x = zone_left, xend = zone_right,
y = zone_bottom + zone_height, yend = zone_bottom + zone_height,
color = "gray50", linetype = "dashed", linewidth = 0.3) +
annotate("segment", x = zone_left, xend = zone_right,
y = zone_bottom + 2*zone_height, yend = zone_bottom + 2*zone_height,
color = "gray50", linetype = "dashed", linewidth = 0.3) +
annotate("polygon", x = c(-0.708, 0.708, 0.708, 0, -0.708),
y = c(0.15, 0.15, 0.30, 0.50, 0.30), fill = NA, color = "black", linewidth = 0.5) +
annotate("segment", x = 0, xend = 0, y = 0.5, yend = shadow_top + 0.2,
color = "gray60", linetype = "dotted", linewidth = 0.3) +
geom_point(aes(color = TaggedPitchType, shape = ResultDisplay), size = 2.6, stroke = 0.85) +
scale_color_manual(values = pitch_colors, name = "Pitch") +
scale_shape_manual(
values = c("Ball" = 1, "2B" = 18, "Foul" = 2, "HBP" = 10,
"Sac" = 3, "1B" = 19, "Called" = 5, "Whiff" = 8,
"3B" = 17, "HR" =15, "Out" = 4, "Error" = 0, "Other" = 16),
name = "Result"
) +
coord_fixed(xlim = c(-2.2, 2.2), ylim = c(0, 4.2)) +
labs(title = "Location Report") +
theme_minimal() +
theme(
plot.title = element_text(size = 12, face = "bold", hjust = 0.5),
legend.position = "left",
legend.title = element_text(size = 7, face = "bold"),
legend.text = element_text(size = 6),
legend.key.size = unit(0.45, "cm"),
legend.spacing.y = unit(0.08, "cm"),
legend.margin = margin(0, 0, 0, 0),
legend.box.margin = margin(0, -4, 0, -6),
axis.text = element_blank(),
axis.title = element_blank(),
axis.ticks = element_blank(),
panel.grid = element_blank(),
plot.margin = margin(2, 2, 2, 2)
) +
guides(
color = guide_legend(override.aes = list(size = 3), ncol = 1),
shape = guide_legend(override.aes = list(size = 3), ncol = 1)
)
}
create_tableau_movement_plot <- function(pitcher_df, pitch_colors) {
filter_types <- get_valid_pitch_types(pitcher_df)
df <- pitcher_df %>%
dplyr::filter(
TaggedPitchType %in% filter_types,
!is.na(HorzBreak), !is.na(InducedVertBreak)
)
if (nrow(df) == 0) {
return(
ggplot() + theme_void() +
labs(title = "Movement Profile") +
theme(plot.title = element_text(size = 12, face = "bold", hjust = 0.5))
)
}
ggplot(df, aes(x = HorzBreak, y = InducedVertBreak)) +
# Center lines: GRAY + DASHED
geom_vline(xintercept = 0, color = "gray60", linetype = "dashed", linewidth = 0.5) +
geom_hline(yintercept = 0, color = "gray60", linetype = "dashed", linewidth = 0.5) +
# Points
geom_point(
aes(fill = TaggedPitchType),
size = 2.6,
shape = 21,
color = "black",
stroke = 0.9
) +
scale_fill_manual(values = pitch_colors, drop = FALSE) +
scale_x_continuous(
limits = c(-30, 30),
breaks = c(-30, -20, -10, 0, 10, 20, 30),
expand = expansion(mult = 0.02)
) +
scale_y_continuous(
limits = c(-30, 30),
breaks = c(-30, -20, -10, 0, 10, 20, 30),
expand = expansion(mult = 0.02)
) +
coord_fixed(ratio = 1) +
labs(title = "Movement Profile", x = "Horz Break", y = "Induced Vert Break") +
theme_minimal() +
theme(
plot.title = element_text(size = 12, face = "bold", hjust = 0.5),
legend.position = "none",
axis.title = element_text(size = 10),
axis.text = element_text(size = 9),
plot.margin = margin(4, 4, 4, 6)
)
}
create_tableau_release_plot <- function(pitcher_df, pitch_colors) {
filter_types <- get_valid_pitch_types(pitcher_df)
df <- pitcher_df %>%
filter(TaggedPitchType %in% filter_types,
!is.na(RelSide), !is.na(RelHeight))
if (nrow(df) == 0) {
return(ggplot() + theme_void() +
labs(title = "Release Plot") +
theme(plot.title = element_text(size = 10, face = "bold", hjust = 0.5)))
}
mound_theta <- seq(0, pi, length.out = 100)
mound_radius <- 3
mound_df <- data.frame(
x = mound_radius * cos(mound_theta),
y = mound_radius * sin(mound_theta) * 0.35
)
ggplot(df, aes(x = RelSide, y = RelHeight)) +
geom_polygon(data = mound_df, aes(x = x, y = y),
fill = "#C0392B", color = NA, inherit.aes = FALSE) +
annotate("rect", xmin = -0.5, xmax = 0.5, ymin = 0.85, ymax = 1.05,
fill = "white", color = "gray40", linewidth = 0.3) +
geom_vline(xintercept = 0, color = "gray60", linetype = "dashed", linewidth = 0.3) +
geom_point(aes(fill = TaggedPitchType), size = 2.6, alpha = 1,
shape = 21, color = "black", stroke = 0.9) +
scale_fill_manual(values = pitch_colors, drop = FALSE) +
coord_cartesian(xlim = c(-4, 4), ylim = c(0, 7)) +
labs(title = "Release Plot", x = "Rel Side", y = "Rel Height") +
theme_minimal() +
theme(
plot.title = element_text(size = 12, face = "bold", hjust = 0.5),
legend.position = "none",
axis.title = element_text(size = 10),
axis.text = element_text(size = 9),
plot.margin = margin(2, 2, 2, 2)
)
}
# =====================================================================
# TABLE DRAWING (BIGGER NUMBERS)
# =====================================================================
draw_tableau_table_fill <- function(
title,
data,
rows,
pitch_types,
pitch_colors,
x, y,
width, height
) {
if (is.null(pitch_types) || length(pitch_types) == 0) pitch_types <- "Undefined"
n_cols <- length(pitch_types)
n_rows <- length(rows) + 1
title_h <- min(0.05, height * 0.18)
table_top <- y - title_h
table_h <- height - title_h
col_w <- width / n_cols
row_h <- table_h / n_rows
# Bigger text than before (without exploding)
header_cex <- max(0.85, min(1.45, row_h * 26))
body_cex <- max(0.82, min(1.35, row_h * 24))
label_cex <- max(0.82, min(1.35, row_h * 24))
title_cex <- max(1.00, min(1.45, row_h * 28))
grid.text(title, x = x + width/2, y = y,
gp = gpar(fontface = "bold", cex = title_cex, col = "#006F71"))
# Column headers
for (i in seq_along(pitch_types)) {
pt <- pitch_types[i]
col_color <- if (pt %in% names(pitch_colors)) pitch_colors[[pt]] else "#95A5A6"
grid.rect(x = x + (i-1)*col_w, y = table_top,
width = col_w * 0.98, height = row_h * 0.95,
just = c("left", "top"),
gp = gpar(fill = col_color, col = "gray30", lwd = 0.6))
pt_short <- pt
pt_short <- gsub("ChangeUp|Changeup", "CH", pt_short)
pt_short <- gsub("Fastball|Four-Seam|FourSeamFastBall|Four-Seam Fastball|4-Seam Fastball|Four-Seam", "FB", pt_short)
pt_short <- gsub("Curveball", "CB", pt_short)
pt_short <- gsub("Slider", "SL", pt_short)
pt_short <- gsub("Sinker", "SI", pt_short)
pt_short <- gsub("Cutter", "CT", pt_short)
pt_short <- gsub("Splitter", "SP", pt_short)
pt_short <- gsub("Sweeper", "SW", pt_short)
grid.text(pt_short,
x = x + (i-1)*col_w + col_w/2,
y = table_top - row_h*0.55,
gp = gpar(col = "white", cex = header_cex, fontface = "bold"))
}
# Data rows
row_names <- names(rows)
col_names <- as.character(rows)
for (r in seq_along(col_names)) {
disp <- row_names[r]
coln <- col_names[r]
y_row_top <- table_top - r*row_h
grid.text(disp,
x = x - 0.010,
y = y_row_top - row_h*0.55,
just = "right",
gp = gpar(cex = label_cex, fontface = "bold"))
for (i in seq_along(pitch_types)) {
pt <- pitch_types[i]
idx <- which(data$TaggedPitchType == pt)
val <- "-"
if (length(idx) > 0 && coln %in% names(data)) {
val <- as.character(data[[coln]][idx[1]])
}
grid.rect(x = x + (i-1)*col_w, y = y_row_top,
width = col_w * 0.98, height = row_h * 0.95,
just = c("left", "top"),
gp = gpar(fill = "white", col = "gray40", lwd = 0.6))
grid.text(val,
x = x + (i-1)*col_w + col_w/2,
y = y_row_top - row_h*0.55,
gp = gpar(cex = body_cex, fontface = "plain"))
}
}
}
get_team_meta <- function(team_abbr, TMB) {
if (is.null(TMB) || !all(c("team_abbr","BTeamName","BTeamPrimColor","BTeamSecondColor") %in% names(TMB))) {
return(list(name = team_abbr, prim = "black", sec = "black"))
}
row <- TMB[TMB$team_abbr == team_abbr, , drop = FALSE]
if (nrow(row) == 0) {
return(list(name = team_abbr, prim = "black", sec = "black"))
}
list(
name = as.character(row$BTeamName[1]),
prim = as.character(row$BTeamPrimColor[1]),
sec = as.character(row$BTeamSecondColor[1])
)
}
draw_info_row_colored_team <- function(game_date, pitcher_name, away_team_abbr, TMB,
x = 0.02, y = 0.935,
base_cex = 0.8, base_col = "black",
fontface = "bold",
max_width = 0.96) {
tm <- get_team_meta(away_team_abbr, TMB)
part1 <- paste0(game_date, " | ", pitcher_name, " vs ")
part2 <- tm$name
gp1 <- grid::gpar(cex = base_cex, fontface = fontface, col = base_col)
gp2 <- grid::gpar(cex = base_cex, fontface = fontface, col = tm$prim)
g1 <- grid::textGrob(part1, gp = gp1, just = "left")
g2 <- grid::textGrob(part2, gp = gp2, just = "left")
w1 <- grid::convertWidth(grid::grobWidth(g1), "npc", valueOnly = TRUE)
w2 <- grid::convertWidth(grid::grobWidth(g2), "npc", valueOnly = TRUE)
padding <- 0.004
total_width <- x + w1 + padding + w2
# Shrink text if it would overflow
if (total_width > max_width) {
shrink <- max_width / total_width
gp1$cex <- base_cex * shrink
gp2$cex <- base_cex * shrink
}
grid::grid.text(part1, x = x, y = y, just = "left", gp = gp1)
grid::grid.text(part2, x = x + w1 + padding, y = y, just = "left", gp = gp2)
invisible(tm)
}
# =====================================================================
# MAIN PDF FUNCTION (SINGLE, CORRECT)
# =====================================================================
create_tableau_pitcher_pdf <- function(game_df, pitcher_name, output_file) {
if (length(dev.list()) > 0) try(dev.off(), silent = TRUE)
pitcher_df <- process_tableau_pitcher_data(game_df) %>%
filter(Pitcher == pitcher_name)
if (nrow(pitcher_df) == 0) {
pdf(output_file, width = 8.5, height = 11)
grid.newpage()
grid.text(paste("No data found for", pitcher_name),
gp = gpar(fontsize = 16, fontface = "bold"))
dev.off()
return(output_file)
}
game_date <- tryCatch({
d <- unique(pitcher_df$Date)[1]
parsed <- parse_flexible_date(d)
if (!is.na(parsed)) format(parsed, "%m/%d/%Y") else "NA"
}, error = function(e) "NA")
batter_teams <- unique(pitcher_df$BatterTeam)
batter_teams <- batter_teams[!is.na(batter_teams)]
away_team <- if (length(batter_teams) > 0) batter_teams[1] else "Unknown"
# Stats + tables
stats <- calculate_tableau_header_stats(pitcher_df)
loc_data <- calculate_tableau_location_data(pitcher_df)
usage_data <- calculate_tableau_pitch_usage(pitcher_df)
velo_data <- calculate_tableau_velo_movement(pitcher_df)
rel_data <- calculate_tableau_release_data(pitcher_df)
# Pitch columns ordered by usage (Pitch Count desc), then append any missing pitch types
pitch_types <- usage_data %>%
arrange(desc(`Pitch Count`)) %>%
pull(TaggedPitchType) %>%
unique()
pitch_types <- pitch_types[!is.na(pitch_types) & pitch_types != ""]
extras <- unique(c(loc_data$TaggedPitchType, velo_data$TaggedPitchType, rel_data$TaggedPitchType))
extras <- extras[!is.na(extras) & extras != "" & !extras %in% pitch_types]
pitch_types <- c(pitch_types, extras)
if (length(pitch_types) == 0) pitch_types <- "Undefined"
# Plots
loc_plot <- create_tableau_location_plot(pitcher_df, tableau_pitch_colors)
mov_plot <- create_tableau_movement_plot(pitcher_df, tableau_pitch_colors)
rel_plot <- create_tableau_release_plot(pitcher_df, tableau_pitch_colors)
# PDF
pdf(output_file, width = 8.5, height = 11)
on.exit(try(dev.off(), silent = TRUE), add = TRUE)
grid.newpage()
# ============================================================
# HEADER BAR + INFO
# ============================================================
grid.rect(x = 0, y = 0.955, width = 1, height = 0.045,
just = c("left", "bottom"),
gp = gpar(fill = "#006F71", col = NA))
logo_url <- "https://i.imgur.com/zjTu3JS.png"
logo_grob <- NULL
try({
logo_img <- magick::image_read(logo_url)
# Optional: add transparency if the logo has a white background you want removed
# logo_img <- magick::image_transparent(logo_img, "white", fuzz = 10)
# Scale to a consistent height in pixels (keeps it crisp)
logo_img <- magick::image_resize(logo_img, "x140")
logo_grob <- grid::rasterGrob(as.raster(logo_img), interpolate = TRUE)
}, silent = TRUE)
if (!is.null(logo_grob)) {
# Place in the top-right corner of the HEADER BAR
# x=0.985 means right edge is near page edge; y=0.977 centers within the bar
pushViewport(viewport(x = 0.988, y = 0.977,
width = 0.10, height = 0.040,
just = c("right", "center")))
grid.draw(logo_grob)
popViewport()
}
grid.text("Pitcher Post-Game Report", x = 0.02, y = 0.977, just = "left",
gp = gpar(col = "white", fontface = "bold", cex = 1.2))
info_y <- 0.935
grid.text(
paste0(game_date, " | ", pitcher_name, " vs ", away_team),
x = 0.02, y = info_y, just = "left",
gp = gpar(cex = 0.8, fontface = "bold", col = "black")
)
# ============================================================
# HEADER STAT TILES (BIGGER + MORE READABLE + MOVED DOWN)
# ============================================================
stat_labels <- c("At Bats","H","XBH","R","BB/HBP","SO","AVG","Strike%","1st P K%","E+A%","Comp%","LOO%")
stat_values <- c(stats$at_bats, stats$hits, stats$xbh, stats$runs,
stats$bb_hbp, stats$so, stats$avg,
stats$strike_pct, stats$fp_k_pct, stats$ea_pct,
stats$comp_pct, stats$loo_pct)
label_colors <- c("#fe0100", "#0000ff", "#0000ff", "#01ab01", "#01ab01","#01ab01",
"#01abff", "#ffaa01", "#ffaa01", "#ffaa01", "#ffaa01", "#ffaa01")
# moved DOWN a bit vs prior (and slightly bigger)
tiles_y <- 0.880
tile_w <- 0.074
tile_h <- 0.060
tile_gap <- 0.008
x_start <- 0.02
band_h <- tile_h * 0.55
for (i in seq_along(stat_labels)) {
x_pos <- x_start + (i - 1) * (tile_w + tile_gap)
# Outer border
grid.rect(
x = x_pos, y = tiles_y, width = tile_w, height = tile_h,
just = c("left", "center"),
gp = gpar(fill = NA, col = label_colors[i], lwd = 2.2)
)
# Top colored band
grid.rect(
x = x_pos, y = tiles_y + (tile_h/2) - (band_h/2),
width = tile_w, height = band_h,
just = c("left", "center"),
gp = gpar(fill = label_colors[i], col = NA)
)
# Bottom white area
grid.rect(
x = x_pos, y = tiles_y - (tile_h/2) + ((tile_h - band_h)/2),
width = tile_w, height = (tile_h - band_h),
just = c("left", "center"),
gp = gpar(fill = "white", col = "black", lwd = 0.8)
)
# Label (white, bigger)
grid.text(
stat_labels[i],
x = x_pos + tile_w/2,
y = tiles_y + (tile_h/2) - (band_h/2),
gp = gpar(col = "white", cex = 0.82, fontface = "bold")
)
# Value (big, very readable)
grid.text(
as.character(stat_values[i]),
x = x_pos + tile_w/2,
y = tiles_y - (tile_h/2) + ((tile_h - band_h)/2),
gp = gpar(col = "black", cex = 1.20, fontface = "bold")
)
}
# ============================================================
# CHARTS (kept same formatting; nudged slightly down)
# ============================================================
pushViewport(viewport(x = 0.23, y = 0.662, width = 0.42, height = 0.30))
print(loc_plot, newpage = FALSE)
popViewport()
pushViewport(viewport(x = 0.22, y = 0.355, width = 0.41, height = 0.29))
print(mov_plot, newpage = FALSE)
popViewport()
pushViewport(viewport(x = 0.23, y = 0.14, width = 0.42, height = 0.26))
print(rel_plot, newpage = FALSE)
popViewport()
# ============================================================
# TABLES (spacing fixed: Release title no overlap)
# ============================================================
table_x <- 0.58
table_w <- 0.41
draw_tableau_table_fill(
title = "Location Data",
data = loc_data,
rows = c("Zone%"="Zone%", "Edge%"="Edge%", "Strike%"="Strike%", "Whiff%"="Whiff%"),
pitch_types = pitch_types,
pitch_colors = tableau_pitch_colors,
x = table_x, y = 0.835, width = table_w, height = 0.16
)
draw_tableau_table_fill(
title = "Pitch Usage",
data = usage_data,
rows = c("Usage vs. LHH"="Usage vs. LHH", "Usage vs. RHH"="Usage vs. RHH", "Pitch Count"="Pitch Count"),
pitch_types = pitch_types,
pitch_colors = tableau_pitch_colors,
x = table_x, y = 0.645, width = table_w, height = 0.15
)
draw_tableau_table_fill(
title = "Velo & Movement",
data = velo_data,
rows = c("Avg. Velo"="Avg. Velo", "Max Velo"="Max. Velo",
"Avg. Spin"="Avg. Spin", "Max Spin"="Max. Spin",
"Avg. IVB"="Avg. IVB", "Avg. HB"="Avg. HB"),
pitch_types = pitch_types,
pitch_colors = tableau_pitch_colors,
x = table_x, y = 0.47, width = table_w, height = 0.21
)
draw_tableau_table_fill(
title = "Release Data",
data = rel_data,
rows = c("Rel Ht"="Avg. Rel Ht",
"Rel Ht vs FB (in)"="Rel Ht vs. FB",
"Rel Side"="Avg. Rel Side",
"Rel Side vs FB (in)"="Rel Side vs. FB",
"Ext"="Avg. Ext"),
pitch_types = pitch_types,
pitch_colors = tableau_pitch_colors,
x = table_x, y = 0.235, width = table_w, height = 0.19
)
invisible(output_file)
}
# =====================================================================
# ===================== CATCHER CODE (wrapped) =======================
# =====================================================================
catcher_process_dataset <- function(df) {
if ("Catcher" %in% names(df)) {
df <- df %>% mutate(Catcher = stringr::str_replace(Catcher, "^\\s*(\\w+)\\s*,\\s*(\\w+)\\s*$", "\\2 \\1"))
}
df <- df %>% distinct()
if ("PitchUID" %in% names(df)) df <- df %>% distinct(PitchUID, .keep_all = TRUE)
if ("Date" %in% names(df)) {
df$Date <- parse_flexible_date(df$Date)
}
if ("PlateLocSide" %in% names(df)) df$PlateLocSide <- as.numeric(df$PlateLocSide)
if ("PlateLocHeight" %in% names(df)) df$PlateLocHeight <- as.numeric(df$PlateLocHeight)
if ("BasePositionZ" %in% names(df)) df$BasePositionZ <- as.numeric(df$BasePositionZ)
if ("BasePositionY" %in% names(df)) df$BasePositionY <- as.numeric(df$BasePositionY)
BALL_CALLS <- c("BallCalled", "BallinDirt", "BallIntentional")
STRIKE_CALLS <- c("StrikeCalled")
SWING_CALLS <- c("StrikeSwinging", "InPlay", "FoulBall", "FoulBallFieldable", "FoulBallNotFieldable")
df %>%
mutate(
PitchCall = trimws(gsub("\\s+", "", PitchCall)),
in_zone = as.integer(!is.na(PlateLocSide) & !is.na(PlateLocHeight) &
PlateLocSide >= -0.83 & PlateLocSide <= 0.83 &
PlateLocHeight >= 1.5 & PlateLocHeight <= 3.38),
is_swing = as.integer(PitchCall %in% SWING_CALLS),
StrikeZoneIndicator = in_zone,
StolenStrike = as.integer(in_zone == 0 & PitchCall %in% STRIKE_CALLS),
StrikeLost = as.integer(in_zone == 1 & PitchCall %in% BALL_CALLS),
frame = dplyr::case_when(
in_zone == 1 & PitchCall %in% BALL_CALLS ~ "Strike Lost",
in_zone == 0 & PitchCall %in% STRIKE_CALLS ~ "Strike Added",
TRUE ~ NA_character_
),
frame_numeric = dplyr::case_when(
in_zone == 1 & PitchCall %in% BALL_CALLS ~ -1,
in_zone == 0 & PitchCall %in% STRIKE_CALLS ~ 1,
TRUE ~ NA_real_
)
)
}
catcher_parse_game_day <- function(df, tz = "America/New_York") {
stopifnot("Date" %in% names(df))
if (inherits(df$Date, "Date")) {
dates <- df$Date[!is.na(df$Date)]
if (length(dates) > 0) {
tab <- sort(table(dates), decreasing = TRUE)
return(as.Date(names(tab)[1]))
}
}
as.Date(df$Date[1])
}
catcher_create_framing_plots <- function(catcher_data, catcher_name) {
df <- dplyr::filter(catcher_data, Catcher == catcher_name, is_swing == 0)
strikes_added <- df %>% filter(frame == "Strike Added")
strikes_lost <- df %>% filter(frame == "Strike Lost")
pitch_colors <- c(
"Fastball"="#FA8072", "FourSeamFastBall" = "#FA8072","Four-Seam"="#FA8072","Sinker"="#fdae61","Slider"="#A020F0",
"Sweeper"="magenta","Curveball"="#2c7bb6","ChangeUp"="#90EE90","Splitter"="#90EE32","Cutter"="red"
)
zone <- data.frame(xmin = -0.83, xmax = 0.83, ymin = 1.5, ymax = 3.38)
make_plot <- function(data, title, show_legend = FALSE){
if (!nrow(data)) return(ggplot2::ggplot() + ggplot2::theme_void() + ggplot2::ggtitle(title))
gg <- ggplot2::ggplot(data, ggplot2::aes(PlateLocSide, PlateLocHeight)) +
ggplot2::annotate("rect",
xmin = zone$xmin, xmax = zone$xmax,
ymin = zone$ymin, ymax = zone$ymax,
fill = NA, color = "black", size = 0.5
) +
ggplot2::annotate("segment", x=-0.83, y=0.05, xend=0.83, yend=0.05, size=0.5, color="black") +
ggplot2::annotate("segment", x=-0.83, y=0.20, xend=-0.83, yend=0.05, size=0.5, color="black") +
ggplot2::annotate("segment", x= 0.83, y=0.20, xend= 0.83, yend=0.05, size=0.5, color="black") +
ggplot2::annotate("segment", x=-0.83, y=0.20, xend=0, yend=0.40, size=0.5, color="black") +
ggplot2::annotate("segment", x= 0.83, y=0.20, xend=0, yend=0.40, size=0.5, color="black") +
ggplot2::geom_point(ggplot2::aes(color = TaggedPitchType), size = 3, alpha = 0.95, na.rm = TRUE) +
ggplot2::scale_color_manual(values = pitch_colors, na.value = "grey60", name = "Pitch Type") +
ggplot2::coord_fixed() +
ggplot2::xlim(-2, 2) + ggplot2::ylim(0, 4) +
ggplot2::labs(title = title) +
ggplot2::theme_void() +
ggplot2::theme(
plot.title = ggplot2::element_text(size = 11, face = "bold", hjust = 0.5),
legend.position = if (show_legend) "bottom" else "none",
legend.title = ggplot2::element_text(size = 9, face = "bold"),
legend.text = ggplot2::element_text(size = 8),
legend.key.size = ggplot2::unit(0.5, "lines"),
legend.box = "horizontal"
)
gg
}
list(
p1 = make_plot(strikes_added, "Strikes Stolen", show_legend = FALSE),
p2 = make_plot(strikes_lost, "Strikes Lost", show_legend = TRUE)
)
}
catcher_create_framing_plot <- function(catcher_data, catcher_name) {
plots <- catcher_create_framing_plots(catcher_data, catcher_name)
gridExtra::grid.arrange(plots$p1, plots$p2, ncol = 2)
}
catcher_create_throwing_plot <- function(catcher_data, catcher_name) {
throws_data <- catcher_data %>%
filter(Catcher == catcher_name) %>%
filter(Notes %in% c('2b out','2b safe','3b out','3b safe'))
if (!nrow(throws_data))
return(ggplot() + theme_void() + ggtitle("No throwing data available") +
theme(plot.title = element_text(hjust = 0.5, size = 11, face = "bold")))
ggplot(throws_data) +
geom_polygon(data=data.frame(x=c(-10,10,10,-10), y=c(0.25,0.25,8,8)),
aes(x=x,y=y), fill='#14a6a8', color='#14a6a8') +
geom_polygon(data=data.frame(x=c(-10,10,10,-10), y=c(8,8,9,9)),
aes(x=x,y=y), fill='yellow', color='yellow') +
geom_polygon(data=data.frame(x=c(-10,10,10,-10), y=c(-2,-2,0.25,0.25)),
aes(x=x,y=y), fill='brown', color='brown') +
geom_polygon(data=data.frame(x=c(-10,10,10,-10), y=c(-5,-5,-2,-2)),
aes(x=x,y=y), fill='darkgreen', color='darkgreen') +
geom_polygon(data=data.frame(x=c(-1,1,1,-1), y=c(0,0,0.45,0.45)),
aes(x=x,y=y), fill='white', color='black') +
geom_polygon(data=data.frame(x=c(-1,0,0,-1), y=c(0,0,0.45,0.45)),
aes(x=x,y=y), fill='lightgrey', color='black') +
geom_point(aes(x=BasePositionZ, y=BasePositionY, fill=Notes),
color='white', pch=21, alpha=.99, size=3.5) +
scale_fill_manual(values=c('2b safe'='red','2b out'='#339a1d','3b safe'='#ff6b6b','3b out'='#1a5d1a')) +
scale_x_continuous(limits=c(-10,10)) + scale_y_continuous(limits=c(-5,9)) +
theme_bw() + coord_fixed() +
theme(legend.position="bottom", axis.title=element_blank(),
axis.text=element_blank(), axis.ticks=element_blank(),
panel.grid=element_blank(),
plot.title=element_text(size=11, face='bold', hjust=.5)) +
ggtitle(paste(catcher_name, "- Throwing Report"))
}
catcher_create_simple_header <- function(catcher_name, game_date, bio_data = NULL) {
suppressPackageStartupMessages({
library(grid)
library(gridExtra)
library(magick)
library(dplyr)
})
title_text <- paste(catcher_name, "- Catcher Report")
subhead_text <- if (!is.na(game_date)) paste("Game Date:", game_date) else ""
## ---------- HEADSHOT (OPTIONAL) ----------
img_grob <- nullGrob()
if (!is.null(bio_data) && nrow(bio_data) > 0) {
catcher_bio <- bio_data %>% filter(Catcher == catcher_name)
if (nrow(catcher_bio) > 0 && "Headshot" %in% names(catcher_bio)) {
url <- catcher_bio$Headshot[1]
if (!is.na(url) && nzchar(url)) {
img <- try(magick::image_read(url), silent = TRUE)
if (!inherits(img, "try-error")) {
img_grob <- rasterGrob(as.raster(img), interpolate = TRUE)
}
}
}
}
## ---------- LOGOS ----------
left_logo <- rasterGrob(as.raster(magick::image_resize(
magick::image_read("https://i.ibb.co/gLfTW4Fz/t-GPe-TPu.png"), "x120"
)))
right_logo <- rasterGrob(as.raster(magick::image_resize(
magick::image_read("https://i.imgur.com/zjTu3JS.png"), "x120"
)))
## ---------- TEXT ----------
title_grob <- textGrob(
title_text,
gp = gpar(fontsize = 18, fontface = "bold", col = "#006F71")
)
sub_grob <- textGrob(
subhead_text,
gp = gpar(fontsize = 11)
)
## ---------- LAYOUT ----------
arrangeGrob(
arrangeGrob(left_logo, title_grob, right_logo, ncol = 3,
widths = c(.15, .7, .15)),
sub_grob,
ncol = 1,
heights = c(.7, .3)
)
}
catcher_create_catcher_pdf <- function(game_df, catcher_name, output_file, bio_data = NULL) {
if (length(dev.list()) > 0) { try(dev.off(), silent = TRUE) }
catcher_df <- dplyr::filter(game_df, Catcher == catcher_name)
game_day <- catcher_parse_game_day(catcher_df, tz = "America/New_York")
game_key <- format(game_day, "%Y-%m-%d")
receiving_stats <- catcher_df %>%
summarise(`CCU Strikes Stolen` = sum(StolenStrike, na.rm = TRUE),
`CCU Strikes Lost` = sum(StrikeLost, na.rm = TRUE),
`CCU Game +/-` = sum(StolenStrike, na.rm = TRUE) - sum(StrikeLost, na.rm = TRUE),
.groups = "drop")
opp_catcher_name <- game_df %>%
filter(CatcherTeam != "COA_CHA") %>%
pull(Catcher) %>%
na.omit() %>%
{ names(sort(table(.), decreasing = TRUE))[1] }
opp_receiving_stats <- game_df %>%
filter(Catcher == opp_catcher_name) %>%
summarise(
`Opp Strikes Stolen` = sum(StolenStrike, na.rm = TRUE),
`Opp Strikes Lost` = sum(StrikeLost, na.rm = TRUE),
`Opp Game +/-` = sum(StolenStrike, na.rm = TRUE) - sum(StrikeLost, na.rm = TRUE),
.groups = "drop"
)
pitch_log <- catcher_df %>%
filter(StolenStrike == 1 | StrikeLost == 1) %>%
select(PitchNo, Pitcher, Catcher, Batter, TaggedPitchType, PitchCall, StrikeZoneIndicator) %>%
mutate(Pitch = TaggedPitchType, Actual = ifelse(StrikeZoneIndicator == 1, "STRIKE", "BALL")) %>%
select(PitchNo, Pitch, Pitcher, Catcher, Batter, PitchCall, Actual)
throw_log <- catcher_df %>%
filter(Notes %in% c('2b out','2b safe','3b out','3b safe')) %>%
select(PitchNo, Pitcher, Catcher, ThrowSpeed, PopTime, ExchangeTime, Notes)
framing_plots <- catcher_create_framing_plots(game_df, catcher_name)
framing_p1_grob <- ggplotGrob(framing_plots$p1)
framing_p2_grob <- ggplotGrob(framing_plots$p2)
throwing_grob <- ggplotGrob(catcher_create_throwing_plot(game_df, catcher_name))
pdf(output_file, width = 10.5, height = 13)
tryCatch({
grid::grid.newpage()
pushViewport(viewport(x=.5, y=.98, width=.94, height=.08, just=c("center","top")))
grid.draw(catcher_create_simple_header(catcher_name, game_key, bio_data)); popViewport()
grid.text("Receiving", x=.5, y=.89, gp=gpar(fontface="bold", cex=1.2, col="#006F71"))
headers_r <- c("CCU Strikes Stolen","CCU Strikes Lost","CCU Game +/-")
values_r <- c(receiving_stats$`CCU Strikes Stolen`, receiving_stats$`CCU Strikes Lost`, receiving_stats$`CCU Game +/-`)
col_w <- .18; x0 <- .5 - (length(headers_r)*col_w)/2; yh <- .868; yv <- .846
for (i in seq_along(headers_r)) {
xi <- x0 + (i-1)*col_w
grid.rect(x=xi, y=yh, width=col_w*.985, height=.018, just=c("left","top"),
gp=gpar(fill="#006F71", col="black", lwd=.5))
grid.text(headers_r[i], x=xi + col_w*.49, y=yh - .009, gp=gpar(col="white", cex=.7, fontface="bold"))
grid.rect(x=xi, y=yv, width=col_w*.985, height=.018, just=c("left","top"),
gp=gpar(fill="white", col="black", lwd=.4))
grid.text(as.character(values_r[i]), x=xi + col_w*.49, y=yv - .009, gp=gpar(cex=.7))
}
headers_opp <- c("Opp Strikes Stolen","Opp Strikes Lost","Opp Game +/-")
values_opp <- c(opp_receiving_stats$`Opp Strikes Stolen`, opp_receiving_stats$`Opp Strikes Lost`, opp_receiving_stats$`Opp Game +/-`)
yh_opp <- .820; yv_opp <- .798
for (i in seq_along(headers_opp)) {
xi <- x0 + (i-1)*col_w
grid.rect(x=xi, y=yh_opp, width=col_w*.985, height=.018, just=c("left","top"),
gp=gpar(fill="#006F71", col="black", lwd=.5))
grid.text(headers_opp[i], x=xi + col_w*.49, y=yh_opp - .009, gp=gpar(col="white", cex=.7, fontface="bold"))
grid.rect(x=xi, y=yv_opp, width=col_w*.985, height=.018, just=c("left","top"),
gp=gpar(fill="white", col="black", lwd=.4))
grid.text(as.character(values_opp[i]), x=xi + col_w*.49, y=yv_opp - .009, gp=gpar(cex=.7))
}
pushViewport(viewport(x=.25, y=.77, width=.47, height=.28, just=c("center","top"))); grid.draw(framing_p1_grob); popViewport()
pushViewport(viewport(x=.75, y=.77, width=.47, height=.28, just=c("center","top"))); grid.draw(framing_p2_grob); popViewport()
y_framing_table_start <- .46
if (nrow(pitch_log) > 0) {
headers_f <- c("PitchNo","Pitch","Pitcher","Catcher","Batter","PitchCall","Actual")
widths_f <- c(.08,.10,.15,.15,.15,.12,.10)
x_start <- .5 - sum(widths_f)/2
x_pos <- c(x_start, x_start + cumsum(widths_f[-length(widths_f)]))
row_h <- .013; y_top <- y_framing_table_start
for(i in seq_along(headers_f)){
grid.rect(x=x_pos[i], y=y_top, width=widths_f[i]*.985, height=row_h, just=c("left","top"),
gp=gpar(fill="#006F71", col="black", lwd=.4))
grid.text(headers_f[i], x=x_pos[i]+widths_f[i]*.49, y=y_top - row_h*.5, gp=gpar(col="white", cex=.58, fontface="bold"))
}
max_rows <- min(8, nrow(pitch_log))
for(r in 1:max_rows){
y_row <- y_top - r*row_h
row_vals <- c(pitch_log$PitchNo[r], pitch_log$Pitch[r], pitch_log$Pitcher[r],
pitch_log$Catcher[r], pitch_log$Batter[r], pitch_log$PitchCall[r],
pitch_log$Actual[r])
fill_color <- ifelse(pitch_log$Actual[r] == "STRIKE", "#90EE90", "#FFB6C1")
for(i in seq_along(row_vals)){
bg_fill <- if (i == 7) fill_color else ifelse(r %% 2 == 0, "#f7f7f7", "white")
grid.rect(x=x_pos[i], y=y_row, width=widths_f[i]*.985, height=row_h, just=c("left","top"),
gp=gpar(fill=bg_fill, col="grey80", lwd=.3))
grid.text(as.character(row_vals[i]), x=x_pos[i]+widths_f[i]*.49, y=y_row - row_h*.5, gp=gpar(cex=.55))
}
}
}
y_throwing_start <- .35
pushViewport(viewport(x=.5, y=y_throwing_start - .02, width=.55, height=.22, just=c("center","top"))); grid.draw(throwing_grob); popViewport()
y_throwing_table_start <- .10
if (nrow(throw_log) > 0) {
headers_t <- c("PitchNo","Pitcher","Catcher","ThrowSpeed","PopTime","ExchangeTime","Notes")
widths_t <- c(.08,.18,.15,.12,.10,.13,.10)
x_start_t <- .5 - sum(widths_t)/2
x_pos_t <- c(x_start_t, x_start_t + cumsum(widths_t[-length(widths_t)]))
row_ht <- .012; y_top_t <- y_throwing_table_start
for(i in seq_along(headers_t)){
grid.rect(x=x_pos_t[i], y=y_top_t, width=widths_t[i]*.985, height=row_ht, just=c("left","top"),
gp=gpar(fill="#006F71", col="black", lwd=.4))
grid.text(headers_t[i], x=x_pos_t[i]+widths_t[i]*.49, y=y_top_t - row_ht*.5, gp=gpar(col="white", cex=.55, fontface="bold"))
}
max_rows_t <- min(5, nrow(throw_log))
for(r in 1:max_rows_t){
y_row_t <- y_top_t - r*row_ht
row_vals_t <- c(throw_log$PitchNo[r], throw_log$Pitcher[r], throw_log$Catcher[r],
round(throw_log$ThrowSpeed[r],1), round(throw_log$PopTime[r],2),
round(throw_log$ExchangeTime[r],2), throw_log$Notes[r])
for(i in seq_along(row_vals_t)){
bg_fill <- ifelse(r %% 2 == 0, "#f7f7f7", "white")
grid.rect(x=x_pos_t[i], y=y_row_t, width=widths_t[i]*.985, height=row_ht, just=c("left","top"),
gp=gpar(fill=bg_fill, col="grey80", lwd=.3))
grid.text(as.character(row_vals_t[i]), x=x_pos_t[i]+widths_t[i]*.49, y=y_row_t - row_ht*.5, gp=gpar(cex=.55))
}
}
}
}, error=function(e){ message("Error creating PDF: ", e$message) }, finally={ dev.off() })
if (!file.exists(output_file)) stop("PDF file was not created successfully")
return(output_file)
grid::grid.text("Data: TrackMan | Report Generated: Coastal Carolina Baseball Analytics",
x = 0.5, y = 0.02, gp = grid::gpar(cex = 0.75, col = "grey50"))
}
# =====================================================================
# ===================== PITCHER CODE (UPDATED) ======================
# =====================================================================
draw_boxed_shared_legend <- function(labels, colors,
x_center = 0.5, y_top = 0.755,
n_cols = 6,
cell_height = 0.036,
box_width = 0.82,
title = "Pitch Type",
pad_h = 0.016, pad_v = 0.018,
border_col = "#bfbfbf",
cex_labels = 0.82) {
labels <- as.character(labels)
colors <- as.character(colors)
if (!length(labels)) {
grid::grid.text("No legend (no pitch types)", x = x_center, y = y_top - 0.02)
return(invisible(NULL))
}
n_cols <- max(1L, min(n_cols, length(labels)))
n_items <- length(labels)
n_rows <- ceiling(n_items / n_cols)
title_h <- cell_height * 0.9
box_h <- pad_v + title_h + (n_rows * cell_height) + pad_v
box_w <- box_width
grid::grid.rect(x = x_center, y = y_top - box_h/2,
width = box_w, height = box_h,
just = c("center", "center"),
gp = grid::gpar(fill = "white", col = border_col, lwd = 0.8))
grid::grid.text(title,
x = x_center,
y = y_top - pad_v,
just = c("center", "top"),
gp = grid::gpar(fontface = "bold", cex = 0.92, col = "#006F71"))
inner_top_y <- y_top - pad_v - title_h
grid::pushViewport(
grid::viewport(
x = x_center,
y = inner_top_y - ((n_rows * cell_height)/2),
width = box_w - 2*pad_h,
height = n_rows * cell_height,
just = c("center","center"),
layout = grid::grid.layout(nrow = n_rows, ncol = n_cols)
)
)
idx <- 1L
for (r in seq_len(n_rows)) {
for (c in seq_len(n_cols)) {
if (idx > n_items) break
lab <- labels[idx]
col <- colors[idx]
grid::pushViewport(grid::viewport(layout.pos.row = r, layout.pos.col = c))
grid::pushViewport(grid::viewport(
layout = grid::grid.layout(
nrow = 1, ncol = 2,
widths = grid::unit.c(grid::unit(0.40, "npc"), grid::unit(0.60, "npc"))
)
))
grid::pushViewport(grid::viewport(layout.pos.row = 1, layout.pos.col = 1))
grid::grid.rect(x = 0.5, y = 0.5,
width = grid::unit(0.55, "npc"),
height = grid::unit(0.55, "npc"),
just = c("center","center"),
gp = grid::gpar(fill = col, col = "black", lwd = 0.4))
grid::popViewport()
grid::pushViewport(grid::viewport(layout.pos.row = 1, layout.pos.col = 2))
grid::grid.text(lab, x = 0.5, y = 0.5, just = c("center","center"),
gp = grid::gpar(cex = cex_labels))
grid::popViewport()
grid::popViewport()
grid::popViewport()
idx <- idx + 1L
}
}
grid::popViewport()
}
draw_simple_table <- function(df, y_top,
col_headers = colnames(df),
col_widths = NULL,
row_height = 0.018,
header_bg = "#006F71",
header_cex = 0.60,
cell_cex = 0.58,
header_fg = "white",
zebra = TRUE) {
if (is.null(df) || !ncol(df)) {
grid::textGrob("No data", gp = grid::gpar(col = "red"))
return(invisible(NULL))
}
df[] <- lapply(df, function(x) ifelse(is.na(x), "", as.character(x)))
if (is.null(col_headers) || length(col_headers) != ncol(df)) {
col_headers <- colnames(df)
}
if (is.null(col_widths)) {
col_widths <- rep(1 / ncol(df), ncol(df))
}
x_start <- 0.5 - sum(col_widths)/2
x_pos <- c(x_start, x_start + cumsum(col_widths[-length(col_widths)]))
for (i in seq_along(col_headers)) {
grid::grid.rect(x = x_pos[i], y = y_top, width = col_widths[i]*0.985, height = row_height,
just = c("left","top"),
gp = grid::gpar(fill = header_bg, col = "black", lwd = 0.5))
grid::grid.text(col_headers[i], x = x_pos[i] + col_widths[i]*0.49, y = y_top - row_height*0.5,
gp = grid::gpar(col = header_fg, cex = header_cex, fontface = "bold"))
}
if (nrow(df) == 0) return(invisible(NULL))
for (r in seq_len(nrow(df))) {
y_row <- y_top - r*row_height
for (i in seq_along(col_headers)) {
val <- df[[i]][r]
bg <- if (zebra && (r %% 2 == 0)) "#f7f7f7" else "white"
grid::grid.rect(x = x_pos[i], y = y_row, width = col_widths[i]*0.985, height = row_height,
just = c("left","top"),
gp = grid::gpar(fill = bg, col = "grey80", lwd = 0.3))
grid::grid.text(val, x = x_pos[i] + col_widths[i]*0.49, y = y_row - row_height*0.5,
gp = grid::gpar(cex = cell_cex))
}
}
}
.pitcher_game_line_headers <- c("Date","BF","K","BB","HBP","H","XBH","Strike %","Whiff %")
.pitcher_game_line_widths <- c(0.13,0.07,0.06,0.06,0.07,0.06,0.07,0.11,0.11)
.pitcher_char_headers <- c("Pitch","Total","Avg Velo","Max Velo","Avg Spin","Max Spin",
"Avg IVB","Avg HB","RelHt","Ext","Strike %","Whiff %")
.pitcher_char_widths <- c(0.12,0.07,0.09,0.09,0.09,0.09,0.085,0.085,0.07,0.07,0.085,0.085)
create_pitcher_game_line <- function(game_data) {
game_data %>%
summarise(
Date = format(unique(Date)[1], "%m/%d/%y"),
BF = n_distinct(paste(Inning, Batter, PAofInning)),
K = sum(KorBB == "Strikeout", na.rm = TRUE),
BB = sum(WalkIndicator, na.rm = TRUE),
HBP = sum(HBPIndicator, na.rm = TRUE),
H = sum(PlayResult %in% c('Single','Double','Triple','HomeRun'), na.rm = TRUE),
XBH = sum(PlayResult %in% c('Double','Triple','HomeRun'), na.rm = TRUE),
`Strike %` = round(mean(PitchCall %in% c("StrikeCalled","StrikeSwinging","FoulBall","FoulBallNotFieldable","InPlay"), na.rm = TRUE) * 100, 1),
`Whiff %` = round(sum(WhiffIndicator, na.rm = TRUE) / sum(SwingIndicator, na.rm = TRUE) * 100, 1)
)
}
create_pitcher_pitch_char <- function(game_data) {
game_data %>%
filter(TaggedPitchType != "Other", !is.na(TaggedPitchType)) %>%
group_by(Pitch = TaggedPitchType) %>%
summarise(
Total = n(),
`Avg Velo` = round(mean(RelSpeed, na.rm = TRUE), 1),
`Max Velo` = round(max(RelSpeed, na.rm = TRUE), 1),
`Avg Spin` = round(mean(SpinRate, na.rm = TRUE), 0),
`Max Spin` = round(max(SpinRate, na.rm = TRUE), 1),
`Avg IVB` = round(mean(InducedVertBreak, na.rm = TRUE), 1),
`Avg HB` = round(mean(HorzBreak, na.rm = TRUE), 1),
RelHt = round(mean(RelHeight, na.rm = TRUE), 1),
Ext = round(mean(Extension, na.rm = TRUE), 1),
`Strike %` = round(mean(PitchCall %in% c("StrikeCalled","StrikeSwinging","FoulBall","FoulBallNotFieldable","InPlay"), na.rm = TRUE) * 100, 1),
`Whiff %` = round(sum(WhiffIndicator, na.rm = TRUE) / sum(SwingIndicator, na.rm = TRUE) * 100, 1),
.groups = "drop"
) %>% arrange(desc(Total))
}
.pitch_theme <- theme_minimal(base_size = 12) +
theme(
plot.title = element_text(size = 16, face = "bold", hjust = 0.5),
panel.grid.minor = element_blank()
)
create_pitcher_movement_plot <- function(game_data, pitcher_name, pitch_colors) {
df <- game_data %>% filter(!is.na(TaggedPitchType), TaggedPitchType != "Other")
if (nrow(df) == 0) return(ggplot() + theme_void() + ggtitle("Pitch Movement"))
centers <- df %>% group_by(TaggedPitchType) %>%
summarise(
mean_velo = round(mean(RelSpeed, na.rm = TRUE)),
mean_hb = median(HorzBreak, na.rm = TRUE),
mean_ivb = median(InducedVertBreak, na.rm = TRUE), .groups = "drop"
)
ggplot(df, aes(x = HorzBreak, y = InducedVertBreak)) +
geom_vline(xintercept = 0, color = "black", linewidth = 0.5) +
geom_hline(yintercept = 0, color = "black", linewidth = 0.5) +
geom_point(aes(fill = TaggedPitchType), alpha = 0.85, shape = 21, color = "black", stroke = 0.4, size = 4.5) +
geom_point(data = centers, aes(x = mean_hb, y = mean_ivb, fill = TaggedPitchType),
alpha = 1, shape = 21, color = "black", stroke = 0.5, size = 8) +
geom_text(data = centers, aes(x = mean_hb, y = mean_ivb, label = mean_velo),
color = "black", size = 4, vjust = 0.5, fontface = "bold") +
scale_fill_manual(values = pitch_colors) +
coord_cartesian(xlim = c(-27.5, 27.5), ylim = c(-27.5, 27.5)) +
labs(title = "Pitch Movement", x = "Horizontal Break (in)", y = "Induced Vertical Break (in)") +
.pitch_theme +
theme(legend.position = "none")
}
create_pitcher_location_plot <- function(game_data, pitch_colors) {
df <- game_data %>% filter(!is.na(TaggedPitchType), TaggedPitchType != "Other")
if (!nrow(df)) {
return(
ggplot() +
annotate("rect", xmin = -0.8303, xmax = 0.8303, ymin = 1.5, ymax = 3.3775,
alpha = 0, size = .5, color = "black") +
theme_void() + ggtitle("Pitch Locations") + theme(plot.title = element_text(size=16, face="bold", hjust=.5))
)
}
ggplot2::ggplot(df, ggplot2::aes(PlateLocSide, PlateLocHeight)) +
ggplot2::annotate("rect", xmin = -0.8303, xmax = 0.8303, ymin = 1.5, ymax = 3.3775,
alpha = 0, size = .5, color = "black") +
ggplot2::annotate("segment", x = -0.708, y = 0.15, xend = 0.708, yend = 0.15, size = .5, color = "black") +
ggplot2::annotate("segment", x = -0.708, y = 0.30, xend = -0.708, yend = 0.15, size = .5, color = "black") +
ggplot2::annotate("segment", x = 0.708, y = 0.30, xend = 0.708, yend = 0.15, size = .5, color = "black") +
ggplot2::annotate("segment", x = -0.708, y = 0.30, xend = 0.000, yend = 0.50, size = .5, color = "black") +
ggplot2::annotate("segment", x = 0.708, y = 0.30, xend = 0.000, yend = 0.50, size = .5, color = "black") +
ggplot2::geom_point(ggplot2::aes(fill = TaggedPitchType),
alpha = 0.95, shape = 21, color = "black", stroke = 0.4, size = 4) +
scale_fill_manual(values = pitch_colors, name = "Pitch Type") +
coord_fixed(xlim = c(-2, 2), ylim = c(0, 4)) +
labs(title = "Pitch Locations", x = NULL, y = NULL) +
theme_void() +
theme(
plot.title = element_text(size = 12, face = "bold", hjust = .5),
legend.position = "none",
plot.margin = margin(6, 6, 6, 6)
)
}
create_pitcher_release_plot <- function(game_data, pitch_colors) {
df <- game_data %>% filter(!is.na(RelSide), !is.na(RelHeight), TaggedPitchType != "Other")
if (!nrow(df)) return(ggplot() + theme_void() + ggtitle("Release Points") + theme(plot.title = element_text(size=16, face="bold", hjust=.5)))
avg_release <- df %>%
group_by(TaggedPitchType) %>%
summarise(RelSide = mean(RelSide, na.rm = TRUE), RelHeight = mean(RelHeight, na.rm = TRUE), .groups = "drop")
ggplot() +
geom_point(data = df, aes(RelSide, RelHeight, fill = TaggedPitchType),
size = 4, shape = 21, color = "black", alpha = 0.85, stroke = 0.25) +
geom_point(data = avg_release, aes(RelSide, RelHeight, fill = TaggedPitchType),
size = 4.5, shape = 21, color = "black", stroke = 0.3, alpha = 1) +
annotate("text", x = -5, y = 8, label = "← 1B", size = 3, hjust = 0) +
annotate("text", x = 5, y = 8, label = "3B →", size = 3, hjust = 1) +
geom_rect(aes(xmin = -5, xmax = 5, ymin = 0, ymax = 0.83), fill = "#632b11", inherit.aes = FALSE) +
geom_rect(aes(xmin = -0.5, xmax = 0.5, ymin = 0.8, ymax = 0.95),
fill = "white", color = "black", linewidth = 0.4, inherit.aes = FALSE) +
scale_fill_manual(values = pitch_colors, name = "Pitch Type") +
coord_cartesian(xlim = c(-5, 5), ylim = c(0, 8)) +
labs(title = "Release Points", x = "Release Side (ft)", y = "Release Height (ft)") +
.pitch_theme +
theme(legend.position = "none")
}
create_relside_height_plot <- function(data, player_name, pitch_colors) {
data <- normalize_columns(data)
pitcher_data <- data %>%
filter(Pitcher == player_name,
!is.na(TaggedPitchType),
TaggedPitchType != "Other",
!is.na(RelSide),
!is.na(RelHeight))
if (nrow(pitcher_data) == 0) {
return(ggplot() + theme_void() + ggtitle("Release Side vs Release Height") +
theme(plot.title = element_text(size = 12, face = "bold", hjust = 0.5)))
}
avg_release <- pitcher_data %>%
group_by(TaggedPitchType) %>%
summarise(
RelSide = mean(RelSide, na.rm = TRUE),
RelHeight = mean(RelHeight, na.rm = TRUE),
.groups = "drop"
)
ggplot(pitcher_data, aes(RelSide, RelHeight)) +
geom_point(aes(fill = TaggedPitchType),
size = 4, shape = 21, color = "black", alpha = 0.85, stroke = 0.25) +
geom_point(data = avg_release,
aes(RelSide, RelHeight, fill = TaggedPitchType),
size = 4.5, shape = 21, color = "black", stroke = 0.3, alpha = 1) +
annotate("text", x = -4.7, y = 8, label = "\u2190 3B", size = 3, hjust = 0) +
annotate("text", x = 4.7, y = 8, label = "1B \u2192", size = 3, hjust = 1) +
geom_rect(aes(xmin = -3.5, xmax = 3.5, ymin = 0, ymax = 0.83),
fill = "#632b11", inherit.aes = FALSE) +
geom_rect(aes(xmin = -0.7, xmax = 0.7, ymin = 0.8, ymax = 0.95),
fill = "white", color = "black", linewidth = 0.4, inherit.aes = FALSE) +
scale_fill_manual(values = pitch_colors, name = "Pitch Type") +
coord_cartesian(xlim = c(-4.3, 4.3), ylim = c(0, 9)) +
labs(title = "Release Height + Release Side",
x = "Release Side (ft)", y = "Release Height (ft)") +
theme_minimal() +
theme(plot.title = element_text(hjust = 0.5, size = 14, face = "bold"),
legend.position = "none")
}
create_pitcher_pdf <- function(game_df, pitcher_name, output_file, pitch_colors) {
ensure_cols <- function(df, cols) {
if (is.null(df)) df <- data.frame()
for (c in cols) if (!c %in% names(df)) df[[c]] <- NA
if (!ncol(df)) df <- as.data.frame(setNames(replicate(length(cols), character(0), simplify = FALSE), cols))
df[, cols, drop = FALSE]
}
draw_simple_table <- function(df, y_top,
col_headers = colnames(df),
col_widths = NULL,
row_height = 0.018,
header_bg = "#006F71",
header_cex = 0.60,
cell_cex = 0.58,
header_fg = "white",
zebra = TRUE) {
if (is.null(df) || !ncol(df)) {
grid::grid.text("No data", y = y_top - 0.012, gp = grid::gpar(col = "red"))
return(invisible(NULL))
}
df[] <- lapply(df, function(x) ifelse(is.na(x), "", as.character(x)))
if (is.null(col_headers) || length(col_headers) != ncol(df)) col_headers <- colnames(df)
if (is.null(col_widths)) col_widths <- rep(1 / ncol(df), ncol(df))
x_start <- 0.5 - sum(col_widths) / 2
x_pos <- c(x_start, x_start + cumsum(col_widths[-length(col_widths)]))
for (i in seq_along(col_headers)) {
grid::grid.rect(x = x_pos[i], y = y_top, width = col_widths[i] * 0.985, height = row_height,
just = c("left", "top"),
gp = grid::gpar(fill = header_bg, col = "black", lwd = 0.5))
grid::grid.text(col_headers[i],
x = x_pos[i] + col_widths[i] * 0.49,
y = y_top - row_height * 0.5,
gp = grid::gpar(col = header_fg, cex = header_cex, fontface = "bold"))
}
if (nrow(df) == 0) return(invisible(NULL))
for (r in seq_len(nrow(df))) {
y_row <- y_top - r * row_height
for (i in seq_along(col_headers)) {
val <- df[[i]][r]
bg <- if (zebra && (r %% 2 == 0)) "#f7f7f7" else "white"
grid::grid.rect(x = x_pos[i], y = y_row, width = col_widths[i] * 0.985, height = row_height,
just = c("left", "top"),
gp = grid::gpar(fill = bg, col = "grey80", lwd = 0.3))
grid::grid.text(val,
x = x_pos[i] + col_widths[i] * 0.49,
y = y_row - row_height * 0.5,
gp = grid::gpar(cex = cell_cex))
}
}
}
draw_boxed_shared_legend <- function(labels, colors,
x_center = 0.5, y_top = 0.755,
n_cols = 6,
cell_height = 0.036,
box_width = 0.82,
title = "Pitch Type",
pad_h = 0.016, pad_v = 0.018,
border_col = "#bfbfbf",
cex_labels = 0.82) {
labels <- as.character(labels)
colors <- as.character(colors)
if (!length(labels)) {
grid::grid.text("No legend (no pitch types)", x = x_center, y = y_top - 0.02)
return(invisible(NULL))
}
n_cols <- max(1L, min(n_cols, length(labels)))
n_items <- length(labels)
n_rows <- ceiling(n_items / n_cols)
title_h <- cell_height * 0.9
box_h <- pad_v + title_h + (n_rows * cell_height) + pad_v
box_w <- box_width
grid::grid.rect(x = x_center, y = y_top - box_h/2,
width = box_w, height = box_h,
just = c("center", "center"),
gp = grid::gpar(fill = "white", col = border_col, lwd = 0.8))
grid::grid.text(title,
x = x_center,
y = y_top - pad_v,
just = c("center", "top"),
gp = grid::gpar(fontface = "bold", cex = 0.92, col = "#006F71"))
inner_top_y <- y_top - pad_v - title_h
grid::pushViewport(
grid::viewport(
x = x_center,
y = inner_top_y - ((n_rows * cell_height)/2),
width = box_w - 2*pad_h,
height = n_rows * cell_height,
just = c("center","center"),
layout = grid::grid.layout(nrow = n_rows, ncol = n_cols)
)
)
idx <- 1L
for (r in seq_len(n_rows)) {
for (c in seq_len(n_cols)) {
if (idx > n_items) break
lab <- labels[idx]
col <- colors[idx]
grid::pushViewport(grid::viewport(layout.pos.row = r, layout.pos.col = c))
grid::pushViewport(grid::viewport(
layout = grid::grid.layout(
nrow = 1, ncol = 2,
widths = grid::unit.c(grid::unit(0.40, "npc"), grid::unit(0.60, "npc"))
)
))
grid::pushViewport(grid::viewport(layout.pos.row = 1, layout.pos.col = 1))
grid::grid.rect(x = 0.5, y = 0.5,
width = grid::unit(0.55, "npc"),
height = grid::unit(0.55, "npc"),
just = c("center","center"),
gp = grid::gpar(fill = col, col = "black", lwd = 0.4))
grid::popViewport()
grid::pushViewport(grid::viewport(layout.pos.row = 1, layout.pos.col = 2))
grid::grid.text(lab, x = 0.5, y = 0.5, just = c("center","center"),
gp = grid::gpar(cex = cex_labels))
grid::popViewport()
grid::popViewport()
grid::popViewport()
idx <- idx + 1L
}
}
grid::popViewport()
}
safe_blank_plot <- function(title_txt) {
ggplot2::ggplot() + ggplot2::theme_void() +
ggplot2::ggtitle(title_txt) +
ggplot2::theme(plot.title = ggplot2::element_text(hjust = 0.5, face = "bold"))
}
dfp <- game_df
if (!"Pitcher" %in% names(dfp)) {
alt <- intersect(c("PitcherName","pitcher","Pitcher_LastFirst","PlayerName"), names(dfp))
if (length(alt)) dfp$Pitcher <- dfp[[alt[1]]] else dfp$Pitcher <- NA_character_
}
dfp <- dfp %>%
dplyr::mutate(Pitcher = stringr::str_replace(coalesce(Pitcher, ""),
"^\\s*(\\w+)\\s*,\\s*(\\w+)\\s*$", "\\2 \\1")) %>%
dplyr::filter(Pitcher == pitcher_name)
if (is.null(dfp) || nrow(dfp) == 0) {
grDevices::pdf(output_file, width = 11, height = 8.5)
on.exit(try(grDevices::dev.off(), silent = TRUE), add = TRUE)
grid::grid.newpage()
grid::grid.text(paste("No data for", pitcher_name),
x = 0.5, y = 0.5,
gp = grid::gpar(fontface = "bold", cex = 1.6, col = "#006F71"))
return(invisible(output_file))
}
game_day <- tryCatch(parse_game_day(dfp), error = function(e) Sys.Date())
game_headers <- c("Date","BF","K","BB","HBP","H","XBH","Strike %","Whiff %")
game_widths <- c(0.13,0.07,0.06,0.06,0.07,0.06,0.07,0.11,0.11)
char_headers <- c("Pitch","Total","Avg Velo","Max Velo","Avg Spin","Max Spin",
"Avg IVB","Avg HB","RelHt","Ext","Strike %","Whiff %")
char_widths <- c(0.12,0.07,0.09,0.09,0.09,0.09,0.085,0.085,0.07,0.07,0.085,0.085)
game_line_df <- tryCatch(create_pitcher_game_line(dfp), error = function(e) data.frame())
game_line_df <- ensure_cols(game_line_df, game_headers)
pitch_char_df <- tryCatch(create_pitcher_pitch_char(dfp), error = function(e) data.frame())
pitch_char_df <- ensure_cols(pitch_char_df, char_headers)
present_types <- dfp %>%
dplyr::filter(!is.na(TaggedPitchType), TaggedPitchType != "Other") %>%
dplyr::count(TaggedPitchType, name = "N") %>%
dplyr::arrange(dplyr::desc(N), TaggedPitchType)
legend_labels <- present_types$TaggedPitchType
legend_labels <- legend_labels[legend_labels %in% names(pitch_colors)]
legend_colors <- unname(pitch_colors[legend_labels])
movement_plot <- tryCatch(
create_pitcher_movement_plot(dfp, pitcher_name, pitch_colors) + ggplot2::theme(legend.position = "none"),
error = function(e) safe_blank_plot("Pitch Movement")
)
location_plot <- tryCatch(
create_pitcher_location_plot(dfp, pitch_colors) + ggplot2::theme(legend.position = "none"),
error = function(e) safe_blank_plot("Pitch Locations")
)
release_plot <- tryCatch(
create_pitcher_release_plot(dfp, pitch_colors) + ggplot2::theme(legend.position = "none"),
error = function(e) safe_blank_plot("Release Points")
)
grDevices::pdf(output_file, width = 11, height = 8.5)
on.exit(try(grDevices::dev.off(), silent = TRUE), add = TRUE)
grid::grid.newpage()
grid::pushViewport(grid::viewport(x = 0.5, y = 0.965, width = 1, height = 0.10, just = c("center","top")))
grid::grid.text(paste(pitcher_name, "- Pitcher Report -", format(game_day, "%m/%d/%y")),
gp = grid::gpar(fontface = "bold", cex = 1.9, col = "#006F71"))
grid::popViewport()
grid::grid.text("Game Line", x = 0.5, y = 0.86,
gp = grid::gpar(fontface = "bold", cex = 1.05, col = "#006F71"))
try(draw_simple_table(
df = game_line_df,
y_top = 0.84,
col_headers = game_headers,
col_widths = game_widths,
row_height = 0.027,
header_cex = 0.72,
cell_cex = 0.68
), silent = TRUE)
grid::grid.text("Pitch Characteristics", x = 0.5, y = 0.76,
gp = grid::gpar(fontface = "bold", cex = 1.05, col = "#006F71"))
try(draw_simple_table(
df = pitch_char_df,
y_top = 0.74,
col_headers = char_headers,
col_widths = char_widths,
row_height = 0.024,
header_cex = 0.66,
cell_cex = 0.62
), silent = TRUE)
try(draw_boxed_shared_legend(
labels = legend_labels,
colors = legend_colors,
x_center = 0.5,
y_top = 0.58,
n_cols = 6,
cell_height = 0.036,
box_width = 0.82,
title = "Pitch Type",
pad_h = 0.016,
pad_v = 0.018,
border_col = "#bfbfbf",
cex_labels = 0.82
), silent = TRUE)
grid::pushViewport(grid::viewport(x = 0.18, y = 0.45, width = 0.32, height = 0.40, just = c("center","top")))
try(print(movement_plot, newpage = FALSE), silent = TRUE); grid::popViewport()
grid::pushViewport(grid::viewport(x = 0.50, y = 0.45, width = 0.32, height = 0.40, just = c("center","top")))
try(print(location_plot, newpage = FALSE), silent = TRUE); grid::popViewport()
grid::pushViewport(grid::viewport(x = 0.82, y = 0.45, width = 0.32, height = 0.40, just = c("center","top")))
try(print(release_plot, newpage = FALSE), silent = TRUE); grid::popViewport()
invisible(output_file)
}
umpire_process_data <- function(df) {
df <- df %>%
filter(PitchCall %in% c("StrikeCalled", "BallCalled", "BallinDirt"))
if ("Date" %in% names(df)) {
df$Date <- parse_flexible_date(df$Date)
}
if ("PlateLocSide" %in% names(df)) df$PlateLocSide <- as.numeric(df$PlateLocSide)
if ("PlateLocHeight" %in% names(df)) df$PlateLocHeight <- as.numeric(df$PlateLocHeight)
df
}
umpire_create_report_pdf <- function(data,
output_file,
left_logo_path = NULL,
right_logo_path = NULL,
matchup_title = NULL,
umpire_name = NULL,
rows_per_page = 30) {
suppressPackageStartupMessages({
library(dplyr); library(grid); library(gridExtra); library(ggplot2); library(stringr)
})
`%||%` <- function(a, b) if (!is.null(a)) a else b
raw_strike_miss <- data %>%
filter(PitchCall == "StrikeCalled") %>%
filter(PlateLocSide < -0.83083 | PlateLocSide > 0.83083 |
PlateLocHeight > 3.37750 | PlateLocHeight < 1.5) %>% nrow()
raw_ball_miss <- data %>%
filter(PitchCall %in% c("BallCalled", "BallinDirt")) %>%
filter(PlateLocSide > -0.83083 & PlateLocSide < 0.83083 &
PlateLocHeight < 3.37750 & PlateLocHeight > 1.5) %>% nrow()
total_called <- nrow(data)
total_missed <- raw_strike_miss + raw_ball_miss
correct <- total_called - total_missed
overall_pct <- paste0(sprintf("%.0f", 100 * (correct / total_called)), "%")
buffer_strike_miss <- data %>%
filter(PitchCall == "StrikeCalled") %>%
filter(PlateLocSide < -0.9975 | PlateLocSide > 0.9975 |
PlateLocHeight > 3.5 | PlateLocHeight < 1.3775) %>% nrow()
buffer_total_missed <- raw_ball_miss + buffer_strike_miss
buffer_correct <- total_called - buffer_total_missed
buffer_pct <- paste0(sprintf("%.0f", 100 * (buffer_correct / total_called)), "%")
game_date <- suppressWarnings(format(max(as.Date(data$Date)), "%b %d, %Y"))
opp_team <- data %>% filter(BatterTeam != "COA_CHA") %>% pull(BatterTeam) %>% unique() %>% head(1)
if (length(opp_team) == 0 || is.na(opp_team)) opp_team <- "Opponent"
title_text <- matchup_title %||% sprintf("%s vs Coastal Carolina", opp_team)
subhead_text <- if (!is.null(umpire_name) && !is.na(umpire_name) && nzchar(umpire_name)) {
paste("Umpire Report —", umpire_name)
} else "Umpire Report"
strike_zone_rect <- data.frame(xmin = -0.83083, xmax = 0.83083, ymin = 1.5, ymax = 3.37750)
buffer_zone_rect <- data.frame(xmin = -0.9975, xmax = 0.9975, ymin = 1.3775, ymax = 3.5)
home_plate <- data.frame(
x = c(-0.708, 0.708, 0.708, 0.000, -0.708),
y = c( 0.150, 0.150, 0.300, 0.500, 0.300)
)
# ---------------------------------------------------------------
# BUILD MISSED CALLS TABLE EARLY (before plots) so we can number them
# ---------------------------------------------------------------
MissedCalls <- dplyr::bind_rows(
data %>% filter(PitchCall %in% c("BallCalled", "BallinDirt"),
PlateLocSide > -0.83083, PlateLocSide < 0.83083,
PlateLocHeight < 3.37750, PlateLocHeight > 1.5),
data %>% filter(PitchCall == "StrikeCalled") %>%
filter(PlateLocSide < -0.83083 | PlateLocSide > 0.83083 |
PlateLocHeight > 3.37750 | PlateLocHeight < 1.5)
) %>%
arrange(PitchNo) %>%
mutate(CallNo = row_number()) %>%
mutate(
Side = paste0(sprintf("%.0f", abs(PlateLocSide * 12)), '"'),
Height = paste0(sprintf("%.0f", PlateLocHeight * 12), '"')
)
# Keep PlateLocSide/PlateLocHeight/BatterSide/BatterTeam available for plot filtering
# but select display columns for the table at the end
# ---------------------------------------------------------------
# BASE ZONE HELPER
# ---------------------------------------------------------------
base_zone <- function() {
ggplot() +
geom_rect(data = buffer_zone_rect,
aes(xmin = xmin, xmax = xmax, ymin = ymin, ymax = ymax),
fill = NA, color = "gray50", linewidth = 0.6, linetype = "dotted") +
geom_rect(data = strike_zone_rect,
aes(xmin = xmin, xmax = xmax, ymin = ymin, ymax = ymax),
fill = NA, color = "black", linewidth = 0.8) +
geom_polygon(data = home_plate, aes(x = x, y = y),
fill = NA, color = "gray40", linewidth = 0.5) +
coord_equal() +
scale_x_continuous(limits = c(-1.8, 1.8)) +
scale_y_continuous(limits = c(0, 4.5)) +
theme_classic() +
theme(
axis.title = element_blank(),
axis.text = element_blank(),
axis.ticks = element_blank(),
axis.line = element_blank(),
panel.grid = element_blank(),
legend.position = "none",
plot.title = element_text(hjust = 0.5, size = 8, face = "bold"),
plot.margin = margin(2, 2, 2, 2)
)
}
# ---------------------------------------------------------------
# PLOT HELPERS — now use MissedCalls with CallNo labels
# ---------------------------------------------------------------
umpire_create_ball_plot <- function(mc, side_label, title_label) {
pts <- mc %>%
filter(BatterSide == side_label,
PitchCall %in% c("BallCalled", "BallinDirt"),
PlateLocSide > -0.83083, PlateLocSide < 0.83083,
PlateLocHeight < 3.37750, PlateLocHeight > 1.5)
base_zone() +
geom_point(data = pts, aes(x = PlateLocSide, y = PlateLocHeight),
pch = 21, fill = "#006F71", color = "black", size = 5) +
geom_text(data = pts, aes(x = PlateLocSide, y = PlateLocHeight, label = CallNo),
color = "white", size = 2.2, fontface = "bold") +
ggtitle(title_label)
}
umpire_create_strike_plot <- function(mc, side_label, title_label) {
pts <- mc %>%
filter(BatterSide == side_label, PitchCall == "StrikeCalled") %>%
filter(PlateLocSide < -0.83083 | PlateLocSide > 0.83083 |
PlateLocHeight > 3.37750 | PlateLocHeight < 1.5)
base_zone() +
geom_point(data = pts, aes(x = PlateLocSide, y = PlateLocHeight),
pch = 21, fill = "#006F71", color = "black", size = 5) +
geom_text(data = pts, aes(x = PlateLocSide, y = PlateLocHeight, label = CallNo),
color = "white", size = 2.2, fontface = "bold") +
ggtitle(title_label)
}
umpire_create_ball_plot_team <- function(mc, is_ccu, title_label) {
pts <- mc %>%
filter(if (is_ccu) BatterTeam == "COA_CHA" else BatterTeam != "COA_CHA",
PitchCall %in% c("BallCalled", "BallinDirt"),
PlateLocSide > -0.83083, PlateLocSide < 0.83083,
PlateLocHeight < 3.37750, PlateLocHeight > 1.5)
base_zone() +
geom_point(data = pts, aes(x = PlateLocSide, y = PlateLocHeight),
pch = 21, fill = "#006F71", color = "black", size = 5) +
geom_text(data = pts, aes(x = PlateLocSide, y = PlateLocHeight, label = CallNo),
color = "white", size = 2.2, fontface = "bold") +
ggtitle(title_label)
}
umpire_create_strike_plot_team <- function(mc, is_ccu, title_label) {
pts <- mc %>%
filter(if (is_ccu) BatterTeam == "COA_CHA" else BatterTeam != "COA_CHA",
PitchCall == "StrikeCalled") %>%
filter(PlateLocSide < -0.83083 | PlateLocSide > 0.83083 |
PlateLocHeight > 3.37750 | PlateLocHeight < 1.5)
base_zone() +
geom_point(data = pts, aes(x = PlateLocSide, y = PlateLocHeight),
pch = 21, fill = "#006F71", color = "black", size = 5) +
geom_text(data = pts, aes(x = PlateLocSide, y = PlateLocHeight, label = CallNo),
color = "white", size = 2.2, fontface = "bold") +
ggtitle(title_label)
}
# ---------------------------------------------------------------
# CREATE ALL PLOTS (pass MissedCalls instead of data)
# ---------------------------------------------------------------
plot_ball_lhb <- umpire_create_ball_plot(MissedCalls, "Left", "Ball Called v LHB")
plot_ball_rhb <- umpire_create_ball_plot(MissedCalls, "Right", "Ball Called v RHB")
plot_strike_lhb <- umpire_create_strike_plot(MissedCalls, "Left", "Strike Called v LHB")
plot_strike_rhb <- umpire_create_strike_plot(MissedCalls, "Right", "Strike Called v RHB")
plot_ball_ccu <- umpire_create_ball_plot_team(MissedCalls, TRUE, "Ball Called v CCU Hitters")
plot_ball_opp <- umpire_create_ball_plot_team(MissedCalls, FALSE, "Ball Called v Opp Hitters")
plot_strike_ccu <- umpire_create_strike_plot_team(MissedCalls, TRUE, "Strike Called v CCU Hitters")
plot_strike_opp <- umpire_create_strike_plot_team(MissedCalls, FALSE, "Strike Called v Opp Hitters")
# ---------------------------------------------------------------
# PREPARE DISPLAY TABLE (select only display columns, CallNo first)
# ---------------------------------------------------------------
MissedCallsDisplay <- MissedCalls %>%
select(dplyr::any_of(c("CallNo","PitchNo","Inning","Top/Bottom","TopBottom",
"Batter","PitchCall","Side","Height","BatterTeam")))
green <- "#006F71"
ttheme_green <- gridExtra::ttheme_minimal(
core = list(fg_params = list(hjust = 0.5, x = 0.5, fontsize = 8),
bg_params = list(fill = "white")),
colhead = list(fg_params = list(hjust = 0.5, x = 0.5, col = "white", fontsize = 8, fontface = "bold"),
bg_params = list(fill = green))
)
ttheme_green_small <- gridExtra::ttheme_minimal(
core = list(fg_params = list(hjust = 0.5, x = 0.5, fontsize = 6.5),
bg_params = list(fill = "white")),
colhead = list(fg_params = list(hjust = 0.5, x = 0.5, col = "white", fontsize = 7, fontface = "bold"),
bg_params = list(fill = green))
)
raw_table <- data.frame(
"Strikes Missed" = raw_strike_miss,
"Balls Missed" = raw_ball_miss,
"Called" = total_called,
"Missed" = total_missed,
"Overall %" = overall_pct,
check.names = FALSE
)
buffer_table <- data.frame(
"Strikes Missed" = buffer_strike_miss,
"Called" = total_called,
"Missed" = buffer_total_missed,
"Overall %" = buffer_pct,
check.names = FALSE
)
draw_header <- function() {
suppressPackageStartupMessages({
library(grid)
library(magick)
})
draw_logo_url <- function(url, x, just) {
img <- try(
magick::image_read(url),
silent = TRUE
)
if (inherits(img, "try-error")) return(NULL)
img <- magick::image_resize(img, "x130")
grid.draw(
grid::rasterGrob(
as.raster(img),
interpolate = TRUE,
vp = viewport(
x = x,
y = 0.96,
width = 0.13,
height = 0.08,
just = c(just, "center")
)
)
)
}
## --- EMBEDDED LOGO LINKS ---
left_logo_url <- "https://i.imgur.com/zjTu3JS.png"
right_logo_url <- "https://i.ibb.co/Q3kFXXd9/8acd1b8a-7920-403a-8e9d-86742634effb.png"
draw_logo_url(left_logo_url, 0.05, "left")
draw_logo_url(right_logo_url, 0.95, "right")
## --- HEADER TEXT ---
grid.text(
title_text,
y = 0.975,
gp = gpar(fontsize = 16, fontface = "bold")
)
grid.text(
subhead_text,
y = 0.948,
gp = gpar(fontsize = 12, fontface = "bold")
)
if (!is.na(game_date)) {
grid.text(
game_date,
y = 0.925,
gp = gpar(fontsize = 9)
)
}
}
grDevices::pdf(output_file, width = 8.5, height = 11)
grid.newpage()
draw_header()
pushViewport(viewport(x = 0.5, y = 0.885, width = 0.70, height = 0.045))
grid.table(raw_table, rows = NULL, theme = ttheme_green)
popViewport()
pushViewport(viewport(x = 0.28, y = 0.70, width = 0.50, height = 0.30)); print(plot_ball_lhb, newpage = FALSE); popViewport()
pushViewport(viewport(x = 0.72, y = 0.70, width = 0.50, height = 0.30)); print(plot_ball_rhb, newpage = FALSE); popViewport()
pushViewport(viewport(x = 0.28, y = 0.48, width = 0.50, height = 0.30)); print(plot_strike_lhb, newpage = FALSE); popViewport()
pushViewport(viewport(x = 0.72, y = 0.48, width = 0.50, height = 0.30)); print(plot_strike_rhb, newpage = FALSE); popViewport()
grid.text("Adjusted Score", y = 0.25, gp = gpar(fontsize = 11, fontface = "bold"))
pushViewport(viewport(x = 0.5, y = 0.215, width = 0.58, height = 0.045))
grid.table(buffer_table, rows = NULL, theme = ttheme_green)
popViewport()
grid.newpage()
grid::grid.text("Data: TrackMan | Report Generated: Coastal Carolina Baseball Analytics",
x = 0.5, y = 0.02, gp = grid::gpar(cex = 0.75, col = "grey50"))
pushViewport(viewport(x = 0.28, y = 0.81, width = 0.47, height = 0.27)); print(plot_ball_ccu, newpage = FALSE); popViewport()
pushViewport(viewport(x = 0.72, y = 0.81, width = 0.47, height = 0.27)); print(plot_ball_opp, newpage = FALSE); popViewport()
pushViewport(viewport(x = 0.28, y = 0.60, width = 0.47, height = 0.27)); print(plot_strike_ccu, newpage = FALSE); popViewport()
pushViewport(viewport(x = 0.72, y = 0.60, width = 0.47, height = 0.27)); print(plot_strike_opp, newpage = FALSE); popViewport()
if (nrow(MissedCallsDisplay) > 0) {
# First page can fit 15 rows in the bottom half
first_page_rows <- 15
remaining_page_rows <- rows_per_page # 30 rows per full page
if (nrow(MissedCallsDisplay) <= first_page_rows) {
# Fits on current page
pushViewport(viewport(x = 0.5, y = 0.30, width = 0.92, height = 0.50))
grid.table(MissedCallsDisplay, rows = NULL, theme = ttheme_green_small)
popViewport()
} else {
# First batch on current page
first_chunk <- MissedCallsDisplay[1:first_page_rows, , drop = FALSE]
pushViewport(viewport(x = 0.5, y = 0.30, width = 0.92, height = 0.50))
grid.table(first_chunk, rows = NULL, theme = ttheme_green_small)
popViewport()
# Remaining rows paginated onto new pages
remaining <- MissedCallsDisplay[(first_page_rows + 1):nrow(MissedCallsDisplay), , drop = FALSE]
remaining_chunks <- split(remaining, ceiling(seq_len(nrow(remaining)) / remaining_page_rows))
for (i in seq_along(remaining_chunks)) {
grid.newpage()
grid.text("Missed Calls (continued)", x = 0.5, y = 0.96,
gp = gpar(fontsize = 12, fontface = "bold", col = "#006F71"))
pushViewport(viewport(x = 0.5, y = 0.75, width = 0.92, height = 0.88))
grid.table(remaining_chunks[[i]], rows = NULL, theme = ttheme_green_small)
popViewport()
}
}
}
grDevices::dev.off()
invisible(output_file)
}
# Advanced Pitcher Functions
`%||%` <- function(a, b) if (!is.null(a)) a else b
safe_color_at <- function(mat, r, c, default = "#FFFFFF") {
if (is.null(mat) || is.null(dim(mat))) return(default)
nr <- nrow(mat); nc <- ncol(mat)
if (length(r) != 1 || length(c) != 1) return(default)
if (is.na(r) || is.na(c)) return(default)
if (r < 1 || c < 1 || r > nr || c > nc) return(default)
mat[r, c]
}
sync_color_matrix_to_df <- function(colmat, df, fill = "#FFFFFF") {
nr <- max(1, nrow(df)); nc <- max(1, ncol(df))
out <- matrix(fill, nrow = nr, ncol = nc)
if (!is.null(colmat) && !is.null(dim(colmat))) {
r_take <- min(nrow(colmat), nr)
c_take <- min(ncol(colmat), nc)
out[seq_len(r_take), seq_len(c_take)] <- colmat[seq_len(r_take), seq_len(c_take), drop = FALSE]
}
out
}
has_col_index <- function(idx) {
is.numeric(idx) && length(idx) == 1 && !is.na(idx) && is.finite(idx) && idx >= 1
}
reference_data_for_stuff <- tryCatch({
message("Loading reference data for Stuff+ standardization...")
ref_list <- list(
spring = arrow::read_parquet("CCUPitcher25.parquet"),
p5 = arrow::read_parquet("P5_2025.parquet"),
sbc = arrow::read_parquet("SBC_2025.parquet")
)
# FIX: Convert all potentially mismatched columns to character to prevent bind_rows errors
convert_problem_cols <- function(df) {
# Date columns
if ("Date" %in% names(df)) df$Date <- as.character(df$Date)
if ("UTCDate" %in% names(df)) df$UTCDate <- as.character(df$UTCDate)
if ("UTCDateTime" %in% names(df)) df$UTCDateTime <- as.character(df$UTCDateTime)
if ("LocalDateTime" %in% names(df)) df$LocalDateTime <- as.character(df$LocalDateTime)
# ID columns that may have mixed types
if ("HomeTeamForeignID" %in% names(df)) df$HomeTeamForeignID <- as.character(df$HomeTeamForeignID)
if ("AwayTeamForeignID" %in% names(df)) df$AwayTeamForeignID <- as.character(df$AwayTeamForeignID)
if ("GameUID" %in% names(df)) df$GameUID <- as.character(df$GameUID)
if ("PitchUID" %in% names(df)) df$PitchUID <- as.character(df$PitchUID)
if ("PlayID" %in% names(df)) df$PlayID <- as.character(df$PlayID)
df
}
ref_list$spring <- convert_problem_cols(ref_list$spring)
ref_list$p5 <- convert_problem_cols(ref_list$p5)
ref_list$sbc <- convert_problem_cols(ref_list$sbc)
ref_list
}, error = function(e) {
message("Reference data files not found. Stuff+ will use local standardization.")
message("ERROR: ", e$message)
NULL
})
preflight_predictor_check <- function(model, newdata) {
if (!inherits(model, "workflow")) return(invisible(NULL))
rec <- try(workflows::extract_recipe(model), silent = TRUE)
if (inherits(rec, "try-error") || is.null(rec)) return(invisible(NULL))
s <- try(summary(rec), silent = TRUE)
if (inherits(s, "try-error") || is.null(s)) return(invisible(NULL))
needed <- s$variable[s$role == "predictor"]
missing <- setdiff(needed, names(newdata))
if (length(missing)) {
message("Stuff+ missing RAW predictors (recipe roles): ", paste(missing, collapse = ", "))
}
invisible(NULL)
}
ensure_stuff_inputs <- function(df) {
df <- df %>%
mutate(RelSide = case_when(
PitcherThrows == "Right" ~ RelSide,
PitcherThrows == "Left" ~ -RelSide,
PitcherThrows %in% c("Both", "Undefined") & RelSide > 0 ~ RelSide,
PitcherThrows %in% c("Both", "Undefined") & RelSide < 0 ~ -RelSide),
ax0 = case_when(
PitcherThrows == "Right" ~ ax0,
PitcherThrows == "Left" ~ -ax0,
PitcherThrows %in% c("Both", "Undefined") & ax0 > 0 ~ ax0,
PitcherThrows %in% c("Both", "Undefined") & ax0 < 0 ~ -ax0),
PlateLocHeight = PlateLocHeight*12,
PlateLocSide = PlateLocSide*12,
ax0 = -ax0) %>%
group_by(Pitcher, GameID) %>%
mutate(
primary_pitch = case_when(
any(TaggedPitchType == "Fastball") ~ "Fastball",
any(TaggedPitchType == "Sinker") ~ "Sinker",
TRUE ~ names(sort(table(TaggedPitchType), decreasing = TRUE))[1]
)
) %>%
group_by(Pitcher, GameID, primary_pitch) %>%
mutate(
primary_az0 = mean(az0[TaggedPitchType == primary_pitch], na.rm = TRUE),
primary_velo = mean(RelSpeed[TaggedPitchType == primary_pitch], na.rm = TRUE)
) %>%
ungroup() %>%
mutate(az0_diff = az0 - primary_az0,
velo_diff = RelSpeed - primary_velo)
df
}
standardize_stuffplus_to_league <- function(data, league_comparison_data) {
data <- ensure_stuff_inputs(data)
league_comparison_data <- ensure_stuff_inputs(league_comparison_data)
common_cols <- intersect(names(data), names(league_comparison_data))
for (col in common_cols) {
type1 <- class(data[[col]])[1]
type2 <- class(league_comparison_data[[col]])[1]
if (type1 != type2) {
message("Converting mismatched column '", col, "': ", type1, " vs ", type2)
data[[col]] <- as.character(data[[col]])
league_comparison_data[[col]] <- as.character(league_comparison_data[[col]])
}
}
df_processed <- bake(stuffplus_recipe, new_data = data)
df_matrix <- as.matrix(df_processed)
data$raw_stuff <- predict(stuffplus_model, df_matrix)
data <- data %>%
mutate(data_ind = 1)
df_processed <- bake(stuffplus_recipe, new_data = league_comparison_data)
df_matrix <- as.matrix(df_processed)
league_comparison_data$raw_stuff <- predict(stuffplus_model, df_matrix)
league_comparison_data <- league_comparison_data %>%
mutate(data_ind = 0)
stuff_df <- bind_rows(data, league_comparison_data)
stuff_df <- stuff_df %>%
mutate(stuff_plus = ((raw_stuff - mean(raw_stuff, na.rm = TRUE)) / sd(raw_stuff, na.rm = TRUE)) * 10 + 100) %>%
filter(data_ind == 1) %>%
dplyr::select(-data_ind)
return(stuff_df)
}
sec_averages <- list(
overall = list(
chase = 26.2, k_rate = 26.4, bb_rate = 10, iz_whiff = 20, miss_rate = 28.9,
fb_velo_l = 91.1, fb_velo_r = 93, strike_rate = 62.6, zone_rate = 46
),
fb_sinker = list(spin = 2267, zone = 50, strike = 64.4, iz_whiff = 17.9, whiff = 22.5, chase = 23.5),
slider = list(velo_l = 81.6, velo_r = 83, zone = 42, spin = 2440, strike = 61.4, iz_whiff = 22.1, whiff = 37.5, chase = 28.6),
curveball = list(velo_l = 78.2, velo_r = 79.1, zone = 40.6, spin = 2442, strike = 57.6, iz_whiff = 22.3, whiff = 38.1, chase = 24.4),
changeup = list(velo_l = 81.8, velo_r = 84.1, zone = 37.1, spin = 1708, strike = 58.6, iz_whiff = 27.6, whiff = 37.7, chase = 31.2),
cutter = list(velo_l = 86, velo_r = 86.8, zone = 46.9, spin = 2387, strike = 64.7, iz_whiff = 19.8, whiff = 30.4, chase = 28.9)
)
sec_extension_benchmark <- function(pt) {
if (pt %in% c("Fastball","Four-Seam","Four Seam","Fourseam","FourSeamFastBall","Sinker","Two-Seam","2-Seam")) return(5.83)
if (pt %in% c("Slider","Sweeper")) return(5.54)
if (pt %in% c("Curveball","Knuckle Curve")) return(5.47)
if (pt %in% c("ChangeUp","Splitter")) return(5.98)
NA_real_
}
get_gradient_color <- function(value, benchmark, metric_type = "higher_better", range_pct = 0.25) {
if (is.na(value) || is.na(benchmark) || is.null(value) || is.null(benchmark)) return("#FFFFFF")
if (is.nan(value) || is.infinite(value)) return("#FFFFFF")
pal <- scales::gradient_n_pal(c("#E1463E", "white", "#00840D"))
range_val <- benchmark * range_pct
min_val <- benchmark - range_val
max_val <- benchmark + range_val
normalized <- if (metric_type == "higher_better") {
(value - min_val) / (max_val - min_val)
} else {
(max_val - value) / (max_val - min_val)
}
normalized <- pmax(0, pmin(1, normalized))
pal(normalized)
}
advanced_normalize_columns <- function(df) {
if ("RelSpeed" %in% names(df)) df <- df %>% filter(!is.na(RelSpeed))
if (!"WhiffIndicator" %in% names(df)) {
df$WhiffIndicator <- ifelse(df$PitchCall == "StrikeSwinging", 1, 0)
}
if (!"SwingIndicator" %in% names(df)) {
df$SwingIndicator <- ifelse(df$PitchCall %in% c("StrikeSwinging","FoulBallNotFieldable","FoulBall","InPlay"), 1, 0)
}
if (!"StrikeZoneIndicator" %in% names(df)) {
df$StrikeZoneIndicator <- ifelse(
df$PlateLocSide >= -0.83 & df$PlateLocSide <= 0.83 &
df$PlateLocHeight >= 1.5 & df$PlateLocHeight <= 3.38, 1, 0
)
}
if (!"WalkIndicator" %in% names(df)) {
df$WalkIndicator <- ifelse(df$KorBB == "Walk", 1, 0)
}
if (!"HBPIndicator" %in% names(df)) {
df$HBPIndicator <- ifelse(df$PitchCall == "HitByPitch", 1, 0)
}
df
}
normalize_columns <- advanced_normalize_columns
process_pitcher_indicators <- function(df) {
# Ensure basic columns exist
df <- df %>%
mutate(
# Outs on play - you may need to adjust based on your data structure
OutsOnPlay = case_when(
PlayResult == "Out" ~ 1,
PlayResult == "FieldersChoice" ~ 1,
PlayResult == "Sacrifice" ~ 1,
PlayResult == "SacrificeFly" ~ 1,
KorBB == "Strikeout" ~ 1,
# Double play - adjust if you have this info
TRUE ~ 0
),
# Runs scored - you may already have this column
RunsScored = if ("RunsScored" %in% names(df)) RunsScored else 0,
# Hit indicator
is_hit = as.integer(PlayResult %in% c("Single", "Double", "Triple", "HomeRun")),
# On base indicator (hits + walks + HBP)
on_base = as.integer(
PlayResult %in% c("Single", "Double", "Triple", "HomeRun") |
KorBB == "Walk" |
PitchCall == "HitByPitch"
),
# Total bases for SLG
total_bases = case_when(
PlayResult == "Single" ~ 1,
PlayResult == "Double" ~ 2,
PlayResult == "Triple" ~ 3,
PlayResult == "HomeRun" ~ 4,
TRUE ~ 0
),
# SLG is total_bases per AB - we'll calculate per PA for simplicity
# You may want to exclude walks/HBP from denominator for true SLG
slg = total_bases,
# Strikeout indicator (per PA)
is_k = as.integer(KorBB == "Strikeout"),
# Walk indicator (per PA)
is_walk = as.integer(KorBB == "Walk"),
# CSW (Called Strike + Whiff) indicator
is_csw = as.integer(PitchCall %in% c("StrikeCalled", "StrikeSwinging")),
# Chase indicator (swing outside zone)
chase = as.integer(
PitchCall %in% c("StrikeSwinging", "FoulBall", "FoulBallNotFieldable",
"FoulBallFieldable", "InPlay") &
(PlateLocSide < -0.83 | PlateLocSide > 0.83 |
PlateLocHeight < 1.5 | PlateLocHeight > 3.38)
),
# In zone indicator
in_zone = as.integer(
PlateLocSide >= -0.83 & PlateLocSide <= 0.83 &
PlateLocHeight >= 1.5 & PlateLocHeight <= 3.38
),
# Whiff indicator
is_whiff = as.integer(PitchCall == "StrikeSwinging"),
# Put away indicator (strikeout with 2 strikes)
is_put_away = as.integer(KorBB == "Strikeout" & Strikes == 2),
# PA indicator for rate calculations
PAindicator = as.integer(
!is.na(KorBB) |
PlayResult %in% c("Single", "Double", "Triple", "HomeRun", "Out",
"FieldersChoice", "Error", "Sacrifice", "SacrificeFly") |
PitchCall == "HitByPitch"
)
)
df
}
create_advanced_pitcher_summary <- function(data, player_name) {
data <- normalize_columns(data)
data <- process_pitcher_indicators(data)
pitcher_data <- data %>% dplyr::filter(Pitcher == player_name)
# Calculate PA-level stats
pa_data <- pitcher_data %>%
filter(PAindicator == 1) %>%
group_by(Inning, Batter, PAofInning) %>%
slice_tail(n = 1) %>% # Get final pitch of each PA
ungroup()
summary_stats <- pitcher_data %>%
dplyr::summarise(
IP = {
total_outs <- sum(OutsOnPlay, na.rm = TRUE)
full_innings <- floor(total_outs / 3)
remainder_outs <- total_outs %% 3
full_innings + remainder_outs / 10
},
R = sum(RunsScored, na.rm = TRUE),
BF = n_distinct(paste(Inning, Batter, PAofInning)),
K = sum(KorBB == "Strikeout", na.rm = TRUE),
BB = sum(WalkIndicator, na.rm = TRUE),
H = sum(PlayResult %in% c("Single","Double","Triple","HomeRun"), na.rm = TRUE),
`Strike%` = round(100 * mean(is_csw | PitchCall %in% c("FoulBall","FoulBallNotFieldable","InPlay"), na.rm = TRUE), 1),
`CSW%` = round(100 * mean(is_csw, na.rm = TRUE), 1),
`Whiff%` = ifelse(sum(SwingIndicator, na.rm = TRUE) > 0,
round(100 * sum(is_whiff, na.rm = TRUE) / sum(SwingIndicator, na.rm = TRUE), 1), 0),
`Zone%` = round(100 * mean(in_zone, na.rm = TRUE), 1),
.groups = "drop"
)
headers <- names(summary_stats)
vals <- as.numeric(summary_stats[1, ])
color_for <- function(h, v) {
if (h == "Strike%") return(get_gradient_color(v, sec_averages$overall$strike_rate, "higher_better", 0.15))
if (h == "Whiff%") return(get_gradient_color(v, sec_averages$overall$miss_rate, "higher_better", 0.25))
if (h == "Zone%") return(get_gradient_color(v, sec_averages$overall$zone_rate, "higher_better", 0.20))
"#FFFFFF"
}
colors <- mapply(color_for, headers, vals, USE.NAMES = FALSE)
list(stats = summary_stats, colors = colors)
}
create_advanced_pitch_characteristics <- function(data, player_name) {
data <- normalize_columns(data)
pitcher_data <- data %>%
dplyr::filter(Pitcher == player_name,
!is.na(TaggedPitchType),
TaggedPitchType != "Other")
pitcher_hand <- if (nrow(pitcher_data) > 0) {
h <- pitcher_data$PitcherThrows[1]; if (is.na(h)) "Right" else h
} else "Right"
if (!is.null(stuffplus_model) && nrow(pitcher_data) > 0) {
# If reference data exists, use the league-standardization path
if (!is.null(reference_data_for_stuff)) {
combined <- dplyr::bind_rows(
reference_data_for_stuff$spring,
reference_data_for_stuff$p5,
reference_data_for_stuff$sbc
)
pitcher_data <- standardize_stuffplus_to_league(pitcher_data, combined)
} else {
message("WARNING: No reference data available. Using local standardization.")
pitcher_data$raw_stuff <- tryCatch({
df_processed <- bake(stuffplus_recipe, new_data = pitcher_data)
df_matrix <- as.matrix(df_processed)
pitcher_data$raw_stuff <- predict(stuffplus_model, df_matrix)
}, error = function(e) {
message("Stuff+ prediction error: ", e$message)
rep(NA_real_, nrow(pitcher_data))
})
finite_raw <- pitcher_data$raw_stuff[is.finite(pitcher_data$raw_stuff)]
if (length(finite_raw) == 0) {
pitcher_data$stuff_plus <- NA_real_
} else {
q <- quantile(finite_raw, probs = c(0.01, 0.99), na.rm = TRUE)
lo <- q[1]; hi <- q[2]
pitcher_data$raw_stuff_winz <- pmin(pmax(pitcher_data$raw_stuff, lo), hi)
raw_mean <- mean(pitcher_data$raw_stuff_winz, na.rm = TRUE)
raw_sd <- sd(pitcher_data$raw_stuff_winz, na.rm = TRUE)
if (!is.finite(raw_sd) || raw_sd == 0) raw_sd <- 1e-8
pitcher_data$stuff_plus <- ((pitcher_data$raw_stuff_winz - raw_mean) / raw_sd) * 10 + 100
}
}
} else {
if (is.null(stuffplus_model)) message("Stuff+ model not loaded")
if (nrow(pitcher_data) == 0) message("No pitcher data for Stuff+ prediction")
pitcher_data$raw_stuff <- NA_real_
pitcher_data$stuff_plus <- NA_real_
}
# Handle case where pitcher_data is empty after filtering
if (nrow(pitcher_data) == 0) {
empty_df <- data.frame(
Pitch = character(0), Count = integer(0), `Usage%` = numeric(0),
`Avg Velo` = numeric(0), `Max Velo` = numeric(0), `Avg Spin` = numeric(0),
`Avg IVB` = numeric(0), `Avg HB` = numeric(0), `VAA` = numeric(0),
`HAA` = numeric(0), `hRel` = numeric(0), `vRel` = numeric(0),
`Ext` = numeric(0), `Strike%` = numeric(0), `Whiff%` = numeric(0),
`Zone%` = numeric(0), `Stuff+` = numeric(0),
check.names = FALSE, stringsAsFactors = FALSE
)
return(list(stats = empty_df, colors = matrix("#FFFFFF", nrow = 0, ncol = 17)))
}
pitch_stats <- pitcher_data %>%
dplyr::group_by(Pitch = TaggedPitchType) %>%
dplyr::summarise(
Count = dplyr::n(),
`Usage%` = round(100 * dplyr::n() / nrow(pitcher_data), 1),
`Avg Velo` = round(mean(RelSpeed, na.rm = TRUE), 1),
`Max Velo` = round(max(RelSpeed, na.rm = TRUE), 1),
`Avg Spin` = round(mean(SpinRate, na.rm = TRUE), 0),
`Avg IVB` = round(mean(InducedVertBreak, na.rm = TRUE), 1),
`Avg HB` = round(mean(HorzBreak, na.rm = TRUE), 1),
`VAA` = round(mean(VertApprAngle, na.rm = TRUE), 1),
`HAA` = round(mean(HorzApprAngle, na.rm = TRUE), 1),
`hRel` = round(mean(RelSide, na.rm = TRUE), 1),
`vRel` = round(mean(RelHeight, na.rm = TRUE), 1),
`Ext` = round(mean(Extension, na.rm = TRUE), 2),
`Strike%` = round(100 * sum(!PitchCall %in% c("BallCalled","BallinDirt","BallIntentional")) / dplyr::n(), 1),
`Whiff%` = ifelse(sum(SwingIndicator, na.rm = TRUE) > 0,
round(100 * sum(WhiffIndicator, na.rm = TRUE) / sum(SwingIndicator, na.rm = TRUE), 1), 0),
`Zone%` = round(100 * sum(StrikeZoneIndicator, na.rm = TRUE) / dplyr::n(), 1),
`Stuff+` = round(mean(stuff_plus, na.rm = TRUE), 1),
.groups = "drop"
) %>%
dplyr::arrange(dplyr::desc(`Usage%`))
# CRITICAL: Remove duplicate columns FIRST before creating color matrix
dup_mask <- duplicated(names(pitch_stats))
if (any(dup_mask)) {
message("Removing ", sum(dup_mask), " duplicate columns from pitch_stats")
pitch_stats <- pitch_stats[, !dup_mask, drop = FALSE]
}
# NOW get final dimensions and create color matrix
num_rows <- nrow(pitch_stats)
num_cols <- ncol(pitch_stats)
color_matrix <- matrix("#FFFFFF", nrow = max(1, num_rows), ncol = max(1, num_cols))
if (num_rows == 0) {
return(list(stats = pitch_stats, colors = color_matrix))
}
# Get column indices AFTER deduplication
col_idx <- function(nm) {
idx <- match(nm, names(pitch_stats))
if (is.na(idx)) return(NA_integer_)
idx
}
c_avg_velo <- col_idx("Avg Velo")
c_max_velo <- col_idx("Max Velo")
c_spin <- col_idx("Avg Spin")
c_strk <- col_idx("Strike%")
c_whiff <- col_idx("Whiff%")
c_zone <- col_idx("Zone%")
c_stuff <- col_idx("Stuff+")
c_ext <- col_idx("Ext")
# Helper to safely check index validity
valid_idx <- function(idx) {
!is.na(idx) && is.numeric(idx) && length(idx) == 1 && idx >= 1 && idx <= num_cols
}
for (i in seq_len(num_rows)) {
pt <- pitch_stats$Pitch[i]
sec_ref <- if (pt %in% c("Fastball","Four-Seam", "FourSeamFastBall","Sinker","Two-Seam","2-Seam")) {
sec_averages$fb_sinker
} else if (pt %in% c("Cutter")) {
sec_averages$cutter
} else if (pt %in% c("Slider","Sweeper")) {
sec_averages$slider
} else if (pt %in% c("Curveball","Knuckle Curve")) {
sec_averages$curveball
} else if (pt %in% c("ChangeUp","Splitter")) {
sec_averages$changeup
} else NULL
if (!is.null(sec_ref)) {
velo_bench <- NA_real_
if (tolower(pt) %in% c("fastball","four-seam","FourSeamFastBall","four seam","fourseam","sinker","two-seam","2-seam")) {
velo_bench <- if (identical(pitcher_hand, "Left")) sec_averages$overall$fb_velo_l else sec_averages$overall$fb_velo_r
} else {
vb_l <- sec_ref$velo_l %||% NA_real_
vb_r <- sec_ref$velo_r %||% NA_real_
velo_bench <- if (identical(pitcher_hand, "Left")) vb_l else vb_r
}
if (!is.na(velo_bench) && valid_idx(c_avg_velo))
color_matrix[i, c_avg_velo] <- get_gradient_color(pitch_stats$`Avg Velo`[i], velo_bench, "higher_better", 0.05)
if (!is.na(velo_bench) && valid_idx(c_max_velo))
color_matrix[i, c_max_velo] <- get_gradient_color(pitch_stats$`Max Velo`[i], velo_bench, "higher_better", 0.05)
}
if (!is.null(sec_ref) && valid_idx(c_spin) && !is.null(sec_ref$spin))
color_matrix[i, c_spin] <- get_gradient_color(pitch_stats$`Avg Spin`[i], sec_ref$spin, "higher_better", 0.20)
if (!is.null(sec_ref) && valid_idx(c_strk) && !is.null(sec_ref$strike))
color_matrix[i, c_strk] <- get_gradient_color(pitch_stats$`Strike%`[i], sec_ref$strike, "higher_better", 0.15)
if (!is.null(sec_ref) && valid_idx(c_whiff) && !is.null(sec_ref$whiff))
color_matrix[i, c_whiff] <- get_gradient_color(pitch_stats$`Whiff%`[i], sec_ref$whiff, "higher_better", 0.30)
if (!is.null(sec_ref) && valid_idx(c_zone) && !is.null(sec_ref$zone))
color_matrix[i, c_zone] <- get_gradient_color(pitch_stats$`Zone%`[i], sec_ref$zone, "higher_better", 0.20)
if (valid_idx(c_stuff)) {
val <- pitch_stats$`Stuff+`[i]
if (is.finite(val)) color_matrix[i, c_stuff] <- get_gradient_color(val, 100, "higher_better", 0.20)
}
if (valid_idx(c_ext)) {
ext_bench <- sec_extension_benchmark(pt)
if (is.finite(ext_bench)) {
color_matrix[i, c_ext] <- get_gradient_color(pitch_stats$`Ext`[i], ext_bench, "higher_better", 0.08)
}
}
}
list(stats = pitch_stats, colors = color_matrix)
}
create_relside_height_plot <- function(data, player_name, pitch_colors) {
data <- normalize_columns(data)
df <- data %>%
dplyr::filter(
Pitcher == player_name,
!is.na(RelSide),
!is.na(RelHeight),
!is.na(TaggedPitchType),
TaggedPitchType != "Other"
)
title_text <- if (!is.null(player_name) && nzchar(player_name)) {
paste("Raw Release Points (Pitcher View):", player_name)
} else {
"Release Points"
}
if (nrow(df) == 0) {
# Empty data, but keep axes / labels / mound so the graphic is stable
avg_release <- df
} else {
avg_release <- df %>%
dplyr::group_by(TaggedPitchType) %>%
dplyr::summarise(
RelSide = mean(RelSide, na.rm = TRUE),
RelHeight = mean(RelHeight, na.rm = TRUE),
.groups = "drop"
)
}
ggplot() +
# Individual pitches (smaller semi-transparent dots)
geom_point(
data = df,
aes(RelSide, RelHeight, fill = TaggedPitchType),
size = 3,
shape = 21,
color = "black",
alpha = 0.8,
stroke = 0.2,
na.rm = TRUE
) +
# Averages per pitch type (larger dots)
geom_point(
data = avg_release,
aes(RelSide, RelHeight, fill = TaggedPitchType),
size = 5,
shape = 21,
color = "black",
stroke = 0.25,
alpha = 1,
na.rm = TRUE
) +
xlim(-5, 5) +
ylim(0, 8) +
annotate("text", x = -5, y = 8, label = "← 1B", size = 3, hjust = 0) +
annotate("text", x = 5, y = 8, label = "3B →", size = 3, hjust = 1) +
geom_rect(
aes(xmin = -5, xmax = 5, ymin = 0, ymax = 0.83),
fill = "#632b11",
inherit.aes = FALSE
) +
geom_rect(
aes(xmin = -0.5, xmax = 0.5, ymin = 0.8, ymax = 0.95),
fill = "white",
color = "black",
linewidth = 0.4,
inherit.aes = FALSE
) +
scale_fill_manual(values = pitch_colors, name = "Pitch Type") +
labs(
title = title_text,
x = "Release Side (ft)",
y = "Release Height (ft)"
) +
theme_minimal(base_size = 11) +
theme(
plot.title = element_text(size = 12, face = "bold", hjust = 0.5),
legend.position = "none",
strip.text = element_text(size = 9, face = "bold"),
strip.placement = "outside"
)
}
create_movement_plot <- create_pitcher_movement_plot
create_velocity_distribution_plot <- function(data, player_name, pitch_colors) {
data <- normalize_columns(data)
pitcher_data <- data %>%
filter(Pitcher == player_name, !is.na(TaggedPitchType), TaggedPitchType != "Other",
!is.na(RelSpeed))
if (nrow(pitcher_data) == 0) {
return(ggplot() + theme_void() + ggtitle("Velocity Distribution") +
theme(plot.title = element_text(size = 14, face = "bold", hjust = 0.5)))
}
pitch_order <- pitcher_data %>%
count(TaggedPitchType, sort = TRUE) %>%
pull(TaggedPitchType)
pitcher_data <- pitcher_data %>%
mutate(TaggedPitchType = factor(TaggedPitchType, levels = pitch_order))
pitch_means <- pitcher_data %>%
group_by(TaggedPitchType) %>%
summarise(mean_velo = mean(RelSpeed, na.rm = TRUE), .groups = "drop")
ggplot(pitcher_data, aes(x = RelSpeed, fill = TaggedPitchType)) +
geom_density(alpha = 0.7, color = "black", size = 0.3) +
geom_vline(data = pitch_means, aes(xintercept = mean_velo),
linetype = "dashed", size = 0.8) +
facet_wrap(~ TaggedPitchType, ncol = 1, strip.position = "left") +
scale_fill_manual(values = pitch_colors) +
labs(title = "Velocity Distribution by Pitch Type",
x = "Velocity (mph)",
y = "") +
theme_minimal(base_size = 11) +
theme(
plot.title = element_text(size = 14, face = "bold", hjust = 0.5),
legend.position = "none",
strip.text.y.left = element_text(angle = 0, hjust = 1, face = "bold", size = 10),
strip.placement = "outside",
panel.grid.major.y = element_blank(),
panel.grid.minor = element_blank(),
axis.text.y = element_blank(),
axis.ticks.y = element_blank()
)
}
create_release_point_plot <- function(data, player_name, pitch_colors) {
data <- normalize_columns(data)
pitcher_data <- data %>%
filter(Pitcher == player_name,
!is.na(TaggedPitchType),
TaggedPitchType != "Other",
!is.na(RelSide),
!is.na(RelHeight))
if (nrow(pitcher_data) == 0) {
return(ggplot() + theme_void() + ggtitle("Release Points") +
theme(plot.title = element_text(size = 14, face = "bold", hjust = 0.5)))
}
avg_release <- pitcher_data %>%
group_by(TaggedPitchType) %>%
summarise(
avg_rel_side = mean(RelSide, na.rm = TRUE),
avg_rel_height = mean(RelHeight, na.rm = TRUE),
.groups = "drop"
)
ggplot(pitcher_data, aes(x = RelSide, y = RelHeight, fill = TaggedPitchType)) +
geom_point(alpha = 0.6, shape = 21, color = "black", stroke = 0.4, size = 3) +
geom_point(data = avg_release,
aes(x = avg_rel_side, y = avg_rel_height, fill = TaggedPitchType),
shape = 21, color = "black", stroke = 1, size = 6, alpha = 1) +
scale_fill_manual(values = pitch_colors, name = "Pitch Type") +
labs(
title = "Release Points",
x = "Horizontal Release (ft)",
y = "Vertical Release (ft)"
) +
theme_minimal(base_size = 11) +
theme(
plot.title = element_text(hjust = 0.5, size = 14, face = "bold"),
legend.position = "none",
panel.grid.minor = element_blank()
)
}
create_count_usage_plot <- function(data, pitcher_name, pitch_colors) {
data <- normalize_columns(data)
df <- data %>%
dplyr::filter(Pitcher == pitcher_name,
!is.na(TaggedPitchType),
TaggedPitchType != "Other")
if (nrow(df) == 0) {
return(
ggplot() + theme_void() + ggtitle("Count Usage") +
theme(plot.title = element_text(size = 14, face = "bold", hjust = 0.5))
)
}
plot_df <- df %>%
dplyr::mutate(
count = dplyr::case_when(
Balls == 0 & Strikes == 0 ~ "0-0",
Strikes == 2 ~ "2 Strikes",
Balls > Strikes ~ "Behind",
Strikes > Balls ~ "Ahead",
TRUE ~ NA_character_
),
BatterSide = ifelse(BatterSide == "Right", "Vs Right", "Vs Left")
) %>%
dplyr::filter(!is.na(count)) %>%
dplyr::group_by(count, BatterSide) %>%
dplyr::mutate(total_count = dplyr::n()) %>%
dplyr::group_by(count, TaggedPitchType, BatterSide) %>%
dplyr::summarise(
n = dplyr::n(),
total = dplyr::first(total_count),
.groups = "drop"
) %>%
dplyr::mutate(
percentage = ifelse(total > 0, 100 * n / total, 0),
pct_label = ifelse(percentage >= 2, paste0(round(percentage), "%"), "")
) %>%
dplyr::filter(total > 0)
plot_df <- plot_df %>%
group_by(count, BatterSide) %>%
arrange(TaggedPitchType) %>%
mutate(
ymax = cumsum(percentage),
ymin = ymax - percentage,
label_pos = (ymin + ymax) / 2
) %>%
ungroup()
plot_df$count <- factor(plot_df$count, levels = c("0-0", "Ahead", "Behind", "2 Strikes"))
plot_df$BatterSide <- factor(plot_df$BatterSide, levels = c("Vs Left", "Vs Right"))
ggplot(plot_df, aes(x = 1, y = percentage, fill = TaggedPitchType)) +
geom_bar(width = 1, stat = "identity", color = "white") +
coord_polar(theta = "y", start = 0) +
facet_grid(BatterSide ~ count, labeller = label_value, drop = FALSE) +
ggtitle("Count Usage") +
scale_fill_manual(values = pitch_colors, na.translate = FALSE) +
theme_minimal() +
theme(
plot.title = element_text(hjust = 0.5, size = 14, face = "bold"),
axis.text = element_blank(),
axis.title = element_blank(),
axis.ticks = element_blank(),
strip.text = element_text(size = 12),
legend.position = "none"
)
}
.add_zones <- function() {
rule_xmin <- -0.83; rule_xmax <- 0.83
rule_ymin <- 1.50; rule_ymax <- 3.38
two_xmin <- -0.95; two_xmax <- 0.95
two_ymin <- 1.40; two_ymax <- 3.50
list(
annotate("rect", xmin = rule_xmin, xmax = rule_xmax, ymin = rule_ymin, ymax = rule_ymax,
fill = NA, color = "black", size = 0.6, linetype = "solid"),
annotate("rect", xmin = two_xmin, xmax = two_xmax, ymin = two_ymin, ymax = two_ymax,
fill = NA, color = "grey30", size = 0.6, linetype = "dashed")
)
}
create_location_by_result_plot <- function(data, player_name, batter_side, pitch_colors) {
data <- normalize_columns(data)
# Fixed facet levels
result_levels <- c(
"Whiffs",
"Balls Called",
"Strikes Called",
"Hard Hits (95+)",
"2 Strikes"
)
pitcher_data <- data %>%
dplyr::filter(
Pitcher == player_name,
BatterSide == batter_side,
!is.na(TaggedPitchType),
TaggedPitchType != "Other",
!is.na(PlateLocSide),
!is.na(PlateLocHeight)
) %>%
dplyr::mutate(
ResultType = dplyr::case_when(
PitchCall == "StrikeSwinging" ~ "Whiffs",
PitchCall %in% c("BallCalled", "BallinDirt") ~ "Balls Called",
PitchCall == "StrikeCalled" ~ "Strikes Called",
PitchCall == "InPlay" & !is.na(ExitSpeed) & ExitSpeed >= 95 ~ "Hard Hits (95+)",
Strikes == 2 ~ "2 Strikes",
TRUE ~ NA_character_
)
) %>%
dplyr::filter(!is.na(ResultType))
if (nrow(pitcher_data) == 0) {
# Dummy data frame so we still draw 5 facets + zones
plot_df <- data.frame(
PlateLocSide = NA_real_,
PlateLocHeight = NA_real_,
TaggedPitchType = factor(NA_character_),
ResultType = factor(result_levels, levels = result_levels)
)
} else {
pitcher_data$ResultType <- factor(pitcher_data$ResultType, levels = result_levels)
plot_df <- pitcher_data
}
ggplot(plot_df, aes(x = PlateLocSide, y = PlateLocHeight, fill = TaggedPitchType)) +
geom_point(
alpha = 0.8, shape = 21, color = "black",
stroke = 0.5, size = 3, na.rm = TRUE
) +
facet_wrap(
~ ResultType,
ncol = 5,
labeller = labeller(ResultType = label_value),
drop = FALSE # <- keep empty facets
) +
.add_zones() +
# Home plate + catcher box
geom_segment(aes(x = -0.708, y = 0.15, xend = 0.708, yend = 0.15),
color = "black", size = 0.5, inherit.aes = FALSE) +
geom_segment(aes(x = -0.708, y = 0.30, xend = -0.708, yend = 0.15),
color = "black", size = 0.5, inherit.aes = FALSE) +
geom_segment(aes(x = 0.708, y = 0.30, xend = 0.708, yend = 0.15),
color = "black", size = 0.5, inherit.aes = FALSE) +
geom_segment(aes(x = -0.708, y = 0.30, xend = 0.000, yend = 0.50),
color = "black", size = 0.5, inherit.aes = FALSE) +
geom_segment(aes(x = 0.708, y = 0.30, xend = 0.000, yend = 0.50),
color = "black", size = 0.5, inherit.aes = FALSE) +
scale_fill_manual(values = pitch_colors, na.translate = FALSE) +
scale_x_continuous(limits = c(-2, 2)) +
scale_y_continuous(limits = c(0, 4.5)) +
coord_fixed() +
ggtitle(paste0("Pitch Locations vs ", batter_side, "HB")) +
theme_void() +
theme(
plot.title = element_text(size = 12, face = "bold", hjust = 0.5),
legend.position = "none",
strip.text = element_text(size = 9, face = "bold"),
strip.placement = "outside"
)
}
create_location_by_side_plot <- function(data, player_name, batter_side, pitch_colors) {
create_location_by_result_plot(data, player_name, batter_side, pitch_colors)
}
create_location_by_side_plot <- function(data, player_name, batter_side, pitch_colors) {
create_location_by_result_plot(data, player_name, batter_side, pitch_colors)
}
get_team_logo_path <- function(team_name, logo_dir = "logos") {
if (is.null(team_name) || is.na(team_name) || !nzchar(team_name)) return(NULL)
# Normalize team name for file matching
team_clean <- tolower(gsub("[^a-zA-Z0-9]", "_", team_name))
team_nospace <- tolower(gsub("[^a-zA-Z0-9]", "", team_name))
# Check multiple possible paths
possible_paths <- c(
file.path(logo_dir, paste0(team_clean, ".png")),
file.path(logo_dir, paste0(team_nospace, ".png")),
file.path(logo_dir, paste0(team_name, ".png")),
file.path(logo_dir, paste0(tolower(team_name), ".png")),
# Common abbreviations
file.path(logo_dir, "coastal_carolina.png"),
file.path(logo_dir, "ccu.png")
)
for (path in possible_paths) {
if (file.exists(path)) {
return(path)
}
}
return(NULL)
}
# Function to add logo to the report
add_team_logo <- function(logo_path, x, y, width, height) {
if (is.null(logo_path) || !file.exists(logo_path)) {
return(invisible(NULL))
}
tryCatch({
# Read the PNG image
img <- png::readPNG(logo_path)
# Create a raster grob and draw it
grid::grid.raster(
img,
x = x,
y = y,
width = width,
height = height,
just = c("center", "center")
)
}, error = function(e) {
message("Could not load logo: ", e$message)
invisible(NULL)
})
}
create_advanced_pitcher_pdf <- function(game_df, pitcher_name, output_file, logo_dir = "logos") {
if (length(dev.list()) > 0) try(dev.off(), silent = TRUE)
pitch_colors <- c(
"Fastball" = "#3465cb",
"Four-Seam" = "#3465cb",
"FourSeamFastBall" = "#3465cb",
"4-Seam Fastball" = "#3465cb",
"FF" = "#3465cb",
"Sinker" = "#e5e501",
"TwoSeamFastBall" = "#e5e501",
"Two-Seam" = "#e5e501",
"2-Seam Fastball" = "#e5e501",
"SI" = "#e5e501",
"Slider" = "#65aa02",
"SL" = "#65aa02",
"Sweeper" = "#dc4476",
"SW" = "#dc4476",
"Curveball" = "#d73813",
"CB" = "#d73813",
"Knuckle Curve" = "#d73813",
"KC" = "#d73813",
"ChangeUp" = "#980099",
"Changeup" = "#980099",
"CH" = "#980099",
"Splitter" = "#23a999",
"FS" = "#23a999",
"SP" = "#23a999",
"Cutter" = "#ff9903",
"FC" = "#ff9903",
"Slurve" = "#9370DB",
"Other" = "gray50"
)
.text_on_fill <- function(hex) {
if (is.na(hex) || !nzchar(hex)) return("black")
tryCatch({
rgb <- grDevices::col2rgb(hex) / 255
L <- 0.2126*rgb[1] + 0.7152*rgb[2] + 0.0722*rgb[3]
ifelse(L < 0.5, "white", "black")
}, error = function(e) "black")
}
get_cell_value <- function(df, colname, row_idx) {
if (is.null(df) || !is.data.frame(df)) return(NA)
if (is.null(colname) || !nzchar(colname)) return(NA)
if (!(colname %in% names(df))) return(NA)
if (row_idx < 1 || row_idx > nrow(df)) return(NA)
tryCatch(df[[colname]][row_idx], error = function(e) NA)
}
pitcher_df <- dplyr::filter(game_df, Pitcher == pitcher_name)
if (nrow(pitcher_df) == 0) {
pdf(output_file, width = 11, height = 14)
grid::grid.newpage()
grid::grid.text(paste("No data available for", pitcher_name),
gp = grid::gpar(fontsize = 16, fontface = "bold"))
dev.off()
return(output_file)
}
# Get pitcher's team for logo
pitcher_team <- NULL
if ("PitcherTeam" %in% names(pitcher_df)) {
pitcher_team <- pitcher_df$PitcherTeam[1]
} else if ("Team" %in% names(pitcher_df)) {
pitcher_team <- pitcher_df$Team[1]
} else if ("HomeTeam" %in% names(pitcher_df)) {
# Try to determine team from context
pitcher_team <- pitcher_df$HomeTeam[1]
}
# Get logo path
logo_path <- get_team_logo_path(pitcher_team, logo_dir)
game_day <- tryCatch(parse_game_day(pitcher_df), error = function(e) Sys.Date())
# Get summary stats
summary_result <- tryCatch(
create_advanced_pitcher_summary(pitcher_df, pitcher_name),
error = function(e) {
message("Error in summary: ", e$message)
list(stats = data.frame(IP=0, R=0, BF=0, K=0, BB=0, H=0, check.names=FALSE),
colors = rep("#FFFFFF", 6))
}
)
summary_stats <- summary_result$stats
summary_colors <- summary_result$colors
# Get pitch characteristics
pitch_result <- tryCatch(
create_advanced_pitch_characteristics(pitcher_df, pitcher_name),
error = function(e) {
message("Error in pitch characteristics: ", e$message)
list(
stats = data.frame(Pitch = "-", Count = 0, `Usage%` = NA_real_, check.names = FALSE),
colors = matrix("#FFFFFF", nrow = 1, ncol = 3)
)
}
)
pitch_char <- pitch_result$stats
pitch_colors_matrix <- pitch_result$colors
# ===== ADD THIS DEBUGGING BLOCK =====
message("========== PITCH CHARACTERISTICS DEBUG ==========")
message("pitch_char class: ", class(pitch_char))
message("pitch_char dimensions: ", nrow(pitch_char), " rows x ", ncol(pitch_char), " cols")
message("pitch_char column names: ", paste(names(pitch_char), collapse = ", "))
if (nrow(pitch_char) > 0) {
message("First row Pitch value: ", pitch_char$Pitch[1])
message("First row data:")
print(pitch_char[1, , drop = FALSE])
} else {
message("WARNING: pitch_char has 0 rows!")
}
message("pitch_colors_matrix dimensions: ", nrow(pitch_colors_matrix), " x ", ncol(pitch_colors_matrix))
message("==================================================")
# ===== END DEBUG BLOCK =====
# Handle empty pitch_char
if (is.null(pitch_char) || nrow(pitch_char) == 0) {
pitch_char <- data.frame(
Pitch = "-", Count = 0, `Usage%` = NA_real_,
`Avg Velo` = NA_real_, `Max Velo` = NA_real_,
`Avg Spin` = NA_real_,
`Avg IVB` = NA_real_, `Avg HB` = NA_real_,
`VAA` = NA_real_, `HAA` = NA_real_,
`hRel` = NA_real_, `vRel` = NA_real_, `Ext` = NA_real_,
`Strike%` = NA_real_, `Whiff%` = NA_real_,
`Zone%` = NA_real_,
`Stuff+` = NA_real_,
check.names = FALSE
)
pitch_colors_matrix <- matrix("#FFFFFF", nrow = 1, ncol = ncol(pitch_char))
}
# Limit rows
max_rows_to_show <- min(nrow(pitch_char), 9)
if (nrow(pitch_char) > max_rows_to_show) {
pitch_char <- pitch_char[1:max_rows_to_show, , drop = FALSE]
}
num_rows <- nrow(pitch_char)
num_cols <- ncol(pitch_char)
# Rebuild color matrix
new_color_matrix <- matrix("#FFFFFF", nrow = num_rows, ncol = num_cols)
if (!is.null(pitch_colors_matrix) && is.matrix(pitch_colors_matrix)) {
rows_to_copy <- min(nrow(pitch_colors_matrix), num_rows)
cols_to_copy <- min(ncol(pitch_colors_matrix), num_cols)
if (rows_to_copy > 0 && cols_to_copy > 0) {
new_color_matrix[1:rows_to_copy, 1:cols_to_copy] <-
pitch_colors_matrix[1:rows_to_copy, 1:cols_to_copy]
}
}
pitch_colors_matrix <- new_color_matrix
# Create plots
movement_plot <- tryCatch(
create_movement_plot(pitcher_df, pitcher_name, pitch_colors),
error = function(e) ggplot2::ggplot() + ggplot2::theme_void() + ggplot2::ggtitle("Movement Plot Error")
)
velo_plot <- tryCatch(
create_velocity_distribution_plot(pitcher_df, pitcher_name, pitch_colors),
error = function(e) ggplot2::ggplot() + ggplot2::theme_void() + ggplot2::ggtitle("Velocity Plot Error")
)
location_lhb <- tryCatch(
create_location_by_result_plot(pitcher_df, pitcher_name, "Left", pitch_colors),
error = function(e) ggplot2::ggplot() + ggplot2::theme_void() + ggplot2::ggtitle("LHB Location Error")
)
location_rhb <- tryCatch(
create_location_by_result_plot(pitcher_df, pitcher_name, "Right", pitch_colors),
error = function(e) ggplot2::ggplot() + ggplot2::theme_void() + ggplot2::ggtitle("RHB Location Error")
)
count_plot <- tryCatch(
create_count_usage_plot(pitcher_df, pitcher_name, pitch_colors),
error = function(e) ggplot2::ggplot() + ggplot2::theme_void() + ggplot2::ggtitle("Count Usage Error")
)
relside_height_plot <- tryCatch({
create_relside_height_plot(
data = pitcher_df,
player_name = pitcher_name,
pitch_colors = pitch_colors
)
}, error = function(e) {
message("RelSide/Height plot error: ", e$message)
ggplot2::ggplot() +
ggplot2::theme_void() +
ggplot2::ggtitle("RelSide/Height Error")
})
# Start PDF
pdf(output_file, width = 11, height = 14)
on.exit(try(dev.off(), silent = TRUE), add = TRUE)
grid::grid.newpage()
header_y_top <- 0.98
charts_y_top <- 0.85
charts_height <- 0.30
charts_y_bottom <- charts_y_top - charts_height
# Row 2 (Count + Release)
count_y_top <- charts_y_bottom - 0.02
count_height <- 0.18 # keep count usage height
release_height <- 0.24 # make release plot TALLER than count
row2_bottom <- count_y_top - max(count_height, release_height)
count_y_bottom <- count_y_top - count_height
base_loc_top <- 0.30
table_margin <- 0.03
min_row_h <- 0.0125
max_row_h <- 0.0180
# Pitch characteristics table starts just below row 2
y_top_char_orig <- row2_bottom - 0.011
rows_including_header <- num_rows + 1
available_for_table_orig <- y_top_char_orig - (base_loc_top + table_margin)
row_h_char <- min(max_row_h, max(min_row_h, available_for_table_orig / max(1, rows_including_header)))
y_loc_top <- y_top_char_orig - rows_including_header * row_h_char - (table_margin * 0.5)
table_lower_offset <- 0.025
y_top_char <- y_top_char_orig - table_lower_offset
available_for_table <- y_top_char - (base_loc_top + table_margin)
row_h_char <- min(max_row_h, max(min_row_h, available_for_table / max(1, rows_including_header)))
# ===== HEADER WITH LOGO =====
grid::pushViewport(grid::viewport(x = 0.5, y = header_y_top, width = 1, height = 0.06, just = c("center","top")))
# Add team logo on the left if available
if (!is.null(logo_path) && file.exists(logo_path)) {
add_team_logo(logo_path, x = 0.08, y = 0.5, width = 0.06, height = grid::unit(0.8, "npc"))
}
# Title in center
grid::grid.text(paste(pitcher_name, "- Advanced Pitcher Report"),
x = 0.5, y = 0.5,
gp = grid::gpar(fontface = "bold", cex = 1.8, col = "red"))
# Add team logo on the right if available (mirror)
if (!is.null(logo_path) && file.exists(logo_path)) {
add_team_logo(logo_path, x = 0.92, y = 0.5, width = 0.06, height = grid::unit(0.8, "npc"))
}
grid::popViewport()
# Summary section
grid::grid.text("Summary", x = 0.5, y = 0.92,
gp = grid::gpar(fontface = "bold", cex = 1.1, col = "red"))
summary_headers <- names(summary_stats)
summary_values <- as.numeric(summary_stats[1, ])
summary_widths <- rep(0.06, length(summary_headers))
x_start <- 0.5 - sum(summary_widths)/2
x_pos <- c(x_start, x_start + cumsum(summary_widths[-length(summary_widths)]))
y_top <- 0.905
row_h <- 0.020
for (i in seq_along(summary_headers)) {
grid::grid.rect(x = x_pos[i], y = y_top, width = summary_widths[i]*0.985, height = row_h,
just = c("left","top"), gp = grid::gpar(fill = "red", col = "black", lwd = 0.5))
grid::grid.text(summary_headers[i],
x = x_pos[i] + summary_widths[i]*0.49, y = y_top - row_h*0.5,
gp = grid::gpar(col = "white", cex = 0.62, fontface = "bold"))
fill_col <- if (i <= length(summary_colors)) summary_colors[i] else "#FFFFFF"
grid::grid.rect(x = x_pos[i], y = y_top - row_h, width = summary_widths[i]*0.985, height = row_h,
just = c("left","top"), gp = grid::gpar(fill = fill_col, col = "black", lwd = 0.4))
grid::grid.text(ifelse(is.finite(summary_values[i]), sprintf("%.1f", summary_values[i]), "-"),
x = x_pos[i] + summary_widths[i]*0.49, y = y_top - row_h*1.5,
gp = grid::gpar(cex = 0.62))
}
# Row 1: Movement plot (left) | Velocity distribution (right)
grid::pushViewport(grid::viewport(x = 0.25, y = charts_y_top, width = 0.45, height = charts_height, just = c("center","top")))
tryCatch(print(movement_plot, newpage = FALSE), error = function(e) NULL)
grid::popViewport()
grid::pushViewport(grid::viewport(x = 0.75, y = charts_y_top, width = 0.45, height = charts_height, just = c("center","top")))
tryCatch(print(velo_plot, newpage = FALSE), error = function(e) NULL)
grid::popViewport()
# Row 2: Count plot (left) | ENLARGED Release side plot (right)
# Count plot on left
grid::pushViewport(grid::viewport(x = 0.27, y = count_y_top, width = 0.50, height = count_height, just = c("center","top")))
tryCatch(print(count_plot, newpage = FALSE), error = function(e) NULL)
grid::popViewport()
grid::pushViewport(grid::viewport(
x = 0.77, y = count_y_top,
width = 0.44, height = release_height, # ← use release_height here
just = c("center","top")
))
tryCatch(print(relside_height_plot, newpage = FALSE), error = function(e) NULL)
grid::popViewport()
# Pitch Characteristics table
grid::grid.text("Pitch Characteristics", x = 0.5, y = y_top_char + 0.015,
gp = grid::gpar(fontface = "bold", cex = 1.1, col = "red"))
char_headers <- names(pitch_char)
num_char_cols <- length(char_headers)
if (num_char_cols > 1) {
char_widths <- c(0.10, rep((1 - 0.10 - 0.06) / (num_char_cols - 1), num_char_cols - 1))
} else {
char_widths <- c(0.10)
}
x_start_char <- 0.5 - sum(char_widths)/2
x_pos_char <- c(x_start_char)
if (length(char_widths) > 1) {
x_pos_char <- c(x_start_char, x_start_char + cumsum(char_widths[-length(char_widths)]))
}
# Draw header row
for (i in seq_along(char_headers)) {
if (i > length(x_pos_char) || i > length(char_widths)) break
grid::grid.rect(x = x_pos_char[i], y = y_top_char, width = char_widths[i]*0.985, height = row_h_char,
just = c("left","top"), gp = grid::gpar(fill = "red", col = "black", lwd = 0.5))
grid::grid.text(char_headers[i],
x = x_pos_char[i] + char_widths[i]*0.49, y = y_top_char - row_h_char*0.5,
gp = grid::gpar(col = "white", cex = 0.50, fontface = "bold"))
}
i_col_pitch <- match("Pitch", char_headers)
has_pitchcol <- !is.na(i_col_pitch) && i_col_pitch >= 1
# Draw data rows
for (r in seq_len(num_rows)) {
y_row <- y_top_char - r * row_h_char
pitch_name <- if (has_pitchcol) as.character(get_cell_value(pitch_char, "Pitch", r)) else NA_character_
for (i in seq_along(char_headers)) {
if (i > length(x_pos_char) || i > length(char_widths)) break
if (i > num_cols) break
colname <- char_headers[i]
bg <- "#FFFFFF"
if (r >= 1 && r <= nrow(pitch_colors_matrix) && i >= 1 && i <= ncol(pitch_colors_matrix)) {
bg <- pitch_colors_matrix[r, i]
if (is.na(bg) || !nzchar(bg)) bg <- "#FFFFFF"
}
if (has_pitchcol && identical(colname, "Pitch") && !is.na(pitch_name) && pitch_name %in% names(pitch_colors)) {
bg <- pitch_colors[[pitch_name]]
}
grid::grid.rect(x = x_pos_char[i], y = y_row, width = char_widths[i]*0.985, height = row_h_char,
just = c("left","top"), gp = grid::gpar(fill = bg, col = "grey80", lwd = 0.3))
val <- get_cell_value(pitch_char, colname, r)
display_val <- if (is.numeric(val)) {
if (is.na(val) || is.nan(val) || is.infinite(val)) "-" else sprintf("%.1f", val)
} else if (is.character(val) || is.factor(val)) {
v <- as.character(val)
ifelse(nzchar(v), v, "-")
} else "-"
txt_col <- if (has_pitchcol && identical(colname, "Pitch")) .text_on_fill(bg) else "black"
grid::grid.text(display_val,
x = x_pos_char[i] + char_widths[i]*0.49,
y = y_row - row_h_char*0.5,
gp = grid::gpar(
cex = 0.50,
col = txt_col,
fontface = if (has_pitchcol && identical(colname, "Pitch")) "bold" else "plain"
))
}
}
# Location plots
grid::pushViewport(grid::viewport(x = 0.25, y = y_loc_top, width = 0.45, height = 0.24, just = c("center","top")))
tryCatch(print(location_lhb, newpage = FALSE), error = function(e) NULL)
grid::popViewport()
grid::pushViewport(grid::viewport(x = 0.75, y = y_loc_top, width = 0.45, height = 0.24, just = c("center","top")))
tryCatch(print(location_rhb, newpage = FALSE), error = function(e) NULL)
grid::popViewport()
grid::grid.text("Data: TrackMan | Report Generated: Coastal Carolina Baseball",
x = 0.5, y = 0.02, gp = grid::gpar(cex = 0.75, col = "grey50"))
invisible(output_file)
}
# =====================================================================
# =========================== UI ================================
# =====================================================================
login_ui <- fluidPage(
tags$style(HTML("
body {
background-color: #f0f4f8;
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
color: #006F71;
}
.login-container {
max-width: 360px;
margin: 120px auto;
background: #A27752;
padding: 30px 25px;
border-radius: 8px;
box-shadow: 0 4px 15px #A1A1A4;
text-align: center;
color: white;
}
.login-message {
margin-bottom: 20px;
font-size: 14px;
color: #ffffff;
font-weight: 600;
}
.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(app_css))),
div(class = "header",
h1("Postgame Report Generator"),
p("Upload a Trackman CSV to generate postgame reports")
),
uiOutput("leaderboard_ui"),
fluidRow(
column(
4,
div(class = "main-panel",
div(class = "upload-box",
h3("Upload Game Data", style = "color: #006F71; margin-top: 0;"),
fileInput("game_csv", NULL, accept = c(".csv","text/csv"),
buttonLabel = "Choose CSV...", placeholder = "No file selected"),
radioButtons("report_type", "Report Type",
c("Hitter"="hitter",
"Pitcher"="pitcher",
"Advanced Pitcher"="advanced_pitcher",
"Matt Williams Report"="tableau_pitcher",
"Catcher"="catcher",
"Umpire"="umpire",
"BP Report"="bp"),
selected = "hitter", inline = TRUE),
hr(),
h4("Optional: Bio CSV", style = "color: #006F71; font-size: 1em;"),
conditionalPanel("input.report_type == 'hitter'",
fileInput("bio_csv_hitter", "Player Bio (optional)", accept = c(".csv","text/csv"),
buttonLabel = "Choose Bio CSV...", placeholder = "Optional"),
p("Upload CCU_Hitter_Bio.csv to add headshots",
style = "font-size: 0.85em; color: #666; margin-top: -10px;")
),
conditionalPanel("input.report_type == 'catcher'",
fileInput("bio_csv_catcher", "Catcher Bio (optional)", accept = c(".csv","text/csv"),
buttonLabel = "Choose Bio CSV...", placeholder = "Optional")
)
),
uiOutput("selector_ui"),
hr(),
uiOutput("download_ui"),
uiOutput("bulk_ui"),
uiOutput("status_message")
)
),
column(
8,
div(class = "main-panel",
h3("Report Preview", style = "color: #006F71; margin-top: 0;"),
uiOutput("preview_content")
)
)
)
)
# =====================================================================
# =========================== SERVER =============================
# =====================================================================
ui <- fluidPage(
uiOutput("page")
)
server <- function(input, output, session) {
logged_in <- reactiveVal(FALSE)
output$page <- renderUI({
if (logged_in()) {
app_ui
} else {
login_ui
}
})
observeEvent(input$login, {
if (input$password == PASSWORD) {
logged_in(TRUE)
output$wrong_pass <- renderText("")
} else {
output$wrong_pass <- renderText("Incorrect password, please try again.")
}
})
data_hitter <- reactiveVal(NULL)
data_catcher <- reactiveVal(NULL)
bio_hitter <- reactiveVal(NULL)
bio_catch <- reactiveVal(NULL)
data_umpire <- reactiveVal(NULL)
data_bp <- reactiveVal(NULL)
observeEvent(
list(data_hitter(), input$report_type),
{
req(data_hitter())
req(input$report_type == "umpire")
data_umpire(umpire_process_data(data_hitter()))
},
ignoreInit = TRUE
)
data_pitcher <- reactive({
df <- data_hitter()
if (is.null(df)) return(NULL)
if (!"Pitcher" %in% names(df)) {
alt <- intersect(c("PitcherName","pitcher","Pitcher_LastFirst","PlayerName"), names(df))
if (length(alt)) df$Pitcher <- df[[alt[1]]] else df$Pitcher <- NA_character_
}
df %>% mutate(
Pitcher = stringr::str_replace(coalesce(Pitcher, ""), "^\\s*(\\w+)\\s*,\\s*(\\w+)\\s*$", "\\2 \\1")
)
})
observeEvent(input$game_csv, {
req(input$game_csv)
tryCatch({
df <- read.csv(input$game_csv$datapath, stringsAsFactors = FALSE)
data_hitter(process_dataset(df))
data_catcher(catcher_process_dataset(df))
showNotification("Game data loaded successfully!", type = "message", duration = 3)
}, error = function(e) {
showNotification(paste("Error loading CSV:", e$message), type = "error", duration = 6)
data_hitter(NULL); data_catcher(NULL)
})
})
observeEvent(input$game_csv, {
req(input$game_csv)
tryCatch({
df <- read.csv(input$game_csv$datapath, stringsAsFactors = FALSE)
# Existing code for hitter/pitcher/catcher/umpire...
data_hitter(process_dataset(df))
data_catcher(catcher_process_dataset(df))
# ADD THIS NEW LINE:
data_bp(process_bp_dataset(df))
showNotification("Game data loaded successfully!", type = "message", duration = 3)
}, error = function(e) {
showNotification(paste("Error loading CSV:", e$message), type = "error", duration = 6)
data_hitter(NULL); data_catcher(NULL); data_bp(NULL)
})
})
observeEvent(input$bio_csv_hitter, {
req(input$bio_csv_hitter)
tryCatch({
bio <- read.csv(input$bio_csv_hitter$datapath, stringsAsFactors = FALSE)
if ("Batter" %in% names(bio)) {
bio <- bio %>% mutate(Batter = stringr::str_replace(Batter, "^\\s*(\\w+)\\s*,\\s*(\\w+)\\s*$", "\\2 \\1"))
}
bio_hitter(bio)
showNotification("Player bio loaded", type = "message", duration = 3)
}, error = function(e) {
showNotification(paste("Player bio error:", e$message), type = "warning", duration = 6)
bio_hitter(NULL)
})
})
observeEvent(input$bio_csv_catcher, {
req(input$bio_csv_catcher)
tryCatch({
bio <- read.csv(input$bio_csv_catcher$datapath, stringsAsFactors = FALSE)
if ("Catcher" %in% names(bio)) {
bio <- bio %>% mutate(Catcher = stringr::str_replace(Catcher, "^\\s*(\\w+)\\s*,\\s*(\\w+)\\s*$", "\\2 \\1"))
}
bio_catch(bio)
showNotification("Catcher bio loaded", type = "message", duration = 3)
}, error = function(e) {
showNotification(paste("Catcher bio error:", e$message), type = "warning", duration = 6)
bio_catch(NULL)
})
})
output$selector_ui <- renderUI({
if (input$report_type == "hitter") {
df <- data_hitter()
if (is.null(df)) return(div(p("Please upload a CSV to begin", style = "color:#666;font-style:italic;text-align:center;")))
players <- sort(unique(na.omit(df$Batter)))
if (!length(players)) return(div(p("No players found in uploaded data", style="color:#cc6600;font-weight:bold;")))
selectInput("player_name", "Select Player", choices = players, selected = players[1], width = "100%")
} else if (input$report_type == "pitcher") {
df <- data_pitcher()
if (is.null(df)) return(div(p("Please upload a CSV to begin", style = "color:#666;font-style:italic;text-align:center;")))
pitchers <- sort(unique(na.omit(df$Pitcher)))
if (!length(pitchers)) return(div(p("No pitchers found in uploaded data", style="color:#cc6600;font-weight:bold;")))
selectInput("pitcher_name", "Select Pitcher", choices = pitchers, selected = pitchers[1], width = "100%")
} else if (input$report_type == "catcher") {
df <- data_catcher()
if (is.null(df)) return(div(p("Please upload a CSV to begin", style = "color:#666;font-style:italic;text-align:center;")))
catchers <- sort(unique(na.omit(df$Catcher)))
if (!length(catchers)) return(div(p("No catchers found in uploaded data", style="color:#cc6600;font-weight:bold;")))
selectInput("catcher_name", "Select Catcher", choices = catchers, selected = catchers[1], width = "100%")
} else if (input$report_type == "bp") {
df <- data_bp()
if (is.null(df)) return(div(p("Please upload a BP CSV to begin",
style = "color:#666;font-style:italic;text-align:center;")))
players <- sort(unique(na.omit(df$Batter)))
if (!length(players)) return(div(p("No players found in BP data",
style="color:#cc6600;font-weight:bold;")))
selectInput("bp_player_name", "Select Player", choices = players, selected = players[1], width = "100%")
} else if (input$report_type == "tableau_pitcher") {
df <- data_pitcher()
if (is.null(df)) return(div(p("Please upload a CSV to begin", style = "color:#666;font-style:italic;text-align:center;")))
pitchers <- sort(unique(na.omit(df$Pitcher)))
if (!length(pitchers)) return(div(p("No pitchers found", style="color:#cc6600;font-weight:bold;")))
selectInput("tableau_pitcher_name", "Select Pitcher", choices = pitchers, selected = pitchers[1], width = "100%")
} else if (input$report_type == "advanced_pitcher") {
df <- data_pitcher()
if (is.null(df)) return(div(p("Please upload a CSV to begin", style = "color:#666;font-style:italic;text-align:center;")))
pitchers <- sort(unique(na.omit(df$Pitcher)))
if (!length(pitchers)) return(div(p("No pitchers found in uploaded data", style="color:#cc6600;font-weight:bold;")))
tagList(
selectInput("advanced_pitcher_name", "Select Pitcher", choices = pitchers, selected = pitchers[1], width = "100%"),
div(class = "status-box",
p(strong("Advanced Report Features:"), style = "margin-top: 0; color: #006F71;"),
tags$ul(
tags$li("Stuff+ Model Predictions"),
tags$li("SEC Benchmarking with Color Coding"),
tags$li("Enhanced Count Usage (Ahead/Behind/2-Strike)"),
tags$li("Location by Result Type Visualization")
))
)
} else if (input$report_type == "umpire") {
df <- data_umpire()
if (is.null(df)) return(div(p("Please upload a CSV to begin", style = "color:#666;font-style:italic;text-align:center;")))
game_date <- format(max(df$Date, na.rm = TRUE), '%B %d, %Y')
tagList(
textInput("umpire_name", "Umpire Name (optional)",
value = "",
placeholder = "Enter umpire name...",
width = "100%"),
div(class = "status-box",
h4("Game Date: ", game_date, style = "margin: 0; color: #006F71;"))
)
}
})
output$download_ui <- renderUI({
if (input$report_type == "hitter") {
downloadButton("download_hitter", "Download Hitter PDF", class = "btn-primary")
} else if (input$report_type == "pitcher") {
downloadButton("download_pitcher", "Download Pitcher PDF", class = "btn-primary")
} else if (input$report_type == "catcher") {
downloadButton("download_catcher", "Download Catcher PDF", class = "btn-primary")
} else if (input$report_type == "advanced_pitcher") {
downloadButton("download_advanced_pitcher", "Download Advanced Pitcher PDF", class = "btn-primary")
} else if (input$report_type == "umpire") {
downloadButton("download_umpire", "Download Umpire PDF", class = "btn-primary")
} else if (input$report_type == "tableau_pitcher") {
downloadButton("download_tableau_pitcher", "Download Tableau Pitcher PDF", class = "btn-primary")
} else if (input$report_type == "bp") {
downloadButton("download_bp", "Download BP Report PDF", class = "btn-primary")
}
})
output$bulk_ui <- renderUI({
if (input$report_type == "hitter") {
df <- data_hitter(); if (is.null(df) || !"BatterTeam" %in% names(df)) return(NULL)
coastal_players <- df %>% filter(BatterTeam == "COA_CHA") %>% pull(Batter) %>% unique() %>% na.omit()
if (!length(coastal_players)) return(NULL)
tagList(
br(),
div(style="text-align:center;padding:10px;background:#f0f8f8;border-radius:6px;margin-top:10px;",
p(strong("Coastal Carolina Players Found: ", length(coastal_players)),
style="color:#006F71;margin:5px 0;"),
downloadButton("download_all_coastal_hitters", "Download All Coastal Hitter Reports (ZIP)", class="btn-secondary")
)
)
} else if (input$report_type == "pitcher") {
df <- data_pitcher(); if (is.null(df) || !"PitcherTeam" %in% names(df)) return(NULL)
coastal_pitchers <- df %>% filter(PitcherTeam == "COA_CHA") %>% pull(Pitcher) %>% unique() %>% na.omit()
if (!length(coastal_pitchers)) return(NULL)
tagList(
br(),
div(style="text-align:center;padding:10px;background:#f0f8f8;border-radius:6px;margin-top:10px;",
p(strong("Coastal Carolina Pitchers Found: ", length(coastal_pitchers)),
style="color:#006F71;margin:5px 0;"),
downloadButton("download_all_coastal_pitchers", "Download All Coastal Pitcher Reports (ZIP)", class="btn-secondary")
)
)
} else if (input$report_type == "advanced_pitcher") {
df <- data_pitcher()
if (is.null(df) || !"PitcherTeam" %in% names(df)) return(NULL)
coastal_pitchers <- df %>% filter(PitcherTeam == "COA_CHA") %>% pull(Pitcher) %>% unique() %>% na.omit()
if (!length(coastal_pitchers)) return(NULL)
tagList(
br(),
div(style="text-align:center;padding:10px;background:#f0f8f8;border-radius:6px;margin-top:10px;",
p(strong("Coastal Carolina Pitchers Found: ", length(coastal_pitchers)),
style="color:#006F71;margin:5px 0;"),
downloadButton("download_all_coastal_advanced_pitchers", "Download All Advanced Pitcher Reports (ZIP)", class="btn-secondary")
)
)
} else if (input$report_type == "tableau_pitcher") {
df <- data_pitcher(); if (is.null(df) || !"PitcherTeam" %in% names(df)) return(NULL)
coastal_pitchers <- df %>% filter(PitcherTeam == "COA_CHA") %>% pull(Pitcher) %>% unique() %>% na.omit()
if (!length(coastal_pitchers)) return(NULL)
tagList(
br(),
div(style="text-align:center;padding:10px;background:#f0f8f8;border-radius:6px;margin-top:10px;",
p(strong("Coastal Carolina Pitchers Found: ", length(coastal_pitchers)),
style="color:#006F71;margin:5px 0;"),
downloadButton("download_all_coastal_tableau_pitchers", "Download All Coastal Tableau Reports (ZIP)", class="btn-secondary")
)
)
} else if (input$report_type == "catcher") {
df <- data_catcher(); if (is.null(df) || !"CatcherTeam" %in% names(df)) return(NULL)
cts <- df %>% filter(CatcherTeam == "COA_CHA") %>% pull(Catcher) %>% unique() %>% na.omit()
if (!length(cts)) return(NULL)
tagList(
br(),
div(style="text-align:center;padding:10px;background:#f0f8f8;border-radius:6px;margin-top:10px;",
p(strong("Coastal Carolina Catchers Found: ", length(cts)),
style="color:#006F71;margin:5px 0;"),
downloadButton("download_all_ccu_catchers", "Download All CCU Catcher Reports (ZIP)", class="btn-secondary")
)
)
}
})
output$download_pitcher <- downloadHandler(
filename = function() {
df <- data_pitcher(); req(df, input$pitcher_name)
pitcher_clean <- gsub(" ", "_", input$pitcher_name)
date_str <- format(parse_game_day(df %>% filter(Pitcher == input$pitcher_name)), "%Y%m%d")
paste0(pitcher_clean, "_", date_str, "_Pitcher_Report.pdf")
},
content = function(file) {
df <- data_pitcher(); req(df, input$pitcher_name)
pitch_colors <- c("Fastball"="#FA8072","FourSeamFastBall"="#FA8072", "Four-Seam"="#FA8072","Sinker"="#fdae61",
"Slider"="#A020F0","Sweeper"="magenta","Curveball"="#2c7bb6",
"ChangeUp"="#90EE90","Splitter"="#90EE32","Cutter"="red")
withProgress(message='Generating Pitcher PDF', value=0, {
incProgress(.3, detail="Processing data...")
incProgress(.4, detail="Creating visualizations...")
create_pitcher_pdf(df, input$pitcher_name, file, pitch_colors)
incProgress(.3, detail="Finalizing report...")
})
showNotification("Pitcher report generated!", type="message", duration=3)
},
contentType = "application/pdf"
)
output$download_bp <- downloadHandler(
filename = function() {
df <- data_bp(); req(df, input$bp_player_name)
player_clean <- gsub(" ", "_", input$bp_player_name)
paste0(player_clean, "_BP_Report.pdf")
},
content = function(file) {
df <- data_bp(); req(df, input$bp_player_name)
withProgress(message='Generating BP Report PDF', value=0, {
incProgress(.3, detail="Processing data...")
incProgress(.4, detail="Creating visualizations...")
create_bp_pdf(df, input$bp_player_name, file)
incProgress(.3, detail="Finalizing report...")
})
showNotification("BP Report generated!", type="message", duration=3)
},
contentType = "application/pdf"
)
output$download_all_coastal_pitchers <- downloadHandler(
filename = function() {
df <- data_pitcher(); req(df)
paste0("Coastal_Pitcher_Reports_", format(parse_game_day(df), "%Y%m%d"), ".zip")
},
content = function(file) {
df <- data_pitcher(); req(df)
pitch_colors <- c(
"Fastball"="#FA8072","Four-Seam"="#FA8072", "FourSeamFastBall" = "#FA8072", "Sinker"="#fdae61",
"Slider"="#A020F0","Sweeper"="magenta","Curveball"="#2c7bb6",
"ChangeUp"="#90EE90","Splitter"="#90EE32","Cutter"="red"
)
pitchers <- df %>% dplyr::filter(PitcherTeam == "COA_CHA") %>%
dplyr::pull(Pitcher) %>% unique() %>% na.omit() %>% sort()
if (!length(pitchers)) {
showNotification("No Coastal pitchers found", type="error", duration=5)
return(NULL)
}
withProgress(message='Generating Coastal Pitcher Reports', value=0, {
tmp <- tempdir(); pdfs <- character(0); total <- length(pitchers)
for (i in seq_along(pitchers)) {
ply <- pitchers[i]; incProgress(1/total, detail=paste("Report for", ply))
out <- file.path(tmp, paste0(gsub(" ","_",ply), "_",
format(parse_game_day(df), "%Y%m%d"),
"_Pitcher_Report.pdf"))
try(create_pitcher_pdf(df, ply, out, pitch_colors), silent = TRUE)
if (file.exists(out)) pdfs <- c(pdfs, out)
}
if (!length(pdfs)) {
showNotification("Failed to generate reports", type="error", duration=5)
return(NULL)
}
zip::zip(zipfile=file, files=basename(pdfs), root=tmp); unlink(pdfs)
})
showNotification("Coastal pitcher ZIP ready!", type="message", duration=5)
},
contentType = "application/zip"
)
output$status_message <- renderUI({
if (input$report_type == "hitter") {
df <- data_hitter(); req(df, input$player_name)
player_df <- df %>% filter(Batter == input$player_name)
if (!nrow(player_df)) return(NULL)
game_date <- parse_game_day(player_df)
div(class = "status-box",
h4("✓ Ready to Generate Hitter Report", style = "margin-top: 0; color: #006F71;"),
p(strong("Player: "), input$player_name),
p(strong("Game Date: "), format(game_date, "%B %d, %Y")),
p(strong("Total Pitches: "), nrow(player_df)))
} else if (input$report_type == "pitcher") {
df <- data_pitcher(); req(df, input$pitcher_name)
pitcher_df <- df %>% filter(Pitcher == input$pitcher_name)
if (!nrow(pitcher_df)) return(NULL)
game_date <- parse_game_day(pitcher_df)
stats <- pitcher_df %>% summarise(pitches=n(), k=sum(KorBB=="Strikeout",na.rm=TRUE), bb=sum(WalkIndicator,na.rm=TRUE))
div(class="status-box",
h4("✓ Ready to Generate Pitcher Report", style="margin-top:0;color:#006F71;"),
p(strong("Pitcher: "), input$pitcher_name),
p(strong("Game Date: "), format(game_date, "%B %d, %Y")),
p(strong("Total Pitches: "), stats$pitches),
p(strong("Strikeouts: "), stats$k, " | ", strong("Walks: "), stats$bb))
} else if (input$report_type == "advanced_pitcher") {
df <- data_pitcher()
req(df, input$advanced_pitcher_name)
pitcher_df <- df %>% filter(Pitcher == input$advanced_pitcher_name)
if (!nrow(pitcher_df)) return(NULL)
game_date <- parse_game_day(pitcher_df)
stats <- pitcher_df %>% summarise(
pitches = n(),
k = sum(KorBB=="Strikeout", na.rm=TRUE),
bb = sum(WalkIndicator, na.rm=TRUE)
)
div(class="status-box",
h4("✓ Ready to Generate Advanced Pitcher Report", style="margin-top:0;color:#006F71;"),
p(strong("Pitcher: "), input$advanced_pitcher_name),
p(strong("Game Date: "), format(game_date, "%B %d, %Y")),
p(strong("Total Pitches: "), stats$pitches),
p(strong("Strikeouts: "), stats$k, " | ", strong("Walks: "), stats$bb),
p(strong("Stuff+ Model: "), ifelse(!is.null(stuffplus_model), "✓ Loaded", "✗ Not Available"),
style = ifelse(!is.null(stuffplus_model), "color: green;", "color: orange;"))
)
} else if (input$report_type == "catcher") {
df <- data_catcher(); req(df, input$catcher_name)
catcher_df <- df %>% filter(Catcher == input$catcher_name)
if (!nrow(catcher_df)) return(NULL)
game_date <- catcher_parse_game_day(catcher_df)
receiving_stats <- catcher_df %>% summarise(strikes_added=sum(StolenStrike,na.rm=TRUE),
strikes_lost=sum(StrikeLost,na.rm=TRUE))
throwing_stats <- catcher_df %>% filter(Notes %in% c('2b out','2b safe','3b out','3b safe','2B out','2B safe','3B out','3B safe')) %>% summarise(throws=n())
div(class = "status-box",
h4("✓ Ready to Generate Catcher Report", style = "margin-top: 0; color: #006F71;"),
p(strong("Catcher: "), input$catcher_name),
p(strong("Game Date: "), format(game_date, "%B %d, %Y")),
p(strong("Total Pitches: "), nrow(catcher_df)),
p(strong("Strikes Stolen: "), receiving_stats$strikes_added, " | ",
strong("Strikes Lost: "), receiving_stats$strikes_lost),
p(strong("Throws Recorded: "), throwing_stats$throws))
} else if (input$report_type == "bp") {
df <- data_bp(); req(df, input$bp_player_name)
player_df <- df %>% filter(Batter == input$bp_player_name)
if (!nrow(player_df)) return(NULL)
stats <- player_df %>%
summarise(
bbe = sum(BIPind, na.rm = TRUE),
avg_ev = round(mean(ExitSpeed[BIPind == 1], na.rm = TRUE), 1),
max_ev = round(max(ExitSpeed[BIPind == 1], na.rm = TRUE), 1)
)
div(class = "status-box",
h4("✓ Ready to Generate BP Report", style = "margin-top: 0; color: #006F71;"),
p(strong("Player: "), input$bp_player_name),
p(strong("Batted Ball Events: "), stats$bbe),
p(strong("Avg Exit Velo: "), stats$avg_ev, " mph"),
p(strong("Max Exit Velo: "), stats$max_ev, " mph"))
}
})
output$leaderboard_ui <- renderUI({
df <- data_hitter()
if (is.null(df)) return(NULL)
leaders <- calculate_leaderboards(df)
gi <- leaders$game_info
make_column <- function(title, data, name_col, value_col, unit) {
if (nrow(data) == 0) {
return(div(class = "leaderboard-column",
div(class = "leaderboard-column-header", span(title), span(unit)),
div("No data available", style = "color: #999; padding: 10px;")))
}
rows <- lapply(seq_len(nrow(data)), function(i) {
logo_html <- if (nzchar(data$Logo[i])) {
tags$img(src = data$Logo[i], class = "leaderboard-logo",
onerror = "this.style.display='none'")
} else tags$span(style = "width: 28px; display: inline-block;")
div(class = "leaderboard-row", logo_html,
span(class = "leaderboard-name", data[[name_col]][i]),
span(class = "leaderboard-value", round(data[[value_col]][i], 1)))
})
div(class = "leaderboard-column",
div(class = "leaderboard-column-header", span(title), span(unit)),
rows)
}
div(class = "leaderboard-section",
# Game Info Bar
div(class = "game-info-bar",
div(class = "game-info-item",
span(class = "game-info-label", "Date"),
span(class = "game-info-value", gi$date)),
div(class = "game-info-item",
span(class = "game-info-label", "Stadium"),
span(class = "game-info-value", gi$stadium)),
div(class = "game-info-item",
span(class = "game-info-label", "Level"),
span(class = "game-info-value", gi$level)),
div(class = "game-info-item",
span(class = "game-info-label", "League"),
span(class = "game-info-value", gi$league)),
div(class = "game-info-item",
span(class = "game-info-label", "Final Score"),
span(class = "game-score", gi$final_score))
),
# Leaders Title
h3(class = "leaderboard-title", icon("trophy"), " Game Leaders"),
# Leaders Grid
div(class = "leaderboard-grid",
make_column("Top Exit Velocity", leaders$exit_velo, "Batter", "MaxEV", "MPH"),
make_column("Top Distances", leaders$distance, "Batter", "MaxDist", "Ft."),
make_column("Top Pitch Velocity", leaders$pitch_velo, "Pitcher", "MaxVelo", "MPH"),
make_column("Swing & Misses", leaders$whiffs, "Pitcher", "Whiffs", "#")))
})
output$preview_content <- renderUI({
if (input$report_type == "hitter") {
df <- data_hitter()
if (is.null(df)) return(div(style = "text-align:center;padding:60px;color:#999;", h4("No data to preview")))
req(input$player_name)
tagList(
h4("At-Bat Visualization", style = "color: #006F71;"),
div(class = "tall-plot", plotOutput("preview_plot_hitter", height = "460px"))
)
} else if (input$report_type == "pitcher") {
df <- data_pitcher()
if (is.null(df)) return(div(style="text-align:center;padding:60px;color:#999;", h4("No data to preview")))
req(input$pitcher_name)
tagList(
h4("Pitch Movement", style="color:#006F71;"),
plotOutput("preview_movement", height="380px"),
br(),
h4("Pitch Locations", style="color:#006F71;"),
plotOutput("preview_location", height="380px"),
br(),
h4("Release Points", style="color:#006F71;"),
plotOutput("preview_release", height="380px")
)
} else if (input$report_type == "advanced_pitcher") {
df <- data_pitcher()
if (is.null(df)) return(div(style="text-align:center;padding:60px;color:#999;", h4("No data to preview")))
req(input$advanced_pitcher_name)
tagList(
h4("Advanced Pitch Movement", style="color:#006F71;"),
plotOutput("preview_advanced_movement", height="380px"),
br(),
h4("Count Usage (Ahead/Behind)", style="color:#006F71;"),
plotOutput("preview_advanced_count", height="380px")
)
} else if (input$report_type == "tableau_pitcher") {
df <- data_pitcher()
if (is.null(df)) return(div(style = "text-align:center;padding:60px;color:#999;", h4("No data to preview")))
req(input$tableau_pitcher_name)
tagList(
h4("Location Report Preview", style = "color:#006F71;"),
plotOutput("preview_tableau_location", height = "380px"),
br(),
h4("Movement Profile Preview", style = "color:#006F71;"),
plotOutput("preview_tableau_movement", height = "380px")
)
} else if (input$report_type == "catcher") {
df <- data_catcher()
if (is.null(df)) return(div(style="text-align:center;padding:60px;color:#999;", h4("No data to preview")))
req(input$catcher_name)
tagList(
h4("Framing Visualization", style = "color: #006F71;"),
plotOutput("preview_framing", height = "350px"),
br(),
h4("Throwing Accuracy", style = "color: #006F71;"),
plotOutput("preview_throwing", height = "400px")
)
} else if (input$report_type == "bp") {
df <- data_bp()
if (is.null(df)) return(div(style = "text-align:center;padding:60px;color:#999;",
h4("No data to preview")))
req(input$bp_player_name)
tagList(
h4("BP Spray Chart", style = "color: #006F71;"),
plotOutput("preview_bp_spray", height = "400px"),
br(),
h4("BP Zone Plot", style = "color: #006F71;"),
plotOutput("preview_bp_zone", height = "400px")
)
}
})
output$preview_plot_hitter <- renderPlot({
df <- data_hitter(); req(df, input$player_name)
player_df <- df %>% filter(Batter == input$player_name)
validate(need(nrow(player_df) > 0, "No rows for selected player"))
game_date <- parse_game_day(player_df)
game_key <- format(game_date, "%Y-%m-%d")
pitch_colors <- c(
"Fastball" = "#FA8072", "Four-Seam" = "#FA8072", "FourSeamFastBall" = "#FA8072","Sinker" = "#fdae61",
"Slider" = "#A020F0", "Sweeper" = "magenta", "Curveball" = "#2c7bb6",
"ChangeUp" = "#90EE90", "Splitter" = "#90EE32", "Cutter" = "red"
)
create_at_bats_plot(df, input$player_name, game_key, pitch_colors)
}, res = 96)
output$preview_movement <- renderPlot({
df <- data_pitcher(); req(df, input$pitcher_name)
pitcher_df <- df %>% filter(Pitcher == input$pitcher_name)
pitch_colors <- c("Fastball"="#FA8072","Four-Seam"="#FA8072","FourSeamFastBall" = "#FA8072","Sinker"="#fdae61","Slider"="#A020F0","Sweeper"="magenta","Curveball"="#2c7bb6","ChangeUp"="#90EE90","Splitter"="#90EE32","Cutter"="red")
create_pitcher_movement_plot(pitcher_df, input$pitcher_name, pitch_colors)
}, res=120)
output$preview_location <- renderPlot({
df <- data_pitcher(); req(df, input$pitcher_name)
pitcher_df <- df %>% filter(Pitcher == input$pitcher_name)
pitch_colors <- c("Fastball"="#FA8072","Four-Seam"="#FA8072","FourSeamFastBall" = "#FA8072","Sinker"="#fdae61","Slider"="#A020F0","Sweeper"="magenta","Curveball"="#2c7bb6","ChangeUp"="#90EE90","Splitter"="#90EE32","Cutter"="red")
create_pitcher_location_plot(pitcher_df, pitch_colors)
}, res=120)
output$preview_bp_spray <- renderPlot({
df <- data_bp(); req(df, input$bp_player_name)
create_bp_spray_chart(input$bp_player_name, df)
}, res = 96)
output$preview_tableau_location <- renderPlot({
df <- data_pitcher(); req(df, input$tableau_pitcher_name)
pitcher_df <- process_tableau_pitcher_data(df) %>% filter(Pitcher == input$tableau_pitcher_name)
create_tableau_location_plot(pitcher_df, tableau_pitch_colors)
}, res = 120)
output$preview_tableau_movement <- renderPlot({
df <- data_pitcher(); req(df, input$tableau_pitcher_name)
pitcher_df <- process_tableau_pitcher_data(df) %>% filter(Pitcher == input$tableau_pitcher_name)
create_tableau_movement_plot(pitcher_df, tableau_pitch_colors)
}, res = 120)
output$preview_bp_zone <- renderPlot({
df <- data_bp(); req(df, input$bp_player_name)
create_bp_zone_plot(input$bp_player_name, df)
}, res = 96)
output$preview_release <- renderPlot({
df <- data_pitcher(); req(df, input$pitcher_name)
pitcher_df <- df %>% filter(Pitcher == input$pitcher_name)
pitch_colors <- c("Fastball"="#FA8072","Four-Seam"="#FA8072","FourSeamFastBall" = "#FA8072","Sinker"="#fdae61","Slider"="#A020F0","Sweeper"="magenta","Curveball"="#2c7bb6","ChangeUp"="#90EE90","Splitter"="#90EE32","Cutter"="red")
create_pitcher_release_plot(pitcher_df, pitch_colors)
}, res=120)
output$preview_framing <- renderPlot({
df <- data_catcher(); req(df, input$catcher_name)
catcher_create_framing_plot(df, input$catcher_name)
}, res = 96)
output$preview_throwing <- renderPlot({
df <- data_catcher(); req(df, input$catcher_name)
catcher_create_throwing_plot(df, input$catcher_name)
}, res = 96)
output$preview_advanced_movement <- renderPlot({
df <- data_pitcher()
req(df, input$advanced_pitcher_name)
pitcher_df <- df %>% filter(Pitcher == input$advanced_pitcher_name)
pitch_colors <- c("Fastball"="#FA8072","Four-Seam"="#FA8072","FourSeamFastBall" = "#FA8072","Sinker"="#fdae61",
"Slider"="#A020F0","Sweeper"="magenta","Curveball"="#2c7bb6",
"ChangeUp"="#90EE90","Splitter"="#90EE32","Cutter"="red")
create_movement_plot(pitcher_df, input$advanced_pitcher_name, pitch_colors)
}, res=120)
output$preview_advanced_count <- renderPlot({
df <- data_pitcher()
req(df, input$advanced_pitcher_name)
pitcher_df <- df %>% filter(Pitcher == input$advanced_pitcher_name)
pitch_colors <- c("Fastball"="#FA8072","Four-Seam"="#FA8072","FourSeamFastBall" = "#FA8072","Sinker"="#fdae61",
"Slider"="#A020F0","Sweeper"="magenta","Curveball"="#2c7bb6",
"ChangeUp"="#90EE90","Splitter"="#90EE32","Cutter"="red")
create_count_usage_plot(pitcher_df, input$advanced_pitcher_name, pitch_colors)
}, res=120)
output$download_hitter <- downloadHandler(
filename = function() {
df <- data_hitter(); req(df, input$player_name)
player_clean <- gsub(" ", "_", input$player_name)
date_str <- format(parse_game_day(df %>% filter(Batter == input$player_name)), "%Y%m%d")
paste0(player_clean, "_", date_str, "_Report.pdf")
},
content = function(file) {
df <- data_hitter(); req(df, input$player_name)
withProgress(message='Generating Hitter PDF', value=0, {
incProgress(.3, detail="Processing data...")
incProgress(.4, detail="Creating visualizations...")
create_postgame_pdf(df, input$player_name, file, bio_hitter())
incProgress(.3, detail="Finalizing report...")
})
showNotification("Hitter report generated!", type="message", duration=3)
},
contentType = "application/pdf"
)
output$download_catcher <- downloadHandler(
filename = function() {
df <- data_catcher(); req(df, input$catcher_name)
catcher_clean <- gsub(" ", "_", input$catcher_name)
date_str <- format(catcher_parse_game_day(df %>% filter(Catcher == input$catcher_name)), "%Y%m%d")
paste0(catcher_clean, "_", date_str, "_Catcher_Report.pdf")
},
content = function(file) {
df <- data_catcher(); req(df, input$catcher_name)
withProgress(message='Generating Catcher PDF', value=0, {
incProgress(.4, detail="Building visualizations...")
catcher_create_catcher_pdf(df, input$catcher_name, file, bio_catch())
incProgress(.6, detail="Finalizing...")
})
showNotification("Catcher report generated!", type="message", duration=3)
},
contentType = "application/pdf"
)
output$download_umpire <- downloadHandler(
filename = function() {
df <- data_umpire()
req(df)
date_str <- format(max(df$Date, na.rm = TRUE), "%Y%m%d")
ump_name <- if (!is.null(input$umpire_name) && nzchar(input$umpire_name)) {
paste0(gsub(" ", "_", input$umpire_name), "_")
} else ""
paste0(ump_name, "Umpire_Report_", date_str, ".pdf")
},
content = function(file) {
df <- data_umpire()
req(df)
withProgress(message = 'Generating Umpire PDF', value = 0, {
incProgress(.5, detail = "Creating visualizations...")
umpire_create_report_pdf(df, file, umpire_name = input$umpire_name)
incProgress(.5, detail = "Finalizing report...")
})
showNotification("Umpire report generated!", type = "message", duration = 3)
},
contentType = "application/pdf"
)
output$download_advanced_pitcher <- downloadHandler(
filename = function() {
df <- data_pitcher()
req(df, input$advanced_pitcher_name)
pitcher_clean <- gsub(" ", "_", input$advanced_pitcher_name)
date_str <- format(parse_game_day(df %>% filter(Pitcher == input$advanced_pitcher_name)), "%Y%m%d")
paste0(pitcher_clean, "_", date_str, "_Advanced_Pitcher_Report.pdf")
},
content = function(file) {
df <- data_pitcher()
req(df, input$advanced_pitcher_name)
withProgress(message='Generating Advanced Pitcher PDF', value=0, {
incProgress(.3, detail="Processing data with Stuff+ model...")
incProgress(.4, detail="Creating advanced visualizations...")
create_advanced_pitcher_pdf(df, input$advanced_pitcher_name, file)
incProgress(.3, detail="Finalizing report...")
})
showNotification("Advanced Pitcher report generated!", type="message", duration=3)
},
contentType = "application/pdf"
)
output$download_all_coastal_advanced_pitchers <- downloadHandler(
filename = function() {
df <- data_pitcher()
req(df)
paste0("Coastal_Advanced_Pitcher_Reports_", format(parse_game_day(df), "%Y%m%d"), ".zip")
},
content = function(file) {
df <- data_pitcher()
req(df)
pitchers <- df %>% filter(PitcherTeam == "COA_CHA") %>% pull(Pitcher) %>% unique() %>% na.omit() %>% sort()
if (!length(pitchers)) {
showNotification("No Coastal pitchers found", type="error", duration=5)
return(NULL)
}
withProgress(message='Generating Advanced Pitcher Reports', value=0, {
tmp <- tempdir()
pdfs <- character(0)
total <- length(pitchers)
for (i in seq_along(pitchers)) {
ply <- pitchers[i]
incProgress(1/total, detail=paste("Advanced report for", ply))
out <- file.path(tmp, paste0(gsub(" ","_",ply), "_",
format(parse_game_day(df), "%Y%m%d"),
"_Advanced_Pitcher_Report.pdf"))
try(create_advanced_pitcher_pdf(df, ply, out), silent = TRUE)
if (file.exists(out)) pdfs <- c(pdfs, out)
}
if (!length(pdfs)) {
showNotification("Failed to generate reports", type="error", duration=5)
return(NULL)
}
zip::zip(zipfile=file, files=basename(pdfs), root=tmp)
unlink(pdfs)
})
showNotification("Advanced Pitcher ZIP ready!", type="message", duration=5)
},
contentType = "application/zip"
)
output$download_tableau_pitcher <- downloadHandler(
filename = function() {
df <- data_pitcher(); req(df, input$tableau_pitcher_name)
pitcher_clean <- gsub(" ", "_", input$tableau_pitcher_name)
date_str <- format(parse_game_day(df %>% filter(Pitcher == input$tableau_pitcher_name)), "%Y%m%d")
paste0(pitcher_clean, "_", date_str, "_Tableau_Report.pdf")
},
content = function(file) {
df <- data_pitcher(); req(df, input$tableau_pitcher_name)
withProgress(message = 'Generating Tableau Pitcher PDF', value = 0, {
incProgress(.5, detail = "Creating visualizations...")
create_tableau_pitcher_pdf(df, input$tableau_pitcher_name, file)
incProgress(.5, detail = "Finalizing...")
})
showNotification("Tableau Pitcher report generated!", type = "message", duration = 3)
},
contentType = "application/pdf"
)
output$download_all_coastal_hitters <- downloadHandler(
filename = function() {
df <- data_hitter(); req(df)
date <- parse_game_day(df)
paste0("Coastal_Carolina_Hitter_Reports_", format(date, "%Y%m%d"), ".zip")
},
content = function(file) {
df <- data_hitter(); req(df)
players <- df %>% filter(BatterTeam=="COA_CHA") %>% pull(Batter) %>% unique() %>% na.omit() %>% sort()
if (!length(players)) { showNotification("No Coastal Carolina players found", type="error", duration=5); return(NULL) }
withProgress(message='Generating Coastal Hitter Reports', value=0, {
tmp <- tempdir(); pdfs <- character(0); total <- length(players)
for (i in seq_along(players)) {
ply <- players[i]; incProgress(1/total, detail=paste("Report for", ply))
out <- file.path(tmp, paste0(gsub(" ","_",ply), "_", format(parse_game_day(df), "%Y%m%d"), "_Report.pdf"))
try(create_postgame_pdf(df, ply, out, bio_hitter()), silent = TRUE)
if (file.exists(out)) pdfs <- c(pdfs, out)
}
if (!length(pdfs)) { showNotification("Failed to generate any hitter reports", type="error", duration=5); return(NULL) }
zip::zip(zipfile=file, files=basename(pdfs), root=tmp); unlink(pdfs)
})
showNotification("Coastal hitter ZIP ready!", type="message", duration=5)
},
contentType = "application/zip"
)
output$download_all_coastal_tableau_pitchers <- downloadHandler(
filename = function() {
df <- data_pitcher(); req(df)
paste0("Coastal_Tableau_Pitcher_Reports_", format(parse_game_day(df), "%Y%m%d"), ".zip")
},
content = function(file) {
df <- data_pitcher(); req(df)
pitchers <- df %>% filter(PitcherTeam == "COA_CHA") %>% pull(Pitcher) %>% unique() %>% na.omit() %>% sort()
if (!length(pitchers)) {
showNotification("No Coastal pitchers found", type="error", duration=5)
return(NULL)
}
withProgress(message='Generating Coastal Tableau Pitcher Reports', value=0, {
tmp <- tempdir(); pdfs <- character(0); total <- length(pitchers)
for (i in seq_along(pitchers)) {
ply <- pitchers[i]; incProgress(1/total, detail=paste("Report for", ply))
out <- file.path(tmp, paste0(gsub(" ","_",ply), "_",
format(parse_game_day(df), "%Y%m%d"),
"_Tableau_Report.pdf"))
try(create_tableau_pitcher_pdf(df, ply, out), silent = TRUE)
if (file.exists(out)) pdfs <- c(pdfs, out)
}
if (!length(pdfs)) {
showNotification("Failed to generate reports", type="error", duration=5)
return(NULL)
}
zip::zip(zipfile=file, files=basename(pdfs), root=tmp); unlink(pdfs)
})
showNotification("Coastal Tableau pitcher ZIP ready!", type="message", duration=5)
},
contentType = "application/zip"
)
output$download_all_ccu_catchers <- downloadHandler(
filename = function() {
df <- data_catcher(); req(df)
date <- catcher_parse_game_day(df)
paste0("CCU_Catcher_Reports_", format(date, "%Y%m%d"), ".zip")
},
content = function(file) {
df <- data_catcher(); req(df)
ccu_catchers <- df %>% filter(CatcherTeam=="COA_CHA") %>% pull(Catcher) %>% unique() %>% na.omit() %>% sort()
if (!length(ccu_catchers)) { showNotification("No CCU catchers found", type="error", duration=5); return(NULL) }
withProgress(message='Generating CCU Catcher Reports', value=0, {
tmp <- tempdir(); pdfs <- character(0); total <- length(ccu_catchers)
for (i in seq_along(ccu_catchers)) {
ct <- ccu_catchers[i]; incProgress(1/total, detail=paste("Report for", ct))
out <- file.path(tmp, paste0(gsub(" ","_",ct), "_", format(catcher_parse_game_day(df), "%Y%m%d"), "_Catcher_Report.pdf"))
try(catcher_create_catcher_pdf(df, ct, out, bio_catch()), silent = TRUE)
if (file.exists(out)) pdfs <- c(pdfs, out)
}
if (!length(pdfs)) { showNotification("Failed to generate any catcher reports", type="error", duration=5); return(NULL) }
zip::zip(zipfile=file, files=basename(pdfs), root=tmp); unlink(pdfs)
})
showNotification("✅ CCU catcher ZIP ready!", type="message", duration=5)
},
contentType = "application/zip"
)
}
shinyApp(ui = ui, server = server)