Skip to content

Fix per-panel colorbars overflowing into neighbouring panels#688

Closed
timtreis wants to merge 12 commits into
mainfrom
fix/issue-687-colorbar-legend-overflow
Closed

Fix per-panel colorbars overflowing into neighbouring panels#688
timtreis wants to merge 12 commits into
mainfrom
fix/issue-687-colorbar-legend-overflow

Conversation

@timtreis
Copy link
Copy Markdown
Member

@timtreis timtreis commented May 28, 2026

Closes #687.

Problem

In multi-panel figures, each panel's colorbar was drawn with inset_axes anchored outside the panel (bbox_to_anchor=(1+pad, ...) on ax.transAxes). No layout engine reserves that space, so when panels get small the colorbar and its tick labels spill into the neighbouring panel.

Fix

Place the colorbar by stealing space from its own panelfig.colorbar(mappable, ax=ax, location=, fraction=, pad=) — exactly as scanpy does (plt.colorbar(cax, ax=ax, ...)). The colorbar now lives inside the panel's grid cell and is accounted for by the layout engine, so it can never overflow into an adjacent panel, at any figure size.

  • location (left/right/top/bottom) sets tick/label side and orientation automatically (removed the manual set_ticks_position/tick_params).
  • Multiple colorbars on one panel stack via repeated calls (matplotlib-native), replacing the manual offset-tracking.
  • colorbar_params (location/fraction/pad/label/custom kwargs) and per-layer alpha are preserved.
  • Removed the now-unused inset_axes / RendererBase imports and the offset/tracker bookkeeping (net −51 lines).

Categorical legends already use scanpy's shrink-then-place for multi-panel (multi_panel=True), so they are unchanged.

Baselines

Colorbar-bearing visual baselines are regenerated from the CI artifact (placement changed). Legend-only and non-colorbar baselines are untouched.

@codecov-commenter
Copy link
Copy Markdown

codecov-commenter commented May 28, 2026

Codecov Report

❌ Patch coverage is 92.00000% with 4 lines in your changes missing coverage. Please review.
✅ Project coverage is 75.98%. Comparing base (c499e8c) to head (d6becd6).

Files with missing lines Patch % Lines
src/spatialdata_plot/pl/basic.py 91.83% 2 Missing and 2 partials ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #688      +/-   ##
==========================================
- Coverage   75.98%   75.98%   -0.01%     
==========================================
  Files          14       14              
  Lines        4156     4164       +8     
  Branches      964      962       -2     
==========================================
+ Hits         3158     3164       +6     
- Misses        647      649       +2     
  Partials      351      351              
Files with missing lines Coverage Δ
src/spatialdata_plot/pl/render_params.py 88.75% <100.00%> (+0.04%) ⬆️
src/spatialdata_plot/pl/basic.py 78.90% <91.83%> (-0.14%) ⬇️
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Place each colorbar through a per-panel axes divider
(make_axes_locatable(ax).append_axes(location, size, pad)) instead of anchoring
an inset axes outside the panel. The divider steals space from the panel, so the
colorbar matches the (equal-aspect) plot's drawn extent and stays inside the
panel's grid cell — it can no longer overflow into a neighbouring panel.

- Colorbar height/width now matches the plot (fixes the equal-aspect mismatch
  that a plain fig.colorbar(ax=ax) would introduce).
- Stacked same-side colorbars use an absolute pad (CBAR_STACK_PAD_INCHES) wide
  enough to clear the inner colorbar's tick labels, preserving order and keeping
  both readable.
- location handles tick/label side; colorbar_params (location/width/pad/label/
  custom kwargs) and per-layer alpha are preserved.

