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:
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: storedRows 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] TRUERows 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.
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.
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:
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"
)
stackedcolumn_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"
)
customArbitrary 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] TRUESubsetting 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] TRUESubsetting 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] TRUERecluster explicitly when new clustering is desired.
clusterRows() and clusterCols() recompute a
single dimension; clusterHeatmap() recomputes both:
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 6Call 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 26.04.1 LTS
#>
#> Matrix products: default
#> BLAS: /usr/lib/x86_64-linux-gnu/openblas-pthread/libblas.so.3
#> LAPACK: /usr/lib/x86_64-linux-gnu/openblas-pthread/libopenblasp-r0.3.32.so; LAPACK version 3.12.0
#>
#> locale:
#> [1] LC_CTYPE=en_US.UTF-8 LC_NUMERIC=C
#> [3] LC_TIME=en_US.UTF-8 LC_COLLATE=en_US.UTF-8
#> [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: Etc/UTC
#> 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 evaluate_1.0.5
#> [7] grid_4.6.1 RColorBrewer_1.1-3
#> [9] fastmap_1.2.0 jsonlite_2.0.0
#> [11] Matrix_1.7-6 BiocManager_1.30.27
#> [13] ggdendro_0.2.0 scales_1.4.0
#> [15] jquerylib_0.1.4 abind_1.4-8
#> [17] cli_3.6.6 rlang_1.3.0
#> [19] XVector_0.53.0 Biobase_2.73.2
#> [21] withr_3.0.3 cachem_1.1.0
#> [23] DelayedArray_0.39.8 yaml_2.3.12
#> [25] otel_0.2.0 S4Arrays_1.13.2
#> [27] tools_4.6.1 ggplot2_4.0.3
#> [29] SummarizedExperiment_1.43.0 BiocGenerics_0.59.12
#> [31] buildtools_1.0.0 vctrs_0.7.3
#> [33] R6_2.6.1 matrixStats_1.5.0
#> [35] stats4_4.6.1 lifecycle_1.0.5
#> [37] Seqinfo_1.3.2 S4Vectors_0.51.10
#> [39] IRanges_2.47.5 MASS_7.3-66
#> [41] bslib_0.12.0 gtable_0.3.6
#> [43] glue_1.8.1 xfun_0.61
#> [45] GenomicRanges_1.65.4 sys_3.4.3
#> [47] MatrixGenerics_1.25.0 knitr_1.52
#> [49] farver_2.1.2 htmltools_0.5.9
#> [51] patchwork_1.3.2 labeling_0.4.3
#> [53] rmarkdown_2.32 maketools_1.3.2
#> [55] compiler_4.6.1 S7_0.2.2