diff --git a/ultraplot/axes/base.py b/ultraplot/axes/base.py index 038829414..8174d870b 100644 --- a/ultraplot/axes/base.py +++ b/ultraplot/axes/base.py @@ -3773,7 +3773,6 @@ def catlegend(self, categories, **kwargs): Whether to render connector lines through the markers. Falls back to :rc:`legend.cat.line`. Setting a non-default ``linestyle`` implicitly enables this. - Other parameters ---------------- %(legend.semantic_style_kwargs)s @@ -3803,7 +3802,6 @@ def entrylegend(self, entries, **kwargs): :rc:`legend.cat.line`. marker, color %(legend.semantic_style_arg)s - Other parameters ---------------- %(legend.semantic_style_kwargs)s diff --git a/ultraplot/axes/plot.py b/ultraplot/axes/plot.py index e9078a1a4..83eba0d93 100644 --- a/ultraplot/axes/plot.py +++ b/ultraplot/axes/plot.py @@ -4369,8 +4369,9 @@ def _parse_cycle( # Apply manual cycle properties if cycle_manually: - current_prop = self._get_lines._cycler_items[self._get_lines._idx] - self._get_lines._idx = (self._get_lines._idx + 1) % len(self._active_cycle) + cycler = getattr(self._get_lines, "_prop_cycle", self._get_lines) + current_prop = cycler._cycler_items[cycler._idx] + cycler._idx = (cycler._idx + 1) % len(cycler._cycler_items) for prop, key in cycle_manually.items(): if kwargs.get(key) is None and prop in current_prop: value = current_prop[prop] @@ -5602,6 +5603,17 @@ def _apply_scatter(self, xs, ys, ss, cc, *, vert=True, **kwargs): kw = kwargs.copy() inbounds = kw.pop("inbounds", None) + size = kw.pop("size", None) + sizes = kw.pop("sizes", None) + size = _not_none(size=size, sizes=sizes) + if size is not None: + if ss is None: + ss = size + else: + warnings._warn_ultraplot( + "Got conflicting scatter size arguments. Using s/ms/markersize " + "and ignoring size/sizes." + ) kw.update(_pop_props(kw, "collection")) kw, extents = self._inbounds_extent(inbounds=inbounds, **kw) xs, ys, kw = self._parse_1d_args(xs, ys, vert=vert, autoreverse=False, **kw) diff --git a/ultraplot/legend.py b/ultraplot/legend.py index 8f1096cb0..c11d4e14b 100644 --- a/ultraplot/legend.py +++ b/ultraplot/legend.py @@ -157,6 +157,30 @@ def marker(cls, label=None, marker="o", **kwargs): return cls(label=label, line=False, marker=marker, **kwargs) +class _Line2DLegendHandler(mhandler.HandlerLine2D): + """ + Match single-point marker plots by hiding the legend connector line. + """ + + def create_artists(self, legend, orig_handle, *args, **kwargs): + artists = super().create_artists(legend, orig_handle, *args, **kwargs) + if isinstance(orig_handle, LegendEntry): + return artists + marker = orig_handle.get_marker() + if marker in (None, "", "none", "None"): + return artists + try: + xdata = orig_handle.get_xdata(orig=False) + ydata = orig_handle.get_ydata(orig=False) + except Exception: + return artists + if len(np.atleast_1d(xdata)) <= 1 and len(np.atleast_1d(ydata)) <= 1: + for artist in artists: + if isinstance(artist, mlines.Line2D): + artist.set_linestyle("None") + return artists + + _GEOMETRY_SHAPE_PATHS = { "circle": mpath.Path.unit_circle(), "square": mpath.Path.unit_rectangle(), @@ -937,6 +961,7 @@ def _is_color_like(value): "c": "color", "m": "marker", "ms": "markersize", + "markersizes": "markersize", "ls": "linestyle", "lw": "linewidth", "mec": "markeredgecolor", @@ -1022,9 +1047,10 @@ def _default_cycle_colors(): "facecolors": "markerfacecolor", "linestyles": "linestyle", "linewidths": "markeredgewidth", - "sizes": "markersize", - "size": "markersize", } +_ENTRY_AREA_SIZE_KEYS = ("s", "size", "sizes") +_ENTRY_DIAMETER_SIZE_KEYS = ("markersize", "ms", "markersizes") +_ENTRY_MARKERSIZE_KEYS = (*_ENTRY_AREA_SIZE_KEYS, *_ENTRY_DIAMETER_SIZE_KEYS) def _pop_aliases(kwargs: dict[str, Any], alias_map: dict[str, str]) -> dict[str, Any]: @@ -1037,7 +1063,7 @@ def _pop_aliases(kwargs: dict[str, Any], alias_map: dict[str, str]) -> dict[str, def _pop_plurals(kwargs: dict[str, Any], plural_map: dict[str, str]) -> dict[str, Any]: - """Pop collection-style plurals (``colors``, ``sizes``, …) from ``kwargs``.""" + """Pop collection-style plurals (``colors``, ``linewidths``, …).""" explicit = {} for key in plural_map: if key in kwargs: @@ -1045,6 +1071,45 @@ def _pop_plurals(kwargs: dict[str, Any], plural_map: dict[str, str]) -> dict[str return explicit +def _area_to_markersize(value: Any) -> Any: + """ + Convert area-style marker sizes to Line2D marker diameters. + """ + if isinstance(value, Mapping): + return {key: _area_to_markersize(val) for key, val in value.items()} + if isinstance(value, str): + return units(value, "pt") + try: + if np.isscalar(value): + return float(np.sqrt(np.clip(value, 0, None))) + except TypeError: + return value + try: + values = list(value) + except TypeError: + return value + return [_area_to_markersize(val) for val in values] + + +def _pop_marker_size(kwargs: dict[str, Any]) -> Any: + """ + Pop marker-size aliases and return Line2D marker diameters. + + Semantic legend helpers accept scatter-style ``s`` / ``size`` / ``sizes`` + inputs as marker areas, but render handles with ``Line2D`` where + ``markersize`` / ``ms`` are diameters. + """ + area_opts = {key: kwargs.pop(key, None) for key in _ENTRY_AREA_SIZE_KEYS} + diameter_opts = {key: kwargs.pop(key, None) for key in _ENTRY_DIAMETER_SIZE_KEYS} + diameter = _not_none(**diameter_opts) + if diameter is not None: + return diameter + area = _not_none(**area_opts) + if area is None: + return None + return _area_to_markersize(area) + + def _pop_line2d_setters(kwargs: dict[str, Any]) -> dict[str, Any]: """ Pop remaining kwargs that correspond to ``Line2D`` setters. @@ -1075,9 +1140,11 @@ def _pop_entry_props(kwargs: dict[str, Any]) -> dict[str, Any]: Resolution order (highest → lowest priority): 1. Full-name properties recognised by ``_pop_props(kwargs, "line")``. - 2. Collection-style plurals (``colors`` → ``color``, ``sizes`` → ``markersize``, …). - 3. Short aliases (``c`` → ``color``, ``ls`` → ``linestyle``, …). - 4. Any other valid ``Line2D`` setter still in ``kwargs``. + 2. Collection-style plurals (``colors`` → ``color``, …). + 3. Marker-size aliases. ``s`` / ``size`` / ``sizes`` are scatter-style + areas converted to diameters; ``markersize`` / ``ms`` are diameters. + 4. Short aliases (``c`` → ``color``, ``ls`` → ``linestyle``, …). + 5. Any other valid ``Line2D`` setter still in ``kwargs``. Advanced ``MarkerStyle`` properties (``marker_capstyle``/``_joinstyle``/ ``_transform``) are pulled out first so ``_pop_props`` does not consume @@ -1088,11 +1155,16 @@ def _pop_entry_props(kwargs: dict[str, Any]) -> dict[str, Any]: if key in kwargs: advanced_marker[key] = kwargs.pop(key) + marker_size = _pop_marker_size(kwargs) resolved_aliases = _pop_aliases(kwargs, _LINE_ALIAS_MAP) explicit_collection = _pop_plurals(kwargs, _ENTRY_STYLE_FROM_COLLECTION) - props = _pop_props(kwargs, "line") - collection_props = _pop_props(kwargs, "collection") + props = _pop_props(kwargs, "line", skip=_ENTRY_MARKERSIZE_KEYS) + collection_props = _pop_props( + kwargs, + "collection", + skip=_ENTRY_MARKERSIZE_KEYS, + ) collection_props.update(explicit_collection) for source, target in _ENTRY_STYLE_FROM_COLLECTION.items(): @@ -1100,6 +1172,9 @@ def _pop_entry_props(kwargs: dict[str, Any]) -> dict[str, Any]: if value is not None and target not in props: props[target] = value + if marker_size is not None and "markersize" not in props: + props["markersize"] = marker_size + for full_key, value in resolved_aliases.items(): props.setdefault(full_key, value) @@ -1549,6 +1624,7 @@ def get_default_handler_map(cls): Extend matplotlib defaults with a wedge handler for pie legends. """ handler_map = dict(super().get_default_handler_map()) + handler_map[mlines.Line2D] = _Line2DLegendHandler() handler_map.setdefault( GeometryEntry, _GeometryEntryLegendHandler(), @@ -1618,7 +1694,10 @@ def _normalize_em_kwargs(kwargs: dict[str, Any], *, fontsize: float) -> dict[str ``marker`` / ``m`` Marker spec. Set to ``None`` or ``""`` to suppress the marker. ``markersize`` / ``ms``, ``markeredgewidth`` / ``mew`` - Marker dimensions. + Marker dimensions. ``markersize`` / ``ms`` denote marker diameter in points. +``s`` / ``size`` / ``sizes`` + Scatter-style marker areas, converted to marker diameters for the legend + handle. Use ``markersize`` / ``ms`` when specifying diameters directly. ``markerfacecolor`` / ``mfc``, ``markeredgecolor`` / ``mec``, ``markerfacecoloralt`` / ``mfcalt`` Marker fills and edges. ``linestyle`` / ``ls``, ``linewidth`` / ``lw`` @@ -1629,9 +1708,10 @@ def _normalize_em_kwargs(kwargs: dict[str, Any], *, fontsize: float) -> dict[str ``marker_capstyle``, ``marker_joinstyle``, ``marker_transform`` Advanced ``MarkerStyle`` properties; wrapped into the rendered marker. -Plural forms (``colors``, ``markers``, ``sizes``, ``edgecolors``, -``facecolors``, ``linestyles``, ``linewidths``) are accepted as -synonyms for the singular per-entry form for backward compatibility. +Plural forms (``colors``, ``markers``, ``edgecolors``, ``facecolors``, +``linestyles``, ``linewidths``) are accepted as synonyms for the singular +per-entry form for backward compatibility. ``sizes`` is accepted as a +scatter-style area alias. Each value accepts the scalar / sequence / mapping forms described in ``%(legend.semantic_style_arg)s``.""" @@ -1832,13 +1912,13 @@ def sizelegend( Build size legend entries and optionally draw a legend. Public docs live on :meth:`Axes.sizelegend`. """ + area = _not_none(area, rc["legend.size.area"]) styles = {} if handle_kw: styles.update(_pop_entry_props(handle_kw)) styles.update(_pop_entry_props(kwargs)) color = _not_none(color, styles.pop("color", None), rc["legend.size.color"]) marker = _not_none(marker, styles.pop("marker", None), rc["legend.size.marker"]) - area = _not_none(area, rc["legend.size.area"]) scale = _not_none(scale, rc["legend.size.scale"]) minsize = _not_none(minsize, rc["legend.size.minsize"]) fmt = _not_none(fmt, rc["legend.size.format"]) diff --git a/ultraplot/tests/test_1dplots.py b/ultraplot/tests/test_1dplots.py index d57309161..c04486a12 100644 --- a/ultraplot/tests/test_1dplots.py +++ b/ultraplot/tests/test_1dplots.py @@ -447,6 +447,37 @@ def test_scatter_alpha(rng): return fig +def test_scatter_size_aliases_are_areas(): + """ + Scatter preserves existing area semantics for all size aliases. + """ + fig, ax = uplt.subplots() + try: + s = ax.scatter([0], [0], s=30) + size = ax.scatter([1], [0], size=30) + sizes = ax.scatter([2], [0], sizes=30) + ms = ax.scatter([3], [0], ms=30) + markersize = ax.scatter([4], [0], markersize=30) + + assert s.get_sizes()[0] == pytest.approx(30) + assert size.get_sizes()[0] == pytest.approx(30) + assert sizes.get_sizes()[0] == pytest.approx(30) + assert ms.get_sizes()[0] == pytest.approx(30) + assert markersize.get_sizes()[0] == pytest.approx(30) + finally: + uplt.close(fig) + + +def test_scatter_cycle_markersize_is_area(): + fig, ax = uplt.subplots() + try: + cycle = uplt.Cycle(marker=["o"], markersize=[30]) + obj = ax.scatter([0], [0], cycle=cycle) + assert obj.get_sizes()[0] == pytest.approx(30) + finally: + uplt.close(fig) + + @pytest.mark.mpl_image_compare def test_scatter_cycle(rng): """ diff --git a/ultraplot/tests/test_legend.py b/ultraplot/tests/test_legend.py index ab7cc2fba..a41b2c1db 100644 --- a/ultraplot/tests/test_legend.py +++ b/ultraplot/tests/test_legend.py @@ -426,6 +426,36 @@ def test_entrylegend_handle_kw_with_per_entry_mappings(): uplt.close(fig) +def test_entrylegend_scatter_sizes_are_converted_to_diameters(): + fig, ax = uplt.subplots() + handles, labels = ax.entrylegend( + [ + {"label": "Size", "line": False, "s": 100}, + {"label": "Diameter", "line": False, "ms": 10}, + {"label": "Full name", "line": False, "markersize": 12}, + ], + add=False, + ) + assert labels == ["Size", "Diameter", "Full name"] + assert handles[0].get_markersize() == pytest.approx(10) + assert handles[1].get_markersize() == pytest.approx(10) + assert handles[2].get_markersize() == pytest.approx(12) + + handles, labels = ax.entrylegend( + [ + {"label": "Size", "line": False, "size": 144}, + {"label": "Sizes", "line": False, "sizes": 169}, + {"label": "Full name", "line": False, "markersize": 14}, + ], + add=False, + ) + assert labels == ["Size", "Sizes", "Full name"] + assert handles[0].get_markersize() == pytest.approx(12) + assert handles[1].get_markersize() == pytest.approx(13) + assert handles[2].get_markersize() == pytest.approx(14) + uplt.close(fig) + + def test_catlegend_handle_kw_accepts_line_scatter_aliases(): fig, ax = uplt.subplots() handles, labels = ax.catlegend( @@ -460,6 +490,69 @@ def test_catlegend_handle_kw_accepts_line_scatter_aliases(): uplt.close(fig) +def test_catlegend_scatter_sizes_are_converted_to_diameters(): + fig, ax = uplt.subplots() + handles, labels = ax.catlegend( + ["A", "B"], + add=False, + sizes={"A": 100, "B": 144}, + ) + assert labels == ["A", "B"] + assert handles[0].get_markersize() == pytest.approx(10) + assert handles[1].get_markersize() == pytest.approx(12) + + handles, labels = ax.catlegend( + ["A", "B"], + add=False, + size={"A": 121, "B": 169}, + ) + assert labels == ["A", "B"] + assert handles[0].get_markersize() == pytest.approx(11) + assert handles[1].get_markersize() == pytest.approx(13) + + handles, labels = ax.catlegend(["C"], add=False, ms=14) + assert labels == ["C"] + assert handles[0].get_markersize() == pytest.approx(14) + uplt.close(fig) + + +def test_sizelegend_marker_size_overrides_use_semantic_size_rules(): + fig, ax = uplt.subplots() + handles, labels = ax.sizelegend( + [1.0], + area=True, + add=False, + markersize=100, + ) + assert labels == ["1"] + assert handles[0].get_markersize() == pytest.approx(100) + + handles, labels = ax.sizelegend( + [1.0], + area=False, + add=False, + s=100, + ) + assert labels == ["1"] + assert handles[0].get_markersize() == pytest.approx(10) + uplt.close(fig) + + +def test_legend_single_point_plot_matches_marker_only_artist(): + fig, ax = uplt.subplots() + ax.plot([0], [0], marker="o", label="point") + ax.plot([0, 1], [1, 2], marker="o", label="line") + leg = ax.legend(loc="best") + fig.canvas.draw() + point_handle, line_handle = leg.legend_handles + + assert point_handle.get_marker() == "o" + assert point_handle.get_linestyle() in ("None", "none", "") + assert line_handle.get_marker() == "o" + assert line_handle.get_linestyle() not in ("None", "none", "") + uplt.close(fig) + + def test_sizelegend_handle_kw_accepts_line_scatter_aliases(): fig, ax = uplt.subplots() handles, labels = ax.sizelegend(