igroffman commited on
Commit
281dfdd
·
verified ·
1 Parent(s): 8104ef6

Update app.R

Browse files
Files changed (1) hide show
  1. app.R +231 -127
app.R CHANGED
@@ -1,4 +1,3 @@
1
- # app.R
2
  library(shiny)
3
  library(shinydashboard)
4
  library(DT)
@@ -82,11 +81,12 @@ ui <- fluidPage(
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
 
@@ -139,19 +139,24 @@ ui <- fluidPage(
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
  )
@@ -162,12 +167,14 @@ ui <- fluidPage(
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"),
@@ -196,8 +203,9 @@ server <- function(input, output, session){
196
  # reactives
197
  processed_data <- reactiveVal(NULL)
198
  plot_data <- reactiveVal(NULL)
199
- selected_pitch <- reactiveVal(NULL)
200
  selected_keys <- reactiveVal(integer(0))
 
201
 
202
  # quick-select buttons
203
  observeEvent(input$select_all_cols, { updateCheckboxGroupInput(session,"columns_to_remove", selected = columns_to_remove) })
@@ -233,18 +241,25 @@ server <- function(input, output, session){
233
 
234
  df <- df %>% distinct()
235
 
236
- # Create unique identifiers
237
- if (!".uid" %in% names(df)) df$.uid <- seq_len(nrow(df))
 
 
238
 
239
  processed_data(df)
240
  plot_data(df)
 
241
 
242
  if ("Pitcher" %in% names(df)) {
243
  choices <- sort(unique(df$Pitcher[!is.na(df$Pitcher)]))
244
- if (length(choices)) updateSelectInput(session,"pitcher_select", choices = choices, selected = choices[1])
 
 
245
  }
 
 
246
  }, error = function(e){
247
- showNotification(paste("Error processing file:", e$message), type="error")
248
  })
249
  })
250
 
@@ -275,19 +290,19 @@ server <- function(input, output, session){
275
  datatable(processed_data(), options=list(scrollX=TRUE, pageLength=10), filter="top")
276
  })
277
 
 
278
  pitcher_df <- reactive({
279
  req(plot_data(), input$pitcher_select)
 
 
280
  d <- plot_data() %>%
281
  filter(Pitcher == input$pitcher_select) %>%
282
  filter(!is.na(HorzBreak), !is.na(InducedVertBreak), !is.na(RelSpeed))
283
 
284
- # Add row_key for this pitcher's subset
285
- if (nrow(d) > 0) {
286
- d$row_key <- seq_len(nrow(d))
287
- }
288
  d
289
  })
290
 
 
291
  output$movement_plot <- renderPlotly({
292
  d <- pitcher_df()
293
  validate(need(nrow(d) > 0, "No data for selected pitcher"))
@@ -300,10 +315,11 @@ server <- function(input, output, session){
300
  "<br>Velo: ", round(RelSpeed,1), " mph",
301
  "<br>IVB: ", round(InducedVertBreak,1), " in",
302
  "<br>HB: ", round(HorzBreak,1), " in",
303
- if ("SpinRate" %in% names(d)) paste0("<br>Spin: ", round(SpinRate), " rpm") else ""
 
304
  ),
305
  hoverinfo = "text",
306
- key = ~.uid,
307
  color = ~factor(TaggedPitchType),
308
  colors = pitch_colors,
309
  marker = list(size = 10)
@@ -314,33 +330,35 @@ server <- function(input, output, session){
314
  yaxis = list(title="Induced Vertical Break (in)", range=c(-25,25), zeroline=TRUE),
315
  dragmode = if (input$selection_mode == "drag") "select" else "zoom"
316
  ) |>
317
- config(displaylogo = FALSE)
318
  })
