Nothing
#' Atomic linked heatmap (internal)
#'
#' @description
#' Draws two heatmaps side-by-side with spline link lines connecting matching
#' rows across the left and right heatmaps. This is the core implementation
#' layer — it takes a **single** data frame and a full set of left/right
#' parameters, delegates to \code{\link{HeatmapAtomic}} (with
#' \code{return_ht = TRUE}) to obtain prepared \code{ComplexHeatmap::Heatmap}
#' objects, extracts exact dimension information, and then assembles
#' everything into a composite \code{grid} layout.
#'
#' @section Architecture:
#' \enumerate{
#' \item \strong{Parameter resolution} — extra arguments passed via
#' \code{...} are split into left-specific and right-specific groups
#' by prefix (\code{left_} vs \code{right_}). Each group is merged
#' with the shared defaults using \code{\%||\%} fallback chains,
#' giving the caller full per-side override capability.
#' \item \strong{Cell dimension pre-computation} — the internal helper
#' \code{.get_dim_pre()} calculates exact cell width and height
#' (in \code{"inches"} units) from \code{cell_type},
#' \code{aspect.ratio}, \code{base_size}, and the unique row/column
#' counts (including split groups). These are passed as explicit
#' \code{width} / \code{height} arguments to
#' \code{ComplexHeatmap::Heatmap()} so that cells have a guaranteed
#' physical size rather than using \code{null} units.
#' \item \strong{Delegation to \code{HeatmapAtomic}} — both the left and
#' right heatmaps are created by calling
#' \code{do_call(HeatmapAtomic, ...)} with \code{return_ht = TRUE}.
#' This returns the prepared \code{Heatmap} object (with
#' \code{cell_w}/\code{cell_h} attributes) without drawing it.
#' \item \strong{Dimension extraction} — exact component heights and widths
#' are read from the prepared \code{Heatmap} objects via
#' \code{ComplexHeatmap:::component_height()} and
#' \code{ComplexHeatmap:::component_width()}. These 9-element vectors
#' break down every part of the heatmap (title, dendrogram, names,
#' annotations, body) so the composite layout can be sized precisely.
#' \item \strong{Link table construction} — rows in the left and right
#' heatmaps are matched by grouping the input data on the row
#' identifier columns (plus optional \code{rows_split_by}). Each
#' link's position is mapped to the ordered row index in its
#' respective heatmap (respecting clustering order).
#' \item \strong{Legend collection} — legends from both heatmaps are
#' collected, packed via \code{ComplexHeatmap::packLegend()}, and
#' placed according to \code{legend.position} (left, right, top, or
#' bottom).
#' \item \strong{Composite drawing} — a \code{grid.layout} is built
#' dynamically based on the legend position. Each heatmap is drawn
#' inside a centered, exact-size viewport so
#' \code{ComplexHeatmap::draw()} sizes the body to the pre-computed
#' row/column counts. Link lines are drawn as \code{grid.xspline()}
#' curves in a dedicated gap column between the two heatmaps.
#' \item \strong{Dimension attributes} — the final \code{patchwork}-wrapped
#' grob carries \code{height} and \code{width} attributes (in inches)
#' for consistent rendering in downstream layouts.
#' }
#'
#' @param data A data frame in long format. Each row represents one
#' observation; columns specify row/column membership for both left and
#' right heatmaps as well as the values to encode as color.
#' @param left_values_by,right_values_by Column name whose values determine
#' the fill color of cells in the left / right heatmap. These are the
#' primary data columns visualized by the colour scale.
#' @param left_rows_by,right_rows_by Column name that defines the rows of
#' the left / right heatmap. Each unique value becomes one row.
#' @param left_columns_by,right_columns_by Column name that defines the
#' columns of the left / right heatmap. Each unique value becomes one
#' column.
#' @param left_columns_split_by,right_columns_split_by Optional column name
#' to split the columns of the left / right heatmap into groups (passed to
#' \code{ComplexHeatmap::Heatmap} as \code{column_split}).
#' @param left_pie_group_by,right_pie_group_by When \code{cell_type = "pie"},
#' column(s) to group values by within each pie cell for the left / right
#' heatmap. Multiple columns are concatenated.
#' @param rows_split_by Optional column name to split the rows of **both**
#' heatmaps into groups (passed as \code{row_split}). When provided, row
#' names in the link table are prefixed with the split level to
#' disambiguate rows across splits.
#' @param values_fill A value used to fill missing cells in the matrix
#' (passed to \code{HeatmapAtomic}). Default is \code{NA} (cells with no
#' data are left empty).
#'
#' @param palette A character string naming a palette (see
#' \code{\link{show_palettes}}) or a character vector of colours for the
#' main heatmap colour scale. Default \code{"RdBu"}. Applied to both
#' heatmaps unless overridden per-side via \code{...}.
#' @param palcolor A custom colour vector that overrides \code{palette} for
#' the main heatmap colour scale. Applied to both heatmaps unless
#' overridden per-side.
#' @param palreverse Logical; if \code{TRUE}, reverse the palette direction.
#'
#' @param pie_size_name Legend title for the pie size when
#' \code{cell_type = "pie"}.
#' @param pie_size A numeric value or function returning the pie radius.
#' When a function, it receives the count of groups in the pie and should
#' return a radius.
#' @param pie_values A function or string (convertible via
#' \code{\link[base]{match.arg}}) to compute the value represented by each
#' pie slice. Default \code{"length"} counts observations per group.
#' @param pie_palette,pie_palcolor Palette and custom colours for pie slice
#' fill colours.
#'
#' @param bars_sample Number of observations sampled per cell when
#' \code{cell_type = "bars"}. Default 100.
#'
#' @param label A function to compute text labels when
#' \code{cell_type = "label"} (or \code{"label+mark"}). Receives the
#' aggregated value for a cell and optionally row/column indices and names.
#' See \code{\link{HeatmapAtomic}} for the full dispatch contract.
#' @param label_size Default point size for label text (used as fallback
#' when the \code{label} function does not return a \code{size} field).
#' @param label_color Default colour for label text (used as fallback when
#' the \code{label} function does not return a \code{color} field).
#' @param label_name Legend title for the label colour scale.
#'
#' @param mark A function to compute mark symbols when
#' \code{cell_type = "mark"} (or \code{"label+mark"}). Same dispatch
#' contract as \code{label}. See \code{\link{HeatmapAtomic}} for supported
#' mark types.
#' @param mark_color Default mark colour (fallback).
#' @param mark_size Default mark stroke width in pt (fallback).
#' @param mark_name Legend title for the mark colour scale.
#'
#' @param violin_fill A character vector of colours to use as fill for
#' violin plots when \code{cell_type = "violin"}. If \code{NULL}, the
#' annotation colour is used.
#' @param boxplot_fill A character vector of colours to use as fill for
#' boxplots when \code{cell_type = "boxplot"}. If \code{NULL}, the
#' annotation colour is used.
#' @param dot_size Dot size when \code{cell_type = "dot"}. Can be a
#' numeric value or a function.
#' @param dot_size_name Legend title for the dot size.
#'
#' @param legend_items A named numeric vector specifying custom legend
#' entries for the main colour scale. Names become the displayed labels.
#' @param legend_discrete Logical; if \code{TRUE}, treat the main colour
#' scale as discrete.
#' @param legend.position A character string specifying where to place the
#' combined legend: \code{"right"} (default), \code{"left"}, \code{"top"},
#' \code{"bottom"}, or \code{"none"}.
#' @param legend.direction Legend stacking direction:
#' \code{"vertical"} (default) or \code{"horizontal"}.
#'
#' @param lower_quantile,upper_quantile Quantiles used for clipping the
#' colour scale when \code{lower_cutoff} / \code{upper_cutoff} are
#' \code{NULL}. Defaults are 0 and 0.99 respectively.
#' @param lower_cutoff,upper_cutoff Explicit cutoffs for the colour scale.
#' Values outside the range are clamped (winsorized). Override
#' \code{lower_quantile} / \code{upper_quantile} when set.
#'
#' @param add_bg Logical; if \code{TRUE}, add a background fill behind
#' non-tile cell types. Not used for \code{cell_type = "tile"} or
#' \code{"bars"}.
#' @param bg_alpha Numeric in \eqn{[0, 1]} for background transparency.
#' @param keep_na,keep_empty Passed through to \code{HeatmapAtomic}.
#' See \code{\link{common_args}} for details.
#'
#' @param add_reticle Logical; if \code{TRUE}, draw a reticle (crosshair
#' pattern) over the heatmap.
#' @param reticle_color Colour for the reticle lines.
#'
#' @param left_cluster_rows,right_cluster_rows Logical controlling whether
#' rows are clustered in the left / right heatmap. \code{NULL} (default)
#' lets \code{HeatmapAtomic} decide based on
#' \code{rows_orderby}/\code{cluster_rows}.
#' @param cluster_columns Logical; cluster columns in both heatmaps.
#' \code{NULL} lets \code{HeatmapAtomic} decide.
#' @param show_row_names,show_column_names Logical; show row/column names.
#' @param border Logical; draw a border around each heatmap. Default
#' \code{TRUE}.
#' @param title A character string for the overall plot title. A function
#' can be used to generate a dynamic title from the default.
#' Note that, `left_title` and `right_title` are used to set the title for each heatmap,
#' and `title` is used to set the overall title for the combined plot.
#' @param title_params A list of parameters passed to \code{grid::grid.text()} to control the title appearance.
#' Default is \code{list(gp = gpar(fontsize = 14, fontface = "bold"))}.
#' @param column_title,row_title Character title displayed above the columns
#' / beside the rows of each heatmap.
#' @param na_col Colour used for \code{NA} cells. Default \code{"grey85"}.
#' @param column_names_side Side for column names. Default
#' \code{"bottom"}.
#'
#' @param column_annotation A structured list specifying column annotations.
#' See \code{\link{HeatmapAtomic}} for the full specification.
#' @param column_annotation_side \strong{Deprecated}: use
#' \code{column_annotation} with the \code{side} sub-key instead.
#' @param column_annotation_palette \strong{Deprecated}: use
#' \code{column_annotation} with the \code{palette} sub-key instead.
#' @param column_annotation_palcolor \strong{Deprecated}: use
#' \code{column_annotation} with the \code{palcolor} sub-key instead.
#' @param column_annotation_type \strong{Deprecated}: use
#' \code{column_annotation} with the \code{type} sub-key instead.
#' @param column_annotation_params \strong{Deprecated}: use
#' \code{column_annotation} with the \code{params} sub-key instead.
#' @param column_annotation_agg \strong{Deprecated}: use
#' \code{column_annotation} with the \code{agg} sub-key instead.
#'
#' @param row_annotation A structured list specifying row annotations.
#' See \code{\link{HeatmapAtomic}} for the full specification.
#' @param row_annotation_side \strong{Deprecated}: use
#' \code{row_annotation} with the \code{side} sub-key instead.
#' @param row_annotation_palette \strong{Deprecated}: use
#' \code{row_annotation} with the \code{palette} sub-key instead.
#' @param row_annotation_palcolor \strong{Deprecated}: use
#' \code{row_annotation} with the \code{palcolor} sub-key instead.
#' @param row_annotation_type \strong{Deprecated}: use
#' \code{row_annotation} with the \code{type} sub-key instead.
#' @param row_annotation_params \strong{Deprecated}: use
#' \code{row_annotation} with the \code{params} sub-key instead.
#' @param row_annotation_agg \strong{Deprecated}: use
#' \code{row_annotation} with the \code{agg} sub-key instead.
#'
#' @param links_span Width (in inches) of the gap column between the two
#' heatmaps where link curves are drawn. Default 0.5.
#' @param link_width_by Optional column name in \code{data} whose values
#' determine the stroke width of each link line (e.g. interaction
#' strength). Values are min-max scaled to \eqn{[0, 1]} and multiplied by
#' \code{link_width_scale}.
#' You can also pass a numeric value to use a constant width for all links.
#' @param link_width_scale Numeric scaling factor applied to the normalised
#' link intensity values to produce final line widths (\code{lwd}).
#' Default 5.
#' @param link_color Colour of the link spline curves. Default
#' \code{"grey30"}.
#' @param link_alpha Alpha transparency of link curves in \eqn{[0, 1]}.
#' Default 0.8.
#'
#' @param alpha Alpha transparency for heatmap cells in \eqn{[0, 1]}.
#' @param seed Random seed for reproducibility. Default 8525.
#' @param padding Padding around the heatmap in CSS order (top, right,
#' bottom, left). Supports 1–4 values. Default 15 (mm).
#' @param base_size A positive numeric scalar used as a scaling factor for
#' the overall heatmap size. Default 1 (no scaling). Values > 1 enlarge
#' all cell dimensions proportionally.
#' @param aspect.ratio Height-to-width ratio of a single heatmap cell.
#' When \code{NULL} (default), sensible defaults are chosen per
#' \code{cell_type} (e.g. 1 for tiles, 0.5 for bars, 2 for violins).
#' @param draw_opts A named list of additional arguments passed to
#' \code{\link[ComplexHeatmap]{draw,HeatmapList-method}}. Internally
#' managed arguments (\code{padding}, \code{show_heatmap_legend}, etc.)
#' take precedence.
#' @param layer_fun_callback A function to add custom graphical layers on
#' top of each heatmap cell. Receives \code{j}, \code{i}, \code{x},
#' \code{y}, \code{w}, \code{h}, \code{fill}, \code{sr}, \code{sc}.
#' See \code{\link[ComplexHeatmap]{Heatmap}} for details.
#' @param cell_type The type of cell to render. One of \code{"tile"}
#' (default), \code{"bars"}, \code{"label"}, \code{"mark"},
#' \code{"label+mark"} (or \code{"mark+label"}), \code{"dot"},
#' \code{"violin"}, \code{"boxplot"}, \code{"pie"}. Different cell types
#' use different \code{cell_fun} / \code{layer_fun} implementations.
#' @param cell_agg A function to aggregate values within each cell when
#' \code{cell_type = "tile"} or \code{"label"}. Default is
#' \code{\link[base]{mean}}.
#' @param ... Additional arguments passed to
#' \code{\link[ComplexHeatmap]{Heatmap}}. Arguments prefixed with
#' \code{left_} or \code{right_} are routed to the corresponding heatmap
#' (the prefix is stripped before passing). Unprefixed arguments are
#' passed to both heatmaps but can be overridden by a prefixed version.
#' Notable examples:
#' \itemize{
#' \item \code{left_name}, \code{right_name} — legend titles for the
#' left / right colour scales.
#' \item \code{left_row_gap}, \code{right_row_gap} — per-side row gaps.
#' \item \code{left_column_gap}, \code{right_column_gap} — per-side
#' column gaps.
#' \item \code{row_names_max_width}, \code{column_names_max_height} —
#' passed through to \code{ComplexHeatmap::Heatmap}.
#' }
#'
#' @return A \code{patchwork}-wrapped grob (class \code{wrap_plots}) with
#' \code{height} and \code{width} attributes (in inches). The original
#' \code{data} is stored in \code{p$data}.
#' @keywords internal
#' @importFrom grid pushViewport popViewport grid.layout grid.xspline
#' @importFrom grid unit convertUnit grid.grabExpr grid.newpage
LinkedHeatmapAtomic <- function(
data,
left_values_by,
right_values_by,
left_rows_by,
right_rows_by,
left_columns_by,
right_columns_by,
left_columns_split_by = NULL,
right_columns_split_by = NULL,
left_pie_group_by = NULL,
right_pie_group_by = NULL,
rows_split_by = NULL,
values_fill = NA,
# palettes
palette = "RdBu",
palcolor = NULL,
palreverse = FALSE,
# cell_type: pies
pie_size_name = "size",
pie_size = NULL,
pie_values = "length",
pie_palette = "Spectral",
pie_palcolor = NULL,
# cell_type: bars
bars_sample = 100,
# cell_type: label
label = identity,
label_size = 10,
label_color = "black",
label_name = "label",
# cell_type: mark
mark = identity,
mark_color = "black",
mark_size = 1,
mark_name = "mark",
# cell_type: violin
violin_fill = NULL,
# cell_type: boxplot
boxplot_fill = NULL,
# cell_type: dot
dot_size = 8,
dot_size_name = "size",
# legend
legend_items = NULL,
legend_discrete = FALSE,
legend.position = "right",
legend.direction = "vertical",
# values
lower_quantile = 0,
upper_quantile = 0.99,
lower_cutoff = NULL,
upper_cutoff = NULL,
# bg
add_bg = FALSE,
bg_alpha = 0.5,
keep_na = FALSE,
keep_empty = FALSE,
# reticle
add_reticle = FALSE,
reticle_color = "grey",
left_cluster_rows = NULL,
right_cluster_rows = NULL,
cluster_columns = NULL,
show_row_names = NULL,
show_column_names = NULL,
border = TRUE,
title = NULL,
title_params = NULL,
column_title = NULL,
row_title = NULL,
na_col = "grey85",
column_names_side = "bottom",
row_annotation = NULL,
row_annotation_side = NULL,
row_annotation_palette = NULL,
row_annotation_palcolor = NULL,
row_annotation_type = NULL,
row_annotation_params = NULL,
row_annotation_agg = NULL,
column_annotation = NULL,
column_annotation_side = NULL,
column_annotation_palette = NULL,
column_annotation_palcolor = NULL,
column_annotation_type = NULL,
column_annotation_params = NULL,
column_annotation_agg = NULL,
links_span = 0.5,
link_width_by = NULL,
link_width_scale = 5,
link_color = "grey30",
link_alpha = 0.8,
alpha = 1,
seed = 8525,
padding = 15,
base_size = 1,
aspect.ratio = NULL,
draw_opts = list(),
# cell customization
layer_fun_callback = NULL,
cell_type = c(
"tile",
"bars",
"label",
"mark",
"label+mark",
"mark+label",
"dot",
"violin",
"boxplot",
"pie"
),
cell_agg = NULL,
...
) {
set.seed(seed)
rest_args <- list(...)
# The arguments in rest_args that don't begin with "right_" prefix
left_args <- rest_args[!grepl("^right_", names(rest_args))]
# Replace all left_ prefix and then remove the duplicate arguments that have
# been passed to left_args
# The duplicated ones to remove are the ones without `left_` prefix
# For example, `left_row_names_side` and `row_names_side`, the one
# to be removed is `row_names_side`
left_names_with_prefix <- names(left_args)[grepl(
"^left_",
names(left_args)
)]
left_names_without_prefix <- gsub("^left_", "", left_names_with_prefix)
left_args <- left_args[!names(left_args) %in% left_names_without_prefix]
names(left_args) <- gsub("^left_", "", names(left_args))
right_args <- rest_args[!grepl("^left_", names(rest_args))]
right_names_with_prefix <- names(right_args)[grepl(
"^right_",
names(right_args)
)]
right_names_without_prefix <- gsub("^right_", "", right_names_with_prefix)
right_args <- right_args[!names(right_args) %in% right_names_without_prefix]
names(right_args) <- gsub("^right_", "", names(right_args))
# ── Resolve left/right parameters ──
left_args$data <- data
left_args$values_by <- left_values_by
left_args$values_fill <- left_args$values_fill %||% values_fill
left_args$rows_by <- left_rows_by
left_args$rows_split_by <- rows_split_by
left_args$columns_by <- left_columns_by
left_args$columns_split_by <- left_columns_split_by
left_args$palette <- left_args$palette %||% palette
left_args$palcolor <- left_args$palcolor %||% palcolor
left_args$palreverse <- left_args$palreverse %||% palreverse
left_args$pie_size_name <- left_args$pie_size_name %||% pie_size_name
left_args$pie_size <- left_args$pie_size %||% pie_size
left_args$pie_values <- left_args$pie_values %||% pie_values
left_args$pie_group_by <- left_pie_group_by
left_args$pie_palette <- left_args$pie_palette %||% pie_palette
left_args$pie_palcolor <- left_args$pie_palcolor %||% pie_palcolor
left_args$bars_sample <- left_args$bars_sample %||% bars_sample
left_args$label <- left_args$label %||% label
left_args$label_size <- left_args$label_size %||% label_size
left_args$label_color <- left_args$label_color %||% label_color
left_args$label_name <- left_args$label_name %||% label_name
left_args$mark <- left_args$mark %||% mark
left_args$mark_color <- left_args$mark_color %||% mark_color
left_args$mark_size <- left_args$mark_size %||% mark_size
left_args$mark_name <- left_args$mark_name %||% mark_name
left_args$violin_fill <- left_args$violin_fill %||% violin_fill
left_args$boxplot_fill <- left_args$boxplot_fill %||% boxplot_fill
left_args$dot_size <- left_args$dot_size %||% dot_size
left_args$dot_size_name <- left_args$dot_size_name %||% dot_size_name
left_args$legend_items <- left_args$legend_items %||% legend_items
left_args$legend_discrete <- left_args$legend_discrete %||% legend_discrete
left_args$legend.position <- "right"
left_args$legend.direction <- left_args$legend.direction %||%
legend.direction
left_args$lower_quantile <- left_args$lower_quantile %||% lower_quantile
left_args$upper_quantile <- left_args$upper_quantile %||% upper_quantile
left_args$lower_cutoff <- left_args$lower_cutoff %||% lower_cutoff
left_args$upper_cutoff <- left_args$upper_cutoff %||% upper_cutoff
left_args$add_bg <- left_args$add_bg %||% add_bg
left_args$bg_alpha <- left_args$bg_alpha %||% bg_alpha
left_args$add_reticle <- left_args$add_reticle %||% add_reticle
left_args$reticle_color <- left_args$reticle_color %||% reticle_color
left_args$cluster_columns <- left_args$cluster_columns %||% cluster_columns
left_args$cluster_rows <- left_cluster_rows
left_args$show_row_names <- left_args$show_row_names %||% show_row_names
left_args$show_column_names <- left_args$show_column_names %||%
show_column_names
left_args$border <- left_args$border %||% border
left_args$column_title <- left_args$column_title %||% column_title
left_args$row_title <- left_args$row_title %||% row_title
left_args$na_col <- left_args$na_col %||% na_col
left_args$column_names_side <- left_args$column_names_side %||%
column_names_side
left_args$row_annotation <- left_args[["row_annotation"]] %||%
row_annotation
left_args$row_annotation_side <- left_args$row_annotation_side %||%
row_annotation_side
left_args$row_annotation_palette <- left_args$row_annotation_palette %||%
row_annotation_palette
left_args$row_annotation_palcolor <- left_args$row_annotation_palcolor %||%
row_annotation_palcolor
left_args$row_annotation_type <- left_args$row_annotation_type %||%
row_annotation_type
left_args$row_annotation_params <- left_args$row_annotation_params %||%
row_annotation_params
left_args$row_annotation_agg <- left_args$row_annotation_agg %||%
row_annotation_agg
left_args$column_annotation <- left_args[["column_annotation"]] %||%
column_annotation
left_args$column_annotation_side <- left_args$column_annotation_side %||%
column_annotation_side
left_args$column_annotation_palette <- left_args$column_annotation_palette %||%
column_annotation_palette
left_args$column_annotation_palcolor <- left_args$column_annotation_palcolor %||%
column_annotation_palcolor
left_args$column_annotation_type <- left_args$column_annotation_type %||%
column_annotation_type
left_args$column_annotation_params <- left_args$column_annotation_params %||%
column_annotation_params
left_args$column_annotation_agg <- left_args$column_annotation_agg %||%
column_annotation_agg
# flip
left_args$alpha <- left_args$alpha %||% alpha
left_args$padding <- left_args$padding %||% padding
left_args$base_size <- left_args$base_size %||% base_size
left_args$aspect.ratio <- left_args$aspect.ratio %||% aspect.ratio
left_args$draw_opts <- left_args$draw_opts %||% draw_opts
left_args$layer_fun_callback <- left_args$layer_fun_callback %||%
layer_fun_callback
left_args$cell_type <- left_args$cell_type %||% cell_type
left_args$cell_agg <- left_args$cell_agg %||% cell_agg
left_args$return_ht <- TRUE
right_args$data <- data
right_args$values_by <- right_values_by
right_args$values_fill <- right_args$values_fill %||% values_fill
right_args$rows_by <- right_rows_by
right_args$rows_split_by <- rows_split_by
right_args$columns_by <- right_columns_by
right_args$columns_split_by <- right_columns_split_by
right_args$palette <- right_args$palette %||% palette
right_args$palcolor <- right_args$palcolor %||% palcolor
right_args$palreverse <- right_args$palreverse %||% palreverse
right_args$pie_size_name <- right_args$pie_size_name %||% pie_size_name
right_args$pie_size <- right_args$pie_size %||% pie_size
right_args$pie_values <- right_args$pie_values %||% pie_values
right_args$pie_group_by <- right_pie_group_by
right_args$pie_palette <- right_args$pie_palette %||% pie_palette
right_args$pie_palcolor <- right_args$pie_palcolor %||% pie_palcolor
right_args$bars_sample <- right_args$bars_sample %||% bars_sample
right_args$label <- right_args$label %||% label
right_args$label_size <- right_args$label_size %||% label_size
right_args$label_color <- right_args$label_color %||% label_color
right_args$label_name <- right_args$label_name %||% label_name
right_args$mark <- right_args$mark %||% mark
right_args$mark_color <- right_args$mark_color %||% mark_color
right_args$mark_size <- right_args$mark_size %||% mark_size
right_args$mark_name <- right_args$mark_name %||% mark_name
right_args$violin_fill <- right_args$violin_fill %||% violin_fill
right_args$boxplot_fill <- right_args$boxplot_fill %||% boxplot_fill
right_args$dot_size <- right_args$dot_size %||% dot_size
right_args$dot_size_name <- right_args$dot_size_name %||% dot_size_name
right_args$legend_items <- right_args$legend_items %||% legend_items
right_args$legend_discrete <- right_args$legend_discrete %||%
legend_discrete
right_args$legend.position <- "right"
right_args$legend.direction <- right_args$legend.direction %||%
legend.direction
right_args$lower_quantile <- right_args$lower_quantile %||% lower_quantile
right_args$upper_quantile <- right_args$upper_quantile %||% upper_quantile
right_args$lower_cutoff <- right_args$lower_cutoff %||% lower_cutoff
right_args$upper_cutoff <- right_args$upper_cutoff %||% upper_cutoff
right_args$add_bg <- right_args$add_bg %||% add_bg
right_args$bg_alpha <- right_args$bg_alpha %||% bg_alpha
right_args$add_reticle <- right_args$add_reticle %||% add_reticle
right_args$reticle_color <- right_args$reticle_color %||% reticle_color
right_args$cluster_columns <- right_args$cluster_columns %||%
cluster_columns
right_args$cluster_rows <- right_cluster_rows
right_args$show_row_names <- right_args$show_row_names %||% show_row_names
right_args$show_column_names <- right_args$show_column_names %||%
show_column_names
right_args$border <- right_args$border %||% border
right_args$column_title <- right_args$column_title %||% column_title
right_args$row_title <- right_args$row_title %||% row_title
right_args$na_col <- right_args$na_col %||% na_col
right_args$column_names_side <- right_args$column_names_side %||%
column_names_side
right_args$row_annotation <- right_args[["row_annotation"]] %||%
row_annotation
right_args$row_annotation_side <- right_args$row_annotation_side %||%
row_annotation_side
right_args$row_annotation_palette <- right_args$row_annotation_palette %||%
row_annotation_palette
right_args$row_annotation_palcolor <- right_args$row_annotation_palcolor %||%
row_annotation_palcolor
right_args$row_annotation_type <- right_args$row_annotation_type %||%
row_annotation_type
right_args$row_annotation_params <- right_args$row_annotation_params %||%
row_annotation_params
right_args$row_annotation_agg <- right_args$row_annotation_agg %||%
row_annotation_agg
right_args$column_annotation <- right_args[["column_annotation"]] %||%
column_annotation
right_args$column_annotation_side <- right_args$column_annotation_side %||%
column_annotation_side
right_args$column_annotation_palette <- right_args$column_annotation_palette %||%
column_annotation_palette
right_args$column_annotation_palcolor <- right_args$column_annotation_palcolor %||%
column_annotation_palcolor
right_args$column_annotation_type <- right_args$column_annotation_type %||%
column_annotation_type
right_args$column_annotation_params <- right_args$column_annotation_params %||%
column_annotation_params
right_args$column_annotation_agg <- right_args$column_annotation_agg %||%
column_annotation_agg
# flip
right_args$alpha <- right_args$alpha %||% alpha
right_args$padding <- right_args$padding %||% padding
right_args$base_size <- right_args$base_size %||% base_size
right_args$aspect.ratio <- right_args$aspect.ratio %||% aspect.ratio
right_args$draw_opts <- right_args$draw_opts %||% draw_opts
right_args$layer_fun_callback <- right_args$layer_fun_callback %||%
layer_fun_callback
right_args$cell_type <- right_args$cell_type %||% cell_type
right_args$cell_agg <- right_args$cell_agg %||% cell_agg
right_args$return_ht <- TRUE
# ── Pre-compute cell dimensions (same formula as HeatmapAtomic) ──
# Pass explicit width/height to guarantee exact cell sizing.
# Without this, ComplexHeatmap uses null units and cells fill
# whatever space is available in the viewport.
.get_dim_pre <- function(args) {
ct <- sub("mark+label", "label+mark", args$cell_type, fixed = TRUE)
cell_w <- switch(
ct,
violin = 0.5,
boxplot = 0.5,
pie = 0.5,
bars = 0.35,
label = 0.6,
mark = 0.25,
`label+mark` = 0.6,
0.25
)
aspect_default <- switch(
ct,
violin = 2,
boxplot = 2,
pie = 1,
bars = 0.5,
label = 0.6,
mark = 1,
`label+mark` = 0.6,
1
)
bs <- args$base_size %||% base_size %||% 1
ar <- args$aspect.ratio %||% aspect_default
cell_w_pre <- cell_w * bs
cell_h_pre <- cell_w_pre * ar
if (!is.null(args$rows_split_by)) {
n_row_splits <- length(unique(args$data[[args$rows_split_by]]))
n_rows <- args$data[,
c(args$rows_by, args$rows_split_by),
drop = FALSE
] %>%
dplyr::n_distinct()
} else {
n_row_splits <- 1
n_rows <- args$data[[args$rows_by]] %>% dplyr::n_distinct()
}
if (!is.null(args$columns_split_by)) {
n_col_splits <- length(unique(args$data[[args$columns_split_by]]))
n_cols <- args$data[,
c(args$columns_by, args$columns_split_by),
drop = FALSE
] %>%
dplyr::n_distinct()
} else {
n_col_splits <- 1
n_cols <- args$data[[args$columns_by]] %>% dplyr::n_distinct()
}
row_gap <- args$row_gap %||%
convertUnit(unit(1, "mm"), "inches", valueOnly = TRUE)
height <- unit(
n_rows * cell_h_pre + (n_row_splits - 1) * row_gap,
"inches"
)
column_gap <- args$column_gap %||%
convertUnit(unit(1, "mm"), "inches", valueOnly = TRUE)
width <- unit(
n_cols * cell_w_pre + (n_col_splits - 1) * column_gap,
"inches"
)
list(width = width, height = height)
}
left_dim_pre <- .get_dim_pre(left_args)
right_dim_pre <- .get_dim_pre(right_args)
left_args$width <- left_dim_pre$width
left_args$height <- left_dim_pre$height
right_args$width <- right_dim_pre$width
right_args$height <- right_dim_pre$height
left_ht <- do_call(HeatmapAtomic, left_args)
right_ht <- do_call(HeatmapAtomic, right_args)
n_left_rows <- nrow(left_ht@matrix)
n_left_cols <- ncol(left_ht@matrix)
n_right_rows <- nrow(right_ht@matrix)
n_right_cols <- ncol(right_ht@matrix)
# Get the order of the rows in the left heatmap after clustering (if any)
left_row_order <- left_ht@row_order
right_row_order <- right_ht@row_order
# ── Extract exact dimensions from ComplexHeatmap prepared objects ──
# component_height() returns a 9-element list (all internal gaps included):
# [1] column_title_top [2] column_dend_top [3] column_names_top
# [4] column_anno_top [5] heatmap_body [6] column_anno_bot
# [7] column_names_bot [8] column_dend_bot [9] column_title_bot
# component_width() returns a 9-element list:
# [1] row_title_left [2] row_dend_left [3] row_names_left
# [4] row_anno_left [5] heatmap_body [6] row_anno_right
# [7] row_names_right [8] row_dend_right [9] row_title_right
.ch_to_in <- function(comp_list) {
vapply(
comp_list,
function(u) {
if (is.null(u)) {
return(0)
}
convertUnit(u, "inches", valueOnly = TRUE)
},
numeric(1)
)
}
component_height <- utils::getFromNamespace(
"component_height",
"ComplexHeatmap"
)
component_width <- utils::getFromNamespace(
"component_width",
"ComplexHeatmap"
)
left_ch <- .ch_to_in(component_height(left_ht))
right_ch <- .ch_to_in(component_height(right_ht))
left_cw <- .ch_to_in(component_width(left_ht))
right_cw <- .ch_to_in(component_width(right_ht))
# Body dimensions (component [5] is the body)
left_body_h <- left_ch[5]
right_body_h <- right_ch[5]
left_body_w <- left_cw[5]
right_body_w <- right_cw[5]
# body_top_offset = sum of all components ABOVE the body [1:4]
body_top_offset_left <- sum(left_ch[1:4])
body_top_offset_right <- sum(right_ch[1:4])
# below_h = sum of all components BELOW the body [6:9]
left_below_h <- sum(left_ch[6:9])
right_below_h <- sum(right_ch[6:9])
# ── Estimate draw-time column_title heights ──
# column_title passed to ComplexHeatmap::draw() is a LIST-level title
# that sits ABOVE the components already captured by component_height().
# We must add its height to body_top_offset & total_h so body NPC
# positions remain correct.
.est_title_h <- function(title_text) {
if (is.null(title_text) || identical(title_text, "")) return(0)
gp <- ComplexHeatmap::ht_opt("heatmap_column_title_gp") %||%
gpar(fontsize = 14, fontface = "bold")
txt_h <- grid::convertHeight(
grid::grobHeight(grid::textGrob(title_text, gp = gp)),
"inches", valueOnly = TRUE
)
# ~2.5mm above + below is the internal padding ComplexHeatmap
# applies to column titles (no separate ht_opt key for it).
pad_h <- grid::convertUnit(unit(5, "mm"), "inches", valueOnly = TRUE)
txt_h + pad_h
}
left_title_h <- .est_title_h(left_args$title)
right_title_h <- .est_title_h(right_args$title)
body_top_offset_left <- body_top_offset_left + left_title_h
body_top_offset_right <- body_top_offset_right + right_title_h
# Total heatmap dimensions
left_total_w <- sum(left_cw)
left_total_h <- sum(left_ch) + left_title_h
right_total_w <- sum(right_cw)
right_total_h <- sum(right_ch) + right_title_h
total_w <- left_total_w + links_span + right_total_w
total_h <- max(left_total_h, right_total_h)
# ── Legend flag (needed later) ──
legend_gap <- 0.15
show_legend <- !identical(legend.position, "none")
# ── Build link table ──
left_row_names_ordered <- rownames(left_ht@matrix)[left_row_order]
right_row_names_ordered <- rownames(right_ht@matrix)[right_row_order]
link_table <- data %>%
dplyr::group_by(
!!!syms(unique(c(left_rows_by, right_rows_by, rows_split_by)))
)
if (is.character(link_width_by)) {
link_table <- link_table %>%
dplyr::summarise(
!!sym(link_width_by) := mean(
!!sym(link_width_by),
na.rm = TRUE
),
.groups = "drop"
)
} else {
link_width_by <- link_width_by %||% 1
link_table <- link_table %>%
dplyr::summarise(.link_width = link_width_by, .groups = "drop")
link_width_by <- ".link_width"
}
if (!is.null(rows_split_by)) {
link_table <- link_table %>%
dplyr::mutate(
left_rows = paste0(
!!sym(rows_split_by),
" // ",
!!sym(left_rows_by)
),
right_rows = paste0(
!!sym(rows_split_by),
" // ",
!!sym(right_rows_by)
)
)
} else {
link_table <- link_table %>%
dplyr::mutate(
left_rows = !!sym(left_rows_by),
right_rows = !!sym(right_rows_by)
)
}
link_table <- link_table %>%
mutate(
pos_left = match(
as.character(!!sym("left_rows")),
left_row_names_ordered
),
pos_right = match(
as.character(!!sym("right_rows")),
right_row_names_ordered
)
) %>%
filter(!is.na(.data$pos_left), !is.na(.data$pos_right))
raw_intensity <- link_table[[link_width_by]]
if (max(raw_intensity, na.rm = TRUE) > min(raw_intensity, na.rm = TRUE)) {
link_table$intensity <- (raw_intensity -
min(raw_intensity, na.rm = TRUE)) /
(max(raw_intensity, na.rm = TRUE) -
min(raw_intensity, na.rm = TRUE))
} else {
link_table$intensity <- raw_intensity
}
link_table$intensity[is.na(link_table$intensity)] <- 0
# ── Collect legends (before link position computation so legend
# dimensions can be factored into plot_h for top/bottom) ──
combined_legend <- NULL
legend_w <- 0
legend_h <- 0
if (show_legend) {
legends <- c(attr(left_ht, "legends"), attr(right_ht, "legends"))
if (length(legends) > 0) {
combined_legend <- ComplexHeatmap::packLegend(
list = legends,
direction = legend.direction,
gap = unit(4, "mm")
)
# Estimate legend width from label text widths.
# Discrete legends have @labels slot; continuous (colorRamp2)
# legends do not — use fixed estimate for those.
label_widths <- vapply(
legends,
function(lgd) {
if (
methods::.hasSlot(lgd, "labels") &&
is.character(lgd@labels) &&
length(lgd@labels) > 0
) {
convertUnit(
ComplexHeatmap::max_text_width(lgd@labels),
"inches",
valueOnly = TRUE
)
} else {
0.6 # continuous legend: fixed label width
}
},
numeric(1)
)
max_label_w <- max(label_widths, 0.5)
title_w <- max(
vapply(
legends,
function(lgd) {
if (
methods::.hasSlot(lgd, "title") &&
is.character(lgd@title) &&
nchar(lgd@title) > 0
) {
convertUnit(
ComplexHeatmap::max_text_width(lgd@title),
"inches",
valueOnly = TRUE
)
} else {
0
}
},
numeric(1)
),
0
)
legend_w <- max(max_label_w, title_w) + 0.6
# Estimate legend height:
# ~0.7 in per legend (title + colour bar + labels + gaps)
# When vertical they stack; when horizontal single row.
n_legends <- length(legends)
mm_gap_in <- convertUnit(unit(4, "mm"), "inches", valueOnly = TRUE)
if (legend.direction == "vertical") {
legend_h <- n_legends * 0.7 + (n_legends - 1) * mm_gap_in
} else {
legend_h <- 0.7
}
}
}
# ── Plot dimensions (heatmap body + legend + title) ──
# Resolve title graphical parameters and height
if (!is.null(title)) {
title_params <- title_params %||% list()
title_params$label <- title
title_params$x <- title_params$x %||% 0.5
if (is.numeric(title_params$x)) {
title_params$x <- unit(title_params$x, "npc")
}
title_params$y <- title_params$y %||% 0.5
if (is.numeric(title_params$y)) {
title_params$y <- unit(title_params$y, "npc")
}
title_params$gp <- title_params$gp %||% gpar()
title_params$gp$fontsize <- title_params$gp$fontsize %||% 14
title_params$gp$fontface <- title_params$gp$fontface %||% "bold"
title_h <- 0.5 # inches reserved for title
} else {
title_h <- 0
}
plot_w <- total_w
plot_h <- total_h + title_h
if (show_legend) {
plot_w <- total_w + legend_gap + legend_w
plot_h <- total_h + legend_gap + legend_h + title_h
}
# ── Link position computation ──
# NPC coords use total_h (heatmap-only height) because each heatmap
# viewport receives that exact height from the grid layout.
# Each heatmap is drawn inside a centered viewport of its exact native
# size, so the body top position includes the centering offset:
# body_top_px = (total_h - heatmap_total_h) / 2 + body_top_offset
left_center_offset <- (total_h - left_total_h) / 2
right_center_offset <- (total_h - right_total_h) / 2
left_body_top_npc <- 1 -
(left_center_offset + body_top_offset_left) / total_h
left_body_bot_npc <- 1 -
(left_center_offset + body_top_offset_left + left_body_h) / total_h
left_body_range <- left_body_top_npc - left_body_bot_npc
right_body_top_npc <- 1 -
(right_center_offset + body_top_offset_right) / total_h
right_body_bot_npc <- 1 -
(right_center_offset + body_top_offset_right + right_body_h) / total_h
right_body_range <- right_body_top_npc - right_body_bot_npc
compute_y <- function(pos, n_rows, top_npc, range_npc) {
top_npc - (pos - 0.5) / n_rows * range_npc
}
# ── Draw composite ──
# Build grid layout dynamically based on legend.position.
# Heatmaps always occupy one row of 3 columns (left | gap | right).
# The legend gets an extra column (left/right) or an extra row (top/bottom).
# When an overall title is provided, a title row is added at the top.
pos_left <- legend.position == "left"
pos_right <- legend.position == "right"
pos_top <- legend.position == "top"
pos_bottom <- legend.position == "bottom"
n_hm_cols <- 3L
hm_col_widths <- unit(c(left_total_w, links_span, right_total_w), "inches")
hm_col_left <- 1L
hm_col_gap <- 2L
hm_col_right <- 3L
if (show_legend && (pos_left || pos_right)) {
n_cols <- 4L
if (pos_left) {
col_widths <- unit(
c(
legend_w + legend_gap,
left_total_w,
links_span,
right_total_w
),
"inches"
)
lg_col <- 1L
hm_col_left <- 2L
hm_col_gap <- 3L
hm_col_right <- 4L
} else {
# right
col_widths <- unit(
c(
left_total_w,
links_span,
right_total_w,
legend_w + legend_gap
),
"inches"
)
lg_col <- 4L
}
n_rows <- 1L
row_heights <- unit(total_h, "inches")
hm_row <- 1L
lg_row <- 1L
} else if (show_legend && (pos_top || pos_bottom)) {
n_cols <- 3L
col_widths <- hm_col_widths
n_rows <- 2L
if (pos_top) {
row_heights <- unit(c(legend_h + legend_gap, total_h), "inches")
lg_row <- 1L
hm_row <- 2L
} else {
# bottom
row_heights <- unit(c(total_h, legend_h + legend_gap), "inches")
hm_row <- 1L
lg_row <- 2L
}
} else {
n_cols <- 3L
col_widths <- hm_col_widths
n_rows <- 1L
row_heights <- unit(total_h, "inches")
hm_row <- 1L
}
# Adjust layout for overall title (added as a top row)
if (!is.null(title)) {
n_rows <- n_rows + 1L
row_heights <- unit.c(unit(title_h, "inches"), row_heights)
hm_row <- hm_row + 1L
if (show_legend && (pos_left || pos_right || pos_top || pos_bottom)) {
lg_row <- lg_row + 1L
}
}
p <- grid.grabExpr({
grid.newpage()
pushViewport(viewport(
layout = grid.layout(
n_rows,
n_cols,
widths = col_widths,
heights = row_heights
)
))
# Overall title
if (!is.null(title)) {
pushViewport(viewport(
layout.pos.row = 1,
layout.pos.col = 1:n_cols
))
# TODO: a better way to control title placement and alignment
do_call(grid.text, title_params)
popViewport()
}
# Left heatmap — centered exact-size viewport so ComplexHeatmap
# sizes the body to exactly n_left_rows × cell_h
pushViewport(viewport(
layout.pos.row = hm_row,
layout.pos.col = hm_col_left
))
pushViewport(viewport(
x = unit(0, "npc"),
y = unit(0.5, "npc"),
width = unit(left_total_w, "inches"),
height = unit(left_total_h, "inches"),
just = c("left", "centre")
))
ComplexHeatmap::draw(
left_ht,
column_title = left_args$title,
newpage = FALSE,
show_heatmap_legend = FALSE,
show_annotation_legend = FALSE,
padding = unit(c(0, 0, 0, 0), "mm")
)
popViewport(2)
# Right heatmap — centered exact-size viewport so ComplexHeatmap
# sizes the body to exactly n_right_rows × cell_h
pushViewport(viewport(
layout.pos.row = hm_row,
layout.pos.col = hm_col_right
))
pushViewport(viewport(
x = unit(0, "npc"),
y = unit(0.5, "npc"),
width = unit(right_total_w, "inches"),
height = unit(right_total_h, "inches"),
just = c("left", "centre")
))
ComplexHeatmap::draw(
right_ht,
column_title = right_args$title,
newpage = FALSE,
show_heatmap_legend = FALSE,
show_annotation_legend = FALSE,
padding = unit(c(0, 0, 0, 0), "mm")
)
popViewport(2)
# Legend
if (!is.null(combined_legend)) {
if (pos_left || pos_right) {
pushViewport(viewport(
layout.pos.row = lg_row,
layout.pos.col = lg_col
))
ComplexHeatmap::draw(
combined_legend,
x = unit(0.05, "npc"),
just = "left"
)
} else {
# top / bottom: legend spans all heatmap columns
pushViewport(viewport(
layout.pos.row = lg_row,
layout.pos.col = 1:3
))
ComplexHeatmap::draw(
combined_legend,
x = unit(0.5, "npc"),
just = "centre"
)
}
popViewport()
}
# Links in middle column
pushViewport(viewport(
layout.pos.row = hm_row,
layout.pos.col = hm_col_gap
))
if (nrow(link_table) > 0) {
for (k in seq_len(nrow(link_table))) {
y_left <- compute_y(
link_table$pos_left[k],
n_left_rows,
left_body_top_npc,
left_body_range
)
y_right <- compute_y(
link_table$pos_right[k],
n_right_rows,
right_body_top_npc,
right_body_range
)
grid.xspline(
x = unit(c(0.15, 0.5, 0.85), "npc"),
y = unit(
c(y_left, mean(c(y_left, y_right)), y_right),
"npc"
),
shape = 0.5,
gp = gpar(
lwd = link_table$intensity[k] * link_width_scale,
col = link_color,
alpha = link_alpha
)
)
}
}
popViewport()
})
p <- patchwork::wrap_plots(p)
# ── Dimension attributes ──
# Legend dimensions are fixed (don't scale with the heatmap body), so we
# compute the aspect ratio from the body only, clamp body dimensions
# (reserving space for the legend), apply ratio correction, then add
# legend space back to get the final display dimensions.
min_size_in <- 4
max_size_in <- 64
display_h <- max(min(plot_h, max_size_in), min_size_in)
display_w <- max(min(plot_w, max_size_in), min_size_in)
ratio <- plot_h / plot_w
if (ratio > 1 && display_h == max_size_in) {
display_w <- display_h / ratio
} else if (ratio < 1 && display_w == max_size_in) {
display_h <- display_w * ratio
}
attr(p, "height") <- display_h + 1
attr(p, "width") <- display_w + 1
p$data <- data
p
}
#' Linked Heatmap
#'
#' @description
#' Draw two heatmaps side-by-side with spline link lines connecting matching
#' rows across the two heatmaps. This is the public, exported interface for
#' creating linked-heatmap visualisations.
#'
#' A typical use case is visualising ligand–receptor interactions: the
#' left heatmap shows ligand expression (rows = ligands, columns = cell
#' sources), the right heatmap shows receptor expression (rows = receptors,
#' columns = cell targets), and link curves connect each ligand to its
#' cognate receptor(s).
#'
#' @section Left / right specification:
#' Parameters that differ between the two heatmaps are prefixed with
#' \code{left_} or \code{right_}. Shared parameters (e.g. \code{palette},
#' \code{cell_type}, \code{cluster_columns}) apply to both sides but can be
#' overridden per-side via the \code{...} argument. Every parameter listed
#' below can also be passed with a \code{left_} or \code{right_} prefix in
#' \code{...} for full per-side control.
#'
#' The \code{...} argument is also forwarded to
#' \code{\link[ComplexHeatmap]{Heatmap}} after prefix-stripping, allowing
#' direct access to \code{ComplexHeatmap} parameters (e.g.
#' \code{left_row_names_gp}, \code{right_column_names_rot}).
#'
#' @section Split-by support:
#' When \code{split_by} is provided, the data is partitioned into subsets
#' and an independent linked-heatmap pair is produced for each level. The
#' results are combined via \code{\link[patchwork]{wrap_plots}} according to
#' \code{nrow}, \code{ncol}, \code{byrow}, and \code{design}.
#' Per-split \code{palette}, \code{palcolor}, and \code{legend.position} can
#' be specified as named lists keyed by split level.
#'
#' @section Dimension calculation:
#' Cell dimensions are pre-computed from \code{cell_type},
#' \code{aspect.ratio}, \code{base_size}, and the unique row/column counts
#' in the data (accounting for split groups). These exact dimensions are
#' passed to \code{ComplexHeatmap::Heatmap} so cells have guaranteed
#' physical sizes, ensuring that the two heatmaps' bodies align precisely
#' and that link line endpoints land on the correct rows. The final
#' \code{height} / \code{width} attributes on the returned object include
#' legend space and are clamped to \eqn{[4, 64]} inches with aspect-ratio
#' correction.
#'
#' @inheritParams LinkedHeatmapAtomic
#' @inheritParams common_args
#' @param values_by Default column name for heatmap cell values. Used as
#' fallback when \code{left_values_by} / \code{right_values_by} are not
#' explicitly provided via \code{...}.
#' @param name Default legend title for the colour scale. Used as fallback
#' when \code{left_name} / \code{right_name} are not provided via
#' \code{...}. The suffixes \code{" (left)"} / \code{" (right)"} are
#' appended automatically.
#' @param rows_by Default column for rows in both heatmaps. Used as
#' fallback for \code{left_rows_by} / \code{right_rows_by}.
#' @param rows_by_sep Separator for concatenated \code{rows_by} columns.
#' @param rows_split_by_sep Separator for concatenated
#' \code{rows_split_by} columns.
#' @param columns_by Default column for columns in both heatmaps. Used as
#' fallback for \code{left_columns_by} / \code{right_columns_by}.
#' @param columns_by_sep Separator for concatenated \code{columns_by}
#' columns.
#' @param columns_split_by Default column to split columns into groups.
#' Used as fallback for \code{left_columns_split_by} /
#' \code{right_columns_split_by}.
#' @param columns_split_by_sep Separator for concatenated
#' \code{columns_split_by} columns.
#' @param rows_data,columns_data Optional data frames providing additional
#' row / column metadata for annotations. Passed through to
#' \code{HeatmapAtomic}.
#' @param rows_orderby,columns_orderby Column name to order rows / columns
#' by (disables clustering when set).
#' @param columns_name Display name for the column annotation.
#' @param columns_split_name Display name for the column split annotation.
#' @param rows_name Display name for the row annotation.
#' @param rows_split_name Display name for the row split annotation.
#' @param pie_name Default name for the pie legend. Used as fallback for
#' \code{left_pie_name} / \code{right_pie_name}.
#' @param pie_group_by Default column(s) for pie grouping. Used as fallback
#' for \code{left_pie_group_by} / \code{right_pie_group_by}.
#' @param pie_group_by_sep Separator for concatenated \code{pie_group_by}
#' columns.
#' @param cluster_rows Default clustering setting for rows. Used as
#' fallback for \code{left_cluster_rows} / \code{right_cluster_rows}.
#' @param row_names_side Default side for row names. Used as fallback for
#' \code{left_row_names_side} / \code{right_row_names_side}. Default
#' \code{"right"}.
#' @param row_annotation_side \strong{Deprecated}: use
#' \code{row_annotation} with the \code{side} sub-key instead. Used as
#' fallback for \code{left_row_annotation_side} /
#' \code{right_row_annotation_side}. Default \code{"left"}.
#' @param flip Logical; must be \code{FALSE} for linked heatmaps (flipping
#' is not supported). Default \code{FALSE}.
#' @param ... Additional arguments passed to \code{\link{LinkedHeatmapAtomic}}.
#' All parameters listed above (and those inherited from
#' \code{LinkedHeatmapAtomic}) can be specified with \code{left_} or
#' \code{right_} prefixes for per-side control (e.g.
#' \code{left_palette = "Blues"}, \code{right_palette = "Reds"}).
#' Unprefixed arguments apply to both sides. Also forwarded to
#' \code{\link[ComplexHeatmap]{Heatmap}} after prefix stripping.
#'
#' @return A \code{patchwork} object (class \code{wrap_plots}) with
#' \code{height} and \code{width} attributes (in inches). When
#' \code{combine = FALSE}, a named list of such objects, one per
#' \code{split_by} level.
#' @export
#' @seealso \code{\link{Heatmap}}
#' @examples
#' \donttest{
#' set.seed(8525)
#' # Define sparse ligand-receptor pairs
#' pairs_df <- data.frame(
#' ligand = c("Ligand1", "Ligand2", "Ligand3", "Ligand4", "Ligand5",
#' "Ligand1", "Ligand3", "Ligand5"),
#' receptor = c("Receptor1", "Receptor2", "Receptor1", "Receptor3", "Receptor4",
#' "Receptor5", "Receptor2", "Receptor5"),
#' stringsAsFactors = FALSE
#' )
#' sources <- paste0("Source", 1:4)
#' targets <- paste0("Target", 1:6)
#'
#' # Expand pairs across all sources and targets
#' data <- merge(
#' merge(pairs_df, data.frame(source = sources, stringsAsFactors = FALSE)),
#' data.frame(target = targets, stringsAsFactors = FALSE)
#' )
#' data$split <- sample(c("A", "B"), nrow(data), replace = TRUE)
#' data$ligand_expr <- runif(nrow(data), 0, 10)
#' data$receptor_expr <- runif(nrow(data), 0, 10)
#' data$intensity <- runif(nrow(data), 0, 1)
#'
#' if (requireNamespace("ComplexHeatmap", quietly = TRUE)) {
#' LinkedHeatmap(
#' data,
#' show_column_names = TRUE,
#' column_names_side = "bottom",
#' show_row_names = FALSE,
#' left_row_names_side = "right",
#' left_rows_by = "ligand",
#' left_columns_by = "source",
#' left_values_by = "ligand_expr",
#' left_name = "Ligand",
#' left_row_annotation = list(
#' .row = list(
#' type = "label",
#' params = list(labels_rot = 0),
#' side = "right"
#' )
#' ),
#' right_cluster_rows = FALSE,
#' right_row_names_side = "left",
#' right_row_annotation = list(
#' .row = list(
#' type = "label",
#' params = list(labels_rot = 0),
#' side = "left"
#' )
#' ),
#' right_rows_by = "receptor",
#' right_columns_by = "target",
#' right_values_by = "receptor_expr",
#' right_name = "Receptor",
#' link_width_by = "intensity"
#' )
#' }
#' }
LinkedHeatmap <- function(
data,
values_by,
values_fill = NA,
name = NULL,
split_by = NULL,
split_by_sep = "_",
# data definition
rows_by = NULL,
rows_by_sep = "_",
rows_split_by = NULL,
rows_split_by_sep = "_",
columns_by = NULL,
columns_by_sep = "_",
columns_split_by = NULL,
columns_split_by_sep = "_",
rows_data = NULL,
columns_data = NULL,
keep_na = FALSE,
keep_empty = FALSE,
rows_orderby = NULL,
columns_orderby = NULL,
# names
columns_name = NULL,
columns_split_name = NULL,
rows_name = NULL,
rows_split_name = NULL,
# palettes
palette = "RdBu",
palcolor = NULL,
palreverse = FALSE,
# cell_type: pies
pie_size_name = "size",
pie_size = NULL,
pie_values = "length",
pie_name = NULL,
pie_group_by = NULL,
pie_group_by_sep = "_",
pie_palette = "Spectral",
pie_palcolor = NULL,
# cell_type: bars
bars_sample = 100,
# cell_type: label
label = identity,
label_size = 10,
label_color = "black",
label_name = "label",
# cell_type: mark
mark = identity,
mark_color = "black",
mark_size = 1,
mark_name = "mark",
# cell_type: violin
violin_fill = NULL,
# cell_type: boxplot
boxplot_fill = NULL,
# cell_type: dot
dot_size = 8,
dot_size_name = "size",
# legend
legend_items = NULL,
legend_discrete = FALSE,
legend.position = "right",
legend.direction = "vertical",
# values
lower_quantile = 0,
upper_quantile = 0.99,
lower_cutoff = NULL,
upper_cutoff = NULL,
# bg
add_bg = FALSE,
bg_alpha = 0.5,
# reticle
add_reticle = FALSE,
reticle_color = "grey",
# passed to ComplexHeatmap::Heatmap
cluster_columns = NULL,
cluster_rows = NULL,
show_row_names = NULL,
show_column_names = NULL,
border = TRUE,
title = NULL,
title_params = NULL,
column_title = NULL,
row_title = NULL,
na_col = "grey85",
row_names_side = "right",
column_names_side = "bottom",
row_annotation = NULL,
row_annotation_side = NULL,
row_annotation_palette = NULL,
row_annotation_palcolor = NULL,
row_annotation_type = NULL,
row_annotation_params = NULL,
row_annotation_agg = NULL,
column_annotation = NULL,
column_annotation_side = NULL,
column_annotation_palette = NULL,
column_annotation_palcolor = NULL,
column_annotation_type = NULL,
column_annotation_params = NULL,
column_annotation_agg = NULL,
# links
link_width_by = NULL,
link_width_scale = 5,
link_color = "grey40",
link_alpha = 0.6,
# misc
flip = FALSE,
alpha = 1,
seed = 8525,
padding = 15,
base_size = 1,
aspect.ratio = NULL,
draw_opts = list(),
# cell customization
layer_fun_callback = NULL,
cell_type = c(
"tile",
"bars",
"label",
"mark",
"label+mark",
"mark+label",
"dot",
"violin",
"boxplot",
"pie"
),
cell_agg = NULL,
# subplots
combine = TRUE,
nrow = NULL,
ncol = NULL,
byrow = TRUE,
axes = NULL,
axis_titles = axes,
guides = NULL,
design = NULL,
...
) {
# ── Validate ──
validate_common_args(seed)
stopifnot(
"[LinkedHeatmap] `flip` is not supported for linked heatmaps; please set `flip = FALSE`" = isFALSE(
flip
)
)
cell_type <- match.arg(cell_type)
cell_type <- sub("mark+label", "label+mark", cell_type, fixed = TRUE)
if (is.character(link_width_by)) {
link_width_by <- check_columns(data, link_width_by)
}
args <- list(...)
left_rows_orderby <- args$left_rows_orderby %||% rows_orderby
if (!is.null(left_rows_orderby)) {
left_cluster_rows <- args$left_cluster_rows %||% cluster_rows %||% FALSE
stopifnot(
"[LinkedHeatmap] `left_rows_orderby` can't be used with `left_cluster_rows/cluster_rows = TRUE`" = !left_cluster_rows
)
} else {
left_cluster_rows <- args$left_cluster_rows %||% cluster_rows %||% TRUE
}
right_rows_orderby <- args$right_rows_orderby %||% rows_orderby
if (!is.null(right_rows_orderby)) {
right_cluster_rows <- args$right_cluster_rows %||%
cluster_rows %||%
FALSE
stopifnot(
"[LinkedHeatmap] `right_rows_orderby` can't be used with `right_cluster_rows/cluster_rows = TRUE`" = !right_cluster_rows
)
} else {
right_cluster_rows <- args$right_cluster_rows %||%
cluster_rows %||%
TRUE
}
# ── Preprocess data ──
hmdata <- process_linkedheatmap_data(
data,
split_by = split_by,
split_by_sep = split_by_sep,
rows_split_by = rows_split_by,
rows_split_by_sep = rows_split_by_sep,
rows_split_name = rows_split_name,
left_values_by = args$left_values_by %||% values_by,
left_name = args$left_name %||%
if (is.null(name)) NULL else paste0(name, " (left)"),
left_rows_by = args$left_rows_by %||% rows_by,
left_rows_by_sep = args$left_rows_by_sep %||% rows_by_sep,
left_rows_name = args$left_rows_name %||% rows_name,
left_rows_orderby = left_rows_orderby,
left_columns_orderby = args$left_columns_orderby %||% columns_orderby,
left_columns_by = args$left_columns_by %||% columns_by,
left_columns_by_sep = args$left_columns_by_sep %||% columns_by_sep,
left_columns_name = args$left_columns_name %||% columns_name,
left_columns_split_by = args$left_columns_split_by %||%
columns_split_by,
left_columns_split_by_sep = args$left_columns_split_by_sep %||%
columns_split_by_sep,
left_columns_split_name = args$left_columns_split_name %||%
columns_split_name,
left_pie_group_by = args$left_pie_group_by %||% pie_group_by,
left_pie_group_by_sep = args$left_pie_group_by_sep %||%
pie_group_by_sep,
left_pie_name = args$left_pie_name %||% pie_name,
left_rows_data = args$left_rows_data %||% rows_data,
left_columns_data = args$left_columns_data %||% columns_data,
right_values_by = args$right_values_by %||% values_by,
right_name = args$right_name %||%
if (is.null(name)) NULL else paste0(name, " (right)"),
right_rows_by = args$right_rows_by %||% rows_by,
right_rows_by_sep = args$right_rows_by_sep %||% rows_by_sep,
right_rows_name = args$right_rows_name %||% rows_name,
right_rows_orderby = right_rows_orderby,
right_columns_orderby = args$right_columns_orderby %||% columns_orderby,
right_columns_by = args$right_columns_by %||% columns_by,
right_columns_by_sep = args$right_columns_by_sep %||% columns_by_sep,
right_columns_name = args$right_columns_name %||% columns_name,
right_columns_split_by = args$right_columns_split_by %||%
columns_split_by,
right_columns_split_by_sep = args$right_columns_split_by_sep %||%
columns_split_by_sep,
right_columns_split_name = args$right_columns_split_name %||%
columns_split_name,
right_pie_group_by = args$right_pie_group_by %||% pie_group_by,
right_pie_group_by_sep = args$right_pie_group_by_sep %||%
pie_group_by_sep,
right_pie_name = args$right_pie_name %||% pie_name,
right_rows_data = args$right_rows_data %||% rows_data,
right_columns_data = args$right_columns_data %||% columns_data,
keep_na = keep_na,
keep_empty = keep_empty
)
split_by <- split_by %||% "..."
palette <- check_palette(palette, names(hmdata$data))
palcolor <- check_palcolor(palcolor, names(hmdata$data))
pie_palette <- check_palette(pie_palette, names(hmdata$data))
pie_palcolor <- check_palcolor(pie_palcolor, names(hmdata$data))
# ── Validate per-split legend params ──
legend.position <- check_legend(
legend.position,
names(hmdata$data),
"legend.position"
)
legend.direction <- check_legend(
legend.direction,
names(hmdata$data),
"legend.direction"
)
# ── Build per-split plots ──
plots <- lapply(names(hmdata$data), function(nm) {
default_title <- if (length(hmdata$data) == 1 && identical(nm, "...")) {
NULL
} else {
nm
}
if (is.function(title)) {
title <- title(default_title)
} else {
title <- title %||% default_title
}
args_atomic <- args
args_atomic$data <- hmdata$data[[nm]]
args_atomic$left_values_by <- hmdata$left_values_by
args_atomic$right_values_by <- hmdata$right_values_by
args_atomic$left_rows_by <- hmdata$left_rows_by
args_atomic$right_rows_by <- hmdata$right_rows_by
args_atomic$rows_split_by <- hmdata$rows_split_by
args_atomic$left_columns_by <- hmdata$left_columns_by
args_atomic$right_columns_by <- hmdata$right_columns_by
args_atomic$left_columns_split_by <- hmdata$left_columns_split_by
args_atomic$right_columns_split_by <- hmdata$right_columns_split_by
args_atomic$left_pie_group_by <- hmdata$left_pie_group_by
args_atomic$right_pie_group_by <- hmdata$right_pie_group_by
args_atomic$palette <- palette[[nm]]
args_atomic$palcolor <- palcolor[[nm]]
args_atomic$palreverse <- palreverse
args_atomic$pie_size_name <- pie_size_name
args_atomic$pie_size <- pie_size
args_atomic$pie_values <- pie_values
args_atomic$pie_palette <- pie_palette[[nm]]
args_atomic$pie_palcolor <- pie_palcolor[[nm]]
args_atomic$bars_sample <- bars_sample
args_atomic$label <- label
args_atomic$label_size <- label_size
args_atomic$label_color <- label_color
args_atomic$mark <- mark
args_atomic$mark_color <- mark_color
args_atomic$mark_size <- mark_size
args_atomic$violin_fill <- violin_fill
args_atomic$boxplot_fill <- boxplot_fill
args_atomic$dot_size <- dot_size
args_atomic$dot_size_name <- dot_size_name
args_atomic$legend_items <- legend_items
args_atomic$legend_discrete <- legend_discrete
args_atomic$legend.position <- legend.position[[nm]]
args_atomic$legend.direction <- legend.direction[[nm]]
args_atomic$lower_quantile <- lower_quantile
args_atomic$upper_quantile <- upper_quantile
args_atomic$lower_cutoff <- lower_cutoff
args_atomic$upper_cutoff <- upper_cutoff
args_atomic$add_bg <- add_bg
args_atomic$bg_alpha <- bg_alpha
args_atomic$keep_na <- hmdata$keep_na
args_atomic$keep_empty <- hmdata$keep_empty
args_atomic$add_reticle <- add_reticle
args_atomic$reticle_color <- reticle_color
args_atomic$left_cluster_rows <- left_cluster_rows
args_atomic$right_cluster_rows <- right_cluster_rows
args_atomic$cluster_columns <- cluster_columns
args_atomic$show_row_names <- show_row_names
args_atomic$show_column_names <- show_column_names
args_atomic$border <- border
args_atomic$title <- title
args_atomic$title_params <- title_params
args_atomic$column_title <- column_title
args_atomic$row_title <- row_title
args_atomic$na_col <- na_col
args_atomic$row_names_side <- row_names_side
args_atomic$column_names_side <- column_names_side
args_atomic$row_annotation <- row_annotation
args_atomic$row_annotation_side <- row_annotation_side
args_atomic$row_annotation_palette <- row_annotation_palette
args_atomic$row_annotation_palcolor <- row_annotation_palcolor
args_atomic$row_annotation_type <- row_annotation_type
args_atomic$row_annotation_params <- row_annotation_params
args_atomic$row_annotation_agg <- row_annotation_agg
args_atomic$column_annotation <- column_annotation
args_atomic$column_annotation_side <- column_annotation_side
args_atomic$column_annotation_palette <- column_annotation_palette
args_atomic$column_annotation_palcolor <- column_annotation_palcolor
args_atomic$column_annotation_type <- column_annotation_type
args_atomic$column_annotation_params <- column_annotation_params
args_atomic$column_annotation_agg <- column_annotation_agg
args_atomic$link_width_by <- link_width_by
args_atomic$link_width_scale <- link_width_scale
args_atomic$link_color <- link_color
args_atomic$link_alpha <- link_alpha
args_atomic$alpha <- alpha
args_atomic$seed <- seed
args_atomic$base_size <- base_size
args_atomic$aspect.ratio <- aspect.ratio
args_atomic$draw_opts <- draw_opts
args_atomic$layer_fun_callback <- layer_fun_callback
args_atomic$cell_type <- cell_type
args_atomic$cell_agg <- cell_agg
args_atomic$values_fill <- values_fill
do_call(LinkedHeatmapAtomic, args_atomic)
})
names(plots) <- names(hmdata$data)
# ── Combine ──
combine_plots(
plots,
combine = combine,
split_by = split_by,
nrow = nrow,
ncol = ncol,
byrow = byrow,
axes = axes,
axis_titles = axis_titles,
guides = guides,
design = design
)
}
Any scripts or data that you put into this service are public.
Add the following code to your website.
For more information on customizing the embed code, read Embedding Snippets.