From c226ab2ca526057ca3cc9fc63a09910a3de1f5e3 Mon Sep 17 00:00:00 2001 From: Grant McDermott Date: Wed, 16 Apr 2025 20:35:05 -0700 Subject: [PATCH 1/5] wrap in recordGraphics --- R/draw_legend.R | 670 +++++++++++++++++++++++++----------------------- 1 file changed, 345 insertions(+), 325 deletions(-) diff --git a/R/draw_legend.R b/R/draw_legend.R index 88c2e2c0..6c09ee30 100644 --- a/R/draw_legend.R +++ b/R/draw_legend.R @@ -112,359 +112,379 @@ draw_legend = function( new_plot = TRUE ) { - 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 ---- + # Note: We wrap everything in recordGraphics to preserve legend spacing if + # the plot is resized + recordGraphics( + { - 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!" + 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!" + } + } + # 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 - } - - 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("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 - } - - 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 - } - - # - ## legend placement ---- - - ooma = par("oma") - omar = par("mar") - topmar_epsilon = 0.1 - - # Catch to avoid recursive offsets, e.g. repeated tinyplot calls with - # "bottom!" legend position. - - ## 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 to outer side (either right or left) of plot - if (grepl("right!$|left!$", legend_args[["x"]])) { - - outer_right = grepl("right!$", legend_args[["x"]]) + + ## 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 - # extra bump for spineplot if outer_right legend (to accommodate secondary y-axis) - if (identical(type, "spineplot")) lmar[1] = lmar[1] + 1.1 + 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 - ## 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 (!isTRUE(type %in% c("p", "ribbon", "polygon", "polypath"))) { + legend_args[["lwd"]] = legend_args[["lwd"]] %||% lwd } - } - legend_args[["horiz"]] = FALSE + if (isTRUE(type %in% c("p", "pointrange", "errorbar")) && (length(col) == 1 || length(cex) == 1)) { + legend_args[["pt.cex"]] = legend_args[["pt.cex"]] %||% cex + } - # "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) + # 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 + } - # 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 - } - par(oma = ooma) + if (isTRUE(type %in% c("ribbon", "hist", "histogram", "spineplot"))) { + legend_args[["pt.lwd"]] = legend_args[["pt.lwd"]] %||% 0 + } - # 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") - 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) + if (identical(type, "p")) { + legend_args[["pt.lwd"]] = legend_args[["pt.lwd"]] %||% lwd + } - ## Legend at the outer top or bottom of plot - } else if (grepl("bottom!$|top!$", legend_args[["x"]])) { + if (identical(type, "n") && isFALSE(gradient)) { + legend_args[["pch"]] = legend_args[["pch"]] %||% par("pch") + } - outer_bottom = grepl("bottom!$", legend_args[["x"]]) + if (identical(type, "spineplot")) { + legend_args[["pt.bg"]] = legend_args[["pt.bg"]] %||% legend_args[["col"]] + } - ## 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"]]) + 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]) + } - ## 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) + legend_args[["pt.bg"]] = legend_args[["pt.bg"]] %||% bg - # 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") + 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 placement ---- + + ooma = par("oma") + omar = par("mar") + topmar_epsilon = 0.1 + + # Catch to avoid recursive offsets, e.g. repeated tinyplot calls with + # "bottom!" legend position. + + ## 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 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) + } + } + + 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 + } + 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") + 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") - omar[1] = theme_clean$mgp[1] + 1*par("cex.lab") ## bit of a hack + 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) - } - } - - 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 + + # 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 + } else { + legend_args[["x.intersp"]] = 0.5 + } + } 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 + } else { + 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. + ## 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[["x.intersp"]] = 0.5 + legend_args[["inset"]] = 0 + if (isTRUE(new_plot)) plot.new() } - } 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)) + + # 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) } - 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 - } else { - 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. - ## 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) + 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 + ), - } else { - legend_args[["inset"]] = 0 - if (isTRUE(new_plot)) plot.new() - } + getNamespace("tinyplot") - # Finally, plot the legend. Note that we use recordGraphics to preserve the - # legend spacing if the plot is resized. - 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"]] - } - } - recordGraphics( - gradient_legend(legend_args = legend_args, lmar = lmar, outer_right = outer_right, outer_bottom = outer_bottom), - list(legend_args = legend_args, lmar = lmar, outer_right = outer_right, outer_bottom = outer_bottom), - getNamespace("tinyplot") - ) - } else { - recordGraphics( - do.call("legend", legend_args), - list(legend_args = legend_args), - getNamespace("tinyplot") - ) - } + ) } From 005730234a967258bf0962683b775e263406ce60 Mon Sep 17 00:00:00 2001 From: Grant McDermott Date: Sun, 20 Apr 2025 10:47:07 -0700 Subject: [PATCH 2/5] document draw_facet_window and use explicit arg passing --- R/facet.R | 1024 ++++++++++++++++++++++++++------------------------ R/tinyplot.R | 80 ++-- man/facet.Rd | 67 ++++ 3 files changed, 653 insertions(+), 518 deletions(-) create mode 100644 man/facet.Rd diff --git a/R/facet.R b/R/facet.R index f4aa0aab..19012a86 100644 --- a/R/facet.R +++ b/R/facet.R @@ -1,8 +1,528 @@ -# Facet layout structure -# -# This function is called by `tinyplot`. Given some inputs, it returns -# information about the layout of the facets. -# +#' Draw facet windows +#' +#' @description Internal functions called from `tinyplot` in order to draw the +#' plot window with different facets, grids, axes, etc. +#' +#' `facet_layout` determines the layout of the facets, based on a set of inputs. +#' +#' `draw_facet_window` is the main workhorse function for setting the exterior +#' plot elements as part of a `tinyplot` call, including adjustment of margins +#' for dynamic themes, etc. +#' +#' @keywords internal +#' @rdname facet +draw_facet_window = function( + # add arg first, since that determines what happens (if at all) + add, + # facet-specific args + cex_fct_adj, + facet.args, + facet_newlines, facet_font, facet_rect, facet_text, + facet_col, facet_bg, facet_border, + facet, facets, ifacet, + nfacets, nfacet_cols, nfacet_rows, + # axes args + axes, flip, frame.plot, oxaxis, oyaxis, + xlabs, xlim, xaxt, xaxs, xaxl, + ylabs, ylim, yaxt, yaxs, yaxl, + asp, log, + # other args (in approx. alphabetical + group ordering) + dots, + draw, + grid, + has_legend, + type, + x, xmax, xmin, + y, ymax, ymin + ) { + + # if add is TRUE, just return inputs without any calculations + if (isTRUE(add)) { + return(as.list(environment())) + } + + # draw background color only in the grid rectangle + grid.bg = get_tpar("grid.bg") + if (!is.null(grid.bg)) { + corners = par("usr") + rect(corners[1], corners[3], corners[2], corners[4], col = grid.bg, border = NA) + } + + ## dynamic margins flag + dynmar = isTRUE(.tpar[["dynmar"]]) + + ## optionally allow to modify the style of axis interval calculation + if (!is.null(xaxs)) par(xaxs = xaxs) + if (!is.null(yaxs)) par(yaxs = yaxs) + + if (nfacets > 1) { + # Set facet margins (i.e., gaps between facets) + if (is.null(facet.args[["fmar"]])) { + fmar = tpar("fmar") + } else { + if (length(facet.args[["fmar"]]) != 4) { + warning( + "`fmar` has to be a vector of length four, e.g.", + "`facet.args = list(fmar = c(b,l,t,r))`.", + "\n", + "Resetting to fmar = c(1,1,1,1) default.", + "\n" + ) + fmar = tpar("fmar") + } else { + fmar = facet.args[["fmar"]] + } + } + # We need to adjust for n>=3 facet cases for correct spacing... + if (nfacets >= 3) { + ## ... exception for 2x2 cases + if (!(nfacet_rows == 2 && nfacet_cols == 2)) fmar = fmar * .75 + } + # Extra reduction if no plot frame to reduce whitespace + if (isFALSE(frame.plot) && !isTRUE(facet.args[["free"]])) { + fmar = fmar - 0.5 + } + + ooma = par("oma") + + # Bump top margin down for facet titles + fmar[3] = fmar[3] + 1 + if (isTRUE(attr(facet, "facet_grid"))) { + fmar[3] = max(0, fmar[3] - 1) + # Indent for RHS facet_grid title strip if "right!" legend + if (has_legend && ooma[4] > 0) ooma[4] = ooma[4] + 1 + } + fmar[3] = fmar[3] + facet_newlines * facet_text / cex_fct_adj + + omar = par("mar") + + ## Dynamic plot margin adjustments + if (dynmar) { + if (par("las") %in% 1:2) { + # extra whitespace bump on the y axis + ## overrides for ridge and some types that use integer spacing with (named) axis labels ## FXIME + if (type == "ridge") { + yaxlabs = levels(y) + } else if (!is.null(ylabs)) { + yaxlabs = if (!is.null(names(ylabs))) names(ylabs) else ylabs + } else if (type == "boxplot" && isTRUE(flip) && !is.null(xlabs)) { + yaxlabs = if (!is.null(names(xlabs))) names(xlabs) else xlabs + } else { + # yaxl = axTicks(2) + 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 + if (whtsbp > 0) { + omar = omar + c(0, whtsbp, 0, 0) * cex_fct_adj + fmar[2] = fmar[2] + whtsbp * cex_fct_adj + } + # Extra reduction if no plot frame to reduce whitespace + if (isFALSE(frame.plot) && !isTRUE(facet.args[["free"]])) { + fmar[2] = fmar[2] - (whtsbp * cex_fct_adj) + } + } + if (par("las") %in% 2:3) { + # extra whitespace bump on the x axis + # xaxlabs = axTicks(1) + 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 + if (whtsbp > 0) { + omar = omar + c(whtsbp, 0, 0, 0) * cex_fct_adj + fmar[1] = fmar[1] + whtsbp * cex_fct_adj + } + # Extra reduction if no plot frame to reduce whitespace + if (isFALSE(frame.plot) && !isTRUE(facet.args[["free"]])) { + fmar[1] = fmar[1] - (whtsbp * cex_fct_adj) + } + } + # FIXME: Is this causing issues for lhs legends with facet_grid? + # catch for missing rhs legend + if (isTRUE(attr(facet, "facet_grid")) && !has_legend) { + omar[4] = omar[4] + 1 + } + } + + # Now we set the margins. The trick here is that we simultaneously adjust + # inner (mar) and outer (oma) margins by the same amount, but in opposite + # directions, to preserve the overall facet and plot centroids. + nmar = (fmar + .1) / cex_fct_adj + noma = (ooma + omar - fmar - .1) / cex_fct_adj + # Catch in case of negative oma values. (Probably only occurs with some + # user-supplied tpar(lmar) values and a "left!" positioned legend.) + if (any(noma < 0)) { + noma_orig = noma + noma[noma < 0] = 0 + # noma_diff = noma-noma_orig + # nmar = nmar + noma_diff + } + # apply changes + par(oma = noma) + par(mar = nmar) + + # Now that the margins have been set, arrange facet rows and columns based + # on our earlier calculations. + par(mfrow = c(nfacet_rows, nfacet_cols)) + } else if (dynmar) { + # Dynamic plot margin adjustments + omar = par("mar") + omar = omar - c(0, 0, 1, 0) # reduce top whitespace since no facet (title) + if (type == "spineplot") omar[4] = 2.1 # FIXME catch for spineplot RHS axis labs + if (par("las") %in% 1:2) { + # extra whitespace bump on the y axis + ## overrides for ridge and some types that use integer spacing with (named) axis labels ## FXIME + if (type == "ridge") { + yaxlabs = levels(y) + } else if (!is.null(ylabs)) { + yaxlabs = if (!is.null(names(ylabs))) names(ylabs) else ylabs + } else if (type == "boxplot" && isTRUE(flip) && !is.null(xlabs)) { + yaxlabs = if (!is.null(names(xlabs))) names(xlabs) else xlabs + } else { + # yaxl = axTicks(2) + 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 + if (whtsbp > 0) { + omar[2] = omar[2] + whtsbp + } + } + if (par("las") %in% 2:3) { + # extra whitespace bump on the x axis + # xaxl = axTicks(1) + 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 + if (whtsbp > 0) { + omar[1] = omar[1] + whtsbp + } + } + par(mar = omar) + } + + ## Loop over the individual facet windows and draw the plot region + ## components (axes, titles, box, grid, etc.) + for (ii in ifacet) { + # See: https://github.com/grantmcdermott/tinyplot/issues/65 + if (nfacets > 1) { + mfgi = ceiling(ii / nfacet_cols) + mfgj = ii %% nfacet_cols + if (mfgj == 0) mfgj = nfacet_cols + par(mfg = c(mfgi, mfgj)) + } + + ## Set the plot window + ## Problem: Passing extra args through ... (e.g., legend_args) to plot.window + ## triggers an annoying warning about unrecognized graphical params. + # plot.window( + # xlim = xlim, ylim = ylim, + # asp = asp, log = log, + # # ... + # ) + ## Solution: Only pass on relevant args using name checking and do.call. + ## Idea borrowed from here: https://stackoverflow.com/a/4128401/4115816 + pdots = dots[names(dots) %in% names(formals(plot.default))] + ## catch for flipped boxplots... + if (type == "boxplot" && isTRUE(flip)) { + log_flip = log + if (!is.null(log)) { + if (log == "x") log_flip = "y" + if (log == "y") log_flip = "x" + } + do.call( + "plot.window", + c(list(xlim = ylim, ylim = xlim, asp = asp, log = log_flip), pdots) + ) + xside = 2 + yside = 1 + } else { + ## ... standard plot window for all other cases + do.call( + "plot.window", + c(list(xlim = xlim, ylim = ylim, asp = asp, log = log), pdots) + ) + xside = 1 + yside = 2 + } + + + # axes, frame.plot and grid + if (isTRUE(axes) || isTRUE(facet.args[["free"]])) { + args_x = list(x, + side = xside, + type = xaxt, + labeller = xaxl, + cex = get_tpar(c("cex.xaxs", "cex.axis"), 0.8), + lwd = get_tpar(c("lwd.xaxs", "lwd.axis"), 1), + lty = get_tpar(c("lty.xaxs", "lty.axis"), 1) + ) + args_y = list(y, + side = yside, + type = yaxt, + labeller = yaxl, + cex = get_tpar(c("cex.yaxs", "cex.axis"), 0.8), + lwd = get_tpar(c("lwd.yaxs", "lwd.axis"), 1), + lty = get_tpar(c("lty.yaxs", "lty.axis"), 1) + ) + type_range_x = type %in% c("barplot", "pointrange", "errorbar", "ribbon", "boxplot", "p", "violin") && !is.null(xlabs) + type_range_y = isTRUE(flip) && type %in% c("barplot", "pointrange", "errorbar", "ribbon", "boxplot", "p", "violin") && !is.null(ylabs) + if (type_range_x) { + args_x = modifyList(args_x, list(at = xlabs, labels = names(xlabs))) + } + if (type_range_y) { + args_y = modifyList(args_y, list(at = ylabs, labels = names(ylabs))) + } + + if (isTRUE(facet.args[["free"]]) && (par("xlog") || par("ylog"))) { + warning( + "\nFree scale axes for faceted plots are currently not supported if the axes are logged. Reverting back to fixed scales.", + "\nIf support for this feature is important to you, please raise an issue on our GitHub repo:", + "\nhttps://github.com/grantmcdermott/tinyplot/issues\n" + ) + facet.args[["free"]] = FALSE + } + + # Special logic if facets are free... + if (isTRUE(facet.args[["free"]])) { + # First, we need to calculate the plot extent and axes range of each + # individual facet. + xfree = split(c(x, xmin, xmax), facet)[[ii]] + yfree = split(c(y, ymin, ymax), facet)[[ii]] + xlim = range(xfree, na.rm = TRUE) + ylim = range(yfree, na.rm = TRUE) + xext = extendrange(xlim, f = 0.04) + yext = extendrange(ylim, f = 0.04) + # We'll save this in a special .fusr env var (list) that we'll re-use + # when it comes to plotting the actual elements later + if (ii == 1) { + fusr = replicate(4, vector("double", length = nfacets), simplify = FALSE) + assign(".fusr", fusr, envir = get(".tinyplot_env", envir = parent.env(environment()))) + } + fusr = get(".fusr", envir = get(".tinyplot_env", envir = parent.env(environment()))) + fusr[[ii]] = c(xext, yext) + assign(".fusr", fusr, envir = get(".tinyplot_env", envir = parent.env(environment()))) + # Explicitly set (override) the current facet extent + par(usr = fusr[[ii]]) + # if plot frame is true then print axes per normal... + if (type %in% c("barplot", "pointrange", "errorbar", "ribbon", "boxplot", "p", "violin") && !is.null(xlabs)) { + tinyAxis(xfree, side = xside, at = xlabs, labels = names(xlabs), type = xaxt) + } else { + tinyAxis(xfree, side = xside, type = xaxt) + } + 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) + } else { + tinyAxis(yfree, side = yside, type = yaxt) + } + + # 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) + do.call(tinyAxis, args_y) + } else { + # ... else only print the "outside" axes. + if (ii %in% oxaxis) do.call(tinyAxis, args_x) + if (ii %in% oyaxis) do.call(tinyAxis, args_y) + } + } + + # facet titles + ## Note: facet titles could be done more simply with mtext... but then we + ## couldn't adjust background features (e.g., fill), or rotate the rhs + ## facet grid text. So we're rolling our own "manual" versions with text + ## and rect. + if (!is.null(facet)) { + # Get the four corners of plot area (x1, x2, y1, y2) + corners = par("usr") + # catch for logged axes + xlog = isTRUE(par("xlog")) + ylog = isTRUE(par("ylog")) + if (xlog) corners[1:2] = 10^(corners[1:2]) + if (ylog) corners[3:4] = 10^(corners[3:4]) + # special logic for facet grids + if (is.null(facet_newlines) || facet_newlines == 0) { + facet_title_lines = 1 + } else { + facet_title_lines = 1 + facet_newlines + } + # different logic for facet grids versus regular facets + if (isTRUE(attr(facet, "facet_grid"))) { + ## top facet strips + if (ii %in% 1:nfacet_cols) { + if (isTRUE(facet_rect)) { + line_height = (facet_title_lines + .1) * facet_text / cex_fct_adj + if (ylog) { + line_height = grconvertY(line_height, from = "lines", to = "user") / grconvertY(0, from = "lines", to = "user") + rect_height = corners[4] * line_height + } else { + line_height = grconvertY(line_height, from = "lines", to = "user") - grconvertY(0, from = "lines", to = "user") + rect_height = corners[4] + line_height + } + rect( + corners[1], corners[4], corners[2], rect_height, + col = facet_bg, border = facet_border, + xpd = NA + ) + } + xpos = if (xlog) 10^(mean(log10(corners[1:2]))) else mean(corners[1:2]) + if (ylog) { + ypos = grconvertY(0.4, from = "lines", to = "user") / grconvertY(0, from = "lines", to = "user") + ypos = corners[4] * ypos + } else { + ypos = grconvertY(0.4, from = "lines", to = "user") - grconvertY(0, from = "lines", to = "user") + ypos = corners[4] + ypos + } + text( + x = xpos, + y = ypos, + labels = sub("^(.*?)~.*", "\\1", facets[[ii]]), + adj = c(0.5, 0), + cex = facet_text / cex_fct_adj, + col = facet_col, + font = facet_font, + xpd = NA, + ) + } + ## right facet strips + if (ii %% nfacet_cols == 0 || ii == nfacets) { + if (isTRUE(facet_rect)) { + line_height = (facet_title_lines + .1) * facet_text / cex_fct_adj + if (xlog) { + line_height = grconvertX(line_height, from = "lines", to = "user") / grconvertX(0, from = "lines", to = "user") + rect_width = corners[2] * line_height + } else { + line_height = grconvertX(line_height, from = "lines", to = "user") - grconvertX(0, from = "lines", to = "user") + rect_width = corners[2] + line_height + } + rect( + corners[2], corners[3], rect_width, corners[4], + col = facet_bg, border = facet_border, + xpd = NA + ) + } + if (xlog) { + xpos = grconvertX(0.4, from = "lines", to = "user") / grconvertX(0, from = "lines", to = "user") + xpos = corners[2] * xpos + } else { + xpos = grconvertX(0.4, from = "lines", to = "user") - grconvertX(0, from = "lines", to = "user") + xpos = corners[2] + xpos + } + ypos = if (ylog) 10^(mean(log10(corners[3:4]))) else mean(corners[3:4]) + text( + x = xpos, + y = ypos, + labels = sub("^.*?~(.*)", "\\1", facets[[ii]]), + srt = 270, + adj = c(0.5, 0), + cex = facet_text / cex_fct_adj, + col = facet_col, + font = facet_font, + xpd = NA + ) + } + } else { + if (isTRUE(facet_rect)) { + line_height = (facet_title_lines + .1) * facet_text / cex_fct_adj + if (ylog) { + line_height = grconvertY(line_height, from = "lines", to = "user") / grconvertY(0, from = "lines", to = "user") + rect_height = corners[4] * line_height + } else { + line_height = grconvertY(line_height, from = "lines", to = "user") - grconvertY(0, from = "lines", to = "user") + rect_height = corners[4] + line_height + } + rect( + corners[1], corners[4], corners[2], rect_height, + col = facet_bg, border = facet_border, + xpd = NA + ) + } + xpos = if (xlog) 10^(mean(log10(corners[1:2]))) else mean(corners[1:2]) + if (ylog) { + ypos = grconvertY(0.4, from = "lines", to = "user") / grconvertY(0, from = "lines", to = "user") + ypos = corners[4] * ypos + } else { + ypos = grconvertY(0.4, from = "lines", to = "user") - grconvertY(0, from = "lines", to = "user") + ypos = corners[4] + ypos + } + text( + x = xpos, + y = ypos, + labels = paste(facets[[ii]]), + adj = c(0.5, 0), + cex = facet_text / cex_fct_adj, + col = facet_col, + font = facet_font, + xpd = NA + ) + } + } + + # plot frame + if (frame.plot) box() + + # panel grid lines + if (is.null(grid)) grid = .tpar[["grid"]] + if (!is.null(grid)) { + if (is.logical(grid)) { + ## If grid is TRUE create a default grid. Rather than just calling the default grid() + ## abline(... = pretty(extendrange(...)), ...) is used. Reason: pretty() is generic + ## and works better for axes based on date/time classes. Exception: For axes in logs, + ## resort to using grid() which is likely better handled there. + if (isTRUE(grid)) { + gnx = gny = NULL + if (!any(c(par("xlog"), type == "boxplot"))) { + if (!inherits(x, c("POSIXt", "Date"))) { + xg = pretty(xlim) + } else { + # Catch for datetime (since xlim has been coerced to numeric) + tz = attributes(x)[["tzone"]] + if (inherits(x, "POSIXt")) { + xg = pretty(as.POSIXct(extendrange(xlim), tz = tz)) + } else { + xg = pretty(as.Date(round(extendrange(xlim)), tz = tz)) + } + } + abline(v = xg, col = .tpar[["grid.col"]], lty = .tpar[["grid.lty"]], lwd = .tpar[["grid.lwd"]]) + gnx = NA + } + if (!any(c(par("ylog"), type == "boxplot"))) { + if (!inherits(y, c("POSIXt", "Date"))) { + yg = pretty(ylim) + } else { + # Catch for datetime (since xlim has been coerced to numeric) + tz = attributes(y)[["tzone"]] + if (inherits(x, "POSIXt")) { + yg = pretty(as.POSIXct(extendrange(ylim), tz = tz)) + } else { + yg = pretty(as.Date(extendrange(ylim), tz = tz)) + } + } + abline(h = yg, col = .tpar[["grid.col"]], lty = .tpar[["grid.lty"]], lwd = .tpar[["grid.lwd"]]) + gny = NA + } + grid(nx = gnx, ny = gny, col = .tpar[["grid.col"]], lty = .tpar[["grid.lty"]], lwd = .tpar[["grid.lwd"]]) + } + } else { + grid + } + } + + # add any drawn elements + if (!is.null(draw)) eval(draw) + } # end of ii facet loop + + return(as.list(environment())) +} + + +#' @rdname facet +#' @keywords internal facet_layout = function(facet, add = FALSE, facet.args = list()) { nfacet_rows = 1 nfacet_cols = 1 @@ -64,8 +584,12 @@ facet_layout = function(facet, add = FALSE, facet.args = list()) { -# utility function for converting facet formulas into variables +# +# helper functions +# + +# utility function for converting facet formulas into variables get_facet_fml = function(formula, data = NULL) { xfacet = yfacet = NULL @@ -114,494 +638,6 @@ get_facet_fml = function(formula, data = NULL) { } - - -# internal function to draw window with different facets, grids, axes, etc. - -draw_facet_window = function(grid, ...) { - list2env(list(...), environment()) - - # draw background color only in the grid rectangle - grid.bg = get_tpar("grid.bg") - if (!is.null(grid.bg)) { - corners = par("usr") - rect(corners[1], corners[3], corners[2], corners[4], col = grid.bg, border = NA) - } - - ## dynamic margins flag - dynmar = isTRUE(.tpar[["dynmar"]]) - - if (isFALSE(add)) { - ## optionally allow to modify the style of axis interval calculation - if (!is.null(xaxs)) par(xaxs = xaxs) - if (!is.null(yaxs)) par(yaxs = yaxs) - - if (nfacets > 1) { - # Set facet margins (i.e., gaps between facets) - if (is.null(facet.args[["fmar"]])) { - fmar = tpar("fmar") - } else { - if (length(facet.args[["fmar"]]) != 4) { - warning( - "`fmar` has to be a vector of length four, e.g.", - "`facet.args = list(fmar = c(b,l,t,r))`.", - "\n", - "Resetting to fmar = c(1,1,1,1) default.", - "\n" - ) - fmar = tpar("fmar") - } else { - fmar = facet.args[["fmar"]] - } - } - # We need to adjust for n>=3 facet cases for correct spacing... - if (nfacets >= 3) { - ## ... exception for 2x2 cases - if (!(nfacet_rows == 2 && nfacet_cols == 2)) fmar = fmar * .75 - } - # Extra reduction if no plot frame to reduce whitespace - if (isFALSE(frame.plot) && !isTRUE(facet.args[["free"]])) { - fmar = fmar - 0.5 - } - - ooma = par("oma") - - # Bump top margin down for facet titles - fmar[3] = fmar[3] + 1 - if (isTRUE(attr(facet, "facet_grid"))) { - fmar[3] = max(0, fmar[3] - 1) - # Indent for RHS facet_grid title strip if "right!" legend - if (has_legend && ooma[4] > 0) ooma[4] = ooma[4] + 1 - } - fmar[3] = fmar[3] + facet_newlines * facet_text / cex_fct_adj - - omar = par("mar") - - ## Dynamic plot margin adjustments - if (dynmar) { - if (par("las") %in% 1:2) { - # extra whitespace bump on the y axis - ## overrides for ridge and some types that use integer spacing with (named) axis labels ## FXIME - if (type == "ridge") { - yaxlabs = levels(y) - } else if (!is.null(ylabs)) { - yaxlabs = if (!is.null(names(ylabs))) names(ylabs) else ylabs - } else if (type == "boxplot" && isTRUE(flip) && !is.null(xlabs)) { - yaxlabs = if (!is.null(names(xlabs))) names(xlabs) else xlabs - } else { - # yaxl = axTicks(2) - 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 - if (whtsbp > 0) { - omar = omar + c(0, whtsbp, 0, 0) * cex_fct_adj - fmar[2] = fmar[2] + whtsbp * cex_fct_adj - } - # Extra reduction if no plot frame to reduce whitespace - if (isFALSE(frame.plot) && !isTRUE(facet.args[["free"]])) { - fmar[2] = fmar[2] - (whtsbp * cex_fct_adj) - } - } - if (par("las") %in% 2:3) { - # extra whitespace bump on the x axis - # xaxlabs = axTicks(1) - 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 - if (whtsbp > 0) { - omar = omar + c(whtsbp, 0, 0, 0) * cex_fct_adj - fmar[1] = fmar[1] + whtsbp * cex_fct_adj - } - # Extra reduction if no plot frame to reduce whitespace - if (isFALSE(frame.plot) && !isTRUE(facet.args[["free"]])) { - fmar[1] = fmar[1] - (whtsbp * cex_fct_adj) - } - } - # FIXME: Is this causing issues for lhs legends with facet_grid? - # catch for missing rhs legend - if (isTRUE(attr(facet, "facet_grid")) && !has_legend) { - omar[4] = omar[4] + 1 - } - } - - # Now we set the margins. The trick here is that we simultaneously adjust - # inner (mar) and outer (oma) margins by the same amount, but in opposite - # directions, to preserve the overall facet and plot centroids. - nmar = (fmar + .1) / cex_fct_adj - noma = (ooma + omar - fmar - .1) / cex_fct_adj - # Catch in case of negative oma values. (Probably only occurs with some - # user-supplied tpar(lmar) values and a "left!" positioned legend.) - if (any(noma < 0)) { - noma_orig = noma - noma[noma < 0] = 0 - # noma_diff = noma-noma_orig - # nmar = nmar + noma_diff - } - # apply changes - par(oma = noma) - par(mar = nmar) - - # Now that the margins have been set, arrange facet rows and columns based - # on our earlier calculations. - par(mfrow = c(nfacet_rows, nfacet_cols)) - } else if (dynmar) { - # Dynamic plot margin adjustments - omar = par("mar") - omar = omar - c(0, 0, 1, 0) # reduce top whitespace since no facet (title) - if (type == "spineplot") omar[4] = 2.1 # FIXME catch for spineplot RHS axis labs - if (par("las") %in% 1:2) { - # extra whitespace bump on the y axis - ## overrides for ridge and some types that use integer spacing with (named) axis labels ## FXIME - if (type == "ridge") { - yaxlabs = levels(y) - } else if (!is.null(ylabs)) { - yaxlabs = if (!is.null(names(ylabs))) names(ylabs) else ylabs - } else if (type == "boxplot" && isTRUE(flip) && !is.null(xlabs)) { - yaxlabs = if (!is.null(names(xlabs))) names(xlabs) else xlabs - } else { - # yaxl = axTicks(2) - 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 - if (whtsbp > 0) { - omar[2] = omar[2] + whtsbp - } - } - if (par("las") %in% 2:3) { - # extra whitespace bump on the x axis - # xaxl = axTicks(1) - 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 - if (whtsbp > 0) { - omar[1] = omar[1] + whtsbp - } - } - par(mar = omar) - } - - ## Loop over the individual facet windows and draw the plot region - ## components (axes, titles, box, grid, etc.) - for (ii in ifacet) { - # See: https://github.com/grantmcdermott/tinyplot/issues/65 - if (nfacets > 1) { - mfgi = ceiling(ii / nfacet_cols) - mfgj = ii %% nfacet_cols - if (mfgj == 0) mfgj = nfacet_cols - par(mfg = c(mfgi, mfgj)) - } - - ## Set the plot window - ## Problem: Passing extra args through ... (e.g., legend_args) to plot.window - ## triggers an annoying warning about unrecognized graphical params. - # plot.window( - # xlim = xlim, ylim = ylim, - # asp = asp, log = log, - # # ... - # ) - ## Solution: Only pass on relevant args using name checking and do.call. - ## Idea borrowed from here: https://stackoverflow.com/a/4128401/4115816 - pdots = dots[names(dots) %in% names(formals(plot.default))] - ## catch for flipped boxplots... - if (type == "boxplot" && isTRUE(flip)) { - log_flip = log - if (!is.null(log)) { - if (log == "x") log_flip = "y" - if (log == "y") log_flip = "x" - } - do.call( - "plot.window", - c(list(xlim = ylim, ylim = xlim, asp = asp, log = log_flip), pdots) - ) - xside = 2 - yside = 1 - } else { - ## ... standard plot window for all other cases - do.call( - "plot.window", - c(list(xlim = xlim, ylim = ylim, asp = asp, log = log), pdots) - ) - xside = 1 - yside = 2 - } - - - # axes, frame.plot and grid - if (isTRUE(axes) || isTRUE(facet.args[["free"]])) { - args_x = list(x, - side = xside, - type = xaxt, - labeller = xaxl, - cex = get_tpar(c("cex.xaxs", "cex.axis"), 0.8), - lwd = get_tpar(c("lwd.xaxs", "lwd.axis"), 1), - lty = get_tpar(c("lty.xaxs", "lty.axis"), 1) - ) - args_y = list(y, - side = yside, - type = yaxt, - labeller = yaxl, - cex = get_tpar(c("cex.yaxs", "cex.axis"), 0.8), - lwd = get_tpar(c("lwd.yaxs", "lwd.axis"), 1), - lty = get_tpar(c("lty.yaxs", "lty.axis"), 1) - ) - type_range_x = type %in% c("barplot", "pointrange", "errorbar", "ribbon", "boxplot", "p", "violin") && !is.null(xlabs) - type_range_y = isTRUE(flip) && type %in% c("barplot", "pointrange", "errorbar", "ribbon", "boxplot", "p", "violin") && !is.null(ylabs) - if (type_range_x) { - args_x = modifyList(args_x, list(at = xlabs, labels = names(xlabs))) - } - if (type_range_y) { - args_y = modifyList(args_y, list(at = ylabs, labels = names(ylabs))) - } - - if (isTRUE(facet.args[["free"]]) && (par("xlog") || par("ylog"))) { - warning( - "\nFree scale axes for faceted plots are currently not supported if the axes are logged. Reverting back to fixed scales.", - "\nIf support for this feature is important to you, please raise an issue on our GitHub repo:", - "\nhttps://github.com/grantmcdermott/tinyplot/issues\n" - ) - facet.args[["free"]] = FALSE - } - - # Special logic if facets are free... - if (isTRUE(facet.args[["free"]])) { - # First, we need to calculate the plot extent and axes range of each - # individual facet. - xfree = split(c(x, xmin, xmax), facet)[[ii]] - yfree = split(c(y, ymin, ymax), facet)[[ii]] - xlim = range(xfree, na.rm = TRUE) - ylim = range(yfree, na.rm = TRUE) - xext = extendrange(xlim, f = 0.04) - yext = extendrange(ylim, f = 0.04) - # We'll save this in a special .fusr env var (list) that we'll re-use - # when it comes to plotting the actual elements later - if (ii == 1) { - fusr = replicate(4, vector("double", length = nfacets), simplify = FALSE) - assign(".fusr", fusr, envir = get(".tinyplot_env", envir = parent.env(environment()))) - } - fusr = get(".fusr", envir = get(".tinyplot_env", envir = parent.env(environment()))) - fusr[[ii]] = c(xext, yext) - assign(".fusr", fusr, envir = get(".tinyplot_env", envir = parent.env(environment()))) - # Explicitly set (override) the current facet extent - par(usr = fusr[[ii]]) - # if plot frame is true then print axes per normal... - if (type %in% c("barplot", "pointrange", "errorbar", "ribbon", "boxplot", "p", "violin") && !is.null(xlabs)) { - tinyAxis(xfree, side = xside, at = xlabs, labels = names(xlabs), type = xaxt) - } else { - tinyAxis(xfree, side = xside, type = xaxt) - } - 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) - } else { - tinyAxis(yfree, side = yside, type = yaxt) - } - - # 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) - do.call(tinyAxis, args_y) - } else { - # ... else only print the "outside" axes. - if (ii %in% oxaxis) do.call(tinyAxis, args_x) - if (ii %in% oyaxis) do.call(tinyAxis, args_y) - } - } - - # facet titles - ## Note: facet titles could be done more simply with mtext... but then we - ## couldn't adjust background features (e.g., fill), or rotate the rhs - ## facet grid text. So we're rolling our own "manual" versions with text - ## and rect. - if (!is.null(facet)) { - # Get the four corners of plot area (x1, x2, y1, y2) - corners = par("usr") - # catch for logged axes - xlog = isTRUE(par("xlog")) - ylog = isTRUE(par("ylog")) - if (xlog) corners[1:2] = 10^(corners[1:2]) - if (ylog) corners[3:4] = 10^(corners[3:4]) - # special logic for facet grids - if (is.null(facet_newlines) || facet_newlines == 0) { - facet_title_lines = 1 - } else { - facet_title_lines = 1 + facet_newlines - } - # different logic for facet grids versus regular facets - if (isTRUE(attr(facet, "facet_grid"))) { - ## top facet strips - if (ii %in% 1:nfacet_cols) { - if (isTRUE(facet_rect)) { - line_height = (facet_title_lines + .1) * facet_text / cex_fct_adj - if (ylog) { - line_height = grconvertY(line_height, from = "lines", to = "user") / grconvertY(0, from = "lines", to = "user") - rect_height = corners[4] * line_height - } else { - line_height = grconvertY(line_height, from = "lines", to = "user") - grconvertY(0, from = "lines", to = "user") - rect_height = corners[4] + line_height - } - rect( - corners[1], corners[4], corners[2], rect_height, - col = facet_bg, border = facet_border, - xpd = NA - ) - } - xpos = if (xlog) 10^(mean(log10(corners[1:2]))) else mean(corners[1:2]) - if (ylog) { - ypos = grconvertY(0.4, from = "lines", to = "user") / grconvertY(0, from = "lines", to = "user") - ypos = corners[4] * ypos - } else { - ypos = grconvertY(0.4, from = "lines", to = "user") - grconvertY(0, from = "lines", to = "user") - ypos = corners[4] + ypos - } - text( - x = xpos, - y = ypos, - labels = sub("^(.*?)~.*", "\\1", facets[[ii]]), - adj = c(0.5, 0), - cex = facet_text / cex_fct_adj, - col = facet_col, - font = facet_font, - xpd = NA, - ) - } - ## right facet strips - if (ii %% nfacet_cols == 0 || ii == nfacets) { - if (isTRUE(facet_rect)) { - line_height = (facet_title_lines + .1) * facet_text / cex_fct_adj - if (xlog) { - line_height = grconvertX(line_height, from = "lines", to = "user") / grconvertX(0, from = "lines", to = "user") - rect_width = corners[2] * line_height - } else { - line_height = grconvertX(line_height, from = "lines", to = "user") - grconvertX(0, from = "lines", to = "user") - rect_width = corners[2] + line_height - } - rect( - corners[2], corners[3], rect_width, corners[4], - col = facet_bg, border = facet_border, - xpd = NA - ) - } - if (xlog) { - xpos = grconvertX(0.4, from = "lines", to = "user") / grconvertX(0, from = "lines", to = "user") - xpos = corners[2] * xpos - } else { - xpos = grconvertX(0.4, from = "lines", to = "user") - grconvertX(0, from = "lines", to = "user") - xpos = corners[2] + xpos - } - ypos = if (ylog) 10^(mean(log10(corners[3:4]))) else mean(corners[3:4]) - text( - x = xpos, - y = ypos, - labels = sub("^.*?~(.*)", "\\1", facets[[ii]]), - srt = 270, - adj = c(0.5, 0), - cex = facet_text / cex_fct_adj, - col = facet_col, - font = facet_font, - xpd = NA - ) - } - } else { - if (isTRUE(facet_rect)) { - line_height = (facet_title_lines + .1) * facet_text / cex_fct_adj - if (ylog) { - line_height = grconvertY(line_height, from = "lines", to = "user") / grconvertY(0, from = "lines", to = "user") - rect_height = corners[4] * line_height - } else { - line_height = grconvertY(line_height, from = "lines", to = "user") - grconvertY(0, from = "lines", to = "user") - rect_height = corners[4] + line_height - } - rect( - corners[1], corners[4], corners[2], rect_height, - col = facet_bg, border = facet_border, - xpd = NA - ) - } - xpos = if (xlog) 10^(mean(log10(corners[1:2]))) else mean(corners[1:2]) - if (ylog) { - ypos = grconvertY(0.4, from = "lines", to = "user") / grconvertY(0, from = "lines", to = "user") - ypos = corners[4] * ypos - } else { - ypos = grconvertY(0.4, from = "lines", to = "user") - grconvertY(0, from = "lines", to = "user") - ypos = corners[4] + ypos - } - text( - x = xpos, - y = ypos, - labels = paste(facets[[ii]]), - adj = c(0.5, 0), - cex = facet_text / cex_fct_adj, - col = facet_col, - font = facet_font, - xpd = NA - ) - } - } - - # plot frame - if (frame.plot) box() - - # panel grid lines - if (is.null(grid)) grid = .tpar[["grid"]] - if (!is.null(grid)) { - if (is.logical(grid)) { - ## If grid is TRUE create a default grid. Rather than just calling the default grid() - ## abline(... = pretty(extendrange(...)), ...) is used. Reason: pretty() is generic - ## and works better for axes based on date/time classes. Exception: For axes in logs, - ## resort to using grid() which is likely better handled there. - if (isTRUE(grid)) { - gnx = gny = NULL - if (!any(c(par("xlog"), type == "boxplot"))) { - if (!inherits(x, c("POSIXt", "Date"))) { - xg = pretty(xlim) - } else { - # Catch for datetime (since xlim has been coerced to numeric) - tz = attributes(x)[["tzone"]] - if (inherits(x, "POSIXt")) { - xg = pretty(as.POSIXct(extendrange(xlim), tz = tz)) - } else { - xg = pretty(as.Date(round(extendrange(xlim)), tz = tz)) - } - } - abline(v = xg, col = .tpar[["grid.col"]], lty = .tpar[["grid.lty"]], lwd = .tpar[["grid.lwd"]]) - gnx = NA - } - if (!any(c(par("ylog"), type == "boxplot"))) { - if (!inherits(y, c("POSIXt", "Date"))) { - yg = pretty(ylim) - } else { - # Catch for datetime (since xlim has been coerced to numeric) - tz = attributes(y)[["tzone"]] - if (inherits(x, "POSIXt")) { - yg = pretty(as.POSIXct(extendrange(ylim), tz = tz)) - } else { - yg = pretty(as.Date(extendrange(ylim), tz = tz)) - } - } - abline(h = yg, col = .tpar[["grid.col"]], lty = .tpar[["grid.lty"]], lwd = .tpar[["grid.lwd"]]) - gny = NA - } - grid(nx = gnx, ny = gny, col = .tpar[["grid.col"]], lty = .tpar[["grid.lty"]], lwd = .tpar[["grid.lwd"]]) - } - } else { - grid - } - } - - # drawn elements - if (!is.null(draw)) eval(draw) - } # end of ii facet loop - } # end of add check - - return(as.list(environment())) -} - ## internal convenience function to determine whether the current facet panel ## has the position "left", "right", "top", or "bottom" in the facet grid is_facet_position = function(position, ifacet, facet_window_args) { diff --git a/R/tinyplot.R b/R/tinyplot.R index 16fbd8f5..d5aadd25 100644 --- a/R/tinyplot.R +++ b/R/tinyplot.R @@ -592,6 +592,7 @@ tinyplot.default = function( height = NULL, asp = NA, ...) { + par_first = get_saved_par("first") if (is.null(par_first)) set_saved_par("first", par()) @@ -1090,7 +1091,7 @@ tinyplot.default = function( } # - ## Facet windows + ## Exterior plot elements (plot and facet windows, axes, etc.) # omar = NULL # Placeholder variable for now, which we re-assign as part of facet margins @@ -1130,28 +1131,59 @@ tinyplot.default = function( } # Now draw the individual facet windows (incl. axes, grid lines, and facet titles) - # Skip if adding to an existing plot - - facet_window_args = draw_facet_window( - add = add, asp = asp, axes = axes, cex_fct_adj = cex_fct_adj, dots = dots, - facet = datapoints$facet, facet.args = facet.args, facet_newlines = facet_newlines, - facet_rect = facet_rect, facet_text = facet_text, facet_font = facet_font, - facet_col = facet_col, facet_bg = facet_bg, facet_border = facet_border, - facets = facets, ifacet = ifacet, - nfacet_cols = nfacet_cols, nfacet_rows = nfacet_rows, nfacets = nfacets, - frame.plot = frame.plot, grid = grid, - has_legend = has_legend, log = log, - oxaxis = oxaxis, oyaxis = oyaxis, type = type, - x = datapoints$x, - y = datapoints$y, - xmax = datapoints$xmax, xmin = datapoints$xmin, - ymax = datapoints$ymax, ymin = datapoints$ymin, - xlabs = xlabs, xlim = xlim, - ylabs = ylabs, ylim = ylim, - xaxt = xaxt, xaxs = xaxs, xaxl = xaxl, - yaxt = yaxt, yaxs = yaxs, yaxl = yaxl, - flip = flip, - draw = draw + # Will be skipped if adding to an existing plot; see ?facet + + facet_window_args = recordGraphics( + draw_facet_window( + add = add, + # facet-specific args + cex_fct_adj = cex_fct_adj, + facet.args = facet.args, + facet_newlines = facet_newlines, facet_font = facet_font, + facet_rect = facet_rect, facet_text = facet_text, + facet_col = facet_col, facet_bg = facet_bg, facet_border = facet_border, + facet = facet, + facets = facets, ifacet = ifacet, + nfacets = nfacets, nfacet_cols = nfacet_cols, nfacet_rows = nfacet_rows, + # axes args + axes = axes, flip = flip, frame.plot = frame.plot, + oxaxis = oxaxis, oyaxis = oyaxis, + xlabs = xlabs, xlim = xlim, xaxt = xaxt, xaxs = xaxs, xaxl = xaxl, + ylabs = ylabs, ylim = ylim, yaxt = yaxt, yaxs = yaxs, yaxl = yaxl, + asp = asp, log = log, + # other args (in approx. alphabetical + group ordering) + dots = dots, + draw = draw, + grid = grid, + has_legend = has_legend, + type = type, + x = x, xmax = xmax, xmin = xmin, + y = y, ymax = ymax, ymin = ymin + ), + list = list( + add = add, + cex_fct_adj = cex_fct_adj, + facet.args = facet.args, + facet_newlines = facet_newlines, facet_font = facet_font, + facet_rect = facet_rect, facet_text = facet_text, + facet_col = facet_col, facet_bg = facet_bg, facet_border = facet_border, + facet = datapoints$facet, + facets = facets, ifacet = ifacet, + nfacets = nfacets, nfacet_cols = nfacet_cols, nfacet_rows = nfacet_rows, + axes = axes, flip = flip, frame.plot = frame.plot, + oxaxis = oxaxis, oyaxis = oyaxis, + xlabs = xlabs, xlim = xlim, xaxt = xaxt, xaxs = xaxs, xaxl = xaxl, + ylabs = ylabs, ylim = ylim, yaxt = yaxt, yaxs = yaxs, yaxl = yaxl, + asp = asp, log = log, + dots = dots, + draw = draw, + grid = grid, + has_legend = has_legend, + type = type, + x = datapoints$x, xmax = datapoints$xmax, xmin = datapoints$xmin, + y = datapoints$y, ymax = datapoints$ymax, ymin = datapoints$ymin + ), + getNamespace("tinyplot") ) list2env(facet_window_args, environment()) @@ -1288,7 +1320,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/facet.Rd b/man/facet.Rd new file mode 100644 index 00000000..da461aea --- /dev/null +++ b/man/facet.Rd @@ -0,0 +1,67 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/facet.R +\name{draw_facet_window} +\alias{draw_facet_window} +\alias{facet_layout} +\title{Draw facet windows} +\usage{ +draw_facet_window( + add, + cex_fct_adj, + facet.args, + facet_newlines, + facet_font, + facet_rect, + facet_text, + facet_col, + facet_bg, + facet_border, + facet, + facets, + ifacet, + nfacets, + nfacet_cols, + nfacet_rows, + axes, + flip, + frame.plot, + oxaxis, + oyaxis, + xlabs, + xlim, + xaxt, + xaxs, + xaxl, + ylabs, + ylim, + yaxt, + yaxs, + yaxl, + asp, + log, + dots, + draw, + grid, + has_legend, + type, + x, + xmax, + xmin, + y, + ymax, + ymin +) + +facet_layout(facet, add = FALSE, facet.args = list()) +} +\description{ +Internal functions called from \code{tinyplot} in order to draw the +plot window with different facets, grids, axes, etc. + +\code{facet_layout} determines the layout of the facets, based on a set of inputs. + +\code{draw_facet_window} is the main workhorse function for setting the exterior +plot elements as part of a \code{tinyplot} call, including adjustment of margins +for dynamic themes, etc. +} +\keyword{internal} From 810b7feea6c2db0128d12625617c01e86dc336b2 Mon Sep 17 00:00:00 2001 From: Grant McDermott Date: Sun, 20 Apr 2025 21:57:24 -0700 Subject: [PATCH 3/5] better matching grid and tinyAxis logic - update tinyAxis to account for DateTime to --- R/facet.R | 24 ++---------------------- R/tinyAxis.R | 23 ++++++++++++++--------- 2 files changed, 16 insertions(+), 31 deletions(-) diff --git a/R/facet.R b/R/facet.R index 19012a86..9b8fbcb5 100644 --- a/R/facet.R +++ b/R/facet.R @@ -477,32 +477,12 @@ draw_facet_window = function( if (isTRUE(grid)) { gnx = gny = NULL if (!any(c(par("xlog"), type == "boxplot"))) { - if (!inherits(x, c("POSIXt", "Date"))) { - xg = pretty(xlim) - } else { - # Catch for datetime (since xlim has been coerced to numeric) - tz = attributes(x)[["tzone"]] - if (inherits(x, "POSIXt")) { - xg = pretty(as.POSIXct(extendrange(xlim), tz = tz)) - } else { - xg = pretty(as.Date(round(extendrange(xlim)), tz = tz)) - } - } + xg = if (!inherits(x, c("POSIXt", "Date"))) axTicks(side = 1) else axTicksDateTime(side = 1, x = x) abline(v = xg, col = .tpar[["grid.col"]], lty = .tpar[["grid.lty"]], lwd = .tpar[["grid.lwd"]]) gnx = NA } if (!any(c(par("ylog"), type == "boxplot"))) { - if (!inherits(y, c("POSIXt", "Date"))) { - yg = pretty(ylim) - } else { - # Catch for datetime (since xlim has been coerced to numeric) - tz = attributes(y)[["tzone"]] - if (inherits(x, "POSIXt")) { - yg = pretty(as.POSIXct(extendrange(ylim), tz = tz)) - } else { - yg = pretty(as.Date(extendrange(ylim), tz = tz)) - } - } + yg = if (!inherits(y, c("POSIXt", "Date"))) axTicks(side = 2) else axTicksDateTime(side = 2, x = x) abline(h = yg, col = .tpar[["grid.col"]], lty = .tpar[["grid.lty"]], lwd = .tpar[["grid.lwd"]]) gny = NA } diff --git a/R/tinyAxis.R b/R/tinyAxis.R index 674eab05..d71b0aba 100644 --- a/R/tinyAxis.R +++ b/R/tinyAxis.R @@ -21,8 +21,7 @@ tinyAxis = function(x = NULL, ..., type = "standard", labeller = NULL) { if (!is.null(args$at)) { args$labels = if (!is.null(args$labels)) tinylabel(args$labels, labeller) else tinylabel(args$at, labeller) } else { - # FIXME: log ? - args$at = if (!inherits(x, c("POSIXt", "Date"))) axTicks(args$side) else axTicksDate(args$side) + args$at = if (!inherits(x, c("POSIXt", "Date"))) axTicks(args$side) else axTicksDateTime(args$side) args$labels = tinylabel(args$at, labeller) } } @@ -31,13 +30,19 @@ tinyAxis = function(x = NULL, ..., type = "standard", labeller = NULL) { } # Special case for Date-Time, adapted/simplified from axis.date() -axTicksDate = function(side, x, ...) { - range = sort(par("usr")[if (side%%2) 1L:2L else 3:4L]) - range[1L] = ceiling(range[1L]) - range[2L] = floor(range[2L]) - rangeDate = range - class(rangeDate) = "Date" - z = pretty(rangeDate, n = par("lab")[2 - side%%2]) +axTicksDateTime = function(side, x, ...) { + if (inherits(x, "POSIXt")) { + tz = attr(x, "tz") + range = extendrange(x) + rangeDateTime = .POSIXct(range, tz = tz) + } else { + range = sort(par("usr")[if (side%%2) 1L:2L else 3:4L]) + range[1L] = ceiling(range[1L]) + range[2L] = floor(range[2L]) + rangeDateTime = range + class(rangeDateTime) = "Date" + } + z = pretty(rangeDateTime, n = par("lab")[2 - side%%2]) keep = z >= range[1L] & z <= range[2L] z = z[keep] return(z) From 364c8538e238106f17d4a507e73d3598b909ba15 Mon Sep 17 00:00:00 2001 From: Grant McDermott Date: Sun, 20 Apr 2025 21:57:40 -0700 Subject: [PATCH 4/5] update tests --- inst/tinytest/_tinysnapshot/flip_facet_by.svg | 27 +++++++++---------- inst/tinytest/_tinysnapshot/flip_p.svg | 5 +++- inst/tinytest/_tinysnapshot/flip_p_logx.svg | 5 +++- .../_tinysnapshot/tinytheme_dynamic_clean.svg | 5 +++- .../_tinysnapshot/tinytheme_dynamic_dark.svg | 5 +++- inst/tinytest/test-restore_par.R | 2 +- 6 files changed, 29 insertions(+), 20 deletions(-) diff --git a/inst/tinytest/_tinysnapshot/flip_facet_by.svg b/inst/tinytest/_tinysnapshot/flip_facet_by.svg index de664799..d96c39d3 100644 --- a/inst/tinytest/_tinysnapshot/flip_facet_by.svg +++ b/inst/tinytest/_tinysnapshot/flip_facet_by.svg @@ -80,10 +80,13 @@ - + + + + @@ -134,10 +137,13 @@ - + + + + @@ -188,10 +194,13 @@ - + + + + @@ -254,12 +263,6 @@ - - - - - - @@ -312,12 +315,6 @@ - - - - - - diff --git a/inst/tinytest/_tinysnapshot/flip_p.svg b/inst/tinytest/_tinysnapshot/flip_p.svg index 24df48b8..c5d9eb59 100644 --- a/inst/tinytest/_tinysnapshot/flip_p.svg +++ b/inst/tinytest/_tinysnapshot/flip_p.svg @@ -80,10 +80,13 @@ - + + + + diff --git a/inst/tinytest/_tinysnapshot/flip_p_logx.svg b/inst/tinytest/_tinysnapshot/flip_p_logx.svg index 7dd32f5b..549c43fd 100644 --- a/inst/tinytest/_tinysnapshot/flip_p_logx.svg +++ b/inst/tinytest/_tinysnapshot/flip_p_logx.svg @@ -80,10 +80,13 @@ - + + + + diff --git a/inst/tinytest/_tinysnapshot/tinytheme_dynamic_clean.svg b/inst/tinytest/_tinysnapshot/tinytheme_dynamic_clean.svg index 1ea35617..4ba8f9f2 100644 --- a/inst/tinytest/_tinysnapshot/tinytheme_dynamic_clean.svg +++ b/inst/tinytest/_tinysnapshot/tinytheme_dynamic_clean.svg @@ -88,10 +88,13 @@ - + + + + diff --git a/inst/tinytest/_tinysnapshot/tinytheme_dynamic_dark.svg b/inst/tinytest/_tinysnapshot/tinytheme_dynamic_dark.svg index 29ad0261..5d7d0bb8 100644 --- a/inst/tinytest/_tinysnapshot/tinytheme_dynamic_dark.svg +++ b/inst/tinytest/_tinysnapshot/tinytheme_dynamic_dark.svg @@ -70,10 +70,13 @@ - + + + + diff --git a/inst/tinytest/test-restore_par.R b/inst/tinytest/test-restore_par.R index f911a0d7..de529d59 100644 --- a/inst/tinytest/test-restore_par.R +++ b/inst/tinytest/test-restore_par.R @@ -12,7 +12,7 @@ op = par(no.readonly = TRUE) f1 = function() { tinyplot( Sepal.Width ~ Sepal.Length | Species, iris, - grid = grid(), + grid = TRUE, legend = legend("bottom!", bty = "o") ) points(6,3, pch = 17, col = "hotpink", cex = 1.5) From 4ed2e45c87ef8d6a55cfaefc76215689908bb65c Mon Sep 17 00:00:00 2001 From: Grant McDermott Date: Sun, 20 Apr 2025 22:23:04 -0700 Subject: [PATCH 5/5] news --- NEWS.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/NEWS.md b/NEWS.md index b80a19d5..a4c7a045 100644 --- a/NEWS.md +++ b/NEWS.md @@ -50,7 +50,9 @@ where the formatting is also better._ - Fixed dynamic y-axis margin spacing for flipped `"boxplot"` and `"jitter"` types. Thanks to @eddelbuettel for the report in #357 (@grantmcdermott). - Fixed dynamic x-axis margin spacing for perpendicular (vertical) label text, - i.e. cases where `las = 2` or `las = 3`. #369 (@grantmcdermott). + 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) ### Internals: