igroffman commited on
Commit
1e4e055
·
verified ·
1 Parent(s): 61f598c

Update app.R

Browse files
Files changed (1) hide show
  1. app.R +508 -215
app.R CHANGED
@@ -163,6 +163,24 @@ app_css <- "
163
  # ====================== HITTER CODE (VERBATIM) ======================
164
  # =====================================================================
165
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
166
  process_dataset <- function(df) {
167
  if ("Batter" %in% names(df)) {
168
  df <- df %>% mutate(Batter = stringr::str_replace(Batter, "^\\s*(\\w+)\\s*,\\s*(\\w+)\\s*$", "\\2 \\1"))
@@ -1072,25 +1090,24 @@ create_postgame_pdf <- function(game_df, player_name, output_file, bio_data = NU
1072
  # TABLEAU-STYLE PITCHER REPORT FUNCTIONS
1073
  # =====================================================================
1074
 
1075
- tableau_pitch_colors <- c(
1076
- "Fastball" = "#FA8072", "FourSeamFastBall" = "#FA8072", "Four-Seam" = "#FA8072",
1077
- "Sinker" = "#F2A541", "Slider" = "#9B59B6", "Sweeper" = "#9B59B6",
1078
- "Curveball" = "#2c7bb6", "ChangeUp" = "#90EE90", "Splitter" = "#90EE32",
1079
- "Cutter" = "#1ABC9C"
1080
- )
1081
-
1082
  process_tableau_pitcher_data <- function(df) {
 
1083
  if (!"Pitcher" %in% names(df)) {
1084
  alt <- intersect(c("PitcherName","pitcher","Pitcher_LastFirst","PlayerName"), names(df))
1085
  if (length(alt)) df$Pitcher <- df[[alt[1]]] else df$Pitcher <- NA_character_
1086
  }
 
1087
  df <- df %>%
1088
- mutate(Pitcher = stringr::str_replace(coalesce(Pitcher, ""), "^\\s*(\\w+)\\s*,\\s*(\\w+)\\s*$", "\\2 \\1"))
1089
 
 
1090
  df <- df %>%
1091
  mutate(
 
1092
  StrikeZoneIndicator = ifelse(PlateLocSide >= -0.8333 & PlateLocSide <= 0.8333 &
1093
- PlateLocHeight >= 1.5 & PlateLocHeight <= 3.37467, 1, 0),
 
 
1094
  EdgeHeightIndicator = ifelse((PlateLocHeight > 14/12 & PlateLocHeight < 22/12) |
1095
  (PlateLocHeight > 38/12 & PlateLocHeight < 46/12), 1, 0),
1096
  EdgeZoneHtIndicator = ifelse(PlateLocHeight > 16/12 & PlateLocHeight < 45.2/12, 1, 0),
@@ -1099,68 +1116,135 @@ process_tableau_pitcher_data <- function(df) {
1099
  (PlateLocSide < 13.3/12 & PlateLocSide > 6.7/12), 1, 0),
1100
  EdgeIndicator = ifelse((EdgeHeightIndicator == 1 & EdgeZoneWIndicator == 1) |
1101
  (EdgeWidthIndicator == 1 & EdgeZoneHtIndicator == 1), 1, 0),
 
 
1102
  QualityPitchIndicator = ifelse(StrikeZoneIndicator == 1 | EdgeIndicator == 1, 1, 0),
1103
- StrikeIndicator = ifelse(PitchCall %in% c("StrikeSwinging", "StrikeCalled", "FoulBallNotFieldable", "FoulBall", "InPlay"), 1, 0),
 
 
 
1104
  WhiffIndicator = ifelse(PitchCall == "StrikeSwinging", 1, 0),
1105
- SwingIndicator = ifelse(PitchCall %in% c("StrikeSwinging", "FoulBallNotFieldable", "FoulBall", "InPlay"), 1, 0),
 
 
 
1106
  FPindicator = ifelse(Balls == 0 & Strikes == 0, 1, 0),
1107
- FPSindicator = ifelse(PitchCall %in% c("StrikeCalled", "StrikeSwinging", "FoulBallNotFieldable", "FoulBall", "InPlay") & FPindicator == 1, 1, 0),
1108
- AheadIndicator = ifelse(((Balls == 0 & Strikes == 1) | (Balls == 1 & Strikes == 1)) & StrikeIndicator == 1, 1, 0),
1109
- ABindicator = ifelse(PlayResult %in% c("Error", "FieldersChoice", "Out", "Single", "Double", "Triple", "HomeRun") | KorBB == "Strikeout", 1, 0),
 
 
 
 
 
 
 
 
 
 
 
1110
  HitIndicator = ifelse(PlayResult %in% c("Single", "Double", "Triple", "HomeRun"), 1, 0),
1111
- PAindicator = ifelse(PitchCall %in% c("InPlay", "HitByPitch", "CatchersInterference") | KorBB %in% c("Walk", "Strikeout"), 1, 0),
1112
- LeadOffIndicator = ifelse((PAofInning == 1 & (PlayResult != "Undefined" | KorBB != "Undefined")) | PitchCall == "HitByPitch", 1, 0),
1113
- OutIndicator = ifelse((PlayResult %in% c("Out", "FieldersChoice") | KorBB == "Strikeout") & PitchCall != "HitByPitch", 1, 0),
 
 
 
 
 
 
 
1114
  LOOindicator = ifelse(LeadOffIndicator == 1 & OutIndicator == 1, 1, 0),
 
 
1115
  HBPIndicator = ifelse(PitchCall == "HitByPitch", 1, 0),
1116
  WalkIndicator = ifelse(KorBB == "Walk", 1, 0),
 
 
1117
  LHHindicator = ifelse(BatterSide == "Left", 1, 0),
1118
  RHHindicator = ifelse(BatterSide == "Right", 1, 0)
1119
  )
 
1120
  df
1121
  }
1122
 
1123
  calculate_tableau_header_stats <- function(pitcher_df) {
1124
- ab_data <- pitcher_df %>% filter(ABindicator == 1) %>%
1125
- group_by(Inning, Batter, PAofInning) %>% slice_tail(n = 1) %>% ungroup()
 
 
 
 
1126
 
1127
  at_bats <- nrow(ab_data)
1128
  hits <- sum(ab_data$HitIndicator, na.rm = TRUE)
1129
  xbh <- sum(ab_data$PlayResult %in% c("Double", "Triple", "HomeRun"), na.rm = TRUE)
1130
  runs <- sum(pitcher_df$RunsScored, na.rm = TRUE)
1131
 
1132
- pa_data <- pitcher_df %>% filter(PAindicator == 1) %>%
1133
- group_by(Inning, Batter, PAofInning) %>% slice_tail(n = 1) %>% ungroup()
 
 
 
 
1134
 
1135
  bb <- sum(pa_data$WalkIndicator, na.rm = TRUE)
1136
  hbp <- sum(pa_data$HBPIndicator, na.rm = TRUE)
1137
  so <- sum(pa_data$KorBB == "Strikeout", na.rm = TRUE)
 
 
1138
  avg <- ifelse(at_bats > 0, round(hits / at_bats, 3), 0)
1139
 
 
1140
  total_pitches <- nrow(pitcher_df)
1141
- strike_pct <- ifelse(total_pitches > 0, round(100 * sum(pitcher_df$StrikeIndicator, na.rm = TRUE) / total_pitches, 0), 0)
 
 
1142
  fp_pitches <- sum(pitcher_df$FPindicator, na.rm = TRUE)
1143
- fp_k_pct <- ifelse(fp_pitches > 0, round(100 * sum(pitcher_df$FPSindicator, na.rm = TRUE) / fp_pitches, 0), 0)
1144
- ea_pct <- ifelse(fp_pitches > 0, round(100 * (sum(pitcher_df$FPSindicator, na.rm = TRUE) + sum(pitcher_df$AheadIndicator & pitcher_df$Balls == 0, na.rm = TRUE)) / fp_pitches, 0), 0)
1145
- comp_pct <- ifelse(total_pitches > 0, round(100 * sum(pitcher_df$QualityPitchIndicator, na.rm = TRUE) / total_pitches, 0), 0)
 
 
 
 
 
 
 
 
 
 
1146
  leadoff_opps <- sum(pitcher_df$LeadOffIndicator, na.rm = TRUE)
1147
- loo_pct <- ifelse(leadoff_opps > 0, round(100 * sum(pitcher_df$LOOindicator, na.rm = TRUE) / leadoff_opps, 0), 0)
 
1148
 
1149
- list(at_bats = at_bats, hits = hits, xbh = xbh, runs = runs, bb_hbp = bb + hbp, so = so,
1150
- avg = sprintf("%.3f", avg), strike_pct = paste0(strike_pct, "%"), fp_k_pct = paste0(fp_k_pct, "%"),
1151
- ea_pct = paste0(ea_pct, "%"), comp_pct = paste0(comp_pct, "%"), loo_pct = paste0(loo_pct, "%"))
 
 
 
 
 
 
 
 
 
 
 
1152
  }
1153
 
1154
  calculate_tableau_location_data <- function(pitcher_df) {
1155
  pitcher_df %>%
1156
- filter(!is.na(TaggedPitchType), TaggedPitchType != "Other") %>%
1157
  group_by(TaggedPitchType) %>%
1158
  summarise(
1159
- `Zone%` = round(100 * sum(StrikeZoneIndicator, na.rm = TRUE) / n(), 0),
1160
- `Edge%` = round(100 * sum(EdgeIndicator, na.rm = TRUE) / n(), 0),
1161
- `Strike%` = round(100 * sum(StrikeIndicator, na.rm = TRUE) / n(), 0),
1162
  `Whiff%` = ifelse(sum(SwingIndicator, na.rm = TRUE) > 0,
1163
- round(100 * sum(WhiffIndicator, na.rm = TRUE) / sum(SwingIndicator, na.rm = TRUE), 0), 0),
 
1164
  .groups = "drop"
1165
  )
1166
  }
@@ -1170,7 +1254,7 @@ calculate_tableau_pitch_usage <- function(pitcher_df) {
1170
  rhh_pitches <- sum(pitcher_df$RHHindicator, na.rm = TRUE)
1171
 
1172
  pitcher_df %>%
1173
- filter(!is.na(TaggedPitchType), TaggedPitchType != "Other") %>%
1174
  group_by(TaggedPitchType) %>%
1175
  summarise(
1176
  `Pitch Count` = n(),
@@ -1182,7 +1266,7 @@ calculate_tableau_pitch_usage <- function(pitcher_df) {
1182
 
1183
  calculate_tableau_velo_movement <- function(pitcher_df) {
1184
  pitcher_df %>%
1185
- filter(!is.na(TaggedPitchType), TaggedPitchType != "Other") %>%
1186
  group_by(TaggedPitchType) %>%
1187
  summarise(
1188
  `Avg. Velo` = round(mean(RelSpeed, na.rm = TRUE), 1),
@@ -1196,280 +1280,489 @@ calculate_tableau_velo_movement <- function(pitcher_df) {
1196
  }
1197
 
1198
  calculate_tableau_release_data <- function(pitcher_df) {
1199
- primary <- pitcher_df %>% filter(TaggedPitchType %in% c("Fastball", "Sinker", "Four-Seam", "FourSeamFastBall"))
 
 
1200
  fb_ht <- if (nrow(primary) > 0) mean(primary$RelHeight, na.rm = TRUE) else NA
1201
  fb_side <- if (nrow(primary) > 0) mean(primary$RelSide, na.rm = TRUE) else NA
1202
 
1203
  pitcher_df %>%
1204
- filter(!is.na(TaggedPitchType), TaggedPitchType != "Other") %>%
1205
  group_by(TaggedPitchType) %>%
1206
  summarise(
1207
  `Avg. Rel Height (ft.)` = round(mean(RelHeight, na.rm = TRUE), 2),
1208
- `Rel Ht vs. FB (in.)` = round((mean(RelHeight, na.rm = TRUE) - fb_ht) * 12, 0),
1209
  `Avg. Rel Side (ft.)` = round(mean(RelSide, na.rm = TRUE), 2),
1210
- `Rel Side vs. FB (in.)` = round((mean(RelSide, na.rm = TRUE) - fb_side) * 12, 0),
1211
  `Avg. Extension (ft.)` = round(mean(Extension, na.rm = TRUE), 2),
1212
  .groups = "drop"
1213
  )
1214
  }
1215
 
 
 
 
1216
  create_tableau_location_plot <- function(pitcher_df, pitch_colors) {
1217
  df <- pitcher_df %>%
1218
- filter(!is.na(TaggedPitchType), TaggedPitchType != "Other",
1219
  !is.na(PlateLocSide), !is.na(PlateLocHeight)) %>%
1220
- mutate(ResultDisplay = case_when(
1221
- PitchCall %in% c("BallCalled", "BallinDirt") ~ "BallCalled",
1222
- PlayResult == "Double" ~ "Double",
1223
- PitchCall %in% c("FoulBall", "FoulBallNotFieldable") ~ "FoulBall",
1224
- PitchCall == "HitByPitch" ~ "HitByPitch",
1225
- PlayResult %in% c("Sacrifice", "SacrificeFly") ~ "Sacrifice",
1226
- PlayResult == "Single" ~ "Single",
1227
- PitchCall == "StrikeCalled" ~ "StrikeCalled",
1228
- PitchCall == "StrikeSwinging" ~ "StrikeSwinging",
1229
- PlayResult == "Triple" ~ "Triple",
1230
- TRUE ~ "Other"
1231
- ))
1232
-
1233
- if (nrow(df) == 0) return(ggplot() + theme_void() + ggtitle("Location Report"))
1234
-
1235
- ggplot(df, aes(x = PlateLocSide, y = PlateLocHeight)) +
1236
- annotate("rect", xmin = -0.8333, xmax = 0.8333, ymin = 1.5, ymax = 3.37467, fill = NA, color = "black", linewidth = 0.8) +
1237
- annotate("segment", x = -0.708, y = 0.15, xend = 0.708, yend = 0.15, color = "black") +
1238
- annotate("segment", x = -0.708, y = 0.30, xend = -0.708, yend = 0.15, color = "black") +
1239
- annotate("segment", x = 0.708, y = 0.30, xend = 0.708, yend = 0.15, color = "black") +
1240
- annotate("segment", x = -0.708, y = 0.30, xend = 0, yend = 0.50, color = "black") +
1241
- annotate("segment", x = 0.708, y = 0.30, xend = 0, yend = 0.50, color = "black") +
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1242
  geom_point(aes(color = TaggedPitchType, shape = ResultDisplay), size = 3.5, stroke = 1.2) +
 
1243
  scale_color_manual(values = pitch_colors, name = "Pitch Type") +
1244
- scale_shape_manual(values = c("BallCalled" = 1, "Double" = 18, "FoulBall" = 2, "HitByPitch" = 8,
1245
- "Sacrifice" = 3, "Single" = 19, "StrikeCalled" = 4,
1246
- "StrikeSwinging" = 8, "Triple" = 17, "Other" = 16),
1247
- name = "Play Result") +
1248
- coord_fixed(xlim = c(-2, 2), ylim = c(0, 4.5)) +
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1249
  labs(title = "Location Report") +
1250
  theme_minimal() +
1251
- theme(plot.title = element_text(size = 14, face = "bold", hjust = 0.5),
1252
- legend.position = "left", axis.text = element_blank(),
1253
- axis.ticks = element_blank(), panel.grid = element_blank())
 
 
 
 
 
 
 
 
 
1254
  }
1255
 
1256
  create_tableau_movement_plot <- function(pitcher_df, pitch_colors) {
1257
  df <- pitcher_df %>%
1258
- filter(!is.na(TaggedPitchType), TaggedPitchType != "Other",
1259
  !is.na(HorzBreak), !is.na(InducedVertBreak))
1260
 
1261
- if (nrow(df) == 0) return(ggplot() + theme_void() + ggtitle("Movement Profile"))
 
 
 
 
1262
 
1263
  ggplot(df, aes(x = HorzBreak, y = InducedVertBreak, color = TaggedPitchType)) +
1264
- geom_vline(xintercept = 0, color = "black") +
1265
- geom_hline(yintercept = 0, color = "black") +
1266
  geom_point(size = 3, alpha = 0.8) +
1267
  scale_color_manual(values = pitch_colors) +
1268
- coord_cartesian(xlim = c(-30, 30), ylim = c(-30, 30)) +
1269
  labs(title = "Movement Profile", x = "Horz Break", y = "Induced Vert Break") +
1270
  theme_minimal() +
1271
- theme(plot.title = element_text(size = 14, face = "bold", hjust = 0.5), legend.position = "none")
 
 
 
 
 
1272
  }
1273
 
1274
  create_tableau_release_plot <- function(pitcher_df, pitch_colors) {
1275
  df <- pitcher_df %>%
1276
- filter(!is.na(TaggedPitchType), TaggedPitchType != "Other",
1277
  !is.na(RelSide), !is.na(RelHeight))
1278
 
1279
- if (nrow(df) == 0) return(ggplot() + theme_void() + ggtitle("Release Plot"))
 
 
 
 
1280
 
1281
- ggplot(df, aes(x = RelSide, y = RelHeight, color = TaggedPitchType)) +
1282
- geom_point(size = 3, alpha = 0.8) +
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1283
  scale_color_manual(values = pitch_colors) +
1284
- coord_cartesian(xlim = c(-4, 4), ylim = c(0, 6)) +
1285
  labs(title = "Release Plot", x = "Rel Side", y = "Rel Height") +
1286
  theme_minimal() +
1287
- theme(plot.title = element_text(size = 14, face = "bold", hjust = 0.5), legend.position = "none")
 
 
 
 
 
1288
  }
1289
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1290
  create_tableau_pitcher_pdf <- function(game_df, pitcher_name, output_file) {
 
1291
  if (length(dev.list()) > 0) try(dev.off(), silent = TRUE)
1292
 
1293
- pitcher_df <- process_tableau_pitcher_data(game_df) %>% filter(Pitcher == pitcher_name)
 
 
1294
 
1295
  if (nrow(pitcher_df) == 0) {
1296
- pdf(output_file, width = 11, height = 8.5)
1297
- grid::grid.newpage()
1298
- grid::grid.text(paste("No data for", pitcher_name), gp = grid::gpar(fontsize = 16, fontface = "bold"))
 
1299
  dev.off()
1300
  return(output_file)
1301
  }
1302
 
 
1303
  game_date <- tryCatch({
1304
  d <- unique(pitcher_df$Date)[1]
1305
  if (inherits(d, "Date")) format(d, "%m/%d/%Y") else as.character(d)
1306
- }, error = function(e) "Unknown")
1307
 
1308
  pitcher_team <- unique(pitcher_df$PitcherTeam)[1]
1309
- if (is.na(pitcher_team)) pitcher_team <- "Multiple values"
1310
- batter_team_str <- if (length(unique(pitcher_df$BatterTeam)) > 1) "All" else unique(pitcher_df$BatterTeam)[1]
1311
 
 
 
 
 
 
1312
  stats <- calculate_tableau_header_stats(pitcher_df)
1313
  loc_data <- calculate_tableau_location_data(pitcher_df)
1314
  usage_data <- calculate_tableau_pitch_usage(pitcher_df)
1315
  velo_data <- calculate_tableau_velo_movement(pitcher_df)
1316
  rel_data <- calculate_tableau_release_data(pitcher_df)
1317
 
 
 
 
 
 
1318
  loc_plot <- create_tableau_location_plot(pitcher_df, tableau_pitch_colors)
1319
  mov_plot <- create_tableau_movement_plot(pitcher_df, tableau_pitch_colors)
1320
  rel_plot <- create_tableau_release_plot(pitcher_df, tableau_pitch_colors)
1321
 
1322
- pitch_types <- loc_data$TaggedPitchType
1323
-
1324
- pdf(output_file, width = 11, height = 8.5)
1325
  on.exit(try(dev.off(), silent = TRUE), add = TRUE)
1326
- grid::grid.newpage()
1327
 
1328
- # Header
1329
- grid::grid.rect(x = 0, y = 0.92, width = 1, height = 0.08, just = c("left", "bottom"),
1330
- gp = grid::gpar(fill = "#006F71", col = NA))
1331
- grid::grid.text("Pitcher Post-Game Report", x = 0.02, y = 0.96, just = "left",
1332
- gp = grid::gpar(col = "white", fontface = "bold", cex = 1.5))
 
 
 
 
 
 
1333
 
1334
- # Info row
 
 
 
1335
  info_labels <- c("Date", "Pitcher Team", "Pitcher", "Batter Team", "vs. L/R")
1336
  info_values <- c(game_date, pitcher_team, pitcher_name, batter_team_str, "All")
 
1337
  for (i in 1:5) {
1338
- grid::grid.text(info_labels[i], x = 0.02 + (i-1)*0.18, y = 0.90, just = "left", gp = grid::gpar(cex = 0.7, col = "gray50"))
1339
- grid::grid.text(info_values[i], x = 0.02 + (i-1)*0.18, y = 0.87, just = "left", gp = grid::gpar(cex = 0.8, fontface = "bold"))
 
 
 
1340
  }
1341
 
1342
- # Stat boxes
1343
- stat_labels <- c("At Bats", "H", "XBH", "R", "BB/HBP", "SO", "AVG")
1344
- stat_values <- c(stats$at_bats, stats$hits, stats$xbh, stats$runs, stats$bb_hbp, stats$so, stats$avg)
1345
- stat_colors <- c("#006F71", "#006F71", "#006F71", "#E74C3C", "#006F71", "#006F71", "#006F71")
 
 
 
 
 
 
 
1346
 
1347
  for (i in 1:7) {
1348
- x <- 0.02 + (i-1)*0.07
1349
- grid::grid.rect(x = x, y = 0.82, width = 0.065, height = 0.055, just = c("left", "center"),
1350
- gp = grid::gpar(fill = stat_colors[i], col = "black"))
1351
- grid::grid.text(stat_labels[i], x = x + 0.0325, y = 0.835, gp = grid::gpar(col = "white", cex = 0.55, fontface = "bold"))
1352
- grid::grid.text(stat_values[i], x = x + 0.0325, y = 0.805, gp = grid::gpar(col = "white", cex = 0.8, fontface = "bold"))
 
 
 
1353
  }
1354
 
1355
- # Percentage boxes
1356
- pct_labels <- c("Strike%", "1st P K%", "E+A%", "Comp%", "LOO%")
1357
- pct_values <- c(stats$strike_pct, stats$fp_k_pct, stats$ea_pct, stats$comp_pct, stats$loo_pct)
 
 
 
1358
  for (i in 1:5) {
1359
- x <- 0.55 + (i-1)*0.075
1360
- grid::grid.rect(x = x, y = 0.82, width = 0.07, height = 0.055, just = c("left", "center"),
1361
- gp = grid::gpar(fill = "#006F71", col = "black"))
1362
- grid::grid.text(pct_labels[i], x = x + 0.035, y = 0.835, gp = grid::gpar(col = "white", cex = 0.5, fontface = "bold"))
1363
- grid::grid.text(pct_values[i], x = x + 0.035, y = 0.805, gp = grid::gpar(col = "white", cex = 0.75, fontface = "bold"))
 
 
 
1364
  }
1365
 
1366
  # PDF button
1367
- grid::grid.rect(x = 0.94, y = 0.82, width = 0.05, height = 0.055, just = c("left", "center"),
1368
- gp = grid::gpar(fill = "#E74C3C", col = "black"))
1369
- grid::grid.text("PDF", x = 0.965, y = 0.82, gp = grid::gpar(col = "white", cex = 0.7, fontface = "bold"))
1370
-
1371
- # Location Plot
1372
- grid::pushViewport(grid::viewport(x = 0.22, y = 0.52, width = 0.40, height = 0.28))
 
 
 
 
1373
  print(loc_plot, newpage = FALSE)
1374
- grid::popViewport()
1375
 
1376
- # Location Data Table
1377
- grid::grid.text("Location Data", x = 0.75, y = 0.74, gp = grid::gpar(fontface = "bold", cex = 1.0))
1378
- col_w <- 0.07; row_h <- 0.025
1379
- for (i in seq_along(pitch_types)) {
1380
- grid::grid.rect(x = 0.58 + i*col_w, y = 0.70, width = col_w*0.95, height = row_h,
1381
- just = c("left", "center"), gp = grid::gpar(fill = tableau_pitch_colors[pitch_types[i]], col = "black"))
1382
- grid::grid.text(pitch_types[i], x = 0.58 + i*col_w + col_w/2, y = 0.70,
1383
- gp = grid::gpar(col = "white", cex = 0.5, fontface = "bold"))
1384
- }
1385
- metrics <- c("Zone%", "Edge%", "Strike%", "Whiff%")
1386
- for (m in seq_along(metrics)) {
1387
- y <- 0.70 - m*row_h
1388
- grid::grid.text(metrics[m], x = 0.58, y = y, just = "left", gp = grid::gpar(cex = 0.55, fontface = "bold"))
1389
- for (i in seq_along(pitch_types)) {
1390
- val <- loc_data[[metrics[m]]][i]
1391
- grid::grid.rect(x = 0.58 + i*col_w, y = y, width = col_w*0.95, height = row_h*0.9,
1392
- just = c("left", "center"), gp = grid::gpar(fill = "white", col = "gray80"))
1393
- grid::grid.text(paste0(val, "%"), x = 0.58 + i*col_w + col_w/2, y = y, gp = grid::gpar(cex = 0.5))
1394
- }
1395
- }
1396
 
1397
- # Pitch Usage Table
1398
- grid::grid.text("Pitch Usage", x = 0.75, y = 0.55, gp = grid::gpar(fontface = "bold", cex = 1.0))
1399
- for (i in seq_along(pitch_types)) {
1400
- grid::grid.rect(x = 0.58 + i*col_w, y = 0.52, width = col_w*0.95, height = row_h,
1401
- just = c("left", "center"), gp = grid::gpar(fill = tableau_pitch_colors[pitch_types[i]], col = "black"))
1402
- grid::grid.text(pitch_types[i], x = 0.58 + i*col_w + col_w/2, y = 0.52, gp = grid::gpar(col = "white", cex = 0.5, fontface = "bold"))
1403
- }
1404
- usage_metrics <- c("Usage vs. LHH", "Usage vs. RHH", "Pitch Count")
1405
- for (m in seq_along(usage_metrics)) {
1406
- y <- 0.52 - m*row_h
1407
- grid::grid.text(usage_metrics[m], x = 0.58, y = y, just = "left", gp = grid::gpar(cex = 0.55, fontface = "bold"))
1408
- for (i in seq_along(pitch_types)) {
1409
- idx <- which(usage_data$TaggedPitchType == pitch_types[i])
1410
- val <- if (length(idx) > 0) usage_data[[usage_metrics[m]]][idx] else "-"
1411
- grid::grid.rect(x = 0.58 + i*col_w, y = y, width = col_w*0.95, height = row_h*0.9,
1412
- just = c("left", "center"), gp = grid::gpar(fill = "white", col = "gray80"))
1413
- grid::grid.text(as.character(val), x = 0.58 + i*col_w + col_w/2, y = y, gp = grid::gpar(cex = 0.5))
1414
- }
1415
- }
1416
 
1417
- # Divider
1418
- grid::grid.lines(x = c(0, 1), y = c(0.38, 0.38), gp = grid::gpar(col = "gray80", lty = "dotted"))
 
 
 
1419
 
1420
- # Movement Plot
1421
- grid::pushViewport(grid::viewport(x = 0.22, y = 0.22, width = 0.38, height = 0.16))
 
 
1422
  print(mov_plot, newpage = FALSE)
1423
- grid::popViewport()
1424
 
1425
- # Velo & Movement Table
1426
- grid::grid.text("Velo & Movement", x = 0.75, y = 0.36, gp = grid::gpar(fontface = "bold", cex = 1.0))
1427
- for (i in seq_along(pitch_types)) {
1428
- grid::grid.rect(x = 0.58 + i*col_w, y = 0.33, width = col_w*0.95, height = row_h,
1429
- just = c("left", "center"), gp = grid::gpar(fill = tableau_pitch_colors[pitch_types[i]], col = "black"))
1430
- grid::grid.text(pitch_types[i], x = 0.58 + i*col_w + col_w/2, y = 0.33, gp = grid::gpar(col = "white", cex = 0.5, fontface = "bold"))
1431
- }
1432
- velo_metrics <- c("Avg. Velo", "Max. Velo", "Avg. Spin Rate", "Max. Spin Rate", "Avg. Vert Break", "Avg. Horz Break")
1433
- for (m in seq_along(velo_metrics)) {
1434
- y <- 0.33 - m*row_h
1435
- grid::grid.text(velo_metrics[m], x = 0.58, y = y, just = "left", gp = grid::gpar(cex = 0.5, fontface = "bold"))
1436
- for (i in seq_along(pitch_types)) {
1437
- idx <- which(velo_data$TaggedPitchType == pitch_types[i])
1438
- val <- if (length(idx) > 0) velo_data[[velo_metrics[m]]][idx] else "-"
1439
- grid::grid.rect(x = 0.58 + i*col_w, y = y, width = col_w*0.95, height = row_h*0.9,
1440
- just = c("left", "center"), gp = grid::gpar(fill = "white", col = "gray80"))
1441
- grid::grid.text(as.character(val), x = 0.58 + i*col_w + col_w/2, y = y, gp = grid::gpar(cex = 0.45))
1442
- }
1443
- }
1444
 
1445
- # Divider
1446
- grid::grid.lines(x = c(0, 1), y = c(0.14, 0.14), gp = grid::gpar(col = "gray80", lty = "dotted"))
 
 
 
1447
 
1448
- # Release Plot
1449
- grid::pushViewport(grid::viewport(x = 0.22, y = 0.07, width = 0.38, height = 0.12))
 
 
1450
  print(rel_plot, newpage = FALSE)
1451
- grid::popViewport()
1452
 
1453
- # Release Data Table
1454
- grid::grid.text("Release Data", x = 0.75, y = 0.13, gp = grid::gpar(fontface = "bold", cex = 1.0))
1455
- row_h_rel <- 0.018
1456
- for (i in seq_along(pitch_types)) {
1457
- grid::grid.rect(x = 0.58 + i*col_w, y = 0.10, width = col_w*0.95, height = row_h_rel,
1458
- just = c("left", "center"), gp = grid::gpar(fill = tableau_pitch_colors[pitch_types[i]], col = "black"))
1459
- grid::grid.text(pitch_types[i], x = 0.58 + i*col_w + col_w/2, y = 0.10, gp = grid::gpar(col = "white", cex = 0.45, fontface = "bold"))
1460
- }
1461
- rel_metrics <- c("Avg. Rel Height (ft.)", "Rel Ht vs. FB (in.)", "Avg. Rel Side (ft.)", "Rel Side vs. FB (in.)", "Avg. Extension (ft.)")
1462
- for (m in seq_along(rel_metrics)) {
1463
- y <- 0.10 - m*row_h_rel
1464
- grid::grid.text(rel_metrics[m], x = 0.58, y = y, just = "left", gp = grid::gpar(cex = 0.45, fontface = "bold"))
1465
- for (i in seq_along(pitch_types)) {
1466
- idx <- which(rel_data$TaggedPitchType == pitch_types[i])
1467
- val <- if (length(idx) > 0) rel_data[[rel_metrics[m]]][idx] else "-"
1468
- grid::grid.rect(x = 0.58 + i*col_w, y = y, width = col_w*0.95, height = row_h_rel*0.9,
1469
- just = c("left", "center"), gp = grid::gpar(fill = "white", col = "gray80"))
1470
- grid::grid.text(as.character(val), x = 0.58 + i*col_w + col_w/2, y = y, gp = grid::gpar(cex = 0.42))
1471
- }
1472
- }
1473
 
1474
  invisible(output_file)
1475
  }
@@ -3933,7 +4226,7 @@ radioButtons("report_type", "Report Type",
3933
  c("Hitter"="hitter",
3934
  "Pitcher"="pitcher",
3935
  "Advanced Pitcher"="advanced_pitcher",
3936
- "Tableau Pitcher"="tableau_pitcher",
3937
  "Catcher"="catcher",
3938
  "Umpire"="umpire",
3939
  "BP Report"="bp"),
 
163
  # ====================== HITTER CODE (VERBATIM) ======================
164
  # =====================================================================
165
 
166
+ tableau_pitch_colors <- c(
167
+ # Primary pitches from screenshots
168
+ "Sinker" = "#5DADE2", # Teal/Cyan
169
+ "Slider" = "#E67E22", # Orange
170
+ "Cutter" = "#F4D03F", # Yellow/Gold
171
+ "Sweeper" = "#E67E22", # Same as Slider
172
+
173
+ # Other pitch types (standard colors)
174
+ "Fastball" = "#E74C3C", # Red
175
+ "Four-Seam" = "#E74C3C",
176
+ "FourSeamFastBall" = "#E74C3C",
177
+ "Curveball" = "#9B59B6", # Purple
178
+ "ChangeUp" = "#2ECC71", # Green
179
+ "Splitter" = "#1ABC9C", # Teal-green
180
+ "Knuckle Curve" = "#8E44AD",
181
+ "Other" = "#95A5A6" # Gray
182
+ )
183
+
184
  process_dataset <- function(df) {
185
  if ("Batter" %in% names(df)) {
186
  df <- df %>% mutate(Batter = stringr::str_replace(Batter, "^\\s*(\\w+)\\s*,\\s*(\\w+)\\s*$", "\\2 \\1"))
 
1090
  # TABLEAU-STYLE PITCHER REPORT FUNCTIONS
1091
  # =====================================================================
1092
 
 
 
 
 
 
 
 
1093
  process_tableau_pitcher_data <- function(df) {
1094
+ # Standardize Pitcher name
1095
  if (!"Pitcher" %in% names(df)) {
1096
  alt <- intersect(c("PitcherName","pitcher","Pitcher_LastFirst","PlayerName"), names(df))
1097
  if (length(alt)) df$Pitcher <- df[[alt[1]]] else df$Pitcher <- NA_character_
1098
  }
1099
+
1100
  df <- df %>%
1101
+ mutate(Pitcher = str_replace(coalesce(Pitcher, ""), "^\\s*(\\w+)\\s*,\\s*(\\w+)\\s*$", "\\2 \\1"))
1102
 
1103
+ # Calculate all indicators
1104
  df <- df %>%
1105
  mutate(
1106
+ # Strike Zone Indicator (standard zone)
1107
  StrikeZoneIndicator = ifelse(PlateLocSide >= -0.8333 & PlateLocSide <= 0.8333 &
1108
+ PlateLocHeight >= 1.5 & PlateLocHeight <= 3.5, 1, 0),
1109
+
1110
+ # Edge calculations
1111
  EdgeHeightIndicator = ifelse((PlateLocHeight > 14/12 & PlateLocHeight < 22/12) |
1112
  (PlateLocHeight > 38/12 & PlateLocHeight < 46/12), 1, 0),
1113
  EdgeZoneHtIndicator = ifelse(PlateLocHeight > 16/12 & PlateLocHeight < 45.2/12, 1, 0),
 
1116
  (PlateLocSide < 13.3/12 & PlateLocSide > 6.7/12), 1, 0),
1117
  EdgeIndicator = ifelse((EdgeHeightIndicator == 1 & EdgeZoneWIndicator == 1) |
1118
  (EdgeWidthIndicator == 1 & EdgeZoneHtIndicator == 1), 1, 0),
1119
+
1120
+ # Quality pitch (zone or edge)
1121
  QualityPitchIndicator = ifelse(StrikeZoneIndicator == 1 | EdgeIndicator == 1, 1, 0),
1122
+
1123
+ # Strike/Ball indicators
1124
+ StrikeIndicator = ifelse(PitchCall %in% c("StrikeSwinging", "StrikeCalled",
1125
+ "FoulBallNotFieldable", "FoulBall", "InPlay"), 1, 0),
1126
  WhiffIndicator = ifelse(PitchCall == "StrikeSwinging", 1, 0),
1127
+ SwingIndicator = ifelse(PitchCall %in% c("StrikeSwinging", "FoulBallNotFieldable",
1128
+ "FoulBall", "InPlay"), 1, 0),
1129
+
1130
+ # First pitch indicators
1131
  FPindicator = ifelse(Balls == 0 & Strikes == 0, 1, 0),
1132
+ FPSindicator = ifelse(PitchCall %in% c("StrikeCalled", "StrikeSwinging",
1133
+ "FoulBallNotFieldable", "FoulBall", "InPlay") &
1134
+ FPindicator == 1, 1, 0),
1135
+
1136
+ # Ahead indicator (0-1 or 1-1 count, got strike)
1137
+ AheadIndicator = ifelse(((Balls == 0 & Strikes == 1) | (Balls == 1 & Strikes == 1)) &
1138
+ StrikeIndicator == 1, 1, 0),
1139
+
1140
+ # At-bat indicator
1141
+ ABindicator = ifelse(PlayResult %in% c("Error", "FieldersChoice", "Out",
1142
+ "Single", "Double", "Triple", "HomeRun") |
1143
+ KorBB == "Strikeout", 1, 0),
1144
+
1145
+ # Hit indicator
1146
  HitIndicator = ifelse(PlayResult %in% c("Single", "Double", "Triple", "HomeRun"), 1, 0),
1147
+
1148
+ # Plate appearance indicator
1149
+ PAindicator = ifelse(PitchCall %in% c("InPlay", "HitByPitch", "CatchersInterference") |
1150
+ KorBB %in% c("Walk", "Strikeout"), 1, 0),
1151
+
1152
+ # Leadoff indicators
1153
+ LeadOffIndicator = ifelse((PAofInning == 1 & (PlayResult != "Undefined" | KorBB != "Undefined")) |
1154
+ PitchCall == "HitByPitch", 1, 0),
1155
+ OutIndicator = ifelse((PlayResult %in% c("Out", "FieldersChoice") | KorBB == "Strikeout") &
1156
+ PitchCall != "HitByPitch", 1, 0),
1157
  LOOindicator = ifelse(LeadOffIndicator == 1 & OutIndicator == 1, 1, 0),
1158
+
1159
+ # Walk/HBP indicators
1160
  HBPIndicator = ifelse(PitchCall == "HitByPitch", 1, 0),
1161
  WalkIndicator = ifelse(KorBB == "Walk", 1, 0),
1162
+
1163
+ # Batter side indicators
1164
  LHHindicator = ifelse(BatterSide == "Left", 1, 0),
1165
  RHHindicator = ifelse(BatterSide == "Right", 1, 0)
1166
  )
1167
+
1168
  df
1169
  }
1170
 
1171
  calculate_tableau_header_stats <- function(pitcher_df) {
1172
+ # At-bats (unique AB-ending events)
1173
+ ab_data <- pitcher_df %>%
1174
+ filter(ABindicator == 1) %>%
1175
+ group_by(Inning, Batter, PAofInning) %>%
1176
+ slice_tail(n = 1) %>%
1177
+ ungroup()
1178
 
1179
  at_bats <- nrow(ab_data)
1180
  hits <- sum(ab_data$HitIndicator, na.rm = TRUE)
1181
  xbh <- sum(ab_data$PlayResult %in% c("Double", "Triple", "HomeRun"), na.rm = TRUE)
1182
  runs <- sum(pitcher_df$RunsScored, na.rm = TRUE)
1183
 
1184
+ # Plate appearances
1185
+ pa_data <- pitcher_df %>%
1186
+ filter(PAindicator == 1) %>%
1187
+ group_by(Inning, Batter, PAofInning) %>%
1188
+ slice_tail(n = 1) %>%
1189
+ ungroup()
1190
 
1191
  bb <- sum(pa_data$WalkIndicator, na.rm = TRUE)
1192
  hbp <- sum(pa_data$HBPIndicator, na.rm = TRUE)
1193
  so <- sum(pa_data$KorBB == "Strikeout", na.rm = TRUE)
1194
+
1195
+ # Batting average
1196
  avg <- ifelse(at_bats > 0, round(hits / at_bats, 3), 0)
1197
 
1198
+ # Percentage stats
1199
  total_pitches <- nrow(pitcher_df)
1200
+ strike_pct <- ifelse(total_pitches > 0,
1201
+ round(100 * sum(pitcher_df$StrikeIndicator, na.rm = TRUE) / total_pitches, 0), 0)
1202
+
1203
  fp_pitches <- sum(pitcher_df$FPindicator, na.rm = TRUE)
1204
+ fp_k_pct <- ifelse(fp_pitches > 0,
1205
+ round(100 * sum(pitcher_df$FPSindicator, na.rm = TRUE) / fp_pitches, 0), 0)
1206
+
1207
+ # E+A% = (FPS + Ahead pitches) / First pitches
1208
+ ea_pct <- ifelse(fp_pitches > 0,
1209
+ round(100 * (sum(pitcher_df$FPSindicator, na.rm = TRUE) +
1210
+ sum(pitcher_df$AheadIndicator & pitcher_df$Balls == 0, na.rm = TRUE)) / fp_pitches, 0), 0)
1211
+
1212
+ # Comp% = Quality pitches / Total pitches
1213
+ comp_pct <- ifelse(total_pitches > 0,
1214
+ round(100 * sum(pitcher_df$QualityPitchIndicator, na.rm = TRUE) / total_pitches, 0), 0)
1215
+
1216
+ # LOO% = Leadoff outs / Leadoff opportunities
1217
  leadoff_opps <- sum(pitcher_df$LeadOffIndicator, na.rm = TRUE)
1218
+ loo_pct <- ifelse(leadoff_opps > 0,
1219
+ round(100 * sum(pitcher_df$LOOindicator, na.rm = TRUE) / leadoff_opps, 0), 0)
1220
 
1221
+ list(
1222
+ at_bats = at_bats,
1223
+ hits = hits,
1224
+ xbh = xbh,
1225
+ runs = runs,
1226
+ bb_hbp = bb + hbp,
1227
+ so = so,
1228
+ avg = sprintf("%.3f", avg),
1229
+ strike_pct = paste0(strike_pct, "%"),
1230
+ fp_k_pct = paste0(fp_k_pct, "%"),
1231
+ ea_pct = paste0(ea_pct, "%"),
1232
+ comp_pct = paste0(comp_pct, "%"),
1233
+ loo_pct = paste0(loo_pct, "%")
1234
+ )
1235
  }
1236
 
1237
  calculate_tableau_location_data <- function(pitcher_df) {
1238
  pitcher_df %>%
1239
+ filter(!is.na(TaggedPitchType), TaggedPitchType != "", TaggedPitchType != "Other") %>%
1240
  group_by(TaggedPitchType) %>%
1241
  summarise(
1242
+ `Zone%` = paste0(round(100 * sum(StrikeZoneIndicator, na.rm = TRUE) / n(), 0), "%"),
1243
+ `Edge%` = paste0(round(100 * sum(EdgeIndicator, na.rm = TRUE) / n(), 0), "%"),
1244
+ `Strike%` = paste0(round(100 * sum(StrikeIndicator, na.rm = TRUE) / n(), 0), "%"),
1245
  `Whiff%` = ifelse(sum(SwingIndicator, na.rm = TRUE) > 0,
1246
+ paste0(round(100 * sum(WhiffIndicator, na.rm = TRUE) / sum(SwingIndicator, na.rm = TRUE), 0), "%"),
1247
+ "0%"),
1248
  .groups = "drop"
1249
  )
1250
  }
 
1254
  rhh_pitches <- sum(pitcher_df$RHHindicator, na.rm = TRUE)
1255
 
1256
  pitcher_df %>%
1257
+ filter(!is.na(TaggedPitchType), TaggedPitchType != "", TaggedPitchType != "Other") %>%
1258
  group_by(TaggedPitchType) %>%
1259
  summarise(
1260
  `Pitch Count` = n(),
 
1266
 
1267
  calculate_tableau_velo_movement <- function(pitcher_df) {
1268
  pitcher_df %>%
1269
+ filter(!is.na(TaggedPitchType), TaggedPitchType != "", TaggedPitchType != "Other") %>%
1270
  group_by(TaggedPitchType) %>%
1271
  summarise(
1272
  `Avg. Velo` = round(mean(RelSpeed, na.rm = TRUE), 1),
 
1280
  }
1281
 
1282
  calculate_tableau_release_data <- function(pitcher_df) {
1283
+ # Get primary fastball for comparison
1284
+ primary <- pitcher_df %>%
1285
+ filter(TaggedPitchType %in% c("Fastball", "Sinker", "Four-Seam", "FourSeamFastBall"))
1286
  fb_ht <- if (nrow(primary) > 0) mean(primary$RelHeight, na.rm = TRUE) else NA
1287
  fb_side <- if (nrow(primary) > 0) mean(primary$RelSide, na.rm = TRUE) else NA
1288
 
1289
  pitcher_df %>%
1290
+ filter(!is.na(TaggedPitchType), TaggedPitchType != "", TaggedPitchType != "Other") %>%
1291
  group_by(TaggedPitchType) %>%
1292
  summarise(
1293
  `Avg. Rel Height (ft.)` = round(mean(RelHeight, na.rm = TRUE), 2),
1294
+ `Rel Ht vs. FB (in.)` = ifelse(is.na(fb_ht), NA, round((mean(RelHeight, na.rm = TRUE) - fb_ht) * 12, 0)),
1295
  `Avg. Rel Side (ft.)` = round(mean(RelSide, na.rm = TRUE), 2),
1296
+ `Rel Side vs. FB (in.)` = ifelse(is.na(fb_side), NA, round((mean(RelSide, na.rm = TRUE) - fb_side) * 12, 0)),
1297
  `Avg. Extension (ft.)` = round(mean(Extension, na.rm = TRUE), 2),
1298
  .groups = "drop"
1299
  )
1300
  }
1301
 
1302
+ # =====================================================================
1303
+ # LOCATION PLOT - WITH PROPER ZONE GRAPHICS
1304
+ # =====================================================================
1305
  create_tableau_location_plot <- function(pitcher_df, pitch_colors) {
1306
  df <- pitcher_df %>%
1307
+ filter(!is.na(TaggedPitchType), TaggedPitchType != "", TaggedPitchType != "Other",
1308
  !is.na(PlateLocSide), !is.na(PlateLocHeight)) %>%
1309
+ mutate(
1310
+ ResultDisplay = case_when(
1311
+ PitchCall %in% c("BallCalled", "BallinDirt") ~ "BallCalled",
1312
+ PlayResult == "Double" ~ "Double",
1313
+ PitchCall %in% c("FoulBall", "FoulBallNotFieldable") ~ "FoulBall",
1314
+ PitchCall == "HitByPitch" ~ "HitByPitch",
1315
+ PlayResult %in% c("Sacrifice", "SacrificeFly") ~ "Sacrifice",
1316
+ PlayResult == "Single" ~ "Single",
1317
+ PitchCall == "StrikeCalled" ~ "StrikeCalled",
1318
+ PitchCall == "StrikeSwinging" ~ "StrikeSwinging",
1319
+ PlayResult == "Triple" ~ "Triple",
1320
+ PlayResult == "HomeRun" ~ "HomeRun",
1321
+ PlayResult == "Out" ~ "Out",
1322
+ TRUE ~ "Other"
1323
+ )
1324
+ )
1325
+
1326
+ if (nrow(df) == 0) {
1327
+ return(ggplot() + theme_void() +
1328
+ labs(title = "Location Report") +
1329
+ theme(plot.title = element_text(size = 14, face = "bold", hjust = 0.5)))
1330
+ }
1331
+
1332
+ # Define zone boundaries
1333
+ zone_left <- -0.8333
1334
+ zone_right <- 0.8333
1335
+ zone_bottom <- 1.5
1336
+ zone_top <- 3.5
1337
+
1338
+ # Shadow zone (outer dashed) - about 0.25 wider on each side
1339
+ shadow_left <- -1.1
1340
+ shadow_right <- 1.1
1341
+ shadow_bottom <- 1.2
1342
+ shadow_top <- 3.8
1343
+
1344
+ # Inner zone thirds
1345
+ zone_width <- (zone_right - zone_left) / 3
1346
+ zone_height <- (zone_top - zone_bottom) / 3
1347
+
1348
+ p <- ggplot(df, aes(x = PlateLocSide, y = PlateLocHeight)) +
1349
+
1350
+ # Shadow zone (outer dashed rectangle)
1351
+ annotate("rect",
1352
+ xmin = shadow_left, xmax = shadow_right,
1353
+ ymin = shadow_bottom, ymax = shadow_top,
1354
+ fill = NA, color = "gray30", linetype = "dashed", linewidth = 0.6) +
1355
+
1356
+ # Strike zone (solid RED rectangle)
1357
+ annotate("rect",
1358
+ xmin = zone_left, xmax = zone_right,
1359
+ ymin = zone_bottom, ymax = zone_top,
1360
+ fill = NA, color = "#E74C3C", linewidth = 1.2) +
1361
+
1362
+ # Inner zone divisions (dashed gray lines)
1363
+ # Vertical lines
1364
+ annotate("segment", x = zone_left + zone_width, xend = zone_left + zone_width,
1365
+ y = zone_bottom, yend = zone_top, color = "gray50", linetype = "dashed", linewidth = 0.4) +
1366
+ annotate("segment", x = zone_left + 2*zone_width, xend = zone_left + 2*zone_width,
1367
+ y = zone_bottom, yend = zone_top, color = "gray50", linetype = "dashed", linewidth = 0.4) +
1368
+ # Horizontal lines
1369
+ annotate("segment", x = zone_left, xend = zone_right,
1370
+ y = zone_bottom + zone_height, yend = zone_bottom + zone_height,
1371
+ color = "gray50", linetype = "dashed", linewidth = 0.4) +
1372
+ annotate("segment", x = zone_left, xend = zone_right,
1373
+ y = zone_bottom + 2*zone_height, yend = zone_bottom + 2*zone_height,
1374
+ color = "gray50", linetype = "dashed", linewidth = 0.4) +
1375
+
1376
+ # Home plate
1377
+ annotate("segment", x = -0.708, y = 0.15, xend = 0.708, yend = 0.15, color = "black", linewidth = 0.6) +
1378
+ annotate("segment", x = -0.708, y = 0.30, xend = -0.708, yend = 0.15, color = "black", linewidth = 0.6) +
1379
+ annotate("segment", x = 0.708, y = 0.30, xend = 0.708, yend = 0.15, color = "black", linewidth = 0.6) +
1380
+ annotate("segment", x = -0.708, y = 0.30, xend = 0, yend = 0.50, color = "black", linewidth = 0.6) +
1381
+ annotate("segment", x = 0.708, y = 0.30, xend = 0, yend = 0.50, color = "black", linewidth = 0.6) +
1382
+
1383
+ # Center line (dashed, vertical through zone)
1384
+ annotate("segment", x = 0, xend = 0, y = 0.5, yend = shadow_top + 0.3,
1385
+ color = "gray60", linetype = "dotted", linewidth = 0.4) +
1386
+
1387
+ # Pitch points
1388
  geom_point(aes(color = TaggedPitchType, shape = ResultDisplay), size = 3.5, stroke = 1.2) +
1389
+
1390
  scale_color_manual(values = pitch_colors, name = "Pitch Type") +
1391
+ scale_shape_manual(
1392
+ values = c(
1393
+ "BallCalled" = 1, # Open circle
1394
+ "Double" = 18, # Diamond
1395
+ "FoulBall" = 2, # Open triangle
1396
+ "HitByPitch" = 8, # Asterisk
1397
+ "Sacrifice" = 3, # Plus
1398
+ "Single" = 19, # Half-filled circle
1399
+ "StrikeCalled" = 4, # X
1400
+ "StrikeSwinging" = 8, # Asterisk
1401
+ "Triple" = 17, # Filled triangle
1402
+ "HomeRun" = 18, # Diamond
1403
+ "Out" = 4, # X
1404
+ "Other" = 16 # Filled circle
1405
+ ),
1406
+ name = "Play Result"
1407
+ ) +
1408
+
1409
+ coord_fixed(xlim = c(-2.2, 2.2), ylim = c(-0.2, 4.5)) +
1410
  labs(title = "Location Report") +
1411
  theme_minimal() +
1412
+ theme(
1413
+ plot.title = element_text(size = 14, face = "bold", hjust = 0.5),
1414
+ legend.position = "left",
1415
+ legend.title = element_text(size = 8, face = "bold"),
1416
+ legend.text = element_text(size = 7),
1417
+ axis.text = element_blank(),
1418
+ axis.title = element_blank(),
1419
+ axis.ticks = element_blank(),
1420
+ panel.grid = element_blank()
1421
+ )
1422
+
1423
+ p
1424
  }
1425
 
1426
  create_tableau_movement_plot <- function(pitcher_df, pitch_colors) {
1427
  df <- pitcher_df %>%
1428
+ filter(!is.na(TaggedPitchType), TaggedPitchType != "", TaggedPitchType != "Other",
1429
  !is.na(HorzBreak), !is.na(InducedVertBreak))
1430
 
1431
+ if (nrow(df) == 0) {
1432
+ return(ggplot() + theme_void() +
1433
+ labs(title = "Movement Profile") +
1434
+ theme(plot.title = element_text(size = 14, face = "bold", hjust = 0.5)))
1435
+ }
1436
 
1437
  ggplot(df, aes(x = HorzBreak, y = InducedVertBreak, color = TaggedPitchType)) +
1438
+ geom_vline(xintercept = 0, color = "black", linewidth = 0.5) +
1439
+ geom_hline(yintercept = 0, color = "black", linewidth = 0.5) +
1440
  geom_point(size = 3, alpha = 0.8) +
1441
  scale_color_manual(values = pitch_colors) +
1442
+ coord_cartesian(xlim = c(-25, 25), ylim = c(-25, 25)) +
1443
  labs(title = "Movement Profile", x = "Horz Break", y = "Induced Vert Break") +
1444
  theme_minimal() +
1445
+ theme(
1446
+ plot.title = element_text(size = 14, face = "bold", hjust = 0.5),
1447
+ legend.position = "none",
1448
+ axis.title = element_text(size = 9),
1449
+ axis.text = element_text(size = 8)
1450
+ )
1451
  }
1452
 
1453
  create_tableau_release_plot <- function(pitcher_df, pitch_colors) {
1454
  df <- pitcher_df %>%
1455
+ filter(!is.na(TaggedPitchType), TaggedPitchType != "", TaggedPitchType != "Other",
1456
  !is.na(RelSide), !is.na(RelHeight))
1457
 
1458
+ if (nrow(df) == 0) {
1459
+ return(ggplot() + theme_void() +
1460
+ labs(title = "Release Plot") +
1461
+ theme(plot.title = element_text(size = 14, face = "bold", hjust = 0.5)))
1462
+ }
1463
 
1464
+ # Create mound arc data (terracotta half-circle)
1465
+ mound_theta <- seq(0, pi, length.out = 100)
1466
+ mound_radius <- 3
1467
+ mound_df <- data.frame(
1468
+ x = mound_radius * cos(mound_theta),
1469
+ y = mound_radius * sin(mound_theta) * 0.35 # Flatten the arc
1470
+ )
1471
+
1472
+ ggplot(df, aes(x = RelSide, y = RelHeight)) +
1473
+ # Mound (terracotta half-circle)
1474
+ geom_polygon(data = mound_df, aes(x = x, y = y),
1475
+ fill = "#C0392B", color = NA, inherit.aes = FALSE) +
1476
+
1477
+ # Pitching rubber (white rectangle)
1478
+ annotate("rect", xmin = -0.5, xmax = 0.5, ymin = 0.85, ymax = 1.05,
1479
+ fill = "white", color = "gray40", linewidth = 0.3) +
1480
+
1481
+ # Center line (dashed vertical)
1482
+ geom_vline(xintercept = 0, color = "gray60", linetype = "dashed", linewidth = 0.4) +
1483
+
1484
+ # Release points
1485
+ geom_point(aes(color = TaggedPitchType), size = 3, alpha = 0.8) +
1486
+
1487
  scale_color_manual(values = pitch_colors) +
1488
+ coord_cartesian(xlim = c(-4, 4), ylim = c(0, 7)) +
1489
  labs(title = "Release Plot", x = "Rel Side", y = "Rel Height") +
1490
  theme_minimal() +
1491
+ theme(
1492
+ plot.title = element_text(size = 14, face = "bold", hjust = 0.5),
1493
+ legend.position = "none",
1494
+ axis.title = element_text(size = 9),
1495
+ axis.text = element_text(size = 8)
1496
+ )
1497
  }
1498
 
1499
+ draw_tableau_table <- function(title, data, metrics, pitch_types, pitch_colors,
1500
+ x_start, y_start, col_w = 0.12, row_h = 0.018) {
1501
+
1502
+ # Title
1503
+ grid.text(title, x = x_start + 0.2, y = y_start + 0.03,
1504
+ gp = gpar(fontface = "bold", cex = 0.9))
1505
+
1506
+ # Column headers (pitch types)
1507
+ header_y <- y_start
1508
+ for (i in seq_along(pitch_types)) {
1509
+ pt <- pitch_types[i]
1510
+ col_color <- ifelse(pt %in% names(pitch_colors), pitch_colors[pt], "#95A5A6")
1511
+
1512
+ grid.rect(x = x_start + (i-1)*col_w, y = header_y,
1513
+ width = col_w * 0.95, height = row_h * 1.1,
1514
+ just = c("left", "center"),
1515
+ gp = gpar(fill = col_color, col = "black", lwd = 0.5))
1516
+
1517
+ grid.text(pt, x = x_start + (i-1)*col_w + col_w/2, y = header_y,
1518
+ gp = gpar(col = "white", cex = 0.45, fontface = "bold"))
1519
+ }
1520
+
1521
+ # Data rows
1522
+ for (m in seq_along(metrics)) {
1523
+ metric_name <- metrics[m]
1524
+ y_pos <- header_y - m * row_h
1525
+
1526
+ # Row label
1527
+ grid.text(metric_name, x = x_start - 0.02, y = y_pos, just = "right",
1528
+ gp = gpar(cex = 0.45, fontface = "bold"))
1529
+
1530
+ # Values for each pitch type
1531
+ for (i in seq_along(pitch_types)) {
1532
+ pt <- pitch_types[i]
1533
+ idx <- which(data$TaggedPitchType == pt)
1534
+ val <- if (length(idx) > 0 && metric_name %in% names(data)) {
1535
+ as.character(data[[metric_name]][idx])
1536
+ } else {
1537
+ "-"
1538
+ }
1539
+
1540
+ grid.rect(x = x_start + (i-1)*col_w, y = y_pos,
1541
+ width = col_w * 0.95, height = row_h * 0.9,
1542
+ just = c("left", "center"),
1543
+ gp = gpar(fill = "white", col = "gray80", lwd = 0.3))
1544
+
1545
+ grid.text(val, x = x_start + (i-1)*col_w + col_w/2, y = y_pos,
1546
+ gp = gpar(cex = 0.42))
1547
+ }
1548
+ }
1549
+ }
1550
+
1551
  create_tableau_pitcher_pdf <- function(game_df, pitcher_name, output_file) {
1552
+ # Close any open devices
1553
  if (length(dev.list()) > 0) try(dev.off(), silent = TRUE)
1554
 
1555
+ # Process and filter data
1556
+ pitcher_df <- process_tableau_pitcher_data(game_df) %>%
1557
+ filter(Pitcher == pitcher_name)
1558
 
1559
  if (nrow(pitcher_df) == 0) {
1560
+ pdf(output_file, width = 8.5, height = 11)
1561
+ grid.newpage()
1562
+ grid.text(paste("No data found for", pitcher_name),
1563
+ gp = gpar(fontsize = 16, fontface = "bold"))
1564
  dev.off()
1565
  return(output_file)
1566
  }
1567
 
1568
+ # Get metadata
1569
  game_date <- tryCatch({
1570
  d <- unique(pitcher_df$Date)[1]
1571
  if (inherits(d, "Date")) format(d, "%m/%d/%Y") else as.character(d)
1572
+ }, error = function(e) "NA")
1573
 
1574
  pitcher_team <- unique(pitcher_df$PitcherTeam)[1]
1575
+ if (is.na(pitcher_team)) pitcher_team <- "Unknown"
 
1576
 
1577
+ batter_teams <- unique(pitcher_df$BatterTeam)
1578
+ batter_team_str <- if (length(batter_teams) > 1) "All" else batter_teams[1]
1579
+ if (is.na(batter_team_str)) batter_team_str <- "Unknown"
1580
+
1581
+ # Calculate all stats
1582
  stats <- calculate_tableau_header_stats(pitcher_df)
1583
  loc_data <- calculate_tableau_location_data(pitcher_df)
1584
  usage_data <- calculate_tableau_pitch_usage(pitcher_df)
1585
  velo_data <- calculate_tableau_velo_movement(pitcher_df)
1586
  rel_data <- calculate_tableau_release_data(pitcher_df)
1587
 
1588
+ # Get pitch types used
1589
+ pitch_types <- unique(c(loc_data$TaggedPitchType, usage_data$TaggedPitchType))
1590
+ pitch_types <- pitch_types[!is.na(pitch_types) & pitch_types != ""]
1591
+
1592
+ # Create plots
1593
  loc_plot <- create_tableau_location_plot(pitcher_df, tableau_pitch_colors)
1594
  mov_plot <- create_tableau_movement_plot(pitcher_df, tableau_pitch_colors)
1595
  rel_plot <- create_tableau_release_plot(pitcher_df, tableau_pitch_colors)
1596
 
1597
+ # Create PDF - PORTRAIT (8.5 x 11)
1598
+ pdf(output_file, width = 8.5, height = 11)
 
1599
  on.exit(try(dev.off(), silent = TRUE), add = TRUE)
 
1600
 
1601
+ grid.newpage()
1602
+
1603
+ # =====================================================================
1604
+ # HEADER BAR (Teal)
1605
+ # =====================================================================
1606
+ grid.rect(x = 0, y = 0.94, width = 1, height = 0.06,
1607
+ just = c("left", "bottom"),
1608
+ gp = gpar(fill = "#006F71", col = NA))
1609
+
1610
+ grid.text("Pitcher Post-Game Report", x = 0.02, y = 0.97, just = "left",
1611
+ gp = gpar(col = "white", fontface = "bold", cex = 1.4))
1612
 
1613
+ # =====================================================================
1614
+ # INFO ROW
1615
+ # =====================================================================
1616
+ info_y <- 0.91
1617
  info_labels <- c("Date", "Pitcher Team", "Pitcher", "Batter Team", "vs. L/R")
1618
  info_values <- c(game_date, pitcher_team, pitcher_name, batter_team_str, "All")
1619
+
1620
  for (i in 1:5) {
1621
+ x_pos <- 0.02 + (i-1) * 0.18
1622
+ grid.text(info_labels[i], x = x_pos, y = info_y + 0.015, just = "left",
1623
+ gp = gpar(cex = 0.6, col = "gray50"))
1624
+ grid.text(info_values[i], x = x_pos, y = info_y - 0.012, just = "left",
1625
+ gp = gpar(cex = 0.7, fontface = "bold"))
1626
  }
1627
 
1628
+ # =====================================================================
1629
+ # STAT BOXES (Two rows)
1630
+ # =====================================================================
1631
+ # Row 1: At Bats, H, XBH, R, BB/HBP, SO, AVG
1632
+ stat_labels_1 <- c("At Bats", "H", "XBH", "R", "BB/HBP", "SO", "AVG")
1633
+ stat_values_1 <- c(stats$at_bats, stats$hits, stats$xbh, stats$runs, stats$bb_hbp, stats$so, stats$avg)
1634
+ stat_colors_1 <- c("#006F71", "#006F71", "#006F71", "#E74C3C", "#006F71", "#006F71", "#006F71")
1635
+
1636
+ box_y1 <- 0.865
1637
+ box_w <- 0.085
1638
+ box_h <- 0.04
1639
 
1640
  for (i in 1:7) {
1641
+ x_pos <- 0.02 + (i-1) * (box_w + 0.01)
1642
+ grid.rect(x = x_pos, y = box_y1, width = box_w, height = box_h,
1643
+ just = c("left", "center"),
1644
+ gp = gpar(fill = stat_colors_1[i], col = "black", lwd = 0.5))
1645
+ grid.text(stat_labels_1[i], x = x_pos + box_w/2, y = box_y1 + 0.01,
1646
+ gp = gpar(col = "white", cex = 0.5, fontface = "bold"))
1647
+ grid.text(as.character(stat_values_1[i]), x = x_pos + box_w/2, y = box_y1 - 0.008,
1648
+ gp = gpar(col = "white", cex = 0.7, fontface = "bold"))
1649
  }
1650
 
1651
+ # Row 2: Strike%, 1st P K%, E+A%, Comp%, LOO%
1652
+ stat_labels_2 <- c("Strike%", "1st P K%", "E+A%", "Comp%", "LOO%")
1653
+ stat_values_2 <- c(stats$strike_pct, stats$fp_k_pct, stats$ea_pct, stats$comp_pct, stats$loo_pct)
1654
+
1655
+ box_y2 <- 0.82
1656
+
1657
  for (i in 1:5) {
1658
+ x_pos <- 0.02 + (i-1) * (box_w + 0.01)
1659
+ grid.rect(x = x_pos, y = box_y2, width = box_w, height = box_h,
1660
+ just = c("left", "center"),
1661
+ gp = gpar(fill = "#006F71", col = "black", lwd = 0.5))
1662
+ grid.text(stat_labels_2[i], x = x_pos + box_w/2, y = box_y2 + 0.01,
1663
+ gp = gpar(col = "white", cex = 0.5, fontface = "bold"))
1664
+ grid.text(stat_values_2[i], x = x_pos + box_w/2, y = box_y2 - 0.008,
1665
+ gp = gpar(col = "white", cex = 0.7, fontface = "bold"))
1666
  }
1667
 
1668
  # PDF button
1669
+ grid.rect(x = 0.92, y = 0.84, width = 0.06, height = 0.06,
1670
+ just = c("left", "center"),
1671
+ gp = gpar(fill = "#E74C3C", col = "black", lwd = 0.5))
1672
+ grid.text("PDF", x = 0.95, y = 0.84,
1673
+ gp = gpar(col = "white", cex = 0.7, fontface = "bold"))
1674
+
1675
+ # =====================================================================
1676
+ # LOCATION PLOT (Left side, smaller)
1677
+ # =====================================================================
1678
+ pushViewport(viewport(x = 0.25, y = 0.60, width = 0.45, height = 0.32))
1679
  print(loc_plot, newpage = FALSE)
1680
+ popViewport()
1681
 
1682
+ # =====================================================================
1683
+ # LOCATION DATA TABLE (Right side)
1684
+ # =====================================================================
1685
+ draw_tableau_table(
1686
+ title = "Location Data",
1687
+ data = loc_data,
1688
+ metrics = c("Zone%", "Edge%", "Strike%", "Whiff%"),
1689
+ pitch_types = pitch_types,
1690
+ pitch_colors = tableau_pitch_colors,
1691
+ x_start = 0.55, y_start = 0.73,
1692
+ col_w = min(0.12, 0.4 / max(1, length(pitch_types))),
1693
+ row_h = 0.018
1694
+ )
 
 
 
 
 
 
 
1695
 
1696
+ # =====================================================================
1697
+ # PITCH USAGE TABLE (Right side, below location data)
1698
+ # =====================================================================
1699
+ draw_tableau_table(
1700
+ title = "Pitch Usage",
1701
+ data = usage_data,
1702
+ metrics = c("Usage vs. LHH", "Usage vs. RHH", "Pitch Count"),
1703
+ pitch_types = pitch_types,
1704
+ pitch_colors = tableau_pitch_colors,
1705
+ x_start = 0.55, y_start = 0.58,
1706
+ col_w = min(0.12, 0.4 / max(1, length(pitch_types))),
1707
+ row_h = 0.018
1708
+ )
 
 
 
 
 
 
1709
 
1710
+ # =====================================================================
1711
+ # DIVIDER
1712
+ # =====================================================================
1713
+ grid.lines(x = c(0.02, 0.98), y = c(0.42, 0.42),
1714
+ gp = gpar(col = "gray70", lty = "dotted"))
1715
 
1716
+ # =====================================================================
1717
+ # MOVEMENT PLOT (Left side)
1718
+ # =====================================================================
1719
+ pushViewport(viewport(x = 0.25, y = 0.28, width = 0.45, height = 0.26))
1720
  print(mov_plot, newpage = FALSE)
1721
+ popViewport()
1722
 
1723
+ # =====================================================================
1724
+ # VELO & MOVEMENT TABLE (Right side)
1725
+ # =====================================================================
1726
+ draw_tableau_table(
1727
+ title = "Velo & Movement",
1728
+ data = velo_data,
1729
+ metrics = c("Avg. Velo", "Max. Velo", "Avg. Spin Rate", "Max. Spin Rate",
1730
+ "Avg. Vert Break", "Avg. Horz Break"),
1731
+ pitch_types = pitch_types,
1732
+ pitch_colors = tableau_pitch_colors,
1733
+ x_start = 0.55, y_start = 0.38,
1734
+ col_w = min(0.12, 0.4 / max(1, length(pitch_types))),
1735
+ row_h = 0.016
1736
+ )
 
 
 
 
 
1737
 
1738
+ # =====================================================================
1739
+ # DIVIDER
1740
+ # =====================================================================
1741
+ grid.lines(x = c(0.02, 0.98), y = c(0.14, 0.14),
1742
+ gp = gpar(col = "gray70", lty = "dotted"))
1743
 
1744
+ # =====================================================================
1745
+ # RELEASE PLOT (Left side)
1746
+ # =====================================================================
1747
+ pushViewport(viewport(x = 0.25, y = 0.07, width = 0.45, height = 0.13))
1748
  print(rel_plot, newpage = FALSE)
1749
+ popViewport()
1750
 
1751
+ # =====================================================================
1752
+ # RELEASE DATA TABLE (Right side)
1753
+ # =====================================================================
1754
+ draw_tableau_table(
1755
+ title = "Release Data",
1756
+ data = rel_data,
1757
+ metrics = c("Avg. Rel Height (ft.)", "Rel Ht vs. FB (in.)",
1758
+ "Avg. Rel Side (ft.)", "Rel Side vs. FB (in.)",
1759
+ "Avg. Extension (ft.)"),
1760
+ pitch_types = pitch_types,
1761
+ pitch_colors = tableau_pitch_colors,
1762
+ x_start = 0.55, y_start = 0.12,
1763
+ col_w = min(0.12, 0.4 / max(1, length(pitch_types))),
1764
+ row_h = 0.014
1765
+ )
 
 
 
 
 
1766
 
1767
  invisible(output_file)
1768
  }
 
4226
  c("Hitter"="hitter",
4227
  "Pitcher"="pitcher",
4228
  "Advanced Pitcher"="advanced_pitcher",
4229
+ "Matt Williams Report"="tableau_pitcher",
4230
  "Catcher"="catcher",
4231
  "Umpire"="umpire",
4232
  "BP Report"="bp"),