From 5156760d921e9aeba591edf9d661cfc01b0e2b4b Mon Sep 17 00:00:00 2001 From: Grant McDermott Date: Sat, 16 May 2026 20:29:06 -0700 Subject: [PATCH 01/14] adjust for cex.lab and cex.axis --- R/tinyplot.R | 25 +++++++++++++++++++++++++ R/utils.R | 26 ++++++++++---------------- 2 files changed, 35 insertions(+), 16 deletions(-) diff --git a/R/tinyplot.R b/R/tinyplot.R index e15b1193..1c767f30 100644 --- a/R/tinyplot.R +++ b/R/tinyplot.R @@ -974,6 +974,7 @@ tinyplot.default = function( # dynmar_computed = NULL .whtsbp = c(0, 0, 0, 0) + .mgp_scaled = FALSE if (!add && isTRUE(get_tpar("dynmar"))) { .side.sub = get_tpar("side.sub", default = 3) # Read the theme's intended mar. Also build a tpars list from the theme @@ -997,6 +998,28 @@ tinyplot.default = function( } if (!is.null(.tpars[["mar"]])) .theme_mar = .tpars[["mar"]] + # Dynamic mgp: scale mgp[1] and mgp[2] when cex.axis or cex.lab exceed 1. + # At cex=1 the theme's base mgp works. At larger cex, tick labels need more + # space to clear the plot edge (mgp[2] >= 0.5*cex_axis) and the title needs + # more separation from tick labels (mgp[1] >= mgp[2] + 0.4*cex_axis + 0.5*cex_lab). + .cex_axis = get_tpar("cex.axis", tpar_list = .tpars, default = 1) + .cex_lab = max( + get_tpar(c("cex.xlab", "cex.lab"), tpar_list = .tpars, default = 1), + get_tpar(c("cex.ylab", "cex.lab"), tpar_list = .tpars, default = 1) + ) + .mgp = get_tpar("mgp", tpar_list = .tpars) + if (.cex_axis > 1 || .cex_lab > 1) { + .mgp2_min = 0.5 * .cex_axis + .mgp2 = max(.mgp[2], .mgp2_min) + .mgp1_min = .mgp2 + 0.4 * .cex_axis + 0.5 * .cex_lab + .mgp1 = max(.mgp[1], .mgp1_min) + if (.mgp1 != .mgp[1] || .mgp2 != .mgp[2]) { + .mgp = c(.mgp1, .mgp2, .mgp[3]) + .tpars[["mgp"]] = .mgp + .mgp_scaled = TRUE + } + } + # Detect outer-legend sides (order: bottom, left, top, right). .lgnd_pos = settings$legend_args[["x"]] .outer_sides = c( @@ -1070,6 +1093,7 @@ tinyplot.default = function( dynmar_computed = .theme_mar + .dyn par(mar = dynmar_computed + .whtsbp) + if (.mgp_scaled) par(mgp = .mgp) } if (legend_draw_flag) { @@ -1126,6 +1150,7 @@ tinyplot.default = function( # (which may have called plot.new and reset par via hooks). if (!is.null(dynmar_computed)) { par(mar = dynmar_computed + .whtsbp) + if (.mgp_scaled) par(mgp = .mgp) if (!is.null(xlim) && !is.null(ylim)) { plot.window(xlim = xlim, ylim = ylim) } diff --git a/R/utils.R b/R/utils.R index 3e647a11..99bbd7a2 100644 --- a/R/utils.R +++ b/R/utils.R @@ -18,10 +18,13 @@ text_line_count = function(x) { # Compute additive margin "build" for a given side under dynmar. # Starts from zero and adds only what the plot actually needs. The tick # row and the axis-label row occupy overlapping vertical space (tick row -# ends at ~|tcl| + mgp[2] + 1, axis label baseline sits at mgp[1]), so the -# margin is the max of: -# - tick-row height (|tcl| + mgp[2] + 1), when the axis is drawn -# - axis-label extent (mgp[1] + (N - 1) * cex + 1 line for asc/desc), when present +# ends at ~|tcl| + mgp[2] + descent, axis label baseline sits at mgp[1]), +# so the margin is the max of: +# - tick-row height (|tcl| + mgp[2] + descent), when the axis is drawn +# - axis-label extent (mgp[1] + (N - 1) * cex + descent), when present +# The descent term = 0.4*cex + 0.6: text at the mgp reference line extends +# ~0.4*cex lines below baseline (empirically measured character cell descent), +# plus 0.6 lines fixed buffer for descender characters and spacing. # Main/sub sit above/below the plot box on the top/bottom side and add to the # margin additively. # Tick-label *width* for sides 2/4 (and *height* for 1/3 under las 2:3) is @@ -33,10 +36,8 @@ dynmar_side = function(side, label, main = NULL, sub = NULL, mgp = get_tpar("mgp", tpar_list = tpars) tcl = get_tpar("tcl", tpar_list = tpars, default = par("tcl")) tick_extent = if (side %in% 1:2 && isTRUE(axis_on)) { - # |tcl| = tick mark length (outward); mgp[2] = tick-label distance from - # axis; +1 = one line for the tick-label text itself. These mirror base - # R's default layout: par(tcl = -0.5, mgp = c(3,1,0)) gives 0.5+1+1=2.5. - max(0, -tcl) + mgp[2] + 1 + cex_axis = get_tpar("cex.axis", tpar_list = tpars, default = 1) + max(0, -tcl) + mgp[2] + 0.4 * cex_axis + 0.6 } else 0 label_extent = 0 lines = text_line_count(label) @@ -45,14 +46,7 @@ dynmar_side = function(side, label, main = NULL, sub = NULL, if (side == 1L) c("cex.xlab", "cex.lab") else c("cex.ylab", "cex.lab"), tpar_list = tpars, default = 1 ) - # Last-line baseline sits at mgp[1] + (N-1)*cex (after line-shift in - # draw_title); add a full line to cover ascender+descender so the text - # doesn't clip against the device edge. - # TODO: the +1 constant doesn't scale with cex_lab, so large values - # (e.g. cex.xlab = 3) under-reserve margin and the title overlaps - # tick labels. Fixing this requires also pushing the draw-position - # (line arg in draw_title) further out. See "P.S." in #574. - label_extent = mgp[1] + (lines - 1) * cex_lab + 1 + label_extent = mgp[1] + (lines - 1) * cex_lab + 0.4 * cex_lab + 0.6 # Expressions (e.g., ylab = expression(mm^{1/2})) can be taller than a # plain text line due to superscripts, subscripts, fractions, etc. Measure # the actual rendered height and add the excess over a normal text line. From 05d340dc54203cf7737149d058f02f67b7ade4d1 Mon Sep 17 00:00:00 2001 From: Grant McDermott Date: Sat, 16 May 2026 21:48:09 -0700 Subject: [PATCH 02/14] las = 1 gotcha --- R/facet.R | 14 +++++++------- R/tinyplot.R | 9 +++++---- 2 files changed, 12 insertions(+), 11 deletions(-) diff --git a/R/facet.R b/R/facet.R index e578f359..5d346a60 100644 --- a/R/facet.R +++ b/R/facet.R @@ -144,14 +144,14 @@ draw_facet_window = function( yaxlabs_all = lapply(yfree_split, function(yf) { axisTicks(usr = extendrange(range(yf, na.rm = TRUE), f = 0.04), log = par("ylog")) }) - widths = vapply(yaxlabs_all, function(labs) max(strwidth(labs, "inches")), numeric(1L)) + widths = vapply(yaxlabs_all, function(labs) max(strwidth(labs, "inches", cex = par("cex.axis"))), numeric(1L)) yaxlabs = yaxlabs_all[[which.max(widths)]] } else { yaxlabs = axisTicks(usr = extendrange(ylim, f = 0.04), log = par("ylog")) } if (!is.null(yaxl)) yaxlabs = tinylabel(yaxlabs, yaxl) # whtsbp = grconvertX(max(strwidth(yaxl, "figure")), from = "nfc", to = "lines") - 1 - whtsbp = grconvertX(max(strwidth(yaxlabs, "figure")), from = "nfc", to = "lines") - grconvertX(0, from = "nfc", to = "lines") - 1 + whtsbp = grconvertX(max(strwidth(yaxlabs, "figure", cex = par("cex.axis"))), from = "nfc", to = "lines") - grconvertX(0, from = "nfc", to = "lines") - 1 if (whtsbp > 0) { omar = omar + c(0, whtsbp, 0, 0) * cex_fct_adj fmar[2] = fmar[2] + whtsbp * cex_fct_adj @@ -168,14 +168,14 @@ draw_facet_window = function( xaxlabs_all = lapply(xfree_split, function(xf) { axisTicks(usr = extendrange(range(xf, na.rm = TRUE), f = 0.04), log = par("xlog")) }) - widths = vapply(xaxlabs_all, function(labs) max(strwidth(labs, "inches")), numeric(1L)) + widths = vapply(xaxlabs_all, function(labs) max(strwidth(labs, "inches", cex = par("cex.axis"))), numeric(1L)) xaxlabs = xaxlabs_all[[which.max(widths)]] } else { xaxlabs = if (is.null(xlabs)) axisTicks(usr = extendrange(xlim, f = 0.04), log = par("xlog")) else if (!is.null(names(xlabs))) names(xlabs) else xlabs } if (!is.null(xaxl)) xaxlabs = tinylabel(xaxlabs, xaxl) - whtsbp = grconvertX(max(strwidth(xaxlabs, "figure")), from = "nfc", to = "lines") - 1 + whtsbp = grconvertX(max(strwidth(xaxlabs, "figure", cex = par("cex.axis"))), from = "nfc", to = "lines") - 1 if (whtsbp > 0) { omar = omar + c(whtsbp, 0, 0, 0) * cex_fct_adj fmar[1] = fmar[1] + whtsbp * cex_fct_adj @@ -236,8 +236,8 @@ draw_facet_window = function( yaxlabs = axisTicks(usr = extendrange(ylim, f = 0.04), log = par("ylog")) } if (!is.null(yaxl)) yaxlabs = tinylabel(yaxlabs, yaxl) - # whtsbp = grconvertX(max(strwidth(yaxlabs, "figure")), from = "nfc", to = "lines") - 1 - whtsbp = grconvertX(max(strwidth(yaxlabs, "figure")), from = "nfc", to = "lines") - grconvertX(0, from = "nfc", to = "lines") - 1 + # whtsbp = grconvertX(max(strwidth(yaxlabs, "figure", cex = par("cex.axis"))), from = "nfc", to = "lines") - 1 + whtsbp = grconvertX(max(strwidth(yaxlabs, "figure", cex = par("cex.axis"))), from = "nfc", to = "lines") - grconvertX(0, from = "nfc", to = "lines") - 1 if (whtsbp > 0) { omar[2] = omar[2] + whtsbp } @@ -248,7 +248,7 @@ draw_facet_window = function( xaxlabs = if (is.null(xlabs)) axisTicks(usr = extendrange(xlim, f = 0.04), log = par("xlog")) else if (!is.null(names(xlabs))) names(xlabs) else xlabs if (!is.null(xaxl)) xaxlabs = tinylabel(xaxlabs, xaxl) - whtsbp = grconvertX(max(strwidth(xaxlabs, "figure")), from = "nfc", to = "lines") - 1 + whtsbp = grconvertX(max(strwidth(xaxlabs, "figure", cex = par("cex.axis"))), from = "nfc", to = "lines") - 1 if (whtsbp > 0) { omar[1] = omar[1] + whtsbp } diff --git a/R/tinyplot.R b/R/tinyplot.R index 1c767f30..6f361fd6 100644 --- a/R/tinyplot.R +++ b/R/tinyplot.R @@ -1058,7 +1058,8 @@ tinyplot.default = function( # Compute whtsbp (tick-label width/height bump). Read `las` from .tpars # (the theme definition) rather than par() — par("las") isn't set to the # theme's intended value until the before.plot.new hook fires, but this - # block runs before that. + # block runs before that. Pass .cex_axis to strwidth so measurements + # reflect the intended text size (par("cex.axis") isn't set yet either). .whtsbp = c(0, 0, 0, 0) .las = get_tpar("las", tpar_list = .tpars, default = par("las")) if (.las %in% 1:2) { @@ -1072,7 +1073,7 @@ tinyplot.default = function( yaxlabs = axisTicks(usr = extendrange(ylim, f = 0.04), log = par("ylog")) } if (!is.null(yaxl)) yaxlabs = tinylabel(yaxlabs, yaxl) - whtsbp_y = grconvertX(max(strwidth(yaxlabs, "figure")), from = "nfc", to = "lines") - + whtsbp_y = grconvertX(max(strwidth(yaxlabs, "figure", cex = .cex_axis)), from = "nfc", to = "lines") - grconvertX(0, from = "nfc", to = "lines") - 1 if (is.finite(whtsbp_y) && whtsbp_y > 0) .whtsbp[2] = whtsbp_y } @@ -1080,7 +1081,7 @@ tinyplot.default = function( xaxlabs = if (is.null(xlabs)) axisTicks(usr = extendrange(xlim, f = 0.04), log = par("xlog")) else if (!is.null(names(xlabs))) names(xlabs) else xlabs if (!is.null(xaxl)) xaxlabs = tinylabel(xaxlabs, xaxl) - whtsbp_x = grconvertX(max(strwidth(xaxlabs, "figure")), from = "nfc", to = "lines") - 1 + whtsbp_x = grconvertX(max(strwidth(xaxlabs, "figure", cex = .cex_axis)), from = "nfc", to = "lines") - 1 if (is.finite(whtsbp_x) && whtsbp_x > 0) .whtsbp[1] = whtsbp_x } @@ -1437,7 +1438,7 @@ tinyplot.default = function( apar = par(no.readonly = TRUE) set_saved_par(when = "after", apar) }, - list = list(), + list = list(), env = getNamespace('tinyplot') ) } From 6df19f5659a2105747f3ef68f56b2d91b4716a08 Mon Sep 17 00:00:00 2001 From: Grant McDermott Date: Sun, 17 May 2026 11:40:53 -0700 Subject: [PATCH 03/14] feat(theme): dynamic mgp from gap primitives, fix cex scaling (#590) --- R/facet.R | 10 +++++++++- R/legend.R | 2 +- R/tinyplot.R | 28 ++++------------------------ R/tinytheme.R | 30 +++++++++++++++++++++++++++++- R/tpar.R | 2 ++ R/utils.R | 3 ++- 6 files changed, 47 insertions(+), 28 deletions(-) diff --git a/R/facet.R b/R/facet.R index 5d346a60..a20c6c9b 100644 --- a/R/facet.R +++ b/R/facet.R @@ -313,11 +313,13 @@ draw_facet_window = function( lwd = get_tpar(c("lwd.xaxs", "lwd.axis"), 1, tpar_list = tpars), lty = get_tpar(c("lty.xaxs", "lty.axis"), 1, tpar_list = tpars) ) + .ca = get_tpar(c("cex.yaxs", "cex.axis"), 0.8, tpar_list = tpars) + .ymgp_shift = if (par("las") %in% c(0L, 1L)) 0.5 * (.ca - 1) else 0 args_y = list(y, side = yside, type = yaxt, labeller = yaxl, - cex = get_tpar(c("cex.yaxs", "cex.axis"), 0.8, tpar_list = tpars), + cex = .ca, lwd = get_tpar(c("lwd.yaxs", "lwd.axis"), 1, tpar_list = tpars), lty = get_tpar(c("lty.yaxs", "lty.axis"), 1, tpar_list = tpars) ) @@ -368,21 +370,27 @@ draw_facet_window = function( } else { tinyAxis(xfree, side = xside, type = xaxt, labeller = xaxl) } + if (.ymgp_shift > 0) par(mgp = par("mgp") - c(0, .ymgp_shift, 0)) 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) } else { tinyAxis(yfree, side = yside, type = yaxt, labeller = yaxl) } + if (.ymgp_shift > 0) par(mgp = par("mgp") + c(0, .ymgp_shift, 0)) # For fixed facets we can just reuse the same plot extent and axes limits } else if (isTRUE(frame.plot)) { # if plot frame is true then print axes per normal... do.call(tinyAxis, args_x) + if (.ymgp_shift > 0) par(mgp = par("mgp") - c(0, .ymgp_shift, 0)) do.call(tinyAxis, args_y) + if (.ymgp_shift > 0) par(mgp = par("mgp") + c(0, .ymgp_shift, 0)) } else { # ... else only print the "outside" axes. if (ii %in% oxaxis) do.call(tinyAxis, args_x) + if (.ymgp_shift > 0) par(mgp = par("mgp") - c(0, .ymgp_shift, 0)) if (ii %in% oyaxis) do.call(tinyAxis, args_y) + if (.ymgp_shift > 0) par(mgp = par("mgp") + c(0, .ymgp_shift, 0)) } } diff --git a/R/legend.R b/R/legend.R index db596b23..96ea572f 100644 --- a/R/legend.R +++ b/R/legend.R @@ -124,7 +124,7 @@ legend_outer_margins = function(legend_env, apply = TRUE) { if (legend_env$dynmar) { omar = par("mar") if (legend_env$outer_bottom) { - omar[1] = theme_dynamic$mgp[1] + 1 * par("cex.lab") + omar[1] = par("mgp")[1] + 1 * par("cex.lab") if (legend_env$has_sub && (is.null(.tpar[["side.sub"]]) || .tpar[["side.sub"]] == 1)) { omar[1] = omar[1] + 1 * par("cex.sub") } diff --git a/R/tinyplot.R b/R/tinyplot.R index 6f361fd6..50e61192 100644 --- a/R/tinyplot.R +++ b/R/tinyplot.R @@ -974,7 +974,6 @@ tinyplot.default = function( # dynmar_computed = NULL .whtsbp = c(0, 0, 0, 0) - .mgp_scaled = FALSE if (!add && isTRUE(get_tpar("dynmar"))) { .side.sub = get_tpar("side.sub", default = 3) # Read the theme's intended mar. Also build a tpars list from the theme @@ -998,27 +997,9 @@ tinyplot.default = function( } if (!is.null(.tpars[["mar"]])) .theme_mar = .tpars[["mar"]] - # Dynamic mgp: scale mgp[1] and mgp[2] when cex.axis or cex.lab exceed 1. - # At cex=1 the theme's base mgp works. At larger cex, tick labels need more - # space to clear the plot edge (mgp[2] >= 0.5*cex_axis) and the title needs - # more separation from tick labels (mgp[1] >= mgp[2] + 0.4*cex_axis + 0.5*cex_lab). .cex_axis = get_tpar("cex.axis", tpar_list = .tpars, default = 1) - .cex_lab = max( - get_tpar(c("cex.xlab", "cex.lab"), tpar_list = .tpars, default = 1), - get_tpar(c("cex.ylab", "cex.lab"), tpar_list = .tpars, default = 1) - ) - .mgp = get_tpar("mgp", tpar_list = .tpars) - if (.cex_axis > 1 || .cex_lab > 1) { - .mgp2_min = 0.5 * .cex_axis - .mgp2 = max(.mgp[2], .mgp2_min) - .mgp1_min = .mgp2 + 0.4 * .cex_axis + 0.5 * .cex_lab - .mgp1 = max(.mgp[1], .mgp1_min) - if (.mgp1 != .mgp[1] || .mgp2 != .mgp[2]) { - .mgp = c(.mgp1, .mgp2, .mgp[3]) - .tpars[["mgp"]] = .mgp - .mgp_scaled = TRUE - } - } + .las = get_tpar("las", tpar_list = .tpars, default = par("las")) + .ymgp_shift = if (.las %in% c(0L, 1L)) 0.5 * (.cex_axis - 1) else 0 # Detect outer-legend sides (order: bottom, left, top, right). .lgnd_pos = settings$legend_args[["x"]] @@ -1040,6 +1021,7 @@ tinyplot.default = function( tpars = .tpars), dynmar_side(4, NULL, tpars = .tpars) ) + if (.ymgp_shift > 0) .dyn[2] = .dyn[2] - .ymgp_shift # Drop the theme's baseline padding on outer-legend sides so the plot # region meets the legend's oma flush. Only .theme_mar is zeroed — the # axis-driven bumps in .dyn (tick rows, axis labels, main/sub) are kept @@ -1094,7 +1076,6 @@ tinyplot.default = function( dynmar_computed = .theme_mar + .dyn par(mar = dynmar_computed + .whtsbp) - if (.mgp_scaled) par(mgp = .mgp) } if (legend_draw_flag) { @@ -1151,14 +1132,13 @@ tinyplot.default = function( # (which may have called plot.new and reset par via hooks). if (!is.null(dynmar_computed)) { par(mar = dynmar_computed + .whtsbp) - if (.mgp_scaled) par(mgp = .mgp) if (!is.null(xlim) && !is.null(ylim)) { plot.window(xlim = xlim, ylim = ylim) } } draw_title(main, sub, xlab, ylab, legend, legend_args, opar, xlab_line_offset = if (!is.null(dynmar_computed)) .whtsbp[1] else 0, - ylab_line_offset = if (!is.null(dynmar_computed)) .whtsbp[2] else 0) + ylab_line_offset = if (!is.null(dynmar_computed)) .whtsbp[2] - .ymgp_shift else 0) } diff --git a/R/tinytheme.R b/R/tinytheme.R index 91c0be84..4f9062d5 100644 --- a/R/tinytheme.R +++ b/R/tinytheme.R @@ -190,6 +190,28 @@ tinytheme = function( settings[[n]] = dots[[n]] } + # Compute mgp from spacing primitives when dynmar is active and the user + # didn't provide an explicit mgp override. Text is centered on mgp[N], so + # it extends 0.5*cex above and below. The visible gap between elements is + # controlled by gap.axis (tick tip to tick label edge) and gap.lab (tick + # label edge to title edge). + if (isTRUE(settings[["dynmar"]]) && !("mgp" %in% names(dots))) { + .ga = settings[["gap.axis"]] %||% 0.2 + .gl = settings[["gap.lab"]] %||% 1.0 + .ca = settings[["cex.axis"]] %||% 1 + # FIXME: mgp is shared across sides, so we use the larger label cex to + # avoid clipping on either axis. Ideally we'd set side-specific mgp when + # cex.xlab and cex.ylab differ. + .cl = max( + settings[["cex.lab"]] %||% 1, + settings[["cex.xlab"]] %||% 0, + settings[["cex.ylab"]] %||% 0 + ) + .mgp2 = .ga + 0.5 * .ca + .mgp1 = .mgp2 + .gl + 0.5 * .cl + settings[["mgp"]] = c(.mgp1, .mgp2, 0) + } + if (length(settings) > 0) { if (theme == "default") { # for default theme, we want to revert the original pars and turn off the @@ -309,6 +331,10 @@ theme_dynamic = modifyList(theme_basic, list( ## `draw_facet_window()` helper builds each side's margin up from this ## pad, adding only what the plot actually needs (tick row, axis label, ## main, sub). See `dynmar_side()` in utils.R. + ## - `mgp` is computed in `tinytheme()` from the spacing primitives + ## (gap.axis, gap.lab) and the active cex.axis/cex.lab values. If the + ## user provides an explicit `mgp`, it is used as-is and the primitives + ## are ignored. ## - `side.sub = 3` moves the sub-caption above the plot (below main). ## - `tcl = -0.3` tightens axis tick marks relative to the base default. ## @@ -316,10 +342,12 @@ theme_dynamic = modifyList(theme_basic, list( adj.main = 0, adj.sub = 0, dynmar = TRUE, + gap.axis = 0.2, # fixed gap (lines) between tick tip and tick label center + gap.lab = 1.0, # gap from tick-label reference to title near cell edge (lines) grid = FALSE, las = 1, mar = c(0.1, 0.1, 0.6, 0.6), - mgp = c(3, 1, 0) - c(0.5+0.3, 0.3, 0), # i.e., subtract 0.5 lines + the (abs) value of the tcl adjustment + mgp = NULL, # computed from gap.axis/gap.lab in tinytheme() side.sub = 3, tcl = -0.3 )) diff --git a/R/tpar.R b/R/tpar.R index 32e6035f..5479791c 100644 --- a/R/tpar.R +++ b/R/tpar.R @@ -228,6 +228,8 @@ known_tpar = c( "col.yaxs", "cairo", "dynmar", + "gap.axis", + "gap.lab", "facet.bg", "facet.border", "facet.cex", diff --git a/R/utils.R b/R/utils.R index 99bbd7a2..a889eb50 100644 --- a/R/utils.R +++ b/R/utils.R @@ -46,7 +46,8 @@ dynmar_side = function(side, label, main = NULL, sub = NULL, if (side == 1L) c("cex.xlab", "cex.lab") else c("cex.ylab", "cex.lab"), tpar_list = tpars, default = 1 ) - label_extent = mgp[1] + (lines - 1) * cex_lab + 0.4 * cex_lab + 0.6 + descent = if (side == 2L) 0.5 * cex_lab + 0.6 else 0.4 * cex_lab + 0.6 + label_extent = mgp[1] + (lines - 1) * cex_lab + descent # Expressions (e.g., ylab = expression(mm^{1/2})) can be taller than a # plain text line due to superscripts, subscripts, fractions, etc. Measure # the actual rendered height and add the excess over a normal text line. From d86ac45f02d41c401df8a83cc8308d2efa1ee5a0 Mon Sep 17 00:00:00 2001 From: Grant McDermott Date: Sun, 17 May 2026 19:35:59 -0700 Subject: [PATCH 04/14] fix(dynmar): constant margins across cex.lab scaling Use ink-extent (0.2*cex) rather than cell-extent (0.5*cex) in descent formulas so the gap between axis titles and figure edges stays constant. Add ylab_cex_shift to correct for R placing rotated text at the baseline (not cell center), which otherwise causes the ylab-to-tick-label gap to grow with cex.lab. See SCRATCH/cex_scaling_internals.md for full derivation. --- R/tinyplot.R | 4 +++- R/tinytheme.R | 12 ++++++------ R/utils.R | 2 +- 3 files changed, 10 insertions(+), 8 deletions(-) diff --git a/R/tinyplot.R b/R/tinyplot.R index 50e61192..e783671e 100644 --- a/R/tinyplot.R +++ b/R/tinyplot.R @@ -998,8 +998,10 @@ tinyplot.default = function( if (!is.null(.tpars[["mar"]])) .theme_mar = .tpars[["mar"]] .cex_axis = get_tpar("cex.axis", tpar_list = .tpars, default = 1) + .cex_lab = get_tpar(c("cex.ylab", "cex.lab"), tpar_list = .tpars, default = 1) .las = get_tpar("las", tpar_list = .tpars, default = par("las")) .ymgp_shift = if (.las %in% c(0L, 1L)) 0.5 * (.cex_axis - 1) else 0 + .ylab_cex_shift = 0.5 * (.cex_lab - 1) # Detect outer-legend sides (order: bottom, left, top, right). .lgnd_pos = settings$legend_args[["x"]] @@ -1138,7 +1140,7 @@ tinyplot.default = function( } draw_title(main, sub, xlab, ylab, legend, legend_args, opar, xlab_line_offset = if (!is.null(dynmar_computed)) .whtsbp[1] else 0, - ylab_line_offset = if (!is.null(dynmar_computed)) .whtsbp[2] - .ymgp_shift else 0) + ylab_line_offset = if (!is.null(dynmar_computed)) .whtsbp[2] - .ymgp_shift - .ylab_cex_shift else 0) } diff --git a/R/tinytheme.R b/R/tinytheme.R index 4f9062d5..4f2057c7 100644 --- a/R/tinytheme.R +++ b/R/tinytheme.R @@ -191,10 +191,10 @@ tinytheme = function( } # Compute mgp from spacing primitives when dynmar is active and the user - # didn't provide an explicit mgp override. Text is centered on mgp[N], so - # it extends 0.5*cex above and below. The visible gap between elements is - # controlled by gap.axis (tick tip to tick label edge) and gap.lab (tick - # label edge to title edge). + # didn't provide an explicit mgp override. The near edge of margin text + # (facing the plot region) aligns with the half-cell boundary (0.5*cex from + # center). Using 0.5*cex in mgp keeps the visible gap between adjacent text + # elements constant regardless of cex scaling. if (isTRUE(settings[["dynmar"]]) && !("mgp" %in% names(dots))) { .ga = settings[["gap.axis"]] %||% 0.2 .gl = settings[["gap.lab"]] %||% 1.0 @@ -342,8 +342,8 @@ theme_dynamic = modifyList(theme_basic, list( adj.main = 0, adj.sub = 0, dynmar = TRUE, - gap.axis = 0.2, # fixed gap (lines) between tick tip and tick label center - gap.lab = 1.0, # gap from tick-label reference to title near cell edge (lines) + gap.axis = 0.2, # gap (lines) between tick tip and tick label cell edge + gap.lab = 1.0, # gap (lines) from tick label cell edge to title cell edge grid = FALSE, las = 1, mar = c(0.1, 0.1, 0.6, 0.6), diff --git a/R/utils.R b/R/utils.R index a889eb50..1f6c98b9 100644 --- a/R/utils.R +++ b/R/utils.R @@ -46,7 +46,7 @@ dynmar_side = function(side, label, main = NULL, sub = NULL, if (side == 1L) c("cex.xlab", "cex.lab") else c("cex.ylab", "cex.lab"), tpar_list = tpars, default = 1 ) - descent = if (side == 2L) 0.5 * cex_lab + 0.6 else 0.4 * cex_lab + 0.6 + descent = if (side == 2L) 0.1 * cex_lab + 1.0 else 0.2 * cex_lab + 0.8 label_extent = mgp[1] + (lines - 1) * cex_lab + descent # Expressions (e.g., ylab = expression(mm^{1/2})) can be taller than a # plain text line due to superscripts, subscripts, fractions, etc. Measure From dbcb0e64a6fd42f8e0f90ce352600a9e526b1c2a Mon Sep 17 00:00:00 2001 From: Grant McDermott Date: Mon, 18 May 2026 09:10:08 -0700 Subject: [PATCH 05/14] better logic for stable cex.axis --- R/utils.R | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/R/utils.R b/R/utils.R index 1f6c98b9..c89a7aa0 100644 --- a/R/utils.R +++ b/R/utils.R @@ -46,7 +46,11 @@ dynmar_side = function(side, label, main = NULL, sub = NULL, if (side == 1L) c("cex.xlab", "cex.lab") else c("cex.ylab", "cex.lab"), tpar_list = tpars, default = 1 ) - descent = if (side == 2L) 0.1 * cex_lab + 1.0 else 0.2 * cex_lab + 0.8 + # Side 2: rotated ylab baseline is shifted by ylab_cex_shift, so far edge + # = mgp[1] + 0.1*cex_lab + 0.5; descent = 0.1*cex + 0.9 gives constant + # 0.4-line buffer (and exactly 1.0 at cex=1, preserving existing behavior). + # Side 1: horizontal xlab ink extends ~0.2*cex below center; +0.8 buffer. + descent = if (side == 2L) 0.1 * cex_lab + 0.9 else 0.2 * cex_lab + 0.8 label_extent = mgp[1] + (lines - 1) * cex_lab + descent # Expressions (e.g., ylab = expression(mm^{1/2})) can be taller than a # plain text line due to superscripts, subscripts, fractions, etc. Measure From 70d71d7b93541d815f36b7a6a21802e1790f313d Mon Sep 17 00:00:00 2001 From: Grant McDermott Date: Mon, 18 May 2026 09:10:20 -0700 Subject: [PATCH 06/14] tests --- .../_tinysnapshot/margins_large_cex.svg | 88 +++++++ .../margins_large_cex_facets.svg | 221 ++++++++++++++++++ inst/tinytest/test-margins.R | 61 ++++- 3 files changed, 369 insertions(+), 1 deletion(-) create mode 100644 inst/tinytest/_tinysnapshot/margins_large_cex.svg create mode 100644 inst/tinytest/_tinysnapshot/margins_large_cex_facets.svg diff --git a/inst/tinytest/_tinysnapshot/margins_large_cex.svg b/inst/tinytest/_tinysnapshot/margins_large_cex.svg new file mode 100644 index 00000000..34ccfe77 --- /dev/null +++ b/inst/tinytest/_tinysnapshot/margins_large_cex.svg @@ -0,0 +1,88 @@ + + + + + + + + + + + + + +cex.axis=3, cex.lab=2 +X label +Y label + + + + + + +2 +4 +6 +8 +10 + + + + + + + +1000 +1002 +1004 +1006 +1008 +1010 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/inst/tinytest/_tinysnapshot/margins_large_cex_facets.svg b/inst/tinytest/_tinysnapshot/margins_large_cex_facets.svg new file mode 100644 index 00000000..d251a95a --- /dev/null +++ b/inst/tinytest/_tinysnapshot/margins_large_cex_facets.svg @@ -0,0 +1,221 @@ + + + + + + + + + + + + + + + + +cyl +4 +6 +8 + + + + + + + +Weight +Miles per gallon + + + + + + + + + + + + + + +2 +3 +4 +5 + + + + + + +10 +15 +20 +25 +30 + +4 + + + + + + + + + + + + + + + + + + + + + + + + + + +2 +3 +4 +5 + + + + + + +10 +15 +20 +25 +30 + +6 + + + + + + + + + + + + + + + + + + + + + + + + + + +2 +3 +4 +5 + + + + + + +10 +15 +20 +25 +30 + +8 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/inst/tinytest/test-margins.R b/inst/tinytest/test-margins.R index 04d26c0d..cffde2ef 100644 --- a/inst/tinytest/test-margins.R +++ b/inst/tinytest/test-margins.R @@ -117,4 +117,63 @@ expect_true( # ylab = expression(Precipitation~"["~mm^{1/2}~"]"), # theme = "clean") # } -# expect_snapshot_plot(f, label = "margins_math_expression") \ No newline at end of file +# expect_snapshot_plot(f, label = "margins_math_expression") + +# Dynamic margin and mgp scaling with large cex values (#574) + +# Helper: capture mar and mgp from inside the plot +get_plot_pars = function(...) { + tinyplot(...) + list(mar = par("mar"), mgp = par("mgp")) +} + +# At default cex=1, mgp is unchanged from the theme value +expect_equal( + { + tinytheme("clean") + p = get_plot_pars(1:10, 1:10, xlab = "X", ylab = "Y") + tinytheme() + p$mgp + }, + c(2.2, 0.7, 0), + info = "dynmar_mgp_unchanged_at_cex_1" +) + +# cex.axis=3, cex.lab=2: mgp scales to accommodate larger text +expect_equal( + { + tinytheme("clean", cex.axis = 3, cex.lab = 2) + p = get_plot_pars(1:10, 1:10, xlab = "X", ylab = "Y") + tinytheme() + p$mgp + }, + c(3.7, 1.7, 0), + info = "dynmar_mgp_at_cex_axis_3_cex_lab_2" +) + +# cex.axis=0.5, cex.lab=0.5: mgp shrinks for small text +expect_equal( + { + tinytheme("clean", cex.axis = 0.5, cex.lab = 0.5) + p = get_plot_pars(1:10, 1:10, xlab = "X", ylab = "Y") + tinytheme() + p$mgp + }, + c(1.7, 0.45, 0), + info = "dynmar_mgp_shrinks_at_small_cex" +) + +# Snapshot tests for scaled-cex margins +f = function() { + tinytheme("clean", cex.axis = 3, cex.lab = 2) + tinyplot(1000:1010, xlab = "X label", ylab = "Y label", main = "cex.axis=3, cex.lab=2") + tinytheme() +} +expect_snapshot_plot(f, label = "margins_large_cex") + +f = function() { + tinytheme("clean", cex.axis = 2, cex.lab = 1.5) + tinyplot(mpg ~ wt | cyl, facet = "by", data = mtcars, xlab = "Weight", ylab = "Miles per gallon") + tinytheme() +} +expect_snapshot_plot(f, label = "margins_large_cex_facets") \ No newline at end of file From e208e5174c05acc16732d83eeacb6938fae6b67c Mon Sep 17 00:00:00 2001 From: Grant McDermott Date: Mon, 18 May 2026 09:43:22 -0700 Subject: [PATCH 07/14] more tests --- .../_tinysnapshot/margins_cex_axis1_lab3.svg | 91 +++++++++++++++++++ .../_tinysnapshot/margins_cex_axis3_lab1.svg | 91 +++++++++++++++++++ .../_tinysnapshot/margins_cex_axis3_lab3.svg | 91 +++++++++++++++++++ inst/tinytest/test-margins.R | 30 +++++- 4 files changed, 302 insertions(+), 1 deletion(-) create mode 100644 inst/tinytest/_tinysnapshot/margins_cex_axis1_lab3.svg create mode 100644 inst/tinytest/_tinysnapshot/margins_cex_axis3_lab1.svg create mode 100644 inst/tinytest/_tinysnapshot/margins_cex_axis3_lab3.svg diff --git a/inst/tinytest/_tinysnapshot/margins_cex_axis1_lab3.svg b/inst/tinytest/_tinysnapshot/margins_cex_axis1_lab3.svg new file mode 100644 index 00000000..9fb77ea8 --- /dev/null +++ b/inst/tinytest/_tinysnapshot/margins_cex_axis1_lab3.svg @@ -0,0 +1,91 @@ + + + + + + + + + + + + + +cex.axis = 1, cex.lab = 3 +X title (JjQqYy) +Y title (JjQqYy) + + + + + + +2 +4 +6 +8 +10 + + + + + + + +1000 +1002 +1004 +1006 +1008 +1010 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/inst/tinytest/_tinysnapshot/margins_cex_axis3_lab1.svg b/inst/tinytest/_tinysnapshot/margins_cex_axis3_lab1.svg new file mode 100644 index 00000000..2c73460f --- /dev/null +++ b/inst/tinytest/_tinysnapshot/margins_cex_axis3_lab1.svg @@ -0,0 +1,91 @@ + + + + + + + + + + + + + +cex.axis = 3, cex.lab = 1 +X title (JjQqYy) +Y title (JjQqYy) + + + + + + +2 +4 +6 +8 +10 + + + + + + + +1000 +1002 +1004 +1006 +1008 +1010 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/inst/tinytest/_tinysnapshot/margins_cex_axis3_lab3.svg b/inst/tinytest/_tinysnapshot/margins_cex_axis3_lab3.svg new file mode 100644 index 00000000..4d4ebcbd --- /dev/null +++ b/inst/tinytest/_tinysnapshot/margins_cex_axis3_lab3.svg @@ -0,0 +1,91 @@ + + + + + + + + + + + + + +cex.axis = 3, cex.lab = 3 +X title (JjQqYy) +Y title (JjQqYy) + + + + + + +2 +4 +6 +8 +10 + + + + + + + +1000 +1002 +1004 +1006 +1008 +1010 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/inst/tinytest/test-margins.R b/inst/tinytest/test-margins.R index cffde2ef..efddc6e5 100644 --- a/inst/tinytest/test-margins.R +++ b/inst/tinytest/test-margins.R @@ -176,4 +176,32 @@ f = function() { tinyplot(mpg ~ wt | cyl, facet = "by", data = mtcars, xlab = "Weight", ylab = "Miles per gallon") tinytheme() } -expect_snapshot_plot(f, label = "margins_large_cex_facets") \ No newline at end of file +expect_snapshot_plot(f, label = "margins_large_cex_facets") + +# Varying cex.axis vs cex.lab independently to check gap constancy +f = function() { + tinytheme("clean", cex.axis = 3, cex.lab = 1) + tinyplot(1000:1010, xlab = "X title (JjQqYy)", ylab = "Y title (JjQqYy)", + main = "cex.axis = 3, cex.lab = 1") + box("inner", lty = 2) + tinytheme() +} +expect_snapshot_plot(f, label = "margins_cex_axis3_lab1") + +f = function() { + tinytheme("clean", cex.axis = 1, cex.lab = 3) + tinyplot(1000:1010, xlab = "X title (JjQqYy)", ylab = "Y title (JjQqYy)", + main = "cex.axis = 1, cex.lab = 3") + box("inner", lty = 2) + tinytheme() +} +expect_snapshot_plot(f, label = "margins_cex_axis1_lab3") + +f = function() { + tinytheme("clean", cex.axis = 3, cex.lab = 3) + tinyplot(1000:1010, xlab = "X title (JjQqYy)", ylab = "Y title (JjQqYy)", + main = "cex.axis = 3, cex.lab = 3") + box("inner", lty = 2) + tinytheme() +} +expect_snapshot_plot(f, label = "margins_cex_axis3_lab3") \ No newline at end of file From 9fece544496c020e3835b2cefd1fc233fc70b455 Mon Sep 17 00:00:00 2001 From: Grant McDermott Date: Mon, 18 May 2026 10:59:27 -0700 Subject: [PATCH 08/14] news --- NEWS.md | 26 +++++++++++++++++++------- 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/NEWS.md b/NEWS.md index 57a1dedd..fe40ac45 100644 --- a/NEWS.md +++ b/NEWS.md @@ -26,7 +26,7 @@ visualizations. logic. Recall, these are themes like `"dynamic"`, `"clean"`, `"bw"`, etc. that automatically adjust margin spacing and related plot elements to reduce whitespace and improve the overall plot aesthetic. - (#549 @grantmcdermott, @vincentarelbundock) + (#549, #591 @grantmcdermott, @vincentarelbundock) - Plot margins now correctly respond to missing and/or multi-line `main`, `sub`, and `x`/`y` axis titles. For example, a plot without a `main` (or @@ -40,21 +40,33 @@ visualizations. more general `cex.lab` is still respected as a fallback. (#574) - Margin spacing now correctly adjusts for math expressions, including fractions and exponents in titles. (#575) + - Dynamic margins and `mgp` now scale correctly with `cex.axis` and + `cex.lab`, maintaining constant visual gaps between axis elements + regardless of text size. From the user perspective, this is + operationalized through the new `gap.axis` and `gap.lab` theme + primitives, which let you control the spacing between margin elements + directly (tick-to-label gap and label-to-title gap, respectively), + replacing the guesswork of manually combining `mar`, `mgp`, and `tcl` + values. (#590) ### New features +- New `ljust` parameter for controlling legend title and label justification. + Accepts values of `"l(eft)"` (default) or `"c(enter")`. Can be set per-plot + via `legend = list(..., ljust = "c")`, or globally via `tpar(ljust = "c")`. + (#500 @grantmcdermott) +- New `"dynamic"` theme that now serves as the foundation for all other dynamic + (tiny)themes. (#549 @grantmcdermott) - The `grid` argument (and `tpar("grid")`) now accepts character strings to control axis-specific grids at different resolutions. Uppercase letters (`"X"`, `"Y"`, `"XY"`) draw grid lines at the standard tick positions, while lowercase letters (`"x"`, `"y"`, `"xy"`) draw a finer grid with additional lines at the midpoints between ticks. Thanks to @zeileis for the suggestion. (#578 @grantmcdermott) -- New `ljust` parameter for controlling legend title and label justification. - Accepts values of `"l(eft)"` (default) or `"c(enter")`. Can be set per-plot - via `legend = list(..., ljust = "c")`, or globally via `tpar(ljust = "c")`. - (#500 @grantmcdermott) -- New `"dynamic"` theme that now serves as the foundation for all other dynamic - (tiny)themes. (#549 @grantmcdermott) +- `tinytheme()` now accepts additional `gap.axis` and `gap.lab` "primitives", + providing finer control for spacing between ticks-labels and labels-titles, + respectively, in dynamic themes. See the **Dynamic themes** entry above. + (#590 @grantmcdermott) ### Bug fixes From 3aa262dcc94eb6d48f3df323318891cc44d5ac11 Mon Sep 17 00:00:00 2001 From: Grant McDermott Date: Mon, 18 May 2026 11:41:08 -0700 Subject: [PATCH 09/14] fix cex.lab and cex.axis reset --- R/tinytheme.R | 30 +++++++++++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/R/tinytheme.R b/R/tinytheme.R index 4f2057c7..f3df7420 100644 --- a/R/tinytheme.R +++ b/R/tinytheme.R @@ -73,8 +73,32 @@ #' #' ``` #' +#' **Spacing primitives.** Dynamic themes compute `mgp` (margin line positions) +#' automatically from two spacing primitives, rather than requiring users to +#' reason about how `mar`, `mgp`, and `tcl` combine: +#' +#' - `gap.axis`: the gap in margin lines between the tick tip and the near edge +#' of the tick label. Default `0.2`. +#' - `gap.lab`: the gap in margin lines between the far edge of the tick label +#' and the near edge of the axis title. Default `1.0`. +#' +#' These primitives scale automatically with `cex.axis` and `cex.lab`, so the +#' visible spacing between elements remains constant regardless of text size. +#' To adjust spacing, pass them as overrides: +#' +#' ``` +#' # Tighter spacing between tick labels and axis titles +#' tinytheme("clean", gap.lab = 0.5) +#' +#' # More room between ticks and tick labels +#' tinytheme("clean", gap.axis = 0.5) +#' ``` +#' +#' If you supply an explicit `mgp` value, it is used as-is and the primitives +#' are ignored. +#' #' **Caveats.** Known `tinytheme` limitations include: -#' +#' #' - Themes do not work well when `legend = "top!"`. #' #' @return The function returns nothing. It is called for its side effects. @@ -243,9 +267,12 @@ theme_default = list( bty = par("bty"), #"o", cex = par("cex"), #1, cex.axis = par("cex.axis"), #1, + cex.lab = par("cex.lab"), #1, cex.main = par("cex.main"), #1.2, + cex.sub = par("cex.sub"), #1, cex.xlab = NULL, # defer to par("cex.lab") unless set explicitly cex.ylab = NULL, # defer to par("cex.lab") unless set explicitly + col = par("col"), #"black", col.axis = par("col.axis"), #1, col.xaxs = par("col.axis"), #1, col.yaxs = par("col.axis"), #1, @@ -278,6 +305,7 @@ theme_default = list( pch = par("pch"), # 1, side.sub = 1, tck = NA, + tcl = par("tcl"), # -0.5 xaxt = "standard", yaxt = "standard" ) From 305851509e8ab84b8b1ce45de9766f9d9d41bb69 Mon Sep 17 00:00:00 2001 From: Grant McDermott Date: Mon, 18 May 2026 13:58:04 -0700 Subject: [PATCH 10/14] docs --- R/tinytheme.R | 13 +++- altdoc/pkgdown.yml | 2 +- man/tinytheme.Rd | 24 ++++++ vignettes/themes.qmd | 170 ++++++++++++++++--------------------------- 4 files changed, 95 insertions(+), 114 deletions(-) diff --git a/R/tinytheme.R b/R/tinytheme.R index f3df7420..4ec7b3e3 100644 --- a/R/tinytheme.R +++ b/R/tinytheme.R @@ -115,21 +115,26 @@ #' p() #' #' # Set a theme -#' tinytheme("bw") +#' tinytheme("dark") #' p() #' #' # A set theme is persistent and will apply to subsequent plots #' tinyplot(0:10) #' #' # Try a different theme -#' tinytheme("dark") +#' tinytheme("clean") #' p() #' #' # Customize the theme by overriding default settings -#' tinytheme("bw", fg = "green", font.main = 2, font.sub = 3, family = "Palatino") +#' tinytheme("clean", +#' adj.xlab = 1, adj.ylab = 1, +#' cex.lab = 0.75, cex.axis = 0.9, +#' font.sub = 3, +#' gap.axis = 0, gap.lab = 0.5, +#' tcl = -0.1) #' p() #' -#' # Another custom theme example +#' # Another custom theme example, including a different font #' tinytheme("bw", font.main = 2, col.axis = "darkcyan", family = "HersheyScript") #' p() #' diff --git a/altdoc/pkgdown.yml b/altdoc/pkgdown.yml index 0354eb90..8c503527 100644 --- a/altdoc/pkgdown.yml +++ b/altdoc/pkgdown.yml @@ -2,7 +2,7 @@ altdoc: 0.7.2 pandoc: 3.9.0.2 pkgdown: 2.1.3 pkgdown_sha: ~ -last_built: 2026-03-26T21:58:46+0000 +last_built: 2026-05-18T20:00:46+0000 urls: reference: https://grantmcdermott.com/tinyplot/man article: https://grantmcdermott.com/tinyplot/vignettes diff --git a/man/tinytheme.Rd b/man/tinytheme.Rd index 56686c67..8156312e 100644 --- a/man/tinytheme.Rd +++ b/man/tinytheme.Rd @@ -86,6 +86,30 @@ tpar(mar = c(5, 5, 2, 2)) }\if{html}{\out{}} +\strong{Spacing primitives.} Dynamic themes compute \code{mgp} (margin line positions) +automatically from two spacing primitives, rather than requiring users to +reason about how \code{mar}, \code{mgp}, and \code{tcl} combine: +\itemize{ +\item \code{gap.axis}: the gap in margin lines between the tick tip and the near edge +of the tick label. Default \code{0.2}. +\item \code{gap.lab}: the gap in margin lines between the far edge of the tick label +and the near edge of the axis title. Default \code{1.0}. +} + +These primitives scale automatically with \code{cex.axis} and \code{cex.lab}, so the +visible spacing between elements remains constant regardless of text size. +To adjust spacing, pass them as overrides: + +\if{html}{\out{
}}\preformatted{# Tighter spacing between tick labels and axis titles +tinytheme("clean", gap.lab = 0.5) + +# More room between ticks and tick labels +tinytheme("clean", gap.axis = 0.5) +}\if{html}{\out{
}} + +If you supply an explicit \code{mgp} value, it is used as-is and the primitives +are ignored. + \strong{Caveats.} Known \code{tinytheme} limitations include: \itemize{ \item Themes do not work well when \code{legend = "top!"}. diff --git a/vignettes/themes.qmd b/vignettes/themes.qmd index f26e7824..e362c358 100644 --- a/vignettes/themes.qmd +++ b/vignettes/themes.qmd @@ -83,12 +83,12 @@ tinytheme() tinyplot(mpg ~ hp, data = mtcars, main = "Fuel efficiency vs. horsepower") ``` -### Aside: ephemeral themes +### Persistent versus ephemeral themes -We expect that users will generally want to set a persistent theme for all -(most) of their plots. But it is also possible to set a ephemeral theme for a -particular plot by calling the `tinyplot(..., theme = )` argument -directly. +Setting a persistent theme for all of your plots is often convenient. But there +are also times where you might prefer an ephemeral theme that only applies to a +single plot (plus any added layers). You can invoke such an ephemeral theme by +calling the `tinyplot(..., theme = )` argument directly. ```{r} #| layout-ncol: 2 @@ -96,7 +96,11 @@ tinyplot(mpg ~ hp, data = mtcars, main = "Ephemeral theme", theme = "clean") tinyplot(mpg ~ hp, data = mtcars, main = "Back to the default") ``` - +One of the advantages of ephemeral themes is that they won't interfere with any +other base graphics calls (e.g, `plot`, `coplot`, etc.) In contrast, the +persistent hook mechanism of `tinytheme()` will also intercept these other base +plot calls, which may lead to undesirable outcomes unless you reset to the +default behaviour. ### Gallery @@ -104,14 +108,16 @@ We'll use the following running example to demonstrate the full gallery of built-in `tinytheme()` themes. ```{r} -p = function() { +p = function(theme = "default") { tinyplot( Sepal.Width ~ Sepal.Length | Species, facet = "by", data = iris, - main = "Title of the plot", - sub = "A smaller subtitle" + main = paste0('theme = "', theme, '"'), + sub = "subtitle", + theme = theme ) + box("outer", lty = 2) } ``` @@ -134,115 +140,42 @@ p = function() { -#### "default" - -```{r} -tinytheme() ## same as tinytheme("default") -p() -``` - -#### "basic" - -```{r} -tinytheme("basic") -p() -``` - -#### "clean" - -```{r} -tinytheme("clean") -p() -``` - -#### "clean2" - -```{r} -tinytheme("clean2") -p() -``` - -#### "classic" - -```{r} -tinytheme("classic") -p() -``` - -#### "bw" - -```{r} -tinytheme("bw") -p() -``` - -#### "minimal" - -```{r} -tinytheme("minimal") -p() -``` - -#### "ipsum" - -```{r} -tinytheme("ipsum") -p() -``` - -#### "dark" ```{r} -tinytheme("dark") -p() +p() ## same as "default" +p("basic") +p("dynamic") +p("clean") +p("clean2") +p("classic") +p("bw") +p("minimal") +p("ipsum") +p("dark") +p("tufte") +p("void") ``` -#### "tufte" - -```{r} -tinytheme("tufte") -p() -``` - -#### "void" - -```{r} -tinytheme("void") -p() -``` - -#### "ridge" - ::: {.callout-note} The specialized `"ridge"` and `"ridge2"` themes are only intended for use with ridge plot types. ::: ```{r} -p2 = function() { +p2 = function(theme = "ridge") { tinyplot( Species ~ Sepal.Width | Species, legend = FALSE, data = iris, type = "ridge", - main = "Title of the plot", - sub = "A smaller subtitle" + main = paste0('theme = "', theme, '"'), + sub = "subtitle", + theme = theme ) + box("outer", lty = 2) } -tinytheme("ridge") -p2() -``` - -#### "ridge2" - -```{r} -tinytheme("ridge2") -p2() -``` - -```{r} -# Reset to default theme -tinytheme() +p2("ridge") +p2("ridge2") ``` Please feel free to make suggestions about themes, or contribute new themes by @@ -291,19 +224,38 @@ It plays very nicely with **tinyplot**. ::: -Similarly, to create your own themes "from scratch", set the theme to -`"default"` and pass additional graphical parameters to `tinytheme()`. +#### Spacing primitives -```{r} -tinytheme("default", font.main = 3, col = "red") -p() -``` +One feature of the `tinytheme()` infrastructure that is especially relevant +to customized themes is how dynamic spacing works. Dynamic themes in +**tinyplot** automatically compute margin positions (`mar`, `mgp`) so that axis +and text elements are well-spaced. Moreover, rather than requiring you to +manually reason about how `mar`, `mgp`, and `tcl` combine to produce certain +spacing, which---trust us---is both confusing and error prone, you can control +the gaps between elements directly through two spacing "primitives": + +- `gap.axis`: the gap (in margin lines) between the tick tip and the near edge + of the tick label. Default `0.2`. +- `gap.lab`: the gap (in margin lines) between the far edge of the tick label + and the near edge of the axis title. Default `1.0`. + +These scale automatically with companion features like `cex.axis` and `cex.lab`, +maintaining constant visible spacing regardless of text size. For example: ```{r} -# Reset to default theme -tinytheme() +#| layout-ncol: 2 +tinytheme("dynamic", gap.axis = 0, gap.lab = 0.5) +tinyplot(mpg ~ hp, data = mtcars, main = "Tighter gaps") + +tinytheme("dynamic", gap.axis = 2, gap.lab = 2) +tinyplot(mpg ~ hp, data = mtcars, main = "Looser gaps") +tinytheme() # reset ``` +Again, this is much more convenient that fiddling with `mgp` values. But you can +always provide `mgp` values if you wish; in which it will take precedence and +the primitives are ignored. + ::: {.callout-tip} To see the full list of parameters that defines a particular theme, simply assign them to an object. This can be helpful if you want to explore creating From f9bd8ea934a88dc035bfb4309c0b0009c1057848 Mon Sep 17 00:00:00 2001 From: Grant McDermott Date: Mon, 18 May 2026 14:35:48 -0700 Subject: [PATCH 11/14] vignette tweaks --- altdoc/pkgdown.yml | 2 +- vignettes/themes.qmd | 19 ++++++++++--------- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/altdoc/pkgdown.yml b/altdoc/pkgdown.yml index 8c503527..0758a82b 100644 --- a/altdoc/pkgdown.yml +++ b/altdoc/pkgdown.yml @@ -2,7 +2,7 @@ altdoc: 0.7.2 pandoc: 3.9.0.2 pkgdown: 2.1.3 pkgdown_sha: ~ -last_built: 2026-05-18T20:00:46+0000 +last_built: 2026-05-18T20:58:22+0000 urls: reference: https://grantmcdermott.com/tinyplot/man article: https://grantmcdermott.com/tinyplot/vignettes diff --git a/vignettes/themes.qmd b/vignettes/themes.qmd index e362c358..a3bdb563 100644 --- a/vignettes/themes.qmd +++ b/vignettes/themes.qmd @@ -21,12 +21,14 @@ knitr::opts_chunk$set( ``` The base R aesthetic tends to divide option. Some people like the -default minimalist look of base R plots and/or are happy to customize the (many) +default minimalist look of base R plots and are happy to customize the (many) graphical parameters that are available to them. Others find base plots ugly -and don't want to spend time endlessly tweaking different parameters. Moreover, -the inherent "canvas" approach to drawing base R graphics, with fixed placement -for plot elements, means that plots don't adjust dynamically and this can lead -to awkward whitespace artifacts unless the user explicitly accounts for them. +and don't want to spend time endlessly tweaking different parameters. Meanwhile, +the "canvas" drawing system of base R graphics creates its own set of issues. +Each plot element is drawn according to a fixed placement logic, which means +that the overall composition can't adjust dynamically according to the elements +that are present or not (including titles). This can lead to awkward whitespace +artifacts unless the user explicitly accounts for them. Regardless of where you stand in this debate, the **tinyplot** view is that base R graphics should ideally combine flexibility and ease of use with aesthetically @@ -56,7 +58,8 @@ tinyplot( One particular feature that may interest users is the fact that `tinytheme()` uses some internal ~~magic~~ logic to dynamically adjust plot margins to avoid -whitespace. For example, when long horizontal y-axis labels are detected: +whitespace and overlapping elements. For example, notice how the plot region +here bumps out to accomodate the long horizontal y-axis labels: ```{r} tinyplot( @@ -105,7 +108,7 @@ default behaviour. ### Gallery We'll use the following running example to demonstrate the full gallery of -built-in `tinytheme()` themes. +built-in **tinyplot** themes. ```{r} p = function(theme = "default") { @@ -224,8 +227,6 @@ It plays very nicely with **tinyplot**. ::: -#### Spacing primitives - One feature of the `tinytheme()` infrastructure that is especially relevant to customized themes is how dynamic spacing works. Dynamic themes in **tinyplot** automatically compute margin positions (`mar`, `mgp`) so that axis From f5fe86ad4fedeb063fc592ac66a0d92212e27640 Mon Sep 17 00:00:00 2001 From: Grant McDermott Date: Mon, 18 May 2026 16:44:08 -0700 Subject: [PATCH 12/14] ignore claude logs --- .Rbuildignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.Rbuildignore b/.Rbuildignore index 2c8f8065..b0f1774f 100644 --- a/.Rbuildignore +++ b/.Rbuildignore @@ -27,4 +27,5 @@ Makefile ^.devcontainer Rplots.pdf ^CLAUDE\.md$ +^.claude/ ^revdep$ From 79726f11da723581fd189f0e9e9f381c88292c47 Mon Sep 17 00:00:00 2001 From: Grant McDermott Date: Mon, 18 May 2026 16:49:01 -0700 Subject: [PATCH 13/14] ignore --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 675bfaa6..f720a349 100644 --- a/.gitignore +++ b/.gitignore @@ -44,6 +44,9 @@ README_files/ inst/tinytest/_tinysnapshot_OLD/ inst/tinytest/_tinysnapshot_review/ +# Claude Code +.claude/ + # MacOS cruft .DS_Store From 0d75d7916068cc36f80430160e98371ee6b28878 Mon Sep 17 00:00:00 2001 From: Grant McDermott Date: Mon, 18 May 2026 16:59:15 -0700 Subject: [PATCH 14/14] forgot to update snapshot --- .../_tinysnapshot/tinytheme_ephemeral.svg | 30 +++++++++---------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/inst/tinytest/_tinysnapshot/tinytheme_ephemeral.svg b/inst/tinytest/_tinysnapshot/tinytheme_ephemeral.svg index 9b3a4897..7bbcf082 100644 --- a/inst/tinytest/_tinysnapshot/tinytheme_ephemeral.svg +++ b/inst/tinytest/_tinysnapshot/tinytheme_ephemeral.svg @@ -269,13 +269,13 @@ - - - - - - - + + + + + + + 1 2 3 @@ -284,14 +284,14 @@ 6 7 - - - - - - - - + + + + + + + + 4.5 5.0 5.5