igroffman commited on
Commit
9e5e0e1
·
verified ·
1 Parent(s): 52fe5de

Update app.R

Browse files
Files changed (1) hide show
  1. app.R +1323 -49
app.R CHANGED
@@ -1,58 +1,1332 @@
 
 
 
1
  library(shiny)
2
- library(bslib)
3
- library(dplyr)
4
  library(ggplot2)
 
 
 
 
 
 
 
 
 
 
 
 
5
 
6
- df <- readr::read_csv("penguins.csv")
7
- # Find subset of columns that are suitable for scatter plot
8
- df_num <- df |> select(where(is.numeric), -Year)
9
-
10
- ui <- page_sidebar(
11
- theme = bs_theme(bootswatch = "minty"),
12
- title = "Penguins explorer",
13
- sidebar = sidebar(
14
- varSelectInput("xvar", "X variable", df_num, selected = "Bill Length (mm)"),
15
- varSelectInput("yvar", "Y variable", df_num, selected = "Bill Depth (mm)"),
16
- checkboxGroupInput("species", "Filter by species",
17
- choices = unique(df$Species), selected = unique(df$Species)
18
- ),
19
- hr(), # Add a horizontal rule
20
- checkboxInput("by_species", "Show species", TRUE),
21
- checkboxInput("show_margins", "Show marginal plots", TRUE),
22
- checkboxInput("smooth", "Add smoother"),
23
- ),
24
- plotOutput("scatter")
25
- )
26
 
27
- server <- function(input, output, session) {
28
- subsetted <- reactive({
29
- req(input$species)
30
- df |> filter(Species %in% input$species)
31
- })
32
-
33
- output$scatter <- renderPlot(
34
- {
35
- p <- ggplot(subsetted(), aes(!!input$xvar, !!input$yvar)) +
36
- theme_light() +
37
- list(
38
- theme(legend.position = "bottom"),
39
- if (input$by_species) aes(color = Species),
40
- geom_point(),
41
- if (input$smooth) geom_smooth()
42
- )
43
 
44
- if (input$show_margins) {
45
- margin_type <- if (input$by_species) "density" else "histogram"
46
- p <- p |> ggExtra::ggMarginal(
47
- type = margin_type, margins = "both",
48
- size = 8, groupColour = input$by_species, groupFill = input$by_species
49
- )
50
- }
 
 
 
 
 
 
51
 
52
- p
53
- },
54
- res = 100
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
55
  )
56
  }
57
 
58
- shinyApp(ui, server)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ============================================================================
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)