Spaces:
Paused
Paused
| library(shiny) | |
| library(shinydashboard) | |
| library(DT) | |
| library(dplyr) | |
| library(readr) | |
| library(stringr) | |
| library(plotly) | |
| # ---------- helpers ---------- | |
| `%||%` <- function(x, y) if (is.null(x)) y else x | |
| # columns to optionally remove | |
| columns_to_remove <- c( | |
| "SpinAxis3dTransverseAngle","SpinAxis3dLongitudinalAngle","SpinAxis3dActiveSpinRate", | |
| "SpinAxis3dSpinEfficiency","SpinAxis3dTilt","SpinAxis3dVectorX","SpinAxis3dVectorY", | |
| "SpinAxis3dVectorZ","SpinAxis3dSeamOrientationRotationX","SpinAxis3dSeamOrientationRotationY", | |
| "SpinAxis3dSeamOrientationRotationZ","SpinAxis3dSeamOrientationBallYAmb1", | |
| "SpinAxis3dSeamOrientationBallAngleHorizontalAmb1","SpinAxis3dSeamOrientationBallZAmb1", | |
| "SpinAxis3dSeamOrientationBallAngleVerticalAmb2","SpinAxis3dSeamOrientationBallZAmb2", | |
| "SpinAxis3dSeamOrientationBallXAmb4","SpinAxis3dSeamOrientationBallYAmb4", | |
| "SpinAxis3dSeamOrientationBallAngleHorizontalAmb2","SpinAxis3dSeamOrientationBallAngleVerticalAmb1", | |
| "SpinAxis3dSeamOrientationBallXAmb1","SpinAxis3dSeamOrientationBallYAmb2", | |
| "SpinAxis3dSeamOrientationBallAngleHorizontalAmb4","SpinAxis3dSeamOrientationBallAngleVerticalAmb4", | |
| "SpinAxis3dSeamOrientationBallXAmb2","SpinAxis3dSeamOrientationBallAngleVerticalAmb3", | |
| "SpinAxis3dSeamOrientationBallAngleHorizontalAmb3","SpinAxis3dSeamOrientationBallXAmb3", | |
| "SpinAxis3dSeamOrientationBallYAmb3","SpinAxis3dSeamOrientationBallZAmb3", | |
| "SpinAxis3dSeamOrientationBallZAmb4","BatSpeed","GameDate","HorizontalAttackAngle", | |
| "Horizontal Attack Angle","VerticalAttackAngle","Vertical attack angle" | |
| ) | |
| # pitch colors | |
| pitch_colors <- c( | |
| "Fastball" = "#FA8072", | |
| "Four-Seam" = "#FA8072", | |
| "Sinker" = "#fdae61", | |
| "Slider" = "#A020F0", | |
| "Sweeper" = "magenta", | |
| "Curveball" = "#2c7bb6", | |
| "ChangeUp" = "#90EE90", | |
| "Splitter" = "#90EE32", | |
| "Cutter" = "red", | |
| "Knuckleball" = "#FFB4B4", | |
| "Other" = "#D3D3D3" | |
| ) | |
| # ---------- UI ---------- | |
| ui <- fluidPage( | |
| tags$head( | |
| tags$style(HTML(" | |
| body, table, .gt_table { | |
| font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, | |
| Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; | |
| } | |
| .app-header { display:flex; justify-content:space-between; align-items:center; | |
| padding:20px 40px; background:#ffffff; border-bottom:3px solid darkcyan; margin-bottom:20px; } | |
| .header-logo-left,.header-logo-right { width:120px; height:auto; } | |
| .header-logo-center { max-width:400px; height:auto; } | |
| @media (max-width:768px){ .app-header{flex-direction:column; padding:15px 20px;} | |
| .header-logo-left,.header-logo-right{width:80px;} .header-logo-center{max-width:250px; margin:10px 0;} } | |
| .nav-tabs{ border:none!important; border-radius:50px; padding:6px 12px; margin:20px auto 0; max-width:100%; | |
| background:linear-gradient(135deg,#d4edeb 0%,#e8ddd0 50%,#d4edeb 100%); | |
| box-shadow:0 4px 16px rgba(0,139,139,.12), inset 0 2px 4px rgba(255,255,255,.6); | |
| border:1px solid rgba(0,139,139,.2); position:relative; overflow-x:auto; display:flex; justify-content:center; | |
| align-items:center; flex-wrap:wrap; gap:6px; -webkit-overflow-scrolling:touch; } | |
| .nav-tabs::-webkit-scrollbar{height:0;} | |
| .nav-tabs::before{content:''; position:absolute; inset:0; pointer-events:none; border-radius:50px; | |
| background:linear-gradient(135deg, rgba(255,255,255,.4), transparent);} | |
| .nav-tabs>li>a{ color:darkcyan!important; border:none!important; border-radius:50px!important; background:transparent!important; | |
| font-weight:700; font-size:14.5px; padding:10px 22px; white-space:nowrap; letter-spacing:.2px; transition:.2s; } | |
| .nav-tabs>li>a:hover{ color:#006666!important; background:rgba(255,255,255,.5)!important; transform:translateY(-1px); } | |
| .nav-tabs>li.active>a,.nav-tabs>li.active>a:focus,.nav-tabs>li.active>a:hover{ | |
| background:linear-gradient(135deg,#008b8b 0%,#20b2aa 30%,#00ced1 50%,#20b2aa 70%,#008b8b 100%)!important; | |
| color:#fff!important; text-shadow:0 1px 2px rgba(0,0,0,.2); | |
| box-shadow:0 4px 16px rgba(0,139,139,.4), inset 0 2px 8px rgba(255,255,255,.4), inset 0 -2px 6px rgba(0,0,0,.2); | |
| border:1px solid rgba(255,255,255,.3)!important; } | |
| .tab-content{ background:linear-gradient(135deg, rgba(255,255,255,.95), rgba(248,249,250,.95)); | |
| border-radius:20px; padding:25px; margin-top:14px; box-shadow:0 15px 40px rgba(0,139,139,.1); | |
| backdrop-filter:blur(15px); border:1px solid rgba(0,139,139,.1); position:relative; overflow:hidden; } | |
| .tab-content::before{content:''; position:absolute; left:0; right:0; top:0; height:4px; | |
| background:linear-gradient(90deg,darkcyan,peru,darkcyan); background-size:200% 100%; | |
| animation:shimmer 3s linear infinite;} | |
| @keyframes shimmer{0%{background-position:-200% 0;}100%{background-position:200% 0;}} | |
| h3{ color:black; font-weight:600; margin-top:25px; margin-bottom:15px; padding-bottom:8px; border-bottom:2px solid #007BA7; } | |
| h4{ color:black; font-weight:500; margin-top:20px; margin-bottom:12px; } | |
| h1{ color:#007BA7; font-weight:700; margin-bottom:20px; text-shadow:1px 1px 2px rgba(0,0,0,0.1); } | |
| label{ font-weight:500; color:peru; margin-bottom:5px; } | |
| thead th{ background:#F8F9FA; color:#2C3E50; font-weight:600; text-align:center!important; padding:10px!important; } | |
| .brand-teal{ color:darkcyan; } .brand-bronze{ color:peru; } | |
| .info-box { background-color:#f0f8ff; padding:10px; border-radius:5px; margin:10px 0; border-left:4px solid darkcyan; } | |
| ")) | |
| ), | |
| # header | |
| div(class="app-header", | |
| tags$img(src="https://i.imgur.com/7vx5Ci8.png", class="header-logo-left", alt="Logo Left"), | |
| tags$img(src="https://i.imgur.com/c3zCSg6.png", class="header-logo-center", alt="Main Logo"), | |
| tags$img(src="https://i.imgur.com/VbrN5WV.png", class="header-logo-right", alt="Logo Right") | |
| ), | |
| tabsetPanel(id="main_tabs", | |
| tabPanel("Upload & Process", | |
| fluidRow( | |
| column(12, | |
| h3("Upload CSV File"), | |
| fileInput("file", "Choose CSV File", accept = c(".csv")), | |
| fluidRow( | |
| column(4, checkboxInput("header","Header", TRUE)), | |
| column(4, radioButtons("sep","Separator", | |
| choices=c(Comma=",", Semicolon=";", Tab="\t"), selected=",", inline=TRUE)), | |
| column(4, radioButtons("quote","Quote", | |
| choices=c(None="", "Double Quote"='"', "Single Quote"="'"), | |
| selected='"', inline=TRUE)) | |
| ) | |
| ) | |
| ), | |
| fluidRow( | |
| column(8, | |
| h3("Columns to Remove"), | |
| p("Select which columns to remove from your dataset:"), | |
| checkboxGroupInput("columns_to_remove","Remove These Columns:", | |
| choices = columns_to_remove, selected = columns_to_remove) | |
| ), | |
| column(4, | |
| h3("Quick Actions"), br(), | |
| actionButton("select_all_cols","Select All",class="btn-primary"), br(), br(), | |
| actionButton("deselect_all_cols","Deselect All",class="btn-default"), br(), br(), | |
| actionButton("select_spinaxis","Select SpinAxis3d Columns",class="btn-info"), br(), br(), | |
| actionButton("select_attack_angle","Select Attack Angle Columns",class="btn-info"), br(), br(), | |
| h4("Processing Summary"), | |
| verbatimTextOutput("process_summary") | |
| ) | |
| ) | |
| ), | |
| tabPanel("Preview Data", | |
| fluidRow(column(12, h3("Data Preview"), DTOutput("preview"))) | |
| ), | |
| tabPanel("Pitch Movement Chart", | |
| fluidRow( | |
| column(3, selectInput("pitcher_select","Select Pitcher:", choices=NULL, selected=NULL)), | |
| column(3, | |
| h4("Selection Mode:"), | |
| radioButtons("selection_mode","", | |
| choices = list("Single Click (Edit One)"="single","Drag Select (Edit Multiple)"="drag"), | |
| selected="single", inline=FALSE) | |
| ), | |
| column(6, | |
| conditionalPanel( | |
| condition = "input.selection_mode == 'drag'", | |
| div(class="info-box", | |
| h4("Bulk Edit Mode:", style="margin-top:0; color:darkcyan;"), | |
| fluidRow( | |
| column(7, selectInput("bulk_pitch_type","Change selected pitches to:", | |
| choices=c("Fastball","Sinker","Cutter","Slider","Curveball","ChangeUp","Splitter","Knuckleball","Other"), | |
| selected="Fastball")), | |
| column(5, br(), actionButton("apply_bulk_change","Apply Changes", class="btn-success btn-block")) | |
| ), | |
| actionButton("clear_selection","Clear Selection", class="btn-warning btn-sm", style="margin-top:10px;") | |
| ) | |
| ) | |
| ) | |
| ), | |
| fluidRow( | |
| column(8, | |
| h3("Interactive Pitch Movement Analysis"), | |
| plotlyOutput("movement_plot", height="600px"), | |
| conditionalPanel( | |
| condition = "input.selection_mode == 'drag'", | |
| div(class="info-box", | |
| h4("Selected Points:", style="margin-top:0; color:darkcyan;"), | |
| textOutput("selection_info") | |
| ) | |
| ), | |
| div(style="padding:10px; background-color:#fff3cd; border-radius:5px; margin-top:10px;", | |
| textOutput("status_info") | |
| ) | |
| ), | |
| column(4, | |
| h3("Pitch Metrics Summary"), | |
| DTOutput("movement_stats"), | |
| h3("Location Plot (Editable)"), | |
| plotlyOutput("location_plot", height="600px") | |
| ) | |
| ) | |
| ), | |
| tabPanel("Download", | |
| fluidRow(column(12, | |
| h3("Download Processed Data"), | |
| h4("Your processed data is ready for download!"), br(), | |
| downloadButton("downloadData","Download CSV", class="btn-success btn-lg"), | |
| br(), br(), | |
| h4("Data Summary:"), | |
| verbatimTextOutput("data_summary") | |
| ))) | |
| ) | |
| ) | |
| # ---------- Server ---------- | |
| server <- function(input, output, session){ | |
| # reactives | |
| processed_data <- reactiveVal(NULL) | |
| plot_data <- reactiveVal(NULL) | |
| selected_pitch_uid <- reactiveVal(NULL) | |
| selected_keys <- reactiveVal(integer(0)) | |
| plot_version <- reactiveVal(0) # Force plot refresh | |
| # quick-select buttons | |
| observeEvent(input$select_all_cols, { updateCheckboxGroupInput(session,"columns_to_remove", selected = columns_to_remove) }) | |
| observeEvent(input$deselect_all_cols, { updateCheckboxGroupInput(session,"columns_to_remove", selected = character(0)) }) | |
| observeEvent(input$select_spinaxis, { | |
| spin <- columns_to_remove[grepl("SpinAxis3d", columns_to_remove)] | |
| updateCheckboxGroupInput(session,"columns_to_remove", selected = spin) | |
| }) | |
| observeEvent(input$select_attack_angle,{ | |
| aa <- columns_to_remove[grepl("AttackAngle|Attack Angle", columns_to_remove)] | |
| updateCheckboxGroupInput(session,"columns_to_remove", selected = aa) | |
| }) | |
| # file upload + processing | |
| observeEvent(input$file, { | |
| req(input$file) | |
| tryCatch({ | |
| df <- read.csv(input$file$datapath, | |
| header = input$header, sep = input$sep, quote = input$quote, | |
| stringsAsFactors = FALSE) | |
| # drop user-selected columns | |
| sel_drop <- input$columns_to_remove %||% character(0) | |
| if (length(sel_drop)) { | |
| df <- df %>% select(-any_of(intersect(names(df), sel_drop))) | |
| } | |
| # numeric coercions for interaction robustness | |
| num_cols <- c("HorzBreak","InducedVertBreak","RelSpeed","SpinRate", | |
| "PlateLocSide","PlateLocHeight","Extension","RelHeight") | |
| have <- intersect(names(df), num_cols) | |
| df[have] <- lapply(df[have], function(x) suppressWarnings(as.numeric(x))) | |
| df <- df %>% distinct() | |
| # Create unique identifiers - CRITICAL for tracking | |
| if (!".uid" %in% names(df)) { | |
| df$.uid <- seq_len(nrow(df)) | |
| } | |
| processed_data(df) | |
| plot_data(df) | |
| plot_version(plot_version() + 1) | |
| if ("Pitcher" %in% names(df)) { | |
| choices <- sort(unique(df$Pitcher[!is.na(df$Pitcher)])) | |
| if (length(choices)) { | |
| updateSelectInput(session,"pitcher_select", choices = choices, selected = choices[1]) | |
| } | |
| } | |
| showNotification("File processed successfully!", type="message", duration=3) | |
| }, error = function(e){ | |
| showNotification(paste("Error processing file:", e$message), type="error", duration=5) | |
| }) | |
| }) | |
| # processing summary | |
| output$process_summary <- renderText({ | |
| if (is.null(input$file)) return("No file uploaded yet.") | |
| if (is.null(processed_data())) return("Processing...") | |
| df <- processed_data() | |
| original_df <- read.csv(input$file$datapath, nrows = 1) | |
| sel <- input$columns_to_remove %||% character(0) | |
| removed <- intersect(sel, names(original_df)) | |
| paste( | |
| "✓ File processed successfully!", | |
| paste("✓ Original columns:", ncol(original_df)), | |
| paste("✓ Final columns:", ncol(df)), | |
| paste("✓ Rows processed:", nrow(df)), | |
| paste("✓ Removed columns:", length(removed)), | |
| if (length(removed)>0) paste(" -", paste(head(removed,5), collapse=", "), | |
| if (length(removed)>5) "..." else "") else "", | |
| "✓ Duplicates removed", | |
| sep = "\n" | |
| ) | |
| }) | |
| # preview table | |
| output$preview <- renderDT({ | |
| req(processed_data()) | |
| datatable(processed_data(), options=list(scrollX=TRUE, pageLength=10), filter="top") | |
| }) | |
| # Get current pitcher's data | |
| pitcher_df <- reactive({ | |
| req(plot_data(), input$pitcher_select) | |
| plot_version() # Depend on version to trigger updates | |
| d <- plot_data() %>% | |
| filter(Pitcher == input$pitcher_select) %>% | |
| filter(!is.na(HorzBreak), !is.na(InducedVertBreak), !is.na(RelSpeed)) | |
| d | |
| }) | |
| # Movement plot | |
| output$movement_plot <- renderPlotly({ | |
| d <- pitcher_df() | |
| validate(need(nrow(d) > 0, "No data for selected pitcher")) | |
| p <- plot_ly( | |
| data = d, source = "mv", type = "scatter", mode = "markers", | |
| x = ~HorzBreak, y = ~InducedVertBreak, | |
| text = ~paste0( | |
| "<b>", TaggedPitchType, "</b>", | |
| "<br>Velo: ", round(RelSpeed,1), " mph", | |
| "<br>IVB: ", round(InducedVertBreak,1), " in", | |
| "<br>HB: ", round(HorzBreak,1), " in", | |
| if ("SpinRate" %in% names(d)) paste0("<br>Spin: ", round(SpinRate), " rpm") else "" | |
| ), | |
| hoverinfo = "text", | |
| customdata = ~.uid, | |
| key = ~.uid, # Keep key for tracking | |
| color = ~factor(TaggedPitchType), | |
| colors = pitch_colors, | |
| marker = list(size = 10) | |
| ) | |
| p <- p %>% layout( | |
| title = paste("Pitch Movement Chart -", input$pitcher_select), | |
| xaxis = list(title="Horizontal Break (in)", range=c(-25,25), zeroline=TRUE), | |
| yaxis = list(title="Induced Vertical Break (in)", range=c(-25,25), zeroline=TRUE), | |
| dragmode = if (input$selection_mode == "drag") "select" else "zoom" | |
| ) | |
| p <- p %>% config(displaylogo = FALSE, modeBarButtonsToRemove = c("lasso2d")) | |
| p | |
| }) | |
| # Location plot | |
| output$location_plot <- renderPlotly({ | |
| d <- pitcher_df() | |
| validate(need(nrow(d) > 0, "No data for selected pitcher")) | |
| p <- plot_ly( | |
| data = d, source = "loc", | |
| type = "scatter", mode = "markers", | |
| x = ~PlateLocSide, y = ~PlateLocHeight, | |
| text = ~paste0( | |
| "<b>", TaggedPitchType, "</b>", | |
| if ("PitchNo" %in% names(d)) paste0("<br>Pitch #: ", PitchNo) else "", | |
| "<br>Velo: ", round(RelSpeed, 1), " mph", | |
| "<br>X: ", round(PlateLocSide, 2), | |
| "<br>Z: ", round(PlateLocHeight, 2), | |
| "<br>IVB: ", round(InducedVertBreak, 1), " in", | |
| "<br>HB: ", round(HorzBreak, 1), " in", | |
| if ("SpinRate" %in% names(d)) paste0("<br>Spin: ", round(SpinRate), " rpm") else "" | |
| ), | |
| hoverinfo = "text", | |
| customdata = ~.uid, | |
| key = ~.uid, | |
| color = ~factor(TaggedPitchType), | |
| colors = pitch_colors, | |
| marker = list(size = 9) | |
| ) | |
| p <- p %>% layout( | |
| title = "Pitch Location (Editable)", | |
| xaxis = list(title="Plate X (ft)", range=c(-2,2), zeroline=TRUE), | |
| yaxis = list(title="Plate Z (ft)", range=c(0,4.5), zeroline=TRUE), | |
| dragmode = if (input$selection_mode == "drag") "select" else "zoom", | |
| shapes = list( | |
| list(type="rect", x0=-0.8303, x1=0.8303, y0=1.6, y1=3.5, | |
| line=list(color="black", width=1), fillcolor="rgba(0,0,0,0)"), | |
| list(type="path", | |
| path="M -0.708 0.15 L 0.708 0.15 L 0.708 0.3 L 0 0.5 L -0.708 0.3 Z", | |
| line=list(color="black", width=0.8)) | |
| ) | |
| ) | |
| p <- p %>% config(displaylogo = FALSE, modeBarButtonsToRemove = c("lasso2d")) | |
| p | |
| }) | |
| # ---- SINGLE CLICK MODE ---- | |
| observeEvent(event_data("plotly_click", source="mv"), { | |
| req(input$selection_mode == "single") | |
| clk <- event_data("plotly_click", source="mv") | |
| req(!is.null(clk), nrow(as.data.frame(clk)) > 0) | |
| # Get UID from customdata | |
| uid <- clk$customdata[[1]] | |
| req(!is.null(uid)) | |
| d <- pitcher_df() | |
| hit <- d[d$.uid == uid, ] | |
| if (nrow(hit) == 1) { | |
| selected_pitch_uid(uid) | |
| showModal( | |
| modalDialog( | |
| title = tags$h3("Edit Pitch Type", style="color: darkcyan;"), | |
| tags$div( | |
| tags$strong("Pitcher: "), input$pitcher_select, tags$br(), | |
| if ("PitchNo" %in% names(hit) && !is.na(hit$PitchNo)) | |
| tagList(tags$strong("Pitch No: "), hit$PitchNo, tags$br()), | |
| tags$strong("Current Type: "), tags$span(hit$TaggedPitchType, style="color: peru; font-size: 16px;"), tags$br(), | |
| tags$strong("Velocity: "), round(hit$RelSpeed, 1), " mph", tags$br(), | |
| tags$strong("Horizontal Break: "), round(hit$HorzBreak, 1), " in", tags$br(), | |
| tags$strong("Induced Vertical Break: "), round(hit$InducedVertBreak, 1), " in", tags$br(), | |
| if ("SpinRate" %in% names(hit) && !is.na(hit$SpinRate)) | |
| tagList(tags$strong("Spin Rate: "), round(hit$SpinRate, 0), " rpm", tags$br()), | |
| tags$br() | |
| ), | |
| tags$label("Change Pitch Type To:", style="color: peru; font-weight: 600;"), | |
| selectInput("modal_new_pitch_type", NULL, | |
| choices = c("Fastball","Sinker","Cutter","Slider","Curveball", | |
| "ChangeUp","Splitter","Knuckleball","Other"), | |
| selected = hit$TaggedPitchType), | |
| tags$br(), | |
| footer = tagList( | |
| actionButton("update_pitch","Update Pitch Type", class="btn-primary"), | |
| actionButton("cancel_edit","Cancel", class="btn-default") | |
| ), | |
| easyClose = TRUE, size = "m" | |
| ) | |
| ) | |
| } | |
| }) | |
| observeEvent(event_data("plotly_click", source="loc"), { | |
| req(input$selection_mode == "single") | |
| clk <- event_data("plotly_click", source="loc") | |
| req(!is.null(clk), nrow(as.data.frame(clk)) > 0) | |
| uid <- clk$customdata[[1]] | |
| req(!is.null(uid)) | |
| d <- pitcher_df() | |
| hit <- d[d$.uid == uid, ] | |
| if (nrow(hit) == 1) { | |
| selected_pitch_uid(uid) | |
| showModal( | |
| modalDialog( | |
| title = tags$h3("Edit Pitch Type", style="color: darkcyan;"), | |
| tags$div( | |
| tags$strong("Pitcher: "), input$pitcher_select, tags$br(), | |
| if ("PitchNo" %in% names(hit) && !is.na(hit$PitchNo)) | |
| tagList(tags$strong("Pitch No: "), hit$PitchNo, tags$br()), | |
| tags$strong("Current Type: "), tags$span(hit$TaggedPitchType, style="color: peru; font-size: 16px;"), tags$br(), | |
| tags$strong("Velocity: "), round(hit$RelSpeed, 1), " mph", tags$br(), | |
| tags$strong("Location: "), "(", round(hit$PlateLocSide, 2), ", ", round(hit$PlateLocHeight, 2), ")", tags$br(), | |
| tags$br() | |
| ), | |
| tags$label("Change Pitch Type To:", style="color: peru; font-weight: 600;"), | |
| selectInput("modal_new_pitch_type", NULL, | |
| choices = c("Fastball","Sinker","Cutter","Slider","Curveball", | |
| "ChangeUp","Splitter","Knuckleball","Other"), | |
| selected = hit$TaggedPitchType), | |
| tags$br(), | |
| footer = tagList( | |
| actionButton("update_pitch","Update Pitch Type", class="btn-primary"), | |
| actionButton("cancel_edit","Cancel", class="btn-default") | |
| ), | |
| easyClose = TRUE, size = "m" | |
| ) | |
| ) | |
| } | |
| }) | |
| # Update single pitch | |
| observeEvent(input$update_pitch, { | |
| uid <- selected_pitch_uid() | |
| req(uid) | |
| new_type <- input$modal_new_pitch_type | |
| req(new_type) | |
| cur <- plot_data() | |
| req(nrow(cur) > 0) | |
| cur$TaggedPitchType <- as.character(cur$TaggedPitchType) | |
| hit_idx <- which(cur$.uid == uid) | |
| if (length(hit_idx) == 1) { | |
| old_type <- cur$TaggedPitchType[hit_idx] | |
| cur$TaggedPitchType[hit_idx] <- new_type | |
| plot_data(cur) | |
| processed_data(cur) | |
| plot_version(plot_version() + 1) # Force refresh | |
| removeModal() | |
| showNotification( | |
| paste("Updated pitch from", old_type, "to", new_type), | |
| type="message", | |
| duration=2 | |
| ) | |
| selected_pitch_uid(NULL) | |
| } else { | |
| showNotification("Error: Could not find pitch to update.", type="error") | |
| } | |
| }) | |
| observeEvent(input$cancel_edit, { | |
| removeModal() | |
| selected_pitch_uid(NULL) | |
| }) | |
| # ---- DRAG SELECT MODE ---- | |
| observeEvent(event_data("plotly_selected", source="mv"), { | |
| req(input$selection_mode == "drag") | |
| ev <- event_data("plotly_selected", source="mv") | |
| if (!is.null(ev) && "customdata" %in% names(ev)) { | |
| keys <- unique(as.integer(ev$customdata)) | |
| selected_keys(keys) | |
| } else { | |
| selected_keys(integer(0)) | |
| } | |
| }) | |
| observeEvent(event_data("plotly_selected", source="loc"), { | |
| req(input$selection_mode == "drag") | |
| ev <- event_data("plotly_selected", source="loc") | |
| if (!is.null(ev) && "customdata" %in% names(ev)) { | |
| keys <- unique(as.integer(ev$customdata)) | |
| selected_keys(keys) | |
| } else { | |
| selected_keys(integer(0)) | |
| } | |
| }) | |
| # Clear selection button | |
| observeEvent(input$clear_selection, { | |
| selected_keys(integer(0)) | |
| showNotification("Selection cleared", type="message", duration=2) | |
| }) | |
| # Selection info | |
| output$selection_info <- renderText({ | |
| keys <- selected_keys() | |
| if (!length(keys)) return("No points selected. Use drag/lasso to select multiple pitches.") | |
| d <- pitcher_df() | |
| sel <- d[d$.uid %in% keys, ] | |
| if (nrow(sel) == 0) return("Selected points not found in current pitcher's data.") | |
| cnt <- sort(table(sel$TaggedPitchType), decreasing = TRUE) | |
| breakdown <- paste(names(cnt), "(", cnt, ")", collapse=", ") | |
| paste(nrow(sel), "pitches selected:", breakdown) | |
| }) | |
| # Apply bulk changes | |
| observeEvent(input$apply_bulk_change, { | |
| req(input$selection_mode == "drag") | |
| keys <- selected_keys() | |
| req(length(keys) > 0) | |
| new_type <- input$bulk_pitch_type | |
| req(new_type) | |
| cur <- plot_data() | |
| req(nrow(cur) > 0) | |
| cur$TaggedPitchType <- as.character(cur$TaggedPitchType) | |
| # Only update pitches from current pitcher | |
| hit_idx <- which(cur$.uid %in% keys & cur$Pitcher == input$pitcher_select) | |
| if (length(hit_idx) == 0) { | |
| showNotification("No matching rows found for bulk edit.", type="warning") | |
| return() | |
| } | |
| # Store old types for notification | |
| old_types <- unique(cur$TaggedPitchType[hit_idx]) | |
| # Update | |
| cur$TaggedPitchType[hit_idx] <- new_type | |
| plot_data(cur) | |
| processed_data(cur) | |
| plot_version(plot_version() + 1) # Force refresh | |
| selected_keys(integer(0)) | |
| showNotification( | |
| paste("✓ Updated", length(hit_idx), "pitches to", new_type), | |
| type="message", | |
| duration=3 | |
| ) | |
| }) | |
| # Status info | |
| output$status_info <- renderText({ | |
| if (input$selection_mode == "single") { | |
| "Mode: Single Click - Click any point to edit its pitch type" | |
| } else { | |
| keys <- selected_keys() | |
| if (length(keys) > 0) { | |
| paste("Mode: Drag Select -", length(keys), "pitches selected. Choose type and click 'Apply Changes'") | |
| } else { | |
| "Mode: Drag Select - Use drag/lasso tool to select multiple pitches, then apply bulk changes" | |
| } | |
| } | |
| }) | |
| # Metrics table | |
| output$movement_stats <- renderDT({ | |
| req(plot_data(), input$pitcher_select) | |
| plot_version() # Depend on version | |
| data <- plot_data() | |
| movement_stats <- data %>% | |
| filter(Pitcher == input$pitcher_select) %>% | |
| filter(!is.na(HorzBreak), !is.na(InducedVertBreak), !is.na(TaggedPitchType)) %>% | |
| mutate( | |
| pitch_group = case_when( | |
| TaggedPitchType %in% c("Fastball","FourSeamFastBall","FourSeamFastB","Four-Seam","4-Seam") ~ "Fastball", | |
| TaggedPitchType %in% c("OneSeamFastBall","TwoSeamFastBall","Sinker","Two-Seam","One-Seam") ~ "Sinker", | |
| TaggedPitchType %in% c("ChangeUp","Changeup") ~ "Changeup", | |
| TRUE ~ TaggedPitchType | |
| ), | |
| in_zone = if ("StrikeZoneIndicator" %in% names(.)) StrikeZoneIndicator else | |
| ifelse(!is.na(PlateLocSide) & !is.na(PlateLocHeight) & | |
| PlateLocSide >= -0.95 & PlateLocSide <= 0.95 & | |
| PlateLocHeight >= 1.6 & PlateLocHeight <= 3.5, 1, 0), | |
| is_whiff = if ("WhiffIndicator" %in% names(.)) WhiffIndicator else | |
| ifelse(!is.na(PitchCall) & PitchCall == "StrikeSwinging", 1, 0), | |
| chase = if ("Chaseindicator" %in% names(.)) Chaseindicator else | |
| ifelse(!is.na(PitchCall) & !is.na(PlateLocSide) & !is.na(PlateLocHeight) & | |
| PitchCall %in% c("StrikeSwinging","FoulBallNotFieldable","FoulBall","InPlay") & | |
| (PlateLocSide < -0.95 | PlateLocSide > 0.95 | PlateLocHeight < 1.6 | PlateLocHeight > 3.5), 1, 0) | |
| ) | |
| total_pitches <- nrow(movement_stats) | |
| summary_stats <- movement_stats %>% | |
| group_by(`Pitch Type` = pitch_group) %>% | |
| summarise( | |
| Count = n(), | |
| `Usage%` = sprintf("%.1f%%", (n()/total_pitches)*100), | |
| `Ext.` = if ("Extension" %in% names(.)) sprintf("%.1f", mean(Extension, na.rm=TRUE)) else "—", | |
| `Avg Velo` = sprintf("%.1f mph", mean(RelSpeed, na.rm=TRUE)), | |
| `90th Velo` = sprintf("%.1f mph", quantile(RelSpeed, 0.9, na.rm=TRUE)), | |
| `Max Velo` = sprintf("%.1f mph", max(RelSpeed, na.rm=TRUE)), | |
| `Avg IVB` = sprintf("%.1f in", mean(InducedVertBreak, na.rm=TRUE)), | |
| `Avg HB` = sprintf("%.1f in", mean(HorzBreak, na.rm=TRUE)), | |
| `Avg Spin` = if ("SpinRate" %in% names(.)) sprintf("%.0f rpm", mean(SpinRate, na.rm=TRUE)) else "—", | |
| `Rel Height` = if ("RelHeight" %in% names(.)) sprintf("%.1f", mean(RelHeight, na.rm=TRUE)) else "—", | |
| `Zone%` = sprintf("%.1f%%", round(mean(in_zone, na.rm=TRUE)*100,1)), | |
| `Whiff%` = sprintf("%.1f%%", round(mean(is_whiff, na.rm=TRUE)*100,1)), | |
| `Chase%` = sprintf("%.1f%%", round(mean(chase, na.rm=TRUE)*100,1)), | |
| .groups = "drop" | |
| ) %>% arrange(desc(Count)) | |
| datatable(summary_stats, options=list(pageLength=15, dom='t', scrollX=TRUE), rownames=FALSE) %>% | |
| formatStyle(columns = names(summary_stats), fontSize='12px') | |
| }) | |
| # data summary + download | |
| output$data_summary <- renderText({ | |
| req(processed_data()) | |
| df <- processed_data() | |
| paste( | |
| paste("Total rows:", nrow(df)), | |
| paste("Total columns:", ncol(df)), | |
| paste("Date range:", | |
| if ("Date" %in% names(df) && !all(is.na(df$Date))) { | |
| paste(min(as.Date(df$Date), na.rm=TRUE), "to", max(as.Date(df$Date), na.rm=TRUE)) | |
| } else "Date column not available"), | |
| paste("Unique pitchers:", | |
| if ("Pitcher" %in% names(df)) length(unique(df$Pitcher[!is.na(df$Pitcher)])) | |
| else "Pitcher column not available"), | |
| paste("Pitch types:", | |
| if ("TaggedPitchType" %in% names(df)) paste(sort(unique(df$TaggedPitchType[!is.na(df$TaggedPitchType)])), collapse=", ") | |
| else "TaggedPitchType column not available"), | |
| sep = "\n" | |
| ) | |
| }) | |
| output$downloadData <- downloadHandler( | |
| filename = function(){ paste0("app_ready_COA_", Sys.Date(), ".csv") }, | |
| content = function(file){ | |
| df_out <- processed_data() | |
| # Remove internal UID column before export | |
| if (".uid" %in% names(df_out)) { | |
| df_out <- df_out %>% select(-.uid) | |
| } | |
| write.csv(df_out, file, row.names=FALSE) | |
| } | |
| ) | |
| } | |
| shinyApp(ui = ui, server = server) |