library(shiny) library(dplyr) library(arrow) library(DT) library(ggplot2) library(shinyjs) library(googlesheets4) # ── Google Sheets auth ───────────────────────────────────────────────────────── gs_key <- Sys.getenv("GS_KEY") sheet_id <- Sys.getenv("GS_SHEET_ID") if (nzchar(gs_key)) { tryCatch({ key_file <- tempfile(fileext = ".json") writeLines(gs_key, key_file) gs4_auth(path = key_file) message("Google Sheets auth successful") }, error = function(e) { message("Google Sheets auth failed: ", e$message) }) } # ── Load functions ───────────────────────────────────────────────────────────── load_data <- function(level) { if (level == "All") { dplyr::bind_rows(lapply(c("D1","D2","D3","JUCO","NAIA"), function(l) { arrow::read_parquet(paste0("/app/", l, ".parquet")) })) } else { arrow::read_parquet(paste0("/app/", gsub("[^A-Za-z0-9]","_",level), ".parquet")) } } load_pitcher_leaderboard <- function(level) { if (level == "All") { dplyr::bind_rows(lapply(c("D1","D2","D3","JUCO","NAIA"), function(l) { arrow::read_parquet(paste0("/app/", l, "_pitcher_leaderboard.parquet")) })) } else { arrow::read_parquet(paste0("/app/", level, "_pitcher_leaderboard.parquet")) } } load_hitter_leaderboard <- function(level) { if (level == "All") { dplyr::bind_rows(lapply(c("D1","D2","D3","JUCO","NAIA"), function(l) { arrow::read_parquet(paste0("/app/", l, "_hitter_leaderboard.parquet")) })) } else { arrow::read_parquet(paste0("/app/", level, "_hitter_leaderboard.parquet")) } } load_portal <- function() { tryCatch({ df <- arrow::read_parquet("/app/transfer_portal.parquet") names(df) <- tolower(names(df)) df %>% rename(Pitcher = pitcher, Portal = transfer, Throws = throws, HomeState = homestate, ERA = era, FIP = fip, WHIP = whip) %>% distinct(Pitcher, .keep_all = TRUE) }, error = function(e) { message("Portal load failed: ", e$message) tibble(Pitcher=character(), Portal=character(), Throws=character(), HomeState=character(), ERA=numeric(), FIP=numeric(), WHIP=numeric()) }) } load_hitter_portal <- function() { tryCatch({ df <- arrow::read_parquet("/app/hitter_portal.parquet") names(df) <- tolower(names(df)) df %>% rename(Batter=batter, Portal=transfer, Position=position, BatSide=batside, Team=team, BA=ba, OBP=obp, SLG=slg, wOBA=woba, xwOBA=xwoba) %>% distinct(Batter, .keep_all = TRUE) }, error = function(e) { message("Hitter portal load failed: ", e$message) tibble(Batter=character(), Portal=character(), Position=character(), BatSide=character(), Team=character(), BA=numeric(), OBP=numeric(), SLG=numeric(), wOBA=numeric(), xwOBA=numeric()) }) } # ── Pitch colors ─────────────────────────────────────────────────────────────── pitch_colors <- c( "Fastball" = "red", "Sinker" = "orange", "Slider" = "gold", "Sweeper" = "pink", "Curveball" = "blue", "Changeup" = "green3", "Cutter" = "#8B4513", "Splitter" = "mediumpurple3" ) # ── Helpers ──────────────────────────────────────────────────────────────────── format_name <- function(name) { parts <- strsplit(name, ", ")[[1]] if (length(parts) == 2) paste(trimws(parts[2]), trimws(parts[1])) else name } clean_pitch_type <- function(pt) { case_when( pt %in% c("Fastball","FourSeamFastBall","Four-Seam","FourSeam") ~ "Fastball", pt %in% c("Sinker","TwoSeamFastBall","OneSeamFastball") ~ "Sinker", pt == "Cutter" ~ "Cutter", pt %in% c("Curveball","CurveBall") ~ "Curveball", pt == "Slider" ~ "Slider", pt == "Sweeper" ~ "Sweeper", pt %in% c("ChangeUp","Changeup") ~ "Changeup", pt == "Splitter" ~ "Splitter", TRUE ~ NA_character_ ) } # ── Build pitcher table from leaderboard ─────────────────────────────────────── build_pitcher_table <- function(lb, min_pitches, portal_only) { has_date <- "DateAdded" %in% names(lb) && any(!is.na(lb$DateAdded)) latest_date <- if (has_date) max(lb$DateAdded, na.rm = TRUE) else NA_character_ df <- lb %>% filter(Pitches >= min_pitches) %>% mutate( `Plot` = paste0(''), is_new = if (has_date) (!is.na(DateAdded) & DateAdded == latest_date) else FALSE, `Twitter` = paste0('🐦 Search'), `Board` = paste0(''), Name = ifelse(HasSinker, paste0('', Name, ifelse(!is.na(DateAdded) & DateAdded == max(DateAdded[!is.na(DateAdded)]), ' *', ''), ''), paste0('', Name, ifelse(!is.na(DateAdded) & DateAdded == max(DateAdded[!is.na(DateAdded)]), ' *', ''), '')) ) if (portal_only) df <- df %>% filter(Portal == "Yes") df %>% select(Name, Level, Team, Portal, `Plot`, `Twitter`, `Board`, HomeState, Throws, DateAdded, Height, Weight, Pitches, ERA, FIP, WHIP, `FB Velo`, `Zone%`, `Strike%`, `K's`, `BB's`, `K/BB`, `Whiff%`, `Chase%`, `Hard Hit%`, `GB%`, `FB%`, `LD%`, `Stuff+`, `Location+`) %>% arrange(desc(Pitches)) } build_hitter_table <- function(lb, min_pa, portal_only, bat_side, hitter_pos) { has_date <- "DateAdded" %in% names(lb) && any(!is.na(lb$DateAdded)) latest_date <- if (has_date) max(lb$DateAdded, na.rm = TRUE) else NA_character_ df <- lb %>% filter(PA >= min_pa) %>% mutate( is_new = if (has_date) (!is.na(DateAdded) & DateAdded == latest_date) else FALSE, Name = paste0(''), `Twitter` = paste0('🐦 Search'), `Board` = paste0('') ) if (portal_only) df <- df %>% filter(Portal == "Yes") if (bat_side != "All") df <- df %>% filter(BatSide == bat_side) if (hitter_pos != "All") df <- df %>% filter(Position == hitter_pos) df %>% select(Name, Level, Team, Portal, Position, BatSide, `Twitter`, `Board`, HomeState, Height, Weight, DateAdded, PA, BA, OBP, SLG, wOBA, xwOBA, `BB%`, `K%`, `Swing%`, `Contact%`, `IZ Whiff%`, `Chase%`, `Hard Hit%`, `Avg EV`, `GB%`, `FB%`, `LD%`) %>% arrange(desc(PA)) } # ── UI ───────────────────────────────────────────────────────────────────────── ui <- fluidPage( useShinyjs(), tags$head( tags$style(HTML(" body { margin:0; font-family:'Segoe UI',Arial,sans-serif; background:#f5f5f5; } .header { background:#002147; padding:14px 24px; display:flex; align-items:center; justify-content:space-between; } .header h1 { color:white; margin:0; font-size:26px; font-weight:700; } .header p { color:#aabbcc; margin:0; font-size:13px; } .filters { display:flex; gap:24px; align-items:flex-end; padding:16px 24px; background:white; border-bottom:1px solid #e0e0e0; flex-wrap:wrap; } .filter-label { font-weight:600; font-size:11px; text-transform:uppercase; letter-spacing:0.4px; color:#555; margin-bottom:4px; } .main { padding:20px 24px; } .form-control { border:1px solid #d0d0d0; border-radius:4px; } .selectize-input { border:1px solid #d0d0d0 !important; border-radius:4px !important; } .selectize-dropdown { border:1px solid #d0d0d0 !important; } .dataTables_wrapper { background:white; border-radius:8px; padding:16px; box-shadow:0 1px 4px rgba(0,0,0,0.08); } .dataTables_filter input { border:1px solid #d0d0d0; border-radius:4px; padding:4px 8px; } .dataTables_length select { border:1px solid #d0d0d0; border-radius:4px; } table.dataTable thead th { background:#002147 !important; color:white !important; font-weight:600; font-size:12px; text-transform:uppercase; letter-spacing:0.3px; border-bottom:none !important; cursor:pointer; } table.dataTable thead th:hover { background:#003366 !important; } table.dataTable tbody tr { background:white; } table.dataTable tbody tr:nth-child(even) { background:#f7f9fc; } table.dataTable tbody tr:hover { background:#eef2f7 !important; } table.dataTable tbody td { font-size:13px; padding:10px 12px; border-bottom:1px solid #f0f0f0; } table.dataTable tbody td:first-child { font-weight:600; color:#002147; } .plot-btn { background:#002147; color:white; border:none; border-radius:4px; padding:4px 10px; font-size:12px; cursor:pointer; font-weight:600; } .plot-btn:hover { background:#003366; } .plot-btn:hover { background:#003366; } .board-btn { background:#f5c842; color:#002147; border:none; border-radius:4px; padding:4px 10px; font-size:12px; cursor:pointer; font-weight:600; } .board-btn:hover { background:#e0b030; } table.dataTable tbody td a { color:#002147; font-weight:600; font-size:12px; text-decoration:none; background:#e8f0fe; border-radius:4px; padding:3px 8px; display:inline-block; } table.dataTable tbody td a:hover { background:#002147; color:white; } .portal-yes { color:#00840D; font-weight:700; } .portal-no { color:#aaa; font-weight:400; } .checkbox label { font-size:13px; color:#333; font-weight:500; } .checkbox input[type='checkbox'] { margin-right:6px; } .modal-overlay { position:fixed; top:0; left:0; width:100%; height:100%; background:rgba(0,0,0,0.55); z-index:2000; display:flex; align-items:center; justify-content:center; } .modal-box { background:white; border-radius:10px; padding:28px 28px 20px 28px; width:760px; max-width:96vw; position:relative; box-shadow:0 8px 32px rgba(0,0,0,0.25); max-height:92vh; overflow-y:auto; } .modal-close { position:absolute; top:14px; right:18px; font-size:22px; cursor:pointer; color:#888; background:none; border:none; } .modal-close:hover { color:#002147; } .modal-title { font-size:17px; font-weight:700; color:#002147; margin:0 0 4px 0; } .modal-subtitle { font-size:12px; color:#888; margin:0 0 16px 0; } .section-label { font-size:12px; font-weight:700; color:#002147; text-transform:uppercase; letter-spacing:0.4px; margin:16px 0 8px 0; border-top:1px solid #eee; padding-top:14px; } #splash { position:fixed; top:0; left:0; width:100%; height:100%; background:#002147; display:flex; flex-direction:column; align-items:center; justify-content:center; z-index:9999; opacity:1; transition:opacity 0.8s ease; } #splash.fade-out { opacity:0; pointer-events:none; } #splash img { width:140px; animation:logoIn 0.7s ease forwards; opacity:0; } #splash h1 { color:white; font-size:28px; font-weight:700; margin:18px 0 6px 0; animation:textIn 0.7s ease 0.3s forwards; opacity:0; letter-spacing:0.5px; } #splash p { color:#aabbcc; font-size:14px; margin:0; animation:textIn 0.7s ease 0.5s forwards; opacity:0; } #splash .bar-track { width:200px; height:3px; background:rgba(255,255,255,0.2); border-radius:2px; margin-top:28px; animation:textIn 0.7s ease 0.6s forwards; opacity:0; } #splash .bar-fill { height:3px; width:0%; background:white; border-radius:2px; animation:barFill 1.8s ease 0.8s forwards; } @keyframes logoIn { from { opacity:0; transform:translateY(-20px); } to { opacity:1; transform:translateY(0); } } @keyframes textIn { from { opacity:0; transform:translateY(10px); } to { opacity:1; transform:translateY(0); } } @keyframes barFill { from { width:0%; } to { width:100%; } } ")), tags$script(HTML(" $(document).ready(function() { setTimeout(function() { $('#splash').addClass('fade-out'); setTimeout(function() { $('#splash').remove(); }, 800); }, 2800); // pitcher modal $(document).on('click', '.plot-btn', function() { var pitcher = $(this).data('pitcher'); Shiny.setInputValue('selected_pitcher', pitcher, {priority: 'event'}); }); $(document).on('click', '#modal_close_btn', function() { $('#modal_wrapper').hide(); Shiny.setInputValue('modal_closed', Math.random(), {priority: 'event'}); }); $(document).on('click', '#modal_wrapper', function(e) { if ($(e.target).is('#modal_wrapper')) { $('#modal_wrapper').hide(); Shiny.setInputValue('modal_closed', Math.random(), {priority: 'event'}); } }); // hitter modal $(document).on('click', '.hitter-name-btn', function() { var batter = $(this).data('batter'); Shiny.setInputValue('selected_batter', batter, {priority: 'event'}); }); $(document).on('click', '#hitter_modal_close_btn', function() { $('#hitter_modal_wrapper').hide(); Shiny.setInputValue('hitter_modal_closed', Math.random(), {priority: 'event'}); }); $(document).on('click', '#hitter_modal_wrapper', function(e) { if ($(e.target).is('#hitter_modal_wrapper')) { $('#hitter_modal_wrapper').hide(); Shiny.setInputValue('hitter_modal_closed', Math.random(), {priority: 'event'}); } }); // board modal $(document).on('click', '.board-btn', function() { var d = $(this).data(); $('#board_first').val(d.first || ''); $('#board_last').val(d.last || ''); $('#board_school').val(d.team || ''); $('#board_throws').val(d.throws || d.batside || ''); $('#board_height').val(d.height || ''); $('#board_weight').val(d.weight || ''); $('#board_position').val(d.position || ''); $('#board_notes').val(''); $('#board_modal_wrapper').show(); }); $(document).on('click', '#board_modal_close, #board_cancel_btn', function() { $('#board_modal_wrapper').hide(); }); $(document).on('click', '#board_submit_btn', function() { Shiny.setInputValue('board_submit', { first: $('#board_first').val(), last: $('#board_last').val(), school: $('#board_school').val(), position: $('#board_position').val(), height: $('#board_height').val(), weight: $('#board_weight').val(), throws: $('#board_throws').val(), notes: $('#board_notes').val(), added_by: $('#board_added_by').val() }, {priority: 'event'}); }); // update min label Shiny.addCustomMessageHandler('update_label', function(msg) { $('#min_label').text(msg); }); }); ")) ), # splash tags$div(id = "splash", tags$img(src = "Butler-Bulldogs-Logo-1990.png"), tags$h1("Butler Baseball Transfer Portal"), tags$p("Butler Baseball Analytics"), tags$div(class = "bar-track", tags$div(class = "bar-fill")) ), # header tags$div(class = "header", tags$div( tags$h1("Butler Baseball Transfer Portal Dashboard"), tags$p("Butler Baseball Analytics") ), tags$img(src = "Butler-Bulldogs-Logo-1990.png", height = "52px") ), # filters tags$div(class = "filters", tags$div( tags$div("Level", class = "filter-label"), selectInput("level", label = NULL, choices = c("All","D1","D2","D3","JUCO","NAIA"), selected = "All", width = "160px") ), tags$div( tags$div("Position", class = "filter-label"), selectInput("position", label = NULL, choices = c("Pitcher","Hitter"), selected = "Pitcher", width = "160px") ), tags$div( tags$div(id = "min_label", "Min Pitches", class = "filter-label"), numericInput("min_pitches", label = NULL, value = 100, min = 1, max = 2000, step = 25, width = "160px") ), tags$div( tags$div("Portal Only", class = "filter-label"), checkboxInput("portal_only", label = "Show Portal Players Only", value = TRUE) ), shinyjs::hidden( tags$div(id = "state_filter_pitcher", tags$div("Home State", class = "filter-label"), selectInput("pitcher_state", label = NULL, choices = c("All", sort(unique(c( "AL","AK","AZ","AR","CA","CO","CT","DE","FL","GA", "HI","ID","IL","IN","IA","KS","KY","LA","ME","MD", "MA","MI","MN","MS","MO","MT","NE","NV","NH","NJ", "NM","NY","NC","ND","OH","OK","OR","PA","RI","SC", "SD","TN","TX","UT","VT","VA","WA","WV","WI","WY" )))), selected = "All", multiple = TRUE, width = "200px") ) ), shinyjs::hidden( tags$div(id = "state_filter_hitter", tags$div("Home State", class = "filter-label"), selectInput("hitter_state", label = NULL, choices = c("All", sort(unique(c( "AL","AK","AZ","AR","CA","CO","CT","DE","FL","GA", "HI","ID","IL","IN","IA","KS","KY","LA","ME","MD", "MA","MI","MN","MS","MO","MT","NE","NV","NH","NJ", "NM","NY","NC","ND","OH","OK","OR","PA","RI","SC", "SD","TN","TX","UT","VT","VA","WA","WV","WI","WY" )))), selected = "All", multiple = TRUE, width = "200px") ) ), shinyjs::hidden( tags$div(id = "throws_filter", tags$div("Throws", class = "filter-label"), selectInput("throws", label = NULL, choices = c("All","R","L"), selected = "All", width = "100px") ) ), shinyjs::hidden( tags$div(id = "hitter_filters", style = "display:flex; gap:24px;", tags$div( tags$div("Bat Side", class = "filter-label"), selectInput("bat_side", label = NULL, choices = c("All","L","R","S"), selected = "All", width = "120px") ), tags$div( tags$div("Hitter Position", class = "filter-label"), selectInput("hitter_position", label = NULL, choices = c("All","C","1B","2B","3B","SS","LF","CF","RF", "OF","DH","IF","UTL"), selected = "All", width = "120px") ) ) ), tags$div( tags$div("New Players", class = "filter-label"), checkboxInput("new_only", label = "Show New Only", value = FALSE) ) ), # main tags$div(class = "main", uiOutput("main_ui") ), # pitcher modal tags$div( id = "modal_wrapper", class = "modal-overlay", style = "display:none;", tags$div( class = "modal-box", tags$button("✕", id = "modal_close_btn", class = "modal-close"), uiOutput("modal_header"), plotOutput("movement_plot", height = "400px"), tags$div("Pitch Specifications", class = "section-label"), DTOutput("pitch_type_table") ) ), # hitter modal tags$div( id = "hitter_modal_wrapper", class = "modal-overlay", style = "display:none;", tags$div( class = "modal-box", tags$button("✕", id = "hitter_modal_close_btn", class = "modal-close"), uiOutput("hitter_modal_header"), tags$div("vs. Handedness", class = "section-label"), DTOutput("hitter_hand_table"), tags$div("vs. Pitch Type", class = "section-label"), DTOutput("hitter_pitch_table") ) ), tags$div( id = "board_modal_wrapper", class = "modal-overlay", style = "display:none;", tags$div( class = "modal-box", style = "width:480px;", tags$button("✕", id = "board_modal_close", class = "modal-close"), tags$p("Add to Scout Board", class = "modal-title"), tags$div(style = "display:grid; grid-template-columns:1fr 1fr; gap:12px; margin-bottom:12px;", tags$div( tags$div("First Name", class = "filter-label"), textInput("board_first", label = NULL, width = "100%") ), tags$div( tags$div("Last Name", class = "filter-label"), textInput("board_last", label = NULL, width = "100%") ), tags$div( tags$div("School", class = "filter-label"), textInput("board_school", label = NULL, width = "100%") ), tags$div( tags$div("Position", class = "filter-label"), textInput("board_position", label = NULL, value = "P", width = "100%") ), tags$div( tags$div("Height", class = "filter-label"), textInput("board_height", label = NULL, width = "100%") ), tags$div( tags$div("Weight", class = "filter-label"), textInput("board_weight", label = NULL, width = "100%") ), tags$div( tags$div("Throws", class = "filter-label"), textInput("board_throws", label = NULL, width = "100%") ), tags$div( tags$div("Added By", class = "filter-label"), textInput("board_added_by", label = NULL, width = "100%") ) ), tags$div( tags$div("Notes", class = "filter-label"), tags$textarea(id = "board_notes", style = "width:100%; height:80px; border:1px solid #d0d0d0; border-radius:4px; padding:8px; font-family:'Segoe UI',Arial,sans-serif; font-size:13px;", placeholder = "Scouting notes...") ), tags$br(), tags$div(style = "display:flex; gap:10px; justify-content:flex-end;", tags$button("Cancel", id = "board_cancel_btn", style = "background:#f0f0f0; border:none; border-radius:4px; padding:8px 16px; cursor:pointer; font-size:13px;"), tags$button("Submit", id = "board_submit_btn", style = "background:#002147; color:white; border:none; border-radius:4px; padding:8px 16px; cursor:pointer; font-size:13px; font-weight:600;") ), uiOutput("board_confirm_msg") ) ) ) # ── Server ───────────────────────────────────────────────────────────────────── server <- function(input, output, session) { # ── Load leaderboards once ────────────────────────────────────────────────── raw_data <- reactive({ req(input$level) tryCatch({ list( pitcher_lb = load_pitcher_leaderboard(input$level), hitter_lb = load_hitter_leaderboard(input$level), raw_df = load_data(input$level) ) }, error = function(e) { showNotification(paste("Failed to load:", e$message), type = "error") NULL }) }) observeEvent(input$position, { if (input$position == "Pitcher") { session$sendCustomMessage("update_label", "Min Pitches") shinyjs::hide("hitter_filters") shinyjs::hide("state_filter_hitter") shinyjs::show("state_filter_pitcher") shinyjs::show("throws_filter") updateSelectInput(session, "level", selected = "All") updateNumericInput(session, "min_pitches", value = 100) } else { session$sendCustomMessage("update_label", "Min PA") shinyjs::show("hitter_filters") shinyjs::show("state_filter_hitter") shinyjs::hide("state_filter_pitcher") shinyjs::hide("throws_filter") updateSelectInput(session, "level", selected = "All") updateNumericInput(session, "min_pitches", value = 25) } }) observe({ shinyjs::show("state_filter_pitcher") shinyjs::show("throws_filter") }) stats_table <- reactive({ req(raw_data(), input$min_pitches) if (input$position == "Pitcher") { tryCatch({ df <- build_pitcher_table( raw_data()$pitcher_lb, input$min_pitches, input$portal_only ) p_states <- input$pitcher_state if (!is.null(p_states) && !("All" %in% p_states) && length(p_states) > 0) df <- df %>% filter(HomeState %in% p_states) if (!is.null(input$throws) && input$throws != "All") df <- df %>% filter(Throws == input$throws) if (isTRUE(input$new_only) && "DateAdded" %in% names(df) && any(!is.na(df$DateAdded))) { latest_date <- max(df$DateAdded, na.rm = TRUE) df <- df %>% filter(!is.na(DateAdded) & DateAdded == latest_date) } df }, error = function(e) { message("build_pitcher_table failed: ", e$message) NULL }) } else { tryCatch({ df <- build_hitter_table( raw_data()$hitter_lb, input$min_pitches, input$portal_only, input$bat_side, input$hitter_position ) h_states <- input$hitter_state if (!is.null(h_states) && !("All" %in% h_states) && length(h_states) > 0) df <- df %>% filter(HomeState %in% h_states) if (isTRUE(input$new_only) && "DateAdded" %in% names(df) && any(!is.na(df$DateAdded))) { latest_date <- max(df$DateAdded, na.rm = TRUE) df <- df %>% filter(!is.na(DateAdded) & DateAdded == latest_date) } df }, error = function(e) { message("build_hitter_table failed: ", e$message) NULL }) } }) # ── Main UI ───────────────────────────────────────────────────────────────── output$main_ui <- renderUI({ if (input$position == "Pitcher") { tagList( tags$p(id = "loading_msg", "Loading data...", style = "color:#888; font-size:14px; padding:10px 0;"), tags$p( HTML('📊 Click View to see pitch movement  |  Name = throws a sinker/two-seam'), style = "color:#555; font-size:12px; margin:0 0 10px 0; background:#f0f4ff; border-left:3px solid #002147; padding:8px 12px; border-radius:4px;" ), DTOutput("pitcher_table") ) } else { tagList( tags$p(id = "loading_msg", "Loading data...", style = "color:#888; font-size:14px; padding:10px 0;"), tags$p("💡 Click a player's name to view splits vs. handedness and pitch type.", style = "color:#555; font-size:12px; margin:0 0 10px 0; background:#f0f4ff; border-left:3px solid #002147; padding:8px 12px; border-radius:4px;"), DTOutput("hitter_table") ) } }) make_color_ramp <- function(values, low, high, flip = FALSE, colors = c("#E1463E","#CDCD00","#00840D")) { ramp_colors <- if (flip) rev(colors) else colors ramp <- colorRamp(ramp_colors) sapply(values, function(v) { if (is.na(v)) return("#ffffff") norm <- pmax(0, pmin(1, (v - low) / (high - low))) rgb_vals <- ramp(norm) rgb(rgb_vals[1], rgb_vals[2], rgb_vals[3], maxColorValue = 255) }) } # ── Pitcher table ──────────────────────────────────────────────────────────── output$pitcher_table <- renderDT({ req(stats_table(), nrow(stats_table()) > 0) dt <- stats_table() # compute row-level colors col_fbvelo <- make_color_ramp(dt$`FB Velo`, 85, 93) col_zone <- make_color_ramp(dt$`Zone%`, 43, 53) col_strike <- make_color_ramp(dt$`Strike%`, 57, 66) col_kbb <- make_color_ramp(dt$`K/BB`, 1.2, 3.8) col_whiff <- make_color_ramp(dt$`Whiff%`, 17, 31) col_chase <- make_color_ramp(dt$`Chase%`, 19, 29) col_hh <- make_color_ramp(dt$`Hard Hit%`, 26, 43, flip = TRUE) col_gb <- make_color_ramp(dt$`GB%`, 33, 51) col_fb <- make_color_ramp(dt$`FB%`, 21, 34, flip = TRUE) col_ld <- make_color_ramp(dt$`LD%`, 17, 27, flip = TRUE) col_stuff <- make_color_ramp(dt$`Stuff+`, 85, 110) col_loc <- make_color_ramp(dt$`Location+`, 85, 110) col_era <- make_color_ramp(dt$ERA, 3.2, 8.0, flip = TRUE) col_fip <- make_color_ramp(dt$FIP, 3.2, 6.0, flip = TRUE) col_whip <- make_color_ramp(dt$WHIP, 1.05,1.9, flip = TRUE) datatable( dt, rownames = FALSE, escape = FALSE, selection = "none", extensions = "FixedHeader", options = list( pageLength = 25, lengthMenu = c(25, 50, 100), scrollX = TRUE, scrollY = "65vh", scrollCollapse= TRUE, fixedHeader = TRUE, order = list(list(12, "desc")), orderMulti = FALSE, dom = "lfrtip", columnDefs = list( list(className = "dt-left", targets = 0:1), list(className = "dt-center", targets = 2:27), list(orderable = FALSE, targets = c(4, 5, 6)) ), initComplete = JS("function(settings, json) { $('#loading_msg').hide(); }") ) ) %>% formatStyle("FB Velo", backgroundColor = styleEqual(dt$`FB Velo`, col_fbvelo)) %>% formatStyle("Zone%", backgroundColor = styleEqual(dt$`Zone%`, col_zone)) %>% formatStyle("Strike%", backgroundColor = styleEqual(dt$`Strike%`, col_strike)) %>% formatStyle("K/BB", backgroundColor = styleEqual(dt$`K/BB`, col_kbb)) %>% formatStyle("Whiff%", backgroundColor = styleEqual(dt$`Whiff%`, col_whiff)) %>% formatStyle("Chase%", backgroundColor = styleEqual(dt$`Chase%`, col_chase)) %>% formatStyle("Hard Hit%", backgroundColor = styleEqual(dt$`Hard Hit%`, col_hh)) %>% formatStyle("GB%", backgroundColor = styleEqual(dt$`GB%`, col_gb)) %>% formatStyle("FB%", backgroundColor = styleEqual(dt$`FB%`, col_fb)) %>% formatStyle("LD%", backgroundColor = styleEqual(dt$`LD%`, col_ld)) %>% formatStyle("Stuff+", backgroundColor = styleEqual(dt$`Stuff+`, col_stuff)) %>% formatStyle("Location+", backgroundColor = styleEqual(dt$`Location+`, col_loc)) %>% formatStyle("ERA", backgroundColor = styleEqual(dt$ERA, col_era)) %>% formatStyle("FIP", backgroundColor = styleEqual(dt$FIP, col_fip)) %>% formatStyle("WHIP", backgroundColor = styleEqual(dt$WHIP, col_whip)) %>% formatStyle( c("FB Velo","Zone%","Strike%","K/BB","Whiff%","Chase%","Hard Hit%", "GB%","FB%","LD%","Stuff+","Location+","ERA","FIP","WHIP", "Pitches","Height","Weight"), fontWeight = "bold" ) %>% formatStyle("Portal", color = styleEqual(c("Yes","No"), c("#00840D","#aaaaaa")), fontWeight = styleEqual(c("Yes","No"), c("700","400")) ) }) # ── Hitter table ───────────────────────────────────────────────────────────── output$hitter_table <- renderDT({ req(stats_table(), nrow(stats_table()) > 0) dt <- stats_table() col_ba <- make_color_ramp(dt$BA, 0.237, 0.347) col_obp <- make_color_ramp(dt$OBP, 0.336, 0.454) col_slg <- make_color_ramp(dt$SLG, 0.338, 0.600) col_bb <- make_color_ramp(dt$`BB%`, 6.7, 16.0) col_k <- make_color_ramp(dt$`K%`, 10.5, 26.0, flip = TRUE) col_swing <- make_color_ramp(dt$`Swing%`, 36.0, 50.0) col_contact <- make_color_ramp(dt$`Contact%`, 69.0, 87.0) col_izwhiff <- make_color_ramp(dt$`IZ Whiff%`, 8.0, 23.0, flip = TRUE) col_chase <- make_color_ramp(dt$`Chase%`, 16.0, 31.0, flip = TRUE) col_hh <- make_color_ramp(dt$`Hard Hit%`, 18.0, 51.0) col_ev <- make_color_ramp(dt$`Avg EV`, 80.0, 91.0) col_gb <- make_color_ramp(dt$`GB%`, 31.0, 52.0, flip = TRUE) col_fb <- make_color_ramp(dt$`FB%`, 20.0, 37.0) col_ld <- make_color_ramp(dt$`LD%`, 17.0, 28.0) col_woba <- make_color_ramp(dt$wOBA, 0.203, 0.426) col_xwoba <- make_color_ramp(dt$xwOBA, 0.222, 0.412) datatable( dt, rownames = FALSE, escape = FALSE, selection = "none", extensions = "FixedHeader", options = list( pageLength = 25, lengthMenu = c(25, 50, 100), scrollX = TRUE, scrollY = "65vh", scrollCollapse= TRUE, fixedHeader = TRUE, order = list(list(12, "desc")), orderMulti = FALSE, dom = "lfrtip", columnDefs = list( list(className = "dt-left", targets = 0:1), list(className = "dt-center", targets = 2:27), list(orderable = FALSE, targets = c(6, 7)) ), initComplete = JS("function(settings, json) { $('#loading_msg').hide(); }") ) ) %>% formatStyle("BA", backgroundColor = styleEqual(dt$BA, col_ba)) %>% formatStyle("OBP", backgroundColor = styleEqual(dt$OBP, col_obp)) %>% formatStyle("SLG", backgroundColor = styleEqual(dt$SLG, col_slg)) %>% formatStyle("BB%", backgroundColor = styleEqual(dt$`BB%`, col_bb)) %>% formatStyle("K%", backgroundColor = styleEqual(dt$`K%`, col_k)) %>% formatStyle("Swing%", backgroundColor = styleEqual(dt$`Swing%`, col_swing)) %>% formatStyle("Contact%", backgroundColor = styleEqual(dt$`Contact%`, col_contact)) %>% formatStyle("IZ Whiff%", backgroundColor = styleEqual(dt$`IZ Whiff%`, col_izwhiff)) %>% formatStyle("Chase%", backgroundColor = styleEqual(dt$`Chase%`, col_chase)) %>% formatStyle("Hard Hit%", backgroundColor = styleEqual(dt$`Hard Hit%`, col_hh)) %>% formatStyle("Avg EV", backgroundColor = styleEqual(dt$`Avg EV`, col_ev)) %>% formatStyle("GB%", backgroundColor = styleEqual(dt$`GB%`, col_gb)) %>% formatStyle("FB%", backgroundColor = styleEqual(dt$`FB%`, col_fb)) %>% formatStyle("LD%", backgroundColor = styleEqual(dt$`LD%`, col_ld)) %>% formatStyle("wOBA", backgroundColor = styleEqual(dt$wOBA, col_woba)) %>% formatStyle("xwOBA", backgroundColor = styleEqual(dt$xwOBA, col_xwoba)) %>% formatStyle( c("BA","OBP","SLG","wOBA","xwOBA","BB%","K%","Swing%","Contact%","IZ Whiff%", "Chase%","Hard Hit%","Avg EV","GB%","FB%","LD%","PA","Height","Weight"), fontWeight = "bold" ) %>% formatStyle("Portal", color = styleEqual(c("Yes","No"), c("#00840D","#aaaaaa")), fontWeight = styleEqual(c("Yes","No"), c("700","400")) ) }) # ── Pitcher modal ───────────────────────────────────────────────────────────── current_pitcher <- reactiveVal(NULL) observeEvent(input$selected_pitcher, { req(input$selected_pitcher, raw_data()) current_pitcher(input$selected_pitcher) shinyjs::delay(100, shinyjs::show("modal_wrapper")) }) observeEvent(input$modal_closed, { shinyjs::hide("modal_wrapper") }) pitcher_plot_data <- reactive({ req(current_pitcher(), raw_data()) raw_data()$raw_df %>% filter(Pitcher == current_pitcher()) %>% mutate(PitchType = clean_pitch_type(TaggedPitchType)) %>% filter(!is.na(PitchType), !is.na(HorzBreak), !is.na(InducedVertBreak)) }) output$modal_header <- renderUI({ req(current_pitcher()) parts <- strsplit(current_pitcher(), ", ")[[1]] display_name <- if (length(parts)==2) paste(trimws(parts[2]),trimws(parts[1])) else current_pitcher() pdata <- pitcher_plot_data() team <- if (nrow(pdata)>0) pdata$PitcherTeam[1] else "" tagList( tags$p(display_name, class = "modal-title"), tags$p(team, class = "modal-subtitle") ) }) output$movement_plot <- renderPlot({ req(pitcher_plot_data(), nrow(pitcher_plot_data()) > 0) pdata <- pitcher_plot_data() parts <- strsplit(current_pitcher(), ", ")[[1]] display_name <- if (length(parts)==2) paste(trimws(parts[2]),trimws(parts[1])) else current_pitcher() avg_data <- pdata %>% group_by(PitchType) %>% summarise( HB = mean(HorzBreak, na.rm = TRUE), iVB = mean(InducedVertBreak, na.rm = TRUE), Velo = round(mean(RelSpeed, na.rm = TRUE), 1), .groups = "drop" ) ggplot() + geom_vline(xintercept = 0, color = "black") + geom_hline(yintercept = 0, color = "black") + geom_point(data = pdata, aes(x = HorzBreak, y = InducedVertBreak, fill = PitchType), size = 3, alpha = 0.6, shape = 21, color = "black", stroke = 0.4) + geom_point(data = avg_data, aes(x = HB, y = iVB, color = PitchType), size = 9, alpha = 0.9) + geom_text(data = avg_data, aes(x = HB, y = iVB, label = Velo), color = "white", size = 3, fontface = "bold") + scale_fill_manual(values = pitch_colors, name = NULL, drop = TRUE) + scale_color_manual(values = pitch_colors, name = NULL, drop = TRUE) + coord_fixed(ratio = 1, xlim = c(-25, 25), ylim = c(-25, 25)) + labs(title = paste(display_name, "— Pitch Movement"), x = "Horizontal Break (in)", y = "Induced Vertical Break (in)") + theme_minimal() + theme( plot.title = element_text(hjust = 0.5, size = 13, face = "bold", color = "#002147"), legend.position = "bottom", legend.text = element_text(size = 9), legend.key.size = unit(0.4, "cm") ) }) output$pitch_type_table <- renderDT({ req(pitcher_plot_data(), nrow(pitcher_plot_data()) > 0) pdata <- pitcher_plot_data() pitch_table <- pdata %>% mutate( IsStrike = PitchCall %in% c("StrikeCalled","StrikeSwinging","FoulBall", "FoulBallNotFieldable","FoulTip","InPlay"), IsZone = !is.na(PlateLocSide) & !is.na(PlateLocHeight) & abs(PlateLocSide) <= 0.8303 & PlateLocHeight >= 1.5 & PlateLocHeight <= 3.3775, IsWhiff = PitchCall == "StrikeSwinging", IsSwing = PitchCall %in% c("StrikeSwinging","FoulBall", "FoulBallNotFieldable","FoulTip","InPlay"), IsChase = IsSwing & !IsZone, IsOutZone = !IsZone, IsHardHit = !is.na(ExitSpeed) & ExitSpeed >= 95 & !PitchCall %in% c("FoulBall","FoulBallNotFieldable","FoulBallFieldable","FoulTip") ) %>% group_by(`Pitch Type` = PitchType) %>% summarise( `#` = n(), `Usage%` = round(n() / nrow(pdata) * 100, 1), Velo = round(mean(RelSpeed, na.rm = TRUE), 1), iVB = round(mean(InducedVertBreak, na.rm = TRUE), 1), HB = round(mean(HorzBreak, na.rm = TRUE), 1), Spin = round(mean(SpinRate, na.rm = TRUE), 0), RelH = round(mean(RelHeight, na.rm = TRUE), 1), RelS = round(mean(RelSide, na.rm = TRUE), 1), VAA = round(mean(VertApprAngle, na.rm = TRUE), 1), HAA = round(mean(HorzApprAngle, na.rm = TRUE), 1), `Zone%` = round(sum(IsZone, na.rm = TRUE) / n() * 100, 1), `Strike%` = round(sum(IsStrike, na.rm = TRUE) / n() * 100, 1), `Whiff%` = round(ifelse(sum(IsSwing) == 0, NA, sum(IsWhiff) / sum(IsSwing) * 100), 1), `Chase%` = round(ifelse(sum(IsOutZone) == 0, NA, sum(IsChase) / sum(IsOutZone) * 100), 1), `Hard Hit%` = round(ifelse(sum(!is.na(ExitSpeed)) == 0, NA, sum(IsHardHit) / sum(!is.na(ExitSpeed)) * 100), 1), .groups = "drop" ) %>% arrange(desc(`#`)) %>% mutate(across(where(is.numeric), ~ifelse(is.nan(.), NA, .))) datatable( pitch_table, rownames = FALSE, selection = "none", options = list( pageLength = 10, dom = "t", scrollX = TRUE, ordering = TRUE, columnDefs = list( list(className = "dt-center", targets = 1:15), list(className = "dt-left", targets = 0) ) ) ) }) # ── Hitter modal ───────────────────────────────────────────────────────────── current_batter <- reactiveVal(NULL) observeEvent(input$selected_batter, { req(input$selected_batter, raw_data()) current_batter(input$selected_batter) shinyjs::delay(100, shinyjs::show("hitter_modal_wrapper")) }) observeEvent(input$hitter_modal_closed, { shinyjs::hide("hitter_modal_wrapper") }) # ── Scout board submission ──────────────────────────────────────────────────── observeEvent(input$board_submit, { req(input$board_submit) d <- input$board_submit tryCatch({ sheet_data <- data.frame( First = d$first, Last = d$last, School = d$school, Position = d$position, Hand = d$throws, Height = d$height, Weight = d$weight, Notes = d$notes, `Added By` = d$added_by, check.names = FALSE ) sheet_append(sheet_id, sheet_data) output$board_confirm_msg <- renderUI({ tags$p("✅ Added to Scout Board!", style = "color:#00840D; font-weight:700; margin-top:10px; text-align:center;") }) shinyjs::delay(2000, { shinyjs::runjs("$('#board_modal_wrapper').hide();") }) }, error = function(e) { output$board_confirm_msg <- renderUI({ tags$p(paste("❌ Error:", e$message), style = "color:#E1463E; font-weight:700; margin-top:10px;") }) }) }) batter_data <- reactive({ req(current_batter(), raw_data()) raw_data()$raw_df %>% filter(Batter == current_batter()) %>% mutate( PitchGroup = case_when( TaggedPitchType %in% c("Fastball","FourSeamFastBall","Four-Seam","FourSeam", "Sinker","TwoSeamFastBall","OneSeamFastball","Cutter") ~ "Fastball", TaggedPitchType %in% c("Slider","Sweeper","Curveball","CurveBall") ~ "Breaker", TaggedPitchType %in% c("Changeup","ChangeUp","Splitter") ~ "Offspeed", TRUE ~ NA_character_ ) ) }) output$hitter_modal_header <- renderUI({ req(current_batter()) parts <- strsplit(current_batter(), ", ")[[1]] display_name <- if (length(parts)==2) paste(trimws(parts[2]),trimws(parts[1])) else current_batter() pdata <- batter_data() team <- if (nrow(pdata)>0) pdata$BatterTeam[1] else "" tagList( tags$p(display_name, class = "modal-title"), tags$p(team, class = "modal-subtitle") ) }) output$hitter_hand_table <- renderDT({ req(batter_data(), nrow(batter_data()) > 0) pdata <- batter_data() hand_table <- pdata %>% mutate( IsHit = !is.na(PlayResult) & PlayResult %in% c("Single","Double","Triple","HomeRun"), IsAB = !is.na(PlayResult) & PlayResult %in% c("Single","Double","Triple","HomeRun", "Out","Error","FieldersChoice"), IsPA = (!is.na(KorBB) & KorBB %in% c("Strikeout","Walk")) | (!is.na(PitchCall) & PitchCall %in% c("InPlay","HitByPitch")), IsHR = !is.na(PlayResult) & PlayResult == "HomeRun", IsBB = !is.na(KorBB) & KorBB == "Walk", IsK = !is.na(KorBB) & KorBB == "Strikeout", IsSwing = PitchCall %in% c("StrikeSwinging","FoulBall", "FoulBallNotFieldable","FoulTip","InPlay"), IsWhiff = PitchCall == "StrikeSwinging", IsHardHit = !is.na(ExitSpeed) & ExitSpeed >= 95 & !PitchCall %in% c("FoulBall","FoulBallNotFieldable","FoulBallFieldable","FoulTip"), IsBIP = !is.na(ExitSpeed) & !PitchCall %in% c("FoulBall","FoulBallNotFieldable","FoulBallFieldable","FoulTip") ) %>% filter(!is.na(PitcherThrows), PitcherThrows != "") %>% group_by(Hand = PitcherThrows) %>% summarise( PA = sum(IsPA, na.rm = TRUE), AB = sum(IsAB, na.rm = TRUE), H = sum(IsHit, na.rm = TRUE), HR = sum(IsHR, na.rm = TRUE), BB = sum(IsBB, na.rm = TRUE), K = sum(IsK, na.rm = TRUE), hh_n = sum(IsHardHit, na.rm = TRUE), bip_n = sum(IsBIP, na.rm = TRUE), whiff_n = sum(IsWhiff, na.rm = TRUE), swing_n = sum(IsSwing, na.rm = TRUE), .groups = "drop" ) %>% mutate( AVG = round(H / pmax(AB, 1), 3), OBP = round((H + BB) / pmax(PA, 1), 3), `K%` = round(K / pmax(PA, 1) * 100, 1), `BB%` = round(BB / pmax(PA, 1) * 100, 1), `Hard Hit%` = round(hh_n / pmax(bip_n, 1) * 100, 1), `Whiff%` = round(whiff_n / pmax(swing_n, 1) * 100, 1) ) %>% select(Hand, PA, AVG, OBP, HR, `BB%`, `K%`, `Hard Hit%`, `Whiff%`) datatable( hand_table, rownames = FALSE, selection = "none", options = list( pageLength = 5, dom = "t", scrollX = TRUE, ordering = TRUE, columnDefs = list( list(className = "dt-center", targets = 1:8), list(className = "dt-left", targets = 0) ) ) ) }) output$hitter_pitch_table <- renderDT({ req(batter_data(), nrow(batter_data()) > 0) pdata <- batter_data() pitch_table <- pdata %>% mutate( IsZone = !is.na(PlateLocSide) & !is.na(PlateLocHeight) & abs(PlateLocSide) <= 0.8303 & PlateLocHeight >= 1.5 & PlateLocHeight <= 3.3775, IsHit = !is.na(PlayResult) & PlayResult %in% c("Single","Double","Triple","HomeRun"), IsAB = !is.na(PlayResult) & PlayResult %in% c("Single","Double","Triple","HomeRun", "Out","Error","FieldersChoice"), IsPA = (!is.na(KorBB) & KorBB %in% c("Strikeout","Walk")) | (!is.na(PitchCall) & PitchCall %in% c("InPlay","HitByPitch")), IsHR = !is.na(PlayResult) & PlayResult == "HomeRun", IsK = !is.na(KorBB) & KorBB == "Strikeout", IsSwing = PitchCall %in% c("StrikeSwinging","FoulBall", "FoulBallNotFieldable","FoulTip","InPlay"), IsWhiff = PitchCall == "StrikeSwinging", IsChase = IsSwing & !IsZone, IsOutZone = !IsZone, IsHardHit = !is.na(ExitSpeed) & ExitSpeed >= 95 & !PitchCall %in% c("FoulBall","FoulBallNotFieldable","FoulBallFieldable","FoulTip"), IsBIP = !is.na(ExitSpeed) & !PitchCall %in% c("FoulBall","FoulBallNotFieldable","FoulBallFieldable","FoulTip") ) %>% filter(!is.na(PitchGroup)) %>% group_by(`Pitch Type` = PitchGroup) %>% summarise( Pitches = n(), PA = sum(IsPA, na.rm = TRUE), AB = sum(IsAB, na.rm = TRUE), H = sum(IsHit, na.rm = TRUE), HR = sum(IsHR, na.rm = TRUE), K = sum(IsK, na.rm = TRUE), hh_n = sum(IsHardHit, na.rm = TRUE), bip_n = sum(IsBIP, na.rm = TRUE), whiff_n = sum(IsWhiff, na.rm = TRUE), swing_n = sum(IsSwing, na.rm = TRUE), chase_n = sum(IsChase, na.rm = TRUE), outzone_n = sum(IsOutZone, na.rm = TRUE), .groups = "drop" ) %>% mutate( AVG = round(H / pmax(AB, 1), 3), `K%` = round(K / pmax(PA, 1) * 100, 1), `Hard Hit%` = round(hh_n / pmax(bip_n, 1) * 100, 1), `Whiff%` = round(whiff_n / pmax(swing_n, 1) * 100, 1), `Chase%` = round(chase_n / pmax(outzone_n, 1) * 100, 1) ) %>% select(`Pitch Type`, Pitches, PA, AVG, HR, `K%`, `Hard Hit%`, `Whiff%`, `Chase%`) %>% arrange(desc(Pitches)) datatable( pitch_table, rownames = FALSE, selection = "none", options = list( pageLength = 5, dom = "t", scrollX = TRUE, ordering = TRUE, columnDefs = list( list(className = "dt-center", targets = 1:8), list(className = "dt-left", targets = 0) ) ) ) }) } shinyApp(ui, server)