diff --git a/R/draw_legend.R b/R/draw_legend.R index 1f5aba88..f46baf86 100644 --- a/R/draw_legend.R +++ b/R/draw_legend.R @@ -129,119 +129,47 @@ draw_legend = function( # ## legend args ---- - - legend_args = sanitize_legend(legend, legend_args) - - ## 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 (is.null(type) || type %in% c("p", "pointrange", "errorbar", "text")) { - legend_args[["pt.cex"]] = legend_args[["pt.cex"]] %||% (cex %||% par("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")) || 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 - } - - if (isTRUE(gradient)) { - legend_args[["ncol"]] = NULL - } - # flag for multicolumn legend - mcol_flag = !is.null(legend_args[["ncol"]]) && legend_args[["ncol"]] > 1 - - # flag for (extra) user inset (also used for dual legends) - user_inset = !is.null(legend_args[["inset"]]) + + outer_side = outer_end = outer_right = outer_bottom = FALSE + list2env( + compute_legend_args( + 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 + ), + environment() + ) # ## legend placement ---- - - # Note: "side" = left/right ; "end" = top/bottom - outer_side = outer_end = outer_right = outer_bottom = FALSE ## placeholders - - 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)) + + # flag for (extra) user inset (also used for dual legends) + user_inset = !is.null(legend_args[["inset"]]) + + ## restore margin defaults + ## (in case the plot region/margins were affected by the preceding tinyplot call) + topmar_epsilon = 0.1 + restore_margin_outer() + if (!dynmar) restore_margin_inner(ooma, topmar_epsilon = topmar_epsilon) + ooma = par("oma") + omar = par("mar") ## Legend to outer side (either right or left) of plot - if (grepl("right!$|left!$", legend_args[["x"]])) { - - outer_side = TRUE - outer_right = grepl("right!$", legend_args[["x"]]) - + if (outer_side) { # 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. @@ -271,24 +199,17 @@ draw_legend = function( } } - 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"]]) + } else if (outer_end) { ## 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") + 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 @@ -318,42 +239,11 @@ draw_legend = function( } } - # enforce horizontal legend if user hasn't specified ncol arg - # (exception: gradient legends at bottom/top are always horizontal) - if (is.null(legend_args[["ncol"]]) || gradient) legend_args[["horiz"]] = TRUE } else { - - legend_args[["inset"]] = 0 - # if (new_plot && draw) plot.new() if (new_plot) plot.new() } - - # Additional tweaks for horiz and/or multi-column legends - if (isTRUE(legend_args[["horiz"]]) || mcol_flag) { - # tighter horizontal labelling - # See: https://github.com/grantmcdermott/tinyplot/issues/434 - if (!gradient) { - legend_args[["text.width"]] = NA - # Add a space to all labs except the outer most right ones - nlabs = length(legend_args[["legend"]]) - nidx = nlabs - if (mcol_flag) nidx = tail(1:nlabs, (nlabs %/% legend_args[["ncol"]])) - legend_args[["legend"]][-nidx] = paste(legend_args[["legend"]][-nidx], " ") - } - # catch for horizontal ribbon legend spacing - if (type=="ribbon") { - if (legend_args[["pt.lwd"]] == 1) { - legend_args[["x.intersp"]] = 1 - } else { - legend_args[["x.intersp"]] = 0.5 - } - } else if (gradient) { - legend_args[["x.intersp"]] = 0.5 - } - } - # ## draw the legend ---- # Legend drawing is handled by the internal `tinylegend()` function, which: @@ -736,4 +626,4 @@ sanitize_legend = function(legend, legend_args) { } } return(legend_args) -} \ No newline at end of file +} diff --git a/R/draw_legend_utils.R b/R/draw_legend_utils.R new file mode 100644 index 00000000..1e4a76ce --- /dev/null +++ b/R/draw_legend_utils.R @@ -0,0 +1,174 @@ +restore_margin_outer = function() { + par(omd = c(0,1,0,1)) +} + + +restore_margin_inner = function(ooma, topmar_epsilon = 0.1) { + ooma = par("oma") + omar = par("mar") + + if (!any(ooma != 0)) return(invisible(NULL)) + + ## restore inner margin defaults + ## (in case the plot region/margins were affected by the preceding tinyplot call) + if (any(ooma != 0)) { + if (ooma[1] != 0 && omar[1] == par("mgp")[1] + 1 * par("cex.lab")) { + omar[1] = 5.1 + } + if (ooma[2] != 0 && omar[2] == par("mgp")[1] + 1 * par("cex.lab")) { + omar[2] = 4.1 + } + if (ooma[3] == topmar_epsilon && omar[3] != 4.1) { + omar[3] = 4.1 + } + if (ooma[4] != 0 && omar[4] == 0) { + omar[4] = 2.1 + } + par(mar = omar) + } + ## restore outer margin defaults (with a catch for custom mfrow plots) + if (all(par("mfrow") == c(1, 1))) { + par(omd = c(0, 1, 0, 1)) + } +} + + +compute_legend_args = function( + legend, + legend_args, + by_dep, + lgnd_labs, + type, + pch, + lty, + lwd, + col, + bg, + cex, + gradient +) { + legend_args = sanitize_legend(legend, legend_args) + ## 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 (is.null(type) || type %in% c("p", "pointrange", "errorbar", "text")) { + legend_args[["pt.cex"]] = legend_args[["pt.cex"]] %||% (cex %||% par("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")) || 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 + } + if (isTRUE(gradient)) { + legend_args[["ncol"]] = NULL + } + # flag for multicolumn legend + mcol_flag = !is.null(legend_args[["ncol"]]) && legend_args[["ncol"]] > 1 + # flag for (extra) user inset (also used for dual legends) + user_inset = !is.null(legend_args[["inset"]]) + + # placement flags and anchor normalization (no par() calls here) + outer_side = outer_end = outer_right = outer_bottom = FALSE + if (grepl("right!$|left!$", legend_args[["x"]])) { + outer_side = TRUE + outer_right = grepl("right!$", legend_args[["x"]]) + } 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_end) { + if (outer_bottom) { + legend_args[["x"]] = gsub("bottom!$", "top", legend_args[["x"]]) + } + if (!outer_bottom) { + legend_args[["x"]] = gsub("top!$", "bottom", legend_args[["x"]]) + } + + # enforce horizontal legend if user hasn't specified ncol arg + # (exception: gradient legends at bottom/top are always horizontal) + if (is.null(legend_args[["ncol"]]) || gradient) legend_args[["horiz"]] = TRUE + + } else if (outer_side) { + if (outer_right) { + legend_args[["x"]] = gsub("right!$", "left", legend_args[["x"]]) + } + if (!outer_right) { + legend_args[["x"]] = gsub("left!$", "right", legend_args[["x"]]) + } + } else { + legend_args[["inset"]] = 0 + } + + # Additional tweaks for horiz and/or multi-column legends + if (isTRUE(legend_args[["horiz"]]) || mcol_flag) { + # tighter horizontal labelling + # See: https://github.com/grantmcdermott/tinyplot/issues/434 + if (!gradient) { + legend_args[["text.width"]] = NA + # Add a space to all labs except the outer most right ones + nlabs = length(legend_args[["legend"]]) + nidx = nlabs + if (mcol_flag) nidx = tail(1:nlabs, (nlabs %/% legend_args[["ncol"]])) + legend_args[["legend"]][-nidx] = paste(legend_args[["legend"]][-nidx], " ") + } + # catch for horizontal ribbon legend spacing + if (type=="ribbon") { + if (legend_args[["pt.lwd"]] == 1) { + legend_args[["x.intersp"]] = 1 + } else { + legend_args[["x.intersp"]] = 0.5 + } + } else if (gradient) { + legend_args[["x.intersp"]] = 0.5 + } + } + + list( + legend_args = legend_args, + mcol_flag = mcol_flag, + user_inset = user_inset, + outer_side = outer_side, + outer_end = outer_end, + outer_right = outer_right, + outer_bottom = outer_bottom + ) +} diff --git a/R/tinyplot.R b/R/tinyplot.R index bba3ff0d..973a39dc 100644 --- a/R/tinyplot.R +++ b/R/tinyplot.R @@ -848,59 +848,30 @@ tinyplot.default = function( # swap x and y values if flip is TRUE - assert_flag(flip) # extra catch for boxplots - # now swap the values + assert_flag(flip) if (isTRUE(flip)) { - if (type != "boxplot") { - # limits, labs, etc. - xlim_cp = xlim - xlim = ylim - ylim = xlim_cp - xlab_cp = xlab - xlab = ylab - ylab = xlab_cp - xlabs_cp = xlabs - xlabs = ylabs - ylabs = xlabs_cp - xaxt_cp = xaxt - xaxt = yaxt - yaxt = xaxt_cp - xaxs_cp = xaxs - xaxs = yaxs - yaxs = xaxs_cp - xaxb_cp = xaxb - xaxb = yaxb - yaxb = xaxb_cp - xaxl_cp = xaxl - xaxl = yaxl - yaxl = xaxl_cp - if (!is.null(log)) { - log = if (log == "x") "y" else if (log == "y") "x" else log - } - # x/y vars - x_cp = datapoints[["x"]] - datapoints[["x"]] = datapoints[["y"]] - datapoints[["y"]] = x_cp - # x/y min and max vars - xmin_cp = if (!is.null(datapoints[["xmin"]])) datapoints[["xmin"]] else NULL - datapoints[["xmin"]] = if (!is.null(datapoints[["ymin"]])) datapoints[["ymin"]] else NULL - datapoints[["ymin"]] = if (!is.null(xmin_cp)) xmin_cp else NULL - xmax_cp = if (!is.null(datapoints[["xmax"]])) datapoints[["xmax"]] else NULL - datapoints[["xmax"]] = if (!is.null(datapoints[["ymax"]])) datapoints[["ymax"]] else NULL - datapoints[["ymax"]] = if (!is.null(xmax_cp)) xmax_cp else NULL - # clean up - rm(xlim_cp, xlab_cp, xlabs_cp, xaxt_cp, xaxs_cp, xaxb_cp, xaxl_cp, x_cp, xmin_cp, xmax_cp) + if (type == "boxplot") { + # boxplot: let horizontal=TRUE do most work; only swap labels + swap_variables(environment(), c("xlab", "ylab")) } else { - # We'll let boxplot(..., horizontal = TRUE) handle most of the adjustments - # and just catch a few elements that we draw beforehand. - xlab_cp = xlab - xlab = ylab - ylab = xlab_cp - rm(xlab_cp) + swap_variables( + environment(), + c("xlim", "ylim"), + c("xlab", "ylab"), + c("xlabs", "ylabs"), + c("xaxt", "yaxt"), + c("xaxs", "yaxs"), + c("xaxb", "yaxb"), + c("xaxl", "yaxl")) + if (!is.null(log)) log = chartr("xy", "yx", log) + datapoints = swap_columns(datapoints, "x", "y") + datapoints = swap_columns(datapoints, "xmin", "ymin") + datapoints = swap_columns(datapoints, "xmax", "ymax") } } + # For cases where x/yaxb is provided and corresponding x/ylabs is not null... # We can subset these here to provide breaks if (!is.null(xaxb) && !is.null(xlabs)) { @@ -1132,21 +1103,7 @@ tinyplot.default = function( # 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) - if (any(ooma != 0)) { - if (ooma[1] != 0 && omar[1] == par("mgp")[1] + 1 * par("cex.lab")) omar[1] = 5.1 - if (ooma[2] != 0 && omar[2] == par("mgp")[1] + 1 * par("cex.lab")) omar[2] = 4.1 - if (ooma[3] == topmar_epsilon && omar[3] != 4.1) omar[3] = 4.1 - if (ooma[4] != 0 && omar[4] == 0) omar[4] = 2.1 - par(mar = omar) - } - ## restore outer margin defaults (with a catch for custom mfrow plots) - if (all(par("mfrow") == c(1, 1))) { - par(omd = c(0, 1, 0, 1)) - } - + restore_margin_inner(ooma) # clean up for now rm(omar, ooma, topmar_epsilon) diff --git a/R/utils.R b/R/utils.R index 90368f19..9443a7c2 100644 --- a/R/utils.R +++ b/R/utils.R @@ -1,7 +1,7 @@ -rescale_num = function (x, from = NULL, to = NULL) { +rescale_num = function(x, from = NULL, to = NULL) { if (is.null(from)) from = range(x) if (is.null(to)) to = c(0, 1) - (x - from[1])/diff(from) * diff(to) + to[1] + (x - from[1]) / diff(from) * diff(to) + to[1] } ## Function for efficiently checking whether a vector has more than n unique @@ -31,21 +31,38 @@ more_than_n_unique = function(x, n, small_vec_len = 1e3L) { ## Null coalescing operator if (getRversion() <= "4.4.0") { - `%||%` = function(x, y) if (is.null(x)) y else x + `%||%` = function(x, y) if (is.null(x)) y else x } ## Function that computes an appropriate bandwidth kernel based on a string ## input bw_fun = function(kernel, x) { - kernel = tolower(kernel) - switch( - kernel, - nrd0 = bw.nrd0(x), - nrd = bw.nrd(x), - ucv = bw.ucv(x), - bcv = bw.bcv(x), - sj = bw.SJ(x), - stop("Invalid `bw` string. Choose from 'nrd0', 'nrd', 'ucv', 'bcv', or 'SJ'.") - ) + kernel = tolower(kernel) + switch(kernel, + nrd0 = bw.nrd0(x), + nrd = bw.nrd(x), + ucv = bw.ucv(x), + bcv = bw.bcv(x), + sj = bw.SJ(x), + stop("Invalid `bw` string. Choose from 'nrd0', 'nrd', 'ucv', 'bcv', or 'SJ'.") + ) +} + + +swap_variables = function(env, ...) { + pairs = list(...) + for (p in pairs) { + tmp = get(p[1], envir = env) + assign(p[1], get(p[2], envir = env), envir = env) + assign(p[2], tmp, envir = env) + } +} + +swap_columns = function(dp, a, b) { + va = dp[[a]] + vb = dp[[b]] + dp[[a]] = if (!is.null(vb)) vb else NULL + dp[[b]] = if (!is.null(va)) va else NULL + dp } diff --git a/inst/tinytest/test-tinyplot_add.R b/inst/tinytest/test-tinyplot_add.R index 39d5918b..ed9fecef 100644 --- a/inst/tinytest/test-tinyplot_add.R +++ b/inst/tinytest/test-tinyplot_add.R @@ -49,7 +49,7 @@ expect_snapshot_plot(f, label = "tinyplot_add_rug") # type = "rug" (adding to "density" should default to x variable) f = function() { - tinyplot( ~ eruptions, data = faithful, type = "density") + tinyplot(~eruptions, data = faithful, type = "density") tinyplot_add(type = "rug") } expect_snapshot_plot(f, label = "tinyplot_add_rug_density") @@ -57,14 +57,14 @@ expect_snapshot_plot(f, label = "tinyplot_add_rug_density") # type = "rug" (adding to "density" should default to x variable) f = function() { - tinyplot( ~ eruptions, data = faithful, type = "density") + tinyplot(~eruptions, data = faithful, type = "density") tinyplot_add(type = "rug") } expect_snapshot_plot(f, label = "tinyplot_add_rug_density") # use tinyplot_add() inside a custom function with local variables -tinyplot_lollipop <- function(x, y) { +tinyplot_lollipop = function(x, y) { tinyplot(x, y, type = "h") tinyplot_add(type = "p", pch = 19) tinyplot_add(type = "hline")