library(shiny) library(dplyr) library(ggplot2) library(utils) library(httr) download_private_csv <- function(repo_id, filename) { url <- paste0("https://huggingface.co/datasets/", repo_id, "/resolve/main/", filename) response <- GET(url, add_headers(Authorization = paste("Bearer", Sys.getenv("GETCSV")))) if (status_code(response) == 200) { # Get content as text first to check if it's an LFS pointer content_text <- content(response, "text", encoding = "UTF-8") # Check if this is an LFS pointer (LFS files start with "version https://git-lfs.github.com/spec/") if (grepl("^version https://git-lfs.github.com/spec/", content_text)) { # This is an LFS file - extract the oid (hash) from the pointer oid_line <- grep("oid sha256:", strsplit(content_text, "\n")[[1]], value = TRUE) oid <- gsub("oid sha256:", "", oid_line) oid <- trimws(oid) # Construct the LFS content URL lfs_url <- paste0("https://huggingface.co/datasets/", repo_id, "/resolve/main/.git/lfs/objects/", substr(oid, 1, 2), "/", substr(oid, 3, 4), "/", oid) # Get the actual content from LFS storage lfs_response <- GET(lfs_url, add_headers(Authorization = paste("Bearer", Sys.getenv("GETCSV")))) if (status_code(lfs_response) == 200) { content_text <- content(lfs_response, "text", encoding = "UTF-8") } else { # Alternative LFS URL format lfs_url <- paste0("https://huggingface.co/datasets/", repo_id, "/lfs/resolve/main/", filename, "?download=true") lfs_response <- GET(lfs_url, add_headers(Authorization = paste("Bearer", Sys.getenv("GETCSV")))) if (status_code(lfs_response) == 200) { content_text <- content(lfs_response, "text", encoding = "UTF-8") } else { stop(paste("Failed to download LFS content. Status code:", status_code(lfs_response))) } } } # Process the content (whether it was LFS or regular) con <- textConnection(content_text) tryCatch({ data <- read.csv(con, header = TRUE, check.names = FALSE, fileEncoding = "UTF-8", stringsAsFactors = FALSE) return(data) }, error = function(e) { close(con) stop(paste("Error parsing CSV:", e$message)) }, finally = { close(con) }) } else { stop(paste("Failed to download dataset. Status code:", status_code(response))) } } download_private_parquet <- function(repo_id, filename) { library(httr) library(arrow) # Create the direct download URL based on your example url <- paste0("https://huggingface.co/datasets/", repo_id, "/resolve/main/", filename, "?download=true") # Create a temporary file temp_file <- tempfile(fileext = ".parquet") # Download directly to file response <- GET( url, add_headers(Authorization = paste("Bearer", Sys.getenv("GETCSV"))), write_disk(temp_file, overwrite = TRUE) ) # Check if download was successful if (status_code(response) == 200) { tryCatch({ # Read the parquet file data <- read_parquet(temp_file) file.remove(temp_file) return(data) }, error = function(e) { file.remove(temp_file) stop(paste("Error reading parquet file:", e$message)) }) } else { file.remove(temp_file) stop(paste("Failed to download file. Status code:", status_code(response))) } } MLB26 <- download_private_parquet("TimStats/StatcastDataAll", "MLB26.parquet") MLB26$level <- "MLB" MLB25 <- download_private_parquet("TimStats/StatcastDataAll", "MLB25.parquet") MLB25$level <- "MLB" #names(ST) MLB <- download_private_parquet("TimStats/StatcastDataAll", "MLB.parquet") MLB$level <- "MLB" data1 <- rbind(MLB,MLB25,MLB26) #data1 <- read.csv("data1.csv", header = TRUE, check.names = FALSE, fileEncoding = "UTF-8") #data1 <- data1[,2:54] AAaveragePT <- data1 %>% mutate(arm_angle = round(arm_angle, digits = 0)) %>% group_by(arm_angle, phand, pitch_name) %>% summarise( AvgIVB = mean(IVB, na.rm = TRUE), AvgHB = mean(HB, na.rm = TRUE), .groups = 'drop' ) pitch_type_lookup <- data.frame( pitch_name = c("Changeup", "Curveball", "Cutter", "Eephus", "Forkball", "Four-Seam Fastball", "Knuckle Ball", "Knuckle Curve", "Screwball", "Sinker", "Slider", "Slurve", "Splitter", "Sweeper"), pitch_abbr = c("CH", "CU", "FC", "EP", "FO", "FF", "KN", "KC", "SC", "SI", "SL", "SV", "FS", "ST"), stringsAsFactors = FALSE ) pitch_colors <- c( "FF" = "#FF4136", "SI" = "#FF851B", "FC" = "#FFDC00", "CH" = "#2ECC40", "SL" = "#0074D9", "ST" = "#ED68ED", "CU" = "#B10DC9", "FS" = "#01FF70", "KC" = "#85144b", "SV" = "#3D9970", "KN" = "#39CCCC", "FO" = "#F012BE", "EP" = "#AAAAAA", "FA" = "#7FDBFF", "SC" = "#FF69B4" ) break_plot_Szn <- function(game, data1, sdate, edate) { # Calculate pitch usage percentages game <- game %>% filter(between(as.Date(date), sdate, edate)) total_pitches <- nrow(game) usage_stats <- game %>% group_by(pitch_name) %>% summarise( count = n(), usage = sprintf("%.1f%%", (count/total_pitches) * 100) ) game <- game %>% left_join(pitch_type_lookup, by = c("pitch_name")) %>% left_join(usage_stats, by = "pitch_name") game <- game %>% filter(!is.na(pitch_abbr)) title <- paste0(unique(game$`Pitcher Name`)," ",sdate," to ",edate) pitcher_hand <- unique(game$phand) angle_degrees <- round(mean(game$arm_angle,na.rm = TRUE),digits = 1) angle_radians <- angle_degrees * (pi / 180) if(pitcher_hand == "L") { leg <- c(0.08, .22) factor <- -1 } else { leg <- c(0.92, .22) factor <- 1 } x_end <- 50 * cos(angle_radians) * factor y_end <- 50 * sin(angle_radians) # Create the base color mapping game_pitches <- unique(game$pitch_abbr) used_colors <- pitch_colors[game_pitches] # Add usage to the game data avg_locations <- game %>% group_by(pitch_abbr) %>% summarize( avg_HB = mean(HB, na.rm = TRUE), avg_IVB = mean(IVB, na.rm = TRUE), usage = first(usage) # Keep the usage info ) # Create labels with percentages legend_labels <- paste0(names(used_colors), " (", avg_locations$usage[match(names(used_colors), avg_locations$pitch_abbr)], ")") # Create the color scale with usage labels fill_values <- used_colors names(fill_values) <- legend_labels arm_angle_data <- data1 %>% filter(!is.na(arm_angle), !is.na(phand), !is.na(pitch_name)) %>% filter(abs(round(arm_angle) - angle_degrees) <= 2, phand == pitcher_hand) %>% left_join(pitch_type_lookup, by = c("pitch_name")) %>% filter(!is.na(pitch_abbr)) %>% filter(pitch_abbr %in% game_pitches) ggplot(game, aes(x = HB, y = IVB)) + geom_vline(xintercept = 0, color = "lightblue", linewidth = 1, linetype = 4) + geom_hline(yintercept = 0, color = "lightblue", linewidth = 1, linetype = 4) + # Ellipses stat_ellipse( data = arm_angle_data, aes(fill = paste0(pitch_abbr, " (", avg_locations$usage[match(pitch_abbr, avg_locations$pitch_abbr)], ")")), geom = "polygon", alpha = 0.2, level = 0.68, show.legend = FALSE ) + # Average points geom_point( data = avg_locations, aes(x = avg_HB, y = avg_IVB, fill = paste0(pitch_abbr, " (", usage, ")")), color = "black", size = 6, stroke = .5, shape = 21 ) + # Arm angle line geom_segment(x = 0, y = 0, xend = x_end, yend = y_end, color = "red", linewidth = 1, linetype = 5) + scale_fill_manual(values = fill_values) + labs( x = "Horizontal Break (in)", y = "Induced Vertical Break (in)", title = title, subtitle = paste0("Arm Angle: ", angle_degrees, "\u00b0"), caption = "Data: MLB | Viz: @TimStats | tim-stats.com\nEllipses show average movement for same-handed pitchers at similar arm angles" ) + xlim(-25, 25) + ylim(-25, 25) + theme_minimal() + theme( legend.position = leg, plot.title = element_text(hjust = 0.5, face = "bold", color = "white"), plot.subtitle = element_text(hjust = 0.5, face = "italic", color = "white"), aspect.ratio = 1, plot.background = element_rect(fill = "#333333", color = NA), panel.background = element_rect(fill = "#333333", color = NA), axis.text = element_text(color = "white"), axis.title = element_text(color = "white"), legend.background = element_rect(fill = "#333333"), legend.text = element_text(color = "white"), legend.title = element_blank(), plot.margin = margin(10, 5, 10, 5), axis.line = element_blank(), axis.ticks = element_line(color = "white"), panel.grid = element_blank(), legend.key = element_blank(), plot.caption = element_text( color = "white", hjust = 0.5 ), panel.border = element_blank() ) } break_plot_tot <- function(game, data1, sdate, edate) { # Calculate pitch usage percentages game <- game %>% filter(between(as.Date(date), sdate, edate)) total_pitches <- nrow(game) usage_stats <- game %>% group_by(pitch_name) %>% summarise( count = n(), usage = sprintf("%.1f%%", (count/total_pitches) * 100) ) game <- game %>% left_join(pitch_type_lookup, by = c("pitch_name")) %>% left_join(usage_stats, by = "pitch_name") game <- game %>% filter(!is.na(pitch_abbr)) title <- paste0(unique(game$`Pitcher Name`)," ",sdate," to ",edate) pitcher_hand <- unique(game$phand) angle_degrees <- round(mean(game$arm_angle,na.rm = TRUE),digits = 1) angle_radians <- angle_degrees * (pi / 180) if(pitcher_hand == "L") { leg <- c(0.08, .22) factor <- -1 } else { leg <- c(0.92, .22) factor <- 1 } x_end <- 50 * cos(angle_radians) * factor y_end <- 50 * sin(angle_radians) # Create the base color mapping game_pitches <- unique(game$pitch_abbr) used_colors <- pitch_colors[game_pitches] # Add usage to the game data avg_locations <- game %>% group_by(pitch_abbr) %>% summarize( avg_HB = mean(HB, na.rm = TRUE), avg_IVB = mean(IVB, na.rm = TRUE), usage = first(usage) # Keep the usage info ) # Create labels with percentages legend_labels <- paste0(names(used_colors), " (", avg_locations$usage[match(names(used_colors), avg_locations$pitch_abbr)], ")") # Create the color scale with usage labels fill_values <- used_colors names(fill_values) <- legend_labels arm_angle_data <- data1 %>% filter(!is.na(arm_angle), !is.na(phand), !is.na(pitch_name)) %>% filter(abs(round(arm_angle) - angle_degrees) <= 2, phand == pitcher_hand) %>% left_join(pitch_type_lookup, by = c("pitch_name")) %>% filter(!is.na(pitch_abbr)) %>% filter(pitch_abbr %in% game_pitches) ggplot(game, aes(x = HB, y = IVB)) + geom_vline(xintercept = 0, color = "lightblue", linewidth = 1, linetype = 4) + geom_hline(yintercept = 0, color = "lightblue", linewidth = 1, linetype = 4) + # Ellipses stat_ellipse( data = arm_angle_data, aes(fill = paste0(pitch_abbr, " (", avg_locations$usage[match(pitch_abbr, avg_locations$pitch_abbr)], ")")), geom = "polygon", alpha = 0.2, level = 0.68, show.legend = FALSE ) + # Individual points geom_point( aes(fill = paste0(pitch_abbr, " (", usage, ")")), color = "#c6c6c6", size = 3, stroke = .5, shape = 21 ) + # Average points geom_point( data = avg_locations, aes(x = avg_HB, y = avg_IVB, fill = paste0(pitch_abbr, " (", usage, ")")), color = "black", size = 6, stroke = .5, shape = 21 ) + # Arm angle line geom_segment(x = 0, y = 0, xend = x_end, yend = y_end, color = "red", linewidth = 1, linetype = 5) + scale_fill_manual(values = fill_values) + labs( x = "Horizontal Break (in)", y = "Induced Vertical Break (in)", title = title, subtitle = paste0("Arm Angle: ", angle_degrees, "\u00b0"), caption = "Data: MLB | Viz: @TimStats | tim-stats.com\nEllipses show average movement for same-handed pitchers at similar arm angles" ) + xlim(-25, 25) + ylim(-25, 25) + theme_minimal() + theme( legend.position = leg, plot.title = element_text(hjust = 0.5, face = "bold", color = "white"), plot.subtitle = element_text(hjust = 0.5, face = "italic", color = "white"), aspect.ratio = 1, plot.background = element_rect(fill = "#333333", color = NA), panel.background = element_rect(fill = "#333333", color = NA), axis.text = element_text(color = "white"), axis.title = element_text(color = "white"), legend.background = element_rect(fill = "#333333"), legend.text = element_text(color = "white"), legend.title = element_blank(), plot.margin = margin(10, 5, 10, 5), axis.line = element_blank(), axis.ticks = element_line(color = "white"), panel.grid = element_blank(), legend.key = element_blank(), plot.caption = element_text( color = "white", hjust = 0.5 ), panel.border = element_blank() ) } ui <- fluidPage( # Application title titlePanel("2020-2025 MLB Pitch Plots"), sidebarLayout( sidebarPanel( width = 3, selectInput("player", "Select Player:", choices = unique(data1$`Pitcher Name`), width = "100%"), dateRangeInput("date1", "Dates: (Be wary of multi year plots as arm angles may vary over years)", start = "2026-03-18", end = Sys.Date(), width = "100%"), radioButtons("type", "Plot Type", choices = c("Season Average","All Pitches")), actionButton("submit", "Update Plot", class = "btn btn-primary btn-block", style = "margin-bottom: 10px"), downloadButton("download", "Download Plot", class = "btn btn-success btn-block") ), mainPanel( width = 9, plotOutput("Plot",height = "1200px",width = "100%") ) ) ) # Define server server <- function(input, output, session) { # Create a reactive value to store current plot settings plotSettings <- reactiveVal(list( player = NULL, type = "Season Average", dates = c(as.Date("2024-03-20"), as.Date("2024-10-01")) )) # Update settings only when submit is clicked observeEvent(input$submit, { plotSettings(list( player = input$player, type = input$type, dates = c(input$date1[1], input$date1[2]) )) }) output$Plot <- renderPlot({ settings <- plotSettings() req(settings$player) # Wait until we have a player selected game <- data1 %>% filter(`Pitcher Name` == settings$player) if(nrow(game) == 0) { return(ggplot() + annotate("text", x = 0.5, y = 0.5, label = "No data available for selected date range", color = "white") + theme_void() + theme(plot.background = element_rect(fill = "#333333", color = NA))) } if(settings$type == "Season Average"){ break_plot_Szn(game, data1, settings$dates[1], settings$dates[2]) } else{ break_plot_tot(game, data1, settings$dates[1], settings$dates[2]) } }, width = 750, height = 750, bg = "#333333") output$download <- downloadHandler( filename = function() { settings <- plotSettings() paste0( gsub(" ", "_", settings$player), "_", format(settings$dates[1], "%Y%m%d"), "_to_", format(settings$dates[2], "%Y%m%d"), ".png" ) }, content = function(file) { settings <- plotSettings() game <- data1 %>% filter(`Pitcher Name` == settings$player) plot <- if(settings$type == "Season Average"){ break_plot_Szn(game, data1, settings$dates[1], settings$dates[2]) } else{ break_plot_tot(game, data1, settings$dates[1], settings$dates[2]) } ggsave(file, plot = plot, width = 10, height = 10, dpi = 300, bg = "#333333") } ) } shinyApp(ui = ui, server = server)