319
 
 
320
  output$location_plot <- renderPlotly({
321
  d <- pitcher_df()
322
  validate(need(nrow(d) > 0, "No data for selected pitcher"))
323
 
324
- plot_ly(
325
- data = d, source = "loc",
326
- type = "scatter", mode = "markers",
327
- x = ~PlateLocSide, y = ~PlateLocHeight,
328
- text = ~paste0(
329
- "<b>", TaggedPitchType, "</b>",
330
- if ("PitchNo" %in% names(d)) paste0("<br>Pitch #: ", PitchNo) else "",
331
- "<br>Velo: ", round(RelSpeed, 1), " mph",
332
- "<br>X: ", round(PlateLocSide, 2),
333
- "<br>Z: ", round(PlateLocHeight, 2),
334
- "<br>IVB: ", round(InducedVertBreak, 1), " in",
335
- "<br>HB: ", round(HorzBreak, 1), " in",
336
- if ("SpinRate" %in% names(d)) paste0("<br>Spin: ", round(SpinRate), " rpm") else ""
337
- ),
338
- hoverinfo = "text",
339
- key = ~.uid, # or ~PitchUID / ~PitchNo if you don't have .uid
340
- color = ~factor(TaggedPitchType),
341
- colors = pitch_colors,
342
- marker = list(size = 9)
343
- ) |>
 
344
  layout(
345
  title = "Pitch Location (Editable)",
346
  xaxis = list(title="Plate X (ft)", range=c(-2,2), zeroline=TRUE),
@@ -354,123 +372,182 @@ plot_ly(
354
  line=list(color="black", width=0.8))
355
  )
356
  ) |>
357
- config(displaylogo = FALSE)
358
  })
359
 
360
- # ---- Click handlers (single mode) for both plots ----
361
- show_pitch_modal <- function(hit_row){
362
- selected_pitch(list(
363
- pitcher = input$pitcher_select,
364
- uid = hit_row$.uid[1],
365
- data = hit_row[1,],
366
- original_type = hit_row$TaggedPitchType[1]
367
- ))
368
- showModal(
369
- modalDialog(
370
- title = tags$h3("Selected Pitch Details:", style="color: darkcyan;"),
371
- verbatimTextOutput("selected_pitch_info"),
372
- tags$br(),
373
- tags$label("Change Pitch Type To:", style="color: peru; font-weight: 600;"),
374
- selectInput("modal_new_pitch_type", NULL,
375
- choices = c("Fastball","Sinker","Cutter","Slider","Curveball",
376
- "ChangeUp","Splitter","Knuckleball","Other"),
377
- selected = selected_pitch()[["data"]][["TaggedPitchType"]]),
378
- tags$br(),
379
- actionButton("update_pitch","Update Pitch Type", class="btn-primary btn-lg"),
380
- actionButton("cancel_edit","Cancel", class="btn-default"),
381
- easyClose = TRUE, footer = NULL, size = "m"
382
- )
383
- )
384
- }
385
-
386
  observeEvent(event_data("plotly_click", source="mv"), {
387
  req(input$selection_mode == "single")
388
  clk <- event_data("plotly_click", source="mv")
389
- req(nrow(as.data.frame(clk))>0)
390
- key <- clk$key[[1]]
 
 
 
 
391
  d <- pitcher_df()
392
- hit <- d[d$.uid == key, ]
393
- if (nrow(hit)==1) show_pitch_modal(hit)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
394
  })
395
 
396
  observeEvent(event_data("plotly_click", source="loc"), {
397
  req(input$selection_mode == "single")
398
  clk <- event_data("plotly_click", source="loc")
399
- req(nrow(as.data.frame(clk))>0)
400
- key <- clk$key[[1]]
 
 
 
401
  d <- pitcher_df()
402
- hit <- d[d$.uid == key, ]
403
- if (nrow(hit)==1) show_pitch_modal(hit)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
404
  })
405
 
