diff --git a/src/spatialdata_plot/pl/_validate.py b/src/spatialdata_plot/pl/_validate.py index b538bfc4..abcabd5e 100644 --- a/src/spatialdata_plot/pl/_validate.py +++ b/src/spatialdata_plot/pl/_validate.py @@ -2,6 +2,7 @@ from __future__ import annotations +import numbers import warnings from collections import Counter from collections.abc import Callable, Sequence @@ -13,7 +14,7 @@ import spatialdata as sd from anndata import AnnData from matplotlib.axes import Axes -from matplotlib.colors import Colormap, Normalize +from matplotlib.colors import Colormap, Normalize, is_color_like from matplotlib.figure import Figure from spatialdata import ( SpatialData, @@ -234,12 +235,62 @@ def _validate_show_parameters( 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"} + # `ncol` is accepted as an alias of `ncols` (matplotlib renamed it in 3.6); we normalise later. + allowed_legend_keys = { + "loc", + "location", + "fontsize", + "fontweight", + "fontoutline", + "na_in_legend", + "ncols", + "ncol", + "markerscale", + "frameon", + "framealpha", + "title_fontsize", + "labelcolor", + } 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)}." ) + _check_legend_styling_params(legend_params) + + +def _is_number(val: Any) -> bool: + """Return ``True`` for a real number (incl. numpy scalars), excluding bool.""" + return isinstance(val, numbers.Real) and not isinstance(val, bool) + + +def _resolve_ncols(legend_params: dict[str, Any]) -> Any: + """Resolve the ``ncols``/``ncol`` alias by precedence (``ncols`` wins), treating None as unset.""" + ncols = legend_params.get("ncols") + return legend_params.get("ncol") if ncols is None else ncols + + +def _check_legend_styling_params(legend_params: dict[str, Any]) -> None: + """Validate the curated categorical-legend styling keys with actionable errors.""" + if (n := _resolve_ncols(legend_params)) is not None and ( + not isinstance(n, numbers.Integral) or isinstance(n, bool) or n < 1 + ): + raise ValueError(f"legend_params 'ncols' must be a positive integer, got {n!r}.") + + if (ms := legend_params.get("markerscale")) is not None and (not _is_number(ms) or ms <= 0): + raise ValueError(f"legend_params['markerscale'] must be a positive number, got {ms!r}.") + + if (fo := legend_params.get("frameon")) is not None and not isinstance(fo, bool): + raise TypeError(f"legend_params['frameon'] must be a bool, got {fo!r}.") + + if (fa := legend_params.get("framealpha")) is not None and (not _is_number(fa) or not 0.0 <= fa <= 1.0): + raise ValueError(f"legend_params['framealpha'] must be a number in [0, 1], got {fa!r}.") + + if (tf := legend_params.get("title_fontsize")) is not None and not (_is_number(tf) or isinstance(tf, str)): + raise TypeError(f"legend_params['title_fontsize'] must be a number or a matplotlib size string, got {tf!r}.") + + if (lc := legend_params.get("labelcolor")) is not None and not is_color_like(lc): + raise ValueError(f"legend_params['labelcolor'] must be a matplotlib color, got {lc!r}.") def _check_color_column_collision( @@ -608,8 +659,6 @@ def _check_cmap_palette_groups(param_dict: dict[str, Any], element_type: str) -> # dict palettes (e.g. from make_palette_from_data) bypass groups validation if isinstance(palette, dict): - from matplotlib.colors import is_color_like - invalid = [f"'{k}': '{v}'" for k, v in palette.items() if not is_color_like(v)] if invalid: raise ValueError(f"Dict palette contains invalid color values: {', '.join(invalid)}.") diff --git a/src/spatialdata_plot/pl/basic.py b/src/spatialdata_plot/pl/basic.py index 4eaab105..df0312e5 100644 --- a/src/spatialdata_plot/pl/basic.py +++ b/src/spatialdata_plot/pl/basic.py @@ -38,6 +38,7 @@ ) from spatialdata_plot.pl._validate import ( _expand_color_panels, + _resolve_ncols, _validate_as_points_size, _validate_graph_render_params, _validate_image_render_params, @@ -1428,8 +1429,11 @@ def show( 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``. + ``location`` (or ``loc``), ``fontsize``, ``fontweight``, ``fontoutline``, ``na_in_legend``, + and the categorical-legend styling overrides ``ncols`` (or ``ncol``), ``markerscale``, + ``frameon``, ``framealpha``, ``title_fontsize`` and ``labelcolor``. Styling overrides default + to the current auto behaviour (column count picked from the number of entries, no frame). + Unknown keys raise ``ValueError``. Returns ------- @@ -1850,6 +1854,8 @@ def _build_legend_params( Keys in the ``legend_params`` dict take precedence over the matching flat ``legend_*`` keyword arguments. """ + # Curated styling overrides; absent keys stay at LegendParams' None defaults. + styling: dict[str, Any] = {} if legend_params: legend_fontsize = legend_params.get("fontsize", legend_fontsize) legend_fontweight = legend_params.get("fontweight", legend_fontweight) @@ -1857,6 +1863,14 @@ def _build_legend_params( 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) + styling = { + "legend_ncols": _resolve_ncols(legend_params), + "legend_markerscale": legend_params.get("markerscale"), + "legend_frameon": legend_params.get("frameon"), + "legend_framealpha": legend_params.get("framealpha"), + "legend_title_fontsize": legend_params.get("title_fontsize"), + "legend_labelcolor": legend_params.get("labelcolor"), + } if legend_loc == "on data": raise ValueError("legend_loc='on data' is not supported in spatialdata-plot.") @@ -1870,6 +1884,7 @@ def _build_legend_params( colorbar=colorbar, legend_title=legend_title, outline_legend_title=outline_legend_title, + **styling, ) diff --git a/src/spatialdata_plot/pl/render.py b/src/spatialdata_plot/pl/render.py index b2aa0a51..62987ece 100644 --- a/src/spatialdata_plot/pl/render.py +++ b/src/spatialdata_plot/pl/render.py @@ -78,6 +78,7 @@ colormap_with_alpha, ) from spatialdata_plot.pl.utils import ( + _apply_legend_overrides, _bbox_mask_points, _bbox_mask_shapes, _bbox_to_element_space, @@ -87,6 +88,7 @@ _first_color_per_category, _join_table_for_element, _legend_ncol, + _legend_style_kwargs, _mpl_ax_contains_elements, _multiscale_to_spatial_image, _pixel_to_coord, @@ -455,6 +457,7 @@ def _add_legend_and_colorbar( col_for_color if isinstance(col_for_color, str) else None, ), legend_title=fill_title, + legend_params=legend_params, ) if outline_has_decorations and outline_cmap_params is not None: @@ -594,11 +597,9 @@ def _add_outline_legend( ax.legend( handles=outline_handles, title=title, - frameon=False, loc=loc, bbox_to_anchor=anchor, - fontsize=legend_params.legend_fontsize, - ncol=_legend_ncol(len(outline_handles)), + **_legend_style_kwargs(legend_params, default_ncols=_legend_ncol(len(outline_handles)), default_frameon=False), ) @@ -1797,6 +1798,8 @@ def _draw_channel_legend( na_in_legend=False, multi_panel=needs_multi_panel, ) + # Honour the curated styling overrides on the channel legend too. + _apply_legend_overrides(ax, legend_loc, legend_params) def _composite_channels( diff --git a/src/spatialdata_plot/pl/render_params.py b/src/spatialdata_plot/pl/render_params.py index ec09b240..1d8eb6c2 100644 --- a/src/spatialdata_plot/pl/render_params.py +++ b/src/spatialdata_plot/pl/render_params.py @@ -30,6 +30,7 @@ class BBox(NamedTuple): x1: float y1: float + # Canonical definition for the package; imported by basic.py and utils.py. # replace with # from spatialdata._types import ColorLike @@ -237,6 +238,28 @@ class LegendParams: # column, in which case they default to "fill" / "outline" to disambiguate. legend_title: str | None = None outline_legend_title: str | None = None + # Curated categorical-legend styling overrides; None => existing auto behaviour. + legend_ncols: int | None = None + legend_markerscale: int | float | None = None + legend_frameon: bool | None = None + legend_framealpha: float | None = None + legend_title_fontsize: int | float | _FontSize | None = None + legend_labelcolor: ColorLike | None = None + + @property + def has_style_overrides(self) -> bool: + """True if any curated styling override is set (else the auto legend behaviour applies).""" + return any( + v is not None + for v in ( + self.legend_ncols, + self.legend_markerscale, + self.legend_frameon, + self.legend_framealpha, + self.legend_title_fontsize, + self.legend_labelcolor, + ) + ) @dataclass diff --git a/src/spatialdata_plot/pl/utils.py b/src/spatialdata_plot/pl/utils.py index dc534295..5f45a9a3 100644 --- a/src/spatialdata_plot/pl/utils.py +++ b/src/spatialdata_plot/pl/utils.py @@ -64,6 +64,7 @@ GraphRenderParams, ImageRenderParams, LabelsRenderParams, + LegendParams, PointsRenderParams, ScalebarParams, ShapesRenderParams, @@ -412,6 +413,59 @@ def _legend_ncol(n: int) -> int: return 1 if n <= 14 else 2 if n <= 30 else 3 +def _legend_style_kwargs(lp: LegendParams, *, default_ncols: int, default_frameon: bool) -> dict[str, Any]: + """Curated legend styling as ``ax.legend()`` kwargs, shared by every legend builder. + + Unset ``ncols``/``frameon`` fall back to the builder defaults; the rest are omitted so + matplotlib's defaults apply. ``framealpha`` implies ``frameon``, but an explicit + ``frameon=False`` wins (matplotlib ignores ``framealpha`` on a hidden frame either way). + """ + kwargs: dict[str, Any] = { + "fontsize": lp.legend_fontsize, + "ncols": default_ncols if lp.legend_ncols is None else lp.legend_ncols, + "frameon": lp.legend_frameon + if lp.legend_frameon is not None + else (default_frameon or lp.legend_framealpha is not None), + } + for key, val in ( + ("markerscale", lp.legend_markerscale), + ("framealpha", lp.legend_framealpha), + ("title_fontsize", lp.legend_title_fontsize), + ("labelcolor", lp.legend_labelcolor), + ): + if val is not None: + kwargs[key] = val + return kwargs + + +def _apply_legend_overrides(ax: Axes, legend_loc: str | None, lp: LegendParams) -> None: + """Re-lay-out scanpy's categorical legend to honour the curated styling overrides. + + scanpy's ``_add_categorical_legend`` takes none of these kwargs and ``ncols``/``markerscale`` + cannot be changed on a built ``Legend``, so we rebuild from its handles/labels/title. Placement + mirrors scanpy so a lone override does not move the legend; unset values preserve its layout. + """ + # No legend is built when placement is suppressed, so there is nothing to restyle. + if not lp.has_style_overrides or legend_loc in (None, "none"): + return + if (leg := ax.get_legend()) is None: + return + placement = ( + {"loc": "center left", "bbox_to_anchor": (1, 0.5)} if legend_loc == "right margin" else {"loc": legend_loc} + ) + tag = getattr(leg, "_sdata_column", None) + new_leg = ax.legend( + list(leg.legend_handles), + [t.get_text() for t in leg.get_texts()], + title=leg.get_title().get_text() or None, + # `_ncols`: private column-count attr, stable since mpl 3.6 (floor >=3.8); no public getter. + **_legend_style_kwargs(lp, default_ncols=leg._ncols, default_frameon=leg.get_frame_on()), + **placement, + ) + if tag is not None: + new_leg._sdata_column = tag # type: ignore[attr-defined] + + def _categorical_legend_handles(ax: Axes, color_map: Mapping[Any, Any], na_hex: str | None = None) -> list[Any]: """Empty-scatter handles (colored dots) for a categorical legend, with an optional NA entry.""" handles = [ax.scatter([], [], c=color, label=str(cat)) for cat, color in color_map.items()] @@ -427,11 +481,12 @@ def _stack_categorical_legend( na_hex: str | None, title: str | None, column: str | None, - legend_fontsize: int | float | _FontSize | None, + lp: LegendParams, ) -> None: """Build the 2nd+ categorical legend on a shared axes without dropping existing ones (#364). Placement and the column auto-title are finalized later by ``_setup_stacked_legends``. + Curated styling overrides are honoured here directly since we own this ``ax.legend()``. """ handles = _categorical_legend_handles(ax, color_mapping, na_hex) if (cur := ax.get_legend()) is not None: @@ -439,11 +494,9 @@ def _stack_categorical_legend( new_leg = ax.legend( handles=handles, title=title, - frameon=False, loc="upper left", bbox_to_anchor=(1.02, 1.0), - fontsize=legend_fontsize, - ncol=_legend_ncol(len(handles)), + **_legend_style_kwargs(lp, default_ncols=_legend_ncol(len(handles)), default_frameon=False), ) new_leg._sdata_column = column # type: ignore[attr-defined] @@ -492,7 +545,10 @@ def _decorate_axs( colorbar_requests: list[ColorbarSpec] | None = None, colorbar_label: str | None = None, legend_title: str | None = None, + legend_params: LegendParams | None = None, ) -> Axes: + # Curated styling overrides; the flat legend_* args above stay authoritative otherwise. + lp = legend_params if legend_params is not None else LegendParams() if value_to_plot is not None: # if only dots were plotted without an associated value # there is not need to plot a legend or a colorbar @@ -533,7 +589,7 @@ def _decorate_axs( na_hex=na_hex, title=legend_title, column=value_to_plot, - legend_fontsize=legend_fontsize, + lp=lp, ) else: _add_categorical_legend( @@ -548,6 +604,7 @@ def _decorate_axs( na_in_legend=na_in_legend, multi_panel=fig_params.axs is not None, ) + _apply_legend_overrides(ax, legend_loc, lp) # before tagging: may rebuild the legend # Tag with the column; the column auto-title (when 2+ legends) is applied in # `_setup_stacked_legends`. An explicit title wins now. if (legend := ax.get_legend()) is not None: @@ -821,7 +878,9 @@ def _rasterize_to_bbox( """ x0, y0, x1, y1 = bbox target_unit_to_pixels = min(target_y_dims / (y1 - y0), target_x_dims / (x1 - x0)) - return rasterize(image, ("y", "x"), [y0, x0], [y1, x1], coordinate_system, target_unit_to_pixels=target_unit_to_pixels) + return rasterize( + image, ("y", "x"), [y0, x0], [y1, x1], coordinate_system, target_unit_to_pixels=target_unit_to_pixels + ) def _rasterize_if_necessary( @@ -934,7 +993,10 @@ def _datashader_window_image( # aggregated grid then lines up with ``base`` (same window, same resolution). cvs = ds.Canvas(plot_width=out_x, plot_height=out_y, x_range=(px0, px1), y_range=(py0, py1)) agg = np.stack( - [np.asarray(cvs.raster(src.isel(c=i), downsample_method=downsample_method).values) for i in range(src.sizes["c"])], + [ + np.asarray(cvs.raster(src.isel(c=i), downsample_method=downsample_method).values) + for i in range(src.sizes["c"]) + ], axis=0, ) base.values = agg.astype(base.dtype, copy=False) @@ -969,7 +1031,9 @@ def _rasterize_if_necessary_datashader( target_x_dims = int(dpi * width) if crop is not None: - windowed = _datashader_window_image(image, crop, coordinate_system, target_x_dims, target_y_dims, downsample_method) + windowed = _datashader_window_image( + image, crop, coordinate_system, target_x_dims, target_y_dims, downsample_method + ) if windowed is not None: return windowed # rotation/shear or empty window: fall through to the full render (axis limits clip) diff --git a/tests/_images/LegendParams_legend_framealpha.png b/tests/_images/LegendParams_legend_framealpha.png new file mode 100644 index 00000000..cd06cc84 Binary files /dev/null and b/tests/_images/LegendParams_legend_framealpha.png differ diff --git a/tests/_images/LegendParams_legend_frameon.png b/tests/_images/LegendParams_legend_frameon.png new file mode 100644 index 00000000..5ca5d8d1 Binary files /dev/null and b/tests/_images/LegendParams_legend_frameon.png differ diff --git a/tests/_images/LegendParams_legend_labelcolor.png b/tests/_images/LegendParams_legend_labelcolor.png new file mode 100644 index 00000000..b36a4e6a Binary files /dev/null and b/tests/_images/LegendParams_legend_labelcolor.png differ diff --git a/tests/_images/LegendParams_legend_markerscale.png b/tests/_images/LegendParams_legend_markerscale.png new file mode 100644 index 00000000..6d739f68 Binary files /dev/null and b/tests/_images/LegendParams_legend_markerscale.png differ diff --git a/tests/_images/LegendParams_legend_ncols.png b/tests/_images/LegendParams_legend_ncols.png new file mode 100644 index 00000000..d3f1ce96 Binary files /dev/null and b/tests/_images/LegendParams_legend_ncols.png differ diff --git a/tests/_images/LegendParams_legend_title_fontsize.png b/tests/_images/LegendParams_legend_title_fontsize.png new file mode 100644 index 00000000..429fdc4f Binary files /dev/null and b/tests/_images/LegendParams_legend_title_fontsize.png differ diff --git a/tests/pl/test_render_labels.py b/tests/pl/test_render_labels.py index 725ab23a..bcb66ad5 100644 --- a/tests/pl/test_render_labels.py +++ b/tests/pl/test_render_labels.py @@ -170,6 +170,32 @@ def test_two_categorical_label_renders_make_two_distinct_legends(self, sdata_blo assert abs(boxes[0].y1 - boxes[1].y1) < 0.01 # tops aligned plt.close() + def test_legend_params_ncols_applies_to_both_stacked_legends(self, sdata_blobs: SpatialData): + # Regression test for #770: a forced legend_params override must reach BOTH the scanpy-built + # primary legend and the stacked (2nd) legend builder. State-based. Each column has >14 + # categories so the auto column count is 2 -- forcing ncols=3 is therefore a real override, + # not a no-op that would pass even if the override were ignored. + n = sdata_blobs["table"].n_obs + sdata_blobs["table"].obs["region"] = pd.Categorical(["blobs_labels"] * n) + sdata_blobs["table"].uns["spatialdata_attrs"]["region"] = "blobs_labels" + sdata_blobs["table"].obs["cat0"] = pd.Categorical([f"a{i % 15}" for i in range(n)]) + sdata_blobs["table"].obs["cat1"] = pd.Categorical([f"b{i % 15}" for i in range(n)]) + + ( + sdata_blobs.pl.render_labels("blobs_labels", color="cat0") + .pl.render_labels("blobs_labels", color="cat1") + .pl.show(legend_params={"ncols": 3, "frameon": True, "title_fontsize": 22}) + ) + + ax = plt.gcf().axes[0] + legends = [c for c in ax.get_children() if isinstance(c, Legend)] + assert len(legends) == 2 + for leg in legends: + assert leg._ncols == 3 # auto would be 2 for >14 categories + assert leg.get_frame_on() is True + assert leg.get_title().get_fontsize() == 22 # stacked legends carry column-name titles + plt.close() + def test_three_categorical_label_renders_make_three_legends(self, sdata_blobs: SpatialData): # Regression test for #364: re-adding prior legends must not duplicate them; three renders # yield exactly three distinct legends (not four with a repeat). diff --git a/tests/pl/test_show.py b/tests/pl/test_show.py index 76dc3d8d..1e80d945 100644 --- a/tests/pl/test_show.py +++ b/tests/pl/test_show.py @@ -183,7 +183,9 @@ def test_crop_sets_exact_axis_limits(sdata_blobs: SpatialData): def test_crop_ignores_pad_extent(sdata_blobs: SpatialData): """pad_extent must not widen a crop box (the view is exactly the box).""" - ax = sdata_blobs.pl.render_points().pl.show(crop_coord=(100, 300, 120, 260), pad_extent=50, return_ax=True, show=False) + ax = sdata_blobs.pl.render_points().pl.show( + crop_coord=(100, 300, 120, 260), pad_extent=50, return_ax=True, show=False + ) assert ax.get_xlim() == pytest.approx((100, 300)) assert ax.get_ylim() == pytest.approx((260, 120)) plt.close("all") @@ -246,11 +248,17 @@ def vrange(ax): def test_crop_datashader_autoscales_over_window(): """Datashader crop autoscales over the visible window: a value far outside the box can't recolor it.""" rng = np.random.default_rng(0) - base = pd.DataFrame({"x": rng.uniform(20, 50, 12000), "y": rng.uniform(30, 60, 12000), "val": rng.uniform(0, 1, 12000)}) + base = pd.DataFrame( + {"x": rng.uniform(20, 50, 12000), "y": rng.uniform(30, 60, 12000), "val": rng.uniform(0, 1, 12000)} + ) outside = pd.DataFrame({"x": [200.0], "y": [200.0], "val": [1000.0]}) # far outside the crop window s_a = SpatialData(points={"p": PointsModel.parse(base, transformations={"global": Identity()})}) s_b = SpatialData( - points={"p": PointsModel.parse(pd.concat([base, outside], ignore_index=True), transformations={"global": Identity()})} + points={ + "p": PointsModel.parse( + pd.concat([base, outside], ignore_index=True), transformations={"global": Identity()} + ) + } ) def raster(s): @@ -279,7 +287,12 @@ def test_crop_multiscale_selects_finer_level(): extent = {"x": (0.0, float(n)), "y": (0.0, float(n))} coarse = _multiscale_to_spatial_image(tree, dpi=10, width=5, height=5) # target ~50px over the full image fine = _multiscale_to_spatial_image( - tree, dpi=10, width=5, height=5, crop=BBox(0.0, 0.0, 80.0, 80.0), extent=extent # 10% window -> 10x boost + tree, + dpi=10, + width=5, + height=5, + crop=BBox(0.0, 0.0, 80.0, 80.0), + extent=extent, # 10% window -> 10x boost ) assert fine.shape[-1] > coarse.shape[-1] @@ -685,11 +698,20 @@ def test_legend_params_overrides_flat_kwarg(sdata_blobs: SpatialData): 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) + """legend_params=None gives a real categorical legend identical to omitting the kwarg.""" + import pandas as pd + + sdata_blobs["table"].obs["_cat"] = pd.Categorical([f"g{i % 20}" for i in range(sdata_blobs["table"].n_obs)]) + ax_a = sdata_blobs.pl.render_labels("blobs_labels", color="_cat").pl.show(return_ax=True, show=False) + leg_a = ax_a.get_legend() + auto = (leg_a._ncols, leg_a.get_frame_on()) 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) + ax_b = sdata_blobs.pl.render_labels("blobs_labels", color="_cat").pl.show( + legend_params=None, return_ax=True, show=False + ) + leg_b = ax_b.get_legend() + assert leg_a is not None and leg_b is not None + assert (leg_b._ncols, leg_b.get_frame_on()) == auto plt.close("all") @@ -698,8 +720,15 @@ def test_legend_params_default_none_is_noop(sdata_blobs: SpatialData): [ ({"legend_params": []}, TypeError), ({"legend_params": "loc=upper right"}, TypeError), - ({"legend_params": {"loc": "upper right", "frameon": True}}, ValueError), + ({"legend_params": {"loc": "upper right", "unknown_key": True}}, ValueError), ({"legend_params": {"locaton": "upper right"}}, ValueError), # typo of "location" + ({"legend_params": {"ncols": 0}}, ValueError), # must be positive + ({"legend_params": {"ncols": 1.5}}, ValueError), # must be an int + ({"legend_params": {"markerscale": -1}}, ValueError), # must be positive + ({"legend_params": {"frameon": "yes"}}, TypeError), # must be a bool + ({"legend_params": {"framealpha": 2.0}}, ValueError), # must be in [0, 1] + ({"legend_params": {"title_fontsize": True}}, TypeError), # bool is not a valid size + ({"legend_params": {"labelcolor": "notacolor"}}, ValueError), # not a matplotlib color ], ) def test_legend_params_validation_rejects_bad_inputs(sdata_blobs: SpatialData, kwargs, exc): @@ -721,3 +750,151 @@ def test_legend_params_location_alias_for_loc(sdata_blobs: SpatialData): legend_params={"loc": "upper left", "location": "lower right"}, return_ax=True, show=False ) plt.close("all") + + +def _categorical_labels_legend(sdata_blobs: SpatialData, legend_params, n_groups: int = 20): + """Render ``blobs_labels`` coloured by a fresh ``n_groups``-category obs column, return the legend.""" + import pandas as pd + + adata = sdata_blobs["table"] + adata.obs["_cat"] = pd.Categorical([f"g{i % n_groups}" for i in range(adata.n_obs)]) + ax = sdata_blobs.pl.render_labels(element="blobs_labels", color="_cat").pl.show( + legend_params=legend_params, return_ax=True, show=False + ) + return ax.get_legend() + + +def test_legend_params_ncols_override(sdata_blobs: SpatialData): + """Regression #770: a forced ncols sticks on the categorical legend (default would be 2 for 20 groups).""" + leg_default = _categorical_labels_legend(sdata_blobs, None) + assert leg_default._ncols == 2 + plt.close("all") + + leg_forced = _categorical_labels_legend(sdata_blobs, {"ncols": 3}) + assert leg_forced._ncols == 3 + plt.close("all") + + +def test_legend_params_ncol_alias(sdata_blobs: SpatialData): + """Regression #770: 'ncol' (matplotlib pre-3.6 spelling) is accepted as an alias of 'ncols'.""" + leg = _categorical_labels_legend(sdata_blobs, {"ncol": 3}) + assert leg._ncols == 3 + plt.close("all") + + +def test_legend_params_override_with_non_margin_loc(sdata_blobs: SpatialData): + """Regression #770: an override on a non-'right margin' loc rebuilds via the else-branch. + + The default is legend_loc='right margin'; a custom loc must still honour the override and keep + the frame matplotlib gives that loc (frameon=True) rather than the right-margin default. + """ + leg = _categorical_labels_legend(sdata_blobs, {"loc": "upper left", "ncols": 2}) + assert leg._ncols == 2 + assert leg.get_frame_on() is True # mpl default for a non-margin loc, preserved by the rebuild + plt.close("all") + + +def test_legend_params_frame_overrides(sdata_blobs: SpatialData): + """Regression #770: frameon/framealpha reach the categorical legend (default is frameon=False).""" + leg_default = _categorical_labels_legend(sdata_blobs, None) + assert leg_default.get_frame_on() is False + plt.close("all") + + leg = _categorical_labels_legend(sdata_blobs, {"frameon": True, "framealpha": 0.5}) + assert leg.get_frame_on() is True + assert leg.get_frame().get_alpha() == 0.5 + plt.close("all") + + # framealpha alone implies frameon, else it would be invisible on the default frameless legend. + leg_alpha = _categorical_labels_legend(sdata_blobs, {"framealpha": 0.3}) + assert leg_alpha.get_frame_on() is True + assert leg_alpha.get_frame().get_alpha() == 0.3 + plt.close("all") + + # An explicit frameon=False wins over framealpha (matplotlib ignores alpha on a hidden frame). + leg_off = _categorical_labels_legend(sdata_blobs, {"frameon": False, "framealpha": 0.5}) + assert leg_off.get_frame_on() is False + plt.close("all") + + +def test_legend_params_markerscale_override(sdata_blobs: SpatialData): + """Regression #770: markerscale is forwarded to the categorical legend (default 1.0).""" + leg_default = _categorical_labels_legend(sdata_blobs, None) + assert leg_default.markerscale == 1.0 + plt.close("all") + + leg = _categorical_labels_legend(sdata_blobs, {"markerscale": 2.0}) + assert leg.markerscale == 2.0 + plt.close("all") + + +def test_legend_params_labelcolor_override(sdata_blobs: SpatialData): + """Regression #770: labelcolor recolours the legend entry labels.""" + from matplotlib.colors import to_rgba + + leg = _categorical_labels_legend(sdata_blobs, {"labelcolor": "white"}) + assert leg.get_texts() # a real categorical legend was built + for text in leg.get_texts(): + assert to_rgba(text.get_color()) == to_rgba("white") + plt.close("all") + + +def test_legend_params_ncols_alias_precedence(sdata_blobs: SpatialData): + """Regression #770: 'ncols' wins over 'ncol', and an explicit None 'ncols' falls back to 'ncol'.""" + leg_both = _categorical_labels_legend(sdata_blobs, {"ncol": 3, "ncols": 1}) + assert leg_both._ncols == 1 + plt.close("all") + + leg_none = _categorical_labels_legend(sdata_blobs, {"ncols": None, "ncol": 1}) + assert leg_none._ncols == 1 + plt.close("all") + + +class TestLegendParams(PlotTester, metaclass=PlotTesterMeta): + """Minimal visual regression tests for each curated legend_params styling key (#770).""" + + @staticmethod + def _color_labels(sdata_blobs: SpatialData, n_groups: int, key: str = "cat") -> None: + obs = sdata_blobs["table"].obs + obs[key] = pd.Categorical([f"g{i % n_groups}" for i in range(len(obs))]) + + def test_plot_legend_ncols(self, sdata_blobs: SpatialData): + """ncols=3 lays the 16-category legend out in three columns (auto count would be 2).""" + self._color_labels(sdata_blobs, 16) + sdata_blobs.pl.render_labels("blobs_labels", color="cat").pl.show(legend_params={"ncols": 3}) + + def test_plot_legend_markerscale(self, sdata_blobs: SpatialData): + """markerscale enlarges the legend handle dots.""" + self._color_labels(sdata_blobs, 5) + sdata_blobs.pl.render_labels("blobs_labels", color="cat").pl.show(legend_params={"markerscale": 3}) + + def test_plot_legend_frameon(self, sdata_blobs: SpatialData): + """frameon draws the legend box (placed over the image so it is visible).""" + self._color_labels(sdata_blobs, 5) + sdata_blobs.pl.render_labels("blobs_labels", color="cat").pl.show( + legend_params={"loc": "upper right", "frameon": True} + ) + + def test_plot_legend_framealpha(self, sdata_blobs: SpatialData): + """framealpha makes the (implied) frame semi-transparent over the image.""" + self._color_labels(sdata_blobs, 5) + sdata_blobs.pl.render_labels("blobs_labels", color="cat").pl.show( + legend_params={"loc": "upper right", "framealpha": 0.3} + ) + + def test_plot_legend_title_fontsize(self, sdata_blobs: SpatialData): + """title_fontsize sizes the (column-name) titles of stacked categorical legends.""" + self._color_labels(sdata_blobs, 3, key="cat0") + self._color_labels(sdata_blobs, 3, key="cat1") + ( + sdata_blobs.pl.render_labels("blobs_labels", color="cat0") + .pl.render_labels("blobs_labels", color="cat1") + .pl.show(legend_params={"title_fontsize": 24}) + ) + + def test_plot_legend_labelcolor(self, sdata_blobs: SpatialData): + """labelcolor recolours the legend entry labels.""" + self._color_labels(sdata_blobs, 5) + sdata_blobs.pl.render_labels("blobs_labels", color="cat").pl.show( + legend_params={"loc": "upper right", "labelcolor": "red"} + )