igroffman commited on
Commit
d47c1f4
·
verified ·
1 Parent(s): 4831df6

Update app.R

Browse files
Files changed (1) hide show
  1. app.R +128 -147
app.R CHANGED
@@ -1065,6 +1065,15 @@ create_postgame_pdf <- function(game_df, player_name, output_file, bio_data = NU
1065
  }
1066
 
1067
 
 
 
 
 
 
 
 
 
 
1068
  library(ggplot2)
1069
  library(dplyr)
1070
  library(grid)
@@ -1120,7 +1129,7 @@ process_tableau_pitcher_data <- function(df) {
1120
  )
1121
  }
1122
 
1123
- # Normalize a few common fastball spellings to improve color mapping consistency
1124
  df <- df %>%
1125
  mutate(
1126
  TaggedPitchType = case_when(
@@ -1129,53 +1138,41 @@ process_tableau_pitcher_data <- function(df) {
1129
  )
1130
  )
1131
 
1132
- # Calculate indicators
1133
- df <- df %>%
1134
  mutate(
1135
  StrikeZoneIndicator = ifelse(PlateLocSide >= -0.8333 & PlateLocSide <= 0.8333 &
1136
  PlateLocHeight >= 1.5 & PlateLocHeight <= 3.5, 1, 0),
1137
  EdgeHeightIndicator = ifelse((PlateLocHeight > 14/12 & PlateLocHeight < 22/12) |
1138
  (PlateLocHeight > 38/12 & PlateLocHeight < 46/12), 1, 0),
1139
  EdgeZoneHtIndicator = ifelse(PlateLocHeight > 16/12 & PlateLocHeight < 45.2/12, 1, 0),
1140
- EdgeZoneWIndicator = ifelse(PlateLocSide > -13.4/12 & PlateLocSide < 13.4/12, 1, 0),
1141
- EdgeWidthIndicator = ifelse((PlateLocSide > -13.3/12 & PlateLocSide < -6.7/12) |
1142
- (PlateLocSide < 13.3/12 & PlateLocSide > 6.7/12), 1, 0),
1143
  EdgeIndicator = ifelse((EdgeHeightIndicator == 1 & EdgeZoneWIndicator == 1) |
1144
  (EdgeWidthIndicator == 1 & EdgeZoneHtIndicator == 1), 1, 0),
1145
  QualityPitchIndicator = ifelse(StrikeZoneIndicator == 1 | EdgeIndicator == 1, 1, 0),
1146
- StrikeIndicator = ifelse(PitchCall %in% c("StrikeSwinging", "StrikeCalled",
1147
- "FoulBallNotFieldable", "FoulBall", "InPlay"), 1, 0),
1148
- WhiffIndicator = ifelse(PitchCall == "StrikeSwinging", 1, 0),
1149
- SwingIndicator = ifelse(PitchCall %in% c("StrikeSwinging", "FoulBallNotFieldable",
1150
- "FoulBall", "InPlay"), 1, 0),
1151
- FPindicator = ifelse(Balls == 0 & Strikes == 0, 1, 0),
1152
- FPSindicator = ifelse(PitchCall %in% c("StrikeCalled", "StrikeSwinging",
1153
- "FoulBallNotFieldable", "FoulBall", "InPlay") &
1154
- FPindicator == 1, 1, 0),
1155
- AheadIndicator = ifelse(((Balls == 0 & Strikes == 1) | (Balls == 1 & Strikes == 1)) &
1156
- StrikeIndicator == 1, 1, 0),
1157
- ABindicator = ifelse(PlayResult %in% c("Error", "FieldersChoice", "Out",
1158
- "Single", "Double", "Triple", "HomeRun") |
1159
- KorBB == "Strikeout", 1, 0),
1160
- HitIndicator = ifelse(PlayResult %in% c("Single", "Double", "Triple", "HomeRun"), 1, 0),
1161
- PAindicator = ifelse(PitchCall %in% c("InPlay", "HitByPitch", "CatchersInterference") |
1162
- KorBB %in% c("Walk", "Strikeout"), 1, 0),
1163
- LeadOffIndicator = ifelse((PAofInning == 1 & (PlayResult != "Undefined" | KorBB != "Undefined")) |
1164
- PitchCall == "HitByPitch", 1, 0),
1165
- OutIndicator = ifelse((PlayResult %in% c("Out", "FieldersChoice") | KorBB == "Strikeout") &
1166
- PitchCall != "HitByPitch", 1, 0),
1167
  LOOindicator = ifelse(LeadOffIndicator == 1 & OutIndicator == 1, 1, 0),
1168
- HBPIndicator = ifelse(PitchCall == "HitByPitch", 1, 0),
1169
  WalkIndicator = ifelse(KorBB == "Walk", 1, 0),
1170
- LHHindicator = ifelse(BatterSide == "Left", 1, 0),
1171
- RHHindicator = ifelse(BatterSide == "Right", 1, 0)
1172
  )
1173
-
1174
- df
1175
  }
1176
 
1177
  # =====================================================================
1178
- # HEADER STATISTICS
1179
  # =====================================================================