406
- output$selected_pitch_info <- renderText({
407
- info <- selected_pitch()
408
- if (is.null(info)) return("No pitch selected")
409
- d <- info$data
410
-
411
- paste(
412
- paste("Pitcher:", info$pitcher),
413
- if ("PitchNo" %in% names(d) && !is.na(d$PitchNo)) paste("PitchNo:", d$PitchNo) else "",
414
- paste("Current Type:", d$TaggedPitchType),
415
- paste("Velocity:", round(d$RelSpeed, 1), "mph"),
416
- paste("Horizontal Break:", round(d$HorzBreak, 1), "inches"),
417
- paste("Induced Vertical Break:", round(d$InducedVertBreak, 1), "inches"),
418
- if ("SpinRate" %in% names(d) && !is.na(d$SpinRate)) paste("Spin Rate:", round(d$SpinRate, 0), "rpm") else "",
419
- if ("Date" %in% names(d) && !is.na(d$Date)) paste("Date:", format(as.Date(d$Date))) else "",
420
- sep = "\n"
421
- )
422
- })
423
-
424
  observeEvent(input$update_pitch, {
425
- info <- selected_pitch()
426
- req(info)
427
  new_type <- input$modal_new_pitch_type
428
  req(new_type)
429
 
430
  cur <- plot_data()
431
- req("Pitcher" %in% names(cur))
432
  cur$TaggedPitchType <- as.character(cur$TaggedPitchType)
433
 
434
- uid <- info$uid
435
  hit_idx <- which(cur$.uid == uid)
436
 
437
  if (length(hit_idx) == 1) {
 
438
  cur$TaggedPitchType[hit_idx] <- new_type
 
439
  plot_data(cur)
440
  processed_data(cur)
 
 
441
  removeModal()
442
- showNotification("Updated pitch tag.", type="message", duration=3)
443
- selected_pitch(NULL)
 
 
 
 
444
  } else {
445
- showNotification("Could not map selection back to dataset.", type="error")
446
  }
447
- })
448
 
449
- observeEvent(input$cancel_edit, { removeModal(); selected_pitch(NULL) })
 
 
 
450
 
451
- # ---- Drag select (either chart) ----
452
  observeEvent(event_data("plotly_selected", source="mv"), {
453
  req(input$selection_mode == "drag")
454
  ev <- event_data("plotly_selected", source="mv")
455
- if (!is.null(ev) && length(ev$key)) selected_keys(unique(as.integer(ev$key)))
 
 
 
 
 
 
456
  })
457
 
458
  observeEvent(event_data("plotly_selected", source="loc"), {
459
  req(input$selection_mode == "drag")
460
  ev <- event_data("plotly_selected", source="loc")
461
- if (!is.null(ev) && length(ev$key)) selected_keys(unique(as.integer(ev$key)))
 
 
 
 
 
 
 
 
 
 
 
 
462
  })
463
 
464
- # selection info text
465
  output$selection_info <- renderText({
466
  keys <- selected_keys()
467
- if (!length(keys)) return("No points selected.")
 
468
  d <- pitcher_df()
469
  sel <- d[d$.uid %in% keys, ]
 
 
 
470
  cnt <- sort(table(sel$TaggedPitchType), decreasing = TRUE)
471
- paste(nrow(sel), "points selected:", paste(names(cnt), "(", cnt, ")", collapse=", "))
 
 
472
  })
