Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Legend and facet spacing #94

Merged
merged 38 commits into from
Jan 21, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
18d2aee
Add par2(lmar) option
grantmcdermott Dec 27, 2023
fff345e
Catch after par2 update
grantmcdermott Dec 28, 2023
ccaa3bc
First stab at integrating fixed margins
grantmcdermott Dec 28, 2023
ed2bb56
clean up
grantmcdermott Jan 2, 2024
78373d1
Still testing, but better defaults
grantmcdermott Jan 6, 2024
d29e70c
document
grantmcdermott Jan 6, 2024
9fd4308
Fix first plot spacing
grantmcdermott Jan 7, 2024
d718197
clean up
grantmcdermott Jan 7, 2024
fc2c9a7
Outer bottom and top
grantmcdermott Jan 7, 2024
aaa8a94
Adjust for sub if legend = "bottom!"
grantmcdermott Jan 7, 2024
12aad13
Better facet support with new legend spacing logic
grantmcdermott Jan 11, 2024
52f0045
Bump roxygen2 version
grantmcdermott Jan 16, 2024
5416712
Tweak plot2 lmar
grantmcdermott Jan 16, 2024
ab8dfc2
Legend fully in outer margin (incl. inside gap)
grantmcdermott Jan 16, 2024
93fd844
Better facet logic for consistency across facet types
grantmcdermott Jan 16, 2024
4d543ff
Clean up old facet logic
grantmcdermott Jan 16, 2024
5a285ec
document
grantmcdermott Jan 16, 2024
3ad719e
Fixed "left!" and "right!" insets
grantmcdermott Jan 18, 2024
1dc508d
Fix "bottom!" and "top!" support
grantmcdermott Jan 18, 2024
6174388
Better catch for recursive "top!" calls
grantmcdermott Jan 18, 2024
32fe8ab
update tests
grantmcdermott Jan 18, 2024
ada1e70
Document par2
grantmcdermott Jan 19, 2024
7b89c16
Remove grid from par2 for the moment
grantmcdermott Jan 19, 2024
0c110c4
better catch for custom user mfrow cases
grantmcdermott Jan 20, 2024
628dfee
Update README and vignette
grantmcdermott Jan 20, 2024
042be73
Add NEWS items
grantmcdermott Jan 20, 2024
de4d941
Bump version
grantmcdermott Jan 20, 2024
3d93208
Add more legend tests
grantmcdermott Jan 20, 2024
e30e782
Add lmar tests
grantmcdermott Jan 20, 2024
9e70ea5
Add more facet tests
grantmcdermott Jan 20, 2024
f3bb45b
Fix fmar bug when passed through facet.args
grantmcdermott Jan 20, 2024
1f1a4f8
Add fmar tests
grantmcdermott Jan 20, 2024
11aae8a
Fix wording about facet margins
grantmcdermott Jan 20, 2024
647ddfa
export draw_legend and update namespace
grantmcdermott Jan 20, 2024
917cad4
Add README.qmd to .Rbuildignore
grantmcdermott Jan 20, 2024
e899981
Tweak facet spacing logic for >=3 case (i.e., exception for 2x2 case)
grantmcdermott Jan 20, 2024
145714f
Update tests and clean up
grantmcdermott Jan 20, 2024
a10ce92
Better documentation of fmar spacing logic
grantmcdermott Jan 20, 2024
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 @@ -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