SavantGraphs / app.R
TimStats's picture
Update app.R
42f20c5 verified
Raw
History Blame Contribute Delete
24.3 kB
# app.R
library(shiny)
#library(tidyverse)
library(ggplot2)
library(dplyr)
library(patchwork)
library(showtext)
library(magick)
library(grid)
library(gridExtra)
library(gtable)
library(httr)
showtext_opts(dpi = 300) # Match plot DPI
showtext_auto(enable = TRUE)
font_add_google("Roboto Condensed", "roboto")
is_barrel <- function(df) {
df$hit_speedr <- round(df$hit_speed)
df <- df |>
mutate(barrel = ifelse((hit_speedr >= 124) &
(hit_angle >= 0 & hit_angle <= 50),1,0)) |>
mutate(barrel = ifelse((hit_speedr == 123) &
(hit_angle >= 1 & hit_angle <= 50),1,barrel)) |>
mutate(barrel = ifelse((hit_speedr == 122) &
(hit_angle >= 2 & hit_angle <= 50),1,barrel)) |>
mutate(barrel = ifelse((hit_speedr == 121) &
(hit_angle >= 3 & hit_angle <= 50),1,barrel)) |>
mutate(barrel = ifelse((hit_speedr == 120) &
(hit_angle >= 4 & hit_angle <= 50),1,barrel)) |>
mutate(barrel = ifelse((hit_speedr == 119) &
(hit_angle >= 5 & hit_angle <= 50),1,barrel)) |>
mutate(barrel = ifelse((hit_speedr == 118) &
(hit_angle >= 6 & hit_angle <= 50),1,barrel)) |>
mutate(barrel = ifelse((hit_speedr == 117) &
(hit_angle >= 7 & hit_angle <= 50),1,barrel)) |>
mutate(barrel = ifelse((hit_speedr == 116) &
(hit_angle >= 8 & hit_angle <= 50),1,barrel)) |>
mutate(barrel = ifelse((hit_speedr == 115) &
(hit_angle >= 9 & hit_angle <= 50),1,barrel)) |>
mutate(barrel = ifelse((hit_speedr == 114) &
(hit_angle >= 10 & hit_angle <= 50),1,barrel)) |>
mutate(barrel = ifelse((hit_speedr == 113) &
(hit_angle >= 11 & hit_angle <= 50),1,barrel)) |>
mutate(barrel = ifelse((hit_speedr == 112) &
(hit_angle >= 12 & hit_angle <= 50),1,barrel)) |>
mutate(barrel = ifelse((hit_speedr == 111) &
(hit_angle >= 13 & hit_angle <= 50),1,barrel)) |>
mutate(barrel = ifelse((hit_speedr == 110) &
(hit_angle >= 14 & hit_angle <= 48),1,barrel)) |>
mutate(barrel = ifelse((hit_speedr == 109) &
(hit_angle >= 15 & hit_angle <= 46),1,barrel)) |>
mutate(barrel = ifelse((hit_speedr == 108) &
(hit_angle >= 16 & hit_angle <= 45),1,barrel)) |>
mutate(barrel = ifelse((hit_speedr == 107) &
(hit_angle >= 17 & hit_angle <= 43),1,barrel)) |>
mutate(barrel = ifelse((hit_speedr == 106) &
(hit_angle >= 18 & hit_angle <= 42),1,barrel)) |>
mutate(barrel = ifelse((hit_speedr == 105) &
(hit_angle >= 19 & hit_angle <= 40),1,barrel)) |>
mutate(barrel = ifelse((hit_speedr == 104) &
(hit_angle >= 20 & hit_angle <= 39),1,barrel)) |>
mutate(barrel = ifelse((hit_speedr == 103) &
(hit_angle >= 21 & hit_angle <= 37),1,barrel)) |>
mutate(barrel = ifelse((hit_speedr == 102) &
(hit_angle >= 22 & hit_angle <= 36),1,barrel)) |>
mutate(barrel = ifelse((hit_speedr == 101) &
(hit_angle >= 23 & hit_angle <= 34),1,barrel)) |>
mutate(barrel = ifelse((hit_speedr == 100) &
(hit_angle >= 24 & hit_angle <= 33),1,barrel)) |>
mutate(barrel = ifelse((hit_speedr == 99) &
(hit_angle >= 25 & hit_angle <= 31),1,barrel)) |>
mutate(barrel = ifelse((hit_speedr == 98) &
(hit_angle >= 26 & hit_angle <= 30),1,barrel)) |>
select(-hit_speedr)
return(df)
}
apply_percentile_calcs <- function(data) {
# List of columns to apply percent_rank
percent_rank_cols <- c("Z-Con%", "Z-Swing%", "O-Con%", "Avg EV", "Max EV", "EV90", "Barrel%", "Swing%", "wOBA",
"wOBACON","xwOBA","xDamage")
# List of columns to apply inverse percent_rank
inverse_percent_rank_cols <- c("Chase%", "Whiff%", "stdev(LA)", "SwStr%")
# Create an empty list to store results
percentile_list <- list()
# Calculate regular percentiles
for(col in percent_rank_cols) {
percentile_list[[col]] <- data.frame(
`Batter Name` = data[["Batter Name"]], # Using [[ ]] to preserve exact column name
`Batter ID` = data[["Batter ID"]], # Using [[ ]] to preserve exact column name
metric = col,
percentile = round(percent_rank(data[[col]]) * 100),
value = data[[col]],
stringsAsFactors = FALSE
)
}
# Calculate inverse percentiles
for(col in inverse_percent_rank_cols) {
percentile_list[[col]] <- data.frame(
`Batter Name` = data[["Batter Name"]], # Using [[ ]] to preserve exact column name
`Batter ID` = data[["Batter ID"]], # Using [[ ]] to preserve exact column name
metric = col,
percentile = round((1 - percent_rank(data[[col]])) * 100),
value = data[[col]],
stringsAsFactors = FALSE
)
}
# Combine all results into one data frame
result <- do.call(rbind, percentile_list)
# Reset row names
rownames(result) <- NULL
return(result)
}
get_player_image <- function(player_id) {
# Try MLB silo image first
silo_url <- sprintf("https://img.mlbstatic.com/mlb-photos/image/upload/w_200,q_auto:best/v1/people/%s/headshot/silo/current", player_id)
# Check if silo works
silo_result <- tryCatch({
response <- httr::HEAD(silo_url)
httr::status_code(response) == 200
}, error = function(e) FALSE)
# If silo fails, use MiLB with correct formatting
if (!silo_result) {
return(sprintf("https://img.mlbstatic.com/mlb-photos/image/upload/c_fill,g_auto,b_white,ar_1:1/w_180/v1/people/%s/headshot/milb/current", player_id))
}
# Return silo if it worked
return(silo_url)
}
get_player_info <- function(player_id, season, level = "MLB") {
# Initialize return values
team <- "MLB"
position <- NA
# If MLB level, use original endpoint
if(level == "MLB") {
url <- paste0("https://statsapi.mlb.com/api/v1/people/", player_id,
"/stats?stats=season&season=", season, "&group=hitting")
response <- httr::GET(url)
data <- httr::content(response, "parsed")
if(length(data$stats) > 0 && length(data$stats[[1]]$splits) > 0) {
team <- data$stats[[1]]$splits[[length(data$stats[[1]]$splits)]]$team$name
}
} else {
# For minor leagues, get player info from sports endpoint
sport_code <- if(level == "AAA") "11" else "14" # 11 for AAA, 14 for FSL
url <- paste0("https://statsapi.mlb.com/api/v1/sports/", sport_code, "/players?season=", season)
response <- httr::GET(url)
# Convert response to data frame
players_df <- jsonlite::fromJSON(rawToChar(response$content), flatten = TRUE)$people
# Find player directly
found_player <- players_df[players_df$id == player_id, ]
if(nrow(found_player) > 0) {
team_id <- found_player$currentTeam.id
# Get parent org using team id
team_url <- paste0("https://statsapi.mlb.com/api/v1/teams/", team_id, "?season=", season)
team_response <- httr::GET(team_url)
team_data <- jsonlite::fromJSON(rawToChar(team_response$content))
team <- team_data$teams$parentOrgName
}
}
# Get position info (same for all levels)
url2 <- paste0("https://statsapi.mlb.com/api/v1/people/", player_id)
response2 <- httr::GET(url2)
data2 <- httr::content(response2, "parsed")
if(length(data2$people) > 0) {
full_position <- data2$people[[1]]$primaryPosition$name
position <- case_when(
full_position == "First Base" ~ "1B",
full_position == "Second Base" ~ "2B",
full_position == "Third Base" ~ "3B",
full_position == "Shortstop" ~ "SS",
full_position == "Catcher" ~ "C",
full_position == "Left Field" ~ "LF",
full_position == "Center Field" ~ "CF",
full_position == "Right Field" ~ "RF",
full_position == "Outfielder" ~ "OF",
full_position == "Outfield" ~ "OF",
full_position == "Designated Hitter" ~ "DH",
full_position == "Pitcher" ~ "P",
full_position == "Two-Way Player" ~ "TWP",
TRUE ~ as.character(full_position)
)
}
return(list(
team = team,
position = position
))
}
download_private_csv <- function(repo_id, filename) {
url <- paste0("https://huggingface.co/datasets/", repo_id, "/resolve/main/", filename)
response <- GET(url, add_headers(Authorization = paste("Bearer", Sys.getenv("GETCSV"))))
if (status_code(response) == 200) {
content <- content(response, "text")
con <- textConnection(content)
# Try different read options
data <- read.csv(con,
header = TRUE,
check.names = FALSE, # This prevents R from modifying column names
fileEncoding = "UTF-8",
stringsAsFactors = FALSE)
close(con)
return(data)
} else {
stop("Failed to download dataset")
}
}
MLB <- download_private_csv("TimStats/StatcastDataAll", "MLB.csv")
AAA <- download_private_csv("TimStats/StatcastDataAll", "AAA.csv")
FSLAll <- download_private_csv("TimStats/StatcastDataAll", "FSL.csv")
temp_players <- MLB %>% filter(season == 2024)
MLBC <- rbind(MLB,AAA,FSLAll)
data <- is_barrel(MLBC) %>%
mutate(
BBE = case_when(description %in% c('In play, run(s)','In play, out(s)','In play, no out') ~ TRUE, TRUE ~ FALSE),
Swing = case_when(description %in% c('Foul','Foul Bunt','Foul Pitchout','Foul Tip',
'In play, run(s)','In play, out(s)','In play, no out',
'Swinging Strike','Swinging Strike (Blocked)',
'Missed Bunt') ~ TRUE, TRUE ~ FALSE),
Contact = case_when(description %in% c('In play, run(s)','In play, out(s)','In play, no out',
'Foul','Foul Bunt','Foul Pitchout') ~ TRUE, TRUE ~ FALSE),
Whiff = case_when(description %in% c('Swinging Strike','Swinging Strike (Blocked)',
'Missed Bunt','Foul Tip') ~ TRUE, TRUE ~ FALSE),
IZ = ifelse(zone <= 9, TRUE, FALSE),
Single = case_when(result == "Single" & BBE == TRUE ~ TRUE, TRUE ~ FALSE),
Double = case_when(result == "Double" & BBE == TRUE ~ TRUE, TRUE ~ FALSE),
Triple = case_when(result == "Triple" & BBE == TRUE ~ TRUE, TRUE ~ FALSE),
`Home Run` = case_when(result == "Home Run" & BBE == TRUE ~ TRUE, TRUE ~ FALSE),
Walk = case_when(balls >= 4 & result == "Walk" ~ TRUE, TRUE ~ FALSE),
HBP = case_when(description == "Hit By Pitch" & result == "Hit By Pitch" ~ TRUE, TRUE ~ FALSE),
Strikeout = case_when(strikes >= 3 & result %in% c("Strikeout",'Stikeout Double Play') ~ TRUE, TRUE ~ FALSE),
Sac = case_when(BBE == TRUE & result %in% c('Sac Fly','Sac Bunt',
'Sac Fly Double Play','Sac Bunt Double Play') ~ TRUE, TRUE ~ FALSE),
IBB = case_when(pitchNum == 1 & result == "Intent Walk" ~ TRUE, TRUE ~ FALSE),
AB = Strikeout + BBE - Sac,
PA = AB + Walk + HBP + IBB
) %>%
group_by(`Batter Name`,`Batter ID`,season,level) %>%
summarise(
BIP = sum(BBE,na.rm = TRUE),
wOBA = round((sum(Single,na.rm = TRUE) * .882 + sum(Double, na.rm = TRUE) * 1.254 +
sum(Triple,na.rm = TRUE) * 1.59 + sum(`Home Run`,na.rm = TRUE) * 2.05 +
sum(Walk,na.rm = TRUE) * .689 + sum(HBP,na.rm = TRUE) * .720) /
(sum(PA,na.rm = TRUE) - sum(IBB,na.rm = TRUE)), 3),
wOBACON = round((sum(Single,na.rm = TRUE) * .882 + sum(Double, na.rm = TRUE) * 1.254 +
sum(Triple,na.rm = TRUE) * 1.59 + sum(`Home Run`,na.rm = TRUE) * 2.05 )/
sum(BBE,na.rm = TRUE), 3),
xwOBA = round(mean(expected_woba,na.rm = TRUE), 3),
xDamage = round(mean(expected_woba[BBE == TRUE],na.rm = TRUE), 3),
`Avg EV` = round(mean(hit_speed,na.rm = TRUE), 1),
EV90 = round(quantile(hit_speed,0.9,na.rm = TRUE), 1),
`Max EV` = round(max(hit_speed,na.rm = TRUE), 1),
'stdev(LA)' = round(sd(hit_angle,na.rm = TRUE), 1),
'Barrel%' = round(100 * mean(barrel[Swing == TRUE],na.rm = TRUE), 1),
"Z-Con%" = round(100 * mean(Contact[IZ == TRUE & Swing == TRUE],na.rm = TRUE), 1),
"Z-Swing%" = round(100 * mean(Swing[IZ == TRUE],na.rm = TRUE), 1),
"O-Con%" = round(100 * mean(Contact[IZ == FALSE & Swing == TRUE],na.rm = TRUE), 1),
"Chase%" = round(100 * mean(Swing[IZ == FALSE],na.rm = TRUE), 1),
"Whiff%" = round(100 * mean(Whiff[Swing == TRUE],na.rm = TRUE), 1),
"Swing%" = round(100 * mean(Swing,na.rm = TRUE), 1),
"SwStr%" = round(100 * mean(Whiff,na.rm = TRUE), 1)
)
# UI definition
ui <- fluidPage(
tags$head(
tags$style(HTML("
.plot-container {
width: 1000px !important;
height: 1000px !important;
}
"))
),
titlePanel(NULL, windowTitle = "Baseball Stats Visualization"),
sidebarLayout(
sidebarPanel(
selectInput("szn", "Season:", c(2024, 2023, 2022, 2021, 2021)),
selectInput("level", "Level:", c("MLB", "AAA", "FSL")),
selectInput("type", "Player Type:", c("Batter", "Pitcher")),
selectInput("player", "Player:", choices = unique(temp_players$`Batter Name`)),
# Add toggle for custom team
checkboxInput("use_custom_team", "Use Custom Team", FALSE),
# Conditional panel for team selection
conditionalPanel(
condition = "input.use_custom_team == true",
selectInput(
inputId = "team",
label = "Select Team",
choices = c(
# Regular teams (sorted alphabetically)
"Angels" = "LAA",
"Astros" = "HOU",
"Athletics" = "OAK",
"Blue Jays" = "TOR",
"Braves" = "ATL",
"Brewers" = "MIL",
"Cardinals" = "STL",
"Cubs" = "CHC",
"D-backs" = "ARI",
"Dodgers" = "LAD",
"Giants" = "SF",
"Guardians" = "CLE",
"Mariners" = "SEA",
"Marlins" = "MIA",
"Mets" = "NYM",
"Nationals" = "WSH",
"Orioles" = "BAL",
"Padres" = "SD",
"Phillies" = "PHI",
"Pirates" = "PIT",
"Rangers" = "TEX",
"Rays" = "TB",
"Red Sox" = "BOS",
"Reds" = "CIN",
"Rockies" = "COL",
"Royals" = "KC",
"Tigers" = "DET",
"Twins" = "MIN",
"White Sox" = "CHW",
"Yankees" = "NYY",
# MLB option at the top
"MLB" = "MLB"
),
selected = "MLB"
)
),
),
mainPanel(
div(class = "plot-container",
plotOutput("statsPlot")
)
)
)
)
# Server logic
server <- function(input, output,session) {
observeEvent(c(input$szn,input$level), {
# Filter data based on selected season
filtered_data <- MLBC[MLBC$season == input$szn & MLBC$level == input$level,]
updateSelectInput(session,
inputId = "player",
choices = unique(filtered_data$`Batter Name`))
})
# Create reactive value to store team
team_value <- reactiveVal("MLB")
position_value <- reactiveVal("")
# Watch for player or season changes to update team
observeEvent(c(input$player, input$szn), {
if (!input$use_custom_team && !is.null(input$player)) {
player_id <- MLBC %>%
filter(`Batter Name` == input$player) %>%
pull(`Batter ID`) %>%
unique() %>%
first()
if (!is.null(player_id)) {
player_info <- get_player_info(player_id, input$szn, input$level)
team_abb <- switch(player_info$team,
"Los Angeles Angels" = "LAA",
"Houston Astros" = "HOU",
"Oakland Athletics" = "OAK",
"Toronto Blue Jays" = "TOR",
"Atlanta Braves" = "ATL",
"Milwaukee Brewers" = "MIL",
"St. Louis Cardinals" = "STL",
"Chicago Cubs" = "CHC",
"Arizona Diamondbacks" = "ARI",
"Los Angeles Dodgers" = "LAD",
"San Francisco Giants" = "SF",
"Cleveland Guardians" = "CLE",
"Seattle Mariners" = "SEA",
"Miami Marlins" = "MIA",
"New York Mets" = "NYM",
"Washington Nationals" = "WSH",
"Baltimore Orioles" = "BAL",
"San Diego Padres" = "SD",
"Philadelphia Phillies" = "PHI",
"Pittsburgh Pirates" = "PIT",
"Texas Rangers" = "TEX",
"Tampa Bay Rays" = "TB",
"Boston Red Sox" = "BOS",
"Cincinnati Reds" = "CIN",
"Colorado Rockies" = "COL",
"Kansas City Royals" = "KC",
"Detroit Tigers" = "DET",
"Minnesota Twins" = "MIN",
"Chicago White Sox" = "CHW",
"New York Yankees" = "NYY",
"MLB")
if(is.na(team_abb)){
team_abb <- "MLB"
}
team_value(team_abb)
position_value(player_info$position)
}
}
})
current_team <- reactive({
if (input$use_custom_team) {
return(input$team)
} else {
return(team_value())
}
})
output$statsPlot <- renderPlot({
req(position_value())
# showtext::showtext_begin()
# on.exit(showtext::showtext_end())
data <- data %>% filter(season == input$szn,level == input$level)
BBE <- MLBC %>%
filter(season == input$szn) %>%
filter(level == input$level) %>%
filter(`Batter Name` == input$player) %>%
mutate(
BBE = case_when(description %in% c('In play, run(s)','In play, out(s)','In play, no out') ~ TRUE, TRUE ~ FALSE)
)
indv <- data %>% filter(`Batter Name` == input$player,level == input$level)
#if(indv[1,5] >= 149){
qual <- data %>% filter(BIP > 249)
data <- rbind(indv,qual)
data <- unique(data)
#}
current_data <- apply_percentile_calcs(data %>% select(-BIP)) %>%
filter(`Batter.Name` == input$player) %>%
mutate(metric = factor(metric, levels = c(
"wOBA", "wOBACON", "xwOBA", "xDamage",
"Avg EV", "EV90", "Max EV",
"stdev(LA)", "Barrel%",
"Z-Con%", "Z-Swing%", "O-Con%",
"Chase%", "Whiff%", "Swing%", "SwStr%"
))) %>%
arrange(metric)
#current_data <- data
pos <- position_value()
BBE <- sum(BBE$BBE,na.rm = TRUE)
# Add Roboto Condensed font
#font_add_google("Roboto Condensed", "roboto")
#showtext_auto()
# Color function
current_data$color <- scales::gradient_n_pal(c("#325aa1","#90A4AE", "#D82129"))(current_data$percentile/100)
# Labels plot
labels_plot <- ggplot() +
annotate("text", x = c(10, 50, 90), y = 1.2,
label = c("Poor", "Average", "Great"),
color = c("#3661ad", "#90A4AE", "#DC3545"),
family = "roboto", size = 6) +
annotate("text", x = c(10, 50, 90), y = .5,
label = "▲",
color = c("#3661ad", "#90A4AE", "#DC3545"), size = 12) +
scale_x_continuous(limits = c(-16, 113), expand = c(0, 0)) +
scale_y_continuous(limits = c(0.5, 1.5)) +
theme_void()
# Main plot
main_plot <- ggplot(current_data, aes(y = factor(metric, levels = rev(metric)))) +
geom_tile(aes(x = 50, width = 100),
fill = "#c7dcdc", alpha = 0.3, height = 0.25) +
geom_tile(aes(x = percentile/2, width = percentile, fill = color),
height = 0.7) +
annotate("segment", x = c(10, 50, 90), xend = c(10, 50, 90),
y = 0, yend = 16.35,
color = c("white"),
linewidth = 1.5, alpha = 0.5) +
geom_segment(aes(x = -2, xend = -16,
y = as.numeric(factor(metric, levels = rev(metric))) - 0.3,
yend = as.numeric(factor(metric, levels = rev(metric))) - 0.3),
linetype = "longdash", color = "#399098", size = 1) +
geom_segment(aes(x = 102, xend = 113,
y = as.numeric(factor(metric, levels = rev(metric))) - 0.3,
yend = as.numeric(factor(metric, levels = rev(metric))) - 0.3),
linetype = "longdash", color = "#399098", size = 1) +
geom_text(aes(x = -3, label = metric),
hjust = 1, size = 5, family = "roboto") +
geom_text(aes(x = 103, label = value),
hjust = 0, size = 5, family = "roboto") +
geom_point(aes(x = percentile, color = "white", fill = color),
size = 12, shape = 21, stroke = 3) +
geom_text(aes(x = percentile, label = percentile),
size = 5, color = "white", fontface = "bold", family = "roboto") +
scale_x_continuous(limits = c(-16, 113), expand = c(0, 0)) +
scale_fill_identity() +
scale_color_identity() +
theme_minimal() +
theme(
axis.text = element_blank(),
axis.title = element_blank(),
panel.grid = element_blank(),
plot.margin = margin(t = 0, r = 0, b = -20, l = 0), # Reduced bottom margin
text = element_text(family = "roboto")
)
# Load and process team logo
if(current_team() == "MLB"){
logo_url <- "https://a.espncdn.com/combiner/i?img=/i/teamlogos/leagues/500/mlb.png?w=400&h=400&transparent=true"
} else {
logo_url <- sprintf("https://a.espncdn.com/combiner/i?img=/i/teamlogos/mlb/500/%s.png&h=200&w=200",
current_team())
}
logo_img <- image_read(logo_url)
logo_raster <- as.raster(logo_img)
#player_url <- sprintf(paste0("https://img.mlbstatic.com/mlb-photos/image/upload/d_headshot_silo_generic.png,ar_1:1,b_auto:border,c_pad,q_auto:best/w_60/v1/people/427012/headshot/milb/current"))
player_url <- get_player_image(current_data[1,2])
player_img <- image_read(player_url)
player_raster <- as.raster(player_img)
# Create title with logo
title_grob <- textGrob(paste0(input$player, " - ", pos,
"\n BBE - ", BBE, "\n",input$level,
" Percentile Rankings - ",input$szn),
gp = gpar(fontsize = 25, fontface = "bold",
fontfamily = "roboto"))
logo_grob <- rasterGrob(logo_raster, x = 0, width = unit(.5, "npc"),hjust = 0)
player_grob <- rasterGrob(player_raster, x = 0.5, width = unit(.5, "npc"),hjust = 0)
title_with_logo <- arrangeGrob(logo_grob, title_grob,player_grob, ncol = 3,
widths = c(.25,.5,.25))
caption_grob <- textGrob("Viz by: @TimStats | tim-stats.com | Data: MLB",gp = gpar(fontsize = 15, fontface = "bold",
fontfamily = "roboto"))
# Final arrangement with logo in title
final_plot <- grid.arrange(
title_with_logo,
labels_plot,
main_plot,
caption_grob,
heights = c(0.15, 0.05, 0.75, 0.05)
)
grid.arrange(
gtable_add_padding(
final_plot,
padding = unit(c(20, 20, 20, 20), "points") # top, right, bottom, left margins
)
)
}, height = 1000, width = 1000, res = 97,pointsize = 12)
}
# Run the app
shinyApp(ui = ui, server = server)