{"spec_id":"alluvial-basic","library":"ggplot2","language":"r","code":"#' anyplot.ai\n#' alluvial-basic: Basic Alluvial Diagram\n#' Library: ggplot2 3.5.1 | R 4.4.1\n#' Quality: 88/100 | Created: 2026-09-02\n\nlibrary(ggplot2)\nlibrary(dplyr)\nlibrary(ragg)\n\nset.seed(42)\n\n# --- Theme tokens -------------------------------------------------------\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\"\nINK_MUTED <- if (THEME == \"light\") \"#6B6A63\" else \"#A8A79F\"\n\n# Imprint palette (see prompts/default-style-guide.md \"Categorical Palette\")\nIMPRINT_PALETTE <- c(\"#009E73\", \"#C475FD\", \"#4467A3\", \"#BD8233\",\n                     \"#AE3030\", \"#2ABCCD\", \"#954477\", \"#99B314\")\n\nCATEGORY_ORDER <- c(\"STEM\", \"Business\", \"Arts\", \"Undeclared\")\nCATEGORY_COLORS <- c(\n  STEM       = IMPRINT_PALETTE[1],\n  Business   = IMPRINT_PALETTE[2],\n  Arts       = IMPRINT_PALETTE[3],\n  Undeclared = INK_MUTED  # semantic anchor: undecided majors read as \"other\"\n)\nSTAGE_LABELS <- c(\"Year 1\", \"Year 2\", \"Year 3\")\nNODE_WIDTH   <- 0.055\nGAP          <- 15  # visual spacing between stacked categories at a stage\n\n# --- Data: student counts transitioning between academic tracks --------\n# ggplot2 has no native Sankey/alluvial geom (ggalluvial is not installed in\n# this environment) — the bands below are built from first principles with\n# stacked node ranges and a smoothstep interpolation, using only geom_ribbon\n# and geom_rect, both native ggplot2 geoms.\nflows <- tibble::tribble(\n  ~stage_from, ~stage_to, ~cat_from,    ~cat_to,      ~value,\n  1, 2, \"STEM\",       \"STEM\",       350,\n  1, 2, \"STEM\",       \"Business\",    30,\n  1, 2, \"STEM\",       \"Arts\",        20,\n  1, 2, \"STEM\",       \"Undeclared\",  20,\n  1, 2, \"Business\",   \"STEM\",        40,\n  1, 2, \"Business\",   \"Business\",   300,\n  1, 2, \"Business\",   \"Arts\",        10,\n  1, 2, \"Business\",   \"Undeclared\",  30,\n  1, 2, \"Arts\",       \"STEM\",        10,\n  1, 2, \"Arts\",       \"Business\",    20,\n  1, 2, \"Arts\",       \"Arts\",       100,\n  1, 2, \"Arts\",       \"Undeclared\",  20,\n  1, 2, \"Undeclared\", \"STEM\",        60,\n  1, 2, \"Undeclared\", \"Business\",    30,\n  1, 2, \"Undeclared\", \"Arts\",        10,\n  1, 2, \"Undeclared\", \"Undeclared\", 150,\n  2, 3, \"STEM\",       \"STEM\",       380,\n  2, 3, \"STEM\",       \"Business\",    40,\n  2, 3, \"STEM\",       \"Arts\",        20,\n  2, 3, \"STEM\",       \"Undeclared\",  20,\n  2, 3, \"Business\",   \"STEM\",        20,\n  2, 3, \"Business\",   \"Business\",   320,\n  2, 3, \"Business\",   \"Arts\",        20,\n  2, 3, \"Business\",   \"Undeclared\",  20,\n  2, 3, \"Arts\",       \"STEM\",        15,\n  2, 3, \"Arts\",       \"Business\",    20,\n  2, 3, \"Arts\",       \"Arts\",        90,\n  2, 3, \"Arts\",       \"Undeclared\",  15,\n  2, 3, \"Undeclared\", \"STEM\",        40,\n  2, 3, \"Undeclared\", \"Business\",    25,\n  2, 3, \"Undeclared\", \"Arts\",        15,\n  2, 3, \"Undeclared\", \"Undeclared\", 140\n) %>%\n  mutate(row_id = row_number())\n\n# --- Node totals & stacked y-ranges per stage (fixed category order) ----\nnode_totals <- bind_rows(\n  flows %>% group_by(stage = stage_from, category = cat_from) %>%\n    summarise(total = sum(value), .groups = \"drop\"),\n  flows %>% filter(stage_to == max(stage_to)) %>%\n    group_by(stage = stage_to, category = cat_to) %>%\n    summarise(total = sum(value), .groups = \"drop\")\n) %>%\n  mutate(category = factor(category, levels = CATEGORY_ORDER)) %>%\n  arrange(stage, category) %>%\n  group_by(stage) %>%\n  mutate(\n    ymax = cumsum(total) + GAP * (row_number() - 1),\n    ymin = ymax - total\n  ) %>%\n  ungroup()\n\n# --- Stack outgoing / incoming flows inside each node --------------------\nout_offsets <- flows %>%\n  mutate(cat_to_f = factor(cat_to, levels = CATEGORY_ORDER)) %>%\n  group_by(stage_from, cat_from) %>%\n  arrange(cat_to_f, .by_group = TRUE) %>%\n  mutate(y1_local = cumsum(value), y0_local = y1_local - value) %>%\n  ungroup() %>%\n  left_join(\n    node_totals %>% transmute(stage_from = stage, cat_from = as.character(category), node_ymin = ymin),\n    by = c(\"stage_from\", \"cat_from\")\n  ) %>%\n  transmute(row_id, y0_from = node_ymin + y0_local, y1_from = node_ymin + y1_local)\n\nin_offsets <- flows %>%\n  mutate(cat_from_f = factor(cat_from, levels = CATEGORY_ORDER)) %>%\n  group_by(stage_to, cat_to) %>%\n  arrange(cat_from_f, .by_group = TRUE) %>%\n  mutate(y1_local = cumsum(value), y0_local = y1_local - value) %>%\n  ungroup() %>%\n  left_join(\n    node_totals %>% transmute(stage_to = stage, cat_to = as.character(category), node_ymin = ymin),\n    by = c(\"stage_to\", \"cat_to\")\n  ) %>%\n  transmute(row_id, y0_to = node_ymin + y0_local, y1_to = node_ymin + y1_local)\n\nflows_full <- flows %>%\n  left_join(out_offsets, by = \"row_id\") %>%\n  left_join(in_offsets, by = \"row_id\")\n\n# --- Smooth alluvial bands via smoothstep interpolation ------------------\nsmooth_band <- function(x_from, x_to, y0_from, y1_from, y0_to, y1_to, row_id, cat_from, n = 40) {\n  t <- seq(0, 1, length.out = n)\n  w <- t^2 * (3 - 2 * t)  # smoothstep S-curve\n  tibble::tibble(\n    row_id   = row_id,\n    cat_from = cat_from,\n    x        = x_from + t * (x_to - x_from),\n    ymin     = y0_from + w * (y0_to - y0_from),\n    ymax     = y1_from + w * (y1_to - y1_from)\n  )\n}\n\nbands <- bind_rows(lapply(seq_len(nrow(flows_full)), function(i) {\n  r <- flows_full[i, ]\n  smooth_band(r$stage_from, r$stage_to, r$y0_from, r$y1_from, r$y0_to, r$y1_to, r$row_id, r$cat_from)\n}))\nbands$cat_from <- factor(bands$cat_from, levels = CATEGORY_ORDER)\n\n# --- Node labels (category name shown at the first & last stage only) ----\nnode_labels <- node_totals %>%\n  filter(stage %in% range(stage)) %>%\n  mutate(\n    y     = (ymin + ymax) / 2,\n    x     = if_else(stage == min(stage), stage - NODE_WIDTH - 0.03, stage + NODE_WIDTH + 0.03),\n    hjust = if_else(stage == min(stage), 1, 0)\n  )\n\n# --- Mini legend (covers the middle stage, where nodes have no free space\n# alongside them for direct labels since ribbons flank both sides) ----------\nmax_y <- max(node_totals$ymax)\nlegend_y <- max_y * 1.09\nlegend_data <- tibble::tibble(\n  category = factor(CATEGORY_ORDER, levels = CATEGORY_ORDER),\n  x        = 2 + seq(-0.42, 0.42, length.out = length(CATEGORY_ORDER)),\n  y        = legend_y\n)\n\n# --- Plot ------------------------------------------------------------------\np <- ggplot() +\n  geom_ribbon(\n    data = bands,\n    aes(x = x, ymin = ymin, ymax = ymax, group = row_id, fill = cat_from),\n    color = INK, linewidth = 0.15, alpha = 0.45\n  ) +\n  geom_rect(\n    data = node_totals,\n    aes(xmin = stage - NODE_WIDTH, xmax = stage + NODE_WIDTH,\n        ymin = ymin, ymax = ymax, fill = category),\n    color = PAGE_BG, linewidth = 0.6\n  ) +\n  geom_text(\n    data = node_labels,\n    aes(x = x, y = y, label = category, hjust = hjust),\n    size = 4, color = INK\n  ) +\n  geom_point(\n    data = legend_data,\n    aes(x = x, y = y),\n    color = CATEGORY_COLORS[as.character(legend_data$category)],\n    shape = 15, size = 3.5\n  ) +\n  geom_text(\n    data = legend_data,\n    aes(x = x + 0.06, y = y, label = category),\n    size = 2.9, color = INK_SOFT, hjust = 0, vjust = 0.4\n  ) +\n  scale_fill_manual(values = CATEGORY_COLORS, guide = \"none\") +\n  scale_x_continuous(\n    breaks = seq_along(STAGE_LABELS), labels = STAGE_LABELS,\n    expand = expansion(mult = c(0.18, 0.18))\n  ) +\n  scale_y_continuous(expand = expansion(mult = c(0.02, 0.1))) +\n  labs(\n    title = \"alluvial-basic · r · ggplot2 · anyplot.ai\",\n    x = NULL, y = NULL\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    axis.title       = element_blank(),\n    axis.text.y      = element_blank(),\n    axis.ticks       = element_blank(),\n    axis.text.x      = element_text(color = INK_SOFT, size = 10),\n    plot.title       = element_text(color = INK, size = 12),\n    plot.margin      = margin(12, 20, 10, 20)\n  )\n\n# --- Save --------------------------------------------------------------------\nggsave(\n  filename = sprintf(\"plot-%s.png\", THEME),\n  plot     = p,\n  device   = ragg::agg_png,\n  width    = 8,\n  height   = 4.5,\n  units    = \"in\",\n  dpi      = 400\n)\n"}