diff --git a/NEWS.md b/NEWS.md index 3b9ce2d4..985c8300 100644 --- a/NEWS.md +++ b/NEWS.md @@ -153,10 +153,13 @@ Theme fixes: and drawing them as standalone bars. This is useful for Likert plots, where you want to show a neutral categories (e.g., "Unsure") apart from the diverging stack. Thanks to @strengejacke for the suggestion. -- `type_text()` (and `type = "text"`) gains a `labeller` argument that is passed - to `tinylabel()` for formatting the text labels. This is useful for ensuring - that text annotations match a formatted axis, e.g. `labeller = "%"` to display - the labels as percentages. (#620 @grantmcdermott) +- `type_text()` gains two new arguments: + - a `labeller` argument that is passed to `tinylabel()` for formatting the + text labels. (#620 @grantmcdermott) + - a `repel` argument that automatically nudges overlapping text labels apart. + One limitation is that the repulsion logic operates with groups. So there + may still be some overlapping text for for grouped data. + (#621 @grantmcdermott) ### Bug fixes diff --git a/R/type_text.R b/R/type_text.R index fd7c8af6..d1b27531 100644 --- a/R/type_text.R +++ b/R/type_text.R @@ -21,6 +21,16 @@ #' @param xpd Logical value or `NA` denoting text clipping behaviour, following #' \code{\link[graphics]{par}}. #' @param srt Numeric giving the desired string rotation in degrees. +#' @param repel Logical or numeric controlling automatic repulsion of +#' overlapping text labels. The default `FALSE` draws the labels at their +#' exact (`x`,`y`) coordinates. `TRUE` nudges overlapping labels apart using a +#' force-directed algorithm (labels are pushed off each other and then sprung +#' back toward their original positions). A numeric value does the same but +#' additionally enforces that much minimum padding (in user coordinates) +#' between labels. Works best with the default centered text placement (i.e. +#' without `pos`). **Caveat:** The repulsion logic currently operates +#' within each group rather than across groups. So the text of different +#' groups may still overlap. See Examples. #' @param clim Numeric giving the lower and upper limits of the character #' expansion (`cex`) normalization for bubble charts. #' @inheritParams graphics::text @@ -30,10 +40,19 @@ #' #' # pass explicit `labels` arg if you want specific text #' tinyplot(1:12, type = "text", labels = month.abb) +#' +#' # you can also use a labeller function (passed to `tinylabel`) to +#' # customize +#' tinyplot(1:12, type = "text", labels = month.abb, labeller = toupper) #' #' # for advanced customization, it's safer to pass args through `type_text()` -#' tinyplot(1:12, type = type_text( -#' labels = month.abb, family = "HersheyScript", srt = -20)) +#' tinyplot( +#' 1:12, +#' type = type_text( +#' labels = month.abb, +#' family = "HersheyScript", +#' srt = -20) +#' ) #' #' # same principles apply to grouped and/or facet data #' tinyplot(mpg ~ hp | factor(cyl), @@ -58,10 +77,18 @@ #' ) #' ) #' -#' # use `labeller` to format the labels, e.g. to match a formatted axis -#' d = data.frame(x = c("A", "B"), y = c(0.5, 0.8)) -#' tinyplot(y ~ x, data = d, type = "bar", ylim = c(0, 1), yaxl = "%") -#' tinyplot_add(type = type_text(labeller = "%", adj = c(0.5, -0.5))) +#' # use `repel = TRUE` to automically nudge overlapping labels apart +#' tinyplot( +#' mpg ~ wt, data = mtcars, +#' type = type_text(labels = row.names(mtcars), repel = TRUE) +#' ) +#' +#' # limitation: `repel` logic currently works per group, so grouped text data +#' # may still overlap +#' tinyplot( +#' mpg ~ wt | factor(cyl), data = mtcars, +#' type = type_text(labels = row.names(mtcars), repel = TRUE) +#' ) #' #' @export type_text = function( @@ -75,6 +102,7 @@ type_text = function( vfont = NULL, xpd = NULL, srt = 0, + repel = FALSE, clim = c(0.5, 2.5) ) { out = list( @@ -86,7 +114,8 @@ type_text = function( family = family, font = font, xpd = xpd, - srt = srt + srt = srt, + repel = repel ), data = data_text(labels = labels, labeller = labeller, clim = clim), name = "text" @@ -133,7 +162,8 @@ draw_text = function( family = NULL, font = NULL, xpd = NULL, - srt = 0 + srt = 0, + repel = FALSE ) { if (is.null(xpd)) { xpd = par("xpd") @@ -142,6 +172,20 @@ draw_text = function( vfont = NULL } fun = function(ix, iy, ilabels, icol, icex, ...) { + # Optionally repel overlapping labels. Measurement requires user + # coordinates, which are only correct here (post `plot.window()`), not in + # the type's data function. See `repel_text()` in R/repel.R. + if (!isFALSE(repel) && length(ix) > 1) { + min_gap = if (isTRUE(repel)) 0 else as.numeric(repel) + w = strwidth(ilabels, units = "user", cex = icex, font = font, family = family) + h = strheight(ilabels, units = "user", cex = icex, font = font, family = family) + rp = repel_text( + x = ix, y = iy, widths = w, heights = h, + min_gap = min_gap, axis = "both" + ) + ix = rp[["x"]] + iy = rp[["y"]] + } text( x = ix, y = iy, diff --git a/inst/tinytest/_tinysnapshot/text_repel.svg b/inst/tinytest/_tinysnapshot/text_repel.svg new file mode 100644 index 00000000..5e8557ca --- /dev/null +++ b/inst/tinytest/_tinysnapshot/text_repel.svg @@ -0,0 +1,94 @@ + + + + + + + + + + + + + +repel = TRUE +wt +mpg + + + + + +2 +3 +4 +5 + + + + + + +10 +15 +20 +25 +30 + + + + + + + + +Mazda RX4 +Mazda RX4 Wag +Datsun 710 +Hornet 4 Drive +Hornet Sportabout +Valiant +Duster 360 +Merc 240D +Merc 230 +Merc 280 +Merc 280C +Merc 450SE +Merc 450SL +Merc 450SLC +Cadillac Fleetwood +Lincoln Continental +Chrysler Imperial +Fiat 128 +Honda Civic +Toyota Corolla +Toyota Corona +Dodge Challenger +AMC Javelin +Camaro Z28 +Pontiac Firebird +Fiat X1-9 +Porsche 914-2 +Lotus Europa +Ford Pantera L +Ferrari Dino +Maserati Bora +Volvo 142E + + + diff --git a/inst/tinytest/test-type_text.R b/inst/tinytest/test-type_text.R index 5d73af6c..e5c2dc64 100644 --- a/inst/tinytest/test-type_text.R +++ b/inst/tinytest/test-type_text.R @@ -38,3 +38,12 @@ f = function() { tinyplot_add(type = type_text(labeller = "%", adj = c(0.5, -0.5))) } expect_snapshot_plot(f, label = "text_labeller_percent") + + +# repel arg nudges overlapping labels apart (#318) +f = function() { + tinyplot(mpg ~ wt, data = mtcars, + type = type_text(labels = row.names(mtcars), repel = TRUE), + main = "repel = TRUE") +} +expect_snapshot_plot(f, label = "text_repel") diff --git a/man/type_text.Rd b/man/type_text.Rd index ed0e6703..5bda6069 100644 --- a/man/type_text.Rd +++ b/man/type_text.Rd @@ -15,6 +15,7 @@ type_text( vfont = NULL, xpd = NULL, srt = 0, + repel = FALSE, clim = c(0.5, 2.5) ) } @@ -65,6 +66,17 @@ regular, \code{2} = bold, \code{3} = italic, \code{4} = bold italic, and \code{5 \item{srt}{Numeric giving the desired string rotation in degrees.} +\item{repel}{Logical or numeric controlling automatic repulsion of +overlapping text labels. The default \code{FALSE} draws the labels at their +exact (\code{x},\code{y}) coordinates. \code{TRUE} nudges overlapping labels apart using a +force-directed algorithm (labels are pushed off each other and then sprung +back toward their original positions). A numeric value does the same but +additionally enforces that much minimum padding (in user coordinates) +between labels. Works best with the default centered text placement (i.e. +without \code{pos}). \strong{Caveat:} The repulsion logic currently operates +within each group rather than across groups. So the text of different +groups may still overlap. See Examples.} + \item{clim}{Numeric giving the lower and upper limits of the character expansion (\code{cex}) normalization for bubble charts.} } @@ -79,9 +91,18 @@ tinyplot(1:12, type = "text") # pass explicit `labels` arg if you want specific text tinyplot(1:12, type = "text", labels = month.abb) +# you can also use a labeller function (passed to `tinylabel`) to +# customize +tinyplot(1:12, type = "text", labels = month.abb, labeller = toupper) + # for advanced customization, it's safer to pass args through `type_text()` -tinyplot(1:12, type = type_text( - labels = month.abb, family = "HersheyScript", srt = -20)) +tinyplot( + 1:12, + type = type_text( + labels = month.abb, + family = "HersheyScript", + srt = -20) +) # same principles apply to grouped and/or facet data tinyplot(mpg ~ hp | factor(cyl), @@ -106,9 +127,17 @@ tinyplot(mpg ~ hp | factor(cyl), ) ) -# use `labeller` to format the labels, e.g. to match a formatted axis -d = data.frame(x = c("A", "B"), y = c(0.5, 0.8)) -tinyplot(y ~ x, data = d, type = "bar", ylim = c(0, 1), yaxl = "\%") -tinyplot_add(type = type_text(labeller = "\%", adj = c(0.5, -0.5))) +# use `repel = TRUE` to automically nudge overlapping labels apart +tinyplot( + mpg ~ wt, data = mtcars, + type = type_text(labels = row.names(mtcars), repel = TRUE) +) + +# limitation: `repel` logic currently works per group, so grouped text data +# may still overlap +tinyplot( + mpg ~ wt | factor(cyl), data = mtcars, + type = type_text(labels = row.names(mtcars), repel = TRUE) +) }