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
2 changes: 2 additions & 0 deletions NAMESPACE
Original file line number Diff line number Diff line change
Expand Up @@ -93,9 +93,11 @@ importFrom(graphics,rasterImage)
importFrom(graphics,rect)
importFrom(graphics,rug)
importFrom(graphics,segments)
importFrom(graphics,strheight)
importFrom(graphics,strwidth)
importFrom(graphics,text)
importFrom(graphics,title)
importFrom(graphics,xinch)
importFrom(stats,aggregate)
importFrom(stats,approx)
importFrom(stats,as.formula)
Expand Down
25 changes: 20 additions & 5 deletions NEWS.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,25 @@ where the formatting is also better._

### Aesthetic changes

The main focus of v0.7.0 is bringing various aesthetic improvements to
**tinyplot**. These aesthetic improvements should carry over to all of your
(tiny)plots automatically and do not require any changes to user-facing inputs
or the core API. Some of your plots may look slightly different from before.
But we hope that you agree the following changes result in better looking
visualizations.

- **Left-justified legends**. Legend titles and labels are now left-justified by
default for vertical, side-positioned legends (e.g., `"right!"`, `"left!"`).
This is a change from the previous (base R default) of centered legends. To
revert to the old behaviour globally, simply set `tpar(ljust = "c(enter)")`.
Or, change a single plot by passing the parameter as part of the legend list
arguments, e.g. `plt(..., legend = list("c"))`. (#500 @grantmcdermott)

- **Dynamic themes**. We have significantly refactored our _dynamic_ themes
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. This internal refactoring
has some user-facing implications, insofar as it can affect the appearance of
your plots. Technically this makes it a "breaking" aesthetic changes, since
some of your plots might look slightly different from before. But we feel that
these are clear improvements. (#549 @grantmcdermott, @vincentarelbundock)
whitespace and improve the overall plot aesthetic.
(#549 @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 @@ -38,6 +49,10 @@ where the formatting is also better._
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)

Expand Down
96 changes: 88 additions & 8 deletions R/legend.R
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,24 @@ measure_legend_inset = function(legend_env) {
}


compute_ljust_text_width = function(legend_env) {
if (!isTRUE(legend_env$ljust)) return(invisible(NULL))

args_oh = modifyList(legend_env$args,
list(plot = FALSE, title = NULL, text.width = 0), keep.null = TRUE)
overhead = do.call("legend", args_oh)$rect$w

args_nat = modifyList(legend_env$args,
list(plot = FALSE, text.width = NULL), keep.null = TRUE)
target_w = do.call("legend", args_nat)$rect$w

tw_needed = target_w - overhead
if (tw_needed > 0) {
legend_env$args[["text.width"]] = tw_needed
}
}


# Internal workhorse function for legend rendering
# This function is called inside recordGraphics() so that all coordinate-dependent
# calculations are re-executed when the plot window is resized
Expand All @@ -216,18 +234,29 @@ tinylegend = function(legend_env) {
legend_env$ooma = legend_env$ooma_base
legend_env$args[["inset"]] = legend_env$inset_base

# Clear stale text.width before measuring (measurement doesn't need it)
if (isTRUE(legend_env$ljust)) {
legend_env$args[["text.width"]] = NULL
}

# Re-measure legend dimensions (device size may have changed on resize)
legend_env$dims = measure_fake_legend(legend_env)

# Calculate and apply soma (outer margin adjustment based on legend size)
soma = if (legend_env$outer_side) {
grconvertX(legend_env$dims$rect$w, to = "lines") - grconvertX(0, to = "lines")
} else if (legend_env$outer_end) {
grconvertY(legend_env$dims$rect$h, to = "lines") - grconvertY(0, to = "lines")
# When soma_target is set (multi-legend), use it directly so all legends
# share the same outer margin and their left edges align.
if (!is.null(legend_env$soma_target)) {
soma = legend_env$soma_target
} else {
0
soma = if (legend_env$outer_side) {
grconvertX(legend_env$dims$rect$w, to = "lines") - grconvertX(0, to = "lines")
} else if (legend_env$outer_end) {
grconvertY(legend_env$dims$rect$h, to = "lines") - grconvertY(0, to = "lines")
} else {
0
}
soma = soma + sum(legend_env$lmar)
}
soma = soma + sum(legend_env$lmar)

if (legend_env$outer_side) {
legend_env$ooma[if (legend_env$outer_right) 4 else 2] = soma
Expand All @@ -252,6 +281,11 @@ tinylegend = function(legend_env) {
plot.new()
setHook("before.plot.new", oldhook, action = "replace")

# Recompute text.width in the final coordinate context
if (isTRUE(legend_env$ljust)) {
compute_ljust_text_width(legend_env)
}

# Set the inset in legend args
legend_env$args[["inset"]] = if (legend_env$user_inset) {
legend_env$args[["inset"]] + legend_env$inset
Expand Down Expand Up @@ -757,11 +791,13 @@ build_legend_env = function(
#' @param draw Logical. If `FALSE`, no legend is drawn but the sizes are
#' returned. Note that a new (blank) plot frame will still need to be started
#' in order to perform the calculations.
#' @param soma_target Numeric. Shared outer margin target (in lines) for
#' multi-legend alignment. If `NULL`, each legend computes its own margin.
#'
#' @returns No return value, called for side effect of producing a(n empty) plot
#' with a legend in the margin.
#'
#' @importFrom graphics grconvertX grconvertY rasterImage strwidth
#' @importFrom graphics grconvertX grconvertY rasterImage strheight strwidth xinch
#' @importFrom grDevices as.raster recordGraphics
#' @importFrom utils modifyList
#'
Expand Down Expand Up @@ -840,7 +876,8 @@ draw_legend = function(
lmar = NULL,
has_sub = FALSE,
new_plot = TRUE,
draw = TRUE
draw = TRUE,
soma_target = NULL
) {
if (is.null(lmar)) {
lmar = tpar("lmar")
Expand Down Expand Up @@ -889,13 +926,56 @@ draw_legend = function(
new_plot = new_plot
)

# Extract and strip ljust before any legend() calls
ljust_mode = legend_env$args[["ljust"]] %||% tpar("ljust") %||% "left"
ljust_mode = match.arg(ljust_mode, c("left", "center", "l", "c"))
if (ljust_mode == "l") ljust_mode = "left"
if (ljust_mode == "c") ljust_mode = "center"
legend_env$args[["ljust"]] = NULL

# Initial setup: adjust margins, call plot.new, and measure (but don't apply soma yet)
legend_outer_margins(legend_env, apply = FALSE)

# Legend justification (vertical, non-gradient, side-positioned only)
if (!legend_env$gradient && !isTRUE(legend_env$args[["horiz"]]) && !legend_env$outer_end) {

if (ljust_mode == "left") {
ttl = legend_env$args[["title"]]
# Compute title.adj to give 0.5*xch of left padding. Base R places
# the title at: left + title.adj * (box_w - strwidth(title)), so
# we solve for title.adj = 0.5*xch / slack.
if (is.null(legend_env$args[["title.adj"]]) && !is.null(ttl)) {
xch = par("cex") * xinch(par("cin")[1])
slack = legend_env$dims$rect$w - strwidth(ttl)
legend_env$args[["title.adj"]] = if (slack > 0) {
min(0.5 * xch / slack, 0.5)
} else {
0
}
} else {
legend_env$args[["title.adj"]] = legend_env$args[["title.adj"]] %||% 0
}
if (is.null(legend_env$args[["text.width"]])) {
ttl = legend_env$args[["title"]]
if (!is.null(ttl)) {
lab_tw = max(strwidth(legend_env$args[["legend"]]))
ttl_tw = strwidth(ttl)
if (ttl_tw > lab_tw) {
legend_env$ljust = TRUE
}
}
}
} else {
legend_env$args[["title.adj"]] = legend_env$args[["title.adj"]] %||% 0.5
}
}

if (!draw) {
return(legend_env$dims)
}

legend_env$soma_target = soma_target

# Store base values AFTER legend_outer_margins setup (before soma/inset are applied)
# These are needed so tinylegend() can reset to them on each recordGraphics replay
legend_env$omar_base = legend_env$omar
Expand Down
19 changes: 18 additions & 1 deletion R/legend_multi.R
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,18 @@ draw_multi_legend = function(
# is bigger than half the plot height.
linset = if (any(lheights > 0.5)) lheights[2] / sum(lheights) else 0.5

# Shared soma from widest legend so all legends use the same outer margin
max_w = max(lwidths)
lmar_vals = legend_list[[1]]$lmar %||% tpar("lmar")
soma_target = (grconvertX(max_w, to = "lines") - grconvertX(0, to = "lines")) +
sum(lmar_vals)

# Determine ljust mode (shared across dual legends)
ljust_mode = legend_list[[1]]$legend_args[["ljust"]] %||% tpar("ljust") %||% "left"
ljust_mode = match.arg(ljust_mode, c("left", "center", "l", "c"))
if (ljust_mode == "l") ljust_mode = "left"
if (ljust_mode == "c") ljust_mode = "center"

#
## Step 3: Reposition (via adjusted inset arg) and draw legends
#
Expand All @@ -211,8 +223,13 @@ draw_multi_legend = function(
legend_o = legend_list[[io]]
legend_o$new_plot = FALSE
legend_o$draw = TRUE
legend_o$soma_target = if (ljust_mode == "center") NULL else soma_target
legend_o$legend_args$inset = c(0, 0)
legend_o$legend_args$inset[1] = if (o == 1) -abs(diff(lwidths)) / 2 else 0
legend_o$legend_args$inset[1] = if (ljust_mode == "center") {
if (o == 1) -abs(diff(lwidths)) / 2 else 0
} else {
0
}
legend_o$legend_args$inset[2] = if (legend_o$idx == 1) linset + 0.01 else 1 - linset + 0.01
legend_o$idx = NULL
do.call(draw_legend, legend_o)
Expand Down
1 change: 1 addition & 0 deletions R/tinytheme.R
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,7 @@ theme_default = list(
grid.col = "lightgray",
grid.lty = "dotted",
grid.lwd = 1,
ljust = "left",
lab = par("lab"), # c(5, 5, 7),
las = par("las"), # 0,
lwd = par("lwd"), # 1,
Expand Down
5 changes: 5 additions & 0 deletions R/tpar.R
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,7 @@ known_tpar = c(
"grid.col",
"grid.lty",
"grid.lwd",
"ljust",
"lmar",
"lty.xaxs",
"lty.yaxs",
Expand Down Expand Up @@ -273,6 +274,7 @@ assert_tpar = function(.tpar) {
assert_numeric(.tpar[["adj.ylab"]], len = 1, lower = 0, upper = 1, null.ok = TRUE, name = "adj.ylab")
assert_flag(.tpar[["cairo"]], name = "cairo")
assert_flag(.tpar[["dynmar"]], null.ok = FALSE, name = "dynmar")
assert_choice(.tpar[["ljust"]], choice = c("left", "center", "l", "c"), null.ok = TRUE, name = "ljust")
assert_numeric(.tpar[["lmar"]], len = 2, null.ok = TRUE, name = "lmar")
assert_numeric(.tpar[["ribbon.alpha"]], len = 1, lower = 0, upper = 1, null.ok = TRUE, name = "ribbon.alpha")
assert_numeric(.tpar[["grid.lwd"]], len = 1, lower = 0, null.ok = TRUE, name = "grid.lwd")
Expand Down Expand Up @@ -348,6 +350,9 @@ init_tpar = function(rm_hook = FALSE) {
.tpar$grid.lty = if (is.null(getOption("tinyplot_grid.lty"))) "dotted" else getOption("tinyplot_grid.lty")
.tpar$grid.lwd = if (is.null(getOption("tinyplot_grid.lwd"))) 1 else as.numeric(getOption("tinyplot_grid.lwd"))

# Legend justification
.tpar$ljust = if (is.null(getOption("tinyplot_ljust"))) "left" else getOption("tinyplot_ljust")

# Legend margin, i.e. gap between the legend and the plot elements
.tpar$lmar = if (is.null(getOption("tinyplot_lmar"))) c(1.0, 0.1) else as.numeric(getOption("tinyplot_lmar"))

Expand Down
2 changes: 1 addition & 1 deletion inst/tinytest/_tinysnapshot/addTRUE.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion inst/tinytest/_tinysnapshot/aesthetics_by.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
20 changes: 10 additions & 10 deletions inst/tinytest/_tinysnapshot/aesthetics_by_fill.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
20 changes: 10 additions & 10 deletions inst/tinytest/_tinysnapshot/aesthetics_by_fill_alpha.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Loading