diff --git a/NEWS.md b/NEWS.md index d31d6a82..56a92978 100644 --- a/NEWS.md +++ b/NEWS.md @@ -8,20 +8,29 @@ where the formatting is also better._ ### Breaking change -- Internal settings and parameters are now stored in an environment called - `settings`, which can be accessed and modified by type-specific functions. - This may require changes to users' custom type functions that previously - accessed settings as passed arguments. This change was necessary to improve - the modularity and maintainability of the codebase, and also to add downstream - flexibility. (#473 @vincentarelbundock and @grantmcdermott) +- "Breaking" change for internal development and custom types only: + The plot settings and parameters from individual `tinyplot` calls are now + stored in a dedicated (temporary) internal environment called `settings`, + which can be accessed and modified by type-specific functions. This change + will enable various internal enhancements, from improving the modularity and + maintainability of the `tinyplot` codebase, to reducing memory overhead and + performance (since we require fewer object copies). Looking ahead, we also + expect that it will make it easier to support new features and integration + with downstream packages. Most `tinyplot` users should be unaffected by these + internal changes. However, users who have defined their own custom types will + need to make some adjustments to match the new `settings` logic; details are + provided in the updated `Types` vignette. (#473 @vincentarelbundock and @grantmcdermott) ### New features - `type_text()` gains a `family` argument for controlling the font family, separate to the main plot text elements. (#494 @grantmcdermott) -- `type_ribbon()` gains a `dodge` argument, supporting similar functionality to - `type_errorbar()` and `type_pointrange()` for dodging overlapping groups. - (#522 @grantmcdermott) +- Expanded `dodge` argument capabilities and consistency for overlapping groups: + - Logical `dodge = TRUE` gives automatic width spacing based on the number + of groups. (#525 @grantmcdermott) + - We now enforce that numeric `dodge` values must be in the range `[0,1)`. + (#526 @grantmcdermott) + - `dodge` argument now supported in `type_ribbon()` (#522 @grantmcdermott) ### Bug fixes @@ -45,8 +54,8 @@ where the formatting is also better._ (e.g., `tinyplot.foo`) would fail. (#515 @grantmcdermott) - Added layers, particularly from `tinyplot_add()`, should now respect the x-axis order of the original plot layer. This should ensure that we don't end - up with misaligned layers. For example, when adding a ribbon on top of an - errorbar plot. (#517, #520, #523 @grantmcdermott) + up with misaligned layers. For example, when ribbon is added on top of an + errorbar plot. (#517, #520, #523, #526 @grantmcdermott) ### Documentation diff --git a/R/align_layer.R b/R/align_layer.R index 4468312d..51b4d72a 100644 --- a/R/align_layer.R +++ b/R/align_layer.R @@ -1,7 +1,6 @@ # Ensure added layers respect the x-axis order of the original plot layer # (e.g., when adding lines or ribbons on top of errorbars) align_layer = function(settings) { - # Retrieve xlabs from current and original layers xlabs_layer = settings[["xlabs"]] xlabs_orig = get("xlabs", envir = get(".tinyplot_env", envir = parent.env(environment()))) @@ -22,15 +21,23 @@ align_layer = function(settings) { if (setequal(names(xlabs_layer), names(xlabs_orig))) { orig_order = xlabs_orig[names(xlabs_layer)[settings$datapoints[["x"]]]] x_layer = settings$datapoints[["x"]] - settings$datapoints[["x"]] = orig_order + if (is.null(settings$dodge)) { + x_new = x_layer[orig_order] + } else { + names(x_layer) = names(xlabs_layer)[round(x_layer)] + x_new = x_layer + (xlabs_orig[names(round(x_layer))] - round(x_layer)) + } + settings$datapoints[["x"]] = x_new # Adjust ancillary variables - for (v in c("rowid", "xmin", "xmax")) { - if (identical(settings$datapoints[[v]], x_layer)) { - settings$datapoints[[v]] = orig_order + for (v in c("xmin", "xmax")) { + if (identical(settings$datapoints[[v]], unname(x_layer))) { + settings$datapoints[[v]] = x_new } } settings$datapoints = settings$datapoints[order(settings$datapoints[["x"]]), ] + settings$datapoints[["rowid"]] = seq_len(nrow(settings$datapoints)) } } } } + diff --git a/R/dodge.R b/R/dodge.R index ed8e9bd9..51ab2735 100644 --- a/R/dodge.R +++ b/R/dodge.R @@ -5,13 +5,22 @@ #' #' @param datapoints Data frame containing plot data with at least `x` and `by` #' columns. -#' @param dodge Numeric value specifying the dodge amount. If 0, no dodging is -#' performed. -#' @param fixed.pos Logical. If `TRUE`, dodge positions are fixed based on the -#' number of groups in `by`. If `FALSE`, dodge positions are calculated -#' separately for each unique x value. +#' @param dodge Numeric value in the range `[0,1)`, or logical. If numeric, +#' values are scaled relative to x-axis break spacing (e.g., `dodge = 0.1` +#' places outermost groups one-tenth of the way to adjacent breaks; +#' `dodge = 0.5` places them midway between breaks; etc.). Values < 0.5 are +#' recommended. If `TRUE`, dodge width is calculated automatically based on +#' the number of groups (0.1 per group for 2-4 groups, 0.45 for 5+ groups). If +#' `FALSE` or 0, no dodging is performed. Default is 0. +#' @param fixed.pos Logical indicating whether dodged groups should retain a +#' fixed relative position based on their group value. Relevant for `x` +#' categories that only have a subset of the total number of groups. Defaults +#' to `FALSE`, in which case dodging is based on the number of unique groups +#' present in that `x` category alone. See Examples. #' @param cols Character vector of column names to dodge. If `NULL` (default), #' automatically detects and dodges `x`, `xmin`, and `xmax` if they exist. +#' @param settings Environment containing plot settings. If `NULL` (default), +#' retrieved from the calling environment. #' #' @return Modified `datapoints` data frame with dodged positions. #' @@ -29,11 +38,37 @@ dodge_positions = function( datapoints, dodge, fixed.pos = TRUE, - cols = NULL + cols = NULL, + settings = NULL ) { + if (is.null(settings)) { + settings = get("settings", envir = parent.frame()) + } + + if (is.logical(dodge)) { + if (isTRUE(dodge)) { + n = nlevels(datapoints$by) + dodge = if (n == 1) 0 else if (n <= 5) (n - 1) * 0.1 else 0.45 + } else { + dodge = 0 + } + } + + assert_numeric(dodge, len = 1, lower = 0, upper = 1) + if (dodge >= 1) { + stop("`dodge` must be in the range [0,1).", call. = FALSE) + } + assert_logical(fixed.pos) + if (dodge == 0) { return(datapoints) + } else if (dodge > 0.5) { + warning( + "Argument `dodge = ", dodge, "` exceeds 0.5. ", + "Large dodge values may position outer groups closer to neighboring axis breaks." + ) } + settings$dodge = dodge # Auto-detect columns to dodge if not specified if (is.null(cols)) { @@ -64,3 +99,4 @@ dodge_positions = function( datapoints } + diff --git a/R/tinyplot.R b/R/tinyplot.R index eb977f06..8d5c1904 100644 --- a/R/tinyplot.R +++ b/R/tinyplot.R @@ -795,6 +795,7 @@ tinyplot.default = function( # misc flip = flip, + dodge = NULL, by = by, dots = dots, type_info = list() # pass type-specific info from type_data to type_draw diff --git a/R/type_errorbar.R b/R/type_errorbar.R index 8eac8c2d..d325acb9 100644 --- a/R/type_errorbar.R +++ b/R/type_errorbar.R @@ -2,16 +2,7 @@ #' #' @description Type function(s) for producing error bar and pointrange plots. #' -#' @param dodge Numeric value (>= 0) for dodging of overlapping `by` groups. -#' Dodging is scaled relative to the x-axis tick locations (i.e., unique -#' levels of `x`). For example, `dodge = 0.5` would place the outermost dodged -#' groups exactly midway the between axis ticks. Default value is 0 (no -#' dodging). -#' @param fixed.pos Logical indicating whether dodged groups should retain a -#' fixed relative position based on their group value. Relevant for `x` -#' categories that only have a subset of the total number of groups. Defaults -#' to `FALSE`, in which case dodging is based on the number of unique groups -#' present in that `x` category alone. See Examples. +#' @inheritParams dodge_positions #' @inheritParams graphics::arrows #' @examples #' mod = lm(mpg ~ wt * factor(am), mtcars) diff --git a/R/type_pointrange.R b/R/type_pointrange.R index 42615a18..9cefe803 100644 --- a/R/type_pointrange.R +++ b/R/type_pointrange.R @@ -1,9 +1,6 @@ #' @rdname type_errorbar #' @export type_pointrange = function(dodge = 0, fixed.pos = FALSE) { - assert_numeric(dodge, len = 1, lower = 0) - assert_logical(fixed.pos) - out = list( draw = draw_pointrange(), data = data_pointrange(dodge = dodge, fixed.pos = fixed.pos), diff --git a/R/type_ribbon.R b/R/type_ribbon.R index 6c19892e..8851e2af 100644 --- a/R/type_ribbon.R +++ b/R/type_ribbon.R @@ -79,9 +79,6 @@ #' #' @export type_ribbon = function(alpha = NULL, dodge = 0, fixed.pos = FALSE) { - assert_numeric(dodge, len = 1, lower = 0) - assert_logical(fixed.pos) - out = list( draw = draw_ribbon(), data = data_ribbon(ribbon.alpha = alpha, dodge = dodge, fixed.pos = fixed.pos), diff --git a/inst/tinytest/_tinysnapshot/dodge_errorbar_add_ribbon.svg b/inst/tinytest/_tinysnapshot/dodge_errorbar_add_ribbon.svg new file mode 100644 index 00000000..d72f004a --- /dev/null +++ b/inst/tinytest/_tinysnapshot/dodge_errorbar_add_ribbon.svg @@ -0,0 +1,131 @@ + + + + + + + + + + + + + + + + +model +Model A +Model B +Model C + + + + + + + +term +estimate + + + + + + + +(Intercept) +wt +cyl +hp + + + + + + +0 +10 +20 +30 +40 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/inst/tinytest/_tinysnapshot/dodge_pointrange.svg b/inst/tinytest/_tinysnapshot/dodge_pointrange.svg new file mode 100644 index 00000000..fd930a5c --- /dev/null +++ b/inst/tinytest/_tinysnapshot/dodge_pointrange.svg @@ -0,0 +1,107 @@ + + + + + + + + + + + + + + + + +model +Model A +Model B +Model C + + + + + + + +term +estimate + + + + + + + +(Intercept) +wt +cyl +hp + + + + + + +0 +10 +20 +30 +40 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/inst/tinytest/_tinysnapshot/pointrange_with_layers_grouped.svg b/inst/tinytest/_tinysnapshot/dodge_pointrange_false.svg similarity index 72% rename from inst/tinytest/_tinysnapshot/pointrange_with_layers_grouped.svg rename to inst/tinytest/_tinysnapshot/dodge_pointrange_false.svg index 0b41a534..c177c2d4 100644 --- a/inst/tinytest/_tinysnapshot/pointrange_with_layers_grouped.svg +++ b/inst/tinytest/_tinysnapshot/dodge_pointrange_false.svg @@ -26,13 +26,13 @@ - - - -model -Model A -Model B -Model C + + + +model +Model A +Model B +Model C @@ -72,27 +72,36 @@ + + + + + + + + + + + + - - - + + + - - - - + + + + - - - - - + + diff --git a/inst/tinytest/_tinysnapshot/pointrange_dodge_01.svg b/inst/tinytest/_tinysnapshot/dodge_pointrange_flip.svg similarity index 56% rename from inst/tinytest/_tinysnapshot/pointrange_dodge_01.svg rename to inst/tinytest/_tinysnapshot/dodge_pointrange_flip.svg index 45f46709..9f4b7efa 100644 --- a/inst/tinytest/_tinysnapshot/pointrange_dodge_01.svg +++ b/inst/tinytest/_tinysnapshot/dodge_pointrange_flip.svg @@ -26,13 +26,13 @@ - - - -model -Model A -Model B -Model C + + + +model +Model A +Model B +Model C @@ -55,14 +55,14 @@ 20 30 40 - - - - + + + + -(Intercept) -wt -cyl +(Intercept) +wt +cyl hp @@ -72,24 +72,36 @@ + + + + + + + + + + + + - - - - - - - + + + + + + + - - - - - - - - - + + + + + + + + + diff --git a/inst/tinytest/_tinysnapshot/dodge_pointrange_true.svg b/inst/tinytest/_tinysnapshot/dodge_pointrange_true.svg new file mode 100644 index 00000000..52a11a5f --- /dev/null +++ b/inst/tinytest/_tinysnapshot/dodge_pointrange_true.svg @@ -0,0 +1,107 @@ + + + + + + + + + + + + + + + + +model +Model A +Model B +Model C + + + + + + + +term +estimate + + + + + + + +(Intercept) +wt +cyl +hp + + + + + + +0 +10 +20 +30 +40 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/inst/tinytest/test-dodge.R b/inst/tinytest/test-dodge.R new file mode 100644 index 00000000..433f97a3 --- /dev/null +++ b/inst/tinytest/test-dodge.R @@ -0,0 +1,108 @@ +source("helpers.R") +using("tinysnapshot") + +# Test data +models = list( + "Model A" = lm(mpg ~ wt + cyl, data = mtcars), + "Model B" = lm(mpg ~ wt + hp + cyl, data = mtcars), + "Model C" = lm(mpg ~ wt, data = mtcars) +) +results = lapply(names(models), function(m) { + data.frame( + model = m, + term = names(coef(models[[m]])), + estimate = coef(models[[m]]), + setNames(data.frame(confint(models[[m]])), c("conf.low", "conf.high")) + ) +}) +results = do.call(rbind, results) + + +# +## Basic examples + +# Pointrange dodge with numeric value +fun = function() { + tinyplot(estimate ~ term | model, + ymin = conf.low, ymax = conf.high, + data = results, + type = "pointrange", + dodge = 0.1, + theme = "basic") + # tinyplot_add(type = "pointrange") +} +expect_snapshot_plot(fun, label = "dodge_pointrange") + +# As above, but flipped +fun = function() { + tinyplot(estimate ~ term | model, + ymin = conf.low, ymax = conf.high, + data = results, + type = "pointrange", + dodge = 0.1, + flip = TRUE, + theme = "basic") +} +expect_snapshot_plot(fun, label = "dodge_pointrange_flip") + +# Pointrange dodge with logical TRUE (automatic calculation) +fun = function() { + tinyplot(estimate ~ term | model, + ymin = conf.low, ymax = conf.high, + data = results, + type = "pointrange", + dodge = TRUE, + theme = "basic") +} +expect_snapshot_plot(fun, label = "dodge_pointrange_true") + +# Pointrange dodge with logical FALSE (no dodging) +fun = function() { + tinyplot(estimate ~ term | model, + ymin = conf.low, ymax = conf.high, + data = results, + type = "pointrange", + dodge = FALSE, + theme = "basic") +} +expect_snapshot_plot(fun, label = "dodge_pointrange_false") + + +# +## Dodge + layering + +fun = function() { + tinyplot(estimate ~ term | model, + ymin = conf.low, ymax = conf.high, + data = results, + type = "errorbar", + dodge = 0.1, + theme = "basic") + tinyplot_add(type = "ribbon") +} +expect_snapshot_plot(fun, label = "dodge_errorbar_add_ribbon") + + +# +## Warning and errors + +# Warning when dodge > 0.5 +expect_warning( + tinyplot(estimate ~ term | model, + ymin = conf.low, ymax = conf.high, + data = results, + type = type_pointrange(dodge = 0.6), + theme = "basic"), + pattern = "exceeds 0.5" +) + +# Error when dodge >= 1 +expect_error( + tinyplot(estimate ~ term | model, + ymin = conf.low, ymax = conf.high, + data = results, + type = type_pointrange(dodge = 1)), + pattern = "must be in the range" +) + + diff --git a/inst/tinytest/test-type_pointrange.R b/inst/tinytest/test-type_pointrange.R index d2f14477..ae87a228 100644 --- a/inst/tinytest/test-type_pointrange.R +++ b/inst/tinytest/test-type_pointrange.R @@ -71,38 +71,3 @@ fun = function() { tinyplot_add(type = "hline", lty = 2) } expect_snapshot_plot(fun, label = "pointrange_with_layers_flipped") - -# Issue #406: dodge pointrange and errorbar -models = list( - "Model A" = lm(mpg ~ wt + cyl, data = mtcars), - "Model B" = lm(mpg ~ wt + hp + cyl, data = mtcars), - "Model C" = lm(mpg ~ wt, data = mtcars) -) -results = lapply(names(models), function(m) { - data.frame( - model = m, - term = names(coef(models[[m]])), - estimate = coef(models[[m]]), - setNames(data.frame(confint(models[[m]])), c("conf.low", "conf.high")) - ) -}) -results = do.call(rbind, results) -fun = function() { - tinyplot(estimate ~ term | model, - ymin = conf.low, ymax = conf.high, - flip = TRUE, data = results, - type = type_pointrange(dodge = 0.2)) -} -expect_snapshot_plot(fun, label = "pointrange_dodge_01") - -# issue #519 layer on top of grouped plots -# (don't care about dodge yet; revist when #493 resolved) - -fun = function() { - tinyplot(estimate ~ term | model, - ymin = conf.low, ymax = conf.high, - data = results, - type = type_pointrange()) - tinyplot_add(type = 'l') -} -expect_snapshot_plot(fun, label = "pointrange_with_layers_grouped") \ No newline at end of file diff --git a/man/dodge_positions.Rd b/man/dodge_positions.Rd index 525f0f9b..ff3df05f 100644 --- a/man/dodge_positions.Rd +++ b/man/dodge_positions.Rd @@ -4,21 +4,37 @@ \alias{dodge_positions} \title{Dodge positions for grouped data} \usage{ -dodge_positions(datapoints, dodge, fixed.pos = TRUE, cols = NULL) +dodge_positions( + datapoints, + dodge, + fixed.pos = TRUE, + cols = NULL, + settings = NULL +) } \arguments{ \item{datapoints}{Data frame containing plot data with at least \code{x} and \code{by} columns.} -\item{dodge}{Numeric value specifying the dodge amount. If 0, no dodging is -performed.} +\item{dodge}{Numeric value in the range \verb{[0,1)}, or logical. If numeric, +values are scaled relative to x-axis break spacing (e.g., \code{dodge = 0.1} +places outermost groups one-tenth of the way to adjacent breaks; +\code{dodge = 0.5} places them midway between breaks; etc.). Values < 0.5 are +recommended. If \code{TRUE}, dodge width is calculated automatically based on +the number of groups (0.1 per group for 2-4 groups, 0.45 for 5+ groups). If +\code{FALSE} or 0, no dodging is performed. Default is 0.} -\item{fixed.pos}{Logical. If \code{TRUE}, dodge positions are fixed based on the -number of groups in \code{by}. If \code{FALSE}, dodge positions are calculated -separately for each unique x value.} +\item{fixed.pos}{Logical indicating whether dodged groups should retain a +fixed relative position based on their group value. Relevant for \code{x} +categories that only have a subset of the total number of groups. Defaults +to \code{FALSE}, in which case dodging is based on the number of unique groups +present in that \code{x} category alone. See Examples.} \item{cols}{Character vector of column names to dodge. If \code{NULL} (default), automatically detects and dodges \code{x}, \code{xmin}, and \code{xmax} if they exist.} + +\item{settings}{Environment containing plot settings. If \code{NULL} (default), +retrieved from the calling environment.} } \value{ Modified \code{datapoints} data frame with dodged positions. diff --git a/man/type_errorbar.Rd b/man/type_errorbar.Rd index d5b6ac59..fe3d8932 100644 --- a/man/type_errorbar.Rd +++ b/man/type_errorbar.Rd @@ -12,11 +12,13 @@ type_pointrange(dodge = 0, fixed.pos = FALSE) \arguments{ \item{length}{length of the edges of the arrow head (in inches).} -\item{dodge}{Numeric value (>= 0) for dodging of overlapping \code{by} groups. -Dodging is scaled relative to the x-axis tick locations (i.e., unique -levels of \code{x}). For example, \code{dodge = 0.5} would place the outermost dodged -groups exactly midway the between axis ticks. Default value is 0 (no -dodging).} +\item{dodge}{Numeric value in the range \verb{[0,1)}, or logical. If numeric, +values are scaled relative to x-axis break spacing (e.g., \code{dodge = 0.1} +places outermost groups one-tenth of the way to adjacent breaks; +\code{dodge = 0.5} places them midway between breaks; etc.). Values < 0.5 are +recommended. If \code{TRUE}, dodge width is calculated automatically based on +the number of groups (0.1 per group for 2-4 groups, 0.45 for 5+ groups). If +\code{FALSE} or 0, no dodging is performed. Default is 0.} \item{fixed.pos}{Logical indicating whether dodged groups should retain a fixed relative position based on their group value. Relevant for \code{x} diff --git a/man/type_ribbon.Rd b/man/type_ribbon.Rd index 417e62fc..b2918dbb 100644 --- a/man/type_ribbon.Rd +++ b/man/type_ribbon.Rd @@ -15,11 +15,13 @@ 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{dodge}{Numeric value (>= 0) for dodging of overlapping \code{by} groups. -Dodging is scaled relative to the x-axis tick locations (i.e., unique -levels of \code{x}). For example, \code{dodge = 0.5} would place the outermost dodged -groups exactly midway the between axis ticks. Default value is 0 (no -dodging).} +\item{dodge}{Numeric value in the range \verb{[0,1)}, or logical. If numeric, +values are scaled relative to x-axis break spacing (e.g., \code{dodge = 0.1} +places outermost groups one-tenth of the way to adjacent breaks; +\code{dodge = 0.5} places them midway between breaks; etc.). Values < 0.5 are +recommended. If \code{TRUE}, dodge width is calculated automatically based on +the number of groups (0.1 per group for 2-4 groups, 0.45 for 5+ groups). If +\code{FALSE} or 0, no dodging is performed. Default is 0.} \item{fixed.pos}{Logical indicating whether dodged groups should retain a fixed relative position based on their group value. Relevant for \code{x}