Skip to content

feat(legend): expose column count and curated styling via legend_params - #772

Merged
timtreis merged 10 commits into
mainfrom
feature/issue-770-legend-params
Aug 27, 2026
Merged

feat(legend): expose column count and curated styling via legend_params#772
timtreis merged 10 commits into
mainfrom
feature/issue-770-legend-params

Conversation

@timtreis

Copy link
Copy Markdown
Member

Closes #770.

Summary

show(legend_params=...) previously accepted only {loc, location, fontsize, fontweight, fontoutline, na_in_legend} and raised on anything else, so the categorical legend's column count could not be controlled — a 15–30-group column always rendered as 2 columns.

This adds a curated, validated set of styling keys, all defaulting to None (unchanged auto behaviour → backward compatible):

  • ncols (alias ncol; matplotlib renamed it in 3.6, normalised here)
  • markerscale
  • frameon / framealpha (framealpha implies frameon, else it would be invisible on the default frameless legend)
  • title_fontsize

Implementation

  • A single _legend_style_kwargs(lp, *, default_ncols, default_frameon) helper turns LegendParams into ax.legend() kwargs — one source of truth shared by all three legend builders (_stack_categorical_legend, _add_outline_legend, and the rebuild).
  • The primary legend is built by scanpy's _add_categorical_legend, which takes none of these kwargs and whose ncols/markerscale can't be changed on a built Legend. _apply_legend_overrides therefore rebuilds it from its handles/labels/title, mirroring scanpy's placement so a lone override does not move the legend, and preserving the auto column count / frame state for anything left unset.
  • Validation lives in _validate.py with actionable, per-key errors (positive-int ncols, positive-number markerscale, bool frameon, framealpha in [0, 1], number/str title_fontsize) and rejects conflicting ncol/ncols.

Why curated, not raw matplotlib passthrough

  • The primary legend is scanpy-built and accepts no arbitrary kwargs; passthrough would only land via the rebuild, where it could silently fight scanpy's + our own placement math.
  • Curated keys keep actionable errors (typos raise instead of being silently dropped by mpl) and shield the public API from matplotlib's version-to-version renames (the ncolncols precedent).

Tests

All non-visual tests pass locally (536). Visual (test_plot_*) baselines are unaffected — the default (no-override) path is byte-identical to before.

…ms (#770)

`show(legend_params=...)` previously accepted only
{loc, location, fontsize, fontweight, fontoutline, na_in_legend} and
raised on anything else, so the categorical legend's column count (and
other styling) could not be controlled — a 15–30-group column always
rendered as 2 columns.

Add a curated, validated set of keys, all defaulting to None (unchanged
auto behaviour, backward compatible):

- ncols (alias ncol; matplotlib renamed it in 3.6)
- markerscale
- frameon / framealpha (framealpha implies frameon)
- title_fontsize

A single `_legend_style_kwargs` helper turns LegendParams into ax.legend()
kwargs for all three legend builders. The scanpy-built primary legend
takes none of these kwargs, so `_apply_legend_overrides` rebuilds it from
its handles/labels/title, mirroring scanpy's placement so a lone override
does not move the legend. Curated (not raw passthrough) keeps actionable
validation errors and shields the public API from matplotlib version drift.

Tests cover the primary and stacked legends plus validation.
#770)

- Declare matplotlib>=3.8 (the first Python-3.12 wheel, which is our
  requires-python floor); it provides Legend.legend_handles and the
  ncols kwarg / Legend._ncols the rebuild relies on.
- Document that an explicit frameon=False wins over framealpha (mpl
  ignores alpha on a hidden frame) and note _ncols is private-but-stable.
- Add tests: override on a non-"right margin" loc (else-branch placement)
  and frameon=False + framealpha.
@timtreis timtreis changed the title feat(legend): expose column count and curated styling via legend_params (#770) feat(legend): expose column count and curated styling via legend_params Aug 26, 2026
…end-params

# Conflicts:
#	src/spatialdata_plot/pl/render.py
The floor already derives from requires-python>=3.12 (mpl 3.8 is the first
py3.12 wheel) and scanpy's matplotlib>=3.7.5 runtime dep, both of which
cover legend_handles (3.7) and ncols/_ncols (3.6).
@codecov-commenter

codecov-commenter commented Aug 26, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 92.59259% with 4 lines in your changes missing coverage. Please review.
✅ Project coverage is 80.09%. Comparing base (e33f196) to head (02b2823).

Files with missing lines Patch % Lines
src/spatialdata_plot/pl/utils.py 80.00% 2 Missing and 2 partials ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #772      +/-   ##
==========================================
+ Coverage   79.93%   80.09%   +0.16%     
==========================================
  Files          18       18              
  Lines        4814     4863      +49     
  Branches     1069     1080      +11     
==========================================
+ Hits         3848     3895      +47     
+ Misses        604      603       -1     
- Partials      362      365       +3     
Files with missing lines Coverage Δ
src/spatialdata_plot/pl/_validate.py 71.69% <100.00%> (+0.68%) ⬆️
src/spatialdata_plot/pl/basic.py 83.29% <100.00%> (+0.07%) ⬆️
src/spatialdata_plot/pl/render.py 88.99% <100.00%> (+0.01%) ⬆️
src/spatialdata_plot/pl/render_params.py 89.45% <100.00%> (+0.38%) ⬆️
src/spatialdata_plot/pl/utils.py 80.20% <80.00%> (-0.07%) ⬇️

... and 1 file with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

…alars, leaner API

Correctness:
- _apply_legend_overrides now no-ops when legend_loc is None/"none", so a
  channel-legend rebuild can't grab and restyle an unrelated pre-existing
  legend (would crash on loc="none" / relocate on loc=None).
- Validation accepts numpy scalars (numbers.Real / numbers.Integral) instead
  of only Python int/float, so params computed from array shapes work.
- ncols/ncol alias now resolves by precedence (ncols wins, None = unset) via
  a shared _resolve_ncols, matching loc/location; dropped the conflict error
  and the falsy-zero `or` footgun.

Cleanliness:
- LegendParams.has_style_overrides property replaces the duplicated 5-field
  guard; removed the redundant ncols validation loop.
- Dropped issue-number refs from src comments; trimmed the _ncols note.

Tests:
- Stacked test now uses >14 categories so the forced ncols=1 is a real
  override (was vacuous at 2 categories) and asserts title_fontsize.
- default-None no-op test now compares a real categorical legend.
- Added ncols alias-precedence test; removed the obsolete conflict test.
labelcolor recolours the legend entry labels (e.g. white text over a dark
image). Without it, frameon=False + manual text recolouring was the only
workaround. Native matplotlib ax.legend(labelcolor=...) kwarg, threaded
through the shared _legend_style_kwargs so it reaches the primary, stacked
and outline legends alike. Validated with matplotlib.colors.is_color_like
(native, not the column-collision-strict _is_color_like, since this is an
explicit styling param).
… key (#770)

One test_plot_* per curated key (ncols, markerscale, frameon, framealpha,
title_fontsize, labelcolor) rendering a categorical legend with the override.
Reference images to follow from the CI figure artifact (local macOS renders
differ from CI's Linux freetype for text).
@timtreis
timtreis force-pushed the feature/issue-770-legend-params branch from 5196a06 to a25a140 Compare August 26, 2026 23:58
@timtreis
timtreis merged commit 9f19b7d into main Aug 27, 2026
8 checks passed
@timtreis
timtreis deleted the feature/issue-770-legend-params branch August 27, 2026 01:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

2 participants