Spaces:
Running
Running
| # app.R | |
| 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:white; 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; } | |
| ")) | |
| ), | |
| # 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"="single","Drag Select"="drag"), | |
| selected="single", inline=TRUE)), | |
| column(6, | |
| conditionalPanel( | |
| condition = "input.selection_mode == 'drag'", | |
| h4("Bulk Edit:"), | |
| fluidRow( | |
| column(8, selectInput("bulk_pitch_type","Change all selected to:", | |
| choices=c("Fastball","Sinker","Cutter","Slider","Curveball","ChangeUp","Splitter","Knuckleball","Other"), | |
| selected="Fastball")), | |
| column(4, br(), actionButton("apply_bulk_change","Apply to Selected", class="btn-success")) | |
| ) | |
| ) | |
| ) | |
| ), | |
| fluidRow( | |
| column(8, | |
| h3("Interactive Pitch Movement Analysis"), | |
| plotlyOutput("movement_plot", height="600px"), | |
| conditionalPanel( | |
| condition = "input.selection_mode == 'drag'", | |
| div(style="background-color:#f0f8ff; padding:10px; border-radius:5px; margin:10px 0; border-left:4px solid darkcyan;", | |
| h4("Selected Points:", style="margin-top:0; color:darkcyan;"), | |
| textOutput("selection_info") | |
| ) | |
| ), | |
| verbatimTextOutput("click_info") | |
| ), | |
| column(4, | |
| h3("Pitch Metrics Summary"), | |
| DTOutput("movement_stats"), | |
| h3("Location Plot (Editable)"), | |
| plotlyOutput("location_plot", height="600px") # <— new location plot | |
| ) | |
| ) | |
| ), | |
| 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 <- reactiveVal(NULL) | |
| selected_keys <- reactiveVal(integer(0)) # works for either chart | |
| # 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() | |
| processed_data(df) | |
| plot_data(df) | |
| 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]) | |
| } | |
| }, error = function(e){ | |
| showNotification(paste("Error processing file:", e$message), type="error") | |
| }) | |
| }) | |
| # 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") | |
| }) | |
| # pitcher-specific reactive with stable row_key (per pitcher group) | |
| pitcher_df <- reactive({ | |
| req(plot_data(), input$pitcher_select) | |
| plot_data() %>% | |
| filter(Pitcher == input$pitcher_select) %>% | |
| filter(!is.na(HorzBreak), !is.na(InducedVertBreak), !is.na(RelSpeed)) %>% | |
| mutate(row_key = row_number()) | |
| }) | |
| output$movement_plot <- renderPlotly({ | |
| d <- pitcher_df() | |
| validate(need(nrow(d) > 0, "No data for selected pitcher")) | |
| 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", | |
| key = ~row_key, | |
| color = ~factor(TaggedPitchType), # <-- data-driven | |
| colors = pitch_colors, # <-- your palette | |
| marker = list(size = 10) | |
| ) |> | |
| 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" | |
| ) |> | |
| config(displaylogo = FALSE) | |
| }) | |
| output$location_plot <- renderPlotly({ | |
| d <- pitcher_df() | |
| validate(need(nrow(d) > 0, "No data for selected pitcher")) | |
| plot_ly( | |
| data = d, source = "loc", type = "scatter", mode = "markers", | |
| x = ~PlateLocSide, y = ~PlateLocHeight, | |
| text = ~paste0( | |
| "<b>", TaggedPitchType, "</b>", | |
| "<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", | |
| key = ~row_key, | |
| color = ~factor(TaggedPitchType), # <-- data-driven | |
| colors = pitch_colors, # <-- your palette | |
| marker = list(size = 9) | |
| ) |> | |
| 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)) | |
| ) | |
| ) |> | |
| config(displaylogo = FALSE) | |
| }) | |
| # ---- Click handlers (single mode) for both plots ---- | |
| show_pitch_modal <- function(hit_row){ | |
| selected_pitch(list( | |
| pitcher = input$pitcher_select, | |
| row_key = hit_row$row_key[1], | |
| data = hit_row[1,], | |
| original_type = hit_row$TaggedPitchType[1] | |
| )) | |
| showModal( | |
| modalDialog( | |
| title = tags$h3("Selected Pitch Details:", style="color: darkcyan;"), | |
| verbatimTextOutput("selected_pitch_info"), | |
| 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 = selected_pitch()[["data"]][["TaggedPitchType"]]), | |
| tags$br(), | |
| actionButton("update_pitch","Update Pitch Type", class="btn-primary btn-lg"), | |
| actionButton("cancel_edit","Cancel", class="btn-default"), | |
| easyClose = TRUE, footer = NULL, size = "m" | |
| ) | |
| ) | |
| } | |
| observeEvent(event_data("plotly_click", source="mv"), { | |
| req(input$selection_mode == "single") | |
| clk <- event_data("plotly_click", source="mv"); req(nrow(as.data.frame(clk))>0) | |
| key <- clk$key[[1]] | |
| d <- pitcher_df(); hit <- d[d$row_key == key, ] | |
| if (nrow(hit)==1) show_pitch_modal(hit) | |
| }) | |
| observeEvent(event_data("plotly_click", source="loc"), { | |
| req(input$selection_mode == "single") | |
| clk <- event_data("plotly_click", source="loc"); req(nrow(as.data.frame(clk))>0) | |
| key <- clk$key[[1]] | |
| d <- pitcher_df(); hit <- d[d$row_key == key, ] | |
| if (nrow(hit)==1) show_pitch_modal(hit) | |
| }) | |
| # ---- Selected pitch info in modal ---- | |
| output$selected_pitch_info <- renderText({ | |
| info <- selected_pitch() | |
| if (is.null(info)) return("No pitch selected") | |
| d <- info$data | |
| paste( | |
| paste("Pitcher:", info$pitcher), | |
| paste("Current Type:", d$TaggedPitchType), | |
| paste("Velocity:", round(d$RelSpeed,1), "mph"), | |
| paste("Horizontal Break:", round(d$HorzBreak,1), "inches"), | |
| paste("Induced Vertical Break:", round(d$InducedVertBreak,1), "inches"), | |
| if ("SpinRate" %in% names(d) && !is.na(d$SpinRate)) paste("Spin Rate:", round(d$SpinRate,0), "rpm") else "", | |
| if ("Date" %in% names(d) && !is.na(d$Date)) paste("Date:", format(as.Date(d$Date))) else "", | |
| sep = "\n" | |
| ) | |
| }) | |
| # ---- Apply single-change from modal ---- | |
| observeEvent(input$update_pitch, { | |
| info <- selected_pitch(); req(info) | |
| new_type <- input$modal_new_pitch_type; req(new_type) | |
| cur <- plot_data(); req("Pitcher" %in% names(cur)) | |
| # map row_key back to absolute index for this pitcher slice | |
| idx_pitcher <- which(cur$Pitcher == info$pitcher) | |
| if (length(idx_pitcher) >= info$row_key && info$row_key > 0) { | |
| abs_row <- idx_pitcher[info$row_key] | |
| cur$TaggedPitchType[abs_row] <- new_type | |
| plot_data(cur); processed_data(cur) | |
| removeModal() | |
| showNotification(paste("Updated pitch from", info$original_type, "to", new_type), | |
| type="message", duration=3) | |
| selected_pitch(NULL) | |
| } else { | |
| showNotification("Could not map selection back to dataset.", type="error") | |
| } | |
| }) | |
| observeEvent(input$cancel_edit, { removeModal(); selected_pitch(NULL) }) | |
| # ---- Drag select (either chart) ---- | |
| observeEvent(event_data("plotly_selected", source="mv"), { | |
| req(input$selection_mode == "drag") | |
| ev <- event_data("plotly_selected", source="mv") | |
| if (!is.null(ev) && length(ev$key)) selected_keys(unique(as.integer(ev$key))) | |
| }) | |
| observeEvent(event_data("plotly_selected", source="loc"), { | |
| req(input$selection_mode == "drag") | |
| ev <- event_data("plotly_selected", source="loc") | |
| if (!is.null(ev) && length(ev$key)) selected_keys(unique(as.integer(ev$key))) | |
| }) | |
| # selection info text | |
| output$selection_info <- renderText({ | |
| keys <- selected_keys() | |
| if (!length(keys)) return("No points selected.") | |
| d <- pitcher_df(); sel <- d[d$row_key %in% keys, ] | |
| cnt <- sort(table(sel$TaggedPitchType), decreasing = TRUE) | |
| paste(nrow(sel), "points selected:", paste(names(cnt), "(", cnt, ")", collapse=", ")) | |
| }) | |
| # bulk change | |
| 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("Pitcher" %in% names(cur)) | |
| idx_pitcher <- which(cur$Pitcher == input$pitcher_select) | |
| valid_keys <- keys[keys > 0 & keys <= length(idx_pitcher)] | |
| abs_rows <- idx_pitcher[valid_keys] | |
| cur$TaggedPitchType[abs_rows] <- new_type | |
| plot_data(cur); processed_data(cur) | |
| selected_keys(integer(0)) | |
| showNotification(paste("Updated", length(abs_rows), "pitches to", new_type), | |
| type="message", duration=3) | |
| }) | |
| # click info small text | |
| output$click_info <- renderText({ | |
| info <- selected_pitch() | |
| if (is.null(info)) "No point selected yet. Click a point to edit its pitch type." | |
| else paste("Last selected pitch:", info$original_type, | |
| "| Position (HB, IVB): (", round(info$data$HorzBreak,1), ",", | |
| round(info$data$InducedVertBreak,1), ")") | |
| }) | |
| # metrics table | |
| output$movement_stats <- renderDT({ | |
| req(plot_data(), input$pitcher_select) | |
| 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){ write.csv(processed_data(), file, row.names=FALSE) } | |
| ) | |
| } | |
| shinyApp(ui = ui, server = server) |