Skip to content

Commit 1d14fba

Browse files
timtreisclaude
andcommitted
fix(labels): robust side-by-side legends via draw-event repositioning
A legend is a fixed-pixel artist, so its axes-fraction footprint shifts whenever the axes is rescaled (constrained_layout, or a figure resize) — placing it once is unstable and overlaps/squashes. Mirror what colorbars get for free from their self-rescaling insets: recompute the side-by-side offsets on every draw via a draw_event callback, measuring each legend at the geometry actually rendered. Lone legends stay untitled in scanpy's default spot; 2+ are titled by their color column and laid left-to-right. Revert the single-legend baselines (unchanged now) to base; only the two two-element shapes plots change. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent ca8f7a7 commit 1d14fba

60 files changed

Lines changed: 35 additions & 30 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

src/spatialdata_plot/pl/basic.py

Lines changed: 27 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1877,34 +1877,40 @@ 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.02) -> None:
1881-
"""Title and stack the per-render categorical legends (#364) in the right margin.
1880+
def _layout_panel_legends(ax: Axes, fig: Figure, gap_px: float = 10.0) -> None:
1881+
"""Lay the per-render categorical legends (#364) left-to-right 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 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.
1884+
channel legends keep their own placement. A lone legend is left exactly as scanpy placed it
1885+
(untitled). When 2+ legends share an axis they are titled by their color column and placed
1886+
side-by-side.
1887+
1888+
A legend is a fixed-pixel artist (unlike a colorbar, which scales to fill its inset), so its
1889+
axes-fraction footprint changes whenever the axes is rescaled — by ``constrained_layout`` or by
1890+
a later figure resize (e.g. the test harness). Placing once is therefore unstable. Instead we
1891+
recompute the side-by-side offsets on every draw via a ``draw_event`` callback, measuring each
1892+
legend at the geometry actually being rendered — the fixed-pixel analogue of the colorbar
1893+
inset's self-rescaling.
18871894
"""
18881895
legends = [c for c in ax.get_children() if isinstance(c, Legend) and hasattr(c, "_sdata_column")]
1889-
if not legends:
1890-
return
1891-
for leg in legends:
1892-
if not leg.get_title().get_text():
1893-
leg.set_title(leg._sdata_column)
18941896
if len(legends) < 2:
18951897
return
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
19021898
for leg in legends:
1903-
leg.set_bbox_to_anchor((1.02, y), transform=ax.transAxes)
1904-
if hasattr(leg, "set_loc"):
1905-
leg.set_loc("upper left")
1906-
fig.canvas.draw()
1907-
y = leg.get_window_extent().transformed(inv).y0 - gap
1899+
if not leg.get_title().get_text(): # explicit title wins
1900+
leg.set_title(leg._sdata_column)
1901+
leg.set_loc("upper left")
1902+
1903+
def _reposition(_event: object = None) -> None:
1904+
renderer = fig.canvas.get_renderer()
1905+
ax_w = ax.get_window_extent().width or 1.0
1906+
x = 1.02
1907+
for leg in legends:
1908+
leg.set_bbox_to_anchor((x, 1.0), transform=ax.transAxes)
1909+
x += (leg.get_window_extent(renderer).width + gap_px) / ax_w
1910+
1911+
fig.canvas.draw() # establish geometry for the initial placement
1912+
_reposition()
1913+
fig.canvas.mpl_connect("draw_event", _reposition) # keep correct across resizes/redraws
19081914

19091915

19101916
def _layout_pending_colorbars(
-2.09 KB
1.28 KB
2.41 KB
7.33 KB
5.92 KB
2.1 KB
-1.97 KB
-1.97 KB
-2.32 KB

0 commit comments

Comments
 (0)