R/geom_lineribbon.R

Defines functions stepify draw_key_lineribbon

# A combination of geom_line and geom_ribbon  with sensible defaults for displaying multiple bands
#
# Author: mjskay
###############################################################################


# Names that should be suppressed from global variable check by codetools
# Names used broadly should be put in _global_variables.R
globalVariables(c(".lower", ".upper", ".width"))


# geom_lineribbon ---------------------------------------------------------

#' Line + multiple-ribbon plots (ggplot geom)
#'
#' A combination of [geom_line()] and [geom_ribbon()] with default aesthetics
#' designed for use with output from [point_interval()].
#'
#' @details
#' [geom_lineribbon()] is a combination of a [geom_line()] and [geom_ribbon()] designed for use
#' with output from [point_interval()]. This geom sets some default aesthetics equal to the `.width`
#' column generated by the [point_interval()] family of functions, making them
#' often more convenient than a vanilla [geom_ribbon()] + [geom_line()].
#'
#' Specifically, [geom_lineribbon()] acts as if its default aesthetics are
#' `aes(fill = forcats::fct_rev(ordered(.width)))`.
#'
#' @eval rd_layer_params("lineribbon")
#' @eval rd_lineribbon_aesthetics("lineribbon")
#' @inheritParams ggplot2::geom_line
#' @param ...  Other arguments passed to [layer()]. These are often aesthetics, used to set an aesthetic
#' to a fixed value, like `colour = "red"` or `linewidth = 3` (see **Aesthetics**, below). They may also be
#' parameters to the paired geom/stat.
#' @return A [ggplot2::Geom] representing a combined line + multiple-ribbon geometry which can
#' be added to a [ggplot()] object.
#' @author Matthew Kay
#' @seealso See [stat_lineribbon()] for a version that does summarizing of samples into points and intervals
#' within ggplot. See [geom_pointinterval()] for a similar geom intended
#' for point summaries and intervals. See [geom_ribbon()] and [geom_line()] for the geoms this is
#' based on.
#' @examples
#'
#' library(dplyr)
#' library(ggplot2)
#'
#' theme_set(theme_ggdist())
#'
#' tibble(x = 1:10) %>%
#'   group_by_all() %>%
#'   do(tibble(y = rnorm(100, .$x))) %>%
#'   median_qi(.width = c(.5, .8, .95)) %>%
#'   ggplot(aes(x = x, y = y, ymin = .lower, ymax = .upper)) +
#'   # automatically uses aes(fill = forcats::fct_rev(ordered(.width)))
#'   geom_lineribbon() +
#'   scale_fill_brewer()
#'
#' @import ggplot2
#' @name geom_lineribbon
NULL

draw_key_lineribbon = function(self, data, params, size) {
  if (is.null(data[["fill"]]) &&
    (!is.null(data[["fill_ramp"]]) || !all(is.na(data[["alpha"]])))
  ) {
    data$fill = self$default_key_aes$fill
  }
  data$fill = apply_colour_ramp(data$fill, data$fill_ramp)

  if (!is.null(data[["colour"]]) || !is.null(data[["linewidth"]])) {
    data$colour = data[["colour"]] %||% self$default_key_aes$colour
    data$linewidth = data[["linewidth"]] %||% self$default_key_aes$linewidth
  }

  fill_grob = if (!is.null(data$fill)) {
    draw_key_rect(data, params, size)
  }
  line_grob = if (!is.null(data$colour)) {
    draw_key_path(data, params, size)
  }
  grobTree(fill_grob, line_grob)
}

