Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
164 changes: 107 additions & 57 deletions src/spatialdata_plot/pl/basic.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -43,6 +46,7 @@
CBAR_DEFAULT_FRACTION,
CBAR_DEFAULT_LOCATION,
CBAR_DEFAULT_PAD,
CBAR_STACK_GAP_INCHES,
ChannelLegendEntry,
CmapParams,
ColorbarSpec,
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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):
Expand All @@ -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

Expand Down Expand Up @@ -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()
Expand All @@ -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)
Expand Down
3 changes: 3 additions & 0 deletions src/spatialdata_plot/pl/render_params.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Binary file modified tests/_images/ColorbarControls_colorbar_can_adjust_pad.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified tests/_images/ColorbarControls_colorbar_can_adjust_width.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified tests/_images/ColorbarControls_colorbar_img_bottom.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified tests/_images/ColorbarControls_colorbar_img_left.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified tests/_images/ColorbarControls_colorbar_img_top.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified tests/_images/Grayscale_grayscale_uint8.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified tests/_images/Images_can_stack_render_images.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified tests/_images/Images_constant_channel_renders_as_midgrey.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified tests/_images/Shapes_can_render_multipolygons.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading