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

Update app.R

Browse files
Files changed (1) hide show
  1. app.R +943 -44
app.R CHANGED
@@ -1,58 +1,957 @@
1
  library(shiny)
2
- library(bslib)
 
 
3
  library(dplyr)
4
- library(ggplot2)
 
5
 
6
- df <- readr::read_csv("penguins.csv")
7
- # Find subset of columns that are suitable for scatter plot
8
- df_num <- df |> select(where(is.numeric), -Year)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9
 
10
- ui <- page_sidebar(
11
- theme = bs_theme(bootswatch = "minty"),
12
- title = "Penguins explorer",
13
- sidebar = sidebar(
14
- varSelectInput("xvar", "X variable", df_num, selected = "Bill Length (mm)"),
15
- varSelectInput("yvar", "Y variable", df_num, selected = "Bill Depth (mm)"),
16
- checkboxGroupInput("species", "Filter by species",
17
- choices = unique(df$Species), selected = unique(df$Species)
18
- ),
19
- hr(), # Add a horizontal rule
20
- checkboxInput("by_species", "Show species", TRUE),
21
- checkboxInput("show_margins", "Show marginal plots", TRUE),
22
- checkboxInput("smooth", "Add smoother"),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
23
  ),
24
- plotOutput("scatter")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
25
  )
26
 
 
27
  server <- function(input, output, session) {
28
- subsetted <- reactive({
29
- req(input$species)
30
- df |> filter(Species %in% input$species)
 
 
 
 
 
 
 
 
31
  })
32
-
33
- output$scatter <- renderPlot(
34
- {
35
- p <- ggplot(subsetted(), aes(!!input$xvar, !!input$yvar)) +
36
- theme_light() +
37
- list(
38
- theme(legend.position = "bottom"),
39
- if (input$by_species) aes(color = Species),
40
- geom_point(),
41
- if (input$smooth) geom_smooth()
42
- )
43
-
44
- if (input$show_margins) {
45
- margin_type <- if (input$by_species) "density" else "histogram"
46
- p <- p |> ggExtra::ggMarginal(
47
- type = margin_type, margins = "both",
48
- size = 8, groupColour = input$by_species, groupFill = input$by_species
49
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
50
  }
51
-
52
- p
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
53
  },
54
- res = 100
 
 
55
  )
56
  }
57
 
58
- shinyApp(ui, 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)