igroffman commited on
Commit
8af692e
·
verified ·
1 Parent(s): a6b1251

Update app.R

Browse files
Files changed (1) hide show
  1. app.R +453 -860
app.R CHANGED
@@ -1,957 +1,550 @@
 
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)
 
1
+ # app.R
2
  library(shiny)
3
  library(shinydashboard)
 
4
  library(DT)
5
  library(dplyr)
6
  library(readr)
7
  library(stringr)
8
+ library(plotly)
9
 
10
+ # ---------- helpers ----------
11
+ `%||%` <- function(x, y) if (is.null(x)) y else x
12
+
13
+ # columns to optionally remove
14
  columns_to_remove <- c(
15
+ "SpinAxis3dTransverseAngle","SpinAxis3dLongitudinalAngle","SpinAxis3dActiveSpinRate",
16
+ "SpinAxis3dSpinEfficiency","SpinAxis3dTilt","SpinAxis3dVectorX","SpinAxis3dVectorY",
17
+ "SpinAxis3dVectorZ","SpinAxis3dSeamOrientationRotationX","SpinAxis3dSeamOrientationRotationY",
18
+ "SpinAxis3dSeamOrientationRotationZ","SpinAxis3dSeamOrientationBallYAmb1",
19
+ "SpinAxis3dSeamOrientationBallAngleHorizontalAmb1","SpinAxis3dSeamOrientationBallZAmb1",
20
+ "SpinAxis3dSeamOrientationBallAngleVerticalAmb2","SpinAxis3dSeamOrientationBallZAmb2",
21
+ "SpinAxis3dSeamOrientationBallXAmb4","SpinAxis3dSeamOrientationBallYAmb4",
22
+ "SpinAxis3dSeamOrientationBallAngleHorizontalAmb2","SpinAxis3dSeamOrientationBallAngleVerticalAmb1",
23
+ "SpinAxis3dSeamOrientationBallXAmb1","SpinAxis3dSeamOrientationBallYAmb2",
24
+ "SpinAxis3dSeamOrientationBallAngleHorizontalAmb4","SpinAxis3dSeamOrientationBallAngleVerticalAmb4",
25
+ "SpinAxis3dSeamOrientationBallXAmb2","SpinAxis3dSeamOrientationBallAngleVerticalAmb3",
26
+ "SpinAxis3dSeamOrientationBallAngleHorizontalAmb3","SpinAxis3dSeamOrientationBallXAmb3",
27
+ "SpinAxis3dSeamOrientationBallYAmb3","SpinAxis3dSeamOrientationBallZAmb3",
28
+ "SpinAxis3dSeamOrientationBallZAmb4","BatSpeed","GameDate","HorizontalAttackAngle",
29
+ "Horizontal Attack Angle","VerticalAttackAngle","Vertical attack angle"
30
  )
31
 
32
+ # pitch colors
33
  pitch_colors <- c(
34
+ "Fastball" = "#FA8072",
35
+ "Four-Seam" = "#FA8072",
36
+ "Sinker" = "#fdae61",
37
+ "Slider" = "#A020F0",
38
+ "Sweeper" = "magenta",
39
+ "Curveball" = "#2c7bb6",
40
+ "ChangeUp" = "#90EE90",
41
+ "Splitter" = "#90EE32",
42
+ "Cutter" = "red",
43
  "Knuckleball" = "#FFB4B4",
44
+ "Other" = "#D3D3D3"
45
  )
46
 
