Spaces:
Running
Running
Update app.R
Browse files
app.R
CHANGED
|
@@ -15,6 +15,10 @@ library(tidymodels)
|
|
| 15 |
library(httr)
|
| 16 |
library(ggforce)
|
| 17 |
library(jsonlite) # for parse_bat_tracking_json()
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
|
| 19 |
PASSWORD <- Sys.getenv("password")
|
| 20 |
|
|
@@ -161,8 +165,69 @@ app_css <- "
|
|
| 161 |
font-size: 1.2em;
|
| 162 |
font-weight: bold;
|
| 163 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 164 |
"
|
| 165 |
|
|
|
|
|
|
|
|
|
|
| 166 |
stuffplus_recipe <- readRDS("stuffplus_recipe.rds")
|
| 167 |
|
| 168 |
stuffplus_model <- xgb.load("stuffplus_xgb.json")
|
|
@@ -5848,61 +5913,431 @@ app_ui <- fluidPage(
|
|
| 5848 |
),
|
| 5849 |
tabPanel("Data Processing",
|
| 5850 |
br(),
|
| 5851 |
-
|
| 5852 |
-
|
| 5853 |
-
|
| 5854 |
-
|
| 5855 |
-
|
| 5856 |
-
|
| 5857 |
-
|
| 5858 |
-
|
| 5859 |
-
|
| 5860 |
-
|
| 5861 |
-
|
| 5862 |
-
|
| 5863 |
-
|
| 5864 |
-
|
| 5865 |
-
|
| 5866 |
-
|
| 5867 |
-
|
| 5868 |
-
|
| 5869 |
-
|
| 5870 |
-
|
| 5871 |
-
|
| 5872 |
-
|
| 5873 |
-
|
| 5874 |
-
|
| 5875 |
-
|
| 5876 |
-
|
| 5877 |
-
|
| 5878 |
-
|
| 5879 |
-
|
| 5880 |
-
|
| 5881 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5882 |
),
|
| 5883 |
-
|
| 5884 |
-
|
| 5885 |
-
|
| 5886 |
-
|
| 5887 |
-
|
| 5888 |
-
|
| 5889 |
-
|
| 5890 |
-
|
| 5891 |
-
|
| 5892 |
-
|
| 5893 |
-
|
| 5894 |
-
|
| 5895 |
-
|
| 5896 |
-
|
| 5897 |
-
|
| 5898 |
-
|
| 5899 |
-
|
| 5900 |
-
|
| 5901 |
-
|
| 5902 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5903 |
)
|
| 5904 |
-
)
|
| 5905 |
-
)
|
| 5906 |
)
|
| 5907 |
)
|
| 5908 |
)
|
|
@@ -6948,127 +7383,1648 @@ server <- function(input, output, session) {
|
|
| 6948 |
contentType = "application/zip"
|
| 6949 |
)
|
| 6950 |
|
| 6951 |
-
# ===================== DATA PROCESSING TAB =====================
|
| 6952 |
-
proc_result <- reactiveVal(NULL)
|
| 6953 |
|
| 6954 |
-
|
| 6955 |
-
|
| 6956 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6957 |
})
|
| 6958 |
-
|
| 6959 |
-
|
|
|
|
| 6960 |
})
|
| 6961 |
-
|
| 6962 |
-
|
| 6963 |
-
|
|
|
|
| 6964 |
})
|
| 6965 |
-
|
| 6966 |
-
#
|
| 6967 |
-
|
| 6968 |
-
|
| 6969 |
-
|
| 6970 |
-
|
|
|
|
|
|
|
| 6971 |
}
|
| 6972 |
-
|
| 6973 |
-
|
| 6974 |
-
|
| 6975 |
-
|
| 6976 |
-
|
| 6977 |
-
|
| 6978 |
-
|
| 6979 |
-
|
| 6980 |
-
|
| 6981 |
-
|
| 6982 |
-
|
| 6983 |
-
|
| 6984 |
-
|
| 6985 |
-
|
| 6986 |
-
|
| 6987 |
-
|
| 6988 |
-
|
| 6989 |
-
|
| 6990 |
-
|
| 6991 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6992 |
})
|
| 6993 |
-
|
| 6994 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6995 |
showNotification(
|
| 6996 |
-
paste0("
|
| 6997 |
-
|
| 6998 |
-
type = "message", duration = 4
|
| 6999 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7000 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7001 |
})
|
| 7002 |
-
|
| 7003 |
-
|
| 7004 |
-
|
| 7005 |
-
|
| 7006 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7007 |
} else {
|
| 7008 |
-
|
| 7009 |
}
|
| 7010 |
})
|
| 7011 |
-
|
| 7012 |
-
|
| 7013 |
-
|
| 7014 |
-
if (is.null(
|
| 7015 |
-
|
| 7016 |
-
has_bat <- "BatSpeed_Sensor" %in% names(df)
|
| 7017 |
-
cat("Rows: ", format(nrow(df), big.mark = ","), "\n", sep = "")
|
| 7018 |
-
cat("Columns: ", ncol(df), "\n", sep = "")
|
| 7019 |
-
cat("Stuff+ added:", if (has_stuff) " yes" else " no", "\n", sep = "")
|
| 7020 |
-
if (has_stuff) {
|
| 7021 |
-
sp <- suppressWarnings(round(mean(df$stuff_plus, na.rm = TRUE), 1))
|
| 7022 |
-
cat("Mean Stuff+: ", sp, "\n", sep = "")
|
| 7023 |
}
|
| 7024 |
-
|
| 7025 |
-
|
| 7026 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7027 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7028 |
})
|
| 7029 |
-
|
| 7030 |
-
|
| 7031 |
-
|
| 7032 |
-
|
| 7033 |
-
|
| 7034 |
-
|
| 7035 |
-
|
| 7036 |
-
|
| 7037 |
-
|
| 7038 |
-
|
| 7039 |
-
|
| 7040 |
-
output$
|
| 7041 |
-
|
| 7042 |
-
|
| 7043 |
-
|
| 7044 |
-
|
| 7045 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7046 |
)
|
| 7047 |
})
|
| 7048 |
-
|
| 7049 |
-
|
| 7050 |
-
|
| 7051 |
-
|
| 7052 |
-
|
| 7053 |
-
|
| 7054 |
-
|
| 7055 |
-
|
| 7056 |
-
|
|
|
|
| 7057 |
}
|
| 7058 |
-
)
|
| 7059 |
-
|
| 7060 |
-
|
| 7061 |
-
|
| 7062 |
-
|
| 7063 |
-
|
| 7064 |
-
|
| 7065 |
-
|
| 7066 |
-
|
| 7067 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7068 |
}
|
| 7069 |
-
)
|
| 7070 |
-
|
| 7071 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7072 |
}
|
| 7073 |
|
| 7074 |
shinyApp(ui = ui, server = server)
|
|
|
|
| 15 |
library(httr)
|
| 16 |
library(ggforce)
|
| 17 |
library(jsonlite) # for parse_bat_tracking_json()
|
| 18 |
+
library(readr) # read_csv() for gzipped scrape downloads
|
| 19 |
+
library(DT) # data preview / retag / scrape tables
|
| 20 |
+
library(shinyBS) # bsModal pitch-edit dialog
|
| 21 |
+
library(reticulate) # huggingface_hub upload in the Scraping tab
|
| 22 |
|
| 23 |
PASSWORD <- Sys.getenv("password")
|
| 24 |
|
|
|
|
| 165 |
font-size: 1.2em;
|
| 166 |
font-weight: bold;
|
| 167 |
}
|
| 168 |
+
|
| 169 |
+
/* ---- ported data-processing styles ---- */
|
| 170 |
+
/* Bat tracking upload box styling */
|
| 171 |
+
.bat-tracking-box {
|
| 172 |
+
background: linear-gradient(135deg, #e8f4f8 0%, #f0e6d3 100%);
|
| 173 |
+
border: 2px dashed darkcyan;
|
| 174 |
+
border-radius: 15px;
|
| 175 |
+
padding: 20px;
|
| 176 |
+
margin-top: 15px;
|
| 177 |
+
}
|
| 178 |
+
/* Catcher notes styling */
|
| 179 |
+
.catcher-notes-input-box {
|
| 180 |
+
background: linear-gradient(135deg, #e8f4f8 0%, #f0e6d3 100%);
|
| 181 |
+
border: 2px solid darkcyan;
|
| 182 |
+
border-radius: 15px;
|
| 183 |
+
padding: 20px;
|
| 184 |
+
}
|
| 185 |
+
/* Download section styling */
|
| 186 |
+
.download-option-box {
|
| 187 |
+
background: linear-gradient(135deg, #e8f4f8 0%, #f0e6d3 100%);
|
| 188 |
+
border: 1px solid rgba(0,139,139,.2);
|
| 189 |
+
border-radius: 12px;
|
| 190 |
+
padding: 20px;
|
| 191 |
+
margin-bottom: 15px;
|
| 192 |
+
}
|
| 193 |
+
.catcher-note-entry {
|
| 194 |
+
background: #fff;
|
| 195 |
+
border: 1px solid rgba(0,139,139,.15);
|
| 196 |
+
border-radius: 10px;
|
| 197 |
+
padding: 12px 16px;
|
| 198 |
+
margin-bottom: 8px;
|
| 199 |
+
display: flex;
|
| 200 |
+
justify-content: space-between;
|
| 201 |
+
align-items: center;
|
| 202 |
+
}
|
| 203 |
+
.catcher-note-entry:hover {
|
| 204 |
+
border-color: darkcyan;
|
| 205 |
+
box-shadow: 0 2px 8px rgba(0,139,139,.12);
|
| 206 |
+
}
|
| 207 |
+
.merge-status-box {
|
| 208 |
+
background: #f8f9fa;
|
| 209 |
+
border-left: 4px solid darkcyan;
|
| 210 |
+
padding: 15px;
|
| 211 |
+
border-radius: 0 10px 10px 0;
|
| 212 |
+
margin-top: 15px;
|
| 213 |
+
}
|
| 214 |
+
.merge-success {
|
| 215 |
+
border-left-color: #28a745;
|
| 216 |
+
background: #d4edda;
|
| 217 |
+
}
|
| 218 |
+
.merge-warning {
|
| 219 |
+
border-left-color: #ffc107;
|
| 220 |
+
background: #fff3cd;
|
| 221 |
+
}
|
| 222 |
+
.merge-error {
|
| 223 |
+
border-left-color: #dc3545;
|
| 224 |
+
background: #f8d7da;
|
| 225 |
+
}
|
| 226 |
"
|
| 227 |
|
| 228 |
+
|
| 229 |
+
|
| 230 |
+
|
| 231 |
stuffplus_recipe <- readRDS("stuffplus_recipe.rds")
|
| 232 |
|
| 233 |
stuffplus_model <- xgb.load("stuffplus_xgb.json")
|
|
|
|
| 5913 |
),
|
| 5914 |
tabPanel("Data Processing",
|
| 5915 |
br(),
|
| 5916 |
+
tabsetPanel(id = "proc_tabs",
|
| 5917 |
+
|
| 5918 |
+
# Upload & Process Tab
|
| 5919 |
+
tabPanel(
|
| 5920 |
+
"Upload & Process",
|
| 5921 |
+
fluidRow(
|
| 5922 |
+
column(6,
|
| 5923 |
+
h3("1. Upload TrackMan CSV or Parquet"),
|
| 5924 |
+
fileInput("file", "Choose CSV or Parquet File (max 5,000 rows)",
|
| 5925 |
+
accept = c(".csv", ".parquet")),
|
| 5926 |
+
fluidRow(
|
| 5927 |
+
column(3,
|
| 5928 |
+
checkboxInput("header", "Header", TRUE)
|
| 5929 |
+
),
|
| 5930 |
+
column(3,
|
| 5931 |
+
radioButtons("sep", "Separator",
|
| 5932 |
+
choices = c(Comma = ",", Semicolon = ";", Tab = "\t"),
|
| 5933 |
+
selected = ",", inline = TRUE)
|
| 5934 |
+
),
|
| 5935 |
+
column(3,
|
| 5936 |
+
radioButtons("quote", "Quote",
|
| 5937 |
+
choices = c(None = "", "Double Quote" = '"', "Single Quote" = "'"),
|
| 5938 |
+
selected = '"', inline = TRUE)
|
| 5939 |
+
),
|
| 5940 |
+
column(3,
|
| 5941 |
+
radioButtons("date_format", "Date Output Format",
|
| 5942 |
+
choices = c("YYYY-MM-DD" = "yyyy", "M/D/YY" = "mdyy"),
|
| 5943 |
+
selected = "yyyy")
|
| 5944 |
+
)
|
| 5945 |
+
),
|
| 5946 |
+
p(style = "color: #666; font-size: 12px;",
|
| 5947 |
+
"CSV options (Header, Separator, Quote) are ignored for Parquet files."),
|
| 5948 |
+
verbatimTextOutput("csv_status")
|
| 5949 |
+
),
|
| 5950 |
+
column(6,
|
| 5951 |
+
div(class = "bat-tracking-box",
|
| 5952 |
+
h3("2. Upload Bat Tracking JSON (Optional)", style = "margin-top: 0;"),
|
| 5953 |
+
fileInput("json_file", "Choose Bat Tracking JSON File", accept = c(".json")),
|
| 5954 |
+
p(style = "color: #666; font-size: 12px;",
|
| 5955 |
+
"Upload the corresponding _battracking.json file to merge bat speed and attack angle data."),
|
| 5956 |
+
verbatimTextOutput("json_status"),
|
| 5957 |
+
uiOutput("merge_status_ui")
|
| 5958 |
+
)
|
| 5959 |
+
)
|
| 5960 |
+
),
|
| 5961 |
+
|
| 5962 |
+
hr(),
|
| 5963 |
+
|
| 5964 |
+
fluidRow(
|
| 5965 |
+
column(8,
|
| 5966 |
+
h3("3. Columns to Remove"),
|
| 5967 |
+
p("Select which columns to remove from your dataset:"),
|
| 5968 |
+
checkboxGroupInput("columns_to_remove", "Remove These Columns:",
|
| 5969 |
+
choices = columns_to_remove,
|
| 5970 |
+
selected = columns_to_remove)
|
| 5971 |
+
),
|
| 5972 |
+
column(4,
|
| 5973 |
+
h3("Quick Actions"),
|
| 5974 |
+
br(),
|
| 5975 |
+
actionButton("select_all_cols", "Select All", class = "btn-primary"),
|
| 5976 |
+
br(), br(),
|
| 5977 |
+
actionButton("deselect_all_cols", "Deselect All", class = "btn-default"),
|
| 5978 |
+
br(), br(),
|
| 5979 |
+
actionButton("select_spinaxis", "Select SpinAxis3d Columns", class = "btn-info"),
|
| 5980 |
+
br(), br(),
|
| 5981 |
+
h4("Processing Summary"),
|
| 5982 |
+
verbatimTextOutput("process_summary")
|
| 5983 |
+
)
|
| 5984 |
+
)
|
| 5985 |
),
|
| 5986 |
+
|
| 5987 |
+
# Bat Tracking Details Tab
|
| 5988 |
+
tabPanel(
|
| 5989 |
+
"Bat Tracking Data",
|
| 5990 |
+
fluidRow(
|
| 5991 |
+
column(12,
|
| 5992 |
+
h3("Bat Tracking Merge Details"),
|
| 5993 |
+
uiOutput("bat_tracking_details"),
|
| 5994 |
+
hr(),
|
| 5995 |
+
h4("Pitches with Bat Tracking Data"),
|
| 5996 |
+
DT::dataTableOutput("bat_tracking_table")
|
| 5997 |
+
)
|
| 5998 |
+
)
|
| 5999 |
+
),
|
| 6000 |
+
|
| 6001 |
+
# Preview Data Tab
|
| 6002 |
+
tabPanel(
|
| 6003 |
+
"Preview Data",
|
| 6004 |
+
fluidRow(
|
| 6005 |
+
column(12,
|
| 6006 |
+
h3("Data Preview"),
|
| 6007 |
+
DT::dataTableOutput("preview")
|
| 6008 |
+
)
|
| 6009 |
+
)
|
| 6010 |
+
),
|
| 6011 |
+
|
| 6012 |
+
# Pitch Movement Chart Tab
|
| 6013 |
+
tabPanel(
|
| 6014 |
+
"Pitch Movement Chart",
|
| 6015 |
+
fluidRow(
|
| 6016 |
+
column(3,
|
| 6017 |
+
selectInput("pitcher_select", "Select Pitcher:",
|
| 6018 |
+
choices = NULL, selected = NULL)
|
| 6019 |
+
),
|
| 6020 |
+
column(3,
|
| 6021 |
+
h4("Selection Mode:"),
|
| 6022 |
+
radioButtons("selection_mode", "",
|
| 6023 |
+
choices = list("Single Click" = "single", "Drag Select" = "drag"),
|
| 6024 |
+
selected = "single", inline = TRUE)
|
| 6025 |
+
),
|
| 6026 |
+
column(6,
|
| 6027 |
+
conditionalPanel(
|
| 6028 |
+
condition = "input.selection_mode == 'drag'",
|
| 6029 |
+
h4("Bulk Edit:"),
|
| 6030 |
+
fluidRow(
|
| 6031 |
+
column(8,
|
| 6032 |
+
selectInput("bulk_pitch_type", "Change all selected to:",
|
| 6033 |
+
choices = c("Fastball", "Sinker", "Cutter", "Slider",
|
| 6034 |
+
"Curveball", "ChangeUp", "Splitter", "Knuckleball", "Sweeper", "Other"),
|
| 6035 |
+
selected = "Fastball")
|
| 6036 |
+
),
|
| 6037 |
+
column(4,
|
| 6038 |
+
br(),
|
| 6039 |
+
actionButton("apply_bulk_change", "Apply to Selected", class = "btn-success")
|
| 6040 |
+
)
|
| 6041 |
+
)
|
| 6042 |
+
)
|
| 6043 |
+
)
|
| 6044 |
+
),
|
| 6045 |
+
|
| 6046 |
+
fluidRow(
|
| 6047 |
+
column(8,
|
| 6048 |
+
h3("Interactive Pitch Movement Analysis"),
|
| 6049 |
+
plotOutput("movement_plot", height = "600px",
|
| 6050 |
+
click = "plot_click",
|
| 6051 |
+
brush = brushOpts(id = "plot_brush"),
|
| 6052 |
+
hover = hoverOpts(id = "plot_hover", delay = 100)),
|
| 6053 |
+
|
| 6054 |
+
h4("Instructions:"),
|
| 6055 |
+
p(strong("Single Click Mode:"), "Click on any point to edit one pitch type at a time via popup modal."),
|
| 6056 |
+
p(strong("Drag Select Mode:"), "Click and drag to select multiple points, then use the dropdown to change them all at once."),
|
| 6057 |
+
conditionalPanel(
|
| 6058 |
+
condition = "input.selection_mode == 'drag'",
|
| 6059 |
+
div(style = "background-color: #f0f8ff; padding: 10px; border-radius: 5px; margin: 10px 0; border-left: 4px solid darkcyan;",
|
| 6060 |
+
h4("Selected Points:", style = "margin-top: 0; color: darkcyan;"),
|
| 6061 |
+
textOutput("selection_info")
|
| 6062 |
+
)
|
| 6063 |
+
),
|
| 6064 |
+
verbatimTextOutput("hover_info"),
|
| 6065 |
+
verbatimTextOutput("click_info")
|
| 6066 |
+
),
|
| 6067 |
+
|
| 6068 |
+
column(4,
|
| 6069 |
+
h3("Pitch Metrics Summary"),
|
| 6070 |
+
DT::dataTableOutput("movement_stats")
|
| 6071 |
+
)
|
| 6072 |
+
),
|
| 6073 |
+
|
| 6074 |
+
# ── Rule-Based Pitch Retagging Panel ──
|
| 6075 |
+
hr(),
|
| 6076 |
+
fluidRow(
|
| 6077 |
+
column(12,
|
| 6078 |
+
div(
|
| 6079 |
+
style = "background: linear-gradient(135deg, #e8f4f8 0%, #f0e6d3 100%); border: 2px solid darkcyan; border-radius: 15px; padding: 20px; margin-top: 10px;",
|
| 6080 |
+
h3("Rule-Based Pitch Retagging", style = "margin-top: 0; color: darkcyan; border-bottom: 2px solid darkcyan; padding-bottom: 8px;"),
|
| 6081 |
+
p(style = "color: #666; font-size: 13px; margin-bottom: 15px;",
|
| 6082 |
+
"Set filters to match pitches, then retag them all at once. ",
|
| 6083 |
+
"Only pitches matching ALL non-empty filters for the selected pitcher will be changed."),
|
| 6084 |
+
|
| 6085 |
+
fluidRow(
|
| 6086 |
+
# Column 1: Source pitch type + date range
|
| 6087 |
+
column(3,
|
| 6088 |
+
h4("Pitch Type & Date", style = "color: peru; margin-top: 0;"),
|
| 6089 |
+
selectInput("retag_from_type", "Current Pitch Type:",
|
| 6090 |
+
choices = c("(Any)" = "", "Fastball", "Sinker", "Cutter",
|
| 6091 |
+
"Slider", "Curveball", "ChangeUp", "Splitter",
|
| 6092 |
+
"Knuckleball", "Sweeper", "Other"),
|
| 6093 |
+
selected = ""),
|
| 6094 |
+
dateRangeInput("retag_dates", "Date Range (optional):",
|
| 6095 |
+
start = NA, end = NA),
|
| 6096 |
+
textInput("retag_pitch_no_range", "Pitch Numbers (e.g. 1-50):", value = "")
|
| 6097 |
+
),
|
| 6098 |
+
|
| 6099 |
+
# Column 2: Velocity & spin filters
|
| 6100 |
+
column(3,
|
| 6101 |
+
h4("Velocity & Spin", style = "color: peru; margin-top: 0;"),
|
| 6102 |
+
fluidRow(
|
| 6103 |
+
column(6, numericInput("retag_velo_min", "Velo Min:", value = NA)),
|
| 6104 |
+
column(6, numericInput("retag_velo_max", "Velo Max:", value = NA))
|
| 6105 |
+
),
|
| 6106 |
+
fluidRow(
|
| 6107 |
+
column(6, numericInput("retag_spin_min", "Spin Min:", value = NA)),
|
| 6108 |
+
column(6, numericInput("retag_spin_max", "Spin Max:", value = NA))
|
| 6109 |
+
)
|
| 6110 |
+
),
|
| 6111 |
+
|
| 6112 |
+
# Column 3: Movement filters
|
| 6113 |
+
column(3,
|
| 6114 |
+
h4("Movement (inches)", style = "color: peru; margin-top: 0;"),
|
| 6115 |
+
fluidRow(
|
| 6116 |
+
column(6, numericInput("retag_ivb_min", "IVB Min:", value = NA)),
|
| 6117 |
+
column(6, numericInput("retag_ivb_max", "IVB Max:", value = NA))
|
| 6118 |
+
),
|
| 6119 |
+
fluidRow(
|
| 6120 |
+
column(6, numericInput("retag_hb_min", "HB Min:", value = NA)),
|
| 6121 |
+
column(6, numericInput("retag_hb_max", "HB Max:", value = NA))
|
| 6122 |
+
),
|
| 6123 |
+
fluidRow(
|
| 6124 |
+
column(6, numericInput("retag_vb_min", "VBreak Min:", value = NA)),
|
| 6125 |
+
column(6, numericInput("retag_vb_max", "VBreak Max:", value = NA))
|
| 6126 |
+
)
|
| 6127 |
+
),
|
| 6128 |
+
|
| 6129 |
+
# Column 4: Target type + actions
|
| 6130 |
+
column(3,
|
| 6131 |
+
h4("Retag To", style = "color: peru; margin-top: 0;"),
|
| 6132 |
+
selectInput("retag_to_type", "New Pitch Type:",
|
| 6133 |
+
choices = c("Fastball", "Sinker", "Cutter", "Slider",
|
| 6134 |
+
"Curveball", "ChangeUp", "Splitter",
|
| 6135 |
+
"Knuckleball", "Sweeper", "Other"),
|
| 6136 |
+
selected = "Sweeper"),
|
| 6137 |
+
br(),
|
| 6138 |
+
actionButton("retag_preview_btn", "Preview Matches",
|
| 6139 |
+
class = "btn-info", style = "width: 100%;"),
|
| 6140 |
+
br(), br(),
|
| 6141 |
+
actionButton("retag_apply_btn", "Apply Retag",
|
| 6142 |
+
class = "btn-success", style = "width: 100%; font-weight: bold;"),
|
| 6143 |
+
br(), br(),
|
| 6144 |
+
actionButton("retag_clear_btn", "Clear Filters",
|
| 6145 |
+
class = "btn-default", style = "width: 100%;")
|
| 6146 |
+
)
|
| 6147 |
+
),
|
| 6148 |
+
|
| 6149 |
+
# Preview results area
|
| 6150 |
+
div(
|
| 6151 |
+
style = "margin-top: 15px;",
|
| 6152 |
+
uiOutput("retag_preview_summary"),
|
| 6153 |
+
DT::dataTableOutput("retag_preview_table")
|
| 6154 |
+
)
|
| 6155 |
+
)
|
| 6156 |
+
)
|
| 6157 |
+
)
|
| 6158 |
+
),
|
| 6159 |
+
|
| 6160 |
+
# ══════════════════════════════════════════════════════════════
|
| 6161 |
+
# Catcher Notes Tab
|
| 6162 |
+
# ══════════════════════════════════════════════════════════════
|
| 6163 |
+
tabPanel(
|
| 6164 |
+
"Catcher Notes",
|
| 6165 |
+
fluidRow(
|
| 6166 |
+
column(5,
|
| 6167 |
+
div(class = "catcher-notes-input-box",
|
| 6168 |
+
h3("Add Catcher Note", style = "margin-top: 0; color: darkcyan; border-bottom: 2px solid darkcyan; padding-bottom: 8px;"),
|
| 6169 |
+
p(style = "color: #666; font-size: 13px; margin-bottom: 15px;",
|
| 6170 |
+
"Log catcher events (throws, wild pitches, passed balls). ",
|
| 6171 |
+
"Each note is matched to the pitch row by Catcher, Batter, Inning, and Count, ",
|
| 6172 |
+
"then written into a CatcherNotes column on download."),
|
| 6173 |
+
|
| 6174 |
+
fluidRow(
|
| 6175 |
+
column(6, selectInput("cn_catcher", "Catcher:", choices = NULL)),
|
| 6176 |
+
column(6, selectInput("cn_batter", "Batter:", choices = NULL))
|
| 6177 |
+
),
|
| 6178 |
+
|
| 6179 |
+
fluidRow(
|
| 6180 |
+
column(4, numericInput("cn_inning", "Inning:", value = 1, min = 1, max = 20, step = 1)),
|
| 6181 |
+
column(4, numericInput("cn_balls", "Balls:", value = 0, min = 0, max = 3, step = 1)),
|
| 6182 |
+
column(4, numericInput("cn_strikes", "Strikes:", value = 0, min = 0, max = 2, step = 1))
|
| 6183 |
+
),
|
| 6184 |
+
|
| 6185 |
+
selectInput("cn_result", "Result:",
|
| 6186 |
+
choices = c("2B Out", "2B Safe", "3B Out", "3B Safe",
|
| 6187 |
+
"Wild Pitch", "Passed Ball",
|
| 6188 |
+
"Pickoff Attempt", "Pickoff Out",
|
| 6189 |
+
"Blocked Ball", "Other"),
|
| 6190 |
+
selected = "2B Out"),
|
| 6191 |
+
|
| 6192 |
+
conditionalPanel(
|
| 6193 |
+
condition = "input.cn_result == 'Other'",
|
| 6194 |
+
textInput("cn_custom_result", "Custom Note:", placeholder = "Describe the event...")
|
| 6195 |
+
),
|
| 6196 |
+
|
| 6197 |
+
br(),
|
| 6198 |
+
actionButton("cn_add_btn", "Add Note", class = "btn-success",
|
| 6199 |
+
style = "width: 100%; font-weight: bold; font-size: 15px;"),
|
| 6200 |
+
br(), br(),
|
| 6201 |
+
uiOutput("cn_match_feedback")
|
| 6202 |
+
)
|
| 6203 |
+
),
|
| 6204 |
+
|
| 6205 |
+
column(7,
|
| 6206 |
+
h3("Logged Catcher Notes"),
|
| 6207 |
+
p(style = "color: #666; font-size: 13px;",
|
| 6208 |
+
"These notes will be merged into the CatcherNotes column when you download the data."),
|
| 6209 |
+
DT::dataTableOutput("cn_notes_table"),
|
| 6210 |
+
br(),
|
| 6211 |
+
fluidRow(
|
| 6212 |
+
column(6,
|
| 6213 |
+
actionButton("cn_clear_all_btn", "Clear All Notes", class = "btn-danger",
|
| 6214 |
+
style = "width: 100%;")
|
| 6215 |
+
),
|
| 6216 |
+
column(6,
|
| 6217 |
+
verbatimTextOutput("cn_summary")
|
| 6218 |
+
)
|
| 6219 |
+
)
|
| 6220 |
+
)
|
| 6221 |
+
)
|
| 6222 |
+
),
|
| 6223 |
+
|
| 6224 |
+
tabPanel(
|
| 6225 |
+
"Download",
|
| 6226 |
+
fluidRow(
|
| 6227 |
+
column(12,
|
| 6228 |
+
h3("Download Processed Data"),
|
| 6229 |
+
|
| 6230 |
+
div(class = "download-option-box",
|
| 6231 |
+
h4("Processing Mode", style = "color: darkcyan; margin-top: 0;"),
|
| 6232 |
+
radioButtons("download_mode", NULL,
|
| 6233 |
+
choices = c(
|
| 6234 |
+
"Raw Edited CSV (as-is)" = "raw",
|
| 6235 |
+
"Full Processing (clean + indicators + Stuff+)" = "full"
|
| 6236 |
+
),
|
| 6237 |
+
selected = "raw", inline = TRUE),
|
| 6238 |
+
conditionalPanel(
|
| 6239 |
+
condition = "input.download_mode == 'full'",
|
| 6240 |
+
div(style = "background: #d4edda; border-left: 4px solid #28a745; padding: 12px; border-radius: 0 8px 8px 0; margin: 10px 0;",
|
| 6241 |
+
p(style = "margin: 0; color: #155724; font-size: 13px;",
|
| 6242 |
+
tags$b("Full Processing will:"),
|
| 6243 |
+
"Normalize names & pitch types, convert PlateLocHeight/Side to inches, ",
|
| 6244 |
+
"add swing/whiff/chase/zone flags, compute wOBA/SLG/OBP indicators, ",
|
| 6245 |
+
"join run values, predict Stuff+, and trim to ~140 columns.")
|
| 6246 |
+
)
|
| 6247 |
+
),
|
| 6248 |
+
conditionalPanel(
|
| 6249 |
+
condition = "input.download_mode == 'raw'",
|
| 6250 |
+
div(style = "background: #e8f4f8; border-left: 4px solid darkcyan; padding: 12px; border-radius: 0 8px 8px 0; margin: 10px 0;",
|
| 6251 |
+
p(style = "margin: 0; color: #006F71; font-size: 13px;",
|
| 6252 |
+
tags$b("Raw Edited:"),
|
| 6253 |
+
"Downloads your current data with any pitch retags, column removals, ",
|
| 6254 |
+
"bat tracking merges, and catcher notes applied — no additional processing.")
|
| 6255 |
+
)
|
| 6256 |
+
),
|
| 6257 |
+
hr(),
|
| 6258 |
+
fluidRow(
|
| 6259 |
+
column(5,
|
| 6260 |
+
textInput("download_filename", "File Name (without extension):",
|
| 6261 |
+
value = paste0("app_ready_COA_", Sys.Date()))
|
| 6262 |
+
),
|
| 6263 |
+
column(3,
|
| 6264 |
+
radioButtons("download_format", "Export Format:",
|
| 6265 |
+
choices = c("CSV" = "csv", "Parquet" = "parquet"),
|
| 6266 |
+
selected = "csv", inline = TRUE)
|
| 6267 |
+
),
|
| 6268 |
+
hr(),
|
| 6269 |
+
radioButtons("download_team_filter", "Team Filter:",
|
| 6270 |
+
choices = c(
|
| 6271 |
+
"All Data" = "all",
|
| 6272 |
+
"Coastal Pitchers Only (PitcherTeam = COA_CHA)" = "coastal_pitchers",
|
| 6273 |
+
"Coastal Hitters Only (BatterTeam = COA_CHA)" = "coastal_hitters"
|
| 6274 |
+
),
|
| 6275 |
+
selected = "all"),
|
| 6276 |
+
hr(),
|
| 6277 |
+
column(4,
|
| 6278 |
+
br(),
|
| 6279 |
+
downloadButton("downloadData", "Download", class = "btn-success btn-lg")
|
| 6280 |
+
)
|
| 6281 |
+
)
|
| 6282 |
+
),
|
| 6283 |
+
|
| 6284 |
+
br(),
|
| 6285 |
+
h4("Data Summary:"),
|
| 6286 |
+
verbatimTextOutput("data_summary")
|
| 6287 |
+
)
|
| 6288 |
+
)
|
| 6289 |
+
),
|
| 6290 |
+
#Scrape Tab
|
| 6291 |
+
tabPanel(
|
| 6292 |
+
"Scraping",
|
| 6293 |
+
fluidRow(
|
| 6294 |
+
column(2,
|
| 6295 |
+
h4("Data Source", style = "color: darkcyan; border-bottom: 2px solid darkcyan; padding-bottom: 6px;"),
|
| 6296 |
+
radioButtons("scrape_source", NULL,
|
| 6297 |
+
choices = c("TrackMan PBP" = "pbp",
|
| 6298 |
+
"TrackMan Positional" = "pos",
|
| 6299 |
+
"NCAA Scoreboard" = "ncaa"),
|
| 6300 |
+
selected = "pbp")
|
| 6301 |
+
),
|
| 6302 |
+
|
| 6303 |
+
column(4,
|
| 6304 |
+
h3("Controls"),
|
| 6305 |
+
|
| 6306 |
+
dateInput("start_date", "Start Date:", value = Sys.Date() - 1),
|
| 6307 |
+
dateInput("end_date", "End Date:", value = Sys.Date() - 1),
|
| 6308 |
+
|
| 6309 |
+
uiOutput("scrape_options"),
|
| 6310 |
+
|
| 6311 |
+
br(),
|
| 6312 |
+
actionButton("scrape_btn", "Scrape Data", class = "btn-primary"),
|
| 6313 |
+
br(), br(),
|
| 6314 |
+
downloadButton("download_scrape", "Download CSV"),
|
| 6315 |
+
actionButton("upload_hf_btn", "Upload to HF Dataset", class = "btn-download")
|
| 6316 |
+
),
|
| 6317 |
+
|
| 6318 |
+
column(6,
|
| 6319 |
+
h3("Progress"),
|
| 6320 |
+
verbatimTextOutput("scrape_status"),
|
| 6321 |
+
hr(),
|
| 6322 |
+
h3("Data Preview"),
|
| 6323 |
+
DT::dataTableOutput("scrape_preview")
|
| 6324 |
+
)
|
| 6325 |
+
)
|
| 6326 |
+
)
|
| 6327 |
+
),,
|
| 6328 |
+
bsModal("pitchEditModal", "Edit Pitch Type", "triggerModal", size = "medium",
|
| 6329 |
+
div(style = "padding: 20px;",
|
| 6330 |
+
h4("Selected Pitch Details:", style = "color: darkcyan;"),
|
| 6331 |
+
verbatimTextOutput("selected_pitch_info"),
|
| 6332 |
+
br(),
|
| 6333 |
+
selectInput("modal_new_pitch_type", "Change Pitch Type To:",
|
| 6334 |
+
choices = c("Fastball", "Sinker", "Cutter", "Slider",
|
| 6335 |
+
"Curveball", "ChangeUp", "Splitter", "Knuckleball", "Sweeper", "Other"),
|
| 6336 |
+
selected = "Fastball"),
|
| 6337 |
+
br(),
|
| 6338 |
+
actionButton("update_pitch", "Update Pitch Type", class = "btn-primary btn-lg"),
|
| 6339 |
+
actionButton("cancel_edit", "Cancel", class = "btn-default")
|
| 6340 |
)
|
|
|
|
|
|
|
| 6341 |
)
|
| 6342 |
)
|
| 6343 |
)
|
|
|
|
| 7383 |
contentType = "application/zip"
|
| 7384 |
)
|
| 7385 |
|
|
|
|
|
|
|
| 7386 |
|
| 7387 |
+
|
| 7388 |
+
# ===== ported data-processing / scraping / retag server logic =====
|
| 7389 |
+
uploaded_file_type <- reactiveVal("csv")
|
| 7390 |
+
|
| 7391 |
+
# Reactive values
|
| 7392 |
+
processed_data <- reactiveVal(NULL)
|
| 7393 |
+
plot_data <- reactiveVal(NULL)
|
| 7394 |
+
selected_pitch <- reactiveVal(NULL)
|
| 7395 |
+
selected_points <- reactiveVal(NULL)
|
| 7396 |
+
csv_data_raw <- reactiveVal(NULL)
|
| 7397 |
+
bat_tracking_parsed <- reactiveVal(NULL)
|
| 7398 |
+
merge_result <- reactiveVal(NULL)
|
| 7399 |
+
scraped_data <- reactiveVal(NULL)
|
| 7400 |
+
scrape_polling <- reactiveVal(FALSE)
|
| 7401 |
+
scrape_status_msg <- reactiveVal("Ready.")
|
| 7402 |
+
|
| 7403 |
+
# Catcher Notes: stored as a list of data frames, each row is one note
|
| 7404 |
+
catcher_notes_list <- reactiveVal(data.frame(
|
| 7405 |
+
NoteID = integer(0),
|
| 7406 |
+
Catcher = character(0),
|
| 7407 |
+
Batter = character(0),
|
| 7408 |
+
Inning = integer(0),
|
| 7409 |
+
Balls = integer(0),
|
| 7410 |
+
Strikes = integer(0),
|
| 7411 |
+
Result = character(0),
|
| 7412 |
+
MatchedRow = integer(0),
|
| 7413 |
+
stringsAsFactors = FALSE
|
| 7414 |
+
))
|
| 7415 |
+
|
| 7416 |
+
# Handle column selection buttons
|
| 7417 |
+
observeEvent(input$select_all_cols, {
|
| 7418 |
+
updateCheckboxGroupInput(session, "columns_to_remove",
|
| 7419 |
+
selected = columns_to_remove)
|
| 7420 |
})
|
| 7421 |
+
|
| 7422 |
+
observeEvent(input$deselect_all_cols, {
|
| 7423 |
+
updateCheckboxGroupInput(session, "columns_to_remove", selected = character(0))
|
| 7424 |
})
|
| 7425 |
+
|
| 7426 |
+
observeEvent(input$select_spinaxis, {
|
| 7427 |
+
spinaxis_cols <- columns_to_remove[grepl("SpinAxis3d", columns_to_remove)]
|
| 7428 |
+
updateCheckboxGroupInput(session, "columns_to_remove", selected = spinaxis_cols)
|
| 7429 |
})
|
| 7430 |
+
|
| 7431 |
+
# Shared helper: process raw data into processed_data / plot_data
|
| 7432 |
+
run_processing <- function(df) {
|
| 7433 |
+
# Merge bat tracking if available
|
| 7434 |
+
if (!is.null(bat_tracking_parsed()) && !is.null(bat_tracking_parsed()$data)) {
|
| 7435 |
+
result <- merge_with_bat_tracking(df, bat_tracking_parsed()$data)
|
| 7436 |
+
merge_result(result)
|
| 7437 |
+
df <- result$data
|
| 7438 |
}
|
| 7439 |
+
|
| 7440 |
+
selected_cols_to_remove <- input$columns_to_remove %||% character(0)
|
| 7441 |
+
processed_df <- df
|
| 7442 |
+
|
| 7443 |
+
if (length(selected_cols_to_remove) > 0) {
|
| 7444 |
+
columns_to_drop <- intersect(names(df), selected_cols_to_remove)
|
| 7445 |
+
if (length(columns_to_drop) > 0) {
|
| 7446 |
+
processed_df <- processed_df %>% select(-all_of(columns_to_drop))
|
| 7447 |
+
}
|
| 7448 |
+
}
|
| 7449 |
+
|
| 7450 |
+
processed_df <- processed_df %>% distinct()
|
| 7451 |
+
|
| 7452 |
+
processed_data(processed_df)
|
| 7453 |
+
plot_data(processed_df)
|
| 7454 |
+
|
| 7455 |
+
# Update catcher notes dropdowns
|
| 7456 |
+
update_catcher_note_choices(processed_df)
|
| 7457 |
+
|
| 7458 |
+
return(processed_df)
|
| 7459 |
+
}
|
| 7460 |
+
|
| 7461 |
+
# Helper to populate Catcher Notes dropdowns from current data
|
| 7462 |
+
update_catcher_note_choices <- function(df) {
|
| 7463 |
+
if (!is.null(df)) {
|
| 7464 |
+
if ("Catcher" %in% names(df)) {
|
| 7465 |
+
catchers <- sort(unique(df$Catcher[!is.na(df$Catcher) & df$Catcher != ""]))
|
| 7466 |
+
updateSelectInput(session, "cn_catcher", choices = catchers,
|
| 7467 |
+
selected = if (length(catchers) > 0) catchers[1] else NULL)
|
| 7468 |
+
}
|
| 7469 |
+
if ("Batter" %in% names(df)) {
|
| 7470 |
+
batters <- sort(unique(df$Batter[!is.na(df$Batter) & df$Batter != ""]))
|
| 7471 |
+
updateSelectInput(session, "cn_batter", choices = batters,
|
| 7472 |
+
selected = if (length(batters) > 0) batters[1] else NULL)
|
| 7473 |
+
}
|
| 7474 |
+
}
|
| 7475 |
+
}
|
| 7476 |
+
|
| 7477 |
+
# Re-process data when date format changes
|
| 7478 |
+
observeEvent(input$date_format, {
|
| 7479 |
+
req(input$file)
|
| 7480 |
+
|
| 7481 |
+
tryCatch({
|
| 7482 |
+
df <- read_uploaded_file(input$file$datapath, input$file$name,
|
| 7483 |
+
input$header, input$sep, input$quote)
|
| 7484 |
+
|
| 7485 |
+
df <- convert_date_columns(df, input$date_format)
|
| 7486 |
+
csv_data_raw(df)
|
| 7487 |
+
run_processing(df)
|
| 7488 |
+
|
| 7489 |
+
showNotification(
|
| 7490 |
+
paste("Date format updated to:", if (input$date_format == "mdyy") "M/D/YY" else "YYYY-MM-DD"),
|
| 7491 |
+
type = "message", duration = 3
|
| 7492 |
+
)
|
| 7493 |
+
|
| 7494 |
+
}, error = function(e) {
|
| 7495 |
+
showNotification(paste("Error updating date format:", e$message), type = "error")
|
| 7496 |
})
|
| 7497 |
+
}, ignoreInit = TRUE)
|
| 7498 |
+
|
| 7499 |
+
# Process uploaded file (CSV or Parquet)
|
| 7500 |
+
observeEvent(input$file, {
|
| 7501 |
+
req(input$file)
|
| 7502 |
+
|
| 7503 |
+
tryCatch({
|
| 7504 |
+
ext <- tolower(tools::file_ext(input$file$name))
|
| 7505 |
+
uploaded_file_type(ext)
|
| 7506 |
+
|
| 7507 |
+
df <- read_uploaded_file(input$file$datapath, input$file$name,
|
| 7508 |
+
input$header, input$sep, input$quote)
|
| 7509 |
+
|
| 7510 |
+
df <- convert_date_columns(df, input$date_format)
|
| 7511 |
+
csv_data_raw(df)
|
| 7512 |
+
|
| 7513 |
+
processed_df <- run_processing(df)
|
| 7514 |
+
|
| 7515 |
+
# Update pitcher choices
|
| 7516 |
+
if ("Pitcher" %in% names(processed_df)) {
|
| 7517 |
+
pitcher_choices <- sort(unique(processed_df$Pitcher[!is.na(processed_df$Pitcher)]))
|
| 7518 |
+
updateSelectInput(session, "pitcher_select", choices = pitcher_choices, selected = pitcher_choices[1])
|
| 7519 |
+
}
|
| 7520 |
+
|
| 7521 |
+
# Auto-populate download filename from uploaded file
|
| 7522 |
+
base_name <- tools::file_path_sans_ext(input$file$name)
|
| 7523 |
+
updateTextInput(session, "download_filename", value = paste0(base_name, "_processed"))
|
| 7524 |
+
|
| 7525 |
+
format_label <- if (ext == "parquet") "Parquet" else "CSV"
|
| 7526 |
showNotification(
|
| 7527 |
+
paste0(format_label, " loaded: ", nrow(df), " rows x ", ncol(df), " columns"),
|
| 7528 |
+
type = "message", duration = 3
|
|
|
|
| 7529 |
)
|
| 7530 |
+
|
| 7531 |
+
}, error = function(e) {
|
| 7532 |
+
showNotification(paste("Error processing file:", e$message), type = "error")
|
| 7533 |
+
})
|
| 7534 |
+
})
|
| 7535 |
+
|
| 7536 |
+
# Process uploaded JSON file
|
| 7537 |
+
observeEvent(input$json_file, {
|
| 7538 |
+
req(input$json_file)
|
| 7539 |
+
|
| 7540 |
+
tryCatch({
|
| 7541 |
+
parsed <- parse_bat_tracking_json(input$json_file$datapath)
|
| 7542 |
+
bat_tracking_parsed(parsed)
|
| 7543 |
+
|
| 7544 |
+
if (!is.null(csv_data_raw()) && parsed$success && !is.null(parsed$data)) {
|
| 7545 |
+
result <- merge_with_bat_tracking(csv_data_raw(), parsed$data)
|
| 7546 |
+
merge_result(result)
|
| 7547 |
+
|
| 7548 |
+
df <- result$data
|
| 7549 |
+
selected_cols_to_remove <- input$columns_to_remove %||% character(0)
|
| 7550 |
+
|
| 7551 |
+
if (length(selected_cols_to_remove) > 0) {
|
| 7552 |
+
columns_to_drop <- intersect(names(df), selected_cols_to_remove)
|
| 7553 |
+
if (length(columns_to_drop) > 0) {
|
| 7554 |
+
df <- df %>% select(-all_of(columns_to_drop))
|
| 7555 |
+
}
|
| 7556 |
+
}
|
| 7557 |
+
|
| 7558 |
+
df <- df %>% distinct()
|
| 7559 |
+
|
| 7560 |
+
processed_data(df)
|
| 7561 |
+
plot_data(df)
|
| 7562 |
+
|
| 7563 |
+
showNotification(result$message, type = "message", duration = 5)
|
| 7564 |
+
}
|
| 7565 |
+
|
| 7566 |
+
}, error = function(e) {
|
| 7567 |
+
showNotification(paste("Error processing JSON:", e$message), type = "error")
|
| 7568 |
+
})
|
| 7569 |
+
})
|
| 7570 |
+
|
| 7571 |
+
# CSV/Parquet status output
|
| 7572 |
+
output$csv_status <- renderText({
|
| 7573 |
+
if (is.null(input$file)) {
|
| 7574 |
+
return("No file uploaded yet. Accepts CSV or Parquet (max 5,000 rows).")
|
| 7575 |
+
}
|
| 7576 |
+
|
| 7577 |
+
if (is.null(csv_data_raw())) {
|
| 7578 |
+
return("Processing file...")
|
| 7579 |
}
|
| 7580 |
+
|
| 7581 |
+
df <- csv_data_raw()
|
| 7582 |
+
ext <- uploaded_file_type()
|
| 7583 |
+
format_label <- if (ext == "parquet") "Parquet" else "CSV"
|
| 7584 |
+
game_id <- if ("GameID" %in% names(df)) unique(df$GameID)[1] else "Unknown"
|
| 7585 |
+
date_fmt <- if (input$date_format == "mdyy") "M/D/YY" else "YYYY-MM-DD"
|
| 7586 |
+
|
| 7587 |
+
paste(
|
| 7588 |
+
paste0("\u2713 ", format_label, " loaded successfully!"),
|
| 7589 |
+
paste(" Game ID:", game_id),
|
| 7590 |
+
paste(" Rows:", nrow(df)),
|
| 7591 |
+
paste(" Columns:", ncol(df)),
|
| 7592 |
+
paste("\u2713 Date format:", date_fmt),
|
| 7593 |
+
sep = "\n"
|
| 7594 |
+
)
|
| 7595 |
})
|
| 7596 |
+
|
| 7597 |
+
# JSON status output
|
| 7598 |
+
output$json_status <- renderText({
|
| 7599 |
+
if (is.null(input$json_file)) {
|
| 7600 |
+
return("No JSON file uploaded yet.")
|
| 7601 |
+
}
|
| 7602 |
+
|
| 7603 |
+
parsed <- bat_tracking_parsed()
|
| 7604 |
+
if (is.null(parsed)) {
|
| 7605 |
+
return("Processing JSON...")
|
| 7606 |
+
}
|
| 7607 |
+
|
| 7608 |
+
if (!parsed$success) {
|
| 7609 |
+
return(paste("\u2717", parsed$message))
|
| 7610 |
+
}
|
| 7611 |
+
|
| 7612 |
+
paste(
|
| 7613 |
+
"\u2713 JSON parsed successfully!",
|
| 7614 |
+
paste(" Game Reference:", parsed$game_reference),
|
| 7615 |
+
paste(" Plays found:", parsed$plays_count %||% 0),
|
| 7616 |
+
sep = "\n"
|
| 7617 |
+
)
|
| 7618 |
+
})
|
| 7619 |
+
|
| 7620 |
+
# Merge status UI
|
| 7621 |
+
output$merge_status_ui <- renderUI({
|
| 7622 |
+
result <- merge_result()
|
| 7623 |
+
parsed <- bat_tracking_parsed()
|
| 7624 |
+
csv <- csv_data_raw()
|
| 7625 |
+
|
| 7626 |
+
if (is.null(parsed) || is.null(csv)) {
|
| 7627 |
+
return(NULL)
|
| 7628 |
+
}
|
| 7629 |
+
|
| 7630 |
+
if (!parsed$success) {
|
| 7631 |
+
return(div(class = "merge-status-box merge-error",
|
| 7632 |
+
h4("Merge Status", style = "margin-top: 0; color: #721c24;"),
|
| 7633 |
+
p(parsed$message)
|
| 7634 |
+
))
|
| 7635 |
+
}
|
| 7636 |
+
|
| 7637 |
+
if (is.null(parsed$data) || is.null(result)) {
|
| 7638 |
+
csv_game <- if ("GameID" %in% names(csv)) unique(csv$GameID)[1] else NULL
|
| 7639 |
+
json_game <- parsed$game_reference
|
| 7640 |
+
|
| 7641 |
+
if (!is.null(csv_game) && !is.null(json_game) && csv_game != json_game) {
|
| 7642 |
+
return(div(class = "merge-status-box merge-warning",
|
| 7643 |
+
h4("\u26A0 Game ID Mismatch", style = "margin-top: 0; color: #856404;"),
|
| 7644 |
+
p(paste("CSV Game:", csv_game)),
|
| 7645 |
+
p(paste("JSON Game:", json_game)),
|
| 7646 |
+
p("Files may be from different games!")
|
| 7647 |
+
))
|
| 7648 |
+
}
|
| 7649 |
+
|
| 7650 |
+
return(div(class = "merge-status-box merge-warning",
|
| 7651 |
+
h4("No Data to Merge", style = "margin-top: 0; color: #856404;"),
|
| 7652 |
+
p(parsed$message)
|
| 7653 |
+
))
|
| 7654 |
+
}
|
| 7655 |
+
|
| 7656 |
+
csv_game <- if ("GameID" %in% names(csv)) unique(csv$GameID)[1] else NULL
|
| 7657 |
+
json_game <- parsed$game_reference
|
| 7658 |
+
game_match <- is.null(csv_game) || is.null(json_game) || csv_game == json_game
|
| 7659 |
+
|
| 7660 |
+
if (result$matched > 0) {
|
| 7661 |
+
div(class = "merge-status-box merge-success",
|
| 7662 |
+
h4("\u2713 Merge Successful!", style = "margin-top: 0; color: #155724;"),
|
| 7663 |
+
p(paste("Matched:", result$matched, "of", result$total_bat, "bat tracking records")),
|
| 7664 |
+
if (!game_match) p(style = "color: #856404;", "\u26A0 Note: Game IDs differ but PitchUIDs matched")
|
| 7665 |
+
)
|
| 7666 |
+
} else {
|
| 7667 |
+
div(class = "merge-status-box merge-warning",
|
| 7668 |
+
h4("\u26A0 No Matches Found", style = "margin-top: 0; color: #856404;"),
|
| 7669 |
+
p(paste("0 of", result$total_bat, "bat tracking records matched")),
|
| 7670 |
+
if (!game_match) p(paste("Game ID mismatch: CSV =", csv_game, ", JSON =", json_game))
|
| 7671 |
+
)
|
| 7672 |
+
}
|
| 7673 |
+
})
|
| 7674 |
+
|
| 7675 |
+
# Bat tracking details
|
| 7676 |
+
output$bat_tracking_details <- renderUI({
|
| 7677 |
+
parsed <- bat_tracking_parsed()
|
| 7678 |
+
result <- merge_result()
|
| 7679 |
+
|
| 7680 |
+
if (is.null(parsed)) {
|
| 7681 |
+
return(div(
|
| 7682 |
+
p("No bat tracking JSON file uploaded."),
|
| 7683 |
+
p("Upload a _battracking.json file in the 'Upload & Process' tab to see bat tracking data here.")
|
| 7684 |
+
))
|
| 7685 |
+
}
|
| 7686 |
+
|
| 7687 |
+
if (!parsed$success) {
|
| 7688 |
+
return(div(class = "alert alert-danger", parsed$message))
|
| 7689 |
+
}
|
| 7690 |
+
|
| 7691 |
+
if (is.null(parsed$data)) {
|
| 7692 |
+
return(div(class = "alert alert-warning",
|
| 7693 |
+
h4("Empty Bat Tracking File"),
|
| 7694 |
+
p(parsed$message),
|
| 7695 |
+
p("The JSON file was valid but contained no swing data in the Plays array.")
|
| 7696 |
+
))
|
| 7697 |
+
}
|
| 7698 |
+
|
| 7699 |
+
div(
|
| 7700 |
+
div(class = "row",
|
| 7701 |
+
div(class = "col-md-4",
|
| 7702 |
+
div(class = "well",
|
| 7703 |
+
h4("Game Reference"),
|
| 7704 |
+
p(parsed$game_reference)
|
| 7705 |
+
)
|
| 7706 |
+
),
|
| 7707 |
+
div(class = "col-md-4",
|
| 7708 |
+
div(class = "well",
|
| 7709 |
+
h4("Total Swings Tracked"),
|
| 7710 |
+
p(style = "font-size: 24px; font-weight: bold; color: darkcyan;", parsed$plays_count)
|
| 7711 |
+
)
|
| 7712 |
+
),
|
| 7713 |
+
div(class = "col-md-4",
|
| 7714 |
+
div(class = "well",
|
| 7715 |
+
h4("Matched to CSV"),
|
| 7716 |
+
p(style = "font-size: 24px; font-weight: bold; color: #28a745;",
|
| 7717 |
+
if (!is.null(result)) result$matched else "N/A")
|
| 7718 |
+
)
|
| 7719 |
+
)
|
| 7720 |
+
)
|
| 7721 |
+
)
|
| 7722 |
+
})
|
| 7723 |
+
|
| 7724 |
+
# Bat tracking table
|
| 7725 |
+
output$bat_tracking_table <- DT::renderDataTable({
|
| 7726 |
+
df <- processed_data()
|
| 7727 |
+
|
| 7728 |
+
if (is.null(df)) {
|
| 7729 |
+
return(NULL)
|
| 7730 |
+
}
|
| 7731 |
+
|
| 7732 |
+
if ("BatSpeed_Sensor" %in% names(df)) {
|
| 7733 |
+
bat_rows <- df %>%
|
| 7734 |
+
filter(!is.na(BatSpeed_Sensor)) %>%
|
| 7735 |
+
select(
|
| 7736 |
+
any_of(c("PitchNo", "Time", "Pitcher", "Batter", "TaggedPitchType", "PitchCall",
|
| 7737 |
+
"RelSpeed", "ExitSpeed", "Angle",
|
| 7738 |
+
"BatSpeed", "BatSpeed_Sensor",
|
| 7739 |
+
"VerticalAttackAngle", "VerticalAttackAngle_Sensor",
|
| 7740 |
+
"HorizontalAttackAngle", "HorizontalAttackAngle_Sensor"))
|
| 7741 |
+
)
|
| 7742 |
+
|
| 7743 |
+
if (nrow(bat_rows) == 0) {
|
| 7744 |
+
return(NULL)
|
| 7745 |
+
}
|
| 7746 |
+
|
| 7747 |
+
DT::datatable(bat_rows,
|
| 7748 |
+
options = list(scrollX = TRUE, pageLength = 10),
|
| 7749 |
+
rownames = FALSE) %>%
|
| 7750 |
+
DT::formatRound(columns = intersect(names(bat_rows),
|
| 7751 |
+
c("BatSpeed_Sensor", "VerticalAttackAngle_Sensor",
|
| 7752 |
+
"HorizontalAttackAngle_Sensor", "RelSpeed",
|
| 7753 |
+
"ExitSpeed", "Angle")),
|
| 7754 |
+
digits = 1)
|
| 7755 |
} else {
|
| 7756 |
+
return(NULL)
|
| 7757 |
}
|
| 7758 |
})
|
| 7759 |
+
|
| 7760 |
+
# Processing summary
|
| 7761 |
+
output$process_summary <- renderText({
|
| 7762 |
+
if (is.null(input$file)) {
|
| 7763 |
+
return("No file uploaded yet.")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7764 |
}
|
| 7765 |
+
|
| 7766 |
+
if (is.null(processed_data())) {
|
| 7767 |
+
return("Processing...")
|
| 7768 |
+
}
|
| 7769 |
+
|
| 7770 |
+
df <- processed_data()
|
| 7771 |
+
original_df <- csv_data_raw()
|
| 7772 |
+
selected_cols_to_remove <- input$columns_to_remove %||% character(0)
|
| 7773 |
+
removed_cols <- intersect(selected_cols_to_remove, names(original_df))
|
| 7774 |
+
result <- merge_result()
|
| 7775 |
+
ext <- uploaded_file_type()
|
| 7776 |
+
format_label <- if (ext == "parquet") "Parquet" else "CSV"
|
| 7777 |
+
|
| 7778 |
+
removed_cols_text <- if (length(removed_cols) > 0) {
|
| 7779 |
+
paste("\u2713 Removed columns:", length(removed_cols))
|
| 7780 |
+
} else {
|
| 7781 |
+
"\u2713 Removed columns: 0"
|
| 7782 |
+
}
|
| 7783 |
+
|
| 7784 |
+
bat_tracking_text <- if (!is.null(result) && result$matched > 0) {
|
| 7785 |
+
paste("\u2713 Bat tracking merged:", result$matched, "pitches")
|
| 7786 |
+
} else if (!is.null(bat_tracking_parsed())) {
|
| 7787 |
+
"\u26A0 Bat tracking: No matches"
|
| 7788 |
+
} else {
|
| 7789 |
+
"\u25CB Bat tracking: Not uploaded"
|
| 7790 |
}
|
| 7791 |
+
|
| 7792 |
+
notes <- catcher_notes_list()
|
| 7793 |
+
notes_text <- if (nrow(notes) > 0) {
|
| 7794 |
+
paste("\u2713 Catcher notes:", nrow(notes), "logged")
|
| 7795 |
+
} else {
|
| 7796 |
+
"\u25CB Catcher notes: None"
|
| 7797 |
+
}
|
| 7798 |
+
|
| 7799 |
+
summary_text <- paste(
|
| 7800 |
+
paste0("\u2713 ", format_label, " file processed successfully!"),
|
| 7801 |
+
paste("\u2713 Original columns:", ncol(original_df)),
|
| 7802 |
+
paste("\u2713 Final columns:", ncol(df)),
|
| 7803 |
+
paste("\u2713 Rows processed:", nrow(df)),
|
| 7804 |
+
removed_cols_text,
|
| 7805 |
+
bat_tracking_text,
|
| 7806 |
+
notes_text,
|
| 7807 |
+
"\u2713 Duplicates removed",
|
| 7808 |
+
paste("\u2713 Date format:", if (input$date_format == "mdyy") "M/D/YY" else "YYYY-MM-DD"),
|
| 7809 |
+
sep = "\n"
|
| 7810 |
+
)
|
| 7811 |
+
|
| 7812 |
+
return(summary_text)
|
| 7813 |
})
|
| 7814 |
+
|
| 7815 |
+
# Preview table
|
| 7816 |
+
output$preview <- DT::renderDataTable({
|
| 7817 |
+
req(processed_data())
|
| 7818 |
+
|
| 7819 |
+
DT::datatable(processed_data(),
|
| 7820 |
+
options = list(scrollX = TRUE, pageLength = 10),
|
| 7821 |
+
filter = "top")
|
| 7822 |
+
})
|
| 7823 |
+
|
| 7824 |
+
# Movement plot
|
| 7825 |
+
output$movement_plot <- renderPlot({
|
| 7826 |
+
req(plot_data(), input$pitcher_select)
|
| 7827 |
+
|
| 7828 |
+
pitcher_data <- plot_data() %>%
|
| 7829 |
+
filter(Pitcher == input$pitcher_select) %>%
|
| 7830 |
+
filter(!is.na(TaggedPitchType), TaggedPitchType != "Other",
|
| 7831 |
+
!is.na(HorzBreak), !is.na(InducedVertBreak), !is.na(RelSpeed)) %>%
|
| 7832 |
+
mutate(pitch_id = row_number())
|
| 7833 |
+
|
| 7834 |
+
if (nrow(pitcher_data) == 0) {
|
| 7835 |
+
plot.new()
|
| 7836 |
+
text(0.5, 0.5, "No data available for selected pitcher", cex = 1.5)
|
| 7837 |
+
return()
|
| 7838 |
+
}
|
| 7839 |
+
|
| 7840 |
+
pitcher_data$color <- pitch_colors[pitcher_data$TaggedPitchType]
|
| 7841 |
+
pitcher_data$color[is.na(pitcher_data$color)] <- "#D3D3D3"
|
| 7842 |
+
|
| 7843 |
+
par(mar = c(5, 5, 4, 8), xpd = TRUE)
|
| 7844 |
+
plot(pitcher_data$HorzBreak, pitcher_data$InducedVertBreak,
|
| 7845 |
+
col = pitcher_data$color,
|
| 7846 |
+
pch = 19, cex = 1.5,
|
| 7847 |
+
xlim = c(-25, 25), ylim = c(-25, 25),
|
| 7848 |
+
xlab = "Horizontal Break (inches)",
|
| 7849 |
+
ylab = "Induced Vertical Break (inches)",
|
| 7850 |
+
main = paste("Pitch Movement Chart -", input$pitcher_select))
|
| 7851 |
+
|
| 7852 |
+
grid(nx = NULL, ny = NULL, col = "lightgray", lty = 1, lwd = 0.5)
|
| 7853 |
+
abline(h = 0, col = "gray", lty = 2, lwd = 1)
|
| 7854 |
+
abline(v = 0, col = "gray", lty = 2, lwd = 1)
|
| 7855 |
+
|
| 7856 |
+
for (r in c(6, 12, 18, 24)) {
|
| 7857 |
+
circle_x <- r * cos(seq(0, 2*pi, length.out = 100))
|
| 7858 |
+
circle_y <- r * sin(seq(0, 2*pi, length.out = 100))
|
| 7859 |
+
lines(circle_x, circle_y, col = "lightgray", lty = 3)
|
| 7860 |
+
}
|
| 7861 |
+
|
| 7862 |
+
if (input$selection_mode == "drag" && !is.null(selected_points())) {
|
| 7863 |
+
sel_points <- selected_points()
|
| 7864 |
+
points(sel_points$HorzBreak, sel_points$InducedVertBreak,
|
| 7865 |
+
pch = 21, cex = 2, col = "red", lwd = 3)
|
| 7866 |
+
}
|
| 7867 |
+
|
| 7868 |
+
unique_pitches <- unique(pitcher_data$TaggedPitchType)
|
| 7869 |
+
unique_colors <- pitch_colors[unique_pitches]
|
| 7870 |
+
legend("topright", inset = c(-0.15, 0),
|
| 7871 |
+
legend = unique_pitches,
|
| 7872 |
+
col = unique_colors,
|
| 7873 |
+
pch = 19,
|
| 7874 |
+
cex = 0.8,
|
| 7875 |
+
title = "Pitch Type")
|
| 7876 |
+
})
|
| 7877 |
+
|
| 7878 |
+
# Handle plot clicks (single mode only)
|
| 7879 |
+
observeEvent(input$plot_click, {
|
| 7880 |
+
req(plot_data(), input$pitcher_select, input$plot_click)
|
| 7881 |
+
|
| 7882 |
+
if (input$selection_mode != "single") return()
|
| 7883 |
+
|
| 7884 |
+
pitcher_data <- plot_data() %>%
|
| 7885 |
+
filter(Pitcher == input$pitcher_select) %>%
|
| 7886 |
+
filter(!is.na(TaggedPitchType), TaggedPitchType != "Other",
|
| 7887 |
+
!is.na(HorzBreak), !is.na(InducedVertBreak), !is.na(RelSpeed)) %>%
|
| 7888 |
+
mutate(pitch_id = row_number())
|
| 7889 |
+
|
| 7890 |
+
if (nrow(pitcher_data) == 0) return()
|
| 7891 |
+
|
| 7892 |
+
click_x <- input$plot_click$x
|
| 7893 |
+
click_y <- input$plot_click$y
|
| 7894 |
+
|
| 7895 |
+
distances <- sqrt((pitcher_data$HorzBreak - click_x)^2 +
|
| 7896 |
+
(pitcher_data$InducedVertBreak - click_y)^2)
|
| 7897 |
+
|
| 7898 |
+
closest_idx <- which.min(distances)
|
| 7899 |
+
|
| 7900 |
+
if (min(distances) <= 2) {
|
| 7901 |
+
clicked_pitch <- pitcher_data[closest_idx, ]
|
| 7902 |
+
|
| 7903 |
+
full_data <- plot_data() %>% filter(Pitcher == input$pitcher_select)
|
| 7904 |
+
original_row <- which(full_data$HorzBreak == clicked_pitch$HorzBreak &
|
| 7905 |
+
full_data$InducedVertBreak == clicked_pitch$InducedVertBreak &
|
| 7906 |
+
full_data$RelSpeed == clicked_pitch$RelSpeed)[1]
|
| 7907 |
+
|
| 7908 |
+
selected_pitch(list(
|
| 7909 |
+
pitcher = input$pitcher_select,
|
| 7910 |
+
row_in_pitcher_data = original_row,
|
| 7911 |
+
data = clicked_pitch,
|
| 7912 |
+
original_type = clicked_pitch$TaggedPitchType
|
| 7913 |
+
))
|
| 7914 |
+
|
| 7915 |
+
updateSelectInput(session, "modal_new_pitch_type",
|
| 7916 |
+
selected = clicked_pitch$TaggedPitchType)
|
| 7917 |
+
|
| 7918 |
+
showModal(modalDialog(
|
| 7919 |
+
title = "Edit Pitch Type",
|
| 7920 |
+
div(style = "padding: 20px;",
|
| 7921 |
+
h4("Selected Pitch Details:", style = "color: darkcyan;"),
|
| 7922 |
+
verbatimTextOutput("selected_pitch_info"),
|
| 7923 |
+
br(),
|
| 7924 |
+
selectInput("modal_new_pitch_type", "Change Pitch Type To:",
|
| 7925 |
+
choices = c("Fastball", "Sinker", "Cutter", "Slider",
|
| 7926 |
+
"Curveball", "ChangeUp", "Splitter", "Knuckleball", "Sweeper","Other"),
|
| 7927 |
+
selected = clicked_pitch$TaggedPitchType),
|
| 7928 |
+
br(),
|
| 7929 |
+
actionButton("update_pitch", "Update Pitch Type", class = "btn-primary btn-lg"),
|
| 7930 |
+
actionButton("cancel_edit", "Cancel", class = "btn-default")
|
| 7931 |
+
),
|
| 7932 |
+
footer = NULL,
|
| 7933 |
+
size = "m",
|
| 7934 |
+
easyClose = TRUE
|
| 7935 |
+
))
|
| 7936 |
+
}
|
| 7937 |
+
})
|
| 7938 |
+
|
| 7939 |
+
# Handle brush selection (drag mode)
|
| 7940 |
+
observeEvent(input$plot_brush, {
|
| 7941 |
+
req(plot_data(), input$pitcher_select, input$plot_brush)
|
| 7942 |
+
|
| 7943 |
+
if (input$selection_mode != "drag") return()
|
| 7944 |
+
|
| 7945 |
+
pitcher_data <- plot_data() %>%
|
| 7946 |
+
filter(Pitcher == input$pitcher_select) %>%
|
| 7947 |
+
filter(!is.na(TaggedPitchType), TaggedPitchType != "Other",
|
| 7948 |
+
!is.na(HorzBreak), !is.na(InducedVertBreak), !is.na(RelSpeed))
|
| 7949 |
+
|
| 7950 |
+
if (nrow(pitcher_data) == 0) return()
|
| 7951 |
+
|
| 7952 |
+
brush <- input$plot_brush
|
| 7953 |
+
brushed_points <- pitcher_data %>%
|
| 7954 |
+
filter(
|
| 7955 |
+
HorzBreak >= brush$xmin & HorzBreak <= brush$xmax &
|
| 7956 |
+
InducedVertBreak >= brush$ymin & InducedVertBreak <= brush$ymax
|
| 7957 |
+
)
|
| 7958 |
+
|
| 7959 |
+
if (nrow(brushed_points) > 0) {
|
| 7960 |
+
selected_points(brushed_points)
|
| 7961 |
+
} else {
|
| 7962 |
+
selected_points(NULL)
|
| 7963 |
+
}
|
| 7964 |
+
})
|
| 7965 |
+
|
| 7966 |
+
# Apply bulk change
|
| 7967 |
+
observeEvent(input$apply_bulk_change, {
|
| 7968 |
+
req(selected_points(), input$bulk_pitch_type)
|
| 7969 |
+
|
| 7970 |
+
sel_points <- selected_points()
|
| 7971 |
+
|
| 7972 |
+
if (nrow(sel_points) == 0) {
|
| 7973 |
+
showNotification("No points selected", type = "warning")
|
| 7974 |
+
return()
|
| 7975 |
+
}
|
| 7976 |
+
|
| 7977 |
+
current_data <- plot_data()
|
| 7978 |
+
|
| 7979 |
+
for (i in 1:nrow(sel_points)) {
|
| 7980 |
+
point <- sel_points[i, ]
|
| 7981 |
+
current_data <- current_data %>%
|
| 7982 |
+
mutate(TaggedPitchType = ifelse(
|
| 7983 |
+
Pitcher == input$pitcher_select &
|
| 7984 |
+
abs(HorzBreak - point$HorzBreak) < 0.01 &
|
| 7985 |
+
abs(InducedVertBreak - point$InducedVertBreak) < 0.01 &
|
| 7986 |
+
abs(RelSpeed - point$RelSpeed) < 0.01,
|
| 7987 |
+
input$bulk_pitch_type,
|
| 7988 |
+
TaggedPitchType
|
| 7989 |
+
))
|
| 7990 |
+
}
|
| 7991 |
+
|
| 7992 |
+
plot_data(current_data)
|
| 7993 |
+
processed_data(current_data)
|
| 7994 |
+
selected_points(NULL)
|
| 7995 |
+
|
| 7996 |
+
showNotification(
|
| 7997 |
+
paste("Updated", nrow(sel_points), "pitches to", input$bulk_pitch_type),
|
| 7998 |
+
type = "message", duration = 3
|
| 7999 |
)
|
| 8000 |
})
|
| 8001 |
+
|
| 8002 |
+
# Selection info for drag mode
|
| 8003 |
+
output$selection_info <- renderText({
|
| 8004 |
+
if (input$selection_mode == "drag" && !is.null(selected_points())) {
|
| 8005 |
+
sel_points <- selected_points()
|
| 8006 |
+
pitch_counts <- table(sel_points$TaggedPitchType)
|
| 8007 |
+
paste(nrow(sel_points), "points selected:",
|
| 8008 |
+
paste(names(pitch_counts), "(", pitch_counts, ")", collapse = ", "))
|
| 8009 |
+
} else {
|
| 8010 |
+
"No points selected. Click and drag to select multiple pitches."
|
| 8011 |
}
|
| 8012 |
+
})
|
| 8013 |
+
|
| 8014 |
+
# Hover info
|
| 8015 |
+
output$hover_info <- renderText({
|
| 8016 |
+
req(input$plot_hover, plot_data(), input$pitcher_select)
|
| 8017 |
+
|
| 8018 |
+
pitcher_data <- plot_data() %>%
|
| 8019 |
+
filter(Pitcher == input$pitcher_select) %>%
|
| 8020 |
+
filter(!is.na(TaggedPitchType), TaggedPitchType != "Other",
|
| 8021 |
+
!is.na(HorzBreak), !is.na(InducedVertBreak), !is.na(RelSpeed))
|
| 8022 |
+
|
| 8023 |
+
if (nrow(pitcher_data) == 0) return("")
|
| 8024 |
+
|
| 8025 |
+
hover_x <- input$plot_hover$x
|
| 8026 |
+
hover_y <- input$plot_hover$y
|
| 8027 |
+
|
| 8028 |
+
distances <- sqrt((pitcher_data$HorzBreak - hover_x)^2 +
|
| 8029 |
+
(pitcher_data$InducedVertBreak - hover_y)^2)
|
| 8030 |
+
|
| 8031 |
+
if (min(distances) <= 2) {
|
| 8032 |
+
closest_idx <- which.min(distances)
|
| 8033 |
+
hover_pitch <- pitcher_data[closest_idx, ]
|
| 8034 |
+
|
| 8035 |
+
bat_info <- ""
|
| 8036 |
+
if ("BatSpeed_Sensor" %in% names(hover_pitch) && !is.na(hover_pitch$BatSpeed_Sensor)) {
|
| 8037 |
+
bat_info <- paste(" | Bat Speed:", round(hover_pitch$BatSpeed_Sensor, 1), "mph")
|
| 8038 |
+
}
|
| 8039 |
+
|
| 8040 |
+
paste("Hovering over:",
|
| 8041 |
+
paste("Type:", hover_pitch$TaggedPitchType),
|
| 8042 |
+
paste("Velocity:", round(hover_pitch$RelSpeed, 1), "mph"),
|
| 8043 |
+
paste("HB:", round(hover_pitch$HorzBreak, 1), "in"),
|
| 8044 |
+
paste("IVB:", round(hover_pitch$InducedVertBreak, 1), "in"),
|
| 8045 |
+
bat_info,
|
| 8046 |
+
sep = " | ")
|
| 8047 |
+
} else {
|
| 8048 |
+
""
|
| 8049 |
}
|
| 8050 |
+
})
|
| 8051 |
+
|
| 8052 |
+
# Movement stats table
|
| 8053 |
+
output$movement_stats <- DT::renderDataTable({
|
| 8054 |
+
req(plot_data(), input$pitcher_select)
|
| 8055 |
+
|
| 8056 |
+
data <- plot_data()
|
| 8057 |
+
|
| 8058 |
+
movement_stats <- data %>%
|
| 8059 |
+
filter(Pitcher == input$pitcher_select) %>%
|
| 8060 |
+
filter(!is.na(HorzBreak), !is.na(InducedVertBreak), !is.na(TaggedPitchType)) %>%
|
| 8061 |
+
mutate(
|
| 8062 |
+
pitch_group = case_when(
|
| 8063 |
+
TaggedPitchType %in% c("Fastball", "FourSeamFastBall", "FourSeamFastB", "Four-Seam", "4-Seam") ~ "Fastball",
|
| 8064 |
+
TaggedPitchType %in% c("OneSeamFastBall", "TwoSeamFastBall", "Sinker", "Two-Seam", "One-Seam") ~ "Sinker",
|
| 8065 |
+
TaggedPitchType %in% c("ChangeUp", "Changeup") ~ "Changeup",
|
| 8066 |
+
TRUE ~ TaggedPitchType
|
| 8067 |
+
),
|
| 8068 |
+
in_zone = ifelse("StrikeZoneIndicator" %in% names(.), StrikeZoneIndicator,
|
| 8069 |
+
ifelse(!is.na(PlateLocSide) & !is.na(PlateLocHeight) &
|
| 8070 |
+
PlateLocSide >= -0.95 & PlateLocSide <= 0.95 &
|
| 8071 |
+
PlateLocHeight >= 1.6 & PlateLocHeight <= 3.5, 1, 0)),
|
| 8072 |
+
is_whiff = ifelse("WhiffIndicator" %in% names(.), WhiffIndicator,
|
| 8073 |
+
ifelse(!is.na(PitchCall) & PitchCall == "StrikeSwinging", 1, 0)),
|
| 8074 |
+
chase = ifelse("Chaseindicator" %in% names(.), Chaseindicator,
|
| 8075 |
+
ifelse(!is.na(PitchCall) & !is.na(PlateLocSide) & !is.na(PlateLocHeight) &
|
| 8076 |
+
PitchCall %in% c("StrikeSwinging", "FoulBallNotFieldable", "FoulBall", "InPlay") &
|
| 8077 |
+
(PlateLocSide < -0.95 | PlateLocSide > 0.95 | PlateLocHeight < 1.6 | PlateLocHeight > 3.5), 1, 0))
|
| 8078 |
+
)
|
| 8079 |
+
|
| 8080 |
+
total_pitches <- nrow(movement_stats)
|
| 8081 |
+
|
| 8082 |
+
has_bat_speed <- "BatSpeed_Sensor" %in% names(movement_stats)
|
| 8083 |
+
|
| 8084 |
+
summary_stats <- movement_stats %>%
|
| 8085 |
+
group_by(`Pitch Type` = pitch_group) %>%
|
| 8086 |
+
summarise(
|
| 8087 |
+
Count = n(),
|
| 8088 |
+
`Usage%` = sprintf("%.1f%%", (n() / total_pitches) * 100),
|
| 8089 |
+
`Avg Velo` = sprintf("%.1f", mean(RelSpeed, na.rm = TRUE)),
|
| 8090 |
+
`Max Velo` = sprintf("%.1f", max(RelSpeed, na.rm = TRUE)),
|
| 8091 |
+
`Avg IVB` = sprintf("%.1f", mean(InducedVertBreak, na.rm = TRUE)),
|
| 8092 |
+
`Avg HB` = sprintf("%.1f", mean(HorzBreak, na.rm = TRUE)),
|
| 8093 |
+
`Avg Spin` = ifelse("SpinRate" %in% names(movement_stats),
|
| 8094 |
+
sprintf("%.0f", mean(SpinRate, na.rm = TRUE)),
|
| 8095 |
+
"\u2014"),
|
| 8096 |
+
`Avg Bat Speed` = if (has_bat_speed) {
|
| 8097 |
+
bat_vals <- BatSpeed_Sensor[!is.na(BatSpeed_Sensor)]
|
| 8098 |
+
if (length(bat_vals) > 0) sprintf("%.1f", mean(bat_vals)) else "\u2014"
|
| 8099 |
+
} else "\u2014",
|
| 8100 |
+
`Zone%` = sprintf("%.1f%%", round(mean(in_zone, na.rm = TRUE) * 100, 1)),
|
| 8101 |
+
`Whiff%` = sprintf("%.1f%%", round(mean(is_whiff, na.rm = TRUE) * 100, 1)),
|
| 8102 |
+
.groups = "drop"
|
| 8103 |
+
) %>%
|
| 8104 |
+
arrange(desc(Count))
|
| 8105 |
+
|
| 8106 |
+
DT::datatable(summary_stats,
|
| 8107 |
+
options = list(pageLength = 15, dom = 't', scrollX = TRUE),
|
| 8108 |
+
rownames = FALSE) %>%
|
| 8109 |
+
DT::formatStyle(columns = names(summary_stats), fontSize = '12px')
|
| 8110 |
+
})
|
| 8111 |
+
|
| 8112 |
+
# Selected pitch info in modal
|
| 8113 |
+
output$selected_pitch_info <- renderText({
|
| 8114 |
+
pitch_info <- selected_pitch()
|
| 8115 |
+
if (!is.null(pitch_info)) {
|
| 8116 |
+
pitch_data <- pitch_info$data
|
| 8117 |
+
|
| 8118 |
+
info_lines <- c(
|
| 8119 |
+
paste("Pitcher:", pitch_info$pitcher),
|
| 8120 |
+
if ("PitchNo" %in% names(pitch_data) && !is.na(pitch_data$PitchNo)) paste("Pitch No:", pitch_data$PitchNo) else NULL,
|
| 8121 |
+
if ("Batter" %in% names(pitch_data) && !is.na(pitch_data$Batter)) paste("Batter:", pitch_data$Batter) else NULL,
|
| 8122 |
+
paste("Current Type:", pitch_data$TaggedPitchType),
|
| 8123 |
+
paste("Velocity:", round(pitch_data$RelSpeed, 1), "mph"),
|
| 8124 |
+
paste("Horizontal Break:", round(pitch_data$HorzBreak, 1), "inches"),
|
| 8125 |
+
paste("Induced Vertical Break:", round(pitch_data$InducedVertBreak, 1), "inches")
|
| 8126 |
+
)
|
| 8127 |
+
|
| 8128 |
+
if ("SpinRate" %in% names(pitch_data) && !is.na(pitch_data$SpinRate)) {
|
| 8129 |
+
info_lines <- c(info_lines, paste("Spin Rate:", round(pitch_data$SpinRate, 0), "rpm"))
|
| 8130 |
+
}
|
| 8131 |
+
|
| 8132 |
+
if ("BatSpeed_Sensor" %in% names(pitch_data) && !is.na(pitch_data$BatSpeed_Sensor)) {
|
| 8133 |
+
info_lines <- c(info_lines,
|
| 8134 |
+
paste("Bat Speed:", round(pitch_data$BatSpeed_Sensor, 1), "mph"),
|
| 8135 |
+
paste("Vertical Attack Angle:", round(pitch_data$VerticalAttackAngle_Sensor, 1), "\u00B0"),
|
| 8136 |
+
paste("Horizontal Attack Angle:", round(pitch_data$HorizontalAttackAngle_Sensor, 1), "\u00B0"))
|
| 8137 |
+
}
|
| 8138 |
+
|
| 8139 |
+
if ("Date" %in% names(pitch_data) && !is.na(pitch_data$Date)) {
|
| 8140 |
+
info_lines <- c(info_lines, paste("Date:", pitch_data$Date))
|
| 8141 |
+
}
|
| 8142 |
+
|
| 8143 |
+
return(paste(info_lines, collapse = "\n"))
|
| 8144 |
+
} else {
|
| 8145 |
+
return("No pitch selected")
|
| 8146 |
+
}
|
| 8147 |
+
})
|
| 8148 |
+
|
| 8149 |
+
# Update pitch type
|
| 8150 |
+
observeEvent(input$update_pitch, {
|
| 8151 |
+
pitch_info <- selected_pitch()
|
| 8152 |
+
|
| 8153 |
+
if (!is.null(pitch_info)) {
|
| 8154 |
+
current_data <- plot_data()
|
| 8155 |
+
|
| 8156 |
+
target_pitcher <- pitch_info$pitcher
|
| 8157 |
+
target_hb <- pitch_info$data$HorzBreak
|
| 8158 |
+
target_ivb <- pitch_info$data$InducedVertBreak
|
| 8159 |
+
target_velo <- pitch_info$data$RelSpeed
|
| 8160 |
+
|
| 8161 |
+
current_data <- current_data %>%
|
| 8162 |
+
mutate(TaggedPitchType = ifelse(
|
| 8163 |
+
Pitcher == target_pitcher &
|
| 8164 |
+
abs(HorzBreak - target_hb) < 0.01 &
|
| 8165 |
+
abs(InducedVertBreak - target_ivb) < 0.01 &
|
| 8166 |
+
abs(RelSpeed - target_velo) < 0.01,
|
| 8167 |
+
input$modal_new_pitch_type,
|
| 8168 |
+
TaggedPitchType
|
| 8169 |
+
))
|
| 8170 |
+
|
| 8171 |
+
plot_data(current_data)
|
| 8172 |
+
processed_data(current_data)
|
| 8173 |
+
|
| 8174 |
+
removeModal()
|
| 8175 |
+
|
| 8176 |
+
showNotification(
|
| 8177 |
+
paste("Updated pitch from", pitch_info$original_type, "to", input$modal_new_pitch_type),
|
| 8178 |
+
type = "message", duration = 3
|
| 8179 |
+
)
|
| 8180 |
+
|
| 8181 |
+
selected_pitch(NULL)
|
| 8182 |
+
}
|
| 8183 |
+
})
|
| 8184 |
+
|
| 8185 |
+
# Cancel edit
|
| 8186 |
+
observeEvent(input$cancel_edit, {
|
| 8187 |
+
removeModal()
|
| 8188 |
+
selected_pitch(NULL)
|
| 8189 |
+
})
|
| 8190 |
+
|
| 8191 |
+
# ══════════════════════════════════════════════════════════════
|
| 8192 |
+
# Rule-Based Pitch Retagging — Server Logic
|
| 8193 |
+
# ══════════════════════════════════════════════════════════════
|
| 8194 |
+
|
| 8195 |
+
# Reactive: compute the set of pitches matching all active filters
|
| 8196 |
+
retag_matched <- reactive({
|
| 8197 |
+
req(plot_data(), input$pitcher_select)
|
| 8198 |
+
|
| 8199 |
+
df <- plot_data() %>%
|
| 8200 |
+
filter(Pitcher == input$pitcher_select)
|
| 8201 |
+
|
| 8202 |
+
# Filter: Current pitch type
|
| 8203 |
+
if (!is.null(input$retag_from_type) && input$retag_from_type != "") {
|
| 8204 |
+
df <- df %>% filter(TaggedPitchType == input$retag_from_type)
|
| 8205 |
+
}
|
| 8206 |
+
|
| 8207 |
+
# Filter: Date range
|
| 8208 |
+
if ("Date" %in% names(df)) {
|
| 8209 |
+
d_start <- input$retag_dates[1]
|
| 8210 |
+
d_end <- input$retag_dates[2]
|
| 8211 |
+
if (!is.na(d_start) && !is.na(d_end)) {
|
| 8212 |
+
df <- df %>%
|
| 8213 |
+
filter(!is.na(Date)) %>%
|
| 8214 |
+
mutate(.tmp_date = as.Date(Date)) %>%
|
| 8215 |
+
filter(.tmp_date >= as.Date(d_start), .tmp_date <= as.Date(d_end)) %>%
|
| 8216 |
+
select(-.tmp_date)
|
| 8217 |
+
}
|
| 8218 |
+
}
|
| 8219 |
+
|
| 8220 |
+
# Filter: Pitch number range (supports "1-50" or single number)
|
| 8221 |
+
if (!is.null(input$retag_pitch_no_range) && nzchar(trimws(input$retag_pitch_no_range))) {
|
| 8222 |
+
pn_text <- trimws(input$retag_pitch_no_range)
|
| 8223 |
+
if (grepl("^\\d+-\\d+$", pn_text)) {
|
| 8224 |
+
parts <- as.numeric(strsplit(pn_text, "-")[[1]])
|
| 8225 |
+
if ("PitchNo" %in% names(df)) {
|
| 8226 |
+
df <- df %>% filter(!is.na(PitchNo), PitchNo >= parts[1], PitchNo <= parts[2])
|
| 8227 |
+
}
|
| 8228 |
+
} else if (grepl("^\\d+$", pn_text)) {
|
| 8229 |
+
if ("PitchNo" %in% names(df)) {
|
| 8230 |
+
df <- df %>% filter(PitchNo == as.numeric(pn_text))
|
| 8231 |
+
}
|
| 8232 |
+
}
|
| 8233 |
+
}
|
| 8234 |
+
|
| 8235 |
+
# Filter: Velocity
|
| 8236 |
+
if (!is.na(input$retag_velo_min)) df <- df %>% filter(!is.na(RelSpeed), RelSpeed >= input$retag_velo_min)
|
| 8237 |
+
if (!is.na(input$retag_velo_max)) df <- df %>% filter(!is.na(RelSpeed), RelSpeed <= input$retag_velo_max)
|
| 8238 |
+
|
| 8239 |
+
# Filter: Spin Rate
|
| 8240 |
+
if ("SpinRate" %in% names(df)) {
|
| 8241 |
+
if (!is.na(input$retag_spin_min)) df <- df %>% filter(!is.na(SpinRate), SpinRate >= input$retag_spin_min)
|
| 8242 |
+
if (!is.na(input$retag_spin_max)) df <- df %>% filter(!is.na(SpinRate), SpinRate <= input$retag_spin_max)
|
| 8243 |
+
}
|
| 8244 |
+
|
| 8245 |
+
# Filter: Induced Vertical Break
|
| 8246 |
+
if (!is.na(input$retag_ivb_min)) df <- df %>% filter(!is.na(InducedVertBreak), InducedVertBreak >= input$retag_ivb_min)
|
| 8247 |
+
if (!is.na(input$retag_ivb_max)) df <- df %>% filter(!is.na(InducedVertBreak), InducedVertBreak <= input$retag_ivb_max)
|
| 8248 |
+
|
| 8249 |
+
# Filter: Horizontal Break
|
| 8250 |
+
if (!is.na(input$retag_hb_min)) df <- df %>% filter(!is.na(HorzBreak), HorzBreak >= input$retag_hb_min)
|
| 8251 |
+
if (!is.na(input$retag_hb_max)) df <- df %>% filter(!is.na(HorzBreak), HorzBreak <= input$retag_hb_max)
|
| 8252 |
+
|
| 8253 |
+
# Filter: Vertical Break
|
| 8254 |
+
if ("VertBreak" %in% names(df)) {
|
| 8255 |
+
if (!is.na(input$retag_vb_min)) df <- df %>% filter(!is.na(VertBreak), VertBreak >= input$retag_vb_min)
|
| 8256 |
+
if (!is.na(input$retag_vb_max)) df <- df %>% filter(!is.na(VertBreak), VertBreak <= input$retag_vb_max)
|
| 8257 |
+
}
|
| 8258 |
+
|
| 8259 |
+
df
|
| 8260 |
+
})
|
| 8261 |
+
|
| 8262 |
+
# Preview button: show what would be retagged
|
| 8263 |
+
observeEvent(input$retag_preview_btn, {
|
| 8264 |
+
matched <- retag_matched()
|
| 8265 |
+
|
| 8266 |
+
output$retag_preview_summary <- renderUI({
|
| 8267 |
+
n <- nrow(matched)
|
| 8268 |
+
if (n == 0) {
|
| 8269 |
+
div(class = "merge-status-box merge-warning",
|
| 8270 |
+
style = "margin-bottom: 10px;",
|
| 8271 |
+
p(style = "margin: 0; font-weight: 600; color: #856404;",
|
| 8272 |
+
paste0("No pitches match these filters for ", input$pitcher_select, ".")))
|
| 8273 |
+
} else {
|
| 8274 |
+
type_counts <- table(matched$TaggedPitchType)
|
| 8275 |
+
type_str <- paste(names(type_counts), "(", type_counts, ")", collapse = ", ")
|
| 8276 |
+
div(class = "merge-status-box merge-success",
|
| 8277 |
+
style = "margin-bottom: 10px;",
|
| 8278 |
+
p(style = "margin: 0; font-weight: 600; color: #155724;",
|
| 8279 |
+
paste0(n, " pitches matched \u2014 will retag to ", input$retag_to_type)),
|
| 8280 |
+
p(style = "margin: 4px 0 0 0; color: #155724; font-size: 13px;", type_str))
|
| 8281 |
+
}
|
| 8282 |
+
})
|
| 8283 |
+
|
| 8284 |
+
output$retag_preview_table <- DT::renderDataTable({
|
| 8285 |
+
if (nrow(matched) == 0) return(NULL)
|
| 8286 |
+
|
| 8287 |
+
preview_cols <- intersect(
|
| 8288 |
+
c("PitchNo", "Date", "Batter", "TaggedPitchType", "RelSpeed",
|
| 8289 |
+
"SpinRate", "InducedVertBreak", "HorzBreak", "VertBreak",
|
| 8290 |
+
"PlateLocSide", "PlateLocHeight", "PitchCall"),
|
| 8291 |
+
names(matched)
|
| 8292 |
+
)
|
| 8293 |
+
|
| 8294 |
+
DT::datatable(
|
| 8295 |
+
matched %>% select(all_of(preview_cols)),
|
| 8296 |
+
options = list(scrollX = TRUE, pageLength = 8, dom = "tip"),
|
| 8297 |
+
rownames = FALSE
|
| 8298 |
+
) %>%
|
| 8299 |
+
DT::formatRound(
|
| 8300 |
+
columns = intersect(preview_cols, c("RelSpeed", "InducedVertBreak", "HorzBreak", "VertBreak")),
|
| 8301 |
+
digits = 1
|
| 8302 |
+
)
|
| 8303 |
+
})
|
| 8304 |
+
})
|
| 8305 |
+
|
| 8306 |
+
# Apply retag: actually change TaggedPitchType
|
| 8307 |
+
observeEvent(input$retag_apply_btn, {
|
| 8308 |
+
matched <- retag_matched()
|
| 8309 |
+
|
| 8310 |
+
if (nrow(matched) == 0) {
|
| 8311 |
+
showNotification("No pitches match the current filters.", type = "warning")
|
| 8312 |
+
return()
|
| 8313 |
+
}
|
| 8314 |
+
|
| 8315 |
+
current_data <- plot_data()
|
| 8316 |
+
|
| 8317 |
+
# Build composite key from available columns to uniquely identify pitches
|
| 8318 |
+
key_cols <- intersect(c("PitchUID", "PitchNo", "Date", "Pitcher", "RelSpeed",
|
| 8319 |
+
"HorzBreak", "InducedVertBreak"), names(current_data))
|
| 8320 |
+
|
| 8321 |
+
make_key <- function(df) {
|
| 8322 |
+
do.call(paste, c(df[key_cols], sep = "||"))
|
| 8323 |
+
}
|
| 8324 |
+
|
| 8325 |
+
matched_keys <- make_key(matched)
|
| 8326 |
+
|
| 8327 |
+
current_data <- current_data %>%
|
| 8328 |
+
mutate(.retag_key = make_key(cur_data())) %>%
|
| 8329 |
+
mutate(TaggedPitchType = ifelse(
|
| 8330 |
+
.retag_key %in% matched_keys & Pitcher == input$pitcher_select,
|
| 8331 |
+
input$retag_to_type,
|
| 8332 |
+
TaggedPitchType
|
| 8333 |
+
)) %>%
|
| 8334 |
+
select(-.retag_key)
|
| 8335 |
+
|
| 8336 |
+
plot_data(current_data)
|
| 8337 |
+
processed_data(current_data)
|
| 8338 |
+
|
| 8339 |
+
n_matched <- nrow(matched)
|
| 8340 |
+
showNotification(
|
| 8341 |
+
paste0("Retagged ", n_matched, " pitches to ", input$retag_to_type),
|
| 8342 |
+
type = "message", duration = 4
|
| 8343 |
+
)
|
| 8344 |
+
|
| 8345 |
+
# Update preview to show completion
|
| 8346 |
+
output$retag_preview_summary <- renderUI({
|
| 8347 |
+
div(class = "merge-status-box merge-success",
|
| 8348 |
+
style = "margin-bottom: 10px;",
|
| 8349 |
+
p(style = "margin: 0; font-weight: 600; color: #155724;",
|
| 8350 |
+
paste0("\u2713 Done! ", n_matched, " pitches retagged to ", input$retag_to_type, ".")))
|
| 8351 |
+
})
|
| 8352 |
+
output$retag_preview_table <- DT::renderDataTable({ NULL })
|
| 8353 |
+
})
|
| 8354 |
+
|
| 8355 |
+
# Clear all retag filters
|
| 8356 |
+
observeEvent(input$retag_clear_btn, {
|
| 8357 |
+
updateSelectInput(session, "retag_from_type", selected = "")
|
| 8358 |
+
updateDateRangeInput(session, "retag_dates", start = NA, end = NA)
|
| 8359 |
+
updateTextInput(session, "retag_pitch_no_range", value = "")
|
| 8360 |
+
updateNumericInput(session, "retag_velo_min", value = NA)
|
| 8361 |
+
updateNumericInput(session, "retag_velo_max", value = NA)
|
| 8362 |
+
updateNumericInput(session, "retag_spin_min", value = NA)
|
| 8363 |
+
updateNumericInput(session, "retag_spin_max", value = NA)
|
| 8364 |
+
updateNumericInput(session, "retag_ivb_min", value = NA)
|
| 8365 |
+
updateNumericInput(session, "retag_ivb_max", value = NA)
|
| 8366 |
+
updateNumericInput(session, "retag_hb_min", value = NA)
|
| 8367 |
+
updateNumericInput(session, "retag_hb_max", value = NA)
|
| 8368 |
+
updateNumericInput(session, "retag_vb_min", value = NA)
|
| 8369 |
+
updateNumericInput(session, "retag_vb_max", value = NA)
|
| 8370 |
+
|
| 8371 |
+
output$retag_preview_summary <- renderUI({ NULL })
|
| 8372 |
+
output$retag_preview_table <- DT::renderDataTable({ NULL })
|
| 8373 |
+
})
|
| 8374 |
+
|
| 8375 |
+
# ══════════════════════════════════════════════════════════════
|
| 8376 |
+
# End Rule-Based Retagging
|
| 8377 |
+
# ══════════════════════════════════════════════════════════════
|
| 8378 |
+
|
| 8379 |
+
# ══════════════════════════════════════════════════════════════
|
| 8380 |
+
# Catcher Notes — Server Logic
|
| 8381 |
+
# ══════════════════════════════════════════════════════════════
|
| 8382 |
+
|
| 8383 |
+
# Add a catcher note
|
| 8384 |
+
observeEvent(input$cn_add_btn, {
|
| 8385 |
+
req(processed_data(), input$cn_catcher, input$cn_batter)
|
| 8386 |
+
|
| 8387 |
+
df <- processed_data()
|
| 8388 |
+
|
| 8389 |
+
# Determine the result text
|
| 8390 |
+
result_text <- input$cn_result
|
| 8391 |
+
if (result_text == "Other" && !is.null(input$cn_custom_result) && nzchar(trimws(input$cn_custom_result))) {
|
| 8392 |
+
result_text <- trimws(input$cn_custom_result)
|
| 8393 |
+
}
|
| 8394 |
+
|
| 8395 |
+
# Find the matching row(s): Catcher + Batter + Inning + Balls + Strikes
|
| 8396 |
+
# We match the LAST pitch in that count for that matchup in that inning
|
| 8397 |
+
# (the event most likely happened on the final pitch of that count)
|
| 8398 |
+
has_catcher <- "Catcher" %in% names(df)
|
| 8399 |
+
has_batter <- "Batter" %in% names(df)
|
| 8400 |
+
has_inning <- "Inning" %in% names(df)
|
| 8401 |
+
has_balls <- "Balls" %in% names(df)
|
| 8402 |
+
has_strikes <- "Strikes" %in% names(df)
|
| 8403 |
+
|
| 8404 |
+
candidates <- df
|
| 8405 |
+
if (has_catcher) candidates <- candidates %>% filter(Catcher == input$cn_catcher)
|
| 8406 |
+
if (has_batter) candidates <- candidates %>% filter(Batter == input$cn_batter)
|
| 8407 |
+
if (has_inning) candidates <- candidates %>% filter(Inning == input$cn_inning)
|
| 8408 |
+
if (has_balls) candidates <- candidates %>% filter(Balls == input$cn_balls)
|
| 8409 |
+
if (has_strikes) candidates <- candidates %>% filter(Strikes == input$cn_strikes)
|
| 8410 |
+
|
| 8411 |
+
# Get the row index in the full dataframe for the last matching pitch
|
| 8412 |
+
if (nrow(candidates) > 0) {
|
| 8413 |
+
# Find which rows in the full df match
|
| 8414 |
+
match_idx <- which(
|
| 8415 |
+
(if (has_catcher) df$Catcher == input$cn_catcher else TRUE) &
|
| 8416 |
+
(if (has_batter) df$Batter == input$cn_batter else TRUE) &
|
| 8417 |
+
(if (has_inning) df$Inning == input$cn_inning else TRUE) &
|
| 8418 |
+
(if (has_balls) df$Balls == input$cn_balls else TRUE) &
|
| 8419 |
+
(if (has_strikes) df$Strikes == input$cn_strikes else TRUE)
|
| 8420 |
+
)
|
| 8421 |
+
matched_row <- max(match_idx) # last pitch at that count
|
| 8422 |
+
} else {
|
| 8423 |
+
matched_row <- NA_integer_
|
| 8424 |
+
}
|
| 8425 |
+
|
| 8426 |
+
# Build the new note
|
| 8427 |
+
notes <- catcher_notes_list()
|
| 8428 |
+
new_id <- if (nrow(notes) == 0) 1L else max(notes$NoteID) + 1L
|
| 8429 |
+
|
| 8430 |
+
new_note <- data.frame(
|
| 8431 |
+
NoteID = new_id,
|
| 8432 |
+
Catcher = input$cn_catcher,
|
| 8433 |
+
Batter = input$cn_batter,
|
| 8434 |
+
Inning = as.integer(input$cn_inning),
|
| 8435 |
+
Balls = as.integer(input$cn_balls),
|
| 8436 |
+
Strikes = as.integer(input$cn_strikes),
|
| 8437 |
+
Result = result_text,
|
| 8438 |
+
MatchedRow = matched_row,
|
| 8439 |
+
stringsAsFactors = FALSE
|
| 8440 |
+
)
|
| 8441 |
+
|
| 8442 |
+
catcher_notes_list(bind_rows(notes, new_note))
|
| 8443 |
+
|
| 8444 |
+
# Show match feedback
|
| 8445 |
+
output$cn_match_feedback <- renderUI({
|
| 8446 |
+
if (!is.na(matched_row)) {
|
| 8447 |
+
pitch_info <- df[matched_row, ]
|
| 8448 |
+
detail_parts <- c()
|
| 8449 |
+
if ("PitchNo" %in% names(pitch_info) && !is.na(pitch_info$PitchNo))
|
| 8450 |
+
detail_parts <- c(detail_parts, paste("Pitch #", pitch_info$PitchNo))
|
| 8451 |
+
if ("PitchCall" %in% names(pitch_info) && !is.na(pitch_info$PitchCall))
|
| 8452 |
+
detail_parts <- c(detail_parts, pitch_info$PitchCall)
|
| 8453 |
+
if ("Pitcher" %in% names(pitch_info) && !is.na(pitch_info$Pitcher))
|
| 8454 |
+
detail_parts <- c(detail_parts, paste("vs", pitch_info$Pitcher))
|
| 8455 |
+
|
| 8456 |
+
div(class = "merge-status-box merge-success",
|
| 8457 |
+
style = "margin-top: 10px;",
|
| 8458 |
+
p(style = "margin: 0; font-weight: 600; color: #155724;",
|
| 8459 |
+
paste0("\u2713 Matched to row ", matched_row)),
|
| 8460 |
+
if (length(detail_parts) > 0)
|
| 8461 |
+
p(style = "margin: 4px 0 0 0; color: #155724; font-size: 13px;",
|
| 8462 |
+
paste(detail_parts, collapse = " | "))
|
| 8463 |
+
)
|
| 8464 |
+
} else {
|
| 8465 |
+
div(class = "merge-status-box merge-warning",
|
| 8466 |
+
style = "margin-top: 10px;",
|
| 8467 |
+
p(style = "margin: 0; font-weight: 600; color: #856404;",
|
| 8468 |
+
paste0("\u26A0 No matching pitch found for ", input$cn_catcher,
|
| 8469 |
+
" / ", input$cn_batter, " / Inn ", input$cn_inning,
|
| 8470 |
+
" / ", input$cn_balls, "-", input$cn_strikes)),
|
| 8471 |
+
p(style = "margin: 4px 0 0 0; color: #856404; font-size: 13px;",
|
| 8472 |
+
"Note saved anyway \u2014 it will appear in CatcherNotes column as unmatched.")
|
| 8473 |
+
)
|
| 8474 |
+
}
|
| 8475 |
+
})
|
| 8476 |
+
|
| 8477 |
+
showNotification(
|
| 8478 |
+
paste0("Added: ", result_text, " (", input$cn_catcher, " / ", input$cn_batter,
|
| 8479 |
+
" / Inn ", input$cn_inning, " / ", input$cn_balls, "-", input$cn_strikes, ")"),
|
| 8480 |
+
type = "message", duration = 3
|
| 8481 |
+
)
|
| 8482 |
+
})
|
| 8483 |
+
|
| 8484 |
+
# Render the notes table
|
| 8485 |
+
output$cn_notes_table <- DT::renderDataTable({
|
| 8486 |
+
notes <- catcher_notes_list()
|
| 8487 |
+
if (nrow(notes) == 0) return(NULL)
|
| 8488 |
+
|
| 8489 |
+
display_notes <- notes %>%
|
| 8490 |
+
mutate(
|
| 8491 |
+
Count = paste0(Balls, "-", Strikes),
|
| 8492 |
+
Match = ifelse(is.na(MatchedRow), "\u2717 No match", paste0("\u2713 Row ", MatchedRow))
|
| 8493 |
+
) %>%
|
| 8494 |
+
select(NoteID, Catcher, Batter, Inning, Count, Result, Match)
|
| 8495 |
+
|
| 8496 |
+
DT::datatable(
|
| 8497 |
+
display_notes,
|
| 8498 |
+
options = list(
|
| 8499 |
+
scrollX = TRUE, pageLength = 15, dom = "tip",
|
| 8500 |
+
columnDefs = list(list(className = "dt-center", targets = "_all"))
|
| 8501 |
+
),
|
| 8502 |
+
rownames = FALSE,
|
| 8503 |
+
selection = "single",
|
| 8504 |
+
callback = DT::JS("
|
| 8505 |
+
table.on('click', 'tr', function() {
|
| 8506 |
+
var data = table.row(this).data();
|
| 8507 |
+
if (data) {
|
| 8508 |
+
Shiny.setInputValue('cn_delete_row', data[0], {priority: 'event'});
|
| 8509 |
+
}
|
| 8510 |
+
});
|
| 8511 |
+
")
|
| 8512 |
+
) %>%
|
| 8513 |
+
DT::formatStyle("Match",
|
| 8514 |
+
color = DT::styleEqual(c("\u2717 No match"), c("#dc3545")),
|
| 8515 |
+
fontWeight = "bold"
|
| 8516 |
+
)
|
| 8517 |
+
})
|
| 8518 |
+
|
| 8519 |
+
# Delete a single note by clicking its row
|
| 8520 |
+
observeEvent(input$cn_delete_row, {
|
| 8521 |
+
notes <- catcher_notes_list()
|
| 8522 |
+
note_id <- as.integer(input$cn_delete_row)
|
| 8523 |
+
|
| 8524 |
+
if (note_id %in% notes$NoteID) {
|
| 8525 |
+
showModal(modalDialog(
|
| 8526 |
+
title = "Delete Catcher Note?",
|
| 8527 |
+
p(paste("Remove note #", note_id, "?")),
|
| 8528 |
+
footer = tagList(
|
| 8529 |
+
actionButton("cn_confirm_delete", "Delete", class = "btn-danger"),
|
| 8530 |
+
modalButton("Cancel")
|
| 8531 |
+
),
|
| 8532 |
+
size = "s", easyClose = TRUE
|
| 8533 |
+
))
|
| 8534 |
+
}
|
| 8535 |
+
})
|
| 8536 |
+
|
| 8537 |
+
observeEvent(input$cn_confirm_delete, {
|
| 8538 |
+
notes <- catcher_notes_list()
|
| 8539 |
+
note_id <- as.integer(input$cn_delete_row)
|
| 8540 |
+
catcher_notes_list(notes %>% filter(NoteID != note_id))
|
| 8541 |
+
removeModal()
|
| 8542 |
+
showNotification(paste("Deleted note #", note_id), type = "message", duration = 2)
|
| 8543 |
+
})
|
| 8544 |
+
|
| 8545 |
+
# Clear all notes
|
| 8546 |
+
observeEvent(input$cn_clear_all_btn, {
|
| 8547 |
+
showModal(modalDialog(
|
| 8548 |
+
title = "Clear All Catcher Notes?",
|
| 8549 |
+
p("This will remove all logged catcher notes. This cannot be undone."),
|
| 8550 |
+
footer = tagList(
|
| 8551 |
+
actionButton("cn_confirm_clear_all", "Clear All", class = "btn-danger"),
|
| 8552 |
+
modalButton("Cancel")
|
| 8553 |
+
),
|
| 8554 |
+
size = "s", easyClose = TRUE
|
| 8555 |
+
))
|
| 8556 |
+
})
|
| 8557 |
+
|
| 8558 |
+
observeEvent(input$cn_confirm_clear_all, {
|
| 8559 |
+
catcher_notes_list(data.frame(
|
| 8560 |
+
NoteID = integer(0), Catcher = character(0), Batter = character(0),
|
| 8561 |
+
Inning = integer(0), Balls = integer(0), Strikes = integer(0),
|
| 8562 |
+
Result = character(0), MatchedRow = integer(0), stringsAsFactors = FALSE
|
| 8563 |
+
))
|
| 8564 |
+
removeModal()
|
| 8565 |
+
output$cn_match_feedback <- renderUI({ NULL })
|
| 8566 |
+
showNotification("All catcher notes cleared.", type = "message", duration = 2)
|
| 8567 |
+
})
|
| 8568 |
+
|
| 8569 |
+
# Catcher notes summary
|
| 8570 |
+
output$cn_summary <- renderText({
|
| 8571 |
+
notes <- catcher_notes_list()
|
| 8572 |
+
if (nrow(notes) == 0) return("No notes logged yet.")
|
| 8573 |
+
|
| 8574 |
+
n_matched <- sum(!is.na(notes$MatchedRow))
|
| 8575 |
+
n_unmatched <- sum(is.na(notes$MatchedRow))
|
| 8576 |
+
result_counts <- table(notes$Result)
|
| 8577 |
+
result_str <- paste(names(result_counts), "(", result_counts, ")", collapse = ", ")
|
| 8578 |
+
|
| 8579 |
+
paste(
|
| 8580 |
+
paste("Total notes:", nrow(notes)),
|
| 8581 |
+
paste("Matched:", n_matched, "| Unmatched:", n_unmatched),
|
| 8582 |
+
paste("Results:", result_str),
|
| 8583 |
+
sep = "\n"
|
| 8584 |
+
)
|
| 8585 |
+
})
|
| 8586 |
+
|
| 8587 |
+
build_download_data <- function() {
|
| 8588 |
+
df <- processed_data()
|
| 8589 |
+
if (is.null(df)) return(NULL)
|
| 8590 |
+
|
| 8591 |
+
# ── Full processing mode: clean + stuff+ ──
|
| 8592 |
+
if (!is.null(input$download_mode) && input$download_mode == "full") {
|
| 8593 |
+
df <- tryCatch({
|
| 8594 |
+
showNotification("Running clean_college_data()...", type = "message", duration = 2)
|
| 8595 |
+
cleaned <- clean_college_data(df)
|
| 8596 |
+
|
| 8597 |
+
showNotification("Predicting Stuff+...", type = "message", duration = 2)
|
| 8598 |
+
cleaned <- predict_stuffplus(cleaned)
|
| 8599 |
+
|
| 8600 |
+
showNotification(
|
| 8601 |
+
paste0("Full processing complete: ", nrow(cleaned), " rows × ", ncol(cleaned), " columns"),
|
| 8602 |
+
type = "message", duration = 4
|
| 8603 |
+
)
|
| 8604 |
+
cleaned
|
| 8605 |
+
}, error = function(e) {
|
| 8606 |
+
showNotification(paste("Processing error:", e$message), type = "error", duration = 8)
|
| 8607 |
+
processed_data()
|
| 8608 |
+
})
|
| 8609 |
+
}
|
| 8610 |
+
|
| 8611 |
+
if (!is.null(input$download_team_filter) && input$download_team_filter != "all") {
|
| 8612 |
+
if (input$download_team_filter == "coastal_pitchers" && "PitcherTeam" %in% names(df)) {
|
| 8613 |
+
df <- df %>% filter(PitcherTeam == "COA_CHA")
|
| 8614 |
+
} else if (input$download_team_filter == "coastal_hitters" && "BatterTeam" %in% names(df)) {
|
| 8615 |
+
df <- df %>% filter(BatterTeam == "COA_CHA")
|
| 8616 |
+
}
|
| 8617 |
+
}
|
| 8618 |
+
|
| 8619 |
+
# ── Merge catcher notes into existing Notes column ──
|
| 8620 |
+
notes <- catcher_notes_list()
|
| 8621 |
+
|
| 8622 |
+
if (nrow(notes) == 0) return(df)
|
| 8623 |
+
|
| 8624 |
+
# Initialize Notes column if not present
|
| 8625 |
+
if (!"Notes" %in% names(df)) {
|
| 8626 |
+
df$Notes <- NA_character_
|
| 8627 |
+
}
|
| 8628 |
+
|
| 8629 |
+
# For each note, append the result into the matched row's Notes
|
| 8630 |
+
for (i in seq_len(nrow(notes))) {
|
| 8631 |
+
row_idx <- notes$MatchedRow[i]
|
| 8632 |
+
result <- notes$Result[i]
|
| 8633 |
+
|
| 8634 |
+
if (!is.na(row_idx) && row_idx >= 1 && row_idx <= nrow(df)) {
|
| 8635 |
+
existing <- df$Notes[row_idx]
|
| 8636 |
+
if (is.na(existing) || existing == "") {
|
| 8637 |
+
df$Notes[row_idx] <- result
|
| 8638 |
+
} else {
|
| 8639 |
+
df$Notes[row_idx] <- paste(existing, result, sep = " | ")
|
| 8640 |
+
}
|
| 8641 |
+
}
|
| 8642 |
+
}
|
| 8643 |
+
|
| 8644 |
+
# Append unmatched notes to last row
|
| 8645 |
+
unmatched <- notes %>% filter(is.na(MatchedRow))
|
| 8646 |
+
if (nrow(unmatched) > 0) {
|
| 8647 |
+
unmatched_texts <- paste0(
|
| 8648 |
+
unmatched$Result, " (", unmatched$Catcher, "/", unmatched$Batter,
|
| 8649 |
+
" Inn", unmatched$Inning, " ", unmatched$Balls, "-", unmatched$Strikes, ")"
|
| 8650 |
+
)
|
| 8651 |
+
last_row <- nrow(df)
|
| 8652 |
+
existing <- df$Notes[last_row]
|
| 8653 |
+
unmatched_str <- paste0("[UNMATCHED] ", paste(unmatched_texts, collapse = "; "))
|
| 8654 |
+
if (is.na(existing) || existing == "") {
|
| 8655 |
+
df$Notes[last_row] <- unmatched_str
|
| 8656 |
+
} else {
|
| 8657 |
+
df$Notes[last_row] <- paste(existing, unmatched_str, sep = " | ")
|
| 8658 |
+
}
|
| 8659 |
+
}
|
| 8660 |
+
|
| 8661 |
+
return(df)
|
| 8662 |
+
}
|
| 8663 |
+
|
| 8664 |
+
|
| 8665 |
+
# ══════════════════════════════════════════════════════════════
|
| 8666 |
+
# End Catcher Notes
|
| 8667 |
+
# ══════════════════════════════════════════════════════════════
|
| 8668 |
+
|
| 8669 |
+
# Click info output
|
| 8670 |
+
output$click_info <- renderText({
|
| 8671 |
+
if (!is.null(selected_pitch())) {
|
| 8672 |
+
pitch_info <- selected_pitch()
|
| 8673 |
+
paste("Last selected pitch:", pitch_info$original_type,
|
| 8674 |
+
"| Position: (", round(pitch_info$data$HorzBreak, 1), ",",
|
| 8675 |
+
round(pitch_info$data$InducedVertBreak, 1), ")")
|
| 8676 |
+
} else {
|
| 8677 |
+
"No point selected yet. Click on a point in the chart above to edit its pitch type."
|
| 8678 |
+
}
|
| 8679 |
+
})
|
| 8680 |
+
|
| 8681 |
+
# Data summary for download page
|
| 8682 |
+
output$data_summary <- renderText({
|
| 8683 |
+
req(processed_data())
|
| 8684 |
+
df <- processed_data()
|
| 8685 |
+
result <- merge_result()
|
| 8686 |
+
notes <- catcher_notes_list()
|
| 8687 |
+
|
| 8688 |
+
bat_tracking_summary <- if (!is.null(result) && result$matched > 0) {
|
| 8689 |
+
paste("Bat tracking data:", result$matched, "pitches with swing metrics")
|
| 8690 |
+
} else {
|
| 8691 |
+
"Bat tracking data: None"
|
| 8692 |
+
}
|
| 8693 |
+
|
| 8694 |
+
notes_summary <- if (nrow(notes) > 0) {
|
| 8695 |
+
n_matched <- sum(!is.na(notes$MatchedRow))
|
| 8696 |
+
paste0("Catcher notes: ", nrow(notes), " total (", n_matched, " matched to rows)")
|
| 8697 |
+
} else {
|
| 8698 |
+
"Catcher notes: None"
|
| 8699 |
+
}
|
| 8700 |
+
|
| 8701 |
+
summary_text <- paste(
|
| 8702 |
+
paste("Total rows:", nrow(df)),
|
| 8703 |
+
paste("Total columns:", ncol(df)),
|
| 8704 |
+
paste("Date range:",
|
| 8705 |
+
if ("Date" %in% names(df) && !all(is.na(df$Date))) {
|
| 8706 |
+
paste(min(as.Date(df$Date), na.rm = TRUE), "to", max(as.Date(df$Date), na.rm = TRUE))
|
| 8707 |
+
} else {
|
| 8708 |
+
"Date column not available"
|
| 8709 |
+
}),
|
| 8710 |
+
paste("Unique pitchers:",
|
| 8711 |
+
if ("Pitcher" %in% names(df)) {
|
| 8712 |
+
length(unique(df$Pitcher[!is.na(df$Pitcher)]))
|
| 8713 |
+
} else {
|
| 8714 |
+
"Pitcher column not available"
|
| 8715 |
+
}),
|
| 8716 |
+
paste("Pitch types:",
|
| 8717 |
+
if ("TaggedPitchType" %in% names(df)) {
|
| 8718 |
+
paste(sort(unique(df$TaggedPitchType[!is.na(df$TaggedPitchType)])), collapse = ", ")
|
| 8719 |
+
} else {
|
| 8720 |
+
"TaggedPitchType column not available"
|
| 8721 |
+
}),
|
| 8722 |
+
bat_tracking_summary,
|
| 8723 |
+
notes_summary,
|
| 8724 |
+
paste("Source format:", toupper(uploaded_file_type())),
|
| 8725 |
+
paste("Date format:", if (input$date_format == "mdyy") "M/D/YY" else "YYYY-MM-DD"),
|
| 8726 |
+
sep = "\n"
|
| 8727 |
+
)
|
| 8728 |
+
|
| 8729 |
+
return(summary_text)
|
| 8730 |
+
})
|
| 8731 |
+
|
| 8732 |
+
# Download handler: CSV or Parquet with custom filename — NOW includes catcher notes
|
| 8733 |
+
output$downloadData <- downloadHandler(
|
| 8734 |
+
filename = function() {
|
| 8735 |
+
base_name <- gsub("[^A-Za-z0-9_\\-]", "_", input$download_filename)
|
| 8736 |
+
if (nchar(trimws(base_name)) == 0) base_name <- paste0("app_ready_COA_", Sys.Date())
|
| 8737 |
+
|
| 8738 |
+
ext <- input$download_format
|
| 8739 |
+
paste0(base_name, ".", ext)
|
| 8740 |
+
},
|
| 8741 |
+
content = function(file) {
|
| 8742 |
+
download_df <- build_download_data()
|
| 8743 |
+
|
| 8744 |
+
if (input$download_format == "parquet") {
|
| 8745 |
+
arrow::write_parquet(download_df, file)
|
| 8746 |
+
} else {
|
| 8747 |
+
write.csv(download_df, file, row.names = FALSE)
|
| 8748 |
+
}
|
| 8749 |
+
}
|
| 8750 |
+
)
|
| 8751 |
+
|
| 8752 |
+
|
| 8753 |
+
|
| 8754 |
+
#SCRAPER STUFF
|
| 8755 |
+
|
| 8756 |
+
output$scrape_options <- renderUI({
|
| 8757 |
+
switch(input$scrape_source,
|
| 8758 |
+
"pbp" = tagList(
|
| 8759 |
+
p("Scrapes TrackMan play-by-play data from FTP.")
|
| 8760 |
+
),
|
| 8761 |
+
"pos" = tagList(
|
| 8762 |
+
p("Scrapes TrackMan player positioning data from FTP.")
|
| 8763 |
+
),
|
| 8764 |
+
"ncaa" = tagList(
|
| 8765 |
+
selectInput("ncaa_division", "Division:", choices = c("D1", "D2", "D3")),
|
| 8766 |
+
p("Scrapes NCAA scoreboard data via API.")
|
| 8767 |
+
)
|
| 8768 |
+
)
|
| 8769 |
+
})
|
| 8770 |
+
|
| 8771 |
+
# Scrape button
|
| 8772 |
+
observeEvent(input$scrape_btn, {
|
| 8773 |
+
scrape_status_msg("Triggering scrape on GitHub...")
|
| 8774 |
+
|
| 8775 |
+
gh_token <- Sys.getenv("GITHUB_TOKEN")
|
| 8776 |
+
gh_repo <- Sys.getenv("GITHUB_REPO")
|
| 8777 |
+
|
| 8778 |
+
result <- tryCatch({
|
| 8779 |
+
httr::POST(
|
| 8780 |
+
paste0("https://api.github.com/repos/", gh_repo, "/actions/workflows/scrape.yml/dispatches"),
|
| 8781 |
+
httr::add_headers(
|
| 8782 |
+
Authorization = paste("Bearer", gh_token),
|
| 8783 |
+
Accept = "application/vnd.github.v3+json"
|
| 8784 |
+
),
|
| 8785 |
+
body = jsonlite::toJSON(list(
|
| 8786 |
+
ref = "main",
|
| 8787 |
+
inputs = list(
|
| 8788 |
+
start_date = as.character(input$start_date),
|
| 8789 |
+
end_date = as.character(input$end_date),
|
| 8790 |
+
data_type = input$scrape_source
|
| 8791 |
+
)
|
| 8792 |
+
), auto_unbox = TRUE),
|
| 8793 |
+
encode = "raw"
|
| 8794 |
+
)
|
| 8795 |
+
}, error = function(e) {
|
| 8796 |
+
scrape_status_msg(paste("Failed:", e$message))
|
| 8797 |
+
return(NULL)
|
| 8798 |
+
})
|
| 8799 |
+
|
| 8800 |
+
if (is.null(result)) return()
|
| 8801 |
+
|
| 8802 |
+
if (httr::status_code(result) == 204) {
|
| 8803 |
+
scrape_status_msg("Scrape triggered! Waiting for GitHub to finish...")
|
| 8804 |
+
scrape_polling(TRUE)
|
| 8805 |
+
} else {
|
| 8806 |
+
scrape_status_msg(paste("GitHub API error:", httr::status_code(result)))
|
| 8807 |
+
}
|
| 8808 |
+
})
|
| 8809 |
+
|
| 8810 |
+
# Poll GitHub every 15 seconds to check if done
|
| 8811 |
+
observe({
|
| 8812 |
+
req(scrape_polling())
|
| 8813 |
+
|
| 8814 |
+
invalidateLater(15000, session)
|
| 8815 |
+
|
| 8816 |
+
gh_token <- Sys.getenv("GITHUB_TOKEN")
|
| 8817 |
+
gh_repo <- Sys.getenv("GITHUB_REPO")
|
| 8818 |
+
|
| 8819 |
+
resp <- tryCatch({
|
| 8820 |
+
httr::GET(
|
| 8821 |
+
paste0("https://api.github.com/repos/", gh_repo, "/actions/runs?per_page=1"),
|
| 8822 |
+
httr::add_headers(
|
| 8823 |
+
Authorization = paste("Bearer", gh_token),
|
| 8824 |
+
Accept = "application/vnd.github.v3+json"
|
| 8825 |
+
)
|
| 8826 |
+
)
|
| 8827 |
+
}, error = function(e) { NULL })
|
| 8828 |
+
|
| 8829 |
+
if (is.null(resp)) return()
|
| 8830 |
+
|
| 8831 |
+
runs <- jsonlite::fromJSON(httr::content(resp, as = "text", encoding = "UTF-8"))
|
| 8832 |
+
|
| 8833 |
+
if (length(runs$workflow_runs) == 0) return()
|
| 8834 |
+
|
| 8835 |
+
latest <- runs$workflow_runs[1, ]
|
| 8836 |
+
status <- latest$status
|
| 8837 |
+
conclusion <- latest$conclusion
|
| 8838 |
+
|
| 8839 |
+
if (status == "completed") {
|
| 8840 |
+
scrape_polling(FALSE)
|
| 8841 |
+
|
| 8842 |
+
if (conclusion == "success") {
|
| 8843 |
+
scrape_status_msg("GitHub finished! Fetching data...")
|
| 8844 |
+
|
| 8845 |
+
filename <- paste0(input$scrape_source, "_", input$start_date, "_to_", input$end_date, ".csv.gz")
|
| 8846 |
+
url <- paste0("https://api.github.com/repos/", gh_repo, "/contents/data/", filename)
|
| 8847 |
+
|
| 8848 |
+
data <- tryCatch({
|
| 8849 |
+
file_resp <- httr::GET(
|
| 8850 |
+
url,
|
| 8851 |
+
httr::add_headers(
|
| 8852 |
+
Authorization = paste("Bearer", gh_token),
|
| 8853 |
+
Accept = "application/vnd.github.v3.raw"
|
| 8854 |
+
)
|
| 8855 |
+
)
|
| 8856 |
+
|
| 8857 |
+
if (httr::status_code(file_resp) == 200) {
|
| 8858 |
+
tmp <- tempfile(fileext = ".csv.gz")
|
| 8859 |
+
writeBin(httr::content(file_resp, as = "raw"), tmp)
|
| 8860 |
+
read_csv(gzfile(tmp))
|
| 8861 |
+
} else {
|
| 8862 |
+
NULL
|
| 8863 |
+
}
|
| 8864 |
+
}, error = function(e) { NULL })
|
| 8865 |
+
|
| 8866 |
+
if (!is.null(data) && nrow(data) > 0) {
|
| 8867 |
+
|
| 8868 |
+
if (input$scrape_source == "pbp") {
|
| 8869 |
+
scrape_status_msg("Processing data...")
|
| 8870 |
+
|
| 8871 |
+
data <- tryCatch({
|
| 8872 |
+
d <- clean_college_data(data)
|
| 8873 |
+
d <- predict_stuffplus(d)
|
| 8874 |
+
d
|
| 8875 |
+
}, error = function(e) {
|
| 8876 |
+
scrape_status_msg(paste("Processing error:", e$message))
|
| 8877 |
+
data
|
| 8878 |
+
})
|
| 8879 |
+
}
|
| 8880 |
+
|
| 8881 |
+
scraped_data(data)
|
| 8882 |
+
scrape_status_msg(paste0("Done! ", nrow(data), " rows \u00D7 ", ncol(data), " columns."))
|
| 8883 |
+
} else {
|
| 8884 |
+
scrape_status_msg("Scrape finished but couldn't fetch the file. Try 'Fetch Results' manually.")
|
| 8885 |
+
}
|
| 8886 |
+
|
| 8887 |
+
} else {
|
| 8888 |
+
scrape_status_msg(paste("GitHub Action failed:", conclusion))
|
| 8889 |
+
}
|
| 8890 |
+
|
| 8891 |
+
} else {
|
| 8892 |
+
scrape_status_msg(paste0("GitHub is running... (status: ", status, ")"))
|
| 8893 |
+
}
|
| 8894 |
+
})
|
| 8895 |
+
|
| 8896 |
+
# Status text
|
| 8897 |
+
output$scrape_status <- renderText({ scrape_status_msg() })
|
| 8898 |
+
|
| 8899 |
+
# Preview table
|
| 8900 |
+
output$scrape_preview <- DT::renderDataTable({
|
| 8901 |
+
req(scraped_data())
|
| 8902 |
+
DT::datatable(scraped_data(), options = list(scrollX = TRUE, pageLength = 10))
|
| 8903 |
+
})
|
| 8904 |
+
|
| 8905 |
+
# Download
|
| 8906 |
+
output$download_scrape <- downloadHandler(
|
| 8907 |
+
filename = function() {
|
| 8908 |
+
label <- switch(input$scrape_source, "pbp" = "pbp", "pos" = "positional", "ncaa" = "ncaa")
|
| 8909 |
+
paste0("trackman_", label, "_",
|
| 8910 |
+
format(input$start_date, "%Y%m%d"), "_to_",
|
| 8911 |
+
format(input$end_date, "%Y%m%d"), ".csv")
|
| 8912 |
+
},
|
| 8913 |
+
content = function(file) {
|
| 8914 |
+
req(scraped_data())
|
| 8915 |
+
write.csv(scraped_data(), file, row.names = FALSE)
|
| 8916 |
+
}
|
| 8917 |
+
)
|
| 8918 |
+
|
| 8919 |
+
|
| 8920 |
+
observeEvent(input$upload_hf_btn, {
|
| 8921 |
+
req(scraped_data())
|
| 8922 |
+
|
| 8923 |
+
hf_token <- Sys.getenv("HF_WRITE_TOKEN")
|
| 8924 |
+
repo_id <- "CoastalBaseball/2026MasterDataset"
|
| 8925 |
+
timestamp <- format(Sys.time(), "%Y%m%d_%H%M%S")
|
| 8926 |
+
|
| 8927 |
+
upload_to_hf <- function(new_data, folder, index_file, label) {
|
| 8928 |
+
scrape_status_msg(paste0("Checking existing UIDs for ", label, "..."))
|
| 8929 |
+
|
| 8930 |
+
existing_uids <- tryCatch({
|
| 8931 |
+
tmp_idx <- tempfile(fileext = ".csv.gz")
|
| 8932 |
+
resp <- httr::GET(
|
| 8933 |
+
paste0("https://huggingface.co/datasets/", repo_id, "/resolve/main/", index_file),
|
| 8934 |
+
httr::add_headers(Authorization = paste("Bearer", hf_token)),
|
| 8935 |
+
httr::write_disk(tmp_idx, overwrite = TRUE)
|
| 8936 |
+
)
|
| 8937 |
+
if (httr::status_code(resp) == 200) {
|
| 8938 |
+
d <- read.csv(gzfile(tmp_idx), stringsAsFactors = FALSE)
|
| 8939 |
+
file.remove(tmp_idx)
|
| 8940 |
+
d$PitchUID
|
| 8941 |
+
} else {
|
| 8942 |
+
file.remove(tmp_idx)
|
| 8943 |
+
character(0)
|
| 8944 |
+
}
|
| 8945 |
+
}, error = function(e) { character(0) })
|
| 8946 |
+
|
| 8947 |
+
scraped_rows <- nrow(new_data)
|
| 8948 |
+
|
| 8949 |
+
if (length(existing_uids) > 0 && "PitchUID" %in% names(new_data)) {
|
| 8950 |
+
new_only <- new_data %>% filter(!PitchUID %in% existing_uids)
|
| 8951 |
+
} else {
|
| 8952 |
+
new_only <- new_data
|
| 8953 |
+
}
|
| 8954 |
+
|
| 8955 |
+
new_rows <- nrow(new_only)
|
| 8956 |
+
total_after <- length(existing_uids) + new_rows
|
| 8957 |
+
|
| 8958 |
+
if (new_rows == 0) {
|
| 8959 |
+
return(paste0(label, ": ", scraped_rows, " rows scraped, 0 new rows added (", length(existing_uids), " total)"))
|
| 8960 |
+
}
|
| 8961 |
+
|
| 8962 |
+
scrape_status_msg(paste0("Uploading ", new_rows, " new rows for ", label, "..."))
|
| 8963 |
+
|
| 8964 |
+
hf <- reticulate::import("huggingface_hub")
|
| 8965 |
+
api <- hf$HfApi()
|
| 8966 |
+
|
| 8967 |
+
tmp_data <- tempfile(fileext = ".parquet")
|
| 8968 |
+
arrow::write_parquet(new_only, tmp_data)
|
| 8969 |
+
|
| 8970 |
+
api$upload_file(
|
| 8971 |
+
path_or_fileobj = tmp_data,
|
| 8972 |
+
path_in_repo = paste0(folder, "/", timestamp, ".parquet"),
|
| 8973 |
+
repo_id = repo_id,
|
| 8974 |
+
repo_type = "dataset",
|
| 8975 |
+
token = hf_token
|
| 8976 |
+
)
|
| 8977 |
+
file.remove(tmp_data)
|
| 8978 |
+
|
| 8979 |
+
scrape_status_msg(paste0("Updating ", label, " index..."))
|
| 8980 |
+
|
| 8981 |
+
all_uids <- data.frame(PitchUID = c(existing_uids, new_only$PitchUID))
|
| 8982 |
+
tmp_idx <- tempfile(fileext = ".csv.gz")
|
| 8983 |
+
gz <- gzfile(tmp_idx, "w")
|
| 8984 |
+
write.csv(all_uids, gz, row.names = FALSE)
|
| 8985 |
+
close(gz)
|
| 8986 |
+
|
| 8987 |
+
api$upload_file(
|
| 8988 |
+
path_or_fileobj = tmp_idx,
|
| 8989 |
+
path_in_repo = index_file,
|
| 8990 |
+
repo_id = repo_id,
|
| 8991 |
+
repo_type = "dataset",
|
| 8992 |
+
token = hf_token
|
| 8993 |
+
)
|
| 8994 |
+
file.remove(tmp_idx)
|
| 8995 |
+
|
| 8996 |
+
rm(new_only, all_uids); gc()
|
| 8997 |
+
paste0(label, ": ", scraped_rows, " rows scraped, ", new_rows, " new rows added (", total_after, " total)")
|
| 8998 |
+
}
|
| 8999 |
+
|
| 9000 |
+
if (input$scrape_source == "pbp") {
|
| 9001 |
+
msg1 <- upload_to_hf(scraped_data(), "pbp", "pbp_uid_index.csv.gz", "Master Dataset")
|
| 9002 |
+
gc()
|
| 9003 |
+
|
| 9004 |
+
cp <- scraped_data() %>% filter(PitcherTeam == "COA_CHA")
|
| 9005 |
+
msg2 <- if (nrow(cp) > 0) {
|
| 9006 |
+
upload_to_hf(cp, "coastal_pitchers", "coastal_pitchers_uid_index.csv.gz", "Coastal Pitchers")
|
| 9007 |
+
} else { "Coastal Pitchers: No matching rows" }
|
| 9008 |
+
rm(cp); gc()
|
| 9009 |
+
|
| 9010 |
+
ch <- scraped_data() %>% filter(BatterTeam == "COA_CHA")
|
| 9011 |
+
msg3 <- if (nrow(ch) > 0) {
|
| 9012 |
+
upload_to_hf(ch, "coastal_hitters", "coastal_hitters_uid_index.csv.gz", "Coastal Hitters")
|
| 9013 |
+
} else { "Coastal Hitters: No matching rows" }
|
| 9014 |
+
rm(ch); gc()
|
| 9015 |
+
|
| 9016 |
+
scrape_status_msg(paste(msg1, msg2, msg3, sep = "\n"))
|
| 9017 |
+
|
| 9018 |
+
} else if (input$scrape_source == "pos") {
|
| 9019 |
+
msg1 <- upload_to_hf(scraped_data(), "pos", "pos_uid_index.csv.gz", "Positional Dataset")
|
| 9020 |
+
scrape_status_msg(msg1)
|
| 9021 |
+
|
| 9022 |
+
} else if (input$scrape_source == "ncaa") {
|
| 9023 |
+
msg1 <- upload_to_hf(scraped_data(), "ncaa_pbp", "ncaa_pbp_uid_index.csv.gz", "NCAA PBP Dataset")
|
| 9024 |
+
scrape_status_msg(msg1)
|
| 9025 |
+
}
|
| 9026 |
+
})
|
| 9027 |
+
|
| 9028 |
}
|
| 9029 |
|
| 9030 |
shinyApp(ui = ui, server = server)
|