1180
  calculate_tableau_header_stats <- function(pitcher_df) {
1181
  ab_data <- pitcher_df %>%
@@ -1186,7 +1183,7 @@ calculate_tableau_header_stats <- function(pitcher_df) {
1186
 
1187
  at_bats <- nrow(ab_data)
1188
  hits <- sum(ab_data$HitIndicator, na.rm = TRUE)
1189
- xbh <- sum(ab_data$PlayResult %in% c("Double", "Triple", "HomeRun"), na.rm = TRUE)
1190
  runs <- sum(pitcher_df$RunsScored, na.rm = TRUE)
1191
 
1192
  pa_data <- pitcher_df %>%
@@ -1195,9 +1192,9 @@ calculate_tableau_header_stats <- function(pitcher_df) {
1195
  slice_tail(n = 1) %>%
1196
  ungroup()
1197
 
1198
- bb <- sum(pa_data$WalkIndicator, na.rm = TRUE)
1199
  hbp <- sum(pa_data$HBPIndicator, na.rm = TRUE)
1200
- so <- sum(pa_data$KorBB == "Strikeout", na.rm = TRUE)
1201
 
1202
  avg <- ifelse(at_bats > 0, round(hits / at_bats, 3), 0)
1203
 
@@ -1233,7 +1230,7 @@ calculate_tableau_header_stats <- function(pitcher_df) {
1233
  }
1234
 
1235
  # =====================================================================
1236
- # TABLE CALCULATIONS
1237
  # =====================================================================
1238
  get_valid_pitch_types <- function(pitcher_df) {
1239
  valid_types <- pitcher_df %>%
@@ -1241,9 +1238,7 @@ get_valid_pitch_types <- function(pitcher_df) {
1241
  pull(TaggedPitchType) %>%
1242
  unique()
1243
 
1244
- if (length(valid_types) == 1 && valid_types[1] == "Undefined") {
1245
- return(valid_types)
1246
- }
1247
 
1248
  filter_types <- valid_types[valid_types != "Undefined" & valid_types != "Other"]
1249
  if (length(filter_types) == 0) return("Undefined")
@@ -1257,12 +1252,12 @@ calculate_tableau_location_data <- function(pitcher_df) {
1257
  filter(TaggedPitchType %in% filter_types) %>%
1258
  group_by(TaggedPitchType) %>%
1259
  summarise(
1260
- `Zone%` = paste0(round(100 * sum(StrikeZoneIndicator, na.rm = TRUE) / n(), 0), "%"),
1261
- `Edge%` = paste0(round(100 * sum(EdgeIndicator, na.rm = TRUE) / n(), 0), "%"),
1262
  `Strike%` = paste0(round(100 * sum(StrikeIndicator, na.rm = TRUE) / n(), 0), "%"),
1263
- `Whiff%` = ifelse(sum(SwingIndicator, na.rm = TRUE) > 0,
1264
- paste0(round(100 * sum(WhiffIndicator, na.rm = TRUE) / sum(SwingIndicator, na.rm = TRUE), 0), "%"),
1265
- "0%"),
1266
  .groups = "drop"
1267
  )
1268
  }
@@ -1276,7 +1271,7 @@ calculate_tableau_pitch_usage <- function(pitcher_df) {
1276
  filter(TaggedPitchType %in% filter_types) %>%
1277
  group_by(TaggedPitchType) %>%
1278
  summarise(
1279
- `Pitch Count` = n(),
1280
  `Usage vs. LHH` = paste0(round(100 * sum(LHHindicator, na.rm = TRUE) / max(1, lhh_pitches), 0), "%"),
1281
  `Usage vs. RHH` = paste0(round(100 * sum(RHHindicator, na.rm = TRUE) / max(1, rhh_pitches), 0), "%"),
1282
  .groups = "drop"
@@ -1294,34 +1289,35 @@ calculate_tableau_velo_movement <- function(pitcher_df) {
1294
  `Max. Velo` = round(max(RelSpeed, na.rm = TRUE), 1),
1295
  `Avg. Spin` = format(round(mean(SpinRate, na.rm = TRUE), 0), big.mark = ","),
1296
  `Max. Spin` = format(round(max(SpinRate, na.rm = TRUE), 0), big.mark = ","),
1297
- `Avg. IVB` = round(mean(InducedVertBreak, na.rm = TRUE), 0),
1298
- `Avg. HB` = round(mean(HorzBreak, na.rm = TRUE), 0),
1299
  .groups = "drop"
1300
  )
1301
  }
1302
 
1303
  calculate_tableau_release_data <- function(pitcher_df) {
1304
  primary <- pitcher_df %>%
1305
- filter(TaggedPitchType %in% c("Fastball", "Sinker", "Four-Seam", "FourSeamFastBall"))
1306
- fb_ht <- if (nrow(primary) > 0) mean(primary$RelHeight, na.rm = TRUE) else NA
1307
- fb_side <- if (nrow(primary) > 0) mean(primary$RelSide, na.rm = TRUE) else NA
 
1308
  filter_types <- get_valid_pitch_types(pitcher_df)
1309
 
1310
  pitcher_df %>%
1311
  filter(TaggedPitchType %in% filter_types) %>%
1312
  group_by(TaggedPitchType) %>%
1313
  summarise(
1314
- `Avg. Rel Ht` = round(mean(RelHeight, na.rm = TRUE), 2),
1315
- `Rel Ht vs. FB` = ifelse(is.na(fb_ht), NA, round((mean(RelHeight, na.rm = TRUE) - fb_ht) * 12, 0)),
1316
- `Avg. Rel Side` = round(mean(RelSide, na.rm = TRUE), 2),
1317
- `Rel Side vs. FB` = ifelse(is.na(fb_side), NA, round((mean(RelSide, na.rm = TRUE) - fb_side) * 12, 0)),
1318
- `Avg. Ext` = round(mean(Extension, na.rm = TRUE), 2),
1319
  .groups = "drop"
1320
  )
1321
  }
1322
 
1323
  # =====================================================================
1324
- # LOCATION PLOT
1325
  # =====================================================================
1326
  create_tableau_location_plot <- function(pitcher_df, pitch_colors) {
1327
  filter_types <- get_valid_pitch_types(pitcher_df)
@@ -1331,11 +1327,11 @@ create_tableau_location_plot <- function(pitcher_df, pitch_colors) {
1331
  !is.na(PlateLocSide), !is.na(PlateLocHeight)) %>%
1332
  mutate(
1333
  ResultDisplay = case_when(
1334
- PitchCall %in% c("BallCalled", "BallinDirt") ~ "Ball",
1335
  PlayResult == "Double" ~ "2B",
1336
- PitchCall %in% c("FoulBall", "FoulBallNotFieldable") ~ "Foul",
1337
  PitchCall == "HitByPitch" ~ "HBP",
1338
- PlayResult %in% c("Sacrifice", "SacrificeFly") ~ "Sac",
1339
  PlayResult == "Single" ~ "1B",
1340
  PitchCall == "StrikeCalled" ~ "Called",
1341
  PitchCall == "StrikeSwinging" ~ "Whiff",
@@ -1380,7 +1376,7 @@ create_tableau_location_plot <- function(pitcher_df, pitch_colors) {
1380
  y = c(0.15, 0.15, 0.30, 0.50, 0.30), fill = NA, color = "black", linewidth = 0.5) +
1381
  annotate("segment", x = 0, xend = 0, y = 0.5, yend = shadow_top + 0.2,
1382
  color = "gray60", linetype = "dotted", linewidth = 0.3) +
1383
- geom_point(aes(color = TaggedPitchType, shape = ResultDisplay), size = 2.5, stroke = 0.8) +
1384
  scale_color_manual(values = pitch_colors, name = "Pitch") +
1385
  scale_shape_manual(
1386
  values = c("Ball" = 1, "2B" = 18, "Foul" = 2, "HBP" = 8,
@@ -1412,9 +1408,6 @@ create_tableau_location_plot <- function(pitcher_df, pitch_colors) {
1412
  )
1413
  }
1414
 
1415
- # =====================================================================
1416
- # MOVEMENT PLOT (FILLED POINTS = PITCH COLORS)
1417
- # =====================================================================
1418
  create_tableau_movement_plot <- function(pitcher_df, pitch_colors) {
1419
  filter_types <- get_valid_pitch_types(pitcher_df)
1420
 
@@ -1431,7 +1424,7 @@ create_tableau_movement_plot <- function(pitcher_df, pitch_colors) {
1431
  ggplot(df, aes(x = HorzBreak, y = InducedVertBreak)) +
1432
  geom_vline(xintercept = 0, color = "black", linewidth = 0.4) +
1433
  geom_hline(yintercept = 0, color = "black", linewidth = 0.4) +
1434
- geom_point(aes(fill = TaggedPitchType), size = 3, alpha = 1,
1435
  shape = 21, color = "black", stroke = 0.9) +
1436
  scale_fill_manual(values = pitch_colors, drop = FALSE) +
1437
  coord_cartesian(xlim = c(-30, 30), ylim = c(-30, 30)) +
@@ -1441,14 +1434,11 @@ create_tableau_movement_plot <- function(pitcher_df, pitch_colors) {
1441
  plot.title = element_text(size = 12, face = "bold", hjust = 0.5),
1442
  legend.position = "none",
1443
  axis.title = element_text(size = 10),
1444
- axis.text = element_text(size = 9),
1445
  plot.margin = margin(2, 2, 2, 2)
1446
  )
1447
  }
1448
 
1449
- # =====================================================================
1450
- # RELEASE PLOT (FILLED POINTS = PITCH COLORS)
1451
- # =====================================================================
1452
  create_tableau_release_plot <- function(pitcher_df, pitch_colors) {
1453
  filter_types <- get_valid_pitch_types(pitcher_df)
1454
 
@@ -1475,7 +1465,7 @@ create_tableau_release_plot <- function(pitcher_df, pitch_colors) {
1475
  annotate("rect", xmin = -0.5, xmax = 0.5, ymin = 0.85, ymax = 1.05,
1476
  fill = "white", color = "gray40", linewidth = 0.3) +
1477
  geom_vline(xintercept = 0, color = "gray60", linetype = "dashed", linewidth = 0.3) +
1478
- geom_point(aes(fill = TaggedPitchType), size = 3, alpha = 1,
1479
  shape = 21, color = "black", stroke = 0.9) +
1480
  scale_fill_manual(values = pitch_colors, drop = FALSE) +
1481
  coord_cartesian(xlim = c(-4, 4), ylim = c(0, 7)) +
@@ -1485,22 +1475,22 @@ create_tableau_release_plot <- function(pitcher_df, pitch_colors) {
1485
  plot.title = element_text(size = 12, face = "bold", hjust = 0.5),
1486
  legend.position = "none",
1487
  axis.title = element_text(size = 10),
1488
- axis.text = element_text(size = 9),
1489
  plot.margin = margin(2, 2, 2, 2)
1490
  )
1491
  }
1492
 
1493
  # =====================================================================
1494
- # TABLE DRAWING (AUTO-SCALED, BUT NOT TOO HUGE)
1495
  # =====================================================================
1496
  draw_tableau_table_fill <- function(
1497
  title,
1498
  data,
1499
- rows, # named vector: display label -> column name in `data`
1500
  pitch_types,
1501
  pitch_colors,
1502
- x, y, # TOP-LEFT anchor (npc)
1503
- width, height # size (npc)
1504
  ) {
1505
  if (is.null(pitch_types) || length(pitch_types) == 0) pitch_types <- "Undefined"
1506
 
@@ -1514,16 +1504,16 @@ draw_tableau_table_fill <- function(
1514
  col_w <- width / n_cols
1515
  row_h <- table_h / n_rows
1516
 
1517
- # Slightly reduced scaling so it doesn't blow up
1518
- header_cex <- max(0.75, min(1.25, row_h * 24))
1519
- body_cex <- max(0.70, min(1.15, row_h * 22))
1520
- label_cex <- max(0.70, min(1.15, row_h * 22))
1521
- title_cex <- max(0.95, min(1.35, row_h * 26))
1522
 
1523
  grid.text(title, x = x + width/2, y = y,
1524
  gp = gpar(fontface = "bold", cex = title_cex, col = "#006F71"))
1525
 
1526
- # headers
1527
  for (i in seq_along(pitch_types)) {
1528
  pt <- pitch_types[i]
1529
  col_color <- if (pt %in% names(pitch_colors)) pitch_colors[[pt]] else "#95A5A6"
@@ -1549,7 +1539,7 @@ draw_tableau_table_fill <- function(
1549
  gp = gpar(col = "white", cex = header_cex, fontface = "bold"))
1550
  }
1551
 
1552
- # rows
1553
  row_names <- names(rows)
1554
  col_names <- as.character(rows)
1555
 
@@ -1581,17 +1571,19 @@ draw_tableau_table_fill <- function(
1581
  grid.text(val,
1582
  x = x + (i-1)*col_w + col_w/2,
1583
  y = y_row_top - row_h*0.55,
1584
- gp = gpar(cex = body_cex))
1585
  }
1586
  }
1587
  }
1588
 
1589
-
 
 
1590
  create_tableau_pitcher_pdf <- function(game_df, pitcher_name, output_file) {
1591
  if (length(dev.list()) > 0) try(dev.off(), silent = TRUE)
1592
 
1593
  pitcher_df <- process_tableau_pitcher_data(game_df) %>%
1594
- dplyr::filter(Pitcher == pitcher_name)
1595
 
1596
  if (nrow(pitcher_df) == 0) {
1597
  pdf(output_file, width = 8.5, height = 11)
@@ -1602,7 +1594,7 @@ create_tableau_pitcher_pdf <- function(game_df, pitcher_name, output_file) {
1602
  return(output_file)
1603
  }
1604
 
1605
- # ---- metadata ----
1606
  game_date <- tryCatch({
1607
  d <- unique(pitcher_df$Date)[1]
1608
  if (inherits(d, "Date")) format(d, "%m/%d/%Y") else as.character(d)
@@ -1612,40 +1604,37 @@ create_tableau_pitcher_pdf <- function(game_df, pitcher_name, output_file) {
1612
  batter_teams <- batter_teams[!is.na(batter_teams)]
1613
  away_team <- if (length(batter_teams) > 0) batter_teams[1] else "Unknown"
1614
 
1615
- # ---- calculations ----
1616
  stats <- calculate_tableau_header_stats(pitcher_df)
1617
  loc_data <- calculate_tableau_location_data(pitcher_df)
1618
  usage_data <- calculate_tableau_pitch_usage(pitcher_df)
1619
  velo_data <- calculate_tableau_velo_movement(pitcher_df)
1620
  rel_data <- calculate_tableau_release_data(pitcher_df)
1621
 
1622
- # Pitch types ordered by usage (Pitch Count desc), then append any that appear elsewhere
1623
- usage_order <- usage_data %>%
1624
- dplyr::arrange(dplyr::desc(`Pitch Count`)) %>%
1625
- dplyr::pull(TaggedPitchType) %>%
1626
  unique()
1627
-
1628
- other_types <- unique(c(loc_data$TaggedPitchType, velo_data$TaggedPitchType, rel_data$TaggedPitchType))
1629
- other_types <- other_types[!is.na(other_types) & other_types != ""]
1630
- usage_order <- usage_order[!is.na(usage_order) & usage_order != ""]
1631
-
1632
- pitch_types <- unique(c(usage_order, setdiff(other_types, usage_order)))
1633
  if (length(pitch_types) == 0) pitch_types <- "Undefined"
1634
 
1635
- # ---- plots ----
1636
  loc_plot <- create_tableau_location_plot(pitcher_df, tableau_pitch_colors)
1637
  mov_plot <- create_tableau_movement_plot(pitcher_df, tableau_pitch_colors)
1638
  rel_plot <- create_tableau_release_plot(pitcher_df, tableau_pitch_colors)
1639
 
1640
- # ---- render ----
1641
  pdf(output_file, width = 8.5, height = 11)
1642
  on.exit(try(dev.off(), silent = TRUE), add = TRUE)
1643
-
1644
  grid.newpage()
1645
 
1646
- # =====================================================================
1647
- # HEADER BAR
1648
- # =====================================================================
1649
  grid.rect(x = 0, y = 0.955, width = 1, height = 0.045,
1650
  just = c("left", "bottom"),
1651
  gp = gpar(fill = "#006F71", col = NA))
@@ -1653,17 +1642,15 @@ create_tableau_pitcher_pdf <- function(game_df, pitcher_name, output_file) {
1653
  grid.text("Pitcher Post-Game Report", x = 0.02, y = 0.977, just = "left",
1654
  gp = gpar(col = "white", fontface = "bold", cex = 1.2))
1655
 
1656
- # INFO ROW
1657
  info_y <- 0.935
1658
  grid.text(paste0(game_date, " | ", pitcher_name, " vs ", away_team),
1659
  x = 0.02, y = info_y, just = "left",
1660
- gp = gpar(cex = 0.82, fontface = "bold"))
1661
 
1662
- # =====================================================================
1663
- # STAT TILES (Tableau-style: top color band + bottom white; BIG + readable)
1664
- # =====================================================================
1665
- stat_labels <- c("At Bats","H","XBH","R","BB/HBP","SO","AVG",
1666
- "Strike%","1st P K%","E+A%","Comp%","LOO%")
1667
  stat_values <- c(stats$at_bats, stats$hits, stats$xbh, stats$runs,
1668
  stats$bb_hbp, stats$so, stats$avg,
1669
  stats$strike_pct, stats$fp_k_pct, stats$ea_pct,
@@ -1672,15 +1659,14 @@ create_tableau_pitcher_pdf <- function(game_df, pitcher_name, output_file) {
1672
  label_colors <- c("#E74C3C", "#3498DB", "#3498DB", "#27AE60", "#27AE60", "#27AE60",
1673
  "#00BCD4", "#F39C12", "#F39C12", "#F39C12", "#F39C12", "#F39C12")
1674
 
1675
- # Fit math (prevents clipping)
1676
- tiles_y <- 0.905
1677
- x_start <- 0.02
1678
- tile_w <- 0.0715
1679
  tile_h <- 0.060
1680
- tile_gap <- 0.0075
 
1681
 
1682
- band_h <- tile_h * 0.52
1683
- bottom_h <- tile_h - band_h
1684
 
1685
  for (i in seq_along(stat_labels)) {
1686
  x_pos <- x_start + (i - 1) * (tile_w + tile_gap)
@@ -1689,13 +1675,12 @@ create_tableau_pitcher_pdf <- function(game_df, pitcher_name, output_file) {
1689
  grid.rect(
1690
  x = x_pos, y = tiles_y, width = tile_w, height = tile_h,
1691
  just = c("left", "center"),
1692
- gp = gpar(fill = NA, col = label_colors[i], lwd = 2.4)
1693
  )
1694
 
1695
  # Top colored band
1696
  grid.rect(
1697
- x = x_pos,
1698
- y = tiles_y + (tile_h/2) - (band_h/2),
1699
  width = tile_w, height = band_h,
1700
  just = c("left", "center"),
1701
  gp = gpar(fill = label_colors[i], col = NA)
@@ -1703,53 +1688,48 @@ create_tableau_pitcher_pdf <- function(game_df, pitcher_name, output_file) {
1703
 
1704
  # Bottom white area
1705
  grid.rect(
1706
- x = x_pos,
1707
- y = tiles_y - (tile_h/2) + (bottom_h/2),
1708
- width = tile_w, height = bottom_h,
1709
  just = c("left", "center"),
1710
- gp = gpar(fill = "white", col = "black", lwd = 0.9)
1711
  )
1712
 
1713
- # Label (white, centered in color band)
1714
  grid.text(
1715
  stat_labels[i],
1716
  x = x_pos + tile_w/2,
1717
  y = tiles_y + (tile_h/2) - (band_h/2),
1718
- gp = gpar(col = "white", cex = 0.88, fontface = "bold")
1719
  )
1720
 
1721
- # Value (BIG)
1722
  grid.text(
1723
  as.character(stat_values[i]),
1724
  x = x_pos + tile_w/2,
1725
- y = tiles_y - (tile_h/2) + (bottom_h/2),
1726
- gp = gpar(col = "black", cex = 1.35, fontface = "bold")
1727
  )
1728
  }
1729
 
1730
- # =====================================================================
1731
- # CHARTS (left column)
1732
- # =====================================================================
1733
- # Location directly under header stats
1734
- pushViewport(viewport(x = 0.23, y = 0.675, width = 0.42, height = 0.30))
1735
  print(loc_plot, newpage = FALSE)
1736
  popViewport()
1737
 
1738
- pushViewport(viewport(x = 0.23, y = 0.375, width = 0.40, height = 0.24))
1739
  print(mov_plot, newpage = FALSE)
1740
  popViewport()
1741
 
1742
- pushViewport(viewport(x = 0.23, y = 0.135, width = 0.42, height = 0.24))
1743
  print(rel_plot, newpage = FALSE)
1744
  popViewport()
1745
 
1746
- # =====================================================================
1747
- # TABLES (right column) - lifted + guaranteed fit
1748
- # Top third: Location + Usage
1749
- # Middle: Velo & Movement
1750
- # Bottom: Release Data
1751
- # =====================================================================
1752
- table_x <- 0.56
1753
  table_w <- 0.41
1754
 
1755
  draw_tableau_table_fill(
@@ -1758,7 +1738,7 @@ create_tableau_pitcher_pdf <- function(game_df, pitcher_name, output_file) {
1758
  rows = c("Zone%"="Zone%", "Edge%"="Edge%", "Strike%"="Strike%", "Whiff%"="Whiff%"),
1759
  pitch_types = pitch_types,
1760
  pitch_colors = tableau_pitch_colors,
1761
- x = table_x, y = 0.84, width = table_w, height = 0.16
1762
  )
1763
 
1764
  draw_tableau_table_fill(
@@ -1767,9 +1747,10 @@ create_tableau_pitcher_pdf <- function(game_df, pitcher_name, output_file) {
1767
  rows = c("Usage vs. LHH"="Usage vs. LHH", "Usage vs. RHH"="Usage vs. RHH", "Pitch Count"="Pitch Count"),
1768
  pitch_types = pitch_types,
1769
  pitch_colors = tableau_pitch_colors,
1770
- x = table_x, y = 0.66, width = table_w, height = 0.15
1771
  )
1772
 
 
1773
  draw_tableau_table_fill(
1774
  title = "Velo & Movement",
1775
  data = velo_data,
@@ -1778,7 +1759,7 @@ create_tableau_pitcher_pdf <- function(game_df, pitcher_name, output_file) {
1778
  "Avg. IVB"="Avg. IVB", "Avg. HB"="Avg. HB"),
1779
  pitch_types = pitch_types,
1780
  pitch_colors = tableau_pitch_colors,
1781
- x = table_x, y = 0.48, width = table_w, height = 0.22
1782
  )
1783
 
1784
  draw_tableau_table_fill(
@@ -1791,7 +1772,7 @@ create_tableau_pitcher_pdf <- function(game_df, pitcher_name, output_file) {
1791
  "Ext"="Avg. Ext"),
1792
  pitch_types = pitch_types,
1793
  pitch_colors = tableau_pitch_colors,
1794
- x = table_x, y = 0.285, width = table_w, height = 0.22
1795
  )
1796
 
1797
  invisible(output_file)
 
1065
  }
1066
 
1067
 
1068
+ # ============================================================
1069
+ # PITCHER POST-GAME PDF (Tableau-style)
1070
+ # Fixes:
1071
+ # - Header stat tiles: bigger + more readable + moved slightly DOWN
1072
+ # - Tables: bigger numbers
1073
+ # - Pitch columns always ordered by pitch usage (Pitch Count desc)
1074
+ # - Table stack spacing fixed so "Release Data" title does NOT overlap Velo table
1075
+ # ============================================================
1076
+
1077
  library(ggplot2)
1078
  library(dplyr)
1079
  library(grid)
 
1129
  )
1130
  }
1131
 
1132
+ # Normalize common FB spellings (for colors + grouping)
1133
  df <- df %>%
1134
  mutate(
1135
  TaggedPitchType = case_when(
 
1138
  )
1139
  )
1140
 
1141
+ # Indicators
1142
+ df %>%
1143
  mutate(
1144
  StrikeZoneIndicator = ifelse(PlateLocSide >= -0.8333 & PlateLocSide <= 0.8333 &
1145
  PlateLocHeight >= 1.5 & PlateLocHeight <= 3.5, 1, 0),
1146
  EdgeHeightIndicator = ifelse((PlateLocHeight > 14/12 & PlateLocHeight < 22/12) |
1147
  (PlateLocHeight > 38/12 & PlateLocHeight < 46/12), 1, 0),
1148
  EdgeZoneHtIndicator = ifelse(PlateLocHeight > 16/12 & PlateLocHeight < 45.2/12, 1, 0),
1149
+ EdgeZoneWIndicator = ifelse(PlateLocSide > -13.4/12 & PlateLocSide < 13.4/12, 1, 0),
1150
+ EdgeWidthIndicator = ifelse((PlateLocSide > -13.3/12 & PlateLocSide < -6.7/12) |
1151
+ (PlateLocSide < 13.3/12 & PlateLocSide > 6.7/12), 1, 0),
1152
  EdgeIndicator = ifelse((EdgeHeightIndicator == 1 & EdgeZoneWIndicator == 1) |
1153
  (EdgeWidthIndicator == 1 & EdgeZoneHtIndicator == 1), 1, 0),
1154
  QualityPitchIndicator = ifelse(StrikeZoneIndicator == 1 | EdgeIndicator == 1, 1, 0),
1155
+ StrikeIndicator = ifelse(PitchCall %in% c("StrikeSwinging","StrikeCalled","FoulBallNotFieldable","FoulBall","InPlay"), 1, 0),
1156
+ WhiffIndicator = ifelse(PitchCall == "StrikeSwinging", 1, 0),
1157
+ SwingIndicator = ifelse(PitchCall %in% c("StrikeSwinging","FoulBallNotFieldable","FoulBall","InPlay"), 1, 0),
1158
+ FPindicator = ifelse(Balls == 0 & Strikes == 0, 1, 0),
1159
+ FPSindicator = ifelse(PitchCall %in% c("StrikeCalled","StrikeSwinging","FoulBallNotFieldable","FoulBall","InPlay") & FPindicator == 1, 1, 0),
1160
+ AheadIndicator = ifelse(((Balls == 0 & Strikes == 1) | (Balls == 1 & Strikes == 1)) & StrikeIndicator == 1, 1, 0),
1161
+ ABindicator = ifelse(PlayResult %in% c("Error","FieldersChoice","Out","Single","Double","Triple","HomeRun") | KorBB == "Strikeout", 1, 0),
1162
+ HitIndicator = ifelse(PlayResult %in% c("Single","Double","Triple","HomeRun"), 1, 0),
1163
+ PAindicator = ifelse(PitchCall %in% c("InPlay","HitByPitch","CatchersInterference") | KorBB %in% c("Walk","Strikeout"), 1, 0),
1164
+ LeadOffIndicator = ifelse((PAofInning == 1 & (PlayResult != "Undefined" | KorBB != "Undefined")) | PitchCall == "HitByPitch", 1, 0),
1165
+ OutIndicator = ifelse((PlayResult %in% c("Out","FieldersChoice") | KorBB == "Strikeout") & PitchCall != "HitByPitch", 1, 0),
 
 
 
 
 
 
 
 
 
 
1166
  LOOindicator = ifelse(LeadOffIndicator == 1 & OutIndicator == 1, 1, 0),
1167
+ HBPIndicator = ifelse(PitchCall == "HitByPitch", 1, 0),
1168
  WalkIndicator = ifelse(KorBB == "Walk", 1, 0),
1169
+ LHHindicator = ifelse(BatterSide == "Left", 1, 0),
1170
+ RHHindicator = ifelse(BatterSide == "Right", 1, 0)
1171
  )
 
 
1172
  }
1173
 
1174
  # =====================================================================
1175
+ # HEADER STATS
1176
  # =====================================================================
1177
  calculate_tableau_header_stats <- function(pitcher_df) {
1178
  ab_data <- pitcher_df %>%
 
1183
 
1184
  at_bats <- nrow(ab_data)
1185
  hits <- sum(ab_data$HitIndicator, na.rm = TRUE)
1186
+ xbh <- sum(ab_data$PlayResult %in% c("Double","Triple","HomeRun"), na.rm = TRUE)
1187
  runs <- sum(pitcher_df$RunsScored, na.rm = TRUE)
1188
 
1189
  pa_data <- pitcher_df %>%
 
1192
  slice_tail(n = 1) %>%
1193
  ungroup()
1194
 
1195
+ bb <- sum(pa_data$WalkIndicator, na.rm = TRUE)
1196
  hbp <- sum(pa_data$HBPIndicator, na.rm = TRUE)
1197
+ so <- sum(pa_data$KorBB == "Strikeout", na.rm = TRUE)
1198
 
1199
  avg <- ifelse(at_bats > 0, round(hits / at_bats, 3), 0)
1200
 
 
1230
  }
1231
 
1232
  # =====================================================================
1233
+ # TABLE CALCS
1234
  # =====================================================================
1235
  get_valid_pitch_types <- function(pitcher_df) {
1236
  valid_types <- pitcher_df %>%
 
1238
  pull(TaggedPitchType) %>%
1239
  unique()
1240
 
1241
+ if (length(valid_types) == 1 && valid_types[1] == "Undefined") return(valid_types)
 
 
1242
 
1243
  filter_types <- valid_types[valid_types != "Undefined" & valid_types != "Other"]
1244
  if (length(filter_types) == 0) return("Undefined")
 
1252
  filter(TaggedPitchType %in% filter_types) %>%
1253
  group_by(TaggedPitchType) %>%
1254
  summarise(
1255
+ `Zone%` = paste0(round(100 * sum(StrikeZoneIndicator, na.rm = TRUE) / n(), 0), "%"),
1256
+ `Edge%` = paste0(round(100 * sum(EdgeIndicator, na.rm = TRUE) / n(), 0), "%"),
1257
  `Strike%` = paste0(round(100 * sum(StrikeIndicator, na.rm = TRUE) / n(), 0), "%"),
1258
+ `Whiff%` = ifelse(sum(SwingIndicator, na.rm = TRUE) > 0,
1259
+ paste0(round(100 * sum(WhiffIndicator, na.rm = TRUE) / sum(SwingIndicator, na.rm = TRUE), 0), "%"),
1260
+ "0%"),
1261
  .groups = "drop"
1262
  )
1263
  }
 
1271
  filter(TaggedPitchType %in% filter_types) %>%
1272
  group_by(TaggedPitchType) %>%
1273
  summarise(
1274
+ `Pitch Count` = n(),
1275
  `Usage vs. LHH` = paste0(round(100 * sum(LHHindicator, na.rm = TRUE) / max(1, lhh_pitches), 0), "%"),
1276
  `Usage vs. RHH` = paste0(round(100 * sum(RHHindicator, na.rm = TRUE) / max(1, rhh_pitches), 0), "%"),
1277
  .groups = "drop"
 
1289
  `Max. Velo` = round(max(RelSpeed, na.rm = TRUE), 1),
1290
  `Avg. Spin` = format(round(mean(SpinRate, na.rm = TRUE), 0), big.mark = ","),
1291
  `Max. Spin` = format(round(max(SpinRate, na.rm = TRUE), 0), big.mark = ","),
1292
+ `Avg. IVB` = round(mean(InducedVertBreak, na.rm = TRUE), 0),
1293
+ `Avg. HB` = round(mean(HorzBreak, na.rm = TRUE), 0),
1294
  .groups = "drop"
1295
  )
1296
  }
1297
 
1298
  calculate_tableau_release_data <- function(pitcher_df) {
1299
  primary <- pitcher_df %>%
1300
+ filter(TaggedPitchType %in% c("Fastball","Sinker","Four-Seam","FourSeamFastBall"))
1301
+
1302
+ fb_ht <- if (nrow(primary) > 0) mean(primary$RelHeight, na.rm = TRUE) else NA
1303
+ fb_side <- if (nrow(primary) > 0) mean(primary$RelSide, na.rm = TRUE) else NA
1304
  filter_types <- get_valid_pitch_types(pitcher_df)
1305
 
1306
  pitcher_df %>%
1307
  filter(TaggedPitchType %in% filter_types) %>%
1308
  group_by(TaggedPitchType) %>%
1309
  summarise(
1310
+ `Avg. Rel Ht` = round(mean(RelHeight, na.rm = TRUE), 2),
1311
+ `Rel Ht vs. FB` = ifelse(is.na(fb_ht), NA, round((mean(RelHeight, na.rm = TRUE) - fb_ht) * 12, 0)),
1312
+ `Avg. Rel Side` = round(mean(RelSide, na.rm = TRUE), 2),
1313
+ `Rel Side vs. FB` = ifelse(is.na(fb_side), NA, round((mean(RelSide, na.rm = TRUE) - fb_side) * 12, 0)),
1314
+ `Avg. Ext` = round(mean(Extension, na.rm = TRUE), 2),
1315
  .groups = "drop"
1316
  )
1317
  }
1318
 
1319
  # =====================================================================
1320
+ # PLOTS
1321
  # =====================================================================
1322
  create_tableau_location_plot <- function(pitcher_df, pitch_colors) {
1323
  filter_types <- get_valid_pitch_types(pitcher_df)
 
1327
  !is.na(PlateLocSide), !is.na(PlateLocHeight)) %>%
1328
  mutate(
1329
  ResultDisplay = case_when(
1330
+ PitchCall %in% c("BallCalled","BallinDirt") ~ "Ball",
1331
  PlayResult == "Double" ~ "2B",
1332
+ PitchCall %in% c("FoulBall","FoulBallNotFieldable") ~ "Foul",
1333
  PitchCall == "HitByPitch" ~ "HBP",
1334
+ PlayResult %in% c("Sacrifice","SacrificeFly") ~ "Sac",
1335
  PlayResult == "Single" ~ "1B",
1336
  PitchCall == "StrikeCalled" ~ "Called",
1337
  PitchCall == "StrikeSwinging" ~ "Whiff",
 
1376
  y = c(0.15, 0.15, 0.30, 0.50, 0.30), fill = NA, color = "black", linewidth = 0.5) +
1377
  annotate("segment", x = 0, xend = 0, y = 0.5, yend = shadow_top + 0.2,
1378
  color = "gray60", linetype = "dotted", linewidth = 0.3) +
1379
+ geom_point(aes(color = TaggedPitchType, shape = ResultDisplay), size = 2.6, stroke = 0.85) +
1380
  scale_color_manual(values = pitch_colors, name = "Pitch") +
1381
  scale_shape_manual(
1382
  values = c("Ball" = 1, "2B" = 18, "Foul" = 2, "HBP" = 8,
 
1408
  )
1409
  }
1410
 
 
 
 
1411
  create_tableau_movement_plot <- function(pitcher_df, pitch_colors) {
1412
  filter_types <- get_valid_pitch_types(pitcher_df)
1413
 
 
1424
  ggplot(df, aes(x = HorzBreak, y = InducedVertBreak)) +
1425
  geom_vline(xintercept = 0, color = "black", linewidth = 0.4) +
1426
  geom_hline(yintercept = 0, color = "black", linewidth = 0.4) +
1427
+ geom_point(aes(fill = TaggedPitchType), size = 3.2, alpha = 1,
1428
  shape = 21, color = "black", stroke = 0.9) +
1429
  scale_fill_manual(values = pitch_colors, drop = FALSE) +
1430
  coord_cartesian(xlim = c(-30, 30), ylim = c(-30, 30)) +
 
1434
  plot.title = element_text(size = 12, face = "bold", hjust = 0.5),
1435
  legend.position = "none",
1436
  axis.title = element_text(size = 10),
1437
+ axis.text = element_text(size = 9),
1438
  plot.margin = margin(2, 2, 2, 2)
1439
  )
1440
  }
1441
 
 
 
 
1442
  create_tableau_release_plot <- function(pitcher_df, pitch_colors) {
1443
  filter_types <- get_valid_pitch_types(pitcher_df)
1444
 
 
1465
  annotate("rect", xmin = -0.5, xmax = 0.5, ymin = 0.85, ymax = 1.05,
1466
  fill = "white", color = "gray40", linewidth = 0.3) +
1467
  geom_vline(xintercept = 0, color = "gray60", linetype = "dashed", linewidth = 0.3) +
1468
+ geom_point(aes(fill = TaggedPitchType), size = 3.2, alpha = 1,
1469
  shape = 21, color = "black", stroke = 0.9) +
1470
  scale_fill_manual(values = pitch_colors, drop = FALSE) +
1471
  coord_cartesian(xlim = c(-4, 4), ylim = c(0, 7)) +
 
1475
  plot.title = element_text(size = 12, face = "bold", hjust = 0.5),
1476
  legend.position = "none",
1477
  axis.title = element_text(size = 10),
1478
+ axis.text = element_text(size = 9),
1479
  plot.margin = margin(2, 2, 2, 2)
1480
  )
1481
  }
1482
 
1483
  # =====================================================================
1484
+ # TABLE DRAWING (BIGGER NUMBERS)
1485
  # =====================================================================
1486
  draw_tableau_table_fill <- function(
1487
  title,
1488
  data,
1489
+ rows,
1490
  pitch_types,
1491
  pitch_colors,
1492
+ x, y,
1493
+ width, height
1494
  ) {
1495
  if (is.null(pitch_types) || length(pitch_types) == 0) pitch_types <- "Undefined"
1496
 
 
1504
  col_w <- width / n_cols
1505
  row_h <- table_h / n_rows
1506
 
1507
+ # Bigger text than before (without exploding)
1508
+ header_cex <- max(0.85, min(1.45, row_h * 26))
1509
+ body_cex <- max(0.82, min(1.35, row_h * 24))
1510
+ label_cex <- max(0.82, min(1.35, row_h * 24))
1511
+ title_cex <- max(1.00, min(1.45, row_h * 28))
1512
 
1513
  grid.text(title, x = x + width/2, y = y,
1514
  gp = gpar(fontface = "bold", cex = title_cex, col = "#006F71"))
1515
 
1516
+ # Column headers
1517
  for (i in seq_along(pitch_types)) {
1518
  pt <- pitch_types[i]
1519
  col_color <- if (pt %in% names(pitch_colors)) pitch_colors[[pt]] else "#95A5A6"
 
1539
  gp = gpar(col = "white", cex = header_cex, fontface = "bold"))
1540
  }
1541
 
1542
+ # Data rows
1543
  row_names <- names(rows)
1544
  col_names <- as.character(rows)
1545
 
 
1571
  grid.text(val,
1572
  x = x + (i-1)*col_w + col_w/2,
1573
  y = y_row_top - row_h*0.55,
1574
+ gp = gpar(cex = body_cex, fontface = "plain"))
1575
  }
1576
  }
1577
  }
1578
 
1579
+ # =====================================================================
1580
+ # MAIN PDF FUNCTION (SINGLE, CORRECT)
1581
+ # =====================================================================
1582
  create_tableau_pitcher_pdf <- function(game_df, pitcher_name, output_file) {
1583
  if (length(dev.list()) > 0) try(dev.off(), silent = TRUE)
1584
 
1585
  pitcher_df <- process_tableau_pitcher_data(game_df) %>%
1586
+ filter(Pitcher == pitcher_name)
1587
 
1588
  if (nrow(pitcher_df) == 0) {
1589
  pdf(output_file, width = 8.5, height = 11)
 
1594
  return(output_file)
1595
  }
1596
 
1597
+ # Metadata
1598
  game_date <- tryCatch({
1599
  d <- unique(pitcher_df$Date)[1]
1600
  if (inherits(d, "Date")) format(d, "%m/%d/%Y") else as.character(d)
 
1604
  batter_teams <- batter_teams[!is.na(batter_teams)]
1605
  away_team <- if (length(batter_teams) > 0) batter_teams[1] else "Unknown"
1606
 
1607
+ # Stats + tables
1608
  stats <- calculate_tableau_header_stats(pitcher_df)
1609
  loc_data <- calculate_tableau_location_data(pitcher_df)
1610
  usage_data <- calculate_tableau_pitch_usage(pitcher_df)
1611
  velo_data <- calculate_tableau_velo_movement(pitcher_df)
1612
  rel_data <- calculate_tableau_release_data(pitcher_df)
1613
 
1614
+ # Pitch columns ordered by usage (Pitch Count desc), then append any missing pitch types
1615
+ pitch_types <- usage_data %>%
1616
+ arrange(desc(`Pitch Count`)) %>%
1617
+ pull(TaggedPitchType) %>%
1618
  unique()
1619
+ pitch_types <- pitch_types[!is.na(pitch_types) & pitch_types != ""]
1620
+ extras <- unique(c(loc_data$TaggedPitchType, velo_data$TaggedPitchType, rel_data$TaggedPitchType))
1621
+ extras <- extras[!is.na(extras) & extras != "" & !extras %in% pitch_types]
1622
+ pitch_types <- c(pitch_types, extras)
 
 
1623
  if (length(pitch_types) == 0) pitch_types <- "Undefined"
1624
 
1625
+ # Plots
1626
  loc_plot <- create_tableau_location_plot(pitcher_df, tableau_pitch_colors)
1627
  mov_plot <- create_tableau_movement_plot(pitcher_df, tableau_pitch_colors)
1628
  rel_plot <- create_tableau_release_plot(pitcher_df, tableau_pitch_colors)
1629
 
1630
+ # PDF
1631
  pdf(output_file, width = 8.5, height = 11)
1632
  on.exit(try(dev.off(), silent = TRUE), add = TRUE)
 
1633
  grid.newpage()
1634
 
1635
+ # ============================================================
1636
+ # HEADER BAR + INFO
1637
+ # ============================================================
1638
  grid.rect(x = 0, y = 0.955, width = 1, height = 0.045,
1639
  just = c("left", "bottom"),
1640
  gp = gpar(fill = "#006F71", col = NA))
 
1642
  grid.text("Pitcher Post-Game Report", x = 0.02, y = 0.977, just = "left",
1643
  gp = gpar(col = "white", fontface = "bold", cex = 1.2))
1644
 
 
1645
  info_y <- 0.935
1646
  grid.text(paste0(game_date, " | ", pitcher_name, " vs ", away_team),
1647
  x = 0.02, y = info_y, just = "left",
1648
+ gp = gpar(cex = 0.8, fontface = "bold"))
1649
 
1650
+ # ============================================================
1651
+ # HEADER STAT TILES (BIGGER + MORE READABLE + MOVED DOWN)
1652
+ # ============================================================
1653
+ stat_labels <- c("At Bats","H","XBH","R","BB/HBP","SO","AVG","Strike%","1st P K%","E+A%","Comp%","LOO%")
 
1654
  stat_values <- c(stats$at_bats, stats$hits, stats$xbh, stats$runs,
1655
  stats$bb_hbp, stats$so, stats$avg,
1656
  stats$strike_pct, stats$fp_k_pct, stats$ea_pct,
 
1659
  label_colors <- c("#E74C3C", "#3498DB", "#3498DB", "#27AE60", "#27AE60", "#27AE60",
1660
  "#00BCD4", "#F39C12", "#F39C12", "#F39C12", "#F39C12", "#F39C12")
1661
 
1662
+ # moved DOWN a bit vs prior (and slightly bigger)
1663
+ tiles_y <- 0.880
1664
+ tile_w <- 0.074
 
1665
  tile_h <- 0.060
1666
+ tile_gap <- 0.008
1667
+ x_start <- 0.02
1668
 
1669
+ band_h <- tile_h * 0.55
 
1670
 
1671
  for (i in seq_along(stat_labels)) {
1672
  x_pos <- x_start + (i - 1) * (tile_w + tile_gap)
 
1675
  grid.rect(
1676
  x = x_pos, y = tiles_y, width = tile_w, height = tile_h,
1677
  just = c("left", "center"),
1678
+ gp = gpar(fill = NA, col = label_colors[i], lwd = 2.2)
1679
  )
1680
 
1681
  # Top colored band
1682
  grid.rect(
1683
+ x = x_pos, y = tiles_y + (tile_h/2) - (band_h/2),
 
1684
  width = tile_w, height = band_h,
1685
  just = c("left", "center"),
1686
  gp = gpar(fill = label_colors[i], col = NA)
 
1688
 
1689
  # Bottom white area
1690
  grid.rect(
1691
+ x = x_pos, y = tiles_y - (tile_h/2) + ((tile_h - band_h)/2),
1692
+ width = tile_w, height = (tile_h - band_h),
 
1693
  just = c("left", "center"),
1694
+ gp = gpar(fill = "white", col = "black", lwd = 0.8)
1695
  )
1696
 
1697
+ # Label (white, bigger)
1698
  grid.text(
1699
  stat_labels[i],
1700
  x = x_pos + tile_w/2,
1701
  y = tiles_y + (tile_h/2) - (band_h/2),
1702
+ gp = gpar(col = "white", cex = 0.82, fontface = "bold")
1703
  )
1704
 
1705
+ # Value (big, very readable)
1706
  grid.text(
1707
  as.character(stat_values[i]),
1708
  x = x_pos + tile_w/2,
1709
+ y = tiles_y - (tile_h/2) + ((tile_h - band_h)/2),
1710
+ gp = gpar(col = "black", cex = 1.20, fontface = "bold")
1711
  )
1712
  }
1713
 
1714
+ # ============================================================
1715
+ # CHARTS (kept same formatting; nudged slightly down)
1716
+ # ============================================================
1717
+ pushViewport(viewport(x = 0.23, y = 0.655, width = 0.42, height = 0.30))
 
1718
  print(loc_plot, newpage = FALSE)
1719
  popViewport()
1720
 
1721
+ pushViewport(viewport(x = 0.23, y = 0.36, width = 0.40, height = 0.24))
1722
  print(mov_plot, newpage = FALSE)
1723
  popViewport()
1724
 
1725
+ pushViewport(viewport(x = 0.23, y = 0.12, width = 0.42, height = 0.24))
1726
  print(rel_plot, newpage = FALSE)
1727
  popViewport()
1728
 
1729
+ # ============================================================
1730
+ # TABLES (spacing fixed: Release title no overlap)
1731
+ # ============================================================
1732
+ table_x <- 0.58
 
 
 
1733
  table_w <- 0.41
1734
 
1735
  draw_tableau_table_fill(
 
1738
  rows = c("Zone%"="Zone%", "Edge%"="Edge%", "Strike%"="Strike%", "Whiff%"="Whiff%"),
1739
  pitch_types = pitch_types,
1740
  pitch_colors = tableau_pitch_colors,
1741
+ x = table_x, y = 0.835, width = table_w, height = 0.16
1742
  )
1743
 
1744
  draw_tableau_table_fill(
 
1747
  rows = c("Usage vs. LHH"="Usage vs. LHH", "Usage vs. RHH"="Usage vs. RHH", "Pitch Count"="Pitch Count"),
1748
  pitch_types = pitch_types,
1749
  pitch_colors = tableau_pitch_colors,
1750
+ x = table_x, y = 0.645, width = table_w, height = 0.15
1751
  )
1752
 
1753
+ # Velo moved slightly UP; release moved slightly DOWN so titles never overlap
1754
  draw_tableau_table_fill(
1755
  title = "Velo & Movement",
1756
  data = velo_data,
 
1759
  "Avg. IVB"="Avg. IVB", "Avg. HB"="Avg. HB"),
1760
  pitch_types = pitch_types,
1761
  pitch_colors = tableau_pitch_colors,
1762
+ x = table_x, y = 0.495, width = table_w, height = 0.21
1763
  )
1764
 
1765
  draw_tableau_table_fill(
 
1772
  "Ext"="Avg. Ext"),
1773
  pitch_types = pitch_types,
1774
  pitch_colors = tableau_pitch_colors,
1775
+ x = table_x, y = 0.235, width = table_w, height = 0.19
1776
  )
1777
 
1778
  invisible(output_file)