feat(legend): expose column count and curated styling via legend_params - #772
Merged
Conversation
…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.
…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 Report❌ Patch coverage is
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
🚀 New features to boost your workflow:
|
…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
force-pushed
the
feature/issue-770-legend-params
branch
from
August 26, 2026 23:58
5196a06 to
a25a140
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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(aliasncol; matplotlib renamed it in 3.6, normalised here)markerscaleframeon/framealpha(framealphaimpliesframeon, else it would be invisible on the default frameless legend)title_fontsizeImplementation
_legend_style_kwargs(lp, *, default_ncols, default_frameon)helper turnsLegendParamsintoax.legend()kwargs — one source of truth shared by all three legend builders (_stack_categorical_legend,_add_outline_legend, and the rebuild)._add_categorical_legend, which takes none of these kwargs and whosencols/markerscalecan't be changed on a builtLegend._apply_legend_overridestherefore 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._validate.pywith actionable, per-key errors (positive-intncols, positive-numbermarkerscale, boolframeon,framealphain [0, 1], number/strtitle_fontsize) and rejects conflictingncol/ncols.Why curated, not raw matplotlib passthrough
ncol→ncolsprecedent).Tests
ncols/ncolalias /markerscale/frameon/framealpha(incl. framealpha-implies-frameon) overrides, and no-op default.ncols/frameonreaches both the scanpy-built primary and the stacked builder.ncols/markerscale/frameon/framealpha/title_fontsizeand conflicting aliases.All non-visual tests pass locally (536). Visual (
test_plot_*) baselines are unaffected — the default (no-override) path is byte-identical to before.