diff --git a/NEWS.md b/NEWS.md index 9e0a8d7c..ea6b04ca 100644 --- a/NEWS.md +++ b/NEWS.md @@ -62,12 +62,22 @@ 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. 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, while + `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 + 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 @@ -107,6 +117,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 diff --git a/R/assertions.R b/R/assertions.R index eefd0d33..0b9ab8f3 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 5781b8b2..4922e78d 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"]]) @@ -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_ord.R b/R/sanitize_ord.R new file mode 100644 index 00000000..cde8f18c --- /dev/null +++ b/R/sanitize_ord.R @@ -0,0 +1,122 @@ +## 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 +## - "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 +## - "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 +## `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.). +## +## 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. +## +## "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 +## 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 +## 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", "rev", "start", "end", "total", "minvar") + +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" 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 + # 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 { + 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]) + # 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( + 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) + ) + } + + # 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 13f96bfc..fc7b2728 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, byord = NULL, FUN = NULL) { out = list( draw = NULL, - data = data_area(alpha = alpha), + data = data_area(alpha = alpha, stack = stack, byord = byord, FUN = FUN), name = "area" ) class(out) = "tinyplot_type" @@ -11,12 +11,58 @@ 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, byord = NULL, FUN = NULL) { + assert_flag(stack) + assert_function(FUN, null.ok = TRUE) + # 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) + } + + # 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. + 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 + if (!is.null(byord)) { + datapoints$by = sanitize_ord( + datapoints$by, datapoints$y, datapoints$x, byord, arg = "byord" + ) + by = datapoints$by + } + + if (isTRUE(stack)) { + # bands read bottom-up, so the legend key should too + settings[["type_hints"]][["legend_reversed"]] = TRUE + 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" @@ -32,13 +78,79 @@ data_area = function(alpha = alpha) { 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", "ymax", "ymin", + "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) } + + +## 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) { + # A gap in one group would otherwise drop every group stacked above it back + # 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, + 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 e303a93a..01765a9e 100644 --- a/R/type_ribbon.R +++ b/R/type_ribbon.R @@ -4,6 +4,36 @@ #' 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. +#' @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. +#' 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 +#' whether it is drawn as bars or as an area. #' @inheritParams type_errorbar #' #' @description Type constructor functions for producing polygon ribbons, which @@ -19,6 +49,50 @@ #' 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. +#' +#' The `byord` argument is a helpful companion to stacked area plots, since it +#' 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. However, size +#' 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. 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 +#' 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 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. +#' +#' 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) @@ -46,7 +120,67 @@ #' #' # 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. +#' +#' # 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 = 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)) +#' +#' # 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") +#' ) +#' +#' # `"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") +#' ) +#' +#' # `"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. +#' +#' 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. +#' +#' 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 #' @@ -76,7 +210,7 @@ #' type = type_ribbon(dodge = 0.1), #' main = "Dodged ribbons" #' ) -#' +#' #' @export type_ribbon = function(alpha = NULL, dodge = 0, fixed.dodge = FALSE) { out = list( diff --git a/inst/tinytest/_tinysnapshot/area_factor_x.svg b/inst/tinytest/_tinysnapshot/area_factor_x.svg new file mode 100644 index 00000000..2572ec10 --- /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 00000000..e1e02402 --- /dev/null +++ b/inst/tinytest/_tinysnapshot/area_grouped.svg @@ -0,0 +1,89 @@ + + + + + + + + + + + + + + + + +grp +A +B +C + + + + + + + +year +val + + + + + + + + +2000 +2005 +2010 +2015 +2020 + + + + + + + + +0 +1 +2 +3 +4 +5 +6 + + + + + + + + + + + + + + + + + diff --git a/inst/tinytest/_tinysnapshot/area_stack.svg b/inst/tinytest/_tinysnapshot/area_stack.svg new file mode 100644 index 00000000..cd336f1a --- /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 00000000..4f98b407 --- /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_byord_aggregated.svg b/inst/tinytest/_tinysnapshot/area_stack_byord_aggregated.svg new file mode 100644 index 00000000..6d726e5d --- /dev/null +++ b/inst/tinytest/_tinysnapshot/area_stack_byord_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_byord_end.svg b/inst/tinytest/_tinysnapshot/area_stack_byord_end.svg new file mode 100644 index 00000000..705c1bd4 --- /dev/null +++ b/inst/tinytest/_tinysnapshot/area_stack_byord_end.svg @@ -0,0 +1,89 @@ + + + + + + + + + + + + + + + + +grp +B +A +C + + + + + + + +year +val + + + + + + + + +2000 +2005 +2010 +2015 +2020 + + + + + + + + +0 +2 +4 +6 +8 +10 +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 00000000..cd336f1a --- /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 00000000..48067228 --- /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_byord_rev.svg b/inst/tinytest/_tinysnapshot/area_stack_byord_rev.svg new file mode 100644 index 00000000..ec6009f1 --- /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/_tinysnapshot/area_stack_facet.svg b/inst/tinytest/_tinysnapshot/area_stack_facet.svg new file mode 100644 index 00000000..51c69739 --- /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_facet_ragged.svg b/inst/tinytest/_tinysnapshot/area_stack_facet_ragged.svg new file mode 100644 index 00000000..d7f36856 --- /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_flip.svg b/inst/tinytest/_tinysnapshot/area_stack_flip.svg new file mode 100644 index 00000000..6836bee9 --- /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/_tinysnapshot/area_stack_legend_bottom.svg b/inst/tinytest/_tinysnapshot/area_stack_legend_bottom.svg new file mode 100644 index 00000000..98710a96 --- /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 new file mode 100644 index 00000000..37eba7b0 --- /dev/null +++ b/inst/tinytest/test-type_area.R @@ -0,0 +1,152 @@ +source("helpers.R") +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 = 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) + + +# +## 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") + +# 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") + + +# +## byord ----- + +f = function() { + tinyplot(val ~ year | grp, data = dat, type = type_area(stack = TRUE, byord = "end")) +} +expect_snapshot_plot(f, label = "area_stack_byord_end") + +# 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, byord = "end")) +} +expect_snapshot_plot(f, label = "area_stack_byord_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") + + +# "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") + +# "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() { + 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 ----- + +# 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" +) + +# 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 086c191a..22d1ab7d 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, byord = NULL, FUN = NULL) type_ribbon(alpha = NULL, dodge = 0, fixed.dodge = FALSE) } @@ -15,6 +15,40 @@ 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{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. +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 +\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 +87,51 @@ 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{byord} argument is a helpful companion to stacked area plots, since it +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. However, size +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. 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 +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 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. + +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) @@ -80,6 +159,66 @@ 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. + +# 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 = 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)) + +# 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") +) + +# `"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") +) + +# `"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. + +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. + +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 diff --git a/vignettes/types.qmd b/vignettes/types.qmd index c8c28577..e417d8e3 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