473
 
 
474
  observeEvent(input$apply_bulk_change, {
475
  req(input$selection_mode == "drag")
476
  keys <- selected_keys()
@@ -479,37 +556,54 @@ output$selected_pitch_info <- renderText({
479
  req(new_type)
480
 
481
  cur <- plot_data()
482
- req("Pitcher" %in% names(cur))
483
  cur$TaggedPitchType <- as.character(cur$TaggedPitchType)
484
 
485
- uids <- as.integer(keys)
486
- hit_idx <- which(cur$.uid %in% uids & cur$Pitcher == input$pitcher_select)
487
 
488
  if (length(hit_idx) == 0) {
489
  showNotification("No matching rows found for bulk edit.", type="warning")
490
  return()
491
  }
492
 
 
 
 
 
493
  cur$TaggedPitchType[hit_idx] <- new_type
494
  plot_data(cur)
495
  processed_data(cur)
 
 
496
  selected_keys(integer(0))
497
- showNotification(paste("Updated", length(hit_idx), "pitches to", new_type),
498
- type="message", duration=3)
 
 
 
 
499
  })
500
 
501
- # click info small text
502
- output$click_info <- renderText({
503
- info <- selected_pitch()
504
- if (is.null(info)) "No point selected yet. Click a point to edit its pitch type."
505
- else paste("Last selected pitch:", info$original_type,
506
- "| Position (HB, IVB): (", round(info$data$HorzBreak,1), ",",
507
- round(info$data$InducedVertBreak,1), ")")
 
 
 
 
 
508
  })
509
 
510
- # metrics table
511
  output$movement_stats <- renderDT({
512
  req(plot_data(), input$pitcher_select)
 
 
513
  data <- plot_data()
514
  movement_stats <- data %>%
515
  filter(Pitcher == input$pitcher_select) %>%
@@ -532,7 +626,9 @@ output$selected_pitch_info <- renderText({
532
  PitchCall %in% c("StrikeSwinging","FoulBallNotFieldable","FoulBall","InPlay") &
533
  (PlateLocSide < -0.95 | PlateLocSide > 0.95 | PlateLocHeight < 1.6 | PlateLocHeight > 3.5), 1, 0)
534
  )
 
535
  total_pitches <- nrow(movement_stats)
 
536
  summary_stats <- movement_stats %>%
537
  group_by(`Pitch Type` = pitch_group) %>%
538
  summarise(
@@ -551,6 +647,7 @@ output$selected_pitch_info <- renderText({
551
  `Chase%` = sprintf("%.1f%%", round(mean(chase, na.rm=TRUE)*100,1)),
552
  .groups = "drop"
553
  ) %>% arrange(desc(Count))
 
554
  datatable(summary_stats, options=list(pageLength=15, dom='t', scrollX=TRUE), rownames=FALSE) %>%
555
  formatStyle(columns = names(summary_stats), fontSize='12px')
556
  })
@@ -578,7 +675,14 @@ output$selected_pitch_info <- renderText({
578
 
579
  output$downloadData <- downloadHandler(
580
  filename = function(){ paste0("app_ready_COA_", Sys.Date(), ".csv") },
581
- content = function(file){ write.csv(processed_data(), file, row.names=FALSE) }
 
 
 
 
 
 
 
582
  )
583
  }
584
 
 
 
1
  library(shiny)
2
  library(shinydashboard)
3
  library(DT)
 
81
  animation:shimmer 3s linear infinite;}
82
  @keyframes shimmer{0%{background-position:-200% 0;}100%{background-position:200% 0;}}
83
  h3{ color:black; font-weight:600; margin-top:25px; margin-bottom:15px; padding-bottom:8px; border-bottom:2px solid #007BA7; }
84
+ h4{ color:black; font-weight:500; margin-top:20px; margin-bottom:12px; }
85
  h1{ color:#007BA7; font-weight:700; margin-bottom:20px; text-shadow:1px 1px 2px rgba(0,0,0,0.1); }
86
  label{ font-weight:500; color:peru; margin-bottom:5px; }
87
  thead th{ background:#F8F9FA; color:#2C3E50; font-weight:600; text-align:center!important; padding:10px!important; }
88
  .brand-teal{ color:darkcyan; } .brand-bronze{ color:peru; }
89
+ .info-box { background-color:#f0f8ff; padding:10px; border-radius:5px; margin:10px 0; border-left:4px solid darkcyan; }
90
  "))
91
  ),
92
 
 
139
  tabPanel("Pitch Movement Chart",
140
  fluidRow(
141
  column(3, selectInput("pitcher_select","Select Pitcher:", choices=NULL, selected=NULL)),
142
+ column(3,
143
+ h4("Selection Mode:"),
144
  radioButtons("selection_mode","",
145
+ choices = list("Single Click (Edit One)"="single","Drag Select (Edit Multiple)"="drag"),
146
+ selected="single", inline=FALSE)
147
+ ),
148
  column(6,
149
  conditionalPanel(
150
  condition = "input.selection_mode == 'drag'",
151
+ div(class="info-box",
152
+ h4("Bulk Edit Mode:", style="margin-top:0; color:darkcyan;"),
153
+ fluidRow(
154
+ column(7, selectInput("bulk_pitch_type","Change selected pitches to:",
155
+ choices=c("Fastball","Sinker","Cutter","Slider","Curveball","ChangeUp","Splitter","Knuckleball","Other"),
156
+ selected="Fastball")),
157
+ column(5, br(), actionButton("apply_bulk_change","Apply Changes", class="btn-success btn-block"))
158
+ ),
159
+ actionButton("clear_selection","Clear Selection", class="btn-warning btn-sm", style="margin-top:10px;")
160
  )
161
  )
162
  )
 
167
  plotlyOutput("movement_plot", height="600px"),
168
  conditionalPanel(
169
  condition = "input.selection_mode == 'drag'",
170
+ div(class="info-box",
171
  h4("Selected Points:", style="margin-top:0; color:darkcyan;"),
172
  textOutput("selection_info")
173
  )
174
  ),
175
+ div(style="padding:10px; background-color:#fff3cd; border-radius:5px; margin-top:10px;",
176
+ textOutput("status_info")
177
+ )
178
  ),
179
  column(4,
180
  h3("Pitch Metrics Summary"),
 
203
  # reactives
204
  processed_data <- reactiveVal(NULL)
205
  plot_data <- reactiveVal(NULL)
206
+ selected_pitch_uid <- reactiveVal(NULL)
207
  selected_keys <- reactiveVal(integer(0))
208
+ plot_version <- reactiveVal(0) # Force plot refresh
209
 
210
  # quick-select buttons
211
  observeEvent(input$select_all_cols, { updateCheckboxGroupInput(session,"columns_to_remove", selected = columns_to_remove) })
 
241
 
242
  df <- df %>% distinct()
243
 
244
+ # Create unique identifiers - CRITICAL for tracking
245
+ if (!".uid" %in% names(df)) {
246
+ df$.uid <- seq_len(nrow(df))
247
+ }
248
 
249
  processed_data(df)
250
  plot_data(df)
251
+ plot_version(plot_version() + 1)
252
 
253
  if ("Pitcher" %in% names(df)) {
254
  choices <- sort(unique(df$Pitcher[!is.na(df$Pitcher)]))
255
+ if (length(choices)) {
256
+ updateSelectInput(session,"pitcher_select", choices = choices, selected = choices[1])
257
+ }
258
  }
259
+
260
+ showNotification("File processed successfully!", type="message", duration=3)
261
  }, error = function(e){
262
+ showNotification(paste("Error processing file:", e$message), type="error", duration=5)
263
  })
264
  })
265
 
 
290
  datatable(processed_data(), options=list(scrollX=TRUE, pageLength=10), filter="top")
291
  })
292
 
293
+ # Get current pitcher's data
294
  pitcher_df <- reactive({
295
  req(plot_data(), input$pitcher_select)
296
+ plot_version() # Depend on version to trigger updates
297
+
298
  d <- plot_data() %>%
299
  filter(Pitcher == input$pitcher_select) %>%
300
  filter(!is.na(HorzBreak), !is.na(InducedVertBreak), !is.na(RelSpeed))
301
 
 
 
 
 
302
  d
303
  })
304
 
305
+ # Movement plot
306
  output$movement_plot <- renderPlotly({
307
  d <- pitcher_df()
308
  validate(need(nrow(d) > 0, "No data for selected pitcher"))
 
315
  "<br>Velo: ", round(RelSpeed,1), " mph",
316
  "<br>IVB: ", round(InducedVertBreak,1), " in",
317
  "<br>HB: ", round(HorzBreak,1), " in",
318
+ if ("SpinRate" %in% names(d)) paste0("<br>Spin: ", round(SpinRate), " rpm") else "",
319
+ "<br>UID: ", .uid
320
  ),
321
  hoverinfo = "text",
322
+ customdata = ~.uid, # Use customdata for reliable key tracking
323
  color = ~factor(TaggedPitchType),
324
  colors = pitch_colors,
325
  marker = list(size = 10)
 
330
  yaxis = list(title="Induced Vertical Break (in)", range=c(-25,25), zeroline=TRUE),
331
  dragmode = if (input$selection_mode == "drag") "select" else "zoom"
332
  ) |>
333
+ config(displaylogo = FALSE, modeBarButtonsToRemove = c("lasso2d"))
334
  })
335
 
336
+ # Location plot
337
  output$location_plot <- renderPlotly({
338
  d <- pitcher_df()
339
  validate(need(nrow(d) > 0, "No data for selected pitcher"))
340
 
341
+ plot_ly(
342
+ data = d, source = "loc",
343
+ type = "scatter", mode = "markers",
344
+ x = ~PlateLocSide, y = ~PlateLocHeight,
345
+ text = ~paste0(
346
+ "<b>", TaggedPitchType, "</b>",
347
+ if ("PitchNo" %in% names(d)) paste0("<br>Pitch #: ", PitchNo) else "",
348
+ "<br>Velo: ", round(RelSpeed, 1), " mph",
349
+ "<br>X: ", round(PlateLocSide, 2),
350
+ "<br>Z: ", round(PlateLocHeight, 2),
351
+ "<br>IVB: ", round(InducedVertBreak, 1), " in",
352
+ "<br>HB: ", round(HorzBreak, 1), " in",
353
+ if ("SpinRate" %in% names(d)) paste0("<br>Spin: ", round(SpinRate), " rpm") else "",
354
+ "<br>UID: ", .uid
355
+ ),
356
+ hoverinfo = "text",
357
+ customdata = ~.uid,
358
+ color = ~factor(TaggedPitchType),
359
+ colors = pitch_colors,
360
+ marker = list(size = 9)
361
+ ) |>
362
  layout(
363
  title = "Pitch Location (Editable)",
364
  xaxis = list(title="Plate X (ft)", range=c(-2,2), zeroline=TRUE),
 
372
  line=list(color="black", width=0.8))
373
  )
374
  ) |>
375
+ config(displaylogo = FALSE, modeBarButtonsToRemove = c("lasso2d"))
376
  })
