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