SummaryCard / app.R
TimStats's picture
Update app.R
4aab48c verified
Raw
History Blame
22 kB
library(shiny)
library(plotly)
library(gridlayout)
library(bslib)
library(DT)
library(rsconnect)
library(baseballr)
library(dplyr)
library(tidyverse)
library(rvest)
library(ggplot2)
library(janitor)
library(ggthemes)
library(ggpubr)
library(jsonlite)
library(utils)
library(grid)
library(gridExtra)
library(png)
library(xgboost)
library(httr)
library(jpeg)
library(zoo) # For rolling mean calculation
pdf(file = NULL)
Sys.setenv(TZ='EST')
# Helper functions
download_and_process_image <- function(url) {
tryCatch({
response <- GET(url)
content_type <- http_type(response)
if (content_type %in% c("image/png", "image/jpeg")) {
temp_file <- tempfile(fileext = ifelse(content_type == "image/png", ".png", ".jpg"))
writeBin(content(response, "raw"), temp_file)
if (content_type == "image/png") {
img <- readPNG(temp_file)
} else {
img <- readJPEG(temp_file)
}
return(list(img = img, type = content_type))
} else {
warning(paste("Unsupported image type:", content_type))
return(NULL)
}
}, error = function(e) {
warning(paste("Error processing image:", e$message))
return(NULL)
})
}
is_barrel <- function(df) {
df$barrel <- with(df, ifelse(hit_angle <= 50 & hit_speed >= 97 & hit_speed * 1.5 -
hit_angle >= 117 & hit_speed + hit_angle >= 123, 1, 0))
return(df)
}
VAA <- function(milbtotal){
milbtotal <- milbtotal %>%
mutate(VAA = -atan((vz0+(az*(-sqrt((vy0*vy0)-(2*ay*(y0-(17/12))))-vy0)/
ay))/(-sqrt((vy0*vy0)-(2*ay*(y0-(17/12))))))*(180/pi))
}
pitcher_summary <- function(game_pk,date){
gdate <- as.Date.character(date)
gdate <- as.Date(gdate)
tmilb <- mlb_pbp(game_pk)
tmilb <- tmilb %>%
filter(type == "pitch")
tmilb <- tmilb %>%
select(matchup.batter.fullName,matchup.batter.id,matchup.pitcher.fullName,
matchup.pitcher.id,result.event,details.description,details.type.description,
result.description,pitchData.startSpeed,pitchData.plateTime,pitchData.zone,
pitchData.breaks.spinRate,pitchData.extension, pitchData.coordinates.pX,
pitchData.coordinates.pZ,pitchData.coordinates.x0, pitchData.coordinates.y0,
pitchData.coordinates.z0,pitchData.coordinates.aX,pitchData.coordinates.aY,
pitchData.coordinates.aZ,pitchData.coordinates.vX0,pitchData.coordinates.vZ0,
pitchData.coordinates.vY0,pitchData.coordinates.pfxX,pitchData.coordinates.pfxZ,
pitchData.breaks.breakVerticalInduced,pitchData.breaks.breakHorizontal,
hitData.launchSpeed,hitData.launchAngle,hitData.totalDistance,details.isInPlay,
last.pitch.of.ab,pitchData.breaks.spinDirection,matchup.pitchHand.code)
colnames(tmilb) <- c("Batter Name","Batter ID","Pitcher Name","Pitcher ID",
"result","description","pitch_name","des","start_speed",
"plateTime","zone","spin_rate","extension","px","pz","x0",
"y0","z0","ax","ay","az","vx0","vz0","vy0","pfxX","pfxZ",
"IVB","HB","hit_speed","hit_angle","hit_distance","inPlay",
"lastPitch","spinDirection","phand")
tmilb <- is_barrel(tmilb)
tmilb <- tmilb %>%
mutate(is_strike_swinging = ifelse(description == "Swinging Strike" |
description == "Foul Tip",TRUE,FALSE))
tmilb <- tmilb %>%
mutate(date = gdate)
return(tmilb)
}
break_plot <- function(game){
ggplot(game, aes(x = HB, y = IVB, color = pitch_name)) +
geom_point(size = 2) +
geom_vline(xintercept = 0, color = "lightblue", linewidth = 1, linetype = 4) +
geom_hline(yintercept = 0, color = "lightblue", linewidth = 1, linetype = 4) +
labs(x = "Horizontal Break (in)", y = "Induced Vertical Break (in)",
title = "Pitch Movement") +
xlim(-25, 25) +
ylim(-25, 25) +
# scale_x_continuous(breaks = seq(-20, 20, by = 20)) +
# scale_y_continuous(breaks = seq(-20, 20, by = 20)) +
theme_minimal() +
theme(
legend.position = "bottom",
plot.title = element_text(hjust = 0.5, face = "bold"),
panel.grid.minor = element_line(color = "gray", size = 0.25, linetype = 1),
aspect.ratio = 1 # This ensures the plot is square
) +
guides(color = guide_legend(title = "Pitch Type", nrow = 1))
}
pitch_plot <- function(game){
ggplot(game, aes(x = px, y = pz, color = pitch_name)) +
geom_point(size = 3.5) +
geom_segment(aes(x = -0.71, xend = 0.71, y = 1.5, yend = 1.5)) +
geom_segment(aes(x = -0.71, xend = 0.71, y = 3.6, yend = 3.6)) +
geom_segment(aes(x = 0.71, xend = 0.71, y = 1.5, yend = 3.6)) +
geom_segment(aes(x = -0.71, xend = -0.71, y = 1.5, yend = 3.6)) +
labs(x = NULL, y = NULL, title = "Pitch Location") +
xlim(-3, 3) +
ylim(0.2, 4) +
coord_fixed(ratio = 1) +
theme_minimal() +
theme(
legend.position = "bottom",
plot.title = element_text(hjust = 0.5, face = "bold"),
axis.text = element_blank(),
axis.ticks = element_blank()
) +
guides(color = guide_legend(title = "Pitch Type", nrow = 1))
}
# Load models
# FB <- xgb.load('FB.model')
# Off <- xgb.load('Off.model')
# Break <- xgb.load('Break.model')
model <- xgb.load('TimStuff2.model')
calculate_VAA <- function(vz0, ay, az, vy0, y0) {
-atan((vz0+(az*(-sqrt((vy0*vy0)-(2*ay*(y0-(17/12))))-vy0)/
ay))/(-sqrt((vy0*vy0)-(2*ay*(y0-(17/12))))))*(180/pi)
}
calculate_EAA <- function(extension) {
extension / 6.3
}
calculate_SADiff <- function(pfxX, pfxZ, spinDirection) {
inSA <- atan2(pfxZ, pfxX) * 180/pi + 90
inSA <- ifelse(inSA < 0, inSA + 360, inSA)
SADiff <- spinDirection - inSA
SADiff <- ifelse(SADiff > 180, SADiff - 360, SADiff)
SADiff <- ifelse(SADiff < -180, SADiff + 360, SADiff)
return(SADiff)
}
scale_TimStuff <- function(raw_score, model_mean, model_sd) {
scaled_score <- (raw_score - model_mean) / model_sd
result <- 100 - (scaled_score * 10)
return(result)
}
calculate_primary <- function(data){
data <- data %>%
group_by(pitch_name, `Pitcher Name`, `Pitcher ID`, date) %>%
mutate(
pitch_count = n(),
avg_start_speed = mean(start_speed, na.rm = TRUE),
avg_IVB = mean(IVB, na.rm = TRUE),
avg_HB = mean(HB, na.rm = TRUE)
) %>%
ungroup() %>%
group_by(`Pitcher Name`, `Pitcher ID`, date) %>%
mutate(
is_highest_occurrence = case_when(
pitch_count == max(pitch_count) ~ 1,
TRUE ~ 0
)
) %>%
mutate(
is_highest_occurrence = case_when(
is_highest_occurrence == 1 & pitch_count == max(pitch_count[is_highest_occurrence == 1]) &
avg_start_speed == max(avg_start_speed[is_highest_occurrence == 1]) ~ 1,
TRUE ~ 0
)
) %>%
mutate(
primary_speed = avg_start_speed[is_highest_occurrence == 1][1],
primary_IVB = avg_IVB[is_highest_occurrence == 1][1],
primary_HB = avg_HB[is_highest_occurrence == 1][1]
) %>%
ungroup()
}
calculate_timstuff <- function(game) {
game <- calculate_primary(game)
game <- game %>%
mutate(VAA = calculate_VAA(vz0, ay, az, vy0, y0),
EAA = calculate_EAA(extension),
SADiff = calculate_SADiff(pfxX, pfxZ, spinDirection),
team_fielding_id = ifelse(description %in% c("Called Strike", "Swinging Strike", "Swinging Strike (Blocked)"), 1, 0),
swing = ifelse(description %in% c("Foul", "Foul Pitchout", "In play, no out", "In play, out(s)", "In play, run(s)", "Swinging Strike", "Swinging Strike (Blocked)", "Foul Tip"), 1, 0),
is_strike_swinging = ifelse(is_strike_swinging, 1, 0),
Pitch = pitch_name,
ishandL = ifelse(phand == "L",1,0))
feature_vars <- c("ishandL","start_speed", "IVB", "HB", "EAA", "x0", "z0", "spin_rate","SADiff","primary_speed","primary_IVB","primary_HB")
complete_rows <- complete.cases(game[, feature_vars])
game_complete <- game[complete_rows, ]
game_na <- game[!complete_rows,]
game_na$TimStuff <- NA
rhp <- game_complete
rhp$TimStuff <- scale_TimStuff(predict(model, as.matrix(cbind(rhp$ishandL,rhp$start_speed, rhp$IVB, rhp$HB, rhp$EAA, rhp$x0, rhp$z0, rhp$spin_rate, rhp$SADiff,rhp$primary_speed,rhp$primary_IVB,rhp$primary_HB))), -0.00249975, 0.007566558)
game_complete <- rbind(rhp,game_na)
return(game_complete)
}
summary_table <- function(game) {
rows <- nrow(game)
game <- calculate_primary(game)
game <- calculate_timstuff(game)
sumtable <- game %>%
mutate(team_fielding_id = ifelse(description == "Called Strike" |
description == "Swinging Strike" |
description == "Swinging Strike (Blocked)", 1, 0)) %>%
mutate(swing = ifelse(description == "Foul" |
description == "Foul Pitchout" |
description == "In play, no out" |
description == "In play, out(s)" |
description == "In play, run(s)" |
description == "Swinging Strike" |
description == "swinging Strike (Blocked)" |
description == "Foul Tip", 1, 0)) %>%
mutate(is_strike_swinging = ifelse(is_strike_swinging == TRUE, 1, 0)) %>%
mutate(Pitch = pitch_name) %>%
rowwise() %>%
group_by(Pitch) %>%
summarize(
Pitches = n(),
'Pitch%' = round(sum(Pitches)/sum(rows) * 100, digits = 1),
'Avg. Velo' = round(mean(start_speed, na.rm = TRUE), digits = 1),
'Spin Rate' = round(mean(spin_rate, na.rm = TRUE), digits = 0),
'Extension' = round(mean(extension, na.rm = TRUE), digits = 1),
'IVB' = round(mean(IVB, na.rm = TRUE), digits = 1),
'HB' = round(mean(HB, na.rm = TRUE), digits = 1),
'VAA' = round(mean(VAA, na.rm = TRUE), digits = 1),
'CSW%' = round(sum(team_fielding_id, na.rm = TRUE) / sum(!is.na(team_fielding_id)) * 100, digits = 1),
'Whiff%' = round(sum(is_strike_swinging, na.rm = TRUE) / sum(swing, na.rm = TRUE) * 100, digits = 1),
'TimStuff+' = round(mean(TimStuff, na.rm = TRUE), digits = 0)
) %>%
arrange(-Pitches)
result <- sumtable %>%
select(Pitch, Pitches, `Pitch%`, `Avg. Velo`, `Spin Rate`, Extension,IVB, HB, VAA, `CSW%`, `Whiff%`, `TimStuff+`) %>%
rename(
"Type" = Pitch,
"#" = Pitches,
"Velo" = `Avg. Velo`,
"Spin" = `Spin Rate`,
"Ext" = Extension,
"Use%" = `Pitch%`
) %>%
mutate(
"Use%" = paste0(`Use%`, "%"),
"Spin" = format(round(Spin), big.mark = ","),
Velo = round(Velo, 1),
Ext = round(Ext, 1),
IVB = round(IVB, 1),
HB = round(HB, 1),
VAA = round(VAA, 1),
`CSW%` = paste0(`CSW%`, "%"),
`Whiff%` = paste0(`Whiff%`, "%")
) %>%
arrange(desc(`#`))
return(result)
}
# Initialize schedule data
mlbid <- mlb_schedule(season = 2024, level_ids = "1")
mlbteamH <- mlbid %>%
select(teams_home_team_name)
mlbteamH <- distinct(mlbteamH)
mlbteamA <- mlbid %>%
select(teams_away_team_name)
mlbteamA <- distinct(mlbteamA)
aaaid <- mlb_schedule(season = 2024, level_ids = "11")
aaateamH <- aaaid %>%
select(teams_home_team_name)
aaateamH <- distinct(aaateamH)
aaateamA <- aaaid %>%
select(teams_away_team_name)
aaateamA <- distinct(aaateamA)
fslid <- mlb_schedule(season = 2024, level_ids = "14")
fslid <- fslid %>%
filter(teams_home_team_name == "Daytona Tortugas" |
teams_home_team_name == "Jupiter Hammerheads" |
teams_home_team_name == "Palm Beach Cardinals" |
teams_home_team_name == "St. Lucie Mets" |
teams_home_team_name == "Bradenton Marauders" |
teams_home_team_name == "Clearwater Threshers" |
teams_home_team_name == "Dunedin Blue Jays" |
teams_home_team_name == "Fort Myers Mighty Mussels" |
teams_home_team_name == "Lakeland Flying Tigers" |
teams_home_team_name == "Tampa Tarpons")
fslteamH <- fslid %>%
select(teams_home_team_name)
fslteamH <- distinct(fslteamH)
fslteamA <- fslid %>%
select(teams_away_team_name)
fslteamA <- distinct(fslteamA)
sbid <- mlb_schedule(season = 2024, level_ids = "22")
sbteamH <- sbid %>%
select(teams_home_team_name)
sbteamH <- distinct(sbteamH)
sbteamA <- sbid %>%
select(teams_away_team_name)
sbteamA <- distinct(sbteamA)
# UI Definition
ui <- fluidPage(
theme = bs_theme(version = 5, bootswatch = "flatly"),
titlePanel("2024 MLB/AAA/FSL Summary Cards"),
sidebarLayout(
sidebarPanel(
width = 3,
dateInput("date", "Date:", value = Sys.Date()),
selectizeInput("level", "Level:",
c("MLB", "AAA", "FSL", "College (Statcast Parks Only)"),
options = list(
placeholder = 'Select a level',
onInitialize = I('function() { this.setValue(""); }')
)),
selectizeInput("homeT", "Home Team:", NULL),
selectizeInput("awayT", "Away Team:", NULL),
selectizeInput("gamenum", "Game Number:", c("1", "2")),
actionButton("update", "Find Pitcher", icon("magnifying-glass"),
class = "btn-primary btn-block"),
selectizeInput("pitcher", "Pitcher Name:", NULL),
#textInput("title", "Card Title"),
actionButton("update1", "Make Card", icon("plus"),
class = "btn-success btn-block"),
downloadButton("downloadPlot", "Download Card", class = "btn-info btn-block")
),
mainPanel(
plotOutput("combinedPlot", height = "900px", width = "100%")
)
)
)
server <- function(input, output, session) {
observeEvent(input$level, {
if(input$level == "AAA"){
updateSelectizeInput(session, "homeT", "Home Team:", choices = aaateamH[,1])
updateSelectizeInput(session, "awayT", "Away Team:", choices = aaateamA[,1])
}
if(input$level == "FSL"){
updateSelectizeInput(session, "homeT", "Home Team:", choices = fslteamH[,1])
updateSelectizeInput(session, "awayT", "Away Team:", choices = fslteamA[,1])
}
if(input$level == "MLB"){
updateSelectizeInput(session, "homeT", "Home Team:", choices = mlbteamH[,1])
updateSelectizeInput(session, "awayT", "Away Team:", choices = mlbteamA[,1])
}
if(input$level == "College (Statcast Parks Only)"){
updateSelectizeInput(session, "homeT", "Home Team:", choices = sbteamH[,1])
updateSelectizeInput(session, "awayT", "Away Team:", choices = sbteamA[,1])
}
})
game_data <- reactiveVal()
observeEvent(input$update, {
tryCatch({
if(input$level == "AAA"){
pname <- aaaid %>%
filter(date == input$date) %>%
filter(teams_home_team_name == input$homeT) %>%
filter(teams_away_team_name == input$awayT) %>%
filter(game_number == input$gamenum)
pname <- pitcher_summary(pname[,6], input$date)
}
if(input$level == "FSL"){
pname <- fslid %>%
filter(date == input$date) %>%
filter(teams_home_team_name == input$homeT) %>%
filter(teams_away_team_name == input$awayT) %>%
filter(game_number == input$gamenum)
pname <- pitcher_summary(pname[,6], input$date)
}
if(input$level == "MLB"){
pname <- mlbid %>%
filter(date == as.character.Date(input$date)) %>%
filter(teams_home_team_name == input$homeT) %>%
filter(teams_away_team_name == input$awayT) %>%
filter(game_number == input$gamenum)
pname <- pitcher_summary(pname[,6], input$date)
}
if(input$level == "College (Statcast Parks Only)"){
pname <- sbid %>%
filter(date == input$date) %>%
filter(teams_home_team_name == input$homeT) %>%
filter(teams_away_team_name == input$awayT) %>%
filter(game_number == input$gamenum)
pname <- pitcher_summary(pname[,6], input$date)
}
if(nrow(pname) == 0) {
showNotification("No pitchers found for the selected game.", type = "warning")
} else {
updateSelectizeInput(session, "pitcher", "Pitcher:", choices = unique(pname$`Pitcher Name`))
game_data(pname)
}
}, error = function(e) {
showNotification(paste("Error finding pitchers:", e$message), type = "error")
})
})
rolling_timstuff <- reactive({
req(input$update1, game_data())
game <- game_data() %>% filter(`Pitcher Name` == input$pitcher)
game <- calculate_timstuff(game)
game %>%
arrange(date) %>%
group_by(pitch_name) %>%
mutate(rolling_timstuff = rollmean(TimStuff, k = 5, fill = NA, align = "right"),
pitch_number = row_number()) %>%
ungroup() # Make sure to ungroup after the grouping operations
})
combinedPlot <- reactiveVal()
observeEvent(input$update1, {
req(game_data())
tryCatch({
game <- game_data() %>% filter(`Pitcher Name` == input$pitcher)
if(nrow(game) == 0) {
showNotification("No data available for the selected pitcher.", type = "warning")
return()
}
break_plot <- break_plot(game) +
theme(legend.position = "none")
pitch_plot <- pitch_plot(game) +
theme(legend.position = "none")
# Create a formatted table
table_data <- summary_table(game)
num_rows <- nrow(table_data)
table_plot <- tableGrob(table_data, rows = NULL, theme = ttheme_minimal(
core = list(fg_params = list(hjust = 0.5, x = 0.5), bg_params = list(fill = "white")),
colhead = list(fg_params = list(hjust = 0.5, x = 0.5, fontface = "bold"), bg_params = list(fill = "#f0f0f0")),
rowhead = list(fg_params = list(hjust = 0.5, x = 0.5), bg_params = list(fill = "white"))
))
# Set a fixed total height for the table, adjusting row heights based on number of pitches
total_height <- unit(1, "npc")
row_height <- total_height / (num_rows + 1) # +1 for header row
table_plot$heights <- unit(rep(row_height, num_rows + 1), "npc")
# Adjust column widths
table_plot$widths <- unit(c(0.1, 0.06, 0.06, 0.08, 0.1, 0.06, 0.08, 0.08, 0.08, 0.1, 0.1, 0.1), "npc")
# Add alternating row colors
for(i in seq(2, nrow(table_plot), 2)) {
table_plot$grobs[[i]]$gp$fill <- "#f9f9f9"
}
id <- as.character(game$`Pitcher ID`[1])
mlb_url <- paste0("https://midfield.mlbstatic.com/v1/people/", id, "/mlb/300?circle=false")
milb_url <- paste0("https://midfield.mlbstatic.com/v1/people/", id, "/milb/300?circle=false")
img_result <- download_and_process_image(mlb_url)
if (is.null(img_result)) {
img_result <- download_and_process_image(milb_url)
}
if (!is.null(img_result)) {
img_grob <- rasterGrob(img_result$img, interpolate = TRUE)
} else {
img_grob <- textGrob("Image not available", gp = gpar(col = "red", fontsize = 20))
}
# Create the rolling TimStuff+ graph
rolling_data <- rolling_timstuff()
timstuff_plot <- ggplot(rolling_data, aes(x = pitch_number, y = rolling_timstuff, color = pitch_name)) +
geom_line(size = 1) +
geom_point(size = 1) +
theme_minimal() +
labs(title = "5-Pitch Rolling TimStuff+",
x = "Pitch Number", y = "TimStuff+") +
ylim(70, 130) +
scale_x_continuous(breaks = seq(5, 55, by = 5), limits = c(5,NA)) +
theme(
plot.title = element_text(hjust = 0.5, face = "bold"),
legend.position = "none",
panel.grid.major.x = element_line(color = "gray", size = 0.5)
)
# Create title and data source text
title_text <- textGrob(
paste(input$pitcher, format(input$date, "%m/%d/%y"), "Summary Card by @TimStats"),
gp = gpar(fontsize = 16, fontface = "bold")
)
data_source_text <- textGrob(
"Data: MLB",
gp = gpar(fontsize = 8),
x = unit(1, "npc") - unit(2, "mm"),
y = unit(2, "mm"),
just = c("right", "bottom")
)
# Create a horizontal legend
legend <- get_legend(
ggplot(rolling_data, aes(x = pitch_number, y = rolling_timstuff, color = pitch_name)) +
geom_point(size = 5) +
theme(legend.position = "bottom",
legend.title = element_blank(),
text = element_text(size = 12.5),
#legend.key.size = unit(.5,"cm"),
legend.box = "horizontal" ) +
guides(color = guide_legend(nrow = 1))
)
# Combine all plots
combined <- grid.arrange(
arrangeGrob(
arrangeGrob(
img_grob,
title_text,
ncol = 1,
heights = c(4, 1)
),
pitch_plot,
ncol = 2,
widths = c(1, 1)
),
arrangeGrob(
break_plot,
timstuff_plot,
ncol = 2,
widths = c(1, 1)
),
#arrangeGrob(
legend,
#),
#arrangeGrob(
table_plot,
#),
data_source_text,
nrow = 5,
heights = c(1.2, 1.2, 0.05, 1.1, 0.05) # Adjusted these values
)
combinedPlot(combined)
output$combinedPlot <- renderPlot({
grid.draw(combinedPlot())
})
}, error = function(e) {
showNotification(paste("Error generating card:", e$message), type = "error")
})
})
output$downloadPlot <- downloadHandler(
filename = function() {
paste("baseball_card_", Sys.Date(), ".png", sep = "")
},
content = function(file) {
ggsave(file, plot = combinedPlot(), width = 18, height = 12, dpi = 300)
}
)
}
shinyApp(ui, server)