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))