igroffman commited on
Commit
e1f8016
·
verified ·
1 Parent(s): ce23775

Update app.R

Browse files
Files changed (1) hide show
  1. app.R +2110 -154
app.R CHANGED
@@ -15,6 +15,10 @@ library(tidymodels)
15
  library(httr)
16
  library(ggforce)
17
  library(jsonlite) # for parse_bat_tracking_json()
 
 
 
 
18
 
19
  PASSWORD <- Sys.getenv("password")
20
 
@@ -161,8 +165,69 @@ app_css <- "
161
  font-size: 1.2em;
162
  font-weight: bold;
163
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
164
  "
165
 
 
 
 
166
  stuffplus_recipe <- readRDS("stuffplus_recipe.rds")
167
 
168
  stuffplus_model <- xgb.load("stuffplus_xgb.json")
@@ -5848,61 +5913,431 @@ app_ui <- fluidPage(
5848
  ),
5849
  tabPanel("Data Processing",
5850
  br(),
5851
- fluidRow(
5852
- column(
5853
- 4,
5854
- div(class = "main-panel",
5855
- div(class = "upload-box",
5856
- h3("Process Raw TrackMan Data", style = "color: #006F71; margin-top: 0;"),
5857
- p("Clean a raw TrackMan export (add result metrics, run values) and optionally append Stuff+, then download the processed dataset for use in the report tabs.",
5858
- style = "font-size: 0.85em; color: #666;"),
5859
- fileInput("proc_file", "Game Data (CSV or Parquet)",
5860
- accept = c(".csv", "text/csv", ".parquet"),
5861
- buttonLabel = "Choose File...", placeholder = "No file selected"),
5862
- fileInput("proc_json", "Bat Tracking JSON (optional)",
5863
- accept = c(".json", "application/json"),
5864
- buttonLabel = "Choose JSON...", placeholder = "Optional - merged by PitchUID"),
5865
- radioButtons("proc_date_format", "Date Format",
5866
- c("YYYY-MM-DD" = "yyyy", "M/D/YY" = "mdyy"),
5867
- selected = "yyyy", inline = TRUE),
5868
- checkboxInput("proc_run_stuff", "Append Stuff+ predictions", value = TRUE),
5869
- actionButton("proc_run", "Process Data", class = "btn-primary",
5870
- style = "margin-top: 10px;")
5871
- ),
5872
- br(),
5873
- div(class = "upload-box",
5874
- h4("Columns to Remove", style = "color: #006F71; margin-top: 0;"),
5875
- div(style = "margin-bottom: 8px;",
5876
- actionButton("proc_cols_all", "Select All", class = "btn-secondary",
5877
- style = "width:auto; padding:4px 10px; font-size:12px; margin-right:4px;"),
5878
- actionButton("proc_cols_none", "Clear", class = "btn-secondary",
5879
- style = "width:auto; padding:4px 10px; font-size:12px; margin-right:4px;"),
5880
- actionButton("proc_cols_spin", "Spin-Axis Only", class = "btn-secondary",
5881
- style = "width:auto; padding:4px 10px; font-size:12px;")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5882
  ),
5883
- div(style = "max-height: 220px; overflow-y: auto; font-size: 0.85em;",
5884
- checkboxGroupInput("proc_drop_cols", NULL,
5885
- choices = columns_to_remove,
5886
- selected = columns_to_remove)
5887
- )
5888
- ),
5889
- hr(),
5890
- uiOutput("proc_download_ui")
5891
- )
5892
- ),
5893
- column(
5894
- 8,
5895
- div(class = "main-panel",
5896
- h3("Processed Data", style = "color: #006F71; margin-top: 0;"),
5897
- uiOutput("proc_status"),
5898
- verbatimTextOutput("proc_summary"),
5899
- br(),
5900
- h4("Preview (first 15 rows)", style = "color: #006F71;"),
5901
- div(style = "overflow-x: auto;",
5902
- tableOutput("proc_preview")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5903
  )
5904
- )
5905
- )
5906
  )
5907
  )
5908
  )
@@ -6948,127 +7383,1648 @@ server <- function(input, output, session) {
6948
  contentType = "application/zip"
6949
  )
6950
 
6951
- # ===================== DATA PROCESSING TAB =====================
6952
- proc_result <- reactiveVal(NULL)
6953
 
6954
- # Column-selection helper buttons
6955
- observeEvent(input$proc_cols_all, {
6956
- updateCheckboxGroupInput(session, "proc_drop_cols", selected = columns_to_remove)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6957
  })
6958
- observeEvent(input$proc_cols_none, {
6959
- updateCheckboxGroupInput(session, "proc_drop_cols", selected = character(0))
 
6960
  })
6961
- observeEvent(input$proc_cols_spin, {
6962
- spin_cols <- columns_to_remove[grepl("SpinAxis3d", columns_to_remove)]
6963
- updateCheckboxGroupInput(session, "proc_drop_cols", selected = spin_cols)
 
6964
  })
6965
-
6966
- # Run the pipeline
6967
- observeEvent(input$proc_run, {
6968
- if (is.null(input$proc_file)) {
6969
- showNotification("Please choose a CSV or Parquet file first.", type = "warning", duration = 5)
6970
- return(NULL)
 
 
6971
  }
6972
- json_path <- if (!is.null(input$proc_json)) input$proc_json$datapath else NULL
6973
-
6974
- withProgress(message = "Processing data...", value = 0, {
6975
- result <- tryCatch({
6976
- incProgress(0.2, detail = "Reading and cleaning")
6977
- df <- process_uploaded_trackman(
6978
- filepath = input$proc_file$datapath,
6979
- filename = input$proc_file$name,
6980
- json_path = json_path,
6981
- drop_columns = input$proc_drop_cols %||% character(0),
6982
- date_format = input$proc_date_format,
6983
- run_stuffplus = isTRUE(input$proc_run_stuff)
6984
- )
6985
- incProgress(0.7, detail = "Finalizing")
6986
- df
6987
- }, error = function(e) {
6988
- showNotification(paste("Processing error:", e$message), type = "error", duration = 10)
6989
- NULL
6990
- })
6991
- proc_result(result)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6992
  })
6993
-
6994
- if (!is.null(proc_result())) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6995
  showNotification(
6996
- paste0("Processed ", format(nrow(proc_result()), big.mark = ","),
6997
- " rows x ", ncol(proc_result()), " columns."),
6998
- type = "message", duration = 4
6999
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7000
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7001
  })
7002
-
7003
- output$proc_status <- renderUI({
7004
- if (is.null(proc_result())) {
7005
- div(style = "color:#888; font-style:italic;",
7006
- "Upload a raw TrackMan file and click Process Data to begin.")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7007
  } else {
7008
- div(style = "color:#006F71; font-weight:bold;", "Processing complete.")
7009
  }
7010
  })
7011
-
7012
- output$proc_summary <- renderPrint({
7013
- df <- proc_result()
7014
- if (is.null(df)) return(invisible(NULL))
7015
- has_stuff <- "stuff_plus" %in% names(df)
7016
- has_bat <- "BatSpeed_Sensor" %in% names(df)
7017
- cat("Rows: ", format(nrow(df), big.mark = ","), "\n", sep = "")
7018
- cat("Columns: ", ncol(df), "\n", sep = "")
7019
- cat("Stuff+ added:", if (has_stuff) " yes" else " no", "\n", sep = "")
7020
- if (has_stuff) {
7021
- sp <- suppressWarnings(round(mean(df$stuff_plus, na.rm = TRUE), 1))
7022
- cat("Mean Stuff+: ", sp, "\n", sep = "")
7023
  }
7024
- if (has_bat) {
7025
- matched <- sum(!is.na(df$BatSpeed_Sensor))
7026
- cat("Bat-tracking matched:", matched, "\n", sep = " ")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7027
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7028
  })
7029
-
7030
- output$proc_preview <- renderTable({
7031
- df <- proc_result()
7032
- if (is.null(df)) return(NULL)
7033
- pref <- c("Date", "Pitcher", "Batter", "TaggedPitchType", "PitchCall",
7034
- "PlayResult", "RelSpeed", "SpinRate", "stuff_plus", "woba")
7035
- cols <- intersect(pref, names(df))
7036
- if (length(cols) == 0) cols <- names(df)[seq_len(min(10, ncol(df)))]
7037
- head(df[, cols, drop = FALSE], 15)
7038
- }, striped = TRUE, hover = TRUE, bordered = TRUE, spacing = "xs", digits = 2, na = "")
7039
-
7040
- output$proc_download_ui <- renderUI({
7041
- if (is.null(proc_result())) return(NULL)
7042
- tagList(
7043
- downloadButton("proc_download_csv", "Download Processed CSV", class = "btn-primary"),
7044
- br(), br(),
7045
- downloadButton("proc_download_parquet", "Download Processed Parquet", class = "btn-secondary")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7046
  )
7047
  })
7048
-
7049
- output$proc_download_csv <- downloadHandler(
7050
- filename = function() {
7051
- base <- if (!is.null(input$proc_file)) tools::file_path_sans_ext(input$proc_file$name) else "trackman"
7052
- paste0(base, "_processed.csv")
7053
- },
7054
- content = function(file) {
7055
- df <- proc_result(); req(df)
7056
- write.csv(df, file, row.names = FALSE, na = "")
 
7057
  }
7058
- )
7059
-
7060
- output$proc_download_parquet <- downloadHandler(
7061
- filename = function() {
7062
- base <- if (!is.null(input$proc_file)) tools::file_path_sans_ext(input$proc_file$name) else "trackman"
7063
- paste0(base, "_processed.parquet")
7064
- },
7065
- content = function(file) {
7066
- df <- proc_result(); req(df)
7067
- arrow::write_parquet(df, file)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7068
  }
7069
- )
7070
- # =================== END DATA PROCESSING TAB ===================
7071
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7072
  }
7073
 
7074
  shinyApp(ui = ui, server = server)
 
15
  library(httr)
16
  library(ggforce)
17
  library(jsonlite) # for parse_bat_tracking_json()
18
+ library(readr) # read_csv() for gzipped scrape downloads
19
+ library(DT) # data preview / retag / scrape tables
20
+ library(shinyBS) # bsModal pitch-edit dialog
21
+ library(reticulate) # huggingface_hub upload in the Scraping tab
22
 
23
  PASSWORD <- Sys.getenv("password")
24
 
 
165
  font-size: 1.2em;
166
  font-weight: bold;
167
  }
