Spaces:
Running
Running
| # ========================================================================= | |
| # Hyannis Harbor Hawks — Lower Level Overview App | |
| # Two tabs: (1) Hitter Overview (2) Pitcher Overview | |
| # Data source: college_26_LowerLevels.parquet (PastSeasonData private dataset) | |
| # UI theme comes from the attached theme.R | |
| # ========================================================================= | |
| library(shiny) | |
| library(bslib) | |
| library(glue) | |
| library(httr) | |
| library(arrow) | |
| library(dplyr) | |
| library(ggplot2) | |
| library(plotly) | |
| library(gt) | |
| library(gtExtras) | |
| library(tidyr) | |
| library(stringr) | |
| library(scales) | |
| library(base64enc) | |
| library(magick) | |
| source("theme.R") | |
| `%||%` <- function(a, b) if (!is.null(a)) a else b | |
| # Hawks logo (absolute path so the working dir doesn't matter in Docker) | |
| logo_b64 <- base64enc::base64encode("/app/HHLogo.png") | |
| logo_uri <- paste0("data:image/png;base64,", logo_b64) | |
| # Load + mirror pitcher silhouette for the inferred arm angle graphic | |
| img <- magick::image_read("https://i.imgur.com/RLlIVrc.png") | |
| img_raster_R <- as.raster(img) | |
| img_raster_L <- as.raster(magick::image_flop(img)) | |
| # ============================================================ | |
| # Data Loading | |
| # ============================================================ | |
| # Union of the columns the two overview tabs need | |
| needed_cols <- c( | |
| # identity | |
| "Batter", "Pitcher", "BatterSide", "BatterTeam", | |
| "PitcherThrows", "Date", | |
| # count | |
| "Balls", "Strikes", "PitchofPA", | |
| # pitch traits | |
| "TaggedPitchType", "RelSpeed", "SpinRate", "InducedVertBreak", "HorzBreak", | |
| "VertApprAngle", "Extension", "RelSide", "RelHeight", "stuff_plus", | |
| # location | |
| "PlateLocSide", "PlateLocHeight", | |
| # results | |
| "PitchCall", "PlayResult", "KorBB", "AutoHitType", "event_type", | |
| "ExitSpeed", "Angle", "Bearing", "Distance", | |
| # expected stats | |
| "prob_0", "prob_1", "prob_2", "prob_3", "prob_4", "xwOBA", "woba", "slg", | |
| # flags | |
| "is_hit", "is_at_bat", "is_plate_appearance", "is_whiff", "is_swing", | |
| "in_zone", "in_zone_whiff", "is_csw", "is_hard_hit", "is_k", "is_walk", | |
| "on_base", "chase" | |
| ) | |
| # Download a parquet from the HF private dataset (HF_Token set as a Space secret) | |
| download_private_parquet <- function(repo_id, filename, max_retries = 3) { | |
| url <- paste0("https://huggingface.co/datasets/", repo_id, "/resolve/main/", filename) | |
| api_key <- Sys.getenv("HF_Token") | |
| if (api_key == "") stop("HF_Token environment variable is not set.") | |
| for (attempt in 1:max_retries) { | |
| tryCatch({ | |
| response <- GET(url, add_headers(Authorization = paste("Bearer", api_key)), timeout(60)) | |
| if (status_code(response) == 200) { | |
| tmp <- tempfile(fileext = ".parquet") | |
| writeBin(content(response, "raw"), tmp) | |
| return(read_parquet(tmp, col_select = any_of(needed_cols))) | |
| } else { | |
| warning(paste("Attempt", attempt, "failed:", status_code(response))) | |
| } | |
| }, error = function(e) { | |
| warning(paste("Attempt", attempt, "error:", e$message)) | |
| if (attempt < max_retries) Sys.sleep(2) | |
| }) | |
| } | |
| stop(paste("Failed after", max_retries, "attempts")) | |
| } | |
| # Single data source for this app | |
| all_data <- download_private_parquet("HyannisHarborHawksCCBL/PastSeasonData", | |
| "college_26_LowerLevels.parquet") %>% | |
| mutate(Date = as.Date(Date)) | |
| # ============================================================ | |
| # Shared helper functions | |
| # ============================================================ | |
| # Pitch colors | |
| pitch_colors <- c( | |
| "Fastball" = "red", "Changeup" = "green4", "Curveball" = "blue", | |
| "Slider" = "gold", "Sweeper" = "maroon2", "Splitter" = "lightblue", | |
| "Cutter" = "brown", "Sinker" = "orange", "Knuckleball" = "#6A89A7" | |
| ) | |
| # blue - white - red color gradient | |
| make_color_fn <- function(min_val, max_val, | |
| low_color = "#3661ad", mid_color = "white", high_color = "#d82029") { | |
| if (is.na(min_val) || is.na(max_val) || min_val == max_val) return(function(x) "white") | |
| gradient_fn <- col_numeric( | |
| palette = c(low_color, mid_color, high_color), | |
| domain = c(min_val, max_val), | |
| na.color = "white" | |
| ) | |
| function(x) { | |
| x_clamped <- pmin(pmax(x, min_val, na.rm = TRUE), max_val, na.rm = TRUE) | |
| ifelse(is.na(x), "white", gradient_fn(x_clamped)) | |
| } | |
| } | |
| # red - white - blue color gradient | |
| make_color_fn_reverse <- function(min_val, max_val, | |
| low_color = "#d82029", mid_color = "white", high_color = "#3661ad") { | |
| make_color_fn(min_val, max_val, low_color, mid_color, high_color) | |
| } | |
| # Curves for spray-chart fence | |
| make_curve_segments <- function(x_start, y_start, x_end, y_end, curvature = 0.3, n = 40) { | |
| t <- seq(0, 1, length.out = n) | |
| cx <- (x_start + x_end) / 2 + curvature * (y_end - y_start) | |
| cy <- (y_start + y_end) / 2 - curvature * (x_end - x_start) | |
| x <- (1 - t)^2 * x_start + 2 * (1 - t) * t * cx + t^2 * x_end | |
| y <- (1 - t)^2 * y_start + 2 * (1 - t) * t * cy + t^2 * y_end | |
| idx <- seq_len(n - 1) | |
| tibble(x = x[idx], y = y[idx], xend = x[idx + 1], yend = y[idx + 1]) | |
| } | |
| # ============================================================ | |
| # HITTER OVERVIEW FUNCTIONS | |
| # ============================================================ | |
| # Reference data for conditional formatting | |
| reference_stats <- function(df) { | |
| df %>% summarise( | |
| PA = sum(is_plate_appearance, na.rm = TRUE), | |
| Avg = mean(is_hit, na.rm = TRUE), | |
| OBP = mean(on_base, na.rm = TRUE), | |
| SLG = mean(slg, na.rm = TRUE), | |
| `Chase%` = 100 * mean(chase, na.rm = TRUE), | |
| `Whiff%` = 100 * mean(is_whiff, na.rm = TRUE), | |
| `Z-Con%` = 100 * (1 - mean(in_zone_whiff, na.rm = TRUE)), | |
| `Avg EV` = mean(ExitSpeed, na.rm = TRUE), | |
| EV50 = quantile(ExitSpeed, .5, na.rm = TRUE), | |
| EV90 = quantile(ExitSpeed, .9, na.rm = TRUE), | |
| `HH%` = 100 * mean(is_hard_hit, na.rm = TRUE), | |
| `GB%` = 100 * (sum((AutoHitType == "GroundBall" & PitchCall == "InPlay"), na.rm = TRUE) / | |
| sum(PitchCall == "InPlay", na.rm = TRUE)), | |
| xwOBAcon = mean(xwOBA[PitchCall == "InPlay"], na.rm = TRUE), | |
| xwOBA = mean(xwOBA, na.rm = TRUE) | |
| ) | |
| } | |
| # overall hitter summary table | |
| batter_summary <- function(data, batter_name) { | |
| data <- data %>% | |
| filter(Batter == batter_name) %>% | |
| mutate(PitchGroup = case_when( | |
| TaggedPitchType %in% c("Fastball","Cutter","Sinker","FastBall") ~ "Fastball", | |
| TaggedPitchType %in% c("Slider","Curveball","Sweeper") ~ "Breaking", | |
| TaggedPitchType %in% c("Changeup","Splutter","Knuckleball") ~ "Offspeed", | |
| TRUE ~ "Other" | |
| )) | |
| summary_stats <- function(df) { | |
| df %>% summarise( | |
| PA = sum(is_plate_appearance, na.rm = TRUE), | |
| Avg = mean(is_hit, na.rm = TRUE), | |
| OBP = mean(on_base, na.rm = TRUE), | |
| SLG = mean(slg, na.rm = TRUE), | |
| `Chase%` = 100 * mean(chase, na.rm = TRUE), | |
| `Whiff%` = 100 * mean(is_whiff, na.rm = TRUE), | |
| `Z-Con%` = 100 * (1 - mean(in_zone_whiff, na.rm = TRUE)), | |
| `Avg EV` = mean(ExitSpeed, na.rm = TRUE), | |
| EV50 = quantile(ExitSpeed, .5, na.rm = TRUE), | |
| EV90 = quantile(ExitSpeed, .9, na.rm = TRUE), | |
| `HH%` = 100 * mean(is_hard_hit, na.rm = TRUE), | |
| `GB%` = 100 * (sum((AutoHitType == "GroundBall" & PitchCall == "InPlay"), na.rm = TRUE) / | |
| sum(PitchCall == "InPlay", na.rm = TRUE)), | |
| xwOBAcon = mean(xwOBA[PitchCall == "InPlay"], na.rm = TRUE), | |
| xwOBA = mean(xwOBA, na.rm = TRUE) | |
| ) | |
| } | |
| pitch_summary <- data %>% group_by(PitchGroup) %>% summary_stats() %>% rename(GROUP = PitchGroup) %>% filter(GROUP != "Other") | |
| handedness_summary <- data %>% group_by(PitcherThrows) %>% summary_stats() %>% rename(GROUP = PitcherThrows) | |
| overall_summary <- data %>% summary_stats() %>% mutate(GROUP = "Overall") | |
| full_summary <- bind_rows(pitch_summary, handedness_summary, overall_summary) | |
| full_summary %>% | |
| gt() %>% gt_theme_espn() %>% | |
| tab_header(title = paste0("Batter Summary — ", batter_name)) %>% | |
| tab_options(heading.align = "center") %>% | |
| sub_missing(missing_text = "—") %>% | |
| fmt_number(columns = c(Avg, OBP, SLG, xwOBA, xwOBAcon), decimals = 3) %>% | |
| fmt_number(columns = c(`Chase%`,`Whiff%`,`Z-Con%`,`Avg EV`,EV50,EV90,`HH%`,`GB%`), decimals = 1) %>% | |
| fmt_number(columns = PA, decimals = 0) %>% | |
| data_color(columns = `Z-Con%`, fn = make_color_fn(lg_ref$`Z-Con%` - 10, lg_ref$`Z-Con%` + 10)) %>% | |
| data_color(columns = `Avg EV`, fn = make_color_fn(lg_ref$`Avg EV` - 6, lg_ref$`Avg EV` + 6)) %>% | |
| data_color(columns = EV50, fn = make_color_fn(lg_ref$EV50 - 6, lg_ref$EV50 + 6)) %>% | |
| data_color(columns = EV90, fn = make_color_fn(lg_ref$EV90 - 6, lg_ref$EV90 + 6)) %>% | |
| data_color(columns = `HH%`, fn = make_color_fn(lg_ref$`HH%` - 12, lg_ref$`HH%` + 12)) %>% | |
| data_color(columns = xwOBA, fn = make_color_fn(lg_ref$xwOBA - .080, lg_ref$xwOBA + .040)) %>% | |
| data_color(columns = xwOBAcon, fn = make_color_fn(lg_ref$xwOBAcon - .080, lg_ref$xwOBAcon + .040)) %>% | |
| data_color(columns = `Chase%`, fn = make_color_fn_reverse(lg_ref$`Chase%` - 10, lg_ref$`Chase%` + 10)) %>% | |
| data_color(columns = `Whiff%`, fn = make_color_fn_reverse(lg_ref$`Whiff%` - 10, lg_ref$`Whiff%` + 10)) %>% | |
| tab_style(style = cell_text(color = "#002855"), | |
| locations = cells_body(columns = everything(), rows = everything())) %>% | |
| tab_style(style = cell_text(color = "#002855"), | |
| locations = cells_column_labels(columns = everything())) %>% | |
| tab_style(style = cell_text(color = "#002855", size = px(22)), | |
| locations = cells_title(groups = "title")) %>% | |
| tab_style(style = cell_borders(sides = c("top","bottom","left","right"), | |
| color = "#002855", weight = px(.5)), | |
| locations = cells_body(columns = everything(), rows = everything())) %>% | |
| tab_style(style = cell_borders(sides = "top", color = "#002855", weight = px(10)), | |
| locations = cells_body(rows = GROUP %in% c("Left"))) %>% | |
| tab_style(style = cell_borders(sides = "bottom", color = "#002855", weight = px(10)), | |
| locations = cells_body(rows = GROUP %in% c("Right"))) %>% | |
| tab_style(style = list(cell_text(weight = "bold"), cell_fill(color = "#f0f0f0")), | |
| locations = cells_body(rows = GROUP == "Overall")) %>% | |
| tab_style(style = list(cell_fill(color = "red"), cell_text(color = "white", weight = "bold")), | |
| locations = cells_body(columns = GROUP, rows = GROUP == "Fastball")) %>% | |
| tab_style(style = list(cell_fill(color = "green4"), cell_text(color = "white", weight = "bold")), | |
| locations = cells_body(columns = GROUP, rows = GROUP == "Offspeed")) %>% | |
| tab_style(style = list(cell_fill(color = "blue"), cell_text(color = "white", weight = "bold")), | |
| locations = cells_body(columns = GROUP, rows = GROUP == "Breaking")) %>% | |
| tab_style(style = list(cell_fill(color = "#2962FF"), cell_text(color = "white", weight = "bold")), | |
| locations = cells_body(columns = GROUP, rows = GROUP == "Right")) %>% | |
| tab_style(style = list(cell_fill(color = "#C62828"), cell_text(color = "white", weight = "bold")), | |
| locations = cells_body(columns = GROUP, rows = GROUP == "Left")) %>% | |
| tab_style(style = list(cell_fill(color = "orange"), cell_text(color = "white", weight = "bold")), | |
| locations = cells_body(columns = GROUP, rows = GROUP == "Overall")) | |
| } | |
| # percentile references for the percentile chart | |
| get_percentile_refs_hitter <- function(data) { | |
| data %>% | |
| group_by(Batter) %>% | |
| summarise( | |
| xwOBA = mean(xwOBA, na.rm = TRUE), | |
| xBA = 1 - mean(prob_0, na.rm = TRUE), | |
| `Avg EV` = mean(ExitSpeed, na.rm = TRUE), | |
| `HH%` = 100 * mean(is_hard_hit, na.rm = TRUE), | |
| `SS%` = 100 * sum(PitchCall == "InPlay" & Angle >= 8 & Angle <= 32, na.rm = TRUE) / | |
| sum(PitchCall == "InPlay", na.rm = TRUE), | |
| `P-Air%` = 100 * sum(if_else(BatterSide == "Right", Bearing < -15, Bearing > 15) & | |
| AutoHitType %in% c("FlyBall","LineDrive","Popup") & | |
| PitchCall == "InPlay", na.rm = TRUE) / | |
| sum(PitchCall == "InPlay", na.rm = TRUE), | |
| `HR/FB%` = 100 * sum(AutoHitType == "FlyBall" & PlayResult == "Homerun" & | |
| PitchCall == "InPlay", na.rm = TRUE) / | |
| sum(AutoHitType == "FlyBall" & PitchCall == "InPlay", na.rm = TRUE), | |
| `Chase%` = 100 * mean(chase, na.rm = TRUE), | |
| `Whiff%` = 100 * mean(is_whiff, na.rm = TRUE), | |
| `Z-Con%` = 100 * (1 - mean(in_zone_whiff, na.rm = TRUE)), | |
| `K%` = 100 * mean(is_k, na.rm = TRUE), | |
| `BB%` = 100 * mean(is_walk, na.rm = TRUE), | |
| number = sum(is_plate_appearance, na.rm = TRUE), | |
| .groups = "drop" | |
| ) %>% | |
| select(-Batter) %>% | |
| filter(number >= 10) %>% | |
| select(-number) | |
| } | |
| # savant-style percentile chart | |
| batter_percentiles <- function(df, batter, summary_ref) { | |
| batter_split <- df %>% | |
| filter(!is.na(TaggedPitchType), Batter == batter) %>% | |
| summarise( | |
| xwOBA = mean(xwOBA, na.rm = TRUE), | |
| xBA = 1 - mean(prob_0, na.rm = TRUE), | |
| `Avg EV` = mean(ExitSpeed, na.rm = TRUE), | |
| `HH%` = 100 * mean(is_hard_hit, na.rm = TRUE), | |
| `SS%` = 100 * sum(PitchCall == "InPlay" & Angle >= 8 & Angle <= 32, na.rm = TRUE) / | |
| sum(PitchCall == "InPlay", na.rm = TRUE), | |
| `P-Air%` = 100 * sum(if_else(BatterSide == "Right", Bearing < -15, Bearing > 15) & | |
| AutoHitType %in% c("FlyBall","LineDrive","Popup") & | |
| PitchCall == "InPlay", na.rm = TRUE) / | |
| sum(PitchCall == "InPlay", na.rm = TRUE), | |
| `HR/FB%` = 100 * sum(AutoHitType == "FlyBall" & PlayResult == "Homerun" & | |
| PitchCall == "InPlay", na.rm = TRUE) / | |
| sum(AutoHitType == "FlyBall" & PitchCall == "InPlay", na.rm = TRUE), | |
| `Chase%` = 100 * mean(chase, na.rm = TRUE), | |
| `Whiff%` = 100 * mean(is_whiff, na.rm = TRUE), | |
| `Z-Con%` = 100 * (1 - mean(in_zone_whiff, na.rm = TRUE)), | |
| `K%` = 100 * mean(is_k, na.rm = TRUE), | |
| `BB%` = 100 * mean(is_walk, na.rm = TRUE), | |
| .groups = "drop" | |
| ) | |
| combined <- bind_cols( | |
| batter_split, | |
| batter_split %>% | |
| mutate(across(everything(), ~ { | |
| if (cur_column() %in% c("Chase%","Whiff%","K%")) { | |
| round(mean(summary_ref[[cur_column()]] >= .x, na.rm = TRUE) * 100) | |
| } else { | |
| round(mean(summary_ref[[cur_column()]] <= .x, na.rm = TRUE) * 100) | |
| } | |
| }, .names = "{.col}_percentile")) %>% | |
| select(matches("_percentile")) | |
| ) | |
| custom_order <- c("xwOBA","xBA","Avg EV","HH%","SS%", | |
| "P-Air%","HR/FB%","Chase%","Whiff%","Z-Con%","K%","BB%") | |
| metrics <- combined %>% | |
| pivot_longer(cols = everything(), names_to = "Metric_Type", values_to = "Value") %>% | |
| mutate( | |
| Metric = str_remove(Metric_Type, "_percentile"), | |
| Type = ifelse(str_detect(Metric_Type, "_percentile"), "Percentile", "Value") | |
| ) %>% | |
| select(Metric, Type, Value) %>% | |
| pivot_wider(names_from = Type, values_from = Value) %>% | |
| filter(!is.na(Percentile) & !is.na(Value)) %>% | |
| mutate( | |
| Metric = factor(Metric, levels = custom_order), | |
| color = gradient_n_pal(c("#164EA7","#c7dcdc","#C8102E"))(Percentile / 100), | |
| Value = case_when( | |
| Metric %in% c("xwOBA","xBA") ~ sprintf("%.3f", Value), | |
| TRUE ~ sprintf("%.1f", Value) | |
| ) | |
| ) | |
| ggplot(metrics, aes(y = factor(Metric, levels = rev(custom_order)))) + | |
| geom_tile(aes(x = 50, width = 100), fill = "#c7dcdc", alpha = 0.3, height = 0.25) + | |
| geom_tile(aes(x = Percentile / 2, width = Percentile, fill = color), height = 0.7) + | |
| annotate("segment", x = c(10,50,90), xend = c(10,50,90), | |
| y = 0, yend = length(custom_order) + 0.35, | |
| color = "white", linewidth = 1.5, alpha = 0.5) + | |
| geom_segment(aes(x = -2, xend = -16, | |
| y = as.numeric(factor(Metric, levels = rev(custom_order))) - 0.3, | |
| yend = as.numeric(factor(Metric, levels = rev(custom_order))) - 0.3), | |
| linetype = "longdash", color = "#399098", linewidth = 1) + | |
| geom_segment(aes(x = 102, xend = 113, | |
| y = as.numeric(factor(Metric, levels = rev(custom_order))) - 0.3, | |
| yend = as.numeric(factor(Metric, levels = rev(custom_order))) - 0.3), | |
| linetype = "longdash", color = "#399098", linewidth = 1) + | |
| geom_text(aes(x = -3, label = Metric), hjust = 1, size = 5) + | |
| geom_text(aes(x = 103, label = Value), hjust = 0, size = 5) + | |
| geom_point(aes(x = Percentile, fill = color), | |
| color = "white", size = 12, shape = 21, stroke = 2.25) + | |
| geom_text(aes(x = Percentile, label = Percentile), | |
| size = 4.5, color = "white", fontface = "bold") + | |
| scale_x_continuous(limits = c(-16, 113), expand = c(0,0)) + | |
| scale_fill_identity() + scale_color_identity() + | |
| theme_minimal() + | |
| theme(axis.text = element_blank(), axis.title = element_blank(), | |
| panel.grid = element_blank()) | |
| } | |
| # hits spray chart | |
| spray_chart <- function(data, batter_name) { | |
| hits <- data %>% | |
| filter(Batter == batter_name, | |
| PlayResult %in% c("Single","Double","Triple","Homerun","HomeRun", "Out", "Error", | |
| "Sacrifice", "FieldersChoice")) %>% | |
| mutate( | |
| hit_type = case_when( | |
| PlayResult == "Single" ~ "SINGLE", | |
| PlayResult == "Double" ~ "DOUBLE", | |
| PlayResult == "Triple" ~ "TRIPLE", | |
| PlayResult %in% c("Homerun","HomeRun") ~ "HOME RUN", | |
| PlayResult %in% c("Out", "Sacrifice", "FieldersChoice") ~ "OUT", | |
| PlayResult == "Error" ~ "REACH ON ERROR" | |
| ), | |
| hit_type = factor(hit_type, levels = c("SINGLE","DOUBLE","TRIPLE","HOME RUN", "OUT", "REACH ON ERROR")), | |
| spray_x = Distance * sin(Bearing * pi / 180), | |
| spray_y = Distance * cos(Bearing * pi / 180) | |
| ) | |
| curve1 <- make_curve_segments(89.095, 89.095, -3, 160, curvature = 0.36, n = 60) | |
| curve2 <- make_curve_segments(-89.095, 89.095, 3, 160, curvature = -0.36, n = 60) | |
| fence_x <- c(0, curve1$x, curve1$xend[nrow(curve1)], | |
| curve2$xend[nrow(curve2)], rev(curve2$x), 0) | |
| fence_y <- c(0, curve1$y, curve1$yend[nrow(curve1)], | |
| curve2$yend[nrow(curve2)], rev(curve2$y), 0) | |
| theta <- seq(-45, 45, length.out = 100) * pi / 180 | |
| fair_x <- c(0, 350 * sin(theta), 0) | |
| fair_y <- c(0, 350 * cos(theta), 0) | |
| colors <- c("SINGLE" = "yellow", "DOUBLE" = "orange", | |
| "TRIPLE" = "purple", "HOME RUN" = "red", | |
| "OUT" = "#808080", "REACH ON ERROR" = "green2") | |
| p <- ggplot() + | |
| geom_polygon(aes(x = fair_x, y = fair_y), fill = "#50BA49", alpha = 0.5) + | |
| geom_polygon(aes(x = fence_x, y = fence_y), fill = "#B06F63", alpha = 0.5) + | |
| geom_segment(aes(x = 0, y = 0, xend = 63.64, yend = 63.64), color = "black", linewidth = .5) + | |
| geom_segment(aes(x = 63.64, y = 63.64, xend = 0, yend = 127.28), color = "black", linewidth = .5) + | |
| geom_segment(aes(x = 0, y = 127.28, xend = -63.64, yend = 63.64), color = "black", linewidth = .5) + | |
| geom_segment(aes(x = -63.64, y = 63.64, xend = 0, yend = 0), color = "black", linewidth = .5) + | |
| geom_segment(data = curve1, aes(x=x,y=y,xend=xend,yend=yend), | |
| linewidth=0.6, color="black", lineend="round") + | |
| geom_segment(data = curve2, aes(x=x,y=y,xend=xend,yend=yend), | |
| linewidth=0.6, color="black", lineend="round") + | |
| geom_segment(aes(x=-1, y=160, xend=1, yend=160), color="black") + | |
| annotate("text", x=c(-155,155), y=135, label="200", size=2.5, color="black") + | |
| annotate("text", x=c(-190,190), y=170, label="250", size=2.5, color="black") + | |
| annotate("text", x=c(-227,227), y=205, label="300", size=2.5, color="black") + | |
| annotate("text", x=c(-262,262), y=242, label="350", size=2.5, color="black") + | |
| annotate("text", x=c(-297,297), y=277, label="400", size=2.5, color="black") + | |
| geom_segment(aes(x=0,y=0,xend= 318.2, yend=318.2), color="black", linewidth=0.5) + | |
| geom_segment(aes(x=0,y=0,xend=-318.2, yend=318.2), color="black", linewidth=0.5) + | |
| geom_point(data = hits, | |
| aes(x=spray_x, y=spray_y, fill=hit_type, | |
| text=paste0(hit_type,"<br>", | |
| "EV: ", round(ExitSpeed,1),"<br>", | |
| "LA: ", round(Angle,1), "<br>", | |
| "Dist: ", round(Distance,0), " ft<br>", | |
| "xwOBA: ",round(xwOBA,3), "<br>", | |
| "Pitch: ",TaggedPitchType)), | |
| color="black", shape=21, size=3, alpha=0.85, stroke=0.8) + | |
| scale_fill_manual(values = colors, name = "") + | |
| labs(title = paste0("Hits Spray Chart — ", batter_name)) + | |
| ylim(-30, 400) + xlim(-330, 330) + coord_equal() + | |
| theme_void() + | |
| theme(plot.title = element_text(hjust=0.5, face="bold", size=16), | |
| legend.position = "right", | |
| plot.background = element_rect(fill="white", color=NA)) | |
| ggplotly(p, tooltip="text") %>% layout(hovermode="closest") | |
| } | |
| # ============================================================ | |
| # PITCHER OVERVIEW FUNCTIONS | |
| # ============================================================ | |
| # movement plot | |
| movement_plot <- function(data, pitcher_name) { | |
| data <- data %>% filter(Pitcher == pitcher_name, TaggedPitchType != "Other") | |
| circle_shape <- function(r) { | |
| list(type = "circle", x0 = -r, x1 = r, y0 = -r, y1 = r, | |
| line = list(color = "rgba(128,128,128,0.5)", width = 1, | |
| dash = if (r %in% c(6, 18)) "dot" else "solid"), | |
| fillcolor = "transparent") | |
| } | |
| shapes <- c( | |
| list( | |
| list(type = "line", x0 = 0, x1 = 0, y0 = -28, y1 = 28, | |
| line = list(color = "rgba(128,128,128,0.6)", dash = "dash", width = 1)), | |
| list(type = "line", x0 = -28, x1 = 28, y0 = 0, y1 = 0, | |
| line = list(color = "rgba(128,128,128,0.6)", dash = "dash", width = 1)) | |
| ), | |
| lapply(c(6, 12, 18, 24), circle_shape) | |
| ) | |
| tick_labels <- c( | |
| lapply(c(24, 12, 12, 24), function(val) { | |
| list(x = val, y = 0, text = as.character(val), showarrow = FALSE, | |
| yshift = -12, font = list(size = 10, color = "grey40")) | |
| }), | |
| lapply(c(24, 12, 12, 24), function(val) { | |
| list(x = 0, y = val, text = as.character(val), showarrow = FALSE, | |
| xshift = -12, font = list(size = 10, color = "grey40")) | |
| }), | |
| list( | |
| list(x = 0.5, y = 0, xref = "paper", yref = "paper", | |
| text = "Horizontal Break (inches)", showarrow = FALSE, | |
| yshift = -15, font = list(size = 13, color = "black")), | |
| list(x = 0, y = 0.5, xref = "paper", yref = "paper", | |
| text = "Induced Vertical Break (inches)", showarrow = FALSE, | |
| xanchor = "center", yanchor = "middle", xshift = 75, | |
| textangle = -90, font = list(size = 13, color = "black")) | |
| ) | |
| ) | |
| plot_ly(data, x = ~HorzBreak, y = ~InducedVertBreak, color = ~TaggedPitchType, | |
| colors = pitch_colors, type = "scatter", mode = "markers", | |
| marker = list(size = 10, opacity = 0.8), hoverinfo = "text", | |
| text = ~paste("Pitch:", TaggedPitchType, | |
| "<br>HB:", round(HorzBreak, 1), | |
| "<br>IVB:", round(InducedVertBreak, 1), | |
| "<br>Velo:", round(RelSpeed, 1), | |
| "<br>Stuff Plus:", round(stuff_plus, 1))) %>% | |
| layout( | |
| title = list(text = paste0("Movement Plot — ", pitcher_name), | |
| font = list(size = 18), x = 0.5, y = 0.98, | |
| xanchor = "center", xref = "paper"), | |
| xaxis = list(title = "", showticklabels = FALSE, showgrid = FALSE, | |
| zeroline = FALSE, range = c(-30, 30), | |
| scaleanchor = "y", scaleratio = 1), | |
| yaxis = list(title = "", showticklabels = FALSE, showgrid = FALSE, | |
| zeroline = FALSE, range = c(-30, 30)), | |
| shapes = shapes, annotations = tick_labels, | |
| plot_bgcolor = "white", paper_bgcolor = "white", | |
| legend = list(orientation = "v", x = 0.85, y = 0.95, xanchor = "left", | |
| yanchor = "top", font = list(size = 12), | |
| bgcolor = "rgba(255,255,255,0.8)"), | |
| autosize = TRUE, margin = list(l = 40, r = 10, t = 40, b = 40), | |
| showlegend = TRUE | |
| ) | |
| } | |
| # Pitcher league reference for conditional formatting | |
| pitcher_reference_stats <- function(df) { | |
| df %>% summarise( | |
| `WHIFF%` = 100 * mean(is_whiff, na.rm = TRUE), | |
| `IZW%` = 100 * sum(is_whiff[in_zone == 1], na.rm = TRUE) / | |
| sum(is_swing[in_zone == 1], na.rm = TRUE), | |
| `CSW%` = 100 * mean(is_csw, na.rm = TRUE), | |
| `CHASE%` = 100 * mean(chase, na.rm = TRUE), | |
| `ZONE%` = 100 * mean(in_zone, na.rm = TRUE), | |
| `STUFF+` = mean(stuff_plus, na.rm = TRUE), | |
| EV = mean(ExitSpeed, na.rm = TRUE), | |
| EV50 = mean(ExitSpeed[!is.na(ExitSpeed) & | |
| ExitSpeed >= quantile(ExitSpeed, 0.5, na.rm = TRUE)], na.rm = TRUE), | |
| EV90 = mean(ExitSpeed[!is.na(ExitSpeed) & | |
| ExitSpeed >= quantile(ExitSpeed, 0.9, na.rm = TRUE)], na.rm = TRUE), | |
| xwOBA = mean(xwOBA, na.rm = TRUE), | |
| xwOBAcon = mean(xwOBA[PitchCall == "InPlay"], na.rm = TRUE) | |
| ) | |
| } | |
| # pitcher summary overview table | |
| pitcher_summary <- function(data, pitcher_name) { | |
| pitchdata <- data %>% filter(Pitcher == pitcher_name, TaggedPitchType != "Other") | |
| pitch_summary <- pitchdata %>% | |
| group_by(TaggedPitchType) %>% | |
| summarise( | |
| usage_temp = round((n() / nrow(pitchdata)) * 100, 1), | |
| `USAGE%` = round((n() / nrow(pitchdata)) * 100, 1), | |
| NUM = n(), | |
| BBE = sum(event_type %in% c("Field Out", "Home Run", "Single", "Double", "Triple")), | |
| VELO = round(mean(RelSpeed, na.rm = TRUE), 1), | |
| `MAX VELO` = round(max(RelSpeed, na.rm = TRUE), 1), | |
| SPIN = round(mean(SpinRate, na.rm = TRUE), 0), | |
| IVB = round(mean(InducedVertBreak, na.rm = TRUE), 1), | |
| HB = round(mean(HorzBreak, na.rm = TRUE), 1), | |
| VAA = round(mean(VertApprAngle, na.rm = TRUE), 2), | |
| EXT = round(mean(Extension, na.rm = TRUE), 1), | |
| RELS = round(mean(RelSide, na.rm = TRUE), 1), | |
| RELH = round(mean(RelHeight, na.rm = TRUE), 1), | |
| EV = round(mean(ExitSpeed, na.rm = TRUE), 1), | |
| EV50 = round(mean(ExitSpeed[!is.na(ExitSpeed) & | |
| ExitSpeed >= quantile(ExitSpeed, 0.5, na.rm = TRUE)], na.rm = TRUE), 1), | |
| EV90 = round(mean(ExitSpeed[!is.na(ExitSpeed) & | |
| ExitSpeed >= quantile(ExitSpeed, 0.9, na.rm = TRUE)], na.rm = TRUE), 1), | |
| `WHIFF%` = round(100 * mean(is_whiff, na.rm = TRUE), 1), | |
| `IZW%` = round(100 * sum(is_whiff[in_zone == 1], na.rm = TRUE) / | |
| sum(is_swing[in_zone == 1], na.rm = TRUE), 1), | |
| `CSW%` = round(100 * mean(is_csw, na.rm = TRUE), 1), | |
| `CHASE%` = round(100 * mean(chase, na.rm = TRUE), 1), | |
| `ZONE%` = round(100 * mean(in_zone, na.rm = TRUE), 1), | |
| `GB%` = round(100 * (sum((AutoHitType == "GroundBall" & PitchCall == "InPlay"), na.rm = TRUE) / | |
| sum(PitchCall == "InPlay", na.rm = TRUE)), 2), | |
| xwOBAcon = round(mean(xwOBA[PitchCall == "InPlay"], na.rm = TRUE), 3), | |
| xwOBA = round(mean(xwOBA, na.rm = TRUE), 3), | |
| `STUFF+` = round(mean(stuff_plus, na.rm = TRUE), 0), | |
| .groups = "drop" | |
| ) %>% | |
| rename(GROUP = TaggedPitchType) %>% | |
| arrange(desc(usage_temp)) %>% | |
| select(-usage_temp) | |
| handedness_summary <- pitchdata %>% | |
| group_by(BatterSide) %>% | |
| summarise( | |
| `USAGE%` = round((n() / nrow(pitchdata)) * 100, 1), NUM = n(), | |
| BBE = sum(event_type %in% c("Field Out", "Home Run", "Single", "Double", "Triple")), | |
| VELO = NA_real_, `MAX VELO` = NA_real_, SPIN = NA_real_, | |
| IVB = NA_real_, HB = NA_real_, EXT = NA_real_, RELS = NA_real_, RELH = NA_real_, | |
| EV = round(mean(ExitSpeed, na.rm = TRUE), 1), | |
| EV50 = round(mean(ExitSpeed[!is.na(ExitSpeed) & | |
| ExitSpeed >= quantile(ExitSpeed, 0.5, na.rm = TRUE)], na.rm = TRUE), 1), | |
| EV90 = round(mean(ExitSpeed[!is.na(ExitSpeed) & | |
| ExitSpeed >= quantile(ExitSpeed, 0.9, na.rm = TRUE)], na.rm = TRUE), 1), | |
| `WHIFF%` = round(100 * mean(is_whiff, na.rm = TRUE), 1), | |
| `IZW%` = round(100 * sum(is_whiff[in_zone == 1], na.rm = TRUE) / | |
| sum(is_swing[in_zone == 1], na.rm = TRUE), 1), | |
| `CSW%` = round(100 * mean(is_csw, na.rm = TRUE), 1), | |
| `CHASE%` = round(100 * mean(chase, na.rm = TRUE), 1), | |
| `ZONE%` = round(100 * mean(in_zone, na.rm = TRUE), 1), | |
| `GB%` = round(100 * (sum((AutoHitType == "GroundBall" & PitchCall == "InPlay"), na.rm = TRUE) / | |
| sum(PitchCall == "InPlay", na.rm = TRUE)), 2), | |
| xwOBAcon = round(mean(xwOBA[PitchCall == "InPlay"], na.rm = TRUE), 3), | |
| xwOBA = round(mean(xwOBA, na.rm = TRUE), 3), | |
| `STUFF+` = round(mean(stuff_plus, na.rm = TRUE), 0), | |
| .groups = "drop" | |
| ) %>% rename(GROUP = BatterSide) | |
| overall_summary <- data %>% | |
| filter(Pitcher == pitcher_name) %>% | |
| summarise( | |
| `USAGE%` = NA_real_, NUM = n(), | |
| BBE = sum(event_type %in% c("Field Out", "Home Run", "Single", "Double", "Triple")), | |
| VELO = NA_real_, `MAX VELO` = NA_real_, SPIN = NA_real_, | |
| IVB = NA_real_, HB = NA_real_, EXT = NA_real_, RELS = NA_real_, RELH = NA_real_, | |
| EV = round(mean(ExitSpeed, na.rm = TRUE), 1), | |
| EV50 = round(mean(ExitSpeed[!is.na(ExitSpeed) & | |
| ExitSpeed >= quantile(ExitSpeed, 0.5, na.rm = TRUE)], na.rm = TRUE), 1), | |
| EV90 = round(mean(ExitSpeed[!is.na(ExitSpeed) & | |
| ExitSpeed >= quantile(ExitSpeed, 0.9, na.rm = TRUE)], na.rm = TRUE), 1), | |
| `WHIFF%` = round(100 * mean(is_whiff, na.rm = TRUE), 1), | |
| `IZW%` = round(100 * sum(is_whiff[in_zone == 1], na.rm = TRUE) / | |
| sum(is_swing[in_zone == 1], na.rm = TRUE), 1), | |
| `CSW%` = round(100 * mean(is_csw, na.rm = TRUE), 1), | |
| `CHASE%` = round(100 * mean(chase, na.rm = TRUE), 1), | |
| `ZONE%` = round(100 * mean(in_zone, na.rm = TRUE), 1), | |
| `GB%` = round(100 * (sum((AutoHitType == "GroundBall" & PitchCall == "InPlay"), na.rm = TRUE) / | |
| sum(PitchCall == "InPlay", na.rm = TRUE)), 2), | |
| xwOBAcon = round(mean(xwOBA[PitchCall == "InPlay"], na.rm = TRUE), 3), | |
| xwOBA = round(mean(xwOBA, na.rm = TRUE), 3), | |
| `STUFF+` = round(mean(stuff_plus, na.rm = TRUE), 0) | |
| ) %>% mutate(GROUP = "Overall") %>% select(GROUP, everything()) | |
| full_summary <- bind_rows(pitch_summary, handedness_summary, overall_summary) | |
| full_summary %>% | |
| gt() %>% | |
| gt_theme_espn() %>% | |
| tab_header(title = paste0("Pitch Summary — ", pitcher_name)) %>% | |
| tab_options(heading.align = "center") %>% | |
| sub_missing(missing_text = "—") %>% | |
| tab_style(style = cell_text(color = "#002855"), | |
| locations = cells_body(columns = everything(), rows = everything())) %>% | |
| tab_style(style = cell_text(color = "#002855"), | |
| locations = cells_column_labels(columns = everything())) %>% | |
| tab_style(style = cell_text(color = "#002855", size = px(22)), | |
| locations = cells_title(groups = "title")) %>% | |
| tab_style(style = cell_borders(sides = c("top","bottom","left","right"), | |
| color = "#002855", weight = px(.5)), | |
| locations = cells_body(columns = everything(), rows = everything())) %>% | |
| tab_style(style = cell_borders(sides = "top", color = "#002855", weight = px(10)), | |
| locations = cells_body(rows = GROUP %in% c("Left"))) %>% | |
| tab_style(style = cell_borders(sides = "bottom", color = "#002855", weight = px(10)), | |
| locations = cells_body(rows = GROUP %in% c("Right"))) %>% | |
| tab_style(style = list(cell_text(weight = "bold"), cell_fill(color = "#f0f0f0")), | |
| locations = cells_body(rows = GROUP == "Overall")) %>% | |
| tab_style(style = list(cell_fill(color = "red"), cell_text(color = "white", weight = "bold")), | |
| locations = cells_body(columns = GROUP, rows = GROUP == "Fastball")) %>% | |
| tab_style(style = list(cell_fill(color = "green4"), cell_text(color = "white", weight = "bold")), | |
| locations = cells_body(columns = GROUP, rows = GROUP == "Changeup")) %>% | |
| tab_style(style = list(cell_fill(color = "blue"), cell_text(color = "white", weight = "bold")), | |
| locations = cells_body(columns = GROUP, rows = GROUP == "Curveball")) %>% | |
| tab_style(style = list(cell_fill(color = "gold"), cell_text(color = "black", weight = "bold")), | |
| locations = cells_body(columns = GROUP, rows = GROUP == "Slider")) %>% | |
| tab_style(style = list(cell_fill(color = "maroon2"), cell_text(color = "white", weight = "bold")), | |
| locations = cells_body(columns = GROUP, rows = GROUP == "Sweeper")) %>% | |
| tab_style(style = list(cell_fill(color = "lightblue"), cell_text(color = "black", weight = "bold")), | |
| locations = cells_body(columns = GROUP, rows = GROUP == "Splitter")) %>% | |
| tab_style(style = list(cell_fill(color = "brown"), cell_text(color = "white", weight = "bold")), | |
| locations = cells_body(columns = GROUP, rows = GROUP == "Cutter")) %>% | |
| tab_style(style = list(cell_fill(color = "orange"), cell_text(color = "white", weight = "bold")), | |
| locations = cells_body(columns = GROUP, rows = GROUP == "Sinker")) %>% | |
| tab_style(style = list(cell_fill(color = "#6A89A7"), cell_text(color = "white", weight = "bold")), | |
| locations = cells_body(columns = GROUP, rows = GROUP == "Knuckleball")) %>% | |
| tab_style(style = list(cell_fill(color = "#2962FF"), cell_text(color = "white", weight = "bold")), | |
| locations = cells_body(columns = GROUP, rows = GROUP == "Right")) %>% | |
| tab_style(style = list(cell_fill(color = "#C62828"), cell_text(color = "white", weight = "bold")), | |
| locations = cells_body(columns = GROUP, rows = GROUP == "Left")) %>% | |
| tab_style(style = list(cell_fill(color = "#333333"), cell_text(color = "white", weight = "bold")), | |
| locations = cells_body(columns = GROUP, rows = GROUP == "Overall")) %>% | |
| data_color(columns = `WHIFF%`, fn = make_color_fn(pitch_lg_ref$`WHIFF%` - 12.5, pitch_lg_ref$`WHIFF%` + 12.5)) %>% | |
| data_color(columns = `IZW%`, fn = make_color_fn(pitch_lg_ref$`IZW%` - 10, pitch_lg_ref$`IZW%` + 10)) %>% | |
| data_color(columns = `CSW%`, fn = make_color_fn(pitch_lg_ref$`CSW%` - 10, pitch_lg_ref$`CSW%` + 10)) %>% | |
| data_color(columns = `CHASE%`, fn = make_color_fn(pitch_lg_ref$`CHASE%` - 12.5, pitch_lg_ref$`CHASE%` + 12.5)) %>% | |
| data_color(columns = `ZONE%`, fn = make_color_fn(pitch_lg_ref$`ZONE%` - 15, pitch_lg_ref$`ZONE%` + 15)) %>% | |
| data_color(columns = `STUFF+`, fn = make_color_fn(pitch_lg_ref$`STUFF+` - 15, pitch_lg_ref$`STUFF+` + 15)) %>% | |
| data_color(columns = EV, fn = make_color_fn_reverse(pitch_lg_ref$EV - 12.5, pitch_lg_ref$EV + 12.5)) %>% | |
| data_color(columns = EV50, fn = make_color_fn_reverse(pitch_lg_ref$EV50 - 10, pitch_lg_ref$EV50 + 10)) %>% | |
| data_color(columns = EV90, fn = make_color_fn_reverse(pitch_lg_ref$EV90 - 10, pitch_lg_ref$EV90 + 10)) %>% | |
| data_color(columns = xwOBA, fn = make_color_fn_reverse(pitch_lg_ref$xwOBA - .10, pitch_lg_ref$xwOBA + .10)) %>% | |
| data_color(columns = xwOBAcon, fn = make_color_fn_reverse(pitch_lg_ref$xwOBAcon - .10, pitch_lg_ref$xwOBAcon + .10)) %>% | |
| fmt_number(columns = xwOBA, decimals = 3) %>% | |
| fmt_number(columns = xwOBAcon, decimals = 3) %>% | |
| fmt_number(columns = c(`WHIFF%`, `IZW%`, `CSW%`, `CHASE%`, `ZONE%`, EV, EV50, EV90), decimals = 1) | |
| } | |
| # reference df for the savant-style percentile chart | |
| get_percentile_refs_pitcher <- function(data) { | |
| data %>% | |
| group_by(Pitcher) %>% | |
| summarise( | |
| xBAcon = 1 - mean(prob_0, na.rm = TRUE), | |
| xwOBA = mean(xwOBA, na.rm = TRUE), | |
| `Stuff+` = round(mean(stuff_plus, na.rm = TRUE)), | |
| `FB Velo` = mean(RelSpeed[TaggedPitchType == "Fastball"], na.rm = TRUE), | |
| `Avg EV` = mean(ExitSpeed, na.rm = TRUE), | |
| EV50 = mean(ExitSpeed[!is.na(ExitSpeed) & | |
| ExitSpeed >= quantile(ExitSpeed, 0.5, na.rm = TRUE)], na.rm = TRUE), | |
| EV90 = mean(ExitSpeed[!is.na(ExitSpeed) & | |
| ExitSpeed >= quantile(ExitSpeed, 0.9, na.rm = TRUE)], na.rm = TRUE), | |
| `HH%` = 100 * mean(is_hard_hit, na.rm = TRUE), | |
| `GB%` = 100 * sum(AutoHitType == "GroundBall" & PitchCall == "InPlay", na.rm = TRUE) / | |
| sum(PitchCall == "InPlay", na.rm = TRUE), | |
| `Chase%` = 100 * mean(chase, na.rm = TRUE), | |
| `Whiff%` = 100 * mean(is_whiff, na.rm = TRUE), | |
| `IZW%` = 100 * sum(is_whiff[in_zone == 1], na.rm = TRUE) / | |
| sum(is_swing[in_zone == 1], na.rm = TRUE), | |
| `K%` = 100 * mean(is_k, na.rm = TRUE), | |
| `BB%` = 100 * mean(is_walk, na.rm = TRUE), | |
| `Ext` = mean(Extension, na.rm = TRUE), | |
| number = sum(is_plate_appearance, na.rm = TRUE), | |
| .groups = "drop" | |
| ) %>% | |
| select(-Pitcher) %>% | |
| filter(number >= 30) %>% | |
| select(-number) %>% | |
| mutate(across(everything(), as.numeric)) | |
| } | |
| # savant-style percentile chart | |
| pitcher_percentiles <- function(df, pitcher, summary_ref) { | |
| pitcher_stats <- df %>% | |
| filter(Pitcher == pitcher) %>% | |
| summarise( | |
| xBAcon = 1 - mean(prob_0, na.rm = TRUE), | |
| xwOBA = mean(xwOBA, na.rm = TRUE), | |
| `Stuff+` = mean(stuff_plus, na.rm = TRUE), | |
| `FB Velo` = mean(RelSpeed[TaggedPitchType == "Fastball"], na.rm = TRUE), | |
| `Avg EV` = mean(ExitSpeed, na.rm = TRUE), | |
| EV50 = mean(ExitSpeed[!is.na(ExitSpeed) & | |
| ExitSpeed >= quantile(ExitSpeed, 0.5, na.rm = TRUE)], na.rm = TRUE), | |
| EV90 = mean(ExitSpeed[!is.na(ExitSpeed) & | |
| ExitSpeed >= quantile(ExitSpeed, 0.9, na.rm = TRUE)], na.rm = TRUE), | |
| `HH%` = 100 * mean(is_hard_hit, na.rm = TRUE), | |
| `GB%` = 100 * sum(AutoHitType == "GroundBall" & PitchCall == "InPlay", na.rm = TRUE) / | |
| sum(PitchCall == "InPlay", na.rm = TRUE), | |
| `Chase%` = 100 * mean(chase, na.rm = TRUE), | |
| `Whiff%` = 100 * mean(is_whiff, na.rm = TRUE), | |
| `IZW%` = 100 * sum(is_whiff[in_zone == 1], na.rm = TRUE) / | |
| sum(is_swing[in_zone == 1], na.rm = TRUE), | |
| `K%` = 100 * mean(is_k, na.rm = TRUE), | |
| `BB%` = 100 * mean(is_walk, na.rm = TRUE), | |
| `Ext` = mean(Extension, na.rm = TRUE), | |
| .groups = "drop" | |
| ) %>% | |
| mutate(across(everything(), as.numeric)) | |
| lower_better <- c("xwOBA", "xBAcon", "Avg EV", "EV50", "EV90", "HH%", "BB%") | |
| pitcher_pctiles <- pitcher_stats %>% | |
| mutate(across(everything(), ~ { | |
| col <- cur_column() | |
| if (col %in% lower_better) { | |
| round(mean(summary_ref[[col]] >= .x, na.rm = TRUE) * 100) | |
| } else { | |
| round(mean(summary_ref[[col]] <= .x, na.rm = TRUE) * 100) | |
| } | |
| }, .names = "{.col}_percentile")) %>% | |
| select(matches("_percentile")) | |
| custom_order <- c("xBAcon", "xwOBA", "Stuff+", "FB Velo", "Avg EV", "EV50", "EV90", | |
| "HH%", "GB%", "Chase%", "Whiff%", "IZW%", "K%", "BB%", "Ext") | |
| metrics <- bind_cols(pitcher_stats, pitcher_pctiles) %>% | |
| pivot_longer(everything(), names_to = "Metric_Type", values_to = "Value") %>% | |
| mutate(Metric = str_remove(Metric_Type, "_percentile"), | |
| Type = ifelse(str_detect(Metric_Type, "_percentile"), "Percentile", "Value")) %>% | |
| select(Metric, Type, Value) %>% | |
| pivot_wider(names_from = Type, values_from = Value) %>% | |
| filter(!is.na(Percentile) & !is.na(Value)) %>% | |
| mutate( | |
| Metric = factor(Metric, levels = custom_order), | |
| color = gradient_n_pal(c("#3661ad", "#90A4AE", "#d82029"))(Percentile / 100), | |
| Value = case_when( | |
| Metric %in% c("xwOBA", "xBAcon") ~ sprintf("%.3f", Value), | |
| Metric == "Stuff+" ~ as.character(round(Value)), | |
| TRUE ~ sprintf("%.1f", Value) | |
| ) | |
| ) | |
| ggplot(metrics, aes(y = factor(Metric, levels = rev(levels(Metric))))) + | |
| geom_tile(aes(x = 50, width = 100), fill = "#c7dcdc", alpha = 0.3, height = 0.25) + | |
| geom_tile(aes(x = Percentile / 2, width = Percentile, fill = color), height = 0.7) + | |
| annotate("segment", x = c(10, 50, 90), xend = c(10, 50, 90), | |
| y = 0, yend = length(custom_order) + 0.35, | |
| color = "white", linewidth = 1.5, alpha = 0.5) + | |
| geom_segment(aes(x = -2, xend = -16, | |
| y = as.numeric(factor(Metric, levels = rev(levels(Metric)))) - 0.3, | |
| yend = as.numeric(factor(Metric, levels = rev(levels(Metric)))) - 0.3), | |
| linetype = "longdash", color = "#399098", linewidth = 1) + | |
| geom_segment(aes(x = 102, xend = 113, | |
| y = as.numeric(factor(Metric, levels = rev(levels(Metric)))) - 0.3, | |
| yend = as.numeric(factor(Metric, levels = rev(levels(Metric)))) - 0.3), | |
| linetype = "longdash", color = "#399098", linewidth = 1) + | |
| geom_text(aes(x = -3, label = Metric), hjust = 1, size = 5) + | |
| geom_text(aes(x = 103, label = Value), hjust = 0, size = 5) + | |
| geom_point(aes(x = Percentile, fill = color), | |
| color = "white", size = 12, shape = 21, stroke = 2.25) + | |
| geom_text(aes(x = Percentile, label = Percentile), | |
| size = 4.5, color = "white", fontface = "bold") + | |
| scale_x_continuous(limits = c(-16, 113), expand = c(0, 0)) + | |
| scale_fill_identity() + scale_color_identity() + | |
| theme_minimal() + | |
| theme(axis.text = element_blank(), axis.title = element_blank(), panel.grid = element_blank()) | |
| } | |
| # inferred arm angle plot using a silhouette image | |
| arm_angle_plot <- function(data, pitcher_name) { | |
| d <- data %>% | |
| filter(Pitcher == pitcher_name, TaggedPitchType != "Other", | |
| !is.na(RelHeight), !is.na(RelSide)) | |
| # throwing hand drives the mirror | |
| h <- d$PitcherThrows[!is.na(d$PitcherThrows)] | |
| is_lhp <- length(h) > 0 && names(sort(table(h), decreasing = TRUE))[1] == "Left" | |
| m <- if (is_lhp) -1 else 1 | |
| arm_points <- d %>% | |
| group_by(pitch_type = TaggedPitchType) %>% | |
| summarise(mean_height = mean(RelHeight, na.rm = TRUE), | |
| mean_side = -mean(RelSide, na.rm = TRUE), | |
| .groups = "drop") | |
| anchor_x <- -0.14 * m | |
| anchor_y <- 4.75 | |
| img_use <- if (is_lhp) img_raster_L else img_raster_R | |
| img_xmin <- if (is_lhp) -1.6 else -0.4 | |
| img_xmax <- if (is_lhp) 0.4 else 1.6 | |
| xlim_use <- if (is_lhp) c(-2.5, 3) else c(-3, 2.5) | |
| mean_h <- round(mean(d$RelHeight, na.rm = TRUE), 1) | |
| mean_s <- round(mean(d$RelSide, na.rm = TRUE), 1) | |
| ggplot() + | |
| annotate("rect", xmin = -4.5, xmax = 4.5, ymin = 0, ymax = 0.6, fill = "#8B4513", alpha = 0.7) + | |
| annotate("rect", xmin = -4.5, xmax = 4.5, ymin = 0.6, ymax = 4.3, fill = "#164EA7", alpha = 0.9) + | |
| annotate("rect", xmin = -4.5, xmax = 4.5, ymin = 4.3, ymax = 4.5, fill = "#F25B00", alpha = 0.9) + | |
| annotate("rect", xmin = -0.5, xmax = 0.5, ymin = 0.5, ymax = 0.65, | |
| fill = "white", color = "black", linewidth = 0.5) + | |
| annotation_raster(img_use, xmin = img_xmin, xmax = img_xmax, ymin = 0.06, ymax = 5.85) + | |
| geom_segment(data = arm_points, | |
| aes(x = anchor_x, y = anchor_y, xend = mean_side, yend = mean_height, | |
| color = pitch_type), linewidth = 1.5) + | |
| geom_point(data = arm_points, | |
| aes(x = mean_side, y = mean_height, color = pitch_type), size = 4) + | |
| scale_color_manual(values = pitch_colors, name = "Pitch Type") + | |
| labs(title = paste0("Inferred Arm Angle \u2014 ", ifelse(is_lhp, "LHP", "RHP")), | |
| x = "Horizontal (ft)", y = "Vertical (ft)", | |
| subtitle = "Player height assumed to be 6 ft") + | |
| coord_fixed(xlim = xlim_use, ylim = c(0, 7)) + | |
| annotate("text", x = -2.5 * m, y = 6.8, label = "1B", size = 2.5, fontface = "bold") + | |
| annotate("text", x = 2.5 * m, y = 6.8, label = "3B", size = 2.5, fontface = "bold") + | |
| annotate("label", x = 0, y = 0.25, | |
| label = paste0("Rel Height: ", mean_h, ", Rel Side: ", mean_s), | |
| fill = "#EF3340", color = "white", fontface = "bold", size = 3.5, | |
| label.padding = unit(0.15, "lines")) + | |
| theme_minimal() + | |
| theme(axis.title.y = element_text(size = 14), | |
| axis.title.x = element_text(size = 14), | |
| axis.ticks = element_blank(), | |
| plot.title = element_text(size = 16, hjust = 0.5, vjust = -1), | |
| panel.grid = element_blank(), | |
| axis.line = element_blank(), | |
| legend.position = "none", | |
| plot.margin = margin(5, 5, 5, 5)) + | |
| theme(plot.subtitle = element_text(hjust = 0.5)) | |
| } | |
| # ============================================================ | |
| # Precomputed references + player lists | |
| # ============================================================ | |
| lg_ref <- reference_stats(all_data) | |
| pitch_lg_ref <- pitcher_reference_stats(all_data) | |
| percentile_ref_hitter <- get_percentile_refs_hitter(all_data) | |
| percentile_ref_pitcher <- get_percentile_refs_pitcher(all_data) | |
| ALL_BATTERS <- sort(unique(all_data$Batter)) | |
| ALL_PITCHERS <- sort(unique(all_data$Pitcher)) | |
| HF_PASSWORD <- Sys.getenv("APP_PASSWORD") | |
| # ============================================================ | |
| # Tab content | |
| # ============================================================ | |
| tab_hitter_overview <- function() tagList( | |
| tags$div(class = "data-card", style = "margin-bottom:16px;", | |
| tags$div(class = "data-card-hdr", | |
| tags$span(class = "data-card-title", "Batter summary"), | |
| tags$span(class = "data-card-sub", "By pitch group, handedness, opponent level") | |
| ), | |
| tags$div(class = "data-card-body", gt_output("gt_batter_summary")) | |
| ), | |
| tags$div(style = "display:grid; grid-template-columns:1fr 1fr; gap:14px;", | |
| tags$div(class = "data-card", | |
| tags$div(class = "data-card-hdr", | |
| tags$span(class = "data-card-title", "Percentile profile"), | |
| tags$span(class = "data-card-sub", "vs. lower-level 2026 dataset") | |
| ), | |
| tags$div(class = "data-card-body", plotOutput("plot_hitter_percentiles", height = "420px")) | |
| ), | |
| tags$div(class = "data-card", | |
| tags$div(class = "data-card-hdr", | |
| tags$span(class = "data-card-title", "Spray chart"), | |
| tags$span(class = "data-card-sub", "Hits only") | |
| ), | |
| tags$div(class = "data-card-body", plotlyOutput("plot_spray_overview", height = "420px")) | |
| ) | |
| ) | |
| ) | |
| tab_pitcher_overview <- function() tagList( | |
| tags$div(class = "data-card", style = "margin-bottom:16px;", | |
| tags$div(class = "data-card-hdr", | |
| tags$span(class = "data-card-title", "Pitch summary"), | |
| tags$span(class = "data-card-sub", "By pitch type, handedness, opponent level") | |
| ), | |
| tags$div(class = "data-card-body", gt_output("gt_pitch_summary")) | |
| ), | |
| tags$div(style = "display:grid; grid-template-columns:1fr 1fr; gap:14px; margin-bottom:16px;", | |
| tags$div(class = "data-card", | |
| tags$div(class = "data-card-hdr", | |
| tags$span(class = "data-card-title", "Movement plot"), | |
| tags$span(class = "data-card-sub", "HB vs IVB by pitch") | |
| ), | |
| tags$div(class = "data-card-body", plotlyOutput("plot_movement", height = "440px")) | |
| ), | |
| tags$div(class = "data-card", | |
| tags$div(class = "data-card-hdr", | |
| tags$span(class = "data-card-title", "Percentile profile"), | |
| tags$span(class = "data-card-sub", "vs. lower-level 2026 pitchers") | |
| ), | |
| tags$div(class = "data-card-body", plotOutput("plot_pitcher_percentiles", height = "440px")) | |
| ) | |
| ), | |
| tags$div(class = "data-card", | |
| tags$div(class = "data-card-hdr", | |
| tags$span(class = "data-card-title", "Inferred arm angle"), | |
| tags$span(class = "data-card-sub", "Mean release by pitch") | |
| ), | |
| tags$div(class = "data-card-body", plotOutput("plot_arm_angle", height = "440px")) | |
| ) | |
| ) | |
| TABS <- list( | |
| list(id = "hitter", label = "Hitter Overview", icon = "ti-chart-bar"), | |
| list(id = "pitcher", label = "Pitcher Overview", icon = "ti-target") | |
| ) | |
| # ============================================================ | |
| # Login | |
| # ============================================================ | |
| login_css <- tags$style(HTML(" | |
| #login_screen { position:fixed; top:0; left:0; right:0; bottom:0; z-index:9999; | |
| display:flex; flex-direction:column; align-items:center; justify-content:center; | |
| background:linear-gradient(135deg,#001a3a 0%,#003366 40%,#0a4a8a 100%); | |
| transition:opacity .8s ease, transform .8s ease; } | |
| #login_screen.fade-out { opacity:0; transform:scale(1.02); pointer-events:none; } | |
| #login_screen .login-logo { width:120px; height:120px; margin-bottom:20px; border-radius:50%; | |
| object-fit:cover; border:3px solid rgba(255,165,0,.6); box-shadow:0 0 30px rgba(255,165,0,.2); } | |
| #login_screen .login-title { font-size:42px; font-weight:800; color:#fff; letter-spacing:1.5px; | |
| margin-bottom:6px; text-align:center; } | |
| #login_screen .login-subtitle { font-size:16px; color:rgba(255,165,0,.7); letter-spacing:3px; | |
| text-transform:uppercase; margin-bottom:40px; } | |
| #login_screen .login-accent { width:60px; height:3px; background:#FF8C00; margin:0 auto 30px; | |
| border-radius:2px; } | |
| #login_screen .login-box { background:rgba(255,255,255,.06); border:1px solid rgba(255,165,0,.2); | |
| border-radius:12px; padding:32px 40px; backdrop-filter:blur(10px); width:340px; text-align:center; } | |
| #login_screen .login-box input[type='password'] { width:100%; padding:12px 16px; | |
| border:1px solid rgba(255,165,0,.3); border-radius:6px; background:rgba(255,255,255,.08); | |
| color:#fff; font-size:15px; margin-bottom:16px; outline:none; transition:border-color .3s; | |
| text-align:center; letter-spacing:2px; } | |
| #login_screen .login-box input[type='password']:focus { border-color:#FF8C00; } | |
| #login_screen .login-box input[type='password']::placeholder { color:rgba(255,255,255,.35); letter-spacing:1px; } | |
| #login_screen .login-btn { width:100%; padding:12px; border:none; border-radius:6px; | |
| background:#FF8C00; color:#fff; font-size:14px; font-weight:700; letter-spacing:1px; | |
| text-transform:uppercase; cursor:pointer; transition:background .3s, transform .15s; } | |
| #login_screen .login-btn:hover { background:#e67e00; transform:translateY(-1px); } | |
| #login_screen .login-error { color:#ff6b6b; font-size:13px; margin-top:12px; min-height:20px; } | |
| ")) | |
| login_overlay <- tags$div(id = "login_screen", | |
| tags$img(class = "login-logo", src = logo_uri), | |
| tags$div(class = "login-title", "Lower Level"), | |
| tags$div(class = "login-accent"), | |
| tags$div(class = "login-subtitle", "Hyannis Harbor Hawks"), | |
| tags$div(class = "login-box", | |
| tags$input(id = "login_pw", type = "password", placeholder = "Enter password"), | |
| tags$button(id = "login_btn", class = "login-btn", "Enter"), | |
| tags$div(id = "login_error", class = "login-error") | |
| ) | |
| ) | |
| login_js <- tags$script(HTML(paste0(" | |
| var APP_PASSWORD = '", HF_PASSWORD, "'; | |
| function tryLogin() { | |
| var pw = document.getElementById('login_pw').value; | |
| if (pw === APP_PASSWORD) { | |
| document.getElementById('login_screen').classList.add('fade-out'); | |
| setTimeout(function(){ document.getElementById('login_screen').style.display = 'none'; }, 800); | |
| } else { | |
| document.getElementById('login_error').textContent = 'Incorrect password'; | |
| document.getElementById('login_pw').value = ''; | |
| document.getElementById('login_pw').style.borderColor = '#ff6b6b'; | |
| setTimeout(function(){ | |
| document.getElementById('login_error').textContent = ''; | |
| document.getElementById('login_pw').style.borderColor = 'rgba(255,165,0,0.3)'; | |
| }, 2000); | |
| } | |
| } | |
| document.addEventListener('DOMContentLoaded', function(){ | |
| document.getElementById('login_btn').addEventListener('click', tryLogin); | |
| document.getElementById('login_pw').addEventListener('keydown', function(e){ | |
| if (e.key === 'Enter') tryLogin(); | |
| }); | |
| }); | |
| "))) | |
| # ============================================================ | |
| # UI | |
| # ============================================================ | |
| ui <- fluidPage( | |
| theme = hawks_theme, | |
| hawks_css, | |
| tags$link(rel = "stylesheet", | |
| href = "https://cdn.jsdelivr.net/npm/@tabler/icons-webfont@latest/dist/tabler-icons.min.css"), | |
| login_css, | |
| tags$style(HTML(glue(" | |
| body {{ margin:0; overflow:hidden; background:{HAWKS_LIGHT}; }} | |
| .hawks-topbar {{ | |
| background:{HAWKS_DARK}; padding:0 24px; height:auto; display:flex; | |
| align-items:center; justify-content:space-between; flex-shrink:0; | |
| flex-wrap:wrap; gap:0; border-bottom:3px solid {HAWKS_ORANGE}; | |
| }} | |
| .topbar-left {{ display:flex; align-items:center; gap:20px; padding:14px 0; }} | |
| .topbar-title {{ | |
| color:#fff; font-size:40px; font-weight:800; letter-spacing:-.3px; | |
| text-align:center; flex:1; white-space:nowrap; | |
| }} | |
| .topbar-brand {{ display:flex; align-items:center; gap:10px; padding:14px 0; }} | |
| .topbar-brand .app-title {{ color:#fff; font-size:16px; font-weight:800; letter-spacing:-.2px; }} | |
| .topbar-player {{ display:flex; flex-direction:column; align-items:flex-start; gap:3px; padding:10px 0; }} | |
| .topbar-player .player-label {{ font-size:9px; font-weight:700; text-transform:uppercase; | |
| letter-spacing:.1em; color:#ffffff55; }} | |
| .topbar-player .selectize-input {{ | |
| background:#ffffff14 !important; border:1px solid #ffffff30 !important; | |
| border-radius:6px !important; color:#fff !important; font-size:13px !important; | |
| font-weight:600 !important; min-width:200px; padding:6px 10px !important; }} | |
| .topbar-player .selectize-input.focus {{ border-color:{HAWKS_ORANGE} !important; box-shadow:none !important; }} | |
| .topbar-player .selectize-dropdown {{ border:1px solid {HAWKS_BORDER}; border-radius:6px; font-size:12px; }} | |
| .topbar-player .selectize-input input {{ | |
| color: #fff !important; | |
| min-width: 120px !important; | |
| width: auto !important; | |
| }} | |
| .topbar-player .selectize-input input::placeholder {{ | |
| color: rgba(255,255,255,.35) !important; | |
| }} | |
| .hawks-tabs {{ background:#fff; border-bottom:1px solid {HAWKS_BORDER}; padding:0 20px; | |
| display:flex; flex-shrink:0; overflow-x:auto; }} | |
| .h-tab {{ padding:11px 15px; font-size:12px; font-weight:500; color:{HAWKS_GRAY}; | |
| border-bottom:2px solid transparent; cursor:pointer; white-space:nowrap; | |
| display:flex; align-items:center; gap:6px; transition:color .15s, border-color .15s; }} | |
| .h-tab:hover {{ color:#374151; }} | |
| .h-tab.active {{ color:{HAWKS_BLUE}; border-bottom-color:{HAWKS_BLUE}; font-weight:600; }} | |
| .hawks-body {{ display:flex; flex:1; overflow:hidden; }} | |
| .hawks-content {{ flex:1; overflow-y:auto; padding:20px; }} | |
| .hawks-shell {{ display:flex; flex-direction:column; height:100vh; overflow:hidden; }} | |
| .hawks-main {{ display:flex; flex-direction:column; flex:1; overflow:hidden; }} | |
| "))), | |
| tags$script(HTML(glue(" | |
| function setTab(tabId) {{ | |
| document.querySelectorAll('.h-tab').forEach(function(el) {{ | |
| el.classList.toggle('active', el.dataset.tab === tabId); | |
| }}); | |
| Shiny.setInputValue('active_tab', tabId, {{priority:'event'}}); | |
| }} | |
| "))), | |
| tags$div( | |
| class = "hawks-shell", | |
| tags$div( | |
| class = "hawks-topbar", | |
| tags$div(class = "topbar-left", | |
| tags$div(class = "topbar-brand", | |
| tags$svg(width="24",height="24",viewBox="0 0 30 30",fill="none", | |
| tags$polygon(points="15,2 28,26 15,21 2,26",fill=HAWKS_ORANGE,opacity="0.9"), | |
| tags$polygon(points="15,2 15,21 2,26",fill="#fff",opacity="0.18") | |
| ), | |
| tags$span(class="app-title","Lower Level") | |
| ) | |
| ), | |
| tags$div(class = "topbar-title", "Hyannis Harbor Hawks Analytics"), | |
| tags$div(class = "topbar-player", | |
| tags$div(class="player-label","Player"), | |
| selectizeInput("selected_player", NULL, | |
| choices = NULL, width = "210px") | |
| ) | |
| ), | |
| tags$div(class = "hawks-main", | |
| tags$div(class = "hawks-tabs", | |
| tagList(lapply(TABS, function(t) { | |
| tags$div( | |
| class = paste("h-tab", if (t$id == "hitter") "active" else ""), | |
| `data-tab` = t$id, | |
| onclick = glue("setTab('{t$id}')"), | |
| tags$i(class=glue("ti {t$icon}"), style="font-size:13px;"), | |
| t$label | |
| ) | |
| })) | |
| ), | |
| tags$div(class = "hawks-body", | |
| tags$div(class="hawks-content", uiOutput("ui_tab_content")) | |
| ) | |
| ) | |
| ), | |
| login_overlay, | |
| login_js | |
| ) | |
| # ============================================================ | |
| # Server | |
| # ============================================================ | |
| server <- function(input, output, session) { | |
| # Populate the player search server-side (default tab is hitter) | |
| updateSelectizeInput(session, "selected_player", | |
| choices = ALL_BATTERS, selected = ALL_BATTERS[1], server = TRUE) | |
| # Swap the player dropdown between batters and pitchers on tab change | |
| observeEvent(input$active_tab, { | |
| if ((input$active_tab %||% "hitter") == "pitcher") { | |
| updateSelectizeInput(session, "selected_player", | |
| choices = ALL_PITCHERS, selected = ALL_PITCHERS[1], server = TRUE) | |
| } else { | |
| updateSelectizeInput(session, "selected_player", | |
| choices = ALL_BATTERS, selected = ALL_BATTERS[1], server = TRUE) | |
| } | |
| }, ignoreInit = TRUE) | |
| # Player data, filtered on the correct identity column for the active tab | |
| player_data <- reactive({ | |
| req(input$selected_player) | |
| if ((input$active_tab %||% "hitter") == "pitcher") { | |
| all_data %>% filter(Pitcher == input$selected_player) | |
| } else { | |
| all_data %>% filter(Batter == input$selected_player) | |
| } | |
| }) | |
| # Header sub-lines | |
| player_team <- reactive({ | |
| req(input$selected_player) | |
| df <- all_data %>% filter(Batter == input$selected_player) | |
| if (nrow(df) == 0) return("") | |
| team <- df %>% count(BatterTeam) %>% slice_max(n, n = 1) %>% pull(BatterTeam) | |
| if (length(team) == 0 || is.na(team[1])) return("") | |
| as.character(team[1]) | |
| }) | |
| pitcher_info <- reactive({ | |
| req(input$selected_player) | |
| df <- all_data %>% filter(Pitcher == input$selected_player) | |
| if (nrow(df) == 0) return("") | |
| hand <- df %>% count(PitcherThrows) %>% slice_max(n, n = 1) %>% pull(PitcherThrows) | |
| if (length(hand) == 0 || is.na(hand[1])) return("") | |
| if (hand[1] == "Right") "Right-Handed Pitcher" | |
| else if (hand[1] == "Left") "Left-Handed Pitcher" | |
| else as.character(hand[1]) | |
| }) | |
| output$ui_tab_content <- renderUI({ | |
| tab <- input$active_tab %||% "hitter" | |
| if (tab == "pitcher") { | |
| header <- tags$div( | |
| style = glue("margin-bottom:18px; padding-bottom:14px; border-bottom:1px solid {HAWKS_BORDER};"), | |
| tags$div(style = glue("font-size:22px; font-weight:800; color:{HAWKS_DARK}; line-height:1;"), | |
| input$selected_player), | |
| tags$div(style = glue("font-size:13px; color:{HAWKS_GRAY}; margin-top:4px;"), | |
| pitcher_info()) | |
| ) | |
| tagList(header, tab_pitcher_overview()) | |
| } else { | |
| header <- tags$div( | |
| style = glue("margin-bottom:18px; padding-bottom:14px; border-bottom:1px solid {HAWKS_BORDER};"), | |
| tags$div(style = glue("font-size:22px; font-weight:800; color:{HAWKS_DARK}; line-height:1;"), | |
| input$selected_player), | |
| tags$div(style = glue("font-size:13px; color:{HAWKS_GRAY}; margin-top:4px;"), | |
| player_team()) | |
| ) | |
| tagList(header, tab_hitter_overview()) | |
| } | |
| }) | |
| # ---- HITTER OVERVIEW ---- | |
| output$gt_batter_summary <- render_gt({ | |
| df <- player_data(); req(nrow(df) > 0) | |
| batter_summary(df, input$selected_player) | |
| }) | |
| output$plot_hitter_percentiles <- renderPlot({ | |
| df <- player_data(); req(nrow(df) > 0) | |
| batter_percentiles(df, input$selected_player, percentile_ref_hitter) | |
| }, bg = "white") | |
| output$plot_spray_overview <- renderPlotly({ | |
| df <- player_data(); req(nrow(df) > 0) | |
| spray_chart(df, input$selected_player) | |
| }) | |
| # ---- PITCHER OVERVIEW ---- | |
| output$gt_pitch_summary <- render_gt({ | |
| df <- player_data(); req(nrow(df) > 0) | |
| pitcher_summary(df, input$selected_player) | |
| }) | |
| output$plot_movement <- renderPlotly({ | |
| df <- player_data(); req(nrow(df) > 0) | |
| movement_plot(df, input$selected_player) | |
| }) | |
| output$plot_pitcher_percentiles <- renderPlot({ | |
| df <- player_data(); req(nrow(df) > 0) | |
| pitcher_percentiles(df, input$selected_player, percentile_ref_pitcher) | |
| }, bg = "white") | |
| output$plot_arm_angle <- renderPlot({ | |
| df <- player_data(); req(nrow(df) > 0) | |
| arm_angle_plot(df, input$selected_player) | |
| }, bg = "white") | |
| } | |
| shinyApp(ui, server) | |