377
 
378
+ # ---- SINGLE CLICK MODE ----
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
379
  observeEvent(event_data("plotly_click", source="mv"), {
380
  req(input$selection_mode == "single")
381
  clk <- event_data("plotly_click", source="mv")
382
+ req(!is.null(clk), nrow(as.data.frame(clk)) > 0)
383
+
384
+ # Get UID from customdata
385
+ uid <- clk$customdata[[1]]
386
+ req(!is.null(uid))
387
+
388
  d <- pitcher_df()
389
+ hit <- d[d$.uid == uid, ]
390
+
391
+ if (nrow(hit) == 1) {
392
+ selected_pitch_uid(uid)
393
+ showModal(
394
+ modalDialog(
395
+ title = tags$h3("Edit Pitch Type", style="color: darkcyan;"),
396
+ tags$div(
397
+ tags$strong("Pitcher: "), input$pitcher_select, tags$br(),
398
+ if ("PitchNo" %in% names(hit) && !is.na(hit$PitchNo))
399
+ tagList(tags$strong("Pitch No: "), hit$PitchNo, tags$br()),
400
+ tags$strong("Current Type: "), tags$span(hit$TaggedPitchType, style="color: peru; font-size: 16px;"), tags$br(),
401
+ tags$strong("Velocity: "), round(hit$RelSpeed, 1), " mph", tags$br(),
402
+ tags$strong("Horizontal Break: "), round(hit$HorzBreak, 1), " in", tags$br(),
403
+ tags$strong("Induced Vertical Break: "), round(hit$InducedVertBreak, 1), " in", tags$br(),
404
+ if ("SpinRate" %in% names(hit) && !is.na(hit$SpinRate))
405
+ tagList(tags$strong("Spin Rate: "), round(hit$SpinRate, 0), " rpm", tags$br()),
406
+ tags$br()
407
+ ),
408
+ tags$label("Change Pitch Type To:", style="color: peru; font-weight: 600;"),
409
+ selectInput("modal_new_pitch_type", NULL,
410
+ choices = c("Fastball","Sinker","Cutter","Slider","Curveball",
411
+ "ChangeUp","Splitter","Knuckleball","Other"),
412
+ selected = hit$TaggedPitchType),
413
+ tags$br(),
414
+ footer = tagList(
415
+ actionButton("update_pitch","Update Pitch Type", class="btn-primary"),
416
+ actionButton("cancel_edit","Cancel", class="btn-default")
417
+ ),
418
+ easyClose = TRUE, size = "m"
419
+ )
420
+ )
421
+ }
422
  })