168
+
169
+ /* ---- ported data-processing styles ---- */
170
+ /* Bat tracking upload box styling */
171
+ .bat-tracking-box {
172
+ background: linear-gradient(135deg, #e8f4f8 0%, #f0e6d3 100%);
173
+ border: 2px dashed darkcyan;
174
+ border-radius: 15px;
175
+ padding: 20px;
176
+ margin-top: 15px;
177
+ }
178
+ /* Catcher notes styling */
179
+ .catcher-notes-input-box {
180
+ background: linear-gradient(135deg, #e8f4f8 0%, #f0e6d3 100%);
181
+ border: 2px solid darkcyan;
182
+ border-radius: 15px;
183
+ padding: 20px;
184
+ }
185
+ /* Download section styling */
186
+ .download-option-box {
187
+ background: linear-gradient(135deg, #e8f4f8 0%, #f0e6d3 100%);
188
+ border: 1px solid rgba(0,139,139,.2);
189
+ border-radius: 12px;
190
+ padding: 20px;
191
+ margin-bottom: 15px;
192
+ }
193
+ .catcher-note-entry {
194
+ background: #fff;
195
+ border: 1px solid rgba(0,139,139,.15);
196
+ border-radius: 10px;
197
+ padding: 12px 16px;
198
+ margin-bottom: 8px;
199
+ display: flex;
200
+ justify-content: space-between;
201
+ align-items: center;
202
+ }
203
+ .catcher-note-entry:hover {
204
+ border-color: darkcyan;
205
+ box-shadow: 0 2px 8px rgba(0,139,139,.12);
206
+ }
207
+ .merge-status-box {
208
+ background: #f8f9fa;
209
+ border-left: 4px solid darkcyan;
210
+ padding: 15px;
211
+ border-radius: 0 10px 10px 0;
212
+ margin-top: 15px;
213
+ }
214
+ .merge-success {
215
+ border-left-color: #28a745;
216
+ background: #d4edda;
217
+ }
218
+ .merge-warning {
219
+ border-left-color: #ffc107;
220
+ background: #fff3cd;
221
+ }
222
+ .merge-error {
223
+ border-left-color: #dc3545;
224
+ background: #f8d7da;
225
+ }
226
  "
227
 
228
+
229
+
230
+
231
  stuffplus_recipe <- readRDS("stuffplus_recipe.rds")
232
 
233
  stuffplus_model <- xgb.load("stuffplus_xgb.json")
 
5913
  ),
5914
  tabPanel("Data Processing",
5915
  br(),
5916
+ tabsetPanel(id = "proc_tabs",
5917
+
5918
+ # Upload & Process Tab
5919
+ tabPanel(
5920
+ "Upload & Process",
5921
+ fluidRow(
5922
+ column(6,
5923
+ h3("1. Upload TrackMan CSV or Parquet"),
5924
+ fileInput("file", "Choose CSV or Parquet File (max 5,000 rows)",
5925
+ accept = c(".csv", ".parquet")),
5926
+ fluidRow(
5927
+ column(3,
5928
+ checkboxInput("header", "Header", TRUE)
5929
+ ),
5930
+ column(3,
5931
+ radioButtons("sep", "Separator",
5932
+ choices = c(Comma = ",", Semicolon = ";", Tab = "\t"),
5933
+ selected = ",", inline = TRUE)
5934
+ ),
5935
+ column(3,
5936
+ radioButtons("quote", "Quote",
5937
+ choices = c(None = "", "Double Quote" = '"', "Single Quote" = "'"),
5938
+ selected = '"', inline = TRUE)
5939
+ ),
5940
+ column(3,
5941
+ radioButtons("date_format", "Date Output Format",
5942
+ choices = c("YYYY-MM-DD" = "yyyy", "M/D/YY" = "mdyy"),
5943
+ selected = "yyyy")
5944
+ )
5945
+ ),
5946
+ p(style = "color: #666; font-size: 12px;",
5947
+ "CSV options (Header, Separator, Quote) are ignored for Parquet files."),
5948
+ verbatimTextOutput("csv_status")
5949
+ ),
5950
+ column(6,
5951
+ div(class = "bat-tracking-box",
5952
+ h3("2. Upload Bat Tracking JSON (Optional)", style = "margin-top: 0;"),
5953
+ fileInput("json_file", "Choose Bat Tracking JSON File", accept = c(".json")),
5954
+ p(style = "color: #666; font-size: 12px;",
5955
+ "Upload the corresponding _battracking.json file to merge bat speed and attack angle data."),
5956
+ verbatimTextOutput("json_status"),
5957
+ uiOutput("merge_status_ui")
5958
+ )
5959
+ )
5960
+ ),
5961
+
5962
+ hr(),
5963
+
5964
+ fluidRow(
5965
+ column(8,
5966
+ h3("3. Columns to Remove"),
5967
+ p("Select which columns to remove from your dataset:"),
5968
+ checkboxGroupInput("columns_to_remove", "Remove These Columns:",
5969
+ choices = columns_to_remove,
5970
+ selected = columns_to_remove)
5971
+ ),
5972
+ column(4,
5973
+ h3("Quick Actions"),
5974
+ br(),
5975
+ actionButton("select_all_cols", "Select All", class = "btn-primary"),
5976
+ br(), br(),
5977
+ actionButton("deselect_all_cols", "Deselect All", class = "btn-default"),
5978
+ br(), br(),
5979
+ actionButton("select_spinaxis", "Select SpinAxis3d Columns", class = "btn-info"),
5980
+ br(), br(),
5981
+ h4("Processing Summary"),
5982
+ verbatimTextOutput("process_summary")
5983
+ )
5984
+ )
5985
  ),
5986
+
5987
+ # Bat Tracking Details Tab
5988
+ tabPanel(
5989
+ "Bat Tracking Data",
5990
+ fluidRow(
5991
+ column(12,
5992
+ h3("Bat Tracking Merge Details"),
5993
+ uiOutput("bat_tracking_details"),
5994
+ hr(),
5995
+ h4("Pitches with Bat Tracking Data"),
5996
+ DT::dataTableOutput("bat_tracking_table")
5997
+ )
5998
+ )
5999
+ ),
6000
+
6001
+ # Preview Data Tab
6002
+ tabPanel(
6003
+ "Preview Data",
6004
+ fluidRow(
6005
+ column(12,
6006
+ h3("Data Preview"),
6007
+ DT::dataTableOutput("preview")
6008
+ )
6009
+ )
6010
+ ),
6011
+
6012
+ # Pitch Movement Chart Tab
6013
+ tabPanel(
6014
+ "Pitch Movement Chart",
6015
+ fluidRow(
6016
+ column(3,
6017
+ selectInput("pitcher_select", "Select Pitcher:",
6018
+ choices = NULL, selected = NULL)
6019
+ ),
6020
+ column(3,
6021
+ h4("Selection Mode:"),
6022
+ radioButtons("selection_mode", "",
6023
+ choices = list("Single Click" = "single", "Drag Select" = "drag"),
6024
+ selected = "single", inline = TRUE)
6025
+ ),
6026
+ column(6,
6027
+ conditionalPanel(
6028
+ condition = "input.selection_mode == 'drag'",
6029
+ h4("Bulk Edit:"),
6030
+ fluidRow(
6031
+ column(8,
6032
+ selectInput("bulk_pitch_type", "Change all selected to:",
6033
+ choices = c("Fastball", "Sinker", "Cutter", "Slider",
6034
+ "Curveball", "ChangeUp", "Splitter", "Knuckleball", "Sweeper", "Other"),
6035
+ selected = "Fastball")
6036
+ ),
6037
+ column(4,
6038
+ br(),
6039
+ actionButton("apply_bulk_change", "Apply to Selected", class = "btn-success")
6040
+ )
6041
+ )
6042
+ )
6043
+ )
6044
+ ),
6045
+
6046
+ fluidRow(
6047
+ column(8,
6048
+ h3("Interactive Pitch Movement Analysis"),
6049
+ plotOutput("movement_plot", height = "600px",
6050
+ click = "plot_click",
6051
+ brush = brushOpts(id = "plot_brush"),
6052
+ hover = hoverOpts(id = "plot_hover", delay = 100)),
6053
+
6054
+ h4("Instructions:"),
6055
+ p(strong("Single Click Mode:"), "Click on any point to edit one pitch type at a time via popup modal."),
6056
+ p(strong("Drag Select Mode:"), "Click and drag to select multiple points, then use the dropdown to change them all at once."),
6057
+ conditionalPanel(
6058
+ condition = "input.selection_mode == 'drag'",
6059
+ div(style = "background-color: #f0f8ff; padding: 10px; border-radius: 5px; margin: 10px 0; border-left: 4px solid darkcyan;",
6060
+ h4("Selected Points:", style = "margin-top: 0; color: darkcyan;"),
6061
+ textOutput("selection_info")
6062
+ )
6063
+ ),
6064
+ verbatimTextOutput("hover_info"),
6065
+ verbatimTextOutput("click_info")
6066
+ ),
6067
+
6068
+ column(4,
6069
+ h3("Pitch Metrics Summary"),
6070
+ DT::dataTableOutput("movement_stats")
6071
+ )
6072
+ ),
6073
+
6074
+ # ── Rule-Based Pitch Retagging Panel ──
6075
+ hr(),
6076
+ fluidRow(
6077
+ column(12,
6078
+ div(
6079
+ style = "background: linear-gradient(135deg, #e8f4f8 0%, #f0e6d3 100%); border: 2px solid darkcyan; border-radius: 15px; padding: 20px; margin-top: 10px;",
6080
+ h3("Rule-Based Pitch Retagging", style = "margin-top: 0; color: darkcyan; border-bottom: 2px solid darkcyan; padding-bottom: 8px;"),
6081
+ p(style = "color: #666; font-size: 13px; margin-bottom: 15px;",
6082
+ "Set filters to match pitches, then retag them all at once. ",
6083
+ "Only pitches matching ALL non-empty filters for the selected pitcher will be changed."),
6084
+
6085
+ fluidRow(
6086
+ # Column 1: Source pitch type + date range
6087
+ column(3,
6088
+ h4("Pitch Type & Date", style = "color: peru; margin-top: 0;"),
6089
+ selectInput("retag_from_type", "Current Pitch Type:",
6090
+ choices = c("(Any)" = "", "Fastball", "Sinker", "Cutter",
6091
+ "Slider", "Curveball", "ChangeUp", "Splitter",
6092
+ "Knuckleball", "Sweeper", "Other"),
6093
+ selected = ""),
6094
+ dateRangeInput("retag_dates", "Date Range (optional):",
6095
+ start = NA, end = NA),
6096
+ textInput("retag_pitch_no_range", "Pitch Numbers (e.g. 1-50):", value = "")
6097
+ ),
6098
+
6099
+ # Column 2: Velocity & spin filters
6100
+ column(3,
6101
+ h4("Velocity & Spin", style = "color: peru; margin-top: 0;"),
6102
+ fluidRow(
6103
+ column(6, numericInput("retag_velo_min", "Velo Min:", value = NA)),
6104
+ column(6, numericInput("retag_velo_max", "Velo Max:", value = NA))
6105
+ ),
6106
+ fluidRow(
6107
+ column(6, numericInput("retag_spin_min", "Spin Min:", value = NA)),
6108
+ column(6, numericInput("retag_spin_max", "Spin Max:", value = NA))
6109
+ )
6110
+ ),
6111
+
6112
+ # Column 3: Movement filters
6113
+ column(3,
6114
+ h4("Movement (inches)", style = "color: peru; margin-top: 0;"),
6115
+ fluidRow(
6116
+ column(6, numericInput("retag_ivb_min", "IVB Min:", value = NA)),
6117
+ column(6, numericInput("retag_ivb_max", "IVB Max:", value = NA))
6118
+ ),
6119
+ fluidRow(
6120
+ column(6, numericInput("retag_hb_min", "HB Min:", value = NA)),
6121
+ column(6, numericInput("retag_hb_max", "HB Max:", value = NA))
6122
+ ),
6123
+ fluidRow(
6124
+ column(6, numericInput("retag_vb_min", "VBreak Min:", value = NA)),
6125
+ column(6, numericInput("retag_vb_max", "VBreak Max:", value = NA))
6126
+ )
6127
+ ),
6128
+
6129
+ # Column 4: Target type + actions
6130
+ column(3,
6131
+ h4("Retag To", style = "color: peru; margin-top: 0;"),
6132
+ selectInput("retag_to_type", "New Pitch Type:",
6133
+ choices = c("Fastball", "Sinker", "Cutter", "Slider",
6134
+ "Curveball", "ChangeUp", "Splitter",
6135
+ "Knuckleball", "Sweeper", "Other"),
6136
+ selected = "Sweeper"),
6137
+ br(),
6138
+ actionButton("retag_preview_btn", "Preview Matches",
6139
+ class = "btn-info", style = "width: 100%;"),
6140
+ br(), br(),
6141
+ actionButton("retag_apply_btn", "Apply Retag",
6142
+ class = "btn-success", style = "width: 100%; font-weight: bold;"),
6143
+ br(), br(),
6144
+ actionButton("retag_clear_btn", "Clear Filters",
6145
+ class = "btn-default", style = "width: 100%;")
6146
+ )
6147
+ ),
6148
+
6149
+ # Preview results area
6150
+ div(
6151
+ style = "margin-top: 15px;",
6152
+ uiOutput("retag_preview_summary"),
6153
+ DT::dataTableOutput("retag_preview_table")
6154
+ )
6155
+ )
6156
+ )
6157
+ )
6158
+ ),
6159
+
6160
+ # ══════════════════════════════════════════════════════════════
6161
+ # Catcher Notes Tab
6162
+ # ══════════════════════════════════════════════════════════════
6163
+ tabPanel(
6164
+ "Catcher Notes",
6165
+ fluidRow(
6166
+ column(5,
6167
+ div(class = "catcher-notes-input-box",
6168
+ h3("Add Catcher Note", style = "margin-top: 0; color: darkcyan; border-bottom: 2px solid darkcyan; padding-bottom: 8px;"),
6169
+ p(style = "color: #666; font-size: 13px; margin-bottom: 15px;",
6170
+ "Log catcher events (throws, wild pitches, passed balls). ",
6171
+ "Each note is matched to the pitch row by Catcher, Batter, Inning, and Count, ",
6172
+ "then written into a CatcherNotes column on download."),
6173
+
6174
+ fluidRow(
6175
+ column(6, selectInput("cn_catcher", "Catcher:", choices = NULL)),
6176
+ column(6, selectInput("cn_batter", "Batter:", choices = NULL))
6177
+ ),
6178
+
6179
+ fluidRow(
6180
+ column(4, numericInput("cn_inning", "Inning:", value = 1, min = 1, max = 20, step = 1)),
6181
+ column(4, numericInput("cn_balls", "Balls:", value = 0, min = 0, max = 3, step = 1)),
6182
+ column(4, numericInput("cn_strikes", "Strikes:", value = 0, min = 0, max = 2, step = 1))
6183
+ ),
6184
+
6185
+ selectInput("cn_result", "Result:",
6186
+ choices = c("2B Out", "2B Safe", "3B Out", "3B Safe",
6187
+ "Wild Pitch", "Passed Ball",
6188
+ "Pickoff Attempt", "Pickoff Out",
6189
+ "Blocked Ball", "Other"),
6190
+ selected = "2B Out"),
6191
+
6192
+ conditionalPanel(
6193
+ condition = "input.cn_result == 'Other'",
6194
+ textInput("cn_custom_result", "Custom Note:", placeholder = "Describe the event...")
6195
+ ),
6196
+
6197
+ br(),
6198
+ actionButton("cn_add_btn", "Add Note", class = "btn-success",
6199
+ style = "width: 100%; font-weight: bold; font-size: 15px;"),
6200
+ br(), br(),
6201
+ uiOutput("cn_match_feedback")
6202
+ )
6203
+ ),
6204
+
6205
+ column(7,
6206
+ h3("Logged Catcher Notes"),
6207
+ p(style = "color: #666; font-size: 13px;",
6208
+ "These notes will be merged into the CatcherNotes column when you download the data."),
6209
+ DT::dataTableOutput("cn_notes_table"),
6210
+ br(),
6211
+ fluidRow(
6212
+ column(6,
6213
+ actionButton("cn_clear_all_btn", "Clear All Notes", class = "btn-danger",
6214
+ style = "width: 100%;")
6215
+ ),
6216
+ column(6,
6217
+ verbatimTextOutput("cn_summary")
6218
+ )
6219
+ )
6220
+ )
6221
+ )
6222
+ ),
6223
+
6224
+ tabPanel(
6225
+ "Download",
6226
+ fluidRow(
6227
+ column(12,
6228
+ h3("Download Processed Data"),
6229
+
6230
+ div(class = "download-option-box",
6231
+ h4("Processing Mode", style = "color: darkcyan; margin-top: 0;"),
6232
+ radioButtons("download_mode", NULL,
6233
+ choices = c(
6234
+ "Raw Edited CSV (as-is)" = "raw",
6235
+ "Full Processing (clean + indicators + Stuff+)" = "full"
6236
+ ),
6237
+ selected = "raw", inline = TRUE),
6238
+ conditionalPanel(
6239
+ condition = "input.download_mode == 'full'",
6240
+ div(style = "background: #d4edda; border-left: 4px solid #28a745; padding: 12px; border-radius: 0 8px 8px 0; margin: 10px 0;",
6241
+ p(style = "margin: 0; color: #155724; font-size: 13px;",
6242
+ tags$b("Full Processing will:"),
6243
+ "Normalize names & pitch types, convert PlateLocHeight/Side to inches, ",
6244
+ "add swing/whiff/chase/zone flags, compute wOBA/SLG/OBP indicators, ",
6245
+ "join run values, predict Stuff+, and trim to ~140 columns.")
6246
+ )
6247
+ ),
6248
+ conditionalPanel(
6249
+ condition = "input.download_mode == 'raw'",
6250
+ div(style = "background: #e8f4f8; border-left: 4px solid darkcyan; padding: 12px; border-radius: 0 8px 8px 0; margin: 10px 0;",
6251
+ p(style = "margin: 0; color: #006F71; font-size: 13px;",
6252
+ tags$b("Raw Edited:"),
6253
+ "Downloads your current data with any pitch retags, column removals, ",
6254
+ "bat tracking merges, and catcher notes applied — no additional processing.")
6255
+ )
6256
+ ),
6257
+ hr(),
6258
+ fluidRow(
6259
+ column(5,
6260
+ textInput("download_filename", "File Name (without extension):",
6261
+ value = paste0("app_ready_COA_", Sys.Date()))
6262
+ ),
6263
+ column(3,
6264
+ radioButtons("download_format", "Export Format:",
6265
+ choices = c("CSV" = "csv", "Parquet" = "parquet"),
6266
+ selected = "csv", inline = TRUE)
6267
+ ),
6268
+ hr(),
6269
+ radioButtons("download_team_filter", "Team Filter:",
6270
+ choices = c(
6271
+ "All Data" = "all",
6272
+ "Coastal Pitchers Only (PitcherTeam = COA_CHA)" = "coastal_pitchers",
6273
+ "Coastal Hitters Only (BatterTeam = COA_CHA)" = "coastal_hitters"
6274
+ ),
6275
+ selected = "all"),
6276
+ hr(),
6277
+ column(4,
6278
+ br(),
6279
+ downloadButton("downloadData", "Download", class = "btn-success btn-lg")
6280
+ )
6281
+ )
6282
+ ),
6283
+
6284
+ br(),
6285
+ h4("Data Summary:"),
6286
+ verbatimTextOutput("data_summary")
6287
+ )
6288
+ )
6289
+ ),
6290
+ #Scrape Tab
6291
+ tabPanel(
6292
+ "Scraping",
6293
+ fluidRow(
6294
+ column(2,
6295
+ h4("Data Source", style = "color: darkcyan; border-bottom: 2px solid darkcyan; padding-bottom: 6px;"),
6296
+ radioButtons("scrape_source", NULL,
6297
+ choices = c("TrackMan PBP" = "pbp",
6298
+ "TrackMan Positional" = "pos",
6299
+ "NCAA Scoreboard" = "ncaa"),
6300
+ selected = "pbp")
6301
+ ),
6302
+
6303
+ column(4,
6304
+ h3("Controls"),
6305
+
6306
+ dateInput("start_date", "Start Date:", value = Sys.Date() - 1),
6307
+ dateInput("end_date", "End Date:", value = Sys.Date() - 1),
6308
+
6309
+ uiOutput("scrape_options"),
6310
+
6311
+ br(),
6312
+ actionButton("scrape_btn", "Scrape Data", class = "btn-primary"),
6313
+ br(), br(),
6314
+ downloadButton("download_scrape", "Download CSV"),
6315
+ actionButton("upload_hf_btn", "Upload to HF Dataset", class = "btn-download")
6316
+ ),
6317
+
6318
+ column(6,
6319
+ h3("Progress"),
6320
+ verbatimTextOutput("scrape_status"),
6321
+ hr(),
6322
+ h3("Data Preview"),
6323
+ DT::dataTableOutput("scrape_preview")
6324
+ )
6325
+ )
6326
+ )
6327
+ ),,
6328
+ bsModal("pitchEditModal", "Edit Pitch Type", "triggerModal", size = "medium",
6329
+ div(style = "padding: 20px;",
6330
+ h4("Selected Pitch Details:", style = "color: darkcyan;"),
6331
+ verbatimTextOutput("selected_pitch_info"),
6332
+ br(),
6333
+ selectInput("modal_new_pitch_type", "Change Pitch Type To:",
6334
+ choices = c("Fastball", "Sinker", "Cutter", "Slider",
6335
+ "Curveball", "ChangeUp", "Splitter", "Knuckleball", "Sweeper", "Other"),
6336
+ selected = "Fastball"),
6337
+ br(),
6338
+ actionButton("update_pitch", "Update Pitch Type", class = "btn-primary btn-lg"),
6339
+ actionButton("cancel_edit", "Cancel", class = "btn-default")
6340
  )
 
 
6341
  )
6342
  )
6343
  )
 
7383
  contentType = "application/zip"
7384
  )
