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 @@
+
+
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 @@
+
+
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 @@
+
+
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 @@
+
+
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 @@
+
+
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