igroffman commited on
Commit
dd98fc4
·
verified ·
1 Parent(s): f82a6a5

Update app.R

Browse files
Files changed (1) hide show
  1. app.R +453 -1274
app.R CHANGED
@@ -1,1332 +1,511 @@
1
- # ============================================================================
2
- # COASTAL CAROLINA BASEBALL - DEFENSIVE ANALYTICS APPLICATION
3
- # ============================================================================
4
  library(shiny)
5
  library(DT)
6
- library(ggplot2)
7
- library(dplyr)
8
- library(plotly)
9
  library(gt)
10
- library(gtExtras)
11
- library(shinyWidgets)
12
- library(tidyr)
13
- library(purrr)
14
- library(stringr)
15
- library(fmsb)
16
- # ============================================================================
17
- # HELPER FUNCTIONS
18
- # ============================================================================
19
-
20
- make_curve_segments <- function(x_start, y_start, x_end, y_end, curvature = 0.3, n = 40) {
21
- if (n < 2) stop("n must be >= 2")
22
- t <- seq(0, 1, length.out = n)
23
-
24
- cx <- (x_start + x_end) / 2 + curvature * (y_end - y_start)
25
- cy <- (y_start + y_end) / 2 - curvature * (x_end - x_start)
26
-
27
- x <- (1 - t)^2 * x_start + 2 * (1 - t) * t * cx + t^2 * x_end
28
- y <- (1 - t)^2 * y_start + 2 * (1 - t) * t * cy + t^2 * y_end
29
-
30
- idx_from <- seq_len(n - 1)
31
- idx_to <- seq_len(n - 1) + 1
32
-
33
- tibble::tibble(
34
- x = x[idx_from],
35
- y = y[idx_from],
36
- xend = x[idx_to],
37
- yend = y[idx_to]
38
- )
39
- }
40
-
41
- curve1 <- make_curve_segments(89.095, 89.095, -1, 160, curvature = 0.36, n = 60)
42
- curve2 <- make_curve_segments(-89.095, 89.095, 1, 160, curvature = -0.36, n = 60)
43
- curve3 <- make_curve_segments(233.35, 233.35, -10, 410, curvature = 0.36, n = 60)
44
- curve4 <- make_curve_segments(-233.35, 233.35, 10, 410, curvature = -0.36, n = 60)
45
-
46
- # ============================================================================
47
- # SYNTHETIC DATA GENERATION
48
- # ============================================================================
49
-
50
- set.seed(42)
51
-
52
- # Team rosters
53
- cc_players <- c("Smith, John", "Johnson, Mike", "Williams, Chris", "Brown, David",
54
- "Jones, Tyler", "Garcia, Alex", "Martinez, Ryan", "Davis, Kyle",
55
- "Rodriguez, Sam", "Wilson, Jake")
56
-
57
- opponent_teams <- c("Georgia Southern", "Appalachian State", "Troy", "Texas State",
58
- "South Alabama", "Arkansas State", "Louisiana", "ULM")
59
-
60
- opponent_players <- c("Thompson, Mark", "Anderson, Jake", "Taylor, Ben", "Thomas, Cole",
61
- "Jackson, Drew", "White, Nick", "Harris, Luke", "Martin, Josh",
62
- "Moore, Zach", "Lee, Grant")
63
-
64
- positions <- c("RF", "CF", "LF")
65
- infield_positions <- c("SS", "1B", "2B", "3B")
66
-
67
- # Generate Outfield OAA data
68
- generate_of_oaa_data <- function(n = 500, team = "CCU") {
69
- players <- if(team == "CCU") cc_players else opponent_players
70
-
71
- tibble(
72
- PitchUID = paste0("P", 1:n),
73
- Date = sample(seq(as.Date("2025-02-14"), as.Date("2025-05-30"), by = "day"), n, replace = TRUE),
74
- obs_player = sample(players[1:4], n, replace = TRUE),
75
- hit_location = sample(positions, n, replace = TRUE),
76
- Batter = sample(opponent_players, n, replace = TRUE),
77
- Pitcher = sample(cc_players[5:8], n, replace = TRUE),
78
- Inning = sample(1:9, n, replace = TRUE),
79
- BatterTeam = if(team == "CCU") sample(opponent_teams, n, replace = TRUE) else "CCU",
80
- PitcherTeam = if(team == "CCU") "CCU" else sample(opponent_teams, n, replace = TRUE),
81
- closest_pos_dist = runif(n, 5, 120),
82
- HangTime = runif(n, 2.5, 5.5),
83
- x_pos = runif(n, 100, 350),
84
- z_pos = runif(n, -200, 200),
85
- CF_PositionAtReleaseX = runif(n, 280, 320),
86
- CF_PositionAtReleaseZ = runif(n, -20, 20),
87
- RF_PositionAtReleaseX = runif(n, 250, 290),
88
- RF_PositionAtReleaseZ = runif(n, 80, 140),
89
- LF_PositionAtReleaseX = runif(n, 250, 290),
90
- LF_PositionAtReleaseZ = runif(n, -140, -80),
91
- angle_from_home = runif(n, -180, 180),
92
- ExitSpeed = runif(n, 70, 110),
93
- Angle = runif(n, 15, 45)
94
- ) %>%
95
- mutate(
96
- # Calculate catch probability based on distance and hang time
97
- catch_prob = pmax(0, pmin(1, 1 - (closest_pos_dist / 150) + (HangTime - 3) * 0.2 + rnorm(n, 0, 0.1))),
98
- # Determine success based on probability with some randomness
99
- success_ind = rbinom(n, 1, pmin(0.95, pmax(0.05, catch_prob + rnorm(n, 0, 0.15)))),
100
- # Calculate OAA
101
- OAA = success_ind - catch_prob,
102
- star_group = case_when(
103
- catch_prob >= 0.90 ~ "1 Star",
104
- catch_prob >= 0.70 ~ "2 Star",
105
- catch_prob >= 0.50 ~ "3 Star",
106
- catch_prob >= 0.25 ~ "4 Star",
107
- TRUE ~ "5 Star"
108
- )
109
- )
110
- }
111
 
112
- # Generate Infield OAA data
113
- generate_if_oaa_data <- function(n = 400, team = "CCU") {
114
- players <- if(team == "CCU") cc_players else opponent_players
115
-
116
- tibble(
117
- PitchUID = paste0("IF", 1:n),
118
- Date = sample(seq(as.Date("2025-02-14"), as.Date("2025-05-30"), by = "day"), n, replace = TRUE),
119
- obs_player_name = sample(players[5:8], n, replace = TRUE),
120
- obs_player = sample(infield_positions, n, replace = TRUE),
121
- Batter = sample(opponent_players, n, replace = TRUE),
122
- Pitcher = sample(cc_players[1:4], n, replace = TRUE),
123
- Inning = sample(1:9, n, replace = TRUE),
124
- BatterTeam = if(team == "CCU") sample(opponent_teams, n, replace = TRUE) else "CCU",
125
- Distance = runif(n, 50, 150),
126
- Bearing = runif(n, -45, 45),
127
- ExitSpeed = runif(n, 60, 110),
128
- Angle = runif(n, -10, 30),
129
- HangTime = runif(n, 1.5, 4.0),
130
- obs_player_bearing = runif(n, -45, 45),
131
- obs_player_x = runif(n, 80, 130),
132
- obs_player_z = runif(n, -60, 60),
133
- Direction = sample(c("Pull", "Center", "Oppo"), n, replace = TRUE)
134
- ) %>%
135
- mutate(
136
- obs_player_bearing_diff = Bearing - obs_player_bearing,
137
- dist_from_lead_base = sqrt((Distance * cos(Bearing * pi/180) - 90)^2 + (Distance * sin(Bearing * pi/180))^2),
138
- player_angle_rad = atan2(obs_player_z, obs_player_x),
139
- # Calculate play probability
140
- play_prob = pmax(0, pmin(1, 0.8 - abs(obs_player_bearing_diff) * 0.02 - Distance * 0.003 + rnorm(n, 0, 0.1))),
141
- success_ind = rbinom(n, 1, pmin(0.95, pmax(0.05, play_prob + rnorm(n, 0, 0.12)))),
142
- OAA = success_ind - play_prob,
143
- star_group = case_when(
144
- play_prob >= 0.90 ~ "1 Star",
145
- play_prob >= 0.70 ~ "2 Star",
146
- play_prob >= 0.50 ~ "3 Star",
147
- play_prob >= 0.25 ~ "4 Star",
148
- TRUE ~ "5 Star"
149
- )
150
- )
151
- }
152
 
