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)