Skip to content

Commit a3938cf

Browse files
timtreisclaude
andcommitted
fix(labels): axes-anchor stacked legends so the plot isn't squished
Anchor the stacked per-render legends in axes-fraction (not figure-fraction): when constrained_layout reserves right-margin space and rescales the axes, the legends move with it instead of leaving a gap that shrinks the plot to a thumbnail. Title each legend by its color column; assert palette offset accumulates across 3 renders. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 2780d56 commit a3938cf

2 files changed

Lines changed: 18 additions & 22 deletions

File tree

src/spatialdata_plot/pl/basic.py

Lines changed: 13 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1877,43 +1877,34 @@ def _draw_colorbar(
18771877
trackers_axes[location] = pad_axes + (bbox_axes.width if vertical else bbox_axes.height)
18781878

18791879

1880-
def _layout_panel_legends(ax: Axes, fig: Figure, gap: float = 0.01) -> None:
1880+
def _layout_panel_legends(ax: Axes, fig: Figure, gap: float = 0.02) -> None:
18811881
"""Title and stack the per-render categorical legends (#364) in the right margin.
18821882
18831883
Only legends this code created (tagged ``_sdata_column``) are touched, so fill/outline and
1884-
channel legends keep their own placement. Each legend is titled by its source column (matching
1885-
colorbars). When 2+ legends share an axis they are stacked top-to-bottom — the right-margin
1886-
convention — so wide legends grow the figure height on save instead of overflowing its right edge.
1884+
channel legends keep their own placement. Each legend is titled by the column passed to its
1885+
render call (an explicit title wins). A lone legend keeps scanpy's placement (just titled); 2+
1886+
are stacked top-to-bottom along the axes' right edge.
18871887
"""
18881888
legends = [c for c in ax.get_children() if isinstance(c, Legend) and hasattr(c, "_sdata_column")]
18891889
if not legends:
18901890
return
1891-
# Title each legend by its source column so it reads like the colorbars (an explicit title set
1892-
# earlier stays as-is). A lone legend keeps scanpy's placement; only its title is added here.
18931891
for leg in legends:
18941892
if not leg.get_title().get_text():
18951893
leg.set_title(leg._sdata_column)
18961894
if len(legends) < 2:
18971895
return
1898-
# 2+ legends share the axis. Let constrained_layout settle, then freeze it: otherwise it shrinks
1899-
# the axes to "make room" for the margin legends (squashing the plot, leaving a gap). Frozen, the
1900-
# legends still count for `bbox_inches="tight"` on save.
1901-
fig.canvas.draw()
1902-
fig.set_layout_engine("none")
1903-
invf = fig.transFigure.inverted()
1904-
ax_bb = ax.get_window_extent().transformed(invf)
1905-
left, top = ax_bb.x1 + gap, ax_bb.y1
1906-
# Anchor all at one point and settle, so heights are measured in a single consistent layout state.
1896+
# Anchor in axes-fraction so the legends stay glued to the axes' right edge: when
1897+
# constrained_layout reserves margin space and rescales the axes, axes-anchored legends move with
1898+
# it (figure-fraction anchoring would leave a gap and squash the plot). Each is placed just below
1899+
# the previous one's measured bottom.
1900+
inv = ax.transAxes.inverted()
1901+
y = 1.0
19071902
for leg in legends:
1908-
leg.set_bbox_to_anchor((left, top), transform=fig.transFigure)
1903+
leg.set_bbox_to_anchor((1.02, y), transform=ax.transAxes)
19091904
if hasattr(leg, "set_loc"):
19101905
leg.set_loc("upper left")
1911-
fig.canvas.draw()
1912-
heights = [leg.get_window_extent().transformed(invf).height for leg in legends]
1913-
y = top
1914-
for leg, h in zip(legends, heights, strict=True):
1915-
leg.set_bbox_to_anchor((left, y), transform=fig.transFigure)
1916-
y -= h + gap
1906+
fig.canvas.draw()
1907+
y = leg.get_window_extent().transformed(inv).y0 - gap
19171908

19181909

19191910
def _layout_pending_colorbars(

tests/pl/test_render_labels.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,11 @@ def test_three_categorical_label_renders_make_three_legends(self, sdata_blobs: S
153153
ax = plt.gcf().axes[0]
154154
titles = sorted(c.get_title().get_text() for c in ax.get_children() if isinstance(c, Legend))
155155
assert titles == ["cat0", "cat1", "cat2"]
156+
157+
# palette offset accumulates: every render skips all colors used by earlier ones, so all
158+
# three palettes are mutually disjoint (not just cat0 vs cat1).
159+
c0, c1, c2 = (set(sdata_blobs["table"].uns[f"cat{i}_colors"]) for i in range(3))
160+
assert c0.isdisjoint(c1) and c1.isdisjoint(c2) and c0.isdisjoint(c2)
156161
plt.close()
157162

158163
def test_single_categorical_label_render_legend_titled_by_column(self, sdata_blobs: SpatialData):

0 commit comments

Comments
 (0)