| 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(lightgbm) |
| library(httr) |
| library(jpeg) |
| library(zoo) |
| library(gtable) |
| library(xgboost) |
|
|
| options(shiny.maxRequestSize = 10000000 * 1024^2) |
| pdf(file = NULL) |
| Sys.setenv(TZ='EST') |
|
|
| 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)) |
| |
| |
| matching_row <- which(content$data$attributes$email == email) |
| |
| if (length(matching_row) > 0) { |
| |
| patron_status <- content$data$attributes$patron_status[matching_row] |
| |
| if (patron_status == "active_patron") { |
| |
| tier_data <- content$data$relationships$currently_entitled_tiers$data[[matching_row]] |
|
|
| tier_id <- tier_data$id |
| |
| result <- tier_id %in% c("25062090") |
| |
| return(result) |
| } |
| } |
| |
| return(FALSE) |
| } |
|
|
| model <- xgb.load('TimStuff2.model') |
|
|
| |
| 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(tmilb){ |
| tmilb %>% |
| select(Batter,Pitcher,PlayResult,PitchCall,TaggedPitchType,RelSpeed,SpinRate, |
| Extension,PlateLocSide,PlateLocHeight,RelSide,RelHeight,ax0,ay0,az0,vx0, |
| vz0,vy0,pfxx,pfxz,InducedVertBreak,HorzBreak,SpinAxis,PitcherThrows,BatterSide,PitchofPA,VertApprAngle,AutoPitchType) %>% |
| rename( |
| "Batter Name" = Batter, "Pitcher Name" = Pitcher, result = PlayResult, |
| description = PitchCall, pitch_name = TaggedPitchType, start_speed = RelSpeed, |
| spin_rate = SpinRate, extension = Extension, px = PlateLocSide, pz = PlateLocHeight, |
| x0 = RelSide, z0 = RelHeight, ax = ax0, ay = ay0, az = az0, vx0 = vx0, |
| vz0 = vz0, vy0 = vy0, pfxX = pfxx, pfxZ = pfxz, IVB = InducedVertBreak, |
| HB = HorzBreak, spinDirection = SpinAxis, phand = PitcherThrows, |
| bhand = BatterSide, PitchNum = PitchofPA, VAA = VertApprAngle, AutoPitchType = AutoPitchType |
| ) %>% |
| mutate( |
| is_strike_swinging = ifelse(description == "StrikeSwinging", TRUE, FALSE), |
| phand = ifelse(phand == "Right", "R", "L"), |
| bhand = ifelse(bhand == "Right", "R", "L") |
| ) |
| } |
|
|
| break_plot <- function(game){ |
| game <- game %>% mutate( |
| pitch_name = case_when( |
| pitch_name %in% c("Fastball", "FourSeamFastBall") ~ "Four-Seam", |
| pitch_name %in% c("TwoSeamFastBall", "OneSeamFastBall", "Sinker") ~ "Sinker", |
| pitch_name %in% c("ChangeUp") ~ "Changeup", |
| pitch_name %in% c("KnuckleCurve") ~ "Knuckle Curve", |
| pitch_name %in% c("CutFastBall") ~ "Cutter", |
| pitch_name %in% c('Undefined') ~ AutoPitchType, |
| TRUE ~ pitch_name |
| )) |
| pitch_colors <- c( |
| "Four-Seam"= "#FF4136", "Sinker"= "#FF851B", "Cutter"= "#FFDC00", |
| "Changeup"= "#2ECC40", "Slider"= "#0074D9", "Sweeper"= "#ED68ED", |
| "Curveball"= "#B10DC9", "Splitter"= "#01FF70", "Knuckle Curve"= "#85144b", |
| "Slurve"= "#3D9970", "Knuckle Ball"= "#39CCCC", "Two-Seam Fastball"= "#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)) |
| } |
|
|
| pitch_plot <- function(game, title) { |
| game <- game %>% mutate( |
| pitch_name = case_when( |
| pitch_name %in% c("Fastball", "FourSeamFastBall") ~ "Four-Seam", |
| pitch_name %in% c("TwoSeamFastBall", "OneSeamFastBall", "Sinker") ~ "Sinker", |
| pitch_name %in% c("ChangeUp") ~ "Changeup", |
| pitch_name %in% c("KnuckleCurve") ~ "Knuckle Curve", |
| pitch_name %in% c("CutFastBall") ~ "Cutter", |
| pitch_name %in% c('Undefined') ~ AutoPitchType, |
| TRUE ~ pitch_name |
| )) |
| pitch_colors <- c( |
| "Four-Seam"= "#FF4136", "Sinker"= "#FF851B", "Cutter"= "#FFDC00", |
| "Changeup"= "#2ECC40", "Slider"= "#0074D9", "Sweeper"= "#ED68ED", |
| "Curveball"= "#B10DC9", "Splitter"= "#01FF70", "Knuckle Curve"= "#85144b", |
| "Slurve"= "#3D9970", "Knuckle Ball"= "#39CCCC", "Two-Seam Fastball"= "#F012BE", |
| "Eephus"= "#AAAAAA", "Fastball"= "#7FDBFF", "Slow Curve"= "#DDDDDD", |
| "Screwball"= "#FF69B4" |
| ) |
| 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)) + |
| scale_color_manual(values = pitch_colors) + |
| labs(x = NULL, y = NULL, title = title) + |
| 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)) |
| } |
|
|
| 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") %>% mutate( |
| pitch_name = case_when( |
| pitch_name %in% c("Fastball", "FourSeamFastBall") ~ "Four-Seam", |
| pitch_name %in% c("TwoSeamFastBall", "OneSeamFastBall", "Sinker") ~ "Sinker", |
| pitch_name %in% c("ChangeUp") ~ "Changeup", |
| pitch_name %in% c("KnuckleCurve") ~ "Knuckle Curve", |
| pitch_name %in% c("CutFastBall") ~ "Cutter", |
| pitch_name %in% c('Undefined') ~ AutoPitchType, |
| TRUE ~ pitch_name |
| )) |
| game_rhb <- game %>% filter(bhand == "R") %>% mutate( |
| pitch_name = case_when( |
| pitch_name %in% c("Fastball", "FourSeamFastBall") ~ "Four-Seam", |
| pitch_name %in% c("TwoSeamFastBall", "OneSeamFastBall", "Sinker") ~ "Sinker", |
| pitch_name %in% c("ChangeUp") ~ "Changeup", |
| pitch_name %in% c("KnuckleCurve") ~ "Knuckle Curve", |
| pitch_name %in% c("CutFastBall") ~ "Cutter", |
| pitch_name %in% c('Undefined') ~ AutoPitchType, |
| TRUE ~ pitch_name |
| )) |
| |
| |
| 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"= "#FF4136", "Sinker"= "#FF851B", "Cutter"= "#FFDC00", |
| "Changeup"= "#2ECC40", "Slider"= "#0074D9", "Sweeper"= "#ED68ED", |
| "Curveball"= "#B10DC9", "Splitter"= "#01FF70", "Knuckle Curve"= "#85144b", |
| "Slurve"= "#3D9970", "Knuckle Ball"= "#39CCCC", "Two-Seam Fastball"= "#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) |
| } |
|
|
| transform_statcast_data <- function(df, |
| release_extension_col = "extension", |
| vx0_col = "vx0", |
| vy0_col = "vy0", |
| vz0_col = "vz0", |
| ax_col = "ax", |
| ay_col = "ay", |
| az_col = "az", |
| arm_angle_col = NULL, |
| p_throws_col = NULL) { |
| |
| |
| MOUND_DISTANCE <- 60.5 |
| STATCAST_INITIAL_MEASUREMENT <- 50.0 |
| Z_CONSTANT <- 32.174 |
| HOME_PLATE_HEIGHT <- 17/12 |
| print("etds") |
| |
| df$release_pos_y <- MOUND_DISTANCE - df[[release_extension_col]] |
| print("etds1") |
| |
| df$release_time <- (-df[[vy0_col]] - |
| sqrt((df[[vy0_col]]^2) - |
| (2 * df[[ay_col]] * (STATCAST_INITIAL_MEASUREMENT - df$release_pos_y)))) / df[[ay_col]] |
| print("etds23") |
| |
| df$vxR <- df[[vx0_col]] + (df[[ax_col]] * df$release_time) |
| df$vyR <- df[[vy0_col]] + (df[[ay_col]] * df$release_time) |
| df$vzR <- df[[vz0_col]] + (df[[az_col]] * df$release_time) |
| print("etds2") |
| |
| df$tf <- (-df$vyR - sqrt(df$vyR^2 - 2 * df[[ay_col]] * (df$release_pos_y - HOME_PLATE_HEIGHT))) / df[[ay_col]] |
| print("etds3") |
| |
| df$vxbar <- (2 * df$vxR + df[[ax_col]] * df$tf) / 2 |
| df$vybar <- (2 * df$vyR + df[[ay_col]] * df$tf) / 2 |
| df$vzbar <- (2 * df$vzR + df[[az_col]] * df$tf) / 2 |
| |
| |
| df$vbar <- sqrt(df$vxbar^2 + df$vybar^2 + df$vzbar^2) |
| print("etds4") |
| |
| df$adrag <- -(df[[ax_col]] * df$vxbar + |
| df[[ay_col]] * df$vybar + |
| (df[[az_col]] + Z_CONSTANT) * df$vzbar) / df$vbar |
| |
| |
| df$amagx <- df[[ax_col]] + df$adrag * df$vxbar / df$vbar |
| df$amagy <- df[[ay_col]] + df$adrag * df$vybar / df$vbar |
| df$amagz <- df[[az_col]] + df$adrag * df$vzbar / df$vbar + Z_CONSTANT |
| |
| |
| df$amag <- sqrt(df$amagx^2 + df$amagy^2 + df$amagz^2) |
| |
| |
| df$v_mag <- sqrt(df$vxR^2 + df$vyR^2 + df$vzR^2) |
| |
| |
| df$tang_x <- df$vxR / df$v_mag |
| df$tang_y <- df$vyR / df$v_mag |
| df$tang_z <- df$vzR / df$v_mag |
| print("etds5") |
| |
| if (!is.null(arm_angle_col)) { |
| df$handedness_factor <- ifelse(df[[p_throws_col]] == "R", 1, -1) |
| df$temp_x <- cos(df[[arm_angle_col]]) * df$handedness_factor |
| df$temp_y <- 0 |
| df$temp_z <- sin(df[[arm_angle_col]]) |
| } else { |
| df$temp_x <- 0 |
| df$temp_y <- rep(0, nrow(df)) |
| df$temp_z <- 1 |
| } |
| |
| |
| df$horz_x <- df$temp_y * df$tang_z - df$temp_z * df$tang_y |
| df$horz_y <- df$temp_z * df$tang_x - df$temp_x * df$tang_z |
| df$horz_z <- df$temp_x * df$tang_y - df$temp_y * df$tang_x |
| print("etds6") |
| |
| df$horz_mag <- sqrt(df$horz_x^2 + df$horz_y^2 + df$horz_z^2) |
| df$horz_x <- df$horz_x / df$horz_mag |
| df$horz_y <- df$horz_y / df$horz_mag |
| df$horz_z <- df$horz_z / df$horz_mag |
| |
| |
| df$vert_x <- df$tang_y * df$horz_z - df$tang_z * df$horz_y |
| df$vert_y <- df$tang_z * df$horz_x - df$tang_x * df$horz_z |
| df$vert_z <- df$tang_x * df$horz_y - df$tang_y * df$horz_x |
| print("etds7") |
| |
| df$a_tang <- df[[ax_col]] * df$tang_x + df[[ay_col]] * df$tang_y + df[[az_col]] * df$tang_z |
| df$a_horz <- df[[ax_col]] * df$horz_x + df[[ay_col]] * df$horz_y + df[[az_col]] * df$horz_z |
| df$a_vert <- df[[ax_col]] * df$vert_x + df[[ay_col]] * df$vert_y + df[[az_col]] * df$vert_z |
| print("ets") |
| |
| intermediate_cols <- c("temp_x", "temp_y", "temp_z", "handedness_factor", |
| "horz_mag", "horz_x", "horz_y", "horz_z", |
| "vert_x", "vert_y", "vert_z", |
| "tang_x", "tang_y", "tang_z") |
| df <- df[, !names(df) %in% intermediate_cols] |
| |
| return(df) |
| } |
|
|
| calculate_timstuff <- function(game) { |
| game <- game %>% |
| mutate( |
| VAA = calculate_VAA(vz0, ay, az, vy0, 50), |
| EAA = calculate_EAA(extension), |
| SADiff = calculate_SADiff(pfxX, pfxZ, spinDirection), |
| 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 <- if(any(!complete_rows)) { |
| na_rows <- game[!complete_rows, ] |
| na_rows$TimStuff <- NA |
| na_rows |
| } else { |
| |
| empty_df <- game_complete[0, ] |
| empty_df$TimStuff <- numeric() |
| empty_df |
| } |
| |
| if(nrow(game_complete) > 0) { |
| pred_matrix <- as.matrix(game_complete[, feature_vars]) |
| predictions <- predict(model, pred_matrix) |
| game_complete$TimStuff <- scale_TimStuff(predictions, -0.002620635, 0.006021368) |
| } |
| |
| if(nrow(game_complete) > 0 && nrow(game_na) > 0) { |
| game_result <- rbind(game_complete, game_na) |
| } else if(nrow(game_complete) > 0) { |
| game_result <- game_complete |
| } else if(nrow(game_na) > 0) { |
| game_result <- game_na |
| } else { |
| game_result <- game |
| } |
| |
| return(game_result) |
| } |
| summary_table <- function(game) { |
| |
| game <- calculate_timstuff(game) |
| |
| if("TimStuff" %in% colnames(game)) { |
| game <- game %>% |
| mutate(TimStuff = ifelse(TimStuff < 50, NA, TimStuff)) |
| } |
| |
| |
| main_summary <- game %>% |
| mutate( |
| team_fielding_id = ifelse(description %in% c("StrikeCalled", "StrikeSwinging"), 1, 0), |
| swing = ifelse(description %in% c("FoulBall", "StrikeSwinging", "InPlay","FoulBallNotFieldable"), 1, 0), |
| is_strike_swinging = as.integer(is_strike_swinging), |
| pitch_name = case_when( |
| pitch_name %in% c("Fastball", "FourSeamFastBall") ~ "Four-Seam", |
| pitch_name %in% c("TwoSeamFastBall", "OneSeamFastBall", "Sinker") ~ "Sinker", |
| pitch_name %in% c("ChangeUp") ~ "Changeup", |
| pitch_name %in% c("KnuckleCurve") ~ "Knuckle Curve", |
| pitch_name %in% c("CutFastBall") ~ "Cutter", |
| pitch_name %in% c('Undefined') ~ AutoPitchType, |
| TRUE ~ pitch_name |
| ), |
| Pitch = pitch_name, |
| FPS = case_when(description %in% c("StrikeCalled","StrikeSwinging","FoulBallNotFieldable","FoulBall","InPlay") ~ TRUE, TRUE ~ FALSE), |
| ) %>% |
| group_by(Pitch) %>% |
| summarize( |
| Pitches = n(), |
| 'Pitch%' = round(n() / nrow(game) * 100, 1), |
| "FP Strike%" = round(mean(FPS[PitchNum == 1], na.rm = TRUE) * 100, 1), |
| 'Avg. Velo' = round(mean(start_speed, na.rm = TRUE), 1), |
| 'Top Velo' = round(max(start_speed, na.rm = TRUE), 1), |
| 'Spin Rate' = round(mean(spin_rate, na.rm = TRUE), 0), |
| 'Extension' = round(mean(extension, na.rm = TRUE), 1), |
| 'IVB' = round(mean(IVB, na.rm = TRUE), 1), |
| 'HB' = round(mean(HB, na.rm = TRUE), 1), |
| 'RelH' = round(mean(z0, na.rm = TRUE), digits = 1), |
| 'RelS' = round(mean(x0, na.rm = TRUE), digits = 1), |
| 'VAA' = round(mean(VAA, na.rm = TRUE), 1), |
| 'CSW%' = round(sum(team_fielding_id, na.rm = TRUE) / sum(!is.na(team_fielding_id)) * 100, 1), |
| 'Whiff%' = round(sum(is_strike_swinging, na.rm = TRUE) / sum(swing, na.rm = TRUE) * 100, 1), |
| 'TimStuff+' = if("TimStuff" %in% colnames(game)) round(mean(TimStuff, na.rm = TRUE), 0) else NA |
| ) %>% |
| arrange(-Pitches) |
| |
| |
| total_row <- game %>% |
| mutate( |
| team_fielding_id = ifelse(description %in% c("StrikeCalled", "StrikeSwinging"), 1, 0), |
| swing = ifelse(description %in% c("FoulBall", "StrikeSwinging", "InPlay","FoulBallNotFieldable"), 1, 0), |
| is_strike_swinging = as.integer(is_strike_swinging), |
| FPS = case_when(description %in% c("StrikeCalled","StrikeSwinging","FoulBallNotFieldable","FoulBall") ~ TRUE, TRUE ~ FALSE) |
| ) %>% |
| summarize( |
| Pitch = "Total", |
| Pitches = n(), |
| 'Pitch%' = 100, |
| "FP Strike%" = round(mean(FPS[PitchNum == 1], na.rm = TRUE) * 100, 1), |
| 'Avg. Velo' = NA_real_, |
| 'Top Velo' = round(max(start_speed, na.rm = TRUE), 1), |
| 'Spin Rate' = NA_real_, |
| 'Extension' = round(mean(extension, na.rm = TRUE), 1), |
| 'IVB' = NA_real_, |
| 'HB' = NA_real_, |
| 'RelH' = round(mean(z0, na.rm = TRUE), digits = 1), |
| 'RelS' = round(mean(x0, na.rm = TRUE), digits = 1), |
| 'VAA' = round(mean(VAA, na.rm = TRUE), 1), |
| 'CSW%' = round(sum(team_fielding_id, na.rm = TRUE) / sum(!is.na(team_fielding_id)) * 100, 1), |
| 'Whiff%' = round(sum(is_strike_swinging, na.rm = TRUE) / sum(swing, na.rm = TRUE) * 100, 1), |
| 'TimStuff+' = if("TimStuff" %in% colnames(game)) round(mean(TimStuff, na.rm = TRUE), 0) else NA |
| ) |
| |
| |
| combined_summary <- bind_rows(main_summary, total_row) %>% |
| rename( |
| "Type" = Pitch, "#" = Pitches, "Velo" = `Avg. Velo`, |
| "Top" = `Top Velo`, |
| "Spin" = `Spin Rate`, "Ext" = Extension, "Usage%" = `Pitch%` |
| ) %>% |
| mutate( |
| "Usage%" = paste0(`Usage%`, "%"), |
| "Spin" = ifelse(Type == "Total", "-", format(round(as.numeric(Spin)), big.mark = ",")), |
| "Velo" = ifelse(Type == "Total", "-", as.character(Velo)), |
| "IVB" = ifelse(Type == "Total", "-", as.character(IVB)), |
| "HB" = ifelse(Type == "Total", "-", as.character(HB)), |
| `CSW%` = paste0(`CSW%`, "%"), |
| `Whiff%` = paste0(`Whiff%`, "%"), |
| `FP Strike%` = paste0(`FP Strike%`, "%") |
| ) |
| |
| return(combined_summary) |
| } |
|
|
| |
| 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; |
| } |
| ")) |
| ), |
| |
| |
| uiOutput("page") |
| ) |
| ) |
|
|
| |
| server <- function(input, output, session) { |
| credentials <- reactiveValues(logged_in = FALSE) |
| output$page <- renderUI({ |
| if (!credentials$logged_in) { |
| |
| 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 { |
| fluidPage( |
| titlePanel("Trackman Summary Card"), |
| sidebarLayout( |
| sidebarPanel( |
| width = 3, |
| fileInput("incsv", "Input File", accept = c(".csv"), multiple = FALSE), |
| actionButton("update", "Load Pitchers", icon("magnifying-glass"), |
| class = "btn-primary btn-block"), |
| selectizeInput("pitcher", "Pitcher Name:", NULL), |
| numericInput("rollnum", "Rolling Pitch #", c("1")), |
| actionButton("update1", "Make Card", icon("plus"), |
| class = "btn-success btn-block"), |
| downloadButton("downloadPlot", "Download Card", class = "btn-info btn-block") |
| ), |
| mainPanel( |
| div(style = "width: 1000px; height: 1000px; overflow: auto;", |
| plotOutput("combinedPlot", width = "100%", height = "100%") |
| ) |
| ) |
| ) |
| ) |
| } |
| }) |
| observeEvent(input$login, { |
| |
| |
| 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 Hall of Fame tier subscription.", |
| easyClose = TRUE |
| )) |
| } |
| }) |
|
|
| game_data <- reactiveVal() |
| |
| observeEvent(input$update, { |
| req(input$incsv) |
| df <- read.csv(input$incsv$datapath) |
| pname <- pitcher_summary(df) |
| game_data(pname) |
| 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) |
| } |
| }) |
| |
| rolling_timstuff <- reactive({ |
| req(input$update1, game_data()) |
| game <- game_data() %>% filter(`Pitcher Name` == input$pitcher) |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| game <- calculate_timstuff(game) |
| print("Test") |
| print(game) |
| print(nrow(game)) |
| |
| game <- game %>% |
| mutate(TimStuff = ifelse(TimStuff < 40, NA, TimStuff)) |
| |
| custom_rollmean <- function(x, k) { |
| n <- length(x) |
| result <- numeric(n) |
| |
| for (i in 1:n) { |
| if (i < k) { |
| |
| result[i] <- x[i] |
| } else { |
| |
| window <- x[(i-k+1):i] |
| result[i] <- mean(window, na.rm = TRUE) |
| } |
| } |
| |
| return(result) |
| } |
| |
| result <- game %>% |
| group_by(pitch_name) %>% |
| mutate( |
| TimStuff = as.numeric(TimStuff), |
| rolling_timstuff = custom_rollmean(TimStuff, k = input$rollnum), |
| pitch_number = row_number() |
| ) %>% |
| ungroup() |
| |
| return(result) |
| }) |
| |
| combinedPlot <- reactiveVal() |
| |
| observeEvent(input$update1,{ |
| req(game_data()) |
| game <- game_data() %>% filter(`Pitcher Name` == input$pitcher) |
| |
| if(nrow(game) == 0) { |
| showNotification("No data available for the selected pitcher.", type = "warning") |
| return() |
| } |
| get_logo <- function() { |
| if (FALSE) { |
| |
| logo_url <- paste0("https://static.trumedianetworks.com/images/ncaabaseball/teams/", input$teamName, ".png") |
| img_result <- download_and_process_image(logo_url) |
| |
| if (!is.null(img_result)) { |
| return(rasterGrob(img_result$img, interpolate = TRUE)) |
| } |
| } |
| |
| |
| default_url <- "https://pbs.twimg.com/profile_images/1754697221595910144/xziLC-5v_400x400.jpg" |
| img_result <- download_and_process_image(default_url) |
| |
| if (!is.null(img_result)) { |
| return(rasterGrob(img_result$img, interpolate = TRUE)) |
| } else { |
| return(textGrob("Image not available", gp = gpar(col = "red", fontsize = 20))) |
| } |
| } |
| |
| |
| logo_grob <- get_logo() |
| |
| break_plot <- break_plot(game) + theme(legend.position = "none") |
| pitch_plots <- pitch_plot_split(game) |
| |
| table_data <- summary_table(game) |
| num_rows <- nrow(table_data) |
| |
| print(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.07, |
| 0.04, |
| 0.08, |
| 0.08, |
| 0.05, |
| 0.05, |
| 0.08, |
| 0.05, |
| 0.05, |
| 0.05, |
| 0.04, |
| 0.04, |
| 0.06, |
| 0.06, |
| 0.06, |
| 0.06 |
| ), "npc") |
| |
| for(i in seq(2, nrow(table_plot), 2)) { |
| table_plot$grobs[[i]]$gp$fill <- "#f9f9f9" |
| } |
| |
| milb_url <- "https://x.com/TimStats/photo" |
| mlb_url <- "https://pbs.twimg.com/profile_images/1754697221595910144/xziLC-5v_400x400.jpg" |
| 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)) |
| } |
| |
| pitch_colors <- c( |
| "Four-Seam"= "#FF4136", "Sinker"= "#FF851B", "Cutter"= "#FFDC00", |
| "Changeup"= "#2ECC40", "Slider"= "#0074D9", "Sweeper"= "#ED68ED", |
| "Curveball"= "#B10DC9", "Splitter"= "#01FF70", "Knuckle Curve"= "#85144b", |
| "Slurve"= "#3D9970", "Knuckle Ball"= "#39CCCC", "Two-Seam Fastball"= "#F012BE", |
| "Eephus"= "#AAAAAA", "Fastball"= "#7FDBFF", "Slow Curve"= "#DDDDDD", |
| "Screwball"= "#FF69B4" |
| ) |
| |
| |
| rolling_data <- rolling_timstuff() %>% |
| mutate( |
| pitch_name = case_when( |
| pitch_name %in% c("Fastball", "FourSeamFastBall") ~ "Four-Seam", |
| pitch_name %in% c("TwoSeamFastBall", "OneSeamFastBall", "Sinker") ~ "Sinker", |
| pitch_name %in% c("ChangeUp") ~ "Changeup", |
| pitch_name %in% c("KnuckleCurve") ~ "Knuckle Curve", |
| pitch_name %in% c("CutFastBall") ~ "Cutter", |
| pitch_name %in% c('Undefined') ~ AutoPitchType, |
| TRUE ~ pitch_name |
| )) |
| 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 = paste0(input$rollnum,"-Pitch Rolling TimStuff+"), |
| x = "Pitch Number", y = "TimStuff+") + |
| scale_y_continuous(limits = c(70, 130), na.value = NA) + |
| scale_x_continuous(breaks = seq(input$rollnum, max(rolling_data$pitch_number, na.rm = TRUE), by = input$rollnum), limits = c(input$rollnum, 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(all(is.na(rolling_data$rolling_timstuff))) { |
| timstuff_plot <- ggplot() + |
| theme_void() + |
| labs(title = "Insufficient data for Stuff+ calculation") + |
| theme( |
| plot.title = element_text(hjust = 0.5, face = "bold") |
| ) |
| } |
| |
| title_text <- textGrob( |
| paste(input$pitcher, input$title, "Summary Card by @TimStats"), |
| gp = gpar(fontsize = 16, fontface = "bold") |
| ) |
| |
| data_source_text <- textGrob( |
| " ", |
| gp = gpar(fontsize = 8), |
| x = unit(1, "npc") - unit(2, "mm"), |
| y = unit(2, "mm"), |
| just = c("right", "bottom") |
| ) |
| |
| 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()) |
| } |
| |
| |
| combined <- grid.arrange( |
| |
| arrangeGrob( |
| img_grob, |
| title_text, |
| logo_grob, |
| ncol = 3, |
| widths = c(1, 2, 1) |
| ), |
| |
| |
| arrangeGrob( |
| timstuff_plot, |
| break_plot, |
| ncol = 2, |
| widths = c(2,1) |
| ), |
| |
| |
| arrangeGrob( |
| pitch_plots$lhb, |
| pitch_plots$rhb, |
| ncol = 2, |
| widths = c(1,1) |
| ), |
| |
| |
| legend, |
| |
| |
| table_plot, |
| |
| |
| nrow = 5, |
| heights = c(0.8, 1.2, 1.2, 0.2, 1) |
| ) |
| grid.arrange( |
| gtable_add_padding( |
| combined, |
| padding = unit(c(20, 20, 20, 20), "points") |
| ) |
| ) |
| combinedPlot(combined) |
| |
| output$combinedPlot <- renderPlot({ |
| grid.draw(combinedPlot()) |
| }, width = 1200, height = 1200) |
| |
| output$combinedPlot <- renderPlot({ |
| grid.draw(combinedPlot()) |
| }, width = 1000, height = 1000) |
| |
| |
| output$downloadPlot <- downloadHandler( |
| filename = function() { |
| paste("baseball_card_", Sys.Date(), ".png", sep = "") |
| }, |
| content = function(file) { |
| ggsave(file, plot = combinedPlot(), width = 15, height = 15, dpi = 300, units = "in") |
| } |
| ) |
| }) |
| } |
|
|
| |
| shinyApp(ui = ui, server = server) |