diff --git a/.Rbuildignore b/.Rbuildignore index 2c8f8065..603799c4 100644 --- a/.Rbuildignore +++ b/.Rbuildignore @@ -27,4 +27,5 @@ Makefile ^.devcontainer Rplots.pdf ^CLAUDE\.md$ +^\.claude$ ^revdep$ diff --git a/NEWS.md b/NEWS.md index 57a1dedd..41b47718 100644 --- a/NEWS.md +++ b/NEWS.md @@ -54,7 +54,11 @@ visualizations. via `legend = list(..., ljust = "c")`, or globally via `tpar(ljust = "c")`. (#500 @grantmcdermott) - New `"dynamic"` theme that now serves as the foundation for all other dynamic - (tiny)themes. (#549 @grantmcdermott) + (tiny)themes. (#549 @grantmcdermott) +- New `legend = "direct"` option (experimental) places text labels at the last + point of each group's data, coloured to match. Best suited to line-based plots + with x-sorted data. The right margin is automatically expanded to prevent + clipping. Pairs well with dynamic themes. (#587 @grantmcdermott) ### Bug fixes diff --git a/R/tinyplot.R b/R/tinyplot.R index e15b1193..e1e659cc 100644 --- a/R/tinyplot.R +++ b/R/tinyplot.R @@ -139,13 +139,19 @@ #' legend is drawn to the _outer_ right of the plotting area. Note that the #' legend title and categories will automatically be inferred from the `by` #' argument and underlying data. -#' - A convenience string indicating the legend position. The string should -#' correspond to one of the position keywords supported by the base `legend` -#' function, e.g. "right", "topleft", "bottom", etc. In addition, `tinyplot` -#' supports adding a trailing exclamation point to these keywords, e.g. -#' "right!", "topleft!", or "bottom!". This will place the legend _outside_ -#' the plotting area and adjust the margins of the plot accordingly. Finally, -#' users can also turn off any legend printing by specifying "none". +#' - A convenience string indicating the legend position. Supported keywords: +#' - Standard position keywords from base `legend()`, e.g. `"right"`, +#' `"topleft"`, `"bottom"`, etc. +#' - Outer positions via a trailing `"!"`, e.g. `"right!"`, `"topleft!"`, +#' or `"bottom!"`. This places the legend _outside_ the plotting area and +#' adjusts the margins accordingly. +#' - `"direct"`: places text labels at the last point of each group's data, +#' coloured to match. Best suited to line-based plots with x-sorted data, +#' where "last" corresponds to "rightmost". The right margin is +#' automatically expanded to prevent clipping. Requires discrete groups +#' via `by`. For faceted plots, labels are only drawn on the last panel +#' in which each group appears. +#' - `"none"`: turns off legend printing. #' - Logical value, where TRUE corresponds to the default case above (same #' effect as specifying NULL) and FALSE turns the legend off (same effect as #' specifying "none"). @@ -554,6 +560,20 @@ #' legend = legend("bottom!", title = "Month of the year", bty = "o") #' ) #' +#' # Use legend = "direct" to place text labels at the last point of each +#' # group's data, coloured to match. Best suited to line-based plots with +#' # x-sorted data, where "last" corresponds to "rightmost". The right +#' # margin is automatically expanded to fit the labels. Pairs well with +#' # dynamic themes for tighter margins overall. +#' +#' tinyplot( +#' Temp ~ Day | Month, +#' data = aq, +#' type = "l", +#' legend = "direct", +#' theme = "clean2" +#' ) +#' #' # The default group colours are inherited from either the "R4" or "Viridis" #' # palettes, depending on the number of groups. However, all palettes listed #' # by `palette.pals()` and `hcl.pals()` are supported as convenience strings, @@ -1072,7 +1092,7 @@ tinyplot.default = function( par(mar = dynmar_computed + .whtsbp) } - if (legend_draw_flag) { + if (legend_draw_flag && !identical(legend_args[["x"]], "direct")) { if (!multi_legend) { ## simple case: single legend only if (is.null(lgnd_cex)) lgnd_cex = cex * cex_fct_adj @@ -1101,7 +1121,7 @@ tinyplot.default = function( } has_legend = TRUE - } else if (legend_args[["x"]] == "none" && !isTRUE(add)) { + } else if (legend_args[["x"]] %in% c("none", "direct") && !isTRUE(add)) { omar = par("mar") ooma = par("oma") topmar_epsilon = 0.1 @@ -1121,6 +1141,12 @@ tinyplot.default = function( ## title and subtitle ----- # + direct_labels_flag = !isTRUE(add) && identical(legend_args[["x"]], "direct") && + !isTRUE(by_continuous) && !null_by + if (identical(legend_args[["x"]], "direct") && !direct_labels_flag && !isTRUE(add)) { + warning("legend=\"direct\" requires discrete groups via `by`. Falling back to no legend.") + } + if (!add) { # Reinstate dynmar margins and user coordinates after draw_legend # (which may have called plot.new and reset par via hooks). @@ -1129,7 +1155,32 @@ tinyplot.default = function( if (!is.null(xlim) && !is.null(ylim)) { plot.window(xlim = xlim, ylim = ylim) } + } else if (direct_labels_flag && !is.null(xlim) && !is.null(ylim)) { + plot.window(xlim = xlim, ylim = ylim) } + + # Expand right margin for direct labels based on actual label overshoot + if (direct_labels_flag && !is.null(xlim) && !is.null(ylim)) { + usr_right = par("usr")[2] + last_x = tapply(datapoints$x, datapoints$by, function(z) tail(z, 1)) + offset_usr = strwidth("m", units = "user") * 0.3 + label_widths = strwidth(lgnd_labs, units = "user") + overshoots = (last_x + offset_usr + label_widths) - usr_right + max_overshoot = max(0, overshoots, na.rm = TRUE) + if (max_overshoot > 0) { + overshoot_lines = max_overshoot * par("pin")[1] / diff(par("usr")[1:2]) / par("csi") + if (!is.null(dynmar_computed)) { + dynmar_computed[4] = dynmar_computed[4] + overshoot_lines + par(mar = dynmar_computed + .whtsbp) + } else { + cur_mar = par("mar") + cur_mar[4] = cur_mar[4] + overshoot_lines + par(mar = cur_mar) + } + plot.window(xlim = xlim, ylim = ylim) + } + } + draw_title(main, sub, xlab, ylab, legend, legend_args, opar, xlab_line_offset = if (!is.null(dynmar_computed)) .whtsbp[1] else 0, ylab_line_offset = if (!is.null(dynmar_computed)) .whtsbp[2] else 0) @@ -1264,6 +1315,8 @@ tinyplot.default = function( split_data = list(as.list(datapoints)) } + if (direct_labels_flag) .dl_info = vector("list", ngrps) + ## Outer loop over the facets for (i in seq_along(split_data)) { # Split group-level data again to grab any "by" groups @@ -1390,9 +1443,21 @@ tinyplot.default = function( facet_window_args = facet_window_args ) } + if (direct_labels_flag && !empty_plot && length(ix) > 0) { + .dl_info[[ii]] = list(x = tail(ix, 1), y = tail(iy, 1), col = icol) + } + } + } + + if (direct_labels_flag) { + dl_labs = lgnd_labs + for (k in seq_along(.dl_info)) { + if (!is.null(.dl_info[[k]])) { + text(.dl_info[[k]]$x, .dl_info[[k]]$y, labels = dl_labs[k], + col = .dl_info[[k]]$col, pos = 4, offset = 0.3, xpd = NA) + } } } - # ## save end pars for possible recall later ----- diff --git a/inst/tinytest/_tinysnapshot/legend_direct_clean2.svg b/inst/tinytest/_tinysnapshot/legend_direct_clean2.svg new file mode 100644 index 00000000..afc1be6b --- /dev/null +++ b/inst/tinytest/_tinysnapshot/legend_direct_clean2.svg @@ -0,0 +1,75 @@ + + + + + + + + + + + + + +Day +Temp +0 +5 +10 +15 +20 +25 +30 +60 +70 +80 +90 + + + + + + + + + + + + + + + + + + + + + + + + + +May +June +July +August +September + + + + diff --git a/inst/tinytest/_tinysnapshot/legend_direct_facet.svg b/inst/tinytest/_tinysnapshot/legend_direct_facet.svg new file mode 100644 index 00000000..670da572 --- /dev/null +++ b/inst/tinytest/_tinysnapshot/legend_direct_facet.svg @@ -0,0 +1,129 @@ + + + + + + + + + + + + + +Day +Temp + + + + + + + + + +0 +5 +10 +15 +20 +25 +30 +60 +70 +80 +90 + +cool + + + + + + + + + + + + + + + + + + + + + + +0 +5 +10 +15 +20 +25 +30 + +hot + + + + + + + + + + + + + + + + + + + + + + + + + + + + +May +June +July +August +September + + + + + + + + + + + + + diff --git a/inst/tinytest/_tinysnapshot/legend_direct_lines.svg b/inst/tinytest/_tinysnapshot/legend_direct_lines.svg new file mode 100644 index 00000000..30f5f926 --- /dev/null +++ b/inst/tinytest/_tinysnapshot/legend_direct_lines.svg @@ -0,0 +1,78 @@ + + + + + + + + + + + + + +Day +Temp + + + + + + + + +0 +5 +10 +15 +20 +25 +30 + + + + + +60 +70 +80 +90 + + + + + + + + + + + + + + + +May +June +July +August +September + + + + diff --git a/inst/tinytest/_tinysnapshot/legend_direct_lm.svg b/inst/tinytest/_tinysnapshot/legend_direct_lm.svg new file mode 100644 index 00000000..e7afbaac --- /dev/null +++ b/inst/tinytest/_tinysnapshot/legend_direct_lm.svg @@ -0,0 +1,74 @@ + + + + + + + + + + + + + +Petal.Length +Sepal.Length +1 +2 +3 +4 +5 +6 +7 +5 +6 +7 +8 + + + + + + + + + + + + + + + + + + + + + + + + + + +setosa +versicolor +virginica + + + + diff --git a/inst/tinytest/_tinysnapshot/legend_direct_long_label.svg b/inst/tinytest/_tinysnapshot/legend_direct_long_label.svg new file mode 100644 index 00000000..947271e2 --- /dev/null +++ b/inst/tinytest/_tinysnapshot/legend_direct_long_label.svg @@ -0,0 +1,74 @@ + + + + + + + + + + + + + +Petal.Length +Sepal.Length +1 +2 +3 +4 +5 +6 +7 +5 +6 +7 +8 + + + + + + + + + + + + + + + + + + + + + + + + + + +A very long species name +Medium +C + + + + diff --git a/inst/tinytest/test-legend_direct.R b/inst/tinytest/test-legend_direct.R new file mode 100644 index 00000000..73a953e8 --- /dev/null +++ b/inst/tinytest/test-legend_direct.R @@ -0,0 +1,60 @@ +source("helpers.R") +using("tinysnapshot") + +aq = airquality +aq$Month = factor(month.name[aq$Month], levels = month.name[5:9]) + +# Basic direct labels with line plot +f = function() { + plt(Temp ~ Day | Month, data = aq, type = "l", legend = "direct") + box("outer", lty = 2) +} +expect_snapshot_plot(f, label = "legend_direct_lines") + +# With dynamic theme (ephemeral) +f = function() { + plt(Temp ~ Day | Month, data = aq, type = "l", + legend = "direct", theme = "clean2") + box("outer", lty = 2) +} +expect_snapshot_plot(f, label = "legend_direct_clean2") + +# With type = "lm" +f = function() { + plt(Sepal.Length ~ Petal.Length | Species, data = iris, + type = "lm", legend = "direct", theme = "clean2") + box("outer", lty = 2) +} +expect_snapshot_plot(f, label = "legend_direct_lm") + +# Long labels expand margin correctly +f = function() { + iris2 = iris + levels(iris2$Species) = c("A very long species name", "Medium", "C") + plt(Sepal.Length ~ Petal.Length | Species, data = iris2, + type = "lm", legend = "direct", theme = "clean2") + box("outer", lty = 2) +} +expect_snapshot_plot(f, label = "legend_direct_long_label") + +# With facets +f = function() { + aq2 = aq + aq2$hot = ifelse(aq2$Temp > 80, "hot", "cool") + plt(Temp ~ Day | Month, data = aq2, facet = ~hot, type = "l", + legend = "direct", theme = "clean2") + box("outer", lty = 2) +} +expect_snapshot_plot(f, label = "legend_direct_facet") + +# No by variable: should warn and produce plot without labels +expect_warning( + plt(0:10, type = "l", legend = "direct"), + "discrete groups" +) + +# Continuous by: should warn +expect_warning( + plt(Sepal.Length ~ Petal.Length | Sepal.Width, data = iris, legend = "direct"), + "discrete groups" +) diff --git a/man/tinyplot.Rd b/man/tinyplot.Rd index 339dc7ce..9f5b1869 100644 --- a/man/tinyplot.Rd +++ b/man/tinyplot.Rd @@ -256,13 +256,21 @@ no legend is drawn. If a grouping variable is detected, then an automatic legend is drawn to the \emph{outer} right of the plotting area. Note that the legend title and categories will automatically be inferred from the \code{by} argument and underlying data. -\item A convenience string indicating the legend position. The string should -correspond to one of the position keywords supported by the base \code{legend} -function, e.g. "right", "topleft", "bottom", etc. In addition, \code{tinyplot} -supports adding a trailing exclamation point to these keywords, e.g. -"right!", "topleft!", or "bottom!". This will place the legend \emph{outside} -the plotting area and adjust the margins of the plot accordingly. Finally, -users can also turn off any legend printing by specifying "none". +\item A convenience string indicating the legend position. Supported keywords: +\itemize{ +\item Standard position keywords from base \code{legend()}, e.g. \code{"right"}, +\code{"topleft"}, \code{"bottom"}, etc. +\item Outer positions via a trailing \code{"!"}, e.g. \code{"right!"}, \code{"topleft!"}, +or \code{"bottom!"}. This places the legend \emph{outside} the plotting area and +adjusts the margins accordingly. +\item \code{"direct"}: places text labels at the last point of each group's data, +coloured to match. Best suited to line-based plots with x-sorted data, +where "last" corresponds to "rightmost". The right margin is +automatically expanded to prevent clipping. Requires discrete groups +via \code{by}. For faceted plots, labels are only drawn on the last panel +in which each group appears. +\item \code{"none"}: turns off legend printing. +} \item Logical value, where TRUE corresponds to the default case above (same effect as specifying NULL) and FALSE turns the legend off (same effect as specifying "none"). @@ -720,6 +728,20 @@ tinyplot( legend = legend("bottom!", title = "Month of the year", bty = "o") ) +# Use legend = "direct" to place text labels at the last point of each +# group's data, coloured to match. Best suited to line-based plots with +# x-sorted data, where "last" corresponds to "rightmost". The right +# margin is automatically expanded to fit the labels. Pairs well with +# dynamic themes for tighter margins overall. + +tinyplot( + Temp ~ Day | Month, + data = aq, + type = "l", + legend = "direct", + theme = "clean2" +) + # The default group colours are inherited from either the "R4" or "Viridis" # palettes, depending on the number of groups. However, all palettes listed # by `palette.pals()` and `hcl.pals()` are supported as convenience strings, diff --git a/vignettes/gallery.qmd b/vignettes/gallery.qmd index 2449a5f6..0f43a30a 100644 --- a/vignettes/gallery.qmd +++ b/vignettes/gallery.qmd @@ -117,4 +117,11 @@ Click on a plot to get the link to its code. #| file: "gallery_figs/density-part-shading.R" ``` +```{r} +#| lightbox: +#| group: r-graph +#| description: "[Code](https://github.com/grantmcdermott/tinyplot/blob/main/vignettes/gallery_figs/direct-labels-aq.R){target='_blank'}" +#| file: "gallery_figs/direct-labels-aq.R" +``` + ::: diff --git a/vignettes/gallery_figs/direct-labels-aq.R b/vignettes/gallery_figs/direct-labels-aq.R new file mode 100644 index 00000000..1437c8c2 --- /dev/null +++ b/vignettes/gallery_figs/direct-labels-aq.R @@ -0,0 +1,13 @@ +library(tinyplot) + +aq = airquality +aq$Month = factor(month.name[aq$Month], levels = month.name[5:9]) + +tinyplot( + Temp ~ Day | Month, + data = aq, + type = "l", + legend = "direct", + theme = "clean2", + main = "Sometimes, direct legend labels are better" +) diff --git a/vignettes/introduction.qmd b/vignettes/introduction.qmd index 8c53eeeb..291caf10 100644 --- a/vignettes/introduction.qmd +++ b/vignettes/introduction.qmd @@ -234,6 +234,20 @@ tinyplot( ) ``` +Another option is `legend = "direct"`, which places text labels at the end of +each group's data instead of a separate legend box. This works best for +line-based plots with x-sorted data, and pairs well with dynamic themes for +tighter margins (more on themes [below](#themes)). + +```{r legend_direct} +tinyplot( + Temp ~ Day | Month, data = aq, + type = "l", + legend = "direct", + theme = "clean2" +) +``` + Beyond the convenience of these positional keywords, the `legend` argument also permits additional customization in the form of a list of arguments, which will be passed on to the standard `legend()` function internally. So you can change diff --git a/vignettes/tips.qmd b/vignettes/tips.qmd index f9f592af..c7e1fdd2 100644 --- a/vignettes/tips.qmd +++ b/vignettes/tips.qmd @@ -157,101 +157,6 @@ plots, or plots saved to disk, the aesthetic effect should be quite pleasing.) ## Labels -### Direct labels - -Direct labels can provide a nice alternative to a standard legend, particularly -for grouped line plots. While `tinyplot` doesn't offer a "native" direct labels -type, you can easily achieve the same end result using an idiomatic layering -approach. - -```{r} -#| eval: false - -library(tinyplot) -tinytheme("clean2") - -aq = airquality -aq$Month = factor(month.name[aq$Month], levels = month.name[5:9]) - -# base layer -plt(Temp ~ Day | Month, data = aq, type = "l", legend = FALSE) - -# for labels: subset to final dates for each month -aq2 = aq[aq$Day == ave(aq$Day, aq$Month, FUN = max), ] - -# add the labels with a type_text() layer -plt_add(data = aq2, type = "text", labels = aq2$Month, - pos = 4, offset = 0.2, xpd = NA) -``` - -```{r} -#| echo: false - -## dev note: we need to go through some eval -> echo false trickery to get -## around the fact that Quarto doesn't keep the same graphics device open... -## which in turn is needed for strwidth(). The website will only display the -## nicely formatted code that works in a live session, though. - -library(tinyplot) -tinytheme("clean2") - -aq = airquality -aq$Month = factor(month.name[aq$Month], levels = month.name[5:9]) - -# base layer -plt(Temp ~ Day | Month, data = aq, type = "l", legend = FALSE) - -# for labels: subset to final dates for each month -aq2 = aq[aq$Day == ave(aq$Day, aq$Month, FUN = max), ] - -longest_lab = max(strwidth(as.character(aq2$Month)))/2 - -# add the labels with a type_text() layer -plt_add(data = aq2, type = "text", labels = aq2$Month, - pos = 4, offset = 0.2, xpd = NA) -``` - -Hmmmm, can you see a problem? We used `type_text(..., xpd = NA)` in the second -layer to avoid text clipping, but the longer labels are still being cut off due -to the limited RHS margin space of our `"clean2"` plotting theme. - -The good news is that there's an easy solution. Simply grab the theme's -parameters, bump out the RHS margin by the longest label in our dataset, and -then replot. - -```{r} -#| eval: false - -# Fix: first grab the theme params and then adjust the RHS margin by -# the longest label in the dataset -longest_lab = max(strwidth(as.character(aq2$Month)))/2 # divide by 2 to get lines -parms = tinyplot:::theme_clean2 -parms$mar[4] = parms$mar[4] + longest_lab -tinytheme("clean2", mar = parms$mar) # theme with adjusted margins - -# Now plot both the base and direct label layers -plt(Temp ~ Day | Month, data = aq, type = "l", legend = FALSE) -plt_add(data = aq2, type = "text", labels = aq2$Month, - pos = 4, offset = 0.2, xpd = NA) -``` - -```{r} -#| echo: false - -## dev note: This is the code that actually runs, using longest_lab from the -## previous code chunk -parms = tinyplot:::theme_clean2 -parms$mar[4] = parms$mar[4] + longest_lab -tinytheme("clean2", mar = parms$mar) -plt(Temp ~ Day | Month, data = aq, type = "l", legend = FALSE) -plt_add(data = aq2, type = "text", labels = aq2$Month, - pos = 4, offset = 0.2, xpd = NA) -``` - -```{r} -# Reset the theme (optional, but recommended) -tinytheme() -``` ### Rotated axis labels