| library(shiny) |
| library(bslib) |
| library(shinyWidgets) |
| library(ggplot2) |
| library(MASS) |
| library(viridis) |
| library(dplyr) |
| library(httr) |
| library(patchwork) |
| library(jsonlite) |
| library(arrow) |
|
|
| updatedDate <- Sys.Date() - 1 |
|
|
| 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("25062087", "25062090") |
| |
| return(result) |
| } |
| } |
| |
| return(FALSE) |
| } |
|
|
| 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) { |
| content <- content(response, "text") |
| con <- textConnection(content) |
| |
| |
| data <- read.csv(con, |
| header = TRUE, |
| check.names = FALSE, |
| fileEncoding = "UTF-8", |
| stringsAsFactors = FALSE) |
| close(con) |
| return(data) |
| } else { |
| stop("Failed to download dataset") |
| } |
| } |
|
|
| heatMap <- function(data, gtitle, concen) { |
| custom_colors <- c("#333333","#46363a","#5a383f","#6d3944","#803947","#933948", |
| "#a63848","#b93647","#cb3445","#dd3340","#ee323b","#ff3333") |
| |
| data_clean <- data %>% |
| filter(!is.na(px) & !is.na(pz) & is.finite(px) & is.finite(pz)) |
| |
| h_x <- MASS::bandwidth.nrd(data_clean$px) |
| h_z <- MASS::bandwidth.nrd(data_clean$pz) |
| |
| bandwidth_factor <- concen |
| |
| kde <- kde2d(data_clean$px, data_clean$pz, n = 200, |
| h = c(h_x, h_z) * bandwidth_factor, |
| lims = c(-3, 3, 0, 5)) |
| |
| df <- expand.grid(x = kde$x, y = kde$y) |
| df$density <- as.vector(kde$z) |
| |
| ggplot(df, aes(x = x, y = y, z = density)) + |
| geom_contour_filled(bins = length(custom_colors) - 1) + |
| scale_fill_manual(values = custom_colors) + |
| coord_cartesian(xlim = c(-3,3), ylim = c(0,5)) + |
| coord_fixed(ratio = 1) + |
| labs(title = gtitle) + |
| theme_void() + |
| geom_segment(aes(x = -0.71, xend = 0.71, y = 1.5, yend = 1.5), colour = "black") + |
| geom_segment(aes(x = -0.71, xend = 0.71, y = 3.6, yend = 3.6), colour = "black") + |
| geom_segment(aes(x = 0.71, xend = 0.71, y = 1.5, yend = 3.6), colour = "black") + |
| geom_segment(aes(x = -0.71, xend = -0.71, y = 1.5, yend = 3.6), colour = "black") + |
| theme( |
| legend.position = "none", |
| plot.background = element_rect(fill = "#333333", color = NA), |
| panel.background = element_rect(fill = "#333333", color = NA), |
| text = element_text(color = "white"), |
| panel.grid = element_blank(), |
| plot.title = element_text(hjust = 0.5) |
| ) |
| } |
|
|
| download_private_parquet <- function(repo_id, filename) { |
| library(httr) |
| library(arrow) |
| |
| |
| url <- paste0("https://huggingface.co/datasets/", repo_id, "/resolve/main/", filename, "?download=true") |
| |
| |
| temp_file <- tempfile(fileext = ".parquet") |
| |
| |
| response <- GET( |
| url, |
| add_headers(Authorization = paste("Bearer", Sys.getenv("GETCSV"))), |
| write_disk(temp_file, overwrite = TRUE) |
| ) |
| |
| |
| if (status_code(response) == 200) { |
| tryCatch({ |
| |
| 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))) |
| } |
| } |
|
|
| |
|
|
| 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) |
| } |
|
|
| apply_percentile_calcs <- function(data) { |
| |
| percent_rank_cols <- c("Z-Con%", "Z-Swing%", "O-Con%", "Avg EV", "Max EV", "EV90", "Barrel%", "Swing%", "wOBA", |
| "wOBACON","xwOBA","xDamage") |
| |
| |
| inverse_percent_rank_cols <- c("Chase%", "Whiff%", "stdev(LA)", "SwStr%") |
| |
| |
| percentile_list <- list() |
| |
| |
| for(col in percent_rank_cols) { |
| percentile_list[[col]] <- data.frame( |
| `Batter Name` = data[["Batter Name"]], |
| `Batter ID` = data[["Batter ID"]], |
| metric = col, |
| percentile = round(percent_rank(data[[col]]) * 100), |
| value = data[[col]], |
| stringsAsFactors = FALSE |
| ) |
| } |
| |
| |
| for(col in inverse_percent_rank_cols) { |
| percentile_list[[col]] <- data.frame( |
| `Batter Name` = data[["Batter Name"]], |
| `Batter ID` = data[["Batter ID"]], |
| metric = col, |
| percentile = round((1 - percent_rank(data[[col]])) * 100), |
| value = data[[col]], |
| stringsAsFactors = FALSE |
| ) |
| } |
| |
| |
| result <- do.call(rbind, percentile_list) |
| |
| |
| rownames(result) <- NULL |
| |
| return(result) |
| } |
|
|
| MLB25 <- download_private_parquet("TimStats/StatcastDataAll", "MLB25.parquet") |
| MLB25$level <- "MLB" |
| AAA25 <- download_private_parquet("TimStats/StatcastDataAll", "AAA25.parquet") |
| AAA25$level <- "AAA" |
| FSL25 <- download_private_parquet("TimStats/StatcastDataAll", "FSL25.parquet") |
| FSL25$level <- "FSL" |
|
|
| MLB26 <- download_private_parquet("TimStats/StatcastDataAll", "MLB26.parquet") |
| MLB26$level <- "MLB" |
| AAA26 <- download_private_parquet("TimStats/StatcastDataAll", "AAA26.parquet") |
| AAA26$level <- "AAA" |
| FSL26 <- download_private_parquet("TimStats/StatcastDataAll", "FSL26.parquet") |
| FSL26$level <- "FSL" |
|
|
| MLB <- download_private_parquet("TimStats/StatcastDataAll", "MLB.parquet") |
| MLB$level <- "MLB" |
| AAA <- download_private_parquet("TimStats/StatcastDataAll", "AAA.parquet") |
| AAA$level <- "AAA" |
| FSLAll <- download_private_parquet("TimStats/StatcastDataAll", "FSL.parquet") |
| FSLAll$level <- "FSL" |
|
|
| MLB <- rbind(MLB, MLB25, MLB26) |
| print("aaa") |
| AAA <- rbind(AAA, AAA25, AAA26) |
| print("fsl") |
| FSL <- rbind(FSLAll, FSL25, FSL26) |
|
|
|
|
| ui <- page_fluid( |
| theme = bs_theme(bg = "#333333", fg = "#ffffff", primary = "#428bca"), |
| uiOutput("page_content") |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| ) |
|
|
| |
| server <- function(input, output, session) { |
| is_authenticated <- reactiveVal(FALSE) |
| |
| |
| output$page_content <- renderUI({ |
| |
| if (FALSE) { |
| |
| card( |
| card_header("Authentication Required"), |
| card_body( |
| textInput("email", "Enter your Patreon email:"), |
| actionButton("check_access", "Access App", class = "btn-primary"), |
| textOutput("auth_message") |
| ) |
| ) |
| } else { |
| |
| card( |
| card_header("2020-2024 Filterable Pitcher Heatmaps"), |
| card( |
| layout_columns( |
| col_widths = c(4, 4, 4), |
| card( |
| selectInput("league", "Select League:", choices = c("MLB","AAA","FSL")), |
| selectInput("player", "Select Player:", choices = c("")), |
| sliderInput("concen", "Heatmap Bandwidth Factor", value = .75, step = 0.05, min = .5, max = 1.5) |
| ), |
| card( |
| selectInput("hand1", "Batter Handedness", choices = c("All Batters","LHH","RHH")), |
| selectInput("pitch1", "Pitch:", choices = c("")), |
| dateRangeInput("date1", "Date Range:", start = "2026-03-15", end = updatedDate) |
| ), |
| card( |
| selectInput("hand2", "Batter Handedness", choices = c("All Batters","LHH","RHH")), |
| selectInput("pitch2", "Pitch:", choices = c("")), |
| dateRangeInput("date2", "Date Range:", start = "2026-03-15", end = updatedDate) |
| ) |
| ) |
| ), |
| card( |
| downloadButton("download", "Download Plot", class = "btn-primary mb-3"), |
| plotOutput("combined_graph", height = "600px") |
| ) |
| ) |
| } |
| }) |
| |
| |
| observeEvent(input$check_access, { |
| |
| if (check_patreon_access(input$email)) { |
| is_authenticated(TRUE) |
| } else { |
| output$auth_message <- renderText({ |
| "Access denied. Please ensure you are an active Veteran or Hall of Fame tier patron." |
| }) |
| } |
| }) |
| |
| pname <- reactiveVal() |
| |
| plot_data <- reactive({ |
| t <- pname() |
| |
| |
| filter_data <- function(hand, pitch, date_range) { |
| data <- if(hand == "All Batters") { |
| t %>% filter(`Pitcher Name` == input$player) |
| } else if(hand == "LHH") { |
| t %>% filter(bside == "L", `Pitcher Name` == input$player) |
| } else { |
| t %>% filter(bside == "R", `Pitcher Name` == input$player) |
| } |
| |
| data %>% |
| filter(pitch_name == pitch) %>% |
| filter(between(as.Date(date), date_range[1], date_range[2])) |
| } |
| |
| graph1_data <- filter_data(input$hand1, input$pitch1, input$date1) |
| graph2_data <- filter_data(input$hand2, input$pitch2, input$date2) |
| |
| title1 <- paste0(input$player, " ", input$pitch1, " vs. ", input$hand1, "\n", input$date1[1], " to ", input$date1[2]) |
| title2 <- paste0(input$player, " ", input$pitch2, " vs. ", input$hand2, "\n", input$date2[1], " to ", input$date2[2]) |
| |
| plot1 <- heatMap(graph1_data, title1, input$concen) |
| plot2 <- heatMap(graph2_data, title2, input$concen) |
| |
| plot1 + plot2 + |
| plot_layout(ncol = 2) + |
| plot_annotation( |
| title = paste0(input$player, " Pitch Comparison"), |
| theme = theme( |
| plot.title = element_text(hjust = 0.5, size = 20, color = "white"), |
| plot.background = element_rect(fill = "#333333", color = NA), |
| plot.margin = margin(0, 0, 0, 0) |
| ) |
| ) & |
| theme(plot.margin = margin(0, 0, 0, 0)) |
| }) |
| |
| observeEvent(input$league, { |
| req(input$league) |
| tryCatch({ |
| if(input$league == "MLB"){ |
| t <- mlb |
| } else if(input$league == "AAA"){ |
| t <- aaa |
| } else if(input$league == "FSL"){ |
| t <- fsl |
| } |
| pname(t) |
| updateSelectInput( |
| session = session, |
| inputId = "player", |
| label = "Select Player:", |
| choices = unique(t$`Pitcher Name`), |
| selected = NULL |
| ) |
| }, error = function(e) { |
| message("Error downloading or processing data: ", e$message) |
| }) |
| }) |
| |
| observeEvent(input$player, { |
| req(input$player) |
| tryCatch({ |
| t <- pname() |
| d <- t |> |
| filter(`Pitcher Name` == input$player) |
| updateSelectInput( |
| session = session, |
| inputId = "pitch1", |
| label = "Pitch:", |
| choices = unique(d$`pitch_name`), |
| selected = NULL |
| ) |
| updateSelectInput( |
| session = session, |
| inputId = "pitch2", |
| label = "Pitch:", |
| choices = unique(d$`pitch_name`), |
| selected = NULL |
| ) |
| }, error = function(e) { |
| message("Error processing player data: ", e$message) |
| }) |
| }) |
| |
| output$combined_graph <- renderPlot({ |
| plot_data() |
| }, bg = "#333333", width = 800) |
| |
| output$download <- downloadHandler( |
| filename = function() { |
| paste0(input$player, "_pitch_comparison.png") |
| }, |
| content = function(file) { |
| ggsave(file, plot = plot_data(), width = 12, height = 8, dpi = 300) |
| } |
| ) |
| } |
|
|
| shinyApp(ui = ui, server = server) |