From ecf6ae246dc95f5ae3de26d1efa2fd633e75dfd0 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Mon, 2 Feb 2026 20:19:13 +1000 Subject: [PATCH 01/17] Refactor legend builder into module --- ultraplot/axes/base.py | 186 +++++--------------------------- ultraplot/legend.py | 240 ++++++++++++++++++++++++++++++++++++++++- 2 files changed, 264 insertions(+), 162 deletions(-) diff --git a/ultraplot/axes/base.py b/ultraplot/axes/base.py index a1ec850f0..759995f71 100644 --- a/ultraplot/axes/base.py +++ b/ultraplot/axes/base.py @@ -82,30 +82,6 @@ # A-b-c label string ABC_STRING = "abcdefghijklmnopqrstuvwxyz" -# Legend align options -ALIGN_OPTS = { - None: { - "center": "center", - "left": "center left", - "right": "center right", - "top": "upper center", - "bottom": "lower center", - }, - "left": { - "top": "upper right", - "center": "center right", - "bottom": "lower right", - }, - "right": { - "top": "upper left", - "center": "center left", - "bottom": "lower left", - }, - "top": {"left": "lower left", "center": "lower center", "right": "lower right"}, - "bottom": {"left": "upper left", "center": "upper center", "right": "upper right"}, -} - - # Projection docstring _proj_docstring = """ proj, projection : \\ @@ -1263,148 +1239,38 @@ def _add_legend( cols: Optional[Union[int, Tuple[int, int]]] = None, **kwargs, ): - """ - The driver function for adding axes legends. - """ - # Parse input argument units - ncol = _not_none(ncols=ncols, ncol=ncol) - order = _not_none(order, "C") - frameon = _not_none(frame=frame, frameon=frameon, default=rc["legend.frameon"]) - fontsize = _not_none(fontsize, rc["legend.fontsize"]) - titlefontsize = _not_none( - title_fontsize=kwargs.pop("title_fontsize", None), - titlefontsize=titlefontsize, - default=rc["legend.title_fontsize"], - ) - fontsize = _fontsize_to_pt(fontsize) - titlefontsize = _fontsize_to_pt(titlefontsize) - if order not in ("F", "C"): - raise ValueError( - f"Invalid order {order!r}. Please choose from " - "'C' (row-major, default) or 'F' (column-major)." - ) - - # Convert relevant keys to em-widths - for setting in rcsetup.EM_KEYS: # em-width keys - pair = setting.split("legend.", 1) - if len(pair) == 1: - continue - _, key = pair - value = kwargs.pop(key, None) - if isinstance(value, str): - value = units(value, "em", fontsize=fontsize) - if value is not None: - kwargs[key] = value - - # Generate and prepare the legend axes - if loc in ("fill", "left", "right", "top", "bottom"): - lax = self._add_guide_panel( - loc, - align, - width=width, - space=space, - pad=pad, - span=span, - row=row, - col=col, - rows=rows, - cols=cols, - ) - kwargs.setdefault("borderaxespad", 0) - if not frameon: - kwargs.setdefault("borderpad", 0) - try: - kwargs["loc"] = ALIGN_OPTS[lax._panel_side][align] - except KeyError: - raise ValueError(f"Invalid align={align!r} for legend loc={loc!r}.") - else: - lax = self - pad = kwargs.pop("borderaxespad", pad) - kwargs["loc"] = loc # simply pass to legend - kwargs["borderaxespad"] = units(pad, "em", fontsize=fontsize) - - # Handle and text properties that are applied after-the-fact - # NOTE: Set solid_capstyle to 'butt' so line does not extend past error bounds - # shading in legend entry. This change is not noticable in other situations. - kw_frame, kwargs = lax._parse_frame("legend", **kwargs) - kw_text = {} - if fontcolor is not None: - kw_text["color"] = fontcolor - if fontweight is not None: - kw_text["weight"] = fontweight - kw_title = {} - if titlefontcolor is not None: - kw_title["color"] = titlefontcolor - if titlefontweight is not None: - kw_title["weight"] = titlefontweight - kw_handle = _pop_props(kwargs, "line") - kw_handle.setdefault("solid_capstyle", "butt") - kw_handle.update(handle_kw or {}) - - # Parse the legend arguments using axes for auto-handle detection - # TODO: Update this when we no longer use "filled panels" for outer legends - pairs, multi = lax._parse_legend_handles( + return plegend.UltraLegend(self).add( handles, labels, + loc=loc, + align=align, + width=width, + pad=pad, + space=space, + frame=frame, + frameon=frameon, ncol=ncol, - order=order, - center=center, + ncols=ncols, alphabetize=alphabetize, + center=center, + order=order, + label=label, + title=title, + fontsize=fontsize, + fontweight=fontweight, + fontcolor=fontcolor, + titlefontsize=titlefontsize, + titlefontweight=titlefontweight, + titlefontcolor=titlefontcolor, + handle_kw=handle_kw, handler_map=handler_map, + span=span, + row=row, + col=col, + rows=rows, + cols=cols, + **kwargs, ) - title = _not_none(label=label, title=title) - kwargs.update( - { - "title": title, - "frameon": frameon, - "fontsize": fontsize, - "handler_map": handler_map, - "title_fontsize": titlefontsize, - } - ) - - # Add the legend and update patch properties - # TODO: Add capacity for categorical labels in a single legend like seaborn - # rather than manual handle overrides with multiple legends. - if multi: - objs = lax._parse_legend_centered(pairs, kw_frame=kw_frame, **kwargs) - else: - kwargs.update({key: kw_frame.pop(key) for key in ("shadow", "fancybox")}) - objs = [lax._parse_legend_aligned(pairs, ncol=ncol, order=order, **kwargs)] - objs[0].legendPatch.update(kw_frame) - for obj in objs: - if hasattr(lax, "legend_") and lax.legend_ is None: - lax.legend_ = obj # make first legend accessible with get_legend() - else: - lax.add_artist(obj) - - # Update legend patch and elements - # WARNING: legendHandles only contains the *first* artist per legend because - # HandlerBase.legend_artist() called in Legend._init_legend_box() only - # returns the first artist. Instead we try to iterate through offset boxes. - for obj in objs: - obj.set_clip_on(False) # needed for tight bounding box calculations - box = getattr(obj, "_legend_handle_box", None) - for obj in guides._iter_children(box): - if isinstance(obj, mtext.Text): - kw = kw_text - else: - kw = { - key: val - for key, val in kw_handle.items() - if hasattr(obj, "set_" + key) - } # noqa: E501 - if hasattr(obj, "set_sizes") and "markersize" in kw_handle: - kw["sizes"] = np.atleast_1d(kw_handle["markersize"]) - obj.update(kw) - - # Register location and return - if isinstance(objs[0], mpatches.FancyBboxPatch): - objs = objs[1:] - obj = objs[0] if len(objs) == 1 else tuple(objs) - self._register_guide("legend", obj, (loc, align)) # possibly replace another - - return obj def _apply_title_above(self): """ diff --git a/ultraplot/legend.py b/ultraplot/legend.py index c6c66ee22..45db3e297 100644 --- a/ultraplot/legend.py +++ b/ultraplot/legend.py @@ -1,7 +1,15 @@ -from matplotlib import lines as mlines +from typing import Any, Optional, Tuple, Union + +import numpy as np +import matplotlib.patches as mpatches +import matplotlib.text as mtext from matplotlib import legend as mlegend from matplotlib import legend_handler as mhandler -from matplotlib import patches as mpatches +from matplotlib import lines as mlines + +from .config import rc +from .internals import _not_none, _pop_props, guides, rcsetup +from .utils import _fontsize_to_pt, units try: from typing import override @@ -94,6 +102,45 @@ def marker(cls, label=None, marker="o", **kwargs): return cls(label=label, line=False, marker=marker, **kwargs) +ALIGN_OPTS = { + None: { + "center": "center", + "left": "center left", + "right": "center right", + "top": "upper center", + "bottom": "lower center", + }, + "left": { + "center": "center right", + "left": "center right", + "right": "center right", + "top": "upper right", + "bottom": "lower right", + }, + "right": { + "center": "center left", + "left": "center left", + "right": "center left", + "top": "upper left", + "bottom": "lower left", + }, + "top": { + "center": "lower center", + "left": "lower left", + "right": "lower right", + "top": "lower center", + "bottom": "lower center", + }, + "bottom": { + "center": "upper center", + "left": "upper left", + "right": "upper right", + "top": "upper center", + "bottom": "upper center", + }, +} + + class Legend(mlegend.Legend): # Soft wrapper of matplotlib legend's class. # Currently we only override the syncing of the location. @@ -131,3 +178,192 @@ def set_loc(self, loc=None): value = self.axes._legend_dict.pop(old_loc, None) where, type = old_loc self.axes._legend_dict[(loc, type)] = value + + +class UltraLegend: + """ + Centralized legend builder for axes. + """ + + def __init__(self, axes): + self.axes = axes + + def add( + self, + handles=None, + labels=None, + *, + loc=None, + align=None, + width=None, + pad=None, + space=None, + frame=None, + frameon=None, + ncol=None, + ncols=None, + alphabetize=False, + center=None, + order=None, + label=None, + title=None, + fontsize=None, + fontweight=None, + fontcolor=None, + titlefontsize=None, + titlefontweight=None, + titlefontcolor=None, + handle_kw=None, + handler_map=None, + span: Optional[Union[int, Tuple[int, int]]] = None, + row: Optional[int] = None, + col: Optional[int] = None, + rows: Optional[Union[int, Tuple[int, int]]] = None, + cols: Optional[Union[int, Tuple[int, int]]] = None, + **kwargs, + ): + """ + The driver function for adding axes legends. + """ + ax = self.axes + # Parse input argument units + ncol = _not_none(ncols=ncols, ncol=ncol) + order = _not_none(order, "C") + frameon = _not_none(frame=frame, frameon=frameon, default=rc["legend.frameon"]) + fontsize = _not_none(fontsize, rc["legend.fontsize"]) + titlefontsize = _not_none( + title_fontsize=kwargs.pop("title_fontsize", None), + titlefontsize=titlefontsize, + default=rc["legend.title_fontsize"], + ) + fontsize = _fontsize_to_pt(fontsize) + titlefontsize = _fontsize_to_pt(titlefontsize) + if order not in ("F", "C"): + raise ValueError( + f"Invalid order {order!r}. Please choose from " + "'C' (row-major, default) or 'F' (column-major)." + ) + + # Convert relevant keys to em-widths + for setting in rcsetup.EM_KEYS: # em-width keys + pair = setting.split("legend.", 1) + if len(pair) == 1: + continue + _, key = pair + value = kwargs.pop(key, None) + if isinstance(value, str): + value = units(value, "em", fontsize=fontsize) + if value is not None: + kwargs[key] = value + + # Generate and prepare the legend axes + if loc in ("fill", "left", "right", "top", "bottom"): + lax = ax._add_guide_panel( + loc, + align, + width=width, + space=space, + pad=pad, + span=span, + row=row, + col=col, + rows=rows, + cols=cols, + ) + kwargs.setdefault("borderaxespad", 0) + if not frameon: + kwargs.setdefault("borderpad", 0) + try: + kwargs["loc"] = ALIGN_OPTS[lax._panel_side][align] + except KeyError as exc: + raise ValueError( + f"Invalid align={align!r} for legend loc={loc!r}." + ) from exc + else: + lax = ax + pad = kwargs.pop("borderaxespad", pad) + kwargs["loc"] = loc # simply pass to legend + kwargs["borderaxespad"] = units(pad, "em", fontsize=fontsize) + + # Handle and text properties that are applied after-the-fact + # NOTE: Set solid_capstyle to 'butt' so line does not extend past error bounds + # shading in legend entry. This change is not noticable in other situations. + kw_frame, kwargs = lax._parse_frame("legend", **kwargs) + kw_text = {} + if fontcolor is not None: + kw_text["color"] = fontcolor + if fontweight is not None: + kw_text["weight"] = fontweight + kw_title = {} + if titlefontcolor is not None: + kw_title["color"] = titlefontcolor + if titlefontweight is not None: + kw_title["weight"] = titlefontweight + kw_handle = _pop_props(kwargs, "line") + kw_handle.setdefault("solid_capstyle", "butt") + kw_handle.update(handle_kw or {}) + + # Parse the legend arguments using axes for auto-handle detection + # TODO: Update this when we no longer use "filled panels" for outer legends + pairs, multi = lax._parse_legend_handles( + handles, + labels, + ncol=ncol, + order=order, + center=center, + alphabetize=alphabetize, + handler_map=handler_map, + ) + title = _not_none(label=label, title=title) + kwargs.update( + { + "title": title, + "frameon": frameon, + "fontsize": fontsize, + "handler_map": handler_map, + "title_fontsize": titlefontsize, + } + ) + + # Add the legend and update patch properties + # TODO: Add capacity for categorical labels in a single legend like seaborn + # rather than manual handle overrides with multiple legends. + if multi: + objs = lax._parse_legend_centered(pairs, kw_frame=kw_frame, **kwargs) + else: + kwargs.update({key: kw_frame.pop(key) for key in ("shadow", "fancybox")}) + objs = [lax._parse_legend_aligned(pairs, ncol=ncol, order=order, **kwargs)] + objs[0].legendPatch.update(kw_frame) + for obj in objs: + if hasattr(lax, "legend_") and lax.legend_ is None: + lax.legend_ = obj # make first legend accessible with get_legend() + else: + lax.add_artist(obj) + + # Update legend patch and elements + # WARNING: legendHandles only contains the *first* artist per legend because + # HandlerBase.legend_artist() called in Legend._init_legend_box() only + # returns the first artist. Instead we try to iterate through offset boxes. + for obj in objs: + obj.set_clip_on(False) # needed for tight bounding box calculations + box = getattr(obj, "_legend_handle_box", None) + for child in guides._iter_children(box): + if isinstance(child, mtext.Text): + kw = kw_text + else: + kw = { + key: val + for key, val in kw_handle.items() + if hasattr(child, "set_" + key) + } + if hasattr(child, "set_sizes") and "markersize" in kw_handle: + kw["sizes"] = np.atleast_1d(kw_handle["markersize"]) + child.update(kw) + + # Register location and return + if isinstance(objs[0], mpatches.FancyBboxPatch): + objs = objs[1:] + obj = objs[0] if len(objs) == 1 else tuple(objs) + ax._register_guide("legend", obj, (loc, align)) # possibly replace another + + return obj From 116f4d54ff227ef3eafea37058d455f5f818fa12 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Mon, 2 Feb 2026 20:22:14 +1000 Subject: [PATCH 02/17] Add legend builder helpers and tests --- ultraplot/legend.py | 28 ++++++++++++++++++---------- ultraplot/tests/test_legend.py | 26 ++++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 10 deletions(-) diff --git a/ultraplot/legend.py b/ultraplot/legend.py index 45db3e297..a27f40ae0 100644 --- a/ultraplot/legend.py +++ b/ultraplot/legend.py @@ -180,6 +180,23 @@ def set_loc(self, loc=None): self.axes._legend_dict[(loc, type)] = value +def _normalize_em_kwargs(kwargs: dict[str, Any], *, fontsize: float) -> dict[str, Any]: + """ + Convert legend-related em unit kwargs to absolute values in points. + """ + for setting in rcsetup.EM_KEYS: + pair = setting.split("legend.", 1) + if len(pair) == 1: + continue + _, key = pair + value = kwargs.pop(key, None) + if isinstance(value, str): + value = units(value, "em", fontsize=fontsize) + if value is not None: + kwargs[key] = value + return kwargs + + class UltraLegend: """ Centralized legend builder for axes. @@ -245,16 +262,7 @@ def add( ) # Convert relevant keys to em-widths - for setting in rcsetup.EM_KEYS: # em-width keys - pair = setting.split("legend.", 1) - if len(pair) == 1: - continue - _, key = pair - value = kwargs.pop(key, None) - if isinstance(value, str): - value = units(value, "em", fontsize=fontsize) - if value is not None: - kwargs[key] = value + kwargs = _normalize_em_kwargs(kwargs, fontsize=fontsize) # Generate and prepare the legend axes if loc in ("fill", "left", "right", "top", "bottom"): diff --git a/ultraplot/tests/test_legend.py b/ultraplot/tests/test_legend.py index f8ce461c6..e7a46cbf0 100644 --- a/ultraplot/tests/test_legend.py +++ b/ultraplot/tests/test_legend.py @@ -202,6 +202,32 @@ def test_legend_col_spacing(rng): return fig +def test_legend_align_opts_mapping(): + """ + Basic sanity check for legend alignment mapping. + """ + from ultraplot.legend import ALIGN_OPTS + + assert ALIGN_OPTS[None]["center"] == "center" + assert ALIGN_OPTS["left"]["top"] == "upper right" + assert ALIGN_OPTS["right"]["bottom"] == "lower left" + assert ALIGN_OPTS["top"]["center"] == "lower center" + assert ALIGN_OPTS["bottom"]["right"] == "upper right" + + +def test_legend_builder_smoke(): + """ + Ensure the legend builder path returns a legend object. + """ + import matplotlib.pyplot as plt + + fig, ax = uplt.subplots() + ax.plot([0, 1, 2], label="a") + leg = ax.legend(loc="right", align="center") + assert leg is not None + plt.close(fig) + + def test_sync_label_dict(rng): """ Legends are held within _legend_dict for which the key is a tuple of location and alignment. From b229c256c7b2ae167ed1be4f7ff6fce4c0aa1f8c Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Mon, 2 Feb 2026 20:25:55 +1000 Subject: [PATCH 03/17] Refine UltraLegend readability --- ultraplot/legend.py | 302 ++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 294 insertions(+), 8 deletions(-) diff --git a/ultraplot/legend.py b/ultraplot/legend.py index a27f40ae0..79cfa6026 100644 --- a/ultraplot/legend.py +++ b/ultraplot/legend.py @@ -205,7 +205,14 @@ class UltraLegend: def __init__(self, axes): self.axes = axes - def add( + @staticmethod + def _align_map() -> dict[Optional[str], dict[str, str]]: + """ + Mapping between panel side + align and matplotlib legend loc strings. + """ + return ALIGN_OPTS + + def _resolve_inputs( self, handles=None, labels=None, @@ -239,11 +246,6 @@ def add( cols: Optional[Union[int, Tuple[int, int]]] = None, **kwargs, ): - """ - The driver function for adding axes legends. - """ - ax = self.axes - # Parse input argument units ncol = _not_none(ncols=ncols, ncol=ncol) order = _not_none(order, "C") frameon = _not_none(frame=frame, frameon=frameon, default=rc["legend.frameon"]) @@ -263,8 +265,53 @@ def add( # Convert relevant keys to em-widths kwargs = _normalize_em_kwargs(kwargs, fontsize=fontsize) + return ( + handles, + labels, + loc, + align, + width, + pad, + space, + frameon, + ncol, + order, + label, + title, + fontsize, + fontweight, + fontcolor, + titlefontsize, + titlefontweight, + titlefontcolor, + handle_kw, + handler_map, + span, + row, + col, + rows, + cols, + kwargs, + ) - # Generate and prepare the legend axes + def _resolve_axes_layout( + self, + *, + loc, + align, + width, + pad, + space, + frameon, + span, + row, + col, + rows, + cols, + fontsize, + kwargs, + ): + ax = self.axes if loc in ("fill", "left", "right", "top", "bottom"): lax = ax._add_guide_panel( loc, @@ -282,7 +329,7 @@ def add( if not frameon: kwargs.setdefault("borderpad", 0) try: - kwargs["loc"] = ALIGN_OPTS[lax._panel_side][align] + kwargs["loc"] = self._align_map()[lax._panel_side][align] except KeyError as exc: raise ValueError( f"Invalid align={align!r} for legend loc={loc!r}." @@ -292,6 +339,245 @@ def add( pad = kwargs.pop("borderaxespad", pad) kwargs["loc"] = loc # simply pass to legend kwargs["borderaxespad"] = units(pad, "em", fontsize=fontsize) + return lax, kwargs + + def _resolve_style_kwargs( + self, + *, + lax, + fontcolor, + fontweight, + handle_kw, + kwargs, + ): + kw_frame, kwargs = lax._parse_frame("legend", **kwargs) + kw_text = {} + if fontcolor is not None: + kw_text["color"] = fontcolor + if fontweight is not None: + kw_text["weight"] = fontweight + kw_handle = _pop_props(kwargs, "line") + kw_handle.setdefault("solid_capstyle", "butt") + kw_handle.update(handle_kw or {}) + return kw_frame, kw_text, kw_handle, kwargs + + def _build_legends( + self, + *, + lax, + handles, + labels, + ncol, + order, + center, + alphabetize, + handler_map, + title, + label, + frameon, + fontsize, + titlefontsize, + kw_frame, + kwargs, + ): + pairs, multi = lax._parse_legend_handles( + handles, + labels, + ncol=ncol, + order=order, + center=center, + alphabetize=alphabetize, + handler_map=handler_map, + ) + title = _not_none(label=label, title=title) + kwargs.update( + { + "title": title, + "frameon": frameon, + "fontsize": fontsize, + "handler_map": handler_map, + "title_fontsize": titlefontsize, + } + ) + if multi: + objs = lax._parse_legend_centered(pairs, kw_frame=kw_frame, **kwargs) + else: + kwargs.update({key: kw_frame.pop(key) for key in ("shadow", "fancybox")}) + objs = [lax._parse_legend_aligned(pairs, ncol=ncol, order=order, **kwargs)] + objs[0].legendPatch.update(kw_frame) + for obj in objs: + if hasattr(lax, "legend_") and lax.legend_ is None: + lax.legend_ = obj + else: + lax.add_artist(obj) + return objs + + def _apply_handle_styles(self, objs, *, kw_text, kw_handle): + for obj in objs: + obj.set_clip_on(False) + box = getattr(obj, "_legend_handle_box", None) + for child in guides._iter_children(box): + if isinstance(child, mtext.Text): + kw = kw_text + else: + kw = { + key: val + for key, val in kw_handle.items() + if hasattr(child, "set_" + key) + } + if hasattr(child, "set_sizes") and "markersize" in kw_handle: + kw["sizes"] = np.atleast_1d(kw_handle["markersize"]) + child.update(kw) + + def _finalize(self, objs, *, loc, align): + ax = self.axes + if isinstance(objs[0], mpatches.FancyBboxPatch): + objs = objs[1:] + obj = objs[0] if len(objs) == 1 else tuple(objs) + ax._register_guide("legend", obj, (loc, align)) + return obj + + def add( + self, + handles=None, + labels=None, + *, + loc=None, + align=None, + width=None, + pad=None, + space=None, + frame=None, + frameon=None, + ncol=None, + ncols=None, + alphabetize=False, + center=None, + order=None, + label=None, + title=None, + fontsize=None, + fontweight=None, + fontcolor=None, + titlefontsize=None, + titlefontweight=None, + titlefontcolor=None, + handle_kw=None, + handler_map=None, + span: Optional[Union[int, Tuple[int, int]]] = None, + row: Optional[int] = None, + col: Optional[int] = None, + rows: Optional[Union[int, Tuple[int, int]]] = None, + cols: Optional[Union[int, Tuple[int, int]]] = None, + **kwargs, + ): + """ + The driver function for adding axes legends. + """ + ( + handles, + labels, + loc, + align, + width, + pad, + space, + frameon, + ncol, + order, + label, + title, + fontsize, + fontweight, + fontcolor, + titlefontsize, + titlefontweight, + titlefontcolor, + handle_kw, + handler_map, + span, + row, + col, + rows, + cols, + kwargs, + ) = self._resolve_inputs( + handles, + labels, + loc=loc, + align=align, + width=width, + pad=pad, + space=space, + frame=frame, + frameon=frameon, + ncol=ncol, + ncols=ncols, + alphabetize=alphabetize, + center=center, + order=order, + label=label, + title=title, + fontsize=fontsize, + fontweight=fontweight, + fontcolor=fontcolor, + titlefontsize=titlefontsize, + titlefontweight=titlefontweight, + titlefontcolor=titlefontcolor, + handle_kw=handle_kw, + handler_map=handler_map, + span=span, + row=row, + col=col, + rows=rows, + cols=cols, + **kwargs, + ) + + lax, kwargs = self._resolve_axes_layout( + loc=loc, + align=align, + width=width, + pad=pad, + space=space, + frameon=frameon, + span=span, + row=row, + col=col, + rows=rows, + cols=cols, + fontsize=fontsize, + kwargs=kwargs, + ) + + kw_frame, kw_text, kw_handle, kwargs = self._resolve_style_kwargs( + lax=lax, + fontcolor=fontcolor, + fontweight=fontweight, + handle_kw=handle_kw, + kwargs=kwargs, + ) + + objs = self._build_legends( + lax=lax, + handles=handles, + labels=labels, + ncol=ncol, + order=order, + center=center, + alphabetize=alphabetize, + handler_map=handler_map, + title=title, + label=label, + frameon=frameon, + fontsize=fontsize, + titlefontsize=titlefontsize, + kw_frame=kw_frame, + kwargs=kwargs, + ) + + self._apply_handle_styles(objs, kw_text=kw_text, kw_handle=kw_handle) + return self._finalize(objs, loc=loc, align=align) # Handle and text properties that are applied after-the-fact # NOTE: Set solid_capstyle to 'butt' so line does not extend past error bounds From 1c682d6a9e60a0a73587f52eb6b5d5c67defc591 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Mon, 2 Feb 2026 20:29:12 +1000 Subject: [PATCH 04/17] Tighten legend typing and docs --- ultraplot/legend.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/ultraplot/legend.py b/ultraplot/legend.py index 79cfa6026..982eea147 100644 --- a/ultraplot/legend.py +++ b/ultraplot/legend.py @@ -140,6 +140,8 @@ def marker(cls, label=None, marker="o", **kwargs): }, } +LegendKw = dict[str, Any] + class Legend(mlegend.Legend): # Soft wrapper of matplotlib legend's class. @@ -244,8 +246,11 @@ def _resolve_inputs( col: Optional[int] = None, rows: Optional[Union[int, Tuple[int, int]]] = None, cols: Optional[Union[int, Tuple[int, int]]] = None, - **kwargs, + **kwargs: Any, ): + """ + Normalize inputs, apply rc defaults, and convert units. + """ ncol = _not_none(ncols=ncols, ncol=ncol) order = _not_none(order, "C") frameon = _not_none(frame=frame, frameon=frameon, default=rc["legend.frameon"]) From 4e0a3637a5f4d5f454c2a9385b55c2182561303e Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Mon, 2 Feb 2026 20:30:53 +1000 Subject: [PATCH 05/17] Structure UltraLegend inputs and helpers --- ultraplot/legend.py | 252 ++++++++++++++++++++------------------------ 1 file changed, 113 insertions(+), 139 deletions(-) diff --git a/ultraplot/legend.py b/ultraplot/legend.py index 982eea147..7cb18b0a3 100644 --- a/ultraplot/legend.py +++ b/ultraplot/legend.py @@ -1,3 +1,4 @@ +from dataclasses import dataclass from typing import Any, Optional, Tuple, Union import numpy as np @@ -143,6 +144,36 @@ def marker(cls, label=None, marker="o", **kwargs): LegendKw = dict[str, Any] +@dataclass(frozen=True) +class _LegendInputs: + handles: Any + labels: Any + loc: Any + align: Any + width: Any + pad: Any + space: Any + frameon: bool + ncol: Any + order: str + label: Any + title: Any + fontsize: float + fontweight: Any + fontcolor: Any + titlefontsize: float + titlefontweight: Any + titlefontcolor: Any + handle_kw: Any + handler_map: Any + span: Optional[Union[int, Tuple[int, int]]] + row: Optional[int] + col: Optional[int] + rows: Optional[Union[int, Tuple[int, int]]] + cols: Optional[Union[int, Tuple[int, int]]] + kwargs: dict[str, Any] + + class Legend(mlegend.Legend): # Soft wrapper of matplotlib legend's class. # Currently we only override the syncing of the location. @@ -270,80 +301,69 @@ def _resolve_inputs( # Convert relevant keys to em-widths kwargs = _normalize_em_kwargs(kwargs, fontsize=fontsize) - return ( - handles, - labels, - loc, - align, - width, - pad, - space, - frameon, - ncol, - order, - label, - title, - fontsize, - fontweight, - fontcolor, - titlefontsize, - titlefontweight, - titlefontcolor, - handle_kw, - handler_map, - span, - row, - col, - rows, - cols, - kwargs, + return _LegendInputs( + handles=handles, + labels=labels, + loc=loc, + align=align, + width=width, + pad=pad, + space=space, + frameon=frameon, + ncol=ncol, + order=order, + label=label, + title=title, + fontsize=fontsize, + fontweight=fontweight, + fontcolor=fontcolor, + titlefontsize=titlefontsize, + titlefontweight=titlefontweight, + titlefontcolor=titlefontcolor, + handle_kw=handle_kw, + handler_map=handler_map, + span=span, + row=row, + col=col, + rows=rows, + cols=cols, + kwargs=kwargs, ) - def _resolve_axes_layout( - self, - *, - loc, - align, - width, - pad, - space, - frameon, - span, - row, - col, - rows, - cols, - fontsize, - kwargs, - ): + def _resolve_axes_layout(self, inputs: _LegendInputs): + """ + Determine the legend axes and layout-related kwargs. + """ ax = self.axes - if loc in ("fill", "left", "right", "top", "bottom"): + if inputs.loc in ("fill", "left", "right", "top", "bottom"): lax = ax._add_guide_panel( - loc, - align, - width=width, - space=space, - pad=pad, - span=span, - row=row, - col=col, - rows=rows, - cols=cols, + inputs.loc, + inputs.align, + width=inputs.width, + space=inputs.space, + pad=inputs.pad, + span=inputs.span, + row=inputs.row, + col=inputs.col, + rows=inputs.rows, + cols=inputs.cols, ) + kwargs = dict(inputs.kwargs) kwargs.setdefault("borderaxespad", 0) - if not frameon: + if not inputs.frameon: kwargs.setdefault("borderpad", 0) try: - kwargs["loc"] = self._align_map()[lax._panel_side][align] + kwargs["loc"] = self._align_map()[lax._panel_side][inputs.align] except KeyError as exc: raise ValueError( - f"Invalid align={align!r} for legend loc={loc!r}." + f"Invalid align={inputs.align!r} for legend loc={inputs.loc!r}." ) from exc else: lax = ax - pad = kwargs.pop("borderaxespad", pad) - kwargs["loc"] = loc # simply pass to legend - kwargs["borderaxespad"] = units(pad, "em", fontsize=fontsize) + kwargs = dict(inputs.kwargs) + pad = kwargs.pop("borderaxespad", inputs.pad) + kwargs["loc"] = inputs.loc # simply pass to legend + kwargs["borderaxespad"] = units(pad, "em", fontsize=inputs.fontsize) return lax, kwargs def _resolve_style_kwargs( @@ -355,6 +375,9 @@ def _resolve_style_kwargs( handle_kw, kwargs, ): + """ + Parse frame settings and build per-element style kwargs. + """ kw_frame, kwargs = lax._parse_frame("legend", **kwargs) kw_text = {} if fontcolor is not None: @@ -370,45 +393,40 @@ def _build_legends( self, *, lax, - handles, - labels, - ncol, - order, + inputs: _LegendInputs, center, alphabetize, - handler_map, - title, - label, - frameon, - fontsize, - titlefontsize, kw_frame, kwargs, ): pairs, multi = lax._parse_legend_handles( - handles, - labels, - ncol=ncol, - order=order, + inputs.handles, + inputs.labels, + ncol=inputs.ncol, + order=inputs.order, center=center, alphabetize=alphabetize, - handler_map=handler_map, + handler_map=inputs.handler_map, ) - title = _not_none(label=label, title=title) + title = _not_none(label=inputs.label, title=inputs.title) kwargs.update( { "title": title, - "frameon": frameon, - "fontsize": fontsize, - "handler_map": handler_map, - "title_fontsize": titlefontsize, + "frameon": inputs.frameon, + "fontsize": inputs.fontsize, + "handler_map": inputs.handler_map, + "title_fontsize": inputs.titlefontsize, } ) if multi: objs = lax._parse_legend_centered(pairs, kw_frame=kw_frame, **kwargs) else: kwargs.update({key: kw_frame.pop(key) for key in ("shadow", "fancybox")}) - objs = [lax._parse_legend_aligned(pairs, ncol=ncol, order=order, **kwargs)] + objs = [ + lax._parse_legend_aligned( + pairs, ncol=inputs.ncol, order=inputs.order, **kwargs + ) + ] objs[0].legendPatch.update(kw_frame) for obj in objs: if hasattr(lax, "legend_") and lax.legend_ is None: @@ -418,6 +436,9 @@ def _build_legends( return objs def _apply_handle_styles(self, objs, *, kw_text, kw_handle): + """ + Apply per-handle styling overrides to legend artists. + """ for obj in objs: obj.set_clip_on(False) box = getattr(obj, "_legend_handle_box", None) @@ -435,6 +456,9 @@ def _apply_handle_styles(self, objs, *, kw_text, kw_handle): child.update(kw) def _finalize(self, objs, *, loc, align): + """ + Register legend for guide tracking and return the public object. + """ ax = self.axes if isinstance(objs[0], mpatches.FancyBboxPatch): objs = objs[1:] @@ -479,34 +503,7 @@ def add( """ The driver function for adding axes legends. """ - ( - handles, - labels, - loc, - align, - width, - pad, - space, - frameon, - ncol, - order, - label, - title, - fontsize, - fontweight, - fontcolor, - titlefontsize, - titlefontweight, - titlefontcolor, - handle_kw, - handler_map, - span, - row, - col, - rows, - cols, - kwargs, - ) = self._resolve_inputs( + inputs = self._resolve_inputs( handles, labels, loc=loc, @@ -539,50 +536,27 @@ def add( **kwargs, ) - lax, kwargs = self._resolve_axes_layout( - loc=loc, - align=align, - width=width, - pad=pad, - space=space, - frameon=frameon, - span=span, - row=row, - col=col, - rows=rows, - cols=cols, - fontsize=fontsize, - kwargs=kwargs, - ) + lax, kwargs = self._resolve_axes_layout(inputs) kw_frame, kw_text, kw_handle, kwargs = self._resolve_style_kwargs( lax=lax, - fontcolor=fontcolor, - fontweight=fontweight, - handle_kw=handle_kw, + fontcolor=inputs.fontcolor, + fontweight=inputs.fontweight, + handle_kw=inputs.handle_kw, kwargs=kwargs, ) objs = self._build_legends( lax=lax, - handles=handles, - labels=labels, - ncol=ncol, - order=order, + inputs=inputs, center=center, alphabetize=alphabetize, - handler_map=handler_map, - title=title, - label=label, - frameon=frameon, - fontsize=fontsize, - titlefontsize=titlefontsize, kw_frame=kw_frame, kwargs=kwargs, ) self._apply_handle_styles(objs, kw_text=kw_text, kw_handle=kw_handle) - return self._finalize(objs, loc=loc, align=align) + return self._finalize(objs, loc=inputs.loc, align=inputs.align) # Handle and text properties that are applied after-the-fact # NOTE: Set solid_capstyle to 'butt' so line does not extend past error bounds From 0113932219d55a4166244bff05be282dfceb3ca3 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Mon, 2 Feb 2026 20:32:38 +1000 Subject: [PATCH 06/17] Add legend typing aliases and em test --- ultraplot/legend.py | 8 +++++--- ultraplot/tests/test_legend.py | 10 ++++++++++ 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/ultraplot/legend.py b/ultraplot/legend.py index 7cb18b0a3..b5b6b6005 100644 --- a/ultraplot/legend.py +++ b/ultraplot/legend.py @@ -1,5 +1,5 @@ from dataclasses import dataclass -from typing import Any, Optional, Tuple, Union +from typing import Any, Iterable, Optional, Tuple, Union import numpy as np import matplotlib.patches as mpatches @@ -142,12 +142,14 @@ def marker(cls, label=None, marker="o", **kwargs): } LegendKw = dict[str, Any] +LegendHandles = Any +LegendLabels = Any @dataclass(frozen=True) class _LegendInputs: - handles: Any - labels: Any + handles: LegendHandles + labels: LegendLabels loc: Any align: Any width: Any diff --git a/ultraplot/tests/test_legend.py b/ultraplot/tests/test_legend.py index e7a46cbf0..e04287286 100644 --- a/ultraplot/tests/test_legend.py +++ b/ultraplot/tests/test_legend.py @@ -228,6 +228,16 @@ def test_legend_builder_smoke(): plt.close(fig) +def test_legend_normalize_em_kwargs(): + """ + Ensure em-based legend kwargs are converted to numeric values. + """ + from ultraplot.legend import _normalize_em_kwargs + + out = _normalize_em_kwargs({"labelspacing": "2em"}, fontsize=10) + assert isinstance(out["labelspacing"], (int, float)) + + def test_sync_label_dict(rng): """ Legends are held within _legend_dict for which the key is a tuple of location and alignment. From 67307aa2fb7e0f0c3d72df933f4c9128eaa92bcf Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Sun, 8 Feb 2026 17:19:05 +1000 Subject: [PATCH 07/17] Add LegendEntry helper for custom legend handles --- ultraplot/legend.py | 6 +----- ultraplot/tests/test_legend.py | 2 -- 2 files changed, 1 insertion(+), 7 deletions(-) diff --git a/ultraplot/legend.py b/ultraplot/legend.py index b5b6b6005..043184f12 100644 --- a/ultraplot/legend.py +++ b/ultraplot/legend.py @@ -1,9 +1,9 @@ from dataclasses import dataclass from typing import Any, Iterable, Optional, Tuple, Union -import numpy as np import matplotlib.patches as mpatches import matplotlib.text as mtext +import numpy as np from matplotlib import legend as mlegend from matplotlib import legend_handler as mhandler from matplotlib import lines as mlines @@ -39,8 +39,6 @@ def _wedge_legend_patch( if theta2 == theta1: theta2 = theta1 + 300.0 return mpatches.Wedge(center, radius, theta1=theta1, theta2=theta2) - - class LegendEntry(mlines.Line2D): """ Convenience artist for custom legend entries. @@ -102,7 +100,6 @@ def marker(cls, label=None, marker="o", **kwargs): """ return cls(label=label, line=False, marker=marker, **kwargs) - ALIGN_OPTS = { None: { "center": "center", @@ -175,7 +172,6 @@ class _LegendInputs: cols: Optional[Union[int, Tuple[int, int]]] kwargs: dict[str, Any] - class Legend(mlegend.Legend): # Soft wrapper of matplotlib legend's class. # Currently we only override the syncing of the location. diff --git a/ultraplot/tests/test_legend.py b/ultraplot/tests/test_legend.py index e04287286..271c0bb35 100644 --- a/ultraplot/tests/test_legend.py +++ b/ultraplot/tests/test_legend.py @@ -349,8 +349,6 @@ def test_pie_legend_handler_map_override(): assert len(handles) == 2 assert all(isinstance(handle, mpatches.Rectangle) for handle in handles) uplt.close(fig) - - def test_external_mode_toggle_enables_auto(): """ Toggling external mode back off should resume on-the-fly guide creation. From f22c5c61b046cc2445c285f1c53b0674edc8342b Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Sun, 15 Feb 2026 06:55:48 +1000 Subject: [PATCH 08/17] Refactor semantic legends into Axes API and expand geo legend controls Move semantic legends into Axes and UltraLegend methods: ax.cat_legend, ax.size_legend, ax.num_legend, ax.geo_legend. Route these methods through Axes.legend so shorthand legend locations (for example loc=r) work consistently.\n\nAdd rc-backed semantic defaults under legend.cat.*, legend.size.*, legend.num.*, and legend.geo.*.\n\nExpand geo legend behavior with country_reso (10m/50m/110m), country_territories toggle, country_proj support (name/CRS/callable), and per-entry tuple overrides for projections/options.\n\nImprove country shorthand handling for legends by preserving nearby islands while pruning far territories by default, with explicit opt-in to include far territories.\n\nAdd regression and feature tests covering shorthand locations, rc defaults, country resolution/projection passthrough, geometry handling, and semantic legend smoke behavior. Legend test suite passes locally. --- ultraplot/axes/base.py | 79 ++- ultraplot/internals/rcsetup.py | 165 +++++ ultraplot/legend.py | 1099 +++++++++++++++++++++++++++++--- ultraplot/tests/test_legend.py | 317 +++++++++ 4 files changed, 1572 insertions(+), 88 deletions(-) diff --git a/ultraplot/axes/base.py b/ultraplot/axes/base.py index 759995f71..b135a7f23 100644 --- a/ultraplot/axes/base.py +++ b/ultraplot/axes/base.py @@ -2100,10 +2100,12 @@ def _parse_legend_centered( return objs @staticmethod - def _parse_legend_group(handles, labels=None): + def _parse_legend_group(handles, labels=None, handler_map=None): """ Parse possibly tuple-grouped input handles. """ + handler_map_full = plegend.Legend.get_default_handler_map().copy() + handler_map_full.update(handler_map or {}) # Helper function. Retrieve labels from a tuple group or from objects # in a container. Multiple labels lead to multiple legend entries. @@ -2154,7 +2156,18 @@ def _legend_tuple(*objs): # noqa: E306 continue handles.append(obj) else: - warnings._warn_ultraplot(f"Ignoring invalid legend handle {obj!r}.") + try: + handler = plegend.Legend.get_legend_handler( + handler_map_full, obj + ) + except Exception: + handler = None + if handler is not None: + handles.append(obj) + else: + warnings._warn_ultraplot( + f"Ignoring invalid legend handle {obj!r}." + ) return tuple(handles) # Sanitize labels. Ignore e.g. extra hist() or hist2d() return values, @@ -2247,7 +2260,9 @@ def _parse_legend_handles( ihandles, ilabels = to_list(ihandles), to_list(ilabels) if ihandles is None: ihandles = self._get_legend_handles(handler_map) - ihandles, ilabels = self._parse_legend_group(ihandles, ilabels) + ihandles, ilabels = self._parse_legend_group( + ihandles, ilabels, handler_map=handler_map + ) ipairs = list(zip(ihandles, ilabels)) if alphabetize: ipairs = sorted(ipairs, key=lambda pair: pair[1]) @@ -3487,6 +3502,64 @@ def legend( **kwargs, ) + def cat_legend(self, categories, **kwargs): + """ + Build categorical legend entries and optionally add a legend. + + Parameters + ---------- + categories + Category labels used to generate legend handles. + **kwargs + Forwarded to `ultraplot.legend.UltraLegend.cat_legend`. + Pass ``add=False`` to return ``(handles, labels)`` without drawing. + """ + return plegend.UltraLegend(self).cat_legend(categories, **kwargs) + + def size_legend(self, levels, **kwargs): + """ + Build size legend entries and optionally add a legend. + + Parameters + ---------- + levels + Numeric levels used to generate marker-size entries. + **kwargs + Forwarded to `ultraplot.legend.UltraLegend.size_legend`. + Pass ``add=False`` to return ``(handles, labels)`` without drawing. + """ + return plegend.UltraLegend(self).size_legend(levels, **kwargs) + + def num_legend(self, levels=None, **kwargs): + """ + Build numeric-color legend entries and optionally add a legend. + + Parameters + ---------- + levels + Numeric levels or number of levels. + **kwargs + Forwarded to `ultraplot.legend.UltraLegend.num_legend`. + Pass ``add=False`` to return ``(handles, labels)`` without drawing. + """ + return plegend.UltraLegend(self).num_legend(levels=levels, **kwargs) + + def geo_legend(self, entries, labels=None, **kwargs): + """ + Build geometry legend entries and optionally add a legend. + + Parameters + ---------- + entries + Geometry entries (mapping, ``(label, geometry)`` pairs, or geometries). + labels + Optional labels for geometry sequences. + **kwargs + Forwarded to `ultraplot.legend.UltraLegend.geo_legend`. + Pass ``add=False`` to return ``(handles, labels)`` without drawing. + """ + return plegend.UltraLegend(self).geo_legend(entries, labels=labels, **kwargs) + @classmethod def _coerce_curve_xy(cls, x, y): """ diff --git a/ultraplot/internals/rcsetup.py b/ultraplot/internals/rcsetup.py index 1029c7a3d..4c4dd95a0 100644 --- a/ultraplot/internals/rcsetup.py +++ b/ultraplot/internals/rcsetup.py @@ -1397,6 +1397,171 @@ def _validator_accepts(validator, value): _validate_bool, "Whether to add a shadow underneath inset colorbar frames.", ), + # Semantic legend helper defaults + "legend.cat.line": ( + False, + _validate_bool, + "Default line/marker mode for `Axes.cat_legend`.", + ), + "legend.cat.marker": ( + "o", + _validate_string, + "Default marker for `Axes.cat_legend` entries.", + ), + "legend.cat.linestyle": ( + "-", + _validate_linestyle, + "Default line style for `Axes.cat_legend` entries.", + ), + "legend.cat.linewidth": ( + 2.0, + _validate_float, + "Default line width for `Axes.cat_legend` entries.", + ), + "legend.cat.markersize": ( + 6.0, + _validate_float, + "Default marker size for `Axes.cat_legend` entries.", + ), + "legend.cat.alpha": ( + None, + _validate_or_none(_validate_float), + "Default alpha for `Axes.cat_legend` entries.", + ), + "legend.cat.markeredgecolor": ( + None, + _validate_or_none(_validate_color), + "Default marker edge color for `Axes.cat_legend` entries.", + ), + "legend.cat.markeredgewidth": ( + None, + _validate_or_none(_validate_float), + "Default marker edge width for `Axes.cat_legend` entries.", + ), + "legend.size.color": ( + "0.35", + _validate_color, + "Default marker color for `Axes.size_legend` entries.", + ), + "legend.size.marker": ( + "o", + _validate_string, + "Default marker for `Axes.size_legend` entries.", + ), + "legend.size.area": ( + True, + _validate_bool, + "Whether `Axes.size_legend` interprets levels as marker area by default.", + ), + "legend.size.scale": ( + 1.0, + _validate_float, + "Default marker size scale factor for `Axes.size_legend` entries.", + ), + "legend.size.minsize": ( + 3.0, + _validate_float, + "Default minimum marker size for `Axes.size_legend` entries.", + ), + "legend.size.format": ( + None, + _validate_or_none(_validate_string), + "Default label format string for `Axes.size_legend` entries.", + ), + "legend.size.alpha": ( + None, + _validate_or_none(_validate_float), + "Default alpha for `Axes.size_legend` entries.", + ), + "legend.size.markeredgecolor": ( + None, + _validate_or_none(_validate_color), + "Default marker edge color for `Axes.size_legend` entries.", + ), + "legend.size.markeredgewidth": ( + None, + _validate_or_none(_validate_float), + "Default marker edge width for `Axes.size_legend` entries.", + ), + "legend.num.n": ( + 5, + _validate_int, + "Default number of sampled levels for `Axes.num_legend`.", + ), + "legend.num.cmap": ( + "viridis", + _validate_cmap("continuous"), + "Default colormap for `Axes.num_legend` entries.", + ), + "legend.num.edgecolor": ( + "none", + _validate_or_none(_validate_color), + "Default edge color for `Axes.num_legend` patch entries.", + ), + "legend.num.linewidth": ( + 0.0, + _validate_float, + "Default edge width for `Axes.num_legend` patch entries.", + ), + "legend.num.alpha": ( + None, + _validate_or_none(_validate_float), + "Default alpha for `Axes.num_legend` entries.", + ), + "legend.num.format": ( + None, + _validate_or_none(_validate_string), + "Default label format string for `Axes.num_legend` entries.", + ), + "legend.geo.facecolor": ( + "none", + _validate_or_none(_validate_color), + "Default face color for `Axes.geo_legend` entries.", + ), + "legend.geo.edgecolor": ( + "0.25", + _validate_or_none(_validate_color), + "Default edge color for `Axes.geo_legend` entries.", + ), + "legend.geo.linewidth": ( + 1.0, + _validate_float, + "Default edge width for `Axes.geo_legend` entries.", + ), + "legend.geo.alpha": ( + None, + _validate_or_none(_validate_float), + "Default alpha for `Axes.geo_legend` entries.", + ), + "legend.geo.fill": ( + None, + _validate_or_none(_validate_bool), + "Default fill mode for `Axes.geo_legend` entries.", + ), + "legend.geo.country_reso": ( + "110m", + _validate_belongs("10m", "50m", "110m"), + "Default Natural Earth resolution used for country shorthand geometry " + "entries in `Axes.geo_legend`.", + ), + "legend.geo.country_territories": ( + False, + _validate_bool, + "Whether country shorthand entries in `Axes.geo_legend` include far-away " + "territories instead of pruning to the local footprint.", + ), + "legend.geo.country_proj": ( + None, + _validate_or_none(_validate_string), + "Optional projection name for country shorthand entries in `Axes.geo_legend`. " + "Can be overridden per call with a cartopy CRS or callable.", + ), + "legend.geo.handlesize": ( + 1.0, + _validate_float, + "Scale factor applied to both legend handle length and height for " + "`Axes.geo_legend` when explicit handle dimensions are not provided.", + ), # Color cycle additions "cycle": ( CYCLE, diff --git a/ultraplot/legend.py b/ultraplot/legend.py index 043184f12..d586881bd 100644 --- a/ultraplot/legend.py +++ b/ultraplot/legend.py @@ -1,12 +1,16 @@ from dataclasses import dataclass +from functools import lru_cache from typing import Any, Iterable, Optional, Tuple, Union import matplotlib.patches as mpatches +import matplotlib.path as mpath import matplotlib.text as mtext import numpy as np +from matplotlib import cm as mcm +from matplotlib import colors as mcolors +from matplotlib import lines as mlines from matplotlib import legend as mlegend from matplotlib import legend_handler as mhandler -from matplotlib import lines as mlines from .config import rc from .internals import _not_none, _pop_props, guides, rcsetup @@ -17,7 +21,29 @@ except ImportError: from typing_extensions import override -__all__ = ["Legend", "LegendEntry"] +try: # optional cartopy-dependent geometry support + import cartopy.crs as ccrs + from cartopy.io import shapereader as cshapereader + from cartopy.mpl.feature_artist import FeatureArtist as _CartopyFeatureArtist + from cartopy.mpl.path import shapely_to_path as _cartopy_shapely_to_path +except Exception: + ccrs = None + cshapereader = None + _CartopyFeatureArtist = None + _cartopy_shapely_to_path = None + +try: # optional shapely support for direct geometry legend handles + from shapely.geometry.base import BaseGeometry as _ShapelyBaseGeometry + from shapely.ops import unary_union as _shapely_unary_union +except Exception: + _ShapelyBaseGeometry = None + _shapely_unary_union = None + +__all__ = [ + "Legend", + "LegendEntry", + "GeometryEntry", +] def _wedge_legend_patch( @@ -99,7 +125,798 @@ def marker(cls, label=None, marker="o", **kwargs): Build a marker-style legend entry. """ return cls(label=label, line=False, marker=marker, **kwargs) +_GEOMETRY_SHAPE_PATHS = { + "circle": mpath.Path.unit_circle(), + "square": mpath.Path.unit_rectangle(), + "triangle": mpath.Path.unit_regular_polygon(3), + "diamond": mpath.Path.unit_regular_polygon(4), + "pentagon": mpath.Path.unit_regular_polygon(5), + "hexagon": mpath.Path.unit_regular_polygon(6), + "star": mpath.Path.unit_regular_star(5), +} +_GEOMETRY_SHAPE_ALIASES = { + "box": "square", + "rect": "square", + "rectangle": "square", + "tri": "triangle", + "pent": "pentagon", + "hex": "hexagon", +} + + +def _normalize_shape_name(value: str) -> str: + """ + Normalize geometry shape shorthand names. + """ + key = str(value).strip().lower().replace("_", "").replace("-", "").replace(" ", "") + return _GEOMETRY_SHAPE_ALIASES.get(key, key) + + +def _normalize_country_resolution(resolution: str) -> str: + """ + Normalize Natural Earth shorthand resolution. + """ + value = str(resolution).strip().lower() + if value in {"10", "10m"}: + return "10m" + if value in {"50", "50m"}: + return "50m" + if value in {"110", "110m"}: + return "110m" + raise ValueError( + f"Invalid country resolution {resolution!r}. " + "Use one of: '10m', '50m', '110m'." + ) + + +def _country_geometry_for_legend(geometry: Any, *, include_far: bool = False) -> Any: + """ + Reduce multi-part country geometry for readability while preserving local islands. + + This avoids tiny legend glyphs for countries with distant overseas territories + (e.g., Netherlands in Natural Earth datasets), but tries to keep nearby islands. + """ + if include_far: + return geometry + geoms = getattr(geometry, "geoms", None) + if geoms is None: + return geometry + parts = [] + for part in geoms: + area = float(getattr(part, "area", 0.0) or 0.0) + if area > 0: + parts.append((area, part)) + if not parts: + return geometry + dominant = max(parts, key=lambda item: item[0])[1] + + # Preserve local components near the dominant polygon (e.g. nearby coastal islands) + # while dropping very distant territories that make legend glyphs too tiny. + minx, miny, maxx, maxy = dominant.bounds + span = max(maxx - minx, maxy - miny, 1e-6) + neighborhood = dominant.buffer(1.5 * span) + keep = [part for _, part in parts if part.intersects(neighborhood)] + if not keep: + return dominant + if len(keep) == 1: + return keep[0] + if _shapely_unary_union is None: + return dominant + try: + return _shapely_unary_union(keep) + except Exception: + return dominant + + +def _resolve_country_projection(country_proj: Any) -> Any: + """ + Resolve shorthand strings to cartopy projections for country legend geometries. + """ + if country_proj is None: + return None + if callable(country_proj) and not hasattr(country_proj, "project_geometry"): + return country_proj + if hasattr(country_proj, "project_geometry"): + return country_proj + if isinstance(country_proj, str): + if ccrs is None: + raise ValueError( + "country_proj requires cartopy. Install cartopy or pass a callable." + ) + key = ( + country_proj.strip() + .lower() + .replace("_", "") + .replace("-", "") + .replace(" ", "") + ) + mapping = { + "platecarree": ccrs.PlateCarree, + "pc": ccrs.PlateCarree, + "mercator": ccrs.Mercator, + "robinson": ccrs.Robinson, + "mollweide": ccrs.Mollweide, + "equalearth": ccrs.EqualEarth, + "orthographic": ccrs.Orthographic, + } + if key not in mapping: + raise ValueError( + f"Unknown country_proj {country_proj!r}. " + "Use a cartopy CRS, callable, or one of: " + + ", ".join(sorted(mapping)) + + "." + ) + # Orthographic needs center lon/lat. + if key == "orthographic": + return mapping[key](0, 0) + return mapping[key]() + raise ValueError( + "country_proj must be None, a cartopy CRS, a projection name string, or " + "a callable accepting and returning a geometry." + ) + + +def _project_geometry_for_legend(geometry: Any, country_proj: Any) -> Any: + """ + Project geometry for legend rendering when requested. + """ + projection = _resolve_country_projection(country_proj) + if projection is None: + return geometry + if callable(projection) and not hasattr(projection, "project_geometry"): + out = projection(geometry) + if out is None: + raise ValueError("country_proj callable returned None geometry.") + return out + if ccrs is None: + raise ValueError( + "country_proj cartopy projection requested but cartopy missing." + ) + try: + return projection.project_geometry(geometry, src_crs=ccrs.PlateCarree()) + except TypeError: + return projection.project_geometry(geometry, ccrs.PlateCarree()) + + +@lru_cache(maxsize=256) +def _resolve_country_geometry( + code: str, resolution: str = "110m", include_far: bool = False +): + """ + Resolve a country shorthand code (e.g., ``AU`` or ``AUS``) to a geometry. + """ + if cshapereader is None: + raise ValueError( + "Country shorthand requires cartopy's shapereader support. " + "Pass a shapely geometry directly instead." + ) + key = str(code).strip().upper() + if not key: + raise ValueError("Country shorthand cannot be empty.") + resolution = _normalize_country_resolution(resolution) + try: + path = cshapereader.natural_earth( + resolution=resolution, + category="cultural", + name="admin_0_countries", + ) + reader = cshapereader.Reader(path) + except Exception as exc: + raise ValueError( + "Unable to load Natural Earth country geometries for shorthand parsing. " + "This usually means cartopy data is not available offline yet. " + "Pass a shapely geometry directly (e.g. from GeoPandas), or pre-download " + "the Natural Earth dataset." + ) from exc + + fields = ( + "ADM0_A3", + "ISO_A3", + "ISO_A3_EH", + "SOV_A3", + "SU_A3", + "GU_A3", + "BRK_A3", + "ADM0_A3_US", + "ISO_A2", + "ISO_A2_EH", + "ABBREV", + "NAME", + "NAME_LONG", + "ADMIN", + ) + for record in reader.records(): + attrs = record.attributes or {} + values = {str(attrs.get(field, "")).strip().upper() for field in fields} + values.discard("") + if key in values: + return _country_geometry_for_legend( + record.geometry, include_far=include_far + ) + raise ValueError(f"Unknown country shorthand {code!r}.") + + +def _geometry_to_path( + geometry: Any, + *, + country_reso: str = "110m", + country_territories: bool = False, + country_proj: Any = None, +) -> mpath.Path: + """ + Convert geometry/path shorthand input to a matplotlib path. + """ + if isinstance(geometry, mpath.Path): + return geometry + if isinstance(geometry, str): + spec = geometry.strip() + shape = _normalize_shape_name(spec) + if shape in _GEOMETRY_SHAPE_PATHS: + return _GEOMETRY_SHAPE_PATHS[shape] + if spec.lower().startswith("country:"): + geometry = _resolve_country_geometry( + spec.split(":", 1)[1], + country_reso, + include_far=country_territories, + ) + geometry = _project_geometry_for_legend(geometry, country_proj) + elif spec.isalpha() and len(spec) in (2, 3): + geometry = _resolve_country_geometry( + spec, + country_reso, + include_far=country_territories, + ) + geometry = _project_geometry_for_legend(geometry, country_proj) + else: + options = ", ".join(sorted(_GEOMETRY_SHAPE_PATHS)) + raise ValueError( + f"Unknown geometry shorthand {geometry!r}. " + f"Use a shapely geometry, country code, or one of: {options}." + ) + if hasattr(geometry, "geom_type") and _cartopy_shapely_to_path is not None: + return _cartopy_shapely_to_path(geometry) + raise TypeError( + "Geometry must be a matplotlib Path, shapely geometry, geometry shorthand, " + "or country shorthand." + ) + + +def _fit_path_to_handlebox( + path: mpath.Path, + *, + xdescent: float, + ydescent: float, + width: float, + height: float, + pad: float = 0.08, +) -> mpath.Path: + """ + Normalize an arbitrary path into the legend-handle box. + """ + verts = np.array(path.vertices, copy=True, dtype=float) + finite = np.isfinite(verts).all(axis=1) + if not finite.any(): + return mpath.Path.unit_rectangle() + xmin, ymin = verts[finite].min(axis=0) + xmax, ymax = verts[finite].max(axis=0) + dx = max(float(xmax - xmin), 1e-12) + dy = max(float(ymax - ymin), 1e-12) + px = max(width * pad, 0.0) + py = max(height * pad, 0.0) + span_x = max(width - 2 * px, 1e-12) + span_y = max(height - 2 * py, 1e-12) + scale = min(span_x / dx, span_y / dy) + cx = -xdescent + width * 0.5 + cy = -ydescent + height * 0.5 + verts[finite, 0] = (verts[finite, 0] - (xmin + xmax) * 0.5) * scale + cx + verts[finite, 1] = (verts[finite, 1] - (ymin + ymax) * 0.5) * scale + cy + return mpath.Path( + verts, None if path.codes is None else np.array(path.codes, copy=True) + ) + + +def _feature_geometry_path(handle: Any) -> Optional[mpath.Path]: + """ + Extract the first geometry path from a cartopy feature artist. + """ + feature = getattr(handle, "_feature", None) + if feature is None or _cartopy_shapely_to_path is None: + return None + geoms = getattr(feature, "geometries", None) + if geoms is None: + return None + try: + iterator = iter(geoms()) + except Exception: + return None + try: + geometry = next(iterator) + except StopIteration: + return None + try: + return _cartopy_shapely_to_path(geometry) + except Exception: + return None + + +def _first_scalar(value: Any, default: Any = None) -> Any: + """ + Return first scalar from lists/arrays used by collection-style artists. + """ + if value is None: + return default + if isinstance(value, np.ndarray): + if value.size == 0: + return default + if value.ndim == 0: + return value.item() + if value.ndim >= 2: + item = value[0] + else: + item = value + if isinstance(item, np.ndarray) and item.size == 1: + return item.item() + return item + if isinstance(value, (list, tuple)): + if not value: + return default + item = value[0] + if isinstance(item, np.ndarray) and item.size == 1: + return item.item() + return item + return value + + +def _feature_legend_patch( + legend, + orig_handle, + xdescent, + ydescent, + width, + height, + fontsize, +): + """ + Draw a normalized geometry path for cartopy feature artists. + """ + path = _feature_geometry_path(orig_handle) + if path is None: + path = mpath.Path.unit_rectangle() + path = _fit_path_to_handlebox( + path, + xdescent=xdescent, + ydescent=ydescent, + width=width, + height=height, + ) + return mpatches.PathPatch(path) + + +def _shapely_geometry_patch( + legend, + orig_handle, + xdescent, + ydescent, + width, + height, + fontsize, +): + """ + Draw shapely geometry handles in legend boxes. + """ + if _cartopy_shapely_to_path is None: + path = mpath.Path.unit_rectangle() + else: + try: + path = _cartopy_shapely_to_path(orig_handle) + except Exception: + path = mpath.Path.unit_rectangle() + path = _fit_path_to_handlebox( + path, + xdescent=xdescent, + ydescent=ydescent, + width=width, + height=height, + ) + return mpatches.PathPatch(path) + + +def _geometry_entry_patch( + legend, + orig_handle, + xdescent, + ydescent, + width, + height, + fontsize, +): + """ + Draw a geometry entry path inside the legend-handle box. + """ + path = _fit_path_to_handlebox( + orig_handle.get_path(), + xdescent=xdescent, + ydescent=ydescent, + width=width, + height=height, + ) + return mpatches.PathPatch(path) + + +class _FeatureArtistLegendHandler(mhandler.HandlerPatch): + """ + Legend handler for cartopy FeatureArtist instances. + """ + + def __init__(self): + super().__init__(patch_func=_feature_legend_patch) + + def update_prop(self, legend_handle, orig_handle, legend): + facecolor = _first_scalar( + ( + orig_handle.get_facecolor() + if hasattr(orig_handle, "get_facecolor") + else None + ), + default="none", + ) + edgecolor = _first_scalar( + ( + orig_handle.get_edgecolor() + if hasattr(orig_handle, "get_edgecolor") + else None + ), + default="none", + ) + linewidth = _first_scalar( + ( + orig_handle.get_linewidth() + if hasattr(orig_handle, "get_linewidth") + else None + ), + default=0.0, + ) + legend_handle.set_facecolor(facecolor) + legend_handle.set_edgecolor(edgecolor) + legend_handle.set_linewidth(linewidth) + if hasattr(orig_handle, "get_alpha"): + legend_handle.set_alpha(orig_handle.get_alpha()) + legend._set_artist_props(legend_handle) + legend_handle.set_clip_box(None) + legend_handle.set_clip_path(None) + + +class _ShapelyGeometryLegendHandler(mhandler.HandlerPatch): + """ + Legend handler for raw shapely geometries. + """ + + def __init__(self): + super().__init__(patch_func=_shapely_geometry_patch) + + def update_prop(self, legend_handle, orig_handle, legend): + # No style information is stored on shapely geometry objects. + legend._set_artist_props(legend_handle) + legend_handle.set_clip_box(None) + legend_handle.set_clip_path(None) + + +class GeometryEntry(mpatches.PathPatch): + """ + Convenience geometry legend entry. + + Parameters + ---------- + geometry + Geometry shorthand (e.g. ``'triangle'`` or ``'country:AU'``), + shapely geometry, or `matplotlib.path.Path`. + """ + + def __init__( + self, + geometry: Any = "square", + *, + country_reso: str = "110m", + country_territories: bool = False, + country_proj: Any = None, + label: Optional[str] = None, + facecolor: Any = "none", + edgecolor: Any = "0.25", + linewidth: float = 1.0, + alpha: Optional[float] = None, + fill: Optional[bool] = None, + **kwargs: Any, + ): + path = _geometry_to_path( + geometry, + country_reso=country_reso, + country_territories=country_territories, + country_proj=country_proj, + ) + if fill is None: + fill = facecolor not in (None, "none") + super().__init__( + path=path, + label=label, + facecolor=facecolor, + edgecolor=edgecolor, + linewidth=linewidth, + alpha=alpha, + fill=fill, + **kwargs, + ) + self._ultraplot_geometry = geometry + + +def _geometry_default_label(geometry: Any, index: int) -> str: + """ + Derive default labels for geo legend entries. + """ + if isinstance(geometry, str): + return geometry + return f"Entry {index + 1}" + + +def _geo_legend_entries( + entries: Iterable[Any] | dict[Any, Any], + labels: Optional[Iterable[Any]] = None, + *, + country_reso: str = "110m", + country_territories: bool = False, + country_proj: Any = None, + facecolor: Any = "none", + edgecolor: Any = "0.25", + linewidth: float = 1.0, + alpha: Optional[float] = None, + fill: Optional[bool] = None, +): + """ + Build geometry semantic legend handles and labels. + + Notes + ----- + `entries` may be: + - mapping of ``label -> geometry`` + - sequence of ``(label, geometry)`` or ``(label, geometry, options)`` tuples + where ``options`` is either a projection spec or a dict of per-entry + `GeometryEntry` keyword overrides (e.g., `country_proj`, `country_reso`) + - sequence of geometries with explicit `labels` + """ + entry_options = None + if isinstance(entries, dict): + label_list = [str(label) for label in entries] + geometry_list = list(entries.values()) + entry_options = [{} for _ in geometry_list] + else: + entries = list(entries) + if labels is None and all( + isinstance(entry, tuple) and len(entry) in (2, 3) for entry in entries + ): + label_list = [] + geometry_list = [] + entry_options = [] + for entry in entries: + if len(entry) == 2: + label, geometry = entry + options = {} + else: + label, geometry, options = entry + if options is None: + options = {} + elif isinstance(options, dict): + options = dict(options) + else: + # Convenience shorthand for per-entry projection only. + options = {"country_proj": options} + label_list.append(str(label)) + geometry_list.append(geometry) + entry_options.append(options) + else: + geometry_list = list(entries) + entry_options = [{} for _ in geometry_list] + if labels is None: + label_list = [ + _geometry_default_label(geometry, idx) + for idx, geometry in enumerate(geometry_list) + ] + else: + label_list = [str(label) for label in labels] + if len(label_list) != len(geometry_list): + raise ValueError( + "Labels and geometry entries must have the same length. " + f"Got {len(label_list)} labels and {len(geometry_list)} entries." + ) + handles = [] + for geometry, label, options in zip(geometry_list, label_list, entry_options): + geo_kwargs = { + "country_reso": country_reso, + "country_territories": country_territories, + "country_proj": country_proj, + "facecolor": facecolor, + "edgecolor": edgecolor, + "linewidth": linewidth, + "alpha": alpha, + "fill": fill, + } + geo_kwargs.update(options or {}) + handles.append(GeometryEntry(geometry, label=label, **geo_kwargs)) + return handles, label_list + + +def _style_lookup(style, key, index, default=None): + """ + Resolve style values from scalar, mapping, or sequence inputs. + """ + if style is None: + return default + if isinstance(style, dict): + return style.get(key, default) + if isinstance(style, str): + return style + try: + values = list(style) + except TypeError: + return style + if not values: + return default + return values[index % len(values)] + + +def _format_label(value, fmt): + """ + Format legend labels from values. + """ + if fmt is None: + return f"{value:g}" if isinstance(value, (float, np.floating)) else str(value) + if callable(fmt): + return str(fmt(value)) + return fmt.format(value) + + +def _default_cycle_colors(): + """ + Return default color cycle entries. + """ + try: + import matplotlib as mpl + + colors = mpl.rcParams["axes.prop_cycle"].by_key().get("color", None) + except Exception: + colors = None + return colors or ["C0"] + + +def _cat_legend_entries( + categories: Iterable[Any], + *, + colors=None, + markers="o", + line: bool = False, + linestyle: str = "-", + linewidth: float = 2.0, + markersize: float = 6.0, + alpha=None, + markeredgecolor=None, + markeredgewidth=None, +): + """ + Build categorical semantic legend handles and labels. + """ + labels = list(dict.fromkeys(categories)) + palette = _default_cycle_colors() + handles = [] + for idx, label in enumerate(labels): + color = _style_lookup(colors, label, idx, default=palette[idx % len(palette)]) + marker = _style_lookup(markers, label, idx, default="o") + if line and marker in (None, ""): + marker = None + handles.append( + LegendEntry( + label=str(label), + color=color, + line=line, + marker=marker, + linestyle=linestyle, + linewidth=linewidth, + markersize=markersize, + markeredgecolor=markeredgecolor, + markeredgewidth=markeredgewidth, + alpha=alpha, + ) + ) + return handles, [str(label) for label in labels] + + +def _size_legend_entries( + levels: Iterable[float], + *, + color="0.35", + marker: str = "o", + area: bool = True, + scale: float = 1.0, + minsize: float = 3.0, + fmt=None, + alpha=None, + markeredgecolor=None, + markeredgewidth=None, +): + """ + Build size semantic legend handles and labels. + """ + values = np.asarray(list(levels), dtype=float) + if values.size == 0: + return [], [] + if area: + ms = np.sqrt(np.clip(values, 0, None)) + else: + ms = np.abs(values) + ms = np.maximum(ms * scale, minsize) + labels = [_format_label(value, fmt) for value in values] + handles = [ + LegendEntry.marker( + label=label, + marker=marker, + color=color, + markersize=float(size), + alpha=alpha, + markeredgecolor=markeredgecolor, + markeredgewidth=markeredgewidth, + ) + for label, size in zip(labels, ms) + ] + return handles, labels + +def _num_legend_entries( + levels=None, + *, + vmin=None, + vmax=None, + n: int = 5, + cmap="viridis", + norm=None, + fmt=None, + edgecolor="none", + linewidth: float = 0.0, + alpha=None, +): + """ + Build numeric-color semantic legend handles and labels. + """ + if levels is None: + if vmin is None or vmax is None: + raise ValueError("Please provide levels or both vmin and vmax.") + values = np.linspace(float(vmin), float(vmax), int(n)) + elif np.isscalar(levels) and isinstance(levels, (int, np.integer)): + if vmin is None or vmax is None: + raise ValueError("Please provide vmin and vmax when levels is an integer.") + values = np.linspace(float(vmin), float(vmax), int(levels)) + else: + values = np.asarray(list(levels), dtype=float) + if values.size == 0: + return [], [] + if norm is None: + lo = float(np.nanmin(values) if vmin is None else vmin) + hi = float(np.nanmax(values) if vmax is None else vmax) + norm = mcolors.Normalize(vmin=lo, vmax=hi) + try: + import matplotlib as mpl + + cmap_obj = mpl.colormaps.get_cmap(cmap) + except Exception: + cmap_obj = mcm.get_cmap(cmap) + labels = [_format_label(value, fmt) for value in values] + handles = [ + mpatches.Patch( + facecolor=cmap_obj(norm(float(value))), + edgecolor=edgecolor, + linewidth=linewidth, + alpha=alpha, + label=label, + ) + for value, label in zip(values, labels) + ] + return handles, labels ALIGN_OPTS = { None: { "center": "center", @@ -187,10 +1004,20 @@ def get_default_handler_map(cls): Extend matplotlib defaults with a wedge handler for pie legends. """ handler_map = dict(super().get_default_handler_map()) + handler_map.setdefault( + GeometryEntry, + mhandler.HandlerPatch(patch_func=_geometry_entry_patch), + ) handler_map.setdefault( mpatches.Wedge, mhandler.HandlerPatch(patch_func=_wedge_legend_patch), ) + if _CartopyFeatureArtist is not None: + handler_map.setdefault(_CartopyFeatureArtist, _FeatureArtistLegendHandler()) + if _ShapelyBaseGeometry is not None: + handler_map.setdefault( + _ShapelyBaseGeometry, _ShapelyGeometryLegendHandler() + ) return handler_map @override @@ -236,6 +1063,191 @@ class UltraLegend: def __init__(self, axes): self.axes = axes + def cat_legend( + self, + categories: Iterable[Any], + *, + colors=None, + markers=None, + line: Optional[bool] = None, + linestyle=None, + linewidth: Optional[float] = None, + markersize: Optional[float] = None, + alpha=None, + markeredgecolor=None, + markeredgewidth=None, + add: bool = True, + **legend_kwargs: Any, + ): + """ + Build categorical legend entries and optionally draw a legend. + """ + line = _not_none(line, rc["legend.cat.line"]) + markers = _not_none(markers, rc["legend.cat.marker"]) + linestyle = _not_none(linestyle, rc["legend.cat.linestyle"]) + linewidth = _not_none(linewidth, rc["legend.cat.linewidth"]) + markersize = _not_none(markersize, rc["legend.cat.markersize"]) + alpha = _not_none(alpha, rc["legend.cat.alpha"]) + markeredgecolor = _not_none(markeredgecolor, rc["legend.cat.markeredgecolor"]) + markeredgewidth = _not_none(markeredgewidth, rc["legend.cat.markeredgewidth"]) + handles, labels = _cat_legend_entries( + categories, + colors=colors, + markers=markers, + line=line, + linestyle=linestyle, + linewidth=linewidth, + markersize=markersize, + alpha=alpha, + markeredgecolor=markeredgecolor, + markeredgewidth=markeredgewidth, + ) + if not add: + return handles, labels + # Route through Axes.legend so location shorthands (e.g. 'r', 'b') + # and queued guide keyword handling behave exactly like the public API. + return self.axes.legend(handles, labels, **legend_kwargs) + + def size_legend( + self, + levels: Iterable[float], + *, + color=None, + marker=None, + area: Optional[bool] = None, + scale: Optional[float] = None, + minsize: Optional[float] = None, + fmt=None, + alpha=None, + markeredgecolor=None, + markeredgewidth=None, + add: bool = True, + **legend_kwargs: Any, + ): + """ + Build size legend entries and optionally draw a legend. + """ + color = _not_none(color, rc["legend.size.color"]) + marker = _not_none(marker, rc["legend.size.marker"]) + area = _not_none(area, rc["legend.size.area"]) + scale = _not_none(scale, rc["legend.size.scale"]) + minsize = _not_none(minsize, rc["legend.size.minsize"]) + fmt = _not_none(fmt, rc["legend.size.format"]) + alpha = _not_none(alpha, rc["legend.size.alpha"]) + markeredgecolor = _not_none(markeredgecolor, rc["legend.size.markeredgecolor"]) + markeredgewidth = _not_none(markeredgewidth, rc["legend.size.markeredgewidth"]) + handles, labels = _size_legend_entries( + levels, + color=color, + marker=marker, + area=area, + scale=scale, + minsize=minsize, + fmt=fmt, + alpha=alpha, + markeredgecolor=markeredgecolor, + markeredgewidth=markeredgewidth, + ) + if not add: + return handles, labels + return self.axes.legend(handles, labels, **legend_kwargs) + + def num_legend( + self, + levels=None, + *, + vmin=None, + vmax=None, + n: Optional[int] = None, + cmap=None, + norm=None, + fmt=None, + edgecolor=None, + linewidth: Optional[float] = None, + alpha=None, + add: bool = True, + **legend_kwargs: Any, + ): + """ + Build numeric-color legend entries and optionally draw a legend. + """ + n = _not_none(n, rc["legend.num.n"]) + cmap = _not_none(cmap, rc["legend.num.cmap"]) + edgecolor = _not_none(edgecolor, rc["legend.num.edgecolor"]) + linewidth = _not_none(linewidth, rc["legend.num.linewidth"]) + alpha = _not_none(alpha, rc["legend.num.alpha"]) + fmt = _not_none(fmt, rc["legend.num.format"]) + handles, labels = _num_legend_entries( + levels=levels, + vmin=vmin, + vmax=vmax, + n=n, + cmap=cmap, + norm=norm, + fmt=fmt, + edgecolor=edgecolor, + linewidth=linewidth, + alpha=alpha, + ) + if not add: + return handles, labels + return self.axes.legend(handles, labels, **legend_kwargs) + + def geo_legend( + self, + entries: Iterable[Any] | dict[Any, Any], + labels: Optional[Iterable[Any]] = None, + *, + country_reso: Optional[str] = None, + country_territories: Optional[bool] = None, + country_proj: Any = None, + handlesize: Optional[float] = None, + facecolor: Any = None, + edgecolor: Any = None, + linewidth: Optional[float] = None, + alpha: Optional[float] = None, + fill: Optional[bool] = None, + add: bool = True, + **legend_kwargs: Any, + ): + """ + Build geometry legend entries and optionally draw a legend. + """ + facecolor = _not_none(facecolor, rc["legend.geo.facecolor"]) + edgecolor = _not_none(edgecolor, rc["legend.geo.edgecolor"]) + linewidth = _not_none(linewidth, rc["legend.geo.linewidth"]) + alpha = _not_none(alpha, rc["legend.geo.alpha"]) + fill = _not_none(fill, rc["legend.geo.fill"]) + country_reso = _not_none(country_reso, rc["legend.geo.country_reso"]) + country_territories = _not_none( + country_territories, rc["legend.geo.country_territories"] + ) + country_proj = _not_none(country_proj, rc["legend.geo.country_proj"]) + handlesize = _not_none(handlesize, rc["legend.geo.handlesize"]) + handles, labels = _geo_legend_entries( + entries, + labels=labels, + country_reso=country_reso, + country_territories=country_territories, + country_proj=country_proj, + facecolor=facecolor, + edgecolor=edgecolor, + linewidth=linewidth, + alpha=alpha, + fill=fill, + ) + if not add: + return handles, labels + if handlesize is not None: + handlesize = float(handlesize) + if handlesize <= 0: + raise ValueError("geo_legend handlesize must be positive.") + if "handlelength" not in legend_kwargs: + legend_kwargs["handlelength"] = rc["legend.handlelength"] * handlesize + if "handleheight" not in legend_kwargs: + legend_kwargs["handleheight"] = rc["legend.handleheight"] * handlesize + return self.axes.legend(handles, labels, **legend_kwargs) + @staticmethod def _align_map() -> dict[Optional[str], dict[str, str]]: """ @@ -555,86 +1567,3 @@ def add( self._apply_handle_styles(objs, kw_text=kw_text, kw_handle=kw_handle) return self._finalize(objs, loc=inputs.loc, align=inputs.align) - - # Handle and text properties that are applied after-the-fact - # NOTE: Set solid_capstyle to 'butt' so line does not extend past error bounds - # shading in legend entry. This change is not noticable in other situations. - kw_frame, kwargs = lax._parse_frame("legend", **kwargs) - kw_text = {} - if fontcolor is not None: - kw_text["color"] = fontcolor - if fontweight is not None: - kw_text["weight"] = fontweight - kw_title = {} - if titlefontcolor is not None: - kw_title["color"] = titlefontcolor - if titlefontweight is not None: - kw_title["weight"] = titlefontweight - kw_handle = _pop_props(kwargs, "line") - kw_handle.setdefault("solid_capstyle", "butt") - kw_handle.update(handle_kw or {}) - - # Parse the legend arguments using axes for auto-handle detection - # TODO: Update this when we no longer use "filled panels" for outer legends - pairs, multi = lax._parse_legend_handles( - handles, - labels, - ncol=ncol, - order=order, - center=center, - alphabetize=alphabetize, - handler_map=handler_map, - ) - title = _not_none(label=label, title=title) - kwargs.update( - { - "title": title, - "frameon": frameon, - "fontsize": fontsize, - "handler_map": handler_map, - "title_fontsize": titlefontsize, - } - ) - - # Add the legend and update patch properties - # TODO: Add capacity for categorical labels in a single legend like seaborn - # rather than manual handle overrides with multiple legends. - if multi: - objs = lax._parse_legend_centered(pairs, kw_frame=kw_frame, **kwargs) - else: - kwargs.update({key: kw_frame.pop(key) for key in ("shadow", "fancybox")}) - objs = [lax._parse_legend_aligned(pairs, ncol=ncol, order=order, **kwargs)] - objs[0].legendPatch.update(kw_frame) - for obj in objs: - if hasattr(lax, "legend_") and lax.legend_ is None: - lax.legend_ = obj # make first legend accessible with get_legend() - else: - lax.add_artist(obj) - - # Update legend patch and elements - # WARNING: legendHandles only contains the *first* artist per legend because - # HandlerBase.legend_artist() called in Legend._init_legend_box() only - # returns the first artist. Instead we try to iterate through offset boxes. - for obj in objs: - obj.set_clip_on(False) # needed for tight bounding box calculations - box = getattr(obj, "_legend_handle_box", None) - for child in guides._iter_children(box): - if isinstance(child, mtext.Text): - kw = kw_text - else: - kw = { - key: val - for key, val in kw_handle.items() - if hasattr(child, "set_" + key) - } - if hasattr(child, "set_sizes") and "markersize" in kw_handle: - kw["sizes"] = np.atleast_1d(kw_handle["markersize"]) - child.update(kw) - - # Register location and return - if isinstance(objs[0], mpatches.FancyBboxPatch): - objs = objs[1:] - obj = objs[0] if len(objs) == 1 else tuple(objs) - ax._register_guide("legend", obj, (loc, align)) # possibly replace another - - return obj diff --git a/ultraplot/tests/test_legend.py b/ultraplot/tests/test_legend.py index 271c0bb35..ad1b6b1a5 100644 --- a/ultraplot/tests/test_legend.py +++ b/ultraplot/tests/test_legend.py @@ -1,6 +1,7 @@ import numpy as np import pandas as pd import pytest +from matplotlib import colors as mcolors from matplotlib import legend_handler as mhandler from matplotlib import patches as mpatches @@ -326,6 +327,322 @@ def test_legend_entry_with_axes_legend(): uplt.close(fig) +def test_semantic_helpers_not_public_on_module(): + for name in ("cat_legend", "size_legend", "num_legend", "geo_legend"): + assert not hasattr(uplt, name) + + +def test_geo_legend_helper_shapes(): + fig, ax = uplt.subplots() + handles, labels = ax.geo_legend( + [("Triangle", "triangle"), ("Hex", "hexagon")], add=False + ) + assert labels == ["Triangle", "Hex"] + assert len(handles) == 2 + assert all(isinstance(handle, mpatches.PathPatch) for handle in handles) + uplt.close(fig) + + +def test_semantic_legend_rc_defaults(): + fig, axs = uplt.subplots(ncols=4, share=False) + with uplt.rc.context( + { + "legend.cat.line": True, + "legend.cat.marker": "s", + "legend.cat.linewidth": 3.25, + "legend.size.marker": "^", + "legend.size.minsize": 8.0, + "legend.num.n": 3, + "legend.geo.facecolor": "red7", + "legend.geo.edgecolor": "black", + "legend.geo.fill": True, + } + ): + leg = axs[0].cat_legend(["A"], loc="best") + h = leg.legend_handles[0] + assert h.get_marker() == "s" + assert h.get_linewidth() == pytest.approx(3.25) + + leg = axs[1].size_legend([1.0], loc="best") + h = leg.legend_handles[0] + assert h.get_marker() == "^" + assert h.get_markersize() >= 8.0 + + leg = axs[2].num_legend(vmin=0, vmax=1, loc="best") + assert len(leg.legend_handles) == 3 + + leg = axs[3].geo_legend([("shape", "triangle")], loc="best") + h = leg.legend_handles[0] + assert isinstance(h, mpatches.PathPatch) + assert np.allclose(h.get_facecolor(), mcolors.to_rgba("red7")) + uplt.close(fig) + + +def test_semantic_legend_loc_shorthand(): + fig, ax = uplt.subplots() + leg = ax.cat_legend(["A", "B"], loc="r") + assert leg is not None + assert [text.get_text() for text in leg.get_texts()] == ["A", "B"] + uplt.close(fig) + + +def test_geo_legend_handlesize_scales_handle_box(): + fig, ax = uplt.subplots() + leg = ax.geo_legend([("shape", "triangle")], loc="best", handlesize=2.0) + assert leg.handlelength == pytest.approx(2.0 * uplt.rc["legend.handlelength"]) + assert leg.handleheight == pytest.approx(2.0 * uplt.rc["legend.handleheight"]) + + with uplt.rc.context({"legend.geo.handlesize": 1.5}): + leg = ax.geo_legend([("shape", "triangle")], loc="best") + assert leg.handlelength == pytest.approx(1.5 * uplt.rc["legend.handlelength"]) + assert leg.handleheight == pytest.approx(1.5 * uplt.rc["legend.handleheight"]) + uplt.close(fig) + + +def test_geo_legend_helper_with_axes_legend(monkeypatch): + sgeom = pytest.importorskip("shapely.geometry") + from ultraplot import legend as plegend + + monkeypatch.setattr( + plegend, + "_resolve_country_geometry", + lambda _, resolution="110m", include_far=False: sgeom.box(-1, -1, 1, 1), + ) + fig, ax = uplt.subplots() + leg = ax.geo_legend({"AUS": "country:AU", "NZL": "country:NZ"}, loc="best") + assert [text.get_text() for text in leg.get_texts()] == ["AUS", "NZL"] + uplt.close(fig) + + +def test_geo_legend_country_resolution_passthrough(monkeypatch): + sgeom = pytest.importorskip("shapely.geometry") + from ultraplot import legend as plegend + + calls = [] + + def _fake_country(code, resolution="110m", include_far=False): + calls.append((str(code).upper(), resolution, bool(include_far))) + return sgeom.box(-1, -1, 1, 1) + + monkeypatch.setattr(plegend, "_resolve_country_geometry", _fake_country) + + fig, ax = uplt.subplots() + ax.geo_legend([("NLD", "country:NLD")], country_reso="10m", add=False) + assert calls == [("NLD", "10m", False)] + + calls.clear() + with uplt.rc.context({"legend.geo.country_reso": "50m"}): + ax.geo_legend([("NLD", "country:NLD")], add=False) + assert calls == [("NLD", "50m", False)] + + calls.clear() + ax.geo_legend([("NLD", "country:NLD")], country_territories=True, add=False) + assert calls == [("NLD", "110m", True)] + + calls.clear() + with uplt.rc.context({"legend.geo.country_territories": True}): + ax.geo_legend([("NLD", "country:NLD")], add=False) + assert calls == [("NLD", "110m", True)] + uplt.close(fig) + + +def test_geo_legend_country_projection_passthrough(monkeypatch): + sgeom = pytest.importorskip("shapely.geometry") + from shapely import affinity + from ultraplot import legend as plegend + + monkeypatch.setattr( + plegend, + "_resolve_country_geometry", + lambda code, resolution="110m", include_far=False: sgeom.box(0, 0, 2, 1), + ) + fig, ax = uplt.subplots() + handles0, _ = ax.geo_legend([("NLD", "country:NLD")], add=False) + handles1, _ = ax.geo_legend( + [("NLD", "country:NLD")], + country_proj=lambda geom: affinity.scale( + geom, xfact=2.0, yfact=1.0, origin=(0, 0) + ), + add=False, + ) + w0 = np.ptp(handles0[0].get_path().vertices[:, 0]) + w1 = np.ptp(handles1[0].get_path().vertices[:, 0]) + assert w1 > w0 + + handles2, _ = ax.geo_legend( + [("NLD", "country:NLD")], + add=False, + country_proj="platecarree", + ) + assert isinstance(handles2[0], mpatches.PathPatch) + + # Per-entry overrides via 3-tuples + handles3, labels3 = ax.geo_legend( + [ + ("Base", "country:NLD"), + ( + "Wide", + "country:NLD", + { + "country_proj": lambda geom: affinity.scale( + geom, xfact=2.0, yfact=1.0, origin=(0, 0) + ) + }, + ), + ("StringProj", "country:NLD", "platecarree"), + ], + add=False, + ) + assert labels3 == ["Base", "Wide", "StringProj"] + w_base = np.ptp(handles3[0].get_path().vertices[:, 0]) + w_wide = np.ptp(handles3[1].get_path().vertices[:, 0]) + assert w_wide > w_base + uplt.close(fig) + + +def test_country_geometry_uses_dominant_component(): + sgeom = pytest.importorskip("shapely.geometry") + from ultraplot import legend as plegend + + big = sgeom.box(4.0, 51.0, 7.0, 54.0) + tiny_far = sgeom.box(-69.0, 12.0, -68.8, 12.2) + geometry = sgeom.MultiPolygon([big, tiny_far]) + dominant = plegend._country_geometry_for_legend(geometry) + assert dominant.equals(big) + + +def test_country_geometry_keeps_nearby_islands(): + sgeom = pytest.importorskip("shapely.geometry") + from ultraplot import legend as plegend + + mainland = sgeom.box(4.0, 51.0, 7.0, 54.0) + nearby_island = sgeom.box(5.0, 54.2, 5.2, 54.35) + far_island = sgeom.box(-69.0, 12.0, -68.8, 12.2) + geometry = sgeom.MultiPolygon([mainland, nearby_island, far_island]) + + reduced = plegend._country_geometry_for_legend(geometry) + geoms = list(getattr(reduced, "geoms", [reduced])) + assert any(part.equals(mainland) for part in geoms) + assert any(part.equals(nearby_island) for part in geoms) + assert not any(part.equals(far_island) for part in geoms) + + +def test_country_geometry_can_include_far_territories(): + sgeom = pytest.importorskip("shapely.geometry") + from ultraplot import legend as plegend + + mainland = sgeom.box(4.0, 51.0, 7.0, 54.0) + far_island = sgeom.box(-69.0, 12.0, -68.8, 12.2) + geometry = sgeom.MultiPolygon([mainland, far_island]) + kept = plegend._country_geometry_for_legend(geometry, include_far=True) + geoms = list(getattr(kept, "geoms", [kept])) + assert any(part.equals(mainland) for part in geoms) + assert any(part.equals(far_island) for part in geoms) + + +def test_geo_axes_add_geometries_auto_legend(): + ccrs = pytest.importorskip("cartopy.crs") + sgeom = pytest.importorskip("shapely.geometry") + + fig, ax = uplt.subplots(proj="cyl") + ax.add_geometries( + [sgeom.box(-20, -10, 20, 10)], + ccrs.PlateCarree(), + facecolor="blue7", + edgecolor="blue9", + label="Region", + ) + leg = ax.legend(loc="best") + labels = [text.get_text() for text in leg.get_texts()] + assert "Region" in labels + assert len(leg.legend_handles) == 1 + assert isinstance(leg.legend_handles[0], mpatches.PathPatch) + uplt.close(fig) + + +@pytest.mark.mpl_image_compare +def test_semantic_legends_showcase_smoke(monkeypatch): + """ + End-to-end smoke test showing semantic legend helpers in one figure: + categorical, size, numeric-color, and geometry (generic + country shorthands). + """ + sgeom = pytest.importorskip("shapely.geometry") + from ultraplot import legend as plegend + + # Prefer real Natural Earth country geometries if available. In offline CI, + # fall back to deterministic local geometries while still exercising shorthand. + country_entries = [("Australia", "country:AU"), ("New Zealand", "country:NZ")] + uses_real_countries = True + try: + fig_tmp, ax_tmp = uplt.subplots() + ax_tmp.geo_legend( + country_entries, edgecolor="black", facecolor="none", add=False + ) + uplt.close(fig_tmp) + except ValueError: + uses_real_countries = False + country_geoms = { + "AU": sgeom.box(110, -45, 155, -10), + "NZ": sgeom.box(166, -48, 179, -34), + } + + def _fake_country(code): + key = str(code).upper() + if key not in country_geoms: + raise ValueError(f"Unknown shorthand in test: {code!r}") + return country_geoms[key] + + monkeypatch.setattr(plegend, "_resolve_country_geometry", _fake_country) + + fig, axs = uplt.subplots(ncols=2, nrows=2, share=False) + + leg = axs[0].cat_legend( + ["A", "B", "C"], + colors={"A": "red7", "B": "green7", "C": "blue7"}, + markers={"A": "o", "B": "s", "C": "^"}, + loc="best", + title="cat_legend", + ) + assert [text.get_text() for text in leg.get_texts()] == ["A", "B", "C"] + + leg = axs[1].size_legend( + [10, 50, 200], color="gray6", loc="best", title="size_legend" + ) + assert [text.get_text() for text in leg.get_texts()] == ["10", "50", "200"] + + leg = axs[2].num_legend( + vmin=0.0, + vmax=1.0, + n=4, + cmap="viridis", + fmt="{:.2f}", + loc="best", + title="num_legend", + ) + assert len(leg.legend_handles) == 4 + assert all(isinstance(handle, mpatches.Patch) for handle in leg.legend_handles) + + handles, labels = axs[3].geo_legend( + [ + ("Triangle", "triangle"), + ("Hexagon", "hexagon"), + *country_entries, + ], + edgecolor="black", + facecolor="none", + add=False, + ) + leg = axs[3].legend(handles, labels, loc="best", title="geo_legend") + legend_labels = [text.get_text() for text in leg.get_texts()] + assert set(legend_labels) == set(labels) + assert len(legend_labels) == len(labels) + assert all(isinstance(handle, mpatches.PathPatch) for handle in leg.legend_handles) + if uses_real_countries: + # Real shorthand resolution succeeded (no monkeypatched fallback). + assert {"Australia", "New Zealand"}.issubset(set(legend_labels)) + return fig + + def test_pie_legend_uses_wedge_handles(): fig, ax = uplt.subplots() wedges, _ = ax.pie([30, 70], labels=["a", "b"]) From bcb7dd9058a0938a83e98e2bcc01b5e6f1a3887a Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Sun, 15 Feb 2026 07:08:51 +1000 Subject: [PATCH 09/17] Docs: add semantic legend guide and examples Add a dedicated semantic legends section to the colorbars/legends guide with working examples for ax.cat_legend, ax.size_legend, ax.num_legend, and ax.geo_legend. The geo example now demonstrates generic polygons, country shorthand, and per-entry tuple overrides for country projection/resolution options. Also clean up the narrative text and convert the snippet into executable notebook cells. --- docs/colorbars_legends.py | 97 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 97 insertions(+) diff --git a/docs/colorbars_legends.py b/docs/colorbars_legends.py index 51ed495b4..55f512219 100644 --- a/docs/colorbars_legends.py +++ b/docs/colorbars_legends.py @@ -469,6 +469,103 @@ ax = axs[1] ax.legend(hs2, loc="b", ncols=3, center=True, title="centered rows") axs.format(xlabel="xlabel", ylabel="ylabel", suptitle="Legend formatting demo") + +# %% [raw] raw_mimetype="text/restructuredtext" +# .. _ug_semantic_legends: +# Semantic legends +# ---------------- +# +# Legends usually annotate artists already drawn on an axes, but sometimes you need +# standalone semantic keys (categories, size scales, color levels, or geometry types). +# UltraPlot provides helper methods that build these entries directly: +# +# * :meth:`~ultraplot.axes.Axes.cat_legend` +# * :meth:`~ultraplot.axes.Axes.size_legend` +# * :meth:`~ultraplot.axes.Axes.num_legend` +# * :meth:`~ultraplot.axes.Axes.geo_legend` + +# %% +import cartopy.crs as ccrs +import shapely.geometry as sg + +fig, axs = uplt.subplots( + ncols=2, + nrows=2, + refwidth=2.25, + span=False, + share=False, + suptitle="Semantic legend helpers", +) +axs.format(grid=False) + +ax = axs[0] +ax.cat_legend( + ["A", "B", "C"], + colors={"A": "red7", "B": "green7", "C": "blue7"}, + markers={"A": "o", "B": "s", "C": "^"}, + loc="c", + frameon=False, +) +ax.format(title="cat_legend()") +ax.axis("off") + +ax = axs[1] +ax.size_legend( + [10, 50, 200], + loc="c", + title="Population", + frameon=False, +) +ax.format(title="size_legend()") +ax.axis("off") + +ax = axs[2] +ax.num_legend( + vmin=0, + vmax=1, + n=5, + cmap="viko", + fmt="{:.2f}", + loc="c", + frameon=False, +) +ax.format(title="num_legend()") +ax.axis("off") + +poly1 = sg.Polygon([(0, 0), (2, 0), (1.2, 1.4)]) +ax = axs[3] +ax.geo_legend( + [ + ("Triangle", "triangle"), + ("Triangle-ish", poly1), + ("Australia", "country:AU"), + ("Netherlands (Mercator)", "country:NLD", "mercator"), + ( + "Netherlands (Lambert)", + "country:NLD", + { + "country_proj": ccrs.LambertConformal( + central_longitude=5, + central_latitude=52, + ), + "country_reso": "10m", + "country_territories": False, + "facecolor": "steelblue", + "fill": True, + }, + ), + ], + loc="c", + ncols=1, + handlesize=1.6, + handletextpad=0.35, + frameon=False, + country_reso="10m", +) +ax.format(title="geo_legend()") +ax.axis("off") + + # %% [raw] raw_mimetype="text/restructuredtext" # .. _ug_guides_decouple: # From ffaaea1ee10fb03413a0d6ee0c3982731536641a Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Sun, 15 Feb 2026 07:25:33 +1000 Subject: [PATCH 10/17] Format legend files with black after rebase --- ultraplot/legend.py | 7 +++++++ ultraplot/tests/test_legend.py | 2 ++ 2 files changed, 9 insertions(+) diff --git a/ultraplot/legend.py b/ultraplot/legend.py index d586881bd..502147ca3 100644 --- a/ultraplot/legend.py +++ b/ultraplot/legend.py @@ -65,6 +65,8 @@ def _wedge_legend_patch( if theta2 == theta1: theta2 = theta1 + 300.0 return mpatches.Wedge(center, radius, theta1=theta1, theta2=theta2) + + class LegendEntry(mlines.Line2D): """ Convenience artist for custom legend entries. @@ -125,6 +127,8 @@ def marker(cls, label=None, marker="o", **kwargs): Build a marker-style legend entry. """ return cls(label=label, line=False, marker=marker, **kwargs) + + _GEOMETRY_SHAPE_PATHS = { "circle": mpath.Path.unit_circle(), "square": mpath.Path.unit_rectangle(), @@ -917,6 +921,8 @@ def _num_legend_entries( for value, label in zip(values, labels) ] return handles, labels + + ALIGN_OPTS = { None: { "center": "center", @@ -989,6 +995,7 @@ class _LegendInputs: cols: Optional[Union[int, Tuple[int, int]]] kwargs: dict[str, Any] + class Legend(mlegend.Legend): # Soft wrapper of matplotlib legend's class. # Currently we only override the syncing of the location. diff --git a/ultraplot/tests/test_legend.py b/ultraplot/tests/test_legend.py index ad1b6b1a5..65931b7cb 100644 --- a/ultraplot/tests/test_legend.py +++ b/ultraplot/tests/test_legend.py @@ -666,6 +666,8 @@ def test_pie_legend_handler_map_override(): assert len(handles) == 2 assert all(isinstance(handle, mpatches.Rectangle) for handle in handles) uplt.close(fig) + + def test_external_mode_toggle_enables_auto(): """ Toggling external mode back off should resume on-the-fly guide creation. From 4c898b5771b3d7ea40b3a07ae9dfc3cad91ae09a Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Sun, 15 Feb 2026 07:26:27 +1000 Subject: [PATCH 11/17] Singular graph example --- docs/colorbars_legends.py | 36 +++++++++--------------------------- 1 file changed, 9 insertions(+), 27 deletions(-) diff --git a/docs/colorbars_legends.py b/docs/colorbars_legends.py index 55f512219..a8b51b4f4 100644 --- a/docs/colorbars_legends.py +++ b/docs/colorbars_legends.py @@ -488,52 +488,35 @@ import cartopy.crs as ccrs import shapely.geometry as sg -fig, axs = uplt.subplots( - ncols=2, - nrows=2, - refwidth=2.25, - span=False, - share=False, - suptitle="Semantic legend helpers", -) -axs.format(grid=False) +fig, ax = uplt.subplots(refwidth=4.2) +ax.format(title="Semantic legend helpers", grid=False) -ax = axs[0] ax.cat_legend( ["A", "B", "C"], colors={"A": "red7", "B": "green7", "C": "blue7"}, markers={"A": "o", "B": "s", "C": "^"}, - loc="c", + loc="top", frameon=False, ) -ax.format(title="cat_legend()") -ax.axis("off") - -ax = axs[1] ax.size_legend( [10, 50, 200], - loc="c", + loc="upper right", title="Population", + ncols=1, frameon=False, ) -ax.format(title="size_legend()") -ax.axis("off") - -ax = axs[2] ax.num_legend( vmin=0, vmax=1, n=5, cmap="viko", fmt="{:.2f}", - loc="c", + loc="ll", + ncols=1, frameon=False, ) -ax.format(title="num_legend()") -ax.axis("off") poly1 = sg.Polygon([(0, 0), (2, 0), (1.2, 1.4)]) -ax = axs[3] ax.geo_legend( [ ("Triangle", "triangle"), @@ -555,14 +538,13 @@ }, ), ], - loc="c", + loc="r", ncols=1, - handlesize=1.6, + handlesize=2.4, handletextpad=0.35, frameon=False, country_reso="10m", ) -ax.format(title="geo_legend()") ax.axis("off") From 874492c434cddce42444e9e20ce2bd633c52441e Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Sun, 15 Feb 2026 07:28:55 +1000 Subject: [PATCH 12/17] Docs: add semantic legends gallery example --- .../legends_colorbars/03_semantic_legends.py | 93 +++++++++++++++++++ 1 file changed, 93 insertions(+) create mode 100644 docs/examples/legends_colorbars/03_semantic_legends.py diff --git a/docs/examples/legends_colorbars/03_semantic_legends.py b/docs/examples/legends_colorbars/03_semantic_legends.py new file mode 100644 index 000000000..20920271f --- /dev/null +++ b/docs/examples/legends_colorbars/03_semantic_legends.py @@ -0,0 +1,93 @@ +""" +Semantic legends +================ + +Build legends from semantic mappings rather than existing artists. + +Why UltraPlot here? +------------------- +UltraPlot adds semantic legend helpers directly on axes: +``cat_legend``, ``size_legend``, ``num_legend``, and ``geo_legend``. +These are useful when you want legend meaning decoupled from plotted handles. + +Key functions: :py:meth:`ultraplot.axes.Axes.cat_legend`, :py:meth:`ultraplot.axes.Axes.size_legend`, :py:meth:`ultraplot.axes.Axes.num_legend`, :py:meth:`ultraplot.axes.Axes.geo_legend`. + +See also +-------- +* :doc:`Colorbars and legends ` +""" + +from matplotlib.path import Path +import numpy as np + +import ultraplot as uplt + +rng = np.random.default_rng(2) + +fig, axs = uplt.subplots(nrows=2, ncols=2, refwidth=2.4, share=0) +axs.format(abc=True, abcloc="ul", grid=False, suptitle="Semantic legend helpers") + +# Categorical legend +ax = axs[0, 0] +x = np.linspace(0, 2 * np.pi, 120) +ax.plot(x, np.sin(x), color="gray6", lw=1.5) +ax.cat_legend( + ["A", "B", "C"], + colors={"A": "red7", "B": "green7", "C": "blue7"}, + markers={"A": "o", "B": "s", "C": "^"}, + loc="ul", + title="Category", + frame=False, +) +ax.format(title="cat_legend", xlocator="null", ylocator="null") + +# Size legend +ax = axs[0, 1] +vals = rng.normal(0, 1, 30) +ax.scatter(rng.uniform(0, 1, 30), vals, c="gray6", s=12, alpha=0.5) +ax.size_legend( + [10, 50, 200], + color="blue7", + fmt="{:.0f}", + loc="ur", + ncols=1, + title="Population", + frame=False, +) +ax.format(title="size_legend", xlocator="null", ylocator="null") + +# Numeric-color legend +ax = axs[1, 0] +z = rng.uniform(0, 1, 60) +ax.scatter(rng.uniform(0, 1, 60), rng.uniform(0, 1, 60), c=z, cmap="viko", s=16) +ax.num_legend( + vmin=0, + vmax=1, + n=5, + cmap="viko", + fmt="{:.2f}", + loc="ll", + ncols=1, + title="Score", + frame=False, +) +ax.format(title="num_legend", xlocator="null", ylocator="null") + +# Geometry legend +ax = axs[1, 1] +diamond = Path.unit_regular_polygon(4) +ax.geo_legend( + [ + ("Triangle", "triangle", {"facecolor": "#6baed6"}), + ("Diamond", diamond, {"facecolor": "#74c476"}), + ("Hexagon", "hexagon", {"facecolor": "#fd8d3c"}), + ], + loc="lr", + title="Geometry", + handlesize=1.8, + linewidth=1.0, + frame=False, +) +ax.format(title="geo_legend", xlocator="null", ylocator="null") + +fig.show() From 8cd5135dc3f72555f5b605a45df59aa3ac83ce73 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Sun, 15 Feb 2026 09:40:11 +1000 Subject: [PATCH 13/17] Update gallery examples --- .../legends_colorbars/03_semantic_legends.py | 87 +++++++++---------- 1 file changed, 43 insertions(+), 44 deletions(-) diff --git a/docs/examples/legends_colorbars/03_semantic_legends.py b/docs/examples/legends_colorbars/03_semantic_legends.py index 20920271f..232525780 100644 --- a/docs/examples/legends_colorbars/03_semantic_legends.py +++ b/docs/examples/legends_colorbars/03_semantic_legends.py @@ -17,49 +17,37 @@ * :doc:`Colorbars and legends ` """ -from matplotlib.path import Path +# %% +import cartopy.crs as ccrs import numpy as np +import shapely.geometry as sg +from matplotlib.path import Path import ultraplot as uplt -rng = np.random.default_rng(2) +np.random.seed(0) +data = np.random.randn(2, 100) +sizes = np.random.randint(10, 512, data.shape[1]) +colors = np.random.rand(data.shape[1]) -fig, axs = uplt.subplots(nrows=2, ncols=2, refwidth=2.4, share=0) -axs.format(abc=True, abcloc="ul", grid=False, suptitle="Semantic legend helpers") +fig, ax = uplt.subplots() +ax.scatter(*data, color=colors, s=sizes, cmap="viko") +ax.format(title="Semantic legend helpers") -# Categorical legend -ax = axs[0, 0] -x = np.linspace(0, 2 * np.pi, 120) -ax.plot(x, np.sin(x), color="gray6", lw=1.5) ax.cat_legend( ["A", "B", "C"], colors={"A": "red7", "B": "green7", "C": "blue7"}, markers={"A": "o", "B": "s", "C": "^"}, - loc="ul", - title="Category", - frame=False, + loc="top", + frameon=False, ) -ax.format(title="cat_legend", xlocator="null", ylocator="null") - -# Size legend -ax = axs[0, 1] -vals = rng.normal(0, 1, 30) -ax.scatter(rng.uniform(0, 1, 30), vals, c="gray6", s=12, alpha=0.5) ax.size_legend( [10, 50, 200], - color="blue7", - fmt="{:.0f}", - loc="ur", - ncols=1, + loc="upper right", title="Population", - frame=False, + ncols=1, + frameon=False, ) -ax.format(title="size_legend", xlocator="null", ylocator="null") - -# Numeric-color legend -ax = axs[1, 0] -z = rng.uniform(0, 1, 60) -ax.scatter(rng.uniform(0, 1, 60), rng.uniform(0, 1, 60), c=z, cmap="viko", s=16) ax.num_legend( vmin=0, vmax=1, @@ -68,26 +56,37 @@ fmt="{:.2f}", loc="ll", ncols=1, - title="Score", - frame=False, + frameon=False, ) -ax.format(title="num_legend", xlocator="null", ylocator="null") -# Geometry legend -ax = axs[1, 1] -diamond = Path.unit_regular_polygon(4) +poly1 = sg.Polygon([(0, 0), (2, 0), (1.2, 1.4)]) ax.geo_legend( [ - ("Triangle", "triangle", {"facecolor": "#6baed6"}), - ("Diamond", diamond, {"facecolor": "#74c476"}), - ("Hexagon", "hexagon", {"facecolor": "#fd8d3c"}), + ("Triangle", "triangle"), + ("Triangle-ish", poly1), + ("Australia", "country:AU"), + ("Netherlands (Mercator)", "country:NLD", "mercator"), + ( + "Netherlands (Lambert)", + "country:NLD", + { + "country_proj": ccrs.LambertConformal( + central_longitude=5, + central_latitude=52, + ), + "country_reso": "10m", + "country_territories": False, + "facecolor": "steelblue", + "fill": True, + }, + ), ], - loc="lr", - title="Geometry", - handlesize=1.8, - linewidth=1.0, - frame=False, + loc="r", + ncols=1, + handlesize=2.4, + handletextpad=0.35, + frameon=False, + country_reso="10m", ) -ax.format(title="geo_legend", xlocator="null", ylocator="null") - fig.show() +uplt.show(block=1) From 493e2a42ffd4c4c04086fddbf27334b825b0821c Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Sun, 15 Feb 2026 09:40:25 +1000 Subject: [PATCH 14/17] Remove blocking plot --- docs/examples/legends_colorbars/03_semantic_legends.py | 1 - 1 file changed, 1 deletion(-) diff --git a/docs/examples/legends_colorbars/03_semantic_legends.py b/docs/examples/legends_colorbars/03_semantic_legends.py index 232525780..e773c43a1 100644 --- a/docs/examples/legends_colorbars/03_semantic_legends.py +++ b/docs/examples/legends_colorbars/03_semantic_legends.py @@ -89,4 +89,3 @@ country_reso="10m", ) fig.show() -uplt.show(block=1) From 011d5deaadac79f0fe4bf3128f45023634fb0fec Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Sun, 15 Feb 2026 09:58:16 +1000 Subject: [PATCH 15/17] Use bevel joins for geometry legend patches --- ultraplot/legend.py | 49 +++++++++++++++++++++++++++++++--- ultraplot/tests/test_legend.py | 16 +++++++++++ 2 files changed, 61 insertions(+), 4 deletions(-) diff --git a/ultraplot/legend.py b/ultraplot/legend.py index 502147ca3..857687a1a 100644 --- a/ultraplot/legend.py +++ b/ultraplot/legend.py @@ -146,6 +146,7 @@ def marker(cls, label=None, marker="o", **kwargs): "pent": "pentagon", "hex": "hexagon", } +_DEFAULT_GEO_JOINSTYLE = "bevel" def _normalize_shape_name(value: str) -> str: @@ -471,6 +472,27 @@ def _first_scalar(value: Any, default: Any = None) -> Any: return value +def _patch_joinstyle(value: Any, default: str = _DEFAULT_GEO_JOINSTYLE) -> str: + """ + Resolve patch joinstyle from artist methods/kwargs with a sensible default. + """ + getter = getattr(value, "get_joinstyle", None) + if callable(getter): + try: + joinstyle = getter() + except Exception: + joinstyle = None + if joinstyle: + return joinstyle + kwargs = getattr(value, "_kwargs", None) + if isinstance(kwargs, dict): + for key in ("joinstyle", "solid_joinstyle", "linejoin"): + joinstyle = kwargs.get(key, None) + if joinstyle: + return joinstyle + return default + + def _feature_legend_patch( legend, orig_handle, @@ -493,7 +515,7 @@ def _feature_legend_patch( width=width, height=height, ) - return mpatches.PathPatch(path) + return mpatches.PathPatch(path, joinstyle=_DEFAULT_GEO_JOINSTYLE) def _shapely_geometry_patch( @@ -522,7 +544,7 @@ def _shapely_geometry_patch( width=width, height=height, ) - return mpatches.PathPatch(path) + return mpatches.PathPatch(path, joinstyle=_DEFAULT_GEO_JOINSTYLE) def _geometry_entry_patch( @@ -544,7 +566,7 @@ def _geometry_entry_patch( width=width, height=height, ) - return mpatches.PathPatch(path) + return mpatches.PathPatch(path, joinstyle=_DEFAULT_GEO_JOINSTYLE) class _FeatureArtistLegendHandler(mhandler.HandlerPatch): @@ -583,6 +605,7 @@ def update_prop(self, legend_handle, orig_handle, legend): legend_handle.set_facecolor(facecolor) legend_handle.set_edgecolor(edgecolor) legend_handle.set_linewidth(linewidth) + legend_handle.set_joinstyle(_patch_joinstyle(orig_handle)) if hasattr(orig_handle, "get_alpha"): legend_handle.set_alpha(orig_handle.get_alpha()) legend._set_artist_props(legend_handle) @@ -600,11 +623,27 @@ def __init__(self): def update_prop(self, legend_handle, orig_handle, legend): # No style information is stored on shapely geometry objects. + legend_handle.set_joinstyle(_DEFAULT_GEO_JOINSTYLE) legend._set_artist_props(legend_handle) legend_handle.set_clip_box(None) legend_handle.set_clip_path(None) +class _GeometryEntryLegendHandler(mhandler.HandlerPatch): + """ + Legend handler for `GeometryEntry` custom handles. + """ + + def __init__(self): + super().__init__(patch_func=_geometry_entry_patch) + + def update_prop(self, legend_handle, orig_handle, legend): + super().update_prop(legend_handle, orig_handle, legend) + legend_handle.set_joinstyle(_patch_joinstyle(orig_handle)) + legend_handle.set_clip_box(None) + legend_handle.set_clip_path(None) + + class GeometryEntry(mpatches.PathPatch): """ Convenience geometry legend entry. @@ -627,6 +666,7 @@ def __init__( facecolor: Any = "none", edgecolor: Any = "0.25", linewidth: float = 1.0, + joinstyle: str = _DEFAULT_GEO_JOINSTYLE, alpha: Optional[float] = None, fill: Optional[bool] = None, **kwargs: Any, @@ -645,6 +685,7 @@ def __init__( facecolor=facecolor, edgecolor=edgecolor, linewidth=linewidth, + joinstyle=joinstyle, alpha=alpha, fill=fill, **kwargs, @@ -1013,7 +1054,7 @@ def get_default_handler_map(cls): handler_map = dict(super().get_default_handler_map()) handler_map.setdefault( GeometryEntry, - mhandler.HandlerPatch(patch_func=_geometry_entry_patch), + _GeometryEntryLegendHandler(), ) handler_map.setdefault( mpatches.Wedge, diff --git a/ultraplot/tests/test_legend.py b/ultraplot/tests/test_legend.py index 65931b7cb..d704b1bbb 100644 --- a/ultraplot/tests/test_legend.py +++ b/ultraplot/tests/test_legend.py @@ -557,6 +557,22 @@ def test_geo_axes_add_geometries_auto_legend(): assert "Region" in labels assert len(leg.legend_handles) == 1 assert isinstance(leg.legend_handles[0], mpatches.PathPatch) + assert leg.legend_handles[0].get_joinstyle() == "bevel" + uplt.close(fig) + + +def test_geo_legend_defaults_to_bevel_joinstyle(): + fig, ax = uplt.subplots() + leg = ax.geo_legend([("shape", "triangle")], loc="best") + assert isinstance(leg.legend_handles[0], mpatches.PathPatch) + assert leg.legend_handles[0].get_joinstyle() == "bevel" + uplt.close(fig) + + +def test_geo_legend_joinstyle_override(): + fig, ax = uplt.subplots() + leg = ax.geo_legend([("shape", "triangle", {"joinstyle": "round"})], loc="best") + assert leg.legend_handles[0].get_joinstyle() == "round" uplt.close(fig) From 3cad6ddfa9a65b8f0e6ce8185562480267a7ec93 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Sun, 15 Feb 2026 11:02:12 +1000 Subject: [PATCH 16/17] Require title keyword for semantic legend titles --- ultraplot/legend.py | 20 ++++++++++++++++++++ ultraplot/tests/test_legend.py | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+) diff --git a/ultraplot/legend.py b/ultraplot/legend.py index 857687a1a..7c5f7a0a6 100644 --- a/ultraplot/legend.py +++ b/ultraplot/legend.py @@ -1111,6 +1111,22 @@ class UltraLegend: def __init__(self, axes): self.axes = axes + @staticmethod + def _validate_semantic_kwargs(method: str, kwargs: dict[str, Any]) -> None: + """ + Prevent ambiguous legend kwargs for semantic legend helpers. + """ + if "label" in kwargs: + raise TypeError( + f"{method}() does not accept the legend kwarg 'label'. " + "Use title=... for the legend title." + ) + if "labels" in kwargs: + raise TypeError( + f"{method}() does not accept the legend kwarg 'labels'. " + "Semantic legend labels are derived from the helper inputs." + ) + def cat_legend( self, categories: Iterable[Any], @@ -1152,6 +1168,7 @@ def cat_legend( ) if not add: return handles, labels + self._validate_semantic_kwargs("cat_legend", legend_kwargs) # Route through Axes.legend so location shorthands (e.g. 'r', 'b') # and queued guide keyword handling behave exactly like the public API. return self.axes.legend(handles, labels, **legend_kwargs) @@ -1198,6 +1215,7 @@ def size_legend( ) if not add: return handles, labels + self._validate_semantic_kwargs("size_legend", legend_kwargs) return self.axes.legend(handles, labels, **legend_kwargs) def num_legend( @@ -1239,6 +1257,7 @@ def num_legend( ) if not add: return handles, labels + self._validate_semantic_kwargs("num_legend", legend_kwargs) return self.axes.legend(handles, labels, **legend_kwargs) def geo_legend( @@ -1286,6 +1305,7 @@ def geo_legend( ) if not add: return handles, labels + self._validate_semantic_kwargs("geo_legend", legend_kwargs) if handlesize is not None: handlesize = float(handlesize) if handlesize <= 0: diff --git a/ultraplot/tests/test_legend.py b/ultraplot/tests/test_legend.py index d704b1bbb..d9bf700c4 100644 --- a/ultraplot/tests/test_legend.py +++ b/ultraplot/tests/test_legend.py @@ -386,6 +386,39 @@ def test_semantic_legend_loc_shorthand(): uplt.close(fig) +@pytest.mark.parametrize( + "builder, args, kwargs", + ( + ("cat_legend", (["A", "B"],), {}), + ("size_legend", ([10, 50],), {}), + ("num_legend", tuple(), {"vmin": 0, "vmax": 1}), + ("geo_legend", ([("shape", "triangle")],), {}), + ), +) +def test_semantic_legend_rejects_label_kwarg(builder, args, kwargs): + fig, ax = uplt.subplots() + method = getattr(ax, builder) + with pytest.raises(TypeError, match="Use title=\\.\\.\\. for the legend title"): + method(*args, label="Legend", **kwargs) + uplt.close(fig) + + +@pytest.mark.parametrize( + "builder, args, kwargs", + ( + ("cat_legend", (["A", "B"],), {}), + ("size_legend", ([10, 50],), {}), + ("num_legend", tuple(), {"vmin": 0, "vmax": 1}), + ), +) +def test_semantic_legend_rejects_labels_kwarg(builder, args, kwargs): + fig, ax = uplt.subplots() + method = getattr(ax, builder) + with pytest.raises(TypeError, match="does not accept the legend kwarg 'labels'"): + method(*args, labels=["x", "y"], **kwargs) + uplt.close(fig) + + def test_geo_legend_handlesize_scales_handle_box(): fig, ax = uplt.subplots() leg = ax.geo_legend([("shape", "triangle")], loc="best", handlesize=2.0) From 8ae2cdf162c196c6a522cbedbe54f582cbf3cfdb Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Mon, 16 Feb 2026 13:20:29 +1000 Subject: [PATCH 17/17] Rename semantic legend API methods to no-underscore names --- docs/colorbars_legends.py | 16 ++--- .../legends_colorbars/03_semantic_legends.py | 12 ++-- ultraplot/axes/base.py | 24 +++---- ultraplot/internals/rcsetup.py | 64 ++++++++--------- ultraplot/legend.py | 18 ++--- ultraplot/tests/test_legend.py | 72 +++++++++---------- 6 files changed, 103 insertions(+), 103 deletions(-) diff --git a/docs/colorbars_legends.py b/docs/colorbars_legends.py index a8b51b4f4..e3e72f59b 100644 --- a/docs/colorbars_legends.py +++ b/docs/colorbars_legends.py @@ -479,10 +479,10 @@ # standalone semantic keys (categories, size scales, color levels, or geometry types). # UltraPlot provides helper methods that build these entries directly: # -# * :meth:`~ultraplot.axes.Axes.cat_legend` -# * :meth:`~ultraplot.axes.Axes.size_legend` -# * :meth:`~ultraplot.axes.Axes.num_legend` -# * :meth:`~ultraplot.axes.Axes.geo_legend` +# * :meth:`~ultraplot.axes.Axes.catlegend` +# * :meth:`~ultraplot.axes.Axes.sizelegend` +# * :meth:`~ultraplot.axes.Axes.numlegend` +# * :meth:`~ultraplot.axes.Axes.geolegend` # %% import cartopy.crs as ccrs @@ -491,21 +491,21 @@ fig, ax = uplt.subplots(refwidth=4.2) ax.format(title="Semantic legend helpers", grid=False) -ax.cat_legend( +ax.catlegend( ["A", "B", "C"], colors={"A": "red7", "B": "green7", "C": "blue7"}, markers={"A": "o", "B": "s", "C": "^"}, loc="top", frameon=False, ) -ax.size_legend( +ax.sizelegend( [10, 50, 200], loc="upper right", title="Population", ncols=1, frameon=False, ) -ax.num_legend( +ax.numlegend( vmin=0, vmax=1, n=5, @@ -517,7 +517,7 @@ ) poly1 = sg.Polygon([(0, 0), (2, 0), (1.2, 1.4)]) -ax.geo_legend( +ax.geolegend( [ ("Triangle", "triangle"), ("Triangle-ish", poly1), diff --git a/docs/examples/legends_colorbars/03_semantic_legends.py b/docs/examples/legends_colorbars/03_semantic_legends.py index e773c43a1..c6bc7e9cc 100644 --- a/docs/examples/legends_colorbars/03_semantic_legends.py +++ b/docs/examples/legends_colorbars/03_semantic_legends.py @@ -7,10 +7,10 @@ Why UltraPlot here? ------------------- UltraPlot adds semantic legend helpers directly on axes: -``cat_legend``, ``size_legend``, ``num_legend``, and ``geo_legend``. +``catlegend``, ``sizelegend``, ``numlegend``, and ``geolegend``. These are useful when you want legend meaning decoupled from plotted handles. -Key functions: :py:meth:`ultraplot.axes.Axes.cat_legend`, :py:meth:`ultraplot.axes.Axes.size_legend`, :py:meth:`ultraplot.axes.Axes.num_legend`, :py:meth:`ultraplot.axes.Axes.geo_legend`. +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`. See also -------- @@ -34,21 +34,21 @@ ax.scatter(*data, color=colors, s=sizes, cmap="viko") ax.format(title="Semantic legend helpers") -ax.cat_legend( +ax.catlegend( ["A", "B", "C"], colors={"A": "red7", "B": "green7", "C": "blue7"}, markers={"A": "o", "B": "s", "C": "^"}, loc="top", frameon=False, ) -ax.size_legend( +ax.sizelegend( [10, 50, 200], loc="upper right", title="Population", ncols=1, frameon=False, ) -ax.num_legend( +ax.numlegend( vmin=0, vmax=1, n=5, @@ -60,7 +60,7 @@ ) poly1 = sg.Polygon([(0, 0), (2, 0), (1.2, 1.4)]) -ax.geo_legend( +ax.geolegend( [ ("Triangle", "triangle"), ("Triangle-ish", poly1), diff --git a/ultraplot/axes/base.py b/ultraplot/axes/base.py index b135a7f23..e685259f5 100644 --- a/ultraplot/axes/base.py +++ b/ultraplot/axes/base.py @@ -3502,7 +3502,7 @@ def legend( **kwargs, ) - def cat_legend(self, categories, **kwargs): + def catlegend(self, categories, **kwargs): """ Build categorical legend entries and optionally add a legend. @@ -3511,12 +3511,12 @@ def cat_legend(self, categories, **kwargs): categories Category labels used to generate legend handles. **kwargs - Forwarded to `ultraplot.legend.UltraLegend.cat_legend`. + Forwarded to `ultraplot.legend.UltraLegend.catlegend`. Pass ``add=False`` to return ``(handles, labels)`` without drawing. """ - return plegend.UltraLegend(self).cat_legend(categories, **kwargs) + return plegend.UltraLegend(self).catlegend(categories, **kwargs) - def size_legend(self, levels, **kwargs): + def sizelegend(self, levels, **kwargs): """ Build size legend entries and optionally add a legend. @@ -3525,12 +3525,12 @@ def size_legend(self, levels, **kwargs): levels Numeric levels used to generate marker-size entries. **kwargs - Forwarded to `ultraplot.legend.UltraLegend.size_legend`. + Forwarded to `ultraplot.legend.UltraLegend.sizelegend`. Pass ``add=False`` to return ``(handles, labels)`` without drawing. """ - return plegend.UltraLegend(self).size_legend(levels, **kwargs) + return plegend.UltraLegend(self).sizelegend(levels, **kwargs) - def num_legend(self, levels=None, **kwargs): + def numlegend(self, levels=None, **kwargs): """ Build numeric-color legend entries and optionally add a legend. @@ -3539,12 +3539,12 @@ def num_legend(self, levels=None, **kwargs): levels Numeric levels or number of levels. **kwargs - Forwarded to `ultraplot.legend.UltraLegend.num_legend`. + Forwarded to `ultraplot.legend.UltraLegend.numlegend`. Pass ``add=False`` to return ``(handles, labels)`` without drawing. """ - return plegend.UltraLegend(self).num_legend(levels=levels, **kwargs) + return plegend.UltraLegend(self).numlegend(levels=levels, **kwargs) - def geo_legend(self, entries, labels=None, **kwargs): + def geolegend(self, entries, labels=None, **kwargs): """ Build geometry legend entries and optionally add a legend. @@ -3555,10 +3555,10 @@ def geo_legend(self, entries, labels=None, **kwargs): labels Optional labels for geometry sequences. **kwargs - Forwarded to `ultraplot.legend.UltraLegend.geo_legend`. + Forwarded to `ultraplot.legend.UltraLegend.geolegend`. Pass ``add=False`` to return ``(handles, labels)`` without drawing. """ - return plegend.UltraLegend(self).geo_legend(entries, labels=labels, **kwargs) + return plegend.UltraLegend(self).geolegend(entries, labels=labels, **kwargs) @classmethod def _coerce_curve_xy(cls, x, y): diff --git a/ultraplot/internals/rcsetup.py b/ultraplot/internals/rcsetup.py index 4c4dd95a0..9f580154b 100644 --- a/ultraplot/internals/rcsetup.py +++ b/ultraplot/internals/rcsetup.py @@ -1401,166 +1401,166 @@ def _validator_accepts(validator, value): "legend.cat.line": ( False, _validate_bool, - "Default line/marker mode for `Axes.cat_legend`.", + "Default line/marker mode for `Axes.catlegend`.", ), "legend.cat.marker": ( "o", _validate_string, - "Default marker for `Axes.cat_legend` entries.", + "Default marker for `Axes.catlegend` entries.", ), "legend.cat.linestyle": ( "-", _validate_linestyle, - "Default line style for `Axes.cat_legend` entries.", + "Default line style for `Axes.catlegend` entries.", ), "legend.cat.linewidth": ( 2.0, _validate_float, - "Default line width for `Axes.cat_legend` entries.", + "Default line width for `Axes.catlegend` entries.", ), "legend.cat.markersize": ( 6.0, _validate_float, - "Default marker size for `Axes.cat_legend` entries.", + "Default marker size for `Axes.catlegend` entries.", ), "legend.cat.alpha": ( None, _validate_or_none(_validate_float), - "Default alpha for `Axes.cat_legend` entries.", + "Default alpha for `Axes.catlegend` entries.", ), "legend.cat.markeredgecolor": ( None, _validate_or_none(_validate_color), - "Default marker edge color for `Axes.cat_legend` entries.", + "Default marker edge color for `Axes.catlegend` entries.", ), "legend.cat.markeredgewidth": ( None, _validate_or_none(_validate_float), - "Default marker edge width for `Axes.cat_legend` entries.", + "Default marker edge width for `Axes.catlegend` entries.", ), "legend.size.color": ( "0.35", _validate_color, - "Default marker color for `Axes.size_legend` entries.", + "Default marker color for `Axes.sizelegend` entries.", ), "legend.size.marker": ( "o", _validate_string, - "Default marker for `Axes.size_legend` entries.", + "Default marker for `Axes.sizelegend` entries.", ), "legend.size.area": ( True, _validate_bool, - "Whether `Axes.size_legend` interprets levels as marker area by default.", + "Whether `Axes.sizelegend` interprets levels as marker area by default.", ), "legend.size.scale": ( 1.0, _validate_float, - "Default marker size scale factor for `Axes.size_legend` entries.", + "Default marker size scale factor for `Axes.sizelegend` entries.", ), "legend.size.minsize": ( 3.0, _validate_float, - "Default minimum marker size for `Axes.size_legend` entries.", + "Default minimum marker size for `Axes.sizelegend` entries.", ), "legend.size.format": ( None, _validate_or_none(_validate_string), - "Default label format string for `Axes.size_legend` entries.", + "Default label format string for `Axes.sizelegend` entries.", ), "legend.size.alpha": ( None, _validate_or_none(_validate_float), - "Default alpha for `Axes.size_legend` entries.", + "Default alpha for `Axes.sizelegend` entries.", ), "legend.size.markeredgecolor": ( None, _validate_or_none(_validate_color), - "Default marker edge color for `Axes.size_legend` entries.", + "Default marker edge color for `Axes.sizelegend` entries.", ), "legend.size.markeredgewidth": ( None, _validate_or_none(_validate_float), - "Default marker edge width for `Axes.size_legend` entries.", + "Default marker edge width for `Axes.sizelegend` entries.", ), "legend.num.n": ( 5, _validate_int, - "Default number of sampled levels for `Axes.num_legend`.", + "Default number of sampled levels for `Axes.numlegend`.", ), "legend.num.cmap": ( "viridis", _validate_cmap("continuous"), - "Default colormap for `Axes.num_legend` entries.", + "Default colormap for `Axes.numlegend` entries.", ), "legend.num.edgecolor": ( "none", _validate_or_none(_validate_color), - "Default edge color for `Axes.num_legend` patch entries.", + "Default edge color for `Axes.numlegend` patch entries.", ), "legend.num.linewidth": ( 0.0, _validate_float, - "Default edge width for `Axes.num_legend` patch entries.", + "Default edge width for `Axes.numlegend` patch entries.", ), "legend.num.alpha": ( None, _validate_or_none(_validate_float), - "Default alpha for `Axes.num_legend` entries.", + "Default alpha for `Axes.numlegend` entries.", ), "legend.num.format": ( None, _validate_or_none(_validate_string), - "Default label format string for `Axes.num_legend` entries.", + "Default label format string for `Axes.numlegend` entries.", ), "legend.geo.facecolor": ( "none", _validate_or_none(_validate_color), - "Default face color for `Axes.geo_legend` entries.", + "Default face color for `Axes.geolegend` entries.", ), "legend.geo.edgecolor": ( "0.25", _validate_or_none(_validate_color), - "Default edge color for `Axes.geo_legend` entries.", + "Default edge color for `Axes.geolegend` entries.", ), "legend.geo.linewidth": ( 1.0, _validate_float, - "Default edge width for `Axes.geo_legend` entries.", + "Default edge width for `Axes.geolegend` entries.", ), "legend.geo.alpha": ( None, _validate_or_none(_validate_float), - "Default alpha for `Axes.geo_legend` entries.", + "Default alpha for `Axes.geolegend` entries.", ), "legend.geo.fill": ( None, _validate_or_none(_validate_bool), - "Default fill mode for `Axes.geo_legend` entries.", + "Default fill mode for `Axes.geolegend` entries.", ), "legend.geo.country_reso": ( "110m", _validate_belongs("10m", "50m", "110m"), "Default Natural Earth resolution used for country shorthand geometry " - "entries in `Axes.geo_legend`.", + "entries in `Axes.geolegend`.", ), "legend.geo.country_territories": ( False, _validate_bool, - "Whether country shorthand entries in `Axes.geo_legend` include far-away " + "Whether country shorthand entries in `Axes.geolegend` include far-away " "territories instead of pruning to the local footprint.", ), "legend.geo.country_proj": ( None, _validate_or_none(_validate_string), - "Optional projection name for country shorthand entries in `Axes.geo_legend`. " + "Optional projection name for country shorthand entries in `Axes.geolegend`. " "Can be overridden per call with a cartopy CRS or callable.", ), "legend.geo.handlesize": ( 1.0, _validate_float, "Scale factor applied to both legend handle length and height for " - "`Axes.geo_legend` when explicit handle dimensions are not provided.", + "`Axes.geolegend` when explicit handle dimensions are not provided.", ), # Color cycle additions "cycle": ( diff --git a/ultraplot/legend.py b/ultraplot/legend.py index 51df1aeb7..baee6e13c 100644 --- a/ultraplot/legend.py +++ b/ultraplot/legend.py @@ -1131,7 +1131,7 @@ def _validate_semantic_kwargs(method: str, kwargs: dict[str, Any]) -> None: "Semantic legend labels are derived from the helper inputs." ) - def cat_legend( + def catlegend( self, categories: Iterable[Any], *, @@ -1172,12 +1172,12 @@ def cat_legend( ) if not add: return handles, labels - self._validate_semantic_kwargs("cat_legend", legend_kwargs) + self._validate_semantic_kwargs("catlegend", legend_kwargs) # Route through Axes.legend so location shorthands (e.g. 'r', 'b') # and queued guide keyword handling behave exactly like the public API. return self.axes.legend(handles, labels, **legend_kwargs) - def size_legend( + def sizelegend( self, levels: Iterable[float], *, @@ -1219,10 +1219,10 @@ def size_legend( ) if not add: return handles, labels - self._validate_semantic_kwargs("size_legend", legend_kwargs) + self._validate_semantic_kwargs("sizelegend", legend_kwargs) return self.axes.legend(handles, labels, **legend_kwargs) - def num_legend( + def numlegend( self, levels=None, *, @@ -1261,10 +1261,10 @@ def num_legend( ) if not add: return handles, labels - self._validate_semantic_kwargs("num_legend", legend_kwargs) + self._validate_semantic_kwargs("numlegend", legend_kwargs) return self.axes.legend(handles, labels, **legend_kwargs) - def geo_legend( + def geolegend( self, entries: Iterable[Any] | dict[Any, Any], labels: Optional[Iterable[Any]] = None, @@ -1309,11 +1309,11 @@ def geo_legend( ) if not add: return handles, labels - self._validate_semantic_kwargs("geo_legend", legend_kwargs) + self._validate_semantic_kwargs("geolegend", legend_kwargs) if handlesize is not None: handlesize = float(handlesize) if handlesize <= 0: - raise ValueError("geo_legend handlesize must be positive.") + raise ValueError("geolegend handlesize must be positive.") if "handlelength" not in legend_kwargs: legend_kwargs["handlelength"] = rc["legend.handlelength"] * handlesize if "handleheight" not in legend_kwargs: diff --git a/ultraplot/tests/test_legend.py b/ultraplot/tests/test_legend.py index d9bf700c4..217417860 100644 --- a/ultraplot/tests/test_legend.py +++ b/ultraplot/tests/test_legend.py @@ -328,13 +328,13 @@ def test_legend_entry_with_axes_legend(): def test_semantic_helpers_not_public_on_module(): - for name in ("cat_legend", "size_legend", "num_legend", "geo_legend"): + for name in ("catlegend", "sizelegend", "numlegend", "geolegend"): assert not hasattr(uplt, name) def test_geo_legend_helper_shapes(): fig, ax = uplt.subplots() - handles, labels = ax.geo_legend( + handles, labels = ax.geolegend( [("Triangle", "triangle"), ("Hex", "hexagon")], add=False ) assert labels == ["Triangle", "Hex"] @@ -358,20 +358,20 @@ def test_semantic_legend_rc_defaults(): "legend.geo.fill": True, } ): - leg = axs[0].cat_legend(["A"], loc="best") + leg = axs[0].catlegend(["A"], loc="best") h = leg.legend_handles[0] assert h.get_marker() == "s" assert h.get_linewidth() == pytest.approx(3.25) - leg = axs[1].size_legend([1.0], loc="best") + leg = axs[1].sizelegend([1.0], loc="best") h = leg.legend_handles[0] assert h.get_marker() == "^" assert h.get_markersize() >= 8.0 - leg = axs[2].num_legend(vmin=0, vmax=1, loc="best") + leg = axs[2].numlegend(vmin=0, vmax=1, loc="best") assert len(leg.legend_handles) == 3 - leg = axs[3].geo_legend([("shape", "triangle")], loc="best") + leg = axs[3].geolegend([("shape", "triangle")], loc="best") h = leg.legend_handles[0] assert isinstance(h, mpatches.PathPatch) assert np.allclose(h.get_facecolor(), mcolors.to_rgba("red7")) @@ -380,7 +380,7 @@ def test_semantic_legend_rc_defaults(): def test_semantic_legend_loc_shorthand(): fig, ax = uplt.subplots() - leg = ax.cat_legend(["A", "B"], loc="r") + leg = ax.catlegend(["A", "B"], loc="r") assert leg is not None assert [text.get_text() for text in leg.get_texts()] == ["A", "B"] uplt.close(fig) @@ -389,10 +389,10 @@ def test_semantic_legend_loc_shorthand(): @pytest.mark.parametrize( "builder, args, kwargs", ( - ("cat_legend", (["A", "B"],), {}), - ("size_legend", ([10, 50],), {}), - ("num_legend", tuple(), {"vmin": 0, "vmax": 1}), - ("geo_legend", ([("shape", "triangle")],), {}), + ("catlegend", (["A", "B"],), {}), + ("sizelegend", ([10, 50],), {}), + ("numlegend", tuple(), {"vmin": 0, "vmax": 1}), + ("geolegend", ([("shape", "triangle")],), {}), ), ) def test_semantic_legend_rejects_label_kwarg(builder, args, kwargs): @@ -406,9 +406,9 @@ def test_semantic_legend_rejects_label_kwarg(builder, args, kwargs): @pytest.mark.parametrize( "builder, args, kwargs", ( - ("cat_legend", (["A", "B"],), {}), - ("size_legend", ([10, 50],), {}), - ("num_legend", tuple(), {"vmin": 0, "vmax": 1}), + ("catlegend", (["A", "B"],), {}), + ("sizelegend", ([10, 50],), {}), + ("numlegend", tuple(), {"vmin": 0, "vmax": 1}), ), ) def test_semantic_legend_rejects_labels_kwarg(builder, args, kwargs): @@ -421,12 +421,12 @@ def test_semantic_legend_rejects_labels_kwarg(builder, args, kwargs): def test_geo_legend_handlesize_scales_handle_box(): fig, ax = uplt.subplots() - leg = ax.geo_legend([("shape", "triangle")], loc="best", handlesize=2.0) + leg = ax.geolegend([("shape", "triangle")], loc="best", handlesize=2.0) assert leg.handlelength == pytest.approx(2.0 * uplt.rc["legend.handlelength"]) assert leg.handleheight == pytest.approx(2.0 * uplt.rc["legend.handleheight"]) with uplt.rc.context({"legend.geo.handlesize": 1.5}): - leg = ax.geo_legend([("shape", "triangle")], loc="best") + leg = ax.geolegend([("shape", "triangle")], loc="best") assert leg.handlelength == pytest.approx(1.5 * uplt.rc["legend.handlelength"]) assert leg.handleheight == pytest.approx(1.5 * uplt.rc["legend.handleheight"]) uplt.close(fig) @@ -442,7 +442,7 @@ def test_geo_legend_helper_with_axes_legend(monkeypatch): lambda _, resolution="110m", include_far=False: sgeom.box(-1, -1, 1, 1), ) fig, ax = uplt.subplots() - leg = ax.geo_legend({"AUS": "country:AU", "NZL": "country:NZ"}, loc="best") + leg = ax.geolegend({"AUS": "country:AU", "NZL": "country:NZ"}, loc="best") assert [text.get_text() for text in leg.get_texts()] == ["AUS", "NZL"] uplt.close(fig) @@ -460,21 +460,21 @@ def _fake_country(code, resolution="110m", include_far=False): monkeypatch.setattr(plegend, "_resolve_country_geometry", _fake_country) fig, ax = uplt.subplots() - ax.geo_legend([("NLD", "country:NLD")], country_reso="10m", add=False) + ax.geolegend([("NLD", "country:NLD")], country_reso="10m", add=False) assert calls == [("NLD", "10m", False)] calls.clear() with uplt.rc.context({"legend.geo.country_reso": "50m"}): - ax.geo_legend([("NLD", "country:NLD")], add=False) + ax.geolegend([("NLD", "country:NLD")], add=False) assert calls == [("NLD", "50m", False)] calls.clear() - ax.geo_legend([("NLD", "country:NLD")], country_territories=True, add=False) + ax.geolegend([("NLD", "country:NLD")], country_territories=True, add=False) assert calls == [("NLD", "110m", True)] calls.clear() with uplt.rc.context({"legend.geo.country_territories": True}): - ax.geo_legend([("NLD", "country:NLD")], add=False) + ax.geolegend([("NLD", "country:NLD")], add=False) assert calls == [("NLD", "110m", True)] uplt.close(fig) @@ -490,8 +490,8 @@ def test_geo_legend_country_projection_passthrough(monkeypatch): lambda code, resolution="110m", include_far=False: sgeom.box(0, 0, 2, 1), ) fig, ax = uplt.subplots() - handles0, _ = ax.geo_legend([("NLD", "country:NLD")], add=False) - handles1, _ = ax.geo_legend( + handles0, _ = ax.geolegend([("NLD", "country:NLD")], add=False) + handles1, _ = ax.geolegend( [("NLD", "country:NLD")], country_proj=lambda geom: affinity.scale( geom, xfact=2.0, yfact=1.0, origin=(0, 0) @@ -502,7 +502,7 @@ def test_geo_legend_country_projection_passthrough(monkeypatch): w1 = np.ptp(handles1[0].get_path().vertices[:, 0]) assert w1 > w0 - handles2, _ = ax.geo_legend( + handles2, _ = ax.geolegend( [("NLD", "country:NLD")], add=False, country_proj="platecarree", @@ -510,7 +510,7 @@ def test_geo_legend_country_projection_passthrough(monkeypatch): assert isinstance(handles2[0], mpatches.PathPatch) # Per-entry overrides via 3-tuples - handles3, labels3 = ax.geo_legend( + handles3, labels3 = ax.geolegend( [ ("Base", "country:NLD"), ( @@ -596,7 +596,7 @@ def test_geo_axes_add_geometries_auto_legend(): def test_geo_legend_defaults_to_bevel_joinstyle(): fig, ax = uplt.subplots() - leg = ax.geo_legend([("shape", "triangle")], loc="best") + leg = ax.geolegend([("shape", "triangle")], loc="best") assert isinstance(leg.legend_handles[0], mpatches.PathPatch) assert leg.legend_handles[0].get_joinstyle() == "bevel" uplt.close(fig) @@ -604,7 +604,7 @@ def test_geo_legend_defaults_to_bevel_joinstyle(): def test_geo_legend_joinstyle_override(): fig, ax = uplt.subplots() - leg = ax.geo_legend([("shape", "triangle", {"joinstyle": "round"})], loc="best") + leg = ax.geolegend([("shape", "triangle", {"joinstyle": "round"})], loc="best") assert leg.legend_handles[0].get_joinstyle() == "round" uplt.close(fig) @@ -624,7 +624,7 @@ def test_semantic_legends_showcase_smoke(monkeypatch): uses_real_countries = True try: fig_tmp, ax_tmp = uplt.subplots() - ax_tmp.geo_legend( + ax_tmp.geolegend( country_entries, edgecolor="black", facecolor="none", add=False ) uplt.close(fig_tmp) @@ -645,33 +645,33 @@ def _fake_country(code): fig, axs = uplt.subplots(ncols=2, nrows=2, share=False) - leg = axs[0].cat_legend( + leg = axs[0].catlegend( ["A", "B", "C"], colors={"A": "red7", "B": "green7", "C": "blue7"}, markers={"A": "o", "B": "s", "C": "^"}, loc="best", - title="cat_legend", + title="catlegend", ) assert [text.get_text() for text in leg.get_texts()] == ["A", "B", "C"] - leg = axs[1].size_legend( - [10, 50, 200], color="gray6", loc="best", title="size_legend" + leg = axs[1].sizelegend( + [10, 50, 200], color="gray6", loc="best", title="sizelegend" ) assert [text.get_text() for text in leg.get_texts()] == ["10", "50", "200"] - leg = axs[2].num_legend( + leg = axs[2].numlegend( vmin=0.0, vmax=1.0, n=4, cmap="viridis", fmt="{:.2f}", loc="best", - title="num_legend", + title="numlegend", ) assert len(leg.legend_handles) == 4 assert all(isinstance(handle, mpatches.Patch) for handle in leg.legend_handles) - handles, labels = axs[3].geo_legend( + handles, labels = axs[3].geolegend( [ ("Triangle", "triangle"), ("Hexagon", "hexagon"), @@ -681,7 +681,7 @@ def _fake_country(code): facecolor="none", add=False, ) - leg = axs[3].legend(handles, labels, loc="best", title="geo_legend") + leg = axs[3].legend(handles, labels, loc="best", title="geolegend") legend_labels = [text.get_text() for text in leg.get_texts()] assert set(legend_labels) == set(labels) assert len(legend_labels) == len(labels)