DaveM2430 commited on
Commit
eb74709
Β·
verified Β·
1 Parent(s): 5e24bb8

Update app.R

Browse files
Files changed (1) hide show
  1. app.R +223 -28
app.R CHANGED
@@ -2,6 +2,7 @@ library(shiny)
2
  library(dplyr)
3
  library(arrow)
4
  library(DT)
 
5
 
6
  # ── Load data ──────────────────────────────────────────────────────────────────
7
  load_data <- function(level) {
@@ -9,6 +10,29 @@ load_data <- function(level) {
9
  arrow::read_parquet(path)
10
  }
11
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
12
  build_pitcher_stats <- function(df, min_pitches) {
13
 
14
  format_name <- function(name) {
@@ -16,7 +40,6 @@ build_pitcher_stats <- function(df, min_pitches) {
16
  if (length(parts) == 2) paste(trimws(parts[2]), trimws(parts[1])) else name
17
  }
18
 
19
- # grab team lookup separately before any grouping
20
  team_lookup <- df %>%
21
  select(Pitcher, PitcherTeam) %>%
22
  distinct(Pitcher, .keep_all = TRUE)
@@ -88,7 +111,9 @@ build_pitcher_stats <- function(df, min_pitches) {
88
  `Chase%` = round(chase_n / pmax(outzone_n, 1) * 100, 1),
89
  `FB Velo` = ifelse(fb_velo_n == 0, NA_real_,
90
  round(fb_velo_sum / fb_velo_n, 1)),
91
- `Hard Hit%` = round(hh_n / pmax(bip_n, 1) * 100, 1)
 
 
92
  ) %>%
93
  filter(
94
  !is.na(Name),
@@ -96,11 +121,11 @@ build_pitcher_stats <- function(df, min_pitches) {
96
  grepl(" ", trimws(Name))
97
  ) %>%
98
  select(Name, Level, Team, Pitches, `FB Velo`, `Zone%`, `Strike%`,
99
- `K's`, `BB's`, `K/BB`, `Whiff%`, `Chase%`, `Hard Hit%`) %>%
100
  arrange(desc(Pitches))
101
  }
102
 
103
- ## UI ##
104
  ui <- fluidPage(
105
  tags$head(
106
  tags$style(HTML("
@@ -120,18 +145,17 @@ ui <- fluidPage(
120
  .main { padding:20px 24px; }
121
  .form-control { border:1px solid #d0d0d0; border-radius:4px; }
122
 
123
- /* table */
124
  .dataTables_wrapper { background:white; border-radius:8px;
125
  padding:16px; box-shadow:0 1px 4px rgba(0,0,0,0.08); }
126
  .dataTables_filter input { border:1px solid #d0d0d0; border-radius:4px;
127
  padding:4px 8px; }
128
  .dataTables_length select { border:1px solid #d0d0d0; border-radius:4px; }
129
  table.dataTable thead th {
130
- background:#002147; color:white; font-weight:600;
131
  font-size:12px; text-transform:uppercase; letter-spacing:0.3px;
132
  border-bottom:none !important; cursor:pointer;
133
  }
134
- table.dataTable thead th:hover { background:#003366; }
135
  table.dataTable tbody tr { background:white; }
136
  table.dataTable tbody tr:nth-child(even) { background:#f7f9fc; }
137
  table.dataTable tbody tr:hover { background:#eef2f7 !important; }
@@ -139,7 +163,25 @@ ui <- fluidPage(
139
  border-bottom:1px solid #f0f0f0; }
140
  table.dataTable tbody td:first-child { font-weight:600; color:#002147; }
141
 
142
- /* splash */
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
143
  #splash {
144
  position:fixed; top:0; left:0; width:100%; height:100%;
145
  background:#002147; display:flex; flex-direction:column;
@@ -177,10 +219,23 @@ ui <- fluidPage(
177
 
178
  tags$script(HTML("
179
  $(document).ready(function() {
 
 
180
  setTimeout(function() {
181
  $('#splash').addClass('fade-out');
182
  setTimeout(function() { $('#splash').remove(); }, 800);
183
  }, 2800);
 
 
 
 
 
 
 
 
 
 
 
184
  });
185
  "))
186
  ),
@@ -188,7 +243,7 @@ ui <- fluidPage(
188
  # splash
189
  tags$div(id="splash",
190
  tags$img(src="Butler-Bulldogs-Logo-1990.png"),
191
- tags$h1("Butler Baseball TPD"),
192
  tags$p("Butler Baseball Analytics"),
193
  tags$div(class="bar-track", tags$div(class="bar-fill"))
194
  ),
@@ -228,44 +283,56 @@ ui <- fluidPage(
228
 
229
  # main
230
  tags$div(class="main",
231
- uiOutput("main_ui")
 
232
  )
233
  )
234
 
235
  # ── Server ─────────────────────────────────────────────────────────────────────
236
  server <- function(input, output, session) {
237
 
238
- # load data reactively when level changes
239
  raw_data <- reactive({
240
  req(input$level)
241
- load_data(input$level)
 
 
 
 
 
 
242
  })
243
 
244
- # build stats table
245
  stats_table <- reactive({
246
  req(raw_data(), input$min_pitches)
247
  if (input$position == "Pitcher") {
248
  build_pitcher_stats(raw_data(), input$min_pitches)
249
  }
250
- # hitter branch coming soon
251
  })
252
 
253
  output$main_ui <- renderUI({
254
  if (input$position == "Pitcher") {
255
- DTOutput("pitcher_table")
 
 
 
 
256
  } else {
257
  tags$p("Hitter view coming soon.", style="color:#888;")
258
  }
259
  })
260
 
261
- output$pitcher_table <- renderDT({
 
262
  req(stats_table())
 
 
263
  dt <- stats_table()
264
 
265
- # ── Color ranges (edit these when ready) ──────────────────────────────────
266
  # zone_pct_range <- c(42, 53)
267
  # strike_pct_range <- c(58, 67)
268
- # kb_range <- c(1.5, 4.0) # K/BB ratio
 
269
  # whiff_range <- c(15, 35)
270
  # chase_range <- c(25, 40)
271
  # hardhit_range <- c(25, 42)
@@ -274,20 +341,148 @@ output$pitcher_table <- renderDT({
274
  datatable(
275
  dt,
276
  rownames = FALSE,
277
- selection = "single",
 
278
  options = list(
279
- pageLength = 25,
280
- lengthMenu = c(25, 50, 100),
281
- scrollX = TRUE,
282
- order = list(list(3, "desc")),
283
- dom = "lfrtip",
284
- columnDefs = list(
285
- list(className="dt-center", targets=2:12),
286
- list(className="dt-left", targets=0:1)
287
- )
 
 
 
 
 
288
  )
289
  )
290
  })
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
291
  }
292
 
293
  shinyApp(ui, server)
 
2
  library(dplyr)
3
  library(arrow)
4
  library(DT)
5
+ library(ggplot2)
6
 
7
  # ── Load data ──────────────────────────────────────────────────────────────────
8
  load_data <- function(level) {
 
10
  arrow::read_parquet(path)
11
  }
12
 
13
+ # ── Pitch colors ───────────────────────────────────────────────────────────────
14
+ pitch_colors <- c(
15
+ "Fastball" = "red", "Sinker" = "orange",
16
+ "Slider" = "gold", "Sweeper" = "pink",
17
+ "Curveball" = "blue", "Changeup" = "green3",
18
+ "Cutter" = "#8B4513", "Splitter" = "mediumpurple3"
19
+ )
20
+
21
+ clean_pitch_type <- function(pt) {
22
+ dplyr::case_when(
23
+ pt %in% c("Fastball","FourSeamFastBall","Four-Seam","FourSeam") ~ "Fastball",
24
+ pt %in% c("Sinker","TwoSeamFastBall","OneSeamFastball") ~ "Sinker",
25
+ pt == "Cutter" ~ "Cutter",
26
+ pt %in% c("Curveball","CurveBall") ~ "Curveball",
27
+ pt == "Slider" ~ "Slider",
28
+ pt == "Sweeper" ~ "Sweeper",
29
+ pt %in% c("ChangeUp","Changeup") ~ "Changeup",
30
+ pt == "Splitter" ~ "Splitter",
31
+ TRUE ~ NA_character_
32
+ )
33
+ }
34
+
35
+ # ── Pitcher stats ──────────────────────────────────────────────────────────────
36
  build_pitcher_stats <- function(df, min_pitches) {
37
 
38
  format_name <- function(name) {
 
40
  if (length(parts) == 2) paste(trimws(parts[2]), trimws(parts[1])) else name
41
  }
42
 
 
43
  team_lookup <- df %>%
44
  select(Pitcher, PitcherTeam) %>%
45
  distinct(Pitcher, .keep_all = TRUE)
 
111
  `Chase%` = round(chase_n / pmax(outzone_n, 1) * 100, 1),
112
  `FB Velo` = ifelse(fb_velo_n == 0, NA_real_,
113
  round(fb_velo_sum / fb_velo_n, 1)),
114
+ `Hard Hit%` = round(hh_n / pmax(bip_n, 1) * 100, 1),
115
+ `Plot` = paste0('<button class="plot-btn" data-pitcher="',
116
+ Pitcher, '">πŸ“Š View</button>')
117
  ) %>%
118
  filter(
119
  !is.na(Name),
 
121
  grepl(" ", trimws(Name))
122
  ) %>%
123
  select(Name, Level, Team, Pitches, `FB Velo`, `Zone%`, `Strike%`,
124
+ `K's`, `BB's`, `K/BB`, `Whiff%`, `Chase%`, `Hard Hit%`, `Plot`) %>%
125
  arrange(desc(Pitches))
126
  }
127
 
128
+ # ── UI ─────────────────────────────────────────────────────────────────────────
129
  ui <- fluidPage(
130
  tags$head(
131
  tags$style(HTML("
 
145
  .main { padding:20px 24px; }
146
  .form-control { border:1px solid #d0d0d0; border-radius:4px; }
147
 
 
148
  .dataTables_wrapper { background:white; border-radius:8px;
149
  padding:16px; box-shadow:0 1px 4px rgba(0,0,0,0.08); }
150
  .dataTables_filter input { border:1px solid #d0d0d0; border-radius:4px;
151
  padding:4px 8px; }
152
  .dataTables_length select { border:1px solid #d0d0d0; border-radius:4px; }
153
  table.dataTable thead th {
154
+ background:#002147 !important; color:white !important; font-weight:600;
155
  font-size:12px; text-transform:uppercase; letter-spacing:0.3px;
156
  border-bottom:none !important; cursor:pointer;
157
  }
158
+ table.dataTable thead th:hover { background:#003366 !important; }
159
  table.dataTable tbody tr { background:white; }
160
  table.dataTable tbody tr:nth-child(even) { background:#f7f9fc; }
161
  table.dataTable tbody tr:hover { background:#eef2f7 !important; }
 
163
  border-bottom:1px solid #f0f0f0; }
164
  table.dataTable tbody td:first-child { font-weight:600; color:#002147; }
165
 
166
+ .plot-btn {
167
+ background:#002147; color:white; border:none; border-radius:4px;
168
+ padding:4px 10px; font-size:12px; cursor:pointer; font-weight:600;
169
+ }
170
+ .plot-btn:hover { background:#003366; }
171
+
172
+ /* modal */
173
+ .modal-backdrop { position:fixed; top:0; left:0; width:100%; height:100%;
174
+ background:rgba(0,0,0,0.5); z-index:1000;
175
+ display:flex; align-items:center; justify-content:center; }
176
+ .modal-box { background:white; border-radius:10px; padding:24px;
177
+ width:700px; max-width:95vw; position:relative;
178
+ box-shadow:0 4px 20px rgba(0,0,0,0.3);
179
+ max-height:90vh; overflow-y:auto; }
180
+ .modal-close { position:absolute; top:12px; right:16px; font-size:20px;
181
+ cursor:pointer; color:#555; background:none; border:none; }
182
+ .modal-title { font-size:18px; font-weight:700; color:#002147;
183
+ margin:0 0 16px 0; }
184
+
185
  #splash {
186
  position:fixed; top:0; left:0; width:100%; height:100%;
187
  background:#002147; display:flex; flex-direction:column;
 
219
 
220
  tags$script(HTML("
221
  $(document).ready(function() {
222
+
223
+ // splash
224
  setTimeout(function() {
225
  $('#splash').addClass('fade-out');
226
  setTimeout(function() { $('#splash').remove(); }, 800);
227
  }, 2800);
228
+
229
+ // plot button click β€” delegate since table renders after doc ready
230
+ $(document).on('click', '.plot-btn', function() {
231
+ var pitcher = $(this).data('pitcher');
232
+ Shiny.setInputValue('selected_pitcher', pitcher, {priority: 'event'});
233
+ });
234
+
235
+ // close modal
236
+ $(document).on('click', '#modal_close', function() {
237
+ $('#pitcher_modal').remove();
238
+ });
239
  });
240
  "))
241
  ),
 
243
  # splash
244
  tags$div(id="splash",
245
  tags$img(src="Butler-Bulldogs-Logo-1990.png"),
246
+ tags$h1("Butler Baseball Transfer Portal"),
247
  tags$p("Butler Baseball Analytics"),
248
  tags$div(class="bar-track", tags$div(class="bar-fill"))
249
  ),
 
283
 
284
  # main
285
  tags$div(class="main",
286
+ uiOutput("main_ui"),
287
+ uiOutput("pitcher_modal")
288
  )
289
  )
290
 
291
  # ── Server ─────────────────────────────────────────────────────────────────────
292
  server <- function(input, output, session) {
293
 
 
294
  raw_data <- reactive({
295
  req(input$level)
296
+ tryCatch(
297
+ load_data(input$level),
298
+ error = function(e) {
299
+ showNotification(paste("Failed to load data:", e$message), type="error")
300
+ NULL
301
+ }
302
+ )
303
  })
304
 
 
305
  stats_table <- reactive({
306
  req(raw_data(), input$min_pitches)
307
  if (input$position == "Pitcher") {
308
  build_pitcher_stats(raw_data(), input$min_pitches)
309
  }
 
310
  })
311
 
312
  output$main_ui <- renderUI({
313
  if (input$position == "Pitcher") {
314
+ tagList(
315
+ tags$p(id="loading_msg", "Loading data...",
316
+ style="color:#888; font-size:14px; padding:10px 0;"),
317
+ DTOutput("pitcher_table")
318
+ )
319
  } else {
320
  tags$p("Hitter view coming soon.", style="color:#888;")
321
  }
322
  })
323
 
324
+ output$pitcher_table <- renderDT({
325
+ req(raw_data())
326
  req(stats_table())
327
+ req(nrow(stats_table()) > 0)
328
+
329
  dt <- stats_table()
330
 
331
+ # ── Color ranges (edit these when ready) ──────────────────────────────────
332
  # zone_pct_range <- c(42, 53)
333
  # strike_pct_range <- c(58, 67)
334
+ # fb_velo_range <- c(88, 95)
335
+ # kb_range <- c(1.5, 4.0)
336
  # whiff_range <- c(15, 35)
337
  # chase_range <- c(25, 40)
338
  # hardhit_range <- c(25, 42)
 
341
  datatable(
342
  dt,
343
  rownames = FALSE,
344
+ escape = FALSE,
345
+ selection = "none",
346
  options = list(
347
+ pageLength = 25,
348
+ lengthMenu = c(25, 50, 100),
349
+ scrollX = TRUE,
350
+ order = list(list(3, "desc")),
351
+ orderMulti = FALSE,
352
+ dom = "lfrtip",
353
+ columnDefs = list(
354
+ list(className="dt-center", targets=2:13),
355
+ list(className="dt-left", targets=0:1),
356
+ list(orderable=FALSE, targets=13)
357
+ ),
358
+ initComplete = JS("function(settings, json) {
359
+ $('#loading_msg').hide();
360
+ }")
361
  )
362
  )
363
  })
364
+
365
+ # movement plot modal
366
+ observeEvent(input$selected_pitcher, {
367
+ req(input$selected_pitcher, raw_data())
368
+
369
+ pitcher_raw <- input$selected_pitcher
370
+
371
+ pdata <- raw_data() %>%
372
+ filter(Pitcher == pitcher_raw) %>%
373
+ mutate(PitchType = clean_pitch_type(TaggedPitchType)) %>%
374
+ filter(!is.na(PitchType), !is.na(HorzBreak), !is.na(InducedVertBreak))
375
+
376
+ req(nrow(pdata) > 0)
377
+
378
+ # format display name
379
+ parts <- strsplit(pitcher_raw, ", ")[[1]]
380
+ display_name <- if (length(parts)==2) paste(trimws(parts[2]),trimws(parts[1])) else pitcher_raw
381
+
382
+ avg_data <- pdata %>%
383
+ group_by(PitchType) %>%
384
+ summarise(
385
+ HB = mean(HorzBreak, na.rm=TRUE),
386
+ iVB = mean(InducedVertBreak,na.rm=TRUE),
387
+ Velo = round(mean(RelSpeed, na.rm=TRUE),1),
388
+ .groups="drop"
389
+ )
390
+
391
+ p <- ggplot() +
392
+ geom_vline(xintercept=0, color="black") +
393
+ geom_hline(yintercept=0, color="black") +
394
+ geom_point(data=pdata,
395
+ aes(x=HorzBreak, y=InducedVertBreak, fill=PitchType),
396
+ size=3, alpha=0.6, shape=21, color="black", stroke=0.4) +
397
+ geom_point(data=avg_data,
398
+ aes(x=HB, y=iVB, color=PitchType),
399
+ size=9, alpha=0.9) +
400
+ geom_text(data=avg_data,
401
+ aes(x=HB, y=iVB, label=Velo),
402
+ color="white", size=3, fontface="bold") +
403
+ scale_fill_manual(values=pitch_colors, name=NULL, drop=TRUE) +
404
+ scale_color_manual(values=pitch_colors, name=NULL, drop=TRUE) +
405
+ xlim(-25,25) + ylim(-25,25) +
406
+ labs(title=paste(display_name, "β€” Pitch Movement"),
407
+ x="Horizontal Break (in)", y="Induced Vertical Break (in)") +
408
+ theme_minimal() +
409
+ theme(
410
+ plot.title = element_text(hjust=0.5, size=13, face="bold", color="#002147"),
411
+ legend.position = "bottom",
412
+ legend.text = element_text(size=9),
413
+ legend.key.size = unit(0.4,"cm")
414
+ )
415
+
416
+ pitch_table <- pdata %>%
417
+ mutate(
418
+ IsStrike = PitchCall %in% c("StrikeCalled","StrikeSwinging","FoulBall",
419
+ "FoulBallNotFieldable","FoulTip","InPlay"),
420
+ IsZone = !is.na(PlateLocSide) & !is.na(PlateLocHeight) &
421
+ abs(PlateLocSide) <= 0.8303 & PlateLocHeight >= 1.5 & PlateLocHeight <= 3.3775,
422
+ IsWhiff = PitchCall == "StrikeSwinging",
423
+ IsSwing = PitchCall %in% c("StrikeSwinging","FoulBall",
424
+ "FoulBallNotFieldable","FoulTip","InPlay"),
425
+ IsChase = IsSwing & !IsZone,
426
+ IsOutZone = !IsZone,
427
+ IsHardHit = !is.na(ExitSpeed) & ExitSpeed >= 95 &
428
+ !PitchCall %in% c("FoulBall","FoulBallNotFieldable","FoulBallFieldable","FoulTip")
429
+ ) %>%
430
+ group_by(`Pitch Type`=PitchType) %>%
431
+ summarise(
432
+ `#` = n(),
433
+ `Usage%` = round(n() / nrow(pdata) * 100, 1),
434
+ Velo = round(mean(RelSpeed, na.rm=TRUE), 1),
435
+ iVB = round(mean(InducedVertBreak, na.rm=TRUE), 1),
436
+ HB = round(mean(HorzBreak, na.rm=TRUE), 1),
437
+ Spin = round(mean(SpinRate, na.rm=TRUE), 0),
438
+ RelH = round(mean(RelHeight, na.rm=TRUE), 1),
439
+ RelS = round(mean(RelSide, na.rm=TRUE), 1),
440
+ VAA = round(mean(VertApprAngle, na.rm=TRUE), 1),
441
+ HAA = round(mean(HorzApprAngle, na.rm=TRUE), 1),
442
+ `Zone%` = round(sum(IsZone, na.rm=TRUE) / n() * 100, 1),
443
+ `Strike%` = round(sum(IsStrike, na.rm=TRUE) / n() * 100, 1),
444
+ `Whiff%` = round(ifelse(sum(IsSwing)==0, NA,
445
+ sum(IsWhiff)/sum(IsSwing)*100), 1),
446
+ `Chase%` = round(ifelse(sum(IsOutZone)==0, NA,
447
+ sum(IsChase)/sum(IsOutZone)*100), 1),
448
+ `Hard Hit%`= round(ifelse(sum(!is.na(ExitSpeed))==0, NA,
449
+ sum(IsHardHit)/sum(!is.na(ExitSpeed))*100), 1),
450
+ .groups = "drop"
451
+ ) %>%
452
+ arrange(desc(`#`)) %>%
453
+ mutate(across(where(is.numeric), ~ifelse(is.nan(.), NA, .)))
454
+
455
+ output$pitch_type_table <- renderDT({
456
+ datatable(
457
+ pitch_table,
458
+ rownames = FALSE,
459
+ selection = "none",
460
+ options = list(
461
+ pageLength = 10,
462
+ dom = "t",
463
+ scrollX = TRUE,
464
+ ordering = TRUE,
465
+ columnDefs = list(
466
+ list(className="dt-center", targets=1:11),
467
+ list(className="dt-left", targets=0)
468
+ )
469
+ )
470
+ )
471
+ })
472
+
473
+ output$pitcher_modal <- renderUI({
474
+ tags$div(class="modal-backdrop",
475
+ tags$div(class="modal-box",
476
+ tags$button("βœ•", id="modal_close", class="modal-close"),
477
+ tags$p(paste(display_name, "β€”", pdata$PitcherTeam[1]),
478
+ class="modal-title"),
479
+ plotOutput("movement_plot", height="420px")
480
+ )
481
+ )
482
+ })
483
+
484
+ output$movement_plot <- renderPlot({ p })
485
+ })
486
  }
487
 
488
  shinyApp(ui, server)