DefensiveApp / app.R
igroffman's picture
Update app.R
9e5e0e1 verified
# ============================================================================
# COASTAL CAROLINA BASEBALL - DEFENSIVE ANALYTICS APPLICATION
# ============================================================================
library(shiny)
library(DT)
library(ggplot2)
library(dplyr)
library(plotly)
library(gt)
library(gtExtras)
library(shinyWidgets)
library(tidyr)
library(purrr)
library(stringr)
library(fmsb)
# ============================================================================
# HELPER FUNCTIONS
# ============================================================================
make_curve_segments <- function(x_start, y_start, x_end, y_end, curvature = 0.3, n = 40) {
if (n < 2) stop("n must be >= 2")
t <- seq(0, 1, length.out = n)
cx <- (x_start + x_end) / 2 + curvature * (y_end - y_start)
cy <- (y_start + y_end) / 2 - curvature * (x_end - x_start)
x <- (1 - t)^2 * x_start + 2 * (1 - t) * t * cx + t^2 * x_end
y <- (1 - t)^2 * y_start + 2 * (1 - t) * t * cy + t^2 * y_end
idx_from <- seq_len(n - 1)
idx_to <- seq_len(n - 1) + 1
tibble::tibble(
x = x[idx_from],
y = y[idx_from],
xend = x[idx_to],
yend = y[idx_to]
)
}
curve1 <- make_curve_segments(89.095, 89.095, -1, 160, curvature = 0.36, n = 60)
curve2 <- make_curve_segments(-89.095, 89.095, 1, 160, curvature = -0.36, n = 60)
curve3 <- make_curve_segments(233.35, 233.35, -10, 410, curvature = 0.36, n = 60)
curve4 <- make_curve_segments(-233.35, 233.35, 10, 410, curvature = -0.36, n = 60)
# ============================================================================
# SYNTHETIC DATA GENERATION
# ============================================================================
set.seed(42)
# Team rosters
cc_players <- c("Smith, John", "Johnson, Mike", "Williams, Chris", "Brown, David",
"Jones, Tyler", "Garcia, Alex", "Martinez, Ryan", "Davis, Kyle",
"Rodriguez, Sam", "Wilson, Jake")
opponent_teams <- c("Georgia Southern", "Appalachian State", "Troy", "Texas State",
"South Alabama", "Arkansas State", "Louisiana", "ULM")
opponent_players <- c("Thompson, Mark", "Anderson, Jake", "Taylor, Ben", "Thomas, Cole",
"Jackson, Drew", "White, Nick", "Harris, Luke", "Martin, Josh",
"Moore, Zach", "Lee, Grant")
positions <- c("RF", "CF", "LF")
infield_positions <- c("SS", "1B", "2B", "3B")
# Generate Outfield OAA data
generate_of_oaa_data <- function(n = 500, team = "CCU") {
players <- if(team == "CCU") cc_players else opponent_players
tibble(
PitchUID = paste0("P", 1:n),
Date = sample(seq(as.Date("2025-02-14"), as.Date("2025-05-30"), by = "day"), n, replace = TRUE),
obs_player = sample(players[1:4], n, replace = TRUE),
hit_location = sample(positions, n, replace = TRUE),
Batter = sample(opponent_players, n, replace = TRUE),
Pitcher = sample(cc_players[5:8], n, replace = TRUE),
Inning = sample(1:9, n, replace = TRUE),
BatterTeam = if(team == "CCU") sample(opponent_teams, n, replace = TRUE) else "CCU",
PitcherTeam = if(team == "CCU") "CCU" else sample(opponent_teams, n, replace = TRUE),
closest_pos_dist = runif(n, 5, 120),
HangTime = runif(n, 2.5, 5.5),
x_pos = runif(n, 100, 350),
z_pos = runif(n, -200, 200),
CF_PositionAtReleaseX = runif(n, 280, 320),
CF_PositionAtReleaseZ = runif(n, -20, 20),
RF_PositionAtReleaseX = runif(n, 250, 290),
RF_PositionAtReleaseZ = runif(n, 80, 140),
LF_PositionAtReleaseX = runif(n, 250, 290),
LF_PositionAtReleaseZ = runif(n, -140, -80),
angle_from_home = runif(n, -180, 180),
ExitSpeed = runif(n, 70, 110),
Angle = runif(n, 15, 45)
) %>%
mutate(
# Calculate catch probability based on distance and hang time
catch_prob = pmax(0, pmin(1, 1 - (closest_pos_dist / 150) + (HangTime - 3) * 0.2 + rnorm(n, 0, 0.1))),
# Determine success based on probability with some randomness
success_ind = rbinom(n, 1, pmin(0.95, pmax(0.05, catch_prob + rnorm(n, 0, 0.15)))),
# Calculate OAA
OAA = success_ind - catch_prob,
star_group = case_when(
catch_prob >= 0.90 ~ "1 Star",
catch_prob >= 0.70 ~ "2 Star",
catch_prob >= 0.50 ~ "3 Star",
catch_prob >= 0.25 ~ "4 Star",
TRUE ~ "5 Star"
)
)
}
# Generate Infield OAA data
generate_if_oaa_data <- function(n = 400, team = "CCU") {
players <- if(team == "CCU") cc_players else opponent_players
tibble(
PitchUID = paste0("IF", 1:n),
Date = sample(seq(as.Date("2025-02-14"), as.Date("2025-05-30"), by = "day"), n, replace = TRUE),
obs_player_name = sample(players[5:8], n, replace = TRUE),
obs_player = sample(infield_positions, n, replace = TRUE),
Batter = sample(opponent_players, n, replace = TRUE),
Pitcher = sample(cc_players[1:4], n, replace = TRUE),
Inning = sample(1:9, n, replace = TRUE),
BatterTeam = if(team == "CCU") sample(opponent_teams, n, replace = TRUE) else "CCU",
Distance = runif(n, 50, 150),
Bearing = runif(n, -45, 45),
ExitSpeed = runif(n, 60, 110),
Angle = runif(n, -10, 30),
HangTime = runif(n, 1.5, 4.0),
obs_player_bearing = runif(n, -45, 45),
obs_player_x = runif(n, 80, 130),
obs_player_z = runif(n, -60, 60),
Direction = sample(c("Pull", "Center", "Oppo"), n, replace = TRUE)
) %>%
mutate(
obs_player_bearing_diff = Bearing - obs_player_bearing,
dist_from_lead_base = sqrt((Distance * cos(Bearing * pi/180) - 90)^2 + (Distance * sin(Bearing * pi/180))^2),
player_angle_rad = atan2(obs_player_z, obs_player_x),
# Calculate play probability
play_prob = pmax(0, pmin(1, 0.8 - abs(obs_player_bearing_diff) * 0.02 - Distance * 0.003 + rnorm(n, 0, 0.1))),
success_ind = rbinom(n, 1, pmin(0.95, pmax(0.05, play_prob + rnorm(n, 0, 0.12)))),
OAA = success_ind - play_prob,
star_group = case_when(
play_prob >= 0.90 ~ "1 Star",
play_prob >= 0.70 ~ "2 Star",
play_prob >= 0.50 ~ "3 Star",
play_prob >= 0.25 ~ "4 Star",
TRUE ~ "5 Star"
)
)
}
# Generate Positioning data
generate_positioning_data <- function(n = 800) {
tibble(
PitchUID = paste0("POS", 1:n),
Date = sample(seq(as.Date("2025-02-14"), as.Date("2025-05-30"), by = "day"), n, replace = TRUE),
PitcherTeam = sample(c("CCU", opponent_teams), n, replace = TRUE),
BatterTeam = sample(c("CCU", opponent_teams), n, replace = TRUE),
Pitcher = sample(c(cc_players, opponent_players), n, replace = TRUE),
Batter = sample(c(cc_players, opponent_players), n, replace = TRUE),
BatterSide = sample(c("Right", "Left"), n, replace = TRUE, prob = c(0.6, 0.4)),
PitcherThrows = sample(c("Right", "Left"), n, replace = TRUE, prob = c(0.7, 0.3)),
Outs = sample(0:2, n, replace = TRUE),
pitch_count = sample(c("0-0", "0-1", "0-2", "1-0", "1-1", "1-2", "2-0", "2-1", "2-2", "3-0", "3-1", "3-2"), n, replace = TRUE),
man_on_firstbase = sample(c(0, 1), n, replace = TRUE, prob = c(0.7, 0.3)),
man_on_secondbase = sample(c(0, 1), n, replace = TRUE, prob = c(0.8, 0.2)),
man_on_thirdbase = sample(c(0, 1), n, replace = TRUE, prob = c(0.9, 0.1)),
away_team = sample(opponent_teams, n, replace = TRUE),
home_score_before = sample(0:8, n, replace = TRUE),
away_score_before = sample(0:8, n, replace = TRUE),
`1B_PositionAtReleaseX` = rnorm(n, 85, 8),
`1B_PositionAtReleaseZ` = rnorm(n, 45, 10),
`2B_PositionAtReleaseX` = rnorm(n, 115, 10),
`2B_PositionAtReleaseZ` = rnorm(n, 35, 12),
`3B_PositionAtReleaseX` = rnorm(n, 85, 8),
`3B_PositionAtReleaseZ` = rnorm(n, -45, 10),
`SS_PositionAtReleaseX` = rnorm(n, 115, 10),
`SS_PositionAtReleaseZ` = rnorm(n, -35, 12),
`LF_PositionAtReleaseX` = rnorm(n, 270, 15),
`LF_PositionAtReleaseZ` = rnorm(n, -110, 20),
`CF_PositionAtReleaseX` = rnorm(n, 300, 15),
`CF_PositionAtReleaseZ` = rnorm(n, 0, 25),
`RF_PositionAtReleaseX` = rnorm(n, 270, 15),
`RF_PositionAtReleaseZ` = rnorm(n, 110, 20)
)
}
# Generate Catcher data
generate_catcher_data <- function(n = 1000) {
catchers <- c("Martinez, Ryan", "Wilson, Jake")
tibble(
PitchUID = paste0("C", 1:n),
Date = sample(seq(as.Date("2025-02-14"), as.Date("2025-05-30"), by = "day"), n, replace = TRUE),
Catcher = sample(catchers, n, replace = TRUE),
Pitcher = sample(cc_players[1:6], n, replace = TRUE),
Batter = sample(opponent_players, n, replace = TRUE),
BatterTeam = sample(opponent_teams, n, replace = TRUE),
TaggedPitchType = sample(c("Fastball", "Sinker", "Slider", "Curveball", "ChangeUp", "Cutter"), n, replace = TRUE, prob = c(0.35, 0.15, 0.2, 0.12, 0.12, 0.06)),
PitchCall = sample(c("BallCalled", "StrikeCalled", "StrikeSwinging", "InPlay", "FoulBall"), n, replace = TRUE, prob = c(0.3, 0.25, 0.15, 0.15, 0.15)),
PlateLocSide = rnorm(n, 0, 0.8),
PlateLocHeight = rnorm(n, 2.5, 0.6),
RelSpeed = rnorm(n, 88, 6),
InducedVertBreak = rnorm(n, 12, 5),
HorzBreak = rnorm(n, 5, 8),
Balls = sample(0:3, n, replace = TRUE),
Strikes = sample(0:2, n, replace = TRUE),
PopTime = ifelse(runif(n) > 0.85, runif(n, 1.85, 2.15), NA),
ThrowSpeed = ifelse(!is.na(PopTime), rnorm(sum(!is.na(PopTime)), 78, 4), NA),
ExchangeTime = ifelse(!is.na(PopTime), rnorm(sum(!is.na(PopTime)), 0.75, 0.08), NA),
TimeToBase = ifelse(!is.na(PopTime), PopTime - ExchangeTime, NA),
BasePositionZ = ifelse(!is.na(PopTime), rnorm(sum(!is.na(PopTime)), 0, 2), NA),
BasePositionY = ifelse(!is.na(PopTime), rnorm(sum(!is.na(PopTime)), 5, 1.5), NA),
Notes = ifelse(!is.na(PopTime), sample(c("2b out", "2b safe", "3b out", "3b safe"), sum(!is.na(PopTime)), replace = TRUE, prob = c(0.4, 0.3, 0.2, 0.1)), NA)
) %>%
mutate(
in_zone = as.integer(PlateLocSide >= -0.83 & PlateLocSide <= 0.83 & PlateLocHeight >= 1.5 & PlateLocHeight <= 3.38),
is_swing = as.integer(PitchCall %in% c("StrikeSwinging", "InPlay", "FoulBall")),
StolenStrike = as.integer(in_zone == 0 & PitchCall == "StrikeCalled"),
StrikeLost = as.integer(in_zone == 1 & PitchCall == "BallCalled"),
frame = case_when(
in_zone == 1 & PitchCall == "BallCalled" ~ "Strike Lost",
in_zone == 0 & PitchCall == "StrikeCalled" ~ "Strike Added",
TRUE ~ NA_character_
),
frame_numeric = case_when(
in_zone == 1 & PitchCall == "BallCalled" ~ -1,
in_zone == 0 & PitchCall == "StrikeCalled" ~ 1,
TRUE ~ NA_real_
),
Count = paste0(Balls, "-", Strikes)
)
}
# Initialize datasets
OAA_DF <- generate_of_oaa_data(500, "CCU")
IF_OAA <- generate_if_oaa_data(400, "CCU")
OPP_OAA_DF <- generate_of_oaa_data(500, "OPP")
OPP_IF_OAA <- generate_if_oaa_data(400, "OPP")
def_pos_data <- generate_positioning_data(800)
catcher_data <- generate_catcher_data(1000)
# ============================================================================
# VISUALIZATION FUNCTIONS
# ============================================================================
# Star graph for outfielders
star_graph <- function(player, position, data = OAA_DF) {
if (position %in% c("RF", "CF", "LF")) {
player_data <- data %>%
filter(obs_player == player) %>%
filter(hit_location == position)
} else {
player_data <- data %>%
filter(obs_player == player) %>%
mutate(hit_location = "All")
}
if (nrow(player_data) == 0) {
return(ggplotly(ggplot() + theme_void() + ggtitle("No data available")))
}
OAA <- round(sum(player_data$OAA, na.rm = TRUE), 1)
n_plays <- nrow(player_data)
expected_success <- round(sum(player_data$catch_prob, na.rm = TRUE), 1)
actual_success <- sum(player_data$success_ind, na.rm = TRUE)
p <- ggplot(data, aes(closest_pos_dist, HangTime)) +
stat_summary_2d(aes(z = catch_prob), fun = mean, bins = 51) +
scale_fill_gradientn(
colors = c("white", "#ffffb2", "#feb24c", "#fc4e2a", "#800026"),
limits = c(0, 1),
breaks = c(0.01, 0.25, 0.5, 0.7, 0.9, 1),
labels = c("5 Star", "4 Star", "3 Star", "2 Star", "1 Star", " "),
name = "Catch Rating",
guide = guide_colorbar(barheight = unit(5, "in"))
) +
scale_x_continuous("Distance to Ball (ft)", limits = c(0, 140), breaks = seq(0, 140, by = 20)) +
scale_y_continuous("Hang Time (sec)", limits = c(2, 6)) +
theme_classic() +
ggtitle(paste0(player, " - ", position, " (", OAA, " OAA)")) +
theme(
legend.position = "right",
plot.title = element_text(hjust = .5, size = 20),
legend.title = element_blank(),
panel.grid.minor = element_blank(),
axis.title = element_text(size = 12),
axis.text = element_text(size = 10)
)
point_colors <- ifelse(player_data$success_ind == 1, "green4", "white")
p_interactive <- suppressWarnings({
ggplotly(p, tooltip = NULL) %>%
add_trace(
data = player_data,
x = ~closest_pos_dist,
y = ~HangTime,
type = "scatter",
mode = "markers",
color = I(point_colors),
marker = list(size = 10, line = list(color = "black", width = 1)),
text = ~paste0(
"Date: ", Date, "<br>",
"Batter: ", Batter, "<br>",
"Pitcher: ", Pitcher, "<br>",
"Inning: ", Inning, "<br>",
"Opportunity Time: ", round(HangTime, 2), " sec<br>",
"Distance Needed: ", round(closest_pos_dist, 1), " ft<br>",
"Catch Probability: ", round(100 * catch_prob, 1), "%<br>",
star_group
),
hoverinfo = "text"
) %>%
layout(
hovermode = "closest",
title = list(
text = paste0(player, " - ", position,
"<br><sup>OAA: ", OAA, " | Plays: ", n_plays,
" | Expected Catches: ", expected_success, " | Actual Catches: ", actual_success, "</sup>"),
font = list(size = 18)
),
margin = list(t = 80)
)
})
return(p_interactive)
}
# Field graph for outfielders
field_graph <- function(player, position, data = OAA_DF) {
if (position %in% c("RF", "CF", "LF")) {
player_data <- data %>%
filter(obs_player == player) %>%
filter(hit_location == position)
} else {
player_data <- data %>%
filter(obs_player == player) %>%
mutate(hit_location = "All")
}
if (nrow(player_data) == 0) {
return(list(
ggplotly(ggplot() + theme_void() + ggtitle("No data available")),
gt(data.frame(Message = "No data")) %>% tab_header(title = "Play Success by Difficulty")
))
}
df_filtered <- player_data %>%
mutate(
avg_y = mean(CF_PositionAtReleaseX, na.rm = TRUE),
avg_x = mean(CF_PositionAtReleaseZ, na.rm = TRUE)
)
df_table <- df_filtered %>%
group_by(star_group) %>%
summarize(
OAA = round(sum(OAA, na.rm = TRUE), 1),
Plays = n(),
Successes = sum(success_ind, na.rm = TRUE),
.groups = "drop"
) %>%
mutate(`Success Rate` = paste0(Successes, "/", Plays, " (", round((Successes/Plays)*100, 1), "%)")) %>%
dplyr::select(`Star Group` = star_group, `Success Rate`) %>%
t() %>% as.data.frame() %>%
`colnames<-`(.[1,]) %>% tibble::as_tibble() %>% dplyr::slice(-1) %>%
mutate(OAA = round(sum(df_filtered$OAA, na.rm = TRUE), 2)) %>%
dplyr::select(OAA, everything()) %>%
gt::gt() %>%
gtExtras::gt_theme_guardian() %>%
gt::tab_header(title = "Play Success by Difficulty") %>%
gt::cols_align(align = "center", columns = gt::everything()) %>%
gt::sub_missing(columns = gt::everything(), missing_text = "-") %>%
gt::tab_options(
heading.background.color = "darkcyan",
column_labels.background.color = "darkcyan",
table.border.top.color = "peru",
table.border.bottom.color = "peru"
) %>%
gt::tab_style(style = gt::cell_text(color = "white"), locations = gt::cells_title(groups = "title"))
p <- ggplot() +
geom_segment(aes(x = 0, y = 0, xend = 318.1981, yend = 318.1981), color = "black") +
geom_segment(aes(x = 0, y = 0, xend = -318.1981, yend = 318.1981), color = "black") +
geom_segment(aes(x = 63.6396, y = 63.6396, xend = 0, yend = 127.279), color = "black") +
geom_segment(aes(x = -63.6396, y = 63.6396, xend = 0, yend = 127.279), color = "black") +
geom_segment(data = curve1, aes(x = x, y = y, xend = xend, yend = yend), size = 0.5, color = "black", lineend = "round") +
geom_segment(data = curve2, aes(x = x, y = y, xend = xend, yend = yend), size = 0.5, color = "black", lineend = "round") +
annotate("text", x = c(-155, 155), y = 135, label = "200", size = 3) +
annotate("text", x = c(-190, 190), y = 170, label = "250", size = 3) +
annotate("text", x = c(-227, 227), y = 205, label = "300", size = 3) +
annotate("text", x = c(-262, 262), y = 242, label = "350", size = 3) +
theme_void() +
geom_point(
data = df_filtered %>% mutate(is_catch = ifelse(success_ind == 1, 'Catch', "Hit")),
aes(x = z_pos, y = x_pos, fill = is_catch,
text = paste0("Date: ", Date, "<br>",
"Catch Probability: ", round(100 * catch_prob, 1), "%<br>", star_group)),
color = "black", shape = 21, size = 2, alpha = .6
) +
labs(title = paste0(player, " Possible Catches - ", position)) +
scale_fill_manual(values = c("Hit" = "white", "Catch" = "green4"), name = " ") +
geom_point(data = df_filtered, aes(x = avg_x, y = avg_y), fill = "red", color = "black", size = 3, shape = 21) +
coord_cartesian(xlim = c(-330, 330), ylim = c(0, 400)) +
coord_equal()
p <- ggplotly(p, tooltip = "text") %>% plotly::layout(hovermode = "closest")
return(list(p, df_table))
}
# Infield star graph
infield_star_graph <- function(player, position, data = IF_OAA) {
if (position %in% c("SS", "1B", "2B", "3B")) {
player_data <- data %>%
filter(obs_player_name == player) %>%
filter(obs_player == position)
} else {
player_data <- data %>%
filter(obs_player_name == player) %>%
mutate(obs_player = "All")
}
if (nrow(player_data) == 0) {
return(ggplotly(ggplot() + theme_void() + ggtitle("No data available")))
}
OAA <- round(sum(player_data$OAA, na.rm = TRUE), 1)
n_plays <- nrow(player_data)
expected_success <- round(sum(player_data$play_prob, na.rm = TRUE), 1)
actual_success <- sum(player_data$success_ind, na.rm = TRUE)
points_data <- player_data %>%
filter(play_prob > 0) %>%
filter(play_prob <= 0.5 | between(obs_player_bearing_diff, -12.5, 12.5))
p <- ggplot(data, aes(obs_player_bearing_diff, play_prob)) +
stat_summary_2d(aes(z = play_prob), fun = mean, bins = 15) +
scale_fill_gradientn(
colors = c("white", "#ffffb2", "#feb24c", "#fc4e2a", "#800026"),
limits = c(0, 1),
breaks = c(0.01, 0.25, 0.5, 0.7, 0.9, 1),
labels = c("5 Star", "4 Star", "3 Star", "2 Star", "1 Star", " "),
name = "Play Rating"
) +
scale_x_continuous("Bearing Difference (degrees)", limits = c(-25, 25), breaks = seq(-25, 25, by = 12.5)) +
scale_y_continuous("Play Probability", limits = c(0, 1), labels = scales::percent) +
theme_minimal() +
theme(
legend.position = "right",
plot.title = element_text(hjust = 0.5, size = 18, face = "bold"),
panel.grid.minor = element_blank(),
axis.title = element_text(size = 12),
axis.text = element_text(size = 10)
)
point_colors <- ifelse(points_data$success_ind == 1, "green4", "white")
p_interactive <- suppressWarnings({
ggplotly(p, tooltip = NULL) %>%
add_trace(
data = points_data,
x = ~obs_player_bearing_diff,
y = ~play_prob,
type = "scatter",
mode = "markers",
color = I(point_colors),
marker = list(size = 10, line = list(color = "black", width = 1)),
text = ~paste0(
"Date: ", Date, "<br>",
"Batter: ", Batter, "<br>",
"Play Probability: ", round(100 * play_prob, 1), "%<br>",
"Angular Distance Away: ", round(obs_player_bearing_diff, 1), "°<br>",
"Exit Velocity: ", round(ExitSpeed, 1), " mph<br>",
star_group
),
hoverinfo = "text"
) %>%
layout(
hovermode = "closest",
title = list(
text = paste0(player, " - ", position,
"<br><sup>OAA: ", OAA, " | Plays: ", n_plays,
" | Expected: ", expected_success, " | Actual: ", actual_success, "</sup>"),
font = list(size = 18)
),
margin = list(t = 80)
)
})
suppressWarnings(p_interactive)
}
# Infield field graph
infield_field_graph <- function(player, position, data = IF_OAA) {
if (position %in% c("SS", "1B", "2B", "3B")) {
player_data <- data %>%
filter(obs_player_name == player) %>%
filter(obs_player == position)
} else {
player_data <- data %>%
filter(obs_player_name == player)
}
if (nrow(player_data) == 0) {
return(list(
ggplotly(ggplot() + theme_void() + ggtitle("No data available")),
gt(data.frame(Message = "No data")) %>% tab_header(title = "Play Success by Difficulty")
))
}
df_filtered <- player_data %>%
mutate(
rad = Bearing * pi/180,
x_pos = Distance * cos(rad),
z_pos = Distance * sin(rad),
avg_y = mean(obs_player_x, na.rm = TRUE),
avg_x = mean(obs_player_z, na.rm = TRUE)
)
df_table <- df_filtered %>%
group_by(star_group) %>%
summarize(
OAA = round(sum(OAA, na.rm = TRUE), 1),
Plays = n(),
Successes = sum(success_ind, na.rm = TRUE),
.groups = "drop"
) %>%
mutate(`Success Rate` = paste0(Successes, "/", Plays, " (", round((Successes/Plays)*100, 1), "%)")) %>%
dplyr::select(`Star Group` = star_group, `Success Rate`) %>%
t() %>% as.data.frame() %>%
`colnames<-`(.[1,]) %>% dplyr::slice(-1) %>%
mutate(OAA = round(sum(df_filtered$OAA, na.rm = TRUE), 1)) %>%
dplyr::select(OAA, everything()) %>%
gt::gt() %>%
gtExtras::gt_theme_guardian() %>%
gt::tab_header(title = "Play Success by Difficulty") %>%
gt::cols_align(align = "center", columns = gt::everything()) %>%
gt::sub_missing(columns = gt::everything(), missing_text = "-") %>%
gt::tab_options(
heading.background.color = "darkcyan",
column_labels.background.color = "darkcyan"
) %>%
gt::tab_style(style = gt::cell_text(color = "white"), locations = gt::cells_title(groups = "title"))
p <- ggplot() +
geom_segment(aes(x = 0, y = 0, xend = 100, yend = 100), color = "black") +
geom_segment(aes(x = 0, y = 0, xend = -100, yend = 100), color = "black") +
geom_segment(aes(x = 63.6396, y = 63.6396, xend = 0, yend = 127.279), color = "black") +
geom_segment(aes(x = -63.6396, y = 63.6396, xend = 0, yend = 127.279), color = "black") +
geom_segment(data = curve1, aes(x = x, y = y, xend = xend, yend = yend), size = 0.5, color = "black") +
geom_segment(data = curve2, aes(x = x, y = y, xend = xend, yend = yend), size = 0.5, color = "black") +
theme_void() +
geom_point(
data = df_filtered %>% mutate(is_play = ifelse(success_ind == 1, 'Play Made', "Not Made")),
aes(x = z_pos, y = x_pos, fill = is_play,
text = paste0("Date: ", Date, "<br>", "Play Probability: ", round(100 * play_prob, 1), "%<br>", star_group)),
color = "black", shape = 21, size = 2, alpha = .6
) +
labs(title = paste0(player, " Possible Plays - ", position)) +
scale_fill_manual(values = c("Not Made" = "white", "Play Made" = "green4"), name = " ") +
geom_point(data = df_filtered, aes(x = avg_x, y = avg_y), fill = "red", color = "black", size = 3, shape = 21) +
ylim(0, 160) + xlim(-120, 120) + coord_equal()
p <- ggplotly(p, tooltip = "text") %>% plotly::layout(hovermode = "closest")
return(list(p, df_table))
}
# Positioning heatmap
infield_positioning_heatmap <- function(team, batter_hand = "No Filter", pitcher_hand = "No Filter",
man_on_first = "No Filter", man_on_second = "No Filter", man_on_third = "No Filter") {
team_data <- def_pos_data %>% filter(PitcherTeam == team)
if (batter_hand != "No Filter") team_data <- team_data %>% filter(BatterSide == batter_hand)
if (pitcher_hand != "No Filter") team_data <- team_data %>% filter(PitcherThrows == pitcher_hand)
if (man_on_first != "No Filter") team_data <- team_data %>% filter(man_on_firstbase == as.numeric(man_on_first == "Yes"))
if (man_on_second != "No Filter") team_data <- team_data %>% filter(man_on_secondbase == as.numeric(man_on_second == "Yes"))
if (man_on_third != "No Filter") team_data <- team_data %>% filter(man_on_thirdbase == as.numeric(man_on_third == "Yes"))
n_observations <- nrow(team_data)
if (n_observations < 10) {
return(ggplot() + theme_void() + ggtitle("Insufficient data for selected filters"))
}
positions_long <- team_data %>%
pivot_longer(
cols = matches("^(1B|2B|3B|SS|LF|CF|RF)_PositionAtRelease[XZ]$"),
names_to = c("position", ".value"),
names_pattern = "^(.+)_PositionAtRelease([XZ])$"
)
p <- ggplot() +
geom_hline(yintercept = seq(0, 400, by = 20), linetype = "dotted", color = "gray80", size = 0.3) +
geom_vline(xintercept = seq(-240, 240, by = 20), linetype = "dotted", color = "gray80", size = 0.3) +
geom_density_2d_filled(data = positions_long, aes(x = Z, y = X), bins = 35, alpha = 0.5) +
scale_fill_manual(values = colorRampPalette(c("#FFFFFF", "#FFF5F0", "#FEE0D2", "#FCBBA1",
"#FC9272", "#FB6A4A", "#EF3B2C", "#CB181D", "#99000D"))(35)) +
geom_segment(aes(x = 0, y = 0, xend = 233.35, yend = 233.35), color = "black") +
geom_segment(aes(x = 0, y = 0, xend = -233.35, yend = 233.35), color = "black") +
geom_segment(aes(x = 63.6396, y = 63.6396, xend = 0, yend = 127.279), color = "black") +
geom_segment(aes(x = -63.6396, y = 63.6396, xend = 0, yend = 127.279), color = "black") +
geom_segment(data = curve1, aes(x = x, y = y, xend = xend, yend = yend), size = 0.5, color = "black") +
geom_segment(data = curve2, aes(x = x, y = y, xend = xend, yend = yend), size = 0.5, color = "black") +
geom_segment(data = curve3, aes(x = x, y = y, xend = xend, yend = yend), size = 0.5, color = "black") +
geom_segment(data = curve4, aes(x = x, y = y, xend = xend, yend = yend), size = 0.5, color = "black") +
labs(
title = paste0("Starting Fielder Position — ", team),
subtitle = paste0("Based on ", n_observations, " observations"),
x = "", y = ""
) +
ylim(0, 411) + xlim(-250, 250) + coord_equal() +
theme_void() +
theme(
legend.position = "none",
plot.title = element_text(hjust = .5, size = 20, face = "bold"),
plot.subtitle = element_text(hjust = 0.5)
)
return(p)
}
# ============================================================================
# UI
# ============================================================================
ui <- fluidPage(
tags$head(
tags$style(HTML("
@import url('https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700;800&display=swap');
body {
font-family: 'Outfit', sans-serif;
background: linear-gradient(135deg, #f5f7fa 0%, #e4e9f2 100%);
color: #2c3e50;
}
.app-header {
display: flex;
justify-content: center;
align-items: center;
padding: 25px 40px;
background: linear-gradient(135deg, #006F71 0%, #008B8B 50%, #20B2AA 100%);
border-bottom: 4px solid #A27752;
margin-bottom: 25px;
box-shadow: 0 8px 32px rgba(0, 111, 113, 0.3);
}
.header-title {
color: white;
font-size: 2.2rem;
font-weight: 800;
text-shadow: 2px 2px 4px rgba(0,0,0,0.2);
letter-spacing: 1px;
}
.header-subtitle {
color: rgba(255,255,255,0.9);
font-size: 1rem;
font-weight: 400;
margin-top: 5px;
}
.nav-tabs {
border: none !important;
border-radius: 50px;
padding: 8px 16px;
margin: 20px auto;
max-width: 95%;
background: linear-gradient(135deg, #d4edeb 0%, #e8ddd0 50%, #d4edeb 100%);
box-shadow: 0 4px 20px rgba(0,139,139,.15), inset 0 2px 4px rgba(255,255,255,.7);
display: flex;
justify-content: center;
flex-wrap: wrap;
gap: 8px;
}
.nav-tabs > li > a {
color: #006F71 !important;
border: none !important;
border-radius: 50px !important;
background: transparent !important;
font-weight: 600;
font-size: 15px;
padding: 12px 28px;
transition: all 0.3s ease;
letter-spacing: 0.3px;
}
.nav-tabs > li > a:hover {
color: #004d4e !important;
background: rgba(255,255,255,0.6) !important;
transform: translateY(-2px);
}
.nav-tabs > li.active > a,
.nav-tabs > li.active > a:focus,
.nav-tabs > li.active > a:hover {
background: linear-gradient(135deg, #006F71 0%, #008B8B 50%, #20B2AA 100%) !important;
color: #fff !important;
text-shadow: 0 1px 2px rgba(0,0,0,0.2);
box-shadow: 0 6px 20px rgba(0,139,139,0.4);
border: none !important;
}
.tab-content {
background: white;
border-radius: 20px;
padding: 30px;
margin-top: 15px;
box-shadow: 0 10px 40px rgba(0,0,0,0.08);
border: 1px solid rgba(0,139,139,0.1);
}
.well {
background: linear-gradient(135deg, #f8fffe 0%, #f5f5f0 100%);
border-radius: 15px;
border: 1px solid rgba(0,139,139,0.15);
box-shadow: 0 4px 15px rgba(0,0,0,0.05);
padding: 20px;
}
.control-label {
font-weight: 600;
color: #006F71;
font-size: 13px;
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 8px;
}
.selectize-input, .form-control {
border-radius: 12px !important;
border: 2px solid rgba(0,139,139,0.25) !important;
padding: 10px 15px !important;
font-size: 14px !important;
transition: all 0.3s ease;
}
.selectize-input:focus, .form-control:focus {
border-color: #006F71 !important;
box-shadow: 0 0 0 3px rgba(0,139,139,0.15) !important;
}
.section-header {
font-size: 1.4rem;
font-weight: 700;
color: #006F71;
margin-bottom: 20px;
padding-bottom: 10px;
border-bottom: 3px solid #A27752;
}
.metric-card {
background: linear-gradient(135deg, #006F71 0%, #008B8B 100%);
border-radius: 15px;
padding: 20px;
color: white;
text-align: center;
box-shadow: 0 6px 20px rgba(0,139,139,0.3);
margin-bottom: 15px;
}
.metric-value {
font-size: 2.5rem;
font-weight: 800;
}
.metric-label {
font-size: 0.9rem;
opacity: 0.9;
text-transform: uppercase;
letter-spacing: 1px;
}
.btn-primary {
background: linear-gradient(135deg, #006F71 0%, #008B8B 100%) !important;
border: none !important;
border-radius: 12px !important;
font-weight: 600 !important;
padding: 12px 25px !important;
transition: all 0.3s ease !important;
}
.btn-primary:hover {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(0,139,139,0.4) !important;
}
.gt_table {
border-radius: 12px !important;
overflow: hidden;
}
hr {
border-top: 2px solid rgba(162, 119, 82, 0.3);
margin: 30px 0;
}
"))
),
# Header
div(class = "app-header",
div(style = "text-align: center;",
div(class = "header-title", "Defensive Analytics Dashboard"),
div(class = "header-subtitle", "Coastal Carolina Baseball • Spring 2025")
)
),
# Main tabs
tabsetPanel(
id = "main_tabs",
# ==================== TAB 1: TEAM OAA ====================
tabPanel(
"Team OAA",
br(),
fluidRow(
column(12, h3(class = "section-header", "Outfielder Outs Above Average"))
),
fluidRow(
column(3, wellPanel(
selectInput("of_player", "Select Player:", choices = unique(OAA_DF$obs_player)),
selectInput("of_position", "Position:", choices = c("All", "RF", "CF", "LF")),
hr(),
h5("Quick Stats"),
uiOutput("of_quick_stats")
)),
column(9,
plotlyOutput("of_star_graph", height = "500px"),
br(),
fluidRow(
column(6, plotlyOutput("of_field_graph", height = "450px")),
column(6, gt_output("of_success_table"))
)
)
),
hr(),
fluidRow(
column(12, h3(class = "section-header", "Infielder Outs Above Average"))
),
fluidRow(
column(3, wellPanel(
selectInput("if_player", "Select Player:", choices = unique(IF_OAA$obs_player_name)),
selectInput("if_position", "Position:", choices = c("All", "SS", "2B", "3B", "1B"))
)),
column(9,
plotlyOutput("if_star_graph", height = "500px"),
br(),
fluidRow(
column(6, plotlyOutput("if_field_graph", height = "400px")),
column(6, gt_output("if_success_table"))
)
)
),
hr(),
fluidRow(
column(12, h3(class = "section-header", "Team OAA Leaderboard")),
column(12, DT::dataTableOutput("team_oaa_leaderboard"))
)
),
# ==================== TAB 2: OPPONENT OAA ====================
tabPanel(
"Opponent OAA",
br(),
fluidRow(
column(12, h3(class = "section-header", "Opponent Outfield Defense Analysis"))
),
fluidRow(
column(3, wellPanel(
selectInput("opp_team_filter", "Opponent Team:",
choices = c("All", unique(OPP_OAA_DF$PitcherTeam))),
selectInput("opp_of_player", "Select Player:", choices = unique(OPP_OAA_DF$obs_player)),
selectInput("opp_of_position", "Position:", choices = c("All", "RF", "CF", "LF"))
)),
column(9,
plotlyOutput("opp_of_star_graph", height = "500px"),
br(),
fluidRow(
column(6, plotlyOutput("opp_of_field_graph", height = "450px")),
column(6, gt_output("opp_of_success_table"))
)
)
),
hr(),
fluidRow(
column(12, h3(class = "section-header", "Opponent Infield Defense Analysis"))
),
fluidRow(
column(3, wellPanel(
selectInput("opp_if_player", "Select Player:", choices = unique(OPP_IF_OAA$obs_player_name)),
selectInput("opp_if_position", "Position:", choices = c("All", "SS", "2B", "3B", "1B"))
)),
column(9,
plotlyOutput("opp_if_star_graph", height = "500px")
)
),
hr(),
fluidRow(
column(12, h3(class = "section-header", "Opponent OAA Summary")),
column(12, DT::dataTableOutput("opp_oaa_leaderboard"))
)
),
# ==================== TAB 3: OPPONENT POSITIONING ====================
tabPanel(
"Opponent Positioning",
br(),
fluidRow(
column(12, h3(class = "section-header", "Defensive Positioning Analysis"))
),
fluidRow(
column(3, wellPanel(
selectInput("pos_team", "Select Team:",
choices = c("CCU", unique(def_pos_data$PitcherTeam[def_pos_data$PitcherTeam != "CCU"]))),
hr(),
h5("Situational Filters", style = "font-weight: 600; color: #006F71;"),
selectInput("pos_batter_hand", "Batter Hand:", choices = c("No Filter", "Right", "Left")),
selectInput("pos_pitcher_hand", "Pitcher Hand:", choices = c("No Filter", "Right", "Left")),
hr(),
h5("Baserunners", style = "font-weight: 600; color: #006F71;"),
selectInput("pos_first", "Runner on 1st:", choices = c("No Filter", "Yes", "No")),
selectInput("pos_second", "Runner on 2nd:", choices = c("No Filter", "Yes", "No")),
selectInput("pos_third", "Runner on 3rd:", choices = c("No Filter", "Yes", "No"))
)),
column(9,
plotOutput("positioning_heatmap", height = "650px")
)
),
hr(),
fluidRow(
column(12, h3(class = "section-header", "Average Positioning by Situation")),
column(12, gt_output("positioning_summary"))
)
),
# ==================== TAB 4: CATCHING ANALYSIS ====================
tabPanel(
"Catching Analysis",
br(),
fluidRow(
column(12, h3(class = "section-header", "Catcher Framing & Throwing"))
),
fluidRow(
column(3, wellPanel(
selectInput("catcher_select", "Select Catcher:", choices = unique(catcher_data$Catcher)),
hr(),
h5("Framing Filters", style = "font-weight: 600; color: #006F71;"),
pickerInput("pitch_type_filter", "Pitch Types:",
choices = unique(catcher_data$TaggedPitchType),
selected = unique(catcher_data$TaggedPitchType),
multiple = TRUE, options = list(`actions-box` = TRUE)),
numericInput("min_pitches_catcher", "Min Pitches:", value = 50, min = 1, max = 500)
)),
column(9,
fluidRow(
column(4, div(class = "metric-card",
div(class = "metric-value", textOutput("catcher_net_strikes")),
div(class = "metric-label", "Net Strikes")
)),
column(4, div(class = "metric-card",
div(class = "metric-value", textOutput("catcher_frame_rate")),
div(class = "metric-label", "Frame Rate")
)),
column(4, div(class = "metric-card",
div(class = "metric-value", textOutput("catcher_avg_pop")),
div(class = "metric-label", "Avg Pop Time")
))
),
br(),
fluidRow(
column(6,
h4("Framing Heatmap"),
plotOutput("catcher_framing_heatmap", height = "500px")),
column(6,
h4("Framing Points"),
plotlyOutput("catcher_framing_points", height = "500px"))
)
)
),
hr(),
fluidRow(
column(6,
h4("Throwing Accuracy Map"),
plotOutput("catcher_throw_map", height = "500px")),
column(6,
h4("Pop Time Distribution"),
plotlyOutput("catcher_pop_dist", height = "250px"),
h4("Throw Velocity Trend"),
plotlyOutput("catcher_velo_trend", height = "250px"))
),
hr(),
fluidRow(
column(12, h3(class = "section-header", "Catcher Leaderboard")),
column(12, DT::dataTableOutput("catcher_leaderboard"))
)
)
)
)
# ============================================================================
# SERVER
# ============================================================================
server <- function(input, output, session) {
# ==================== TAB 1: TEAM OAA ====================
output$of_quick_stats <- renderUI({
player_data <- OAA_DF %>% filter(obs_player == input$of_player)
if (input$of_position != "All") {
player_data <- player_data %>% filter(hit_location == input$of_position)
}
total_oaa <- round(sum(player_data$OAA, na.rm = TRUE), 1)
plays <- nrow(player_data)
success_rate <- round(mean(player_data$success_ind) * 100, 1)
tagList(
div(class = "metric-card", style = "padding: 12px;",
div(class = "metric-value", style = "font-size: 1.8rem;", total_oaa),
div(class = "metric-label", "Total OAA")
),
div(class = "metric-card", style = "padding: 12px; background: linear-gradient(135deg, #A27752 0%, #c49a6c 100%);",
div(class = "metric-value", style = "font-size: 1.8rem;", plays),
div(class = "metric-label", "Total Plays")
),
div(class = "metric-card", style = "padding: 12px; background: linear-gradient(135deg, #2c3e50 0%, #4a6278 100%);",
div(class = "metric-value", style = "font-size: 1.8rem;", paste0(success_rate, "%")),
div(class = "metric-label", "Success Rate")
)
)
})
output$of_star_graph <- renderPlotly({
star_graph(input$of_player, input$of_position, OAA_DF)
})
of_field_results <- reactive({
field_graph(input$of_player, input$of_position, OAA_DF)
})
output$of_field_graph <- renderPlotly({
of_field_results()[[1]]
})
output$of_success_table <- render_gt({
of_field_results()[[2]]
})
output$if_star_graph <- renderPlotly({
infield_star_graph(input$if_player, input$if_position, IF_OAA)
})
if_field_results <- reactive({
infield_field_graph(input$if_player, input$if_position, IF_OAA)
})
output$if_field_graph <- renderPlotly({
if_field_results()[[1]]
})
output$if_success_table <- render_gt({
if_field_results()[[2]]
})
output$team_oaa_leaderboard <- DT::renderDataTable({
of_summary <- OAA_DF %>%
group_by(Player = obs_player) %>%
summarise(
`OF OAA` = round(sum(OAA, na.rm = TRUE), 1),
`OF Plays` = n(),
`OF Success %` = round(mean(success_ind) * 100, 1),
.groups = "drop"
)
if_summary <- IF_OAA %>%
group_by(Player = obs_player_name) %>%
summarise(
`IF OAA` = round(sum(OAA, na.rm = TRUE), 1),
`IF Plays` = n(),
`IF Success %` = round(mean(success_ind) * 100, 1),
.groups = "drop"
)
full_join(of_summary, if_summary, by = "Player") %>%
mutate(
`Total OAA` = coalesce(`OF OAA`, 0) + coalesce(`IF OAA`, 0)
) %>%
arrange(desc(`Total OAA`)) %>%
DT::datatable(
options = list(pageLength = 15, scrollX = TRUE),
rownames = FALSE
)
})
# ==================== TAB 2: OPPONENT OAA ====================
output$opp_of_star_graph <- renderPlotly({
star_graph(input$opp_of_player, input$opp_of_position, OPP_OAA_DF)
})
opp_of_field_results <- reactive({
field_graph(input$opp_of_player, input$opp_of_position, OPP_OAA_DF)
})
output$opp_of_field_graph <- renderPlotly({
opp_of_field_results()[[1]]
})
output$opp_of_success_table <- render_gt({
opp_of_field_results()[[2]]
})
output$opp_if_star_graph <- renderPlotly({
infield_star_graph(input$opp_if_player, input$opp_if_position, OPP_IF_OAA)
})
output$opp_oaa_leaderboard <- DT::renderDataTable({
opp_summary <- OPP_OAA_DF %>%
group_by(Player = obs_player, Team = PitcherTeam) %>%
summarise(
OAA = round(sum(OAA, na.rm = TRUE), 1),
Plays = n(),
`Success %` = round(mean(success_ind) * 100, 1),
.groups = "drop"
) %>%
arrange(desc(OAA))
DT::datatable(opp_summary, options = list(pageLength = 15), rownames = FALSE)
})
# ==================== TAB 3: POSITIONING ====================
output$positioning_heatmap <- renderPlot({
infield_positioning_heatmap(
team = input$pos_team,
batter_hand = input$pos_batter_hand,
pitcher_hand = input$pos_pitcher_hand,
man_on_first = input$pos_first,
man_on_second = input$pos_second,
man_on_third = input$pos_third
)
})
output$positioning_summary <- render_gt({
team_data <- def_pos_data %>% filter(PitcherTeam == input$pos_team)
team_data %>%
summarise(
`Avg 1B X` = round(mean(`1B_PositionAtReleaseX`, na.rm = TRUE), 1),
`Avg 1B Z` = round(mean(`1B_PositionAtReleaseZ`, na.rm = TRUE), 1),
`Avg SS X` = round(mean(`SS_PositionAtReleaseX`, na.rm = TRUE), 1),
`Avg SS Z` = round(mean(`SS_PositionAtReleaseZ`, na.rm = TRUE), 1),
`Avg CF X` = round(mean(`CF_PositionAtReleaseX`, na.rm = TRUE), 1),
`Avg CF Z` = round(mean(`CF_PositionAtReleaseZ`, na.rm = TRUE), 1),
Observations = n()
) %>%
gt() %>%
tab_header(title = paste("Average Positioning -", input$pos_team)) %>%
cols_align(align = "center") %>%
tab_options(heading.background.color = "darkcyan") %>%
tab_style(style = cell_text(color = "white"), locations = cells_title())
})
# ==================== TAB 4: CATCHING ====================
catcher_filtered <- reactive({
catcher_data %>%
filter(Catcher == input$catcher_select) %>%
filter(TaggedPitchType %in% input$pitch_type_filter)
})
output$catcher_net_strikes <- renderText({
df <- catcher_filtered()
net <- sum(df$frame_numeric, na.rm = TRUE)
sprintf("%+d", net)
})
output$catcher_frame_rate <- renderText({
df <- catcher_filtered() %>% filter(!is.na(frame_numeric))
if (nrow(df) == 0) return("N/A")
added <- sum(df$frame_numeric == 1, na.rm = TRUE)
total <- sum(df$frame_numeric != 0, na.rm = TRUE)
if (total == 0) return("N/A")
paste0(round(added / total * 100, 1), "%")
})
output$catcher_avg_pop <- renderText({
df <- catcher_filtered() %>% filter(!is.na(PopTime))
if (nrow(df) == 0) return("N/A")
paste0(round(mean(df$PopTime, na.rm = TRUE), 2), "s")
})
output$catcher_framing_heatmap <- renderPlot({
df <- catcher_filtered() %>%
filter(!is.na(frame), !is.na(PlateLocSide), !is.na(PlateLocHeight)) %>%
filter(frame %in% c("Strike Added", "Strike Lost"))
if (nrow(df) < 10) {
return(ggplot() + theme_void() + ggtitle("Insufficient data"))
}
ggplot(df, aes(PlateLocSide, PlateLocHeight)) +
stat_density_2d(aes(fill = after_stat(density)), geom = "raster", contour = FALSE, alpha = 0.8) +
scale_fill_gradientn(colours = c("white", "lightblue", "#FF9999", "red", "darkred")) +
annotate("rect", xmin = -0.83, xmax = 0.83, ymin = 1.5, ymax = 3.38,
fill = NA, color = "black", linewidth = 1.5) +
coord_fixed() + xlim(-2, 2) + ylim(0, 4.5) +
facet_wrap(~frame, nrow = 1) +
theme_void() +
theme(strip.text = element_text(size = 16, face = "bold"), legend.position = "none")
})
output$catcher_framing_points <- renderPlotly({
df <- catcher_filtered() %>%
filter(!is.na(frame), !is.na(PlateLocSide), !is.na(PlateLocHeight),
frame %in% c("Strike Added", "Strike Lost"))
if (nrow(df) < 10) {
return(ggplotly(ggplot() + theme_void()))
}
pitch_colors <- c("Fastball" = "#FA8072", "Sinker" = "#fdae61", "Slider" = "#A020F0",
"Curveball" = "#2c7bb6", "ChangeUp" = "#90EE90", "Cutter" = "red")
p <- ggplot(df, aes(PlateLocSide, PlateLocHeight, color = TaggedPitchType,
text = paste0("Pitch: ", TaggedPitchType, "<br>Frame: ", frame))) +
geom_point(alpha = 0.7, size = 2) +
annotate("rect", xmin = -0.83, xmax = 0.83, ymin = 1.5, ymax = 3.38,
fill = NA, color = "black", linewidth = 1) +
coord_fixed(xlim = c(-2, 2), ylim = c(0, 4.5)) +
facet_wrap(~frame, nrow = 1) +
scale_color_manual(values = pitch_colors, na.value = "grey60") +
theme_minimal() +
theme(strip.text = element_text(face = "bold"))
ggplotly(p, tooltip = "text")
})
output$catcher_throw_map <- renderPlot({
df <- catcher_filtered() %>%
filter(!is.na(Notes), Notes %in% c("2b out", "2b safe", "3b out", "3b safe"))
if (nrow(df) == 0) {
return(ggplot() + theme_void() + ggtitle("No throwing data available"))
}
ggplot(df) +
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_point(aes(x = BasePositionZ, y = BasePositionY, fill = Notes),
color = 'white', pch = 21, size = 4) +
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)) +
coord_fixed() +
theme_void() +
theme(legend.position = "bottom") +
ggtitle(paste(input$catcher_select, "- Throwing Report"))
})
output$catcher_pop_dist <- renderPlotly({
df <- catcher_filtered() %>% filter(!is.na(PopTime))
if (nrow(df) == 0) return(ggplotly(ggplot() + theme_void()))
p <- ggplot(df, aes(PopTime)) +
geom_histogram(bins = 15, fill = "darkcyan", alpha = 0.7, color = "white") +
geom_vline(xintercept = mean(df$PopTime), color = "peru", linetype = "dashed", size = 1) +
labs(x = "Pop Time (s)", y = "Count") +
theme_minimal()
ggplotly(p)
})
output$catcher_velo_trend <- renderPlotly({
df <- catcher_filtered() %>%
filter(!is.na(ThrowSpeed)) %>%
arrange(Date) %>%
mutate(throw_num = row_number())
if (nrow(df) == 0) return(ggplotly(ggplot() + theme_void()))
p <- ggplot(df, aes(throw_num, ThrowSpeed)) +
geom_point(color = "darkcyan", size = 2) +
geom_smooth(method = "loess", color = "peru", se = TRUE, fill = "peru", alpha = 0.3) +
labs(x = "Throw #", y = "Velocity (MPH)") +
theme_minimal()
ggplotly(p)
})
output$catcher_leaderboard <- DT::renderDataTable({
catcher_data %>%
group_by(Catcher) %>%
summarise(
`Pitches Caught` = n(),
`Strikes Added` = sum(frame_numeric == 1, na.rm = TRUE),
`Strikes Lost` = sum(frame_numeric == -1, na.rm = TRUE),
`Net Strikes` = sum(frame_numeric, na.rm = TRUE),
`Frame Rate` = round(sum(frame_numeric == 1, na.rm = TRUE) /
max(1, sum(!is.na(frame_numeric) & frame_numeric != 0)) * 100, 1),
`Avg Pop Time` = round(mean(PopTime, na.rm = TRUE), 2),
`Avg Throw Velo` = round(mean(ThrowSpeed, na.rm = TRUE), 1),
.groups = "drop"
) %>%
arrange(desc(`Net Strikes`)) %>%
DT::datatable(options = list(pageLength = 10), rownames = FALSE)
})
# ============================================================================
# RUN APP
# ============================================================================
shinyApp(ui = ui, server = server)