Verified: single colorbar, adjust_pad/width, all four locations, two-on-same-side,
all-sides, and multiple-images-in-one-cs all render correctly at native size and
under the test thumbnail. Categorical legends already use scanpy's shrink-then-place.
@timtreis timtreis force-pushed the fix/issue-687-colorbar-legend-overflow branch from 0622a78 to b5710c5 Compare May 28, 2026 22:19
timtreis added 11 commits May 29, 2026 00:32
15 colorbar-bearing baselines updated to the new in-panel colorbar layout
(matches the plot extent, stacks with readable labels, no overflow). Verified the
control cases render correctly: adjust_pad/width, all/different sides,
two-on-same-side, per-side locations, and multiple-images-in-one-cs. Generated
from the py3.11-stable CI artifact.
The divider only knows about the axes box, so left/top/bottom colorbars landed on
the panel's own y-ticks, title, and x-ticks. Measure each side's decoration
clearance (tight bbox vs axes box, in inches) and pad the first colorbar on that
side past it, plus the requested pad. Right-side colorbars are unaffected. Colorbar
size is now absolute (fraction x axis extent) for consistent stacking.
Keep the colorbar width relative to the panel (size as a percentage) rather than
absolute inches; only the pad is absolute (decoration clearance + stacking gap).
This matches the previous inset width closely, so single right-side colorbars are
visually unchanged and only the genuinely-affected colorbars (left/top/bottom,
stacked, all-sides) get new baselines.
Use the absolute (clearance-aware) pad only where it's needed — sides with
ticks/labels/title to clear (left/top/bottom) and stacked colorbars. Sides with
negligible clearance (typically the default right) keep the relative pad, which
matches the historical placement, so single right-side colorbars stay visually
unchanged and baseline churn is limited to the genuinely-affected colorbars.
The decoration-clearance fix updates only the genuinely-affected colorbars: the
per-side locations (img top/left/bottom, all-sides, different-sides), stacked
multiple-images-in-one-cs, and a few colorbar-bearing image/label baselines.
Right-side single colorbars are unchanged. Generated from the py3.11-stable CI
artifact.
test_plot_can_render_multipolygons colors by a continuous value (right-side
colorbar) on a wide, short plot. For non-square aspects the divider's
space-stealing diverges slightly more from the old inset placement, so its
baseline needs updating too. Verified the colorbar still matches the plot height
with no overflow.
The fixed stacking pad cleared an inner colorbar's tick labels but not its axis
label (e.g. a rotated "instance_id"), so a longer label still overlapped the next
colorbar — worst for vertical (left/right) colorbars. Instead, draw each colorbar
and measure how far its ticks/labels/axis-label extend beyond its box on the outer
side, then pad the next stacked colorbar past that (plus a small gap). Replaces the
fixed CBAR_STACK_PAD_INCHES with the measured extent + CBAR_STACK_GAP_INCHES.
Stacked same-side colorbars (all-sides, two-on-same-side, multiple-images-in-one-cs,
stacked render_images, two-call labels) now sit clear of the previous colorbar's
tick labels and axis label. Generated from the py3.11-stable CI artifact.
…ct, orientation warning (#688)

- Extract `_extent_beyond_box_inches()` used by both the panel-decoration clearance
  and the stacked-colorbar measurement, with a guard for `get_tightbbox() is None`
  (invisible/empty axes) and non-positive dpi (returns 0 instead of crashing).
- Collapse the four per-location tick/label-position branches into a `_CBAR_TICK_SIDE`
  lookup.
- Warn instead of silently dropping a user-supplied `orientation` that conflicts with
  the one implied by the colorbar `location`.

Behavior-preserving: rendered colorbars are byte-identical across single, stacked,
all-sides, and per-location cases (verified locally), so no baselines change. The
per-colorbar `fig.canvas.draw()` is kept intentionally — it is load-bearing for the
layout engine (skipping it shifts wide colorbars), so it cannot be safely elided.
)

The labels two_calls baseline was not regenerated when the measured-pad fix for
stacked colorbars went in (the layout there changes substantially with two
stacked colorbars). Update it to the CI-rendered version so py3.11/py3.14 stable
match again.
`fig.colorbar(mappable)` already bakes the mappable's alpha into the colorbar's
QuadMesh facecolors (verified: alpha column = mappable.alpha straight after
construction). Our subsequent `cb.solids.set_alpha(spec.alpha)` then multiplied
on top, so the colorbar rendered at alpha squared and looked much paler than the
layer it represents.

Only apply `spec.alpha` when the mappable does not carry alpha of its own; if it
does, trust the inherited value. Visible improvement on every continuous-color
colorbar (especially labels at low fill_alpha, e.g.
`Labels_can_handle_dropping_small_labels_after_rasterize_continuous` and
`Labels_two_calls_with_coloring_result_in_two_colorbars`).
@timtreis
Copy link
Copy Markdown
Member Author

Converged to an equal implementation without benefits

@timtreis timtreis closed this May 30, 2026
@timtreis timtreis deleted the fix/issue-687-colorbar-legend-overflow branch May 30, 2026 20:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Per-panel colorbars/legends overflow into neighbouring panels in multi-panel plots

2 participants