{"spec_id":"scatter-shot-chart","library":"ggplot2","language":"r","code":"#' anyplot.ai\n#' scatter-shot-chart: Basketball Shot Chart\n#' Library: ggplot2 3.5.1 | R 4.4.1\n#' Quality: 83/100 | Created: 2026-06-21\n\nlibrary(ggplot2)\nlibrary(ragg)\n\nset.seed(42)\n\n# Theme tokens (Imprint palette — see prompts/default-style-guide.md)\nTHEME       <- Sys.getenv(\"ANYPLOT_THEME\", \"light\")\nPAGE_BG     <- if (THEME == \"light\") \"#FAF8F1\" else \"#1A1A17\"\nINK         <- if (THEME == \"light\") \"#1A1A17\" else \"#F0EFE8\"\nINK_SOFT    <- if (THEME == \"light\") \"#4A4A44\" else \"#B8B7B0\"\nELEVATED_BG <- if (THEME == \"light\") \"#FFFDF6\" else \"#242420\"\nCOURT_LINE  <- if (THEME == \"light\") \"#8A8880\" else \"#6A6960\"\n\n# Imprint palette — semantic color assignment for shot outcomes\nCOLOR_MADE   <- \"#009E73\"  # brand green (position 1) — positive outcome\nCOLOR_MISSED <- \"#AE3030\"  # matte red (semantic anchor) — negative outcome\n\n# NBA half-court dimensions in feet (basket center = origin)\nBASELINE_Y   <- -4.75\nCOURT_H      <- 42.25\nCOURT_W      <- 25.0\nTHREE_R      <- 23.75\nCORNER_X     <- 22.0\nCORNER_Y_TOP <- sqrt(THREE_R^2 - CORNER_X^2)  # ~8.95 ft — arc/corner junction\nFT_Y         <- 15.0\nPAINT_W      <- 8.0\nRESTRICT_R   <- 4.0\nFT_CIRC_R   <- 6.0\nBASKET_R     <- 0.75\n\n# Shot data — 300 attempts from a hypothetical player's season\nn_shots   <- 300\nshot_type <- sample(c(\"2-pointer\", \"3-pointer\", \"free throw\"),\n                    n_shots, replace = TRUE, prob = c(0.60, 0.30, 0.10))\nn_2pt <- sum(shot_type == \"2-pointer\")\nn_3pt <- sum(shot_type == \"3-pointer\")\nn_ft  <- sum(shot_type == \"free throw\")\n\n# 2-pointers: distributed inside the three-point arc\nang_2 <- runif(n_2pt, 0, pi)\nr_2   <- runif(n_2pt, 1.5, 21.0)\nx_2   <- r_2 * cos(ang_2)\ny_2   <- r_2 * sin(ang_2)\n\n# 3-pointers: above-the-break and corner variants\narc_alpha <- acos(CORNER_X / THREE_R)\nis_corner <- runif(n_3pt) < 0.25\nang_ab    <- runif(n_3pt, arc_alpha, pi - arc_alpha)\nr_ab      <- runif(n_3pt, THREE_R + 0.3, THREE_R + 2.5)\nx_corn    <- sample(c(-1L, 1L), n_3pt, replace = TRUE) * runif(n_3pt, 22.2, 24.5)\ny_corn    <- runif(n_3pt, BASELINE_Y + 0.3, 7.5)\nx_3       <- ifelse(is_corner, x_corn, r_ab * cos(ang_ab))\ny_3       <- ifelse(is_corner, y_corn, r_ab * sin(ang_ab))\n\n# Free throws: clustered at the free-throw line\nx_ft_d <- rnorm(n_ft, 0, 0.4)\ny_ft_d <- rnorm(n_ft, FT_Y, 0.4)\n\n# Combine and clip to court bounds\ndf_shots <- data.frame(\n  x    = pmax(-24.5, pmin(24.5, c(x_2, x_3, x_ft_d))),\n  y    = pmax(BASELINE_Y + 0.2, pmin(COURT_H - 0.5, c(y_2, y_3, y_ft_d))),\n  type = c(rep(\"2-pointer\", n_2pt), rep(\"3-pointer\", n_3pt), rep(\"free throw\", n_ft))\n)\n\n# Shot outcomes with realistic field-goal percentages\nfg_prob <- ifelse(df_shots$type == \"free throw\", 0.78,\n           ifelse(df_shots$type == \"3-pointer\",  0.37, 0.47))\ndf_shots$outcome <- factor(\n  ifelse(runif(n_shots) < fg_prob, \"Made\", \"Missed\"),\n  levels = c(\"Made\", \"Missed\")\n)\n\n# Sort so missed shots render first (bottom layer), made shots on top\ndf_shots <- df_shots[order(df_shots$outcome, decreasing = TRUE), ]\n\n# Zone classification for efficiency callouts\ndist       <- sqrt(df_shots$x^2 + df_shots$y^2)\nis_corner3 <- abs(df_shots$x) >= CORNER_X & df_shots$y <= CORNER_Y_TOP\nis_3pt     <- dist >= THREE_R | is_corner3\nis_paint   <- !is_3pt & abs(df_shots$x) <= PAINT_W & df_shots$y <= FT_Y\ndf_shots$zone <- ifelse(df_shots$type == \"free throw\", \"ft\",\n                 ifelse(is_corner3, \"corner3\",\n                 ifelse(is_3pt, \"above3\",\n                 ifelse(is_paint, \"paint\", \"mid\"))))\n\n# FG% per zone\nzone_fg <- function(zone) {\n  s <- df_shots[df_shots$zone == zone, ]\n  sprintf(\"%.0f%%\", 100 * mean(s$outcome == \"Made\"))\n}\noverall_made <- round(100 * mean(df_shots$outcome == \"Made\"))\nsubtitle_str <- sprintf(\"%d%% FG overall | %d shots | Hypothetical Season\",\n                        overall_made, n_shots)\n\n# Court geometry data frames\narc_angles <- seq(arc_alpha, pi - arc_alpha, length.out = 200)\ndf_arc <- data.frame(\n  x = THREE_R * cos(arc_angles),\n  y = THREE_R * sin(arc_angles)\n)\ndf_ft_top <- data.frame(\n  x = FT_CIRC_R * cos(seq(0, pi, length.out = 80)),\n  y = FT_Y + FT_CIRC_R * sin(seq(0, pi, length.out = 80))\n)\ndf_ft_bot <- data.frame(\n  x = FT_CIRC_R * cos(seq(pi, 2 * pi, length.out = 80)),\n  y = FT_Y + FT_CIRC_R * sin(seq(pi, 2 * pi, length.out = 80))\n)\ndf_ra <- data.frame(\n  x = RESTRICT_R * cos(seq(0, pi, length.out = 60)),\n  y = RESTRICT_R * sin(seq(0, pi, length.out = 60))\n)\ndf_basket <- data.frame(\n  x = BASKET_R * cos(seq(0, 2 * pi, length.out = 60)),\n  y = BASKET_R * sin(seq(0, 2 * pi, length.out = 60))\n)\ndf_court <- data.frame(\n  x = c(-COURT_W, COURT_W, COURT_W, -COURT_W, -COURT_W),\n  y = c(BASELINE_Y, BASELINE_Y, COURT_H, COURT_H, BASELINE_Y)\n)\ndf_key <- data.frame(\n  x = c(-PAINT_W, PAINT_W, PAINT_W, -PAINT_W, -PAINT_W),\n  y = c(BASELINE_Y, BASELINE_Y, FT_Y, FT_Y, BASELINE_Y)\n)\n\n# Plot\np <- ggplot() +\n  # Court boundary\n  geom_path(data = df_court, aes(x, y),\n            color = COURT_LINE, linewidth = 0.7) +\n  # Paint / key rectangle\n  geom_path(data = df_key, aes(x, y),\n            color = COURT_LINE, linewidth = 0.7) +\n  # Corner three-point lines (straight vertical portions at x = ±22)\n  annotate(\"segment\",\n           x    = c(-CORNER_X, CORNER_X),\n           xend = c(-CORNER_X, CORNER_X),\n           y    = c(BASELINE_Y, BASELINE_Y),\n           yend = c(CORNER_Y_TOP, CORNER_Y_TOP),\n           color = COURT_LINE, linewidth = 0.7) +\n  # Three-point arc\n  geom_path(data = df_arc, aes(x, y),\n            color = COURT_LINE, linewidth = 0.7) +\n  # Free-throw circle — solid top half (above key)\n  geom_path(data = df_ft_top, aes(x, y),\n            color = COURT_LINE, linewidth = 0.7) +\n  # Free-throw circle — dashed bottom half (inside key)\n  geom_path(data = df_ft_bot, aes(x, y),\n            color = COURT_LINE, linewidth = 0.7, linetype = \"dashed\") +\n  # Restricted area arc\n  geom_path(data = df_ra, aes(x, y),\n            color = COURT_LINE, linewidth = 0.7) +\n  # Backboard\n  annotate(\"segment\",\n           x = -3, xend = 3,\n           y = BASELINE_Y + 0.5, yend = BASELINE_Y + 0.5,\n           color = COURT_LINE, linewidth = 1.4) +\n  # Basket (rim circle)\n  geom_path(data = df_basket, aes(x, y),\n            color = COURT_LINE, linewidth = 1.0) +\n  # Shot attempts — missed drawn first, made on top for visibility\n  geom_point(data = df_shots,\n             aes(x = x, y = y, color = outcome, shape = outcome),\n             size = 2.5, alpha = 0.75, stroke = 0.8) +\n  # Color and shape scales (redundant encoding for colorblind accessibility)\n  scale_color_manual(\n    name   = \"Outcome\",\n    values = c(\"Made\" = COLOR_MADE, \"Missed\" = COLOR_MISSED)\n  ) +\n  scale_shape_manual(\n    name   = \"Outcome\",\n    values = c(\"Made\" = 16, \"Missed\" = 4)\n  ) +\n  # Zone efficiency callouts — FG% per court zone for storytelling (DE-03)\n  annotate(\"label\", x = 0, y = 8,\n           label = paste0(\"Paint\\n\", zone_fg(\"paint\")),\n           color = INK_SOFT, fill = ELEVATED_BG, label.size = 0.2,\n           size = 2.5, lineheight = 1.1) +\n  annotate(\"label\", x = 0, y = 19,\n           label = paste0(\"Mid-Range\\n\", zone_fg(\"mid\")),\n           color = INK_SOFT, fill = ELEVATED_BG, label.size = 0.2,\n           size = 2.5, lineheight = 1.1) +\n  annotate(\"label\", x = 0, y = 29,\n           label = paste0(\"3-Pt Arc\\n\", zone_fg(\"above3\")),\n           color = INK_SOFT, fill = ELEVATED_BG, label.size = 0.2,\n           size = 2.5, lineheight = 1.1) +\n  annotate(\"text\", x = -23.5, y = 3.5, label = zone_fg(\"corner3\"),\n           color = INK_SOFT, size = 2.2, angle = 90) +\n  annotate(\"text\", x = 23.5, y = 3.5, label = zone_fg(\"corner3\"),\n           color = INK_SOFT, size = 2.2, angle = 90) +\n  # 1:1 aspect ratio — court must not be distorted\n  coord_fixed(ratio = 1, xlim = c(-26, 26), ylim = c(-6, 44)) +\n  labs(\n    title    = \"scatter-shot-chart · r · ggplot2 · anyplot.ai\",\n    subtitle = subtitle_str\n  ) +\n  theme_minimal(base_size = 8) +\n  theme(\n    plot.background   = element_rect(fill = PAGE_BG, color = PAGE_BG),\n    panel.background  = element_rect(fill = PAGE_BG, color = NA),\n    panel.grid        = element_blank(),\n    panel.border      = element_blank(),\n    axis.title        = element_blank(),\n    axis.text         = element_blank(),\n    axis.ticks        = element_blank(),\n    plot.title        = element_text(color = INK, size = 12, hjust = 0.5,\n                                     face = \"bold\", margin = margin(b = 4)),\n    plot.subtitle     = element_text(color = INK_SOFT, size = 8, hjust = 0.5,\n                                     margin = margin(b = 8)),\n    legend.background = element_rect(fill = ELEVATED_BG, color = INK_SOFT,\n                                     linewidth = 0.3),\n    legend.text       = element_text(color = INK_SOFT, size = 8),\n    legend.title      = element_text(color = INK, size = 9),\n    legend.position          = \"inside\",\n    legend.position.inside   = c(0.88, 0.90),\n    legend.justification.inside = c(1, 1),\n    plot.margin       = margin(12, 12, 12, 12, \"pt\")\n  )\n\n# Save — square canvas (2400×2400 px) for undistorted court geometry\nggsave(\n  filename = sprintf(\"plot-%s.png\", THEME),\n  plot     = p,\n  device   = ragg::agg_png,\n  width    = 6,\n  height   = 6,\n  units    = \"in\",\n  dpi      = 400\n)\n"}