Sys.setenv(RETICULATE_PYTHON = "/usr/bin/python3") library(reticulate) library(shiny) library(shinydashboard) library(shinyBS) library(DT) library(dplyr) library(readr) library(stringr) library(jsonlite) library(httr) library(progressr) library(RCurl) library(curl) library(xgboost) library(recipes) library(arrow) library(base64enc) PASSWORD <- Sys.getenv("password") rv <- read_csv("non_context_run_values.csv") stuffplus_model <- xgb.load("stuffplus_xgb.json") stuffplus_recipe <- readRDS("stuffplus_recipe.rds") # Define columns to remove if they exist columns_to_remove <- c( "SpinAxis3dTransverseAngle", "SpinAxis3dLongitudinalAngle", "SpinAxis3dActiveSpinRate", "SpinAxis3dSpinEfficiency", "SpinAxis3dTilt", "SpinAxis3dVectorX", "SpinAxis3dVectorY", "SpinAxis3dVectorZ", "SpinAxis3dSeamOrientationRotationX", "SpinAxis3dSeamOrientationRotationY", "SpinAxis3dSeamOrientationRotationZ", "SpinAxis3dSeamOrientationBallYAmb1", "SpinAxis3dSeamOrientationBallAngleHorizontalAmb1", "SpinAxis3dSeamOrientationBallZAmb1", "SpinAxis3dSeamOrientationBallAngleVerticalAmb2", "SpinAxis3dSeamOrientationBallZAmb2", "SpinAxis3dSeamOrientationBallXAmb4", "SpinAxis3dSeamOrientationBallYAmb4", "SpinAxis3dSeamOrientationBallAngleHorizontalAmb2", "SpinAxis3dSeamOrientationBallAngleVerticalAmb1", "SpinAxis3dSeamOrientationBallXAmb1", "SpinAxis3dSeamOrientationBallYAmb2", "SpinAxis3dSeamOrientationBallAngleHorizontalAmb4", "SpinAxis3dSeamOrientationBallAngleVerticalAmb4", "SpinAxis3dSeamOrientationBallXAmb2", "SpinAxis3dSeamOrientationBallAngleVerticalAmb3", "SpinAxis3dSeamOrientationBallAngleHorizontalAmb3", "SpinAxis3dSeamOrientationBallXAmb3", "SpinAxis3dSeamOrientationBallYAmb3", "SpinAxis3dSeamOrientationBallZAmb3", "SpinAxis3dSeamOrientationBallZAmb4", "GameDate" ) # Pitch colors for visualization (Coastal Carolina theme) pitch_colors <- c( "Fastball" = '#FA8072', "Four-Seam" = '#FA8072', "Sinker" = "#fdae61", "Slider" = "#A020F0", "Sweeper" = "magenta", "Curveball" = '#2c7bb6', "ChangeUp" = '#90EE90', "Splitter" = '#90EE32', "Cutter" = "red", "Knuckleball" = "#FFB4B4", "Other" = "#D3D3D3" ) # Function to convert date formats # input_string: the date string to convert # output_format: "yyyy" for YYYY-MM-DD or "mdyy" for M/D/YY convert_date_format <- function(date_string, output_format = "yyyy") { if (is.na(date_string) || date_string == "") { return(NA) } # Convert to character if not already date_string <- as.character(date_string) parsed_date <- NULL # Try to parse YYYY-MM-DD format if (grepl("^\\d{4}-\\d{2}-\\d{2}$", date_string)) { parsed_date <- tryCatch({ as.Date(date_string, format = "%Y-%m-%d") }, error = function(e) NULL) } # Try to parse MM/DD/YYYY or M/D/YYYY format if (is.null(parsed_date) && grepl("^\\d{1,2}/\\d{1,2}/\\d{4}$", date_string)) { parsed_date <- tryCatch({ as.Date(date_string, format = "%m/%d/%Y") }, error = function(e) NULL) } # Try to parse MM/DD/YY or M/D/YY format if (is.null(parsed_date) && grepl("^\\d{1,2}/\\d{1,2}/\\d{2}$", date_string)) { parsed_date <- tryCatch({ as.Date(date_string, format = "%m/%d/%y") }, error = function(e) NULL) } # If we successfully parsed a date, format it according to output_format if (!is.null(parsed_date) && !is.na(parsed_date)) { if (output_format == "mdyy") { # M/D/YY format (no leading zeros, 2-digit year) return(format(parsed_date, "%m/%d/%y") %>% gsub("^0", "", .) %>% # Remove leading zero from month gsub("/0", "/", .)) # Remove leading zero from day } else { # YYYY-MM-DD format return(format(parsed_date, "%Y-%m-%d")) } } # Return original if no conversion possible return(date_string) } # Function to convert date columns in a dataframe convert_date_columns <- function(df, output_format = "yyyy") { # Common date column names in TrackMan data date_columns <- c("Date", "GameDate", "UTCDate", "LocalDateTime") for (col in date_columns) { if (col %in% names(df)) { df[[col]] <- sapply(df[[col]], function(x) convert_date_format(x, output_format), USE.NAMES = FALSE) } } return(df) } # Function to parse bat tracking JSON parse_bat_tracking_json <- function(json_path) { tryCatch({ json_data <- fromJSON(json_path, simplifyVector = FALSE) # Extract metadata game_reference <- json_data$GameReference session_id <- json_data$SessionId # Extract plays plays <- json_data$Plays if (length(plays) == 0) { return(list( success = TRUE, data = NULL, game_reference = game_reference, message = "JSON parsed but contains no bat tracking plays (empty Plays array)" )) } # Build data frame from plays bat_tracking_df <- data.frame( PitchUID = sapply(plays, function(p) p$PitchUID), BatSpeed_Sensor = sapply(plays, function(p) p$BatSpeed), VerticalAttackAngle_Sensor = sapply(plays, function(p) p$VerticalAttackAngle), HorizontalAttackAngle_Sensor = sapply(plays, function(p) p$HorizontalAttackAngle), BatTracking_PlayId = sapply(plays, function(p) p$PlayId), BatTracking_Time = sapply(plays, function(p) p$Time), stringsAsFactors = FALSE ) return(list( success = TRUE, data = bat_tracking_df, game_reference = game_reference, session_id = session_id, plays_count = length(plays), message = paste("Successfully parsed", length(plays), "bat tracking play(s)") )) }, error = function(e) { return(list( success = FALSE, data = NULL, message = paste("Error parsing JSON:", e$message) )) }) } # Function to merge CSV with bat tracking merge_with_bat_tracking <- function(csv_data, bat_tracking_data) { if (is.null(bat_tracking_data) || nrow(bat_tracking_data) == 0) { return(list( data = csv_data, matched = 0, total_bat = 0, message = "No bat tracking data to merge" )) } # Check if PitchUID exists in CSV if (!"PitchUID" %in% names(csv_data)) { return(list( data = csv_data, matched = 0, total_bat = nrow(bat_tracking_data), message = "CSV does not contain PitchUID column - cannot merge" )) } # Perform left join merged_data <- csv_data %>% left_join(bat_tracking_data, by = "PitchUID") # Count matches matched_count <- sum(!is.na(merged_data$BatSpeed_Sensor)) # If original BatSpeed column exists and is empty, fill with sensor data if ("BatSpeed" %in% names(merged_data)) { merged_data <- merged_data %>% mutate(BatSpeed = ifelse(is.na(BatSpeed) & !is.na(BatSpeed_Sensor), BatSpeed_Sensor, BatSpeed)) } if ("VerticalAttackAngle" %in% names(merged_data)) { merged_data <- merged_data %>% mutate(VerticalAttackAngle = ifelse(is.na(VerticalAttackAngle) & !is.na(VerticalAttackAngle_Sensor), VerticalAttackAngle_Sensor, VerticalAttackAngle)) } if ("HorizontalAttackAngle" %in% names(merged_data)) { merged_data <- merged_data %>% mutate(HorizontalAttackAngle = ifelse(is.na(HorizontalAttackAngle) & !is.na(HorizontalAttackAngle_Sensor), HorizontalAttackAngle_Sensor, HorizontalAttackAngle)) } return(list( data = merged_data, matched = matched_count, total_bat = nrow(bat_tracking_data), message = paste("Merged successfully:", matched_count, "of", nrow(bat_tracking_data), "bat tracking records matched") )) } clean_college_data <- function(data, teams = NA){ data <- data %>% mutate(PlayResult = ifelse(PlayResult %in% c("HomeRun", "homerun"), "Homerun", PlayResult), Batter = sub("(.*),\\s*(.*)", "\\2 \\1", Batter), Pitcher = sub("(.*),\\s*(.*)", "\\2 \\1", Pitcher), Catcher = sub("(.*),\\s*(.*)", "\\2 \\1", Catcher)) col <- colnames(data) if ("Top/Bottom" %in% col){ data <- data %>% rename(`Top.Bottom` = `Top/Bottom`) } numeric_columns <- c("PitchNo", "PAofInning", "PitchofPA", "PitcherId", "BatterId", "Inning", "Outs", "Balls", "Strikes", "OutsOnPlay", "RunsScored", "RelSpeed", "VertRelAngle", "HorzRelAngle", "SpinRate", "SpinAxis", "RelHeight", "RelSide", "Extension", "VertBreak", "InducedVertBreak", "HorzBreak", "PlateLocHeight", "PlateLocSide", "ZoneSpeed", "VertApprAngle", "HorzApprAngle", "ZoneTime", "ExitSpeed", "Angle", "Direction", "HitSpinRate", "Distance", "Bearing", "HangTime", "LastTrackedDistance", "pfxx", "pfxz", "x0", "y0", "z0", "vx0", "vz0", "vy0", "ax0", "ay0", "az0", "EffectiveVelo", "MaxHeight", "SpeedDrop", "ContactPositionX", "ContactPositionY", "ContactPositionZ", "HomeTeamForeignID", "AwayTeamForeignID", "CatcherId", "ThrowSpeed", "PopTime", "ExchangeTime", "TimeToBase") data <- data %>% mutate(across(any_of(numeric_columns), as.numeric), PlateLocHeight = if ("PlateLocHeight" %in% names(.)) 12 * PlateLocHeight else PlateLocHeight, PlateLocSide = if ("PlateLocSide" %in% names(.)) 12 * PlateLocSide else PlateLocSide) data <- data %>% mutate(TaggedPitchType = case_when( TaggedPitchType == "FourSeamFastBall" ~ "Fastball", TaggedPitchType %in% c("TwoSeamFastBall", "OneSeamFastBall") ~ "Sinker", TaggedPitchType == "ChangeUp" ~ "Changeup", TaggedPitchType == "Undefined" ~ "Other", T ~ TaggedPitchType )) data <- data %>% mutate( is_csw = case_when( PitchCall %in% c("StrikeSwinging", "StrikeCalled") ~ 1, TRUE ~ 0 ), is_swing = case_when( PitchCall %in% c("StrikeSwinging", "FoulBallNotFieldable", "InPlay", "FoulBallFieldable", "FoulBall") ~ 1, TRUE ~ 0 ), is_whiff = case_when( PitchCall == "StrikeSwinging" & is_swing == 1 ~ 1, PitchCall != "StrikeSwinging" & is_swing == 1 ~ 0, TRUE ~ NA_real_ ), in_zone = case_when( PlateLocSide > 9.975 | PlateLocSide < -9.975 | PlateLocHeight > 40 | PlateLocHeight < 20 ~ 0, TRUE ~ 1 ), chase = case_when( is_swing == 1 & in_zone == 0 ~ 1, is_swing == 0 & in_zone == 0 ~ 0, TRUE ~ NA_real_ ), in_zone_whiff = case_when( is_swing == 1 & in_zone == 1 & is_whiff == 1 ~ 1, is_swing == 1 & in_zone == 1 & is_whiff == 0 ~ 0, TRUE ~ NA_real_ ), is_hit = case_when( PlayResult %in% c("Single", "Double", "Triple", "Homerun", "HomeRun") & PitchCall == "InPlay" ~ 1, !PlayResult %in% c("Single", "Double", "Triple", "Homerun", "HomeRun") & PitchCall == "InPlay" ~ 0, KorBB == "Strikeout" ~ 0, PlayResult %in% c("StrikeoutSwinging", "StrikeoutLooking") ~ 0, TRUE ~ NA_real_ ), slg = case_when( PitchCall == "InPlay" & PlayResult == "Single" ~ 1, PitchCall == "InPlay" & PlayResult == "Double" ~ 2, PitchCall == "InPlay" & PlayResult == "Triple" ~ 3, PitchCall == "InPlay" & PlayResult %in% c("Homerun", "HomeRun") ~ 4, !PlayResult %in% c("Single", "Double", "Triple", "Homerun", "HomeRun") & PitchCall == "InPlay" ~ 0, KorBB == "Strikeout" ~ 0, PlayResult %in% c("StrikeoutSwinging", "StrikeoutLooking") ~ 0, TRUE ~ NA_real_ ), on_base = case_when( PitchCall == "InPlay" & PlayResult %in% c("Single", "Double", "Triple", "Homerun", "HomeRun") ~ 1, PitchCall %in% c("HitByPitch") | KorBB == "Walk" ~ 1, PitchCall == "InPlay" & PlayResult %in% c("Out", "Error", "FieldersChoice") & PlayResult != "Sacrifice" ~ 0, KorBB == "Strikeout" ~ 0, PlayResult %in% c("StrikeoutSwinging", "StrikeoutLooking") ~ 0, TRUE ~ NA_real_ ), is_hard_hit = case_when( ExitSpeed >= 95 & PitchCall == "InPlay" ~ 1, ExitSpeed < 95 & PitchCall == "InPlay" ~ 0, TRUE ~ NA_real_ ), woba = case_when( PitchCall == "InPlay" & PlayResult == "Single" ~ 0.95, PitchCall == "InPlay" & PlayResult == "Double" ~ 1.24, PitchCall == "InPlay" & PlayResult == "Triple" ~ 1.47, PitchCall == "InPlay" & PlayResult %in% c("Homerun", "HomeRun") ~ 1.71, KorBB == "Walk" ~ 0.82, PitchCall %in% c("HitByPitch") ~ 0.85, KorBB == "Strikeout" ~ 0, PlayResult %in% c("StrikeoutSwinging", "StrikeoutLooking") ~ 0, PitchCall == "InPlay" & !PlayResult %in% c("Single", "Double" ,"Triple" ,"Homerun", "HomeRun") ~ 0, TRUE ~ NA_real_ ), wobacon = case_when( PitchCall == "InPlay" & PlayResult == "Single" ~ 0.95, PitchCall == "InPlay" & PlayResult == "Double" ~ 1.24, PitchCall == "InPlay" & PlayResult == "Triple" ~ 1.47, PitchCall == "InPlay" & PlayResult %in% c("Homerun", "HomeRun") ~ 1.71, PitchCall == "InPlay" & !PlayResult %in% c("Single", "Double" ,"Triple" ,"Homerun", "HomeRun") ~ 0, TRUE ~ NA_real_ ), is_plate_appearance = ifelse( PitchCall %in% c("InPlay", "HitByPitch") | KorBB %in% c("Strikeout", "Walk") | PlayResult %in% c("StrikeoutSwinging", "StrikeoutLooking"), 1, 0 ), is_at_bat = case_when( PitchCall == "InPlay" & !PlayResult %in% c("StolenBase", "Sacrifice", "CaughtStealing", "Undefined") ~ 1, KorBB == "Strikeout" ~ 1, PlayResult %in% c("StrikeoutSwinging", "StrikeoutLooking") ~ 1, TRUE ~ 0 ), is_walk = case_when( is_plate_appearance == 1 & KorBB == "Walk" ~ 1, is_plate_appearance == 1 & KorBB != "Walk" ~ 0, TRUE ~ NA_real_ ), is_k = case_when( is_at_bat == 1 & KorBB == "Strikeout" ~ 1, is_at_bat == 1 & KorBB != "Strikeout" ~ 0, PlayResult %in% c("StrikeoutSwinging", "StrikeoutLooking") ~ 1, TRUE ~ NA_real_ ), is_put_away = case_when( Strikes == 2 & KorBB == "Strikeout" ~ 1, Strikes == 2 & KorBB != "Strikeout" ~ 0, Strikes == 2 & PlayResult %in% c("StrikeoutSwinging", "StrikeoutLooking") ~ 1, TRUE ~ NA_real_ ), OutsOnPlay = ifelse(KorBB == "Strikeout" | PlayResult %in% c("StrikeoutSwinging", "StrikeoutLooking"), OutsOnPlay + 1, OutsOnPlay) ) data <- data %>% mutate(event_type = case_when( PitchCall %in% c("StrikeSwinging", "StrkeSwinging") ~ "Whiff", PitchCall %in% c("StriekC", "StrikeCalled") ~ "Called Strike", PitchCall %in% c("FoulBallFieldable", "FoulBall", "FoulBallNotFieldable", "FouldBallNotFieldable") ~ "Foul Ball", PitchCall %in% c("BallCalled", "BallinDirt", "BallIntentional", "BalIntentional") ~ "Ball", PitchCall == "HitByPitch" ~ "HBP", PitchCall == "InPlay" & PlayResult %in% c("Out", "FieldersChoice", "Error", "error", "Sacrifice") ~ "Field Out", PitchCall == "InPlay" & PlayResult == "Single" ~ "Single", PitchCall == "InPlay" & PlayResult == "Double" ~ "Double", PitchCall == "InPlay" & PlayResult == "Triple" ~ "Triple", PitchCall == "InPlay" & PlayResult == "Homerun" ~ "Home Run", T ~ NA )) %>% left_join(rv, by = "event_type") data <- data %>% dplyr::select( -PitchLastMeasuredX, -PitchLastMeasuredY, -PitchLastMeasuredZ, -starts_with("PitchTrajectory"), -HitSpinAxis, -starts_with("HitTrajectory"), -PitchReleaseConfidence, -PitchLocationConfidence, -PitchMovementConfidence, -HitLaunchConfidence, -HitLandingConfidence, -CatcherThrowCatchConfidence, -CatcherThrowReleaseConfidence, -CatcherThrowLocationConfidence, -PositionAt110X, -PositionAt110Y, -PositionAt110Z, -BatSpeed, -VerticalAttackAngle, -HorizontalAttackAngle ) return(data) } predict_stuffplus <- function(data) { predict_data <- data %>% mutate(RelSide = case_when( PitcherThrows == "Right" ~ RelSide, PitcherThrows == "Left" ~ -RelSide, PitcherThrows %in% c("Both", "Undefined") & RelSide > 0 ~ RelSide, PitcherThrows %in% c("Both", "Undefined") & RelSide < 0 ~ -RelSide), ax0 = case_when( PitcherThrows == "Right" ~ ax0, PitcherThrows == "Left" ~ -ax0, PitcherThrows %in% c("Both", "Undefined") & ax0 > 0 ~ ax0, PitcherThrows %in% c("Both", "Undefined") & ax0 < 0 ~ -ax0), PlateLocHeight = PlateLocHeight*12, PlateLocSide = PlateLocSide*12, ax0 = -ax0) %>% group_by(Pitcher, GameID) %>% mutate( primary_pitch = case_when( any(TaggedPitchType == "Fastball") ~ "Fastball", any(TaggedPitchType == "Sinker") ~ "Sinker", TRUE ~ names(sort(table(TaggedPitchType), decreasing = TRUE))[1] ) ) %>% group_by(Pitcher, GameID, primary_pitch) %>% mutate( primary_az0 = mean(az0[TaggedPitchType == primary_pitch], na.rm = TRUE), primary_velo = mean(RelSpeed[TaggedPitchType == primary_pitch], na.rm = TRUE) ) %>% ungroup() %>% mutate(az0_diff = az0 - primary_az0, velo_diff = RelSpeed - primary_velo) df_processed <- bake(stuffplus_recipe, new_data = predict_data) df_matrix <- as.matrix(df_processed) raw_stuff <- predict(stuffplus_model, df_matrix) data$raw_stuff <- raw_stuff data <- data %>% mutate(stuff_plus = ((raw_stuff - 0.004424894) / 0.01010482) * 10 + 100) return(data) } login_ui <- fluidPage( tags$style(HTML(" body { background-color: #f0f4f8; font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; color: #006F71; } .login-container { max-width: 360px; margin: 120px auto; background: #A27752; padding: 30px 25px; border-radius: 8px; box-shadow: 0 4px 15px #A1A1A4; text-align: center; color: white; } .login-message { margin-bottom: 20px; font-size: 14px; color: #ffffff; font-weight: 600; } .btn-primary { background-color: #006F71 !important; border-color: #006F71 !important; color: white !important; font-weight: bold; width: 100%; margin-top: 10px; box-shadow: 0 2px 5px #006F71; transition: background-color 0.3s ease; } .btn-primary:hover { background-color: #006F71 !important; border-color: #A27752 !important; } .form-control { border-radius: 4px; border: 1.5px solid #006F71 !important; color: #006F71; font-weight: 600; } ")), div(class = "login-container", tags$img(src = "https://upload.wikimedia.org/wikipedia/en/thumb/e/ef/Coastal_Carolina_Chanticleers_logo.svg/1200px-Coastal_Carolina_Chanticleers_logo.svg.png", height = "150px"), passwordInput("password", "Password:"), actionButton("login", "Login"), textOutput("wrong_pass") ) ) # UI app_ui <- fluidPage( tags$head( tags$style(HTML(" body, table, .gt_table { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; } /* Header styling */ .app-header { display: flex; justify-content: space-between; align-items: center; padding: 20px 40px; background: #ffffff; border-bottom: 3px solid darkcyan; margin-bottom: 20px; } .header-logo-left, .header-logo-right { width: 120px; height: auto; } .header-logo-center { max-width: 400px; height: auto; } @media (max-width: 768px) { .app-header { flex-direction: column; padding: 15px 20px; } .header-logo-left, .header-logo-right { width: 80px; } .header-logo-center { max-width: 250px; margin: 10px 0; } } /* Gradient pill tabs styling */ .nav-tabs { border: none !important; border-radius: 50px; padding: 6px 12px; margin: 20px auto 0; max-width: 100%; background: linear-gradient(135deg, #d4edeb 0%, #e8ddd0 50%, #d4edeb 100%); box-shadow: 0 4px 16px rgba(0,139,139,.12), inset 0 2px 4px rgba(255,255,255,.6); border: 1px solid rgba(0,139,139,.2); position: relative; overflow-x: auto; -webkit-overflow-scrolling: touch; display: flex; justify-content: center; align-items: center; flex-wrap: wrap; gap: 6px; } .nav-tabs::-webkit-scrollbar { height: 0; } .nav-tabs::before { content: ''; position: absolute; inset: 0; pointer-events: none; border-radius: 50px; background: linear-gradient(135deg, rgba(255,255,255,.4), transparent); } .nav-tabs > li > a { color: darkcyan !important; border: none !important; border-radius: 50px !important; background: transparent !important; font-weight: 700; font-size: 14.5px; padding: 10px 22px; white-space: nowrap; letter-spacing: 0.2px; transition: all 0.2s ease; } .nav-tabs > li > a:hover { color: #006666 !important; background: rgba(255,255,255,.5) !important; transform: translateY(-1px); } .nav-tabs > li.active > a, .nav-tabs > li.active > a:focus, .nav-tabs > li.active > a:hover { background: linear-gradient(135deg, #008b8b 0%, #20b2aa 30%, #00ced1 50%, #20b2aa 70%, #008b8b 100%) !important; color: #fff !important; text-shadow: 0 1px 2px rgba(0,0,0,.2); box-shadow: 0 4px 16px rgba(0,139,139,.4), inset 0 2px 8px rgba(255,255,255,.4), inset 0 -2px 6px rgba(0,0,0,.2); border: 1px solid rgba(255,255,255,.3) !important; } .nav-tabs > li > a:focus { outline: 3px solid rgba(205,133,63,.6); outline-offset: 2px; } .tab-content { background: linear-gradient(135deg, rgba(255,255,255,.95), rgba(248,249,250,.95)); border-radius: 20px; padding: 25px; margin-top: 14px; box-shadow: 0 15px 40px rgba(0,139,139,.1); backdrop-filter: blur(15px); border: 1px solid rgba(0,139,139,.1); position: relative; overflow: hidden; } .tab-content::before { content: ''; position: absolute; left: 0; right: 0; top: 0; height: 4px; background: linear-gradient(90deg, darkcyan, peru, darkcyan); background-size: 200% 100%; animation: shimmer 3s linear infinite; } @keyframes shimmer { 0% { background-position: -200% 0; } 100% { background-position: 200% 0; } } #name { font-size: 10px; font-weight: 500; text-align: right; margin-bottom: 8px; color: #6C757D; letter-spacing: 0.5px; } h3 { color: black; font-weight: 600; margin-top: 25px; margin-bottom: 15px; padding-bottom: 8px; border-bottom: 2px solid #007BA7; } h4 { color: darkcyan; font-weight: 500; margin-top: 20px; margin-bottom: 12px; } h1 { color: #007BA7; font-weight: 700; margin-bottom: 20px; text-shadow: 1px 1px 2px rgba(0,0,0,0.1); } label { font-weight: 500; color: peru; margin-bottom: 5px; } .plot-title { text-align: center; font-weight: 600; color: #2C3E50; margin-bottom: 10px; } .dataTables_wrapper .dataTables_length, .dataTables_wrapper .dataTables_filter, .dataTables_wrapper .dataTables_info, .dataTables_wrapper .dataTables_paginate { color: #2C3E50; } thead th { background-color: #F8F9FA; color: #2C3E50; font-weight: 600; text-align: center !important; padding: 10px !important; } .brand-teal { color: darkcyan; } .brand-bronze { color: peru; } /* Bat tracking upload box styling */ .bat-tracking-box { background: linear-gradient(135deg, #e8f4f8 0%, #f0e6d3 100%); border: 2px dashed darkcyan; border-radius: 15px; padding: 20px; margin-top: 15px; } .merge-status-box { background: #f8f9fa; border-left: 4px solid darkcyan; padding: 15px; border-radius: 0 10px 10px 0; margin-top: 15px; } .merge-success { border-left-color: #28a745; background: #d4edda; } .merge-warning { border-left-color: #ffc107; background: #fff3cd; } .merge-error { border-left-color: #dc3545; background: #f8d7da; } ")) ), # Header with three logos div(class = "app-header", tags$img(src = "https://i.imgur.com/7vx5Ci8.png", class = "header-logo-left", alt = "Logo Left"), tags$img(src = "https://i.imgur.com/c3zCSg6.png", class = "header-logo-center", alt = "Main Logo"), tags$img(src = "https://i.imgur.com/VbrN5WV.png", class = "header-logo-right", alt = "Logo Right") ), tabsetPanel(id = "main_tabs", # Upload & Process Tab tabPanel( "Upload & Process", fluidRow( column(6, h3("1. Upload TrackMan CSV"), fileInput("file", "Choose CSV File", accept = c(".csv")), fluidRow( column(3, checkboxInput("header", "Header", TRUE) ), column(3, radioButtons("sep", "Separator", choices = c(Comma = ",", Semicolon = ";", Tab = "\t"), selected = ",", inline = TRUE) ), column(3, radioButtons("quote", "Quote", choices = c(None = "", "Double Quote" = '"', "Single Quote" = "'"), selected = '"', inline = TRUE) ), column(3, radioButtons("date_format", "Date Output Format", choices = c("YYYY-MM-DD" = "yyyy", "M/D/YY" = "mdyy"), selected = "yyyy") ) ), verbatimTextOutput("csv_status") ), column(6, div(class = "bat-tracking-box", h3("2. Upload Bat Tracking JSON (Optional)", style = "margin-top: 0;"), fileInput("json_file", "Choose Bat Tracking JSON File", accept = c(".json")), p(style = "color: #666; font-size: 12px;", "Upload the corresponding _battracking.json file to merge bat speed and attack angle data."), verbatimTextOutput("json_status"), uiOutput("merge_status_ui") ) ) ), hr(), fluidRow( column(8, h3("3. Columns to Remove"), p("Select which columns to remove from your dataset:"), checkboxGroupInput("columns_to_remove", "Remove These Columns:", choices = columns_to_remove, selected = columns_to_remove) ), column(4, h3("Quick Actions"), br(), actionButton("select_all_cols", "Select All", class = "btn-primary"), br(), br(), actionButton("deselect_all_cols", "Deselect All", class = "btn-default"), br(), br(), actionButton("select_spinaxis", "Select SpinAxis3d Columns", class = "btn-info"), br(), br(), h4("Processing Summary"), verbatimTextOutput("process_summary") ) ) ), # Bat Tracking Details Tab tabPanel( "Bat Tracking Data", fluidRow( column(12, h3("Bat Tracking Merge Details"), uiOutput("bat_tracking_details"), hr(), h4("Pitches with Bat Tracking Data"), DT::dataTableOutput("bat_tracking_table") ) ) ), # Preview Data Tab tabPanel( "Preview Data", fluidRow( column(12, h3("Data Preview"), DT::dataTableOutput("preview") ) ) ), # Pitch Movement Chart Tab tabPanel( "Pitch Movement Chart", fluidRow( column(3, selectInput("pitcher_select", "Select Pitcher:", choices = NULL, selected = NULL) ), column(3, h4("Selection Mode:"), radioButtons("selection_mode", "", choices = list("Single Click" = "single", "Drag Select" = "drag"), selected = "single", inline = TRUE) ), column(6, conditionalPanel( condition = "input.selection_mode == 'drag'", h4("Bulk Edit:"), fluidRow( column(8, selectInput("bulk_pitch_type", "Change all selected to:", choices = c("Fastball", "Sinker", "Cutter", "Slider", "Curveball", "ChangeUp", "Splitter", "Knuckleball", "Other"), selected = "Fastball") ), column(4, br(), actionButton("apply_bulk_change", "Apply to Selected", class = "btn-success") ) ) ) ) ), fluidRow( column(8, h3("Interactive Pitch Movement Analysis"), plotOutput("movement_plot", height = "600px", click = "plot_click", brush = brushOpts(id = "plot_brush"), hover = hoverOpts(id = "plot_hover", delay = 100)), h4("Instructions:"), p(strong("Single Click Mode:"), "Click on any point to edit one pitch type at a time via popup modal."), p(strong("Drag Select Mode:"), "Click and drag to select multiple points, then use the dropdown to change them all at once."), conditionalPanel( condition = "input.selection_mode == 'drag'", div(style = "background-color: #f0f8ff; padding: 10px; border-radius: 5px; margin: 10px 0; border-left: 4px solid darkcyan;", h4("Selected Points:", style = "margin-top: 0; color: darkcyan;"), textOutput("selection_info") ) ), verbatimTextOutput("hover_info"), verbatimTextOutput("click_info") ), column(4, h3("Pitch Metrics Summary"), DT::dataTableOutput("movement_stats") ) ) ), # Download Tab tabPanel( "Download", fluidRow( column(12, h3("Download Processed Data"), h4("Your processed data is ready for download!"), br(), downloadButton("downloadData", "Download CSV", class = "btn-success btn-lg"), br(), br(), h4("Data Summary:"), verbatimTextOutput("data_summary") ) ) ), #Scrape Tab tabPanel( "Scraping", fluidRow( column(2, h4("Data Source", style = "color: darkcyan; border-bottom: 2px solid darkcyan; padding-bottom: 6px;"), radioButtons("scrape_source", NULL, choices = c("TrackMan PBP" = "pbp", "TrackMan Positional" = "pos", "NCAA Scoreboard" = "ncaa"), selected = "pbp") ), column(4, h3("Controls"), dateInput("start_date", "Start Date:", value = Sys.Date() - 1), dateInput("end_date", "End Date:", value = Sys.Date() - 1), uiOutput("scrape_options"), br(), actionButton("scrape_btn", "Scrape Data", class = "btn-primary"), br(), br(), downloadButton("download_scrape", "Download CSV"), actionButton("upload_hf_btn", "Upload to HF Dataset", class = "btn-download") ), column(6, h3("Progress"), verbatimTextOutput("scrape_status"), hr(), h3("Data Preview"), DT::dataTableOutput("scrape_preview") ) ) ) ), # Modal for editing pitch type bsModal("pitchEditModal", "Edit Pitch Type", "triggerModal", size = "medium", div(style = "padding: 20px;", h4("Selected Pitch Details:", style = "color: darkcyan;"), verbatimTextOutput("selected_pitch_info"), br(), selectInput("modal_new_pitch_type", "Change Pitch Type To:", choices = c("Fastball", "Sinker", "Cutter", "Slider", "Curveball", "ChangeUp", "Splitter", "Knuckleball", "Other"), selected = "Fastball"), br(), actionButton("update_pitch", "Update Pitch Type", class = "btn-primary btn-lg"), actionButton("cancel_edit", "Cancel", class = "btn-default") ) ) ) ui <- fluidPage( uiOutput("page") ) # Server server <- function(input, output, session) { logged_in <- reactiveVal(FALSE) output$page <- renderUI({ if (logged_in()) { app_ui } else { login_ui } }) observeEvent(input$login, { if (input$password == PASSWORD) { logged_in(TRUE) output$wrong_pass <- renderText("") } else { output$wrong_pass <- renderText("Incorrect password, please try again.") } }) # Reactive values processed_data <- reactiveVal(NULL) plot_data <- reactiveVal(NULL) selected_pitch <- reactiveVal(NULL) selected_points <- reactiveVal(NULL) csv_data_raw <- reactiveVal(NULL) bat_tracking_parsed <- reactiveVal(NULL) merge_result <- reactiveVal(NULL) scraped_data <- reactiveVal(NULL) scrape_polling <- reactiveVal(FALSE) scrape_status_msg <- reactiveVal("Ready.") # Handle column selection buttons observeEvent(input$select_all_cols, { updateCheckboxGroupInput(session, "columns_to_remove", selected = columns_to_remove) }) observeEvent(input$deselect_all_cols, { updateCheckboxGroupInput(session, "columns_to_remove", selected = character(0)) }) observeEvent(input$select_spinaxis, { spinaxis_cols <- columns_to_remove[grepl("SpinAxis3d", columns_to_remove)] updateCheckboxGroupInput(session, "columns_to_remove", selected = spinaxis_cols) }) # Re-process data when date format changes observeEvent(input$date_format, { req(input$file) # Only run if a file has been uploaded # Re-read and process the CSV with new date format tryCatch({ df <- read.csv(input$file$datapath, header = input$header, sep = input$sep, quote = input$quote, stringsAsFactors = FALSE) # Auto-convert date formats based on user selection df <- convert_date_columns(df, input$date_format) csv_data_raw(df) # If we already have bat tracking data, try to merge if (!is.null(bat_tracking_parsed()) && !is.null(bat_tracking_parsed()$data)) { result <- merge_with_bat_tracking(df, bat_tracking_parsed()$data) merge_result(result) df <- result$data } # Process the data (remove columns) selected_cols_to_remove <- input$columns_to_remove %||% character(0) processed_df <- df if (length(selected_cols_to_remove) > 0) { columns_to_drop <- intersect(names(df), selected_cols_to_remove) if (length(columns_to_drop) > 0) { processed_df <- processed_df %>% select(-all_of(columns_to_drop)) } } processed_df <- processed_df %>% distinct() processed_data(processed_df) plot_data(processed_df) showNotification( paste("Date format updated to:", if (input$date_format == "mdyy") "M/D/YY" else "YYYY-MM-DD"), type = "message", duration = 3 ) }, error = function(e) { showNotification(paste("Error updating date format:", e$message), type = "error") }) }, ignoreInit = TRUE) # Process uploaded CSV file observeEvent(input$file, { req(input$file) tryCatch({ df <- read.csv(input$file$datapath, header = input$header, sep = input$sep, quote = input$quote, stringsAsFactors = FALSE) # Auto-convert date formats based on user selection df <- convert_date_columns(df, input$date_format) csv_data_raw(df) # If we already have bat tracking data, try to merge if (!is.null(bat_tracking_parsed()) && !is.null(bat_tracking_parsed()$data)) { result <- merge_with_bat_tracking(df, bat_tracking_parsed()$data) merge_result(result) df <- result$data } # Process the data (remove columns) selected_cols_to_remove <- input$columns_to_remove %||% character(0) processed_df <- df if (length(selected_cols_to_remove) > 0) { columns_to_drop <- intersect(names(df), selected_cols_to_remove) if (length(columns_to_drop) > 0) { processed_df <- processed_df %>% select(-all_of(columns_to_drop)) } } processed_df <- processed_df %>% distinct() processed_data(processed_df) plot_data(processed_df) # Update pitcher choices if ("Pitcher" %in% names(processed_df)) { pitcher_choices <- sort(unique(processed_df$Pitcher[!is.na(processed_df$Pitcher)])) updateSelectInput(session, "pitcher_select", choices = pitcher_choices, selected = pitcher_choices[1]) } }, error = function(e) { showNotification(paste("Error processing CSV:", e$message), type = "error") }) }) # Process uploaded JSON file observeEvent(input$json_file, { req(input$json_file) tryCatch({ parsed <- parse_bat_tracking_json(input$json_file$datapath) bat_tracking_parsed(parsed) # If we already have CSV data, merge if (!is.null(csv_data_raw()) && parsed$success && !is.null(parsed$data)) { result <- merge_with_bat_tracking(csv_data_raw(), parsed$data) merge_result(result) # Re-process with merged data df <- result$data selected_cols_to_remove <- input$columns_to_remove %||% character(0) if (length(selected_cols_to_remove) > 0) { columns_to_drop <- intersect(names(df), selected_cols_to_remove) if (length(columns_to_drop) > 0) { df <- df %>% select(-all_of(columns_to_drop)) } } df <- df %>% distinct() processed_data(df) plot_data(df) showNotification(result$message, type = "message", duration = 5) } }, error = function(e) { showNotification(paste("Error processing JSON:", e$message), type = "error") }) }) # CSV status output output$csv_status <- renderText({ if (is.null(input$file)) { return("No CSV file uploaded yet.") } if (is.null(csv_data_raw())) { return("Processing CSV...") } df <- csv_data_raw() game_id <- if ("GameID" %in% names(df)) unique(df$GameID)[1] else "Unknown" date_fmt <- if (input$date_format == "mdyy") "M/D/YY" else "YYYY-MM-DD" paste( "✓ CSV loaded successfully!", paste(" Game ID:", game_id), paste(" Rows:", nrow(df)), paste(" Columns:", ncol(df)), paste("✓ Date format:", date_fmt), sep = "\n" ) }) # JSON status output output$json_status <- renderText({ if (is.null(input$json_file)) { return("No JSON file uploaded yet.") } parsed <- bat_tracking_parsed() if (is.null(parsed)) { return("Processing JSON...") } if (!parsed$success) { return(paste("✗", parsed$message)) } paste( "✓ JSON parsed successfully!", paste(" Game Reference:", parsed$game_reference), paste(" Plays found:", parsed$plays_count %||% 0), sep = "\n" ) }) # Merge status UI output$merge_status_ui <- renderUI({ result <- merge_result() parsed <- bat_tracking_parsed() csv <- csv_data_raw() if (is.null(parsed) || is.null(csv)) { return(NULL) } if (!parsed$success) { return(div(class = "merge-status-box merge-error", h4("Merge Status", style = "margin-top: 0; color: #721c24;"), p(parsed$message) )) } if (is.null(parsed$data) || is.null(result)) { # Check game ID match csv_game <- if ("GameID" %in% names(csv)) unique(csv$GameID)[1] else NULL json_game <- parsed$game_reference if (!is.null(csv_game) && !is.null(json_game) && csv_game != json_game) { return(div(class = "merge-status-box merge-warning", h4("⚠ Game ID Mismatch", style = "margin-top: 0; color: #856404;"), p(paste("CSV Game:", csv_game)), p(paste("JSON Game:", json_game)), p("Files may be from different games!") )) } return(div(class = "merge-status-box merge-warning", h4("No Data to Merge", style = "margin-top: 0; color: #856404;"), p(parsed$message) )) } # Check game ID match csv_game <- if ("GameID" %in% names(csv)) unique(csv$GameID)[1] else NULL json_game <- parsed$game_reference game_match <- is.null(csv_game) || is.null(json_game) || csv_game == json_game if (result$matched > 0) { div(class = "merge-status-box merge-success", h4("✓ Merge Successful!", style = "margin-top: 0; color: #155724;"), p(paste("Matched:", result$matched, "of", result$total_bat, "bat tracking records")), if (!game_match) p(style = "color: #856404;", "⚠ Note: Game IDs differ but PitchUIDs matched") ) } else { div(class = "merge-status-box merge-warning", h4("⚠ No Matches Found", style = "margin-top: 0; color: #856404;"), p(paste("0 of", result$total_bat, "bat tracking records matched")), if (!game_match) p(paste("Game ID mismatch: CSV =", csv_game, ", JSON =", json_game)) ) } }) # Bat tracking details output$bat_tracking_details <- renderUI({ parsed <- bat_tracking_parsed() result <- merge_result() if (is.null(parsed)) { return(div( p("No bat tracking JSON file uploaded."), p("Upload a _battracking.json file in the 'Upload & Process' tab to see bat tracking data here.") )) } if (!parsed$success) { return(div(class = "alert alert-danger", parsed$message)) } if (is.null(parsed$data)) { return(div(class = "alert alert-warning", h4("Empty Bat Tracking File"), p(parsed$message), p("The JSON file was valid but contained no swing data in the Plays array.") )) } # Show summary div( div(class = "row", div(class = "col-md-4", div(class = "well", h4("Game Reference"), p(parsed$game_reference) ) ), div(class = "col-md-4", div(class = "well", h4("Total Swings Tracked"), p(style = "font-size: 24px; font-weight: bold; color: darkcyan;", parsed$plays_count) ) ), div(class = "col-md-4", div(class = "well", h4("Matched to CSV"), p(style = "font-size: 24px; font-weight: bold; color: #28a745;", if (!is.null(result)) result$matched else "N/A") ) ) ) ) }) # Bat tracking table output$bat_tracking_table <- DT::renderDataTable({ df <- processed_data() if (is.null(df)) { return(NULL) } # Filter to rows with bat tracking data if ("BatSpeed_Sensor" %in% names(df)) { bat_rows <- df %>% filter(!is.na(BatSpeed_Sensor)) %>% select( any_of(c("PitchNo", "Time", "Pitcher", "Batter", "TaggedPitchType", "PitchCall", "RelSpeed", "ExitSpeed", "Angle", "BatSpeed", "BatSpeed_Sensor", "VerticalAttackAngle", "VerticalAttackAngle_Sensor", "HorizontalAttackAngle", "HorizontalAttackAngle_Sensor")) ) if (nrow(bat_rows) == 0) { return(NULL) } DT::datatable(bat_rows, options = list(scrollX = TRUE, pageLength = 10), rownames = FALSE) %>% DT::formatRound(columns = intersect(names(bat_rows), c("BatSpeed_Sensor", "VerticalAttackAngle_Sensor", "HorizontalAttackAngle_Sensor", "RelSpeed", "ExitSpeed", "Angle")), digits = 1) } else { return(NULL) } }) # Processing summary output$process_summary <- renderText({ if (is.null(input$file)) { return("No file uploaded yet.") } if (is.null(processed_data())) { return("Processing...") } df <- processed_data() original_df <- csv_data_raw() selected_cols_to_remove <- input$columns_to_remove %||% character(0) removed_cols <- intersect(selected_cols_to_remove, names(original_df)) result <- merge_result() removed_cols_text <- if (length(removed_cols) > 0) { cols_display <- if (length(removed_cols) > 5) { paste(paste(head(removed_cols, 5), collapse = ", "), "...") } else { paste(removed_cols, collapse = ", ") } paste("✓ Removed columns:", length(removed_cols)) } else { "✓ Removed columns: 0" } bat_tracking_text <- if (!is.null(result) && result$matched > 0) { paste("✓ Bat tracking merged:", result$matched, "pitches") } else if (!is.null(bat_tracking_parsed())) { "⚠ Bat tracking: No matches" } else { "○ Bat tracking: Not uploaded" } summary_text <- paste( "✓ File processed successfully!", paste("✓ Original columns:", ncol(original_df)), paste("✓ Final columns:", ncol(df)), paste("✓ Rows processed:", nrow(df)), removed_cols_text, bat_tracking_text, "✓ Duplicates removed", paste("✓ Date format:", if (input$date_format == "mdyy") "M/D/YY" else "YYYY-MM-DD"), sep = "\n" ) return(summary_text) }) # Preview table output$preview <- DT::renderDataTable({ req(processed_data()) DT::datatable(processed_data(), options = list(scrollX = TRUE, pageLength = 10), filter = "top") }) # Movement plot output$movement_plot <- renderPlot({ req(plot_data(), input$pitcher_select) pitcher_data <- plot_data() %>% filter(Pitcher == input$pitcher_select) %>% filter(!is.na(TaggedPitchType), TaggedPitchType != "Other", !is.na(HorzBreak), !is.na(InducedVertBreak), !is.na(RelSpeed)) %>% mutate(pitch_id = row_number()) if (nrow(pitcher_data) == 0) { plot.new() text(0.5, 0.5, "No data available for selected pitcher", cex = 1.5) return() } pitcher_data$color <- pitch_colors[pitcher_data$TaggedPitchType] pitcher_data$color[is.na(pitcher_data$color)] <- "#D3D3D3" par(mar = c(5, 5, 4, 8), xpd = TRUE) plot(pitcher_data$HorzBreak, pitcher_data$InducedVertBreak, col = pitcher_data$color, pch = 19, cex = 1.5, xlim = c(-25, 25), ylim = c(-25, 25), xlab = "Horizontal Break (inches)", ylab = "Induced Vertical Break (inches)", main = paste("Pitch Movement Chart -", input$pitcher_select)) grid(nx = NULL, ny = NULL, col = "lightgray", lty = 1, lwd = 0.5) abline(h = 0, col = "gray", lty = 2, lwd = 1) abline(v = 0, col = "gray", lty = 2, lwd = 1) for (r in c(6, 12, 18, 24)) { circle_x <- r * cos(seq(0, 2*pi, length.out = 100)) circle_y <- r * sin(seq(0, 2*pi, length.out = 100)) lines(circle_x, circle_y, col = "lightgray", lty = 3) } if (input$selection_mode == "drag" && !is.null(selected_points())) { sel_points <- selected_points() points(sel_points$HorzBreak, sel_points$InducedVertBreak, pch = 21, cex = 2, col = "red", lwd = 3) } unique_pitches <- unique(pitcher_data$TaggedPitchType) unique_colors <- pitch_colors[unique_pitches] legend("topright", inset = c(-0.15, 0), legend = unique_pitches, col = unique_colors, pch = 19, cex = 0.8, title = "Pitch Type") }) # Handle plot clicks (single mode only) observeEvent(input$plot_click, { req(plot_data(), input$pitcher_select, input$plot_click) if (input$selection_mode != "single") return() pitcher_data <- plot_data() %>% filter(Pitcher == input$pitcher_select) %>% filter(!is.na(TaggedPitchType), TaggedPitchType != "Other", !is.na(HorzBreak), !is.na(InducedVertBreak), !is.na(RelSpeed)) %>% mutate(pitch_id = row_number()) if (nrow(pitcher_data) == 0) return() click_x <- input$plot_click$x click_y <- input$plot_click$y distances <- sqrt((pitcher_data$HorzBreak - click_x)^2 + (pitcher_data$InducedVertBreak - click_y)^2) closest_idx <- which.min(distances) if (min(distances) <= 2) { clicked_pitch <- pitcher_data[closest_idx, ] full_data <- plot_data() %>% filter(Pitcher == input$pitcher_select) original_row <- which(full_data$HorzBreak == clicked_pitch$HorzBreak & full_data$InducedVertBreak == clicked_pitch$InducedVertBreak & full_data$RelSpeed == clicked_pitch$RelSpeed)[1] selected_pitch(list( pitcher = input$pitcher_select, row_in_pitcher_data = original_row, data = clicked_pitch, original_type = clicked_pitch$TaggedPitchType )) updateSelectInput(session, "modal_new_pitch_type", selected = clicked_pitch$TaggedPitchType) showModal(modalDialog( title = "Edit Pitch Type", div(style = "padding: 20px;", h4("Selected Pitch Details:", style = "color: darkcyan;"), verbatimTextOutput("selected_pitch_info"), br(), selectInput("modal_new_pitch_type", "Change Pitch Type To:", choices = c("Fastball", "Sinker", "Cutter", "Slider", "Curveball", "ChangeUp", "Splitter", "Knuckleball", "Other"), selected = clicked_pitch$TaggedPitchType), br(), actionButton("update_pitch", "Update Pitch Type", class = "btn-primary btn-lg"), actionButton("cancel_edit", "Cancel", class = "btn-default") ), footer = NULL, size = "m", easyClose = TRUE )) } }) # Handle brush selection (drag mode) observeEvent(input$plot_brush, { req(plot_data(), input$pitcher_select, input$plot_brush) if (input$selection_mode != "drag") return() pitcher_data <- plot_data() %>% filter(Pitcher == input$pitcher_select) %>% filter(!is.na(TaggedPitchType), TaggedPitchType != "Other", !is.na(HorzBreak), !is.na(InducedVertBreak), !is.na(RelSpeed)) if (nrow(pitcher_data) == 0) return() brush <- input$plot_brush brushed_points <- pitcher_data %>% filter( HorzBreak >= brush$xmin & HorzBreak <= brush$xmax & InducedVertBreak >= brush$ymin & InducedVertBreak <= brush$ymax ) if (nrow(brushed_points) > 0) { selected_points(brushed_points) } else { selected_points(NULL) } }) # Apply bulk change observeEvent(input$apply_bulk_change, { req(selected_points(), input$bulk_pitch_type) sel_points <- selected_points() if (nrow(sel_points) == 0) { showNotification("No points selected", type = "warning") return() } current_data <- plot_data() for (i in 1:nrow(sel_points)) { point <- sel_points[i, ] current_data <- current_data %>% mutate(TaggedPitchType = ifelse( Pitcher == input$pitcher_select & abs(HorzBreak - point$HorzBreak) < 0.01 & abs(InducedVertBreak - point$InducedVertBreak) < 0.01 & abs(RelSpeed - point$RelSpeed) < 0.01, input$bulk_pitch_type, TaggedPitchType )) } plot_data(current_data) processed_data(current_data) selected_points(NULL) showNotification( paste("Updated", nrow(sel_points), "pitches to", input$bulk_pitch_type), type = "message", duration = 3 ) }) # Selection info for drag mode output$selection_info <- renderText({ if (input$selection_mode == "drag" && !is.null(selected_points())) { sel_points <- selected_points() pitch_counts <- table(sel_points$TaggedPitchType) paste(nrow(sel_points), "points selected:", paste(names(pitch_counts), "(", pitch_counts, ")", collapse = ", ")) } else { "No points selected. Click and drag to select multiple pitches." } }) # Hover info output$hover_info <- renderText({ req(input$plot_hover, plot_data(), input$pitcher_select) pitcher_data <- plot_data() %>% filter(Pitcher == input$pitcher_select) %>% filter(!is.na(TaggedPitchType), TaggedPitchType != "Other", !is.na(HorzBreak), !is.na(InducedVertBreak), !is.na(RelSpeed)) if (nrow(pitcher_data) == 0) return("") hover_x <- input$plot_hover$x hover_y <- input$plot_hover$y distances <- sqrt((pitcher_data$HorzBreak - hover_x)^2 + (pitcher_data$InducedVertBreak - hover_y)^2) if (min(distances) <= 2) { closest_idx <- which.min(distances) hover_pitch <- pitcher_data[closest_idx, ] # Include bat tracking info if available bat_info <- "" if ("BatSpeed_Sensor" %in% names(hover_pitch) && !is.na(hover_pitch$BatSpeed_Sensor)) { bat_info <- paste(" | Bat Speed:", round(hover_pitch$BatSpeed_Sensor, 1), "mph") } paste("Hovering over:", paste("Type:", hover_pitch$TaggedPitchType), paste("Velocity:", round(hover_pitch$RelSpeed, 1), "mph"), paste("HB:", round(hover_pitch$HorzBreak, 1), "in"), paste("IVB:", round(hover_pitch$InducedVertBreak, 1), "in"), bat_info, sep = " | ") } else { "" } }) # Movement stats table output$movement_stats <- DT::renderDataTable({ req(plot_data(), input$pitcher_select) data <- plot_data() movement_stats <- data %>% filter(Pitcher == input$pitcher_select) %>% filter(!is.na(HorzBreak), !is.na(InducedVertBreak), !is.na(TaggedPitchType)) %>% mutate( pitch_group = case_when( TaggedPitchType %in% c("Fastball", "FourSeamFastBall", "FourSeamFastB", "Four-Seam", "4-Seam") ~ "Fastball", TaggedPitchType %in% c("OneSeamFastBall", "TwoSeamFastBall", "Sinker", "Two-Seam", "One-Seam") ~ "Sinker", TaggedPitchType %in% c("ChangeUp", "Changeup") ~ "Changeup", TRUE ~ TaggedPitchType ), in_zone = ifelse("StrikeZoneIndicator" %in% names(.), StrikeZoneIndicator, ifelse(!is.na(PlateLocSide) & !is.na(PlateLocHeight) & PlateLocSide >= -0.95 & PlateLocSide <= 0.95 & PlateLocHeight >= 1.6 & PlateLocHeight <= 3.5, 1, 0)), is_whiff = ifelse("WhiffIndicator" %in% names(.), WhiffIndicator, ifelse(!is.na(PitchCall) & PitchCall == "StrikeSwinging", 1, 0)), chase = ifelse("Chaseindicator" %in% names(.), Chaseindicator, ifelse(!is.na(PitchCall) & !is.na(PlateLocSide) & !is.na(PlateLocHeight) & PitchCall %in% c("StrikeSwinging", "FoulBallNotFieldable", "FoulBall", "InPlay") & (PlateLocSide < -0.95 | PlateLocSide > 0.95 | PlateLocHeight < 1.6 | PlateLocHeight > 3.5), 1, 0)) ) total_pitches <- nrow(movement_stats) # Check if bat tracking columns exist has_bat_speed <- "BatSpeed_Sensor" %in% names(movement_stats) summary_stats <- movement_stats %>% group_by(`Pitch Type` = pitch_group) %>% summarise( Count = n(), `Usage%` = sprintf("%.1f%%", (n() / total_pitches) * 100), `Avg Velo` = sprintf("%.1f", mean(RelSpeed, na.rm = TRUE)), `Max Velo` = sprintf("%.1f", max(RelSpeed, na.rm = TRUE)), `Avg IVB` = sprintf("%.1f", mean(InducedVertBreak, na.rm = TRUE)), `Avg HB` = sprintf("%.1f", mean(HorzBreak, na.rm = TRUE)), `Avg Spin` = ifelse("SpinRate" %in% names(movement_stats), sprintf("%.0f", mean(SpinRate, na.rm = TRUE)), "—"), `Avg Bat Speed` = if (has_bat_speed) { bat_vals <- BatSpeed_Sensor[!is.na(BatSpeed_Sensor)] if (length(bat_vals) > 0) sprintf("%.1f", mean(bat_vals)) else "—" } else "—", `Zone%` = sprintf("%.1f%%", round(mean(in_zone, na.rm = TRUE) * 100, 1)), `Whiff%` = sprintf("%.1f%%", round(mean(is_whiff, na.rm = TRUE) * 100, 1)), .groups = "drop" ) %>% arrange(desc(Count)) DT::datatable(summary_stats, options = list(pageLength = 15, dom = 't', scrollX = TRUE), rownames = FALSE) %>% DT::formatStyle(columns = names(summary_stats), fontSize = '12px') }) # Selected pitch info in modal output$selected_pitch_info <- renderText({ pitch_info <- selected_pitch() if (!is.null(pitch_info)) { pitch_data <- pitch_info$data info_lines <- c( paste("Pitcher:", pitch_info$pitcher), paste("Current Type:", pitch_data$TaggedPitchType), paste("Velocity:", round(pitch_data$RelSpeed, 1), "mph"), paste("Horizontal Break:", round(pitch_data$HorzBreak, 1), "inches"), paste("Induced Vertical Break:", round(pitch_data$InducedVertBreak, 1), "inches") ) if ("SpinRate" %in% names(pitch_data) && !is.na(pitch_data$SpinRate)) { info_lines <- c(info_lines, paste("Spin Rate:", round(pitch_data$SpinRate, 0), "rpm")) } # Add bat tracking info if available if ("BatSpeed_Sensor" %in% names(pitch_data) && !is.na(pitch_data$BatSpeed_Sensor)) { info_lines <- c(info_lines, paste("Bat Speed:", round(pitch_data$BatSpeed_Sensor, 1), "mph"), paste("Vertical Attack Angle:", round(pitch_data$VerticalAttackAngle_Sensor, 1), "°"), paste("Horizontal Attack Angle:", round(pitch_data$HorizontalAttackAngle_Sensor, 1), "°")) } if ("Date" %in% names(pitch_data) && !is.na(pitch_data$Date)) { info_lines <- c(info_lines, paste("Date:", pitch_data$Date)) } return(paste(info_lines, collapse = "\n")) } else { return("No pitch selected") } }) # Update pitch type observeEvent(input$update_pitch, { pitch_info <- selected_pitch() if (!is.null(pitch_info)) { current_data <- plot_data() target_pitcher <- pitch_info$pitcher target_hb <- pitch_info$data$HorzBreak target_ivb <- pitch_info$data$InducedVertBreak target_velo <- pitch_info$data$RelSpeed current_data <- current_data %>% mutate(TaggedPitchType = ifelse( Pitcher == target_pitcher & abs(HorzBreak - target_hb) < 0.01 & abs(InducedVertBreak - target_ivb) < 0.01 & abs(RelSpeed - target_velo) < 0.01, input$modal_new_pitch_type, TaggedPitchType )) plot_data(current_data) processed_data(current_data) removeModal() showNotification( paste("Updated pitch from", pitch_info$original_type, "to", input$modal_new_pitch_type), type = "message", duration = 3 ) selected_pitch(NULL) } }) # Cancel edit observeEvent(input$cancel_edit, { removeModal() selected_pitch(NULL) }) # Click info output output$click_info <- renderText({ if (!is.null(selected_pitch())) { pitch_info <- selected_pitch() paste("Last selected pitch:", pitch_info$original_type, "| Position: (", round(pitch_info$data$HorzBreak, 1), ",", round(pitch_info$data$InducedVertBreak, 1), ")") } else { "No point selected yet. Click on a point in the chart above to edit its pitch type." } }) # Data summary for download page output$data_summary <- renderText({ req(processed_data()) df <- processed_data() result <- merge_result() bat_tracking_summary <- if (!is.null(result) && result$matched > 0) { paste("Bat tracking data:", result$matched, "pitches with swing metrics") } else { "Bat tracking data: None" } summary_text <- paste( paste("Total rows:", nrow(df)), paste("Total columns:", ncol(df)), paste("Date range:", if ("Date" %in% names(df) && !all(is.na(df$Date))) { paste(min(as.Date(df$Date), na.rm = TRUE), "to", max(as.Date(df$Date), na.rm = TRUE)) } else { "Date column not available" }), paste("Unique pitchers:", if ("Pitcher" %in% names(df)) { length(unique(df$Pitcher[!is.na(df$Pitcher)])) } else { "Pitcher column not available" }), paste("Pitch types:", if ("TaggedPitchType" %in% names(df)) { paste(sort(unique(df$TaggedPitchType[!is.na(df$TaggedPitchType)])), collapse = ", ") } else { "TaggedPitchType column not available" }), bat_tracking_summary, paste("Date format:", if (input$date_format == "mdyy") "M/D/YY" else "YYYY-MM-DD"), sep = "\n" ) return(summary_text) }) # Download handler output$downloadData <- downloadHandler( filename = function() { paste("app_ready_COA_", Sys.Date(), ".csv", sep = "") }, content = function(file) { write.csv(processed_data(), file, row.names = FALSE) } ) #SCRAPER STUFF #Handles the middle column where it is dynamically based off the left column output$scrape_options <- renderUI({ switch(input$scrape_source, "pbp" = tagList( p("Scrapes TrackMan play-by-play data from FTP.") ), "pos" = tagList( p("Scrapes TrackMan player positioning data from FTP.") ), "ncaa" = tagList( selectInput("ncaa_division", "Division:", choices = c("D1", "D2", "D3")), p("Scrapes NCAA scoreboard data via API.") ) ) }) # Scrape button observeEvent(input$scrape_btn, { scrape_status_msg("Triggering scrape on GitHub...") gh_token <- Sys.getenv("GITHUB_TOKEN") gh_repo <- Sys.getenv("GITHUB_REPO") result <- tryCatch({ httr::POST( paste0("https://api.github.com/repos/", gh_repo, "/actions/workflows/scrape.yml/dispatches"), httr::add_headers( Authorization = paste("Bearer", gh_token), Accept = "application/vnd.github.v3+json" ), body = jsonlite::toJSON(list( ref = "main", inputs = list( start_date = as.character(input$start_date), end_date = as.character(input$end_date), data_type = input$scrape_source ) ), auto_unbox = TRUE), encode = "raw" ) }, error = function(e) { scrape_status_msg(paste("Failed:", e$message)) return(NULL) }) if (is.null(result)) return() if (httr::status_code(result) == 204) { scrape_status_msg("Scrape triggered! Waiting for GitHub to finish...") scrape_polling(TRUE) } else { scrape_status_msg(paste("GitHub API error:", httr::status_code(result))) } }) # Poll GitHub every 15 seconds to check if done observe({ req(scrape_polling()) invalidateLater(15000, session) gh_token <- Sys.getenv("GITHUB_TOKEN") gh_repo <- Sys.getenv("GITHUB_REPO") resp <- tryCatch({ httr::GET( paste0("https://api.github.com/repos/", gh_repo, "/actions/runs?per_page=1"), httr::add_headers( Authorization = paste("Bearer", gh_token), Accept = "application/vnd.github.v3+json" ) ) }, error = function(e) { NULL }) if (is.null(resp)) return() runs <- jsonlite::fromJSON(httr::content(resp, as = "text", encoding = "UTF-8")) if (length(runs$workflow_runs) == 0) return() latest <- runs$workflow_runs[1, ] status <- latest$status conclusion <- latest$conclusion if (status == "completed") { scrape_polling(FALSE) if (conclusion == "success") { scrape_status_msg("GitHub finished! Fetching data...") # Auto-fetch the CSV filename <- paste0(input$scrape_source, "_", input$start_date, "_to_", input$end_date, ".csv.gz") url <- paste0("https://api.github.com/repos/", gh_repo, "/contents/data/", filename) data <- tryCatch({ file_resp <- httr::GET( url, httr::add_headers( Authorization = paste("Bearer", gh_token), Accept = "application/vnd.github.v3.raw" ) ) if (httr::status_code(file_resp) == 200) { tmp <- tempfile(fileext = ".csv.gz") writeBin(httr::content(file_resp, as = "raw"), tmp) read_csv(gzfile(tmp)) } else { NULL } }, error = function(e) { NULL }) if (!is.null(data) && nrow(data) > 0) { if (input$scrape_source == "pbp") { scrape_status_msg("Processing data...") data <- tryCatch({ d <- clean_college_data(data) d <- predict_stuffplus(d) d }, error = function(e) { scrape_status_msg(paste("Processing error:", e$message)) data }) } scraped_data(data) scrape_status_msg(paste0("Done! ", nrow(data), " rows × ", ncol(data), " columns.")) } else { scrape_status_msg("Scrape finished but couldn't fetch the file. Try 'Fetch Results' manually.") } } else { scrape_status_msg(paste("GitHub Action failed:", conclusion)) } } else { scrape_status_msg(paste0("GitHub is running... (status: ", status, ")")) } }) # Status text output$scrape_status <- renderText({ scrape_status_msg() }) # Preview table output$scrape_preview <- DT::renderDataTable({ req(scraped_data()) DT::datatable(scraped_data(), options = list(scrollX = TRUE, pageLength = 10)) }) # Download output$download_scrape <- downloadHandler( filename = function() { label <- switch(input$scrape_source, "pbp" = "pbp", "pos" = "positional", "ncaa" = "ncaa") paste0("trackman_", label, "_", format(input$start_date, "%Y%m%d"), "_to_", format(input$end_date, "%Y%m%d"), ".csv") }, content = function(file) { req(scraped_data()) write.csv(scraped_data(), file, row.names = FALSE) } ) observeEvent(input$upload_hf_btn, { req(scraped_data()) hf_token <- Sys.getenv("HF_WRITE_TOKEN") repo_id <- "CoastalBaseball/2026MasterDataset" timestamp <- format(Sys.time(), "%Y%m%d_%H%M%S") upload_to_hf <- function(new_data, folder, index_file, label) { scrape_status_msg(paste0("Checking existing UIDs for ", label, "...")) existing_uids <- tryCatch({ tmp_idx <- tempfile(fileext = ".csv.gz") resp <- httr::GET( paste0("https://huggingface.co/datasets/", repo_id, "/resolve/main/", index_file), httr::add_headers(Authorization = paste("Bearer", hf_token)), httr::write_disk(tmp_idx, overwrite = TRUE) ) if (httr::status_code(resp) == 200) { d <- read.csv(gzfile(tmp_idx), stringsAsFactors = FALSE) file.remove(tmp_idx) d$PitchUID } else { file.remove(tmp_idx) character(0) } }, error = function(e) { character(0) }) scraped_rows <- nrow(new_data) if (length(existing_uids) > 0 && "PitchUID" %in% names(new_data)) { new_only <- new_data %>% filter(!PitchUID %in% existing_uids) } else { new_only <- new_data } new_rows <- nrow(new_only) total_after <- length(existing_uids) + new_rows if (new_rows == 0) { return(paste0(label, ": ", scraped_rows, " rows scraped, 0 new rows added (", length(existing_uids), " total)")) } scrape_status_msg(paste0("Uploading ", new_rows, " new rows for ", label, "...")) hf <- reticulate::import("huggingface_hub") api <- hf$HfApi() tmp_data <- tempfile(fileext = ".parquet") arrow::write_parquet(new_only, tmp_data) api$upload_file( path_or_fileobj = tmp_data, path_in_repo = paste0(folder, "/", timestamp, ".parquet"), repo_id = repo_id, repo_type = "dataset", token = hf_token ) file.remove(tmp_data) scrape_status_msg(paste0("Updating ", label, " index...")) all_uids <- data.frame(PitchUID = c(existing_uids, new_only$PitchUID)) tmp_idx <- tempfile(fileext = ".csv.gz") gz <- gzfile(tmp_idx, "w") write.csv(all_uids, gz, row.names = FALSE) close(gz) api$upload_file( path_or_fileobj = tmp_idx, path_in_repo = index_file, repo_id = repo_id, repo_type = "dataset", token = hf_token ) file.remove(tmp_idx) rm(new_only, all_uids); gc() paste0(label, ": ", scraped_rows, " rows scraped, ", new_rows, " new rows added (", total_after, " total)") } if (input$scrape_source == "pbp") { msg1 <- upload_to_hf(scraped_data(), "pbp", "pbp_uid_index.csv.gz", "Master Dataset") gc() cp <- scraped_data() %>% filter(PitcherTeam == "COA_CHA") msg2 <- if (nrow(cp) > 0) { upload_to_hf(cp, "coastal_pitchers", "coastal_pitchers_uid_index.csv.gz", "Coastal Pitchers") } else { "Coastal Pitchers: No matching rows" } rm(cp); gc() ch <- scraped_data() %>% filter(BatterTeam == "COA_CHA") msg3 <- if (nrow(ch) > 0) { upload_to_hf(ch, "coastal_hitters", "coastal_hitters_uid_index.csv.gz", "Coastal Hitters") } else { "Coastal Hitters: No matching rows" } rm(ch); gc() scrape_status_msg(paste(msg1, msg2, msg3, sep = "\n")) } else if (input$scrape_source == "pos") { msg1 <- upload_to_hf(scraped_data(), "pos", "pos_uid_index.csv.gz", "Positional Dataset") scrape_status_msg(msg1) } else if (input$scrape_source == "ncaa") { msg1 <- upload_to_hf(scraped_data(), "ncaa_pbp", "ncaa_pbp_uid_index.csv.gz", "NCAA PBP Dataset") scrape_status_msg(msg1) } }) } # Run the app shinyApp(ui = ui, server = server)