Skip to content

Commit

Permalink
Legend and facet spacing (#94)
Browse files Browse the repository at this point in the history
* Add par2(lmar) option

* Catch after par2 update

* First stab at integrating fixed margins

* clean up

* Still testing, but better defaults

* document

* Fix first plot spacing

- Need to set par(mar) margins first.
- Also consolidate outer right and left code logic.
- Clean up too.

* clean up

* Outer bottom and top

* Adjust for sub if legend = "bottom!"

* Better facet support with new legend spacing logic

* Bump roxygen2 version

* Tweak plot2 lmar

* Legend fully in outer margin (incl. inside gap)

* Better facet logic for consistency across facet types

- Principled gaps between facets
- Overall plot region matched to single (no facet) plot

* Clean up old facet logic

- Better internal comments too.

* document

* Fixed "left!" and "right!" insets

* Fix "bottom!" and "top!" support

* Better catch for recursive "top!" calls

* update tests

* Document par2

- Also add fmar to par2 options

* Remove grid from par2 for the moment

- Reinstate after this PR is finished

* better catch for custom user mfrow cases

* Update README and vignette

* Add NEWS items

* Bump version

* Add more legend tests

* Add lmar tests

* Add more facet tests

- Specifically 2 by x cases

* Fix fmar bug when passed through facet.args

* Add fmar tests

* Fix wording about facet margins

* export draw_legend and update namespace

* Add README.qmd to .Rbuildignore

* Tweak facet spacing logic for >=3 case (i.e., exception for 2x2 case)

* Update tests and clean up

* Better documentation of fmar spacing logic
  • Loading branch information
grantmcdermott authored Jan 21, 2024
1 parent c7436cc commit 2dcae14
Show file tree
Hide file tree
Showing 129 changed files with 15,026 additions and 12,639 deletions.
1 change: 1 addition & 0 deletions .Rbuildignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
^\.Rproj\.user$
^_EXAMPLES$
^README\.Rmd$
^README\.qmd$
^README\.html$
^_pkgdown\.yml$
^docs$
Expand Down
4 changes: 2 additions & 2 deletions DESCRIPTION
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
Package: plot2
Type: Package
Title: Lightweight extension of base R plot
Version: 0.0.3.9014
Version: 0.0.3.9015
Authors@R:
c(
person(
Expand Down Expand Up @@ -53,7 +53,7 @@ Suggests:
Remotes:
etiennebacher/altdoc
Encoding: UTF-8
RoxygenNote: 7.2.3.9000
RoxygenNote: 7.3.0
URL: https://grantmcdermott.com/plot2/,
http://grantmcdermott.com/plot2/
BugReports: https://github.com/grantmcdermott/plot2/issues
3 changes: 3 additions & 0 deletions NAMESPACE
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
S3method(plot2,default)
S3method(plot2,density)
S3method(plot2,formula)
export(draw_legend)
export(par2)
export(plot2)
importFrom(grDevices,adjustcolor)
importFrom(grDevices,extendrange)
Expand All @@ -18,6 +20,7 @@ importFrom(graphics,arrows)
importFrom(graphics,axis)
importFrom(graphics,box)
importFrom(graphics,grconvertX)
importFrom(graphics,grconvertY)
importFrom(graphics,lines)
importFrom(graphics,mtext)
importFrom(graphics,par)
Expand Down
21 changes: 19 additions & 2 deletions NEWS.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# News

## 0.0.3.914 (development version)
## 0.0.3.915 (development version)

Website:

Expand All @@ -26,7 +26,13 @@ existing plot window. (#60 @grantmcdermott)
- `plot2` gains a new `facet` argument for drawing faceted plots. Users can
override the default square arrangement by passing the desired number of facet
rows or columns to the companion `facet.args` helper function. Facets can be
combined with `by` grouping, or used on their own. (#83, #91 @grantmcdermott)
combined with `by` grouping, or used on their own. (#83, #91, #94
@grantmcdermott)
- Users can now control `plot2`-specific graphical parameters globally via
the new `par2()` function (which is modeled on the base `par()` function). At
the moment only a subset of global parameters, mostly related to legend and
facet behaviour, are exposed in `par2`. But users can expect that more will be
added in future releases. (#33, #94 @grantmcdermott)

Bug fixes:

Expand All @@ -37,6 +43,17 @@ e.g. `plot2(rnorm(100)`. (#52 etiennebacher)
- Interval plots like ribbons, errorbars, and pointranges are now correctly
plotted even if a y variable isn't specified. (#54 @grantmcdermott)
- Correctly label date-time axes. (#77 @grantmcdermott and @zeileis)
- Improved consistency of legend and facet margins across different plot types
and placement, via the new `lmar` and `fmar` arguments of `par2()`. The default
legend margin is `par2(lmar = c(1,0, 0.1)`, which means that there is 1.0 line
of padding between the legend and the plot region (inside margin) and 0.1 line
of padding between the legend and edge of the graphics device (outer margin).
Similarly, the default facet padding is `par2(fmar = c(1,1,1,1)`, which means
that there is a single line of padding around each side of the individual
facets. Users can override these defaults by passing numeric vectors of the
appropriate length to `par2()`. For example, `par2(lmar = c(0,0.1)` would shrink
the inner gap between the legend and plot region to zero, but leave the small
outer gap to outside of the graphics device unchanged. (#94 @grantmcdermott)

## 0.0.3

Expand Down
221 changes: 185 additions & 36 deletions R/draw_legend.R
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,64 @@
#' @param col Plotting colour(s), passed down from `plot2`.
#' @param bg Plotting character background fill colour(s), passed down from `plot2`.
#' @param cex Plotting character expansion(s), passed down from `plot2`.
#' @param new_plot Should we be calling plot.new internally?
#' @param lmar Legend margins (in lines). Should be a numeric vector of the form
#' `c(inner, outer)`, where the first number represents the "inner" margin
#' between the legend and the plot, and the second number represents the
#' "outer" margin between the legend and edge of the graphics device. If no
#' explicit value is provided by the user, then reverts back to `par2("lmar")`
#' for which the default values are `c(1.0, 0.1)`.
#' @param has_sub Logical. Does the plot have a sub-caption. Only used if
#' keyword position is "bottom!", in which case we need to bump the legend
#' margin a bit further.
#' @param new_plot Should we be calling plot.new internally?
#' @examples
#'
#' oldmar = par("mar")
#'
#' draw_legend(
#' legend = "right!", ## default (other options incl, "left(!)", ""bottom(!)", etc.)
#' legend.args = list(title = "Key", bty = "o"),
#' lgnd_labs = c("foo", "bar"),
#' type = "p",
#' pch = 21:22,
#' col = 1:2
#' )
#'
#' # The legend is placed in the outer margin...
#' box("figure", col = "cyan", lty = 4)
#' # ... and the plot is proportionally adjusted against the edge of this
#' # margin.
#' box("plot")
#' # You can add regular plot objects per normal now
#' plot.window(xlim = c(1,10), ylim = c(1,10))
#' points(1:10)
#' points(10:1, pch = 22, col = "red")
#' axis(1); axis(2)
#' # etc.
#'
#' # Important: A side effect of draw_legend is that the inner margins have been
#' # adjusted. (Here: The right margin, since we called "right!" above.)
#' par("mar")
#'
#' # To reset you should call `dev.off()` or just reset manually.
#' par(mar = oldmar)
#'
#' # Note that the inner and outer margin of the legend itself can be set via
#' # the `lmar` argument. (This can also be set globally via
#' # `par2(lmar = c(inner, outer))`.)
#' draw_legend(
#' legend.args = list(title = "Key", bty = "o"),
#' lgnd_labs = c("foo", "bar"),
#' type = "p",
#' pch = 21:22,
#' col = 1:2,
#' lmar = c(0, 0.1) ## set inner margin to zero
#' )
#' box("figure", col = "cyan", lty = 4)
#'
#' par(mar = oldmar)
#'
#' @export
draw_legend = function(
legend = NULL,
legend.args = NULL,
Expand All @@ -26,10 +83,21 @@ draw_legend = function(
col = NULL,
bg = NULL,
cex = NULL,
lmar = NULL,
has_sub = FALSE,
new_plot = TRUE
) {

w = h = outer_right = outer_bottom = NULL
if (is.null(lmar)) {
lmar = par2("lmar")
} else {
if (!is.numeric(lmar) || length(lmar)!=2) stop ("lmar must be a numeric of length 2.")
}

soma = outer_right = outer_bottom = NULL

#
## legend args ----

if (is.null(legend)) {
legend.args[["x"]] = "right!"
Expand Down Expand Up @@ -88,28 +156,59 @@ draw_legend = function(
legend.args[["legend"]] = lgnd_labs
}

# Catch to avoid recursive offsets, e.g., repeated plot2 calls with
#
## legend placement ----

ooma = par("oma")
omar = par("mar")
topmar_epsilon = 0.1

# Catch to avoid recursive offsets, e.g. repeated plot2 calls with
# "bottom!" legend position.

## restore inner margin defaults
## (in case the plot region/margins were affected by the preceding plot2 call)
if (any(ooma != 0)) {
if ( ooma[1] != 0 & omar[1] == par("mgp")[1] + 1*par("cex.lab") ) omar[1] = 5.1
if ( ooma[2] != 0 & omar[2] == par("mgp")[1] + 1*par("cex.lab") ) omar[2] = 4.1
if ( ooma[3] == topmar_epsilon & omar[3] != 4.1 ) omar[3] = 4.1
if ( ooma[4] != 0 & omar[4] == 0 ) omar[4] = 2.1
par(mar = omar)
}
## restore outer margin defaults
par(omd = c(0,1,0,1))
ooma = par("oma")


## Legend to outer side of plot
## Legend to outer side (either right or left) of plot
if (grepl("right!$|left!$", legend.args[["x"]])) {

outer_right = grepl("right!$", legend.args[["x"]])

# Margins of the plot (the first is the bottom margin)
## Switch position anchor (we'll adjust relative to the _opposite_ side below)
if (outer_right) legend.args[["x"]] = gsub("right!$", "left", legend.args[["x"]])
if (!outer_right) legend.args[["x"]] = gsub("left!$", "right", legend.args[["x"]])

## We have to set the inner margins of the plot before the (fake) legend is
## drawn, otherwise the inset calculation---which is based in the legend
## width---will be off the first time.
if (outer_right) {
par(mar=c(par("mar")[1:3], 0.1)) # remove right inner margin space
} else if (par("mar")[4]==0.1) {
par(mar=c(par("mar")[1:3], 2.1)) # revert right margin if outer left
# par(mar=c(par("mar")[1:3], 0)) ## Set rhs inner mar to zero
omar[4] = 0 ## TEST
} else {
# For outer left we have to account for the y-axis label too, which
# requires additional space
# par(mar=c(
# par("mar")[1],
# par("mgp")[1] + 1*par("cex.lab"),
# par("mar")[3:4]
# ))
omar[2] = par("mgp")[1] + 1*par("cex.lab") ## TEST
}
par(mar = omar) ## TEST

if (isTRUE(new_plot)) plot.new()

## Switch position anchor (we'll adjust relative to the _opposite_ side below)
if (outer_right) legend.args[["x"]] = gsub("right!$", "left", legend.args[["x"]])
if (!outer_right) legend.args[["x"]] = gsub("left!$", "right", legend.args[["x"]])

legend.args[["horiz"]] = FALSE

# "draw" fake legend
Expand All @@ -119,33 +218,62 @@ draw_legend = function(
keep.null = TRUE
)
fklgnd = do.call("legend", fklgnd.args)
# calculate side margin width in ndc
w = grconvertX(fklgnd$rect$w, to="ndc") - grconvertX(0, to="ndc")
## differing adjustments depending on side

# calculate outer margin width in lines
soma = grconvertX(fklgnd$rect$w, to="lines") - grconvertX(0, to="lines")
# Add legend margins to the outer margin
soma = soma + sum(lmar)
# ooma = par("oma") ## TEST (comment)
## differing outer margin adjustments depending on side
if (outer_right) {
w = w*1.5
par(omd = c(0, 1-w, 0, 1))
legend.args[["inset"]] = c(1.025, 0)
ooma[4] = soma
} else {
w = w + grconvertX(par("mgp")[1], from = "lines", to = "ndc") # extra space for y-axis title
par(omd = c(w, 1, 0, 1))
legend.args[["inset"]] = c(1+w, 0)
ooma[2] = soma
}
par(oma = ooma)

# determine legend inset
inset = grconvertX(lmar[1], from="lines", to="npc") - grconvertX(0, from = "lines", to = "npc")
if (isFALSE(outer_right)) {
# extra space needed for "left!" b/c of lhs inner margin
inset_bump = grconvertX(par("mar")[2], from = "lines", to = "npc") - grconvertX(0, from = "lines", to = "npc")
inset = inset + inset_bump
}
# GM: The legend inset spacing only works _exactly_ if we refresh the plot
# area. I'm not sure why (and it works properly if we use the same
# parameters manually while debugging), but this hack seems to work.
par(new = TRUE)
plot.new()
par(new = FALSE)
# Finally, set the inset as part of the legend args.
legend.args[["inset"]] = c(1+inset, 0)

## Legend at the outer top or bottom of plot
} else if (grepl("bottom!$|top!$", legend.args[["x"]])) {

outer_bottom = grepl("bottom!$", legend.args[["x"]])

# Catch to reset right margin if previous legend position was "right!"
if (par("mar")[4]== 0.1) par(mar=c(par("mar")[1:3], 2.1))

if (isTRUE(new_plot)) plot.new()

## Switch position anchor (we'll adjust relative to the _opposite_ side below)
if (outer_bottom) legend.args[["x"]] = gsub("bottom!$", "top", legend.args[["x"]])
if (!outer_bottom) legend.args[["x"]] = gsub("top!$", "bottom", legend.args[["x"]])

## We have to set the inner margins of the plot before the (fake) legend is
## drawn, otherwise the inset calculation---which is based in the legend
## width---will be off the first time.
if (outer_bottom) {
omar[1] = par("mgp")[1] + 1*par("cex.lab") ## TEST
if (isTRUE(has_sub)) omar[1] = omar[1] + 1*par("cex.sub") ## TEST
} else {
## For "top!", the logic is slightly different: We don't expand the outer
## margin b/c we need the legend to come underneath the main title. So
## we rather expand the existing inner margin.
ooma[3] = ooma[3] + topmar_epsilon ## TESTING
par(oma = ooma)
}
par(mar = omar)

if (isTRUE(new_plot)) plot.new()

legend.args[["horiz"]] = TRUE

# Catch for horizontal ribbon legend spacing
Expand All @@ -160,24 +288,45 @@ draw_legend = function(
# "draw" fake legend
fklgnd.args = utils::modifyList(
legend.args,
list(x = 0, y = 0, plot = FALSE),
# list(x = 0, y = 0, plot = FALSE),
list(plot = FALSE),
keep.null = TRUE
)
fklgnd = do.call("legend", fklgnd.args)
# calculate bottom margin height in ndc
h = grconvertX(fklgnd$rect$h, to="ndc") - grconvertX(0, to="ndc")
## differing adjustments depending on side

# calculate outer margin width in lines
soma = grconvertY(fklgnd$rect$h, to="lines") - grconvertY(0, to="lines")
# Add legend margins to outer margin
soma = soma + sum(lmar)
## differing outer margin adjustments depending on side
if (outer_bottom) {
legend.args[["inset"]] = c(0, 1+2*h)
par(omd = c(0,1,0+h,1))
ooma[1] = soma
} else {
omar[3] = omar[3] + soma - topmar_epsilon
par(mar = omar)
}
par(oma = ooma)

# determine legend inset
inset = grconvertY(lmar[1], from="lines", to="npc") - grconvertY(0, from="lines", to="npc")
if (isTRUE(outer_bottom)) {
# extra space needed for "bottom!" b/c of lhs inner margin
inset_bump = grconvertY(par("mar")[1], from="lines", to="npc") - grconvertY(0, from="lines", to="npc")
inset = inset + inset_bump
} else {
legend.args[["inset"]] = c(0, 1)
par(omd = c(0,1,0,1-h))
epsilon_bump = grconvertY(topmar_epsilon, from="lines", to="npc") - grconvertY(0, from="lines", to="npc")
inset = inset + epsilon_bump
}
# GM: The legend inset spacing only works _exactly_ if we refresh the plot
# area. I'm not sure why (and it works properly if we use the same
# parameters manually while debugging), but this hack seems to work.
par(new = TRUE)
plot.new()
par(new = FALSE)
# Finally, set the inset as part of the legend args.
legend.args[["inset"]] = c(0, 1+inset)

} else {
# Catch to reset right margin if previous legend position was "right!"
if (par("mar")[4] == 0.1) par(mar=c(par("mar")[1:3], par("mar")[2]-2))
legend.args[["inset"]] = 0
if (isTRUE(new_plot)) plot.new()
}
Expand Down
Loading

0 comments on commit 2dcae14

Please sign in to comment.