diff --git a/ultraplot/axes/base.py b/ultraplot/axes/base.py index f72dea8a8..96bff909b 100644 --- a/ultraplot/axes/base.py +++ b/ultraplot/axes/base.py @@ -3599,78 +3599,207 @@ def legend( **kwargs, ) + @docstring._snippet_manager def catlegend(self, categories, **kwargs): """ - Build categorical legend entries and optionally add a legend. + Build a categorical legend — one handle per unique category — and + optionally draw it. Parameters ---------- - categories - Category labels used to generate legend handles. - **kwargs - Forwarded to `ultraplot.legend.UltraLegend.catlegend`. - Pass ``add=False`` to return ``(handles, labels)`` without drawing. + categories : iterable + Category labels in display order. Duplicates are collapsed; the + first occurrence determines position. + color, marker + %(legend.semantic_style_arg)s + Defaults to ultraplot's color cycle for ``color`` and ``"o"`` for + ``marker`` (or :rc:`legend.cat.marker` when set). + line : bool, optional + Whether to render connector lines through the markers. Falls back + to :rc:`legend.cat.line`. Setting a non-default ``linestyle`` + implicitly enables this. + + Other parameters + ---------------- + %(legend.semantic_style_kwargs)s + %(legend.semantic_handle_kw)s + + See also + -------- + Axes.entrylegend + Axes.sizelegend """ return plegend.UltraLegend(self).catlegend(categories, **kwargs) + @docstring._snippet_manager def entrylegend(self, entries, **kwargs): """ - Build generic semantic legend entries and optionally add a legend. + Build generic semantic legend entries from explicit ``{label: style}`` + entries and optionally draw the legend. Parameters ---------- - entries - Entry specifications as handles, style dictionaries, or ``(label, spec)`` - pairs. - **kwargs - Forwarded to `ultraplot.legend.UltraLegend.entrylegend`. - Pass ``add=False`` to return ``(handles, labels)`` without drawing. + entries : iterable or mapping + Entry specifications. Either a sequence of ``{**style_kwargs}`` + dicts (each requiring at least ``label``) or a mapping from label + to style-kwargs dict. + line : bool, optional + Whether each entry shows a connector line. Falls back to + :rc:`legend.cat.line`. + marker, color + %(legend.semantic_style_arg)s + + Other parameters + ---------------- + %(legend.semantic_style_kwargs)s + %(legend.semantic_handle_kw)s + + See also + -------- + Axes.catlegend + Axes.sizelegend """ return plegend.UltraLegend(self).entrylegend(entries, **kwargs) + @docstring._snippet_manager def sizelegend(self, levels, **kwargs): """ - Build size legend entries and optionally add a legend. + Build a size legend — one handle per level, scaled by marker size — + and optionally draw it. Parameters ---------- - levels - Numeric levels used to generate marker-size entries. - **kwargs - Forwarded to `ultraplot.legend.UltraLegend.sizelegend`. - Pass ``labels=[...]`` or ``labels={level: label}`` to override the - generated labels. - Pass ``add=False`` to return ``(handles, labels)`` without drawing. + levels : iterable of float + Numeric values to render as size-scaled markers. + labels : iterable or mapping, optional + Custom labels. A mapping ``{level: label}`` overrides individual + entries (every level must be a key). When omitted, labels are + formatted from ``levels`` via ``fmt``. + color, marker + %(legend.semantic_style_arg)s + Defaults to :rc:`legend.size.color` and :rc:`legend.size.marker`. + area : bool, optional + Treat ``levels`` as marker areas (``True``, default) or + diameters (``False``). Areas are converted with + ``ms = sqrt(level) * scale``. Falls back to :rc:`legend.size.area`. + scale : float, optional + Multiplier applied after area/diameter conversion. + Falls back to :rc:`legend.size.scale`. + minsize : float, optional + Lower bound on rendered marker size. + Falls back to :rc:`legend.size.minsize`. + fmt : str or callable, optional + Format used to label levels. Falls back to :rc:`legend.size.format`. + + Other parameters + ---------------- + %(legend.semantic_style_kwargs)s + %(legend.semantic_handle_kw)s + + See also + -------- + Axes.catlegend + Axes.numlegend """ return plegend.UltraLegend(self).sizelegend(levels, **kwargs) + @docstring._snippet_manager def numlegend(self, levels=None, **kwargs): """ - Build numeric-color legend entries and optionally add a legend. + Build a numeric legend — one patch handle per level, colored from a + colormap — and optionally draw it. Parameters ---------- - levels - Numeric levels or number of levels. - **kwargs - Forwarded to `ultraplot.legend.UltraLegend.numlegend`. - Pass ``add=False`` to return ``(handles, labels)`` without drawing. + levels : iterable of float, optional + Numeric levels to render. When omitted, ``n`` evenly spaced + levels are derived from ``vmin`` / ``vmax``. + vmin, vmax : float, optional + Limits for sampling ``cmap`` when ``norm`` is not provided. + n : int, optional + Number of levels to sample when ``levels`` is omitted. + Falls back to :rc:`legend.num.n`. + cmap : str or `~matplotlib.colors.Colormap`, optional + Colormap used to color the patches. + Falls back to :rc:`legend.num.cmap`. + norm : `~matplotlib.colors.Normalize`, optional + Normalization applied to ``levels`` before colormap lookup. + fmt : str or callable, optional + Format used to label levels. + Falls back to :rc:`legend.num.format`. + facecolor, edgecolor + %(legend.semantic_style_arg)s + ``facecolor`` defaults to colormap-derived values; ``edgecolor`` + falls back to :rc:`legend.num.edgecolor`. + linewidth, linestyle, alpha + Patch outline width, style, and transparency. ``linewidth`` / + ``alpha`` fall back to :rc:`legend.num.linewidth` / + :rc:`legend.num.alpha`. + + Other parameters + ---------------- + %(legend.semantic_num_style_kwargs)s + %(legend.semantic_handle_kw)s + + See also + -------- + Axes.sizelegend + Axes.geolegend """ return plegend.UltraLegend(self).numlegend(levels=levels, **kwargs) + @docstring._snippet_manager def geolegend(self, entries, labels=None, **kwargs): """ - Build geometry legend entries and optionally add a legend. + Build a geometry legend — one patch handle per geometry entry — and + optionally draw it. Parameters ---------- - entries - Geometry entries (mapping, ``(label, geometry)`` pairs, or geometries). - labels - Optional labels for geometry sequences. - **kwargs - Forwarded to `ultraplot.legend.UltraLegend.geolegend`. - Pass ``add=False`` to return ``(handles, labels)`` without drawing. + entries : iterable or mapping + Either a sequence of ``(label, geometry)`` pairs or a mapping + from label to geometry specification (string keyword, shapely + geometry, ``cartopy`` feature, or a country name when + ``country_reso`` is set). + labels : iterable, optional + Labels overriding those derived from ``entries``. + country_reso : str, optional + Natural Earth resolution for country geometries (e.g. ``"110m"``). + Falls back to :rc:`legend.geo.country_reso`. + country_territories : bool, optional + Whether country lookups include overseas territories. + Falls back to :rc:`legend.geo.country_territories`. + country_proj : any, optional + Projection used to render country geometries; ignored for non- + country entries. Falls back to :rc:`legend.geo.country_proj`. + handlesize : float, optional + Multiplier applied to legend ``handlelength`` / ``handleheight`` + to enlarge geometry handles. Falls back to + :rc:`legend.geo.handlesize`. Must be positive. + facecolor, edgecolor + %(legend.semantic_style_arg)s + Default to :rc:`legend.geo.facecolor` / :rc:`legend.geo.edgecolor`. + linewidth, alpha, fill + Patch outline width, transparency, and fill toggle. + Defaults from :rc:`legend.geo.linewidth` / :rc:`legend.geo.alpha` / + :rc:`legend.geo.fill`. + + Other parameters + ---------------- + %(legend.semantic_num_style_kwargs)s + %(legend.semantic_handle_kw)s + + Notes + ----- + Geometry legend entries use normalized patch proxies inside the legend + handle box rather than reusing the original map artist directly. This + preserves the general geometry shape and copied patch styling, but very + small or high-aspect-ratio handles can still make hatches difficult to + read at legend scale. + + See also + -------- + Axes.numlegend """ return plegend.UltraLegend(self).geolegend(entries, labels=labels, **kwargs) diff --git a/ultraplot/internals/docstring.py b/ultraplot/internals/docstring.py index 39b2938f6..13bdff625 100644 --- a/ultraplot/internals/docstring.py +++ b/ultraplot/internals/docstring.py @@ -121,6 +121,7 @@ class _SnippetManager(dict): "plot": "ultraplot.axes.plot", "figure": "ultraplot.figure", "gridspec": "ultraplot.gridspec", + "legend": "ultraplot.legend", "ticker": "ultraplot.ticker", "proj": "ultraplot.proj", "colors": "ultraplot.colors", diff --git a/ultraplot/legend.py b/ultraplot/legend.py index db6e5f777..8f1096cb0 100644 --- a/ultraplot/legend.py +++ b/ultraplot/legend.py @@ -9,12 +9,14 @@ import numpy as np from matplotlib import cm as mcm from matplotlib import colors as mcolors +from matplotlib.colors import is_color_like as _mpl_is_color_like from matplotlib import lines as mlines from matplotlib import legend as mlegend from matplotlib import legend_handler as mhandler +from matplotlib.markers import MarkerStyle from .config import rc -from .internals import _not_none, _pop_props, guides, rcsetup +from .internals import _not_none, _pop_props, docstring, guides, rcsetup from .utils import _fontsize_to_pt, units try: @@ -91,8 +93,33 @@ def __init__( markeredgecolor=None, markeredgewidth=None, alpha=None, + marker_capstyle=None, + marker_joinstyle=None, + marker_transform=None, **kwargs, ): + # ``Line2D`` exposes capstyle/joinstyle/transform/fillstyle only via the + # marker object, not as kwargs. Wrap the marker spec in a ``MarkerStyle`` + # so these properties survive into the rendered legend entry. Pop + # ``fillstyle`` from kwargs first so it doesn't reach ``Line2D.__init__`` + # twice when ``MarkerStyle`` consumes it. + fillstyle = kwargs.pop("fillstyle", None) + if ( + marker_capstyle is not None + or marker_joinstyle is not None + or marker_transform is not None + or fillstyle is not None + ) and not isinstance(marker, MarkerStyle): + marker_kw = {} + if marker_capstyle is not None: + marker_kw["capstyle"] = marker_capstyle + if marker_joinstyle is not None: + marker_kw["joinstyle"] = marker_joinstyle + if marker_transform is not None: + marker_kw["transform"] = marker_transform + if fillstyle is not None: + marker_kw["fillstyle"] = fillstyle + marker = MarkerStyle(marker, **marker_kw) marker = "o" if marker is None and not line else marker linestyle = "none" if not line else linestyle if markerfacecolor is None and color is not None: @@ -775,11 +802,7 @@ def _geo_legend_entries( 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, + patch_kw: dict = None, ): """ Build geometry semantic legend handles and labels. @@ -837,29 +860,125 @@ def _geo_legend_entries( "Labels and geometry entries must have the same length. " f"Got {len(label_list)} labels and {len(geometry_list)} entries." ) + if patch_kw is None: + patch_kw = {} + facecolor = patch_kw.get("facecolor", "none") + edgecolor = patch_kw.get("edgecolor", "0.25") + linewidth = patch_kw.get("linewidth", 1.0) + alpha = patch_kw.get("alpha", None) + fill = patch_kw.get("fill", None) + handles = [] - for geometry, label, options in zip(geometry_list, label_list, entry_options): + for idx, (geometry, label, options) in enumerate( + zip(geometry_list, label_list, entry_options) + ): + # Resolve per-entry values (scalar → all; list → cycled; dict → matched by label) + fc = _style_lookup(facecolor, label, idx, default="none", prop="facecolor") + ec = _style_lookup(edgecolor, label, idx, default="0.25", prop="edgecolor") + lw = _style_lookup(linewidth, label, idx, default=1.0, prop=None) + a = _style_lookup(alpha, label, idx, default=None, prop=None) + fl = _style_lookup(fill, label, idx, default=None, prop=None) + geo_kwargs = { "country_reso": country_reso, "country_territories": country_territories, "country_proj": country_proj, - "facecolor": facecolor, - "edgecolor": edgecolor, - "linewidth": linewidth, - "alpha": alpha, - "fill": fill, + "facecolor": fc, + "edgecolor": ec, + "linewidth": lw, + "alpha": a, + "fill": fl, } + # Apply any remaining patch properties (hatch, linestyle, capstyle, etc.) + for k, v in patch_kw.items(): + if k not in geo_kwargs: + geo_kwargs[k] = _style_lookup(v, label, idx, default=None, prop=k) 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): +# _is_color_like should only check the following args +_COLOR_KEYS = { + "color", + "facecolor", + "edgecolor", + "markerfacecolor", + "markeredgecolor", + "markerfacecoloralt", +} + + +def _is_color_like(value): + """ + Determine whether a value can be interpreted as a single color. + + A tuple or list of 3 or 4 numbers in ``[0, 1]`` is treated as one RGB(A) + color rather than a per-entry style sequence — matching matplotlib's + color parser and giving tuple/list symmetric behavior. Other lists fall + through to per-entry resolution by ``_style_lookup``. + """ + if value is None: + return False + if isinstance(value, (tuple, list)): + if len(value) in (3, 4) and all( + isinstance(v, (int, float)) and 0.0 <= v <= 1.0 for v in value + ): + return True + return False + return _mpl_is_color_like(value) + + +# Line2D / LegendEntry alias mapping. ``ec`` / ``fc`` are deliberately +# omitted: they already resolve to ``markeredgecolor`` / ``markerfacecolor`` +# via ultraplot's internal ``_pop_props(kwargs, "line")``. +_LINE_ALIAS_MAP = { + "c": "color", + "m": "marker", + "ms": "markersize", + "ls": "linestyle", + "lw": "linewidth", + "mec": "markeredgecolor", + "mew": "markeredgewidth", + "mfc": "markerfacecolor", + "mfcalt": "markerfacecoloralt", + "aa": "antialiased", + "fs": "fillstyle", +} + +# Patch alias mapping +_PATCH_ALIAS_MAP = { + "c": "color", + "fc": "facecolor", + "ec": "edgecolor", + "ls": "linestyle", + "lw": "linewidth", + "aa": "antialiased", +} + + +def _style_lookup(style, key, index, default=None, *, prop=None): """ - Resolve style values from scalar, mapping, or sequence inputs. + Resolve a style value from scalar, mapping, or sequence inputs. + + Parameters + ---------- + style : the style value (scalar, list, dict) + key : dict key when `style` is a mapping (typically a label) + index : list index when `style` is a sequence + default : fallback value + prop : optional attribute name; if it belongs to _COLOR_KEYS, + the function treats color-like sequences as single colors. """ if style is None: return default + + # Only perform color detection for known color properties + check_color = prop is not None and prop in _COLOR_KEYS + + if check_color and _is_color_like(style): + return style if isinstance(style, dict): return style.get(key, default) if isinstance(style, str): @@ -904,24 +1023,90 @@ def _default_cycle_colors(): "linestyles": "linestyle", "linewidths": "markeredgewidth", "sizes": "markersize", + "size": "markersize", } +def _pop_aliases(kwargs: dict[str, Any], alias_map: dict[str, str]) -> dict[str, Any]: + """Pop short aliases (``c``, ``ls``, …) from ``kwargs`` mapped to full names.""" + resolved = {} + for alias in list(kwargs): + if alias in alias_map: + resolved[alias_map[alias]] = kwargs.pop(alias) + return resolved + + +def _pop_plurals(kwargs: dict[str, Any], plural_map: dict[str, str]) -> dict[str, Any]: + """Pop collection-style plurals (``colors``, ``sizes``, …) from ``kwargs``.""" + explicit = {} + for key in plural_map: + if key in kwargs: + explicit[key] = kwargs.pop(key) + return explicit + + +def _pop_line2d_setters(kwargs: dict[str, Any]) -> dict[str, Any]: + """ + Pop remaining kwargs that correspond to ``Line2D`` setters. + + Catches properties that ``_pop_props(..., "line")`` does not know about + (e.g. ``fillstyle``, ``solid_capstyle``) so they survive into the + ``LegendEntry`` constructor instead of leaking through to ``Axes.legend``, + where matplotlib rejects them. + + ``label``/``labels`` look like Line2D setters but are intentionally not + consumed here — the semantic-legend validator (covered by + ``test_semantic_legend_rejects_label{,s}_kwarg``) needs them to surface + as ``TypeError`` from the public ``legend()`` call. + """ + extracted = {} + for key in list(kwargs): + if key in ("labels", "label") or key.startswith("_"): + continue + if hasattr(mlines.Line2D, "set_" + key): + extracted[key] = kwargs.pop(key) + return extracted + + def _pop_entry_props(kwargs: dict[str, Any]) -> dict[str, Any]: """ - Pop style properties with line/scatter aliases for LegendEntry objects. + Extract ``LegendEntry`` style properties from ``kwargs``. + + Resolution order (highest → lowest priority): + + 1. Full-name properties recognised by ``_pop_props(kwargs, "line")``. + 2. Collection-style plurals (``colors`` → ``color``, ``sizes`` → ``markersize``, …). + 3. Short aliases (``c`` → ``color``, ``ls`` → ``linestyle``, …). + 4. Any other valid ``Line2D`` setter still in ``kwargs``. + + Advanced ``MarkerStyle`` properties (``marker_capstyle``/``_joinstyle``/ + ``_transform``) are pulled out first so ``_pop_props`` does not consume + them, and merged back at the end with full priority. """ - explicit_collection = {} - for key in _ENTRY_STYLE_FROM_COLLECTION: + advanced_marker = {} + for key in ("marker_capstyle", "marker_joinstyle", "marker_transform"): if key in kwargs: - explicit_collection[key] = kwargs.pop(key) + advanced_marker[key] = kwargs.pop(key) + + resolved_aliases = _pop_aliases(kwargs, _LINE_ALIAS_MAP) + explicit_collection = _pop_plurals(kwargs, _ENTRY_STYLE_FROM_COLLECTION) + props = _pop_props(kwargs, "line") collection_props = _pop_props(kwargs, "collection") collection_props.update(explicit_collection) + for source, target in _ENTRY_STYLE_FROM_COLLECTION.items(): value = collection_props.get(source, None) if value is not None and target not in props: props[target] = value + + for full_key, value in resolved_aliases.items(): + props.setdefault(full_key, value) + + for full_key, value in _pop_line2d_setters(kwargs).items(): + props.setdefault(full_key, value) + + props.update(advanced_marker) return props @@ -936,19 +1121,24 @@ def _pop_entry_props(kwargs: dict[str, Any]) -> dict[str, Any]: def _pop_num_props(kwargs: dict[str, Any]) -> dict[str, Any]: """ - Pop patch/collection style aliases for numeric semantic legend entries. + Extract patch-style properties (and collection-plural / short aliases) for + numeric semantic legend entries (``numlegend`` / ``geolegend``). """ - explicit_collection = {} - for key in _NUM_STYLE_FROM_COLLECTION: - if key in kwargs: - explicit_collection[key] = kwargs.pop(key) + resolved_aliases = _pop_aliases(kwargs, _PATCH_ALIAS_MAP) + explicit_collection = _pop_plurals(kwargs, _NUM_STYLE_FROM_COLLECTION) + props = _pop_props(kwargs, "patch") collection_props = _pop_props(kwargs, "collection") collection_props.update(explicit_collection) + for source, target in _NUM_STYLE_FROM_COLLECTION.items(): value = collection_props.get(source, None) if value is not None and target not in props: props[target] = value + + for full_key, value in resolved_aliases.items(): + props.setdefault(full_key, value) + return props @@ -962,17 +1152,17 @@ def _resolve_style_values( """ output = {} for key, value in styles.items(): - resolved = _style_lookup(value, label, index, default=None) + resolved = _style_lookup(value, label, index, default=None, prop=key) if resolved is not None: output[key] = resolved return output def _cat_legend_entries( - categories: Iterable[Any], + categories, *, - colors=None, - markers="o", + color=None, + marker="o", line=False, linestyle="-", linewidth=2.0, @@ -1002,18 +1192,27 @@ def _cat_legend_entries( handles = [] for idx, label in enumerate(labels): styles = _resolve_style_values(base_styles, label, idx) - color = _style_lookup(colors, label, idx, default=palette[idx % len(palette)]) - marker = _style_lookup(markers, label, idx, default="o") line_value = bool(styles.pop("line", False)) - if line_value and marker in (None, ""): - marker = None - styles.pop("marker", None) + linestyle_value = styles.pop("linestyle", "-") + marker_value = styles.pop("marker", None) + + # If line=False but user provides a non-default linestyle, automatically enable line=True + if not line_value and linestyle_value not in (None, "-", "none", "None"): + line_value = True + + color_val = _style_lookup( + color, label, idx, default=palette[idx % len(palette)], prop="color" + ) + marker_val = _style_lookup(marker, label, idx, default="o", prop="marker") + if line_value and marker_val in (None, ""): + marker_val = None handles.append( LegendEntry( label=str(label), - color=color, + color=color_val, line=line_value, - marker=marker, + marker=marker_val, + linestyle=linestyle_value, **styles, ) ) @@ -1172,8 +1371,12 @@ def _size_legend_entries( handles = [] for idx, (value, label, size) in enumerate(zip(values, label_list, ms)): styles = _resolve_style_values(base_styles, float(value), idx) - color_value = _style_lookup(color, float(value), idx, default="0.35") - marker_value = _style_lookup(marker, float(value), idx, default="o") + color_value = _style_lookup( + color, float(value), idx, default="0.35", prop="color" + ) + marker_value = _style_lookup( + marker, float(value), idx, default="o", prop="marker" + ) line_value = bool(styles.pop("line", False)) if line_value and marker_value in ("", None): marker_value = None @@ -1397,6 +1600,81 @@ def _normalize_em_kwargs(kwargs: dict[str, Any], *, fontsize: float) -> dict[str return kwargs +_semantic_style_arg_docstring = """\ +A style value resolved per legend entry. Accepts a **scalar** (applied + to every entry), a **list / tuple / ndarray** (one value per entry, + cycled to match the number of entries), or a **dict** (mapping from + label — or from numeric value for ``sizelegend`` / ``numlegend`` — to + style; missing keys fall back to the default). A 3- or 4-element + sequence of floats in ``[0, 1]`` is treated as a single RGB(A) color + rather than as per-entry values, so ``color=[0.5, 0.5, 0.5]`` and + ``color=(0.5, 0.5, 0.5)`` behave the same.""" + +_semantic_style_kwargs_docstring = """\ +Common style keywords accepted via ``handle_kw`` or ``**kwargs``: + +``color`` / ``c`` + Marker (and line, when ``line=True``) color. ``c`` is the short alias. +``marker`` / ``m`` + Marker spec. Set to ``None`` or ``""`` to suppress the marker. +``markersize`` / ``ms``, ``markeredgewidth`` / ``mew`` + Marker dimensions. +``markerfacecolor`` / ``mfc``, ``markeredgecolor`` / ``mec``, ``markerfacecoloralt`` / ``mfcalt`` + Marker fills and edges. +``linestyle`` / ``ls``, ``linewidth`` / ``lw`` + Connector line styling. Setting a non-default ``linestyle`` implicitly + enables ``line=True``. +``alpha``, ``antialiased`` / ``aa``, ``fillstyle`` / ``fs`` + Generic appearance. +``marker_capstyle``, ``marker_joinstyle``, ``marker_transform`` + Advanced ``MarkerStyle`` properties; wrapped into the rendered marker. + +Plural forms (``colors``, ``markers``, ``sizes``, ``edgecolors``, +``facecolors``, ``linestyles``, ``linewidths``) are accepted as +synonyms for the singular per-entry form for backward compatibility. +Each value accepts the scalar / sequence / mapping forms described in +``%(legend.semantic_style_arg)s``.""" + +_semantic_num_style_kwargs_docstring = """\ +Patch-style keywords accepted via ``handle_kw`` or ``**kwargs``: + +``facecolor`` / ``fc``, ``edgecolor`` / ``ec``, ``color`` / ``c`` + Patch fills and edges. +``linewidth`` / ``lw``, ``linestyle`` / ``ls`` + Patch outline styling. +``alpha``, ``antialiased`` / ``aa``, ``hatch``, ``fill``, +``joinstyle``, ``capstyle`` + Generic patch appearance. + +Plural collection forms (``colors``, ``facecolors``, ``edgecolors``, +``linestyles``, ``linewidths``) map to the singular per-entry form. +Each value accepts the scalar / sequence / mapping forms described in +``%(legend.semantic_style_arg)s``.""" + +_semantic_handle_kw_docstring = """\ +handle_kw : dict, optional + Style overrides applied to each generated handle. Same vocabulary as + ``**kwargs``; useful when style kwargs would otherwise collide with + matplotlib's :class:`~matplotlib.legend.Legend` keywords (``loc``, + ``title``, …). +add : bool, default: True + When ``True`` (default), draw the legend on the axes and return the + legend artist. When ``False``, return ``(handles, labels)`` without + drawing — useful for composing into a parent legend. +**kwargs + Style keywords applied per entry (see above), plus any + :class:`~matplotlib.legend.Legend` keyword.""" + +docstring._snippet_manager["legend.semantic_style_arg"] = _semantic_style_arg_docstring +docstring._snippet_manager["legend.semantic_style_kwargs"] = ( + _semantic_style_kwargs_docstring +) +docstring._snippet_manager["legend.semantic_num_style_kwargs"] = ( + _semantic_num_style_kwargs_docstring +) +docstring._snippet_manager["legend.semantic_handle_kw"] = _semantic_handle_kw_docstring + + class UltraLegend: """ Centralized legend builder for axes. @@ -1428,55 +1706,36 @@ def entrylegend( line: Optional[bool] = None, marker=None, color=None, - linestyle=None, - linewidth: Optional[float] = None, - markersize: Optional[float] = None, - alpha=None, - markeredgecolor=None, - markeredgewidth=None, - markerfacecolor=None, handle_kw: Optional[dict[str, Any]] = None, add: bool = True, - **legend_kwargs: Any, + **kwargs: Any, ): """ Build generic semantic legend entries and optionally draw a legend. + Public docs live on :meth:`Axes.entrylegend`. """ - styles = dict(handle_kw or {}) - styles.update(_pop_entry_props(styles)) + styles = {} + if handle_kw: + styles.update(_pop_entry_props(handle_kw)) + styles.update(_pop_entry_props(kwargs)) + line = _not_none(line, styles.pop("line", None), rc["legend.cat.line"]) marker = _not_none(marker, styles.pop("marker", None), rc["legend.cat.marker"]) color = _not_none(color, styles.pop("color", None)) - linestyle = _not_none( - linestyle, - styles.pop("linestyle", None), - rc["legend.cat.linestyle"], - ) - linewidth = _not_none( - linewidth, - styles.pop("linewidth", None), - rc["legend.cat.linewidth"], - ) + linestyle = _not_none(styles.pop("linestyle", None), rc["legend.cat.linestyle"]) + linewidth = _not_none(styles.pop("linewidth", None), rc["legend.cat.linewidth"]) markersize = _not_none( - markersize, - styles.pop("markersize", None), - rc["legend.cat.markersize"], + styles.pop("markersize", None), rc["legend.cat.markersize"] ) - alpha = _not_none(alpha, styles.pop("alpha", None), rc["legend.cat.alpha"]) + alpha = _not_none(styles.pop("alpha", None), rc["legend.cat.alpha"]) markeredgecolor = _not_none( - markeredgecolor, - styles.pop("markeredgecolor", None), - rc["legend.cat.markeredgecolor"], + styles.pop("markeredgecolor", None), rc["legend.cat.markeredgecolor"] ) markeredgewidth = _not_none( - markeredgewidth, - styles.pop("markeredgewidth", None), - rc["legend.cat.markeredgewidth"], - ) - markerfacecolor = _not_none( - markerfacecolor, - styles.pop("markerfacecolor", None), + styles.pop("markeredgewidth", None), rc["legend.cat.markeredgewidth"] ) + markerfacecolor = _not_none(styles.pop("markerfacecolor", None), None) + handles, labels = _entry_legend_entries( entries, line=line, @@ -1493,71 +1752,52 @@ def entrylegend( ) if not add: return handles, labels - self._validate_semantic_kwargs("entrylegend", legend_kwargs) - return self.axes.legend(handles, labels, **legend_kwargs) + self._validate_semantic_kwargs("entrylegend", kwargs) + return self.axes.legend(handles, labels, **kwargs) def catlegend( self, categories: Iterable[Any], *, - colors=None, - markers=None, + color=None, + marker=None, line: Optional[bool] = None, - linestyle=None, - linewidth: Optional[float] = None, - markersize: Optional[float] = None, - alpha=None, - markeredgecolor=None, - markeredgewidth=None, - markerfacecolor=None, handle_kw: Optional[dict[str, Any]] = None, add: bool = True, - **legend_kwargs: Any, + **kwargs: Any, ): """ Build categorical legend entries and optionally draw a legend. + Public docs live on :meth:`Axes.catlegend`. """ - styles = dict(handle_kw or {}) - styles.update(_pop_entry_props(styles)) + styles = {} + if handle_kw: + styles.update(_pop_entry_props(handle_kw)) + styles.update(_pop_entry_props(kwargs)) + line = _not_none(line, styles.pop("line", None), rc["legend.cat.line"]) - colors = _not_none(colors, styles.pop("color", None)) - markers = _not_none( - markers, styles.pop("marker", None), rc["legend.cat.marker"] - ) - linestyle = _not_none( - linestyle, - styles.pop("linestyle", None), - rc["legend.cat.linestyle"], - ) - linewidth = _not_none( - linewidth, - styles.pop("linewidth", None), - rc["legend.cat.linewidth"], - ) + color = _not_none(color, styles.pop("color", None)) + marker = _not_none(marker, styles.pop("marker", None), rc["legend.cat.marker"]) + linestyle = _not_none(styles.pop("linestyle", None), rc["legend.cat.linestyle"]) + linewidth = _not_none(styles.pop("linewidth", None), rc["legend.cat.linewidth"]) markersize = _not_none( - markersize, - styles.pop("markersize", None), - rc["legend.cat.markersize"], + styles.pop("markersize", None), rc["legend.cat.markersize"] ) - alpha = _not_none(alpha, styles.pop("alpha", None), rc["legend.cat.alpha"]) + alpha = _not_none(styles.pop("alpha", None), rc["legend.cat.alpha"]) markeredgecolor = _not_none( - markeredgecolor, - styles.pop("markeredgecolor", None), - rc["legend.cat.markeredgecolor"], + styles.pop("markeredgecolor", None), rc["legend.cat.markeredgecolor"] ) markeredgewidth = _not_none( - markeredgewidth, - styles.pop("markeredgewidth", None), - rc["legend.cat.markeredgewidth"], - ) - markerfacecolor = _not_none( - markerfacecolor, - styles.pop("markerfacecolor", None), + styles.pop("markeredgewidth", None), rc["legend.cat.markeredgewidth"] ) + markerfacecolor = _not_none(styles.pop("markerfacecolor", None), None) + + # Remaining styles are passed as additional entry properties + # (e.g., 'markerfacecoloralt') to _cat_legend_entries handles, labels = _cat_legend_entries( categories, - colors=colors, - markers=markers, + color=color, + marker=marker, line=line, linestyle=linestyle, linewidth=linewidth, @@ -1570,10 +1810,8 @@ def catlegend( ) if not add: return handles, labels - 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) + self._validate_semantic_kwargs("catlegend", kwargs) + return self.axes.legend(handles, labels, **kwargs) def sizelegend( self, @@ -1586,40 +1824,32 @@ def sizelegend( scale: Optional[float] = None, minsize: Optional[float] = None, fmt=None, - alpha=None, - markeredgecolor=None, - markeredgewidth=None, - markerfacecolor=None, handle_kw: Optional[dict[str, Any]] = None, add: bool = True, - **legend_kwargs: Any, + **kwargs: Any, ): """ Build size legend entries and optionally draw a legend. + Public docs live on :meth:`Axes.sizelegend`. """ - styles = dict(handle_kw or {}) - styles.update(_pop_entry_props(styles)) + styles = {} + if handle_kw: + styles.update(_pop_entry_props(handle_kw)) + styles.update(_pop_entry_props(kwargs)) color = _not_none(color, styles.pop("color", None), rc["legend.size.color"]) marker = _not_none(marker, styles.pop("marker", None), 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, styles.pop("alpha", None), rc["legend.size.alpha"]) + alpha = _not_none(styles.pop("alpha", None), rc["legend.size.alpha"]) markeredgecolor = _not_none( - markeredgecolor, - styles.pop("markeredgecolor", None), - rc["legend.size.markeredgecolor"], + styles.pop("markeredgecolor", None), rc["legend.size.markeredgecolor"] ) markeredgewidth = _not_none( - markeredgewidth, - styles.pop("markeredgewidth", None), - rc["legend.size.markeredgewidth"], - ) - markerfacecolor = _not_none( - markerfacecolor, - styles.pop("markerfacecolor", None), + styles.pop("markeredgewidth", None), rc["legend.size.markeredgewidth"] ) + markerfacecolor = _not_none(styles.pop("markerfacecolor", None), None) handles, labels = _size_legend_entries( levels, labels=labels, @@ -1637,8 +1867,8 @@ def sizelegend( ) if not add: return handles, labels - self._validate_semantic_kwargs("sizelegend", legend_kwargs) - return self.axes.legend(handles, labels, **legend_kwargs) + self._validate_semantic_kwargs("sizelegend", kwargs) + return self.axes.legend(handles, labels, **kwargs) def numlegend( self, @@ -1657,30 +1887,32 @@ def numlegend( alpha=None, handle_kw: Optional[dict[str, Any]] = None, add: bool = True, - **legend_kwargs: Any, + **kwargs: Any, ): """ Build numeric-color legend entries and optionally draw a legend. + Public docs live on :meth:`Axes.numlegend`. """ - styles = dict(handle_kw or {}) - styles.update(_pop_num_props(styles)) + styles = {} + if handle_kw: + styles.update(_pop_num_props(handle_kw)) + styles.update(_pop_num_props(kwargs)) + color = styles.pop("color", None) n = _not_none(n, rc["legend.num.n"]) cmap = _not_none(cmap, rc["legend.num.cmap"]) facecolor = _not_none(facecolor, styles.pop("facecolor", None), color) edgecolor = _not_none( - edgecolor, - styles.pop("edgecolor", None), - rc["legend.num.edgecolor"], + edgecolor, styles.pop("edgecolor", None), rc["legend.num.edgecolor"] ) linewidth = _not_none( - linewidth, - styles.pop("linewidth", None), - rc["legend.num.linewidth"], + linewidth, styles.pop("linewidth", None), rc["legend.num.linewidth"] ) linestyle = _not_none(linestyle, styles.pop("linestyle", None)) alpha = _not_none(alpha, styles.pop("alpha", None), rc["legend.num.alpha"]) fmt = _not_none(fmt, rc["legend.num.format"]) + + # Remaining styles (e.g. hatch, joinstyle, capstyle, fill) pass through. handles, labels = _num_legend_entries( levels=levels, vmin=vmin, @@ -1696,10 +1928,11 @@ def numlegend( alpha=alpha, **styles, ) + if not add: return handles, labels - self._validate_semantic_kwargs("numlegend", legend_kwargs) - return self.axes.legend(handles, labels, **legend_kwargs) + self._validate_semantic_kwargs("numlegend", kwargs) + return self.axes.legend(handles, labels, **kwargs) def geolegend( self, @@ -1715,55 +1948,69 @@ def geolegend( linewidth: Optional[float] = None, alpha: Optional[float] = None, fill: Optional[bool] = None, + handle_kw: Optional[dict[str, Any]] = None, add: bool = True, - **legend_kwargs: Any, + **kwargs: Any, ): """ Build geometry legend entries and optionally draw a legend. - - Notes - ----- - Geometry legend entries use normalized patch proxies inside the legend - handle box rather than reusing the original map artist directly. This - preserves the general geometry shape and copied patch styling, but very - small or high-aspect-ratio handles can still make hatches difficult to - read at legend scale. + Public docs live on :meth:`Axes.geolegend`. """ - 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"]) + styles = {} + if handle_kw: + styles.update(_pop_num_props(handle_kw)) + styles.update(_pop_num_props(kwargs)) + + facecolor = _not_none( + facecolor, styles.pop("facecolor", None), rc["legend.geo.facecolor"] + ) + edgecolor = _not_none( + edgecolor, styles.pop("edgecolor", None), rc["legend.geo.edgecolor"] + ) + linewidth = _not_none( + linewidth, styles.pop("linewidth", None), rc["legend.geo.linewidth"] + ) + alpha = _not_none(alpha, styles.pop("alpha", None), rc["legend.geo.alpha"]) + fill = _not_none(fill, styles.pop("fill", None), rc["legend.geo.fill"]) + + # Carry remaining styles (hatch, linestyle, joinstyle, …) through + # to per-entry resolution in ``_geo_legend_entries``. + patch_kw = { + "facecolor": facecolor, + "edgecolor": edgecolor, + "linewidth": linewidth, + "alpha": alpha, + "fill": fill, + } + patch_kw.update(styles) + 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, + patch_kw=patch_kw, ) if not add: return handles, labels - self._validate_semantic_kwargs("geolegend", legend_kwargs) + self._validate_semantic_kwargs("geolegend", kwargs) if handlesize is not None: handlesize = float(handlesize) if handlesize <= 0: 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: - legend_kwargs["handleheight"] = rc["legend.handleheight"] * handlesize - return self.axes.legend(handles, labels, **legend_kwargs) + if "handlelength" not in kwargs: + kwargs["handlelength"] = rc["legend.handlelength"] * handlesize + if "handleheight" not in kwargs: + kwargs["handleheight"] = rc["legend.handleheight"] * handlesize + return self.axes.legend(handles, labels, **kwargs) @staticmethod def _align_map() -> dict[Optional[str], dict[str, str]]: diff --git a/ultraplot/tests/test_semantic_legend.py b/ultraplot/tests/test_semantic_legend.py new file mode 100644 index 000000000..f2091def1 --- /dev/null +++ b/ultraplot/tests/test_semantic_legend.py @@ -0,0 +1,544 @@ +""" +Unit tests for semantic legend style aliases, color parsing, and advanced markers. +These tests focus on functionality not covered by test_legend.py. +""" + +import matplotlib + +matplotlib.use("Agg") # non-interactive backend + +import numpy as np +import pytest +from matplotlib import colors as mcolors +from matplotlib import patches as mpatches +from matplotlib.markers import CapStyle, JoinStyle, MarkerStyle +import matplotlib.transforms as mtransforms + +import ultraplot as uplt + + +def _make_fig(): + """Helper to create a figure and axis with axes turned off.""" + fig, ax = uplt.subplots() + ax.axis("off") + return fig, ax + + +# ----------------------------------------------------------------------------- +# Non-color properties: scalar, list, dict (single catlegend call) +# ----------------------------------------------------------------------------- +def test_non_color_properties(): + """Non-color properties (marker, markersize, linewidth, alpha, fillstyle, + antialiased, markerfacecoloralt, markerfacecolor, markeredgecolor, size) + are correctly parsed and applied when passed together.""" + fig, ax = _make_fig() + try: + # Combine many non-color properties in one catlegend call. + h, _ = ax.catlegend( + ["A", "B", "C"], + marker="o", + ms=[10, 20, 30], # alias list – overrides above for each entry + lw=[1.5, 2.5, 3.5], # linewidth via alias list + alpha=[0.2, 0.5, 0.8], # length-3 list, not a color + fs="full", # fillstyle + aa=False, # antialiased scalar + markerfacecolor="green", # full name + markeredgecolor="black", # full name + markerfacecoloralt="orange", + line=True, # enable lines + add=False, + ) + # markersize from ms list + assert h[0].get_markersize() == 10 + assert h[1].get_markersize() == 20 + assert h[2].get_markersize() == 30 + # linewidth from lw list + assert h[0].get_linewidth() == 1.5 + assert h[1].get_linewidth() == 2.5 + assert h[2].get_linewidth() == 3.5 + # alpha + assert h[0].get_alpha() == 0.2 + assert h[1].get_alpha() == 0.5 + assert h[2].get_alpha() == 0.8 + # antialiased + for hh in h: + assert hh.get_antialiased() is False + for hh in h: + assert hh.get_markerfacecoloralt() == "orange" + assert hh.get_fillstyle() == "full" + finally: + uplt.close(fig) + + +def test_size_alias_and_markersize_dict(): + """'size' (collection style) maps to markersize, and dict works.""" + fig, ax = _make_fig() + try: + # size as list and dict + h, _ = ax.catlegend( + ["X", "Y", "Z"], + marker="s", + ms={"X": 5, "Y": 12, "Z": 20}, # dict should override per label + add=False, + ) + assert h[0].get_markersize() == 5 + assert h[1].get_markersize() == 12 + assert h[2].get_markersize() == 20 + finally: + uplt.close(fig) + + +def test_markerfacecolor_and_edgecolor(): + """Test full-name markerfacecolor and markeredgecolor with fillstyle='full'.""" + fig, ax = _make_fig() + try: + h, _ = ax.catlegend( + ["A", "B"], + marker="o", + markerfacecolor="green", + markeredgecolor="black", + add=False, + ) + for hh in h: + assert np.allclose( + mcolors.to_rgba(hh.get_markerfacecolor()), mcolors.to_rgba("green") + ) + assert np.allclose( + mcolors.to_rgba(hh.get_markeredgecolor()), mcolors.to_rgba("black") + ) + finally: + uplt.close(fig) + + +# ----------------------------------------------------------------------------- +# Alias resolution and conflicts +# ----------------------------------------------------------------------------- +def test_alias_resolution_and_conflicts(): + """Aliases (c, m, ms, ls, lw, mec, mew, mfc, mfcalt, aa, fs) work, + and full names override aliases when both are given.""" + fig, ax = _make_fig() + try: + # All aliases in one catlegend call + h, _ = ax.catlegend( + ["A", "B"], + c="red", + m="^", + ms=15, + ls="--", + lw=3.0, + mec="blue", + mew=2.0, + mfc="yellow", + mfcalt="orange", + aa=False, + fs="full", + add=False, + ) + for hh in h: + assert hh.get_color() == "red" + assert hh.get_marker() == "^" + assert hh.get_markersize() == 15 + assert hh.get_linestyle() == "--" + assert hh.get_linewidth() == 3.0 + assert hh.get_markeredgecolor() == "blue" + assert hh.get_markeredgewidth() == 2.0 + assert hh.get_markerfacecolor() == "yellow" + assert hh.get_markerfacecoloralt() == "orange" + assert hh.get_antialiased() is False + assert hh.get_fillstyle() == "full" + + # Conflict: full name overrides alias (markersize vs ms) + h, _ = ax.catlegend(["U", "V"], markersize=15, ms=99, add=False) + assert h[0].get_markersize() == 15 + + # Dict styles with aliases + h, _ = ax.catlegend( + ["red", "green", "blue"], + c={"red": "red", "green": "green", "blue": "blue"}, + ms={"red": 10, "green": 20, "blue": 30}, + add=False, + ) + assert h[0].get_color() == "red" + assert h[1].get_color() == "green" + assert h[2].get_color() == "blue" + assert h[0].get_markersize() == 10 + assert h[1].get_markersize() == 20 + assert h[2].get_markersize() == 30 + + # sizelegend aliases + h, _ = ax.sizelegend([1, 2, 3], c="purple", mec="green", add=False) + for hh in h: + assert hh.get_color() == "purple" + assert hh.get_markeredgecolor() == "green" + finally: + uplt.close(fig) + + +# ----------------------------------------------------------------------------- +# Color parsing: many formats (scalar, list, dict, tuple, etc.) +# ----------------------------------------------------------------------------- +def test_color_parsing(): + """Color parameters accept many formats (names, hex, tuples, lists, dicts), + and RGBA tuples are treated as single colors, not unpacked.""" + fig, ax = _make_fig() + try: + # Scalar colors: named, hex, grayscale, RGB tuple, RGBA tuple + for color in ["red", "#ff0000", "0.5", (0.2, 0.4, 0.6), (0.2, 0.4, 0.6, 0.8)]: + h, _ = ax.catlegend(["x", "y", "z"], color=color, add=False) + first = h[0].get_color() + assert all(hh.get_color() == first for hh in h), f"Failed for {color}" + + # List of colors: mixed formats + c_list = ["red", "#00ff00", (0.0, 0.0, 1.0)] + h, _ = ax.catlegend(["p", "q", "r"], color=c_list, add=False) + assert h[0].get_color() == c_list[0] + assert h[1].get_color() == c_list[1] + assert h[2].get_color() == c_list[2] + + # List of RGBA tuples + c_rgba = [(1.0, 0.0, 0.0, 1.0), (0.0, 1.0, 0.0, 1.0)] + h, _ = ax.catlegend(["X", "Y"], color=c_rgba, add=False) + assert h[0].get_color() == c_rgba[0] + assert h[1].get_color() == c_rgba[1] + + # Dict mapping labels to colors + color_dict = {"A": "red", "B": "green", "C": "blue"} + h, _ = ax.catlegend(["A", "B", "C"], color=color_dict, add=False) + assert h[0].get_color() == "red" + assert h[1].get_color() == "green" + assert h[2].get_color() == "blue" + + # markerfacecolor as single RGBA tuple + h, _ = ax.catlegend( + ["m1", "m2"], marker="o", markerfacecolor=(0.1, 0.2, 0.3, 1.0), add=False + ) + ref = h[0].get_markerfacecolor() + assert np.allclose(h[1].get_markerfacecolor(), ref) + + # markerfacecolor via alias (mfc) with list of colors + h, _ = ax.catlegend(["g", "l"], marker="o", mfc=["gold", "lime"], add=False) + assert np.allclose( + mcolors.to_rgba(h[0].get_markerfacecolor()), mcolors.to_rgba("gold") + ) + assert np.allclose( + mcolors.to_rgba(h[1].get_markerfacecolor()), mcolors.to_rgba("lime") + ) + + # numlegend facecolor as RGBA tuple + h, _ = ax.numlegend( + [1, 2, 3], vmin=0, vmax=4, facecolor=(0.8, 0.2, 0.3, 0.6), add=False + ) + ref_patch = np.array(h[0].get_facecolor()) + assert all(np.allclose(np.array(hh.get_facecolor()), ref_patch) for hh in h) + finally: + uplt.close(fig) + + +# ----------------------------------------------------------------------------- +# Advanced marker styles (capstyle, joinstyle, transform) +# ----------------------------------------------------------------------------- +def test_marker_advanced(): + """marker_capstyle, marker_joinstyle, marker_transform create MarkerStyle.""" + fig, ax = _make_fig() + try: + # cap & join + h, _ = ax.catlegend( + ["A", "B"], + marker_capstyle=[CapStyle.round, CapStyle.butt], + marker_joinstyle=[JoinStyle.miter, JoinStyle.bevel], + add=False, + ) + h[0]._marker.get_capstyle() == CapStyle.round + h[0]._marker.get_joinstyle() == JoinStyle.miter + h[1]._marker.get_capstyle() == CapStyle.butt + h[1]._marker.get_joinstyle() == JoinStyle.bevel + + # transform (rotation) + h, _ = ax.catlegend( + ["0°", "45°"], + marker_transform=[ + mtransforms.Affine2D().rotate_deg(0), + mtransforms.Affine2D().rotate_deg(45), + ], + add=False, + ) + h[0]._marker.get_transform().get_matrix()[ + :2, :2 + ] == mtransforms.Affine2D().rotate_deg(0).get_matrix()[:2, :2] + h[1]._marker.get_transform().get_matrix()[ + :2, :2 + ] == mtransforms.Affine2D().rotate_deg(45).get_matrix()[:2, :2] + + # combined with fillstyle and markerfacecoloralt + h, _ = ax.catlegend( + ["left", "right"], + marker="o", + markersize=25, + markerfacecolor="tab:blue", + markerfacecoloralt="lightsteelblue", + fillstyle=["left", "right"], + marker_capstyle=CapStyle.round, + marker_joinstyle="round", + add=False, + ) + assert len(h) == 2 + # Check each handle + for hh, expected_fillstyle in zip(h, ["left", "right"]): + # MarkerStyle creation + m = hh._marker + assert isinstance(m, MarkerStyle) + assert m.get_capstyle() == CapStyle.round + # 'round' string should be converted to JoinStyle.round by MarkerStyle + assert m.get_joinstyle() == JoinStyle.round + + # Check Line2D properties + assert hh.get_markersize() == 25 + assert np.allclose( + mcolors.to_rgba(hh.get_markerfacecolor()), mcolors.to_rgba("tab:blue") + ) + assert hh.get_markerfacecoloralt() == "lightsteelblue" + assert hh.get_fillstyle() == expected_fillstyle + finally: + uplt.close(fig) + + +# ----------------------------------------------------------------------------- +# Validation of forbidden legend kwargs +# ----------------------------------------------------------------------------- +def test_forbidden_legend_kwargs(): + """Passing 'label' or 'labels' to semantic helpers raises TypeError.""" + fig, ax = _make_fig() + try: + with pytest.raises(TypeError, match=r"Use title=\.\.\. for the legend title"): + ax.catlegend(["A"], label="Legend", add=True) + with pytest.raises( + TypeError, match="does not accept the legend kwarg 'labels'" + ): + ax.catlegend(["A"], labels=["x"], add=True) + finally: + uplt.close(fig) + + +# ----------------------------------------------------------------------------- +# Patch aliases and styles (numlegend, geolegend) +# ----------------------------------------------------------------------------- +def test_patch_aliases_and_styles(): + """numlegend and geolegend accept Patch aliases (fc, ec, ls, lw).""" + fig, ax = _make_fig() + try: + # numlegend with aliases + h, _ = ax.numlegend( + [1, 2], + vmin=0, + vmax=2, + fc=["red", "green"], + ec="black", + ls=":", + lw=1.5, + add=False, + ) + assert np.allclose(h[0].get_facecolor()[:3], mcolors.to_rgb("red")) + assert np.allclose(h[1].get_facecolor()[:3], mcolors.to_rgb("green")) + assert h[0].get_edgecolor()[:3] == (0, 0, 0) + assert h[0].get_linestyle() == ":" + assert h[0].get_linewidth() == 1.5 + + # geolegend shape existence + handles, labels = ax.geolegend( + [("Triangle", "triangle"), ("Hex", "hexagon")], add=False + ) + assert labels == ["Triangle", "Hex"] + assert all(isinstance(hh, mpatches.PathPatch) for hh in handles) + finally: + uplt.close(fig) + + +# ----------------------------------------------------------------------------- +# Linestyle auto-enables line +# ----------------------------------------------------------------------------- +def test_linestyle_auto_enable_line(): + """Providing a non-default linestyle automatically enables line=True.""" + fig, ax = _make_fig() + try: + h, _ = ax.catlegend(["A", "B"], ls="--", add=False) + for hh in h: + assert hh.get_linestyle() == "--" + # when line is enabled, marker becomes None + assert hh.get_marker() == uplt.rc["legend.cat.marker"] + finally: + uplt.close(fig) + + +# ----------------------------------------------------------------------------- +# geolegend: per‑entry lists +# ----------------------------------------------------------------------------- +def test_geolegend_per_entry_lists(): + """geolegend applies per-entry styles from lists (facecolor, edgecolor, linewidth, alpha, fill).""" + fig, ax = _make_fig() + try: + handles, labels = ax.geolegend( + ["box", "tri", "hex"], + facecolor=["tab:red", "tab:green", "tab:blue"], + edgecolor=["black", "gray", "white"], + linewidth=[1.0, 2.0, 3.0], + alpha=[0.5, 0.7, 1.0], + fill=[True, False, True], + add=False, + ) + assert len(handles) == 3 + assert labels == ["box", "tri", "hex"] + + # Check per-entry properties + expected_fc = ["tab:red", "tab:green", "tab:blue"] # None for fill=False + expected_ec = ["black", "gray", "white"] + expected_lw = [1.0, 2.0, 3.0] + expected_alpha = [0.5, 0.7, 1.0] + expected_fill = [True, False, True] + + for i, h in enumerate(handles): + assert isinstance(h, mpatches.PathPatch) + if expected_fill[i]: + assert np.allclose( + h.get_facecolor(), + mcolors.to_rgba(expected_fc[i], expected_alpha[i]), + ) + else: + # for fill=False, facecolor is preserved, and set alpha=0 + assert np.allclose( + mcolors.to_rgba(h.get_facecolor()[:3], 0), + mcolors.to_rgba(expected_fc[i], 0), + ) + assert np.allclose( + h.get_edgecolor(), mcolors.to_rgba(expected_ec[i], expected_alpha[i]) + ) + assert h.get_linewidth() == pytest.approx(expected_lw[i]) + assert h.get_alpha() == expected_alpha[i] + assert h.get_fill() == expected_fill[i] + finally: + uplt.close(fig) + + +# ----------------------------------------------------------------------------- +# geolegend: per‑entry dicts +# ----------------------------------------------------------------------------- +def test_geolegend_per_entry_dicts(): + """geolegend applies per-entry styles from dicts.""" + fig, ax = _make_fig() + try: + handles, labels = ax.geolegend( + ["box", "tri", "hex"], + facecolor={"box": "red", "tri": "green", "hex": "blue"}, + edgecolor={"box": "black", "tri": "gray", "hex": "white"}, + linewidth={"box": 1.0, "tri": 2.0, "hex": 3.0}, + alpha={"box": 0.5, "tri": 0.7, "hex": 1.0}, + fill={"box": True, "tri": False, "hex": True}, + add=False, + ) + assert len(handles) == 3 + assert labels == ["box", "tri", "hex"] + + expected = { + "box": ("red", "black", 1.0, 0.5, True), + "tri": ("green", "gray", 2.0, 0.7, False), + "hex": ("blue", "white", 3.0, 1.0, True), + } + for h, label in zip(handles, labels): + fc, ec, lw, alpha, fill = expected[label] + if fill: + assert np.allclose(h.get_facecolor(), mcolors.to_rgba(fc, alpha)) + else: + # for fill=False, facecolor is preserved, and set alpha=0 + assert np.allclose( + mcolors.to_rgba(h.get_facecolor()[:3], 0), mcolors.to_rgba(fc, 0) + ) + assert np.allclose(h.get_edgecolor(), mcolors.to_rgba(ec, alpha)) + assert h.get_linewidth() == pytest.approx(lw) + assert h.get_alpha() == alpha + assert h.get_fill() == fill + finally: + uplt.close(fig) + + +# ----------------------------------------------------------------------------- +# geolegend: alias support +# ----------------------------------------------------------------------------- +def test_geolegend_alias_support(): + """geolegend accepts aliases fc, ec, lw, ls, etc.""" + fig, ax = _make_fig() + try: + handles, _ = ax.geolegend( + ["box", "tri"], + fc=["red", "green"], # alias for facecolor + ec=["black", "blue"], # alias for edgecolor + lw=2.0, # alias for linewidth + ls="--", # alias for linestyle + add=False, + ) + assert len(handles) == 2 + # First geometry + h0 = handles[0] + assert np.allclose(h0.get_facecolor(), mcolors.to_rgba("red")) + assert np.allclose(h0.get_edgecolor(), mcolors.to_rgba("black")) + assert h0.get_linewidth() == 2.0 + assert h0.get_linestyle() == "--" + # Second geometry + h1 = handles[1] + assert np.allclose(h1.get_facecolor(), mcolors.to_rgba("green")) + assert np.allclose(h1.get_edgecolor(), mcolors.to_rgba("blue")) + finally: + uplt.close(fig) + + +# ----------------------------------------------------------------------------- +# geolegend: explicit parameter overrides alias (no conflict error) +# ----------------------------------------------------------------------------- +def test_geolegend_explicit_overrides_alias(): + """Explicit facecolor parameter overrides alias fc.""" + fig, ax = _make_fig() + try: + # facecolor='red' (explicit) vs fc='blue' (alias) → explicit wins + handles, _ = ax.geolegend( + ["box"], + facecolor="red", + fc="blue", + add=False, + ) + h = handles[0] + assert np.allclose(h.get_facecolor(), mcolors.to_rgba("red")) + # edgecolor explicit vs ec + handles, _ = ax.geolegend( + ["box"], + edgecolor="green", + ec="black", + add=False, + ) + h = handles[0] + assert np.allclose(h.get_edgecolor(), mcolors.to_rgba("green")) + finally: + uplt.close(fig) + + +# ----------------------------------------------------------------------------- +# geolegend: per-entry scalar applied to all +# ----------------------------------------------------------------------------- +def test_geolegend_scalar_applied_to_all(): + """Scalar styles are applied to all geometry entries.""" + fig, ax = _make_fig() + try: + handles, _ = ax.geolegend( + ["box", "tri", "hex"], + facecolor="cyan", + edgecolor="black", + linewidth=2.5, + alpha=0.6, + fill=True, + add=False, + ) + for h in handles: + assert np.allclose(h.get_facecolor(), mcolors.to_rgba("cyan", 0.6)) + assert np.allclose(h.get_edgecolor(), mcolors.to_rgba("black", 0.6)) + assert h.get_linewidth() == pytest.approx(2.5) + assert h.get_alpha() == 0.6 + assert h.get_fill() == True + finally: + uplt.close(fig)