423
 
424
  observeEvent(event_data("plotly_click", source="loc"), {
425
  req(input$selection_mode == "single")
426
  clk <- event_data("plotly_click", source="loc")
427
+ req(!is.null(clk), nrow(as.data.frame(clk)) > 0)
428
+
429
+ uid <- clk$customdata[[1]]
430
+ req(!is.null(uid))
431
+
432
  d <- pitcher_df()
433
+ hit <- d[d$.uid == uid, ]
434
+
435
+ if (nrow(hit) == 1) {
436
+ selected_pitch_uid(uid)
437
+ showModal(
438
+ modalDialog(
439
+ title = tags$h3("Edit Pitch Type", style="color: darkcyan;"),
440
+ tags$div(
441
+ tags$strong("Pitcher: "), input$pitcher_select, tags$br(),
442
+ if ("PitchNo" %in% names(hit) && !is.na(hit$PitchNo))
443
+ tagList(tags$strong("Pitch No: "), hit$PitchNo, tags$br()),
444
+ tags$strong("Current Type: "), tags$span(hit$TaggedPitchType, style="color: peru; font-size: 16px;"), tags$br(),
445
+ tags$strong("Velocity: "), round(hit$RelSpeed, 1), " mph", tags$br(),
446
+ tags$strong("Location: "), "(", round(hit$PlateLocSide, 2), ", ", round(hit$PlateLocHeight, 2), ")", tags$br(),
447
+ tags$br()
448
+ ),
449
+ tags$label("Change Pitch Type To:", style="color: peru; font-weight: 600;"),
450
+ selectInput("modal_new_pitch_type", NULL,
451
+ choices = c("Fastball","Sinker","Cutter","Slider","Curveball",
452
+ "ChangeUp","Splitter","Knuckleball","Other"),
453
+ selected = hit$TaggedPitchType),
454
+ tags$br(),
455
+ footer = tagList(
456
+ actionButton("update_pitch","Update Pitch Type", class="btn-primary"),
457
+ actionButton("cancel_edit","Cancel", class="btn-default")
458
+ ),
459
+ easyClose = TRUE, size = "m"
460
+ )
461
+ )
462
+ }
463
  })
