Spaces:
Sleeping
Sleeping
| 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('<button class="plot-btn" data-pitcher="', | |
| gsub('"', '"', Pitcher), | |
| '">📊 View</button>'), | |
| is_new = if (has_date) (!is.na(DateAdded) & DateAdded == latest_date) else FALSE, | |
| `Twitter` = paste0('<a href="https://x.com/search?q=', | |
| gsub(" ", "+", Name), | |
| '&src=typed_query&f=user" target="_blank">🐦 Search</a>'), | |
| `Board` = paste0('<button class="board-btn" data-pitcher="', | |
| gsub('"', '"', Pitcher), | |
| '" data-first="', gsub('"', '"', sapply(Pitcher, function(n) { | |
| parts <- strsplit(n, ", ")[[1]] | |
| if (length(parts)==2) trimws(parts[2]) else n | |
| })), | |
| '" data-last="', gsub('"', '"', sapply(Pitcher, function(n) { | |
| parts <- strsplit(n, ", ")[[1]] | |
| if (length(parts)==2) trimws(parts[1]) else n | |
| })), | |
| '" data-team="', gsub('"', '"', ifelse(is.na(Team), "", Team)), | |
| '" data-throws="', gsub('"', '"', ifelse(is.na(Throws), "", Throws)), | |
| '" data-height="', gsub('"', '"', ifelse(is.na(Height), "", Height)), | |
| '" data-weight="', gsub('"', '"', ifelse(is.na(Weight), "", Weight)), | |
| '">⭐ Add</button>'), | |
| Name = ifelse(HasSinker, | |
| paste0('<span style="background:#cce5ff; padding:2px 6px;', | |
| 'border-radius:3px; font-weight:700;">', Name, | |
| ifelse(!is.na(DateAdded) & DateAdded == max(DateAdded[!is.na(DateAdded)]), ' *', ''), | |
| '</span>'), | |
| paste0('<span style="font-weight:700;">', Name, | |
| ifelse(!is.na(DateAdded) & DateAdded == max(DateAdded[!is.na(DateAdded)]), ' *', ''), | |
| '</span>')) | |
| ) | |
| 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('<button class="hitter-name-btn" data-batter="', | |
| gsub('"', '"', Batter), | |
| '" style="background:none;border:none;color:#002147;', | |
| 'font-weight:700;cursor:pointer;padding:0;font-size:13px;">', | |
| Name, ifelse(is_new, ' *', ''), '</button>'), | |
| `Twitter` = paste0('<a href="https://x.com/search?q=', | |
| gsub(" ", "+", sapply(Batter, format_name)), | |
| '&src=typed_query&f=user" target="_blank">🐦 Search</a>'), | |
| `Board` = paste0('<button class="board-btn" data-batter="', | |
| gsub('"', '"', Batter), | |
| '" data-first="', gsub('"', '"', sapply(Batter, function(n) { | |
| parts <- strsplit(n, ", ")[[1]] | |
| if (length(parts)==2) trimws(parts[2]) else n | |
| })), | |
| '" data-last="', gsub('"', '"', sapply(Batter, function(n) { | |
| parts <- strsplit(n, ", ")[[1]] | |
| if (length(parts)==2) trimws(parts[1]) else n | |
| })), | |
| '" data-team="', gsub('"', '"', ifelse(is.na(Team), "", Team)), | |
| '" data-position="',gsub('"', '"', ifelse(is.na(Position), "", Position)), | |
| '" data-batside="', gsub('"', '"', ifelse(is.na(BatSide), "", BatSide)), | |
| '" data-height="', gsub('"', '"', ifelse(is.na(Height), "", Height)), | |
| '" data-weight="', gsub('"', '"', ifelse(is.na(Weight), "", Weight)), | |
| '">⭐ Add</button>') | |
| ) | |
| 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 <b>View</b> to see pitch movement | | |
| <span style="background:#cce5ff; padding:2px 6px; border-radius:3px; | |
| font-weight:700;">Name</span> = 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) |