diff --git a/CHANGELOG.md b/CHANGELOG.md
index cc40e602..f68732c7 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -10,6 +10,55 @@ change. Versions absent from the list were never released.
## [Unreleased]
### Breaking
+- `annotated_heatmap` returns one `PlotSpec` instead of a `gggrid` of
+ `SupPlotsSpec` panels. Annotation tracks are ggside side layers
+ (`geom_xsidetile`/`geom_ysidetile`), each with its own fill scale
+ (`new_scale_fill`) and legend, so they zoom and pan with the heatmap in HTML
+ output (`interactive=True`). Track titles beside the bands are hidden by
+ default, since the legends name each track; the new `track_titles=True`
+ shows them (a top or bottom band's names on its right, a left or right
+ band's names above it). The group dendrogram is drawn in
+ the observation band, outermost. `layers_all` acts as `layers` now that there
+ is a single plot. `column_annotation_height`, `row_annotation_width` and the
+ tree's 0.15 width set the bands' share of the panel
+ (`ggside_panel_scale_x`/`_y`).
+ - Migration: index the plot's `as_dict()["layers"]` instead of
+ `["figures"]`; combine it with other plots through `gggrid` as any
+ `PlotSpec`.
+- ggrust 0.6 is required. `cellestial.setup_html` is removed, with the ggrust
+ `LetsPlot` shim it aliased; it was a no-op, as is the setup call cellestial
+ made on import in IPython.
+ - Migration: delete `setup_html()` calls; use `ggrust.display("html" | "svg" | "png")`
+ to choose notebook output.
+- `dotplot`, `stacked_violin`, `matrixplot` and `heatmap(aggregate=True)` draw
+ the group dendrogram with ggrust's axis dendrogram (`scale_y_dendro` +
+ `guide_axis_dendro`) instead of a `geom_path` layer. The tree sits in a fixed
+ band right of the panel rather than inside an extended x range, so the data
+ area no longer shrinks to make room for it. `dendrogram_kwargs` is removed from
+ these functions and from `heatmap`; the per-cell `heatmap(aggregate=False)`
+ keeps its path layer, styled by `dendrogram_color` and `dendrogram_size`.
+ - Migration: drop `dendrogram_kwargs`; style the tree with `dendrogram_color`
+ and `dendrogram_size`, or with `theme(axis_ticks_y=element_line(...))`.
+- `arrow_axis` returns a ggrust theme with arrow axis lines instead of a deferred
+ layer of `geom_path`/`geom_segment`. It no longer takes `plot=` (the theme needs
+ no data) or `**geom_kwargs`; `linetype=` is now an explicit parameter. `length`
+ is a fraction of the panel rather than of the data range, so arrows start at the
+ panel corner.
+ - Migration: drop `plot=`; pass `linetype=` directly; other geom kwargs such as
+ `alpha` are no longer supported.
+- `bracket` computes its tests with ggrust's `stat_compare_means` instead of
+ scipy. P-values, the `none`/`bonferroni`/`fdr_bh` corrections and the stars
+ agree for groups of 50 or more values; below that, tie-free groups get R's
+ exact Wilcoxon test, so `mannwhitney` p-values change there. Stars now use
+ `<=` cutoffs, and p-value labels show 2 significant digits. Bracket tips are
+ 3% of the y range (ggpubr's default) instead of a fixed size. Removed:
+ `test="student"`, `alternative`, `label="padj"` and label sequences,
+ `label_format`, `prefix`, `prefix_style`, `separator` and `threshold`.
+ - Migration: `label="padj"` becomes `label="pvalue"` (it shows the adjusted
+ p-value); `threshold=0.05` becomes `hide_ns=True` with `label="stars"`;
+ pass `tip_length=` for other tips. One-sided and pooled-variance tests are
+ no longer available: ggrust's `student` is Welch's test, so it is not offered
+ under that name; use `test="ttest"`.
- `spatialdata` is no longer installed by default. It is only needed to plot
`SpatialData` objects with `spatial`, `spatials` or `build_frame`; AnnData
inputs, spatial ones included, work without it. Dropping it from the core
@@ -23,7 +72,8 @@ change. Versions absent from the list were never released.
in code that adds layers, scales or themes to cellestial plots.
- `bracket(test="ttest")` is now Welch's t-test rather than the pooled-variance
one, matching R's `t.test()` and scanpy. P-values change.
- - Migration: pass `test="student"`, a new option, for the previous behaviour.
+ - Migration: none; the pooled-variance test is no longer available (see the
+ `stat_compare_means` entry above).
- `volcano` and `volcanos` name their p-value column after the values it holds.
Under the default `use_adjusted_pvalue=True` the column, tooltip entry and
y-axis title read `pvalue_adj` and `-log10(Padj)`; raw p-values are unaffected.
diff --git a/README.md b/README.md
index 763181d7..cdc8a86b 100644
--- a/README.md
+++ b/README.md
@@ -177,8 +177,6 @@ cl.boxplot(
) + scale_fill_hue() + cl.bracket(
y_padding=0.2,
label="pvalue",
- prefix="p",
- prefix_style="<",
comparisons=[
("Lymphocytes", "Monocytes"),
("Monocytes", "Erythroid"),
diff --git a/SKILLS/dendrogram.md b/SKILLS/dendrogram.md
index 3035b8f3..62bc9321 100644
--- a/SKILLS/dendrogram.md
+++ b/SKILLS/dendrogram.md
@@ -2,83 +2,53 @@
How dendrograms are wired into the heatmap variants (`heatmap`, `matrixplot`, `dotplot`, `stacked_violin`). Use this as the template when adding a dendrogram to a new plot.
-## Helpers
-
-Two private helpers live in [cellestial/util/dendrogram.py](../cellestial/util/dendrogram.py) and are re-exported from [cellestial.util](../cellestial/util/__init__.py):
+## Two cases
-- `_get_dendrogram(data, group_by) -> (categories_ordered, paths)`
- - Reads `data.uns[f"dendrogram_{group_by}"]`, computes via `scanpy.tl.dendrogram` if missing.
- - `categories_ordered`: list of group labels in dendrogram leaf order. **Use this as the y order.**
- - `paths`: Polars DataFrame with normalized `x` (leaf positions 0..n_groups-1) and `y` (dcoord scaled to 0..1), plus `group` id per linkage segment.
+- **One row per group** (dotplot, stacked_violin, matrixplot, heatmap with `aggregate=True`): use ggrust's native axis dendrogram. The tree orders the y-axis and draws itself right of the panel.
+- **Per-cell rows** (heatmap with `aggregate=False`; `complex/annotated_heatmap.py`): groups span many rows of unequal height, so a discrete dendrogram scale cannot express them. These keep the hand-drawn tree: `geom_path` in the heatmap, two-point `geom_xsideline`/`geom_ysideline` segments in the annotated heatmap's observation side band (outermost, beyond the tracks).
-- `_get_dendrogram_path_frame(paths, *, n_x, n_groups, group_centers, dendrogram_ratio=0.15) -> pl.DataFrame`
- - Maps the normalized paths into actual plot coordinates on the **right side**, along the y-axis.
- - Width of dendrogram = `n_x * dendrogram_ratio`, anchored at `x = n_x - 0.5`.
- - `group_centers` is the y-pixel center of each group, ordered to match `categories_ordered`.
+## Helpers
-## Required parameters to expose
+All live in [cellestial/util/dendrogram.py](../cellestial/util/dendrogram.py), re-exported from [cellestial.util](../cellestial/util/__init__.py). Each reads `data.uns[use_key or f"dendrogram_{group_by}"]` and computes it with `scanpy.tl.dendrogram` if missing.
-```python
-dendrogram: bool = False,
-dendrogram_color: str = "black",
-dendrogram_size: float = 0.5,
-dendrogram_kwargs: dict | None = None,
-```
+- `_get_dendrogram_tree(data, group_by, *, use_key=None) -> ClusterTree`: scanpy's linkage as a ggrust tree; `tree.ordered_labels` is scanpy's `categories_ordered`.
+- `_dendrogram_y_axis(tree, *, limits, fixed=False)`: `scale_y_dendro(..., guide=guide_axis_dendro(side="opposite"))` plus a coord holding the y `limits`. Pass `fixed=True` for `geom_raster` plots, which default to `coord_fixed()`.
+- `_dendrogram_line_theme(*, color, size)`: the tree is drawn with the y tick line, so this styles `axis_ticks_y` (size in geom units) and sets the tick length to 0. Add it **after** any full theme (`theme_classic()` in `_THEME_DOTPLOT` resets it).
+- Per-cell only: `_get_dendrogram(...) -> (categories_ordered, paths)` and `_get_dendrogram_path_frame(paths, *, n_x, n_groups, group_centers)`, which map scipy's `icoord`/`dcoord` beside the panel.
-## Wiring (three steps)
+## Wiring a one-row-per-group plot
-**1. Determine y order before building positions.** The dendrogram dictates group order; everything else (positions, separator lines, group bars) must follow.
+Keep numeric `position_y` values (group `i` of the tree order at `i`): numeric layers such as key-group brackets and the border `geom_rect` still work on the discrete dendrogram scale.
```python
if dendrogram:
- y_order_groups, paths = _get_dendrogram(data, group_by)
+ tree = _get_dendrogram_tree(data, group_by, use_key=dendrogram_key)
+ y_order_groups = list(tree.ordered_labels)
+...
+if dendrogram:
+ plot += _dendrogram_y_axis(tree, limits=[-0.5, y_max_limit])
else:
- y_order_groups = frame.select(group_by).unique(maintain_order=True)[group_by].cast(pl.String).to_list()
- paths = None
-```
-
-**2. After computing layout, build the path frame and add `geom_path`.** You need `n_x`, `n_groups`, and `group_centers` (the y-coordinate of each group's center, in `y_order_groups` order).
-
-```python
+ plot += scale_y_continuous(breaks=list(range(n_y)), labels=y_order_groups,
+ limits=[-0.5, y_max_limit], expand=[0, 0])
+...
+plot += _THEME_DOTPLOT
if dendrogram:
- dendro_frame = _get_dendrogram_path_frame(
- paths, n_x=n_x, n_groups=len(y_order_groups), group_centers=group_centers,
- )
- plot += geom_path(
- data=dendro_frame,
- mapping=aes(x="x", y="y", group="group"),
- color=dendrogram_color,
- size=dendrogram_size,
- **(dendrogram_kwargs or {}),
- )
+ plot += _dendrogram_line_theme(color=dendrogram_color, size=dendrogram_size)
```
-**3. Extend the x-axis limit to include the dendrogram width.** The dendrogram lives at `x in [n_x - 0.5, n_x - 0.5 + n_x*dendrogram_ratio]`. If you set explicit `scale_x_continuous(limits=...)` or have a bordering `geom_rect`, account for this:
+No x limit needs to make room for the tree; it lives in its own band outside the panel.
+
+## Parameters to expose
```python
-x_max_limit = n_x - 0.5
-if dendrogram:
- x_max_limit = dendro_frame["x"].max()
-plot += scale_x_continuous(breaks=list(range(n_x)), labels=x_keys, limits=[-0.5, x_max_limit], expand=[0, 0])
+dendrogram: bool = False,
+dendrogram_color: str = "black",
+dendrogram_size: float = 0.5,
+dendrogram_key: str | None = None,
```
-If a border rectangle frames the data area, draw it from `-0.5` to `n_x - 0.5` so the dendrogram sits **outside** the frame (see dotplot pattern).
-
-## Axis type
-
-The plot must use **continuous numeric** axes (not discrete), because the dendrogram lives at fractional x-coordinates beyond the data area. Pattern:
-
-- Encode positions as floats (`position_x`, `position_y`).
-- Use `scale_x_continuous(breaks=list(range(n_x)), labels=x_keys)` and analogous y scale to fake categorical labels on a continuous axis.
-- Always pass `limits=[...]` and `expand=[0, 0]` when a dendrogram is present, so it isn't clipped.
-
-## Computing `group_centers`
-
-- **Aggregated row-per-group plots** (matrixplot, dotplot, stacked_violin, heatmap with `aggregate=True`): `group_centers = [float(i) for i in range(n_groups)]`.
-- **Per-cell rows** (heatmap with `aggregate=False`): compute the mean `position_y` of cells in each group, sorted by group order. See `_assign_positions` in [heatmap.py](../cellestial/single/heatmap/heatmap.py) for the canonical implementation.
-
## Reference implementations
-- [cellestial/single/heatmap/heatmap.py](../cellestial/single/heatmap/heatmap.py) (per-cell and aggregated)
-- [cellestial/single/heatmap/dotplot.py](../cellestial/single/heatmap/dotplot.py) (with bordering rectangle)
+- [cellestial/single/heatmap/dotplot.py](../cellestial/single/heatmap/dotplot.py) (with bordering rectangle and key-group brackets)
- [cellestial/single/heatmap/stacked_violin.py](../cellestial/single/heatmap/stacked_violin.py)
+- [cellestial/single/heatmap/heatmap.py](../cellestial/single/heatmap/heatmap.py) (native when aggregated, path for per-cell rows)
diff --git a/cellestial/__init__.py b/cellestial/__init__.py
index e21eccab..f082cd60 100644
--- a/cellestial/__init__.py
+++ b/cellestial/__init__.py
@@ -1,7 +1,3 @@
-__lazy_modules__ = ["ggrust"]
-
-from ggrust import LetsPlot
-
from cellestial import datasets
from cellestial._version import __version__, versions
from cellestial.complex import annotated_heatmap
@@ -65,8 +61,6 @@
show_colors,
)
-setup_html = LetsPlot.setup_html
-
__all__ = [
"BLUE",
"CHERRY",
@@ -115,7 +109,6 @@
"ridges",
"save",
"scatter",
- "setup_html",
"show_colors",
"spatial",
"spatials",
@@ -133,13 +126,3 @@
"xyplot",
"xyplots",
]
-
-try:
- from IPython.core.interactiveshell import InteractiveShell
-
- if InteractiveShell.initialized():
- from ggrust import LetsPlot
-
- LetsPlot.setup_html()
-except ImportError:
- pass
diff --git a/cellestial/complex/annotated_heatmap.py b/cellestial/complex/annotated_heatmap.py
index f2a9d7aa..61aef5cc 100644
--- a/cellestial/complex/annotated_heatmap.py
+++ b/cellestial/complex/annotated_heatmap.py
@@ -11,21 +11,26 @@
from ggrust import (
aes,
coord_cartesian,
- element_blank,
element_text,
- geom_path,
geom_raster,
geom_segment,
geom_tile,
- gggrid,
+ geom_xsideline,
+ geom_xsidetile,
+ geom_ysideline,
+ geom_ysidetile,
ggplot,
+ ggside,
ggtb,
+ new_scale_fill,
scale_fill_brewer,
scale_fill_gradient,
scale_fill_gradientn,
scale_fill_manual,
scale_x_continuous,
+ scale_xsidey_discrete,
scale_y_continuous,
+ scale_ysidex_discrete,
theme,
)
from mudata import MuData
@@ -41,13 +46,13 @@
from collections.abc import Sequence
from ggrust.plot.core import FeatureSpec, PlotSpec
- from ggrust.plot.subplots import SupPlotsSpec
AnnotationColors = Mapping[str, str] | Sequence[str]
-# Width of the dendrogram panel, relative to the heatmap.
+# Width of the dendrogram, relative to the heatmap.
_DENDROGRAM_WIDTH = 0.15
+
# ColorBrewer palettes cycled across annotation tracks so each gets a distinct one.
# Qualitative palettes come first (better for categorical tracks), then diverging.
_BREWER_PALETTES = (
@@ -70,9 +75,7 @@
"RdYlGn",
)
-# Legend element sizing. Must live on the subplots that own each legend: the grid
-# theme only controls legend placement/arrangement, not the collected element sizes.
-# `title` carries the bold: lets-plot resolves a legend title's `face` from the
+# Legend element sizing. `title` carries the bold: lets-plot resolves a legend title's `face` from the
# parent `title` element, so setting `face` on `legend_title` alone has no effect.
# Axis titles are blanked here, so this only bolds the (legend) titles.
_LEGEND_THEME = theme(
@@ -240,107 +243,86 @@ def _annotation_scale(
return scale_fill_manual(values=colors, name=series.name)
-def _blank_strip_theme(*, legend: bool) -> FeatureSpec:
- """Void theme shared by annotation tracks, labels, and the dendrogram panel."""
- return (
- _THEME_HEATMAP
- + theme(
- axis_title=element_blank(),
- axis_text=element_blank(),
- axis_ticks=element_blank(),
- axis_line=element_blank(),
- legend_position="bottom" if legend else "none",
- )
- + _LEGEND_THEME
- )
-
-
-def _annotation_strip(
+def _track_layers(
frame: pl.DataFrame,
+ annotations: Sequence[str],
*,
- position: str,
- value: str,
- horizontal: bool,
- legend: bool,
- colors: AnnotationColors | None,
- palette: str,
- position_limits: tuple[float, float],
-) -> PlotSpec:
+ band: Literal["x", "y"],
+ far: bool,
+ colors: Mapping[str, AnnotationColors],
+ palette_of: Mapping[str, str],
+ titles: bool,
+) -> list[FeatureSpec]:
"""
- Build a single one-row (or one-column) annotation track.
+ Build one band's annotation tracks, each with its own fill scale.
Notes
-----
- `horizontal` strips vary along x (column annotations); vertical strips vary
- along y (row annotations). `position_limits` pin the varying axis so the
- track aligns with the heatmap panel. `palette` is the ColorBrewer palette used
- for a categorical track when `colors` is not given.
+ `frame` holds the band's position column (shared with the heatmap axis) and
+ the annotation columns. Each track is a level of the band's discrete cross
+ axis, named after its column. The first listed is the outermost, so on a
+ `far` band (right or top, where the cross axis grows away from the heatmap)
+ the level order is reversed. Without `titles` the band axis has no breaks,
+ so the track names take no room.
"""
- constant = "_strip"
- frame = frame.with_columns(pl.lit(0).alias(constant))
- mapping = (
- aes(x=position, y=constant, fill=value)
- if horizontal
- else aes(x=constant, y=position, fill=value)
- )
- plot = ggplot(frame) + geom_tile(mapping)
- scale = _annotation_scale(frame[value], colors, palette)
- if scale is not None:
- plot += scale
- if horizontal:
- plot += scale_x_continuous(limits=list(position_limits), expand=[0, 0])
- plot += scale_y_continuous(expand=[0, 0])
- else:
- plot += scale_x_continuous(expand=[0, 0])
- plot += scale_y_continuous(limits=list(position_limits), expand=[0, 0])
- plot += _blank_strip_theme(legend=legend)
- return plot
+ side_tile = geom_xsidetile if band == "x" else geom_ysidetile
+ layers: list[FeatureSpec] = []
+ for annotation in annotations:
+ layers.append(new_scale_fill())
+ layers.append(side_tile(aes(fill=annotation), data=frame))
+ layers.append(
+ _annotation_scale(frame[annotation], colors.get(annotation), palette_of[annotation])
+ )
+ if annotations:
+ limits = list(reversed(annotations)) if far else list(annotations)
+ # Track names on the right of an x band, clear of the heatmap's y axis
+ # labels, and on top of a y band: turned names at the bottom would make the
+ # bottom axis area as tall as the longest name. Hidden names keep the band
+ # axis on the main axes' sides, where its line joins theirs.
+ if band == "x":
+ names = {"position": "right"} if titles else {"breaks": []}
+ layers.append(scale_xsidey_discrete(limits=limits, expand=[0, 0], **names))
+ else:
+ names = {"position": "top"} if titles else {"breaks": []}
+ layers.append(scale_ysidex_discrete(limits=limits, expand=[0, 0], **names))
+ return layers
-def _dendrogram_panel(
+def _dendrogram_layer(
paths: pl.DataFrame,
*,
+ band: Literal["x", "y"],
group_centers: list[float],
- n_groups: int,
- position_limits: tuple[float, float],
+ leaf_edge: float,
+ extent: float,
color: str,
size: float,
- side: Literal["left", "right", "top", "bottom"],
kwargs: dict,
-) -> PlotSpec:
+) -> FeatureSpec:
"""
- Build the group dendrogram as a panel on one `side` of the heatmap.
+ Draw the group dendrogram as line segments in a side band.
Notes
-----
- Leaves sit at the group centers (height 0, next to the tracks); the root
- extends away from the heatmap toward `side`. `position_limits` match the
- heatmap observation axis so leaves line up with the group blocks. On a
- left/right side the height runs along x and leaves along y; on a top/bottom
- side the height runs along y and leaves along x.
+ Leaves sit at the group centers along the heatmap's observation axis, at
+ cross position `leaf_edge` next to the tracks; the root lies `extent` cross
+ units away (negative `extent` points toward the left or bottom). Each path
+ is split into two-point segments, since a side line orders its points along
+ the shared axis.
"""
- leaf_positions = np.arange(n_groups, dtype=float)
+ leaf_positions = np.arange(len(group_centers), dtype=float)
leaf = np.interp(paths["x"].to_numpy(), leaf_positions, group_centers)
- height = paths["y"].to_numpy()
- # Root extends away from the heatmap: negative toward left/bottom, positive
- # toward right/top. `expand` keeps a small margin so the root is not clipped.
- if side in ("left", "right"):
- signed_height = -height if side == "left" else height
- frame = pl.DataFrame({"x": signed_height, "y": leaf, "group": paths["group"]})
- height_scale = scale_x_continuous(expand=[0.05, 0])
- leaf_scale = scale_y_continuous(limits=list(position_limits), expand=[0, 0])
+ height = leaf_edge + paths["y"].to_numpy() * extent
+ path_group = paths["group"].to_numpy()
+ starts = np.flatnonzero(path_group[:-1] == path_group[1:])
+ segment = np.repeat(np.arange(starts.size), 2)
+ index = np.column_stack([starts, starts + 1]).ravel()
+ frame = pl.DataFrame({"leaf": leaf[index], "height": height[index], "segment": segment})
+ if band == "x":
+ mapping, side_line = aes(x="leaf", y="height", group="segment"), geom_xsideline
else:
- signed_height = height if side == "top" else -height
- frame = pl.DataFrame({"x": leaf, "y": signed_height, "group": paths["group"]})
- height_scale = scale_y_continuous(expand=[0.05, 0])
- leaf_scale = scale_x_continuous(limits=list(position_limits), expand=[0, 0])
- return (
- ggplot(frame)
- + geom_path(aes("x", "y", group="group"), color=color, size=size, **kwargs)
- + height_scale
- + leaf_scale
- + _blank_strip_theme(legend=False)
- )
+ mapping, side_line = aes(x="height", y="leaf", group="segment"), geom_ysideline
+ return side_line(mapping, data=frame, inherit_aes=False, color=color, size=size, **kwargs)
def annotated_heatmap(
@@ -371,6 +353,7 @@ def annotated_heatmap(
row_annotation_width: float = 0.025,
column_annotation_position: Literal["top", "bottom"] = "top",
row_annotation_position: Literal["left", "right"] = "left",
+ track_titles: bool = False,
color_low: str = "#0000ff",
color_mid: str = "#ffffff",
color_high: str = "#ff0000",
@@ -382,7 +365,7 @@ def annotated_heatmap(
interactive: bool = False,
legend: bool = True,
**geom_kwargs,
-) -> SupPlotsSpec:
+) -> PlotSpec:
"""
Annotated heatmap.
@@ -417,9 +400,8 @@ def annotated_heatmap(
single spec or a sequence of them. A `scale_fill_*` (e.g.
`scale_fill_viridis()`) overrides the default value gradient.
layers_all : FeatureSpec | Sequence[FeatureSpec] | None, default=None
- Extra lets-plot layers to add to every subplot (the heatmap, each
- annotation track, and the dendrogram panel), unlike `layers` which only
- affects the heatmap; a single spec or a sequence of them.
+ Extra lets-plot layers added after `layers`. The heatmap and its tracks
+ are one plot, so this now acts as `layers`; kept for compatibility.
scale_axis : {0, 1} | None, default=None
Standardize values with min-max scaling. If 0, standardize each
variable (column); if 1, standardize each observation (row).
@@ -429,7 +411,8 @@ def annotated_heatmap(
their data: variable tracks to the left, observation tracks and the
dendrogram above the heatmap.
dendrogram : bool, default=False
- Whether to cluster the groups and draw a dendrogram panel to the left.
+ Whether to cluster the groups and draw a dendrogram beside the
+ observation tracks, outermost in their side band.
The clustering is computed if not already available. Requires `group_by`.
group_lines : bool, default=True
Whether to draw horizontal separator lines between groups.
@@ -459,6 +442,11 @@ def annotated_heatmap(
Which side of the heatmap the row (observation) tracks and the
dendrogram sit on. Under `transpose` this reflects to the top/bottom
side (left to top, right to bottom).
+ track_titles : bool, default=False
+ Whether to name each annotation track beside its band. Hidden by
+ default, since the legends already name the tracks; set to `True` to
+ show them (a top or bottom band's names on its right, a left or right
+ band's names above it).
color_low : str, default='#0000ff'
Color for low values in the heatmap gradient.
color_mid : str, default='#ffffff'
@@ -487,8 +475,8 @@ def annotated_heatmap(
Returns
-------
- SupPlotsSpec
- The composite annotated heatmap.
+ PlotSpec
+ The annotated heatmap, one plot with its tracks in side bands.
Raises
------
@@ -500,8 +488,10 @@ def annotated_heatmap(
Notes
-----
Rows are observations and columns are the variable `keys`. Variable tracks
- sit above the heatmap and observation tracks to its left. The panels are
- composed with `gggrid`, each keeping its own legend.
+ sit above the heatmap and observation tracks to its left, in ggside side
+ bands that share the heatmap's axes (and its zoom, with `interactive`).
+ Each track has its own fill scale (`new_scale_fill`) and legend, and is
+ labelled on its band's axis.
When `dendrogram=True` and the requested dendrogram is absent from
`data.uns`, plot construction calls `scanpy.tl.dendrogram` and stores the
@@ -533,7 +523,6 @@ def annotated_heatmap(
column_annotations = _as_list(column_annotations)
row_annotations = _as_list(row_annotations)
annotation_colors = annotation_colors or {}
- shared_layers = _as_layers(layers_all)
if dendrogram and group_by is None:
msg = "`group_by` is required when `dendrogram` is set."
@@ -594,33 +583,36 @@ def annotated_heatmap(
# Axis roles. `position_x` holds the variable-key index, `position_y` the
# observation index; `transpose` swaps which is drawn on the visual x-axis.
- # The variable axis carries the tick labels; the observation axis is blanked.
+ # The variable axis carries the tick labels; the observation axis has no breaks
+ # (a theme blank would also hide the side band's track names on that axis).
if transpose:
heatmap_x, heatmap_y = "position_y", "position_x"
key_scale = scale_y_continuous(
breaks=list(range(n_keys)), labels=keys, limits=list(key_limits), expand=[0, 0]
)
- observation_scale = scale_x_continuous(limits=list(observation_limits), expand=[0, 0])
- blank_observation_axis = theme(axis_text_x=element_blank(), axis_ticks_x=element_blank())
+ observation_scale = scale_x_continuous(
+ breaks=[], limits=list(observation_limits), expand=[0, 0]
+ )
else:
heatmap_x, heatmap_y = "position_x", "position_y"
key_scale = scale_x_continuous(
breaks=list(range(n_keys)), labels=keys, limits=list(key_limits), expand=[0, 0]
)
- observation_scale = scale_y_continuous(limits=list(observation_limits), expand=[0, 0])
- blank_observation_axis = theme(axis_text_y=element_blank(), axis_ticks_y=element_blank())
+ observation_scale = scale_y_continuous(
+ breaks=[], limits=list(observation_limits), expand=[0, 0]
+ )
# MAIN heatmap. `raster` renders the cells as one image (fast for many cells);
- # `tile` draws individual cells and supports tooltips. Aspect stays controllable
- # through the grid widths/heights.
- aes_main = aes(heatmap_x, heatmap_y, fill=value_column, **mapping.as_dict())
+ # `tile` draws individual cells and supports tooltips. The plot maps only the
+ # positions, which the side tracks inherit along the shared axis.
+ aes_main = aes(fill=value_column, **mapping.as_dict())
geom_layer = (
geom_raster(aes_main, **geom_kwargs)
if geom == "raster"
else geom_tile(aes_main, **geom_kwargs)
)
- htmp = (
- ggplot(frame)
+ plot = (
+ ggplot(frame, aes(heatmap_x, heatmap_y))
+ geom_layer
+ _fill_gradient(
frame[value_column],
@@ -629,22 +621,11 @@ def annotated_heatmap(
color_high=color_high,
midpoint=midpoint,
)
- + key_scale
- + observation_scale
- # `geom_raster` otherwise locks to square pixels (collapsing a few-column,
- # many-row heatmap); `coord_cartesian` frees the aspect for the grid to set.
- + coord_cartesian()
- + _THEME_HEATMAP
- + blank_observation_axis
- + theme(legend_position="bottom" if legend else "none")
- + _LEGEND_THEME
)
- if interactive:
- htmp += ggtb(size_zoomin=-1)
- # Extra user layers for the heatmap, a single spec or a sequence. A `scale_fill_*`
- # here overrides the default value gradient (lets-plot keeps the last scale).
- htmp = _apply_layers(htmp, _as_layers(layers))
- htmp = _apply_layers(htmp, shared_layers)
+ # Extra user layers, a single spec or a sequence, added before the tracks' new
+ # fill scales so a `scale_fill_*` here overrides the default value gradient.
+ plot = _apply_layers(plot, _as_layers(layers))
+ plot = _apply_layers(plot, _as_layers(layers_all))
# GROUP separator lines between contiguous blocks, spanning the variable axis.
if group_lines and group_order is not None and len(group_order) > 1:
@@ -656,22 +637,24 @@ def annotated_heatmap(
boundaries.append(n_observations - running - 0.5)
if transpose:
lines = pl.DataFrame({"x": boundaries})
- htmp += geom_segment(
+ plot += geom_segment(
data=lines,
mapping=aes(x="x", xend="x"),
y=-0.5,
yend=n_keys - 0.5,
+ inherit_aes=False,
color=group_lines_color,
size=group_lines_size,
**(group_lines_kwargs or {}),
)
else:
lines = pl.DataFrame({"y": boundaries})
- htmp += geom_segment(
+ plot += geom_segment(
data=lines,
mapping=aes(y="y", yend="y"),
x=-0.5,
xend=n_keys - 0.5,
+ inherit_aes=False,
color=group_lines_color,
size=group_lines_size,
**(group_lines_kwargs or {}),
@@ -679,8 +662,8 @@ def annotated_heatmap(
# Resolve which side each annotation group sits on. `transpose` reflects the
# requested position across the main diagonal (left<->top, right<->bottom),
- # since it swaps which axis is horizontal. A top/bottom side means the track
- # is a horizontal strip; a left/right side means a vertical strip.
+ # since it swaps which axis is horizontal. A top/bottom side is the x side
+ # band (sharing the x axis); a left/right side is the y side band.
reflect: dict[str, Literal["left", "right", "top", "bottom"]] = {
"left": "top",
"right": "bottom",
@@ -698,8 +681,9 @@ def annotated_heatmap(
for index, name in enumerate([*column_annotations, *row_annotations])
}
- # COLUMN annotation tracks (variable metadata), drawn along the variable axis.
- column_strips: list[PlotSpec] = []
+ # COLUMN annotation tracks (variable metadata), in the band along the variable axis.
+ variable_band: Literal["x", "y"] = "x" if variable_side in ("top", "bottom") else "y"
+ variable_layers: list[FeatureSpec] = []
if column_annotations:
variables = build_frame(
data=data,
@@ -709,37 +693,33 @@ def annotated_heatmap(
).filter(pl.col(variable_id_column).is_in(keys))
variables = variables.with_columns(
pl.col(variable_id_column).replace_strict(position_x).alias("position_x")
+ ).select("position_x", *column_annotations)
+ variable_layers = _track_layers(
+ variables,
+ column_annotations,
+ band=variable_band,
+ far=variable_side in ("right", "top"),
+ colors=annotation_colors,
+ palette_of=palette_of,
+ titles=track_titles,
)
- for annotation in column_annotations:
- strip = _annotation_strip(
- variables.select("position_x", annotation),
- position="position_x",
- value=annotation,
- horizontal=variable_side in ("top", "bottom"),
- legend=legend,
- colors=annotation_colors.get(annotation),
- palette=palette_of[annotation],
- position_limits=key_limits,
- )
- column_strips.append(_apply_layers(strip, shared_layers))
-
- # ROW annotation tracks (observation metadata), drawn along the observation axis.
- row_strips: list[PlotSpec] = []
- for annotation in row_annotations:
- strip = _annotation_strip(
- observations.select("position_y", annotation),
- position="position_y",
- value=annotation,
- horizontal=observation_side in ("top", "bottom"),
- legend=legend,
- colors=annotation_colors.get(annotation),
- palette=palette_of[annotation],
- position_limits=observation_limits,
- )
- row_strips.append(_apply_layers(strip, shared_layers))
- # DENDROGRAM panel (group clustering), drawn alongside the observation axis.
- dendrogram_panel = None
+ # ROW annotation tracks (observation metadata), in the band along the observation axis.
+ observation_band: Literal["x", "y"] = "y" if variable_band == "x" else "x"
+ observation_far = observation_side in ("right", "top")
+ observation_layers = _track_layers(
+ observations.select("position_y", *row_annotations),
+ row_annotations,
+ band=observation_band,
+ far=observation_far,
+ colors=annotation_colors,
+ palette_of=palette_of,
+ titles=track_titles,
+ )
+
+ # DENDROGRAM (group clustering), outermost in the observation band. Groups span
+ # many rows, so an axis dendrogram (one leaf per discrete level) cannot be used.
+ observation_thickness = row_annotation_width * len(row_annotations)
if dendrogram:
assert group_by is not None
assert dendrogram_paths is not None
@@ -748,111 +728,49 @@ def annotated_heatmap(
pl.col("position_y").mean().alias("_center")
)
center_of = dict(zip(centers[group_by].cast(pl.String), centers["_center"], strict=True))
- group_centers = [center_of[group] for group in group_order]
- dendrogram_panel = _apply_layers(
- _dendrogram_panel(
+ # The tree spans `_DENDROGRAM_WIDTH` of the heatmap; cross units are tracks.
+ extent = _DENDROGRAM_WIDTH / row_annotation_width
+ leaf_edge = len(row_annotations) - 0.5 if observation_far else -0.5
+ observation_layers.append(
+ _dendrogram_layer(
dendrogram_paths,
- group_centers=group_centers,
- n_groups=len(group_order),
- position_limits=observation_limits,
+ band=observation_band,
+ group_centers=[center_of[group] for group in group_order],
+ leaf_edge=leaf_edge,
+ extent=extent if observation_far else -extent,
color=dendrogram_color,
size=dendrogram_size,
- side=observation_side,
kwargs=dendrogram_kwargs or {},
- ),
- shared_layers,
- )
-
- # COMPOSE: the observation group (dendrogram outermost, then observation
- # tracks) and the variable group (variable tracks). One group flanks the
- # heatmap horizontally (left/right side), the other vertically (top/bottom),
- # since their axes are perpendicular. First listed track is the outermost;
- # each keeps its per-track thickness.
- dendrogram_thickness = [_DENDROGRAM_WIDTH] if dendrogram_panel is not None else []
- observation_panels = ([dendrogram_panel] if dendrogram_panel is not None else []) + row_strips
- observation_thickness = dendrogram_thickness + [row_annotation_width] * len(row_strips)
- variable_thickness = [column_annotation_height] * len(column_strips)
- if observation_side in ("left", "right"):
- flank_panels, flank_thickness, flank_side = (
- observation_panels,
- observation_thickness,
- observation_side,
- )
- stack_panels, stack_thickness, stack_side = (
- column_strips,
- variable_thickness,
- variable_side,
- )
- else:
- flank_panels, flank_thickness, flank_side = (
- column_strips,
- variable_thickness,
- variable_side,
- )
- stack_panels, stack_thickness, stack_side = (
- observation_panels,
- observation_thickness,
- observation_side,
+ )
)
+ observation_thickness += _DENDROGRAM_WIDTH
- # Heatmap row: flank tracks sit to the left or right of the heatmap; on the
- # right the first-listed (outermost) track ends up farthest out, so reverse.
- if flank_side == "left":
- row_cells, widths, heatmap_column = (
- [*flank_panels, htmp],
- [*flank_thickness, 1.0],
- len(flank_panels),
- )
- else:
- row_cells = [htmp, *reversed(flank_panels)]
- widths = [1.0, *reversed(flank_thickness)]
- heatmap_column = 0
- n_columns = len(row_cells)
-
- def _stack_row(panel: PlotSpec) -> list[PlotSpec | None]:
- """A grid row holding one stacked track in the heatmap column, else empty."""
- row: list[PlotSpec | None] = [None] * n_columns
- row[heatmap_column] = panel
- return row
-
- # Stack tracks sit above or below the heatmap; reverse for bottom so the
- # first-listed (outermost) track ends up farthest out.
- if stack_side == "top":
- top_panels, top_thickness, bottom_panels, bottom_thickness = (
- stack_panels,
- stack_thickness,
- [],
- [],
- )
- else:
- top_panels, top_thickness = [], []
- bottom_panels, bottom_thickness = (
- list(reversed(stack_panels)),
- list(reversed(stack_thickness)),
+ # COMPOSE: both bands in one plot, each sized by its tracks (and the tree).
+ thickness = {
+ variable_band: column_annotation_height * len(column_annotations),
+ observation_band: observation_thickness,
+ }
+ plot = _apply_layers(plot, variable_layers + observation_layers)
+ plot += (
+ key_scale
+ + observation_scale
+ # `geom_raster` otherwise locks to square pixels (collapsing a few-column,
+ # many-row heatmap); `coord_cartesian` frees the aspect.
+ + coord_cartesian()
+ + ggside(
+ x_pos=variable_side if variable_band == "x" else observation_side,
+ y_pos=variable_side if variable_band == "y" else observation_side,
)
-
- plots: list[PlotSpec | None] = []
- for panel in top_panels:
- plots.extend(_stack_row(panel))
- plots.extend(row_cells)
- for panel in bottom_panels:
- plots.extend(_stack_row(panel))
-
- heights = [*top_thickness, 1.0, *bottom_thickness]
- grid = gggrid(
- plots,
- ncol=n_columns,
- widths=widths,
- heights=heights,
- align=True,
- guides="collect" if legend else None, # ty:ignore[invalid-argument-type]
- )
- if legend:
- # The grid only places/arranges the collected legends (one bottom row); their
- # element sizes come from the subplots' `_LEGEND_THEME`, not from here.
- grid += theme(
- legend_position="bottom",
+ + _THEME_HEATMAP
+ + theme(
+ ggside_panel_scale_x=thickness["x"] or None,
+ ggside_panel_scale_y=thickness["y"] or None,
+ legend_position="bottom" if legend else "none",
legend_box="horizontal",
legend_direction="horizontal",
)
- return grid
+ + _LEGEND_THEME
+ )
+ if interactive:
+ plot += ggtb(size_zoomin=-1)
+ return plot
diff --git a/cellestial/layers/arrow.py b/cellestial/layers/arrow.py
index fab10829..b03d762b 100644
--- a/cellestial/layers/arrow.py
+++ b/cellestial/layers/arrow.py
@@ -4,154 +4,72 @@
from typing import TYPE_CHECKING, Literal
-import polars as pl
-from ggrust import (
- aes,
- arrow,
- element_blank,
- element_text,
- geom_blank,
- geom_path,
- geom_segment,
- theme,
-)
-
-from cellestial.layers._deferred import DeferredLayer
-from cellestial.util import get_mapping
-from cellestial.util.errors import MissingAestheticError
+from ggrust import arrow, element_blank, element_line, element_text, geom_blank, theme
if TYPE_CHECKING:
- from ggrust.plot.core import FeatureSpecArray, PlotSpec
- from polars import DataFrame
+ from ggrust.plot.core import FeatureSpec
-# fraction of each axis arm drawn as the arrowhead-bearing tip segment;
-# ggrust caps the arrowhead to ~half the segment length, so the tip must be
-# long enough to render a full-size head
-_TIP_FRACTION = 0.3
+# geom line widths are drawn at `size * 2.2` px, theme lines at `size` px;
+# scaling keeps `size` in geom units so the arrows match the former layer version
+_GEOM_LINE_UNIT = 2.2
-def _axis_arrow_layers(
- frame: DataFrame,
+def _arrow_theme(
*,
- x: str,
- y: str,
length: float,
size: float,
color: str,
angle: float,
+ linetype: int | str | None = None,
arrow_kwargs: dict | None = None,
- geom_kwargs: dict | None = None,
-) -> FeatureSpecArray:
+) -> FeatureSpec:
"""
- Build the arrow-axis as an L-shaped path plus two arrowhead tip segments.
+ Build the arrow axis as theme axis lines with arrowheads.
Parameters
----------
- frame : `polars.DataFrame`
- DataFrame holding the `x` and `y` columns.
- x : str
- Name of the x axis column.
- y : str
- Name of the y axis column.
length : float
- Length of each axis arm as a fraction of its data range.
+ Span of each axis line as a fraction of the panel (0.25 covers 25%).
size : float
- Line width of the path and tip segments.
+ Width of the axis lines, in geom line-width units.
color : str
- Color of the path and tip segments.
+ Color of the axis lines.
angle : float
Angle of the arrowhead in degrees.
- arrow_kwargs : dict | None
- Additional parameters for the `arrow` layer.
- geom_kwargs : dict | None
- Additional parameters for the `geom_path` and `geom_segment` layers.
+ linetype : int | str | None, default=None
+ Line type of the axis lines, by number or name.
+ arrow_kwargs : dict | None, default=None
+ Additional parameters for `arrow`.
Returns
-------
- `FeatureSpecArray`
- The path and tip-segment layers.
-
- Notes
- -----
- The semi-square L is one continuous `geom_path` that stops short of each
- open end so the corner joins cleanly with no overlap. Each tip is completed
- by a `geom_segment` carrying the arrowhead, meeting the path end-to-end.
+ `FeatureSpec`
+ Theme feature specification.
"""
- arrow_kwargs = arrow_kwargs or {}
- geom_kwargs = geom_kwargs or {}
-
- bounds = frame.select(
- pl.col(x).min().alias("x_min"),
- pl.col(x).max().alias("x_max"),
- pl.col(y).min().alias("y_min"),
- pl.col(y).max().alias("y_max"),
- ).row(0, named=True)
- if any(value is None for value in bounds.values()):
- msg = "Arrow axes require at least one valid coordinate pair."
- raise ValueError(msg)
-
- x_min = bounds["x_min"]
- x_max = bounds["x_max"]
- y_min = bounds["y_min"]
- y_max = bounds["y_max"]
-
- # find total difference between the max and min for both axis
- x_diff = x_max - x_min
- y_diff = y_max - y_min
-
- # find the ends of the arrows
- xend = x_min + length * x_diff
- yend = y_min + length * y_diff
-
- # adjust bottom ends of arrows
- adjust_rate = 0.025
- x0 = x_min - x_diff * adjust_rate
- y0 = y_min - y_diff * adjust_rate
-
- # tip segments carry the arrowheads; the path stops short of each tip
- tip_x = (xend - x0) * _TIP_FRACTION
- tip_y = (yend - y0) * _TIP_FRACTION
-
- # semi-square L (stopping short of both tips), drawn as one connected path
- path_frame = pl.DataFrame({"x": [xend - tip_x, x0, x0], "y": [y0, y0, yend - tip_y]})
-
- layers = geom_path(
- data=path_frame,
- mapping=aes(x="x", y="y"),
- color=color,
- size=size,
- **geom_kwargs,
- )
- # X axis tip
- layers += geom_segment(
- x=xend - tip_x,
- y=y0,
- xend=xend,
- yend=y0,
+ line = element_line(
color=color,
- size=size,
- arrow=arrow(angle, **arrow_kwargs),
- **geom_kwargs,
+ size=size * _GEOM_LINE_UNIT,
+ linetype=linetype,
+ arrow=arrow(angle, **(arrow_kwargs or {})),
+ extent=length,
)
- # Y axis tip
- layers += geom_segment(
- x=x0,
- y=yend - tip_y,
- xend=x0,
- yend=yend,
- color=color,
- size=size,
- arrow=arrow(angle, **arrow_kwargs),
- **geom_kwargs,
+ return theme(
+ # remove axis elements
+ axis_text_x=element_blank(),
+ axis_text_y=element_blank(),
+ axis_ticks_y=element_blank(),
+ axis_ticks_x=element_blank(),
+ # the default theme blanks `axis_line_y`, so set both lines explicitly
+ axis_line_x=line,
+ axis_line_y=line,
+ # position axis titles according to arrow size
+ axis_title_x=element_text(hjust=length / 2.5), # better than 2
+ axis_title_y=element_text(hjust=length / 2.5),
)
- return layers
def _modify_axis(
- frame: DataFrame,
*,
- x: str,
- y: str,
axis_type: Literal["axis", "arrow"] | None,
arrow_size: float,
arrow_color: str,
@@ -163,12 +81,6 @@ def _modify_axis(
Parameters
----------
- frame : `polars.DataFrame`
- DataFrame copied from the single cell data.
- x : str
- Name of the x axis column.
- y : str
- Name of the y axis column.
axis_type : {'axis', 'arrow'} | None
Whether to use regular axis or arrows as the axis.
arrow_size : float
@@ -178,15 +90,12 @@ def _modify_axis(
arrow_angle : float
Angle of the arrow head in degrees.
arrow_length : float
- Span of each axis line as a fraction of its data range (0.25 covers 25%).
+ Span of each axis line as a fraction of the panel (0.25 covers 25%).
Returns
-------
`FeatureSpec` or `FeatureSpecArray`
Theme feature specification.
-
- for more information on the arrow parameters, see:
- https://lets-plot.org/python/pages/api/lets_plot.arrow.html
"""
if axis_type is None:
return theme(
@@ -202,21 +111,7 @@ def _modify_axis(
return geom_blank()
elif axis_type == "arrow":
- new_layer = theme(
- # remove axis elements
- axis_text_x=element_blank(),
- axis_text_y=element_blank(),
- axis_ticks_y=element_blank(),
- axis_ticks_x=element_blank(),
- axis_line=element_blank(),
- # position axis titles according to arrow size
- axis_title_x=element_text(hjust=arrow_length / 2.5), # better than 2
- axis_title_y=element_text(hjust=arrow_length / 2.5),
- )
- new_layer += _axis_arrow_layers(
- frame,
- x=x,
- y=y,
+ return _arrow_theme(
length=arrow_length,
size=arrow_size,
color=arrow_color,
@@ -226,28 +121,21 @@ def _modify_axis(
msg = f"expected 'axis' or 'arrow' for 'axis_type' argument, but received {axis_type}"
raise ValueError(msg)
- return new_layer
-
def arrow_axis(
*,
- plot: PlotSpec | None = None,
size: float = 1,
length: float = 0.25,
angle: float = 10,
color: str = "#3f3f3f",
+ linetype: int | str | None = None,
arrow_kwargs: dict | None = None,
- **geom_kwargs,
-) -> DeferredLayer:
+) -> FeatureSpec:
"""
- Layer of arrows as the X and Y axis to the plot.
+ Theme with arrows as the X and Y axis of the plot.
Parameters
----------
- plot : PlotSpec | None, default=None
- If provided, the arrow axis is built from this plot's data and aesthetics
- regardless of which plot the resulting layer is added to. When `None`,
- the layer is deferred and introspects the plot it is added to via `+`.
size : float
Width of the axis lines.
color : str
@@ -255,28 +143,20 @@ def arrow_axis(
angle : float
Angle of the arrow head in degrees.
length : float
- Span of each axis line as a fraction of its data range (0.25 covers 25%).
+ Span of each axis line as a fraction of the panel (0.25 covers 25%).
+ linetype : int | str | None, default=None
+ Line type of the axis lines, by number or name. E.g "dashed".
arrow_kwargs : dict | None, default=None
- Additional parameters for the `arrow` layer. E.g {"type": "closed"}.
- For more information on arrow parameters, see:
- https://lets-plot.org/python/pages/api/lets_plot.arrow.html
- **geom_kwargs
- Additional parameters for the `geom_path` and `geom_segment` layers.
- For more information on geom_path parameters, see:
- https://lets-plot.org/python/pages/api/lets_plot.geom_path.html
+ Additional parameters for `arrow`. E.g {"type": "closed"}.
Returns
-------
- DeferredLayer
-
- Raises
- ------
- MissingAestheticError
- If `x` or `y` cannot be inferred from the receiving plot.
+ `FeatureSpec`
+ Theme feature specification.
Notes
-----
- Uses `geom_path` to draw the axis lines and `geom_segment` for the arrowheads.
+ Uses ggrust arrow axes: `element_line(arrow=..., extent=...)` on the axis lines.
Examples
--------
@@ -323,42 +203,11 @@ def arrow_axis(
umap + cl.arrow_axis(length=0.20,color="dark_violet")
"""
- explicit_plot = plot
-
- def _build(receiving_plot: PlotSpec) -> FeatureSpecArray:
- source = explicit_plot if explicit_plot is not None else receiving_plot
- frame = source.get_plot_shared_data()
- mapping = get_mapping(source, index=0)
- x = mapping.get("x")
- y = mapping.get("y")
- if x is None:
- msg = "`x` is present neither as argument nor in the plot aesthetics."
- raise MissingAestheticError(msg)
- if y is None:
- msg = "`y` is present neither as argument nor in the plot aesthetics."
- raise MissingAestheticError(msg)
- new_layer = theme(
- # remove axis elements
- axis_text_x=element_blank(),
- axis_text_y=element_blank(),
- axis_ticks_y=element_blank(),
- axis_ticks_x=element_blank(),
- axis_line=element_blank(),
- # position axis titles according to arrow size
- axis_title_x=element_text(hjust=length / 2.5), # better than 2
- axis_title_y=element_text(hjust=length / 2.5),
- )
- new_layer += _axis_arrow_layers(
- frame,
- x=x,
- y=y,
- length=length,
- size=size,
- color=color,
- angle=angle,
- arrow_kwargs=arrow_kwargs,
- geom_kwargs=geom_kwargs,
- )
- return new_layer
-
- return DeferredLayer(_build)
+ return _arrow_theme(
+ length=length,
+ size=size,
+ color=color,
+ angle=angle,
+ linetype=linetype,
+ arrow_kwargs=arrow_kwargs,
+ )
diff --git a/cellestial/layers/bracket.py b/cellestial/layers/bracket.py
index f66b67b2..32f2ffda 100644
--- a/cellestial/layers/bracket.py
+++ b/cellestial/layers/bracket.py
@@ -1,14 +1,12 @@
from __future__ import annotations
-__lazy_modules__ = ["ggrust", "numpy", "scipy.stats"]
+__lazy_modules__ = ["ggrust"]
from itertools import combinations
from typing import TYPE_CHECKING, Literal
-import numpy as np
import polars as pl
-from ggrust import aes, geom_bracket
-from scipy.stats import mannwhitneyu, ttest_ind
+from ggrust import aes, stat_compare_means
from cellestial.layers._deferred import DeferredLayer
from cellestial.util import get_mapping, retrieve
@@ -20,53 +18,17 @@
from ggrust.plot.core import FeatureSpec, LayerSpec, PlotSpec
from polars import DataFrame
+_TEST_METHODS = {"mannwhitney": "wilcox.test", "ttest": "t.test"}
+_CORRECTION_METHODS = {"none": "none", "bonferroni": "bonferroni", "fdr_bh": "BH"}
+_LABELS = {"stars": "p.signif", "pvalue": "p.format"}
-def _correct_pvalues(
- pvalues: np.ndarray,
- method: Literal["none", "bonferroni", "fdr_bh"],
-) -> np.ndarray:
- """Adjust p-values for multiple testing."""
- if method == "none":
- return pvalues
- if method == "bonferroni":
- return np.minimum(pvalues * len(pvalues), 1.0)
- if method == "fdr_bh":
- n = len(pvalues)
- order = np.argsort(pvalues)
- ranked = pvalues[order]
- adjusted = ranked * n / np.arange(1, n + 1)
- # enforce monotonicity from the largest p-value downwards
- adjusted = np.minimum.accumulate(adjusted[::-1])[::-1]
- adjusted = np.minimum(adjusted, 1.0)
- result = np.empty_like(adjusted)
- result[order] = adjusted
- return result
- msg = f"`correction` must be one of 'none', 'bonferroni', 'fdr_bh'. Received: {method!r}"
- raise ValueError(msg)
-
-
-_STAR_THRESHOLDS = (
- (0.0001, "****"),
- (0.001, "***"),
- (0.01, "**"),
- (0.05, "*"),
-)
-
-
-def _significance_stars(pvalue: float) -> str:
- """Convert a p-value into an asterisk significance label."""
- for threshold, stars in _STAR_THRESHOLDS:
- if pvalue < threshold:
- return stars
- return "ns"
-
-
-def _star_threshold(pvalue: float) -> float | None:
- """Return the star-bracket upper bound for a p-value, or None if not significant."""
- for threshold, _ in _STAR_THRESHOLDS:
- if pvalue < threshold:
- return threshold
- return None
+
+def _option(name: str, value: str, options: dict[str, str]) -> str:
+ """Translate a cellestial option into its `stat_compare_means` name."""
+ if value not in options:
+ msg = f"`{name}` must be one of {list(options)}. Received: {value!r}"
+ raise ValueError(msg)
+ return options[value]
def _expand_comparisons(
@@ -117,26 +79,17 @@ def _add(group_a: str, group_b: str) -> None:
return expanded
-def _compute_bracket_frame(
+def _bracket_layout(
frame: DataFrame,
*,
x: str,
y: str,
comparisons: Sequence[Sequence[str]] | None,
- test: Literal["mannwhitney", "ttest", "student"],
- alternative: Literal["two-sided", "less", "greater"],
- correction: Literal["none", "bonferroni", "fdr_bh"],
- label: Literal["stars", "pvalue", "padj"] | Sequence[Literal["stars", "pvalue", "padj"]],
- label_format: str,
- prefix: str,
- prefix_style: Literal["=", "<"] | None,
- separator: str,
- threshold: float | None,
y_position: float | None,
y_step: float | None,
y_padding: float,
-) -> DataFrame:
- """Build a `geom_bracket` DataFrame from pairwise significance tests."""
+) -> tuple[DataFrame, list[tuple[str, str]], list[float]]:
+ """Return the finite rows, the group pairs and the bracket heights."""
finite_frame = frame.filter(pl.col(y).is_not_null() & pl.col(y).is_finite())
# determine which pairs to compare
@@ -150,38 +103,13 @@ def _compute_bracket_frame(
msg = "No group pairs available to compare."
raise ValueError(msg)
- # run the pairwise test
- test_functions = {
- "mannwhitney": lambda a, b: mannwhitneyu(a, b, alternative=alternative),
- # Welch's, matching `t.test()` in R and scanpy's own `t-test` ranking.
- "ttest": lambda a, b: ttest_ind(a, b, alternative=alternative, equal_var=False),
- "student": lambda a, b: ttest_ind(a, b, alternative=alternative, equal_var=True),
- }
- if test not in test_functions:
- msg = f"`test` must be one of {list(test_functions)}. Received: {test!r}"
- raise ValueError(msg)
- run_test = test_functions[test]
-
- records = []
- invalid_comparisons = []
- for group_a, group_b in pairs:
- values_a = finite_frame.filter(pl.col(x) == group_a)[y].to_numpy()
- values_b = finite_frame.filter(pl.col(x) == group_b)[y].to_numpy()
- if len(values_a) < 2 or len(values_b) < 2:
- invalid_comparisons.append(
- f"{group_a!r} ({len(values_a)}) vs {group_b!r} ({len(values_b)})"
- )
- continue
- result = run_test(values_a, values_b)
- records.append(
- {
- "xmin": group_a,
- "xmax": group_b,
- "statistic": float(result.statistic),
- "pvalue": float(result.pvalue),
- }
- )
-
+ # every compared group needs at least 2 finite observations
+ counts = dict(finite_frame[x].value_counts().iter_rows())
+ invalid_comparisons = [
+ f"{group_a!r} ({counts.get(group_a, 0)}) vs {group_b!r} ({counts.get(group_b, 0)})"
+ for group_a, group_b in pairs
+ if counts.get(group_a, 0) < 2 or counts.get(group_b, 0) < 2
+ ]
if invalid_comparisons:
prefix = (
"No valid group comparisons available. "
@@ -195,90 +123,24 @@ def _compute_bracket_frame(
)
raise ValueError(msg)
- brackets = pl.DataFrame(records)
-
- # adjust for multiple testing
- pvalue_adjusted = _correct_pvalues(brackets["pvalue"].to_numpy(), method=correction)
- brackets = brackets.with_columns(pvalue_adj=pl.Series(pvalue_adjusted))
-
- # filter by threshold
- if threshold is not None:
- pvalue_column = "pvalue_adj" if correction != "none" else "pvalue"
- brackets = brackets.filter(pl.col(pvalue_column) <= threshold)
- if len(brackets) == 0:
- return brackets
-
- # build the label column - one component per label kind, merged row-wise
- label_kinds = [label] if isinstance(label, str) else list(label)
- valid_kinds = {"stars", "pvalue", "padj"}
- invalid = [kind for kind in label_kinds if kind not in valid_kinds]
- if invalid:
- msg = (
- "`label` entries must be from 'stars', 'pvalue', 'padj'. "
- f"Received invalid: {invalid!r}"
- )
- raise ValueError(msg)
- if len(label_kinds) == 0:
- msg = "`label` must contain at least one entry."
- raise ValueError(msg)
-
- pvalues_raw = brackets["pvalue"].to_list()
- pvalues_adj = brackets["pvalue_adj"].to_list()
- pvalues_for_stars = brackets["pvalue_adj" if correction != "none" else "pvalue"].to_list()
-
- def _format_pvalue(pvalue: float) -> str:
- if prefix_style == "<":
- threshold = _star_threshold(pvalue)
- symbol, value = (">", 0.05) if threshold is None else ("<", threshold)
- return f"{prefix} {symbol} {value:{label_format}}".lstrip()
- if prefix_style == "=":
- if not prefix:
- return f"{pvalue:{label_format}}"
- return f"{prefix} = {pvalue:{label_format}}"
- if prefix_style is None:
- return f"{prefix}{pvalue:{label_format}}"
- msg = f"`prefix_style` must be '=', '<', or None. Received: {prefix_style!r}"
- raise ValueError(msg)
-
- components = []
- for kind in label_kinds:
- if kind == "stars":
- components.append([_significance_stars(pvalue) for pvalue in pvalues_for_stars])
- elif kind == "pvalue":
- components.append([_format_pvalue(pvalue) for pvalue in pvalues_raw])
- else: # "padj"
- components.append([_format_pvalue(pvalue) for pvalue in pvalues_adj])
-
- labels = [separator.join(parts) for parts in zip(*components, strict=True)]
- brackets = brackets.with_columns(label=pl.Series(labels))
-
- # compute y positions so brackets stack above the data without overlapping
-
+ # stack the brackets above the data without overlapping
data_min = finite_frame[y].min()
data_max = finite_frame[y].max()
data_range = data_max - data_min # ty:ignore[unsupported-operator]
_y_position = data_max + data_range * y_padding if y_position is None else y_position
_y_step = data_range * y_padding if y_step is None else y_step
- positions = [_y_position + _y_step * index for index in range(len(brackets))]
- brackets = brackets.with_columns(y=pl.Series(positions))
-
- return brackets
+ positions = [_y_position + _y_step * index for index in range(len(pairs))]
+ return finite_frame, pairs, positions
def bracket(
*,
plot: PlotSpec | None = None,
comparisons: Sequence[Sequence[str]] | None = None,
- test: Literal["mannwhitney", "ttest", "student"] = "mannwhitney",
- alternative: Literal["two-sided", "less", "greater"] = "two-sided",
+ test: Literal["mannwhitney", "ttest"] = "mannwhitney",
correction: Literal["none", "bonferroni", "fdr_bh"] = "fdr_bh",
- label: Literal["stars", "pvalue", "padj"]
- | Sequence[Literal["stars", "pvalue", "padj"]] = "stars",
- label_format: str = ".3g",
- prefix: str = "",
- prefix_style: Literal["=", "<"] | None = "=",
- separator: str = " ",
- threshold: float | None = None,
+ label: Literal["stars", "pvalue"] = "stars",
+ hide_ns: bool = False,
y_position: float | None = None,
y_step: float | None = None,
y_padding: float = 0.08,
@@ -291,7 +153,7 @@ def bracket(
**geom_kwargs,
) -> DeferredLayer:
"""
- Layer of `geom_bracket` annotating pairwise significance between groups.
+ Layer of `stat_compare_means` annotating pairwise significance between groups.
Parameters
----------
@@ -305,47 +167,21 @@ def bracket(
If None, every pair of groups present in the plot is compared.
`"*"` on either side expands to all other groups, so
`[("A", "*")]` compares `A` against every remaining group.
- test : {'mannwhitney', 'ttest', 'student'}, default='mannwhitney'
- Statistical test used for each pairwise comparison.
- 'mannwhitney' is non-parametric and recommended for expression data.
+ test : {'mannwhitney', 'ttest'}, default='mannwhitney'
+ Two-sided statistical test used for each pairwise comparison, as in R.
+ 'mannwhitney' is the Wilcoxon rank-sum test, non-parametric and
+ recommended for expression data; it is exact below 50 tie-free values
+ per group (R's `wilcox.test`).
'ttest' is Welch's independent two-sample t-test (assumes normality).
- 'student' is the pooled-variance variant, which also assumes both
- groups share a variance.
- alternative : {'two-sided', 'less', 'greater'}, default='two-sided'
- The alternative hypothesis passed to the underlying test.
correction : {'none', 'bonferroni', 'fdr_bh'}, default='fdr_bh'
Multiple-testing correction applied across the pairwise p-values.
- label : {'stars', 'pvalue', 'padj'} or sequence of those, default='stars'
- The bracket label to draw.
- 'stars' maps p-values to asterisks (`****` < 0.0001, `***` < 0.001,
- `**` < 0.01, `*` < 0.05, `ns` otherwise).
- 'pvalue' prints the raw p-value and 'padj' prints the adjusted p-value.
- A sequence merges each component with a newline, e.g.
- `label=("stars", "padj")` stacks the stars on top of the adjusted p-value.
- label_format : str, default='.3g'
- Format string used when `label` contains 'pvalue' or 'padj'.
- prefix : str, default=''
- Text prepended to the numeric p-value/padj labels. Ignored for the
- 'stars' component. When `prefix_style` is '=' or '<', the symbol is
- inserted between `prefix` and the value with whitespace around it,
- so `prefix='p'` with `prefix_style='='` renders `'p = 0.001'`.
- When `prefix_style` is None, `prefix` is used as-is, so the caller
- is responsible for including any symbol and spacing.
- prefix_style : {'=', '<'} or None, default='='
- How the numeric portion of the label is rendered.
- '=' shows the actual p-value with `' = '` inserted after `prefix`.
- '<' replaces the p-value with the star-bracket threshold it falls
- below, with `' < '` inserted after `prefix`, giving
- `'p < 0.0001'`, `'p < 0.001'`, `'p < 0.01'`, or `'p < 0.05'`.
- Non-significant comparisons render as `'p > 0.05'`.
- None disables symbol insertion; the caller supplies any symbol via
- `prefix`.
- separator : str, default=' '
- Separator used to join components when `label` is a sequence with
- more than one entry (e.g. `('stars', 'padj')`).
- threshold : float | None, default=None
- If provided, only comparisons whose p-value (adjusted if `correction` is not
- 'none') is below this threshold are drawn.
+ label : {'stars', 'pvalue'}, default='stars'
+ The bracket label to draw, both from the adjusted p-value.
+ 'stars' maps p-values to asterisks (`****` <= 0.0001, `***` <= 0.001,
+ `**` <= 0.01, `*` <= 0.05, `ns` otherwise).
+ 'pvalue' prints the p-value with 2 significant digits.
+ hide_ns : bool, default=False
+ With `label='stars'`, drop the `ns` (not significant) brackets.
y_position : float | None, default=None
Vertical position of the lowest bracket. If None, it is placed above
the maximum `y` value with a gap of `y_padding * y_range`.
@@ -358,7 +194,7 @@ def bracket(
color : str, default='#1f1f1f'
Color of the brackets and labels.
label_size : float | None, default=None
- Font size of the label text. If None, `geom_bracket`'s default is used.
+ Font size of the label text. If None, ggrust's default is used.
segment_size : float, default=0.5
Line width of the bracket segments.
x : str | None, default=None
@@ -370,9 +206,8 @@ def bracket(
mapping : FeatureSpec | None, default=None
Additional aesthetic mappings for the layer, the result of `aes()`.
**geom_kwargs
- Additional parameters for the `geom_bracket` layer.
- For more information on geom_bracket parameters, see:
- https://lets-plot.org/python/pages/api/lets_plot.geom_bracket.html
+ Additional parameters for the `stat_compare_means` layer, e.g.
+ `tip_length`.
Returns
-------
@@ -388,13 +223,14 @@ def bracket(
If `comparisons` contains malformed pairs or groups not present in
the plot data.
ValueError
- If a statistical option or label option is invalid, or there are too
- few observations to compute brackets.
+ If `test`, `correction` or `label` is invalid, or there are too few
+ observations to compute brackets.
Notes
-----
- Pairwise tests are computed from the plot's retrieved DataFrame using the `x`
- aesthetic as the grouping column and the `y` aesthetic as the value column.
+ Pairwise tests are computed by ggrust's `stat_compare_means` from the plot's
+ retrieved DataFrame, using the `x` aesthetic as the grouping column and the
+ `y` aesthetic as the value column.
Rows the plot dropped are absent from the test too, so a plot built with
`threshold` compares only the observations above it.
@@ -435,7 +271,7 @@ def bracket(
)
box + cl.bracket(
comparisons=[("Monocytes", "Erythroid"), ("Monocytes", "B Cells")],
- label="padj",
+ label="pvalue",
y_padding=0.2,
)
@@ -449,38 +285,15 @@ def bracket(
y_padding=0.2,
)
- Hide non-significant brackets by setting a threshold on the adjusted p-value.
-
- .. jupyter-execute::
-
- box + cl.bracket(threshold=0.001,y_padding=0.2)
-
- Adjust the label format places and add a prefix.
-
- .. jupyter-execute::
- :emphasize-lines: 4-5
-
- box + cl.bracket(
- threshold=0.05,
- label="pvalue",
- prefix="p",
- prefix_style="=",
- y_padding=0.2,
- )
-
- Use "<" notation instead.
+ Hide non-significant brackets.
.. jupyter-execute::
- :emphasize-lines: 4
- box + cl.bracket(
- threshold=0.05,
- label="pvalue",
- prefix="p",
- prefix_style="<",
- y_padding=0.2,
- )
+ box + cl.bracket(hide_ns=True, y_padding=0.2)
"""
+ method = _option("test", test, _TEST_METHODS)
+ p_adjust_method = _option("correction", correction, _CORRECTION_METHODS)
+ p_label = _option("label", label, _LABELS)
explicit_plot = plot
explicit_x = x
explicit_y = y
@@ -499,21 +312,11 @@ def _build(receiving_plot: PlotSpec) -> LayerSpec:
msg = "`y` is present neither as argument nor in the plot aesthetics."
raise MissingAestheticError(msg)
- # compute the pairwise significance frame
- brackets = _compute_bracket_frame(
+ finite_frame, pairs, positions = _bracket_layout(
frame,
x=x,
y=y,
comparisons=comparisons,
- test=test,
- alternative=alternative,
- correction=correction,
- label=label,
- label_format=label_format,
- prefix=prefix,
- prefix_style=prefix_style,
- separator=separator,
- threshold=threshold,
y_position=y_position,
y_step=y_step,
y_padding=y_padding,
@@ -523,9 +326,16 @@ def _build(receiving_plot: PlotSpec) -> LayerSpec:
local_mapping = mapping or aes()
size_kwargs = {} if label_size is None else {"size": label_size}
local_color = None if "color" in local_mapping.as_dict() else color
- return geom_bracket(
- data=brackets,
- mapping=aes(xmin="xmin", xmax="xmax", y="y", label="label", **local_mapping.as_dict()),
+ return stat_compare_means(
+ aes(x=x, y=y, **local_mapping.as_dict()),
+ data=finite_frame,
+ method=method,
+ comparisons=pairs,
+ hide_ns=hide_ns,
+ label=p_label,
+ label_y=positions,
+ p_adjust_method=p_adjust_method,
+ inherit_aes=False,
color=local_color,
segment_size=segment_size,
**size_kwargs,
diff --git a/cellestial/single/core/dimensional.py b/cellestial/single/core/dimensional.py
index 7c501912..9c4387e7 100644
--- a/cellestial/single/core/dimensional.py
+++ b/cellestial/single/core/dimensional.py
@@ -143,7 +143,7 @@ def dimensional(
axis_type : {'axis', 'arrow'} | None
Whether to use regular axis or arrows as the axis.
arrow_length : float, default=0.25
- Span of each axis line as a fraction of its data range (0.25 covers 25%).
+ Span of each axis line as a fraction of the panel (0.25 covers 25%).
arrow_size : float, default=1
Width of the axis lines.
arrow_color : str, default='#3f3f3f'
@@ -339,9 +339,6 @@ def dimensional(
# HANDLE: arrow axis
scttr += _modify_axis(
- frame=frame,
- x=x,
- y=y,
axis_type=axis_type,
arrow_size=arrow_size,
arrow_color=arrow_color,
diff --git a/cellestial/single/heatmap/dotplot.py b/cellestial/single/heatmap/dotplot.py
index ba8805cc..36e7cba7 100644
--- a/cellestial/single/heatmap/dotplot.py
+++ b/cellestial/single/heatmap/dotplot.py
@@ -9,7 +9,6 @@
from ggrust import (
aes,
element_line,
- geom_path,
geom_point,
geom_rect,
ggplot,
@@ -33,9 +32,10 @@
from cellestial.themes import _THEME_DOTPLOT
from cellestial.util import (
_color_gradient,
+ _dendrogram_line_theme,
+ _dendrogram_y_axis,
_fill_gradient,
- _get_dendrogram,
- _get_dendrogram_path_frame,
+ _get_dendrogram_tree,
_resolve_tooltips,
_validate_tooltips,
_warn,
@@ -75,7 +75,6 @@ def dotplot(
dendrogram_color: str = "black",
dendrogram_size: float = 0.5,
dendrogram_key: str | None = None,
- dendrogram_kwargs: dict | None = None,
rectangle_size: float = 0.8,
rectangle_color: str = "#3f3f3f",
rectangle_kwargs: dict | None = None,
@@ -153,8 +152,6 @@ def dotplot(
dendrogram_key : str | None, default=None
Specific key holding the precomputed dendrogram.
By default, `dendrogram_{group_by}` is used.
- dendrogram_kwargs : dict | None, default=None
- Additional parameters to pass to the dendrogram geom_segment.
rectangle_size : float, default=0.8
Size (thickness) of the rectangle border.
rectangle_color : str, default='#3f3f3f'
@@ -359,9 +356,10 @@ def dotplot(
# DETERMINE: y order of groups
if dendrogram:
- y_order_groups, paths = _get_dendrogram(
+ tree = _get_dendrogram_tree(
*_modality_source(data, modality, group_by), use_key=dendrogram_key
)
+ y_order_groups = list(tree.ordered_labels)
else:
y_order_groups = (
frame.select(group_by).unique(maintain_order=True)[group_by].cast(pl.String).to_list()
@@ -428,22 +426,6 @@ def dotplot(
+ _size_scale
)
- # DENDROGRAM (right side, along y-axis), built first so we can derive tight x limits
- x_max_limit = n_x - 0.5
- if dendrogram:
- group_centers = [float(i) for i in range(n_y)]
- dendrogram_frame = _get_dendrogram_path_frame(
- paths, n_x=n_x, n_groups=n_y, group_centers=group_centers
- )
- x_max_limit = dendrogram_frame["x"].max()
- dtplt += geom_path(
- data=dendrogram_frame,
- mapping=aes(x="x", y="y", group="group"),
- color=dendrogram_color,
- size=dendrogram_size,
- **(dendrogram_kwargs or {}),
- )
-
# AXES: discrete labels via continuous breaks; tight limits keep ticks against the rectangle
data_top = n_y - 0.5
y_max_limit = data_top
@@ -455,7 +437,6 @@ def dotplot(
key_groups,
padding=None,
data_range=data_range,
- size_unit="y",
label_size_scale=key_labels_text_size,
)
key_groups_total_span = data_range + key_groups_padding
@@ -463,15 +444,19 @@ def dotplot(
dtplt += scale_x_continuous(
breaks=list(range(n_x)),
labels=x_keys,
- limits=[-0.5, x_max_limit],
- expand=[0, 0],
- )
- dtplt += scale_y_continuous(
- breaks=list(range(n_y)),
- labels=y_order_groups,
- limits=[-0.5, y_max_limit],
+ limits=[-0.5, n_x - 0.5],
expand=[0, 0],
)
+ # DENDROGRAM (right side, along y-axis): the tree orders and labels the groups
+ if dendrogram:
+ dtplt += _dendrogram_y_axis(tree, limits=[-0.5, y_max_limit])
+ else:
+ dtplt += scale_y_continuous(
+ breaks=list(range(n_y)),
+ labels=y_order_groups,
+ limits=[-0.5, y_max_limit],
+ expand=[0, 0],
+ )
# KEY-GROUP brackets above the data area when `keys` was a mapping.
if key_labels and key_groups is not None:
@@ -486,8 +471,6 @@ def dotplot(
bracket_size=key_labels_bracket_size,
width=key_labels_width,
label_size_scale=key_labels_text_size,
- label_size_span=n_x,
- size_unit="x",
):
dtplt += layer
@@ -512,6 +495,8 @@ def dotplot(
# ADD: layers
dtplt += _THEME_DOTPLOT
+ if dendrogram:
+ dtplt += _dendrogram_line_theme(color=dendrogram_color, size=dendrogram_size)
# HANDLE: interactive
if interactive:
diff --git a/cellestial/single/heatmap/heatmap.py b/cellestial/single/heatmap/heatmap.py
index 89a81476..b1c36354 100644
--- a/cellestial/single/heatmap/heatmap.py
+++ b/cellestial/single/heatmap/heatmap.py
@@ -39,9 +39,12 @@
)
from cellestial.themes import _THEME_HEATMAP
from cellestial.util import (
+ _dendrogram_line_theme,
+ _dendrogram_y_axis,
_fill_gradient,
_get_dendrogram,
_get_dendrogram_path_frame,
+ _get_dendrogram_tree,
_warn,
)
from cellestial.util.errors import _unsupported_data_type
@@ -77,7 +80,6 @@ def heatmap(
dendrogram_size: float = 0.5,
dendrogram_key: str | None = None,
group_lines_kwargs: dict | None = None,
- dendrogram_kwargs: dict | None = None,
key_labels: bool = True,
key_labels_text_size: float = 1.0,
key_labels_bracket_size: float = 0.6,
@@ -161,8 +163,6 @@ def heatmap(
By default, `dendrogram_{group_by}` is used.
group_lines_kwargs : dict | None, default=None
Additional parameters to pass to the group separator lines geom_segment.
- dendrogram_kwargs : dict | None, default=None
- Additional parameters to pass to the dendrogram geom_path.
key_labels : bool, default=True
Whether to draw bracket labels above the plot when `keys` is a mapping.
key_labels_text_size : float, default=1.0
@@ -406,7 +406,13 @@ def heatmap(
frame = _scale_wide_values(frame, keys=keys, scale_axis=scale_axis)
# DETERMINE: y order of groups
- if dendrogram:
+ if dendrogram and aggregate:
+ tree = _get_dendrogram_tree(
+ *_modality_source(data, modality, group_by), use_key=dendrogram_key
+ )
+ y_order_groups = list(tree.ordered_labels)
+ paths = None
+ elif dendrogram:
y_order_groups, paths = _get_dendrogram(
*_modality_source(data, modality, group_by), use_key=dendrogram_key
)
@@ -490,12 +496,16 @@ def heatmap(
data_range=data_range,
scale=1.0,
label_size_scale=key_labels_text_size,
+ square_cell_columns=n_x if aggregate and geom == "raster" else None,
)
key_groups_total_span = data_range + key_groups_padding
y_max_limit = data_top + key_groups_padding
# Y scale: groups for aggregate, group-center labels when titling bars, else hidden
- if aggregate:
+ if aggregate and dendrogram:
+ # DENDROGRAM (right side, along y-axis): the tree orders and labels the groups
+ htmp += _dendrogram_y_axis(tree, limits=[data_bottom, y_max_limit], fixed=geom == "raster")
+ elif aggregate:
htmp += scale_y_continuous(
breaks=list(range(n_y)),
labels=y_order_groups,
@@ -542,8 +552,10 @@ def heatmap(
**(group_lines_kwargs or {}),
)
- # DENDROGRAM (right side, along y-axis)
- if dendrogram:
+ # DENDROGRAM (right side, along y-axis); per-cell rows draw the tree as a path
+ if dendrogram and aggregate:
+ htmp += _dendrogram_line_theme(color=dendrogram_color, size=dendrogram_size)
+ elif dendrogram:
assert paths is not None
dendro_frame = _get_dendrogram_path_frame(
paths, n_x=n_x, n_groups=len(y_order_groups), group_centers=group_centers
@@ -553,7 +565,6 @@ def heatmap(
mapping=aes(x="x", y="y", group="group"),
color=dendrogram_color,
size=dendrogram_size,
- **(dendrogram_kwargs or {}),
)
# FILL gradient
@@ -606,7 +617,6 @@ def matrixplot(
dendrogram_size: float = 0.5,
dendrogram_key: str | None = None,
group_lines_kwargs: dict | None = None,
- dendrogram_kwargs: dict | None = None,
key_labels: bool = True,
key_labels_text_size: float = 1.0,
key_labels_bracket_size: float = 0.6,
@@ -677,8 +687,6 @@ def matrixplot(
By default, `dendrogram_{group_by}` is used.
group_lines_kwargs : dict | None, default=None
Additional parameters to pass to the group separator lines geom_segment.
- dendrogram_kwargs : dict | None, default=None
- Additional parameters to pass to the dendrogram geom_path.
key_labels : bool, default=True
Whether to draw bracket labels above the plot when `keys` is a mapping.
key_labels_text_size : float, default=1.0
@@ -797,7 +805,6 @@ def matrixplot(
dendrogram_size=dendrogram_size,
dendrogram_key=dendrogram_key,
group_lines_kwargs=group_lines_kwargs,
- dendrogram_kwargs=dendrogram_kwargs,
value_column=value_column,
variable_column=variable_column,
color_low=color_low,
diff --git a/cellestial/single/heatmap/stacked_violin.py b/cellestial/single/heatmap/stacked_violin.py
index e24b3349..d1b38f24 100644
--- a/cellestial/single/heatmap/stacked_violin.py
+++ b/cellestial/single/heatmap/stacked_violin.py
@@ -9,7 +9,6 @@
from ggrust import (
aes,
element_line,
- geom_path,
geom_polygon,
geom_rect,
ggplot,
@@ -32,10 +31,11 @@
)
from cellestial.themes import _THEME_DOTPLOT
from cellestial.util import (
+ _dendrogram_line_theme,
+ _dendrogram_y_axis,
_drop_nonfinite_rows,
_fill_gradient,
- _get_dendrogram,
- _get_dendrogram_path_frame,
+ _get_dendrogram_tree,
_resolve_tooltips,
_validate_tooltips,
)
@@ -76,7 +76,6 @@ def stacked_violin(
dendrogram_color: str = "black",
dendrogram_size: float = 0.5,
dendrogram_key: str | None = None,
- dendrogram_kwargs: dict | None = None,
rectangle: bool = True,
rectangle_size: float = 0.8,
rectangle_color: str = "#3f3f3f",
@@ -177,8 +176,6 @@ def stacked_violin(
dendrogram_key : str | None, default=None
Specific key holding the precomputed dendrogram.
By default, `dendrogram_{group_by}` is used.
- dendrogram_kwargs : dict | None, default=None
- Additional parameters to pass to the dendrogram geom_path.
rectangle : bool, default=True
Whether to add a rectangle border around the data area.
rectangle_size : float, default=0.8
@@ -351,14 +348,14 @@ def stacked_violin(
# DETERMINE: y order of groups (dendrogram or first-seen order)
if dendrogram:
- y_order_groups, paths = _get_dendrogram(
+ tree = _get_dendrogram_tree(
*_modality_source(data, modality, group_by), use_key=dendrogram_key
)
+ y_order_groups = list(tree.ordered_labels)
else:
y_order_groups = (
frame.select(group_by).unique(maintain_order=True)[group_by].cast(pl.String).to_list()
)
- paths = None
x_keys = keys_list
n_x = len(x_keys)
@@ -420,23 +417,6 @@ def stacked_violin(
midpoint=midpoint,
)
- # DENDROGRAM (right side, along y-axis), built first so we can derive tight x limits
- x_max_limit = n_x - 0.5
- if dendrogram:
- assert paths is not None
- group_centers = [float(i) for i in range(n_y)]
- dendrogram_frame = _get_dendrogram_path_frame(
- paths, n_x=n_x, n_groups=n_y, group_centers=group_centers
- )
- x_max_limit = dendrogram_frame["x"].max()
- plot += geom_path(
- data=dendrogram_frame,
- mapping=aes(x="x", y="y", group="group"),
- color=dendrogram_color,
- size=dendrogram_size,
- **(dendrogram_kwargs or {}),
- )
-
# AXES: discrete labels via continuous breaks; tight limits keep ticks against the rectangle
data_top = n_y - 0.5
y_max_limit = data_top
@@ -454,15 +434,19 @@ def stacked_violin(
plot += scale_x_continuous(
breaks=list(range(n_x)),
labels=x_keys,
- limits=[-0.5, x_max_limit],
- expand=[0, 0],
- )
- plot += scale_y_continuous(
- breaks=list(range(n_y)),
- labels=y_order_groups,
- limits=[-0.5, y_max_limit],
+ limits=[-0.5, n_x - 0.5],
expand=[0, 0],
)
+ # DENDROGRAM (right side, along y-axis): the tree orders and labels the groups
+ if dendrogram:
+ plot += _dendrogram_y_axis(tree, limits=[-0.5, y_max_limit])
+ else:
+ plot += scale_y_continuous(
+ breaks=list(range(n_y)),
+ labels=y_order_groups,
+ limits=[-0.5, y_max_limit],
+ expand=[0, 0],
+ )
# KEY-GROUP brackets above the data area when `keys` was a mapping.
if key_labels and key_groups is not None:
@@ -501,6 +485,8 @@ def stacked_violin(
# ADD: theme
plot += _THEME_DOTPLOT
+ if dendrogram:
+ plot += _dendrogram_line_theme(color=dendrogram_color, size=dendrogram_size)
# HANDLE: interactive
if interactive:
diff --git a/cellestial/single/heatmap/utilities.py b/cellestial/single/heatmap/utilities.py
index 4f121bbb..e24b2abc 100644
--- a/cellestial/single/heatmap/utilities.py
+++ b/cellestial/single/heatmap/utilities.py
@@ -293,20 +293,8 @@ def _get_group_lines_frame(
_TIP_LENGTH_FRACTION = 0.018
_LABEL_GAP_FRACTION = 0.012
-# Label sizing has two modes:
-#
-# - "absolute" (default, used by heatmap): label_size is in lets-plot's default
-# text-size unit (~pt-like). Works well when no other scale_size interferes
-# with the geom_text. Padding is computed as a fraction of the total y-axis
-# span using the original heuristic.
-#
-# - "y" (used by dotplot and stacked_violin): label_size is in y-axis units
-# via `geom_text(size_unit="y")`. This bypasses the silent shrinkage that
-# `scale_size(range=[...])` applies to `geom_text` size constants in
-# plots that map a size aesthetic. Padding is computed directly from the
-# rotated text extent in y-axis units.
-
-# Absolute mode (heatmap).
+# Label size is in the default text-size unit (~pt-like). Padding is a
+# fraction of the total y-axis span.
_ABS_LABEL_PADDING_BASE_FRACTION = 0.060
_ABS_LABEL_PADDING_PER_CHAR_SIZE_FRACTION = 0.0051
_ABS_LABEL_PADDING_MIN_DENSITY_SCALE = 0.45
@@ -317,17 +305,8 @@ def _get_group_lines_frame(
_ABS_LABEL_TEXT_SIZE_PER_KEY = 0.06
_ABS_LABEL_TEXT_SIZE_PER_EXTRA_CHAR = 0.08
_ABS_LABEL_TEXT_SIZE_CHAR_THRESHOLD = 16
-
-# y-unit mode (dotplot, stacked_violin).
-# Empirically calibrated: cap height ~ 22 px on a typical 6-inch panel when
-# size = 0.037 * total_span y units. Each rotated character occupies roughly
-# `_Y_CHAR_WIDTH_PER_SIZE * label_size` y units along the y axis.
-_Y_LABEL_VISUAL_CAP_FRACTION = 0.037
-_Y_LABEL_LONG_THRESHOLD = 12
-_Y_LABEL_LONG_MIN_FRACTION = 0.6
-_Y_LABEL_SIZE_FLOOR = 0.10
-_Y_CHAR_WIDTH_PER_SIZE = 0.40
-_Y_PADDING_SAFETY_FRACTION = 0.030
+# Square cells: padding in column widths per column, label character and size unit.
+_SQUARE_CELL_PADDING_PER_CHAR_SIZE = 0.0038
def _resolve_key_groups(
@@ -377,7 +356,7 @@ def _resolve_key_groups(
def _label_size_absolute(groups: dict[str, list[str]], *, scale: float = 1.0) -> float:
- """Label size in absolute lets-plot text units (used without `size_unit`)."""
+ """Label size in absolute text units."""
n_keys = sum(len(values) for values in groups.values())
max_chars = max((len(label) for label in groups), default=0)
extra_chars = max(0, max_chars - _ABS_LABEL_TEXT_SIZE_CHAR_THRESHOLD)
@@ -393,23 +372,6 @@ def _label_size_absolute(groups: dict[str, list[str]], *, scale: float = 1.0) ->
return max(_ABS_LABEL_TEXT_SIZE_MIN, size * scale)
-def _label_size_y_units(groups: dict[str, list[str]], *, span: float, scale: float = 1.0) -> float:
- """
- Label size in y-axis units (used with `geom_text(size_unit='y')`).
-
- Notes
- -----
- Scales with `span` so the rendered visual size stays roughly constant
- across plots with different y-axis spans. Long labels are shrunk slightly
- to keep the bracket area from dominating the plot.
- """
- max_chars = max((len(label) for label in groups), default=0)
- visual_fraction = _Y_LABEL_VISUAL_CAP_FRACTION
- if max_chars > _Y_LABEL_LONG_THRESHOLD:
- visual_fraction *= max(_Y_LABEL_LONG_MIN_FRACTION, _Y_LABEL_LONG_THRESHOLD / max_chars)
- return max(_Y_LABEL_SIZE_FLOOR, visual_fraction * span * scale)
-
-
def _resolve_padding(
groups: dict[str, list[str]],
*,
@@ -417,7 +379,7 @@ def _resolve_padding(
data_range: float | None = None,
scale: float = 1.0,
label_size_scale: float = 1.0,
- size_unit: str | None = None,
+ square_cell_columns: int | None = None,
) -> float:
"""
Compute y-axis padding (in data units) for the bracket area.
@@ -426,41 +388,36 @@ def _resolve_padding(
-----
`padding` overrides the auto value when given. `scale` is a per-plot
multiplier applied to the auto padding (use `<1` for a more compact
- bracket area). `size_unit` selects the sizing mode (`None` for the
- absolute heatmap mode, `"y"` for the y-unit dotplot/stacked_violin mode).
+ bracket area). `square_cell_columns` is the column count of a plot with
+ square cells (`geom_raster`): one y unit is then one column wide, so the
+ labels need more y units as columns are added.
"""
if padding is not None:
return padding
data = data_range if data_range is not None and data_range > 0 else 5.0
max_chars = max((len(label) for label in groups), default=0)
-
- if size_unit is None:
- n_keys = sum(len(values) for values in groups.values())
- label_size = _label_size_absolute(groups, scale=label_size_scale)
- density_scale = max(
- _ABS_LABEL_PADDING_MIN_DENSITY_SCALE,
- min(1.0, n_keys / _ABS_LABEL_PADDING_FULL_DENSITY_KEYS),
- )
- target_fraction = min(
- _BAR_OFFSET_FRACTION
- + _LABEL_GAP_FRACTION
- + _ABS_LABEL_PADDING_BASE_FRACTION
- + _ABS_LABEL_PADDING_PER_CHAR_SIZE_FRACTION * max_chars * label_size * density_scale,
- _ABS_LABEL_PADDING_MAX_TARGET_FRACTION,
+ n_keys = sum(len(values) for values in groups.values())
+ label_size = _label_size_absolute(groups, scale=label_size_scale)
+ density_scale = max(
+ _ABS_LABEL_PADDING_MIN_DENSITY_SCALE,
+ min(1.0, n_keys / _ABS_LABEL_PADDING_FULL_DENSITY_KEYS),
+ )
+ target_fraction = min(
+ _BAR_OFFSET_FRACTION
+ + _LABEL_GAP_FRACTION
+ + _ABS_LABEL_PADDING_BASE_FRACTION
+ + _ABS_LABEL_PADDING_PER_CHAR_SIZE_FRACTION * max_chars * label_size * density_scale,
+ _ABS_LABEL_PADDING_MAX_TARGET_FRACTION,
+ )
+ resolved = target_fraction * data / (1.0 - target_fraction) * scale
+ if square_cell_columns is not None:
+ square_padding = (
+ _SQUARE_CELL_PADDING_PER_CHAR_SIZE * square_cell_columns * max_chars * label_size
)
- resolved = target_fraction * data / (1.0 - target_fraction) * scale
- if data_range is None or data_range <= 0:
- return max(1.5, resolved)
- return resolved
-
- # y-unit mode: padding fits the rotated text extent directly in y units.
- approx_total_span = data * 1.3
- label_size = _label_size_y_units(groups, span=approx_total_span, scale=label_size_scale)
- text_extent = max_chars * _Y_CHAR_WIDTH_PER_SIZE * label_size
- bar_offset = _BAR_OFFSET_FRACTION * data
- label_gap = _LABEL_GAP_FRACTION * data
- safety = _Y_PADDING_SAFETY_FRACTION * data
- return (bar_offset + label_gap + text_extent + safety) * scale
+ resolved = max(resolved, square_padding * scale)
+ if data_range is None or data_range <= 0:
+ return max(1.5, resolved)
+ return resolved
def _build_key_groups_frame(
@@ -515,8 +472,6 @@ def _key_groups_layers(
bracket_size: float = 0.6,
width: float = 0.6,
label_size_scale: float = 1.0,
- label_size_span: float | None = None,
- size_unit: str | None = None,
extra_kwargs: dict | None = None,
) -> list[FeatureSpec]:
"""
@@ -527,11 +482,6 @@ def _key_groups_layers(
Each bracket is one connected `geom_path` (left tip down, top bar across,
right tip down) so the corners join cleanly with no sub-pixel gap. A
vertical label is centered on each bracket as a separate `geom_text`.
-
- `size_unit` selects the label sizing mode (`None` for absolute units,
- otherwise express the size in that axis' data units). `label_size_span`
- lets callers size text from a different axis span than the y span used for
- bracket geometry.
"""
frame = _build_key_groups_frame(groups, y=y, width=width)
tip_length = _TIP_LENGTH_FRACTION * total_span
@@ -564,18 +514,8 @@ def _key_groups_layers(
"hjust": 0,
"vjust": 0.5,
}
- if size_unit is not None:
- text_kwargs["size_unit"] = size_unit
text_kwargs.update(extra_kwargs or {})
-
- if size_unit is None:
- label_size = _label_size_absolute(groups, scale=label_size_scale)
- else:
- label_size = _label_size_y_units(
- groups,
- span=total_span if label_size_span is None else label_size_span,
- scale=label_size_scale,
- )
+ label_size = _label_size_absolute(groups, scale=label_size_scale)
return [
geom_path(
diff --git a/cellestial/util/__init__.py b/cellestial/util/__init__.py
index 45e7949b..5840f06e 100644
--- a/cellestial/util/__init__.py
+++ b/cellestial/util/__init__.py
@@ -1,6 +1,9 @@
from cellestial.util.dendrogram import ( # noqa: F401
+ _dendrogram_line_theme,
+ _dendrogram_y_axis,
_get_dendrogram,
_get_dendrogram_path_frame,
+ _get_dendrogram_tree,
)
from cellestial.util.markers import marker_genes, marker_genes_dict
from cellestial.util.operations import get_figure, get_figures, get_mapping, layout, retrieve
diff --git a/cellestial/util/dendrogram.py b/cellestial/util/dendrogram.py
index d49aa9b9..6dcba0db 100644
--- a/cellestial/util/dendrogram.py
+++ b/cellestial/util/dendrogram.py
@@ -1,13 +1,98 @@
from __future__ import annotations
-__lazy_modules__ = ["anndata", "numpy"]
+__lazy_modules__ = ["anndata", "ggrust", "ggrust.plot.core", "numpy"]
import numpy as np
import polars as pl
from anndata import AnnData
+from ggrust import (
+ ClusterTree,
+ coord_cartesian,
+ coord_fixed,
+ element_line,
+ guide_axis_dendro,
+ scale_y_dendro,
+ theme,
+)
+from ggrust.plot.core import FeatureSpec, FeatureSpecArray
from cellestial.util.errors import KeyNotFoundError
+# geom line widths are drawn at `size * 2.2` px, theme lines at `size` px;
+# scaling keeps `dendrogram_size` in the geom units of the former path layer.
+_GEOM_LINE_UNIT = 2.2
+
+
+def _dendrogram_info(
+ data: AnnData,
+ group_by: str,
+ *,
+ use_key: str | None = None,
+) -> dict:
+ """Get or compute the scanpy dendrogram of `group_by` stored in `data.uns`."""
+ if not isinstance(data, AnnData):
+ msg = f"Unsupported data type: `{type(data)}`"
+ raise TypeError(msg)
+ key = use_key if use_key is not None else f"dendrogram_{group_by}"
+ if key not in data.uns:
+ if group_by not in data.obs.columns:
+ # Reached when a multimodal object groups by a container-level
+ # column that the selected modality does not carry.
+ msg = (
+ f"Cannot compute a dendrogram for `{group_by}`: it is not an "
+ "observation column of the data the dendrogram is computed from.\n"
+ "Precompute the dendrogram, or group by a column that data carries."
+ )
+ raise KeyNotFoundError(msg)
+ import scanpy as sc
+
+ sc.tl.dendrogram(data, groupby=group_by, key_added=key)
+ return data.uns[key]
+
+
+def _get_dendrogram_tree(
+ data: AnnData,
+ group_by: str,
+ *,
+ use_key: str | None = None,
+) -> ClusterTree:
+ """
+ Get or compute the dendrogram of `group_by` as a ggrust tree.
+
+ Leaves are labelled by category; the tree's order is scanpy's
+ `categories_ordered`.
+ """
+ dendro = _dendrogram_info(data, group_by, use_key=use_key)
+ # Linkage leaf `i` is category `i`; recover the categories from the stored order.
+ labels = [""] * len(dendro["categories_ordered"])
+ for index, category in zip(
+ dendro["categories_idx_ordered"], dendro["categories_ordered"], strict=True
+ ):
+ labels[index] = str(category)
+ return ClusterTree.from_linkage(dendro["linkage"], labels)
+
+
+def _dendrogram_y_axis(
+ tree: ClusterTree, *, limits: list[float], fixed: bool = False
+) -> FeatureSpecArray:
+ """
+ Order the y-axis by `tree` and draw it right of the panel, as scanpy does.
+
+ Layers keep numeric y positions: leaf `i` of the tree's order sits at `i`.
+ `limits` replace the continuous scale's limits (the tree fixes the levels);
+ `fixed` keeps the square cells `geom_raster` plots default to.
+ """
+ coord = coord_fixed(ylim=limits) if fixed else coord_cartesian(ylim=limits)
+ return scale_y_dendro(tree, expand=[0, 0], guide=guide_axis_dendro(side="opposite")) + coord
+
+
+def _dendrogram_line_theme(*, color: str, size: float) -> FeatureSpec:
+ """Style the y-axis tree, drawn with the y tick line; zero-length ticks stay hidden."""
+ return theme(
+ axis_ticks_y=element_line(color=color, size=size * _GEOM_LINE_UNIT),
+ axis_ticks_length_y=0,
+ )
+
def _get_dendrogram(
data: AnnData,
@@ -17,22 +102,7 @@ def _get_dendrogram(
) -> tuple[list[str], pl.DataFrame]:
"""Get or compute the dendrogram for `group_by` and extract paths."""
if isinstance(data, AnnData):
- key = use_key if use_key is not None else f"dendrogram_{group_by}"
- if key not in data.uns:
- if group_by not in data.obs.columns:
- # Reached when a multimodal object groups by a container-level
- # column that the selected modality does not carry.
- msg = (
- f"Cannot compute a dendrogram for `{group_by}`: it is not an "
- "observation column of the data the dendrogram is computed from.\n"
- "Precompute the dendrogram, or group by a column that data carries."
- )
- raise KeyNotFoundError(msg)
- import scanpy as sc
-
- sc.tl.dendrogram(data, groupby=group_by, key_added=key)
-
- dendro = data.uns[key]
+ dendro = _dendrogram_info(data, group_by, use_key=use_key)
categories_ordered = list(dendro["categories_ordered"])
dendro_info = dendro["dendrogram_info"]
diff --git a/cellestial/util/operations.py b/cellestial/util/operations.py
index 03b45996..ba572056 100644
--- a/cellestial/util/operations.py
+++ b/cellestial/util/operations.py
@@ -13,13 +13,6 @@
from polars import DataFrame
-def _grid_figures(grid: SupPlotsSpec) -> list[PlotSpec]:
- # Lets-Plot does not expose the underlying PlotSpec list on SupPlotsSpec;
- # `.as_dict()["figures"]` returns serialized dicts that `gggrid` cannot consume.
- # Name-mangled access is the only path to the live PlotSpec objects.
- return vars(grid)["_SupPlotsSpec__figures"]
-
-
def _normalize_widths(
plots: Sequence[Sequence[PlotSpec | SupPlotsSpec]],
widths: Sequence[float] | Sequence[Sequence[float]] | None,
@@ -222,7 +215,7 @@ def get_figure(grid: SupPlotsSpec, index: int) -> PlotSpec:
msg = f"Expected int for index, but received {type(index)}"
raise TypeError(msg)
- figures = _grid_figures(grid)
+ figures = grid.figures
if not -len(figures) <= index < len(figures):
msg = f"index {index} is out of range for a grid with {len(figures)} figures."
raise IndexError(msg)
@@ -286,7 +279,7 @@ def get_figures(grid: SupPlotsSpec, indices: Sequence[int], **kwargs) -> SupPlot
msg = f"Expected Sequence[int] for indices, but received {type(indices)}"
raise TypeError(msg)
- figures = _grid_figures(grid)
+ figures = grid.figures
out_of_range = [i for i in indices if not -len(figures) <= i < len(figures)]
if out_of_range:
msg = f"indices {out_of_range} are out of range for a grid with {len(figures)} figures"
diff --git a/notebooks/completed/pval.ipynb b/notebooks/completed/pval.ipynb
index df52c80c..105510ff 100644
--- a/notebooks/completed/pval.ipynb
+++ b/notebooks/completed/pval.ipynb
@@ -45,7 +45,7 @@
" mapping=aes(x=\"cell_type_lvl1\", y=\"CD3D\", fill=\"cell_type_lvl1\"),\n",
" stat=\"summary\",\n",
")\n",
- "br + cl.layers.bracket(y_position=1.2, prefix=\"p\", prefix_style=\"<\", label=\"pvalue\")"
+ "br + cl.layers.bracket(y_position=1.2, label=\"pvalue\")"
]
},
{
diff --git a/poetry.lock b/poetry.lock
index 7260e99f..1698ce0c 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -1,4 +1,4 @@
-# This file is automatically @generated by Poetry 2.4.1 and should not be changed by hand.
+# This file is automatically @generated by Poetry 2.5.1 and should not be changed by hand.
[[package]]
name = "accessible-pygments"
@@ -261,22 +261,22 @@ legacy-api-wrap = "*"
natsort = "*"
numpy = ">=1.26"
packaging = ">=24.2"
-pandas = ">=2.1.0,<2.1.2 || >2.1.2,<3"
-scipy = ">=1.12,<1.17.0 || >1.17.0"
+pandas = ">=2.1.0,!=2.1.2,<3"
+scipy = ">=1.12,!=1.17.0"
scverse-misc = ">=0.0.3"
-zarr = ">=2.18.7,<3.0.dev0 || >=3.1.dev0"
+zarr = ">=2.18.7,<3.0 || >=3.1.dev0"
[package.extras]
cu11 = ["cupy-cuda11x"]
cu12 = ["cupy-cuda12x"]
-dask = ["dask[array] (>=2023.5.1,<2024.8.dev0 || >=2024.10.dev0,<2025.2.dev0 || >=2025.9.dev0)"]
+dask = ["dask[array] (>=2023.5.1,<2024.8 || >=2024.10.dev0,<2025.2 || >=2025.9.dev0)"]
dev = ["towncrier (>=24.8.0)"]
dev-doc = ["towncrier (>=24.8.0)"]
-doc = ["awkward (>=2.6.3)", "dask[array] (>=2023.5.1,<2024.8.dev0 || >=2024.10.dev0,<2025.2.dev0 || >=2025.9.dev0)", "ipython", "myst-nb", "myst-parser", "scanpydoc[theme,typehints] (>=0.17.1)", "sphinx (>=9.1)", "sphinx-autodoc-typehints (>=3.6.2)", "sphinx-book-theme (>=1.1.0)", "sphinx-copybutton", "sphinx-design", "sphinx-issues (>=5.0.1)", "sphinxext-opengraph", "towncrier (>=24.8.0)"]
+doc = ["awkward (>=2.6.3)", "dask[array] (>=2023.5.1,<2024.8 || >=2024.10.dev0,<2025.2 || >=2025.9.dev0)", "ipython", "myst-nb", "myst-parser", "scanpydoc[theme,typehints] (>=0.17.1)", "sphinx (>=9.1)", "sphinx-autodoc-typehints (>=3.6.2)", "sphinx-book-theme (>=1.1.0)", "sphinx-copybutton", "sphinx-design", "sphinx-issues (>=5.0.1)", "sphinxext-opengraph", "towncrier (>=24.8.0)"]
gpu = ["cupy"]
-lazy = ["aiohttp", "dask[array] (>=2023.5.1,<2024.8.dev0 || >=2024.10.dev0,<2025.2.dev0 || >=2025.9.dev0)", "requests", "xarray (>=2025.6.1)"]
-test = ["aiohttp", "awkward (>=2.3.2)", "boltons", "dask[array] (>=2023.5.1,<2024.8.dev0 || >=2024.10.dev0,<2025.2.dev0 || >=2025.9.dev0)", "dask[distributed]", "filelock", "joblib", "loompy (>=3.0.5)", "matplotlib", "openpyxl", "pooch", "pyarrow", "pytest", "pytest-cov", "pytest-memray", "pytest-mock", "pytest-randomly", "pytest-xdist[psutil]", "requests", "scanpy (>=1.10)", "scikit-learn", "xarray (>=2025.6.1)"]
-test-min = ["awkward (>=2.3.2)", "boltons", "dask[array] (>=2023.5.1,<2024.8.dev0 || >=2024.10.dev0,<2025.2.dev0 || >=2025.9.dev0)", "dask[distributed]", "filelock", "joblib", "loompy (>=3.0.5)", "matplotlib", "openpyxl", "pooch", "pyarrow", "pytest", "pytest-cov", "pytest-memray", "pytest-mock", "pytest-randomly", "pytest-xdist[psutil]", "scanpy (>=1.10)", "scikit-learn"]
+lazy = ["aiohttp", "dask[array] (>=2023.5.1,<2024.8 || >=2024.10.dev0,<2025.2 || >=2025.9.dev0)", "requests", "xarray (>=2025.6.1)"]
+test = ["aiohttp", "awkward (>=2.3.2)", "boltons", "dask[array] (>=2023.5.1,<2024.8 || >=2024.10.dev0,<2025.2 || >=2025.9.dev0)", "dask[distributed]", "filelock", "joblib", "loompy (>=3.0.5)", "matplotlib", "openpyxl", "pooch", "pyarrow", "pytest", "pytest-cov", "pytest-memray", "pytest-mock", "pytest-randomly", "pytest-xdist[psutil]", "requests", "scanpy (>=1.10)", "scikit-learn", "xarray (>=2025.6.1)"]
+test-min = ["awkward (>=2.3.2)", "boltons", "dask[array] (>=2023.5.1,<2024.8 || >=2024.10.dev0,<2025.2 || >=2025.9.dev0)", "dask[distributed]", "filelock", "joblib", "loompy (>=3.0.5)", "matplotlib", "openpyxl", "pooch", "pyarrow", "pytest", "pytest-cov", "pytest-memray", "pytest-mock", "pytest-randomly", "pytest-xdist[psutil]", "scanpy (>=1.10)", "scikit-learn"]
[[package]]
name = "annsel"
@@ -312,7 +312,7 @@ version = "0.1.4"
description = "Disable App Nap on macOS >= 10.9"
optional = false
python-versions = ">=3.6"
-groups = ["main", "dev", "docs"]
+groups = ["dev", "docs"]
markers = "platform_system == \"Darwin\""
files = [
{file = "appnope-0.1.4-py2.py3-none-any.whl", hash = "sha256:502575ee11cd7a28c0205f379b525beefebab9d161b7c964670864014ed7213c"},
@@ -391,7 +391,7 @@ version = "3.0.1"
description = "Annotate AST trees with source code positions"
optional = false
python-versions = ">=3.8"
-groups = ["main", "dev", "docs"]
+groups = ["dev", "docs"]
files = [
{file = "asttokens-3.0.1-py3-none-any.whl", hash = "sha256:15a3ebc0f43c2d0a50eeafea25e19046c68398e487b9f1f5b517f7c0f40f976a"},
{file = "asttokens-3.0.1.tar.gz", hash = "sha256:71a4ee5de0bde6a31d64f6b13f2293ac190344478f081c3d1bccfcf5eacb0cb7"},
@@ -487,7 +487,7 @@ markers = {main = "extra == \"spatial\""}
[package.dependencies]
jmespath = ">=0.7.1,<2.0.0"
python-dateutil = ">=2.1,<3.0.0"
-urllib3 = ">=1.25.4,<2.2.0 || >2.2.0,<3"
+urllib3 = ">=1.25.4,!=2.2.0,<3"
[package.extras]
crt = ["awscrt (==0.32.2)"]
@@ -554,7 +554,7 @@ version = "2.0.0"
description = "Foreign Function Interface for Python calling C code."
optional = false
python-versions = ">=3.9"
-groups = ["main", "dev", "docs"]
+groups = ["dev", "docs"]
files = [
{file = "cffi-2.0.0-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44"},
{file = "cffi-2.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49"},
@@ -641,7 +641,7 @@ files = [
{file = "cffi-2.0.0-cp39-cp39-win_amd64.whl", hash = "sha256:b882b3df248017dba09d6b16defe9b5c407fe32fc7c65a9c69798e6175601be9"},
{file = "cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529"},
]
-markers = {main = "implementation_name == \"pypy\"", docs = "implementation_name == \"pypy\""}
+markers = {docs = "implementation_name == \"pypy\""}
[package.dependencies]
pycparser = {version = "*", markers = "implementation_name != \"PyPy\""}
@@ -822,11 +822,11 @@ description = "Cross-platform colored terminal text."
optional = false
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7"
groups = ["main", "dev", "docs"]
-markers = "sys_platform == \"win32\" or platform_system == \"Windows\""
files = [
{file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"},
{file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"},
]
+markers = {main = "platform_system == \"Windows\"", dev = "platform_system == \"Windows\" or sys_platform == \"win32\"", docs = "platform_system == \"Windows\" or sys_platform == \"win32\""}
[[package]]
name = "colorcet"
@@ -855,7 +855,7 @@ version = "0.2.3"
description = "Jupyter Python Comm implementation, for usage in ipykernel, xeus-python etc."
optional = false
python-versions = ">=3.8"
-groups = ["main", "dev", "docs"]
+groups = ["dev", "docs"]
files = [
{file = "comm-0.2.3-py3-none-any.whl", hash = "sha256:c615d91d75f7f04f095b30d1c1711babd43bdc6419c1be9886a85f2f4e489417"},
{file = "comm-0.2.3.tar.gz", hash = "sha256:2dc8048c10962d55d7ad693be1e7045d891b7ce8d999c97963a5e3e99c055971"},
@@ -1203,7 +1203,7 @@ version = "1.8.20"
description = "An implementation of the Debug Adapter Protocol for Python"
optional = false
python-versions = ">=3.8"
-groups = ["main", "dev", "docs"]
+groups = ["dev", "docs"]
files = [
{file = "debugpy-1.8.20-cp310-cp310-macosx_15_0_x86_64.whl", hash = "sha256:157e96ffb7f80b3ad36d808646198c90acb46fdcfd8bb1999838f0b6f2b59c64"},
{file = "debugpy-1.8.20-cp310-cp310-manylinux_2_34_x86_64.whl", hash = "sha256:c1178ae571aff42e61801a38b007af504ec8e05fde1c5c12e5a7efef21009642"},
@@ -1243,7 +1243,7 @@ version = "5.3.1"
description = "Decorators for Humans"
optional = false
python-versions = ">=3.8"
-groups = ["main", "dev", "docs"]
+groups = ["dev", "docs"]
files = [
{file = "decorator-5.3.1-py3-none-any.whl", hash = "sha256:f47fe6fdbd2edd623ecfe36875d37aba411624e2670dd395dddae1358689bb3c"},
{file = "decorator-5.3.1.tar.gz", hash = "sha256:4cbcdd55a6efadb9dbea26b858f4fb3264567b52d69ca0d25b721b553f60ea82"},
@@ -1304,7 +1304,7 @@ packaging = ">=20.0"
psutil = ">=5.8.0"
pyyaml = ">=5.4.1"
sortedcontainers = ">=2.0.5"
-tblib = ">=1.6.0,<3.2.0 || >3.2.0,<3.2.1 || >3.2.1"
+tblib = ">=1.6.0,!=3.2.0,!=3.2.1"
toolz = ">=0.12.0"
tornado = ">=6.2.0"
urllib3 = ">=1.26.5"
@@ -1376,7 +1376,7 @@ version = "2.2.1"
description = "Get the currently executing AST node of a frame, and other information"
optional = false
python-versions = ">=3.8"
-groups = ["main", "dev", "docs"]
+groups = ["dev", "docs"]
files = [
{file = "executing-2.2.1-py2.py3-none-any.whl", hash = "sha256:760643d3452b4d777d295bb167ccc74c64a81df23fb5e08eff250c425a4b2017"},
{file = "executing-2.2.1.tar.gz", hash = "sha256:3632cc370565f6648cc328b32435bd120a1e4ebb20c77e3fdde9a13cd1e533c4"},
@@ -1680,7 +1680,7 @@ files = [
markers = {main = "extra == \"spatial\""}
[package.dependencies]
-aiohttp = {version = "<4.0.0a0 || >4.0.0a0,<4.0.0a1 || >4.0.0a1", optional = true, markers = "extra == \"http\""}
+aiohttp = {version = "!=4.0.0a0,!=4.0.0a1", optional = true, markers = "extra == \"http\""}
s3fs = {version = ">2024.2.0", optional = true, markers = "extra == \"s3\""}
[package.extras]
@@ -1757,23 +1757,22 @@ dev = ["codecov", "pre-commit", "pytest (>=3.1.0)", "pytest-cov", "pytest-xdist"
[[package]]
name = "ggrust"
-version = "0.3.0"
+version = "0.6.0"
description = "Grammar-of-graphics plotting engine in Rust with a lets_plot-compatible Python API"
optional = false
python-versions = ">=3.11"
groups = ["main"]
files = [
- {file = "ggrust-0.3.0-cp311-abi3-macosx_10_12_x86_64.whl", hash = "sha256:c034d5a539e1bc60de8e5dbc0f360341fd9514c6ec36cccb209883913fa99bc2"},
- {file = "ggrust-0.3.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:f2bb40834bb5929f3431bf0ea0e765706f70beac390aa5084e0c5fbb3e841354"},
- {file = "ggrust-0.3.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:81775c0079ec576604fc2de772fd29d80994cc34be08c8ae449d8476f3159a6a"},
- {file = "ggrust-0.3.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:b18f0bfa70480d3dc631b3f0eff6b1c8248f71a13f9261659448771abb7eaa7a"},
- {file = "ggrust-0.3.0-cp311-abi3-win_amd64.whl", hash = "sha256:62d9415bb3f2dc88aa795dd1cdc38ecbf80f66831c90bee2d47da36deeec88c5"},
- {file = "ggrust-0.3.0-cp311-abi3-win_arm64.whl", hash = "sha256:55db7f3454373d067611b6e361a45dbaefbf1de41816418f13fef29d1826baa7"},
- {file = "ggrust-0.3.0.tar.gz", hash = "sha256:3e0e8a2b4f4a1466ee7f4151225690b89b6aedff52143dc76513c782f4231f88"},
+ {file = "ggrust-0.6.0-cp311-abi3-macosx_10_12_x86_64.whl", hash = "sha256:14d127b2d054e006456ab734b95e457212809b118a7c89c66bccec0d7bcb3990"},
+ {file = "ggrust-0.6.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:51f34622c8483f7b329c965107444f5bb949b4fabb015ce6c658d2c6e785a66b"},
+ {file = "ggrust-0.6.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:b5625149e90c35a4a068b07ebddebabb728f6dd9f804d35bcae1521805e33c8d"},
+ {file = "ggrust-0.6.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:c17cf3fd994dd2db708e0a08e8782e1e129668271587cd95baf8902c7502749a"},
+ {file = "ggrust-0.6.0-cp311-abi3-win_amd64.whl", hash = "sha256:97a9ca7fb18defb9117a5311cbaba6422dd8eeb95c544bc09c83142bd45198df"},
+ {file = "ggrust-0.6.0-cp311-abi3-win_arm64.whl", hash = "sha256:eda08c8a01d015e2c278653ba3f010e56ddb4944365e6a431e882c7037f1e7d0"},
+ {file = "ggrust-0.6.0.tar.gz", hash = "sha256:1386f9101cf3c265d15dc50e019890fe872272adc4fae0316337622923827ce1"},
]
[package.dependencies]
-ipykernel = ">=7.4.0"
polars = ">=1.30"
[[package]]
@@ -2180,7 +2179,7 @@ version = "7.4.0"
description = "IPython Kernel for Jupyter"
optional = false
python-versions = ">=3.11"
-groups = ["main", "dev", "docs"]
+groups = ["dev", "docs"]
files = [
{file = "ipykernel-7.4.0-py3-none-any.whl", hash = "sha256:a6757f790ddc5a6006b813d82da1a9dbb65b3a00f2b6607df776418c6566ec1c"},
{file = "ipykernel-7.4.0.tar.gz", hash = "sha256:4330114d22b9b33575b2c7c68753fc8faabbbcfbc60b0cdff9f14dd4ce63742f"},
@@ -2192,7 +2191,7 @@ comm = ">=0.1.1"
debugpy = ">=1.6.5"
ipython = ">=7.23.1"
jupyter-client = ">=8.9.0"
-jupyter-core = ">=5.1,<6.0.dev0 || >=6.1.dev0"
+jupyter-core = ">=5.1,<6.0 || >=6.1.dev0"
matplotlib-inline = ">=0.1"
nest-asyncio2 = ">=1.7.0"
packaging = ">=22"
@@ -2206,7 +2205,7 @@ version = "9.13.0"
description = "IPython: Productive Interactive Computing"
optional = false
python-versions = ">=3.11"
-groups = ["main", "dev", "docs"]
+groups = ["dev", "docs"]
files = [
{file = "ipython-9.13.0-py3-none-any.whl", hash = "sha256:57f9d4639e20818d328d287c7b549af3d05f12486ea8f2e7f73e52a36ec4d201"},
{file = "ipython-9.13.0.tar.gz", hash = "sha256:7e834b6afc99f020e3f05966ced34792f40267d64cb1ea9043886dab0dde5967"},
@@ -2240,7 +2239,7 @@ version = "1.1.1"
description = "Defines a variety of Pygments lexers for highlighting IPython code."
optional = false
python-versions = ">=3.8"
-groups = ["main", "dev", "docs"]
+groups = ["dev", "docs"]
files = [
{file = "ipython_pygments_lexers-1.1.1-py3-none-any.whl", hash = "sha256:a9462224a505ade19a605f71f8fa63c2048833ce50abc86768a0d81d876dc81c"},
{file = "ipython_pygments_lexers-1.1.1.tar.gz", hash = "sha256:09c0138009e56b6854f9535736f4171d855c8c08a563a0dcd8022f78355c7e81"},
@@ -2277,7 +2276,7 @@ version = "0.20.0"
description = "An autocompletion tool for Python that can be used for text editors."
optional = false
python-versions = ">=3.10"
-groups = ["main", "dev", "docs"]
+groups = ["dev", "docs"]
files = [
{file = "jedi-0.20.0-py2.py3-none-any.whl", hash = "sha256:7bdd9c2634f56713299976f4cbd59cb3fa92165cc5e05ea811fb253480728b67"},
{file = "jedi-0.20.0.tar.gz", hash = "sha256:c3f4ccbd276696f4b19c54618d4fb18f9fc24b0aef02acf704b23f487daa1011"},
@@ -2378,7 +2377,7 @@ version = "8.10.0"
description = "Jupyter protocol implementation and client libraries"
optional = false
python-versions = ">=3.10"
-groups = ["main", "dev", "docs"]
+groups = ["dev", "docs"]
files = [
{file = "jupyter_client-8.10.0-py3-none-any.whl", hash = "sha256:5f73f24f22fa25192cfff6b23c051932a2473a797b05734aff495b392103e14e"},
{file = "jupyter_client-8.10.0.tar.gz", hash = "sha256:9f7116294dca55f1785be880057d44544db9b1567718d92cb33c58886afb9497"},
@@ -2403,7 +2402,7 @@ version = "5.9.1"
description = "Jupyter core package. A base package on which Jupyter projects rely."
optional = false
python-versions = ">=3.10"
-groups = ["main", "dev", "docs"]
+groups = ["dev", "docs"]
files = [
{file = "jupyter_core-5.9.1-py3-none-any.whl", hash = "sha256:ebf87fdc6073d142e114c72c9e29a9d7ca03fad818c5d300ce2adc1fb0743407"},
{file = "jupyter_core-5.9.1.tar.gz", hash = "sha256:4d09aaff303b9566c3ce657f580bd089ff5c91f5f89cf7d8846c3cdf465b5508"},
@@ -3039,7 +3038,7 @@ version = "0.2.2"
description = "Inline Matplotlib backend for Jupyter"
optional = false
python-versions = ">=3.9"
-groups = ["main", "dev", "docs"]
+groups = ["dev", "docs"]
files = [
{file = "matplotlib_inline-0.2.2-py3-none-any.whl", hash = "sha256:3c821cf1c209f59fb2d2d64abbf5b23b67bcb2210d663f9918dd851c6da1fcf6"},
{file = "matplotlib_inline-0.2.2.tar.gz", hash = "sha256:72f3fe8fce36b70d4a5b612f899090cd0401deddc4ea90e1572b9f4bfb058c79"},
@@ -3543,7 +3542,7 @@ files = [
[package.dependencies]
jupyter-client = ">=6.1.12"
-jupyter-core = ">=4.12,<5.0.dev0 || >=5.1.dev0"
+jupyter-core = ">=4.12,<5.0 || >=5.1.dev0"
nbformat = ">=5.1.3"
traitlets = ">=5.4"
@@ -3604,7 +3603,7 @@ files = [
[package.dependencies]
fastjsonschema = ">=2.15"
jsonschema = ">=2.6"
-jupyter-core = ">=4.12,<5.0.dev0 || >=5.1.dev0"
+jupyter-core = ">=4.12,<5.0 || >=5.1.dev0"
traitlets = ">=5.1"
[package.extras]
@@ -3632,7 +3631,7 @@ version = "1.7.4"
description = "Patch asyncio to allow nested event loops"
optional = false
python-versions = ">=3.5"
-groups = ["main", "dev", "docs"]
+groups = ["dev", "docs"]
files = [
{file = "nest_asyncio2-1.7.4-py3-none-any.whl", hash = "sha256:86e77b52c3d1496b294af3c944b44213b9b82a12a5ee590c0acc4ca4692ef6c7"},
{file = "nest_asyncio2-1.7.4.tar.gz", hash = "sha256:995b1409d926ec55e0876ea39a69bffdd8852cf1db3652c177e8c8ffdbcaf3cc"},
@@ -3890,7 +3889,7 @@ markers = {main = "extra == \"spatial\""}
aiohttp = "*"
dask = ">=2025.12.0,<=2026.1.1"
Deprecated = "*"
-fsspec = {version = ">=0.8,<2021.7.0 || >2021.7.0,<2023.9.0 || >2023.9.0", extras = ["s3"]}
+fsspec = {version = ">=0.8,!=2021.7.0,!=2023.9.0", extras = ["s3"]}
numpy = "*"
rangehttpserver = "*"
requests = "*"
@@ -4078,7 +4077,7 @@ version = "0.8.7"
description = "A Python Parser"
optional = false
python-versions = ">=3.6"
-groups = ["main", "dev", "docs"]
+groups = ["dev", "docs"]
files = [
{file = "parso-0.8.7-py2.py3-none-any.whl", hash = "sha256:a8926eb2a1b915486941fdbd31e86a4baf88fe8c210f25f2f35ecec5b574ca1c"},
{file = "parso-0.8.7.tar.gz", hash = "sha256:eaaac4c9fdd5e9e8852dc778d2d7405897ec510f2a298071453e5e3a07914bb1"},
@@ -4163,7 +4162,7 @@ version = "4.9.0"
description = "Pexpect allows easy control of interactive console applications."
optional = false
python-versions = "*"
-groups = ["main", "dev", "docs"]
+groups = ["dev", "docs"]
markers = "sys_platform != \"win32\" and sys_platform != \"emscripten\""
files = [
{file = "pexpect-4.9.0-py2.py3-none-any.whl", hash = "sha256:7236d1e080e4936be2dc3e326cec0af72acf9212a7e1d060210e70a47e253523"},
@@ -4312,6 +4311,7 @@ files = [
{file = "platformdirs-4.10.0-py3-none-any.whl", hash = "sha256:fb516cdb12eb0d857d0cd85a7c57cea4d060bee4578d6cf5a14dfdf8cbf8784a"},
{file = "platformdirs-4.10.0.tar.gz", hash = "sha256:31e761a6a0ca04faf7353ea759bdba55652be214725111e5aac52dfa29d4bef7"},
]
+markers = {main = "extra == \"spatial\""}
[[package]]
name = "playwright"
@@ -4445,7 +4445,7 @@ version = "3.0.52"
description = "Library for building powerful interactive command lines in Python"
optional = false
python-versions = ">=3.8"
-groups = ["main", "dev", "docs"]
+groups = ["dev", "docs"]
files = [
{file = "prompt_toolkit-3.0.52-py3-none-any.whl", hash = "sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955"},
{file = "prompt_toolkit-3.0.52.tar.gz", hash = "sha256:28cde192929c8e7321de85de1ddbe736f1375148b02f2e17edd840042b1be855"},
@@ -4616,6 +4616,7 @@ files = [
{file = "psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee"},
{file = "psutil-7.2.2.tar.gz", hash = "sha256:0746f5f8d406af344fd547f1c8daa5f5c33dbc293bb8d6a16d80b4bb88f59372"},
]
+markers = {main = "extra == \"spatial\""}
[package.extras]
dev = ["abi3audit", "black", "check-manifest", "colorama ; os_name == \"nt\"", "coverage", "packaging", "psleak", "pylint", "pyperf", "pypinfo", "pyreadline3 ; os_name == \"nt\"", "pytest", "pytest-cov", "pytest-instafail", "pytest-xdist", "pywin32 ; os_name == \"nt\" and implementation_name != \"pypy\"", "requests", "rstcheck", "ruff", "setuptools", "sphinx", "sphinx_rtd_theme", "toml-sort", "twine", "validate-pyproject[all]", "virtualenv", "vulture", "wheel", "wheel ; os_name == \"nt\" and implementation_name != \"pypy\"", "wmi ; os_name == \"nt\" and implementation_name != \"pypy\""]
@@ -4627,7 +4628,7 @@ version = "0.7.0"
description = "Run a subprocess in a pseudo terminal"
optional = false
python-versions = "*"
-groups = ["main", "dev", "docs"]
+groups = ["dev", "docs"]
markers = "sys_platform != \"win32\" and sys_platform != \"emscripten\""
files = [
{file = "ptyprocess-0.7.0-py2.py3-none-any.whl", hash = "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35"},
@@ -4640,7 +4641,7 @@ version = "0.2.3"
description = "Safely evaluate AST nodes without side effects"
optional = false
python-versions = "*"
-groups = ["main", "dev", "docs"]
+groups = ["dev", "docs"]
files = [
{file = "pure_eval-0.2.3-py3-none-any.whl", hash = "sha256:1db8e35b67b3d218d818ae653e27f06c3aa420901fa7b081ca98cbedc874e0d0"},
{file = "pure_eval-0.2.3.tar.gz", hash = "sha256:5f4e983f40564c576c7c8635ae88db5956bb2229d7e9237d03b3c0b0190eaf42"},
@@ -4743,12 +4744,12 @@ version = "3.0"
description = "C parser in Python"
optional = false
python-versions = ">=3.10"
-groups = ["main", "dev", "docs"]
+groups = ["dev", "docs"]
files = [
{file = "pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992"},
{file = "pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29"},
]
-markers = {main = "implementation_name == \"pypy\"", dev = "implementation_name != \"PyPy\"", docs = "implementation_name == \"pypy\""}
+markers = {dev = "implementation_name != \"PyPy\"", docs = "implementation_name == \"pypy\""}
[[package]]
name = "pyct"
@@ -4827,6 +4828,7 @@ files = [
{file = "pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176"},
{file = "pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f"},
]
+markers = {main = "extra == \"spatial\""}
[package.extras]
windows-terminal = ["colorama (>=0.4.6)"]
@@ -5238,7 +5240,7 @@ version = "27.1.0"
description = "Python bindings for 0MQ"
optional = false
python-versions = ">=3.8"
-groups = ["main", "dev", "docs"]
+groups = ["dev", "docs"]
files = [
{file = "pyzmq-27.1.0-cp310-cp310-macosx_10_15_universal2.whl", hash = "sha256:508e23ec9bc44c0005c4946ea013d9317ae00ac67778bd47519fdf5a0e930ff4"},
{file = "pyzmq-27.1.0-cp310-cp310-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:507b6f430bdcf0ee48c0d30e734ea89ce5567fd7b8a0f0044a369c176aa44556"},
@@ -5605,7 +5607,7 @@ markers = {main = "extra == \"spatial\""}
[package.dependencies]
aiobotocore = ">=2.19.0,<4.0.0"
-aiohttp = ">=3.9.0,<4.0.0a0 || >4.0.0a0,<4.0.0a1 || >4.0.0a1"
+aiohttp = ">=3.9.0,!=4.0.0a0,!=4.0.0a1"
fsspec = "2026.4.0"
[[package]]
@@ -5631,7 +5633,7 @@ leidenalg = {version = ">=0.9.0", optional = true, markers = "extra == \"leiden\
matplotlib = ">=3.7.5"
natsort = "*"
networkx = ">=2.7.1"
-numba = ">=0.57.1,<0.62.0rc1 || >0.62.0rc1"
+numba = ">=0.57.1,!=0.62.0rc1"
numpy = ">=1.24.1"
packaging = ">=21.3"
pandas = ">=1.5.3"
@@ -5779,7 +5781,7 @@ files = [
]
[package.dependencies]
-imageio = ">=2.33,<2.35.0 || >2.35.0"
+imageio = ">=2.33,!=2.35.0"
lazy-loader = ">=0.4"
networkx = ">=3.0"
numpy = ">=1.24"
@@ -5959,7 +5961,7 @@ loompy = ">=2.0.12"
matplotlib = ">=3.3.0"
numba = ">=0.41.0"
numpy = ">=1.17"
-pandas = ">=1.1.1,<1.4.0 || >1.4.0"
+pandas = ">=1.1.1,!=1.4.0"
scanpy = ">=1.5"
scikit-learn = ">=0.21.2"
scipy = ">=1.4.1"
@@ -6024,8 +6026,8 @@ files = [
markers = {main = "extra == \"extras\""}
[package.dependencies]
-matplotlib = ">=3.4,<3.6.1 || >3.6.1"
-numpy = ">=1.20,<1.24.0 || >1.24.0"
+matplotlib = ">=3.4,!=3.6.1"
+numpy = ">=1.20,!=1.24.0"
pandas = ">=1.2"
[package.extras]
@@ -6577,7 +6579,7 @@ version = "0.6.3"
description = "Extract data from python stack frames and tracebacks for informative displays"
optional = false
python-versions = "*"
-groups = ["main", "dev", "docs"]
+groups = ["dev", "docs"]
files = [
{file = "stack_data-0.6.3-py3-none-any.whl", hash = "sha256:d5558e0c25a4cb0853cddad3d77da9891a08cb85dd9f9f91b9f8cd66e511e695"},
{file = "stack_data-0.6.3.tar.gz", hash = "sha256:836a778de4fec4dcd1dcd89ed8abff8a221f58308462e1c4aa2a3cf30148f0b9"},
@@ -6641,9 +6643,9 @@ markers = {main = "extra == \"extras\""}
[package.dependencies]
numpy = ">=1.22.3,<3"
packaging = ">=21.3"
-pandas = ">=1.4,<2.1.0 || >2.1.0"
+pandas = ">=1.4,!=2.1.0"
patsy = ">=0.5.6"
-scipy = ">=1.8,<1.9.2 || >1.9.2"
+scipy = ">=1.8,!=1.9.2"
[package.extras]
build = ["cython (>=3.0.10)"]
@@ -6833,6 +6835,7 @@ files = [
{file = "tornado-6.6-cp315-cp315t-win_arm64.whl", hash = "sha256:7de62e2dfdf559fd7154d2b9aa58ff3d9cff8304ae0519341feb082618bc2a33"},
{file = "tornado-6.6.tar.gz", hash = "sha256:34c4aa916d7f2b2d5cadee2c6850f5a4a7f3d688cbf58399ea9bd3057af4abd5"},
]
+markers = {main = "extra == \"spatial\""}
[[package]]
name = "tqdm"
@@ -6862,7 +6865,7 @@ version = "5.15.0"
description = "Traitlets Python configuration system"
optional = false
python-versions = ">=3.9"
-groups = ["main", "dev", "docs"]
+groups = ["dev", "docs"]
files = [
{file = "traitlets-5.15.0-py3-none-any.whl", hash = "sha256:fb36a18867a6803deab09f3c5e0fa81bb7b26a5c9e82501c9933f759166eff40"},
{file = "traitlets-5.15.0.tar.gz", hash = "sha256:4fead733f81cf1c4c938e06f8ca4633896833c9d89eff878159457f4d4392971"},
@@ -7028,7 +7031,7 @@ version = "0.7.0"
description = "Measures the displayed width of unicode strings in a terminal"
optional = false
python-versions = ">=3.8"
-groups = ["main", "dev", "docs"]
+groups = ["dev", "docs"]
files = [
{file = "wcwidth-0.7.0-py3-none-any.whl", hash = "sha256:5d69154c429a82910e241c738cd0e2976fac8a2dd47a1a805f4afed1c0f136f2"},
{file = "wcwidth-0.7.0.tar.gz", hash = "sha256:90e3a7ea092341c44b99562e75d09e4d5160fe7a3974c6fb842a101a95e7eed0"},
@@ -7596,4 +7599,4 @@ spatial = ["spatialdata"]
[metadata]
lock-version = "2.1"
python-versions = ">=3.11,<4.0"
-content-hash = "70f85b8a93e24873feee1f17d0cb946edaeea9800557ad88f33c039905929d88"
+content-hash = "3498d51739cf1dc0db505960833e2cdece95f1e7090231c28efdd4275742c500"
diff --git a/pyproject.toml b/pyproject.toml
index 7873183f..0fe6f724 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -31,7 +31,7 @@ classifiers = [
]
dependencies = [
"anndata>=0.10.8,<1.0",
- "ggrust>=0.3.0,<0.4.0",
+ "ggrust>=0.6.0,<0.7.0",
"numpy>=1.26,<3",
"pandas>=2.2.3,<3",
"polars[pyarrow]>=1.30.0,<2.0.0",
diff --git a/tests/test_annotated_heatmap.py b/tests/test_annotated_heatmap.py
index e4f49a73..39db9a9b 100644
--- a/tests/test_annotated_heatmap.py
+++ b/tests/test_annotated_heatmap.py
@@ -1,7 +1,7 @@
import polars as pl
import pytest
from ggrust import geom_blank
-from ggrust.plot.subplots import SupPlotsSpec
+from ggrust.plot.core import PlotSpec
import cellestial as cl
from cellestial.complex.annotated_heatmap import _bin_observations
@@ -10,7 +10,7 @@
def test_annotated_heatmap_default(adata, markers, group_key):
plot = cl.annotated_heatmap(adata, keys=markers, group_by=group_key)
- assert isinstance(plot, SupPlotsSpec)
+ assert isinstance(plot, PlotSpec)
def test_annotated_heatmap_both_axes(adata, markers, group_key, cluster_key):
@@ -22,14 +22,14 @@ def test_annotated_heatmap_both_axes(adata, markers, group_key, cluster_key):
row_annotations=[cluster_key, "pct_counts_mt"],
scale_axis=0,
)
- assert isinstance(plot, SupPlotsSpec)
+ assert isinstance(plot, PlotSpec)
def test_annotated_heatmap_numeric_column_annotation(adata, markers, group_key):
plot = cl.annotated_heatmap(
adata, keys=markers, group_by=group_key, column_annotations=["means"]
)
- assert isinstance(plot, SupPlotsSpec)
+ assert isinstance(plot, PlotSpec)
def test_annotated_heatmap_string_annotations(adata, markers, group_key, cluster_key):
@@ -40,26 +40,26 @@ def test_annotated_heatmap_string_annotations(adata, markers, group_key, cluster
column_annotations="highly_variable",
row_annotations=cluster_key,
)
- assert isinstance(plot, SupPlotsSpec)
+ assert isinstance(plot, PlotSpec)
def test_annotated_heatmap_no_legend(adata, markers, group_key, cluster_key):
plot = cl.annotated_heatmap(
adata, keys=markers, group_by=group_key, row_annotations=[cluster_key], legend=False
)
- assert isinstance(plot, SupPlotsSpec)
+ assert isinstance(plot, PlotSpec)
def test_annotated_heatmap_no_group_by(adata, markers):
plot = cl.annotated_heatmap(adata, keys=markers, row_annotations="pct_counts_mt")
- assert isinstance(plot, SupPlotsSpec)
+ assert isinstance(plot, PlotSpec)
def test_annotated_heatmap_max_rows(adata, markers, group_key, cluster_key):
plot = cl.annotated_heatmap(
adata, keys=markers, group_by=group_key, row_annotations=[cluster_key], max_rows=200
)
- assert isinstance(plot, SupPlotsSpec)
+ assert isinstance(plot, PlotSpec)
def test_annotated_heatmap_annotation_colors(adata, markers, group_key):
@@ -74,7 +74,7 @@ def test_annotated_heatmap_annotation_colors(adata, markers, group_key):
"pct_counts_mt": ["#ffffcc", "#800026"],
},
)
- assert isinstance(plot, SupPlotsSpec)
+ assert isinstance(plot, PlotSpec)
def test_annotated_heatmap_dendrogram(adata, markers, group_key):
@@ -85,7 +85,7 @@ def test_annotated_heatmap_dendrogram(adata, markers, group_key):
row_annotations=[group_key],
dendrogram=True,
)
- assert isinstance(plot, SupPlotsSpec)
+ assert isinstance(plot, PlotSpec)
def test_annotated_heatmap_dendrogram_requires_group_by(adata, markers):
@@ -102,7 +102,7 @@ def test_annotated_heatmap_passthrough_params(adata, markers, group_key):
interactive=True,
alpha=0.9,
)
- assert isinstance(plot, SupPlotsSpec)
+ assert isinstance(plot, PlotSpec)
def test_annotated_heatmap_unsupported_data_type(markers, group_key):
@@ -131,6 +131,63 @@ def test_bin_observations_noop_when_small():
assert _bin_observations(frame, group_by=None, max_rows=1000) is frame
+def _side_layers(spec: dict, band: str) -> list[dict]:
+ return [layer for layer in spec["layers"] if layer.get("ggside") == band]
+
+
+def test_annotated_heatmap_is_one_plot_with_side_tracks(adata, markers, group_key, cluster_key):
+ spec = cl.annotated_heatmap(
+ adata,
+ keys=markers[:2],
+ group_by=group_key,
+ column_annotations=["highly_variable"],
+ row_annotations=[cluster_key, "pct_counts_mt"],
+ max_rows=20,
+ ).as_dict()
+
+ assert spec["mapping"] == {"x": "position_x", "y": "position_y"}
+ assert spec["layers"][0]["mapping"] == {"fill": "value"}
+ assert spec["ggside"] == {"x_pos": "top", "y_pos": "left"}
+ assert spec["theme"]["ggside_panel_scale_x"] == pytest.approx(0.025)
+ assert spec["theme"]["ggside_panel_scale_y"] == pytest.approx(0.05)
+ assert [layer["mapping"]["fill"] for layer in _side_layers(spec, "x")] == ["highly_variable"]
+ assert [layer["mapping"]["fill"] for layer in _side_layers(spec, "y")] == [
+ cluster_key,
+ "pct_counts_mt",
+ ]
+ # every track has its own fill scale group, so its own legend
+ groups = [layer["scale_group"]["fill"] for layer in spec["layers"] if "ggside" in layer]
+ assert len(set(groups)) == 3
+ side_scales = {scale["ggside"]: scale for scale in spec["scales"] if "ggside" in scale}
+ # first listed track outermost: leftmost in the left band
+ assert side_scales["y"]["limits"] == [cluster_key, "pct_counts_mt"]
+ # track titles hidden by default: the legends name the tracks, and the band
+ # axes stay on the main axes' sides (no stray line on the far side)
+ assert side_scales["x"]["breaks"] == []
+ assert side_scales["y"]["breaks"] == []
+ assert "position" not in side_scales["x"]
+ assert "position" not in side_scales["y"]
+
+
+def test_annotated_heatmap_track_titles_shown(adata, markers, group_key, cluster_key):
+ spec = cl.annotated_heatmap(
+ adata,
+ keys=markers[:2],
+ group_by=group_key,
+ column_annotations=["highly_variable"],
+ row_annotations=[cluster_key],
+ track_titles=True,
+ max_rows=20,
+ ).as_dict()
+ side_scales = {scale["ggside"]: scale for scale in spec["scales"] if "ggside" in scale}
+
+ assert "breaks" not in side_scales["x"]
+ assert "breaks" not in side_scales["y"]
+ assert side_scales["x"]["position"] == "right"
+ # turned names on top, so they don't inflate the bottom axis area
+ assert side_scales["y"]["position"] == "top"
+
+
def test_annotated_heatmap_transpose_moves_tracks_and_group_lines(
adata, markers, group_key, cluster_key
):
@@ -144,16 +201,11 @@ def test_annotated_heatmap_transpose_moves_tracks_and_group_lines(
max_rows=20,
).as_dict()
- assert spec["layout"]["widths"] == [0.025, 1.0]
- assert spec["layout"]["heights"] == [0.025, 1.0]
- assert spec["figures"][0] is None
- heatmap = spec["figures"][3]
- assert heatmap["layers"][0]["mapping"] == {
- "x": "position_y",
- "y": "position_x",
- "fill": "value",
- }
- assert heatmap["layers"][1]["mapping"] == {"x": "x", "xend": "x"}
+ assert spec["mapping"] == {"x": "position_y", "y": "position_x"}
+ assert spec["ggside"] == {"x_pos": "top", "y_pos": "left"}
+ assert [layer["mapping"]["fill"] for layer in _side_layers(spec, "x")] == [cluster_key]
+ assert [layer["mapping"]["fill"] for layer in _side_layers(spec, "y")] == ["highly_variable"]
+ assert spec["layers"][1]["mapping"] == {"x": "x", "xend": "x"}
def test_annotated_heatmap_right_and_bottom_track_positions(
@@ -164,20 +216,16 @@ def test_annotated_heatmap_right_and_bottom_track_positions(
keys=markers[:2],
group_by=group_key,
column_annotations=["highly_variable"],
- row_annotations=[cluster_key],
+ row_annotations=[cluster_key, "pct_counts_mt"],
row_annotation_position="right",
column_annotation_position="bottom",
max_rows=20,
).as_dict()
- assert spec["layout"]["widths"] == [1.0, 0.025]
- assert spec["layout"]["heights"] == [1.0, 0.025]
- assert spec["figures"][3] is None
- assert spec["figures"][0]["layers"][0]["mapping"] == {
- "x": "position_x",
- "y": "position_y",
- "fill": "value",
- }
+ assert spec["ggside"] == {"x_pos": "bottom", "y_pos": "right"}
+ side_scales = {scale["ggside"]: scale for scale in spec["scales"] if "ggside" in scale}
+ # first listed track outermost: rightmost in the right band
+ assert side_scales["y"]["limits"] == ["pct_counts_mt", cluster_key]
def test_annotated_heatmap_transpose_moves_dendrogram_above_heatmap(adata, markers, group_key):
@@ -190,29 +238,29 @@ def test_annotated_heatmap_transpose_moves_dendrogram_above_heatmap(adata, marke
max_rows=20,
).as_dict()
- assert spec["layout"]["ncol"] == 1
- assert spec["layout"]["nrow"] == 2
- assert spec["figures"][0]["layers"][0]["geom"] == "path"
- assert spec["figures"][1]["layers"][0]["geom"] == "raster"
+ assert spec["layers"][0]["geom"] == "raster"
+ (tree,) = _side_layers(spec, "x")
+ assert tree["geom"] == "line"
+ assert spec["ggside"]["x_pos"] == "top"
+ # root away from the heatmap: heights grow upward from the band's inner edge
+ assert min(tree["data"]["height"]) == pytest.approx(-0.5)
+ assert max(tree["data"]["height"]) > 0
-def test_annotated_heatmap_layers_and_layers_all_have_distinct_scope(
- adata, markers, group_key, cluster_key
-):
+def test_annotated_heatmap_layers_and_layers_all_are_added(adata, markers, group_key, cluster_key):
spec = cl.annotated_heatmap(
adata,
keys=markers[:2],
group_by=group_key,
- column_annotations=["highly_variable"],
row_annotations=[cluster_key],
layers=geom_blank(),
layers_all=geom_blank(),
max_rows=20,
).as_dict()
- panels = [panel for panel in spec["figures"] if panel is not None]
- blank_counts = [sum(layer["geom"] == "blank" for layer in panel["layers"]) for panel in panels]
+ geoms = [layer["geom"] for layer in spec["layers"]]
- assert sorted(blank_counts) == [1, 1, 2]
+ # both come right after the heatmap, before the tracks' new fill scales
+ assert geoms[:3] == ["raster", "blank", "blank"]
def test_annotated_heatmap_raster_warns_and_removes_tooltips(adata, markers, group_key):
@@ -226,11 +274,5 @@ def test_annotated_heatmap_raster_warns_and_removes_tooltips(adata, markers, gro
max_rows=20,
)
- heatmap_layer = next(
- layer
- for panel in plot.as_dict()["figures"]
- if panel is not None
- for layer in panel["layers"]
- if layer["geom"] == "raster"
- )
+ heatmap_layer = next(layer for layer in plot.as_dict()["layers"] if layer["geom"] == "raster")
assert "tooltips" not in heatmap_layer
diff --git a/tests/test_audit_regressions.py b/tests/test_audit_regressions.py
index 3b4bac15..fe3365aa 100644
--- a/tests/test_audit_regressions.py
+++ b/tests/test_audit_regressions.py
@@ -10,12 +10,12 @@
import polars as pl
import pytest
from anndata import AnnData
-from ggrust import aes
+from ggrust import aes, geom_point, ggplot
from scipy.stats import ttest_ind
import cellestial as cl
from cellestial.datasets import datasets
-from cellestial.layers.bracket import _compute_bracket_frame
+from cellestial.layers.bracket import _bracket_layout
from cellestial.util import retrieve
from cellestial.util.utilities import _color_gradient, _fill_gradient, _range_inclusive
@@ -43,22 +43,20 @@ def _axis_probe_data() -> AnnData:
return data
-def _compute_single_bracket(frame: pl.DataFrame) -> pl.DataFrame:
- """Compute one uncorrected two-sided Welch t-test bracket."""
- return _compute_bracket_frame(
+def _bracket_label(frame: pl.DataFrame) -> str:
+ """Render one uncorrected two-sided Welch `a` vs `b` bracket and return its label."""
+ plot = ggplot(frame) + geom_point(aes(x="group", y="value"))
+ plot += cl.bracket(comparisons=[("a", "b")], test="ttest", correction="none", label="pvalue")
+ return re.findall(r"]*>([^<]*)", plot.to_svg())[0]
+
+
+def _single_layout(frame: pl.DataFrame) -> tuple[pl.DataFrame, list, list[float]]:
+ """Lay out one `a` vs `b` bracket."""
+ return _bracket_layout(
frame,
x="group",
y="value",
comparisons=[("a", "b")],
- test="ttest",
- alternative="two-sided",
- correction="none",
- label="stars",
- label_format=".3g",
- prefix="",
- prefix_style="=",
- separator=" ",
- threshold=None,
y_position=None,
y_step=None,
y_padding=0.1,
@@ -77,13 +75,11 @@ def test_bracket_omits_nonfinite_observations(nonfinite_value):
)
expected = ttest_ind([1.0, 2.0], [10.0, 11.0, 12.0], equal_var=False)
- brackets = _compute_single_bracket(frame)
+ finite_frame, _, positions = _single_layout(frame)
- assert brackets["statistic"][0] == pytest.approx(expected.statistic)
- assert brackets["pvalue"][0] == pytest.approx(expected.pvalue)
- assert brackets["pvalue_adj"][0] == pytest.approx(expected.pvalue)
- assert np.isfinite(brackets["pvalue"][0])
- assert np.isfinite(brackets["y"][0])
+ assert finite_frame.height == 5
+ assert float(_bracket_label(frame)) == float(f"{expected.pvalue:.2g}")
+ assert np.isfinite(positions[0])
@pytest.mark.parametrize("nonfinite_value", [None, np.nan, np.inf, -np.inf])
@@ -98,7 +94,7 @@ def test_bracket_rejects_groups_with_too_few_finite_observations(nonfinite_value
)
with pytest.raises(ValueError, match="at least 2 observations"):
- _compute_single_bracket(frame)
+ _single_layout(frame)
def test_bracket_rejects_partial_results_with_an_invalid_comparison():
@@ -111,20 +107,11 @@ def test_bracket_rejects_partial_results_with_an_invalid_comparison():
)
with pytest.raises(ValueError, match=r"'a'.*'c'.*\(1\)"):
- _compute_bracket_frame(
+ _bracket_layout(
frame,
x="group",
y="value",
comparisons=[("a", "b"), ("a", "c")],
- test="ttest",
- alternative="two-sided",
- correction="none",
- label="stars",
- label_format=".3g",
- prefix="",
- prefix_style="=",
- separator=" ",
- threshold=None,
y_position=None,
y_step=None,
y_padding=0.1,
@@ -428,29 +415,6 @@ def _unequal_variance_frame() -> pl.DataFrame:
)
-def _bracket_pvalue(frame: pl.DataFrame, test: str) -> float:
- """Run one uncorrected pairwise bracket and return its p-value."""
- brackets = _compute_bracket_frame(
- frame,
- x="group",
- y="value",
- comparisons=[("a", "b")],
- test=test,
- alternative="two-sided",
- correction="none",
- label="stars",
- label_format=".3g",
- prefix="",
- prefix_style="=",
- separator=" ",
- threshold=None,
- y_position=None,
- y_step=None,
- y_padding=0.1,
- )
- return brackets["pvalue"][0]
-
-
def test_bracket_ttest_does_not_assume_equal_variances():
"""`ttest` must be Welch's, matching R's `t.test` and scanpy's own ranking."""
frame = _unequal_variance_frame()
@@ -458,16 +422,10 @@ def test_bracket_ttest_does_not_assume_equal_variances():
values_b = frame.filter(pl.col("group") == "b")["value"].to_numpy()
welch = ttest_ind(values_a, values_b, equal_var=False)
- assert _bracket_pvalue(frame, "ttest") == pytest.approx(welch.pvalue)
+ assert float(_bracket_label(frame)) == float(f"{welch.pvalue:.2g}")
-def test_bracket_student_keeps_the_pooled_variance_test():
- """The pooled-variance test stays reachable under its own name."""
- frame = _unequal_variance_frame()
- values_a = frame.filter(pl.col("group") == "a")["value"].to_numpy()
- values_b = frame.filter(pl.col("group") == "b")["value"].to_numpy()
- student = ttest_ind(values_a, values_b, equal_var=True)
-
- assert _bracket_pvalue(frame, "student") == pytest.approx(student.pvalue)
- # the assumption matters: the two tests disagree on this data
- assert _bracket_pvalue(frame, "ttest") != pytest.approx(student.pvalue)
+def test_bracket_rejects_the_pooled_variance_test():
+ """With no pooled-variance t-test in ggrust, `student` is no longer accepted."""
+ with pytest.raises(ValueError, match="`test`"):
+ cl.bracket(test="student")
diff --git a/tests/test_bracket_stat.py b/tests/test_bracket_stat.py
new file mode 100644
index 00000000..30e84ba7
--- /dev/null
+++ b/tests/test_bracket_stat.py
@@ -0,0 +1,102 @@
+"""
+`bracket` statistics, computed by ggrust's `stat_compare_means`, against scipy.
+
+The bracket labels carry the p-value at 2 significant digits, so the comparison
+is made at that precision.
+"""
+
+import re
+
+import numpy as np
+import polars as pl
+import pytest
+from ggrust import aes, geom_boxplot, ggplot
+from scipy.stats import false_discovery_control, mannwhitneyu, ttest_ind
+
+import cellestial as cl
+
+PAIRS = [("A", "B"), ("A", "C"), ("B", "C")]
+STARS = ((0.0001, "****"), (0.001, "***"), (0.01, "**"), (0.05, "*"))
+
+
+def _frame(n: int, shifts: tuple[float, float, float], seed: int = 0, *, ties: bool = False):
+ rng = np.random.default_rng(seed)
+ values = [rng.normal(shift, 1, n) for shift in shifts]
+ if ties:
+ values = [np.round(group) for group in values]
+ return pl.DataFrame({"g": [g for g in "ABC" for _ in range(n)], "v": np.concatenate(values)})
+
+
+def _labels(frame: pl.DataFrame, **kwargs) -> list[str]:
+ plot = ggplot(frame, aes("g", "v")) + geom_boxplot()
+ plot += cl.bracket(comparisons=PAIRS, **kwargs)
+ texts = re.findall(r"]*>([^<]*)", plot.to_svg())
+ return texts[: len(PAIRS)]
+
+
+def _reference(frame: pl.DataFrame, test: str, correction: str, **test_kwargs) -> list[float]:
+ groups = {g: frame.filter(pl.col("g") == g)["v"].to_numpy() for g in "ABC"}
+ run = {
+ "mannwhitney": lambda a, b: mannwhitneyu(a, b, **test_kwargs),
+ "ttest": lambda a, b: ttest_ind(a, b, equal_var=False),
+ }[test]
+ pvalues = np.array([run(groups[a], groups[b]).pvalue for a, b in PAIRS])
+ if correction == "bonferroni":
+ return np.minimum(pvalues * len(pvalues), 1.0).tolist()
+ if correction == "fdr_bh":
+ return false_discovery_control(pvalues).tolist()
+ return pvalues.tolist()
+
+
+def _two_digits(values) -> list[float]:
+ return [float(f"{value:.2g}") for value in values]
+
+
+def _stars(pvalue: float) -> str:
+ return next((stars for threshold, stars in STARS if pvalue <= threshold), "ns")
+
+
+@pytest.mark.parametrize("ties", [False, True])
+@pytest.mark.parametrize("correction", ["none", "bonferroni", "fdr_bh"])
+@pytest.mark.parametrize("test", ["mannwhitney", "ttest"])
+def test_pvalues_and_adjustment_match_scipy(test, correction, ties):
+ """With >= 50 values per group, the asymptotic scipy tests agree."""
+ frame = _frame(60, (0.0, 0.4, 0.8), ties=ties)
+ labels = _labels(frame, test=test, correction=correction, label="pvalue")
+ assert [float(label) for label in labels] == _two_digits(_reference(frame, test, correction))
+
+
+@pytest.mark.parametrize("correction", ["none", "bonferroni", "fdr_bh"])
+@pytest.mark.parametrize("shift", [0.1, 0.3, 0.5, 0.7, 0.9, 1.2])
+def test_stars_follow_the_adjusted_pvalue(shift, correction):
+ """Stars come from the adjusted p-value with `<=` cutoffs."""
+ frame = _frame(60, (0.0, shift, 2 * shift), seed=1)
+ expected = [_stars(p) for p in _reference(frame, "mannwhitney", correction)]
+ assert _labels(frame, correction=correction) == expected
+
+
+def test_star_bands_are_all_covered():
+ """The star test above reaches every band from 'ns' to '****'."""
+ seen = set()
+ for shift in [0.1, 0.3, 0.5, 0.7, 0.9, 1.2]:
+ frame = _frame(60, (0.0, shift, 2 * shift), seed=1)
+ seen |= {_stars(p) for p in _reference(frame, "mannwhitney", "none")}
+ assert seen == {"ns", "*", "**", "***", "****"}
+
+
+def test_hide_ns_drops_only_ns_brackets():
+ """`hide_ns` removes exactly the brackets labelled `ns`."""
+ frame = _frame(60, (0.0, 0.1, 0.2), seed=1)
+ stars = _labels(frame, correction="none")
+ hidden = _labels(frame, correction="none", hide_ns=True)
+ assert [star for star in stars if star != "ns"] == [
+ label for label in hidden if label in {"*", "**", "***", "****"}
+ ]
+
+
+def test_small_groups_wilcoxon_is_exact():
+ """Below 50 tie-free values per group the Wilcoxon test is exact, as in R."""
+ frame = _frame(30, (0.0, 0.5, 1.0))
+ labels = _labels(frame, correction="none", label="pvalue")
+ exact = _reference(frame, "mannwhitney", "none", method="exact")
+ assert [float(label) for label in labels] == _two_digits(exact)
diff --git a/tests/test_browser_plots.py b/tests/test_browser_plots.py
index a2278830..0d7ec8f7 100644
--- a/tests/test_browser_plots.py
+++ b/tests/test_browser_plots.py
@@ -62,7 +62,7 @@ def _histogram(data: AnnData, _markers: list[str], group_key: str) -> PlotSpec:
return cl.histogram(data, "n_genes_by_counts", fill=group_key, bins=20)
-def _annotated_heatmap(data: AnnData, markers: list[str], group_key: str) -> SupPlotsSpec:
+def _annotated_heatmap(data: AnnData, markers: list[str], group_key: str) -> PlotSpec:
return cl.annotated_heatmap(
data,
keys=markers[:3],
@@ -86,7 +86,7 @@ def _umaps(data: AnnData, _markers: list[str], group_key: str) -> SupPlotsSpec:
pytest.param(_ridge, PlotSpec, id="ridge"),
pytest.param(_histogram, PlotSpec, id="histogram"),
pytest.param(_heatmap, PlotSpec, id="heatmap"),
- pytest.param(_annotated_heatmap, SupPlotsSpec, id="annotated-heatmap"),
+ pytest.param(_annotated_heatmap, PlotSpec, id="annotated-heatmap"),
pytest.param(_umaps, SupPlotsSpec, id="umaps"),
],
)
diff --git a/tests/test_coverage_boost.py b/tests/test_coverage_boost.py
index 832fec27..98fc4395 100644
--- a/tests/test_coverage_boost.py
+++ b/tests/test_coverage_boost.py
@@ -11,7 +11,6 @@
from ggrust.plot.subplots import SupPlotsSpec
import cellestial as cl
-from cellestial.layers import DeferredLayer
from cellestial.util.errors import VariableNotFoundError
# ===========================================================================
@@ -319,9 +318,7 @@ def test_dimensional_axis_type_none(adata):
def test_arrow_axis_default(adata):
umap = cl.umap(adata, "CD14")
- arrow = cl.arrow_axis()
- assert isinstance(arrow, DeferredLayer)
- combined = umap + arrow
+ combined = umap + cl.arrow_axis()
assert isinstance(combined, PlotSpec)
diff --git a/tests/test_heatmap.py b/tests/test_heatmap.py
index 5be478d7..3e95251e 100644
--- a/tests/test_heatmap.py
+++ b/tests/test_heatmap.py
@@ -35,6 +35,23 @@ def _stage_custom_dendrogram(adata, group_key, custom_key="dendrogram_custom_v2"
return local, custom_key
+def _dendrogram_leaf_order(plot):
+ """Leaf labels of the plot's dendrogram scale, in the tree's order."""
+ (tree,) = (scale["dendrogram"] for scale in plot.as_dict()["scales"] if "dendrogram" in scale)
+ return [tree["labels"][leaf] for leaf in tree["order"]]
+
+
+@pytest.mark.parametrize("function", ["dotplot", "stacked_violin", "matrixplot"])
+def test_dendrogram_leaf_order_matches_scanpy(adata, markers, group_key, function):
+ import scanpy as sc
+
+ local = adata.copy()
+ sc.tl.dendrogram(local, groupby=group_key)
+ plot = getattr(cl, function)(local, keys=markers, group_by=group_key, dendrogram=True)
+ expected = list(local.uns[f"dendrogram_{group_key}"]["categories_ordered"])
+ assert _dendrogram_leaf_order(plot) == expected
+
+
def test_heatmap_dendrogram_key_uses_custom_key(adata, markers, group_key):
local, custom = _stage_custom_dendrogram(adata, group_key)
plot = cl.heatmap(
@@ -310,29 +327,6 @@ def test_resolve_padding_explicit_overrides_auto():
assert explicit == 0.5
-def test_key_groups_x_unit_label_size_ignores_y_span():
- from cellestial.single.heatmap.utilities import _key_groups_layers
-
- groups = {"Group_1": ["A", "B"], "Group_2": ["C", "D"]}
- small_y_text = _key_groups_layers(
- groups,
- y=3.5,
- total_span=4,
- label_size_span=4,
- size_unit="x",
- )[-1].as_dict()
- large_y_text = _key_groups_layers(
- groups,
- y=15.5,
- total_span=16,
- label_size_span=4,
- size_unit="x",
- )[-1].as_dict()
- assert small_y_text["size_unit"] == "x"
- assert large_y_text["size_unit"] == "x"
- assert small_y_text["size"] == large_y_text["size"]
-
-
def test_dotplot_dict_keys_extend_y_limit_for_brackets(adata, group_key):
groups = _marker_groups()
plot = cl.dotplot(adata, group_by=group_key, keys=groups)
@@ -354,10 +348,22 @@ def test_dotplot_dict_keys_without_key_labels_keeps_tight_y_limit(adata, group_k
assert scales[-1]["limits"][1] == n_y - 0.5
-def test_dotplot_key_labels_use_x_unit_text_size(adata, group_key):
+def test_dotplot_key_labels_use_absolute_text_size(adata, group_key):
plot = cl.dotplot(adata, group_by=group_key, keys=_marker_groups())
text_layers = [layer for layer in plot.as_dict()["layers"] if layer.get("geom") == "text"]
- assert text_layers[-1]["size_unit"] == "x"
+ assert "size_unit" not in text_layers[-1]
+ assert text_layers[-1]["size"] >= 3.75
+
+
+def test_resolve_padding_square_cells_grow_with_columns():
+ from cellestial.single.heatmap.utilities import _resolve_padding
+
+ groups = {f"group number {index}": [str(index)] for index in range(12)}
+ free = _resolve_padding(groups, padding=None, data_range=4)
+ six = _resolve_padding(groups, padding=None, data_range=4, square_cell_columns=6)
+ twelve = _resolve_padding(groups, padding=None, data_range=4, square_cell_columns=12)
+ assert six >= free, "square cells never get less padding"
+ assert twelve > six, "one y unit is one column wide, so more columns need more y units"
def test_stacked_violin_key_labels_use_absolute_text_size(adata, group_key):
diff --git a/tests/test_layers.py b/tests/test_layers.py
index d6721aee..76be441c 100644
--- a/tests/test_layers.py
+++ b/tests/test_layers.py
@@ -8,75 +8,34 @@
from ggrust.plot.core import PlotSpec
import cellestial as cl
-from cellestial.layers import DeferredLayer
-from cellestial.layers.arrow import _axis_arrow_layers
-from cellestial.layers.bracket import _compute_bracket_frame, _correct_pvalues, _expand_comparisons
+from cellestial.layers.bracket import _bracket_layout, _expand_comparisons
from cellestial.layers.ondata_legend import _compute_label_positions
from cellestial.util import retrieve
from cellestial.util.errors import InvalidComparisonError, MissingAestheticError
-def test_arrow_axis_returns_deferred(adata):
+def test_arrow_axis_adds_to_plot(adata):
umap = cl.umap(adata, "CD14")
- arrow = cl.arrow_axis()
- assert isinstance(arrow, DeferredLayer)
- # Addition should succeed
- combined = umap + arrow
+ combined = umap + cl.arrow_axis()
assert isinstance(combined, PlotSpec)
def test_arrow_axis_customization(adata):
umap = cl.umap(adata, "CD14")
- arrow = cl.arrow_axis(length=0.20, color="dark_violet")
- assert isinstance(arrow, DeferredLayer)
- combined = umap + arrow
- assert isinstance(combined, PlotSpec)
-
-
-def test_arrow_axis_explicit_plot_kwarg(adata):
- # Explicit `plot=` pins the data source regardless of the receiving plot.
- umap = cl.umap(adata, "CD14")
- arrow = cl.arrow_axis(plot=umap, length=0.20)
- assert isinstance(arrow, DeferredLayer)
- # Even when added to an empty ggplot, the data source stays `umap`.
- combined = ggplot() + arrow
- assert isinstance(combined, PlotSpec)
+ combined = umap + cl.arrow_axis(length=0.20, color="dark_violet")
+ theme = combined.as_dict()["theme"]
+ for key in ("axis_line_x", "axis_line_y"):
+ assert theme[key]["extent"] == 0.20
+ assert theme[key]["color"] == "dark_violet"
-def test_arrow_axis_forwards_arrow_and_geom_kwargs(adata):
+def test_arrow_axis_forwards_linetype_and_arrow_kwargs(adata):
umap = cl.umap(adata, "CD14")
combined = umap + cl.arrow_axis(arrow_kwargs={"type": "closed"}, linetype="dashed")
- layers = [
- layer for layer in combined.as_dict()["layers"] if layer["geom"] in ("path", "segment")
- ]
- assert [layer["geom"] for layer in layers] == ["path", "segment", "segment"]
- assert all(layer["linetype"] == "dashed" for layer in layers)
- assert all(layer["arrow"]["type"] == "closed" for layer in layers[1:])
-
-
-def test_arrow_axis_rejects_empty_coordinates():
- """Empty coordinates should raise a clear validation error."""
- frame = pl.DataFrame(schema={"x": pl.Float64, "y": pl.Float64})
-
- with pytest.raises(ValueError, match="coordinate"):
- _axis_arrow_layers(
- frame,
- x="x",
- y="y",
- length=0.25,
- size=1.0,
- color="black",
- angle=10.0,
- )
-
-
-def test_arrow_axis_requires_each_coordinate_aesthetic():
- frame = pl.DataFrame({"x": [0.0, 1.0], "y": [0.0, 1.0]})
-
- with pytest.raises(MissingAestheticError, match="`x`"):
- _ = (ggplot(frame) + geom_point(aes(y="y"))) + cl.arrow_axis()
- with pytest.raises(MissingAestheticError, match="`y`"):
- _ = (ggplot(frame) + geom_point(aes(x="x"))) + cl.arrow_axis()
+ theme = combined.as_dict()["theme"]
+ for key in ("axis_line_x", "axis_line_y"):
+ assert theme[key]["linetype"] == "dashed"
+ assert theme[key]["arrow"]["type"] == "closed"
def test_ondata_legend_uses_group_median_positions():
@@ -248,26 +207,17 @@ def test_bracket_preserves_group_order_for_default_comparisons():
}
)
- brackets = _compute_bracket_frame(
+ _, pairs, _ = _bracket_layout(
frame,
x="group",
y="value",
comparisons=None,
- test="mannwhitney",
- alternative="two-sided",
- correction="none",
- label="stars",
- label_format=".3g",
- prefix="",
- prefix_style="=",
- separator=" ",
- threshold=None,
y_position=None,
y_step=None,
y_padding=0.08,
)
- assert list(zip(brackets["xmin"], brackets["xmax"], strict=True)) == [
+ assert pairs == [
("b", "a"),
("b", "c"),
("a", "c"),
@@ -329,124 +279,50 @@ def test_bracket_frame_accepts_group_vs_rest_comparisons():
}
)
- brackets = _compute_bracket_frame(
+ _, pairs, _ = _bracket_layout(
frame,
x="group",
y="value",
comparisons=[("a", "*")],
- test="mannwhitney",
- alternative="two-sided",
- correction="none",
- label="stars",
- label_format=".3g",
- prefix="",
- prefix_style="=",
- separator=" ",
- threshold=None,
y_position=None,
y_step=None,
y_padding=0.08,
)
- assert list(zip(brackets["xmin"], brackets["xmax"], strict=True)) == [
+ assert pairs == [
("a", "b"),
("a", "c"),
]
-def test_correct_pvalues_methods_and_invalid_method():
- pvalues = np.array([0.01, 0.02, 0.2])
-
- assert _correct_pvalues(pvalues, "none").tolist() == pvalues.tolist()
- assert _correct_pvalues(pvalues, "bonferroni").round(3).tolist() == [0.03, 0.06, 0.6]
- assert _correct_pvalues(pvalues, "fdr_bh").round(3).tolist() == [0.03, 0.03, 0.2]
- with pytest.raises(ValueError, match="correction"):
- _correct_pvalues(pvalues, "bad")
-
-
-def test_bracket_frame_ttest_pvalue_labels_and_threshold_empty():
+def test_bracket_layout_explicit_positions():
frame = pl.DataFrame(
{
- "group": ["a", "a", "a", "b", "b", "b"],
- "value": [1.0, 1.1, 1.2, 5.0, 5.1, 5.2],
+ "group": ["a", "a", "b", "b", "c", "c"],
+ "value": [1.0, 1.1, 5.0, 5.1, 3.0, 3.1],
}
)
- brackets = _compute_bracket_frame(
+ _, _, positions = _bracket_layout(
frame,
x="group",
y="value",
- comparisons=[("a", "b")],
- test="ttest",
- alternative="less",
- correction="bonferroni",
- label=["stars", "pvalue", "padj"],
- label_format=".2g",
- prefix="p",
- prefix_style="<",
- separator="\n",
- threshold=None,
+ comparisons=None,
y_position=10.0,
y_step=2.0,
y_padding=0.1,
)
- filtered = _compute_bracket_frame(
- frame,
- x="group",
- y="value",
- comparisons=[("a", "b")],
- test="ttest",
- alternative="less",
- correction="bonferroni",
- label="pvalue",
- label_format=".2g",
- prefix="p",
- prefix_style=None,
- separator=" ",
- threshold=1e-12,
- y_position=None,
- y_step=None,
- y_padding=0.1,
- )
- assert brackets["y"].to_list() == [10.0]
- assert "p <" in brackets["label"][0]
- assert filtered.is_empty()
+ assert positions == [10.0, 12.0, 14.0]
-def test_bracket_frame_validation_paths():
- frame = pl.DataFrame(
- {
- "group": ["a", "a", "b", "b"],
- "value": [1.0, 1.1, 2.0, 2.1],
- }
- )
- kwargs = {
- "frame": frame,
- "x": "group",
- "y": "value",
- "comparisons": [("a", "b")],
- "test": "mannwhitney",
- "alternative": "two-sided",
- "correction": "none",
- "label_format": ".3g",
- "prefix": "",
- "prefix_style": "=",
- "separator": " ",
- "threshold": None,
- "y_position": None,
- "y_step": None,
- "y_padding": 0.1,
- }
-
- with pytest.raises(ValueError, match="test"):
- _compute_bracket_frame(**(kwargs | {"test": "bad", "label": "stars"}))
- with pytest.raises(ValueError, match="label"):
- _compute_bracket_frame(**(kwargs | {"label": ["bad"]}))
- with pytest.raises(ValueError, match="at least one"):
- _compute_bracket_frame(**(kwargs | {"label": []}))
- with pytest.raises(ValueError, match="prefix_style"):
- _compute_bracket_frame(**(kwargs | {"label": "pvalue", "prefix_style": "bad"}))
+@pytest.mark.parametrize(
+ "kwargs",
+ [{"test": "student"}, {"correction": "holm"}, {"label": "padj"}],
+)
+def test_bracket_rejects_unknown_options(kwargs):
+ with pytest.raises(ValueError, match=f"`{next(iter(kwargs))}`"):
+ cl.bracket(**kwargs)
def test_bracket_layer_explicit_plot_and_mapping():
@@ -465,9 +341,8 @@ def test_bracket_layer_explicit_plot_and_mapping():
y="value",
correction="none",
label="pvalue",
- mapping=aes(color="label"),
+ mapping=aes(color="bracket_color"),
label_size=6,
- threshold=1,
)
assert isinstance(combined, PlotSpec)
diff --git a/tests/test_mudata.py b/tests/test_mudata.py
index 5d577ae8..4077d16e 100644
--- a/tests/test_mudata.py
+++ b/tests/test_mudata.py
@@ -357,6 +357,10 @@ def test_anndata_frame_is_unchanged(mudata):
"stacked_violin",
lambda data: cl.stacked_violin(data, keys=[GENE, PROTEIN], group_by=JOINT_GROUP),
),
+ (
+ "annotated_heatmap",
+ lambda data: cl.annotated_heatmap(data, keys=[GENE, PROTEIN], group_by=JOINT_GROUP),
+ ),
],
)
def test_single_plots_accept_a_container(mudata, name, call):
@@ -380,10 +384,6 @@ def test_single_plots_accept_a_container(mudata, name, call):
lambda data: cl.expressions(data, keys=[GENE, PROTEIN], group_by=JOINT_GROUP),
),
("xyplots", lambda data: cl.xyplots(data, x=GENE, y=PROTEIN, colors=["celltype"])),
- (
- "annotated_heatmap",
- lambda data: cl.annotated_heatmap(data, keys=[GENE, PROTEIN], group_by=JOINT_GROUP),
- ),
],
)
def test_grid_plots_accept_a_container(mudata, name, call):