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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 modifiedtests/_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 modifiedtests/_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 modifiedtests/_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 modifiedtests/_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 modifiedtests/_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 modifiedtests/_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 modifiedtests/_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 modifiedtests/_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 modifiedtests/_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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 modifiedtests/_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 modifiedtests/_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 modifiedtests/_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 modifiedtests/_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 modifiedtests/_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 modifiedtests/_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 modifiedtests/_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 modifiedtests/_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 modifiedtests/_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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 modifiedtests/_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 modifiedtests/_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 modifiedtests/_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 modifiedtests/_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 modifiedtests/_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 modifiedtests/_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 modifiedtests/_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 modifiedtests/_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 modifiedtests/_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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 modifiedtests/_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 modifiedtests/_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 modifiedtests/_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 modifiedtests/_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 modifiedtests/_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 modifiedtests/_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 modifiedtests/_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 modifiedtests/_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 modifiedtests/_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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 modifiedtests/_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 modifiedtests/_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 modifiedtests/_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 modifiedtests/_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 modifiedtests/_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 modifiedtests/_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 modifiedtests/_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 modifiedtests/_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 modifiedtests/_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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 modifiedtests/_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 modifiedtests/_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 modifiedtests/_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 modifiedtests/_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 modifiedtests/_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 modifiedtests/_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 modifiedtests/_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 modifiedtests/_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 modifiedtests/_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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 modifiedtests/_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 modifiedtests/_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 modifiedtests/_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 modifiedtests/_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 modifiedtests/_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 modifiedtests/_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 modifiedtests/_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 modifiedtests/_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 modifiedtests/_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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 modifiedtests/_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 modifiedtests/_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 modifiedtests/_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 modifiedtests/_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 modifiedtests/_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 modifiedtests/_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 modifiedtests/_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 modifiedtests/_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 modifiedtests/_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