igroffman commited on
Commit
b6b1b1c
·
verified ·
1 Parent(s): bc9dd32

Update app.R

Browse files
Files changed (1) hide show
  1. app.R +824 -572
app.R CHANGED
@@ -1,705 +1,957 @@
1
  library(shiny)
2
  library(shinydashboard)
 
3
  library(DT)
4
  library(dplyr)
5
  library(readr)
6
  library(stringr)
7
- library(plotly)
8
 
9
- # ---------- helpers ----------
10
- `%||%` <- function(x, y) if (is.null(x)) y else x
11
-
12
- # columns to optionally remove
13
  columns_to_remove <- c(
14
- "SpinAxis3dTransverseAngle","SpinAxis3dLongitudinalAngle","SpinAxis3dActiveSpinRate",
15
- "SpinAxis3dSpinEfficiency","SpinAxis3dTilt","SpinAxis3dVectorX","SpinAxis3dVectorY",
16
- "SpinAxis3dVectorZ","SpinAxis3dSeamOrientationRotationX","SpinAxis3dSeamOrientationRotationY",
17
- "SpinAxis3dSeamOrientationRotationZ","SpinAxis3dSeamOrientationBallYAmb1",
18
- "SpinAxis3dSeamOrientationBallAngleHorizontalAmb1","SpinAxis3dSeamOrientationBallZAmb1",
19
- "SpinAxis3dSeamOrientationBallAngleVerticalAmb2","SpinAxis3dSeamOrientationBallZAmb2",
20
- "SpinAxis3dSeamOrientationBallXAmb4","SpinAxis3dSeamOrientationBallYAmb4",
21
- "SpinAxis3dSeamOrientationBallAngleHorizontalAmb2","SpinAxis3dSeamOrientationBallAngleVerticalAmb1",
22
- "SpinAxis3dSeamOrientationBallXAmb1","SpinAxis3dSeamOrientationBallYAmb2",
23
- "SpinAxis3dSeamOrientationBallAngleHorizontalAmb4","SpinAxis3dSeamOrientationBallAngleVerticalAmb4",
24
- "SpinAxis3dSeamOrientationBallXAmb2","SpinAxis3dSeamOrientationBallAngleVerticalAmb3",
25
- "SpinAxis3dSeamOrientationBallAngleHorizontalAmb3","SpinAxis3dSeamOrientationBallXAmb3",
26
- "SpinAxis3dSeamOrientationBallYAmb3","SpinAxis3dSeamOrientationBallZAmb3",
27
- "SpinAxis3dSeamOrientationBallZAmb4","BatSpeed","GameDate","HorizontalAttackAngle",
28
- "Horizontal Attack Angle","VerticalAttackAngle","Vertical attack angle"
29
  )
30
 
31
- # pitch colors
32
  pitch_colors <- c(
33
- "Fastball" = "#FA8072",
34
- "Four-Seam" = "#FA8072",
35
- "Sinker" = "#fdae61",
36
- "Slider" = "#A020F0",
37
- "Sweeper" = "magenta",
38
- "Curveball" = "#2c7bb6",
39
- "ChangeUp" = "#90EE90",
40
- "Splitter" = "#90EE32",
41
- "Cutter" = "red",
42
  "Knuckleball" = "#FFB4B4",
43
- "Other" = "#D3D3D3"
44
  )
45
 
