From 8ecf89ea46310b7487f67003f19a01fb737b16c5 Mon Sep 17 00:00:00 2001 From: gepcel Date: Mon, 18 May 2026 14:11:50 +0800 Subject: [PATCH 01/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] [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) From 393386a47298c1e95c81ab8a5b1bddfd314da69c Mon Sep 17 00:00:00 2001 From: gepcel Date: Wed, 20 May 2026 09:18:51 +0800 Subject: [PATCH 11/14] Refactor test, and fix some bugs --- ultraplot/legend.py | 34 +- ultraplot/tests/test_sematic_legend.py | 572 ++++++++++++------------- 2 files changed, 293 insertions(+), 313 deletions(-) diff --git a/ultraplot/legend.py b/ultraplot/legend.py index bfa31f5df..833331901 100644 --- a/ultraplot/legend.py +++ b/ultraplot/legend.py @@ -98,10 +98,14 @@ def __init__( 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 = {} @@ -111,6 +115,8 @@ def __init__( 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 @@ -1015,37 +1021,46 @@ def _pop_entry_props(kwargs: dict[str, Any]) -> dict[str, Any]: - Plural collection parameters (like 'colors', 'edgecolors') are converted to singular - Full name parameters take precedence over aliases """ - # 1. Extract and resolve aliases (pop alias keys, map to full names) + # 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) - # 2. Extract explicit collection-style plural parameters (like 'colors', 'edgecolors') + # 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) - # 3. Use ultraplot's internal _pop_props to extract 'line' and 'collection' category properties + # 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) - # 4. Map collection plural parameters to singular property names + # 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 - # 5. Merge resolved aliases (aliases have lowest priority, + # 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 - # NEW: grab any remaining kwargs that are valid Line2D setters + + # 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"): @@ -1054,9 +1069,7 @@ def _pop_entry_props(kwargs: dict[str, Any]) -> dict[str, Any]: 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 @@ -1160,7 +1173,6 @@ def _cat_legend_entries( 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), @@ -1495,7 +1507,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 diff --git a/ultraplot/tests/test_sematic_legend.py b/ultraplot/tests/test_sematic_legend.py index 4d7239044..205b117a4 100644 --- a/ultraplot/tests/test_sematic_legend.py +++ b/ultraplot/tests/test_sematic_legend.py @@ -1,367 +1,335 @@ """ -Unit tests for semantic legend style aliases and color detection. +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 -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 +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 + # ----------------------------------------------------------------------------- -# Color detection +# Non-color properties: scalar, list, dict (single catlegend call) # ----------------------------------------------------------------------------- -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() +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: - 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}" + # 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_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() +def test_size_alias_and_markersize_dict(): + """'size' (collection style) maps to markersize, and dict works.""" + fig, ax = _make_fig() 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 + # 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_numlegend_facecolor_rgba_tuple_is_color(): - """RGBA facecolor for numlegend is not mistaken for a list.""" - fig, ax = uplt.subplots() +def test_markerfacecolor_and_edgecolor(): + """Test full-name markerfacecolor and markeredgecolor with fillstyle='full'.""" + fig, ax = _make_fig() 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 + h, _ = ax.catlegend( + ["A", "B"], + marker="o", + markerfacecolor="green", + markeredgecolor="black", + 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" + 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) - # ----------------------------------------------------------------------------- -# Line2D style aliases (catlegend) +# Alias resolution and conflicts # ----------------------------------------------------------------------------- -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() +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: - 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" + # 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) # ----------------------------------------------------------------------------- -# Patch style aliases (numlegend) +# Color parsing: many formats (scalar, list, dict, tuple, etc.) # ----------------------------------------------------------------------------- -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() +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: - 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 + # 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) # ----------------------------------------------------------------------------- -# Alias priority & dict styles +# Advanced marker styles (capstyle, joinstyle, transform) # ----------------------------------------------------------------------------- -def test_alias_and_fullname_priority(): - """Full name should override its alias.""" - fig, ax = uplt.subplots() +def test_marker_advanced(): + """marker_capstyle, marker_joinstyle, marker_transform create MarkerStyle.""" + fig, ax = _make_fig() 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}, + # 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, ) - 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 + 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) # ----------------------------------------------------------------------------- -# sizelegend alias support +# Validation of forbidden legend kwargs # ----------------------------------------------------------------------------- -def test_sizelegend_alias_c(): - """sizelegend accepts 'c' as color alias.""" - fig, ax = uplt.subplots() +def test_forbidden_legend_kwargs(): + """Passing 'label' or 'labels' to semantic helpers raises TypeError.""" + fig, ax = _make_fig() try: - ax.axis("off") - handles, _ = ax.sizelegend([1, 2, 3], c="purple", add=False) - for h in handles: - assert h.get_color() == "purple" + 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) -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() +# ----------------------------------------------------------------------------- +# 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: - 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 + # 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) -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() +# ----------------------------------------------------------------------------- +# Linestyle auto-enables line +# ----------------------------------------------------------------------------- +def test_linestyle_auto_enable_line(): + """Providing a non-default linestyle automatically enables line=True.""" + fig, ax = _make_fig() 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 + 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) From 65e5b22d563ba2e093e71771424f8e0dce7f358b Mon Sep 17 00:00:00 2001 From: gepcel Date: Wed, 20 May 2026 12:54:24 +0800 Subject: [PATCH 12/14] color check for now --- ultraplot/legend.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/ultraplot/legend.py b/ultraplot/legend.py index 833331901..f24628605 100644 --- a/ultraplot/legend.py +++ b/ultraplot/legend.py @@ -896,10 +896,12 @@ def _is_color_like(value): """ 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. - if isinstance(value, tuple): + 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): From 1883569de32c396f931d62286e42be366a7a666c Mon Sep 17 00:00:00 2001 From: gepcel Date: Wed, 20 May 2026 16:11:31 +0800 Subject: [PATCH 13/14] Seems geolegend doesn't accept alias like ec, and pass edgecolor ends up mulple args. Try unifying. --- ultraplot/legend.py | 74 +++++++++++++++++++++++++++++---------------- 1 file changed, 48 insertions(+), 26 deletions(-) diff --git a/ultraplot/legend.py b/ultraplot/legend.py index f24628605..893377285 100644 --- a/ultraplot/legend.py +++ b/ultraplot/legend.py @@ -797,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. @@ -859,20 +855,40 @@ 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 @@ -1845,18 +1861,29 @@ def geolegend( **kwargs: Any, ): # Geolegend can accept Patch styles (linestyle, hatch, etc.), similar to numlegend - styles = dict(handle_kw or {}) + styles = {} + if handle_kw: + styles.update(_pop_num_props(handle_kw)) 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"]) - alpha = _not_none(alpha, rc["legend.geo.alpha"]) - fill = _not_none(fill, rc["legend.geo.fill"]) + 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"]) @@ -1867,12 +1894,7 @@ def geolegend( country_reso=country_reso, country_territories=country_territories, country_proj=country_proj, - facecolor=facecolor, - edgecolor=edgecolor, - linewidth=linewidth, - alpha=alpha, - fill=fill, - **styles, # Additional Patch properties + patch_kw=patch_kw, ) if not add: return handles, labels From c876eb0cad9d72596a97e263a149f1b156a64a28 Mon Sep 17 00:00:00 2001 From: gepcel Date: Wed, 20 May 2026 16:51:11 +0800 Subject: [PATCH 14/14] Add some tests for geolegend unifying. pre-commit.ci autofix --- ultraplot/tests/test_sematic_legend.py | 164 +++++++++++++++++++++++++ 1 file changed, 164 insertions(+) diff --git a/ultraplot/tests/test_sematic_legend.py b/ultraplot/tests/test_sematic_legend.py index 205b117a4..8d18c5315 100644 --- a/ultraplot/tests/test_sematic_legend.py +++ b/ultraplot/tests/test_sematic_legend.py @@ -333,3 +333,167 @@ def test_linestyle_auto_enable_line(): 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)