from __future__ import annotations
from collections import Counter
from typing import TYPE_CHECKING
from typing import Any, Literal, Sequence
import numpy as np
import polars as pl
if TYPE_CHECKING:
import anndata as ad
import numpy as np
import polars as pl
import plotly.graph_objects as go
import plotly.subplots as sp
import logging
logger = logging.getLogger(__name__)
from . import _sizing
from ._setting import _save_figure
from ._setting import _get_categorical_colormap
from ._setting import _COLORMAP_OPTIONS # dict[str, list[str] | dict[str, str]]
from ..pp._markdup import markdup
def _resolve_colormap_for_annotation(
ann_name: str,
all_labels: list[str],
colormap: (
dict[str, str] | list[str] | str |
dict[str, dict[str, str] | list[str] | str] | None
) = None,
) -> tuple[list[str], dict[str, str]]:
"""Resolve the colormap for a single annotation dimension.
If ``colormap`` is a nested dict containing ``ann_name``, use the
dimension-specific value. Otherwise apply the top-level colormap.
"""
if isinstance(colormap, dict) and ann_name in colormap:
return _get_categorical_colormap(all_labels, colormap[ann_name])
return _get_categorical_colormap(all_labels, colormap)
def _extract_original_name(label: str) -> str:
"""Parse the original sample name from a deduplicated row label."""
if " ... (n=" in label:
return label.split(" ... (n=")[0]
return label
def _is_stacked(annotation_dict: dict[str, list[list[str]]] | None) -> bool:
"""Return True if any annotation dimension contains multi-label lists."""
if not annotation_dict:
return False
return any(
any(len(labels) > 1 for labels in values)
for values in annotation_dict.values()
)
[docs]
def heatmap_from_matrix(
matrix: "np.ndarray",
*,
is_distance: bool = False,
row_labels: list[str] | None = None,
col_labels: list[str] | None = None,
standard_scale: Literal["obs", "var", "zscore_obs", "zscore_var"] | None = None,
cluster_rows: bool = True,
cluster_cols: bool = True,
row_cluster_method: str = "average",
col_cluster_method: str = "average",
row_cluster_metric: str = "euclidean",
col_cluster_metric: str = "euclidean",
colormap: str | list[str] | None = None,
colorbar_title: str = "Value",
showticklabels: bool = True,
vmax: float | None = None,
vmin: float | None = None,
hover_template: str = "Row: %{y}<br>Col: %{x}<br>Value: %{hovertext}<extra></extra>",
row_annotation: dict[str, list[list[str]]] | None = None,
row_annotation_colormap: (
dict[str, str] | list[str] | str |
dict[str, dict[str, str] | list[str] | str] | None
) = None,
col_annotation: dict[str, list[list[str]]] | None = None,
col_annotation_colormap: (
dict[str, str] | list[str] | str |
dict[str, dict[str, str] | list[str] | str] | None
) = None,
figsize: tuple[int | None, int | None] = (None, None),
save: str | bool | None = None,
**kwargs,
) -> "go.Figure":
"""
Plot a clustered heatmap from an arbitrary numeric matrix.
This is the generic engine underlying all domain-specific heatmap
functions. It accepts a raw 2-D numpy array, optionally clusters
rows and/or columns, and returns an interactive Plotly figure with
dendrograms.
Parameters
----------
matrix : np.ndarray
2-D array of shape (n_rows, n_cols).
is_distance : bool, default=False
Whether the matrix is alreadt distance matrix.
row_labels : list[str] | None, optional
Labels for rows. If ``None``, integer indices are used.
col_labels : list[str] | None, optional
Labels for columns. If ``None``, integer indices are used.
standard_scale : {"obs", "var", "zscore_obs", "zscore_var"}, optional
Standard scaling mode:
- ``"obs"`` — min-max scale each row to [0, 1]
- ``"var"`` — min-max scale each column to [0, 1]
- ``"zscore_obs"`` — z-score standardize each row
- ``"zscore_var"`` — z-score standardize each column
cluster_rows : bool, default=True
Whether to hierarchically cluster rows.
cluster_cols : bool, default=True
Whether to hierarchically cluster columns.
row_cluster_method : str, default="average"
Linkage method for row clustering.
col_cluster_method : str, default="average"
Linkage method for column clustering.
row_cluster_metric : str, default="euclidean"
Distance metric for row clustering.
col_cluster_metric : str, default="euclidean"
Distance metric for column clustering.
colormap : str or list, optional
Plotly colormap.
colorbar_title : str, default="Value"
Title shown next to the color bar.
showticklabels : bool, default=True
Whether to display row and column tick labels.
vmax : float, optional
Upper bound for clipping the heatmap color scale.
Values above ``vmax`` are clipped for visualization only;
the original values are still shown on hover.
vmin : float, optional
Lower bound for clipping the heatmap color scale.
Values below ``vmin`` are clipped for visualization only.
hover_template : str, optional
Plotly hover template for the heatmap trace.
Use ``%{text}`` to reference the un-clipped original value.
row_annotation : dict[str, list[list[str]]] | None, optional
Categorical annotation(s) for each row. Keys are dimension
names (e.g. ``"haplotype"``) and values are lists of length
``n_rows``. Each inner list contains the labels for that row;
multiple labels per row are rendered as a stacked proportion
bar. Displayed as coloured sidebars between the row dendrogram
and the heatmap.
row_annotation_colormap : dict[str, str] | list[str] | str |
dict[str, dict[str, str] | list[str] | str] | None, optional
Color specification for row annotations. If a non-nested
value is given, it applies to all dimensions. If a nested
dict is given, keys must match dimension names and values
are used for that dimension only. Missing dimensions fall
back to auto-generated Glasbey colors. If ``None``, colours
are auto-generated from the Glasbey palette. If a string is
provided, it must be selected from ``rainbow``, ``glasbey``,
or ``sequential`` to use the corresponding preset palette.
col_annotation : dict[str, list[list[str]]] | None, optional
Categorical annotation(s) for each column. Keys are dimension
names and values are lists of length ``n_cols``. Each inner
list contains the labels for that column; multiple labels per
column are rendered as a stacked proportion bar. Each dimension
is rendered as a separate coloured bar stacked between the
column dendrogram and the heatmap.
col_annotation_colormap : dict[str, str] | list[str] | str |
dict[str, dict[str, str] | list[str] | str] | None, optional
Color specification for column annotations. Same semantics as
``row_annotation_colormap``: non-nested values apply to all
dimensions; nested dict keys must match dimension names.
figsize : tuple[int | None, int | None], default=(None, None)
Figure size in pixels. ``(None, None)`` triggers auto-computation
from the matrix dimensions so that heatmap cells are square and
labels are not clipped.
save : str | bool | None, default=None
If ``True`` or a ``str``, save the figure. A string is appended to the default filename.
Infer the filetype if ending on {``'.pdf'``, ``'.png'``, ``'.svg'``}.
**kwargs
Additional keyword arguments passed to
``fig.update_layout()``.
Returns
-------
go.Figure
A Plotly figure containing the clustered heatmap with
dendrograms and optional annotation blocks.
"""
import plotly.graph_objects as go
from scipy.cluster.hierarchy import linkage, dendrogram
X = np.asarray(matrix, dtype=float)
if X.ndim != 2:
raise ValueError("matrix must be 2-D")
n_rows, n_cols = X.shape
if n_rows == 0 or n_cols == 0:
return go.Figure()
has_row_labels = row_labels is not None
has_col_labels = col_labels is not None
if row_labels is None:
row_labels = [str(i) for i in range(n_rows)]
if col_labels is None:
col_labels = [str(i) for i in range(n_cols)]
if len(row_labels) != n_rows:
raise ValueError("row_labels length must match matrix row count")
if len(col_labels) != n_cols:
raise ValueError("col_labels length must match matrix column count")
if row_annotation is not None:
for ann_name, ann_values in row_annotation.items():
if len(ann_values) != n_rows:
raise ValueError(
f"row_annotation['{ann_name}'] length ({len(ann_values)}) must match matrix row count ({n_rows})"
)
for i, v in enumerate(ann_values):
if not isinstance(v, list):
raise TypeError(
f"row_annotation['{ann_name}'][{i}] must be a list, got {type(v)}"
)
if col_annotation is not None:
for ann_name, ann_values in col_annotation.items():
if len(ann_values) != n_cols:
raise ValueError(
f"col_annotation['{ann_name}'] length ({len(ann_values)}) must match matrix column count ({n_cols})"
)
for i, v in enumerate(ann_values):
if not isinstance(v, list):
raise TypeError(
f"col_annotation['{ann_name}'][{i}] must be a list, got {type(v)}"
)
# Standard scale
if standard_scale == "obs":
xmin = X.min(axis=1, keepdims=True)
xmax = X.max(axis=1, keepdims=True)
X = (X - xmin) / (xmax - xmin + 1e-12)
elif standard_scale == "var":
xmin = X.min(axis=0, keepdims=True)
xmax = X.max(axis=0, keepdims=True)
X = (X - xmin) / (xmax - xmin + 1e-12)
elif standard_scale == "zscore_obs":
mean = X.mean(axis=1, keepdims=True)
std = X.std(axis=1, ddof=0, keepdims=True)
X = (X - mean) / (std + 1e-12)
elif standard_scale == "zscore_var":
mean = X.mean(axis=0, keepdims=True)
std = X.std(axis=0, ddof=0, keepdims=True)
X = (X - mean) / (std + 1e-12)
# Clustering
row_order = list(range(n_rows))
col_order = list(range(n_cols))
row_dendro_data = None
col_dendro_data = None
# Detect distance matrix: square, symmetric, hollow, non-negative
if is_distance and (cluster_rows or cluster_cols) and n_rows > 1:
from scipy.spatial.distance import squareform
row_linkage = linkage(
squareform(X, checks=False),
method=row_cluster_method,
)
if cluster_rows:
row_dendro_data = dendrogram(
row_linkage, no_plot=True, color_threshold=0,
above_threshold_color="#000000",
)
if cluster_cols:
# For a distance matrix row/col represent the same samples,
# so col linkage is identical to row linkage.
col_dendro_data = dendrogram(
row_linkage, no_plot=True, color_threshold=0,
above_threshold_color="#000000",
)
else:
if cluster_rows and n_rows > 1:
row_linkage = linkage(X, method=row_cluster_method, metric=row_cluster_metric)
row_dendro_data = dendrogram(
row_linkage, no_plot=True, color_threshold=0,
above_threshold_color="#000000",
)
if cluster_cols and n_cols > 1:
col_linkage = linkage(X.T, method=col_cluster_method, metric=col_cluster_metric)
col_dendro_data = dendrogram(
col_linkage, no_plot=True, color_threshold=0,
above_threshold_color="#000000",
)
# Get leaf order
if row_dendro_data is not None:
row_order = row_dendro_data["leaves"]
if col_dendro_data is not None:
col_order = col_dendro_data["leaves"]
# Reorder matrix and labels
X_reordered = X[np.ix_(row_order, col_order)]
row_labels_reordered = [str(row_labels[i]) for i in row_order]
col_labels_reordered = [str(col_labels[i]) for i in col_order]
row_annotations_reordered: dict[str, list[list[str]]] = {}
if row_annotation is not None:
row_annotations_reordered = {
name: [values[i] for i in row_order]
for name, values in row_annotation.items()
}
col_annotations_reordered: dict[str, list[list[str]]] = {}
if col_annotation is not None:
col_annotations_reordered = {
name: [values[i] for i in col_order]
for name, values in col_annotation.items()
}
n_col_annots = len(col_annotations_reordered)
# Clip for visualization, but keep original values for hover
X_display = np.clip(X_reordered, a_min=vmin, a_max=vmax)
# Create figure manually (no subplots) so dendrogram and heatmap can share
# axes and zoom / pan together.
fig = go.Figure()
# Pre-compute max distances for dendrogram axis ranges
row_max_dist = 1.0
if row_dendro_data is not None and row_dendro_data["dcoord"]:
row_max_dist = max(max(d) for d in row_dendro_data["dcoord"])
col_max_dist = 1.0
if col_dendro_data is not None and col_dendro_data["dcoord"]:
col_max_dist = max(max(d) for d in col_dendro_data["dcoord"])
# Add column dendrogram (top) — shares x-axis with heatmap
if col_dendro_data is not None:
for x_pos, y_dist in zip(col_dendro_data["icoord"], col_dendro_data["dcoord"]):
x_norm = [(xi - 5) / 10 for xi in x_pos]
fig.add_trace(go.Scatter(
x=x_norm, y=y_dist,
mode="lines",
line=dict(color="black", width=1.2),
showlegend=False,
hoverinfo="skip",
xaxis="x",
yaxis="y2",
))
# Add row dendrogram (left) — shares y-axis with heatmap
if row_dendro_data is not None:
for x_dist, y_pos in zip(row_dendro_data["dcoord"], row_dendro_data["icoord"]):
y_norm = [(yi - 5) / 10 for yi in y_pos]
fig.add_trace(go.Scatter(
x=[row_max_dist - xi for xi in x_dist], # flip: leaves on the right
y=y_norm,
mode="lines",
line=dict(color="black", width=1.2),
showlegend=False,
hoverinfo="skip",
xaxis="x2",
yaxis="y",
))
# Add heatmap — shares x-axis with column dendrogram, y-axis with row dendrogram
_DEFAULT_COLORMAP = [
[0.0, "rgb(33, 102, 172)"],
[0.5, "rgb(255, 255, 255)"],
[1.0, "rgb(178, 34, 34)"],
] if (standard_scale is not None and "zscore" in standard_scale) and (X.min() < 0) else [
[0.0, "rgb(255, 255, 255)"],
[1.0, "rgb(178, 34, 34)"],
]
_colormap = colormap if colormap is not None else _DEFAULT_COLORMAP
fig.add_trace(go.Heatmap(
z=X_display.tolist(),
hovertext=[[f"{v:.4g}" for v in row] for row in X_reordered],
x=list(range(n_cols)),
y=list(range(n_rows)),
colorscale=_colormap,
showscale=True,
colorbar=dict(
orientation="h",
x=0.5,
xanchor="center",
thickness=15,
len=0.4,
title=dict(text=colorbar_title, side="bottom"),
),
hovertemplate=hover_template,
xaxis="x",
yaxis="y",
))
# Add black border around the heatmap matrix
fig.add_shape(
type="rect",
x0=0,
x1=1,
y0=0,
y1=1,
xref="x domain",
yref="y domain",
line=dict(
color="black",
width=_sizing.get_active_line_width(),
),
fillcolor="rgba(0,0,0,0)",
layer="above",
)
# Annotation blocks
all_row_palettes: dict[str, dict[str, str]] = {}
n_row_annots = len(row_annotations_reordered)
if row_annotations_reordered:
for idx, (ann_name, ann_values) in enumerate(row_annotations_reordered.items()):
xaxis_name = f"x{3 + idx}" if n_row_annots > 1 else "x3"
all_labels = sorted(set(label for labels in ann_values for label in labels))
label_order = {l: i for i, l in enumerate(all_labels)}
_, palette = _resolve_colormap_for_annotation(
ann_name, all_labels, row_annotation_colormap
)
all_row_palettes[ann_name] = palette
# stacked horizontal bars: one trace per unique label
for label in all_labels:
widths: list[float] = []
bases: list[float] = []
y_positions: list[int] = []
hover_texts: list[str] = []
for row_idx, labels in enumerate(ann_values):
if not labels:
continue
cnt = Counter(labels)
total = len(labels)
base = sum(
cnt.get(all_labels[i], 0) / total
for i in range(label_order[label])
)
w = cnt.get(label, 0) / total
if w > 0:
widths.append(w)
bases.append(base)
y_positions.append(row_idx)
hover_texts.append(
f"{ann_name}: {label}<br>count: {int(w * total)}/{total}"
)
if widths:
fig.add_trace(go.Bar(
x=widths,
y=y_positions,
base=bases,
marker=dict(color=palette[label]),
orientation="h",
width=1,
showlegend=False,
hoverinfo="text",
hovertext=hover_texts,
xaxis=xaxis_name,
yaxis="y",
))
all_col_palettes: dict[str, dict[str, str]] = {}
if col_annotations_reordered:
for idx, (ann_name, ann_values) in enumerate(col_annotations_reordered.items()):
yaxis_name = f"y{3 + idx}" if n_col_annots > 1 else "y3"
all_labels = sorted(set(label for labels in ann_values for label in labels))
label_order = {l: i for i, l in enumerate(all_labels)}
_, palette = _resolve_colormap_for_annotation(
ann_name, all_labels, col_annotation_colormap
)
all_col_palettes[ann_name] = palette
# stacked vertical bars: one trace per unique label
for label in all_labels:
heights: list[float] = []
bases: list[float] = []
x_positions: list[int] = []
hover_texts: list[str] = []
for col_idx, labels in enumerate(ann_values):
if not labels:
continue
cnt = Counter(labels)
total = len(labels)
base = sum(
cnt.get(all_labels[i], 0) / total
for i in range(label_order[label])
)
h = cnt.get(label, 0) / total
if h > 0:
heights.append(h)
bases.append(base)
x_positions.append(col_idx)
hover_texts.append(
f"{ann_name}: {label}<br>count: {int(h * total)}/{total}"
)
if heights:
fig.add_trace(go.Bar(
x=x_positions,
y=heights,
base=bases,
marker=dict(color=palette[label]),
orientation="v",
width=1,
showlegend=False,
hoverinfo="text",
hovertext=hover_texts,
xaxis="x",
yaxis=yaxis_name,
))
# Resolve figsize with auto-sizing
font_size = kwargs.get("font", {}).get("size")
if font_size is None:
font_size = _sizing.get_active_font_size()
max_row_label_len = max(len(l) for l in row_labels) if row_labels else 0
max_col_label_len = max(len(l) for l in col_labels) if col_labels else 0
_is_auto = figsize[0] is None or figsize[1] is None
# Dynamic margins so long labels are not clipped.
char_width = font_size * 0.7
_l_margin = 80
_t_margin = 100
_r_margin = 120
_b_margin = max(120, int(max(max_row_label_len, max_col_label_len) * char_width + 40))
_width, _height = _sizing.resolve_figsize(
figsize[0],
figsize[1],
calc_width=lambda: _sizing.heatmap_width(
n_cols, font_size, cluster_rows=cluster_rows,
n_row_annotations=n_row_annots,
l_margin=_l_margin, r_margin=_r_margin
),
calc_height=lambda: _sizing.heatmap_height(
n_rows, font_size, cluster_cols=cluster_cols, n_col_annotations=n_col_annots,
t_margin=_t_margin, b_margin=_b_margin
),
)
figsize = (_width, _height)
# Domain layout — space allocation follows cluster_* parameters exactly.
# When auto-sizing, dendrogram / annotation sizes are fixed pixels so the
# heatmap area lines up precisely with n_cols * cell_px × n_rows * cell_px.
# When the user supplies figsize, the original ratio-based behaviour is kept.
dendro_px = _sizing.HEATMAP_DENDROGRAM_PX
annot_px = _sizing.HEATMAP_ANNOTATION_PX
annot_gap_px = 2
# Domain layout
plot_w = max(figsize[0] - _l_margin - _r_margin, 1)
plot_h = max(figsize[1] - _t_margin - _b_margin, 1)
gap_per_x = annot_gap_px / plot_w
gap_per_y = annot_gap_px / plot_h
row_stacked = _is_stacked(row_annotations_reordered)
col_stacked = _is_stacked(col_annotations_reordered)
annot_w_per = annot_px / plot_w * (3 if row_stacked else 1)
annot_h_per = annot_px / plot_h * (3 if col_stacked else 1)
x_dendro_w = dendro_px / plot_w if cluster_rows else 0.0
x_annot_gap = gap_per_x if n_row_annots > 0 else 0.0
x_annot_w_total = annot_w_per * n_row_annots + gap_per_x * max(0, n_row_annots - 1)
x_heatmap_left = x_dendro_w + x_annot_w_total + 2 * x_annot_gap
y_dendro_h = dendro_px / plot_h if cluster_cols else 0.0
y_annot_gap = gap_per_y if n_col_annots > 0 else 0.0
y_annot_h_total = annot_h_per * n_col_annots + gap_per_y * max(0, n_col_annots - 1)
y_heatmap_top = 1.0 - y_dendro_h - y_annot_h_total - 2 * y_annot_gap
# Register x-axis for each row annotation dimension
if n_row_annots > 0:
for idx, ann_name in enumerate(row_annotations_reordered.keys()):
xaxis_name = f"x{3 + idx}" if n_row_annots > 1 else "x3"
# trace references axis as "x3", but layout property name is "xaxis3"
layout_xaxis_name = "xaxis" + xaxis_name[1:]
domain_start = x_dendro_w + x_annot_gap + idx * (annot_w_per + gap_per_x)
domain_end = domain_start + annot_w_per
fig.update_layout(**{
layout_xaxis_name: dict(
domain=[domain_start, domain_end],
range=[0, 1],
showticklabels=False,
showline=False,
automargin=False,
mirror=False,
showgrid=False,
zeroline=False,
ticks="",
)
})
# 100% label (above annotation column) only when stacked
if _is_stacked(row_annotations_reordered):
fig.add_annotation(
xref="paper",
yref="paper",
x=(domain_start + domain_end) / 2,
y=y_heatmap_top + gap_per_y,
text="100%",
showarrow=False,
xanchor="center",
yanchor="bottom",
font=dict(size=font_size),
)
# Dimension name label (vertical, below annotation column)
fig.add_annotation(
xref=xaxis_name,
yref="paper",
x=0.5,
y=-3.0 / plot_h,
text=ann_name,
showarrow=False,
textangle=-90,
xanchor="center",
yanchor="top",
font=dict(size=font_size),
)
# Register y-axis for each column annotation dimension
if n_col_annots > 0:
for idx, ann_name in enumerate(col_annotations_reordered.keys()):
yaxis_name = f"y{3 + idx}" if n_col_annots > 1 else "y3"
layout_yaxis_name = "yaxis" + yaxis_name[1:]
domain_start = y_heatmap_top + y_annot_gap + idx * (annot_h_per + gap_per_y)
domain_end = domain_start + annot_h_per
fig.update_layout(**{
layout_yaxis_name: dict(
domain=[domain_start, domain_end],
range=[0, 1],
showticklabels=False,
showline=False,
automargin=False,
mirror=False,
showgrid=False,
zeroline=False,
ticks="",
)
})
# 100% label (vertical, to the right of annotation row) only when stacked
if _is_stacked(col_annotations_reordered):
fig.add_annotation(
xref="paper",
yref="paper",
x=1.0 + gap_per_x,
y=(domain_start + domain_end) / 2,
text="100%",
showarrow=False,
textangle=-90,
xanchor="left",
yanchor="middle",
font=dict(size=font_size),
)
# Dimension name label (horizontal, to the right of annotation row)
fig.add_annotation(
xref="paper",
yref=yaxis_name,
x=1.0 + 3.0 / plot_w,
y=0.5,
text=ann_name,
showarrow=False,
xanchor="left",
yanchor="middle",
font=dict(size=font_size),
)
# Add legend entries — one legend per annotation dimension
legend_infos = [] # list of (legend_id, title, names)
legend_idx = 0
for ann_name, palette in all_row_palettes.items():
legend_id = f"legend{legend_idx + 1}" if legend_idx > 0 else "legend"
for cat, color in palette.items():
fig.add_trace(go.Scatter(
x=[None], y=[None],
mode="markers",
marker=dict(size=12, color=color, symbol="square"),
showlegend=True,
name=cat,
legend=legend_id,
))
legend_infos.append((legend_id, ann_name, list(palette.keys())))
legend_idx += 1
for ann_name, palette in all_col_palettes.items():
legend_id = f"legend{legend_idx + 1}" if legend_idx > 0 else "legend"
for cat, color in palette.items():
fig.add_trace(go.Scatter(
x=[None], y=[None],
mode="markers",
marker=dict(size=12, color=color, symbol="square"),
showlegend=True,
name=cat,
legend=legend_id,
))
legend_infos.append((legend_id, ann_name, list(palette.keys())))
legend_idx += 1
# ---- Legend layout estimation ----
def _estimate_legend_layout(names, title, avail_width, font_size):
"""
Estimate the number of wrapped legend rows and the total legend height in pixels.
"""
if not names:
return 0, 0
_marker_px = 20 # marker + left padding
_gap_px = 10 # entry spacing distance
_char_px = font_size * 0.7
entry_widths = [len(str(n)) * _char_px + _marker_px + _gap_px for n in names]
lines = 0
current_width = 0
for w in entry_widths:
if current_width + w > avail_width and current_width > 0:
lines += 1
current_width = w
else:
current_width += w
if current_width > 0:
lines += 1
_title_h = font_size + 8 if title else 0
_line_h = font_size + 6
total_h = _title_h + lines * _line_h + 8 # bottom padding
return lines, int(total_h)
_avail_width = max(_width - 60, 100)
_legend_gap = 30 # legend spacing distance
plot_height = max(_height - 100 - _b_margin, 1)
# colorbar position
cb_y = -50 / plot_height
fig.update_traces(
selector=dict(type="heatmap"),
colorbar=dict(y=cb_y, yanchor="top"),
)
# Build per-dimension legend layout params, stacked vertically below the plot
legend_layouts = {}
current_y_offset_px = 150 # first legend top distance from plot bottom
for legend_id, title, names in legend_infos:
lines, h = _estimate_legend_layout(names, title, _avail_width, font_size)
legend_layouts[legend_id] = dict(
orientation="h",
yanchor="top",
y=-current_y_offset_px / plot_height,
x=0.5,
xanchor="center",
bgcolor="rgba(0,0,0,0)",
title=dict(text=title, side="top"),
)
current_y_offset_px += h + _legend_gap
fig.update_layout(
xaxis=dict( # heatmap
domain=[x_heatmap_left, 1],
range=[-0.5, n_cols - 0.5],
tickvals=list(range(n_cols)),
ticktext=col_labels_reordered if (showticklabels and has_col_labels) else [],
tickangle=90,
showticklabels=showticklabels and has_col_labels,
showline=False,
automargin=False,
mirror=False,
showgrid=False,
zeroline=False,
ticks="outside" if (showticklabels and has_col_labels) else "",
),
xaxis2=dict( # row dendrogram
domain=[0, x_dendro_w],
range=[0, row_max_dist],
showticklabels=False,
showline=False,
automargin=False,
mirror=False,
showgrid=False,
zeroline=False,
ticks="",
),
yaxis=dict( # heatmap
domain=[0, y_heatmap_top],
range=[-0.5, n_rows - 0.5],
tickvals=list(range(n_rows)),
ticktext=row_labels_reordered if (showticklabels and has_row_labels) else [],
showticklabels=showticklabels and has_row_labels,
side="right",
showline=False,
automargin=False,
mirror=False,
showgrid=False,
zeroline=False,
ticks="outside" if (showticklabels and has_row_labels) else "",
),
yaxis2=dict( # column dendrogram
domain=[y_heatmap_top + y_annot_h_total + 2 * y_annot_gap, 1],
range=[0, col_max_dist],
showticklabels=False,
showline=False,
automargin=False,
mirror=False,
showgrid=False,
zeroline=False,
ticks="",
),
bargap=0,
barmode="overlay",
width=figsize[0],
height=figsize[1],
**legend_layouts,
margin=dict(l=_l_margin, r=_r_margin, t=_t_margin, b=_b_margin),
)
fig.update_layout(**kwargs)
if save:
_save_figure(fig, save, "heatmap_from_matrix")
return fig
[docs]
def motif_abundance_heatmap(
adata: "ad.AnnData",
*,
layer: str | None = None,
standard_scale: Literal["obs", "var", "zscore_obs", "zscore_var"] | None = "obs",
deduplicate = False,
cluster_rows: bool = True,
cluster_cols: bool = True,
row_cluster_method: str = "average",
col_cluster_method: str = "average",
row_cluster_metric: str = "euclidean",
col_cluster_metric: str = "euclidean",
colormap: str | list[str] | None = None,
showticklabels: bool = True,
vmax: float | None = None,
vmin: float | None = None,
row_annotation: str | list[str] | dict[str, dict[str, str]] | None = None,
row_annotation_colormap: (
dict[str, str] | list[str] | str |
dict[str, dict[str, str] | list[str] | str] | None
) = None,
col_annotation: str | list[str] | dict[str, dict[str, str]] | None = None,
col_annotation_colormap: (
dict[str, str] | list[str] | str |
dict[str, dict[str, str] | list[str] | str] | None
) = None,
figsize: tuple[int | None, int | None] | None = (None, None),
save: str | bool | None = None,
**kwargs,
) -> "go.Figure":
"""
Plot a sample × motif abundance heatmap with hierarchical clustering
and dendrograms.
This is a convenience wrapper around :func:`matrix_heatmap` that
extracts the motif abundance matrix from an ``AnnData`` object.
Parameters
----------
adata : ad.AnnData
Annotated data object generated from ``pp.read_anno()``.
layer : str, optional
Layer in ``adata.layers`` to use. If ``None``, uses ``adata.X``.
standard_scale : {"obs", "var", "zscore_obs", "zscore_var"}, optional, default is ``"obs"``
Standard scaling mode:
- ``"obs"`` — min-max scale each row (sample) to [0, 1]
- ``"var"`` — min-max scale each column (motif) to [0, 1]
- ``"zscore_obs"`` — z-score standardize each row (sample)
- ``"zscore_var"`` — z-score standardize each column (motif)
deduplicate : bool, default=False
If True, collapse samples with identical motif arrays into a single
row. The track label shows the first sample name followed by
``... (n=X)`` where X is the number of collapsed samples. The draw
order follows the position of the first occurrence in ``sample_order``.
cluster_rows : bool, default=True
Whether to hierarchically cluster rows (samples).
cluster_cols : bool, default=True
Whether to hierarchically cluster columns (motifs).
row_cluster_method : str, default="average"
Linkage method for row clustering.
col_cluster_method : str, default="average"
Linkage method for column clustering.
row_cluster_metric : str, default="euclidean"
Distance metric for row clustering.
col_cluster_metric : str, default="euclidean"
Distance metric for column clustering.
colormap : str or list[str], optional
Plotly colormap name. Default is white to red.
showticklabels : bool, default=True
Whether to display row and column tick labels.
vmax : float, optional
Upper bound for clipping the heatmap color scale.
Values above ``vmax`` are clipped for visualization only.
vmin : float, optional
Lower bound for clipping the heatmap color scale.
Values below ``vmin`` are clipped for visualization only.
row_annotation : str | list[str] | dict[str, dict[str, str]] | None, optional
Categorical annotation(s) for each row.
- ``str`` — column name in ``adata.obs``; values are read and
wrapped into the unified dict format.
- ``list[str]`` — multiple column names in ``adata.obs``; each
becomes a separate annotation dimension.
- ``dict[str, dict[str, str]]`` — keys are dimension names and
values are ``{sample_name -> category}`` mappings. Looked up
by sample name so the order is safe.
When ``deduplicate=True``, inputs are aggregated per sample group
so collapsed rows show stacked proportions.
row_annotation_colormap : dict[str, str] | list[str] | str |
dict[str, dict[str, str] | list[str] | str] | None, optional
Color specification for row annotations. Non-nested values apply
to all dimensions; nested dict keys must match dimension names.
col_annotation : str | list[str] | dict[str, dict[str, str]] | None, optional
Categorical annotation(s) for each column. Same semantics as
``row_annotation``, but reads from ``adata.var`` instead of
``adata.obs``.
col_annotation_colormap : dict[str, str] | list[str] | str |
dict[str, dict[str, str] | list[str] | str] | None, optional
Color specification for column annotations. Non-nested values apply
to all dimensions; nested dict keys must match dimension names.
figsize : tuple[int | None, int | None] | None, default=(None, None)
Figure size in pixels. ``(None, None)`` triggers auto-computation
from the matrix dimensions so that heatmap cells are square and
labels are not clipped.
save : str | bool | None, default=None
If ``True`` or a ``str``, save the figure. A string is appended to the default filename.
Infer the filetype if ending on {``'.pdf'``, ``'.png'``, ``'.svg'``}.
**kwargs
Additional keyword arguments forwarded to
:func:`matrix_heatmap` and ``fig.update_layout()``.
Returns
-------
go.Figure
A Plotly figure containing the clustered heatmap with
dendrograms.
Examples
--------
>>> import vampire as vp
>>> vp.anno.pl.set_default_plotstyle()
>>> adata = vp.datasets.wdr7_hprc()
>>> vp.anno.pl.motif_abundance_heatmap(
... adata,
... cluster_rows=True,
... cluster_cols=True,
... standard_scale="obs",
... )
"""
# Extract data matrix
X = adata.X if layer is None else adata.layers[layer]
if hasattr(X, "toarray"):
X = X.toarray()
row_labels = [str(l) for l in adata.obs.index]
col_labels = [str(l) for l in adata.var.index]
if deduplicate:
if "unique_group" not in adata.obs.columns:
logger.warning(
"unique_group not found in adata.obs. "
"vp.anno.pp.markdup() has not been run. Running it automatically."
)
adata = markdup(adata)
obs = adata.obs.copy()
name_to_group: dict[str, int] = obs["unique_group"].to_dict()
group_to_names: dict[int, list[str]] = {}
for name, group in name_to_group.items():
if group not in group_to_names:
group_to_names[group] = []
group_to_names[group].append(name)
# keep first occurrence of each group
seen_groups = {}
keep_idx = []
for i, name in enumerate(obs.index.astype(str)):
gid = obs.loc[name, "unique_group"]
if gid not in seen_groups:
seen_groups[gid] = i
keep_idx.append(name)
# subset matrix
X = X[adata.obs.index.get_indexer(keep_idx), :]
# update labels
row_labels = []
for name in keep_idx:
gid = name_to_group[name]
gsize = len(group_to_names[gid])
if gsize == 1:
row_labels.append(name)
else:
row_labels.append(f"{name} ... (n={gsize})")
# resolve row_annotation into dict[str, list[list[str]]] | None
row_annotation_dict: dict[str, list[list[str]]] | None = None
if row_annotation is not None:
if isinstance(row_annotation, str):
row_annotation = [row_annotation]
if isinstance(row_annotation, list):
row_annotation_dict = {}
for col_name in row_annotation:
if col_name not in adata.obs.columns:
raise ValueError(
f"row_annotation column '{col_name}' not found in adata.obs.columns"
)
if deduplicate:
row_annotation_dict[col_name] = [
[
str(adata.obs.loc[s, col_name])
for s in group_to_names[name_to_group[_extract_original_name(l)]]
]
for l in row_labels
]
else:
row_annotation_dict[col_name] = [
[str(adata.obs.loc[i, col_name])]
for i in row_labels
]
elif isinstance(row_annotation, dict):
row_annotation_dict = {}
for name, mapping in row_annotation.items():
if deduplicate:
row_annotation_dict[name] = [
[
str(mapping.get(s, ""))
for s in group_to_names[name_to_group[_extract_original_name(l)]]
]
for l in row_labels
]
else:
row_annotation_dict[name] = [
[str(mapping.get(i, ""))]
for i in row_labels
]
else:
raise TypeError(
f"row_annotation must be str, list[str], dict[str, dict[str, str]] or None, "
f"got {type(row_annotation)}"
)
# resolve col_annotation into dict[str, list[list[str]]] | None
col_annotation_dict: dict[str, list[list[str]]] | None = None
if col_annotation is not None:
if isinstance(col_annotation, str):
col_annotation = [col_annotation]
if isinstance(col_annotation, list):
col_annotation_dict = {}
for col_name in col_annotation:
if col_name not in adata.var.columns:
raise ValueError(
f"col_annotation column '{col_name}' not found in adata.var.columns"
)
col_annotation_dict[col_name] = [
[str(adata.var.loc[i, col_name])]
for i in col_labels
]
elif isinstance(col_annotation, dict):
col_annotation_dict = {
name: [
[str(mapping.get(i, ""))]
for i in col_labels
]
for name, mapping in col_annotation.items()
}
else:
raise TypeError(
f"col_annotation must be str, list[str], dict[str, dict[str, str]] or None, "
f"got {type(col_annotation)}"
)
fig = heatmap_from_matrix(
matrix=X,
is_distance=False,
row_labels=row_labels,
col_labels=col_labels,
standard_scale=standard_scale,
cluster_rows=cluster_rows,
cluster_cols=cluster_cols,
row_cluster_method=row_cluster_method,
col_cluster_method=col_cluster_method,
row_cluster_metric=row_cluster_metric,
col_cluster_metric=col_cluster_metric,
colormap=colormap,
showticklabels=showticklabels,
figsize=figsize,
vmax=vmax,
vmin=vmin,
row_annotation=row_annotation_dict,
row_annotation_colormap=row_annotation_colormap,
col_annotation=col_annotation_dict,
col_annotation_colormap=col_annotation_colormap,
colorbar_title="Abundance",
hover_template="Sample: %{y}<br>Motif: %{x}<br>Value: %{hovertext}<extra></extra>",
**kwargs,
)
if save:
_save_figure(fig, save, "motif_abundance_heatmap")
return fig
[docs]
def haplotype_distance_heatmap(
adata: "ad.AnnData",
*,
store_key: str = "haplotype",
metric: str = "structural",
deduplicate: bool = False,
reorder: bool = True,
cluster: bool = False,
colormap: str | list[str] | None = None,
figsize: tuple[int | None, int | None] | None = (None, None),
save: str | bool | None = None,
**kwargs,
) -> "go.Figure":
"""Plot sample pairwise distance matrix from haplotype analysis.
Visualises one of the distance matrices stored in ``obsp`` by
``vp.anno.tl.haplotype_neighbor()``. Samples are annotated by their haplotype
assignment so that the block structure is visible.
Parameters
----------
adata : ad.AnnData
Annotated data with haplotype results from ``vp.anno.tl.haplotype_neighbor()``.
store_key : str, default="haplotype"
Key prefix matching ``store_key`` used in ``vp.anno.tl.haplotype_neighbor()``.
metric : str, default="structural"
Which distance matrix to visualise. Options:
``"structural"``, ``"cnv"``.
deduplicate : bool, default=False
If True, collapse samples with identical motif arrays into a single
row. The track label shows the first sample name followed by
``... (n=X)`` where X is the number of collapsed samples. The draw
order follows the position of the first occurrence in ``sample_order``.
reorder : bool, default=True
If ``True``, rows and columns are sorted by haplotype label
so that samples from the same haplotype are adjacent.
cluster : bool, default=False
If ``True``, hierarchically cluster rows and columns
(overrides ``reorder``).
colormap : str | list[str] | None, default=None
Plotly colormap for the heatmap. If ``None``, defaults to a red-to-white scale.
figsize : tuple[int | None, int | None] | None, default=(None, None)
Figure size in pixels. ``(None, None)`` triggers auto-computation
from the matrix dimensions so that heatmap cells are square and
labels are not clipped.
save : str | bool | None, default=None
If ``True`` or a ``str``, save the figure. A string is appended to the default filename.
Infer the filetype if ending on {``'.pdf'``, ``'.png'``, ``'.svg'``}.
**kwargs
Additional arguments passed to ``heatmap_from_matrix``.
Returns
-------
go.Figure
Plotly figure with the distance matrix heatmap.
Examples
--------
>>> import vampire as vp
>>> vp.anno.pl.set_default_plotstyle()
>>> adata = vp.datasets.wdr7_hprc()
>>> vp.anno.tl.sample_msa(adata)
>>> vp.anno.tl.haplotype_neighbor(adata)
>>> vp.anno.pl.haplotype_distance_heatmap(adata, metric = "structural")
"""
import numpy as np
metric_key = f"{store_key}_{metric}_distance"
dist_mat = adata.obsp.get(metric_key)
if dist_mat is None:
raise KeyError(
f"Distance matrix not found at obsp['{metric_key}']. "
f"Run tl.haplotype_neighbor() first."
)
if store_key not in adata.obs.columns:
raise KeyError(
f"'{store_key}' not found at adata.obs. "
f"Run vp.anno.tl.haplotype_neighbor() first."
)
if deduplicate:
if "unique_group" not in adata.obs.columns:
logger.warning(
"unique_group not found in adata.obs. "
"vp.anno.pp.markdup() has not been run. Running it automatically."
)
adata = markdup(adata)
obs = adata.obs.copy()
name_to_group: dict[str, int] = obs["unique_group"].to_dict()
group_to_names: dict[int, list[str]] = {}
for name, group in name_to_group.items():
if group not in group_to_names:
group_to_names[group] = []
group_to_names[group].append(name)
# keep first occurrence of each group
seen_groups = {}
keep_idx = []
for i, name in enumerate(obs.index.astype(str)):
gid = obs.loc[name, "unique_group"]
if gid not in seen_groups:
seen_groups[gid] = i
keep_idx.append(name)
# subset matrix (keep symmetric)
keep_positions = adata.obs.index.get_indexer(keep_idx)
dist_mat = dist_mat[np.ix_(keep_positions, keep_positions)]
# update labels
names: list[str] = []
annotation: dict[str, Any] = {}
for name in keep_idx:
gid = name_to_group[name]
gsize = len(group_to_names[gid])
if gsize == 1:
names.append(name)
annotation[name] = adata.obs.loc[name, store_key]
else:
compact_name = f"{name} ... (n={gsize})"
names.append(compact_name)
annotation[compact_name] = adata.obs.loc[name, store_key]
annotation_list: list[str] = [annotation[i] for i in names]
else:
annotation: dict[str, str] = adata.obs[store_key].to_dict()
names: list[str] = list(adata.obs_names)
annotation_list: list[str] = [annotation[i] for i in names]
# sort by haplotype
def _haplotype_sort_key(x):
import re
m = re.search(r"\d+", str(x))
if m:
return int(m.group())
return float("inf")
if reorder and not cluster:
###sort_idx = np.argsort(annotation_list)
sort_idx = sorted(
range(len(annotation_list)),
key=lambda i: _haplotype_sort_key(annotation_list[i])
)
dist_mat = dist_mat[np.ix_(sort_idx, sort_idx)]
names = [names[i] for i in sort_idx]
annotation_list = [annotation[i] for i in names]
_DEFAULT_COLORMAP = [
[0.0, "rgb(178, 34, 34)"],
[1.0, "rgb(255, 255, 255)"],
]
row_annotation_dict = {"Haplotype": [[h] for h in annotation_list]}
fig = heatmap_from_matrix(
matrix=dist_mat,
is_distance=True,
row_labels=names,
col_labels=None,
cluster_rows=cluster,
cluster_cols=cluster,
colormap=colormap or _DEFAULT_COLORMAP,
figsize=figsize,
colorbar_title="Distance",
row_annotation=row_annotation_dict,
col_annotation={"Haplotype": [[v] for v in annotation_list]},
hover_template="Sample: %{y}<br>Sample: %{x}<br>Distance: %{hovertext}<extra></extra>",
legend2=dict(visible=False),
**kwargs,
)
if save:
_save_figure(fig, save, "haplotype_distance_heatmap")
return fig