diff --git a/R/guides-axis.r b/R/guides-axis.r index 62c6f29b1c..3b13fcea06 100644 --- a/R/guides-axis.r +++ b/R/guides-axis.r @@ -4,11 +4,20 @@ #' @param break_position position of ticks #' @param break_labels labels at ticks #' @param axis_position position of axis (top, bottom, left or right) -#' @param theme A [theme()] object +#' @param theme A complete [theme()] object +#' @param check.overlap silently remove overlapping labels, +#' (recursively) prioritizing the first, last, and middle labels. +#' @param angle Compared to setting the angle in [theme()] / [element_text()], +#' this also uses some heuristics to automatically pick the `hjust` and `vjust` that +#' you probably want. +#' @param n_dodge The number of rows (for vertical axes) or columns (for +#' horizontal axes) that should be used to render the labels. This is +#' useful for displaying labels that would otherwise overlap. #' #' @noRd #' -draw_axis <- function(break_positions, break_labels, axis_position, theme) { +draw_axis <- function(break_positions, break_labels, axis_position, theme, + check.overlap = FALSE, angle = NULL, n_dodge = 1) { axis_position <- match.arg(axis_position, c("top", "bottom", "right", "left")) aesthetic <- if (axis_position %in% c("top", "bottom")) "x" else "y" @@ -24,6 +33,14 @@ draw_axis <- function(break_positions, break_labels, axis_position, theme) { tick_length <- calc_element(tick_length_element_name, theme) label_element <- calc_element(label_element_name, theme) + # override label element parameters for rotation + if (inherits(label_element, "element_text")) { + label_element <- merge_element( + axis_label_element_overrides(axis_position, angle), + label_element + ) + } + # conditionally set parameters that depend on axis orientation is_vertical <- axis_position %in% c("left", "right") @@ -31,10 +48,9 @@ draw_axis <- function(break_positions, break_labels, axis_position, theme) { non_position_dim <- if (is_vertical) "x" else "y" position_size <- if (is_vertical) "height" else "width" non_position_size <- if (is_vertical) "width" else "height" - label_margin_name <- if (is_vertical) "margin_x" else "margin_y" gtable_element <- if (is_vertical) gtable_row else gtable_col measure_gtable <- if (is_vertical) gtable_width else gtable_height - measure_labels <- if (is_vertical) grobWidth else grobHeight + measure_labels_non_pos <- if (is_vertical) grobWidth else grobHeight # conditionally set parameters that depend on which side of the panel # the axis is on @@ -47,8 +63,6 @@ draw_axis <- function(break_positions, break_labels, axis_position, theme) { # conditionally set the gtable ordering labels_first_gtable <- axis_position %in% c("left", "top") # refers to position in gtable - table_order <- if (labels_first_gtable) c("labels", "ticks") else c("ticks", "labels") - # set common parameters n_breaks <- length(break_positions) opposite_positions <- c("top" = "bottom", "bottom" = "top", "right" = "left", "left" = "right") @@ -80,12 +94,19 @@ draw_axis <- function(break_positions, break_labels, axis_position, theme) { } } - labels_grob <- exec( - element_grob, label_element, - !!position_dim := unit(break_positions, "native"), - !!label_margin_name := TRUE, - label = break_labels - ) + # calculate multiple rows/columns of labels (which is usually 1) + dodge_pos <- rep(seq_len(n_dodge), length.out = n_breaks) + dodge_indices <- split(seq_len(n_breaks), dodge_pos) + + label_grobs <- lapply(dodge_indices, function(indices) { + draw_axis_labels( + break_positions = break_positions[indices], + break_labels = break_labels[indices], + label_element = label_element, + is_vertical = is_vertical, + check.overlap = check.overlap + ) + }) ticks_grob <- exec( element_grob, tick_element, @@ -98,14 +119,21 @@ draw_axis <- function(break_positions, break_labels, axis_position, theme) { ) # create gtable - table_order_int <- match(table_order, c("labels", "ticks")) non_position_sizes <- paste0(non_position_size, "s") + label_dims <- do.call(unit.c, lapply(label_grobs, measure_labels_non_pos)) + grobs <- c(list(ticks_grob), label_grobs) + grob_dims <- unit.c(tick_length, label_dims) + + if (labels_first_gtable) { + grobs <- rev(grobs) + grob_dims <- rev(grob_dims) + } gt <- exec( gtable_element, name = "axis", - grobs = list(labels_grob, ticks_grob)[table_order_int], - !!non_position_sizes := unit.c(measure_labels(labels_grob), tick_length)[table_order_int], + grobs = grobs, + !!non_position_sizes := grob_dims, !!position_size := unit(1, "npc") ) @@ -124,3 +152,106 @@ draw_axis <- function(break_positions, break_labels, axis_position, theme) { vp = justvp ) } + +draw_axis_labels <- function(break_positions, break_labels, label_element, is_vertical, + check.overlap = FALSE) { + + position_dim <- if (is_vertical) "y" else "x" + label_margin_name <- if (is_vertical) "margin_x" else "margin_y" + + n_breaks <- length(break_positions) + break_positions <- unit(break_positions, "native") + + if (check.overlap) { + priority <- axis_label_priority(n_breaks) + break_labels <- break_labels[priority] + break_positions <- break_positions[priority] + } + + labels_grob <- exec( + element_grob, label_element, + !!position_dim := break_positions, + !!label_margin_name := TRUE, + label = break_labels, + check.overlap = check.overlap + ) +} + +#' Determine the label priority for a given number of labels +#' +#' @param n The number of labels +#' +#' @return The vector `seq_len(n)` arranged such that the +#' first, last, and middle elements are recursively +#' placed at the beginning of the vector. +#' @noRd +#' +axis_label_priority <- function(n) { + if (n <= 0) { + return(numeric(0)) + } + + c(1, n, axis_label_priority_between(1, n)) +} + +axis_label_priority_between <- function(x, y) { + n <- y - x + 1 + if (n <= 2) { + return(numeric(0)) + } + + mid <- x - 1 + (n + 1) %/% 2 + c( + mid, + axis_label_priority_between(x, mid), + axis_label_priority_between(mid, y) + ) +} + +#' Override axis text angle and alignment +#' +#' @param axis_position One of bottom, left, top, or right +#' @param angle The text angle, or NULL to override nothing +#' +#' @return An [element_text()] that contains parameters that should be +#' overridden from the user- or theme-supplied element. +#' @noRd +#' +axis_label_element_overrides <- function(axis_position, angle = NULL) { + if (is.null(angle)) { + return(element_text(angle = NULL, hjust = NULL, vjust = NULL)) + } + + # it is not worth the effort to align upside-down labels properly + if (angle > 90 || angle < -90) { + stop("`angle` must be between 90 and -90", call. = FALSE) + } + + if (axis_position == "bottom") { + element_text( + angle = angle, + hjust = if (angle > 0) 1 else if (angle < 0) 0 else 0.5, + vjust = if (abs(angle) == 90) 0.5 else 1 + ) + } else if (axis_position == "left") { + element_text( + angle = angle, + hjust = if (abs(angle) == 90) 0.5 else 1, + vjust = if (angle > 0) 0 else if (angle < 0) 1 else 0.5, + ) + } else if (axis_position == "top") { + element_text( + angle = angle, + hjust = if (angle > 0) 0 else if (angle < 0) 1 else 0.5, + vjust = if (abs(angle) == 90) 0.5 else 0 + ) + } else if (axis_position == "right") { + element_text( + angle = angle, + hjust = if (abs(angle) == 90) 0.5 else 0, + vjust = if (angle > 0) 1 else if (angle < 0) 0 else 0.5, + ) + } else { + stop("Unrecognized position: '", axis_position, "'", call. = FALSE) + } +} diff --git a/R/margins.R b/R/margins.R index 6314981f6a..b85f37a5fe 100644 --- a/R/margins.R +++ b/R/margins.R @@ -37,7 +37,7 @@ margin_width <- function(grob, margins) { #' #' @noRd title_spec <- function(label, x, y, hjust, vjust, angle, gp = gpar(), - debug = FALSE) { + debug = FALSE, check.overlap = FALSE) { if (is.null(label)) return(zeroGrob()) @@ -56,7 +56,8 @@ title_spec <- function(label, x, y, hjust, vjust, angle, gp = gpar(), hjust = hjust, vjust = vjust, rot = angle, - gp = gp + gp = gp, + check.overlap = check.overlap ) # The grob dimensions don't include the text descenders, so these need to be added @@ -175,7 +176,7 @@ add_margins <- function(grob, height, width, margin = NULL, #' @noRd titleGrob <- function(label, x, y, hjust, vjust, angle = 0, gp = gpar(), margin = NULL, margin_x = FALSE, margin_y = FALSE, - debug = FALSE) { + debug = FALSE, check.overlap = FALSE) { if (is.null(label)) return(zeroGrob()) @@ -189,7 +190,8 @@ titleGrob <- function(label, x, y, hjust, vjust, angle = 0, gp = gpar(), vjust = vjust, angle = angle, gp = gp, - debug = debug + debug = debug, + check.overlap = check.overlap ) add_margins( diff --git a/R/theme-elements.r b/R/theme-elements.r index 247c4c868c..f2cdd5c0fa 100644 --- a/R/theme-elements.r +++ b/R/theme-elements.r @@ -215,7 +215,7 @@ element_grob.element_text <- function(element, label = "", x = NULL, y = NULL, titleGrob(label, x, y, hjust = hj, vjust = vj, angle = angle, gp = modify_list(element_gp, gp), margin = margin, - margin_x = margin_x, margin_y = margin_y, debug = element$debug) + margin_x = margin_x, margin_y = margin_y, debug = element$debug, ...) } diff --git a/tests/figs/guides/axis-guides-basic.svg b/tests/figs/guides/axis-guides-basic.svg new file mode 100644 index 0000000000..efc918fbba --- /dev/null +++ b/tests/figs/guides/axis-guides-basic.svg @@ -0,0 +1,84 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +1 +2 +3 + + + + + + + +1 +2 +3 + + + + +1 +2 +3 + +1 +2 +3 + + + + diff --git a/tests/figs/guides/axis-guides-check-overlap.svg b/tests/figs/guides/axis-guides-check-overlap.svg new file mode 100644 index 0000000000..dc9faa4c64 --- /dev/null +++ b/tests/figs/guides/axis-guides-check-overlap.svg @@ -0,0 +1,198 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +1,000,000,000 +20,000,000,000 +10,000,000,000 +5,000,000,000 +3,000,000,000 +7,000,000,000 +15,000,000,000 +12,000,000,000 +17,000,000,000 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +1,000,000,000 +20,000,000,000 +10,000,000,000 +5,000,000,000 +3,000,000,000 +2,000,000,000 +4,000,000,000 +7,000,000,000 +6,000,000,000 +8,000,000,000 +9,000,000,000 +15,000,000,000 +12,000,000,000 +11,000,000,000 +13,000,000,000 +14,000,000,000 +17,000,000,000 +16,000,000,000 +18,000,000,000 +19,000,000,000 + + + + + + + + + + + + + + + + + + + + + +1,000,000,000 +20,000,000,000 +10,000,000,000 +5,000,000,000 +3,000,000,000 +7,000,000,000 +15,000,000,000 +12,000,000,000 +17,000,000,000 + +1,000,000,000 +20,000,000,000 +10,000,000,000 +5,000,000,000 +3,000,000,000 +2,000,000,000 +4,000,000,000 +7,000,000,000 +6,000,000,000 +8,000,000,000 +9,000,000,000 +15,000,000,000 +12,000,000,000 +11,000,000,000 +13,000,000,000 +14,000,000,000 +17,000,000,000 +16,000,000,000 +18,000,000,000 +19,000,000,000 + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/figs/guides/axis-guides-negative-rotation.svg b/tests/figs/guides/axis-guides-negative-rotation.svg new file mode 100644 index 0000000000..fb9c1da5d3 --- /dev/null +++ b/tests/figs/guides/axis-guides-negative-rotation.svg @@ -0,0 +1,140 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +1,000 +2,000 +3,000 +4,000 +5,000 +6,000 +7,000 +8,000 +9,000 +10,000 + + + + + + + + + + + + + + + + + + + + + +1,000 +2,000 +3,000 +4,000 +5,000 +6,000 +7,000 +8,000 +9,000 +10,000 + + + + + + + + + + + +1,000 +2,000 +3,000 +4,000 +5,000 +6,000 +7,000 +8,000 +9,000 +10,000 + +1,000 +2,000 +3,000 +4,000 +5,000 +6,000 +7,000 +8,000 +9,000 +10,000 + + + + + + + + + + + diff --git a/tests/figs/guides/axis-guides-positive-rotation.svg b/tests/figs/guides/axis-guides-positive-rotation.svg new file mode 100644 index 0000000000..fe73c5aa16 --- /dev/null +++ b/tests/figs/guides/axis-guides-positive-rotation.svg @@ -0,0 +1,140 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +1,000 +2,000 +3,000 +4,000 +5,000 +6,000 +7,000 +8,000 +9,000 +10,000 + + + + + + + + + + + + + + + + + + + + + +1,000 +2,000 +3,000 +4,000 +5,000 +6,000 +7,000 +8,000 +9,000 +10,000 + + + + + + + + + + + +1,000 +2,000 +3,000 +4,000 +5,000 +6,000 +7,000 +8,000 +9,000 +10,000 + +1,000 +2,000 +3,000 +4,000 +5,000 +6,000 +7,000 +8,000 +9,000 +10,000 + + + + + + + + + + + diff --git a/tests/figs/guides/axis-guides-text-dodged-into-rows-cols.svg b/tests/figs/guides/axis-guides-text-dodged-into-rows-cols.svg new file mode 100644 index 0000000000..224723b0c0 --- /dev/null +++ b/tests/figs/guides/axis-guides-text-dodged-into-rows-cols.svg @@ -0,0 +1,140 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +2,000,000,000 +4,000,000,000 +6,000,000,000 +8,000,000,000 +10,000,000,000 +1,000,000,000 +3,000,000,000 +5,000,000,000 +7,000,000,000 +9,000,000,000 + + + + + + + + + + + + + + + + + + + + + +1,000,000,000 +3,000,000,000 +5,000,000,000 +7,000,000,000 +9,000,000,000 +2,000,000,000 +4,000,000,000 +6,000,000,000 +8,000,000,000 +10,000,000,000 + + + + + + + + + + + +1,000,000,000 +3,000,000,000 +5,000,000,000 +7,000,000,000 +9,000,000,000 +2,000,000,000 +4,000,000,000 +6,000,000,000 +8,000,000,000 +10,000,000,000 + +2,000,000,000 +4,000,000,000 +6,000,000,000 +8,000,000,000 +10,000,000,000 +1,000,000,000 +3,000,000,000 +5,000,000,000 +7,000,000,000 +9,000,000,000 + + + + + + + + + + + diff --git a/tests/figs/guides/axis-guides-vertical-negative-rotation.svg b/tests/figs/guides/axis-guides-vertical-negative-rotation.svg new file mode 100644 index 0000000000..ade52eeafd --- /dev/null +++ b/tests/figs/guides/axis-guides-vertical-negative-rotation.svg @@ -0,0 +1,140 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +1,000 +2,000 +3,000 +4,000 +5,000 +6,000 +7,000 +8,000 +9,000 +10,000 + + + + + + + + + + + + + + + + + + + + + +1,000 +2,000 +3,000 +4,000 +5,000 +6,000 +7,000 +8,000 +9,000 +10,000 + + + + + + + + + + + +1,000 +2,000 +3,000 +4,000 +5,000 +6,000 +7,000 +8,000 +9,000 +10,000 + +1,000 +2,000 +3,000 +4,000 +5,000 +6,000 +7,000 +8,000 +9,000 +10,000 + + + + + + + + + + + diff --git a/tests/figs/guides/axis-guides-vertical-rotation.svg b/tests/figs/guides/axis-guides-vertical-rotation.svg new file mode 100644 index 0000000000..66f71a4725 --- /dev/null +++ b/tests/figs/guides/axis-guides-vertical-rotation.svg @@ -0,0 +1,140 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +1,000 +2,000 +3,000 +4,000 +5,000 +6,000 +7,000 +8,000 +9,000 +10,000 + + + + + + + + + + + + + + + + + + + + + +1,000 +2,000 +3,000 +4,000 +5,000 +6,000 +7,000 +8,000 +9,000 +10,000 + + + + + + + + + + + +1,000 +2,000 +3,000 +4,000 +5,000 +6,000 +7,000 +8,000 +9,000 +10,000 + +1,000 +2,000 +3,000 +4,000 +5,000 +6,000 +7,000 +8,000 +9,000 +10,000 + + + + + + + + + + + diff --git a/tests/figs/guides/axis-guides-zero-breaks.svg b/tests/figs/guides/axis-guides-zero-breaks.svg new file mode 100644 index 0000000000..b157ad8d9b --- /dev/null +++ b/tests/figs/guides/axis-guides-zero-breaks.svg @@ -0,0 +1,60 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/figs/guides/axis-guides-zero-rotation.svg b/tests/figs/guides/axis-guides-zero-rotation.svg new file mode 100644 index 0000000000..e3d93e9a3c --- /dev/null +++ b/tests/figs/guides/axis-guides-zero-rotation.svg @@ -0,0 +1,140 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +1,000 +2,000 +3,000 +4,000 +5,000 +6,000 +7,000 +8,000 +9,000 +10,000 + + + + + + + + + + + + + + + + + + + + + +1,000 +2,000 +3,000 +4,000 +5,000 +6,000 +7,000 +8,000 +9,000 +10,000 + + + + + + + + + + + +1,000 +2,000 +3,000 +4,000 +5,000 +6,000 +7,000 +8,000 +9,000 +10,000 + +1,000 +2,000 +3,000 +4,000 +5,000 +6,000 +7,000 +8,000 +9,000 +10,000 + + + + + + + + + + + diff --git a/tests/figs/themes/ticks_length.svg b/tests/figs/themes/ticks_length.svg deleted file mode 100644 index 54c82ae3b1..0000000000 --- a/tests/figs/themes/ticks_length.svg +++ /dev/null @@ -1,74 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -2.5 -5.0 -7.5 -10.0 - - - - -2.5 -5.0 -7.5 -10.0 - - - - - - - - -2.5 -5.0 -7.5 -10.0 - - - - -2.5 -5.0 -7.5 -10.0 -1:10 -1:10 -1:10 -1:10 - diff --git a/tests/testthat/test-guides.R b/tests/testthat/test-guides.R index 1800a87cbe..2bd7d0b508 100644 --- a/tests/testthat/test-guides.R +++ b/tests/testthat/test-guides.R @@ -46,10 +46,92 @@ test_that("show.legend handles named vectors", { expect_equal(n_legends(p), 0) }) +test_that("axis_label_overlap_priority always returns the correct number of elements", { + expect_identical(axis_label_priority(0), numeric(0)) + expect_setequal(axis_label_priority(1), seq_len(1)) + expect_setequal(axis_label_priority(5), seq_len(5)) + expect_setequal(axis_label_priority(10), seq_len(10)) + expect_setequal(axis_label_priority(100), seq_len(100)) +}) + +test_that("axis_label_element_overrides errors when angles are outside the range [0, 90]", { + expect_is(axis_label_element_overrides("bottom", 0), "element") + expect_error(axis_label_element_overrides("bottom", 91), "`angle` must") + expect_error(axis_label_element_overrides("bottom", -91), "`angle` must") +}) # Visual tests ------------------------------------------------------------ test_that("axis guides are drawn correctly", { + theme_test_axis <- theme_test() + theme(axis.line = element_line(size = 0.5)) + test_draw_axis <- function(n_breaks = 3, + break_positions = seq_len(n_breaks) / (n_breaks + 1), + labels = as.character, + positions = c("top", "right", "bottom", "left"), + theme = theme_test_axis, + ...) { + + break_labels <- labels(seq_along(break_positions)) + + # create the axes + axes <- lapply(positions, function(position) { + draw_axis(break_positions, break_labels, axis_position = position, theme = theme, ...) + }) + axes_grob <- gTree(children = do.call(gList, axes)) + + # arrange them so there's some padding on each side + gt <- gtable( + widths = unit(c(0.05, 0.9, 0.05), "npc"), + heights = unit(c(0.05, 0.9, 0.05), "npc") + ) + gt <- gtable_add_grob(gt, list(axes_grob), 2, 2, clip = "off") + plot(gt) + } + + # basic + expect_doppelganger("axis guides basic", function() test_draw_axis()) + expect_doppelganger("axis guides, zero breaks", function() test_draw_axis(n_breaks = 0)) + + # overlapping text + expect_doppelganger( + "axis guides, check overlap", + function() test_draw_axis(20, labels = function(b) comma(b * 1e9), check.overlap = TRUE) + ) + + # rotated text + expect_doppelganger( + "axis guides, zero rotation", + function() test_draw_axis(10, labels = function(b) comma(b * 1e3), angle = 0) + ) + + expect_doppelganger( + "axis guides, positive rotation", + function() test_draw_axis(10, labels = function(b) comma(b * 1e3), angle = 45) + ) + + expect_doppelganger( + "axis guides, negative rotation", + function() test_draw_axis(10, labels = function(b) comma(b * 1e3), angle = -45) + ) + + expect_doppelganger( + "axis guides, vertical rotation", + function() test_draw_axis(10, labels = function(b) comma(b * 1e3), angle = 90) + ) + + expect_doppelganger( + "axis guides, vertical negative rotation", + function() test_draw_axis(10, labels = function(b) comma(b * 1e3), angle = -90) + ) + + # dodged text + expect_doppelganger( + "axis guides, text dodged into rows/cols", + function() test_draw_axis(10, labels = function(b) comma(b * 1e9), n_dodge = 2) + ) +}) + +test_that("axis guides are drawn correctly in plots", { expect_doppelganger("align facet labels, facets horizontal", qplot(hwy, reorder(model, hwy), data = mpg) + facet_grid(manufacturer ~ ., scales = "free", space = "free") +