From d4a80e297862ddc6918ea6fa6010880565bfe179 Mon Sep 17 00:00:00 2001 From: Grant McDermott Date: Thu, 24 Sep 2026 16:15:45 -0700 Subject: [PATCH 1/8] feat: add tinyplot.array() method Add a tinyplot.array() method for arrays with up to four dimensions. The first two dimensions follow the tinyplot.matrix() conventions; the third maps to a facet wrap and a fourth to a facet grid. The matrix method is refactored onto a shared internal array_plot() helper. Also fix two related bugs: - Drop degenerate dimensions (1-row/1-column arrays) in sanitize_datapoints(), so tinyplot(1:10, array(1:10, c(1, 10))) works like plot(). - Forward `data` to sanitize_facet(), so two-sided facet formulas work with the default method. Previously `data` resolved to utils::data. Closes #548 --- NAMESPACE | 1 + NEWS.md | 14 + R/sanitize_datapoints.R | 14 +- R/sanitize_facet.R | 6 +- R/tinyplot.R | 1 + R/tinyplot.array.R | 92 ++++ R/tinyplot.matrix.R | 167 ++++--- altdoc/quarto_website.yml | 2 + inst/tinytest/_tinysnapshot/array_1d.svg | 71 +++ inst/tinytest/_tinysnapshot/array_1row_y.svg | 73 +++ inst/tinytest/_tinysnapshot/array_3d.svg | 193 ++++++++ inst/tinytest/_tinysnapshot/array_4d.svg | 429 ++++++++++++++++++ .../_tinysnapshot/array_4d_heatmap.svg | 168 +++++++ .../facet_grid_default_method.svg | 208 +++++++++ inst/tinytest/test-array.R | 49 ++ man/tinyplot.array.Rd | 99 ++++ man/tinyplot.matrix.Rd | 2 +- 17 files changed, 1516 insertions(+), 73 deletions(-) create mode 100644 R/tinyplot.array.R create mode 100644 inst/tinytest/_tinysnapshot/array_1d.svg create mode 100644 inst/tinytest/_tinysnapshot/array_1row_y.svg create mode 100644 inst/tinytest/_tinysnapshot/array_3d.svg create mode 100644 inst/tinytest/_tinysnapshot/array_4d.svg create mode 100644 inst/tinytest/_tinysnapshot/array_4d_heatmap.svg create mode 100644 inst/tinytest/_tinysnapshot/facet_grid_default_method.svg create mode 100644 inst/tinytest/test-array.R create mode 100644 man/tinyplot.array.Rd diff --git a/NAMESPACE b/NAMESPACE index 5b03fe9ec..f69578b11 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 f01c7e5a4..2e8770234 100644 --- a/NEWS.md +++ b/NEWS.md @@ -149,6 +149,14 @@ 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. (#548 + @grantmcdermott) + #### Other new features - New top-level `tinyplot()`/`plt()` arguments: @@ -243,6 +251,12 @@ 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 @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. (@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/sanitize_datapoints.R b/R/sanitize_datapoints.R index 1a99752a8..123a04731 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 4a51cf717..d9369cf73 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 87c950bc6..52843433b 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), diff --git a/R/tinyplot.array.R b/R/tinyplot.array.R new file mode 100644 index 000000000..9db3ca81a --- /dev/null +++ b/R/tinyplot.array.R @@ -0,0 +1,92 @@ +#' 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. +#' - 2D arrays are matrices and are passed on 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. +#' +#' 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` for arrays with three or more dimensions, since +#' the facets are then determined by the array dimensions. +#' @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") +#' +#' @export +tinyplot.array = function(x, type = NULL, legend = NULL, facet = NULL, xlab = NULL, ylab = 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 > 2L && !is.null(facet)) { + stop( + "`facet` must be NULL for arrays with 3 or more dimensions, since the ", + "facets are determined by the array dimensions.", + call. = FALSE + ) + } + if (nd <= 2L) assert_choice(facet, "by", null.ok = TRUE) + ## 1D arrays are treated as a single-column matrix + if (nd == 1L) { + dn = dimnames(x) + dim(x) = c(length(x), 1L) + if (!is.null(dn)) dimnames(x) = c(dn, list(NULL)) + } + array_plot( + x, type = type, legend = legend, facet = facet, xlab = xlab, ylab = ylab, + dep = dep, ... + ) +} diff --git a/R/tinyplot.matrix.R b/R/tinyplot.matrix.R index 61ebd6419..ec6895a76 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,12 +68,44 @@ #' @export tinyplot.matrix = function(x, type = NULL, legend = NULL, facet = NULL, xlab = NULL, ylab = NULL, ...) { assert_choice(facet, "by", null.ok = TRUE) + 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; dimensions 3 and 4 (if any) are mapped to +## facets, as a wrap and a grid, respectively. +array_plot = function(x, type = NULL, legend = NULL, facet = NULL, + xlab = NULL, ylab = NULL, dep = NULL, ...) { ## 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)) + rev_y = FALSE 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) + } + } ## 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) @@ -83,88 +115,89 @@ tinyplot.matrix = function(x, type = NULL, legend = NULL, facet = NULL, xlab = N 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])) - } else { - factor(rep(cnms, each = dims[1]), levels = cnms) - } ## 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 + rev_y = !"ylim" %in% names(list(...)) } 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) + if (dims[2] == 1L) { + by = NULL + legend = FALSE + } else if (!is.null(dnms[[2]])) { + by = dim_factor(2) + if (is.null(legend)) legend = list(title = dvar(2)) } else { - bby = factor(rep(seq_len(dims[2]), each = dims[1])) + by = dim_factor(2) legend = FALSE } + ## 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 + + ## Higher dimensions become facets: the 3rd dimension as a wrap, or (with a + ## 4th) as the rows of a grid whose columns are the 4th dimension. We build + ## the facet factor ourselves, mimicking what get_facet_fml() returns for a + ## `dim3 ~ dim4` formula. + if (length(dims) > 2L) { + fvar = function(k, f) { + out = list(levels(f)) + names(out) = dvar(k) %||% paste0("dim", k) + out + } + f3 = dim_factor(3) + if (length(dims) == 3L) { + facet = f3 + attr(facet, "facet_vars") = list(x = fvar(3, f3)) + } else { + f4 = dim_factor(4) + ## NOTE: the grid columns come first, since mfrow plots rowwise + facet = interaction(f4, f3, sep = "~", lex.order = FALSE) + attr(facet, "facet_grid") = TRUE + attr(facet, "facet_nrow") = dims[3] + attr(facet, "facet_vars") = list(x = fvar(4, f4), y = fvar(3, f3)) + } } - if (is.null(ylab)) ylab = dep_x - tinyplot.default( - x = x, y = y, - type = type, - by = bby, - facet = facet, - legend = legend, - xlab = xlab, - ylab = ylab, - ... - ) + + ## (a direct call, rather than do.call(), avoids deparsing the long vectors + ## into tinyplot's internal labels and keeps any `...` expressions intact) + draw = function(...) { + tinyplot.default( + x = xx, y = yy, + type = type, + by = by, + facet = facet, + legend = legend, + xlab = xlab, + ylab = ylab, + ... + ) + } + if (rev_y) draw(ylim = "reverse", ...) else draw(...) } diff --git a/altdoc/quarto_website.yml b/altdoc/quarto_website.yml index 2cfe173ff..108c4a5f2 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 000000000..156305d08 --- /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_1row_y.svg b/inst/tinytest/_tinysnapshot/array_1row_y.svg new file mode 100644 index 000000000..f7a095711 --- /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 000000000..e182d222b --- /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 000000000..a94cf3a91 --- /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 000000000..502b53cf0 --- /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/facet_grid_default_method.svg b/inst/tinytest/_tinysnapshot/facet_grid_default_method.svg new file mode 100644 index 000000000..b5f196593 --- /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/test-array.R b/inst/tinytest/test-array.R new file mode 100644 index 000000000..8eedaab58 --- /dev/null +++ b/inst/tinytest/test-array.R @@ -0,0 +1,49 @@ +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") diff --git a/man/tinyplot.array.Rd b/man/tinyplot.array.Rd new file mode 100644 index 000000000..c72981c37 --- /dev/null +++ b/man/tinyplot.array.Rd @@ -0,0 +1,99 @@ +% 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, + ... +) +} +\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} for arrays with three or more dimensions, since +the facets are then determined by the array dimensions.} + +\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{...}{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. +\item 2D arrays are matrices and are passed on 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. + +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") + +} +\seealso{ +\code{\link{tinyplot.matrix}}, \code{\link[graphics]{matplot}} +} diff --git a/man/tinyplot.matrix.Rd b/man/tinyplot.matrix.Rd index c20c91063..86f009af1 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}} } From a0e8b2d066914020d59b24b81ce69a487563e965 Mon Sep 17 00:00:00 2001 From: Grant McDermott Date: Thu, 24 Sep 2026 16:15:53 -0700 Subject: [PATCH 2/8] docs: add PR number to NEWS --- NEWS.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/NEWS.md b/NEWS.md index 2e8770234..5fac1db2d 100644 --- a/NEWS.md +++ b/NEWS.md @@ -154,7 +154,7 @@ related to plot layering. See "Bug fixes" below. - `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. (#548 + dimnames (if any) are used for the axis, legend, and facet titles. (#746 @grantmcdermott) #### Other new features @@ -253,10 +253,11 @@ related to plot layering. See "Bug fixes" below. - 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 @tony-aw @zeileis @grantmcdermott) + 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. (@grantmcdermott) + 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 From 3f3938133cd3bcc2dc5f8e3641efbc49e3fed4ec Mon Sep 17 00:00:00 2001 From: Grant McDermott Date: Thu, 24 Sep 2026 17:12:29 -0700 Subject: [PATCH 3/8] simplify --- R/facet.R | 21 ++++++++++----- R/tinyplot.R | 10 +++---- R/tinyplot.array.R | 22 +++++++++++---- R/tinyplot.matrix.R | 62 ++++++++++++++++++------------------------- man/tinyplot.array.Rd | 5 ++-- 5 files changed, 64 insertions(+), 56 deletions(-) diff --git a/R/facet.R b/R/facet.R index a59fb447e..52f85c815 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/tinyplot.R b/R/tinyplot.R index 52843433b..91b29f948 100644 --- a/R/tinyplot.R +++ b/R/tinyplot.R @@ -2210,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 index 9db3ca81a..fb91e4803 100644 --- a/R/tinyplot.array.R +++ b/R/tinyplot.array.R @@ -12,8 +12,9 @@ #' Higher dimensions are then mapped to facets: #' #' - 1D arrays are treated as a single-column matrix, i.e. a simple index -#' plot. -#' - 2D arrays are matrices and are passed on to +#' 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 @@ -71,16 +72,27 @@ tinyplot.array = function(x, type = NULL, legend = NULL, facet = NULL, xlab = NU call. = FALSE ) } - if (nd > 2L && !is.null(facet)) { + if (nd <= 2L) { + assert_choice(facet, "by", null.ok = TRUE) + } else if (!is.null(facet)) { stop( "`facet` must be NULL for arrays with 3 or more dimensions, since the ", "facets are determined by the array dimensions.", call. = FALSE ) } - if (nd <= 2L) assert_choice(facet, "by", null.ok = TRUE) - ## 1D arrays are treated as a single-column matrix 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)) diff --git a/R/tinyplot.matrix.R b/R/tinyplot.matrix.R index ec6895a76..d5647835a 100644 --- a/R/tinyplot.matrix.R +++ b/R/tinyplot.matrix.R @@ -81,12 +81,11 @@ tinyplot.matrix = function(x, type = NULL, legend = NULL, facet = NULL, xlab = N ## documented in ?tinyplot.matrix; dimensions 3 and 4 (if any) are mapped to ## facets, as a wrap and a grid, respectively. array_plot = function(x, type = NULL, legend = NULL, facet = NULL, - xlab = NULL, ylab = NULL, dep = NULL, ...) { + xlab = NULL, ylab = NULL, ylim = NULL, dep = NULL, ...) { ## 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" - rev_y = FALSE dims = dim(x) dnms = dimnames(x) ## names(dimnames(x)), if any, e.g. for arrays built from a table @@ -133,17 +132,21 @@ array_plot = function(x, type = NULL, legend = NULL, facet = NULL, ## 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.) - rev_y = !"ylim" %in% names(list(...)) + if (is.null(ylim)) ylim = "reverse" } else { if (dims[2] == 1L) { + ## a single column is a simple index plot, so there is nothing to group + ## (or facet) by by = NULL legend = FALSE - } else if (!is.null(dnms[[2]])) { - by = dim_factor(2) - if (is.null(legend)) legend = list(title = dvar(2)) + if (identical(facet, "by")) facet = NULL } else { by = dim_factor(2) - legend = FALSE + if (is.null(dnms[[2]])) { + legend = FALSE + } else if (is.null(legend)) { + legend = list(title = dvar(2)) + } } ## 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 @@ -162,42 +165,29 @@ array_plot = function(x, type = NULL, legend = NULL, facet = NULL, } ## Higher dimensions become facets: the 3rd dimension as a wrap, or (with a - ## 4th) as the rows of a grid whose columns are the 4th dimension. We build - ## the facet factor ourselves, mimicking what get_facet_fml() returns for a - ## `dim3 ~ dim4` formula. + ## 4th) as the rows of a grid whose columns are the 4th dimension, i.e. the + ## same as a `dim3 ~ dim4` facet formula. if (length(dims) > 2L) { - fvar = function(k, f) { - out = list(levels(f)) - names(out) = dvar(k) %||% paste0("dim", k) - out - } + fvars = function(k, f) facet_var_list(f, dvar(k) %||% paste0("dim", k)) f3 = dim_factor(3) if (length(dims) == 3L) { facet = f3 - attr(facet, "facet_vars") = list(x = fvar(3, f3)) + attr(facet, "facet_vars") = list(x = fvars(3, f3)) } else { f4 = dim_factor(4) - ## NOTE: the grid columns come first, since mfrow plots rowwise - facet = interaction(f4, f3, sep = "~", lex.order = FALSE) - attr(facet, "facet_grid") = TRUE - attr(facet, "facet_nrow") = dims[3] - attr(facet, "facet_vars") = list(x = fvar(4, f4), y = fvar(3, f3)) + facet = facet_grid_factor(f4, f3, fvars(4, f4), fvars(3, f3)) } } - ## (a direct call, rather than do.call(), avoids deparsing the long vectors - ## into tinyplot's internal labels and keeps any `...` expressions intact) - draw = function(...) { - tinyplot.default( - x = xx, y = yy, - type = type, - by = by, - facet = facet, - legend = legend, - xlab = xlab, - ylab = ylab, - ... - ) - } - if (rev_y) draw(ylim = "reverse", ...) else draw(...) + tinyplot.default( + x = xx, y = yy, + type = type, + by = by, + facet = facet, + legend = legend, + xlab = xlab, + ylab = ylab, + ylim = ylim, + ... + ) } diff --git a/man/tinyplot.array.Rd b/man/tinyplot.array.Rd index c72981c37..2c0f9d03c 100644 --- a/man/tinyplot.array.Rd +++ b/man/tinyplot.array.Rd @@ -54,8 +54,9 @@ 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. -\item 2D arrays are matrices and are passed on to +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 From 51e22423df0354f51663727335794f0af37ead2e Mon Sep 17 00:00:00 2001 From: Grant McDermott Date: Thu, 24 Sep 2026 17:12:38 -0700 Subject: [PATCH 4/8] tests --- .../_tinysnapshot/array_1d_with_y.svg | 74 +++++++++++++++++++ .../_tinysnapshot/matrix_1col_facet_by.svg | 72 ++++++++++++++++++ inst/tinytest/test-array.R | 14 ++++ 3 files changed, 160 insertions(+) create mode 100644 inst/tinytest/_tinysnapshot/array_1d_with_y.svg create mode 100644 inst/tinytest/_tinysnapshot/matrix_1col_facet_by.svg 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 000000000..af262c8dc --- /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/matrix_1col_facet_by.svg b/inst/tinytest/_tinysnapshot/matrix_1col_facet_by.svg new file mode 100644 index 000000000..d9237c3dd --- /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 index 8eedaab58..2d0fa3b0a 100644 --- a/inst/tinytest/test-array.R +++ b/inst/tinytest/test-array.R @@ -47,3 +47,17 @@ 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") From 8cad2f969d6e2ec6e1432d48751592ed9331a83d Mon Sep 17 00:00:00 2001 From: Grant McDermott Date: Thu, 24 Sep 2026 17:12:43 -0700 Subject: [PATCH 5/8] news --- NEWS.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/NEWS.md b/NEWS.md index 5fac1db2d..695950930 100644 --- a/NEWS.md +++ b/NEWS.md @@ -154,8 +154,8 @@ related to plot layering. See "Bug fixes" below. - `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. (#746 - @grantmcdermott) + dimnames (if any) are used for the axis, legend, and facet titles. + (#746 @grantmcdermott) #### Other new features From 94af31044982c501fb522815a8dc1671e9ddfb49 Mon Sep 17 00:00:00 2001 From: Grant McDermott Date: Sat, 26 Sep 2026 19:42:36 -0700 Subject: [PATCH 6/8] xy --- NEWS.md | 4 +- R/tinyplot.array.R | 115 ++++++++-- R/tinyplot.matrix.R | 85 ++++--- inst/tinytest/_tinysnapshot/array_xy.svg | 281 +++++++++++++++++++++++ inst/tinytest/test-array.R | 16 ++ man/tinyplot.array.Rd | 37 ++- 6 files changed, 497 insertions(+), 41 deletions(-) create mode 100644 inst/tinytest/_tinysnapshot/array_xy.svg diff --git a/NEWS.md b/NEWS.md index 695950930..cf9bc28dd 100644 --- a/NEWS.md +++ b/NEWS.md @@ -154,7 +154,9 @@ related to plot layering. See "Bug fixes" below. - `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. + 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. (#746 @grantmcdermott) #### Other new features diff --git a/R/tinyplot.array.R b/R/tinyplot.array.R index fb91e4803..ed6f296e2 100644 --- a/R/tinyplot.array.R +++ b/R/tinyplot.array.R @@ -24,7 +24,26 @@ #' #' 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. +#' 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 @@ -34,6 +53,10 @@ #' @param x an object of class `"array"`. #' @param facet must be `NULL` for arrays with three or more dimensions, since #' the facets are then determined by the array dimensions. +#' @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 @@ -61,8 +84,17 @@ #' # tile/heatmap types lay out each 2D slice as a grid #' tinyplot(sims4[1:5, , , ], type = "heatmap", theme = "heatmap") #' +#' # x/y pairs: a length-2 dimension is plotted as x vs y +#' if (getRversion() >= "4.5.0") { +#' # hip vs knee angle through the gait cycle, coloured by time and +#' # faceted by boy +#' tinyplot(gait[, 1:9, ]) +#' # opt out, to plot the angles against time instead +#' tinyplot(gait[, 1:9, ], type = "l", xy = FALSE, legend = FALSE) +#' } +#' #' @export -tinyplot.array = function(x, type = NULL, legend = NULL, facet = NULL, xlab = NULL, ylab = NULL, ...) { +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) { @@ -72,15 +104,6 @@ tinyplot.array = function(x, type = NULL, legend = NULL, facet = NULL, xlab = NU call. = FALSE ) } - if (nd <= 2L) { - assert_choice(facet, "by", null.ok = TRUE) - } else if (!is.null(facet)) { - stop( - "`facet` must be NULL for arrays with 3 or more dimensions, since the ", - "facets are determined by the array dimensions.", - 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 @@ -97,8 +120,74 @@ tinyplot.array = function(x, type = NULL, legend = NULL, facet = NULL, xlab = NU 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]) + } + + if (is.null(k) && nd <= 2L) { + assert_choice(facet, "by", null.ok = TRUE) + } else if (!is.null(facet)) { + stop( + "`facet` must be NULL 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, type = type, legend = legend, facet = facet, xlab = xlab, ylab = ylab, - dep = dep, ... + 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 d5647835a..f0fa8e3ee 100644 --- a/R/tinyplot.matrix.R +++ b/R/tinyplot.matrix.R @@ -77,15 +77,14 @@ tinyplot.matrix = function(x, type = NULL, legend = NULL, facet = NULL, xlab = N ## 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; dimensions 3 and 4 (if any) are mapped to -## facets, as a wrap and a grid, respectively. -array_plot = function(x, type = NULL, legend = NULL, facet = NULL, +## 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, ...) { - ## 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" dims = dim(x) dnms = dimnames(x) ## names(dimnames(x)), if any, e.g. for arrays built from a table @@ -105,15 +104,36 @@ array_plot = function(x, type = NULL, legend = NULL, facet = NULL, 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)] - ## 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")) { + ## 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) + ## 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)) { ## 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 @@ -134,6 +154,10 @@ array_plot = function(x, type = NULL, legend = NULL, facet = NULL, ## are idempotent and so compose safely.) if (is.null(ylim)) ylim = "reverse" } else { + ## 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" if (dims[2] == 1L) { ## a single column is a simple index plot, so there is nothing to group ## (or facet) by @@ -164,18 +188,20 @@ array_plot = function(x, type = NULL, legend = NULL, facet = NULL, if (is.null(ylab)) ylab = dep } - ## Higher dimensions become facets: the 3rd dimension as a wrap, or (with a - ## 4th) as the rows of a grid whose columns are the 4th dimension, i.e. the - ## same as a `dim3 ~ dim4` facet formula. - if (length(dims) > 2L) { + ## 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)) { fvars = function(k, f) facet_var_list(f, dvar(k) %||% paste0("dim", k)) - f3 = dim_factor(3) - if (length(dims) == 3L) { - facet = f3 - attr(facet, "facet_vars") = list(x = fvars(3, f3)) + fr = dim_factor(fdims[1]) + if (length(fdims) == 1L) { + facet = fr + attr(facet, "facet_vars") = list(x = fvars(fdims[1], fr)) } else { - f4 = dim_factor(4) - facet = facet_grid_factor(f4, f3, fvars(4, f4), fvars(3, f3)) + fc = dim_factor(fdims[2]) + facet = facet_grid_factor( + fc, fr, fvars(fdims[2], fc), fvars(fdims[1], fr) + ) } } @@ -191,3 +217,10 @@ array_plot = function(x, type = NULL, legend = NULL, facet = NULL, ... ) } + + +## 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/inst/tinytest/_tinysnapshot/array_xy.svg b/inst/tinytest/_tinysnapshot/array_xy.svg new file mode 100644 index 000000000..9267d68d8 --- /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/test-array.R b/inst/tinytest/test-array.R index 2d0fa3b0a..b812d478f 100644 --- a/inst/tinytest/test-array.R +++ b/inst/tinytest/test-array.R @@ -61,3 +61,19 @@ 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") diff --git a/man/tinyplot.array.Rd b/man/tinyplot.array.Rd index 2c0f9d03c..815e95f36 100644 --- a/man/tinyplot.array.Rd +++ b/man/tinyplot.array.Rd @@ -11,6 +11,7 @@ facet = NULL, xlab = NULL, ylab = NULL, + xy = NULL, ... ) } @@ -32,6 +33,11 @@ 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{ @@ -67,7 +73,27 @@ 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. +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 @@ -94,6 +120,15 @@ 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") +# x/y pairs: a length-2 dimension is plotted as x vs y +if (getRversion() >= "4.5.0") { + # hip vs knee angle through the gait cycle, coloured by time and + # faceted by boy + tinyplot(gait[, 1:9, ]) + # opt out, to plot the angles against time instead + tinyplot(gait[, 1:9, ], type = "l", xy = FALSE, legend = FALSE) +} + } \seealso{ \code{\link{tinyplot.matrix}}, \code{\link[graphics]{matplot}} From 31c8f38d38d8069c527f19c5446c10cf875897e4 Mon Sep 17 00:00:00 2001 From: Grant McDermott Date: Sat, 26 Sep 2026 19:44:06 -0700 Subject: [PATCH 7/8] rather use examplesIf for gait example --- R/tinyplot.array.R | 14 +++++++------- man/tinyplot.array.Rd | 14 +++++++------- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/R/tinyplot.array.R b/R/tinyplot.array.R index ed6f296e2..ac4786e8f 100644 --- a/R/tinyplot.array.R +++ b/R/tinyplot.array.R @@ -84,14 +84,14 @@ #' # 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 -#' if (getRversion() >= "4.5.0") { -#' # hip vs knee angle through the gait cycle, coloured by time and -#' # faceted by boy -#' tinyplot(gait[, 1:9, ]) -#' # opt out, to plot the angles against time instead -#' tinyplot(gait[, 1:9, ], type = "l", xy = FALSE, legend = FALSE) -#' } +#' # (e.g., hip vs knee angle through the gait cycle, coloured by time and +#' # faceted by boy) +#' tinyplot(gait[, 1:9, ]) +#' +#' # opt out, to plot the angles against time instead +#' tinyplot(gait[, 1:9, ], type = "l", xy = FALSE, legend = FALSE) #' #' @export tinyplot.array = function(x, type = NULL, legend = NULL, facet = NULL, xlab = NULL, ylab = NULL, xy = NULL, ...) { diff --git a/man/tinyplot.array.Rd b/man/tinyplot.array.Rd index 815e95f36..85b1988b2 100644 --- a/man/tinyplot.array.Rd +++ b/man/tinyplot.array.Rd @@ -120,15 +120,15 @@ 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 -if (getRversion() >= "4.5.0") { - # hip vs knee angle through the gait cycle, coloured by time and - # faceted by boy - tinyplot(gait[, 1:9, ]) - # opt out, to plot the angles against time instead - tinyplot(gait[, 1:9, ], type = "l", xy = FALSE, legend = FALSE) -} +# (e.g., hip vs knee angle through the gait cycle, coloured by time and +# faceted by boy) +tinyplot(gait[, 1:9, ]) +# opt out, to plot the angles against time instead +tinyplot(gait[, 1:9, ], type = "l", xy = FALSE, legend = FALSE) +\dontshow{\}) # examplesIf} } \seealso{ \code{\link{tinyplot.matrix}}, \code{\link[graphics]{matplot}} From 98630dfcf8b27b4631822a8dad52f8622725b451 Mon Sep 17 00:00:00 2001 From: Grant McDermott Date: Sat, 26 Sep 2026 20:05:24 -0700 Subject: [PATCH 8/8] support facet = FALSE --- NEWS.md | 1 + R/tinyplot.array.R | 33 +++++-- R/tinyplot.matrix.R | 69 ++++++++++----- .../_tinysnapshot/array_xy_nofacet.svg | 85 +++++++++++++++++++ inst/tinytest/test-array.R | 4 + man/tinyplot.array.Rd | 24 ++++-- 6 files changed, 182 insertions(+), 34 deletions(-) create mode 100644 inst/tinytest/_tinysnapshot/array_xy_nofacet.svg diff --git a/NEWS.md b/NEWS.md index cf9bc28dd..3113e37d8 100644 --- a/NEWS.md +++ b/NEWS.md @@ -157,6 +157,7 @@ related to plot layering. See "Bug fixes" below. 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 diff --git a/R/tinyplot.array.R b/R/tinyplot.array.R index ac4786e8f..706af295e 100644 --- a/R/tinyplot.array.R +++ b/R/tinyplot.array.R @@ -51,8 +51,12 @@ #' #' @inheritParams tinyplot.matrix #' @param x an object of class `"array"`. -#' @param facet must be `NULL` for arrays with three or more dimensions, since -#' the facets are then determined by the array dimensions. +#' @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 @@ -88,10 +92,20 @@ #' # 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) -#' tinyplot(gait[, 1:9, ]) +#' gait9 = gait[, 1:9, ] # take a subset for demonstration +#' tinyplot(gait9) #' -#' # opt out, to plot the angles against time instead -#' tinyplot(gait[, 1:9, ], type = "l", xy = FALSE, legend = FALSE) +#' # 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, ...) { @@ -142,12 +156,13 @@ tinyplot.array = function(x, type = NULL, legend = NULL, facet = NULL, xlab = NU 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) { - assert_choice(facet, "by", null.ok = TRUE) - } else if (!is.null(facet)) { + if (!isFALSE(facet)) assert_choice(facet, "by", null.ok = TRUE) + } else if (!is.null(facet) && !isFALSE(facet)) { stop( - "`facet` must be NULL for arrays with 3 or more dimensions (or x/y ", - "pairs), since the facets are determined by the array dimensions.", + "`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 ) } diff --git a/R/tinyplot.matrix.R b/R/tinyplot.matrix.R index f0fa8e3ee..d8ca68767 100644 --- a/R/tinyplot.matrix.R +++ b/R/tinyplot.matrix.R @@ -106,6 +106,18 @@ array_plot = function(x, y = NULL, type = NULL, legend = NULL, facet = NULL, } ## 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 = ":") + } ## 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 @@ -116,24 +128,39 @@ array_plot = function(x, y = NULL, type = NULL, legend = NULL, facet = NULL, if (!is.null(y)) { xx = as.vector(x) yy = as.vector(y) - ## 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))] + 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 { - dim_factor(1, ordered = TRUE) + ## 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)) } - 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 @@ -158,18 +185,20 @@ array_plot = function(x, y = NULL, type = NULL, legend = NULL, facet = NULL, ## 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" - if (dims[2] == 1L) { + ## 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 = dim_factor(2) - if (is.null(dnms[[2]])) { + by = group_by(bdims) + if (all(vapply(dnms[bdims], is.null, NA))) { legend = FALSE } else if (is.null(legend)) { - legend = list(title = dvar(2)) + legend = list(title = group_title(bdims)) } } ## If the matrix has row names, use them for the x-axis tick labels via an @@ -191,7 +220,7 @@ array_plot = function(x, y = NULL, type = NULL, legend = NULL, facet = NULL, ## 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)) { + 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) { diff --git a/inst/tinytest/_tinysnapshot/array_xy_nofacet.svg b/inst/tinytest/_tinysnapshot/array_xy_nofacet.svg new file mode 100644 index 000000000..6e630bbc2 --- /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/test-array.R b/inst/tinytest/test-array.R index b812d478f..2a2ecb18d 100644 --- a/inst/tinytest/test-array.R +++ b/inst/tinytest/test-array.R @@ -77,3 +77,7 @@ 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 index 85b1988b2..bb125ce6b 100644 --- a/man/tinyplot.array.Rd +++ b/man/tinyplot.array.Rd @@ -24,8 +24,12 @@ 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} for arrays with three or more dimensions, since -the facets are then determined by the array dimensions.} +\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 @@ -124,10 +128,20 @@ tinyplot(sims4[1:5, , , ], type = "heatmap", theme = "heatmap") # 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) -tinyplot(gait[, 1:9, ]) +gait9 = gait[, 1:9, ] # take a subset for demonstration +tinyplot(gait9) -# opt out, to plot the angles against time instead -tinyplot(gait[, 1:9, ], type = "l", xy = FALSE, legend = FALSE) +# 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{