Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 0 additions & 2 deletions ultraplot/axes/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
16 changes: 14 additions & 2 deletions ultraplot/axes/plot.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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)
Expand Down
106 changes: 93 additions & 13 deletions ultraplot/legend.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -937,6 +961,7 @@ def _is_color_like(value):
"c": "color",
"m": "marker",
"ms": "markersize",
"markersizes": "markersize",
"ls": "linestyle",
"lw": "linewidth",
"mec": "markeredgecolor",
Expand Down Expand Up @@ -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]:
Expand All @@ -1037,14 +1063,53 @@ 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:
explicit[key] = kwargs.pop(key)
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.
Expand Down Expand Up @@ -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
Expand All @@ -1088,18 +1155,26 @@ 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():
value = collection_props.get(source, None)
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)

Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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``
Expand All @@ -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``."""

Expand Down Expand Up @@ -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"])
Expand Down
31 changes: 31 additions & 0 deletions ultraplot/tests/test_1dplots.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
"""
Expand Down
93 changes: 93 additions & 0 deletions ultraplot/tests/test_legend.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand Down