Skip to content

Commit

Permalink
Dendrograms (#34)
Browse files Browse the repository at this point in the history
* allow rels as units

* draft new primitive guide

* draft segment keys

* document segment keys

* rename `label_args()` to `extra_args()`

* add segment key tests

* document segments primitive

* allow for sensible `oppo` parameter in keys

* little bit of tuning

* add tests for `primitive_segments()`

* fix links in docs

* allow for `key = <hclust>` in `primitive_segments()`

* Add dendrogram guide

* fix bug in r-axis when `coord_radial(theta = "y")`

* add dendrogram scales

* add news bullet

* update pkgdown yaml

* R CMD Check compliance

* add piece about dendrograms
  • Loading branch information
teunbrand authored Dec 8, 2024
1 parent fd4c78c commit dd9e060
Show file tree
Hide file tree
Showing 46 changed files with 1,852 additions and 34 deletions.
7 changes: 7 additions & 0 deletions NAMESPACE
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ export(gizmo_grob)
export(gizmo_histogram)
export(gizmo_stepcap)
export(guide_axis_base)
export(guide_axis_dendro)
export(guide_axis_nested)
export(guide_colbar)
export(guide_colring)
Expand All @@ -52,6 +53,7 @@ export(guide_legend_cross)
export(guide_legend_group)
export(key_auto)
export(key_bins)
export(key_dendro)
export(key_group_lut)
export(key_group_split)
export(key_log)
Expand All @@ -62,16 +64,21 @@ export(key_none)
export(key_range_auto)
export(key_range_manual)
export(key_range_map)
export(key_segment_manual)
export(key_segment_map)
export(key_sequence)
export(new_compose)
export(primitive_box)
export(primitive_bracket)
export(primitive_fence)
export(primitive_labels)
export(primitive_line)
export(primitive_segments)
export(primitive_spacer)
export(primitive_ticks)
export(primitive_title)
export(scale_x_dendro)
export(scale_y_dendro)
export(theme_guide)
import(ggplot2)
import(grid)
Expand Down
7 changes: 7 additions & 0 deletions NEWS.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
# legendry (development version)

* Added support for dendrograms (#33):
* New scale functions `scale_x_dendro()` and `scale_y_dendro()`.
* New full guide function: `guide_axis_dendro()`.
* New primitive guide function: `primitive_segments()`
* New key functions: `key_segment_manual()`, `key_segment_map()` and
`key_dendro()`.

* Fixed bug where `guide_axis_nested(key = key_range_auto(...))` produced
duplicated labels (#31)

Expand Down
77 changes: 77 additions & 0 deletions R/guide_axis_dendro.R
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
# Constructor -------------------------------------------------------------

#' Dendrogram guide
#'
#' This axis is a speciality axis for discrete data that has been
#' hierarchically clustered. Please be aware that the guide cannot affect the
#' scale limits, which should be set appropriately. This guide will give
#' misleading results when this step is skipped!
#'
#' @inheritParams primitive_segments
#' @inheritParams primitive_labels
#' @inheritParams common_parameters
#' @param ticks,axis_line Guides to use as ticks or axis lines. Defaults to
#' drawing no ticks or axis lines. Can be specified as one of the following:
#' * A `<Guide>` class object.
#' * A `<function>` that returns a `<Guide>` class object.
#' * A `<character[1]>` naming such a function, without the `guide_` or
#' `primitive_` prefix.
#' @return A `<Guide>` object.
#' @export
#' @family standalone guides
#'
#' @examples
#' # Hierarchically cluster data
#' clust <- hclust(dist(scale(mtcars)), "ave")
#'
#' # Using the guide along with appropriate limits
#' p <- ggplot(mtcars, aes(disp, rownames(mtcars))) +
#' geom_col() +
#' scale_y_discrete(limits = clust$labels[clust$order])
#'
#' # Standard usage
#' p + guides(y = guide_axis_dendro(clust))
#'
#' # Adding ticks and axis line
#' p + guides(y = guide_axis_dendro(clust, ticks = "ticks", axis_line = "line")) +
#' theme(axis.line = element_line())
#'
#' # Controlling space allocated to dendrogram
#' p + guides(y = guide_axis_dendro(clust, space = unit(4, "cm"))) +
#' theme(axis.ticks.y.left = element_line("red"))
#'
#' # If want just the dendrograme, use `primitive_segments()`
#' p + guides(y = primitive_segments(clust), y.sec = "axis")
guide_axis_dendro <- function(
key = "dendro", title = waiver(), theme = NULL,
space = rel(10), vanish = TRUE,
n.dodge = 1, angle = waiver(), check.overlap = FALSE,
ticks = "none", axis_line = "none",
order = 0, position = waiver()
) {

theme <- replace_null(
theme %||% theme(),
legendry.guide.spacing = unit(0, "cm")
)

labels <- primitive_labels(
angle = angle,
n.dodge = n.dodge,
check.overlap = check.overlap
)

dendro <- primitive_segments(
key = key,
space = space,
vanish = vanish
)

compose_stack(
axis_line, ticks, labels, dendro,
drop = c(3L, 4L),
title = title, theme = theme, order = order,
available_aes = c("any", "x", "y", "r", "theta"),
position = position
)
}
8 changes: 4 additions & 4 deletions R/key-.R
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ key_auto <- function(...) {
function(scale, aesthetic = NULL) {
aesthetic <- aesthetic %||% scale$aesthetics[1]
df <- Guide$extract_key(scale, aesthetic)
df <- data_frame0(df, !!!label_args(...))
df <- data_frame0(df, !!!extra_args(...))
class(df) <- c("key_standard", "key_guide", class(df))
df
}
Expand All @@ -99,7 +99,7 @@ key_manual <- function(aesthetic, value = aesthetic,
label = as.character(value), type = NULL,
...) {
df <- data_frame0(aesthetic = aesthetic, value = value,
label = label, type = type, !!!label_args(...))
label = label, type = type, !!!extra_args(...))
check_columns(df, c("aesthetic", "value", "label"))
df <- rename(df, c("value", "label", "type"), c(".value", ".label", ".type"))
class(df) <- c("key_standard", "key_guide", class(df))
Expand Down Expand Up @@ -136,7 +136,7 @@ key_map <- function(data, ..., .call = caller_env()) {
#' @rdname key_standard
#' @export
key_minor <- function(...) {
dots <- label_args(...)
dots <- extra_args(...)
function(scale, aesthetic = NULL) {
aesthetic <- aesthetic %||% scale$aesthetics[1]
df <- GuideAxis$extract_key(scale, aesthetic, minor.ticks = TRUE)
Expand All @@ -163,7 +163,7 @@ key_log <- function(
force(prescale_base)
force(negative_small)
force(expanded)
dots <- label_args(...)
dots <- extra_args(...)
call <- expr(key_log())
function(scale, aesthetic = NULL) {
key <- log10_keys(
Expand Down
4 changes: 2 additions & 2 deletions R/key-range.R
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ key_range_auto <- function(sep = "[^[:alnum:]]+", reverse = FALSE, ...) {
check_bool(reverse)
force(sep)
force(reverse)
dots <- label_args(...)
dots <- extra_args(...)
call <- current_call()
fun <- function(scale, aesthetic = NULL) {
range_from_label(
Expand All @@ -102,7 +102,7 @@ key_range_auto <- function(sep = "[^[:alnum:]]+", reverse = FALSE, ...) {
key_range_manual <- function(start, end, name = NULL, level = NULL, ...) {
df <- data_frame0(
start = start, end = end, .label = name, .level = level,
!!!label_args(...)
!!!extra_args(...)
)
check_columns(df, c("start", "end"))
class(df) <- c("key_range", "key_guide", class(df))
Expand Down
204 changes: 204 additions & 0 deletions R/key-segment.R
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
#' Segment keys
#'
#' @description
#' These functions are helper functions for working with segment data as keys
#' in guides. They all share the goal of creating a guide key, but have
#' different methods:
#'
#' * `key_segment_manual()` directly uses user-provided vectors to set segments.
#' * `key_segment_map()` makes mappings from a `<data.frame>` to set segments.
#' * `key_dendro()` is a specialty case for coercing dendrogram data to segments.
#' Be aware that setting the key alone cannot affect the scale limits, and
#' will give misleading results when used incorrectly!
#'
#' @param value,value_end A vector that is interpreted to be along the scale
#' that the guide codifies.
#' @param oppo,oppo_end A vector that is interpreted to be orthogonal to the
#' `value` and `value_end` variables.
#' @param data A `<data.frame>` or similar object coerced by
#' [`fortify()`][ggplot2::fortify] to a `<data.frame>`, in which the `mapping`
#' argument is evaluated.
#' @param dendro A data structure that can be coerced to a dendrogram through
#' the [`as.dendrogram()`][stats::as.dendrogram()] function. When `NULL`
#' (default) an attempt is made to search for such data in the scale.
#' @param type A string, either `"rectangle"` or `"triangle"`, indicating the
#' shape of edges between nodes of the dendrogram.
#' @param ... [`<data-masking>`][rlang::topic-data-mask] A set of mappings
#' similar to those provided to [`aes()`][ggplot2::aes], which will be
#' evaluated in the `data` argument.
#' For `key_segments_map()`, these *must* contain `value` and `oppo` mappings.
#' @param .call A [call][rlang::topic-error-call] to display in messages.
#'
#' @export
#' @name key_segments
#' @family keys
#' @return
#' For `key_segments_manual()` and `key_segments_map()`, a `<data.frame>` with
#' the `<key_range>` class.
#'
#' @examples
#' # Giving vectors directly
#' key_segment_manual(
#' value = 0:1, value_end = 2:3,
#' oppo = 1:0, oppo_end = 3:2
#' )
#'
#' # Taking columns of a data frame
#' data <- data.frame(x = 0:1, y = 1:0, xend = 2:3, yend = 3:2)
#' key_segment_map(data, value = x, oppo = y, value_end = xend, oppo_end = yend)
#'
#' # Using dendrogram data
#' clust <- hclust(dist(USArrests), "ave")
#' key_dendro(clust)(scale_x_discrete())
key_segment_manual <- function(value, oppo, value_end = value,
oppo_end = oppo, ...) {
df <- data_frame0(
value = value, oppo = oppo,
value_end = value_end, oppo_end = oppo_end,
!!!extra_args(..., .valid_args = .line_params)
)
check_columns(df, c("value", "oppo"))
class(df) <- c("key_segment", "key_guide", class(df))
df
}

#' @rdname key_segments
#' @export
key_segment_map <- function(data, ..., .call = caller_env()) {

mapping <- enquos(...)
mapping <- Filter(Negate(quo_is_missing), mapping)
mapping <- new_aes(mapping, env = .call)

df <- eval_aes(
data, mapping,
required = c("value", "oppo"),
optional = c("value_end", "oppo_end", .line_params),
call = .call, arg_mapping = "mapping", arg_data = "data"
)

df$colour <- df$color
df$color <- NULL
df <- rename(df, .line_params, paste0(".", .line_params))
class(df) <- c("key_segment", "key_guide", class(df))
df

}

#' @rdname key_segments
#' @export
key_dendro <- function(dendro = NULL, type = "rectangle") {
force(dendro)
function(scale, aesthetic = NULL, ...) {
extract_dendro(scale$scale$clust %||% dendro, type = type)
}
}

# Dendrogram utilities ----------------------------------------------------

# Simplified version of `stats:::plotNode`.
# It only looks for the segments and ignores labels and most other attributes.
extract_dendro <- function(tree, type = "rectangle") {

# Check arguments
whole_tree <- tree <- try_fetch(
stats::as.dendrogram(tree),
error = function(cnd) {
cli::cli_abort("Could not find or coerce {.arg dendro} argument.", parent = cnd)
}
)
type <- arg_match0(type, c("rectangle", "triangle"))

# Initialise stuff
depth <- 0
llimit <- list()
x1 <- i <- 1
x2 <- number_of_members(tree)
KK <- kk <- integer()

n_obs <- stats::nobs(tree)
n_segments <- switch(type, triangle = 2 * n_obs - 2, 4 * n_obs - 4)

mtx <- matrix(NA_real_, n_segments, ncol = 4)
colnames(mtx) <- c("value", "oppo", "value_end", "oppo_end")

repeat {
depth <- depth + 1
inner <- !stats::is.leaf(tree) && x1 != x2

node <- node_limit(x1, x2, tree)
llimit[[depth]] <- node$limit

ymax <- attr(tree, 'height')
xmax <- node$x

if (inner) {
for (k in seq_along(tree)) {
child <- tree[[k]]

ymin <- attr(child, "height") %||% 0
xmin <- node$limit[k] + (attr(child, "midpoint") %||% 0)

# Update segments
if (type == "triangle") {
mtx[i, ] <- c(xmax, ymax, xmin, ymin)
i <- i + 1
} else {
mtx[i + 0:1, ] <- c(xmax, xmin, ymax, ymax, xmin, xmin, ymax, ymin)
i <- i + 2
}
}
if (length(tree) > 0) {
KK[depth] <- length(tree)
kk[depth] <- 1L
x1 <- node$limit[1L]
x2 <- node$limit[2L]
tree <- tree[[1]]
}
} else {
repeat {
depth <- depth - 1L
if (!depth || kk[depth] < KK[depth]) {
break
}
}
if (!depth) {
break
}
length(kk) <- depth
kk[depth] <- k <- kk[depth] + 1L
x1 <- llimit[[depth]][k]
x2 <- llimit[[depth]][k + 1L]
tree <- whole_tree[[kk]]
}
}
as.data.frame(mtx)
}

# Copy of `stats:::.memberDend()`
number_of_members <- function(tree) {
attr(tree, "x.member") %||% attr(tree, "members") %||% 1L
}

# Simplified version of `stats:::plotNodeLimit`,
# It has `center = FALSE` build-in.
node_limit <- function(x1, x2, subtree) {
inner <- !stats::is.leaf(subtree) && x1 != x2
if (inner) {
K <- length(subtree)
limit <- integer(K)
xx1 <- x1
for (k in 1L:K) {
xx1 <- xx1 + number_of_members(subtree[[k]])
limit[k] <- xx1
}
} else {
limit <- x2
}
limit <- c(x1, limit)
mid <- attr(subtree, "midpoint")
center <- inner && !is.numeric(mid)
x <- if (center) mean(c(x1, x2)) else x1 + (mid %||% 0)
list(x = x, limit = limit)
}

Loading

0 comments on commit dd9e060

Please sign in to comment.