46
- # ---------- UI ----------
 
 
 
 
 
 
 
 
 
 
 
 
 
 
47
  ui <- fluidPage(
48
  tags$head(
49
  tags$style(HTML("
50
  body, table, .gt_table {
51
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
52
- Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol';
53
- }
54
- .app-header { display:flex; justify-content:space-between; align-items:center;
55
- padding:20px 40px; background:#ffffff; border-bottom:3px solid darkcyan; margin-bottom:20px; }
56
- .header-logo-left,.header-logo-right { width:120px; height:auto; }
57
- .header-logo-center { max-width:400px; height:auto; }
58
- @media (max-width:768px){ .app-header{flex-direction:column; padding:15px 20px;}
59
- .header-logo-left,.header-logo-right{width:80px;} .header-logo-center{max-width:250px; margin:10px 0;} }
60
- .nav-tabs{ border:none!important; border-radius:50px; padding:6px 12px; margin:20px auto 0; max-width:100%;
61
- background:linear-gradient(135deg,#d4edeb 0%,#e8ddd0 50%,#d4edeb 100%);
62
- box-shadow:0 4px 16px rgba(0,139,139,.12), inset 0 2px 4px rgba(255,255,255,.6);
63
- border:1px solid rgba(0,139,139,.2); position:relative; overflow-x:auto; display:flex; justify-content:center;
64
- align-items:center; flex-wrap:wrap; gap:6px; -webkit-overflow-scrolling:touch; }
65
- .nav-tabs::-webkit-scrollbar{height:0;}
66
- .nav-tabs::before{content:''; position:absolute; inset:0; pointer-events:none; border-radius:50px;
67
- background:linear-gradient(135deg, rgba(255,255,255,.4), transparent);}
68
- .nav-tabs>li>a{ color:darkcyan!important; border:none!important; border-radius:50px!important; background:transparent!important;
69
- font-weight:700; font-size:14.5px; padding:10px 22px; white-space:nowrap; letter-spacing:.2px; transition:.2s; }
70
- .nav-tabs>li>a:hover{ color:#006666!important; background:rgba(255,255,255,.5)!important; transform:translateY(-1px); }
71
- .nav-tabs>li.active>a,.nav-tabs>li.active>a:focus,.nav-tabs>li.active>a:hover{
72
- background:linear-gradient(135deg,#008b8b 0%,#20b2aa 30%,#00ced1 50%,#20b2aa 70%,#008b8b 100%)!important;
73
- color:#fff!important; text-shadow:0 1px 2px rgba(0,0,0,.2);
74
- 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);
75
- border:1px solid rgba(255,255,255,.3)!important; }
76
- .tab-content{ background:linear-gradient(135deg, rgba(255,255,255,.95), rgba(248,249,250,.95));
77
- border-radius:20px; padding:25px; margin-top:14px; box-shadow:0 15px 40px rgba(0,139,139,.1);
78
- backdrop-filter:blur(15px); border:1px solid rgba(0,139,139,.1); position:relative; overflow:hidden; }
79
- .tab-content::before{content:''; position:absolute; left:0; right:0; top:0; height:4px;
80
- background:linear-gradient(90deg,darkcyan,peru,darkcyan); background-size:200% 100%;
81
- animation:shimmer 3s linear infinite;}
82
- @keyframes shimmer{0%{background-position:-200% 0;}100%{background-position:200% 0;}}
83
- h3{ color:black; font-weight:600; margin-top:25px; margin-bottom:15px; padding-bottom:8px; border-bottom:2px solid #007BA7; }
84
- h4{ color:black; font-weight:500; margin-top:20px; margin-bottom:12px; }
85
- h1{ color:#007BA7; font-weight:700; margin-bottom:20px; text-shadow:1px 1px 2px rgba(0,0,0,0.1); }
86
- label{ font-weight:500; color:peru; margin-bottom:5px; }
87
- thead th{ background:#F8F9FA; color:#2C3E50; font-weight:600; text-align:center!important; padding:10px!important; }
88
- .brand-teal{ color:darkcyan; } .brand-bronze{ color:peru; }
89
- .info-box { background-color:#f0f8ff; padding:10px; border-radius:5px; margin:10px 0; border-left:4px solid darkcyan; }
90
- "))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
91
  ),
92
-
93
- # header
94
- div(class="app-header",
95
- tags$img(src="https://i.imgur.com/7vx5Ci8.png", class="header-logo-left", alt="Logo Left"),
96
- tags$img(src="https://i.imgur.com/c3zCSg6.png", class="header-logo-center", alt="Main Logo"),
97
- tags$img(src="https://i.imgur.com/VbrN5WV.png", class="header-logo-right", alt="Logo Right")
98
  ),
99
-
100
- tabsetPanel(id="main_tabs",
101
- tabPanel("Upload & Process",
102
- fluidRow(
103
- column(12,
104
- h3("Upload CSV File"),
105
- fileInput("file", "Choose CSV File", accept = c(".csv")),
106
- fluidRow(
107
- column(4, checkboxInput("header","Header", TRUE)),
108
- column(4, radioButtons("sep","Separator",
109
- choices=c(Comma=",", Semicolon=";", Tab="\t"), selected=",", inline=TRUE)),
110
- column(4, radioButtons("quote","Quote",
111
- choices=c(None="", "Double Quote"='"', "Single Quote"="'"),
112
- selected='"', inline=TRUE))
113
- )
114
- )
115
- ),
116
- fluidRow(
117
- column(8,
118
- h3("Columns to Remove"),
119
- p("Select which columns to remove from your dataset:"),
120
- checkboxGroupInput("columns_to_remove","Remove These Columns:",
121
- choices = columns_to_remove, selected = columns_to_remove)
122
- ),
123
- column(4,
124
- h3("Quick Actions"), br(),
125
- actionButton("select_all_cols","Select All",class="btn-primary"), br(), br(),
126
- actionButton("deselect_all_cols","Deselect All",class="btn-default"), br(), br(),
127
- actionButton("select_spinaxis","Select SpinAxis3d Columns",class="btn-info"), br(), br(),
128
- actionButton("select_attack_angle","Select Attack Angle Columns",class="btn-info"), br(), br(),
129
- h4("Processing Summary"),
130
- verbatimTextOutput("process_summary")
131
- )
132
- )
133
- ),
134
-
135
- tabPanel("Preview Data",
136
- fluidRow(column(12, h3("Data Preview"), DTOutput("preview")))
137
- ),
138
-
139
- tabPanel("Pitch Movement Chart",
140
- fluidRow(
141
- column(3, selectInput("pitcher_select","Select Pitcher:", choices=NULL, selected=NULL)),
142
- column(3,
143
- h4("Selection Mode:"),
144
- radioButtons("selection_mode","",
145
- choices = list("Single Click (Edit One)"="single","Drag Select (Edit Multiple)"="drag"),
146
- selected="single", inline=FALSE)
147
- ),
148
- column(6,
149
- conditionalPanel(
150
- condition = "input.selection_mode == 'drag'",
151
- div(class="info-box",
152
- h4("Bulk Edit Mode:", style="margin-top:0; color:darkcyan;"),
153
- fluidRow(
154
- column(7, selectInput("bulk_pitch_type","Change selected pitches to:",
155
- choices=c("Fastball","Sinker","Cutter","Slider","Curveball","ChangeUp","Splitter","Knuckleball","Other"),
156
- selected="Fastball")),
157
- column(5, br(), actionButton("apply_bulk_change","Apply Changes", class="btn-success btn-block"))
158
  ),
159
- actionButton("clear_selection","Clear Selection", class="btn-warning btn-sm", style="margin-top:10px;")
160
- )
161
- )
162
- )
163
- ),
164
- fluidRow(
165
- column(8,
166
- h3("Interactive Pitch Movement Analysis"),
167
- plotlyOutput("movement_plot", height="600px"),
168
- conditionalPanel(
169
- condition = "input.selection_mode == 'drag'",
170
- div(class="info-box",
171
- h4("Selected Points:", style="margin-top:0; color:darkcyan;"),
172
- textOutput("selection_info")
173
- )
174
- ),
175
- div(style="padding:10px; background-color:#fff3cd; border-radius:5px; margin-top:10px;",
176
- textOutput("status_info")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
177
  )
178
- ),
179
- column(4,
180
- h3("Pitch Metrics Summary"),
181
- DTOutput("movement_stats"),
182
- h3("Location Plot (Editable)"),
183
- plotlyOutput("location_plot", height="600px")
184
- )
185
- )
186
- ),
187
-
188
- tabPanel("Download",
189
- fluidRow(column(12,
190
- h3("Download Processed Data"),
191
- h4("Your processed data is ready for download!"), br(),
192
- downloadButton("downloadData","Download CSV", class="btn-success btn-lg"),
193
- br(), br(),
194
- h4("Data Summary:"),
195
- verbatimTextOutput("data_summary")
196
- )))
197
  )
198
  )
199
 
200
- # ---------- Server ----------
201
- server <- function(input, output, session){
202
-
203
- # reactives
204
  processed_data <- reactiveVal(NULL)
205
- plot_data <- reactiveVal(NULL)
206
- selected_pitch_uid <- reactiveVal(NULL)
207
- selected_keys <- reactiveVal(integer(0))
208
- plot_version <- reactiveVal(0) # Force plot refresh
209
-
210
- # quick-select buttons
211
- observeEvent(input$select_all_cols, { updateCheckboxGroupInput(session,"columns_to_remove", selected = columns_to_remove) })
212
- observeEvent(input$deselect_all_cols, { updateCheckboxGroupInput(session,"columns_to_remove", selected = character(0)) })
213
- observeEvent(input$select_spinaxis, {
214
- spin <- columns_to_remove[grepl("SpinAxis3d", columns_to_remove)]
215
- updateCheckboxGroupInput(session,"columns_to_remove", selected = spin)
216
  })
217
- observeEvent(input$select_attack_angle,{
218
- aa <- columns_to_remove[grepl("AttackAngle|Attack Angle", columns_to_remove)]
219
- updateCheckboxGroupInput(session,"columns_to_remove", selected = aa)
220
  })
221
-
222
- # file upload + processing
 
 
 
 
 
 
 
 
 
 
223
  observeEvent(input$file, {
224
  req(input$file)
 
225
  tryCatch({
 
226
  df <- read.csv(input$file$datapath,
227
- header = input$header, sep = input$sep, quote = input$quote,
 
 
228
  stringsAsFactors = FALSE)
229
-
230
- # drop user-selected columns
231
- sel_drop <- input$columns_to_remove %||% character(0)
232
- if (length(sel_drop)) {
233
- df <- df %>% select(-any_of(intersect(names(df), sel_drop)))
234
- }
235
-
236
- # numeric coercions for interaction robustness
237
- num_cols <- c("HorzBreak","InducedVertBreak","RelSpeed","SpinRate",
238
- "PlateLocSide","PlateLocHeight","Extension","RelHeight")
239
- have <- intersect(names(df), num_cols)
240
- df[have] <- lapply(df[have], function(x) suppressWarnings(as.numeric(x)))
241
-
242
- df <- df %>% distinct()
243
-
244
- # Create unique identifiers - CRITICAL for tracking
245
- if (!".uid" %in% names(df)) {
246
- df$.uid <- seq_len(nrow(df))
247
- }
248
-
249
- processed_data(df)
250
- plot_data(df)
251
- plot_version(plot_version() + 1)
252
-
253
- if ("Pitcher" %in% names(df)) {
254
- choices <- sort(unique(df$Pitcher[!is.na(df$Pitcher)]))
255
- if (length(choices)) {
256
- updateSelectInput(session,"pitcher_select", choices = choices, selected = choices[1])
257
  }
258
  }
259
 
260
- showNotification("File processed successfully!", type="message", duration=3)
261
- }, error = function(e){
262
- showNotification(paste("Error processing file:", e$message), type="error", duration=5)
 
 
 
 
 
 
 
 
 
 
 
263
  })
264
  })
265
-
266
- # processing summary
267
  output$process_summary <- renderText({
268
- if (is.null(input$file)) return("No file uploaded yet.")
269
- if (is.null(processed_data())) return("Processing...")
 
 
 
 
 
 
270
  df <- processed_data()
271
  original_df <- read.csv(input$file$datapath, nrows = 1)
272
- sel <- input$columns_to_remove %||% character(0)
273
- removed <- intersect(sel, names(original_df))
274
- paste(
 
275
  "✓ File processed successfully!",
276
  paste("✓ Original columns:", ncol(original_df)),
277
  paste("✓ Final columns:", ncol(df)),
 
278
  paste("✓ Rows processed:", nrow(df)),
279
- paste("✓ Removed columns:", length(removed)),
280
- if (length(removed)>0) paste(" -", paste(head(removed,5), collapse=", "),
281
- if (length(removed)>5) "..." else "") else "",
282
  "✓ Duplicates removed",
 
283
  sep = "\n"
284
  )
 
 
285
  })
286
-
287
- # preview table
288
- output$preview <- renderDT({
289
  req(processed_data())
290
- datatable(processed_data(), options=list(scrollX=TRUE, pageLength=10), filter="top")
 
 
 
291
  })
292
-
293
- # Get current pitcher's data
294
- pitcher_df <- reactive({
295
  req(plot_data(), input$pitcher_select)
296
- plot_version() # Depend on version to trigger updates
297
 
298
- d <- plot_data() %>%
299
  filter(Pitcher == input$pitcher_select) %>%
300
- filter(!is.na(HorzBreak), !is.na(InducedVertBreak), !is.na(RelSpeed))
 
 
301
 
302
- d
303
- })
304
-
305
- # Movement plot
306
- output$movement_plot <- renderPlotly({
307
- d <- pitcher_df()
308
- validate(need(nrow(d) > 0, "No data for selected pitcher"))
309
-
310
- p <- plot_ly(
311
- data = d, source = "mv", type = "scatter", mode = "markers",
312
- x = ~HorzBreak, y = ~InducedVertBreak,
313
- text = ~paste0(
314
- "<b>", TaggedPitchType, "</b>",
315
- "<br>Velo: ", round(RelSpeed,1), " mph",
316
- "<br>IVB: ", round(InducedVertBreak,1), " in",
317
- "<br>HB: ", round(HorzBreak,1), " in",
318
- if ("SpinRate" %in% names(d)) paste0("<br>Spin: ", round(SpinRate), " rpm") else ""
319
- ),
320
- hoverinfo = "text",
321
- customdata = ~.uid,
322
- key = ~.uid, # Keep key for tracking
323
- color = ~factor(TaggedPitchType),
324
- colors = pitch_colors,
325
- marker = list(size = 10)
326
- )
327
 
328
- p <- p %>% layout(
329
- title = paste("Pitch Movement Chart -", input$pitcher_select),
330
- xaxis = list(title="Horizontal Break (in)", range=c(-25,25), zeroline=TRUE),
331
- yaxis = list(title="Induced Vertical Break (in)", range=c(-25,25), zeroline=TRUE),
332
- dragmode = if (input$selection_mode == "drag") "select" else "zoom"
333
- )
334
 
335
- p <- p %>% config(displaylogo = FALSE, modeBarButtonsToRemove = c("lasso2d"))
 
 
 
 
 
 
 
 
336
 
337
- # Register events
338
- p <- event_register(p, 'plotly_click')
339
- p <- event_register(p, 'plotly_selected')
 
 
 
 
 
 
 
 
340
 
341
- p
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
342
  })
343
-
344
- # Location plot
345
- output$location_plot <- renderPlotly({
346
- d <- pitcher_df()
347
- validate(need(nrow(d) > 0, "No data for selected pitcher"))
348
-
349
- p <- plot_ly(
350
- data = d, source = "loc",
351
- type = "scatter", mode = "markers",
352
- x = ~PlateLocSide, y = ~PlateLocHeight,
353
- text = ~paste0(
354
- "<b>", TaggedPitchType, "</b>",
355
- if ("PitchNo" %in% names(d)) paste0("<br>Pitch #: ", PitchNo) else "",
356
- "<br>Velo: ", round(RelSpeed, 1), " mph",
357
- "<br>X: ", round(PlateLocSide, 2),
358
- "<br>Z: ", round(PlateLocHeight, 2),
359
- "<br>IVB: ", round(InducedVertBreak, 1), " in",
360
- "<br>HB: ", round(HorzBreak, 1), " in",
361
- if ("SpinRate" %in% names(d)) paste0("<br>Spin: ", round(SpinRate), " rpm") else ""
362
- ),
363
- hoverinfo = "text",
364
- customdata = ~.uid,
365
- key = ~.uid,
366
- color = ~factor(TaggedPitchType),
367
- colors = pitch_colors,
368
- marker = list(size = 9)
369
- )
370
 
371
- p <- p %>% layout(
372
- title = "Pitch Location (Editable)",
373
- xaxis = list(title="Plate X (ft)", range=c(-2,2), zeroline=TRUE),
374
- yaxis = list(title="Plate Z (ft)", range=c(0,4.5), zeroline=TRUE),
375
- dragmode = if (input$selection_mode == "drag") "select" else "zoom",
376
- shapes = list(
377
- list(type="rect", x0=-0.8303, x1=0.8303, y0=1.6, y1=3.5,
378
- line=list(color="black", width=1), fillcolor="rgba(0,0,0,0)"),
379
- list(type="path",
380
- 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",
381
- line=list(color="black", width=0.8))
382
- )
383
- )
384
 
385
- p <- p %>% config(displaylogo = FALSE, modeBarButtonsToRemove = c("lasso2d"))
 
 
 
 
386
 
387
- # Register events
388
- p <- event_register(p, 'plotly_click')
389
- p <- event_register(p, 'plotly_selected')
390
 
391
- p
392
- })
393
-
394
- # ---- SINGLE CLICK MODE ----
395
- observeEvent(event_data("plotly_click", source="mv"), {
396
- req(input$selection_mode == "single")
397
- clk <- event_data("plotly_click", source="mv")
398
- req(!is.null(clk), nrow(as.data.frame(clk)) > 0)
399
-
400
- # Get UID from customdata
401
- uid <- clk$customdata[[1]]
402
- req(!is.null(uid))
403
-
404
- d <- pitcher_df()
405
- hit <- d[d$.uid == uid, ]
406
-
407
- if (nrow(hit) == 1) {
408
- selected_pitch_uid(uid)
409
- showModal(
410
- modalDialog(
411
- title = tags$h3("Edit Pitch Type", style="color: darkcyan;"),
412
- tags$div(
413
- tags$strong("Pitcher: "), input$pitcher_select, tags$br(),
414
- if ("PitchNo" %in% names(hit) && !is.na(hit$PitchNo))
415
- tagList(tags$strong("Pitch No: "), hit$PitchNo, tags$br()),
416
- tags$strong("Current Type: "), tags$span(hit$TaggedPitchType, style="color: peru; font-size: 16px;"), tags$br(),
417
- tags$strong("Velocity: "), round(hit$RelSpeed, 1), " mph", tags$br(),
418
- tags$strong("Horizontal Break: "), round(hit$HorzBreak, 1), " in", tags$br(),
419
- tags$strong("Induced Vertical Break: "), round(hit$InducedVertBreak, 1), " in", tags$br(),
420
- if ("SpinRate" %in% names(hit) && !is.na(hit$SpinRate))
421
- tagList(tags$strong("Spin Rate: "), round(hit$SpinRate, 0), " rpm", tags$br()),
422
- tags$br()
423
- ),
424
- tags$label("Change Pitch Type To:", style="color: peru; font-weight: 600;"),
425
- selectInput("modal_new_pitch_type", NULL,
426
- choices = c("Fastball","Sinker","Cutter","Slider","Curveball",
427
- "ChangeUp","Splitter","Knuckleball","Other"),
428
- selected = hit$TaggedPitchType),
429
- tags$br(),
430
- footer = tagList(
431
- actionButton("update_pitch","Update Pitch Type", class="btn-primary"),
432
- actionButton("cancel_edit","Cancel", class="btn-default")
433
- ),
434
- easyClose = TRUE, size = "m"
435
- )
436
- )
437
- }
438
- })
439
-
440
- observeEvent(event_data("plotly_click", source="loc"), {
441
- req(input$selection_mode == "single")
442
- clk <- event_data("plotly_click", source="loc")
443
- req(!is.null(clk), nrow(as.data.frame(clk)) > 0)
444
-
445
- uid <- clk$customdata[[1]]
446
- req(!is.null(uid))
447
-
448
- d <- pitcher_df()
449
- hit <- d[d$.uid == uid, ]
450
-
451
- if (nrow(hit) == 1) {
452
- selected_pitch_uid(uid)
453
- showModal(
454
- modalDialog(
455
- title = tags$h3("Edit Pitch Type", style="color: darkcyan;"),
456
- tags$div(
457
- tags$strong("Pitcher: "), input$pitcher_select, tags$br(),
458
- if ("PitchNo" %in% names(hit) && !is.na(hit$PitchNo))
459
- tagList(tags$strong("Pitch No: "), hit$PitchNo, tags$br()),
460
- tags$strong("Current Type: "), tags$span(hit$TaggedPitchType, style="color: peru; font-size: 16px;"), tags$br(),
461
- tags$strong("Velocity: "), round(hit$RelSpeed, 1), " mph", tags$br(),
462
- tags$strong("Location: "), "(", round(hit$PlateLocSide, 2), ", ", round(hit$PlateLocHeight, 2), ")", tags$br(),
463
- tags$br()
464
- ),
465
- tags$label("Change Pitch Type To:", style="color: peru; font-weight: 600;"),
466
- selectInput("modal_new_pitch_type", NULL,
467
- choices = c("Fastball","Sinker","Cutter","Slider","Curveball",
468
- "ChangeUp","Splitter","Knuckleball","Other"),
469
- selected = hit$TaggedPitchType),
470
- tags$br(),
471
- footer = tagList(
472
- actionButton("update_pitch","Update Pitch Type", class="btn-primary"),
473
- actionButton("cancel_edit","Cancel", class="btn-default")
474
- ),
475
- easyClose = TRUE, size = "m"
476
- )
477
- )
478
- }
479
- })
480
-
481
- # Update single pitch
482
- observeEvent(input$update_pitch, {
483
- uid <- selected_pitch_uid()
484
- req(uid)
485
- new_type <- input$modal_new_pitch_type
486
- req(new_type)
487
-
488
- cur <- plot_data()
489
- req(nrow(cur) > 0)
490
- cur$TaggedPitchType <- as.character(cur$TaggedPitchType)
491
-
492
- hit_idx <- which(cur$.uid == uid)
493
-
494
- if (length(hit_idx) == 1) {
495
- old_type <- cur$TaggedPitchType[hit_idx]
496
- cur$TaggedPitchType[hit_idx] <- new_type
497
 
498
- plot_data(cur)
499
- processed_data(cur)
500
- plot_version(plot_version() + 1) # Force refresh
 
 
501
 
502
- removeModal()
503
- showNotification(
504
- paste("Updated pitch from", old_type, "to", new_type),
505
- type="message",
506
- duration=2
507
- )
508
- selected_pitch_uid(NULL)
509
- } else {
510
- showNotification("Error: Could not find pitch to update.", type="error")
511
- }
512
- })
513
-
514
- observeEvent(input$cancel_edit, {
515
- removeModal()
516
- selected_pitch_uid(NULL)
517
- })
518
-
519
- # ---- DRAG SELECT MODE ----
520
- observeEvent(event_data("plotly_selected", source="mv"), {
521
- req(input$selection_mode == "drag")
522
- ev <- event_data("plotly_selected", source="mv")
523
-
524
- if (!is.null(ev) && "customdata" %in% names(ev)) {
525
- keys <- unique(as.integer(ev$customdata))
526
- selected_keys(keys)
527
- } else {
528
- selected_keys(integer(0))
529
  }
530
  })
531
 
532
- observeEvent(event_data("plotly_selected", source="loc"), {
533
- req(input$selection_mode == "drag")
534
- ev <- event_data("plotly_selected", source="loc")
535
 
536
- if (!is.null(ev) && "customdata" %in% names(ev)) {
537
- keys <- unique(as.integer(ev$customdata))
538
- selected_keys(keys)
539
- } else {
540
- selected_keys(integer(0))
541
- }
542
- })
543
-
544
- # Clear selection button
545
- observeEvent(input$clear_selection, {
546
- selected_keys(integer(0))
547
- showNotification("Selection cleared", type="message", duration=2)
548
- })
549
-
550
- # Selection info
551
- output$selection_info <- renderText({
552
- keys <- selected_keys()
553
- if (!length(keys)) return("No points selected. Use drag/lasso to select multiple pitches.")
554
 
555
- d <- pitcher_df()
556
- sel <- d[d$.uid %in% keys, ]
 
 
557
 
558
- if (nrow(sel) == 0) return("Selected points not found in current pitcher's data.")
559
 
560
- cnt <- sort(table(sel$TaggedPitchType), decreasing = TRUE)
561
- breakdown <- paste(names(cnt), "(", cnt, ")", collapse=", ")
 
 
 
 
 
562
 
563
- paste(nrow(sel), "pitches selected:", breakdown)
 
 
 
 
564
  })
565
-
566
- # Apply bulk changes
567
  observeEvent(input$apply_bulk_change, {
568
- req(input$selection_mode == "drag")
569
- keys <- selected_keys()
570
- req(length(keys) > 0)
571
- new_type <- input$bulk_pitch_type
572
- req(new_type)
573
-
574
- cur <- plot_data()
575
- req(nrow(cur) > 0)
576
- cur$TaggedPitchType <- as.character(cur$TaggedPitchType)
577
-
578
- # Only update pitches from current pitcher
579
- hit_idx <- which(cur$.uid %in% keys & cur$Pitcher == input$pitcher_select)
580
-
581
- if (length(hit_idx) == 0) {
582
- showNotification("No matching rows found for bulk edit.", type="warning")
583
  return()
584
  }
585
-
586
- # Store old types for notification
587
- old_types <- unique(cur$TaggedPitchType[hit_idx])
588
 
589
- # Update
590
- cur$TaggedPitchType[hit_idx] <- new_type
591
- plot_data(cur)
592
- processed_data(cur)
593
- plot_version(plot_version() + 1) # Force refresh
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
594
 
595
- selected_keys(integer(0))
 
596
 
 
597
  showNotification(
598
- paste("Updated", length(hit_idx), "pitches to", new_type),
599
- type="message",
600
- duration=3
601
  )
602
  })
603
-
604
- # Status info
605
- output$status_info <- renderText({
606
- if (input$selection_mode == "single") {
607
- "Mode: Single Click - Click any point to edit its pitch type"
 
 
 
608
  } else {
609
- keys <- selected_keys()
610
- if (length(keys) > 0) {
611
- paste("Mode: Drag Select -", length(keys), "pitches selected. Choose type and click 'Apply Changes'")
612
- } else {
613
- "Mode: Drag Select - Use drag/lasso tool to select multiple pitches, then apply bulk changes"
614
- }
615
  }
616
  })
617
-
618
- # Metrics table
619
- output$movement_stats <- renderDT({
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
620
  req(plot_data(), input$pitcher_select)
621
- plot_version() # Depend on version
622
 
623
  data <- plot_data()
 
624
  movement_stats <- data %>%
625
  filter(Pitcher == input$pitcher_select) %>%
626
  filter(!is.na(HorzBreak), !is.na(InducedVertBreak), !is.na(TaggedPitchType)) %>%
627
  mutate(
628
  pitch_group = case_when(
629
- TaggedPitchType %in% c("Fastball","FourSeamFastBall","FourSeamFastB","Four-Seam","4-Seam") ~ "Fastball",
630
- TaggedPitchType %in% c("OneSeamFastBall","TwoSeamFastBall","Sinker","Two-Seam","One-Seam") ~ "Sinker",
631
- TaggedPitchType %in% c("ChangeUp","Changeup") ~ "Changeup",
632
  TRUE ~ TaggedPitchType
633
  ),
634
- in_zone = if ("StrikeZoneIndicator" %in% names(.)) StrikeZoneIndicator else
635
- ifelse(!is.na(PlateLocSide) & !is.na(PlateLocHeight) &
 
636
  PlateLocSide >= -0.95 & PlateLocSide <= 0.95 &
637
  PlateLocHeight >= 1.6 & PlateLocHeight <= 3.5, 1, 0),
638
- is_whiff = if ("WhiffIndicator" %in% names(.)) WhiffIndicator else
639
  ifelse(!is.na(PitchCall) & PitchCall == "StrikeSwinging", 1, 0),
640
  chase = if ("Chaseindicator" %in% names(.)) Chaseindicator else
641
  ifelse(!is.na(PitchCall) & !is.na(PlateLocSide) & !is.na(PlateLocHeight) &
642
- PitchCall %in% c("StrikeSwinging","FoulBallNotFieldable","FoulBall","InPlay") &
643
  (PlateLocSide < -0.95 | PlateLocSide > 0.95 | PlateLocHeight < 1.6 | PlateLocHeight > 3.5), 1, 0)
644
  )
645
 
 
646
  total_pitches <- nrow(movement_stats)
647
 
648
  summary_stats <- movement_stats %>%
649
  group_by(`Pitch Type` = pitch_group) %>%
650
  summarise(
651
  Count = n(),
652
- `Usage%` = sprintf("%.1f%%", (n()/total_pitches)*100),
653
- `Ext.` = if ("Extension" %in% names(.)) sprintf("%.1f", mean(Extension, na.rm=TRUE)) else "—",
654
- `Avg Velo` = sprintf("%.1f mph", mean(RelSpeed, na.rm=TRUE)),
655
- `90th Velo` = sprintf("%.1f mph", quantile(RelSpeed, 0.9, na.rm=TRUE)),
656
- `Max Velo` = sprintf("%.1f mph", max(RelSpeed, na.rm=TRUE)),
657
- `Avg IVB` = sprintf("%.1f in", mean(InducedVertBreak, na.rm=TRUE)),
658
- `Avg HB` = sprintf("%.1f in", mean(HorzBreak, na.rm=TRUE)),
659
- `Avg Spin` = if ("SpinRate" %in% names(.)) sprintf("%.0f rpm", mean(SpinRate, na.rm=TRUE)) else "—",
660
- `Rel Height` = if ("RelHeight" %in% names(.)) sprintf("%.1f", mean(RelHeight, na.rm=TRUE)) else "—",
661
- `Zone%` = sprintf("%.1f%%", round(mean(in_zone, na.rm=TRUE)*100,1)),
662
- `Whiff%` = sprintf("%.1f%%", round(mean(is_whiff, na.rm=TRUE)*100,1)),
663
- `Chase%` = sprintf("%.1f%%", round(mean(chase, na.rm=TRUE)*100,1)),
664
  .groups = "drop"
665
- ) %>% arrange(desc(Count))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
666
 
667
- datatable(summary_stats, options=list(pageLength=15, dom='t', scrollX=TRUE), rownames=FALSE) %>%
668
- formatStyle(columns = names(summary_stats), fontSize='12px')
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
669
  })
670
 
671
- # data summary + download
 
 
 
 
 
 
 
 
 
 
 
 
672
  output$data_summary <- renderText({
673
  req(processed_data())
674
  df <- processed_data()
675
- paste(
 
676
  paste("Total rows:", nrow(df)),
677
  paste("Total columns:", ncol(df)),
678
- paste("Date range:",
679
- if ("Date" %in% names(df) && !all(is.na(df$Date))) {
680
- paste(min(as.Date(df$Date), na.rm=TRUE), "to", max(as.Date(df$Date), na.rm=TRUE))
681
- } else "Date column not available"),
 
 
682
  paste("Unique pitchers:",
683
- if ("Pitcher" %in% names(df)) length(unique(df$Pitcher[!is.na(df$Pitcher)]))
684
- else "Pitcher column not available"),
 
 
 
685
  paste("Pitch types:",
686
- if ("TaggedPitchType" %in% names(df)) paste(sort(unique(df$TaggedPitchType[!is.na(df$TaggedPitchType)])), collapse=", ")
687
- else "TaggedPitchType column not available"),
 
 
 
688
  sep = "\n"
689
  )
 
 
690
  })
691
 
 
692
  output$downloadData <- downloadHandler(
693
- filename = function(){ paste0("app_ready_COA_", Sys.Date(), ".csv") },
694
- content = function(file){
695
- df_out <- processed_data()
696
- # Remove internal UID column before export
697
- if (".uid" %in% names(df_out)) {
698
- df_out <- df_out %>% select(-.uid)
699
- }
700
- write.csv(df_out, file, row.names=FALSE)
701
  }
702
  )
703
  }
704
 
 
705
  shinyApp(ui = ui, server = server)
 
1
  library(shiny)
2
  library(shinydashboard)
3
+ library(shinyBS)
4
  library(DT)
5
  library(dplyr)
6
  library(readr)
7
  library(stringr)
 
8
 
9
+ # Define columns to remove if they exist
 
 
 
10
  columns_to_remove <- c(
11
+ "SpinAxis3dTransverseAngle", "SpinAxis3dLongitudinalAngle", "SpinAxis3dActiveSpinRate",
12
+ "SpinAxis3dSpinEfficiency", "SpinAxis3dTilt", "SpinAxis3dVectorX", "SpinAxis3dVectorY",
13
+ "SpinAxis3dVectorZ", "SpinAxis3dSeamOrientationRotationX", "SpinAxis3dSeamOrientationRotationY",
14
+ "SpinAxis3dSeamOrientationRotationZ", "SpinAxis3dSeamOrientationBallYAmb1",
15
+ "SpinAxis3dSeamOrientationBallAngleHorizontalAmb1", "SpinAxis3dSeamOrientationBallZAmb1",
16
+ "SpinAxis3dSeamOrientationBallAngleVerticalAmb2", "SpinAxis3dSeamOrientationBallZAmb2",
17
+ "SpinAxis3dSeamOrientationBallXAmb4", "SpinAxis3dSeamOrientationBallYAmb4",
18
+ "SpinAxis3dSeamOrientationBallAngleHorizontalAmb2", "SpinAxis3dSeamOrientationBallAngleVerticalAmb1",
19
+ "SpinAxis3dSeamOrientationBallXAmb1", "SpinAxis3dSeamOrientationBallYAmb2",
20
+ "SpinAxis3dSeamOrientationBallAngleHorizontalAmb4", "SpinAxis3dSeamOrientationBallAngleVerticalAmb4",
21
+ "SpinAxis3dSeamOrientationBallXAmb2", "SpinAxis3dSeamOrientationBallAngleVerticalAmb3",
22
+ "SpinAxis3dSeamOrientationBallAngleHorizontalAmb3", "SpinAxis3dSeamOrientationBallXAmb3",
23
+ "SpinAxis3dSeamOrientationBallYAmb3", "SpinAxis3dSeamOrientationBallZAmb3",
24
+ "SpinAxis3dSeamOrientationBallZAmb4", "BatSpeed", "GameDate", "HorizontalAttackAngle",
25
+ "Horizontal Attack Angle", "VerticalAttackAngle", "Vertical attack angle"
26
  )
27
 
28
+ # Pitch colors for visualization (Coastal Carolina theme)
29
  pitch_colors <- c(
30
+ "Fastball" = '#FA8072',
31
+ "Four-Seam" = '#FA8072',
32
+ "Sinker" = "#fdae61",
33
+ "Slider" = "#A020F0",
34
+ "Sweeper" = "magenta",
35
+ "Curveball" = '#2c7bb6',
36
+ "ChangeUp" = '#90EE90',
37
+ "Splitter" = '#90EE32',
38
+ "Cutter" = "red",
39
  "Knuckleball" = "#FFB4B4",
40
+ "Other" = "#D3D3D3"
41
  )
42
 
43
+ # Function to process the uploaded data (only remove columns)
44
+ process_baseball_data <- function(df) {
45
+ # Remove unwanted columns
46
+ columns_to_drop <- intersect(names(df), columns_to_remove)
47
+ if (length(columns_to_drop) > 0) {
48
+ df <- df %>% select(-all_of(columns_to_drop))
49
+ }
50
+
51
+ # Remove duplicates only
52
+ df <- df %>% distinct()
53
+
54
+ return(df)
55
+ }
56
+
57
+ # UI
58
  ui <- fluidPage(
59
  tags$head(
60
  tags$style(HTML("
61
  body, table, .gt_table {
62
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
63
+ Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji',
64
+ 'Segoe UI Symbol';
65
+ }
66
+
67
+ /* Header styling */
68
+ .app-header {
69
+ display: flex;
70
+ justify-content: space-between;
71
+ align-items: center;
72
+ padding: 20px 40px;
73
+ background: #ffffff);
74
+ border-bottom: 3px solid darkcyan;
75
+ margin-bottom: 20px;
76
+ }
77
+
78
+ .header-logo-left, .header-logo-right {
79
+ width: 120px;
80
+ height: auto;
81
+ }
82
+
83
+ .header-logo-center {
84
+ max-width: 400px;
85
+ height: auto;
86
+ }
87
+
88
+ @media (max-width: 768px) {
89
+ .app-header {
90
+ flex-direction: column;
91
+ padding: 15px 20px;
92
+ }
93
+ .header-logo-left, .header-logo-right {
94
+ width: 80px;
95
+ }
96
+ .header-logo-center {
97
+ max-width: 250px;
98
+ margin: 10px 0;
99
+ }
100
+ }
101
+
102
+ /* Gradient pill tabs styling */
103
+ .nav-tabs {
104
+ border: none !important;
105
+ border-radius: 50px;
106
+ padding: 6px 12px;
107
+ margin: 20px auto 0;
108
+ max-width: 100%;
109
+ background: linear-gradient(135deg, #d4edeb 0%, #e8ddd0 50%, #d4edeb 100%);
110
+ box-shadow: 0 4px 16px rgba(0,139,139,.12), inset 0 2px 4px rgba(255,255,255,.6);
111
+ border: 1px solid rgba(0,139,139,.2);
112
+ position: relative;
113
+ overflow-x: auto;
114
+ -webkit-overflow-scrolling: touch;
115
+ display: flex;
116
+ justify-content: center;
117
+ align-items: center;
118
+ flex-wrap: wrap;
119
+ gap: 6px;
120
+ }
121
+
122
+ .nav-tabs::-webkit-scrollbar {
123
+ height: 0;
124
+ }
125
+
126
+ .nav-tabs::before {
127
+ content: '';
128
+ position: absolute;
129
+ inset: 0;
130
+ pointer-events: none;
131
+ border-radius: 50px;
132
+ background: linear-gradient(135deg, rgba(255,255,255,.4), transparent);
133
+ }
134
+
135
+ .nav-tabs > li > a {
136
+ color: darkcyan !important;
137
+ border: none !important;
138
+ border-radius: 50px !important;
139
+ background: transparent !important;
140
+ font-weight: 700;
141
+ font-size: 14.5px;
142
+ padding: 10px 22px;
143
+ white-space: nowrap;
144
+ letter-spacing: 0.2px;
145
+ transition: all 0.2s ease;
146
+ }
147
+
148
+ .nav-tabs > li > a:hover {
149
+ color: #006666 !important;
150
+ background: rgba(255,255,255,.5) !important;
151
+ transform: translateY(-1px);
152
+ }
153
+
154
+ .nav-tabs > li.active > a,
155
+ .nav-tabs > li.active > a:focus,
156
+ .nav-tabs > li.active > a:hover {
157
+ background: linear-gradient(135deg, #008b8b 0%, #20b2aa 30%, #00ced1 50%, #20b2aa 70%, #008b8b 100%) !important;
158
+ color: #fff !important;
159
+ text-shadow: 0 1px 2px rgba(0,0,0,.2);
160
+ 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);
161
+ border: 1px solid rgba(255,255,255,.3) !important;
162
+ }
163
+
164
+ .nav-tabs > li > a:focus {
165
+ outline: 3px solid rgba(205,133,63,.6);
166
+ outline-offset: 2px;
167
+ }
168
+
169
+ .tab-content {
170
+ background: linear-gradient(135deg, rgba(255,255,255,.95), rgba(248,249,250,.95));
171
+ border-radius: 20px;
172
+ padding: 25px;
173
+ margin-top: 14px;
174
+ box-shadow: 0 15px 40px rgba(0,139,139,.1);
175
+ backdrop-filter: blur(15px);
176
+ border: 1px solid rgba(0,139,139,.1);
177
+ position: relative;
178
+ overflow: hidden;
179
+ }
180
+
181
+ .tab-content::before {
182
+ content: '';
183
+ position: absolute;
184
+ left: 0;
185
+ right: 0;
186
+ top: 0;
187
+ height: 4px;
188
+ background: linear-gradient(90deg, darkcyan, peru, darkcyan);
189
+ background-size: 200% 100%;
190
+ animation: shimmer 3s linear infinite;
191
+ }
192
+
193
+ @keyframes shimmer {
194
+ 0% { background-position: -200% 0; }
195
+ 100% { background-position: 200% 0; }
196
+ }
197
+
198
+ #name {
199
+ font-size: 10px;
200
+ font-weight: 500;
201
+ text-align: right;
202
+ margin-bottom: 8px;
203
+ color: #6C757D;
204
+ letter-spacing: 0.5px;
205
+ }
206
+
207
+ h3 {
208
+ color: black;
209
+ font-weight: 600;
210
+ margin-top: 25px;
211
+ margin-bottom: 15px;
212
+ padding-bottom: 8px;
213
+ border-bottom: 2px solid #007BA7;
214
+ }
215
+
216
+ h4 {
217
+ color: white;
218
+ font-weight: 500;
219
+ margin-top: 20px;
220
+ margin-bottom: 12px;
221
+ }
222
+
223
+ h1 {
224
+ color: #007BA7;
225
+ font-weight: 700;
226
+ margin-bottom: 20px;
227
+ text-shadow: 1px 1px 2px rgba(0,0,0,0.1);
228
+ }
229
+
230
+ label {
231
+ font-weight: 500;
232
+ color: peru;
233
+ margin-bottom: 5px;
234
+ }
235
+
236
+ .plot-title {
237
+ text-align: center;
238
+ font-weight: 600;
239
+ color: #2C3E50;
240
+ margin-bottom: 10px;
241
+ }
242
+
243
+ .dataTables_wrapper .dataTables_length,
244
+ .dataTables_wrapper .dataTables_filter,
245
+ .dataTables_wrapper .dataTables_info,
246
+ .dataTables_wrapper .dataTables_paginate {
247
+ color: #2C3E50;
248
+ }
249
+
250
+ thead th {
251
+ background-color: #F8F9FA;
252
+ color: #2C3E50;
253
+ font-weight: 600;
254
+ text-align: center !important;
255
+ padding: 10px !important;
256
+ }
257
+
258
+ .brand-teal { color: darkcyan; }
259
+ .brand-bronze { color: peru; }
260
+ "))
261
  ),
262
+
263
+ # Header with three logos
264
+ div(class = "app-header",
265
+ tags$img(src = "https://i.imgur.com/7vx5Ci8.png", class = "header-logo-left", alt = "Logo Left"),
266
+ tags$img(src = "https://i.imgur.com/c3zCSg6.png", class = "header-logo-center", alt = "Main Logo"),
267
+ tags$img(src = "https://i.imgur.com/VbrN5WV.png", class = "header-logo-right", alt = "Logo Right")
268
  ),
269
+
270
+ tabsetPanel(id = "main_tabs",
271
+
272
+ # Upload & Process Tab
273
+ tabPanel(
274
+ "Upload & Process",
275
+ fluidRow(
276
+ column(12,
277
+ h3("Upload CSV File"),
278
+ fileInput("file", "Choose CSV File", accept = c(".csv")),
279
+ fluidRow(
280
+ column(4,
281
+ checkboxInput("header", "Header", TRUE)
282
+ ),
283
+ column(4,
284
+ radioButtons("sep", "Separator",
285
+ choices = c(Comma = ",", Semicolon = ";", Tab = "\t"),
286
+ selected = ",", inline = TRUE)
287
+ ),
288
+ column(4,
289
+ radioButtons("quote", "Quote",
290
+ choices = c(None = "", "Double Quote" = '"', "Single Quote" = "'"),
291
+ selected = '"', inline = TRUE)
292
+ )
293
+ )
294
+ )
295
+ ),
296
+
297
+ fluidRow(
298
+ column(8,
299
+ h3("Columns to Remove"),
300
+ p("Select which columns to remove from your dataset:"),
301
+ checkboxGroupInput("columns_to_remove", "Remove These Columns:",
302
+ choices = columns_to_remove,
303
+ selected = columns_to_remove)
304
+ ),
305
+ column(4,
306
+ h3("Quick Actions"),
307
+ br(),
308
+ actionButton("select_all_cols", "Select All", class = "btn-primary"),
309
+ br(), br(),
310
+ actionButton("deselect_all_cols", "Deselect All", class = "btn-default"),
311
+ br(), br(),
312
+ actionButton("select_spinaxis", "Select SpinAxis3d Columns", class = "btn-info"),
313
+ br(), br(),
314
+ actionButton("select_attack_angle", "Select Attack Angle Columns", class = "btn-info"),
315
+ br(), br(),
316
+ h4("Processing Summary"),
317
+ verbatimTextOutput("process_summary")
318
+ )
319
+ )
 
 
 
 
 
 
 
 
320
  ),
321
+
322
+ # Preview Data Tab
323
+ tabPanel(
324
+ "Preview Data",
325
+ fluidRow(
326
+ column(12,
327
+ h3("Data Preview"),
328
+ DT::dataTableOutput("preview")
329
+ )
330
+ )
331
+ ),
332
+
333
+ # Pitch Movement Chart Tab
334
+ tabPanel(
335
+ "Pitch Movement Chart",
336
+ fluidRow(
337
+ column(3,
338
+ selectInput("pitcher_select", "Select Pitcher:",
339
+ choices = NULL, selected = NULL)
340
+ ),
341
+ column(3,
342
+ h4("Selection Mode:"),
343
+ radioButtons("selection_mode", "",
344
+ choices = list("Single Click" = "single", "Drag Select" = "drag"),
345
+ selected = "single", inline = TRUE)
346
+ ),
347
+ column(6,
348
+ conditionalPanel(
349
+ condition = "input.selection_mode == 'drag'",
350
+ h4("Bulk Edit:"),
351
+ fluidRow(
352
+ column(8,
353
+ selectInput("bulk_pitch_type", "Change all selected to:",
354
+ choices = c("Fastball", "Sinker", "Cutter", "Slider",
355
+ "Curveball", "ChangeUp", "Splitter", "Knuckleball", "Other"),
356
+ selected = "Fastball")
357
+ ),
358
+ column(4,
359
+ br(),
360
+ actionButton("apply_bulk_change", "Apply to Selected", class = "btn-success")
361
+ )
362
+ )
363
+ )
364
+ )
365
+ ),
366
+
367
+ fluidRow(
368
+ column(8,
369
+ h3("Interactive Pitch Movement Analysis"),
370
+ plotOutput("movement_plot", height = "600px",
371
+ click = "plot_click",
372
+ brush = brushOpts(id = "plot_brush"),
373
+ hover = hoverOpts(id = "plot_hover", delay = 100)),
374
+
375
+ h4("Instructions:"),
376
+ p(strong("Single Click Mode:"), "Click on any point to edit one pitch type at a time via popup modal."),
377
+ p(strong("Drag Select Mode:"), "Click and drag to select multiple points, then use the dropdown to change them all at once."),
378
+ conditionalPanel(
379
+ condition = "input.selection_mode == 'drag'",
380
+ div(style = "background-color: #f0f8ff; padding: 10px; border-radius: 5px; margin: 10px 0; border-left: 4px solid darkcyan;",
381
+ h4("Selected Points:", style = "margin-top: 0; color: darkcyan;"),
382
+ textOutput("selection_info")
383
+ )
384
+ ),
385
+ verbatimTextOutput("hover_info"),
386
+ verbatimTextOutput("click_info")
387
+ ),
388
+
389
+ column(4,
390
+ h3("Pitch Metrics Summary"),
391
+ DT::dataTableOutput("movement_stats")
392
+ )
393
+ )
394
+ ),
395
+
396
+ # Download Tab
397
+ tabPanel(
398
+ "Download",
399
+ fluidRow(
400
+ column(12,
401
+ h3("Download Processed Data"),
402
+ h4("Your processed data is ready for download!"),
403
+ br(),
404
+ downloadButton("downloadData", "Download CSV", class = "btn-success btn-lg"),
405
+ br(), br(),
406
+ h4("Data Summary:"),
407
+ verbatimTextOutput("data_summary")
408
+ )
409
+ )
410
+ )
411
+ ),
412
+
413
+ # Modal for editing pitch type
414
+ bsModal("pitchEditModal", "Edit Pitch Type", "triggerModal", size = "medium",
415
+ div(style = "padding: 20px;",
416
+ h4("Selected Pitch Details:", style = "color: darkcyan;"),
417
+ verbatimTextOutput("selected_pitch_info"),
418
+ br(),
419
+ selectInput("modal_new_pitch_type", "Change Pitch Type To:",
420
+ choices = c("Fastball", "Sinker", "Cutter", "Slider",
421
+ "Curveball", "ChangeUp", "Splitter", "Knuckleball", "Other"),
422
+ selected = "Fastball"),
423
+ br(),
424
+ actionButton("update_pitch", "Update Pitch Type", class = "btn-primary btn-lg"),
425
+ actionButton("cancel_edit", "Cancel", class = "btn-default")
426
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
427
  )
428
  )
429
 
430
+ # Server
431
+ server <- function(input, output, session) {
432
+
433
+ # Reactive values
434
  processed_data <- reactiveVal(NULL)
435
+ plot_data <- reactiveVal(NULL)
436
+ selected_pitch <- reactiveVal(NULL)
437
+ selected_points <- reactiveVal(NULL)
438
+
439
+ # Handle column selection buttons
440
+ observeEvent(input$select_all_cols, {
441
+ updateCheckboxGroupInput(session, "columns_to_remove",
442
+ selected = columns_to_remove)
 
 
 
443
  })
444
+
445
+ observeEvent(input$deselect_all_cols, {
446
+ updateCheckboxGroupInput(session, "columns_to_remove", selected = character(0))
447
  })
448
+
449
+ observeEvent(input$select_spinaxis, {
450
+ spinaxis_cols <- columns_to_remove[grepl("SpinAxis3d", columns_to_remove)]
451
+ updateCheckboxGroupInput(session, "columns_to_remove", selected = spinaxis_cols)
452
+ })
453
+
454
+ observeEvent(input$select_attack_angle, {
455
+ attack_angle_cols <- columns_to_remove[grepl("AttackAngle|Attack Angle", columns_to_remove)]
456
+ updateCheckboxGroupInput(session, "columns_to_remove", selected = attack_angle_cols)
457
+ })
458
+
459
+ # Process uploaded file
460
  observeEvent(input$file, {
461
  req(input$file)
462
+
463
  tryCatch({
464
+ # Read the uploaded file
465
  df <- read.csv(input$file$datapath,
466
+ header = input$header,
467
+ sep = input$sep,
468
+ quote = input$quote,
469
  stringsAsFactors = FALSE)
470
+
471
+ # Process the data using selected columns to remove
472
+ selected_cols_to_remove <- input$columns_to_remove %||% character(0)
473
+ processed_df <- df
474
+
475
+ # Remove selected columns
476
+ if (length(selected_cols_to_remove) > 0) {
477
+ columns_to_drop <- intersect(names(df), selected_cols_to_remove)
478
+ if (length(columns_to_drop) > 0) {
479
+ processed_df <- processed_df %>% select(-all_of(columns_to_drop))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
480
  }
481
  }
482
 
483
+ # Remove duplicates only
484
+ processed_df <- processed_df %>% distinct()
485
+
486
+ processed_data(processed_df)
487
+ plot_data(processed_df)
488
+
489
+ # Update pitcher choices
490
+ if ("Pitcher" %in% names(processed_df)) {
491
+ pitcher_choices <- sort(unique(processed_df$Pitcher[!is.na(processed_df$Pitcher)]))
492
+ updateSelectInput(session, "pitcher_select", choices = pitcher_choices, selected = pitcher_choices[1])
493
+ }
494
+
495
+ }, error = function(e) {
496
+ showNotification(paste("Error processing file:", e$message), type = "error")
497
  })
498
  })
499
+
500
+ # Processing summary
501
  output$process_summary <- renderText({
502
+ if (is.null(input$file)) {
503
+ return("No file uploaded yet.")
504
+ }
505
+
506
+ if (is.null(processed_data())) {
507
+ return("Processing...")
508
+ }
509
+
510
  df <- processed_data()
511
  original_df <- read.csv(input$file$datapath, nrows = 1)
512
+ selected_cols_to_remove <- input$columns_to_remove %||% character(0)
513
+ removed_cols <- intersect(selected_cols_to_remove, names(original_df))
514
+
515
+ summary_text <- paste(
516
  "✓ File processed successfully!",
517
  paste("✓ Original columns:", ncol(original_df)),
518
  paste("✓ Final columns:", ncol(df)),
519
+ paste("✓ Target columns: 167"),
520
  paste("✓ Rows processed:", nrow(df)),
521
+ paste("✓ Removed columns:", length(removed_cols)),
522
+ if (length(removed_cols) > 0) paste(" -", paste(head(removed_cols, 5), collapse = ", "),
523
+ if (length(removed_cols) > 5) "..." else ""),
524
  "✓ Duplicates removed",
525
+ paste("✓ Ready for further processing"),
526
  sep = "\n"
527
  )
528
+
529
+ return(summary_text)
530
  })
531
+
532
+ # Preview table
533
+ output$preview <- DT::renderDataTable({
534
  req(processed_data())
535
+
536
+ DT::datatable(processed_data(),
537
+ options = list(scrollX = TRUE, pageLength = 10),
538
+ filter = "top")
539
  })
540
+
541
+ # Movement plot
542
+ output$movement_plot <- renderPlot({
543
  req(plot_data(), input$pitcher_select)
 
544
 
545
+ pitcher_data <- plot_data() %>%
546
  filter(Pitcher == input$pitcher_select) %>%
547
+ filter(!is.na(TaggedPitchType), TaggedPitchType != "Other",
548
+ !is.na(HorzBreak), !is.na(InducedVertBreak), !is.na(RelSpeed)) %>%
549
+ mutate(pitch_id = row_number())
550
 
551
+ if (nrow(pitcher_data) == 0) {
552
+ plot.new()
553
+ text(0.5, 0.5, "No data available for selected pitcher", cex = 1.5)
554
+ return()
555
+ }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
556
 
557
+ # Get colors for each pitch type
558
+ pitcher_data$color <- pitch_colors[pitcher_data$TaggedPitchType]
559
+ pitcher_data$color[is.na(pitcher_data$color)] <- "#D3D3D3"
 
 
 
560
 
561
+ # Create the plot without grid parameter
562
+ par(mar = c(5, 5, 4, 8), xpd = TRUE)
563
+ plot(pitcher_data$HorzBreak, pitcher_data$InducedVertBreak,
564
+ col = pitcher_data$color,
565
+ pch = 19, cex = 1.5,
566
+ xlim = c(-25, 25), ylim = c(-25, 25),
567
+ xlab = "Horizontal Break (inches)",
568
+ ylab = "Induced Vertical Break (inches)",
569
+ main = paste("Pitch Movement Chart -", input$pitcher_select))
570
 
571
+ # Add grid lines manually
572
+ grid(nx = NULL, ny = NULL, col = "lightgray", lty = 1, lwd = 0.5)
573
+ abline(h = 0, col = "gray", lty = 2, lwd = 1)
574
+ abline(v = 0, col = "gray", lty = 2, lwd = 1)
575
+
576
+ # Add concentric circles
577
+ for (r in c(6, 12, 18, 24)) {
578
+ circle_x <- r * cos(seq(0, 2*pi, length.out = 100))
579
+ circle_y <- r * sin(seq(0, 2*pi, length.out = 100))
580
+ lines(circle_x, circle_y, col = "lightgray", lty = 3)
581
+ }
582
 
583
+ # Add points to show selection
584
+ if (input$selection_mode == "drag" && !is.null(selected_points())) {
585
+ sel_points <- selected_points()
586
+ points(sel_points$HorzBreak, sel_points$InducedVertBreak,
587
+ pch = 21, cex = 2, col = "red", lwd = 3)
588
+ }
589
+
590
+ # Create legend
591
+ unique_pitches <- unique(pitcher_data$TaggedPitchType)
592
+ unique_colors <- pitch_colors[unique_pitches]
593
+ legend("topright", inset = c(-0.15, 0),
594
+ legend = unique_pitches,
595
+ col = unique_colors,
596
+ pch = 19,
597
+ cex = 0.8,
598
+ title = "Pitch Type")
599
  })
600
+
601
+ # Handle plot clicks (single mode only)
602
+ observeEvent(input$plot_click, {
603
+ req(plot_data(), input$pitcher_select, input$plot_click)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
604
 
605
+ # Only handle clicks in single mode
606
+ if (input$selection_mode != "single") return()
 
 
 
 
 
 
 
 
 
 
 
607
 
608
+ pitcher_data <- plot_data() %>%
609
+ filter(Pitcher == input$pitcher_select) %>%
610
+ filter(!is.na(TaggedPitchType), TaggedPitchType != "Other",
611
+ !is.na(HorzBreak), !is.na(InducedVertBreak), !is.na(RelSpeed)) %>%
612
+ mutate(pitch_id = row_number())
613
 
614
+ if (nrow(pitcher_data) == 0) return()
 
 
615
 
616
+ # Find closest point to click
617
+ click_x <- input$plot_click$x
618
+ click_y <- input$plot_click$y
619
+
620
+ distances <- sqrt((pitcher_data$HorzBreak - click_x)^2 +
621
+ (pitcher_data$InducedVertBreak - click_y)^2)
622
+
623
+ closest_idx <- which.min(distances)
624
+
625
+ # Only proceed if click is reasonably close (within 2 inches)
626
+ if (min(distances) <= 2) {
627
+ clicked_pitch <- pitcher_data[closest_idx, ]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
628
 
629
+ # Store the original row index in the full dataset
630
+ full_data <- plot_data() %>% filter(Pitcher == input$pitcher_select)
631
+ original_row <- which(full_data$HorzBreak == clicked_pitch$HorzBreak &
632
+ full_data$InducedVertBreak == clicked_pitch$InducedVertBreak &
633
+ full_data$RelSpeed == clicked_pitch$RelSpeed)[1]
634
 
635
+ selected_pitch(list(
636
+ pitcher = input$pitcher_select,
637
+ row_in_pitcher_data = original_row,
638
+ data = clicked_pitch,
639
+ original_type = clicked_pitch$TaggedPitchType
640
+ ))
641
+
642
+ # Update modal dropdown to current pitch type
643
+ updateSelectInput(session, "modal_new_pitch_type",
644
+ selected = clicked_pitch$TaggedPitchType)
645
+
646
+ # Show modal
647
+ toggleModal(session, "pitchEditModal", toggle = "open")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
648
  }
649
  })
650
 
651
+ # Handle brush selection (drag mode)
652
+ observeEvent(input$plot_brush, {
653
+ req(plot_data(), input$pitcher_select, input$plot_brush)
654
 
655
+ # Only handle brush in drag mode
656
+ if (input$selection_mode != "drag") return()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
657
 
658
+ pitcher_data <- plot_data() %>%
659
+ filter(Pitcher == input$pitcher_select) %>%
660
+ filter(!is.na(TaggedPitchType), TaggedPitchType != "Other",
661
+ !is.na(HorzBreak), !is.na(InducedVertBreak), !is.na(RelSpeed))
662
 
663
+ if (nrow(pitcher_data) == 0) return()
664
 
665
+ # Find points within brush area
666
+ brush <- input$plot_brush
667
+ brushed_points <- pitcher_data %>%
668
+ filter(
669
+ HorzBreak >= brush$xmin & HorzBreak <= brush$xmax &
670
+ InducedVertBreak >= brush$ymin & InducedVertBreak <= brush$ymax
671
+ )
672
 
673
+ if (nrow(brushed_points) > 0) {
674
+ selected_points(brushed_points)
675
+ } else {
676
+ selected_points(NULL)
677
+ }
678
  })
679
+
680
+ # Apply bulk change
681
  observeEvent(input$apply_bulk_change, {
682
+ req(selected_points(), input$bulk_pitch_type)
683
+
684
+ sel_points <- selected_points()
685
+
686
+ if (nrow(sel_points) == 0) {
687
+ showNotification("No points selected", type = "warning")
 
 
 
 
 
 
 
 
 
688
  return()
689
  }
 
 
 
690
 
691
+ # Get current data
692
+ current_data <- plot_data()
693
+
694
+ # Update all selected points
695
+ for (i in 1:nrow(sel_points)) {
696
+ point <- sel_points[i, ]
697
+ current_data <- current_data %>%
698
+ mutate(TaggedPitchType = ifelse(
699
+ Pitcher == input$pitcher_select &
700
+ abs(HorzBreak - point$HorzBreak) < 0.01 &
701
+ abs(InducedVertBreak - point$InducedVertBreak) < 0.01 &
702
+ abs(RelSpeed - point$RelSpeed) < 0.01,
703
+ input$bulk_pitch_type,
704
+ TaggedPitchType
705
+ ))
706
+ }
707
+
708
+ # Update reactive values
709
+ plot_data(current_data)
710
+ processed_data(current_data)
711
 
712
+ # Clear selection
713
+ selected_points(NULL)
714
 
715
+ # Show success message
716
  showNotification(
717
+ paste("Updated", nrow(sel_points), "pitches to", input$bulk_pitch_type),
718
+ type = "message", duration = 3
 
719
  )
720
  })
721
+
722
+ # Selection info for drag mode
723
+ output$selection_info <- renderText({
724
+ if (input$selection_mode == "drag" && !is.null(selected_points())) {
725
+ sel_points <- selected_points()
726
+ pitch_counts <- table(sel_points$TaggedPitchType)
727
+ paste(nrow(sel_points), "points selected:",
728
+ paste(names(pitch_counts), "(", pitch_counts, ")", collapse = ", "))
729
  } else {
730
+ "No points selected. Click and drag to select multiple pitches."
 
 
 
 
 
731
  }
732
  })
733
+
734
+ # Hover info
735
+ output$hover_info <- renderText({
736
+ req(input$plot_hover, plot_data(), input$pitcher_select)
737
+
738
+ pitcher_data <- plot_data() %>%
739
+ filter(Pitcher == input$pitcher_select) %>%
740
+ filter(!is.na(TaggedPitchType), TaggedPitchType != "Other",
741
+ !is.na(HorzBreak), !is.na(InducedVertBreak), !is.na(RelSpeed))
742
+
743
+ if (nrow(pitcher_data) == 0) return("")
744
+
745
+ hover_x <- input$plot_hover$x
746
+ hover_y <- input$plot_hover$y
747
+
748
+ distances <- sqrt((pitcher_data$HorzBreak - hover_x)^2 +
749
+ (pitcher_data$InducedVertBreak - hover_y)^2)
750
+
751
+ if (min(distances) <= 2) {
752
+ closest_idx <- which.min(distances)
753
+ hover_pitch <- pitcher_data[closest_idx, ]
754
+
755
+ paste("Hovering over:",
756
+ paste("Type:", hover_pitch$TaggedPitchType),
757
+ paste("Velocity:", round(hover_pitch$RelSpeed, 1), "mph"),
758
+ paste("HB:", round(hover_pitch$HorzBreak, 1), "in"),
759
+ paste("IVB:", round(hover_pitch$InducedVertBreak, 1), "in"),
760
+ sep = " | ")
761
+ } else {
762
+ ""
763
+ }
764
+ })
765
+
766
+ # Movement stats table
767
+ output$movement_stats <- DT::renderDataTable({
768
  req(plot_data(), input$pitcher_select)
 
769
 
770
  data <- plot_data()
771
+
772
  movement_stats <- data %>%
773
  filter(Pitcher == input$pitcher_select) %>%
774
  filter(!is.na(HorzBreak), !is.na(InducedVertBreak), !is.na(TaggedPitchType)) %>%
775
  mutate(
776
  pitch_group = case_when(
777
+ TaggedPitchType %in% c("Fastball", "FourSeamFastBall", "FourSeamFastB", "Four-Seam", "4-Seam") ~ "Fastball",
778
+ TaggedPitchType %in% c("OneSeamFastBall", "TwoSeamFastBall", "Sinker", "Two-Seam", "One-Seam") ~ "Sinker",
779
+ TaggedPitchType %in% c("ChangeUp", "Changeup") ~ "Changeup",
780
  TRUE ~ TaggedPitchType
781
  ),
782
+ # Create necessary indicator variables if they don't exist
783
+ in_zone = if ("StrikeZoneIndicator" %in% names(.)) StrikeZoneIndicator else
784
+ ifelse(!is.na(PlateLocSide) & !is.na(PlateLocHeight) &
785
  PlateLocSide >= -0.95 & PlateLocSide <= 0.95 &
786
  PlateLocHeight >= 1.6 & PlateLocHeight <= 3.5, 1, 0),
787
+ is_whiff = if ("WhiffIndicator" %in% names(.)) WhiffIndicator else
788
  ifelse(!is.na(PitchCall) & PitchCall == "StrikeSwinging", 1, 0),
789
  chase = if ("Chaseindicator" %in% names(.)) Chaseindicator else
790
  ifelse(!is.na(PitchCall) & !is.na(PlateLocSide) & !is.na(PlateLocHeight) &
791
+ PitchCall %in% c("StrikeSwinging", "FoulBallNotFieldable", "FoulBall", "InPlay") &
792
  (PlateLocSide < -0.95 | PlateLocSide > 0.95 | PlateLocHeight < 1.6 | PlateLocHeight > 3.5), 1, 0)
793
  )
794
 
795
+ # Calculate total pitches for usage percentage
796
  total_pitches <- nrow(movement_stats)
797
 
798
  summary_stats <- movement_stats %>%
799
  group_by(`Pitch Type` = pitch_group) %>%
800
  summarise(
801
  Count = n(),
802
+ `Usage%` = sprintf("%.1f%%", (n() / total_pitches) * 100),
803
+ `Ext.` = if ("Extension" %in% names(.)) sprintf("%.1f", mean(Extension, na.rm = TRUE)) else "—",
804
+ `Avg Velo` = sprintf("%.1f mph", mean(RelSpeed, na.rm = TRUE)),
805
+ `90th Velo` = sprintf("%.1f mph", quantile(RelSpeed, 0.9, na.rm = TRUE)),
806
+ `Max Velo` = sprintf("%.1f mph", max(RelSpeed, na.rm = TRUE)),
807
+ `Avg IVB` = sprintf("%.1f in", mean(InducedVertBreak, na.rm = TRUE)),
808
+ `Avg HB` = sprintf("%.1f in", mean(HorzBreak, na.rm = TRUE)),
809
+ `Avg Spin` = if ("SpinRate" %in% names(.)) sprintf("%.0f rpm", mean(SpinRate, na.rm = TRUE)) else "—",
810
+ `Rel Height` = if ("RelHeight" %in% names(.)) sprintf("%.1f", mean(RelHeight, na.rm = TRUE)) else "—",
811
+ `Zone%` = sprintf("%.1f%%", round(mean(in_zone, na.rm = TRUE) * 100, 1)),
812
+ `Whiff%` = sprintf("%.1f%%", round(mean(is_whiff, na.rm = TRUE) * 100, 1)),
813
+ `Chase%` = sprintf("%.1f%%", round(mean(chase, na.rm = TRUE) * 100, 1)),
814
  .groups = "drop"
815
+ ) %>%
816
+ arrange(desc(Count))
817
+
818
+ DT::datatable(summary_stats,
819
+ options = list(pageLength = 15, dom = 't', scrollX = TRUE),
820
+ rownames = FALSE) %>%
821
+ DT::formatStyle(columns = names(summary_stats), fontSize = '12px')
822
+ })
823
+
824
+ # Selected pitch info in modal
825
+ output$selected_pitch_info <- renderText({
826
+ pitch_info <- selected_pitch()
827
+ if (!is.null(pitch_info)) {
828
+ pitch_data <- pitch_info$data
829
+
830
+ info_text <- paste(
831
+ paste("Pitcher:", pitch_info$pitcher),
832
+ paste("Current Type:", pitch_data$TaggedPitchType),
833
+ paste("Velocity:", round(pitch_data$RelSpeed, 1), "mph"),
834
+ paste("Horizontal Break:", round(pitch_data$HorzBreak, 1), "inches"),
835
+ paste("Induced Vertical Break:", round(pitch_data$InducedVertBreak, 1), "inches"),
836
+ if ("SpinRate" %in% names(pitch_data) && !is.na(pitch_data$SpinRate)) {
837
+ paste("Spin Rate:", round(pitch_data$SpinRate, 0), "rpm")
838
+ } else "",
839
+ if ("Date" %in% names(pitch_data) && !is.na(pitch_data$Date)) {
840
+ paste("Date:", format(as.Date(pitch_data$Date)))
841
+ } else "",
842
+ sep = "\n"
843
+ )
844
+
845
+ return(info_text)
846
+ } else {
847
+ return("No pitch selected")
848
+ }
849
+ })
850
+
851
+ # Update pitch type
852
+ observeEvent(input$update_pitch, {
853
+ pitch_info <- selected_pitch()
854
 
855
+ if (!is.null(pitch_info)) {
856
+ # Get the full dataset
857
+ current_data <- plot_data()
858
+
859
+ # Find and update the specific pitch more reliably
860
+ # Use multiple criteria to identify the exact row
861
+ target_pitcher <- pitch_info$pitcher
862
+ target_hb <- pitch_info$data$HorzBreak
863
+ target_ivb <- pitch_info$data$InducedVertBreak
864
+ target_velo <- pitch_info$data$RelSpeed
865
+
866
+ # Update the TaggedPitchType for the matching row
867
+ current_data <- current_data %>%
868
+ mutate(TaggedPitchType = ifelse(
869
+ Pitcher == target_pitcher &
870
+ abs(HorzBreak - target_hb) < 0.01 &
871
+ abs(InducedVertBreak - target_ivb) < 0.01 &
872
+ abs(RelSpeed - target_velo) < 0.01,
873
+ input$modal_new_pitch_type,
874
+ TaggedPitchType
875
+ ))
876
+
877
+ # Update reactive values
878
+ plot_data(current_data)
879
+ processed_data(current_data)
880
+
881
+ # Close modal
882
+ toggleModal(session, "pitchEditModal", toggle = "close")
883
+
884
+ # Show success message
885
+ showNotification(
886
+ paste("Updated pitch from", pitch_info$original_type, "to", input$modal_new_pitch_type),
887
+ type = "message", duration = 3
888
+ )
889
+
890
+ # Clear selection
891
+ selected_pitch(NULL)
892
+ }
893
+ })
894
+
895
+ # Cancel edit
896
+ observeEvent(input$cancel_edit, {
897
+ toggleModal(session, "pitchEditModal", toggle = "close")
898
+ selected_pitch(NULL)
899
  })
900
 
901
+ # Click info output
902
+ output$click_info <- renderText({
903
+ if (!is.null(selected_pitch())) {
904
+ pitch_info <- selected_pitch()
905
+ paste("Last selected pitch:", pitch_info$original_type,
906
+ "| Position: (", round(pitch_info$data$HorzBreak, 1), ",",
907
+ round(pitch_info$data$InducedVertBreak, 1), ")")
908
+ } else {
909
+ "No point selected yet. Click on a point in the chart above to edit its pitch type."
910
+ }
911
+ })
912
+
913
+ # Data summary for download page
914
  output$data_summary <- renderText({
915
  req(processed_data())
916
  df <- processed_data()
917
+
918
+ summary_text <- paste(
919
  paste("Total rows:", nrow(df)),
920
  paste("Total columns:", ncol(df)),
921
+ paste("Date range:",
922
+ if ("Date" %in% names(df) && !all(is.na(df$Date))) {
923
+ paste(min(as.Date(df$Date), na.rm = TRUE), "to", max(as.Date(df$Date), na.rm = TRUE))
924
+ } else {
925
+ "Date column not available"
926
+ }),
927
  paste("Unique pitchers:",
928
+ if ("Pitcher" %in% names(df)) {
929
+ length(unique(df$Pitcher[!is.na(df$Pitcher)]))
930
+ } else {
931
+ "Pitcher column not available"
932
+ }),
933
  paste("Pitch types:",
934
+ if ("TaggedPitchType" %in% names(df)) {
935
+ paste(sort(unique(df$TaggedPitchType[!is.na(df$TaggedPitchType)])), collapse = ", ")
936
+ } else {
937
+ "TaggedPitchType column not available"
938
+ }),
939
  sep = "\n"
940
  )
941
+
942
+ return(summary_text)
943
  })
944
 
945
+ # Download handler
946
  output$downloadData <- downloadHandler(
947
+ filename = function() {
948
+ paste("app_ready_COA", Sys.Date(), ".csv", sep = "")
949
+ },
950
+ content = function(file) {
951
+ write.csv(processed_data(), file, row.names = FALSE)
 
 
 
952
  }
953
  )
954
  }
955
 
956
+ # Run the app
957
  shinyApp(ui = ui, server = server)