Skip to content

Commit faacb83

Browse files
Support custom labels in sizelegend (#629)
* Add custom labels to sizelegend * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
1 parent a198ace commit faacb83

File tree

5 files changed

+227
-17
lines changed

5 files changed

+227
-17
lines changed

docs/colorbars_legends.py

Lines changed: 138 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -479,18 +479,154 @@
479479
# standalone semantic keys (categories, size scales, color levels, or geometry types).
480480
# UltraPlot provides helper methods that build these entries directly:
481481
#
482+
# * :meth:`~ultraplot.axes.Axes.entrylegend`
482483
# * :meth:`~ultraplot.axes.Axes.catlegend`
483484
# * :meth:`~ultraplot.axes.Axes.sizelegend`
484485
# * :meth:`~ultraplot.axes.Axes.numlegend`
485486
# * :meth:`~ultraplot.axes.Axes.geolegend`
487+
#
488+
# These helpers are useful whenever the legend should describe an encoding rather than
489+
# mirror artists that already happen to be drawn. In practice there are two distinct
490+
# workflows:
491+
#
492+
# * Use :meth:`~ultraplot.axes.Axes.legend` when you already have artists and want to
493+
# reuse their labels or lightly restyle the legend handles.
494+
# * Use the semantic helpers when you want to define the legend from meaning-first
495+
# inputs such as categories, numeric size levels, numeric color levels, or geometry
496+
# types, even if no matching exemplar artist exists on the axes.
497+
#
498+
# Choosing a helper
499+
# ~~~~~~~~~~~~~~~~~
500+
#
501+
# * :meth:`~ultraplot.axes.Axes.entrylegend` is the most general helper. Use it when
502+
# you want explicit labels, mixed line and marker entries, or fully custom legend
503+
# rows that are not easily described by a single category or numeric scale.
504+
# * :meth:`~ultraplot.axes.Axes.catlegend` is for discrete categories mapped to colors,
505+
# markers, and optional line styles. Labels come from the category names.
506+
# * :meth:`~ultraplot.axes.Axes.sizelegend` is for marker-size semantics. Labels are
507+
# derived from the numeric levels by default, can be formatted with ``fmt=``, and
508+
# can now be overridden directly with ``labels=[...]`` or ``labels={level: label}``.
509+
# * :meth:`~ultraplot.axes.Axes.numlegend` is for numeric color encodings rendered as
510+
# discrete patches without requiring a pre-existing mappable.
511+
# * :meth:`~ultraplot.axes.Axes.geolegend` is for shapes and map-like semantics. It can
512+
# mix named symbols, Shapely geometries, and country shorthands in one legend.
513+
#
514+
# The helpers are intentionally composable. Each one accepts ``add=False`` and returns
515+
# ``(handles, labels)`` so you can merge semantic sections and pass the result through
516+
# :meth:`~ultraplot.axes.Axes.legend` yourself.
517+
#
518+
# .. code-block:: python
519+
#
520+
# # Reuse plotted artists when they already exist.
521+
# hs = ax.plot(data, labels=["control", "treatment"])
522+
# ax.legend(hs, loc="r")
523+
#
524+
# # Build a category key without plotting one exemplar artist per category.
525+
# ax.catlegend(
526+
# ["Control", "Treatment"],
527+
# colors={"Control": "blue7", "Treatment": "red7"},
528+
# markers={"Control": "o", "Treatment": "^"},
529+
# loc="r",
530+
# )
531+
#
532+
# # Build fully custom entries with explicit labels and mixed semantics.
533+
# ax.entrylegend(
534+
# [
535+
# {
536+
# "label": "Observed samples",
537+
# "line": False,
538+
# "marker": "o",
539+
# "markersize": 8,
540+
# "markerfacecolor": "blue7",
541+
# "markeredgecolor": "black",
542+
# },
543+
# {
544+
# "label": "Model fit",
545+
# "line": True,
546+
# "color": "black",
547+
# "linewidth": 2.5,
548+
# "linestyle": "--",
549+
# },
550+
# ],
551+
# title="Entry styles",
552+
# loc="l",
553+
# )
554+
#
555+
# # Size legends can format labels automatically or accept explicit labels.
556+
# ax.sizelegend(
557+
# [10, 50, 200],
558+
# labels=["Small", "Medium", "Large"],
559+
# title="Population",
560+
# loc="ur",
561+
# )
562+
#
563+
# # Numeric color legends are discrete color keys decoupled from a mappable.
564+
# ax.numlegend(vmin=0, vmax=1, n=5, cmap="viko", fmt="{:.2f}", loc="ll")
565+
#
566+
# # Geometry legends can mix named shapes, Shapely geometries, and country codes.
567+
# ax.geolegend([("Triangle", "triangle"), ("Australia", "country:AU")], loc="r")
568+
#
569+
# .. code-block:: python
570+
#
571+
# # Compose multiple semantic helpers into one legend.
572+
# size_handles, size_labels = ax.sizelegend(
573+
# [10, 50, 200],
574+
# labels=["Small", "Medium", "Large"],
575+
# add=False,
576+
# )
577+
# entry_handles, entry_labels = ax.entrylegend(
578+
# [
579+
# {
580+
# "label": "Observed",
581+
# "line": False,
582+
# "marker": "o",
583+
# "markerfacecolor": "blue7",
584+
# },
585+
# {
586+
# "label": "Fit",
587+
# "line": True,
588+
# "color": "black",
589+
# "linewidth": 2,
590+
# },
591+
# ],
592+
# add=False,
593+
# )
594+
# ax.legend(
595+
# size_handles + entry_handles,
596+
# size_labels + entry_labels,
597+
# loc="r",
598+
# title="Combined semantic key",
599+
# )
486600

487601
# %%
488602
import cartopy.crs as ccrs
489603
import shapely.geometry as sg
490604

491-
fig, ax = uplt.subplots(refwidth=4.2)
605+
fig, ax = uplt.subplots(refwidth=5.0)
492606
ax.format(title="Semantic legend helpers", grid=False)
493607

608+
ax.entrylegend(
609+
[
610+
{
611+
"label": "Observed samples",
612+
"line": False,
613+
"marker": "o",
614+
"markersize": 8,
615+
"markerfacecolor": "blue7",
616+
"markeredgecolor": "black",
617+
},
618+
{
619+
"label": "Model fit",
620+
"line": True,
621+
"color": "black",
622+
"linewidth": 2.5,
623+
"linestyle": "--",
624+
},
625+
],
626+
loc="l",
627+
title="Entry styles",
628+
frameon=False,
629+
)
494630
ax.catlegend(
495631
["A", "B", "C"],
496632
colors={"A": "red7", "B": "green7", "C": "blue7"},
@@ -500,6 +636,7 @@
500636
)
501637
ax.sizelegend(
502638
[10, 50, 200],
639+
labels=["Small", "Medium", "Large"],
503640
loc="upper right",
504641
title="Population",
505642
ncols=1,

docs/examples/legends_colorbars/03_semantic_legends.py

Lines changed: 29 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,11 @@
77
Why UltraPlot here?
88
-------------------
99
UltraPlot adds semantic legend helpers directly on axes:
10-
``catlegend``, ``sizelegend``, ``numlegend``, and ``geolegend``.
11-
These are useful when you want legend meaning decoupled from plotted handles.
10+
``entrylegend``, ``catlegend``, ``sizelegend``, ``numlegend``, and ``geolegend``.
11+
These are useful when you want legend meaning decoupled from plotted handles, or
12+
when you want a standalone semantic key that describes an encoding directly.
1213
13-
Key functions: :py:meth:`ultraplot.axes.Axes.catlegend`, :py:meth:`ultraplot.axes.Axes.sizelegend`, :py:meth:`ultraplot.axes.Axes.numlegend`, :py:meth:`ultraplot.axes.Axes.geolegend`.
14+
Key functions: :py:meth:`ultraplot.axes.Axes.entrylegend`, :py:meth:`ultraplot.axes.Axes.catlegend`, :py:meth:`ultraplot.axes.Axes.sizelegend`, :py:meth:`ultraplot.axes.Axes.numlegend`, :py:meth:`ultraplot.axes.Axes.geolegend`.
1415
1516
See also
1617
--------
@@ -19,21 +20,35 @@
1920

2021
# %%
2122
import cartopy.crs as ccrs
22-
import numpy as np
2323
import shapely.geometry as sg
24-
from matplotlib.path import Path
2524

2625
import ultraplot as uplt
2726

28-
np.random.seed(0)
29-
data = np.random.randn(2, 100)
30-
sizes = np.random.randint(10, 512, data.shape[1])
31-
colors = np.random.rand(data.shape[1])
32-
33-
fig, ax = uplt.subplots()
34-
ax.scatter(*data, color=colors, s=sizes, cmap="viko")
27+
fig, ax = uplt.subplots(refwidth=5.0)
3528
ax.format(title="Semantic legend helpers")
3629

30+
ax.entrylegend(
31+
[
32+
{
33+
"label": "Observed samples",
34+
"line": False,
35+
"marker": "o",
36+
"markersize": 8,
37+
"markerfacecolor": "blue7",
38+
"markeredgecolor": "black",
39+
},
40+
{
41+
"label": "Model fit",
42+
"line": True,
43+
"color": "black",
44+
"linewidth": 2.5,
45+
"linestyle": "--",
46+
},
47+
],
48+
loc="l",
49+
title="Entry styles",
50+
frameon=False,
51+
)
3752
ax.catlegend(
3853
["A", "B", "C"],
3954
colors={"A": "red7", "B": "green7", "C": "blue7"},
@@ -43,6 +58,7 @@
4358
)
4459
ax.sizelegend(
4560
[10, 50, 200],
61+
labels=["Small", "Medium", "Large"],
4662
loc="upper right",
4763
title="Population",
4864
ncols=1,
@@ -88,4 +104,5 @@
88104
frameon=False,
89105
country_reso="10m",
90106
)
107+
ax.axis("off")
91108
fig.show()

ultraplot/axes/base.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3555,6 +3555,8 @@ def sizelegend(self, levels, **kwargs):
35553555
Numeric levels used to generate marker-size entries.
35563556
**kwargs
35573557
Forwarded to `ultraplot.legend.UltraLegend.sizelegend`.
3558+
Pass ``labels=[...]`` or ``labels={level: label}`` to override the
3559+
generated labels.
35583560
Pass ``add=False`` to return ``(handles, labels)`` without drawing.
35593561
"""
35603562
return plegend.UltraLegend(self).sizelegend(levels, **kwargs)

ultraplot/legend.py

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1119,6 +1119,7 @@ def _entry_legend_entries(
11191119
def _size_legend_entries(
11201120
levels: Iterable[float],
11211121
*,
1122+
labels=None,
11221123
color="0.35",
11231124
marker="o",
11241125
area=True,
@@ -1142,7 +1143,21 @@ def _size_legend_entries(
11421143
else:
11431144
ms = np.abs(values)
11441145
ms = np.maximum(ms * scale, minsize)
1145-
labels = [_format_label(value, fmt) for value in values]
1146+
if labels is None:
1147+
label_list = [_format_label(value, fmt) for value in values]
1148+
elif isinstance(labels, Mapping):
1149+
label_list = []
1150+
for value in values:
1151+
key = float(value)
1152+
if key not in labels:
1153+
raise ValueError(
1154+
"sizelegend labels mapping must include a label for every level."
1155+
)
1156+
label_list.append(str(labels[key]))
1157+
else:
1158+
label_list = [str(label) for label in labels]
1159+
if len(label_list) != len(values):
1160+
raise ValueError("sizelegend labels must have the same length as levels.")
11461161
base_styles = {
11471162
"line": False,
11481163
"alpha": alpha,
@@ -1152,7 +1167,7 @@ def _size_legend_entries(
11521167
}
11531168
base_styles.update(entry_kwargs)
11541169
handles = []
1155-
for idx, (value, label, size) in enumerate(zip(values, labels, ms)):
1170+
for idx, (value, label, size) in enumerate(zip(values, label_list, ms)):
11561171
styles = _resolve_style_values(base_styles, float(value), idx)
11571172
color_value = _style_lookup(color, float(value), idx, default="0.35")
11581173
marker_value = _style_lookup(marker, float(value), idx, default="o")
@@ -1171,7 +1186,7 @@ def _size_legend_entries(
11711186
**styles,
11721187
)
11731188
)
1174-
return handles, labels
1189+
return handles, label_list
11751190

11761191

11771192
def _num_legend_entries(
@@ -1561,6 +1576,7 @@ def sizelegend(
15611576
self,
15621577
levels: Iterable[float],
15631578
*,
1579+
labels=None,
15641580
color=None,
15651581
marker=None,
15661582
area: Optional[bool] = None,
@@ -1603,6 +1619,7 @@ def sizelegend(
16031619
)
16041620
handles, labels = _size_legend_entries(
16051621
levels,
1622+
labels=labels,
16061623
color=color,
16071624
marker=marker,
16081625
area=area,

ultraplot/tests/test_legend.py

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -469,6 +469,44 @@ def test_sizelegend_handle_kw_accepts_line_scatter_aliases():
469469
uplt.close(fig)
470470

471471

472+
def test_sizelegend_supports_custom_labels_sequence():
473+
fig, ax = uplt.subplots()
474+
handles, labels = ax.sizelegend(
475+
[10, 50, 200],
476+
labels=["small", "medium", "large"],
477+
add=False,
478+
)
479+
assert labels == ["small", "medium", "large"]
480+
assert [handle.get_label() for handle in handles] == labels
481+
uplt.close(fig)
482+
483+
484+
def test_sizelegend_supports_custom_labels_mapping():
485+
fig, ax = uplt.subplots()
486+
handles, labels = ax.sizelegend(
487+
[10, 50, 200],
488+
labels={10: "small", 50: "medium", 200: "large"},
489+
add=False,
490+
)
491+
assert labels == ["small", "medium", "large"]
492+
assert [handle.get_label() for handle in handles] == labels
493+
uplt.close(fig)
494+
495+
496+
def test_sizelegend_custom_labels_validate_length():
497+
fig, ax = uplt.subplots()
498+
with pytest.raises(ValueError, match="same length as levels"):
499+
ax.sizelegend([10, 50], labels=["small"], add=False)
500+
uplt.close(fig)
501+
502+
503+
def test_sizelegend_custom_labels_mapping_must_cover_levels():
504+
fig, ax = uplt.subplots()
505+
with pytest.raises(ValueError, match="include a label for every level"):
506+
ax.sizelegend([10, 50], labels={10: "small"}, add=False)
507+
uplt.close(fig)
508+
509+
472510
def test_numlegend_handle_kw_accepts_patch_aliases():
473511
fig, ax = uplt.subplots()
474512
handles, labels = ax.numlegend(
@@ -585,7 +623,6 @@ def test_semantic_legend_rejects_label_kwarg(builder, args, kwargs):
585623
(
586624
("entrylegend", (["A", "B"],), {}),
587625
("catlegend", (["A", "B"],), {}),
588-
("sizelegend", ([10, 50],), {}),
589626
("numlegend", tuple(), {"vmin": 0, "vmax": 1}),
590627
),
591628
)

0 commit comments

Comments
 (0)