7385
 
 
 
7386
 
7387
+
7388
+ # ===== ported data-processing / scraping / retag server logic =====
7389
+ uploaded_file_type <- reactiveVal("csv")
7390
+
7391
+ # Reactive values
7392
+ processed_data <- reactiveVal(NULL)
7393
+ plot_data <- reactiveVal(NULL)
7394
+ selected_pitch <- reactiveVal(NULL)
7395
+ selected_points <- reactiveVal(NULL)
7396
+ csv_data_raw <- reactiveVal(NULL)
7397
+ bat_tracking_parsed <- reactiveVal(NULL)
7398
+ merge_result <- reactiveVal(NULL)
7399
+ scraped_data <- reactiveVal(NULL)
7400
+ scrape_polling <- reactiveVal(FALSE)
7401
+ scrape_status_msg <- reactiveVal("Ready.")
7402
+
7403
+ # Catcher Notes: stored as a list of data frames, each row is one note
7404
+ catcher_notes_list <- reactiveVal(data.frame(
7405
+ NoteID = integer(0),
7406
+ Catcher = character(0),
7407
+ Batter = character(0),
7408
+ Inning = integer(0),
7409
+ Balls = integer(0),
7410
+ Strikes = integer(0),
7411
+ Result = character(0),
7412
+ MatchedRow = integer(0),
7413
+ stringsAsFactors = FALSE
7414
+ ))
7415
+
7416
+ # Handle column selection buttons
7417
+ observeEvent(input$select_all_cols, {
7418
+ updateCheckboxGroupInput(session, "columns_to_remove",
7419
+ selected = columns_to_remove)
7420
  })
7421
+
7422
+ observeEvent(input$deselect_all_cols, {
7423
+ updateCheckboxGroupInput(session, "columns_to_remove", selected = character(0))
7424
  })
7425
+
7426
+ observeEvent(input$select_spinaxis, {
7427
+ spinaxis_cols <- columns_to_remove[grepl("SpinAxis3d", columns_to_remove)]
7428
+ updateCheckboxGroupInput(session, "columns_to_remove", selected = spinaxis_cols)
7429
  })
7430
+
7431
+ # Shared helper: process raw data into processed_data / plot_data
7432
+ run_processing <- function(df) {
7433
+ # Merge bat tracking if available
7434
+ if (!is.null(bat_tracking_parsed()) && !is.null(bat_tracking_parsed()$data)) {
7435
+ result <- merge_with_bat_tracking(df, bat_tracking_parsed()$data)
7436
+ merge_result(result)
7437
+ df <- result$data
7438
  }
7439
+
7440
+ selected_cols_to_remove <- input$columns_to_remove %||% character(0)
7441
+ processed_df <- df
7442
+
7443
+ if (length(selected_cols_to_remove) > 0) {
7444
+ columns_to_drop <- intersect(names(df), selected_cols_to_remove)
7445
+ if (length(columns_to_drop) > 0) {
7446
+ processed_df <- processed_df %>% select(-all_of(columns_to_drop))
7447
+ }
7448
+ }
7449
+
7450
+ processed_df <- processed_df %>% distinct()
7451
+
7452
+ processed_data(processed_df)
7453
+ plot_data(processed_df)
7454
+
7455
+ # Update catcher notes dropdowns
7456
+ update_catcher_note_choices(processed_df)
7457
+
7458
+ return(processed_df)
7459
+ }
7460
+
7461
+ # Helper to populate Catcher Notes dropdowns from current data
7462
+ update_catcher_note_choices <- function(df) {
7463
+ if (!is.null(df)) {
7464
+ if ("Catcher" %in% names(df)) {
7465
+ catchers <- sort(unique(df$Catcher[!is.na(df$Catcher) & df$Catcher != ""]))
7466
+ updateSelectInput(session, "cn_catcher", choices = catchers,
7467
+ selected = if (length(catchers) > 0) catchers[1] else NULL)
7468
+ }
7469
+ if ("Batter" %in% names(df)) {
7470
+ batters <- sort(unique(df$Batter[!is.na(df$Batter) & df$Batter != ""]))
7471
+ updateSelectInput(session, "cn_batter", choices = batters,
7472
+ selected = if (length(batters) > 0) batters[1] else NULL)
7473
+ }
7474
+ }
7475
+ }
7476
+
7477
+ # Re-process data when date format changes
7478
+ observeEvent(input$date_format, {
7479
+ req(input$file)
7480
+
7481
+ tryCatch({
7482
+ df <- read_uploaded_file(input$file$datapath, input$file$name,
7483
+ input$header, input$sep, input$quote)
7484
+
7485
+ df <- convert_date_columns(df, input$date_format)
7486
+ csv_data_raw(df)
7487
+ run_processing(df)
7488
+
7489
+ showNotification(
7490
+ paste("Date format updated to:", if (input$date_format == "mdyy") "M/D/YY" else "YYYY-MM-DD"),
7491
+ type = "message", duration = 3
7492
+ )
7493
+
7494
+ }, error = function(e) {
7495
+ showNotification(paste("Error updating date format:", e$message), type = "error")
7496
  })
7497
+ }, ignoreInit = TRUE)
7498
+
7499
+ # Process uploaded file (CSV or Parquet)
7500
+ observeEvent(input$file, {
7501
+ req(input$file)
7502
+
7503
+ tryCatch({
7504
+ ext <- tolower(tools::file_ext(input$file$name))
7505
+ uploaded_file_type(ext)
7506
+
7507
+ df <- read_uploaded_file(input$file$datapath, input$file$name,
7508
+ input$header, input$sep, input$quote)
7509
+
7510
+ df <- convert_date_columns(df, input$date_format)
7511
+ csv_data_raw(df)
7512
+
7513
+ processed_df <- run_processing(df)
7514
+
7515
+ # Update pitcher choices
7516
+ if ("Pitcher" %in% names(processed_df)) {
7517
+ pitcher_choices <- sort(unique(processed_df$Pitcher[!is.na(processed_df$Pitcher)]))
7518
+ updateSelectInput(session, "pitcher_select", choices = pitcher_choices, selected = pitcher_choices[1])
7519
+ }
7520
+
7521
+ # Auto-populate download filename from uploaded file
7522
+ base_name <- tools::file_path_sans_ext(input$file$name)
7523
+ updateTextInput(session, "download_filename", value = paste0(base_name, "_processed"))
7524
+
7525
+ format_label <- if (ext == "parquet") "Parquet" else "CSV"
7526
  showNotification(
7527
+ paste0(format_label, " loaded: ", nrow(df), " rows x ", ncol(df), " columns"),
7528
+ type = "message", duration = 3
 
7529
  )
7530
+
7531
+ }, error = function(e) {
7532
+ showNotification(paste("Error processing file:", e$message), type = "error")
7533
+ })
7534
+ })
7535
+
7536
+ # Process uploaded JSON file
7537
+ observeEvent(input$json_file, {
7538
+ req(input$json_file)
7539
+
7540
+ tryCatch({
7541
+ parsed <- parse_bat_tracking_json(input$json_file$datapath)
7542
+ bat_tracking_parsed(parsed)
7543
+
7544
+ if (!is.null(csv_data_raw()) && parsed$success && !is.null(parsed$data)) {
7545
+ result <- merge_with_bat_tracking(csv_data_raw(), parsed$data)
7546
+ merge_result(result)
7547
+
7548
+ df <- result$data
7549
+ selected_cols_to_remove <- input$columns_to_remove %||% character(0)
7550
+
7551
+ if (length(selected_cols_to_remove) > 0) {
7552
+ columns_to_drop <- intersect(names(df), selected_cols_to_remove)
7553
+ if (length(columns_to_drop) > 0) {
7554
+ df <- df %>% select(-all_of(columns_to_drop))
7555
+ }
7556
+ }
7557
+
7558
+ df <- df %>% distinct()
7559
+
7560
+ processed_data(df)
7561
+ plot_data(df)
7562
+
7563
+ showNotification(result$message, type = "message", duration = 5)
7564
+ }
7565
+
7566
+ }, error = function(e) {
7567
+ showNotification(paste("Error processing JSON:", e$message), type = "error")
7568
+ })
7569
+ })
7570
+
7571
+ # CSV/Parquet status output
7572
+ output$csv_status <- renderText({
7573
+ if (is.null(input$file)) {
7574
+ return("No file uploaded yet. Accepts CSV or Parquet (max 5,000 rows).")
7575
+ }
7576
+
7577
+ if (is.null(csv_data_raw())) {
7578
+ return("Processing file...")
7579
  }
7580
+
7581
+ df <- csv_data_raw()
7582
+ ext <- uploaded_file_type()
7583
+ format_label <- if (ext == "parquet") "Parquet" else "CSV"
7584
+ game_id <- if ("GameID" %in% names(df)) unique(df$GameID)[1] else "Unknown"
7585
+ date_fmt <- if (input$date_format == "mdyy") "M/D/YY" else "YYYY-MM-DD"
7586
+
7587
+ paste(
7588
+ paste0("\u2713 ", format_label, " loaded successfully!"),
7589
+ paste(" Game ID:", game_id),
7590
+ paste(" Rows:", nrow(df)),
7591
+ paste(" Columns:", ncol(df)),
7592
+ paste("\u2713 Date format:", date_fmt),
7593
+ sep = "\n"
7594
+ )
7595
  })
