PitchPlots / app.R
TimStats's picture
Update app.R
7d5f54d verified
Raw
History Blame Contribute Delete
16.7 kB
library(shiny)
library(dplyr)
library(ggplot2)
library(utils)
library(httr)
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) {
# Get content as text first to check if it's an LFS pointer
content_text <- content(response, "text", encoding = "UTF-8")
# Check if this is an LFS pointer (LFS files start with "version https://git-lfs.github.com/spec/")
if (grepl("^version https://git-lfs.github.com/spec/", content_text)) {
# This is an LFS file - extract the oid (hash) from the pointer
oid_line <- grep("oid sha256:", strsplit(content_text, "\n")[[1]], value = TRUE)
oid <- gsub("oid sha256:", "", oid_line)
oid <- trimws(oid)
# Construct the LFS content URL
lfs_url <- paste0("https://huggingface.co/datasets/", repo_id, "/resolve/main/.git/lfs/objects/",
substr(oid, 1, 2), "/", substr(oid, 3, 4), "/", oid)
# Get the actual content from LFS storage
lfs_response <- GET(lfs_url, add_headers(Authorization = paste("Bearer", Sys.getenv("GETCSV"))))
if (status_code(lfs_response) == 200) {
content_text <- content(lfs_response, "text", encoding = "UTF-8")
} else {
# Alternative LFS URL format
lfs_url <- paste0("https://huggingface.co/datasets/", repo_id, "/lfs/resolve/main/", filename, "?download=true")
lfs_response <- GET(lfs_url, add_headers(Authorization = paste("Bearer", Sys.getenv("GETCSV"))))
if (status_code(lfs_response) == 200) {
content_text <- content(lfs_response, "text", encoding = "UTF-8")
} else {
stop(paste("Failed to download LFS content. Status code:", status_code(lfs_response)))
}
}
}
# Process the content (whether it was LFS or regular)
con <- textConnection(content_text)
tryCatch({
data <- read.csv(con,
header = TRUE,
check.names = FALSE,
fileEncoding = "UTF-8",
stringsAsFactors = FALSE)
return(data)
}, error = function(e) {
close(con)
stop(paste("Error parsing CSV:", e$message))
}, finally = {
close(con)
})
} else {
stop(paste("Failed to download dataset. Status code:", status_code(response)))
}
}
download_private_parquet <- function(repo_id, filename) {
library(httr)
library(arrow)
# Create the direct download URL based on your example
url <- paste0("https://huggingface.co/datasets/", repo_id, "/resolve/main/", filename, "?download=true")
# Create a temporary file
temp_file <- tempfile(fileext = ".parquet")
# Download directly to file
response <- GET(
url,
add_headers(Authorization = paste("Bearer", Sys.getenv("GETCSV"))),
write_disk(temp_file, overwrite = TRUE)
)
# Check if download was successful
if (status_code(response) == 200) {
tryCatch({
# Read the parquet file
data <- read_parquet(temp_file)
file.remove(temp_file)
return(data)
}, error = function(e) {
file.remove(temp_file)
stop(paste("Error reading parquet file:", e$message))
})
} else {
file.remove(temp_file)
stop(paste("Failed to download file. Status code:", status_code(response)))
}
}
MLB26 <- download_private_parquet("TimStats/StatcastDataAll", "MLB26.parquet")
MLB26$level <- "MLB"
MLB25 <- download_private_parquet("TimStats/StatcastDataAll", "MLB25.parquet")
MLB25$level <- "MLB"
#names(ST)
MLB <- download_private_parquet("TimStats/StatcastDataAll", "MLB.parquet")
MLB$level <- "MLB"
data1 <- rbind(MLB,MLB25,MLB26)
#data1 <- read.csv("data1.csv", header = TRUE, check.names = FALSE, fileEncoding = "UTF-8")
#data1 <- data1[,2:54]
AAaveragePT <- data1 %>%
mutate(arm_angle = round(arm_angle, digits = 0)) %>%
group_by(arm_angle, phand, pitch_name) %>%
summarise(
AvgIVB = mean(IVB, na.rm = TRUE),
AvgHB = mean(HB, na.rm = TRUE),
.groups = 'drop'
)
pitch_type_lookup <- data.frame(
pitch_name = c("Changeup", "Curveball", "Cutter", "Eephus", "Forkball",
"Four-Seam Fastball", "Knuckle Ball", "Knuckle Curve",
"Screwball", "Sinker", "Slider", "Slurve", "Splitter", "Sweeper"),
pitch_abbr = c("CH", "CU", "FC", "EP", "FO", "FF", "KN", "KC",
"SC", "SI", "SL", "SV", "FS", "ST"),
stringsAsFactors = FALSE
)
pitch_colors <- c(
"FF" = "#FF4136",
"SI" = "#FF851B",
"FC" = "#FFDC00",
"CH" = "#2ECC40",
"SL" = "#0074D9",
"ST" = "#ED68ED",
"CU" = "#B10DC9",
"FS" = "#01FF70",
"KC" = "#85144b",
"SV" = "#3D9970",
"KN" = "#39CCCC",
"FO" = "#F012BE",
"EP" = "#AAAAAA",
"FA" = "#7FDBFF",
"SC" = "#FF69B4"
)
break_plot_Szn <- function(game, data1, sdate, edate) {
# Calculate pitch usage percentages
game <- game %>% filter(between(as.Date(date), sdate, edate))
total_pitches <- nrow(game)
usage_stats <- game %>%
group_by(pitch_name) %>%
summarise(
count = n(),
usage = sprintf("%.1f%%", (count/total_pitches) * 100)
)
game <- game %>%
left_join(pitch_type_lookup, by = c("pitch_name")) %>%
left_join(usage_stats, by = "pitch_name")
game <- game %>% filter(!is.na(pitch_abbr))
title <- paste0(unique(game$`Pitcher Name`)," ",sdate," to ",edate)
pitcher_hand <- unique(game$phand)
angle_degrees <- round(mean(game$arm_angle,na.rm = TRUE),digits = 1)
angle_radians <- angle_degrees * (pi / 180)
if(pitcher_hand == "L") {
leg <- c(0.08, .22)
factor <- -1
} else {
leg <- c(0.92, .22)
factor <- 1
}
x_end <- 50 * cos(angle_radians) * factor
y_end <- 50 * sin(angle_radians)
# Create the base color mapping
game_pitches <- unique(game$pitch_abbr)
used_colors <- pitch_colors[game_pitches]
# Add usage to the game data
avg_locations <- game %>%
group_by(pitch_abbr) %>%
summarize(
avg_HB = mean(HB, na.rm = TRUE),
avg_IVB = mean(IVB, na.rm = TRUE),
usage = first(usage) # Keep the usage info
)
# Create labels with percentages
legend_labels <- paste0(names(used_colors), " (",
avg_locations$usage[match(names(used_colors), avg_locations$pitch_abbr)], ")")
# Create the color scale with usage labels
fill_values <- used_colors
names(fill_values) <- legend_labels
arm_angle_data <- data1 %>%
filter(!is.na(arm_angle), !is.na(phand), !is.na(pitch_name)) %>%
filter(abs(round(arm_angle) - angle_degrees) <= 2, phand == pitcher_hand) %>%
left_join(pitch_type_lookup, by = c("pitch_name")) %>%
filter(!is.na(pitch_abbr)) %>%
filter(pitch_abbr %in% game_pitches)
ggplot(game, aes(x = HB, y = IVB)) +
geom_vline(xintercept = 0, color = "lightblue", linewidth = 1, linetype = 4) +
geom_hline(yintercept = 0, color = "lightblue", linewidth = 1, linetype = 4) +
# Ellipses
stat_ellipse(
data = arm_angle_data,
aes(fill = paste0(pitch_abbr, " (",
avg_locations$usage[match(pitch_abbr, avg_locations$pitch_abbr)], ")")),
geom = "polygon",
alpha = 0.2,
level = 0.68,
show.legend = FALSE
) +
# Average points
geom_point(
data = avg_locations,
aes(x = avg_HB, y = avg_IVB,
fill = paste0(pitch_abbr, " (", usage, ")")),
color = "black",
size = 6,
stroke = .5,
shape = 21
) +
# Arm angle line
geom_segment(x = 0, y = 0, xend = x_end, yend = y_end,
color = "red", linewidth = 1, linetype = 5) +
scale_fill_manual(values = fill_values) +
labs(
x = "Horizontal Break (in)",
y = "Induced Vertical Break (in)",
title = title,
subtitle = paste0("Arm Angle: ", angle_degrees, "\u00b0"),
caption = "Data: MLB | Viz: @TimStats | tim-stats.com\nEllipses show average movement for same-handed pitchers at similar arm angles"
) +
xlim(-25, 25) +
ylim(-25, 25) +
theme_minimal() +
theme(
legend.position = leg,
plot.title = element_text(hjust = 0.5, face = "bold", color = "white"),
plot.subtitle = element_text(hjust = 0.5, face = "italic", color = "white"),
aspect.ratio = 1,
plot.background = element_rect(fill = "#333333", color = NA),
panel.background = element_rect(fill = "#333333", color = NA),
axis.text = element_text(color = "white"),
axis.title = element_text(color = "white"),
legend.background = element_rect(fill = "#333333"),
legend.text = element_text(color = "white"),
legend.title = element_blank(),
plot.margin = margin(10, 5, 10, 5),
axis.line = element_blank(),
axis.ticks = element_line(color = "white"),
panel.grid = element_blank(),
legend.key = element_blank(),
plot.caption = element_text(
color = "white",
hjust = 0.5
),
panel.border = element_blank()
)
}
break_plot_tot <- function(game, data1, sdate, edate) {
# Calculate pitch usage percentages
game <- game %>% filter(between(as.Date(date), sdate, edate))
total_pitches <- nrow(game)
usage_stats <- game %>%
group_by(pitch_name) %>%
summarise(
count = n(),
usage = sprintf("%.1f%%", (count/total_pitches) * 100)
)
game <- game %>%
left_join(pitch_type_lookup, by = c("pitch_name")) %>%
left_join(usage_stats, by = "pitch_name")
game <- game %>% filter(!is.na(pitch_abbr))
title <- paste0(unique(game$`Pitcher Name`)," ",sdate," to ",edate)
pitcher_hand <- unique(game$phand)
angle_degrees <- round(mean(game$arm_angle,na.rm = TRUE),digits = 1)
angle_radians <- angle_degrees * (pi / 180)
if(pitcher_hand == "L") {
leg <- c(0.08, .22)
factor <- -1
} else {
leg <- c(0.92, .22)
factor <- 1
}
x_end <- 50 * cos(angle_radians) * factor
y_end <- 50 * sin(angle_radians)
# Create the base color mapping
game_pitches <- unique(game$pitch_abbr)
used_colors <- pitch_colors[game_pitches]
# Add usage to the game data
avg_locations <- game %>%
group_by(pitch_abbr) %>%
summarize(
avg_HB = mean(HB, na.rm = TRUE),
avg_IVB = mean(IVB, na.rm = TRUE),
usage = first(usage) # Keep the usage info
)
# Create labels with percentages
legend_labels <- paste0(names(used_colors), " (",
avg_locations$usage[match(names(used_colors), avg_locations$pitch_abbr)], ")")
# Create the color scale with usage labels
fill_values <- used_colors
names(fill_values) <- legend_labels
arm_angle_data <- data1 %>%
filter(!is.na(arm_angle), !is.na(phand), !is.na(pitch_name)) %>%
filter(abs(round(arm_angle) - angle_degrees) <= 2, phand == pitcher_hand) %>%
left_join(pitch_type_lookup, by = c("pitch_name")) %>%
filter(!is.na(pitch_abbr)) %>%
filter(pitch_abbr %in% game_pitches)
ggplot(game, aes(x = HB, y = IVB)) +
geom_vline(xintercept = 0, color = "lightblue", linewidth = 1, linetype = 4) +
geom_hline(yintercept = 0, color = "lightblue", linewidth = 1, linetype = 4) +
# Ellipses
stat_ellipse(
data = arm_angle_data,
aes(fill = paste0(pitch_abbr, " (",
avg_locations$usage[match(pitch_abbr, avg_locations$pitch_abbr)], ")")),
geom = "polygon",
alpha = 0.2,
level = 0.68,
show.legend = FALSE
) +
# Individual points
geom_point(
aes(fill = paste0(pitch_abbr, " (", usage, ")")),
color = "#c6c6c6",
size = 3,
stroke = .5,
shape = 21
) +
# Average points
geom_point(
data = avg_locations,
aes(x = avg_HB, y = avg_IVB,
fill = paste0(pitch_abbr, " (", usage, ")")),
color = "black",
size = 6,
stroke = .5,
shape = 21
) +
# Arm angle line
geom_segment(x = 0, y = 0, xend = x_end, yend = y_end,
color = "red", linewidth = 1, linetype = 5) +
scale_fill_manual(values = fill_values) +
labs(
x = "Horizontal Break (in)",
y = "Induced Vertical Break (in)",
title = title,
subtitle = paste0("Arm Angle: ", angle_degrees, "\u00b0"),
caption = "Data: MLB | Viz: @TimStats | tim-stats.com\nEllipses show average movement for same-handed pitchers at similar arm angles"
) +
xlim(-25, 25) +
ylim(-25, 25) +
theme_minimal() +
theme(
legend.position = leg,
plot.title = element_text(hjust = 0.5, face = "bold", color = "white"),
plot.subtitle = element_text(hjust = 0.5, face = "italic", color = "white"),
aspect.ratio = 1,
plot.background = element_rect(fill = "#333333", color = NA),
panel.background = element_rect(fill = "#333333", color = NA),
axis.text = element_text(color = "white"),
axis.title = element_text(color = "white"),
legend.background = element_rect(fill = "#333333"),
legend.text = element_text(color = "white"),
legend.title = element_blank(),
plot.margin = margin(10, 5, 10, 5),
axis.line = element_blank(),
axis.ticks = element_line(color = "white"),
panel.grid = element_blank(),
legend.key = element_blank(),
plot.caption = element_text(
color = "white",
hjust = 0.5
),
panel.border = element_blank()
)
}
ui <- fluidPage(
# Application title
titlePanel("2020-2025 MLB Pitch Plots"),
sidebarLayout(
sidebarPanel(
width = 3,
selectInput("player", "Select Player:", choices = unique(data1$`Pitcher Name`),
width = "100%"),
dateRangeInput("date1", "Dates: (Be wary of multi year plots as arm angles may vary over years)",
start = "2026-03-18",
end = Sys.Date(),
width = "100%"),
radioButtons("type", "Plot Type",
choices = c("Season Average","All Pitches")),
actionButton("submit", "Update Plot",
class = "btn btn-primary btn-block",
style = "margin-bottom: 10px"),
downloadButton("download", "Download Plot",
class = "btn btn-success btn-block")
),
mainPanel(
width = 9,
plotOutput("Plot",height = "1200px",width = "100%")
)
)
)
# Define server
server <- function(input, output, session) {
# Create a reactive value to store current plot settings
plotSettings <- reactiveVal(list(
player = NULL,
type = "Season Average",
dates = c(as.Date("2024-03-20"), as.Date("2024-10-01"))
))
# Update settings only when submit is clicked
observeEvent(input$submit, {
plotSettings(list(
player = input$player,
type = input$type,
dates = c(input$date1[1], input$date1[2])
))
})
output$Plot <- renderPlot({
settings <- plotSettings()
req(settings$player) # Wait until we have a player selected
game <- data1 %>%
filter(`Pitcher Name` == settings$player)
if(nrow(game) == 0) {
return(ggplot() +
annotate("text", x = 0.5, y = 0.5,
label = "No data available for selected date range",
color = "white") +
theme_void() +
theme(plot.background = element_rect(fill = "#333333", color = NA)))
}
if(settings$type == "Season Average"){
break_plot_Szn(game, data1, settings$dates[1], settings$dates[2])
} else{
break_plot_tot(game, data1, settings$dates[1], settings$dates[2])
}
}, width = 750, height = 750, bg = "#333333")
output$download <- downloadHandler(
filename = function() {
settings <- plotSettings()
paste0(
gsub(" ", "_", settings$player), "_",
format(settings$dates[1], "%Y%m%d"), "_to_",
format(settings$dates[2], "%Y%m%d"), ".png"
)
},
content = function(file) {
settings <- plotSettings()
game <- data1 %>%
filter(`Pitcher Name` == settings$player)
plot <- if(settings$type == "Season Average"){
break_plot_Szn(game, data1, settings$dates[1], settings$dates[2])
} else{
break_plot_tot(game, data1, settings$dates[1], settings$dates[2])
}
ggsave(file,
plot = plot,
width = 10,
height = 10,
dpi = 300,
bg = "#333333")
}
)
}
shinyApp(ui = ui, server = server)