From ae96438a7bb8aa5a9542e55baadeeb09736eea8f Mon Sep 17 00:00:00 2001 From: AtomicGlance Date: Thu, 27 Aug 2026 20:25:03 +0330 Subject: [PATCH 1/2] feat: add configurable columns to PAGA plots Signed-off-by: AtomicGlance --- docs/release-notes/1203.feat.md | 1 + src/scanpy/plotting/legacy/_tools/paga.py | 53 +++++++++++++++++++---- tests/plotting/legacy/test_paga.py | 22 ++++++++++ 3 files changed, 67 insertions(+), 9 deletions(-) create mode 100644 docs/release-notes/1203.feat.md diff --git a/docs/release-notes/1203.feat.md b/docs/release-notes/1203.feat.md new file mode 100644 index 0000000000..fc0d490011 --- /dev/null +++ b/docs/release-notes/1203.feat.md @@ -0,0 +1 @@ +Add an ``ncols`` parameter to {func}`scanpy.pl.paga` so multi-color PAGA plots can be arranged in a configurable grid instead of a single row. {smaller}`AtomicGlance` diff --git a/src/scanpy/plotting/legacy/_tools/paga.py b/src/scanpy/plotting/legacy/_tools/paga.py index 015eaaa003..7f8df4db7a 100644 --- a/src/scanpy/plotting/legacy/_tools/paga.py +++ b/src/scanpy/plotting/legacy/_tools/paga.py @@ -384,6 +384,7 @@ def paga( # noqa: PLR0912, PLR0913, PLR0915 cax: Axes | None = None, colorbar=None, # TODO: this seems to be unused cb_kwds: Mapping[str, Any] = frozendict({}), + ncols: int | None = None, frameon: bool | None = None, add_pos: bool = True, export_to_gexf: bool = False, @@ -495,6 +496,9 @@ def paga( # noqa: PLR0912, PLR0913, PLR0915 cb_kwds Keyword arguments for :class:`~matplotlib.colorbar.Colorbar`, for instance, `ticks`. + ncols + Number of panels shown per row when plotting multiple colors. If + `None`, all panels are shown in a single row. add_pos Add the positions to `adata.uns['paga']`. title @@ -547,6 +551,13 @@ def paga( # noqa: PLR0912, PLR0913, PLR0915 """ rng = np.random.default_rng(rng) + if ncols is not None: + if isinstance(ncols, bool) or not isinstance(ncols, int) or ncols < 1: + msg = "`ncols` must be a positive integer or `None`." + raise ValueError(msg) + if ax is not None: + msg = "Cannot specify `ncols` when passing a pre-supplied `ax`." + raise ValueError(msg) if groups is not None: # backwards compat labels = groups logg.warning("`groups` is deprecated in `pl.paga`: use `labels` instead") @@ -633,9 +644,21 @@ def is_flat(x): ) if plot: - axs, panel_pos, draw_region_width, _figure_width = _utils.setup_axes( - ax, panels=colors, colorbars=colorbars - ) + if ncols is None: + axs, panel_pos, draw_region_width, _figure_width = _utils.setup_axes( + ax, panels=colors, colorbars=colorbars + ) + else: + from .scatterplots import _panel_grid + + fig, grid = _panel_grid( + hspace=0.25, + wspace=0.1, + ncols=ncols, + num_panels=len(colors), + ) + axs = [fig.add_subplot(grid[i]) for i in range(len(colors))] + panel_pos = None if len(colors) == 1 and not isinstance(axs, list): axs = [axs] @@ -677,7 +700,21 @@ def is_flat(x): pos=pos, ) if colorbars[icolor]: - if cax is None: + if ncols is not None: + if cax is None: + colorbar_kwds = { + "format": ticker.FuncFormatter(_utils.ticks_formatter) + } + colorbar_kwds.update(cb_kwds) + _ = axs[icolor].figure.colorbar( + sct, + ax=axs[icolor], + use_gridspec=False, + **colorbar_kwds, + ) + continue + ax_cb = cax[icolor] if isinstance(cax, Sequence) else cax + elif cax is None: bottom = panel_pos[0][0] height = panel_pos[1][0] - bottom width = 0.006 * draw_region_width / len(colors) @@ -688,11 +725,9 @@ def is_flat(x): else: ax_cb = cax[icolor] - _ = plt.colorbar( - sct, - format=ticker.FuncFormatter(_utils.ticks_formatter), - cax=ax_cb, - ) + colorbar_kwds = {"format": ticker.FuncFormatter(_utils.ticks_formatter)} + colorbar_kwds.update(cb_kwds) + _ = plt.colorbar(sct, cax=ax_cb, **colorbar_kwds) if add_pos: adata.uns["paga"]["pos"] = pos logg.hint("added 'pos', the PAGA positions (adata.uns['paga'])") diff --git a/tests/plotting/legacy/test_paga.py b/tests/plotting/legacy/test_paga.py index 3c1f9e65ff..aab2da4a8b 100644 --- a/tests/plotting/legacy/test_paga.py +++ b/tests/plotting/legacy/test_paga.py @@ -5,6 +5,7 @@ import pytest from matplotlib import colormaps +from matplotlib import pyplot as plt from packaging.version import Version import scanpy as sc @@ -76,6 +77,27 @@ def test_paga_pie(plot_cmp, pbmc) -> None: plot_cmp("paga_pie") +@SKIP_IF_OLD_IGRAPH +@pytest.mark.parametrize(("ncols", "expected_shape"), [(1, (3, 1)), (2, (2, 2))]) +def test_paga_ncols(pbmc, ncols, expected_shape) -> None: + axs = sc.pl.paga( + pbmc, + color=["CST3", "GATA2", "cool_feature"], + ncols=ncols, + show=False, + ) + + assert len(axs) == 3 + assert axs[0].get_subplotspec().get_gridspec().get_geometry() == expected_shape + plt.close(axs[0].figure) + + +@pytest.mark.parametrize("ncols", [0, -1, True, 1.5]) +def test_paga_ncols_rejects_invalid(ncols) -> None: + with pytest.raises(ValueError, match=r"ncols.*positive integer"): + sc.pl.paga(None, ncols=ncols) + + def test_paga_path(plot_cmp, pbmc) -> None: pbmc.uns["iroot"] = 0 sc.tl.dpt(pbmc) From b6c44578e4d27b18e3b31d7847ee0cc8749f0461 Mon Sep 17 00:00:00 2001 From: AtomicGlance Date: Thu, 27 Aug 2026 20:54:50 +0330 Subject: [PATCH 2/2] test: cover PAGA ncols branches Signed-off-by: AtomicGlance --- src/scanpy/plotting/legacy/_tools/paga.py | 8 +++++--- tests/plotting/legacy/test_paga.py | 17 +++++++++++++++++ 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/src/scanpy/plotting/legacy/_tools/paga.py b/src/scanpy/plotting/legacy/_tools/paga.py index 7f8df4db7a..637a240cd1 100644 --- a/src/scanpy/plotting/legacy/_tools/paga.py +++ b/src/scanpy/plotting/legacy/_tools/paga.py @@ -725,9 +725,11 @@ def is_flat(x): else: ax_cb = cax[icolor] - colorbar_kwds = {"format": ticker.FuncFormatter(_utils.ticks_formatter)} - colorbar_kwds.update(cb_kwds) - _ = plt.colorbar(sct, cax=ax_cb, **colorbar_kwds) + _ = plt.colorbar( + sct, + format=ticker.FuncFormatter(_utils.ticks_formatter), + cax=ax_cb, + ) if add_pos: adata.uns["paga"]["pos"] = pos logg.hint("added 'pos', the PAGA positions (adata.uns['paga'])") diff --git a/tests/plotting/legacy/test_paga.py b/tests/plotting/legacy/test_paga.py index aab2da4a8b..06c9374e78 100644 --- a/tests/plotting/legacy/test_paga.py +++ b/tests/plotting/legacy/test_paga.py @@ -98,6 +98,23 @@ def test_paga_ncols_rejects_invalid(ncols) -> None: sc.pl.paga(None, ncols=ncols) +def test_paga_ncols_rejects_ax() -> None: + fig, ax = plt.subplots() + with pytest.raises(ValueError, match="Cannot specify `ncols`"): + sc.pl.paga(None, ncols=2, ax=ax) + plt.close(fig) + + +@SKIP_IF_OLD_IGRAPH +def test_paga_ncols_custom_colorbar(pbmc) -> None: + cax_fig, cax = plt.subplots() + ax = sc.pl.paga(pbmc, color="CST3", ncols=1, cax=cax, show=False) + + assert ax.figure is not cax_fig + plt.close(ax.figure) + plt.close(cax_fig) + + def test_paga_path(plot_cmp, pbmc) -> None: pbmc.uns["iroot"] = 0 sc.tl.dpt(pbmc)