Spaces:
Running
Running
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) | |
library(pkgconfig) | |
library(rsvg) | |
pdf(file = NULL) | |
Sys.setenv(TZ='EST') | |
model <- xgb.load('TimStuff2.model') | |
# Helper functions | |
check_patreon_access <- function(email) { | |
campaign_id <- Sys.getenv("PATREON_CAMPAIGN_ID") | |
access_token <- Sys.getenv("PATREON_ACCESS_TOKEN") | |
base_url <- paste0("https://www.patreon.com/api/oauth2/v2/campaigns/", campaign_id, "/members") | |
params <- list( | |
`include` = "currently_entitled_tiers", | |
`fields[member]` = "patron_status,email", | |
`fields[tier]` = "title" | |
) | |
response <- GET( | |
base_url, | |
query = params, | |
add_headers( | |
`Authorization` = paste("Bearer", access_token), | |
`User-Agent` = "R/httr" | |
) | |
) | |
content <- fromJSON(rawToChar(response$content)) | |
# Check in data$attributes for matching email | |
matching_row <- which(content$data$attributes$email == email) | |
if (length(matching_row) > 0) { | |
# Get patron status | |
patron_status <- content$data$attributes$patron_status[matching_row] | |
if (patron_status == "active_patron") { | |
# Get tier info | |
tier_data <- content$data$relationships$currently_entitled_tiers$data[[matching_row]] | |
tier_id <- tier_data$id | |
result <- tier_id %in% c("25062087", "25062090") | |
return(result) | |
} | |
} | |
return(FALSE) | |
} | |
gtable_add_padding <- function(gtable, padding = unit(c(5, 5, 5, 5), "mm")) { | |
padding <- rep(padding, length.out = 4) | |
# Add padding to the layout | |
new_widths <- unit.c(padding[4], gtable$widths, padding[2]) | |
new_heights <- unit.c(padding[1], gtable$heights, padding[3]) | |
# Create the new layout with added padding | |
new_layout <- gtable$layout | |
new_layout$l <- new_layout$l + 1 | |
new_layout$t <- new_layout$t + 1 | |
# Create new gtable with padding | |
new_gtable <- gtable( | |
widths = new_widths, | |
heights = new_heights | |
) | |
# Add grobs from original gtable | |
new_gtable$layout <- new_layout | |
new_gtable$grobs <- gtable$grobs | |
return(new_gtable) | |
} | |
get_team_logo <- function(team_name) { | |
# ESPN team ID and URL mapping | |
team_info <- list( | |
"Los Angeles Angels" = "LAA", | |
"Arizona Diamondbacks" = "ARI", | |
"Baltimore Orioles" = "BAL", | |
"Boston Red Sox" = "BOS", | |
"Chicago Cubs" = "CHC", | |
"Cincinnati Reds" = "CIN", | |
"Cleveland Guardians" = "CLE", | |
"Colorado Rockies" = "COL", | |
"Detroit Tigers" = "DET", | |
"Houston Astros" = "HOU", | |
"Kansas City Royals" = "KC", | |
"Los Angeles Dodgers" = "LAD", | |
"Washington Nationals" = "WSH", | |
"New York Mets" = "NYM", | |
"Oakland Athletics" = "OAK", | |
"Pittsburgh Pirates" = "PIT", | |
"San Diego Padres" = "SD", | |
"Seattle Mariners" = "SEA", | |
"San Francisco Giants" = "SF", | |
"St. Louis Cardinals" = "STL", | |
"Tampa Bay Rays" = "TB", | |
"Texas Rangers" = "TEX", | |
"Toronto Blue Jays" = "TOR", | |
"Minnesota Twins" = "MIN", | |
"Philadelphia Phillies" = "PHI", | |
"Atlanta Braves" = "ATL", | |
"Chicago White Sox" = "CHW", | |
"Miami Marlins" = "MIA", | |
"New York Yankees" = "NYY", | |
"Milwaukee Brewers" = "MIL" | |
) | |
team_abbrev <- team_info[[team_name]] | |
if (is.null(team_abbrev)) { | |
return(textGrob("Logo not available", gp = gpar(col = "gray", fontsize = 12))) | |
} | |
logo_url <- sprintf("https://a.espncdn.com/combiner/i?img=/i/teamlogos/mlb/500/scoreboard/%s.png&h=400&w=400", team_abbrev) | |
logo_result <- download_and_process_image(logo_url) | |
if (!is.null(logo_result)) { | |
# Set fixed size for the logo | |
return(rasterGrob(logo_result$img, | |
interpolate = TRUE, | |
width = unit(.75, "npc"), # Reduced size to 50% of container | |
height = unit(.75, "npc"))) | |
} else { | |
return(textGrob("Logo not available", gp = gpar(col = "gray", fontsize = 12))) | |
} | |
} | |
#logo_svg <- function(id){ | |
# logo_url <- paste0("https://www.mlbstatic.com/team-logos/",id,".svg") | |
# logo_result <- download_and_process_image(logo_url) | |
# | |
# if (!is.null(logo_result)) { | |
# # Set fixed size for the logo | |
# return(rasterGrob(logo_result$img, | |
# interpolate = TRUE, | |
# width = unit(.75, "npc"), # Reduced size to 50% of container | |
# height = unit(.75, "npc"))) | |
# } else { | |
# return(textGrob("Logo not available", gp = gpar(col = "gray", fontsize = 12))) | |
# } | |
#} | |
logo_svg <- function(id) { | |
logo_url <- paste0("https://www.mlbstatic.com/team-logos/", id, ".svg") | |
logo_result <- download_and_process_image(logo_url) | |
if (!is.null(logo_result)) { | |
# Create a square viewport/container first | |
return( | |
gTree( | |
children = gList( | |
# Add a transparent square background (optional) | |
rectGrob(gp = gpar(fill = NA, col = NA)), | |
# Place the logo in center of the square container | |
rasterGrob(logo_result$img, | |
interpolate = TRUE, | |
width = unit(.75, "npc"), | |
height = unit(.75, "npc"), | |
vp = viewport(width = unit(1, "snpc"), | |
height = unit(1, "snpc"))) | |
), | |
vp = viewport(width = unit(1, "npc"), | |
height = unit(1, "npc"), | |
layout = grid.layout(1, 1)) | |
) | |
) | |
} else { | |
return(textGrob("Logo not available", gp = gpar(col = "gray", fontsize = 12))) | |
} | |
} | |
download_and_process_image <- function(url) { | |
tryCatch({ | |
response <- GET(url) | |
content_type <- http_type(response) | |
if (content_type %in% c("image/png", "image/jpeg", "image/svg+xml")) { | |
temp_file <- tempfile(fileext = switch(content_type, | |
"image/png" = ".png", | |
"image/jpeg" = ".jpg", | |
"image/svg+xml" = ".svg")) | |
writeBin(content(response, "raw"), temp_file) | |
# For SVG, convert to PNG first | |
if (content_type == "image/svg+xml") { | |
svg_content <- readLines(temp_file, warn = FALSE) | |
# Create a PNG from the SVG | |
png_file <- tempfile(fileext = ".png") | |
rsvg::rsvg_png(temp_file, png_file) | |
img <- png::readPNG(png_file) | |
} else 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,matchup.batSide.code,game_pk) | |
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","bhand","gamepk") | |
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){ | |
pitch_colors <- c( | |
"Four-Seam Fastball"= "#FF4136", | |
"Sinker"= "#FF851B", | |
"Cutter"= "#FFDC00", | |
"Changeup"= "#2ECC40", | |
"Slider"= "#0074D9", | |
"Sweeper"= "#ED68ED", | |
"Curveball"= "#B10DC9", | |
"Splitter"= "#01FF70", | |
"Knuckle Curve"= "#85144b", | |
"Slurve"= "#3D9970", | |
"Knuckle Ball"= "#39CCCC", | |
"Forkball"= "#F012BE", | |
"Eephus"= "#AAAAAA", | |
"Fastball"= "#7FDBFF", | |
"Slow Curve"= "#DDDDDD", | |
"Screwball"= "#FF69B4" | |
) | |
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) + | |
scale_color_manual(values = pitch_colors) + | |
labs(x = "Horizontal Break (in)", y = "Induced Vertical Break (in)", | |
title = "Pitch Movement") + | |
xlim(-25, 25) + | |
ylim(-25, 25) + | |
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 | |
) + | |
guides(color = guide_legend(title = "Pitch Type", nrow = 1)) | |
} | |
left_batter <- png::readPNG("left_batter.png") | |
right_batter <- png::readPNG("right_batter.png") | |
pitch_plot_split <- function(game, title) { | |
game_lhb <- game %>% filter(bhand == "L") | |
game_rhb <- game %>% filter(bhand == "R") | |
home_plate <- data.frame( | |
x = c(0.6, -0.6, -0.7083, 0, 0.7083), | |
y = c(0.5, 0.5, 0.25, 0, 0.25) | |
) | |
pitch_colors <- c( | |
"Four-Seam Fastball"= "#FF4136", | |
"Sinker"= "#FF851B", | |
"Cutter"= "#FFDC00", | |
"Changeup"= "#2ECC40", | |
"Slider"= "#0074D9", | |
"Sweeper"= "#ED68ED", | |
"Curveball"= "#B10DC9", | |
"Splitter"= "#01FF70", | |
"Knuckle Curve"= "#85144b", | |
"Slurve"= "#3D9970", | |
"Knuckle Ball"= "#39CCCC", | |
"Forkball"= "#F012BE", | |
"Eephus"= "#AAAAAA", | |
"Fastball"= "#7FDBFF", | |
"Slow Curve"= "#DDDDDD", | |
"Screwball"= "#FF69B4" | |
) | |
plot_lhb <- ggplot(game_lhb, aes(x = px, y = pz, color = pitch_name)) + | |
geom_polygon(data = home_plate, aes(x = x, y = y), | |
fill = "white", color = "black", inherit.aes = FALSE) + | |
annotation_custom(rasterGrob(left_batter, | |
width = unit(.75, "npc"), | |
height = unit(1.5, "npc")), | |
xmin = .7, xmax = 3.2, | |
ymin = 1, ymax = 5) + | |
geom_point(size = 3) + | |
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)) + | |
scale_color_manual(values = pitch_colors) + | |
labs(x = NULL, y = NULL, title = "LHB") + | |
xlim(-3, 3) + | |
ylim(0, 5) + | |
coord_fixed(ratio = 1) + | |
theme_void() + | |
theme( | |
legend.position = "none", | |
plot.title = element_text(hjust = 0.5, face = "bold"), | |
axis.text = element_blank(), | |
axis.ticks = element_blank() | |
) | |
plot_rhb <- ggplot(game_rhb, aes(x = px, y = pz, color = pitch_name)) + | |
geom_polygon(data = home_plate, aes(x = x, y = y), | |
fill = "white", color = "black", inherit.aes = FALSE) + | |
annotation_custom(rasterGrob(right_batter, | |
width = unit(.75, "npc"), | |
height = unit(1.5, "npc")), | |
xmin = -3, xmax = -0.5, | |
ymin = 1, ymax = 5) + | |
geom_point(size = 3) + | |
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)) + | |
scale_color_manual(values = pitch_colors) + | |
labs(x = NULL, y = NULL, title = "RHB") + | |
xlim(-3, 3) + | |
ylim(0, 5) + | |
coord_fixed(ratio = 1) + | |
theme_void() + | |
theme( | |
legend.position = "none", | |
plot.title = element_text(hjust = 0.5, face = "bold"), | |
axis.text = element_blank(), | |
axis.ticks = element_blank() | |
) | |
return(list(lhb = plot_lhb, rhb = plot_rhb)) | |
} | |
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) | |
} | |
getBoxScore <- function(game_pk, pid) { | |
url <- paste0("https://statsapi.mlb.com/api/v1/game/", game_pk, "/boxscore") | |
bs <- fromJSON(url) | |
t <- as.data.frame(bs[["teams"]][["away"]][["players"]][[paste0("ID", pid)]][["stats"]][["pitching"]]) | |
if (nrow(t) == 0) { | |
t <- as.data.frame(bs[["teams"]][["home"]][["players"]][[paste0("ID", pid)]][["stats"]][["pitching"]]) | |
} | |
stats <- c("inningsPitched", "battersFaced", "runs", "earnedRuns", "hits", "baseOnBalls", "strikeOuts", "strikePercentage") | |
display_names <- c("IP", "TBF", "R", "ER", "H", "BB", "K", "Strike%") | |
result <- data.frame(matrix(ncol = length(stats), nrow = 1)) | |
colnames(result) <- display_names | |
for (i in 1:length(stats)) { | |
value <- t[[stats[i]]] | |
if (is.null(value)) value <- "0" | |
if (stats[i] == "strikePercentage") { | |
value <- paste0(round(as.numeric(value) * 100, 1), "%") | |
} | |
result[1, i] <- as.character(value) | |
} | |
return(result) | |
} | |
calculate_timstuff <- function(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") | |
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))), -0.002620635, 0.006021368) | |
game_complete <- rbind(rhp,game_na) | |
return(game_complete) | |
} | |
summary_table <- function(game) { | |
rows <- nrow(game) | |
game <- calculate_timstuff(game) | |
game <- game %>% | |
mutate(TimStuff = ifelse(TimStuff > 150,NA,TimStuff)) %>% | |
mutate(TimStuff = ifelse(TimStuff < 50,NA,TimStuff)) | |
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) | |
} | |
mlbid <- bind_rows( | |
mlb_schedule(season = 2020, level_ids = "1"), | |
mlb_schedule(season = 2021, level_ids = "1"), | |
mlb_schedule(season = 2022, level_ids = "1"), | |
mlb_schedule(season = 2023, level_ids = "1"), | |
mlb_schedule(season = 2024, level_ids = "1"), | |
mlb_schedule(season = 2025, level_ids = "1") | |
) | |
mlbteamH <- mlbid %>% | |
select(teams_home_team_name) %>% | |
distinct() | |
mlbteamA <- mlbid %>% | |
select(teams_away_team_name) %>% | |
distinct() | |
# AAA Schedule (2021-2025) | |
aaaid <- bind_rows( | |
mlb_schedule(season = 2021, level_ids = "11"), | |
mlb_schedule(season = 2022, level_ids = "11"), | |
mlb_schedule(season = 2023, level_ids = "11"), | |
mlb_schedule(season = 2024, level_ids = "11"), | |
mlb_schedule(season = 2025, level_ids = "11") | |
) | |
aaateamH <- aaaid %>% | |
select(teams_home_team_name) %>% | |
distinct() | |
aaateamA <- aaaid %>% | |
select(teams_away_team_name) %>% | |
distinct() | |
# FSL Schedule (2021-2025) | |
fslid <- bind_rows( | |
mlb_schedule(season = 2021, level_ids = "14"), | |
mlb_schedule(season = 2022, level_ids = "14"), | |
mlb_schedule(season = 2023, level_ids = "14"), | |
mlb_schedule(season = 2024, level_ids = "14"), | |
mlb_schedule(season = 2025, level_ids = "14") | |
) %>% | |
filter(gameday_type == "E" | gameday_type == "P") | |
fslteamH <- fslid %>% | |
select(teams_home_team_name) %>% | |
distinct() | |
fslteamA <- fslid %>% | |
select(teams_away_team_name) %>% | |
distinct() | |
# College Schedule (2023-2025) | |
sbid <- bind_rows( | |
mlb_schedule(season = 2023, level_ids = "22"), | |
mlb_schedule(season = 2024, level_ids = "22"), | |
mlb_schedule(season = 2025, level_ids = "22") | |
) %>% | |
filter(gameday_type == "E" | gameday_type == "P") | |
sbteamH <- sbid %>% | |
select(teams_home_team_name) %>% | |
distinct() | |
sbteamA <- sbid %>% | |
select(teams_away_team_name) %>% | |
distinct() | |
#Select ACL & FCL Statcast Games | |
cpxid <- bind_rows( | |
mlb_schedule(season = 2025, level_ids = "16") | |
) %>% | |
filter(gameday_type == "E" | gameday_type == "P") | |
cpxteamH <- cpxid %>% | |
select(teams_home_team_name) %>% | |
distinct() | |
cpxteamA <- cpxid %>% | |
select(teams_away_team_name) %>% | |
distinct() | |
# UI Definition | |
ui <- fluidPage( | |
theme = bs_theme(bg = "#ffffff", fg = "#333333", primary = "#428bca"), | |
tagList( | |
tags$head( | |
tags$link(href = "https://fonts.googleapis.com/css2?family=Roboto+Condensed:wght@400;700&display=swap", | |
rel = "stylesheet"), | |
tags$style(HTML(" | |
* { font-family: 'Roboto Condensed', sans-serif !important; } | |
.login-screen { | |
max-width: 400px; | |
margin: 100px auto; | |
padding: 20px; | |
border: 1px solid #ddd; | |
border-radius: 5px; | |
box-shadow: 0 0 10px rgba(0,0,0,0.1); | |
} | |
.login-header { | |
text-align: center; | |
margin-bottom: 20px; | |
} | |
")) | |
), | |
# Use uiOutput to conditionally render either login or main page | |
uiOutput("page") | |
) | |
) | |
# Server Definition | |
server <- function(input, output, session) { | |
credentials <- reactiveValues(logged_in = FALSE) | |
output$page <- renderUI({ | |
if (!credentials$logged_in) { | |
# Login page | |
div(class = "login-screen", | |
div(class = "login-header", | |
h2("Baseball Stats Visualization"), | |
p("Please log in with your Patreon email") | |
), | |
textInput("email", "Email:"), | |
actionButton("login", "Login", class = "btn-primary"), | |
p(style = "margin-top: 20px; text-align: center;", | |
"Access requires Veteran or Hall of Fame tier on Patreon") | |
) | |
} else { | |
# Main application UI (your existing UI) | |
fluidPage( | |
theme = bs_theme(version = 5, bootswatch = "flatly"), | |
titlePanel("2020-2025 Daily MLB/AAA/FSL Summary Cards"), | |
sidebarLayout( | |
sidebarPanel( | |
width = 3, | |
dateInput("date", "Date:"), | |
selectizeInput("level", "Level:", | |
c("MLB", "AAA", "FSL","College (Statcast Parks Only)", "Select Complex League Games"), | |
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), | |
actionButton("update1", "Make Card", icon("plus"), | |
class = "btn-success btn-block"), | |
downloadButton("downloadPlot", "Download Card", class = "btn-info btn-block") | |
), | |
mainPanel( | |
div(style = "width: 1100px; height: 1100px; overflow: auto;", # Increased from 1000px | |
plotOutput("combinedPlot", width = "100%", height = "100%") | |
), | |
tableOutput("boxscoreTable") | |
) | |
) | |
) | |
} | |
}) | |
observeEvent(input$login, { | |
# Check Patreon access | |
has_access <- check_patreon_access(input$email) | |
if (has_access) { | |
credentials$logged_in <- TRUE | |
} else { | |
showModal(modalDialog( | |
title = "Access Denied", | |
"This email does not have access. Please make sure you're using the email associated with your Patreon account and you have an active Veteran or Hall of Fame tier subscription.", | |
easyClose = TRUE | |
)) | |
} | |
}) | |
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]) | |
} | |
if(input$level == "Select Complex League Games"){ | |
updateSelectizeInput(session, "homeT", "Home Team:", choices = cpxteamH[,1]) | |
updateSelectizeInput(session, "awayT", "Away Team:", choices = cpxteamA[,1]) | |
} | |
}) | |
game_data <- reactiveVal() | |
boxscore_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(input$level == "Select Complex League Games"){ | |
pname <- cpxid %>% | |
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 <- game %>% | |
mutate(TimStuff = ifelse(TimStuff > 150,NA,TimStuff)) %>% | |
mutate(TimStuff = ifelse(TimStuff < 50,NA,TimStuff)) | |
result <- game %>% | |
arrange(date) %>% | |
group_by(pitch_name) %>% | |
mutate( | |
TimStuff = as.numeric(TimStuff), | |
rolling_timstuff = if(n() >= 5) rollmean(TimStuff, k = 5, fill = NA, align = "right") else TimStuff, | |
pitch_number = row_number() | |
) %>% | |
ungroup() | |
return(result) | |
}) | |
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() | |
} | |
game_pk <- unique(game$gamepk)[1] | |
bs <- fromJSON(paste0("https://statsapi.mlb.com/api/v1/game/", game_pk, "/boxscore")) | |
pitcher_id <- unique(game$`Pitcher ID`)[1] | |
# Look for pitcher in both home and away teams | |
pitcher_team <- NULL | |
if(paste0("ID", pitcher_id) %in% names(bs$teams$away$players)) { | |
pitcher_team <- bs$teams$away$team$name | |
pitcher_id <- bs$teams$away$team$id | |
} else if(paste0("ID", pitcher_id) %in% names(bs$teams$home$players)) { | |
pitcher_team <- bs$teams$home$team$name | |
pitcher_id <- bs$teams$home$team$id | |
} | |
if(is.null(pitcher_id)) { | |
mlb_team_logo <- textGrob("Logo not available", gp = gpar(col = "gray", fontsize = 12)) | |
} else { | |
#mlb_team_logo <- get_team_logo(pitcher_team) | |
mlb_team_logo <- logo_svg(pitcher_id) | |
if(is.null(mlb_team_logo)) { | |
mlb_team_logo <- textGrob("Logo not available", gp = gpar(col = "gray", fontsize = 12)) | |
} | |
} | |
#mlb_team_logo <- get_team_logo(pitcher_team) | |
# Get plots and data | |
break_plot <- break_plot(game) + theme(legend.position = "none") | |
pitch_plots <- pitch_plot_split(game) | |
boxscore_data <- getBoxScore(unique(game$gamepk[1]), unique(game$`Pitcher ID`[1])) | |
# Create boxscore table | |
boxscore_table <- tableGrob(boxscore_data, rows = NULL, theme = ttheme_minimal( | |
core = list( | |
fg_params = list(hjust = 0.5, x = 0.5), | |
bg_params = list(fill = "white"), | |
# Reduce the y padding to make the table more compact | |
y.padding = unit(4, "mm") # Reduced from default | |
), | |
colhead = list( | |
fg_params = list(hjust = 0.5, x = 0.5, fontface = "bold"), | |
bg_params = list(fill = "#f0f0f0"), | |
# Reduce the y padding for headers | |
y.padding = unit(4, "mm") # Reduced from default | |
) | |
)) | |
boxscore_table$heights <- unit(rep(1/(nrow(boxscore_data) + 1), nrow(boxscore_data) + 1), "npc") | |
# Create summary 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")) | |
)) | |
total_height <- unit(1, "npc") | |
row_height <- total_height / (num_rows + 1) | |
table_plot$heights <- unit(rep(row_height, num_rows + 1), "npc") | |
table_plot$widths <- unit(c(0.12, 0.05, 0.05, 0.07, 0.08, 0.05, 0.07, 0.07, 0.07, 0.09, 0.09, 0.09), "npc") | |
for(i in seq(2, nrow(table_plot), 2)) { | |
table_plot$grobs[[i]]$gp$fill <- "#f9f9f9" | |
} | |
# Get player image | |
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) | |
} | |
img_grob <- if (!is.null(img_result)) { | |
rasterGrob(img_result$img, interpolate = TRUE) | |
} else { | |
textGrob("Image not available", gp = gpar(col = "red", fontsize = 20)) | |
} | |
# Create rolling TimStuff+ plot | |
rolling_data <- rolling_timstuff() | |
pitch_colors <- c( | |
"Four-Seam Fastball"= "#FF4136", | |
"Sinker"= "#FF851B", | |
"Cutter"= "#FFDC00", | |
"Changeup"= "#2ECC40", | |
"Slider"= "#0074D9", | |
"Sweeper"= "#ED68ED", | |
"Curveball"= "#B10DC9", | |
"Splitter"= "#01FF70", | |
"Knuckle Curve"= "#85144b", | |
"Slurve"= "#3D9970", | |
"Knuckle Ball"= "#39CCCC", | |
"Forkball"= "#F012BE", | |
"Eephus"= "#AAAAAA", | |
"Fastball"= "#7FDBFF", | |
"Slow Curve"= "#DDDDDD", | |
"Screwball"= "#FF69B4" | |
) | |
timstuff_plot <- ggplot(rolling_data, aes(x = pitch_number, y = rolling_timstuff, color = pitch_name)) + | |
geom_line(size = 1, na.rm = TRUE) + | |
geom_point(size = 1, na.rm = TRUE) + | |
scale_color_manual(values = pitch_colors) + | |
theme_minimal() + | |
labs(title = if(any(rolling_data$pitch_number >= 5)) "5-Pitch Rolling TimStuff+" else "5-Pitch Rolling TimStuff+", | |
x = "Pitch Number", y = "TimStuff+") + | |
scale_y_continuous(limits = c(70, 130), na.value = NA) + | |
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) | |
) | |
if(nrow(rolling_data) == 0) { | |
timstuff_plot <- ggplot() + | |
theme_void() + | |
labs(title = "No pitch data available") + | |
theme( | |
plot.title = element_text(hjust = 0.5, face = "bold") | |
) | |
} | |
# 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 legend | |
legend <- if(nrow(rolling_data) > 0) { | |
get_legend( | |
ggplot(rolling_data, aes(x = pitch_number, y = rolling_timstuff, color = pitch_name)) + | |
geom_point(size = 5) + | |
scale_color_manual(values = pitch_colors) + | |
theme(legend.position = "bottom", | |
legend.title = element_blank(), | |
text = element_text(size = 12.5), | |
legend.box = "horizontal" ) + | |
guides(color = guide_legend(nrow = 1)) | |
) | |
} else { | |
ggplotGrob(ggplot() + theme_void()) | |
} | |
# NEW LAYOUT: Combine all plots | |
combined <- grid.arrange( | |
# Row 1: Headshot - Title - Team Logo | |
arrangeGrob( | |
img_grob, | |
title_text, | |
mlb_team_logo, | |
ncol = 3, | |
widths = c(1, 2, 1) | |
), | |
# Row 2: TimStuff+ (2/3) and boxscore (1/3) | |
arrangeGrob( | |
timstuff_plot, | |
boxscore_table, | |
ncol = 2, | |
widths = c(2, 1) | |
), | |
# Row 3: Three plots in one row | |
arrangeGrob( | |
pitch_plots$lhb, | |
pitch_plots$rhb, | |
break_plot, | |
ncol = 3, | |
widths = c(1, 1, 1) | |
), | |
# Row 4: Legend | |
legend, | |
# Row 5: Pitch summary table | |
table_plot, | |
# Adjust the relative heights | |
nrow = 5, | |
heights = c(0.8, 0.9, 1.2, 0.2, 1) | |
) | |
# Add padding to the combined plot | |
# padded_plot <- gtable_add_padding(combined, padding = unit(c(30, 30, 30, 30), "points")) # top, right, bottom, left padding | |
# Update the reactive value with the padded plot | |
combinedPlot(combined) | |
# Update the rendering to use the padded plot | |
output$combinedPlot <- renderPlot({ | |
par(mar = c(2, 2, 2, 2)) # Add margins (bottom, left, top, right) | |
grid.draw(combinedPlot()) | |
}, width = 1000, height = 1000, bg = "white") | |
# For download | |
output$downloadPlot <- downloadHandler( | |
filename = function() { | |
paste("baseball_card_", Sys.Date(), ".png", sep = "") | |
}, | |
content = function(file) { | |
ggsave(file, plot = combinedPlot(), | |
width = 15, height = 15, # Increased from 15 | |
dpi = 300, units = "in", | |
device = "png", | |
bg = "white") | |
} | |
) | |
# Render the boxscore table | |
output$boxscoreTable <- renderTable({ | |
boxscore_data() | |
}) | |
}, error = function(e) { | |
showNotification(paste("Error creating plot:", e$message), type = "error") | |
}) | |
}) | |
} | |
# Run the application | |
shinyApp(ui = ui, server = server) |