| |
|
|
| library(arrow) |
| library(httr) |
| library(jsonlite) |
| library(readr) |
| library(DT) |
| library(recipes) |
| library(xgboost) |
| library(stringr) |
| library(bundle) |
| library(tidymodels) |
| library(base64enc) |
| library(tidyr) |
| library(dplyr) |
| library(shiny) |
| library(bslib) |
| library(glue) |
| library(ggplot2) |
| library(gt) |
| library(gtExtras) |
| library(purrr) |
| library(scales) |
| library(knitr) |
| library(htmltools) |
| library(patchwork) |
|
|
|
|
| |
| library(reticulate) |
| use_python("/usr/bin/python3", required = TRUE) |
|
|
| `%||%` <- function(a, b) if (!is.null(a)) a else b |
|
|
| |
| |
| |
| |
| |
|
|
| |
| HF_READ_TOKEN <- Sys.getenv("READ_CLEANDATAOBJ_TOKEN") |
|
|
| |
| HF_READ_TOKEN_PASTSEASONDATA <- Sys.getenv("READ_PASTSEASONDATA_TOKEN") |
|
|
| |
| gh_token <- Sys.getenv("GITHUB_TOKEN") |
|
|
| |
| gh_repo <- "HyannisHarborHawks/HyannisAnalytics" |
|
|
| |
| HF_WRITE_TOKEN <- Sys.getenv("WRITE_PASTSEASONDATA_TOKEN") |
|
|
| |
| HF_REPO_ID <- "HyannisHarborHawksCCBL/PastSeasonData" |
|
|
| |
| PASSWORD <- Sys.getenv("APP_PASSWORD") |
|
|
| |
| hf_target_for <- function(source) { |
| switch(source, |
| "pbp" = list(folder = "2026_cape/pbp", index = "2026_cape/pbp_uid_index.csv.gz", label = "2026 Cape PBP"), |
| "pos" = list(folder = "2026_cape/pos", index = "2026_cape/pos_uid_index.csv.gz", label = "2026 Cape POS") |
| ) |
| } |
|
|
| |
| |
| |
|
|
| download_private_parquet <- function(repo_id, filename, max_retries = 3) { |
| url <- paste0("https://huggingface.co/datasets/", repo_id, "/resolve/main/", filename) |
| api_key <- HF_READ_TOKEN_PASTSEASONDATA |
| if (api_key == "") stop("API key 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)) |
| } else warning(paste("Attempt", attempt, "failed with status:", status_code(response))) |
| }, error = function(e) { |
| warning(paste("Attempt", attempt, "failed:", e$message)) |
| if (attempt < max_retries) Sys.sleep(2) |
| }) |
| } |
| stop(paste("Failed to download dataset after", max_retries, "attempts")) |
| } |
|
|
| download_private_csv <- function(repo_id, filename, max_retries = 3) { |
| url <- paste0("https://huggingface.co/datasets/", repo_id, "/resolve/main/", filename) |
| api_key <- HF_READ_TOKEN |
| if (api_key == "") stop("API key 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 = ".csv") |
| writeBin(content(response, "raw"), tmp) |
| return(readr::read_csv(tmp, show_col_types = FALSE)) |
| } else warning(paste("Attempt", attempt, "failed with status:", status_code(response))) |
| }, error = function(e) { |
| warning(paste("Attempt", attempt, "failed:", e$message)) |
| if (attempt < max_retries) Sys.sleep(2) |
| }) |
| } |
| stop(paste("Failed to download dataset after", max_retries, "attempts")) |
| } |
|
|
| download_private_xgb <- function(repo_id, filename, max_retries = 3) { |
| url <- paste0("https://huggingface.co/datasets/", repo_id, "/resolve/main/", filename) |
| api_key <- HF_READ_TOKEN |
| if (api_key == "") stop("API key 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 = ".json") |
| writeBin(content(response, "raw"), tmp) |
| return(xgb.load(tmp)) |
| } else warning(paste("Attempt", attempt, "failed with status:", status_code(response))) |
| }, error = function(e) { |
| warning(paste("Attempt", attempt, "failed:", e$message)) |
| if (attempt < max_retries) Sys.sleep(2) |
| }) |
| } |
| stop(paste("Failed to download dataset after", max_retries, "attempts")) |
| } |
|
|
| download_private_rds <- function(repo_id, filename, max_retries = 3) { |
| url <- paste0("https://huggingface.co/datasets/", repo_id, "/resolve/main/", filename) |
| api_key <- HF_READ_TOKEN |
| if (api_key == "") stop("API key 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 = ".rds") |
| writeBin(content(response, "raw"), tmp) |
| return(readRDS(tmp)) |
| } else warning(paste("Attempt", attempt, "failed:", e$message)) |
| }, error = function(e) { |
| warning(paste("Attempt", attempt, "failed:", e$message)) |
| if (attempt < max_retries) Sys.sleep(2) |
| }) |
| } |
| stop(paste("Failed to download dataset after", max_retries, "attempts")) |
| } |
|
|
| |
| stuffplus_recipe <- download_private_rds("HyannisHarborHawksCCBL/CleanDataObjects", "stuffplus_recipe.rds") |
| stuffplus_model <- download_private_xgb("HyannisHarborHawksCCBL/CleanDataObjects", "stuffplus_xgb.json") |
|
|
| |
| xwoba_model <- unbundle(download_private_rds("HyannisHarborHawksCCBL/CleanDataObjects", "bundle_college_xwoba_knn.rds")) |
|
|
| |
| retag_model <- unbundle(download_private_rds("HyannisHarborHawksCCBL/CleanDataObjects", "pitch_tag_model.rds")) |
|
|
| |
| rv <- download_private_csv("HyannisHarborHawksCCBL/CleanDataObjects", "non_context_run_values.csv") |
| team_leagues <- download_private_csv("HyannisHarborHawksCCBL/CleanDataObjects", "college_team_leagues.csv") |
| college_guts <- download_private_csv("HyannisHarborHawksCCBL/CleanDataObjects", "college_GUTS.csv") |
| college_join <- download_private_csv("HyannisHarborHawksCCBL/CleanDataObjects", "college_join.csv") |
|
|
| |
| pitcher_arsenals_over_5perc <- download_private_csv("HyannisHarborHawksCCBL/CleanDataObjects", "pitcher_arsenals_over_5perc.csv") |
|
|
| |
| logo_b64 <- base64enc::base64encode("HHLogo.png") |
| logo_uri <- paste0("data:image/png;base64,", logo_b64) |
|
|
| |
| REFERENCE_DATA <- download_private_parquet("HyannisHarborHawksCCBL/PastSeasonData", "college_26_clean.parquet") |
|
|
|
|
| |
| |
| |
| |
| |
|
|
| |
| NAVY <- "#002855" |
| ORANGE <- "#F25B00" |
| BLUE <- "#164EA7" |
| GRAY <- "black" |
|
|
| |
| pitch_colors <- c( |
| "Fastball" = "red", "FourSeamFastBall" = "red", "Four-Seam" = "red", |
| "Sinker" = "orange", "TwoSeamFastBall" = "orange", |
| "Cutter" = "brown", |
| "Slider" = "gold", |
| "Sweeper" = "maroon2", |
| "Curveball" = "blue", |
| "Changeup" = "green4", "ChangeUp" = "green4", |
| "Splitter" = "lightblue", |
| "Knuckleball" = "#6A89A7" |
| ) |
|
|
| pcol <- function(x) { out <- pitch_colors[as.character(x)]; out[is.na(out)] <- "#8A8D91"; unname(out) } |
|
|
| text_for <- function(hex) { |
| v <- grDevices::col2rgb(hex) |
| lum <- (0.299 * v[1] + 0.587 * v[2] + 0.114 * v[3]) / 255 |
| if (lum < 0.5) "white" else NAVY |
| } |
|
|
| make_color_fn <- function(min_val, max_val, low = "#3661ad", mid = "white", high = "#d82029") { |
| if (is.na(min_val) || is.na(max_val) || min_val == max_val) return(function(x) "white") |
| g <- scales::col_numeric(c(low, mid, high), domain = c(min_val, max_val), na.color = "white") |
| function(x) ifelse(is.na(x), "white", g(pmin(pmax(x, min_val), max_val))) |
| } |
| make_color_fn_reverse <- function(min_val, max_val) make_color_fn(min_val, max_val, "#d82029", "white", "#3661ad") |
|
|
| |
| derive_outing <- function(d, pitch_col = "TaggedPitchType") { |
| z_half <- 9.975; z_lo <- 18; z_hi <- 42 |
| d %>% |
| mutate( |
| pitch_type = .data[[pitch_col]], |
| .swing = PitchCall %in% c("StrikeSwinging", "InPlay") | grepl("Foul", PitchCall), |
| .whiff = PitchCall == "StrikeSwinging", |
| .csw = PitchCall %in% c("StrikeSwinging", "StrikeCalled"), |
| .strike = PitchCall %in% c("StrikeCalled", "StrikeSwinging", "InPlay") | grepl("Foul", PitchCall), |
| .inzone = abs(PlateLocSide) <= z_half & PlateLocHeight >= z_lo & PlateLocHeight <= z_hi, |
| .chase = .swing & !.inzone, |
| .bip = PitchCall == "InPlay", |
| .hit = PlayResult %in% c("Single", "Double", "Triple", "HomeRun", "Homerun") |
| ) |
| } |
|
|
| color_group_cells <- function(gt_tbl, group_col = "GROUP") { |
| for (pt in names(pitch_colors)) { |
| fill <- unname(pitch_colors[pt]) |
| gt_tbl <- gt_tbl %>% |
| gt::tab_style( |
| style = list(gt::cell_fill(color = fill), |
| gt::cell_text(color = text_for(fill), weight = "bold")), |
| locations = gt::cells_body(columns = gt::all_of(group_col), |
| rows = .data[[group_col]] == pt)) |
| } |
| gt_tbl |
| } |
|
|
| hawks_gt_base <- function(gt_tbl, title) { |
| gt_tbl %>% |
| gtExtras::gt_theme_espn() %>% |
| gt::tab_header(title = title) %>% |
| gt::tab_options(heading.align = "center", table.font.size = gt::px(11), |
| data_row.padding = gt::px(4), table.width = gt::pct(100)) %>% |
| gt::sub_missing(missing_text = "\u2014") %>% |
| gt::tab_style(gt::cell_text(color = NAVY), gt::cells_body()) %>% |
| gt::tab_style(gt::cell_text(color = NAVY, weight = "bold"), gt::cells_column_labels()) %>% |
| gt::tab_style(gt::cell_text(color = BLUE, size = gt::px(18), weight = "bold"), |
| gt::cells_title("title")) %>% |
| gt::tab_style(gt::cell_borders(sides = c("top", "bottom", "left", "right"), |
| color = NAVY, weight = gt::px(0.5)), gt::cells_body()) |
| } |
|
|
| |
| |
| |
| build_season_ref <- function(data, pitcher, pitch_col = "TaggedPitchType", min_pitches = 15) { |
| out <- data %>% |
| filter(Pitcher == pitcher) %>% |
| mutate(pitch_type = .data[[pitch_col]]) %>% |
| group_by(pitch_type) %>% |
| summarise( |
| season_n = n(), |
| VELO = mean(RelSpeed, na.rm = TRUE), VELO_sd = sd(RelSpeed, na.rm = TRUE), |
| SPIN = mean(SpinRate, na.rm = TRUE), SPIN_sd = sd(SpinRate, na.rm = TRUE), |
| IVB = mean(InducedVertBreak, na.rm = TRUE), IVB_sd = sd(InducedVertBreak, na.rm = TRUE), |
| HB = mean(HorzBreak, na.rm = TRUE), HB_sd = sd(HorzBreak, na.rm = TRUE), |
| EXT = mean(Extension, na.rm = TRUE), EXT_sd = sd(Extension, na.rm = TRUE), |
| RELH = mean(RelHeight, na.rm = TRUE), RELH_sd = sd(RelHeight, na.rm = TRUE), |
| RELS = mean(RelSide, na.rm = TRUE), RELS_sd = sd(RelSide, na.rm = TRUE), |
| .groups = "drop") %>% |
| filter(season_n >= min_pitches) %>% |
| select(-season_n) |
| if (nrow(out) == 0) NULL else out |
| } |
|
|
| |
| |
| |
| shape_table_gt <- function(outing, season_ref = NULL, pitch_col = "TaggedPitchType", k = 1) { |
| metrics <- c("VELO", "SPIN", "IVB", "HB", "EXT", "RELH", "RELS") |
| digits <- c(VELO = 1, MAX = 1, SPIN = 0, IVB = 1, HB = 1, EXT = 1, RELH = 1, RELS = 1) |
| has_stuff <- "stuff_plus" %in% names(outing) |
| |
| summ <- outing %>% |
| mutate(pitch_type = .data[[pitch_col]]) %>% |
| filter(pitch_type != "Other") %>% |
| group_by(pitch_type) %>% |
| summarise( |
| n_temp = n(), |
| `USAGE%` = round(100 * n() / nrow(outing), 1), `#` = n(), |
| VELO = mean(RelSpeed, na.rm = TRUE), MAX = max(RelSpeed, na.rm = TRUE), |
| SPIN = mean(SpinRate, na.rm = TRUE), |
| IVB = mean(InducedVertBreak, na.rm = TRUE), HB = mean(HorzBreak, na.rm = TRUE), |
| EXT = mean(Extension, na.rm = TRUE), |
| RELH = mean(RelHeight, na.rm = TRUE), RELS = mean(RelSide, na.rm = TRUE), |
| `STUFF+` = if (has_stuff) round(mean(stuff_plus, na.rm = TRUE)) else NA_real_, |
| .groups = "drop") %>% |
| arrange(desc(n_temp)) %>% select(-n_temp) |
| |
| have_ref <- !is.null(season_ref) |
| if (have_ref) |
| summ <- summ %>% left_join(season_ref, by = "pitch_type", suffix = c("", "_ref")) |
| |
| arrow_html <- function(delta, thr, dg) { |
| if (is.na(delta) || is.na(thr) || abs(delta) <= thr) return("") |
| col <- if (delta > 0) "#C8102E" else "#164EA7" |
| sym <- if (delta > 0) "\u25B2" else "\u25BC" |
| sprintf(" <span style='color:%s;font-size:9px;font-weight:700;'>%s%s</span>", |
| col, sym, formatC(abs(delta), format = "f", digits = max(dg, 1))) |
| } |
| |
| disp <- tibble(GROUP = summ$pitch_type, `USAGE%` = summ$`USAGE%`, `#` = summ$`#`) |
| cell <- function(m, i) { |
| val <- summ[[m]][i]; dg <- digits[[m]] |
| base <- sprintf(paste0("%.", dg, "f"), val) |
| if (have_ref && m %in% metrics) { |
| ref <- summ[[paste0(m, "_ref")]][i]; sdv <- summ[[paste0(m, "_sd")]][i] |
| thr <- if (is.na(sdv)) NA_real_ else k * sdv |
| paste0(base, arrow_html(val - ref, thr, dg)) |
| } else base |
| } |
| nr <- nrow(summ) |
| for (m in c("VELO", "MAX", "SPIN", "IVB", "HB", "EXT", "RELH", "RELS")) |
| disp[[m]] <- vapply(seq_len(nr), function(i) cell(m, i), character(1)) |
| if (has_stuff) disp[["STUFF+"]] <- summ[["STUFF+"]] |
| |
| ttl <- if (have_ref) sprintf("Pitch Shapes \u2014 vs Season", k) else "Pitch Shapes" |
| gt_tbl <- disp %>% |
| gt::gt() %>% |
| hawks_gt_base(title = ttl) %>% |
| gt::fmt_markdown(columns = c("VELO", "MAX", "SPIN", "IVB", "HB", "EXT", "RELH", "RELS")) %>% |
| gt::cols_label(GROUP = "PITCH", MAX = "MAX V", RELH = "REL H", RELS = "REL S") %>% |
| gt::tab_style(gt::cell_borders(sides = "right", color = ORANGE, weight = gt::px(1.2)), |
| gt::cells_body(columns = c(GROUP, `#`))) |
| if (has_stuff) |
| gt_tbl <- gt_tbl %>% gt::data_color(columns = `STUFF+`, fn = make_color_fn(80, 120)) |
| color_group_cells(gt_tbl, "GROUP") |
| } |
|
|
| |
| |
| |
| results_table_gt <- function(outing, pitch_col = "TaggedPitchType") { |
| d <- derive_outing(outing, pitch_col) %>% filter(pitch_type != "Other") |
| |
| |
| agg <- d %>% |
| group_by(pitch_type) %>% |
| summarise( |
| n_temp = n(), `#` = n(), |
| `WHIFF%` = round(100 * sum(.whiff, na.rm = TRUE) / pmax(sum(.swing, na.rm = TRUE), 1), 1), |
| `CSW%` = round(100 * mean(.csw, na.rm = TRUE), 1), |
| `CHASE%` = round(100 * sum(.chase, na.rm = TRUE) / pmax(sum(!.inzone, na.rm = TRUE), 1), 1), |
| `ZONE%` = round(100 * mean(.inzone, na.rm = TRUE), 1), |
| EV = round(mean(ExitSpeed, na.rm = TRUE), 1), |
| |
| .groups = "drop") %>% |
| arrange(desc(n_temp)) %>% select(-n_temp) %>% |
| rename(GROUP = pitch_type) |
| |
| |
| |
| gt_tbl <- agg %>% |
| gt::gt() %>% |
| hawks_gt_base(title = "Results") %>% |
| gt::cols_label(GROUP = "PITCH") %>% |
| gt::fmt_number(columns = c("WHIFF%", "CSW%", "CHASE%", "ZONE%", "EV"), decimals = 1) %>% |
| gt::data_color(columns = `WHIFF%`, fn = make_color_fn(15, 40)) %>% |
| gt::data_color(columns = `CSW%`, fn = make_color_fn(20, 40)) %>% |
| gt::data_color(columns = `CHASE%`, fn = make_color_fn(20, 45)) %>% |
| gt::data_color(columns = `ZONE%`, fn = make_color_fn(30, 60)) %>% |
| gt::data_color(columns = EV, fn = make_color_fn_reverse(75, 95)) %>% |
| gt::tab_style(gt::cell_borders(sides = "right", color = ORANGE, weight = gt::px(1.2)), |
| gt::cells_body(columns = c(GROUP, `#`))) |
| |
| |
| |
| |
| } |
|
|
|
|
|
|
| |
| sequencing_table <- function(data, pitcher, pitch_col = "TaggedPitchType") { |
| navy <- "#002855"; orange <- "#F25B00" |
| |
| tryCatch({ |
| d <- data %>% filter(Pitcher == pitcher) %>% mutate(pitch_type = .data[[pitch_col]]) |
| keep <- d %>% count(pitch_type) %>% pull(pitch_type) |
| d <- d %>% filter(pitch_type %in% keep) |
| |
| situations <- list( |
| "First Pitch" = quote(Balls == 0 & Strikes == 0), |
| "Even" = quote(Balls == Strikes & Strikes > 0), |
| "2 Strikes" = quote(Strikes == 2), |
| "Behind" = quote(Balls > Strikes & !(Balls == 3 & Strikes == 2)), |
| "Full" = quote(Balls == 3 & Strikes == 2) |
| ) |
| |
| per_sit <- imap(situations, function(cond, label) { |
| sub <- d %>% filter(!!cond) |
| list(n = nrow(sub), |
| usage = sub %>% count(pitch_type, name = "k") %>% |
| mutate(usage = if (nrow(sub) > 0) k / nrow(sub) else 0)) |
| }) |
| n_lookup <- tibble(situation = names(per_sit), n_sit = map_int(per_sit, "n")) |
| usage_long <- imap_dfr(per_sit, ~ .x$usage %>% transmute(pitch_type, situation = .y, usage)) |
| |
| order_levels <- d %>% count(pitch_type, sort = TRUE) %>% pull(pitch_type) |
| wide <- usage_long %>% pivot_wider(names_from = situation, values_from = usage, values_fill = 0) |
| for (s in names(situations)) if (!s %in% names(wide)) wide[[s]] <- 0 |
| wide <- wide %>% |
| mutate(pitch_type = factor(pitch_type, levels = order_levels)) %>% |
| arrange(pitch_type) %>% |
| select(pitch_type, all_of(names(situations))) |
| |
| maxv <- max(as.matrix(wide[, names(situations)]), na.rm = TRUE) |
| labs <- setNames(lapply(names(situations), function(s) { |
| gt::html(sprintf("%s<br><span style='font-weight:400;font-size:9px;color:#9bb;'>%d P</span>", |
| s, n_lookup$n_sit[n_lookup$situation == s])) |
| }), names(situations)) |
| |
| wide %>% |
| gt(rowname_col = "pitch_type") %>% |
| gt_theme_espn() %>% |
| tab_header(title = "Pitch Usage by Count") %>% |
| fmt_percent(columns = all_of(names(situations)), decimals = 0) %>% |
| cols_label(.list = labs) %>% |
| cols_align("center", columns = all_of(names(situations))) %>% |
| data_color(columns = all_of(names(situations)), |
| palette = c("white", orange), domain = c(0, maxv)) %>% |
| tab_style(list(cell_fill(navy), cell_text(color = "white", weight = "bold")), |
| cells_column_labels()) %>% |
| tab_style(cell_text(color = navy, weight = "bold"), cells_stub()) %>% |
| tab_style(cell_text(color = navy, weight = "bold"), cells_title("title")) %>% |
| tab_options( |
| table.width = px(225), |
| table.font.size = px(10), |
| column_labels.font.size = px(10), |
| heading.title.font.size = px(14), |
| data_row.padding = px(3) |
| ) |
| }, |
| error = function(e) { |
| gt(tibble(` ` = "Bullpen \u2014 no sequencing")) %>% |
| gt_theme_espn() %>% |
| tab_options(column_labels.hidden = TRUE, |
| table.width = px(225), table.font.size = px(12)) %>% |
| cols_align("center") %>% |
| tab_style(cell_text(color = navy, weight = "bold"), cells_body()) |
| }) |
| } |
|
|
| |
| |
| |
| theme_report <- function(base = 10) { |
| theme_minimal() + |
| theme( |
| legend.position = "bottom", |
| plot.title = element_text(hjust = .5, size = 20), |
| legend.title = element_blank(), |
| panel.grid.minor = element_blank(), |
| axis.title = element_text(size = 13), |
| axis.text = element_text(size = 6) |
| ) |
| } |
|
|
| |
| release_plot <- function(outing, pitch_col = "TaggedPitchType") { |
| d <- outing %>% mutate(pitch_type = .data[[pitch_col]]) %>% filter(pitch_type != "Other") |
| m <- d %>% group_by(pitch_type) %>% |
| summarise(RelSide = mean(RelSide, na.rm = TRUE), RelHeight = mean(RelHeight, na.rm = TRUE), .groups = "drop") |
| ggplot(d, aes(RelSide, RelHeight, color = pitch_type)) + |
| geom_point(alpha = 0.55, size = 1.6) + |
| geom_point(data = m, aes(fill = pitch_type), shape = 21, size = 3.4, |
| color = "white", stroke = 1, show.legend = FALSE) + |
| scale_color_manual(values = pitch_colors, name = NULL) + |
| scale_fill_manual(values = pitch_colors, guide = "none") + |
| coord_fixed(xlim = c(-4.5, 4.5), ylim = c(0, 7.5), expand = FALSE) + |
| labs(title = "Release Points", x = "Release Side (ft)", y = "Release Height (ft)") + |
| theme_report() + |
| theme( |
| legend.position = "none" |
| ) |
| } |
|
|
| |
| movement_plot <- function(outing, season_ref = NULL, pitch_col = "TaggedPitchType") { |
| d <- outing %>% mutate(pitch_type = .data[[pitch_col]]) %>% filter(pitch_type != "Other") |
| |
| ggplot(d, aes(HorzBreak, InducedVertBreak, color = pitch_type)) + |
| geom_vline(xintercept = 0, linewidth = 1, linetype = "dotted", color = "black") + |
| geom_hline(yintercept = 0, linewidth = 1, linetype = "dotted", color = "black") + |
| geom_point(size = 2, alpha = 0.85) + |
| scale_color_manual(values = pitch_colors, name = NULL) + |
| coord_fixed(xlim = c(-28, 28), ylim = c(-28, 28)) + |
| labs(title = "Movement", x = "HB", y = "IVB") + |
| theme_report() + |
| theme( |
| legend.position = "none" |
| ) |
| |
| } |
|
|
|
|
| |
| |
| location_grid <- function(outing, pitch_col = "TaggedPitchType", n_pitches = 3) { |
| zb <- list(xmin = -8.5, xmax = 8.5, ymin = 18, ymax = 42) |
| plate <- tibble(x = c(-8.5, 8.5, 8.5, 0, -8.5), y = c(8, 8, 11, 14, 11)) |
| |
| d <- derive_outing(outing, pitch_col) %>% |
| filter(!is.na(PlateLocSide), !is.na(PlateLocHeight), pitch_type != "Other") |
| |
| |
| top <- d %>% count(pitch_type) %>% slice_max(n, n = n_pitches, with_ties = FALSE) %>% pull(pitch_type) |
| d <- d %>% |
| filter(pitch_type %in% top) %>% |
| mutate(pitch_type = factor(pitch_type, levels = top), |
| outcome = dplyr::case_when(.whiff ~ "Whiff", .bip ~ "In play", TRUE ~ "Other"), |
| outcome = factor(outcome, levels = c("Other", "Whiff", "In play"))) |
| |
| panel <- function(df, title) { |
| if (nrow(df) == 0) |
| return(ggplot() + |
| annotate("text", x = 0, y = 30, label = "No pitches", |
| color = "grey60", size = 3) + |
| coord_fixed(xlim = c(-14, 14), ylim = c(4, 50), expand = FALSE) + |
| labs(title = title, x = NULL, y = NULL) + |
| theme_void() + |
| theme(plot.title = element_text(face = "bold", color = NAVY, |
| size = 10, hjust = 0.5))) |
| |
| ggplot(df, aes(PlateLocSide, PlateLocHeight)) + |
| annotate("rect", xmin = zb$xmin, xmax = zb$xmax, ymin = zb$ymin, ymax = zb$ymax, |
| fill = NA, color = "grey35", linewidth = 0.5) + |
| annotate("segment", x = c(-2.84, 2.84), xend = c(-2.84, 2.84), |
| y = zb$ymin, yend = zb$ymax, color = "grey82", linewidth = 0.3) + |
| annotate("segment", x = zb$xmin, xend = zb$xmax, |
| y = c(26, 34), yend = c(26, 34), color = "grey82", linewidth = 0.3) + |
| geom_polygon(data = plate, aes(x, y), inherit.aes = FALSE, |
| fill = "grey88", color = "grey55", linewidth = 0.35) + |
| geom_point(aes(color = pitch_type, shape = outcome), size = 1.5, stroke = 0.8) + |
| scale_color_manual(values = pitch_colors, guide = "none", drop = FALSE) + |
| scale_shape_manual(values = c("Other" = 16, "Whiff" = 4, "In play" = 17), |
| name = NULL, drop = FALSE) + |
| coord_fixed(xlim = c(-14*1.25, 14*1.25), ylim = c(4*1.25, 50*1.25), expand = FALSE) + |
| facet_wrap(~ pitch_type, nrow = 1, drop = FALSE) + |
| labs(title = title, x = NULL, y = NULL) + |
| theme_report() + |
| theme(axis.text = element_blank(), axis.ticks = element_blank(), |
| panel.grid = element_blank(), |
| plot.title = element_text(face = "bold", color = NAVY, size = 10, hjust = 0.5), |
| strip.text = element_text(face = "bold", color = NAVY, size = 8)) |
| } |
| |
| |
| if (!("BatterSide" %in% names(d))) return(panel(d, "Locations (pitcher's view)")) |
| |
| d <- d %>% mutate(side = dplyr::case_when( |
| BatterSide %in% c("Left", "L") ~ "L", |
| BatterSide %in% c("Right", "R") ~ "R", TRUE ~ NA_character_)) %>% |
| filter(!is.na(side)) |
| |
| p_l <- panel(dplyr::filter(d, side == "L"), "vs LHB") |
| p_r <- panel(dplyr::filter(d, side == "R"), "vs RHB") |
| bar <- ggplot() + |
| geom_vline(xintercept = 0, color = ORANGE, linewidth = 1) + |
| theme_void() |
| |
| patchwork::wrap_plots(p_l, bar, p_r, widths = c(1, 0.05, 1), guides = "collect") + |
| patchwork::plot_annotation( |
| title = "Locations (pitcher's view)", |
| theme = theme(plot.title = element_text(face = "bold", color = NAVY, size = 11))) & |
| theme(legend.position = "bottom") |
| } |
|
|
| |
| |
| |
| gg_to_img <- function(p, width = 8.0, height = 3.0, dpi = 150) { |
| tmp <- tempfile(fileext = ".png") |
| ggsave(tmp, p, width = width, height = height, dpi = dpi, bg = "white", device = "png") |
| uri <- knitr::image_uri(tmp); file.remove(tmp) |
| sprintf('<img src="%s" style="width:100%%;height:auto;display:block;"/>', uri) |
| } |
|
|
| game_stats <- function(outing) { |
| d <- derive_outing(outing) |
| has_pa <- "PitchofPA" %in% names(d) |
| list( |
| P = nrow(d), |
| BF = if (has_pa) sum(d$PitchofPA == 1, na.rm = TRUE) else NA, |
| K = sum(d$KorBB == "Strikeout", na.rm = TRUE), |
| BB = sum(d$KorBB == "Walk", na.rm = TRUE), |
| H = sum(d$.hit, na.rm = TRUE), |
| Str = round(100 * mean(d$.strike, na.rm = TRUE))) |
| } |
|
|
| report_css <- function() { |
| glue::glue(" |
| @page {{ size: 8.5in 11in; margin: 0; }} |
| * {{ box-sizing: border-box; }} |
| body {{ margin:0; font-family:'Inter',system-ui,sans-serif; color:{NAVY}; |
| width:8.5in; -webkit-print-color-adjust:exact; print-color-adjust:exact; }} |
| .rpt {{ width:8.5in; margin:0; }} |
| .rpt-head {{ background:#0D1B2A; border-bottom:3px solid {ORANGE}; |
| padding:12px 0.25in; display:flex; align-items:center; justify-content:space-between; }} |
| .rpt-head .name {{ color:#fff; font-size:20px; font-weight:800; }} |
| .rpt-head .date {{ color:#ffffffaa; font-size:12px; margin-top:2px; }} |
| .rpt-stats {{ display:grid; grid-template-columns:repeat(6,1fr); gap:8px; |
| padding:0 0.25in; margin:10px 0 6px; }} |
| .sbox {{ border:1px solid #E2E8F0; border-radius:7px; padding:7px 4px; text-align:center; |
| border-bottom:3px solid {BLUE}; }} |
| .sbox.k {{ border-bottom-color:{ORANGE}; }} |
| .sbox .v {{ font-size:20px; font-weight:800; color:{NAVY}; line-height:1; }} |
| .sbox .l {{ font-size:9px; color:{GRAY}; text-transform:uppercase; letter-spacing:.06em; margin-top:3px; }} |
| .sec {{ break-inside:avoid; page-break-inside:avoid; margin-top:10px; padding:0 0.25in; }} |
| .row2 {{ display:flex; gap:10px; }} .row2 > div {{ flex:1; }} |
| table {{ width:100%; font-size:11px; }} |
| .cap {{ text-align:center; font-size:8px; color:{GRAY}; margin-top:14px; padding:0 0.25in; }} |
| ") |
| } |
|
|
| build_report_html <- function(outing, season_ref = NULL, pitcher_name, game_date, |
| pitch_col = "TaggedPitchType", k = 1) { |
| stopifnot(nrow(outing) > 0) |
| nm <- sub("^(.*),\\s*(.*)$", "\\2 \\1", pitcher_name) |
| gs <- game_stats(outing) |
| |
| shape_html <- gt::as_raw_html(shape_table_gt(outing, season_ref, pitch_col, k)) |
| res_html <- gt::as_raw_html(results_table_gt(outing, pitch_col)) |
| rel_img <- gg_to_img(release_plot(outing, pitch_col), width = 3.9, height = 3.2) |
| mov_img <- gg_to_img(movement_plot(outing, season_ref, pitch_col), width = 3.9, height = 3.2) |
| seq_html <- gt::as_raw_html(sequencing_table(outing, pitcher_name, pitch_col)) |
| loc_img <- gg_to_img(location_grid(outing, pitch_col), width = 8.0, height = 3.3) |
| |
| sbox <- function(v, l, k = FALSE) |
| sprintf('<div class="sbox%s"><div class="v">%s</div><div class="l">%s</div></div>', |
| if (k) " k" else "", v, l) |
| |
| htmltools::HTML(glue::glue(' |
| <!DOCTYPE html><html><head><meta charset="utf-8"> |
| <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;800&display=swap" rel="stylesheet"> |
| <style>{report_css()}</style></head> |
| <body><div class="rpt"> |
| |
| <div class="rpt-head"> |
| <div><div class="name">{nm}</div><div class="date">{as.character(game_date)} \u00b7 Hyannis Harbor Hawks</div></div> |
| <svg width="34" height="34" viewBox="0 0 30 30"> |
| <polygon points="15,2 28,26 15,21 2,26" fill="{ORANGE}" opacity="0.9"/> |
| <polygon points="15,2 15,21 2,26" fill="#fff" opacity="0.18"/></svg> |
| </div> |
| |
| <div class="rpt-stats"> |
| {sbox(gs$P, "Pitches")} |
| {sbox(ifelse(is.na(gs$BF), "\u2014", gs$BF), "Batters")} |
| {sbox(gs$K, "K", k = TRUE)} |
| {sbox(gs$BB, "BB")} |
| {sbox(gs$H, "Hits")} |
| {sbox(paste0(gs$Str, "%"), "Strike%")} |
| </div> |
| |
| <div class="sec">{shape_html}</div> |
| <div class="sec">{res_html}</div> |
| |
| <div class="sec"><div class="row2"> |
| <div>{rel_img}</div> |
| <div>{mov_img}</div> |
| <div style="flex:0 0 225px;">{seq_html}</div> |
| </div></div> |
| |
| <div class="sec">{loc_img}</div> |
| |
| <div class="cap">Hyannis Harbor Hawks \u00b7 Cape Cod Baseball League \u00b7 generated {format(Sys.Date())}</div> |
| </div></body></html>')) |
| } |
|
|
| REQUIRED_COLS <- c("Pitcher", "Date", "TaggedPitchType", "RelSpeed", "SpinRate", |
| "InducedVertBreak", "HorzBreak", "PlateLocSide", "PlateLocHeight", "PitchCall") |
|
|
| |
| |
| |
|
|
| |
| retag_pitches <- function(data) { |
| |
| |
| arsenal_percentages <- pitcher_arsenals_over_5perc %>% |
| mutate(in_reference = TRUE) |
| |
| |
| data <- data %>% mutate(row_id = row_number()) |
| |
| |
| predict_data <- data %>% |
| mutate( |
| max_velo_diff = max(RelSpeed, na.rm = TRUE) - RelSpeed, |
| max_vert_diff = InducedVertBreak[which.max(RelSpeed)] - InducedVertBreak, |
| max_ext_diff = Extension[which.max(RelSpeed)] - Extension, |
| .by = c(GameUID, Pitcher) |
| ) %>% |
| mutate( |
| RelSide = case_when( |
| PitcherThrows == "Right" ~ RelSide, |
| PitcherThrows == "Left" ~ -RelSide, |
| PitcherThrows %in% c("Both", "Undefined") & RelSide > 0 ~ RelSide, |
| PitcherThrows %in% c("Both", "Undefined") & RelSide < 0 ~ -RelSide), |
| HorzBreak = case_when( |
| PitcherThrows == "Right" ~ HorzBreak, |
| PitcherThrows == "Left" ~ -HorzBreak, |
| PitcherThrows %in% c("Both", "Undefined") & HorzBreak > 0 ~ HorzBreak, |
| PitcherThrows %in% c("Both", "Undefined") & HorzBreak < 0 ~ -HorzBreak) |
| ) |
| |
| |
| probs <- predict(retag_model, predict_data, type = "prob") |
| pred_col_names <- names(probs) |
| |
| |
| probs$row_id <- predict_data$row_id |
| |
| |
| data <- data %>% |
| left_join(arsenal_percentages, by = "Pitcher") %>% |
| left_join(probs, by = "row_id") %>% |
| select(-row_id) |
| |
| |
| has_arsenal <- !is.na(data$in_reference) |
| |
| |
| |
| pitch_to_perc <- c( |
| .pred_Fastball = "perc_FF", .pred_Cutter = "perc_CT", |
| .pred_Sinker = "perc_SI", .pred_Slider = "perc_SL", |
| .pred_Sweeper = "perc_SW", .pred_Curveball = "perc_CU", |
| .pred_Changeup = "perc_CH", .pred_Splitter = "perc_SP" |
| ) |
| for (pc in intersect(names(pitch_to_perc), names(data))) { |
| ac <- pitch_to_perc[[pc]] |
| mult <- ifelse(has_arsenal, as.numeric(coalesce(data[[ac]], 0) > 0), 1) |
| data[[pc]] <- data[[pc]] * mult |
| } |
| |
| |
| prob_mat <- as.matrix(data[pred_col_names]) |
| |
| |
| predicted <- rowSums(!is.na(prob_mat)) > 0 |
| |
| |
| pt <- rep(NA_character_, nrow(data)) |
| |
| |
| m <- prob_mat[predicted, , drop = FALSE] |
| m[is.na(m)] <- 0 |
| |
| |
| pt[predicted] <- pred_col_names[max.col(m, ties.method = "first")] |
|
|
| |
| data <- data %>% |
| mutate(real_tag = TaggedPitchType) |
| |
| |
| data$TaggedPitchType <- sub("^\\.pred_", "", pt) |
| data %>% select(-in_reference) |
| |
| |
| data <- data %>% |
| mutate(TaggedPitchType = ifelse(TaggedPitchType == "Splitter", "Changeup", TaggedPitchType)) |
| |
| return(data) |
| |
| } |
|
|
|
|
| |
| predict_stuffplus <- function(data) { |
|
|
| |
| predict_data <- data %>% |
| mutate(RelSide = case_when( |
| PitcherThrows == "Right" ~ RelSide, |
| PitcherThrows == "Left" ~ -RelSide, |
| PitcherThrows %in% c("Both", "Undefined") & RelSide > 0 ~ RelSide, |
| PitcherThrows %in% c("Both", "Undefined") & RelSide < 0 ~ -RelSide), |
| ax0 = case_when( |
| PitcherThrows == "Right" ~ ax0, |
| PitcherThrows == "Left" ~ -ax0, |
| PitcherThrows %in% c("Both", "Undefined") & ax0 > 0 ~ ax0, |
| PitcherThrows %in% c("Both", "Undefined") & ax0 < 0 ~ -ax0), |
| ax0 = -ax0) %>% |
| group_by(Pitcher, GameID) %>% |
|
|
| |
| |
| |
| mutate( |
| primary_pitch = case_when( |
| any(TaggedPitchType == "Fastball") ~ "Fastball", |
| any(TaggedPitchType == "Sinker") ~ "Sinker", |
| TRUE ~ names(sort(table(TaggedPitchType), decreasing = TRUE))[1] |
| ) |
| ) %>% |
| ungroup() %>% |
| group_by(Pitcher, GameID, primary_pitch) %>% |
| mutate( |
| primary_az0 = mean(az0[TaggedPitchType == primary_pitch], na.rm = TRUE), |
| primary_velo = mean(RelSpeed[TaggedPitchType == primary_pitch], na.rm = TRUE) |
| ) %>% |
| ungroup() %>% |
| mutate(az0_diff = az0 - primary_az0, |
| velo_diff = RelSpeed - primary_velo) |
|
|
| |
| |
| df_processed <- bake(stuffplus_recipe, new_data = predict_data) |
| |
| df_matrix <- as.matrix(df_processed) |
| |
| raw_stuff <- predict(stuffplus_model, df_matrix) |
| |
| data$raw_stuff <- raw_stuff |
| |
| |
| data <- data %>% |
| mutate(stuff_plus = ((raw_stuff - 0.004424894) / 0.01010482) * 10 + 100) |
| |
| return(data) |
| |
| } |
|
|
| |
| |
|
|
| |
| clean_college_data <- function(data, teams = NA) { |
| |
| |
| data <- data %>% |
| mutate(PlayResult = ifelse(PlayResult %in% c("HomeRun", "homerun"), "Homerun", PlayResult), |
| Batter = sub("(.*),\\s*(.*)", "\\2 \\1", Batter), |
| Pitcher = sub("(.*),\\s*(.*)", "\\2 \\1", Pitcher), |
| Catcher = sub("(.*),\\s*(.*)", "\\2 \\1", Catcher)) |
| |
| |
| col <- colnames(data) |
|
|
| |
| if ("Top/Bottom" %in% col){ |
| data <- data %>% |
| rename(`Top.Bottom` = `Top/Bottom`) |
| } |
| |
| |
| numeric_columns <- c("PitchNo", "PAofInning", "PitchofPA", "PitcherId", "BatterId", "Inning", "Outs", "Balls", |
| "Strikes", "OutsOnPlay", "RunsScored", "RelSpeed", "VertRelAngle", "HorzRelAngle", "SpinRate", |
| "SpinAxis", "RelHeight", "RelSide", "Extension", "VertBreak", "InducedVertBreak", "HorzBreak", |
| "PlateLocHeight", "PlateLocSide", "ZoneSpeed", "VertApprAngle", "HorzApprAngle", "ZoneTime", |
| "ExitSpeed", "Angle", "Direction", "HitSpinRate", "Distance", "Bearing", "HangTime", |
| "LastTrackedDistance", "pfxx", "pfxz", "x0", "y0", "z0", "vx0", "vz0", "vy0", "ax0", "ay0", |
| "az0", "EffectiveVelo", "MaxHeight", "SpeedDrop", "ContactPositionX", "ContactPositionY", |
| "ContactPositionZ", "HomeTeamForeignID", "AwayTeamForeignID", "CatcherId", "ThrowSpeed", |
| "PopTime", "ExchangeTime", "TimeToBase", "OutsOnPlay") |
| data <- data %>% |
| mutate(across(any_of(numeric_columns), ~suppressWarnings(as.numeric(.x)))) |
| |
| |
| |
| data <- data %>% |
| mutate( |
| Batter = ifelse(str_detect(Batter, ","), |
| str_trim(sub("^([^,]*),\\s*(.*)$", "\\2 \\1", Batter)), |
| Batter), |
| Pitcher = ifelse(str_detect(Pitcher, ","), |
| str_trim(sub("^([^,]*),\\s*(.*)$", "\\2 \\1", Pitcher)), |
| Pitcher) |
| ) |
| |
| |
| data <- data %>% |
| group_by(GameID) %>% |
| mutate( |
| PlateLocHeight = PlateLocHeight * if (isTRUE(sd(PlateLocHeight, na.rm = TRUE) < 5)) 12 else 1, |
| PlateLocSide = PlateLocSide * if (isTRUE(sd(PlateLocSide, na.rm = TRUE) < 5)) 12 else 1 |
| ) %>% |
| ungroup() |
| |
| |
| |
| data <- data %>% |
| mutate(TaggedPitchType = case_when( |
| TaggedPitchType == "FourSeamFastBall" ~ "Fastball", |
| TaggedPitchType %in% c("TwoSeamFastBall", "OneSeamFastBall") ~ "Sinker", |
| TaggedPitchType == "ChangeUp" ~ "Changeup", |
| TaggedPitchType == "Undefined" ~ "Other", |
| T ~ TaggedPitchType |
| )) |
|
|
| data <- retag_pitches(data) |
| |
| |
| data <- data %>% |
| mutate( |
| is_csw = case_when( |
| PitchCall %in% c("StrikeSwinging", "StrikeCalled") ~ 1, |
| TRUE ~ 0 |
| ), |
| is_swing = case_when( |
| PitchCall %in% c("StrikeSwinging", "FoulBallNotFieldable", "InPlay", |
| "FoulBallFieldable", "FoulBall") ~ 1, |
| TRUE ~ 0 |
| ), |
| is_whiff = case_when( |
| PitchCall == "StrikeSwinging" & is_swing == 1 ~ 1, |
| PitchCall != "StrikeSwinging" & is_swing == 1 ~ 0, |
| TRUE ~ NA_real_ |
| ), |
| in_zone = case_when( |
| PlateLocSide > 9.975 | PlateLocSide < -9.975 | |
| PlateLocHeight > 40 | PlateLocHeight < 20 ~ 0, |
| TRUE ~ 1 |
| ), |
| chase = case_when( |
| is_swing == 1 & in_zone == 0 ~ 1, |
| is_swing == 0 & in_zone == 0 ~ 0, |
| TRUE ~ NA_real_ |
| ), |
| in_zone_whiff = case_when( |
| is_swing == 1 & in_zone == 1 & is_whiff == 1 ~ 1, |
| is_swing == 1 & in_zone == 1 & is_whiff == 0 ~ 0, |
| TRUE ~ NA_real_ |
| ), |
| is_hit = case_when( |
| PlayResult %in% c("Single", "Double", "Triple", "Homerun", "HomeRun") & PitchCall == "InPlay" ~ 1, |
| !PlayResult %in% c("Single", "Double", "Triple", "Homerun", "HomeRun") & PitchCall == "InPlay" ~ 0, |
| KorBB == "Strikeout" ~ 0, |
| PlayResult %in% c("StrikeoutSwinging", "StrikeoutLooking") ~ 0, |
| TRUE ~ NA_real_ |
| ), |
| slg = case_when( |
| PitchCall == "InPlay" & PlayResult == "Single" ~ 1, |
| PitchCall == "InPlay" & PlayResult == "Double" ~ 2, |
| PitchCall == "InPlay" & PlayResult == "Triple" ~ 3, |
| PitchCall == "InPlay" & PlayResult %in% c("Homerun", "HomeRun") ~ 4, |
| !PlayResult %in% c("Single", "Double", "Triple", "Homerun", "HomeRun") & PitchCall == "InPlay" ~ 0, |
| KorBB == "Strikeout" ~ 0, |
| PlayResult %in% c("StrikeoutSwinging", "StrikeoutLooking") ~ 0, |
| TRUE ~ NA_real_ |
| ), |
| on_base = case_when( |
| PitchCall == "InPlay" & PlayResult %in% c("Single", "Double", "Triple", "Homerun", "HomeRun") ~ 1, |
| PitchCall %in% c("HitByPitch") | KorBB == "Walk" ~ 1, |
| PitchCall == "InPlay" & PlayResult %in% c("Out", "Error", "FieldersChoice") & PlayResult != "Sacrifice" ~ 0, |
| KorBB == "Strikeout" ~ 0, |
| PlayResult %in% c("StrikeoutSwinging", "StrikeoutLooking") ~ 0, |
| TRUE ~ NA_real_ |
| ), |
| is_hard_hit = case_when( |
| ExitSpeed >= 95 & PitchCall == "InPlay" ~ 1, |
| ExitSpeed < 95 & PitchCall == "InPlay" ~ 0, |
| TRUE ~ NA_real_ |
| ), |
| woba = case_when( |
| PitchCall == "InPlay" & PlayResult == "Single" ~ 0.95, |
| PitchCall == "InPlay" & PlayResult == "Double" ~ 1.24, |
| PitchCall == "InPlay" & PlayResult == "Triple" ~ 1.47, |
| PitchCall == "InPlay" & PlayResult %in% c("Homerun", "HomeRun") ~ 1.71, |
| KorBB == "Walk" ~ 0.82, |
| PitchCall %in% c("HitByPitch") ~ 0.85, |
| KorBB == "Strikeout" ~ 0, |
| PlayResult %in% c("StrikeoutSwinging", "StrikeoutLooking") ~ 0, |
| PitchCall == "InPlay" & !PlayResult %in% c("Single", "Double" ,"Triple" ,"Homerun", "HomeRun") ~ 0, |
| TRUE ~ NA_real_ |
| ), |
| wobacon = case_when( |
| PitchCall == "InPlay" & PlayResult == "Single" ~ 0.95, |
| PitchCall == "InPlay" & PlayResult == "Double" ~ 1.24, |
| PitchCall == "InPlay" & PlayResult == "Triple" ~ 1.47, |
| PitchCall == "InPlay" & PlayResult %in% c("Homerun", "HomeRun") ~ 1.71, |
| PitchCall == "InPlay" & !PlayResult %in% c("Single", "Double" ,"Triple" ,"Homerun", "HomeRun") ~ 0, |
| TRUE ~ NA_real_ |
| ), |
| is_plate_appearance = ifelse( |
| PitchCall %in% c("InPlay", "HitByPitch") | KorBB %in% c("Strikeout", "Walk") | PlayResult %in% c("StrikeoutSwinging", "StrikeoutLooking"), 1, 0 |
| ), |
| is_at_bat = case_when( |
| PitchCall == "InPlay" & !PlayResult %in% c("StolenBase", "Sacrifice", "CaughtStealing", "Undefined") ~ 1, |
| KorBB == "Strikeout" ~ 1, |
| PlayResult %in% c("StrikeoutSwinging", "StrikeoutLooking") ~ 1, |
| TRUE ~ 0 |
| ), |
| is_walk = case_when( |
| is_plate_appearance == 1 & KorBB == "Walk" ~ 1, |
| is_plate_appearance == 1 & KorBB != "Walk" ~ 0, |
| TRUE ~ NA_real_ |
| ), |
| is_k = case_when( |
| is_at_bat == 1 & KorBB == "Strikeout" ~ 1, |
| is_at_bat == 1 & KorBB != "Strikeout" ~ 0, |
| PlayResult %in% c("StrikeoutSwinging", "StrikeoutLooking") ~ 1, |
| TRUE ~ NA_real_ |
| ), |
| is_put_away = case_when( |
| Strikes == 2 & KorBB == "Strikeout" ~ 1, |
| Strikes == 2 & KorBB != "Strikeout" ~ 0, |
| Strikes == 2 & PlayResult %in% c("StrikeoutSwinging", "StrikeoutLooking") ~ 1, |
| TRUE ~ NA_real_ |
| ), |
| OutsOnPlay = ifelse(KorBB == "Strikeout" | PlayResult %in% c("StrikeoutSwinging", "StrikeoutLooking"), OutsOnPlay + 1, OutsOnPlay) |
| ) |
| |
| data <- data %>% |
| mutate(event_type = case_when( |
| PitchCall %in% c("StrikeSwinging", "StrkeSwinging") ~ "Whiff", |
| PitchCall %in% c("StriekC", "StrikeCalled") ~ "Called Strike", |
| PitchCall %in% c("FoulBallFieldable", "FoulBall", "FoulBallNotFieldable", |
| "FouldBallNotFieldable") ~ "Foul Ball", |
| PitchCall %in% c("BallCalled", "BallinDirt", "BallIntentional", "BalIntentional") ~ "Ball", |
| PitchCall == "HitByPitch" ~ "HBP", |
| PitchCall == "InPlay" & PlayResult %in% c("Out", "FieldersChoice", |
| "Error", "error", |
| "Sacrifice") ~ "Field Out", |
| PitchCall == "InPlay" & PlayResult == "Single" ~ "Single", |
| PitchCall == "InPlay" & PlayResult == "Double" ~ "Double", |
| PitchCall == "InPlay" & PlayResult == "Triple" ~ "Triple", |
| PitchCall == "InPlay" & PlayResult == "Homerun" ~ "Home Run", |
| T ~ NA |
| )) |
| |
|
|
| |
| if (!"mean_DRE" %in% names(data)) { |
| data <- data %>% |
| left_join(rv, by = "event_type") |
| } |
|
|
| |
| data <- data %>% |
| dplyr::select( |
| -any_of(c( |
| "PitchLastMeasuredX", "PitchLastMeasuredY", "PitchLastMeasuredZ", |
| "HitSpinAxis", |
| "PitchReleaseConfidence", "PitchLocationConfidence", "PitchMovementConfidence", |
| "HitLaunchConfidence", "HitLandingConfidence", |
| "CatcherThrowCatchConfidence", "CatcherThrowReleaseConfidence", "CatcherThrowLocationConfidence", |
| "PositionAt110X", "PositionAt110Y", "PositionAt110Z" |
| )), |
| -starts_with("PitchTrajectory"), |
| -starts_with("HitTrajectory") |
| ) |
|
|
| |
| data <- data %>% |
| dplyr::select(-any_of(c("HomeLeague", "AwayLeague", "PitcherLeague", "BatterLeague"))) %>% |
| left_join(team_leagues, by = "HomeTeam") %>% |
| left_join(team_leagues %>% rename(AwayLeague = HomeLeague, AwayTeam = HomeTeam), by = "AwayTeam") %>% |
| mutate(PitcherLeague = ifelse(`Top.Bottom` == "Top", HomeLeague, AwayLeague), |
| BatterLeague = ifelse(`Top.Bottom` == "Top", AwayLeague, HomeLeague), |
| HomeLeague = replace_na(HomeLeague, "Other"), |
| AwayLeague = replace_na(AwayLeague, "Other"), |
| PitcherLeague = replace_na(PitcherLeague, "Other"), |
| BatterLeague = replace_na(BatterLeague, "Other") |
| ) |
| |
|
|
| |
| if (!"stuff_plus" %in% names(data)) { |
| data <- predict_stuffplus(data) |
| } |
|
|
| |
| data <- data %>% |
| filter(!(Level %in% c("D2", "JUCO", "NAIA", "D3"))) |
|
|
| |
| data <- tryCatch({ |
| d <- data %>% mutate(row_id = row_number()) |
| prob_cols <- c("prob_0", "prob_1", "prob_2", "prob_3", "prob_4") |
| |
| complete <- d %>% |
| filter(!is.na(ExitSpeed) & !is.na(Angle)) %>% |
| dplyr::select(-any_of(prob_cols)) |
| |
| xwoba_preds <- predict(xwoba_model, complete, type = "prob") |
| colnames(xwoba_preds) <- prob_cols |
| |
| complete <- complete %>% bind_cols(xwoba_preds) |
| |
| d <- d %>% |
| dplyr::select(-any_of(prob_cols)) %>% |
| left_join(complete %>% dplyr::select(row_id, any_of(prob_cols)), by = "row_id") %>% |
| dplyr::select(-row_id) |
| |
| d <- d %>% |
| dplyr::select(-any_of(c("w1B", "w2B", "w3B", "wHR"))) %>% |
| cross_join(college_guts %>% dplyr::select(w1B, w2B, w3B, wHR)) %>% |
| mutate(xwOBA = w1B*prob_1 + w2B*prob_2 + w3B*prob_3 + wHR*prob_4 + 0*prob_0, |
| xwOBA = case_when( |
| KorBB == "Strikeout" ~ 0, |
| KorBB == "Walk" ~ .82, |
| event_type == "HBP" ~ .85, |
| TRUE ~ xwOBA |
| )) |
| d |
| }, error = function(e) { |
| message("xwOBA prediction failed: ", conditionMessage(e), " \u2014 setting xwOBA and prob_* to NA.") |
| data %>% mutate( |
| prob_0 = NA_real_, prob_1 = NA_real_, prob_2 = NA_real_, |
| prob_3 = NA_real_, prob_4 = NA_real_, |
| xwOBA = NA_real_ |
| ) |
| }) |
| |
| message(nrow(data), " rows cleaned.") |
|
|
| return(data) |
| |
| |
| } |
|
|
|
|
| |
| |
| |
| HAWKS_ORANGE <- "#F25B00"; HAWKS_BLUE <- "#164EA7"; HAWKS_DARK <- "#0D1B2A" |
| HAWKS_LIGHT <- "#F5F7FA"; HAWKS_GRAY <- "#6B7280"; HAWKS_BORDER <- "#E2E8F0" |
|
|
| hawks_theme <- bs_theme( |
| version = 5, bg = "#FFFFFF", fg = HAWKS_DARK, |
| primary = HAWKS_BLUE, secondary = HAWKS_ORANGE, |
| base_font = font_google("Inter"), heading_font = font_google("Inter"), |
| `border-radius` = "0.5rem", `font-size-base` = "0.925rem" |
| ) |
|
|
| hawks_css <- tags$style(HTML(glue(" |
| * {{ box-sizing:border-box; }} |
| body {{ background:{HAWKS_LIGHT}; font-family:'Inter',system-ui,sans-serif; margin:0; }} |
| .hawks-shell {{ display:flex; flex-direction:column; min-height:100vh; }} |
| .hawks-topbar {{ background:{HAWKS_DARK}; padding:0 24px; display:flex; align-items:center; |
| justify-content:space-between; border-bottom:3px solid {HAWKS_ORANGE}; }} |
| .topbar-brand {{ display:flex; align-items:center; gap:10px; padding:14px 0; }} |
| .topbar-brand .app-title {{ color:#fff; font-size:16px; font-weight:800; }} |
| .topbar-title {{ color:#fff; font-size:26px; font-weight:800; flex:1; text-align:center; white-space:nowrap; }} |
| .topbar-spacer {{ width:110px; }} |
| .hawks-content {{ flex:1; padding:24px; }} |
| .data-card {{ background:#fff; border:1px solid {HAWKS_BORDER}; border-radius:8px; overflow:hidden; }} |
| .data-card-hdr {{ padding:10px 14px; border-bottom:1px solid {HAWKS_BORDER}; |
| display:flex; align-items:center; justify-content:space-between; }} |
| .data-card-title {{ font-size:11px; font-weight:700; color:{HAWKS_DARK}; |
| text-transform:uppercase; letter-spacing:.05em; }} |
| .data-card-sub {{ font-size:10px; color:#9CA3AF; }} |
| .data-card-body {{ padding:16px; }} |
| .status-box {{ background:#eef3fb; border-left:4px solid {HAWKS_BLUE}; padding:14px; |
| border-radius:4px; white-space:pre-wrap; font-family:monospace; font-size:13px; color:{HAWKS_DARK}; }} |
| .btn-scrape {{ background:{HAWKS_BLUE} !important; border:none !important; color:#fff !important; |
| font-weight:700; width:100%; }} |
| .btn-scrape:hover {{ background:#0f3d82 !important; }} |
| .btn-hf {{ background:{HAWKS_ORANGE} !important; border:none !important; color:#fff !important; |
| font-weight:700; width:100%; }} |
| .btn-hf:hover {{ background:#d44f00 !important; }} |
| "))) |
|
|
| |
| |
| |
| login_css <- tags$style(HTML(" |
| #login_screen { position:fixed; inset: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; text-align:center; letter-spacing:2px; } |
| #login_screen .login-box input[type='password']:focus { border-color:#FF8C00; } |
| #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; } |
| #login_screen .login-btn:hover { background:#e67e00; } |
| #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", "TrackMan Scraper"), |
| 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 = '", 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.addEventListener('DOMContentLoaded', function(){ |
| document.getElementById('login_btn').addEventListener('click', tryLogin); |
| document.getElementById('login_pw').addEventListener('keydown', function(e){ |
| if (e.key === 'Enter') tryLogin(); |
| }); |
| }); |
| "))) |
|
|
| |
| |
| |
| ui <- fluidPage( |
| theme = hawks_theme, |
| hawks_css, |
| login_css, |
| tags$style(HTML(glue(" |
| .page-frame {{ width:8.5in; height:11in; border:1px solid {HAWKS_BORDER}; |
| box-shadow:0 4px 24px rgba(0,0,0,.12); background:#fff; }} |
| "))), |
| tags$div(class = "hawks-shell", |
| tags$div(class = "hawks-topbar", |
| 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", "Scraper") |
| ), |
| tags$div(class = "topbar-title", "TrackMan Scraper \u2014 2026 Cape"), |
| tags$div(class = "topbar-spacer") |
| ), |
| tags$div(class = "hawks-content", |
| tabsetPanel( |
| id = "main_tabs", |
|
|
| tabPanel("Scrape & Upload", |
| tags$div(style = "padding-top:16px;", |
| fluidRow( |
| column(4, |
| tags$div(class = "data-card", |
| tags$div(class = "data-card-hdr", |
| tags$span(class = "data-card-title", "Controls"), |
| tags$span(class = "data-card-sub", "Source & dates")), |
| tags$div(class = "data-card-body", |
| radioButtons("scrape_source", "Data Source", |
| choices = c("TrackMan PBP" = "pbp", "TrackMan Positional" = "pos"), selected = "pbp"), |
| dateInput("start_date", "Start Date:", value = Sys.Date() - 1), |
| dateInput("end_date", "End Date:", value = Sys.Date() - 1), |
| br(), |
| actionButton("scrape_btn", "Scrape Data", class = "btn-scrape"), |
| br(), br(), |
| downloadButton("download_scrape", "Download CSV", style = "width:100%;"), |
| br(), br(), |
| actionButton("upload_hf_btn", "Upload to HF Dataset", class = "btn-hf") |
| ) |
| ) |
| ), |
| column(8, |
| tags$div(class = "data-card", style = "margin-bottom:16px;", |
| tags$div(class = "data-card-hdr", |
| tags$span(class = "data-card-title", "Progress"), |
| tags$span(class = "data-card-sub", "Live status")), |
| tags$div(class = "data-card-body", |
| tags$div(class = "status-box", textOutput("scrape_status"))) |
| ), |
| tags$div(class = "data-card", |
| tags$div(class = "data-card-hdr", |
| tags$span(class = "data-card-title", "Data Preview"), |
| tags$span(class = "data-card-sub", "Scraped rows")), |
| tags$div(class = "data-card-body", DT::dataTableOutput("scrape_preview")) |
| ) |
| ) |
| ) |
| ) |
| ), |
|
|
| tabPanel("Pitcher Report", |
| tags$div(style = "display:flex; gap:16px; padding-top:16px;", |
| tags$div(style = "width:260px; flex-shrink:0;", |
| tags$div(class = "data-card", |
| tags$div(class = "data-card-hdr", |
| tags$span(class = "data-card-title", "Report Inputs")), |
| tags$div(class = "data-card-body", |
| fileInput("file", "Outing CSV (TrackMan)", accept = ".csv", |
| buttonLabel = "Browse", placeholder = "No file"), |
| checkboxInput("needs_cleaning", |
| "Raw data \u2014 run cleaning + tagging", value = FALSE), |
| selectInput("pitcher", "Pitcher", choices = NULL, width = "100%"), |
| selectInput("game", "Outing", choices = NULL, width = "100%"), |
| actionButton("build", "Build report", class = "btn-scrape"), |
| br(), br(), |
| downloadButton("dl", "Download HTML", style = "width:100%;") |
| ) |
| ) |
| ), |
| tags$div(style = "flex:1; overflow:auto;", |
| uiOutput("preview") |
| ) |
| ) |
| ) |
| ) |
| ) |
| ), |
| login_overlay, |
| login_js |
| ) |
|
|
| |
| |
| |
| server <- function(input, output, session) { |
|
|
| scraped_data <- reactiveVal(NULL) |
| scrape_polling <- reactiveVal(FALSE) |
| scrape_status_msg <- reactiveVal("Ready.") |
|
|
| output$scrape_status <- renderText({ scrape_status_msg() }) |
|
|
| |
| observeEvent(input$scrape_btn, { |
| scrape_status_msg("Triggering scrape on GitHub...") |
|
|
| result <- tryCatch({ |
| httr::POST( |
| paste0("https://api.github.com/repos/", gh_repo, "/actions/workflows/scrape.yml/dispatches"), |
| httr::add_headers( |
| Authorization = paste("Bearer", gh_token), |
| Accept = "application/vnd.github.v3+json" |
| ), |
| body = jsonlite::toJSON(list( |
| ref = "main", |
| inputs = list( |
| start_date = as.character(input$start_date), |
| end_date = as.character(input$end_date), |
| data_type = input$scrape_source |
| ) |
| ), auto_unbox = TRUE), |
| encode = "raw" |
| ) |
| }, error = function(e) { |
| scrape_status_msg(paste("Failed:", e$message)); NULL |
| }) |
|
|
| if (is.null(result)) return() |
|
|
| if (httr::status_code(result) == 204) { |
| scrape_status_msg("Scrape triggered! Waiting for GitHub to finish...") |
| scrape_polling(TRUE) |
| } else { |
| scrape_status_msg(paste("GitHub API error:", httr::status_code(result))) |
| } |
| }) |
|
|
| |
| shiny::observe({ |
| req(scrape_polling()) |
| invalidateLater(15000, session) |
|
|
| resp <- tryCatch({ |
| httr::GET( |
| paste0("https://api.github.com/repos/", gh_repo, "/actions/runs?per_page=1"), |
| httr::add_headers( |
| Authorization = paste("Bearer", gh_token), |
| Accept = "application/vnd.github.v3+json" |
| ) |
| ) |
| }, error = function(e) NULL) |
|
|
| if (is.null(resp)) return() |
|
|
| runs <- jsonlite::fromJSON(httr::content(resp, as = "text", encoding = "UTF-8")) |
| if (length(runs$workflow_runs) == 0) return() |
|
|
| latest <- runs$workflow_runs[1, ] |
| status <- latest$status |
| conclusion <- latest$conclusion |
|
|
| if (status == "completed") { |
| scrape_polling(FALSE) |
|
|
| if (conclusion == "success") { |
| scrape_status_msg("GitHub finished! Fetching data...") |
|
|
| filename <- paste0(input$scrape_source, "_", input$start_date, "_to_", input$end_date, ".csv.gz") |
| url <- paste0("https://api.github.com/repos/", gh_repo, "/contents/TrackmanScraper/data/", filename) |
|
|
| data <- tryCatch({ |
| file_resp <- httr::GET( |
| url, |
| httr::add_headers( |
| Authorization = paste("Bearer", gh_token), |
| Accept = "application/vnd.github.v3.raw" |
| ) |
| ) |
| if (httr::status_code(file_resp) == 200) { |
| tmp <- tempfile(fileext = ".csv.gz") |
| writeBin(httr::content(file_resp, as = "raw"), tmp) |
| read_csv(gzfile(tmp)) |
| } else NULL |
| }, error = function(e) NULL) |
|
|
| if (!is.null(data) && nrow(data) > 0) { |
|
|
| |
| if (input$scrape_source == "pbp") { |
| scrape_status_msg("Processing data (clean + Stuff+)...") |
| data <- tryCatch({ |
| d <- clean_college_data(data) |
| d |
| }, error = function(e) { |
| scrape_status_msg(paste("Processing error:", e$message)); data |
| }) |
| } |
|
|
| scraped_data(data) |
| scrape_status_msg(paste0("Done! ", nrow(data), " rows x ", ncol(data), " columns.")) |
| } else { |
| scrape_status_msg("Scrape finished but couldn't fetch the file.") |
| } |
|
|
| } else { |
| scrape_status_msg(paste("GitHub Action failed:", conclusion)) |
| } |
|
|
| } else { |
| scrape_status_msg(paste0("GitHub is running... (status: ", status, ")")) |
| } |
| }) |
|
|
| |
| output$scrape_preview <- DT::renderDataTable({ |
| req(scraped_data()) |
| DT::datatable(scraped_data(), options = list(scrollX = TRUE, pageLength = 10)) |
| }) |
|
|
| output$download_scrape <- downloadHandler( |
| filename = function() { |
| paste0("trackman_", input$scrape_source, "_", |
| format(input$start_date, "%Y%m%d"), "_to_", |
| format(input$end_date, "%Y%m%d"), ".csv") |
| }, |
| content = function(file) { |
| req(scraped_data()) |
| write.csv(scraped_data(), file, row.names = FALSE) |
| } |
| ) |
|
|
| |
| observeEvent(input$upload_hf_btn, { |
| req(scraped_data()) |
|
|
| timestamp <- format(Sys.time(), "%Y%m%d_%H%M%S") |
| target <- hf_target_for(input$scrape_source) |
|
|
| upload_to_hf <- function(new_data, folder, index_file, label) { |
| scrape_status_msg(paste0("Checking existing UIDs for ", label, "...")) |
|
|
| existing_uids <- tryCatch({ |
| tmp_idx <- tempfile(fileext = ".csv.gz") |
| resp <- httr::GET( |
| paste0("https://huggingface.co/datasets/", HF_REPO_ID, "/resolve/main/", index_file), |
| httr::add_headers(Authorization = paste("Bearer", HF_WRITE_TOKEN)), |
| httr::write_disk(tmp_idx, overwrite = TRUE) |
| ) |
| if (httr::status_code(resp) == 200) { |
| d <- read.csv(gzfile(tmp_idx), stringsAsFactors = FALSE) |
| file.remove(tmp_idx) |
| d$PitchUID |
| } else { |
| file.remove(tmp_idx) |
| character(0) |
| } |
| }, error = function(e) character(0)) |
|
|
| scraped_rows <- nrow(new_data) |
|
|
| if (length(existing_uids) > 0 && "PitchUID" %in% names(new_data)) { |
| new_only <- new_data %>% filter(!PitchUID %in% existing_uids) |
| } else { |
| new_only <- new_data |
| } |
|
|
| new_rows <- nrow(new_only) |
| total_after <- length(existing_uids) + new_rows |
|
|
| if (new_rows == 0) { |
| return(paste0(label, ": ", scraped_rows, " rows scraped, 0 new rows added (", |
| length(existing_uids), " total)")) |
| } |
|
|
| scrape_status_msg(paste0("Uploading ", new_rows, " new rows for ", label, "...")) |
|
|
| hf <- reticulate::import("huggingface_hub") |
| api <- hf$HfApi() |
|
|
| tmp_data <- tempfile(fileext = ".parquet") |
| arrow::write_parquet(new_only, tmp_data) |
|
|
| api$upload_file( |
| path_or_fileobj = tmp_data, |
| path_in_repo = paste0(folder, "/", timestamp, ".parquet"), |
| repo_id = HF_REPO_ID, |
| repo_type = "dataset", |
| token = HF_WRITE_TOKEN |
| ) |
| file.remove(tmp_data) |
|
|
| |
| if ("PitchUID" %in% names(new_only)) { |
| scrape_status_msg(paste0("Updating ", label, " index...")) |
| all_uids <- data.frame(PitchUID = c(existing_uids, new_only$PitchUID)) |
| tmp_idx <- tempfile(fileext = ".csv.gz") |
| gz <- gzfile(tmp_idx, "w"); write.csv(all_uids, gz, row.names = FALSE); close(gz) |
|
|
| api$upload_file( |
| path_or_fileobj = tmp_idx, |
| path_in_repo = index_file, |
| repo_id = HF_REPO_ID, |
| repo_type = "dataset", |
| token = HF_WRITE_TOKEN |
| ) |
| file.remove(tmp_idx) |
| } |
|
|
| rm(new_only); gc() |
| paste0(label, ": ", scraped_rows, " rows scraped, ", new_rows, " new rows added (", |
| total_after, " total)") |
| } |
|
|
| msg <- tryCatch( |
| upload_to_hf(scraped_data(), target$folder, target$index, target$label), |
| error = function(e) paste("Upload failed:", e$message) |
| ) |
| scrape_status_msg(msg) |
| }) |
|
|
| |
| |
| |
| raw_upload <- reactive({ |
| req(input$file) |
| df <- tryCatch(readr::read_csv(input$file$datapath, show_col_types = FALSE, guess_max = 50000), |
| error = function(e) { showNotification(paste("Read error:", e$message), type = "error"); NULL }) |
| req(df) |
| missing <- setdiff(REQUIRED_COLS, names(df)) |
| shiny::validate(shiny::need(length(missing) == 0, |
| paste0("CSV missing required column(s): ", paste(missing, collapse = ", ")))) |
| df |
| }) |
|
|
| |
| processed_upload <- reactive({ |
| req(raw_upload()) |
| if (isTRUE(input$needs_cleaning)) { |
| withProgress(message = "Cleaning + tagging data\u2026", { |
| tryCatch( |
| clean_college_data(raw_upload()), |
| error = function(e) { |
| showNotification( |
| paste0("Cleaning failed: ", conditionMessage(e), |
| " \u2014 if this file is already cleaned, leave the \u201cRaw data\u201d box unchecked."), |
| type = "error", duration = 12 |
| ) |
| NULL |
| } |
| ) |
| }) |
| } else { |
| raw_upload() |
| } |
| }) |
|
|
| observeEvent(processed_upload(), { |
| ps <- sort(unique(processed_upload()$Pitcher)) |
| updateSelectInput(session, "pitcher", choices = ps, selected = ps[1]) |
| }) |
|
|
| observeEvent(input$pitcher, { |
| req(processed_upload(), input$pitcher) |
| dts <- processed_upload() %>% filter(Pitcher == input$pitcher) %>% |
| pull(Date) %>% unique() %>% as.character() %>% sort(decreasing = TRUE) |
| updateSelectInput(session, "game", choices = dts, selected = dts[1]) |
| }) |
|
|
| season_ref <- reactive({ |
| req(input$pitcher) |
| if (is.null(REFERENCE_DATA) || !("Pitcher" %in% names(REFERENCE_DATA))) return(NULL) |
| build_season_ref(REFERENCE_DATA, input$pitcher, min_pitches = 1) |
| }) |
|
|
| outing <- reactive({ |
| req(processed_upload(), input$pitcher, input$game) |
| processed_upload() %>% |
| filter(Pitcher == input$pitcher, as.character(Date) == as.character(input$game)) |
| }) |
|
|
| report_html <- eventReactive(input$build, { |
| req(nrow(outing()) > 0) |
| withProgress(message = "Building report\u2026", { |
| build_report_html(outing(), season_ref(), input$pitcher, input$game, |
| k = 0.1) |
| }) |
| }) |
|
|
| output$preview <- renderUI({ |
| if (input$build == 0 || is.null(input$file)) { |
| return(tags$div(style = "width:8.5in; padding:40px; text-align:center; color:#6B7280;", |
| tags$div(style = "font-size:14px; font-weight:600;", |
| "Upload an outing CSV, pick the pitcher & date, then Build report."), |
| tags$div(style = "font-size:12px; margin-top:6px;", |
| "The preview renders the exact 8.5\u00d711 page you'll download."))) |
| } |
| tags$iframe(class = "page-frame", srcdoc = as.character(report_html())) |
| }) |
|
|
| output$dl <- downloadHandler( |
| filename = function() paste0(gsub("[ ,]+", "_", input$pitcher %||% "pitcher"), |
| "_", input$game %||% "outing", ".html"), |
| content = function(f) writeLines(as.character(report_html()), f, useBytes = TRUE) |
| ) |
| } |
|
|
| shinyApp(ui = ui, server = server) |
|
|