igroffman commited on
Commit
fac5de6
·
verified ·
1 Parent(s): 3d1fade

Create app.R

Browse files
Files changed (1) hide show
  1. app.R +2461 -0
app.R ADDED
@@ -0,0 +1,2461 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ Sys.setenv(RETICULATE_PYTHON = "/usr/bin/python3")
2
+ library(reticulate)
3
+ library(shiny)
4
+ library(shinydashboard)
5
+ library(shinyBS)
6
+ library(DT)
7
+ library(dplyr)
8
+ library(readr)
9
+ library(stringr)
10
+ library(jsonlite)
11
+ library(httr)
12
+ library(progressr)
13
+ library(RCurl)
14
+ library(curl)
15
+ library(xgboost)
16
+ library(recipes)
17
+ library(arrow)
18
+ library(base64enc)
19
+
20
+ # Maximum rows allowed for upload
21
+ MAX_UPLOAD_ROWS <- 5000
22
+
23
+ PASSWORD <- Sys.getenv("password")
24
+
25
+ rv <- read_csv("non_context_run_values.csv")
26
+ stuffplus_model <- xgb.load("stuffplus_xgb.json")
27
+ stuffplus_recipe <- readRDS("stuffplus_recipe.rds")
28
+
29
+ # Define columns to remove if they exist
30
+ columns_to_remove <- c(
31
+ "SpinAxis3dTransverseAngle", "SpinAxis3dLongitudinalAngle", "SpinAxis3dActiveSpinRate",
32
+ "SpinAxis3dSpinEfficiency", "SpinAxis3dTilt", "SpinAxis3dVectorX", "SpinAxis3dVectorY",
33
+ "SpinAxis3dVectorZ", "SpinAxis3dSeamOrientationRotationX", "SpinAxis3dSeamOrientationRotationY",
34
+ "SpinAxis3dSeamOrientationRotationZ", "SpinAxis3dSeamOrientationBallYAmb1",
35
+ "SpinAxis3dSeamOrientationBallAngleHorizontalAmb1", "SpinAxis3dSeamOrientationBallZAmb1",
36
+ "SpinAxis3dSeamOrientationBallAngleVerticalAmb2", "SpinAxis3dSeamOrientationBallZAmb2",
37
+ "SpinAxis3dSeamOrientationBallXAmb4", "SpinAxis3dSeamOrientationBallYAmb4",
38
+ "SpinAxis3dSeamOrientationBallAngleHorizontalAmb2", "SpinAxis3dSeamOrientationBallAngleVerticalAmb1",
39
+ "SpinAxis3dSeamOrientationBallXAmb1", "SpinAxis3dSeamOrientationBallYAmb2",
40
+ "SpinAxis3dSeamOrientationBallAngleHorizontalAmb4", "SpinAxis3dSeamOrientationBallAngleVerticalAmb4",
41
+ "SpinAxis3dSeamOrientationBallXAmb2", "SpinAxis3dSeamOrientationBallAngleVerticalAmb3",
42
+ "SpinAxis3dSeamOrientationBallAngleHorizontalAmb3", "SpinAxis3dSeamOrientationBallXAmb3",
43
+ "SpinAxis3dSeamOrientationBallYAmb3", "SpinAxis3dSeamOrientationBallZAmb3",
44
+ "SpinAxis3dSeamOrientationBallZAmb4", "GameDate"
45
+ )
46
+
47
+ # Pitch colors for visualization (Coastal Carolina theme)
48
+ pitch_colors <- c(
49
+ "Fastball" = '#FA8072',
50
+ "Four-Seam" = '#FA8072',
51
+ "Sinker" = "#fdae61",
52
+ "Slider" = "#A020F0",
53
+ "Sweeper" = "magenta",
54
+ "Curveball" = '#2c7bb6',
55
+ "ChangeUp" = '#90EE90',
56
+ "Splitter" = '#90EE32',
57
+ "Cutter" = "red",
58
+ "Knuckleball" = "#FFB4B4",
59
+ "Other" = "#D3D3D3"
60
+ )
61
+
62
+ # Function to convert date formats
63
+ convert_date_format <- function(date_string, output_format = "yyyy") {
64
+ if (is.null(date_string) || length(date_string) == 0) return(NA_character_)
65
+ if (inherits(date_string, "Date") || inherits(date_string, "POSIXct")) {
66
+ if (is.na(date_string)) return(NA_character_)
67
+ parsed_date <- as.Date(date_string)
68
+ if (output_format == "mdyy") {
69
+ return(gsub("/0", "/", gsub("^0", "", format(parsed_date, "%m/%d/%y"))))
70
+ } else {
71
+ return(format(parsed_date, "%Y-%m-%d"))
72
+ }
73
+ }
74
+ if (is.na(date_string) || identical(as.character(date_string), "")) {
75
+ return(NA_character_)
76
+ }
77
+
78
+ date_string <- as.character(date_string)
79
+
80
+ parsed_date <- NULL
81
+
82
+ if (grepl("^\\d{4}-\\d{2}-\\d{2}$", date_string)) {
83
+ parsed_date <- tryCatch({
84
+ as.Date(date_string, format = "%Y-%m-%d")
85
+ }, error = function(e) NULL)
86
+ }
87
+
88
+ if (is.null(parsed_date) && grepl("^\\d{1,2}/\\d{1,2}/\\d{4}$", date_string)) {
89
+ parsed_date <- tryCatch({
90
+ as.Date(date_string, format = "%m/%d/%Y")
91
+ }, error = function(e) NULL)
92
+ }
93
+
94
+ if (is.null(parsed_date) && grepl("^\\d{1,2}/\\d{1,2}/\\d{2}$", date_string)) {
95
+ parsed_date <- tryCatch({
96
+ as.Date(date_string, format = "%m/%d/%y")
97
+ }, error = function(e) NULL)
98
+ }
99
+
100
+ if (!is.null(parsed_date) && !is.na(parsed_date)) {
101
+ if (output_format == "mdyy") {
102
+ return(format(parsed_date, "%m/%d/%y") %>%
103
+ gsub("^0", "", .) %>%
104
+ gsub("/0", "/", .))
105
+ } else {
106
+ return(format(parsed_date, "%Y-%m-%d"))
107
+ }
108
+ }
109
+
110
+ return(date_string)
111
+ }
112
+
113
+ convert_date_columns <- function(df, output_format = "yyyy") {
114
+ date_columns <- c("Date", "GameDate", "UTCDate", "LocalDateTime")
115
+
116
+ for (col in date_columns) {
117
+ if (col %in% names(df)) {
118
+ col_data <- df[[col]]
119
+
120
+ if (inherits(col_data, "Date") || inherits(col_data, "POSIXct")) {
121
+ if (output_format == "mdyy") {
122
+ df[[col]] <- ifelse(is.na(col_data), NA_character_,
123
+ gsub("/0", "/", gsub("^0", "", format(as.Date(col_data), "%m/%d/%y"))))
124
+ } else {
125
+ df[[col]] <- ifelse(is.na(col_data), NA_character_,
126
+ format(as.Date(col_data), "%Y-%m-%d"))
127
+ }
128
+ } else {
129
+ df[[col]] <- sapply(df[[col]], function(x) convert_date_format(x, output_format), USE.NAMES = FALSE)
130
+ }
131
+ }
132
+ }
133
+
134
+ return(df)
135
+ }
136
+
137
+ # Read uploaded file: CSV or Parquet, with row-limit enforcement
138
+ read_uploaded_file <- function(filepath, filename, header = TRUE, sep = ",", quote = '"') {
139
+ ext <- tolower(tools::file_ext(filename))
140
+
141
+ if (ext == "parquet") {
142
+ df <- as.data.frame(arrow::read_parquet(filepath))
143
+ } else {
144
+ df <- read.csv(filepath,
145
+ header = header,
146
+ sep = sep,
147
+ quote = quote,
148
+ stringsAsFactors = FALSE)
149
+ }
150
+
151
+ if (nrow(df) > MAX_UPLOAD_ROWS) {
152
+ stop(paste0("File contains ", format(nrow(df), big.mark = ","), " rows which exceeds the ",
153
+ format(MAX_UPLOAD_ROWS, big.mark = ","), " row limit. Please upload a smaller file."))
154
+ }
155
+
156
+ return(df)
157
+ }
158
+
159
+ # Function to parse bat tracking JSON
160
+ parse_bat_tracking_json <- function(json_path) {
161
+ tryCatch({
162
+ json_data <- fromJSON(json_path, simplifyVector = FALSE)
163
+
164
+ game_reference <- json_data$GameReference
165
+ session_id <- json_data$SessionId
166
+
167
+ plays <- json_data$Plays
168
+
169
+ if (length(plays) == 0) {
170
+ return(list(
171
+ success = TRUE,
172
+ data = NULL,
173
+ game_reference = game_reference,
174
+ message = "JSON parsed but contains no bat tracking plays (empty Plays array)"
175
+ ))
176
+ }
177
+
178
+ bat_tracking_df <- data.frame(
179
+ PitchUID = sapply(plays, function(p) p$PitchUID),
180
+ BatSpeed_Sensor = sapply(plays, function(p) p$BatSpeed),
181
+ VerticalAttackAngle_Sensor = sapply(plays, function(p) p$VerticalAttackAngle),
182
+ HorizontalAttackAngle_Sensor = sapply(plays, function(p) p$HorizontalAttackAngle),
183
+ BatTracking_PlayId = sapply(plays, function(p) p$PlayId),
184
+ BatTracking_Time = sapply(plays, function(p) p$Time),
185
+ stringsAsFactors = FALSE
186
+ )
187
+
188
+ return(list(
189
+ success = TRUE,
190
+ data = bat_tracking_df,
191
+ game_reference = game_reference,
192
+ session_id = session_id,
193
+ plays_count = length(plays),
194
+ message = paste("Successfully parsed", length(plays), "bat tracking play(s)")
195
+ ))
196
+
197
+ }, error = function(e) {
198
+ return(list(
199
+ success = FALSE,
200
+ data = NULL,
201
+ message = paste("Error parsing JSON:", e$message)
202
+ ))
203
+ })
204
+ }
205
+
206
+ merge_with_bat_tracking <- function(csv_data, bat_tracking_data) {
207
+ if (is.null(bat_tracking_data) || nrow(bat_tracking_data) == 0) {
208
+ return(list(
209
+ data = csv_data,
210
+ matched = 0,
211
+ total_bat = 0,
212
+ message = "No bat tracking data to merge"
213
+ ))
214
+ }
215
+
216
+ if (!"PitchUID" %in% names(csv_data)) {
217
+ return(list(
218
+ data = csv_data,
219
+ matched = 0,
220
+ total_bat = nrow(bat_tracking_data),
221
+ message = "CSV does not contain PitchUID column - cannot merge"
222
+ ))
223
+ }
224
+
225
+ merged_data <- csv_data %>%
226
+ left_join(bat_tracking_data, by = "PitchUID")
227
+
228
+ matched_count <- sum(!is.na(merged_data$BatSpeed_Sensor))
229
+
230
+ if ("BatSpeed" %in% names(merged_data)) {
231
+ merged_data <- merged_data %>%
232
+ mutate(BatSpeed = ifelse(is.na(BatSpeed) & !is.na(BatSpeed_Sensor),
233
+ BatSpeed_Sensor, BatSpeed))
234
+ }
235
+
236
+ if ("VerticalAttackAngle" %in% names(merged_data)) {
237
+ merged_data <- merged_data %>%
238
+ mutate(VerticalAttackAngle = ifelse(is.na(VerticalAttackAngle) & !is.na(VerticalAttackAngle_Sensor),
239
+ VerticalAttackAngle_Sensor, VerticalAttackAngle))
240
+ }
241
+
242
+ if ("HorizontalAttackAngle" %in% names(merged_data)) {
243
+ merged_data <- merged_data %>%
244
+ mutate(HorizontalAttackAngle = ifelse(is.na(HorizontalAttackAngle) & !is.na(HorizontalAttackAngle_Sensor),
245
+ HorizontalAttackAngle_Sensor, HorizontalAttackAngle))
246
+ }
247
+
248
+ return(list(
249
+ data = merged_data,
250
+ matched = matched_count,
251
+ total_bat = nrow(bat_tracking_data),
252
+ message = paste("Merged successfully:", matched_count, "of", nrow(bat_tracking_data), "bat tracking records matched")
253
+ ))
254
+ }
255
+
256
+
257
+ clean_college_data <- function(data, teams = NA){
258
+
259
+ data <- data %>%
260
+ mutate(PlayResult = ifelse(PlayResult %in% c("HomeRun", "homerun"), "Homerun", PlayResult),
261
+ Batter = sub("(.*),\\s*(.*)", "\\2 \\1", Batter),
262
+ Pitcher = sub("(.*),\\s*(.*)", "\\2 \\1", Pitcher),
263
+ Catcher = sub("(.*),\\s*(.*)", "\\2 \\1", Catcher))
264
+
265
+
266
+ col <- colnames(data)
267
+
268
+ if ("Top/Bottom" %in% col){
269
+ data <- data %>%
270
+ rename(`Top.Bottom` = `Top/Bottom`)
271
+ }
272
+
273
+ numeric_columns <- c("PitchNo", "PAofInning", "PitchofPA", "PitcherId", "BatterId", "Inning", "Outs", "Balls",
274
+ "Strikes", "OutsOnPlay", "RunsScored", "RelSpeed", "VertRelAngle", "HorzRelAngle", "SpinRate",
275
+ "SpinAxis", "RelHeight", "RelSide", "Extension", "VertBreak", "InducedVertBreak", "HorzBreak",
276
+ "PlateLocHeight", "PlateLocSide", "ZoneSpeed", "VertApprAngle", "HorzApprAngle", "ZoneTime",
277
+ "ExitSpeed", "Angle", "Direction", "HitSpinRate", "Distance", "Bearing", "HangTime",
278
+ "LastTrackedDistance", "pfxx", "pfxz", "x0", "y0", "z0", "vx0", "vz0", "vy0", "ax0", "ay0",
279
+ "az0", "EffectiveVelo", "MaxHeight", "SpeedDrop", "ContactPositionX", "ContactPositionY",
280
+ "ContactPositionZ", "HomeTeamForeignID", "AwayTeamForeignID", "CatcherId", "ThrowSpeed",
281
+ "PopTime", "ExchangeTime", "TimeToBase")
282
+
283
+ data <- data %>%
284
+ mutate(across(any_of(numeric_columns), as.numeric),
285
+ PlateLocHeight = if ("PlateLocHeight" %in% names(.)) 12 * PlateLocHeight else PlateLocHeight,
286
+ PlateLocSide = if ("PlateLocSide" %in% names(.)) 12 * PlateLocSide else PlateLocSide)
287
+
288
+
289
+ data <- data %>%
290
+ mutate(TaggedPitchType = case_when(
291
+ TaggedPitchType == "FourSeamFastBall" ~ "Fastball",
292
+ TaggedPitchType %in% c("TwoSeamFastBall", "OneSeamFastBall") ~ "Sinker",
293
+ TaggedPitchType == "ChangeUp" ~ "Changeup",
294
+ TaggedPitchType == "Undefined" ~ "Other",
295
+ T ~ TaggedPitchType
296
+ ))
297
+
298
+ data <- data %>%
299
+ mutate(
300
+ is_csw = case_when(
301
+ PitchCall %in% c("StrikeSwinging", "StrikeCalled") ~ 1,
302
+ TRUE ~ 0
303
+ ),
304
+ is_swing = case_when(
305
+ PitchCall %in% c("StrikeSwinging", "FoulBallNotFieldable", "InPlay",
306
+ "FoulBallFieldable", "FoulBall") ~ 1,
307
+ TRUE ~ 0
308
+ ),
309
+ is_whiff = case_when(
310
+ PitchCall == "StrikeSwinging" & is_swing == 1 ~ 1,
311
+ PitchCall != "StrikeSwinging" & is_swing == 1 ~ 0,
312
+ TRUE ~ NA_real_
313
+ ),
314
+ in_zone = case_when(
315
+ PlateLocSide > 9.975 | PlateLocSide < -9.975 |
316
+ PlateLocHeight > 40 | PlateLocHeight < 20 ~ 0,
317
+ TRUE ~ 1
318
+ ),
319
+ chase = case_when(
320
+ is_swing == 1 & in_zone == 0 ~ 1,
321
+ is_swing == 0 & in_zone == 0 ~ 0,
322
+ TRUE ~ NA_real_
323
+ ),
324
+ in_zone_whiff = case_when(
325
+ is_swing == 1 & in_zone == 1 & is_whiff == 1 ~ 1,
326
+ is_swing == 1 & in_zone == 1 & is_whiff == 0 ~ 0,
327
+ TRUE ~ NA_real_
328
+ ),
329
+ is_hit = case_when(
330
+ PlayResult %in% c("Single", "Double", "Triple", "Homerun", "HomeRun") & PitchCall == "InPlay" ~ 1,
331
+ !PlayResult %in% c("Single", "Double", "Triple", "Homerun", "HomeRun") & PitchCall == "InPlay" ~ 0,
332
+ KorBB == "Strikeout" ~ 0,
333
+ PlayResult %in% c("StrikeoutSwinging", "StrikeoutLooking") ~ 0,
334
+ TRUE ~ NA_real_
335
+ ),
336
+ slg = case_when(
337
+ PitchCall == "InPlay" & PlayResult == "Single" ~ 1,
338
+ PitchCall == "InPlay" & PlayResult == "Double" ~ 2,
339
+ PitchCall == "InPlay" & PlayResult == "Triple" ~ 3,
340
+ PitchCall == "InPlay" & PlayResult %in% c("Homerun", "HomeRun") ~ 4,
341
+ !PlayResult %in% c("Single", "Double", "Triple", "Homerun", "HomeRun") & PitchCall == "InPlay" ~ 0,
342
+ KorBB == "Strikeout" ~ 0,
343
+ PlayResult %in% c("StrikeoutSwinging", "StrikeoutLooking") ~ 0,
344
+ TRUE ~ NA_real_
345
+ ),
346
+ on_base = case_when(
347
+ PitchCall == "InPlay" & PlayResult %in% c("Single", "Double", "Triple", "Homerun", "HomeRun") ~ 1,
348
+ PitchCall %in% c("HitByPitch") | KorBB == "Walk" ~ 1,
349
+ PitchCall == "InPlay" & PlayResult %in% c("Out", "Error", "FieldersChoice") & PlayResult != "Sacrifice" ~ 0,
350
+ KorBB == "Strikeout" ~ 0,
351
+ PlayResult %in% c("StrikeoutSwinging", "StrikeoutLooking") ~ 0,
352
+ TRUE ~ NA_real_
353
+ ),
354
+ is_hard_hit = case_when(
355
+ ExitSpeed >= 95 & PitchCall == "InPlay" ~ 1,
356
+ ExitSpeed < 95 & PitchCall == "InPlay" ~ 0,
357
+ TRUE ~ NA_real_
358
+ ),
359
+ woba = case_when(
360
+ PitchCall == "InPlay" & PlayResult == "Single" ~ 0.95,
361
+ PitchCall == "InPlay" & PlayResult == "Double" ~ 1.24,
362
+ PitchCall == "InPlay" & PlayResult == "Triple" ~ 1.47,
363
+ PitchCall == "InPlay" & PlayResult %in% c("Homerun", "HomeRun") ~ 1.71,
364
+ KorBB == "Walk" ~ 0.82,
365
+ PitchCall %in% c("HitByPitch") ~ 0.85,
366
+ KorBB == "Strikeout" ~ 0,
367
+ PlayResult %in% c("StrikeoutSwinging", "StrikeoutLooking") ~ 0,
368
+ PitchCall == "InPlay" & !PlayResult %in% c("Single", "Double" ,"Triple" ,"Homerun", "HomeRun") ~ 0,
369
+ TRUE ~ NA_real_
370
+ ),
371
+ wobacon = case_when(
372
+ PitchCall == "InPlay" & PlayResult == "Single" ~ 0.95,
373
+ PitchCall == "InPlay" & PlayResult == "Double" ~ 1.24,
374
+ PitchCall == "InPlay" & PlayResult == "Triple" ~ 1.47,
375
+ PitchCall == "InPlay" & PlayResult %in% c("Homerun", "HomeRun") ~ 1.71,
376
+ PitchCall == "InPlay" & !PlayResult %in% c("Single", "Double" ,"Triple" ,"Homerun", "HomeRun") ~ 0,
377
+ TRUE ~ NA_real_
378
+ ),
379
+ is_plate_appearance = ifelse(
380
+ PitchCall %in% c("InPlay", "HitByPitch") | KorBB %in% c("Strikeout", "Walk") | PlayResult %in% c("StrikeoutSwinging", "StrikeoutLooking"), 1, 0
381
+ ),
382
+ is_at_bat = case_when(
383
+ PitchCall == "InPlay" & !PlayResult %in% c("StolenBase", "Sacrifice", "CaughtStealing", "Undefined") ~ 1,
384
+ KorBB == "Strikeout" ~ 1,
385
+ PlayResult %in% c("StrikeoutSwinging", "StrikeoutLooking") ~ 1,
386
+ TRUE ~ 0
387
+ ),
388
+ is_walk = case_when(
389
+ is_plate_appearance == 1 & KorBB == "Walk" ~ 1,
390
+ is_plate_appearance == 1 & KorBB != "Walk" ~ 0,
391
+ TRUE ~ NA_real_
392
+ ),
393
+ is_k = case_when(
394
+ is_at_bat == 1 & KorBB == "Strikeout" ~ 1,
395
+ is_at_bat == 1 & KorBB != "Strikeout" ~ 0,
396
+ PlayResult %in% c("StrikeoutSwinging", "StrikeoutLooking") ~ 1,
397
+ TRUE ~ NA_real_
398
+ ),
399
+ is_put_away = case_when(
400
+ Strikes == 2 & KorBB == "Strikeout" ~ 1,
401
+ Strikes == 2 & KorBB != "Strikeout" ~ 0,
402
+ Strikes == 2 & PlayResult %in% c("StrikeoutSwinging", "StrikeoutLooking") ~ 1,
403
+ TRUE ~ NA_real_
404
+ ),
405
+ OutsOnPlay = ifelse(KorBB == "Strikeout" | PlayResult %in% c("StrikeoutSwinging", "StrikeoutLooking"), OutsOnPlay + 1, OutsOnPlay)
406
+ )
407
+
408
+ data <- data %>%
409
+ mutate(event_type = case_when(
410
+ PitchCall %in% c("StrikeSwinging", "StrkeSwinging") ~ "Whiff",
411
+ PitchCall %in% c("StriekC", "StrikeCalled") ~ "Called Strike",
412
+ PitchCall %in% c("FoulBallFieldable", "FoulBall", "FoulBallNotFieldable",
413
+ "FouldBallNotFieldable") ~ "Foul Ball",
414
+ PitchCall %in% c("BallCalled", "BallinDirt", "BallIntentional", "BalIntentional") ~ "Ball",
415
+ PitchCall == "HitByPitch" ~ "HBP",
416
+ PitchCall == "InPlay" & PlayResult %in% c("Out", "FieldersChoice",
417
+ "Error", "error",
418
+ "Sacrifice") ~ "Field Out",
419
+ PitchCall == "InPlay" & PlayResult == "Single" ~ "Single",
420
+ PitchCall == "InPlay" & PlayResult == "Double" ~ "Double",
421
+ PitchCall == "InPlay" & PlayResult == "Triple" ~ "Triple",
422
+ PitchCall == "InPlay" & PlayResult == "Homerun" ~ "Home Run",
423
+ T ~ NA
424
+ )) %>%
425
+ left_join(rv, by = "event_type")
426
+
427
+ data <- data %>%
428
+ dplyr::select(
429
+ -PitchLastMeasuredX, -PitchLastMeasuredY, -PitchLastMeasuredZ,
430
+ -starts_with("PitchTrajectory"),
431
+ -HitSpinAxis,
432
+ -starts_with("HitTrajectory"),
433
+ -PitchReleaseConfidence, -PitchLocationConfidence, -PitchMovementConfidence,
434
+ -HitLaunchConfidence, -HitLandingConfidence,
435
+ -CatcherThrowCatchConfidence, -CatcherThrowReleaseConfidence, -CatcherThrowLocationConfidence,
436
+ -PositionAt110X, -PositionAt110Y, -PositionAt110Z
437
+ )
438
+
439
+ return(data)
440
+ }
441
+
442
+
443
+
444
+ predict_stuffplus <- function(data) {
445
+
446
+ predict_data <- data %>%
447
+ mutate(RelSide = case_when(
448
+ PitcherThrows == "Right" ~ RelSide,
449
+ PitcherThrows == "Left" ~ -RelSide,
450
+ PitcherThrows %in% c("Both", "Undefined") & RelSide > 0 ~ RelSide,
451
+ PitcherThrows %in% c("Both", "Undefined") & RelSide < 0 ~ -RelSide),
452
+ ax0 = case_when(
453
+ PitcherThrows == "Right" ~ ax0,
454
+ PitcherThrows == "Left" ~ -ax0,
455
+ PitcherThrows %in% c("Both", "Undefined") & ax0 > 0 ~ ax0,
456
+ PitcherThrows %in% c("Both", "Undefined") & ax0 < 0 ~ -ax0),
457
+ PlateLocHeight = PlateLocHeight*12,
458
+ PlateLocSide = PlateLocSide*12,
459
+ ax0 = -ax0) %>%
460
+ group_by(Pitcher, GameID) %>%
461
+ mutate(
462
+ primary_pitch = case_when(
463
+ any(TaggedPitchType == "Fastball") ~ "Fastball",
464
+ any(TaggedPitchType == "Sinker") ~ "Sinker",
465
+ TRUE ~ names(sort(table(TaggedPitchType), decreasing = TRUE))[1]
466
+ )
467
+ ) %>%
468
+ group_by(Pitcher, GameID, primary_pitch) %>%
469
+ mutate(
470
+ primary_az0 = mean(az0[TaggedPitchType == primary_pitch], na.rm = TRUE),
471
+ primary_velo = mean(RelSpeed[TaggedPitchType == primary_pitch], na.rm = TRUE)
472
+ ) %>%
473
+ ungroup() %>%
474
+ mutate(az0_diff = az0 - primary_az0,
475
+ velo_diff = RelSpeed - primary_velo)
476
+
477
+
478
+ df_processed <- bake(stuffplus_recipe, new_data = predict_data)
479
+
480
+ df_matrix <- as.matrix(df_processed)
481
+
482
+ raw_stuff <- predict(stuffplus_model, df_matrix)
483
+
484
+ data$raw_stuff <- raw_stuff
485
+
486
+
487
+ data <- data %>%
488
+ mutate(stuff_plus = ((raw_stuff - 0.004424894) / 0.01010482) * 10 + 100)
489
+
490
+ return(data)
491
+
492
+ }
493
+
494
+
495
+ login_ui <- fluidPage(
496
+ tags$style(HTML("
497
+ body {
498
+ background-color: #f0f4f8;
499
+ font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
500
+ color: #006F71;
501
+ }
502
+ .login-container {
503
+ max-width: 360px;
504
+ margin: 120px auto;
505
+ background: #A27752;
506
+ padding: 30px 25px;
507
+ border-radius: 8px;
508
+ box-shadow: 0 4px 15px #A1A1A4;
509
+ text-align: center;
510
+ color: white;
511
+ }
512
+ .login-message {
513
+ margin-bottom: 20px;
514
+ font-size: 14px;
515
+ color: #ffffff;
516
+ font-weight: 600;
517
+ }
518
+ .btn-primary {
519
+ background-color: #006F71 !important;
520
+ border-color: #006F71 !important;
521
+ color: white !important;
522
+ font-weight: bold;
523
+ width: 100%;
524
+ margin-top: 10px;
525
+ box-shadow: 0 2px 5px #006F71;
526
+ transition: background-color 0.3s ease;
527
+ }
528
+ .btn-primary:hover {
529
+ background-color: #006F71 !important;
530
+ border-color: #A27752 !important;
531
+ }
532
+ .form-control {
533
+ border-radius: 4px;
534
+ border: 1.5px solid #006F71 !important;
535
+ color: #006F71;
536
+ font-weight: 600;
537
+ }
538
+ ")),
539
+
540
+ div(class = "login-container",
541
+ tags$img(src = "https://upload.wikimedia.org/wikipedia/en/thumb/e/ef/Coastal_Carolina_Chanticleers_logo.svg/1200px-Coastal_Carolina_Chanticleers_logo.svg.png", height = "150px"),
542
+ passwordInput("password", "Password:"),
543
+ actionButton("login", "Login"),
544
+ textOutput("wrong_pass")
545
+ )
546
+ )
547
+
548
+ # UI
549
+ app_ui <- fluidPage(
550
+ tags$head(
551
+ tags$style(HTML("
552
+ body, table, .gt_table {
553
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
554
+ Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji',
555
+ 'Segoe UI Symbol';
556
+ }
557
+
558
+ /* Header styling */
559
+ .app-header {
560
+ display: flex;
561
+ justify-content: space-between;
562
+ align-items: center;
563
+ padding: 20px 40px;
564
+ background: #ffffff;
565
+ border-bottom: 3px solid darkcyan;
566
+ margin-bottom: 20px;
567
+ }
568
+
569
+ .header-logo-left, .header-logo-right {
570
+ width: 120px;
571
+ height: auto;
572
+ }
573
+
574
+ .header-logo-center {
575
+ max-width: 400px;
576
+ height: auto;
577
+ }
578
+
579
+ @media (max-width: 768px) {
580
+ .app-header {
581
+ flex-direction: column;
582
+ padding: 15px 20px;
583
+ }
584
+ .header-logo-left, .header-logo-right {
585
+ width: 80px;
586
+ }
587
+ .header-logo-center {
588
+ max-width: 250px;
589
+ margin: 10px 0;
590
+ }
591
+ }
592
+
593
+ /* Gradient pill tabs styling */
594
+ .nav-tabs {
595
+ border: none !important;
596
+ border-radius: 50px;
597
+ padding: 6px 12px;
598
+ margin: 20px auto 0;
599
+ max-width: 100%;
600
+ background: linear-gradient(135deg, #d4edeb 0%, #e8ddd0 50%, #d4edeb 100%);
601
+ box-shadow: 0 4px 16px rgba(0,139,139,.12), inset 0 2px 4px rgba(255,255,255,.6);
602
+ border: 1px solid rgba(0,139,139,.2);
603
+ position: relative;
604
+ overflow-x: auto;
605
+ -webkit-overflow-scrolling: touch;
606
+ display: flex;
607
+ justify-content: center;
608
+ align-items: center;
609
+ flex-wrap: wrap;
610
+ gap: 6px;
611
+ }
612
+
613
+ .nav-tabs::-webkit-scrollbar {
614
+ height: 0;
615
+ }
616
+
617
+ .nav-tabs::before {
618
+ content: '';
619
+ position: absolute;
620
+ inset: 0;
621
+ pointer-events: none;
622
+ border-radius: 50px;
623
+ background: linear-gradient(135deg, rgba(255,255,255,.4), transparent);
624
+ }
625
+
626
+ .nav-tabs > li > a {
627
+ color: darkcyan !important;
628
+ border: none !important;
629
+ border-radius: 50px !important;
630
+ background: transparent !important;
631
+ font-weight: 700;
632
+ font-size: 14.5px;
633
+ padding: 10px 22px;
634
+ white-space: nowrap;
635
+ letter-spacing: 0.2px;
636
+ transition: all 0.2s ease;
637
+ }
638
+
639
+ .nav-tabs > li > a:hover {
640
+ color: #006666 !important;
641
+ background: rgba(255,255,255,.5) !important;
642
+ transform: translateY(-1px);
643
+ }
644
+
645
+ .nav-tabs > li.active > a,
646
+ .nav-tabs > li.active > a:focus,
647
+ .nav-tabs > li.active > a:hover {
648
+ background: linear-gradient(135deg, #008b8b 0%, #20b2aa 30%, #00ced1 50%, #20b2aa 70%, #008b8b 100%) !important;
649
+ color: #fff !important;
650
+ text-shadow: 0 1px 2px rgba(0,0,0,.2);
651
+ 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);
652
+ border: 1px solid rgba(255,255,255,.3) !important;
653
+ }
654
+
655
+ .nav-tabs > li > a:focus {
656
+ outline: 3px solid rgba(205,133,63,.6);
657
+ outline-offset: 2px;
658
+ }
659
+
660
+ .tab-content {
661
+ background: linear-gradient(135deg, rgba(255,255,255,.95), rgba(248,249,250,.95));
662
+ border-radius: 20px;
663
+ padding: 25px;
664
+ margin-top: 14px;
665
+ box-shadow: 0 15px 40px rgba(0,139,139,.1);
666
+ backdrop-filter: blur(15px);
667
+ border: 1px solid rgba(0,139,139,.1);
668
+ position: relative;
669
+ overflow: hidden;
670
+ }
671
+
672
+ .tab-content::before {
673
+ content: '';
674
+ position: absolute;
675
+ left: 0;
676
+ right: 0;
677
+ top: 0;
678
+ height: 4px;
679
+ background: linear-gradient(90deg, darkcyan, peru, darkcyan);
680
+ background-size: 200% 100%;
681
+ animation: shimmer 3s linear infinite;
682
+ }
683
+
684
+ @keyframes shimmer {
685
+ 0% { background-position: -200% 0; }
686
+ 100% { background-position: 200% 0; }
687
+ }
688
+
689
+ #name {
690
+ font-size: 10px;
691
+ font-weight: 500;
692
+ text-align: right;
693
+ margin-bottom: 8px;
694
+ color: #6C757D;
695
+ letter-spacing: 0.5px;
696
+ }
697
+
698
+ h3 {
699
+ color: black;
700
+ font-weight: 600;
701
+ margin-top: 25px;
702
+ margin-bottom: 15px;
703
+ padding-bottom: 8px;
704
+ border-bottom: 2px solid #007BA7;
705
+ }
706
+
707
+ h4 {
708
+ color: darkcyan;
709
+ font-weight: 500;
710
+ margin-top: 20px;
711
+ margin-bottom: 12px;
712
+ }
713
+
714
+ h1 {
715
+ color: #007BA7;
716
+ font-weight: 700;
717
+ margin-bottom: 20px;
718
+ text-shadow: 1px 1px 2px rgba(0,0,0,0.1);
719
+ }
720
+
721
+ label {
722
+ font-weight: 500;
723
+ color: peru;
724
+ margin-bottom: 5px;
725
+ }
726
+
727
+ .plot-title {
728
+ text-align: center;
729
+ font-weight: 600;
730
+ color: #2C3E50;
731
+ margin-bottom: 10px;
732
+ }
733
+
734
+ .dataTables_wrapper .dataTables_length,
735
+ .dataTables_wrapper .dataTables_filter,
736
+ .dataTables_wrapper .dataTables_info,
737
+ .dataTables_wrapper .dataTables_paginate {
738
+ color: #2C3E50;
739
+ }
740
+
741
+ thead th {
742
+ background-color: #F8F9FA;
743
+ color: #2C3E50;
744
+ font-weight: 600;
745
+ text-align: center !important;
746
+ padding: 10px !important;
747
+ }
748
+
749
+ .brand-teal { color: darkcyan; }
750
+ .brand-bronze { color: peru; }
751
+
752
+ /* Bat tracking upload box styling */
753
+ .bat-tracking-box {
754
+ background: linear-gradient(135deg, #e8f4f8 0%, #f0e6d3 100%);
755
+ border: 2px dashed darkcyan;
756
+ border-radius: 15px;
757
+ padding: 20px;
758
+ margin-top: 15px;
759
+ }
760
+
761
+ .merge-status-box {
762
+ background: #f8f9fa;
763
+ border-left: 4px solid darkcyan;
764
+ padding: 15px;
765
+ border-radius: 0 10px 10px 0;
766
+ margin-top: 15px;
767
+ }
768
+
769
+ .merge-success {
770
+ border-left-color: #28a745;
771
+ background: #d4edda;
772
+ }
773
+
774
+ .merge-warning {
775
+ border-left-color: #ffc107;
776
+ background: #fff3cd;
777
+ }
778
+
779
+ .merge-error {
780
+ border-left-color: #dc3545;
781
+ background: #f8d7da;
782
+ }
783
+
784
+ /* Download section styling */
785
+ .download-option-box {
786
+ background: linear-gradient(135deg, #e8f4f8 0%, #f0e6d3 100%);
787
+ border: 1px solid rgba(0,139,139,.2);
788
+ border-radius: 12px;
789
+ padding: 20px;
790
+ margin-bottom: 15px;
791
+ }
792
+ "))
793
+ ),
794
+
795
+ # Header with three logos
796
+ div(class = "app-header",
797
+ tags$img(src = "https://i.imgur.com/7vx5Ci8.png", class = "header-logo-left", alt = "Logo Left"),
798
+ tags$img(src = "https://i.imgur.com/c3zCSg6.png", class = "header-logo-center", alt = "Main Logo"),
799
+ tags$img(src = "https://i.imgur.com/VbrN5WV.png", class = "header-logo-right", alt = "Logo Right")
800
+ ),
801
+
802
+ tabsetPanel(id = "main_tabs",
803
+
804
+ # Upload & Process Tab
805
+ tabPanel(
806
+ "Upload & Process",
807
+ fluidRow(
808
+ column(6,
809
+ h3("1. Upload TrackMan CSV or Parquet"),
810
+ fileInput("file", "Choose CSV or Parquet File (max 5,000 rows)",
811
+ accept = c(".csv", ".parquet")),
812
+ fluidRow(
813
+ column(3,
814
+ checkboxInput("header", "Header", TRUE)
815
+ ),
816
+ column(3,
817
+ radioButtons("sep", "Separator",
818
+ choices = c(Comma = ",", Semicolon = ";", Tab = "\t"),
819
+ selected = ",", inline = TRUE)
820
+ ),
821
+ column(3,
822
+ radioButtons("quote", "Quote",
823
+ choices = c(None = "", "Double Quote" = '"', "Single Quote" = "'"),
824
+ selected = '"', inline = TRUE)
825
+ ),
826
+ column(3,
827
+ radioButtons("date_format", "Date Output Format",
828
+ choices = c("YYYY-MM-DD" = "yyyy", "M/D/YY" = "mdyy"),
829
+ selected = "yyyy")
830
+ )
831
+ ),
832
+ p(style = "color: #666; font-size: 12px;",
833
+ "CSV options (Header, Separator, Quote) are ignored for Parquet files."),
834
+ verbatimTextOutput("csv_status")
835
+ ),
836
+ column(6,
837
+ div(class = "bat-tracking-box",
838
+ h3("2. Upload Bat Tracking JSON (Optional)", style = "margin-top: 0;"),
839
+ fileInput("json_file", "Choose Bat Tracking JSON File", accept = c(".json")),
840
+ p(style = "color: #666; font-size: 12px;",
841
+ "Upload the corresponding _battracking.json file to merge bat speed and attack angle data."),
842
+ verbatimTextOutput("json_status"),
843
+ uiOutput("merge_status_ui")
844
+ )
845
+ )
846
+ ),
847
+
848
+ hr(),
849
+
850
+ fluidRow(
851
+ column(8,
852
+ h3("3. Columns to Remove"),
853
+ p("Select which columns to remove from your dataset:"),
854
+ checkboxGroupInput("columns_to_remove", "Remove These Columns:",
855
+ choices = columns_to_remove,
856
+ selected = columns_to_remove)
857
+ ),
858
+ column(4,
859
+ h3("Quick Actions"),
860
+ br(),
861
+ actionButton("select_all_cols", "Select All", class = "btn-primary"),
862
+ br(), br(),
863
+ actionButton("deselect_all_cols", "Deselect All", class = "btn-default"),
864
+ br(), br(),
865
+ actionButton("select_spinaxis", "Select SpinAxis3d Columns", class = "btn-info"),
866
+ br(), br(),
867
+ h4("Processing Summary"),
868
+ verbatimTextOutput("process_summary")
869
+ )
870
+ )
871
+ ),
872
+
873
+ # Bat Tracking Details Tab
874
+ tabPanel(
875
+ "Bat Tracking Data",
876
+ fluidRow(
877
+ column(12,
878
+ h3("Bat Tracking Merge Details"),
879
+ uiOutput("bat_tracking_details"),
880
+ hr(),
881
+ h4("Pitches with Bat Tracking Data"),
882
+ DT::dataTableOutput("bat_tracking_table")
883
+ )
884
+ )
885
+ ),
886
+
887
+ # Preview Data Tab
888
+ tabPanel(
889
+ "Preview Data",
890
+ fluidRow(
891
+ column(12,
892
+ h3("Data Preview"),
893
+ DT::dataTableOutput("preview")
894
+ )
895
+ )
896
+ ),
897
+
898
+ # Pitch Movement Chart Tab
899
+ tabPanel(
900
+ "Pitch Movement Chart",
901
+ fluidRow(
902
+ column(3,
903
+ selectInput("pitcher_select", "Select Pitcher:",
904
+ choices = NULL, selected = NULL)
905
+ ),
906
+ column(3,
907
+ h4("Selection Mode:"),
908
+ radioButtons("selection_mode", "",
909
+ choices = list("Single Click" = "single", "Drag Select" = "drag"),
910
+ selected = "single", inline = TRUE)
911
+ ),
912
+ column(6,
913
+ conditionalPanel(
914
+ condition = "input.selection_mode == 'drag'",
915
+ h4("Bulk Edit:"),
916
+ fluidRow(
917
+ column(8,
918
+ selectInput("bulk_pitch_type", "Change all selected to:",
919
+ choices = c("Fastball", "Sinker", "Cutter", "Slider",
920
+ "Curveball", "ChangeUp", "Splitter", "Knuckleball", "Sweeper", "Other"),
921
+ selected = "Fastball")
922
+ ),
923
+ column(4,
924
+ br(),
925
+ actionButton("apply_bulk_change", "Apply to Selected", class = "btn-success")
926
+ )
927
+ )
928
+ )
929
+ )
930
+ ),
931
+
932
+ fluidRow(
933
+ column(8,
934
+ h3("Interactive Pitch Movement Analysis"),
935
+ plotOutput("movement_plot", height = "600px",
936
+ click = "plot_click",
937
+ brush = brushOpts(id = "plot_brush"),
938
+ hover = hoverOpts(id = "plot_hover", delay = 100)),
939
+
940
+ h4("Instructions:"),
941
+ p(strong("Single Click Mode:"), "Click on any point to edit one pitch type at a time via popup modal."),
942
+ p(strong("Drag Select Mode:"), "Click and drag to select multiple points, then use the dropdown to change them all at once."),
943
+ conditionalPanel(
944
+ condition = "input.selection_mode == 'drag'",
945
+ div(style = "background-color: #f0f8ff; padding: 10px; border-radius: 5px; margin: 10px 0; border-left: 4px solid darkcyan;",
946
+ h4("Selected Points:", style = "margin-top: 0; color: darkcyan;"),
947
+ textOutput("selection_info")
948
+ )
949
+ ),
950
+ verbatimTextOutput("hover_info"),
951
+ verbatimTextOutput("click_info")
952
+ ),
953
+
954
+ column(4,
955
+ h3("Pitch Metrics Summary"),
956
+ DT::dataTableOutput("movement_stats")
957
+ )
958
+ ),
959
+
960
+ # ── NEW: Rule-Based Pitch Retagging Panel ──
961
+ hr(),
962
+ fluidRow(
963
+ column(12,
964
+ div(
965
+ style = "background: linear-gradient(135deg, #e8f4f8 0%, #f0e6d3 100%); border: 2px solid darkcyan; border-radius: 15px; padding: 20px; margin-top: 10px;",
966
+ h3("Rule-Based Pitch Retagging", style = "margin-top: 0; color: darkcyan; border-bottom: 2px solid darkcyan; padding-bottom: 8px;"),
967
+ p(style = "color: #666; font-size: 13px; margin-bottom: 15px;",
968
+ "Set filters to match pitches, then retag them all at once. ",
969
+ "Only pitches matching ALL non-empty filters for the selected pitcher will be changed."),
970
+
971
+ fluidRow(
972
+ # Column 1: Source pitch type + date range
973
+ column(3,
974
+ h4("Pitch Type & Date", style = "color: peru; margin-top: 0;"),
975
+ selectInput("retag_from_type", "Current Pitch Type:",
976
+ choices = c("(Any)" = "", "Fastball", "Sinker", "Cutter",
977
+ "Slider", "Curveball", "ChangeUp", "Splitter",
978
+ "Knuckleball", "Sweeper", "Other"),
979
+ selected = ""),
980
+ dateRangeInput("retag_dates", "Date Range (optional):",
981
+ start = NA, end = NA),
982
+ textInput("retag_pitch_no_range", "Pitch Numbers (e.g. 1-50):", value = "")
983
+ ),
984
+
985
+ # Column 2: Velocity & spin filters
986
+ column(3,
987
+ h4("Velocity & Spin", style = "color: peru; margin-top: 0;"),
988
+ fluidRow(
989
+ column(6, numericInput("retag_velo_min", "Velo Min:", value = NA)),
990
+ column(6, numericInput("retag_velo_max", "Velo Max:", value = NA))
991
+ ),
992
+ fluidRow(
993
+ column(6, numericInput("retag_spin_min", "Spin Min:", value = NA)),
994
+ column(6, numericInput("retag_spin_max", "Spin Max:", value = NA))
995
+ )
996
+ ),
997
+
998
+ # Column 3: Movement filters
999
+ column(3,
1000
+ h4("Movement (inches)", style = "color: peru; margin-top: 0;"),
1001
+ fluidRow(
1002
+ column(6, numericInput("retag_ivb_min", "IVB Min:", value = NA)),
1003
+ column(6, numericInput("retag_ivb_max", "IVB Max:", value = NA))
1004
+ ),
1005
+ fluidRow(
1006
+ column(6, numericInput("retag_hb_min", "HB Min:", value = NA)),
1007
+ column(6, numericInput("retag_hb_max", "HB Max:", value = NA))
1008
+ ),
1009
+ fluidRow(
1010
+ column(6, numericInput("retag_vb_min", "VBreak Min:", value = NA)),
1011
+ column(6, numericInput("retag_vb_max", "VBreak Max:", value = NA))
1012
+ )
1013
+ ),
1014
+
1015
+ # Column 4: Target type + actions
1016
+ column(3,
1017
+ h4("Retag To", style = "color: peru; margin-top: 0;"),
1018
+ selectInput("retag_to_type", "New Pitch Type:",
1019
+ choices = c("Fastball", "Sinker", "Cutter", "Slider",
1020
+ "Curveball", "ChangeUp", "Splitter",
1021
+ "Knuckleball", "Sweeper", "Other"),
1022
+ selected = "Sweeper"),
1023
+ br(),
1024
+ actionButton("retag_preview_btn", "Preview Matches",
1025
+ class = "btn-info", style = "width: 100%;"),
1026
+ br(), br(),
1027
+ actionButton("retag_apply_btn", "Apply Retag",
1028
+ class = "btn-success", style = "width: 100%; font-weight: bold;"),
1029
+ br(), br(),
1030
+ actionButton("retag_clear_btn", "Clear Filters",
1031
+ class = "btn-default", style = "width: 100%;")
1032
+ )
1033
+ ),
1034
+
1035
+ # Preview results area
1036
+ div(
1037
+ style = "margin-top: 15px;",
1038
+ uiOutput("retag_preview_summary"),
1039
+ DT::dataTableOutput("retag_preview_table")
1040
+ )
1041
+ )
1042
+ )
1043
+ )
1044
+ ),
1045
+
1046
+ # Download Tab
1047
+ tabPanel(
1048
+ "Download",
1049
+ fluidRow(
1050
+ column(12,
1051
+ h3("Download Processed Data"),
1052
+
1053
+ div(class = "download-option-box",
1054
+ fluidRow(
1055
+ column(5,
1056
+ textInput("download_filename", "File Name (without extension):",
1057
+ value = paste0("app_ready_COA_", Sys.Date()))
1058
+ ),
1059
+ column(3,
1060
+ radioButtons("download_format", "Export Format:",
1061
+ choices = c("CSV" = "csv", "Parquet" = "parquet"),
1062
+ selected = "csv", inline = TRUE)
1063
+ ),
1064
+ column(4,
1065
+ br(),
1066
+ downloadButton("downloadData", "Download", class = "btn-success btn-lg")
1067
+ )
1068
+ )
1069
+ ),
1070
+
1071
+ br(),
1072
+ h4("Data Summary:"),
1073
+ verbatimTextOutput("data_summary")
1074
+ )
1075
+ )
1076
+ ),
1077
+ #Scrape Tab
1078
+ tabPanel(
1079
+ "Scraping",
1080
+ fluidRow(
1081
+ column(2,
1082
+ h4("Data Source", style = "color: darkcyan; border-bottom: 2px solid darkcyan; padding-bottom: 6px;"),
1083
+ radioButtons("scrape_source", NULL,
1084
+ choices = c("TrackMan PBP" = "pbp",
1085
+ "TrackMan Positional" = "pos",
1086
+ "NCAA Scoreboard" = "ncaa"),
1087
+ selected = "pbp")
1088
+ ),
1089
+
1090
+ column(4,
1091
+ h3("Controls"),
1092
+
1093
+ dateInput("start_date", "Start Date:", value = Sys.Date() - 1),
1094
+ dateInput("end_date", "End Date:", value = Sys.Date() - 1),
1095
+
1096
+ uiOutput("scrape_options"),
1097
+
1098
+ br(),
1099
+ actionButton("scrape_btn", "Scrape Data", class = "btn-primary"),
1100
+ br(), br(),
1101
+ downloadButton("download_scrape", "Download CSV"),
1102
+ actionButton("upload_hf_btn", "Upload to HF Dataset", class = "btn-download")
1103
+ ),
1104
+
1105
+ column(6,
1106
+ h3("Progress"),
1107
+ verbatimTextOutput("scrape_status"),
1108
+ hr(),
1109
+ h3("Data Preview"),
1110
+ DT::dataTableOutput("scrape_preview")
1111
+ )
1112
+ )
1113
+ )
1114
+ ),
1115
+
1116
+ # Modal for editing pitch type
1117
+ bsModal("pitchEditModal", "Edit Pitch Type", "triggerModal", size = "medium",
1118
+ div(style = "padding: 20px;",
1119
+ h4("Selected Pitch Details:", style = "color: darkcyan;"),
1120
+ verbatimTextOutput("selected_pitch_info"),
1121
+ br(),
1122
+ selectInput("modal_new_pitch_type", "Change Pitch Type To:",
1123
+ choices = c("Fastball", "Sinker", "Cutter", "Slider",
1124
+ "Curveball", "ChangeUp", "Splitter", "Knuckleball", "Sweeper", "Other"),
1125
+ selected = "Fastball"),
1126
+ br(),
1127
+ actionButton("update_pitch", "Update Pitch Type", class = "btn-primary btn-lg"),
1128
+ actionButton("cancel_edit", "Cancel", class = "btn-default")
1129
+ )
1130
+ )
1131
+ )
1132
+
1133
+ ui <- fluidPage(
1134
+ uiOutput("page")
1135
+ )
1136
+
1137
+ # Server
1138
+ server <- function(input, output, session) {
1139
+
1140
+ logged_in <- reactiveVal(FALSE)
1141
+ uploaded_file_type <- reactiveVal("csv")
1142
+
1143
+ output$page <- renderUI({
1144
+ if (logged_in()) {
1145
+ app_ui
1146
+ } else {
1147
+ login_ui
1148
+ }
1149
+ })
1150
+
1151
+ observeEvent(input$login, {
1152
+ if (input$password == PASSWORD) {
1153
+ logged_in(TRUE)
1154
+ output$wrong_pass <- renderText("")
1155
+ } else {
1156
+ output$wrong_pass <- renderText("Incorrect password, please try again.")
1157
+ }
1158
+ })
1159
+
1160
+
1161
+ # Reactive values
1162
+ processed_data <- reactiveVal(NULL)
1163
+ plot_data <- reactiveVal(NULL)
1164
+ selected_pitch <- reactiveVal(NULL)
1165
+ selected_points <- reactiveVal(NULL)
1166
+ csv_data_raw <- reactiveVal(NULL)
1167
+ bat_tracking_parsed <- reactiveVal(NULL)
1168
+ merge_result <- reactiveVal(NULL)
1169
+ scraped_data <- reactiveVal(NULL)
1170
+ scrape_polling <- reactiveVal(FALSE)
1171
+
1172
+ scrape_status_msg <- reactiveVal("Ready.")
1173
+
1174
+ # Handle column selection buttons
1175
+ observeEvent(input$select_all_cols, {
1176
+ updateCheckboxGroupInput(session, "columns_to_remove",
1177
+ selected = columns_to_remove)
1178
+ })
1179
+
1180
+ observeEvent(input$deselect_all_cols, {
1181
+ updateCheckboxGroupInput(session, "columns_to_remove", selected = character(0))
1182
+ })
1183
+
1184
+ observeEvent(input$select_spinaxis, {
1185
+ spinaxis_cols <- columns_to_remove[grepl("SpinAxis3d", columns_to_remove)]
1186
+ updateCheckboxGroupInput(session, "columns_to_remove", selected = spinaxis_cols)
1187
+ })
1188
+
1189
+ # Shared helper: process raw data into processed_data / plot_data
1190
+ run_processing <- function(df) {
1191
+ # Merge bat tracking if available
1192
+ if (!is.null(bat_tracking_parsed()) && !is.null(bat_tracking_parsed()$data)) {
1193
+ result <- merge_with_bat_tracking(df, bat_tracking_parsed()$data)
1194
+ merge_result(result)
1195
+ df <- result$data
1196
+ }
1197
+
1198
+ selected_cols_to_remove <- input$columns_to_remove %||% character(0)
1199
+ processed_df <- df
1200
+
1201
+ if (length(selected_cols_to_remove) > 0) {
1202
+ columns_to_drop <- intersect(names(df), selected_cols_to_remove)
1203
+ if (length(columns_to_drop) > 0) {
1204
+ processed_df <- processed_df %>% select(-all_of(columns_to_drop))
1205
+ }
1206
+ }
1207
+
1208
+ processed_df <- processed_df %>% distinct()
1209
+
1210
+ processed_data(processed_df)
1211
+ plot_data(processed_df)
1212
+
1213
+ return(processed_df)
1214
+ }
1215
+
1216
+ # Re-process data when date format changes
1217
+ observeEvent(input$date_format, {
1218
+ req(input$file)
1219
+
1220
+ tryCatch({
1221
+ df <- read_uploaded_file(input$file$datapath, input$file$name,
1222
+ input$header, input$sep, input$quote)
1223
+
1224
+ df <- convert_date_columns(df, input$date_format)
1225
+ csv_data_raw(df)
1226
+ run_processing(df)
1227
+
1228
+ showNotification(
1229
+ paste("Date format updated to:", if (input$date_format == "mdyy") "M/D/YY" else "YYYY-MM-DD"),
1230
+ type = "message", duration = 3
1231
+ )
1232
+
1233
+ }, error = function(e) {
1234
+ showNotification(paste("Error updating date format:", e$message), type = "error")
1235
+ })
1236
+ }, ignoreInit = TRUE)
1237
+
1238
+ # Process uploaded file (CSV or Parquet)
1239
+ observeEvent(input$file, {
1240
+ req(input$file)
1241
+
1242
+ tryCatch({
1243
+ ext <- tolower(tools::file_ext(input$file$name))
1244
+ uploaded_file_type(ext)
1245
+
1246
+ df <- read_uploaded_file(input$file$datapath, input$file$name,
1247
+ input$header, input$sep, input$quote)
1248
+
1249
+ df <- convert_date_columns(df, input$date_format)
1250
+ csv_data_raw(df)
1251
+
1252
+ processed_df <- run_processing(df)
1253
+
1254
+ # Update pitcher choices
1255
+ if ("Pitcher" %in% names(processed_df)) {
1256
+ pitcher_choices <- sort(unique(processed_df$Pitcher[!is.na(processed_df$Pitcher)]))
1257
+ updateSelectInput(session, "pitcher_select", choices = pitcher_choices, selected = pitcher_choices[1])
1258
+ }
1259
+
1260
+ # Auto-populate download filename from uploaded file
1261
+ base_name <- tools::file_path_sans_ext(input$file$name)
1262
+ updateTextInput(session, "download_filename", value = paste0(base_name, "_processed"))
1263
+
1264
+ format_label <- if (ext == "parquet") "Parquet" else "CSV"
1265
+ showNotification(
1266
+ paste0(format_label, " loaded: ", nrow(df), " rows x ", ncol(df), " columns"),
1267
+ type = "message", duration = 3
1268
+ )
1269
+
1270
+ }, error = function(e) {
1271
+ showNotification(paste("Error processing file:", e$message), type = "error")
1272
+ })
1273
+ })
1274
+
1275
+ # Process uploaded JSON file
1276
+ observeEvent(input$json_file, {
1277
+ req(input$json_file)
1278
+
1279
+ tryCatch({
1280
+ parsed <- parse_bat_tracking_json(input$json_file$datapath)
1281
+ bat_tracking_parsed(parsed)
1282
+
1283
+ if (!is.null(csv_data_raw()) && parsed$success && !is.null(parsed$data)) {
1284
+ result <- merge_with_bat_tracking(csv_data_raw(), parsed$data)
1285
+ merge_result(result)
1286
+
1287
+ df <- result$data
1288
+ selected_cols_to_remove <- input$columns_to_remove %||% character(0)
1289
+
1290
+ if (length(selected_cols_to_remove) > 0) {
1291
+ columns_to_drop <- intersect(names(df), selected_cols_to_remove)
1292
+ if (length(columns_to_drop) > 0) {
1293
+ df <- df %>% select(-all_of(columns_to_drop))
1294
+ }
1295
+ }
1296
+
1297
+ df <- df %>% distinct()
1298
+
1299
+ processed_data(df)
1300
+ plot_data(df)
1301
+
1302
+ showNotification(result$message, type = "message", duration = 5)
1303
+ }
1304
+
1305
+ }, error = function(e) {
1306
+ showNotification(paste("Error processing JSON:", e$message), type = "error")
1307
+ })
1308
+ })
1309
+
1310
+ # CSV/Parquet status output
1311
+ output$csv_status <- renderText({
1312
+ if (is.null(input$file)) {
1313
+ return("No file uploaded yet. Accepts CSV or Parquet (max 5,000 rows).")
1314
+ }
1315
+
1316
+ if (is.null(csv_data_raw())) {
1317
+ return("Processing file...")
1318
+ }
1319
+
1320
+ df <- csv_data_raw()
1321
+ ext <- uploaded_file_type()
1322
+ format_label <- if (ext == "parquet") "Parquet" else "CSV"
1323
+ game_id <- if ("GameID" %in% names(df)) unique(df$GameID)[1] else "Unknown"
1324
+ date_fmt <- if (input$date_format == "mdyy") "M/D/YY" else "YYYY-MM-DD"
1325
+
1326
+ paste(
1327
+ paste0("\u2713 ", format_label, " loaded successfully!"),
1328
+ paste(" Game ID:", game_id),
1329
+ paste(" Rows:", nrow(df)),
1330
+ paste(" Columns:", ncol(df)),
1331
+ paste("\u2713 Date format:", date_fmt),
1332
+ sep = "\n"
1333
+ )
1334
+ })
1335
+
1336
+ # JSON status output
1337
+ output$json_status <- renderText({
1338
+ if (is.null(input$json_file)) {
1339
+ return("No JSON file uploaded yet.")
1340
+ }
1341
+
1342
+ parsed <- bat_tracking_parsed()
1343
+ if (is.null(parsed)) {
1344
+ return("Processing JSON...")
1345
+ }
1346
+
1347
+ if (!parsed$success) {
1348
+ return(paste("\u2717", parsed$message))
1349
+ }
1350
+
1351
+ paste(
1352
+ "\u2713 JSON parsed successfully!",
1353
+ paste(" Game Reference:", parsed$game_reference),
1354
+ paste(" Plays found:", parsed$plays_count %||% 0),
1355
+ sep = "\n"
1356
+ )
1357
+ })
1358
+
1359
+ # Merge status UI
1360
+ output$merge_status_ui <- renderUI({
1361
+ result <- merge_result()
1362
+ parsed <- bat_tracking_parsed()
1363
+ csv <- csv_data_raw()
1364
+
1365
+ if (is.null(parsed) || is.null(csv)) {
1366
+ return(NULL)
1367
+ }
1368
+
1369
+ if (!parsed$success) {
1370
+ return(div(class = "merge-status-box merge-error",
1371
+ h4("Merge Status", style = "margin-top: 0; color: #721c24;"),
1372
+ p(parsed$message)
1373
+ ))
1374
+ }
1375
+
1376
+ if (is.null(parsed$data) || is.null(result)) {
1377
+ csv_game <- if ("GameID" %in% names(csv)) unique(csv$GameID)[1] else NULL
1378
+ json_game <- parsed$game_reference
1379
+
1380
+ if (!is.null(csv_game) && !is.null(json_game) && csv_game != json_game) {
1381
+ return(div(class = "merge-status-box merge-warning",
1382
+ h4("\u26A0 Game ID Mismatch", style = "margin-top: 0; color: #856404;"),
1383
+ p(paste("CSV Game:", csv_game)),
1384
+ p(paste("JSON Game:", json_game)),
1385
+ p("Files may be from different games!")
1386
+ ))
1387
+ }
1388
+
1389
+ return(div(class = "merge-status-box merge-warning",
1390
+ h4("No Data to Merge", style = "margin-top: 0; color: #856404;"),
1391
+ p(parsed$message)
1392
+ ))
1393
+ }
1394
+
1395
+ csv_game <- if ("GameID" %in% names(csv)) unique(csv$GameID)[1] else NULL
1396
+ json_game <- parsed$game_reference
1397
+ game_match <- is.null(csv_game) || is.null(json_game) || csv_game == json_game
1398
+
1399
+ if (result$matched > 0) {
1400
+ div(class = "merge-status-box merge-success",
1401
+ h4("\u2713 Merge Successful!", style = "margin-top: 0; color: #155724;"),
1402
+ p(paste("Matched:", result$matched, "of", result$total_bat, "bat tracking records")),
1403
+ if (!game_match) p(style = "color: #856404;", "\u26A0 Note: Game IDs differ but PitchUIDs matched")
1404
+ )
1405
+ } else {
1406
+ div(class = "merge-status-box merge-warning",
1407
+ h4("\u26A0 No Matches Found", style = "margin-top: 0; color: #856404;"),
1408
+ p(paste("0 of", result$total_bat, "bat tracking records matched")),
1409
+ if (!game_match) p(paste("Game ID mismatch: CSV =", csv_game, ", JSON =", json_game))
1410
+ )
1411
+ }
1412
+ })
1413
+
1414
+ # Bat tracking details
1415
+ output$bat_tracking_details <- renderUI({
1416
+ parsed <- bat_tracking_parsed()
1417
+ result <- merge_result()
1418
+
1419
+ if (is.null(parsed)) {
1420
+ return(div(
1421
+ p("No bat tracking JSON file uploaded."),
1422
+ p("Upload a _battracking.json file in the 'Upload & Process' tab to see bat tracking data here.")
1423
+ ))
1424
+ }
1425
+
1426
+ if (!parsed$success) {
1427
+ return(div(class = "alert alert-danger", parsed$message))
1428
+ }
1429
+
1430
+ if (is.null(parsed$data)) {
1431
+ return(div(class = "alert alert-warning",
1432
+ h4("Empty Bat Tracking File"),
1433
+ p(parsed$message),
1434
+ p("The JSON file was valid but contained no swing data in the Plays array.")
1435
+ ))
1436
+ }
1437
+
1438
+ div(
1439
+ div(class = "row",
1440
+ div(class = "col-md-4",
1441
+ div(class = "well",
1442
+ h4("Game Reference"),
1443
+ p(parsed$game_reference)
1444
+ )
1445
+ ),
1446
+ div(class = "col-md-4",
1447
+ div(class = "well",
1448
+ h4("Total Swings Tracked"),
1449
+ p(style = "font-size: 24px; font-weight: bold; color: darkcyan;", parsed$plays_count)
1450
+ )
1451
+ ),
1452
+ div(class = "col-md-4",
1453
+ div(class = "well",
1454
+ h4("Matched to CSV"),
1455
+ p(style = "font-size: 24px; font-weight: bold; color: #28a745;",
1456
+ if (!is.null(result)) result$matched else "N/A")
1457
+ )
1458
+ )
1459
+ )
1460
+ )
1461
+ })
1462
+
1463
+ # Bat tracking table
1464
+ output$bat_tracking_table <- DT::renderDataTable({
1465
+ df <- processed_data()
1466
+
1467
+ if (is.null(df)) {
1468
+ return(NULL)
1469
+ }
1470
+
1471
+ if ("BatSpeed_Sensor" %in% names(df)) {
1472
+ bat_rows <- df %>%
1473
+ filter(!is.na(BatSpeed_Sensor)) %>%
1474
+ select(
1475
+ any_of(c("PitchNo", "Time", "Pitcher", "Batter", "TaggedPitchType", "PitchCall",
1476
+ "RelSpeed", "ExitSpeed", "Angle",
1477
+ "BatSpeed", "BatSpeed_Sensor",
1478
+ "VerticalAttackAngle", "VerticalAttackAngle_Sensor",
1479
+ "HorizontalAttackAngle", "HorizontalAttackAngle_Sensor"))
1480
+ )
1481
+
1482
+ if (nrow(bat_rows) == 0) {
1483
+ return(NULL)
1484
+ }
1485
+
1486
+ DT::datatable(bat_rows,
1487
+ options = list(scrollX = TRUE, pageLength = 10),
1488
+ rownames = FALSE) %>%
1489
+ DT::formatRound(columns = intersect(names(bat_rows),
1490
+ c("BatSpeed_Sensor", "VerticalAttackAngle_Sensor",
1491
+ "HorizontalAttackAngle_Sensor", "RelSpeed",
1492
+ "ExitSpeed", "Angle")),
1493
+ digits = 1)
1494
+ } else {
1495
+ return(NULL)
1496
+ }
1497
+ })
1498
+
1499
+ # Processing summary
1500
+ output$process_summary <- renderText({
1501
+ if (is.null(input$file)) {
1502
+ return("No file uploaded yet.")
1503
+ }
1504
+
1505
+ if (is.null(processed_data())) {
1506
+ return("Processing...")
1507
+ }
1508
+
1509
+ df <- processed_data()
1510
+ original_df <- csv_data_raw()
1511
+ selected_cols_to_remove <- input$columns_to_remove %||% character(0)
1512
+ removed_cols <- intersect(selected_cols_to_remove, names(original_df))
1513
+ result <- merge_result()
1514
+ ext <- uploaded_file_type()
1515
+ format_label <- if (ext == "parquet") "Parquet" else "CSV"
1516
+
1517
+ removed_cols_text <- if (length(removed_cols) > 0) {
1518
+ paste("\u2713 Removed columns:", length(removed_cols))
1519
+ } else {
1520
+ "\u2713 Removed columns: 0"
1521
+ }
1522
+
1523
+ bat_tracking_text <- if (!is.null(result) && result$matched > 0) {
1524
+ paste("\u2713 Bat tracking merged:", result$matched, "pitches")
1525
+ } else if (!is.null(bat_tracking_parsed())) {
1526
+ "\u26A0 Bat tracking: No matches"
1527
+ } else {
1528
+ "\u25CB Bat tracking: Not uploaded"
1529
+ }
1530
+
1531
+ summary_text <- paste(
1532
+ paste0("\u2713 ", format_label, " file processed successfully!"),
1533
+ paste("\u2713 Original columns:", ncol(original_df)),
1534
+ paste("\u2713 Final columns:", ncol(df)),
1535
+ paste("\u2713 Rows processed:", nrow(df)),
1536
+ removed_cols_text,
1537
+ bat_tracking_text,
1538
+ "\u2713 Duplicates removed",
1539
+ paste("\u2713 Date format:", if (input$date_format == "mdyy") "M/D/YY" else "YYYY-MM-DD"),
1540
+ sep = "\n"
1541
+ )
1542
+
1543
+ return(summary_text)
1544
+ })
1545
+
1546
+ # Preview table
1547
+ output$preview <- DT::renderDataTable({
1548
+ req(processed_data())
1549
+
1550
+ DT::datatable(processed_data(),
1551
+ options = list(scrollX = TRUE, pageLength = 10),
1552
+ filter = "top")
1553
+ })
1554
+
1555
+ # Movement plot
1556
+ output$movement_plot <- renderPlot({
1557
+ req(plot_data(), input$pitcher_select)
1558
+
1559
+ pitcher_data <- plot_data() %>%
1560
+ filter(Pitcher == input$pitcher_select) %>%
1561
+ filter(!is.na(TaggedPitchType), TaggedPitchType != "Other",
1562
+ !is.na(HorzBreak), !is.na(InducedVertBreak), !is.na(RelSpeed)) %>%
1563
+ mutate(pitch_id = row_number())
1564
+
1565
+ if (nrow(pitcher_data) == 0) {
1566
+ plot.new()
1567
+ text(0.5, 0.5, "No data available for selected pitcher", cex = 1.5)
1568
+ return()
1569
+ }
1570
+
1571
+ pitcher_data$color <- pitch_colors[pitcher_data$TaggedPitchType]
1572
+ pitcher_data$color[is.na(pitcher_data$color)] <- "#D3D3D3"
1573
+
1574
+ par(mar = c(5, 5, 4, 8), xpd = TRUE)
1575
+ plot(pitcher_data$HorzBreak, pitcher_data$InducedVertBreak,
1576
+ col = pitcher_data$color,
1577
+ pch = 19, cex = 1.5,
1578
+ xlim = c(-25, 25), ylim = c(-25, 25),
1579
+ xlab = "Horizontal Break (inches)",
1580
+ ylab = "Induced Vertical Break (inches)",
1581
+ main = paste("Pitch Movement Chart -", input$pitcher_select))
1582
+
1583
+ grid(nx = NULL, ny = NULL, col = "lightgray", lty = 1, lwd = 0.5)
1584
+ abline(h = 0, col = "gray", lty = 2, lwd = 1)
1585
+ abline(v = 0, col = "gray", lty = 2, lwd = 1)
1586
+
1587
+ for (r in c(6, 12, 18, 24)) {
1588
+ circle_x <- r * cos(seq(0, 2*pi, length.out = 100))
1589
+ circle_y <- r * sin(seq(0, 2*pi, length.out = 100))
1590
+ lines(circle_x, circle_y, col = "lightgray", lty = 3)
1591
+ }
1592
+
1593
+ if (input$selection_mode == "drag" && !is.null(selected_points())) {
1594
+ sel_points <- selected_points()
1595
+ points(sel_points$HorzBreak, sel_points$InducedVertBreak,
1596
+ pch = 21, cex = 2, col = "red", lwd = 3)
1597
+ }
1598
+
1599
+ unique_pitches <- unique(pitcher_data$TaggedPitchType)
1600
+ unique_colors <- pitch_colors[unique_pitches]
1601
+ legend("topright", inset = c(-0.15, 0),
1602
+ legend = unique_pitches,
1603
+ col = unique_colors,
1604
+ pch = 19,
1605
+ cex = 0.8,
1606
+ title = "Pitch Type")
1607
+ })
1608
+
1609
+ # Handle plot clicks (single mode only)
1610
+ observeEvent(input$plot_click, {
1611
+ req(plot_data(), input$pitcher_select, input$plot_click)
1612
+
1613
+ if (input$selection_mode != "single") return()
1614
+
1615
+ pitcher_data <- plot_data() %>%
1616
+ filter(Pitcher == input$pitcher_select) %>%
1617
+ filter(!is.na(TaggedPitchType), TaggedPitchType != "Other",
1618
+ !is.na(HorzBreak), !is.na(InducedVertBreak), !is.na(RelSpeed)) %>%
1619
+ mutate(pitch_id = row_number())
1620
+
1621
+ if (nrow(pitcher_data) == 0) return()
1622
+
1623
+ click_x <- input$plot_click$x
1624
+ click_y <- input$plot_click$y
1625
+
1626
+ distances <- sqrt((pitcher_data$HorzBreak - click_x)^2 +
1627
+ (pitcher_data$InducedVertBreak - click_y)^2)
1628
+
1629
+ closest_idx <- which.min(distances)
1630
+
1631
+ if (min(distances) <= 2) {
1632
+ clicked_pitch <- pitcher_data[closest_idx, ]
1633
+
1634
+ full_data <- plot_data() %>% filter(Pitcher == input$pitcher_select)
1635
+ original_row <- which(full_data$HorzBreak == clicked_pitch$HorzBreak &
1636
+ full_data$InducedVertBreak == clicked_pitch$InducedVertBreak &
1637
+ full_data$RelSpeed == clicked_pitch$RelSpeed)[1]
1638
+
1639
+ selected_pitch(list(
1640
+ pitcher = input$pitcher_select,
1641
+ row_in_pitcher_data = original_row,
1642
+ data = clicked_pitch,
1643
+ original_type = clicked_pitch$TaggedPitchType
1644
+ ))
1645
+
1646
+ updateSelectInput(session, "modal_new_pitch_type",
1647
+ selected = clicked_pitch$TaggedPitchType)
1648
+
1649
+ showModal(modalDialog(
1650
+ title = "Edit Pitch Type",
1651
+ div(style = "padding: 20px;",
1652
+ h4("Selected Pitch Details:", style = "color: darkcyan;"),
1653
+ verbatimTextOutput("selected_pitch_info"),
1654
+ br(),
1655
+ selectInput("modal_new_pitch_type", "Change Pitch Type To:",
1656
+ choices = c("Fastball", "Sinker", "Cutter", "Slider",
1657
+ "Curveball", "ChangeUp", "Splitter", "Knuckleball", "Sweeper","Other"),
1658
+ selected = clicked_pitch$TaggedPitchType),
1659
+ br(),
1660
+ actionButton("update_pitch", "Update Pitch Type", class = "btn-primary btn-lg"),
1661
+ actionButton("cancel_edit", "Cancel", class = "btn-default")
1662
+ ),
1663
+ footer = NULL,
1664
+ size = "m",
1665
+ easyClose = TRUE
1666
+ ))
1667
+ }
1668
+ })
1669
+
1670
+ # Handle brush selection (drag mode)
1671
+ observeEvent(input$plot_brush, {
1672
+ req(plot_data(), input$pitcher_select, input$plot_brush)
1673
+
1674
+ if (input$selection_mode != "drag") return()
1675
+
1676
+ pitcher_data <- plot_data() %>%
1677
+ filter(Pitcher == input$pitcher_select) %>%
1678
+ filter(!is.na(TaggedPitchType), TaggedPitchType != "Other",
1679
+ !is.na(HorzBreak), !is.na(InducedVertBreak), !is.na(RelSpeed))
1680
+
1681
+ if (nrow(pitcher_data) == 0) return()
1682
+
1683
+ brush <- input$plot_brush
1684
+ brushed_points <- pitcher_data %>%
1685
+ filter(
1686
+ HorzBreak >= brush$xmin & HorzBreak <= brush$xmax &
1687
+ InducedVertBreak >= brush$ymin & InducedVertBreak <= brush$ymax
1688
+ )
1689
+
1690
+ if (nrow(brushed_points) > 0) {
1691
+ selected_points(brushed_points)
1692
+ } else {
1693
+ selected_points(NULL)
1694
+ }
1695
+ })
1696
+
1697
+ # Apply bulk change
1698
+ observeEvent(input$apply_bulk_change, {
1699
+ req(selected_points(), input$bulk_pitch_type)
1700
+
1701
+ sel_points <- selected_points()
1702
+
1703
+ if (nrow(sel_points) == 0) {
1704
+ showNotification("No points selected", type = "warning")
1705
+ return()
1706
+ }
1707
+
1708
+ current_data <- plot_data()
1709
+
1710
+ for (i in 1:nrow(sel_points)) {
1711
+ point <- sel_points[i, ]
1712
+ current_data <- current_data %>%
1713
+ mutate(TaggedPitchType = ifelse(
1714
+ Pitcher == input$pitcher_select &
1715
+ abs(HorzBreak - point$HorzBreak) < 0.01 &
1716
+ abs(InducedVertBreak - point$InducedVertBreak) < 0.01 &
1717
+ abs(RelSpeed - point$RelSpeed) < 0.01,
1718
+ input$bulk_pitch_type,
1719
+ TaggedPitchType
1720
+ ))
1721
+ }
1722
+
1723
+ plot_data(current_data)
1724
+ processed_data(current_data)
1725
+ selected_points(NULL)
1726
+
1727
+ showNotification(
1728
+ paste("Updated", nrow(sel_points), "pitches to", input$bulk_pitch_type),
1729
+ type = "message", duration = 3
1730
+ )
1731
+ })
1732
+
1733
+ # Selection info for drag mode
1734
+ output$selection_info <- renderText({
1735
+ if (input$selection_mode == "drag" && !is.null(selected_points())) {
1736
+ sel_points <- selected_points()
1737
+ pitch_counts <- table(sel_points$TaggedPitchType)
1738
+ paste(nrow(sel_points), "points selected:",
1739
+ paste(names(pitch_counts), "(", pitch_counts, ")", collapse = ", "))
1740
+ } else {
1741
+ "No points selected. Click and drag to select multiple pitches."
1742
+ }
1743
+ })
1744
+
1745
+ # Hover info
1746
+ output$hover_info <- renderText({
1747
+ req(input$plot_hover, plot_data(), input$pitcher_select)
1748
+
1749
+ pitcher_data <- plot_data() %>%
1750
+ filter(Pitcher == input$pitcher_select) %>%
1751
+ filter(!is.na(TaggedPitchType), TaggedPitchType != "Other",
1752
+ !is.na(HorzBreak), !is.na(InducedVertBreak), !is.na(RelSpeed))
1753
+
1754
+ if (nrow(pitcher_data) == 0) return("")
1755
+
1756
+ hover_x <- input$plot_hover$x
1757
+ hover_y <- input$plot_hover$y
1758
+
1759
+ distances <- sqrt((pitcher_data$HorzBreak - hover_x)^2 +
1760
+ (pitcher_data$InducedVertBreak - hover_y)^2)
1761
+
1762
+ if (min(distances) <= 2) {
1763
+ closest_idx <- which.min(distances)
1764
+ hover_pitch <- pitcher_data[closest_idx, ]
1765
+
1766
+ bat_info <- ""
1767
+ if ("BatSpeed_Sensor" %in% names(hover_pitch) && !is.na(hover_pitch$BatSpeed_Sensor)) {
1768
+ bat_info <- paste(" | Bat Speed:", round(hover_pitch$BatSpeed_Sensor, 1), "mph")
1769
+ }
1770
+
1771
+ paste("Hovering over:",
1772
+ paste("Type:", hover_pitch$TaggedPitchType),
1773
+ paste("Velocity:", round(hover_pitch$RelSpeed, 1), "mph"),
1774
+ paste("HB:", round(hover_pitch$HorzBreak, 1), "in"),
1775
+ paste("IVB:", round(hover_pitch$InducedVertBreak, 1), "in"),
1776
+ bat_info,
1777
+ sep = " | ")
1778
+ } else {
1779
+ ""
1780
+ }
1781
+ })
1782
+
1783
+ # Movement stats table
1784
+ output$movement_stats <- DT::renderDataTable({
1785
+ req(plot_data(), input$pitcher_select)
1786
+
1787
+ data <- plot_data()
1788
+
1789
+ movement_stats <- data %>%
1790
+ filter(Pitcher == input$pitcher_select) %>%
1791
+ filter(!is.na(HorzBreak), !is.na(InducedVertBreak), !is.na(TaggedPitchType)) %>%
1792
+ mutate(
1793
+ pitch_group = case_when(
1794
+ TaggedPitchType %in% c("Fastball", "FourSeamFastBall", "FourSeamFastB", "Four-Seam", "4-Seam") ~ "Fastball",
1795
+ TaggedPitchType %in% c("OneSeamFastBall", "TwoSeamFastBall", "Sinker", "Two-Seam", "One-Seam") ~ "Sinker",
1796
+ TaggedPitchType %in% c("ChangeUp", "Changeup") ~ "Changeup",
1797
+ TRUE ~ TaggedPitchType
1798
+ ),
1799
+ in_zone = ifelse("StrikeZoneIndicator" %in% names(.), StrikeZoneIndicator,
1800
+ ifelse(!is.na(PlateLocSide) & !is.na(PlateLocHeight) &
1801
+ PlateLocSide >= -0.95 & PlateLocSide <= 0.95 &
1802
+ PlateLocHeight >= 1.6 & PlateLocHeight <= 3.5, 1, 0)),
1803
+ is_whiff = ifelse("WhiffIndicator" %in% names(.), WhiffIndicator,
1804
+ ifelse(!is.na(PitchCall) & PitchCall == "StrikeSwinging", 1, 0)),
1805
+ chase = ifelse("Chaseindicator" %in% names(.), Chaseindicator,
1806
+ ifelse(!is.na(PitchCall) & !is.na(PlateLocSide) & !is.na(PlateLocHeight) &
1807
+ PitchCall %in% c("StrikeSwinging", "FoulBallNotFieldable", "FoulBall", "InPlay") &
1808
+ (PlateLocSide < -0.95 | PlateLocSide > 0.95 | PlateLocHeight < 1.6 | PlateLocHeight > 3.5), 1, 0))
1809
+ )
1810
+
1811
+ total_pitches <- nrow(movement_stats)
1812
+
1813
+ has_bat_speed <- "BatSpeed_Sensor" %in% names(movement_stats)
1814
+
1815
+ summary_stats <- movement_stats %>%
1816
+ group_by(`Pitch Type` = pitch_group) %>%
1817
+ summarise(
1818
+ Count = n(),
1819
+ `Usage%` = sprintf("%.1f%%", (n() / total_pitches) * 100),
1820
+ `Avg Velo` = sprintf("%.1f", mean(RelSpeed, na.rm = TRUE)),
1821
+ `Max Velo` = sprintf("%.1f", max(RelSpeed, na.rm = TRUE)),
1822
+ `Avg IVB` = sprintf("%.1f", mean(InducedVertBreak, na.rm = TRUE)),
1823
+ `Avg HB` = sprintf("%.1f", mean(HorzBreak, na.rm = TRUE)),
1824
+ `Avg Spin` = ifelse("SpinRate" %in% names(movement_stats),
1825
+ sprintf("%.0f", mean(SpinRate, na.rm = TRUE)),
1826
+ "\u2014"),
1827
+ `Avg Bat Speed` = if (has_bat_speed) {
1828
+ bat_vals <- BatSpeed_Sensor[!is.na(BatSpeed_Sensor)]
1829
+ if (length(bat_vals) > 0) sprintf("%.1f", mean(bat_vals)) else "\u2014"
1830
+ } else "\u2014",
1831
+ `Zone%` = sprintf("%.1f%%", round(mean(in_zone, na.rm = TRUE) * 100, 1)),
1832
+ `Whiff%` = sprintf("%.1f%%", round(mean(is_whiff, na.rm = TRUE) * 100, 1)),
1833
+ .groups = "drop"
1834
+ ) %>%
1835
+ arrange(desc(Count))
1836
+
1837
+ DT::datatable(summary_stats,
1838
+ options = list(pageLength = 15, dom = 't', scrollX = TRUE),
1839
+ rownames = FALSE) %>%
1840
+ DT::formatStyle(columns = names(summary_stats), fontSize = '12px')
1841
+ })
1842
+
1843
+ # Selected pitch info in modal
1844
+ output$selected_pitch_info <- renderText({
1845
+ pitch_info <- selected_pitch()
1846
+ if (!is.null(pitch_info)) {
1847
+ pitch_data <- pitch_info$data
1848
+
1849
+ info_lines <- c(
1850
+ paste("Pitcher:", pitch_info$pitcher),
1851
+ if ("PitchNo" %in% names(pitch_data) && !is.na(pitch_data$PitchNo)) paste("Pitch No:", pitch_data$PitchNo) else NULL,
1852
+ if ("Batter" %in% names(pitch_data) && !is.na(pitch_data$Batter)) paste("Batter:", pitch_data$Batter) else NULL,
1853
+ paste("Current Type:", pitch_data$TaggedPitchType),
1854
+ paste("Velocity:", round(pitch_data$RelSpeed, 1), "mph"),
1855
+ paste("Horizontal Break:", round(pitch_data$HorzBreak, 1), "inches"),
1856
+ paste("Induced Vertical Break:", round(pitch_data$InducedVertBreak, 1), "inches")
1857
+ )
1858
+
1859
+ if ("SpinRate" %in% names(pitch_data) && !is.na(pitch_data$SpinRate)) {
1860
+ info_lines <- c(info_lines, paste("Spin Rate:", round(pitch_data$SpinRate, 0), "rpm"))
1861
+ }
1862
+
1863
+ if ("BatSpeed_Sensor" %in% names(pitch_data) && !is.na(pitch_data$BatSpeed_Sensor)) {
1864
+ info_lines <- c(info_lines,
1865
+ paste("Bat Speed:", round(pitch_data$BatSpeed_Sensor, 1), "mph"),
1866
+ paste("Vertical Attack Angle:", round(pitch_data$VerticalAttackAngle_Sensor, 1), "\u00B0"),
1867
+ paste("Horizontal Attack Angle:", round(pitch_data$HorizontalAttackAngle_Sensor, 1), "\u00B0"))
1868
+ }
1869
+
1870
+ if ("Date" %in% names(pitch_data) && !is.na(pitch_data$Date)) {
1871
+ info_lines <- c(info_lines, paste("Date:", pitch_data$Date))
1872
+ }
1873
+
1874
+ return(paste(info_lines, collapse = "\n"))
1875
+ } else {
1876
+ return("No pitch selected")
1877
+ }
1878
+ })
1879
+
1880
+ # Update pitch type
1881
+ observeEvent(input$update_pitch, {
1882
+ pitch_info <- selected_pitch()
1883
+
1884
+ if (!is.null(pitch_info)) {
1885
+ current_data <- plot_data()
1886
+
1887
+ target_pitcher <- pitch_info$pitcher
1888
+ target_hb <- pitch_info$data$HorzBreak
1889
+ target_ivb <- pitch_info$data$InducedVertBreak
1890
+ target_velo <- pitch_info$data$RelSpeed
1891
+
1892
+ current_data <- current_data %>%
1893
+ mutate(TaggedPitchType = ifelse(
1894
+ Pitcher == target_pitcher &
1895
+ abs(HorzBreak - target_hb) < 0.01 &
1896
+ abs(InducedVertBreak - target_ivb) < 0.01 &
1897
+ abs(RelSpeed - target_velo) < 0.01,
1898
+ input$modal_new_pitch_type,
1899
+ TaggedPitchType
1900
+ ))
1901
+
1902
+ plot_data(current_data)
1903
+ processed_data(current_data)
1904
+
1905
+ removeModal()
1906
+
1907
+ showNotification(
1908
+ paste("Updated pitch from", pitch_info$original_type, "to", input$modal_new_pitch_type),
1909
+ type = "message", duration = 3
1910
+ )
1911
+
1912
+ selected_pitch(NULL)
1913
+ }
1914
+ })
1915
+
1916
+ # Cancel edit
1917
+ observeEvent(input$cancel_edit, {
1918
+ removeModal()
1919
+ selected_pitch(NULL)
1920
+ })
1921
+
1922
+ # ══════════════════════════════════════════════════════════════
1923
+ # Rule-Based Pitch Retagging — Server Logic
1924
+ # ══════════════════════════════════════════════════════════════
1925
+
1926
+ # Reactive: compute the set of pitches matching all active filters
1927
+ retag_matched <- reactive({
1928
+ req(plot_data(), input$pitcher_select)
1929
+
1930
+ df <- plot_data() %>%
1931
+ filter(Pitcher == input$pitcher_select)
1932
+
1933
+ # Filter: Current pitch type
1934
+ if (!is.null(input$retag_from_type) && input$retag_from_type != "") {
1935
+ df <- df %>% filter(TaggedPitchType == input$retag_from_type)
1936
+ }
1937
+
1938
+ # Filter: Date range
1939
+ if ("Date" %in% names(df)) {
1940
+ d_start <- input$retag_dates[1]
1941
+ d_end <- input$retag_dates[2]
1942
+ if (!is.na(d_start) && !is.na(d_end)) {
1943
+ df <- df %>%
1944
+ filter(!is.na(Date)) %>%
1945
+ mutate(.tmp_date = as.Date(Date)) %>%
1946
+ filter(.tmp_date >= as.Date(d_start), .tmp_date <= as.Date(d_end)) %>%
1947
+ select(-.tmp_date)
1948
+ }
1949
+ }
1950
+
1951
+ # Filter: Pitch number range (supports "1-50" or single number)
1952
+ if (!is.null(input$retag_pitch_no_range) && nzchar(trimws(input$retag_pitch_no_range))) {
1953
+ pn_text <- trimws(input$retag_pitch_no_range)
1954
+ if (grepl("^\\d+-\\d+$", pn_text)) {
1955
+ parts <- as.numeric(strsplit(pn_text, "-")[[1]])
1956
+ if ("PitchNo" %in% names(df)) {
1957
+ df <- df %>% filter(!is.na(PitchNo), PitchNo >= parts[1], PitchNo <= parts[2])
1958
+ }
1959
+ } else if (grepl("^\\d+$", pn_text)) {
1960
+ if ("PitchNo" %in% names(df)) {
1961
+ df <- df %>% filter(PitchNo == as.numeric(pn_text))
1962
+ }
1963
+ }
1964
+ }
1965
+
1966
+ # Filter: Velocity
1967
+ if (!is.na(input$retag_velo_min)) df <- df %>% filter(!is.na(RelSpeed), RelSpeed >= input$retag_velo_min)
1968
+ if (!is.na(input$retag_velo_max)) df <- df %>% filter(!is.na(RelSpeed), RelSpeed <= input$retag_velo_max)
1969
+
1970
+ # Filter: Spin Rate
1971
+ if ("SpinRate" %in% names(df)) {
1972
+ if (!is.na(input$retag_spin_min)) df <- df %>% filter(!is.na(SpinRate), SpinRate >= input$retag_spin_min)
1973
+ if (!is.na(input$retag_spin_max)) df <- df %>% filter(!is.na(SpinRate), SpinRate <= input$retag_spin_max)
1974
+ }
1975
+
1976
+ # Filter: Induced Vertical Break
1977
+ if (!is.na(input$retag_ivb_min)) df <- df %>% filter(!is.na(InducedVertBreak), InducedVertBreak >= input$retag_ivb_min)
1978
+ if (!is.na(input$retag_ivb_max)) df <- df %>% filter(!is.na(InducedVertBreak), InducedVertBreak <= input$retag_ivb_max)
1979
+
1980
+ # Filter: Horizontal Break
1981
+ if (!is.na(input$retag_hb_min)) df <- df %>% filter(!is.na(HorzBreak), HorzBreak >= input$retag_hb_min)
1982
+ if (!is.na(input$retag_hb_max)) df <- df %>% filter(!is.na(HorzBreak), HorzBreak <= input$retag_hb_max)
1983
+
1984
+ # Filter: Vertical Break
1985
+ if ("VertBreak" %in% names(df)) {
1986
+ if (!is.na(input$retag_vb_min)) df <- df %>% filter(!is.na(VertBreak), VertBreak >= input$retag_vb_min)
1987
+ if (!is.na(input$retag_vb_max)) df <- df %>% filter(!is.na(VertBreak), VertBreak <= input$retag_vb_max)
1988
+ }
1989
+
1990
+ df
1991
+ })
1992
+
1993
+ # Preview button: show what would be retagged
1994
+ observeEvent(input$retag_preview_btn, {
1995
+ matched <- retag_matched()
1996
+
1997
+ output$retag_preview_summary <- renderUI({
1998
+ n <- nrow(matched)
1999
+ if (n == 0) {
2000
+ div(class = "merge-status-box merge-warning",
2001
+ style = "margin-bottom: 10px;",
2002
+ p(style = "margin: 0; font-weight: 600; color: #856404;",
2003
+ paste0("No pitches match these filters for ", input$pitcher_select, ".")))
2004
+ } else {
2005
+ type_counts <- table(matched$TaggedPitchType)
2006
+ type_str <- paste(names(type_counts), "(", type_counts, ")", collapse = ", ")
2007
+ div(class = "merge-status-box merge-success",
2008
+ style = "margin-bottom: 10px;",
2009
+ p(style = "margin: 0; font-weight: 600; color: #155724;",
2010
+ paste0(n, " pitches matched \u2014 will retag to ", input$retag_to_type)),
2011
+ p(style = "margin: 4px 0 0 0; color: #155724; font-size: 13px;", type_str))
2012
+ }
2013
+ })
2014
+
2015
+ output$retag_preview_table <- DT::renderDataTable({
2016
+ if (nrow(matched) == 0) return(NULL)
2017
+
2018
+ preview_cols <- intersect(
2019
+ c("PitchNo", "Date", "Batter", "TaggedPitchType", "RelSpeed",
2020
+ "SpinRate", "InducedVertBreak", "HorzBreak", "VertBreak",
2021
+ "PlateLocSide", "PlateLocHeight", "PitchCall"),
2022
+ names(matched)
2023
+ )
2024
+
2025
+ DT::datatable(
2026
+ matched %>% select(all_of(preview_cols)),
2027
+ options = list(scrollX = TRUE, pageLength = 8, dom = "tip"),
2028
+ rownames = FALSE
2029
+ ) %>%
2030
+ DT::formatRound(
2031
+ columns = intersect(preview_cols, c("RelSpeed", "InducedVertBreak", "HorzBreak", "VertBreak")),
2032
+ digits = 1
2033
+ )
2034
+ })
2035
+ })
2036
+
2037
+ # Apply retag: actually change TaggedPitchType
2038
+ observeEvent(input$retag_apply_btn, {
2039
+ matched <- retag_matched()
2040
+
2041
+ if (nrow(matched) == 0) {
2042
+ showNotification("No pitches match the current filters.", type = "warning")
2043
+ return()
2044
+ }
2045
+
2046
+ current_data <- plot_data()
2047
+
2048
+ # Build composite key from available columns to uniquely identify pitches
2049
+ key_cols <- intersect(c("PitchUID", "PitchNo", "Date", "Pitcher", "RelSpeed",
2050
+ "HorzBreak", "InducedVertBreak"), names(current_data))
2051
+
2052
+ make_key <- function(df) {
2053
+ do.call(paste, c(df[key_cols], sep = "||"))
2054
+ }
2055
+
2056
+ matched_keys <- make_key(matched)
2057
+
2058
+ current_data <- current_data %>%
2059
+ mutate(.retag_key = make_key(cur_data())) %>%
2060
+ mutate(TaggedPitchType = ifelse(
2061
+ .retag_key %in% matched_keys & Pitcher == input$pitcher_select,
2062
+ input$retag_to_type,
2063
+ TaggedPitchType
2064
+ )) %>%
2065
+ select(-.retag_key)
2066
+
2067
+ plot_data(current_data)
2068
+ processed_data(current_data)
2069
+
2070
+ n_matched <- nrow(matched)
2071
+ showNotification(
2072
+ paste0("Retagged ", n_matched, " pitches to ", input$retag_to_type),
2073
+ type = "message", duration = 4
2074
+ )
2075
+
2076
+ # Update preview to show completion
2077
+ output$retag_preview_summary <- renderUI({
2078
+ div(class = "merge-status-box merge-success",
2079
+ style = "margin-bottom: 10px;",
2080
+ p(style = "margin: 0; font-weight: 600; color: #155724;",
2081
+ paste0("\u2713 Done! ", n_matched, " pitches retagged to ", input$retag_to_type, ".")))
2082
+ })
2083
+ output$retag_preview_table <- DT::renderDataTable({ NULL })
2084
+ })
2085
+
2086
+ # Clear all retag filters
2087
+ observeEvent(input$retag_clear_btn, {
2088
+ updateSelectInput(session, "retag_from_type", selected = "")
2089
+ updateDateRangeInput(session, "retag_dates", start = NA, end = NA)
2090
+ updateTextInput(session, "retag_pitch_no_range", value = "")
2091
+ updateNumericInput(session, "retag_velo_min", value = NA)
2092
+ updateNumericInput(session, "retag_velo_max", value = NA)
2093
+ updateNumericInput(session, "retag_spin_min", value = NA)
2094
+ updateNumericInput(session, "retag_spin_max", value = NA)
2095
+ updateNumericInput(session, "retag_ivb_min", value = NA)
2096
+ updateNumericInput(session, "retag_ivb_max", value = NA)
2097
+ updateNumericInput(session, "retag_hb_min", value = NA)
2098
+ updateNumericInput(session, "retag_hb_max", value = NA)
2099
+ updateNumericInput(session, "retag_vb_min", value = NA)
2100
+ updateNumericInput(session, "retag_vb_max", value = NA)
2101
+
2102
+ output$retag_preview_summary <- renderUI({ NULL })
2103
+ output$retag_preview_table <- DT::renderDataTable({ NULL })
2104
+ })
2105
+
2106
+ # ══════════════════════════════════════════════════════════════
2107
+ # End Rule-Based Retagging
2108
+ # ══════════════════════════════════════════════════════════════
2109
+
2110
+ # Click info output
2111
+ output$click_info <- renderText({
2112
+ if (!is.null(selected_pitch())) {
2113
+ pitch_info <- selected_pitch()
2114
+ paste("Last selected pitch:", pitch_info$original_type,
2115
+ "| Position: (", round(pitch_info$data$HorzBreak, 1), ",",
2116
+ round(pitch_info$data$InducedVertBreak, 1), ")")
2117
+ } else {
2118
+ "No point selected yet. Click on a point in the chart above to edit its pitch type."
2119
+ }
2120
+ })
2121
+
2122
+ # Data summary for download page
2123
+ output$data_summary <- renderText({
2124
+ req(processed_data())
2125
+ df <- processed_data()
2126
+ result <- merge_result()
2127
+
2128
+ bat_tracking_summary <- if (!is.null(result) && result$matched > 0) {
2129
+ paste("Bat tracking data:", result$matched, "pitches with swing metrics")
2130
+ } else {
2131
+ "Bat tracking data: None"
2132
+ }
2133
+
2134
+ summary_text <- paste(
2135
+ paste("Total rows:", nrow(df)),
2136
+ paste("Total columns:", ncol(df)),
2137
+ paste("Date range:",
2138
+ if ("Date" %in% names(df) && !all(is.na(df$Date))) {
2139
+ paste(min(as.Date(df$Date), na.rm = TRUE), "to", max(as.Date(df$Date), na.rm = TRUE))
2140
+ } else {
2141
+ "Date column not available"
2142
+ }),
2143
+ paste("Unique pitchers:",
2144
+ if ("Pitcher" %in% names(df)) {
2145
+ length(unique(df$Pitcher[!is.na(df$Pitcher)]))
2146
+ } else {
2147
+ "Pitcher column not available"
2148
+ }),
2149
+ paste("Pitch types:",
2150
+ if ("TaggedPitchType" %in% names(df)) {
2151
+ paste(sort(unique(df$TaggedPitchType[!is.na(df$TaggedPitchType)])), collapse = ", ")
2152
+ } else {
2153
+ "TaggedPitchType column not available"
2154
+ }),
2155
+ bat_tracking_summary,
2156
+ paste("Source format:", toupper(uploaded_file_type())),
2157
+ paste("Date format:", if (input$date_format == "mdyy") "M/D/YY" else "YYYY-MM-DD"),
2158
+ sep = "\n"
2159
+ )
2160
+
2161
+ return(summary_text)
2162
+ })
2163
+
2164
+ # Download handler: CSV or Parquet with custom filename
2165
+ output$downloadData <- downloadHandler(
2166
+ filename = function() {
2167
+ base_name <- gsub("[^A-Za-z0-9_\\-]", "_", input$download_filename)
2168
+ if (nchar(trimws(base_name)) == 0) base_name <- paste0("app_ready_COA_", Sys.Date())
2169
+
2170
+ ext <- input$download_format
2171
+ paste0(base_name, ".", ext)
2172
+ },
2173
+ content = function(file) {
2174
+ if (input$download_format == "parquet") {
2175
+ arrow::write_parquet(processed_data(), file)
2176
+ } else {
2177
+ write.csv(processed_data(), file, row.names = FALSE)
2178
+ }
2179
+ }
2180
+ )
2181
+
2182
+
2183
+
2184
+ #SCRAPER STUFF
2185
+
2186
+ output$scrape_options <- renderUI({
2187
+ switch(input$scrape_source,
2188
+ "pbp" = tagList(
2189
+ p("Scrapes TrackMan play-by-play data from FTP.")
2190
+ ),
2191
+ "pos" = tagList(
2192
+ p("Scrapes TrackMan player positioning data from FTP.")
2193
+ ),
2194
+ "ncaa" = tagList(
2195
+ selectInput("ncaa_division", "Division:", choices = c("D1", "D2", "D3")),
2196
+ p("Scrapes NCAA scoreboard data via API.")
2197
+ )
2198
+ )
2199
+ })
2200
+
2201
+ # Scrape button
2202
+ observeEvent(input$scrape_btn, {
2203
+ scrape_status_msg("Triggering scrape on GitHub...")
2204
+
2205
+ gh_token <- Sys.getenv("GITHUB_TOKEN")
2206
+ gh_repo <- Sys.getenv("GITHUB_REPO")
2207
+
2208
+ result <- tryCatch({
2209
+ httr::POST(
2210
+ paste0("https://api.github.com/repos/", gh_repo, "/actions/workflows/scrape.yml/dispatches"),
2211
+ httr::add_headers(
2212
+ Authorization = paste("Bearer", gh_token),
2213
+ Accept = "application/vnd.github.v3+json"
2214
+ ),
2215
+ body = jsonlite::toJSON(list(
2216
+ ref = "main",
2217
+ inputs = list(
2218
+ start_date = as.character(input$start_date),
2219
+ end_date = as.character(input$end_date),
2220
+ data_type = input$scrape_source
2221
+ )
2222
+ ), auto_unbox = TRUE),
2223
+ encode = "raw"
2224
+ )
2225
+ }, error = function(e) {
2226
+ scrape_status_msg(paste("Failed:", e$message))
2227
+ return(NULL)
2228
+ })
2229
+
2230
+ if (is.null(result)) return()
2231
+
2232
+ if (httr::status_code(result) == 204) {
2233
+ scrape_status_msg("Scrape triggered! Waiting for GitHub to finish...")
2234
+ scrape_polling(TRUE)
2235
+ } else {
2236
+ scrape_status_msg(paste("GitHub API error:", httr::status_code(result)))
2237
+ }
2238
+ })
2239
+
2240
+ # Poll GitHub every 15 seconds to check if done
2241
+ observe({
2242
+ req(scrape_polling())
2243
+
2244
+ invalidateLater(15000, session)
2245
+
2246
+ gh_token <- Sys.getenv("GITHUB_TOKEN")
2247
+ gh_repo <- Sys.getenv("GITHUB_REPO")
2248
+
2249
+ resp <- tryCatch({
2250
+ httr::GET(
2251
+ paste0("https://api.github.com/repos/", gh_repo, "/actions/runs?per_page=1"),
2252
+ httr::add_headers(
2253
+ Authorization = paste("Bearer", gh_token),
2254
+ Accept = "application/vnd.github.v3+json"
2255
+ )
2256
+ )
2257
+ }, error = function(e) { NULL })
2258
+
2259
+ if (is.null(resp)) return()
2260
+
2261
+ runs <- jsonlite::fromJSON(httr::content(resp, as = "text", encoding = "UTF-8"))
2262
+
2263
+ if (length(runs$workflow_runs) == 0) return()
2264
+
2265
+ latest <- runs$workflow_runs[1, ]
2266
+ status <- latest$status
2267
+ conclusion <- latest$conclusion
2268
+
2269
+ if (status == "completed") {
2270
+ scrape_polling(FALSE)
2271
+
2272
+ if (conclusion == "success") {
2273
+ scrape_status_msg("GitHub finished! Fetching data...")
2274
+
2275
+ filename <- paste0(input$scrape_source, "_", input$start_date, "_to_", input$end_date, ".csv.gz")
2276
+ url <- paste0("https://api.github.com/repos/", gh_repo, "/contents/data/", filename)
2277
+
2278
+ data <- tryCatch({
2279
+ file_resp <- httr::GET(
2280
+ url,
2281
+ httr::add_headers(
2282
+ Authorization = paste("Bearer", gh_token),
2283
+ Accept = "application/vnd.github.v3.raw"
2284
+ )
2285
+ )
2286
+
2287
+ if (httr::status_code(file_resp) == 200) {
2288
+ tmp <- tempfile(fileext = ".csv.gz")
2289
+ writeBin(httr::content(file_resp, as = "raw"), tmp)
2290
+ read_csv(gzfile(tmp))
2291
+ } else {
2292
+ NULL
2293
+ }
2294
+ }, error = function(e) { NULL })
2295
+
2296
+ if (!is.null(data) && nrow(data) > 0) {
2297
+
2298
+ if (input$scrape_source == "pbp") {
2299
+ scrape_status_msg("Processing data...")
2300
+
2301
+ data <- tryCatch({
2302
+ d <- clean_college_data(data)
2303
+ d <- predict_stuffplus(d)
2304
+ d
2305
+ }, error = function(e) {
2306
+ scrape_status_msg(paste("Processing error:", e$message))
2307
+ data
2308
+ })
2309
+ }
2310
+
2311
+ scraped_data(data)
2312
+ scrape_status_msg(paste0("Done! ", nrow(data), " rows \u00D7 ", ncol(data), " columns."))
2313
+ } else {
2314
+ scrape_status_msg("Scrape finished but couldn't fetch the file. Try 'Fetch Results' manually.")
2315
+ }
2316
+
2317
+ } else {
2318
+ scrape_status_msg(paste("GitHub Action failed:", conclusion))
2319
+ }
2320
+
2321
+ } else {
2322
+ scrape_status_msg(paste0("GitHub is running... (status: ", status, ")"))
2323
+ }
2324
+ })
2325
+
2326
+ # Status text
2327
+ output$scrape_status <- renderText({ scrape_status_msg() })
2328
+
2329
+ # Preview table
2330
+ output$scrape_preview <- DT::renderDataTable({
2331
+ req(scraped_data())
2332
+ DT::datatable(scraped_data(), options = list(scrollX = TRUE, pageLength = 10))
2333
+ })
2334
+
2335
+ # Download
2336
+ output$download_scrape <- downloadHandler(
2337
+ filename = function() {
2338
+ label <- switch(input$scrape_source, "pbp" = "pbp", "pos" = "positional", "ncaa" = "ncaa")
2339
+ paste0("trackman_", label, "_",
2340
+ format(input$start_date, "%Y%m%d"), "_to_",
2341
+ format(input$end_date, "%Y%m%d"), ".csv")
2342
+ },
2343
+ content = function(file) {
2344
+ req(scraped_data())
2345
+ write.csv(scraped_data(), file, row.names = FALSE)
2346
+ }
2347
+ )
2348
+
2349
+
2350
+ observeEvent(input$upload_hf_btn, {
2351
+ req(scraped_data())
2352
+
2353
+ hf_token <- Sys.getenv("HF_WRITE_TOKEN")
2354
+ repo_id <- "CoastalBaseball/2026MasterDataset"
2355
+ timestamp <- format(Sys.time(), "%Y%m%d_%H%M%S")
2356
+
2357
+ upload_to_hf <- function(new_data, folder, index_file, label) {
2358
+ scrape_status_msg(paste0("Checking existing UIDs for ", label, "..."))
2359
+
2360
+ existing_uids <- tryCatch({
2361
+ tmp_idx <- tempfile(fileext = ".csv.gz")
2362
+ resp <- httr::GET(
2363
+ paste0("https://huggingface.co/datasets/", repo_id, "/resolve/main/", index_file),
2364
+ httr::add_headers(Authorization = paste("Bearer", hf_token)),
2365
+ httr::write_disk(tmp_idx, overwrite = TRUE)
2366
+ )
2367
+ if (httr::status_code(resp) == 200) {
2368
+ d <- read.csv(gzfile(tmp_idx), stringsAsFactors = FALSE)
2369
+ file.remove(tmp_idx)
2370
+ d$PitchUID
2371
+ } else {
2372
+ file.remove(tmp_idx)
2373
+ character(0)
2374
+ }
2375
+ }, error = function(e) { character(0) })
2376
+
2377
+ scraped_rows <- nrow(new_data)
2378
+
2379
+ if (length(existing_uids) > 0 && "PitchUID" %in% names(new_data)) {
2380
+ new_only <- new_data %>% filter(!PitchUID %in% existing_uids)
2381
+ } else {
2382
+ new_only <- new_data
2383
+ }
2384
+
2385
+ new_rows <- nrow(new_only)
2386
+ total_after <- length(existing_uids) + new_rows
2387
+
2388
+ if (new_rows == 0) {
2389
+ return(paste0(label, ": ", scraped_rows, " rows scraped, 0 new rows added (", length(existing_uids), " total)"))
2390
+ }
2391
+
2392
+ scrape_status_msg(paste0("Uploading ", new_rows, " new rows for ", label, "..."))
2393
+
2394
+ hf <- reticulate::import("huggingface_hub")
2395
+ api <- hf$HfApi()
2396
+
2397
+ tmp_data <- tempfile(fileext = ".parquet")
2398
+ arrow::write_parquet(new_only, tmp_data)
2399
+
2400
+ api$upload_file(
2401
+ path_or_fileobj = tmp_data,
2402
+ path_in_repo = paste0(folder, "/", timestamp, ".parquet"),
2403
+ repo_id = repo_id,
2404
+ repo_type = "dataset",
2405
+ token = hf_token
2406
+ )
2407
+ file.remove(tmp_data)
2408
+
2409
+ scrape_status_msg(paste0("Updating ", label, " index..."))
2410
+
2411
+ all_uids <- data.frame(PitchUID = c(existing_uids, new_only$PitchUID))
2412
+ tmp_idx <- tempfile(fileext = ".csv.gz")
2413
+ gz <- gzfile(tmp_idx, "w")
2414
+ write.csv(all_uids, gz, row.names = FALSE)
2415
+ close(gz)
2416
+
2417
+ api$upload_file(
2418
+ path_or_fileobj = tmp_idx,
2419
+ path_in_repo = index_file,
2420
+ repo_id = repo_id,
2421
+ repo_type = "dataset",
2422
+ token = hf_token
2423
+ )
2424
+ file.remove(tmp_idx)
2425
+
2426
+ rm(new_only, all_uids); gc()
2427
+ paste0(label, ": ", scraped_rows, " rows scraped, ", new_rows, " new rows added (", total_after, " total)")
2428
+ }
2429
+
2430
+ if (input$scrape_source == "pbp") {
2431
+ msg1 <- upload_to_hf(scraped_data(), "pbp", "pbp_uid_index.csv.gz", "Master Dataset")
2432
+ gc()
2433
+
2434
+ cp <- scraped_data() %>% filter(PitcherTeam == "COA_CHA")
2435
+ msg2 <- if (nrow(cp) > 0) {
2436
+ upload_to_hf(cp, "coastal_pitchers", "coastal_pitchers_uid_index.csv.gz", "Coastal Pitchers")
2437
+ } else { "Coastal Pitchers: No matching rows" }
2438
+ rm(cp); gc()
2439
+
2440
+ ch <- scraped_data() %>% filter(BatterTeam == "COA_CHA")
2441
+ msg3 <- if (nrow(ch) > 0) {
2442
+ upload_to_hf(ch, "coastal_hitters", "coastal_hitters_uid_index.csv.gz", "Coastal Hitters")
2443
+ } else { "Coastal Hitters: No matching rows" }
2444
+ rm(ch); gc()
2445
+
2446
+ scrape_status_msg(paste(msg1, msg2, msg3, sep = "\n"))
2447
+
2448
+ } else if (input$scrape_source == "pos") {
2449
+ msg1 <- upload_to_hf(scraped_data(), "pos", "pos_uid_index.csv.gz", "Positional Dataset")
2450
+ scrape_status_msg(msg1)
2451
+
2452
+ } else if (input$scrape_source == "ncaa") {
2453
+ msg1 <- upload_to_hf(scraped_data(), "ncaa_pbp", "ncaa_pbp_uid_index.csv.gz", "NCAA PBP Dataset")
2454
+ scrape_status_msg(msg1)
2455
+ }
2456
+ })
2457
+
2458
+ }
2459
+
2460
+ # Run the app
2461
+ shinyApp(ui = ui, server = server)