#Read in libraries (make sure these are installed in the Docker file) library(shiny) library(brms) library(tidyverse) library(DT) library(bayesplot) library(httr) library(arrow) library(base64enc) #Read in HH Logo logo_b64 <- base64enc::base64encode("HHLogo.png") logo_uri <- paste0("data:image/png;base64,", logo_b64) #Get passoword from app secrets HF_PASSWORD <- Sys.getenv('APP_PASSWORD') # ============================================================ #Functions to read in private datasets and models # ============================================================ download_private_parquet <- function(repo_id, filename, max_retries = 3) { url <- paste0("https://huggingface.co/datasets/", repo_id, "/resolve/main/", filename) api_key <- Sys.getenv("HF_PitSuccess_Dataset_Token") if (api_key == "") stop("API key is not set.") for (attempt in 1:max_retries) { tryCatch({ response <- GET(url, add_headers(Authorization = paste("Bearer", api_key)), timeout(60)) if (status_code(response) == 200) { tmp <- tempfile(fileext = ".parquet") writeBin(content(response, "raw"), tmp) return(read_parquet(tmp)) } else warning(paste("Attempt", attempt, "failed with status:", status_code(response))) }, error = function(e) { warning(paste("Attempt", attempt, "failed:", e$message)) if (attempt < max_retries) Sys.sleep(2) }) } stop(paste("Failed to download dataset after", max_retries, "attempts")) } download_private_rds <- function(repo_id, filename, max_retries = 3) { url <- paste0("https://huggingface.co/datasets/", repo_id, "/resolve/main/", filename) api_key <- Sys.getenv("HF_PitSuccess_Dataset_Token") if (api_key == "") stop("API key is not set.") for (attempt in 1:max_retries) { tryCatch({ response <- GET(url, add_headers(Authorization = paste("Bearer", api_key)), timeout(60)) if (status_code(response) == 200) { tmp <- tempfile(fileext = ".rds") writeBin(content(response, "raw"), tmp) return(readRDS(tmp)) } else warning(paste("Attempt", attempt, "failed:", e$message)) }, error = function(e) { warning(paste("Attempt", attempt, "failed:", e$message)) if (attempt < max_retries) Sys.sleep(2) }) } stop(paste("Failed to download dataset after", max_retries, "attempts")) } #Read in the pitching model and the full dataframe with results cape_model_pitching <- download_private_rds("HyannisHarborHawksCCBL/PitcherSuccessDataset", "PitchingModel.rds") college_26_results <- download_private_parquet("HyannisHarborHawksCCBL/PitcherSuccessDataset", "college_26_results.parquet") # ============================================================ #App UI # ============================================================ ui <- fluidPage( tags$head(tags$style(HTML(" body { font-family: 'Helvetica Neue', sans-serif; background: #f8f8f8; } .navbar { background: #1B3A5C; } .navbar-default .navbar-brand { color: white; font-weight: 600; } .navbar-default .navbar-nav > li > a { color: #ccc; } .navbar-default .navbar-nav > .active > a { background: #2a5a8c; color: white; } .card { background: white; border-radius: 8px; padding: 20px; margin-bottom: 15px; border: 1px solid #e0e0e0; } .metric-box { text-align: center; padding: 15px; } .metric-value { font-size: 28px; font-weight: 600; color: #1B3A5C; } .metric-label { font-size: 12px; color: #888; text-transform: uppercase; letter-spacing: 1px; } /* Login screen */ #login_screen { position: fixed; top: 0; left: 0; right: 0; bottom: 0; z-index: 9999; display: flex; flex-direction: column; align-items: center; justify-content: center; background: linear-gradient(135deg, #0d1f33 0%, #1B3A5C 40%, #2a5a8c 100%); transition: opacity 0.8s ease, transform 0.8s ease; } #login_screen.fade-out { opacity: 0; transform: scale(1.02); pointer-events: none; } #login_screen .login-title { font-size: 36px; font-weight: 800; color: white; letter-spacing: 1px; margin-bottom: 8px; text-align: center; } #login_screen .login-subtitle { font-size: 14px; color: rgba(255,255,255,0.5); letter-spacing: 2px; text-transform: uppercase; margin-bottom: 36px; } #login_screen .login-box { background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.15); border-radius: 12px; padding: 32px 40px; backdrop-filter: blur(10px); width: 340px; text-align: center; } #login_screen .login-box input[type='password'] { width: 100%; padding: 12px 16px; border: 1px solid rgba(255,255,255,0.2); border-radius: 6px; background: rgba(255,255,255,0.08); color: white; font-size: 15px; margin-bottom: 16px; outline: none; text-align: center; letter-spacing: 2px; transition: border-color 0.3s; } #login_screen .login-box input[type='password']:focus { border-color: #4a9eff; } #login_screen .login-box input[type='password']::placeholder { color: rgba(255,255,255,0.3); letter-spacing: 1px; } #login_screen .login-btn { width: 100%; padding: 12px; border: none; border-radius: 6px; background: #2a5a8c; color: white; font-size: 14px; font-weight: 700; letter-spacing: 1px; text-transform: uppercase; cursor: pointer; transition: background 0.3s; } #login_screen .login-btn:hover { background: #3a7abc; } #login_screen .login-error { color: #ff6b6b; font-size: 13px; margin-top: 12px; min-height: 20px; } "))), # Login overlay tags$div(id = "login_screen", tags$img(class = "login-logo", src = logo_uri), tags$div(class = "login-title", "Cape Cod Pitching Projections"), tags$div(class = "login-subtitle", "Hyannis Harbor Hawks"), tags$div(class = "login-box", tags$input(id = "login_pw", type = "password", placeholder = "Enter password"), tags$button(id = "login_btn", class = "login-btn", "Enter"), tags$div(id = "login_error", class = "login-error") ) ), # Login JS tags$script(HTML(paste0(" var APP_PASSWORD = '", HF_PASSWORD, "'; function tryLogin() { var pw = document.getElementById('login_pw').value; if (pw === APP_PASSWORD) { document.getElementById('login_screen').classList.add('fade-out'); setTimeout(function() { document.getElementById('login_screen').style.display = 'none'; }, 800); } else { document.getElementById('login_error').textContent = 'Incorrect password'; document.getElementById('login_pw').value = ''; document.getElementById('login_pw').style.borderColor = '#ff6b6b'; setTimeout(function() { document.getElementById('login_error').textContent = ''; document.getElementById('login_pw').style.borderColor = 'rgba(255,255,255,0.2)'; }, 2000); } } document.addEventListener('DOMContentLoaded', function() { document.getElementById('login_btn').addEventListener('click', tryLogin); document.getElementById('login_pw').addEventListener('keydown', function(e) { if (e.key === 'Enter') tryLogin(); }); }); "))), navbarPage( title = "Cape Cod Pitching Projections", #Leaderboard tab -- Filterable table of all projected pitchers with summary metrics and sorting options tabPanel("Leaderboard", fluidRow( column(3, div(class = "card", selectInput("league_filter", "Conference:", choices = c("All", sort(unique(college_26_results$PitcherLeague_college))), selected = "All"), selectInput("throws_filter", "Throws:", choices = c("All", "Right", "Left"), selected = "All"), sliderInput("bf_filter", "Min College BF:", min = 0, max = 300, value = 30, step = 10), selectInput("sort_by", "Sort by:", choices = c("Predicted xwOBA" = "pred_xwOBA", "Uncertainty" = "pred_uncertainty", "College BF" = "BF_college"), selected = "pred_xwOBA"), checkboxInput("sort_asc", "Ascending", value = TRUE) ) ), column(9, div(class = "card", fluidRow( column(3, div(class = "metric-box", textOutput("n_pitchers_text") %>% tagAppendAttributes(class = "metric-value"), div(class = "metric-label", "Pitchers") )), column(3, div(class = "metric-box", textOutput("avg_pred_text") %>% tagAppendAttributes(class = "metric-value"), div(class = "metric-label", "Avg Predicted xwOBA") )), column(3, div(class = "metric-box", textOutput("best_pred_text") %>% tagAppendAttributes(class = "metric-value"), div(class = "metric-label", "Best Predicted") )), column(3, div(class = "metric-box", textOutput("avg_unc_text") %>% tagAppendAttributes(class = "metric-value"), div(class = "metric-label", "Avg Uncertainty") )) ) ), div(class = "card", DTOutput("leaderboard_table") ) ) ) ), #Breakdown tab -- Individual pitcher deep dive with waterfall contributions, posterior distributions, and peer comparisons tabPanel("Player Breakdown", fluidRow( column(3, div(class = "card", selectizeInput("player_select", "Select Pitcher:", choices = sort(unique(college_26_results$Pitcher)), selected = NULL, options = list(placeholder = "Type a name...")), hr(), uiOutput("player_summary") ) ), column(9, fluidRow( column(6, div(class = "card", plotOutput("waterfall_plot", height = "450px"))), column(6, div(class = "card", plotOutput("uncertainty_plot", height = "450px"))) ), fluidRow( column(6, div(class = "card", plotOutput("posterior_dist_plot", height = "350px"))), column(6, div(class = "card", plotOutput("comparison_plot", height = "350px"))) ) ) ) ) ) ) # ============================================================ #App Server # ============================================================ server <- function(input, output, session) { filtered_data <- reactive({ df <- college_26_results #Filters to filter by college league, handedness, BF, and sorts if (input$league_filter != "All") { df <- df %>% filter(PitcherLeague_college == input$league_filter) } if (input$throws_filter != "All") { df <- df %>% filter(PitcherThrows_college == input$throws_filter) } df <- df %>% filter(BF_college >= input$bf_filter) if (input$sort_asc) { df <- df %>% arrange(.data[[input$sort_by]]) } else { df <- df %>% arrange(desc(.data[[input$sort_by]])) } df }) #Summary stats output$n_pitchers_text <- renderText(nrow(filtered_data())) output$avg_pred_text <- renderText(round(mean(filtered_data()$pred_xwOBA, na.rm = TRUE), 3)) output$best_pred_text <- renderText(round(min(filtered_data()$pred_xwOBA, na.rm = TRUE), 3)) output$avg_unc_text <- renderText(round(mean(filtered_data()$pred_uncertainty, na.rm = TRUE), 3)) #Returns leaderboard table output$leaderboard_table <- renderDT({ filtered_data() %>% mutate(rank = row_number()) %>% select(rank, Pitcher, PitcherThrows_college, PitcherLeague_college, BF_college, pred_xwOBA, pred_lower, pred_upper, pred_uncertainty) %>% mutate(across(where(is.numeric) & !c(rank, BF_college), ~round(.x, 3))) %>% datatable( colnames = c("Rank", "Pitcher", "Throws", "Conference", "College BF", "Pred xwOBA", "Lower", "Upper", "Uncertainty"), selection = "single", options = list(pageLength = 25, scrollX = TRUE), rownames = FALSE ) }) #Player selection observeEvent(input$leaderboard_table_rows_selected, { row <- filtered_data()[input$leaderboard_table_rows_selected, ] updateSelectizeInput(session, "player_select", selected = row$Pitcher) }) #Reactive filter: returns the selected pitcher's row from the results table player_data <- reactive({ req(input$player_select) college_26_results %>% filter(Pitcher == input$player_select) }) output$player_summary <- renderUI({ obs <- player_data() if (nrow(obs) == 0) return(NULL) #Compute observation-level sigma from the distributional submodel fixed effects (get player uncertainty) sigma_fe <- fixef(cape_model_pitching, dpar = "sigma") log_sigma <- sigma_fe["sigma_Intercept", "Estimate"] + sigma_fe["sigma_logBF_cape", "Estimate"] * log(obs$BF_cape) + sigma_fe["sigma_logBF_college", "Estimate"] * log(obs$BF_college) obs_sigma <- exp(log_sigma) #Render the player summary card with prediction, interval, and pitcher info tagList( h4(obs$Pitcher), tags$table(style = "width: 100%; font-size: 14px;", tags$tr(tags$td("Predicted xwOBA"), tags$td(style = "text-align: right; font-weight: 600;", round(obs$pred_xwOBA, 3))), tags$tr(tags$td("95% Interval"), tags$td(style = "text-align: right;", paste0("[", round(obs$pred_lower, 3), ", ", round(obs$pred_upper, 3), "]"))), tags$tr(tags$td("Uncertainty"), tags$td(style = "text-align: right;", round(obs$pred_uncertainty, 3))), tags$tr(tags$td(style = "padding-top: 10px;", "College BF"), tags$td(style = "text-align: right; padding-top: 10px;", obs$BF_college)), tags$tr(tags$td("Throws"), tags$td(style = "text-align: right;", as.character(obs$PitcherThrows_college))), tags$tr(tags$td("Conference"), tags$td(style = "text-align: right;", as.character(obs$PitcherLeague_college))), tags$tr(tags$td(style = "padding-top: 10px;", "Obs Sigma"), tags$td(style = "text-align: right; padding-top: 10px;", round(obs_sigma, 4))) ) ) }) #Predict cape success get_contributions <- reactive({ obs <- player_data() if (nrow(obs) == 0) return(NULL) fe <- fixef(cape_model_pitching) coefs <- fe[, "Estimate"] pred_cols <- c("zone_pct_college", "k_minus_bb_college", "csw_plus_college", "stuff_plus_primary_college", "stuff_plus_secondary_college", "stuff_plus_tertiary_college", "arsenal_depth_college", "chase_pct_college", "iz_whiff_pct_college", "mean_ev_college", "ev_90_college", "ev_sd_college", "gb_pct_college", "xwOBA_R_minus_L_college") contributions <- data.frame( predictor = pred_cols, value = as.numeric(obs[1, pred_cols]), coefficient = as.numeric(coefs[pred_cols]), stringsAsFactors = FALSE ) %>% bind_rows(data.frame( predictor = "logBF_college", value = log(obs$BF_college), coefficient = as.numeric(coefs["logBF_college"]), stringsAsFactors = FALSE )) %>% mutate( contribution = value * coefficient, direction = ifelse(contribution > 0, "increases xwOBA", "decreases xwOBA") ) %>% arrange(desc(abs(contribution))) draws_matrix <- as.matrix(as_draws_df(cape_model_pitching)) contributions <- contributions %>% mutate( coef_sd = sapply(predictor, function(p) { col_name <- paste0("b_", p) if (col_name %in% colnames(draws_matrix)) sd(draws_matrix[, col_name], na.rm = TRUE) else NA }), contribution_sd = abs(value) * coef_sd ) contributions }) #Create plot to show which variables contribute the most to the uncertainty output$waterfall_plot <- renderPlot({ contributions <- get_contributions() if (is.null(contributions)) return(NULL) obs <- player_data() waterfall <- contributions %>% head(10) %>% mutate(predictor = factor(predictor, levels = rev(predictor))) ggplot(waterfall, aes(x = predictor, y = contribution, fill = direction)) + geom_col() + geom_hline(yintercept = 0, linewidth = 0.3) + coord_flip() + scale_fill_manual(values = c("increases xwOBA" = "#B2182B", "decreases xwOBA" = "#2166AC")) + labs(title = "What drives this prediction?", subtitle = paste("Predicted:", round(obs$pred_xwOBA, 3)), x = NULL, y = "Contribution to xwOBA", fill = NULL) + theme_minimal() + theme(legend.position = "bottom") }) output$uncertainty_plot <- renderPlot({ contributions <- get_contributions() if (is.null(contributions)) return(NULL) obs <- player_data() sigma_fe <- fixef(cape_model_pitching, dpar = "sigma") sigma_bf_cape <- sigma_fe["sigma_logBF_cape", "Estimate"] sigma_bf_col <- sigma_fe["sigma_logBF_college", "Estimate"] log_sigma <- sigma_fe["sigma_Intercept", "Estimate"] + sigma_bf_cape * log(obs$BF_cape) + sigma_bf_col * log(obs$BF_college) obs_sigma <- exp(log_sigma) unc_data <- contributions %>% arrange(desc(contribution_sd)) %>% head(8) %>% bind_rows(data.frame( predictor = c("college sample size", "cape sample size"), contribution_sd = c( abs(sigma_bf_col) * abs(log(obs$BF_college) - median(log(college_26_results$BF_college), na.rm = TRUE)) * obs_sigma, abs(sigma_bf_cape) * abs(log(obs$BF_cape) - median(log(college_26_results$BF_cape), na.rm = TRUE)) * obs_sigma ), stringsAsFactors = FALSE )) %>% arrange(desc(contribution_sd)) %>% mutate(predictor = factor(predictor, levels = rev(predictor))) ggplot(unc_data, aes(x = predictor, y = contribution_sd)) + geom_col(fill = "#E8913A") + coord_flip() + labs(title = "Where does uncertainty come from?", subtitle = paste("College BF:", obs$BF_college, "| Sigma:", round(obs_sigma, 4)), x = NULL, y = "Contribution to uncertainty") + theme_minimal() }) #Plot of the player's posteriior distribution (distribution of 95% credible interval of predicted values) output$posterior_dist_plot <- renderPlot({ obs <- player_data() if (nrow(obs) == 0) return(NULL) post_draws <- fitted(cape_model_pitching, newdata = obs, allow_new_levels = TRUE, summary = FALSE) draws_df <- data.frame(xwOBA = as.numeric(post_draws)) ggplot(draws_df, aes(x = xwOBA)) + geom_density(fill = "#2166AC", alpha = 0.4, color = "#2166AC") + geom_vline(xintercept = obs$pred_xwOBA, linewidth = 1, color = "#1B3A5C") + geom_vline(xintercept = obs$pred_lower, linetype = "dashed", color = "#888") + geom_vline(xintercept = obs$pred_upper, linetype = "dashed", color = "#888") + labs(title = "Predicted xwOBA distribution", subtitle = paste0("Median: ", round(obs$pred_xwOBA, 3), " | 95% CI: [", round(obs$pred_lower, 3), ", ", round(obs$pred_upper, 3), "]"), x = "xwOBA", y = NULL) + theme_minimal() + theme(axis.text.y = element_blank()) }) output$comparison_plot <- renderPlot({ obs <- player_data() if (nrow(obs) == 0) return(NULL) ggplot(college_26_results, aes(x = pred_xwOBA)) + geom_histogram(fill = "#cccccc", color = "white", bins = 30) + geom_vline(xintercept = obs$pred_xwOBA, color = "#B2182B", linewidth = 1.2) + annotate("text", x = obs$pred_xwOBA, y = Inf, label = obs$Pitcher, vjust = 2, hjust = -0.1, color = "#B2182B", fontface = "bold", size = 4) + labs(title = "vs. all projected pitchers", x = "Predicted Cape xwOBA", y = "Count") + theme_minimal() }) } shinyApp(ui, server)