Download app.R from TimStats/TMSumC: direct link, hf CLI and curl.
- Browser
- Download file 35.4 kB
-
https://huggingface.co/spaces/TimStats/TMSumC/resolve/main/app.R
- Command line
-
hf download hf://spaces/TimStats/TMSumC/app.R
-
curl -L -o app.R https://huggingface.co/spaces/TimStats/TMSumC/resolve/main/app.R
35.4 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(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)) | |
| # 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("25062090") | |
| return(result) | |
| } | |
| } | |
| return(FALSE) | |
| } | |
| model <- xgb.load('TimStuff2.model') | |
| # 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(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 | |
| )) | |
| # Define home plate coordinates (adjusted position) | |
| home_plate <- data.frame( | |
| x = c(0.6, -0.6, -0.7083, 0, 0.7083), # Keep x-coordinates | |
| y = c(0.5, 0.5, 0.25, 0, 0.25) # Adjust y-coordinates to put point at 0 | |
| ) | |
| 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" | |
| ) | |
| # Create left-handed batter plot | |
| plot_lhb <- ggplot(game_lhb, aes(x = -px, y = pz, color = pitch_name)) + | |
| # Add home plate first (so it appears under the points) | |
| geom_polygon(data = home_plate, aes(x = x, y = y), | |
| fill = "white", color = "black", inherit.aes = FALSE) + | |
| # Add batter image (now using left_batter on right side) | |
| 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() | |
| ) | |
| # Create right-handed batter plot | |
| plot_rhb <- ggplot(game_rhb, aes(x = -px, y = pz, color = pitch_name)) + | |
| # Add home plate first | |
| geom_polygon(data = home_plate, aes(x = x, y = y), | |
| fill = "white", color = "black", inherit.aes = FALSE) + | |
| # Add batter image (now using right_batter on left side) | |
| 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) { | |
| # Constants | |
| MOUND_DISTANCE <- 60.5 | |
| STATCAST_INITIAL_MEASUREMENT <- 50.0 | |
| Z_CONSTANT <- 32.174 | |
| HOME_PLATE_HEIGHT <- 17/12 # Convert 17 inches to feet | |
| print("etds") | |
| # Compute release position | |
| df$release_pos_y <- MOUND_DISTANCE - df[[release_extension_col]] | |
| print("etds1") | |
| # Compute release time | |
| 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") | |
| # Compute release velocities | |
| 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") | |
| # Compute time to home plate | |
| df$tf <- (-df$vyR - sqrt(df$vyR^2 - 2 * df[[ay_col]] * (df$release_pos_y - HOME_PLATE_HEIGHT))) / df[[ay_col]] | |
| print("etds3") | |
| # Compute average velocities | |
| 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 | |
| # Compute average velocity magnitude | |
| df$vbar <- sqrt(df$vxbar^2 + df$vybar^2 + df$vzbar^2) | |
| print("etds4") | |
| # Compute drag acceleration | |
| df$adrag <- -(df[[ax_col]] * df$vxbar + | |
| df[[ay_col]] * df$vybar + | |
| (df[[az_col]] + Z_CONSTANT) * df$vzbar) / df$vbar | |
| # Compute Magnus force components | |
| 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 | |
| # Compute Magnus force magnitude | |
| df$amag <- sqrt(df$amagx^2 + df$amagy^2 + df$amagz^2) | |
| # Original transformation calculations | |
| df$v_mag <- sqrt(df$vxR^2 + df$vyR^2 + df$vzR^2) | |
| # Compute tangential unit vectors | |
| 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") | |
| # Handle arm angle and handedness | |
| 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 | |
| } | |
| # Compute horizontal vector | |
| 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") | |
| # Normalize horizontal vector | |
| 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 | |
| # Compute vertical vector | |
| 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") | |
| # Project accelerations | |
| 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") | |
| # Clean up intermediate columns | |
| 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 { | |
| # Create empty data frame with same structure plus TimStuff | |
| empty_df <- game_complete[0, ] # Get structure from game_complete | |
| empty_df$TimStuff <- numeric() # Add empty TimStuff column | |
| 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)) | |
| } | |
| # Calculate main summary | |
| 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), # Add Top Velo | |
| '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) | |
| # Calculate totals | |
| 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), # Add overall top velo | |
| '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 | |
| ) | |
| # Combine main summary with total row | |
| combined_summary <- bind_rows(main_summary, total_row) %>% | |
| rename( | |
| "Type" = Pitch, "#" = Pitches, "Velo" = `Avg. Velo`, | |
| "Top" = `Top Velo`, # Rename Top Velo to shorter column name | |
| "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) | |
| } | |
| # Modify the UI | |
| 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 { | |
| 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, { | |
| # 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 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)# %>% | |
| # mutate( | |
| # pitch pitch_name %in% c("ChangeUp") ~ "Changeup", | |
| # _name = case_when( | |
| # pitch_name %in% c("Fastball", "FourSeamFastBall") ~ "Four-Seam", | |
| # pitch_name %in% c("TwoSeamFastBall", "OneSeamFastBall", "Sinker") ~ "Sinker", | |
| # pitch_name %in% c("KnuckleCurve") ~ "Knuckle Curve", | |
| # pitch_name %in% c("CutFastBall") ~ "Cutter", | |
| # TRUE ~ pitch_name | |
| # )) | |
| game <- calculate_timstuff(game) | |
| print("Test") | |
| print(game) | |
| print(nrow(game)) | |
| #names(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) { | |
| # For the first k-1 points, use a window size of 1 (i.e., the point itself) | |
| result[i] <- x[i] | |
| } else { | |
| # For k and onwards, use the specified rolling window | |
| 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) { | |
| # Try to get team logo using the selected team ID | |
| 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)) | |
| } | |
| } | |
| # Fallback to default logo | |
| 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))) | |
| } | |
| } | |
| # Get the logo once and use it twice | |
| 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, # Type | |
| 0.04, # # | |
| 0.08, # Usage% | |
| 0.08, # FP Strike% | |
| 0.05, # Velo | |
| 0.05, # Top | |
| 0.08, # Spin | |
| 0.05, # Ext | |
| 0.05, # IVB | |
| 0.05, # HB | |
| 0.04, # RelH | |
| 0.04, # RelS | |
| 0.06, # VAA | |
| 0.06, # CSW% | |
| 0.06, # Whiff% | |
| 0.06 # Stuff+ | |
| ), "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" | |
| ) | |
| # Create the rolling TimStuff+ graph | |
| 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 there's no valid data, display a message | |
| 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()) | |
| } | |
| # Combine all plots | |
| combined <- grid.arrange( | |
| # Row 1: Title and Player Image | |
| arrangeGrob( | |
| img_grob, | |
| title_text, | |
| logo_grob, | |
| ncol = 3, | |
| widths = c(1, 2, 1) | |
| ), | |
| # Row 2: Rolling TimStuff+ graph and boxscore | |
| arrangeGrob( | |
| timstuff_plot, | |
| break_plot, | |
| ncol = 2, | |
| widths = c(2,1) | |
| ), | |
| # Row 3: Three plots in a row | |
| arrangeGrob( | |
| pitch_plots$lhb, | |
| pitch_plots$rhb, | |
| ncol = 2, | |
| widths = c(1,1) | |
| ), | |
| # Row 4: Legend | |
| legend, | |
| # Row 5: Pitch summary table | |
| table_plot, | |
| # Layout parameters | |
| 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") # top, right, bottom, left margins | |
| ) | |
| ) | |
| combinedPlot(combined) | |
| output$combinedPlot <- renderPlot({ | |
| grid.draw(combinedPlot()) | |
| }, width = 1200, height = 1200) | |
| output$combinedPlot <- renderPlot({ | |
| grid.draw(combinedPlot()) | |
| }, width = 1000, height = 1000) # Modified to be square | |
| # Update the download handler dimensions | |
| 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") # Modified to be square | |
| } | |
| ) | |
| }) | |
| } | |
| # Run the application | |
| shinyApp(ui = ui, server = server) |