Tracking / app.R
igroffman's picture
Update app.R
032726a verified
raw
history blame
27.3 kB
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)