Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .Rbuildignore
Original file line number Diff line number Diff line change
Expand Up @@ -27,4 +27,5 @@ Makefile
^.devcontainer
Rplots.pdf
^CLAUDE\.md$
^.claude/
^revdep$
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,9 @@ README_files/
inst/tinytest/_tinysnapshot_OLD/
inst/tinytest/_tinysnapshot_review/

# Claude Code
.claude/

# MacOS cruft
.DS_Store

Expand Down
26 changes: 19 additions & 7 deletions NEWS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down
24 changes: 16 additions & 8 deletions R/facet.R
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
}
Expand All @@ -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
}
Expand Down Expand Up @@ -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)
)
Expand Down Expand Up @@ -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))
}
}

Expand Down
2 changes: 1 addition & 1 deletion R/legend.R
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Expand Down
18 changes: 13 additions & 5 deletions R/tinyplot.R
Original file line number Diff line number Diff line change
Expand Up @@ -997,6 +997,12 @@ 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"]]
.outer_sides = c(
Expand All @@ -1017,6 +1023,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
Expand All @@ -1035,7 +1042,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) {
Expand All @@ -1049,15 +1057,15 @@ 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
}
if (.las %in% 2:3) {
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
}

Expand Down Expand Up @@ -1132,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] else 0)
ylab_line_offset = if (!is.null(dynmar_computed)) .whtsbp[2] - .ymgp_shift - .ylab_cex_shift else 0)
}


Expand Down Expand Up @@ -1412,7 +1420,7 @@ tinyplot.default = function(
apar = par(no.readonly = TRUE)
set_saved_par(when = "after", apar)
},
list = list(),
list = list(),
env = getNamespace('tinyplot')
)
}
Expand Down
73 changes: 67 additions & 6 deletions R/tinytheme.R
Original file line number Diff line number Diff line change
Expand Up @@ -73,8 +73,32 @@
#' <some plot>
#' ```
#'
#' **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.
Expand All @@ -91,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()
#'
Expand Down Expand Up @@ -190,6 +219,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. 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
.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
Expand Down Expand Up @@ -221,9 +272,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,
Expand Down Expand Up @@ -256,6 +310,7 @@ theme_default = list(
pch = par("pch"), # 1,
side.sub = 1,
tck = NA,
tcl = par("tcl"), # -0.5
xaxt = "standard",
yaxt = "standard"
)
Expand Down Expand Up @@ -309,17 +364,23 @@ 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.
##
tinytheme = "dynamic",
adj.main = 0,
adj.sub = 0,
dynmar = TRUE,
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),
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
))
Expand Down
2 changes: 2 additions & 0 deletions R/tpar.R
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,8 @@ known_tpar = c(
"col.yaxs",
"cairo",
"dynmar",
"gap.axis",
"gap.lab",
"facet.bg",
"facet.border",
"facet.cex",
Expand Down
Loading
Loading