DataProcess / app.R
igroffman's picture
Update app.R
0d8dc4b verified
raw
history blame
24.7 kB
# app.R
library(shiny)
library(shinydashboard)
library(DT)
library(dplyr)
library(readr)
library(stringr)
library(plotly)
# ---------- helpers ----------
`%||%` <- function(x, y) if (is.null(x)) y else x
# columns to optionally remove
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","BatSpeed","GameDate","HorizontalAttackAngle",
"Horizontal Attack Angle","VerticalAttackAngle","Vertical attack angle"
)
# pitch colors
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"
)
# ---------- UI ----------
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';
}
.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;} }
.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; display:flex; justify-content:center;
align-items:center; flex-wrap:wrap; gap:6px; -webkit-overflow-scrolling:touch; }
.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:.2px; transition:.2s; }
.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; }
.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;}}
h3{ color:black; font-weight:600; margin-top:25px; margin-bottom:15px; padding-bottom:8px; border-bottom:2px solid #007BA7; }
h4{ color:white; 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; }
thead th{ background:#F8F9FA; color:#2C3E50; font-weight:600; text-align:center!important; padding:10px!important; }
.brand-teal{ color:darkcyan; } .brand-bronze{ color:peru; }
"))
),
# header
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",
tabPanel("Upload & Process",
fluidRow(
column(12,
h3("Upload CSV File"),
fileInput("file", "Choose CSV File", accept = c(".csv")),
fluidRow(
column(4, checkboxInput("header","Header", TRUE)),
column(4, radioButtons("sep","Separator",
choices=c(Comma=",", Semicolon=";", Tab="\t"), selected=",", inline=TRUE)),
column(4, radioButtons("quote","Quote",
choices=c(None="", "Double Quote"='"', "Single Quote"="'"),
selected='"', inline=TRUE))
)
)
),
fluidRow(
column(8,
h3("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(),
actionButton("select_attack_angle","Select Attack Angle Columns",class="btn-info"), br(), br(),
h4("Processing Summary"),
verbatimTextOutput("process_summary")
)
)
),
tabPanel("Preview Data",
fluidRow(column(12, h3("Data Preview"), DTOutput("preview")))
),
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"),
plotlyOutput("movement_plot", height="600px"),
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("click_info")
),
column(4,
h3("Pitch Metrics Summary"),
DTOutput("movement_stats"),
h3("Location Plot (Editable)"),
plotlyOutput("location_plot", height="600px") # <— new location plot
)
)
),
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")
)))
)
)
# ---------- Server ----------
server <- function(input, output, session){
# reactives
processed_data <- reactiveVal(NULL)
plot_data <- reactiveVal(NULL)
selected_pitch <- reactiveVal(NULL)
selected_keys <- reactiveVal(integer(0)) # works for either chart
# quick-select 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, {
spin <- columns_to_remove[grepl("SpinAxis3d", columns_to_remove)]
updateCheckboxGroupInput(session,"columns_to_remove", selected = spin)
})
observeEvent(input$select_attack_angle,{
aa <- columns_to_remove[grepl("AttackAngle|Attack Angle", columns_to_remove)]
updateCheckboxGroupInput(session,"columns_to_remove", selected = aa)
})
# file upload + processing
observeEvent(input$file, {
req(input$file)
tryCatch({
df <- read.csv(input$file$datapath,
header = input$header, sep = input$sep, quote = input$quote,
stringsAsFactors = FALSE)
# drop user-selected columns
sel_drop <- input$columns_to_remove %||% character(0)
if (length(sel_drop)) {
df <- df %>% select(-any_of(intersect(names(df), sel_drop)))
}
# numeric coercions for interaction robustness
num_cols <- c("HorzBreak","InducedVertBreak","RelSpeed","SpinRate",
"PlateLocSide","PlateLocHeight","Extension","RelHeight")
have <- intersect(names(df), num_cols)
df[have] <- lapply(df[have], function(x) suppressWarnings(as.numeric(x)))
df <- df %>% distinct()
processed_data(df)
plot_data(df)
if ("Pitcher" %in% names(df)) {
choices <- sort(unique(df$Pitcher[!is.na(df$Pitcher)]))
if (length(choices)) updateSelectInput(session,"pitcher_select", choices = choices, selected = choices[1])
}
}, error = function(e){
showNotification(paste("Error processing file:", e$message), type="error")
})
})
# 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 <- read.csv(input$file$datapath, nrows = 1)
sel <- input$columns_to_remove %||% character(0)
removed <- intersect(sel, names(original_df))
paste(
"✓ File processed successfully!",
paste("✓ Original columns:", ncol(original_df)),
paste("✓ Final columns:", ncol(df)),
paste("✓ Rows processed:", nrow(df)),
paste("✓ Removed columns:", length(removed)),
if (length(removed)>0) paste(" -", paste(head(removed,5), collapse=", "),
if (length(removed)>5) "..." else "") else "",
"✓ Duplicates removed",
sep = "\n"
)
})
# preview table
output$preview <- renderDT({
req(processed_data())
datatable(processed_data(), options=list(scrollX=TRUE, pageLength=10), filter="top")
})
# pitcher-specific reactive with stable row_key (per pitcher group)
pitcher_df <- reactive({
req(plot_data(), input$pitcher_select)
plot_data() %>%
filter(Pitcher == input$pitcher_select) %>%
filter(!is.na(HorzBreak), !is.na(InducedVertBreak), !is.na(RelSpeed)) %>%
mutate(row_key = row_number())
})
output$movement_plot <- renderPlotly({
d <- pitcher_df()
validate(need(nrow(d) > 0, "No data for selected pitcher"))
plot_ly(
data = d, source = "mv", type = "scatter", mode = "markers",
x = ~HorzBreak, y = ~InducedVertBreak,
text = ~paste0(
"<b>", TaggedPitchType, "</b>",
"<br>Velo: ", round(RelSpeed,1), " mph",
"<br>IVB: ", round(InducedVertBreak,1), " in",
"<br>HB: ", round(HorzBreak,1), " in",
if ("SpinRate" %in% names(d)) paste0("<br>Spin: ", round(SpinRate), " rpm") else ""
),
hoverinfo = "text",
key = ~row_key,
color = ~factor(TaggedPitchType), # <-- data-driven
colors = pitch_colors, # <-- your palette
marker = list(size = 10)
) |>
layout(
title = paste("Pitch Movement Chart -", input$pitcher_select),
xaxis = list(title="Horizontal Break (in)", range=c(-25,25), zeroline=TRUE),
yaxis = list(title="Induced Vertical Break (in)", range=c(-25,25), zeroline=TRUE),
dragmode = if (input$selection_mode == "drag") "select" else "zoom"
) |>
config(displaylogo = FALSE)
})
output$location_plot <- renderPlotly({
d <- pitcher_df()
validate(need(nrow(d) > 0, "No data for selected pitcher"))
plot_ly(
data = d, source = "loc", type = "scatter", mode = "markers",
x = ~PlateLocSide, y = ~PlateLocHeight,
text = ~paste0(
"<b>", TaggedPitchType, "</b>",
"<br>Velo: ", round(RelSpeed,1), " mph",
"<br>X: ", round(PlateLocSide,2),
"<br>Z: ", round(PlateLocHeight,2),
"<br>IVB: ", round(InducedVertBreak,1), " in",
"<br>HB: ", round(HorzBreak,1), " in",
if ("SpinRate" %in% names(d)) paste0("<br>Spin: ", round(SpinRate), " rpm") else ""
),
hoverinfo = "text",
key = ~row_key,
color = ~factor(TaggedPitchType), # <-- data-driven
colors = pitch_colors, # <-- your palette
marker = list(size = 9)
) |>
layout(
title = "Pitch Location (Editable)",
xaxis = list(title="Plate X (ft)", range=c(-2,2), zeroline=TRUE),
yaxis = list(title="Plate Z (ft)", range=c(0,4.5), zeroline=TRUE),
dragmode = if (input$selection_mode == "drag") "select" else "zoom",
shapes = list(
list(type="rect", x0=-0.8303, x1=0.8303, y0=1.6, y1=3.5,
line=list(color="black", width=1), fillcolor="rgba(0,0,0,0)"),
list(type="path",
path="M -0.708 0.15 L 0.708 0.15 L 0.708 0.3 L 0 0.5 L -0.708 0.3 Z",
line=list(color="black", width=0.8))
)
) |>
config(displaylogo = FALSE)
})
# ---- Click handlers (single mode) for both plots ----
show_pitch_modal <- function(hit_row){
selected_pitch(list(
pitcher = input$pitcher_select,
row_key = hit_row$row_key[1],
data = hit_row[1,],
original_type = hit_row$TaggedPitchType[1]
))
showModal(
modalDialog(
title = tags$h3("Selected Pitch Details:", style="color: darkcyan;"),
verbatimTextOutput("selected_pitch_info"),
tags$br(),
tags$label("Change Pitch Type To:", style="color: peru; font-weight: 600;"),
selectInput("modal_new_pitch_type", NULL,
choices = c("Fastball","Sinker","Cutter","Slider","Curveball",
"ChangeUp","Splitter","Knuckleball","Other"),
selected = selected_pitch()[["data"]][["TaggedPitchType"]]),
tags$br(),
actionButton("update_pitch","Update Pitch Type", class="btn-primary btn-lg"),
actionButton("cancel_edit","Cancel", class="btn-default"),
easyClose = TRUE, footer = NULL, size = "m"
)
)
}
observeEvent(event_data("plotly_click", source="mv"), {
req(input$selection_mode == "single")
clk <- event_data("plotly_click", source="mv"); req(nrow(as.data.frame(clk))>0)
key <- clk$key[[1]]
d <- pitcher_df(); hit <- d[d$row_key == key, ]
if (nrow(hit)==1) show_pitch_modal(hit)
})
observeEvent(event_data("plotly_click", source="loc"), {
req(input$selection_mode == "single")
clk <- event_data("plotly_click", source="loc"); req(nrow(as.data.frame(clk))>0)
key <- clk$key[[1]]
d <- pitcher_df(); hit <- d[d$row_key == key, ]
if (nrow(hit)==1) show_pitch_modal(hit)
})
# ---- Selected pitch info in modal ----
output$selected_pitch_info <- renderText({
info <- selected_pitch()
if (is.null(info)) return("No pitch selected")
d <- info$data
paste(
paste("Pitcher:", info$pitcher),
paste("Current Type:", d$TaggedPitchType),
paste("Velocity:", round(d$RelSpeed,1), "mph"),
paste("Horizontal Break:", round(d$HorzBreak,1), "inches"),
paste("Induced Vertical Break:", round(d$InducedVertBreak,1), "inches"),
if ("SpinRate" %in% names(d) && !is.na(d$SpinRate)) paste("Spin Rate:", round(d$SpinRate,0), "rpm") else "",
if ("Date" %in% names(d) && !is.na(d$Date)) paste("Date:", format(as.Date(d$Date))) else "",
sep = "\n"
)
})
# ---- Apply single-change from modal ----
observeEvent(input$update_pitch, {
info <- selected_pitch(); req(info)
new_type <- input$modal_new_pitch_type; req(new_type)
cur <- plot_data(); req("Pitcher" %in% names(cur))
# map row_key back to absolute index for this pitcher slice
idx_pitcher <- which(cur$Pitcher == info$pitcher)
if (length(idx_pitcher) >= info$row_key && info$row_key > 0) {
abs_row <- idx_pitcher[info$row_key]
cur$TaggedPitchType[abs_row] <- new_type
plot_data(cur); processed_data(cur)
removeModal()
showNotification(paste("Updated pitch from", info$original_type, "to", new_type),
type="message", duration=3)
selected_pitch(NULL)
} else {
showNotification("Could not map selection back to dataset.", type="error")
}
})
observeEvent(input$cancel_edit, { removeModal(); selected_pitch(NULL) })
# ---- Drag select (either chart) ----
observeEvent(event_data("plotly_selected", source="mv"), {
req(input$selection_mode == "drag")
ev <- event_data("plotly_selected", source="mv")
if (!is.null(ev) && length(ev$key)) selected_keys(unique(as.integer(ev$key)))
})
observeEvent(event_data("plotly_selected", source="loc"), {
req(input$selection_mode == "drag")
ev <- event_data("plotly_selected", source="loc")
if (!is.null(ev) && length(ev$key)) selected_keys(unique(as.integer(ev$key)))
})
# selection info text
output$selection_info <- renderText({
keys <- selected_keys()
if (!length(keys)) return("No points selected.")
d <- pitcher_df(); sel <- d[d$row_key %in% keys, ]
cnt <- sort(table(sel$TaggedPitchType), decreasing = TRUE)
paste(nrow(sel), "points selected:", paste(names(cnt), "(", cnt, ")", collapse=", "))
})
# bulk change
observeEvent(input$apply_bulk_change, {
req(input$selection_mode == "drag")
keys <- selected_keys(); req(length(keys) > 0)
new_type <- input$bulk_pitch_type; req(new_type)
cur <- plot_data(); req("Pitcher" %in% names(cur))
idx_pitcher <- which(cur$Pitcher == input$pitcher_select)
valid_keys <- keys[keys > 0 & keys <= length(idx_pitcher)]
abs_rows <- idx_pitcher[valid_keys]
cur$TaggedPitchType[abs_rows] <- new_type
plot_data(cur); processed_data(cur)
selected_keys(integer(0))
showNotification(paste("Updated", length(abs_rows), "pitches to", new_type),
type="message", duration=3)
})
# click info small text
output$click_info <- renderText({
info <- selected_pitch()
if (is.null(info)) "No point selected yet. Click a point to edit its pitch type."
else paste("Last selected pitch:", info$original_type,
"| Position (HB, IVB): (", round(info$data$HorzBreak,1), ",",
round(info$data$InducedVertBreak,1), ")")
})
# metrics table
output$movement_stats <- renderDT({
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 = if ("StrikeZoneIndicator" %in% names(.)) StrikeZoneIndicator else
ifelse(!is.na(PlateLocSide) & !is.na(PlateLocHeight) &
PlateLocSide >= -0.95 & PlateLocSide <= 0.95 &
PlateLocHeight >= 1.6 & PlateLocHeight <= 3.5, 1, 0),
is_whiff = if ("WhiffIndicator" %in% names(.)) WhiffIndicator else
ifelse(!is.na(PitchCall) & PitchCall == "StrikeSwinging", 1, 0),
chase = if ("Chaseindicator" %in% names(.)) Chaseindicator else
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)
summary_stats <- movement_stats %>%
group_by(`Pitch Type` = pitch_group) %>%
summarise(
Count = n(),
`Usage%` = sprintf("%.1f%%", (n()/total_pitches)*100),
`Ext.` = if ("Extension" %in% names(.)) sprintf("%.1f", mean(Extension, na.rm=TRUE)) else "—",
`Avg Velo` = sprintf("%.1f mph", mean(RelSpeed, na.rm=TRUE)),
`90th Velo` = sprintf("%.1f mph", quantile(RelSpeed, 0.9, na.rm=TRUE)),
`Max Velo` = sprintf("%.1f mph", max(RelSpeed, na.rm=TRUE)),
`Avg IVB` = sprintf("%.1f in", mean(InducedVertBreak, na.rm=TRUE)),
`Avg HB` = sprintf("%.1f in", mean(HorzBreak, na.rm=TRUE)),
`Avg Spin` = if ("SpinRate" %in% names(.)) sprintf("%.0f rpm", mean(SpinRate, na.rm=TRUE)) else "—",
`Rel Height` = if ("RelHeight" %in% names(.)) sprintf("%.1f", mean(RelHeight, na.rm=TRUE)) 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)),
`Chase%` = sprintf("%.1f%%", round(mean(chase, na.rm=TRUE)*100,1)),
.groups = "drop"
) %>% arrange(desc(Count))
datatable(summary_stats, options=list(pageLength=15, dom='t', scrollX=TRUE), rownames=FALSE) %>%
formatStyle(columns = names(summary_stats), fontSize='12px')
})
# data summary + download
output$data_summary <- renderText({
req(processed_data()); df <- processed_data()
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"),
sep = "\n"
)
})
output$downloadData <- downloadHandler(
filename = function(){ paste0("app_ready_COA_", Sys.Date(), ".csv") },
content = function(file){ write.csv(processed_data(), file, row.names=FALSE) }
)
}
shinyApp(ui = ui, server = server)