47
+ # ---------- UI ----------
 
 
 
 
 
 
 
 
 
 
 
 
 
 
48
  ui <- fluidPage(
49
  tags$head(
50
  tags$style(HTML("
51
  body, table, .gt_table {
52
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
53
+ Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol';
54
+ }
55
+ .app-header { display:flex; justify-content:space-between; align-items:center;
56
+ padding:20px 40px; background:#ffffff; border-bottom:3px solid darkcyan; margin-bottom:20px; }
57
+ .header-logo-left,.header-logo-right { width:120px; height:auto; }
58
+ .header-logo-center { max-width:400px; height:auto; }
59
+ @media (max-width:768px){ .app-header{flex-direction:column; padding:15px 20px;}
60
+ .header-logo-left,.header-logo-right{width:80px;} .header-logo-center{max-width:250px; margin:10px 0;} }
61
+ .nav-tabs{ border:none!important; border-radius:50px; padding:6px 12px; margin:20px auto 0; max-width:100%;
62
+ background:linear-gradient(135deg,#d4edeb 0%,#e8ddd0 50%,#d4edeb 100%);
63
+ box-shadow:0 4px 16px rgba(0,139,139,.12), inset 0 2px 4px rgba(255,255,255,.6);
64
+ border:1px solid rgba(0,139,139,.2); position:relative; overflow-x:auto; display:flex; justify-content:center;
65
+ align-items:center; flex-wrap:wrap; gap:6px; -webkit-overflow-scrolling:touch; }
66
+ .nav-tabs::-webkit-scrollbar{height:0;}
67
+ .nav-tabs::before{content:''; position:absolute; inset:0; pointer-events:none; border-radius:50px;
68
+ background:linear-gradient(135deg, rgba(255,255,255,.4), transparent);}
69
+ .nav-tabs>li>a{ color:darkcyan!important; border:none!important; border-radius:50px!important; background:transparent!important;
70
+ font-weight:700; font-size:14.5px; padding:10px 22px; white-space:nowrap; letter-spacing:.2px; transition:.2s; }
71
+ .nav-tabs>li>a:hover{ color:#006666!important; background:rgba(255,255,255,.5)!important; transform:translateY(-1px); }
72
+ .nav-tabs>li.active>a,.nav-tabs>li.active>a:focus,.nav-tabs>li.active>a:hover{
73
+ background:linear-gradient(135deg,#008b8b 0%,#20b2aa 30%,#00ced1 50%,#20b2aa 70%,#008b8b 100%)!important;
74
+ color:#fff!important; text-shadow:0 1px 2px rgba(0,0,0,.2);
75
+ 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);
76
+ border:1px solid rgba(255,255,255,.3)!important; }
77
+ .tab-content{ background:linear-gradient(135deg, rgba(255,255,255,.95), rgba(248,249,250,.95));
78
+ border-radius:20px; padding:25px; margin-top:14px; box-shadow:0 15px 40px rgba(0,139,139,.1);
79
+ backdrop-filter:blur(15px); border:1px solid rgba(0,139,139,.1); position:relative; overflow:hidden; }
80
+ .tab-content::before{content:''; position:absolute; left:0; right:0; top:0; height:4px;
81
+ background:linear-gradient(90deg,darkcyan,peru,darkcyan); background-size:200% 100%;
82
+ animation:shimmer 3s linear infinite;}
83
+ @keyframes shimmer{0%{background-position:-200% 0;}100%{background-position:200% 0;}}
84
+ h3{ color:black; font-weight:600; margin-top:25px; margin-bottom:15px; padding-bottom:8px; border-bottom:2px solid #007BA7; }
85
+ h4{ color:white; font-weight:500; margin-top:20px; margin-bottom:12px; }
86
+ h1{ color:#007BA7; font-weight:700; margin-bottom:20px; text-shadow:1px 1px 2px rgba(0,0,0,0.1); }
87
+ label{ font-weight:500; color:peru; margin-bottom:5px; }
88
+ thead th{ background:#F8F9FA; color:#2C3E50; font-weight:600; text-align:center!important; padding:10px!important; }
89
+ .brand-teal{ color:darkcyan; } .brand-bronze{ color:peru; }
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, h4("Selection Mode:"),
143
+ radioButtons("selection_mode","",
144
+ choices = list("Single Click"="single","Drag Select"="drag"),
145
+ selected="single", inline=TRUE)),
146
+ column(6,
147
+ conditionalPanel(
148
+ condition = "input.selection_mode == 'drag'",
149
+ h4("Bulk Edit:"),
150
+ fluidRow(
151
+ column(8, selectInput("bulk_pitch_type","Change all selected to:",
152
+ choices=c("Fastball","Sinker","Cutter","Slider","Curveball","ChangeUp","Splitter","Knuckleball","Other"),
153
+ selected="Fastball")),
154
+ column(4, br(), actionButton("apply_bulk_change","Apply to Selected", class="btn-success"))
155
+ )
156
  )
157
+ )
158
+ ),
159
+ fluidRow(
160
+ column(8,
161
+ h3("Interactive Pitch Movement Analysis"),
162
+ plotlyOutput("movement_plot", height="600px"),
163
+ conditionalPanel(
164
+ condition = "input.selection_mode == 'drag'",
165
+ div(style="background-color:#f0f8ff; padding:10px; border-radius:5px; margin:10px 0; border-left:4px solid darkcyan;",
166
+ h4("Selected Points:", style="margin-top:0; color:darkcyan;"),
167
+ textOutput("selection_info")
168
+ )
169
+ ),
170
+ verbatimTextOutput("click_info")
171
+ ),
172
+ column(4,
173
+ h3("Pitch Metrics Summary"),
174
+ DTOutput("movement_stats"),
175
+ h3("Location Plot (Editable)"),
176
+ plotlyOutput("location_plot", height="600px") # <— new location plot
177
+ )
178
+ )
179
+ ),
180
+
181
+ tabPanel("Download",
182
+ fluidRow(column(12,
183
+ h3("Download Processed Data"),
184
+ h4("Your processed data is ready for download!"), br(),
185
+ downloadButton("downloadData","Download CSV", class="btn-success btn-lg"),
186
+ br(), br(),
187
+ h4("Data Summary:"),
188
+ verbatimTextOutput("data_summary")
189
+ )))
190
  )
191
  )
192
 
193
+ # ---------- Server ----------
194
+ server <- function(input, output, session){
195
+
196
+ # reactives
197
  processed_data <- reactiveVal(NULL)
198
+ plot_data <- reactiveVal(NULL)
199
  selected_pitch <- reactiveVal(NULL)
200
+ selected_keys <- reactiveVal(integer(0)) # works for either chart
201
+
202
+ # quick-select buttons
203
+ observeEvent(input$select_all_cols, { updateCheckboxGroupInput(session,"columns_to_remove", selected = columns_to_remove) })
204
+ observeEvent(input$deselect_all_cols, { updateCheckboxGroupInput(session,"columns_to_remove", selected = character(0)) })
205
+ observeEvent(input$select_spinaxis, {
206
+ spin <- columns_to_remove[grepl("SpinAxis3d", columns_to_remove)]
207
+ updateCheckboxGroupInput(session,"columns_to_remove", selected = spin)
 
 
 
 
 
 
 
208
  })
209
+ observeEvent(input$select_attack_angle,{
210
+ aa <- columns_to_remove[grepl("AttackAngle|Attack Angle", columns_to_remove)]
211
+ updateCheckboxGroupInput(session,"columns_to_remove", selected = aa)
 
212
  })
213
+
214
+ # file upload + processing
215
  observeEvent(input$file, {
216
  req(input$file)
 
217
  tryCatch({
 
218
  df <- read.csv(input$file$datapath,
219
+ header = input$header, sep = input$sep, quote = input$quote,
 
 
220
  stringsAsFactors = FALSE)
221
+
222
+ # drop user-selected columns
223
+ sel_drop <- input$columns_to_remove %||% character(0)
224
+ if (length(sel_drop)) {
225
+ df <- df %>% select(-any_of(intersect(names(df), sel_drop)))
 
 
 
 
 
 
226
  }
227
+
228
+ # numeric coercions for interaction robustness
229
+ num_cols <- c("HorzBreak","InducedVertBreak","RelSpeed","SpinRate",
230
+ "PlateLocSide","PlateLocHeight","Extension","RelHeight")
231
+ have <- intersect(names(df), num_cols)
232
+ df[have] <- lapply(df[have], function(x) suppressWarnings(as.numeric(x)))
233
+
234
+ df <- df %>% distinct()
235
+
236
+ processed_data(df)
237
+ plot_data(df)
238
+
239
+ if ("Pitcher" %in% names(df)) {
240
+ choices <- sort(unique(df$Pitcher[!is.na(df$Pitcher)]))
241
+ if (length(choices)) updateSelectInput(session,"pitcher_select", choices = choices, selected = choices[1])
242
  }
243
+ }, error = function(e){
244
+ showNotification(paste("Error processing file:", e$message), type="error")
 
245
  })
246
  })
247
+
248
+ # processing summary
249
  output$process_summary <- renderText({
250
+ if (is.null(input$file)) return("No file uploaded yet.")
251
+ if (is.null(processed_data())) return("Processing...")
 
 
 
 
 
 
252
  df <- processed_data()
253
  original_df <- read.csv(input$file$datapath, nrows = 1)
254
+ sel <- input$columns_to_remove %||% character(0)
255
+ removed <- intersect(sel, names(original_df))
256
+ paste(
 
257
  "✓ File processed successfully!",
258
  paste("✓ Original columns:", ncol(original_df)),
259
  paste("✓ Final columns:", ncol(df)),
 
260
  paste("✓ Rows processed:", nrow(df)),
261
+ paste("✓ Removed columns:", length(removed)),
262
+ if (length(removed)>0) paste(" -", paste(head(removed,5), collapse=", "),
263
+ if (length(removed)>5) "..." else "") else "",
264
  "✓ Duplicates removed",
 
265
  sep = "\n"
266
  )
 
 
267
  })
268
+
269
+ # preview table
270
+ output$preview <- renderDT({
271
  req(processed_data())
272
+ datatable(processed_data(), options=list(scrollX=TRUE, pageLength=10), filter="top")
 
 
 
273
  })
274
+
275
+ # pitcher-specific reactive with stable row_key (per pitcher group)
276
+ pitcher_df <- reactive({
277
  req(plot_data(), input$pitcher_select)
278
+ plot_data() %>%
 
279
  filter(Pitcher == input$pitcher_select) %>%
280
+ filter(!is.na(HorzBreak), !is.na(InducedVertBreak), !is.na(RelSpeed)) %>%
281
+ mutate(row_key = row_number())
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
282
  })
283
+
284
+ # ---- Plotly Movement plot ----
285
+ output$movement_plot <- renderPlotly({
286
+ d <- pitcher_df()
287
+ validate(need(nrow(d) > 0, "No data for selected pitcher"))
288
+ pal <- pitch_colors
289
+ cols <- pal[d$TaggedPitchType]; cols[is.na(cols)] <- "#D3D3D3"
290
+
291
+ plot_ly(
292
+ data = d, source="mv", type="scatter", mode="markers",
293
+ x = ~HorzBreak, y = ~InducedVertBreak,
294
+ text = ~paste0(
295
+ "<b>", TaggedPitchType, "</b>",
296
+ "<br>Velo: ", round(RelSpeed,1), " mph",
297
+ "<br>HB: ", round(HorzBreak,1), " in",
298
+ "<br>IVB: ", round(InducedVertBreak,1), " in",
299
+ if ("SpinRate" %in% names(d)) paste0("<br>Spin: ", round(SpinRate), " rpm") else ""
300
+ ),
301
+ hoverinfo = "text",
302
+ key = ~row_key,
303
+ marker = list(size = 10, color = cols)
304
+ ) %>%
305
+ layout(
306
+ title = paste("Pitch Movement Chart -", input$pitcher_select),
307
+ xaxis = list(title="Horizontal Break (in)", range=c(-25,25), zeroline=TRUE),
308
+ yaxis = list(title="Induced Vertical Break (in)", range=c(-25,25), zeroline=TRUE),
309
+ dragmode = if (input$selection_mode == "drag") "select" else "zoom"
310
+ ) %>%
311
+ config(displaylogo = FALSE)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
312
  })
313
+
314
+ # ---- Plotly Location plot (under table) ----
315
+ output$location_plot <- renderPlotly({
316
+ d <- pitcher_df()
317
+ validate(need(nrow(d) > 0, "No data for selected pitcher"))
318
+ pal <- pitch_colors
319
+ cols <- pal[d$TaggedPitchType]; cols[is.na(cols)] <- "#D3D3D3"
320
+
321
+ p <- plot_ly(
322
+ data = d, source="loc", type="scatter", mode="markers",
323
+ x = ~PlateLocSide, y = ~PlateLocHeight,
324
+ text = ~paste0(
325
+ "<b>", TaggedPitchType, "</b>",
326
+ "<br>Velo: ", round(RelSpeed,1), " mph",
327
+ "<br>X: ", round(PlateLocSide,2),
328
+ "<br>Z: ", round(PlateLocHeight,2)
329
+ ),
330
+ hoverinfo = "text",
331
+ key = ~row_key,
332
+ marker = list(size = 9, color = cols)
333
+ ) %>%
334
+ layout(
335
+ title = "Pitch Location (Editable)",
336
+ xaxis = list(title="Plate X (ft)", range=c(-2,2), zeroline=TRUE),
337
+ yaxis = list(title="Plate Z (ft)", range=c(0,4.5), zeroline=TRUE),
338
+ dragmode = if (input$selection_mode == "drag") "select" else "zoom",
339
+ shapes = list(
340
+ # strike zone rectangle
341
+ list(type="rect", x0=-0.8303, x1=0.8303, y0=1.6, y1=3.5,
342
+ line=list(color="black", width=1), fillcolor="rgba(0,0,0,0)"),
343
+ # home plate path
344
+ list(type="path",
345
+ path = paste0("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"),
346
+ line=list(color="black", width=0.8))
347
+ )
348
+ ) %>% config(displaylogo = FALSE)
349
+
350
+ p
351
+ })
352
+
353
+ # ---- Click handlers (single mode) for both plots ----
354
+ show_pitch_modal <- function(hit_row){
355
+ selected_pitch(list(
356
+ pitcher = input$pitcher_select,
357
+ row_key = hit_row$row_key[1],
358
+ data = hit_row[1,],
359
+ original_type = hit_row$TaggedPitchType[1]
360
+ ))
361
+ showModal(
362
+ modalDialog(
363
+ title = tags$h3("Selected Pitch Details:", style="color: darkcyan;"),
364
+ verbatimTextOutput("selected_pitch_info"),
365
+ tags$br(),
366
+ tags$label("Change Pitch Type To:", style="color: peru; font-weight: 600;"),
367
+ selectInput("modal_new_pitch_type", NULL,
368
+ choices = c("Fastball","Sinker","Cutter","Slider","Curveball",
369
+ "ChangeUp","Splitter","Knuckleball","Other"),
370
+ selected = selected_pitch()[["data"]][["TaggedPitchType"]]),
371
+ tags$br(),
372
+ actionButton("update_pitch","Update Pitch Type", class="btn-primary btn-lg"),
373
+ actionButton("cancel_edit","Cancel", class="btn-default"),
374
+ easyClose = TRUE, footer = NULL, size = "m"
375
  )
376
+ )
377
+ }
378
+
379
+ observeEvent(event_data("plotly_click", source="mv"), {
380
+ req(input$selection_mode == "single")
381
+ clk <- event_data("plotly_click", source="mv"); req(nrow(as.data.frame(clk))>0)
382
+ key <- clk$key[[1]]
383
+ d <- pitcher_df(); hit <- d[d$row_key == key, ]
384
+ if (nrow(hit)==1) show_pitch_modal(hit)
385
  })
386
+ observeEvent(event_data("plotly_click", source="loc"), {
387
+ req(input$selection_mode == "single")
388
+ clk <- event_data("plotly_click", source="loc"); req(nrow(as.data.frame(clk))>0)
389
+ key <- clk$key[[1]]
390
+ d <- pitcher_df(); hit <- d[d$row_key == key, ]
391
+ if (nrow(hit)==1) show_pitch_modal(hit)
392
+ })
393
+
394
+ # ---- Selected pitch info in modal ----
395
+ output$selected_pitch_info <- renderText({
396
+ info <- selected_pitch()
397
+ if (is.null(info)) return("No pitch selected")
398
+ d <- info$data
399
+ paste(
400
+ paste("Pitcher:", info$pitcher),
401
+ paste("Current Type:", d$TaggedPitchType),
402
+ paste("Velocity:", round(d$RelSpeed,1), "mph"),
403
+ paste("Horizontal Break:", round(d$HorzBreak,1), "inches"),
404
+ paste("Induced Vertical Break:", round(d$InducedVertBreak,1), "inches"),
405
+ if ("SpinRate" %in% names(d) && !is.na(d$SpinRate)) paste("Spin Rate:", round(d$SpinRate,0), "rpm") else "",
406
+ if ("Date" %in% names(d) && !is.na(d$Date)) paste("Date:", format(as.Date(d$Date))) else "",
407
+ sep = "\n"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
408
  )
409
  })
410
+
411
+ # ---- Apply single-change from modal ----
412
+ observeEvent(input$update_pitch, {
413
+ info <- selected_pitch(); req(info)
414
+ new_type <- input$modal_new_pitch_type; req(new_type)
415
+ cur <- plot_data(); req("Pitcher" %in% names(cur))
416
+
417
+ # map row_key back to absolute index for this pitcher slice
418
+ idx_pitcher <- which(cur$Pitcher == info$pitcher)
419
+ if (length(idx_pitcher) >= info$row_key && info$row_key > 0) {
420
+ abs_row <- idx_pitcher[info$row_key]
421
+ cur$TaggedPitchType[abs_row] <- new_type
422
+ plot_data(cur); processed_data(cur)
423
+ removeModal()
424
+ showNotification(paste("Updated pitch from", info$original_type, "to", new_type),
425
+ type="message", duration=3)
426
+ selected_pitch(NULL)
427
  } else {
428
+ showNotification("Could not map selection back to dataset.", type="error")
429
  }
430
  })
431
+ observeEvent(input$cancel_edit, { removeModal(); selected_pitch(NULL) })
432
+
433
+ # ---- Drag select (either chart) ----
434
+ observeEvent(event_data("plotly_selected", source="mv"), {
435
+ req(input$selection_mode == "drag")
436
+ ev <- event_data("plotly_selected", source="mv")
437
+ if (!is.null(ev) && length(ev$key)) selected_keys(unique(as.integer(ev$key)))
438
+ })
439
+ observeEvent(event_data("plotly_selected", source="loc"), {
440
+ req(input$selection_mode == "drag")
441
+ ev <- event_data("plotly_selected", source="loc")
442
+ if (!is.null(ev) && length(ev$key)) selected_keys(unique(as.integer(ev$key)))
443
+ })
444
+
445
+ # selection info text
446
+ output$selection_info <- renderText({
447
+ keys <- selected_keys()
448
+ if (!length(keys)) return("No points selected.")
449
+ d <- pitcher_df(); sel <- d[d$row_key %in% keys, ]
450
+ cnt <- sort(table(sel$TaggedPitchType), decreasing = TRUE)
451
+ paste(nrow(sel), "points selected:", paste(names(cnt), "(", cnt, ")", collapse=", "))
452
+ })
453
+
454
+ # bulk change
455
+ observeEvent(input$apply_bulk_change, {
456
+ req(input$selection_mode == "drag")
457
+ keys <- selected_keys(); req(length(keys) > 0)
458
+ new_type <- input$bulk_pitch_type; req(new_type)
459
+
460
+ cur <- plot_data(); req("Pitcher" %in% names(cur))
461
+ idx_pitcher <- which(cur$Pitcher == input$pitcher_select)
462
+ valid_keys <- keys[keys > 0 & keys <= length(idx_pitcher)]
463
+ abs_rows <- idx_pitcher[valid_keys]
464
+ cur$TaggedPitchType[abs_rows] <- new_type
465
+
466
+ plot_data(cur); processed_data(cur)
467
+ selected_keys(integer(0))
468
+ showNotification(paste("Updated", length(abs_rows), "pitches to", new_type),
469
+ type="message", duration=3)
470
+ })
471
+
472
+ # click info small text
473
+ output$click_info <- renderText({
474
+ info <- selected_pitch()
475
+ if (is.null(info)) "No point selected yet. Click a point to edit its pitch type."
476
+ else paste("Last selected pitch:", info$original_type,
477
+ "| Position (HB, IVB): (", round(info$data$HorzBreak,1), ",",
478
+ round(info$data$InducedVertBreak,1), ")")
479
  })
480
+
481
+ # metrics table
482
+ output$movement_stats <- renderDT({
483
  req(plot_data(), input$pitcher_select)
 
484
  data <- plot_data()
 
485
  movement_stats <- data %>%
486
  filter(Pitcher == input$pitcher_select) %>%
487
  filter(!is.na(HorzBreak), !is.na(InducedVertBreak), !is.na(TaggedPitchType)) %>%
488
  mutate(
489
  pitch_group = case_when(
490
+ TaggedPitchType %in% c("Fastball","FourSeamFastBall","FourSeamFastB","Four-Seam","4-Seam") ~ "Fastball",
491
+ TaggedPitchType %in% c("OneSeamFastBall","TwoSeamFastBall","Sinker","Two-Seam","One-Seam") ~ "Sinker",
492
+ TaggedPitchType %in% c("ChangeUp","Changeup") ~ "Changeup",
493
  TRUE ~ TaggedPitchType
494
  ),
495
+ in_zone = if ("StrikeZoneIndicator" %in% names(.)) StrikeZoneIndicator else
496
+ ifelse(!is.na(PlateLocSide) & !is.na(PlateLocHeight) &
 
497
  PlateLocSide >= -0.95 & PlateLocSide <= 0.95 &
498
  PlateLocHeight >= 1.6 & PlateLocHeight <= 3.5, 1, 0),
499
+ is_whiff = if ("WhiffIndicator" %in% names(.)) WhiffIndicator else
500
  ifelse(!is.na(PitchCall) & PitchCall == "StrikeSwinging", 1, 0),
501
  chase = if ("Chaseindicator" %in% names(.)) Chaseindicator else
502
  ifelse(!is.na(PitchCall) & !is.na(PlateLocSide) & !is.na(PlateLocHeight) &
503
+ PitchCall %in% c("StrikeSwinging","FoulBallNotFieldable","FoulBall","InPlay") &
504
  (PlateLocSide < -0.95 | PlateLocSide > 0.95 | PlateLocHeight < 1.6 | PlateLocHeight > 3.5), 1, 0)
505
  )
 
 
506
  total_pitches <- nrow(movement_stats)
 
507
  summary_stats <- movement_stats %>%
508
  group_by(`Pitch Type` = pitch_group) %>%
509
  summarise(
510
  Count = n(),
511
+ `Usage%` = sprintf("%.1f%%", (n()/total_pitches)*100),
512
+ `Ext.` = if ("Extension" %in% names(.)) sprintf("%.1f", mean(Extension, na.rm=TRUE)) else "—",
513
+ `Avg Velo` = sprintf("%.1f mph", mean(RelSpeed, na.rm=TRUE)),
514
+ `90th Velo` = sprintf("%.1f mph", quantile(RelSpeed, 0.9, na.rm=TRUE)),
515
+ `Max Velo` = sprintf("%.1f mph", max(RelSpeed, na.rm=TRUE)),
516
+ `Avg IVB` = sprintf("%.1f in", mean(InducedVertBreak, na.rm=TRUE)),
517
+ `Avg HB` = sprintf("%.1f in", mean(HorzBreak, na.rm=TRUE)),
518
+ `Avg Spin` = if ("SpinRate" %in% names(.)) sprintf("%.0f rpm", mean(SpinRate, na.rm=TRUE)) else "—",
519
+ `Rel Height` = if ("RelHeight" %in% names(.)) sprintf("%.1f", mean(RelHeight, na.rm=TRUE)) else "—",
520
+ `Zone%` = sprintf("%.1f%%", round(mean(in_zone, na.rm=TRUE)*100,1)),
521
+ `Whiff%` = sprintf("%.1f%%", round(mean(is_whiff, na.rm=TRUE)*100,1)),
522
+ `Chase%` = sprintf("%.1f%%", round(mean(chase, na.rm=TRUE)*100,1)),
523
  .groups = "drop"
524
+ ) %>% arrange(desc(Count))
525
+ datatable(summary_stats, options=list(pageLength=15, dom='t', scrollX=TRUE), rownames=FALSE) %>%
526
+ formatStyle(columns = names(summary_stats), fontSize='12px')
 
 
 
 
527
  })
528
+
529
+ # data summary + download
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
530
  output$data_summary <- renderText({
531
+ req(processed_data()); df <- processed_data()
532
+ paste(
 
 
533
  paste("Total rows:", nrow(df)),
534
  paste("Total columns:", ncol(df)),
535
+ paste("Date range:",
536
+ if ("Date" %in% names(df) && !all(is.na(df$Date))) {
537
+ paste(min(as.Date(df$Date), na.rm=TRUE), "to", max(as.Date(df$Date), na.rm=TRUE))
538
+ } else "Date column not available"),
539
+ paste("Unique pitchers:", if ("Pitcher" %in% names(df)) length(unique(df$Pitcher[!is.na(df$Pitcher)])) else "Pitcher column not available"),
540
+ paste("Pitch types:", if ("TaggedPitchType" %in% names(df)) paste(sort(unique(df$TaggedPitchType[!is.na(df$TaggedPitchType)])), collapse=", ") else "TaggedPitchType column not available"),
 
 
 
 
 
 
 
 
 
 
 
 
541
  sep = "\n"
542
  )
 
 
543
  })
 
 
544
  output$downloadData <- downloadHandler(
545
+ filename = function(){ paste0("app_ready_COA_", Sys.Date(), ".csv") },
546
+ content = function(file){ write.csv(processed_data(), file, row.names=FALSE) }
 
 
 
 
547
  )
548
  }
549
 
 
550
  shinyApp(ui = ui, server = server)