diff --git a/ultraplot/legend.py b/ultraplot/legend.py index c8c5c579d..893377285 100644 --- a/ultraplot/legend.py +++ b/ultraplot/legend.py @@ -9,9 +9,11 @@ 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 @@ -91,8 +93,31 @@ def __init__( markeredgecolor=None, markeredgewidth=None, alpha=None, + marker_capstyle=None, + marker_joinstyle=None, + marker_transform=None, **kwargs, ): + # extract fillstyle,to avoid conflict with MarkerStyle + 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 + ): + if 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: @@ -772,11 +797,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. @@ -834,29 +855,131 @@ 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 color (including RGBA tuples). + + For tuple/list, if its length is 3 or 4 and each element is a number + strictly in the range [0, 1], it is treated as a color rather than a style list. + """ + if value is None: + return False + elif np.isscalar(value): + return True + # matplotlib's is_color_like can already handle tuples like (1, 0, 0.5) + # But we additionally check for numeric sequences with values in [0, 1] + # to avoid misidentifying coordinate pairs or other numeric lists as colors. + elif isinstance(value, tuple): + if len(value) in (3, 4): + # Ensure all elements are numbers within [0, 1] + if all(isinstance(v, (int, float)) and 0.0 <= v <= 1.0 for v in value): + print( + f"Tuple {value} treated as a single color. Pass a list to apply per entry." + ) + return True + # List shouldn't be converted to color, to prevent confusion. + if isinstance(value, list): + return False + return _mpl_is_color_like(value) + + +# Line2D / LegendEntry alias mapping +_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", + # "ec": "markeredgecolor", # Compatible with 'ec' in Line2D context + # "fc": "markerfacecolor", # Compatible with 'fc' in Line2D context +} + +# 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): @@ -867,7 +990,10 @@ def _style_lookup(style, key, index, default=None): return style if not values: return default - return values[index % len(values)] + val = values[index % len(values)] + if check_color and _is_color_like(val): + return val + return val def _format_label(value, fmt): @@ -901,24 +1027,67 @@ def _default_cycle_colors(): "linestyles": "linestyle", "linewidths": "markeredgewidth", "sizes": "markersize", + "size": "markersize", } 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. + Supports: + - Aliases (like 'c', 'ls', 'lw', 'mec', etc.) are automatically converted to full names + - Plural collection parameters (like 'colors', 'edgecolors') are converted to singular + - Full name parameters take precedence over aliases """ + # 1. Pop marker advanced properties BEFORE _pop_props so they are not swallowed + advanced_marker = {} + for key in ("marker_capstyle", "marker_joinstyle", "marker_transform"): + if key in kwargs: + advanced_marker[key] = kwargs.pop(key) + + # 2. Extract and resolve aliases (pop alias keys, map to full names) + resolved_aliases = {} + for alias in list(kwargs.keys()): + if alias in _LINE_ALIAS_MAP: + full_key = _LINE_ALIAS_MAP[alias] + resolved_aliases[full_key] = kwargs.pop(alias) + + # 3. Extract explicit collection-style plural parameters (like 'colors', 'edgecolors') explicit_collection = {} for key in _ENTRY_STYLE_FROM_COLLECTION: if key in kwargs: explicit_collection[key] = kwargs.pop(key) + + # 4. Use ultraplot's internal _pop_props to extract 'line' and 'collection' category properties props = _pop_props(kwargs, "line") collection_props = _pop_props(kwargs, "collection") collection_props.update(explicit_collection) + + # 5. Map collection plural parameters to singular property names + # only if the singular name is not already set) 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 + + # 6. Merge resolved aliases (aliases have lowest priority, + # do not overwrite existing full-name parameters) + for full_key, value in resolved_aliases.items(): + if full_key not in props: + props[full_key] = value + + # 7. Put back the advanced marker properties + props.update(advanced_marker) + # 8. Grab any remaining kwargs that are valid Line2D setters + for key in list(kwargs.keys()): + # without this, line 645 of test_legend.py won't pass + if key in ("labels", "label"): + continue + if key.startswith("_"): + continue + if hasattr(mlines.Line2D, "set_" + key): + props[key] = kwargs.pop(key) + return props @@ -935,6 +1104,13 @@ def _pop_num_props(kwargs: dict[str, Any]) -> dict[str, Any]: """ Pop patch/collection style aliases for numeric semantic legend entries. """ + # Resolve aliases first + resolved = {} + for key in list(kwargs.keys()): + if key in _PATCH_ALIAS_MAP: + full_key = _PATCH_ALIAS_MAP[key] + resolved[full_key] = kwargs.pop(key) + explicit_collection = {} for key in _NUM_STYLE_FROM_COLLECTION: if key in kwargs: @@ -946,6 +1122,11 @@ def _pop_num_props(kwargs: dict[str, Any]) -> dict[str, Any]: 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.items(): + if full_key not in props: + props[full_key] = value + return props @@ -959,17 +1140,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, @@ -980,9 +1161,6 @@ def _cat_legend_entries( markerfacecolor=None, **entry_kwargs, ): - """ - Build categorical semantic legend handles and labels. - """ labels = list(dict.fromkeys(categories)) palette = _default_cycle_colors() base_styles = { @@ -999,18 +1177,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, ) ) @@ -1169,8 +1356,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 @@ -1334,7 +1525,7 @@ class Legend(mlegend.Legend): # The user may change the location and the legend_dict should # be updated accordingly. This caused an issue where # a legend format was not behaving according to the docs - def __init__(self, *args, **kwargs): + def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @classmethod @@ -1425,55 +1616,34 @@ 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. - """ - styles = dict(handle_kw or {}) - styles.update(_pop_entry_props(styles)) + styles = {} + if handle_kw: + styles.update( + _pop_entry_props(handle_kw) + ) # Handle explicit handle_kw first + 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, @@ -1490,71 +1660,57 @@ 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, # Originally 'colors', change to singular form + marker=None, # Originally 'markers', change to singular form 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. """ - styles = dict(handle_kw or {}) - styles.update(_pop_entry_props(styles)) + # Merge handle_kw with auto-extracted styles + styles = {} + if handle_kw: + styles.update( + _pop_entry_props(handle_kw) + ) # Handle explicit handle_kw first + styles.update( + _pop_entry_props(kwargs) + ) # Alias-to-full-name conversion happens here + + # Apply rc default values 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, @@ -1567,10 +1723,9 @@ 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) + # Handle Patch styles and plural aliases + self._validate_semantic_kwargs("catlegend", kwargs) + return self.axes.legend(handles, labels, **kwargs) def sizelegend( self, @@ -1583,40 +1738,30 @@ 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. - """ - styles = dict(handle_kw or {}) - styles.update(_pop_entry_props(styles)) + styles = {} + if handle_kw: + styles.update( + _pop_entry_props(handle_kw) + ) # Handle explicit handle_kw first + 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, @@ -1634,8 +1779,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, @@ -1654,30 +1799,28 @@ 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. - """ - styles = dict(handle_kw or {}) - styles.update(_pop_num_props(styles)) + styles = {} + if handle_kw: + styles.update(_pop_num_props(handle_kw)) # Handle explicit handle_kw first + styles.update(_pop_num_props(kwargs)) # Handle Patch styles and plural aliases + 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 may include 'hatch', 'joinstyle', 'capstyle', 'fill', etc. handles, labels = _num_legend_entries( levels=levels, vmin=vmin, @@ -1693,10 +1836,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, @@ -1712,55 +1856,58 @@ 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. - """ - 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"]) + # Geolegend can accept Patch styles (linestyle, hatch, etc.), similar to numlegend + 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"]) + + # Build final patch kw dict (includes remaining properties like hatch, linestyle) + patch_kw = { + "facecolor": facecolor, + "edgecolor": edgecolor, + "linewidth": linewidth, + "alpha": alpha, + "fill": fill, + } + patch_kw.update(styles) # any leftover keys (hatch, linestyle, joinstyle, etc.) + country_reso = _not_none(country_reso, rc["legend.geo.country_reso"]) - country_territories = _not_none( - country_territories, rc["legend.geo.country_territories"] - ) + 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"]) + + # Additional styles (e.g., linestyle, hatch, joinstyle) are merged later 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_sematic_legend.py b/ultraplot/tests/test_sematic_legend.py new file mode 100644 index 000000000..8d18c5315 --- /dev/null +++ b/ultraplot/tests/test_sematic_legend.py @@ -0,0 +1,499 @@ +""" +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)