diff --git a/src/spatialdata_plot/pl/basic.py b/src/spatialdata_plot/pl/basic.py index 41965dec..521d5a23 100644 --- a/src/spatialdata_plot/pl/basic.py +++ b/src/spatialdata_plot/pl/basic.py @@ -54,6 +54,7 @@ ) from spatialdata_plot.pl.utils import ( _RENDER_CMD_TO_CS_FLAG, + _draw_scalebar, _get_cs_contents, _get_elements_to_be_rendered, _get_valid_cs, @@ -889,6 +890,10 @@ def show( return_ax: bool = False, save: str | Path | None = None, show: bool | None = None, + scalebar_dx: float | None = None, + scalebar_units: str = "um", + scalebar_params: dict[str, Any] | None = None, + legend_params: dict[str, Any] | None = None, ) -> Axes | list[Axes] | None: """ Execute the plotting tree and display the final figure. @@ -949,6 +954,22 @@ def show( automatically when running in non-interactive mode (scripts) and suppressed in interactive sessions (e.g. Jupyter). When ``ax`` is provided by the user, defaults to ``False`` to allow further modifications. + scalebar_dx : float | None + Physical size of one axes-unit in ``scalebar_units``. If ``None``, no scalebar is drawn. + SpatialData coordinate systems carry no unit metadata, so this value must be supplied + explicitly (e.g. ``1.0`` when axes are already in micrometers; the microns-per-pixel + value when axes are in image pixels). + scalebar_units : str, default "um" + Unit string for the scalebar (passed to :class:`matplotlib_scalebar.scalebar.ScaleBar`). + Only takes effect when ``scalebar_dx`` is set. + scalebar_params : dict[str, Any] | None + Extra keyword arguments forwarded to :class:`matplotlib_scalebar.scalebar.ScaleBar`, + e.g. ``{"location": "lower right", "color": "white", "length_fraction": 0.25}``. + See the matplotlib-scalebar documentation for the full list of options. + legend_params : dict[str, Any] | None + Bundled legend options; overrides the matching ``legend_*`` flat kwargs. Accepted keys: + ``location`` (or ``loc``), ``fontsize``, ``fontweight``, ``fontoutline``, + ``na_in_legend``. Unknown keys raise ``ValueError``. Returns ------- @@ -986,6 +1007,10 @@ def show( return_ax, save, show, + scalebar_dx, + scalebar_units, + scalebar_params, + legend_params, ) if fig is not None and not isinstance(ax, Sequence): @@ -1098,7 +1123,7 @@ def show( raise ValueError(msg) # set up canvas - fig_params, scalebar_params = _prepare_params_plot( + fig_params, scalebar_params_obj = _prepare_params_plot( num_panels=len(coordinate_systems), figsize=figsize, dpi=dpi, @@ -1108,15 +1133,25 @@ def show( hspace=hspace, ncols=ncols, frameon=frameon, + scalebar_dx=scalebar_dx, + scalebar_units=scalebar_units, + scalebar_kwargs=scalebar_params, ) - legend_colorbar = colorbar - legend_params = LegendParams( + if legend_params: + legend_fontsize = legend_params.get("fontsize", legend_fontsize) + legend_fontweight = legend_params.get("fontweight", legend_fontweight) + # `loc` is matplotlib.Legend's native key; `location` aligns with colorbar/scalebar. + legend_loc = legend_params.get("location", legend_params.get("loc", legend_loc)) + legend_fontoutline = legend_params.get("fontoutline", legend_fontoutline) + na_in_legend = legend_params.get("na_in_legend", na_in_legend) + + legend_params_obj = LegendParams( legend_fontsize=legend_fontsize, legend_fontweight=legend_fontweight, legend_loc=legend_loc, legend_fontoutline=legend_fontoutline, na_in_legend=na_in_legend, - colorbar=legend_colorbar, + colorbar=colorbar, ) def _draw_colorbar( @@ -1208,7 +1243,7 @@ def _draw_colorbar( ) ax = fig_params.ax if fig_params.axs is None else fig_params.axs[i] assert isinstance(ax, Axes) - axis_colorbar_requests: list[ColorbarSpec] | None = [] if legend_params.colorbar else None + axis_colorbar_requests: list[ColorbarSpec] | None = [] if legend_params_obj.colorbar else None axis_channel_legend_entries: list[ChannelLegendEntry] = [] wants_images = False @@ -1237,8 +1272,7 @@ def _draw_colorbar( coordinate_system=cs, ax=ax, fig_params=fig_params, - scalebar_params=scalebar_params, - legend_params=legend_params, + legend_params=legend_params_obj, colorbar_requests=axis_colorbar_requests, channel_legend_entries=axis_channel_legend_entries, rasterize=rasterize, @@ -1256,8 +1290,7 @@ def _draw_colorbar( coordinate_system=cs, ax=ax, fig_params=fig_params, - scalebar_params=scalebar_params, - legend_params=legend_params, + legend_params=legend_params_obj, colorbar_requests=axis_colorbar_requests, ) @@ -1273,8 +1306,7 @@ def _draw_colorbar( coordinate_system=cs, ax=ax, fig_params=fig_params, - scalebar_params=scalebar_params, - legend_params=legend_params, + legend_params=legend_params_obj, colorbar_requests=axis_colorbar_requests, ) @@ -1309,8 +1341,7 @@ def _draw_colorbar( coordinate_system=cs, ax=ax, fig_params=fig_params, - scalebar_params=scalebar_params, - legend_params=legend_params, + legend_params=legend_params_obj, colorbar_requests=axis_colorbar_requests, rasterize=rasterize, ) @@ -1350,11 +1381,13 @@ def _draw_colorbar( ax.set_xlim(x_min, x_max) ax.set_ylim(y_max, y_min) # (0, 0) is top-left - if legend_params.colorbar and axis_colorbar_requests: + if legend_params_obj.colorbar and axis_colorbar_requests: pending_colorbars.append((ax, axis_colorbar_requests)) if axis_channel_legend_entries: - _draw_channel_legend(ax, axis_channel_legend_entries, legend_params, fig_params) + _draw_channel_legend(ax, axis_channel_legend_entries, legend_params_obj, fig_params) + + _draw_scalebar(ax, scalebar_params_obj, panel_idx=i) if pending_colorbars and fig_params.fig is not None: fig = fig_params.fig diff --git a/src/spatialdata_plot/pl/render.py b/src/spatialdata_plot/pl/render.py index eb0fc300..c1910fb6 100644 --- a/src/spatialdata_plot/pl/render.py +++ b/src/spatialdata_plot/pl/render.py @@ -53,7 +53,6 @@ LabelsRenderParams, LegendParams, PointsRenderParams, - ScalebarParams, ShapesRenderParams, ) from spatialdata_plot.pl.utils import ( @@ -268,9 +267,8 @@ def _add_legend_and_colorbar( colorbar: bool | str | None, colorbar_params: dict[str, object] | None, colorbar_requests: list[ColorbarSpec] | None, - scalebar_params: ScalebarParams, ) -> None: - """Add legend, colorbar, and scalebar decorations if the color vector warrants them.""" + """Add legend and colorbar decorations if the color vector warrants them.""" if not _want_decorations(color_vector, na_color): return @@ -309,8 +307,6 @@ def _add_legend_and_colorbar( colorbar_params, col_for_color if isinstance(col_for_color, str) else None, ), - scalebar_dx=scalebar_params.scalebar_dx, - scalebar_units=scalebar_params.scalebar_units, ) @@ -320,7 +316,6 @@ def _render_shapes( coordinate_system: str, ax: matplotlib.axes.SubplotBase, fig_params: FigParams, - scalebar_params: ScalebarParams, legend_params: LegendParams, colorbar_requests: list[ColorbarSpec] | None = None, ) -> None: @@ -696,7 +691,6 @@ def _render_shapes( colorbar=render_params.colorbar, colorbar_params=render_params.colorbar_params, colorbar_requests=colorbar_requests, - scalebar_params=scalebar_params, ) @@ -706,7 +700,6 @@ def _render_points( coordinate_system: str, ax: matplotlib.axes.SubplotBase, fig_params: FigParams, - scalebar_params: ScalebarParams, legend_params: LegendParams, colorbar_requests: list[ColorbarSpec] | None = None, ) -> None: @@ -1050,7 +1043,6 @@ def _render_points( colorbar=render_params.colorbar, colorbar_params=render_params.colorbar_params, colorbar_requests=colorbar_requests, - scalebar_params=scalebar_params, ) @@ -1197,7 +1189,6 @@ def _render_images( coordinate_system: str, ax: matplotlib.axes.SubplotBase, fig_params: FigParams, - scalebar_params: ScalebarParams, legend_params: LegendParams, rasterize: bool, colorbar_requests: list[ColorbarSpec] | None = None, @@ -1592,7 +1583,6 @@ def _render_labels( coordinate_system: str, ax: matplotlib.axes.SubplotBase, fig_params: FigParams, - scalebar_params: ScalebarParams, legend_params: LegendParams, rasterize: bool, colorbar_requests: list[ColorbarSpec] | None = None, @@ -1821,7 +1811,4 @@ def _draw_labels( render_params.colorbar_params, col_for_color if isinstance(col_for_color, str) else None, ), - scalebar_dx=scalebar_params.scalebar_dx, - scalebar_units=scalebar_params.scalebar_units, - # scalebar_kwargs=scalebar_params.scalebar_kwargs, ) diff --git a/src/spatialdata_plot/pl/render_params.py b/src/spatialdata_plot/pl/render_params.py index e53c07e0..bc69f16a 100644 --- a/src/spatialdata_plot/pl/render_params.py +++ b/src/spatialdata_plot/pl/render_params.py @@ -1,8 +1,8 @@ from __future__ import annotations -from collections.abc import Callable, Sequence -from dataclasses import dataclass -from typing import Literal +from collections.abc import Callable, Mapping, Sequence +from dataclasses import dataclass, field +from typing import Any, Literal import numpy as np from matplotlib.axes import Axes @@ -220,6 +220,7 @@ class ScalebarParams: scalebar_dx: Sequence[float] | None = None scalebar_units: Sequence[str] | None = None + scalebar_kwargs: Mapping[str, Any] = field(default_factory=dict) @dataclass diff --git a/src/spatialdata_plot/pl/utils.py b/src/spatialdata_plot/pl/utils.py index 1aa283e9..d747289b 100644 --- a/src/spatialdata_plot/pl/utils.py +++ b/src/spatialdata_plot/pl/utils.py @@ -7,7 +7,6 @@ from copy import copy from functools import partial from pathlib import Path -from types import MappingProxyType from typing import Any, Literal import dask @@ -273,6 +272,7 @@ def _prepare_params_plot( # this args will be inferred from coordinate system scalebar_dx: float | Sequence[float] | None = None, scalebar_units: str | Sequence[str] | None = None, + scalebar_kwargs: Mapping[str, Any] | None = None, ) -> tuple[FigParams, ScalebarParams]: # handle axes and size wspace = 0.75 / rcParams["figure.figsize"][0] + 0.02 if wspace is None else wspace @@ -325,11 +325,29 @@ def _prepare_params_plot( num_panels=num_panels, frameon=frameon, ) - scalebar_params = ScalebarParams(scalebar_dx=scalebar_dx, scalebar_units=scalebar_units) + scalebar_params = ScalebarParams( + scalebar_dx=scalebar_dx, + scalebar_units=scalebar_units, + scalebar_kwargs=dict(scalebar_kwargs) if scalebar_kwargs else {}, + ) return fig_params, scalebar_params +def _draw_scalebar(ax: Axes, scalebar_params: ScalebarParams, panel_idx: int) -> None: + """Attach a single :class:`matplotlib_scalebar.scalebar.ScaleBar` to ``ax``. + + No-op when ``scalebar_dx`` is ``None``. ``scalebar_dx`` and ``scalebar_units`` are + broadcast lists indexed by the panel position; ``scalebar_kwargs`` is forwarded + verbatim to :class:`~matplotlib_scalebar.scalebar.ScaleBar`. + """ + if scalebar_params.scalebar_dx is None or scalebar_params.scalebar_units is None: + return + dx = scalebar_params.scalebar_dx[panel_idx] + units = scalebar_params.scalebar_units[panel_idx] + ax.add_artist(ScaleBar(dx, units=units, **scalebar_params.scalebar_kwargs)) + + def _get_cs_contents(sdata: sd.SpatialData) -> pd.DataFrame: """Check which coordinate systems contain which elements and return that info.""" cs_mapping = _get_coordinate_system_mapping(sdata) @@ -1694,9 +1712,6 @@ def _decorate_axs( colorbar_params: dict[str, object] | None = None, colorbar_requests: list[ColorbarSpec] | None = None, colorbar_label: str | None = None, - scalebar_dx: Sequence[float] | None = None, - scalebar_units: Sequence[str] | None = None, - scalebar_kwargs: Mapping[str, Any] = MappingProxyType({}), ) -> Axes: if value_to_plot is not None: # if only dots were plotted without an associated value @@ -1743,10 +1758,6 @@ def _decorate_axs( ) ) - if isinstance(scalebar_dx, list) and isinstance(scalebar_units, list): - scalebar = ScaleBar(scalebar_dx, units=scalebar_units, **scalebar_kwargs) - ax.add_artist(scalebar) - return ax @@ -2159,6 +2170,10 @@ def _validate_show_parameters( return_ax: bool, save: str | Path | None, show: bool | None, + scalebar_dx: float | None, + scalebar_units: str, + scalebar_params: dict[str, Any] | None, + legend_params: dict[str, Any] | None, ) -> None: if coordinate_systems is not None and not isinstance(coordinate_systems, list | str): raise TypeError("Parameter 'coordinate_systems' must be a string or a list of strings.") @@ -2248,6 +2263,28 @@ def _validate_show_parameters( if show is not None and not isinstance(show, bool): raise TypeError("Parameter 'show' must be a boolean or None.") + if scalebar_dx is not None: + if not isinstance(scalebar_dx, int | float) or isinstance(scalebar_dx, bool): + raise TypeError("Parameter 'scalebar_dx' must be a number or None.") + if scalebar_dx <= 0: + raise ValueError("Parameter 'scalebar_dx' must be > 0.") + if not isinstance(scalebar_units, str): + raise TypeError("Parameter 'scalebar_units' must be a string.") + + if scalebar_params is not None and not isinstance(scalebar_params, dict): + raise TypeError("Parameter 'scalebar_params' must be a dictionary or None.") + + if legend_params is not None: + if not isinstance(legend_params, dict): + raise TypeError("Parameter 'legend_params' must be a dictionary or None.") + # `loc` is matplotlib.Legend's native key; `location` aligns with colorbar_params / scalebar_params. + allowed_legend_keys = {"loc", "location", "fontsize", "fontweight", "fontoutline", "na_in_legend"} + unknown = set(legend_params) - allowed_legend_keys + if unknown: + raise ValueError( + f"Unknown legend_params key(s): {sorted(unknown)}. Allowed keys: {sorted(allowed_legend_keys)}." + ) + def _type_check_params(param_dict: dict[str, Any], element_type: str) -> dict[str, Any]: colorbar = param_dict.get("colorbar", "auto") diff --git a/tests/_images/Show_scalebar_compact.png b/tests/_images/Show_scalebar_compact.png new file mode 100644 index 00000000..4d7865d1 Binary files /dev/null and b/tests/_images/Show_scalebar_compact.png differ diff --git a/tests/_images/Show_scalebar_default.png b/tests/_images/Show_scalebar_default.png new file mode 100644 index 00000000..76d76f95 Binary files /dev/null and b/tests/_images/Show_scalebar_default.png differ diff --git a/tests/_images/Show_scalebar_fixed_value_label.png b/tests/_images/Show_scalebar_fixed_value_label.png new file mode 100644 index 00000000..a89d63bd Binary files /dev/null and b/tests/_images/Show_scalebar_fixed_value_label.png differ diff --git a/tests/_images/Show_scalebar_no_frame.png b/tests/_images/Show_scalebar_no_frame.png new file mode 100644 index 00000000..6efeaa66 Binary files /dev/null and b/tests/_images/Show_scalebar_no_frame.png differ diff --git a/tests/_images/Show_scalebar_styled.png b/tests/_images/Show_scalebar_styled.png new file mode 100644 index 00000000..e7250f38 Binary files /dev/null and b/tests/_images/Show_scalebar_styled.png differ diff --git a/tests/pl/test_show.py b/tests/pl/test_show.py index de3fb5a8..bb1358ad 100644 --- a/tests/pl/test_show.py +++ b/tests/pl/test_show.py @@ -42,6 +42,39 @@ def test_plot_no_decorations(self, sdata_blobs: SpatialData): """Visual test: frameon=False + title='' produces just the plot content (regression for #204).""" sdata_blobs.pl.render_images(element="blobs_image").pl.show(frameon=False, title="", colorbar=False) + def test_plot_scalebar_default(self, sdata_blobs: SpatialData): + """Visual test: scalebar_dx attaches a default scalebar (regression for #614).""" + sdata_blobs.pl.render_images(element="blobs_image").pl.show(scalebar_dx=1.0) + + def test_plot_scalebar_styled(self, sdata_blobs: SpatialData): + """Visual test: scalebar_params overrides location and color (regression for #614).""" + sdata_blobs.pl.render_images(element="blobs_image").pl.show( + scalebar_dx=1.0, + scalebar_units="um", + scalebar_params={"location": "lower right", "color": "white", "box_alpha": 0.6}, + ) + + def test_plot_scalebar_no_frame(self, sdata_blobs: SpatialData): + """Visual test: frameon=False drops the surrounding box.""" + sdata_blobs.pl.render_images(element="blobs_image").pl.show( + scalebar_dx=1.0, + scalebar_params={"frameon": False, "color": "white"}, + ) + + def test_plot_scalebar_compact(self, sdata_blobs: SpatialData): + """Visual test: padding and length_fraction shrink the scalebar footprint.""" + sdata_blobs.pl.render_images(element="blobs_image").pl.show( + scalebar_dx=1.0, + scalebar_params={"length_fraction": 0.15, "pad": 0.1, "border_pad": 0.1}, + ) + + def test_plot_scalebar_fixed_value_label(self, sdata_blobs: SpatialData): + """Visual test: fixed_value pins the bar length and label overrides the displayed text.""" + sdata_blobs.pl.render_images(element="blobs_image").pl.show( + scalebar_dx=1.0, + scalebar_params={"fixed_value": 200, "label": "200 um"}, + ) + def test_plot_user_ax_dpi_preserved(self, sdata_blobs: SpatialData): """Visual test: low DPI produces visibly pixelated rasterization (regression for #310). @@ -144,3 +177,164 @@ def test_dpi_default_used_when_no_ax(sdata_blobs: SpatialData): fig = ax.get_figure() assert fig.get_dpi() == matplotlib.rcParams["figure.dpi"] plt.close(fig) + + +def _scalebars_on(ax): + from matplotlib_scalebar.scalebar import ScaleBar + + return [c for c in ax.get_children() if isinstance(c, ScaleBar)] + + +def test_scalebar_default_off(sdata_blobs: SpatialData): + """Without scalebar_dx, no ScaleBar artist is attached (preserves existing behavior).""" + ax = sdata_blobs.pl.render_shapes(element="blobs_circles").pl.show(return_ax=True, show=False) + assert _scalebars_on(ax) == [] + plt.close("all") + + +def test_scalebar_dx_attaches_one_scalebar(sdata_blobs: SpatialData): + """show(scalebar_dx=...) attaches exactly one ScaleBar to the axes (regression for #614).""" + ax = sdata_blobs.pl.render_shapes(element="blobs_circles").pl.show( + scalebar_dx=1.0, scalebar_units="um", return_ax=True, show=False + ) + sbs = _scalebars_on(ax) + assert len(sbs) == 1 + assert sbs[0].units == "um" + plt.close("all") + + +def test_scalebar_units_default_is_um(sdata_blobs: SpatialData): + """Omitting scalebar_units falls back to 'um' (matches scanpy/squidpy convention).""" + ax = sdata_blobs.pl.render_shapes(element="blobs_circles").pl.show(scalebar_dx=2.5, return_ax=True, show=False) + sbs = _scalebars_on(ax) + assert len(sbs) == 1 + assert sbs[0].units == "um" + plt.close("all") + + +def test_scalebar_params_passthrough(sdata_blobs: SpatialData): + """scalebar_params keys are forwarded verbatim to matplotlib_scalebar.ScaleBar.""" + ax = sdata_blobs.pl.render_shapes(element="blobs_circles").pl.show( + scalebar_dx=1.0, + scalebar_params={"location": "lower right", "color": "red", "box_alpha": 0.5}, + return_ax=True, + show=False, + ) + sbs = _scalebars_on(ax) + assert len(sbs) == 1 + # ScaleBar normalizes "lower right" to its integer code (4); just verify the constructor accepted it + # by checking attributes that survive verbatim. + assert sbs[0].color == "red" + assert sbs[0].box_alpha == 0.5 + plt.close("all") + + +def test_scalebar_single_panel_multi_layer_attaches_one(sdata_blobs: SpatialData): + """Stacking render_images + render_shapes on one axis must produce exactly one scalebar. + + The pre-fix code drew the scalebar inside per-layer decoration logic, so a multi-layer + plot would have attached duplicates. The fix moves drawing to the per-axis tail of show(). + """ + ax = ( + sdata_blobs.pl.render_images(element="blobs_image") + .pl.render_shapes(element="blobs_circles") + .pl.show(scalebar_dx=1.0, return_ax=True, show=False) + ) + assert len(_scalebars_on(ax)) == 1 + plt.close("all") + + +def test_scalebar_multi_panel_attaches_one_per_axis(sdata_blobs: SpatialData): + """Each panel in a multi-panel plot gets its own ScaleBar.""" + set_transformation(sdata_blobs["blobs_image"], Identity(), "second_cs") + axs = sdata_blobs.pl.render_images(element="blobs_image").pl.show(scalebar_dx=1.0, return_ax=True, show=False) + for ax in axs: + assert len(_scalebars_on(ax)) == 1 + plt.close("all") + + +@pytest.mark.parametrize( + ("kwargs", "exc"), + [ + ({"scalebar_dx": "bad"}, TypeError), + ({"scalebar_dx": True}, TypeError), # bool is rejected even though it is an int + ({"scalebar_dx": 0}, ValueError), + ({"scalebar_dx": -1.5}, ValueError), + ({"scalebar_dx": 1.0, "scalebar_units": 42}, TypeError), + ({"scalebar_dx": 1.0, "scalebar_params": []}, TypeError), + ], +) +def test_scalebar_validation_rejects_bad_inputs(sdata_blobs: SpatialData, kwargs, exc): + """_validate_show_parameters surfaces actionable errors for bad scalebar inputs.""" + with pytest.raises(exc): + sdata_blobs.pl.render_shapes(element="blobs_circles").pl.show(show=False, **kwargs) + plt.close("all") + + +def test_legend_params_dict_form(sdata_blobs: SpatialData): + """legend_params dict form is accepted and applied (additive sugar around flat legend_* kwargs).""" + ax = sdata_blobs.pl.render_shapes(element="blobs_circles").pl.show( + legend_params={"loc": "upper right", "fontsize": 14}, + return_ax=True, + show=False, + ) + legend = ax.get_legend() + if legend is not None: + # When a legend is rendered, fontsize was forwarded. + for text in legend.get_texts(): + assert text.get_fontsize() == 14 + plt.close("all") + + +def test_legend_params_overrides_flat_kwarg(sdata_blobs: SpatialData): + """When the same option is set as both flat kwarg and dict key, the dict wins.""" + ax = sdata_blobs.pl.render_shapes(element="blobs_circles").pl.show( + legend_fontsize=10, + legend_params={"fontsize": 18}, + return_ax=True, + show=False, + ) + legend = ax.get_legend() + if legend is not None: + for text in legend.get_texts(): + assert text.get_fontsize() == 18 + plt.close("all") + + +def test_legend_params_default_none_is_noop(sdata_blobs: SpatialData): + """legend_params=None preserves identical behavior to omitting the kwarg.""" + ax_a = sdata_blobs.pl.render_shapes(element="blobs_circles").pl.show(return_ax=True, show=False) + plt.close("all") + ax_b = sdata_blobs.pl.render_shapes(element="blobs_circles").pl.show(legend_params=None, return_ax=True, show=False) + assert (ax_a.get_legend() is None) == (ax_b.get_legend() is None) + plt.close("all") + + +@pytest.mark.parametrize( + ("kwargs", "exc"), + [ + ({"legend_params": []}, TypeError), + ({"legend_params": "loc=upper right"}, TypeError), + ({"legend_params": {"loc": "upper right", "frameon": True}}, ValueError), + ({"legend_params": {"locaton": "upper right"}}, ValueError), # typo of "location" + ], +) +def test_legend_params_validation_rejects_bad_inputs(sdata_blobs: SpatialData, kwargs, exc): + """_validate_show_parameters surfaces actionable errors for bad legend_params inputs.""" + with pytest.raises(exc): + sdata_blobs.pl.render_shapes(element="blobs_circles").pl.show(show=False, **kwargs) + plt.close("all") + + +def test_legend_params_location_alias_for_loc(sdata_blobs: SpatialData): + """legend_params accepts both 'location' (canonical) and 'loc' (matplotlib-native alias).""" + sdata_blobs.pl.render_shapes(element="blobs_circles").pl.show( + legend_params={"loc": "upper right"}, return_ax=True, show=False + ) + sdata_blobs.pl.render_shapes(element="blobs_circles").pl.show( + legend_params={"location": "upper right"}, return_ax=True, show=False + ) + sdata_blobs.pl.render_shapes(element="blobs_circles").pl.show( + legend_params={"loc": "upper left", "location": "lower right"}, return_ax=True, show=False + ) + plt.close("all")