7596
+
7597
+ # JSON status output
7598
+ output$json_status <- renderText({
7599
+ if (is.null(input$json_file)) {
7600
+ return("No JSON file uploaded yet.")
7601
+ }
7602
+
7603
+ parsed <- bat_tracking_parsed()
7604
+ if (is.null(parsed)) {
7605
+ return("Processing JSON...")
7606
+ }
7607
+
7608
+ if (!parsed$success) {
7609
+ return(paste("\u2717", parsed$message))
7610
+ }
7611
+
7612
+ paste(
7613
+ "\u2713 JSON parsed successfully!",
7614
+ paste(" Game Reference:", parsed$game_reference),
7615
+ paste(" Plays found:", parsed$plays_count %||% 0),
7616
+ sep = "\n"
7617
+ )
7618
+ })
7619
+
7620
+ # Merge status UI
7621
+ output$merge_status_ui <- renderUI({
7622
+ result <- merge_result()
7623
+ parsed <- bat_tracking_parsed()
7624
+ csv <- csv_data_raw()
7625
+
7626
+ if (is.null(parsed) || is.null(csv)) {
7627
+ return(NULL)
7628
+ }
7629
+
7630
+ if (!parsed$success) {
7631
+ return(div(class = "merge-status-box merge-error",
7632
+ h4("Merge Status", style = "margin-top: 0; color: #721c24;"),
7633
+ p(parsed$message)
7634
+ ))
7635
+ }
7636
+
7637
+ if (is.null(parsed$data) || is.null(result)) {
7638
+ csv_game <- if ("GameID" %in% names(csv)) unique(csv$GameID)[1] else NULL
7639
+ json_game <- parsed$game_reference
7640
+
7641
+ if (!is.null(csv_game) && !is.null(json_game) && csv_game != json_game) {
7642
+ return(div(class = "merge-status-box merge-warning",
7643
+ h4("\u26A0 Game ID Mismatch", style = "margin-top: 0; color: #856404;"),
7644
+ p(paste("CSV Game:", csv_game)),
7645
+ p(paste("JSON Game:", json_game)),
7646
+ p("Files may be from different games!")
7647
+ ))
7648
+ }
7649
+
7650
+ return(div(class = "merge-status-box merge-warning",
7651
+ h4("No Data to Merge", style = "margin-top: 0; color: #856404;"),
7652
+ p(parsed$message)
7653
+ ))
7654
+ }
7655
+
7656
+ csv_game <- if ("GameID" %in% names(csv)) unique(csv$GameID)[1] else NULL
7657
+ json_game <- parsed$game_reference
7658
+ game_match <- is.null(csv_game) || is.null(json_game) || csv_game == json_game
7659
+
7660
+ if (result$matched > 0) {
7661
+ div(class = "merge-status-box merge-success",
7662
+ h4("\u2713 Merge Successful!", style = "margin-top: 0; color: #155724;"),
7663
+ p(paste("Matched:", result$matched, "of", result$total_bat, "bat tracking records")),
7664
+ if (!game_match) p(style = "color: #856404;", "\u26A0 Note: Game IDs differ but PitchUIDs matched")
7665
+ )
7666
+ } else {
7667
+ div(class = "merge-status-box merge-warning",
7668
+ h4("\u26A0 No Matches Found", style = "margin-top: 0; color: #856404;"),
7669
+ p(paste("0 of", result$total_bat, "bat tracking records matched")),
7670
+ if (!game_match) p(paste("Game ID mismatch: CSV =", csv_game, ", JSON =", json_game))
7671
+ )
7672
+ }
7673
+ })
7674
+
7675
+ # Bat tracking details
7676
+ output$bat_tracking_details <- renderUI({
7677
+ parsed <- bat_tracking_parsed()
7678
+ result <- merge_result()
7679
+
7680
+ if (is.null(parsed)) {
7681
+ return(div(
7682
+ p("No bat tracking JSON file uploaded."),
7683
+ p("Upload a _battracking.json file in the 'Upload & Process' tab to see bat tracking data here.")
7684
+ ))
7685
+ }
7686
+
7687
+ if (!parsed$success) {
7688
+ return(div(class = "alert alert-danger", parsed$message))
7689
+ }
7690
+
7691
+ if (is.null(parsed$data)) {
7692
+ return(div(class = "alert alert-warning",
7693
+ h4("Empty Bat Tracking File"),
7694
+ p(parsed$message),
7695
+ p("The JSON file was valid but contained no swing data in the Plays array.")
7696
+ ))
7697
+ }
7698
+
7699
+ div(
7700
+ div(class = "row",
7701
+ div(class = "col-md-4",
7702
+ div(class = "well",
7703
+ h4("Game Reference"),
7704
+ p(parsed$game_reference)
7705
+ )
7706
+ ),
7707
+ div(class = "col-md-4",
7708
+ div(class = "well",
7709
+ h4("Total Swings Tracked"),
7710
+ p(style = "font-size: 24px; font-weight: bold; color: darkcyan;", parsed$plays_count)
7711
+ )
7712
+ ),
7713
+ div(class = "col-md-4",
7714
+ div(class = "well",
7715
+ h4("Matched to CSV"),
7716
+ p(style = "font-size: 24px; font-weight: bold; color: #28a745;",
7717
+ if (!is.null(result)) result$matched else "N/A")
7718
+ )
7719
+ )
7720
+ )
7721
+ )
7722
+ })
7723
+
7724
+ # Bat tracking table
7725
+ output$bat_tracking_table <- DT::renderDataTable({
7726
+ df <- processed_data()
7727
+
7728
+ if (is.null(df)) {
7729
+ return(NULL)
7730
+ }
7731
+
7732
+ if ("BatSpeed_Sensor" %in% names(df)) {
7733
+ bat_rows <- df %>%
7734
+ filter(!is.na(BatSpeed_Sensor)) %>%
7735
+ select(
7736
+ any_of(c("PitchNo", "Time", "Pitcher", "Batter", "TaggedPitchType", "PitchCall",
7737
+ "RelSpeed", "ExitSpeed", "Angle",
7738
+ "BatSpeed", "BatSpeed_Sensor",
7739
+ "VerticalAttackAngle", "VerticalAttackAngle_Sensor",
7740
+ "HorizontalAttackAngle", "HorizontalAttackAngle_Sensor"))
7741
+ )
7742
+
7743
+ if (nrow(bat_rows) == 0) {
7744
+ return(NULL)
7745
+ }
7746
+
7747
+ DT::datatable(bat_rows,
7748
+ options = list(scrollX = TRUE, pageLength = 10),
7749
+ rownames = FALSE) %>%
7750
+ DT::formatRound(columns = intersect(names(bat_rows),
7751
+ c("BatSpeed_Sensor", "VerticalAttackAngle_Sensor",
7752
+ "HorizontalAttackAngle_Sensor", "RelSpeed",
7753
+ "ExitSpeed", "Angle")),
7754
+ digits = 1)
7755
  } else {
7756
+ return(NULL)
7757
  }
7758
  })
7759
+
7760
+ # Processing summary
7761
+ output$process_summary <- renderText({
7762
+ if (is.null(input$file)) {
7763
+ return("No file uploaded yet.")
 
 
 
 
 
 
 
7764
  }
7765
+
7766
+ if (is.null(processed_data())) {
7767
+ return("Processing...")
7768
+ }
7769
+
7770
+ df <- processed_data()
7771
+ original_df <- csv_data_raw()
7772
+ selected_cols_to_remove <- input$columns_to_remove %||% character(0)
7773
+ removed_cols <- intersect(selected_cols_to_remove, names(original_df))
7774
+ result <- merge_result()
7775
+ ext <- uploaded_file_type()
7776
+ format_label <- if (ext == "parquet") "Parquet" else "CSV"
7777
+
7778
+ removed_cols_text <- if (length(removed_cols) > 0) {
7779
+ paste("\u2713 Removed columns:", length(removed_cols))
7780
+ } else {
7781
+ "\u2713 Removed columns: 0"
7782
+ }
7783
+
7784
+ bat_tracking_text <- if (!is.null(result) && result$matched > 0) {
7785
+ paste("\u2713 Bat tracking merged:", result$matched, "pitches")
7786
+ } else if (!is.null(bat_tracking_parsed())) {
7787
+ "\u26A0 Bat tracking: No matches"
7788
+ } else {
7789
+ "\u25CB Bat tracking: Not uploaded"
7790
  }
7791
+
7792
+ notes <- catcher_notes_list()
7793
+ notes_text <- if (nrow(notes) > 0) {
7794
+ paste("\u2713 Catcher notes:", nrow(notes), "logged")
7795
+ } else {
7796
+ "\u25CB Catcher notes: None"
7797
+ }
7798
+
7799
+ summary_text <- paste(
7800
+ paste0("\u2713 ", format_label, " file processed successfully!"),
7801
+ paste("\u2713 Original columns:", ncol(original_df)),
7802
+ paste("\u2713 Final columns:", ncol(df)),
7803
+ paste("\u2713 Rows processed:", nrow(df)),
7804
+ removed_cols_text,
7805
+ bat_tracking_text,
7806
+ notes_text,
7807
+ "\u2713 Duplicates removed",
7808
+ paste("\u2713 Date format:", if (input$date_format == "mdyy") "M/D/YY" else "YYYY-MM-DD"),
7809
+ sep = "\n"
7810
+ )
7811
+
7812
+ return(summary_text)
7813
  })
