diff --git a/NEWS.md b/NEWS.md index c2628383..35793e14 100644 --- a/NEWS.md +++ b/NEWS.md @@ -28,8 +28,16 @@ where the formatting is also better._ draw their own axes: `"all"`, `"outer"` (drop redundant interior axes), or `"none"`. Previously this was only achievable as a side effect of `frame.plot = FALSE`, so `facet.args = list(axes = "outer")` now allows - redundant axes to be dropped while *keeping* the facet frames. (#661 + redundant axes to be dropped while *keeping* the facet frames. (#661, #673 @grantmcdermott) + - The same behaviour can be set globally via the new `facet.axes` parameter + (note the reverse order), e.g. `tpar(facet.axes = "outer")`, which also + makes it available to themes. A per-call `facet.args = list(axes = ...)` + takes precedence over the global setting, which in turn takes precedence + over the old frame-based inference. + - Accordingly, themes with L-shaped axes (`"classic"`, `"socviz"`, `"tufte"`, + and `"float"`) now default to `facet.axes = "outer"` so that they drop the + redundant interior axes of faceted plots. ### Bug fixes @@ -39,6 +47,11 @@ where the formatting is also better._ (#668 @grantmcdermott) - User-provided `x/ylim` overrides now work correctly with flipped plots. (#670 @grantmcdermott) + - Axes now inherit the same themed `cex`, `lwd` and `lty` as their fixed-scale + counterparts. Previously the free-facet code path built its axis calls by + hand and so silently ignored `cex.axis`, `lwd.axis` and `lty.axis` (plus + their per-side variants), which was most visible under themes that set them, + e.g. `tinytheme("bw")`. (#673 @grantmcdermott) - Grouped and faceted plots no longer redraw axes once per empty group. This was most visible for `"spineplot"` types (e.g. `facet = "by"`), where the self-drawn axis labels were overplotted several times and rendered too heavy. diff --git a/R/facet.R b/R/facet.R index 66b42d45..8843be5d 100644 --- a/R/facet.R +++ b/R/facet.R @@ -81,6 +81,25 @@ draw_facet_window = function( } } + # Are only the outer (edge) facet axes drawn? Computed once, up here, because + # both the margin logic below and the per-panel frame drawing further down need + # it (and the latter also runs for unfaceted plots). Two variants: + # - .outer_axes: keyed off the structural `frame.plot`; used for the + # box-to-box facet gap. + # - .outer_axes_eff: keyed off the `framed` hint where a type sets one (e.g. + # data_spineplot() forces frame.plot = FALSE internally but + # still draws per-panel axes); used for the tick-label width. + # A per-call `facet.args$axes` wins over the global `tpar("facet.axes")`, which + # in turn wins over the implicit frame-based rule (i.e. NULL, the default). + # Resolve it back into `facet.args` so that every downstream consumer -- incl. + # the self-drawing types, which read it off `facet_window_args` -- sees the + # same value without each having to redo the lookup. + facet.args[["axes"]] = facet.args[["axes"]] %||% get_tpar("facet.axes", tpar_list = tpars) + .axes = facet.args[["axes"]] + .eff_frame = if (!is.null(type_hints[["framed"]])) type_hints[["framed"]] else frame.plot + .outer_axes = outer_axes_only(frame.plot, facet.args[["free"]], .axes) + .outer_axes_eff = outer_axes_only(.eff_frame, facet.args[["free"]], .axes) + if (nfacets > 1) { # Set facet margins (i.e., gaps between facets) if (is.null(facet.args[["fmar"]])) { @@ -104,53 +123,16 @@ draw_facet_window = function( ## ... exception for 2x2 cases if (!(nfacet_rows == 2 && nfacet_cols == 2)) fmar = fmar * .75 } - # Extra reduction if no plot frame to reduce whitespace - if (isFALSE(frame.plot) && !isTRUE(facet.args[["free"]])) { + # Extra reduction to close up the whitespace that an interior facet axis + # would otherwise occupy; see outer_axes_only(). This gap is the + # box-to-box spacing, so it keys off the *structural* `frame.plot` (a type + # that suppresses its own box wants the tighter gap), not `.eff_frame`. + if (.outer_axes) { fmar = fmar - 0.5 } ooma = par("oma") - # Types that draw their own axes may force `frame.plot = FALSE` so the - # pipeline skips the box (e.g. data_spineplot()), while surfacing the user's - # real choice via the `framed` hint. Use the hint where given, so the margin - # logic below matches what the type will actually draw. - .framed = facet_axes_framed( - if (!is.null(type_hints[["framed"]])) type_hints[["framed"]] else frame.plot, - xaxt, yaxt - ) - - # Will any *interior* (non-edge) facet draw its own axis on this side? If so - # that facet needs the tick-label width in its own margin, rather than the - # single outer allocation that the nmar/noma split below would otherwise - # make. - # - # Only reached for genuinely bare axis styles ("l", "n") with fixed scales: - # both callers below guard on `!.framed` first, and `.framed` is TRUE for - # every framed theme *and* for `axes = "t"`. Note this does not mirror the - # generic draw site, which is not tick-aware; see facet_axes_framed(). - .interior_axis = function(side) { - if (nfacets <= 1) return(FALSE) - fwa = list(ifacet = ifacet, nfacet_cols = nfacet_cols) - keep = vapply( - ifacet, - function(ii) draw_facet_axis( - side, ii, fwa, - framed = .framed, - free = isTRUE(facet.args[["free"]]), - axes = facet.args[["axes"]] - ), - logical(1L) - ) - # more panels draw this axis than sit on its outer edge => interior draws - edge = vapply( - ifacet, - function(ii) draw_facet_axis(side, ii, fwa, framed = FALSE, free = FALSE, axes = "outer"), - logical(1L) - ) - sum(keep) > sum(edge) - } - # Bump top margin for facet strip. Use facet_text (not / cex_fct_adj) # because nmar = (fmar + 0.1) / cex_fct_adj already divides — using # facet_text directly keeps the inter-panel gap constant as newlines grow. @@ -204,9 +186,10 @@ draw_facet_window = function( # hands it to the *outer* margin -- correct when only the leftmost facet # draws a y axis. But when interior facets draw their own (e.g. framed # panels), each needs that width in its own margin instead, else the - # labels overflow into the neighbouring panel. Keep the fmar bump in that - # case; otherwise release it back to the outer margin as before. - if (!.framed && !isTRUE(facet.args[["free"]]) && !.interior_axis(2)) { + # labels overflow into the neighbouring panel. So only release it back to + # the outer margin when interior axes aren't drawn at all; same rule + # (and same reason) as the inter-facet gap above. + if (.outer_axes_eff) { fmar[2] = fmar[2] - (whtsbp * cex_fct_adj) } } @@ -231,7 +214,7 @@ draw_facet_window = function( } # As per the y axis above: keep the label width in fmar when interior # facets draw their own x axis, else release it to the outer margin. - if (!.framed && !isTRUE(facet.args[["free"]]) && !.interior_axis(1)) { + if (.outer_axes_eff) { fmar[1] = fmar[1] - (whtsbp * cex_fct_adj) } } @@ -437,26 +420,38 @@ draw_facet_window = function( par(usr = fusr[[ii]]) # Free facets each need their own axes, since every panel has its own # scale. The one exception is an explicit `axes = "none"` request. - .free_axes = !identical(facet.args[["axes"]], "none") - # if plot frame is true then print axes per normal... + .free_axes = !identical(.axes, "none") + # Reuse the args_x/args_y lists built above rather than calling tinyAxis() + # with a bare handful of arguments, so that free facets pick up the same + # themed `cex`/`lwd`/`lty` (cex.axis, lwd.axis, lty.axis and their + # per-side variants) as fixed ones. Only the per-facet bits are + # overridden: the panel's own data, plus `at`/`labels` where this facet + # needs explicit ticks. if (.free_axes) { + .axf = args_x + .axf[[1L]] = xfree if (!is.null(xlabs)) { - tinyAxis(xfree, side = xside, at = xlabs, labels = names(xlabs), type = xaxt, labeller = xaxl) + .axf = modifyList(.axf, list(at = xlabs, labels = names(xlabs))) } else if (!is.null(xat)) { - tinyAxis(xfree, side = xside, at = xat, type = xaxt, labeller = xaxl) + .axf = modifyList(.axf, list(at = xat)) } else { - tinyAxis(xfree, side = xside, type = xaxt, labeller = xaxl) + # a fixed-scale `at` (from xaxb) doesn't apply to this facet's range + .axf[["at"]] = NULL } + do.call(tinyAxis, .axf) } if (.ymgp_shift > 0) par(mgp = par("mgp") - c(0, .ymgp_shift, 0)) if (.free_axes) { + .ayf = args_y + .ayf[[1L]] = yfree if (isTRUE(flip) && type %in% c("barplot", "pointrange", "errorbar", "ribbon", "boxplot", "p", "violin") && !is.null(ylabs)) { - tinyAxis(yfree, side = yside, at = ylabs, labels = names(ylabs), type = yaxt, labeller = yaxl) + .ayf = modifyList(.ayf, list(at = ylabs, labels = names(ylabs))) } else if (!is.null(yat)) { - tinyAxis(yfree, side = yside, at = yat, type = yaxt, labeller = yaxl) + .ayf = modifyList(.ayf, list(at = yat)) } else { - tinyAxis(yfree, side = yside, type = yaxt, labeller = yaxl) + .ayf[["at"]] = NULL } + do.call(tinyAxis, .ayf) } if (.ymgp_shift > 0) par(mgp = par("mgp") + c(0, .ymgp_shift, 0)) @@ -471,7 +466,7 @@ draw_facet_window = function( draw_facet_axis( side, ii, .fwa, framed = isTRUE(frame.plot), - axes = facet.args[["axes"]] + axes = .axes ) } if (keep_axis(xside)) do.call(tinyAxis, args_x) @@ -612,8 +607,27 @@ draw_facet_window = function( } } - # plot frame - if (frame.plot) box() + # plot frame. For a directional `bty` (e.g. the L of tinytheme("classic")), + # drop any edge that faces a neighbouring panel rather than the grid's outer + # boundary, else it floats in the gutter; see draw_facet_box(). + # + # Only when the interior axes are dropped too, though. A per-panel box also + # extends that panel's axis rules to the full panel width, so removing it + # while the axes remain would leave short, inset rules behind. Tie the two + # together: same condition, so the frame and the axes agree. + # + # Fast path: a stray interior edge needs more than one facet, a *directional* + # bty, and suppressed interior axes. A single panel has no interior edge at + # all; nor does a full box ("o") or no box ("n"); nor does a plot that still + # draws its interior axes. All of those defer straight to box(), which is + # cheaper, exact, and (unlike per-side segments) draws one joined polyline. + if (frame.plot) { + if (nfacets > 1 && .outer_axes && !(par("bty") %in% c("o", "O", "n", "N"))) { + draw_facet_box(par("bty"), ii, list(ifacet = ifacet, nfacet_cols = nfacet_cols)) + } else { + box() + } + } # panel grid lines if (is.null(grid)) grid = get_tpar("grid", tpar_list = tpars) @@ -893,6 +907,94 @@ is_facet_position = function(position, ifacet, facet_window_args) { } +## Draw a facet panel's plot frame, dropping any edge that faces a neighbour. +## +## For a directional `bty` (i.e. anything but the full box "o"), a per-panel +## frame leaves stray lines floating in the gutter between facets -- e.g. +## tinytheme("classic") draws an L in every panel, so interior panels show a bare +## vertical/horizontal rule. Only the edges that sit on the facet grid's *outer* +## boundary are wanted, matching how ggplot2 renders `theme_classic()` facets. +## +## This can't be delegated back to box(): `bty` has no code for a single edge +## ("o" = all four, "l" = left+bottom, "7" = top+right, "u" = 3 sides, "c"/"]" = +## bracket, "n" = none), yet a 2x2 grid needs left-only and bottom-only panels. +## So decompose `bty` into its constituent sides and draw the survivors with +## segments() along par("usr"). +## +## `bty = "o"` keeps calling box() directly: a full box on every panel is the +## conventional faceted look, and it has no interior-facing edge problem. +draw_facet_box = function(bty, ifacet, facet_window_args) { + sides = switch( + bty, + # GBox() case-folds these, so accept both cases for the letter codes. + "o" = , "O" = c("bottom", "left", "top", "right"), + "l" = , "L" = c("bottom", "left"), + "7" = c("top", "right"), + "u" = , "U" = c("bottom", "left", "right"), + # "c" opens to the right, so it draws top/left/bottom (same as "["); "]" + # opens to the left, so bottom/right/top. Verified against base box(). + "c" = , "C" = , "[" = c("bottom", "left", "top"), + "]" = c("bottom", "top", "right"), + "n" = , "N" = character(0L), + # unknown/unsupported code: fall back to base R's own handling + NULL + ) + if (is.null(sides)) { + box(bty = bty) + return(invisible(NULL)) + } + if (!length(sides)) return(invisible(NULL)) + # A full box has no interior-facing edge to drop, so keep base R's version + # (identical output, and it draws the frame as a single polygon). + if (bty %in% c("o", "O")) { + box() + return(invisible(NULL)) + } + # Drop the edges that abut another panel rather than the grid's outer boundary + if (!is.null(facet_window_args)) { + sides = sides[vapply( + sides, + function(s) isTRUE(is_facet_position(s, ifacet, facet_window_args)), + logical(1L) + )] + } + if (!length(sides)) return(invisible(NULL)) + u = par("usr") + if (par("xlog")) u[1:2] = 10^u[1:2] + if (par("ylog")) u[3:4] = 10^u[3:4] + # `xpd = 2` mirrors what C_box() does before calling GBox() ("force clipping to + # device region"). The frame lies exactly *on* the plot region boundary, so + # under the default xpd = FALSE it gets clipped to that same boundary, losing + # half its stroke width and rendering visibly thin. + for (s in sides) { + switch(s, + "bottom" = segments(u[1], u[3], u[2], u[3], xpd = 2), + "top" = segments(u[1], u[4], u[2], u[4], xpd = 2), + "left" = segments(u[1], u[3], u[1], u[4], xpd = 2), + "right" = segments(u[2], u[3], u[2], u[4], xpd = 2) + ) + } + invisible(NULL) +} + + +## Are only the outer (edge) facet axes drawn, i.e. no interior axes? +## +## Drives the inter-facet gap (fmar), which must shrink exactly when no interior +## axis is drawn to fill it. This has to mirror what the pipeline *actually* +## draws, which is not a single rule: free scales draw per-panel via `.free_axes` +## (ignoring "outer"), while fixed scales follow draw_facet_axis(). Hence the +## `none` and `free` short-circuits below come before the "outer"/frame checks. +## (For "none", strictly *no* axes are drawn, but the gap-tightening is the same.) +outer_axes_only = function(frame.plot, free, axes) { + if (identical(axes, "none")) return(TRUE) # no axes at all (free or fixed) + if (isTRUE(free)) return(FALSE) # free scales draw per-panel + if (identical(axes, "all")) return(FALSE) # per-panel axes forced on + if (identical(axes, "outer")) return(TRUE) # interior axes off, edges kept + isFALSE(frame.plot) # frameless => interior dropped +} + + ## Should facet panel `ifacet` draw its own axis on `side`? ## ## Framed panels each get their own axis, since the frame visually contains it. diff --git a/R/tinytheme.R b/R/tinytheme.R index 231754c7..6840722b 100644 --- a/R/tinytheme.R +++ b/R/tinytheme.R @@ -427,6 +427,7 @@ theme_classic = modifyList(theme_dynamic, list( cex.axis = 0.8, cex.cap = 0.8, col.default = -1L, # black single-group; drop it from the grouped palette + facet.axes = "outer", facet.bg = NULL, font.main = 1, gap.axis = 0.1, @@ -440,6 +441,7 @@ theme_classic = modifyList(theme_dynamic, list( theme_clean2 = modifyList(theme_clean, list( tinytheme = "clean2", + facet.axes = "outer", facet.border = "gray90", xaxt = "labels", yaxt = "labels" @@ -476,6 +478,7 @@ theme_linedraw = modifyList(theme_bw, list( theme_minimal = modifyList(theme_bw, list( tinytheme = "minimal", bty = "n", + facet.axes = "outer", facet.bg = NULL, facet.border = NULL, xaxt = "labels", @@ -558,6 +561,7 @@ theme_socviz = modifyList(theme_minimal, list( col.default = "black", # bespoke palette doesn't lead with black; pin it col.xaxs = "gray10", col.yaxs = "gray10", + facet.axes = "outer", facet.bg = NULL, facet.col = "grey10", font.main = 2, @@ -584,6 +588,7 @@ theme_broadsheet = modifyList(theme_dynamic, list( col.cap = "gray40", col.default = -1L, # black single-group; drop it from the grouped palette col.sub = "gray40", + facet.axes = "outer", font.main = 2, gap.axis = 0.1, gap.lab = 0.5, @@ -630,6 +635,7 @@ theme_web = modifyList(theme_dynamic, list( cex.cap = 0.8, col.cap = "gray40", col.sub = "gray40", + facet.axes = "outer", font.main = 2, grid = TRUE, grid.col = "#D2D2D2", @@ -650,6 +656,7 @@ theme_web = modifyList(theme_dynamic, list( theme_tufte = modifyList(theme_dynamic, list( tinytheme = "tufte", bty = "n", + facet.axes = "outer", facet.bg = NULL, facet.border = NA, font.main = 1, @@ -674,6 +681,7 @@ theme_float = modifyList(theme_tufte, list( theme_void = modifyList(theme_dynamic, list( tinytheme = "void", col.default = "black", + facet.axes = "outer", facet.bg = NULL, facet.border = NA, font.main = 1, diff --git a/R/tpar.R b/R/tpar.R index 07d5adbe..ef0ec206 100644 --- a/R/tpar.R +++ b/R/tpar.R @@ -59,6 +59,7 @@ #' * `font.cap`: Integer specifying the font face for the plot caption (`1` = plain, `2` = bold, `3` = italic, `4` = bold italic). Defaults to `1`. #' * `line.cap`: Numeric specifying the margin line on which to draw the caption. If `NULL` (default), computed automatically based on the available bottom margin. #' * `dynmar`: Logical indicating whether `tinyplot` should attempt dynamic adjustment of margins to reduce whitespace and/or account for spacing of text elements (e.g., long horizontal y-axis labels). Note that this parameter is tightly coupled to internal `tinythemes()` logic and should _not_ be adjusted manually unless you really know what you are doing or don't mind risking unintended consequences to your plot. +#' * `facet.axes`: Character string controlling which facets draw their own axes: `"all"` (each facet), `"outer"` (only facets on the outer edge of the facet grid, dropping redundant interior axes), or `"none"`. Defaults to `NULL`, whereby the choice is inferred from whether the plot is framed (see the `axes` argument of [`facet.args`][tinyplot]). Equivalent to setting `tinyplot(..., facet.args = list(axes = X))`, but globally, which also makes it available to themes. #' * `facet.bg`: Character or integer specifying the facet background colour. If an integer, will correspond to the user's default colour palette (see `palette`). Passed to `rect`. Defaults to `NULL` (none). #' * `facet.border`: Character or integer specifying the facet border colour. If an integer, will correspond to the user's default colour palette (see `palette`). Passed to `rect`. Defaults to `NA` (none). #' * `facet.cex`: Expansion factor for facet titles. Defaults to `1`. @@ -245,6 +246,7 @@ known_tpar = c( "gap.lab", "gap.main", "gap.sub", + "facet.axes", "facet.bg", "facet.border", "facet.cex", @@ -303,6 +305,7 @@ assert_tpar = function(.tpar) { assert_numeric(.tpar[["file.width"]], len = 1, lower = 0, null.ok = TRUE, name = "file.width") assert_numeric(.tpar[["facet.font"]], len = 1, null.ok = TRUE, name = "facet.font") assert_numeric(.tpar[["facet.cex"]], len = 1, null.ok = TRUE, name = "facet.cex") + assert_choice(.tpar[["facet.axes"]], c("all", "outer", "none"), null.ok = TRUE, name = "facet.axes") assert_numeric(.tpar[["side.sub"]], len = 1, null.ok = TRUE, name = "side.sub") assert_string(.tpar[["grid.bg"]], null.ok = TRUE, name = "grid.bg") assert_numeric(.tpar[["fmar"]], len = 4, null.ok = TRUE, name = "fmar") diff --git a/inst/tinytest/_tinysnapshot/facet_axes_all_classic_override.svg b/inst/tinytest/_tinysnapshot/facet_axes_all_classic_override.svg new file mode 100644 index 00000000..28fa1060 --- /dev/null +++ b/inst/tinytest/_tinysnapshot/facet_axes_all_classic_override.svg @@ -0,0 +1,208 @@ + + + + + + + + + + + + + +wt +mpg + + + + + + + + + + + + + + +2 +3 +4 +5 + + + + + + +10 +15 +20 +25 +30 + +0:0 + + + + + + + + + + + + + + + +2 +3 +4 +5 + + + + + + +10 +15 +20 +25 +30 + +1:0 + + + + + + + + + + + + + + + +2 +3 +4 +5 + + + + + + +10 +15 +20 +25 +30 + +0:1 + + + + + + + + + + + + + + + +2 +3 +4 +5 + + + + + + +10 +15 +20 +25 +30 + +1:1 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/inst/tinytest/_tinysnapshot/facet_axes_outer.svg b/inst/tinytest/_tinysnapshot/facet_axes_outer.svg new file mode 100644 index 00000000..c70115e5 --- /dev/null +++ b/inst/tinytest/_tinysnapshot/facet_axes_outer.svg @@ -0,0 +1,164 @@ + + + + + + + + + + + + + +wt +mpg + + + + + + + + + + + + + + + +10 +15 +20 +25 +30 + +0:0 + + + + + + + + + + +1:0 + + + + + + + + + + + + + + +2 +3 +4 +5 + + + + + + +10 +15 +20 +25 +30 + +0:1 + + + + + + + + + + + + + + +2 +3 +4 +5 + +1:1 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/inst/tinytest/_tinysnapshot/facet_axes_outer_classic.svg b/inst/tinytest/_tinysnapshot/facet_axes_outer_classic.svg new file mode 100644 index 00000000..da232492 --- /dev/null +++ b/inst/tinytest/_tinysnapshot/facet_axes_outer_classic.svg @@ -0,0 +1,189 @@ + + + + + + + + + + + + + +wt +mpg + + + + + + + + + + + + + + + +10 +15 +20 +25 +30 + +0:0 + + + + + + + + + + + + + + + + + + +1:0 + + + + + + + + + + + + + + +2 +3 +4 +5 + + + + + + +10 +15 +20 +25 +30 + +0:1 + + + + + + + + + + + + + + + + + + + + + + + +2 +3 +4 +5 + +1:1 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/inst/tinytest/_tinysnapshot/facet_axes_outer_free.svg b/inst/tinytest/_tinysnapshot/facet_axes_outer_free.svg new file mode 100644 index 00000000..11539e2b --- /dev/null +++ b/inst/tinytest/_tinysnapshot/facet_axes_outer_free.svg @@ -0,0 +1,234 @@ + + + + + + + + + + + + + +wt +mpg + + + + + + + + + + + + + + + +3.5 +4.0 +4.5 +5.0 +5.5 + + + + + +12 +14 +16 +18 + +0:0 + + + + + + + + + + + + + + + + + + + +2.2 +2.4 +2.6 +2.8 +3.0 +3.2 +3.4 +3.6 + + + + + + + +16 +18 +20 +22 +24 +26 + +1:0 + + + + + + + + + + + + + + + + +2.6 +2.8 +3.0 +3.2 +3.4 + + + + + + + + +18 +19 +20 +21 +22 +23 +24 + +0:1 + + + + + + + + + + + + + + + + + + +1.6 +1.8 +2.0 +2.2 +2.4 +2.6 +2.8 + + + + + + + + +22 +24 +26 +28 +30 +32 +34 + +1:1 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/inst/tinytest/_tinysnapshot/facet_axes_outer_tpar.svg b/inst/tinytest/_tinysnapshot/facet_axes_outer_tpar.svg new file mode 100644 index 00000000..0c6ec226 --- /dev/null +++ b/inst/tinytest/_tinysnapshot/facet_axes_outer_tpar.svg @@ -0,0 +1,168 @@ + + + + + + + + + + + + + +wt +mpg + + + + + + + + + + + + + + + +10 +15 +20 +25 +30 + +0:0 + + + + + + + + + + + +1:0 + + + + + + + + + + + + + + + +2 +3 +4 +5 + + + + + + +10 +15 +20 +25 +30 + +0:1 + + + + + + + + + + + + + + + +2 +3 +4 +5 + +1:1 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/inst/tinytest/test-facet.R b/inst/tinytest/test-facet.R index 41f256f5..fcc7b154 100644 --- a/inst/tinytest/test-facet.R +++ b/inst/tinytest/test-facet.R @@ -585,6 +585,65 @@ f = function() { } expect_snapshot_plot(f, label = "facet_free_flip") +# `axes = "outer"` must also close up the whitespace that the dropped interior +# axes would have occupied, i.e. match the spacing of a frameless plot rather +# than leaving a gap behind (#637, #673). Testing with "float", which should +# defaults to `facet.axes = "outer"` as part of its tpar settings. +f = function() { + tinyplot( + mpg ~ wt, data = mtcars, + facet = ~am:vs, + theme = "float" + ) +} +expect_snapshot_plot(f, label = "facet_axes_outer") + +# Same, but for a directional `bty` (the L-shaped frame of "classic"), and +# relying on the theme's own `facet.axes = "outer"` default rather than passing +# it per call. The interior frame edges have to be dropped alongside the axes, +# else they float in the gutter without an axis to anchor them. +f = function() { + tinyplot( + mpg ~ wt, data = mtcars, + facet = ~am:vs, + theme = "classic" + ) +} +expect_snapshot_plot(f, label = "facet_axes_outer_classic") + +# The global parameter should work through an ephemeral theme too, i.e. suppress +# the interior axes of an otherwise framed theme. +f = function() { + tinyplot( + mpg ~ wt, data = mtcars, + facet = ~am:vs, + theme = list("dynamic", facet.axes = "outer") + ) +} +expect_snapshot_plot(f, label = "facet_axes_outer_tpar") + +## Opposite for themes that normally suppress; override with all axes +f = function() { + tinyplot( + mpg ~ wt, data = mtcars, + facet = ~am:vs, facet.args = list(axes = "all"), + theme = "classic" + ) +} +expect_snapshot_plot(f, label = "facet_axes_all_classic_override") + +# Free scales are the exception: every panel has its own scale, so each keeps +# its own axes and `axes = "outer"` is (deliberately) a no-op. Guards against +# the outer-axis rule stripping axes that a free panel needs to be readable. +f = function() { + tinyplot( + mpg ~ wt, data = mtcars, + facet = ~am:vs, facet.args = list(free = TRUE), + theme = "classic" + ) +} +expect_snapshot_plot(f, label = "facet_axes_outer_free") + # # restore original par settings # diff --git a/man/tpar.Rd b/man/tpar.Rd index 98c9809d..72c90376 100644 --- a/man/tpar.Rd +++ b/man/tpar.Rd @@ -70,6 +70,7 @@ you should rather use \code{par()} instead. \item \code{font.cap}: Integer specifying the font face for the plot caption (\code{1} = plain, \code{2} = bold, \code{3} = italic, \code{4} = bold italic). Defaults to \code{1}. \item \code{line.cap}: Numeric specifying the margin line on which to draw the caption. If \code{NULL} (default), computed automatically based on the available bottom margin. \item \code{dynmar}: Logical indicating whether \code{tinyplot} should attempt dynamic adjustment of margins to reduce whitespace and/or account for spacing of text elements (e.g., long horizontal y-axis labels). Note that this parameter is tightly coupled to internal \code{tinythemes()} logic and should \emph{not} be adjusted manually unless you really know what you are doing or don't mind risking unintended consequences to your plot. +\item \code{facet.axes}: Character string controlling which facets draw their own axes: \code{"all"} (each facet), \code{"outer"} (only facets on the outer edge of the facet grid, dropping redundant interior axes), or \code{"none"}. Defaults to \code{NULL}, whereby the choice is inferred from whether the plot is framed (see the \code{axes} argument of \code{\link[=tinyplot]{facet.args}}). Equivalent to setting \code{tinyplot(..., facet.args = list(axes = X))}, but globally, which also makes it available to themes. \item \code{facet.bg}: Character or integer specifying the facet background colour. If an integer, will correspond to the user's default colour palette (see \code{palette}). Passed to \code{rect}. Defaults to \code{NULL} (none). \item \code{facet.border}: Character or integer specifying the facet border colour. If an integer, will correspond to the user's default colour palette (see \code{palette}). Passed to \code{rect}. Defaults to \code{NA} (none). \item \code{facet.cex}: Expansion factor for facet titles. Defaults to \code{1}.