153
- # Generate Positioning data
154
- generate_positioning_data <- function(n = 800) {
155
- tibble(
156
- PitchUID = paste0("POS", 1:n),
157
- Date = sample(seq(as.Date("2025-02-14"), as.Date("2025-05-30"), by = "day"), n, replace = TRUE),
158
- PitcherTeam = sample(c("CCU", opponent_teams), n, replace = TRUE),
159
- BatterTeam = sample(c("CCU", opponent_teams), n, replace = TRUE),
160
- Pitcher = sample(c(cc_players, opponent_players), n, replace = TRUE),
161
- Batter = sample(c(cc_players, opponent_players), n, replace = TRUE),
162
- BatterSide = sample(c("Right", "Left"), n, replace = TRUE, prob = c(0.6, 0.4)),
163
- PitcherThrows = sample(c("Right", "Left"), n, replace = TRUE, prob = c(0.7, 0.3)),
164
- Outs = sample(0:2, n, replace = TRUE),
165
- pitch_count = sample(c("0-0", "0-1", "0-2", "1-0", "1-1", "1-2", "2-0", "2-1", "2-2", "3-0", "3-1", "3-2"), n, replace = TRUE),
166
- man_on_firstbase = sample(c(0, 1), n, replace = TRUE, prob = c(0.7, 0.3)),
167
- man_on_secondbase = sample(c(0, 1), n, replace = TRUE, prob = c(0.8, 0.2)),
168
- man_on_thirdbase = sample(c(0, 1), n, replace = TRUE, prob = c(0.9, 0.1)),
169
- away_team = sample(opponent_teams, n, replace = TRUE),
170
- home_score_before = sample(0:8, n, replace = TRUE),
171
- away_score_before = sample(0:8, n, replace = TRUE),
172
- `1B_PositionAtReleaseX` = rnorm(n, 85, 8),
173
- `1B_PositionAtReleaseZ` = rnorm(n, 45, 10),
174
- `2B_PositionAtReleaseX` = rnorm(n, 115, 10),
175
- `2B_PositionAtReleaseZ` = rnorm(n, 35, 12),
176
- `3B_PositionAtReleaseX` = rnorm(n, 85, 8),
177
- `3B_PositionAtReleaseZ` = rnorm(n, -45, 10),
178
- `SS_PositionAtReleaseX` = rnorm(n, 115, 10),
179
- `SS_PositionAtReleaseZ` = rnorm(n, -35, 12),
180
- `LF_PositionAtReleaseX` = rnorm(n, 270, 15),
181
- `LF_PositionAtReleaseZ` = rnorm(n, -110, 20),
182
- `CF_PositionAtReleaseX` = rnorm(n, 300, 15),
183
- `CF_PositionAtReleaseZ` = rnorm(n, 0, 25),
184
- `RF_PositionAtReleaseX` = rnorm(n, 270, 15),
185
- `RF_PositionAtReleaseZ` = rnorm(n, 110, 20)
186
  )
187
- }
188
-
189
- # Generate Catcher data
190
- generate_catcher_data <- function(n = 1000) {
191
- catchers <- c("Martinez, Ryan", "Wilson, Jake")
192
-
193
- tibble(
194
- PitchUID = paste0("C", 1:n),
195
- Date = sample(seq(as.Date("2025-02-14"), as.Date("2025-05-30"), by = "day"), n, replace = TRUE),
196
- Catcher = sample(catchers, n, replace = TRUE),
197
- Pitcher = sample(cc_players[1:6], n, replace = TRUE),
198
- Batter = sample(opponent_players, n, replace = TRUE),
199
- BatterTeam = sample(opponent_teams, n, replace = TRUE),
200
- TaggedPitchType = sample(c("Fastball", "Sinker", "Slider", "Curveball", "ChangeUp", "Cutter"), n, replace = TRUE, prob = c(0.35, 0.15, 0.2, 0.12, 0.12, 0.06)),
201
- PitchCall = sample(c("BallCalled", "StrikeCalled", "StrikeSwinging", "InPlay", "FoulBall"), n, replace = TRUE, prob = c(0.3, 0.25, 0.15, 0.15, 0.15)),
202
- PlateLocSide = rnorm(n, 0, 0.8),
203
- PlateLocHeight = rnorm(n, 2.5, 0.6),
204
- RelSpeed = rnorm(n, 88, 6),
205
- InducedVertBreak = rnorm(n, 12, 5),
206
- HorzBreak = rnorm(n, 5, 8),
207
- Balls = sample(0:3, n, replace = TRUE),
208
- Strikes = sample(0:2, n, replace = TRUE),
209
- PopTime = ifelse(runif(n) > 0.85, runif(n, 1.85, 2.15), NA),
210
- ThrowSpeed = ifelse(!is.na(PopTime), rnorm(sum(!is.na(PopTime)), 78, 4), NA),
211
- ExchangeTime = ifelse(!is.na(PopTime), rnorm(sum(!is.na(PopTime)), 0.75, 0.08), NA),
212
- TimeToBase = ifelse(!is.na(PopTime), PopTime - ExchangeTime, NA),
213
- BasePositionZ = ifelse(!is.na(PopTime), rnorm(sum(!is.na(PopTime)), 0, 2), NA),
214
- BasePositionY = ifelse(!is.na(PopTime), rnorm(sum(!is.na(PopTime)), 5, 1.5), NA),
215
- Notes = ifelse(!is.na(PopTime), sample(c("2b out", "2b safe", "3b out", "3b safe"), sum(!is.na(PopTime)), replace = TRUE, prob = c(0.4, 0.3, 0.2, 0.1)), NA)
216
- ) %>%
217
- mutate(
218
- in_zone = as.integer(PlateLocSide >= -0.83 & PlateLocSide <= 0.83 & PlateLocHeight >= 1.5 & PlateLocHeight <= 3.38),
219
- is_swing = as.integer(PitchCall %in% c("StrikeSwinging", "InPlay", "FoulBall")),
220
- StolenStrike = as.integer(in_zone == 0 & PitchCall == "StrikeCalled"),
221
- StrikeLost = as.integer(in_zone == 1 & PitchCall == "BallCalled"),
222
- frame = case_when(
223
- in_zone == 1 & PitchCall == "BallCalled" ~ "Strike Lost",
224
- in_zone == 0 & PitchCall == "StrikeCalled" ~ "Strike Added",
225
- TRUE ~ NA_character_
226
- ),
227
- frame_numeric = case_when(
228
- in_zone == 1 & PitchCall == "BallCalled" ~ -1,
229
- in_zone == 0 & PitchCall == "StrikeCalled" ~ 1,
230
- TRUE ~ NA_real_
231
- ),
232
- Count = paste0(Balls, "-", Strikes)
233
- )
234
- }
235
-
236
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
237
 
238
- # Initialize datasets
239
- OAA_DF <- generate_of_oaa_data(500, "CCU")
240
- IF_OAA <- generate_if_oaa_data(400, "CCU")
241
- OPP_OAA_DF <- generate_of_oaa_data(500, "OPP")
242
- OPP_IF_OAA <- generate_if_oaa_data(400, "OPP")
243
- def_pos_data <- generate_positioning_data(800)
244
- catcher_data <- generate_catcher_data(1000)
245
 
246
- # ============================================================================
247
- # VISUALIZATION FUNCTIONS
248
- # ============================================================================
249
 
250
- # Star graph for outfielders
251
- star_graph <- function(player, position, data = OAA_DF) {
252
- if (position %in% c("RF", "CF", "LF")) {
253
- player_data <- data %>%
254
- filter(obs_player == player) %>%
255
- filter(hit_location == position)
256
- } else {
257
- player_data <- data %>%
258
- filter(obs_player == player) %>%
259
- mutate(hit_location = "All")
260
- }
261
-
262
- if (nrow(player_data) == 0) {
263
- return(ggplotly(ggplot() + theme_void() + ggtitle("No data available")))
264
- }
265
-
266
- OAA <- round(sum(player_data$OAA, na.rm = TRUE), 1)
267
- n_plays <- nrow(player_data)
268
- expected_success <- round(sum(player_data$catch_prob, na.rm = TRUE), 1)
269
- actual_success <- sum(player_data$success_ind, na.rm = TRUE)
270
-
271
- p <- ggplot(data, aes(closest_pos_dist, HangTime)) +
272
- stat_summary_2d(aes(z = catch_prob), fun = mean, bins = 51) +
273
- scale_fill_gradientn(
274
- colors = c("white", "#ffffb2", "#feb24c", "#fc4e2a", "#800026"),
275
- limits = c(0, 1),
276
- breaks = c(0.01, 0.25, 0.5, 0.7, 0.9, 1),
277
- labels = c("5 Star", "4 Star", "3 Star", "2 Star", "1 Star", " "),
278
- name = "Catch Rating",
279
- guide = guide_colorbar(barheight = unit(5, "in"))
280
- ) +
281
- scale_x_continuous("Distance to Ball (ft)", limits = c(0, 140), breaks = seq(0, 140, by = 20)) +
282
- scale_y_continuous("Hang Time (sec)", limits = c(2, 6)) +
283
- theme_classic() +
284
- ggtitle(paste0(player, " - ", position, " (", OAA, " OAA)")) +
285
- theme(
286
- legend.position = "right",
287
- plot.title = element_text(hjust = .5, size = 20),
288
- legend.title = element_blank(),
289
- panel.grid.minor = element_blank(),
290
- axis.title = element_text(size = 12),
291
- axis.text = element_text(size = 10)
292
- )
293
-
294
- point_colors <- ifelse(player_data$success_ind == 1, "green4", "white")
295
-
296
- p_interactive <- suppressWarnings({
297
- ggplotly(p, tooltip = NULL) %>%
298
- add_trace(
299
- data = player_data,
300
- x = ~closest_pos_dist,
301
- y = ~HangTime,
302
- type = "scatter",
303
- mode = "markers",
304
- color = I(point_colors),
305
- marker = list(size = 10, line = list(color = "black", width = 1)),
306
- text = ~paste0(
307
- "Date: ", Date, "<br>",
308
- "Batter: ", Batter, "<br>",
309
- "Pitcher: ", Pitcher, "<br>",
310
- "Inning: ", Inning, "<br>",
311
- "Opportunity Time: ", round(HangTime, 2), " sec<br>",
312
- "Distance Needed: ", round(closest_pos_dist, 1), " ft<br>",
313
- "Catch Probability: ", round(100 * catch_prob, 1), "%<br>",
314
- star_group
315
- ),
316
- hoverinfo = "text"
317
- ) %>%
318
- layout(
319
- hovermode = "closest",
320
- title = list(
321
- text = paste0(player, " - ", position,
322
- "<br><sup>OAA: ", OAA, " | Plays: ", n_plays,
323
- " | Expected Catches: ", expected_success, " | Actual Catches: ", actual_success, "</sup>"),
324
- font = list(size = 18)
325
  ),
326
- margin = list(t = 80)
 
 
 
 
 
 
 
 
 
 
 
 
327
  )
328
- })
329
-
330
- return(p_interactive)
331
- }
332
-
333
- # Field graph for outfielders
334
- field_graph <- function(player, position, data = OAA_DF) {
335
- if (position %in% c("RF", "CF", "LF")) {
336
- player_data <- data %>%
337
- filter(obs_player == player) %>%
338
- filter(hit_location == position)
339
- } else {
340
- player_data <- data %>%
341
- filter(obs_player == player) %>%
342
- mutate(hit_location = "All")
343
- }
344
-
345
- if (nrow(player_data) == 0) {
346
- return(list(
347
- ggplotly(ggplot() + theme_void() + ggtitle("No data available")),
348
- gt(data.frame(Message = "No data")) %>% tab_header(title = "Play Success by Difficulty")
349
- ))
350
- }
351
-
352
- df_filtered <- player_data %>%
353
- mutate(
354
- avg_y = mean(CF_PositionAtReleaseX, na.rm = TRUE),
355
- avg_x = mean(CF_PositionAtReleaseZ, na.rm = TRUE)
356
- )
357
-
358
- df_table <- df_filtered %>%
359
- group_by(star_group) %>%
360
- summarize(
361
- OAA = round(sum(OAA, na.rm = TRUE), 1),
362
- Plays = n(),
363
- Successes = sum(success_ind, na.rm = TRUE),
364
- .groups = "drop"
365
- ) %>%
366
- mutate(`Success Rate` = paste0(Successes, "/", Plays, " (", round((Successes/Plays)*100, 1), "%)")) %>%
367
- dplyr::select(`Star Group` = star_group, `Success Rate`) %>%
368
- t() %>% as.data.frame() %>%
369
- `colnames<-`(.[1,]) %>% tibble::as_tibble() %>% dplyr::slice(-1) %>%
370
- mutate(OAA = round(sum(df_filtered$OAA, na.rm = TRUE), 2)) %>%
371
- dplyr::select(OAA, everything()) %>%
372
- gt::gt() %>%
373
- gtExtras::gt_theme_guardian() %>%
374
- gt::tab_header(title = "Play Success by Difficulty") %>%
375
- gt::cols_align(align = "center", columns = gt::everything()) %>%
376
- gt::sub_missing(columns = gt::everything(), missing_text = "-") %>%
377
- gt::tab_options(
378
- heading.background.color = "darkcyan",
379
- column_labels.background.color = "darkcyan",
380
- table.border.top.color = "peru",
381
- table.border.bottom.color = "peru"
382
- ) %>%
383
- gt::tab_style(style = gt::cell_text(color = "white"), locations = gt::cells_title(groups = "title"))
384
-
385
- p <- ggplot() +
386
- geom_segment(aes(x = 0, y = 0, xend = 318.1981, yend = 318.1981), color = "black") +
387
- geom_segment(aes(x = 0, y = 0, xend = -318.1981, yend = 318.1981), color = "black") +
388
- geom_segment(aes(x = 63.6396, y = 63.6396, xend = 0, yend = 127.279), color = "black") +
389
- geom_segment(aes(x = -63.6396, y = 63.6396, xend = 0, yend = 127.279), color = "black") +
390
- geom_segment(data = curve1, aes(x = x, y = y, xend = xend, yend = yend), size = 0.5, color = "black", lineend = "round") +
391
- geom_segment(data = curve2, aes(x = x, y = y, xend = xend, yend = yend), size = 0.5, color = "black", lineend = "round") +
392
- annotate("text", x = c(-155, 155), y = 135, label = "200", size = 3) +
393
- annotate("text", x = c(-190, 190), y = 170, label = "250", size = 3) +
394
- annotate("text", x = c(-227, 227), y = 205, label = "300", size = 3) +
395
- annotate("text", x = c(-262, 262), y = 242, label = "350", size = 3) +
396
- theme_void() +
397
- geom_point(
398
- data = df_filtered %>% mutate(is_catch = ifelse(success_ind == 1, 'Catch', "Hit")),
399
- aes(x = z_pos, y = x_pos, fill = is_catch,
400
- text = paste0("Date: ", Date, "<br>",
401
- "Catch Probability: ", round(100 * catch_prob, 1), "%<br>", star_group)),
402
- color = "black", shape = 21, size = 2, alpha = .6
403
- ) +
404
- labs(title = paste0(player, " Possible Catches - ", position)) +
405
- scale_fill_manual(values = c("Hit" = "white", "Catch" = "green4"), name = " ") +
406
- geom_point(data = df_filtered, aes(x = avg_x, y = avg_y), fill = "red", color = "black", size = 3, shape = 21) +
407
- coord_cartesian(xlim = c(-330, 330), ylim = c(0, 400)) +
408
- coord_equal()
409
-
410
- p <- ggplotly(p, tooltip = "text") %>% plotly::layout(hovermode = "closest")
411
-
412
- return(list(p, df_table))
413
- }
414
 
