--- title: "A Bioconductor-Native, Composable Heatmap" author: "SummarizedHeatmap developers" output: BiocStyle::html_document: toc: true toc_depth: 2 vignette: > %\VignetteIndexEntry{A Bioconductor-Native, Composable Heatmap} %\VignetteEngine{knitr::rmarkdown} %\VignetteEncoding{UTF-8} --- ```{r setup, include=FALSE} knitr::opts_chunk$set( collapse = TRUE, comment = "#>", warning = FALSE, message = FALSE, dev = "png", fig_crop = FALSE, fig.width = 7, fig.height = 5 ) options(bitmapType = "cairo") library(SummarizedHeatmap) ``` # 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](https://bioconductor.org/packages/ComplexHeatmap/) provides a comprehensive system for arranging heatmaps and annotations. The CRAN package [ggheatmapper](https://csgroen.github.io/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: ```{r install, eval=FALSE} 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. ```{r create} 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 ``` 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()`. ```{r state} rowOrder(hm) colOrder(hm) class(rowDendro(hm)) class(colDendro(hm)) ``` 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. ```{r manual-col-order} hm_manual_cols <- SummarizedHeatmap( mat, rowData = row_info, colData = column_info, colOrder = rev(colnames(mat)) ) colOrder(hm_manual_cols) is.null(colDendro(hm_manual_cols)) ``` 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. ```{r from-se-single} se <- SummarizedExperiment::SummarizedExperiment( assays = list(scaled = mat), rowData = row_info, colData = column_info ) hm_from_se <- SummarizedHeatmap(se) hm_from_se SummarizedExperiment::assayNames(hm_from_se) # "scaled", preserved from se ``` 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. ```{r from-se-multi} 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) ``` 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. ```{r from-se-error, error=TRUE} SummarizedHeatmap(se_multi) ``` # 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. ```{r components} 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) class(column_annotation) ``` `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. ```{r complete, height = 9} 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: ```{r manual-col-order-plot} 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. ```{r custom-layout} 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). ```{r free-layout} 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. ```{r extra-plot} 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. ```{r custom-grid} 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`: ```{r subset-rows} hm_rows <- hm[1:5, ] is.null(rowDendro(hm_rows)) rowOrder(hm_rows) identical(colDendro(hm_rows), colDendro(hm)) identical(colOrder(hm_rows), colOrder(hm)) ``` Subsetting columns only, keeping all rows, has the mirror-image effect: the column dendrogram is cleared, and row clustering is preserved: ```{r subset-cols} hm_cols <- hm[, c("sample1", "sample3", "sample5")] is.null(colDendro(hm_cols)) colOrder(hm_cols) identical(rowDendro(hm_cols), rowDendro(hm)) identical(rowOrder(hm_cols), rowOrder(hm)) ``` Subsetting both dimensions at once clears clustering for both, since both row and column membership changed: ```{r subset-both} hm_both <- hm[1:5, c("sample1", "sample3", "sample5")] is.null(rowDendro(hm_both)) is.null(colDendro(hm_both)) ``` Recluster explicitly when new clustering is desired. `clusterRows()` and `clusterCols()` recompute a single dimension; `clusterHeatmap()` recomputes both: ```{r recluster} hm_both <- clusterHeatmap(hm_both) validObject(hm_both) class(rowDendro(hm_both)) class(colDendro(hm_both)) ``` # 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: ```{r assay-replace} hm_new_values <- hm SummarizedExperiment::assay(hm_new_values) <- SummarizedExperiment::assay(hm_new_values) * -1 is.null(rowDendro(hm_new_values)) is.null(colDendro(hm_new_values)) rowOrder(hm_new_values) colOrder(hm_new_values) ``` Call `clusterRows()`, `clusterCols()`, or `clusterHeatmap()` afterward to recompute clustering against the new values. # Session information ```{r session-info} sessionInfo() ```