From 4a751274482effe3d8abba821512a0b35f919ad5 Mon Sep 17 00:00:00 2001 From: Grant McDermott Date: Mon, 23 Mar 2026 12:24:16 -0700 Subject: [PATCH 1/4] save group_offset to tinyplot_env and invoke when relevant --- R/tinyplot.R | 7 +++++-- R/type_boxplot.R | 32 +++++++++++++++++++------------- R/type_jitter.R | 17 ++++++++++++++++- R/type_violin.R | 18 +++++++++++++++--- 4 files changed, 55 insertions(+), 19 deletions(-) diff --git a/R/tinyplot.R b/R/tinyplot.R index 71e125d1..692a9740 100644 --- a/R/tinyplot.R +++ b/R/tinyplot.R @@ -795,10 +795,12 @@ tinyplot.default = function( ribbon.alpha = sanitize_ribbon_alpha(NULL), # misc - flip = flip, - dodge = NULL, + add = add, by = by, + dodge = NULL, dots = dots, + flip = flip, + group_offsets = NULL, type_info = list() # pass type-specific info from type_data to type_draw ) @@ -873,6 +875,7 @@ tinyplot.default = function( # ensure axis aligment of any added layers if (!add) { assign("xlabs_orig", settings[["xlabs"]], envir = get(".tinyplot_env", envir = parent.env(environment()))) + assign(".group_offsets", settings[["group_offsets"]], envir = get(".tinyplot_env", envir = parent.env(environment()))) } else { align_layer(settings) } diff --git a/R/type_boxplot.R b/R/type_boxplot.R index d46fe330..6df64fc8 100644 --- a/R/type_boxplot.R +++ b/R/type_boxplot.R @@ -43,7 +43,7 @@ type_boxplot = function( boxwex = boxwex, staplewex = staplewex, outwex = outwex), - data = data_boxplot(), + data = data_boxplot(boxwex = boxwex), name = "boxplot" ) class(out) = "tinyplot_type" @@ -60,13 +60,9 @@ draw_boxplot = function(range, width, varwidth, notch, outline, boxwex, staplewe # Handle multiple groups if (ngrps > 1 && isFALSE(x_by) && isFALSE(facet_by)) { - boxwex_orig = boxwex + group_offsets = get_environment_variable(".group_offsets") boxwex = boxwex / ngrps - 0.01 - at_ix = at_ix + seq( - -((boxwex_orig - boxwex) / 2), - ((boxwex_orig - boxwex) / 2), - length.out = ngrps - )[iby] + at_ix = at_ix + group_offsets[iby] } boxplot( @@ -93,7 +89,7 @@ draw_boxplot = function(range, width, varwidth, notch, outline, boxwex, staplewe -data_boxplot = function() { +data_boxplot = function(boxwex = 0.8) { fun = function(settings, ...) { env2env(settings, environment(), c("datapoints", "by", "facet", "null_facet", "null_palette", "x", "col", "bg", "null_by")) # Convert x to factor if it's not already @@ -134,6 +130,19 @@ data_boxplot = function() { by = if (length(unique(datapoints$by)) > 1) datapoints$by else by facet = if (length(unique(datapoints$facet)) > 1) datapoints$facet else facet + # Compute group offsets for multi-group boxplots + ngrps = length(unique(datapoints$by)) + if (ngrps > 1 && !settings$x_by) { + boxwex_grp = boxwex / ngrps - 0.01 + group_offsets = seq( + -((boxwex - boxwex_grp) / 2), + ((boxwex - boxwex_grp) / 2), + length.out = ngrps + ) + } else { + group_offsets = rep(0, max(ngrps, 1)) + } + # legend customizations settings$legend_args[["pch"]] = settings$legend_args[["pch"]] %||% 22 settings$legend_args[["pt.cex"]] = settings$legend_args[["pt.cex"]] %||% 3.5 @@ -150,11 +159,8 @@ data_boxplot = function() { "col", "bg", "by", - "facet")) + "facet", + "group_offsets")) } return(fun) } - - - - diff --git a/R/type_jitter.R b/R/type_jitter.R index 6a2ea6f1..a1f22ecd 100644 --- a/R/type_jitter.R +++ b/R/type_jitter.R @@ -40,7 +40,7 @@ jitter_restore = function(obj, factor, amount) { data_jitter = function(factor, amount) { fun = function(settings, ...) { - env2env(settings, environment(), "datapoints") + env2env(settings, environment(), c("datapoints", "add")) x = datapoints$x y = datapoints$y @@ -60,6 +60,21 @@ data_jitter = function(factor, amount) { } else { ylabs = NULL } + + # Apply group offsets from base layer (e.g., boxplot, violin) + group_offsets = get_environment_variable(".group_offsets") + if (isTRUE(add) && !is.null(group_offsets) && is.factor(datapoints$by)) { + # Ensure x uses integer factor codes to match the base layer + if (is.null(xlabs)) { + xf = as.factor(x) + xlvls = levels(xf) + xlabs = seq_along(xlvls) + names(xlabs) = xlvls + x = as.integer(xf) + } + x = x + group_offsets[as.integer(datapoints$by)] + } + x = jitter_restore(x, factor = factor, amount = amount) y = jitter_restore(y, factor = factor, amount = amount) diff --git a/R/type_violin.R b/R/type_violin.R index f1d7969d..24d52577 100644 --- a/R/type_violin.R +++ b/R/type_violin.R @@ -151,6 +151,18 @@ data_violin = function(bw = "nrd0", adjust = 1, kernel = "gaussian", n = 512, } } + # Compute group offsets for multi-group violins + if (ngrps > 1 && isFALSE(x_by) && isFALSE(facet_by)) { + xwidth_grp = width / ngrps - 0.01 + group_offsets = seq( + -((width - xwidth_grp) / 2), + ((width - xwidth_grp) / 2), + length.out = ngrps + ) + } else { + group_offsets = rep(0, max(ngrps, 1)) + } + datapoints = lapply(seq_along(datapoints), function(d) { dat = datapoints[[d]] if (trim) { @@ -182,7 +194,7 @@ data_violin = function(bw = "nrd0", adjust = 1, kernel = "gaussian", n = 512, xwidth = xwidth_orig / ngrps - 0.01 x = rescale_num(x, to = c(0, xwidth)) x = x + as.numeric(sub("^([0-9]+)\\..*", "\\1", names(datapoints)[d])) - xwidth/2 - x = x + seq(-((xwidth_orig - xwidth) / 2), ((xwidth_orig - xwidth) / 2), length.out = ngrps)[dat$by[1]] + x = x + group_offsets[dat$by[1]] } else if (nfacets > 1) { x = rescale_num(x, to = c(0, xwidth)) x = x + as.numeric(sub("^([0-9]+)\\..*", "\\1", names(datapoints)[d])) - xwidth/2 @@ -221,9 +233,9 @@ data_violin = function(bw = "nrd0", adjust = 1, kernel = "gaussian", n = 512, "ylab", "xlabs", "col", - "bg" + "bg", + "group_offsets" )) } return(fun) } - From e4786b6edce16d7671a7665e29b19557c3246819 Mon Sep 17 00:00:00 2001 From: Grant McDermott Date: Mon, 23 Mar 2026 12:27:33 -0700 Subject: [PATCH 2/4] tests --- ...tinyplot_add_jitter_on_grouped_boxplot.svg | 180 ++++++++++++++++++ .../tinyplot_add_jitter_on_grouped_violin.svg | 139 ++++++++++++++ inst/tinytest/test-tinyplot_add.R | 20 +- 3 files changed, 337 insertions(+), 2 deletions(-) create mode 100644 inst/tinytest/_tinysnapshot/tinyplot_add_jitter_on_grouped_boxplot.svg create mode 100644 inst/tinytest/_tinysnapshot/tinyplot_add_jitter_on_grouped_violin.svg diff --git a/inst/tinytest/_tinysnapshot/tinyplot_add_jitter_on_grouped_boxplot.svg b/inst/tinytest/_tinysnapshot/tinyplot_add_jitter_on_grouped_boxplot.svg new file mode 100644 index 00000000..0a0032ed --- /dev/null +++ b/inst/tinytest/_tinysnapshot/tinyplot_add_jitter_on_grouped_boxplot.svg @@ -0,0 +1,180 @@ + + + + + + + + + + + + + + + +supp +OJ +VC + + + + + + + +dose +len + + + + + + +0.5 +1 +2 + + + + + + + + +5 +10 +15 +20 +25 +30 +35 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/inst/tinytest/_tinysnapshot/tinyplot_add_jitter_on_grouped_violin.svg b/inst/tinytest/_tinysnapshot/tinyplot_add_jitter_on_grouped_violin.svg new file mode 100644 index 00000000..4a8e977e --- /dev/null +++ b/inst/tinytest/_tinysnapshot/tinyplot_add_jitter_on_grouped_violin.svg @@ -0,0 +1,139 @@ + + + + + + + + + + + + + + + +supp +OJ +VC + + + + + + + +dose +len + + + + + + +0.5 +1 +2 + + + + + + +0 +10 +20 +30 +40 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/inst/tinytest/test-tinyplot_add.R b/inst/tinytest/test-tinyplot_add.R index 9b72ddac..1f5914ef 100644 --- a/inst/tinytest/test-tinyplot_add.R +++ b/inst/tinytest/test-tinyplot_add.R @@ -95,10 +95,26 @@ f = function() { expect_snapshot_plot(f, label = "tinyplot_add_no_recursive_margins") -# jitter layer on top of boxplot (#559) -set.seed(42) +# jitter layer on top of violin (#559) f = function() { + set.seed(42) tinyplot(Sepal.Length ~ Species, data = iris, type = "violin") tinyplot_add(type = "jitter", cex = 0.5, alpha = 0.3) } expect_snapshot_plot(f, label = "tinyplot_add_jitter_on_violin") + +# jitter layer on top of grouped boxplot (#493) +f = function() { + set.seed(42) + tinyplot(len ~ dose | supp, data = ToothGrowth, type = "boxplot") + tinyplot_add(type = "jitter", cex = 0.5, alpha = 0.3) +} +expect_snapshot_plot(f, label = "tinyplot_add_jitter_on_grouped_boxplot") + +# jitter layer on top of grouped violin (#493) +f = function() { + set.seed(42) + tinyplot(len ~ dose | supp, data = ToothGrowth, type = "violin", bg = 0.2) + tinyplot_add(type = "jitter", cex = 0.5, alpha = 0.3) +} +expect_snapshot_plot(f, label = "tinyplot_add_jitter_on_grouped_violin") From 1046542337c7860917978f5597938b3b2397e47b Mon Sep 17 00:00:00 2001 From: Grant McDermott Date: Mon, 23 Mar 2026 13:20:21 -0700 Subject: [PATCH 3/4] specify which axis is offset, add ridge support - ridge support still doesn't look great b/c (a) bottom jitter is being cut off, and (b) color mismatch... but those are separate issues. --- R/tinyplot.R | 2 ++ R/type_boxplot.R | 5 ++++- R/type_jitter.R | 34 ++++++++++++++++++++++++---------- R/type_ridge.R | 9 ++++++++- R/type_violin.R | 4 +++- 5 files changed, 41 insertions(+), 13 deletions(-) diff --git a/R/tinyplot.R b/R/tinyplot.R index 692a9740..2ba8f847 100644 --- a/R/tinyplot.R +++ b/R/tinyplot.R @@ -801,6 +801,7 @@ tinyplot.default = function( dots = dots, flip = flip, group_offsets = NULL, + offsets_axis = NULL, type_info = list() # pass type-specific info from type_data to type_draw ) @@ -876,6 +877,7 @@ tinyplot.default = function( if (!add) { assign("xlabs_orig", settings[["xlabs"]], envir = get(".tinyplot_env", envir = parent.env(environment()))) assign(".group_offsets", settings[["group_offsets"]], envir = get(".tinyplot_env", envir = parent.env(environment()))) + assign(".offsets_axis", settings[["offsets_axis"]], envir = get(".tinyplot_env", envir = parent.env(environment()))) } else { align_layer(settings) } diff --git a/R/type_boxplot.R b/R/type_boxplot.R index 6df64fc8..6610846c 100644 --- a/R/type_boxplot.R +++ b/R/type_boxplot.R @@ -142,6 +142,7 @@ data_boxplot = function(boxwex = 0.8) { } else { group_offsets = rep(0, max(ngrps, 1)) } + offsets_axis = "x" # legend customizations settings$legend_args[["pch"]] = settings$legend_args[["pch"]] %||% 22 @@ -160,7 +161,9 @@ data_boxplot = function(boxwex = 0.8) { "bg", "by", "facet", - "group_offsets")) + "group_offsets", + "offsets_axis" + )) } return(fun) } diff --git a/R/type_jitter.R b/R/type_jitter.R index a1f22ecd..21363f0d 100644 --- a/R/type_jitter.R +++ b/R/type_jitter.R @@ -61,18 +61,32 @@ data_jitter = function(factor, amount) { ylabs = NULL } - # Apply group offsets from base layer (e.g., boxplot, violin) + # Apply group offsets from base layer (e.g., boxplot, violin, ridge) group_offsets = get_environment_variable(".group_offsets") - if (isTRUE(add) && !is.null(group_offsets) && is.factor(datapoints$by)) { - # Ensure x uses integer factor codes to match the base layer - if (is.null(xlabs)) { - xf = as.factor(x) - xlvls = levels(xf) - xlabs = seq_along(xlvls) - names(xlabs) = xlvls - x = as.integer(xf) + offsets_axis = get_environment_variable(".offsets_axis") + if (isTRUE(add) && !is.null(group_offsets)) { + if (identical(offsets_axis, "x") && is.factor(datapoints$by)) { + # x-axis offsets (boxplot, violin): keyed by group level + if (is.null(xlabs)) { + xf = as.factor(x) + xlvls = levels(xf) + xlabs = seq_along(xlvls) + names(xlabs) = xlvls + x = as.integer(xf) + } + x = x + group_offsets[as.integer(datapoints$by)] + } else if (identical(offsets_axis, "y")) { + # y-axis offsets (ridge): keyed by y-level name + if (is.null(ylabs)) { + yf = as.factor(y) + ylvls = levels(yf) + ylabs = seq_along(ylvls) + names(ylabs) = ylvls + y = as.integer(yf) + } + y_labels = names(ylabs)[y] + y = group_offsets[y_labels] } - x = x + group_offsets[as.integer(datapoints$by)] } x = jitter_restore(x, factor = factor, amount = amount) diff --git a/R/type_ridge.R b/R/type_ridge.R index 24133f7f..605a0c40 100644 --- a/R/type_ridge.R +++ b/R/type_ridge.R @@ -332,6 +332,11 @@ data_ridge = function(bw = "nrd0", adjust = 1, kernel = "gaussian", n = 512, } datapoints = do.call(rbind, lapply(datapoints, offset_z)) + # Store y-level offsets for added layers (e.g., jitter) + ylevs = unique(datapoints$y) + group_offsets = setNames(seq_along(ylevs) - 1L, ylevs) + offsets_axis = "y" + if (y_by) { datapoints$y = factor(datapoints$y) datapoints$by = factor(datapoints$y, levels = rev(levels(datapoints$y))) @@ -420,7 +425,9 @@ data_ridge = function(bw = "nrd0", adjust = 1, kernel = "gaussian", n = 512, "datapoints", "yaxt", "ylim", - "type_info" + "type_info", + "group_offsets", + "offsets_axis" )) } return(fun) diff --git a/R/type_violin.R b/R/type_violin.R index 24d52577..42eb2dd5 100644 --- a/R/type_violin.R +++ b/R/type_violin.R @@ -162,6 +162,7 @@ data_violin = function(bw = "nrd0", adjust = 1, kernel = "gaussian", n = 512, } else { group_offsets = rep(0, max(ngrps, 1)) } + offsets_axis = "x" datapoints = lapply(seq_along(datapoints), function(d) { dat = datapoints[[d]] @@ -234,7 +235,8 @@ data_violin = function(bw = "nrd0", adjust = 1, kernel = "gaussian", n = 512, "xlabs", "col", "bg", - "group_offsets" + "group_offsets", + "offsets_axis" )) } return(fun) From 4b696285d3fa0dfe97bf74f1d08e7848f30b5672 Mon Sep 17 00:00:00 2001 From: Grant McDermott Date: Mon, 23 Mar 2026 13:32:12 -0700 Subject: [PATCH 4/4] news --- NEWS.md | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/NEWS.md b/NEWS.md index 16a5430d..255fc6ff 100644 --- a/NEWS.md +++ b/NEWS.md @@ -23,10 +23,13 @@ where the formatting is also better._ ### Bug fixes -- Jittered plots now support Date/POSIXt axes. Thanks to @wachtermh for the bug - report and @vincentarelbundock for the code contribution. (#327) -- `tinyplot_add(type = "jitter")` no longer errors when layered on top of - boxplot, violin, or similar categorical plot types. (#560 @grantmcdermott) +- Several improvements/fixes to jittered plots and layering: + - Jittered plots now support Date/POSIXt axes. Thanks to @wachtermh for the + bug report and @vincentarelbundock for the code contribution. (#327) + - `tinyplot_add(type = "jitter")` no longer errors when layered on top of + boxplot, violin, or similar categorical plot types. (#560 @grantmcdermott) + - Jitter layers added via `tinyplot_add()` now align correctly with grouped + (offset) boxplot, violin, and ridge base layers. (#493 @grantmcdermott) ### Internals