415
- # Infield star graph
416
- infield_star_graph <- function(player, position, data = IF_OAA) {
417
- if (position %in% c("SS", "1B", "2B", "3B")) {
418
- player_data <- data %>%
419
- filter(obs_player_name == player) %>%
420
- filter(obs_player == position)
421
- } else {
422
- player_data <- data %>%
423
- filter(obs_player_name == player) %>%
424
- mutate(obs_player = "All")
425
- }
426
-
427
- if (nrow(player_data) == 0) {
428
- return(ggplotly(ggplot() + theme_void() + ggtitle("No data available")))
429
- }
430
-
431
- OAA <- round(sum(player_data$OAA, na.rm = TRUE), 1)
432
- n_plays <- nrow(player_data)
433
- expected_success <- round(sum(player_data$play_prob, na.rm = TRUE), 1)
434
- actual_success <- sum(player_data$success_ind, na.rm = TRUE)
435
-
436
- points_data <- player_data %>%
437
- filter(play_prob > 0) %>%
438
- filter(play_prob <= 0.5 | between(obs_player_bearing_diff, -12.5, 12.5))
439
-
440
- p <- ggplot(data, aes(obs_player_bearing_diff, play_prob)) +
441
- stat_summary_2d(aes(z = play_prob), fun = mean, bins = 15) +
442
- scale_fill_gradientn(
443
- colors = c("white", "#ffffb2", "#feb24c", "#fc4e2a", "#800026"),
444
- limits = c(0, 1),
445
- breaks = c(0.01, 0.25, 0.5, 0.7, 0.9, 1),
446
- labels = c("5 Star", "4 Star", "3 Star", "2 Star", "1 Star", " "),
447
- name = "Play Rating"
448
- ) +
449
- scale_x_continuous("Bearing Difference (degrees)", limits = c(-25, 25), breaks = seq(-25, 25, by = 12.5)) +
450
- scale_y_continuous("Play Probability", limits = c(0, 1), labels = scales::percent) +
451
- theme_minimal() +
452
- theme(
453
- legend.position = "right",
454
- plot.title = element_text(hjust = 0.5, size = 18, face = "bold"),
455
- panel.grid.minor = element_blank(),
456
- axis.title = element_text(size = 12),
457
- axis.text = element_text(size = 10)
458
- )
459
-
460
- point_colors <- ifelse(points_data$success_ind == 1, "green4", "white")
461
-
462
- p_interactive <- suppressWarnings({
463
- ggplotly(p, tooltip = NULL) %>%
464
- add_trace(
465
- data = points_data,
466
- x = ~obs_player_bearing_diff,
467
- y = ~play_prob,
468
- type = "scatter",
469
- mode = "markers",
470
- color = I(point_colors),
471
- marker = list(size = 10, line = list(color = "black", width = 1)),
472
- text = ~paste0(
473
- "Date: ", Date, "<br>",
474
- "Batter: ", Batter, "<br>",
475
- "Play Probability: ", round(100 * play_prob, 1), "%<br>",
476
- "Angular Distance Away: ", round(obs_player_bearing_diff, 1), "°<br>",
477
- "Exit Velocity: ", round(ExitSpeed, 1), " mph<br>",
478
- star_group
479
- ),
480
- hoverinfo = "text"
481
- ) %>%
482
- layout(
483
- hovermode = "closest",
484
- title = list(
485
- text = paste0(player, " - ", position,
486
- "<br><sup>OAA: ", OAA, " | Plays: ", n_plays,
487
- " | Expected: ", expected_success, " | Actual: ", actual_success, "</sup>"),
488
- font = list(size = 18)
489
  ),
490
- margin = list(t = 80)
 
 
 
 
 
 
 
 
491
  )
492
- })
493
-
494
- suppressWarnings(p_interactive)
495
- }
496
-
497
- # Infield field graph
498
- infield_field_graph <- function(player, position, data = IF_OAA) {
499
- if (position %in% c("SS", "1B", "2B", "3B")) {
500
- player_data <- data %>%
501
- filter(obs_player_name == player) %>%
502
- filter(obs_player == position)
503
- } else {
504
- player_data <- data %>%
505
- filter(obs_player_name == player)
506
- }
507
-
508
- if (nrow(player_data) == 0) {
509
- return(list(
510
- ggplotly(ggplot() + theme_void() + ggtitle("No data available")),
511
- gt(data.frame(Message = "No data")) %>% tab_header(title = "Play Success by Difficulty")
512
- ))
513
- }
514
-
515
- df_filtered <- player_data %>%
516
- mutate(
517
- rad = Bearing * pi/180,
518
- x_pos = Distance * cos(rad),
519
- z_pos = Distance * sin(rad),
520
- avg_y = mean(obs_player_x, na.rm = TRUE),
521
- avg_x = mean(obs_player_z, na.rm = TRUE)
522
- )
523
-
524
- df_table <- df_filtered %>%
525
- group_by(star_group) %>%
526
- summarize(
527
- OAA = round(sum(OAA, na.rm = TRUE), 1),
528
- Plays = n(),
529
- Successes = sum(success_ind, na.rm = TRUE),
530
- .groups = "drop"
531
- ) %>%
532
- mutate(`Success Rate` = paste0(Successes, "/", Plays, " (", round((Successes/Plays)*100, 1), "%)")) %>%
533
- dplyr::select(`Star Group` = star_group, `Success Rate`) %>%
534
- t() %>% as.data.frame() %>%
535
- `colnames<-`(.[1,]) %>% dplyr::slice(-1) %>%
536
- mutate(OAA = round(sum(df_filtered$OAA, na.rm = TRUE), 1)) %>%
537
- dplyr::select(OAA, everything()) %>%
538
- gt::gt() %>%
539
- gtExtras::gt_theme_guardian() %>%
540
- gt::tab_header(title = "Play Success by Difficulty") %>%
541
- gt::cols_align(align = "center", columns = gt::everything()) %>%
542
- gt::sub_missing(columns = gt::everything(), missing_text = "-") %>%
543
- gt::tab_options(
544
- heading.background.color = "darkcyan",
545
- column_labels.background.color = "darkcyan"
546
- ) %>%
547
- gt::tab_style(style = gt::cell_text(color = "white"), locations = gt::cells_title(groups = "title"))
548
-
549
- p <- ggplot() +
550
- geom_segment(aes(x = 0, y = 0, xend = 100, yend = 100), color = "black") +
551
- geom_segment(aes(x = 0, y = 0, xend = -100, yend = 100), color = "black") +
552
- geom_segment(aes(x = 63.6396, y = 63.6396, xend = 0, yend = 127.279), color = "black") +
553
- geom_segment(aes(x = -63.6396, y = 63.6396, xend = 0, yend = 127.279), color = "black") +
554
- geom_segment(data = curve1, aes(x = x, y = y, xend = xend, yend = yend), size = 0.5, color = "black") +
555
- geom_segment(data = curve2, aes(x = x, y = y, xend = xend, yend = yend), size = 0.5, color = "black") +
556
- theme_void() +
557
- geom_point(
558
- data = df_filtered %>% mutate(is_play = ifelse(success_ind == 1, 'Play Made', "Not Made")),
559
- aes(x = z_pos, y = x_pos, fill = is_play,
560
- text = paste0("Date: ", Date, "<br>", "Play Probability: ", round(100 * play_prob, 1), "%<br>", star_group)),
561
- color = "black", shape = 21, size = 2, alpha = .6
562
- ) +
563
- labs(title = paste0(player, " Possible Plays - ", position)) +
564
- scale_fill_manual(values = c("Not Made" = "white", "Play Made" = "green4"), name = " ") +
565
- geom_point(data = df_filtered, aes(x = avg_x, y = avg_y), fill = "red", color = "black", size = 3, shape = 21) +
566
- ylim(0, 160) + xlim(-120, 120) + coord_equal()
567
-
568
- p <- ggplotly(p, tooltip = "text") %>% plotly::layout(hovermode = "closest")
569
-
570
- return(list(p, df_table))
571
- }
572
-
573
- # Positioning heatmap
574
- infield_positioning_heatmap <- function(team, batter_hand = "No Filter", pitcher_hand = "No Filter",
575
- man_on_first = "No Filter", man_on_second = "No Filter", man_on_third = "No Filter") {
576
-
577
- team_data <- def_pos_data %>% filter(PitcherTeam == team)
578
-
579
- if (batter_hand != "No Filter") team_data <- team_data %>% filter(BatterSide == batter_hand)
580
- if (pitcher_hand != "No Filter") team_data <- team_data %>% filter(PitcherThrows == pitcher_hand)
581
- if (man_on_first != "No Filter") team_data <- team_data %>% filter(man_on_firstbase == as.numeric(man_on_first == "Yes"))
582
- if (man_on_second != "No Filter") team_data <- team_data %>% filter(man_on_secondbase == as.numeric(man_on_second == "Yes"))
583
- if (man_on_third != "No Filter") team_data <- team_data %>% filter(man_on_thirdbase == as.numeric(man_on_third == "Yes"))
584
-
585
- n_observations <- nrow(team_data)
586
-
587
- if (n_observations < 10) {
588
- return(ggplot() + theme_void() + ggtitle("Insufficient data for selected filters"))
589
- }
590
-
591
- positions_long <- team_data %>%
592
- pivot_longer(
593
- cols = matches("^(1B|2B|3B|SS|LF|CF|RF)_PositionAtRelease[XZ]$"),
594
- names_to = c("position", ".value"),
595
- names_pattern = "^(.+)_PositionAtRelease([XZ])$"
596
- )
597
-
598
- p <- ggplot() +
599
- geom_hline(yintercept = seq(0, 400, by = 20), linetype = "dotted", color = "gray80", size = 0.3) +
600
- geom_vline(xintercept = seq(-240, 240, by = 20), linetype = "dotted", color = "gray80", size = 0.3) +
601
- geom_density_2d_filled(data = positions_long, aes(x = Z, y = X), bins = 35, alpha = 0.5) +
602
- scale_fill_manual(values = colorRampPalette(c("#FFFFFF", "#FFF5F0", "#FEE0D2", "#FCBBA1",
603
- "#FC9272", "#FB6A4A", "#EF3B2C", "#CB181D", "#99000D"))(35)) +
604
- geom_segment(aes(x = 0, y = 0, xend = 233.35, yend = 233.35), color = "black") +
605
- geom_segment(aes(x = 0, y = 0, xend = -233.35, yend = 233.35), color = "black") +
606
- geom_segment(aes(x = 63.6396, y = 63.6396, xend = 0, yend = 127.279), color = "black") +
607
- geom_segment(aes(x = -63.6396, y = 63.6396, xend = 0, yend = 127.279), color = "black") +
608
- geom_segment(data = curve1, aes(x = x, y = y, xend = xend, yend = yend), size = 0.5, color = "black") +
609
- geom_segment(data = curve2, aes(x = x, y = y, xend = xend, yend = yend), size = 0.5, color = "black") +
610
- geom_segment(data = curve3, aes(x = x, y = y, xend = xend, yend = yend), size = 0.5, color = "black") +
611
- geom_segment(data = curve4, aes(x = x, y = y, xend = xend, yend = yend), size = 0.5, color = "black") +
612
- labs(
613
- title = paste0("Starting Fielder Position — ", team),
614
- subtitle = paste0("Based on ", n_observations, " observations"),
615
- x = "", y = ""
616
- ) +
617
- ylim(0, 411) + xlim(-250, 250) + coord_equal() +
618
- theme_void() +
619
- theme(
620
- legend.position = "none",
621
- plot.title = element_text(hjust = .5, size = 20, face = "bold"),
622
- plot.subtitle = element_text(hjust = 0.5)
623
- )
624
-
625
- return(p)
626
- }
627
-
628
- # ============================================================================
629
- # UI
630
- # ============================================================================
631
 
