From b32c1626a7daf313da0895a332b13ffcc1233d6c Mon Sep 17 00:00:00 2001 From: Grant McDermott Date: Sat, 22 Aug 2026 11:35:35 -0700 Subject: [PATCH 01/12] support stack argument --- R/type_area.R | 82 ++++++++++++++++++++++++++++++++++++++++++++----- R/type_ribbon.R | 39 ++++++++++++++++++++++- 2 files changed, 113 insertions(+), 8 deletions(-) diff --git a/R/type_area.R b/R/type_area.R index 13f96bfcd..13e63184a 100644 --- a/R/type_area.R +++ b/R/type_area.R @@ -1,9 +1,9 @@ #' @rdname type_ribbon #' @export -type_area = function(alpha = NULL) { +type_area = function(alpha = NULL, stack = FALSE) { out = list( draw = NULL, - data = data_area(alpha = alpha), + data = data_area(alpha = alpha, stack = stack), name = "area" ) class(out) = "tinyplot_type" @@ -11,12 +11,37 @@ type_area = function(alpha = NULL) { } -data_area = function(alpha = alpha) { - ribbon.alpha = if (is.null(alpha)) .tpar[["ribbon.alpha"]] else (alpha) +data_area = function(alpha = NULL, stack = FALSE) { + assert_flag(stack) + # Stacked bands don't overlap, so the usual semi-transparent ribbon fill + # only mutes them; default to opaque unless the user asks otherwise. + ribbon.alpha = if (is.null(alpha) && isTRUE(stack)) { + 1 + } else { + sanitize_ribbon_alpha(alpha) + } + fun = function(settings, ...) { - env2env(settings, environment(), "datapoints") - datapoints$ymax = datapoints$y - datapoints$ymin = rep.int(0, nrow(datapoints)) + env2env(settings, environment(), c("datapoints", "xlabs")) + + # Categorical x -> integer positions plus axis labels (cf. data_ribbon) + if (is.character(datapoints$x)) datapoints$x = as.factor(datapoints$x) + if (is.factor(datapoints$x)) { + xlvls = levels(datapoints$x) + xlabs = seq_along(xlvls) + names(xlabs) = xlvls + datapoints$x = as.integer(datapoints$x) + } + + if (isTRUE(stack)) { + datapoints = stack_area(datapoints) + } else { + datapoints$ymax = datapoints$y + datapoints$ymin = rep.int(0, nrow(datapoints)) + } + + x = datapoints$x + y = datapoints$y ymax = datapoints$ymax ymin = datapoints$ymin type = "ribbon" @@ -34,11 +59,54 @@ data_area = function(alpha = alpha) { env2env(environment(), settings, c( "datapoints", + "x", + "y", "ymax", "ymin", + "xlabs", "type", "ribbon.alpha" )) } return(fun) } + + +## Cumulatively stack `y` across the `by` groups, separately within each facet +## and x position. Groups accumulate in `by` level order, so the first level +## forms the bottom band. Returns `datapoints` with `ymin`/`ymax` set to the +## band edges and `y` set to the running total (the ribbon's line is drawn at +## `y`, i.e. along the top of each band). +stack_area = function(datapoints) { + # A gap in one group would otherwise drop every group stacked above it back + # down to zero, so complete the facet x by x grid and treat missing (or NA) + # cells as contributing zero. + cells = expand.grid( + x = sort(unique(datapoints$x)), + by = unique(datapoints$by), + facet = unique(datapoints$facet), + KEEP.OUT.ATTRS = FALSE, + stringsAsFactors = FALSE + ) + if (nrow(cells) > nrow(datapoints)) { + datapoints = merge( + cells, datapoints, + by = c("x", "by", "facet"), all.x = TRUE, sort = FALSE + ) + } + datapoints$y[is.na(datapoints$y)] = 0 + + # cumsum has to run across groups within each (facet, x) cell... + cellord = order(datapoints$facet, datapoints$x, datapoints$by) + datapoints = datapoints[cellord, , drop = FALSE] + cell = paste(datapoints$facet, datapoints$x, sep = "\r") + datapoints$ymax = ave(datapoints$y, cell, FUN = cumsum) + datapoints$ymin = datapoints$ymax - datapoints$y + datapoints$y = datapoints$ymax + + # ... but the polygons are traced along x, so restore group-major ordering + xord = order(datapoints$facet, datapoints$by, datapoints$x) + datapoints = datapoints[xord, , drop = FALSE] + + return(datapoints) +} diff --git a/R/type_ribbon.R b/R/type_ribbon.R index e303a93ac..e1d3a746b 100644 --- a/R/type_ribbon.R +++ b/R/type_ribbon.R @@ -4,6 +4,10 @@ #' If no `alpha` value is provided, then will default to `tpar("ribbon.alpha")` #' (i.e., probably `0.2` unless this has been overridden by the user in their global #' settings.) +#' @param stack logical. Should the `by` groups be stacked on top of one +#' another, rather than overplotted from a common zero baseline? Only +#' relevant for grouped area plots. Default is `FALSE`. See the "Stacked +#' area plots" section below. #' @inheritParams type_errorbar #' #' @description Type constructor functions for producing polygon ribbons, which @@ -19,6 +23,24 @@ #' limited number of discrete cases (e.g., coefficient or event-study plots). #' See Examples. #' +#' @section Stacked area plots: +#' +#' Passing `type_area(stack = TRUE)` stacks the `by` groups cumulatively, +#' rather than drawing each one from a zero baseline. Groups are accumulated in +#' the order of their (factor) levels, so the first level forms the bottom band +#' and the top of the final band traces the group total. Stacking is computed +#' separately within each facet. +#' +#' Since stacked bands do not overlap, they are drawn opaque by default (i.e. +#' `alpha = 1`) instead of inheriting the usual semi-transparent `tpar( +#' "ribbon.alpha")` shading. Pass an explicit `alpha` to override. +#' +#' Stacking assumes a single `y` value per group per `x` value. Groups that are +#' missing an `x` value (or have an `NA` there) are treated as contributing +#' zero at that point, so that a gap in one group does not shift the groups +#' stacked above it. Note that stacking negative values is not meaningful and +#' will produce overlapping bands. +#' #' @examples #' x = 1:100 / 10 #' y = sin(x) @@ -46,7 +68,22 @@ #' #' # Area plots are often used for time series charts #' tinyplot(AirPassengers, type = "area") -#' +#' +#' # +#' ## Stacked area plots +#' +#' # Grouped area plots can be stacked cumulatively, rather than being drawn +#' # from a common zero baseline. +#' +#' ucb = as.data.frame(UCBAdmissions) +#' +#' tinyplot( +#' Freq ~ Dept | Admit, data = ucb, +#' facet = ~ Gender, facet.args = list(ncol = 1), +#' type = type_area(stack = TRUE), +#' frame = FALSE +#' ) +#' #' # #' ## Dodged ribbon/area plots #' From 1bada311726436bc921fa37da8886e637200b268 Mon Sep 17 00:00:00 2001 From: Grant McDermott Date: Sat, 22 Aug 2026 19:08:53 -0700 Subject: [PATCH 02/12] bylevels to control ordering by y value along x axis --- R/assertions.R | 3 +- R/legend.R | 31 ++++++++++++++++++ R/sanitize_bylevels.R | 74 +++++++++++++++++++++++++++++++++++++++++++ R/type_area.R | 53 +++++++++++++++++++++++++++---- R/type_ribbon.R | 62 +++++++++++++++++++++++++++++++----- 5 files changed, 207 insertions(+), 16 deletions(-) create mode 100644 R/sanitize_bylevels.R diff --git a/R/assertions.R b/R/assertions.R index eefd0d330..0b9ab8f31 100644 --- a/R/assertions.R +++ b/R/assertions.R @@ -323,7 +323,8 @@ known_type_hints = c( "has_rhs_axis", # secondary right-hand axis (reserve margin) "legend_border_fg", # legend swatch border is always par("fg") "legend_fills_from_col", # legend swatch fill comes from `col` - "legend_fills_from_seq_palette" # ... or from the colour's sequential ramp + "legend_fills_from_seq_palette", # ... or from the colour's sequential ramp + "legend_reversed" # list the key bottom-up, not top-down ) ## Validate a type's declared hints. diff --git a/R/legend.R b/R/legend.R index 5781b8b29..bd4748e51 100644 --- a/R/legend.R +++ b/R/legend.R @@ -716,6 +716,13 @@ build_legend_args = function( } } + # legend() lists its first entry at the top, so a type whose groups read + # bottom-up needs its key flipped or it runs backwards against the geometry it + # labels. Gradient legends already run bottom-up, so they are exempt. (#632) + if (isTRUE(legend_env[["type_hints"]][["legend_reversed"]]) && isFALSE(gradient)) { + legend_args = reverse_legend_keys(legend_args, n = length(lgnd_labs)) + } + # Populate legend environment with args and flags legend_env$args = legend_args legend_env$mcol = mcol_flag @@ -727,6 +734,30 @@ build_legend_args = function( } +## Flip a discrete legend key end-for-end. Every element below is positionally +## aligned with the labels, so they all have to move together or the swatches +## detach from their text. An allowlist rather than "reverse anything of length +## n", because some non-grouped args are legitimately length 2 -- `inset` above +## all -- and would be corrupted on any two-group plot. Scalars are skipped (a +## recycled `lty`, or a `col` that legend_border_fg collapsed to par("fg")), as +## is a `legend` still held as an unevaluated expression. +reverse_legend_keys = function(legend_args, n) { + if (n < 2L) return(legend_args) + keys = c( + "legend", # the labels themselves + "col", "pch", "lty", "lwd", # line/point key + "pt.bg", "pt.cex", "pt.lwd", # point key fill and sizing + "fill", "border", "density", "angle", # box key, only ever user-supplied + "text.col" # label colour, ditto + ) + for (key in keys) { + val = legend_args[[key]] + if (is.atomic(val) && length(val) == n) legend_args[[key]] = rev(val) + } + legend_args +} + + #' Build legend environment #' #' @description Creates the legend environment by: diff --git a/R/sanitize_bylevels.R b/R/sanitize_bylevels.R new file mode 100644 index 000000000..68cb0b7d9 --- /dev/null +++ b/R/sanitize_bylevels.R @@ -0,0 +1,74 @@ +## Reorder the levels of a `by` grouping variable, per a type's `bylevels` +## argument. Accepts everything that sanitize_xlevels() does, and defers to it +## for those cases: +## +## - NULL: keep the existing factor levels (the default) +## - "asis": the categories in the order they appear in the data +## - character: the levels in the desired order +## - numeric: indexes into the existing levels, e.g. 3:1 +## +## ... plus three data-dependent keywords that rank the groups by size, largest +## first (i.e. into the first level, which is the bottom band of a stacked area): +## +## - "start": the group's y value at the smallest x +## - "end": the group's y value at the largest x +## - "total": the group's summed y across every x +## +## ... and, for anything else, a function that is handed each group's y values +## (ordered by x) and returns a single number to sort *ascending* on. So +## `function(y) -sum(y)` reproduces "total", and `function(y) sum(y)` reverses +## it. This is the escape hatch for the reverse direction, and for statistics we +## don't have a keyword for (`function(y) -median(y)`, etc.). +## +## Ranking pools over facets. `by` levels are global -- one legend, one colour +## mapping -- so ordering each facet separately would desync the legend from the +## groups it labels. Absent groups (and NA values) count as zero, matching how +## stack_area() completes a ragged grid. Ties keep their existing relative +## order. Only factors are touched, so the argument is inert for continuous +## groupings. +## +## As with sanitize_xlevels()'s "asis", the keywords win over a same-named +## category: in the degenerate case of a group literally called "end", pass the +## levels explicitly instead. + +## Only the size keywords. "asis" belongs to the xlevels vocabulary and is +## delegated below; routing it through here would silently treat it as "end". +bylevels_size_keywords = c("start", "end", "total") + +sanitize_bylevels = function(by, y, x, bylevels, arg = "bylevels") { + if (is.null(bylevels) || !is.factor(by)) { + return(by) + } + + size_keyword = is.character(bylevels) && length(bylevels) == 1L && + bylevels %in% bylevels_size_keywords + + # Static respecifications are the shared xlevels vocabulary; only the + # data-dependent cases need the machinery below. + if (!size_keyword && !is.function(bylevels)) { + return(sanitize_xlevels(by, bylevels, arg = arg)) + } + + if (size_keyword) { + if (identical(bylevels, "total")) { + keep = rep.int(TRUE, length(x)) + } else { + edge = if (identical(bylevels, "start")) min(x, na.rm = TRUE) else max(x, na.rm = TRUE) + keep = !is.na(x) & x == edge + } + stat = tapply(y[keep], by[keep], function(z) sum(z, na.rm = TRUE), default = 0) + stat = -stat # largest group first, i.e. the bottom band + } else { + xord = order(x) + grps = split(y[xord], by[xord]) + stat = vapply( + grps, + function(z) if (length(z) == 0L) NA_real_ else as.numeric(bylevels(z)), + numeric(1) + ) + } + + # seq_along() breaks ties on the existing level order; empty groups sort last + ord = order(stat, seq_along(stat), na.last = TRUE) + factor(by, levels = levels(by)[ord]) +} diff --git a/R/type_area.R b/R/type_area.R index 13e63184a..2ed72bf9d 100644 --- a/R/type_area.R +++ b/R/type_area.R @@ -1,9 +1,9 @@ #' @rdname type_ribbon #' @export -type_area = function(alpha = NULL, stack = FALSE) { +type_area = function(alpha = NULL, stack = FALSE, bylevels = NULL, FUN = NULL) { out = list( draw = NULL, - data = data_area(alpha = alpha, stack = stack), + data = data_area(alpha = alpha, stack = stack, bylevels = bylevels, FUN = FUN), name = "area" ) class(out) = "tinyplot_type" @@ -11,8 +11,16 @@ type_area = function(alpha = NULL, stack = FALSE) { } -data_area = function(alpha = NULL, stack = FALSE) { +data_area = function(alpha = NULL, stack = FALSE, bylevels = NULL, FUN = NULL) { assert_flag(stack) + assert_function(FUN, null.ok = TRUE) + if (!is.null(bylevels) && + !is.character(bylevels) && !is.numeric(bylevels) && !is.function(bylevels)) { + stop( + "`bylevels` must be NULL, a character or numeric vector, or a function.", + call. = FALSE + ) + } # Stacked bands don't overlap, so the usual semi-transparent ribbon fill # only mutes them; default to opaque unless the user asks otherwise. ribbon.alpha = if (is.null(alpha) && isTRUE(stack)) { @@ -33,8 +41,20 @@ data_area = function(alpha = NULL, stack = FALSE) { datapoints$x = as.integer(datapoints$x) } + # The `by` level order sets the band order, and with it the legend + # order and the palette assignment, so this has to happen up front. + by = NULL + if (!is.null(bylevels)) { + datapoints$by = sanitize_bylevels( + datapoints$by, datapoints$y, datapoints$x, bylevels + ) + by = datapoints$by + } + if (isTRUE(stack)) { - datapoints = stack_area(datapoints) + # bands read bottom-up, so the legend key should too + settings[["type_hints"]][["legend_reversed"]] = TRUE + datapoints = stack_area(datapoints, FUN = FUN) } else { datapoints$ymax = datapoints$y datapoints$ymin = rep.int(0, nrow(datapoints)) @@ -57,7 +77,7 @@ data_area = function(alpha = NULL, stack = FALSE) { settings$legend_args[["y.intersp"]] = settings$legend_args[["y.intersp"]] %||% 1.25 settings$legend_args[["seg.len"]] = settings$legend_args[["seg.len"]] %||% 1.25 - env2env(environment(), settings, c( + vars_to_settings = c( "datapoints", "x", "y", @@ -66,7 +86,11 @@ data_area = function(alpha = NULL, stack = FALSE) { "xlabs", "type", "ribbon.alpha" - )) + ) + # keep settings$by in step with datapoints$by if we releveled it + if (!is.null(by)) vars_to_settings = c(vars_to_settings, "by") + + env2env(environment(), settings, vars_to_settings) } return(fun) } @@ -77,7 +101,22 @@ data_area = function(alpha = NULL, stack = FALSE) { ## forms the bottom band. Returns `datapoints` with `ymin`/`ymax` set to the ## band edges and `y` set to the running total (the ribbon's line is drawn at ## `y`, i.e. along the top of each band). -stack_area = function(datapoints) { +stack_area = function(datapoints, FUN = NULL) { + # Stacking needs exactly one y per group per x. Repeated cells (typically a + # variable that is in the data but not in the plot) would otherwise be + # cumsum'd against each other into overlapping bands, so collapse them + # first. Matches data_barplot(), down to the default statistic, so that the + # same data stacks to the same heights whether drawn as bars or as an area. + cellid = paste(datapoints$facet, datapoints$x, datapoints$by, sep = "\r") + if (anyDuplicated(cellid)) { + if (is.null(FUN)) FUN = function(x, ...) mean(x, ..., na.rm = TRUE) + datapoints = aggregate( + datapoints[, "y", drop = FALSE], + datapoints[, c("x", "by", "facet")], + FUN = FUN + ) + } + # A gap in one group would otherwise drop every group stacked above it back # down to zero, so complete the facet x by x grid and treat missing (or NA) # cells as contributing zero. diff --git a/R/type_ribbon.R b/R/type_ribbon.R index e1d3a746b..32f122c1d 100644 --- a/R/type_ribbon.R +++ b/R/type_ribbon.R @@ -8,6 +8,24 @@ #' another, rather than overplotted from a common zero baseline? Only #' relevant for grouped area plots. Default is `FALSE`. See the "Stacked #' area plots" section below. +#' @param bylevels controls the order of the `by` groups and, thus, the order in +#' which they stack. Accepts the same values as the `xlevels` argument of +#' [`type_points()`] and friends (i.e., a character vector of level names, a +#' numeric vector of level indexes, or the `"asis"` keyword). More germanely, +#' accepts a further three keywords that rank groups according to their `y` +#' values along the `x` axis: `"start"`, `"end"`, and `"total"` (i.e., summed +#' `y` values over the full `x` axis range). These three options are +#' especially convenient for stacked area plots, where it is helpful to order +#' layers by their relative `y` values. This usually means the biggest group +#' first (i.e., on the bottom layer). But users can also pass their own custom +#' function to determine both the ranking statistic and its direction, e.g. +#' `function(y) -median(y)` would layer by median `y` value, from the biggest +#' to the smallest. Default is `NULL`, in which case the existing factor level +#' order is retained. See Examples and the "Stacked area plots" section below. +#' @param FUN a function for collapsing repeated `y` values within a group and +#' `x` position, used only when `stack = TRUE`. Defaults to `mean`, matching +#' [`type_barplot()`], so that the same data stacks to the same heights +#' whether it is drawn as bars or as an area. #' @inheritParams type_errorbar #' #' @description Type constructor functions for producing polygon ribbons, which @@ -31,15 +49,35 @@ #' and the top of the final band traces the group total. Stacking is computed #' separately within each facet. #' -#' Since stacked bands do not overlap, they are drawn opaque by default (i.e. -#' `alpha = 1`) instead of inheriting the usual semi-transparent `tpar( -#' "ribbon.alpha")` shading. Pass an explicit `alpha` to override. +#' The `bylevels` argument is a helpful companion to stacked area plots, since +#' it controls which group ends up where. While it accepts various inputs, the +#' most useful are the three positional keywords: `"start"`, `"end"`, and +#' `"total"`. These rank the stacked `by` groups according to size---at the +#' designated position along the `x` axis---so that the largest layer sits at +#' the bottom and thus allowing for a more stable visual baseline. +#' +#' Stacking needs exactly one `y` value per group per `x` value. Repeated cells +#' ---typically caused by a variable that is present in the data but absent from +#' the plot---are collapsed with `FUN` (default `mean`) rather than being +#' stacked against each other. Conversely, groups that are *missing* an `x` +#' value (or have an `NA` there) count as contributing zero at that point, so +#' that a gap in one group does not shift the groups stacked above it. Note that +#' stacking negative values is not meaningful and will produce overlapping +#' bands. + +#' Note that the legend key for stacked area plots is deliberately inverted +#' compared to other plot types (including non-stacked area plots) to ensure a +#' consistent ordering with the "bottoms-up" layering of the stacked regions. +#' Similarly, reordering of the `by` group levels will reassigns the palette, +#' since group colours are allocated by level position. This matches what +#' releveling a factor does elsewhere, but it does mean that reordering the +#' bands repaints them. +#' +#' +#' Finally, note that unlike non-stacked area plots, the stacked bands are +#' drawn with opaque fill by default, since they do not overlap. Pass an +#' explicit `alpha` or `fill` value to override. #' -#' Stacking assumes a single `y` value per group per `x` value. Groups that are -#' missing an `x` value (or have an `NA` there) are treated as contributing -#' zero at that point, so that a gap in one group does not shift the groups -#' stacked above it. Note that stacking negative values is not meaningful and -#' will produce overlapping bands. #' #' @examples #' x = 1:100 / 10 @@ -84,6 +122,14 @@ #' frame = FALSE #' ) #' +#' # Use `bylevels` to control which group stacks where. The size keywords rank +#' # the groups largest-first, so the biggest band forms the baseline. +#' +#' tinyplot( +#' Freq ~ Dept | Admit, data = ucb, facet = ~ Gender, +#' type = type_area(stack = TRUE, bylevels = "total") +#' ) +#' #' # #' ## Dodged ribbon/area plots #' From e9b8fe10e8f996187dcbaf0a4eaf9b05c74241e4 Mon Sep 17 00:00:00 2001 From: Grant McDermott Date: Sat, 22 Aug 2026 21:57:43 -0700 Subject: [PATCH 03/12] aggregation gotcha and docs --- R/type_area.R | 47 ++++++++++++++-------- R/type_ribbon.R | 81 +++++++++++++++++++++----------------- man/type_ribbon.Rd | 98 +++++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 172 insertions(+), 54 deletions(-) diff --git a/R/type_area.R b/R/type_area.R index 2ed72bf9d..020511783 100644 --- a/R/type_area.R +++ b/R/type_area.R @@ -41,6 +41,14 @@ data_area = function(alpha = NULL, stack = FALSE, bylevels = NULL, FUN = NULL) { datapoints$x = as.integer(datapoints$x) } + # Collapse repeated cells *before* ranking below, so that `bylevels` + # sees the values that actually get drawn. Ranking first would sort on + # raw per-cell sums, which unequal cell counts can order differently + # from the aggregated bands. + if (isTRUE(stack)) { + datapoints = aggregate_cells(datapoints, FUN = FUN) + } + # The `by` level order sets the band order, and with it the legend # order and the palette assignment, so this has to happen up front. by = NULL @@ -54,7 +62,7 @@ data_area = function(alpha = NULL, stack = FALSE, bylevels = NULL, FUN = NULL) { if (isTRUE(stack)) { # bands read bottom-up, so the legend key should too settings[["type_hints"]][["legend_reversed"]] = TRUE - datapoints = stack_area(datapoints, FUN = FUN) + datapoints = stack_area(datapoints) } else { datapoints$ymax = datapoints$y datapoints$ymin = rep.int(0, nrow(datapoints)) @@ -96,27 +104,32 @@ data_area = function(alpha = NULL, stack = FALSE, bylevels = NULL, FUN = NULL) { } +## Collapse repeated cells down to one `y` per group per `x`. Stacking needs +## exactly one value per cell; repeats -- typically a variable that is in the +## data but not in the plot -- would otherwise be cumsum'd against each other +## into overlapping bands. Mirrors data_barplot(), default statistic included, +## so that the same data stacks to the same heights whether it is drawn as bars +## or as an area. +aggregate_cells = function(datapoints, FUN = NULL) { + cellid = paste(datapoints$facet, datapoints$x, datapoints$by, sep = "\r") + if (!anyDuplicated(cellid)) { + return(datapoints) + } + if (is.null(FUN)) FUN = function(x, ...) mean(x, ..., na.rm = TRUE) + aggregate( + datapoints[, "y", drop = FALSE], + datapoints[, c("x", "by", "facet")], + FUN = FUN + ) +} + + ## Cumulatively stack `y` across the `by` groups, separately within each facet ## and x position. Groups accumulate in `by` level order, so the first level ## forms the bottom band. Returns `datapoints` with `ymin`/`ymax` set to the ## band edges and `y` set to the running total (the ribbon's line is drawn at ## `y`, i.e. along the top of each band). -stack_area = function(datapoints, FUN = NULL) { - # Stacking needs exactly one y per group per x. Repeated cells (typically a - # variable that is in the data but not in the plot) would otherwise be - # cumsum'd against each other into overlapping bands, so collapse them - # first. Matches data_barplot(), down to the default statistic, so that the - # same data stacks to the same heights whether drawn as bars or as an area. - cellid = paste(datapoints$facet, datapoints$x, datapoints$by, sep = "\r") - if (anyDuplicated(cellid)) { - if (is.null(FUN)) FUN = function(x, ...) mean(x, ..., na.rm = TRUE) - datapoints = aggregate( - datapoints[, "y", drop = FALSE], - datapoints[, c("x", "by", "facet")], - FUN = FUN - ) - } - +stack_area = function(datapoints) { # A gap in one group would otherwise drop every group stacked above it back # down to zero, so complete the facet x by x grid and treat missing (or NA) # cells as contributing zero. diff --git a/R/type_ribbon.R b/R/type_ribbon.R index 32f122c1d..b40973de4 100644 --- a/R/type_ribbon.R +++ b/R/type_ribbon.R @@ -12,16 +12,15 @@ #' which they stack. Accepts the same values as the `xlevels` argument of #' [`type_points()`] and friends (i.e., a character vector of level names, a #' numeric vector of level indexes, or the `"asis"` keyword). More germanely, -#' accepts a further three keywords that rank groups according to their `y` +#' accepts three additional keywords that rank groups according to their `y` #' values along the `x` axis: `"start"`, `"end"`, and `"total"` (i.e., summed #' `y` values over the full `x` axis range). These three options are -#' especially convenient for stacked area plots, where it is helpful to order -#' layers by their relative `y` values. This usually means the biggest group -#' first (i.e., on the bottom layer). But users can also pass their own custom -#' function to determine both the ranking statistic and its direction, e.g. -#' `function(y) -median(y)` would layer by median `y` value, from the biggest -#' to the smallest. Default is `NULL`, in which case the existing factor level -#' order is retained. See Examples and the "Stacked area plots" section below. +#' especially convenient for (re)ordering the layers of a stacked area plot on +#' the fly. Users can also pass their own custom function to determine both +#' the ranking statistic and its direction, e.g. `function(y) -median(y)` +#' would layer by median `y` value, from the biggest to the smallest. Default +#' is `NULL`, in which case the existing factor level order is retained. See +#' Examples, as well as the "Stacked area plots" section below. #' @param FUN a function for collapsing repeated `y` values within a group and #' `x` position, used only when `stack = TRUE`. Defaults to `mean`, matching #' [`type_barplot()`], so that the same data stacks to the same heights @@ -52,9 +51,10 @@ #' The `bylevels` argument is a helpful companion to stacked area plots, since #' it controls which group ends up where. While it accepts various inputs, the #' most useful are the three positional keywords: `"start"`, `"end"`, and -#' `"total"`. These rank the stacked `by` groups according to size---at the -#' designated position along the `x` axis---so that the largest layer sits at -#' the bottom and thus allowing for a more stable visual baseline. +#' `"total"`. These rank the stacked `by` groups according to their `y` +#' values at the designated position along the `x` axis. Following convention, +#' the ranking runs in descending order, so that the biggest group is drawn +#' on the bottom layer to provide a stable visual baseline. #' #' Stacking needs exactly one `y` value per group per `x` value. Repeated cells #' ---typically caused by a variable that is present in the data but absent from @@ -73,12 +73,10 @@ #' releveling a factor does elsewhere, but it does mean that reordering the #' bands repaints them. #' -#' #' Finally, note that unlike non-stacked area plots, the stacked bands are #' drawn with opaque fill by default, since they do not overlap. Pass an #' explicit `alpha` or `fill` value to override. #' -#' #' @examples #' x = 1:100 / 10 #' y = sin(x) @@ -108,29 +106,6 @@ #' tinyplot(AirPassengers, type = "area") #' #' # -#' ## Stacked area plots -#' -#' # Grouped area plots can be stacked cumulatively, rather than being drawn -#' # from a common zero baseline. -#' -#' ucb = as.data.frame(UCBAdmissions) -#' -#' tinyplot( -#' Freq ~ Dept | Admit, data = ucb, -#' facet = ~ Gender, facet.args = list(ncol = 1), -#' type = type_area(stack = TRUE), -#' frame = FALSE -#' ) -#' -#' # Use `bylevels` to control which group stacks where. The size keywords rank -#' # the groups largest-first, so the biggest band forms the baseline. -#' -#' tinyplot( -#' Freq ~ Dept | Admit, data = ucb, facet = ~ Gender, -#' type = type_area(stack = TRUE, bylevels = "total") -#' ) -#' -#' # #' ## Dodged ribbon/area plots #' #' # Dodged ribbon or area plots can be useful in cases where there is strong @@ -159,7 +134,41 @@ #' type = type_ribbon(dodge = 0.1), #' main = "Dodged ribbons" #' ) +#' +#' # +#' ## Stacked area plots +#' +#' # Grouped area plots can be stacked cumulatively, rather than being drawn +#' # from a common zero baseline. +#' +#' # (A not very sensible example that sums *average* chick weights across diets +#' # and over time...) #' +#' cw = aggregate(weight ~ Time + Diet, data = ChickWeight, FUN = mean) +#' +#' tinyplot( +#' weight ~ Time | Diet, data = cw, +#' type = type_area(stack = TRUE) +#' ) +#' +#' # Use `bylevels` to control which group stacks where. The size keywords rank +#' # the groups largest-first, so the biggest band forms the baseline. Here the +#' # diets are reordered by their final mean weight. +#' +#' tinyplot( +#' weight ~ Time | Diet, data = cw, +#' type = type_area(stack = TRUE, bylevels = "end") +#' ) +#' +#' # Aside: Aggregating up front isn't actually necessary. Passing the raw data +#' # gives the same plot, since repeated values are collapsed for us. Use `FUN` +#' # to choose ange the summary statistic from its `mean` default. +#' +#' tinyplot( +#' weight ~ Time | Diet, data = ChickWeight, +#' type = type_area(stack = TRUE, bylevels = "end", FUN = median) +#' ) +#' #' @export type_ribbon = function(alpha = NULL, dodge = 0, fixed.dodge = FALSE) { out = list( diff --git a/man/type_ribbon.Rd b/man/type_ribbon.Rd index 086c191a5..6c9c5604d 100644 --- a/man/type_ribbon.Rd +++ b/man/type_ribbon.Rd @@ -5,7 +5,7 @@ \alias{type_ribbon} \title{Ribbon and area plot types} \usage{ -type_area(alpha = NULL) +type_area(alpha = NULL, stack = FALSE, bylevels = NULL, FUN = NULL) type_ribbon(alpha = NULL, dodge = 0, fixed.dodge = FALSE) } @@ -15,6 +15,30 @@ If no \code{alpha} value is provided, then will default to \code{tpar("ribbon.al (i.e., probably \code{0.2} unless this has been overridden by the user in their global settings.)} +\item{stack}{logical. Should the \code{by} groups be stacked on top of one +another, rather than overplotted from a common zero baseline? Only +relevant for grouped area plots. Default is \code{FALSE}. See the "Stacked +area plots" section below.} + +\item{bylevels}{controls the order of the \code{by} groups and, thus, the order in +which they stack. Accepts the same values as the \code{xlevels} argument of +\code{\link[=type_points]{type_points()}} and friends (i.e., a character vector of level names, a +numeric vector of level indexes, or the \code{"asis"} keyword). More germanely, +accepts three additional keywords that rank groups according to their \code{y} +values along the \code{x} axis: \code{"start"}, \code{"end"}, and \code{"total"} (i.e., summed +\code{y} values over the full \code{x} axis range). These three options are +especially convenient for (re)ordering the layers of a stacked area plot on +the fly. Users can also pass their own custom function to determine both +the ranking statistic and its direction, e.g. \code{function(y) -median(y)} +would layer by median \code{y} value, from the biggest to the smallest. Default +is \code{NULL}, in which case the existing factor level order is retained. See +Examples, as well as the "Stacked area plots" section below.} + +\item{FUN}{a function for collapsing repeated \code{y} values within a group and +\code{x} position, used only when \code{stack = TRUE}. Defaults to \code{mean}, matching +\code{\link[=type_barplot]{type_barplot()}}, so that the same data stacks to the same heights +whether it is drawn as bars or as an area.} + \item{dodge}{Adjustment parameter for dodging overlapping points or ranges in grouped plots along the x-axis (or y-axis for flipped plots). Either: \itemize{ @@ -53,6 +77,44 @@ limited number of discrete cases (e.g., coefficient or event-study plots). See Examples. } +\section{Stacked area plots}{ + + +Passing \code{type_area(stack = TRUE)} stacks the \code{by} groups cumulatively, +rather than drawing each one from a zero baseline. Groups are accumulated in +the order of their (factor) levels, so the first level forms the bottom band +and the top of the final band traces the group total. Stacking is computed +separately within each facet. + +The \code{bylevels} argument is a helpful companion to stacked area plots, since +it controls which group ends up where. While it accepts various inputs, the +most useful are the three positional keywords: \code{"start"}, \code{"end"}, and +\code{"total"}. These rank the stacked \code{by} groups according to their \code{y} +values at the designated position along the \code{x} axis. Following convention, +the ranking runs in descending order, so that the biggest group is drawn +on the bottom layer to provide a stable visual baseline. + +Stacking needs exactly one \code{y} value per group per \code{x} value. Repeated cells +---typically caused by a variable that is present in the data but absent from +the plot---are collapsed with \code{FUN} (default \code{mean}) rather than being +stacked against each other. Conversely, groups that are \emph{missing} an \code{x} +value (or have an \code{NA} there) count as contributing zero at that point, so +that a gap in one group does not shift the groups stacked above it. Note that +stacking negative values is not meaningful and will produce overlapping +bands. +Note that the legend key for stacked area plots is deliberately inverted +compared to other plot types (including non-stacked area plots) to ensure a +consistent ordering with the "bottoms-up" layering of the stacked regions. +Similarly, reordering of the \code{by} group levels will reassigns the palette, +since group colours are allocated by level position. This matches what +releveling a factor does elsewhere, but it does mean that reordering the +bands repaints them. + +Finally, note that unlike non-stacked area plots, the stacked bands are +drawn with opaque fill by default, since they do not overlap. Pass an +explicit \code{alpha} or \code{fill} value to override. +} + \examples{ x = 1:100 / 10 y = sin(x) @@ -110,4 +172,38 @@ tinyplot( main = "Dodged ribbons" ) +# +## Stacked area plots + +# Grouped area plots can be stacked cumulatively, rather than being drawn +# from a common zero baseline. + +# (A not very sensible example that sums *average* chick weights across diets +# and over time...) + +cw = aggregate(weight ~ Time + Diet, data = ChickWeight, FUN = mean) + +tinyplot( + weight ~ Time | Diet, data = cw, + type = type_area(stack = TRUE) +) + +# Use `bylevels` to control which group stacks where. The size keywords rank +# the groups largest-first, so the biggest band forms the baseline. Here the +# diets are reordered by their final mean weight. + +tinyplot( + weight ~ Time | Diet, data = cw, + type = type_area(stack = TRUE, bylevels = "end") +) + +# Aside: Aggregating up front isn't actually necessary. Passing the raw data +# gives the same plot, since repeated values are collapsed for us. Use `FUN` +# to choose ange the summary statistic from its `mean` default. + +tinyplot( + weight ~ Time | Diet, data = ChickWeight, + type = type_area(stack = TRUE, bylevels = "end", FUN = median) +) + } From 044108df4fa42bda622c0173ece9179fb64c40dd Mon Sep 17 00:00:00 2001 From: Grant McDermott Date: Sat, 22 Aug 2026 21:58:09 -0700 Subject: [PATCH 04/12] document legend_reversed type hint --- vignettes/types.qmd | 1 + 1 file changed, 1 insertion(+) diff --git a/vignettes/types.qmd b/vignettes/types.qmd index c8c285779..e417d8e3d 100644 --- a/vignettes/types.qmd +++ b/vignettes/types.qmd @@ -406,6 +406,7 @@ need only define the list of hints that you actually want: | `legend_fills_from_col` | `type_hexbin()`, `type_spineplot()` | Fill the legend key with the group colour (`col`) rather than `bg`. | | `legend_fills_from_seq_palette` | `type_ridge()` | Fill the legend key with a lighter step of the group colour's sequential ramp. | | `legend_border_fg` | `type_spineplot()` | Draw the legend key border in the foreground colour rather than the group colour. | +| `legend_reversed` | `type_area(stack = TRUE)` | List the legend key bottom-up rather than top-down. For types whose groups read from the bottom of the plot upwards---stacking puts the first `by` level in the bottom band---so that the key reads in the same direction as the geometry rather than backwards against it. | To see `type_hints` in action, consult the [source code](https://github.com/grantmcdermott/tinyplot/tree/main/R) of the From 1f95731ca69b08a7f1b9f46747a4c9edc3e7abc7 Mon Sep 17 00:00:00 2001 From: Grant McDermott Date: Sat, 22 Aug 2026 22:06:41 -0700 Subject: [PATCH 05/12] tests --- inst/tinytest/_tinysnapshot/area_factor_x.svg | 69 ++++++++++ inst/tinytest/_tinysnapshot/area_grouped.svg | 87 +++++++++++++ inst/tinytest/_tinysnapshot/area_stack.svg | 89 +++++++++++++ .../_tinysnapshot/area_stack_alpha.svg | 89 +++++++++++++ .../area_stack_bylevels_aggregated.svg | 89 +++++++++++++ .../_tinysnapshot/area_stack_bylevels_end.svg | 89 +++++++++++++ .../_tinysnapshot/area_stack_facet.svg | 121 ++++++++++++++++++ .../_tinysnapshot/area_stack_flip.svg | 89 +++++++++++++ inst/tinytest/test-type_area.R | 73 +++++++++++ 9 files changed, 795 insertions(+) create mode 100644 inst/tinytest/_tinysnapshot/area_factor_x.svg create mode 100644 inst/tinytest/_tinysnapshot/area_grouped.svg create mode 100644 inst/tinytest/_tinysnapshot/area_stack.svg create mode 100644 inst/tinytest/_tinysnapshot/area_stack_alpha.svg create mode 100644 inst/tinytest/_tinysnapshot/area_stack_bylevels_aggregated.svg create mode 100644 inst/tinytest/_tinysnapshot/area_stack_bylevels_end.svg create mode 100644 inst/tinytest/_tinysnapshot/area_stack_facet.svg create mode 100644 inst/tinytest/_tinysnapshot/area_stack_flip.svg create mode 100644 inst/tinytest/test-type_area.R diff --git a/inst/tinytest/_tinysnapshot/area_factor_x.svg b/inst/tinytest/_tinysnapshot/area_factor_x.svg new file mode 100644 index 000000000..2572ec100 --- /dev/null +++ b/inst/tinytest/_tinysnapshot/area_factor_x.svg @@ -0,0 +1,69 @@ + + + + + + + + + + + + + +Dept +Freq + + + + + + + +A +B +C +D +E +F + + + + + + + +0 +100 +200 +300 +400 +500 + + + + + + + + + + + + + diff --git a/inst/tinytest/_tinysnapshot/area_grouped.svg b/inst/tinytest/_tinysnapshot/area_grouped.svg new file mode 100644 index 000000000..ff18270e6 --- /dev/null +++ b/inst/tinytest/_tinysnapshot/area_grouped.svg @@ -0,0 +1,87 @@ + + + + + + + + + + + + + + + + +grp +A +B +C + + + + + + + +year +val + + + + + + + + +2000 +2005 +2010 +2015 +2020 + + + + + + + +0 +1 +2 +3 +4 +5 + + + + + + + + + + + + + + + + + diff --git a/inst/tinytest/_tinysnapshot/area_stack.svg b/inst/tinytest/_tinysnapshot/area_stack.svg new file mode 100644 index 000000000..b3284ec9d --- /dev/null +++ b/inst/tinytest/_tinysnapshot/area_stack.svg @@ -0,0 +1,89 @@ + + + + + + + + + + + + + + + + +grp +C +B +A + + + + + + + +year +val + + + + + + + + +2000 +2005 +2010 +2015 +2020 + + + + + + + + +0 +2 +4 +6 +8 +10 +12 + + + + + + + + + + + + + + + + + diff --git a/inst/tinytest/_tinysnapshot/area_stack_alpha.svg b/inst/tinytest/_tinysnapshot/area_stack_alpha.svg new file mode 100644 index 000000000..f3ff037f0 --- /dev/null +++ b/inst/tinytest/_tinysnapshot/area_stack_alpha.svg @@ -0,0 +1,89 @@ + + + + + + + + + + + + + + + + +grp +C +B +A + + + + + + + +year +val + + + + + + + + +2000 +2005 +2010 +2015 +2020 + + + + + + + + +0 +2 +4 +6 +8 +10 +12 + + + + + + + + + + + + + + + + + diff --git a/inst/tinytest/_tinysnapshot/area_stack_bylevels_aggregated.svg b/inst/tinytest/_tinysnapshot/area_stack_bylevels_aggregated.svg new file mode 100644 index 000000000..6d726e5d0 --- /dev/null +++ b/inst/tinytest/_tinysnapshot/area_stack_bylevels_aggregated.svg @@ -0,0 +1,89 @@ + + + + + + + + + + + + + + + + + +Diet +1 +2 +4 +3 + + + + + + + +Time +weight + + + + + + + + +0 +5 +10 +15 +20 + + + + + + +0 +200 +400 +600 +800 + + + + + + + + + + + + + + + + + + + diff --git a/inst/tinytest/_tinysnapshot/area_stack_bylevels_end.svg b/inst/tinytest/_tinysnapshot/area_stack_bylevels_end.svg new file mode 100644 index 000000000..9c4f5db8a --- /dev/null +++ b/inst/tinytest/_tinysnapshot/area_stack_bylevels_end.svg @@ -0,0 +1,89 @@ + + + + + + + + + + + + + + + + +grp +A +B +C + + + + + + + +year +val + + + + + + + + +2000 +2005 +2010 +2015 +2020 + + + + + + + + +0 +2 +4 +6 +8 +10 +12 + + + + + + + + + + + + + + + + + diff --git a/inst/tinytest/_tinysnapshot/area_stack_facet.svg b/inst/tinytest/_tinysnapshot/area_stack_facet.svg new file mode 100644 index 000000000..51c69739f --- /dev/null +++ b/inst/tinytest/_tinysnapshot/area_stack_facet.svg @@ -0,0 +1,121 @@ + + + + + + + + + + + + + + + +Admit +Rejected +Admitted + + + + + + + +Dept +Freq + + + + + + + + + + + + + + + +0 +200 +400 +600 +800 + +Male + + + + + + + + + + + + + + + + +A +B +C +D +E +F + + + + + + +0 +200 +400 +600 +800 + +Female + + + + + + + + + + + + + + + + + + + + + + diff --git a/inst/tinytest/_tinysnapshot/area_stack_flip.svg b/inst/tinytest/_tinysnapshot/area_stack_flip.svg new file mode 100644 index 000000000..4e16ddd18 --- /dev/null +++ b/inst/tinytest/_tinysnapshot/area_stack_flip.svg @@ -0,0 +1,89 @@ + + + + + + + + + + + + + + + + +grp +C +B +A + + + + + + + +val +year + + + + + + + + + + +0 +2 +4 +6 +8 +10 +12 + + + + + + +2000 +2005 +2010 +2015 +2020 + + + + + + + + + + + + + + + + + diff --git a/inst/tinytest/test-type_area.R b/inst/tinytest/test-type_area.R new file mode 100644 index 000000000..2d55a02ec --- /dev/null +++ b/inst/tinytest/test-type_area.R @@ -0,0 +1,73 @@ +source("helpers.R") +using("tinysnapshot") + +ucb = as.data.frame(UCBAdmissions) + +dat = expand.grid(year = 2000:2020, grp = factor(c("A", "B", "C"))) +dat$val = abs(sin(dat$year / 3) + as.integer(dat$grp)) + 1 + + +# +## stacked areas ----- + +f = function() { + tinyplot( + Freq ~ Dept | Admit, + data = ucb, + facet = ~Gender, facet.args = list(ncol = 1), + type = type_area(stack = TRUE), + frame = FALSE + ) +} +expect_snapshot_plot(f, label = "area_stack_facet") + +f = function() { + tinyplot(val ~ year | grp, data = dat, type = type_area(stack = TRUE)) +} +expect_snapshot_plot(f, label = "area_stack") + +# stacked bands are opaque by default, but `alpha` still wins +f = function() { + tinyplot( + val ~ year | grp, data = dat, + type = type_area(stack = TRUE, alpha = 0.4) + ) +} +expect_snapshot_plot(f, label = "area_stack_alpha") + +f = function() { + tinyplot(val ~ year | grp, data = dat, type = type_area(stack = TRUE), flip = TRUE) +} +expect_snapshot_plot(f, label = "area_stack_flip") + + +# +## bylevels ----- + +f = function() { + tinyplot(val ~ year | grp, data = dat, type = type_area(stack = TRUE, bylevels = "end")) +} +expect_snapshot_plot(f, label = "area_stack_bylevels_end") + +# repeated cells must be collapsed *before* `bylevels` ranks them, or the +# ranking sorts on raw per-cell sums rather than the bands actually drawn +f = function() { + tinyplot(weight ~ Time | Diet, ChickWeight, + type = type_area(stack = TRUE, bylevels = "end")) +} +expect_snapshot_plot(f, label = "area_stack_bylevels_aggregated") + + +# +## unstacked areas ----- + +f = function() { + tinyplot(val ~ year | grp, data = dat, type = "area") +} +expect_snapshot_plot(f, label = "area_grouped") + +# categorical x should be labelled with its factor levels +f = function() { + tinyplot(Freq ~ Dept, data = ucb[ucb$Admit == "Admitted" & ucb$Gender == "Male", ], type = "area") +} +expect_snapshot_plot(f, label = "area_factor_x") From ae6d71709a507f39143f91603af7601080507647 Mon Sep 17 00:00:00 2001 From: Grant McDermott Date: Sat, 22 Aug 2026 22:13:20 -0700 Subject: [PATCH 06/12] better simulated example --- R/type_ribbon.R | 28 +++++++++++----------------- man/type_ribbon.Rd | 26 ++++++++++---------------- 2 files changed, 21 insertions(+), 33 deletions(-) diff --git a/R/type_ribbon.R b/R/type_ribbon.R index b40973de4..2f4efc502 100644 --- a/R/type_ribbon.R +++ b/R/type_ribbon.R @@ -140,33 +140,27 @@ #' #' # Grouped area plots can be stacked cumulatively, rather than being drawn #' # from a common zero baseline. -#' -#' # (A not very sensible example that sums *average* chick weights across diets -#' # and over time...) -#' -#' cw = aggregate(weight ~ Time + Diet, data = ChickWeight, FUN = mean) #' -#' tinyplot( -#' weight ~ Time | Diet, data = cw, -#' type = type_area(stack = TRUE) -#' ) +#' dat = expand.grid(year = 2000:2020, grp = factor(c("A", "B", "C"))) +#' dat$val = abs(sin(dat$year / 3) + as.integer(dat$grp)) + 1 +#' +#' tinyplot(val ~ year | grp, data = dat, type = type_area(stack = TRUE)) #' -#' # Use `bylevels` to control which group stacks where. The size keywords rank -#' # the groups largest-first, so the biggest band forms the baseline. Here the -#' # diets are reordered by their final mean weight. +#' # Use `bylevels` to control which group stacks where. Here we stack by their +#' # largest end value. #' #' tinyplot( -#' weight ~ Time | Diet, data = cw, +#' val ~ year | grp, data = dat, #' type = type_area(stack = TRUE, bylevels = "end") #' ) #' -#' # Aside: Aggregating up front isn't actually necessary. Passing the raw data -#' # gives the same plot, since repeated values are collapsed for us. Use `FUN` -#' # to choose ange the summary statistic from its `mean` default. +#' # Stacking expects a single `y` value per group per `x` value. Any repeats +#' # are collapsed for us first, using `FUN` (`mean` by default). Here, for +#' # instance, ChickWeight records many chicks per diet at each timepoint. #' #' tinyplot( #' weight ~ Time | Diet, data = ChickWeight, -#' type = type_area(stack = TRUE, bylevels = "end", FUN = median) +#' type = type_area(stack = TRUE, FUN = median) #' ) #' #' @export diff --git a/man/type_ribbon.Rd b/man/type_ribbon.Rd index 6c9c5604d..b84a9e478 100644 --- a/man/type_ribbon.Rd +++ b/man/type_ribbon.Rd @@ -178,32 +178,26 @@ tinyplot( # Grouped area plots can be stacked cumulatively, rather than being drawn # from a common zero baseline. -# (A not very sensible example that sums *average* chick weights across diets -# and over time...) +dat = expand.grid(year = 2000:2020, grp = factor(c("A", "B", "C"))) +dat$val = abs(sin(dat$year / 3) + as.integer(dat$grp)) + 1 -cw = aggregate(weight ~ Time + Diet, data = ChickWeight, FUN = mean) +tinyplot(val ~ year | grp, data = dat, type = type_area(stack = TRUE)) -tinyplot( - weight ~ Time | Diet, data = cw, - type = type_area(stack = TRUE) -) - -# Use `bylevels` to control which group stacks where. The size keywords rank -# the groups largest-first, so the biggest band forms the baseline. Here the -# diets are reordered by their final mean weight. +# Use `bylevels` to control which group stacks where. Here we stack by their +# largest end value. tinyplot( - weight ~ Time | Diet, data = cw, + val ~ year | grp, data = dat, type = type_area(stack = TRUE, bylevels = "end") ) -# Aside: Aggregating up front isn't actually necessary. Passing the raw data -# gives the same plot, since repeated values are collapsed for us. Use `FUN` -# to choose ange the summary statistic from its `mean` default. +# Stacking expects a single `y` value per group per `x` value. Any repeats +# are collapsed for us first, using `FUN` (`mean` by default). Here, for +# instance, ChickWeight records many chicks per diet at each timepoint. tinyplot( weight ~ Time | Diet, data = ChickWeight, - type = type_area(stack = TRUE, bylevels = "end", FUN = median) + type = type_area(stack = TRUE, FUN = median) ) } From b6236f7637df08ccc3b9f6f189f947704ec66fae Mon Sep 17 00:00:00 2001 From: Grant McDermott Date: Sat, 22 Aug 2026 22:19:32 -0700 Subject: [PATCH 07/12] news --- NEWS.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/NEWS.md b/NEWS.md index 9e0a8d7c7..462d76126 100644 --- a/NEWS.md +++ b/NEWS.md @@ -68,6 +68,9 @@ a (custom) theme, e.g. `tinytheme("clean", facet.axes = "outer")`. #### Other new features +- `type_area()` gains a `stack` argument for stacked area plots, plus `bylevels` + to control the stacking order, and `FUN` to collapse repeated `y` values. + (#688 @grantmcdermott) - `type_points()`, `type_lines()`, `type_errorbar()`, and `type_pointrange()` gain an `xlevels` argument for reordering a categorical `x` variable on the fly (matching existing functionality for `type_barplot()` and several other @@ -107,6 +110,9 @@ a (custom) theme, e.g. `tinytheme("clean", facet.axes = "outer")`. ### Bug fixes +- `type_area()` now labels a categorical `x` axis with its factor levels, + rather than falling back to the underlying integer positions. + (#688 @grantmcdermott) - Density-based plots no longer error out on singleton groups, i.e. `by` and `facet` combinations containing only one observation. Such groups are now dropped, together with a warning reporting how many were removed. The From 365840151cd4211148662791b5a0e98395892fbe Mon Sep 17 00:00:00 2001 From: Grant McDermott Date: Sun, 23 Aug 2026 10:13:22 -0700 Subject: [PATCH 08/12] fix(area): scope stacked grid to observed facet-x pairs stack_area() completed its grid from the global cross product of x, by and facet, so every facet received zero-valued cells at x positions that only occurred in other facets. Facets with different x domains drew artificial ramps to zero, and free-scale facets inherited the union of all x ranges rather than their own. Cross `by` against the (facet, x) pairs actually observed instead; ragged groups within a facet are still zero-filled, which is all the completion was ever for. Also move the legend_reversed key flip ahead of the horizontal padding block. The padding appends a space to every label but the rightmost, so flipping afterwards stranded it on the wrong end: the new leftmost label lost its inter-label gap and the new rightmost carried dead width. Both paths picked up regression snapshots, neither of which was covered before. Reported by Copilot on #688. --- R/legend.R | 14 +- R/type_area.R | 17 +- R/type_ribbon.R | 2 +- .../_tinysnapshot/area_stack_facet_ragged.svg | 146 ++++++++++++++++++ .../area_stack_legend_bottom.svg | 89 +++++++++++ inst/tinytest/test-type_area.R | 23 +++ man/type_ribbon.Rd | 2 +- 7 files changed, 275 insertions(+), 18 deletions(-) create mode 100644 inst/tinytest/_tinysnapshot/area_stack_facet_ragged.svg create mode 100644 inst/tinytest/_tinysnapshot/area_stack_legend_bottom.svg diff --git a/R/legend.R b/R/legend.R index bd4748e51..4922e78d7 100644 --- a/R/legend.R +++ b/R/legend.R @@ -690,6 +690,13 @@ build_legend_args = function( legend_args[["inset"]] = 0 } + # legend() lists its first entry at the top, so a type whose groups read + # bottom-up needs its key flipped or it runs backwards against the geometry it + # labels. Gradient legends already run bottom-up, so they are exempt. (#632) + if (isTRUE(legend_env[["type_hints"]][["legend_reversed"]]) && isFALSE(gradient)) { + legend_args = reverse_legend_keys(legend_args, n = length(lgnd_labs)) + } + # Additional tweaks for horizontal and/or multi-column legends mcol_flag = !is.null(legend_args[["ncol"]]) && legend_args[["ncol"]] > 1 user_inset = !is.null(legend_args[["inset"]]) @@ -716,13 +723,6 @@ build_legend_args = function( } } - # legend() lists its first entry at the top, so a type whose groups read - # bottom-up needs its key flipped or it runs backwards against the geometry it - # labels. Gradient legends already run bottom-up, so they are exempt. (#632) - if (isTRUE(legend_env[["type_hints"]][["legend_reversed"]]) && isFALSE(gradient)) { - legend_args = reverse_legend_keys(legend_args, n = length(lgnd_labs)) - } - # Populate legend environment with args and flags legend_env$args = legend_args legend_env$mcol = mcol_flag diff --git a/R/type_area.R b/R/type_area.R index 020511783..3bcf05431 100644 --- a/R/type_area.R +++ b/R/type_area.R @@ -131,15 +131,14 @@ aggregate_cells = function(datapoints, FUN = NULL) { ## `y`, i.e. along the top of each band). stack_area = function(datapoints) { # A gap in one group would otherwise drop every group stacked above it back - # down to zero, so complete the facet x by x grid and treat missing (or NA) - # cells as contributing zero. - cells = expand.grid( - x = sort(unique(datapoints$x)), - by = unique(datapoints$by), - facet = unique(datapoints$facet), - KEEP.OUT.ATTRS = FALSE, - stringsAsFactors = FALSE - ) + # down to zero, so complete the grid and treat missing (or NA) cells as + # contributing zero. Cross `by` against the (facet, x) pairs that were + # actually observed, not against every x in the data: a facet must not + # inherit x positions that only exist in some other facet, or it ramps to + # zero across a range it never spanned. + fx = unique(datapoints[, c("facet", "x")]) + fx = fx[order(fx$facet, fx$x), , drop = FALSE] + cells = merge(fx, data.frame(by = unique(datapoints$by)), by = NULL) if (nrow(cells) > nrow(datapoints)) { datapoints = merge( cells, datapoints, diff --git a/R/type_ribbon.R b/R/type_ribbon.R index 2f4efc502..8a941e0b0 100644 --- a/R/type_ribbon.R +++ b/R/type_ribbon.R @@ -68,7 +68,7 @@ #' Note that the legend key for stacked area plots is deliberately inverted #' compared to other plot types (including non-stacked area plots) to ensure a #' consistent ordering with the "bottoms-up" layering of the stacked regions. -#' Similarly, reordering of the `by` group levels will reassigns the palette, +#' Similarly, reordering of the `by` group levels will reassign the palette, #' since group colours are allocated by level position. This matches what #' releveling a factor does elsewhere, but it does mean that reordering the #' bands repaints them. diff --git a/inst/tinytest/_tinysnapshot/area_stack_facet_ragged.svg b/inst/tinytest/_tinysnapshot/area_stack_facet_ragged.svg new file mode 100644 index 000000000..5baa15a09 --- /dev/null +++ b/inst/tinytest/_tinysnapshot/area_stack_facet_ragged.svg @@ -0,0 +1,146 @@ + + + + + + + + + + + + + + + + +grp +C +B +A + + + + + + + +year +val + + + + + + + + + + + + + + + +2000 +2005 +2010 +2015 +2020 + + + + + + + + +0 +2 +4 +6 +8 +10 +12 + +early + + + + + + + + + + + + + + + + +2000 +2005 +2010 +2015 +2020 + + + + + + + + +0 +2 +4 +6 +8 +10 +12 + +late + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/inst/tinytest/_tinysnapshot/area_stack_legend_bottom.svg b/inst/tinytest/_tinysnapshot/area_stack_legend_bottom.svg new file mode 100644 index 000000000..ffc6174b0 --- /dev/null +++ b/inst/tinytest/_tinysnapshot/area_stack_legend_bottom.svg @@ -0,0 +1,89 @@ + + + + + + + + + + + + + + + + +grp +C +B +A + + + + + + + +year +val + + + + + + + + +2000 +2005 +2010 +2015 +2020 + + + + + + + + +0 +2 +4 +6 +8 +10 +12 + + + + + + + + + + + + + + + + + diff --git a/inst/tinytest/test-type_area.R b/inst/tinytest/test-type_area.R index 2d55a02ec..30327fd33 100644 --- a/inst/tinytest/test-type_area.R +++ b/inst/tinytest/test-type_area.R @@ -40,6 +40,29 @@ f = function() { } expect_snapshot_plot(f, label = "area_stack_flip") +# facets with different x coverage must not inherit each other's x positions, +# or the bands ramp to zero across a range the facet never spanned +f = function() { + d = dat + d$half = ifelse(d$year < 2010, "early", "late") + tinyplot( + val ~ year | grp, data = d, + facet = ~half, facet.args = list(ncol = 1), + type = type_area(stack = TRUE) + ) +} +expect_snapshot_plot(f, label = "area_stack_facet_ragged") + +# horizontal legends pad every label but the rightmost, so the key has to be +# reversed before that padding is applied +f = function() { + tinyplot( + val ~ year | grp, data = dat, + type = type_area(stack = TRUE), legend = "bottom!" + ) +} +expect_snapshot_plot(f, label = "area_stack_legend_bottom") + # ## bylevels ----- diff --git a/man/type_ribbon.Rd b/man/type_ribbon.Rd index b84a9e478..beac596b8 100644 --- a/man/type_ribbon.Rd +++ b/man/type_ribbon.Rd @@ -105,7 +105,7 @@ bands. Note that the legend key for stacked area plots is deliberately inverted compared to other plot types (including non-stacked area plots) to ensure a consistent ordering with the "bottoms-up" layering of the stacked regions. -Similarly, reordering of the \code{by} group levels will reassigns the palette, +Similarly, reordering of the \code{by} group levels will reassign the palette, since group colours are allocated by level position. This matches what releveling a factor does elsewhere, but it does mean that reordering the bands repaints them. From b25daa6cf36c7aaff58b0d8b7650d248d333b11d Mon Sep 17 00:00:00 2001 From: Grant McDermott Date: Sun, 23 Aug 2026 12:21:06 -0700 Subject: [PATCH 09/12] refactor(area)!: rename bylevels to byord, keyword-only `bylevels` did two unrelated jobs: respecify factor levels literally (character names, numeric indexes) and derive an order from the data ("start", "end", "total", a function). The name describes the first and fits the second badly -- `bylevels = "total"` reads as nonsense. Split the vocabularies. `type_area()` now exposes `byord`, which accepts only the computed forms: "asis", "start", "end", "total", or a function. Explicit level order is rejected with a message pointing at factor(levels = ), which is what it was always delegating to. Keyword-only on purpose. The wider cleanup -- restricting xlevels / ylevels back to their released contract and giving the other seven types an `*ord` -- needs `type_barplot` to validate the composition rule and the aggregate-before-rank ordering, and that belongs in its own PR. A `bylevels` for `type_area` can be added there; adding an argument is not breaking, whereas shipping the overloaded name would have been. None of the keyword vocabulary has ever been released, so there is nothing to deprecate. Snapshots are byte-identical -- only the labels move. Spec for the remaining work: SCRATCH/spec-ord-family.md --- NEWS.md | 8 +- R/sanitize_bylevels.R | 74 ----------------- R/sanitize_ord.R | 81 +++++++++++++++++++ R/type_area.R | 21 ++--- R/type_ribbon.R | 44 +++++----- ...ed.svg => area_stack_byord_aggregated.svg} | 0 ...evels_end.svg => area_stack_byord_end.svg} | 0 inst/tinytest/test-type_area.R | 29 +++++-- man/type_ribbon.Rd | 46 +++++------ 9 files changed, 161 insertions(+), 142 deletions(-) delete mode 100644 R/sanitize_bylevels.R create mode 100644 R/sanitize_ord.R rename inst/tinytest/_tinysnapshot/{area_stack_bylevels_aggregated.svg => area_stack_byord_aggregated.svg} (100%) rename inst/tinytest/_tinysnapshot/{area_stack_bylevels_end.svg => area_stack_byord_end.svg} (100%) diff --git a/NEWS.md b/NEWS.md index 462d76126..dfb7b749f 100644 --- a/NEWS.md +++ b/NEWS.md @@ -68,9 +68,11 @@ a (custom) theme, e.g. `tinytheme("clean", facet.axes = "outer")`. #### Other new features -- `type_area()` gains a `stack` argument for stacked area plots, plus `bylevels` - to control the stacking order, and `FUN` to collapse repeated `y` values. - (#688 @grantmcdermott) +- `type_area()` gains a `stack` argument for stacked area plots, plus `byord` + to control the stacking order (`"start"`, `"end"` and `"total"` rank groups by + size, largest onto the baseline) and `FUN` to collapse repeated `y` values. + Stacked types list their legend keys bottom-up, via the new + `"legend_reversed"` entry in `type_hints`. (#688 @grantmcdermott) - `type_points()`, `type_lines()`, `type_errorbar()`, and `type_pointrange()` gain an `xlevels` argument for reordering a categorical `x` variable on the fly (matching existing functionality for `type_barplot()` and several other diff --git a/R/sanitize_bylevels.R b/R/sanitize_bylevels.R deleted file mode 100644 index 68cb0b7d9..000000000 --- a/R/sanitize_bylevels.R +++ /dev/null @@ -1,74 +0,0 @@ -## Reorder the levels of a `by` grouping variable, per a type's `bylevels` -## argument. Accepts everything that sanitize_xlevels() does, and defers to it -## for those cases: -## -## - NULL: keep the existing factor levels (the default) -## - "asis": the categories in the order they appear in the data -## - character: the levels in the desired order -## - numeric: indexes into the existing levels, e.g. 3:1 -## -## ... plus three data-dependent keywords that rank the groups by size, largest -## first (i.e. into the first level, which is the bottom band of a stacked area): -## -## - "start": the group's y value at the smallest x -## - "end": the group's y value at the largest x -## - "total": the group's summed y across every x -## -## ... and, for anything else, a function that is handed each group's y values -## (ordered by x) and returns a single number to sort *ascending* on. So -## `function(y) -sum(y)` reproduces "total", and `function(y) sum(y)` reverses -## it. This is the escape hatch for the reverse direction, and for statistics we -## don't have a keyword for (`function(y) -median(y)`, etc.). -## -## Ranking pools over facets. `by` levels are global -- one legend, one colour -## mapping -- so ordering each facet separately would desync the legend from the -## groups it labels. Absent groups (and NA values) count as zero, matching how -## stack_area() completes a ragged grid. Ties keep their existing relative -## order. Only factors are touched, so the argument is inert for continuous -## groupings. -## -## As with sanitize_xlevels()'s "asis", the keywords win over a same-named -## category: in the degenerate case of a group literally called "end", pass the -## levels explicitly instead. - -## Only the size keywords. "asis" belongs to the xlevels vocabulary and is -## delegated below; routing it through here would silently treat it as "end". -bylevels_size_keywords = c("start", "end", "total") - -sanitize_bylevels = function(by, y, x, bylevels, arg = "bylevels") { - if (is.null(bylevels) || !is.factor(by)) { - return(by) - } - - size_keyword = is.character(bylevels) && length(bylevels) == 1L && - bylevels %in% bylevels_size_keywords - - # Static respecifications are the shared xlevels vocabulary; only the - # data-dependent cases need the machinery below. - if (!size_keyword && !is.function(bylevels)) { - return(sanitize_xlevels(by, bylevels, arg = arg)) - } - - if (size_keyword) { - if (identical(bylevels, "total")) { - keep = rep.int(TRUE, length(x)) - } else { - edge = if (identical(bylevels, "start")) min(x, na.rm = TRUE) else max(x, na.rm = TRUE) - keep = !is.na(x) & x == edge - } - stat = tapply(y[keep], by[keep], function(z) sum(z, na.rm = TRUE), default = 0) - stat = -stat # largest group first, i.e. the bottom band - } else { - xord = order(x) - grps = split(y[xord], by[xord]) - stat = vapply( - grps, - function(z) if (length(z) == 0L) NA_real_ else as.numeric(bylevels(z)), - numeric(1) - ) - } - - # seq_along() breaks ties on the existing level order; empty groups sort last - ord = order(stat, seq_along(stat), na.last = TRUE) - factor(by, levels = levels(by)[ord]) -} diff --git a/R/sanitize_ord.R b/R/sanitize_ord.R new file mode 100644 index 000000000..6a4ffc0a6 --- /dev/null +++ b/R/sanitize_ord.R @@ -0,0 +1,81 @@ +## Derive a factor's level order from the data, per a type's `*ord` argument. +## This is the computed counterpart to sanitize_xlevels(), which respecifies +## levels literally. Accepts: +## +## - NULL: keep the existing factor levels (the default) +## - "asis": the categories in the order they appear in the data +## - "start": rank by the group's y value at the smallest x +## - "end": rank by the group's y value at the largest x +## - "total": rank by the group's summed y across every x +## +## ... and, for anything else, a function that is handed each group's y values +## (ordered by x) and returns a single number to sort *ascending* on. So +## `function(y) -sum(y)` reproduces "total", and `function(y) sum(y)` reverses +## it. This is the escape hatch for the reverse direction, and for statistics we +## don't have a keyword for (`function(y) -median(y)`, etc.). +## +## The three size keywords rank largest first, i.e. into the first level, which +## is the bottom band of a stacked area. +## +## Explicit level names or indexes are deliberately *not* accepted here -- that +## is what sanitize_xlevels() is for, and letting both arguments take the same +## inputs would collapse the distinction between them. Types that expose only +## `*ord` point users at factor() instead; see the error below. +## +## Ranking pools over facets. `by` levels are global -- one legend, one colour +## mapping -- so ordering each facet separately would desync the legend from the +## groups it labels. Absent groups (and NA values) count as zero, matching how +## stack_area() completes a ragged grid. Ties keep their existing relative +## order. Only factors are touched, so the argument is inert for continuous +## groupings. +## +## As with sanitize_xlevels()'s "asis", the keywords win over a same-named +## category: in the degenerate case of a group literally called "end", set the +## factor levels beforehand instead. + +ord_keywords = c("asis", "start", "end", "total") + +sanitize_ord = function(v, y, x, ord, arg = "ord") { + if (is.null(ord) || !is.factor(v)) { + return(v) + } + + keyword = is.character(ord) && length(ord) == 1L && ord %in% ord_keywords + if (!keyword && !is.function(ord)) { + stop( + sprintf( + "`%s` must be NULL, one of %s, or a function.\n To set the level order explicitly, use factor(levels = ) on the variable beforehand.", + arg, paste(sprintf('"%s"', ord_keywords), collapse = ", ") + ), + call. = FALSE + ) + } + + # "asis" needs no y, and must work when y is absent or non-numeric + if (identical(ord, "asis")) { + return(factor(v, levels = unique(v))) + } + + if (keyword) { + if (identical(ord, "total")) { + keep = rep.int(TRUE, length(x)) + } else { + edge = if (identical(ord, "start")) min(x, na.rm = TRUE) else max(x, na.rm = TRUE) + keep = !is.na(x) & x == edge + } + stat = tapply(y[keep], v[keep], function(z) sum(z, na.rm = TRUE), default = 0) + stat = -stat # largest group first, i.e. the bottom band + } else { + xord = order(x) + grps = split(y[xord], v[xord]) + stat = vapply( + grps, + function(z) if (length(z) == 0L) NA_real_ else as.numeric(ord(z)), + numeric(1) + ) + } + + # seq_along() breaks ties on the existing level order; empty groups sort last + o = order(stat, seq_along(stat), na.last = TRUE) + factor(v, levels = levels(v)[o]) +} diff --git a/R/type_area.R b/R/type_area.R index 3bcf05431..fc7b27283 100644 --- a/R/type_area.R +++ b/R/type_area.R @@ -1,9 +1,9 @@ #' @rdname type_ribbon #' @export -type_area = function(alpha = NULL, stack = FALSE, bylevels = NULL, FUN = NULL) { +type_area = function(alpha = NULL, stack = FALSE, byord = NULL, FUN = NULL) { out = list( draw = NULL, - data = data_area(alpha = alpha, stack = stack, bylevels = bylevels, FUN = FUN), + data = data_area(alpha = alpha, stack = stack, byord = byord, FUN = FUN), name = "area" ) class(out) = "tinyplot_type" @@ -11,16 +11,9 @@ type_area = function(alpha = NULL, stack = FALSE, bylevels = NULL, FUN = NULL) { } -data_area = function(alpha = NULL, stack = FALSE, bylevels = NULL, FUN = NULL) { +data_area = function(alpha = NULL, stack = FALSE, byord = NULL, FUN = NULL) { assert_flag(stack) assert_function(FUN, null.ok = TRUE) - if (!is.null(bylevels) && - !is.character(bylevels) && !is.numeric(bylevels) && !is.function(bylevels)) { - stop( - "`bylevels` must be NULL, a character or numeric vector, or a function.", - call. = FALSE - ) - } # Stacked bands don't overlap, so the usual semi-transparent ribbon fill # only mutes them; default to opaque unless the user asks otherwise. ribbon.alpha = if (is.null(alpha) && isTRUE(stack)) { @@ -41,7 +34,7 @@ data_area = function(alpha = NULL, stack = FALSE, bylevels = NULL, FUN = NULL) { datapoints$x = as.integer(datapoints$x) } - # Collapse repeated cells *before* ranking below, so that `bylevels` + # Collapse repeated cells *before* ranking below, so that `byord` # sees the values that actually get drawn. Ranking first would sort on # raw per-cell sums, which unequal cell counts can order differently # from the aggregated bands. @@ -52,9 +45,9 @@ data_area = function(alpha = NULL, stack = FALSE, bylevels = NULL, FUN = NULL) { # The `by` level order sets the band order, and with it the legend # order and the palette assignment, so this has to happen up front. by = NULL - if (!is.null(bylevels)) { - datapoints$by = sanitize_bylevels( - datapoints$by, datapoints$y, datapoints$x, bylevels + if (!is.null(byord)) { + datapoints$by = sanitize_ord( + datapoints$by, datapoints$y, datapoints$x, byord, arg = "byord" ) by = datapoints$by } diff --git a/R/type_ribbon.R b/R/type_ribbon.R index 8a941e0b0..080ed40d6 100644 --- a/R/type_ribbon.R +++ b/R/type_ribbon.R @@ -8,19 +8,19 @@ #' another, rather than overplotted from a common zero baseline? Only #' relevant for grouped area plots. Default is `FALSE`. See the "Stacked #' area plots" section below. -#' @param bylevels controls the order of the `by` groups and, thus, the order in -#' which they stack. Accepts the same values as the `xlevels` argument of -#' [`type_points()`] and friends (i.e., a character vector of level names, a -#' numeric vector of level indexes, or the `"asis"` keyword). More germanely, -#' accepts three additional keywords that rank groups according to their `y` -#' values along the `x` axis: `"start"`, `"end"`, and `"total"` (i.e., summed -#' `y` values over the full `x` axis range). These three options are -#' especially convenient for (re)ordering the layers of a stacked area plot on -#' the fly. Users can also pass their own custom function to determine both -#' the ranking statistic and its direction, e.g. `function(y) -median(y)` -#' would layer by median `y` value, from the biggest to the smallest. Default -#' is `NULL`, in which case the existing factor level order is retained. See -#' Examples, as well as the "Stacked area plots" section below. +#' @param byord controls the order of the `by` groups and, thus, the order in +#' which they stack. Three keywords rank the groups according to their `y` +#' values along the `x` axis: `"start"` (value at the smallest `x`), `"end"` +#' (value at the largest `x`), and `"total"` (summed `y` values over the full +#' `x` axis range). Each ranks the largest group first, i.e. into the bottom +#' band. A fourth keyword, `"asis"`, takes the groups in the order that they +#' appear in the data. Users can also pass their own custom function to +#' determine both the ranking statistic and its direction, e.g. +#' `function(y) -median(y)` would layer by median `y` value, from the biggest +#' to the smallest. Default is `NULL`, in which case the existing factor level +#' order is retained; to set that order explicitly, call +#' `factor(levels = )` on the grouping variable beforehand. See Examples, as +#' well as the "Stacked area plots" section below. #' @param FUN a function for collapsing repeated `y` values within a group and #' `x` position, used only when `stack = TRUE`. Defaults to `mean`, matching #' [`type_barplot()`], so that the same data stacks to the same heights @@ -48,13 +48,13 @@ #' and the top of the final band traces the group total. Stacking is computed #' separately within each facet. #' -#' The `bylevels` argument is a helpful companion to stacked area plots, since -#' it controls which group ends up where. While it accepts various inputs, the -#' most useful are the three positional keywords: `"start"`, `"end"`, and -#' `"total"`. These rank the stacked `by` groups according to their `y` -#' values at the designated position along the `x` axis. Following convention, -#' the ranking runs in descending order, so that the biggest group is drawn -#' on the bottom layer to provide a stable visual baseline. +#' The `byord` argument is a helpful companion to stacked area plots, since +#' it controls which group ends up where. Most useful are the three positional +#' keywords: `"start"`, `"end"`, and `"total"`. These rank the stacked `by` +#' groups according to their `y` values at the designated position along the +#' `x` axis. Following convention, the ranking runs in descending order, so that +#' the biggest group is drawn on the bottom layer to provide a stable visual +#' baseline. #' #' Stacking needs exactly one `y` value per group per `x` value. Repeated cells #' ---typically caused by a variable that is present in the data but absent from @@ -146,12 +146,12 @@ #' #' tinyplot(val ~ year | grp, data = dat, type = type_area(stack = TRUE)) #' -#' # Use `bylevels` to control which group stacks where. Here we stack by their +#' # Use `byord` to control which group stacks where. Here we stack by their #' # largest end value. #' #' tinyplot( #' val ~ year | grp, data = dat, -#' type = type_area(stack = TRUE, bylevels = "end") +#' type = type_area(stack = TRUE, byord = "end") #' ) #' #' # Stacking expects a single `y` value per group per `x` value. Any repeats diff --git a/inst/tinytest/_tinysnapshot/area_stack_bylevels_aggregated.svg b/inst/tinytest/_tinysnapshot/area_stack_byord_aggregated.svg similarity index 100% rename from inst/tinytest/_tinysnapshot/area_stack_bylevels_aggregated.svg rename to inst/tinytest/_tinysnapshot/area_stack_byord_aggregated.svg diff --git a/inst/tinytest/_tinysnapshot/area_stack_bylevels_end.svg b/inst/tinytest/_tinysnapshot/area_stack_byord_end.svg similarity index 100% rename from inst/tinytest/_tinysnapshot/area_stack_bylevels_end.svg rename to inst/tinytest/_tinysnapshot/area_stack_byord_end.svg diff --git a/inst/tinytest/test-type_area.R b/inst/tinytest/test-type_area.R index 30327fd33..3508f490b 100644 --- a/inst/tinytest/test-type_area.R +++ b/inst/tinytest/test-type_area.R @@ -65,20 +65,20 @@ expect_snapshot_plot(f, label = "area_stack_legend_bottom") # -## bylevels ----- +## byord ----- f = function() { - tinyplot(val ~ year | grp, data = dat, type = type_area(stack = TRUE, bylevels = "end")) + tinyplot(val ~ year | grp, data = dat, type = type_area(stack = TRUE, byord = "end")) } -expect_snapshot_plot(f, label = "area_stack_bylevels_end") +expect_snapshot_plot(f, label = "area_stack_byord_end") -# repeated cells must be collapsed *before* `bylevels` ranks them, or the +# repeated cells must be collapsed *before* `byord` ranks them, or the # ranking sorts on raw per-cell sums rather than the bands actually drawn f = function() { tinyplot(weight ~ Time | Diet, ChickWeight, - type = type_area(stack = TRUE, bylevels = "end")) + type = type_area(stack = TRUE, byord = "end")) } -expect_snapshot_plot(f, label = "area_stack_bylevels_aggregated") +expect_snapshot_plot(f, label = "area_stack_byord_aggregated") # @@ -94,3 +94,20 @@ f = function() { tinyplot(Freq ~ Dept, data = ucb[ucb$Admit == "Admitted" & ucb$Gender == "Male", ], type = "area") } expect_snapshot_plot(f, label = "area_factor_x") + + +# +## byord rejects explicit levels ----- + +# explicit level order belongs to factor(levels = ), not `byord`; accepting it +# here would collapse the distinction between the two vocabularies +expect_error( + tinyplot(val ~ year | grp, data = dat, + type = type_area(stack = TRUE, byord = c("C", "A", "B"))), + pattern = "must be NULL" +) +expect_error( + tinyplot(val ~ year | grp, data = dat, + type = type_area(stack = TRUE, byord = 3:1)), + pattern = "must be NULL" +) diff --git a/man/type_ribbon.Rd b/man/type_ribbon.Rd index beac596b8..86ad5acdd 100644 --- a/man/type_ribbon.Rd +++ b/man/type_ribbon.Rd @@ -5,7 +5,7 @@ \alias{type_ribbon} \title{Ribbon and area plot types} \usage{ -type_area(alpha = NULL, stack = FALSE, bylevels = NULL, FUN = NULL) +type_area(alpha = NULL, stack = FALSE, byord = NULL, FUN = NULL) type_ribbon(alpha = NULL, dodge = 0, fixed.dodge = FALSE) } @@ -20,19 +20,19 @@ another, rather than overplotted from a common zero baseline? Only relevant for grouped area plots. Default is \code{FALSE}. See the "Stacked area plots" section below.} -\item{bylevels}{controls the order of the \code{by} groups and, thus, the order in -which they stack. Accepts the same values as the \code{xlevels} argument of -\code{\link[=type_points]{type_points()}} and friends (i.e., a character vector of level names, a -numeric vector of level indexes, or the \code{"asis"} keyword). More germanely, -accepts three additional keywords that rank groups according to their \code{y} -values along the \code{x} axis: \code{"start"}, \code{"end"}, and \code{"total"} (i.e., summed -\code{y} values over the full \code{x} axis range). These three options are -especially convenient for (re)ordering the layers of a stacked area plot on -the fly. Users can also pass their own custom function to determine both -the ranking statistic and its direction, e.g. \code{function(y) -median(y)} -would layer by median \code{y} value, from the biggest to the smallest. Default -is \code{NULL}, in which case the existing factor level order is retained. See -Examples, as well as the "Stacked area plots" section below.} +\item{byord}{controls the order of the \code{by} groups and, thus, the order in +which they stack. Three keywords rank the groups according to their \code{y} +values along the \code{x} axis: \code{"start"} (value at the smallest \code{x}), \code{"end"} +(value at the largest \code{x}), and \code{"total"} (summed \code{y} values over the full +\code{x} axis range). Each ranks the largest group first, i.e. into the bottom +band. A fourth keyword, \code{"asis"}, takes the groups in the order that they +appear in the data. Users can also pass their own custom function to +determine both the ranking statistic and its direction, e.g. +\code{function(y) -median(y)} would layer by median \code{y} value, from the biggest +to the smallest. Default is \code{NULL}, in which case the existing factor level +order is retained; to set that order explicitly, call +\code{factor(levels = )} on the grouping variable beforehand. See Examples, as +well as the "Stacked area plots" section below.} \item{FUN}{a function for collapsing repeated \code{y} values within a group and \code{x} position, used only when \code{stack = TRUE}. Defaults to \code{mean}, matching @@ -86,13 +86,13 @@ the order of their (factor) levels, so the first level forms the bottom band and the top of the final band traces the group total. Stacking is computed separately within each facet. -The \code{bylevels} argument is a helpful companion to stacked area plots, since -it controls which group ends up where. While it accepts various inputs, the -most useful are the three positional keywords: \code{"start"}, \code{"end"}, and -\code{"total"}. These rank the stacked \code{by} groups according to their \code{y} -values at the designated position along the \code{x} axis. Following convention, -the ranking runs in descending order, so that the biggest group is drawn -on the bottom layer to provide a stable visual baseline. +The \code{byord} argument is a helpful companion to stacked area plots, since +it controls which group ends up where. Most useful are the three positional +keywords: \code{"start"}, \code{"end"}, and \code{"total"}. These rank the stacked \code{by} +groups according to their \code{y} values at the designated position along the +\code{x} axis. Following convention, the ranking runs in descending order, so that +the biggest group is drawn on the bottom layer to provide a stable visual +baseline. Stacking needs exactly one \code{y} value per group per \code{x} value. Repeated cells ---typically caused by a variable that is present in the data but absent from @@ -183,12 +183,12 @@ dat$val = abs(sin(dat$year / 3) + as.integer(dat$grp)) + 1 tinyplot(val ~ year | grp, data = dat, type = type_area(stack = TRUE)) -# Use `bylevels` to control which group stacks where. Here we stack by their +# Use `byord` to control which group stacks where. Here we stack by their # largest end value. tinyplot( val ~ year | grp, data = dat, - type = type_area(stack = TRUE, bylevels = "end") + type = type_area(stack = TRUE, byord = "end") ) # Stacking expects a single `y` value per group per `x` value. Any repeats From 981f0478cf9d3c56dd8478db96851ce4959e70d1 Mon Sep 17 00:00:00 2001 From: Grant McDermott Date: Sun, 23 Aug 2026 13:09:09 -0700 Subject: [PATCH 10/12] doc tweaks --- NEWS.md | 13 +++--- R/type_ribbon.R | 100 +++++++++++++++++++++++---------------------- man/type_ribbon.Rd | 100 +++++++++++++++++++++++---------------------- 3 files changed, 111 insertions(+), 102 deletions(-) diff --git a/NEWS.md b/NEWS.md index dfb7b749f..609371a0b 100644 --- a/NEWS.md +++ b/NEWS.md @@ -62,17 +62,18 @@ enable finer control and customization of faceted plots: concatenating via the default `":"`. (#684 @grantmcdermott) Note that each of these `facet.args` arguments is paired with an equivalent -`tpar(facet.)` parameter. For example, call `tpar(facet.axes = "outer")` +`tpar(facet.)` parameter. For example, call `tpar(facet.axes = "outer")` to set this behaviour globally. This also means that they can be set as part of a (custom) theme, e.g. `tinytheme("clean", facet.axes = "outer")`. #### Other new features -- `type_area()` gains a `stack` argument for stacked area plots, plus `byord` - to control the stacking order (`"start"`, `"end"` and `"total"` rank groups by - size, largest onto the baseline) and `FUN` to collapse repeated `y` values. - Stacked types list their legend keys bottom-up, via the new - `"legend_reversed"` entry in `type_hints`. (#688 @grantmcdermott) +- `type_area()` gains a `stack` argument for stacked area plots. A sister + `byord` argument enables convenient, on-the-fly (re-)ordering of stacking + layers through convenience keywords or custom functions (e.g., + `byord = "end"` ranks groups according to their largest final value). + Similarly, a new `FUN` argument permits stacking of multi-observation data by + collapsing repeated `y` values. (#688 @grantmcdermott) - `type_points()`, `type_lines()`, `type_errorbar()`, and `type_pointrange()` gain an `xlevels` argument for reordering a categorical `x` variable on the fly (matching existing functionality for `type_barplot()` and several other diff --git a/R/type_ribbon.R b/R/type_ribbon.R index 080ed40d6..921ee47e6 100644 --- a/R/type_ribbon.R +++ b/R/type_ribbon.R @@ -8,19 +8,20 @@ #' another, rather than overplotted from a common zero baseline? Only #' relevant for grouped area plots. Default is `FALSE`. See the "Stacked #' area plots" section below. -#' @param byord controls the order of the `by` groups and, thus, the order in -#' which they stack. Three keywords rank the groups according to their `y` -#' values along the `x` axis: `"start"` (value at the smallest `x`), `"end"` -#' (value at the largest `x`), and `"total"` (summed `y` values over the full -#' `x` axis range). Each ranks the largest group first, i.e. into the bottom -#' band. A fourth keyword, `"asis"`, takes the groups in the order that they -#' appear in the data. Users can also pass their own custom function to -#' determine both the ranking statistic and its direction, e.g. -#' `function(y) -median(y)` would layer by median `y` value, from the biggest -#' to the smallest. Default is `NULL`, in which case the existing factor level -#' order is retained; to set that order explicitly, call -#' `factor(levels = )` on the grouping variable beforehand. See Examples, as -#' well as the "Stacked area plots" section below. +#' @param byord keyword string or function. Permits on-the-fly (re)ordering of +#' the `by` group layers, thus controlling the order in which they stack. +#' Three keywords rank the groups according to their `y` values along the `x` +#' axis: `"start"` (value at the smallest `x`), `"end"` (value at the largest +#' `x`), and `"total"` (summed `y` values over the full `x` axis range). Each +#' ranks the largest group first, i.e. into the bottom band. A fourth +#' keyword, `"asis"`, takes the groups in the order that they appear in the +#' data. Users can also pass their own custom function to determine both the +#' ranking statistic and its direction, e.g. `function(y) -median(y)` would +#' layer by median `y` value, from the biggest to the smallest. Default is +#' `NULL`, in which case the existing factor level order is retained; to set +#' that order explicitly, call `factor(levels = )` on the grouping variable +#' beforehand. See Examples, as well as the "Stacked area plots" section +#' below. #' @param FUN a function for collapsing repeated `y` values within a group and #' `x` position, used only when `stack = TRUE`. Defaults to `mean`, matching #' [`type_barplot()`], so that the same data stacks to the same heights @@ -48,13 +49,13 @@ #' and the top of the final band traces the group total. Stacking is computed #' separately within each facet. #' -#' The `byord` argument is a helpful companion to stacked area plots, since -#' it controls which group ends up where. Most useful are the three positional -#' keywords: `"start"`, `"end"`, and `"total"`. These rank the stacked `by` -#' groups according to their `y` values at the designated position along the -#' `x` axis. Following convention, the ranking runs in descending order, so that -#' the biggest group is drawn on the bottom layer to provide a stable visual -#' baseline. +#' The `byord` argument is a helpful companion to stacked area plots, since it +#' enables on-the-fly adjustment of the stacking order. Most useful are the +#' three positional keywords: `"start"`, `"end"`, and `"total"`. These rank the +#' stacked `by` groups according to their `y` values at the designated position +#' along the `x` axis. Following convention, the ranking runs in descending +#' order, so that the biggest group is drawn on the bottom layer to provide a +#' stable visual baseline. #' #' Stacking needs exactly one `y` value per group per `x` value. Repeated cells #' ---typically caused by a variable that is present in the data but absent from @@ -106,6 +107,37 @@ #' tinyplot(AirPassengers, type = "area") #' #' # +#' ## Stacked area plots +#' +#' # Grouped area plots can be stacked cumulatively, rather than being drawn +#' # from a common zero baseline. +#' +#' dat = expand.grid(year = 2000:2020, grp = factor(c("A", "B", "C"))) +#' dat$val = abs(sin(dat$year / 3) + as.integer(dat$grp)) + 1 +#' +#' tinyplot(val ~ year | grp, data = dat, type = type_area(stack = TRUE)) +#' +#' # Use `byord` to control which group stacks where. Here we stack by their +#' # largest end value. +#' +#' tinyplot( +#' val ~ year | grp, data = dat, +#' type = type_area(stack = TRUE, byord = "end") +#' ) +#' +#' # Stacking expects a single `y` value per group per `x` value. Any repeats +#' # are collapsed for us first, using `FUN` (`mean` by default). Here, for +#' # instance, ChickWeight records many chicks per diet at each timepoint. +#' +#' tinyplot( +#' weight ~ Time | Diet, data = ChickWeight, +#' type = type_area(stack = TRUE, FUN = median) +#' ) +#' +#' # (Illustrative purposes aside, we leave it to the reader to decide whether +#' # stacking separate diets on top of one another makes any sense...) +#' +#' # #' ## Dodged ribbon/area plots #' #' # Dodged ribbon or area plots can be useful in cases where there is strong @@ -135,34 +167,6 @@ #' main = "Dodged ribbons" #' ) #' -#' # -#' ## Stacked area plots -#' -#' # Grouped area plots can be stacked cumulatively, rather than being drawn -#' # from a common zero baseline. -#' -#' dat = expand.grid(year = 2000:2020, grp = factor(c("A", "B", "C"))) -#' dat$val = abs(sin(dat$year / 3) + as.integer(dat$grp)) + 1 -#' -#' tinyplot(val ~ year | grp, data = dat, type = type_area(stack = TRUE)) -#' -#' # Use `byord` to control which group stacks where. Here we stack by their -#' # largest end value. -#' -#' tinyplot( -#' val ~ year | grp, data = dat, -#' type = type_area(stack = TRUE, byord = "end") -#' ) -#' -#' # Stacking expects a single `y` value per group per `x` value. Any repeats -#' # are collapsed for us first, using `FUN` (`mean` by default). Here, for -#' # instance, ChickWeight records many chicks per diet at each timepoint. -#' -#' tinyplot( -#' weight ~ Time | Diet, data = ChickWeight, -#' type = type_area(stack = TRUE, FUN = median) -#' ) -#' #' @export type_ribbon = function(alpha = NULL, dodge = 0, fixed.dodge = FALSE) { out = list( diff --git a/man/type_ribbon.Rd b/man/type_ribbon.Rd index 86ad5acdd..d9b5ccda6 100644 --- a/man/type_ribbon.Rd +++ b/man/type_ribbon.Rd @@ -20,19 +20,20 @@ another, rather than overplotted from a common zero baseline? Only relevant for grouped area plots. Default is \code{FALSE}. See the "Stacked area plots" section below.} -\item{byord}{controls the order of the \code{by} groups and, thus, the order in -which they stack. Three keywords rank the groups according to their \code{y} -values along the \code{x} axis: \code{"start"} (value at the smallest \code{x}), \code{"end"} -(value at the largest \code{x}), and \code{"total"} (summed \code{y} values over the full -\code{x} axis range). Each ranks the largest group first, i.e. into the bottom -band. A fourth keyword, \code{"asis"}, takes the groups in the order that they -appear in the data. Users can also pass their own custom function to -determine both the ranking statistic and its direction, e.g. -\code{function(y) -median(y)} would layer by median \code{y} value, from the biggest -to the smallest. Default is \code{NULL}, in which case the existing factor level -order is retained; to set that order explicitly, call -\code{factor(levels = )} on the grouping variable beforehand. See Examples, as -well as the "Stacked area plots" section below.} +\item{byord}{keyword string or function. Permits on-the-fly (re)ordering of +the \code{by} group layers, thus controlling the order in which they stack. +Three keywords rank the groups according to their \code{y} values along the \code{x} +axis: \code{"start"} (value at the smallest \code{x}), \code{"end"} (value at the largest +\code{x}), and \code{"total"} (summed \code{y} values over the full \code{x} axis range). Each +ranks the largest group first, i.e. into the bottom band. A fourth +keyword, \code{"asis"}, takes the groups in the order that they appear in the +data. Users can also pass their own custom function to determine both the +ranking statistic and its direction, e.g. \code{function(y) -median(y)} would +layer by median \code{y} value, from the biggest to the smallest. Default is +\code{NULL}, in which case the existing factor level order is retained; to set +that order explicitly, call \code{factor(levels = )} on the grouping variable +beforehand. See Examples, as well as the "Stacked area plots" section +below.} \item{FUN}{a function for collapsing repeated \code{y} values within a group and \code{x} position, used only when \code{stack = TRUE}. Defaults to \code{mean}, matching @@ -86,13 +87,13 @@ the order of their (factor) levels, so the first level forms the bottom band and the top of the final band traces the group total. Stacking is computed separately within each facet. -The \code{byord} argument is a helpful companion to stacked area plots, since -it controls which group ends up where. Most useful are the three positional -keywords: \code{"start"}, \code{"end"}, and \code{"total"}. These rank the stacked \code{by} -groups according to their \code{y} values at the designated position along the -\code{x} axis. Following convention, the ranking runs in descending order, so that -the biggest group is drawn on the bottom layer to provide a stable visual -baseline. +The \code{byord} argument is a helpful companion to stacked area plots, since it +enables on-the-fly adjustment of the stacking order. Most useful are the +three positional keywords: \code{"start"}, \code{"end"}, and \code{"total"}. These rank the +stacked \code{by} groups according to their \code{y} values at the designated position +along the \code{x} axis. Following convention, the ranking runs in descending +order, so that the biggest group is drawn on the bottom layer to provide a +stable visual baseline. Stacking needs exactly one \code{y} value per group per \code{x} value. Repeated cells ---typically caused by a variable that is present in the data but absent from @@ -142,6 +143,37 @@ tinyplot(x, y, type = type_area()) # Area plots are often used for time series charts tinyplot(AirPassengers, type = "area") +# +## Stacked area plots + +# Grouped area plots can be stacked cumulatively, rather than being drawn +# from a common zero baseline. + +dat = expand.grid(year = 2000:2020, grp = factor(c("A", "B", "C"))) +dat$val = abs(sin(dat$year / 3) + as.integer(dat$grp)) + 1 + +tinyplot(val ~ year | grp, data = dat, type = type_area(stack = TRUE)) + +# Use `byord` to control which group stacks where. Here we stack by their +# largest end value. + +tinyplot( + val ~ year | grp, data = dat, + type = type_area(stack = TRUE, byord = "end") +) + +# Stacking expects a single `y` value per group per `x` value. Any repeats +# are collapsed for us first, using `FUN` (`mean` by default). Here, for +# instance, ChickWeight records many chicks per diet at each timepoint. + +tinyplot( + weight ~ Time | Diet, data = ChickWeight, + type = type_area(stack = TRUE, FUN = median) +) + +# (Illustrative purposes aside, we leave it to the reader to decide whether +# stacking separate diets on top of one another makes any sense...) + # ## Dodged ribbon/area plots @@ -172,32 +204,4 @@ tinyplot( main = "Dodged ribbons" ) -# -## Stacked area plots - -# Grouped area plots can be stacked cumulatively, rather than being drawn -# from a common zero baseline. - -dat = expand.grid(year = 2000:2020, grp = factor(c("A", "B", "C"))) -dat$val = abs(sin(dat$year / 3) + as.integer(dat$grp)) + 1 - -tinyplot(val ~ year | grp, data = dat, type = type_area(stack = TRUE)) - -# Use `byord` to control which group stacks where. Here we stack by their -# largest end value. - -tinyplot( - val ~ year | grp, data = dat, - type = type_area(stack = TRUE, byord = "end") -) - -# Stacking expects a single `y` value per group per `x` value. Any repeats -# are collapsed for us first, using `FUN` (`mean` by default). Here, for -# instance, ChickWeight records many chicks per diet at each timepoint. - -tinyplot( - weight ~ Time | Diet, data = ChickWeight, - type = type_area(stack = TRUE, FUN = median) -) - } From b1e71b4513e7b5c5461de5f5a9e0824129f1db4a Mon Sep 17 00:00:00 2001 From: Grant McDermott Date: Sun, 23 Aug 2026 14:37:37 -0700 Subject: [PATCH 11/12] "minvar" --- NEWS.md | 5 +- R/sanitize_ord.R | 36 ++++++-- R/type_ribbon.R | 49 ++++++++-- inst/tinytest/_tinysnapshot/area_grouped.svg | 36 ++++---- inst/tinytest/_tinysnapshot/area_stack.svg | 38 ++++---- .../_tinysnapshot/area_stack_alpha.svg | 38 ++++---- .../_tinysnapshot/area_stack_byord_end.svg | 42 ++++----- .../_tinysnapshot/area_stack_byord_fun_x.svg | 89 +++++++++++++++++++ .../_tinysnapshot/area_stack_byord_minvar.svg | 89 +++++++++++++++++++ .../_tinysnapshot/area_stack_facet_ragged.svg | 76 ++++++++-------- .../_tinysnapshot/area_stack_flip.svg | 38 ++++---- .../area_stack_legend_bottom.svg | 38 ++++---- inst/tinytest/test-type_area.R | 34 ++++++- man/type_ribbon.Rd | 49 ++++++++-- 14 files changed, 480 insertions(+), 177 deletions(-) create mode 100644 inst/tinytest/_tinysnapshot/area_stack_byord_fun_x.svg create mode 100644 inst/tinytest/_tinysnapshot/area_stack_byord_minvar.svg diff --git a/NEWS.md b/NEWS.md index 609371a0b..28d202b33 100644 --- a/NEWS.md +++ b/NEWS.md @@ -71,7 +71,10 @@ a (custom) theme, e.g. `tinytheme("clean", facet.axes = "outer")`. - `type_area()` gains a `stack` argument for stacked area plots. A sister `byord` argument enables convenient, on-the-fly (re-)ordering of stacking layers through convenience keywords or custom functions (e.g., - `byord = "end"` ranks groups according to their largest final value). + `byord = "end"` ranks groups according to their largest final value, while + `byord = "minvar"` puts the lowest variance group on the baseline). Custom + functions may additionally name an `x` argument to receive the group's `x` + values, as needed by any statistic that depends on their spacing. Similarly, a new `FUN` argument permits stacking of multi-observation data by collapsing repeated `y` values. (#688 @grantmcdermott) - `type_points()`, `type_lines()`, `type_errorbar()`, and `type_pointrange()` diff --git a/R/sanitize_ord.R b/R/sanitize_ord.R index 6a4ffc0a6..1f824bd8e 100644 --- a/R/sanitize_ord.R +++ b/R/sanitize_ord.R @@ -7,6 +7,7 @@ ## - "start": rank by the group's y value at the smallest x ## - "end": rank by the group's y value at the largest x ## - "total": rank by the group's summed y across every x +## - "minvar": rank by the group's variance, least variable first ## ## ... and, for anything else, a function that is handed each group's y values ## (ordered by x) and returns a single number to sort *ascending* on. So @@ -14,8 +15,18 @@ ## it. This is the escape hatch for the reverse direction, and for statistics we ## don't have a keyword for (`function(y) -median(y)`, etc.). ## +## A function that declares a formal named `x` also receives that group's x +## values, which is what any statistic depending on the spacing between +## observations needs -- a slope, say: `function(y, x) coef(lm(y ~ x))[2]`. +## Keying on the *name* rather than the number of formals is deliberate: it +## keeps a tuning parameter carrying a default, e.g. `function(y, p = 0.9)`, +## from being silently handed x. x is passed by name, so the two arguments may +## be declared in either order. +## ## The three size keywords rank largest first, i.e. into the first level, which -## is the bottom band of a stacked area. +## is the bottom band of a stacked area. "minvar" ranks the *other* way -- +## smallest first -- because there the stable baseline is the calm group, not +## the big one. Both directions serve the same end. ## ## Explicit level names or indexes are deliberately *not* accepted here -- that ## is what sanitize_xlevels() is for, and letting both arguments take the same @@ -33,7 +44,7 @@ ## category: in the degenerate case of a group literally called "end", set the ## factor levels beforehand instead. -ord_keywords = c("asis", "start", "end", "total") +ord_keywords = c("asis", "start", "end", "total", "minvar") sanitize_ord = function(v, y, x, ord, arg = "ord") { if (is.null(ord) || !is.factor(v)) { @@ -56,7 +67,14 @@ sanitize_ord = function(v, y, x, ord, arg = "ord") { return(factor(v, levels = unique(v))) } - if (keyword) { + if (identical(ord, "minvar")) { + # Ascending, i.e. *not* negated like the size keywords below: a stacked + # baseline is steadiest when the least variable group sits on it, since + # every band above inherits its movement. Groups too short to have a + # variance give NA and sort last (to the top), which is the right place + # for them anyway. + stat = tapply(y, v, function(z) var(z, na.rm = TRUE), default = NA_real_) + } else if (keyword) { if (identical(ord, "total")) { keep = rep.int(TRUE, length(x)) } else { @@ -68,9 +86,17 @@ sanitize_ord = function(v, y, x, ord, arg = "ord") { } else { xord = order(x) grps = split(y[xord], v[xord]) + # Hand over x too, but only to functions that ask for it by name; see the + # note at the top of this file. + want_x = "x" %in% names(formals(ord)) + xgrps = if (want_x) split(x[xord], v[xord]) else NULL stat = vapply( - grps, - function(z) if (length(z) == 0L) NA_real_ else as.numeric(ord(z)), + seq_along(grps), + function(i) { + z = grps[[i]] + if (length(z) == 0L) return(NA_real_) + as.numeric(if (want_x) ord(z, x = xgrps[[i]]) else ord(z)) + }, numeric(1) ) } diff --git a/R/type_ribbon.R b/R/type_ribbon.R index 921ee47e6..16bc9b32c 100644 --- a/R/type_ribbon.R +++ b/R/type_ribbon.R @@ -14,10 +14,16 @@ #' axis: `"start"` (value at the smallest `x`), `"end"` (value at the largest #' `x`), and `"total"` (summed `y` values over the full `x` axis range). Each #' ranks the largest group first, i.e. into the bottom band. A fourth -#' keyword, `"asis"`, takes the groups in the order that they appear in the -#' data. Users can also pass their own custom function to determine both the -#' ranking statistic and its direction, e.g. `function(y) -median(y)` would -#' layer by median `y` value, from the biggest to the smallest. Default is +#' keyword, `"minvar"`, instead ranks by variance and puts the *least* +#' variable group on the baseline, which is often the steadier choice since +#' every band inherits the movement of those below it. A fifth, `"asis"`, +#' takes the groups in the order that they appear in the data. Users can also +#' pass their own custom function to determine both the ranking statistic and +#' its direction, e.g. `function(y) -median(y)` would layer by median `y` +#' value, from the biggest to the smallest; name one of its arguments `x` and +#' it will additionally receive that group's `x` values, as needed by any +#' statistic that depends on their spacing (e.g. +#' `function(y, x) coef(lm(y ~ x))[2]` to layer by trend). Default is #' `NULL`, in which case the existing factor level order is retained; to set #' that order explicitly, call `factor(levels = )` on the grouping variable #' beforehand. See Examples, as well as the "Stacked area plots" section @@ -50,12 +56,16 @@ #' separately within each facet. #' #' The `byord` argument is a helpful companion to stacked area plots, since it -#' enables on-the-fly adjustment of the stacking order. Most useful are the -#' three positional keywords: `"start"`, `"end"`, and `"total"`. These rank the +#' enables on-the-fly adjustment of the stacking order. For example, +#' three positional keywords---`"start"`, `"end"`, and `"total"`---rank the #' stacked `by` groups according to their `y` values at the designated position #' along the `x` axis. Following convention, the ranking runs in descending -#' order, so that the biggest group is drawn on the bottom layer to provide a -#' stable visual baseline. +#' order, so that the biggest group is drawn on the bottom layer. However, size +#' is not the only route to a stable baseline, though. Because each band is +#' drawn on top of the ones below it, they all inherit whatever movement the +#' bottom layer has; a large but volatile group can therefore be a worse choice +#' of foundation than a small, steady one. The `"minvar"` keyword ranks by +#' variance instead, placing the least variable group at the bottom. #' #' Stacking needs exactly one `y` value per group per `x` value. Repeated cells #' ---typically caused by a variable that is present in the data but absent from @@ -112,8 +122,11 @@ #' # Grouped area plots can be stacked cumulatively, rather than being drawn #' # from a common zero baseline. #' +#' # Group B is small and steady; A and C are larger and wobblier. #' dat = expand.grid(year = 2000:2020, grp = factor(c("A", "B", "C"))) -#' dat$val = abs(sin(dat$year / 3) + as.integer(dat$grp)) + 1 +#' dat$val = as.integer(dat$grp) + +#' c(1.2, 0.1, 1.8)[dat$grp] * sin(dat$year / 3) + +#' c(0.06, 0.02, 0.10)[dat$grp] * (dat$year - 2000) #' #' tinyplot(val ~ year | grp, data = dat, type = type_area(stack = TRUE)) #' @@ -125,6 +138,24 @@ #' type = type_area(stack = TRUE, byord = "end") #' ) #' +#' # `"minvar"` instead puts the *least variable* group on the baseline. Every +#' # band inherits the movement of the ones below it, so a steady bottom layer +#' # keeps the whole chart legible. Here that picks group B, which the default +#' # level order leaves in the middle and `"end"`/`"total"` push to the top. +#' +#' tinyplot( +#' val ~ year | grp, data = dat, +#' type = type_area(stack = TRUE, byord = "minvar") +#' ) +#' +#' # Custom ranking functions are also accepted. Name an argument `x` and it +#' # receives the group's x values too, which is what a slope needs. +#' +#' tinyplot( +#' val ~ year | grp, data = dat, +#' type = type_area(stack = TRUE, byord = function(y, x) coef(lm(y ~ x))[2]) +#' ) +#' #' # Stacking expects a single `y` value per group per `x` value. Any repeats #' # are collapsed for us first, using `FUN` (`mean` by default). Here, for #' # instance, ChickWeight records many chicks per diet at each timepoint. diff --git a/inst/tinytest/_tinysnapshot/area_grouped.svg b/inst/tinytest/_tinysnapshot/area_grouped.svg index ff18270e6..e1e02402a 100644 --- a/inst/tinytest/_tinysnapshot/area_grouped.svg +++ b/inst/tinytest/_tinysnapshot/area_grouped.svg @@ -55,19 +55,21 @@ 2010 2015 2020 - + - - - - - + + + + + + 0 -1 -2 -3 -4 -5 +1 +2 +3 +4 +5 +6 @@ -76,12 +78,12 @@ - - - - - - + + + + + + diff --git a/inst/tinytest/_tinysnapshot/area_stack.svg b/inst/tinytest/_tinysnapshot/area_stack.svg index b3284ec9d..cd336f1aa 100644 --- a/inst/tinytest/_tinysnapshot/area_stack.svg +++ b/inst/tinytest/_tinysnapshot/area_stack.svg @@ -55,21 +55,21 @@ 2010 2015 2020 - + - - - - - - + + + + + + 0 -2 -4 -6 -8 -10 -12 +2 +4 +6 +8 +10 +12 @@ -78,12 +78,12 @@ - - - - - - + + + + + + diff --git a/inst/tinytest/_tinysnapshot/area_stack_alpha.svg b/inst/tinytest/_tinysnapshot/area_stack_alpha.svg index f3ff037f0..4f98b407b 100644 --- a/inst/tinytest/_tinysnapshot/area_stack_alpha.svg +++ b/inst/tinytest/_tinysnapshot/area_stack_alpha.svg @@ -55,21 +55,21 @@ 2010 2015 2020 - + - - - - - - + + + + + + 0 -2 -4 -6 -8 -10 -12 +2 +4 +6 +8 +10 +12 @@ -78,12 +78,12 @@ - - - - - - + + + + + + diff --git a/inst/tinytest/_tinysnapshot/area_stack_byord_end.svg b/inst/tinytest/_tinysnapshot/area_stack_byord_end.svg index 9c4f5db8a..705c1bd40 100644 --- a/inst/tinytest/_tinysnapshot/area_stack_byord_end.svg +++ b/inst/tinytest/_tinysnapshot/area_stack_byord_end.svg @@ -30,8 +30,8 @@ grp -A -B +B +A C @@ -55,21 +55,21 @@ 2010 2015 2020 - + - - - - - - + + + + + + 0 -2 -4 -6 -8 -10 -12 +2 +4 +6 +8 +10 +12 @@ -78,12 +78,12 @@ - - - - - - + + + + + + diff --git a/inst/tinytest/_tinysnapshot/area_stack_byord_fun_x.svg b/inst/tinytest/_tinysnapshot/area_stack_byord_fun_x.svg new file mode 100644 index 000000000..cd336f1aa --- /dev/null +++ b/inst/tinytest/_tinysnapshot/area_stack_byord_fun_x.svg @@ -0,0 +1,89 @@ + + + + + + + + + + + + + + + + +grp +C +B +A + + + + + + + +year +val + + + + + + + + +2000 +2005 +2010 +2015 +2020 + + + + + + + + +0 +2 +4 +6 +8 +10 +12 + + + + + + + + + + + + + + + + + diff --git a/inst/tinytest/_tinysnapshot/area_stack_byord_minvar.svg b/inst/tinytest/_tinysnapshot/area_stack_byord_minvar.svg new file mode 100644 index 000000000..48067228d --- /dev/null +++ b/inst/tinytest/_tinysnapshot/area_stack_byord_minvar.svg @@ -0,0 +1,89 @@ + + + + + + + + + + + + + + + + +grp +C +A +B + + + + + + + +year +val + + + + + + + + +2000 +2005 +2010 +2015 +2020 + + + + + + + + +0 +2 +4 +6 +8 +10 +12 + + + + + + + + + + + + + + + + + diff --git a/inst/tinytest/_tinysnapshot/area_stack_facet_ragged.svg b/inst/tinytest/_tinysnapshot/area_stack_facet_ragged.svg index 5baa15a09..d7f368562 100644 --- a/inst/tinytest/_tinysnapshot/area_stack_facet_ragged.svg +++ b/inst/tinytest/_tinysnapshot/area_stack_facet_ragged.svg @@ -62,21 +62,21 @@ 2010 2015 2020 - + - - - - - - + + + + + + 0 -2 -4 -6 -8 -10 -12 +2 +4 +6 +8 +10 +12 early @@ -100,40 +100,40 @@ 2010 2015 2020 - + - - - - - - + + + + + + 0 -2 -4 -6 -8 -10 -12 +2 +4 +6 +8 +10 +12 late - - - - - - + + + + + + - - - - - - + + + + + + diff --git a/inst/tinytest/_tinysnapshot/area_stack_flip.svg b/inst/tinytest/_tinysnapshot/area_stack_flip.svg index 4e16ddd18..6836bee93 100644 --- a/inst/tinytest/_tinysnapshot/area_stack_flip.svg +++ b/inst/tinytest/_tinysnapshot/area_stack_flip.svg @@ -44,21 +44,21 @@ year - + - - - - - - + + + + + + 0 -2 -4 -6 -8 -10 -12 +2 +4 +6 +8 +10 +12 @@ -78,12 +78,12 @@ - - - - - - + + + + + + diff --git a/inst/tinytest/_tinysnapshot/area_stack_legend_bottom.svg b/inst/tinytest/_tinysnapshot/area_stack_legend_bottom.svg index ffc6174b0..98710a96c 100644 --- a/inst/tinytest/_tinysnapshot/area_stack_legend_bottom.svg +++ b/inst/tinytest/_tinysnapshot/area_stack_legend_bottom.svg @@ -55,21 +55,21 @@ 2010 2015 2020 - + - - - - - - + + + + + + 0 -2 -4 -6 -8 -10 -12 +2 +4 +6 +8 +10 +12 @@ -78,12 +78,12 @@ - - - - - - + + + + + + diff --git a/inst/tinytest/test-type_area.R b/inst/tinytest/test-type_area.R index 3508f490b..23ea42b4e 100644 --- a/inst/tinytest/test-type_area.R +++ b/inst/tinytest/test-type_area.R @@ -3,8 +3,13 @@ using("tinysnapshot") ucb = as.data.frame(UCBAdmissions) +# group B is small and steady; A and C are larger and wobblier, so that the +# default level order, the size keywords, and "minvar" each pick a different +# bottom band dat = expand.grid(year = 2000:2020, grp = factor(c("A", "B", "C"))) -dat$val = abs(sin(dat$year / 3) + as.integer(dat$grp)) + 1 +dat$val = as.integer(dat$grp) + + c(1.2, 0.1, 1.8)[dat$grp] * sin(dat$year / 3) + + c(0.06, 0.02, 0.10)[dat$grp] * (dat$year - 2000) # @@ -96,6 +101,21 @@ f = function() { expect_snapshot_plot(f, label = "area_factor_x") +# "minvar" ranks the other way to the size keywords -- least variable onto the +# baseline -- so it must pick a different bottom band here than "end" does +f = function() { + tinyplot(val ~ year | grp, data = dat, type = type_area(stack = TRUE, byord = "minvar")) +} +expect_snapshot_plot(f, label = "area_stack_byord_minvar") + +# a ranking function that names an `x` argument receives the group's x values, +# without which a slope cannot be computed against uneven spacing +f = function() { + tinyplot(val ~ year | grp, data = dat, + type = type_area(stack = TRUE, byord = function(y, x) coef(lm(y ~ x))[2])) +} +expect_snapshot_plot(f, label = "area_stack_byord_fun_x") + # ## byord rejects explicit levels ----- @@ -111,3 +131,15 @@ expect_error( type = type_area(stack = TRUE, byord = 3:1)), pattern = "must be NULL" ) + +# a one-argument function keeps working unchanged, and a second argument that +# is *not* named `x` (e.g. a tuning parameter with a default) must not be fed +# the x values by mistake +dp = function(byord) { + d = data.frame(x = rep(1:4, 2), y = c(1, 2, 3, 100, 4, 4, 4, 4), + by = factor(rep(c("a", "b"), each = 4)), facet = "f") + levels(tinyplot:::sanitize_ord(d$by, d$y, d$x, byord)) +} +expect_equal(dp(function(y) -median(y)), c("b", "a")) +expect_equal(dp(function(y, p = 0.9) -as.numeric(quantile(y, p))), c("a", "b")) +expect_equal(dp(function(y, x) coef(lm(y ~ x))[2]), c("b", "a")) diff --git a/man/type_ribbon.Rd b/man/type_ribbon.Rd index d9b5ccda6..7bb84b1dd 100644 --- a/man/type_ribbon.Rd +++ b/man/type_ribbon.Rd @@ -26,10 +26,16 @@ Three keywords rank the groups according to their \code{y} values along the \cod axis: \code{"start"} (value at the smallest \code{x}), \code{"end"} (value at the largest \code{x}), and \code{"total"} (summed \code{y} values over the full \code{x} axis range). Each ranks the largest group first, i.e. into the bottom band. A fourth -keyword, \code{"asis"}, takes the groups in the order that they appear in the -data. Users can also pass their own custom function to determine both the -ranking statistic and its direction, e.g. \code{function(y) -median(y)} would -layer by median \code{y} value, from the biggest to the smallest. Default is +keyword, \code{"minvar"}, instead ranks by variance and puts the \emph{least} +variable group on the baseline, which is often the steadier choice since +every band inherits the movement of those below it. A fifth, \code{"asis"}, +takes the groups in the order that they appear in the data. Users can also +pass their own custom function to determine both the ranking statistic and +its direction, e.g. \code{function(y) -median(y)} would layer by median \code{y} +value, from the biggest to the smallest; name one of its arguments \code{x} and +it will additionally receive that group's \code{x} values, as needed by any +statistic that depends on their spacing (e.g. +\code{function(y, x) coef(lm(y ~ x))[2]} to layer by trend). Default is \code{NULL}, in which case the existing factor level order is retained; to set that order explicitly, call \code{factor(levels = )} on the grouping variable beforehand. See Examples, as well as the "Stacked area plots" section @@ -88,12 +94,16 @@ and the top of the final band traces the group total. Stacking is computed separately within each facet. The \code{byord} argument is a helpful companion to stacked area plots, since it -enables on-the-fly adjustment of the stacking order. Most useful are the -three positional keywords: \code{"start"}, \code{"end"}, and \code{"total"}. These rank the +enables on-the-fly adjustment of the stacking order. For example, +three positional keywords---\code{"start"}, \code{"end"}, and \code{"total"}---rank the stacked \code{by} groups according to their \code{y} values at the designated position along the \code{x} axis. Following convention, the ranking runs in descending -order, so that the biggest group is drawn on the bottom layer to provide a -stable visual baseline. +order, so that the biggest group is drawn on the bottom layer. However, size +is not the only route to a stable baseline, though. Because each band is +drawn on top of the ones below it, they all inherit whatever movement the +bottom layer has; a large but volatile group can therefore be a worse choice +of foundation than a small, steady one. The \code{"minvar"} keyword ranks by +variance instead, placing the least variable group at the bottom. Stacking needs exactly one \code{y} value per group per \code{x} value. Repeated cells ---typically caused by a variable that is present in the data but absent from @@ -149,8 +159,11 @@ tinyplot(AirPassengers, type = "area") # Grouped area plots can be stacked cumulatively, rather than being drawn # from a common zero baseline. +# Group B is small and steady; A and C are larger and wobblier. dat = expand.grid(year = 2000:2020, grp = factor(c("A", "B", "C"))) -dat$val = abs(sin(dat$year / 3) + as.integer(dat$grp)) + 1 +dat$val = as.integer(dat$grp) + + c(1.2, 0.1, 1.8)[dat$grp] * sin(dat$year / 3) + + c(0.06, 0.02, 0.10)[dat$grp] * (dat$year - 2000) tinyplot(val ~ year | grp, data = dat, type = type_area(stack = TRUE)) @@ -162,6 +175,24 @@ tinyplot( type = type_area(stack = TRUE, byord = "end") ) +# `"minvar"` instead puts the *least variable* group on the baseline. Every +# band inherits the movement of the ones below it, so a steady bottom layer +# keeps the whole chart legible. Here that picks group B, which the default +# level order leaves in the middle and `"end"`/`"total"` push to the top. + +tinyplot( + val ~ year | grp, data = dat, + type = type_area(stack = TRUE, byord = "minvar") +) + +# Custom ranking functions are also accepted. Name an argument `x` and it +# receives the group's x values too, which is what a slope needs. + +tinyplot( + val ~ year | grp, data = dat, + type = type_area(stack = TRUE, byord = function(y, x) coef(lm(y ~ x))[2]) +) + # Stacking expects a single `y` value per group per `x` value. Any repeats # are collapsed for us first, using `FUN` (`mean` by default). Here, for # instance, ChickWeight records many chicks per diet at each timepoint. From e1fbf2945b32d8fa7ea088174dcf4988d346b9d9 Mon Sep 17 00:00:00 2001 From: Grant McDermott Date: Sun, 23 Aug 2026 15:42:38 -0700 Subject: [PATCH 12/12] "rev" --- NEWS.md | 3 +- R/sanitize_ord.R | 19 +++- R/type_ribbon.R | 57 +++++++----- .../_tinysnapshot/area_stack_byord_rev.svg | 89 +++++++++++++++++++ inst/tinytest/test-type_area.R | 7 ++ man/type_ribbon.Rd | 58 +++++++----- 6 files changed, 186 insertions(+), 47 deletions(-) create mode 100644 inst/tinytest/_tinysnapshot/area_stack_byord_rev.svg diff --git a/NEWS.md b/NEWS.md index 28d202b33..ea6b04cab 100644 --- a/NEWS.md +++ b/NEWS.md @@ -72,7 +72,8 @@ a (custom) theme, e.g. `tinytheme("clean", facet.axes = "outer")`. `byord` argument enables convenient, on-the-fly (re-)ordering of stacking layers through convenience keywords or custom functions (e.g., `byord = "end"` ranks groups according to their largest final value, while - `byord = "minvar"` puts the lowest variance group on the baseline). Custom + `byord = "minvar"` puts the lowest variance group on the baseline, and + `byord = "rev"` simply reverses the existing level order). Custom functions may additionally name an `x` argument to receive the group's `x` values, as needed by any statistic that depends on their spacing. Similarly, a new `FUN` argument permits stacking of multi-observation data by diff --git a/R/sanitize_ord.R b/R/sanitize_ord.R index 1f824bd8e..cde8f18c3 100644 --- a/R/sanitize_ord.R +++ b/R/sanitize_ord.R @@ -4,6 +4,7 @@ ## ## - NULL: keep the existing factor levels (the default) ## - "asis": the categories in the order they appear in the data +## - "rev": the existing factor levels, reversed ## - "start": rank by the group's y value at the smallest x ## - "end": rank by the group's y value at the largest x ## - "total": rank by the group's summed y across every x @@ -23,6 +24,15 @@ ## from being silently handed x. x is passed by name, so the two arguments may ## be declared in either order. ## +## "asis" and "rev" are the two keywords that consult no data at all -- they +## just permute the levels -- so they work when y is absent or non-numeric. +## "rev" is also the one thing a ranking function cannot express: a function is +## handed only its own group's y values, never its group identity or level +## index, so it has no way to say "put me where I already am, backwards". Note +## that it reverses the *existing* level order only; to reverse what another +## keyword computed, negate it with a function instead (`function(y) sum(y)` is +## the reverse of "total"). +## ## The three size keywords rank largest first, i.e. into the first level, which ## is the bottom band of a stacked area. "minvar" ranks the *other* way -- ## smallest first -- because there the stable baseline is the calm group, not @@ -44,7 +54,7 @@ ## category: in the degenerate case of a group literally called "end", set the ## factor levels beforehand instead. -ord_keywords = c("asis", "start", "end", "total", "minvar") +ord_keywords = c("asis", "rev", "start", "end", "total", "minvar") sanitize_ord = function(v, y, x, ord, arg = "ord") { if (is.null(ord) || !is.factor(v)) { @@ -62,10 +72,15 @@ sanitize_ord = function(v, y, x, ord, arg = "ord") { ) } - # "asis" needs no y, and must work when y is absent or non-numeric + # "asis" and "rev" need no y, and must work when y is absent or non-numeric. + # factor() defaults `ordered` to is.ordered(v), so an ordered grouping stays + # ordered (and keeps its sequential palette) through either. if (identical(ord, "asis")) { return(factor(v, levels = unique(v))) } + if (identical(ord, "rev")) { + return(factor(v, levels = rev(levels(v)))) + } if (identical(ord, "minvar")) { # Ascending, i.e. *not* negated like the size keywords below: a stacked diff --git a/R/type_ribbon.R b/R/type_ribbon.R index 16bc9b32c..01765a9e9 100644 --- a/R/type_ribbon.R +++ b/R/type_ribbon.R @@ -10,24 +10,26 @@ #' area plots" section below. #' @param byord keyword string or function. Permits on-the-fly (re)ordering of #' the `by` group layers, thus controlling the order in which they stack. -#' Three keywords rank the groups according to their `y` values along the `x` -#' axis: `"start"` (value at the smallest `x`), `"end"` (value at the largest -#' `x`), and `"total"` (summed `y` values over the full `x` axis range). Each -#' ranks the largest group first, i.e. into the bottom band. A fourth -#' keyword, `"minvar"`, instead ranks by variance and puts the *least* -#' variable group on the baseline, which is often the steadier choice since -#' every band inherits the movement of those below it. A fifth, `"asis"`, -#' takes the groups in the order that they appear in the data. Users can also -#' pass their own custom function to determine both the ranking statistic and -#' its direction, e.g. `function(y) -median(y)` would layer by median `y` -#' value, from the biggest to the smallest; name one of its arguments `x` and -#' it will additionally receive that group's `x` values, as needed by any -#' statistic that depends on their spacing (e.g. -#' `function(y, x) coef(lm(y ~ x))[2]` to layer by trend). Default is -#' `NULL`, in which case the existing factor level order is retained; to set -#' that order explicitly, call `factor(levels = )` on the grouping variable -#' beforehand. See Examples, as well as the "Stacked area plots" section -#' below. +#' Options are: +#' +#' - `"start"`, `"end"`, and `"total"` are positional keywords that rank groups +#' according to their `y` values along the `x` axis. In each case, the group +#' with the largest value is stacked first as the bottom layer. +#' - `"minvar"` ranks by variance and puts the lowest variance group on the +#' baseline. +#' - `"asis"` and `"rev"` permute the existing levels without consulting the +#' data at all. The former takes the groups in the order that they appear in +#' the data, while `"rev"` reverses the current level order. +#' - custom function that determines both the ranking statistic and its +#' direction, e.g. `function(y) -median(y)` would layer by median `y` value, +#' from the biggest to the smallest. Note: if a function requires access to a +#' group's `x` values, then one of its arguments _must_ be named `x`, e.g. +#' `function(y, x) coef(lm(y ~ x))[2]` would layer by trend. +#' +#' Default is `NULL`, in which case the existing factor level order is +#' retained; to set that order explicitly, call `factor(levels = ...)` on the +#' grouping variable beforehand. See Examples, as well as the "Stacked area +#' plots" section below. #' @param FUN a function for collapsing repeated `y` values within a group and #' `x` position, used only when `stack = TRUE`. Defaults to `mean`, matching #' [`type_barplot()`], so that the same data stacks to the same heights @@ -61,11 +63,14 @@ #' stacked `by` groups according to their `y` values at the designated position #' along the `x` axis. Following convention, the ranking runs in descending #' order, so that the biggest group is drawn on the bottom layer. However, size -#' is not the only route to a stable baseline, though. Because each band is +#' is not the only route to a stable baseline. Because each band is #' drawn on top of the ones below it, they all inherit whatever movement the -#' bottom layer has; a large but volatile group can therefore be a worse choice -#' of foundation than a small, steady one. The `"minvar"` keyword ranks by -#' variance instead, placing the least variable group at the bottom. +#' bottom layer has. A large but volatile group can therefore be a worse choice +#' of foundation than a small, steady one. In this latter case, the `"minvar"` +#' keyword would be a more appropriate choice since it places the lowest +#' variance group at the bottom. Your choice of stacking ordering should +#' therefore respond to the patterns in your data and which layers you want to +#' emphasize. #' #' Stacking needs exactly one `y` value per group per `x` value. Repeated cells #' ---typically caused by a variable that is present in the data but absent from @@ -148,6 +153,14 @@ #' type = type_area(stack = TRUE, byord = "minvar") #' ) #' +#' # `"rev"` simply flips the existing level order, which is the one thing a +#' # ranking function cannot do (it never sees which group it was handed). +#' +#' tinyplot( +#' val ~ year | grp, data = dat, +#' type = type_area(stack = TRUE, byord = "rev") +#' ) +#' #' # Custom ranking functions are also accepted. Name an argument `x` and it #' # receives the group's x values too, which is what a slope needs. #' diff --git a/inst/tinytest/_tinysnapshot/area_stack_byord_rev.svg b/inst/tinytest/_tinysnapshot/area_stack_byord_rev.svg new file mode 100644 index 000000000..ec6009f1e --- /dev/null +++ b/inst/tinytest/_tinysnapshot/area_stack_byord_rev.svg @@ -0,0 +1,89 @@ + + + + + + + + + + + + + + + + +grp +A +B +C + + + + + + + +year +val + + + + + + + + +2000 +2005 +2010 +2015 +2020 + + + + + + + + +0 +2 +4 +6 +8 +10 +12 + + + + + + + + + + + + + + + + + diff --git a/inst/tinytest/test-type_area.R b/inst/tinytest/test-type_area.R index 23ea42b4e..37eba7b0b 100644 --- a/inst/tinytest/test-type_area.R +++ b/inst/tinytest/test-type_area.R @@ -108,6 +108,13 @@ f = function() { } expect_snapshot_plot(f, label = "area_stack_byord_minvar") +# "rev" flips the existing level order; unlike every other byord input it +# consults no data, so it also works when `y` is absent or non-numeric +f = function() { + tinyplot(val ~ year | grp, data = dat, type = type_area(stack = TRUE, byord = "rev")) +} +expect_snapshot_plot(f, label = "area_stack_byord_rev") + # a ranking function that names an `x` argument receives the group's x values, # without which a slope cannot be computed against uneven spacing f = function() { diff --git a/man/type_ribbon.Rd b/man/type_ribbon.Rd index 7bb84b1dd..22d1ab7d8 100644 --- a/man/type_ribbon.Rd +++ b/man/type_ribbon.Rd @@ -22,24 +22,27 @@ area plots" section below.} \item{byord}{keyword string or function. Permits on-the-fly (re)ordering of the \code{by} group layers, thus controlling the order in which they stack. -Three keywords rank the groups according to their \code{y} values along the \code{x} -axis: \code{"start"} (value at the smallest \code{x}), \code{"end"} (value at the largest -\code{x}), and \code{"total"} (summed \code{y} values over the full \code{x} axis range). Each -ranks the largest group first, i.e. into the bottom band. A fourth -keyword, \code{"minvar"}, instead ranks by variance and puts the \emph{least} -variable group on the baseline, which is often the steadier choice since -every band inherits the movement of those below it. A fifth, \code{"asis"}, -takes the groups in the order that they appear in the data. Users can also -pass their own custom function to determine both the ranking statistic and -its direction, e.g. \code{function(y) -median(y)} would layer by median \code{y} -value, from the biggest to the smallest; name one of its arguments \code{x} and -it will additionally receive that group's \code{x} values, as needed by any -statistic that depends on their spacing (e.g. -\code{function(y, x) coef(lm(y ~ x))[2]} to layer by trend). Default is -\code{NULL}, in which case the existing factor level order is retained; to set -that order explicitly, call \code{factor(levels = )} on the grouping variable -beforehand. See Examples, as well as the "Stacked area plots" section -below.} +Options are: +\itemize{ +\item \code{"start"}, \code{"end"}, and \code{"total"} are positional keywords that rank groups +according to their \code{y} values along the \code{x} axis. In each case, the group +with the largest value is stacked first as the bottom layer. +\item \code{"minvar"} ranks by variance and puts the lowest variance group on the +baseline. +\item \code{"asis"} and \code{"rev"} permute the existing levels without consulting the +data at all. The former takes the groups in the order that they appear in +the data, while \code{"rev"} reverses the current level order. +\item custom function that determines both the ranking statistic and its +direction, e.g. \code{function(y) -median(y)} would layer by median \code{y} value, +from the biggest to the smallest. Note: if a function requires access to a +group's \code{x} values, then one of its arguments \emph{must} be named \code{x}, e.g. +\code{function(y, x) coef(lm(y ~ x))[2]} would layer by trend. +} + +Default is \code{NULL}, in which case the existing factor level order is +retained; to set that order explicitly, call \code{factor(levels = ...)} on the +grouping variable beforehand. See Examples, as well as the "Stacked area +plots" section below.} \item{FUN}{a function for collapsing repeated \code{y} values within a group and \code{x} position, used only when \code{stack = TRUE}. Defaults to \code{mean}, matching @@ -99,11 +102,14 @@ three positional keywords---\code{"start"}, \code{"end"}, and \code{"total"}---r stacked \code{by} groups according to their \code{y} values at the designated position along the \code{x} axis. Following convention, the ranking runs in descending order, so that the biggest group is drawn on the bottom layer. However, size -is not the only route to a stable baseline, though. Because each band is +is not the only route to a stable baseline. Because each band is drawn on top of the ones below it, they all inherit whatever movement the -bottom layer has; a large but volatile group can therefore be a worse choice -of foundation than a small, steady one. The \code{"minvar"} keyword ranks by -variance instead, placing the least variable group at the bottom. +bottom layer has. A large but volatile group can therefore be a worse choice +of foundation than a small, steady one. In this latter case, the \code{"minvar"} +keyword would be a more appropriate choice since it places the lowest +variance group at the bottom. Your choice of stacking ordering should +therefore respond to the patterns in your data and which layers you want to +emphasize. Stacking needs exactly one \code{y} value per group per \code{x} value. Repeated cells ---typically caused by a variable that is present in the data but absent from @@ -185,6 +191,14 @@ tinyplot( type = type_area(stack = TRUE, byord = "minvar") ) +# `"rev"` simply flips the existing level order, which is the one thing a +# ranking function cannot do (it never sees which group it was handed). + +tinyplot( + val ~ year | grp, data = dat, + type = type_area(stack = TRUE, byord = "rev") +) + # Custom ranking functions are also accepted. Name an argument `x` and it # receives the group's x values too, which is what a slope needs.