From c89f960367e3c39b21398e7a7360722ab21c319f Mon Sep 17 00:00:00 2001 From: Grant McDermott Date: Fri, 15 May 2026 16:59:58 -0700 Subject: [PATCH 01/18] legend = "direct" --- R/tinyplot.R | 85 +++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 75 insertions(+), 10 deletions(-) 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 ----- From 4f8d761418b81aefbd9b6ddff5ce784cbd371702 Mon Sep 17 00:00:00 2001 From: Grant McDermott Date: Fri, 15 May 2026 17:00:08 -0700 Subject: [PATCH 02/18] docs and vignette --- man/tinyplot.Rd | 36 +++++++++++++++++++++++++++++------- vignettes/introduction.qmd | 14 ++++++++++++++ 2 files changed, 43 insertions(+), 7 deletions(-) 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/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 From 77234c15f6794dac3533df6bda68c840a1498d54 Mon Sep 17 00:00:00 2001 From: Grant McDermott Date: Fri, 15 May 2026 17:07:16 -0700 Subject: [PATCH 03/18] replace manual tips & tricks ex. with simpler gallery ex. --- vignettes/gallery.qmd | 7 ++ vignettes/gallery_figs/direct-labels-aq.R | 13 ++++ vignettes/tips.qmd | 95 ----------------------- 3 files changed, 20 insertions(+), 95 deletions(-) create mode 100644 vignettes/gallery_figs/direct-labels-aq.R 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/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 From 8a560e9ed70bf882fbf1f552ac802fdda2fae7a5 Mon Sep 17 00:00:00 2001 From: Grant McDermott Date: Fri, 15 May 2026 17:09:30 -0700 Subject: [PATCH 04/18] r cmd catch --- .Rbuildignore | 1 + 1 file changed, 1 insertion(+) 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$ From 5b87b68c004969cf38da67dd634eedce778acd5c Mon Sep 17 00:00:00 2001 From: Grant McDermott Date: Fri, 15 May 2026 17:16:07 -0700 Subject: [PATCH 05/18] tests --- .../_tinysnapshot/legend_direct_clean2.svg | 75 ++++++++++ .../_tinysnapshot/legend_direct_facet.svg | 129 ++++++++++++++++++ .../_tinysnapshot/legend_direct_lines.svg | 78 +++++++++++ .../_tinysnapshot/legend_direct_lm.svg | 74 ++++++++++ .../legend_direct_long_label.svg | 74 ++++++++++ inst/tinytest/test-legend_direct.R | 60 ++++++++ 6 files changed, 490 insertions(+) create mode 100644 inst/tinytest/_tinysnapshot/legend_direct_clean2.svg create mode 100644 inst/tinytest/_tinysnapshot/legend_direct_facet.svg create mode 100644 inst/tinytest/_tinysnapshot/legend_direct_lines.svg create mode 100644 inst/tinytest/_tinysnapshot/legend_direct_lm.svg create mode 100644 inst/tinytest/_tinysnapshot/legend_direct_long_label.svg create mode 100644 inst/tinytest/test-legend_direct.R 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" +) From a4977d53aa2125999dc6b54654ca946152b85bde Mon Sep 17 00:00:00 2001 From: Grant McDermott Date: Fri, 15 May 2026 17:17:36 -0700 Subject: [PATCH 06/18] news --- NEWS.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) 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 From f27057104a72a698972bd0b44ce1ae867c2203e3 Mon Sep 17 00:00:00 2001 From: Grant McDermott Date: Tue, 19 May 2026 12:41:59 -0700 Subject: [PATCH 07/18] add repel options --- R/legend.R | 6 +++ R/repel.R | 106 +++++++++++++++++++++++++++++++++++++++++++++++++++ R/tinyplot.R | 45 ++++++++++++++++++++-- 3 files changed, 154 insertions(+), 3 deletions(-) create mode 100644 R/repel.R diff --git a/R/legend.R b/R/legend.R index 96ea572f..5128ca60 100644 --- a/R/legend.R +++ b/R/legend.R @@ -34,6 +34,12 @@ sanitize_legend = function(legend, legend_args) { if (length(new_legend) >= 1 && (is.null(names(new_legend)) || names(new_legend)[1] == "")) { names(new_legend)[1] = "x" } + # Evaluate language elements so e.g. c(0, 1, 2) becomes a real vector + for (nm in names(new_legend)) { + if (is.language(new_legend[[nm]])) { + new_legend[[nm]] = eval(new_legend[[nm]]) + } + } new_legend } else { list(x = "right!") # Fallback diff --git a/R/repel.R b/R/repel.R new file mode 100644 index 00000000..ebd619df --- /dev/null +++ b/R/repel.R @@ -0,0 +1,106 @@ +#' Force-directed text repelling +#' +#' Resolves overlapping text labels using a two-phase algorithm: (1) push +#' overlapping labels apart symmetrically, (2) spring-pull labels back toward +#' their anchors without reintroducing overlaps. +#' +#' @param x,y Numeric vectors of current label positions. +#' @param widths,heights Numeric vectors of label bounding box dimensions (in +#' the same units as `x` and `y`). +#' @param anchor_x,anchor_y Numeric vectors of original target positions that +#' labels are pulled back toward during the spring pass. Defaults to `x` +#' and `y`. +#' @param min_gap Minimum padding between labels. Default `0`. +#' @param iterations Maximum number of repulsion iterations. Default `20`. +#' @param axis Movement constraint: `"both"` (default), `"x"`, or `"y"`. +#' @return A list with elements `x` and `y` giving adjusted positions. +#' @keywords internal +repel_text = function(x, y, widths, heights, + anchor_x = x, anchor_y = y, + min_gap = 0, iterations = 20, + axis = "both") { + n = length(x) + if (n <= 1) return(list(x = x, y = y)) + + valid = !is.na(x) & !is.na(y) + vx = x[valid] + vy = y[valid] + ax = anchor_x[valid] + ay = anchor_y[valid] + vw = widths[valid] + vh = heights[valid] + nv = sum(valid) + + move_x = axis %in% c("both", "x") + move_y = axis %in% c("both", "y") + + # Phase 1: resolve overlaps by pushing apart (full overlap each step) + for (iter in seq_len(iterations)) { + any_overlap = FALSE + for (i in seq_len(nv - 1)) { + for (j in (i + 1):nv) { + ox = (vw[i] + vw[j]) / 2 + min_gap - abs(vx[i] - vx[j]) + oy = (vh[i] + vh[j]) / 2 + min_gap - abs(vy[i] - vy[j]) + + overlapping = if (axis == "y") oy > 0 + else if (axis == "x") ox > 0 + else ox > 0 && oy > 0 + if (overlapping) { + any_overlap = TRUE + push_y = move_y && (oy <= ox || !move_x) + if (push_y) { + push = oy / 2 + if (vy[i] <= vy[j]) { + vy[i] = vy[i] - push + vy[j] = vy[j] + push + } else { + vy[i] = vy[i] + push + vy[j] = vy[j] - push + } + } else { + push = ox / 2 + if (vx[i] <= vx[j]) { + vx[i] = vx[i] - push + vx[j] = vx[j] + push + } else { + vx[i] = vx[i] + push + vx[j] = vx[j] - push + } + } + } + } + } + if (!any_overlap) break + } + + # Phase 2: spring pass — pull toward anchors without reintroducing overlaps + displacements = (vx - ax)^2 + (vy - ay)^2 + for (idx in order(displacements, decreasing = TRUE)) { + step_x = if (move_x) (ax[idx] - vx[idx]) * 0.5 else 0 + step_y = if (move_y) (ay[idx] - vy[idx]) * 0.5 else 0 + new_x = vx[idx] + step_x + new_y = vy[idx] + step_y + + overlaps = FALSE + for (k in seq_len(nv)) { + if (k == idx) next + ox = (vw[idx] + vw[k]) / 2 + min_gap - abs(new_x - vx[k]) + oy = (vh[idx] + vh[k]) / 2 + min_gap - abs(new_y - vy[k]) + hit = if (axis == "y") oy > 0 + else if (axis == "x") ox > 0 + else ox > 0 && oy > 0 + if (hit) { + overlaps = TRUE + break + } + } + if (!overlaps) { + vx[idx] = new_x + vy[idx] = new_y + } + } + + x[valid] = vx + y[valid] = vy + list(x = x, y = y) +} diff --git a/R/tinyplot.R b/R/tinyplot.R index c479bf62..e0ad0a69 100644 --- a/R/tinyplot.R +++ b/R/tinyplot.R @@ -150,7 +150,13 @@ #' 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. +#' in which each group appears. Supports additional arguments when passed +#' via `legend(...)`: +#' - `nudge_x`, `nudge_y`: numeric vectors of per-group offsets in data +#' units. Recycled to the number of groups. +#' - `repel`: if `TRUE`, automatically separates overlapping labels +#' vertically. If a positive number, sets the minimum gap between +#' labels in data units. #' - `"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 @@ -574,6 +580,16 @@ #' theme = "clean2" #' ) #' +#' # Use nudge_y to manually adjust label positions, or repel to auto-separate +#' # overlapping labels: +#' tinyplot( +#' Temp ~ Day | Month, +#' data = aq, +#' type = "l", +#' legend = legend("direct", repel = TRUE), +#' 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, @@ -1171,9 +1187,11 @@ tinyplot.default = function( 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)) + dl_nudge_x = legend_args[["nudge_x"]] %||% rep(0, length(last_x)) + dl_nudge_x = rep_len(dl_nudge_x, length(last_x)) offset_usr = strwidth("m", units = "user") * 0.3 label_widths = strwidth(lgnd_labs, units = "user") - overshoots = (last_x + offset_usr + label_widths) - usr_right + overshoots = (last_x + dl_nudge_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") @@ -1459,9 +1477,30 @@ tinyplot.default = function( if (direct_labels_flag) { dl_labs = lgnd_labs + nudge_x = legend_args[["nudge_x"]] %||% rep(0, length(.dl_info)) + nudge_y = legend_args[["nudge_y"]] %||% rep(0, length(.dl_info)) + nudge_x = rep_len(nudge_x, length(.dl_info)) + nudge_y = rep_len(nudge_y, length(.dl_info)) + repel = legend_args[["repel"]] + + dl_x = vapply(.dl_info, function(d) if (!is.null(d)) d$x else NA_real_, numeric(1)) + dl_y = vapply(.dl_info, function(d) if (!is.null(d)) d$y else NA_real_, numeric(1)) + dl_x = dl_x + nudge_x + dl_y = dl_y + nudge_y + + if (!is.null(repel) && !isFALSE(repel)) { + min_gap = if (isTRUE(repel)) 0 else as.numeric(repel) + dl_y = repel_text( + x = rep(0, length(dl_y)), y = dl_y, + widths = rep(0, length(dl_y)), + heights = strheight(dl_labs, units = "user"), + min_gap = min_gap, axis = "y" + )[["y"]] + } + 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], + text(dl_x[k], dl_y[k], labels = dl_labs[k], col = .dl_info[[k]]$col, pos = 4, offset = 0.3, xpd = NA) } } From 7bc0bfa39de6c1c44c69a23b34518f9883a3f104 Mon Sep 17 00:00:00 2001 From: Grant McDermott Date: Tue, 19 May 2026 13:00:00 -0700 Subject: [PATCH 08/18] repel tests --- .../legend_direct_nudge_scalar.svg | 78 +++++++++++++++++++ .../_tinysnapshot/legend_direct_nudge_y.svg | 75 ++++++++++++++++++ .../_tinysnapshot/legend_direct_repel.svg | 75 ++++++++++++++++++ inst/tinytest/test-legend_direct.R | 26 +++++++ 4 files changed, 254 insertions(+) create mode 100644 inst/tinytest/_tinysnapshot/legend_direct_nudge_scalar.svg create mode 100644 inst/tinytest/_tinysnapshot/legend_direct_nudge_y.svg create mode 100644 inst/tinytest/_tinysnapshot/legend_direct_repel.svg diff --git a/inst/tinytest/_tinysnapshot/legend_direct_nudge_scalar.svg b/inst/tinytest/_tinysnapshot/legend_direct_nudge_scalar.svg new file mode 100644 index 00000000..7cbfc03d --- /dev/null +++ b/inst/tinytest/_tinysnapshot/legend_direct_nudge_scalar.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_nudge_y.svg b/inst/tinytest/_tinysnapshot/legend_direct_nudge_y.svg new file mode 100644 index 00000000..a29400b4 --- /dev/null +++ b/inst/tinytest/_tinysnapshot/legend_direct_nudge_y.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_repel.svg b/inst/tinytest/_tinysnapshot/legend_direct_repel.svg new file mode 100644 index 00000000..7250bbb3 --- /dev/null +++ b/inst/tinytest/_tinysnapshot/legend_direct_repel.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/test-legend_direct.R b/inst/tinytest/test-legend_direct.R index 73a953e8..247e421e 100644 --- a/inst/tinytest/test-legend_direct.R +++ b/inst/tinytest/test-legend_direct.R @@ -47,6 +47,32 @@ f = function() { } expect_snapshot_plot(f, label = "legend_direct_facet") +# nudge_y adjusts label positions +f = function() { + plt(Temp ~ Day | Month, data = aq, type = "l", + legend = legend("direct", nudge_y = c(0, 1, -1, 0, -1)), + theme = "clean2") + box("outer", lty = 2) +} +expect_snapshot_plot(f, label = "legend_direct_nudge_y") + +# repel separates overlapping labels +f = function() { + plt(Temp ~ Day | Month, data = subset(aq, Day <= 30), type = "l", + legend = legend("direct", repel = TRUE), + theme = "clean2") + box("outer", lty = 2) +} +expect_snapshot_plot(f, label = "legend_direct_repel") + +# scalar nudge_y is recycled +f = function() { + plt(Temp ~ Day | Month, data = aq, type = "l", + legend = legend("direct", nudge_y = 2)) + box("outer", lty = 2) +} +expect_snapshot_plot(f, label = "legend_direct_nudge_scalar") + # No by variable: should warn and produce plot without labels expect_warning( plt(0:10, type = "l", legend = "direct"), From 0c42a10c80635efc9c1ca9e732d306b743b3ad76 Mon Sep 17 00:00:00 2001 From: Grant McDermott Date: Tue, 19 May 2026 13:00:14 -0700 Subject: [PATCH 09/18] news --- NEWS.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/NEWS.md b/NEWS.md index fe8ce571..c6f6e233 100644 --- a/NEWS.md +++ b/NEWS.md @@ -66,7 +66,10 @@ visualizations. - 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) + clipping. Pairs well with dynamic themes. Supports `nudge_x`/`nudge_y` for + manual per-group offsets and `repel` for automatic vertical separation of + overlapping labels, e.g. `legend = legend("direct", repel = TRUE)`. + (#587 @grantmcdermott) - `tinytheme()` now accepts additional `gap.axis` and `gap.lab` "primitives", providing finer control for spacing between ticks-labels and labels-titles, respectively, in dynamic themes. See the **Dynamic themes** entry above. From 23be9b4c33cd967173a118a1b345bb18b9d02142 Mon Sep 17 00:00:00 2001 From: Grant McDermott Date: Tue, 19 May 2026 13:00:26 -0700 Subject: [PATCH 10/18] docs (from main) --- man/tinyplot.Rd | 20 +++++++++++++++++++- man/tinytheme.Rd | 13 +++++++++---- 2 files changed, 28 insertions(+), 5 deletions(-) diff --git a/man/tinyplot.Rd b/man/tinyplot.Rd index 9f5b1869..a897136b 100644 --- a/man/tinyplot.Rd +++ b/man/tinyplot.Rd @@ -268,7 +268,15 @@ 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. +in which each group appears. Supports additional arguments when passed +via \code{legend(...)}: +\itemize{ +\item \code{nudge_x}, \code{nudge_y}: numeric vectors of per-group offsets in data +units. Recycled to the number of groups. +\item \code{repel}: if \code{TRUE}, automatically separates overlapping labels +vertically. If a positive number, sets the minimum gap between +labels in data units. +} \item \code{"none"}: turns off legend printing. } \item Logical value, where TRUE corresponds to the default case above (same @@ -742,6 +750,16 @@ tinyplot( theme = "clean2" ) +# Use nudge_y to manually adjust label positions, or repel to auto-separate +# overlapping labels: +tinyplot( + Temp ~ Day | Month, + data = aq, + type = "l", + legend = legend("direct", repel = TRUE), + 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/man/tinytheme.Rd b/man/tinytheme.Rd index 8156312e..8b54c1d0 100644 --- a/man/tinytheme.Rd +++ b/man/tinytheme.Rd @@ -125,21 +125,26 @@ p = function() tinyplot( p() # Set a theme -tinytheme("bw") +tinytheme("dark") p() # A set theme is persistent and will apply to subsequent plots tinyplot(0:10) # Try a different theme -tinytheme("dark") +tinytheme("clean") p() # Customize the theme by overriding default settings -tinytheme("bw", fg = "green", font.main = 2, font.sub = 3, family = "Palatino") +tinytheme("clean", + adj.xlab = 1, adj.ylab = 1, + cex.lab = 0.75, cex.axis = 0.9, + font.sub = 3, + gap.axis = 0, gap.lab = 0.5, + tcl = -0.1) p() -# Another custom theme example +# Another custom theme example, including a different font tinytheme("bw", font.main = 2, col.axis = "darkcyan", family = "HersheyScript") p() From 5031991069a23f58baa20f904a7faceba568f601 Mon Sep 17 00:00:00 2001 From: Grant McDermott Date: Tue, 19 May 2026 14:04:40 -0700 Subject: [PATCH 11/18] better facet support --- R/facet.R | 7 ++++- R/tinyplot.R | 78 +++++++++++++++++++++++++++++++++------------------- 2 files changed, 56 insertions(+), 29 deletions(-) diff --git a/R/facet.R b/R/facet.R index a20c6c9b..b06651a2 100644 --- a/R/facet.R +++ b/R/facet.R @@ -39,7 +39,8 @@ draw_facet_window = function( ylab, y, ymax, ymin, tpars = NULL, - dynmar_computed = NULL + dynmar_computed = NULL, + dl_overshoot = 0 ) { if (is.null(tpars)) tpars = tpar() @@ -195,6 +196,10 @@ draw_facet_window = function( } } + if (dl_overshoot > 0) { + fmar[4] = fmar[4] + dl_overshoot + } + # 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. diff --git a/R/tinyplot.R b/R/tinyplot.R index e0ad0a69..0be764fd 100644 --- a/R/tinyplot.R +++ b/R/tinyplot.R @@ -149,8 +149,8 @@ #' 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. Supports additional arguments when passed +#' via `by`. For faceted plots, labels are drawn in each panel for the +#' groups present there. Supports additional arguments when passed #' via `legend(...)`: #' - `nudge_x`, `nudge_y`: numeric vectors of per-group offsets in data #' units. Recycled to the number of groups. @@ -1260,6 +1260,12 @@ tinyplot.default = function( # Now draw the individual facet windows (incl. axes, grid lines, and facet titles) # Will be skipped if adding to an existing plot; see ?facet + dl_overshoot = 0 + if (direct_labels_flag && nfacets > 1) { + dl_label_lines = max(strwidth(lgnd_labs, "inches")) / par("csi") + dl_overshoot = dl_label_lines * cex_fct_adj * 0.5 + } + facet_window_args = recordGraphics( draw_facet_window( add = add, @@ -1291,7 +1297,8 @@ tinyplot.default = function( ylab = ylab, y = y, ymax = ymax, ymin = ymin, tpars = tpars, - dynmar_computed = dynmar_computed + dynmar_computed = dynmar_computed, + dl_overshoot = dl_overshoot ), list = list( add = add, @@ -1320,7 +1327,8 @@ tinyplot.default = function( ylab = ylab, y = datapoints$y, ymax = datapoints$ymax, ymin = datapoints$ymin, tpars = tpar(), # https://github.com/grantmcdermott/tinyplot/issues/474 - dynmar_computed = dynmar_computed + dynmar_computed = dynmar_computed, + dl_overshoot = dl_overshoot ), getNamespace("tinyplot") ) @@ -1341,7 +1349,7 @@ tinyplot.default = function( split_data = list(as.list(datapoints)) } - if (direct_labels_flag) .dl_info = vector("list", ngrps) + if (direct_labels_flag) .dl_info = lapply(seq_along(split_data), function(x) vector("list", ngrps)) ## Outer loop over the facets for (i in seq_along(split_data)) { @@ -1470,38 +1478,52 @@ tinyplot.default = function( ) } if (direct_labels_flag && !empty_plot && length(ix) > 0) { - .dl_info[[ii]] = list(x = tail(ix, 1), y = tail(iy, 1), col = icol) + .dl_info[[i]][[ii]] = list(x = tail(ix, 1), y = tail(iy, 1), col = icol) } } } if (direct_labels_flag) { dl_labs = lgnd_labs - nudge_x = legend_args[["nudge_x"]] %||% rep(0, length(.dl_info)) - nudge_y = legend_args[["nudge_y"]] %||% rep(0, length(.dl_info)) - nudge_x = rep_len(nudge_x, length(.dl_info)) - nudge_y = rep_len(nudge_y, length(.dl_info)) + nudge_x = legend_args[["nudge_x"]] %||% rep(0, ngrps) + nudge_y = legend_args[["nudge_y"]] %||% rep(0, ngrps) + nudge_x = rep_len(nudge_x, ngrps) + nudge_y = rep_len(nudge_y, ngrps) repel = legend_args[["repel"]] - dl_x = vapply(.dl_info, function(d) if (!is.null(d)) d$x else NA_real_, numeric(1)) - dl_y = vapply(.dl_info, function(d) if (!is.null(d)) d$y else NA_real_, numeric(1)) - dl_x = dl_x + nudge_x - dl_y = dl_y + nudge_y - - if (!is.null(repel) && !isFALSE(repel)) { - min_gap = if (isTRUE(repel)) 0 else as.numeric(repel) - dl_y = repel_text( - x = rep(0, length(dl_y)), y = dl_y, - widths = rep(0, length(dl_y)), - heights = strheight(dl_labs, units = "user"), - min_gap = min_gap, axis = "y" - )[["y"]] - } + for (fi in seq_along(.dl_info)) { + if (nfacets > 1) { + mfgi = ceiling(fi / nfacet_cols) + mfgj = fi %% nfacet_cols + if (mfgj == 0) mfgj = nfacet_cols + par(mfg = c(mfgi, mfgj)) + if (isTRUE(facet.args[["free"]])) { + fusr = get(".fusr", envir = get(".tinyplot_env", envir = parent.env(environment()))) + par(usr = fusr[[fi]]) + } + } + + fi_info = .dl_info[[fi]] + dl_x = vapply(fi_info, function(d) if (!is.null(d)) d$x else NA_real_, numeric(1)) + dl_y = vapply(fi_info, function(d) if (!is.null(d)) d$y else NA_real_, numeric(1)) + dl_x = dl_x + nudge_x + dl_y = dl_y + nudge_y + + if (!is.null(repel) && !isFALSE(repel)) { + min_gap = if (isTRUE(repel)) 0 else as.numeric(repel) + dl_y = repel_text( + x = rep(0, length(dl_y)), y = dl_y, + widths = rep(0, length(dl_y)), + heights = strheight(dl_labs, units = "user"), + min_gap = min_gap, axis = "y" + )[["y"]] + } - for (k in seq_along(.dl_info)) { - if (!is.null(.dl_info[[k]])) { - text(dl_x[k], dl_y[k], labels = dl_labs[k], - col = .dl_info[[k]]$col, pos = 4, offset = 0.3, xpd = NA) + for (k in seq_along(fi_info)) { + if (!is.null(fi_info[[k]])) { + text(dl_x[k], dl_y[k], labels = dl_labs[k], + col = fi_info[[k]]$col, pos = 4, offset = 0.3, xpd = NA) + } } } } From c820f35086c9a181ff754d7fce588b8054758b8a Mon Sep 17 00:00:00 2001 From: Grant McDermott Date: Tue, 19 May 2026 14:04:55 -0700 Subject: [PATCH 12/18] news --- NEWS.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/NEWS.md b/NEWS.md index c6f6e233..a8a8347d 100644 --- a/NEWS.md +++ b/NEWS.md @@ -66,7 +66,8 @@ visualizations. - 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. Supports `nudge_x`/`nudge_y` for + clipping. Pairs well with dynamic themes. For faceted plots, labels are drawn + in each panel for the groups present there. Supports `nudge_x`/`nudge_y` for manual per-group offsets and `repel` for automatic vertical separation of overlapping labels, e.g. `legend = legend("direct", repel = TRUE)`. (#587 @grantmcdermott) From c7e4ccaa2c3475e80b33d7cc155fde9a3d740958 Mon Sep 17 00:00:00 2001 From: Grant McDermott Date: Tue, 19 May 2026 14:07:07 -0700 Subject: [PATCH 13/18] docs --- man/facet.Rd | 3 ++- man/repel_text.Rd | 43 +++++++++++++++++++++++++++++++++++++++++++ man/tinyplot.Rd | 4 ++-- 3 files changed, 47 insertions(+), 3 deletions(-) create mode 100644 man/repel_text.Rd diff --git a/man/facet.Rd b/man/facet.Rd index bd5f864a..8799e439 100644 --- a/man/facet.Rd +++ b/man/facet.Rd @@ -59,7 +59,8 @@ draw_facet_window( ymax, ymin, tpars = NULL, - dynmar_computed = NULL + dynmar_computed = NULL, + dl_overshoot = 0 ) facet_layout(settings) diff --git a/man/repel_text.Rd b/man/repel_text.Rd new file mode 100644 index 00000000..22b3ccd6 --- /dev/null +++ b/man/repel_text.Rd @@ -0,0 +1,43 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/repel.R +\name{repel_text} +\alias{repel_text} +\title{Force-directed text repelling} +\usage{ +repel_text( + x, + y, + widths, + heights, + anchor_x = x, + anchor_y = y, + min_gap = 0, + iterations = 20, + axis = "both" +) +} +\arguments{ +\item{x, y}{Numeric vectors of current label positions.} + +\item{widths, heights}{Numeric vectors of label bounding box dimensions (in +the same units as \code{x} and \code{y}).} + +\item{anchor_x, anchor_y}{Numeric vectors of original target positions that +labels are pulled back toward during the spring pass. Defaults to \code{x} +and \code{y}.} + +\item{min_gap}{Minimum padding between labels. Default \code{0}.} + +\item{iterations}{Maximum number of repulsion iterations. Default \code{20}.} + +\item{axis}{Movement constraint: \code{"both"} (default), \code{"x"}, or \code{"y"}.} +} +\value{ +A list with elements \code{x} and \code{y} giving adjusted positions. +} +\description{ +Resolves overlapping text labels using a two-phase algorithm: (1) push +overlapping labels apart symmetrically, (2) spring-pull labels back toward +their anchors without reintroducing overlaps. +} +\keyword{internal} diff --git a/man/tinyplot.Rd b/man/tinyplot.Rd index a897136b..f67f1dc3 100644 --- a/man/tinyplot.Rd +++ b/man/tinyplot.Rd @@ -267,8 +267,8 @@ adjusts the margins accordingly. 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. Supports additional arguments when passed +via \code{by}. For faceted plots, labels are drawn in each panel for the +groups present there. Supports additional arguments when passed via \code{legend(...)}: \itemize{ \item \code{nudge_x}, \code{nudge_y}: numeric vectors of per-group offsets in data From 9d70a269cf766f1f8494447b3c3d672f1b73b6f8 Mon Sep 17 00:00:00 2001 From: Grant McDermott Date: Tue, 19 May 2026 14:09:11 -0700 Subject: [PATCH 14/18] redo snapshot --- .../_tinysnapshot/legend_direct_facet.svg | 153 ++++++++++-------- 1 file changed, 82 insertions(+), 71 deletions(-) diff --git a/inst/tinytest/_tinysnapshot/legend_direct_facet.svg b/inst/tinytest/_tinysnapshot/legend_direct_facet.svg index 670da572..b90120c5 100644 --- a/inst/tinytest/_tinysnapshot/legend_direct_facet.svg +++ b/inst/tinytest/_tinysnapshot/legend_direct_facet.svg @@ -30,97 +30,108 @@ Temp - - + + - + -0 -5 -10 -15 -20 -25 -30 +0 +5 +10 +15 +20 +25 +30 60 70 80 90 - -cool - - - - - - - - - - - - - + +cool + + + + + + + + + + + + + - - + + - + + + +0 +5 +10 +15 +20 +25 +30 + +hot + + + + + + + + + + + + + + + + + + + + + + + + + + + + -0 -5 -10 -15 -20 -25 -30 - -hot - - - - - - - - - - - - - - - - - - - - - - - - - - +May +June +July +August +September + + -May -June -July -August -September +May +June +July +August +September - - + + - + From 13cb2de80ff254339d05cbf5dfaa97463a1deaf9 Mon Sep 17 00:00:00 2001 From: Grant McDermott Date: Tue, 19 May 2026 14:45:14 -0700 Subject: [PATCH 15/18] named vector for targeted nudge --- R/tinyplot.R | 11 ++++++++++- man/tinyplot.Rd | 3 ++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/R/tinyplot.R b/R/tinyplot.R index 0be764fd..a7991f81 100644 --- a/R/tinyplot.R +++ b/R/tinyplot.R @@ -153,7 +153,8 @@ #' groups present there. Supports additional arguments when passed #' via `legend(...)`: #' - `nudge_x`, `nudge_y`: numeric vectors of per-group offsets in data -#' units. Recycled to the number of groups. +#' units. Recycled to the number of groups. Supports named vectors +#' for targeted adjustment, e.g. `nudge_y = c("August" = 5)`. #' - `repel`: if `TRUE`, automatically separates overlapping labels #' vertically. If a positive number, sets the minimum gap between #' labels in data units. @@ -1487,6 +1488,14 @@ tinyplot.default = function( dl_labs = lgnd_labs nudge_x = legend_args[["nudge_x"]] %||% rep(0, ngrps) nudge_y = legend_args[["nudge_y"]] %||% rep(0, ngrps) + if (!is.null(names(nudge_x))) { + idx = match(lgnd_labs, names(nudge_x)) + nudge_x = ifelse(is.na(idx), 0, nudge_x[idx]) + } + if (!is.null(names(nudge_y))) { + idx = match(lgnd_labs, names(nudge_y)) + nudge_y = ifelse(is.na(idx), 0, nudge_y[idx]) + } nudge_x = rep_len(nudge_x, ngrps) nudge_y = rep_len(nudge_y, ngrps) repel = legend_args[["repel"]] diff --git a/man/tinyplot.Rd b/man/tinyplot.Rd index f67f1dc3..bf8bf212 100644 --- a/man/tinyplot.Rd +++ b/man/tinyplot.Rd @@ -272,7 +272,8 @@ groups present there. Supports additional arguments when passed via \code{legend(...)}: \itemize{ \item \code{nudge_x}, \code{nudge_y}: numeric vectors of per-group offsets in data -units. Recycled to the number of groups. +units. Recycled to the number of groups. Supports named vectors +for targeted adjustment, e.g. \code{nudge_y = c("August" = 5)}. \item \code{repel}: if \code{TRUE}, automatically separates overlapping labels vertically. If a positive number, sets the minimum gap between labels in data units. From d7af3fdf3e2e69438bc70f259fafbe51128d6a3e Mon Sep 17 00:00:00 2001 From: Grant McDermott Date: Tue, 19 May 2026 14:45:52 -0700 Subject: [PATCH 16/18] tests --- .../legend_direct_nudge_named.svg | 75 +++++++++++++++++++ inst/tinytest/test-legend_direct.R | 9 +++ 2 files changed, 84 insertions(+) create mode 100644 inst/tinytest/_tinysnapshot/legend_direct_nudge_named.svg diff --git a/inst/tinytest/_tinysnapshot/legend_direct_nudge_named.svg b/inst/tinytest/_tinysnapshot/legend_direct_nudge_named.svg new file mode 100644 index 00000000..014212e7 --- /dev/null +++ b/inst/tinytest/_tinysnapshot/legend_direct_nudge_named.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/test-legend_direct.R b/inst/tinytest/test-legend_direct.R index 247e421e..f74975df 100644 --- a/inst/tinytest/test-legend_direct.R +++ b/inst/tinytest/test-legend_direct.R @@ -73,6 +73,15 @@ f = function() { } expect_snapshot_plot(f, label = "legend_direct_nudge_scalar") +# named nudge_y targets specific groups +f = function() { + plt(Temp ~ Day | Month, data = aq, type = "l", + legend = legend("direct", nudge_y = c("June" = -3, "August" = 2)), + theme = "clean2") + box("outer", lty = 2) +} +expect_snapshot_plot(f, label = "legend_direct_nudge_named") + # No by variable: should warn and produce plot without labels expect_warning( plt(0:10, type = "l", legend = "direct"), From 4befef1336776cf55c0006e3ed69e029cf6070e8 Mon Sep 17 00:00:00 2001 From: Grant McDermott Date: Tue, 19 May 2026 15:18:09 -0700 Subject: [PATCH 17/18] slightly better example --- R/tinyplot.R | 2 +- man/tinyplot.Rd | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/R/tinyplot.R b/R/tinyplot.R index a7991f81..319da136 100644 --- a/R/tinyplot.R +++ b/R/tinyplot.R @@ -154,7 +154,7 @@ #' via `legend(...)`: #' - `nudge_x`, `nudge_y`: numeric vectors of per-group offsets in data #' units. Recycled to the number of groups. Supports named vectors -#' for targeted adjustment, e.g. `nudge_y = c("August" = 5)`. +#' for targeted adjustment, e.g. `nudge_y = c(May = -1, June = 2)`. #' - `repel`: if `TRUE`, automatically separates overlapping labels #' vertically. If a positive number, sets the minimum gap between #' labels in data units. diff --git a/man/tinyplot.Rd b/man/tinyplot.Rd index bf8bf212..8e5eed6c 100644 --- a/man/tinyplot.Rd +++ b/man/tinyplot.Rd @@ -273,7 +273,7 @@ via \code{legend(...)}: \itemize{ \item \code{nudge_x}, \code{nudge_y}: numeric vectors of per-group offsets in data units. Recycled to the number of groups. Supports named vectors -for targeted adjustment, e.g. \code{nudge_y = c("August" = 5)}. +for targeted adjustment, e.g. \code{nudge_y = c(May = -1, June = 2)}. \item \code{repel}: if \code{TRUE}, automatically separates overlapping labels vertically. If a positive number, sets the minimum gap between labels in data units. From 1736453f72b30dc7221350240c4455a5dc64e828 Mon Sep 17 00:00:00 2001 From: Grant McDermott Date: Tue, 19 May 2026 16:18:31 -0700 Subject: [PATCH 18/18] warning if both nudge and repl are specified --- R/tinyplot.R | 39 +++++++++++++++++++++++++-------------- man/tinyplot.Rd | 8 +++++--- 2 files changed, 30 insertions(+), 17 deletions(-) diff --git a/R/tinyplot.R b/R/tinyplot.R index 319da136..7fd90150 100644 --- a/R/tinyplot.R +++ b/R/tinyplot.R @@ -154,7 +154,8 @@ #' via `legend(...)`: #' - `nudge_x`, `nudge_y`: numeric vectors of per-group offsets in data #' units. Recycled to the number of groups. Supports named vectors -#' for targeted adjustment, e.g. `nudge_y = c(May = -1, June = 2)`. +#' for targeted adjustment, e.g. +#' `nudge_y = c("Group A" = -10, "Group B" = 20)`. #' - `repel`: if `TRUE`, automatically separates overlapping labels #' vertically. If a positive number, sets the minimum gap between #' labels in data units. @@ -581,12 +582,13 @@ #' theme = "clean2" #' ) #' -#' # Use nudge_y to manually adjust label positions, or repel to auto-separate -#' # overlapping labels: +#' # Use `nudge_x/y`` to manually adjust label positions, or `repel` to +#' # auto-separate overlapping labels: #' tinyplot( #' Temp ~ Day | Month, #' data = aq, #' type = "l", +#' # legend = legend("direct", nudge_y = c(May = -1, Jun = 2)), # another option #' legend = legend("direct", repel = TRUE), #' theme = "clean2" #' ) @@ -1486,19 +1488,28 @@ tinyplot.default = function( if (direct_labels_flag) { dl_labs = lgnd_labs - nudge_x = legend_args[["nudge_x"]] %||% rep(0, ngrps) - nudge_y = legend_args[["nudge_y"]] %||% rep(0, ngrps) - if (!is.null(names(nudge_x))) { - idx = match(lgnd_labs, names(nudge_x)) - nudge_x = ifelse(is.na(idx), 0, nudge_x[idx]) + repel = legend_args[["repel"]] + has_nudge = !is.null(legend_args[["nudge_x"]]) || !is.null(legend_args[["nudge_y"]]) + if (has_nudge && !is.null(repel) && !isFALSE(repel)) { + warning("Direct labels: both `nudge` and `repel` specified. Using `nudge`.", call. = FALSE) + repel = FALSE } - if (!is.null(names(nudge_y))) { - idx = match(lgnd_labs, names(nudge_y)) - nudge_y = ifelse(is.na(idx), 0, nudge_y[idx]) + nudge_x = rep(0, ngrps) + nudge_y = rep(0, ngrps) + if (has_nudge) { + nudge_x = legend_args[["nudge_x"]] %||% rep(0, ngrps) + nudge_y = legend_args[["nudge_y"]] %||% rep(0, ngrps) + if (!is.null(names(nudge_x))) { + idx = match(lgnd_labs, names(nudge_x)) + nudge_x = ifelse(is.na(idx), 0, nudge_x[idx]) + } + if (!is.null(names(nudge_y))) { + idx = match(lgnd_labs, names(nudge_y)) + nudge_y = ifelse(is.na(idx), 0, nudge_y[idx]) + } + nudge_x = rep_len(nudge_x, ngrps) + nudge_y = rep_len(nudge_y, ngrps) } - nudge_x = rep_len(nudge_x, ngrps) - nudge_y = rep_len(nudge_y, ngrps) - repel = legend_args[["repel"]] for (fi in seq_along(.dl_info)) { if (nfacets > 1) { diff --git a/man/tinyplot.Rd b/man/tinyplot.Rd index 8e5eed6c..9e1850cc 100644 --- a/man/tinyplot.Rd +++ b/man/tinyplot.Rd @@ -273,7 +273,8 @@ via \code{legend(...)}: \itemize{ \item \code{nudge_x}, \code{nudge_y}: numeric vectors of per-group offsets in data units. Recycled to the number of groups. Supports named vectors -for targeted adjustment, e.g. \code{nudge_y = c(May = -1, June = 2)}. +for targeted adjustment, e.g. +\code{nudge_y = c("Group A" = -10, "Group B" = 20)}. \item \code{repel}: if \code{TRUE}, automatically separates overlapping labels vertically. If a positive number, sets the minimum gap between labels in data units. @@ -751,12 +752,13 @@ tinyplot( theme = "clean2" ) -# Use nudge_y to manually adjust label positions, or repel to auto-separate -# overlapping labels: +# Use `nudge_x/y`` to manually adjust label positions, or `repel` to +# auto-separate overlapping labels: tinyplot( Temp ~ Day | Month, data = aq, type = "l", + # legend = legend("direct", nudge_y = c(May = -1, Jun = 2)), # another option legend = legend("direct", repel = TRUE), theme = "clean2" )