632
- ui <- fluidPage(
633
- tags$head(
634
- tags$style(HTML("
635
- @import url('https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700;800&display=swap');
636
-
637
- body {
638
- font-family: 'Outfit', sans-serif;
639
- background: linear-gradient(135deg, #f5f7fa 0%, #e4e9f2 100%);
640
- color: #2c3e50;
641
- }
642
-
643
- .app-header {
644
- display: flex;
645
- justify-content: center;
646
- align-items: center;
647
- padding: 25px 40px;
648
- background: linear-gradient(135deg, #006F71 0%, #008B8B 50%, #20B2AA 100%);
649
- border-bottom: 4px solid #A27752;
650
- margin-bottom: 25px;
651
- box-shadow: 0 8px 32px rgba(0, 111, 113, 0.3);
652
- }
653
-
654
- .header-title {
655
- color: white;
656
- font-size: 2.2rem;
657
- font-weight: 800;
658
- text-shadow: 2px 2px 4px rgba(0,0,0,0.2);
659
- letter-spacing: 1px;
660
- }
661
-
662
- .header-subtitle {
663
- color: rgba(255,255,255,0.9);
664
- font-size: 1rem;
665
- font-weight: 400;
666
- margin-top: 5px;
667
- }
668
-
669
- .nav-tabs {
670
- border: none !important;
671
- border-radius: 50px;
672
- padding: 8px 16px;
673
- margin: 20px auto;
674
- max-width: 95%;
675
- background: linear-gradient(135deg, #d4edeb 0%, #e8ddd0 50%, #d4edeb 100%);
676
- box-shadow: 0 4px 20px rgba(0,139,139,.15), inset 0 2px 4px rgba(255,255,255,.7);
677
- display: flex;
678
- justify-content: center;
679
- flex-wrap: wrap;
680
- gap: 8px;
681
- }
682
-
683
- .nav-tabs > li > a {
684
- color: #006F71 !important;
685
- border: none !important;
686
- border-radius: 50px !important;
687
- background: transparent !important;
688
- font-weight: 600;
689
- font-size: 15px;
690
- padding: 12px 28px;
691
- transition: all 0.3s ease;
692
- letter-spacing: 0.3px;
693
- }
694
-
695
- .nav-tabs > li > a:hover {
696
- color: #004d4e !important;
697
- background: rgba(255,255,255,0.6) !important;
698
- transform: translateY(-2px);
699
- }
700
-
701
- .nav-tabs > li.active > a,
702
- .nav-tabs > li.active > a:focus,
703
- .nav-tabs > li.active > a:hover {
704
- background: linear-gradient(135deg, #006F71 0%, #008B8B 50%, #20B2AA 100%) !important;
705
- color: #fff !important;
706
- text-shadow: 0 1px 2px rgba(0,0,0,0.2);
707
- box-shadow: 0 6px 20px rgba(0,139,139,0.4);
708
- border: none !important;
709
- }
710
-
711
- .tab-content {
712
- background: white;
713
- border-radius: 20px;
714
- padding: 30px;
715
- margin-top: 15px;
716
- box-shadow: 0 10px 40px rgba(0,0,0,0.08);
717
- border: 1px solid rgba(0,139,139,0.1);
718
- }
719
-
720
- .well {
721
- background: linear-gradient(135deg, #f8fffe 0%, #f5f5f0 100%);
722
- border-radius: 15px;
723
- border: 1px solid rgba(0,139,139,0.15);
724
- box-shadow: 0 4px 15px rgba(0,0,0,0.05);
725
- padding: 20px;
726
- }
727
-
728
- .control-label {
729
- font-weight: 600;
730
- color: #006F71;
731
- font-size: 13px;
732
- text-transform: uppercase;
733
- letter-spacing: 0.5px;
734
- margin-bottom: 8px;
735
- }
736
-
737
- .selectize-input, .form-control {
738
- border-radius: 12px !important;
739
- border: 2px solid rgba(0,139,139,0.25) !important;
740
- padding: 10px 15px !important;
741
- font-size: 14px !important;
742
- transition: all 0.3s ease;
743
- }
744
-
745
- .selectize-input:focus, .form-control:focus {
746
- border-color: #006F71 !important;
747
- box-shadow: 0 0 0 3px rgba(0,139,139,0.15) !important;
748
- }
749
-
750
- .section-header {
751
- font-size: 1.4rem;
752
- font-weight: 700;
753
- color: #006F71;
754
- margin-bottom: 20px;
755
- padding-bottom: 10px;
756
- border-bottom: 3px solid #A27752;
757
- }
758
-
759
- .metric-card {
760
- background: linear-gradient(135deg, #006F71 0%, #008B8B 100%);
761
- border-radius: 15px;
762
- padding: 20px;
763
- color: white;
764
- text-align: center;
765
- box-shadow: 0 6px 20px rgba(0,139,139,0.3);
766
- margin-bottom: 15px;
767
- }
768
-
769
- .metric-value {
770
- font-size: 2.5rem;
771
- font-weight: 800;
772
- }
773
-
774
- .metric-label {
775
- font-size: 0.9rem;
776
- opacity: 0.9;
777
- text-transform: uppercase;
778
- letter-spacing: 1px;
779
- }
780
-
781
- .btn-primary {
782
- background: linear-gradient(135deg, #006F71 0%, #008B8B 100%) !important;
783
- border: none !important;
784
- border-radius: 12px !important;
785
- font-weight: 600 !important;
786
- padding: 12px 25px !important;
787
- transition: all 0.3s ease !important;
788
- }
789
-
790
- .btn-primary:hover {
791
- transform: translateY(-2px);
792
- box-shadow: 0 6px 20px rgba(0,139,139,0.4) !important;
793
- }
794
-
795
- .gt_table {
796
- border-radius: 12px !important;
797
- overflow: hidden;
798
- }
799
-
800
- hr {
801
- border-top: 2px solid rgba(162, 119, 82, 0.3);
802
- margin: 30px 0;
803
- }
804
- "))
805
- ),
806
-
807
- # Header
808
- div(class = "app-header",
809
- div(style = "text-align: center;",
810
- div(class = "header-title", "Defensive Analytics Dashboard"),
811
- div(class = "header-subtitle", "Coastal Carolina Baseball • Spring 2025")
812
- )
813
- ),
814
-
815
- # Main tabs
816
- tabsetPanel(
817
- id = "main_tabs",
818
-
819
- # ==================== TAB 1: TEAM OAA ====================
820
- tabPanel(
821
- "Team OAA",
822
- br(),
823
- fluidRow(
824
- column(12, h3(class = "section-header", "Outfielder Outs Above Average"))
825
- ),
826
- fluidRow(
827
- column(3, wellPanel(
828
- selectInput("of_player", "Select Player:", choices = unique(OAA_DF$obs_player)),
829
- selectInput("of_position", "Position:", choices = c("All", "RF", "CF", "LF")),
830
  hr(),
831
- h5("Quick Stats"),
832
- uiOutput("of_quick_stats")
833
- )),
834
- column(9,
835
- plotlyOutput("of_star_graph", height = "500px"),
836
- br(),
837
- fluidRow(
838
- column(6, plotlyOutput("of_field_graph", height = "450px")),
839
- column(6, gt_output("of_success_table"))
840
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
841
  )
842
- ),
843
- hr(),
844
- fluidRow(
845
- column(12, h3(class = "section-header", "Infielder Outs Above Average"))
846
- ),
847
- fluidRow(
848
- column(3, wellPanel(
849
- selectInput("if_player", "Select Player:", choices = unique(IF_OAA$obs_player_name)),
850
- selectInput("if_position", "Position:", choices = c("All", "SS", "2B", "3B", "1B"))
851
- )),
852
- column(9,
853
- plotlyOutput("if_star_graph", height = "500px"),
854
- br(),
855
- fluidRow(
856
- column(6, plotlyOutput("if_field_graph", height = "400px")),
857
- column(6, gt_output("if_success_table"))
858
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
859
  )
860
- ),
861
- hr(),
862
- fluidRow(
863
- column(12, h3(class = "section-header", "Team OAA Leaderboard")),
864
- column(12, DT::dataTableOutput("team_oaa_leaderboard"))
865
  )
866
  ),
867
-
868
- # ==================== TAB 2: OPPONENT OAA ====================
869
- tabPanel(
870
- "Opponent OAA",
871
- br(),
872
- fluidRow(
873
- column(12, h3(class = "section-header", "Opponent Outfield Defense Analysis"))
874
- ),
875
  fluidRow(
876
- column(3, wellPanel(
877
- selectInput("opp_team_filter", "Opponent Team:",
878
- choices = c("All", unique(OPP_OAA_DF$PitcherTeam))),
879
- selectInput("opp_of_player", "Select Player:", choices = unique(OPP_OAA_DF$obs_player)),
880
- selectInput("opp_of_position", "Position:", choices = c("All", "RF", "CF", "LF"))
881
- )),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
882
  column(9,
883
- plotlyOutput("opp_of_star_graph", height = "500px"),
884
- br(),
885
- fluidRow(
886
- column(6, plotlyOutput("opp_of_field_graph", height = "450px")),
887
- column(6, gt_output("opp_of_success_table"))
888
- )
889
  )
890
- ),
891
- hr(),
892
- fluidRow(
893
- column(12, h3(class = "section-header", "Opponent Infield Defense Analysis"))
894
- ),
895
- fluidRow(
896
- column(3, wellPanel(
897
- selectInput("opp_if_player", "Select Player:", choices = unique(OPP_IF_OAA$obs_player_name)),
898
- selectInput("opp_if_position", "Position:", choices = c("All", "SS", "2B", "3B", "1B"))
899
- )),
900
- column(9,
901
- plotlyOutput("opp_if_star_graph", height = "500px")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
902
  )
903
- ),
904
- hr(),
905
- fluidRow(
906
- column(12, h3(class = "section-header", "Opponent OAA Summary")),
907
- column(12, DT::dataTableOutput("opp_oaa_leaderboard"))
908
  )
909
  ),
910
-
911
- # ==================== TAB 3: OPPONENT POSITIONING ====================
912
- tabPanel(
913
- "Opponent Positioning",
914
- br(),
915
- fluidRow(
916
- column(12, h3(class = "section-header", "Defensive Positioning Analysis"))
917
- ),
918
- fluidRow(
919
- column(3, wellPanel(
920
- selectInput("pos_team", "Select Team:",
921
- choices = c("CCU", unique(def_pos_data$PitcherTeam[def_pos_data$PitcherTeam != "CCU"]))),
922
  hr(),
923
- h5("Situational Filters", style = "font-weight: 600; color: #006F71;"),
924
- selectInput("pos_batter_hand", "Batter Hand:", choices = c("No Filter", "Right", "Left")),
925
- selectInput("pos_pitcher_hand", "Pitcher Hand:", choices = c("No Filter", "Right", "Left")),
926
  hr(),
927
- h5("Baserunners", style = "font-weight: 600; color: #006F71;"),
928
- selectInput("pos_first", "Runner on 1st:", choices = c("No Filter", "Yes", "No")),
929
- selectInput("pos_second", "Runner on 2nd:", choices = c("No Filter", "Yes", "No")),
930
- selectInput("pos_third", "Runner on 3rd:", choices = c("No Filter", "Yes", "No"))
931
- )),
932
- column(9,
933
- plotOutput("positioning_heatmap", height = "650px")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
934
  )
935
- ),
936
- hr(),
937
- fluidRow(
938
- column(12, h3(class = "section-header", "Average Positioning by Situation")),
939
- column(12, gt_output("positioning_summary"))
940
  )
941
  ),
942
-
943
- # ==================== TAB 4: CATCHING ANALYSIS ====================
944
- tabPanel(
945
- "Catching Analysis",
946
- br(),
947
  fluidRow(
948
- column(12, h3(class = "section-header", "Catcher Framing & Throwing"))
 
 
 
 
949
  ),
950
  fluidRow(
951
- column(3, wellPanel(
952
- selectInput("catcher_select", "Select Catcher:", choices = unique(catcher_data$Catcher)),
953
- hr(),
954
- h5("Framing Filters", style = "font-weight: 600; color: #006F71;"),
955
- pickerInput("pitch_type_filter", "Pitch Types:",
956
- choices = unique(catcher_data$TaggedPitchType),
957
- selected = unique(catcher_data$TaggedPitchType),
958
- multiple = TRUE, options = list(`actions-box` = TRUE)),
959
- numericInput("min_pitches_catcher", "Min Pitches:", value = 50, min = 1, max = 500)
960
- )),
961
- column(9,
962
- fluidRow(
963
- column(4, div(class = "metric-card",
964
- div(class = "metric-value", textOutput("catcher_net_strikes")),
965
- div(class = "metric-label", "Net Strikes")
966
- )),
967
- column(4, div(class = "metric-card",
968
- div(class = "metric-value", textOutput("catcher_frame_rate")),
969
- div(class = "metric-label", "Frame Rate")
970
- )),
971
- column(4, div(class = "metric-card",
972
- div(class = "metric-value", textOutput("catcher_avg_pop")),
973
- div(class = "metric-label", "Avg Pop Time")
974
- ))
975
- ),
976
- br(),
977
- fluidRow(
978
- column(6,
979
- h4("Framing Heatmap"),
980
- plotOutput("catcher_framing_heatmap", height = "500px")),
981
- column(6,
982
- h4("Framing Points"),
983
- plotlyOutput("catcher_framing_points", height = "500px"))
984
- )
985
- )
986
  ),
987
- hr(),
988
  fluidRow(
989
- column(6,
990
- h4("Throwing Accuracy Map"),
991
- plotOutput("catcher_throw_map", height = "500px")),
992
- column(6,
993
- h4("Pop Time Distribution"),
994
- plotlyOutput("catcher_pop_dist", height = "250px"),
995
- h4("Throw Velocity Trend"),
996
- plotlyOutput("catcher_velo_trend", height = "250px"))
997
  ),
998
  hr(),
 
 
 
 
 
 
 
 
999
  fluidRow(
1000
- column(12, h3(class = "section-header", "Catcher Leaderboard")),
1001
- column(12, DT::dataTableOutput("catcher_leaderboard"))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1002
  )
1003
  )
1004
- )
1005
  )
 
1006
 
1007
- # ============================================================================
1008
- # SERVER
1009
- # ============================================================================
 
1010
 
 
 
 
1011
  server <- function(input, output, session) {
1012
-
1013
- # ==================== TAB 1: TEAM OAA ====================
1014
-
1015
- output$of_quick_stats <- renderUI({
1016
- player_data <- OAA_DF %>% filter(obs_player == input$of_player)
1017
- if (input$of_position != "All") {
1018
- player_data <- player_data %>% filter(hit_location == input$of_position)
 
 
 
 
 
 
 
1019
  }
1020
-
1021
- total_oaa <- round(sum(player_data$OAA, na.rm = TRUE), 1)
1022
- plays <- nrow(player_data)
1023
- success_rate <- round(mean(player_data$success_ind) * 100, 1)
1024
-
1025
- tagList(
1026
- div(class = "metric-card", style = "padding: 12px;",
1027
- div(class = "metric-value", style = "font-size: 1.8rem;", total_oaa),
1028
- div(class = "metric-label", "Total OAA")
1029
- ),
1030
- div(class = "metric-card", style = "padding: 12px; background: linear-gradient(135deg, #A27752 0%, #c49a6c 100%);",
1031
- div(class = "metric-value", style = "font-size: 1.8rem;", plays),
1032
- div(class = "metric-label", "Total Plays")
1033
- ),
1034
- div(class = "metric-card", style = "padding: 12px; background: linear-gradient(135deg, #2c3e50 0%, #4a6278 100%);",
1035
- div(class = "metric-value", style = "font-size: 1.8rem;", paste0(success_rate, "%")),
1036
- div(class = "metric-label", "Success Rate")
1037
- )
1038
- )
1039
- })
1040
-
1041
- output$of_star_graph <- renderPlotly({
1042
- star_graph(input$of_player, input$of_position, OAA_DF)
1043
- })
1044
-
1045
- of_field_results <- reactive({
1046
- field_graph(input$of_player, input$of_position, OAA_DF)
1047
- })
1048
-
1049
- output$of_field_graph <- renderPlotly({
1050
- of_field_results()[[1]]
1051
- })
1052
-
1053
- output$of_success_table <- render_gt({
1054
- of_field_results()[[2]]
1055
- })
1056
-
1057
- output$if_star_graph <- renderPlotly({
1058
- infield_star_graph(input$if_player, input$if_position, IF_OAA)
1059
- })
1060
-
1061
- if_field_results <- reactive({
1062
- infield_field_graph(input$if_player, input$if_position, IF_OAA)
1063
- })
1064
-
1065
- output$if_field_graph <- renderPlotly({
1066
- if_field_results()[[1]]
1067
- })
1068
-
1069
- output$if_success_table <- render_gt({
1070
- if_field_results()[[2]]
1071
- })
1072
-
1073
- output$team_oaa_leaderboard <- DT::renderDataTable({
1074
- of_summary <- OAA_DF %>%
1075
- group_by(Player = obs_player) %>%
1076
- summarise(
1077
- `OF OAA` = round(sum(OAA, na.rm = TRUE), 1),
1078
- `OF Plays` = n(),
1079
- `OF Success %` = round(mean(success_ind) * 100, 1),
1080
- .groups = "drop"
1081
- )
1082
-
1083
- if_summary <- IF_OAA %>%
1084
- group_by(Player = obs_player_name) %>%
1085
- summarise(
1086
- `IF OAA` = round(sum(OAA, na.rm = TRUE), 1),
1087
- `IF Plays` = n(),
1088
- `IF Success %` = round(mean(success_ind) * 100, 1),
1089
- .groups = "drop"
1090
- )
1091
-
1092
- full_join(of_summary, if_summary, by = "Player") %>%
1093
- mutate(
1094
- `Total OAA` = coalesce(`OF OAA`, 0) + coalesce(`IF OAA`, 0)
1095
- ) %>%
1096
- arrange(desc(`Total OAA`)) %>%
1097
- DT::datatable(
1098
- options = list(pageLength = 15, scrollX = TRUE),
1099
- rownames = FALSE
1100
- )
1101
- })
1102
-
1103
- # ==================== TAB 2: OPPONENT OAA ====================
1104
-
1105
- output$opp_of_star_graph <- renderPlotly({
1106
- star_graph(input$opp_of_player, input$opp_of_position, OPP_OAA_DF)
1107
- })
1108
-
1109
- opp_of_field_results <- reactive({
1110
- field_graph(input$opp_of_player, input$opp_of_position, OPP_OAA_DF)
1111
- })
1112
-
1113
- output$opp_of_field_graph <- renderPlotly({
1114
- opp_of_field_results()[[1]]
1115
- })
1116
-
1117
- output$opp_of_success_table <- render_gt({
1118
- opp_of_field_results()[[2]]
1119
- })
1120
-
1121
- output$opp_if_star_graph <- renderPlotly({
1122
- infield_star_graph(input$opp_if_player, input$opp_if_position, OPP_IF_OAA)
1123
- })
1124
-
1125
- output$opp_oaa_leaderboard <- DT::renderDataTable({
1126
- opp_summary <- OPP_OAA_DF %>%
1127
- group_by(Player = obs_player, Team = PitcherTeam) %>%
1128
- summarise(
1129
- OAA = round(sum(OAA, na.rm = TRUE), 1),
1130
- Plays = n(),
1131
- `Success %` = round(mean(success_ind) * 100, 1),
1132
- .groups = "drop"
1133
- ) %>%
1134
- arrange(desc(OAA))
1135
-
1136
- DT::datatable(opp_summary, options = list(pageLength = 15), rownames = FALSE)
1137
- })
1138
-
1139
- # ==================== TAB 3: POSITIONING ====================
1140
-
1141
- output$positioning_heatmap <- renderPlot({
1142
- infield_positioning_heatmap(
1143
- team = input$pos_team,
1144
- batter_hand = input$pos_batter_hand,
1145
- pitcher_hand = input$pos_pitcher_hand,
1146
- man_on_first = input$pos_first,
1147
- man_on_second = input$pos_second,
1148
- man_on_third = input$pos_third
1149
- )
1150
- })
1151
-
1152
- output$positioning_summary <- render_gt({
1153
- team_data <- def_pos_data %>% filter(PitcherTeam == input$pos_team)
1154
-
1155
- team_data %>%
1156
- summarise(
1157
- `Avg 1B X` = round(mean(`1B_PositionAtReleaseX`, na.rm = TRUE), 1),
1158
- `Avg 1B Z` = round(mean(`1B_PositionAtReleaseZ`, na.rm = TRUE), 1),
1159
- `Avg SS X` = round(mean(`SS_PositionAtReleaseX`, na.rm = TRUE), 1),
1160
- `Avg SS Z` = round(mean(`SS_PositionAtReleaseZ`, na.rm = TRUE), 1),
1161
- `Avg CF X` = round(mean(`CF_PositionAtReleaseX`, na.rm = TRUE), 1),
1162
- `Avg CF Z` = round(mean(`CF_PositionAtReleaseZ`, na.rm = TRUE), 1),
1163
- Observations = n()
1164
- ) %>%
1165
- gt() %>%
1166
- tab_header(title = paste("Average Positioning -", input$pos_team)) %>%
1167
- cols_align(align = "center") %>%
1168
- tab_options(heading.background.color = "darkcyan") %>%
1169
- tab_style(style = cell_text(color = "white"), locations = cells_title())
1170
- })
1171
-
1172
- # ==================== TAB 4: CATCHING ====================
1173
-
1174
- catcher_filtered <- reactive({
1175
- catcher_data %>%
1176
- filter(Catcher == input$catcher_select) %>%
1177
- filter(TaggedPitchType %in% input$pitch_type_filter)
1178
- })
1179
-
1180
- output$catcher_net_strikes <- renderText({
1181
- df <- catcher_filtered()
1182
- net <- sum(df$frame_numeric, na.rm = TRUE)
1183
- sprintf("%+d", net)
1184
  })
1185
-
1186
- output$catcher_frame_rate <- renderText({
1187
- df <- catcher_filtered() %>% filter(!is.na(frame_numeric))
1188
- if (nrow(df) == 0) return("N/A")
1189
- added <- sum(df$frame_numeric == 1, na.rm = TRUE)
1190
- total <- sum(df$frame_numeric != 0, na.rm = TRUE)
1191
- if (total == 0) return("N/A")
1192
- paste0(round(added / total * 100, 1), "%")
1193
  })
1194
-
1195
- output$catcher_avg_pop <- renderText({
1196
- df <- catcher_filtered() %>% filter(!is.na(PopTime))
1197
- if (nrow(df) == 0) return("N/A")
1198
- paste0(round(mean(df$PopTime, na.rm = TRUE), 2), "s")
1199
  })
1200
-
1201
- output$catcher_framing_heatmap <- renderPlot({
1202
- df <- catcher_filtered() %>%
1203
- filter(!is.na(frame), !is.na(PlateLocSide), !is.na(PlateLocHeight)) %>%
1204
- filter(frame %in% c("Strike Added", "Strike Lost"))
1205
-
1206
- if (nrow(df) < 10) {
1207
- return(ggplot() + theme_void() + ggtitle("Insufficient data"))
1208
- }
1209
-
1210
- ggplot(df, aes(PlateLocSide, PlateLocHeight)) +
1211
- stat_density_2d(aes(fill = after_stat(density)), geom = "raster", contour = FALSE, alpha = 0.8) +
1212
- scale_fill_gradientn(colours = c("white", "lightblue", "#FF9999", "red", "darkred")) +
1213
- annotate("rect", xmin = -0.83, xmax = 0.83, ymin = 1.5, ymax = 3.38,
1214
- fill = NA, color = "black", linewidth = 1.5) +
1215
- coord_fixed() + xlim(-2, 2) + ylim(0, 4.5) +
1216
- facet_wrap(~frame, nrow = 1) +
1217
- theme_void() +
1218
- theme(strip.text = element_text(size = 16, face = "bold"), legend.position = "none")
1219
  })
1220
-
1221
- output$catcher_framing_points <- renderPlotly({
1222
- df <- catcher_filtered() %>%
1223
- filter(!is.na(frame), !is.na(PlateLocSide), !is.na(PlateLocHeight),
1224
- frame %in% c("Strike Added", "Strike Lost"))
1225
-
1226
- if (nrow(df) < 10) {
1227
- return(ggplotly(ggplot() + theme_void()))
1228
- }
1229
-
1230
- pitch_colors <- c("Fastball" = "#FA8072", "Sinker" = "#fdae61", "Slider" = "#A020F0",
1231
- "Curveball" = "#2c7bb6", "ChangeUp" = "#90EE90", "Cutter" = "red")
1232
-
1233
- p <- ggplot(df, aes(PlateLocSide, PlateLocHeight, color = TaggedPitchType,
1234
- text = paste0("Pitch: ", TaggedPitchType, "<br>Frame: ", frame))) +
1235
- geom_point(alpha = 0.7, size = 2) +
1236
- annotate("rect", xmin = -0.83, xmax = 0.83, ymin = 1.5, ymax = 3.38,
1237
- fill = NA, color = "black", linewidth = 1) +
1238
- coord_fixed(xlim = c(-2, 2), ylim = c(0, 4.5)) +
1239
- facet_wrap(~frame, nrow = 1) +
1240
- scale_color_manual(values = pitch_colors, na.value = "grey60") +
1241
- theme_minimal() +
1242
- theme(strip.text = element_text(face = "bold"))
1243
-
1244
- ggplotly(p, tooltip = "text")
1245
  })
1246
-
1247
- output$catcher_throw_map <- renderPlot({
1248
- df <- catcher_filtered() %>%
1249
- filter(!is.na(Notes), Notes %in% c("2b out", "2b safe", "3b out", "3b safe"))
1250
-
1251
- if (nrow(df) == 0) {
1252
- return(ggplot() + theme_void() + ggtitle("No throwing data available"))
1253
- }
1254
-
1255
- ggplot(df) +
1256
- geom_polygon(data = data.frame(x = c(-10,10,10,-10), y = c(0.25,0.25,8,8)),
1257
- aes(x = x, y = y), fill = '#14a6a8', color = '#14a6a8') +
1258
- geom_polygon(data = data.frame(x = c(-10,10,10,-10), y = c(8,8,9,9)),
1259
- aes(x = x, y = y), fill = 'yellow', color = 'yellow') +
1260
- geom_polygon(data = data.frame(x = c(-10,10,10,-10), y = c(-2,-2,0.25,0.25)),
1261
- aes(x = x, y = y), fill = 'brown', color = 'brown') +
1262
- geom_polygon(data = data.frame(x = c(-10,10,10,-10), y = c(-5,-5,-2,-2)),
1263
- aes(x = x, y = y), fill = 'darkgreen', color = 'darkgreen') +
1264
- geom_polygon(data = data.frame(x = c(-1,1,1,-1), y = c(0,0,0.45,0.45)),
1265
- aes(x = x, y = y), fill = 'white', color = 'black') +
1266
- geom_point(aes(x = BasePositionZ, y = BasePositionY, fill = Notes),
1267
- color = 'white', pch = 21, size = 4) +
1268
- scale_fill_manual(values = c('2b safe' = 'red', '2b out' = '#339a1d',
1269
- '3b safe' = '#ff6b6b', '3b out' = '#1a5d1a')) +
1270
- scale_x_continuous(limits = c(-10, 10)) +
1271
- scale_y_continuous(limits = c(-5, 9)) +
1272
- coord_fixed() +
1273
- theme_void() +
1274
- theme(legend.position = "bottom") +
1275
- ggtitle(paste(input$catcher_select, "- Throwing Report"))
1276
  })
1277
-
1278
- output$catcher_pop_dist <- renderPlotly({
1279
- df <- catcher_filtered() %>% filter(!is.na(PopTime))
1280
-
1281
- if (nrow(df) == 0) return(ggplotly(ggplot() + theme_void()))
1282
-
1283
- p <- ggplot(df, aes(PopTime)) +
1284
- geom_histogram(bins = 15, fill = "darkcyan", alpha = 0.7, color = "white") +
1285
- geom_vline(xintercept = mean(df$PopTime), color = "peru", linetype = "dashed", size = 1) +
1286
- labs(x = "Pop Time (s)", y = "Count") +
1287
- theme_minimal()
1288
-
1289
- ggplotly(p)
1290
  })
1291
-
1292
- output$catcher_velo_trend <- renderPlotly({
1293
- df <- catcher_filtered() %>%
1294
- filter(!is.na(ThrowSpeed)) %>%
1295
- arrange(Date) %>%
1296
- mutate(throw_num = row_number())
1297
-
1298
- if (nrow(df) == 0) return(ggplotly(ggplot() + theme_void()))
1299
-
1300
- p <- ggplot(df, aes(throw_num, ThrowSpeed)) +
1301
- geom_point(color = "darkcyan", size = 2) +
1302
- geom_smooth(method = "loess", color = "peru", se = TRUE, fill = "peru", alpha = 0.3) +
1303
- labs(x = "Throw #", y = "Velocity (MPH)") +
1304
- theme_minimal()
1305
-
1306
- ggplotly(p)
1307
  })
1308
-
1309
- output$catcher_leaderboard <- DT::renderDataTable({
1310
- catcher_data %>%
1311
- group_by(Catcher) %>%
1312
- summarise(
1313
- `Pitches Caught` = n(),
1314
- `Strikes Added` = sum(frame_numeric == 1, na.rm = TRUE),
1315
- `Strikes Lost` = sum(frame_numeric == -1, na.rm = TRUE),
1316
- `Net Strikes` = sum(frame_numeric, na.rm = TRUE),
1317
- `Frame Rate` = round(sum(frame_numeric == 1, na.rm = TRUE) /
1318
- max(1, sum(!is.na(frame_numeric) & frame_numeric != 0)) * 100, 1),
1319
- `Avg Pop Time` = round(mean(PopTime, na.rm = TRUE), 2),
1320
- `Avg Throw Velo` = round(mean(ThrowSpeed, na.rm = TRUE), 1),
1321
- .groups = "drop"
1322
- ) %>%
1323
- arrange(desc(`Net Strikes`)) %>%
1324
- DT::datatable(options = list(pageLength = 10), rownames = FALSE)
1325
  })
1326
-
1327
 
1328
- # ============================================================================
1329
- # RUN APP
1330
- # ============================================================================
 
1331
 
1332
- shinyApp(ui = ui, server = server)
 
 
 
 
1
  library(shiny)
2
  library(DT)
3
+ library(htmltools)
 
 
4
  library(gt)
5
+ library(plotly)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6
 
7
+ # ============================================================
8
+ # PASSWORD
9
+ # ============================================================
10
+ PASSWORD <- Sys.getenv("password")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
11
 
12
+ # ============================================================
13
+ # LOGIN UI
14
+ # ============================================================
15
+ login_ui <- fluidPage(
16
+ tags$style(HTML("
17
+ body { background-color: #f0f4f8; font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; color: #006F71; }
18
+ .login-container { max-width: 360px; margin: 120px auto; background: #A27752; padding: 30px 25px; border-radius: 8px;
19
+ box-shadow: 0 4px 15px #A1A1A4; text-align: center; color: white; }
20
+ .btn-primary { background-color: #006F71 !important; border-color: #006F71 !important; color: white !important;
21
+ font-weight: bold; width: 100%; margin-top: 10px; box-shadow: 0 2px 5px #006F71; transition: background-color 0.3s ease; }
22
+ .btn-primary:hover { background-color: #006F71 !important; border-color: #A27752 !important; }
23
+ .form-control { border-radius: 4px; border: 1.5px solid #006F71 !important; color: #006F71; font-weight: 600; }
24
+ ")),
25
+ div(class = "login-container",
26
+ tags$img(src = "https://upload.wikimedia.org/wikipedia/en/thumb/e/ef/Coastal_Carolina_Chanticleers_logo.svg/1200px-Coastal_Carolina_Chanticleers_logo.svg.png", height = "150px"),
27
+ passwordInput("password", "Password:"),
28
+ actionButton("login", "Login"),
29
+ textOutput("wrong_pass")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
30
  )
31
+ )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
32
 
33
+ # ============================================================
34
+ # MAIN APP UI
35
+ # ============================================================
36
+ app_ui <- fluidPage(
37
+ tags$head(
38
+ tags$style(HTML("
39
+ body, table, .gt_table { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; }
40
+ .app-header { display:flex; justify-content:space-between; align-items:center; padding:20px 40px; background:#ffffff; border-bottom:3px solid darkcyan; margin-bottom:20px; }
41
+ .header-logo-left, .header-logo-right { width:120px; height:auto; }
42
+ .header-logo-center { max-width:400px; height:auto; }
43
+ @media (max-width:768px){
44
+ .app-header{ flex-direction:column; padding:15px 20px; }
45
+ .header-logo-left, .header-logo-right { width:80px; }
46
+ .header-logo-center { max-width:250px; margin:10px 0; }
47
+ }
48
+ .nav-tabs{ border:none !important; border-radius:50px; padding:6px 12px; margin:20px auto 0; max-width:100%;
49
+ background:linear-gradient(135deg, #d4edeb 0%, #e8ddd0 50%, #d4edeb 100%); box-shadow:0 4px 16px rgba(0,139,139,.12), inset 0 2px 4px rgba(255,255,255,.6);
50
+ border:1px solid rgba(0,139,139,.2); position:relative; overflow-x:auto; -webkit-overflow-scrolling:touch; display:flex; justify-content:center; align-items:center; flex-wrap:wrap; gap:6px; }
51
+ .nav-tabs::-webkit-scrollbar{ height:0; }
52
+ .nav-tabs::before{ content:''; position:absolute; inset:0; pointer-events:none; border-radius:50px; background:linear-gradient(135deg, rgba(255,255,255,.4), transparent); }
53
+ .nav-tabs>li>a{ color:darkcyan !important; border:none !important; border-radius:50px !important; background:transparent !important; font-weight:700; font-size:14.5px; padding:10px 22px; white-space:nowrap; letter-spacing:.2px; transition:all .2s ease; }
54
+ .nav-tabs>li>a:hover{ color:#006666 !important; background:rgba(255,255,255,.5) !important; transform:translateY(-1px); }
55
+ .nav-tabs>li.active>a, .nav-tabs>li.active>a:focus, .nav-tabs>li.active>a:hover{
56
+ background:linear-gradient(135deg,#008b8b 0%,#20b2aa 30%,#00ced1 50%,#20b2aa 70%,#008b8b 100%) !important;
57
+ color:#fff !important; text-shadow:0 1px 2px rgba(0,0,0,.2); box-shadow:0 4px 16px rgba(0,139,139,.4), inset 0 2px 8px rgba(255,255,255,.4), inset 0 -2px 6px rgba(0,0,0,.2);
58
+ border:1px solid rgba(255,255,255,.3) !important; }
59
+ .nav-tabs>li>a:focus{ outline:3px solid rgba(205,133,63,.6); outline-offset:2px; }
60
+ .tab-content{ background:linear-gradient(135deg, rgba(255,255,255,.95), rgba(248,249,250,.95)); border-radius:20px; padding:25px; margin-top:14px; box-shadow:0 15px 40px rgba(0,139,139,.1); backdrop-filter:blur(15px); border:1px solid rgba(0,139,139,.1); position:relative; overflow:hidden; }
61
+ .tab-content::before{ content:''; position:absolute; left:0; right:0; top:0; height:4px; background:linear-gradient(90deg, darkcyan, peru, darkcyan); background-size:200% 100%; animation:shimmer 3s linear infinite; }
62
+ @keyframes shimmer { 0%{background-position:-200% 0;} 100%{background-position:200% 0;} }
63
+ .well { background:#ffffff; border-radius:12px; border:1px solid rgba(0,139,139,.15); box-shadow:0 6px 18px rgba(0,0,0,.06); }
64
+ .control-label{ font-weight:700; color:#0a6a6a; }
65
+ .selectize-input, .form-control{ border-radius:10px; border:1px solid rgba(0,139,139,.35); }
66
+ .datatables, .dataTables_wrapper .dataTables_filter input{ border-radius:10px; }
67
+ .gt_table{ border-radius:12px; overflow:hidden; border:1px solid rgba(0,139,139,.15); }
68
+ .placeholder-box { background: #f8fafa; border: 2px dashed rgba(0,139,139,.25); border-radius: 12px;
69
+ padding: 60px 20px; text-align: center; color: #8aacac; font-size: 16px; font-weight: 600; margin: 10px 0; }
70
+ .section-header { color: darkcyan; border-bottom: 2px solid peru; padding-bottom: 6px; margin-bottom: 16px; }
71
+ "))
72
+ ),
73
 
74
+ # ---- Header ----
75
+ div(class = "app-header",
76
+ tags$img(src = "https://i.imgur.com/7vx5Ci8.png", class = "header-logo-left", alt = "Logo Left"),
77
+ tags$img(src = "https://i.imgur.com/Sq0CZ0F.png", class = "header-logo-center", alt = "Main Logo"),
78
+ tags$img(src = "https://i.imgur.com/VbrN5WV.png", class = "header-logo-right", alt = "Logo Right")
79
+ ),
 
80
 
81
+ # ---- Tabs ----
82
+ tabsetPanel(
83
+ id = "tabs",
84
 
85
+ # =============================================
86
+ # TAB 1: Outfield OAA
87
+ # =============================================
88
+ tabPanel("Outfield OAA",
89
+ sidebarLayout(
90
+ sidebarPanel(
91
+ width = 3,
92
+ selectInput("of_player", "Player:", choices = NULL),
93
+ hr(),
94
+ checkboxGroupInput("of_year_select", "Year:",
95
+ choices = c("2025", "2026"),
96
+ selected = c("2025", "2026")),
97
+ hr(),
98
+ selectInput("of_position", "Position:",
99
+ choices = c("LF", "CF", "RF", "ALL"),
100
+ selected = "CF")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
101
  ),
102
+ mainPanel(
103
+ width = 9,
104
+ h4("Star Graph (Catch Difficulty)", class = "section-header"),
105
+ div(class = "placeholder-box", "Star Graph \u2014 plotly scatter on catch probability grid"),
106
+ hr(),
107
+ h4("Field Graph (Catch Locations)", class = "section-header"),
108
+ div(class = "placeholder-box", "Field Graph \u2014 plotly spray on field outline with catch/hit markers"),
109
+ gt::gt_output("of_field_graph_table"),
110
+ hr(),
111
+ h4("Directional OAA", class = "section-header"),
112
+ div(class = "placeholder-box", "Radar Chart \u2014 OAA by direction (Back, Left, In, Right, etc.)"),
113
+ gt::gt_output("of_direction_oaa_table")
114
+ )
115
  )
116
+ ),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
117
 
118
+ # =============================================
119
+ # TAB 2: Infield OAA
120
+ # =============================================
121
+ tabPanel("Infield OAA",
122
+ sidebarLayout(
123
+ sidebarPanel(
124
+ width = 3,
125
+ selectInput("if_player", "Player:", choices = NULL),
126
+ hr(),
127
+ selectInput("if_position", "Position:",
128
+ choices = c("SS", "2B", "3B", "1B", "ALL"),
129
+ selected = "SS")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
130
  ),
131
+ mainPanel(
132
+ width = 9,
133
+ h4("Star Graph (Play Difficulty)", class = "section-header"),
134
+ div(class = "placeholder-box", "Infield Star Graph \u2014 bearing diff vs play probability with star ratings"),
135
+ hr(),
136
+ h4("Field Graph (Play Locations)", class = "section-header"),
137
+ div(class = "placeholder-box", "Infield Field Graph \u2014 batted ball locations with play made/not made markers"),
138
+ gt::gt_output("if_field_graph_table")
139
+ )
140
  )
141
+ ),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
142
 
143
+ # =============================================
144
+ # TAB 3: Receiving (Catcher Framing)
145
+ # =============================================
146
+ tabPanel("Receiving",
147
+ sidebarLayout(
148
+ sidebarPanel(
149
+ width = 3,
150
+ selectInput("recv_catcher", "Catcher:", choices = NULL),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
151
  hr(),
152
+ checkboxGroupInput("recv_year", "Year:",
153
+ choices = c("2025", "2026"),
154
+ selected = c("2025", "2026")),
155
+ hr(),
156
+ selectInput("recv_pitcher_filter", "Pitcher:",
157
+ choices = c("All"), selected = "All"),
158
+ hr(),
159
+ checkboxGroupInput("recv_pitch_type", "Pitch Type:",
160
+ choices = c("Fastball", "Sinker", "Cutter", "Slider",
161
+ "Curveball", "ChangeUp", "Splitter"),
162
+ selected = c("Fastball", "Sinker", "Cutter", "Slider",
163
+ "Curveball", "ChangeUp", "Splitter"),
164
+ inline = FALSE),
165
+ hr(),
166
+ selectInput("recv_count_filter", "Count:",
167
+ choices = c("All", "0-0", "0-1", "0-2", "1-0", "1-1", "1-2",
168
+ "2-0", "2-1", "2-2", "3-0", "3-1", "3-2"),
169
+ selected = "All")
170
+ ),
171
+ mainPanel(
172
+ width = 9,
173
+ h4("Framing Heatmap (Called Strike Probability)", class = "section-header"),
174
+ div(class = "placeholder-box", "Strike zone heatmap \u2014 called strike added probability by location"),
175
+ hr(),
176
+ h4("Shadow Zone Performance", class = "section-header"),
177
+ fluidRow(
178
+ column(6, div(class = "placeholder-box", "Shadow zone framing rate \u2014 zone edge called strike %")),
179
+ column(6, div(class = "placeholder-box", "Framing runs by zone quadrant"))
180
+ ),
181
+ hr(),
182
+ h4("Receiving Summary", class = "section-header"),
183
+ gt::gt_output("recv_summary_table"),
184
+ hr(),
185
+ h4("Opponent Receiving Comparison", class = "section-header"),
186
+ div(class = "placeholder-box", "Opponent catcher framing stats comparison table")
187
  )
188
+ )
189
+ ),
190
+
191
+ # =============================================
192
+ # TAB 4: Throwing (Catcher Arm)
193
+ # =============================================
194
+ tabPanel("Throwing",
195
+ sidebarLayout(
196
+ sidebarPanel(
197
+ width = 3,
198
+ selectInput("throw_catcher", "Catcher:", choices = NULL),
199
+ hr(),
200
+ checkboxGroupInput("throw_year", "Year:",
201
+ choices = c("2025", "2026"),
202
+ selected = c("2025", "2026")),
203
+ hr(),
204
+ selectInput("throw_base", "Target Base:",
205
+ choices = c("All", "2nd Base", "3rd Base"),
206
+ selected = "All")
207
+ ),
208
+ mainPanel(
209
+ width = 9,
210
+ h4("Throwing Accuracy Map", class = "section-header"),
211
+ div(class = "placeholder-box", "Throw location scatter \u2014 accuracy to base with pop time labels"),
212
+ hr(),
213
+ h4("Pop Time Distribution", class = "section-header"),
214
+ fluidRow(
215
+ column(6, div(class = "placeholder-box", "Pop time histogram / density plot")),
216
+ column(6, div(class = "placeholder-box", "Pop time by game date trend line"))
217
+ ),
218
+ hr(),
219
+ h4("Throwing Summary", class = "section-header"),
220
+ gt::gt_output("throw_summary_table"),
221
+ hr(),
222
+ h4("Stolen Base Prevention", class = "section-header"),
223
+ div(class = "placeholder-box", "CS% breakdown \u2014 attempts, caught, allowed, by runner speed tier")
224
  )
 
 
 
 
 
225
  )
226
  ),
227
+
228
+ # =============================================
229
+ # TAB 5: Catching Leaderboard
230
+ # =============================================
231
+ tabPanel("Catching Leaderboard",
 
 
 
232
  fluidRow(
233
+ column(3,
234
+ wellPanel(
235
+ checkboxGroupInput("clb_year", "Year:",
236
+ choices = c("2025", "2026"),
237
+ selected = c("2025", "2026")),
238
+ hr(),
239
+ selectInput("clb_sort_by", "Sort By:",
240
+ choices = c("Total Catching Runs", "Framing Runs",
241
+ "Throwing Runs", "Blocking Runs", "Pop Time"),
242
+ selected = "Total Catching Runs"),
243
+ hr(),
244
+ checkboxGroupInput("clb_columns", "Columns:",
245
+ choices = c("Framing Runs", "Strike Rate Added",
246
+ "Shadow Zone %", "Pop Time (avg)",
247
+ "Pop Time (best)", "CS%", "SB Allowed",
248
+ "Throwing Runs", "Blocking Runs",
249
+ "Passed Balls", "Wild Pitches",
250
+ "Total Catching Runs"),
251
+ selected = c("Framing Runs", "Pop Time (avg)",
252
+ "CS%", "Total Catching Runs"),
253
+ inline = FALSE)
254
+ )
255
+ ),
256
  column(9,
257
+ h4("Catcher Leaderboard", style = "text-align: center; color: darkcyan;"),
258
+ div(class = "placeholder-box", "Catcher leaderboard DT table \u2014 sortable, color-coded"),
259
+ hr(),
260
+ h4("Catcher Comparison", style = "text-align: center; color: darkcyan;"),
261
+ div(class = "placeholder-box", "Side-by-side catcher radar or bar chart comparison")
 
262
  )
263
+ )
264
+ ),
265
+
266
+ # =============================================
267
+ # TAB 6: 6th Tool Fielding
268
+ # =============================================
269
+ tabPanel("6th Tool Fielding",
270
+ sidebarLayout(
271
+ sidebarPanel(
272
+ width = 3,
273
+ selectInput("st_player", "Player:", choices = NULL),
274
+ hr(),
275
+ checkboxGroupInput("st_year", "Year:",
276
+ choices = c("2025", "2026"),
277
+ selected = c("2025", "2026")),
278
+ hr(),
279
+ selectInput("st_position", "Position:",
280
+ choices = c("All", "P", "C", "1B", "2B", "3B", "SS",
281
+ "LF", "CF", "RF"),
282
+ selected = "All"),
283
+ hr(),
284
+ selectInput("st_play_type", "Play Type:",
285
+ choices = c("All", "Routine", "Likely", "Even",
286
+ "Unlikely", "Remote"),
287
+ selected = "All")
288
+ ),
289
+ mainPanel(
290
+ width = 9,
291
+ h4("Fielding Overview", class = "section-header"),
292
+ fluidRow(
293
+ column(4, div(class = "placeholder-box", "Fielding % gauge")),
294
+ column(4, div(class = "placeholder-box", "Range factor gauge")),
295
+ column(4, div(class = "placeholder-box", "Errors committed gauge"))
296
+ ),
297
+ hr(),
298
+ h4("Play Breakdown by Difficulty", class = "section-header"),
299
+ div(class = "placeholder-box", "Star-group table \u2014 plays made vs expected by 1\u20135 star difficulty"),
300
+ hr(),
301
+ h4("Error Log", class = "section-header"),
302
+ div(class = "placeholder-box", "Detailed error log \u2014 date, opponent, inning, play description"),
303
+ hr(),
304
+ h4("Positioning & First Step", class = "section-header"),
305
+ fluidRow(
306
+ column(6, div(class = "placeholder-box", "Average starting position on field diagram")),
307
+ column(6, div(class = "placeholder-box", "First-step quickness distribution"))
308
+ ),
309
+ hr(),
310
+ h4("Defensive Runs Saved Breakdown", class = "section-header"),
311
+ gt::gt_output("st_drs_table")
312
  )
 
 
 
 
 
313
  )
314
  ),
315
+
316
+ # =============================================
317
+ # TAB 7: Baserunning
318
+ # =============================================
319
+ tabPanel("Baserunning",
320
+ sidebarLayout(
321
+ sidebarPanel(
322
+ width = 3,
323
+ selectInput("br_player", "Player:", choices = NULL),
 
 
 
324
  hr(),
325
+ checkboxGroupInput("br_year", "Year:",
326
+ choices = c("2025", "2026"),
327
+ selected = c("2025", "2026")),
328
  hr(),
329
+ selectInput("br_event_type", "Event Type:",
330
+ choices = c("All", "Stolen Bases", "Extra Bases Taken",
331
+ "Advances on Fly", "Advances on Wild Pitch/PB",
332
+ "Home to First"),
333
+ selected = "All")
334
+ ),
335
+ mainPanel(
336
+ width = 9,
337
+ h4("Baserunning Overview", class = "section-header"),
338
+ fluidRow(
339
+ column(3, div(class = "placeholder-box", "SB attempts")),
340
+ column(3, div(class = "placeholder-box", "SB success %")),
341
+ column(3, div(class = "placeholder-box", "Extra bases taken")),
342
+ column(3, div(class = "placeholder-box", "Baserunning runs"))
343
+ ),
344
+ hr(),
345
+ h4("Sprint Speed", class = "section-header"),
346
+ fluidRow(
347
+ column(6, div(class = "placeholder-box", "Home-to-first time distribution / trend")),
348
+ column(6, div(class = "placeholder-box", "Sprint speed percentile vs team / conference"))
349
+ ),
350
+ hr(),
351
+ h4("Stolen Base Detail", class = "section-header"),
352
+ div(class = "placeholder-box", "SB attempt log \u2014 date, base, pitcher, catcher, result, lead distance, jump time"),
353
+ hr(),
354
+ h4("Extra Bases Taken", class = "section-header"),
355
+ div(class = "placeholder-box", "Advancement opportunities vs taken \u2014 1st-to-3rd, 2nd-to-home, tag-up"),
356
+ hr(),
357
+ h4("Baserunning Runs Summary", class = "section-header"),
358
+ gt::gt_output("br_summary_table"),
359
+ hr(),
360
+ h4("Team Baserunning Leaderboard", class = "section-header"),
361
+ div(class = "placeholder-box", "Team baserunning leaderboard DT table")
362
  )
 
 
 
 
 
363
  )
364
  ),
365
+
366
+ # =============================================
367
+ # TAB 8: Team Defensive Positioning
368
+ # =============================================
369
+ tabPanel("Team Defensive Positioning",
370
  fluidRow(
371
+ column(2, selectInput("dp_team", "Team:", choices = c("Coastal Carolina"), selected = "Coastal Carolina")),
372
+ column(2, selectInput("dp_man_on_first", "Runner on 1st:", choices = c("No Filter", TRUE, FALSE), selected = "No Filter")),
373
+ column(2, selectInput("dp_man_on_second", "Runner on 2nd:", choices = c("No Filter", TRUE, FALSE), selected = "No Filter")),
374
+ column(2, selectInput("dp_man_on_third", "Runner on 3rd:", choices = c("No Filter", TRUE, FALSE), selected = "No Filter")),
375
+ column(2, selectInput("dp_batter_hand", "Bat Hand:", choices = c("No Filter", "Right", "Left"), selected = "No Filter"))
376
  ),
377
  fluidRow(
378
+ column(2, selectInput("dp_pitcher_hand", "Pitch Hand:", choices = c("No Filter", "Right", "Left"), selected = "No Filter")),
379
+ column(2, selectInput("dp_scorediff", "Score Diff:", choices = c("No Filter", -4:4), selected = "No Filter")),
380
+ column(2, dateInput("dp_date_from", "From:", value = "2025-02-14")),
381
+ column(2, dateInput("dp_date_to", "To:", value = "2026-06-22")),
382
+ column(2, selectInput("dp_pitcher", "Pitcher:", choices = c("No Filter"), selected = "No Filter"))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
383
  ),
 
384
  fluidRow(
385
+ column(2, selectInput("dp_hitter", "Batter:", choices = c("No Filter"), selected = "No Filter")),
386
+ column(2, selectInput("dp_count", "Count:", choices = c("No Filter"), selected = "No Filter")),
387
+ column(2, selectInput("dp_outs", "Outs:", choices = c("No Filter", 0, 1, 2), selected = "No Filter")),
388
+ column(2, selectInput("dp_opponent", "Opponent:", choices = c("No Filter"), selected = "No Filter"))
 
 
 
 
389
  ),
390
  hr(),
391
+ div(class = "placeholder-box", style = "min-height: 500px;",
392
+ "Defensive Positioning Heatmap \u2014 density contours on field diagram with median position markers")
393
+ ),
394
+
395
+ # =============================================
396
+ # TAB 9: OAA Leaderboard
397
+ # =============================================
398
+ tabPanel("OAA Leaderboard",
399
  fluidRow(
400
+ column(3,
401
+ wellPanel(
402
+ checkboxGroupInput("oaa_lb_year", "Year:",
403
+ choices = c("2025", "2026"),
404
+ selected = c("2025", "2026")),
405
+ hr(),
406
+ selectInput("oaa_lb_group", "Position Group:",
407
+ choices = c("All Positions", "Outfield (OF)",
408
+ "Infield (IF)", "By Position"),
409
+ selected = "All Positions"),
410
+ hr(),
411
+ sliderInput("oaa_lb_min_plays", "Min Plays:",
412
+ min = 1, max = 100, value = 10, step = 1),
413
+ hr(),
414
+ selectInput("oaa_lb_sort", "Sort By:",
415
+ choices = c("Total OAA", "OAA per Play",
416
+ "Success Rate Added", "Plays Made",
417
+ "5-Star Plays"),
418
+ selected = "Total OAA"),
419
+ hr(),
420
+ checkboxGroupInput("oaa_lb_columns", "Columns:",
421
+ choices = c("OAA", "OAA/Play", "Plays",
422
+ "Expected Catches", "Actual Catches",
423
+ "Success Rate", "Success Rate Added",
424
+ "1-Star Made", "2-Star Made",
425
+ "3-Star Made", "4-Star Made",
426
+ "5-Star Made"),
427
+ selected = c("OAA", "Plays", "Success Rate",
428
+ "Success Rate Added"),
429
+ inline = FALSE)
430
+ )
431
+ ),
432
+ column(9,
433
+ h4("OAA Leaderboard", style = "text-align: center; color: darkcyan;"),
434
+ div(class = "placeholder-box", "OAA leaderboard DT table \u2014 sortable, color-coded by OAA value"),
435
+ hr(),
436
+ h4("OAA by Star Group", style = "text-align: center; color: darkcyan;"),
437
+ div(class = "placeholder-box", "Stacked bar chart \u2014 OAA contribution by difficulty tier per player"),
438
+ hr(),
439
+ h4("Team OAA Summary", style = "text-align: center; color: darkcyan;"),
440
+ gt::gt_output("oaa_lb_team_summary")
441
+ )
442
  )
443
  )
 
444
  )
445
+ )
446
 
447
+ # ============================================================
448
+ # ROOT UI (login gating)
449
+ # ============================================================
450
+ ui <- fluidPage(uiOutput("mainUI"))
451
 
452
+ # ============================================================
453
+ # SERVER
454
+ # ============================================================
455
  server <- function(input, output, session) {
456
+
457
+ # ---- Auth ----
458
+ authed <- reactiveVal(FALSE)
459
+
460
+ observeEvent(input$login, {
461
+ if (!nzchar(PASSWORD)) {
462
+ authed(TRUE)
463
+ output$wrong_pass <- renderText("")
464
+ } else if (identical(input$password, PASSWORD)) {
465
+ authed(TRUE)
466
+ output$wrong_pass <- renderText("")
467
+ } else {
468
+ authed(FALSE)
469
+ output$wrong_pass <- renderText("Incorrect password.")
470
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
471
  })
472
+
473
+ output$mainUI <- renderUI({
474
+ if (isTRUE(authed())) app_ui else login_ui
 
 
 
 
 
475
  })
476
+
477
+ # ---- Placeholder GT outputs ----
478
+ output$of_field_graph_table <- gt::render_gt({
479
+ gt::gt(data.frame(Note = "Connect data to populate"))
 
480
  })
481
+
482
+ output$of_direction_oaa_table <- gt::render_gt({
483
+ gt::gt(data.frame(Note = "Connect data to populate"))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
484
  })
485
+
486
+ output$if_field_graph_table <- gt::render_gt({
487
+ gt::gt(data.frame(Note = "Connect data to populate"))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
488
  })
489
+
490
+ output$recv_summary_table <- gt::render_gt({
491
+ gt::gt(data.frame(Note = "Connect data to populate"))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
492
  })
493
+
494
+ output$throw_summary_table <- gt::render_gt({
495
+ gt::gt(data.frame(Note = "Connect data to populate"))
 
 
 
 
 
 
 
 
 
 
496
  })
497
+
498
+ output$st_drs_table <- gt::render_gt({
499
+ gt::gt(data.frame(Note = "Connect data to populate"))
 
 
 
 
 
 
 
 
 
 
 
 
 
500
  })
501
+
502
+ output$br_summary_table <- gt::render_gt({
503
+ gt::gt(data.frame(Note = "Connect data to populate"))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
504
  })
 
505
 
506
+ output$oaa_lb_team_summary <- gt::render_gt({
507
+ gt::gt(data.frame(Note = "Connect data to populate"))
508
+ })
509
+ }
510
 
511
+ shinyApp(ui, server)