igroffman commited on
Commit
bf1498a
·
verified ·
1 Parent(s): 3bced15

Update app.R

Browse files
Files changed (1) hide show
  1. app.R +1436 -42
app.R CHANGED
@@ -1,58 +1,1452 @@
1
  library(shiny)
2
- library(bslib)
3
  library(dplyr)
4
  library(ggplot2)
 
 
 
 
 
 
 
5
 
6
- df <- readr::read_csv("penguins.csv")
7
- # Find subset of columns that are suitable for scatter plot
8
- df_num <- df |> select(where(is.numeric), -Year)
9
-
10
- ui <- page_sidebar(
11
- theme = bs_theme(bootswatch = "minty"),
12
- title = "Penguins explorer",
13
- sidebar = sidebar(
14
- varSelectInput("xvar", "X variable", df_num, selected = "Bill Length (mm)"),
15
- varSelectInput("yvar", "Y variable", df_num, selected = "Bill Depth (mm)"),
16
- checkboxGroupInput("species", "Filter by species",
17
- choices = unique(df$Species), selected = unique(df$Species)
18
- ),
19
- hr(), # Add a horizontal rule
20
- checkboxInput("by_species", "Show species", TRUE),
21
- checkboxInput("show_margins", "Show marginal plots", TRUE),
22
- checkboxInput("smooth", "Add smoother"),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
23
  ),
24
- plotOutput("scatter")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
25
  )
26
 
 
 
 
 
