library(shiny)
library(tidyverse)
library(plotly)
PITCH_TYPES <- c("Fastball","Sinker","Cutter","Curveball","Slider",
"Sweeper","Slurve","Changeup","Splitter","Knuckleball","Other")
pitch_colors <- c(
"Fastball" = "red",
"Sinker" = "orange",
"Cutter" = "#8B4513",
"Curveball" = "blue",
"Slider" = "gold",
"Sweeper" = "darkgoldenrod",
"Slurve" = "goldenrod",
"Changeup" = "green3",
"Splitter" = "mediumpurple3",
"Knuckleball" = "gray50",
"Other" = "gray80",
"Untagged" = "black"
)
`%||%` <- function(a, b) if (!is.null(a)) a else b
# ── UI ─────────────────────────────────────────────────────────────────────────
ui <- fluidPage(
tags$head(tags$style(HTML("
body { background:#f5f5f5; font-family:'Segoe UI',sans-serif; margin:0; padding:0; }
.header { background:#13294B; padding:12px 20px; display:flex; align-items:center; }
.header h2 { color:#fff; margin:0; font-size:22px; font-weight:600; }
.header p { color:#aabbcc; margin:0; font-size:12px; }
.main { padding:15px; }
.well { background:#fff; border:1px solid #e0e0e0;
box-shadow:0 1px 3px rgba(0,0,0,0.06); border-radius:8px; }
.form-control { border:1px solid #d0d0d0; border-radius:6px; }
hr { border-color:#e8e8e8; }
label { font-size:12px; font-weight:600; color:#555;
text-transform:uppercase; letter-spacing:0.4px; }
.section-label { font-size:11px; font-weight:700; color:#888;
text-transform:uppercase; letter-spacing:0.6px; margin-bottom:6px; }
.info-box { background:#f9f9f9; border:1px solid #e0e0e0; border-radius:6px;
padding:10px 12px; font-size:13px; color:#333; margin-bottom:10px; }
.btn-primary { background:#13294B; color:#fff; border:none;
border-radius:6px; font-weight:500; width:100%; }
.btn-primary:hover { background:#1a3a6b; color:#fff; }
.btn-danger { background:#8B0000; color:#fff; border:none;
border-radius:6px; font-weight:500; width:100%; }
.btn-danger:hover { background:#a00000; color:#fff; }
.btn-default { background:#fff; color:#222; border:1px solid #d0d0d0;
border-radius:6px; font-weight:500; width:100%; }
.btn-default:hover { background:#f0f0f0; }
.btn-warning { background:#e0a800; color:#fff; border:none;
border-radius:6px; font-weight:500; width:100%; }
.btn-warning:hover { background:#c89600; color:#fff; }
"))),
# header
tags$div(class="header",
tags$img(
src="https://1000logos.net/wp-content/uploads/2022/05/Butler-Bulldogs-Logo.png",
height="50px", style="margin-right:14px;"
),
tags$div(tags$h2("Pitch Retagger"), tags$p("Trackman Pitch Type Editor"))
),
tags$div(class="main",
fluidRow(
# ── Sidebar ──────────────────────────────────────────────────────────────
column(3,
wellPanel(
# file + filters
fileInput("file", "Upload Trackman CSV", accept=".csv"),
selectInput("team", "Team", choices=NULL),
selectInput("pitcher", "Pitcher", choices=NULL),
selectInput("pitch_filter", "Filter by Pitch Type", choices=c("All",PITCH_TYPES), selected="All"),
hr(),
# single pitch
tags$p("Single Pitch Retag", class="section-label"),
uiOutput("selected_info"),
selectInput("new_tag", "Retag To", choices=PITCH_TYPES),
hr(),
# lasso retag
tags$p("Lasso / Box Selection", class="section-label"),
uiOutput("lasso_info"),
selectInput("lasso_tag", "Retag Selection To", choices=PITCH_TYPES),
actionButton("apply_lasso", "Apply to Selection",
class="btn-primary", style="margin-bottom:6px;"),
actionButton("clear_lasso", "Clear Selection", class="btn-default"),
hr(),
# bulk retag
tags$p("Bulk Retag — This Pitcher", class="section-label"),
selectInput("bulk_from", "Change From", choices=PITCH_TYPES),
selectInput("bulk_to", "Change To", choices=PITCH_TYPES),
actionButton("retag_all", "Retag All Matching",
class="btn-danger", style="margin-bottom:6px;"),
hr(),
# undo
actionButton("undo", "↩ Undo Last Action",
class="btn-warning", style="margin-bottom:6px;"),
hr(),
# download / overwrite
downloadButton("download_csv", "Download Updated CSV", class="btn-default"),
br(), br(),
actionButton("overwrite", "Overwrite Original File", class="btn-default")
)
),
# ── Plot ─────────────────────────────────────────────────────────────────
column(9,
plotlyOutput("movement_plot", height="620px")
)
)
)
)
# ── Server ─────────────────────────────────────────────────────────────────────
server <- function(input, output, session) {
rv <- reactiveValues(
data = NULL,
filepath = NULL,
selected = NULL, # single click row_id
lasso_ids = NULL, # lasso selected row_ids
history = list() # undo stack — each entry is a full copy of rv$data
)
# ── Load CSV ─────────────────────────────────────────────────────────────────
observeEvent(input$file, {
req(input$file)
rv$filepath <- input$file$datapath
df <- read_csv(input$file$datapath, show_col_types=FALSE) %>%
mutate(
row_id = row_number(),
TaggedPitchType = case_when(
TaggedPitchType %in% c("Fastball","FourSeamFastBall","Four-Seam") ~ "Fastball",
TaggedPitchType %in% c("Sinker","TwoSeamFastBall") ~ "Sinker",
TaggedPitchType == "Cutter" ~ "Cutter",
TaggedPitchType == "Curveball" ~ "Curveball",
TaggedPitchType == "Slider" ~ "Slider",
TaggedPitchType == "Sweeper" ~ "Sweeper",
TaggedPitchType == "Slurve" ~ "Slurve",
TaggedPitchType %in% c("ChangeUp","Changeup") ~ "Changeup",
TaggedPitchType == "Splitter" ~ "Splitter",
TaggedPitchType == "Knuckleball" ~ "Knuckleball",
TRUE ~ NA_character_
)
)
rv$data <- df
rv$history <- list()
teams <- sort(unique(df$PitcherTeam))
updateSelectInput(session, "team", choices=teams)
})
# ── Team → Pitcher ───────────────────────────────────────────────────────────
observeEvent(input$team, {
req(rv$data, input$team)
pitchers <- rv$data %>%
filter(PitcherTeam==input$team) %>%
pull(Pitcher) %>% unique() %>% sort()
updateSelectInput(session, "pitcher", choices=pitchers)
rv$selected <- NULL
rv$lasso_ids <- NULL
})
observeEvent(input$pitcher, {
rv$selected <- NULL
rv$lasso_ids <- NULL
updateSelectInput(session, "pitch_filter", selected="All")
})
# ── Filtered data for current pitcher ────────────────────────────────────────
pitcher_data <- reactive({
req(rv$data, input$pitcher)
pd <- rv$data %>% filter(Pitcher==input$pitcher)
if (input$pitch_filter != "All")
pd <- pd %>% filter(TaggedPitchType==input$pitch_filter)
pd
})
# ── Movement plot ─────────────────────────────────────────────────────────────
output$movement_plot <- renderPlotly({
req(pitcher_data())
pd <- pitcher_data()
pd <- pd %>%
mutate(
tag_display = ifelse(is.na(TaggedPitchType),"Untagged",TaggedPitchType),
is_selected = row_id %in% (rv$lasso_ids %||% integer(0)),
pt_stroke = ifelse(is_selected, 2.5, 0.5),
pt_outline = ifelse(is_selected, "cyan", "black")
)
avgs <- pd %>%
filter(!is.na(TaggedPitchType)) %>%
group_by(TaggedPitchType) %>%
summarise(HB=mean(HorzBreak,na.rm=TRUE),
iVB=mean(InducedVertBreak,na.rm=TRUE),
Velo=round(mean(RelSpeed,na.rm=TRUE),1),
.groups="drop")
p <- ggplot() +
geom_vline(xintercept=0, color="black") +
geom_hline(yintercept=0, color="black") +
geom_point(
data=pd,
aes(x=HorzBreak, y=InducedVertBreak, fill=tag_display,
text=paste0("Row: ",row_id,
"
Tag: ",tag_display,
"
Velo: ",round(RelSpeed,1),
"
HB: ",round(HorzBreak,1),
"
iVB: ",round(InducedVertBreak,1))),
size=4, alpha=0.85, shape=21,
color=pd$pt_outline, stroke=pd$pt_stroke
) +
geom_point(data=avgs, aes(x=HB,y=iVB,color=TaggedPitchType),
size=10, alpha=0.9) +
geom_text(data=avgs, aes(x=HB,y=iVB,label=Velo),
color="white", size=3.5, fontface="bold") +
scale_fill_manual(values=pitch_colors, drop=TRUE) +
scale_color_manual(values=pitch_colors, drop=TRUE) +
labs(title="Pitch Movement — Box or Lasso to select region",
x="Horizontal Break (in)", y="Induced Vertical Break (in)") +
xlim(-25,25) + ylim(-25,25) +
theme_minimal() +
theme(legend.position="none",
plot.title=element_text(hjust=0.5,size=13,face="bold"))
ggplotly(p, tooltip="text") %>%
layout(dragmode="lasso") %>%
event_register("plotly_click") %>%
event_register("plotly_selected")
})
# ── Single click ──────────────────────────────────────────────────────────────
observeEvent(event_data("plotly_click"), {
click <- event_data("plotly_click")
req(click, pitcher_data())
pd <- pitcher_data()
clicked <- pd %>%
mutate(dist=abs(HorzBreak-click$x)+abs(InducedVertBreak-click$y)) %>%
slice_min(dist, n=1)
rv$selected <- clicked$row_id[1]
rv$lasso_ids <- NULL
cur <- clicked$TaggedPitchType[1]
if (!is.na(cur)) updateSelectInput(session, "new_tag", selected=cur)
})
# ── Apply single retag when new_tag changes ───────────────────────────────────
observeEvent(input$new_tag, {
req(rv$selected, rv$data)
push_history()
rv$data <- rv$data %>%
mutate(TaggedPitchType=ifelse(row_id==rv$selected, input$new_tag, TaggedPitchType))
rv$selected <- NULL
})
# ── Single pitch info box ─────────────────────────────────────────────────────
output$selected_info <- renderUI({
if (is.null(rv$selected)) {
tags$div(class="info-box", "Click a pitch on the plot to select it.")
} else {
row <- rv$data %>% filter(row_id==rv$selected)
tags$div(class="info-box",
tags$b("Selected:"), br(),
paste0("Tag: ", ifelse(is.na(row$TaggedPitchType),"Untagged",row$TaggedPitchType)), br(),
paste0("Velo: ", round(row$RelSpeed,1),
" HB: ", round(row$HorzBreak,1),
" iVB: ", round(row$InducedVertBreak,1))
)
}
})
# ── Lasso selection ───────────────────────────────────────────────────────────
observeEvent(event_data("plotly_selected"), {
sel <- event_data("plotly_selected")
if (is.null(sel) || nrow(sel)==0) { rv$lasso_ids <- NULL; return() }
req(pitcher_data())
pd <- pitcher_data() %>%
filter(!is.na(HorzBreak), !is.na(InducedVertBreak))
ids <- pd %>%
filter(
round(HorzBreak,3) %in% round(sel$x,3),
round(InducedVertBreak,3) %in% round(sel$y,3)
) %>%
pull(row_id)
rv$lasso_ids <- ids
rv$selected <- NULL
})
output$lasso_info <- renderUI({
n <- length(rv$lasso_ids %||% integer(0))
if (n==0) {
tags$div(class="info-box",
style="color:#888;",
"Draw a box or lasso on the plot to select pitches.")
} else {
tags$div(class="info-box",
style="color:#13294B; font-weight:bold;",
paste0(n, " pitch(es) selected"))
}
})
observeEvent(input$apply_lasso, {
req(rv$lasso_ids, length(rv$lasso_ids)>0, input$lasso_tag)
n <- length(rv$lasso_ids)
push_history()
rv$data <- rv$data %>%
mutate(TaggedPitchType=ifelse(row_id %in% rv$lasso_ids, input$lasso_tag, TaggedPitchType))
rv$lasso_ids <- NULL
showNotification(paste0("Retagged ",n," pitch(es) → '",input$lasso_tag,"'."), type="message")
})
observeEvent(input$clear_lasso, {
rv$lasso_ids <- NULL
})
# ── Bulk retag ────────────────────────────────────────────────────────────────
observeEvent(input$retag_all, {
req(rv$data, input$pitcher, input$bulk_from, input$bulk_to)
ids <- rv$data %>%
filter(Pitcher==input$pitcher, TaggedPitchType==input$bulk_from) %>%
pull(row_id)
if (length(ids)==0) {
showNotification(paste0("No '",input$bulk_from,"' found for ",input$pitcher,"."), type="warning")
return()
}
push_history()
rv$data <- rv$data %>%
mutate(TaggedPitchType=ifelse(row_id %in% ids, input$bulk_to, TaggedPitchType))
showNotification(
paste0("Retagged ",length(ids)," '",input$bulk_from,"' → '",input$bulk_to,"'."),
type="message"
)
})
# ── Undo ──────────────────────────────────────────────────────────────────────
push_history <- function() {
rv$history <- c(rv$history, list(rv$data))
if (length(rv$history) > 20) rv$history <- tail(rv$history, 20)
}
observeEvent(input$undo, {
if (length(rv$history)==0) {
showNotification("Nothing to undo.", type="warning")
return()
}
rv$data <- rv$history[[length(rv$history)]]
rv$history <- rv$history[-length(rv$history)]
rv$selected <- NULL
rv$lasso_ids <- NULL
showNotification("Undone.", type="message")
})
# ── Download ──────────────────────────────────────────────────────────────────
output$download_csv <- downloadHandler(
filename = function() paste0("retagged_", basename(rv$filepath)),
content = function(file) {
rv$data %>% select(-row_id) %>% write_csv(file)
}
)
observeEvent(input$overwrite, {
req(rv$data, rv$filepath)
rv$data %>% select(-row_id) %>% write_csv(rv$filepath)
showNotification("File overwritten.", type="message")
})
}
shinyApp(ui, server, options=list(launch.browser=TRUE))