diff --git a/NEWS.md b/NEWS.md index debddb74..2da16260 100644 --- a/NEWS.md +++ b/NEWS.md @@ -53,7 +53,10 @@ where the formatting is also better._ - Fixed dynamic x-axis margin spacing for perpendicular (vertical) label text, i.e. cases where `las = 2` or `las = 3`. (#369 @grantmcdermott) - Better integration with the Positron IDE graphics pane. Thanks to @thomasp85 - for the report and helpful suggestions. (#377 @grantmcdermott) + for the report and helpful suggestions. (#377, #394 @grantmcdermott) + - The one remaining Positron issue at present is calling `plt_add()` on a + faceted plot, but this appears to be an upstream limitation/bug (see + https://github.com/posit-dev/positron/issues/7316) - Fixed a bug that resulted in y-axis labels being coerced to numeric for `"p"`-alike plot types (including `"jitter"`) if `y` is a factor or character (#387 @grantmcdermott). diff --git a/R/draw_legend.R b/R/draw_legend.R index 89e1235c..861850e4 100644 --- a/R/draw_legend.R +++ b/R/draw_legend.R @@ -28,6 +28,9 @@ #' keyword position is "bottom!", in which case we need to bump the legend #' margin a bit further. #' @param new_plot Logical. Should we be calling plot.new internally? +#' @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. #' #' @returns No return value, called for side effect of producing a(n empty) plot #' with a legend in the margin. @@ -110,393 +113,429 @@ draw_legend = function( gradient = FALSE, lmar = NULL, has_sub = FALSE, - new_plot = TRUE + new_plot = TRUE, + draw = TRUE ) { - # Note: We wrap everything in recordGraphics to preserve legend spacing if - # the plot is resized - recordGraphics( - { + if (is.null(lmar)) { + lmar = tpar("lmar") + } else { + if (!is.numeric(lmar) || length(lmar)!=2) stop ("lmar must be a numeric of length 2.") + } - if (is.null(lmar)) { - lmar = tpar("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_args[["x"]])) { - if (is.null(legend)) { - legend_args[["x"]] = "right!" - } else if (is.character(legend)) { - legend_args = utils::modifyList(legend_args, list(x = legend)) - } else if (class(legend) %in% c("call", "name")) { - largs = as.list(legend) - if (is.null(largs[["x"]])) { - lnms = names(largs) - # check second position b/c first will be a symbol - if (is.null(lnms)) { - largs = stats::setNames(largs, c("", "x")) - } else if (length(largs)>=2 && lnms[2] == "") { - lnms[2] = "x" - largs = stats::setNames(largs, lnms) - } else { - largs[["x"]] = "right!" - } + assert_logical(gradient) + assert_logical(has_sub) + assert_logical(new_plot) + assert_logical(draw) + + # + ## legend args ---- + + if (is.null(legend_args[["x"]])) { + if (is.null(legend)) { + legend_args[["x"]] = "right!" + } else if (is.character(legend)) { + legend_args = utils::modifyList(legend_args, list(x = legend)) + } else if (class(legend) %in% c("call", "name")) { + largs = as.list(legend) + if (is.null(largs[["x"]])) { + lnms = names(largs) + # check second position b/c first will be a symbol + if (is.null(lnms)) { + largs = stats::setNames(largs, c("", "x")) + } else if (length(largs)>=2 && lnms[2] == "") { + lnms[2] = "x" + largs = stats::setNames(largs, lnms) + } else { + largs[["x"]] = "right!" } - # Finally, combine with any pre-existing legend args (e.g., title from the by label) - legend_args = utils::modifyList(legend_args, largs, keep.null = TRUE) } + # Finally, combine with any pre-existing legend args (e.g., title from the by label) + legend_args = utils::modifyList(legend_args, largs, keep.null = TRUE) } - - ## Use `!exists` rather than `is.null` for title in case user specified no title - if (!exists("title", where = legend_args)) legend_args[["title"]] = by_dep - - legend_args[["pch"]] = legend_args[["pch"]] %||% pch - legend_args[["lty"]] = legend_args[["lty"]] %||% lty - legend_args[["col"]] = legend_args[["col"]] %||% col - legend_args[["bty"]] = legend_args[["bty"]] %||% "n" - legend_args[["horiz"]] = legend_args[["horiz"]] %||% FALSE - legend_args[["xpd"]] = legend_args[["xpd"]] %||% NA + } - if (!isTRUE(type %in% c("p", "ribbon", "polygon", "polypath"))) { - legend_args[["lwd"]] = legend_args[["lwd"]] %||% lwd - } + ## Use `!exists` rather than `is.null` for title in case user specified no title + if (!exists("title", where = legend_args)) legend_args[["title"]] = by_dep + + legend_args[["pch"]] = legend_args[["pch"]] %||% pch + legend_args[["lty"]] = legend_args[["lty"]] %||% lty + legend_args[["col"]] = legend_args[["col"]] %||% col + legend_args[["bty"]] = legend_args[["bty"]] %||% "n" + legend_args[["horiz"]] = legend_args[["horiz"]] %||% FALSE + legend_args[["xpd"]] = legend_args[["xpd"]] %||% NA + + if (!isTRUE(type %in% c("p", "ribbon", "polygon", "polypath"))) { + legend_args[["lwd"]] = legend_args[["lwd"]] %||% lwd + } + + if (isTRUE(type %in% c("p", "pointrange", "errorbar")) && (length(col) == 1 || length(cex) == 1)) { + legend_args[["pt.cex"]] = legend_args[["pt.cex"]] %||% cex + } + + # turn off inner line for "barplot" type + if (identical(type, "barplot")) { + legend_args[["lty"]] = 0 + } - if (isTRUE(type %in% c("p", "pointrange", "errorbar")) && (length(col) == 1 || length(cex) == 1)) { - legend_args[["pt.cex"]] = legend_args[["pt.cex"]] %||% cex - } + if (isTRUE(type %in% c("rect", "ribbon", "polygon", "polypath", "boxplot", "hist", "histogram", "spineplot", "ridge", "barplot", "violin")) || gradient) { + legend_args[["pch"]] = 22 + legend_args[["pt.cex"]] = legend_args[["pt.cex"]] %||% 3.5 + legend_args[["y.intersp"]] = legend_args[["y.intersp"]] %||% 1.25 + legend_args[["seg.len"]] = legend_args[["seg.len"]] %||% 1.25 + } + + if (isTRUE(type %in% c("ribbon", "hist", "histogram", "spineplot"))) { + legend_args[["pt.lwd"]] = legend_args[["pt.lwd"]] %||% 0 + } + + if (identical(type, "p")) { + legend_args[["pt.lwd"]] = legend_args[["pt.lwd"]] %||% lwd + } + + if (identical(type, "n") && isFALSE(gradient)) { + legend_args[["pch"]] = legend_args[["pch"]] %||% par("pch") + } + + if (identical(type, "spineplot")) { + legend_args[["pt.bg"]] = legend_args[["pt.bg"]] %||% legend_args[["col"]] + } + + if (identical(type, "ridge") && isFALSE(gradient)) { + legend_args[["pt.bg"]] = legend_args[["pt.bg"]] %||% sapply(legend_args[["col"]], function(ccol) seq_palette(ccol, n = 2)[2]) + } + + legend_args[["pt.bg"]] = legend_args[["pt.bg"]] %||% bg + + legend_args[["legend"]] = legend_args[["legend"]] %||% lgnd_labs + if (length(lgnd_labs) != length(eval(legend_args[["legend"]]))) { + warning( + "\nUser-supplied legend labels do not match the number of groups.\n", + "Defaulting to automatic labels determined by the group splits in `by`,\n" + ) + legend_args[["legend"]] = lgnd_labs + } - # turn off inner line for "barplot" type - if (identical(type, "barplot")) { - legend_args[["lty"]] = 0 - } - - if (isTRUE(type %in% c("rect", "ribbon", "polygon", "polypath", "boxplot", "hist", "histogram", "spineplot", "ridge", "barplot", "violin")) || isTRUE(gradient)) { - legend_args[["pch"]] = 22 - legend_args[["pt.cex"]] = legend_args[["pt.cex"]] %||% 3.5 - legend_args[["y.intersp"]] = legend_args[["y.intersp"]] %||% 1.25 - legend_args[["seg.len"]] = legend_args[["seg.len"]] %||% 1.25 - } + # + ## legend placement ---- - if (isTRUE(type %in% c("ribbon", "hist", "histogram", "spineplot"))) { - legend_args[["pt.lwd"]] = legend_args[["pt.lwd"]] %||% 0 - } + # Note: "side" = left/right ; "end" = top/bottom + outer_side = outer_end = outer_right = outer_bottom = FALSE ## placeholders - if (identical(type, "p")) { - legend_args[["pt.lwd"]] = legend_args[["pt.lwd"]] %||% lwd - } + ooma = par("oma") + omar = par("mar") + topmar_epsilon = 0.1 - if (identical(type, "n") && isFALSE(gradient)) { - legend_args[["pch"]] = legend_args[["pch"]] %||% par("pch") - } + # Catch to avoid recursive offsets, e.g. repeated tinyplot calls with + # "bottom!" legend position. - if (identical(type, "spineplot")) { - legend_args[["pt.bg"]] = legend_args[["pt.bg"]] %||% legend_args[["col"]] - } - - if (identical(type, "ridge") && isFALSE(gradient)) { - legend_args[["pt.bg"]] = legend_args[["pt.bg"]] %||% sapply(legend_args[["col"]], function(ccol) seq_palette(ccol, n = 2)[2]) - } - - legend_args[["pt.bg"]] = legend_args[["pt.bg"]] %||% bg + ## restore inner margin defaults + ## (in case the plot region/margins were affected by the preceding tinyplot call) + dynmar = isTRUE(.tpar[["dynmar"]]) + if (any(ooma != 0) && !dynmar) { + 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_args[["legend"]] = legend_args[["legend"]] %||% lgnd_labs - if (length(lgnd_labs) != length(eval(legend_args[["legend"]]))) { - warning( - "\nUser-supplied legend labels do not match the number of groups.\n", - "Defaulting to automatic labels determined by the group splits in `by`,\n" - ) - legend_args[["legend"]] = lgnd_labs - } + ## Legend to outer side (either right or left) of plot + if (grepl("right!$|left!$", legend_args[["x"]])) { - # - ## legend placement ---- + outer_side = TRUE + outer_right = grepl("right!$", legend_args[["x"]]) - ooma = par("oma") - omar = par("mar") - topmar_epsilon = 0.1 + # extra bump for spineplot if outer_right legend (to accommodate secondary y-axis) + if (identical(type, "spineplot")) lmar[1] = lmar[1] + 1.1 - # Catch to avoid recursive offsets, e.g. repeated tinyplot calls with - # "bottom!" legend position. + ## 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"]]) - ## restore inner margin defaults - ## (in case the plot region/margins were affected by the preceding tinyplot call) - dynmar = isTRUE(.tpar[["dynmar"]]) - if (any(ooma != 0) && !dynmar) { - 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) + ## 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) { + omar[4] = 0 + } else { + # For outer left we have to account for the y-axis label too, which + # requires additional space + omar[2] = par("mgp")[1] + 1*par("cex.lab") } - ## restore outer margin defaults - par(omd = c(0,1,0,1)) - ooma = par("oma") + par(mar = omar) - ## Legend to outer side (either right or left) of plot - if (grepl("right!$|left!$", legend_args[["x"]])) { - - outer_right = grepl("right!$", legend_args[["x"]]) - - # extra bump for spineplot if outer_right legend (to accommodate secondary y-axis) - if (identical(type, "spineplot")) lmar[1] = lmar[1] + 1.1 - - ## 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) { - omar[4] = 0 - } else { - # For outer left we have to account for the y-axis label too, which - # requires additional space - omar[2] = par("mgp")[1] + 1*par("cex.lab") - } - par(mar = omar) - - # if (isTRUE(new_plot)) plot.new() - if (isTRUE(new_plot)) { - plot.new() - # Experimental: For themed + dynamic plots, we need to make sure the - # adjusted plot margins for the legend are reinstated (after being - # overwritten by the before.plot.new hook. - if (dynmar) { - omar = par("mar") - if (outer_right) { - omar[4] = 0 - } else { - omar[2] = par("mgp")[1] + 1*par("cex.lab") - } - par(mar = omar) + if (new_plot && draw) { + plot.new() + # For themed + dynamic plots, we need to make sure the adjusted plot + # margins for the legend are reinstated (after being overwritten by + # the before.plot.new hook. + if (dynmar) { + omar = par("mar") + if (outer_right) { + omar[4] = 0 + } else { + omar[2] = par("mgp")[1] + 1*par("cex.lab") } + par(mar = omar) } - - legend_args[["horiz"]] = FALSE - - # "draw" fake legend - fklgnd.args = modifyList( - legend_args, - list(x = 0, y = 0, plot = FALSE), - keep.null = TRUE - ) - if (isTRUE(gradient)) { - lgnd_labs_tmp = na.omit(fklgnd.args[["legend"]]) - if (length(lgnd_labs_tmp) < 5L) { - nmore = 5L - length(lgnd_labs_tmp) - lgnd_labs_tmp = c(lgnd_labs_tmp, rep("", nmore)) - } - fklgnd.args = modifyList( - fklgnd.args, - list(legend = lgnd_labs_tmp), - keep.null = TRUE - ) - } - fklgnd = do.call("legend", fklgnd.args) - - # 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) - ## differing outer margin adjustments depending on side - if (outer_right) { - ooma[4] = soma - } else { - ooma[2] = soma - } + } + + legend_args[["horiz"]] = FALSE + + ## Legend at the outer top or bottom of plot + } else if (grepl("bottom!$|top!$", legend_args[["x"]])) { + + outer_end = TRUE + outer_bottom = grepl("bottom!$", legend_args[["x"]]) + + ## 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") + if (has_sub && (is.null(.tpar[["side.sub"]]) || .tpar[["side.sub"]]==1)) omar[1] = omar[1] + 1*par("cex.sub") + } 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 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. - ## v0.3.0 update: Using (temporary) hook instead of direct par(new = TRUE) - ## assignment to play nice with tinytheme logic. - oldhook = getHook("before.plot.new") - setHook("before.plot.new", function() par(new = TRUE), action = "append") - setHook("before.plot.new", function() par(mar = omar), action = "append") + } + par(mar = omar) + + if (new_plot && draw) { plot.new() - setHook("before.plot.new", oldhook, action = "replace") - # 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"]]) - - ## 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") - if (isTRUE(has_sub) && (is.null(.tpar[["side.sub"]]) || .tpar[["side.sub"]]==1)) omar[1] = omar[1] + 1*par("cex.sub") - } 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 - par(oma = ooma) - } - par(mar = omar) - - # if (isTRUE(new_plot)) plot.new() - if (isTRUE(new_plot)) { - plot.new() - # Experimental: For themed + dynamic plots, we need to make sure the - # adjusted plot margins for the legend are reinstated (after being - # overwritten by the before.plot.new hook. - if (dynmar) { - omar = par("mar") - if (outer_bottom) { - # omar[1] = par("mgp")[1] + 1*par("cex.lab") - omar[1] = theme_clean$mgp[1] + 1*par("cex.lab") ## bit of a hack - if (isTRUE(has_sub) && (is.null(.tpar[["side.sub"]]) || .tpar[["side.sub"]]==1)) omar[1] = omar[1] + 1*par("cex.sub") - } else { - ooma[3] = ooma[3] + topmar_epsilon - par(oma = ooma) - } - par(mar = omar) - } - } - - legend_args[["horiz"]] = TRUE - - # Catch for horizontal ribbon legend spacing - if (type=="ribbon" && isTRUE(legend_args[["horiz"]])) { - if (legend_args[["pt.lwd"]] == 1) { - legend_args[["x.intersp"]] = 1 + # For themed + dynamic plots, we need to make sure the adjusted plot + # margins for the legend are reinstated (after being overwritten by + # the before.plot.new hook. + if (dynmar) { + omar = par("mar") + if (outer_bottom) { + # omar[1] = par("mgp")[1] + 1*par("cex.lab") + omar[1] = theme_clean$mgp[1] + 1*par("cex.lab") ## bit of a hack + if (has_sub && (is.null(.tpar[["side.sub"]]) || .tpar[["side.sub"]]==1)) omar[1] = omar[1] + 1*par("cex.sub") } else { - legend_args[["x.intersp"]] = 0.5 + ooma[3] = ooma[3] + topmar_epsilon + par(oma = ooma) } - } else if (isTRUE(gradient) && isTRUE(legend_args[["horiz"]])) { - legend_args[["x.intersp"]] = 0.5 - } - - # "draw" fake legend - fklgnd.args = modifyList( - legend_args, - list(plot = FALSE), - keep.null = TRUE - ) - if (isTRUE(gradient)) { - lgnd_labs_tmp = na.omit(fklgnd.args[["legend"]]) - if (length(lgnd_labs_tmp) < 5L) { - nmore = 5L - length(lgnd_labs_tmp) - lgnd_labs_tmp = c(lgnd_labs_tmp, rep("", nmore)) - } - fklgnd.args = modifyList( - fklgnd.args, - list(legend = lgnd_labs_tmp, title = NULL), - keep.null = TRUE - ) - } - fklgnd = do.call("legend", fklgnd.args) - - # 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) { - 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 + } + + legend_args[["horiz"]] = TRUE + + # Catch for horizontal ribbon legend spacing + if (type=="ribbon" && isTRUE(legend_args[["horiz"]])) { + if (legend_args[["pt.lwd"]] == 1) { + legend_args[["x.intersp"]] = 1 } else { - epsilon_bump = grconvertY(topmar_epsilon, from="lines", to="npc") - grconvertY(0, from="lines", to="npc") - inset = inset + epsilon_bump + legend_args[["x.intersp"]] = 0.5 } - # 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. - ## v0.3.0 update: Using (temporary) hook instead of direct par(new = TRUE) - ## assignment to play nice with tinytheme logic. - oldhook = getHook("before.plot.new") - setHook("before.plot.new", function() par(new = TRUE), action = "append") - setHook("before.plot.new", function() par(mar = omar), action = "append") ## experimental dynmar - plot.new() - setHook("before.plot.new", oldhook, action = "replace") - # Finally, set the inset as part of the legend args. - legend_args[["inset"]] = c(0, 1+inset) - - } else { - legend_args[["inset"]] = 0 - if (isTRUE(new_plot)) plot.new() + } else if (gradient && isTRUE(legend_args[["horiz"]])) { + legend_args[["x.intersp"]] = 0.5 } - # Finally, plot the legend - if (isTRUE(gradient)) { - if (!more_than_n_unique(legend_args[["col"]], 1)) { - if (!is.null(legend_args[["pt.bg"]]) && length(legend_args[["pt.bg"]])==100) { - legend_args[["col"]] = legend_args[["pt.bg"]] - } - } - gradient_legend(legend_args = legend_args, lmar = lmar, outer_right = outer_right, outer_bottom = outer_bottom) - } else { - do.call("legend", legend_args) - } + } else { - }, + legend_args[["inset"]] = 0 + if (new_plot && draw) plot.new() + + } - list = list( - legend = legend, - legend_args = legend_args, - by_dep = by_dep, - lgnd_labs = lgnd_labs, - type = type, - pch = pch, - lty = lty, - lwd = lwd, - col = col, - bg = bg, - cex = cex, - gradient = gradient, - lmar = lmar, - has_sub = has_sub, - new_plot = new_plot - ), + # + ## draw the legend ---- - getNamespace("tinyplot") - - ) + # Legend drawing is handled by the internal `tinylegend()` function, which: + # 1. calculates appropriate insets for "outer" legend placement + # 2. can draw gradient legends (via `gradient_legend()` below) + # + # Note: We wrap everything in `recordGraphics()` to preserve legend spacing + # if the plot is resized (also necessary for Positron graphics logic regardless) + recordGraphics( + tinylegend( + legend_args = legend_args, + ooma = ooma, + omar = omar, + lmar = lmar, + topmar_epsilon = topmar_epsilon, + outer_side = outer_side, + outer_right = outer_right, + outer_end = outer_end, + outer_bottom = outer_bottom, + gradient = gradient, + draw = draw + ), + list = list( + legend_args = legend_args, + ooma = ooma, + omar = omar, + lmar = lmar, + topmar_epsilon = topmar_epsilon, + outer_side = outer_side, + outer_right = outer_right, + outer_end = outer_end, + outer_bottom = outer_bottom, + gradient = gradient, + draw = draw + ), + env = getNamespace("tinyplot") + ) } +# tinylegend ---- +## Internal workhorse function that draws the legend, given a set of legend +## arguments and other graphical parameters. It does this in three steps: +## 1) draw a fake legend, 2) calculate the associated inset and adjust the plot +## margins accordingly, 3) draw the real legend +tinylegend = function( + legend_args, + ooma, omar, lmar, topmar_epsilon, + outer_side, outer_right, outer_end, outer_bottom, + gradient, + draw +) { + + # + ## Step 1: "draw" fake legend + + fklgnd.args = modifyList( + legend_args, + list(plot = FALSE), + keep.null = TRUE + ) + + if (gradient) { + lgnd_labs_tmp = na.omit(fklgnd.args[["legend"]]) + if (length(lgnd_labs_tmp) < 5L) { + nmore = 5L - length(lgnd_labs_tmp) + lgnd_labs_tmp = c(lgnd_labs_tmp, rep("", nmore)) + } + fklgnd.args = modifyList( + fklgnd.args, + list(legend = lgnd_labs_tmp), + keep.null = TRUE + ) + if (outer_end) fklgnd.args = modifyList(fklgnd.args, list(title = NULL), keep.null = TRUE) + } + + if (draw) { + fklgnd = do.call("legend", fklgnd.args) + } else { + plot.new() + fklgnd = do.call("legend", fklgnd.args) + return(fklgnd) + } + + # + ## Step 2: Calculate legend inset (for outer placement in plot region) + + # calculate outer margin width in lines + soma = 0 + if (outer_side) { + soma = grconvertX(fklgnd$rect$w, to="lines") - grconvertX(0, to="lines") + } else if (outer_end) { + soma = grconvertY(fklgnd$rect$h, to="lines") - grconvertY(0, to="lines") + } + # Add legend margins to the outer margin + soma = soma + sum(lmar) + + ## differing outer margin adjustments depending on side + if (outer_side) { + if (outer_right) { + ooma[4] = soma + } else { + ooma[2] = soma + } + } else if (outer_end) { + if (outer_bottom) { + ooma[1] = soma + } else { + omar[3] = omar[3] + soma - topmar_epsilon + par(mar = omar) + } + } + par(oma = ooma) + + # determine legend inset + inset = 0 + if (outer_side) { + inset = grconvertX(lmar[1], from="lines", to="npc") - grconvertX(0, from = "lines", to = "npc") + # extra space needed for "left!" b/c of lhs inner margin + if (!outer_right) { + inset_bump = grconvertX(par("mar")[2], from = "lines", to = "npc") - grconvertX(0, from = "lines", to = "npc") + inset = inset + inset_bump + } + inset = c(1+inset, 0) + } else if (outer_end) { + inset = grconvertY(lmar[1], from="lines", to="npc") - grconvertY(0, from="lines", to="npc") + if (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 { + epsilon_bump = grconvertY(topmar_epsilon, from="lines", to="npc") - grconvertY(0, from="lines", to="npc") + inset = inset + epsilon_bump + } + inset = c(0, 1+inset) + } + + # 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. + ## v0.3.0 update: Using (temporary) hook instead of direct par(new = TRUE) + ## assignment to play nice with tinytheme logic. + oldhook = getHook("before.plot.new") + setHook("before.plot.new", function() par(new = TRUE), action = "append") + setHook("before.plot.new", function() par(mar = omar), action = "append") + plot.new() + setHook("before.plot.new", oldhook, action = "replace") + + # Finally, set the inset as part of the legend args. + legend_args[["inset"]] = inset + + # + ## Step 3: Draw the legend + + if (gradient) { + if (!more_than_n_unique(legend_args[["col"]], 1)) { + if (!is.null(legend_args[["pt.bg"]]) && length(legend_args[["pt.bg"]])==100) { + legend_args[["col"]] = legend_args[["pt.bg"]] + } + } + gradient_legend( + legend_args = legend_args, + fklgnd = fklgnd, + lmar = lmar, + outer_side = outer_side, + outer_end = outer_end, + outer_right = outer_right, + outer_bottom = outer_bottom + ) + } else { + do.call("legend", legend_args) + } + +} + + +# gradient legend ---- # For gradient (i.e., continuous color) legends, we'll role our own bespoke # legend function based on grDevices::as.raster -gradient_legend = function(legend_args, lmar = NULL, outer_right = NULL, outer_bottom = NULL) { - if (is.null(lmar)) lmar = .tpar[["lmar"]] + +gradient_legend = function(legend_args, fklgnd, lmar, outer_side, outer_end, outer_right, outer_bottom) { pal = legend_args[["col"]] lgnd_labs = legend_args[["legend"]] if (!is.null(legend_args[["horiz"]])) horiz = legend_args[["horiz"]] else horiz = FALSE @@ -509,11 +548,9 @@ gradient_legend = function(legend_args, lmar = NULL, outer_right = NULL, outer_b corners = par("usr") rasterbox = rep(NA_real_, 4) - # catch for "inner" legends - inner = FALSE - inner_right = inner_bottom = NULL - if (is.null(outer_right) && is.null(outer_bottom)) { - inner = TRUE + inner = !any(c(outer_side, outer_end)) + inner_right = inner_bottom = FALSE + if (inner) { if (!is.null(legend_args[["x"]]) && grepl("left$|right$", legend_args[["x"]])) { inner_right = grepl("right$", legend_args[["x"]]) } @@ -522,7 +559,17 @@ gradient_legend = function(legend_args, lmar = NULL, outer_right = NULL, outer_b } } - if (!is.null(outer_right)) { + if (inner) { + + fklgnd$rect$h = fklgnd$rect$h - (grconvertY(1.5 + 0.4, from="lines", to="user") - grconvertY(0, from="lines", to="user")) + + rasterbox[1] = fklgnd$rect$left + if (isFALSE(inner_right)) rasterbox[1] = rasterbox[1] + (grconvertX(0.2, from="lines", to="user") - grconvertX(0, from="lines", to="user")) + rasterbox[2] = fklgnd$rect$top - fklgnd$rect$h - (grconvertY(1.5 + 0.2, from="lines", to="user") - grconvertY(0, from="lines", to="user")) + rasterbox[3] = rasterbox[1] + (grconvertX(1.25, from="lines", to="user") - grconvertX(0, from="lines", to="user")) + rasterbox[4] = rasterbox[2] + fklgnd$rect$h + + } else if (outer_side) { rb1_adj = grconvertX(lmar[1] + 0.2, from="lines", to="user") - grconvertX(0, from="lines", to="user") rb3_adj = grconvertX(1.25, from="lines", to="user") - grconvertX(0, from="lines", to="user") @@ -538,12 +585,12 @@ gradient_legend = function(legend_args, lmar = NULL, outer_right = NULL, outer_b } rb4_adj = grconvertY(5+1, from="lines", to="user") - grconvertY(0, from="lines", to="user") - if (isTRUE(outer_right)) { + if (outer_right) { rasterbox[1] = corners[2] + rb1_adj rasterbox[2] = rb2_adj rasterbox[3] = rasterbox[1] + rb3_adj rasterbox[4] = rasterbox[2] + rb4_adj - } else if (isFALSE(outer_right)) { + } else { rb1_adj = rb1_adj + grconvertX(par("mar")[2] + 1, from="lines", to="user") - grconvertX(0, from="lines", to="user") rasterbox[1] = corners[1] - rb1_adj rasterbox[2] = rb2_adj @@ -551,14 +598,14 @@ gradient_legend = function(legend_args, lmar = NULL, outer_right = NULL, outer_b rasterbox[4] = rasterbox[2] + rb4_adj } - } else if (!is.null(outer_bottom)) { + } else if (outer_end) { rb1_adj = (corners[2] - corners[1] - (grconvertX(5+1, from="lines", to="user") - grconvertX(0, from="lines", to="user"))) / 2 rb3_adj = grconvertX(5+1, from="lines", to="user") - grconvertX(0, from="lines", to="user") rb2_adj = grconvertY(lmar[1], from="lines", to="user") - grconvertY(0, from="lines", to="user") rb4_adj = grconvertY(1.25, from="lines", to="user") - grconvertY(0, from="lines", to="user") - if (isTRUE(outer_bottom)) { + if (outer_bottom) { rb2_adj = rb2_adj + grconvertY(par("mar")[2], from="lines", to="user") - grconvertY(0, from="lines", to="user") rasterbox[1] = rb1_adj rasterbox[2] = corners[3] - rb2_adj @@ -572,30 +619,11 @@ gradient_legend = function(legend_args, lmar = NULL, outer_right = NULL, outer_b rasterbox[4] = rasterbox[2] - rb4_adj } - } else if (isTRUE(inner)) { - - # "draw" fake legend - lgnd_labs_tmp = na.omit(legend_args[["legend"]]) - if (length(lgnd_labs_tmp) < 5L) { - nmore = 5L - length(lgnd_labs_tmp) - lgnd_labs_tmp = c(lgnd_labs_tmp, rep("", nmore)) - } - fklgnd.args = modifyList( - legend_args, - list(plot = FALSE, legend = lgnd_labs_tmp), - keep.null = TRUE - ) - fklgnd = do.call("legend", fklgnd.args) - fklgnd$rect$h = fklgnd$rect$h - (grconvertY(1.5 + 0.4, from="lines", to="user") - grconvertY(0, from="lines", to="user")) - - rasterbox[1] = fklgnd$rect$left - if (isFALSE(inner_right)) rasterbox[1] = rasterbox[1] + (grconvertX(0.2, from="lines", to="user") - grconvertX(0, from="lines", to="user")) - rasterbox[2] = fklgnd$rect$top - fklgnd$rect$h - (grconvertY(1.5 + 0.2, from="lines", to="user") - grconvertY(0, from="lines", to="user")) - rasterbox[3] = rasterbox[1] + (grconvertX(1.25, from="lines", to="user") - grconvertX(0, from="lines", to="user")) - rasterbox[4] = rasterbox[2] + fklgnd$rect$h - } + # + ## Draw the gradient swatch + rasterImage( rasterlgd, rasterbox[1], #x1 @@ -605,6 +633,9 @@ gradient_legend = function(legend_args, lmar = NULL, outer_right = NULL, outer_b xpd = NA ) + # + ## Add the labels, tick marks, and title + if (isFALSE(horiz)) { labs_idx = !is.na(lgnd_labs) lgnd_labs[labs_idx] = paste0(" ", format(lgnd_labs[labs_idx])) @@ -613,7 +644,7 @@ gradient_legend = function(legend_args, lmar = NULL, outer_right = NULL, outer_b lbl_adj = c(0, 0.5) tck_adj = c(1, 0.5) ttl_adj = c(0, 0) - if (isFALSE(outer_right)) { + if (!inner && !outer_right) { lbl_x_anchor = rasterbox[1] ttl_x_anchor = ttl_x_anchor + max(strwidth(lgnd_labs[labs_idx])) ttl_adj = c(1, 0) diff --git a/R/tinyplot.R b/R/tinyplot.R index 1b5fb3eb..c896bfc6 100644 --- a/R/tinyplot.R +++ b/R/tinyplot.R @@ -1167,7 +1167,7 @@ tinyplot.default = function( y = y, ymax = ymax, ymin = ymin ), list = list( - add = add, + add = add, cex_fct_adj = cex_fct_adj, facet.args = facet.args, facet_newlines = facet_newlines, facet_font = facet_font, @@ -1322,7 +1322,8 @@ tinyplot.default = function( ngrps = ngrps, flip = flip, type_info = type_info, - facet_window_args = facet_window_args) + facet_window_args = facet_window_args + ) } } } @@ -1330,6 +1331,7 @@ tinyplot.default = function( # save end pars for possible recall later apar = par(no.readonly = TRUE) set_saved_par(when = "after", apar) + } diff --git a/man/draw_legend.Rd b/man/draw_legend.Rd index 4cd50a94..f4a4d64a 100644 --- a/man/draw_legend.Rd +++ b/man/draw_legend.Rd @@ -19,7 +19,8 @@ draw_legend( gradient = FALSE, lmar = NULL, has_sub = FALSE, - new_plot = TRUE + new_plot = TRUE, + draw = TRUE ) } \arguments{ @@ -61,6 +62,10 @@ keyword position is "bottom!", in which case we need to bump the legend margin a bit further.} \item{new_plot}{Logical. Should we be calling plot.new internally?} + +\item{draw}{Logical. If \code{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.} } \value{ No return value, called for side effect of producing a(n empty) plot