27
  server <- function(input, output, session) {
28
- subsetted <- reactive({
29
- req(input$species)
30
- df |> filter(Species %in% input$species)
31
- })
32
-
33
- output$scatter <- renderPlot(
34
- {
35
- p <- ggplot(subsetted(), aes(!!input$xvar, !!input$yvar)) +
36
- theme_light() +
37
- list(
38
- theme(legend.position = "bottom"),
39
- if (input$by_species) aes(color = Species),
40
- geom_point(),
41
- if (input$smooth) geom_smooth()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
42
  )
43
-
44
- if (input$show_margins) {
45
- margin_type <- if (input$by_species) "density" else "histogram"
46
- p <- p |> ggExtra::ggMarginal(
47
- type = margin_type, margins = "both",
48
- size = 8, groupColour = input$by_species, groupFill = input$by_species
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
49
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
50
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
51
 
52
- p
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
53
  },
54
- res = 100
55
  )
56
  }
57
 
58
- shinyApp(ui, server)
 
1
  library(shiny)
 
2
  library(dplyr)
3
  library(ggplot2)
4
+ library(grid)
5
+ library(gridExtra)
6
+ library(gt)
7
+ library(gtExtras)
8
+ library(stringr)
9
+ library(zip)
10
+ library(png) # for reading GT-rendered PNGs
11
 
12
+ if (!requireNamespace("magick", quietly = TRUE)) {
13
+ message("Note: Install 'magick' to enable player headshots in reports")
14
+ }
15
+
16
+ # -------------------- GLOBAL CSS --------------------
17
+ app_css <- "
18
+ body { background-color: #f5f5f5; font-family: 'Segoe UI', Arial, sans-serif; }
19
+ .header {
20
+ background: linear-gradient(135deg, #006F71 0%, #00a8a8 100%);
21
+ color: white; padding: 30px; text-align: center;
22
+ box-shadow: 0 4px 6px rgba(0,0,0,0.1); margin-bottom: 30px;
23
+ }
24
+ .header h1 { margin: 0; font-size: 2.5em; font-weight: bold; }
25
+ .header p { margin: 10px 0 0 0; font-size: 1.1em; opacity: 0.9; }
26
+ .main-panel {
27
+ background: white; border-radius: 12px; padding: 30px;
28
+ box-shadow: 0 2px 8px rgba(0,0,0,0.1);
29
+ }
30
+ .upload-box {
31
+ border: 2px dashed #006F71; border-radius: 8px; padding: 30px;
32
+ text-align: center; background: #f9fcfc; transition: all 0.3s;
33
+ }
34
+ .upload-box:hover { border-color: #00a8a8; background: #f0f8f8; }
35
+ .btn-primary {
36
+ background-color: #006F71 !important; border: none !important; padding: 12px 30px;
37
+ font-size: 16px; font-weight: bold; border-radius: 6px; transition: all 0.3s;
38
+ width: 100%;
39
+ }
40
+ .btn-primary:hover {
41
+ background-color: #00a8a8 !important; transform: translateY(-2px);
42
+ box-shadow: 0 4px 12px rgba(0,111,113,0.3);
43
+ }
44
+ .btn-secondary {
45
+ background-color: #00a8a8 !important; border: none !important; padding: 12px 30px;
46
+ font-size: 16px; font-weight: bold; border-radius: 6px; transition: all 0.3s;
47
+ width: 100%; margin-top: 10px;
48
+ }
49
+ .btn-secondary:hover {
50
+ background-color: #008a8a !important; transform: translateY(-2px);
51
+ box-shadow: 0 4px 12px rgba(0,138,138,0.3);
52
+ }
53
+ .status-box {
54
+ background: #e8f5f5; border-left: 4px solid #006F71; padding: 15px;
55
+ margin: 20px 0; border-radius: 4px;
56
+ }
57
+ .plot-container, .html-widget, .plotly, .shiny-plot-output {
58
+ width: 100% !important;
59
+ overflow: visible !important;
60
+ }
61
+ .tall-plot { height: 440px !important; }
62
+ @media (max-width: 992px) { .tall-plot { height: 360px !important; } }
63
+ "
64
+
65
+ # =====================================================================
66
+ # ====================== HITTER CODE (VERBATIM) ======================
67
+ # — copied exactly as you provided (no edits) —
68
+ # =====================================================================
69
+
70
+ process_dataset <- function(df) {
71
+ if ("Batter" %in% names(df)) {
72
+ df <- df %>% mutate(Batter = stringr::str_replace(Batter, "^\\s*(\\w+)\\s*,\\s*(\\w+)\\s*$", "\\2 \\1"))
73
+ }
74
+ df <- df %>% distinct()
75
+ if ("PitchUID" %in% names(df)) df <- df %>% distinct(PitchUID, .keep_all = TRUE)
76
+
77
+ if ("Date" %in% names(df)) {
78
+ df$Date <- suppressWarnings(as.Date(df$Date, format = "%m/%d/%Y"))
79
+ if (all(is.na(df$Date))) df$Date <- suppressWarnings(as.Date(df$Date, format = "%m/%d/%y"))
80
+ if (all(is.na(df$Date))) df$Date <- suppressWarnings(as.Date(df$Date))
81
+ }
82
+
83
+ if ("PlateLocSide" %in% names(df)) df$PlateLocSide <- as.numeric(df$PlateLocSide)
84
+ if ("PlateLocHeight" %in% names(df)) df$PlateLocHeight <- as.numeric(df$PlateLocHeight)
85
+
86
+ if ("RelSpeed" %in% names(df)) df <- df %>% filter(!is.na(RelSpeed))
87
+
88
+ if (!"TaggedPitchType" %in% names(df)) {
89
+ alt <- intersect(c("pitch_type","PitchType","TaggedPitch","TaggedPitchName"), names(df))
90
+ if (length(alt)) df$TaggedPitchType <- df[[alt[1]]] else df$TaggedPitchType <- NA_character_
91
+ }
92
+
93
+ df <- df %>%
94
+ mutate(
95
+ ExitSpeed = ifelse(!is.na(ExitSpeed) & !is.na(Angle) &
96
+ (ExitSpeed > 120 & Angle < -10 | ExitSpeed < 70),
97
+ NA, ExitSpeed),
98
+ WhiffIndicator = ifelse(PitchCall == "StrikeSwinging", 1, 0),
99
+ StrikeZoneIndicator = ifelse(
100
+ PlateLocSide >= -0.95*12 & PlateLocSide <= 0.95*12 &
101
+ PlateLocHeight >= 1.6*12 & PlateLocHeight <= 3.5*12, 1, 0
102
+ ),
103
+ SwingIndicator = ifelse(PitchCall %in% c("StrikeSwinging","FoulBallNotFieldable","FoulBall","InPlay"), 1, 0),
104
+ BIPind = ifelse(PitchCall == "InPlay" & TaggedHitType != "Bunt", 1, 0),
105
+ ABindicator = ifelse(PlayResult %in% c("Error","FieldersChoice","Out","Single","Double","Triple","HomeRun") |
106
+ KorBB == "Strikeout", 1, 0),
107
+ HitIndicator = ifelse(PlayResult %in% c("Single","Double","Triple","HomeRun"), 1, 0),
108
+ PAindicator = ifelse(PitchCall %in% c("InPlay","HitByPitch","CatchersInterference") |
109
+ KorBB %in% c("Walk","Strikeout"), 1, 0),
110
+ HBPIndicator = ifelse(PitchCall == "HitByPitch", 1, 0),
111
+ WalkIndicator = ifelse(KorBB == "Walk", 1, 0),
112
+ totalbases = dplyr::case_when(
113
+ PlayResult == "Single" ~ 1,
114
+ PlayResult == "Double" ~ 2,
115
+ PlayResult == "Triple" ~ 3,
116
+ PlayResult == "HomeRun" ~ 4,
117
+ TRUE ~ 0
118
+ ),
119
+ HHind = ifelse(PitchCall == "InPlay" & ExitSpeed >= 95, 1, 0),
120
+ Chaseindicator = ifelse(SwingIndicator == 1 & StrikeZoneIndicator == 0, 1, 0),
121
+ Zwhiffind = ifelse(WhiffIndicator == 1 & StrikeZoneIndicator == 1, 1, 0),
122
+ Zswing = ifelse(StrikeZoneIndicator == 1 & SwingIndicator == 1, 1, 0)
123
+ )
124
+ df
125
+ }
126
+
127
+ parse_game_day <- function(df, tz = "America/New_York") {
128
+ stopifnot("Date" %in% names(df))
129
+ if (inherits(df$Date, "Date")) {
130
+ dates <- df$Date[!is.na(df$Date)]
131
+ if (length(dates) > 0) {
132
+ tab <- sort(table(dates), decreasing = TRUE)
133
+ return(as.Date(names(tab)[1]))
134
+ }
135
+ }
136
+ as.Date(df$Date[1])
137
+ }
138
+
139
+ create_at_bats_plot <- function(batter_data, player_name, game_key, pitch_colors,
140
+ max_lines_per_col = 16L) {
141
+ df <- dplyr::filter(batter_data, Batter == player_name)
142
+ if (!nrow(df)) {
143
+ return(ggplot2::ggplot() + ggplot2::theme_void() +
144
+ ggplot2::ggtitle(paste("No data for", player_name)) +
145
+ ggplot2::theme(plot.title = ggplot2::element_text(hjust = 0.5, size = 14, face = "bold")))
146
+ }
147
+
148
+ plot_data <- df %>%
149
+ arrange(PitchNo) %>%
150
+ group_by(Inning, PAofInning) %>%
151
+ mutate(pa_number = dplyr::cur_group_id()) %>%
152
+ ungroup() %>%
153
+ mutate(
154
+ PlayResult = na_if(str_squish(PlayResult), "Undefined"),
155
+ PitchCall_display = dplyr::case_when(
156
+ PitchCall == "StrikeSwinging" ~ "Whiff",
157
+ PitchCall == "StrikeCalled" ~ "CS",
158
+ PitchCall %in% c("FoulBall","FoulBallNotFieldable","FoulBallFieldable") ~ "Foul",
159
+ PitchCall %in% c("BallCalled","BallinDirt","BallIntentional") ~ "Ball",
160
+ PitchCall == "HitByPitch" ~ "HBP",
161
+ PitchCall == "InPlay" ~ "In Play",
162
+ TRUE ~ coalesce(PitchCall, "—")
163
+ ),
164
+ BIP_display = dplyr::case_when(
165
+ PlayResult %in% c("Single","Double","Triple","HomeRun") ~
166
+ dplyr::recode(PlayResult, Single="1B", Double="2B", Triple="3B", HomeRun="HR"),
167
+ PlayResult == "FieldersChoice" ~ "FC",
168
+ PlayResult %in% c("Out","Error","Sacrifice","SacrificeFly") ~ PlayResult,
169
+ TRUE ~ NA_character_
170
+ ),
171
+ PlayResult_clean = dplyr::case_when(
172
+ PitchCall_display == "In Play" ~ coalesce(BIP_display, "Out"),
173
+ TRUE ~ PitchCall_display
174
+ )
175
+ ) %>%
176
+ dplyr::group_by(pa_number) %>%
177
+ dplyr::mutate(
178
+ line_idx = rank(PitchofPA, ties.method = "first"),
179
+ col_idx = ((line_idx - 1L) %/% max_lines_per_col) + 1L,
180
+ row_idx = ((line_idx - 1L) %% max_lines_per_col) + 1L
181
+ ) %>%
182
+ dplyr::ungroup() %>%
183
+ dplyr::mutate(
184
+ text_x = (20 + (col_idx - 1L) * 12) / 12,
185
+ text_y_main = (50 - (row_idx * 3)) / 12,
186
+ text_y_ev = (50 - (row_idx * 3) - 3) / 12
187
+ )
188
+
189
+ used_second_col <- any(plot_data$col_idx > 1)
190
+ x_max <- if (used_second_col) ((35 + 12) / 12) else 35/12
191
+ y_min_needed <- suppressWarnings(min(c(-1/12, min(plot_data$text_y_ev, na.rm = TRUE) - 0.05), na.rm = TRUE))
192
+
193
+ ggplot2::ggplot(plot_data, ggplot2::aes(PlateLocSide, PlateLocHeight)) +
194
+ ggplot2::annotate("rect", xmin = -0.8303, xmax = 0.8303, ymin = 1.5, ymax = 3.3775,
195
+ alpha = 0, size = .5, color = "black") +
196
+ ggplot2::annotate("segment", x = -0.708, y = 0.15, xend = 0.708, yend = 0.15, size = .5, color = "black") +
197
+ ggplot2::annotate("segment", x = -0.708, y = 0.30, xend = -0.708, yend = 0.15, size = .5, color = "black") +
198
+ ggplot2::annotate("segment", x = 0.708, y = 0.30, xend = 0.708, yend = 0.15, size = .5, color = "black") +
199
+ ggplot2::annotate("segment", x = -0.708, y = 0.30, xend = 0.000, yend = 0.50, size = .5, color = "black") +
200
+ ggplot2::annotate("segment", x = 0.708, y = 0.30, xend = 0.000, yend = 0.50, size = .5, color = "black") +
201
+ ggplot2::geom_point(ggplot2::aes(fill = TaggedPitchType),
202
+ alpha = 1, shape = 21, color = "black", stroke = 0.5, size = 4) +
203
+ ggplot2::geom_text(ggplot2::aes(label = PitchofPA),
204
+ vjust = 0.5, size = 2.2, color = "white", fontface = "bold") +
205
+ geom_text(
206
+ aes(x = text_x, y = text_y_main,
207
+ label = paste(PitchofPA, ":", PlayResult_clean)),
208
+ inherit.aes = FALSE, size = 2.1, hjust = 0
209
+ ) +
210
+ geom_text(
211
+ aes(x = text_x, y = text_y_ev,
212
+ label = ifelse(PitchCall_display == "In Play" & !is.na(ExitSpeed),
213
+ paste0(round(ExitSpeed), " EV"), "")),
214
+ inherit.aes = FALSE, size = 2.0, hjust = 0
215
+ ) +
216
+ ggplot2::facet_wrap(~ pa_number, ncol = 5) +
217
+ ggplot2::theme_void() +
218
+ ggplot2::scale_x_continuous(NULL, limits = c(-20/12, x_max)) +
219
+ ggplot2::scale_y_continuous(NULL, limits = c(y_min_needed, 60/12)) +
220
+ ggplot2::coord_fixed(ratio = 1.3, clip = "off") +
221
+ ggplot2::scale_fill_manual(values = c(
222
+ "Fastball" = "#FA8072", "Four-Seam" = "#FA8072", "Sinker" = "#fdae61",
223
+ "Slider" = "#A020F0", "Sweeper" = "magenta", "Curveball" = "#2c7bb6",
224
+ "ChangeUp" = "#90EE90", "Splitter" = "#90EE32", "Cutter" = "red"
225
+ ), name = "Pitch Type") +
226
+ ggplot2::theme(
227
+ panel.background = ggplot2::element_rect(fill = "#ffffff", color = NA),
228
+ legend.position = "top",
229
+ strip.text = ggplot2::element_text(size = 1, vjust = 1),
230
+ strip.placement = "outside",
231
+ strip.background = ggplot2::element_blank(),
232
+ plot.margin = ggplot2::margin(6, 18, 6, 6),
233
+ panel.spacing = grid::unit(8, "pt")
234
+ )
235
+ }
236
+
237
+ create_report_spray_chart <- function(game_data, player_name) {
238
+ spray_data <- game_data %>%
239
+ dplyr::filter(Batter == player_name) %>%
240
+ dplyr::arrange(PitchNo) %>%
241
+ dplyr::mutate(PitchNumber = dplyr::row_number()) %>%
242
+ dplyr::filter(!is.na(Distance), !is.na(Bearing),
243
+ PitchCall == "InPlay",
244
+ !PitchCall %in% c("FoulBall","FoulBallNotFieldable","FoulBallFieldable")) %>%
245
+ dplyr::mutate(
246
+ Bearing2 = Bearing * pi/180,
247
+ x = Distance * sin(Bearing2),
248
+ y = Distance * cos(Bearing2)
249
+ )
250
+ if (!nrow(spray_data)) return(ggplot() + theme_void() + ggtitle("No spray data"))
251
+
252
+ ggplot2::ggplot(spray_data, ggplot2::aes(x, y)) +
253
+ ggplot2::coord_fixed(xlim = c(-360, 360), ylim = c(-20, 410), expand = FALSE) +
254
+ ggplot2::annotate("segment", x = 0, y = 0, xend = 247.487, yend = 247.487, color = "black") +
255
+ ggplot2::annotate("segment", x = 0, y = 0, xend = -247.487, yend = 247.487, color = "black") +
256
+ ggplot2::annotate("segment", x = 63.6396, y = 63.6396, xend = 0, yend = 127.279, color = "black") +
257
+ ggplot2::annotate("segment", x = -63.6396, y = 63.6396, xend = 0, yend = 127.279, color = "black") +
258
+ ggplot2::annotate("curve", x = 89.095, y = 89.095, xend = 0, yend = 160, curvature = 0.36, linewidth = 0.5, color = "black") +
259
+ ggplot2::annotate("curve", x = -89.095, y = 89.095, xend = 0, yend = 160, curvature = -0.36, linewidth = 0.5, color = "black") +
260
+ ggplot2::annotate("curve", x = -247.487, y = 247.487, xend = 247.487, yend = 247.487, curvature = -0.65, linewidth = 0.5, color = "black") +
261
+ ggplot2::geom_point(size = 2.8, shape = 21, color = "black",
262
+ fill = "darkred", stroke = 0.4, alpha = 0.85) +
263
+ ggplot2::geom_text(ggplot2::aes(label = PitchNumber),
264
+ size = 1.8, color = "white", fontface = "bold") +
265
+ ggplot2::ggtitle(paste(player_name, "- Spray Chart")) +
266
+ ggplot2::theme_void() +
267
+ ggplot2::theme(
268
+ plot.margin = ggplot2::margin(5, 5, 5, 5),
269
+ plot.title = ggplot2::element_text(hjust = 0.5, size = 9, face = "bold")
270
+ )
271
+ }
272
+
273
+ create_report_contact_chart <- function(game_data, player_name) {
274
+ contact_data <- game_data %>%
275
+ filter(Batter == player_name) %>%
276
+ arrange(PitchNo) %>%
277
+ mutate(PitchNumber = row_number()) %>%
278
+ filter(!is.na(ExitSpeed), !is.na(ContactPositionZ),
279
+ !is.na(ContactPositionX), !is.na(ContactPositionY),
280
+ PitchCall == "InPlay",
281
+ !PitchCall %in% c("FoulBall", "FoulBallNotFieldable", "FoulBallFieldable")) %>%
282
+ mutate(ContactPositionX = ContactPositionX*12,
283
+ ContactPositionY = ContactPositionY*12,
284
+ ContactPositionZ = ContactPositionZ*12)
285
+ if (!nrow(contact_data)) return(ggplot() + theme_void() + ggtitle("No contact data"))
286
+
287
+ batter_side <- contact_data$BatterSide[1]; if (is.na(batter_side)) batter_side <- "Right"
288
+
289
+ ggplot(contact_data, aes(x = ContactPositionZ, y = ContactPositionX)) +
290
+ annotate("segment", x = -8.5, y = 17, xend = 8.5, yend = 17, color = "black", linewidth = 0.5) +
291
+ annotate("segment", x = 8.5, y = 8.5, xend = 8.5, yend = 17, color = "black", linewidth = 0.5) +
292
+ annotate("segment", x = -8.5, y = 8.5, xend = -8.5, yend = 17, color = "black", linewidth = 0.5) +
293
+ annotate("segment", x = -8.5, y = 8.5, xend = 0, yend = 0, color = "black", linewidth = 0.5) +
294
+ annotate("segment", x = 8.5, y = 8.5, xend = 0, yend = 0, color = "black", linewidth = 0.5) +
295
+ annotate("rect", xmin = 20, xmax = 48, ymin = -20, ymax = 40, fill = NA, color = "black", linewidth = 0.5) +
296
+ annotate("rect", xmin = -48, xmax = -20, ymin = -20, ymax = 40, fill = NA, color = "black", linewidth = 0.5) +
297
+ annotate("text", x = ifelse(batter_side == "Right", -34, 34), y = 10,
298
+ label = ifelse(batter_side == "Right", "R", "L"), size = 3.5, fontface = "bold") +
299
+ xlim(-50, 50) + ylim(-20, 50) +
300
+ geom_point(aes(fill = ExitSpeed), color = "black", stroke = .25, shape = 21, alpha = .85, size = 2.8) +
301
+ geom_text(aes(label = PitchNumber), size = 1.7, color = "white", fontface = "bold") +
302
+ scale_fill_gradient(name = "Exit Velo", low = "#E1463E", high = "#00840D") +
303
+ coord_fixed() +
304
+ ggtitle(paste(player_name, "- Contact Points")) +
305
+ theme_void() +
306
+ theme(
307
+ legend.position = "right",
308
+ plot.margin = margin(2, 2, 2, 2),
309
+ plot.title = element_text(hjust = 0.5, size = 9, face = "bold"),
310
+ legend.title = element_text(size = 7),
311
+ legend.text = element_text(size = 6),
312
+ legend.key.height = unit(0.4, "cm"),
313
+ legend.key.width = unit(0.3, "cm")
314
+ )
315
+ }
316
+
317
+ create_simple_header <- function(player_name, game_date, bio_data = NULL) {
318
+ if (!is.null(bio_data) && nrow(bio_data) > 0) {
319
+ player_bio <- bio_data %>% filter(Batter == player_name)
320
+ if (nrow(player_bio) > 0 && "Headshot" %in% names(player_bio)) {
321
+ headshot_url <- player_bio$Headshot[1]
322
+ if (!is.na(headshot_url) && nzchar(headshot_url)) {
323
+ tryCatch({
324
+ if (requireNamespace("magick", quietly = TRUE)) {
325
+ img <- magick::image_read(headshot_url)
326
+ img_grob <- rasterGrob(as.raster(img), interpolate = TRUE)
327
+ text_grob <- textGrob(
328
+ paste(player_name, "-", game_date, "Game Report"),
329
+ gp = gpar(fontface = "bold", cex = 1.6, col = "#006F71")
330
+ )
331
+ return(arrangeGrob(img_grob, text_grob, ncol = 2,
332
+ widths = unit.c(unit(100, "pt"), unit(1, "null"))))
333
+ }
334
+ }, error = function(e) { })
335
+ }
336
+ }
337
+ }
338
+ grid::textGrob(
339
+ paste(player_name, "-", game_date, "Game Report"),
340
+ gp = grid::gpar(fontface = "bold", cex = 1.8, col = "#006F71")
341
+ )
342
+ }
343
+
344
+ create_postgame_pdf <- function(game_df, player_name, output_file, bio_data = NULL) {
345
+ if (length(dev.list()) > 0) { try(dev.off(), silent = TRUE) }
346
+
347
+ pitch_colors <- c(
348
+ "Fastball" = "#FA8072", "Four-Seam" = "#FA8072", "Sinker" = "#fdae61",
349
+ "Slider" = "#A020F0", "Sweeper" = "magenta", "Curveball" = "#2c7bb6",
350
+ "ChangeUp" = "#90EE90", "Splitter" = "#90EE32", "Cutter" = "red"
351
+ )
352
+
353
+ batter_df <- dplyr::filter(game_df, Batter == player_name)
354
+ game_day <- parse_game_day(batter_df, tz = "America/New_York")
355
+ game_key <- format(game_day, "%Y-%m-%d")
356
+
357
+ game_stats <- batter_df %>%
358
+ summarise(
359
+ PA = sum(PAindicator, na.rm = TRUE),
360
+ H = sum(HitIndicator, na.rm = TRUE),
361
+ XBH = sum(PlayResult %in% c("Double","Triple","HomeRun"), na.rm = TRUE),
362
+ BB = sum(WalkIndicator, na.rm = TRUE),
363
+ K = sum(KorBB == "Strikeout", na.rm = TRUE),
364
+ Chase = sum(Chaseindicator, na.rm = TRUE),
365
+ Whiffs = sum(WhiffIndicator, na.rm = TRUE),
366
+ `IZ Whiffs` = sum(Zwhiffind, na.rm = TRUE),
367
+ BIP = sum(BIPind, na.rm = TRUE),
368
+ `Avg EV` = round(mean(ExitSpeed[PitchCall == "InPlay"], na.rm = TRUE), 1),
369
+ `Avg LA` = round(mean(Angle[PitchCall == "InPlay"], na.rm = TRUE), 1),
370
+ HH = sum(HHind, na.rm = TRUE),
371
+ .groups = "drop"
372
+ )
373
+
374
+ pitch_sequence <- batter_df %>%
375
+ arrange(PitchNo) %>%
376
+ mutate(PitchNumber = row_number()) %>%
377
+ select(PitchNumber, dplyr::everything())
378
+
379
+ at_bats_plot <- create_at_bats_plot(game_df, player_name, game_key, pitch_colors) +
380
+ theme(
381
+ legend.position = "top", plot.margin = margin(2,2,2,2),
382
+ axis.title = element_blank(), axis.text = element_blank(), axis.ticks = element_blank(),
383
+ strip.text = element_text(size = 9), legend.title = element_text(size = 9), legend.text = element_text(size = 8)
384
+ )
385
+ spray_plot <- create_report_spray_chart(game_df, player_name)
386
+ contact_plot <- create_report_contact_chart(game_df, player_name)
387
+
388
+ pitch_log <- pitch_sequence %>%
389
+ filter(PitchCall == "InPlay") %>%
390
+ mutate(
391
+ Throws = ifelse(PitcherThrows == "Right", "R", "L"),
392
+ `Zone?` = ifelse(StrikeZoneIndicator == 1, "Yes", "No"),
393
+ event = dplyr::case_when(
394
+ !is.na(PlayResult) & PlayResult != "Undefined" ~ PlayResult, TRUE ~ "Out"
395
+ ),
396
+ EV = round(ExitSpeed), LA = round(Angle),
397
+ Dist = ifelse(!is.na(Distance), round(Distance), NA),
398
+ Velo = round(RelSpeed, 1)
399
+ ) %>%
400
+ select(PitchNumber, Pitcher, Throws, Balls, Strikes, TaggedPitchType, Velo, `Zone?`, event, EV, LA, Dist)
401
+
402
+ chart_y <- 0.36
403
+ chart_h <- 0.22
404
+ plot_w <- 0.35
405
+ table_title_y <- 0.13
406
+ table_y <- 0.11
407
+
408
+ draw_pitch_table <- function(df, y_top, row_height = 0.0135, cex = 0.58) {
409
+ headers <- c("#","Pitcher","T","B","S","Pitch","Velo","Zone?","Event","EV","LA","Dist")
410
+ widths <- c(0.03,0.15,0.03,0.04,0.04,0.09,0.05,0.06,0.20,0.05,0.05,0.06)
411
+ x_start <- 0.5 - sum(widths)/2
412
+ x_pos <- c(x_start, x_start + cumsum(widths[-length(widths)]))
413
+ for (i in seq_along(headers)) {
414
+ grid.rect(x = x_pos[i], y = y_top, width = widths[i]*0.985, height = row_height,
415
+ just = c("left","top"), gp = gpar(fill = "#006F71", col = "black", lwd = 0.4))
416
+ grid.text(headers[i], x = x_pos[i] + widths[i]*0.49, y = y_top - row_height*0.5,
417
+ gp = gpar(col = "white", cex = cex, fontface = "bold"))
418
+ }
419
+ for (r in seq_len(nrow(df))) {
420
+ y_row <- y_top - r*row_height
421
+ row_vals <- c(
422
+ df$PitchNumber[r], df$Pitcher[r], df$Throws[r], df$Balls[r], df$Strikes[r],
423
+ df$TaggedPitchType[r], df$Velo[r], df$`Zone?`[r], df$event[r],
424
+ ifelse(is.na(df$EV[r]), "-", df$EV[r]),
425
+ ifelse(is.na(df$LA[r]), "-", df$LA[r]),
426
+ ifelse(is.na(df$Dist[r]), "-", df$Dist[r])
427
+ )
428
+ for (i in seq_along(row_vals)) {
429
+ grid.rect(x = x_pos[i], y = y_row, width = widths[i]*0.985, height = row_height, just = c("left","top"),
430
+ gp = gpar(fill = ifelse(r %% 2 == 0, "#f7f7f7", "white"), col = "grey80", lwd = 0.3))
431
+ grid.text(as.character(row_vals[i]),
432
+ x = x_pos[i] + widths[i]*0.49, y = y_row - row_height*0.5, gp = gpar(cex = cex))
433
+ }
434
+ }
435
+ }
436
+
437
+ pdf(output_file, width = 10.5, height = 13)
438
+ on.exit(try(dev.off(), silent = TRUE), add = TRUE)
439
+
440
+ grid::grid.newpage()
441
+
442
+ pushViewport(viewport(x = 0.5, y = 0.98, width = 0.94, height = 0.08, just = c("center","top")))
443
+ grid.draw(create_simple_header(player_name, game_key, bio_data))
444
+ popViewport()
445
+
446
+ grid.text("Game Line", x = 0.5, y = 0.89, gp = gpar(fontface = "bold", cex = 1.0))
447
+ headers <- c("PA","H","XBH","BB","K","Chase","Whiffs","IZ Whiffs","BIP","Avg EV","Avg LA","HH")
448
+ values <- c(game_stats$PA, game_stats$H, game_stats$XBH, game_stats$BB, game_stats$K,
449
+ game_stats$Chase, game_stats$Whiffs, game_stats$`IZ Whiffs`, game_stats$BIP,
450
+ game_stats$`Avg EV`, game_stats$`Avg LA`, game_stats$HH)
451
+ col_w <- 0.06; x0 <- 0.5 - (length(headers)*col_w)/2; yh <- 0.868; yv <- 0.846
452
+ for (i in seq_along(headers)) {
453
+ xi <- x0 + (i-1)*col_w
454
+ grid.rect(x = xi, y = yh, width = col_w*0.985, height = 0.018, just = c("left","top"),
455
+ gp = gpar(fill = "#006F71", col = "black", lwd = 0.5))
456
+ grid.text(headers[i], x = xi + col_w*0.49, y = yh - 0.009,
457
+ gp = gpar(col = "white", cex = 0.62, fontface = "bold"))
458
+ grid.rect(x = xi, y = yv, width = col_w*0.985, height = 0.018, just = c("left","top"),
459
+ gp = gpar(fill = "white", col = "black", lwd = 0.4))
460
+ grid.text(as.character(values[i]), x = xi + col_w*0.49, y = yv - 0.009, gp = gpar(cex = 0.62))
461
+ }
462
+
463
+ pushViewport(viewport(x = 0.5, y = 0.805, width = 0.96, height = 0.44, just = c("center","top")))
464
+ print(at_bats_plot, newpage = FALSE)
465
+ popViewport()
466
+
467
+ pushViewport(viewport(x = 0.27, y = 0.36, width = 0.35, height = 0.22, just = c("center","top")))
468
+ print(spray_plot, newpage = FALSE)
469
+ popViewport()
470
+
471
+ pushViewport(viewport(x = 0.73, y = 0.36, width = 0.35, height = 0.22, just = c("center","top")))
472
+ print(contact_plot, newpage = FALSE)
473
+ popViewport()
474
+
475
+ grid.text(paste(player_name, "-", game_key), x = 0.5, y = 0.13,
476
+ gp = gpar(fontface = "bold", cex = 0.98))
477
+
478
+ rows_total <- nrow(pitch_log)
479
+ max_rows_first <- floor((0.11 - 0.02) / 0.0130)
480
+ rows_first <- min(rows_total, max_rows_first)
481
+ if (rows_first > 0) {
482
+ draw_pitch_table(pitch_log[1:rows_first, , drop = FALSE], y_top = 0.11, row_height = 0.0130, cex = 0.58)
483
+ }
484
+ next_row <- rows_first + 1
485
+ if (next_row <= rows_total) {
486
+ grid::grid.newpage()
487
+ draw_pitch_table(pitch_log[next_row:rows_total, , drop = FALSE], y_top = 0.97, row_height = 0.0175, cex = 0.62)
488
+ }
489
+ }
490
+
491
+ # =====================================================================
492
+ # ===================== CATCHER CODE (wrapped) =======================
493
+ # Bodies mirror your catcher code; names are prefixed to avoid clashes
494
+ # =====================================================================
495
+
496
+ catcher_process_dataset <- function(df) {
497
+ if ("Catcher" %in% names(df)) {
498
+ df <- df %>% mutate(Catcher = stringr::str_replace(Catcher, "^\\s*(\\w+)\\s*,\\s*(\\w+)\\s*$", "\\2 \\1"))
499
+ }
500
+ df <- df %>% distinct()
501
+ if ("PitchUID" %in% names(df)) df <- df %>% distinct(PitchUID, .keep_all = TRUE)
502
+ if ("Date" %in% names(df)) {
503
+ df$Date <- suppressWarnings(as.Date(df$Date, format = "%m/%d/%Y"))
504
+ if (all(is.na(df$Date))) df$Date <- suppressWarnings(as.Date(df$Date, format = "%m/%d/%y"))
505
+ if (all(is.na(df$Date))) df$Date <- suppressWarnings(as.Date(df$Date))
506
+ }
507
+ if ("PlateLocSide" %in% names(df)) df$PlateLocSide <- as.numeric(df$PlateLocSide)
508
+ if ("PlateLocHeight" %in% names(df)) df$PlateLocHeight <- as.numeric(df$PlateLocHeight)
509
+ if ("BasePositionZ" %in% names(df)) df$BasePositionZ <- as.numeric(df$BasePositionZ)
510
+ if ("BasePositionY" %in% names(df)) df$BasePositionY <- as.numeric(df$BasePositionY)
511
+
512
+ BALL_CALLS <- c("BallCalled", "BallinDirt", "BallIntentional")
513
+ STRIKE_CALLS <- c("StrikeCalled")
514
+ SWING_CALLS <- c("StrikeSwinging", "InPlay", "FoulBall", "FoulBallFieldable", "FoulBallNotFieldable")
515
+
516
+ df %>%
517
+ mutate(
518
+ PitchCall = trimws(gsub("\\s+", "", PitchCall)),
519
+ in_zone = as.integer(!is.na(PlateLocSide) & !is.na(PlateLocHeight) &
520
+ PlateLocSide >= -0.95 & PlateLocSide <= 0.95 &
521
+ PlateLocHeight >= 1.6 & PlateLocHeight <= 3.5),
522
+ is_swing = as.integer(PitchCall %in% SWING_CALLS),
523
+ StrikeZoneIndicator = in_zone,
524
+ StolenStrike = as.integer(in_zone == 0 & PitchCall %in% STRIKE_CALLS),
525
+ StrikeLost = as.integer(in_zone == 1 & PitchCall %in% BALL_CALLS),
526
+ frame = dplyr::case_when(
527
+ in_zone == 1 & PitchCall %in% BALL_CALLS ~ "Strike Lost",
528
+ in_zone == 0 & PitchCall %in% STRIKE_CALLS ~ "Strike Added",
529
+ TRUE ~ NA_character_
530
+ ),
531
+ frame_numeric = dplyr::case_when(
532
+ in_zone == 1 & PitchCall %in% BALL_CALLS ~ -1,
533
+ in_zone == 0 & PitchCall %in% STRIKE_CALLS ~ 1,
534
+ TRUE ~ NA_real_
535
+ )
536
+ )
537
+ }
538
+
539
+ catcher_parse_game_day <- function(df, tz = "America/New_York") {
540
+ stopifnot("Date" %in% names(df))
541
+ if (inherits(df$Date, "Date")) {
542
+ dates <- df$Date[!is.na(df$Date)]
543
+ if (length(dates) > 0) {
544
+ tab <- sort(table(dates), decreasing = TRUE)
545
+ return(as.Date(names(tab)[1]))
546
+ }
547
+ }
548
+ as.Date(df$Date[1])
549
+ }
550
+
551
+ catcher_create_framing_plots <- function(catcher_data, catcher_name) {
552
+ df <- dplyr::filter(catcher_data, Catcher == catcher_name, is_swing == 0)
553
+ strikes_added <- df %>% filter(frame == "Strike Added")
554
+ strikes_lost <- df %>% filter(frame == "Strike Lost")
555
+ pitch_colors <- c(
556
+ "Fastball"="#FA8072","Four-Seam"="#FA8072","Sinker"="#fdae61","Slider"="#A020F0",
557
+ "Sweeper"="magenta","Curveball"="#2c7bb6","ChangeUp"="#90EE90","Splitter"="#90EE32","Cutter"="red"
558
+ )
559
+ make_plot <- function(data, title){
560
+ if (!nrow(data)) return(ggplot() + theme_void() + ggtitle(title))
561
+ ggplot(data, aes(PlateLocSide, PlateLocHeight)) +
562
+ annotate("rect", xmin=-0.83, xmax=0.83, ymin=1.5, ymax=3.3775, alpha=0, size=.5, color="black") +
563
+ annotate("segment", x=-0.708,y=0.15,xend=0.708,yend=0.15, size=.5, color="black") +
564
+ annotate("segment", x=-0.708,y=0.3,xend=-0.708,yend=0.15, size=.5, color="black") +
565
+ annotate("segment", x= 0.708,y=0.3,xend= 0.708,yend=0.15, size=.5, color="black") +
566
+ annotate("segment", x=-0.708,y=0.3,xend=0,yend=0.5, size=.5, color="black") +
567
+ annotate("segment", x= 0.708,y=0.3,xend=0,yend=0.5, size=.5, color="black") +
568
+ geom_point(aes(color=TaggedPitchType), size=3, alpha=.95) +
569
+ scale_color_manual(values=pitch_colors, na.value="grey60") +
570
+ xlim(-2,2) + ylim(0,4) + coord_fixed() +
571
+ labs(color="", title=title) + theme_void() +
572
+ theme(plot.title=element_text(size=11, face="bold", hjust=.5),
573
+ legend.position="none")
574
+ }
575
+ list(p1 = make_plot(strikes_added, "Strikes Stolen"),
576
+ p2 = make_plot(strikes_lost, "Strikes Lost"))
577
+ }
578
+
579
+ catcher_create_framing_plot <- function(catcher_data, catcher_name) {
580
+ plots <- catcher_create_framing_plots(catcher_data, catcher_name)
581
+ gridExtra::grid.arrange(plots$p1, plots$p2, ncol = 2)
582
+ }
583
+
584
+ catcher_create_throwing_plot <- function(catcher_data, catcher_name) {
585
+ throws_data <- catcher_data %>%
586
+ filter(Catcher == catcher_name) %>%
587
+ filter(Notes %in% c('2b out','2b safe','3b out','3b safe'))
588
+ if (!nrow(throws_data))
589
+ return(ggplot() + theme_void() + ggtitle("No throwing data available") +
590
+ theme(plot.title = element_text(hjust = 0.5, size = 11, face = "bold")))
591
+ ggplot(throws_data) +
592
+ geom_polygon(data=data.frame(x=c(-10,10,10,-10), y=c(0.25,0.25,8,8)),
593
+ aes(x=x,y=y), fill='#14a6a8', color='#14a6a8') +
594
+ geom_polygon(data=data.frame(x=c(-10,10,10,-10), y=c(8,8,9,9)),
595
+ aes(x=x,y=y), fill='yellow', color='yellow') +
596
+ geom_polygon(data=data.frame(x=c(-10,10,10,-10), y=c(-2,-2,0.25,0.25)),
597
+ aes(x=x,y=y), fill='brown', color='brown') +
598
+ geom_polygon(data=data.frame(x=c(-10,10,10,-10), y=c(-5,-5,-2,-2)),
599
+ aes(x=x,y=y), fill='darkgreen', color='darkgreen') +
600
+ geom_polygon(data=data.frame(x=c(-1,1,1,-1), y=c(0,0,0.45,0.45)),
601
+ aes(x=x,y=y), fill='white', color='black') +
602
+ geom_polygon(data=data.frame(x=c(-1,0,0,-1), y=c(0,0,0.45,0.45)),
603
+ aes(x=x,y=y), fill='lightgrey', color='black') +
604
+ geom_point(aes(x=BasePositionZ, y=BasePositionY, fill=Notes),
605
+ color='white', pch=21, alpha=.99, size=3.5) +
606
+ scale_fill_manual(values=c('2b safe'='red','2b out'='#339a1d','3b safe'='#ff6b6b','3b out'='#1a5d1a')) +
607
+ scale_x_continuous(limits=c(-10,10)) + scale_y_continuous(limits=c(-5,9)) +
608
+ theme_bw() + coord_fixed() +
609
+ theme(legend.position="bottom", axis.title=element_blank(),
610
+ axis.text=element_blank(), axis.ticks=element_blank(),
611
+ panel.grid=element_blank(),
612
+ plot.title=element_text(size=11, face='bold', hjust=.5)) +
613
+ ggtitle(paste(catcher_name, "- Throwing Report"))
614
+ }
615
+
616
+ catcher_create_simple_header <- function(catcher_name, game_date, bio_data = NULL) {
617
+ if (!is.null(bio_data) && nrow(bio_data) > 0) {
618
+ catcher_bio <- bio_data %>% filter(Catcher == catcher_name)
619
+ if (nrow(catcher_bio) > 0 && "Headshot" %in% names(catcher_bio)) {
620
+ headshot_url <- catcher_bio$Headshot[1]
621
+ if (!is.na(headshot_url) && nzchar(headshot_url)) {
622
+ tryCatch({
623
+ if (requireNamespace("magick", quietly = TRUE)) {
624
+ img <- magick::image_read(headshot_url)
625
+ img_grob <- rasterGrob(as.raster(img), interpolate = TRUE)
626
+ text_grob <- textGrob(paste(catcher_name, "- Post Game Report"),
627
+ gp = gpar(fontface = "bold", cex = 1.6, col = "#006F71"))
628
+ return(arrangeGrob(img_grob, text_grob, ncol = 2,
629
+ widths = unit.c(unit(100, "pt"), unit(1, "null"))))
630
+ }
631
+ }, error = function(e) { })
632
+ }
633
+ }
634
+ }
635
+ grid::textGrob(paste(catcher_name, "- Post Game Report"),
636
+ gp = grid::gpar(fontface = "bold", cex = 1.8, col = "#006F71"))
637
+ }
638
+
639
+ catcher_create_catcher_pdf <- function(game_df, catcher_name, output_file, bio_data = NULL) {
640
+ if (length(dev.list()) > 0) { try(dev.off(), silent = TRUE) }
641
+ catcher_df <- dplyr::filter(game_df, Catcher == catcher_name)
642
+ game_day <- catcher_parse_game_day(catcher_df, tz = "America/New_York")
643
+ game_key <- format(game_day, "%Y-%m-%d")
644
+
645
+ receiving_stats <- catcher_df %>%
646
+ summarise(`CCU Strikes Stolen` = sum(StolenStrike, na.rm = TRUE),
647
+ `CCU Strikes Lost` = sum(StrikeLost, na.rm = TRUE),
648
+ `CCU Game +/-` = sum(StolenStrike, na.rm = TRUE) - sum(StrikeLost, na.rm = TRUE),
649
+ .groups = "drop")
650
+
651
+ pitch_log <- catcher_df %>%
652
+ filter(StolenStrike == 1 | StrikeLost == 1) %>%
653
+ select(PitchNo, Pitcher, Catcher, Batter, TaggedPitchType, PitchCall, StrikeZoneIndicator) %>%
654
+ mutate(Pitch = TaggedPitchType, Actual = ifelse(StrikeZoneIndicator == 1, "STRIKE", "BALL")) %>%
655
+ select(PitchNo, Pitch, Pitcher, Catcher, Batter, PitchCall, Actual)
656
+
657
+ throw_log <- catcher_df %>%
658
+ filter(Notes %in% c('2b out','2b safe','3b out','3b safe')) %>%
659
+ select(PitchNo, Pitcher, Catcher, ThrowSpeed, PopTime, ExchangeTime, Notes)
660
+
661
+ framing_plots <- catcher_create_framing_plots(game_df, catcher_name)
662
+ framing_p1_grob <- ggplotGrob(framing_plots$p1)
663
+ framing_p2_grob <- ggplotGrob(framing_plots$p2)
664
+ throwing_grob <- ggplotGrob(catcher_create_throwing_plot(game_df, catcher_name))
665
+
666
+ pdf(output_file, width = 10.5, height = 13)
667
+ tryCatch({
668
+ grid::grid.newpage()
669
+ pushViewport(viewport(x=.5, y=.98, width=.94, height=.08, just=c("center","top")))
670
+ grid.draw(catcher_create_simple_header(catcher_name, game_key, bio_data)); popViewport()
671
+
672
+ grid.text("Receiving", x=.5, y=.89, gp=gpar(fontface="bold", cex=1.2, col="#006F71"))
673
+ headers_r <- c("CCU Strikes Stolen","CCU Strikes Lost","CCU Game +/-")
674
+ values_r <- c(receiving_stats$`CCU Strikes Stolen`, receiving_stats$`CCU Strikes Lost`, receiving_stats$`CCU Game +/-`)
675
+ col_w <- .18; x0 <- .5 - (length(headers_r)*col_w)/2; yh <- .868; yv <- .846
676
+ for (i in seq_along(headers_r)) {
677
+ xi <- x0 + (i-1)*col_w
678
+ grid.rect(x=xi, y=yh, width=col_w*.985, height=.018, just=c("left","top"),
679
+ gp=gpar(fill="#006F71", col="black", lwd=.5))
680
+ grid.text(headers_r[i], x=xi + col_w*.49, y=yh - .009, gp=gpar(col="white", cex=.7, fontface="bold"))
681
+ grid.rect(x=xi, y=yv, width=col_w*.985, height=.018, just=c("left","top"),
682
+ gp=gpar(fill="white", col="black", lwd=.4))
683
+ grid.text(as.character(values_r[i]), x=xi + col_w*.49, y=yv - .009, gp=gpar(cex=.7))
684
+ }
685
+
686
+ pushViewport(viewport(x=.25, y=.81, width=.47, height=.28, just=c("center","top"))); grid.draw(framing_p1_grob); popViewport()
687
+ pushViewport(viewport(x=.75, y=.81, width=.47, height=.28, just=c("center","top"))); grid.draw(framing_p2_grob); popViewport()
688
+
689
+ y_framing_table_start <- .50
690
+ if (nrow(pitch_log) > 0) {
691
+ headers_f <- c("PitchNo","Pitch","Pitcher","Catcher","Batter","PitchCall","Actual")
692
+ widths_f <- c(.08,.10,.15,.15,.15,.12,.10)
693
+ x_start <- .5 - sum(widths_f)/2
694
+ x_pos <- c(x_start, x_start + cumsum(widths_f[-length(widths_f)]))
695
+ row_h <- .013; y_top <- y_framing_table_start
696
+ for(i in seq_along(headers_f)){
697
+ grid.rect(x=x_pos[i], y=y_top, width=widths_f[i]*.985, height=row_h, just=c("left","top"),
698
+ gp=gpar(fill="#006F71", col="black", lwd=.4))
699
+ grid.text(headers_f[i], x=x_pos[i]+widths_f[i]*.49, y=y_top - row_h*.5, gp=gpar(col="white", cex=.58, fontface="bold"))
700
+ }
701
+ max_rows <- min(8, nrow(pitch_log))
702
+ for(r in 1:max_rows){
703
+ y_row <- y_top - r*row_h
704
+ row_vals <- c(pitch_log$PitchNo[r], pitch_log$Pitch[r], pitch_log$Pitcher[r],
705
+ pitch_log$Catcher[r], pitch_log$Batter[r], pitch_log$PitchCall[r],
706
+ pitch_log$Actual[r])
707
+ fill_color <- ifelse(pitch_log$Actual[r] == "STRIKE", "#90EE90", "#FFB6C1")
708
+ for(i in seq_along(row_vals)){
709
+ bg_fill <- if (i == 7) fill_color else ifelse(r %% 2 == 0, "#f7f7f7", "white")
710
+ grid.rect(x=x_pos[i], y=y_row, width=widths_f[i]*.985, height=row_h, just=c("left","top"),
711
+ gp=gpar(fill=bg_fill, col="grey80", lwd=.3))
712
+ grid.text(as.character(row_vals[i]), x=x_pos[i]+widths_f[i]*.49, y=y_row - row_h*.5, gp=gpar(cex=.55))
713
+ }
714
+ }
715
+ }
716
+
717
+ y_throwing_start <- .35
718
+ grid.text("Throwing Report", x=.5, y=y_throwing_start, gp=gpar(fontface="bold", cex=1.2, col="#006F71"))
719
+ pushViewport(viewport(x=.5, y=y_throwing_start - .02, width=.55, height=.22, just=c("center","top"))); grid.draw(throwing_grob); popViewport()
720
+
721
+ y_throwing_table_start <- .10
722
+ if (nrow(throw_log) > 0) {
723
+ headers_t <- c("PitchNo","Pitcher","Catcher","ThrowSpeed","PopTime","ExchangeTime","Notes")
724
+ widths_t <- c(.08,.18,.15,.12,.10,.13,.10)
725
+ x_start_t <- .5 - sum(widths_t)/2
726
+ x_pos_t <- c(x_start_t, x_start_t + cumsum(widths_t[-length(widths_t)]))
727
+ row_ht <- .012; y_top_t <- y_throwing_table_start
728
+ for(i in seq_along(headers_t)){
729
+ grid.rect(x=x_pos_t[i], y=y_top_t, width=widths_t[i]*.985, height=row_ht, just=c("left","top"),
730
+ gp=gpar(fill="#006F71", col="black", lwd=.4))
731
+ grid.text(headers_t[i], x=x_pos_t[i]+widths_t[i]*.49, y=y_top_t - row_ht*.5, gp=gpar(col="white", cex=.55, fontface="bold"))
732
+ }
733
+ max_rows_t <- min(5, nrow(throw_log))
734
+ for(r in 1:max_rows_t){
735
+ y_row_t <- y_top_t - r*row_ht
736
+ row_vals_t <- c(throw_log$PitchNo[r], throw_log$Pitcher[r], throw_log$Catcher[r],
737
+ round(throw_log$ThrowSpeed[r],1), round(throw_log$PopTime[r],2),
738
+ round(throw_log$ExchangeTime[r],2), throw_log$Notes[r])
739
+ for(i in seq_along(row_vals_t)){
740
+ bg_fill <- ifelse(r %% 2 == 0, "#f7f7f7", "white")
741
+ grid.rect(x=x_pos_t[i], y=y_row_t, width=widths_t[i]*.985, height=row_ht, just=c("left","top"),
742
+ gp=gpar(fill=bg_fill, col="grey80", lwd=.3))
743
+ grid.text(as.character(row_vals_t[i]), x=x_pos_t[i]+widths_t[i]*.49, y=y_row_t - row_ht*.5, gp=gpar(cex=.55))
744
+ }
745
+ }
746
+ }
747
+ }, error=function(e){ message("Error creating PDF: ", e$message) }, finally={ dev.off() })
748
+ if (!file.exists(output_file)) stop("PDF file was not created successfully")
749
+ return(output_file)
750
+ }
751
+
752
+ # =====================================================================
753
+ # ===================== PITCHER CODE (UPDATED) ======================
754
+ # - GT tables (sharp) via png + grob
755
+ # - Bigger, consistent titles
756
+ # - Location plot uses your zone annotations & scaled points
757
+ # =====================================================================
758
+
759
+ # helper: render gt to PNG -> grob (no dpi arg; uses vwidth+zoom for crispness)
760
+ gt_to_grob <- function(gt_tbl, vwidth = 2000, vheight = NULL, zoom = 2.5) {
761
+ if (!requireNamespace("gt", quietly = TRUE)) stop("Package 'gt' is required")
762
+ tmp <- tempfile(fileext = ".png")
763
+ # gtsave uses webshot2 under the hood when saving images
764
+ gt::gtsave(gt_tbl, filename = tmp, path = NULL, vwidth = vwidth, vheight = vheight, zoom = zoom, expand = 5)
765
+ img <- png::readPNG(tmp)
766
+ grid::rasterGrob(img, interpolate = TRUE)
767
+ }
768
+
769
+ create_pitcher_game_line <- function(game_data) {
770
+ game_data %>%
771
+ summarise(
772
+ Date = format(unique(Date)[1], "%m/%d/%y"),
773
+ BF = n_distinct(paste(Inning, Batter, PAofInning)),
774
+ K = sum(KorBB == "Strikeout", na.rm = TRUE),
775
+ BB = sum(WalkIndicator, na.rm = TRUE),
776
+ HBP = sum(HBPIndicator, na.rm = TRUE),
777
+ H = sum(PlayResult %in% c('Single','Double','Triple','HomeRun'), na.rm = TRUE),
778
+ XBH = sum(PlayResult %in% c('Double','Triple','HomeRun'), na.rm = TRUE),
779
+ `Strike %` = round(mean(PitchCall %in% c("StrikeCalled","StrikeSwinging","FoulBall","FoulBallNotFieldable","InPlay"), na.rm = TRUE) * 100, 1),
780
+ `Whiff %` = round(sum(WhiffIndicator, na.rm = TRUE) / sum(SwingIndicator, na.rm = TRUE) * 100, 1)
781
+ )
782
+ }
783
+
784
+ create_pitcher_pitch_char <- function(game_data) {
785
+ game_data %>%
786
+ filter(TaggedPitchType != "Other", !is.na(TaggedPitchType)) %>%
787
+ group_by(Pitch = TaggedPitchType) %>%
788
+ summarise(
789
+ Total = n(),
790
+ `Avg Velo` = round(mean(RelSpeed, na.rm = TRUE), 1),
791
+ `Max Velo` = round(max(RelSpeed, na.rm = TRUE), 1),
792
+ `Avg Spin` = round(mean(SpinRate, na.rm = TRUE), 0),
793
+ `Max Spin` = round(max(SpinRate, na.rm = TRUE), 1),
794
+ `Avg IVB` = round(mean(InducedVertBreak, na.rm = TRUE), 1),
795
+ `Avg HB` = round(mean(HorzBreak, na.rm = TRUE), 1),
796
+ RelHt = round(mean(RelHeight, na.rm = TRUE), 1),
797
+ Ext = round(mean(Extension, na.rm = TRUE), 1),
798
+ `Strike %` = round(mean(PitchCall %in% c("StrikeCalled","StrikeSwinging","FoulBall","FoulBallNotFieldable","InPlay"), na.rm = TRUE) * 100, 1),
799
+ `Whiff %` = round(sum(WhiffIndicator, na.rm = TRUE) / sum(SwingIndicator, na.rm = TRUE) * 100, 1),
800
+ .groups = "drop"
801
+ ) %>% arrange(desc(Total))
802
+ }
803
+
804
+ .pitch_theme <- theme_minimal(base_size = 12) +
805
+ theme(
806
+ plot.title = element_text(size = 16, face = "bold", hjust = 0.5),
807
+ panel.grid.minor = element_blank()
808
+ )
809
+
810
+ create_pitcher_movement_plot <- function(game_data, pitcher_name, pitch_colors) {
811
+ df <- game_data %>% filter(!is.na(TaggedPitchType), TaggedPitchType != "Other")
812
+ if (nrow(df) == 0) return(ggplot() + theme_void() + ggtitle("Pitch Movement"))
813
+ centers <- df %>% group_by(TaggedPitchType) %>%
814
+ summarise(
815
+ mean_velo = round(mean(RelSpeed, na.rm = TRUE)),
816
+ mean_hb = median(HorzBreak, na.rm = TRUE),
817
+ mean_ivb = median(InducedVertBreak, na.rm = TRUE), .groups = "drop"
818
+ )
819
+ ggplot(df, aes(x = HorzBreak, y = InducedVertBreak)) +
820
+ geom_vline(xintercept = 0, color = "black", linewidth = 0.5) +
821
+ geom_hline(yintercept = 0, color = "black", linewidth = 0.5) +
822
+ geom_point(aes(fill = TaggedPitchType), alpha = 0.85, shape = 21, color = "black", stroke = 0.4, size = 4.5) +
823
+ geom_point(data = centers, aes(x = mean_hb, y = mean_ivb, fill = TaggedPitchType),
824
+ alpha = 1, shape = 21, color = "black", stroke = 0.5, size = 8) +
825
+ geom_text(data = centers, aes(x = mean_hb, y = mean_ivb, label = mean_velo),
826
+ color = "black", size = 4, vjust = 0.5, fontface = "bold") +
827
+ scale_fill_manual(values = pitch_colors) +
828
+ coord_cartesian(xlim = c(-27.5, 27.5), ylim = c(-27.5, 27.5)) +
829
+ labs(title = "Pitch Movement", x = "Horizontal Break (in)", y = "Induced Vertical Break (in)") +
830
+ .pitch_theme +
831
+ theme(aspect.ratio = 1)
832
+ }
833
+
834
+ create_pitcher_location_plot <- function(game_data, pitch_colors) {
835
+ df <- game_data %>% filter(!is.na(TaggedPitchType), TaggedPitchType != "Other")
836
+ if (!nrow(df)) {
837
+ return(
838
+ ggplot() +
839
+ annotate("rect", xmin = -0.8303, xmax = 0.8303, ymin = 1.5, ymax = 3.3775,
840
+ alpha = 0, size = .5, color = "black") +
841
+ theme_void() + ggtitle("Pitch Locations") + theme(plot.title = element_text(size=16, face="bold", hjust=.5))
842
+ )
843
+ }
844
+ ggplot2::ggplot(df, ggplot2::aes(PlateLocSide, PlateLocHeight)) +
845
+ ggplot2::annotate("rect", xmin = -0.8303, xmax = 0.8303, ymin = 1.5, ymax = 3.3775,
846
+ alpha = 0, size = .5, color = "black") +
847
+ ggplot2::annotate("segment", x = -0.708, y = 0.15, xend = 0.708, yend = 0.15, size = .5, color = "black") +
848
+ ggplot2::annotate("segment", x = -0.708, y = 0.30, xend = -0.708, yend = 0.15, size = .5, color = "black") +
849
+ ggplot2::annotate("segment", x = 0.708, y = 0.30, xend = 0.708, yend = 0.15, size = .5, color = "black") +
850
+ ggplot2::annotate("segment", x = -0.708, y = 0.30, xend = 0.000, yend = 0.50, size = .5, color = "black") +
851
+ ggplot2::annotate("segment", x = 0.708, y = 0.30, xend = 0.000, yend = 0.50, size = .5, color = "black") +
852
+ ggplot2::geom_point(ggplot2::aes(fill = TaggedPitchType),
853
+ alpha = 0.95, shape = 21, color = "black", stroke = 0.4, size = 4) +
854
+ scale_fill_manual(values = pitch_colors, name = "Pitch Type") +
855
+ coord_fixed(xlim = c(-2, 2), ylim = c(0, 4)) +
856
+ labs(title = "Pitch Locations", x = NULL, y = NULL) +
857
+ theme_void() +
858
+ theme(
859
+ plot.title = element_text(size = 12, face = "bold", hjust = .5),
860
+ legend.position = "top",
861
+ plot.margin = margin(6, 6, 6, 6)
862
+ )
863
+ }
864
+
865
+ create_pitcher_release_plot <- function(game_data, pitch_colors) {
866
+ df <- game_data %>% filter(!is.na(RelSide), !is.na(RelHeight), TaggedPitchType != "Other")
867
+ if (!nrow(df)) return(ggplot() + theme_void() + ggtitle("Release Points") + theme(plot.title = element_text(size=16, face="bold", hjust=.5)))
868
+ avg_release <- df %>%
869
+ group_by(TaggedPitchType) %>%
870
+ summarise(RelSide = mean(RelSide, na.rm = TRUE), RelHeight = mean(RelHeight, na.rm = TRUE), .groups = "drop")
871
+ ggplot() +
872
+ geom_point(data = df, aes(RelSide, RelHeight, fill = TaggedPitchType),
873
+ size = 4, shape = 21, color = "black", alpha = 0.85, stroke = 0.25) +
874
+ geom_point(data = avg_release, aes(RelSide, RelHeight, fill = TaggedPitchType),
875
+ size = 4.5, shape = 21, color = "black", stroke = 0.3, alpha = 1) +
876
+ annotate("text", x = -5, y = 8, label = "← 1B", size = 3, hjust = 0) +
877
+ annotate("text", x = 5, y = 8, label = "3B →", size = 3, hjust = 1) +
878
+ geom_rect(aes(xmin = -5, xmax = 5, ymin = 0, ymax = 0.83), fill = "#632b11", inherit.aes = FALSE) +
879
+ geom_rect(aes(xmin = -0.5, xmax = 0.5, ymin = 0.8, ymax = 0.95),
880
+ fill = "white", color = "black", linewidth = 0.4, inherit.aes = FALSE) +
881
+ scale_fill_manual(values = pitch_colors, name = "Pitch Type") +
882
+ coord_cartesian(xlim = c(-5, 5), ylim = c(0, 8)) +
883
+ labs(title = "Release Points", x = "Release Side (ft)", y = "Release Height (ft)") +
884
+ .pitch_theme +
885
+ theme(legend.position = "top")
886
+ }
887
+
888
+ create_pitcher_pdf <- function(game_df, pitcher_name, output_file, pitch_colors) {
889
+ if (length(dev.list()) > 0) try(dev.off(), silent = TRUE)
890
+ pitcher_df <- game_df %>% {
891
+ df <- .
892
+ if (!"Pitcher" %in% names(df)) {
893
+ alt <- intersect(c("PitcherName","pitcher","Pitcher_LastFirst","PlayerName"), names(df))
894
+ if (length(alt)) df$Pitcher <- df[[alt[1]]] else df$Pitcher <- NA_character_
895
+ }
896
+ df %>% mutate(Pitcher = stringr::str_replace(coalesce(Pitcher, ""), "^\\s*(\\w+)\\s*,\\s*(\\w+)\\s*$", "\\2 \\1"))
897
+ } %>% filter(Pitcher == pitcher_name)
898
+ game_day <- parse_game_day(pitcher_df)
899
+
900
+ # data tables -> GT
901
+ game_line_df <- create_pitcher_game_line(pitcher_df)
902
+ gt_game <- game_line_df %>%
903
+ gt() %>%
904
+ tab_options(table.border.top.color = "white") %>%
905
+ opt_row_striping() %>%
906
+ cols_align(everything(), align = "center") %>%
907
+ tab_style(style = list(cell_text(weight = "bold", color = "white")),
908
+ locations = cells_column_labels(everything())) %>%
909
+ tab_style(style = cell_fill(color = "#006F71"),
910
+ locations = cells_column_labels(everything()))
911
+
912
+ pitch_char_df <- create_pitcher_pitch_char(pitcher_df)
913
+ gt_chars <- pitch_char_df %>%
914
+ gt() %>%
915
+ opt_row_striping() %>%
916
+ cols_align(everything(), align = "center")
917
+
918
+ game_grob <- gt_to_grob(gt_game, vwidth = 1700, zoom = 2.5)
919
+ char_grob <- gt_to_grob(gt_chars, vwidth = 2200, zoom = 2.5)
920
+
921
+ # plots (consistent titles + larger sizing)
922
+ movement_plot <- create_pitcher_movement_plot(pitcher_df, pitcher_name, pitch_colors)
923
+ location_plot <- create_pitcher_location_plot(pitcher_df, pitch_colors)
924
+ release_plot <- create_pitcher_release_plot(pitcher_df, pitch_colors)
925
+
926
+ pdf(output_file, width = 11, height = 8.5)
927
+ grid.newpage()
928
+
929
+ pushViewport(viewport(x = 0.5, y = 0.96, width = 1, height = 0.10, just = c("center", "top")))
930
+ grid.text(paste(pitcher_name, "- Pitcher Report -", format(game_day, "%m/%d/%y")),
931
+ gp = gpar(fontface = "bold", cex = 1.8, col = "#006F71"))
932
+ popViewport()
933
+
934
+ # GT tables
935
+ pushViewport(viewport(x = 0.5, y = 0.84, width = 0.9, height = 0.12, just = c("center","top")))
936
+ grid.draw(game_grob); popViewport()
937
+
938
+ pushViewport(viewport(x = 0.5, y = 0.68, width = 0.95, height = 0.18, just = c("center","top")))
939
+ grid.draw(char_grob); popViewport()
940
+
941
+ pushViewport(viewport(x = 0.18, y = 0.48, width = 0.32, height = 0.42, just = c("center","top")))
942
+ print(movement_plot, newpage = FALSE); popViewport()
943
+
944
+ pushViewport(viewport(x = 0.50, y = 0.48, width = 0.32, height = 0.42, just = c("center","top")))
945
+ print(location_plot, newpage = FALSE); popViewport()
946
+
947
+ pushViewport(viewport(x = 0.82, y = 0.48, width = 0.32, height = 0.42, just = c("center","top")))
948
+ print(release_plot, newpage = FALSE); popViewport()
949
+
950
+ dev.off()
951
+ }
952
+
953
+ # =====================================================================
954
+ # =========================== UI =============================
955
+ # =====================================================================
956
+
957
+ ui <- fluidPage(
958
+ tags$head(tags$style(HTML(app_css))),
959
+ div(class = "header",
960
+ h1("Postgame Report Generator"),
961
+ p("Upload one CSV to generate Coastal postgame reports")
962
  ),
963
+ fluidRow(
964
+ column(
965
+ 4,
966
+ div(class = "main-panel",
967
+ div(class = "upload-box",
968
+ h3("📁 Upload Game Data", style = "color: #006F71; margin-top: 0;"),
969
+ fileInput("game_csv", NULL, accept = c(".csv","text/csv"),
970
+ buttonLabel = "Choose CSV...", placeholder = "No file selected"),
971
+ radioButtons("report_type", "Report Type", c("Hitter"="hitter","Pitcher"="pitcher","Catcher"="catcher"),
972
+ selected = "hitter", inline = TRUE),
973
+ hr(),
974
+ h4("📸 Optional: Bio CSV", style = "color: #006F71; font-size: 1em;"),
975
+ conditionalPanel("input.report_type == 'hitter'",
976
+ fileInput("bio_csv_hitter", "Player Bio (optional)", accept = c(".csv","text/csv"),
977
+ buttonLabel = "Choose Bio CSV...", placeholder = "Optional"),
978
+ p("Upload CCU_Hitter_Bio.csv to add headshots",
979
+ style = "font-size: 0.85em; color: #666; margin-top: -10px;")
980
+ ),
981
+ conditionalPanel("input.report_type == 'catcher'",
982
+ fileInput("bio_csv_catcher", "Catcher Bio (optional)", accept = c(".csv","text/csv"),
983
+ buttonLabel = "Choose Bio CSV...", placeholder = "Optional")
984
+ )
985
+ ),
986
+ uiOutput("selector_ui"),
987
+ hr(),
988
+ uiOutput("download_ui"),
989
+ uiOutput("bulk_ui"),
990
+ uiOutput("status_message")
991
+ )
992
+ ),
993
+ column(
994
+ 8,
995
+ div(class = "main-panel",
996
+ h3("📊 Report Preview", style = "color: #006F71; margin-top: 0;"),
997
+ uiOutput("preview_content")
998
+ )
999
+ )
1000
+ )
1001
  )
1002
 
1003
+ # =====================================================================
1004
+ # =========================== SERVER =============================
1005
+ # =====================================================================
1006
+
1007
  server <- function(input, output, session) {
1008
+ data_hitter <- reactiveVal(NULL)
1009
+ data_catcher <- reactiveVal(NULL)
1010
+ bio_hitter <- reactiveVal(NULL)
1011
+ bio_catch <- reactiveVal(NULL)
1012
+
1013
+ # derive pitcher data from same CSV (ensures one read)
1014
+ data_pitcher <- reactive({
1015
+ df <- data_hitter()
1016
+ if (is.null(df)) return(NULL)
1017
+ if (!"Pitcher" %in% names(df)) {
1018
+ alt <- intersect(c("PitcherName","pitcher","Pitcher_LastFirst","PlayerName"), names(df))
1019
+ if (length(alt)) df$Pitcher <- df[[alt[1]]] else df$Pitcher <- NA_character_
1020
+ }
1021
+ df %>% mutate(
1022
+ Pitcher = stringr::str_replace(coalesce(Pitcher, ""), "^\\s*(\\w+)\\s*,\\s*(\\w+)\\s*$", "\\2 \\1")
1023
+ )
1024
+ })
1025
+
1026
+ observeEvent(input$game_csv, {
1027
+ req(input$game_csv)
1028
+ tryCatch({
1029
+ df <- read.csv(input$game_csv$datapath, stringsAsFactors = FALSE)
1030
+ data_hitter(process_dataset(df)) # EXACT hitter processing
1031
+ data_catcher(catcher_process_dataset(df))# catcher processing
1032
+ showNotification("✅ Game data loaded successfully!", type = "message", duration = 3)
1033
+ }, error = function(e) {
1034
+ showNotification(paste("❌ Error loading CSV:", e$message), type = "error", duration = 6)
1035
+ data_hitter(NULL); data_catcher(NULL)
1036
+ })
1037
+ })
1038
+
1039
+ observeEvent(input$bio_csv_hitter, {
1040
+ req(input$bio_csv_hitter)
1041
+ tryCatch({
1042
+ bio <- read.csv(input$bio_csv_hitter$datapath, stringsAsFactors = FALSE)
1043
+ if ("Batter" %in% names(bio)) {
1044
+ bio <- bio %>% mutate(Batter = stringr::str_replace(Batter, "^\\s*(\\w+)\\s*,\\s*(\\w+)\\s*$", "\\2 \\1"))
1045
+ }
1046
+ bio_hitter(bio)
1047
+ showNotification("✅ Player bio loaded", type = "message", duration = 3)
1048
+ }, error = function(e) {
1049
+ showNotification(paste("⚠️ Player bio error:", e$message), type = "warning", duration = 6)
1050
+ bio_hitter(NULL)
1051
+ })
1052
+ })
1053
+
1054
+ observeEvent(input$bio_csv_catcher, {
1055
+ req(input$bio_csv_catcher)
1056
+ tryCatch({
1057
+ bio <- read.csv(input$bio_csv_catcher$datapath, stringsAsFactors = FALSE)
1058
+ if ("Catcher" %in% names(bio)) {
1059
+ bio <- bio %>% mutate(Catcher = stringr::str_replace(Catcher, "^\\s*(\\w+)\\s*,\\s*(\\w+)\\s*$", "\\2 \\1"))
1060
+ }
1061
+ bio_catch(bio)
1062
+ showNotification("✅ Catcher bio loaded", type = "message", duration = 3)
1063
+ }, error = function(e) {
1064
+ showNotification(paste("⚠️ Catcher bio error:", e$message), type = "warning", duration = 6)
1065
+ bio_catch(NULL)
1066
+ })
1067
+ })
1068
+
1069
+
1070
+ output$selector_ui <- renderUI({
1071
+ if (input$report_type == "hitter") {
1072
+ df <- data_hitter()
1073
+ if (is.null(df)) return(div(p("Please upload a CSV to begin", style = "color:#666;font-style:italic;text-align:center;")))
1074
+ players <- sort(unique(na.omit(df$Batter)))
1075
+ if (!length(players)) return(div(p("⚠️ No players found in uploaded data", style="color:#cc6600;font-weight:bold;")))
1076
+ selectInput("player_name", "Select Player", choices = players, selected = players[1], width = "100%")
1077
+ } else if (input$report_type == "pitcher") {
1078
+ df <- data_pitcher()
1079
+ if (is.null(df)) return(div(p("Please upload a CSV to begin", style = "color:#666;font-style:italic;text-align:center;")))
1080
+ pitchers <- sort(unique(na.omit(df$Pitcher)))
1081
+ if (!length(pitchers)) return(div(p("⚠️ No pitchers found in uploaded data", style="color:#cc6600;font-weight:bold;")))
1082
+ selectInput("pitcher_name", "Select Pitcher", choices = pitchers, selected = pitchers[1], width = "100%")
1083
+ } else {
1084
+ df <- data_catcher()
1085
+ if (is.null(df)) return(div(p("Please upload a CSV to begin", style = "color:#666;font-style:italic;text-align:center;")))
1086
+ catchers <- sort(unique(na.omit(df$Catcher)))
1087
+ if (!length(catchers)) return(div(p("⚠️ No catchers found in uploaded data", style="color:#cc6600;font-weight:bold;")))
1088
+ selectInput("catcher_name", "Select Catcher", choices = catchers, selected = catchers[1], width = "100%")
1089
+ }
1090
+ })
1091
+
1092
+ output$download_ui <- renderUI({
1093
+ if (input$report_type == "hitter") {
1094
+ downloadButton("download_hitter", "📄 Download Hitter PDF", class = "btn-primary")
1095
+ } else if (input$report_type == "pitcher") {
1096
+ downloadButton("download_pitcher", "📄 Download Pitcher PDF ", class = "btn-primary")
1097
+ } else {
1098
+ downloadButton("download_catcher", "📄 Download Catcher PDF", class = "btn-primary")
1099
+ }
1100
+ })
1101
+
1102
+ output$bulk_ui <- renderUI({
1103
+ if (input$report_type == "hitter") {
1104
+ df <- data_hitter(); if (is.null(df) || !"BatterTeam" %in% names(df)) return(NULL)
1105
+ coastal_players <- df %>% filter(BatterTeam == "COA_CHA") %>% pull(Batter) %>% unique() %>% na.omit()
1106
+ if (!length(coastal_players)) return(NULL)
1107
+ tagList(
1108
+ br(),
1109
+ div(style="text-align:center;padding:10px;background:#f0f8f8;border-radius:6px;margin-top:10px;",
1110
+ p(strong("Coastal Carolina Players Found: ", length(coastal_players)),
1111
+ style="color:#006F71;margin:5px 0;"),
1112
+ downloadButton("download_all_coastal_hitters", "📦 Download All Coastal Hitter Reports (ZIP)", class="btn-secondary")
1113
  )
1114
+ )
1115
+ } else if (input$report_type == "pitcher") {
1116
+ df <- data_pitcher(); if (is.null(df) || !"PitcherTeam" %in% names(df)) return(NULL)
1117
+ coastal_pitchers <- df %>% filter(PitcherTeam == "COA_CHA") %>% pull(Pitcher) %>% unique() %>% na.omit()
1118
+ if (!length(coastal_pitchers)) return(NULL)
1119
+ tagList(
1120
+ br(),
1121
+ div(style="text-align:center;padding:10px;background:#f0f8f8;border-radius:6px;margin-top:10px;",
1122
+ p(strong("Coastal Carolina Pitchers Found: ", length(coastal_pitchers)),
1123
+ style="color:#006F71;margin:5px 0;"),
1124
+ downloadButton("download_all_coastal_pitchers", "📦 Download All Coastal Pitcher Reports (ZIP)", class="btn-secondary")
1125
+ )
1126
+ )
1127
+ } else {
1128
+ df <- data_catcher(); if (is.null(df) || !"CatcherTeam" %in% names(df)) return(NULL)
1129
+ cts <- df %>% filter(CatcherTeam == "COA_CHA") %>% pull(Catcher) %>% unique() %>% na.omit()
1130
+ if (!length(cts)) return(NULL)
1131
+ tagList(
1132
+ br(),
1133
+ div(style="text-align:center;padding:10px;background:#f0f8f8;border-radius:6px;margin-top:10px;",
1134
+ p(strong("Coastal Carolina Catchers Found: ", length(cts)),
1135
+ style="color:#006F71;margin:5px 0;"),
1136
+ downloadButton("download_all_ccu_catchers", "📦 Download All CCU Catcher Reports (ZIP)", class="btn-secondary")
1137
  )
1138
+ )
1139
+ }
1140
+ })
1141
+
1142
+ output$download_pitcher <- downloadHandler(
1143
+ filename = function() {
1144
+ df <- data_pitcher(); req(df, input$pitcher_name)
1145
+ pitcher_clean <- gsub(" ", "_", input$pitcher_name)
1146
+ date_str <- format(parse_game_day(df %>% filter(Pitcher == input$pitcher_name)), "%Y%m%d")
1147
+ paste0(pitcher_clean, "_", date_str, "_Pitcher_Report.pdf")
1148
+ },
1149
+ content = function(file) {
1150
+ df <- data_pitcher(); req(df, input$pitcher_name)
1151
+ pitch_colors <- c("Fastball"="#FA8072","Four-Seam"="#FA8072","Sinker"="#fdae61",
1152
+ "Slider"="#A020F0","Sweeper"="magenta","Curveball"="#2c7bb6",
1153
+ "ChangeUp"="#90EE90","Splitter"="#90EE32","Cutter"="red")
1154
+ withProgress(message='Generating Pitcher PDF', value=0, {
1155
+ incProgress(.3, detail="Processing data...")
1156
+ incProgress(.4, detail="Creating visualizations...")
1157
+ create_pitcher_pdf(df, input$pitcher_name, file, pitch_colors)
1158
+ incProgress(.3, detail="Finalizing report...")
1159
+ })
1160
+ showNotification("✅ Pitcher report generated!", type="message", duration=3)
1161
+ },
1162
+ contentType = "application/pdf"
1163
+ )
1164
+
1165
+ # ---- Pitcher bulk ZIP
1166
+ output$download_all_coastal_pitchers <- downloadHandler(
1167
+ filename = function() {
1168
+ df <- data_pitcher(); req(df)
1169
+ paste0("Coastal_Pitcher_Reports_", format(parse_game_day(df), "%Y%m%d"), ".zip")
1170
+ },
1171
+ content = function(file) {
1172
+ df <- data_pitcher(); req(df)
1173
+ pitch_colors <- c(
1174
+ "Fastball"="#FA8072","Four-Seam"="#FA8072","Sinker"="#fdae61",
1175
+ "Slider"="#A020F0","Sweeper"="magenta","Curveball"="#2c7bb6",
1176
+ "ChangeUp"="#90EE90","Splitter"="#90EE32","Cutter"="red"
1177
+ )
1178
+ pitchers <- df %>% dplyr::filter(PitcherTeam == "COA_CHA") %>%
1179
+ dplyr::pull(Pitcher) %>% unique() %>% na.omit() %>% sort()
1180
+ if (!length(pitchers)) {
1181
+ showNotification("No Coastal pitchers found", type="error", duration=5)
1182
+ return(NULL)
1183
  }
1184
+ withProgress(message='Generating Coastal Pitcher Reports', value=0, {
1185
+ tmp <- tempdir(); pdfs <- character(0); total <- length(pitchers)
1186
+ for (i in seq_along(pitchers)) {
1187
+ ply <- pitchers[i]; incProgress(1/total, detail=paste("Report for", ply))
1188
+ out <- file.path(tmp, paste0(gsub(" ","_",ply), "_",
1189
+ format(parse_game_day(df), "%Y%m%d"),
1190
+ "_Pitcher_Report.pdf"))
1191
+ try(create_pitcher_pdf(df, ply, out, pitch_colors), silent = TRUE)
1192
+ if (file.exists(out)) pdfs <- c(pdfs, out)
1193
+ }
1194
+ if (!length(pdfs)) {
1195
+ showNotification("Failed to generate reports", type="error", duration=5)
1196
+ return(NULL)
1197
+ }
1198
+ zip::zip(zipfile=file, files=basename(pdfs), root=tmp); unlink(pdfs)
1199
+ })
1200
+ showNotification("✅ Coastal pitcher ZIP ready!", type="message", duration=5)
1201
+ },
1202
+ contentType = "application/zip"
1203
+ )
1204
+
1205
+
1206
+ output$status_message <- renderUI({
1207
+ if (input$report_type == "hitter") {
1208
+ df <- data_hitter(); req(df, input$player_name)
1209
+ player_df <- df %>% filter(Batter == input$player_name)
1210
+ if (!nrow(player_df)) return(NULL)
1211
+ game_date <- parse_game_day(player_df)
1212
+ div(class = "status-box",
1213
+ h4("✓ Ready to Generate Hitter Report", style = "margin-top: 0; color: #006F71;"),
1214
+ p(strong("Player: "), input$player_name),
1215
+ p(strong("Game Date: "), format(game_date, "%B %d, %Y")),
1216
+ p(strong("Total Pitches: "), nrow(player_df)))
1217
+ } else if (input$report_type == "pitcher") {
1218
+ df <- data_pitcher(); req(df, input$pitcher_name)
1219
+ pitcher_df <- df %>% filter(Pitcher == input$pitcher_name)
1220
+ if (!nrow(pitcher_df)) return(NULL)
1221
+ game_date <- parse_game_day(pitcher_df)
1222
+ stats <- pitcher_df %>% summarise(pitches=n(), k=sum(KorBB=="Strikeout",na.rm=TRUE), bb=sum(WalkIndicator,na.rm=TRUE))
1223
+ div(class="status-box",
1224
+ h4("✓ Ready to Generate Pitcher Report", style="margin-top:0;color:#006F71;"),
1225
+ p(strong("Pitcher: "), input$pitcher_name),
1226
+ p(strong("Game Date: "), format(game_date, "%B %d, %Y")),
1227
+ p(strong("Total Pitches: "), stats$pitches),
1228
+ p(strong("Strikeouts: "), stats$k, " | ", strong("Walks: "), stats$bb))
1229
+ } else {
1230
+ df <- data_catcher(); req(df, input$catcher_name)
1231
+ catcher_df <- df %>% filter(Catcher == input$catcher_name)
1232
+ if (!nrow(catcher_df)) return(NULL)
1233
+ game_date <- catcher_parse_game_day(catcher_df)
1234
+ receiving_stats <- catcher_df %>% summarise(strikes_added=sum(StolenStrike,na.rm=TRUE),
1235
+ strikes_lost=sum(StrikeLost,na.rm=TRUE))
1236
+ throwing_stats <- catcher_df %>% filter(Notes %in% c('2b out','2b safe','3b out','3b safe')) %>% summarise(throws=n())
1237
+ div(class = "status-box",
1238
+ h4("✓ Ready to Generate Catcher Report", style = "margin-top: 0; color: #006F71;"),
1239
+ p(strong("Catcher: "), input$catcher_name),
1240
+ p(strong("Game Date: "), format(game_date, "%B %d, %Y")),
1241
+ p(strong("Total Pitches: "), nrow(catcher_df)),
1242
+ p(strong("Strikes Stolen: "), receiving_stats$strikes_added, " | ",
1243
+ strong("Strikes Lost: "), receiving_stats$strikes_lost),
1244
+ p(strong("Throws Recorded: "), throwing_stats$throws))
1245
+ }
1246
+ })
1247
+
1248
+ output$preview_content <- renderUI({
1249
+ if (input$report_type == "hitter") {
1250
+ df <- data_hitter(); if (is.null(df)) return(div(style = "text-align:center;padding:60px;color:#999;", h4("No data to preview")))
1251
+ req(input$player_name)
1252
+ tagList(
1253
+ h4("At-Bat Visualization", style = "color: #006F71;"),
1254
+ div(class = "tall-plot", plotOutput("preview_plot_hitter", height = "460px"))
1255
+ )
1256
+ } else if (input$report_type == "pitcher") {
1257
+ df <- data_pitcher()
1258
+ if (is.null(df)) return(div(style="text-align:center;padding:60px;color:#999;", h4("No data to preview")))
1259
+ req(input$pitcher_name)
1260
+ tagList(
1261
+ h4("Pitch Movement", style="color:#006F71;"),
1262
+ plotOutput("preview_movement", height="380px"),
1263
+ br(),
1264
+ h4("Pitch Locations", style="color:#006F71;"),
1265
+ plotOutput("preview_location", height="380px"),
1266
+ br(),
1267
+ h4("Release Points", style="color:#006F71;"),
1268
+ plotOutput("preview_release", height="380px")
1269
+ )
1270
+ } else {
1271
+ df <- data_catcher(); if (is.null(df)) return(div(style="text-align:center;padding:60px;color:#999;", h4("No data to preview")))
1272
+ req(input$catcher_name)
1273
+ tagList(
1274
+ h4("Framing Visualization", style = "color: #006F71;"),
1275
+ plotOutput("preview_framing", height = "350px"),
1276
+ br(),
1277
+ h4("Throwing Accuracy", style = "color: #006F71;"),
1278
+ plotOutput("preview_throwing", height = "400px")
1279
+ )
1280
+ }
1281
+ })
1282
+
1283
+ # ---- Hitter preview (unchanged plotting code)
1284
+ output$preview_plot_hitter <- renderPlot({
1285
+ df <- data_hitter(); req(df, input$player_name)
1286
+ player_df <- df %>% filter(Batter == input$player_name)
1287
+ validate(need(nrow(player_df) > 0, "No rows for selected player"))
1288
+ game_date <- parse_game_day(player_df)
1289
+ game_key <- format(game_date, "%Y-%m-%d")
1290
+ pitch_colors <- c(
1291
+ "Fastball" = "#FA8072", "Four-Seam" = "#FA8072", "Sinker" = "#fdae61",
1292
+ "Slider" = "#A020F0", "Sweeper" = "magenta", "Curveball" = "#2c7bb6",
1293
+ "ChangeUp" = "#90EE90", "Splitter" = "#90EE32", "Cutter" = "red"
1294
+ )
1295
+ create_at_bats_plot(df, input$player_name, game_key, pitch_colors)
1296
+ }, res = 96)
1297
+
1298
+ # ---- Pitcher previews
1299
+ output$preview_movement <- renderPlot({
1300
+ df <- data_pitcher(); req(df, input$pitcher_name)
1301
+ pitcher_df <- df %>% filter(Pitcher == input$pitcher_name)
1302
+ pitch_colors <- c("Fastball"="#FA8072","Four-Seam"="#FA8072","Sinker"="#fdae61","Slider"="#A020F0","Sweeper"="magenta","Curveball"="#2c7bb6","ChangeUp"="#90EE90","Splitter"="#90EE32","Cutter"="red")
1303
+ create_pitcher_movement_plot(pitcher_df, input$pitcher_name, pitch_colors)
1304
+ }, res=120)
1305
+
1306
+ output$preview_location <- renderPlot({
1307
+ df <- data_pitcher(); req(df, input$pitcher_name)
1308
+ pitcher_df <- df %>% filter(Pitcher == input$pitcher_name)
1309
+ pitch_colors <- c("Fastball"="#FA8072","Four-Seam"="#FA8072","Sinker"="#fdae61","Slider"="#A020F0","Sweeper"="magenta","Curveball"="#2c7bb6","ChangeUp"="#90EE90","Splitter"="#90EE32","Cutter"="red")
1310
+ create_pitcher_location_plot(pitcher_df, pitch_colors)
1311
+ }, res=120)
1312
+
1313
+ output$preview_release <- renderPlot({
1314
+ df <- data_pitcher(); req(df, input$pitcher_name)
1315
+ pitcher_df <- df %>% filter(Pitcher == input$pitcher_name)
1316
+ pitch_colors <- c("Fastball"="#FA8072","Four-Seam"="#FA8072","Sinker"="#fdae61","Slider"="#A020F0","Sweeper"="magenta","Curveball"="#2c7bb6","ChangeUp"="#90EE90","Splitter"="#90EE32","Cutter"="red")
1317
+ create_pitcher_release_plot(pitcher_df, pitch_colors)
1318
+ }, res=120)
1319
+
1320
+ # ---- Catcher previews
1321
+ output$preview_framing <- renderPlot({
1322
+ df <- data_catcher(); req(df, input$catcher_name)
1323
+ catcher_create_framing_plot(df, input$catcher_name)
1324
+ }, res = 96)
1325
+
1326
+ output$preview_throwing <- renderPlot({
1327
+ df <- data_catcher(); req(df, input$catcher_name)
1328
+ catcher_create_throwing_plot(df, input$catcher_name)
1329
+ }, res = 96)
1330
+
1331
+ # ---- Downloads
1332
+ output$download_hitter <- downloadHandler(
1333
+ filename = function() {
1334
+ df <- data_hitter(); req(df, input$player_name)
1335
+ player_clean <- gsub(" ", "_", input$player_name)
1336
+ date_str <- format(parse_game_day(df %>% filter(Batter == input$player_name)), "%Y%m%d")
1337
+ paste0(player_clean, "_", date_str, "_Report.pdf")
1338
+ },
1339
+ content = function(file) {
1340
+ df <- data_hitter(); req(df, input$player_name)
1341
+ withProgress(message='Generating Hitter PDF', value=0, {
1342
+ incProgress(.3, detail="Processing data...")
1343
+ incProgress(.4, detail="Creating visualizations...")
1344
+ create_postgame_pdf(df, input$player_name, file, bio_hitter())
1345
+ incProgress(.3, detail="Finalizing report...")
1346
+ })
1347
+ showNotification("✅ Hitter report generated!", type="message", duration=3)
1348
+ },
1349
+ contentType = "application/pdf"
1350
+ )
1351
+
1352
 
1353
+ output$download_catcher <- downloadHandler(
1354
+ filename = function() {
1355
+ df <- data_catcher(); req(df, input$catcher_name)
1356
+ catcher_clean <- gsub(" ", "_", input$catcher_name)
1357
+ date_str <- format(catcher_parse_game_day(df %>% filter(Catcher == input$catcher_name)), "%Y%m%d")
1358
+ paste0(catcher_clean, "_", date_str, "_Catcher_Report.pdf")
1359
+ },
1360
+ content = function(file) {
1361
+ df <- data_catcher(); req(df, input$catcher_name)
1362
+ withProgress(message='Generating Catcher PDF', value=0, {
1363
+ incProgress(.4, detail="Building visualizations...")
1364
+ catcher_create_catcher_pdf(df, input$catcher_name, file, bio_catch())
1365
+ incProgress(.6, detail="Finalizing...")
1366
+ })
1367
+ showNotification("✅ Catcher report generated!", type="message", duration=3)
1368
+ },
1369
+ contentType = "application/pdf"
1370
+ )
1371
+
1372
+ # ---- Bulk zips
1373
+ output$download_all_coastal_hitters <- downloadHandler(
1374
+ filename = function() {
1375
+ df <- data_hitter(); req(df)
1376
+ date <- parse_game_day(df)
1377
+ paste0("Coastal_Carolina_Hitter_Reports_", format(date, "%Y%m%d"), ".zip")
1378
+ },
1379
+ content = function(file) {
1380
+ df <- data_hitter(); req(df)
1381
+ players <- df %>% filter(BatterTeam=="COA_CHA") %>% pull(Batter) %>% unique() %>% na.omit() %>% sort()
1382
+ if (!length(players)) { showNotification("No Coastal Carolina players found", type="error", duration=5); return(NULL) }
1383
+ withProgress(message='Generating Coastal Hitter Reports', value=0, {
1384
+ tmp <- tempdir(); pdfs <- character(0); total <- length(players)
1385
+ for (i in seq_along(players)) {
1386
+ ply <- players[i]; incProgress(1/total, detail=paste("Report for", ply))
1387
+ out <- file.path(tmp, paste0(gsub(" ","_",ply), "_", format(parse_game_day(df), "%Y%m%d"), "_Report.pdf"))
1388
+ try(create_postgame_pdf(df, ply, out, bio_hitter()), silent = TRUE)
1389
+ if (file.exists(out)) pdfs <- c(pdfs, out)
1390
+ }
1391
+ if (!length(pdfs)) { showNotification("Failed to generate any hitter reports", type="error", duration=5); return(NULL) }
1392
+ zip::zip(zipfile=file, files=basename(pdfs), root=tmp); unlink(pdfs)
1393
+ })
1394
+ showNotification("✅ Coastal hitter ZIP ready!", type="message", duration=5)
1395
+ },
1396
+ contentType = "application/zip"
1397
+ )
1398
+
1399
+ output$download_all_coastal_pitchers <- downloadHandler(
1400
+ filename = function() {
1401
+ df <- data_pitcher(); req(df)
1402
+ paste0("Coastal_Pitcher_Reports_", format(parse_game_day(df), "%Y%m%d"), ".zip")
1403
+ },
1404
+ content = function(file) {
1405
+ df <- data_pitcher(); req(df)
1406
+ pitch_colors <- c("Fastball"="#FA8072","Four-Seam"="#FA8072","Sinker"="#fdae61","Slider"="#A020F0","Sweeper"="magenta","Curveball"="#2c7bb6","ChangeUp"="#90EE90","Splitter"="#90EE32","Cutter"="red")
1407
+ pitchers <- df %>% filter(PitcherTeam=="COA_CHA") %>% pull(Pitcher) %>% unique() %>% na.omit() %>% sort()
1408
+ if (!length(pitchers)) { showNotification("No Coastal pitchers found", type="error", duration=5); return(NULL) }
1409
+ withProgress(message='Generating Coastal Pitcher Reports', value=0, {
1410
+ tmp <- tempdir(); pdfs <- character(0); total <- length(pitchers)
1411
+ for (i in seq_along(pitchers)) {
1412
+ ply <- pitchers[i]; incProgress(1/total, detail=paste("Report for", ply))
1413
+ out <- file.path(tmp, paste0(gsub(" ","_",ply), "_", format(parse_game_day(df), "%Y%m%d"), "_Pitcher_Report.pdf"))
1414
+ try(create_pitcher_pdf(df, ply, out, pitch_colors), silent = TRUE)
1415
+ if (file.exists(out)) pdfs <- c(pdfs, out)
1416
+ }
1417
+ if (!length(pdfs)) { showNotification("Failed to generate reports", type="error", duration=5); return(NULL) }
1418
+ zip::zip(zipfile=file, files=basename(pdfs), root=tmp); unlink(pdfs)
1419
+ })
1420
+ showNotification("✅ Coastal pitcher ZIP ready!", type="message", duration=5)
1421
+ },
1422
+ contentType = "application/zip"
1423
+ )
1424
+
1425
+ output$download_all_ccu_catchers <- downloadHandler(
1426
+ filename = function() {
1427
+ df <- data_catcher(); req(df)
1428
+ date <- catcher_parse_game_day(df)
1429
+ paste0("CCU_Catcher_Reports_", format(date, "%Y%m%d"), ".zip")
1430
+ },
1431
+ content = function(file) {
1432
+ df <- data_catcher(); req(df)
1433
+ ccu_catchers <- df %>% filter(CatcherTeam=="COA_CHA") %>% pull(Catcher) %>% unique() %>% na.omit() %>% sort()
1434
+ if (!length(ccu_catchers)) { showNotification("No CCU catchers found", type="error", duration=5); return(NULL) }
1435
+ withProgress(message='Generating CCU Catcher Reports', value=0, {
1436
+ tmp <- tempdir(); pdfs <- character(0); total <- length(ccu_catchers)
1437
+ for (i in seq_along(ccu_catchers)) {
1438
+ ct <- ccu_catchers[i]; incProgress(1/total, detail=paste("Report for", ct))
1439
+ out <- file.path(tmp, paste0(gsub(" ","_",ct), "_", format(catcher_parse_game_day(df), "%Y%m%d"), "_Catcher_Report.pdf"))
1440
+ try(catcher_create_catcher_pdf(df, ct, out, bio_catch()), silent = TRUE)
1441
+ if (file.exists(out)) pdfs <- c(pdfs, out)
1442
+ }
1443
+ if (!length(pdfs)) { showNotification("Failed to generate any catcher reports", type="error", duration=5); return(NULL) }
1444
+ zip::zip(zipfile=file, files=basename(pdfs), root=tmp); unlink(pdfs)
1445
+ })
1446
+ showNotification("✅ CCU catcher ZIP ready!", type="message", duration=5)
1447
  },
1448
+ contentType = "application/zip"
1449
  )
1450
  }
1451
 
1452
+ shinyApp(ui = ui, server = server)