7814
+
7815
+ # Preview table
7816
+ output$preview <- DT::renderDataTable({
7817
+ req(processed_data())
7818
+
7819
+ DT::datatable(processed_data(),
7820
+ options = list(scrollX = TRUE, pageLength = 10),
7821
+ filter = "top")
7822
+ })
7823
+
7824
+ # Movement plot
7825
+ output$movement_plot <- renderPlot({
7826
+ req(plot_data(), input$pitcher_select)
7827
+
7828
+ pitcher_data <- plot_data() %>%
7829
+ filter(Pitcher == input$pitcher_select) %>%
7830
+ filter(!is.na(TaggedPitchType), TaggedPitchType != "Other",
7831
+ !is.na(HorzBreak), !is.na(InducedVertBreak), !is.na(RelSpeed)) %>%
7832
+ mutate(pitch_id = row_number())
7833
+
7834
+ if (nrow(pitcher_data) == 0) {
7835
+ plot.new()
7836
+ text(0.5, 0.5, "No data available for selected pitcher", cex = 1.5)
7837
+ return()
7838
+ }
7839
+
7840
+ pitcher_data$color <- pitch_colors[pitcher_data$TaggedPitchType]
7841
+ pitcher_data$color[is.na(pitcher_data$color)] <- "#D3D3D3"
7842
+
7843
+ par(mar = c(5, 5, 4, 8), xpd = TRUE)
7844
+ plot(pitcher_data$HorzBreak, pitcher_data$InducedVertBreak,
7845
+ col = pitcher_data$color,
7846
+ pch = 19, cex = 1.5,
7847
+ xlim = c(-25, 25), ylim = c(-25, 25),
7848
+ xlab = "Horizontal Break (inches)",
7849
+ ylab = "Induced Vertical Break (inches)",
7850
+ main = paste("Pitch Movement Chart -", input$pitcher_select))
7851
+
7852
+ grid(nx = NULL, ny = NULL, col = "lightgray", lty = 1, lwd = 0.5)
7853
+ abline(h = 0, col = "gray", lty = 2, lwd = 1)
7854
+ abline(v = 0, col = "gray", lty = 2, lwd = 1)
7855
+
7856
+ for (r in c(6, 12, 18, 24)) {
7857
+ circle_x <- r * cos(seq(0, 2*pi, length.out = 100))
7858
+ circle_y <- r * sin(seq(0, 2*pi, length.out = 100))
7859
+ lines(circle_x, circle_y, col = "lightgray", lty = 3)
7860
+ }
7861
+
7862
+ if (input$selection_mode == "drag" && !is.null(selected_points())) {
7863
+ sel_points <- selected_points()
7864
+ points(sel_points$HorzBreak, sel_points$InducedVertBreak,
7865
+ pch = 21, cex = 2, col = "red", lwd = 3)
7866
+ }
7867
+
7868
+ unique_pitches <- unique(pitcher_data$TaggedPitchType)
7869
+ unique_colors <- pitch_colors[unique_pitches]
7870
+ legend("topright", inset = c(-0.15, 0),
7871
+ legend = unique_pitches,
7872
+ col = unique_colors,
7873
+ pch = 19,
7874
+ cex = 0.8,
7875
+ title = "Pitch Type")
7876
+ })
7877
+
7878
+ # Handle plot clicks (single mode only)
7879
+ observeEvent(input$plot_click, {
7880
+ req(plot_data(), input$pitcher_select, input$plot_click)
7881
+
7882
+ if (input$selection_mode != "single") return()
7883
+
7884
+ pitcher_data <- plot_data() %>%
7885
+ filter(Pitcher == input$pitcher_select) %>%
7886
+ filter(!is.na(TaggedPitchType), TaggedPitchType != "Other",
7887
+ !is.na(HorzBreak), !is.na(InducedVertBreak), !is.na(RelSpeed)) %>%
7888
+ mutate(pitch_id = row_number())
7889
+
7890
+ if (nrow(pitcher_data) == 0) return()
7891
+
7892
+ click_x <- input$plot_click$x
7893
+ click_y <- input$plot_click$y
7894
+
7895
+ distances <- sqrt((pitcher_data$HorzBreak - click_x)^2 +
7896
+ (pitcher_data$InducedVertBreak - click_y)^2)
7897
+
7898
+ closest_idx <- which.min(distances)
7899
+
7900
+ if (min(distances) <= 2) {
7901
+ clicked_pitch <- pitcher_data[closest_idx, ]
7902
+
7903
+ full_data <- plot_data() %>% filter(Pitcher == input$pitcher_select)
7904
+ original_row <- which(full_data$HorzBreak == clicked_pitch$HorzBreak &
7905
+ full_data$InducedVertBreak == clicked_pitch$InducedVertBreak &
7906
+ full_data$RelSpeed == clicked_pitch$RelSpeed)[1]
7907
+
7908
+ selected_pitch(list(
7909
+ pitcher = input$pitcher_select,
7910
+ row_in_pitcher_data = original_row,
7911
+ data = clicked_pitch,
7912
+ original_type = clicked_pitch$TaggedPitchType
7913
+ ))
7914
+
7915
+ updateSelectInput(session, "modal_new_pitch_type",
7916
+ selected = clicked_pitch$TaggedPitchType)
7917
+
7918
+ showModal(modalDialog(
7919
+ title = "Edit Pitch Type",
7920
+ div(style = "padding: 20px;",
7921
+ h4("Selected Pitch Details:", style = "color: darkcyan;"),
7922
+ verbatimTextOutput("selected_pitch_info"),
7923
+ br(),
7924
+ selectInput("modal_new_pitch_type", "Change Pitch Type To:",
7925
+ choices = c("Fastball", "Sinker", "Cutter", "Slider",
7926
+ "Curveball", "ChangeUp", "Splitter", "Knuckleball", "Sweeper","Other"),
7927
+ selected = clicked_pitch$TaggedPitchType),
7928
+ br(),
7929
+ actionButton("update_pitch", "Update Pitch Type", class = "btn-primary btn-lg"),
7930
+ actionButton("cancel_edit", "Cancel", class = "btn-default")
7931
+ ),
7932
+ footer = NULL,
7933
+ size = "m",
7934
+ easyClose = TRUE
7935
+ ))
7936
+ }
7937
+ })
7938
+
7939
+ # Handle brush selection (drag mode)
7940
+ observeEvent(input$plot_brush, {
7941
+ req(plot_data(), input$pitcher_select, input$plot_brush)
7942
+
7943
+ if (input$selection_mode != "drag") return()
7944
+
7945
+ pitcher_data <- plot_data() %>%
7946
+ filter(Pitcher == input$pitcher_select) %>%
7947
+ filter(!is.na(TaggedPitchType), TaggedPitchType != "Other",
7948
+ !is.na(HorzBreak), !is.na(InducedVertBreak), !is.na(RelSpeed))
7949
+
7950
+ if (nrow(pitcher_data) == 0) return()
7951
+
7952
+ brush <- input$plot_brush
7953
+ brushed_points <- pitcher_data %>%
7954
+ filter(
7955
+ HorzBreak >= brush$xmin & HorzBreak <= brush$xmax &
7956
+ InducedVertBreak >= brush$ymin & InducedVertBreak <= brush$ymax
7957
+ )
7958
+
7959
+ if (nrow(brushed_points) > 0) {
7960
+ selected_points(brushed_points)
7961
+ } else {
7962
+ selected_points(NULL)
7963
+ }
7964
+ })
7965
+
7966
+ # Apply bulk change
7967
+ observeEvent(input$apply_bulk_change, {
7968
+ req(selected_points(), input$bulk_pitch_type)
7969
+
7970
+ sel_points <- selected_points()
7971
+
7972
+ if (nrow(sel_points) == 0) {
7973
+ showNotification("No points selected", type = "warning")
7974
+ return()
7975
+ }
7976
+
7977
+ current_data <- plot_data()
7978
+
7979
+ for (i in 1:nrow(sel_points)) {
7980
+ point <- sel_points[i, ]
7981
+ current_data <- current_data %>%
7982
+ mutate(TaggedPitchType = ifelse(
7983
+ Pitcher == input$pitcher_select &
7984
+ abs(HorzBreak - point$HorzBreak) < 0.01 &
7985
+ abs(InducedVertBreak - point$InducedVertBreak) < 0.01 &
7986
+ abs(RelSpeed - point$RelSpeed) < 0.01,
7987
+ input$bulk_pitch_type,
7988
+ TaggedPitchType
7989
+ ))
7990
+ }
7991
+
7992
+ plot_data(current_data)
7993
+ processed_data(current_data)
7994
+ selected_points(NULL)
7995
+
7996
+ showNotification(
7997
+ paste("Updated", nrow(sel_points), "pitches to", input$bulk_pitch_type),
7998
+ type = "message", duration = 3
7999
  )
8000
  })
