Summary
show(legend_params=...) only accepts {loc, location, fontsize, fontweight, fontoutline, na_in_legend} and raises on anything else (_validate.py:222-227). In particular there is no way to control the categorical legend's column count — it is hardcoded 1 if n<=14 else 2 if n<=30 else 3 in both legend builders:
- primary legend → scanpy's
_add_categorical_legend (takes no ncol)
- stacked legends →
_legend_ncol() (utils.py:409, used at utils.py:445)
So a categorical column with 15–30 groups (e.g. the Visium cluster demo in the tutorials) always renders as 2 columns with no override.
Proposal
Extend legend_params with a small curated, validated set of keys (not a raw matplotlib passthrough — see rationale). Tier 1:
ncols — legend column count (normalise ncol/ncols; mpl renamed ncol→ncols in 3.6)
markerscale — legend marker size (dots are often too small to read against tissue)
frameon (+ framealpha) — currently hardcoded frameon=False; a semi-opaque box aids readability over busy images
title_fontsize — title size independent of entry size
Tier 2 (optional): labelspacing, columnspacing, handletextpad.
Default all to None → current auto behaviour unchanged (backward compatible).
Implementation notes
_validate.py — add keys to the allowlist + _check_legend_param with actionable errors; validate ncols is a positive int.
render_params.py LegendParams — add the fields.
- Two legend sites:
- stacked (
utils.py:445): ncol = override or _legend_ncol(len(handles)), forward the rest.
- primary (
render.py:1698): scanpy's _add_categorical_legend takes none of these, so override after it builds — recover handles/labels/title/loc from the created legend and re-lay it out with the requested kwargs. Must not fight the existing legend_loc/"right margin"/multi-panel-shrink placement.
- Regression test asserting a forced
ncols (and markerscale/frameon) sticks on both a single and a stacked legend.
Why curated, not matplotlib passthrough
- The primary legend is scanpy-built and accepts no arbitrary kwargs, so passthrough only lands via the rebuild step, where it can silently fight scanpy's + our own placement math.
- Passthrough loses actionable errors (typos sail through and mpl silently drops some legend kwargs).
- It couples our public API to matplotlib's version-to-version (
ncol→ncols precedent); a curated set lets us normalise aliases and stay version-stable.
- Footgun kwargs (
handles/labels/loc/bbox_to_anchor) would need a denylist anyway.
colorbar_params forwarding is fine because it targets a single owned call (fig.colorbar); the legend has two divergent builders, so it is higher-risk.
Follow-up
Once landed, demo legend_params={"ncols": 1, ...} in the legends_and_colorbars tutorial (scverse/spatialdata-plot-tutorials PR #18).
Summary
show(legend_params=...)only accepts{loc, location, fontsize, fontweight, fontoutline, na_in_legend}and raises on anything else (_validate.py:222-227). In particular there is no way to control the categorical legend's column count — it is hardcoded1 if n<=14 else 2 if n<=30 else 3in both legend builders:_add_categorical_legend(takes noncol)_legend_ncol()(utils.py:409, used atutils.py:445)So a categorical column with 15–30 groups (e.g. the Visium
clusterdemo in the tutorials) always renders as 2 columns with no override.Proposal
Extend
legend_paramswith a small curated, validated set of keys (not a raw matplotlib passthrough — see rationale). Tier 1:ncols— legend column count (normalisencol/ncols; mpl renamedncol→ncolsin 3.6)markerscale— legend marker size (dots are often too small to read against tissue)frameon(+framealpha) — currently hardcodedframeon=False; a semi-opaque box aids readability over busy imagestitle_fontsize— title size independent of entry sizeTier 2 (optional):
labelspacing,columnspacing,handletextpad.Default all to
None→ current auto behaviour unchanged (backward compatible).Implementation notes
_validate.py— add keys to the allowlist +_check_legend_paramwith actionable errors; validatencolsis a positive int.render_params.pyLegendParams— add the fields.utils.py:445):ncol = override or _legend_ncol(len(handles)), forward the rest.render.py:1698): scanpy's_add_categorical_legendtakes none of these, so override after it builds — recover handles/labels/title/loc from the created legend and re-lay it out with the requested kwargs. Must not fight the existinglegend_loc/"right margin"/multi-panel-shrink placement.ncols(andmarkerscale/frameon) sticks on both a single and a stacked legend.Why curated, not matplotlib passthrough
ncol→ncolsprecedent); a curated set lets us normalise aliases and stay version-stable.handles/labels/loc/bbox_to_anchor) would need a denylist anyway.colorbar_paramsforwarding is fine because it targets a single owned call (fig.colorbar); the legend has two divergent builders, so it is higher-risk.Follow-up
Once landed, demo
legend_params={"ncols": 1, ...}in thelegends_and_colorbarstutorial (scverse/spatialdata-plot-tutorials PR #18).