Skip to content

Expose legend column count (and a curated set of legend styling keys) via legend_params #770

Description

@timtreis

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 ncolncols 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

  1. _validate.py — add keys to the allowlist + _check_legend_param with actionable errors; validate ncols is a positive int.
  2. render_params.py LegendParams — add the fields.
  3. 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.
  4. 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 (ncolncols 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).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions