From 794515bcbc7362953bc409e0a24da3f83c198df0 Mon Sep 17 00:00:00 2001 From: gkneighb <26003+gkneighb@users.noreply.github.com> Date: Wed, 20 May 2026 23:12:56 -0400 Subject: [PATCH 1/2] fix: anchor sc.pl.scatter colorbar to user-supplied ax When sc.pl.scatter is called with a user-supplied ax, scatter_base created the colorbar with fig.add_axes(rectangle) using rectangle coordinates derived from panel_pos -- positions computed against scanpy's own panel layout, which has no relationship to the caller's figure. Result: the colorbar lands somewhere in the figure interior, typically overlapping a sibling user-axes. Detect the user-supplied-ax case at the top of scatter_base and route that branch through plt.colorbar(sct, ax=ax, ...), mirroring how sc.pl.embedding already handles its colorbar. The original rectangle-based path is preserved for the scanpy-managed-figure case so the visual layout of multi-panel scatter outputs stays unchanged. Adds tests/test_plotting.py::test_scatter_colorbar_uses_user_ax as a structural regression test that asserts the colorbar sits just to the right of the user ax, not floating across the figure. AI-assisted by Claude Code. Closes #3963 --- src/scanpy/plotting/_utils.py | 39 +++++++++++++++++++++++++---------- tests/test_plotting.py | 35 +++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 11 deletions(-) diff --git a/src/scanpy/plotting/_utils.py b/src/scanpy/plotting/_utils.py index 23cad1c111..7a22f56fb3 100644 --- a/src/scanpy/plotting/_utils.py +++ b/src/scanpy/plotting/_utils.py @@ -790,6 +790,10 @@ def scatter_base( # noqa: PLR0912, PLR0913, PLR0915 sizes = [sizes[0] for _ in range(len(colors))] if len(markers) != len(colors) and len(markers) == 1: markers = [markers[0] for _ in range(len(colors))] + # When the caller provides an ax, panel_pos is computed for scanpy's own + # layout and does not reflect the user's figure, so the rectangle-based + # colorbar placement below would fall outside the user's ax (see #3963). + ax_was_user_supplied = ax is not None axs, panel_pos, draw_region_width, _figure_width = setup_axes( ax, panels=colors, @@ -830,17 +834,30 @@ def scatter_base( # noqa: PLR0912, PLR0913, PLR0915 rasterized=settings._vector_friendly, ) if colorbars[icolor]: - width = 0.006 * draw_region_width / len(colors) - left = ( - panel_pos[2][2 * icolor + 1] - + (1.2 if projection == "3d" else 0.2) * width - ) - rectangle = [left, bottom, width, height] - fig = plt.gcf() - ax_cb = fig.add_axes(rectangle) - _ = plt.colorbar( - sct, format=ticker.FuncFormatter(ticks_formatter), cax=ax_cb - ) + if ax_was_user_supplied: + # Attach the colorbar to the caller's ax instead of using the + # internal panel_pos rectangle (which is in figure coords for + # scanpy's own layout). Mirrors sc.pl.embedding. See #3963. + _ = plt.colorbar( + sct, + ax=ax, + format=ticker.FuncFormatter(ticks_formatter), + pad=0.01, + fraction=0.08, + aspect=30, + ) + else: + width = 0.006 * draw_region_width / len(colors) + left = ( + panel_pos[2][2 * icolor + 1] + + (1.2 if projection == "3d" else 0.2) * width + ) + rectangle = [left, bottom, width, height] + fig = plt.gcf() + ax_cb = fig.add_axes(rectangle) + _ = plt.colorbar( + sct, format=ticker.FuncFormatter(ticks_formatter), cax=ax_cb + ) # set the title if title is not None: ax.set_title(title[icolor]) diff --git a/tests/test_plotting.py b/tests/test_plotting.py index 60e5d18c2f..8d1ff01385 100644 --- a/tests/test_plotting.py +++ b/tests/test_plotting.py @@ -1419,6 +1419,41 @@ def test_dpt_plots( save_and_compare_images(func.__name__) +def test_scatter_colorbar_uses_user_ax(): + """Regression test for #3963. + + When ``sc.pl.scatter`` receives a user-supplied ``ax``, the colorbar must + be attached to that ax. Previously the colorbar was placed via + ``fig.add_axes(rectangle)`` with rectangle in figure coords for scanpy's + own panel layout, landing the colorbar over an unrelated user axes. + """ + rng = np.random.default_rng(0) + adata = AnnData( + rng.random((50, 3), dtype=np.float32), + obs=dict(score=rng.random(50, dtype=np.float32)), + ) + adata.obsm["X_umap"] = rng.random((50, 2), dtype=np.float32) + + fig, axs = plt.subplots(1, 2, figsize=(10, 4)) + sc.pl.scatter(adata, color="score", basis="umap", ax=axs[0], show=False) + + extra = [a for a in fig.axes if a not in axs] + assert extra, "sc.pl.scatter should add a colorbar for a continuous color" + cb = extra[0] + user_ax_pos = axs[0].get_position() + cb_pos = cb.get_position() + # The colorbar should sit just to the right of the user's ax, not float + # away from it across the figure. + assert cb_pos.x0 >= user_ax_pos.x0, ( + f"Colorbar x0={cb_pos.x0:.3f} is left of user ax x0={user_ax_pos.x0:.3f}" + ) + assert cb_pos.x0 < user_ax_pos.x1 + 0.05, ( + f"Colorbar x0={cb_pos.x0:.3f} is far past user ax x1={user_ax_pos.x1:.3f}; " + "this would overlap a sibling axes." + ) + plt.close(fig) + + def test_scatter_raw(tmp_path): pbmc = pbmc68k_reduced()[:100].copy() raw_pth = tmp_path / "raw.png" From 3f9fcad95ecec9571bf60df898c8115c10bb7b36 Mon Sep 17 00:00:00 2001 From: gkneighb <26003+gkneighb@users.noreply.github.com> Date: Wed, 20 May 2026 23:13:29 -0400 Subject: [PATCH 2/2] docs: release notes entry for #4137 --- docs/release-notes/4137.fix.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 docs/release-notes/4137.fix.md diff --git a/docs/release-notes/4137.fix.md b/docs/release-notes/4137.fix.md new file mode 100644 index 0000000000..409a8452bb --- /dev/null +++ b/docs/release-notes/4137.fix.md @@ -0,0 +1 @@ +Anchor the {func}`scanpy.pl.scatter` colorbar to a caller-supplied `ax` instead of placing it in figure coordinates computed from scanpy's internal panel layout, which previously dropped the colorbar over a sibling axes.