{"spec_id":"sankey-basic","library":"ggplot2","language":"r","code":"#' anyplot.ai\n#' sankey-basic: Basic Sankey Diagram\n#' Library: ggplot2 3.5.1 | R 4.4.1\n#' Quality: 93/100 | Created: 2026-07-25\n\nlibrary(ggplot2)\nlibrary(dplyr)\nlibrary(tidyr)\nlibrary(tibble)\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\"\nIMPRINT_PALETTE <- c(\"#009E73\", \"#C475FD\", \"#4467A3\", \"#BD8233\",\n                     \"#AE3030\", \"#2ABCCD\", \"#954477\", \"#99B314\")\n\n# --- Data: national energy supply (PJ/year) by source and end-use sector -\nflows <- tibble::tribble(\n  ~source,   ~target,        ~value,\n  \"Coal\",    \"Industrial\",   220,\n  \"Coal\",    \"Commercial\",   90,\n  \"Coal\",    \"Residential\",  40,\n  \"Coal\",    \"Transport\",    30,\n  \"Gas\",     \"Residential\",  140,\n  \"Gas\",     \"Commercial\",   80,\n  \"Gas\",     \"Industrial\",   70,\n  \"Gas\",     \"Transport\",    20,\n  \"Nuclear\", \"Industrial\",   100,\n  \"Nuclear\", \"Commercial\",   70,\n  \"Nuclear\", \"Residential\",  70,\n  \"Wind\",    \"Residential\",  60,\n  \"Wind\",    \"Commercial\",   50,\n  \"Wind\",    \"Industrial\",   40,\n  \"Solar\",   \"Residential\",  50,\n  \"Solar\",   \"Commercial\",   30,\n  \"Solar\",   \"Industrial\",   10\n)\n\nsource_order <- c(\"Coal\", \"Gas\", \"Nuclear\", \"Wind\", \"Solar\")\ntarget_order <- c(\"Industrial\", \"Residential\", \"Commercial\", \"Transport\")\ndominant_link <- flows |> slice_max(value, n = 1, with_ties = FALSE)\n\n# ggplot2 has no native Sankey geom; nodes/links are laid out by hand below\n# (geom_rect for nodes, geom_ribbon with smoothstep-eased curves for links)\n# using only ggplot2 + dplyr/tidyr pipelines (vectorized, no helper functions)\n# — no ggalluvial/ggsankey/ggforce dependency.\n\n# --- Layout: stack nodes top-down with a fixed padding between them -----\nnode_width <- 0.07\ngap_frac   <- 0.025\ntotal_value <- sum(flows$value)\ngap <- gap_frac * total_value\n\nsource_totals <- flows |> group_by(source) |> summarise(total = sum(value)) |> tibble::deframe()\ntarget_totals <- flows |> group_by(target) |> summarise(total = sum(value)) |> tibble::deframe()\n\nnode_stack <- bind_rows(\n  tibble::tibble(side = \"source\", name = source_order, total = as.numeric(source_totals[source_order])),\n  tibble::tibble(side = \"target\", name = target_order, total = as.numeric(target_totals[target_order]))\n) |>\n  group_by(side) |>\n  mutate(\n    cum_before   = cumsum(lag(total + gap, default = 0)),\n    total_height = sum(total) + gap * (n() - 1),\n    y1           = total_height - cum_before,\n    y0           = y1 - total\n  ) |>\n  ungroup()\n\nsource_nodes <- node_stack |>\n  filter(side == \"source\") |>\n  mutate(xmin = 0, xmax = node_width,\n         fill_color = IMPRINT_PALETTE[match(name, source_order)])\n\ntarget_nodes <- node_stack |>\n  filter(side == \"target\") |>\n  mutate(xmin = 1 - node_width, xmax = 1, fill_color = INK_SOFT)\n\nnode_rects <- bind_rows(source_nodes, target_nodes)\n\n# --- Assign each link a vertical slice within its source and target nodes\nlink_positions <- flows |>\n  arrange(match(source, source_order), match(target, target_order)) |>\n  group_by(source) |>\n  mutate(src_cum1 = cumsum(value), src_cum0 = src_cum1 - value) |>\n  ungroup() |>\n  arrange(match(target, target_order), match(source, source_order)) |>\n  group_by(target) |>\n  mutate(tgt_cum1 = cumsum(value), tgt_cum0 = tgt_cum1 - value) |>\n  ungroup() |>\n  left_join(source_nodes |> select(source = name, src_top = y1), by = \"source\") |>\n  left_join(target_nodes |> select(target = name, tgt_top = y1), by = \"target\") |>\n  mutate(\n    src_ymin = src_top - src_cum1,\n    src_ymax = src_top - src_cum0,\n    tgt_ymin = tgt_top - tgt_cum1,\n    tgt_ymax = tgt_top - tgt_cum0,\n    link_id  = row_number(),\n    is_dominant = source == dominant_link$source & target == dominant_link$target\n  )\n\ndominant_id <- link_positions$link_id[link_positions$is_dominant]\n\n# --- Interpolate every link into a smooth ribbon (cubic smoothstep ease) -\n# One vectorized crossing (data x sample-points) replaces a per-row helper.\ncurves <- link_positions |>\n  select(link_id, source, src_ymin, src_ymax, tgt_ymin, tgt_ymax) |>\n  crossing(t = seq(0, 1, length.out = 60)) |>\n  mutate(\n    ease = t^2 * (3 - 2 * t),\n    x    = node_width + (1 - 2 * node_width) * t,\n    ymin = src_ymin + (tgt_ymin - src_ymin) * ease,\n    ymax = src_ymax + (tgt_ymax - src_ymax) * ease\n  )\n\ncurve_fill_values <- setNames(IMPRINT_PALETTE[seq_along(source_order)], source_order)\n\n# --- Title (fontsize scales with title length, see plot-generator.md) ---\ntitle_str <- \"sankey-basic · r · ggplot2 · anyplot.ai\"\ntitle_ratio <- if (nchar(title_str) > 67) 67 / nchar(title_str) else 1.0\ntitle_fontsize <- round(12 * title_ratio)\n\n# --- Plot -----------------------------------------------------------------\np <- ggplot() +\n  # Flat fills for every ribbon.\n  geom_ribbon(\n    data = curves,\n    aes(x = x, ymin = ymin, ymax = ymax, group = link_id, fill = source),\n    alpha = 0.55, color = NA\n  ) +\n  # Thin edge stroke on every ribbon keeps individual flows legible where\n  # dense crossing bands would otherwise blend into a muddy overlap.\n  geom_ribbon(\n    data = curves,\n    aes(x = x, ymin = ymin, ymax = ymax, group = link_id, color = source),\n    fill = NA, alpha = 0.9, linewidth = 0.15\n  ) +\n  # Re-draw the single largest flow on top, more saturated and outlined in\n  # ink, so the dominant Coal -> Industrial pathway reads at a glance.\n  geom_ribbon(\n    data = filter(curves, link_id == dominant_id),\n    aes(x = x, ymin = ymin, ymax = ymax, group = link_id),\n    fill = IMPRINT_PALETTE[1], color = INK, alpha = 0.88, linewidth = 0.35\n  ) +\n  geom_rect(\n    data = node_rects,\n    aes(xmin = xmin, xmax = xmax, ymin = y0, ymax = y1, fill = I(fill_color)),\n    color = PAGE_BG, linewidth = 0.6\n  ) +\n  geom_text(\n    data = source_nodes,\n    aes(x = xmin - 0.015, y = (y0 + y1) / 2, label = paste0(name, \"  ·  \", total, \" PJ\")),\n    hjust = 1, size = 3.2, color = INK\n  ) +\n  geom_text(\n    data = target_nodes,\n    aes(x = xmax + 0.015, y = (y0 + y1) / 2, label = paste0(name, \"  ·  \", total, \" PJ\")),\n    hjust = 0, size = 3.2, color = INK\n  ) +\n  scale_fill_manual(values = curve_fill_values) +\n  scale_color_manual(values = curve_fill_values) +\n  scale_y_continuous(expand = expansion(mult = c(0.02, 0.05))) +\n  coord_cartesian(xlim = c(-0.34, 1.34), clip = \"off\") +\n  labs(title = title_str) +\n  theme_void(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    plot.title        = element_text(color = INK, size = title_fontsize,\n                                      margin = margin(b = 16)),\n    legend.position   = \"none\",\n    plot.margin       = margin(t = 24, r = 100, b = 14, l = 100)\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"}