diff --git a/NAMESPACE b/NAMESPACE index 5b03fe9e..f69578b1 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -2,6 +2,7 @@ S3method(print,recordedtinyplot) S3method(str,recordedtinyplot) +S3method(tinyplot,array) S3method(tinyplot,data.frame) S3method(tinyplot,default) S3method(tinyplot,density) diff --git a/NEWS.md b/NEWS.md index f01c7e5a..3113e37d 100644 --- a/NEWS.md +++ b/NEWS.md @@ -149,6 +149,17 @@ related to plot layering. See "Bug fixes" below. the new `x/ypad` arguments (see below) to match their own aesthetic preferences. (#732 @grantmcdermott) +#### New `tinyplot.*` methods + +- `tinyplot.array()`: for (unclassed) `array` objects with up to four + dimensions. This extends the `tinyplot.matrix()` conventions by mapping the + third dimension to facets, and a fourth to a facet grid. The names of the + dimnames (if any) are used for the axis, legend, and facet titles. Arrays + with a length-2 dimension (e.g., `gait`) are plotted as x/y pairs, i.e. one + slice against the other. See the `tinyplot.array`-specific `xy` argument. + Passing `facet = FALSE` draws everything in a single panel instead. + (#746 @grantmcdermott) + #### Other new features - New top-level `tinyplot()`/`plt()` arguments: @@ -243,6 +254,13 @@ related to plot layering. See "Bug fixes" below. ### Bug fixes +- Degenerate array inputs, i.e. 1-row or 1-column matrices, are now dropped + to plain vectors, so that e.g. `tinyplot(1:10, array(1:10, c(1, 10)))` works + just like `plot()`. (#548, #746 @tony-aw @zeileis @grantmcdermott) +- Two-sided facet formulas (`facet = rows ~ cols`) now work with the default + (non-formula) method, e.g. `tinyplot(x, y, facet = a ~ b, data = dat)`. + Previously this errored because `data` was not forwarded. (#746 + @grantmcdermott) - Free facets (`facet.args = list(free = TRUE)`) now respect `asp`. (#744 @grantmcdermott) - Multi-line x-axis tick labels (e.g. `"Hello\nWorld"`) are now spaced diff --git a/R/facet.R b/R/facet.R index a59fb447..52f85c81 100644 --- a/R/facet.R +++ b/R/facet.R @@ -1430,19 +1430,28 @@ get_facet_fml = function(formula, data = NULL) { xfacet = interaction(xfacet, sep = ":") if (no_yfacet) { ret = xfacet + attr(ret, "facet_vars") = list(x = xfacet_vars, y = NULL) } else { - # yfacet = interaction(yfacet, sep = ":") - ## NOTE: We "swap" the formula LHS and RHS since mfrow plots rowwise - ret = interaction(xfacet, yfacet, sep = "~") - attr(ret, "facet_grid") = TRUE - attr(ret, "facet_nrow") = length(unique(yfacet)) + ret = facet_grid_factor(xfacet, yfacet, xfacet_vars, yfacet_vars) } - attr(ret, "facet_vars") = list(x = xfacet_vars, y = yfacet_vars) return(ret) } +## Combine the column (x) and row (y) facet factors into a single grid facet, +## with the attributes that facet_layout() and facet_titles() expect. NOTE: the +## columns come first (i.e. we "swap" a formula's LHS and RHS), since mfrow +## plots rowwise. +facet_grid_factor = function(xfacet, yfacet, xvars, yvars) { + out = interaction(xfacet, yfacet, sep = "~") + attr(out, "facet_grid") = TRUE + attr(out, "facet_nrow") = length(unique(yfacet)) + attr(out, "facet_vars") = list(x = xvars, y = yvars) + out +} + + ## Are a facet's interior tick labels visually anchored? ## ## draw_facet_axis() keys the "outer facets only" rule off framing, on the basis diff --git a/R/sanitize_datapoints.R b/R/sanitize_datapoints.R index 1a99752a..123a0473 100644 --- a/R/sanitize_datapoints.R +++ b/R/sanitize_datapoints.R @@ -11,6 +11,14 @@ sanitize_datapoints = function(settings) { ) ) + ## drop degenerate dimensions from array inputs, e.g. a 1-row or 1-column + ## matrix is just a vector (#548) + drop_dims = function(z) { + if (length(dim(z)) > 1L && sum(dim(z) > 1L) <= 1L) drop(z) else z + } + x = drop_dims(x); xmin = drop_dims(xmin); xmax = drop_dims(xmax) + y = drop_dims(y); ymin = drop_dims(ymin); ymax = drop_dims(ymax) + ## coerce character and logical variables to factors ## (aside: we won't risk converting x and y logicals to factors b/c it can ## mess up types that rely on predict underneath the hood, e.g type_lm) @@ -53,5 +61,9 @@ sanitize_datapoints = function(settings) { } # potentially modified variables - env2env(environment(), settings, c("x", "y", "xaxt", "datapoints")) + env2env( + environment(), + settings, + c("x", "xmin", "xmax", "y", "ymin", "ymax", "xaxt", "datapoints") + ) } diff --git a/R/sanitize_facet.R b/R/sanitize_facet.R index 4a51cf71..d9369cf7 100644 --- a/R/sanitize_facet.R +++ b/R/sanitize_facet.R @@ -3,7 +3,7 @@ sanitize_facet = function(settings) { settings, environment(), c( - "facet", "by", "null_facet", "facet_attr", "facet_by", + "facet", "data", "by", "null_facet", "facet_attr", "facet_by", "by_dep", "facet_dep", "legend_args" ) ) @@ -26,10 +26,8 @@ sanitize_facet = function(settings) { # facet titles inherit the "by" variable name (same as the legend title) facet_vars = list(x = facet_var_list(by, legend_args[["title"]] %||% by_dep)) } else if (inherits(facet, "formula")) { + # (grid layout is read off the "facet_grid" attribute downstream) facet = get_facet_fml(facet, data = data) - if (isTRUE(attr(facet, "facet_grid"))) { - facet.args[["nrow"]] = attr(facet, "facet_nrow") - } facet_vars = attr(facet, "facet_vars") } else { # recorded by tinyplot.formula(), else fall back to the deparsed input of diff --git a/R/tinyplot.R b/R/tinyplot.R index 87c950bc..91b29f94 100644 --- a/R/tinyplot.R +++ b/R/tinyplot.R @@ -1070,6 +1070,7 @@ tinyplot.default = function( # unevaluated expressions with side effects draw = substitute(draw), + data = data, # for facet formulas passed to the default method facet = facet, facet.args = facet.args, palette = substitute(palette), @@ -2209,25 +2210,21 @@ tinyplot.formula = function( } else { if (xtype %in% c("none", "empty")) { facet = yfacet - fvars = list(x = yfacet_vars) + attr(facet, "facet_vars") = list(x = yfacet_vars) if (xtype == "empty") { if (is.null(facet.args)) facet.args = list() if (is.null(facet.args[["nrow"]])) facet.args[["nrow"]] = length(unique(yfacet)) } } else if (ytype %in% c("none", "empty")) { facet = xfacet - fvars = list(x = xfacet_vars) + attr(facet, "facet_vars") = list(x = xfacet_vars) if (ytype == "empty") { if (is.null(facet.args)) facet.args = list() if (is.null(facet.args[["nrow"]])) facet.args[["nrow"]] = 1L } } else { - facet = interaction(xfacet, yfacet, sep = "~") - attr(facet, "facet_grid") = TRUE - attr(facet, "facet_nrow") = length(unique(yfacet)) - fvars = list(x = xfacet_vars, y = yfacet_vars) + facet = facet_grid_factor(xfacet, yfacet, xfacet_vars, yfacet_vars) } - attr(facet, "facet_vars") = fvars } } else if (!is.null(facet) && !inherits(facet, "formula") && is.null(attr(facet, "facet_vars")) && !identical(facet, "by")) { diff --git a/R/tinyplot.array.R b/R/tinyplot.array.R new file mode 100644 index 00000000..706af295 --- /dev/null +++ b/R/tinyplot.array.R @@ -0,0 +1,208 @@ +#' tinyplot Method for Plotting Arrays +#' +#' @description Convenience interface for visualizing +#' \code{\link[base]{array}} objects with tinyplot. Extends the +#' \code{\link{tinyplot.matrix}} conventions to arrays with up to four +#' dimensions, by mapping the higher dimensions to facets. +#' +#' @details The first two dimensions of the array are treated exactly like the +#' rows and columns of a matrix; see \code{\link{tinyplot.matrix}}. By default, +#' that means the values are plotted against the (first dimension) row index, +#' with a separate `by` group for each element of the second dimension. +#' Higher dimensions are then mapped to facets: +#' +#' - 1D arrays are treated as a single-column matrix, i.e. a simple index +#' plot. If a `y` variable is also supplied, e.g. `tinyplot(tapply(...), y)`, +#' then the array is instead treated as a plain `x` vector. +#' - 2D arrays are matrices, and hence dispatch to +#' \code{\link{tinyplot.matrix}}. +#' - 3D arrays are faceted by the third dimension (i.e., a "facet wrap"). +#' - 4D arrays are faceted by the third and fourth dimensions, as the rows +#' and columns of a "facet grid", respectively. +#' - Arrays with more than four dimensions are not supported. Subset or +#' reshape them first. +#' +#' Dimension names are used to label the groups, facets, and axis ticks, while +#' the names of the dimnames (if any) are used as the axis, legend, and facet +#' titles. To assign the dimensions to different roles, rearrange them first +#' with \code{\link[base]{aperm}}. +#' +#' **x/y pairs.** Arrays often hold a stack of matrices, one per variable, +#' e.g. the hip and knee angles of the `gait` data (`Time` x `Subject` x +#' `Variable`). If an array of three or more dimensions has exactly one +#' length-2 dimension besides the first, then its two slices are plotted +#' against each other, as x and y. Each of the remaining dimensions then +#' shifts down a role: +#' +#' - The first dimension becomes `by`, and orders the points along each path. +#' It is kept numeric where possible (its dimnames, e.g. times, or else its +#' index), so that each path is drawn as a line with a colour gradient. If +#' the dimnames are not numeric, then the groups are discrete and drawn as +#' points instead. +#' - The second dimension becomes a facet wrap (e.g. one panel per subject). +#' - The third dimension (if any) makes this a facet grid, as its columns. +#' +#' Control this via the `xy` argument. Tile and heatmap types are exempt, +#' since they need the array values as their fill. +#' +#' Note that contingency tables created by \code{\link[base]{table}} (e.g., +#' `HairEyeColor` or `Titanic`) are of class `"table"` and hence do not +#' dispatch to this method. +#' +#' @inheritParams tinyplot.matrix +#' @param x an object of class `"array"`. +#' @param facet must be `NULL` (the default) or `FALSE` for arrays with three +#' or more dimensions, since the facets are then determined by the array +#' dimensions. `FALSE` draws everything in a single panel instead, by folding +#' the facet dimensions into the `by` groups. For x/y pairs, these groups +#' then replace the first dimension as `by`, so that each path is drawn +#' separately. Not supported for tile and heatmap types. +#' @param xy which dimension (if any) holds x/y pairs; see Details. The default +#' `NULL` auto-detects it, `FALSE` opts out, and a dimension index or name +#' selects it explicitly. The selected dimension must have length 2, and its +#' dimnames (if any) are used as the axis titles. +#' @param ... further arguments passed to `tinyplot`. +#' +#' @inherit tinyplot return +#' +#' @seealso \code{\link{tinyplot.matrix}}, \code{\link[graphics]{matplot}} +#' +#' @examples +#' # 3D array: facet wrap by the third dimension +#' sims = array( +#' cumsum(rnorm(20 * 3 * 4)), dim = c(20, 3, 4), +#' dimnames = list(NULL, paste("Series", 1:3), paste("Run", 1:4)) +#' ) +#' tinyplot(sims, type = "l") +#' +#' # 4D array: facet grid by the third and fourth dimensions +#' sims4 = array( +#' rnorm(20 * 3 * 2 * 2), dim = c(20, 3, 2, 2), +#' dimnames = list( +#' time = NULL, series = paste0("s", 1:3), +#' model = c("A", "B"), scenario = c("low", "high") +#' ) +#' ) +#' tinyplot(sims4, type = "b", facet.args = list(prefix = TRUE)) +#' +#' # tile/heatmap types lay out each 2D slice as a grid +#' tinyplot(sims4[1:5, , , ], type = "heatmap", theme = "heatmap") +#' +#' @examplesIf getRversion() >= "4.5.0" +#' # x/y pairs: a length-2 dimension is plotted as x vs y +#' # (e.g., hip vs knee angle through the gait cycle, coloured by time and +#' # faceted by boy) +#' gait9 = gait[, 1:9, ] # take a subset for demonstration +#' tinyplot(gait9) +#' +#' # all boys in a single panel instead +#' tinyplot(gait9, facet = FALSE) +#' +#' # ... or in the background of each boy's own panel +#' tinyplot( +#' gait9, +#' draw = tinyplot(gait9, col = "lightgray", facet = FALSE, add = TRUE) +#' ) +#' +#' # opt out of x/y pairs, to plot the angles against time instead +#' tinyplot(gait9, type = "l", xy = FALSE, legend = FALSE) +#' +#' @export +tinyplot.array = function(x, type = NULL, legend = NULL, facet = NULL, xlab = NULL, ylab = NULL, xy = NULL, ...) { + dep = deparse1(substitute(x)) + nd = length(dim(x)) + if (nd > 4L) { + stop( + "Arrays with more than 4 dimensions are not supported. ", + "Please subset or reshape your array first.", + call. = FALSE + ) + } + if (nd == 1L) { + ## A 1D array (e.g. from tapply()) passed alongside a `y` variable is just + ## an x vector, so hand the call on to the default method. We check the + ## unmatched call, since a positional `y` would otherwise be matched to + ## one of this method's formals (`type`, `legend`, etc.). + cl = sys.call() + nms = names(cl) %||% character(length(cl)) + if ("y" %in% nms || (length(cl) > 2L && !nzchar(nms[3L]))) { + cl[[1L]] = tinyplot.default + return(eval(cl, parent.frame())) + } + ## Otherwise, it is treated as a single-column matrix + dn = dimnames(x) + dim(x) = c(length(x), 1L) + if (!is.null(dn)) dimnames(x) = c(dn, list(NULL)) + } + + ## Split off a length-2 dimension holding x/y pairs (if any), leaving two + ## arrays of one dimension less: the x values, and the matching y values + y = NULL + k = array_xy_dim(x, xy = xy, type = type) + if (!is.null(k)) { + si = slice.index(x, k) + xlvls = dimnames(x)[[k]] + if (is.null(xlvls)) { + ## e.g. "arr[, , 1]" and "arr[, , 2]" + xlvls = vapply(1:2, function(i) { + idx = character(nd) + idx[k] = i + paste0(dep, "[", paste(idx, collapse = ", "), "]") + }, character(1)) + } + if (is.null(xlab)) xlab = xlvls[1] + if (is.null(ylab)) ylab = xlvls[2] + y = array(x[si == 2L], dim(x)[-k], dimnames(x)[-k]) + x = array(x[si == 1L], dim(x)[-k], dimnames(x)[-k]) + } + + ## (facet = FALSE is fine for any array: it folds the facets into `by`) + if (is.null(k) && nd <= 2L) { + if (!isFALSE(facet)) assert_choice(facet, "by", null.ok = TRUE) + } else if (!is.null(facet) && !isFALSE(facet)) { + stop( + "`facet` must be NULL or FALSE for arrays with 3 or more dimensions (or ", + "x/y pairs), since the facets are determined by the array dimensions.", + call. = FALSE + ) + } + array_plot( + x, y = y, type = type, legend = legend, facet = facet, + xlab = xlab, ylab = ylab, dep = dep, ... + ) +} + + +## Which dimension of an array (if any) holds x/y pairs. Auto-detected as the +## only length-2 dimension besides the first (which orders the paths), unless +## the user sets `xy` explicitly: a dimension index or name, or FALSE to opt +## out. Tile and heatmap types are skipped, since they need the array values +## as their fill, leaving nothing to split into x and y. +array_xy_dim = function(x, xy = NULL, type = NULL) { + if (isFALSE(xy)) return(NULL) + dims = dim(x) + grid_type = is_grid_type(type) + if (is.null(xy)) { + k = setdiff(which(dims == 2L), 1L) + if (length(dims) < 3L || length(k) != 1L || grid_type) return(NULL) + return(k) + } + k = if (is.character(xy)) match(xy, names(dimnames(x))) else xy + if (length(k) != 1L || is.na(k) || !k %in% seq_along(dims)) { + stop( + "`xy` must be FALSE, or a single dimension index or name.", + call. = FALSE + ) + } + if (dims[k] != 2L) { + stop("The `xy` dimension must have length 2.", call. = FALSE) + } + if (grid_type) { + stop( + "`xy` is not supported for \"tile\" or \"heatmap\" types, since they ", + "need the array values as their fill.", + call. = FALSE + ) + } + k +} diff --git a/R/tinyplot.matrix.R b/R/tinyplot.matrix.R index 61ebd641..d8ca6876 100644 --- a/R/tinyplot.matrix.R +++ b/R/tinyplot.matrix.R @@ -42,7 +42,7 @@ #' #' @inherit tinyplot return #' -#' @seealso \code{\link[graphics]{matplot}} +#' @seealso \code{\link{tinyplot.array}}, \code{\link[graphics]{matplot}} #' #' @examples #' # basic use @@ -68,103 +68,188 @@ #' @export tinyplot.matrix = function(x, type = NULL, legend = NULL, facet = NULL, xlab = NULL, ylab = NULL, ...) { assert_choice(facet, "by", null.ok = TRUE) - ## Default to points. We set this explicitly (rather than relying on - ## tinyplot's auto-inference) because the x-axis row labels are passed as a - ## factor, which would otherwise be inferred as a boxplot. - if (is.null(type)) type = "p" - dep_x = deparse1(substitute(x)) + array_plot( + x, type = type, legend = legend, facet = facet, xlab = xlab, ylab = ylab, + dep = deparse1(substitute(x)), ... + ) +} + + +## Internal workhorse shared by the matrix and array methods. Converts an array +## with 2-4 dimensions to long form and passes it on to tinyplot.default(). The +## first two dimensions follow the matrix conventions documented in +## ?tinyplot.matrix, i.e. x-axis and `by`, and any further dimensions are mapped +## to facets, as a wrap and a grid, respectively. If `y` is supplied (an array +## of the same shape), then `x` and `y` hold x/y pairs instead. Each dimension +## then shifts down a role: the 1st becomes `by`, drawn along each path, and the +## 2nd and 3rd (if any) the facets. +array_plot = function(x, y = NULL, type = NULL, legend = NULL, facet = NULL, + xlab = NULL, ylab = NULL, ylim = NULL, dep = NULL, ...) { dims = dim(x) + dnms = dimnames(x) + ## names(dimnames(x)), if any, e.g. for arrays built from a table + dvars = names(dnms) + dvar = function(k) { + v = dvars[k] + if (is.null(v) || is.na(v) || !nzchar(v)) NULL else v + } + ## position of each value along dimension k, as a factor labelled by the + ## dimnames (if any) + dim_factor = function(k, ordered = FALSE) { + i = as.vector(slice.index(x, k)) + lvls = dnms[[k]] + if (is.null(lvls)) { + factor(i, levels = seq_len(dims[k]), ordered = ordered) + } else { + factor(lvls[i], levels = lvls, ordered = ordered) + } + } + ## the dimensions that become facets + fdims = seq_along(dims)[-seq_len(if (is.null(y)) 2L else 1L)] + ## facet = FALSE folds these into the `by` groups instead + fold = isFALSE(facet) && length(fdims) > 0L + if (isFALSE(facet)) facet = NULL + ## `by` groups (and legend title) spanning one or more dimensions + group_by = function(ks) { + if (length(ks) == 1L) return(dim_factor(ks)) + interaction(lapply(ks, dim_factor), sep = ":", lex.order = TRUE) + } + group_title = function(ks) { + v = unlist(lapply(ks, dvar)) + if (length(v) == length(ks)) paste(v, collapse = ":") + } - ## Tile and heatmap types need a different mapping to the matplot convention - ## below: they want the matrix laid out as a grid (columns on x, rows on y) - ## with the *values* supplied as the fill, rather than a series per column - ## with the values on y. Detect via the resolved type name, so that both the - ## convenience strings and the type_*() constructors are covered. - tname = if (inherits(type, "tinyplot_type")) type[["name"]] else type - if (is.character(tname) && length(tname) == 1L && - tname %in% c("tile", "heatmap")) { - rnms = rownames(x) - cnms = colnames(x) - xx = if (is.null(cnms)) { - factor(rep(seq_len(dims[2]), each = dims[1])) + ## x/y pairs aside, tile and heatmap types need a different mapping to the + ## matplot convention below: they want the matrix laid out as a grid + ## (columns on x, rows on y) with the *values* supplied as the fill, rather + ## than a series per column with the values on y. Detect via the resolved + ## type name, so that both the convenience strings and the type_*() + ## constructors are covered. + if (!is.null(y)) { + xx = as.vector(x) + yy = as.vector(y) + if (fold) { + ## Without facets, each path needs its own (discrete) `by` group, so + ## that the 1st dimension merely orders the points along it. + by = group_by(fdims) + if (is.null(type)) type = "l" + if (is.null(legend)) legend = list(title = group_title(fdims)) } else { - factor(rep(cnms, each = dims[1]), levels = cnms) + ## The 1st dimension orders the points along each path, e.g. time, so we + ## keep it numeric where possible (the index, or numeric dimnames). That + ## way `by` is continuous and the path is drawn as a single colour + ## gradient by type "l". Otherwise, fall back to discrete groups and + ## points. + lvls = dnms[[1]] + lvls = if (is.null(lvls)) { + seq_len(dims[1]) + } else { + type.convert(lvls, as.is = TRUE) + } + by = if (is.numeric(lvls)) { + lvls[as.vector(slice.index(x, 1))] + } else { + dim_factor(1, ordered = TRUE) + } + if (is.null(type)) type = if (is.numeric(by)) "l" else "p" + if (is.null(legend)) legend = list(title = dvar(1)) + } + } else if (is_grid_type(type)) { + if (fold) { + stop( + "`facet = FALSE` is not supported for \"tile\" or \"heatmap\" types.", + call. = FALSE + ) } ## Note the row levels are *not* reversed here. Row 1 belongs at the *top* ## of a matrix display (cf. `heatmap()`, `image()`), but we get that by ## defaulting the y-axis to reversed below, which keeps it overridable via ## `ylim`. Reversing the levels *and* the axis would cancel out. - yy = if (is.null(rnms)) { - factor(rep(seq_len(dims[1]), times = dims[2])) - } else { - factor(rep(rnms, times = dims[2]), levels = rnms) - } + xx = dim_factor(2) + yy = dim_factor(1) + by = as.vector(x) ## Both axes are labelled by the matrix dimnames, so axis titles would be ## redundant. Ditto the legend: the fill encodes the matrix's own values, so ## a colourbar adds little for a bare `tinyplot(m, type = "heatmap")` call. ## Users who want one can still ask for it explicitly. - if (is.null(xlab)) xlab = NA - if (is.null(ylab)) ylab = NA + if (is.null(xlab)) xlab = dvar(2) %||% NA + if (is.null(ylab)) ylab = dvar(1) %||% NA if (is.null(legend)) legend = FALSE ## Applies to "tile" as well as "heatmap": the matrix *layout* is what ## implies the orientation here, not the choice of type. (type_heatmap() ## additionally defaults to this on its own, for the formula method; the two ## are idempotent and so compose safely.) - dots = list(...) - if (!"ylim" %in% names(dots)) dots[["ylim"]] = "reverse" - return(do.call( - tinyplot.default, - c( - list( - x = xx, y = yy, - type = type, - by = as.vector(x), - facet = facet, - legend = legend, - xlab = xlab, - ylab = ylab - ), - dots - ) - )) - } - if (dims[2] == 1L) { - bby = NULL - legend = FALSE + if (is.null(ylim)) ylim = "reverse" } else { - nms = colnames(x) - if (!is.null(nms)) { - bby = factor(rep(nms, each = dims[1]), levels = nms) - if (is.null(legend)) legend = list(title = NULL) - } else { - bby = factor(rep(seq_len(dims[2]), each = dims[1])) + ## Default to points. We set this explicitly (rather than relying on + ## tinyplot's auto-inference) because the x-axis row labels are passed as + ## a factor, which would otherwise be inferred as a boxplot. + if (is.null(type)) type = "p" + ## group by the columns (unless there is just one) and any folded facets + bdims = c(if (dims[2] > 1L) 2L, if (fold) fdims) + if (!length(bdims)) { + ## a single column is a simple index plot, so there is nothing to group + ## (or facet) by + by = NULL legend = FALSE + if (identical(facet, "by")) facet = NULL + } else { + by = group_by(bdims) + if (all(vapply(dnms[bdims], is.null, NA))) { + legend = FALSE + } else if (is.null(legend)) { + legend = list(title = group_title(bdims)) + } + } + ## If the matrix has row names, use them for the x-axis tick labels via an + ## ordered factor (preserving row order). Otherwise fall back to a plain + ## numeric index. + if (is.null(dnms[[1]])) { + xx = as.vector(slice.index(x, 1)) + ## no row names: x is a plain numeric index, so label it as such + if (is.null(xlab)) xlab = dvar(1) %||% "Index" + } else { + xx = dim_factor(1, ordered = TRUE) + ## row names already label the ticks, so an "Index" title is redundant + if (is.null(xlab)) xlab = dvar(1) %||% NA } + yy = as.vector(x) + if (is.null(ylab)) ylab = dep } - ## If the matrix has row names, use them for the x-axis tick labels via an - ## ordered factor (preserving row order). Otherwise fall back to a plain - ## numeric index. - rnms = rownames(x) - dim(x) = dims[1] * dims[2] - y = x - if (is.null(rnms)) { - x = rep(seq_len(dims[1]), times = dims[2]) - ## no row names: x is a plain numeric index, so label it as such - if (is.null(xlab)) xlab = "Index" - } else { - x = factor(rep(rnms, times = dims[2]), levels = rnms, ordered = TRUE) - ## row names already label the ticks, so an "Index" title is redundant - if (is.null(xlab)) xlab = NA + + ## The remaining dimensions become facets: the first as a wrap, or (with a + ## second) as the rows of a grid whose columns are the second, i.e. the same + ## as a `rows ~ cols` facet formula. + if (length(fdims) && !fold) { + fvars = function(k, f) facet_var_list(f, dvar(k) %||% paste0("dim", k)) + fr = dim_factor(fdims[1]) + if (length(fdims) == 1L) { + facet = fr + attr(facet, "facet_vars") = list(x = fvars(fdims[1], fr)) + } else { + fc = dim_factor(fdims[2]) + facet = facet_grid_factor( + fc, fr, fvars(fdims[2], fc), fvars(fdims[1], fr) + ) + } } - if (is.null(ylab)) ylab = dep_x + tinyplot.default( - x = x, y = y, + x = xx, y = yy, type = type, - by = bby, + by = by, facet = facet, legend = legend, xlab = xlab, ylab = ylab, + ylim = ylim, ... ) } + + +## Tile and heatmap types, which lay out a matrix (slice) as a grid +is_grid_type = function(type) { + tname = if (inherits(type, "tinyplot_type")) type[["name"]] else type + is.character(tname) && length(tname) == 1L && tname %in% c("tile", "heatmap") +} diff --git a/altdoc/quarto_website.yml b/altdoc/quarto_website.yml index 2cfe173f..108c4a5f 100644 --- a/altdoc/quarto_website.yml +++ b/altdoc/quarto_website.yml @@ -64,6 +64,8 @@ website: file: man/tinyplot_add.qmd - section: "Methods" contents: + - text: tinyplot.array + file: man/tinyplot.array.qmd - text: tinyplot.data.frame file: man/tinyplot.data.frame.qmd - text: tinyplot.matrix diff --git a/inst/tinytest/_tinysnapshot/array_1d.svg b/inst/tinytest/_tinysnapshot/array_1d.svg new file mode 100644 index 00000000..156305d0 --- /dev/null +++ b/inst/tinytest/_tinysnapshot/array_1d.svg @@ -0,0 +1,71 @@ + + + + + + + + + + + + + +array(1:5, 5, list(letters[1:5])) + + + + + + +a +b +c +d +e + + + + + + +1 +2 +3 +4 +5 + + + + + + + + + + + + + + + + + + + + diff --git a/inst/tinytest/_tinysnapshot/array_1d_with_y.svg b/inst/tinytest/_tinysnapshot/array_1d_with_y.svg new file mode 100644 index 00000000..af262c8d --- /dev/null +++ b/inst/tinytest/_tinysnapshot/array_1d_with_y.svg @@ -0,0 +1,74 @@ + + + + + + + + + + + + + +tapply(mtcars$mpg, mtcars$cyl, mean) +tapply(mtcars$hp, mtcars$cyl, mean) + + + + + + + +16 +18 +20 +22 +24 +26 + + + + + + + + +80 +100 +120 +140 +160 +180 +200 + + + + + + + + + + + + + + + + diff --git a/inst/tinytest/_tinysnapshot/array_1row_y.svg b/inst/tinytest/_tinysnapshot/array_1row_y.svg new file mode 100644 index 00000000..f7a09571 --- /dev/null +++ b/inst/tinytest/_tinysnapshot/array_1row_y.svg @@ -0,0 +1,73 @@ + + + + + + + + + + + + + +1:10 +y + + + + + + +2 +4 +6 +8 +10 + + + + + + +2 +4 +6 +8 +10 + + + + + + + + + + + + + + + + + + + + + diff --git a/inst/tinytest/_tinysnapshot/array_3d.svg b/inst/tinytest/_tinysnapshot/array_3d.svg new file mode 100644 index 00000000..e182d222 --- /dev/null +++ b/inst/tinytest/_tinysnapshot/array_3d.svg @@ -0,0 +1,193 @@ + + + + + + + + + + + + + + + + +Series 1 +Series 2 +Series 3 + + + + + + + +Index +sims + + + + + + + + + + + + + + +5 +10 +15 +20 + + + + + +-10 +-5 +0 +5 + +Run 1 + + + + + + + + + + + + + + + +5 +10 +15 +20 + + + + + +-10 +-5 +0 +5 + +Run 2 + + + + + + + + + + + + + + + +5 +10 +15 +20 + + + + + +-10 +-5 +0 +5 + +Run 3 + + + + + + + + + + + + + + + +5 +10 +15 +20 + + + + + +-10 +-5 +0 +5 + +Run 4 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/inst/tinytest/_tinysnapshot/array_4d.svg b/inst/tinytest/_tinysnapshot/array_4d.svg new file mode 100644 index 00000000..a94cf3a9 --- /dev/null +++ b/inst/tinytest/_tinysnapshot/array_4d.svg @@ -0,0 +1,429 @@ + + + + + + + + + + + + + + + + + + + +series +s1 +s2 +s3 + + + + + + + +time +sims4 + + + + + + + + + + + + + + + +2 +4 +6 +8 +10 + + + + + + +-2 +-1 +0 +1 +2 + +scenario = low + + + + + + + + + + + + + + + + +2 +4 +6 +8 +10 + + + + + + +-2 +-1 +0 +1 +2 + +scenario = high + +model = A + + + + + + + + + + + + + + + + +2 +4 +6 +8 +10 + + + + + + +-2 +-1 +0 +1 +2 + + + + + + + + + + + + + + + + +2 +4 +6 +8 +10 + + + + + + +-2 +-1 +0 +1 +2 + +model = B + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/inst/tinytest/_tinysnapshot/array_4d_heatmap.svg b/inst/tinytest/_tinysnapshot/array_4d_heatmap.svg new file mode 100644 index 00000000..502b53cf --- /dev/null +++ b/inst/tinytest/_tinysnapshot/array_4d_heatmap.svg @@ -0,0 +1,168 @@ + + + + + + + + + + + + + +series +time + + + + + + + + + +5 +4 +3 +2 +1 + +low + + + + + + + + + + +high + +A + + + + + + + + + +s1 +s2 +s3 +5 +4 +3 +2 +1 + + + + + + + + + +s1 +s2 +s3 + +B + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/inst/tinytest/_tinysnapshot/array_xy.svg b/inst/tinytest/_tinysnapshot/array_xy.svg new file mode 100644 index 00000000..9267d68d --- /dev/null +++ b/inst/tinytest/_tinysnapshot/array_xy.svg @@ -0,0 +1,281 @@ + + + + + + + + + + + + + + + 5 + 10 + 15 + 20 + + + + + + + + +time + + + + + + + +u +v + + + + + + + + + + + + + + + +-4 +-2 +0 +2 +4 + + + + + + +-4 +-2 +0 +2 +4 + +s1 + + + + + + + + + + + + + + + + +-4 +-2 +0 +2 +4 + + + + + + +-4 +-2 +0 +2 +4 + +s2 + + + + + + + + + + + + + + + + +-4 +-2 +0 +2 +4 + + + + + + +-4 +-2 +0 +2 +4 + +s3 + + + + + + + + + + + + + + + + +-4 +-2 +0 +2 +4 + + + + + + +-4 +-2 +0 +2 +4 + +s4 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/inst/tinytest/_tinysnapshot/array_xy_nofacet.svg b/inst/tinytest/_tinysnapshot/array_xy_nofacet.svg new file mode 100644 index 00000000..6e630bbc --- /dev/null +++ b/inst/tinytest/_tinysnapshot/array_xy_nofacet.svg @@ -0,0 +1,85 @@ + + + + + + + + + + + + + + + + + +subject +s1 +s2 +s3 +s4 + + + + + + + +u +v + + + + + + + + +-4 +-2 +0 +2 +4 + + + + + + +-4 +-2 +0 +2 +4 + + + + + + + + + + + + + + + diff --git a/inst/tinytest/_tinysnapshot/facet_grid_default_method.svg b/inst/tinytest/_tinysnapshot/facet_grid_default_method.svg new file mode 100644 index 00000000..b5f19659 --- /dev/null +++ b/inst/tinytest/_tinysnapshot/facet_grid_default_method.svg @@ -0,0 +1,208 @@ + + + + + + + + + + + + + +mtcars$wt +mtcars$mpg + + + + + + + + + + + + + + +2 +3 +4 +5 + + + + + + +10 +15 +20 +25 +30 + +0 + + + + + + + + + + + + + + + +2 +3 +4 +5 + + + + + + +10 +15 +20 +25 +30 + +1 + +0 + + + + + + + + + + + + + + + +2 +3 +4 +5 + + + + + + +10 +15 +20 +25 +30 + + + + + + + + + + + + + + + +2 +3 +4 +5 + + + + + + +10 +15 +20 +25 +30 + +1 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/inst/tinytest/_tinysnapshot/matrix_1col_facet_by.svg b/inst/tinytest/_tinysnapshot/matrix_1col_facet_by.svg new file mode 100644 index 00000000..d9237c3d --- /dev/null +++ b/inst/tinytest/_tinysnapshot/matrix_1col_facet_by.svg @@ -0,0 +1,72 @@ + + + + + + + + + + + + + +Index +matrix(1:5) + + + + + + +1 +2 +3 +4 +5 + + + + + + +1 +2 +3 +4 +5 + + + + + + + + + + + + + + + + + + + + diff --git a/inst/tinytest/test-array.R b/inst/tinytest/test-array.R new file mode 100644 index 00000000..2a2ecb18 --- /dev/null +++ b/inst/tinytest/test-array.R @@ -0,0 +1,83 @@ +source("helpers.R") +using("tinysnapshot") + +# tinyplot.array() method + +set.seed(42) +sims = array( + cumsum(rnorm(20 * 3 * 4)), dim = c(20, 3, 4), + dimnames = list(NULL, paste("Series", 1:3), paste("Run", 1:4)) +) +sims4 = array( + rnorm(10 * 3 * 2 * 2), dim = c(10, 3, 2, 2), + dimnames = list( + time = NULL, series = paste0("s", 1:3), + model = c("A", "B"), scenario = c("low", "high") + ) +) + +# 3D -> facet wrap by the third dimension +f = function() tinyplot(sims, type = "l") +expect_snapshot_plot(f, label = "array_3d") + +# 4D -> facet grid, with dimnames names as axis/legend/facet titles +f = function() tinyplot(sims4, type = "b", facet.args = list(prefix = TRUE)) +expect_snapshot_plot(f, label = "array_4d") + +# tile / heatmap layout per 2D slice +f = function() tinyplot(sims4[1:5, , , ], type = "heatmap", theme = "heatmap") +expect_snapshot_plot(f, label = "array_4d_heatmap") + +# 1D arrays are index plots +f = function() tinyplot(array(1:5, 5, list(letters[1:5])), type = "b") +expect_snapshot_plot(f, label = "array_1d") + +# unsupported inputs +expect_error(tinyplot(array(1:32, rep(2, 5))), "more than 4 dimensions") +expect_error(tinyplot(sims, facet = "by"), "must be NULL") + +# degenerate (1-row or 1-column) array inputs are dropped to vectors (#548) +f = function() tinyplot(1:10, array(1:10, c(1, 10)), ylab = "y") +expect_snapshot_plot(f, label = "array_1row_y") +f = function() tinyplot(1:10, array(1:10, c(10, 1)), ylab = "y") +expect_snapshot_plot(f, label = "array_1row_y") + +# facet formulas passed to the default method (i.e. without a model formula) +f = function() { + tinyplot(mtcars$wt, mtcars$mpg, facet = am ~ vs, data = mtcars) +} +expect_snapshot_plot(f, label = "facet_grid_default_method") + +# 1D arrays (e.g. from tapply) alongside a `y` defer to the default method +f = function() { + tinyplot( + tapply(mtcars$mpg, mtcars$cyl, mean), + tapply(mtcars$hp, mtcars$cyl, mean), + type = "b" + ) +} +expect_snapshot_plot(f, label = "array_1d_with_y") + +# single-column matrices ignore facet = "by" +f = function() tinyplot(matrix(1:5), facet = "by", type = "b") +expect_snapshot_plot(f, label = "matrix_1col_facet_by") + +# x/y pairs from a length-2 dimension: by (gradient) along the 1st dimension, +# faceted by the 2nd +set.seed(42) +tt = seq(0, 2 * pi, length.out = 20) +loops = array( + c(outer(cos(tt), 1:4) + rnorm(80, sd = 0.1), + outer(sin(tt), 1:4) + rnorm(80, sd = 0.1)), + dim = c(20, 4, 2), + dimnames = list(time = NULL, subject = paste0("s", 1:4), var = c("u", "v")) +) +f = function() tinyplot(loops) +expect_snapshot_plot(f, label = "array_xy") +expect_error(tinyplot(loops, xy = "subject"), "must have length 2") +expect_error(tinyplot(loops, xy = 3, type = "heatmap"), "not supported") +expect_error(tinyplot(loops, facet = "by"), "must be NULL") + +# facet = FALSE folds the facets into `by`, i.e. one path per subject +f = function() tinyplot(loops, facet = FALSE) +expect_snapshot_plot(f, label = "array_xy_nofacet") diff --git a/man/tinyplot.array.Rd b/man/tinyplot.array.Rd new file mode 100644 index 00000000..bb125ce6 --- /dev/null +++ b/man/tinyplot.array.Rd @@ -0,0 +1,149 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/tinyplot.array.R +\name{tinyplot.array} +\alias{tinyplot.array} +\title{tinyplot Method for Plotting Arrays} +\usage{ +\method{tinyplot}{array}( + x, + type = NULL, + legend = NULL, + facet = NULL, + xlab = NULL, + ylab = NULL, + xy = NULL, + ... +) +} +\arguments{ +\item{x}{an object of class \code{"array"}.} + +\item{type}{plot type passed on to \code{tinyplot}. Defaults to \code{"p"} (points).} + +\item{legend}{specification passed on to \code{tinyplot}. The default is to draw a +legend when the matrix has named columns, and to suppress it otherwise. For +\code{"tile"} and \code{"heatmap"} types it is suppressed by default.} + +\item{facet}{must be \code{NULL} (the default) or \code{FALSE} for arrays with three +or more dimensions, since the facets are then determined by the array +dimensions. \code{FALSE} draws everything in a single panel instead, by folding +the facet dimensions into the \code{by} groups. For x/y pairs, these groups +then replace the first dimension as \code{by}, so that each path is drawn +separately. Not supported for tile and heatmap types.} + +\item{xlab, ylab}{axis labels passed on to \code{tinyplot}. \code{ylab} defaults to the +deparsed matrix name. \code{xlab} defaults to \code{"Index"} when the matrix has no +row names; when it does, the row names already label the ticks so the +x-axis title is suppressed. For \code{"tile"} and \code{"heatmap"} types both +titles default to \code{NA}, since the dimnames label both axes.} + +\item{xy}{which dimension (if any) holds x/y pairs; see Details. The default +\code{NULL} auto-detects it, \code{FALSE} opts out, and a dimension index or name +selects it explicitly. The selected dimension must have length 2, and its +dimnames (if any) are used as the axis titles.} + +\item{...}{further arguments passed to \code{tinyplot}.} +} +\value{ +By default, no return value; called for the side effect of producing +a plot. If \code{record = TRUE} (or globally via \code{tpar(record = TRUE)}), the +plot is instead returned invisibly as a \code{"recordedtinyplot"} object, which +can be replayed later; see \code{\link{recordedtinyplot}}. +} +\description{ +Convenience interface for visualizing +\code{\link[base]{array}} objects with tinyplot. Extends the +\code{\link{tinyplot.matrix}} conventions to arrays with up to four +dimensions, by mapping the higher dimensions to facets. +} +\details{ +The first two dimensions of the array are treated exactly like the +rows and columns of a matrix; see \code{\link{tinyplot.matrix}}. By default, +that means the values are plotted against the (first dimension) row index, +with a separate \code{by} group for each element of the second dimension. +Higher dimensions are then mapped to facets: +\itemize{ +\item 1D arrays are treated as a single-column matrix, i.e. a simple index +plot. If a \code{y} variable is also supplied, e.g. \code{tinyplot(tapply(...), y)}, +then the array is instead treated as a plain \code{x} vector. +\item 2D arrays are matrices, and hence dispatch to +\code{\link{tinyplot.matrix}}. +\item 3D arrays are faceted by the third dimension (i.e., a "facet wrap"). +\item 4D arrays are faceted by the third and fourth dimensions, as the rows +and columns of a "facet grid", respectively. +\item Arrays with more than four dimensions are not supported. Subset or +reshape them first. +} + +Dimension names are used to label the groups, facets, and axis ticks, while +the names of the dimnames (if any) are used as the axis, legend, and facet +titles. To assign the dimensions to different roles, rearrange them first +with \code{\link[base]{aperm}}. + +\strong{x/y pairs.} Arrays often hold a stack of matrices, one per variable, +e.g. the hip and knee angles of the \code{gait} data (\code{Time} x \code{Subject} x +\code{Variable}). If an array of three or more dimensions has exactly one +length-2 dimension besides the first, then its two slices are plotted +against each other, as x and y. Each of the remaining dimensions then +shifts down a role: +\itemize{ +\item The first dimension becomes \code{by}, and orders the points along each path. +It is kept numeric where possible (its dimnames, e.g. times, or else its +index), so that each path is drawn as a line with a colour gradient. If +the dimnames are not numeric, then the groups are discrete and drawn as +points instead. +\item The second dimension becomes a facet wrap (e.g. one panel per subject). +\item The third dimension (if any) makes this a facet grid, as its columns. +} + +Control this via the \code{xy} argument. Tile and heatmap types are exempt, +since they need the array values as their fill. + +Note that contingency tables created by \code{\link[base]{table}} (e.g., +\code{HairEyeColor} or \code{Titanic}) are of class \code{"table"} and hence do not +dispatch to this method. +} +\examples{ +# 3D array: facet wrap by the third dimension +sims = array( + cumsum(rnorm(20 * 3 * 4)), dim = c(20, 3, 4), + dimnames = list(NULL, paste("Series", 1:3), paste("Run", 1:4)) +) +tinyplot(sims, type = "l") + +# 4D array: facet grid by the third and fourth dimensions +sims4 = array( + rnorm(20 * 3 * 2 * 2), dim = c(20, 3, 2, 2), + dimnames = list( + time = NULL, series = paste0("s", 1:3), + model = c("A", "B"), scenario = c("low", "high") + ) +) +tinyplot(sims4, type = "b", facet.args = list(prefix = TRUE)) + +# tile/heatmap types lay out each 2D slice as a grid +tinyplot(sims4[1:5, , , ], type = "heatmap", theme = "heatmap") + +\dontshow{if (getRversion() >= "4.5.0") withAutoprint(\{ # examplesIf} +# x/y pairs: a length-2 dimension is plotted as x vs y +# (e.g., hip vs knee angle through the gait cycle, coloured by time and +# faceted by boy) +gait9 = gait[, 1:9, ] # take a subset for demonstration +tinyplot(gait9) + +# all boys in a single panel instead +tinyplot(gait9, facet = FALSE) + +# ... or in the background of each boy's own panel +tinyplot( + gait9, + draw = tinyplot(gait9, col = "lightgray", facet = FALSE, add = TRUE) +) + +# opt out of x/y pairs, to plot the angles against time instead +tinyplot(gait9, type = "l", xy = FALSE, legend = FALSE) +\dontshow{\}) # examplesIf} +} +\seealso{ +\code{\link{tinyplot.matrix}}, \code{\link[graphics]{matplot}} +} diff --git a/man/tinyplot.matrix.Rd b/man/tinyplot.matrix.Rd index c20c9106..86f009af 100644 --- a/man/tinyplot.matrix.Rd +++ b/man/tinyplot.matrix.Rd @@ -91,5 +91,5 @@ tinyplot(VADeaths, type = "barplot", facet = "by", legend = FALSE) } \seealso{ -\code{\link[graphics]{matplot}} +\code{\link{tinyplot.array}}, \code{\link[graphics]{matplot}} }