From 941c173f7660788da6e48e3259b52281f3223c1e Mon Sep 17 00:00:00 2001 From: Grant McDermott Date: Fri, 5 Jun 2026 18:09:23 -0700 Subject: [PATCH 1/6] barplot offset --- R/type_barplot.R | 55 +++++++++++++++++++++++++++++++++++++++------ man/type_barplot.Rd | 16 +++++++++++++ 2 files changed, 64 insertions(+), 7 deletions(-) diff --git a/R/type_barplot.R b/R/type_barplot.R index 9211ea90..2d003425 100644 --- a/R/type_barplot.R +++ b/R/type_barplot.R @@ -24,6 +24,11 @@ #' (if numeric) for the plot. #' @param xaxlabels a character vector with the axis labels for the `x` variable, #' defaulting to the levels of `x`. +#' @param offset numeric. Optional vertical offset(s) for bar baselines. When +#' provided, bars start at the offset value(s) rather than zero. Accepts +#' either a single scalar (applied to all bars) or a numeric vector with one +#' value per x-level (matched after any `xlevels` reordering). +#' Cannot be combined with `center`. #' @param drop.zeros logical. Should bars with zero height be dropped? If set #' to `FALSE` (default) a zero height bar is still drawn for which the border #' lines will still be visible. @@ -64,11 +69,20 @@ #' center = TRUE, flip = TRUE, facet.args = list(ncol = 1), yaxl = "percent") #' #' tinytheme() -#' +#' +#' # Manualy waterfall plot example using offset +#' d = data.frame(item = c("Sales", "Services", "Costs", "Returns", "TOTAL"), +#' value = c(100, 40, -80, -10, 60)) +#' d$item = factor(d$item, levels = d$item) +#' d$offset = c(0, cumsum(d$value[1:3]), 0) +#' tinyplot(value ~ item | I(value < 0), data = d, +#' type = type_barplot(offset = d$offset), legend = FALSE) +#' tinyplot_add(type = type_vline(4.5), lty = 2) +#' #' @export -type_barplot = function(width = 5/6, beside = FALSE, center = FALSE, FUN = NULL, xlevels = NULL, xaxlabels = NULL, drop.zeros = FALSE) { +type_barplot = function(width = 5/6, beside = FALSE, center = FALSE, offset = NULL, FUN = NULL, xlevels = NULL, xaxlabels = NULL, drop.zeros = FALSE) { out = list( - data = data_barplot(width = width, beside = beside, center = center, FUN = FUN, xlevels = xlevels, xaxlabels = xaxlabels, drop.zeros = drop.zeros), + data = data_barplot(width = width, beside = beside, center = center, offset = offset, FUN = FUN, xlevels = xlevels, xaxlabels = xaxlabels, drop.zeros = drop.zeros), draw = draw_rect(), name = "barplot" ) @@ -77,7 +91,7 @@ type_barplot = function(width = 5/6, beside = FALSE, center = FALSE, FUN = NULL, } #' @importFrom stats aggregate -data_barplot = function(width = 5/6, beside = FALSE, center = FALSE, FUN = NULL, xlevels = NULL, xaxlabels = NULL, drop.zeros = FALSE) { +data_barplot = function(width = 5/6, beside = FALSE, center = FALSE, offset = NULL, FUN = NULL, xlevels = NULL, xaxlabels = NULL, drop.zeros = FALSE) { fun = function(settings, ...) { env2env( settings, @@ -111,13 +125,30 @@ data_barplot = function(width = 5/6, beside = FALSE, center = FALSE, FUN = NULL, if (!is.factor(datapoints$by)) datapoints$by = factor(datapoints$by) if (!is.factor(datapoints$facet)) datapoints$facet = factor(datapoints$facet) - if (isFALSE(null_by) && isFALSE(facet_by) && !beside && any(datapoints$y < 0)) { + if (!is.null(offset)) { + if (!is.numeric(offset)) stop("'offset' must be numeric") + if (!isFALSE(center)) { + warning("'offset' cannot be combined with 'center'; ignoring 'center'") + center = FALSE + } + nx_levels = nlevels(datapoints$x) + if (length(offset) == 1L) { + offset = rep(offset, nx_levels) + } else if (length(offset) != nx_levels) { + stop(sprintf( + "'offset' must be length 1 or %d (number of x levels), got %d", + nx_levels, length(offset) + )) + } + } + if (is.null(offset) && isFALSE(null_by) && isFALSE(facet_by) && !beside && any(datapoints$y < 0)) { warning("'beside' must be TRUE if there are negative 'y' values") beside = TRUE } if (beside & !isFALSE(center)) { warning("'center' is currently only supported for 'beside = FALSE'") } + null_ylim = is.null(ylim) offset_sum = function(z, center = TRUE, na.rm = TRUE) { n = length(z) if (isFALSE(center) || n < 1L) return(0) @@ -153,7 +184,7 @@ data_barplot = function(width = 5/6, beside = FALSE, center = FALSE, FUN = NULL, nx = nlevels(df$x) nb = nlevels(df$by) - if (beside) { + if (beside) { xl = as.numeric(df$x) - width/2 + (as.numeric(df$by) - 1) * width/nb * as.numeric(!facet_by) xr = if (facet_by) xl + width else xl + width/nb yb = 0 @@ -185,7 +216,17 @@ data_barplot = function(width = 5/6, beside = FALSE, center = FALSE, FUN = NULL, datapoints$nx = NULL xlabs = 1L:nx names(xlabs) = levels(datapoints$x) - + + # Apply offset: shift bar baselines after rectangle computation + if (!is.null(offset)) { + off = offset[as.numeric(datapoints$x)] + datapoints$ymin = datapoints$ymin + off + datapoints$ymax = datapoints$ymax + off + if (null_ylim) { + ylim = range(c(0, datapoints$ymin, datapoints$ymax), na.rm = TRUE) * 1.02 + } + } + if (!isFALSE(center)) { if (is.null(yaxl)) { yaxl = abs diff --git a/man/type_barplot.Rd b/man/type_barplot.Rd index 35266a78..c86ec7c0 100644 --- a/man/type_barplot.Rd +++ b/man/type_barplot.Rd @@ -8,6 +8,7 @@ type_barplot( width = 5/6, beside = FALSE, center = FALSE, + offset = NULL, FUN = NULL, xlevels = NULL, xaxlabels = NULL, @@ -29,6 +30,12 @@ of an even number). Additionally it is possible to set \code{center = 2} or \code{center = 2.5} to indicate that centering should be after the second category or the mid-way in the third category, respectively.} +\item{offset}{numeric. Optional vertical offset(s) for bar baselines. When +provided, bars start at the offset value(s) rather than zero. Accepts +either a single scalar (applied to all bars) or a numeric vector with one +value per x-level (matched after any \code{xlevels} reordering). +Cannot be combined with \code{center}.} + \item{FUN}{a function to compute the summary statistic for \code{y} within each group of \code{x} in case of using a two-sided formula \code{y ~ x} (default: mean).} @@ -87,4 +94,13 @@ tinyplot(Freq ~ Eye | Hair, facet = ~ Sex, data = hec, type = "barplot", tinytheme() +# Manualy waterfall plot example using offset +d = data.frame(item = c("Sales", "Services", "Costs", "Returns", "TOTAL"), + value = c(100, 40, -80, -10, 60)) +d$item = factor(d$item, levels = d$item) +d$offset = c(0, cumsum(d$value[1:3]), 0) +tinyplot(value ~ item | I(value < 0), data = d, + type = type_barplot(offset = d$offset), legend = FALSE) +tinyplot_add(type = type_vline(4.5), lty = 2) + } From b01917884e84491af437fa064aa0048434b980a5 Mon Sep 17 00:00:00 2001 From: Grant McDermott Date: Fri, 5 Jun 2026 19:02:02 -0700 Subject: [PATCH 2/6] tests --- .../barplot_offset_beside_group.svg | 99 +++++++++++++++++++ .../_tinysnapshot/barplot_offset_flip.svg | 57 +++++++++++ .../_tinysnapshot/barplot_offset_scalar.svg | 75 ++++++++++++++ .../_tinysnapshot/barplot_offset_stacked.svg | 71 +++++++++++++ .../barplot_offset_waterfall.svg | 59 +++++++++++ inst/tinytest/test-type_barplot.R | 53 ++++++++++ 6 files changed, 414 insertions(+) create mode 100644 inst/tinytest/_tinysnapshot/barplot_offset_beside_group.svg create mode 100644 inst/tinytest/_tinysnapshot/barplot_offset_flip.svg create mode 100644 inst/tinytest/_tinysnapshot/barplot_offset_scalar.svg create mode 100644 inst/tinytest/_tinysnapshot/barplot_offset_stacked.svg create mode 100644 inst/tinytest/_tinysnapshot/barplot_offset_waterfall.svg diff --git a/inst/tinytest/_tinysnapshot/barplot_offset_beside_group.svg b/inst/tinytest/_tinysnapshot/barplot_offset_beside_group.svg new file mode 100644 index 00000000..1a7c6108 --- /dev/null +++ b/inst/tinytest/_tinysnapshot/barplot_offset_beside_group.svg @@ -0,0 +1,99 @@ + + + + + + + + + + + + + + + +group +1 +2 + + + + + + + +ID +extra + + +1 +2 +3 +4 +5 +6 +7 +8 +9 +10 + + + + + + + + +0 +1 +2 +3 +4 +5 +6 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/inst/tinytest/_tinysnapshot/barplot_offset_flip.svg b/inst/tinytest/_tinysnapshot/barplot_offset_flip.svg new file mode 100644 index 00000000..627db276 --- /dev/null +++ b/inst/tinytest/_tinysnapshot/barplot_offset_flip.svg @@ -0,0 +1,57 @@ + + + + + + + + + + + + + +y +x + + + + + + +0 +2 +4 +6 +8 +A +B +C + + + + + + + + + + + + + diff --git a/inst/tinytest/_tinysnapshot/barplot_offset_scalar.svg b/inst/tinytest/_tinysnapshot/barplot_offset_scalar.svg new file mode 100644 index 00000000..17d6d75b --- /dev/null +++ b/inst/tinytest/_tinysnapshot/barplot_offset_scalar.svg @@ -0,0 +1,75 @@ + + + + + + + + + + + + + +ID +extra +1 +2 +3 +4 +5 +6 +7 +8 +9 +10 + + + + + + + + +0 +2 +4 +6 +8 +10 +12 + + + + + + + + + + + + + + + + + + + + diff --git a/inst/tinytest/_tinysnapshot/barplot_offset_stacked.svg b/inst/tinytest/_tinysnapshot/barplot_offset_stacked.svg new file mode 100644 index 00000000..2883bb5d --- /dev/null +++ b/inst/tinytest/_tinysnapshot/barplot_offset_stacked.svg @@ -0,0 +1,71 @@ + + + + + + + + + + + + + + + +Survived +No +Yes + + + + + + + +Sex +Freq + + +Male +Female + + + + + + +0 +5 +10 +15 +20 + + + + + + + + + + + + + + diff --git a/inst/tinytest/_tinysnapshot/barplot_offset_waterfall.svg b/inst/tinytest/_tinysnapshot/barplot_offset_waterfall.svg new file mode 100644 index 00000000..a73e64c9 --- /dev/null +++ b/inst/tinytest/_tinysnapshot/barplot_offset_waterfall.svg @@ -0,0 +1,59 @@ + + + + + + + + + + + + + +x +y +A +B +C +D + + + + + + +0 +5 +10 +15 +20 + + + + + + + + + + + + + + diff --git a/inst/tinytest/test-type_barplot.R b/inst/tinytest/test-type_barplot.R index 712fdf18..8e8b2e9d 100644 --- a/inst/tinytest/test-type_barplot.R +++ b/inst/tinytest/test-type_barplot.R @@ -87,3 +87,56 @@ f = function() { tinyplot(~ Species, data = iris) } expect_snapshot_plot(f, label = "barplot_formula_univariate") + + +# +## offset argument + +# Scalar offset shifts all bars +f = function() { + tinyplot(extra ~ ID, data = sleep[sleep$group == 1, ], + type = type_barplot(offset = 10)) +} +expect_snapshot_plot(f, label = "barplot_offset_scalar") + +# Vector offset (waterfall pattern) +f = function() { + d = data.frame(x = factor(LETTERS[1:4]), y = c(10, 5, -3, 8)) + d$off = c(0, cumsum(d$y[-4])) + tinyplot(y ~ x, data = d, type = type_barplot(offset = d$off)) +} +expect_snapshot_plot(f, label = "barplot_offset_waterfall") + +# Offset + beside with grouping +f = function() { + tinyplot(extra ~ ID | group, data = sleep, + type = type_barplot(beside = TRUE, offset = rep(1, 10))) +} +expect_snapshot_plot(f, label = "barplot_offset_beside_group") + +# Offset + stacked +f = function() { + tinyplot(Freq ~ Sex | Survived, data = as.data.frame(Titanic)[1:8, ], + type = type_barplot(offset = c(10, 20))) +} +expect_snapshot_plot(f, label = "barplot_offset_stacked") + +# Offset + flip +f = function() { + d = data.frame(x = factor(LETTERS[1:3]), y = c(5, 3, 7)) + tinyplot(y ~ x, data = d, type = type_barplot(offset = c(2, 4, 1)), flip = TRUE) +} +expect_snapshot_plot(f, label = "barplot_offset_flip") + +# Offset + center warns and ignores center +expect_warning( + tinyplot(~ cyl | vs, data = mtcars, + type = type_barplot(offset = c(5, 10, 15), center = TRUE)), + "cannot be combined" +) + +# Wrong offset length errors +expect_error( + tinyplot(~ cyl, data = mtcars, type = type_barplot(offset = c(1, 2))), + "must be length" +) From 7ecce41f156164d92e0b242f6c71fcea276300cc Mon Sep 17 00:00:00 2001 From: Grant McDermott Date: Fri, 5 Jun 2026 19:03:20 -0700 Subject: [PATCH 3/6] news --- NEWS.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/NEWS.md b/NEWS.md index c18e445a..2a3917d7 100644 --- a/NEWS.md +++ b/NEWS.md @@ -143,6 +143,9 @@ Theme fixes: `plt(..., facet = ~z, facet.args = list(ncol = 1))`. Analogously, `plt(..., facet = 1 ~ z)` can be used as a shortcut for `plt(..., facet = ~ z, facet.args = list(nrow = 1))`. (#562 @zeileis) +- `type_barplot()` gains an `offset` argument for shifting bar baselines away + from zero. Accepts a scalar or per-x-level numeric vector. Useful for + waterfall plots and floating bars. (#611 @grantmcdermott) ### Bug fixes From ff4b2e5c1665429b7f938bee8efa282918b0b3ca Mon Sep 17 00:00:00 2001 From: Grant McDermott Date: Fri, 5 Jun 2026 19:04:05 -0700 Subject: [PATCH 4/6] tinylabel format_percent duplicate gotcha - unrelated --- R/tinylabel.R | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/R/tinylabel.R b/R/tinylabel.R index baf09c02..e6cc647a 100644 --- a/R/tinylabel.R +++ b/R/tinylabel.R @@ -136,9 +136,10 @@ labeller_fun = function(label = "percent") { format_percent = function(x) { max_decimals = 5L pct = as.numeric(x) * 100 + upct = unique(pct) d = Find( function(d) { - length(unique(sprintf(paste0('%.', d, 'f%%'), pct))) == length(pct) + length(unique(sprintf(paste0('%.', d, 'f%%'), upct))) == length(upct) }, 0:max_decimals ) %||% From 1e6b5d17284169787cd1150d3b17caafa1298d86 Mon Sep 17 00:00:00 2001 From: Grant McDermott Date: Fri, 5 Jun 2026 19:13:55 -0700 Subject: [PATCH 5/6] typo --- R/type_barplot.R | 2 +- man/type_barplot.Rd | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/R/type_barplot.R b/R/type_barplot.R index 2d003425..92c3b883 100644 --- a/R/type_barplot.R +++ b/R/type_barplot.R @@ -70,7 +70,7 @@ #' #' tinytheme() #' -#' # Manualy waterfall plot example using offset +#' # Manual waterfall plot example using offset #' d = data.frame(item = c("Sales", "Services", "Costs", "Returns", "TOTAL"), #' value = c(100, 40, -80, -10, 60)) #' d$item = factor(d$item, levels = d$item) diff --git a/man/type_barplot.Rd b/man/type_barplot.Rd index c86ec7c0..2fd553dc 100644 --- a/man/type_barplot.Rd +++ b/man/type_barplot.Rd @@ -94,7 +94,7 @@ tinyplot(Freq ~ Eye | Hair, facet = ~ Sex, data = hec, type = "barplot", tinytheme() -# Manualy waterfall plot example using offset +# Manual waterfall plot example using offset d = data.frame(item = c("Sales", "Services", "Costs", "Returns", "TOTAL"), value = c(100, 40, -80, -10, 60)) d$item = factor(d$item, levels = d$item) From b28997786167b182d0ce6334321054eb268783ab Mon Sep 17 00:00:00 2001 From: Grant McDermott Date: Fri, 5 Jun 2026 19:15:56 -0700 Subject: [PATCH 6/6] typo --- R/type_barplot.R | 2 +- man/type_barplot.Rd | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/R/type_barplot.R b/R/type_barplot.R index 92c3b883..af36f2de 100644 --- a/R/type_barplot.R +++ b/R/type_barplot.R @@ -72,7 +72,7 @@ #' #' # Manual waterfall plot example using offset #' d = data.frame(item = c("Sales", "Services", "Costs", "Returns", "TOTAL"), -#' value = c(100, 40, -80, -10, 60)) +#' value = c(100, 40, -80, -10, 50)) #' d$item = factor(d$item, levels = d$item) #' d$offset = c(0, cumsum(d$value[1:3]), 0) #' tinyplot(value ~ item | I(value < 0), data = d, diff --git a/man/type_barplot.Rd b/man/type_barplot.Rd index 2fd553dc..1c5ee15c 100644 --- a/man/type_barplot.Rd +++ b/man/type_barplot.Rd @@ -96,7 +96,7 @@ tinytheme() # Manual waterfall plot example using offset d = data.frame(item = c("Sales", "Services", "Costs", "Returns", "TOTAL"), - value = c(100, 40, -80, -10, 60)) + value = c(100, 40, -80, -10, 50)) d$item = factor(d$item, levels = d$item) d$offset = c(0, cumsum(d$value[1:3]), 0) tinyplot(value ~ item | I(value < 0), data = d,