# =========================================================================
# Hyannis Harbor Hawks — Lower Level Overview App
# Two tabs: (1) Hitter Overview (2) Pitcher Overview
# Data source: college_26_LowerLevels.parquet (PastSeasonData private dataset)
# UI theme comes from the attached theme.R
# =========================================================================
library(shiny)
library(bslib)
library(glue)
library(httr)
library(arrow)
library(dplyr)
library(ggplot2)
library(plotly)
library(gt)
library(gtExtras)
library(tidyr)
library(stringr)
library(scales)
library(base64enc)
library(magick)
source("theme.R")
`%||%` <- function(a, b) if (!is.null(a)) a else b
# Hawks logo (absolute path so the working dir doesn't matter in Docker)
logo_b64 <- base64enc::base64encode("/app/HHLogo.png")
logo_uri <- paste0("data:image/png;base64,", logo_b64)
# Load + mirror pitcher silhouette for the inferred arm angle graphic
img <- magick::image_read("https://i.imgur.com/RLlIVrc.png")
img_raster_R <- as.raster(img)
img_raster_L <- as.raster(magick::image_flop(img))
# ============================================================
# Data Loading
# ============================================================
# Union of the columns the two overview tabs need
needed_cols <- c(
# identity
"Batter", "Pitcher", "BatterSide", "BatterTeam",
"PitcherThrows", "Date",
# count
"Balls", "Strikes", "PitchofPA",
# pitch traits
"TaggedPitchType", "RelSpeed", "SpinRate", "InducedVertBreak", "HorzBreak",
"VertApprAngle", "Extension", "RelSide", "RelHeight", "stuff_plus",
# location
"PlateLocSide", "PlateLocHeight",
# results
"PitchCall", "PlayResult", "KorBB", "AutoHitType", "event_type",
"ExitSpeed", "Angle", "Bearing", "Distance",
# expected stats
"prob_0", "prob_1", "prob_2", "prob_3", "prob_4", "xwOBA", "woba", "slg",
# flags
"is_hit", "is_at_bat", "is_plate_appearance", "is_whiff", "is_swing",
"in_zone", "in_zone_whiff", "is_csw", "is_hard_hit", "is_k", "is_walk",
"on_base", "chase"
)
# Download a parquet from the HF private dataset (HF_Token set as a Space secret)
download_private_parquet <- function(repo_id, filename, max_retries = 3) {
url <- paste0("https://huggingface.co/datasets/", repo_id, "/resolve/main/", filename)
api_key <- Sys.getenv("HF_Token")
if (api_key == "") stop("HF_Token environment variable is not set.")
for (attempt in 1:max_retries) {
tryCatch({
response <- GET(url, add_headers(Authorization = paste("Bearer", api_key)), timeout(60))
if (status_code(response) == 200) {
tmp <- tempfile(fileext = ".parquet")
writeBin(content(response, "raw"), tmp)
return(read_parquet(tmp, col_select = any_of(needed_cols)))
} else {
warning(paste("Attempt", attempt, "failed:", status_code(response)))
}
}, error = function(e) {
warning(paste("Attempt", attempt, "error:", e$message))
if (attempt < max_retries) Sys.sleep(2)
})
}
stop(paste("Failed after", max_retries, "attempts"))
}
# Single data source for this app
all_data <- download_private_parquet("HyannisHarborHawksCCBL/PastSeasonData",
"college_26_LowerLevels.parquet") %>%
mutate(Date = as.Date(Date))
# ============================================================
# Shared helper functions
# ============================================================
# Pitch colors
pitch_colors <- c(
"Fastball" = "red", "Changeup" = "green4", "Curveball" = "blue",
"Slider" = "gold", "Sweeper" = "maroon2", "Splitter" = "lightblue",
"Cutter" = "brown", "Sinker" = "orange", "Knuckleball" = "#6A89A7"
)
# blue - white - red color gradient
make_color_fn <- function(min_val, max_val,
low_color = "#3661ad", mid_color = "white", high_color = "#d82029") {
if (is.na(min_val) || is.na(max_val) || min_val == max_val) return(function(x) "white")
gradient_fn <- col_numeric(
palette = c(low_color, mid_color, high_color),
domain = c(min_val, max_val),
na.color = "white"
)
function(x) {
x_clamped <- pmin(pmax(x, min_val, na.rm = TRUE), max_val, na.rm = TRUE)
ifelse(is.na(x), "white", gradient_fn(x_clamped))
}
}
# red - white - blue color gradient
make_color_fn_reverse <- function(min_val, max_val,
low_color = "#d82029", mid_color = "white", high_color = "#3661ad") {
make_color_fn(min_val, max_val, low_color, mid_color, high_color)
}
# Curves for spray-chart fence
make_curve_segments <- function(x_start, y_start, x_end, y_end, curvature = 0.3, n = 40) {
t <- seq(0, 1, length.out = n)
cx <- (x_start + x_end) / 2 + curvature * (y_end - y_start)
cy <- (y_start + y_end) / 2 - curvature * (x_end - x_start)
x <- (1 - t)^2 * x_start + 2 * (1 - t) * t * cx + t^2 * x_end
y <- (1 - t)^2 * y_start + 2 * (1 - t) * t * cy + t^2 * y_end
idx <- seq_len(n - 1)
tibble(x = x[idx], y = y[idx], xend = x[idx + 1], yend = y[idx + 1])
}
# ============================================================
# HITTER OVERVIEW FUNCTIONS
# ============================================================
# Reference data for conditional formatting
reference_stats <- function(df) {
df %>% summarise(
PA = sum(is_plate_appearance, na.rm = TRUE),
Avg = mean(is_hit, na.rm = TRUE),
OBP = mean(on_base, na.rm = TRUE),
SLG = mean(slg, na.rm = TRUE),
`Chase%` = 100 * mean(chase, na.rm = TRUE),
`Whiff%` = 100 * mean(is_whiff, na.rm = TRUE),
`Z-Con%` = 100 * (1 - mean(in_zone_whiff, na.rm = TRUE)),
`Avg EV` = mean(ExitSpeed, na.rm = TRUE),
EV50 = quantile(ExitSpeed, .5, na.rm = TRUE),
EV90 = quantile(ExitSpeed, .9, na.rm = TRUE),
`HH%` = 100 * mean(is_hard_hit, na.rm = TRUE),
`GB%` = 100 * (sum((AutoHitType == "GroundBall" & PitchCall == "InPlay"), na.rm = TRUE) /
sum(PitchCall == "InPlay", na.rm = TRUE)),
xwOBAcon = mean(xwOBA[PitchCall == "InPlay"], na.rm = TRUE),
xwOBA = mean(xwOBA, na.rm = TRUE)
)
}
# overall hitter summary table
batter_summary <- function(data, batter_name) {
data <- data %>%
filter(Batter == batter_name) %>%
mutate(PitchGroup = case_when(
TaggedPitchType %in% c("Fastball","Cutter","Sinker","FastBall") ~ "Fastball",
TaggedPitchType %in% c("Slider","Curveball","Sweeper") ~ "Breaking",
TaggedPitchType %in% c("Changeup","Splutter","Knuckleball") ~ "Offspeed",
TRUE ~ "Other"
))
summary_stats <- function(df) {
df %>% summarise(
PA = sum(is_plate_appearance, na.rm = TRUE),
Avg = mean(is_hit, na.rm = TRUE),
OBP = mean(on_base, na.rm = TRUE),
SLG = mean(slg, na.rm = TRUE),
`Chase%` = 100 * mean(chase, na.rm = TRUE),
`Whiff%` = 100 * mean(is_whiff, na.rm = TRUE),
`Z-Con%` = 100 * (1 - mean(in_zone_whiff, na.rm = TRUE)),
`Avg EV` = mean(ExitSpeed, na.rm = TRUE),
EV50 = quantile(ExitSpeed, .5, na.rm = TRUE),
EV90 = quantile(ExitSpeed, .9, na.rm = TRUE),
`HH%` = 100 * mean(is_hard_hit, na.rm = TRUE),
`GB%` = 100 * (sum((AutoHitType == "GroundBall" & PitchCall == "InPlay"), na.rm = TRUE) /
sum(PitchCall == "InPlay", na.rm = TRUE)),
xwOBAcon = mean(xwOBA[PitchCall == "InPlay"], na.rm = TRUE),
xwOBA = mean(xwOBA, na.rm = TRUE)
)
}
pitch_summary <- data %>% group_by(PitchGroup) %>% summary_stats() %>% rename(GROUP = PitchGroup) %>% filter(GROUP != "Other")
handedness_summary <- data %>% group_by(PitcherThrows) %>% summary_stats() %>% rename(GROUP = PitcherThrows)
overall_summary <- data %>% summary_stats() %>% mutate(GROUP = "Overall")
full_summary <- bind_rows(pitch_summary, handedness_summary, overall_summary)
full_summary %>%
gt() %>% gt_theme_espn() %>%
tab_header(title = paste0("Batter Summary — ", batter_name)) %>%
tab_options(heading.align = "center") %>%
sub_missing(missing_text = "—") %>%
fmt_number(columns = c(Avg, OBP, SLG, xwOBA, xwOBAcon), decimals = 3) %>%
fmt_number(columns = c(`Chase%`,`Whiff%`,`Z-Con%`,`Avg EV`,EV50,EV90,`HH%`,`GB%`), decimals = 1) %>%
fmt_number(columns = PA, decimals = 0) %>%
data_color(columns = `Z-Con%`, fn = make_color_fn(lg_ref$`Z-Con%` - 10, lg_ref$`Z-Con%` + 10)) %>%
data_color(columns = `Avg EV`, fn = make_color_fn(lg_ref$`Avg EV` - 6, lg_ref$`Avg EV` + 6)) %>%
data_color(columns = EV50, fn = make_color_fn(lg_ref$EV50 - 6, lg_ref$EV50 + 6)) %>%
data_color(columns = EV90, fn = make_color_fn(lg_ref$EV90 - 6, lg_ref$EV90 + 6)) %>%
data_color(columns = `HH%`, fn = make_color_fn(lg_ref$`HH%` - 12, lg_ref$`HH%` + 12)) %>%
data_color(columns = xwOBA, fn = make_color_fn(lg_ref$xwOBA - .080, lg_ref$xwOBA + .040)) %>%
data_color(columns = xwOBAcon, fn = make_color_fn(lg_ref$xwOBAcon - .080, lg_ref$xwOBAcon + .040)) %>%
data_color(columns = `Chase%`, fn = make_color_fn_reverse(lg_ref$`Chase%` - 10, lg_ref$`Chase%` + 10)) %>%
data_color(columns = `Whiff%`, fn = make_color_fn_reverse(lg_ref$`Whiff%` - 10, lg_ref$`Whiff%` + 10)) %>%
tab_style(style = cell_text(color = "#002855"),
locations = cells_body(columns = everything(), rows = everything())) %>%
tab_style(style = cell_text(color = "#002855"),
locations = cells_column_labels(columns = everything())) %>%
tab_style(style = cell_text(color = "#002855", size = px(22)),
locations = cells_title(groups = "title")) %>%
tab_style(style = cell_borders(sides = c("top","bottom","left","right"),
color = "#002855", weight = px(.5)),
locations = cells_body(columns = everything(), rows = everything())) %>%
tab_style(style = cell_borders(sides = "top", color = "#002855", weight = px(10)),
locations = cells_body(rows = GROUP %in% c("Left"))) %>%
tab_style(style = cell_borders(sides = "bottom", color = "#002855", weight = px(10)),
locations = cells_body(rows = GROUP %in% c("Right"))) %>%
tab_style(style = list(cell_text(weight = "bold"), cell_fill(color = "#f0f0f0")),
locations = cells_body(rows = GROUP == "Overall")) %>%
tab_style(style = list(cell_fill(color = "red"), cell_text(color = "white", weight = "bold")),
locations = cells_body(columns = GROUP, rows = GROUP == "Fastball")) %>%
tab_style(style = list(cell_fill(color = "green4"), cell_text(color = "white", weight = "bold")),
locations = cells_body(columns = GROUP, rows = GROUP == "Offspeed")) %>%
tab_style(style = list(cell_fill(color = "blue"), cell_text(color = "white", weight = "bold")),
locations = cells_body(columns = GROUP, rows = GROUP == "Breaking")) %>%
tab_style(style = list(cell_fill(color = "#2962FF"), cell_text(color = "white", weight = "bold")),
locations = cells_body(columns = GROUP, rows = GROUP == "Right")) %>%
tab_style(style = list(cell_fill(color = "#C62828"), cell_text(color = "white", weight = "bold")),
locations = cells_body(columns = GROUP, rows = GROUP == "Left")) %>%
tab_style(style = list(cell_fill(color = "orange"), cell_text(color = "white", weight = "bold")),
locations = cells_body(columns = GROUP, rows = GROUP == "Overall"))
}
# percentile references for the percentile chart
get_percentile_refs_hitter <- function(data) {
data %>%
group_by(Batter) %>%
summarise(
xwOBA = mean(xwOBA, na.rm = TRUE),
xBA = 1 - mean(prob_0, na.rm = TRUE),
`Avg EV` = mean(ExitSpeed, na.rm = TRUE),
`HH%` = 100 * mean(is_hard_hit, na.rm = TRUE),
`SS%` = 100 * sum(PitchCall == "InPlay" & Angle >= 8 & Angle <= 32, na.rm = TRUE) /
sum(PitchCall == "InPlay", na.rm = TRUE),
`P-Air%` = 100 * sum(if_else(BatterSide == "Right", Bearing < -15, Bearing > 15) &
AutoHitType %in% c("FlyBall","LineDrive","Popup") &
PitchCall == "InPlay", na.rm = TRUE) /
sum(PitchCall == "InPlay", na.rm = TRUE),
`HR/FB%` = 100 * sum(AutoHitType == "FlyBall" & PlayResult == "Homerun" &
PitchCall == "InPlay", na.rm = TRUE) /
sum(AutoHitType == "FlyBall" & PitchCall == "InPlay", na.rm = TRUE),
`Chase%` = 100 * mean(chase, na.rm = TRUE),
`Whiff%` = 100 * mean(is_whiff, na.rm = TRUE),
`Z-Con%` = 100 * (1 - mean(in_zone_whiff, na.rm = TRUE)),
`K%` = 100 * mean(is_k, na.rm = TRUE),
`BB%` = 100 * mean(is_walk, na.rm = TRUE),
number = sum(is_plate_appearance, na.rm = TRUE),
.groups = "drop"
) %>%
select(-Batter) %>%
filter(number >= 10) %>%
select(-number)
}
# savant-style percentile chart
batter_percentiles <- function(df, batter, summary_ref) {
batter_split <- df %>%
filter(!is.na(TaggedPitchType), Batter == batter) %>%
summarise(
xwOBA = mean(xwOBA, na.rm = TRUE),
xBA = 1 - mean(prob_0, na.rm = TRUE),
`Avg EV` = mean(ExitSpeed, na.rm = TRUE),
`HH%` = 100 * mean(is_hard_hit, na.rm = TRUE),
`SS%` = 100 * sum(PitchCall == "InPlay" & Angle >= 8 & Angle <= 32, na.rm = TRUE) /
sum(PitchCall == "InPlay", na.rm = TRUE),
`P-Air%` = 100 * sum(if_else(BatterSide == "Right", Bearing < -15, Bearing > 15) &
AutoHitType %in% c("FlyBall","LineDrive","Popup") &
PitchCall == "InPlay", na.rm = TRUE) /
sum(PitchCall == "InPlay", na.rm = TRUE),
`HR/FB%` = 100 * sum(AutoHitType == "FlyBall" & PlayResult == "Homerun" &
PitchCall == "InPlay", na.rm = TRUE) /
sum(AutoHitType == "FlyBall" & PitchCall == "InPlay", na.rm = TRUE),
`Chase%` = 100 * mean(chase, na.rm = TRUE),
`Whiff%` = 100 * mean(is_whiff, na.rm = TRUE),
`Z-Con%` = 100 * (1 - mean(in_zone_whiff, na.rm = TRUE)),
`K%` = 100 * mean(is_k, na.rm = TRUE),
`BB%` = 100 * mean(is_walk, na.rm = TRUE),
.groups = "drop"
)
combined <- bind_cols(
batter_split,
batter_split %>%
mutate(across(everything(), ~ {
if (cur_column() %in% c("Chase%","Whiff%","K%")) {
round(mean(summary_ref[[cur_column()]] >= .x, na.rm = TRUE) * 100)
} else {
round(mean(summary_ref[[cur_column()]] <= .x, na.rm = TRUE) * 100)
}
}, .names = "{.col}_percentile")) %>%
select(matches("_percentile"))
)
custom_order <- c("xwOBA","xBA","Avg EV","HH%","SS%",
"P-Air%","HR/FB%","Chase%","Whiff%","Z-Con%","K%","BB%")
metrics <- combined %>%
pivot_longer(cols = everything(), names_to = "Metric_Type", values_to = "Value") %>%
mutate(
Metric = str_remove(Metric_Type, "_percentile"),
Type = ifelse(str_detect(Metric_Type, "_percentile"), "Percentile", "Value")
) %>%
select(Metric, Type, Value) %>%
pivot_wider(names_from = Type, values_from = Value) %>%
filter(!is.na(Percentile) & !is.na(Value)) %>%
mutate(
Metric = factor(Metric, levels = custom_order),
color = gradient_n_pal(c("#164EA7","#c7dcdc","#C8102E"))(Percentile / 100),
Value = case_when(
Metric %in% c("xwOBA","xBA") ~ sprintf("%.3f", Value),
TRUE ~ sprintf("%.1f", Value)
)
)
ggplot(metrics, aes(y = factor(Metric, levels = rev(custom_order)))) +
geom_tile(aes(x = 50, width = 100), fill = "#c7dcdc", alpha = 0.3, height = 0.25) +
geom_tile(aes(x = Percentile / 2, width = Percentile, fill = color), height = 0.7) +
annotate("segment", x = c(10,50,90), xend = c(10,50,90),
y = 0, yend = length(custom_order) + 0.35,
color = "white", linewidth = 1.5, alpha = 0.5) +
geom_segment(aes(x = -2, xend = -16,
y = as.numeric(factor(Metric, levels = rev(custom_order))) - 0.3,
yend = as.numeric(factor(Metric, levels = rev(custom_order))) - 0.3),
linetype = "longdash", color = "#399098", linewidth = 1) +
geom_segment(aes(x = 102, xend = 113,
y = as.numeric(factor(Metric, levels = rev(custom_order))) - 0.3,
yend = as.numeric(factor(Metric, levels = rev(custom_order))) - 0.3),
linetype = "longdash", color = "#399098", linewidth = 1) +
geom_text(aes(x = -3, label = Metric), hjust = 1, size = 5) +
geom_text(aes(x = 103, label = Value), hjust = 0, size = 5) +
geom_point(aes(x = Percentile, fill = color),
color = "white", size = 12, shape = 21, stroke = 2.25) +
geom_text(aes(x = Percentile, label = Percentile),
size = 4.5, color = "white", fontface = "bold") +
scale_x_continuous(limits = c(-16, 113), expand = c(0,0)) +
scale_fill_identity() + scale_color_identity() +
theme_minimal() +
theme(axis.text = element_blank(), axis.title = element_blank(),
panel.grid = element_blank())
}
# hits spray chart
spray_chart <- function(data, batter_name) {
hits <- data %>%
filter(Batter == batter_name,
PlayResult %in% c("Single","Double","Triple","Homerun","HomeRun", "Out", "Error",
"Sacrifice", "FieldersChoice")) %>%
mutate(
hit_type = case_when(
PlayResult == "Single" ~ "SINGLE",
PlayResult == "Double" ~ "DOUBLE",
PlayResult == "Triple" ~ "TRIPLE",
PlayResult %in% c("Homerun","HomeRun") ~ "HOME RUN",
PlayResult %in% c("Out", "Sacrifice", "FieldersChoice") ~ "OUT",
PlayResult == "Error" ~ "REACH ON ERROR"
),
hit_type = factor(hit_type, levels = c("SINGLE","DOUBLE","TRIPLE","HOME RUN", "OUT", "REACH ON ERROR")),
spray_x = Distance * sin(Bearing * pi / 180),
spray_y = Distance * cos(Bearing * pi / 180)
)
curve1 <- make_curve_segments(89.095, 89.095, -3, 160, curvature = 0.36, n = 60)
curve2 <- make_curve_segments(-89.095, 89.095, 3, 160, curvature = -0.36, n = 60)
fence_x <- c(0, curve1$x, curve1$xend[nrow(curve1)],
curve2$xend[nrow(curve2)], rev(curve2$x), 0)
fence_y <- c(0, curve1$y, curve1$yend[nrow(curve1)],
curve2$yend[nrow(curve2)], rev(curve2$y), 0)
theta <- seq(-45, 45, length.out = 100) * pi / 180
fair_x <- c(0, 350 * sin(theta), 0)
fair_y <- c(0, 350 * cos(theta), 0)
colors <- c("SINGLE" = "yellow", "DOUBLE" = "orange",
"TRIPLE" = "purple", "HOME RUN" = "red",
"OUT" = "#808080", "REACH ON ERROR" = "green2")
p <- ggplot() +
geom_polygon(aes(x = fair_x, y = fair_y), fill = "#50BA49", alpha = 0.5) +
geom_polygon(aes(x = fence_x, y = fence_y), fill = "#B06F63", alpha = 0.5) +
geom_segment(aes(x = 0, y = 0, xend = 63.64, yend = 63.64), color = "black", linewidth = .5) +
geom_segment(aes(x = 63.64, y = 63.64, xend = 0, yend = 127.28), color = "black", linewidth = .5) +
geom_segment(aes(x = 0, y = 127.28, xend = -63.64, yend = 63.64), color = "black", linewidth = .5) +
geom_segment(aes(x = -63.64, y = 63.64, xend = 0, yend = 0), color = "black", linewidth = .5) +
geom_segment(data = curve1, aes(x=x,y=y,xend=xend,yend=yend),
linewidth=0.6, color="black", lineend="round") +
geom_segment(data = curve2, aes(x=x,y=y,xend=xend,yend=yend),
linewidth=0.6, color="black", lineend="round") +
geom_segment(aes(x=-1, y=160, xend=1, yend=160), color="black") +
annotate("text", x=c(-155,155), y=135, label="200", size=2.5, color="black") +
annotate("text", x=c(-190,190), y=170, label="250", size=2.5, color="black") +
annotate("text", x=c(-227,227), y=205, label="300", size=2.5, color="black") +
annotate("text", x=c(-262,262), y=242, label="350", size=2.5, color="black") +
annotate("text", x=c(-297,297), y=277, label="400", size=2.5, color="black") +
geom_segment(aes(x=0,y=0,xend= 318.2, yend=318.2), color="black", linewidth=0.5) +
geom_segment(aes(x=0,y=0,xend=-318.2, yend=318.2), color="black", linewidth=0.5) +
geom_point(data = hits,
aes(x=spray_x, y=spray_y, fill=hit_type,
text=paste0(hit_type,"
",
"EV: ", round(ExitSpeed,1),"
",
"LA: ", round(Angle,1), "
",
"Dist: ", round(Distance,0), " ft
",
"xwOBA: ",round(xwOBA,3), "
",
"Pitch: ",TaggedPitchType)),
color="black", shape=21, size=3, alpha=0.85, stroke=0.8) +
scale_fill_manual(values = colors, name = "") +
labs(title = paste0("Hits Spray Chart — ", batter_name)) +
ylim(-30, 400) + xlim(-330, 330) + coord_equal() +
theme_void() +
theme(plot.title = element_text(hjust=0.5, face="bold", size=16),
legend.position = "right",
plot.background = element_rect(fill="white", color=NA))
ggplotly(p, tooltip="text") %>% layout(hovermode="closest")
}
# ============================================================
# PITCHER OVERVIEW FUNCTIONS
# ============================================================
# movement plot
movement_plot <- function(data, pitcher_name) {
data <- data %>% filter(Pitcher == pitcher_name, TaggedPitchType != "Other")
circle_shape <- function(r) {
list(type = "circle", x0 = -r, x1 = r, y0 = -r, y1 = r,
line = list(color = "rgba(128,128,128,0.5)", width = 1,
dash = if (r %in% c(6, 18)) "dot" else "solid"),
fillcolor = "transparent")
}
shapes <- c(
list(
list(type = "line", x0 = 0, x1 = 0, y0 = -28, y1 = 28,
line = list(color = "rgba(128,128,128,0.6)", dash = "dash", width = 1)),
list(type = "line", x0 = -28, x1 = 28, y0 = 0, y1 = 0,
line = list(color = "rgba(128,128,128,0.6)", dash = "dash", width = 1))
),
lapply(c(6, 12, 18, 24), circle_shape)
)
tick_labels <- c(
lapply(c(24, 12, 12, 24), function(val) {
list(x = val, y = 0, text = as.character(val), showarrow = FALSE,
yshift = -12, font = list(size = 10, color = "grey40"))
}),
lapply(c(24, 12, 12, 24), function(val) {
list(x = 0, y = val, text = as.character(val), showarrow = FALSE,
xshift = -12, font = list(size = 10, color = "grey40"))
}),
list(
list(x = 0.5, y = 0, xref = "paper", yref = "paper",
text = "Horizontal Break (inches)", showarrow = FALSE,
yshift = -15, font = list(size = 13, color = "black")),
list(x = 0, y = 0.5, xref = "paper", yref = "paper",
text = "Induced Vertical Break (inches)", showarrow = FALSE,
xanchor = "center", yanchor = "middle", xshift = 75,
textangle = -90, font = list(size = 13, color = "black"))
)
)
plot_ly(data, x = ~HorzBreak, y = ~InducedVertBreak, color = ~TaggedPitchType,
colors = pitch_colors, type = "scatter", mode = "markers",
marker = list(size = 10, opacity = 0.8), hoverinfo = "text",
text = ~paste("Pitch:", TaggedPitchType,
"
HB:", round(HorzBreak, 1),
"
IVB:", round(InducedVertBreak, 1),
"
Velo:", round(RelSpeed, 1),
"
Stuff Plus:", round(stuff_plus, 1))) %>%
layout(
title = list(text = paste0("Movement Plot — ", pitcher_name),
font = list(size = 18), x = 0.5, y = 0.98,
xanchor = "center", xref = "paper"),
xaxis = list(title = "", showticklabels = FALSE, showgrid = FALSE,
zeroline = FALSE, range = c(-30, 30),
scaleanchor = "y", scaleratio = 1),
yaxis = list(title = "", showticklabels = FALSE, showgrid = FALSE,
zeroline = FALSE, range = c(-30, 30)),
shapes = shapes, annotations = tick_labels,
plot_bgcolor = "white", paper_bgcolor = "white",
legend = list(orientation = "v", x = 0.85, y = 0.95, xanchor = "left",
yanchor = "top", font = list(size = 12),
bgcolor = "rgba(255,255,255,0.8)"),
autosize = TRUE, margin = list(l = 40, r = 10, t = 40, b = 40),
showlegend = TRUE
)
}
# Pitcher league reference for conditional formatting
pitcher_reference_stats <- function(df) {
df %>% summarise(
`WHIFF%` = 100 * mean(is_whiff, na.rm = TRUE),
`IZW%` = 100 * sum(is_whiff[in_zone == 1], na.rm = TRUE) /
sum(is_swing[in_zone == 1], na.rm = TRUE),
`CSW%` = 100 * mean(is_csw, na.rm = TRUE),
`CHASE%` = 100 * mean(chase, na.rm = TRUE),
`ZONE%` = 100 * mean(in_zone, na.rm = TRUE),
`STUFF+` = mean(stuff_plus, na.rm = TRUE),
EV = mean(ExitSpeed, na.rm = TRUE),
EV50 = mean(ExitSpeed[!is.na(ExitSpeed) &
ExitSpeed >= quantile(ExitSpeed, 0.5, na.rm = TRUE)], na.rm = TRUE),
EV90 = mean(ExitSpeed[!is.na(ExitSpeed) &
ExitSpeed >= quantile(ExitSpeed, 0.9, na.rm = TRUE)], na.rm = TRUE),
xwOBA = mean(xwOBA, na.rm = TRUE),
xwOBAcon = mean(xwOBA[PitchCall == "InPlay"], na.rm = TRUE)
)
}
# pitcher summary overview table
pitcher_summary <- function(data, pitcher_name) {
pitchdata <- data %>% filter(Pitcher == pitcher_name, TaggedPitchType != "Other")
pitch_summary <- pitchdata %>%
group_by(TaggedPitchType) %>%
summarise(
usage_temp = round((n() / nrow(pitchdata)) * 100, 1),
`USAGE%` = round((n() / nrow(pitchdata)) * 100, 1),
NUM = n(),
BBE = sum(event_type %in% c("Field Out", "Home Run", "Single", "Double", "Triple")),
VELO = round(mean(RelSpeed, na.rm = TRUE), 1),
`MAX VELO` = round(max(RelSpeed, na.rm = TRUE), 1),
SPIN = round(mean(SpinRate, na.rm = TRUE), 0),
IVB = round(mean(InducedVertBreak, na.rm = TRUE), 1),
HB = round(mean(HorzBreak, na.rm = TRUE), 1),
VAA = round(mean(VertApprAngle, na.rm = TRUE), 2),
EXT = round(mean(Extension, na.rm = TRUE), 1),
RELS = round(mean(RelSide, na.rm = TRUE), 1),
RELH = round(mean(RelHeight, na.rm = TRUE), 1),
EV = round(mean(ExitSpeed, na.rm = TRUE), 1),
EV50 = round(mean(ExitSpeed[!is.na(ExitSpeed) &
ExitSpeed >= quantile(ExitSpeed, 0.5, na.rm = TRUE)], na.rm = TRUE), 1),
EV90 = round(mean(ExitSpeed[!is.na(ExitSpeed) &
ExitSpeed >= quantile(ExitSpeed, 0.9, na.rm = TRUE)], na.rm = TRUE), 1),
`WHIFF%` = round(100 * mean(is_whiff, na.rm = TRUE), 1),
`IZW%` = round(100 * sum(is_whiff[in_zone == 1], na.rm = TRUE) /
sum(is_swing[in_zone == 1], na.rm = TRUE), 1),
`CSW%` = round(100 * mean(is_csw, na.rm = TRUE), 1),
`CHASE%` = round(100 * mean(chase, na.rm = TRUE), 1),
`ZONE%` = round(100 * mean(in_zone, na.rm = TRUE), 1),
`GB%` = round(100 * (sum((AutoHitType == "GroundBall" & PitchCall == "InPlay"), na.rm = TRUE) /
sum(PitchCall == "InPlay", na.rm = TRUE)), 2),
xwOBAcon = round(mean(xwOBA[PitchCall == "InPlay"], na.rm = TRUE), 3),
xwOBA = round(mean(xwOBA, na.rm = TRUE), 3),
`STUFF+` = round(mean(stuff_plus, na.rm = TRUE), 0),
.groups = "drop"
) %>%
rename(GROUP = TaggedPitchType) %>%
arrange(desc(usage_temp)) %>%
select(-usage_temp)
handedness_summary <- pitchdata %>%
group_by(BatterSide) %>%
summarise(
`USAGE%` = round((n() / nrow(pitchdata)) * 100, 1), NUM = n(),
BBE = sum(event_type %in% c("Field Out", "Home Run", "Single", "Double", "Triple")),
VELO = NA_real_, `MAX VELO` = NA_real_, SPIN = NA_real_,
IVB = NA_real_, HB = NA_real_, EXT = NA_real_, RELS = NA_real_, RELH = NA_real_,
EV = round(mean(ExitSpeed, na.rm = TRUE), 1),
EV50 = round(mean(ExitSpeed[!is.na(ExitSpeed) &
ExitSpeed >= quantile(ExitSpeed, 0.5, na.rm = TRUE)], na.rm = TRUE), 1),
EV90 = round(mean(ExitSpeed[!is.na(ExitSpeed) &
ExitSpeed >= quantile(ExitSpeed, 0.9, na.rm = TRUE)], na.rm = TRUE), 1),
`WHIFF%` = round(100 * mean(is_whiff, na.rm = TRUE), 1),
`IZW%` = round(100 * sum(is_whiff[in_zone == 1], na.rm = TRUE) /
sum(is_swing[in_zone == 1], na.rm = TRUE), 1),
`CSW%` = round(100 * mean(is_csw, na.rm = TRUE), 1),
`CHASE%` = round(100 * mean(chase, na.rm = TRUE), 1),
`ZONE%` = round(100 * mean(in_zone, na.rm = TRUE), 1),
`GB%` = round(100 * (sum((AutoHitType == "GroundBall" & PitchCall == "InPlay"), na.rm = TRUE) /
sum(PitchCall == "InPlay", na.rm = TRUE)), 2),
xwOBAcon = round(mean(xwOBA[PitchCall == "InPlay"], na.rm = TRUE), 3),
xwOBA = round(mean(xwOBA, na.rm = TRUE), 3),
`STUFF+` = round(mean(stuff_plus, na.rm = TRUE), 0),
.groups = "drop"
) %>% rename(GROUP = BatterSide)
overall_summary <- data %>%
filter(Pitcher == pitcher_name) %>%
summarise(
`USAGE%` = NA_real_, NUM = n(),
BBE = sum(event_type %in% c("Field Out", "Home Run", "Single", "Double", "Triple")),
VELO = NA_real_, `MAX VELO` = NA_real_, SPIN = NA_real_,
IVB = NA_real_, HB = NA_real_, EXT = NA_real_, RELS = NA_real_, RELH = NA_real_,
EV = round(mean(ExitSpeed, na.rm = TRUE), 1),
EV50 = round(mean(ExitSpeed[!is.na(ExitSpeed) &
ExitSpeed >= quantile(ExitSpeed, 0.5, na.rm = TRUE)], na.rm = TRUE), 1),
EV90 = round(mean(ExitSpeed[!is.na(ExitSpeed) &
ExitSpeed >= quantile(ExitSpeed, 0.9, na.rm = TRUE)], na.rm = TRUE), 1),
`WHIFF%` = round(100 * mean(is_whiff, na.rm = TRUE), 1),
`IZW%` = round(100 * sum(is_whiff[in_zone == 1], na.rm = TRUE) /
sum(is_swing[in_zone == 1], na.rm = TRUE), 1),
`CSW%` = round(100 * mean(is_csw, na.rm = TRUE), 1),
`CHASE%` = round(100 * mean(chase, na.rm = TRUE), 1),
`ZONE%` = round(100 * mean(in_zone, na.rm = TRUE), 1),
`GB%` = round(100 * (sum((AutoHitType == "GroundBall" & PitchCall == "InPlay"), na.rm = TRUE) /
sum(PitchCall == "InPlay", na.rm = TRUE)), 2),
xwOBAcon = round(mean(xwOBA[PitchCall == "InPlay"], na.rm = TRUE), 3),
xwOBA = round(mean(xwOBA, na.rm = TRUE), 3),
`STUFF+` = round(mean(stuff_plus, na.rm = TRUE), 0)
) %>% mutate(GROUP = "Overall") %>% select(GROUP, everything())
full_summary <- bind_rows(pitch_summary, handedness_summary, overall_summary)
full_summary %>%
gt() %>%
gt_theme_espn() %>%
tab_header(title = paste0("Pitch Summary — ", pitcher_name)) %>%
tab_options(heading.align = "center") %>%
sub_missing(missing_text = "—") %>%
tab_style(style = cell_text(color = "#002855"),
locations = cells_body(columns = everything(), rows = everything())) %>%
tab_style(style = cell_text(color = "#002855"),
locations = cells_column_labels(columns = everything())) %>%
tab_style(style = cell_text(color = "#002855", size = px(22)),
locations = cells_title(groups = "title")) %>%
tab_style(style = cell_borders(sides = c("top","bottom","left","right"),
color = "#002855", weight = px(.5)),
locations = cells_body(columns = everything(), rows = everything())) %>%
tab_style(style = cell_borders(sides = "top", color = "#002855", weight = px(10)),
locations = cells_body(rows = GROUP %in% c("Left"))) %>%
tab_style(style = cell_borders(sides = "bottom", color = "#002855", weight = px(10)),
locations = cells_body(rows = GROUP %in% c("Right"))) %>%
tab_style(style = list(cell_text(weight = "bold"), cell_fill(color = "#f0f0f0")),
locations = cells_body(rows = GROUP == "Overall")) %>%
tab_style(style = list(cell_fill(color = "red"), cell_text(color = "white", weight = "bold")),
locations = cells_body(columns = GROUP, rows = GROUP == "Fastball")) %>%
tab_style(style = list(cell_fill(color = "green4"), cell_text(color = "white", weight = "bold")),
locations = cells_body(columns = GROUP, rows = GROUP == "Changeup")) %>%
tab_style(style = list(cell_fill(color = "blue"), cell_text(color = "white", weight = "bold")),
locations = cells_body(columns = GROUP, rows = GROUP == "Curveball")) %>%
tab_style(style = list(cell_fill(color = "gold"), cell_text(color = "black", weight = "bold")),
locations = cells_body(columns = GROUP, rows = GROUP == "Slider")) %>%
tab_style(style = list(cell_fill(color = "maroon2"), cell_text(color = "white", weight = "bold")),
locations = cells_body(columns = GROUP, rows = GROUP == "Sweeper")) %>%
tab_style(style = list(cell_fill(color = "lightblue"), cell_text(color = "black", weight = "bold")),
locations = cells_body(columns = GROUP, rows = GROUP == "Splitter")) %>%
tab_style(style = list(cell_fill(color = "brown"), cell_text(color = "white", weight = "bold")),
locations = cells_body(columns = GROUP, rows = GROUP == "Cutter")) %>%
tab_style(style = list(cell_fill(color = "orange"), cell_text(color = "white", weight = "bold")),
locations = cells_body(columns = GROUP, rows = GROUP == "Sinker")) %>%
tab_style(style = list(cell_fill(color = "#6A89A7"), cell_text(color = "white", weight = "bold")),
locations = cells_body(columns = GROUP, rows = GROUP == "Knuckleball")) %>%
tab_style(style = list(cell_fill(color = "#2962FF"), cell_text(color = "white", weight = "bold")),
locations = cells_body(columns = GROUP, rows = GROUP == "Right")) %>%
tab_style(style = list(cell_fill(color = "#C62828"), cell_text(color = "white", weight = "bold")),
locations = cells_body(columns = GROUP, rows = GROUP == "Left")) %>%
tab_style(style = list(cell_fill(color = "#333333"), cell_text(color = "white", weight = "bold")),
locations = cells_body(columns = GROUP, rows = GROUP == "Overall")) %>%
data_color(columns = `WHIFF%`, fn = make_color_fn(pitch_lg_ref$`WHIFF%` - 12.5, pitch_lg_ref$`WHIFF%` + 12.5)) %>%
data_color(columns = `IZW%`, fn = make_color_fn(pitch_lg_ref$`IZW%` - 10, pitch_lg_ref$`IZW%` + 10)) %>%
data_color(columns = `CSW%`, fn = make_color_fn(pitch_lg_ref$`CSW%` - 10, pitch_lg_ref$`CSW%` + 10)) %>%
data_color(columns = `CHASE%`, fn = make_color_fn(pitch_lg_ref$`CHASE%` - 12.5, pitch_lg_ref$`CHASE%` + 12.5)) %>%
data_color(columns = `ZONE%`, fn = make_color_fn(pitch_lg_ref$`ZONE%` - 15, pitch_lg_ref$`ZONE%` + 15)) %>%
data_color(columns = `STUFF+`, fn = make_color_fn(pitch_lg_ref$`STUFF+` - 15, pitch_lg_ref$`STUFF+` + 15)) %>%
data_color(columns = EV, fn = make_color_fn_reverse(pitch_lg_ref$EV - 12.5, pitch_lg_ref$EV + 12.5)) %>%
data_color(columns = EV50, fn = make_color_fn_reverse(pitch_lg_ref$EV50 - 10, pitch_lg_ref$EV50 + 10)) %>%
data_color(columns = EV90, fn = make_color_fn_reverse(pitch_lg_ref$EV90 - 10, pitch_lg_ref$EV90 + 10)) %>%
data_color(columns = xwOBA, fn = make_color_fn_reverse(pitch_lg_ref$xwOBA - .10, pitch_lg_ref$xwOBA + .10)) %>%
data_color(columns = xwOBAcon, fn = make_color_fn_reverse(pitch_lg_ref$xwOBAcon - .10, pitch_lg_ref$xwOBAcon + .10)) %>%
fmt_number(columns = xwOBA, decimals = 3) %>%
fmt_number(columns = xwOBAcon, decimals = 3) %>%
fmt_number(columns = c(`WHIFF%`, `IZW%`, `CSW%`, `CHASE%`, `ZONE%`, EV, EV50, EV90), decimals = 1)
}
# reference df for the savant-style percentile chart
get_percentile_refs_pitcher <- function(data) {
data %>%
group_by(Pitcher) %>%
summarise(
xBAcon = 1 - mean(prob_0, na.rm = TRUE),
xwOBA = mean(xwOBA, na.rm = TRUE),
`Stuff+` = round(mean(stuff_plus, na.rm = TRUE)),
`FB Velo` = mean(RelSpeed[TaggedPitchType == "Fastball"], na.rm = TRUE),
`Avg EV` = mean(ExitSpeed, na.rm = TRUE),
EV50 = mean(ExitSpeed[!is.na(ExitSpeed) &
ExitSpeed >= quantile(ExitSpeed, 0.5, na.rm = TRUE)], na.rm = TRUE),
EV90 = mean(ExitSpeed[!is.na(ExitSpeed) &
ExitSpeed >= quantile(ExitSpeed, 0.9, na.rm = TRUE)], na.rm = TRUE),
`HH%` = 100 * mean(is_hard_hit, na.rm = TRUE),
`GB%` = 100 * sum(AutoHitType == "GroundBall" & PitchCall == "InPlay", na.rm = TRUE) /
sum(PitchCall == "InPlay", na.rm = TRUE),
`Chase%` = 100 * mean(chase, na.rm = TRUE),
`Whiff%` = 100 * mean(is_whiff, na.rm = TRUE),
`IZW%` = 100 * sum(is_whiff[in_zone == 1], na.rm = TRUE) /
sum(is_swing[in_zone == 1], na.rm = TRUE),
`K%` = 100 * mean(is_k, na.rm = TRUE),
`BB%` = 100 * mean(is_walk, na.rm = TRUE),
`Ext` = mean(Extension, na.rm = TRUE),
number = sum(is_plate_appearance, na.rm = TRUE),
.groups = "drop"
) %>%
select(-Pitcher) %>%
filter(number >= 30) %>%
select(-number) %>%
mutate(across(everything(), as.numeric))
}
# savant-style percentile chart
pitcher_percentiles <- function(df, pitcher, summary_ref) {
pitcher_stats <- df %>%
filter(Pitcher == pitcher) %>%
summarise(
xBAcon = 1 - mean(prob_0, na.rm = TRUE),
xwOBA = mean(xwOBA, na.rm = TRUE),
`Stuff+` = mean(stuff_plus, na.rm = TRUE),
`FB Velo` = mean(RelSpeed[TaggedPitchType == "Fastball"], na.rm = TRUE),
`Avg EV` = mean(ExitSpeed, na.rm = TRUE),
EV50 = mean(ExitSpeed[!is.na(ExitSpeed) &
ExitSpeed >= quantile(ExitSpeed, 0.5, na.rm = TRUE)], na.rm = TRUE),
EV90 = mean(ExitSpeed[!is.na(ExitSpeed) &
ExitSpeed >= quantile(ExitSpeed, 0.9, na.rm = TRUE)], na.rm = TRUE),
`HH%` = 100 * mean(is_hard_hit, na.rm = TRUE),
`GB%` = 100 * sum(AutoHitType == "GroundBall" & PitchCall == "InPlay", na.rm = TRUE) /
sum(PitchCall == "InPlay", na.rm = TRUE),
`Chase%` = 100 * mean(chase, na.rm = TRUE),
`Whiff%` = 100 * mean(is_whiff, na.rm = TRUE),
`IZW%` = 100 * sum(is_whiff[in_zone == 1], na.rm = TRUE) /
sum(is_swing[in_zone == 1], na.rm = TRUE),
`K%` = 100 * mean(is_k, na.rm = TRUE),
`BB%` = 100 * mean(is_walk, na.rm = TRUE),
`Ext` = mean(Extension, na.rm = TRUE),
.groups = "drop"
) %>%
mutate(across(everything(), as.numeric))
lower_better <- c("xwOBA", "xBAcon", "Avg EV", "EV50", "EV90", "HH%", "BB%")
pitcher_pctiles <- pitcher_stats %>%
mutate(across(everything(), ~ {
col <- cur_column()
if (col %in% lower_better) {
round(mean(summary_ref[[col]] >= .x, na.rm = TRUE) * 100)
} else {
round(mean(summary_ref[[col]] <= .x, na.rm = TRUE) * 100)
}
}, .names = "{.col}_percentile")) %>%
select(matches("_percentile"))
custom_order <- c("xBAcon", "xwOBA", "Stuff+", "FB Velo", "Avg EV", "EV50", "EV90",
"HH%", "GB%", "Chase%", "Whiff%", "IZW%", "K%", "BB%", "Ext")
metrics <- bind_cols(pitcher_stats, pitcher_pctiles) %>%
pivot_longer(everything(), names_to = "Metric_Type", values_to = "Value") %>%
mutate(Metric = str_remove(Metric_Type, "_percentile"),
Type = ifelse(str_detect(Metric_Type, "_percentile"), "Percentile", "Value")) %>%
select(Metric, Type, Value) %>%
pivot_wider(names_from = Type, values_from = Value) %>%
filter(!is.na(Percentile) & !is.na(Value)) %>%
mutate(
Metric = factor(Metric, levels = custom_order),
color = gradient_n_pal(c("#3661ad", "#90A4AE", "#d82029"))(Percentile / 100),
Value = case_when(
Metric %in% c("xwOBA", "xBAcon") ~ sprintf("%.3f", Value),
Metric == "Stuff+" ~ as.character(round(Value)),
TRUE ~ sprintf("%.1f", Value)
)
)
ggplot(metrics, aes(y = factor(Metric, levels = rev(levels(Metric))))) +
geom_tile(aes(x = 50, width = 100), fill = "#c7dcdc", alpha = 0.3, height = 0.25) +
geom_tile(aes(x = Percentile / 2, width = Percentile, fill = color), height = 0.7) +
annotate("segment", x = c(10, 50, 90), xend = c(10, 50, 90),
y = 0, yend = length(custom_order) + 0.35,
color = "white", linewidth = 1.5, alpha = 0.5) +
geom_segment(aes(x = -2, xend = -16,
y = as.numeric(factor(Metric, levels = rev(levels(Metric)))) - 0.3,
yend = as.numeric(factor(Metric, levels = rev(levels(Metric)))) - 0.3),
linetype = "longdash", color = "#399098", linewidth = 1) +
geom_segment(aes(x = 102, xend = 113,
y = as.numeric(factor(Metric, levels = rev(levels(Metric)))) - 0.3,
yend = as.numeric(factor(Metric, levels = rev(levels(Metric)))) - 0.3),
linetype = "longdash", color = "#399098", linewidth = 1) +
geom_text(aes(x = -3, label = Metric), hjust = 1, size = 5) +
geom_text(aes(x = 103, label = Value), hjust = 0, size = 5) +
geom_point(aes(x = Percentile, fill = color),
color = "white", size = 12, shape = 21, stroke = 2.25) +
geom_text(aes(x = Percentile, label = Percentile),
size = 4.5, color = "white", fontface = "bold") +
scale_x_continuous(limits = c(-16, 113), expand = c(0, 0)) +
scale_fill_identity() + scale_color_identity() +
theme_minimal() +
theme(axis.text = element_blank(), axis.title = element_blank(), panel.grid = element_blank())
}
# inferred arm angle plot using a silhouette image
arm_angle_plot <- function(data, pitcher_name) {
d <- data %>%
filter(Pitcher == pitcher_name, TaggedPitchType != "Other",
!is.na(RelHeight), !is.na(RelSide))
# throwing hand drives the mirror
h <- d$PitcherThrows[!is.na(d$PitcherThrows)]
is_lhp <- length(h) > 0 && names(sort(table(h), decreasing = TRUE))[1] == "Left"
m <- if (is_lhp) -1 else 1
arm_points <- d %>%
group_by(pitch_type = TaggedPitchType) %>%
summarise(mean_height = mean(RelHeight, na.rm = TRUE),
mean_side = -mean(RelSide, na.rm = TRUE),
.groups = "drop")
anchor_x <- -0.14 * m
anchor_y <- 4.75
img_use <- if (is_lhp) img_raster_L else img_raster_R
img_xmin <- if (is_lhp) -1.6 else -0.4
img_xmax <- if (is_lhp) 0.4 else 1.6
xlim_use <- if (is_lhp) c(-2.5, 3) else c(-3, 2.5)
mean_h <- round(mean(d$RelHeight, na.rm = TRUE), 1)
mean_s <- round(mean(d$RelSide, na.rm = TRUE), 1)
ggplot() +
annotate("rect", xmin = -4.5, xmax = 4.5, ymin = 0, ymax = 0.6, fill = "#8B4513", alpha = 0.7) +
annotate("rect", xmin = -4.5, xmax = 4.5, ymin = 0.6, ymax = 4.3, fill = "#164EA7", alpha = 0.9) +
annotate("rect", xmin = -4.5, xmax = 4.5, ymin = 4.3, ymax = 4.5, fill = "#F25B00", alpha = 0.9) +
annotate("rect", xmin = -0.5, xmax = 0.5, ymin = 0.5, ymax = 0.65,
fill = "white", color = "black", linewidth = 0.5) +
annotation_raster(img_use, xmin = img_xmin, xmax = img_xmax, ymin = 0.06, ymax = 5.85) +
geom_segment(data = arm_points,
aes(x = anchor_x, y = anchor_y, xend = mean_side, yend = mean_height,
color = pitch_type), linewidth = 1.5) +
geom_point(data = arm_points,
aes(x = mean_side, y = mean_height, color = pitch_type), size = 4) +
scale_color_manual(values = pitch_colors, name = "Pitch Type") +
labs(title = paste0("Inferred Arm Angle \u2014 ", ifelse(is_lhp, "LHP", "RHP")),
x = "Horizontal (ft)", y = "Vertical (ft)",
subtitle = "Player height assumed to be 6 ft") +
coord_fixed(xlim = xlim_use, ylim = c(0, 7)) +
annotate("text", x = -2.5 * m, y = 6.8, label = "1B", size = 2.5, fontface = "bold") +
annotate("text", x = 2.5 * m, y = 6.8, label = "3B", size = 2.5, fontface = "bold") +
annotate("label", x = 0, y = 0.25,
label = paste0("Rel Height: ", mean_h, ", Rel Side: ", mean_s),
fill = "#EF3340", color = "white", fontface = "bold", size = 3.5,
label.padding = unit(0.15, "lines")) +
theme_minimal() +
theme(axis.title.y = element_text(size = 14),
axis.title.x = element_text(size = 14),
axis.ticks = element_blank(),
plot.title = element_text(size = 16, hjust = 0.5, vjust = -1),
panel.grid = element_blank(),
axis.line = element_blank(),
legend.position = "none",
plot.margin = margin(5, 5, 5, 5)) +
theme(plot.subtitle = element_text(hjust = 0.5))
}
# ============================================================
# Precomputed references + player lists
# ============================================================
lg_ref <- reference_stats(all_data)
pitch_lg_ref <- pitcher_reference_stats(all_data)
percentile_ref_hitter <- get_percentile_refs_hitter(all_data)
percentile_ref_pitcher <- get_percentile_refs_pitcher(all_data)
ALL_BATTERS <- sort(unique(all_data$Batter))
ALL_PITCHERS <- sort(unique(all_data$Pitcher))
HF_PASSWORD <- Sys.getenv("APP_PASSWORD")
# ============================================================
# Tab content
# ============================================================
tab_hitter_overview <- function() tagList(
tags$div(class = "data-card", style = "margin-bottom:16px;",
tags$div(class = "data-card-hdr",
tags$span(class = "data-card-title", "Batter summary"),
tags$span(class = "data-card-sub", "By pitch group, handedness, opponent level")
),
tags$div(class = "data-card-body", gt_output("gt_batter_summary"))
),
tags$div(style = "display:grid; grid-template-columns:1fr 1fr; gap:14px;",
tags$div(class = "data-card",
tags$div(class = "data-card-hdr",
tags$span(class = "data-card-title", "Percentile profile"),
tags$span(class = "data-card-sub", "vs. lower-level 2026 dataset")
),
tags$div(class = "data-card-body", plotOutput("plot_hitter_percentiles", height = "420px"))
),
tags$div(class = "data-card",
tags$div(class = "data-card-hdr",
tags$span(class = "data-card-title", "Spray chart"),
tags$span(class = "data-card-sub", "Hits only")
),
tags$div(class = "data-card-body", plotlyOutput("plot_spray_overview", height = "420px"))
)
)
)
tab_pitcher_overview <- function() tagList(
tags$div(class = "data-card", style = "margin-bottom:16px;",
tags$div(class = "data-card-hdr",
tags$span(class = "data-card-title", "Pitch summary"),
tags$span(class = "data-card-sub", "By pitch type, handedness, opponent level")
),
tags$div(class = "data-card-body", gt_output("gt_pitch_summary"))
),
tags$div(style = "display:grid; grid-template-columns:1fr 1fr; gap:14px; margin-bottom:16px;",
tags$div(class = "data-card",
tags$div(class = "data-card-hdr",
tags$span(class = "data-card-title", "Movement plot"),
tags$span(class = "data-card-sub", "HB vs IVB by pitch")
),
tags$div(class = "data-card-body", plotlyOutput("plot_movement", height = "440px"))
),
tags$div(class = "data-card",
tags$div(class = "data-card-hdr",
tags$span(class = "data-card-title", "Percentile profile"),
tags$span(class = "data-card-sub", "vs. lower-level 2026 pitchers")
),
tags$div(class = "data-card-body", plotOutput("plot_pitcher_percentiles", height = "440px"))
)
),
tags$div(class = "data-card",
tags$div(class = "data-card-hdr",
tags$span(class = "data-card-title", "Inferred arm angle"),
tags$span(class = "data-card-sub", "Mean release by pitch")
),
tags$div(class = "data-card-body", plotOutput("plot_arm_angle", height = "440px"))
)
)
TABS <- list(
list(id = "hitter", label = "Hitter Overview", icon = "ti-chart-bar"),
list(id = "pitcher", label = "Pitcher Overview", icon = "ti-target")
)
# ============================================================
# Login
# ============================================================
login_css <- tags$style(HTML("
#login_screen { position:fixed; top:0; left:0; right:0; bottom:0; z-index:9999;
display:flex; flex-direction:column; align-items:center; justify-content:center;
background:linear-gradient(135deg,#001a3a 0%,#003366 40%,#0a4a8a 100%);
transition:opacity .8s ease, transform .8s ease; }
#login_screen.fade-out { opacity:0; transform:scale(1.02); pointer-events:none; }
#login_screen .login-logo { width:120px; height:120px; margin-bottom:20px; border-radius:50%;
object-fit:cover; border:3px solid rgba(255,165,0,.6); box-shadow:0 0 30px rgba(255,165,0,.2); }
#login_screen .login-title { font-size:42px; font-weight:800; color:#fff; letter-spacing:1.5px;
margin-bottom:6px; text-align:center; }
#login_screen .login-subtitle { font-size:16px; color:rgba(255,165,0,.7); letter-spacing:3px;
text-transform:uppercase; margin-bottom:40px; }
#login_screen .login-accent { width:60px; height:3px; background:#FF8C00; margin:0 auto 30px;
border-radius:2px; }
#login_screen .login-box { background:rgba(255,255,255,.06); border:1px solid rgba(255,165,0,.2);
border-radius:12px; padding:32px 40px; backdrop-filter:blur(10px); width:340px; text-align:center; }
#login_screen .login-box input[type='password'] { width:100%; padding:12px 16px;
border:1px solid rgba(255,165,0,.3); border-radius:6px; background:rgba(255,255,255,.08);
color:#fff; font-size:15px; margin-bottom:16px; outline:none; transition:border-color .3s;
text-align:center; letter-spacing:2px; }
#login_screen .login-box input[type='password']:focus { border-color:#FF8C00; }
#login_screen .login-box input[type='password']::placeholder { color:rgba(255,255,255,.35); letter-spacing:1px; }
#login_screen .login-btn { width:100%; padding:12px; border:none; border-radius:6px;
background:#FF8C00; color:#fff; font-size:14px; font-weight:700; letter-spacing:1px;
text-transform:uppercase; cursor:pointer; transition:background .3s, transform .15s; }
#login_screen .login-btn:hover { background:#e67e00; transform:translateY(-1px); }
#login_screen .login-error { color:#ff6b6b; font-size:13px; margin-top:12px; min-height:20px; }
"))
login_overlay <- tags$div(id = "login_screen",
tags$img(class = "login-logo", src = logo_uri),
tags$div(class = "login-title", "Lower Level"),
tags$div(class = "login-accent"),
tags$div(class = "login-subtitle", "Hyannis Harbor Hawks"),
tags$div(class = "login-box",
tags$input(id = "login_pw", type = "password", placeholder = "Enter password"),
tags$button(id = "login_btn", class = "login-btn", "Enter"),
tags$div(id = "login_error", class = "login-error")
)
)
login_js <- tags$script(HTML(paste0("
var APP_PASSWORD = '", HF_PASSWORD, "';
function tryLogin() {
var pw = document.getElementById('login_pw').value;
if (pw === APP_PASSWORD) {
document.getElementById('login_screen').classList.add('fade-out');
setTimeout(function(){ document.getElementById('login_screen').style.display = 'none'; }, 800);
} else {
document.getElementById('login_error').textContent = 'Incorrect password';
document.getElementById('login_pw').value = '';
document.getElementById('login_pw').style.borderColor = '#ff6b6b';
setTimeout(function(){
document.getElementById('login_error').textContent = '';
document.getElementById('login_pw').style.borderColor = 'rgba(255,165,0,0.3)';
}, 2000);
}
}
document.addEventListener('DOMContentLoaded', function(){
document.getElementById('login_btn').addEventListener('click', tryLogin);
document.getElementById('login_pw').addEventListener('keydown', function(e){
if (e.key === 'Enter') tryLogin();
});
});
")))
# ============================================================
# UI
# ============================================================
ui <- fluidPage(
theme = hawks_theme,
hawks_css,
tags$link(rel = "stylesheet",
href = "https://cdn.jsdelivr.net/npm/@tabler/icons-webfont@latest/dist/tabler-icons.min.css"),
login_css,
tags$style(HTML(glue("
body {{ margin:0; overflow:hidden; background:{HAWKS_LIGHT}; }}
.hawks-topbar {{
background:{HAWKS_DARK}; padding:0 24px; height:auto; display:flex;
align-items:center; justify-content:space-between; flex-shrink:0;
flex-wrap:wrap; gap:0; border-bottom:3px solid {HAWKS_ORANGE};
}}
.topbar-left {{ display:flex; align-items:center; gap:20px; padding:14px 0; }}
.topbar-title {{
color:#fff; font-size:40px; font-weight:800; letter-spacing:-.3px;
text-align:center; flex:1; white-space:nowrap;
}}
.topbar-brand {{ display:flex; align-items:center; gap:10px; padding:14px 0; }}
.topbar-brand .app-title {{ color:#fff; font-size:16px; font-weight:800; letter-spacing:-.2px; }}
.topbar-player {{ display:flex; flex-direction:column; align-items:flex-start; gap:3px; padding:10px 0; }}
.topbar-player .player-label {{ font-size:9px; font-weight:700; text-transform:uppercase;
letter-spacing:.1em; color:#ffffff55; }}
.topbar-player .selectize-input {{
background:#ffffff14 !important; border:1px solid #ffffff30 !important;
border-radius:6px !important; color:#fff !important; font-size:13px !important;
font-weight:600 !important; min-width:200px; padding:6px 10px !important; }}
.topbar-player .selectize-input.focus {{ border-color:{HAWKS_ORANGE} !important; box-shadow:none !important; }}
.topbar-player .selectize-dropdown {{ border:1px solid {HAWKS_BORDER}; border-radius:6px; font-size:12px; }}
.topbar-player .selectize-input input {{
color: #fff !important;
min-width: 120px !important;
width: auto !important;
}}
.topbar-player .selectize-input input::placeholder {{
color: rgba(255,255,255,.35) !important;
}}
.hawks-tabs {{ background:#fff; border-bottom:1px solid {HAWKS_BORDER}; padding:0 20px;
display:flex; flex-shrink:0; overflow-x:auto; }}
.h-tab {{ padding:11px 15px; font-size:12px; font-weight:500; color:{HAWKS_GRAY};
border-bottom:2px solid transparent; cursor:pointer; white-space:nowrap;
display:flex; align-items:center; gap:6px; transition:color .15s, border-color .15s; }}
.h-tab:hover {{ color:#374151; }}
.h-tab.active {{ color:{HAWKS_BLUE}; border-bottom-color:{HAWKS_BLUE}; font-weight:600; }}
.hawks-body {{ display:flex; flex:1; overflow:hidden; }}
.hawks-content {{ flex:1; overflow-y:auto; padding:20px; }}
.hawks-shell {{ display:flex; flex-direction:column; height:100vh; overflow:hidden; }}
.hawks-main {{ display:flex; flex-direction:column; flex:1; overflow:hidden; }}
"))),
tags$script(HTML(glue("
function setTab(tabId) {{
document.querySelectorAll('.h-tab').forEach(function(el) {{
el.classList.toggle('active', el.dataset.tab === tabId);
}});
Shiny.setInputValue('active_tab', tabId, {{priority:'event'}});
}}
"))),
tags$div(
class = "hawks-shell",
tags$div(
class = "hawks-topbar",
tags$div(class = "topbar-left",
tags$div(class = "topbar-brand",
tags$svg(width="24",height="24",viewBox="0 0 30 30",fill="none",
tags$polygon(points="15,2 28,26 15,21 2,26",fill=HAWKS_ORANGE,opacity="0.9"),
tags$polygon(points="15,2 15,21 2,26",fill="#fff",opacity="0.18")
),
tags$span(class="app-title","Lower Level")
)
),
tags$div(class = "topbar-title", "Hyannis Harbor Hawks Analytics"),
tags$div(class = "topbar-player",
tags$div(class="player-label","Player"),
selectizeInput("selected_player", NULL,
choices = NULL, width = "210px")
)
),
tags$div(class = "hawks-main",
tags$div(class = "hawks-tabs",
tagList(lapply(TABS, function(t) {
tags$div(
class = paste("h-tab", if (t$id == "hitter") "active" else ""),
`data-tab` = t$id,
onclick = glue("setTab('{t$id}')"),
tags$i(class=glue("ti {t$icon}"), style="font-size:13px;"),
t$label
)
}))
),
tags$div(class = "hawks-body",
tags$div(class="hawks-content", uiOutput("ui_tab_content"))
)
)
),
login_overlay,
login_js
)
# ============================================================
# Server
# ============================================================
server <- function(input, output, session) {
# Populate the player search server-side (default tab is hitter)
updateSelectizeInput(session, "selected_player",
choices = ALL_BATTERS, selected = ALL_BATTERS[1], server = TRUE)
# Swap the player dropdown between batters and pitchers on tab change
observeEvent(input$active_tab, {
if ((input$active_tab %||% "hitter") == "pitcher") {
updateSelectizeInput(session, "selected_player",
choices = ALL_PITCHERS, selected = ALL_PITCHERS[1], server = TRUE)
} else {
updateSelectizeInput(session, "selected_player",
choices = ALL_BATTERS, selected = ALL_BATTERS[1], server = TRUE)
}
}, ignoreInit = TRUE)
# Player data, filtered on the correct identity column for the active tab
player_data <- reactive({
req(input$selected_player)
if ((input$active_tab %||% "hitter") == "pitcher") {
all_data %>% filter(Pitcher == input$selected_player)
} else {
all_data %>% filter(Batter == input$selected_player)
}
})
# Header sub-lines
player_team <- reactive({
req(input$selected_player)
df <- all_data %>% filter(Batter == input$selected_player)
if (nrow(df) == 0) return("")
team <- df %>% count(BatterTeam) %>% slice_max(n, n = 1) %>% pull(BatterTeam)
if (length(team) == 0 || is.na(team[1])) return("")
as.character(team[1])
})
pitcher_info <- reactive({
req(input$selected_player)
df <- all_data %>% filter(Pitcher == input$selected_player)
if (nrow(df) == 0) return("")
hand <- df %>% count(PitcherThrows) %>% slice_max(n, n = 1) %>% pull(PitcherThrows)
if (length(hand) == 0 || is.na(hand[1])) return("")
if (hand[1] == "Right") "Right-Handed Pitcher"
else if (hand[1] == "Left") "Left-Handed Pitcher"
else as.character(hand[1])
})
output$ui_tab_content <- renderUI({
tab <- input$active_tab %||% "hitter"
if (tab == "pitcher") {
header <- tags$div(
style = glue("margin-bottom:18px; padding-bottom:14px; border-bottom:1px solid {HAWKS_BORDER};"),
tags$div(style = glue("font-size:22px; font-weight:800; color:{HAWKS_DARK}; line-height:1;"),
input$selected_player),
tags$div(style = glue("font-size:13px; color:{HAWKS_GRAY}; margin-top:4px;"),
pitcher_info())
)
tagList(header, tab_pitcher_overview())
} else {
header <- tags$div(
style = glue("margin-bottom:18px; padding-bottom:14px; border-bottom:1px solid {HAWKS_BORDER};"),
tags$div(style = glue("font-size:22px; font-weight:800; color:{HAWKS_DARK}; line-height:1;"),
input$selected_player),
tags$div(style = glue("font-size:13px; color:{HAWKS_GRAY}; margin-top:4px;"),
player_team())
)
tagList(header, tab_hitter_overview())
}
})
# ---- HITTER OVERVIEW ----
output$gt_batter_summary <- render_gt({
df <- player_data(); req(nrow(df) > 0)
batter_summary(df, input$selected_player)
})
output$plot_hitter_percentiles <- renderPlot({
df <- player_data(); req(nrow(df) > 0)
batter_percentiles(df, input$selected_player, percentile_ref_hitter)
}, bg = "white")
output$plot_spray_overview <- renderPlotly({
df <- player_data(); req(nrow(df) > 0)
spray_chart(df, input$selected_player)
})
# ---- PITCHER OVERVIEW ----
output$gt_pitch_summary <- render_gt({
df <- player_data(); req(nrow(df) > 0)
pitcher_summary(df, input$selected_player)
})
output$plot_movement <- renderPlotly({
df <- player_data(); req(nrow(df) > 0)
movement_plot(df, input$selected_player)
})
output$plot_pitcher_percentiles <- renderPlot({
df <- player_data(); req(nrow(df) > 0)
pitcher_percentiles(df, input$selected_player, percentile_ref_pitcher)
}, bg = "white")
output$plot_arm_angle <- renderPlot({
df <- player_data(); req(nrow(df) > 0)
arm_angle_plot(df, input$selected_player)
}, bg = "white")
}
shinyApp(ui, server)