igroffman commited on
Commit
2f79c5d
·
verified ·
1 Parent(s): 7eba80e

Update app.R

Browse files
Files changed (1) hide show
  1. app.R +455 -84
app.R CHANGED
@@ -5,6 +5,7 @@ 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(
@@ -21,8 +22,7 @@ columns_to_remove <- c(
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)
@@ -40,18 +40,109 @@ pitch_colors <- c(
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
@@ -70,7 +161,7 @@ ui <- fluidPage(
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
  }
@@ -214,7 +305,7 @@ ui <- fluidPage(
214
  }
215
 
216
  h4 {
217
- color: white;
218
  font-weight: 500;
219
  margin-top: 20px;
220
  margin-bottom: 12px;
@@ -257,6 +348,38 @@ ui <- fluidPage(
257
 
258
  .brand-teal { color: darkcyan; }
259
  .brand-bronze { color: peru; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
260
  "))
261
  ),
262
 
@@ -273,8 +396,8 @@ ui <- fluidPage(
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,
@@ -290,13 +413,26 @@ ui <- fluidPage(
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,
@@ -311,14 +447,26 @@ ui <- fluidPage(
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",
@@ -435,6 +583,9 @@ server <- function(input, output, session) {
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, {
@@ -451,28 +602,30 @@ server <- function(input, output, session) {
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) {
@@ -480,7 +633,6 @@ server <- function(input, output, session) {
480
  }
481
  }
482
 
483
- # Remove duplicates only
484
  processed_df <- processed_df %>% distinct()
485
 
486
  processed_data(processed_df)
@@ -493,10 +645,236 @@ server <- function(input, output, session) {
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)) {
@@ -508,31 +886,38 @@ server <- function(input, output, session) {
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
- # Build the removed columns text
516
  removed_cols_text <- if (length(removed_cols) > 0) {
517
  cols_display <- if (length(removed_cols) > 5) {
518
  paste(paste(head(removed_cols, 5), collapse = ", "), "...")
519
  } else {
520
  paste(removed_cols, collapse = ", ")
521
  }
522
- paste("✓ Removed columns:", length(removed_cols), "\n -", cols_display)
523
  } else {
524
  "✓ Removed columns: 0"
525
  }
526
 
 
 
 
 
 
 
 
 
527
  summary_text <- paste(
528
  "✓ File processed successfully!",
529
  paste("✓ Original columns:", ncol(original_df)),
530
  paste("✓ Final columns:", ncol(df)),
531
- paste("✓ Target columns: 167"),
532
  paste("✓ Rows processed:", nrow(df)),
533
  removed_cols_text,
 
534
  "✓ Duplicates removed",
535
- paste("✓ Ready for further processing"),
536
  sep = "\n"
537
  )
538
 
@@ -564,11 +949,9 @@ server <- function(input, output, session) {
564
  return()
565
  }
566
 
567
- # Get colors for each pitch type
568
  pitcher_data$color <- pitch_colors[pitcher_data$TaggedPitchType]
569
  pitcher_data$color[is.na(pitcher_data$color)] <- "#D3D3D3"
570
 
571
- # Create the plot without grid parameter
572
  par(mar = c(5, 5, 4, 8), xpd = TRUE)
573
  plot(pitcher_data$HorzBreak, pitcher_data$InducedVertBreak,
574
  col = pitcher_data$color,
@@ -578,26 +961,22 @@ server <- function(input, output, session) {
578
  ylab = "Induced Vertical Break (inches)",
579
  main = paste("Pitch Movement Chart -", input$pitcher_select))
580
 
581
- # Add grid lines manually
582
  grid(nx = NULL, ny = NULL, col = "lightgray", lty = 1, lwd = 0.5)
583
  abline(h = 0, col = "gray", lty = 2, lwd = 1)
584
  abline(v = 0, col = "gray", lty = 2, lwd = 1)
585
 
586
- # Add concentric circles
587
  for (r in c(6, 12, 18, 24)) {
588
  circle_x <- r * cos(seq(0, 2*pi, length.out = 100))
589
  circle_y <- r * sin(seq(0, 2*pi, length.out = 100))
590
  lines(circle_x, circle_y, col = "lightgray", lty = 3)
591
  }
592
 
593
- # Add points to show selection
594
  if (input$selection_mode == "drag" && !is.null(selected_points())) {
595
  sel_points <- selected_points()
596
  points(sel_points$HorzBreak, sel_points$InducedVertBreak,
597
  pch = 21, cex = 2, col = "red", lwd = 3)
598
  }
599
 
600
- # Create legend
601
  unique_pitches <- unique(pitcher_data$TaggedPitchType)
602
  unique_colors <- pitch_colors[unique_pitches]
603
  legend("topright", inset = c(-0.15, 0),
@@ -612,7 +991,6 @@ server <- function(input, output, session) {
612
  observeEvent(input$plot_click, {
613
  req(plot_data(), input$pitcher_select, input$plot_click)
614
 
615
- # Only handle clicks in single mode
616
  if (input$selection_mode != "single") return()
617
 
618
  pitcher_data <- plot_data() %>%
@@ -623,7 +1001,6 @@ server <- function(input, output, session) {
623
 
624
  if (nrow(pitcher_data) == 0) return()
625
 
626
- # Find closest point to click
627
  click_x <- input$plot_click$x
628
  click_y <- input$plot_click$y
629
 
@@ -632,15 +1009,9 @@ server <- function(input, output, session) {
632
 
633
  closest_idx <- which.min(distances)
634
 
635
- # Only proceed if click is reasonably close (within 2 inches)
636
  if (min(distances) <= 2) {
637
  clicked_pitch <- pitcher_data[closest_idx, ]
638
 
639
- # DEBUG: Print to console
640
- print("Pitch selected!")
641
- print(paste("Type:", clicked_pitch$TaggedPitchType))
642
-
643
- # Store the original row index in the full dataset
644
  full_data <- plot_data() %>% filter(Pitcher == input$pitcher_select)
645
  original_row <- which(full_data$HorzBreak == clicked_pitch$HorzBreak &
646
  full_data$InducedVertBreak == clicked_pitch$InducedVertBreak &
@@ -653,11 +1024,9 @@ server <- function(input, output, session) {
653
  original_type = clicked_pitch$TaggedPitchType
654
  ))
655
 
656
- # Update modal dropdown to current pitch type
657
  updateSelectInput(session, "modal_new_pitch_type",
658
  selected = clicked_pitch$TaggedPitchType)
659
 
660
- # Show modal
661
  toggleModal(session, "pitchEditModal", toggle = "open")
662
  }
663
  })
@@ -666,7 +1035,6 @@ server <- function(input, output, session) {
666
  observeEvent(input$plot_brush, {
667
  req(plot_data(), input$pitcher_select, input$plot_brush)
668
 
669
- # Only handle brush in drag mode
670
  if (input$selection_mode != "drag") return()
671
 
672
  pitcher_data <- plot_data() %>%
@@ -676,7 +1044,6 @@ server <- function(input, output, session) {
676
 
677
  if (nrow(pitcher_data) == 0) return()
678
 
679
- # Find points within brush area
680
  brush <- input$plot_brush
681
  brushed_points <- pitcher_data %>%
682
  filter(
@@ -702,10 +1069,8 @@ server <- function(input, output, session) {
702
  return()
703
  }
704
 
705
- # Get current data
706
  current_data <- plot_data()
707
 
708
- # Update all selected points
709
  for (i in 1:nrow(sel_points)) {
710
  point <- sel_points[i, ]
711
  current_data <- current_data %>%
@@ -719,14 +1084,10 @@ server <- function(input, output, session) {
719
  ))
720
  }
721
 
722
- # Update reactive values
723
  plot_data(current_data)
724
  processed_data(current_data)
725
-
726
- # Clear selection
727
  selected_points(NULL)
728
 
729
- # Show success message
730
  showNotification(
731
  paste("Updated", nrow(sel_points), "pitches to", input$bulk_pitch_type),
732
  type = "message", duration = 3
@@ -766,11 +1127,18 @@ server <- function(input, output, session) {
766
  closest_idx <- which.min(distances)
767
  hover_pitch <- pitcher_data[closest_idx, ]
768
 
 
 
 
 
 
 
769
  paste("Hovering over:",
770
  paste("Type:", hover_pitch$TaggedPitchType),
771
  paste("Velocity:", round(hover_pitch$RelSpeed, 1), "mph"),
772
  paste("HB:", round(hover_pitch$HorzBreak, 1), "in"),
773
  paste("IVB:", round(hover_pitch$InducedVertBreak, 1), "in"),
 
774
  sep = " | ")
775
  } else {
776
  ""
@@ -793,7 +1161,6 @@ server <- function(input, output, session) {
793
  TaggedPitchType %in% c("ChangeUp", "Changeup") ~ "Changeup",
794
  TRUE ~ TaggedPitchType
795
  ),
796
- # Create necessary indicator variables if they don't exist
797
  in_zone = ifelse("StrikeZoneIndicator" %in% names(.), StrikeZoneIndicator,
798
  ifelse(!is.na(PlateLocSide) & !is.na(PlateLocHeight) &
799
  PlateLocSide >= -0.95 & PlateLocSide <= 0.95 &
@@ -806,31 +1173,29 @@ server <- function(input, output, session) {
806
  (PlateLocSide < -0.95 | PlateLocSide > 0.95 | PlateLocHeight < 1.6 | PlateLocHeight > 3.5), 1, 0))
807
  )
808
 
809
- # Calculate total pitches for usage percentage
810
  total_pitches <- nrow(movement_stats)
811
 
 
 
 
812
  summary_stats <- movement_stats %>%
813
  group_by(`Pitch Type` = pitch_group) %>%
814
  summarise(
815
  Count = n(),
816
  `Usage%` = sprintf("%.1f%%", (n() / total_pitches) * 100),
817
- `Ext.` = ifelse("Extension" %in% names(movement_stats),
818
- sprintf("%.1f", mean(Extension, na.rm = TRUE)),
819
- ""),
820
- `Avg Velo` = sprintf("%.1f mph", mean(RelSpeed, na.rm = TRUE)),
821
- `90th Velo` = sprintf("%.1f mph", quantile(RelSpeed, 0.9, na.rm = TRUE)),
822
- `Max Velo` = sprintf("%.1f mph", max(RelSpeed, na.rm = TRUE)),
823
- `Avg IVB` = sprintf("%.1f in", mean(InducedVertBreak, na.rm = TRUE)),
824
- `Avg HB` = sprintf("%.1f in", mean(HorzBreak, na.rm = TRUE)),
825
  `Avg Spin` = ifelse("SpinRate" %in% names(movement_stats),
826
- sprintf("%.0f rpm", mean(SpinRate, na.rm = TRUE)),
827
  "—"),
828
- `Rel Height` = ifelse("RelHeight" %in% names(movement_stats),
829
- sprintf("%.1f", mean(RelHeight, na.rm = TRUE)),
830
- ""),
 
831
  `Zone%` = sprintf("%.1f%%", round(mean(in_zone, na.rm = TRUE) * 100, 1)),
832
  `Whiff%` = sprintf("%.1f%%", round(mean(is_whiff, na.rm = TRUE) * 100, 1)),
833
- `Chase%` = sprintf("%.1f%%", round(mean(chase, na.rm = TRUE) * 100, 1)),
834
  .groups = "drop"
835
  ) %>%
836
  arrange(desc(Count))
@@ -847,7 +1212,6 @@ server <- function(input, output, session) {
847
  if (!is.null(pitch_info)) {
848
  pitch_data <- pitch_info$data
849
 
850
- # Build info text with only available fields
851
  info_lines <- c(
852
  paste("Pitcher:", pitch_info$pitcher),
853
  paste("Current Type:", pitch_data$TaggedPitchType),
@@ -856,11 +1220,18 @@ server <- function(input, output, session) {
856
  paste("Induced Vertical Break:", round(pitch_data$InducedVertBreak, 1), "inches")
857
  )
858
 
859
- # Add optional fields only if they exist and have values
860
  if ("SpinRate" %in% names(pitch_data) && !is.na(pitch_data$SpinRate)) {
861
  info_lines <- c(info_lines, paste("Spin Rate:", round(pitch_data$SpinRate, 0), "rpm"))
862
  }
863
 
 
 
 
 
 
 
 
 
864
  if ("Date" %in% names(pitch_data) && !is.na(pitch_data$Date)) {
865
  info_lines <- c(info_lines, paste("Date:", pitch_data$Date))
866
  }
@@ -876,17 +1247,13 @@ server <- function(input, output, session) {
876
  pitch_info <- selected_pitch()
877
 
878
  if (!is.null(pitch_info)) {
879
- # Get the full dataset
880
  current_data <- plot_data()
881
 
882
- # Find and update the specific pitch more reliably
883
- # Use multiple criteria to identify the exact row
884
  target_pitcher <- pitch_info$pitcher
885
  target_hb <- pitch_info$data$HorzBreak
886
  target_ivb <- pitch_info$data$InducedVertBreak
887
  target_velo <- pitch_info$data$RelSpeed
888
 
889
- # Update the TaggedPitchType for the matching row
890
  current_data <- current_data %>%
891
  mutate(TaggedPitchType = ifelse(
892
  Pitcher == target_pitcher &
@@ -897,20 +1264,16 @@ server <- function(input, output, session) {
897
  TaggedPitchType
898
  ))
899
 
900
- # Update reactive values
901
  plot_data(current_data)
902
  processed_data(current_data)
903
 
904
- # Close modal
905
  toggleModal(session, "pitchEditModal", toggle = "close")
906
 
907
- # Show success message
908
  showNotification(
909
  paste("Updated pitch from", pitch_info$original_type, "to", input$modal_new_pitch_type),
910
  type = "message", duration = 3
911
  )
912
 
913
- # Clear selection
914
  selected_pitch(NULL)
915
  }
916
  })
@@ -937,6 +1300,13 @@ server <- function(input, output, session) {
937
  output$data_summary <- renderText({
938
  req(processed_data())
939
  df <- processed_data()
 
 
 
 
 
 
 
940
 
941
  summary_text <- paste(
942
  paste("Total rows:", nrow(df)),
@@ -959,6 +1329,7 @@ server <- function(input, output, session) {
959
  } else {
960
  "TaggedPitchType column not available"
961
  }),
 
962
  sep = "\n"
963
  )
964
 
@@ -968,7 +1339,7 @@ server <- function(input, output, session) {
968
  # Download handler
969
  output$downloadData <- downloadHandler(
970
  filename = function() {
971
- paste("app_ready_COA", Sys.Date(), ".csv", sep = "")
972
  },
973
  content = function(file) {
974
  write.csv(processed_data(), file, row.names = FALSE)
 
5
  library(dplyr)
6
  library(readr)
7
  library(stringr)
8
+ library(jsonlite)
9
 
10
  # Define columns to remove if they exist
11
  columns_to_remove <- c(
 
22
  "SpinAxis3dSeamOrientationBallXAmb2", "SpinAxis3dSeamOrientationBallAngleVerticalAmb3",
23
  "SpinAxis3dSeamOrientationBallAngleHorizontalAmb3", "SpinAxis3dSeamOrientationBallXAmb3",
24
  "SpinAxis3dSeamOrientationBallYAmb3", "SpinAxis3dSeamOrientationBallZAmb3",
25
+ "SpinAxis3dSeamOrientationBallZAmb4", "GameDate"
 
26
  )
27
 
28
  # Pitch colors for visualization (Coastal Carolina theme)
 
40
  "Other" = "#D3D3D3"
41
  )
42
 
43
+ # Function to parse bat tracking JSON
44
+ parse_bat_tracking_json <- function(json_path) {
45
+ tryCatch({
46
+ json_data <- fromJSON(json_path, simplifyVector = FALSE)
47
+
48
+ # Extract metadata
49
+ game_reference <- json_data$GameReference
50
+ session_id <- json_data$SessionId
51
+
52
+ # Extract plays
53
+ plays <- json_data$Plays
54
+
55
+ if (length(plays) == 0) {
56
+ return(list(
57
+ success = TRUE,
58
+ data = NULL,
59
+ game_reference = game_reference,
60
+ message = "JSON parsed but contains no bat tracking plays (empty Plays array)"
61
+ ))
62
+ }
63
+
64
+ # Build data frame from plays
65
+ bat_tracking_df <- data.frame(
66
+ PitchUID = sapply(plays, function(p) p$PitchUID),
67
+ BatSpeed_Sensor = sapply(plays, function(p) p$BatSpeed),
68
+ VerticalAttackAngle_Sensor = sapply(plays, function(p) p$VerticalAttackAngle),
69
+ HorizontalAttackAngle_Sensor = sapply(plays, function(p) p$HorizontalAttackAngle),
70
+ BatTracking_PlayId = sapply(plays, function(p) p$PlayId),
71
+ BatTracking_Time = sapply(plays, function(p) p$Time),
72
+ stringsAsFactors = FALSE
73
+ )
74
+
75
+ return(list(
76
+ success = TRUE,
77
+ data = bat_tracking_df,
78
+ game_reference = game_reference,
79
+ session_id = session_id,
80
+ plays_count = length(plays),
81
+ message = paste("Successfully parsed", length(plays), "bat tracking play(s)")
82
+ ))
83
+
84
+ }, error = function(e) {
85
+ return(list(
86
+ success = FALSE,
87
+ data = NULL,
88
+ message = paste("Error parsing JSON:", e$message)
89
+ ))
90
+ })
91
+ }
92
+
93
+ # Function to merge CSV with bat tracking
94
+ merge_with_bat_tracking <- function(csv_data, bat_tracking_data) {
95
+ if (is.null(bat_tracking_data) || nrow(bat_tracking_data) == 0) {
96
+ return(list(
97
+ data = csv_data,
98
+ matched = 0,
99
+ total_bat = 0,
100
+ message = "No bat tracking data to merge"
101
+ ))
102
  }
103
 
104
+ # Check if PitchUID exists in CSV
105
+ if (!"PitchUID" %in% names(csv_data)) {
106
+ return(list(
107
+ data = csv_data,
108
+ matched = 0,
109
+ total_bat = nrow(bat_tracking_data),
110
+ message = "CSV does not contain PitchUID column - cannot merge"
111
+ ))
112
+ }
113
+
114
+ # Perform left join
115
+ merged_data <- csv_data %>%
116
+ left_join(bat_tracking_data, by = "PitchUID")
117
+
118
+ # Count matches
119
+ matched_count <- sum(!is.na(merged_data$BatSpeed_Sensor))
120
+
121
+ # If original BatSpeed column exists and is empty, fill with sensor data
122
+ if ("BatSpeed" %in% names(merged_data)) {
123
+ merged_data <- merged_data %>%
124
+ mutate(BatSpeed = ifelse(is.na(BatSpeed) & !is.na(BatSpeed_Sensor),
125
+ BatSpeed_Sensor, BatSpeed))
126
+ }
127
+
128
+ if ("VerticalAttackAngle" %in% names(merged_data)) {
129
+ merged_data <- merged_data %>%
130
+ mutate(VerticalAttackAngle = ifelse(is.na(VerticalAttackAngle) & !is.na(VerticalAttackAngle_Sensor),
131
+ VerticalAttackAngle_Sensor, VerticalAttackAngle))
132
+ }
133
+
134
+ if ("HorizontalAttackAngle" %in% names(merged_data)) {
135
+ merged_data <- merged_data %>%
136
+ mutate(HorizontalAttackAngle = ifelse(is.na(HorizontalAttackAngle) & !is.na(HorizontalAttackAngle_Sensor),
137
+ HorizontalAttackAngle_Sensor, HorizontalAttackAngle))
138
+ }
139
 
140
+ return(list(
141
+ data = merged_data,
142
+ matched = matched_count,
143
+ total_bat = nrow(bat_tracking_data),
144
+ message = paste("Merged successfully:", matched_count, "of", nrow(bat_tracking_data), "bat tracking records matched")
145
+ ))
146
  }
147
 
148
  # UI
 
161
  justify-content: space-between;
162
  align-items: center;
163
  padding: 20px 40px;
164
+ background: #ffffff;
165
  border-bottom: 3px solid darkcyan;
166
  margin-bottom: 20px;
167
  }
 
305
  }
306
 
307
  h4 {
308
+ color: darkcyan;
309
  font-weight: 500;
310
  margin-top: 20px;
311
  margin-bottom: 12px;
 
348
 
349
  .brand-teal { color: darkcyan; }
350
  .brand-bronze { color: peru; }
351
+
352
+ /* Bat tracking upload box styling */
353
+ .bat-tracking-box {
354
+ background: linear-gradient(135deg, #e8f4f8 0%, #f0e6d3 100%);
355
+ border: 2px dashed darkcyan;
356
+ border-radius: 15px;
357
+ padding: 20px;
358
+ margin-top: 15px;
359
+ }
360
+
361
+ .merge-status-box {
362
+ background: #f8f9fa;
363
+ border-left: 4px solid darkcyan;
364
+ padding: 15px;
365
+ border-radius: 0 10px 10px 0;
366
+ margin-top: 15px;
367
+ }
368
+
369
+ .merge-success {
370
+ border-left-color: #28a745;
371
+ background: #d4edda;
372
+ }
373
+
374
+ .merge-warning {
375
+ border-left-color: #ffc107;
376
+ background: #fff3cd;
377
+ }
378
+
379
+ .merge-error {
380
+ border-left-color: #dc3545;
381
+ background: #f8d7da;
382
+ }
383
  "))
384
  ),
385
 
 
396
  tabPanel(
397
  "Upload & Process",
398
  fluidRow(
399
+ column(6,
400
+ h3("1. Upload TrackMan CSV"),
401
  fileInput("file", "Choose CSV File", accept = c(".csv")),
402
  fluidRow(
403
  column(4,
 
413
  choices = c(None = "", "Double Quote" = '"', "Single Quote" = "'"),
414
  selected = '"', inline = TRUE)
415
  )
416
+ ),
417
+ verbatimTextOutput("csv_status")
418
+ ),
419
+ column(6,
420
+ div(class = "bat-tracking-box",
421
+ h3("2. Upload Bat Tracking JSON (Optional)", style = "margin-top: 0;"),
422
+ fileInput("json_file", "Choose Bat Tracking JSON File", accept = c(".json")),
423
+ p(style = "color: #666; font-size: 12px;",
424
+ "Upload the corresponding _battracking.json file to merge bat speed and attack angle data."),
425
+ verbatimTextOutput("json_status"),
426
+ uiOutput("merge_status_ui")
427
  )
428
  )
429
  ),
430
 
431
+ hr(),
432
+
433
  fluidRow(
434
  column(8,
435
+ h3("3. Columns to Remove"),
436
  p("Select which columns to remove from your dataset:"),
437
  checkboxGroupInput("columns_to_remove", "Remove These Columns:",
438
  choices = columns_to_remove,
 
447
  br(), br(),
448
  actionButton("select_spinaxis", "Select SpinAxis3d Columns", class = "btn-info"),
449
  br(), br(),
 
 
450
  h4("Processing Summary"),
451
  verbatimTextOutput("process_summary")
452
  )
453
  )
454
  ),
455
 
456
+ # Bat Tracking Details Tab
457
+ tabPanel(
458
+ "Bat Tracking Data",
459
+ fluidRow(
460
+ column(12,
461
+ h3("Bat Tracking Merge Details"),
462
+ uiOutput("bat_tracking_details"),
463
+ hr(),
464
+ h4("Pitches with Bat Tracking Data"),
465
+ DT::dataTableOutput("bat_tracking_table")
466
+ )
467
+ )
468
+ ),
469
+
470
  # Preview Data Tab
471
  tabPanel(
472
  "Preview Data",
 
583
  plot_data <- reactiveVal(NULL)
584
  selected_pitch <- reactiveVal(NULL)
585
  selected_points <- reactiveVal(NULL)
586
+ csv_data_raw <- reactiveVal(NULL)
587
+ bat_tracking_parsed <- reactiveVal(NULL)
588
+ merge_result <- reactiveVal(NULL)
589
 
590
  # Handle column selection buttons
591
  observeEvent(input$select_all_cols, {
 
602
  updateCheckboxGroupInput(session, "columns_to_remove", selected = spinaxis_cols)
603
  })
604
 
605
+ # Process uploaded CSV file
 
 
 
 
 
606
  observeEvent(input$file, {
607
  req(input$file)
608
 
609
  tryCatch({
 
610
  df <- read.csv(input$file$datapath,
611
  header = input$header,
612
  sep = input$sep,
613
  quote = input$quote,
614
  stringsAsFactors = FALSE)
615
 
616
+ csv_data_raw(df)
617
+
618
+ # If we already have bat tracking data, try to merge
619
+ if (!is.null(bat_tracking_parsed()) && !is.null(bat_tracking_parsed()$data)) {
620
+ result <- merge_with_bat_tracking(df, bat_tracking_parsed()$data)
621
+ merge_result(result)
622
+ df <- result$data
623
+ }
624
+
625
+ # Process the data (remove columns)
626
  selected_cols_to_remove <- input$columns_to_remove %||% character(0)
627
  processed_df <- df
628
 
 
629
  if (length(selected_cols_to_remove) > 0) {
630
  columns_to_drop <- intersect(names(df), selected_cols_to_remove)
631
  if (length(columns_to_drop) > 0) {
 
633
  }
634
  }
635
 
 
636
  processed_df <- processed_df %>% distinct()
637
 
638
  processed_data(processed_df)
 
645
  }
646
 
647
  }, error = function(e) {
648
+ showNotification(paste("Error processing CSV:", e$message), type = "error")
649
+ })
650
+ })
651
+
652
+ # Process uploaded JSON file
653
+ observeEvent(input$json_file, {
654
+ req(input$json_file)
655
+
656
+ tryCatch({
657
+ parsed <- parse_bat_tracking_json(input$json_file$datapath)
658
+ bat_tracking_parsed(parsed)
659
+
660
+ # If we already have CSV data, merge
661
+ if (!is.null(csv_data_raw()) && parsed$success && !is.null(parsed$data)) {
662
+ result <- merge_with_bat_tracking(csv_data_raw(), parsed$data)
663
+ merge_result(result)
664
+
665
+ # Re-process with merged data
666
+ df <- result$data
667
+ selected_cols_to_remove <- input$columns_to_remove %||% character(0)
668
+
669
+ if (length(selected_cols_to_remove) > 0) {
670
+ columns_to_drop <- intersect(names(df), selected_cols_to_remove)
671
+ if (length(columns_to_drop) > 0) {
672
+ df <- df %>% select(-all_of(columns_to_drop))
673
+ }
674
+ }
675
+
676
+ df <- df %>% distinct()
677
+
678
+ processed_data(df)
679
+ plot_data(df)
680
+
681
+ showNotification(result$message, type = "message", duration = 5)
682
+ }
683
+
684
+ }, error = function(e) {
685
+ showNotification(paste("Error processing JSON:", e$message), type = "error")
686
  })
687
  })
688
 
689
+ # CSV status output
690
+ output$csv_status <- renderText({
691
+ if (is.null(input$file)) {
692
+ return("No CSV file uploaded yet.")
693
+ }
694
+
695
+ if (is.null(csv_data_raw())) {
696
+ return("Processing CSV...")
697
+ }
698
+
699
+ df <- csv_data_raw()
700
+ game_id <- if ("GameID" %in% names(df)) unique(df$GameID)[1] else "Unknown"
701
+
702
+ paste(
703
+ "✓ CSV loaded successfully!",
704
+ paste(" Game ID:", game_id),
705
+ paste(" Rows:", nrow(df)),
706
+ paste(" Columns:", ncol(df)),
707
+ sep = "\n"
708
+ )
709
+ })
710
+
711
+ # JSON status output
712
+ output$json_status <- renderText({
713
+ if (is.null(input$json_file)) {
714
+ return("No JSON file uploaded yet.")
715
+ }
716
+
717
+ parsed <- bat_tracking_parsed()
718
+ if (is.null(parsed)) {
719
+ return("Processing JSON...")
720
+ }
721
+
722
+ if (!parsed$success) {
723
+ return(paste("✗", parsed$message))
724
+ }
725
+
726
+ paste(
727
+ "✓ JSON parsed successfully!",
728
+ paste(" Game Reference:", parsed$game_reference),
729
+ paste(" Plays found:", parsed$plays_count %||% 0),
730
+ sep = "\n"
731
+ )
732
+ })
733
+
734
+ # Merge status UI
735
+ output$merge_status_ui <- renderUI({
736
+ result <- merge_result()
737
+ parsed <- bat_tracking_parsed()
738
+ csv <- csv_data_raw()
739
+
740
+ if (is.null(parsed) || is.null(csv)) {
741
+ return(NULL)
742
+ }
743
+
744
+ if (!parsed$success) {
745
+ return(div(class = "merge-status-box merge-error",
746
+ h4("Merge Status", style = "margin-top: 0; color: #721c24;"),
747
+ p(parsed$message)
748
+ ))
749
+ }
750
+
751
+ if (is.null(parsed$data) || is.null(result)) {
752
+ # Check game ID match
753
+ csv_game <- if ("GameID" %in% names(csv)) unique(csv$GameID)[1] else NULL
754
+ json_game <- parsed$game_reference
755
+
756
+ if (!is.null(csv_game) && !is.null(json_game) && csv_game != json_game) {
757
+ return(div(class = "merge-status-box merge-warning",
758
+ h4("⚠ Game ID Mismatch", style = "margin-top: 0; color: #856404;"),
759
+ p(paste("CSV Game:", csv_game)),
760
+ p(paste("JSON Game:", json_game)),
761
+ p("Files may be from different games!")
762
+ ))
763
+ }
764
+
765
+ return(div(class = "merge-status-box merge-warning",
766
+ h4("No Data to Merge", style = "margin-top: 0; color: #856404;"),
767
+ p(parsed$message)
768
+ ))
769
+ }
770
+
771
+ # Check game ID match
772
+ csv_game <- if ("GameID" %in% names(csv)) unique(csv$GameID)[1] else NULL
773
+ json_game <- parsed$game_reference
774
+ game_match <- is.null(csv_game) || is.null(json_game) || csv_game == json_game
775
+
776
+ if (result$matched > 0) {
777
+ div(class = "merge-status-box merge-success",
778
+ h4("✓ Merge Successful!", style = "margin-top: 0; color: #155724;"),
779
+ p(paste("Matched:", result$matched, "of", result$total_bat, "bat tracking records")),
780
+ if (!game_match) p(style = "color: #856404;", "⚠ Note: Game IDs differ but PitchUIDs matched")
781
+ )
782
+ } else {
783
+ div(class = "merge-status-box merge-warning",
784
+ h4("⚠ No Matches Found", style = "margin-top: 0; color: #856404;"),
785
+ p(paste("0 of", result$total_bat, "bat tracking records matched")),
786
+ if (!game_match) p(paste("Game ID mismatch: CSV =", csv_game, ", JSON =", json_game))
787
+ )
788
+ }
789
+ })
790
+
791
+ # Bat tracking details
792
+ output$bat_tracking_details <- renderUI({
793
+ parsed <- bat_tracking_parsed()
794
+ result <- merge_result()
795
+
796
+ if (is.null(parsed)) {
797
+ return(div(
798
+ p("No bat tracking JSON file uploaded."),
799
+ p("Upload a _battracking.json file in the 'Upload & Process' tab to see bat tracking data here.")
800
+ ))
801
+ }
802
+
803
+ if (!parsed$success) {
804
+ return(div(class = "alert alert-danger", parsed$message))
805
+ }
806
+
807
+ if (is.null(parsed$data)) {
808
+ return(div(class = "alert alert-warning",
809
+ h4("Empty Bat Tracking File"),
810
+ p(parsed$message),
811
+ p("The JSON file was valid but contained no swing data in the Plays array.")
812
+ ))
813
+ }
814
+
815
+ # Show summary
816
+ div(
817
+ div(class = "row",
818
+ div(class = "col-md-4",
819
+ div(class = "well",
820
+ h4("Game Reference"),
821
+ p(parsed$game_reference)
822
+ )
823
+ ),
824
+ div(class = "col-md-4",
825
+ div(class = "well",
826
+ h4("Total Swings Tracked"),
827
+ p(style = "font-size: 24px; font-weight: bold; color: darkcyan;", parsed$plays_count)
828
+ )
829
+ ),
830
+ div(class = "col-md-4",
831
+ div(class = "well",
832
+ h4("Matched to CSV"),
833
+ p(style = "font-size: 24px; font-weight: bold; color: #28a745;",
834
+ if (!is.null(result)) result$matched else "N/A")
835
+ )
836
+ )
837
+ )
838
+ )
839
+ })
840
+
841
+ # Bat tracking table
842
+ output$bat_tracking_table <- DT::renderDataTable({
843
+ df <- processed_data()
844
+
845
+ if (is.null(df)) {
846
+ return(NULL)
847
+ }
848
+
849
+ # Filter to rows with bat tracking data
850
+ if ("BatSpeed_Sensor" %in% names(df)) {
851
+ bat_rows <- df %>%
852
+ filter(!is.na(BatSpeed_Sensor)) %>%
853
+ select(
854
+ any_of(c("PitchNo", "Time", "Pitcher", "Batter", "TaggedPitchType", "PitchCall",
855
+ "RelSpeed", "ExitSpeed", "Angle",
856
+ "BatSpeed", "BatSpeed_Sensor",
857
+ "VerticalAttackAngle", "VerticalAttackAngle_Sensor",
858
+ "HorizontalAttackAngle", "HorizontalAttackAngle_Sensor"))
859
+ )
860
+
861
+ if (nrow(bat_rows) == 0) {
862
+ return(NULL)
863
+ }
864
+
865
+ DT::datatable(bat_rows,
866
+ options = list(scrollX = TRUE, pageLength = 10),
867
+ rownames = FALSE) %>%
868
+ DT::formatRound(columns = intersect(names(bat_rows),
869
+ c("BatSpeed_Sensor", "VerticalAttackAngle_Sensor",
870
+ "HorizontalAttackAngle_Sensor", "RelSpeed",
871
+ "ExitSpeed", "Angle")),
872
+ digits = 1)
873
+ } else {
874
+ return(NULL)
875
+ }
876
+ })
877
+
878
  # Processing summary
879
  output$process_summary <- renderText({
880
  if (is.null(input$file)) {
 
886
  }
887
 
888
  df <- processed_data()
889
+ original_df <- csv_data_raw()
890
  selected_cols_to_remove <- input$columns_to_remove %||% character(0)
891
  removed_cols <- intersect(selected_cols_to_remove, names(original_df))
892
+ result <- merge_result()
893
 
 
894
  removed_cols_text <- if (length(removed_cols) > 0) {
895
  cols_display <- if (length(removed_cols) > 5) {
896
  paste(paste(head(removed_cols, 5), collapse = ", "), "...")
897
  } else {
898
  paste(removed_cols, collapse = ", ")
899
  }
900
+ paste("✓ Removed columns:", length(removed_cols))
901
  } else {
902
  "✓ Removed columns: 0"
903
  }
904
 
905
+ bat_tracking_text <- if (!is.null(result) && result$matched > 0) {
906
+ paste("✓ Bat tracking merged:", result$matched, "pitches")
907
+ } else if (!is.null(bat_tracking_parsed())) {
908
+ "⚠ Bat tracking: No matches"
909
+ } else {
910
+ "○ Bat tracking: Not uploaded"
911
+ }
912
+
913
  summary_text <- paste(
914
  "✓ File processed successfully!",
915
  paste("✓ Original columns:", ncol(original_df)),
916
  paste("✓ Final columns:", ncol(df)),
 
917
  paste("✓ Rows processed:", nrow(df)),
918
  removed_cols_text,
919
+ bat_tracking_text,
920
  "✓ Duplicates removed",
 
921
  sep = "\n"
922
  )
923
 
 
949
  return()
950
  }
951
 
 
952
  pitcher_data$color <- pitch_colors[pitcher_data$TaggedPitchType]
953
  pitcher_data$color[is.na(pitcher_data$color)] <- "#D3D3D3"
954
 
 
955
  par(mar = c(5, 5, 4, 8), xpd = TRUE)
956
  plot(pitcher_data$HorzBreak, pitcher_data$InducedVertBreak,
957
  col = pitcher_data$color,
 
961
  ylab = "Induced Vertical Break (inches)",
962
  main = paste("Pitch Movement Chart -", input$pitcher_select))
963
 
 
964
  grid(nx = NULL, ny = NULL, col = "lightgray", lty = 1, lwd = 0.5)
965
  abline(h = 0, col = "gray", lty = 2, lwd = 1)
966
  abline(v = 0, col = "gray", lty = 2, lwd = 1)
967
 
 
968
  for (r in c(6, 12, 18, 24)) {
969
  circle_x <- r * cos(seq(0, 2*pi, length.out = 100))
970
  circle_y <- r * sin(seq(0, 2*pi, length.out = 100))
971
  lines(circle_x, circle_y, col = "lightgray", lty = 3)
972
  }
973
 
 
974
  if (input$selection_mode == "drag" && !is.null(selected_points())) {
975
  sel_points <- selected_points()
976
  points(sel_points$HorzBreak, sel_points$InducedVertBreak,
977
  pch = 21, cex = 2, col = "red", lwd = 3)
978
  }
979
 
 
980
  unique_pitches <- unique(pitcher_data$TaggedPitchType)
981
  unique_colors <- pitch_colors[unique_pitches]
982
  legend("topright", inset = c(-0.15, 0),
 
991
  observeEvent(input$plot_click, {
992
  req(plot_data(), input$pitcher_select, input$plot_click)
993
 
 
994
  if (input$selection_mode != "single") return()
995
 
996
  pitcher_data <- plot_data() %>%
 
1001
 
1002
  if (nrow(pitcher_data) == 0) return()
1003
 
 
1004
  click_x <- input$plot_click$x
1005
  click_y <- input$plot_click$y
1006
 
 
1009
 
1010
  closest_idx <- which.min(distances)
1011
 
 
1012
  if (min(distances) <= 2) {
1013
  clicked_pitch <- pitcher_data[closest_idx, ]
1014
 
 
 
 
 
 
1015
  full_data <- plot_data() %>% filter(Pitcher == input$pitcher_select)
1016
  original_row <- which(full_data$HorzBreak == clicked_pitch$HorzBreak &
1017
  full_data$InducedVertBreak == clicked_pitch$InducedVertBreak &
 
1024
  original_type = clicked_pitch$TaggedPitchType
1025
  ))
1026
 
 
1027
  updateSelectInput(session, "modal_new_pitch_type",
1028
  selected = clicked_pitch$TaggedPitchType)
1029
 
 
1030
  toggleModal(session, "pitchEditModal", toggle = "open")
1031
  }
1032
  })
 
1035
  observeEvent(input$plot_brush, {
1036
  req(plot_data(), input$pitcher_select, input$plot_brush)
1037
 
 
1038
  if (input$selection_mode != "drag") return()
1039
 
1040
  pitcher_data <- plot_data() %>%
 
1044
 
1045
  if (nrow(pitcher_data) == 0) return()
1046
 
 
1047
  brush <- input$plot_brush
1048
  brushed_points <- pitcher_data %>%
1049
  filter(
 
1069
  return()
1070
  }
1071
 
 
1072
  current_data <- plot_data()
1073
 
 
1074
  for (i in 1:nrow(sel_points)) {
1075
  point <- sel_points[i, ]
1076
  current_data <- current_data %>%
 
1084
  ))
1085
  }
1086
 
 
1087
  plot_data(current_data)
1088
  processed_data(current_data)
 
 
1089
  selected_points(NULL)
1090
 
 
1091
  showNotification(
1092
  paste("Updated", nrow(sel_points), "pitches to", input$bulk_pitch_type),
1093
  type = "message", duration = 3
 
1127
  closest_idx <- which.min(distances)
1128
  hover_pitch <- pitcher_data[closest_idx, ]
1129
 
1130
+ # Include bat tracking info if available
1131
+ bat_info <- ""
1132
+ if ("BatSpeed_Sensor" %in% names(hover_pitch) && !is.na(hover_pitch$BatSpeed_Sensor)) {
1133
+ bat_info <- paste(" | Bat Speed:", round(hover_pitch$BatSpeed_Sensor, 1), "mph")
1134
+ }
1135
+
1136
  paste("Hovering over:",
1137
  paste("Type:", hover_pitch$TaggedPitchType),
1138
  paste("Velocity:", round(hover_pitch$RelSpeed, 1), "mph"),
1139
  paste("HB:", round(hover_pitch$HorzBreak, 1), "in"),
1140
  paste("IVB:", round(hover_pitch$InducedVertBreak, 1), "in"),
1141
+ bat_info,
1142
  sep = " | ")
1143
  } else {
1144
  ""
 
1161
  TaggedPitchType %in% c("ChangeUp", "Changeup") ~ "Changeup",
1162
  TRUE ~ TaggedPitchType
1163
  ),
 
1164
  in_zone = ifelse("StrikeZoneIndicator" %in% names(.), StrikeZoneIndicator,
1165
  ifelse(!is.na(PlateLocSide) & !is.na(PlateLocHeight) &
1166
  PlateLocSide >= -0.95 & PlateLocSide <= 0.95 &
 
1173
  (PlateLocSide < -0.95 | PlateLocSide > 0.95 | PlateLocHeight < 1.6 | PlateLocHeight > 3.5), 1, 0))
1174
  )
1175
 
 
1176
  total_pitches <- nrow(movement_stats)
1177
 
1178
+ # Check if bat tracking columns exist
1179
+ has_bat_speed <- "BatSpeed_Sensor" %in% names(movement_stats)
1180
+
1181
  summary_stats <- movement_stats %>%
1182
  group_by(`Pitch Type` = pitch_group) %>%
1183
  summarise(
1184
  Count = n(),
1185
  `Usage%` = sprintf("%.1f%%", (n() / total_pitches) * 100),
1186
+ `Avg Velo` = sprintf("%.1f", mean(RelSpeed, na.rm = TRUE)),
1187
+ `Max Velo` = sprintf("%.1f", max(RelSpeed, na.rm = TRUE)),
1188
+ `Avg IVB` = sprintf("%.1f", mean(InducedVertBreak, na.rm = TRUE)),
1189
+ `Avg HB` = sprintf("%.1f", mean(HorzBreak, na.rm = TRUE)),
 
 
 
 
1190
  `Avg Spin` = ifelse("SpinRate" %in% names(movement_stats),
1191
+ sprintf("%.0f", mean(SpinRate, na.rm = TRUE)),
1192
  "—"),
1193
+ `Avg Bat Speed` = if (has_bat_speed) {
1194
+ bat_vals <- BatSpeed_Sensor[!is.na(BatSpeed_Sensor)]
1195
+ if (length(bat_vals) > 0) sprintf("%.1f", mean(bat_vals)) else "—"
1196
+ } else "—",
1197
  `Zone%` = sprintf("%.1f%%", round(mean(in_zone, na.rm = TRUE) * 100, 1)),
1198
  `Whiff%` = sprintf("%.1f%%", round(mean(is_whiff, na.rm = TRUE) * 100, 1)),
 
1199
  .groups = "drop"
1200
  ) %>%
1201
  arrange(desc(Count))
 
1212
  if (!is.null(pitch_info)) {
1213
  pitch_data <- pitch_info$data
1214
 
 
1215
  info_lines <- c(
1216
  paste("Pitcher:", pitch_info$pitcher),
1217
  paste("Current Type:", pitch_data$TaggedPitchType),
 
1220
  paste("Induced Vertical Break:", round(pitch_data$InducedVertBreak, 1), "inches")
1221
  )
1222
 
 
1223
  if ("SpinRate" %in% names(pitch_data) && !is.na(pitch_data$SpinRate)) {
1224
  info_lines <- c(info_lines, paste("Spin Rate:", round(pitch_data$SpinRate, 0), "rpm"))
1225
  }
1226
 
1227
+ # Add bat tracking info if available
1228
+ if ("BatSpeed_Sensor" %in% names(pitch_data) && !is.na(pitch_data$BatSpeed_Sensor)) {
1229
+ info_lines <- c(info_lines,
1230
+ paste("Bat Speed:", round(pitch_data$BatSpeed_Sensor, 1), "mph"),
1231
+ paste("Vertical Attack Angle:", round(pitch_data$VerticalAttackAngle_Sensor, 1), "°"),
1232
+ paste("Horizontal Attack Angle:", round(pitch_data$HorizontalAttackAngle_Sensor, 1), "°"))
1233
+ }
1234
+
1235
  if ("Date" %in% names(pitch_data) && !is.na(pitch_data$Date)) {
1236
  info_lines <- c(info_lines, paste("Date:", pitch_data$Date))
1237
  }
 
1247
  pitch_info <- selected_pitch()
1248
 
1249
  if (!is.null(pitch_info)) {
 
1250
  current_data <- plot_data()
1251
 
 
 
1252
  target_pitcher <- pitch_info$pitcher
1253
  target_hb <- pitch_info$data$HorzBreak
1254
  target_ivb <- pitch_info$data$InducedVertBreak
1255
  target_velo <- pitch_info$data$RelSpeed
1256
 
 
1257
  current_data <- current_data %>%
1258
  mutate(TaggedPitchType = ifelse(
1259
  Pitcher == target_pitcher &
 
1264
  TaggedPitchType
1265
  ))
1266
 
 
1267
  plot_data(current_data)
1268
  processed_data(current_data)
1269
 
 
1270
  toggleModal(session, "pitchEditModal", toggle = "close")
1271
 
 
1272
  showNotification(
1273
  paste("Updated pitch from", pitch_info$original_type, "to", input$modal_new_pitch_type),
1274
  type = "message", duration = 3
1275
  )
1276
 
 
1277
  selected_pitch(NULL)
1278
  }
1279
  })
 
1300
  output$data_summary <- renderText({
1301
  req(processed_data())
1302
  df <- processed_data()
1303
+ result <- merge_result()
1304
+
1305
+ bat_tracking_summary <- if (!is.null(result) && result$matched > 0) {
1306
+ paste("Bat tracking data:", result$matched, "pitches with swing metrics")
1307
+ } else {
1308
+ "Bat tracking data: None"
1309
+ }
1310
 
1311
  summary_text <- paste(
1312
  paste("Total rows:", nrow(df)),
 
1329
  } else {
1330
  "TaggedPitchType column not available"
1331
  }),
1332
+ bat_tracking_summary,
1333
  sep = "\n"
1334
  )
1335
 
 
1339
  # Download handler
1340
  output$downloadData <- downloadHandler(
1341
  filename = function() {
1342
+ paste("app_ready_COA_", Sys.Date(), ".csv", sep = "")
1343
  },
1344
  content = function(file) {
1345
  write.csv(processed_data(), file, row.names = FALSE)