8001
+
8002
+ # Selection info for drag mode
8003
+ output$selection_info <- renderText({
8004
+ if (input$selection_mode == "drag" && !is.null(selected_points())) {
8005
+ sel_points <- selected_points()
8006
+ pitch_counts <- table(sel_points$TaggedPitchType)
8007
+ paste(nrow(sel_points), "points selected:",
8008
+ paste(names(pitch_counts), "(", pitch_counts, ")", collapse = ", "))
8009
+ } else {
8010
+ "No points selected. Click and drag to select multiple pitches."
8011
  }
8012
+ })
8013
+
8014
+ # Hover info
8015
+ output$hover_info <- renderText({
8016
+ req(input$plot_hover, plot_data(), input$pitcher_select)
8017
+
8018
+ pitcher_data <- plot_data() %>%
8019
+ filter(Pitcher == input$pitcher_select) %>%
8020
+ filter(!is.na(TaggedPitchType), TaggedPitchType != "Other",
8021
+ !is.na(HorzBreak), !is.na(InducedVertBreak), !is.na(RelSpeed))
8022
+
8023
+ if (nrow(pitcher_data) == 0) return("")
8024
+
8025
+ hover_x <- input$plot_hover$x
8026
+ hover_y <- input$plot_hover$y
8027
+
8028
+ distances <- sqrt((pitcher_data$HorzBreak - hover_x)^2 +
8029
+ (pitcher_data$InducedVertBreak - hover_y)^2)
8030
+
8031
+ if (min(distances) <= 2) {
8032
+ closest_idx <- which.min(distances)
8033
+ hover_pitch <- pitcher_data[closest_idx, ]
8034
+
8035
+ bat_info <- ""
8036
+ if ("BatSpeed_Sensor" %in% names(hover_pitch) && !is.na(hover_pitch$BatSpeed_Sensor)) {
8037
+ bat_info <- paste(" | Bat Speed:", round(hover_pitch$BatSpeed_Sensor, 1), "mph")
8038
+ }
8039
+
8040
+ paste("Hovering over:",
8041
+ paste("Type:", hover_pitch$TaggedPitchType),
8042
+ paste("Velocity:", round(hover_pitch$RelSpeed, 1), "mph"),
8043
+ paste("HB:", round(hover_pitch$HorzBreak, 1), "in"),
8044
+ paste("IVB:", round(hover_pitch$InducedVertBreak, 1), "in"),
8045
+ bat_info,
8046
+ sep = " | ")
8047
+ } else {
8048
+ ""
8049
  }
8050
+ })
8051
+
8052
+ # Movement stats table
8053
+ output$movement_stats <- DT::renderDataTable({
8054
+ req(plot_data(), input$pitcher_select)
8055
+
8056
+ data <- plot_data()
8057
+
8058
+ movement_stats <- data %>%
8059
+ filter(Pitcher == input$pitcher_select) %>%
8060
+ filter(!is.na(HorzBreak), !is.na(InducedVertBreak), !is.na(TaggedPitchType)) %>%
8061
+ mutate(
8062
+ pitch_group = case_when(
8063
+ TaggedPitchType %in% c("Fastball", "FourSeamFastBall", "FourSeamFastB", "Four-Seam", "4-Seam") ~ "Fastball",
8064
+ TaggedPitchType %in% c("OneSeamFastBall", "TwoSeamFastBall", "Sinker", "Two-Seam", "One-Seam") ~ "Sinker",
8065
+ TaggedPitchType %in% c("ChangeUp", "Changeup") ~ "Changeup",
8066
+ TRUE ~ TaggedPitchType
8067
+ ),
8068
+ in_zone = ifelse("StrikeZoneIndicator" %in% names(.), StrikeZoneIndicator,
8069
+ ifelse(!is.na(PlateLocSide) & !is.na(PlateLocHeight) &
8070
+ PlateLocSide >= -0.95 & PlateLocSide <= 0.95 &
8071
+ PlateLocHeight >= 1.6 & PlateLocHeight <= 3.5, 1, 0)),
8072
+ is_whiff = ifelse("WhiffIndicator" %in% names(.), WhiffIndicator,
8073
+ ifelse(!is.na(PitchCall) & PitchCall == "StrikeSwinging", 1, 0)),
8074
+ chase = ifelse("Chaseindicator" %in% names(.), Chaseindicator,
8075
+ ifelse(!is.na(PitchCall) & !is.na(PlateLocSide) & !is.na(PlateLocHeight) &
8076
+ PitchCall %in% c("StrikeSwinging", "FoulBallNotFieldable", "FoulBall", "InPlay") &
8077
+ (PlateLocSide < -0.95 | PlateLocSide > 0.95 | PlateLocHeight < 1.6 | PlateLocHeight > 3.5), 1, 0))
8078
+ )
8079
+
8080
+ total_pitches <- nrow(movement_stats)
8081
+
8082
+ has_bat_speed <- "BatSpeed_Sensor" %in% names(movement_stats)
8083
+
8084
+ summary_stats <- movement_stats %>%
8085
+ group_by(`Pitch Type` = pitch_group) %>%
8086
+ summarise(
8087
+ Count = n(),
8088
+ `Usage%` = sprintf("%.1f%%", (n() / total_pitches) * 100),
8089
+ `Avg Velo` = sprintf("%.1f", mean(RelSpeed, na.rm = TRUE)),
8090
+ `Max Velo` = sprintf("%.1f", max(RelSpeed, na.rm = TRUE)),
8091
+ `Avg IVB` = sprintf("%.1f", mean(InducedVertBreak, na.rm = TRUE)),
8092
+ `Avg HB` = sprintf("%.1f", mean(HorzBreak, na.rm = TRUE)),
8093
+ `Avg Spin` = ifelse("SpinRate" %in% names(movement_stats),
8094
+ sprintf("%.0f", mean(SpinRate, na.rm = TRUE)),
8095
+ "\u2014"),
8096
+ `Avg Bat Speed` = if (has_bat_speed) {
8097
+ bat_vals <- BatSpeed_Sensor[!is.na(BatSpeed_Sensor)]
8098
+ if (length(bat_vals) > 0) sprintf("%.1f", mean(bat_vals)) else "\u2014"
8099
+ } else "\u2014",
8100
+ `Zone%` = sprintf("%.1f%%", round(mean(in_zone, na.rm = TRUE) * 100, 1)),
8101
+ `Whiff%` = sprintf("%.1f%%", round(mean(is_whiff, na.rm = TRUE) * 100, 1)),
8102
+ .groups = "drop"
8103
+ ) %>%
8104
+ arrange(desc(Count))
8105
+
8106
+ DT::datatable(summary_stats,
8107
+ options = list(pageLength = 15, dom = 't', scrollX = TRUE),
8108
+ rownames = FALSE) %>%
8109
+ DT::formatStyle(columns = names(summary_stats), fontSize = '12px')
8110
+ })
8111
+
8112
+ # Selected pitch info in modal
8113
+ output$selected_pitch_info <- renderText({
8114
+ pitch_info <- selected_pitch()
8115
+ if (!is.null(pitch_info)) {
8116
+ pitch_data <- pitch_info$data
8117
+
8118
+ info_lines <- c(
8119
+ paste("Pitcher:", pitch_info$pitcher),
8120
+ if ("PitchNo" %in% names(pitch_data) && !is.na(pitch_data$PitchNo)) paste("Pitch No:", pitch_data$PitchNo) else NULL,
8121
+ if ("Batter" %in% names(pitch_data) && !is.na(pitch_data$Batter)) paste("Batter:", pitch_data$Batter) else NULL,
8122
+ paste("Current Type:", pitch_data$TaggedPitchType),
8123
+ paste("Velocity:", round(pitch_data$RelSpeed, 1), "mph"),
8124
+ paste("Horizontal Break:", round(pitch_data$HorzBreak, 1), "inches"),
8125
+ paste("Induced Vertical Break:", round(pitch_data$InducedVertBreak, 1), "inches")
8126
+ )
8127
+
8128
+ if ("SpinRate" %in% names(pitch_data) && !is.na(pitch_data$SpinRate)) {
8129
+ info_lines <- c(info_lines, paste("Spin Rate:", round(pitch_data$SpinRate, 0), "rpm"))
8130
+ }
8131
+
8132
+ if ("BatSpeed_Sensor" %in% names(pitch_data) && !is.na(pitch_data$BatSpeed_Sensor)) {
8133
+ info_lines <- c(info_lines,
8134
+ paste("Bat Speed:", round(pitch_data$BatSpeed_Sensor, 1), "mph"),
8135
+ paste("Vertical Attack Angle:", round(pitch_data$VerticalAttackAngle_Sensor, 1), "\u00B0"),
8136
+ paste("Horizontal Attack Angle:", round(pitch_data$HorizontalAttackAngle_Sensor, 1), "\u00B0"))
8137
+ }
8138
+
8139
+ if ("Date" %in% names(pitch_data) && !is.na(pitch_data$Date)) {
8140
+ info_lines <- c(info_lines, paste("Date:", pitch_data$Date))
8141
+ }
8142
+
8143
+ return(paste(info_lines, collapse = "\n"))
8144
+ } else {
8145
+ return("No pitch selected")
8146
+ }
8147
+ })
8148
+
8149
+ # Update pitch type
8150
+ observeEvent(input$update_pitch, {
8151
+ pitch_info <- selected_pitch()
8152
+
8153
+ if (!is.null(pitch_info)) {
8154
+ current_data <- plot_data()
8155
+
8156
+ target_pitcher <- pitch_info$pitcher
8157
+ target_hb <- pitch_info$data$HorzBreak
8158
+ target_ivb <- pitch_info$data$InducedVertBreak
8159
+ target_velo <- pitch_info$data$RelSpeed
8160
+
8161
+ current_data <- current_data %>%
8162
+ mutate(TaggedPitchType = ifelse(
8163
+ Pitcher == target_pitcher &
8164
+ abs(HorzBreak - target_hb) < 0.01 &
8165
+ abs(InducedVertBreak - target_ivb) < 0.01 &
8166
+ abs(RelSpeed - target_velo) < 0.01,
8167
+ input$modal_new_pitch_type,
8168
+ TaggedPitchType
8169
+ ))
8170
+
8171
+ plot_data(current_data)
8172
+ processed_data(current_data)
8173
+
8174
+ removeModal()
8175
+
8176
+ showNotification(
8177
+ paste("Updated pitch from", pitch_info$original_type, "to", input$modal_new_pitch_type),
8178
+ type = "message", duration = 3
8179
+ )
8180
+
8181
+ selected_pitch(NULL)
8182
+ }
8183
+ })
8184
+
8185
+ # Cancel edit
8186
+ observeEvent(input$cancel_edit, {
8187
+ removeModal()
8188
+ selected_pitch(NULL)
8189
+ })
8190
+
8191
+ # ══════════════════════════════════════════════════════════════
8192
+ # Rule-Based Pitch Retagging — Server Logic
8193
+ # ══════════════════════════════════════════════════════════════
8194
+
8195
+ # Reactive: compute the set of pitches matching all active filters
8196
+ retag_matched <- reactive({
8197
+ req(plot_data(), input$pitcher_select)
8198
+
8199
+ df <- plot_data() %>%
8200
+ filter(Pitcher == input$pitcher_select)
8201
+
8202
+ # Filter: Current pitch type
8203
+ if (!is.null(input$retag_from_type) && input$retag_from_type != "") {
8204
+ df <- df %>% filter(TaggedPitchType == input$retag_from_type)
8205
+ }
8206
+
8207
+ # Filter: Date range
8208
+ if ("Date" %in% names(df)) {
8209
+ d_start <- input$retag_dates[1]
8210
+ d_end <- input$retag_dates[2]
8211
+ if (!is.na(d_start) && !is.na(d_end)) {
8212
+ df <- df %>%
8213
+ filter(!is.na(Date)) %>%
8214
+ mutate(.tmp_date = as.Date(Date)) %>%
8215
+ filter(.tmp_date >= as.Date(d_start), .tmp_date <= as.Date(d_end)) %>%
8216
+ select(-.tmp_date)
8217
+ }
8218
+ }
8219
+
8220
+ # Filter: Pitch number range (supports "1-50" or single number)
8221
+ if (!is.null(input$retag_pitch_no_range) && nzchar(trimws(input$retag_pitch_no_range))) {
8222
+ pn_text <- trimws(input$retag_pitch_no_range)
8223
+ if (grepl("^\\d+-\\d+$", pn_text)) {
8224
+ parts <- as.numeric(strsplit(pn_text, "-")[[1]])
8225
+ if ("PitchNo" %in% names(df)) {
8226
+ df <- df %>% filter(!is.na(PitchNo), PitchNo >= parts[1], PitchNo <= parts[2])
8227
+ }
8228
+ } else if (grepl("^\\d+$", pn_text)) {
8229
+ if ("PitchNo" %in% names(df)) {
8230
+ df <- df %>% filter(PitchNo == as.numeric(pn_text))
8231
+ }
8232
+ }
8233
+ }
8234
+
8235
+ # Filter: Velocity
8236
+ if (!is.na(input$retag_velo_min)) df <- df %>% filter(!is.na(RelSpeed), RelSpeed >= input$retag_velo_min)
8237
+ if (!is.na(input$retag_velo_max)) df <- df %>% filter(!is.na(RelSpeed), RelSpeed <= input$retag_velo_max)
8238
+
8239
+ # Filter: Spin Rate
8240
+ if ("SpinRate" %in% names(df)) {
8241
+ if (!is.na(input$retag_spin_min)) df <- df %>% filter(!is.na(SpinRate), SpinRate >= input$retag_spin_min)
8242
+ if (!is.na(input$retag_spin_max)) df <- df %>% filter(!is.na(SpinRate), SpinRate <= input$retag_spin_max)
8243
+ }
8244
+
8245
+ # Filter: Induced Vertical Break
8246
+ if (!is.na(input$retag_ivb_min)) df <- df %>% filter(!is.na(InducedVertBreak), InducedVertBreak >= input$retag_ivb_min)
8247
+ if (!is.na(input$retag_ivb_max)) df <- df %>% filter(!is.na(InducedVertBreak), InducedVertBreak <= input$retag_ivb_max)
8248
+
8249
+ # Filter: Horizontal Break
8250
+ if (!is.na(input$retag_hb_min)) df <- df %>% filter(!is.na(HorzBreak), HorzBreak >= input$retag_hb_min)
8251
+ if (!is.na(input$retag_hb_max)) df <- df %>% filter(!is.na(HorzBreak), HorzBreak <= input$retag_hb_max)
8252
+
8253
+ # Filter: Vertical Break
8254
+ if ("VertBreak" %in% names(df)) {
8255
+ if (!is.na(input$retag_vb_min)) df <- df %>% filter(!is.na(VertBreak), VertBreak >= input$retag_vb_min)
8256
+ if (!is.na(input$retag_vb_max)) df <- df %>% filter(!is.na(VertBreak), VertBreak <= input$retag_vb_max)
8257
+ }
8258
+
8259
+ df
8260
+ })
8261
+
8262
+ # Preview button: show what would be retagged
8263
+ observeEvent(input$retag_preview_btn, {
8264
+ matched <- retag_matched()
8265
+
8266
+ output$retag_preview_summary <- renderUI({
8267
+ n <- nrow(matched)
8268
+ if (n == 0) {
8269
+ div(class = "merge-status-box merge-warning",
8270
+ style = "margin-bottom: 10px;",
8271
+ p(style = "margin: 0; font-weight: 600; color: #856404;",
8272
+ paste0("No pitches match these filters for ", input$pitcher_select, ".")))
8273
+ } else {
8274
+ type_counts <- table(matched$TaggedPitchType)
8275
+ type_str <- paste(names(type_counts), "(", type_counts, ")", collapse = ", ")
8276
+ div(class = "merge-status-box merge-success",
8277
+ style = "margin-bottom: 10px;",
8278
+ p(style = "margin: 0; font-weight: 600; color: #155724;",
8279
+ paste0(n, " pitches matched \u2014 will retag to ", input$retag_to_type)),
8280
+ p(style = "margin: 4px 0 0 0; color: #155724; font-size: 13px;", type_str))
8281
+ }
8282
+ })
8283
+
8284
+ output$retag_preview_table <- DT::renderDataTable({
8285
+ if (nrow(matched) == 0) return(NULL)
8286
+
8287
+ preview_cols <- intersect(
8288
+ c("PitchNo", "Date", "Batter", "TaggedPitchType", "RelSpeed",
8289
+ "SpinRate", "InducedVertBreak", "HorzBreak", "VertBreak",
8290
+ "PlateLocSide", "PlateLocHeight", "PitchCall"),
8291
+ names(matched)
8292
+ )
8293
+
8294
+ DT::datatable(
8295
+ matched %>% select(all_of(preview_cols)),
8296
+ options = list(scrollX = TRUE, pageLength = 8, dom = "tip"),
8297
+ rownames = FALSE
8298
+ ) %>%
8299
+ DT::formatRound(
8300
+ columns = intersect(preview_cols, c("RelSpeed", "InducedVertBreak", "HorzBreak", "VertBreak")),
8301
+ digits = 1
8302
+ )
8303
+ })
8304
+ })
8305
+
8306
+ # Apply retag: actually change TaggedPitchType
8307
+ observeEvent(input$retag_apply_btn, {
8308
+ matched <- retag_matched()
8309
+
8310
+ if (nrow(matched) == 0) {
8311
+ showNotification("No pitches match the current filters.", type = "warning")
8312
+ return()
8313
+ }
8314
+
8315
+ current_data <- plot_data()
8316
+
8317
+ # Build composite key from available columns to uniquely identify pitches
8318
+ key_cols <- intersect(c("PitchUID", "PitchNo", "Date", "Pitcher", "RelSpeed",
8319
+ "HorzBreak", "InducedVertBreak"), names(current_data))
8320
+
8321
+ make_key <- function(df) {
8322
+ do.call(paste, c(df[key_cols], sep = "||"))
8323
+ }
8324
+
8325
+ matched_keys <- make_key(matched)
8326
+
8327
+ current_data <- current_data %>%
8328
+ mutate(.retag_key = make_key(cur_data())) %>%
8329
+ mutate(TaggedPitchType = ifelse(
8330
+ .retag_key %in% matched_keys & Pitcher == input$pitcher_select,
8331
+ input$retag_to_type,
8332
+ TaggedPitchType
8333
+ )) %>%
8334
+ select(-.retag_key)
8335
+
8336
+ plot_data(current_data)
8337
+ processed_data(current_data)
8338
+
8339
+ n_matched <- nrow(matched)
8340
+ showNotification(
8341
+ paste0("Retagged ", n_matched, " pitches to ", input$retag_to_type),
8342
+ type = "message", duration = 4
8343
+ )
8344
+
8345
+ # Update preview to show completion
8346
+ output$retag_preview_summary <- renderUI({
8347
+ div(class = "merge-status-box merge-success",
8348
+ style = "margin-bottom: 10px;",
8349
+ p(style = "margin: 0; font-weight: 600; color: #155724;",
8350
+ paste0("\u2713 Done! ", n_matched, " pitches retagged to ", input$retag_to_type, ".")))
8351
+ })
8352
+ output$retag_preview_table <- DT::renderDataTable({ NULL })
8353
+ })
8354
+
8355
+ # Clear all retag filters
8356
+ observeEvent(input$retag_clear_btn, {
8357
+ updateSelectInput(session, "retag_from_type", selected = "")
8358
+ updateDateRangeInput(session, "retag_dates", start = NA, end = NA)
8359
+ updateTextInput(session, "retag_pitch_no_range", value = "")
8360
+ updateNumericInput(session, "retag_velo_min", value = NA)
8361
+ updateNumericInput(session, "retag_velo_max", value = NA)
8362
+ updateNumericInput(session, "retag_spin_min", value = NA)
8363
+ updateNumericInput(session, "retag_spin_max", value = NA)
8364
+ updateNumericInput(session, "retag_ivb_min", value = NA)
8365
+ updateNumericInput(session, "retag_ivb_max", value = NA)
8366
+ updateNumericInput(session, "retag_hb_min", value = NA)
8367
+ updateNumericInput(session, "retag_hb_max", value = NA)
8368
+ updateNumericInput(session, "retag_vb_min", value = NA)
8369
+ updateNumericInput(session, "retag_vb_max", value = NA)
8370
+
8371
+ output$retag_preview_summary <- renderUI({ NULL })
8372
+ output$retag_preview_table <- DT::renderDataTable({ NULL })
8373
+ })
8374
+
8375
+ # ══════════════════════════════════════════════════════════════
8376
+ # End Rule-Based Retagging
8377
+ # ══════════════════════════════════════════════════════════════
8378
+
8379
+ # ══════════════════════════════════════════════════════════════
8380
+ # Catcher Notes — Server Logic
8381
+ # ══════════════════════════════════════════════════════════════
8382
+
8383
+ # Add a catcher note
8384
+ observeEvent(input$cn_add_btn, {
8385
+ req(processed_data(), input$cn_catcher, input$cn_batter)
8386
+
8387
+ df <- processed_data()
8388
+
8389
+ # Determine the result text
8390
+ result_text <- input$cn_result
8391
+ if (result_text == "Other" && !is.null(input$cn_custom_result) && nzchar(trimws(input$cn_custom_result))) {
8392
+ result_text <- trimws(input$cn_custom_result)
8393
+ }
8394
+
8395
+ # Find the matching row(s): Catcher + Batter + Inning + Balls + Strikes
8396
+ # We match the LAST pitch in that count for that matchup in that inning
8397
+ # (the event most likely happened on the final pitch of that count)
8398
+ has_catcher <- "Catcher" %in% names(df)
8399
+ has_batter <- "Batter" %in% names(df)
8400
+ has_inning <- "Inning" %in% names(df)
8401
+ has_balls <- "Balls" %in% names(df)
8402
+ has_strikes <- "Strikes" %in% names(df)
8403
+
8404
+ candidates <- df
8405
+ if (has_catcher) candidates <- candidates %>% filter(Catcher == input$cn_catcher)
8406
+ if (has_batter) candidates <- candidates %>% filter(Batter == input$cn_batter)
8407
+ if (has_inning) candidates <- candidates %>% filter(Inning == input$cn_inning)
8408
+ if (has_balls) candidates <- candidates %>% filter(Balls == input$cn_balls)
8409
+ if (has_strikes) candidates <- candidates %>% filter(Strikes == input$cn_strikes)
8410
+
8411
+ # Get the row index in the full dataframe for the last matching pitch
8412
+ if (nrow(candidates) > 0) {
8413
+ # Find which rows in the full df match
8414
+ match_idx <- which(
8415
+ (if (has_catcher) df$Catcher == input$cn_catcher else TRUE) &
8416
+ (if (has_batter) df$Batter == input$cn_batter else TRUE) &
8417
+ (if (has_inning) df$Inning == input$cn_inning else TRUE) &
8418
+ (if (has_balls) df$Balls == input$cn_balls else TRUE) &
8419
+ (if (has_strikes) df$Strikes == input$cn_strikes else TRUE)
8420
+ )
8421
+ matched_row <- max(match_idx) # last pitch at that count
8422
+ } else {
8423
+ matched_row <- NA_integer_
8424
+ }
8425
+
8426
+ # Build the new note
8427
+ notes <- catcher_notes_list()
8428
+ new_id <- if (nrow(notes) == 0) 1L else max(notes$NoteID) + 1L
8429
+
8430
+ new_note <- data.frame(
8431
+ NoteID = new_id,
8432
+ Catcher = input$cn_catcher,
8433
+ Batter = input$cn_batter,
8434
+ Inning = as.integer(input$cn_inning),
8435
+ Balls = as.integer(input$cn_balls),
8436
+ Strikes = as.integer(input$cn_strikes),
8437
+ Result = result_text,
8438
+ MatchedRow = matched_row,
8439
+ stringsAsFactors = FALSE
8440
+ )
8441
+
8442
+ catcher_notes_list(bind_rows(notes, new_note))
8443
+
8444
+ # Show match feedback
8445
+ output$cn_match_feedback <- renderUI({
8446
+ if (!is.na(matched_row)) {
8447
+ pitch_info <- df[matched_row, ]
8448
+ detail_parts <- c()
8449
+ if ("PitchNo" %in% names(pitch_info) && !is.na(pitch_info$PitchNo))
8450
+ detail_parts <- c(detail_parts, paste("Pitch #", pitch_info$PitchNo))
8451
+ if ("PitchCall" %in% names(pitch_info) && !is.na(pitch_info$PitchCall))
8452
+ detail_parts <- c(detail_parts, pitch_info$PitchCall)
8453
+ if ("Pitcher" %in% names(pitch_info) && !is.na(pitch_info$Pitcher))
8454
+ detail_parts <- c(detail_parts, paste("vs", pitch_info$Pitcher))
8455
+
8456
+ div(class = "merge-status-box merge-success",
8457
+ style = "margin-top: 10px;",
8458
+ p(style = "margin: 0; font-weight: 600; color: #155724;",
8459
+ paste0("\u2713 Matched to row ", matched_row)),
8460
+ if (length(detail_parts) > 0)
8461
+ p(style = "margin: 4px 0 0 0; color: #155724; font-size: 13px;",
8462
+ paste(detail_parts, collapse = " | "))
8463
+ )
8464
+ } else {
8465
+ div(class = "merge-status-box merge-warning",
8466
+ style = "margin-top: 10px;",
8467
+ p(style = "margin: 0; font-weight: 600; color: #856404;",
8468
+ paste0("\u26A0 No matching pitch found for ", input$cn_catcher,
8469
+ " / ", input$cn_batter, " / Inn ", input$cn_inning,
8470
+ " / ", input$cn_balls, "-", input$cn_strikes)),
8471
+ p(style = "margin: 4px 0 0 0; color: #856404; font-size: 13px;",
8472
+ "Note saved anyway \u2014 it will appear in CatcherNotes column as unmatched.")
8473
+ )
8474
+ }
8475
+ })
8476
+
8477
+ showNotification(
8478
+ paste0("Added: ", result_text, " (", input$cn_catcher, " / ", input$cn_batter,
8479
+ " / Inn ", input$cn_inning, " / ", input$cn_balls, "-", input$cn_strikes, ")"),
8480
+ type = "message", duration = 3
8481
+ )
8482
+ })
8483
+
8484
+ # Render the notes table
8485
+ output$cn_notes_table <- DT::renderDataTable({
8486
+ notes <- catcher_notes_list()
8487
+ if (nrow(notes) == 0) return(NULL)
8488
+
8489
+ display_notes <- notes %>%
8490
+ mutate(
8491
+ Count = paste0(Balls, "-", Strikes),
8492
+ Match = ifelse(is.na(MatchedRow), "\u2717 No match", paste0("\u2713 Row ", MatchedRow))
8493
+ ) %>%
8494
+ select(NoteID, Catcher, Batter, Inning, Count, Result, Match)
8495
+
8496
+ DT::datatable(
8497
+ display_notes,
8498
+ options = list(
8499
+ scrollX = TRUE, pageLength = 15, dom = "tip",
8500
+ columnDefs = list(list(className = "dt-center", targets = "_all"))
8501
+ ),
8502
+ rownames = FALSE,
8503
+ selection = "single",
8504
+ callback = DT::JS("
8505
+ table.on('click', 'tr', function() {
8506
+ var data = table.row(this).data();
8507
+ if (data) {
8508
+ Shiny.setInputValue('cn_delete_row', data[0], {priority: 'event'});
8509
+ }
8510
+ });
8511
+ ")
8512
+ ) %>%
8513
+ DT::formatStyle("Match",
8514
+ color = DT::styleEqual(c("\u2717 No match"), c("#dc3545")),
8515
+ fontWeight = "bold"
8516
+ )
8517
+ })
8518
+
8519
+ # Delete a single note by clicking its row
8520
+ observeEvent(input$cn_delete_row, {
8521
+ notes <- catcher_notes_list()
8522
+ note_id <- as.integer(input$cn_delete_row)
8523
+
8524
+ if (note_id %in% notes$NoteID) {
8525
+ showModal(modalDialog(
8526
+ title = "Delete Catcher Note?",
8527
+ p(paste("Remove note #", note_id, "?")),
8528
+ footer = tagList(
8529
+ actionButton("cn_confirm_delete", "Delete", class = "btn-danger"),
8530
+ modalButton("Cancel")
8531
+ ),
8532
+ size = "s", easyClose = TRUE
8533
+ ))
8534
+ }
8535
+ })
8536
+
8537
+ observeEvent(input$cn_confirm_delete, {
8538
+ notes <- catcher_notes_list()
8539
+ note_id <- as.integer(input$cn_delete_row)
8540
+ catcher_notes_list(notes %>% filter(NoteID != note_id))
8541
+ removeModal()
8542
+ showNotification(paste("Deleted note #", note_id), type = "message", duration = 2)
8543
+ })
8544
+
8545
+ # Clear all notes
8546
+ observeEvent(input$cn_clear_all_btn, {
8547
+ showModal(modalDialog(
8548
+ title = "Clear All Catcher Notes?",
8549
+ p("This will remove all logged catcher notes. This cannot be undone."),
8550
+ footer = tagList(
8551
+ actionButton("cn_confirm_clear_all", "Clear All", class = "btn-danger"),
8552
+ modalButton("Cancel")
8553
+ ),
8554
+ size = "s", easyClose = TRUE
8555
+ ))
8556
+ })
8557
+
8558
+ observeEvent(input$cn_confirm_clear_all, {
8559
+ catcher_notes_list(data.frame(
8560
+ NoteID = integer(0), Catcher = character(0), Batter = character(0),
8561
+ Inning = integer(0), Balls = integer(0), Strikes = integer(0),
8562
+ Result = character(0), MatchedRow = integer(0), stringsAsFactors = FALSE
8563
+ ))
8564
+ removeModal()
8565
+ output$cn_match_feedback <- renderUI({ NULL })
8566
+ showNotification("All catcher notes cleared.", type = "message", duration = 2)
8567
+ })
8568
+
8569
+ # Catcher notes summary
8570
+ output$cn_summary <- renderText({
8571
+ notes <- catcher_notes_list()
8572
+ if (nrow(notes) == 0) return("No notes logged yet.")
8573
+
8574
+ n_matched <- sum(!is.na(notes$MatchedRow))
8575
+ n_unmatched <- sum(is.na(notes$MatchedRow))
8576
+ result_counts <- table(notes$Result)
8577
+ result_str <- paste(names(result_counts), "(", result_counts, ")", collapse = ", ")
8578
+
8579
+ paste(
8580
+ paste("Total notes:", nrow(notes)),
8581
+ paste("Matched:", n_matched, "| Unmatched:", n_unmatched),
8582
+ paste("Results:", result_str),
8583
+ sep = "\n"
8584
+ )
8585
+ })
8586
+
8587
+ build_download_data <- function() {
8588
+ df <- processed_data()
8589
+ if (is.null(df)) return(NULL)
8590
+
8591
+ # ── Full processing mode: clean + stuff+ ──
8592
+ if (!is.null(input$download_mode) && input$download_mode == "full") {
8593
+ df <- tryCatch({
8594
+ showNotification("Running clean_college_data()...", type = "message", duration = 2)
8595
+ cleaned <- clean_college_data(df)
8596
+
8597
+ showNotification("Predicting Stuff+...", type = "message", duration = 2)
8598
+ cleaned <- predict_stuffplus(cleaned)
8599
+
8600
+ showNotification(
8601
+ paste0("Full processing complete: ", nrow(cleaned), " rows × ", ncol(cleaned), " columns"),
8602
+ type = "message", duration = 4
8603
+ )
8604
+ cleaned
8605
+ }, error = function(e) {
8606
+ showNotification(paste("Processing error:", e$message), type = "error", duration = 8)
8607
+ processed_data()
8608
+ })
8609
+ }
8610
+
8611
+ if (!is.null(input$download_team_filter) && input$download_team_filter != "all") {
8612
+ if (input$download_team_filter == "coastal_pitchers" && "PitcherTeam" %in% names(df)) {
8613
+ df <- df %>% filter(PitcherTeam == "COA_CHA")
8614
+ } else if (input$download_team_filter == "coastal_hitters" && "BatterTeam" %in% names(df)) {
8615
+ df <- df %>% filter(BatterTeam == "COA_CHA")
8616
+ }
8617
+ }
8618
+
8619
+ # ── Merge catcher notes into existing Notes column ──
8620
+ notes <- catcher_notes_list()
8621
+
8622
+ if (nrow(notes) == 0) return(df)
8623
+
8624
+ # Initialize Notes column if not present
8625
+ if (!"Notes" %in% names(df)) {
8626
+ df$Notes <- NA_character_
8627
+ }
8628
+
8629
+ # For each note, append the result into the matched row's Notes
8630
+ for (i in seq_len(nrow(notes))) {
8631
+ row_idx <- notes$MatchedRow[i]
8632
+ result <- notes$Result[i]
8633
+
8634
+ if (!is.na(row_idx) && row_idx >= 1 && row_idx <= nrow(df)) {
8635
+ existing <- df$Notes[row_idx]
8636
+ if (is.na(existing) || existing == "") {
8637
+ df$Notes[row_idx] <- result
8638
+ } else {
8639
+ df$Notes[row_idx] <- paste(existing, result, sep = " | ")
8640
+ }
8641
+ }
8642
+ }
8643
+
8644
+ # Append unmatched notes to last row
8645
+ unmatched <- notes %>% filter(is.na(MatchedRow))
8646
+ if (nrow(unmatched) > 0) {
8647
+ unmatched_texts <- paste0(
8648
+ unmatched$Result, " (", unmatched$Catcher, "/", unmatched$Batter,
8649
+ " Inn", unmatched$Inning, " ", unmatched$Balls, "-", unmatched$Strikes, ")"
8650
+ )
8651
+ last_row <- nrow(df)
8652
+ existing <- df$Notes[last_row]
8653
+ unmatched_str <- paste0("[UNMATCHED] ", paste(unmatched_texts, collapse = "; "))
8654
+ if (is.na(existing) || existing == "") {
8655
+ df$Notes[last_row] <- unmatched_str
8656
+ } else {
8657
+ df$Notes[last_row] <- paste(existing, unmatched_str, sep = " | ")
8658
+ }
8659
+ }
8660
+
8661
+ return(df)
8662
+ }
8663
+
8664
+
8665
+ # ══════════════════════════════════════════════════════════════
8666
+ # End Catcher Notes
8667
+ # ══════════════════════════════════════════════════════════════
8668
+
8669
+ # Click info output
8670
+ output$click_info <- renderText({
8671
+ if (!is.null(selected_pitch())) {
8672
+ pitch_info <- selected_pitch()
8673
+ paste("Last selected pitch:", pitch_info$original_type,
8674
+ "| Position: (", round(pitch_info$data$HorzBreak, 1), ",",
8675
+ round(pitch_info$data$InducedVertBreak, 1), ")")
8676
+ } else {
8677
+ "No point selected yet. Click on a point in the chart above to edit its pitch type."
8678
+ }
8679
+ })
8680
+
8681
+ # Data summary for download page
8682
+ output$data_summary <- renderText({
8683
+ req(processed_data())
8684
+ df <- processed_data()
8685
+ result <- merge_result()
8686
+ notes <- catcher_notes_list()
8687
+
8688
+ bat_tracking_summary <- if (!is.null(result) && result$matched > 0) {
8689
+ paste("Bat tracking data:", result$matched, "pitches with swing metrics")
8690
+ } else {
8691
+ "Bat tracking data: None"
8692
+ }
8693
+
8694
+ notes_summary <- if (nrow(notes) > 0) {
8695
+ n_matched <- sum(!is.na(notes$MatchedRow))
8696
+ paste0("Catcher notes: ", nrow(notes), " total (", n_matched, " matched to rows)")
8697
+ } else {
8698
+ "Catcher notes: None"
8699
+ }
8700
+
8701
+ summary_text <- paste(
8702
+ paste("Total rows:", nrow(df)),
8703
+ paste("Total columns:", ncol(df)),
8704
+ paste("Date range:",
8705
+ if ("Date" %in% names(df) && !all(is.na(df$Date))) {
8706
+ paste(min(as.Date(df$Date), na.rm = TRUE), "to", max(as.Date(df$Date), na.rm = TRUE))
8707
+ } else {
8708
+ "Date column not available"
8709
+ }),
8710
+ paste("Unique pitchers:",
8711
+ if ("Pitcher" %in% names(df)) {
8712
+ length(unique(df$Pitcher[!is.na(df$Pitcher)]))
8713
+ } else {
8714
+ "Pitcher column not available"
8715
+ }),
8716
+ paste("Pitch types:",
8717
+ if ("TaggedPitchType" %in% names(df)) {
8718
+ paste(sort(unique(df$TaggedPitchType[!is.na(df$TaggedPitchType)])), collapse = ", ")
8719
+ } else {
8720
+ "TaggedPitchType column not available"
8721
+ }),
8722
+ bat_tracking_summary,
8723
+ notes_summary,
8724
+ paste("Source format:", toupper(uploaded_file_type())),
8725
+ paste("Date format:", if (input$date_format == "mdyy") "M/D/YY" else "YYYY-MM-DD"),
8726
+ sep = "\n"
8727
+ )
8728
+
8729
+ return(summary_text)
8730
+ })
8731
+
8732
+ # Download handler: CSV or Parquet with custom filename — NOW includes catcher notes
8733
+ output$downloadData <- downloadHandler(
8734
+ filename = function() {
8735
+ base_name <- gsub("[^A-Za-z0-9_\\-]", "_", input$download_filename)
8736
+ if (nchar(trimws(base_name)) == 0) base_name <- paste0("app_ready_COA_", Sys.Date())
8737
+
8738
+ ext <- input$download_format
8739
+ paste0(base_name, ".", ext)
8740
+ },
8741
+ content = function(file) {
8742
+ download_df <- build_download_data()
8743
+
8744
+ if (input$download_format == "parquet") {
8745
+ arrow::write_parquet(download_df, file)
8746
+ } else {
8747
+ write.csv(download_df, file, row.names = FALSE)
8748
+ }
8749
+ }
8750
+ )
8751
+
8752
+
8753
+
8754
+ #SCRAPER STUFF
8755
+
8756
+ output$scrape_options <- renderUI({
8757
+ switch(input$scrape_source,
8758
+ "pbp" = tagList(
8759
+ p("Scrapes TrackMan play-by-play data from FTP.")
8760
+ ),
8761
+ "pos" = tagList(
8762
+ p("Scrapes TrackMan player positioning data from FTP.")
8763
+ ),
8764
+ "ncaa" = tagList(
8765
+ selectInput("ncaa_division", "Division:", choices = c("D1", "D2", "D3")),
8766
+ p("Scrapes NCAA scoreboard data via API.")
8767
+ )
8768
+ )
8769
+ })
8770
+
8771
+ # Scrape button
8772
+ observeEvent(input$scrape_btn, {
8773
+ scrape_status_msg("Triggering scrape on GitHub...")
8774
+
8775
+ gh_token <- Sys.getenv("GITHUB_TOKEN")
8776
+ gh_repo <- Sys.getenv("GITHUB_REPO")
8777
+
8778
+ result <- tryCatch({
8779
+ httr::POST(
8780
+ paste0("https://api.github.com/repos/", gh_repo, "/actions/workflows/scrape.yml/dispatches"),
8781
+ httr::add_headers(
8782
+ Authorization = paste("Bearer", gh_token),
8783
+ Accept = "application/vnd.github.v3+json"
8784
+ ),
8785
+ body = jsonlite::toJSON(list(
8786
+ ref = "main",
8787
+ inputs = list(
8788
+ start_date = as.character(input$start_date),
8789
+ end_date = as.character(input$end_date),
8790
+ data_type = input$scrape_source
8791
+ )
8792
+ ), auto_unbox = TRUE),
8793
+ encode = "raw"
8794
+ )
8795
+ }, error = function(e) {
8796
+ scrape_status_msg(paste("Failed:", e$message))
8797
+ return(NULL)
8798
+ })
8799
+
8800
+ if (is.null(result)) return()
8801
+
8802
+ if (httr::status_code(result) == 204) {
8803
+ scrape_status_msg("Scrape triggered! Waiting for GitHub to finish...")
8804
+ scrape_polling(TRUE)
8805
+ } else {
8806
+ scrape_status_msg(paste("GitHub API error:", httr::status_code(result)))
8807
+ }
8808
+ })
8809
+
8810
+ # Poll GitHub every 15 seconds to check if done
8811
+ observe({
8812
+ req(scrape_polling())
8813
+
8814
+ invalidateLater(15000, session)
8815
+
8816
+ gh_token <- Sys.getenv("GITHUB_TOKEN")
8817
+ gh_repo <- Sys.getenv("GITHUB_REPO")
8818
+
8819
+ resp <- tryCatch({
8820
+ httr::GET(
8821
+ paste0("https://api.github.com/repos/", gh_repo, "/actions/runs?per_page=1"),
8822
+ httr::add_headers(
8823
+ Authorization = paste("Bearer", gh_token),
8824
+ Accept = "application/vnd.github.v3+json"
8825
+ )
8826
+ )
8827
+ }, error = function(e) { NULL })
8828
+
8829
+ if (is.null(resp)) return()
8830
+
8831
+ runs <- jsonlite::fromJSON(httr::content(resp, as = "text", encoding = "UTF-8"))
8832
+
8833
+ if (length(runs$workflow_runs) == 0) return()
8834
+
8835
+ latest <- runs$workflow_runs[1, ]
8836
+ status <- latest$status
8837
+ conclusion <- latest$conclusion
8838
+
8839
+ if (status == "completed") {
8840
+ scrape_polling(FALSE)
8841
+
8842
+ if (conclusion == "success") {
8843
+ scrape_status_msg("GitHub finished! Fetching data...")
8844
+
8845
+ filename <- paste0(input$scrape_source, "_", input$start_date, "_to_", input$end_date, ".csv.gz")
8846
+ url <- paste0("https://api.github.com/repos/", gh_repo, "/contents/data/", filename)
8847
+
8848
+ data <- tryCatch({
8849
+ file_resp <- httr::GET(
8850
+ url,
8851
+ httr::add_headers(
8852
+ Authorization = paste("Bearer", gh_token),
8853
+ Accept = "application/vnd.github.v3.raw"
8854
+ )
8855
+ )
8856
+
8857
+ if (httr::status_code(file_resp) == 200) {
8858
+ tmp <- tempfile(fileext = ".csv.gz")
8859
+ writeBin(httr::content(file_resp, as = "raw"), tmp)
8860
+ read_csv(gzfile(tmp))
8861
+ } else {
8862
+ NULL
8863
+ }
8864
+ }, error = function(e) { NULL })
8865
+
8866
+ if (!is.null(data) && nrow(data) > 0) {
8867
+
8868
+ if (input$scrape_source == "pbp") {
8869
+ scrape_status_msg("Processing data...")
8870
+
8871
+ data <- tryCatch({
8872
+ d <- clean_college_data(data)
8873
+ d <- predict_stuffplus(d)
8874
+ d
8875
+ }, error = function(e) {
8876
+ scrape_status_msg(paste("Processing error:", e$message))
8877
+ data
8878
+ })
8879
+ }
8880
+
8881
+ scraped_data(data)
8882
+ scrape_status_msg(paste0("Done! ", nrow(data), " rows \u00D7 ", ncol(data), " columns."))
8883
+ } else {
8884
+ scrape_status_msg("Scrape finished but couldn't fetch the file. Try 'Fetch Results' manually.")
8885
+ }
8886
+
8887
+ } else {
8888
+ scrape_status_msg(paste("GitHub Action failed:", conclusion))
8889
+ }
8890
+
8891
+ } else {
8892
+ scrape_status_msg(paste0("GitHub is running... (status: ", status, ")"))
8893
+ }
8894
+ })
8895
+
8896
+ # Status text
8897
+ output$scrape_status <- renderText({ scrape_status_msg() })
8898
+
8899
+ # Preview table
8900
+ output$scrape_preview <- DT::renderDataTable({
8901
+ req(scraped_data())
8902
+ DT::datatable(scraped_data(), options = list(scrollX = TRUE, pageLength = 10))
8903
+ })
8904
+
8905
+ # Download
8906
+ output$download_scrape <- downloadHandler(
8907
+ filename = function() {
8908
+ label <- switch(input$scrape_source, "pbp" = "pbp", "pos" = "positional", "ncaa" = "ncaa")
8909
+ paste0("trackman_", label, "_",
8910
+ format(input$start_date, "%Y%m%d"), "_to_",
8911
+ format(input$end_date, "%Y%m%d"), ".csv")
8912
+ },
8913
+ content = function(file) {
8914
+ req(scraped_data())
8915
+ write.csv(scraped_data(), file, row.names = FALSE)
8916
+ }
8917
+ )
8918
+
8919
+
8920
+ observeEvent(input$upload_hf_btn, {
8921
+ req(scraped_data())
8922
+
8923
+ hf_token <- Sys.getenv("HF_WRITE_TOKEN")
8924
+ repo_id <- "CoastalBaseball/2026MasterDataset"
8925
+ timestamp <- format(Sys.time(), "%Y%m%d_%H%M%S")
8926
+
8927
+ upload_to_hf <- function(new_data, folder, index_file, label) {
8928
+ scrape_status_msg(paste0("Checking existing UIDs for ", label, "..."))
8929
+
8930
+ existing_uids <- tryCatch({
8931
+ tmp_idx <- tempfile(fileext = ".csv.gz")
8932
+ resp <- httr::GET(
8933
+ paste0("https://huggingface.co/datasets/", repo_id, "/resolve/main/", index_file),
8934
+ httr::add_headers(Authorization = paste("Bearer", hf_token)),
8935
+ httr::write_disk(tmp_idx, overwrite = TRUE)
8936
+ )
8937
+ if (httr::status_code(resp) == 200) {
8938
+ d <- read.csv(gzfile(tmp_idx), stringsAsFactors = FALSE)
8939
+ file.remove(tmp_idx)
8940
+ d$PitchUID
8941
+ } else {
8942
+ file.remove(tmp_idx)
8943
+ character(0)
8944
+ }
8945
+ }, error = function(e) { character(0) })
8946
+
8947
+ scraped_rows <- nrow(new_data)
8948
+
8949
+ if (length(existing_uids) > 0 && "PitchUID" %in% names(new_data)) {
8950
+ new_only <- new_data %>% filter(!PitchUID %in% existing_uids)
8951
+ } else {
8952
+ new_only <- new_data
8953
+ }
8954
+
8955
+ new_rows <- nrow(new_only)
8956
+ total_after <- length(existing_uids) + new_rows
8957
+
8958
+ if (new_rows == 0) {
8959
+ return(paste0(label, ": ", scraped_rows, " rows scraped, 0 new rows added (", length(existing_uids), " total)"))
8960
+ }
8961
+
8962
+ scrape_status_msg(paste0("Uploading ", new_rows, " new rows for ", label, "..."))
8963
+
8964
+ hf <- reticulate::import("huggingface_hub")
8965
+ api <- hf$HfApi()
8966
+
8967
+ tmp_data <- tempfile(fileext = ".parquet")
8968
+ arrow::write_parquet(new_only, tmp_data)
8969
+
8970
+ api$upload_file(
8971
+ path_or_fileobj = tmp_data,
8972
+ path_in_repo = paste0(folder, "/", timestamp, ".parquet"),
8973
+ repo_id = repo_id,
8974
+ repo_type = "dataset",
8975
+ token = hf_token
8976
+ )
8977
+ file.remove(tmp_data)
8978
+
8979
+ scrape_status_msg(paste0("Updating ", label, " index..."))
8980
+
8981
+ all_uids <- data.frame(PitchUID = c(existing_uids, new_only$PitchUID))
8982
+ tmp_idx <- tempfile(fileext = ".csv.gz")
8983
+ gz <- gzfile(tmp_idx, "w")
8984
+ write.csv(all_uids, gz, row.names = FALSE)
8985
+ close(gz)
8986
+
8987
+ api$upload_file(
8988
+ path_or_fileobj = tmp_idx,
8989
+ path_in_repo = index_file,
8990
+ repo_id = repo_id,
8991
+ repo_type = "dataset",
8992
+ token = hf_token
8993
+ )
8994
+ file.remove(tmp_idx)
8995
+
8996
+ rm(new_only, all_uids); gc()
8997
+ paste0(label, ": ", scraped_rows, " rows scraped, ", new_rows, " new rows added (", total_after, " total)")
8998
+ }
8999
+
9000
+ if (input$scrape_source == "pbp") {
9001
+ msg1 <- upload_to_hf(scraped_data(), "pbp", "pbp_uid_index.csv.gz", "Master Dataset")
9002
+ gc()
9003
+
9004
+ cp <- scraped_data() %>% filter(PitcherTeam == "COA_CHA")
9005
+ msg2 <- if (nrow(cp) > 0) {
9006
+ upload_to_hf(cp, "coastal_pitchers", "coastal_pitchers_uid_index.csv.gz", "Coastal Pitchers")
9007
+ } else { "Coastal Pitchers: No matching rows" }
9008
+ rm(cp); gc()
9009
+
9010
+ ch <- scraped_data() %>% filter(BatterTeam == "COA_CHA")
9011
+ msg3 <- if (nrow(ch) > 0) {
9012
+ upload_to_hf(ch, "coastal_hitters", "coastal_hitters_uid_index.csv.gz", "Coastal Hitters")
9013
+ } else { "Coastal Hitters: No matching rows" }
9014
+ rm(ch); gc()
9015
+
9016
+ scrape_status_msg(paste(msg1, msg2, msg3, sep = "\n"))
9017
+
9018
+ } else if (input$scrape_source == "pos") {
9019
+ msg1 <- upload_to_hf(scraped_data(), "pos", "pos_uid_index.csv.gz", "Positional Dataset")
9020
+ scrape_status_msg(msg1)
9021
+
9022
+ } else if (input$scrape_source == "ncaa") {
9023
+ msg1 <- upload_to_hf(scraped_data(), "ncaa_pbp", "ncaa_pbp_uid_index.csv.gz", "NCAA PBP Dataset")
9024
+ scrape_status_msg(msg1)
9025
+ }
9026
+ })
9027
+
9028
  }
9029
 
9030
  shinyApp(ui = ui, server = server)