DataProcess / app.R
igroffman's picture
Update app.R
bb254fa verified
raw
history blame
28.5 kB
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:black; 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; }
.info-box { background-color:#f0f8ff; padding:10px; border-radius:5px; margin:10px 0; border-left:4px solid darkcyan; }
"))
),
# 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 (Edit One)"="single","Drag Select (Edit Multiple)"="drag"),
selected="single", inline=FALSE)
),
column(6,
conditionalPanel(
condition = "input.selection_mode == 'drag'",
div(class="info-box",
h4("Bulk Edit Mode:", style="margin-top:0; color:darkcyan;"),
fluidRow(
column(7, selectInput("bulk_pitch_type","Change selected pitches to:",
choices=c("Fastball","Sinker","Cutter","Slider","Curveball","ChangeUp","Splitter","Knuckleball","Other"),
selected="Fastball")),
column(5, br(), actionButton("apply_bulk_change","Apply Changes", class="btn-success btn-block"))
),
actionButton("clear_selection","Clear Selection", class="btn-warning btn-sm", style="margin-top:10px;")
)
)
)
),
fluidRow(
column(8,
h3("Interactive Pitch Movement Analysis"),
plotlyOutput("movement_plot", height="600px"),
conditionalPanel(
condition = "input.selection_mode == 'drag'",
div(class="info-box",
h4("Selected Points:", style="margin-top:0; color:darkcyan;"),
textOutput("selection_info")
)
),
div(style="padding:10px; background-color:#fff3cd; border-radius:5px; margin-top:10px;",
textOutput("status_info")
)
),
column(4,
h3("Pitch Metrics Summary"),
DTOutput("movement_stats"),
h3("Location Plot (Editable)"),
plotlyOutput("location_plot", height="600px")
)
)
),
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_uid <- reactiveVal(NULL)
selected_keys <- reactiveVal(integer(0))
plot_version <- reactiveVal(0) # Force plot refresh
# 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()
# Create unique identifiers - CRITICAL for tracking
if (!".uid" %in% names(df)) {
df$.uid <- seq_len(nrow(df))
}
processed_data(df)
plot_data(df)
plot_version(plot_version() + 1)
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])
}
}
showNotification("File processed successfully!", type="message", duration=3)
}, error = function(e){
showNotification(paste("Error processing file:", e$message), type="error", duration=5)
})
})
# 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")
})
# Get current pitcher's data
pitcher_df <- reactive({
req(plot_data(), input$pitcher_select)
plot_version() # Depend on version to trigger updates
d <- plot_data() %>%
filter(Pitcher == input$pitcher_select) %>%
filter(!is.na(HorzBreak), !is.na(InducedVertBreak), !is.na(RelSpeed))
d
})
# Movement plot
output$movement_plot <- renderPlotly({
d <- pitcher_df()
validate(need(nrow(d) > 0, "No data for selected pitcher"))
p <- 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",
customdata = ~.uid,
key = ~.uid, # Keep key for tracking
color = ~factor(TaggedPitchType),
colors = pitch_colors,
marker = list(size = 10)
)
p <- p %>% 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"
)
p <- p %>% config(displaylogo = FALSE, modeBarButtonsToRemove = c("lasso2d"))
p
})
# Location plot
output$location_plot <- renderPlotly({
d <- pitcher_df()
validate(need(nrow(d) > 0, "No data for selected pitcher"))
p <- plot_ly(
data = d, source = "loc",
type = "scatter", mode = "markers",
x = ~PlateLocSide, y = ~PlateLocHeight,
text = ~paste0(
"<b>", TaggedPitchType, "</b>",
if ("PitchNo" %in% names(d)) paste0("<br>Pitch #: ", PitchNo) else "",
"<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",
customdata = ~.uid,
key = ~.uid,
color = ~factor(TaggedPitchType),
colors = pitch_colors,
marker = list(size = 9)
)
p <- p %>% 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))
)
)
p <- p %>% config(displaylogo = FALSE, modeBarButtonsToRemove = c("lasso2d"))
p
})
# ---- SINGLE CLICK MODE ----
observeEvent(event_data("plotly_click", source="mv"), {
req(input$selection_mode == "single")
clk <- event_data("plotly_click", source="mv")
req(!is.null(clk), nrow(as.data.frame(clk)) > 0)
# Get UID from customdata
uid <- clk$customdata[[1]]
req(!is.null(uid))
d <- pitcher_df()
hit <- d[d$.uid == uid, ]
if (nrow(hit) == 1) {
selected_pitch_uid(uid)
showModal(
modalDialog(
title = tags$h3("Edit Pitch Type", style="color: darkcyan;"),
tags$div(
tags$strong("Pitcher: "), input$pitcher_select, tags$br(),
if ("PitchNo" %in% names(hit) && !is.na(hit$PitchNo))
tagList(tags$strong("Pitch No: "), hit$PitchNo, tags$br()),
tags$strong("Current Type: "), tags$span(hit$TaggedPitchType, style="color: peru; font-size: 16px;"), tags$br(),
tags$strong("Velocity: "), round(hit$RelSpeed, 1), " mph", tags$br(),
tags$strong("Horizontal Break: "), round(hit$HorzBreak, 1), " in", tags$br(),
tags$strong("Induced Vertical Break: "), round(hit$InducedVertBreak, 1), " in", tags$br(),
if ("SpinRate" %in% names(hit) && !is.na(hit$SpinRate))
tagList(tags$strong("Spin Rate: "), round(hit$SpinRate, 0), " rpm", tags$br()),
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 = hit$TaggedPitchType),
tags$br(),
footer = tagList(
actionButton("update_pitch","Update Pitch Type", class="btn-primary"),
actionButton("cancel_edit","Cancel", class="btn-default")
),
easyClose = TRUE, size = "m"
)
)
}
})
observeEvent(event_data("plotly_click", source="loc"), {
req(input$selection_mode == "single")
clk <- event_data("plotly_click", source="loc")
req(!is.null(clk), nrow(as.data.frame(clk)) > 0)
uid <- clk$customdata[[1]]
req(!is.null(uid))
d <- pitcher_df()
hit <- d[d$.uid == uid, ]
if (nrow(hit) == 1) {
selected_pitch_uid(uid)
showModal(
modalDialog(
title = tags$h3("Edit Pitch Type", style="color: darkcyan;"),
tags$div(
tags$strong("Pitcher: "), input$pitcher_select, tags$br(),
if ("PitchNo" %in% names(hit) && !is.na(hit$PitchNo))
tagList(tags$strong("Pitch No: "), hit$PitchNo, tags$br()),
tags$strong("Current Type: "), tags$span(hit$TaggedPitchType, style="color: peru; font-size: 16px;"), tags$br(),
tags$strong("Velocity: "), round(hit$RelSpeed, 1), " mph", tags$br(),
tags$strong("Location: "), "(", round(hit$PlateLocSide, 2), ", ", round(hit$PlateLocHeight, 2), ")", tags$br(),
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 = hit$TaggedPitchType),
tags$br(),
footer = tagList(
actionButton("update_pitch","Update Pitch Type", class="btn-primary"),
actionButton("cancel_edit","Cancel", class="btn-default")
),
easyClose = TRUE, size = "m"
)
)
}
})
# Update single pitch
observeEvent(input$update_pitch, {
uid <- selected_pitch_uid()
req(uid)
new_type <- input$modal_new_pitch_type
req(new_type)
cur <- plot_data()
req(nrow(cur) > 0)
cur$TaggedPitchType <- as.character(cur$TaggedPitchType)
hit_idx <- which(cur$.uid == uid)
if (length(hit_idx) == 1) {
old_type <- cur$TaggedPitchType[hit_idx]
cur$TaggedPitchType[hit_idx] <- new_type
plot_data(cur)
processed_data(cur)
plot_version(plot_version() + 1) # Force refresh
removeModal()
showNotification(
paste("Updated pitch from", old_type, "to", new_type),
type="message",
duration=2
)
selected_pitch_uid(NULL)
} else {
showNotification("Error: Could not find pitch to update.", type="error")
}
})
observeEvent(input$cancel_edit, {
removeModal()
selected_pitch_uid(NULL)
})
# ---- DRAG SELECT MODE ----
observeEvent(event_data("plotly_selected", source="mv"), {
req(input$selection_mode == "drag")
ev <- event_data("plotly_selected", source="mv")
if (!is.null(ev) && "customdata" %in% names(ev)) {
keys <- unique(as.integer(ev$customdata))
selected_keys(keys)
} else {
selected_keys(integer(0))
}
})
observeEvent(event_data("plotly_selected", source="loc"), {
req(input$selection_mode == "drag")
ev <- event_data("plotly_selected", source="loc")
if (!is.null(ev) && "customdata" %in% names(ev)) {
keys <- unique(as.integer(ev$customdata))
selected_keys(keys)
} else {
selected_keys(integer(0))
}
})
# Clear selection button
observeEvent(input$clear_selection, {
selected_keys(integer(0))
showNotification("Selection cleared", type="message", duration=2)
})
# Selection info
output$selection_info <- renderText({
keys <- selected_keys()
if (!length(keys)) return("No points selected. Use drag/lasso to select multiple pitches.")
d <- pitcher_df()
sel <- d[d$.uid %in% keys, ]
if (nrow(sel) == 0) return("Selected points not found in current pitcher's data.")
cnt <- sort(table(sel$TaggedPitchType), decreasing = TRUE)
breakdown <- paste(names(cnt), "(", cnt, ")", collapse=", ")
paste(nrow(sel), "pitches selected:", breakdown)
})
# Apply bulk changes
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(nrow(cur) > 0)
cur$TaggedPitchType <- as.character(cur$TaggedPitchType)
# Only update pitches from current pitcher
hit_idx <- which(cur$.uid %in% keys & cur$Pitcher == input$pitcher_select)
if (length(hit_idx) == 0) {
showNotification("No matching rows found for bulk edit.", type="warning")
return()
}
# Store old types for notification
old_types <- unique(cur$TaggedPitchType[hit_idx])
# Update
cur$TaggedPitchType[hit_idx] <- new_type
plot_data(cur)
processed_data(cur)
plot_version(plot_version() + 1) # Force refresh
selected_keys(integer(0))
showNotification(
paste("✓ Updated", length(hit_idx), "pitches to", new_type),
type="message",
duration=3
)
})
# Status info
output$status_info <- renderText({
if (input$selection_mode == "single") {
"Mode: Single Click - Click any point to edit its pitch type"
} else {
keys <- selected_keys()
if (length(keys) > 0) {
paste("Mode: Drag Select -", length(keys), "pitches selected. Choose type and click 'Apply Changes'")
} else {
"Mode: Drag Select - Use drag/lasso tool to select multiple pitches, then apply bulk changes"
}
}
})
# Metrics table
output$movement_stats <- renderDT({
req(plot_data(), input$pitcher_select)
plot_version() # Depend on version
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){
df_out <- processed_data()
# Remove internal UID column before export
if (".uid" %in% names(df_out)) {
df_out <- df_out %>% select(-.uid)
}
write.csv(df_out, file, row.names=FALSE)
}
)
}
shinyApp(ui = ui, server = server)