Spaces:
Paused
Paused
| library(shiny) | |
| library(shinydashboard) | |
| library(DT) | |
| library(dplyr) | |
| library(shinyjs) | |
| library(readr) | |
| library(webshot2) | |
| library(htmlwidgets) | |
| ui <- dashboardPage( | |
| dashboardHeader(title = "Bullpen Tracker"), | |
| dashboardSidebar( | |
| sidebarMenu( | |
| menuItem("Bullpen Session", tabName = "bullpen", icon = icon("baseball")), | |
| menuItem("Leaderboard", tabName = "leaderboard", icon = icon("chart-bar")), | |
| menuItem("Export Data", tabName = "export", icon = icon("download")) | |
| ) | |
| ), | |
| dashboardBody( | |
| useShinyjs(), | |
| tags$head( | |
| tags$style(HTML(" | |
| .zone-btn { | |
| width: 100%; | |
| min-height: 80px; | |
| font-size: 28px; | |
| font-weight: bold; | |
| margin: 2px; | |
| } | |
| .border-zone-btn { | |
| min-height: 60px; | |
| font-size: 20px; | |
| font-weight: bold; | |
| } | |
| .zone-grid { | |
| display: grid; | |
| grid-template-columns: repeat(3, 1fr); | |
| gap: 5px; | |
| max-width: 400px; | |
| margin: 0 auto; | |
| } | |
| .selection-display { | |
| background: #f0f0f0; | |
| padding: 15px; | |
| border-radius: 10px; | |
| margin: 10px 0; | |
| min-height: 60px; | |
| font-size: 16px; | |
| font-weight: bold; | |
| } | |
| ")) | |
| ), | |
| tabItems( | |
| tabItem( | |
| tabName = "bullpen", | |
| fluidRow( | |
| box( | |
| title = "Pitcher Setup", | |
| status = "primary", | |
| solidHeader = TRUE, | |
| width = 4, | |
| textInput("pitcherName", "Pitcher Name:", placeholder = "Enter name"), | |
| actionButton("addPitcher", "Add Pitcher", class = "btn-success btn-block"), | |
| hr(), | |
| selectInput("currentPitcher", "Currently Throwing:", choices = NULL), | |
| selectInput("batterSide", "Batter Side:", | |
| choices = c("Right (Default)" = "R", "Left" = "L"), | |
| selected = "R"), | |
| hr(), | |
| div( | |
| style = "background: #f4f4f4; padding: 15px; border-radius: 10px;", | |
| h4("Current Stats"), | |
| verbatimTextOutput("currentStats") | |
| ) | |
| ), | |
| box( | |
| title = "Record Pitch", | |
| status = "success", | |
| solidHeader = TRUE, | |
| width = 8, | |
| div(class = "selection-display", uiOutput("selectionDisplay")), | |
| h4("1. Pitch Type"), | |
| fluidRow( | |
| column(4, actionButton("pitch_fb", "Fastball", class = "btn-primary btn-block", style = "min-height: 60px; font-size: 18px;")), | |
| column(4, actionButton("pitch_cb", "Curveball", class = "btn-warning btn-block", style = "min-height: 60px; font-size: 18px;")), | |
| column(4, actionButton("pitch_ch", "Changeup", class = "btn-info btn-block", style = "min-height: 60px; font-size: 18px;")) | |
| ), | |
| fluidRow( | |
| column(4, actionButton("pitch_sl", "Slider", class = "btn-warning btn-block", style = "min-height: 60px; font-size: 18px;")), | |
| column(4, actionButton("pitch_ct", "Cutter", class = "btn-primary btn-block", style = "min-height: 60px; font-size: 18px;")), | |
| column(4, actionButton("pitch_sn", "Sinker", class = "btn-primary btn-block", style = "min-height: 60px; font-size: 18px;")) | |
| ), | |
| hr(), | |
| h4("2. Intended Location (Optional)"), | |
| p("Select vertical OR horizontal OR both", style = "font-size: 12px; color: #666;"), | |
| fluidRow( | |
| column(6, actionButton("int_up", "UP", class = "btn-info btn-block", style = "min-height: 60px; font-size: 18px;")), | |
| column(6, actionButton("int_down", "DOWN", class = "btn-info btn-block", style = "min-height: 60px; font-size: 18px;")) | |
| ), | |
| fluidRow( | |
| column(4, actionButton("int_away", "AWAY", class = "btn-secondary btn-block", style = "min-height: 60px; font-size: 18px;")), | |
| column(4, actionButton("int_middle", "MIDDLE", class = "btn-secondary btn-block", style = "min-height: 60px; font-size: 18px;")), | |
| column(4, actionButton("int_in", "IN", class = "btn-secondary btn-block", style = "min-height: 60px; font-size: 18px;")) | |
| ), | |
| hr(), | |
| h4("3. Actual Zone"), | |
| div( | |
| style = "text-align: center; max-width: 500px; margin: 0 auto;", | |
| actionButton("zone_12", "12", class = "border-zone-btn btn-outline-secondary", style = "width: 66%; margin: 5px auto;"), | |
| div( | |
| style = "display: flex; gap: 5px; justify-content: center; align-items: center;", | |
| actionButton("zone_14", "14", class = "border-zone-btn btn-outline-secondary", style = "width: 60px; height: 280px;"), | |
| div( | |
| class = "zone-grid", | |
| actionButton("zone_3", "3", class = "zone-btn btn-primary"), | |
| actionButton("zone_2", "2", class = "zone-btn btn-primary"), | |
| actionButton("zone_1", "1", class = "zone-btn btn-primary"), | |
| actionButton("zone_6", "6", class = "zone-btn btn-primary"), | |
| actionButton("zone_5", "5", class = "zone-btn btn-success"), | |
| actionButton("zone_4", "4", class = "zone-btn btn-primary"), | |
| actionButton("zone_9", "9", class = "zone-btn btn-info"), | |
| actionButton("zone_8", "8", class = "zone-btn btn-info"), | |
| actionButton("zone_7", "7", class = "zone-btn btn-info") | |
| ), | |
| actionButton("zone_11", "11", class = "border-zone-btn btn-outline-secondary", style = "width: 60px; height: 280px;") | |
| ), | |
| actionButton("zone_13", "13", class = "border-zone-btn btn-outline-secondary", style = "width: 66%; margin: 5px auto;") | |
| ), | |
| hr(), | |
| h4("4. Result"), | |
| fluidRow( | |
| column(6, actionButton("result_strike", "STRIKE", class = "btn-success", style = "width: 100%; min-height: 80px; font-size: 24px;")), | |
| column(6, actionButton("result_ball", "BALL", class = "btn-danger", style = "width: 100%; min-height: 80px; font-size: 24px;")) | |
| ), | |
| hr(), | |
| h4("5. Velocity (Required)"), | |
| numericInput("velo", "MPH:", value = NULL, min = 40, max = 110, width = "200px"), | |
| hr(), | |
| actionButton("clearPitch", "Clear Selection", class = "btn-warning btn-block") | |
| ) | |
| ), | |
| fluidRow( | |
| box( | |
| title = "Recent Pitches", | |
| status = "info", | |
| solidHeader = TRUE, | |
| width = 12, | |
| DT::dataTableOutput("recentPitches") | |
| ) | |
| ) | |
| ), | |
| tabItem( | |
| tabName = "leaderboard", | |
| fluidRow( | |
| box( | |
| title = "Upload Previous Sessions", | |
| status = "info", | |
| solidHeader = TRUE, | |
| width = 12, | |
| fileInput("uploadCSV", "Upload CSV File(s) from Previous Bullpens:", | |
| multiple = TRUE, | |
| accept = c(".csv")), | |
| actionButton("clearUploads", "Clear Uploaded Data", class = "btn-warning") | |
| ) | |
| ), | |
| fluidRow( | |
| box( | |
| title = "Leaderboard Options", | |
| status = "primary", | |
| solidHeader = TRUE, | |
| width = 12, | |
| fluidRow( | |
| column( | |
| 2, | |
| numericInput("minPitches", "Minimum Pitches:", value = 10, min = 1) | |
| ), | |
| column( | |
| 10, | |
| checkboxGroupInput( | |
| "leaderboardCols", | |
| "Select Columns:", | |
| choices = c( | |
| "Pitcher" = "Pitcher", | |
| "Total Pitches" = "Total", | |
| "Overall Strike %" = "StrikeRate", | |
| "FB Strike %" = "FB_StrikeRate", | |
| "OS Strike %" = "OS_StrikeRate", | |
| "Avg FB Velo" = "AvgFBVelo", | |
| "Max FB Velo" = "MaxFBVelo", | |
| "Avg Velo" = "AvgVelo", | |
| "Hit Spot %" = "HitSpotRate", | |
| "In Zone %" = "InZoneRate" | |
| ), | |
| selected = c("Pitcher", "FB_StrikeRate", "OS_StrikeRate", "StrikeRate"), | |
| inline = TRUE | |
| ) | |
| ) | |
| ) | |
| ) | |
| ), | |
| fluidRow( | |
| box( | |
| title = "Session Leaderboard", | |
| status = "warning", | |
| solidHeader = TRUE, | |
| width = 12, | |
| downloadButton("downloadLeaderboardCSV", "Download CSV", class = "btn-success", style = "margin-bottom: 15px; margin-right: 10px;"), | |
| downloadButton("downloadLeaderboardPNG", "Download PNG", class = "btn-primary", style = "margin-bottom: 15px;"), | |
| DT::dataTableOutput("leaderboard") | |
| ) | |
| ) | |
| ), | |
| tabItem( | |
| tabName = "export", | |
| fluidRow( | |
| box( | |
| title = "Export Session Data", | |
| status = "success", | |
| solidHeader = TRUE, | |
| width = 12, | |
| textInput("exportFilename", "Session Name (e.g., Horn_Doran_Bullpen):", | |
| placeholder = "LastName1_LastName2_Bullpen"), | |
| downloadButton("downloadData", "Download CSV", class = "btn-primary btn-lg") | |
| ) | |
| ) | |
| ) | |
| ) | |
| ) | |
| ) | |
| server <- function(input, output, session) { | |
| values <- reactiveValues( | |
| pitchers = character(0), | |
| pitchData = data.frame(), | |
| uploadedData = data.frame(), | |
| currentPitchType = NULL, | |
| currentIntendedVert = NULL, | |
| currentIntendedHorz = NULL, | |
| currentZone = NULL | |
| ) | |
| output$selectionDisplay <- renderUI({ | |
| parts <- c() | |
| if (!is.null(values$currentPitchType)) { | |
| parts <- c(parts, paste("Type:", values$currentPitchType)) | |
| } | |
| if (!is.null(values$currentIntendedVert) || !is.null(values$currentIntendedHorz)) { | |
| loc <- paste(c(values$currentIntendedVert, values$currentIntendedHorz), collapse = " + ") | |
| parts <- c(parts, paste("Intended:", loc)) | |
| } | |
| if (!is.null(values$currentZone)) { | |
| parts <- c(parts, paste("Zone:", values$currentZone)) | |
| } | |
| if (!is.null(input$velo) && !is.na(input$velo)) { | |
| parts <- c(parts, paste(input$velo, "MPH")) | |
| } | |
| if (length(parts) == 0) { | |
| HTML("<span style='color: #999;'>Select pitch details...</span>") | |
| } else { | |
| HTML(paste(parts, collapse = " | ")) | |
| } | |
| }) | |
| observeEvent(input$addPitcher, { | |
| req(input$pitcherName) | |
| name <- trimws(input$pitcherName) | |
| if (name != "" && !(name %in% values$pitchers)) { | |
| values$pitchers <- c(values$pitchers, name) | |
| updateSelectInput(session, "currentPitcher", choices = values$pitchers, selected = name) | |
| updateTextInput(session, "pitcherName", value = "") | |
| showNotification(paste("Added", name), type = "message") | |
| } | |
| }) | |
| output$currentStats <- renderText({ | |
| req(input$currentPitcher) | |
| data <- values$pitchData %>% filter(Pitcher == input$currentPitcher) | |
| if (nrow(data) == 0) return("No pitches yet") | |
| total <- nrow(data) | |
| strikes <- sum(data$Result == "Strike") | |
| strike_pct <- round(strikes / total * 100, 1) | |
| fb_data <- data %>% filter(PitchType %in% c("Fastball", "Sinker", "Cutter")) | |
| fb_total <- nrow(fb_data) | |
| fb_strikes <- sum(fb_data$Result == "Strike") | |
| fb_pct <- if (fb_total > 0) round(fb_strikes / fb_total * 100, 1) else 0 | |
| os_data <- data %>% filter(PitchType %in% c("Curveball", "Slider", "Changeup")) | |
| os_total <- nrow(os_data) | |
| os_strikes <- sum(os_data$Result == "Strike") | |
| os_pct <- if (os_total > 0) round(os_strikes / os_total * 100, 1) else 0 | |
| hit_spot <- sum(data$HitSpot, na.rm = TRUE) | |
| hit_spot_pct <- round(hit_spot / total * 100, 1) | |
| paste0( | |
| "Total Pitches: ", total, "\n", | |
| "==================\n", | |
| "Overall Strike%: ", strike_pct, "%\n", | |
| "FB Strike%: ", fb_pct, "% (", fb_total, " FB)\n", | |
| "OS Strike%: ", os_pct, "% (", os_total, " OS)\n", | |
| "==================\n", | |
| "Hit Spot%: ", hit_spot_pct, "% (", hit_spot, "/", total, ")" | |
| ) | |
| }) | |
| observeEvent(input$pitch_fb, { values$currentPitchType <- "Fastball" }) | |
| observeEvent(input$pitch_cb, { values$currentPitchType <- "Curveball" }) | |
| observeEvent(input$pitch_ch, { values$currentPitchType <- "Changeup" }) | |
| observeEvent(input$pitch_sl, { values$currentPitchType <- "Slider" }) | |
| observeEvent(input$pitch_ct, { values$currentPitchType <- "Cutter" }) | |
| observeEvent(input$pitch_sn, { values$currentPitchType <- "Sinker" }) | |
| observeEvent(input$int_up, { values$currentIntendedVert <- "Up" }) | |
| observeEvent(input$int_down, { values$currentIntendedVert <- "Down" }) | |
| observeEvent(input$int_away, { values$currentIntendedHorz <- "Away" }) | |
| observeEvent(input$int_middle, { values$currentIntendedHorz <- "Middle" }) | |
| observeEvent(input$int_in, { values$currentIntendedHorz <- "In" }) | |
| observeEvent(input$zone_1, { values$currentZone <- 1 }) | |
| observeEvent(input$zone_2, { values$currentZone <- 2 }) | |
| observeEvent(input$zone_3, { values$currentZone <- 3 }) | |
| observeEvent(input$zone_4, { values$currentZone <- 4 }) | |
| observeEvent(input$zone_5, { values$currentZone <- 5 }) | |
| observeEvent(input$zone_6, { values$currentZone <- 6 }) | |
| observeEvent(input$zone_7, { values$currentZone <- 7 }) | |
| observeEvent(input$zone_8, { values$currentZone <- 8 }) | |
| observeEvent(input$zone_9, { values$currentZone <- 9 }) | |
| observeEvent(input$zone_11, { values$currentZone <- 11 }) | |
| observeEvent(input$zone_12, { values$currentZone <- 12 }) | |
| observeEvent(input$zone_13, { values$currentZone <- 13 }) | |
| observeEvent(input$zone_14, { values$currentZone <- 14 }) | |
| recordPitch <- function(result) { | |
| req(input$currentPitcher, values$currentPitchType, values$currentZone) | |
| if (is.null(input$velo) || is.na(input$velo)) { | |
| showNotification("Please enter velocity!", type = "error") | |
| return() | |
| } | |
| batter_side <- input$batterSide | |
| zone <- values$currentZone | |
| hit_spot <- NA | |
| if (!is.null(values$currentIntendedVert) || !is.null(values$currentIntendedHorz)) { | |
| vert_match <- FALSE | |
| horz_match <- FALSE | |
| if (!is.null(values$currentIntendedVert)) { | |
| if (values$currentIntendedVert == "Up" && zone %in% c(1, 2, 3, 12)) { | |
| vert_match <- TRUE | |
| } else if (values$currentIntendedVert == "Down" && zone %in% c(7, 8, 9, 13)) { | |
| vert_match <- TRUE | |
| } | |
| } else { | |
| vert_match <- TRUE | |
| } | |
| if (!is.null(values$currentIntendedHorz)) { | |
| if (batter_side == "R") { | |
| if (values$currentIntendedHorz == "Away" && zone %in% c(3, 6, 9, 14)) { | |
| horz_match <- TRUE | |
| } else if (values$currentIntendedHorz == "In" && zone %in% c(1, 4, 7, 11)) { | |
| horz_match <- TRUE | |
| } else if (values$currentIntendedHorz == "Middle" && zone %in% c(2, 5, 8)) { | |
| horz_match <- TRUE | |
| } | |
| } else { | |
| if (values$currentIntendedHorz == "Away" && zone %in% c(1, 4, 7, 11)) { | |
| horz_match <- TRUE | |
| } else if (values$currentIntendedHorz == "In" && zone %in% c(3, 6, 9, 14)) { | |
| horz_match <- TRUE | |
| } else if (values$currentIntendedHorz == "Middle" && zone %in% c(2, 5, 8)) { | |
| horz_match <- TRUE | |
| } | |
| } | |
| } else { | |
| horz_match <- TRUE | |
| } | |
| hit_spot <- vert_match && horz_match | |
| } | |
| in_zone <- zone <= 9 | |
| new_pitch <- data.frame( | |
| Pitcher = input$currentPitcher, | |
| PitchType = values$currentPitchType, | |
| Velocity = input$velo, | |
| BatterSide = batter_side, | |
| IntendedVert = if (!is.null(values$currentIntendedVert)) values$currentIntendedVert else NA, | |
| IntendedHorz = if (!is.null(values$currentIntendedHorz)) values$currentIntendedHorz else NA, | |
| ActualZone = values$currentZone, | |
| Result = result, | |
| HitSpot = hit_spot, | |
| InZone = in_zone, | |
| Timestamp = Sys.time(), | |
| stringsAsFactors = FALSE | |
| ) | |
| values$pitchData <- rbind(values$pitchData, new_pitch) | |
| values$currentPitchType <- NULL | |
| values$currentIntendedVert <- NULL | |
| values$currentIntendedHorz <- NULL | |
| values$currentZone <- NULL | |
| updateNumericInput(session, "velo", value = NA) | |
| showNotification(paste(result, "recorded!"), type = "message", duration = 1) | |
| } | |
| observeEvent(input$result_strike, { recordPitch("Strike") }) | |
| observeEvent(input$result_ball, { recordPitch("Ball") }) | |
| observeEvent(input$clearPitch, { | |
| values$currentPitchType <- NULL | |
| values$currentIntendedVert <- NULL | |
| values$currentIntendedHorz <- NULL | |
| values$currentZone <- NULL | |
| updateNumericInput(session, "velo", value = NA) | |
| }) | |
| observeEvent(input$uploadCSV, { | |
| req(input$uploadCSV) | |
| tryCatch({ | |
| uploaded_list <- lapply(input$uploadCSV$datapath, function(path) { | |
| read_csv(path, show_col_types = FALSE) | |
| }) | |
| new_data <- bind_rows(uploaded_list) | |
| values$uploadedData <- bind_rows(values$uploadedData, new_data) | |
| showNotification(paste("Uploaded", nrow(new_data), "pitches from", length(uploaded_list), "file(s)"), | |
| type = "message") | |
| }, error = function(e) { | |
| showNotification(paste("Error uploading files:", e$message), type = "error") | |
| }) | |
| }) | |
| observeEvent(input$clearUploads, { | |
| values$uploadedData <- data.frame() | |
| showNotification("Cleared uploaded data", type = "warning") | |
| }) | |
| output$recentPitches <- DT::renderDataTable({ | |
| if (nrow(values$pitchData) == 0) { | |
| return(data.frame(Message = "No pitches recorded")) | |
| } | |
| recent <- tail(values$pitchData, 20) %>% | |
| arrange(desc(Timestamp)) %>% | |
| select(Pitcher, PitchType, Velocity, BatterSide, IntendedVert, IntendedHorz, ActualZone, Result, HitSpot) | |
| DT::datatable(recent, options = list(pageLength = 20, dom = 't'), rownames = FALSE) %>% | |
| formatStyle('HitSpot', backgroundColor = styleEqual(c(TRUE, FALSE), c('#90EE90', '#FFB6C1'))) | |
| }) | |
| # Function to generate leaderboard data | |
| generate_leaderboard <- function() { | |
| all_data <- bind_rows(values$pitchData, values$uploadedData) | |
| if (nrow(all_data) == 0) { | |
| return(NULL) | |
| } | |
| leaderboard <- all_data %>% | |
| group_by(Pitcher) %>% | |
| summarise( | |
| Total = n(), | |
| Strikes = sum(Result == "Strike"), | |
| StrikeRate = round(Strikes / Total * 100, 1), | |
| FB_Total = sum(PitchType %in% c("Fastball", "Sinker", "Cutter")), | |
| FB_Strikes = sum(Result == "Strike" & PitchType %in% c("Fastball", "Sinker", "Cutter")), | |
| FB_StrikeRate = if_else(FB_Total > 0, round(FB_Strikes / FB_Total * 100, 1), NA_real_), | |
| OS_Total = sum(PitchType %in% c("Curveball", "Slider", "Changeup")), | |
| OS_Strikes = sum(Result == "Strike" & PitchType %in% c("Curveball", "Slider", "Changeup")), | |
| OS_StrikeRate = if_else(OS_Total > 0, round(OS_Strikes / OS_Total * 100, 1), NA_real_), | |
| AvgFBVelo = round(mean(Velocity[PitchType %in% c("Fastball", "Sinker", "Cutter")], na.rm = TRUE), 1), | |
| MaxFBVelo = suppressWarnings(max(Velocity[PitchType %in% c("Fastball", "Sinker", "Cutter")], na.rm = TRUE)), | |
| AvgVelo = round(mean(Velocity, na.rm = TRUE), 1), | |
| HitSpotRate = round(mean(HitSpot, na.rm = TRUE) * 100, 1), | |
| InZoneRate = round(mean(InZone) * 100, 1), | |
| .groups = 'drop' | |
| ) %>% | |
| filter(Total >= input$minPitches) | |
| return(leaderboard) | |
| } | |
| # Function to prepare leaderboard display (used by both render and download) | |
| prepare_leaderboard_display <- function(leaderboard) { | |
| if (is.null(leaderboard)) return(NULL) | |
| # Rename columns for display | |
| leaderboard_display <- leaderboard | |
| if ("Pitcher" %in% colnames(leaderboard)) leaderboard_display <- leaderboard_display %>% rename(PLAYER = Pitcher) | |
| if ("FB_StrikeRate" %in% colnames(leaderboard)) leaderboard_display <- leaderboard_display %>% rename(`FB STRIKE%` = FB_StrikeRate) | |
| if ("OS_StrikeRate" %in% colnames(leaderboard)) leaderboard_display <- leaderboard_display %>% rename(`OS STRIKE%` = OS_StrikeRate) | |
| if ("StrikeRate" %in% colnames(leaderboard)) leaderboard_display <- leaderboard_display %>% rename(`OVR STRIKE%` = StrikeRate) | |
| if ("AvgFBVelo" %in% colnames(leaderboard)) leaderboard_display <- leaderboard_display %>% rename(`AVG FB VELO` = AvgFBVelo) | |
| if ("MaxFBVelo" %in% colnames(leaderboard)) leaderboard_display <- leaderboard_display %>% rename(`MAX FB VELO` = MaxFBVelo) | |
| if ("AvgVelo" %in% colnames(leaderboard)) leaderboard_display <- leaderboard_display %>% rename(`AVG VELO` = AvgVelo) | |
| if ("HitSpotRate" %in% colnames(leaderboard)) leaderboard_display <- leaderboard_display %>% rename(`HIT SPOT%` = HitSpotRate) | |
| if ("InZoneRate" %in% colnames(leaderboard)) leaderboard_display <- leaderboard_display %>% rename(`IN ZONE%` = InZoneRate) | |
| if ("Total" %in% colnames(leaderboard)) leaderboard_display <- leaderboard_display %>% rename(`TOTAL PITCHES` = Total) | |
| # Select only chosen columns | |
| if (!is.null(input$leaderboardCols) && length(input$leaderboardCols) > 0) { | |
| cols_to_keep <- input$leaderboardCols | |
| col_mapping <- c( | |
| "Pitcher" = "PLAYER", | |
| "Total" = "TOTAL PITCHES", | |
| "StrikeRate" = "OVR STRIKE%", | |
| "FB_StrikeRate" = "FB STRIKE%", | |
| "OS_StrikeRate" = "OS STRIKE%", | |
| "AvgFBVelo" = "AVG FB VELO", | |
| "MaxFBVelo" = "MAX FB VELO", | |
| "AvgVelo" = "AVG VELO", | |
| "HitSpotRate" = "HIT SPOT%", | |
| "InZoneRate" = "IN ZONE%" | |
| ) | |
| display_cols <- col_mapping[cols_to_keep] | |
| display_cols <- display_cols[!is.na(display_cols)] | |
| leaderboard_display <- leaderboard_display %>% select(all_of(display_cols)) | |
| } | |
| return(leaderboard_display) | |
| } | |
| output$leaderboard <- DT::renderDataTable({ | |
| leaderboard <- generate_leaderboard() | |
| if (is.null(leaderboard)) { | |
| return(data.frame(Message = "No data yet")) | |
| } | |
| leaderboard_display <- prepare_leaderboard_display(leaderboard) | |
| dt <- DT::datatable( | |
| leaderboard_display, | |
| options = list( | |
| pageLength = 25, | |
| scrollX = TRUE, | |
| ordering = TRUE, | |
| dom = 't', | |
| columnDefs = list( | |
| list(className = 'dt-center', targets = '_all') | |
| ) | |
| ), | |
| rownames = FALSE, | |
| class = 'cell-border stripe' | |
| ) %>% | |
| formatStyle( | |
| columns = colnames(leaderboard_display), | |
| fontSize = '18px', | |
| fontWeight = 'bold', | |
| color = 'black', | |
| border = '2px solid black' | |
| ) %>% | |
| formatStyle( | |
| columns = 1, | |
| fontWeight = 'bold', | |
| border = '2px solid black' | |
| ) | |
| # Apply conditional formatting to percentage columns | |
| pct_cols <- intersect( | |
| c("FB STRIKE%", "OS STRIKE%", "OVR STRIKE%", "HIT SPOT%", "IN ZONE%"), | |
| colnames(leaderboard_display) | |
| ) | |
| for (col in pct_cols) { | |
| dt <- dt %>% | |
| formatStyle( | |
| col, | |
| backgroundColor = styleInterval( | |
| cuts = c(30, 40, 50, 55, 60, 65, 70), | |
| values = c('#FF6B6B', '#FF8C8C', '#FFD700', '#FFED4E', '#90EE90', '#7CCD7C', '#32CD32', '#228B22') | |
| ), | |
| color = 'black', | |
| fontWeight = 'bold', | |
| border = '2px solid black' | |
| ) | |
| } | |
| dt | |
| }) | |
| # Download leaderboard as CSV | |
| output$downloadLeaderboardCSV <- downloadHandler( | |
| filename = function() { | |
| paste0("Leaderboard_", format(Sys.Date(), "%m%d"), ".csv") | |
| }, | |
| content = function(file) { | |
| leaderboard <- generate_leaderboard() | |
| if (!is.null(leaderboard)) { | |
| write_csv(leaderboard, file) | |
| } else { | |
| write_csv(data.frame(Message = "No data"), file) | |
| } | |
| } | |
| ) | |
| # Download leaderboard as PNG | |
| output$downloadLeaderboardPNG <- downloadHandler( | |
| filename = function() {paste0("Leaderboard_", format(Sys.Date(), "%m%d"), ".png") | |
| }, | |
| content = function(file) { | |
| leaderboard <- generate_leaderboard() | |
| if (is.null(leaderboard)) { | |
| showNotification("No data to export", type = "error") | |
| return() | |
| } | |
| # Prepare display version using the shared function | |
| leaderboard_display <- prepare_leaderboard_display(leaderboard) | |
| if (is.null(leaderboard_display)) { | |
| showNotification("Error preparing leaderboard data", type = "error") | |
| return() | |
| } | |
| # Create datatable widget with conditional formatting | |
| dt <- DT::datatable( | |
| leaderboard_display, | |
| options = list( | |
| pageLength = nrow(leaderboard_display), | |
| dom = 't', | |
| ordering = FALSE, | |
| columnDefs = list( | |
| list(className = 'dt-center', targets = '_all') | |
| ) | |
| ), | |
| rownames = FALSE, | |
| class = 'cell-border stripe' | |
| ) %>% | |
| formatStyle( | |
| columns = colnames(leaderboard_display), | |
| fontSize = '18px', | |
| fontWeight = 'bold', | |
| color = 'black', | |
| border = '2px solid black' | |
| ) %>% | |
| formatStyle( | |
| columns = 1, | |
| fontWeight = 'bold', | |
| border = '2px solid black' | |
| ) | |
| # Apply conditional formatting to percentage columns | |
| pct_cols <- intersect( | |
| c("FB STRIKE%", "OS STRIKE%", "OVR STRIKE%", "HIT SPOT%", "IN ZONE%"), | |
| colnames(leaderboard_display) | |
| ) | |
| for (col in pct_cols) { | |
| dt <- dt %>% | |
| formatStyle( | |
| col, | |
| backgroundColor = styleInterval( | |
| cuts = c(30, 40, 50, 55, 60, 65, 70), | |
| values = c('#FF6B6B', '#FF8C8C', '#FFD700', '#FFED4E', '#90EE90', '#7CCD7C', '#32CD32', '#228B22') | |
| ), | |
| color = 'black', | |
| fontWeight = 'bold', | |
| border = '2px solid black' | |
| ) | |
| } | |
| # Save as HTML temporarily | |
| temp_html <- tempfile(fileext = ".html") | |
| htmlwidgets::saveWidget(dt, temp_html, selfcontained = TRUE) | |
| # Convert to PNG using webshot2 | |
| tryCatch({ | |
| webshot2::webshot(temp_html, file = file, vwidth = 1400, vheight = 1000, delay = 0.5) | |
| showNotification("Leaderboard PNG downloaded successfully!", type = "message") | |
| }, error = function(e) { | |
| showNotification(paste("Error creating PNG:", e$message), type = "error") | |
| }) | |
| # Clean up temp file | |
| unlink(temp_html) | |
| } | |
| ) | |
| output$downloadData <- downloadHandler( | |
| filename = function() { | |
| base_name <- if (!is.null(input$exportFilename) && input$exportFilename != "") { | |
| gsub(" ", "_", trimws(input$exportFilename)) | |
| } else { | |
| "Bullpen_Session" | |
| } | |
| date_str <- format(Sys.Date(), "%m%d") | |
| paste0(base_name, "_", date_str, ".csv") | |
| }, | |
| content = function(file) { | |
| write_csv(values$pitchData, file) | |
| } | |
| ) | |
| } | |
| shinyApp(ui = ui, server = server) |