Spaces:
Sleeping
Sleeping
| 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, | |
| "<br>Tag: ",tag_display, | |
| "<br>Velo: ",round(RelSpeed,1), | |
| "<br>HB: ",round(HorzBreak,1), | |
| "<br>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)) |