diff --git a/NAMESPACE b/NAMESPACE
index ce4e7391..0dcfbb08 100644
--- a/NAMESPACE
+++ b/NAMESPACE
@@ -29,6 +29,7 @@ export(type_ellipse)
export(type_errorbar)
export(type_function)
export(type_glm)
+export(type_heatmap)
export(type_hexbin)
export(type_hist)
export(type_histogram)
@@ -51,6 +52,7 @@ export(type_spineplot)
export(type_spline)
export(type_summary)
export(type_text)
+export(type_tile)
export(type_violin)
export(type_vline)
importFrom(grDevices,adjustcolor)
@@ -135,6 +137,7 @@ importFrom(stats,qnorm)
importFrom(stats,qt)
importFrom(stats,quantile)
importFrom(stats,reformulate)
+importFrom(stats,sd)
importFrom(stats,setNames)
importFrom(stats,spline)
importFrom(stats,terms)
diff --git a/NEWS.md b/NEWS.md
index 9281cc89..fe7d356e 100644
--- a/NEWS.md
+++ b/NEWS.md
@@ -10,8 +10,14 @@ where the formatting is also better._
#### New plot types
-- `type_hexbin()` (equivalently, `type = "hexbin"`) for hexagonal bin plots, a
- 2D analogue of a histogram. (#667 @grantmcdermott)
+- `type_hexbin()` / `"hexbin"` for hexagonal bin plots, a 2D analogue of a
+ histogram. (#667 @grantmcdermott)
+- `type_tile()` / `"tile"` for tile plots, i.e. a grid of rectangles whose fill
+ encodes a third variable. (#677 @grantmcdermott)
+- `type_heatmap()` / `"heatmap"` builds on `type_tile()`, adding a `scale`
+ argument that scales the fill values *within* each category of one axis. This
+ is analogous to base R's `heatmap()` function, and like the latter it z-scores
+ along the chosen margin by default. (#677 @grantmcdermott)
#### Other new features
@@ -44,6 +50,12 @@ where the formatting is also better._
`"cat"` (console), in any combination; a destination the user has already
labelled is left alone. Shared bandwidths are reported once and named as
joint, individual bandwidths per group. (#287 @haomeng797-ship-it)
+- Themes:
+ - `"heatmap"` provides a dedicated companion theme to the new `type_tile()`
+ and `type_heatmap()` types (see above). The theme removes all axis padding,
+ so that tiles meet the panel edge, and also rotates the tick labels against
+ their respective axes. Colour fills default to the "tealgrn" sequential
+ palette. (#677 @grantmcdermott)
### Bug fixes
diff --git a/R/facet.R b/R/facet.R
index 8843be5d..9e9e22fb 100644
--- a/R/facet.R
+++ b/R/facet.R
@@ -357,10 +357,13 @@ draw_facet_window = function(
)
if (!is.null(xaxb)) args_x$at = xaxb
if (!is.null(yaxb)) args_y$at = yaxb
- # `xlabs` is only non-NULL when a type has placed categorical data on the
- # x-axis, so its presence is the signal to draw labelled ticks.
+ # `xlabs`/`ylabs` are only non-NULL when a type has placed categorical data
+ # on that axis, so their presence is the signal to draw labelled ticks.
+ # The y-side previously listed the eligible types by name, but every type
+ # that populates `ylabs` does so precisely because it has categories to
+ # label, making the extra condition redundant (#665).
type_range_x = !is.null(xlabs)
- type_range_y = !is.null(ylabs) && (type == "p" || (isTRUE(flip) && type %in% c("barplot", "pointrange", "errorbar", "ribbon", "boxplot", "violin")))
+ type_range_y = !is.null(ylabs)
if (type_range_x) {
args_x = modifyList(args_x, list(at = xlabs, labels = names(xlabs)))
}
diff --git a/R/sanitize_type.R b/R/sanitize_type.R
index c2611456..074336ca 100644
--- a/R/sanitize_type.R
+++ b/R/sanitize_type.R
@@ -50,6 +50,7 @@ sanitize_type = function(settings) {
"spline",
"summary",
"text",
+ "tile", "heatmap",
"violin",
"vline"
)
@@ -114,6 +115,8 @@ sanitize_type = function(settings) {
"spline" = type_spline,
"summary" = type_summary,
"text" = type_text,
+ "tile" = type_tile,
+ "heatmap" = type_heatmap,
"violin" = type_violin,
"vline" = type_vline,
type # default case (incl. line-family chars, handled below)
diff --git a/R/tinyplot.R b/R/tinyplot.R
index 68561183..5f7d3b8f 100644
--- a/R/tinyplot.R
+++ b/R/tinyplot.R
@@ -115,23 +115,27 @@
#' - Shapes:
#' - `"area"` / [`type_area()`]: Plots the area under the curve from `y` = 0 to `y` = f(`x`).
#' - `"errorbar"` / [`type_errorbar()`]: Adds error bars to points; requires `ymin` and `ymax`.
+#' - `"jitter"` / [`type_jitter()`]: Jittered points.
#' - `"pointrange"` / [`type_pointrange()`]: Combines points with error bars.
#' - `"polygon"` / [`type_polygon()`]: Draws polygons.
#' - `"polypath"` / [`type_polypath()`]: Draws a path whose vertices are given in `x` and `y`.
#' - `"rect"` / [`type_rect()`]: Draws rectangles; requires `xmin`, `xmax`, `ymin`, and `ymax`.
#' - `"ribbon"` / [`type_ribbon()`]: Creates a filled area between `ymin` and `ymax`.
+#' - `"rug"` / [`type_rug()`]: Adds a rug to an existing plot.
#' - `"segments"` / [`type_segments()`]: Draws line segments between pairs of points.
#' - `"text"` / [`type_text()`]: Add text annotations.
+#' - `"tile"` / [`type_tile()`]: Draws a grid of tiles, with the fill given by `by`.
#' - Visualizations:
#' - `"barplot"` / [`type_barplot()`]: Creates a bar plot.
#' - `"boxplot"` / [`type_boxplot()`]: Creates a box-and-whisker plot.
#' - `"chull"` / [`type_chull()`]: Draws convex hull(s) around grouped points.
#' - `"density"` / [`type_density()`]: Plots the density estimate of a variable.
+#' - `"ellipse"` / [`type_ellipse()`]: Draws confidence ellipse(s) around grouped points.
+#' - `"heatmap"` / [`type_heatmap()`]: Draws a grid of tiles, optionally rescaling the fill along one axis.
+#' - `"hexbin"` / [`type_hexbin()`]: Creates a hexagonal bin plot, a 2D analogue of a histogram.
#' - `"histogram"` / [`type_histogram()`]: Creates a histogram of a single variable.
-#' - `"jitter"` / [`type_jitter()`]: Jittered points.
#' - `"qq"` / [`type_qq()`]: Creates a quantile-quantile plot.
#' - `"ridge"` / [`type_ridge()`]: Creates a ridgeline (aka joy) plot.
-#' - `"rug"` / [`type_rug()`]: Adds a rug to an existing plot.
#' - `"spineplot"` / [`type_spineplot()`]: Creates a spineplot or spinogram.
#' - `"violin"` / [`type_violin()`]: Creates a violin plot.
#' - Models:
diff --git a/R/tinyplot.matrix.R b/R/tinyplot.matrix.R
index b2fc29c1..03af133d 100644
--- a/R/tinyplot.matrix.R
+++ b/R/tinyplot.matrix.R
@@ -13,17 +13,30 @@
#' used as the group (and legend) labels. Single-column matrices are drawn as
#' a simple index plot with no grouping or legend.
#'
+#' The `"tile"` and `"heatmap"` types are an exception, since the matplot
+#' convention makes little sense for them. Instead the matrix is laid out as a
+#' grid---columns along the x-axis, rows along the y-axis---with the matrix
+#' *values* supplied as the fill. Row order is reversed so that the first row
+#' sits at the top, matching how one reads a matrix (cf.
+#' \code{\link[stats]{heatmap}} and \code{\link[graphics]{image}}). Both axis
+#' titles are suppressed, since the dimnames already label the ticks, and so
+#' is the legend, since the fill merely re-encodes the matrix's own values.
+#' Pass an explicit `legend` (or `xlab`/`ylab`) to override either. See
+#' Examples.
+#'
#' @param x an object of class `"matrix"`.
#' @param type plot type passed on to `tinyplot`. Defaults to `"p"` (points).
#' @param legend specification passed on to `tinyplot`. The default is to draw a
-#' legend when the matrix has named columns, and to suppress it otherwise.
+#' legend when the matrix has named columns, and to suppress it otherwise. For
+#' `"tile"` and `"heatmap"` types it is suppressed by default.
#' @param facet specification of `facet` passed on to `tinyplot`. The only
#' accepted non-`NULL` value is the `"by"` convenience string, which facets
#' the plot by matrix column.
#' @param xlab,ylab axis labels passed on to `tinyplot`. `ylab` defaults to the
#' deparsed matrix name. `xlab` defaults to `"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.
+#' x-axis title is suppressed. For `"tile"` and `"heatmap"` types both
+#' titles default to `NA`, since the dimnames label both axes.
#' @param ... further arguments passed to `tinyplot`.
#'
#' @returns No return value, called for the side effect of producing a plot.
@@ -36,10 +49,13 @@
#' tinyplot(VADeaths, type = "b")
#' tinyplot(VADeaths, type = "b", legend = "direct", theme = "socviz")
#' tinyplot(VADeaths, type = "b", legend = FALSE, facet = "by", theme = "socviz")
-#'
+#'
#' # equivalent plot to an example in `?matplot`
#' sines = outer(1:20, 1:4, function(x, y) sin(x / 20 * pi * y))
#' tinyplot(sines, type = "o", pch = "by", lty = "by", col = rainbow(ncol(sines)))
+#'
+#' # `"tile"` + `"heatmap"` types lay the matrix out as a grid instead
+#' tinyplot(VADeaths, type = "heatmap", theme = "heatmap", col = "white")
#'
#' @export
tinyplot.matrix = function(x, type = NULL, legend = NULL, facet = NULL, xlab = NULL, ylab = NULL, ...) {
@@ -50,6 +66,48 @@ tinyplot.matrix = function(x, type = NULL, legend = NULL, facet = NULL, xlab = N
if (is.null(type)) type = "p"
dep_x = deparse1(substitute(x))
dims = dim(x)
+
+ ## 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]))
+ } else {
+ factor(rep(cnms, each = dims[1]), levels = cnms)
+ }
+ ## Reverse the row levels so that row 1 sits at the *top* of the plot,
+ ## matching how one reads a matrix (cf. `heatmap()`, `image()`).
+ yy = if (is.null(rnms)) {
+ factor(rep(seq_len(dims[1]), times = dims[2]),
+ levels = rev(seq_len(dims[1])))
+ } else {
+ factor(rep(rnms, times = dims[2]), levels = rev(rnms))
+ }
+ ## 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(legend)) legend = FALSE
+ return(tinyplot.default(
+ x = xx, y = yy,
+ type = type,
+ by = as.vector(x),
+ facet = facet,
+ legend = legend,
+ xlab = xlab,
+ ylab = ylab,
+ ...
+ ))
+ }
if (dims[2] == 1L) {
bby = NULL
legend = FALSE
diff --git a/R/tinytheme.R b/R/tinytheme.R
index 6840722b..b56ac61f 100644
--- a/R/tinytheme.R
+++ b/R/tinytheme.R
@@ -33,6 +33,7 @@
#' - `"tufte"` (*): floating axes and minimalist plot artifacts in the style of Edward Tufte.
#' - `"float"` (*): builds on `"tufte"` with outward ticks, fewer tick marks, and a "dark" qualitative palette.
#' - `"void"` (*): switches off all axes, titles, legends, etc.
+#' - `"heatmap"` (*): a specialized theme for tile plots and heatmaps (see [`type_tile()`]). Builds off of `"clean2"`, but removes the axis padding so that the tiles meet the panel edge, drops the (redundant) grid lines, rotates the tick labels and removes their tick marks, and defaults to the "tealgrn" sequential palette. Not recommended for non-tile plots.
#' - `"ridge"` (*): a specialized theme for ridge plots (see [`type_ridge()`]). Builds off of `"clean"`, but adds ridge-specific tweaks (e.g. default "Zissou 1" palette for discrete colors, solid horizontal grid lines, and minor adjustments to y-axis labels). Not recommended for non-ridge plots.
#' - `"ridge2"` (*): removes the plot frame (box) from `"ridge"`, but retains the x-axis line. Again, not recommended for non-ridge plots.
#' @param ... Named arguments to override specific theme settings. These
@@ -194,7 +195,7 @@ tinytheme = function(
"clean", "clean2", "bw", "linedraw", "classic",
"minimal", "ipsum", "ipsum2", "dark",
"socviz", "broadsheet", "nber", "web",
- "ridge", "ridge2",
+ "heatmap", "ridge", "ridge2",
"tufte", "float", "void"
),
...,
@@ -225,6 +226,7 @@ tinytheme = function(
"ipsum2" = theme_ipsum2,
"minimal" = theme_minimal,
"nber" = theme_nber,
+ "heatmap" = theme_heatmap,
"ridge" = theme_ridge,
"ridge2" = theme_ridge2,
"socviz" = theme_socviz,
@@ -300,7 +302,7 @@ builtin_themes = c(
"clean", "clean2", "bw", "linedraw", "classic",
"minimal", "ipsum", "ipsum2", "dark",
"socviz", "broadsheet", "nber", "web",
- "ridge", "ridge2",
+ "heatmap", "ridge", "ridge2",
"tufte", "float", "void"
)
@@ -359,6 +361,12 @@ theme_default = list(
side.sub = 1,
tck = NA,
tcl = par("tcl"), # -0.5
+ # `theme_default` doubles as the reset baseline for tinytheme(), so every
+ # parameter that *any* theme sets has to appear here -- otherwise nothing
+ # restores it and the setting leaks into subsequent (incl. base) plots. The
+ # axis styles below are only touched by the "heatmap" theme so far.
+ xaxs = par("xaxs"), # "r"
+ yaxs = par("yaxs"), # "r"
xaxt = "standard",
yaxt = "standard"
)
@@ -534,6 +542,21 @@ theme_dark = modifyList(theme_minimal, list(
# derivatives of clean/clean2
+# Companion theme for type_tile() / type_heatmap(). Tiles are opaque and drawn
+# edge-to-edge, so the usual axis padding leaves them floating inside the panel
+# and the grid is hidden behind them regardless. Long categorical labels are the
+# norm for correlation matrices, hence the rotated, tick-less axes.
+theme_heatmap = modifyList(theme_clean2, list(
+ tinytheme = "heatmap",
+ gap.axis = 0,
+ grid = FALSE,
+ las = 2,
+ palette.sequential = "tealgrn",
+ tcl = 0,
+ xaxs = "i",
+ yaxs = "i"
+))
+
theme_ridge = modifyList(theme_clean, list(
tinytheme = "ridge",
col.default = "black", # keep black ridgelines; Zissou is for gradient fills
diff --git a/R/type_hexbin.R b/R/type_hexbin.R
index 28a5cd06..8f19beec 100644
--- a/R/type_hexbin.R
+++ b/R/type_hexbin.R
@@ -101,12 +101,11 @@
#' # 2) Continuous grouping variable: each cell is coloured by its mean.
#' # Example: Create a long version of the `volcano` dataset, and plot its
#' # elevations onto a gridded terrain map.
-#' volc = local({
-#' v = setNames(stack(as.data.frame(volcano)), c("elevation", "y"))
-#' v$y = as.numeric(gsub("^V", "", v$y))
-#' v$x = seq_len(nrow(volcano))
-#' v
-#' })
+#' volc = data.frame(
+#' x = as.vector(row(volcano)),
+#' y = as.vector(col(volcano)),
+#' elevation = as.vector(volcano)
+#' )
#' tinyplot(
#' y ~ x | elevation, data = volc,
#' type = "hexbin", xbins = 50,
diff --git a/R/type_tile.R b/R/type_tile.R
new file mode 100644
index 00000000..9526a10b
--- /dev/null
+++ b/R/type_tile.R
@@ -0,0 +1,409 @@
+#' Tile and heatmap plot types
+#'
+#' @description Type functions for tile plots, i.e. a grid of rectangles whose
+#' fill colour encodes a third variable. `type_tile()` is the default building
+#' block for these gridded shapes, drawing the values exactly as supplied. It
+#' underpins heatmaps, correlation matrices, calendar plots, confusion
+#' matrices, and similar displays.
+#'
+#' `type_heatmap()` is a specialised case that first rescales the fill values
+#' within each category of one axis. Reach for it when those values are not
+#' already on a common scale.
+#'
+#' @details Tile plots are specified as `z ~ x` with the fill variable passed as
+#' the `by` grouping, i.e. `tinyplot(y ~ x | z, type = "tile")`. The `x` and
+#' `y` variables may be factors, characters, or numerics; the `by` variable
+#' supplies the fill and will typically be numeric, yielding a continuous
+#' colour gradient and colourbar legend. Omitting `by` leaves the tiles
+#' unfilled, since there is nothing for the fill to encode; pass an explicit
+#' `fill` (or `bg`) if you want a uniform colour in that case.
+#'
+#' Unlike the closely-related \code{\link{type_rect}}, which requires explicit
+#' `xmin`/`xmax`/`ymin`/`ymax` bounds, `type_tile()` derives the tile bounds
+#' for you: each tile is centred on its `x`/`y` position and extends
+#' `width/2` and `height/2` in each direction. Categorical axes are converted
+#' to consecutive integer positions and the axis tick labels are taken from
+#' the factor levels automatically.
+#'
+#' Explicit bounds still take precedence. Passing any of `xmin`, `xmax`,
+#' `ymin`, or `ymax` leaves that dimension untouched, which is useful for
+#' irregular or unequal-width tiles (e.g. binned continuous data). Bounds may
+#' be given for one axis while the other is derived.
+#'
+#' Note that tiles are opaque and drawn edge-to-edge, so the default axis
+#' padding and grid lines of most themes are redundant (and the grid is hidden
+#' behind the tiles in any case). We therefore ship a dedicated `"heatmap"`
+#' theme that removes the padding and grid, rotates the tick labels, and
+#' switches to a sequential palette. See [`tinytheme()`] and the Examples.
+#'
+#' `type_heatmap()`'s `scale` argument is the analogue of the `scale` argument
+#' in base R's \code{\link[stats]{heatmap}}, and like the latter it z-scores
+#' along the chosen margin by default. Pass `method = "rescale"` to map each
+#' group onto the unit \[0, 1\] interval instead.
+#'
+#' Either way, note that scaling along a margin necessarily discards the
+#' *relative* spread of each group: a narrow-range column will occupy as much
+#' of the colour ramp as a wide-range one, since both are divided by their own
+#' measure of spread. That is the price of making a matrix of incomparable
+#' units legible; use `scale = "none"` (or `type_tile()`) when preserving
+#' cross-group magnitudes matters more.
+#'
+#' @param width,height Numeric tile dimensions in data units. Both default to
+#' `1`, which produces contiguous tiles on categorical (or unit-spaced
+#' numeric) axes. Values below `1` inset the tiles, leaving gaps between them.
+#' Recycled across tiles, so a vector may be used for variable sizes.
+#' @examples
+#' # It is recommended to use the dedicated "heatmap" theme for tile plots
+#' tinytheme("heatmap")
+#'
+#' #
+#' ## type_tile ----
+#'
+#' # Correlation matrix of the base `attitude` dataset in "long" form.
+#' catt = as.data.frame(as.table(cor(attitude)), responseName = "Correlation")
+#'
+#' tinyplot(Var1 ~ Var2 | Correlation, data = catt, type = "tile")
+#'
+#' # fancier version where we reverse the y-axis (to mimic the usual correlation
+#' # matrix layout), add white borders around each tile, and suppress the legend
+#' # but layer on the values as text
+#' tinyplot(
+#' Var1 ~ Var2 | Correlation, data = catt,
+#' type = "tile",
+#' col = "white",
+#' legend = FALSE,
+#' main = "Correlation matrix of base attitude dataset",
+#' xlab = NA, ylab = NA,
+#' ylim = "rev"
+#' )
+#' tinyplot_add(type = "text", labels = round(catt$Correlation, 2))
+#'
+#' # Pass scaled tile widths and heights through type_tile() for a gridded look
+#' tinyplot(
+#' Var1 ~ Var2 | Correlation, data = catt,
+#' type = type_tile(width = 0.9, height = 0.9)
+#' )
+#'
+#' # It doesn't really work for this example, but you can easily switch to a
+#' # diverging palettes if it makes sense for your data
+#' tinyplot(
+#' Var1 ~ Var2 | Correlation, data = catt,
+#' type = type_tile(width = 0.9, height = 0.9),
+#' palette = "tropic"
+#' )
+#'
+#' # Numeric axes work too, e.g. a (reshaped long) data.frame of volcano heights
+#' volc = data.frame(
+#' x = as.vector(row(volcano)),
+#' y = as.vector(col(volcano)),
+#' elevation = as.vector(volcano)
+#' )
+#' tinyplot(
+#' y ~ x | elevation, data = volc,
+#' type = "tile",
+#' theme = "void", # void theme looks better with this numeric example
+#' xlab = NA, ylab = NA,
+#' main = "Maunga Whau volcano"
+#' )
+#'
+#' #
+#' ## type_heatmap ----
+#'
+#' # Raw data matrices are usually dominated by their largest-magnitude column.
+#' # `type_heatmap()` can rescale within each column to make the rest legible.
+#' mt = as.data.frame(as.table(as.matrix(mtcars)))
+#'
+#' # first, the unscaled version: only `disp` and `hp` are visible
+#' tinyplot(
+#' Var1 ~ Var2 | Freq, data = mt,
+#' type = "heatmap",
+#' xlab = NA, ylab = NA
+#' )
+#'
+#' # and now scaled within each x variable (i.e., column). The default is to
+#' # z-score, matching base R's `heatmap(scale = "column")`.
+#' tinyplot(
+#' Var1 ~ Var2 | Freq, data = mt,
+#' type = type_heatmap(scale = "x"),
+#' xlab = NA, ylab = NA
+#' )
+#'
+#' # `method = "rescale"` maps each column onto [0, 1] instead. This uses the
+#' # colour ramp more fully, at the cost of pinning every column's min and max to
+#' # the same two colours.
+#' tinyplot(
+#' Var1 ~ Var2 | Freq, data = mt,
+#' type = type_heatmap(scale = "x", method = "rescale"),
+#' xlab = NA, ylab = NA
+#' )
+#'
+#' #
+#' ## aside: use tinyplot.matrix directly to avoid reshaping ----
+#'
+#' tinyplot(as.matrix(mtcars), type = type_heatmap(scale = "x"), col = "white")
+#'
+#' ## restore the default theme
+#' tinytheme()
+#'
+#' @seealso \code{\link{type_rect}} for the lower-level rectangle type that
+#' `type_tile()` builds on, and [`tinytheme()`] for the companion `"heatmap"`
+#' theme.
+#'
+#' @export
+type_tile = function(width = 1, height = 1) {
+ assert_numeric(width)
+ assert_numeric(height)
+ out = list(
+ draw = draw_rect(),
+ data = data_tile(width = width, height = height),
+ name = "tile"
+ )
+ class(out) = "tinyplot_type"
+ return(out)
+}
+
+
+#' @rdname type_tile
+#' @param scale Character. Should the `by` (fill) values be scaled *within*
+#' each category of one axis? One of `"none"` (default, i.e. the raw values
+#' are used), `"x"`, or `"y"`. Scaling is what makes a raw matrix legible
+#' when its variables span very different magnitudes: left alone, the
+#' largest-magnitude column monopolises the entire colour ramp. See Examples.
+#'
+#' Note that `"x"` and `"y"` refer to the axes *as written in the formula*,
+#' i.e. before any `flip = TRUE` is applied. We deliberately avoid base R's
+#' `"row"`/`"column"` wording, since a tile's position depends on which
+#' variable the user placed where in the formula, so there is no fixed matrix
+#' orientation to refer to.
+#'
+#' Rescaling is computed independently per facet; pooling across facets would
+#' pin a panel on a different scale to one end of the ramp and lose its
+#' internal structure. Since rescaled values are no longer in the units of the
+#' `by` variable, the legend title is annotated accordingly.
+#' @param method Character. How should the values be rescaled, if `scale` is not
+#' `"none"`? Either `"zscore"` (default) to centre each group and divide by its
+#' standard deviation, or `"rescale"` to map each group onto the unit interval
+#' \[0, 1\]. Ignored when `scale = "none"`.
+#'
+#' `"zscore"` matches base R's \code{\link[stats]{heatmap}} and keeps values
+#' comparable across groups, since `-1` means "one standard deviation below
+#' this group's mean" everywhere. `"rescale"` instead pins every group's
+#' minimum and maximum to the ends of the colour ramp, which uses the palette
+#' more fully but makes the endpoints an artefact of the transform rather than
+#' a feature of the data.
+#'
+#' Groups with no spread---a constant column, or a single tile---would divide
+#' by zero, so they are set to the midpoint of the target range (`0.5` and `0`
+#' respectively) and a warning is emitted.
+#'
+#' @importFrom stats sd
+#' @export
+type_heatmap = function(
+ width = 1,
+ height = 1,
+ scale = c("none", "x", "y"),
+ method = c("zscore", "rescale")) {
+ assert_numeric(width)
+ assert_numeric(height)
+ if (length(scale) > 1L) scale = scale[1L]
+ assert_choice(scale, c("none", "x", "y"))
+ if (length(method) > 1L) method = method[1L]
+ assert_choice(method, c("zscore", "rescale"))
+ out = list(
+ draw = draw_rect(),
+ data = data_tile(
+ width = width, height = height, scale = scale, method = method
+ ),
+ # Deliberately reports "tile": the two types are interchangeable as far as
+ # the rest of the pipeline is concerned, and nothing downstream needs to
+ # tell them apart. Keeps the option of diverging later.
+ name = "tile"
+ )
+ class(out) = "tinyplot_type"
+ return(out)
+}
+
+
+## Rescale `by` within each level of `g`, either to the unit interval
+## (method = "rescale") or as a z-score (method = "zscore"). Both divide by a
+## measure of spread, so a group with no spread (all values identical, or a
+## single observation) would produce NaN. That is much worse than it sounds:
+## `range()` of a vector containing one NaN is NaN, so the draw loop's colour
+## indices all become NA and tiles blank out across the *whole* plot, not just
+## the offending group. Map such groups to the midpoint of the target range
+## instead, and report them back so the caller can warn -- a silently flattened
+## group otherwise reads as a genuine mid-scale value.
+scale_by_group = function(by, g, method = "zscore") {
+ gi = if (is.factor(g)) g else factor(g)
+ mid = if (identical(method, "zscore")) 0 else 0.5
+ flat = character(0)
+ out = unsplit(
+ lapply(split(seq_along(by), gi), function(ix) {
+ v = by[ix]
+ if (identical(method, "zscore")) {
+ s = sd(v, na.rm = TRUE)
+ if (!is.finite(s) || s == 0) {
+ flat[[length(flat) + 1L]] <<- as.character(gi[ix][1L])
+ return(rep.int(mid, length(v)))
+ }
+ return((v - mean(v, na.rm = TRUE)) / s)
+ }
+ # rescale_num()'s default `from` is range(x), which propagates an NA to
+ # every element, so compute the range with na.rm explicitly.
+ rng = range(v, na.rm = TRUE)
+ if (!all(is.finite(rng)) || diff(rng) == 0) {
+ flat[[length(flat) + 1L]] <<- as.character(gi[ix][1L])
+ return(rep.int(mid, length(v)))
+ }
+ rescale_num(v, from = rng, to = c(0, 1))
+ }),
+ gi
+ )
+ attr(out, "flat") = flat
+ out
+}
+
+
+data_tile = function(
+ width = 1, height = 1, scale = "none", method = "zscore") {
+ fun = function(settings, ...) {
+ env2env(
+ settings,
+ environment(),
+ c(
+ "datapoints", "xlabs", "ylabs", "xaxt", "yaxt", "bg", "fill",
+ "null_by", "by", "by_dep", "legend_args"
+ )
+ )
+
+ # Tiles are a filled mark: the `by` variable encodes the *fill*, not the
+ # outline. Default `bg` to the palette so that a bare `type = "tile"` is
+ # filled, matching how the user would otherwise have to spell it out with
+ # `fill = "by"`. An explicit bg/fill still wins.
+ #
+ # Without a `by` variable there is nothing for the fill to encode, so leave
+ # the tiles unfilled (cf. type_rect()) rather than flooding every one with
+ # the same flat colour, which would read as a solid black grid.
+ if (is.null(bg) && is.null(fill) && !isTRUE(null_by)) bg = "by"
+
+ # Optional z-scoring of the fill values within each x (or y) category, cf.
+ # `heatmap(scale=)`. Must happen *before* the factor -> integer conversion
+ # below, which needs the axis variables still as factors to group on. Note
+ # this keys off the axis as written in the formula: flip_datapoints() runs
+ # later in the pipeline, so `flip` does not invert the meaning.
+ if (!identical(scale, "none")) {
+ if (isTRUE(null_by) || !is.numeric(datapoints[["by"]])) {
+ # Nothing numeric to standardize. Also catches `facet = "by"`, which
+ # coerces `by` to a factor upstream in sanitize_facet(). Warn rather
+ # than error: the plot is still perfectly drawable, just unscaled.
+ warning(
+ "`type_tile(scale=)` requires a numeric `by` (fill) variable. ",
+ "Ignoring `scale`.",
+ call. = FALSE
+ )
+ } else {
+ # Group on the axis position *and* the facet, so each panel is scaled
+ # independently. Pooling across panels defeats the purpose: a panel on
+ # a different order of magnitude would pin its whole range to one end
+ # of the ramp and lose all within-panel structure. `datapoints$facet`
+ # is always present (a constant "" when unfaceted).
+ grp = interaction(
+ datapoints[[scale]], datapoints[["facet"]], drop = TRUE
+ )
+ z = scale_by_group(datapoints[["by"]], grp, method = method)
+ flat = attr(z, "flat")
+ if (length(flat) > 0L) {
+ warning(
+ sprintf(
+ paste(
+ "No variation within %d %s of `%s`;",
+ "set to the scale midpoint: %s"
+ ),
+ length(flat), if (length(flat) > 1L) "groups" else "group",
+ scale, paste(flat, collapse = ", ")
+ ),
+ call. = FALSE
+ )
+ }
+ if (anyNA(z)) {
+ warning(
+ "Missing values in `by`; those tiles are left unfilled.",
+ call. = FALSE
+ )
+ }
+ attributes(z) = NULL
+ # Both slots are needed: the tile fills read `datapoints$by`, but the
+ # gradient legend's tick labels come from the bare `by`, so updating
+ # only one would leave the colourbar numbers disagreeing with the
+ # colours (cf. type_hexbin()).
+ datapoints[["by"]] = z
+ by = z
+ # A scaled fill is no longer in the units of the `by` variable, so a
+ # legend still titled e.g. "Freq" would be actively misleading. Note the
+ # formula method has already pre-filled the title with the variable
+ # name, so annotate whatever is there rather than only filling a blank.
+ # The grepl() guard keeps this idempotent under tinyplot_add() replay.
+ sfx = if (identical(method, "zscore")) "(z-score)" else "(rescaled)"
+ ttl = legend_args[["title"]] %||% by_dep
+ if (is.character(ttl) && length(ttl) == 1L && nzchar(ttl) &&
+ !grepl(sfx, ttl, fixed = TRUE)) {
+ legend_args[["title"]] = paste0(ttl, "\n", sfx)
+ }
+ }
+ }
+
+ # A categorical axis carries its own tick labels, so convert to consecutive
+ # integer positions and hand the levels off to the axis machinery. Numeric
+ # axes are already positional and keep their default (computed) ticks.
+ for (ax in c("x", "y")) {
+ v = datapoints[[ax]]
+ if (is.null(v) || !(is.factor(v) || is.character(v))) next
+ if (!is.factor(v)) v = factor(v)
+ labs = seq_along(levels(v))
+ names(labs) = levels(v)
+ datapoints[[ax]] = as.numeric(v)
+ if (ax == "x") {
+ xlabs = xlabs %||% labs
+ # cf. data_barplot(): "l" keeps the labels but drops the tick marks,
+ # which have no meaning for a categorical position.
+ if (identical(xaxt, "s")) xaxt = "l"
+ } else {
+ ylabs = ylabs %||% labs
+ if (identical(yaxt, "s")) yaxt = "l"
+ }
+ }
+
+ # Derive the tile bounds, but never clobber user-supplied ones: an explicit
+ # xmin/xmax (or ymin/ymax) is how irregular or unequally-sized tiles get
+ # specified, so each axis is derived only if *both* of its bounds are absent.
+ if (is.null(datapoints[["xmin"]]) && is.null(datapoints[["xmax"]])) {
+ w = rep_len(width, nrow(datapoints)) / 2
+ datapoints[["xmin"]] = datapoints[["x"]] - w
+ datapoints[["xmax"]] = datapoints[["x"]] + w
+ }
+ if (is.null(datapoints[["ymin"]]) && is.null(datapoints[["ymax"]])) {
+ h = rep_len(height, nrow(datapoints)) / 2
+ datapoints[["ymin"]] = datapoints[["y"]] - h
+ datapoints[["ymax"]] = datapoints[["y"]] + h
+ }
+
+ # Match type_rect()'s legend keys for the discrete case. A numeric `by`
+ # renders a colourbar instead, where these are simply ignored.
+ settings$legend_args[["pch"]] = settings$legend_args[["pch"]] %||% 22
+ settings$legend_args[["pt.cex"]] = settings$legend_args[["pt.cex"]] %||% 3.5
+ settings$legend_args[["pt.lwd"]] = settings$legend_args[["pt.lwd"]] %||% par("lwd")
+ settings$legend_args[["lty"]] = settings$legend_args[["lty"]] %||% 0
+ settings$legend_args[["y.intersp"]] = settings$legend_args[["y.intersp"]] %||% 1.25
+ settings$legend_args[["seg.len"]] = settings$legend_args[["seg.len"]] %||% 1.25
+
+ env2env(
+ environment(),
+ settings,
+ c(
+ "datapoints", "xlabs", "ylabs", "xaxt", "yaxt", "bg", "by",
+ "legend_args"
+ )
+ )
+ }
+ return(fun)
+}
diff --git a/altdoc/pkgdown.yml b/altdoc/pkgdown.yml
index 729e0533..0d2dc353 100644
--- a/altdoc/pkgdown.yml
+++ b/altdoc/pkgdown.yml
@@ -2,7 +2,7 @@ altdoc: 0.7.3
pandoc: 3.10.1
pkgdown: 2.1.3
pkgdown_sha: ~
-last_built: 2026-07-29T21:54:53+0000
+last_built: 2026-08-02T21:07:48+0000
urls:
reference: https://grantmcdermott.com/tinyplot/man
article: https://grantmcdermott.com/tinyplot/vignettes
diff --git a/altdoc/quarto_website.yml b/altdoc/quarto_website.yml
index 90105e92..ef9a346d 100644
--- a/altdoc/quarto_website.yml
+++ b/altdoc/quarto_website.yml
@@ -70,6 +70,8 @@ website:
file: man/type_ribbon.qmd
- text: type_errorbar
file: man/type_errorbar.qmd
+ - text: type_jitter
+ file: man/type_jitter.qmd
- text: type_lines
file: man/type_lines.qmd
- text: type_pointrange
@@ -84,10 +86,14 @@ website:
file: man/type_rect.qmd
- text: type_ribbon
file: man/type_ribbon.qmd
- - text: type_text
- file: man/type_text.qmd
+ - text: type_rug
+ file: man/type_rug.qmd
- text: type_segments
file: man/type_segments.qmd
+ - text: type_text
+ file: man/type_text.qmd
+ - text: type_tile
+ file: man/type_tile.qmd
- section: "Visualizations"
contents:
- text: type_barplot
@@ -96,22 +102,20 @@ website:
file: man/type_boxplot.qmd
- text: type_chull
file: man/type_chull.qmd
- - text: type_ellipse
- file: man/type_ellipse.qmd
- text: type_density
file: man/type_density.qmd
+ - text: type_ellipse
+ file: man/type_ellipse.qmd
+ - text: type_heatmap
+ file: man/type_tile.qmd
- text: type_hexbin
file: man/type_hexbin.qmd
- text: type_histogram
file: man/type_histogram.qmd
- - text: type_jitter
- file: man/type_jitter.qmd
- text: type_qq
file: man/type_qq.qmd
- text: type_ridge
file: man/type_ridge.qmd
- - text: type_rug
- file: man/type_rug.qmd
- text: type_spineplot
file: man/type_spineplot.qmd
- text: type_violin
@@ -120,10 +124,10 @@ website:
contents:
- text: type_glm
file: man/type_glm.qmd
- - text: type_loess
- file: man/type_loess.qmd
- text: type_lm
file: man/type_lm.qmd
+ - text: type_loess
+ file: man/type_loess.qmd
- text: type_spline
file: man/type_spline.qmd
- section: "Functions"
diff --git a/inst/tinytest/_tinysnapshot/heatmap_scale_x.svg b/inst/tinytest/_tinysnapshot/heatmap_scale_x.svg
new file mode 100644
index 00000000..17df5e33
--- /dev/null
+++ b/inst/tinytest/_tinysnapshot/heatmap_scale_x.svg
@@ -0,0 +1,446 @@
+
+
diff --git a/inst/tinytest/_tinysnapshot/heatmap_scale_x_rescale.svg b/inst/tinytest/_tinysnapshot/heatmap_scale_x_rescale.svg
new file mode 100644
index 00000000..9fbe5215
--- /dev/null
+++ b/inst/tinytest/_tinysnapshot/heatmap_scale_x_rescale.svg
@@ -0,0 +1,442 @@
+
+
diff --git a/inst/tinytest/_tinysnapshot/heatmap_scale_x_zscore.svg b/inst/tinytest/_tinysnapshot/heatmap_scale_x_zscore.svg
new file mode 100644
index 00000000..17df5e33
--- /dev/null
+++ b/inst/tinytest/_tinysnapshot/heatmap_scale_x_zscore.svg
@@ -0,0 +1,446 @@
+
+
diff --git a/inst/tinytest/_tinysnapshot/matrix_heatmap.svg b/inst/tinytest/_tinysnapshot/matrix_heatmap.svg
new file mode 100644
index 00000000..932cb3fb
--- /dev/null
+++ b/inst/tinytest/_tinysnapshot/matrix_heatmap.svg
@@ -0,0 +1,67 @@
+
+
diff --git a/inst/tinytest/_tinysnapshot/matrix_heatmap_scale.svg b/inst/tinytest/_tinysnapshot/matrix_heatmap_scale.svg
new file mode 100644
index 00000000..b5a48a24
--- /dev/null
+++ b/inst/tinytest/_tinysnapshot/matrix_heatmap_scale.svg
@@ -0,0 +1,433 @@
+
+
diff --git a/inst/tinytest/_tinysnapshot/tile_basic.svg b/inst/tinytest/_tinysnapshot/tile_basic.svg
new file mode 100644
index 00000000..50c29301
--- /dev/null
+++ b/inst/tinytest/_tinysnapshot/tile_basic.svg
@@ -0,0 +1,124 @@
+
+
diff --git a/inst/tinytest/_tinysnapshot/tile_facet.svg b/inst/tinytest/_tinysnapshot/tile_facet.svg
new file mode 100644
index 00000000..9eb7b7d7
--- /dev/null
+++ b/inst/tinytest/_tinysnapshot/tile_facet.svg
@@ -0,0 +1,101 @@
+
+
diff --git a/inst/tinytest/_tinysnapshot/tile_fancy.svg b/inst/tinytest/_tinysnapshot/tile_fancy.svg
new file mode 100644
index 00000000..4201e3e8
--- /dev/null
+++ b/inst/tinytest/_tinysnapshot/tile_fancy.svg
@@ -0,0 +1,151 @@
+
+
diff --git a/inst/tinytest/_tinysnapshot/tile_numeric_axes.svg b/inst/tinytest/_tinysnapshot/tile_numeric_axes.svg
new file mode 100644
index 00000000..98fec62e
--- /dev/null
+++ b/inst/tinytest/_tinysnapshot/tile_numeric_axes.svg
@@ -0,0 +1,5365 @@
+
+
diff --git a/inst/tinytest/_tinysnapshot/tile_scale_x.svg b/inst/tinytest/_tinysnapshot/tile_scale_x.svg
new file mode 100644
index 00000000..9fbe5215
--- /dev/null
+++ b/inst/tinytest/_tinysnapshot/tile_scale_x.svg
@@ -0,0 +1,442 @@
+
+
diff --git a/inst/tinytest/_tinysnapshot/tinytheme_heatmap.svg b/inst/tinytest/_tinysnapshot/tinytheme_heatmap.svg
new file mode 100644
index 00000000..97cf61e1
--- /dev/null
+++ b/inst/tinytest/_tinysnapshot/tinytheme_heatmap.svg
@@ -0,0 +1,97 @@
+
+
diff --git a/inst/tinytest/_tinysnapshot/tinytheme_single_heatmap.svg b/inst/tinytest/_tinysnapshot/tinytheme_single_heatmap.svg
new file mode 100644
index 00000000..46a6e6fd
--- /dev/null
+++ b/inst/tinytest/_tinysnapshot/tinytheme_single_heatmap.svg
@@ -0,0 +1,83 @@
+
+
diff --git a/inst/tinytest/test-matrix.R b/inst/tinytest/test-matrix.R
index 11f54670..8f3d10a5 100644
--- a/inst/tinytest/test-matrix.R
+++ b/inst/tinytest/test-matrix.R
@@ -15,3 +15,23 @@ expect_snapshot_plot(f, label = "matrix_type_b")
# faceting by column
f = function() tinyplot(VADeaths, type = "o", facet = "by")
expect_snapshot_plot(f, label = "matrix_facet")
+
+
+#
+## tile / heatmap types get a grid layout instead of the matplot convention
+#
+
+f = function() tinyplot(VADeaths, type = "heatmap", theme = "heatmap")
+expect_snapshot_plot(f, label = "matrix_heatmap")
+
+# "tile" takes the same layout, just without the rescaling option
+f = function() tinyplot(VADeaths, type = "tile", theme = "heatmap")
+expect_snapshot_plot(f, label = "matrix_heatmap")
+
+# rescaling within each column, for matrices whose columns are on different
+# scales. This is the motivating case: unscaled, `disp`/`hp` swamp everything.
+f = function() {
+ tinyplot(as.matrix(mtcars), type = type_heatmap(scale = "x"),
+ theme = "heatmap")
+}
+expect_snapshot_plot(f, label = "matrix_heatmap_scale")
diff --git a/inst/tinytest/test-type_tile.R b/inst/tinytest/test-type_tile.R
new file mode 100644
index 00000000..7f9417c7
--- /dev/null
+++ b/inst/tinytest/test-type_tile.R
@@ -0,0 +1,110 @@
+source("helpers.R")
+using("tinysnapshot")
+
+# shared fixture: correlation matrix of the base `attitude` dataset, in the same
+# "long" form used by the type_tile() examples
+catt = as.data.frame(as.table(cor(attitude)), responseName = "Correlation")
+
+# "tile" and "heatmap" are aliases, as are type_tile() and type_heatmap(),
+# so all four spellings must produce an identical plot.
+f = function() {
+ tinyplot(
+ Var1 ~ Var2 | Correlation, data = catt, type = "tile",
+ theme = "heatmap"
+ )
+}
+expect_snapshot_plot(f, label = "tile_basic")
+
+# "heatmap" alias (should be identical to the above)
+f = function() {
+ tinyplot(
+ Var1 ~ Var2 | Correlation, data = catt, type = "heatmap",
+ theme = "heatmap"
+ )
+}
+expect_snapshot_plot(f, label = "tile_basic")
+
+# fancy version, including gridded spacing and added labels
+f = function() {
+ tinyplot(
+ Var1 ~ Var2 | Correlation, data = catt,
+ type = type_tile(width = 0.9, height = 0.9),
+ theme = "heatmap",
+ col = "white",
+ legend = FALSE,
+ main = "Correlation matrix of base attitude dataset",
+ xlab = NA, ylab = NA,
+ ylim = "rev"
+ )
+ tinyplot_add(type = "text", labels = round(catt$Correlation, 2))
+}
+expect_snapshot_plot(f, label = "tile_fancy")
+
+# numeric axes: no factor conversion, ticks stay numeric
+volc = data.frame(
+ x = as.vector(row(volcano)),
+ y = as.vector(col(volcano)),
+ elevation = as.vector(volcano)
+)
+f = function() {
+ tinyplot(
+ y ~ x | elevation, data = volc,
+ type = "tile",
+ theme = "void",
+ xlab = NA, ylab = NA,
+ main = "Maunga Whau volcano"
+ )
+}
+expect_snapshot_plot(f, label = "tile_numeric_axes")
+
+# faceting: categorical tick labels must survive on both axes in every panel
+d = expand.grid(
+ a = factor(c("x", "y", "z")),
+ b = factor(c("p", "q")),
+ g = factor(c("G1", "G2"))
+)
+d$v = seq_len(nrow(d))
+f = function() {
+ tinyplot(b ~ a | v, facet = ~g, data = d, type = "tile", theme = "heatmap")
+}
+expect_snapshot_plot(f, label = "tile_facet")
+
+
+#
+## type_heatmap(): scale/method, cf. base R `heatmap(scale=)`
+#
+
+# A raw data matrix is the motivating case: unscaled, `disp`/`hp` monopolise the
+# colour ramp and the other nine columns are indistinguishable.
+mt = as.data.frame(as.table(as.matrix(mtcars)))
+
+f = function() {
+ tinyplot(
+ Var1 ~ Var2 | Freq, data = mt,
+ type = type_heatmap(scale = "x"),
+ theme = "heatmap",
+ xlab = NA, ylab = NA
+ )
+}
+expect_snapshot_plot(f, label = "heatmap_scale_x")
+
+# `method = "rescale"` is the alternative to the z-score default
+f = function() {
+ tinyplot(
+ Var1 ~ Var2 | Freq, data = mt,
+ type = type_heatmap(scale = "x", method = "rescale"),
+ theme = "heatmap",
+ xlab = NA, ylab = NA
+ )
+}
+expect_snapshot_plot(f, label = "heatmap_scale_x_rescale")
+
+# `scale = "none"` is the default, so a bare type_heatmap() must reproduce the
+# plain type_tile() fixture exactly (asserted against the *same* label)
+f = function() {
+ tinyplot(
+ Var1 ~ Var2 | Correlation, data = catt, type = type_heatmap(scale = "none"),
+ theme = "heatmap"
+ )
+}
+expect_snapshot_plot(f, label = "tile_basic")
diff --git a/man/tinyplot-package.Rd b/man/tinyplot-package.Rd
index 5684b661..1e46fc28 100644
--- a/man/tinyplot-package.Rd
+++ b/man/tinyplot-package.Rd
@@ -28,6 +28,7 @@ Authors:
Other contributors:
\itemize{
\item Etienne Bacher \email{etienne.bacher@protonmail.com} [contributor]
+ \item Miura Meng (\href{https://orcid.org/0009-0004-1522-1997}{ORCID}) [contributor]
}
}
diff --git a/man/tinyplot.Rd b/man/tinyplot.Rd
index 14da3a10..11a792a4 100644
--- a/man/tinyplot.Rd
+++ b/man/tinyplot.Rd
@@ -244,13 +244,16 @@ type of plot desired.
\itemize{
\item \code{"area"} / \code{\link[=type_area]{type_area()}}: Plots the area under the curve from \code{y} = 0 to \code{y} = f(\code{x}).
\item \code{"errorbar"} / \code{\link[=type_errorbar]{type_errorbar()}}: Adds error bars to points; requires \code{ymin} and \code{ymax}.
+\item \code{"jitter"} / \code{\link[=type_jitter]{type_jitter()}}: Jittered points.
\item \code{"pointrange"} / \code{\link[=type_pointrange]{type_pointrange()}}: Combines points with error bars.
\item \code{"polygon"} / \code{\link[=type_polygon]{type_polygon()}}: Draws polygons.
\item \code{"polypath"} / \code{\link[=type_polypath]{type_polypath()}}: Draws a path whose vertices are given in \code{x} and \code{y}.
\item \code{"rect"} / \code{\link[=type_rect]{type_rect()}}: Draws rectangles; requires \code{xmin}, \code{xmax}, \code{ymin}, and \code{ymax}.
\item \code{"ribbon"} / \code{\link[=type_ribbon]{type_ribbon()}}: Creates a filled area between \code{ymin} and \code{ymax}.
+\item \code{"rug"} / \code{\link[=type_rug]{type_rug()}}: Adds a rug to an existing plot.
\item \code{"segments"} / \code{\link[=type_segments]{type_segments()}}: Draws line segments between pairs of points.
\item \code{"text"} / \code{\link[=type_text]{type_text()}}: Add text annotations.
+\item \code{"tile"} / \code{\link[=type_tile]{type_tile()}}: Draws a grid of tiles, with the fill given by \code{by}.
}
\item Visualizations:
\itemize{
@@ -258,11 +261,12 @@ type of plot desired.
\item \code{"boxplot"} / \code{\link[=type_boxplot]{type_boxplot()}}: Creates a box-and-whisker plot.
\item \code{"chull"} / \code{\link[=type_chull]{type_chull()}}: Draws convex hull(s) around grouped points.
\item \code{"density"} / \code{\link[=type_density]{type_density()}}: Plots the density estimate of a variable.
+\item \code{"ellipse"} / \code{\link[=type_ellipse]{type_ellipse()}}: Draws confidence ellipse(s) around grouped points.
+\item \code{"heatmap"} / \code{\link[=type_heatmap]{type_heatmap()}}: Draws a grid of tiles, optionally rescaling the fill along one axis.
+\item \code{"hexbin"} / \code{\link[=type_hexbin]{type_hexbin()}}: Creates a hexagonal bin plot, a 2D analogue of a histogram.
\item \code{"histogram"} / \code{\link[=type_histogram]{type_histogram()}}: Creates a histogram of a single variable.
-\item \code{"jitter"} / \code{\link[=type_jitter]{type_jitter()}}: Jittered points.
\item \code{"qq"} / \code{\link[=type_qq]{type_qq()}}: Creates a quantile-quantile plot.
\item \code{"ridge"} / \code{\link[=type_ridge]{type_ridge()}}: Creates a ridgeline (aka joy) plot.
-\item \code{"rug"} / \code{\link[=type_rug]{type_rug()}}: Adds a rug to an existing plot.
\item \code{"spineplot"} / \code{\link[=type_spineplot]{type_spineplot()}}: Creates a spineplot or spinogram.
\item \code{"violin"} / \code{\link[=type_violin]{type_violin()}}: Creates a violin plot.
}
diff --git a/man/tinyplot.matrix.Rd b/man/tinyplot.matrix.Rd
index 2aae1d0b..db3c311b 100644
--- a/man/tinyplot.matrix.Rd
+++ b/man/tinyplot.matrix.Rd
@@ -20,7 +20,8 @@
\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.}
+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}{specification of \code{facet} passed on to \code{tinyplot}. The only
accepted non-\code{NULL} value is the \code{"by"} convenience string, which facets
@@ -29,7 +30,8 @@ the plot by matrix column.}
\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.}
+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}.}
}
@@ -50,6 +52,17 @@ faceted via \code{facet = "by"}. This mirrors the base R
matrix against the row numbers. If the matrix has column names, these are
used as the group (and legend) labels. Single-column matrices are drawn as
a simple index plot with no grouping or legend.
+
+The \code{"tile"} and \code{"heatmap"} types are an exception, since the matplot
+convention makes little sense for them. Instead the matrix is laid out as a
+grid---columns along the x-axis, rows along the y-axis---with the matrix
+\emph{values} supplied as the fill. Row order is reversed so that the first row
+sits at the top, matching how one reads a matrix (cf.
+\code{\link[stats]{heatmap}} and \code{\link[graphics]{image}}). Both axis
+titles are suppressed, since the dimnames already label the ticks, and so
+is the legend, since the fill merely re-encodes the matrix's own values.
+Pass an explicit \code{legend} (or \code{xlab}/\code{ylab}) to override either. See
+Examples.
}
\examples{
# basic use
@@ -62,6 +75,9 @@ tinyplot(VADeaths, type = "b", legend = FALSE, facet = "by", theme = "socviz")
sines = outer(1:20, 1:4, function(x, y) sin(x / 20 * pi * y))
tinyplot(sines, type = "o", pch = "by", lty = "by", col = rainbow(ncol(sines)))
+# `"tile"` + `"heatmap"` types lay the matrix out as a grid instead
+tinyplot(VADeaths, type = "heatmap", theme = "heatmap", col = "white")
+
}
\seealso{
\code{\link[graphics]{matplot}}
diff --git a/man/tinytheme.Rd b/man/tinytheme.Rd
index 78542b61..cc5261db 100644
--- a/man/tinytheme.Rd
+++ b/man/tinytheme.Rd
@@ -7,7 +7,7 @@
tinytheme(
theme = c("default", "basic", "dynamic", "clean", "clean2", "bw", "linedraw",
"classic", "minimal", "ipsum", "ipsum2", "dark", "socviz", "broadsheet", "nber",
- "web", "ridge", "ridge2", "tufte", "float", "void"),
+ "web", "heatmap", "ridge", "ridge2", "tufte", "float", "void"),
...,
register = NULL
)
@@ -50,6 +50,7 @@ dynamic plots are marked with an asterisk (*) below.
\item \code{"float"} (*): builds on \code{"tufte"} with outward ticks, fewer tick marks, and a "dark" qualitative palette.
}
\item \code{"void"} (*): switches off all axes, titles, legends, etc.
+\item \code{"heatmap"} (*): a specialized theme for tile plots and heatmaps (see \code{\link[=type_tile]{type_tile()}}). Builds off of \code{"clean2"}, but removes the axis padding so that the tiles meet the panel edge, drops the (redundant) grid lines, rotates the tick labels and removes their tick marks, and defaults to the "tealgrn" sequential palette. Not recommended for non-tile plots.
\item \code{"ridge"} (*): a specialized theme for ridge plots (see \code{\link[=type_ridge]{type_ridge()}}). Builds off of \code{"clean"}, but adds ridge-specific tweaks (e.g. default "Zissou 1" palette for discrete colors, solid horizontal grid lines, and minor adjustments to y-axis labels). Not recommended for non-ridge plots.
\itemize{
\item \code{"ridge2"} (*): removes the plot frame (box) from \code{"ridge"}, but retains the x-axis line. Again, not recommended for non-ridge plots.
diff --git a/man/type_hexbin.Rd b/man/type_hexbin.Rd
index e0f028b0..a5f9c463 100644
--- a/man/type_hexbin.Rd
+++ b/man/type_hexbin.Rd
@@ -110,12 +110,11 @@ tinyplot(y ~ x | g, data = dat, type = "hexbin")
# 2) Continuous grouping variable: each cell is coloured by its mean.
# Example: Create a long version of the `volcano` dataset, and plot its
# elevations onto a gridded terrain map.
-volc = local({
- v = setNames(stack(as.data.frame(volcano)), c("elevation", "y"))
- v$y = as.numeric(gsub("^V", "", v$y))
- v$x = seq_len(nrow(volcano))
- v
-})
+volc = data.frame(
+ x = as.vector(row(volcano)),
+ y = as.vector(col(volcano)),
+ elevation = as.vector(volcano)
+)
tinyplot(
y ~ x | elevation, data = volc,
type = "hexbin", xbins = 50,
diff --git a/man/type_tile.Rd b/man/type_tile.Rd
new file mode 100644
index 00000000..80f8b7e6
--- /dev/null
+++ b/man/type_tile.Rd
@@ -0,0 +1,204 @@
+% Generated by roxygen2: do not edit by hand
+% Please edit documentation in R/type_tile.R
+\name{type_tile}
+\alias{type_tile}
+\alias{type_heatmap}
+\title{Tile and heatmap plot types}
+\usage{
+type_tile(width = 1, height = 1)
+
+type_heatmap(
+ width = 1,
+ height = 1,
+ scale = c("none", "x", "y"),
+ method = c("zscore", "rescale")
+)
+}
+\arguments{
+\item{width, height}{Numeric tile dimensions in data units. Both default to
+\code{1}, which produces contiguous tiles on categorical (or unit-spaced
+numeric) axes. Values below \code{1} inset the tiles, leaving gaps between them.
+Recycled across tiles, so a vector may be used for variable sizes.}
+
+\item{scale}{Character. Should the \code{by} (fill) values be scaled \emph{within}
+each category of one axis? One of \code{"none"} (default, i.e. the raw values
+are used), \code{"x"}, or \code{"y"}. Scaling is what makes a raw matrix legible
+when its variables span very different magnitudes: left alone, the
+largest-magnitude column monopolises the entire colour ramp. See Examples.
+
+Note that \code{"x"} and \code{"y"} refer to the axes \emph{as written in the formula},
+i.e. before any \code{flip = TRUE} is applied. We deliberately avoid base R's
+\code{"row"}/\code{"column"} wording, since a tile's position depends on which
+variable the user placed where in the formula, so there is no fixed matrix
+orientation to refer to.
+
+Rescaling is computed independently per facet; pooling across facets would
+pin a panel on a different scale to one end of the ramp and lose its
+internal structure. Since rescaled values are no longer in the units of the
+\code{by} variable, the legend title is annotated accordingly.}
+
+\item{method}{Character. How should the values be rescaled, if \code{scale} is not
+\code{"none"}? Either \code{"zscore"} (default) to centre each group and divide by its
+standard deviation, or \code{"rescale"} to map each group onto the unit interval
+[0, 1]. Ignored when \code{scale = "none"}.
+
+\code{"zscore"} matches base R's \code{\link[stats]{heatmap}} and keeps values
+comparable across groups, since \code{-1} means "one standard deviation below
+this group's mean" everywhere. \code{"rescale"} instead pins every group's
+minimum and maximum to the ends of the colour ramp, which uses the palette
+more fully but makes the endpoints an artefact of the transform rather than
+a feature of the data.
+
+Groups with no spread---a constant column, or a single tile---would divide
+by zero, so they are set to the midpoint of the target range (\code{0.5} and \code{0}
+respectively) and a warning is emitted.}
+}
+\description{
+Type functions for tile plots, i.e. a grid of rectangles whose
+fill colour encodes a third variable. \code{type_tile()} is the default building
+block for these gridded shapes, drawing the values exactly as supplied. It
+underpins heatmaps, correlation matrices, calendar plots, confusion
+matrices, and similar displays.
+
+\code{type_heatmap()} is a specialised case that first rescales the fill values
+within each category of one axis. Reach for it when those values are not
+already on a common scale.
+}
+\details{
+Tile plots are specified as \code{z ~ x} with the fill variable passed as
+the \code{by} grouping, i.e. \code{tinyplot(y ~ x | z, type = "tile")}. The \code{x} and
+\code{y} variables may be factors, characters, or numerics; the \code{by} variable
+supplies the fill and will typically be numeric, yielding a continuous
+colour gradient and colourbar legend. Omitting \code{by} leaves the tiles
+unfilled, since there is nothing for the fill to encode; pass an explicit
+\code{fill} (or \code{bg}) if you want a uniform colour in that case.
+
+Unlike the closely-related \code{\link{type_rect}}, which requires explicit
+\code{xmin}/\code{xmax}/\code{ymin}/\code{ymax} bounds, \code{type_tile()} derives the tile bounds
+for you: each tile is centred on its \code{x}/\code{y} position and extends
+\code{width/2} and \code{height/2} in each direction. Categorical axes are converted
+to consecutive integer positions and the axis tick labels are taken from
+the factor levels automatically.
+
+Explicit bounds still take precedence. Passing any of \code{xmin}, \code{xmax},
+\code{ymin}, or \code{ymax} leaves that dimension untouched, which is useful for
+irregular or unequal-width tiles (e.g. binned continuous data). Bounds may
+be given for one axis while the other is derived.
+
+Note that tiles are opaque and drawn edge-to-edge, so the default axis
+padding and grid lines of most themes are redundant (and the grid is hidden
+behind the tiles in any case). We therefore ship a dedicated \code{"heatmap"}
+theme that removes the padding and grid, rotates the tick labels, and
+switches to a sequential palette. See \code{\link[=tinytheme]{tinytheme()}} and the Examples.
+
+\code{type_heatmap()}'s \code{scale} argument is the analogue of the \code{scale} argument
+in base R's \code{\link[stats]{heatmap}}, and like the latter it z-scores
+along the chosen margin by default. Pass \code{method = "rescale"} to map each
+group onto the unit [0, 1] interval instead.
+
+Either way, note that scaling along a margin necessarily discards the
+\emph{relative} spread of each group: a narrow-range column will occupy as much
+of the colour ramp as a wide-range one, since both are divided by their own
+measure of spread. That is the price of making a matrix of incomparable
+units legible; use \code{scale = "none"} (or \code{type_tile()}) when preserving
+cross-group magnitudes matters more.
+}
+\examples{
+# It is recommended to use the dedicated "heatmap" theme for tile plots
+tinytheme("heatmap")
+
+#
+## type_tile ----
+
+# Correlation matrix of the base `attitude` dataset in "long" form.
+catt = as.data.frame(as.table(cor(attitude)), responseName = "Correlation")
+
+tinyplot(Var1 ~ Var2 | Correlation, data = catt, type = "tile")
+
+# fancier version where we reverse the y-axis (to mimic the usual correlation
+# matrix layout), add white borders around each tile, and suppress the legend
+# but layer on the values as text
+tinyplot(
+ Var1 ~ Var2 | Correlation, data = catt,
+ type = "tile",
+ col = "white",
+ legend = FALSE,
+ main = "Correlation matrix of base attitude dataset",
+ xlab = NA, ylab = NA,
+ ylim = "rev"
+)
+tinyplot_add(type = "text", labels = round(catt$Correlation, 2))
+
+# Pass scaled tile widths and heights through type_tile() for a gridded look
+tinyplot(
+ Var1 ~ Var2 | Correlation, data = catt,
+ type = type_tile(width = 0.9, height = 0.9)
+)
+
+# It doesn't really work for this example, but you can easily switch to a
+# diverging palettes if it makes sense for your data
+tinyplot(
+ Var1 ~ Var2 | Correlation, data = catt,
+ type = type_tile(width = 0.9, height = 0.9),
+ palette = "tropic"
+)
+
+# Numeric axes work too, e.g. a (reshaped long) data.frame of volcano heights
+volc = data.frame(
+ x = as.vector(row(volcano)),
+ y = as.vector(col(volcano)),
+ elevation = as.vector(volcano)
+)
+tinyplot(
+ y ~ x | elevation, data = volc,
+ type = "tile",
+ theme = "void", # void theme looks better with this numeric example
+ xlab = NA, ylab = NA,
+ main = "Maunga Whau volcano"
+)
+
+#
+## type_heatmap ----
+
+# Raw data matrices are usually dominated by their largest-magnitude column.
+# `type_heatmap()` can rescale within each column to make the rest legible.
+mt = as.data.frame(as.table(as.matrix(mtcars)))
+
+# first, the unscaled version: only `disp` and `hp` are visible
+tinyplot(
+ Var1 ~ Var2 | Freq, data = mt,
+ type = "heatmap",
+ xlab = NA, ylab = NA
+)
+
+# and now scaled within each x variable (i.e., column). The default is to
+# z-score, matching base R's `heatmap(scale = "column")`.
+tinyplot(
+ Var1 ~ Var2 | Freq, data = mt,
+ type = type_heatmap(scale = "x"),
+ xlab = NA, ylab = NA
+)
+
+# `method = "rescale"` maps each column onto [0, 1] instead. This uses the
+# colour ramp more fully, at the cost of pinning every column's min and max to
+# the same two colours.
+tinyplot(
+ Var1 ~ Var2 | Freq, data = mt,
+ type = type_heatmap(scale = "x", method = "rescale"),
+ xlab = NA, ylab = NA
+)
+
+#
+## aside: use tinyplot.matrix directly to avoid reshaping ----
+
+tinyplot(as.matrix(mtcars), type = type_heatmap(scale = "x"), col = "white")
+
+## restore the default theme
+tinytheme()
+
+}
+\seealso{
+\code{\link{type_rect}} for the lower-level rectangle type that
+\code{type_tile()} builds on, and \code{\link[=tinytheme]{tinytheme()}} for the companion \code{"heatmap"}
+theme.
+}
diff --git a/vignettes/themes.qmd b/vignettes/themes.qmd
index 3e116e05..f8900c27 100644
--- a/vignettes/themes.qmd
+++ b/vignettes/themes.qmd
@@ -156,12 +156,13 @@ p("void")
```
::: {.callout-note}
-The specialized `"ridge"` and `"ridge2"` themes are only intended for use with
-ridge plot types.
+The specialized `"ridge(2)"` and `"heatmap"` themes are only intended for use
+with their respective types.
:::
```{r}
-p2 = function(theme = "ridge") {
+p2 = function(theme = c("ridge", "ridge2")) {
+ theme = match.arg(theme)
tinyplot(
species ~ body_mass | species,
data = penguins,
@@ -173,11 +174,28 @@ p2 = function(theme = "ridge") {
)
box("outer", lty = 2)
}
-
p2("ridge")
p2("ridge2")
```
+```{r}
+# For tiles/heatmap, better to use a different dataset
+p3 = function(theme = "heatmap") {
+ catt = as.data.frame(as.table(cor(attitude)))
+ tinyplot(
+ Var1 ~ Var2 | Freq,
+ data = catt,
+ type = "tile", # or, "heatmap" (alias)
+ main = paste0('theme = "', theme, '"'),
+ sub = "subtitle",
+ cap = "caption",
+ theme = theme
+ )
+ box("outer", lty = 2)
+}
+p3("heatmap")
+```
+
Please feel free to make suggestions about themes, or contribute new themes by
[opening a Pull Request on Github.](https://github.com/grantmcdermott/tinyplot)
diff --git a/vignettes/types.qmd b/vignettes/types.qmd
index 9cc943ae..c8c28577 100644
--- a/vignettes/types.qmd
+++ b/vignettes/types.qmd
@@ -62,6 +62,7 @@ a convenience string (with default behaviour) or a companion `type_*()` function
|-----------------------|---------------------|----------------------------------------------------------------|------|
| `"area"` | `type_area()` | Plots the area under the curve from `y` = 0 to `y` = f(`x`). | [link](/man/type_ribbon.qmd) |
| `"errorbar"` | `type_errorbar()` | Adds error bars to points; requires `ymin` and `ymax`. | [link](/man/type_errorbar.qmd) |
+| `"jitter"` / `"j"` | `type_jitter()` | Jittered points. | [link](/man/type_jitter.qmd) |
| `"l"` / `"b"` / etc. | `type_lines()` | Draws lines and line-alike (same as base `"l"`, `"b"`, etc.) | [link](/man/type_lines.qmd) |
| `"pointrange"` | `type_pointrange()` | Combines points with error bars. | [link](/man/type_errorbar.qmd) |
| `"p"` | `type_points()` | Draws points (same as base `"p"`). | [link](/man/type_points.qmd) |
@@ -69,8 +70,10 @@ a convenience string (with default behaviour) or a companion `type_*()` function
| `"polypath"` | `type_polypath()` | Draws a path whose vertices are given in `x` and `y`. | [link](/man/type_polypath.qmd) |
| `"rect"` | `type_rect()` | Draws rectangles; requires `xmin`, `xmax`, `ymin`, and `ymax`. | [link](/man/type_rect.qmd) |
| `"ribbon"` | `type_ribbon()` | Creates a filled area between `ymin` and `ymax`. | [link](/man/type_ribbon.qmd) |
+| `"rug"` | `type_rug()` | Adds a rug to an existing plot. | [link](/man/type_rug.qmd) |
| `"segments"` | `type_segments()` | Draws line segments between pairs of points. | [link](/man/type_segments.qmd) |
| `"text"` | `type_text()` | Adds text annotations to a plot. | [link](/man/type_text.qmd) |
+| `"tile"` | `type_tile()` | Draws a grid of tiles; fill given by `by`. | [link](/man/type_tile.qmd) |
#### Visualizations
@@ -81,11 +84,11 @@ a convenience string (with default behaviour) or a companion `type_*()` function
| `"chull"` | `type_chull()` | Draws convex hull(s) around grouped points. | [link](/man/type_chull.qmd) |
| `"density"` | `type_density()` | Plots the density estimate of a variable. | [link](/man/type_density.qmd) |
| `"ellipse"` | `type_ellipse()` | Draws confidence ellipse(s) around grouped points. | [link](/man/type_ellipse.qmd) |
+| `"heatmap"` | `type_heatmap()` | Tiles, optionally rescaled along one axis. | [link](/man/type_tile.qmd) |
+| `"hexbin"` | `type_hexbin()` | Creates a hexagonal bin plot (2D histogram). | [link](/man/type_hexbin.qmd) |
| `"histogram"` / `"hist"` | `type_histogram()` | Creates a histogram of a single variable. | [link](/man/type_histogram.qmd) |
-| `"jitter"` / `"j"` | `type_jitter()` | Jittered points. | [link](/man/type_jitter.qmd) |
| `"qq"` | `type_qq()` | Creates a quantile-quantile plot. | [link](/man/type_qq.qmd) |
| `"ridge"` | `type_ridge()` | Creates a ridgeline (aka joy) plot. | [link](/man/type_ridge.qmd) |
-| `"rug"` | `type_rug()` | Adds a rug to an existing plot. | [link](/man/type_rug.qmd) |
| `"spineplot"` / `"spine"` | `type_spineplot()` | Creates a spine plot or spinogram. | [link](/man/type_spineplot.qmd) |
| `"violin"` | `type_violin()` | Creates a violin plot. | [link](/man/type_violin.qmd) |