# ========================================================================= # 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,"
", "EV: ", round(ExitSpeed,1),"
", "LA: ", round(Angle,1), "
", "Dist: ", round(Distance,0), " ft
", "xwOBA: ",round(xwOBA,3), "
", "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, "
HB:", round(HorzBreak, 1), "
IVB:", round(InducedVertBreak, 1), "
Velo:", round(RelSpeed, 1), "
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)