igroffman's picture
Update app.R
7ccc0f3 verified
library(shiny)
library(shinydashboard)
library(DT)
library(dplyr)
library(ggplot2)
library(tidyr)
library(stringr)
library(scales)
library(maps)
library(mapproj)
library(arrow)
library(readr)
library(xgboost)
# Load data
TM25 <- read_parquet("TM25.parquet")
ccbl_data <- read_parquet("ccbl_data.parquet")
# Function to calculate Stuff+ using your models
calculate_stuff_plus <- function(data) {
# Initialize Stuff+ column first
data$StuffPlus <- NA
# Check if required columns exist (updated list)
required_cols <- c("RelSpeed", "SpinRate", "RelHeight", "RelSide", "Extension",
"InducedVertBreak", "HorzBreak", "PitcherThrows")
missing_cols <- required_cols[!required_cols %in% names(data)]
if(length(missing_cols) > 0) {
warning("Missing required columns for Stuff+ calculation: ", paste(missing_cols, collapse = ", "))
return(data)
}
# Check if models are loaded and xgboost is available
if(!exists("RHPFBStuff") || !exists("LHPFBStuff") || !exists("RHPBBStuff") || !exists("LHPBBStuff")) {
warning("Stuff+ models not found")
return(data)
}
if(!requireNamespace("xgboost", quietly = TRUE)) {
warning("xgboost package not available for Stuff+ calculation")
return(data)
}
tryCatch({
# Create missing columns that the models expect
data$SeamShiftedWakeEffect <- ifelse(is.na(data$SeamShiftedWakeEffect), 0, data$SeamShiftedWakeEffect)
data$UniquenessScore <- ifelse(is.na(data$UniquenessScore), 0, data$UniquenessScore)
# Define the exact feature columns expected by the models
model_features <- c("RelSpeed", "SpinRate", "RelHeight", "RelSide",
"Extension", "InducedVertBreak", "HorzBreak",
"SeamShiftedWakeEffect", "UniquenessScore")
# Create prediction matrix for fastballs
fb_data <- data %>%
filter(TaggedPitchType %in% c("Fastball", "FourSeamFastBall", "FourSeamFastB", "Sinker", "TwoSeamFastBall")) %>%
filter(!is.na(RelSpeed), !is.na(SpinRate), !is.na(RelHeight), !is.na(RelSide),
!is.na(Extension), !is.na(InducedVertBreak), !is.na(HorzBreak), !is.na(PitcherThrows))
# Create prediction matrix for breaking balls
bb_data <- data %>%
filter(TaggedPitchType %in% c("Slider", "Curveball", "Changeup", "ChangeUp", "Sweeper", "Cutter", "Splitter")) %>%
filter(!is.na(RelSpeed), !is.na(SpinRate), !is.na(RelHeight), !is.na(RelSide),
!is.na(Extension), !is.na(InducedVertBreak), !is.na(HorzBreak), !is.na(PitcherThrows))
# Calculate Fastball Stuff+ for RHP
if(nrow(fb_data %>% filter(PitcherThrows == "Right")) > 0) {
rhp_fb <- fb_data %>% filter(PitcherThrows == "Right")
# Create matrix with all model features
rhp_fb_matrix <- as.matrix(rhp_fb[, model_features])
# Apply the same preprocessing as used in model training (center and scale)
# Get preprocessing info from the model if available
if(!is.null(RHPFBStuff$preProcess)) {
rhp_fb_matrix_processed <- predict(RHPFBStuff$preProcess, rhp_fb_matrix)
} else {
# Manual centering and scaling if preProcess object not available
rhp_fb_matrix_processed <- scale(rhp_fb_matrix, center = TRUE, scale = TRUE)
}
# Convert to xgb.DMatrix
rhp_fb_dmatrix <- xgboost::xgb.DMatrix(data = rhp_fb_matrix_processed)
# Predict using the model directly (not finalModel)
if(!is.null(RHPFBStuff$finalModel)) {
rhp_fb_pred <- predict(RHPFBStuff$finalModel, rhp_fb_dmatrix)
} else {
rhp_fb_pred <- predict(RHPFBStuff, rhp_fb_dmatrix)
}
# Convert to Stuff+ scale (models predict delta_RE, so negative values are better)
rhp_fb_stuffplus <- 100 + (rhp_fb_pred * -20)
# Find indices in original data
rhp_fb_indices <- which(data$TaggedPitchType %in% c("Fastball", "FourSeamFastBall", "FourSeamFastB", "Sinker", "TwoSeamFastBall") &
data$PitcherThrows == "Right" &
!is.na(data$RelSpeed) & !is.na(data$SpinRate) & !is.na(data$RelHeight) &
!is.na(data$RelSide) & !is.na(data$Extension) & !is.na(data$InducedVertBreak) &
!is.na(data$HorzBreak) & !is.na(data$PitcherThrows))
if(length(rhp_fb_stuffplus) == length(rhp_fb_indices)) {
data$StuffPlus[rhp_fb_indices] <- rhp_fb_stuffplus
}
}
# Calculate Fastball Stuff+ for LHP
if(nrow(fb_data %>% filter(PitcherThrows == "Left")) > 0) {
lhp_fb <- fb_data %>% filter(PitcherThrows == "Left")
lhp_fb_matrix <- as.matrix(lhp_fb[, model_features])
if(!is.null(LHPFBStuff$preProcess)) {
lhp_fb_matrix_processed <- predict(LHPFBStuff$preProcess, lhp_fb_matrix)
} else {
lhp_fb_matrix_processed <- scale(lhp_fb_matrix, center = TRUE, scale = TRUE)
}
lhp_fb_dmatrix <- xgboost::xgb.DMatrix(data = lhp_fb_matrix_processed)
if(!is.null(LHPFBStuff$finalModel)) {
lhp_fb_pred <- predict(LHPFBStuff$finalModel, lhp_fb_dmatrix)
} else {
lhp_fb_pred <- predict(LHPFBStuff, lhp_fb_dmatrix)
}
lhp_fb_stuffplus <- 100 + (lhp_fb_pred * -20)
lhp_fb_indices <- which(data$TaggedPitchType %in% c("Fastball", "FourSeamFastBall", "FourSeamFastB", "Sinker", "TwoSeamFastBall") &
data$PitcherThrows == "Left" &
!is.na(data$RelSpeed) & !is.na(data$SpinRate) & !is.na(data$RelHeight) &
!is.na(data$RelSide) & !is.na(data$Extension) & !is.na(data$InducedVertBreak) &
!is.na(data$HorzBreak) & !is.na(data$PitcherThrows))
if(length(lhp_fb_stuffplus) == length(lhp_fb_indices)) {
data$StuffPlus[lhp_fb_indices] <- lhp_fb_stuffplus
}
}
# Calculate Breaking Ball Stuff+ for RHP
if(nrow(bb_data %>% filter(PitcherThrows == "Right")) > 0) {
rhp_bb <- bb_data %>% filter(PitcherThrows == "Right")
rhp_bb_matrix <- as.matrix(rhp_bb[, model_features])
if(!is.null(RHPBBStuff$preProcess)) {
rhp_bb_matrix_processed <- predict(RHPBBStuff$preProcess, rhp_bb_matrix)
} else {
rhp_bb_matrix_processed <- scale(rhp_bb_matrix, center = TRUE, scale = TRUE)
}
rhp_bb_dmatrix <- xgboost::xgb.DMatrix(data = rhp_bb_matrix_processed)
if(!is.null(RHPBBStuff$finalModel)) {
rhp_bb_pred <- predict(RHPBBStuff$finalModel, rhp_bb_dmatrix)
} else {
rhp_bb_pred <- predict(RHPBBStuff, rhp_bb_dmatrix)
}
rhp_bb_stuffplus <- 100 + (rhp_bb_pred * -20)
rhp_bb_indices <- which(data$TaggedPitchType %in% c("Slider", "Curveball", "Changeup", "ChangeUp", "Sweeper", "Cutter", "Splitter") &
data$PitcherThrows == "Right" &
!is.na(data$RelSpeed) & !is.na(data$SpinRate) & !is.na(data$RelHeight) &
!is.na(data$RelSide) & !is.na(data$Extension) & !is.na(data$InducedVertBreak) &
!is.na(data$HorzBreak) & !is.na(data$PitcherThrows))
if(length(rhp_bb_stuffplus) == length(rhp_bb_indices)) {
data$StuffPlus[rhp_bb_indices] <- rhp_bb_stuffplus
}
}
# Calculate Breaking Ball Stuff+ for LHP
if(nrow(bb_data %>% filter(PitcherThrows == "Left")) > 0) {
lhp_bb <- bb_data %>% filter(PitcherThrows == "Left")
lhp_bb_matrix <- as.matrix(lhp_bb[, model_features])
if(!is.null(LHPBBStuff$preProcess)) {
lhp_bb_matrix_processed <- predict(LHPBBStuff$preProcess, lhp_bb_matrix)
} else {
lhp_bb_matrix_processed <- scale(lhp_bb_matrix, center = TRUE, scale = TRUE)
}
lhp_bb_dmatrix <- xgboost::xgb.DMatrix(data = lhp_bb_matrix_processed)
if(!is.null(LHPBBStuff$finalModel)) {
lhp_bb_pred <- predict(LHPBBStuff$finalModel, lhp_bb_dmatrix)
} else {
lhp_bb_pred <- predict(LHPBBStuff, lhp_bb_dmatrix)
}
lhp_bb_stuffplus <- 100 + (lhp_bb_pred * -20)
lhp_bb_indices <- which(data$TaggedPitchType %in% c("Slider", "Curveball", "Changeup", "ChangeUp", "Sweeper", "Cutter", "Splitter") &
data$PitcherThrows == "Left" &
!is.na(data$RelSpeed) & !is.na(data$SpinRate) & !is.na(data$RelHeight) &
!is.na(data$RelSide) & !is.na(data$Extension) & !is.na(data$InducedVertBreak) &
!is.na(data$HorzBreak) & !is.na(data$PitcherThrows))
if(length(lhp_bb_stuffplus) == length(lhp_bb_indices)) {
data$StuffPlus[lhp_bb_indices] <- lhp_bb_stuffplus
}
}
}, error = function(e) {
warning("Error in Stuff+ calculation: ", e$message)
# Print more detailed error info for debugging
cat("Detailed error in Stuff+ calculation:\n")
cat("Error message:", e$message, "\n")
if(exists("rhp_fb_matrix")) {
cat("RHP FB matrix dimensions:", dim(rhp_fb_matrix), "\n")
cat("RHP FB matrix colnames:", colnames(rhp_fb_matrix), "\n")
}
})
return(data)
}
# Function to calculate run values (from your TM25 processing code)
calculate_run_values <- function(data) {
# Add count state columns
data$begin_Strike <- data$Strikes
data$begin_Ball <- data$Balls
data$begin_Outs <- data$Outs
data$end_Strike <- data$Strikes
data$end_Ball <- data$Balls
data$end_Outs <- data$Outs
# Update end states based on pitch outcomes
for (i in 1:nrow(data)) {
if (!is.na(data$OutsOnPlay[i]) && data$OutsOnPlay[i] == 1) {
data$end_Outs[i] <- data$begin_Outs[i] + 1
}
if (data$PitchCall[i] == "StrikeCalled") {
data$end_Strike[i] <- data$end_Strike[i] + 1
} else if (data$PitchCall[i] %in% c("BallCalled", "BallinDirt", "BallIntentional")) {
data$end_Ball[i] <- data$end_Ball[i] + 1
} else if (data$PitchCall[i] == "BattersInterference") {
data$end_Ball[i] <- 0
data$end_Strike[i] <- 0
} else if (data$PitchCall[i] %in% c("FoulBall", "FoulBallFieldable", "FoulBallNotFieldable")) {
if (data$begin_Strike[i] < 2) {
data$end_Strike[i] <- data$end_Strike[i] + 1
}
} else if (data$PitchCall[i] %in% c("HitByPitch", "InPlay")) {
data$end_Strike[i] <- 0
data$end_Ball[i] <- 0
} else if (data$PitchCall[i] == "StrikeSwinging") {
data$end_Strike[i] <- data$end_Strike[i] + 1
}
if (data$end_Strike[i] == 3) {
data$end_Strike[i] <- 0
data$end_Ball[i] <- 0
data$end_Outs[i] <- data$end_Outs[i] + 1
}
if (data$end_Ball[i] == 4) {
data$end_Ball[i] <- 0
data$end_Strike[i] <- 0
}
}
# Create run expectancy table
simple_RE <- data %>%
group_by(GameID, Inning, Top.Bottom) %>%
mutate(
cumulative_runs = cumsum(RunsScored),
total_runs = max(cumulative_runs),
runs_remaining = total_runs - cumulative_runs
) %>%
ungroup() %>%
filter(begin_Ball < 4, begin_Strike < 3, begin_Outs < 3) %>%
group_by(begin_Ball, begin_Strike, begin_Outs) %>%
summarise(
run_expectancy = round(mean(runs_remaining, na.rm = TRUE), 3),
.groups = 'drop'
)
# Join beginning state RE
data <- left_join(data, simple_RE,
by = c("begin_Strike", "begin_Ball", "begin_Outs"))
# Rename for end state join
simple_RE_end <- simple_RE %>%
rename(
end_Strike = begin_Strike,
end_Ball = begin_Ball,
end_Outs = begin_Outs,
end_run_expectancy = run_expectancy
)
# Join end state RE
data <- left_join(data, simple_RE_end,
by = c("end_Strike", "end_Ball", "end_Outs"))
# Handle end of inning (3 outs = 0 run expectancy)
data$end_run_expectancy <- ifelse(data$end_Outs >= 3, 0, data$end_run_expectancy)
# Calculate delta run expectancy
data$delta_RE <- data$end_run_expectancy - data$run_expectancy + data$RunsScored
return(data)
}
TMcolors <- c(
"Fastball" = "dodgerblue2",
"FourSeamFastBall" = "dodgerblue2",
"FourSeamFastB" = "dodgerblue2",
"OneSeamFastBall" = "yellow",
"TwoSeamFastBall" = "yellow",
"Sinker" = "yellow",
"Cutter" = "orange",
"Changeup" = "#01FF70",
"ChangeUp" = "#01FF70",
"Slider" = "tomato",
"Sweeper" = "gold3",
"Curveball" = "#B10DC9",
"Splitter" = "#01FF70",
"Knuckleball" = "lightblue",
"Other" = "gray60"
)
ui <- dashboardPage(
dashboardHeader(title = "NCAA Pitcher Dashboard"),
dashboardSidebar(
sidebarMenu(
menuItem("Percentiles", tabName = "pitcher", icon = icon("baseball-ball")),
menuItem("Pitch Movement and Arsenal", tabName = "movement", icon = icon("chart-line")),
menuItem("Seam Orientation", tabName = "seam", icon = icon("circle-dot")),
menuItem("Location and Usage", tabName = "location", icon = icon("crosshairs"))
)
),
dashboardBody(
tabItems(
# Original Pitcher Analysis Tab
tabItem(tabName = "pitcher",
fluidRow(
box(
title = "Controls", status = "primary", solidHeader = TRUE, width = 12, color = "teal",
fluidRow(
column(4,
selectInput("league", "Select League:",
choices = c("Cape Cod League" = "ccbl", "NCAA 2025" = "ncaa2025", "NCAA 2024" = "ncaa2024"),
selected = "ccbl"
)
),
column(4,
selectInput("pitcher", "Select Pitcher:",
choices = NULL,
selected = NULL
)
),
column(4,
br(),
actionButton("update", "Update Analysis", class = "btn-primary")
)
)
)
),
fluidRow(
box(
title = "Pitcher Percentiles", status = "success", solidHeader = TRUE, width = 12,
plotOutput("percentile_plot", height = "550px")
)
),
fluidRow(
box(
title = "Pitcher Statistics", status = "info", solidHeader = TRUE, width = 12,
DT::dataTableOutput("pitcher_stats")
)
)
),
# Enhanced Pitch Movement Tab
tabItem(tabName = "movement",
fluidRow(
box(
title = "Movement Controls", status = "primary", solidHeader = TRUE, width = 12, color = "teal",
fluidRow(
column(3,
selectInput("movement_league", "Select League:",
choices = c("Cape Cod League" = "ccbl", "NCAA 2025" = "ncaa2025", "NCAA 2024" = "ncaa2024"),
selected = "ccbl"
)
),
column(3,
selectInput("movement_pitcher", "Select Pitcher:",
choices = NULL,
selected = NULL
)
),
column(3,
selectInput("plot_type", "Plot Type:",
choices = c("Movement Profile" = "movement", "Pitch Breaks" = "breaks"),
selected = "movement"
)
),
column(3,
br(),
actionButton("update_movement", "Update Movement", class = "btn-primary")
)
)
)
),
fluidRow(
box(
title = "Pitch Visualization", status = "success", solidHeader = TRUE, width = 12, color = "teal",
plotOutput("movement_plot", height = "700px")
)
),
fluidRow(
box(
title = "Release Points", status = "info", solidHeader = TRUE, width = 6, color = "teal",
plotOutput("release_plot", height = "550px")
)
),
fluidRow(
box(
title = "Pitch Arsenal Summary", status = "info", solidHeader = TRUE, width = 12, color = "teal",
DT::dataTableOutput("movement_stats")
)
)
),
# Seam Orientation Tab
tabItem(tabName = "seam",
fluidRow(
box(
title = "Seam Orientation Controls", status = "primary", solidHeader = TRUE, width = 12, color = "teal",
fluidRow(
column(3,
h5("Data Source: Cape Cod League Only"),
p("Seam orientation analysis requires 3D spin axis data only available in CCBL dataset.",
style = "color: #666; font-style: italic;")
),
column(3,
selectInput("seam_pitcher", "Select Pitcher:",
choices = NULL,
selected = NULL
)
),
column(3,
checkboxInput("show_seam_curve", "Show Seam Curve", value = TRUE),
numericInput("point_size", "Point Size:", value = 3, min = 0.5, max = 3, step = 0.1)
),
column(3,
numericInput("point_alpha", "Point Transparency:", value = 1.5, min = 0.1, max = 2, step = 0.05),
selectInput("filter_pitch_type", "Filter by Pitch Type:",
choices = c("All Pitches" = "all"),
selected = "all"),
br(),
actionButton("update_seam", "Update Chart", class = "btn-primary")
)
)
)
),
fluidRow(
box(
title = "Seam Orientation Chart", status = "success", solidHeader = TRUE, width = 12, color = "teal",
plotOutput("seam_plot", height = "700px")
)
),
fluidRow(
box(
title = "Seam Orientation Metrics", status = "info", solidHeader = TRUE, width = 12, color = "teal",
DT::dataTableOutput("seam_metrics_table")
)
)
),
# Location and Usage Tab (Fixed)
tabItem(tabName = "location",
fluidRow(
box(
title = "Chart Controls", status = "primary", solidHeader = TRUE, width = 12, color = "teal",
fluidRow(
column(6,
selectInput("location_league", "Select League:",
choices = c("Cape Cod League" = "ccbl", "NCAA 2025" = "ncaa2025", "NCAA 2024" = "ncaa2024"),
selected = "ccbl")
),
column(6,
selectInput("location_pitcher", "Select Pitcher:", choices = NULL)
)
)
)
),
fluidRow(
box(title = "Pitch Location Heatmaps", width = 6, status = "info", solidHeader = TRUE,
plotOutput("location_plot", height = "450px")),
box(title = "Pitch Usage by Count", width = 6, status = "warning", solidHeader = TRUE,
plotOutput("count_usage_plot", height = "450px"))
),
fluidRow(
box(
title = "Location Summary Statistics", status = "success", solidHeader = TRUE, width = 12, color = "teal",
DT::dataTableOutput("location_stats")
)
)
)
)
)
)
# Define server logic
server <- function(input, output, session) {
# Data preprocessing function - UPDATED to include Stuff+ calculation
preprocess_data <- function(data, league_type) {
if (league_type == "ccbl") {
# Cape Cod League preprocessing
data <- data %>%
filter(!TaggedPitchType %in% c("Knuckleball", "Undefined", "Other")) %>%
mutate(
RelSpeed = as.numeric(RelSpeed),
SpinRate = as.numeric(SpinRate),
InducedVertBreak = as.numeric(InducedVertBreak),
HorzBreak = as.numeric(HorzBreak),
Extension = as.numeric(Extension),
ExitSpeed = as.numeric(ExitSpeed),
Angle = as.numeric(Angle),
RelSide = as.numeric(RelSide),
RelHeight = as.numeric(RelHeight),
PlateLocSide = as.numeric(PlateLocSide),
PlateLocHeight = as.numeric(PlateLocHeight),
is_csw = ifelse(PitchCall %in% c("StrikeSwinging", "StrikeCalled"), 1, 0),
is_swing = ifelse(PitchCall %in% c("StrikeSwinging", "FoulBallNotFieldable", "InPlay",
"FoulBallFieldable", "FoulBall"), 1, 0),
is_whiff = ifelse(PitchCall == "StrikeSwinging" & is_swing == 1, 1,
ifelse(is_swing == 1, 0, NA)),
in_zone = case_when(
PlateLocSide >= -0.95 & PlateLocSide <= 0.95 &
PlateLocHeight >= 1.6 & PlateLocHeight <= 3.5 ~ 1,
TRUE ~ 0
),
in_zone_whiff = ifelse(PitchCall == "StrikeSwinging" & in_zone == 1, 1,
ifelse(is_swing == 1 & in_zone == 1, 0, NA)),
chase = ifelse(is_swing == 1 & in_zone == 0, 1,
ifelse(is_swing == 0 & in_zone == 0, 0, NA)),
is_hit = ifelse(PlayResult %in% c("Single", "Double", "Triple", "HomeRun"), 1,
ifelse(PlayResult %in% c("FieldersChoice", "Error", "Out", "Sacrifice"), 0, NA)),
is_hard_hit = ifelse(PitchCall == "InPlay" & ExitSpeed >= 95, 1,
ifelse(PitchCall == "InPlay" & ExitSpeed < 95, 0, NA)),
is_barrel = ifelse(!is.na(ExitSpeed) & !is.na(Angle) & PitchCall == "InPlay" &
(ExitSpeed * 1.5 - Angle) >= 117 &
(ExitSpeed + Angle) >= 124 &
Angle <= 50 & ExitSpeed >= 98, 1,
ifelse(PitchCall == "InPlay", 0, NA)),
is_plate_appearance = ifelse(
PitchCall %in% c("InPlay", "HitByPitch") | KorBB %in% c("Strikeout", "Walk") |
PlayResult %in% c("StrikeoutSwinging", "StrikeoutLooking"), 1, 0
),
is_at_bat = case_when(
PitchCall == "InPlay" & !PlayResult %in% c("StolenBase", "Sacrifice", "CaughtStealing", "Undefined") ~ 1,
KorBB == "Strikeout" ~ 1,
PlayResult %in% c("StrikeoutSwinging", "StrikeoutLooking") ~ 1,
TRUE ~ 0
),
is_walk = case_when(
is_plate_appearance == 1 & KorBB == "Walk" ~ 1,
is_plate_appearance == 1 & KorBB != "Walk" ~ 0,
TRUE ~ NA_real_
),
Pitcher = str_replace_all(Pitcher, "(\\w+), (\\w+)", "\\2 \\1"),
is_k = case_when(
is_at_bat == 1 & KorBB == "Strikeout" ~ 1,
is_at_bat == 1 & KorBB != "Strikeout" ~ 0,
PlayResult %in% c("StrikeoutSwinging", "StrikeoutLooking") ~ 1,
TRUE ~ NA_real_
)
)
} else if (league_type %in% c("ncaa2025", "ncaa2024")) {
# NCAA preprocessing (both 2024 and 2025)
data <- data %>%
mutate(
RelSide = as.numeric(RelSide),
RelHeight = as.numeric(RelHeight),
PlateLocSide = as.numeric(PlateLocSide),
PlateLocHeight = as.numeric(PlateLocHeight),
is_csw = case_when(
PitchCall %in% c("StrikeSwinging", "StrikeCalled") ~ 1,
TRUE ~ 0
),
is_swing = case_when(
PitchCall %in% c("StrikeSwinging", "FoulBallNotFieldable", "InPlay",
"FoulBallFieldable", "FoulBall") ~ 1,
TRUE ~ 0
),
is_whiff = case_when(
PitchCall == "StrikeSwinging" & is_swing == 1 ~ 1,
PitchCall != "StrikeSwinging" & is_swing == 1 ~ 0,
TRUE ~ NA_real_
),
in_zone = case_when(
PlateLocSide >= -0.95 & PlateLocSide <= 0.95 &
PlateLocHeight >= 1.6 & PlateLocHeight <= 3.5 ~ 1,
TRUE ~ 0
),
in_zone_whiff = case_when(
PitchCall == "StrikeSwinging" & in_zone == 1 ~ 1,
is_swing == 1 & in_zone == 1 ~ 0,
TRUE ~ NA_real_
),
chase = case_when(
is_swing == 1 & in_zone == 0 ~ 1,
is_swing == 0 & in_zone == 0 ~ 0,
TRUE ~ NA_real_
),
is_hit = case_when(
PlayResult %in% c("Single", "Double", "Triple", "HomeRun") ~ 1,
PlayResult %in% c("FieldersChoice", "Error", "Out", "Sacrifice") ~ 0,
TRUE ~ NA_real_
),
is_hard_hit = case_when(
ExitSpeed >= 95 & PitchCall == "InPlay" ~ 1,
ExitSpeed < 95 & PitchCall == "InPlay" ~ 0,
TRUE ~ NA_real_
),
is_barrel = case_when(
!is.na(ExitSpeed) & !is.na(Angle) & PitchCall == "InPlay" &
(ExitSpeed * 1.5 - Angle) >= 117 &
(ExitSpeed + Angle) >= 124 &
Angle <= 50 & ExitSpeed >= 98 ~ 1,
is.na(ExitSpeed) | is.na(Angle) ~ NA_real_,
PitchCall == "InPlay" ~ 0
),
is_plate_appearance = ifelse(
PitchCall %in% c("InPlay", "HitByPitch") | KorBB %in% c("Strikeout", "Walk") |
PlayResult %in% c("StrikeoutSwinging", "StrikeoutLooking"), 1, 0
),
is_at_bat = case_when(
PitchCall == "InPlay" & !PlayResult %in% c("StolenBase", "Sacrifice", "CaughtStealing", "Undefined") ~ 1,
KorBB == "Strikeout" ~ 1,
PlayResult %in% c("StrikeoutSwinging", "StrikeoutLooking") ~ 1,
TRUE ~ 0
),
is_walk = case_when(
is_plate_appearance == 1 & KorBB == "Walk" ~ 1,
is_plate_appearance == 1 & KorBB != "Walk" ~ 0,
TRUE ~ NA_real_
),
is_k = case_when(
is_at_bat == 1 & KorBB == "Strikeout" ~ 1,
is_at_bat == 1 & KorBB != "Strikeout" ~ 0,
PlayResult %in% c("StrikeoutSwinging", "StrikeoutLooking") ~ 1,
TRUE ~ NA_real_
)
)
}
# CRITICAL FIX: Calculate Stuff+ for the processed data
tryCatch({
data <- calculate_stuff_plus(data)
}, error = function(e) {
warning("Error calculating Stuff+: ", e$message)
# Ensure StuffPlus column exists even if calculation fails
if(!"StuffPlus" %in% names(data)) {
data$StuffPlus <- NA
}
})
# Ensure StuffPlus column exists regardless
if(!"StuffPlus" %in% names(data)) {
data$StuffPlus <- NA
}
return(data)
}
# Create summary stats for percentiles - UPDATED to include Stuff+
create_summary_stats <- function(data) {
# Check if AutoHitType column exists, if not create a simplified GB% calculation
if(!"AutoHitType" %in% names(data)) {
gb_calc <- quote(100 * mean(Angle[PitchCall == "InPlay"] < 10, na.rm = TRUE))
} else {
gb_calc <- quote(100 * (sum((AutoHitType == "GroundBall" & PitchCall == "InPlay"), na.rm = TRUE) /
sum(PitchCall == "InPlay", na.rm = TRUE)))
}
summary_data <- data %>%
group_by(Pitcher) %>%
summarise(
`FB Velo` = mean(RelSpeed[TaggedPitchType == "Fastball"], na.rm = TRUE),
`FB IVB` = mean(InducedVertBreak[TaggedPitchType == "Fastball"], na.rm = TRUE),
`Avg EV` = mean(ExitSpeed, na.rm = TRUE),
`Hard-Hit%` = 100 * mean(is_hard_hit, na.rm = TRUE),
`GB%` = !!gb_calc,
`Zone%` = round(mean(in_zone, na.rm = TRUE) * 100, 1),
`Chase%` = 100 * mean(chase, na.rm = TRUE),
`Whiff%` = 100 * mean(is_whiff, na.rm = TRUE),
`Z-Whiff%` = 100 * mean(in_zone_whiff, na.rm = TRUE),
`K%` = 100 * mean(is_k, na.rm = TRUE),
`BB%` = 100 * mean(is_walk, na.rm = TRUE),
Extension = mean(Extension, na.rm = TRUE),
`Stuff+` = mean(StuffPlus, na.rm = TRUE), # ADD STUFF+ HERE
.groups = "drop"
)
# Filter out rows with all NA values, but be more lenient
summary_data %>%
filter(rowSums(is.na(.) | is.infinite(as.matrix(.))) < ncol(.) - 1) # Allow if at least one valid metric
}
# Location heatmap function
locations <- function(data, player) {
# Filter and check for required columns
if(!"PlateLocSide" %in% names(data) || !"PlateLocHeight" %in% names(data)) {
return(ggplot() +
annotate("text", x = 0, y = 0, label = "Missing PlateLocSide or\nPlateLocHeight columns", size = 4) +
theme_void())
}
filtered_data <- data %>%
filter(Pitcher == player, TaggedPitchType != "Other", !is.na(TaggedPitchType)) %>%
filter(!is.na(PlateLocSide), !is.na(PlateLocHeight), !is.na(BatterSide)) %>%
filter(is.finite(PlateLocSide), is.finite(PlateLocHeight))
if(nrow(filtered_data) == 0) {
return(ggplot() +
annotate("text", x = 0, y = 0, label = "No location data\navailable for this pitcher", size = 4) +
theme_void())
}
# Calculate pitch usage and filter
pitch_usage_data <- filtered_data %>%
group_by(TaggedPitchType) %>%
summarise(pitch_count = n(), .groups = "drop") %>%
mutate(usage_pct = 100 * pitch_count / nrow(filtered_data)) %>%
filter(usage_pct >= 2)
if(nrow(pitch_usage_data) == 0) {
return(ggplot() +
annotate("text", x = 0, y = 0, label = "No pitches used ≥2%\nof the time", size = 4) +
theme_void())
}
# Filter to qualifying pitch types
player_data <- filtered_data %>%
filter(TaggedPitchType %in% pitch_usage_data$TaggedPitchType) %>%
mutate(
BatterSide = case_when(
BatterSide %in% c("Right", "R") ~ "Vs Right",
BatterSide %in% c("Left", "L") ~ "Vs Left",
TRUE ~ paste("Vs", BatterSide)
),
TaggedPitchType = factor(TaggedPitchType)
)
if(nrow(player_data) == 0) {
return(ggplot() +
annotate("text", x = 0, y = 0, label = "No qualifying pitch data\navailable", size = 4) +
theme_void())
}
# Check if we have enough data points for density calculation
min_points_check <- player_data %>%
group_by(TaggedPitchType, BatterSide) %>%
summarise(n_points = n(), .groups = "drop") %>%
filter(n_points >= 3) # Need at least 3 points for density
if(nrow(min_points_check) == 0) {
return(ggplot() +
annotate("text", x = 0, y = 0, label = "Insufficient data points\nfor density calculation", size = 4) +
theme_void())
}
# Create the plot
p <- player_data %>%
ggplot(aes(PlateLocSide, PlateLocHeight)) +
# Only add density if we have sufficient data
{if(nrow(player_data) >= 10)
stat_density_2d(
aes(fill = after_stat(ndensity)),
geom = "raster", contour = FALSE, n = 20
)
} +
# Add individual points if density fails or not enough data
{if(nrow(player_data) < 10)
geom_point(alpha = 0.6, size = 2)
} +
facet_grid(BatterSide ~ TaggedPitchType, labeller = label_value) +
geom_rect(
xmin = -8.5/12, xmax = 8.5/12, # Convert to feet (assuming inches input)
ymin = 18/12, ymax = 42/12,
fill = NA, color = "black", linewidth = 0.4
) +
coord_fixed() +
scale_x_continuous("", limits = c(-2, 2)) +
scale_y_continuous("", limits = c(0, 5)) +
theme_void() +
{if(nrow(player_data) >= 10)
scale_fill_gradientn(
colors = c("#315aa1", "grey80", "#d72029"),
values = scales::rescale(c(0, 0.5, 1)),
limits = c(0, 1),
guide = "none"
)
} +
theme(
strip.text.x = element_text(size = 12, face = "bold"),
strip.text.y.right = element_text(size = 12, angle = 0, hjust = 0, face = "bold"),
strip.placement = "outside",
strip.background = element_blank(),
panel.background = element_rect(fill = "#ffffff", color = NA),
legend.position = "none",
plot.title = element_text(hjust = 0.5, size = 14, face = "bold")
) +
labs(title = paste(player, "- Pitch Locations"))
return(p)
}
# Count usage function (reverted to original pie chart style)
count_usage <- function(data, player) {
# Define pitch colors for use in the function
pitch_colors <- c(
"Fastball" = "dodgerblue2", "FourSeamFastBall" = "dodgerblue2", "FourSeamFastB" = "dodgerblue2",
"OneSeamFastBall" = "yellow", "TwoSeamFastBall" = "yellow", "Sinker" = "yellow",
"Cutter" = "orange", "Changeup" = "#01FF70", "ChangeUp" = "#01FF70",
"Slider" = "tomato", "Sweeper" = "gold3", "Curveball" = "#B10DC9",
"Splitter" = "#01FF70", "Knuckleball" = "lightblue",
"Other" = "gray60", "Undefined" = "gray40"
)
data <- data %>%
filter(Pitcher == player, TaggedPitchType != "Other") %>%
group_by(TaggedPitchType) %>%
mutate(n = 100 * n() / nrow(data %>% filter(Pitcher == player))) %>%
filter(n >= 2) %>%
ungroup()
if(nrow(data) == 0) {
return(ggplot() +
annotate("text", x = 0, y = 0, label = "No count data\navailable for this pitcher", size = 4) +
theme_void())
}
data %>%
mutate(
count = case_when(
Balls == 0 & Strikes == 0 ~ "0-0",
Balls == 3 & Strikes == 2 ~ "3-2",
Balls > Strikes ~ "Behind",
Strikes > Balls ~ "Ahead",
Balls == Strikes ~ "Tied"
),
BatterSide = ifelse(BatterSide == "Right", "Vs Right", "Vs Left")
) %>%
group_by(count, BatterSide) %>%
mutate(total_count = n()) %>%
group_by(count, TaggedPitchType, BatterSide) %>%
summarise(n = n(), total = first(total_count), .groups = "drop") %>%
mutate(percentage = n / total * 100) %>%
arrange(desc(percentage)) %>%
ggplot(aes(x = "", y = percentage, fill = TaggedPitchType)) +
geom_bar(width = 1, stat = "identity") +
coord_polar("y", start = 0) +
facet_grid(BatterSide ~ count, labeller = label_value) +
theme_minimal() +
labs(title = paste(player, "- Pitch Usage by Count"), fill = "Pitch Type") +
scale_fill_manual(values = pitch_colors) +
theme(plot.title = element_text(hjust = 0.5, size = 12),
axis.text = element_blank(),
axis.title = element_blank(),
axis.ticks = element_blank(),
strip.text = element_text(size = 12),
legend.position = "bottom"
)
}
# Seam orientation functions
filtered_seam_data <- reactive({
req(input$seam_pitcher)
base_data <- ccbl_data %>%
filter(!is.na(SpinAxis3dSeamOrientationBallAngleHorizontalAmb1),
!is.na(SpinAxis3dSeamOrientationBallAngleVerticalAmb1),
Pitcher == input$seam_pitcher)
# Apply pitch type filter if not "all"
if(input$filter_pitch_type != "all") {
base_data <- base_data %>%
filter(TaggedPitchType == input$filter_pitch_type)
}
base_data
})
seam_curve_data <- reactive({
if (!input$show_seam_curve) return(NULL)
# Defined seam control points
seam_points <- data.frame(
lon = c(-157, -90, 0, 90, 157, 90, 0, -90, -157),
lat = c(0, 45, 22.5, 45, 0, -45, -22.5, -45, 0)
)
# Create parametric interpolation
t <- seq_along(seam_points$lon)
interp_n <- 300
t_interp <- seq(min(t), max(t), length.out = interp_n)
lon_spline <- spline(t, seam_points$lon, xout = t_interp)$y
lat_spline <- spline(t, seam_points$lat, xout = t_interp)$y
data.frame(lon = lon_spline, lat = lat_spline)
})
# Release point visualization function
create_release_plot <- function(game_data, pitcher_name, league_type) {
# Filter the game data for the selected pitcher
pitcher_data <- game_data %>%
filter(Pitcher == pitcher_name) %>%
filter(!is.na(RelSide), !is.na(RelHeight), !is.na(TaggedPitchType)) %>%
# Group fastball types together
mutate(
pitch_group = case_when(
TaggedPitchType %in% c("Fastball", "FourSeamFastBall", "FourSeamFastB", "Four-Seam", "4-Seam") ~ "Fastball",
TaggedPitchType %in% c("OneSeamFastBall", "TwoSeamFastBall", "Sinker", "Two-Seam", "One-Seam") ~ "Sinker",
TaggedPitchType %in% c("ChangeUp", "Changeup") ~ "Changeup",
TRUE ~ TaggedPitchType
)
)
if(nrow(pitcher_data) == 0) {
return(ggplot() +
annotate("text", x = 0, y = 4, label = "No release point data\navailable for this pitcher", size = 4) +
theme_void())
}
# Get unique pitch groups present in data
pitch_groups <- unique(pitcher_data$pitch_group)
# Filter colors to only include pitch types present in data
pitch_colors_used <- TMcolors[names(TMcolors) %in% pitch_groups]
if(length(pitch_colors_used) == 0) {
pitch_colors_used <- rainbow(length(pitch_groups))
names(pitch_colors_used) <- pitch_groups
}
# Create the release point plot
ggplot(pitcher_data, aes(x = RelSide, y = RelHeight, fill = pitch_group)) +
# Add mound and rubber
annotate("rect", xmin = -5, xmax = 5, ymin = 0, ymax = 0.83, fill = "#632b11") +
annotate("rect", xmin = -0.5, xmax = 0.5, ymin = 0.8, ymax = 0.95,
fill = "white", color = "black", linewidth = 0.5) +
geom_point(size = 3, alpha = 1.5, shape = 21, color = "black", stroke = 0.5) +
xlim(-5, 5) + ylim(0, 8) +
annotate("text", x = -4, y = 7.5, label = "← 1B", face = "bold", color = "black", size = 6, hjust = 0.5) +
annotate("text", x = 4, y = 7.5, label = "3B →", face = "bold", color = "black", size = 6, hjust = 0.5) +
labs(x = "Rel Side", y = "Rel Height", title = paste(pitcher_name, "Release Heights")) +
scale_fill_manual(values = pitch_colors_used) +
theme_minimal() +
theme(
plot.title = element_text(hjust = 0.5, face = "bold", color = "black", size = 15),
axis.title = element_text(size = 10),
axis.text = element_text(size = 10),
legend.position = "bottom",
legend.text = element_text(color = "black", size = 12),
legend.title = element_blank(),
panel.border = element_rect(color = "black", fill = NA, size = 0.5),
legend.key.size = unit(0.2, "cm"),
legend.margin = margin(0, 0, 0, 0)
)
}
# Pitch break visualization function
create_pitch_break_plot <- function(game_data, pitcher_name, league_type) {
# Filter the game data for the selected pitcher
pitcher_data <- game_data %>%
filter(Pitcher == pitcher_name) %>%
filter(!is.na(HorzBreak), !is.na(InducedVertBreak), !is.na(TaggedPitchType)) %>%
# Group fastball types together
mutate(
pitch_group = case_when(
TaggedPitchType %in% c("Fastball", "FourSeamFastBall", "FourSeamFastB", "Four-Seam", "4-Seam") ~ "Fastball",
TaggedPitchType %in% c("OneSeamFastBall", "TwoSeamFastBall", "Sinker", "Two-Seam", "One-Seam") ~ "Sinker",
TaggedPitchType %in% c("ChangeUp", "Changeup") ~ "Changeup",
TRUE ~ TaggedPitchType
)
)
p_throws <- "R" # Default to right-handed
# Apply handedness adjustment to horizontal break
if (p_throws == "R") {
pitcher_data$adj_HorzBreak <- pitcher_data$HorzBreak * -1
} else {
pitcher_data$adj_HorzBreak <- pitcher_data$HorzBreak
}
# Pitch colors
pitch_colors <- c(
"Fastball" = "dodgerblue2", "FourSeamFastBall" = "dodgerblue2", "FourSeamFastB" = "dodgerblue2",
"OneSeamFastBall" = "yellow", "TwoSeamFastBall" = "yellow", "Sinker" = "yellow",
"Cutter" = "orange", "Changeup" = "#01FF70", "ChangeUp" = "#01FF70",
"Slider" = "tomato", "Sweeper" = "gold3", "Curveball" = "#B10DC9",
"Splitter" = "#01FF70", "Knuckleball" = "lightblue",
"Other" = "gray60", "Undefined" = "gray40"
)
# Get unique pitch groups present in data
pitch_groups <- unique(pitcher_data$pitch_group)
# Filter colors to only include pitch types present in data
pitch_colors_filtered <- pitch_colors[names(pitch_colors) %in% pitch_groups]
if(length(pitch_colors_filtered) == 0) {
pitch_colors_filtered <- rainbow(length(pitch_groups))
names(pitch_colors_filtered) <- pitch_groups
}
if(nrow(pitcher_data) == 0) {
return(ggplot() +
annotate("text", x = 0, y = 0, label = "No pitch break data available for this pitcher", size = 6) +
theme_void())
}
# Create the base plot
p <- ggplot(pitcher_data, aes(x = adj_HorzBreak, y = InducedVertBreak)) +
geom_hline(yintercept = 0, color = "#808080", alpha = 0.5, linetype = "dashed", size = 0.8) +
geom_vline(xintercept = 0, color = "#808080", alpha = 0.5, linetype = "dashed", size = 0.8) +
geom_point(aes(fill = pitch_group), shape = 21, size = 4, color = "black", stroke = 0.5, alpha = 1) +
scale_fill_manual(values = pitch_colors_filtered) +
scale_x_continuous(limits = c(-25, 25), breaks = seq(-20, 20, 10)) +
scale_y_continuous(limits = c(-25, 25), breaks = seq(-20, 20, 10)) +
coord_equal() +
labs(x = "Horizontal Break (in)", y = "Induced Vertical Break (in)", title = "Pitch Breaks") +
theme_minimal() +
theme(
legend.position = "none",
plot.title = element_text(hjust = 0.5, face = "bold", color = "black", size = 16),
aspect.ratio = 1,
plot.background = element_rect(fill = "white", color = NA),
panel.background = element_rect(fill = "white", color = NA),
axis.text = element_text(color = "black", size = 10),
axis.title = element_text(color = "black", size = 12),
panel.border = element_rect(color = "black", fill = NA, size = 1)
)
# Add handedness-specific annotations
if (p_throws == "R") {
p <- p +
annotate("text", x = -24.2, y = -24.2, label = "← Glove Side", hjust = 0, vjust = 0, fontface = "italic", size = 3) +
annotate("text", x = 24.2, y = -24.2, label = "Arm Side →", hjust = 1, vjust = 0, fontface = "italic", size = 3)
}
return(p)
}
# Original pitch movement visualization function
create_pitch_movement_plot <- function(game_data, pitcher_name, league_type) {
# Filter the game data for the selected pitcher
pitcher_data <- game_data %>%
filter(Pitcher == pitcher_name) %>%
filter(!is.na(HorzBreak), !is.na(InducedVertBreak), !is.na(TaggedPitchType)) %>%
# Group fastball types together
mutate(
pitch_group = case_when(
TaggedPitchType %in% c("Fastball", "FourSeamFastBall", "FourSeamFastB", "Four-Seam", "4-Seam") ~ "Fastball",
TaggedPitchType %in% c("OneSeamFastBall", "TwoSeamFastBall", "Sinker", "Two-Seam", "One-Seam") ~ "Sinker",
TaggedPitchType %in% c("ChangeUp", "Changeup") ~ "Changeup",
TRUE ~ TaggedPitchType
)
)
# Calculate average locations, usage, and velocity for each pitch group
avg_locations <- pitcher_data %>%
group_by(pitch_group) %>%
summarise(
avg_HB = mean(HorzBreak, na.rm = TRUE),
avg_IVB = mean(InducedVertBreak, na.rm = TRUE),
avg_velo = round(mean(RelSpeed, na.rm = TRUE), 1),
count = n(),
.groups = 'drop'
) %>%
mutate(
usage = paste0(round(count / sum(count) * 100, 1), "%")
)
# Determine league name for title
league_name <- case_when(
league_type == "ccbl" ~ "Cape Cod League",
league_type == "ncaa2025" ~ "NCAA 2025",
league_type == "ncaa2024" ~ "NCAA 2024",
TRUE ~ "Unknown League"
)
# Pitch colors for movement chart
pitch_colors <- c(
"Fastball" = "dodgerblue2", "FourSeamFastBall" = "dodgerblue2", "FourSeamFastB" = "dodgerblue2",
"OneSeamFastBall" = "yellow", "TwoSeamFastBall" = "yellow", "Sinker" = "yellow",
"Cutter" = "orange", "Changeup" = "#01FF70", "ChangeUp" = "#01FF70",
"Slider" = "tomato", "Sweeper" = "gold3", "Curveball" = "#B10DC9",
"Splitter" = "#01FF70", "Knuckleball" = "lightblue",
"Other" = "gray60", "Undefined" = "gray40"
)
# Get unique pitch groups present in data
pitch_groups <- unique(pitcher_data$pitch_group)
# Filter colors to only include pitch types present in data
pitch_colors_filtered <- pitch_colors[names(pitch_colors) %in% pitch_groups]
if(length(pitch_colors_filtered) == 0) {
pitch_colors_filtered <- rainbow(length(pitch_groups))
names(pitch_colors_filtered) <- pitch_groups
}
if(nrow(pitcher_data) == 0 || nrow(avg_locations) == 0) {
return(ggplot() +
annotate("text", x = 0, y = 0, label = "No pitch movement data available for this pitcher", size = 6) +
theme_void())
}
ggplot(pitcher_data, aes(x = HorzBreak, y = InducedVertBreak)) +
geom_vline(xintercept = 0, color = "black", size = 0.8, linetype = 1) +
geom_hline(yintercept = 0, color = "black", size = 0.8, linetype = 1) +
geom_point(aes(fill = pitch_group), shape = 21, size = 4, color = "black", stroke = 0.4, alpha = 1) +
geom_point(data = avg_locations, aes(x = avg_HB, y = avg_IVB, fill = pitch_group),
color = "black", size = 8, stroke = 2, shape = 21, alpha = 0.95) +
geom_label(data = avg_locations, aes(x = avg_HB, y = avg_IVB, label = paste0(avg_velo, " mph")),
color = "black", fill = "white", size = 5, fontface = "bold", alpha = 0.9) +
scale_fill_manual(values = pitch_colors_filtered) +
scale_color_manual(values = pitch_colors_filtered) +
labs(x = "HB", y = "IVB", title = paste(pitcher_name, "- Pitch Movement -", league_name)) +
xlim(-25, 25) + ylim(-25, 25) +
theme_void() +
theme(
legend.position = "right",
plot.title = element_text(hjust = 0.5, face = "bold", color = "black", size = 20),
aspect.ratio = 1,
plot.background = element_rect(fill = "white", color = NA),
panel.background = element_rect(fill = "white", color = NA),
axis.text = element_text(color = "white", size = 10),
axis.title = element_text(color = "white", size = 12),
legend.background = element_rect(fill = "white"),
legend.text = element_text(color = "black", size = 10),
legend.title = element_blank(),
panel.border = element_rect(color = "black", fill = NA, size = 2)
)
}
# Reactive data for all tabs - UPDATED to use preprocessing with Stuff+
current_data <- reactive({
if (input$league == "ccbl") {
preprocess_data(ccbl_data, "ccbl")
} else if (input$league == "ncaa2025") {
preprocess_data(TM25, "ncaa2025")
} else if (input$league == "ncaa2024") {
preprocess_data(NCAA, "ncaa2024")
}
})
current_movement_data <- reactive({
if (input$movement_league == "ccbl") {
preprocess_data(ccbl_data, "ccbl")
} else if (input$movement_league == "ncaa2025") {
preprocess_data(TM25, "ncaa2025")
} else if (input$movement_league == "ncaa2024") {
preprocess_data(NCAA, "ncaa2024")
}
})
current_location_data <- reactive({
switch(input$location_league,
"ccbl" = preprocess_data(ccbl_data, "ccbl"),
"ncaa2025" = preprocess_data(TM25, "ncaa2025"),
"ncaa2024" = preprocess_data(NCAA, "ncaa2024"))
})
summary_stats <- reactive({
create_summary_stats(current_data())
})
# Update pitcher choices based on league selection for main tab
observe({
data <- current_data()
pitcher_choices <- sort(unique(data$Pitcher))
pitcher_choices <- pitcher_choices[!is.na(pitcher_choices)]
updateSelectInput(session, "pitcher",
choices = pitcher_choices,
selected = pitcher_choices[1]
)
})
# Update pitcher choices based on league selection for movement tab
observe({
data <- current_movement_data()
pitcher_choices <- sort(unique(data$Pitcher))
pitcher_choices <- pitcher_choices[!is.na(pitcher_choices)]
updateSelectInput(session, "movement_pitcher",
choices = pitcher_choices,
selected = pitcher_choices[1]
)
})
# Update pitcher choices for location tab
observe({
data <- current_location_data()
pitchers <- sort(unique(data$Pitcher[!is.na(data$Pitcher)]))
updateSelectInput(session, "location_pitcher", choices = pitchers, selected = pitchers[1])
})
# Update pitcher choices for seam orientation tab (CCBL only)
observe({
seam_pitcher_choices <- ccbl_data %>%
filter(!is.na(SpinAxis3dSeamOrientationBallAngleHorizontalAmb1),
!is.na(SpinAxis3dSeamOrientationBallAngleVerticalAmb1),
!is.na(Pitcher)) %>%
pull(Pitcher) %>%
unique() %>%
sort()
updateSelectInput(session, "seam_pitcher",
choices = seam_pitcher_choices,
selected = seam_pitcher_choices[1]
)
})
# Update pitch type filter based on selected pitcher
observe({
req(input$seam_pitcher)
pitcher_pitch_types <- ccbl_data %>%
filter(!is.na(SpinAxis3dSeamOrientationBallAngleHorizontalAmb1),
!is.na(SpinAxis3dSeamOrientationBallAngleVerticalAmb1),
Pitcher == input$seam_pitcher,
!is.na(TaggedPitchType)) %>%
pull(TaggedPitchType) %>%
unique() %>%
sort()
pitch_choices <- c("All Pitches" = "all")
if(length(pitcher_pitch_types) > 0) {
pitch_choices <- c(pitch_choices, setNames(pitcher_pitch_types, pitcher_pitch_types))
}
updateSelectInput(session, "filter_pitch_type",
choices = pitch_choices,
selected = "all"
)
})
# Pitcher percentiles function - UPDATED to include Stuff+
pitcher_percentiles <- function(pitcher_name, data, summary_df, league_type) {
# Check if AutoHitType column exists for GB% calculation
if(!"AutoHitType" %in% names(data)) {
gb_calc <- quote(100 * mean(Angle[PitchCall == "InPlay"] < 10, na.rm = TRUE))
} else {
gb_calc <- quote(100 * (sum((AutoHitType == "GroundBall" & PitchCall == "InPlay"), na.rm = TRUE) /
sum(PitchCall == "InPlay", na.rm = TRUE)))
}
# Get individual pitcher stats
pitcher_split <- data %>%
filter(Pitcher == pitcher_name) %>%
summarise(
`FB Velo` = mean(RelSpeed[TaggedPitchType == "Fastball"], na.rm = TRUE),
`FB IVB` = mean(InducedVertBreak[TaggedPitchType == "Fastball"], na.rm = TRUE),
`Avg EV` = mean(ExitSpeed, na.rm = TRUE),
`Hard-Hit%` = 100 * mean(is_hard_hit, na.rm = TRUE),
`GB%` = !!gb_calc,
`Zone%` = round(mean(in_zone, na.rm = TRUE) * 100, 1),
`Chase%` = 100 * mean(chase, na.rm = TRUE),
`Whiff%` = 100 * mean(is_whiff, na.rm = TRUE),
`Z-Whiff%` = 100 * mean(in_zone_whiff, na.rm = TRUE),
`K%` = 100 * mean(is_k, na.rm = TRUE),
`BB%` = 100 * mean(is_walk, na.rm = TRUE),
Extension = mean(Extension, na.rm = TRUE),
`Stuff+` = mean(StuffPlus, na.rm = TRUE), # ADD STUFF+ HERE TOO
.groups = "drop"
)
if(nrow(pitcher_split) == 0) {
return(ggplot() + annotate("text", x = 0.5, y = 0.5, label = "No data available for this pitcher") + theme_void())
}
# Convert to long format for easier processing
pitcher_long <- pitcher_split %>%
pivot_longer(cols = everything(), names_to = "Metric", values_to = "Value") %>%
filter(!is.na(Value) & !is.infinite(Value))
if(nrow(pitcher_long) == 0) {
return(ggplot() + annotate("text", x = 0.5, y = 0.5, label = "No valid metrics available for this pitcher") + theme_void())
}
# Calculate percentiles for each metric
pitcher_long$Percentile <- sapply(1:nrow(pitcher_long), function(i) {
metric_name <- pitcher_long$Metric[i]
pitcher_value <- pitcher_long$Value[i]
if(metric_name %in% names(summary_df)) {
league_values <- summary_df[[metric_name]]
league_values <- league_values[!is.na(league_values) & !is.infinite(league_values)]
if(length(league_values) > 1) {
# Higher is better for these metrics
if (metric_name %in% c("FB Velo", "FB IVB", "K%", "Whiff%", "Z-Whiff%", "Extension", "GB%", "Zone%", "Chase%", "Stuff+")) {
percentile <- round(mean(league_values <= pitcher_value, na.rm = TRUE) * 100)
} else {
# Lower is better for these metrics
percentile <- round(mean(league_values >= pitcher_value, na.rm = TRUE) * 100)
}
return(max(1, min(99, percentile)))
}
}
return(50)
})
# Filter valid metrics
metrics <- pitcher_long %>%
filter(!is.na(Percentile) & !is.na(Value))
if(nrow(metrics) == 0) {
return(ggplot() + annotate("text", x = 0.5, y = 0.5, label = "No valid metrics available") + theme_void())
}
# Updated custom order to include Stuff+
custom_order <- c("FB Velo", "FB IVB", "Avg EV", "Hard-Hit%", "GB%", "Zone%", "Chase%", "Whiff%", "Z-Whiff%", "K%", "BB%", "Extension", "Stuff+")
metrics$Metric <- factor(metrics$Metric, levels = custom_order)
metrics <- metrics %>% arrange(Metric) %>% filter(!is.na(Metric))
if(nrow(metrics) == 0) {
return(ggplot() + annotate("text", x = 0.5, y = 0.5, label = "No valid metrics available") + theme_void())
}
metrics$color <- gradient_n_pal(c("#3661ad","#90A4AE", "#d82029"))(metrics$Percentile/100)
# Determine league name for subtitle
league_name <- case_when(
league_type == "ccbl" ~ "Cape Cod League",
league_type == "ncaa2025" ~ "NCAA 2025",
league_type == "ncaa2024" ~ "NCAA 2024",
TRUE ~ "Unknown League"
)
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 = nrow(metrics) + 0.5, color = c("white"), size = 1.5, alpha = 0.5) +
geom_text(aes(x = -3, label = Metric), hjust = 1, size = 4) +
geom_text(aes(x = 103, label = sprintf("%.1f", Value)), hjust = 0, size = 4) +
geom_point(aes(x = Percentile, color = "white", fill = color), size = 10, shape = 21, stroke = 2) +
geom_text(aes(x = Percentile, label = Percentile), size = 4, color = "white", fontface = "bold") +
scale_x_continuous(limits = c(-16, 113), expand = c(0, 0)) +
scale_fill_identity() + scale_color_identity() +
labs(title = paste("Pitcher Percentiles:", pitcher_name), subtitle = paste("League:", league_name),
caption = "Made by Isaac Groffman | Inspired by Baseball Savant") +
theme_minimal() +
theme(
axis.text = element_blank(), axis.title = element_blank(), panel.grid = element_blank(),
plot.title = element_text(hjust = 0.5, size = 16, face = "bold"),
plot.subtitle = element_text(hjust = 0.5, size = 12),
plot.caption = element_text(hjust = 1, size = 10, color = "gray60", margin = margin(t = 10))
)
}
# OUTPUT RENDERERS
# Generate percentile plot
output$percentile_plot <- renderPlot({
req(input$pitcher)
data <- current_data()
summary_df <- summary_stats()
pitcher_percentiles(input$pitcher, data, summary_df, input$league)
})
# Generate location plot
output$location_plot <- renderPlot({
req(input$location_pitcher)
data <- current_location_data()
locations(data, input$location_pitcher)
})
# Generate count usage plot
output$count_usage_plot <- renderPlot({
req(input$location_pitcher)
data <- current_location_data()
count_usage(data, input$location_pitcher)
})
# Generate movement plot
output$movement_plot <- renderPlot({
req(input$movement_pitcher)
data <- current_movement_data()
if (input$plot_type == "movement") {
create_pitch_movement_plot(data, input$movement_pitcher, input$movement_league)
} else if (input$plot_type == "breaks") {
create_pitch_break_plot(data, input$movement_pitcher, input$movement_league)
}
})
# Generate release point plot
output$release_plot <- renderPlot({
req(input$movement_pitcher)
data <- current_movement_data()
create_release_plot(data, input$movement_pitcher, input$movement_league)
})
# Generate seam orientation plot
output$seam_plot <- renderPlot({
req(input$seam_pitcher)
data <- filtered_seam_data()
if (nrow(data) == 0) {
return(ggplot() +
annotate("text", x = 0, y = 0, label = "No seam orientation data available for this pitcher", size = 6) +
theme_void())
}
# Get world map data for Mollweide background
world_map <- map_data("world")
p <- ggplot(data, aes(x = SpinAxis3dSeamOrientationBallAngleHorizontalAmb1,
y = SpinAxis3dSeamOrientationBallAngleVerticalAmb1,
color = TaggedPitchType)) +
# Add world map as background
geom_polygon(data = world_map, aes(x = long, y = lat, group = group),
fill = "lightgray", color = "white", alpha = 0.3, size = 0.1, inherit.aes = FALSE) +
scale_color_manual(values = TMcolors, na.value = "gray60") +
geom_point(size = input$point_size, alpha = input$point_alpha, stroke = 0.5, color = "black", shape = 21, aes(fill = TaggedPitchType)) +
geom_point(aes(x = SpinAxis3dSeamOrientationBallAngleHorizontalAmb2,
y = SpinAxis3dSeamOrientationBallAngleVerticalAmb2, fill = TaggedPitchType),
size = input$point_size, alpha = input$point_alpha, stroke = 0.5, color = "black", shape = 21) +
geom_point(aes(x = SpinAxis3dSeamOrientationBallAngleHorizontalAmb3,
y = SpinAxis3dSeamOrientationBallAngleVerticalAmb3, fill = TaggedPitchType),
size = input$point_size, alpha = input$point_alpha, stroke = 0.5, color = "black", shape = 21) +
geom_point(aes(x = SpinAxis3dSeamOrientationBallAngleHorizontalAmb4,
y = SpinAxis3dSeamOrientationBallAngleVerticalAmb4, fill = TaggedPitchType),
size = input$point_size, alpha = input$point_alpha, stroke = 0.5, color = "black", shape = 21) +
scale_fill_manual(values = TMcolors, na.value = "gray60") +
coord_map("mollweide") +
scale_x_continuous(limits = c(-180, 180), breaks = seq(-180, 180, 45)) +
scale_y_continuous(limits = c(-90, 90), breaks = seq(-90, 90, 30)) +
# Add seam orientation guide annotations with boxes
annotate("label", x = 0, y = 67.5, label = "2S", color = "blue", size = 6, fontface = "bold",
fill = "white", label.padding = unit(0.3, "lines"), label.r = unit(0.15, "lines")) +
annotate("label", x = 95, y = 52, label = "1S", color = "red", size = 6, fontface = "bold",
fill = "white", label.padding = unit(0.3, "lines"), label.r = unit(0.15, "lines")) +
annotate("label", x = 90, y = 0, label = "2S", color = "blue", size = 6, fontface = "bold",
fill = "white", label.padding = unit(0.3, "lines"), label.r = unit(0.15, "lines")) +
annotate("label", x = 95, y = -52, label = "1S", color = "red", size = 6, fontface = "bold",
fill = "white", label.padding = unit(0.3, "lines"), label.r = unit(0.15, "lines")) +
annotate("label", x = 0, y = -67.5, label = "2S", color = "blue", size = 6, fontface = "bold",
fill = "white", label.padding = unit(0.3, "lines"), label.r = unit(0.15, "lines")) +
annotate("label", x = -95, y = -52, label = "1S", color = "red", size = 6, fontface = "bold",
fill = "white", label.padding = unit(0.3, "lines"), label.r = unit(0.15, "lines")) +
annotate("label", x = -90, y = 0, label = "2S", color = "blue", size = 6, fontface = "bold",
fill = "white", label.padding = unit(0.3, "lines"), label.r = unit(0.15, "lines")) +
annotate("label", x = -95, y = 52, label = "1S", color = "red", size = 6, fontface = "bold",
fill = "white", label.padding = unit(0.3, "lines"), label.r = unit(0.15, "lines")) +
annotate("label", x = 180, y = 0, label = "4S", color = "darkgreen", size = 6, fontface = "bold",
fill = "white", label.padding = unit(0.3, "lines"), label.r = unit(0.15, "lines")) +
annotate("label", x = -180, y = 0, label = "4S", color = "darkgreen", size = 6, fontface = "bold",
fill = "white", label.padding = unit(0.3, "lines"), label.r = unit(0.15, "lines")) +
annotate("label", x = 0, y = 0, label = "4S", color = "darkgreen", size = 6, fontface = "bold",
fill = "white", label.padding = unit(0.3, "lines"), label.r = unit(0.15, "lines")) +
labs(
title = paste0("Seam Orientation: ", input$seam_pitcher,
if(input$filter_pitch_type != "all") paste0(" (", input$filter_pitch_type, ")") else ""),
subtitle = "Using TrackMan 3D Spin (4S = 4-Seam, 2S = 2-Seam, 1S = 1-Seam)",
x = "Horizontal Angle (°)",
y = "Vertical Angle (°)",
color = "Pitch Type"
) +
theme_minimal() +
theme(
plot.title = element_text(size = 16, face = "bold"),
plot.subtitle = element_text(size = 12),
axis.title = element_text(size = 12),
legend.title = element_text(size = 11),
legend.text = element_text(size = 10)
)
# Add seam curve if selected
if (input$show_seam_curve) {
curve_data <- seam_curve_data()
if (!is.null(curve_data)) {
p <- p + geom_path(data = curve_data, aes(x = lon, y = lat),
color = "red", linewidth = 1.5, inherit.aes = FALSE)
}
}
p
})
# Generate seam orientation metrics table
output$seam_metrics_table <- DT::renderDataTable({
req(input$seam_pitcher)
data <- filtered_seam_data()
if (nrow(data) == 0) {
empty_df <- data.frame(
`Pitch Type` = character(0),
Count = numeric(0),
`Gyro Angle` = character(0),
`Spin Efficiency` = character(0),
`Active Spin` = character(0),
`Total Spin` = character(0),
`Avg Tilt` = character(0),
`Spin Axis X` = character(0),
`Spin Axis Y` = character(0),
`Spin Axis Z` = character(0),
`Transverse Angle` = character(0),
check.names = FALSE
)
return(DT::datatable(empty_df,
options = list(pageLength = 15, dom = 't'),
rownames = FALSE))
}
seam_metrics <- data %>%
filter(!is.na(TaggedPitchType)) %>%
group_by(`Pitch Type` = TaggedPitchType) %>%
summarise(
Count = n(),
`Gyro Angle` = sprintf("%.1f°", mean(SpinAxis3dLongitudinalAngle, na.rm = TRUE)),
`Spin Efficiency` = sprintf("%.1f%%", mean(SpinAxis3dSpinEfficiency * 100, na.rm = TRUE)),
`Active Spin` = sprintf("%.0f rpm", mean(SpinAxis3dActiveSpinRate, na.rm = TRUE)),
`Total Spin` = sprintf("%.0f rpm", mean(SpinRate, na.rm = TRUE)),
`Avg Tilt` = if_else(
all(is.na(SpinAxis3dTilt)),
"N/A",
paste0(first(na.omit(SpinAxis3dTilt)))
),
`Spin Axis X` = sprintf("%.3f", mean(SpinAxis3dVectorX, na.rm = TRUE)),
`Spin Axis Y` = sprintf("%.3f", mean(SpinAxis3dVectorY, na.rm = TRUE)),
`Spin Axis Z` = sprintf("%.3f", mean(SpinAxis3dVectorZ, na.rm = TRUE)),
`Transverse Angle` = sprintf("%.1f°", mean(SpinAxis3dTransverseAngle, na.rm = TRUE)),
.groups = "drop"
) %>%
arrange(desc(Count))
DT::datatable(seam_metrics,
options = list(
pageLength = 15,
dom = 't',
scrollX = TRUE,
columnDefs = list(
list(className = 'dt-center', targets = 1:10)
)
),
rownames = FALSE) %>%
DT::formatStyle(columns = names(seam_metrics), fontSize = '11px') %>%
DT::formatStyle(
columns = "Count",
backgroundColor = "#f8f9fa"
) %>%
DT::formatStyle(
columns = c("Gyro Angle", "Spin Efficiency", "Avg Tilt"),
backgroundColor = "#e8f4f8"
) %>%
DT::formatStyle(
columns = c("Active Spin", "Total Spin"),
backgroundColor = "#e6f3ff"
) %>%
DT::formatStyle(
columns = c("Spin Axis X", "Spin Axis Y", "Spin Axis Z"),
backgroundColor = "#fff3cd"
)
}, server = FALSE)
# Generate pitcher statistics table - UPDATED to include Stuff+
output$pitcher_stats <- DT::renderDataTable({
req(input$pitcher)
data <- current_data()
# Check if AutoHitType column exists for GB% calculation in table
if(!"AutoHitType" %in% names(data)) {
gb_calc_table <- quote(sprintf("%.1f", 100 * mean(Angle[PitchCall == "InPlay"] < 10, na.rm = TRUE)))
} else {
gb_calc_table <- quote(sprintf("%.1f", 100 * (sum((AutoHitType == "GroundBall" & PitchCall == "InPlay"), na.rm = TRUE) /
sum(PitchCall == "InPlay", na.rm = TRUE))))
}
pitcher_stats <- data %>%
filter(Pitcher == input$pitcher) %>%
summarise(
`FB Velo` = sprintf("%.1f", mean(RelSpeed[TaggedPitchType == "Fastball"], na.rm = TRUE)),
`Avg EV` = sprintf("%.1f", mean(ExitSpeed, na.rm = TRUE)),
`Barrel%` = sprintf("%.1f", 100 * mean(is_barrel, na.rm = TRUE)),
`Hard-Hit%` = sprintf("%.1f", 100 * mean(is_hard_hit, na.rm = TRUE)),
`GB%` = !!gb_calc_table,
`Zone%` = sprintf("%.1f", round(mean(in_zone, na.rm = TRUE) * 100, 1)),
`Chase%` = sprintf("%.1f", 100 * mean(chase, na.rm = TRUE)),
`Whiff%` = sprintf("%.1f", 100 * mean(is_whiff, na.rm = TRUE)),
`Z-Whiff%` = sprintf("%.1f", 100 * mean(in_zone_whiff, na.rm = TRUE)),
`K%` = sprintf("%.1f", 100 * mean(is_k, na.rm = TRUE)),
`BB%` = sprintf("%.1f", 100 * mean(is_walk, na.rm = TRUE)),
Extension = sprintf("%.1f", mean(Extension, na.rm = TRUE)),
`Stuff+` = sprintf("%.1f", mean(StuffPlus, na.rm = TRUE)), # ADD STUFF+ TO TABLE
.groups = "drop"
) %>%
pivot_longer(cols = everything(), names_to = "Metric", values_to = "Value")
DT::datatable(pitcher_stats,
options = list(pageLength = 15, dom = 't'),
rownames = FALSE) %>%
DT::formatStyle(columns = c("Metric", "Value"), fontSize = '14px')
})
# Generate movement statistics table - UPDATED to include Stuff+
output$movement_stats <- DT::renderDataTable({
req(input$movement_pitcher)
data <- current_movement_data()
movement_stats <- data %>%
filter(Pitcher == input$movement_pitcher) %>%
filter(!is.na(HorzBreak), !is.na(InducedVertBreak), !is.na(TaggedPitchType)) %>%
mutate(
pitch_group = case_when(
TaggedPitchType %in% c("Fastball", "FourSeamFastBall", "FourSeamFastB", "Four-Seam", "4-Seam") ~ "Fastball",
TaggedPitchType %in% c("OneSeamFastBall", "TwoSeamFastBall", "Sinker", "Two-Seam", "One-Seam") ~ "Sinker",
TaggedPitchType %in% c("ChangeUp", "Changeup") ~ "Changeup",
TRUE ~ TaggedPitchType
)
) %>%
group_by(`Pitch Type` = pitch_group) %>%
summarise(
Count = n(),
`Usage%` = sprintf("%.1f%%", (n() / nrow(filter(data, Pitcher == input$movement_pitcher))) * 100),
`Ext.` = sprintf("%.1f", mean(Extension, na.rm = TRUE)),
`Avg Velo` = sprintf("%.1f mph", mean(RelSpeed, na.rm = TRUE)),
`90th Velo` = sprintf("%.1f mph", quantile(RelSpeed, 0.9, na.rm = TRUE)),
`Max Velo` = sprintf("%.1f mph", max(RelSpeed, na.rm = TRUE)),
`Avg IVB` = sprintf("%.1f in", mean(InducedVertBreak, na.rm = TRUE)),
`Avg HB` = sprintf("%.1f in", mean(HorzBreak, na.rm = TRUE)),
`Avg Spin` = sprintf("%.0f rpm", mean(SpinRate, na.rm = TRUE)),
`Rel Height` = sprintf("%.1f", mean(RelHeight, na.rm = TRUE)),
`Zone%` = sprintf("%.1f%%", round(mean(in_zone, na.rm = TRUE) * 100, 1)),
`Whiff%` = sprintf("%.1f%%", round(mean(is_whiff, na.rm = TRUE) * 100, 1)),
`Chase%` = sprintf("%.1f%%", round(mean(chase, na.rm = TRUE) * 100, 1)),
`Stuff+` = sprintf("%.1f", mean(StuffPlus, na.rm = TRUE)), # ADD STUFF+ TO MOVEMENT TABLE
.groups = "drop"
) %>%
arrange(desc(Count))
DT::datatable(movement_stats,
options = list(pageLength = 15, dom = 't'),
rownames = FALSE) %>%
DT::formatStyle(columns = names(movement_stats), fontSize = '12px')
})
# Generate location statistics table - UPDATED to include Stuff+
output$location_stats <- DT::renderDataTable({
req(input$location_pitcher)
data <- current_location_data()
location_stats <- data %>%
filter(Pitcher == input$location_pitcher) %>%
filter(!is.na(PlateLocSide), !is.na(PlateLocHeight), !is.na(TaggedPitchType)) %>%
mutate(
pitch_group = case_when(
TaggedPitchType %in% c("Fastball", "FourSeamFastBall", "FourSeamFastB", "Four-Seam", "4-Seam") ~ "Fastball",
TaggedPitchType %in% c("OneSeamFastBall", "TwoSeamFastBall", "Sinker", "Two-Seam", "One-Seam") ~ "Sinker",
TaggedPitchType %in% c("ChangeUp", "Changeup") ~ "Changeup",
TRUE ~ TaggedPitchType
)
) %>%
group_by(`Pitch Type` = pitch_group) %>%
summarise(
Count = n(),
`Usage%` = sprintf("%.1f%%", (n() / nrow(filter(data, Pitcher == input$location_pitcher))) * 100),
`Avg Horizontal` = sprintf("%.2f ft", mean(PlateLocSide, na.rm = TRUE)),
`Avg Vertical` = sprintf("%.2f ft", mean(PlateLocHeight, na.rm = TRUE)),
`Zone%` = sprintf("%.1f%%", round(mean(in_zone, na.rm = TRUE) * 100, 1)),
`Strike%` = sprintf("%.1f%%", round(mean(is_csw, na.rm = TRUE) * 100, 1)),
`Whiff%` = sprintf("%.1f%%", round(mean(is_whiff, na.rm = TRUE) * 100, 1)),
`Chase%` = sprintf("%.1f%%", round(mean(chase, na.rm = TRUE) * 100, 1)),
`Stuff+` = sprintf("%.1f", mean(StuffPlus, na.rm = TRUE)), # ADD STUFF+ TO LOCATION TABLE
.groups = "drop"
) %>%
arrange(desc(Count))
DT::datatable(location_stats,
options = list(pageLength = 15, dom = 't'),
rownames = FALSE) %>%
DT::formatStyle(columns = names(location_stats), fontSize = '12px')
})
}
# Run the application
shinyApp(ui = ui, server = server)