From eed198c7eb4db324bb0ae91db6ac72ecbcdf81d0 Mon Sep 17 00:00:00 2001 From: Grant McDermott Date: Sun, 13 Sep 2026 10:20:10 -0700 Subject: [PATCH 01/11] smart categorical axis padding --- R/assertions.R | 3 ++- R/facet.R | 9 +++++++-- R/lim.R | 22 ++++++++++++++++++++++ R/type_lines.R | 4 ++++ R/type_pointrange.R | 2 ++ R/type_points.R | 4 ++++ 6 files changed, 41 insertions(+), 3 deletions(-) diff --git a/R/assertions.R b/R/assertions.R index 2b8ed3438..1cd781727 100644 --- a/R/assertions.R +++ b/R/assertions.R @@ -346,7 +346,8 @@ known_type_hints = c( "legend_border_fg", # legend swatch border is always par("fg") "legend_fills_from_col", # legend swatch fill comes from `col` "legend_fills_from_seq_palette", # ... or from the colour's sequential ramp - "legend_reversed" # list the key bottom-up, not top-down + "legend_reversed", # list the key bottom-up, not top-down + "pads_cat_axis" # buffer the ends of a categorical axis ) ## Validate a type's declared hints. diff --git a/R/facet.R b/R/facet.R index 3c1397d47..58a43ce16 100644 --- a/R/facet.R +++ b/R/facet.R @@ -525,8 +525,13 @@ draw_facet_window = function( # barplot_facet_free snapshot will catch you if you change that. .padded_x = !is.null(xpad) && !.derived_x # lim_args() got to it first .padded_y = !is.null(ypad) && !.derived_y - if (!.padded_x) xext = expand_lim(xext, xpad %||% 0.04) - if (!.padded_y) yext = expand_lim(yext, ypad %||% 0.04) + # A panel that derives its own categorical range asks for the same + # buffer lim_args() gives an unfaceted one, so the two agree. + .catpad = isTRUE(type_hints[["pads_cat_axis"]]) + .xp = xpad %||% (if (.catpad && length(.fxlabs)) cat_pad(xext)) + .yp = ypad %||% (if (.catpad && length(.fylabs)) cat_pad(yext)) + if (!.padded_x) xext = expand_lim(xext, .xp %||% 0.04) + if (!.padded_y) yext = expand_lim(yext, .yp %||% 0.04) # A facet with a single distinct x (or y) value yields a zero-width # extent, which par(usr=) rejects. Mirror base plot.window() and pad # a degenerate range symmetrically so the facet still draws. (#668) diff --git a/R/lim.R b/R/lim.R index c3437e493..fa1313cfa 100644 --- a/R/lim.R +++ b/R/lim.R @@ -51,6 +51,13 @@ lim_args = function(settings) { if (null_xlim && !is.null(xaxb) && !prop_lim) xlim = range(c(xlim, xaxb)) if (null_ylim && !is.null(yaxb) && !prop_lim) ylim = range(c(ylim, yaxb)) + # A categorical axis asks for its buffer in category widths; xpad speaks in + # fractions of the range. Convert and let the existing machinery apply it. + if (isTRUE(type_hints[["pads_cat_axis"]])) { + if (is.null(xpad) && !is.null(xlabs)) xpad = cat_pad(xlim) + if (is.null(ypad) && !is.null(ylabs)) ypad = cat_pad(ylim) + } + if (!is.null(xpad)) { xlim = expand_lim(widen_degenerate(xlim), xpad, log = grepl("x", log, fixed = TRUE)) @@ -103,6 +110,21 @@ widen_degenerate = function(lim) { lim + c(-1, 1) * (if (lim[1L] == 0) 1 else 0.4 * abs(lim[1L])) } + +# The categorical buffer as the fraction of the range that `xpad` wants, one +# category being one unit. Takes `lim` rather than a category count because +# dodging widens the span past n-1, and the gutter should clear what is drawn. +# Past a span of reach/0.04 (eight categories at the default) base's own 4% +# already reaches further, so NULL hands those plots back to it untouched. A +# quarter of a category, not the half box-like types take: a box is most of a +# category wide and needs the room; a point is not. +cat_pad = function(lim, reach = 0.25) { + span = abs(diff(lim)) + if (!is.finite(span) || span == 0 || span > reach / 0.04) return(NULL) + reach / span +} + + # Resolve a user-supplied x/ylim that may be a scalar or contains a single NA. # `lim` : raw user value (already known to be non-NULL) # `drng` : data range, 2-element numeric, i.e. range(..., finite = TRUE) diff --git a/R/type_lines.R b/R/type_lines.R index 889892d01..cc4872ba1 100644 --- a/R/type_lines.R +++ b/R/type_lines.R @@ -109,6 +109,8 @@ data_lines = function(dodge = 0, fixed.dodge = FALSE, xlevels = NULL, xord = NUL xlabs = seq_along(xlvls) names(xlabs) = xlvls datapoints[["x"]] = as.integer(datapoints[["x"]]) + # More generous padding if categorical x-axis; better plot aesthetic + settings[["type_hints"]][["pads_cat_axis"]] = TRUE } else { xlabs = NULL } @@ -117,6 +119,8 @@ data_lines = function(dodge = 0, fixed.dodge = FALSE, xlevels = NULL, xord = NUL ylabs = seq_along(ylvls) names(ylabs) = ylvls datapoints[["y"]] = as.integer(datapoints[["y"]]) + # More generous padding if categorical y-axis; better plot aesthetic + settings[["type_hints"]][["pads_cat_axis"]] = TRUE } else { ylabs = NULL } diff --git a/R/type_pointrange.R b/R/type_pointrange.R index 2e64df950..14c147312 100644 --- a/R/type_pointrange.R +++ b/R/type_pointrange.R @@ -73,6 +73,8 @@ data_pointrange = function(dodge, fixed.dodge, xlevels = NULL, xord = "asis", or xlabs = seq_along(xlvls) names(xlabs) = xlvls datapoints$x = as.integer(datapoints$x) + # More generous padding for better plot aesthetic + settings[["type_hints"]][["pads_cat_axis"]] = TRUE } datapoints$xmin = datapoints$x datapoints$xmax = datapoints$x diff --git a/R/type_points.R b/R/type_points.R index 31c2fc55e..e25897309 100644 --- a/R/type_points.R +++ b/R/type_points.R @@ -93,6 +93,8 @@ data_points = function(clim = c(0.5, 2.5), dodge = 0, fixed.dodge = FALSE, xleve xlabs = seq_along(xlvls) names(xlabs) = xlvls datapoints$x = as.integer(datapoints$x) + # More generous padding if categorical x-axis; better plot aesthetic + settings[["type_hints"]][["pads_cat_axis"]] = TRUE } else { xlabs = NULL } @@ -101,6 +103,8 @@ data_points = function(clim = c(0.5, 2.5), dodge = 0, fixed.dodge = FALSE, xleve ylabs = seq_along(ylvls) names(ylabs) = ylvls datapoints$y = as.integer(datapoints$y) + # More generous padding if categorical y-axis; better plot aesthetic + settings[["type_hints"]][["pads_cat_axis"]] = TRUE } else { ylabs = NULL } From f2c1123770eb0bef35ecdbba79dcd3706598ca13 Mon Sep 17 00:00:00 2001 From: Grant McDermott Date: Sun, 13 Sep 2026 12:48:15 -0700 Subject: [PATCH 02/11] docs --- R/tinyplot.R | 19 +++++++++++++++---- R/tpar.R | 2 +- man/tinyplot.Rd | 19 +++++++++++++++---- man/tpar.Rd | 2 +- 4 files changed, 32 insertions(+), 10 deletions(-) diff --git a/R/tinyplot.R b/R/tinyplot.R index 021feb432..7ecb540c3 100644 --- a/R/tinyplot.R +++ b/R/tinyplot.R @@ -276,10 +276,21 @@ #' used for the x-axis and y-axis, respectively. See #' \code{\link[graphics]{par}} for the possible values. #' @param xpad,ypad numeric specifying how much padding, as a fraction of the -#' data range, should be added to each end of the axes. Defaults to `NULL`, -#' in which case behaviour depends on the value of `x/yaxs`. In most cases, -#' this will translate to a value of `0.04`, i.e. 4% padding on each end (see -#' \code{\link[graphics]{par}}). +#' data range, should be added to each end of the axes. If `NULL` (the +#' default), follows an automatic padding heuristic that tries to optimize for +#' plot aesthetic depending on the axis type: +#' +#' - numeric axis: inherited from `x/yaxs`. Under the default `x/yaxs = "r"` +#' style, this means `0.04`, i.e. 4% padding on each end (see +#' \code{\link[graphics]{par}}). +#' +#' - categorical axis: behaviour depends on the glyph-type and number of +#' unique categories, and therefore tick marks. For example, point-like +#' glyphs with numerous (8+) categories inherit the same padding logic as a +#' numeric axis (i.e., usually 4%). For cases with fewer unique categories, +#' the padding will instead correspond to 25% of the gap between interior +#' tick marks, so that the end categories aren't drawn flush against the +#' plot frame. #' @param xaxb,yaxb numeric vector (or character vector, if appropriate) giving #' the break points at which the axis tick-marks are to be drawn. Break points #' outside the range of the data will be ignored if the associated axis diff --git a/R/tpar.R b/R/tpar.R index 0c542bf62..b6efa10ad 100644 --- a/R/tpar.R +++ b/R/tpar.R @@ -89,7 +89,7 @@ #' * `palette.sequential`: Palette for sequential colors. See the `palette` argument in `?tinyplot`. #' * `record`: (experimental) Logical indicating whether `tinyplot()` should record plots and return them as replayable \code{\link{recordedtinyplot}} objects. Defaults to `NULL`, which is equivalent to `FALSE`. Setting to `TRUE` allows for assignment and later recall, e.g. `myplot = tinyplot(...); myplot`. Sets the default for the `record` argument of [`tinyplot()`], which takes precedence. Note that recording requires a device with an enabled display list (see \code{\link[grDevices]{dev.control}}). Most interactive devices enable this behaviour by default, whereas file-based devices do not. However `tinyplot()` automatically enables it for any device that it opens itself via `file`, and further emits a warning if the current device is not recording. #' * `ribbon.alpha`: Numeric factor in the range `[0,1]` for modifying the opacity alpha of "ribbon" and "area" type plots. Default value is `0.2`. -#' * `xpad`, `ypad`: Numeric specifying how much padding, as a fraction of the data range, should be added to each end of the x- and y-axis, respectively. Both default to `NULL`, in which case behaviour depends on the value of `x/yaxs`. In most cases, this will translate to a value of `0.04`, i.e. 4% padding on each end (see \code{\link[graphics]{par}}). Sets the default for the `xpad` and `ypad` arguments of [`tinyplot()`], which take precedence. +#' * `xpad`, `ypad`: Numeric specifying how much padding, as a fraction of the data range, should be added to each end of the x- and y-axis, respectively. Both default to `NULL`, in which case an automatic heuristic applies: a numeric axis inherits from `x/yaxs` (usually 4% padding on each end), whereas a categorical axis with point-like glyphs and few unique categories instead gets 25% of the gap between interior tick marks. Sets the default for the `xpad` and `ypad` arguments of [`tinyplot()`], which take precedence and where the heuristic is described in full. #' * `xaxr`, `yaxr`: Numeric giving the rotation of the x- and y-axis tick labels, in degrees counter-clockwise; `NULL` (the default) leaves them unrotated. Unlike `las`, which is limited to the four right angles, any angle is permitted. Setting one overrides `las` for that axis alone, leaving the other axis under `las` as usual, and `0` (or any multiple of 360) counts as no rotation at all. Sets the default for the `xaxr` and `yaxr` arguments of [`tinyplot()`], which take precedence. Two caveats follow from tinyplot drawing rotated labels itself rather than deferring to base `axis()`. First, margins are only resized to fit them under a theme with `dynmar = TRUE` (see `tinytheme`); under the default theme the margins are left alone, so a long rotated label will be clipped unless you widen `mar` yourself. Second, rotated labels do not inherit the thinning that `axis()` applies via `gap.axis`, so they start to overlap once the spacing between ticks falls below `line height / sin(srt)`. #' #' @importFrom graphics par diff --git a/man/tinyplot.Rd b/man/tinyplot.Rd index f7b5e3dd6..0a5a616c8 100644 --- a/man/tinyplot.Rd +++ b/man/tinyplot.Rd @@ -440,10 +440,21 @@ used for the x-axis and y-axis, respectively. See \code{\link[graphics]{par}} for the possible values.} \item{xpad, ypad}{numeric specifying how much padding, as a fraction of the -data range, should be added to each end of the axes. Defaults to \code{NULL}, -in which case behaviour depends on the value of \code{x/yaxs}. In most cases, -this will translate to a value of \code{0.04}, i.e. 4\% padding on each end (see -\code{\link[graphics]{par}}).} +data range, should be added to each end of the axes. If \code{NULL} (the +default), follows an automatic padding heuristic that tries to optimize for +plot aesthetic depending on the axis type: +\itemize{ +\item numeric axis: inherited from \code{x/yaxs}. Under the default \code{x/yaxs = "r"} +style, this means \code{0.04}, i.e. 4\% padding on each end (see +\code{\link[graphics]{par}}). +\item categorical axis: behaviour depends on the glyph-type and number of +unique categories, and therefore tick marks. For example, point-like +glyphs with numerous (8+) categories inherit the same padding logic as a +numeric axis (i.e., usually 4\%). For cases with fewer unique categories, +the padding will instead correspond to 25\% of the gap between interior +tick marks, so that the end categories aren't drawn flush against the +plot frame. +}} \item{xaxb, yaxb}{numeric vector (or character vector, if appropriate) giving the break points at which the axis tick-marks are to be drawn. Break points diff --git a/man/tpar.Rd b/man/tpar.Rd index de32dc8f6..1bfb5de4e 100644 --- a/man/tpar.Rd +++ b/man/tpar.Rd @@ -100,7 +100,7 @@ you should rather use \code{par()} instead. \item \code{palette.sequential}: Palette for sequential colors. See the \code{palette} argument in \code{?tinyplot}. \item \code{record}: (experimental) Logical indicating whether \code{tinyplot()} should record plots and return them as replayable \code{\link{recordedtinyplot}} objects. Defaults to \code{NULL}, which is equivalent to \code{FALSE}. Setting to \code{TRUE} allows for assignment and later recall, e.g. \verb{myplot = tinyplot(...); myplot}. Sets the default for the \code{record} argument of \code{\link[=tinyplot]{tinyplot()}}, which takes precedence. Note that recording requires a device with an enabled display list (see \code{\link[grDevices]{dev.control}}). Most interactive devices enable this behaviour by default, whereas file-based devices do not. However \code{tinyplot()} automatically enables it for any device that it opens itself via \code{file}, and further emits a warning if the current device is not recording. \item \code{ribbon.alpha}: Numeric factor in the range \verb{[0,1]} for modifying the opacity alpha of "ribbon" and "area" type plots. Default value is \code{0.2}. -\item \code{xpad}, \code{ypad}: Numeric specifying how much padding, as a fraction of the data range, should be added to each end of the x- and y-axis, respectively. Both default to \code{NULL}, in which case behaviour depends on the value of \code{x/yaxs}. In most cases, this will translate to a value of \code{0.04}, i.e. 4\% padding on each end (see \code{\link[graphics]{par}}). Sets the default for the \code{xpad} and \code{ypad} arguments of \code{\link[=tinyplot]{tinyplot()}}, which take precedence. +\item \code{xpad}, \code{ypad}: Numeric specifying how much padding, as a fraction of the data range, should be added to each end of the x- and y-axis, respectively. Both default to \code{NULL}, in which case an automatic heuristic applies: a numeric axis inherits from \code{x/yaxs} (usually 4\% padding on each end), whereas a categorical axis with point-like glyphs and few unique categories instead gets 25\% of the gap between interior tick marks. Sets the default for the \code{xpad} and \code{ypad} arguments of \code{\link[=tinyplot]{tinyplot()}}, which take precedence and where the heuristic is described in full. \item \code{xaxr}, \code{yaxr}: Numeric giving the rotation of the x- and y-axis tick labels, in degrees counter-clockwise; \code{NULL} (the default) leaves them unrotated. Unlike \code{las}, which is limited to the four right angles, any angle is permitted. Setting one overrides \code{las} for that axis alone, leaving the other axis under \code{las} as usual, and \code{0} (or any multiple of 360) counts as no rotation at all. Sets the default for the \code{xaxr} and \code{yaxr} arguments of \code{\link[=tinyplot]{tinyplot()}}, which take precedence. Two caveats follow from tinyplot drawing rotated labels itself rather than deferring to base \code{axis()}. First, margins are only resized to fit them under a theme with \code{dynmar = TRUE} (see \code{tinytheme}); under the default theme the margins are left alone, so a long rotated label will be clipped unless you widen \code{mar} yourself. Second, rotated labels do not inherit the thinning that \code{axis()} applies via \code{gap.axis}, so they start to overlap once the spacing between ticks falls below \verb{line height / sin(srt)}. } } From 2d0fc31807d4cffabefb06ad2ee910f922eec1d9 Mon Sep 17 00:00:00 2001 From: Grant McDermott Date: Sun, 13 Sep 2026 12:52:48 -0700 Subject: [PATCH 03/11] add hint to table in types vignette --- vignettes/types.qmd | 1 + 1 file changed, 1 insertion(+) diff --git a/vignettes/types.qmd b/vignettes/types.qmd index e417d8e3d..b5ceed760 100644 --- a/vignettes/types.qmd +++ b/vignettes/types.qmd @@ -406,6 +406,7 @@ need only define the list of hints that you actually want: | `legend_fills_from_col` | `type_hexbin()`, `type_spineplot()` | Fill the legend key with the group colour (`col`) rather than `bg`. | | `legend_fills_from_seq_palette` | `type_ridge()` | Fill the legend key with a lighter step of the group colour's sequential ramp. | | `legend_border_fg` | `type_spineplot()` | Draw the legend key border in the foreground colour rather than the group colour. | +| `pads_cat_axis` | `type_points()`, `type_lines()`, `type_pointrange()`, `type_errorbar()` | Buffer the ends of a categorical axis, so that the outermost categories aren't drawn flush against the plot frame. Declare it for types whose glyphs sit *on* their tick with no width of their own; see `xpad` in `?tinyplot` or `?tpar` for the details. | | `legend_reversed` | `type_area(stack = TRUE)` | List the legend key bottom-up rather than top-down. For types whose groups read from the bottom of the plot upwards---stacking puts the first `by` level in the bottom band---so that the key reads in the same direction as the geometry rather than backwards against it. | To see `type_hints` in action, consult the From 86ff69cec5b18865308c4c6ebe8ab65aec790760 Mon Sep 17 00:00:00 2001 From: Grant McDermott Date: Sun, 13 Sep 2026 13:05:19 -0700 Subject: [PATCH 04/11] tests --- .../dodge_errorbar_add_lines.svg | 110 ++++++++--------- .../dodge_errorbar_add_ribbon.svg | 116 +++++++++--------- .../_tinysnapshot/dodge_pointrange.svg | 68 +++++----- .../_tinysnapshot/dodge_pointrange_false.svg | 66 +++++----- .../_tinysnapshot/dodge_pointrange_flip.svg | 68 +++++----- .../_tinysnapshot/dodge_pointrange_true.svg | 68 +++++----- .../_tinysnapshot/facet_drop_levels_xaxb.svg | 50 ++++---- .../facet_free_categorical_yaxis.svg | 36 +++--- .../_tinysnapshot/flip_pointrange.svg | 48 ++++---- .../tinytest/_tinysnapshot/lines_xord_rev.svg | 18 +-- inst/tinytest/_tinysnapshot/matrix_basic.svg | 50 ++++---- inst/tinytest/_tinysnapshot/matrix_facet.svg | 96 +++++++-------- inst/tinytest/_tinysnapshot/matrix_type_b.svg | 82 ++++++------- .../_tinysnapshot/pointrange_errorbar.svg | 50 ++++---- .../_tinysnapshot/pointrange_with_layers.svg | 50 ++++---- .../pointrange_with_layers_flipped.svg | 38 +++--- .../_tinysnapshot/pointrange_xlevels_null.svg | 34 ++--- .../_tinysnapshot/points_xord_desc.svg | 60 ++++----- .../_tinysnapshot/points_xord_rev.svg | 60 ++++----- .../_tinysnapshot/readme_pointrange.svg | 34 ++--- .../tinyplot_add_layer_category_alignment.svg | 18 +-- .../type_lines_categorical_lines.svg | 14 +-- .../type_lines_categorical_points.svg | 14 +-- .../type_lines_categorical_y.svg | 18 +-- .../type_lines_explicit_levels.svg | 18 +-- .../_tinysnapshot/type_lines_flip_labels.svg | 18 +-- .../_tinysnapshot/type_lines_layer_h_p.svg | 18 +-- .../_tinysnapshot/type_lines_xlevels_asis.svg | 18 +-- .../_tinysnapshot/type_points_xlevels_idx.svg | 14 +-- inst/tinytest/test-axis-pad.R | 57 +++++++++ 30 files changed, 733 insertions(+), 676 deletions(-) diff --git a/inst/tinytest/_tinysnapshot/dodge_errorbar_add_lines.svg b/inst/tinytest/_tinysnapshot/dodge_errorbar_add_lines.svg index 5789fc1fd..227b0b4d6 100644 --- a/inst/tinytest/_tinysnapshot/dodge_errorbar_add_lines.svg +++ b/inst/tinytest/_tinysnapshot/dodge_errorbar_add_lines.svg @@ -47,15 +47,15 @@ estimate - - - - - -(Intercept) -wt -cyl -hp + + + + + +(Intercept) +wt +cyl +hp @@ -75,57 +75,57 @@ - - - - - - - + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/inst/tinytest/_tinysnapshot/dodge_errorbar_add_ribbon.svg b/inst/tinytest/_tinysnapshot/dodge_errorbar_add_ribbon.svg index ea2b7a945..62ca90fe0 100644 --- a/inst/tinytest/_tinysnapshot/dodge_errorbar_add_ribbon.svg +++ b/inst/tinytest/_tinysnapshot/dodge_errorbar_add_ribbon.svg @@ -47,15 +47,15 @@ estimate - - - - - -(Intercept) -wt -cyl -hp + + + + + +(Intercept) +wt +cyl +hp @@ -75,60 +75,60 @@ - - - - - - - + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/inst/tinytest/_tinysnapshot/dodge_pointrange.svg b/inst/tinytest/_tinysnapshot/dodge_pointrange.svg index 77188c89d..fd80eec02 100644 --- a/inst/tinytest/_tinysnapshot/dodge_pointrange.svg +++ b/inst/tinytest/_tinysnapshot/dodge_pointrange.svg @@ -47,15 +47,15 @@ estimate - - - - - -(Intercept) -wt -cyl -hp + + + + + +(Intercept) +wt +cyl +hp @@ -75,36 +75,36 @@ - - - - - - - + + + + + + + - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + diff --git a/inst/tinytest/_tinysnapshot/dodge_pointrange_false.svg b/inst/tinytest/_tinysnapshot/dodge_pointrange_false.svg index 01018522c..6c2b24710 100644 --- a/inst/tinytest/_tinysnapshot/dodge_pointrange_false.svg +++ b/inst/tinytest/_tinysnapshot/dodge_pointrange_false.svg @@ -47,15 +47,15 @@ estimate - - - - - -(Intercept) -wt -cyl -hp + + + + + +(Intercept) +wt +cyl +hp @@ -75,36 +75,36 @@ - - - + + + - - - + + + - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + diff --git a/inst/tinytest/_tinysnapshot/dodge_pointrange_flip.svg b/inst/tinytest/_tinysnapshot/dodge_pointrange_flip.svg index ddcf84e01..a32246784 100644 --- a/inst/tinytest/_tinysnapshot/dodge_pointrange_flip.svg +++ b/inst/tinytest/_tinysnapshot/dodge_pointrange_flip.svg @@ -58,15 +58,15 @@ 20 30 40 - - - - - -(Intercept) -wt -cyl -hp + + + + + +(Intercept) +wt +cyl +hp @@ -80,31 +80,31 @@ - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/inst/tinytest/_tinysnapshot/dodge_pointrange_true.svg b/inst/tinytest/_tinysnapshot/dodge_pointrange_true.svg index 233d47429..2522a2f5a 100644 --- a/inst/tinytest/_tinysnapshot/dodge_pointrange_true.svg +++ b/inst/tinytest/_tinysnapshot/dodge_pointrange_true.svg @@ -47,15 +47,15 @@ estimate - - - - - -(Intercept) -wt -cyl -hp + + + + + +(Intercept) +wt +cyl +hp @@ -75,36 +75,36 @@ - - - - - - - + + + + + + + - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + diff --git a/inst/tinytest/_tinysnapshot/facet_drop_levels_xaxb.svg b/inst/tinytest/_tinysnapshot/facet_drop_levels_xaxb.svg index 7517ae184..49093057b 100644 --- a/inst/tinytest/_tinysnapshot/facet_drop_levels_xaxb.svg +++ b/inst/tinytest/_tinysnapshot/facet_drop_levels_xaxb.svg @@ -38,10 +38,10 @@ - - + + -2 +2 4 @@ -64,11 +64,11 @@ - + - + 2 -4 +4 @@ -83,36 +83,36 @@ - + - - - + + + - - + + - - + + - - + + - - - + + + - - - + + + - - - + + + diff --git a/inst/tinytest/_tinysnapshot/facet_free_categorical_yaxis.svg b/inst/tinytest/_tinysnapshot/facet_free_categorical_yaxis.svg index e088df266..1db865885 100644 --- a/inst/tinytest/_tinysnapshot/facet_free_categorical_yaxis.svg +++ b/inst/tinytest/_tinysnapshot/facet_free_categorical_yaxis.svg @@ -48,13 +48,13 @@ 230 240 250 - - + + - -Fellowship + +Fellowship Two Towers -Return +Return extended @@ -78,30 +78,30 @@ 190 195 200 - - + + - -Fellowship + +Fellowship Two Towers -Return +Return theatrical - - - + + + - + - - - + + + - + diff --git a/inst/tinytest/_tinysnapshot/flip_pointrange.svg b/inst/tinytest/_tinysnapshot/flip_pointrange.svg index 057a93348..ed60ffe3e 100644 --- a/inst/tinytest/_tinysnapshot/flip_pointrange.svg +++ b/inst/tinytest/_tinysnapshot/flip_pointrange.svg @@ -33,12 +33,12 @@ 10 20 30 -(Intercept) -wt -gear4 -gear5 -wt:gear4 -wt:gear5 +(Intercept) +wt +gear4 +gear5 +wt:gear4 +wt:gear5 @@ -51,24 +51,24 @@ - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + diff --git a/inst/tinytest/_tinysnapshot/lines_xord_rev.svg b/inst/tinytest/_tinysnapshot/lines_xord_rev.svg index abee4dc78..8c6915ee8 100644 --- a/inst/tinytest/_tinysnapshot/lines_xord_rev.svg +++ b/inst/tinytest/_tinysnapshot/lines_xord_rev.svg @@ -28,13 +28,13 @@ name runtime - - + + - -Two Towers + +Two Towers Return -Fellowship +Fellowship @@ -54,10 +54,10 @@ - - - - + + + + diff --git a/inst/tinytest/_tinysnapshot/matrix_basic.svg b/inst/tinytest/_tinysnapshot/matrix_basic.svg index e8389c55d..960aeaca1 100644 --- a/inst/tinytest/_tinysnapshot/matrix_basic.svg +++ b/inst/tinytest/_tinysnapshot/matrix_basic.svg @@ -44,17 +44,17 @@ VADeaths - - - + + + - - -50-54 -55-59 + + +50-54 +55-59 60-64 -65-69 -70-74 +65-69 +70-74 @@ -78,26 +78,26 @@ - - + + - - - - + + + + - - - - + + + + - - - - + + + + - - + + diff --git a/inst/tinytest/_tinysnapshot/matrix_facet.svg b/inst/tinytest/_tinysnapshot/matrix_facet.svg index 1103ac61c..a7ca53fe7 100644 --- a/inst/tinytest/_tinysnapshot/matrix_facet.svg +++ b/inst/tinytest/_tinysnapshot/matrix_facet.svg @@ -55,15 +55,15 @@ - - - + + + - - -50-54 + + +50-54 60-64 -70-74 +70-74 @@ -91,15 +91,15 @@ - - - + + + - - -50-54 + + +50-54 60-64 -70-74 +70-74 @@ -127,15 +127,15 @@ - - - + + + - - -50-54 + + +50-54 60-64 -70-74 +70-74 @@ -163,15 +163,15 @@ - - - + + + - - -50-54 + + +50-54 60-64 -70-74 +70-74 @@ -192,36 +192,36 @@ - - - + + + - - + + - - - + + + - - + + - - - + + + - - + + - - - + + + - - + + diff --git a/inst/tinytest/_tinysnapshot/matrix_type_b.svg b/inst/tinytest/_tinysnapshot/matrix_type_b.svg index d9d39d308..ea07816cb 100644 --- a/inst/tinytest/_tinysnapshot/matrix_type_b.svg +++ b/inst/tinytest/_tinysnapshot/matrix_type_b.svg @@ -48,17 +48,17 @@ VADeaths - - - + + + - - -50-54 -55-59 + + +50-54 +55-59 60-64 -65-69 -70-74 +65-69 +70-74 @@ -82,42 +82,42 @@ - - - - - - + + + + + + - - - - - - - - + + + + + + + + - - - - - - - - + + + + + + + + - - - - - - - - + + + + + + + + - - + + diff --git a/inst/tinytest/_tinysnapshot/pointrange_errorbar.svg b/inst/tinytest/_tinysnapshot/pointrange_errorbar.svg index 0b22f9f72..a67f947f0 100644 --- a/inst/tinytest/_tinysnapshot/pointrange_errorbar.svg +++ b/inst/tinytest/_tinysnapshot/pointrange_errorbar.svg @@ -28,15 +28,15 @@ x y - - - - - -(Intercept) -hp -factor(cyl)6 -factor(cyl)8 + + + + + +(Intercept) +hp +factor(cyl)6 +factor(cyl)8 @@ -56,22 +56,22 @@ - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + diff --git a/inst/tinytest/_tinysnapshot/pointrange_with_layers.svg b/inst/tinytest/_tinysnapshot/pointrange_with_layers.svg index fc8092caf..1bac32685 100644 --- a/inst/tinytest/_tinysnapshot/pointrange_with_layers.svg +++ b/inst/tinytest/_tinysnapshot/pointrange_with_layers.svg @@ -28,15 +28,15 @@ x y - - - - - -(Intercept) -hp -factor(cyl)6 -factor(cyl)8 + + + + + +(Intercept) +hp +factor(cyl)6 +factor(cyl)8 @@ -56,28 +56,28 @@ - - - + + + - - - + + + - - - - - - - - - - + + + + + + + + + + diff --git a/inst/tinytest/_tinysnapshot/pointrange_with_layers_flipped.svg b/inst/tinytest/_tinysnapshot/pointrange_with_layers_flipped.svg index 8fa10b1cd..68d5fa672 100644 --- a/inst/tinytest/_tinysnapshot/pointrange_with_layers_flipped.svg +++ b/inst/tinytest/_tinysnapshot/pointrange_with_layers_flipped.svg @@ -39,15 +39,15 @@ 10 20 30 - - - - - -(Intercept) -hp -factor(cyl)6 -factor(cyl)8 + + + + + +(Intercept) +hp +factor(cyl)6 +factor(cyl)8 @@ -56,16 +56,16 @@ - - - - - - - - - - + + + + + + + + + + diff --git a/inst/tinytest/_tinysnapshot/pointrange_xlevels_null.svg b/inst/tinytest/_tinysnapshot/pointrange_xlevels_null.svg index 722f223fd..8e6e7b7cd 100644 --- a/inst/tinytest/_tinysnapshot/pointrange_xlevels_null.svg +++ b/inst/tinytest/_tinysnapshot/pointrange_xlevels_null.svg @@ -28,15 +28,15 @@ x y - - - - - -(Intercept) -factor(cyl)6 -factor(cyl)8 -hp + + + + + +(Intercept) +factor(cyl)6 +factor(cyl)8 +hp @@ -56,14 +56,14 @@ - - - - - - - - + + + + + + + + diff --git a/inst/tinytest/_tinysnapshot/points_xord_desc.svg b/inst/tinytest/_tinysnapshot/points_xord_desc.svg index 0f16a85bc..47fea24a2 100644 --- a/inst/tinytest/_tinysnapshot/points_xord_desc.svg +++ b/inst/tinytest/_tinysnapshot/points_xord_desc.svg @@ -28,13 +28,13 @@ factor(cyl) mpg - - + + - -4 + +4 6 -8 +8 @@ -56,36 +56,36 @@ - + - + - - - + + + - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + - - + + diff --git a/inst/tinytest/_tinysnapshot/points_xord_rev.svg b/inst/tinytest/_tinysnapshot/points_xord_rev.svg index ae47da1e9..adc741b02 100644 --- a/inst/tinytest/_tinysnapshot/points_xord_rev.svg +++ b/inst/tinytest/_tinysnapshot/points_xord_rev.svg @@ -28,13 +28,13 @@ factor(cyl) mpg - - + + - -8 + +8 6 -4 +4 @@ -56,36 +56,36 @@ - + - + - - - + + + - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + - - + + diff --git a/inst/tinytest/_tinysnapshot/readme_pointrange.svg b/inst/tinytest/_tinysnapshot/readme_pointrange.svg index c4c688a88..408d39b4c 100644 --- a/inst/tinytest/_tinysnapshot/readme_pointrange.svg +++ b/inst/tinytest/_tinysnapshot/readme_pointrange.svg @@ -29,17 +29,17 @@ Effect on Temperature term estimate - - - + + + - - -factor(Month)5 -factor(Month)6 + + +factor(Month)5 +factor(Month)6 factor(Month)7 -factor(Month)8 -factor(Month)9 +factor(Month)8 +factor(Month)9 @@ -59,16 +59,16 @@ - - + + - - - - + + + + - - + + diff --git a/inst/tinytest/_tinysnapshot/tinyplot_add_layer_category_alignment.svg b/inst/tinytest/_tinysnapshot/tinyplot_add_layer_category_alignment.svg index 2030f903d..c0a8f8013 100644 --- a/inst/tinytest/_tinysnapshot/tinyplot_add_layer_category_alignment.svg +++ b/inst/tinytest/_tinysnapshot/tinyplot_add_layer_category_alignment.svg @@ -28,13 +28,13 @@ g y - - + + - -a + +a b -c +c @@ -60,12 +60,12 @@ - + - - + + - + diff --git a/inst/tinytest/_tinysnapshot/type_lines_categorical_lines.svg b/inst/tinytest/_tinysnapshot/type_lines_categorical_lines.svg index 01cfe2ec9..b89886f4e 100644 --- a/inst/tinytest/_tinysnapshot/type_lines_categorical_lines.svg +++ b/inst/tinytest/_tinysnapshot/type_lines_categorical_lines.svg @@ -28,13 +28,13 @@ name runtime - - + + - -Fellowship + +Fellowship Return -Two Towers +Two Towers @@ -54,8 +54,8 @@ - - + + diff --git a/inst/tinytest/_tinysnapshot/type_lines_categorical_points.svg b/inst/tinytest/_tinysnapshot/type_lines_categorical_points.svg index 01cfe2ec9..b89886f4e 100644 --- a/inst/tinytest/_tinysnapshot/type_lines_categorical_points.svg +++ b/inst/tinytest/_tinysnapshot/type_lines_categorical_points.svg @@ -28,13 +28,13 @@ name runtime - - + + - -Fellowship + +Fellowship Return -Two Towers +Two Towers @@ -54,8 +54,8 @@ - - + + diff --git a/inst/tinytest/_tinysnapshot/type_lines_categorical_y.svg b/inst/tinytest/_tinysnapshot/type_lines_categorical_y.svg index 6157d6e0c..07b21455a 100644 --- a/inst/tinytest/_tinysnapshot/type_lines_categorical_y.svg +++ b/inst/tinytest/_tinysnapshot/type_lines_categorical_y.svg @@ -39,13 +39,13 @@ 190 195 200 - - + + - -Fellowship + +Fellowship Return -Two Towers +Two Towers @@ -54,10 +54,10 @@ - - - - + + + + diff --git a/inst/tinytest/_tinysnapshot/type_lines_explicit_levels.svg b/inst/tinytest/_tinysnapshot/type_lines_explicit_levels.svg index af59da9d4..deff7b2be 100644 --- a/inst/tinytest/_tinysnapshot/type_lines_explicit_levels.svg +++ b/inst/tinytest/_tinysnapshot/type_lines_explicit_levels.svg @@ -28,13 +28,13 @@ name runtime - - + + - -Fellowship + +Fellowship Two Towers -Return +Return @@ -54,11 +54,11 @@ - - - + + + - + diff --git a/inst/tinytest/_tinysnapshot/type_lines_flip_labels.svg b/inst/tinytest/_tinysnapshot/type_lines_flip_labels.svg index 6157d6e0c..07b21455a 100644 --- a/inst/tinytest/_tinysnapshot/type_lines_flip_labels.svg +++ b/inst/tinytest/_tinysnapshot/type_lines_flip_labels.svg @@ -39,13 +39,13 @@ 190 195 200 - - + + - -Fellowship + +Fellowship Return -Two Towers +Two Towers @@ -54,10 +54,10 @@ - - - - + + + + diff --git a/inst/tinytest/_tinysnapshot/type_lines_layer_h_p.svg b/inst/tinytest/_tinysnapshot/type_lines_layer_h_p.svg index 68d0f8316..67d529b8f 100644 --- a/inst/tinytest/_tinysnapshot/type_lines_layer_h_p.svg +++ b/inst/tinytest/_tinysnapshot/type_lines_layer_h_p.svg @@ -28,13 +28,13 @@ name runtime - - + + - -Fellowship + +Fellowship Return -Two Towers +Two Towers @@ -54,11 +54,11 @@ - - + + - - + + diff --git a/inst/tinytest/_tinysnapshot/type_lines_xlevels_asis.svg b/inst/tinytest/_tinysnapshot/type_lines_xlevels_asis.svg index af59da9d4..deff7b2be 100644 --- a/inst/tinytest/_tinysnapshot/type_lines_xlevels_asis.svg +++ b/inst/tinytest/_tinysnapshot/type_lines_xlevels_asis.svg @@ -28,13 +28,13 @@ name runtime - - + + - -Fellowship + +Fellowship Two Towers -Return +Return @@ -54,11 +54,11 @@ - - - + + + - + diff --git a/inst/tinytest/_tinysnapshot/type_points_xlevels_idx.svg b/inst/tinytest/_tinysnapshot/type_points_xlevels_idx.svg index 2715e07c7..9446aa213 100644 --- a/inst/tinytest/_tinysnapshot/type_points_xlevels_idx.svg +++ b/inst/tinytest/_tinysnapshot/type_points_xlevels_idx.svg @@ -28,13 +28,13 @@ name runtime - - + + - -Two Towers + +Two Towers Return -Fellowship +Fellowship @@ -54,8 +54,8 @@ - - + + diff --git a/inst/tinytest/test-axis-pad.R b/inst/tinytest/test-axis-pad.R index 8d6d1f3b5..557ae857e 100644 --- a/inst/tinytest/test-axis-pad.R +++ b/inst/tinytest/test-axis-pad.R @@ -77,4 +77,61 @@ tpar(xpad = NULL) expect_error(tpar(ypad = -1), pattern = "ypad") tpar(ypad = NULL) + +# With no explicit pad, a categorical axis of a point-like type gets a quarter +# of the tick gap instead of the usual 4%, so its end categories aren't drawn +# flush against the frame. +cats = function(n) factor(letters[1:n], levels = letters[1:n]) +cat_usr = function(n = 3, ...) { + tinyplot(cats(n), seq_len(n), ...) + par("usr") +} + +# Constant in tick gaps however many categories there are -- until 4% of the +# span reaches further on its own, which is where it hands back to base. +expect_equal(cat_usr(2, type = "p")[1:2], c(0.75, 2.25)) +expect_equal(cat_usr(7, type = "p")[1:2], c(0.75, 7.25)) +expect_equal(cat_usr(8, type = "p")[1:2], c(0.72, 8.28)) + +# Each type that declares the hint does so in its own data function. +expect_equal(cat_usr(3, type = "l")[1:2], c(0.75, 3.25)) +rng = function(...) { + d = data.frame(g = cats(3), y = c(1, 5, 10)) + tinyplot(y ~ g, ymin = y - 1, ymax = y + 1, data = d, ...) + par("usr") +} +expect_equal(rng(type = "errorbar")[1:2], c(0.75, 3.25)) # "pointrange" same + +# An explicit pad still wins, and the buffer follows its variable under a flip. +expect_equal(cat_usr(3, type = "p", xpad = 0)[1:2], c(1, 3)) +expect_equal(cat_usr(3, type = "p", flip = TRUE)[3:4], c(0.75, 3.25)) + +# Dodging widens the drawn extent past n-1; the gutter clears what is actually +# drawn, so cat_pad() has to read the limits rather than the category count. +dodged = function(dodge) { + d = data.frame(g = rep(cats(3), 4), y = rnorm(12), m = rep(c("m1", "m2"), 6)) + tinyplot(y ~ g | m, ymin = y - 1, ymax = y + 1, data = d, + type = type_pointrange(dodge = dodge)) + par("usr")[1:2] +} +set.seed(1) +expect_equal(diff(dodged(0.3)) - diff(dodged(0)), 0.9, tolerance = 1e-8) + +# Free facets derive a range per panel, so the buffer has to be worked out per +# panel too. Nine categories globally puts the whole plot past the breakeven, +# but a dropped panel holding three is back under it -- the one case where the +# facet path has to reach for the buffer itself rather than inherit the pad +# lim_args() already computed. `.fusr` is where a free panel's extent lives; +# par("usr") reports the outer region once the plot is finished. +expect_equal({ + d = data.frame( + g = factor(rep(letters[1:9], each = 2), levels = letters[1:9]), + y = rep(c(1, 2), 9), + f = rep(c("p", "q", "r"), each = 6) + ) + tinyplot(y ~ g, facet = ~f, data = d, type = "p", + facet.args = list(free = TRUE, drop.levels = TRUE)) + tinyplot:::get_environment_variable(".fusr")[[1]][1:2] +}, c(0.75, 3.25)) + dev.off() From 1bc5c8359c429844aaa1b80539447750c1684baf Mon Sep 17 00:00:00 2001 From: Grant McDermott Date: Sun, 13 Sep 2026 14:11:36 -0700 Subject: [PATCH 05/11] news --- NEWS.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/NEWS.md b/NEWS.md index b198ec7a0..96a3239bf 100644 --- a/NEWS.md +++ b/NEWS.md @@ -136,6 +136,15 @@ Beyond convenience, these improvements to categorical variable handling also provide the scaffolding to eliminate some niggling inconsistencies; for example, related to plot layering. See "Bug fixes" below. +#### Axis aesthetics + +- Point-like glyphs (`"p"`, `"l"`, `"pointrange"`, `"errorbar"`, and friends) + gain smart padding behaviour for categorical axes. Most notably, we use more + generous padding by default when there are few unique categories (levels), so + that the end tick marks aren't drawn flush against the plot frame. Users can + also override with the new `x/ypad` arguments (see below) to match their own + aesthetic preferences. (#662 @grantmcdermott) + #### Other new features - New top-level `tinyplot()`/`plt()` arguments: From 5204677ca3336601d9e28ad4044bf1a6f8d09eb0 Mon Sep 17 00:00:00 2001 From: Grant McDermott Date: Sun, 13 Sep 2026 15:10:03 -0700 Subject: [PATCH 06/11] support type text --- R/type_text.R | 4 ++++ inst/tinytest/_tinysnapshot/text_categorical_x.svg | 14 +++++++------- vignettes/types.qmd | 2 +- 3 files changed, 12 insertions(+), 8 deletions(-) diff --git a/R/type_text.R b/R/type_text.R index ad10a0151..a8f8e22a5 100644 --- a/R/type_text.R +++ b/R/type_text.R @@ -173,6 +173,8 @@ data_text = function(labels = NULL, labeller = NULL, clim = c(0.5, 2.5)) { xlabs = seq_along(xlvls) names(xlabs) = xlvls datapoints$x = as.integer(datapoints$x) + # More generous padding if categorical x-axis; better plot aesthetic + settings[["type_hints"]][["pads_cat_axis"]] = TRUE } else { xlabs = NULL } @@ -181,6 +183,8 @@ data_text = function(labels = NULL, labeller = NULL, clim = c(0.5, 2.5)) { ylabs = seq_along(ylvls) names(ylabs) = ylvls datapoints$y = as.integer(datapoints$y) + # More generous padding if categorical y-axis; better plot aesthetic + settings[["type_hints"]][["pads_cat_axis"]] = TRUE } else { ylabs = NULL } diff --git a/inst/tinytest/_tinysnapshot/text_categorical_x.svg b/inst/tinytest/_tinysnapshot/text_categorical_x.svg index 89516c407..8d3bb7c5f 100644 --- a/inst/tinytest/_tinysnapshot/text_categorical_x.svg +++ b/inst/tinytest/_tinysnapshot/text_categorical_x.svg @@ -28,11 +28,11 @@ LETTERS[1:2] 1:2 - - - -A -B + + + +A +B @@ -54,8 +54,8 @@ -1 -2 +1 +2 diff --git a/vignettes/types.qmd b/vignettes/types.qmd index b5ceed760..667702e19 100644 --- a/vignettes/types.qmd +++ b/vignettes/types.qmd @@ -406,7 +406,7 @@ need only define the list of hints that you actually want: | `legend_fills_from_col` | `type_hexbin()`, `type_spineplot()` | Fill the legend key with the group colour (`col`) rather than `bg`. | | `legend_fills_from_seq_palette` | `type_ridge()` | Fill the legend key with a lighter step of the group colour's sequential ramp. | | `legend_border_fg` | `type_spineplot()` | Draw the legend key border in the foreground colour rather than the group colour. | -| `pads_cat_axis` | `type_points()`, `type_lines()`, `type_pointrange()`, `type_errorbar()` | Buffer the ends of a categorical axis, so that the outermost categories aren't drawn flush against the plot frame. Declare it for types whose glyphs sit *on* their tick with no width of their own; see `xpad` in `?tinyplot` or `?tpar` for the details. | +| `pads_cat_axis` | `type_points()`, `type_lines()`, `type_pointrange()`, `type_errorbar()`, `type_text()` | Buffer the ends of a categorical axis, so that the outermost categories aren't drawn flush against the plot frame. Declare it for types whose glyphs sit *on* their tick with no width of their own; see `xpad` in `?tinyplot` or `?tpar` for the details. | | `legend_reversed` | `type_area(stack = TRUE)` | List the legend key bottom-up rather than top-down. For types whose groups read from the bottom of the plot upwards---stacking puts the first `by` level in the bottom band---so that the key reads in the same direction as the geometry rather than backwards against it. | To see `type_hints` in action, consult the From 3beeafb2b838a43ed3da2f5088c0488a95dd8fda Mon Sep 17 00:00:00 2001 From: Grant McDermott Date: Sun, 13 Sep 2026 15:13:01 -0700 Subject: [PATCH 07/11] news --- NEWS.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/NEWS.md b/NEWS.md index 99a929379..b7a6657de 100644 --- a/NEWS.md +++ b/NEWS.md @@ -138,12 +138,12 @@ related to plot layering. See "Bug fixes" below. #### Axis aesthetics -- Point-like glyphs (`"p"`, `"l"`, `"pointrange"`, `"errorbar"`, and friends) - gain smart padding behaviour for categorical axes. Most notably, we use more - generous padding by default when there are few unique categories (levels), so - that the end tick marks aren't drawn flush against the plot frame. Users can - also override with the new `x/ypad` arguments (see below) to match their own - aesthetic preferences. (#662 @grantmcdermott) +- Point-like glyphs (`"p"`, `"l"`, `"errorbar"`, and friends) gain smart padding + behaviour for categorical axes. Most notably, we now use more generous axis + padding when there are few unique categories (levels), so that the end tick + marks aren't drawn flush against the plot frame. Users can also override with + the new `x/ypad` arguments (see below) to match their own aesthetic + preferences. (#662 @grantmcdermott) #### Other new features From 6aec87f48618a81b6d03027cd215ed871519e13e Mon Sep 17 00:00:00 2001 From: Grant McDermott Date: Sun, 13 Sep 2026 17:15:15 -0700 Subject: [PATCH 08/11] pr link --- NEWS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/NEWS.md b/NEWS.md index b7a6657de..f99ee16f7 100644 --- a/NEWS.md +++ b/NEWS.md @@ -143,7 +143,7 @@ related to plot layering. See "Bug fixes" below. padding when there are few unique categories (levels), so that the end tick marks aren't drawn flush against the plot frame. Users can also override with the new `x/ypad` arguments (see below) to match their own aesthetic - preferences. (#662 @grantmcdermott) + preferences. (#732 @grantmcdermott) #### Other new features From 9dc947fa5f412a91a1892a09ee26778ad0ab2e65 Mon Sep 17 00:00:00 2001 From: Grant McDermott Date: Sun, 13 Sep 2026 17:16:53 -0700 Subject: [PATCH 09/11] address copilot review --- R/facet.R | 5 +- R/lim.R | 5 +- R/sanitize_axes.R | 8 ++- R/tinyplot.R | 8 +-- .../_tinysnapshot/facet_drop_levels_xaxb.svg | 50 +++++++++---------- inst/tinytest/test-axis-pad.R | 37 +++++++++----- 6 files changed, 68 insertions(+), 45 deletions(-) diff --git a/R/facet.R b/R/facet.R index 58a43ce16..2dd89dc7b 100644 --- a/R/facet.R +++ b/R/facet.R @@ -26,6 +26,7 @@ draw_facet_window = function( axes, flip, frame.plot, oxaxis, oyaxis, xlabs, xlim, null_xlim, xaxt, xaxs, xaxb, xaxl, xaxr = NULL, xpad = NULL, ylabs, ylim, null_ylim, yaxt, yaxs, yaxb, yaxl, yaxr = NULL, ypad = NULL, + xpad_user = NULL, ypad_user = NULL, rev_x = FALSE, rev_y = FALSE, xlim_partial = NULL, ylim_partial = NULL, facet_labs = NULL, @@ -528,8 +529,8 @@ draw_facet_window = function( # A panel that derives its own categorical range asks for the same # buffer lim_args() gives an unfaceted one, so the two agree. .catpad = isTRUE(type_hints[["pads_cat_axis"]]) - .xp = xpad %||% (if (.catpad && length(.fxlabs)) cat_pad(xext)) - .yp = ypad %||% (if (.catpad && length(.fylabs)) cat_pad(yext)) + .xp = xpad_user %||% (if (.catpad && length(.fxlabs)) cat_pad(xext)) + .yp = ypad_user %||% (if (.catpad && length(.fylabs)) cat_pad(yext)) if (!.padded_x) xext = expand_lim(xext, .xp %||% 0.04) if (!.padded_y) yext = expand_lim(yext, .yp %||% 0.04) # A facet with a single distinct x (or y) value yields a zero-width diff --git a/R/lim.R b/R/lim.R index fa1313cfa..5fc4a3439 100644 --- a/R/lim.R +++ b/R/lim.R @@ -53,6 +53,8 @@ lim_args = function(settings) { # A categorical axis asks for its buffer in category widths; xpad speaks in # fractions of the range. Convert and let the existing machinery apply it. + xpad_user = xpad + ypad_user = ypad if (isTRUE(type_hints[["pads_cat_axis"]])) { if (is.null(xpad) && !is.null(xlabs)) xpad = cat_pad(xlim) if (is.null(ypad) && !is.null(ylabs)) ypad = cat_pad(ylim) @@ -75,7 +77,8 @@ lim_args = function(settings) { env2env( environment(), settings, - c("xlim", "ylim", "xpad", "ypad", "xlabs", "ylabs", "xaxb", "yaxb") + c("xlim", "ylim", "xpad", "ypad", "xpad_user", "ypad_user", + "xlabs", "ylabs", "xaxb", "yaxb") ) } diff --git a/R/sanitize_axes.R b/R/sanitize_axes.R index 581d44e67..5da2379c5 100644 --- a/R/sanitize_axes.R +++ b/R/sanitize_axes.R @@ -43,10 +43,16 @@ sanitize_axes = function(settings) { if (is.null(ypad)) ypad = get_tpar("ypad") assert_numeric(xpad, len = 1, lower = 0, null.ok = TRUE, name = "xpad") assert_numeric(ypad, len = 1, lower = 0, null.ok = TRUE, name = "ypad") + ## Keep the user's pad distinct from the one lim_args() may synthesize for a + ## categorical axis: that one is a fraction of the whole plot's range, so a + ## free panel spanning fewer categories has to work its own out instead. + xpad_user = xpad + ypad_user = ypad env2env( environment(), settings, - c("axes", "xaxt", "yaxt", "frame.plot", "xaxr", "yaxr", "xpad", "ypad") + c("axes", "xaxt", "yaxt", "frame.plot", "xaxr", "yaxr", "xpad", "ypad", + "xpad_user", "ypad_user") ) } diff --git a/R/tinyplot.R b/R/tinyplot.R index 7ecb540c3..83ac851c5 100644 --- a/R/tinyplot.R +++ b/R/tinyplot.R @@ -1667,8 +1667,8 @@ tinyplot.default = function( # axes args axes = axes, flip = flip, frame.plot = frame.plot, oxaxis = oxaxis, oyaxis = oyaxis, - xlabs = xlabs, xlim = xlim, null_xlim = null_xlim, xaxt = xaxt, xaxs = xaxs, xaxb = xaxb, xaxl = xaxl, xaxr = xaxr, xpad = xpad, - ylabs = ylabs, ylim = ylim, null_ylim = null_ylim, yaxt = yaxt, yaxs = yaxs, yaxb = yaxb, yaxl = yaxl, yaxr = yaxr, ypad = ypad, + xlabs = xlabs, xlim = xlim, null_xlim = null_xlim, xaxt = xaxt, xaxs = xaxs, xaxb = xaxb, xaxl = xaxl, xaxr = xaxr, xpad = xpad, xpad_user = xpad_user, + ylabs = ylabs, ylim = ylim, null_ylim = null_ylim, yaxt = yaxt, yaxs = yaxs, yaxb = yaxb, yaxl = yaxl, yaxr = yaxr, ypad = ypad, ypad_user = ypad_user, rev_x = rev_x, rev_y = rev_y, xlim_partial = xlim_partial, ylim_partial = ylim_partial, facet_labs = facet_labs, @@ -1704,8 +1704,8 @@ tinyplot.default = function( nfacets = nfacets, nfacet_cols = nfacet_cols, nfacet_rows = nfacet_rows, axes = axes, flip = flip, frame.plot = frame.plot, oxaxis = oxaxis, oyaxis = oyaxis, - xlabs = xlabs, xlim = xlim, null_xlim = null_xlim, xaxt = xaxt, xaxs = xaxs, xaxb = xaxb, xaxl = xaxl, xaxr = xaxr, xpad = xpad, - ylabs = ylabs, ylim = ylim, null_ylim = null_ylim, yaxt = yaxt, yaxs = yaxs, yaxb = yaxb, yaxl = yaxl, yaxr = yaxr, ypad = ypad, + xlabs = xlabs, xlim = xlim, null_xlim = null_xlim, xaxt = xaxt, xaxs = xaxs, xaxb = xaxb, xaxl = xaxl, xaxr = xaxr, xpad = xpad, xpad_user = xpad_user, + ylabs = ylabs, ylim = ylim, null_ylim = null_ylim, yaxt = yaxt, yaxs = yaxs, yaxb = yaxb, yaxl = yaxl, yaxr = yaxr, ypad = ypad, ypad_user = ypad_user, rev_x = rev_x, rev_y = rev_y, xlim_partial = xlim_partial, ylim_partial = ylim_partial, facet_labs = facet_labs, diff --git a/inst/tinytest/_tinysnapshot/facet_drop_levels_xaxb.svg b/inst/tinytest/_tinysnapshot/facet_drop_levels_xaxb.svg index 49093057b..8cad8b4d5 100644 --- a/inst/tinytest/_tinysnapshot/facet_drop_levels_xaxb.svg +++ b/inst/tinytest/_tinysnapshot/facet_drop_levels_xaxb.svg @@ -38,10 +38,10 @@ - - + + -2 +2 4 @@ -64,11 +64,11 @@ - + - + 2 -4 +4 @@ -83,36 +83,36 @@ - + - - - + + + - - + + - - + + - - + + - - - + + + - - - + + + - - - + + + diff --git a/inst/tinytest/test-axis-pad.R b/inst/tinytest/test-axis-pad.R index 557ae857e..969083044 100644 --- a/inst/tinytest/test-axis-pad.R +++ b/inst/tinytest/test-axis-pad.R @@ -118,20 +118,33 @@ set.seed(1) expect_equal(diff(dodged(0.3)) - diff(dodged(0)), 0.9, tolerance = 1e-8) # Free facets derive a range per panel, so the buffer has to be worked out per -# panel too. Nine categories globally puts the whole plot past the breakeven, -# but a dropped panel holding three is back under it -- the one case where the -# facet path has to reach for the buffer itself rather than inherit the pad -# lim_args() already computed. `.fusr` is where a free panel's extent lives; -# par("usr") reports the outer region once the plot is finished. -expect_equal({ +# panel too -- a dropped panel spanning fewer categories than the plot cannot +# just inherit. `.fusr` is where a free panel's extent lives; par("usr") +# reports the outer region once the plot is finished. +panel_reach = function(cats_per_panel, n_global, ...) { + g = rep(letters[seq_len(n_global)], length.out = sum(cats_per_panel)) d = data.frame( - g = factor(rep(letters[1:9], each = 2), levels = letters[1:9]), - y = rep(c(1, 2), 9), - f = rep(c("p", "q", "r"), each = 6) + g = factor(g, levels = letters[seq_len(n_global)]), + y = seq_along(g), + f = rep(seq_along(cats_per_panel), cats_per_panel) ) tinyplot(y ~ g, facet = ~f, data = d, type = "p", - facet.args = list(free = TRUE, drop.levels = TRUE)) - tinyplot:::get_environment_variable(".fusr")[[1]][1:2] -}, c(0.75, 3.25)) + facet.args = list(free = TRUE, drop.levels = TRUE), ...) + fu = tinyplot:::get_environment_variable(".fusr") + vapply(seq_along(cats_per_panel), + function(i) fu[[i]][2] - cats_per_panel[i], numeric(1)) +} + +# Nine categories globally puts the plot past the breakeven, so lim_args() +# computes nothing and the facet path has to reach for the buffer itself. +expect_equal(panel_reach(c(3, 3, 3), 9)[1], 0.25) + +# Three globally puts it under, so lim_args() does compute a pad -- but that is +# a fraction of the *global* span, and a narrower panel needs its own. Reusing +# it gave a two-category panel 0.125 instead of 0.25. (#732) +expect_equal(panel_reach(c(3, 2), 3), c(0.25, 0.25)) + +# An explicit pad is the user's business and still applies verbatim throughout. +expect_equal(panel_reach(c(3, 2), 3, xpad = 0), c(0, 0)) dev.off() From 2146c6684f779f0823b6e77471bfc88d1b2c5906 Mon Sep 17 00:00:00 2001 From: Grant McDermott Date: Sun, 13 Sep 2026 18:30:33 -0700 Subject: [PATCH 10/11] honor explicit x/yaxs = "I" - also avoid duplicate xpad_user capture --- R/flip.R | 1 + R/lim.R | 17 ++++++++++------- R/sanitize_axes.R | 2 ++ R/tinyplot.R | 2 +- inst/tinytest/test-axis-pad.R | 4 +++- man/facet.Rd | 2 ++ man/tinyplot.Rd | 2 +- 7 files changed, 20 insertions(+), 10 deletions(-) diff --git a/R/flip.R b/R/flip.R index 3b3b9ef7c..98ed8db10 100644 --- a/R/flip.R +++ b/R/flip.R @@ -33,6 +33,7 @@ flip_datapoints = function(settings) { swap_elements(settings, "xlabs", "ylabs") swap_elements(settings, "xlim", "ylim") swap_elements(settings, "xpad", "ypad") + swap_elements(settings, "xpad_user", "ypad_user") swap_elements(settings, "null_xlim", "null_ylim") swap_elements(settings, "rev_x", "rev_y") swap_elements(settings, "xmax", "ymax") diff --git a/R/lim.R b/R/lim.R index 5fc4a3439..311b70712 100644 --- a/R/lim.R +++ b/R/lim.R @@ -7,7 +7,8 @@ lim_args = function(settings) { c( "xaxb", "xlabs", "xlim", "null_xlim", "rev_x", "yaxb", "ylabs", "ylim", "null_ylim", "rev_y", - "datapoints", "type", "type_hints", "xpad", "ypad", "log" + "datapoints", "type", "type_hints", "xpad", "ypad", "xaxs", "yaxs", + "log" ) ) @@ -53,11 +54,14 @@ lim_args = function(settings) { # A categorical axis asks for its buffer in category widths; xpad speaks in # fractions of the range. Convert and let the existing machinery apply it. - xpad_user = xpad - ypad_user = ypad + # An explicit x/yaxs = "i" asked for tight limits, so leave those alone. if (isTRUE(type_hints[["pads_cat_axis"]])) { - if (is.null(xpad) && !is.null(xlabs)) xpad = cat_pad(xlim) - if (is.null(ypad) && !is.null(ylabs)) ypad = cat_pad(ylim) + if (is.null(xpad) && !is.null(xlabs) && !identical(xaxs, "i")) { + xpad = cat_pad(xlim) + } + if (is.null(ypad) && !is.null(ylabs) && !identical(yaxs, "i")) { + ypad = cat_pad(ylim) + } } if (!is.null(xpad)) { @@ -77,8 +81,7 @@ lim_args = function(settings) { env2env( environment(), settings, - c("xlim", "ylim", "xpad", "ypad", "xpad_user", "ypad_user", - "xlabs", "ylabs", "xaxb", "yaxb") + c("xlim", "ylim", "xpad", "ypad", "xlabs", "ylabs", "xaxb", "yaxb") ) } diff --git a/R/sanitize_axes.R b/R/sanitize_axes.R index 5da2379c5..88c2855ec 100644 --- a/R/sanitize_axes.R +++ b/R/sanitize_axes.R @@ -46,6 +46,8 @@ sanitize_axes = function(settings) { ## Keep the user's pad distinct from the one lim_args() may synthesize for a ## categorical axis: that one is a fraction of the whole plot's range, so a ## free panel spanning fewer categories has to work its own out instead. + ## Captured here rather than in lim_args() because that never runs on the + ## `add = TRUE` path; flip_datapoints() swaps the pair alongside x/ypad. xpad_user = xpad ypad_user = ypad diff --git a/R/tinyplot.R b/R/tinyplot.R index 83ac851c5..445869ff2 100644 --- a/R/tinyplot.R +++ b/R/tinyplot.R @@ -290,7 +290,7 @@ #' numeric axis (i.e., usually 4%). For cases with fewer unique categories, #' the padding will instead correspond to 25% of the gap between interior #' tick marks, so that the end categories aren't drawn flush against the -#' plot frame. +#' plot frame. An explicit `x/yaxs = "i"` still yields tight limits. #' @param xaxb,yaxb numeric vector (or character vector, if appropriate) giving #' the break points at which the axis tick-marks are to be drawn. Break points #' outside the range of the data will be ignored if the associated axis diff --git a/inst/tinytest/test-axis-pad.R b/inst/tinytest/test-axis-pad.R index 969083044..b192c0b20 100644 --- a/inst/tinytest/test-axis-pad.R +++ b/inst/tinytest/test-axis-pad.R @@ -102,8 +102,10 @@ rng = function(...) { } expect_equal(rng(type = "errorbar")[1:2], c(0.75, 3.25)) # "pointrange" same -# An explicit pad still wins, and the buffer follows its variable under a flip. +# An explicit pad still wins, as does an explicit xaxs = "i", and the buffer +# follows its variable under a flip. expect_equal(cat_usr(3, type = "p", xpad = 0)[1:2], c(1, 3)) +expect_equal(cat_usr(3, type = "p", xaxs = "i")[1:2], c(1, 3)) expect_equal(cat_usr(3, type = "p", flip = TRUE)[3:4], c(0.75, 3.25)) # Dodging widens the drawn extent past n-1; the gutter clears what is actually diff --git a/man/facet.Rd b/man/facet.Rd index 7f747fe97..21f05c5ca 100644 --- a/man/facet.Rd +++ b/man/facet.Rd @@ -47,6 +47,8 @@ draw_facet_window( yaxl, yaxr = NULL, ypad = NULL, + xpad_user = NULL, + ypad_user = NULL, rev_x = FALSE, rev_y = FALSE, xlim_partial = NULL, diff --git a/man/tinyplot.Rd b/man/tinyplot.Rd index 0a5a616c8..56435952c 100644 --- a/man/tinyplot.Rd +++ b/man/tinyplot.Rd @@ -453,7 +453,7 @@ glyphs with numerous (8+) categories inherit the same padding logic as a numeric axis (i.e., usually 4\%). For cases with fewer unique categories, the padding will instead correspond to 25\% of the gap between interior tick marks, so that the end categories aren't drawn flush against the -plot frame. +plot frame. An explicit \code{x/yaxs = "i"} still yields tight limits. }} \item{xaxb, yaxb}{numeric vector (or character vector, if appropriate) giving From 5e4cd3782f60a70487a16cf00fac93804c73d4fb Mon Sep 17 00:00:00 2001 From: Grant McDermott Date: Sun, 13 Sep 2026 18:57:01 -0700 Subject: [PATCH 11/11] skip if user provides explicit overrides --- R/lim.R | 6 +++--- R/tinyplot.R | 4 +++- inst/tinytest/test-axis-pad.R | 6 ++++-- man/tinyplot.Rd | 4 +++- 4 files changed, 13 insertions(+), 7 deletions(-) diff --git a/R/lim.R b/R/lim.R index 311b70712..0658f68f5 100644 --- a/R/lim.R +++ b/R/lim.R @@ -54,12 +54,12 @@ lim_args = function(settings) { # A categorical axis asks for its buffer in category widths; xpad speaks in # fractions of the range. Convert and let the existing machinery apply it. - # An explicit x/yaxs = "i" asked for tight limits, so leave those alone. + # An explicit x/ylim or x/yaxs = "i" is the user's call; leave those to base. if (isTRUE(type_hints[["pads_cat_axis"]])) { - if (is.null(xpad) && !is.null(xlabs) && !identical(xaxs, "i")) { + if (is.null(xpad) && !is.null(xlabs) && null_xlim && !identical(xaxs, "i")) { xpad = cat_pad(xlim) } - if (is.null(ypad) && !is.null(ylabs) && !identical(yaxs, "i")) { + if (is.null(ypad) && !is.null(ylabs) && null_ylim && !identical(yaxs, "i")) { ypad = cat_pad(ylim) } } diff --git a/R/tinyplot.R b/R/tinyplot.R index 445869ff2..24921f516 100644 --- a/R/tinyplot.R +++ b/R/tinyplot.R @@ -290,7 +290,9 @@ #' numeric axis (i.e., usually 4%). For cases with fewer unique categories, #' the padding will instead correspond to 25% of the gap between interior #' tick marks, so that the end categories aren't drawn flush against the -#' plot frame. An explicit `x/yaxs = "i"` still yields tight limits. +#' plot frame. Note that providing an explicit `x/ylim` or setting +#' `x/yaxs = "i"` skips this heuristic in favour of the standard numeric +#' behaviour. #' @param xaxb,yaxb numeric vector (or character vector, if appropriate) giving #' the break points at which the axis tick-marks are to be drawn. Break points #' outside the range of the data will be ignored if the associated axis diff --git a/inst/tinytest/test-axis-pad.R b/inst/tinytest/test-axis-pad.R index b192c0b20..0a0bd3d6b 100644 --- a/inst/tinytest/test-axis-pad.R +++ b/inst/tinytest/test-axis-pad.R @@ -102,10 +102,12 @@ rng = function(...) { } expect_equal(rng(type = "errorbar")[1:2], c(0.75, 3.25)) # "pointrange" same -# An explicit pad still wins, as does an explicit xaxs = "i", and the buffer -# follows its variable under a flip. +# An explicit pad still wins. So do explicit limits or xaxs = "i", which hand +# the axis back to base's usual treatment. The buffer follows its variable +# under a flip. expect_equal(cat_usr(3, type = "p", xpad = 0)[1:2], c(1, 3)) expect_equal(cat_usr(3, type = "p", xaxs = "i")[1:2], c(1, 3)) +expect_equal(cat_usr(3, type = "p", xlim = c(1, 3))[1:2], c(0.92, 3.08)) expect_equal(cat_usr(3, type = "p", flip = TRUE)[3:4], c(0.75, 3.25)) # Dodging widens the drawn extent past n-1; the gutter clears what is actually diff --git a/man/tinyplot.Rd b/man/tinyplot.Rd index 56435952c..be5fb20e7 100644 --- a/man/tinyplot.Rd +++ b/man/tinyplot.Rd @@ -453,7 +453,9 @@ glyphs with numerous (8+) categories inherit the same padding logic as a numeric axis (i.e., usually 4\%). For cases with fewer unique categories, the padding will instead correspond to 25\% of the gap between interior tick marks, so that the end categories aren't drawn flush against the -plot frame. An explicit \code{x/yaxs = "i"} still yields tight limits. +plot frame. Note that providing an explicit \code{x/ylim} or setting +\code{x/yaxs = "i"} skips this heuristic in favour of the standard numeric +behaviour. }} \item{xaxb, yaxb}{numeric vector (or character vector, if appropriate) giving