From 8ecf89ea46310b7487f67003f19a01fb737b16c5 Mon Sep 17 00:00:00 2001 From: gepcel Date: Mon, 18 May 2026 14:11:50 +0800 Subject: [PATCH 01/10] unify sematic legend args --- ultraplot/legend.py | 376 ++++++++++++++----------- ultraplot/tests/test_sematic_legend.py | 376 +++++++++++++++++++++++++ 2 files changed, 592 insertions(+), 160 deletions(-) create mode 100644 ultraplot/tests/test_sematic_legend.py diff --git a/ultraplot/legend.py b/ultraplot/legend.py index c8c5c579d..0227381bc 100644 --- a/ultraplot/legend.py +++ b/ultraplot/legend.py @@ -9,6 +9,7 @@ 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 @@ -851,23 +852,101 @@ def _geo_legend_entries( 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): + """ + 判断一个值是否可被解释为颜色(包括 RGBA 元组)。 + + 对于 tuple/list,若其长度为 3 或 4 且每个元素都是 0-1 之间的数字, + 则视为颜色而非样式列表。 + """ + if value is None: + return False + # matplotlib 的 is_color_like 本身就能处理 (1, 0, 0.5) 这样的 tuple + # 但为了更精确,我们额外检查 tuple/list 的特殊情况 + if isinstance(value, (tuple, list)): + # 长度为 3 或 4 的数字序列视为颜色 + if len(value) in (3, 4) and all(isinstance(v, (int, float)) for v in value): + return True + return _mpl_is_color_like(value) + + +# Line2D / LegendEntry 别名映射 +_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", # 兼容 Line2D 上下文中的 ec + # "fc": "markerfacecolor", # 兼容 Line2D 上下文中的 fc +} + +# Patch 别名映射 +_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): return style + try: values = list(style) except TypeError: 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 +980,47 @@ 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. + 从 kwargs 中提取 LegendEntry 样式属性。 + 支持: + - 别名(如 'c', 'ls', 'lw', 'mec' 等)自动转换为全名 + - 复数形式的集合参数(如 'colors', 'edgecolors')转换为单数 + - 全名参数优先级高于别名 """ + # 1. 提取并解析别名(弹出别名键,映射为全名) + 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) + + # 2. 提取显式的集合类复数参数(如 'colors', 'edgecolors') explicit_collection = {} for key in _ENTRY_STYLE_FROM_COLLECTION: if key in kwargs: explicit_collection[key] = kwargs.pop(key) + + # 3. 用 ultraplot 内部的 _pop_props 提取 'line' 和 'collection' 分类属性 props = _pop_props(kwargs, "line") collection_props = _pop_props(kwargs, "collection") collection_props.update(explicit_collection) + + # 4. 将集合类复数参数映射到单数属性名(仅当单数名尚未设置时) 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 + + # 5. 合并别名解析结果(别名优先级最低,不覆盖已存在的全名参数) + for full_key, value in resolved_aliases.items(): + if full_key not in props: + props[full_key] = value + return props @@ -935,6 +1037,13 @@ def _pop_num_props(kwargs: dict[str, Any]) -> dict[str, Any]: """ Pop patch/collection style aliases for numeric semantic legend entries. """ + # 先解析别名 + 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 +1055,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 +1073,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 +1094,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 +1110,26 @@ 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) + + # 如果 line=False 但用户提供了非默认线型,自动启用 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 +1288,8 @@ 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 @@ -1425,55 +1544,28 @@ 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.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"], - ) - markersize = _not_none( - markersize, - styles.pop("markersize", None), - rc["legend.cat.markersize"], - ) - alpha = _not_none(alpha, styles.pop("alpha", None), rc["legend.cat.alpha"]) + 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(styles.pop("markersize", None), rc["legend.cat.markersize"]) + 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, @@ -1488,73 +1580,51 @@ def entrylegend( markerfacecolor=markerfacecolor, styles=styles, ) + 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, # 原 colors,单数形式 + marker=None, # 原 markers,单数形式 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. """ + # 合并 handle_kw 与自动提取的样式 styles = dict(handle_kw or {}) - styles.update(_pop_entry_props(styles)) + styles.update(_pop_entry_props(kwargs)) # 此处完成别名→全名转换 + + # 应用 rc 默认值 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"], - ) - markersize = _not_none( - markersize, - styles.pop("markersize", None), - rc["legend.cat.markersize"], - ) - alpha = _not_none(alpha, styles.pop("alpha", None), rc["legend.cat.alpha"]) + 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(styles.pop("markersize", None), rc["legend.cat.markersize"]) + 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) + + # 剩余 styles 会作为额外 entry 属性(如 'markerfacecoloralt')传入 _cat_legend_entries handles, labels = _cat_legend_entries( categories, - colors=colors, - markers=markers, + color=color, + marker=marker, line=line, linestyle=linestyle, linewidth=linewidth, @@ -1565,12 +1635,13 @@ def catlegend( markerfacecolor=markerfacecolor, **styles, ) + 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) + + # 确保没有冲突的整体 legend 参数 + self._validate_semantic_kwargs("catlegend", kwargs) + return self.axes.legend(handles, labels, **kwargs) def sizelegend( self, @@ -1583,40 +1654,28 @@ 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.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, @@ -1632,10 +1691,11 @@ def sizelegend( markerfacecolor=markerfacecolor, **styles, ) + 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 +1714,26 @@ 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.update(_pop_num_props(kwargs)) # 处理 Patch 样式及复数别名 + 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"]) + + # 剩余 styles 可包含 'hatch', 'joinstyle', 'capstyle', 'fill' 等 handles, labels = _num_legend_entries( levels=levels, vmin=vmin, @@ -1693,10 +1749,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,20 +1769,14 @@ 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. - """ + # 几何图例可接收 Patch 样式(linestyle, hatch 等),与 numlegend 类似 + styles = dict(handle_kw or {}) + styles.update(_pop_num_props(kwargs)) + facecolor = _not_none(facecolor, rc["legend.geo.facecolor"]) edgecolor = _not_none(edgecolor, rc["legend.geo.edgecolor"]) linewidth = _not_none(linewidth, rc["legend.geo.linewidth"]) @@ -1737,6 +1788,8 @@ def geolegend( ) country_proj = _not_none(country_proj, rc["legend.geo.country_proj"]) handlesize = _not_none(handlesize, rc["legend.geo.handlesize"]) + + # 额外样式(如 linestyle, hatch, joinstyle)合并到后面 handles, labels = _geo_legend_entries( entries, labels=labels, @@ -1748,19 +1801,22 @@ def geolegend( linewidth=linewidth, alpha=alpha, fill=fill, + **styles, # 额外的 Patch 属性 ) + 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..74d5df681 --- /dev/null +++ b/ultraplot/tests/test_sematic_legend.py @@ -0,0 +1,376 @@ +""" +Unit tests for semantic legend style aliases and color detection. +""" +import matplotlib +matplotlib.use('Agg') # Must be before any other matplotlib import for local test +import numpy as np +import pytest +from matplotlib import colors as mcolors + +import ultraplot as uplt + + +# ----------------------------------------------------------------------------- +# Color detection +# ----------------------------------------------------------------------------- +def test_catlegend_rgba_tuple_is_color(): + """RGBA tuple like (1, 0, 0.5, 0.5) is treated as a single color.""" + fig, ax = uplt.subplots() + try: + ax.axis("off") + handles, _ = ax.catlegend( + list("ABC"), color=(0.2, 0.4, 0.6, 0.8), add=False + ) + colors = [h.get_color() for h in handles] + assert all(c == colors[0] for c in colors), ( + f"All entries should share the same color, got {colors}" + ) + finally: + uplt.close(fig) + + +def test_catlegend_rgba_list_of_tuples(): + """List of RGBA tuples is treated as a per‑entry color list.""" + c1 = (1.0, 0.0, 0.0, 1.0) + c2 = (0.0, 1.0, 0.0, 1.0) + c3 = (0.0, 0.0, 1.0, 1.0) + fig, ax = uplt.subplots() + try: + ax.axis("off") + handles, _ = ax.catlegend(list("ABC"), color=[c1, c2, c3], add=False) + assert handles[0].get_color() == c1 + assert handles[1].get_color() == c2 + assert handles[2].get_color() == c3 + finally: + uplt.close(fig) + + +def test_numlegend_facecolor_rgba_tuple_is_color(): + """RGBA facecolor for numlegend is not mistaken for a list.""" + fig, ax = uplt.subplots() + try: + ax.axis("off") + handles, _ = ax.numlegend( + [1, 2, 3], vmin=0, vmax=4, + facecolor=(0.8, 0.2, 0.3, 0.6), add=False + ) + ref = np.array(handles[0].get_facecolor()) + for h in handles: + assert np.allclose(np.array(h.get_facecolor()), ref), ( + "All patches should have identical facecolor" + ) + finally: + uplt.close(fig) + + +# ----------------------------------------------------------------------------- +# Line2D style aliases (catlegend) +# ----------------------------------------------------------------------------- +def test_alias_c_color(): + """'c' is an alias for 'color'.""" + fig, ax = uplt.subplots() + try: + ax.axis("off") + handles, _ = ax.catlegend(list("AB"), c="red", add=False) + for h in handles: + assert h.get_color() == "red" + finally: + uplt.close(fig) + + +def test_alias_m_marker(): + """'m' is an alias for 'marker'.""" + fig, ax = uplt.subplots() + try: + ax.axis("off") + handles, _ = ax.catlegend(list("AB"), m="^", add=False) + for h in handles: + assert h.get_marker() == "^" + finally: + uplt.close(fig) + + +def test_alias_ms_markersize_list(): + """'ms' can be a list that cycles through entries.""" + fig, ax = uplt.subplots() + try: + ax.axis("off") + handles, _ = ax.catlegend(list("ABCD"), ms=[10, 20], add=False) + assert handles[0].get_markersize() == 10 + assert handles[1].get_markersize() == 20 + assert handles[2].get_markersize() == 10 # wraps around + finally: + uplt.close(fig) + + +def test_alias_ls_linestyle(): + """'ls' is an alias for 'linestyle'.""" + fig, ax = uplt.subplots() + try: + ax.axis("off") + handles, _ = ax.catlegend(list("AB"), ls="--", add=False) + for h in handles: + assert h.get_linestyle() == "--" + finally: + uplt.close(fig) + + +def test_alias_lw_linewidth(): + """'lw' is an alias for 'linewidth'.""" + fig, ax = uplt.subplots() + try: + ax.axis("off") + handles, _ = ax.catlegend(list("AB"), lw=3.0, add=False) + for h in handles: + assert h.get_linewidth() == 3.0 + finally: + uplt.close(fig) + + +def test_alias_mec_markeredgecolor(): + """'mec' is an alias for 'markeredgecolor'.""" + fig, ax = uplt.subplots() + try: + ax.axis("off") + handles, _ = ax.catlegend(list("AB"), mec="blue", add=False) + for h in handles: + assert h.get_markeredgecolor() == "blue" + finally: + uplt.close(fig) + + +def test_alias_mew_markeredgewidth(): + """'mew' is an alias for 'markeredgewidth'.""" + fig, ax = uplt.subplots() + try: + ax.axis("off") + handles, _ = ax.catlegend(list("AB"), mew=2.0, add=False) + for h in handles: + assert h.get_markeredgewidth() == 2.0 + finally: + uplt.close(fig) + + +def test_alias_mfc_markerfacecolor(): + """'mfc' is an alias for 'markerfacecolor'.""" + fig, ax = uplt.subplots() + try: + ax.axis("off") + handles, _ = ax.catlegend(list("AB"), mfc="yellow", add=False) + for h in handles: + assert h.get_markerfacecolor() == "yellow" + finally: + uplt.close(fig) + + +def test_alias_mfcalt_markerfacecoloralt(): + """'mfcalt' is an alias for 'markerfacecoloralt'.""" + fig, ax = uplt.subplots() + try: + ax.axis("off") + handles, _ = ax.catlegend(list("AB"), mfcalt="orange", add=False) + for h in handles: + assert h.get_markerfacecoloralt() == "orange" + finally: + uplt.close(fig) + + +def test_alias_aa_antialiased(): + """'aa' is an alias for 'antialiased'.""" + fig, ax = uplt.subplots() + try: + ax.axis("off") + handles, _ = ax.catlegend(list("AB"), aa=False, add=False) + for h in handles: + assert h.get_antialiased() is False + finally: + uplt.close(fig) + + +def test_alias_fs_fillstyle(): + """'fs' is an alias for 'fillstyle'.""" + fig, ax = uplt.subplots() + try: + ax.axis("off") + handles, _ = ax.catlegend(list("AB"), fs="none", add=False) + for h in handles: + assert h.get_fillstyle() == "none" + finally: + uplt.close(fig) + + +# ----------------------------------------------------------------------------- +# Patch style aliases (numlegend) +# ----------------------------------------------------------------------------- +def test_numlegend_alias_fc_facecolor(): + """'fc' is an alias for 'facecolor' in numlegend.""" + fig, ax = uplt.subplots() + try: + ax.axis("off") + handles, _ = ax.numlegend( + [1, 2, 3], vmin=0, vmax=4, fc="lightblue", add=False + ) + for h in handles: + assert h.get_facecolor()[:3] == mcolors.to_rgb("lightblue") + finally: + uplt.close(fig) + + +def test_numlegend_alias_ec_edgecolor(): + """'ec' is an alias for 'edgecolor' in numlegend.""" + fig, ax = uplt.subplots() + try: + ax.axis("off") + handles, _ = ax.numlegend( + [1, 2, 3], vmin=0, vmax=4, ec="black", add=False + ) + for h in handles: + assert h.get_edgecolor()[:3] == (0.0, 0.0, 0.0) + finally: + uplt.close(fig) + + +def test_numlegend_alias_ls_linestyle(): + """'ls' is an alias for 'linestyle' in numlegend.""" + fig, ax = uplt.subplots() + try: + ax.axis("off") + handles, _ = ax.numlegend( + [1, 2, 3], vmin=0, vmax=4, ls=":", add=False + ) + for h in handles: + assert h.get_linestyle() == ":" + finally: + uplt.close(fig) + + +def test_numlegend_alias_lw_linewidth(): + """'lw' is an alias for 'linewidth' in numlegend.""" + fig, ax = uplt.subplots() + try: + ax.axis("off") + handles, _ = ax.numlegend( + [1, 2, 3], vmin=0, vmax=4, lw=1.5, add=False + ) + for h in handles: + assert h.get_linewidth() == 1.5 + finally: + uplt.close(fig) + + +# ----------------------------------------------------------------------------- +# Alias priority & dict styles +# ----------------------------------------------------------------------------- +def test_alias_and_fullname_priority(): + """Full name should override its alias.""" + fig, ax = uplt.subplots() + try: + ax.axis("off") + handles, _ = ax.catlegend( + list("AB"), markersize=15, ms=99, add=False + ) + for h in handles: + assert h.get_markersize() == 15 + finally: + uplt.close(fig) + + +def test_alias_dict_style(): + """Aliases work with dictionary-based per‑label styles.""" + fig, ax = uplt.subplots() + try: + ax.axis("off") + handles, _ = ax.catlegend( + list("ABC"), + c={"A": "red", "B": "green", "C": "blue"}, + ms={"A": 10, "B": 20, "C": 30}, + add=False, + ) + assert handles[0].get_color() == "red" + assert handles[1].get_color() == "green" + assert handles[2].get_color() == "blue" + assert handles[0].get_markersize() == 10 + assert handles[1].get_markersize() == 20 + assert handles[2].get_markersize() == 30 + finally: + uplt.close(fig) + + +# ----------------------------------------------------------------------------- +# sizelegend alias support +# ----------------------------------------------------------------------------- +def test_sizelegend_alias_c(): + """sizelegend accepts 'c' as color alias.""" + fig, ax = uplt.subplots() + try: + ax.axis("off") + handles, _ = ax.sizelegend([1, 2, 3], c="purple", add=False) + for h in handles: + assert h.get_color() == "purple" + finally: + uplt.close(fig) + + +def test_sizelegend_alias_mec(): + """sizelegend accepts 'mec' for markeredgecolor.""" + fig, ax = uplt.subplots() + try: + ax.axis("off") + handles, _ = ax.sizelegend([1, 2, 3], mec="green", add=False) + for h in handles: + assert h.get_markeredgecolor() == "green" + finally: + uplt.close(fig) + +def test_catlegend_ms_length_three_is_not_color(): + """ms list of length 3 should be treated as per‑entry markersize, not a color.""" + fig, ax = uplt.subplots() + try: + ax.axis("off") + handles, _ = ax.catlegend(list("abc"), ms=[10, 20, 30], add=False) + assert handles[0].get_markersize() == 10 + assert handles[1].get_markersize() == 20 + assert handles[2].get_markersize() == 30 + finally: + uplt.close(fig) + + +def test_catlegend_lw_length_three(): + """Linewidth list of length 3 should work.""" + fig, ax = uplt.subplots() + try: + ax.axis("off") + handles, _ = ax.catlegend(list("abc"), lw=[1.5, 2.5, 3.5], line=True, add=False) + assert handles[0].get_linewidth() == 1.5 + assert handles[1].get_linewidth() == 2.5 + assert handles[2].get_linewidth() == 3.5 + finally: + uplt.close(fig) + + +def test_catlegend_alpha_length_three(): + """Alpha list of length 3 should be per‑entry, not mistaken for a color.""" + fig, ax = uplt.subplots() + try: + ax.axis("off") + handles, _ = ax.catlegend(list("abc"), alpha=[0.2, 0.5, 0.8], add=False) + assert handles[0].get_alpha() == 0.2 + assert handles[1].get_alpha() == 0.5 + assert handles[2].get_alpha() == 0.8 + finally: + uplt.close(fig) + +def test_catlegend_color_as_list_of_rgba_tuples(): + """Color with list of RGBA tuples still works correctly.""" + c1 = (1.0, 0.0, 0.0, 1.0) + c2 = (0.0, 1.0, 0.0, 1.0) + c3 = (0.0, 0.0, 1.0, 1.0) + fig, ax = uplt.subplots() + try: + ax.axis("off") + handles, _ = ax.catlegend(list("abc"), color=[c1, c2, c3], add=False) + assert handles[0].get_color() == c1 + assert handles[1].get_color() == c2 + assert handles[2].get_color() == c3 + finally: + uplt.close(fig) From 3dd3084fea95268b48efe6c07c5d594744c30444 Mon Sep 17 00:00:00 2001 From: gepcel Date: Mon, 18 May 2026 14:32:00 +0800 Subject: [PATCH 02/10] Convert all Chinese comments to English --- ultraplot/legend.py | 73 +++++++++++++++++++++++---------------------- 1 file changed, 38 insertions(+), 35 deletions(-) diff --git a/ultraplot/legend.py b/ultraplot/legend.py index 0227381bc..c3c6d3ae0 100644 --- a/ultraplot/legend.py +++ b/ultraplot/legend.py @@ -862,23 +862,23 @@ def _geo_legend_entries( def _is_color_like(value): """ - 判断一个值是否可被解释为颜色(包括 RGBA 元组)。 - - 对于 tuple/list,若其长度为 3 或 4 且每个元素都是 0-1 之间的数字, - 则视为颜色而非样式列表。 + 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 between 0 and 1, + it is treated as a color rather than a style list. """ if value is None: return False - # matplotlib 的 is_color_like 本身就能处理 (1, 0, 0.5) 这样的 tuple - # 但为了更精确,我们额外检查 tuple/list 的特殊情况 + # matplotlib's is_color_like can already handle tuples like (1, 0, 0.5) + # But for better precision, we additionally check the special case of tuple/list if isinstance(value, (tuple, list)): - # 长度为 3 或 4 的数字序列视为颜色 + # Numeric sequences of length 3 or 4 are treated as colors if len(value) in (3, 4) and all(isinstance(v, (int, float)) for v in value): return True return _mpl_is_color_like(value) -# Line2D / LegendEntry 别名映射 +# Line2D / LegendEntry alias mapping _LINE_ALIAS_MAP = { "c": "color", "m": "marker", @@ -891,11 +891,11 @@ def _is_color_like(value): "mfcalt": "markerfacecoloralt", "aa": "antialiased", "fs": "fillstyle", - # "ec": "markeredgecolor", # 兼容 Line2D 上下文中的 ec - # "fc": "markerfacecolor", # 兼容 Line2D 上下文中的 fc + # "ec": "markeredgecolor", # Compatible with 'ec' in Line2D context + # "fc": "markerfacecolor", # Compatible with 'fc' in Line2D context } -# Patch 别名映射 +# Patch alias mapping _PATCH_ALIAS_MAP = { "c": "color", "fc": "facecolor", @@ -986,37 +986,39 @@ def _default_cycle_colors(): def _pop_entry_props(kwargs: dict[str, Any]) -> dict[str, Any]: """ - 从 kwargs 中提取 LegendEntry 样式属性。 - 支持: - - 别名(如 'c', 'ls', 'lw', 'mec' 等)自动转换为全名 - - 复数形式的集合参数(如 'colors', 'edgecolors')转换为单数 - - 全名参数优先级高于别名 + 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. 提取并解析别名(弹出别名键,映射为全名) + # 1. 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) - # 2. 提取显式的集合类复数参数(如 'colors', 'edgecolors') + # 2. 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) - # 3. 用 ultraplot 内部的 _pop_props 提取 'line' 和 'collection' 分类属性 + # 3. 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) - # 4. 将集合类复数参数映射到单数属性名(仅当单数名尚未设置时) + # 4. 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 - # 5. 合并别名解析结果(别名优先级最低,不覆盖已存在的全名参数) + # 5. 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 @@ -1037,7 +1039,7 @@ 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: @@ -1114,7 +1116,7 @@ def _cat_legend_entries( linestyle_value = styles.pop("linestyle", "-") marker_value = styles.pop("marker", None) - # 如果 line=False 但用户提供了非默认线型,自动启用 line=True + # 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 @@ -1590,8 +1592,8 @@ def catlegend( self, categories: Iterable[Any], *, - color=None, # 原 colors,单数形式 - marker=None, # 原 markers,单数形式 + color=None, # Originally 'colors', change to singular form + marker=None, # Originally 'markers', change to singular form line: Optional[bool] = None, handle_kw: Optional[dict[str, Any]] = None, add: bool = True, @@ -1600,11 +1602,11 @@ def catlegend( """ Build categorical legend entries and optionally draw a legend. """ - # 合并 handle_kw 与自动提取的样式 + # Merge handle_kw with auto-extracted styles styles = dict(handle_kw or {}) - styles.update(_pop_entry_props(kwargs)) # 此处完成别名→全名转换 + styles.update(_pop_entry_props(kwargs)) # Alias-to-full-name conversion happens here - # 应用 rc 默认值 + # Apply rc default values line = _not_none(line, styles.pop("line", None), rc["legend.cat.line"]) color = _not_none(color, styles.pop("color", None)) marker = _not_none(marker, styles.pop("marker", None), rc["legend.cat.marker"]) @@ -1620,7 +1622,8 @@ def catlegend( ) markerfacecolor = _not_none(styles.pop("markerfacecolor", None), None) - # 剩余 styles 会作为额外 entry 属性(如 'markerfacecoloralt')传入 _cat_legend_entries + # Remaining styles are passed as additional entry properties + # (e.g., 'markerfacecoloralt') to _cat_legend_entries handles, labels = _cat_legend_entries( categories, color=color, @@ -1639,7 +1642,7 @@ def catlegend( if not add: return handles, labels - # 确保没有冲突的整体 legend 参数 + # Handle Patch styles and plural aliases self._validate_semantic_kwargs("catlegend", kwargs) return self.axes.legend(handles, labels, **kwargs) @@ -1717,7 +1720,7 @@ def numlegend( **kwargs: Any, ): styles = dict(handle_kw or {}) - styles.update(_pop_num_props(kwargs)) # 处理 Patch 样式及复数别名 + 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"]) @@ -1733,7 +1736,7 @@ def numlegend( alpha = _not_none(alpha, styles.pop("alpha", None), rc["legend.num.alpha"]) fmt = _not_none(fmt, rc["legend.num.format"]) - # 剩余 styles 可包含 'hatch', 'joinstyle', 'capstyle', 'fill' 等 + # Remaining styles may include 'hatch', 'joinstyle', 'capstyle', 'fill', etc. handles, labels = _num_legend_entries( levels=levels, vmin=vmin, @@ -1773,7 +1776,7 @@ def geolegend( add: bool = True, **kwargs: Any, ): - # 几何图例可接收 Patch 样式(linestyle, hatch 等),与 numlegend 类似 + # Geolegend can accept Patch styles (linestyle, hatch, etc.), similar to numlegend styles = dict(handle_kw or {}) styles.update(_pop_num_props(kwargs)) @@ -1789,7 +1792,7 @@ def geolegend( country_proj = _not_none(country_proj, rc["legend.geo.country_proj"]) handlesize = _not_none(handlesize, rc["legend.geo.handlesize"]) - # 额外样式(如 linestyle, hatch, joinstyle)合并到后面 + # Additional styles (e.g., linestyle, hatch, joinstyle) are merged later handles, labels = _geo_legend_entries( entries, labels=labels, @@ -1801,7 +1804,7 @@ def geolegend( linewidth=linewidth, alpha=alpha, fill=fill, - **styles, # 额外的 Patch 属性 + **styles, # Additional Patch properties ) if not add: From 6b6221768a9a74186c8e8f6b7a85417963045c23 Mon Sep 17 00:00:00 2001 From: gepcel Date: Mon, 18 May 2026 14:42:44 +0800 Subject: [PATCH 03/10] Improve the color check logic --- ultraplot/legend.py | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/ultraplot/legend.py b/ultraplot/legend.py index c3c6d3ae0..2939095e9 100644 --- a/ultraplot/legend.py +++ b/ultraplot/legend.py @@ -864,17 +864,23 @@ 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 between 0 and 1, - it is treated as a color rather than a style list. + 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 # matplotlib's is_color_like can already handle tuples like (1, 0, 0.5) - # But for better precision, we additionally check the special case of tuple/list - if isinstance(value, (tuple, list)): - # Numeric sequences of length 3 or 4 are treated as colors - if len(value) in (3, 4) and all(isinstance(v, (int, float)) for v in value): - return True + # But we additionally check for numeric sequences with values in [0, 1] + # to avoid misidentifying coordinate pairs or other numeric lists as colors. + if 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 return _mpl_is_color_like(value) From 26b10869c60f6d890fd27cbf751da03c0bec29ee Mon Sep 17 00:00:00 2001 From: gepcel Date: Mon, 18 May 2026 14:50:36 +0800 Subject: [PATCH 04/10] Remove the extra blank lines. --- ultraplot/legend.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/ultraplot/legend.py b/ultraplot/legend.py index 2939095e9..b10e32f3b 100644 --- a/ultraplot/legend.py +++ b/ultraplot/legend.py @@ -934,21 +934,16 @@ def _style_lookup(style, key, index, default=None, *, prop=None): if check_color and _is_color_like(style): return style - if isinstance(style, dict): return style.get(key, default) - if isinstance(style, str): return style - try: values = list(style) except TypeError: return style - if not values: return default - val = values[index % len(values)] if check_color and _is_color_like(val): return val From 5ed72ff2a870c4e39392b22dbf72549848788ee8 Mon Sep 17 00:00:00 2001 From: gepcel Date: Mon, 18 May 2026 14:54:05 +0800 Subject: [PATCH 05/10] More extra blank lines. --- ultraplot/legend.py | 8 -------- 1 file changed, 8 deletions(-) diff --git a/ultraplot/legend.py b/ultraplot/legend.py index b10e32f3b..d595584c9 100644 --- a/ultraplot/legend.py +++ b/ultraplot/legend.py @@ -1583,7 +1583,6 @@ def entrylegend( markerfacecolor=markerfacecolor, styles=styles, ) - if not add: return handles, labels self._validate_semantic_kwargs("entrylegend", kwargs) @@ -1639,10 +1638,8 @@ def catlegend( markerfacecolor=markerfacecolor, **styles, ) - if not add: return handles, labels - # Handle Patch styles and plural aliases self._validate_semantic_kwargs("catlegend", kwargs) return self.axes.legend(handles, labels, **kwargs) @@ -1664,7 +1661,6 @@ def sizelegend( ): styles = dict(handle_kw or {}) 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"]) @@ -1679,7 +1675,6 @@ def sizelegend( styles.pop("markeredgewidth", None), rc["legend.size.markeredgewidth"] ) markerfacecolor = _not_none(styles.pop("markerfacecolor", None), None) - handles, labels = _size_legend_entries( levels, labels=labels, @@ -1695,7 +1690,6 @@ def sizelegend( markerfacecolor=markerfacecolor, **styles, ) - if not add: return handles, labels self._validate_semantic_kwargs("sizelegend", kwargs) @@ -1807,10 +1801,8 @@ def geolegend( fill=fill, **styles, # Additional Patch properties ) - if not add: return handles, labels - self._validate_semantic_kwargs("geolegend", kwargs) if handlesize is not None: handlesize = float(handlesize) From 0a03a6bee73d876f9e03e41303df20fa540af643 Mon Sep 17 00:00:00 2001 From: gepcel Date: Mon, 18 May 2026 15:11:23 +0800 Subject: [PATCH 06/10] I'm not sure, but I think list shouldn't be converted color. Otherwise, cann't avoid list be forced to convert. --- ultraplot/legend.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/ultraplot/legend.py b/ultraplot/legend.py index d595584c9..ad50bb1db 100644 --- a/ultraplot/legend.py +++ b/ultraplot/legend.py @@ -881,6 +881,9 @@ def _is_color_like(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) From 61be512735c1a244a077568b828589e16cc17737 Mon Sep 17 00:00:00 2001 From: gepcel Date: Mon, 18 May 2026 16:08:20 +0800 Subject: [PATCH 07/10] Handle explicit handle_kw first --- ultraplot/legend.py | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/ultraplot/legend.py b/ultraplot/legend.py index ad50bb1db..cb5c22d18 100644 --- a/ultraplot/legend.py +++ b/ultraplot/legend.py @@ -1554,7 +1554,9 @@ def entrylegend( add: bool = True, **kwargs: Any, ): - styles = dict(handle_kw or {}) + 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"]) @@ -1606,7 +1608,9 @@ def catlegend( Build categorical legend entries and optionally draw a legend. """ # Merge handle_kw with auto-extracted styles - styles = dict(handle_kw or {}) + 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 @@ -1662,7 +1666,9 @@ def sizelegend( add: bool = True, **kwargs: Any, ): - styles = dict(handle_kw or {}) + 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"]) @@ -1717,7 +1723,9 @@ def numlegend( add: bool = True, **kwargs: Any, ): - styles = dict(handle_kw or {}) + 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) From 97b690c6cedeaa1b9aee37f6501fb51d2056b6a1 Mon Sep 17 00:00:00 2001 From: gepcel Date: Mon, 18 May 2026 17:58:39 +0800 Subject: [PATCH 08/10] Support catstyle, joinstyle, and transform --- ultraplot/legend.py | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/ultraplot/legend.py b/ultraplot/legend.py index cb5c22d18..55e484387 100644 --- a/ultraplot/legend.py +++ b/ultraplot/legend.py @@ -13,6 +13,7 @@ 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 @@ -92,8 +93,21 @@ def __init__( markeredgecolor=None, markeredgewidth=None, alpha=None, + marker_capstyle=None, + marker_joinstyle=None, + marker_transform=None, **kwargs, ): + if marker_capstyle is not None or marker_joinstyle is not None or marker_transform 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 + 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: @@ -1026,7 +1040,15 @@ def _pop_entry_props(kwargs: dict[str, Any]) -> dict[str, Any]: for full_key, value in resolved_aliases.items(): if full_key not in props: props[full_key] = value - + # NEW: grab any remaining kwargs that are valid Line2D setters + for key in list(kwargs.keys()): + if key.startswith('_'): + continue + if hasattr(mlines.Line2D, 'set_' + key): + props[key] = kwargs.pop(key) + for key in ('marker_capstyle', 'marker_joinstyle', 'marker_transform'): + if key in kwargs: + props[key] = kwargs.pop(key) return props From 9ddb28a2a4b85557ed9eeb8321d89527128f6186 Mon Sep 17 00:00:00 2001 From: gepcel Date: Mon, 18 May 2026 18:17:12 +0800 Subject: [PATCH 09/10] Fix a failure of test_legend --- ultraplot/legend.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/ultraplot/legend.py b/ultraplot/legend.py index 55e484387..9d46e139c 100644 --- a/ultraplot/legend.py +++ b/ultraplot/legend.py @@ -1042,6 +1042,9 @@ def _pop_entry_props(kwargs: dict[str, Any]) -> dict[str, Any]: props[full_key] = value # NEW: 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): From c5905fdda3195955a4062063ff80b55141aa365a Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 19 May 2026 04:54:52 +0000 Subject: [PATCH 10/10] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- ultraplot/legend.py | 95 ++++++++++++++++---------- ultraplot/tests/test_sematic_legend.py | 45 +++++------- 2 files changed, 77 insertions(+), 63 deletions(-) diff --git a/ultraplot/legend.py b/ultraplot/legend.py index 9d46e139c..bfa31f5df 100644 --- a/ultraplot/legend.py +++ b/ultraplot/legend.py @@ -98,16 +98,20 @@ def __init__( marker_transform=None, **kwargs, ): - if marker_capstyle is not None or marker_joinstyle is not None or marker_transform is not None: + if ( + marker_capstyle is not None + or marker_joinstyle is not None + or marker_transform is not None + ): if not isinstance(marker, MarkerStyle): marker_kw = {} if marker_capstyle is not None: - marker_kw['capstyle'] = marker_capstyle + marker_kw["capstyle"] = marker_capstyle if marker_joinstyle is not None: - marker_kw['joinstyle'] = marker_joinstyle + marker_kw["joinstyle"] = marker_joinstyle if marker_transform is not None: - marker_kw['transform'] = marker_transform - marker = MarkerStyle(marker, **marker_kw) + marker_kw["transform"] = marker_transform + 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: @@ -866,11 +870,14 @@ def _geo_legend_entries( return handles, label_list - # _is_color_like should only check the following args _COLOR_KEYS = { - 'color', 'facecolor', 'edgecolor', - 'markerfacecolor', 'markeredgecolor', 'markerfacecoloralt', + "color", + "facecolor", + "edgecolor", + "markerfacecolor", + "markeredgecolor", + "markerfacecoloralt", } @@ -889,11 +896,10 @@ def _is_color_like(value): if 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.") + 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): @@ -929,7 +935,6 @@ def _is_color_like(value): } - def _style_lookup(style, key, index, default=None, *, prop=None): """ Resolve a style value from scalar, mapping, or sequence inputs. @@ -947,7 +952,7 @@ def _style_lookup(style, key, index, default=None, *, prop=None): return default # Only perform color detection for known color properties - check_color = (prop is not None and prop in _COLOR_KEYS) + check_color = prop is not None and prop in _COLOR_KEYS if check_color and _is_color_like(style): return style @@ -1028,14 +1033,14 @@ def _pop_entry_props(kwargs: dict[str, Any]) -> dict[str, Any]: collection_props = _pop_props(kwargs, "collection") collection_props.update(explicit_collection) - # 4. Map collection plural parameters to singular property names + # 4. 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 - # 5. Merge resolved aliases (aliases have lowest priority, + # 5. 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: @@ -1045,13 +1050,13 @@ def _pop_entry_props(kwargs: dict[str, Any]) -> dict[str, Any]: # without this, line 645 of test_legend.py won't pass if key in ("labels", "label"): continue - if key.startswith('_'): + if key.startswith("_"): continue - if hasattr(mlines.Line2D, 'set_' + key): + if hasattr(mlines.Line2D, "set_" + key): + props[key] = kwargs.pop(key) + for key in ("marker_capstyle", "marker_joinstyle", "marker_transform"): + if key in kwargs: props[key] = kwargs.pop(key) - for key in ('marker_capstyle', 'marker_joinstyle', 'marker_transform'): - if key in kwargs: - props[key] = kwargs.pop(key) return props @@ -1149,7 +1154,9 @@ def _cat_legend_entries( 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") + 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 @@ -1319,8 +1326,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", prop="color") - marker_value = _style_lookup(marker, float(value), idx, default="o", prop="marker") + 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 @@ -1581,7 +1592,9 @@ def entrylegend( ): styles = {} if handle_kw: - styles.update(_pop_entry_props(handle_kw)) # Handle explicit handle_kw first + 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"]) @@ -1589,7 +1602,9 @@ def entrylegend( color = _not_none(color, styles.pop("color", None)) 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(styles.pop("markersize", None), rc["legend.cat.markersize"]) + markersize = _not_none( + styles.pop("markersize", None), rc["legend.cat.markersize"] + ) alpha = _not_none(styles.pop("alpha", None), rc["legend.cat.alpha"]) markeredgecolor = _not_none( styles.pop("markeredgecolor", None), rc["legend.cat.markeredgecolor"] @@ -1622,8 +1637,8 @@ def catlegend( self, categories: Iterable[Any], *, - color=None, # Originally 'colors', change to singular form - marker=None, # Originally 'markers', change to singular form + color=None, # Originally 'colors', change to singular form + marker=None, # Originally 'markers', change to singular form line: Optional[bool] = None, handle_kw: Optional[dict[str, Any]] = None, add: bool = True, @@ -1635,8 +1650,12 @@ def catlegend( # 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 + 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"]) @@ -1644,7 +1663,9 @@ def catlegend( 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(styles.pop("markersize", None), rc["legend.cat.markersize"]) + markersize = _not_none( + styles.pop("markersize", None), rc["legend.cat.markersize"] + ) alpha = _not_none(styles.pop("alpha", None), rc["legend.cat.alpha"]) markeredgecolor = _not_none( styles.pop("markeredgecolor", None), rc["legend.cat.markeredgecolor"] @@ -1654,7 +1675,7 @@ def catlegend( ) markerfacecolor = _not_none(styles.pop("markerfacecolor", None), None) - # Remaining styles are passed as additional entry properties + # Remaining styles are passed as additional entry properties # (e.g., 'markerfacecoloralt') to _cat_legend_entries handles, labels = _cat_legend_entries( categories, @@ -1693,7 +1714,9 @@ def sizelegend( ): styles = {} if handle_kw: - styles.update(_pop_entry_props(handle_kw)) # Handle explicit handle_kw first + 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"]) @@ -1751,7 +1774,7 @@ def numlegend( 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 + 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"]) @@ -1835,7 +1858,7 @@ def geolegend( linewidth=linewidth, alpha=alpha, fill=fill, - **styles, # Additional Patch properties + **styles, # Additional Patch properties ) if not add: return handles, labels diff --git a/ultraplot/tests/test_sematic_legend.py b/ultraplot/tests/test_sematic_legend.py index 74d5df681..4d7239044 100644 --- a/ultraplot/tests/test_sematic_legend.py +++ b/ultraplot/tests/test_sematic_legend.py @@ -1,8 +1,10 @@ """ Unit tests for semantic legend style aliases and color detection. """ + import matplotlib -matplotlib.use('Agg') # Must be before any other matplotlib import for local test + +matplotlib.use("Agg") # Must be before any other matplotlib import for local test import numpy as np import pytest from matplotlib import colors as mcolors @@ -18,13 +20,11 @@ def test_catlegend_rgba_tuple_is_color(): fig, ax = uplt.subplots() try: ax.axis("off") - handles, _ = ax.catlegend( - list("ABC"), color=(0.2, 0.4, 0.6, 0.8), add=False - ) + handles, _ = ax.catlegend(list("ABC"), color=(0.2, 0.4, 0.6, 0.8), add=False) colors = [h.get_color() for h in handles] - assert all(c == colors[0] for c in colors), ( - f"All entries should share the same color, got {colors}" - ) + assert all( + c == colors[0] for c in colors + ), f"All entries should share the same color, got {colors}" finally: uplt.close(fig) @@ -51,14 +51,13 @@ def test_numlegend_facecolor_rgba_tuple_is_color(): try: ax.axis("off") handles, _ = ax.numlegend( - [1, 2, 3], vmin=0, vmax=4, - facecolor=(0.8, 0.2, 0.3, 0.6), add=False + [1, 2, 3], vmin=0, vmax=4, facecolor=(0.8, 0.2, 0.3, 0.6), add=False ) ref = np.array(handles[0].get_facecolor()) for h in handles: - assert np.allclose(np.array(h.get_facecolor()), ref), ( - "All patches should have identical facecolor" - ) + assert np.allclose( + np.array(h.get_facecolor()), ref + ), "All patches should have identical facecolor" finally: uplt.close(fig) @@ -207,9 +206,7 @@ def test_numlegend_alias_fc_facecolor(): fig, ax = uplt.subplots() try: ax.axis("off") - handles, _ = ax.numlegend( - [1, 2, 3], vmin=0, vmax=4, fc="lightblue", add=False - ) + handles, _ = ax.numlegend([1, 2, 3], vmin=0, vmax=4, fc="lightblue", add=False) for h in handles: assert h.get_facecolor()[:3] == mcolors.to_rgb("lightblue") finally: @@ -221,9 +218,7 @@ def test_numlegend_alias_ec_edgecolor(): fig, ax = uplt.subplots() try: ax.axis("off") - handles, _ = ax.numlegend( - [1, 2, 3], vmin=0, vmax=4, ec="black", add=False - ) + handles, _ = ax.numlegend([1, 2, 3], vmin=0, vmax=4, ec="black", add=False) for h in handles: assert h.get_edgecolor()[:3] == (0.0, 0.0, 0.0) finally: @@ -235,9 +230,7 @@ def test_numlegend_alias_ls_linestyle(): fig, ax = uplt.subplots() try: ax.axis("off") - handles, _ = ax.numlegend( - [1, 2, 3], vmin=0, vmax=4, ls=":", add=False - ) + handles, _ = ax.numlegend([1, 2, 3], vmin=0, vmax=4, ls=":", add=False) for h in handles: assert h.get_linestyle() == ":" finally: @@ -249,9 +242,7 @@ def test_numlegend_alias_lw_linewidth(): fig, ax = uplt.subplots() try: ax.axis("off") - handles, _ = ax.numlegend( - [1, 2, 3], vmin=0, vmax=4, lw=1.5, add=False - ) + handles, _ = ax.numlegend([1, 2, 3], vmin=0, vmax=4, lw=1.5, add=False) for h in handles: assert h.get_linewidth() == 1.5 finally: @@ -266,9 +257,7 @@ def test_alias_and_fullname_priority(): fig, ax = uplt.subplots() try: ax.axis("off") - handles, _ = ax.catlegend( - list("AB"), markersize=15, ms=99, add=False - ) + handles, _ = ax.catlegend(list("AB"), markersize=15, ms=99, add=False) for h in handles: assert h.get_markersize() == 15 finally: @@ -322,6 +311,7 @@ def test_sizelegend_alias_mec(): finally: uplt.close(fig) + def test_catlegend_ms_length_three_is_not_color(): """ms list of length 3 should be treated as per‑entry markersize, not a color.""" fig, ax = uplt.subplots() @@ -360,6 +350,7 @@ def test_catlegend_alpha_length_three(): finally: uplt.close(fig) + def test_catlegend_color_as_list_of_rgba_tuples(): """Color with list of RGBA tuples still works correctly.""" c1 = (1.0, 0.0, 0.0, 1.0)