Skip to content

Commit 3238391

Browse files
committed
Address colorbar review: dedup extent helper, None/dpi guard, tick dict, 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.
1 parent e230706 commit 3238391

1 file changed

Lines changed: 48 additions & 36 deletions

File tree

src/spatialdata_plot/pl/basic.py

Lines changed: 48 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
from matplotlib.backend_bases import RendererBase
2323
from matplotlib.colors import Colormap, LogNorm, Normalize
2424
from matplotlib.figure import Figure
25+
from matplotlib.transforms import Bbox
2526
from mpl_toolkits.axes_grid1 import make_axes_locatable
2627
from mpl_toolkits.axes_grid1.axes_divider import AxesDivider
2728
from mpl_toolkits.axes_grid1.axes_size import Fixed
@@ -91,6 +92,29 @@
9192
# so the colorbar keeps its relative pad (matching the historical placement) instead of an absolute one.
9293
_CBAR_DECORATION_EPS_INCHES = 0.05
9394

95+
# Per colorbar location: the axis ("x"/"y") its ticks live on, and the opposite side.
96+
_CBAR_TICK_SIDE = {"left": ("y", "right"), "right": ("y", "left"), "top": ("x", "bottom"), "bottom": ("x", "top")}
97+
98+
99+
def _extent_beyond_box_inches(box: Bbox, tight: Bbox | None, dpi: float, side: str) -> float:
100+
"""Inches that ``tight`` (an artist's full extent incl. ticks/labels) sticks out past ``box`` on ``side``.
101+
102+
Returns ``0.0`` when there is no overhang, when ``tight`` is ``None`` (an axes with nothing
103+
drawable), or when ``dpi`` is non-positive. Used both to clear a panel's own decorations and to
104+
clear a stacked colorbar's labels before placing the next one.
105+
"""
106+
if tight is None or dpi <= 0:
107+
return 0.0
108+
if side == "left":
109+
delta = box.x0 - tight.x0
110+
elif side == "right":
111+
delta = tight.x1 - box.x1
112+
elif side == "bottom":
113+
delta = box.y0 - tight.y0
114+
else: # top
115+
delta = tight.y1 - box.y1
116+
return max(0.0, float(delta) / dpi)
117+
94118

95119
@register_spatial_data_accessor("pl")
96120
class PlotAccessor:
@@ -1563,7 +1587,17 @@ def _draw_colorbar(
15631587
if location not in {"left", "right", "top", "bottom"}:
15641588
location = CBAR_DEFAULT_LOCATION
15651589
orientation = "vertical" if location in {"right", "left"} else "horizontal"
1566-
cbar_kwargs.pop("orientation", None)
1590+
# Orientation is fixed by the location (the divider places a left/right colorbar
1591+
# vertically and a top/bottom one horizontally); warn rather than silently ignore a
1592+
# conflicting user-supplied orientation.
1593+
user_orientation = cbar_kwargs.pop("orientation", None)
1594+
if user_orientation is not None and user_orientation != orientation:
1595+
warnings.warn(
1596+
f"`orientation` is determined by the colorbar location ('{location}' -> '{orientation}'); "
1597+
f"the requested '{user_orientation}' is ignored.",
1598+
UserWarning,
1599+
stacklevel=2,
1600+
)
15671601

15681602
fraction = float(cast(float | int, layout.get("fraction", base_layout["fraction"])))
15691603
pad = float(cast(float | int, layout.get("pad", base_layout["pad"])))
@@ -1594,22 +1628,11 @@ def _draw_colorbar(
15941628
)
15951629
side_counts[location] = n_on_side + 1
15961630
cb = fig.colorbar(spec.mappable, cax=cax, orientation=orientation, **cbar_kwargs)
1597-
if location == "left":
1598-
cb.ax.yaxis.set_ticks_position("left")
1599-
cb.ax.yaxis.set_label_position("left")
1600-
cb.ax.tick_params(labelleft=True, labelright=False)
1601-
elif location == "top":
1602-
cb.ax.xaxis.set_ticks_position("top")
1603-
cb.ax.xaxis.set_label_position("top")
1604-
cb.ax.tick_params(labeltop=True, labelbottom=False)
1605-
elif location == "right":
1606-
cb.ax.yaxis.set_ticks_position("right")
1607-
cb.ax.yaxis.set_label_position("right")
1608-
cb.ax.tick_params(labelright=True, labelleft=False)
1609-
elif location == "bottom":
1610-
cb.ax.xaxis.set_ticks_position("bottom")
1611-
cb.ax.xaxis.set_label_position("bottom")
1612-
cb.ax.tick_params(labelbottom=True, labeltop=False)
1631+
tick_axis, opposite = _CBAR_TICK_SIDE[location]
1632+
cb_axis = cb.ax.yaxis if tick_axis == "y" else cb.ax.xaxis
1633+
cb_axis.set_ticks_position(location)
1634+
cb_axis.set_label_position(location)
1635+
cb.ax.tick_params(**{f"label{location}": True, f"label{opposite}": False})
16131636

16141637
final_label = global_label_override or layer_label_override or spec.label
16151638
if final_label:
@@ -1618,20 +1641,14 @@ def _draw_colorbar(
16181641
with contextlib.suppress(Exception):
16191642
cb.solids.set_alpha(spec.alpha)
16201643

1621-
# Measure how far this colorbar's own ticks/labels/axis-label extend beyond its box on
1622-
# the outer side, so the next stacked colorbar on this side can be padded clear of them.
1644+
# Measure how far this colorbar's ticks/labels/axis-label extend beyond its box, so the
1645+
# next stacked colorbar on this side clears them. The draw is kept unconditionally: it is
1646+
# also load-bearing for the layout engine (skipping it shifts wide colorbars), so it is not
1647+
# safe to skip even for the last colorbar on a side.
16231648
fig.canvas.draw()
1624-
cb_box = cax.get_window_extent(renderer)
1625-
cb_tight = cax.get_tightbbox(renderer)
1626-
if location == "right":
1627-
outer = (cb_tight.x1 - cb_box.x1) / dpi
1628-
elif location == "left":
1629-
outer = (cb_box.x0 - cb_tight.x0) / dpi
1630-
elif location == "top":
1631-
outer = (cb_tight.y1 - cb_box.y1) / dpi
1632-
else: # bottom
1633-
outer = (cb_box.y0 - cb_tight.y0) / dpi
1634-
prev_outer[location] = max(0.0, outer)
1649+
prev_outer[location] = _extent_beyond_box_inches(
1650+
cb.ax.get_window_extent(renderer), cb.ax.get_tightbbox(renderer), dpi, location
1651+
)
16351652

16361653
# go through tree
16371654

@@ -1837,12 +1854,7 @@ def _draw_colorbar(
18371854
# the axes box, not its decorations).
18381855
box = axis.get_window_extent(renderer)
18391856
tight = axis.get_tightbbox(renderer)
1840-
clearance = {
1841-
"left": max(0.0, (box.x0 - tight.x0) / dpi),
1842-
"right": max(0.0, (tight.x1 - box.x1) / dpi),
1843-
"bottom": max(0.0, (box.y0 - tight.y0) / dpi),
1844-
"top": max(0.0, (tight.y1 - box.y1) / dpi),
1845-
}
1857+
clearance = {side: _extent_beyond_box_inches(box, tight, dpi, side) for side in _CBAR_TICK_SIDE}
18461858
axes_size_in = (box.width / dpi, box.height / dpi)
18471859
# One divider per panel so multiple colorbars on the same panel stack via the divider.
18481860
divider = make_axes_locatable(axis)

0 commit comments

Comments
 (0)