From b8c11e0bff87ef2796a79378b7518b43fac8decc Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Mon, 29 Jun 2026 13:56:39 +1000 Subject: [PATCH 1/4] Align the parameters to be more in line with user expectation --- ultraplot/axes/plot.py | 45 +++++++++++++++++++--- ultraplot/legend.py | 67 +++++++++++++++++++++++++++------ ultraplot/tests/test_1dplots.py | 22 +++++++++++ ultraplot/tests/test_legend.py | 34 +++++++++++++++++ 4 files changed, 150 insertions(+), 18 deletions(-) diff --git a/ultraplot/axes/plot.py b/ultraplot/axes/plot.py index e9078a1a4..96d7e006c 100644 --- a/ultraplot/axes/plot.py +++ b/ultraplot/axes/plot.py @@ -64,6 +64,8 @@ # NOTE: Increased from native linewidth of 0.25 matplotlib uses for grid box edges. # This is half of rc['patch.linewidth'] of 0.6. Half seems like a nice default. EDGEWIDTH = 0.3 +SCATTER_SIZE_KEYS = ("s", "sizes", "size") +SCATTER_MARKERSIZE_KEYS = ("markersize", "ms", "markersizes") DataInput: TypeAlias = ArrayLike ColorTupleRGB: TypeAlias = tuple[float, float, float] @@ -994,10 +996,14 @@ Parameters ---------- %(plot.args_1d_{y})s -s, size, ms, markersize : float or array-like or unit-spec, optional +s, size, sizes : float or array-like or unit-spec, optional The marker size area(s). If this is an array matching the shape of `x` and `y`, the units are scaled by `smin` and `smax`. If this contains unit string(s), it is processed by `~ultraplot.utils.units` and represents the width rather than area. +ms, markersize, markersizes : float or array-like or unit-spec, optional + The marker diameter(s) in points. These are converted to area before + dispatching to `~matplotlib.axes.Axes.scatter`, so they are visually + consistent with `~ultraplot.axes.PlotAxes.plot`. c, color, colors, mc, markercolor, markercolors, fc, facecolor, facecolors \ : array-like or color-spec, optional The marker color(s). If this is an array matching the shape of `x` and `y`, @@ -4369,8 +4375,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] @@ -5579,6 +5586,16 @@ def _parse_markersize( s = s ** (2, 1)[area_size] return s, kwargs + def _pop_markersize_as_diameter(self, kwargs): + """ + Pop scatter ``ms`` / ``markersize`` aliases as point diameters. + """ + opts = {key: kwargs.pop(key, None) for key in SCATTER_MARKERSIZE_KEYS} + value = _not_none(**opts) + if isinstance(value, str): + value = units(value, "pt") + return value + def _apply_scatter(self, xs, ys, ss, cc, *, vert=True, **kwargs): """ Apply scatter or scatterx markers. @@ -5602,10 +5619,26 @@ def _apply_scatter(self, xs, ys, ss, cc, *, vert=True, **kwargs): kw = kwargs.copy() inbounds = kw.pop("inbounds", None) - kw.update(_pop_props(kw, "collection")) + marker_size = self._pop_markersize_as_diameter(kw) + kw.update(_pop_props(kw, "collection", skip=SCATTER_MARKERSIZE_KEYS)) kw, extents = self._inbounds_extent(inbounds=inbounds, **kw) xs, ys, kw = self._parse_1d_args(xs, ys, vert=vert, autoreverse=False, **kw) ys, kw = inputs._dist_reduce(ys, **kw) + if marker_size is not None: + if ss is None: + ss = marker_size + area_size = kw.get("area_size", None) + if area_size not in (None, False): + warnings._warn_ultraplot( + "Ignoring area_size=True because ms/markersize " + "now denotes marker diameter." + ) + kw["area_size"] = False + else: + warnings._warn_ultraplot( + "Got conflicting scatter size arguments. Using s/size/sizes " + "and ignoring ms/markersize." + ) ss, kw = self._parse_markersize(ss, **kw) # parse 's' # Only parse color if explicitly provided @@ -5655,7 +5688,7 @@ def _apply_scatter(self, xs, ys, ss, cc, *, vert=True, **kwargs): @inputs._preprocess_or_redirect( "x", "y", - _get_aliases("collection", "sizes"), + SCATTER_SIZE_KEYS, _get_aliases("collection", "colors", "facecolors"), keywords=_get_aliases("collection", "linewidths", "edgecolors"), ) @@ -5671,7 +5704,7 @@ def scatter(self, *args, **kwargs): @inputs._preprocess_or_redirect( "y", "x", - _get_aliases("collection", "sizes"), + SCATTER_SIZE_KEYS, _get_aliases("collection", "colors", "facecolors"), keywords=_get_aliases("collection", "linewidths", "edgecolors"), ) diff --git a/ultraplot/legend.py b/ultraplot/legend.py index 8f1096cb0..709b70b53 100644 --- a/ultraplot/legend.py +++ b/ultraplot/legend.py @@ -937,6 +937,7 @@ def _is_color_like(value): "c": "color", "m": "marker", "ms": "markersize", + "markersizes": "markersize", "ls": "linestyle", "lw": "linewidth", "mec": "markeredgecolor", @@ -1022,9 +1023,9 @@ 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 = ("ms", "markersizes") def _pop_aliases(kwargs: dict[str, Any], alias_map: dict[str, str]) -> dict[str, Any]: @@ -1037,7 +1038,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 +1046,35 @@ 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_area_size(kwargs: dict[str, Any]) -> Any: + """ + Pop scatter-style size aliases and return Line2D marker diameters. + """ + opts = {key: kwargs.pop(key, None) for key in _ENTRY_AREA_SIZE_KEYS} + value = _not_none(**opts) + return None if value is None else _area_to_markersize(value) + + def _pop_line2d_setters(kwargs: dict[str, Any]) -> dict[str, Any]: """ Pop remaining kwargs that correspond to ``Line2D`` setters. @@ -1075,9 +1105,10 @@ 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. Scatter-style area sizes (``s`` / ``size`` / ``sizes`` → ``markersize``). + 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 +1119,16 @@ def _pop_entry_props(kwargs: dict[str, Any]) -> dict[str, Any]: if key in kwargs: advanced_marker[key] = kwargs.pop(key) + area_size = _pop_area_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=("s", *_ENTRY_DIAMETER_SIZE_KEYS)) + collection_props = _pop_props( + kwargs, + "collection", + skip=(*_ENTRY_AREA_SIZE_KEYS, "markersize", *_ENTRY_DIAMETER_SIZE_KEYS), + ) collection_props.update(explicit_collection) for source, target in _ENTRY_STYLE_FROM_COLLECTION.items(): @@ -1100,6 +1136,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 area_size is not None and "markersize" not in props: + props["markersize"] = area_size + for full_key, value in resolved_aliases.items(): props.setdefault(full_key, value) @@ -1618,7 +1657,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 +1671,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 with +scatter-style area semantics. Each value accepts the scalar / sequence / mapping forms described in ``%(legend.semantic_style_arg)s``.""" diff --git a/ultraplot/tests/test_1dplots.py b/ultraplot/tests/test_1dplots.py index d57309161..a76688bbc 100644 --- a/ultraplot/tests/test_1dplots.py +++ b/ultraplot/tests/test_1dplots.py @@ -447,6 +447,28 @@ def test_scatter_alpha(rng): return fig +def test_scatter_s_is_area_ms_is_diameter(): + """ + Scatter preserves matplotlib ``s`` area semantics while ``ms`` / + ``markersize`` use Line2D-style diameter semantics. + """ + 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**2) + assert markersize.get_sizes()[0] == pytest.approx(30**2) + 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..e8d0980d0 100644 --- a/ultraplot/tests/test_legend.py +++ b/ultraplot/tests/test_legend.py @@ -426,6 +426,23 @@ def test_entrylegend_handle_kw_with_per_entry_mappings(): uplt.close(fig) +def test_entrylegend_s_is_area_ms_is_diameter(): + fig, ax = uplt.subplots() + handles, labels = ax.entrylegend( + [ + {"label": "Area", "line": False, "s": 100}, + {"label": "Diameter", "line": False, "ms": 10}, + {"label": "Full name", "line": False, "markersize": 12}, + ], + add=False, + ) + assert labels == ["Area", "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) + uplt.close(fig) + + def test_catlegend_handle_kw_accepts_line_scatter_aliases(): fig, ax = uplt.subplots() handles, labels = ax.catlegend( @@ -460,6 +477,23 @@ def test_catlegend_handle_kw_accepts_line_scatter_aliases(): uplt.close(fig) +def test_catlegend_s_is_area_ms_is_diameter(): + 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(["C"], add=False, ms=12) + assert labels == ["C"] + assert handles[0].get_markersize() == pytest.approx(12) + uplt.close(fig) + + def test_sizelegend_handle_kw_accepts_line_scatter_aliases(): fig, ax = uplt.subplots() handles, labels = ax.sizelegend( From 40e461116965f2c980473aed0514a7f229b91e15 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Wed, 1 Jul 2026 07:41:23 +1000 Subject: [PATCH 2/4] See comment on 745 --- ultraplot/axes/base.py | 6 +++ ultraplot/axes/plot.py | 32 +++++++++---- ultraplot/figure.py | 4 ++ ultraplot/legend.py | 84 +++++++++++++++++++++++---------- ultraplot/tests/test_1dplots.py | 10 ++++ ultraplot/tests/test_legend.py | 71 ++++++++++++++++++++++++++-- 6 files changed, 166 insertions(+), 41 deletions(-) diff --git a/ultraplot/axes/base.py b/ultraplot/axes/base.py index 038829414..dd50a5e23 100644 --- a/ultraplot/axes/base.py +++ b/ultraplot/axes/base.py @@ -3773,6 +3773,9 @@ 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. + area : bool, default: False + Whether marker-size style keywords are interpreted as areas + (``True``) or diameters (``False``). Other parameters ---------------- @@ -3803,6 +3806,9 @@ def entrylegend(self, entries, **kwargs): :rc:`legend.cat.line`. marker, color %(legend.semantic_style_arg)s + area : bool, default: False + Whether marker-size style keywords are interpreted as areas + (``True``) or diameters (``False``). Other parameters ---------------- diff --git a/ultraplot/axes/plot.py b/ultraplot/axes/plot.py index 96d7e006c..ac2669993 100644 --- a/ultraplot/axes/plot.py +++ b/ultraplot/axes/plot.py @@ -5596,6 +5596,17 @@ def _pop_markersize_as_diameter(self, kwargs): value = units(value, "pt") return value + def _parse_markersize_as_diameter(self, markersize): + """ + Convert point-diameter marker sizes to scatter areas. + """ + sizes, _ = self._parse_markersize( + markersize, + area_size=False, + absolute_size=True, + ) + return sizes + def _apply_scatter(self, xs, ys, ss, cc, *, vert=True, **kwargs): """ Apply scatter or scatterx markers. @@ -5611,7 +5622,7 @@ def _apply_scatter(self, xs, ys, ss, cc, *, vert=True, **kwargs): "markerfacecolor": "c", "markeredgecolor": "edgecolors", "marker": "marker", - "markersize": "s", + "markersize": "markersize", "markeredgewidth": "linewidths", "linestyle": "linestyles", "linewidth": "linewidths", @@ -5624,22 +5635,20 @@ def _apply_scatter(self, xs, ys, ss, cc, *, vert=True, **kwargs): kw, extents = self._inbounds_extent(inbounds=inbounds, **kw) xs, ys, kw = self._parse_1d_args(xs, ys, vert=vert, autoreverse=False, **kw) ys, kw = inputs._dist_reduce(ys, **kw) + size_is_parsed = False if marker_size is not None: if ss is None: - ss = marker_size - area_size = kw.get("area_size", None) - if area_size not in (None, False): - warnings._warn_ultraplot( - "Ignoring area_size=True because ms/markersize " - "now denotes marker diameter." - ) - kw["area_size"] = False + ss = self._parse_markersize_as_diameter(marker_size) + size_is_parsed = True + for key in ("area_size", "absolute_size", "smin", "smax"): + kw.pop(key, None) else: warnings._warn_ultraplot( "Got conflicting scatter size arguments. Using s/size/sizes " "and ignoring ms/markersize." ) - ss, kw = self._parse_markersize(ss, **kw) # parse 's' + if not size_is_parsed: + ss, kw = self._parse_markersize(ss, **kw) # parse 's' # Only parse color if explicitly provided infer_rgb = True @@ -5671,6 +5680,9 @@ def _apply_scatter(self, xs, ys, ss, cc, *, vert=True, **kwargs): # Note: they could be None kw["s"], kw["c"] = s, c kw = self._parse_cycle(n, cycle_manually=cycle_manually, **kw) + cycle_marker_size = self._pop_markersize_as_diameter(kw) + if cycle_marker_size is not None and kw.get("s") is None: + kw["s"] = self._parse_markersize_as_diameter(cycle_marker_size) *eb, kw = self._add_error_bars(x, y, vert=vert, default_barstds=True, **kw) *es, kw = self._add_error_shading(x, y, vert=vert, color_key="c", **kw) if not vert: diff --git a/ultraplot/figure.py b/ultraplot/figure.py index 5493755ab..4866e68fc 100644 --- a/ultraplot/figure.py +++ b/ultraplot/figure.py @@ -3244,6 +3244,7 @@ def entrylegend( self, entries, *, + area=None, line=None, marker=None, color=None, @@ -3266,6 +3267,7 @@ def entrylegend( ) handles, labels = plegend.UltraLegend(axes).entrylegend( entries, + area=area, line=line, marker=marker, color=color, @@ -3288,6 +3290,7 @@ def catlegend( self, categories, *, + area=None, colors=None, markers=None, line=None, @@ -3310,6 +3313,7 @@ def catlegend( ) handles, labels = plegend.UltraLegend(axes).catlegend( categories, + area=area, colors=colors, markers=markers, line=line, diff --git a/ultraplot/legend.py b/ultraplot/legend.py index 709b70b53..ab345c304 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(), @@ -1024,8 +1048,7 @@ def _default_cycle_colors(): "linestyles": "linestyle", "linewidths": "markeredgewidth", } -_ENTRY_AREA_SIZE_KEYS = ("s", "size", "sizes") -_ENTRY_DIAMETER_SIZE_KEYS = ("ms", "markersizes") +_ENTRY_MARKERSIZE_KEYS = ("markersize", "s", "size", "ms", "markersizes", "sizes") def _pop_aliases(kwargs: dict[str, Any], alias_map: dict[str, str]) -> dict[str, Any]: @@ -1066,13 +1089,15 @@ def _area_to_markersize(value: Any) -> Any: return [_area_to_markersize(val) for val in values] -def _pop_area_size(kwargs: dict[str, Any]) -> Any: +def _pop_marker_size(kwargs: dict[str, Any], *, area: bool = False) -> Any: """ - Pop scatter-style size aliases and return Line2D marker diameters. + Pop marker-size aliases and return Line2D marker diameters. """ - opts = {key: kwargs.pop(key, None) for key in _ENTRY_AREA_SIZE_KEYS} + opts = {key: kwargs.pop(key, None) for key in _ENTRY_MARKERSIZE_KEYS} value = _not_none(**opts) - return None if value is None else _area_to_markersize(value) + if value is None: + return None + return _area_to_markersize(value) if area else value def _pop_line2d_setters(kwargs: dict[str, Any]) -> dict[str, Any]: @@ -1098,7 +1123,7 @@ def _pop_line2d_setters(kwargs: dict[str, Any]) -> dict[str, Any]: return extracted -def _pop_entry_props(kwargs: dict[str, Any]) -> dict[str, Any]: +def _pop_entry_props(kwargs: dict[str, Any], *, area: bool = False) -> dict[str, Any]: """ Extract ``LegendEntry`` style properties from ``kwargs``. @@ -1106,7 +1131,7 @@ def _pop_entry_props(kwargs: dict[str, Any]) -> dict[str, Any]: 1. Full-name properties recognised by ``_pop_props(kwargs, "line")``. 2. Collection-style plurals (``colors`` → ``color``, …). - 3. Scatter-style area sizes (``s`` / ``size`` / ``sizes`` → ``markersize``). + 3. Marker-size aliases, converted from area when ``area=True``. 4. Short aliases (``c`` → ``color``, ``ls`` → ``linestyle``, …). 5. Any other valid ``Line2D`` setter still in ``kwargs``. @@ -1119,15 +1144,15 @@ def _pop_entry_props(kwargs: dict[str, Any]) -> dict[str, Any]: if key in kwargs: advanced_marker[key] = kwargs.pop(key) - area_size = _pop_area_size(kwargs) + marker_size = _pop_marker_size(kwargs, area=area) resolved_aliases = _pop_aliases(kwargs, _LINE_ALIAS_MAP) explicit_collection = _pop_plurals(kwargs, _ENTRY_STYLE_FROM_COLLECTION) - props = _pop_props(kwargs, "line", skip=("s", *_ENTRY_DIAMETER_SIZE_KEYS)) + props = _pop_props(kwargs, "line", skip=_ENTRY_MARKERSIZE_KEYS) collection_props = _pop_props( kwargs, "collection", - skip=(*_ENTRY_AREA_SIZE_KEYS, "markersize", *_ENTRY_DIAMETER_SIZE_KEYS), + skip=_ENTRY_MARKERSIZE_KEYS, ) collection_props.update(explicit_collection) @@ -1136,8 +1161,8 @@ 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 area_size is not None and "markersize" not in props: - props["markersize"] = area_size + 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) @@ -1261,6 +1286,7 @@ def _cat_legend_entries( def _entry_legend_entries( entries: Iterable[Any] | Mapping[Any, Any], *, + area: bool, line: bool, marker, color, @@ -1335,7 +1361,7 @@ def _entry_legend_entries( entry_style = {} else: entry_style = {"color": entry_spec} - entry_style.update(_pop_entry_props(entry_style)) + entry_style.update(_pop_entry_props(entry_style, area=area)) entry_label = entry_style.pop("label", entry_label) entry_label = entry_style.pop("name", entry_label) @@ -1588,6 +1614,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(), @@ -1657,10 +1684,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. ``markersize`` / ``ms`` denote marker diameter in points. + Marker dimensions. ``markersize`` / ``ms`` obey the helper's ``area`` + setting: areas when ``area=True`` and diameters when ``area=False``. ``s`` / ``size`` / ``sizes`` - Scatter-style marker areas, converted to marker diameters for the legend - handle. Use ``markersize`` / ``ms`` when specifying diameters directly. + Marker-size aliases with the same ``area`` semantics as ``markersize``. ``markerfacecolor`` / ``mfc``, ``markeredgecolor`` / ``mec``, ``markerfacecoloralt`` / ``mfcalt`` Marker fills and edges. ``linestyle`` / ``ls``, ``linewidth`` / ``lw`` @@ -1673,8 +1700,8 @@ def _normalize_em_kwargs(kwargs: dict[str, Any], *, fontsize: float) -> dict[str Plural forms (``colors``, ``markers``, ``edgecolors``, ``facecolors``, ``linestyles``, ``linewidths``) are accepted as synonyms for the singular -per-entry form for backward compatibility. ``sizes`` is accepted with -scatter-style area semantics. +per-entry form for backward compatibility. ``sizes`` is accepted as a +marker-size alias. Each value accepts the scalar / sequence / mapping forms described in ``%(legend.semantic_style_arg)s``.""" @@ -1746,6 +1773,7 @@ def entrylegend( self, entries: Iterable[Any] | Mapping[Any, Any], *, + area: Optional[bool] = None, line: Optional[bool] = None, marker=None, color=None, @@ -1757,10 +1785,11 @@ def entrylegend( Build generic semantic legend entries and optionally draw a legend. Public docs live on :meth:`Axes.entrylegend`. """ + area = _not_none(area, False) styles = {} if handle_kw: - styles.update(_pop_entry_props(handle_kw)) - styles.update(_pop_entry_props(kwargs)) + styles.update(_pop_entry_props(handle_kw, area=area)) + styles.update(_pop_entry_props(kwargs, area=area)) line = _not_none(line, styles.pop("line", None), rc["legend.cat.line"]) marker = _not_none(marker, styles.pop("marker", None), rc["legend.cat.marker"]) @@ -1781,6 +1810,7 @@ def entrylegend( handles, labels = _entry_legend_entries( entries, + area=area, line=line, marker=marker, color=color, @@ -1802,6 +1832,7 @@ def catlegend( self, categories: Iterable[Any], *, + area: Optional[bool] = None, color=None, marker=None, line: Optional[bool] = None, @@ -1813,10 +1844,11 @@ def catlegend( Build categorical legend entries and optionally draw a legend. Public docs live on :meth:`Axes.catlegend`. """ + area = _not_none(area, False) styles = {} if handle_kw: - styles.update(_pop_entry_props(handle_kw)) - styles.update(_pop_entry_props(kwargs)) + styles.update(_pop_entry_props(handle_kw, area=area)) + styles.update(_pop_entry_props(kwargs, area=area)) line = _not_none(line, styles.pop("line", None), rc["legend.cat.line"]) color = _not_none(color, styles.pop("color", None)) @@ -1875,13 +1907,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)) + styles.update(_pop_entry_props(handle_kw, area=area)) + styles.update(_pop_entry_props(kwargs, area=area)) 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 a76688bbc..77addecb9 100644 --- a/ultraplot/tests/test_1dplots.py +++ b/ultraplot/tests/test_1dplots.py @@ -469,6 +469,16 @@ def test_scatter_s_is_area_ms_is_diameter(): uplt.close(fig) +def test_scatter_cycle_markersize_is_diameter(): + 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**2) + 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 e8d0980d0..e98454810 100644 --- a/ultraplot/tests/test_legend.py +++ b/ultraplot/tests/test_legend.py @@ -426,17 +426,31 @@ def test_entrylegend_handle_kw_with_per_entry_mappings(): uplt.close(fig) -def test_entrylegend_s_is_area_ms_is_diameter(): +def test_entrylegend_marker_sizes_obey_area(): fig, ax = uplt.subplots() handles, labels = ax.entrylegend( [ - {"label": "Area", "line": False, "s": 100}, + {"label": "Size", "line": False, "s": 100}, {"label": "Diameter", "line": False, "ms": 10}, {"label": "Full name", "line": False, "markersize": 12}, ], add=False, ) - assert labels == ["Area", "Diameter", "Full name"] + assert labels == ["Size", "Diameter", "Full name"] + assert handles[0].get_markersize() == pytest.approx(100) + assert handles[1].get_markersize() == pytest.approx(10) + assert handles[2].get_markersize() == pytest.approx(12) + + handles, labels = ax.entrylegend( + [ + {"label": "Size", "line": False, "s": 100}, + {"label": "MS", "line": False, "ms": 100}, + {"label": "Full name", "line": False, "markersize": 144}, + ], + area=True, + add=False, + ) + assert labels == ["Size", "MS", "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) @@ -477,7 +491,7 @@ def test_catlegend_handle_kw_accepts_line_scatter_aliases(): uplt.close(fig) -def test_catlegend_s_is_area_ms_is_diameter(): +def test_catlegend_marker_sizes_obey_area(): fig, ax = uplt.subplots() handles, labels = ax.catlegend( ["A", "B"], @@ -485,15 +499,62 @@ def test_catlegend_s_is_area_ms_is_diameter(): sizes={"A": 100, "B": 144}, ) assert labels == ["A", "B"] + assert handles[0].get_markersize() == pytest.approx(100) + assert handles[1].get_markersize() == pytest.approx(144) + + handles, labels = ax.catlegend( + ["A", "B"], + area=True, + 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(["C"], add=False, ms=12) + handles, labels = ax.catlegend(["C"], area=True, add=False, ms=144) assert labels == ["C"] assert handles[0].get_markersize() == pytest.approx(12) uplt.close(fig) +def test_sizelegend_marker_size_overrides_obey_area(): + 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(10) + + handles, labels = ax.sizelegend( + [1.0], + area=False, + add=False, + s=10, + ) + 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( From 21040141f28d9cee2dfe729c1f0f6c78690c07a6 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Wed, 1 Jul 2026 08:42:25 +1000 Subject: [PATCH 3/4] Restore default scatter behavior --- ultraplot/axes/plot.py | 56 ++++----------------------------- ultraplot/tests/test_1dplots.py | 13 ++++---- 2 files changed, 12 insertions(+), 57 deletions(-) diff --git a/ultraplot/axes/plot.py b/ultraplot/axes/plot.py index ac2669993..415104ed3 100644 --- a/ultraplot/axes/plot.py +++ b/ultraplot/axes/plot.py @@ -64,8 +64,6 @@ # NOTE: Increased from native linewidth of 0.25 matplotlib uses for grid box edges. # This is half of rc['patch.linewidth'] of 0.6. Half seems like a nice default. EDGEWIDTH = 0.3 -SCATTER_SIZE_KEYS = ("s", "sizes", "size") -SCATTER_MARKERSIZE_KEYS = ("markersize", "ms", "markersizes") DataInput: TypeAlias = ArrayLike ColorTupleRGB: TypeAlias = tuple[float, float, float] @@ -996,14 +994,10 @@ Parameters ---------- %(plot.args_1d_{y})s -s, size, sizes : float or array-like or unit-spec, optional +s, size, ms, markersize : float or array-like or unit-spec, optional The marker size area(s). If this is an array matching the shape of `x` and `y`, the units are scaled by `smin` and `smax`. If this contains unit string(s), it is processed by `~ultraplot.utils.units` and represents the width rather than area. -ms, markersize, markersizes : float or array-like or unit-spec, optional - The marker diameter(s) in points. These are converted to area before - dispatching to `~matplotlib.axes.Axes.scatter`, so they are visually - consistent with `~ultraplot.axes.PlotAxes.plot`. c, color, colors, mc, markercolor, markercolors, fc, facecolor, facecolors \ : array-like or color-spec, optional The marker color(s). If this is an array matching the shape of `x` and `y`, @@ -5586,27 +5580,6 @@ def _parse_markersize( s = s ** (2, 1)[area_size] return s, kwargs - def _pop_markersize_as_diameter(self, kwargs): - """ - Pop scatter ``ms`` / ``markersize`` aliases as point diameters. - """ - opts = {key: kwargs.pop(key, None) for key in SCATTER_MARKERSIZE_KEYS} - value = _not_none(**opts) - if isinstance(value, str): - value = units(value, "pt") - return value - - def _parse_markersize_as_diameter(self, markersize): - """ - Convert point-diameter marker sizes to scatter areas. - """ - sizes, _ = self._parse_markersize( - markersize, - area_size=False, - absolute_size=True, - ) - return sizes - def _apply_scatter(self, xs, ys, ss, cc, *, vert=True, **kwargs): """ Apply scatter or scatterx markers. @@ -5622,7 +5595,7 @@ def _apply_scatter(self, xs, ys, ss, cc, *, vert=True, **kwargs): "markerfacecolor": "c", "markeredgecolor": "edgecolors", "marker": "marker", - "markersize": "markersize", + "markersize": "s", "markeredgewidth": "linewidths", "linestyle": "linestyles", "linewidth": "linewidths", @@ -5630,25 +5603,11 @@ def _apply_scatter(self, xs, ys, ss, cc, *, vert=True, **kwargs): kw = kwargs.copy() inbounds = kw.pop("inbounds", None) - marker_size = self._pop_markersize_as_diameter(kw) - kw.update(_pop_props(kw, "collection", skip=SCATTER_MARKERSIZE_KEYS)) + 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) ys, kw = inputs._dist_reduce(ys, **kw) - size_is_parsed = False - if marker_size is not None: - if ss is None: - ss = self._parse_markersize_as_diameter(marker_size) - size_is_parsed = True - for key in ("area_size", "absolute_size", "smin", "smax"): - kw.pop(key, None) - else: - warnings._warn_ultraplot( - "Got conflicting scatter size arguments. Using s/size/sizes " - "and ignoring ms/markersize." - ) - if not size_is_parsed: - ss, kw = self._parse_markersize(ss, **kw) # parse 's' + ss, kw = self._parse_markersize(ss, **kw) # parse 's' # Only parse color if explicitly provided infer_rgb = True @@ -5680,9 +5639,6 @@ def _apply_scatter(self, xs, ys, ss, cc, *, vert=True, **kwargs): # Note: they could be None kw["s"], kw["c"] = s, c kw = self._parse_cycle(n, cycle_manually=cycle_manually, **kw) - cycle_marker_size = self._pop_markersize_as_diameter(kw) - if cycle_marker_size is not None and kw.get("s") is None: - kw["s"] = self._parse_markersize_as_diameter(cycle_marker_size) *eb, kw = self._add_error_bars(x, y, vert=vert, default_barstds=True, **kw) *es, kw = self._add_error_shading(x, y, vert=vert, color_key="c", **kw) if not vert: @@ -5700,7 +5656,7 @@ def _apply_scatter(self, xs, ys, ss, cc, *, vert=True, **kwargs): @inputs._preprocess_or_redirect( "x", "y", - SCATTER_SIZE_KEYS, + _get_aliases("collection", "sizes"), _get_aliases("collection", "colors", "facecolors"), keywords=_get_aliases("collection", "linewidths", "edgecolors"), ) @@ -5716,7 +5672,7 @@ def scatter(self, *args, **kwargs): @inputs._preprocess_or_redirect( "y", "x", - SCATTER_SIZE_KEYS, + _get_aliases("collection", "sizes"), _get_aliases("collection", "colors", "facecolors"), keywords=_get_aliases("collection", "linewidths", "edgecolors"), ) diff --git a/ultraplot/tests/test_1dplots.py b/ultraplot/tests/test_1dplots.py index 77addecb9..c04486a12 100644 --- a/ultraplot/tests/test_1dplots.py +++ b/ultraplot/tests/test_1dplots.py @@ -447,10 +447,9 @@ def test_scatter_alpha(rng): return fig -def test_scatter_s_is_area_ms_is_diameter(): +def test_scatter_size_aliases_are_areas(): """ - Scatter preserves matplotlib ``s`` area semantics while ``ms`` / - ``markersize`` use Line2D-style diameter semantics. + Scatter preserves existing area semantics for all size aliases. """ fig, ax = uplt.subplots() try: @@ -463,18 +462,18 @@ def test_scatter_s_is_area_ms_is_diameter(): 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**2) - assert markersize.get_sizes()[0] == pytest.approx(30**2) + 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_diameter(): +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**2) + assert obj.get_sizes()[0] == pytest.approx(30) finally: uplt.close(fig) From 1c64321bd6ddbafc0c72b193a02589214cbf6317 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Wed, 1 Jul 2026 19:34:27 +1000 Subject: [PATCH 4/4] Align parameters and call it a day --- ultraplot/axes/base.py | 8 ----- ultraplot/axes/plot.py | 11 +++++++ ultraplot/figure.py | 4 --- ultraplot/legend.py | 59 ++++++++++++++++++---------------- ultraplot/tests/test_legend.py | 42 ++++++++++++------------ 5 files changed, 63 insertions(+), 61 deletions(-) diff --git a/ultraplot/axes/base.py b/ultraplot/axes/base.py index dd50a5e23..8174d870b 100644 --- a/ultraplot/axes/base.py +++ b/ultraplot/axes/base.py @@ -3773,10 +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. - area : bool, default: False - Whether marker-size style keywords are interpreted as areas - (``True``) or diameters (``False``). - Other parameters ---------------- %(legend.semantic_style_kwargs)s @@ -3806,10 +3802,6 @@ def entrylegend(self, entries, **kwargs): :rc:`legend.cat.line`. marker, color %(legend.semantic_style_arg)s - area : bool, default: False - Whether marker-size style keywords are interpreted as areas - (``True``) or diameters (``False``). - Other parameters ---------------- %(legend.semantic_style_kwargs)s diff --git a/ultraplot/axes/plot.py b/ultraplot/axes/plot.py index 415104ed3..83eba0d93 100644 --- a/ultraplot/axes/plot.py +++ b/ultraplot/axes/plot.py @@ -5603,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/figure.py b/ultraplot/figure.py index 4866e68fc..5493755ab 100644 --- a/ultraplot/figure.py +++ b/ultraplot/figure.py @@ -3244,7 +3244,6 @@ def entrylegend( self, entries, *, - area=None, line=None, marker=None, color=None, @@ -3267,7 +3266,6 @@ def entrylegend( ) handles, labels = plegend.UltraLegend(axes).entrylegend( entries, - area=area, line=line, marker=marker, color=color, @@ -3290,7 +3288,6 @@ def catlegend( self, categories, *, - area=None, colors=None, markers=None, line=None, @@ -3313,7 +3310,6 @@ def catlegend( ) handles, labels = plegend.UltraLegend(axes).catlegend( categories, - area=area, colors=colors, markers=markers, line=line, diff --git a/ultraplot/legend.py b/ultraplot/legend.py index ab345c304..c11d4e14b 100644 --- a/ultraplot/legend.py +++ b/ultraplot/legend.py @@ -1048,7 +1048,9 @@ def _default_cycle_colors(): "linestyles": "linestyle", "linewidths": "markeredgewidth", } -_ENTRY_MARKERSIZE_KEYS = ("markersize", "s", "size", "ms", "markersizes", "sizes") +_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]: @@ -1089,15 +1091,23 @@ def _area_to_markersize(value: Any) -> Any: return [_area_to_markersize(val) for val in values] -def _pop_marker_size(kwargs: dict[str, Any], *, area: bool = False) -> Any: +def _pop_marker_size(kwargs: dict[str, Any]) -> Any: """ Pop marker-size aliases and return Line2D marker diameters. - """ - opts = {key: kwargs.pop(key, None) for key in _ENTRY_MARKERSIZE_KEYS} - value = _not_none(**opts) - if value is None: + + 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(value) if area else value + return _area_to_markersize(area) def _pop_line2d_setters(kwargs: dict[str, Any]) -> dict[str, Any]: @@ -1123,7 +1133,7 @@ def _pop_line2d_setters(kwargs: dict[str, Any]) -> dict[str, Any]: return extracted -def _pop_entry_props(kwargs: dict[str, Any], *, area: bool = False) -> dict[str, Any]: +def _pop_entry_props(kwargs: dict[str, Any]) -> dict[str, Any]: """ Extract ``LegendEntry`` style properties from ``kwargs``. @@ -1131,7 +1141,8 @@ def _pop_entry_props(kwargs: dict[str, Any], *, area: bool = False) -> dict[str, 1. Full-name properties recognised by ``_pop_props(kwargs, "line")``. 2. Collection-style plurals (``colors`` → ``color``, …). - 3. Marker-size aliases, converted from area when ``area=True``. + 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``. @@ -1144,7 +1155,7 @@ def _pop_entry_props(kwargs: dict[str, Any], *, area: bool = False) -> dict[str, if key in kwargs: advanced_marker[key] = kwargs.pop(key) - marker_size = _pop_marker_size(kwargs, area=area) + marker_size = _pop_marker_size(kwargs) resolved_aliases = _pop_aliases(kwargs, _LINE_ALIAS_MAP) explicit_collection = _pop_plurals(kwargs, _ENTRY_STYLE_FROM_COLLECTION) @@ -1286,7 +1297,6 @@ def _cat_legend_entries( def _entry_legend_entries( entries: Iterable[Any] | Mapping[Any, Any], *, - area: bool, line: bool, marker, color, @@ -1361,7 +1371,7 @@ def _entry_legend_entries( entry_style = {} else: entry_style = {"color": entry_spec} - entry_style.update(_pop_entry_props(entry_style, area=area)) + entry_style.update(_pop_entry_props(entry_style)) entry_label = entry_style.pop("label", entry_label) entry_label = entry_style.pop("name", entry_label) @@ -1684,10 +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. ``markersize`` / ``ms`` obey the helper's ``area`` - setting: areas when ``area=True`` and diameters when ``area=False``. + Marker dimensions. ``markersize`` / ``ms`` denote marker diameter in points. ``s`` / ``size`` / ``sizes`` - Marker-size aliases with the same ``area`` semantics as ``markersize``. + 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`` @@ -1701,7 +1711,7 @@ def _normalize_em_kwargs(kwargs: dict[str, Any], *, fontsize: float) -> dict[str 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 -marker-size alias. +scatter-style area alias. Each value accepts the scalar / sequence / mapping forms described in ``%(legend.semantic_style_arg)s``.""" @@ -1773,7 +1783,6 @@ def entrylegend( self, entries: Iterable[Any] | Mapping[Any, Any], *, - area: Optional[bool] = None, line: Optional[bool] = None, marker=None, color=None, @@ -1785,11 +1794,10 @@ def entrylegend( Build generic semantic legend entries and optionally draw a legend. Public docs live on :meth:`Axes.entrylegend`. """ - area = _not_none(area, False) styles = {} if handle_kw: - styles.update(_pop_entry_props(handle_kw, area=area)) - styles.update(_pop_entry_props(kwargs, area=area)) + styles.update(_pop_entry_props(handle_kw)) + styles.update(_pop_entry_props(kwargs)) line = _not_none(line, styles.pop("line", None), rc["legend.cat.line"]) marker = _not_none(marker, styles.pop("marker", None), rc["legend.cat.marker"]) @@ -1810,7 +1818,6 @@ def entrylegend( handles, labels = _entry_legend_entries( entries, - area=area, line=line, marker=marker, color=color, @@ -1832,7 +1839,6 @@ def catlegend( self, categories: Iterable[Any], *, - area: Optional[bool] = None, color=None, marker=None, line: Optional[bool] = None, @@ -1844,11 +1850,10 @@ def catlegend( Build categorical legend entries and optionally draw a legend. Public docs live on :meth:`Axes.catlegend`. """ - area = _not_none(area, False) styles = {} if handle_kw: - styles.update(_pop_entry_props(handle_kw, area=area)) - styles.update(_pop_entry_props(kwargs, area=area)) + styles.update(_pop_entry_props(handle_kw)) + styles.update(_pop_entry_props(kwargs)) line = _not_none(line, styles.pop("line", None), rc["legend.cat.line"]) color = _not_none(color, styles.pop("color", None)) @@ -1910,8 +1915,8 @@ def sizelegend( area = _not_none(area, rc["legend.size.area"]) styles = {} if handle_kw: - styles.update(_pop_entry_props(handle_kw, area=area)) - styles.update(_pop_entry_props(kwargs, area=area)) + 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"]) scale = _not_none(scale, rc["legend.size.scale"]) diff --git a/ultraplot/tests/test_legend.py b/ultraplot/tests/test_legend.py index e98454810..a41b2c1db 100644 --- a/ultraplot/tests/test_legend.py +++ b/ultraplot/tests/test_legend.py @@ -426,7 +426,7 @@ def test_entrylegend_handle_kw_with_per_entry_mappings(): uplt.close(fig) -def test_entrylegend_marker_sizes_obey_area(): +def test_entrylegend_scatter_sizes_are_converted_to_diameters(): fig, ax = uplt.subplots() handles, labels = ax.entrylegend( [ @@ -437,23 +437,22 @@ def test_entrylegend_marker_sizes_obey_area(): add=False, ) assert labels == ["Size", "Diameter", "Full name"] - assert handles[0].get_markersize() == pytest.approx(100) + 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, "s": 100}, - {"label": "MS", "line": False, "ms": 100}, - {"label": "Full name", "line": False, "markersize": 144}, + {"label": "Size", "line": False, "size": 144}, + {"label": "Sizes", "line": False, "sizes": 169}, + {"label": "Full name", "line": False, "markersize": 14}, ], - area=True, add=False, ) - assert labels == ["Size", "MS", "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) + 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) @@ -491,7 +490,7 @@ def test_catlegend_handle_kw_accepts_line_scatter_aliases(): uplt.close(fig) -def test_catlegend_marker_sizes_obey_area(): +def test_catlegend_scatter_sizes_are_converted_to_diameters(): fig, ax = uplt.subplots() handles, labels = ax.catlegend( ["A", "B"], @@ -499,26 +498,25 @@ def test_catlegend_marker_sizes_obey_area(): sizes={"A": 100, "B": 144}, ) assert labels == ["A", "B"] - assert handles[0].get_markersize() == pytest.approx(100) - assert handles[1].get_markersize() == pytest.approx(144) + assert handles[0].get_markersize() == pytest.approx(10) + assert handles[1].get_markersize() == pytest.approx(12) handles, labels = ax.catlegend( ["A", "B"], - area=True, add=False, - sizes={"A": 100, "B": 144}, + size={"A": 121, "B": 169}, ) assert labels == ["A", "B"] - assert handles[0].get_markersize() == pytest.approx(10) - assert handles[1].get_markersize() == pytest.approx(12) + assert handles[0].get_markersize() == pytest.approx(11) + assert handles[1].get_markersize() == pytest.approx(13) - handles, labels = ax.catlegend(["C"], area=True, add=False, ms=144) + handles, labels = ax.catlegend(["C"], add=False, ms=14) assert labels == ["C"] - assert handles[0].get_markersize() == pytest.approx(12) + assert handles[0].get_markersize() == pytest.approx(14) uplt.close(fig) -def test_sizelegend_marker_size_overrides_obey_area(): +def test_sizelegend_marker_size_overrides_use_semantic_size_rules(): fig, ax = uplt.subplots() handles, labels = ax.sizelegend( [1.0], @@ -527,13 +525,13 @@ def test_sizelegend_marker_size_overrides_obey_area(): markersize=100, ) assert labels == ["1"] - assert handles[0].get_markersize() == pytest.approx(10) + assert handles[0].get_markersize() == pytest.approx(100) handles, labels = ax.sizelegend( [1.0], area=False, add=False, - s=10, + s=100, ) assert labels == ["1"] assert handles[0].get_markersize() == pytest.approx(10)