Skip to content

Commit b5710c5

Browse files
committed
Fix per-panel colorbars overflowing into neighbouring panels (#687)
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.
1 parent c499e8c commit b5710c5

2 files changed

Lines changed: 29 additions & 49 deletions

File tree

‎src/spatialdata_plot/pl/basic.py‎

Lines changed: 26 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,11 @@
1919
from dask.dataframe import DataFrame as DaskDataFrame
2020
from geopandas import GeoDataFrame
2121
from matplotlib.axes import Axes
22-
from matplotlib.backend_bases import RendererBase
2322
from matplotlib.colors import Colormap, LogNorm, Normalize
2423
from matplotlib.figure import Figure
25-
from mpl_toolkits.axes_grid1.inset_locator import inset_axes
24+
from mpl_toolkits.axes_grid1 import make_axes_locatable
25+
from mpl_toolkits.axes_grid1.axes_divider import AxesDivider
26+
from mpl_toolkits.axes_grid1.axes_size import Fixed
2627
from spatialdata import get_extent
2728
from spatialdata._utils import _deprecation_alias
2829
from spatialdata.transformations.operations import get_transformation
@@ -43,6 +44,7 @@
4344
CBAR_DEFAULT_FRACTION,
4445
CBAR_DEFAULT_LOCATION,
4546
CBAR_DEFAULT_PAD,
47+
CBAR_STACK_PAD_INCHES,
4648
ChannelLegendEntry,
4749
CmapParams,
4850
ColorbarSpec,
@@ -1519,13 +1521,7 @@ def show(
15191521
outline_legend_title=outline_legend_title,
15201522
)
15211523

1522-
def _draw_colorbar(
1523-
spec: ColorbarSpec,
1524-
fig: Figure,
1525-
renderer: RendererBase,
1526-
base_offsets_axes: dict[str, float],
1527-
trackers_axes: dict[str, float],
1528-
) -> None:
1524+
def _draw_colorbar(spec: ColorbarSpec, fig: Figure, divider: AxesDivider, side_counts: dict[str, int]) -> None:
15291525
norm = spec.mappable.norm
15301526
if isinstance(norm, LogNorm):
15311527
vmin, vmax = norm.vmin, norm.vmax
@@ -1551,31 +1547,28 @@ def _draw_colorbar(
15511547
location = cast(str, layout.get("location", base_layout["location"]))
15521548
if location not in {"left", "right", "top", "bottom"}:
15531549
location = CBAR_DEFAULT_LOCATION
1554-
default_orientation = "vertical" if location in {"right", "left"} else "horizontal"
1555-
cbar_kwargs.setdefault("orientation", default_orientation)
1550+
orientation = "vertical" if location in {"right", "left"} else "horizontal"
1551+
cbar_kwargs.pop("orientation", None)
15561552

15571553
fraction = float(cast(float | int, layout.get("fraction", base_layout["fraction"])))
15581554
pad = float(cast(float | int, layout.get("pad", base_layout["pad"])))
15591555

1560-
if location in {"left", "right"}:
1561-
pad_axes = pad + trackers_axes[location]
1562-
x0 = -pad_axes - fraction if location == "left" else 1 + pad_axes
1563-
bbox = (float(x0), 0.0, float(fraction), 1.0)
1564-
else:
1565-
pad_axes = pad + trackers_axes[location]
1566-
y0 = -pad_axes - fraction if location == "bottom" else 1 + pad_axes
1567-
bbox = (0.0, float(y0), 1.0, float(fraction))
1568-
cax = inset_axes(
1569-
spec.ax,
1570-
width="100%",
1571-
height="100%",
1572-
loc="center",
1573-
bbox_to_anchor=bbox,
1574-
bbox_transform=spec.ax.transAxes,
1575-
borderpad=0.0,
1556+
# Append the colorbar axes through the panel's shared divider. This steals space from the
1557+
# panel (so the colorbar matches the equal-aspect plot's extent) and keeps it inside the
1558+
# panel's grid cell, never overflowing into a neighbouring panel. Repeated appends on the
1559+
# same divider stack additional colorbars on that side; the first sits next to the plot
1560+
# with the requested (relative) pad, while each subsequent one uses an absolute pad wide
1561+
# enough to clear the previous colorbar's tick labels instead of overlapping them.
1562+
n_on_side = side_counts.get(location, 0)
1563+
pad_arg: str | Fixed = Fixed(CBAR_STACK_PAD_INCHES) if n_on_side else f"{max(pad, 0.0) * 100}%"
1564+
cax = divider.append_axes(
1565+
location,
1566+
size=f"{fraction * 100}%",
1567+
pad=pad_arg,
1568+
axes_class=Axes,
15761569
)
1577-
1578-
cb = fig.colorbar(spec.mappable, cax=cax, **cbar_kwargs)
1570+
side_counts[location] = n_on_side + 1
1571+
cb = fig.colorbar(spec.mappable, cax=cax, orientation=orientation, **cbar_kwargs)
15791572
if location == "left":
15801573
cb.ax.yaxis.set_ticks_position("left")
15811574
cb.ax.yaxis.set_label_position("left")
@@ -1599,15 +1592,6 @@ def _draw_colorbar(
15991592
if spec.alpha is not None:
16001593
with contextlib.suppress(Exception):
16011594
cb.solids.set_alpha(spec.alpha)
1602-
bbox_axes = cb.ax.get_tightbbox(renderer).transformed(spec.ax.transAxes.inverted())
1603-
if location == "left":
1604-
trackers_axes["left"] = pad_axes + bbox_axes.width
1605-
elif location == "right":
1606-
trackers_axes["right"] = pad_axes + bbox_axes.width
1607-
elif location == "bottom":
1608-
trackers_axes["bottom"] = pad_axes + bbox_axes.height
1609-
elif location == "top":
1610-
trackers_axes["top"] = pad_axes + bbox_axes.height
16111595

16121596
# go through tree
16131597

@@ -1795,8 +1779,6 @@ def _draw_colorbar(
17951779

17961780
if pending_colorbars and fig_params.fig is not None:
17971781
fig = fig_params.fig
1798-
fig.canvas.draw()
1799-
renderer = fig.canvas.get_renderer()
18001782
for axis, requests in pending_colorbars:
18011783
unique_specs: list[ColorbarSpec] = []
18021784
seen_mappables: set[int] = set()
@@ -1806,16 +1788,11 @@ def _draw_colorbar(
18061788
continue
18071789
seen_mappables.add(mappable_id)
18081790
unique_specs.append(spec)
1809-
tight_bbox = axis.get_tightbbox(renderer).transformed(axis.transAxes.inverted())
1810-
base_offsets_axes = {
1811-
"left": max(0.0, -tight_bbox.x0),
1812-
"right": max(0.0, tight_bbox.x1 - 1),
1813-
"bottom": max(0.0, -tight_bbox.y0),
1814-
"top": max(0.0, tight_bbox.y1 - 1),
1815-
}
1816-
trackers_axes = {k: base_offsets_axes[k] for k in base_offsets_axes}
1791+
# One divider per panel so multiple colorbars on the same panel stack via the divider.
1792+
divider = make_axes_locatable(axis)
1793+
side_counts: dict[str, int] = {}
18171794
for spec in unique_specs:
1818-
_draw_colorbar(spec, fig, renderer, base_offsets_axes, trackers_axes)
1795+
_draw_colorbar(spec, fig, divider, side_counts)
18191796

18201797
if fig_params.fig is not None and save is not None:
18211798
save_fig(fig_params.fig, path=save)

‎src/spatialdata_plot/pl/render_params.py‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,9 @@ class ChannelLegendEntry:
219219
CBAR_DEFAULT_LOCATION = "right"
220220
CBAR_DEFAULT_FRACTION = 0.075
221221
CBAR_DEFAULT_PAD = 0.015
222+
# Absolute pad (inches) between stacked colorbars on the same side, wide enough to clear the
223+
# inner colorbar's tick labels so they don't overlap the next colorbar.
224+
CBAR_STACK_PAD_INCHES = 0.5
222225

223226

224227
@dataclass

0 commit comments

Comments
 (0)