Nothing
#' Atomic area plot (internal)
#'
#' @description
#' Core implementation for drawing a single stacked area plot. This is the
#' workhorse behind the exported \code{\link{AreaPlot}} function — it takes a
#' **single** data frame (no \code{split_by} support) and returns a
#' \code{ggplot} object. The plot shows how one or more groups' numeric values
#' (or counts) accumulate across a discrete x-axis, with each group rendered
#' as a filled area stacked from baseline.
#'
#' @section Architecture:
#' \enumerate{
#' \item \strong{Column resolution} — \code{x}, \code{y}, \code{group_by},
#' and \code{facet_by} are validated and transformed via
#' \code{\link{check_columns}}. Multi-column inputs for \code{x} and
#' \code{group_by} are concatenated into single columns using their
#' respective separators (\code{x_sep}, \code{group_by_sep}).
#' \item \strong{NA / empty-level handling} — \code{\link{process_keep_na_empty}()}
#' applies \code{keep_na} and \code{keep_empty} policies. Per-column
#' \code{keep_empty} settings are extracted for \code{x},
#' \code{group_by}, and \code{facet_by} independently. The facet
#' columns must have identical \code{keep_empty} values.
#' \item \strong{Count aggregation} — when \code{y = NULL}, the count of
#' observations in each unique (\code{x}, \code{group_by},
#' \code{facet_by}) combination is computed as a new \code{.count}
#' column. Factor levels are preserved after aggregation.
#' \item \strong{Proportion scaling} — when \code{scale_y = TRUE}, the
#' y-values are divided by the sum within each (\code{x},
#' \code{facet_by}) group, producing a proportion (0–1). Percent
#' labels are used automatically on the y-axis.
#' \item \strong{Empty-fill guard} — when \code{group_by = NULL} (no
#' grouping), a dummy \code{.fill} factor is created so the single
#' area still draws with the first palette colour. The legend is
#' suppressed (\code{legend.position = "none"}).
#' \item \strong{Colour mapping} — \code{\link{palette_this}()} assigns
#' colours to all \code{group_by} levels, including \code{NA}
#' (defaulting to \code{"grey80"}).
#' \item \strong{Data completion} — \code{\link[tidyr]{complete}()} pads
#' all \code{x} × \code{group_by} (× \code{facet_by}) combinations
#' with \code{y = 0}. This prevents \code{\link[ggplot2]{geom_area}()}
#' from interpolating across missing groups, which would otherwise
#' cause stacked areas to exceed the correct total.
#' \item \strong{x-axis numeric mapping} — the discrete x variable is
#' converted to a numeric position column (\code{.x_numeric}) so
#' \code{geom_area()} can draw continuous area fills between x
#' positions. \code{NA} levels are placed at position
#' \code{n_levels + 1}.
#' \item \strong{Plot assembly} — the \code{ggplot} object is built with
#' \code{geom_area(position = position_stack(vjust = 0.5))},
#' \code{scale_x_discrete()} (breaks from factor levels),
#' \code{scale_y_continuous()} (percent labels when scaled), and
#' \code{scale_fill_manual()} with per-group colours. The fill
#' scale \code{drop} argument is controlled by
#' \code{keep_empty_group}.
#' \item \strong{Dimension calculation} — \code{\link{calculate_plot_dimensions}()}
#' computes plot height and width from the x-axis category count,
#' \code{aspect.ratio}, and legend metrics. The resulting
#' \code{height} / \code{width} attributes are stored on the
#' \code{ggplot} object.
#' \item \strong{Faceting} — \code{\link{facet_plot}()} wraps the plot
#' with \code{facet_wrap} / \code{facet_grid} if \code{facet_by} is
#' provided, respecting the \code{keep_empty} setting for facet
#' variables.
#' }
#'
#' @inheritParams common_args
#' @param x A character string specifying the column name to plot on the
#' x-axis. Must be character or factor. Multiple columns can be provided;
#' they are concatenated with \code{x_sep} as the separator.
#' @param x_sep A character string used to join multiple \code{x} columns.
#' Default \code{"_"}. Ignored when \code{x} is a single column.
#' @param y A character string specifying the numeric column for the y-axis.
#' When \code{NULL}, the count of observations in each (\code{x},
#' \code{group_by}, \code{facet_by}) combination is used.
#' @param scale_y A logical value. When \code{TRUE}, y-values are scaled to
#' proportions within each (\code{x}, \code{facet_by}) group so that each
#' x position stacks to 1.0. The y-axis labels switch from numeric to
#' percent format automatically.
#' @param group_by A character vector of column names to fill the areas by.
#' Each unique combination becomes a separate stacked area. Multiple
#' columns are concatenated with \code{group_by_sep}. When \code{NULL},
#' a single filled area is drawn (no grouping) and the legend is hidden.
#' @param group_by_sep A character string to separate concatenated
#' \code{group_by} columns. Default \code{"_"}.
#' @param group_name A character string used as the fill legend title.
#' When \code{NULL}, the \code{group_by} column name is used.
#' @keywords internal
#' @return A \code{ggplot} object with \code{height} and \code{width}
#' attributes (in inches) attached.
#' @importFrom rlang syms :=
#' @importFrom dplyr summarise n %>%
#' @importFrom ggplot2 geom_area scale_x_discrete scale_y_continuous scale_fill_manual
#' @importFrom ggplot2 labs theme element_line element_text position_stack waiver
AreaPlotAtomic <- function(
data,
x,
y = NULL,
x_sep = "_",
group_by = NULL,
group_by_sep = "_",
group_name = NULL,
scale_y = FALSE,
theme = "theme_this",
theme_args = list(),
palette = "Paired",
palcolor = NULL,
palreverse = FALSE,
alpha = 1,
facet_by = NULL,
facet_scales = "fixed",
facet_ncol = NULL,
facet_nrow = NULL,
facet_byrow = TRUE,
x_text_angle = 0,
aspect.ratio = 1,
legend.position = waiver(),
legend.direction = "vertical",
title = NULL,
subtitle = NULL,
xlab = NULL,
ylab = NULL,
keep_na = FALSE,
keep_empty = FALSE,
...
) {
ggplot <- if (getOption("plotthis.gglogger.enabled", FALSE)) {
gglogger::ggplot
} else {
ggplot2::ggplot
}
x <- check_columns(
data,
x,
force_factor = TRUE,
allow_multi = TRUE,
concat_multi = TRUE,
concat_sep = x_sep
)
y <- check_columns(data, y)
group_by <- check_columns(
data,
group_by,
force_factor = TRUE,
allow_multi = TRUE,
concat_multi = TRUE,
concat_sep = group_by_sep
)
facet_by <- check_columns(
data,
facet_by,
force_factor = TRUE,
allow_multi = TRUE
)
data <- process_keep_na_empty(data, keep_na, keep_empty)
# TRUE: unused levels will be kept on X axis
# FALSE/level: unused levels will be dropped
keep_empty_x <- keep_empty[[x]]
# TRUE: unused levels will be kept in group_by
# FALSE: unused levels will be dropped
# level: unused levels will be dropped, but the group colors will be identified using all levels
keep_empty_group <- if (!is.null(group_by)) keep_empty[[group_by]] else NULL
# TRUE: unused levels will be kept in facet_by
# FALSE/level: unused levels will be dropped
# 2-column facet_by is not supported yet
keep_empty_facet <- if (!is.null(facet_by)) {
keep_empty[[facet_by[1]]]
} else {
NULL
}
if (length(facet_by) > 1) {
stopifnot(
"[AreaPlot] `keep_empty` for `facet_by` variables must be identical." = identical(
keep_empty_facet,
keep_empty[[facet_by[2]]]
)
)
}
orig_data <- data
if (is.null(y)) {
y <- ".count"
data <- data %>%
dplyr::group_by(!!!syms(unique(c(x, group_by, facet_by)))) %>%
summarise(.count = n(), .groups = "drop")
# keep the levels
for (col in unique(c(x, group_by, facet_by))) {
data[[col]] <- factor(
data[[col]],
levels = levels(orig_data[[col]])
)
}
}
if (isTRUE(scale_y)) {
data <- data %>%
dplyr::group_by(!!!syms(unique(c(x, facet_by)))) %>%
mutate(!!sym(y) := !!sym(y) / sum(!!sym(y))) %>%
ungroup()
# keep the levels
for (col in unique(c(x, group_by, facet_by))) {
data[[col]] <- factor(
data[[col]],
levels = levels(orig_data[[col]])
)
}
}
rm(orig_data)
if (is.null(group_by)) {
data$.fill <- factor("")
group_by <- ".fill"
legend.position <- ifelse(
inherits(legend.position, "waiver"),
"none",
"right"
)
} else {
legend.position <- ifelse(
inherits(legend.position, "waiver"),
"right",
legend.position
)
}
if (!isTRUE(keep_empty_x)) {
data[[x]] <- droplevels(data[[x]])
}
x_vals <- levels(data[[x]])
if (anyNA(data[[x]])) {
x_vals <- c(x_vals, NA)
}
# group_by_vals <- if (!isTRUE(keep_empty_group) || !anyNA(data[[group_by]])) {
# levels(data[[group_by]])
# } else {
# c(levels(data[[group_by]]), NA)
# }
group_by_vals <- levels(data[[group_by]])
if (anyNA(data[[group_by]])) {
group_by_vals <- c(group_by_vals, NA)
}
group_colors <- palette_this(
group_by_vals,
palette = palette,
palcolor = palcolor,
NA_keep = TRUE,
reverse = palreverse
)
just <- calc_just(x_text_angle)
# Complete all x * group_by (and facet_by) combinations so that geom_area
# doesn't interpolate across missing groups, which would cause stacked
# areas to exceed the correct total.
complete_vars <- unique(c(x, group_by, facet_by))
complete_fill <- setNames(list(0), y)
data_complete <- data %>%
tidyr::complete(!!!syms(complete_vars), fill = complete_fill)
# Restore factor levels that complete() may have altered
for (col in complete_vars) {
if (is.factor(data[[col]])) {
data_complete[[col]] <- factor(
data_complete[[col]],
levels = levels(data[[col]])
)
}
}
# Convert x to numeric, handling NA values by assigning them the next position
data_complete$.x_numeric <- as.numeric(data_complete[[x]])
if (anyNA(data_complete[[x]])) {
data_complete$.x_numeric[is.na(data_complete[[
x
]])] <- length(levels(data_complete[[x]])) + 1
}
p <- ggplot(
data_complete,
aes(x = !!sym(".x_numeric"), y = !!sym(y), fill = !!sym(group_by))
) +
geom_area(
alpha = alpha,
color = "grey50",
position = position_stack(vjust = 0.5),
show.legend = TRUE
) +
scale_x_discrete(
expand = c(0, 0),
breaks = x_vals,
limits = x_vals,
drop = isFALSE(keep_empty_x)
) +
scale_y_continuous(
expand = c(0, 0),
labels = if (isFALSE(scale_y)) scales::number else scales::percent
) +
labs(
title = title,
subtitle = subtitle,
x = xlab %||% x,
y = ylab %||% y
) +
do_call(theme, theme_args) +
ggplot2::theme(
aspect.ratio = aspect.ratio,
legend.position = legend.position,
legend.direction = legend.direction,
panel.grid.major = element_line(colour = "grey80", linetype = 2),
axis.text.x = element_text(
angle = x_text_angle,
hjust = just$h,
vjust = just$v
)
)
if (isTRUE(keep_empty_group)) {
p <- p +
scale_fill_manual(
name = group_name %||% group_by,
values = group_colors,
na.value = group_colors["NA"] %||% "grey80",
breaks = group_by_vals,
limits = group_by_vals,
drop = FALSE
)
} else {
p <- p +
scale_fill_manual(
name = group_name %||% group_by,
values = group_colors,
na.value = group_colors["NA"] %||% "grey80"
)
}
# Calculate plot dimensions with aspect ratio consideration
dims <- calculate_plot_dimensions(
base_height = 4.5,
aspect.ratio = aspect.ratio,
n_x = length(x_vals),
x_scale_factor = 0.5,
legend.position = legend.position,
legend.direction = legend.direction,
legend_n = length(group_by_vals),
legend_nchar = max(nchar(as.character(group_by_vals)), na.rm = TRUE),
flip = FALSE
)
attr(p, "height") <- dims$height
attr(p, "width") <- dims$width
facet_plot(
p,
facet_by,
facet_scales,
facet_nrow,
facet_ncol,
facet_byrow,
legend.position = legend.position,
legend.direction = legend.direction,
drop = !isTRUE(keep_empty_facet)
)
}
#' Area plot
#'
#' @description
#' Draws a stacked area plot showing how one or more groups' numeric values
#' (or counts) accumulate across the progression of a discrete x-axis
#' variable. Each group is rendered as a filled area stacked from baseline,
#' making it easy to compare both individual magnitudes and the total across
#' categories.
#'
#' The function supports \strong{count aggregation} (omit \code{y} to plot
#' observation counts per x-category), \strong{proportion scaling}
#' (\code{scale_y = TRUE} normalises each x position to 100\%), per-group
#' colour control, faceting, and splitting into separate sub-plots via
#' \code{split_by}.
#'
#' @section split_by workflow:
#' When \code{split_by} is provided:
#' \enumerate{
#' \item \code{\link{check_keep_na}()} and \code{\link{check_keep_empty}()}
#' normalise the \code{keep_na} / \code{keep_empty} arguments for all
#' columns (\code{x}, \code{split_by}, \code{group_by}, \code{facet_by}).
#' \item The \code{split_by} column is validated and its NA / empty levels
#' are processed via \code{\link{process_keep_na_empty}()}. It is
#' then removed from the per-column \code{keep_na} / \code{keep_empty}
#' lists.
#' \item The data frame is split by \code{split_by} (preserving level
#' order). If \code{split_by} is \code{NULL}, the data is wrapped in
#' a single-element list with name \code{"..."}.
#' \item Per-split \code{palette}, \code{palcolor},
#' \code{legend.position}, and \code{legend.direction} are resolved
#' via \code{\link{check_palette}()}, \code{\link{check_palcolor}()},
#' and \code{\link{check_legend}()}.
#' \item \code{\link{AreaPlotAtomic}()} is called for each split. If
#' \code{title} is a function, it receives the split level name and
#' can generate dynamic titles.
#' \item Results are combined via \code{\link{combine_plots}()} (when
#' \code{combine = TRUE}) or returned as a named list.
#' }
#'
#' @inheritParams common_args
#' @inheritParams AreaPlotAtomic
#' @param split_by The column(s) to split the data by and produce separate
#' sub-plots. Multiple columns are concatenated with \code{split_by_sep}.
#' @param split_by_sep A character string to separate concatenated
#' \code{split_by} columns. Default \code{"_"}.
#' @param seed A numeric seed for reproducibility. Passed to
#' \code{\link{validate_common_args}()}.
#' @param combine Logical; when \code{TRUE} (default), returns a combined
#' \code{patchwork} object. When \code{FALSE}, returns a named list of
#' individual \code{ggplot} objects.
#' @param ncol,nrow Integer number of columns / rows for the combined layout
#' (passed to \code{\link[patchwork]{wrap_plots}}).
#' @param byrow Logical; fill the combined layout by row. Default \code{TRUE}
#' (passed to \code{\link[patchwork]{wrap_plots}}).
#' @param axes A character string specifying how axes should be treated across
#' the combined layout (passed to \code{\link[patchwork]{wrap_plots}}).
#' @param axis_titles A character string specifying how axis titles should be
#' treated across the combined layout. Defaults to \code{axes}.
#' @param guides A character string specifying how guides (legends) should be
#' collected across panels. Default \code{"collect"} (passed to
#' \code{\link{combine_plots}()}).
#' @param design A custom layout design for the combined plot (passed to
#' \code{\link{combine_plots}()}).
#' @return A \code{ggplot} object, a \code{patchwork} object, or a named list
#' of \code{ggplot} objects (when \code{combine = FALSE}), each with
#' \code{height} and \code{width} attributes in inches.
#' @importFrom ggplot2 waiver
#' @export
#' @examples
#' \donttest{
#' set.seed(8525)
#' data <- data.frame(
#' x = rep(c("A", "B", "C", "D"), 2),
#' y = c(1, 3, 6, 4, 2, 5, 7, 8),
#' group = rep(c("F1", "F2"), each = 4),
#' split = rep(c("X", "Y"), 4)
#' )
#' # Basic stacked area
#' AreaPlot(data, x = "x", y = "y", group_by = "group")
#'
#' # Scaled to proportions
#' AreaPlot(data, x = "x", y = "y", group_by = "group",
#' scale_y = TRUE)
#'
#' # Split into sub-plots (no group_by — single-colour fill)
#' AreaPlot(data, x = "x", y = "y", split_by = "group")
#'
#' # Per-split palettes
#' AreaPlot(data, x = "x", y = "y", split_by = "group",
#' palette = c(F1 = "Blues", F2 = "Reds"))
#'
#' # Per-split legend positioning
#' AreaPlot(data, x = "x", y = "y", group_by = "group",
#' split_by = "split",
#' legend.direction = c(X = "horizontal", Y = "vertical"),
#' legend.position = c(X = "top", Y = "right"))
#'
#' # How keep_na and keep_empty work
#' data <- data.frame(
#' x = factor(rep(c("A", NA, "C", "D"), 3),
#' levels = c("A", "B", "C", "D")),
#' y = c(1, 3, 6, 4, 2, 5, 7, 8, 4, 2, 3, 5),
#' group = factor(sample(rep(c("F1", NA, "F3"), each = 4)),
#' levels = c("F1", "F2", "F3")),
#' split = factor(sample(rep(c("X", "Y", NA), 4)),
#' levels = c("X", "Y", "Z")),
#' facet = factor(sample(rep(c("M", "N", NA), 4)),
#' levels = c("M", "N", "O"))
#' )
#'
#' # Default: NA and empty levels dropped
#' AreaPlot(data, x = "x", y = "y", group_by = "group")
#'
#' # Keep NA and empty levels
#' AreaPlot(data, x = "x", y = "y", group_by = "group",
#' keep_na = TRUE, keep_empty = TRUE)
#'
#' # Keep NA, assign empty levels colours but don't show them
#' AreaPlot(data, x = "x", y = "y", group_by = "group",
#' keep_na = TRUE, keep_empty = "level")
#'
#' # Drop NA, keep empty levels
#' AreaPlot(data, x = "x", y = "y", group_by = "group",
#' keep_na = FALSE, keep_empty = TRUE)
#'
#' # Per-column keep_na / keep_empty via named lists
#' AreaPlot(data, x = "x", y = "y", group_by = "group",
#' keep_na = list(x = TRUE, group = FALSE),
#' keep_empty = list(x = FALSE, group = TRUE))
#' AreaPlot(data, x = "x", y = "y", group_by = "group",
#' keep_na = list(x = FALSE, group = TRUE),
#' keep_empty = list(x = TRUE, group = FALSE))
#' }
AreaPlot <- function(
data,
x,
y = NULL,
x_sep = "_",
split_by = NULL,
split_by_sep = "_",
group_by = NULL,
group_by_sep = "_",
group_name = NULL,
scale_y = FALSE,
theme = "theme_this",
theme_args = list(),
palette = "Paired",
palcolor = NULL,
palreverse = FALSE,
alpha = 1,
facet_by = NULL,
facet_scales = "fixed",
facet_ncol = NULL,
facet_nrow = NULL,
facet_byrow = TRUE,
x_text_angle = 0,
aspect.ratio = 1,
legend.position = waiver(),
legend.direction = "vertical",
title = NULL,
subtitle = NULL,
xlab = NULL,
ylab = NULL,
keep_na = FALSE,
keep_empty = FALSE,
seed = 8525,
combine = TRUE,
nrow = NULL,
ncol = NULL,
byrow = TRUE,
axes = NULL,
axis_titles = axes,
guides = NULL,
design = NULL,
...
) {
validate_common_args(seed, facet_by = facet_by)
keep_na <- check_keep_na(keep_na, c(x, split_by, group_by, facet_by))
keep_empty <- check_keep_empty(
keep_empty,
c(x, split_by, group_by, facet_by)
)
theme <- process_theme(theme)
split_by <- check_columns(
data,
split_by,
force_factor = TRUE,
allow_multi = TRUE,
concat_multi = TRUE,
concat_sep = split_by_sep
)
if (!is.null(split_by)) {
data <- process_keep_na_empty(data, keep_na, keep_empty, col = split_by)
keep_na[[split_by]] <- NULL
keep_empty[[split_by]] <- NULL
datas <- split(data, data[[split_by]])
# keep the order of levels
datas <- datas[levels(data[[split_by]])]
} else {
datas <- list(data)
split_by <- names(datas) <- "..."
}
palette <- check_palette(palette, names(datas))
palcolor <- check_palcolor(palcolor, names(datas))
legend.direction <- check_legend(
legend.direction,
names(datas),
"legend.direction"
)
legend.position <- check_legend(
legend.position,
names(datas),
"legend.position"
)
plots <- lapply(
names(datas),
function(nm) {
default_title <- if (length(datas) == 1 && identical(nm, "...")) {
NULL
} else {
nm
}
if (is.function(title)) {
title <- title(default_title)
} else {
title <- title %||% default_title
}
AreaPlotAtomic(
datas[[nm]],
x = x,
y = y,
x_sep = x_sep,
group_by = group_by,
group_by_sep = group_by_sep,
group_name = group_name,
scale_y = scale_y,
theme = theme,
theme_args = theme_args,
palette = palette[[nm]],
palcolor = palcolor[[nm]],
palreverse = palreverse,
alpha = alpha,
facet_by = facet_by,
facet_scales = facet_scales,
facet_ncol = facet_ncol,
facet_nrow = facet_nrow,
facet_byrow = facet_byrow,
x_text_angle = x_text_angle,
aspect.ratio = aspect.ratio,
legend.position = legend.position[[nm]],
legend.direction = legend.direction[[nm]],
title = title,
subtitle = subtitle,
xlab = xlab,
ylab = ylab,
keep_na = keep_na,
keep_empty = keep_empty,
...
)
}
)
names(plots) <- names(datas)
combine_plots(
plots,
combine = combine,
split_by = split_by,
nrow = nrow,
ncol = ncol,
byrow = byrow,
axes = axes,
axis_titles = axis_titles,
guides = guides,
design = design
)
}
Any scripts or data that you put into this service are public.
Add the following code to your website.
For more information on customizing the embed code, read Embedding Snippets.