From 654511f87d4f7f1b304ce0ebb7ce3afd2d64cb04 Mon Sep 17 00:00:00 2001 From: Grant McDermott Date: Fri, 21 Nov 2025 15:30:31 -0800 Subject: [PATCH 1/4] dodge_positions function --- R/dodge.R | 66 ++++++++++++++++++++++++++++++++++++++++++ R/type_pointrange.R | 21 +------------- man/dodge_positions.Rd | 41 ++++++++++++++++++++++++++ 3 files changed, 108 insertions(+), 20 deletions(-) create mode 100644 R/dodge.R create mode 100644 man/dodge_positions.Rd diff --git a/R/dodge.R b/R/dodge.R new file mode 100644 index 00000000..ed8e9bd9 --- /dev/null +++ b/R/dodge.R @@ -0,0 +1,66 @@ +#' Dodge positions for grouped data +#' +#' Adjusts x-coordinates (and optionally xmin/xmax) to dodge overlapping points +#' or ranges in grouped plots. +#' +#' @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 cols Character vector of column names to dodge. If `NULL` (default), +#' automatically detects and dodges `x`, `xmin`, and `xmax` if they exist. +#' +#' @return Modified `datapoints` data frame with dodged positions. +#' +#' @details +#' When `fixed.pos = TRUE`, all groups are dodged by the same amount across all +#' x values, which is useful when x is categorical. When `fixed.pos = FALSE`, +#' dodging is calculated independently for each x value, which is useful when +#' the number of groups varies across x values. +#' +#' If `cols` is not specified, the function automatically dodges `x` and any +#' `xmin`/`xmax` columns that exist in the data. +#' +#' @keywords internal +dodge_positions = function( + datapoints, + dodge, + fixed.pos = TRUE, + cols = NULL +) { + if (dodge == 0) { + return(datapoints) + } + + # Auto-detect columns to dodge if not specified + if (is.null(cols)) { + cols = c("x", "xmin", "xmax") + cols = cols[cols %in% names(datapoints)] + } + + if (fixed.pos) { + n = nlevels(datapoints$by) + d = cumsum(rep(dodge, n)) + d = d - mean(d) + x_adj = d[as.integer(datapoints$by)] + for (col in cols) { + datapoints[[col]] = datapoints[[col]] + x_adj + } + } else { + xuniq = unique(datapoints$x) + for (i in seq_along(xuniq)) { + idx = which(datapoints$x == xuniq[i]) + n = length(idx) + d = cumsum(rep(dodge, n)) + d = d - mean(d) + for (col in cols) { + datapoints[[col]][idx] = datapoints[[col]][idx] + d + } + } + } + + datapoints +} diff --git a/R/type_pointrange.R b/R/type_pointrange.R index 49be5874..42615a18 100644 --- a/R/type_pointrange.R +++ b/R/type_pointrange.R @@ -70,26 +70,7 @@ data_pointrange = function(dodge, fixed.pos) { # dodge if (dodge != 0) { - if (fixed.pos) { - n = nlevels(datapoints$by) - d = cumsum(rep(dodge, n)) - d = d - mean(d) - x_adj = d[as.integer(datapoints$by)] - datapoints$x = datapoints$x + x_adj - datapoints$xmin = datapoints$xmin + x_adj - datapoints$xmax = datapoints$xmax + x_adj - } else { - xuniq = unique(datapoints$x) - for (i in seq_along(xuniq)) { - idx = which(datapoints$x == xuniq[i]) - n = length(idx) - d = cumsum(rep(dodge, n)) - d = d - mean(d) - datapoints$x[idx] = datapoints$x[idx] + d - datapoints$xmin[idx] = datapoints$xmin[idx] + d - datapoints$xmax[idx] = datapoints$xmax[idx] + d - } - } + datapoints = dodge_positions(datapoints, dodge, fixed.pos) } x = datapoints$x diff --git a/man/dodge_positions.Rd b/man/dodge_positions.Rd new file mode 100644 index 00000000..c8bdfa79 --- /dev/null +++ b/man/dodge_positions.Rd @@ -0,0 +1,41 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/dodge.R +\name{dodge_positions} +\alias{dodge_positions} +\title{Dodge positions for grouped data} +\usage{ +dodge_positions( + datapoints, + dodge, + fixed.pos = TRUE, + cols = c("x", "xmin", "xmax") +) +} +\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{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{cols}{Character vector of column names to dodge. Defaults to +\code{c("x", "xmin", "xmax")}.} +} +\value{ +Modified \code{datapoints} data frame with dodged positions. +} +\description{ +Adjusts x-coordinates (and optionally xmin/xmax) to dodge overlapping points +or ranges in grouped plots. +} +\details{ +When \code{fixed.pos = TRUE}, all groups are dodged by the same amount across all +x values, which is useful when x is categorical. When \code{fixed.pos = FALSE}, +dodging is calculated independently for each x value, which is useful when +the number of groups varies across x values. +} +\keyword{internal} From 35aea12821b2b6c60b91309a5de913b23bac0cd0 Mon Sep 17 00:00:00 2001 From: Grant McDermott Date: Fri, 21 Nov 2025 16:24:47 -0800 Subject: [PATCH 2/4] support ribbon dodging --- R/type_ribbon.R | 54 ++++++++++++++++++++++++++++++++++++++--- man/dodge_positions.Rd | 14 +++++------ man/type_ribbon.Rd | 55 +++++++++++++++++++++++++++++++++++++++++- 3 files changed, 111 insertions(+), 12 deletions(-) diff --git a/R/type_ribbon.R b/R/type_ribbon.R index b3028447..6c19892e 100644 --- a/R/type_ribbon.R +++ b/R/type_ribbon.R @@ -4,11 +4,20 @@ #' 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.) +#' @inheritParams type_errorbar #' #' @description Type constructor functions for producing polygon ribbons, which #' define a `y` interval (usually spanning from `ymin` to `ymax`) for each #' `x` value. Area plots are a special case of ribbon plot where `ymin` is #' set to 0 and `ymax` is set to `y`. +#' +#' @section Dodging ribbon plots: +#' +#' We support dodging for grouped ribbon plots, enabling similar functionality +#' to dodged errorbar and pointrange plots. However, it is strongly recommended +#' that dodging is only implemented for cases where the x-axis comprises a +#' limited number of discrete cases (e.g., coefficient or event-study plots). +#' See Examples. #' #' @examples #' x = 1:100 / 10 @@ -37,11 +46,45 @@ #' #' # Area plots are often used for time series charts #' tinyplot(AirPassengers, type = "area") +#' +#' # +#' ## Dodged ribbon/area plots +#' +#' # Dodged ribbon or area plots can be useful in cases where there is strong +#' # overlap across groups (and a limited number of discrete x-axis values). +#' +#' dat = data.frame( +#' x = rep(c("Before", "After"), each = 2), +#' grp = rep(c("A", "B"), 2), +#' y = c(10, 10.5, 15, 15.3), +#' lwr = c(8, 8.5, 13, 13.3), +#' upr = c(12, 12.5, 17, 17.3) +#' ) +#' +#' tinyplot( +#' y ~ x | grp, +#' data = dat, +#' ymin = lwr, ymax = upr, +#' type = type_ribbon(), +#' main = "Overlappling ribbons" +#' ) +#' +#' tinyplot( +#' y ~ x | grp, +#' data = dat, +#' ymin = lwr, ymax = upr, +#' type = type_ribbon(dodge = 0.1), +#' main = "Dodged ribbons" +#' ) +#' #' @export -type_ribbon = function(alpha = NULL) { +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), + data = data_ribbon(ribbon.alpha = alpha, dodge = dodge, fixed.pos = fixed.pos), name = "ribbon" ) class(out) = "tinyplot_type" @@ -64,7 +107,7 @@ draw_ribbon = function() { } -data_ribbon = function(ribbon.alpha = NULL) { +data_ribbon = function(ribbon.alpha = NULL, dodge = 0, fixed.pos = FALSE) { ribbon.alpha = sanitize_ribbon_alpha(ribbon.alpha) fun = function(settings, ...) { env2env(settings, environment(), c("datapoints", "xlabs", "null_by", "null_facet")) @@ -81,6 +124,11 @@ data_ribbon = function(ribbon.alpha = NULL) { } else { xlabs = NULL } + + # dodge (auto-detects x, xmin, xmax columns) + if (dodge != 0) { + datapoints = dodge_positions(datapoints, dodge, fixed.pos) + } if (null_by && null_facet) { xord = order(datapoints$x) diff --git a/man/dodge_positions.Rd b/man/dodge_positions.Rd index c8bdfa79..525f0f9b 100644 --- a/man/dodge_positions.Rd +++ b/man/dodge_positions.Rd @@ -4,12 +4,7 @@ \alias{dodge_positions} \title{Dodge positions for grouped data} \usage{ -dodge_positions( - datapoints, - dodge, - fixed.pos = TRUE, - cols = c("x", "xmin", "xmax") -) +dodge_positions(datapoints, dodge, fixed.pos = TRUE, cols = NULL) } \arguments{ \item{datapoints}{Data frame containing plot data with at least \code{x} and \code{by} @@ -22,8 +17,8 @@ performed.} number of groups in \code{by}. If \code{FALSE}, dodge positions are calculated separately for each unique x value.} -\item{cols}{Character vector of column names to dodge. Defaults to -\code{c("x", "xmin", "xmax")}.} +\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.} } \value{ Modified \code{datapoints} data frame with dodged positions. @@ -37,5 +32,8 @@ When \code{fixed.pos = TRUE}, all groups are dodged by the same amount across al x values, which is useful when x is categorical. When \code{fixed.pos = FALSE}, dodging is calculated independently for each x value, which is useful when the number of groups varies across x values. + +If \code{cols} is not specified, the function automatically dodges \code{x} and any +\code{xmin}/\code{xmax} columns that exist in the data. } \keyword{internal} diff --git a/man/type_ribbon.Rd b/man/type_ribbon.Rd index 910f2008..417e62fc 100644 --- a/man/type_ribbon.Rd +++ b/man/type_ribbon.Rd @@ -7,13 +7,25 @@ \usage{ type_area(alpha = NULL) -type_ribbon(alpha = NULL) +type_ribbon(alpha = NULL, dodge = 0, fixed.pos = FALSE) } \arguments{ \item{alpha}{numeric value between 0 and 1 specifying the opacity of ribbon shading If no \code{alpha} value is provided, then will default to \code{tpar("ribbon.alpha")} (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{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.} } \description{ Type constructor functions for producing polygon ribbons, which @@ -21,6 +33,16 @@ define a \code{y} interval (usually spanning from \code{ymin} to \code{ymax}) fo \code{x} value. Area plots are a special case of ribbon plot where \code{ymin} is set to 0 and \code{ymax} is set to \code{y}. } +\section{Dodging ribbon plots}{ + + +We support dodging for grouped ribbon plots, enabling similar functionality +to dodged errorbar and pointrange plots. However, it is strongly recommended +that dodging is only implemented for cases where the x-axis comprises a +limited number of discrete cases (e.g., coefficient or event-study plots). +See Examples. +} + \examples{ x = 1:100 / 10 y = sin(x) @@ -47,4 +69,35 @@ tinyplot(x, y, type = type_area()) # Area plots are often used for time series charts tinyplot(AirPassengers, type = "area") + +# +## Dodged ribbon/area plots + +# Dodged ribbon or area plots can be useful in cases where there is strong +# overlap across groups (and a limited number of discrete x-axis values). + +dat = data.frame( + x = rep(c("Before", "After"), each = 2), + grp = rep(c("A", "B"), 2), + y = c(10, 10.5, 15, 15.3), + lwr = c(8, 8.5, 13, 13.3), + upr = c(12, 12.5, 17, 17.3) +) + +tinyplot( + y ~ x | grp, + data = dat, + ymin = lwr, ymax = upr, + type = type_ribbon(), + main = "Overlappling ribbons" +) + +tinyplot( + y ~ x | grp, + data = dat, + ymin = lwr, ymax = upr, + type = type_ribbon(dodge = 0.1), + main = "Dodged ribbons" +) + } From b0f6a8597e9a477ede21b545d3823eb0fe891886 Mon Sep 17 00:00:00 2001 From: Grant McDermott Date: Fri, 21 Nov 2025 16:29:41 -0800 Subject: [PATCH 3/4] add test --- inst/tinytest/_tinysnapshot/ribbon_dodge.svg | 77 ++++++++++++++++++++ inst/tinytest/test-type_ribbon.R | 19 +++++ 2 files changed, 96 insertions(+) create mode 100644 inst/tinytest/_tinysnapshot/ribbon_dodge.svg diff --git a/inst/tinytest/_tinysnapshot/ribbon_dodge.svg b/inst/tinytest/_tinysnapshot/ribbon_dodge.svg new file mode 100644 index 00000000..834310bb --- /dev/null +++ b/inst/tinytest/_tinysnapshot/ribbon_dodge.svg @@ -0,0 +1,77 @@ + + + + + + + + + + + + + + + + + +grp +A +B + + + + + + + +x +y + + + + + +After +Before + + + + + + +8 +10 +12 +14 +16 + + + + + + + + + + + + + + + diff --git a/inst/tinytest/test-type_ribbon.R b/inst/tinytest/test-type_ribbon.R index 2b95bb62..4d96bdb4 100644 --- a/inst/tinytest/test-type_ribbon.R +++ b/inst/tinytest/test-type_ribbon.R @@ -54,3 +54,22 @@ f = function() { ) } expect_snapshot_plot(f, label = "ribbon_alpha50") + + +# Dodged ribbon test +f = function() { + dat = data.frame( + x = rep(c("Before", "After"), each = 2), + grp = rep(c("A", "B"), 2), + y = c(10, 10.5, 15, 15.3), + lwr = c(8, 8.5, 13, 13.3), + upr = c(12, 12.5, 17, 17.3) + ) + tinyplot( + y ~ x | grp, + data = dat, + ymin = lwr, ymax = upr, + type = type_ribbon(dodge = 0.1) + ) +} +expect_snapshot_plot(f, label = "ribbon_dodge") From b1fc07e4dd9180733583e5f8e19ef10d9ee1a5c3 Mon Sep 17 00:00:00 2001 From: Grant McDermott Date: Fri, 21 Nov 2025 16:34:33 -0800 Subject: [PATCH 4/4] news --- NEWS.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/NEWS.md b/NEWS.md index 853c89bc..04f267ba 100644 --- a/NEWS.md +++ b/NEWS.md @@ -19,6 +19,9 @@ where the formatting is also better._ - `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) ### Bug fixes