Heatmaps are often built from a matrix and a collection of separate annotation
vectors. Keeping those pieces synchronized becomes difficult when data are
subsetted, reordered, or reused in a downstream analysis. SummarizedHeatmap
stores the assay, rowData, colData, display order, and any hierarchical
clusterings in one SummarizedExperiment-derived object. It separates that
data representation from rendering: each visual component is a regular
ggplot2 plot, and patchwork handles layout and composition.
This makes the package a natural fit for Bioconductor workflows, where assays
and their feature and sample metadata are already represented by
SummarizedExperiment. It also lets users continue to work with familiar
ggplot2 scales, themes, and geoms after making a heatmap component.
This approach complements other heatmap packages.
ComplexHeatmap provides a
comprehensive system for arranging heatmaps and annotations. The
CRAN package ggheatmapper also uses
ggplot2 and patchwork for tile-based heatmaps. SummarizedHeatmap focuses on a
different boundary: persistent assay, metadata, order, and clustering state
inside a Bioconductor class, with individually accessible ggplot components
for users who want to compose their own layouts.
For a released version, install with BiocManager:
if (!requireNamespace("BiocManager", quietly = TRUE)) {
install.packages("BiocManager")
}
BiocManager::install("SummarizedHeatmap")
The small deterministic example below resembles a normalized gene-expression
assay. Feature and sample annotations are passed using the usual
SummarizedExperiment metadata classes.
set.seed(2026)
mat <- matrix(
rnorm(48),
nrow = 8,
dimnames = list(paste0("gene", seq_len(8)), paste0("sample", seq_len(6)))
)
row_info <- S4Vectors::DataFrame(
feature_class = rep(c("marker", "background"), each = 4),
row.names = rownames(mat)
)
column_info <- S4Vectors::DataFrame(
condition = rep(c("control", "treated"), each = 3),
batch = rep(c("batch 1", "batch 2"), 3),
row.names = colnames(mat)
)
hm <- SummarizedHeatmap(
mat,
rowData = row_info,
colData = column_info
)
hm
#> SummarizedHeatmap: 8 rows x 6 columns
#> row clustering: stored
#> column clustering: stored
Rows and columns are clustered independently using Euclidean distance and
average linkage by default. rowOrder() and colOrder() return integer
indices in display order; the corresponding dendrograms are available through
rowDendro() and colDendro().
rowOrder(hm)
#> [1] 7 2 6 8 4 1 3 5
colOrder(hm)
#> [1] 4 6 2 3 1 5
class(rowDendro(hm))
#> [1] "dendrogram"
class(colDendro(hm))
#> [1] "dendrogram"
Annotations remain ordinary rowData and colData and can be read or
modified through the inherited SummarizedExperiment accessors.
Columns don’t have to be ordered by clustering. Passing colOrder to the
constructor – as column names or integer indices, covering every column
exactly once – fixes the display order directly, leaves the column
dendrogram unset, and takes precedence over clusterCols. This suits columns
with a meaningful order of their own, such as a time course or a predefined
experimental grouping, rather than one derived from assay similarity.
rowOrder accepts the same two forms for manually ordering rows.
hm_manual_cols <- SummarizedHeatmap(
mat,
rowData = row_info,
colData = column_info,
colOrder = rev(colnames(mat))
)
colOrder(hm_manual_cols)
#> [1] 6 5 4 3 2 1
is.null(colDendro(hm_manual_cols))
#> [1] TRUE
Rows are still clustered by default here, since rowOrder/clusterRows
weren’t touched. See Plot components for how this shows up in
plotHeatmap()’s layout.
A SummarizedHeatmap always holds exactly one assay, but the constructor also
accepts an existing SummarizedExperiment directly, which is convenient when
one is already produced upstream (for example, by an RNA-seq quantification
or normalization workflow). When the input has a single assay, it is used
automatically and rowData, colData, and metadata carry over unchanged.
The selected assay’s original name is preserved as well.
se <- SummarizedExperiment::SummarizedExperiment(
assays = list(scaled = mat),
rowData = row_info,
colData = column_info
)
hm_from_se <- SummarizedHeatmap(se)
hm_from_se
#> SummarizedHeatmap: 8 rows x 6 columns
#> row clustering: stored
#> column clustering: stored
SummarizedExperiment::assayNames(hm_from_se) # "scaled", preserved from se
#> [1] "scaled"
When the input has more than one assay, supply assay to select one by name
or by position; the others are dropped from the resulting object.
se_multi <- SummarizedExperiment::SummarizedExperiment(
assays = list(raw = mat, scaled = mat / max(abs(mat))),
rowData = row_info,
colData = column_info
)
hm_scaled <- SummarizedHeatmap(se_multi, assay = "scaled")
SummarizedExperiment::assayNames(hm_scaled)
#> [1] "scaled"
Omitting assay for a multi-assay input, or supplying an unknown name or
out-of-range index, raises an informative error rather than silently picking
an assay.
SummarizedHeatmap(se_multi)
#> Error:
#> ! 'x' has multiple assays; specify 'assay' by name or index
plotHeatmapMain() returns a regular ggplot. Annotation functions create one
ggplot per variable, labelled with its variable name as axis text next to the
tiles; selecting multiple variables returns a patchwork stacking the
independent plots. Dendrogram functions also return ggplots.
main <- plotHeatmapMain(hm) +
ggplot2::labs(fill = "Scaled expression") +
ggplot2::theme(axis.text.x = ggplot2::element_text(angle = 45, hjust = 1))
column_annotation <- plotColData(hm, vars = c("condition", "batch"))
row_annotation <- plotRowData(hm, vars = "feature_class")
column_tree <- plotColDendro(hm)
row_tree <- plotRowDendro(hm)
class(main)
#> [1] "ggplot2::ggplot" "ggplot" "ggplot2::gg" "S7_object"
#> [5] "gg"
class(column_annotation)
#> [1] "patchwork" "ggplot2::ggplot" "ggplot" "ggplot2::gg"
#> [5] "S7_object" "gg"
plotHeatmap() provides a complete starting layout with annotations,
dendrograms, and a shared guide area. Column annotations are stacked vertically;
on the left of the heatmap, the row dendrogram is followed by row annotations,
so the annotations sit beside the heatmap body. The row dendrogram’s leaves
face the heatmap. The component functions remain available when a custom layout
is preferred.
complete <- plotHeatmap(hm)
complete
hm_manual_cols (see Manual column ordering) has no stored column
dendrogram, since its column order was supplied manually rather than
clustered. plotHeatmap() omits that panel entirely – rather than leaving
it blank – while the row dendrogram, which is still clustered, draws as
usual, and the column annotation and heatmap columns simply follow the
manual order:
plotHeatmap(hm_manual_cols)
The components can be arranged with patchwork operators, wrap_plots(), and
plot_layout(). free() releases a panel’s alignment on selected sides, and
guide_area() reserves a location for guides when guides = "collect" is
used.
stacked <- patchwork::wrap_plots(
list(column_tree, main),
ncol = 1, heights = c(1, 5), guides = "collect"
)
stacked
column_annotation is itself a two-panel patchwork (one panel per selected
variable), so combining it directly with main using an operator like /
would flatten both objects’ panel lists together, leaving three panels where
heights = c(1, 5) only accounts for two. Use wrap_plots() with a list
instead: each list element is kept as one area regardless of how many panels
it contains internally, so the annotation strip stays aligned with the
heatmap and its guides are still collected normally.
Each annotation panel also labels its variable as axis text next to the
tiles. Wrapping the annotation in patchwork::free(type = "space") lets that
text occupy space without reserving room for it in the shared grid, so it
packs tightly against the heatmap instead of forcing extra column/row width
(plotHeatmap() does this internally for every annotation strip).
custom <- patchwork::wrap_plots(
list(patchwork::free(column_annotation, type = "space"), main),
ncol = 1, heights = c(1, 5), guides = "collect"
)
custom
Arbitrary ggplots can be combined with heatmap components using the same patchwork operations. Here a small sample-level summary plot sits above the heatmap.
display_samples <- colnames(mat)[colOrder(hm)]
qc <- ggplot2::ggplot(
data.frame(
sample = factor(display_samples, levels = display_samples),
mean = colMeans(mat)[display_samples]
),
ggplot2::aes(x = sample, y = mean)
) +
ggplot2::geom_point() +
ggplot2::labs(x = NULL, y = "Mean\nassay\nvalue") +
ggplot2::theme(axis.text.x = ggplot2::element_text(angle = 45, hjust = 1))
(qc / main) + patchwork::plot_layout(heights = c(1, 5))
Component functions also compose directly with wrap_plots()’s named-argument
form, letting a custom design string place each panel (and guide_area())
anywhere in the grid. palette on plotColData()/plotRowData() maps
annotation levels to specific colours (via scale_fill_manual()); a single
call’s palette applies to every variable it plots.
row_palette <- c(marker = "#8DA0CB", background = "#E5C494")
col_palette <- c(
control = "#66C2A5",
treated = "#FC8D62",
`batch 1` = "#386CB0",
`batch 2` = "#F0027F"
)
pColDendro <- plotColDendro(hm)
pColData <- plotColData(hm, palette = col_palette)
pRowDendro <- plotRowDendro(hm)
pRowData <- patchwork::free(
plotRowData(
hm, vars = "feature_class", side = "top", palette = row_palette
),
type = "space"
)
pMain <- plotHeatmapMain(hm)
design <- "
##AG
##BG
CDEG
"
patchwork::wrap_plots(
A = pColDendro, B = pColData, C = pRowDendro, D = pRowData, E = pMain,
G = patchwork::guide_area(),
design = design,
heights = c(1, 1, 8),
widths = c(1, 1, 8, 2),
guides = "collect"
)
Subsetting follows SummarizedExperiment semantics: i selects rows and j
selects columns. Whether a dimension’s clustering survives subsetting depends
only on whether that dimension’s membership or order changed:
rowOrder() to the
natural order of the subset. Column clustering is untouched as long as the
columns themselves are unchanged.colOrder(),
while row clustering is untouched as long as the rows are unchanged.Subsetting never silently reclusters. Call clusterRows(), clusterCols(),
or clusterHeatmap() afterward to compute new clustering.
Subsetting rows only, keeping all columns, clears the row dendrogram but
leaves the column dendrogram and order exactly as they were in hm:
hm_rows <- hm[1:5, ]
is.null(rowDendro(hm_rows))
#> [1] TRUE
rowOrder(hm_rows)
#> [1] 1 2 3 4 5
identical(colDendro(hm_rows), colDendro(hm))
#> [1] TRUE
identical(colOrder(hm_rows), colOrder(hm))
#> [1] TRUE
Subsetting columns only, keeping all rows, has the mirror-image effect: the column dendrogram is cleared, and row clustering is preserved:
hm_cols <- hm[, c("sample1", "sample3", "sample5")]
is.null(colDendro(hm_cols))
#> [1] TRUE
colOrder(hm_cols)
#> [1] 1 2 3
identical(rowDendro(hm_cols), rowDendro(hm))
#> [1] TRUE
identical(rowOrder(hm_cols), rowOrder(hm))
#> [1] TRUE
Subsetting both dimensions at once clears clustering for both, since both row and column membership changed:
hm_both <- hm[1:5, c("sample1", "sample3", "sample5")]
is.null(rowDendro(hm_both))
#> [1] TRUE
is.null(colDendro(hm_both))
#> [1] TRUE
Recluster explicitly when new clustering is desired. clusterRows() and
clusterCols() recompute a single dimension; clusterHeatmap() recomputes
both:
hm_both <- clusterHeatmap(hm_both)
validObject(hm_both)
#> [1] TRUE
class(rowDendro(hm_both))
#> [1] "dendrogram"
class(colDendro(hm_both))
#> [1] "dendrogram"
Replacing the assay (assay(hm) <- ... or assays(hm) <- ...) applies the
same invalidation rule as subsetting, since a stored dendrogram and its
implied order are only valid for the assay values they were computed from.
Whenever a dendrogram is currently stored for an axis, replacing the assay
clears it and resets that axis’s order to natural order; axes with no stored
dendrogram (e.g. a manually-supplied order) are left untouched. Assay
replacement never reclusters automatically:
hm_new_values <- hm
SummarizedExperiment::assay(hm_new_values) <-
SummarizedExperiment::assay(hm_new_values) * -1
is.null(rowDendro(hm_new_values))
#> [1] TRUE
is.null(colDendro(hm_new_values))
#> [1] TRUE
rowOrder(hm_new_values)
#> [1] 1 2 3 4 5 6 7 8
colOrder(hm_new_values)
#> [1] 1 2 3 4 5 6
Call clusterRows(), clusterCols(), or clusterHeatmap() afterward to
recompute clustering against the new values.
sessionInfo()
#> R version 4.6.1 (2026-06-24)
#> Platform: x86_64-pc-linux-gnu
#> Running under: Ubuntu 24.04.5 LTS
#>
#> Matrix products: default
#> BLAS: /home/biocbuild/bbs-3.24-bioc/R/lib/libRblas.so
#> LAPACK: /usr/lib/x86_64-linux-gnu/lapack/liblapack.so.3.12.0 LAPACK version 3.12.0
#>
#> locale:
#> [1] LC_CTYPE=en_US.UTF-8 LC_NUMERIC=C
#> [3] LC_TIME=en_GB LC_COLLATE=C
#> [5] LC_MONETARY=en_US.UTF-8 LC_MESSAGES=en_US.UTF-8
#> [7] LC_PAPER=en_US.UTF-8 LC_NAME=C
#> [9] LC_ADDRESS=C LC_TELEPHONE=C
#> [11] LC_MEASUREMENT=en_US.UTF-8 LC_IDENTIFICATION=C
#>
#> time zone: America/New_York
#> tzcode source: system (glibc)
#>
#> attached base packages:
#> [1] stats graphics grDevices utils datasets methods base
#>
#> other attached packages:
#> [1] SummarizedHeatmap_0.99.0 BiocStyle_2.41.0
#>
#> loaded via a namespace (and not attached):
#> [1] sass_0.4.10 generics_0.1.4
#> [3] SparseArray_1.13.4 lattice_0.23-1
#> [5] digest_0.6.39 magrittr_2.0.5
#> [7] evaluate_1.0.5 grid_4.6.1
#> [9] RColorBrewer_1.1-3 bookdown_0.48
#> [11] fastmap_1.2.0 jsonlite_2.0.0
#> [13] Matrix_1.7-6 tinytex_0.61
#> [15] BiocManager_1.30.27 ggdendro_0.2.0
#> [17] scales_1.4.0 jquerylib_0.1.4
#> [19] abind_1.4-8 cli_3.6.6
#> [21] rlang_1.3.0 XVector_0.53.0
#> [23] Biobase_2.73.2 withr_3.0.3
#> [25] cachem_1.1.0 DelayedArray_0.39.8
#> [27] yaml_2.3.12 otel_0.2.0
#> [29] S4Arrays_1.13.2 tools_4.6.1
#> [31] dplyr_1.2.1 ggplot2_4.0.3
#> [33] SummarizedExperiment_1.43.0 BiocGenerics_0.59.12
#> [35] vctrs_0.7.3 R6_2.6.1
#> [37] magick_2.9.1 matrixStats_1.5.0
#> [39] stats4_4.6.1 lifecycle_1.0.5
#> [41] Seqinfo_1.3.2 S4Vectors_0.51.10
#> [43] IRanges_2.47.5 MASS_7.3-66
#> [45] pkgconfig_2.0.3 pillar_1.11.1
#> [47] bslib_0.12.0 gtable_0.3.6
#> [49] Rcpp_1.1.2 glue_1.8.1
#> [51] tidyselect_1.2.1 tibble_3.3.1
#> [53] xfun_0.61 GenomicRanges_1.65.4
#> [55] MatrixGenerics_1.25.0 knitr_1.52
#> [57] dichromat_2.0-1 farver_2.1.2
#> [59] patchwork_1.3.2 htmltools_0.5.9
#> [61] labeling_0.4.3 rmarkdown_2.32
#> [63] compiler_4.6.1 S7_0.2.2