diff --git a/NEWS.md b/NEWS.md index f189be98..f99ee16f 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"`, `"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. (#732 @grantmcdermott) + #### Other new features - New top-level `tinyplot()`/`plt()` arguments: diff --git a/R/assertions.R b/R/assertions.R index 2b8ed343..1cd78172 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 3c1397d4..2dd89dc7 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, @@ -525,8 +526,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_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 # 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/flip.R b/R/flip.R index 3b3b9ef7..98ed8db1 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 c3437e49..0658f68f 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" ) ) @@ -51,6 +52,18 @@ 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. + # 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) && null_xlim && !identical(xaxs, "i")) { + xpad = cat_pad(xlim) + } + if (is.null(ypad) && !is.null(ylabs) && null_ylim && !identical(yaxs, "i")) { + ypad = cat_pad(ylim) + } + } + if (!is.null(xpad)) { xlim = expand_lim(widen_degenerate(xlim), xpad, log = grepl("x", log, fixed = TRUE)) @@ -103,6 +116,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/sanitize_axes.R b/R/sanitize_axes.R index 581d44e6..88c2855e 100644 --- a/R/sanitize_axes.R +++ b/R/sanitize_axes.R @@ -43,10 +43,18 @@ 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. + ## 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 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 021feb43..24921f51 100644 --- a/R/tinyplot.R +++ b/R/tinyplot.R @@ -276,10 +276,23 @@ #' 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. 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 @@ -1656,8 +1669,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, @@ -1693,8 +1706,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/R/tpar.R b/R/tpar.R index 0c542bf6..b6efa10a 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/R/type_lines.R b/R/type_lines.R index 889892d0..cc4872ba 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 2e64df95..14c14731 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 31c2fc55..e2589730 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 } diff --git a/R/type_text.R b/R/type_text.R index ad10a015..a8f8e22a 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/dodge_errorbar_add_lines.svg b/inst/tinytest/_tinysnapshot/dodge_errorbar_add_lines.svg index 5789fc1f..227b0b4d 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 ea2b7a94..62ca90fe 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 77188c89..fd80eec0 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 01018522..6c2b2471 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 ddcf84e0..a3224678 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 233d4742..2522a2f5 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 7517ae18..8cad8b4d 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 e088df26..1db86588 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 057a9334..ed60ffe3 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 abee4dc7..8c6915ee 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 e8389c55..960aeaca 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 1103ac61..a7ca53fe 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 d9d39d30..ea07816c 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 0b22f9f7..a67f947f 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 fc8092ca..1bac3268 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 8fa10b1c..68d5fa67 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 722f223f..8e6e7b7c 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 0f16a85b..47fea24a 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 ae47da1e..adc741b0 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 c4c688a8..408d39b4 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/text_categorical_x.svg b/inst/tinytest/_tinysnapshot/text_categorical_x.svg index 89516c40..8d3bb7c5 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/inst/tinytest/_tinysnapshot/tinyplot_add_layer_category_alignment.svg b/inst/tinytest/_tinysnapshot/tinyplot_add_layer_category_alignment.svg index 2030f903..c0a8f801 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 01cfe2ec..b89886f4 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 01cfe2ec..b89886f4 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 6157d6e0..07b21455 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 af59da9d..deff7b2b 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 6157d6e0..07b21455 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 68d0f831..67d529b8 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 af59da9d..deff7b2b 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 2715e07c..9446aa21 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 8d6d1f3b..0a0bd3d6 100644 --- a/inst/tinytest/test-axis-pad.R +++ b/inst/tinytest/test-axis-pad.R @@ -77,4 +77,78 @@ 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. 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 +# 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 -- 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(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), ...) + 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() diff --git a/man/facet.Rd b/man/facet.Rd index 7f747fe9..21f05c5c 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 f7b5e3dd..be5fb20e 100644 --- a/man/tinyplot.Rd +++ b/man/tinyplot.Rd @@ -440,10 +440,23 @@ 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. 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 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 de32dc8f..1bfb5de4 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)}. } } diff --git a/vignettes/types.qmd b/vignettes/types.qmd index e417d8e3..667702e1 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()`, `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