A Bioconductor-Native, Composable Heatmap

Introduction

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.

Installation

For a released version, install with BiocManager:

if (!requireNamespace("BiocManager", quietly = TRUE)) {
    install.packages("BiocManager")
}
BiocManager::install("SummarizedHeatmap")

Create a heatmap object

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.

Manual column ordering

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.

Constructing from a SummarizedExperiment

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

Plot components

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)

Compose and customize with patchwork

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 and reclustering

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:

  • Subsetting rows clears the row dendrogram and resets rowOrder() to the natural order of the subset. Column clustering is untouched as long as the columns themselves are unchanged.
  • Subsetting columns clears the column dendrogram and resets colOrder(), while row clustering is untouched as long as the rows are unchanged.
  • Subsetting both dimensions at once clears clustering for both, since both have changed.

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 assay values

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.

Session information

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