464
 
465
+ # Update single pitch
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
466
  observeEvent(input$update_pitch, {
467
+ uid <- selected_pitch_uid()
468
+ req(uid)
469
  new_type <- input$modal_new_pitch_type
470
  req(new_type)
471
 
472
  cur <- plot_data()
473
+ req(nrow(cur) > 0)
474
  cur$TaggedPitchType <- as.character(cur$TaggedPitchType)
475
 
 
476
  hit_idx <- which(cur$.uid == uid)
477
 
478
  if (length(hit_idx) == 1) {
479
+ old_type <- cur$TaggedPitchType[hit_idx]
480
  cur$TaggedPitchType[hit_idx] <- new_type
481
+
482
  plot_data(cur)
483
  processed_data(cur)
484
+ plot_version(plot_version() + 1) # Force refresh
485
+
486
  removeModal()
487
+ showNotification(
488
+ paste("Updated pitch from", old_type, "to", new_type),
489
+ type="message",
490
+ duration=2
491
+ )
492
+ selected_pitch_uid(NULL)
493
  } else {
494
+ showNotification("Error: Could not find pitch to update.", type="error")
495
  }
496
+ })
497
 
498
+ observeEvent(input$cancel_edit, {
499
+ removeModal()
500
+ selected_pitch_uid(NULL)
501
+ })
502
 
503
+ # ---- DRAG SELECT MODE ----
504
  observeEvent(event_data("plotly_selected", source="mv"), {
505
  req(input$selection_mode == "drag")
506
  ev <- event_data("plotly_selected", source="mv")
507
+
508
+ if (!is.null(ev) && "customdata" %in% names(ev)) {
509
+ keys <- unique(as.integer(ev$customdata))
510
+ selected_keys(keys)
511
+ } else {
512
+ selected_keys(integer(0))
513
+ }
514
  })
515
 
516
  observeEvent(event_data("plotly_selected", source="loc"), {
517
  req(input$selection_mode == "drag")
518
  ev <- event_data("plotly_selected", source="loc")
519
+
520
+ if (!is.null(ev) && "customdata" %in% names(ev)) {
521
+ keys <- unique(as.integer(ev$customdata))
522
+ selected_keys(keys)
523
+ } else {
524
+ selected_keys(integer(0))
525
+ }
526
+ })
527
+
528
+ # Clear selection button
529
+ observeEvent(input$clear_selection, {
530
+ selected_keys(integer(0))
531
+ showNotification("Selection cleared", type="message", duration=2)
532
  })
533
 