#' @rdname ggdist-ggproto
#' @format NULL
#' @usage NULL
#' @import ggplot2
#' @export
GeomLineribbon = ggproto("GeomLineribbon", AbstractGeom,

  ## aesthetics --------------------------------------------------------------

  aes_docs = modifyList(AbstractGeom$aes_docs, list(
    "Ribbon-specific aesthetics" = list(
      xmin = 'Left edge of the ribbon sub-geometry (if `orientation = "horizontal"`).',
      xmax = 'Right edge of the ribbon sub-geometry (if `orientation = "horizontal"`).',
      ymin = 'Lower edge of the ribbon sub-geometry (if `orientation = "vertical"`).',
      ymax = 'Upper edge of the ribbon sub-geometry (if `orientation = "vertical"`).',
      order = 'The order in which ribbons are drawn. Ribbons with the smallest mean value of `order`
        are drawn first (i.e., will be drawn below ribbons with larger mean values of `order`). If
        `order` is not supplied to [geom_lineribbon()], `-abs(xmax - xmin)` or `-abs(ymax - ymax)`
        (depending on `orientation`) is used, having the effect of drawing the widest (on average)
        ribbons on the bottom. [stat_lineribbon()] uses `order = after_stat(level)` by default,
        causing the ribbons generated from the largest `.width` to be drawn on the bottom.'
    ),

    "Color aesthetics" = list(
      colour = '(or `color`) The color of the **line** sub-geometry.',
      fill = 'The fill color of the **ribbon** sub-geometry.',
      alpha = 'The opacity of the **line** and **ribbon** sub-geometries.',
      fill_ramp = 'A secondary scale that modifies the `fill`
       scale to "ramp" to another color. See [scale_fill_ramp()] for examples.'
    ),

    "Line aesthetics" = list(
      linewidth = 'Width of **line**. In \\pkg{ggplot2} < 3.4, was called `size`.',
      linetype = 'Type of **line** (e.g., `"solid"`, `"dashed"`, etc)'
    )
  )),

  default_aes = aes(
    colour = NULL,
    linewidth = NULL,
    linetype = 1,
    fill = NULL,
    fill_ramp = NULL,
    alpha = NA,
    order = NULL
  ),

  default_key_aes = aes(
    colour = "black",
    fill = "gray65",
    linewidth = 1.25
  ),

  default_computed_aes = aes(
    fill = fct_rev_(ordered(.width))
  ),

  # support for `size` in place of `linewidth` aes in ggplot2 < 3.4
  rename_size = TRUE,
  non_missing_aes = union("size", AbstractGeom$non_missing_aes),

  required_aes = c("x|y"),

  optional_aes = c("ymin", "ymax", "xmin", "xmax", "fill_ramp", "order"),


  ## params ------------------------------------------------------------------

  param_docs = defaults(list(
    step = glue_doc('
      Should the line/ribbon be drawn as a step function? One of:
      \\itemize{
        \\item `FALSE` (default): do not draw as a step function.
        \\item `"mid"` (or `TRUE`): draw steps midway between adjacent x values.
        \\item `"hv"`: draw horizontal-then-vertical steps.
        \\item `"vh"`: draw as vertical-then-horizontal steps.
      }
      `TRUE` is an alias for `"mid"` because for a step function with ribbons, `"mid"` is probably what you want
      (for the other two step approaches the ribbons at either the very first or very last x value will not be
      visible).
      ')
  ), AbstractGeom$param_docs),

  default_params = list(
    step = FALSE,
    orientation = NA,
    na.rm = FALSE
  ),

  orientation_options = defaults(list(
    range_is_orthogonal = TRUE, ambiguous = TRUE, group_has_equal = TRUE
  ), AbstractGeom$orientation_options),


  ## other methods -----------------------------------------------------------

  # workaround (#84)
  draw_key = function(self, ...) draw_key_lineribbon(self, ...),

  draw_panel = function(self, data, panel_scales, coord,
    step = self$default_params$step,
    orientation = self$default_params$orientation,
    flipped_aes = FALSE,
    ...
  ) {
    define_orientation_variables(orientation)

    # provide defaults for color aesthetics --- we do this here because
    # doing it with default_aes makes the scales very busy (as all of
    # these elements get drawn even if they aren't mapped). By
    # setting the defaults here we can then check if these are present
    # in draw_key and not draw them if they aren't mapped.
    for (aesthetic in names(self$default_key_aes)) {
      data[[aesthetic]] = data[[aesthetic]] %||% self$default_key_aes[[aesthetic]]
    }

    # must save the raw fill color prior to doing the ramp, otherwise if two different
    # colors ramp to the same fill (e.g. both ramp to 100% white) they will get
    # grouped together erroneously
    data$fill_raw = data$fill
    data$fill = apply_colour_ramp(data$fill, data$fill_ramp)

    # ribbons do not autogroup by color/fill/linetype, so if someone groups by changing the color
    # of the line or by setting fill, the ribbons might give an error. So we will do the
    # grouping ourselves
    grouping_columns = names(data) %>%
      intersect(c("colour", "fill", "fill_raw", "linetype", "group"))

    # draw as a step function if requested
    if (isTRUE(step)) step = "mid"
    if (!isFALSE(step)) data = ddply_(data, grouping_columns, stepify, x = y, direction = step)

    # determine order we will draw ribbons in (smallest order last)
    data[["order"]] = if (is.null(data[["order"]])) {
      # this is a slightly hackish approach to getting the draw order correct for the common
      # use case of fit lines / curves: when order is not specified explicitly, draw the ribbons
      # in order from largest mean width to smallest mean width, so that the widest intervals
      # are on the bottom.
      -abs(data[[xmax]] - data[[xmin]])
    } else {
      xtfrm(data[["order"]])
    }

    # draw all the ribbons
    ribbon_grobs = data %>%
      dlply_(grouping_columns, function(d) {
        group_grobs = list(GeomRibbon$draw_panel(transform(d, linewidth = NA), panel_scales, coord, flipped_aes = flipped_aes))
        list(
          order = mean(d[["order"]], na.rm = TRUE),
          grobs = group_grobs
        )
      })

    ribbon_grobs = ribbon_grobs[order(map_dbl_(ribbon_grobs, `[[`, "order"))] %>%
      lapply(`[[`, i = "grobs") %>%
      unlist(recursive = FALSE) %||%
      list()

    # now draw all the lines
    line_grobs = data %>%
      dlply_(grouping_columns, function(d) {
        if (!is.null(d[[x]])) {
          list(GeomLine$draw_panel(d, panel_scales, coord))
        } else {
          list()
        }
      }) %>%
      unlist(recursive = FALSE) %||%
      list()

    grobs = c(ribbon_grobs, line_grobs)

    ggname("geom_lineribbon",
      gTree(children = do.call(gList, grobs))
    )
  }
)

#' @rdname geom_lineribbon
#' @export
geom_lineribbon = make_geom(GeomLineribbon)


# helpers -----------------------------------------------------------------

#' @importFrom dplyr lag lead
stepify = function(df, x = "x", direction = "hv") {
  n = nrow(df)

  # sort by x and double up all rows in the data frame
  step_df = df[rep(order(df[[x]]), each = 2),]

  if (direction == "hv") {
    # horizontal-to-vertical step => lead x and drop last row
    step_df[[x]] = lead(step_df[[x]])
    step_df[-2*n,]
  } else if (direction == "vh") {
    # vertical-to-horizontal step => lag x and drop first row
    step_df[[x]] = lag(step_df[[x]])
    step_df[-1,]
  } else if (direction == "mid") {
    # mid step => last value in each pair is matched with the first value in the next pair,
    # then we set their x position to their average.
    # Need to repeat the last value one more time to make it work
    step_df[2*n + 1,] = step_df[2*n,]

    x_i = seq_len(n)*2
    mid_x = (step_df[x_i, x] + step_df[x_i + 1, x]) / 2

    step_df[x_i, x] = mid_x
    step_df[x_i + 1, x] = mid_x
    step_df
  }
}

Try the ggdist package in your browser

Any scripts or data that you put into this service are public.

ggdist documentation built on Nov. 27, 2023, 9:06 a.m.