diff --git a/src/spatialdata_plot/pl/basic.py b/src/spatialdata_plot/pl/basic.py index 085d9b31..c343ca75 100644 --- a/src/spatialdata_plot/pl/basic.py +++ b/src/spatialdata_plot/pl/basic.py @@ -22,7 +22,10 @@ from matplotlib.backend_bases import RendererBase from matplotlib.colors import Colormap, LogNorm, Normalize from matplotlib.figure import Figure -from mpl_toolkits.axes_grid1.inset_locator import inset_axes +from matplotlib.transforms import Bbox +from mpl_toolkits.axes_grid1 import make_axes_locatable +from mpl_toolkits.axes_grid1.axes_divider import AxesDivider +from mpl_toolkits.axes_grid1.axes_size import Fixed from spatialdata import get_extent from spatialdata._utils import _deprecation_alias from spatialdata.transformations.operations import get_transformation @@ -43,6 +46,7 @@ CBAR_DEFAULT_FRACTION, CBAR_DEFAULT_LOCATION, CBAR_DEFAULT_PAD, + CBAR_STACK_GAP_INCHES, ChannelLegendEntry, CmapParams, ColorbarSpec, @@ -84,6 +88,33 @@ # once https://github.com/scverse/spatialdata/pull/689/ is in a release ColorLike = tuple[float, ...] | list[float] | str +# Below this clearance (inches), a colorbar side is treated as having no axis decorations to clear, +# so the colorbar keeps its relative pad (matching the historical placement) instead of an absolute one. +_CBAR_DECORATION_EPS_INCHES = 0.05 + +# Per colorbar location: the axis ("x"/"y") its ticks live on, and the opposite side. +_CBAR_TICK_SIDE = {"left": ("y", "right"), "right": ("y", "left"), "top": ("x", "bottom"), "bottom": ("x", "top")} + + +def _extent_beyond_box_inches(box: Bbox, tight: Bbox | None, dpi: float, side: str) -> float: + """Inches that ``tight`` (an artist's full extent incl. ticks/labels) sticks out past ``box`` on ``side``. + + Returns ``0.0`` when there is no overhang, when ``tight`` is ``None`` (an axes with nothing + drawable), or when ``dpi`` is non-positive. Used both to clear a panel's own decorations and to + clear a stacked colorbar's labels before placing the next one. + """ + if tight is None or dpi <= 0: + return 0.0 + if side == "left": + delta = box.x0 - tight.x0 + elif side == "right": + delta = tight.x1 - box.x1 + elif side == "bottom": + delta = box.y0 - tight.y0 + else: # top + delta = tight.y1 - box.y1 + return max(0.0, float(delta) / dpi) + @register_spatial_data_accessor("pl") class PlotAccessor: @@ -1522,9 +1553,13 @@ def show( def _draw_colorbar( spec: ColorbarSpec, fig: Figure, + divider: AxesDivider, + side_counts: dict[str, int], + clearance: dict[str, float], + axes_size_in: tuple[float, float], + prev_outer: dict[str, float], renderer: RendererBase, - base_offsets_axes: dict[str, float], - trackers_axes: dict[str, float], + dpi: float, ) -> None: norm = spec.mappable.norm if isinstance(norm, LogNorm): @@ -1551,63 +1586,73 @@ def _draw_colorbar( location = cast(str, layout.get("location", base_layout["location"])) if location not in {"left", "right", "top", "bottom"}: location = CBAR_DEFAULT_LOCATION - default_orientation = "vertical" if location in {"right", "left"} else "horizontal" - cbar_kwargs.setdefault("orientation", default_orientation) + orientation = "vertical" if location in {"right", "left"} else "horizontal" + # Orientation is fixed by the location (the divider places a left/right colorbar + # vertically and a top/bottom one horizontally); warn rather than silently ignore a + # conflicting user-supplied orientation. + user_orientation = cbar_kwargs.pop("orientation", None) + if user_orientation is not None and user_orientation != orientation: + warnings.warn( + f"`orientation` is determined by the colorbar location ('{location}' -> '{orientation}'); " + f"the requested '{user_orientation}' is ignored.", + UserWarning, + stacklevel=2, + ) fraction = float(cast(float | int, layout.get("fraction", base_layout["fraction"]))) pad = float(cast(float | int, layout.get("pad", base_layout["pad"]))) - if location in {"left", "right"}: - pad_axes = pad + trackers_axes[location] - x0 = -pad_axes - fraction if location == "left" else 1 + pad_axes - bbox = (float(x0), 0.0, float(fraction), 1.0) + # Append the colorbar axes through the panel's shared divider. This steals space from the + # panel (so the colorbar matches the equal-aspect plot's extent) and keeps it inside the + # panel's grid cell, never overflowing into a neighbouring panel. + # Pad of the first colorbar on a side clears the panel's own decorations (ticks/labels/ + # title); a side with negligible clearance (typically "right") keeps the relative pad to + # match the historical placement. Each subsequent (stacked) colorbar is padded past the + # *measured* outer extent of the previous one (its tick labels AND axis label) so it never + # overlaps it. + n_on_side = side_counts.get(location, 0) + ref_in = axes_size_in[0] if location in {"left", "right"} else axes_size_in[1] + side_clearance = clearance.get(location, 0.0) + pad_spec: str | Fixed + if n_on_side: + pad_spec = Fixed(prev_outer.get(location, 0.0) + CBAR_STACK_GAP_INCHES) + elif side_clearance > _CBAR_DECORATION_EPS_INCHES: + pad_spec = Fixed(side_clearance + max(pad, 0.0) * ref_in) else: - pad_axes = pad + trackers_axes[location] - y0 = -pad_axes - fraction if location == "bottom" else 1 + pad_axes - bbox = (0.0, float(y0), 1.0, float(fraction)) - cax = inset_axes( - spec.ax, - width="100%", - height="100%", - loc="center", - bbox_to_anchor=bbox, - bbox_transform=spec.ax.transAxes, - borderpad=0.0, + pad_spec = f"{max(pad, 0.0) * 100}%" + cax = divider.append_axes( + location, + size=f"{max(fraction, 0.0) * 100}%", + pad=pad_spec, + axes_class=Axes, ) - - cb = fig.colorbar(spec.mappable, cax=cax, **cbar_kwargs) - if location == "left": - cb.ax.yaxis.set_ticks_position("left") - cb.ax.yaxis.set_label_position("left") - cb.ax.tick_params(labelleft=True, labelright=False) - elif location == "top": - cb.ax.xaxis.set_ticks_position("top") - cb.ax.xaxis.set_label_position("top") - cb.ax.tick_params(labeltop=True, labelbottom=False) - elif location == "right": - cb.ax.yaxis.set_ticks_position("right") - cb.ax.yaxis.set_label_position("right") - cb.ax.tick_params(labelright=True, labelleft=False) - elif location == "bottom": - cb.ax.xaxis.set_ticks_position("bottom") - cb.ax.xaxis.set_label_position("bottom") - cb.ax.tick_params(labelbottom=True, labeltop=False) + side_counts[location] = n_on_side + 1 + cb = fig.colorbar(spec.mappable, cax=cax, orientation=orientation, **cbar_kwargs) + tick_axis, opposite = _CBAR_TICK_SIDE[location] + cb_axis = cb.ax.yaxis if tick_axis == "y" else cb.ax.xaxis + cb_axis.set_ticks_position(location) + cb_axis.set_label_position(location) + cb.ax.tick_params(**{f"label{location}": True, f"label{opposite}": False}) final_label = global_label_override or layer_label_override or spec.label if final_label: cb.set_label(final_label) - if spec.alpha is not None: + # `fig.colorbar(mappable)` already bakes the mappable's alpha into the colorbar's + # facecolors, so we only apply `spec.alpha` when the mappable carries no alpha of its own + # — otherwise we'd multiply alpha twice and the colorbar would render at alpha squared + # (much paler than the layer it represents). + if spec.alpha is not None and spec.mappable.get_alpha() is None: with contextlib.suppress(Exception): cb.solids.set_alpha(spec.alpha) - bbox_axes = cb.ax.get_tightbbox(renderer).transformed(spec.ax.transAxes.inverted()) - if location == "left": - trackers_axes["left"] = pad_axes + bbox_axes.width - elif location == "right": - trackers_axes["right"] = pad_axes + bbox_axes.width - elif location == "bottom": - trackers_axes["bottom"] = pad_axes + bbox_axes.height - elif location == "top": - trackers_axes["top"] = pad_axes + bbox_axes.height + + # Measure how far this colorbar's ticks/labels/axis-label extend beyond its box, so the + # next stacked colorbar on this side clears them. The draw is kept unconditionally: it is + # also load-bearing for the layout engine (skipping it shifts wide colorbars), so it is not + # safe to skip even for the last colorbar on a side. + fig.canvas.draw() + prev_outer[location] = _extent_beyond_box_inches( + cb.ax.get_window_extent(renderer), cb.ax.get_tightbbox(renderer), dpi, location + ) # go through tree @@ -1797,6 +1842,7 @@ def _draw_colorbar( fig = fig_params.fig fig.canvas.draw() renderer = fig.canvas.get_renderer() + dpi = fig.get_dpi() for axis, requests in pending_colorbars: unique_specs: list[ColorbarSpec] = [] seen_mappables: set[int] = set() @@ -1806,16 +1852,20 @@ def _draw_colorbar( continue seen_mappables.add(mappable_id) unique_specs.append(spec) - tight_bbox = axis.get_tightbbox(renderer).transformed(axis.transAxes.inverted()) - base_offsets_axes = { - "left": max(0.0, -tight_bbox.x0), - "right": max(0.0, tight_bbox.x1 - 1), - "bottom": max(0.0, -tight_bbox.y0), - "top": max(0.0, tight_bbox.y1 - 1), - } - trackers_axes = {k: base_offsets_axes[k] for k in base_offsets_axes} + # Clearance (inches) that the panel's own ticks, tick labels and title extend beyond + # the axes box on each side. The first colorbar on a side is padded past this so it + # clears those decorations instead of overlapping them (the divider only knows about + # the axes box, not its decorations). + box = axis.get_window_extent(renderer) + tight = axis.get_tightbbox(renderer) + clearance = {side: _extent_beyond_box_inches(box, tight, dpi, side) for side in _CBAR_TICK_SIDE} + axes_size_in = (box.width / dpi, box.height / dpi) + # One divider per panel so multiple colorbars on the same panel stack via the divider. + divider = make_axes_locatable(axis) + side_counts: dict[str, int] = {} + prev_outer: dict[str, float] = {} for spec in unique_specs: - _draw_colorbar(spec, fig, renderer, base_offsets_axes, trackers_axes) + _draw_colorbar(spec, fig, divider, side_counts, clearance, axes_size_in, prev_outer, renderer, dpi) if fig_params.fig is not None and save is not None: save_fig(fig_params.fig, path=save) diff --git a/src/spatialdata_plot/pl/render_params.py b/src/spatialdata_plot/pl/render_params.py index a8a7c928..8c6ea29f 100644 --- a/src/spatialdata_plot/pl/render_params.py +++ b/src/spatialdata_plot/pl/render_params.py @@ -219,6 +219,9 @@ class ChannelLegendEntry: CBAR_DEFAULT_LOCATION = "right" CBAR_DEFAULT_FRACTION = 0.075 CBAR_DEFAULT_PAD = 0.015 +# Small gap (inches) added beyond a stacked colorbar's measured outer extent (its tick labels and +# axis label) before placing the next colorbar on the same side, so they never overlap. +CBAR_STACK_GAP_INCHES = 0.08 @dataclass diff --git a/tests/_images/ColorbarControls_colorbar_can_adjust_pad.png b/tests/_images/ColorbarControls_colorbar_can_adjust_pad.png index ed34c034..f391fda9 100644 Binary files a/tests/_images/ColorbarControls_colorbar_can_adjust_pad.png and b/tests/_images/ColorbarControls_colorbar_can_adjust_pad.png differ diff --git a/tests/_images/ColorbarControls_colorbar_can_adjust_width.png b/tests/_images/ColorbarControls_colorbar_can_adjust_width.png index 1a591f7e..6b6ad67a 100644 Binary files a/tests/_images/ColorbarControls_colorbar_can_adjust_width.png and b/tests/_images/ColorbarControls_colorbar_can_adjust_width.png differ diff --git a/tests/_images/ColorbarControls_colorbar_can_have_colorbars_on_all_sides.png b/tests/_images/ColorbarControls_colorbar_can_have_colorbars_on_all_sides.png index 499b9413..629843fa 100644 Binary files a/tests/_images/ColorbarControls_colorbar_can_have_colorbars_on_all_sides.png and b/tests/_images/ColorbarControls_colorbar_can_have_colorbars_on_all_sides.png differ diff --git a/tests/_images/ColorbarControls_colorbar_can_have_colorbars_on_different_sides.png b/tests/_images/ColorbarControls_colorbar_can_have_colorbars_on_different_sides.png index 6a20052c..e2471e64 100644 Binary files a/tests/_images/ColorbarControls_colorbar_can_have_colorbars_on_different_sides.png and b/tests/_images/ColorbarControls_colorbar_can_have_colorbars_on_different_sides.png differ diff --git a/tests/_images/ColorbarControls_colorbar_can_have_two_colorbars_on_same_side.png b/tests/_images/ColorbarControls_colorbar_can_have_two_colorbars_on_same_side.png index cc704883..a6d42583 100644 Binary files a/tests/_images/ColorbarControls_colorbar_can_have_two_colorbars_on_same_side.png and b/tests/_images/ColorbarControls_colorbar_can_have_two_colorbars_on_same_side.png differ diff --git a/tests/_images/ColorbarControls_colorbar_img_bottom.png b/tests/_images/ColorbarControls_colorbar_img_bottom.png index 9ffa0f96..6efee594 100644 Binary files a/tests/_images/ColorbarControls_colorbar_img_bottom.png and b/tests/_images/ColorbarControls_colorbar_img_bottom.png differ diff --git a/tests/_images/ColorbarControls_colorbar_img_left.png b/tests/_images/ColorbarControls_colorbar_img_left.png index b23cbdc0..3bd504ac 100644 Binary files a/tests/_images/ColorbarControls_colorbar_img_left.png and b/tests/_images/ColorbarControls_colorbar_img_left.png differ diff --git a/tests/_images/ColorbarControls_colorbar_img_top.png b/tests/_images/ColorbarControls_colorbar_img_top.png index 6924ca0e..92166369 100644 Binary files a/tests/_images/ColorbarControls_colorbar_img_top.png and b/tests/_images/ColorbarControls_colorbar_img_top.png differ diff --git a/tests/_images/ColorbarControls_multiple_images_in_one_cs_result_in_multiple_colorbars.png b/tests/_images/ColorbarControls_multiple_images_in_one_cs_result_in_multiple_colorbars.png index a874a2db..ebc1cc4b 100644 Binary files a/tests/_images/ColorbarControls_multiple_images_in_one_cs_result_in_multiple_colorbars.png and b/tests/_images/ColorbarControls_multiple_images_in_one_cs_result_in_multiple_colorbars.png differ diff --git a/tests/_images/ColorbarControls_single_channel_default_channel_name_omits_label.png b/tests/_images/ColorbarControls_single_channel_default_channel_name_omits_label.png index 2fe14027..28d57132 100644 Binary files a/tests/_images/ColorbarControls_single_channel_default_channel_name_omits_label.png and b/tests/_images/ColorbarControls_single_channel_default_channel_name_omits_label.png differ diff --git a/tests/_images/Grayscale_grayscale_uint8.png b/tests/_images/Grayscale_grayscale_uint8.png index 6a9be146..eedd19d3 100644 Binary files a/tests/_images/Grayscale_grayscale_uint8.png and b/tests/_images/Grayscale_grayscale_uint8.png differ diff --git a/tests/_images/Images_can_stack_render_images.png b/tests/_images/Images_can_stack_render_images.png index 89a17ec0..e3dc34a7 100644 Binary files a/tests/_images/Images_can_stack_render_images.png and b/tests/_images/Images_can_stack_render_images.png differ diff --git a/tests/_images/Images_constant_channel_renders_as_midgrey.png b/tests/_images/Images_constant_channel_renders_as_midgrey.png index c7ffd0b1..94d10750 100644 Binary files a/tests/_images/Images_constant_channel_renders_as_midgrey.png and b/tests/_images/Images_constant_channel_renders_as_midgrey.png differ diff --git a/tests/_images/Labels_can_handle_dropping_small_labels_after_rasterize_continuous.png b/tests/_images/Labels_can_handle_dropping_small_labels_after_rasterize_continuous.png index cea046a3..5a89efcf 100644 Binary files a/tests/_images/Labels_can_handle_dropping_small_labels_after_rasterize_continuous.png and b/tests/_images/Labels_can_handle_dropping_small_labels_after_rasterize_continuous.png differ diff --git a/tests/_images/Labels_two_calls_with_coloring_result_in_two_colorbars.png b/tests/_images/Labels_two_calls_with_coloring_result_in_two_colorbars.png index 73d7a1ab..f7edb3bb 100644 Binary files a/tests/_images/Labels_two_calls_with_coloring_result_in_two_colorbars.png and b/tests/_images/Labels_two_calls_with_coloring_result_in_two_colorbars.png differ diff --git a/tests/_images/Shapes_can_render_multipolygons.png b/tests/_images/Shapes_can_render_multipolygons.png index 92cf6a37..15a17f0f 100644 Binary files a/tests/_images/Shapes_can_render_multipolygons.png and b/tests/_images/Shapes_can_render_multipolygons.png differ