534
+ # Selection info
535
  output$selection_info <- renderText({
536
  keys <- selected_keys()
537
+ if (!length(keys)) return("No points selected. Use drag/lasso to select multiple pitches.")
538
+
539
  d <- pitcher_df()
540
  sel <- d[d$.uid %in% keys, ]
541
+
542
+ if (nrow(sel) == 0) return("Selected points not found in current pitcher's data.")
543
+
544
  cnt <- sort(table(sel$TaggedPitchType), decreasing = TRUE)
545
+ breakdown <- paste(names(cnt), "(", cnt, ")", collapse=", ")
546
+
547
+ paste(nrow(sel), "pitches selected:", breakdown)
548
  })
549
 
550
+ # Apply bulk changes
551
  observeEvent(input$apply_bulk_change, {
552
  req(input$selection_mode == "drag")
553
  keys <- selected_keys()
 
556
  req(new_type)
557
 
558
  cur <- plot_data()
559
+ req(nrow(cur) > 0)
560
  cur$TaggedPitchType <- as.character(cur$TaggedPitchType)
561
 
562
+ # Only update pitches from current pitcher
563
+ hit_idx <- which(cur$.uid %in% keys & cur$Pitcher == input$pitcher_select)
564
 
565
  if (length(hit_idx) == 0) {
566
  showNotification("No matching rows found for bulk edit.", type="warning")
567
  return()
568
  }
569
 
570
+ # Store old types for notification
571
+ old_types <- unique(cur$TaggedPitchType[hit_idx])
572
+
573
+ # Update
574
  cur$TaggedPitchType[hit_idx] <- new_type
575
  plot_data(cur)
576
  processed_data(cur)
577
+ plot_version(plot_version() + 1) # Force refresh
578
+
579
  selected_keys(integer(0))
580
+
581
+ showNotification(
582
+ paste("✓ Updated", length(hit_idx), "pitches to", new_type),
583
+ type="message",
584
+ duration=3
585
+ )
586
  })
587
 
588
+ # Status info
589
+ output$status_info <- renderText({
590
+ if (input$selection_mode == "single") {
591
+ "Mode: Single Click - Click any point to edit its pitch type"
592
+ } else {
593
+ keys <- selected_keys()
594
+ if (length(keys) > 0) {
595
+ paste("Mode: Drag Select -", length(keys), "pitches selected. Choose type and click 'Apply Changes'")
596
+ } else {
597
+ "Mode: Drag Select - Use drag/lasso tool to select multiple pitches, then apply bulk changes"
598
+ }
599
+ }
600
  })
601
 
602
+ # Metrics table
603
  output$movement_stats <- renderDT({
604
  req(plot_data(), input$pitcher_select)
605
+ plot_version() # Depend on version
606
+
607
  data <- plot_data()
608
  movement_stats <- data %>%
609
  filter(Pitcher == input$pitcher_select) %>%
 
626
  PitchCall %in% c("StrikeSwinging","FoulBallNotFieldable","FoulBall","InPlay") &
627
  (PlateLocSide < -0.95 | PlateLocSide > 0.95 | PlateLocHeight < 1.6 | PlateLocHeight > 3.5), 1, 0)
628
  )
629
+
630
  total_pitches <- nrow(movement_stats)
631
+
632
  summary_stats <- movement_stats %>%
633
  group_by(`Pitch Type` = pitch_group) %>%
634
  summarise(
 
647
  `Chase%` = sprintf("%.1f%%", round(mean(chase, na.rm=TRUE)*100,1)),
648
  .groups = "drop"
649
  ) %>% arrange(desc(Count))
650
+
651
  datatable(summary_stats, options=list(pageLength=15, dom='t', scrollX=TRUE), rownames=FALSE) %>%
652
  formatStyle(columns = names(summary_stats), fontSize='12px')
653
  })
 
675
 
676
  output$downloadData <- downloadHandler(
677
  filename = function(){ paste0("app_ready_COA_", Sys.Date(), ".csv") },
678
+ content = function(file){
679
+ df_out <- processed_data()
680
+ # Remove internal UID column before export
681
+ if (".uid" %in% names(df_out)) {
682
+ df_out <- df_out %>% select(-.uid)
683
+ }
684
+ write.csv(df_out, file, row.names=FALSE)
685
+ }
686
  )
687
  }
688