diff --git a/doc/users/next_whats_new/extending_MarkerStyle.rst b/doc/users/next_whats_new/extending_MarkerStyle.rst new file mode 100644 index 000000000000..6e970d0738fe --- /dev/null +++ b/doc/users/next_whats_new/extending_MarkerStyle.rst @@ -0,0 +1,19 @@ +New customization of MarkerStyle +-------------------------------- + +New MarkerStyle parameters allow control of join style and cap style, and for +the user to supply a transformation to be applied to the marker (e.g. a rotation). + +.. plot:: + :include-source: true + + import matplotlib.pyplot as plt + from matplotlib.markers import MarkerStyle + from matplotlib.transforms import Affine2D + fig, ax = plt.subplots(figsize=(6, 1)) + fig.suptitle('New markers', fontsize=14) + for col, (size, rot) in enumerate(zip([2, 5, 10], [0, 45, 90])): + t = Affine2D().rotate_deg(rot).scale(size) + ax.plot(col, 0, marker=MarkerStyle("*", transform=t)) + ax.axis("off") + ax.set_xlim(-0.1, 2.4) diff --git a/examples/lines_bars_and_markers/marker_reference.py b/examples/lines_bars_and_markers/marker_reference.py index 98af2519124c..c4564d2b027d 100644 --- a/examples/lines_bars_and_markers/marker_reference.py +++ b/examples/lines_bars_and_markers/marker_reference.py @@ -19,8 +19,10 @@ .. redirect-from:: /gallery/shapes_and_collections/marker_path """ +from matplotlib.markers import MarkerStyle import matplotlib.pyplot as plt from matplotlib.lines import Line2D +from matplotlib.transforms import Affine2D text_style = dict(horizontalalignment='right', verticalalignment='center', @@ -159,3 +161,96 @@ def split_list(a_list): format_axes(ax) plt.show() + +############################################################################### +# Advanced marker modifications with transform +# ============================================ +# +# Markers can be modified by passing a transform to the MarkerStyle +# constructor. Following example shows how a supplied rotation is applied to +# several marker shapes. + +common_style = {k: v for k, v in filled_marker_style.items() if k != 'marker'} +angles = [0, 10, 20, 30, 45, 60, 90] + +fig, ax = plt.subplots() +fig.suptitle('Rotated markers', fontsize=14) + +ax.text(-0.5, 0, 'Filled marker', **text_style) +for x, theta in enumerate(angles): + t = Affine2D().rotate_deg(theta) + ax.plot(x, 0, marker=MarkerStyle('o', 'left', t), **common_style) + +ax.text(-0.5, 1, 'Un-filled marker', **text_style) +for x, theta in enumerate(angles): + t = Affine2D().rotate_deg(theta) + ax.plot(x, 1, marker=MarkerStyle('1', 'left', t), **common_style) + +ax.text(-0.5, 2, 'Equation marker', **text_style) +for x, theta in enumerate(angles): + t = Affine2D().rotate_deg(theta) + eq = r'$\frac{1}{x}$' + ax.plot(x, 2, marker=MarkerStyle(eq, 'left', t), **common_style) + +for x, theta in enumerate(angles): + ax.text(x, 2.5, f"{theta}°", horizontalalignment="center") +format_axes(ax) + +fig.tight_layout() +plt.show() + +############################################################################### +# Setting marker cap style and join style +# ======================================= +# +# Markers have default cap and join styles, but these can be +# customized when creating a MarkerStyle. + +from matplotlib.markers import JoinStyle, CapStyle + +marker_inner = dict(markersize=35, + markerfacecolor='tab:blue', + markerfacecoloralt='lightsteelblue', + markeredgecolor='brown', + markeredgewidth=8, + ) + +marker_outer = dict(markersize=35, + markerfacecolor='tab:blue', + markerfacecoloralt='lightsteelblue', + markeredgecolor='white', + markeredgewidth=1, + ) + +fig, ax = plt.subplots() +fig.suptitle('Marker CapStyle', fontsize=14) +fig.subplots_adjust(left=0.1) + +for y, cap_style in enumerate(CapStyle): + ax.text(-0.5, y, cap_style.name, **text_style) + for x, theta in enumerate(angles): + t = Affine2D().rotate_deg(theta) + m = MarkerStyle('1', transform=t, capstyle=cap_style) + ax.plot(x, y, marker=m, **marker_inner) + ax.plot(x, y, marker=m, **marker_outer) + ax.text(x, len(CapStyle) - .5, f'{theta}°', ha='center') +format_axes(ax) +plt.show() + +############################################################################### +# Modifying the join style: + +fig, ax = plt.subplots() +fig.suptitle('Marker JoinStyle', fontsize=14) +fig.subplots_adjust(left=0.05) + +for y, join_style in enumerate(JoinStyle): + ax.text(-0.5, y, join_style.name, **text_style) + for x, theta in enumerate(angles): + t = Affine2D().rotate_deg(theta) + m = MarkerStyle('*', transform=t, joinstyle=join_style) + ax.plot(x, y, marker=m, **marker_inner) + ax.text(x, len(JoinStyle) - .5, f'{theta}°', ha='center') +format_axes(ax) + +plt.show() diff --git a/examples/lines_bars_and_markers/multivariate_marker_plot.py b/examples/lines_bars_and_markers/multivariate_marker_plot.py new file mode 100644 index 000000000000..487bb2b85fd3 --- /dev/null +++ b/examples/lines_bars_and_markers/multivariate_marker_plot.py @@ -0,0 +1,46 @@ +""" +============================================== +Mapping marker properties to multivariate data +============================================== + +This example shows how to use different properties of markers to plot +multivariate datasets. Here we represent a successful baseball throw as a +smiley face with marker size mapped to the skill of thrower, marker rotation to +the take-off angle, and thrust to the marker color. +""" + +import numpy as np +import matplotlib.pyplot as plt +from matplotlib.markers import MarkerStyle +from matplotlib.transforms import Affine2D +from matplotlib.textpath import TextPath +from matplotlib.colors import Normalize + +SUCCESS_SYMBOLS = [ + TextPath((0, 0), "☹"), + TextPath((0, 0), "😒"), + TextPath((0, 0), "☺"), +] + +N = 25 +np.random.seed(42) +skills = np.random.uniform(5, 80, size=N) * 0.1 + 5 +takeoff_angles = np.random.normal(0, 90, N) +thrusts = np.random.uniform(size=N) +successfull = np.random.randint(0, 3, size=N) +positions = np.random.normal(size=(N, 2)) * 5 +data = zip(skills, takeoff_angles, thrusts, successfull, positions) + +cmap = plt.cm.get_cmap("plasma") +fig, ax = plt.subplots() +fig.suptitle("Throwing success", size=14) +for skill, takeoff, thrust, mood, pos in data: + t = Affine2D().scale(skill).rotate_deg(takeoff) + m = MarkerStyle(SUCCESS_SYMBOLS[mood], transform=t) + ax.plot(pos[0], pos[1], marker=m, color=cmap(thrust)) +fig.colorbar(plt.cm.ScalarMappable(norm=Normalize(0, 1), cmap=cmap), + ax=ax, label="Normalized Thrust [a.u.]") +ax.set_xlabel("X position [m]") +ax.set_ylabel("Y position [m]") + +plt.show() diff --git a/lib/matplotlib/lines.py b/lib/matplotlib/lines.py index ea3b98b63208..f1efd125a4ed 100644 --- a/lib/matplotlib/lines.py +++ b/lib/matplotlib/lines.py @@ -370,7 +370,10 @@ def __init__(self, xdata, ydata, self.set_color(color) if marker is None: marker = 'none' # Default. - self._marker = MarkerStyle(marker, fillstyle) + if not isinstance(marker, MarkerStyle): + self._marker = MarkerStyle(marker, fillstyle) + else: + self._marker = marker self._markevery = None self._markersize = None diff --git a/lib/matplotlib/markers.py b/lib/matplotlib/markers.py index 8fbbb71818a4..b1d6fa2be6a4 100644 --- a/lib/matplotlib/markers.py +++ b/lib/matplotlib/markers.py @@ -82,11 +82,16 @@ plt.plot([1, 2, 3], marker=11) plt.plot([1, 2, 3], marker=matplotlib.markers.CARETDOWNBASE) +Markers join and cap styles can be customized by creating a new instance of +MarkerStyle. +A MarkerStyle can also have a custom `~matplotlib.transforms.Transform` +allowing it to be arbitrarily rotated or offset. + Examples showing the use of markers: * :doc:`/gallery/lines_bars_and_markers/marker_reference` * :doc:`/gallery/lines_bars_and_markers/scatter_star_poly` - +* :doc:`/gallery/lines_bars_and_markers/multivariate_marker_plot` .. |m00| image:: /_static/markers/m00.png .. |m01| image:: /_static/markers/m01.png @@ -127,6 +132,7 @@ .. |m36| image:: /_static/markers/m36.png .. |m37| image:: /_static/markers/m37.png """ +import copy from collections.abc import Sized import inspect @@ -221,7 +227,8 @@ class MarkerStyle: _unset = object() # For deprecation of MarkerStyle(). - def __init__(self, marker=_unset, fillstyle=None): + def __init__(self, marker=_unset, fillstyle=None, + transform=None, capstyle=None, joinstyle=None): """ Parameters ---------- @@ -234,8 +241,21 @@ def __init__(self, marker=_unset, fillstyle=None): fillstyle : str, default: :rc:`markers.fillstyle` One of 'full', 'left', 'right', 'bottom', 'top', 'none'. + + transform : transforms.Transform, default: None + Transform that will be combined with the native transform of the + marker. + + capstyle : CapStyle, default: None + Cap style that will override the default cap style of the marker. + + joinstyle : JoinStyle, default: None + Join style that will override the default join style of the marker. """ self._marker_function = None + self._user_transform = transform + self._user_capstyle = capstyle + self._user_joinstyle = joinstyle self._set_fillstyle(fillstyle) # Remove _unset and signature rewriting after deprecation elapses. if marker is self._unset: @@ -265,7 +285,7 @@ def _recache(self): self._alt_transform = None self._snap_threshold = None self._joinstyle = JoinStyle.round - self._capstyle = CapStyle.butt + self._capstyle = self._user_capstyle or CapStyle.butt # Initial guess: Assume the marker is filled unless the fillstyle is # set to 'none'. The marker function will override this for unfilled # markers. @@ -342,7 +362,8 @@ def _set_marker(self, marker): self._marker_function = getattr( self, '_set_' + self.markers[marker]) elif isinstance(marker, MarkerStyle): - self.__dict__.update(marker.__dict__) + self.__dict__ = copy.deepcopy(marker.__dict__) + else: try: Path(marker) @@ -369,7 +390,10 @@ def get_transform(self): Return the transform to be applied to the `.Path` from `MarkerStyle.get_path()`. """ - return self._transform.frozen() + if self._user_transform is None: + return self._transform.frozen() + else: + return (self._transform + self._user_transform).frozen() def get_alt_path(self): """ @@ -385,11 +409,86 @@ def get_alt_transform(self): Return the transform to be applied to the `.Path` from `MarkerStyle.get_alt_path()`. """ - return self._alt_transform.frozen() + if self._user_transform is None: + return self._alt_transform.frozen() + else: + return (self._alt_transform + self._user_transform).frozen() def get_snap_threshold(self): return self._snap_threshold + def get_user_transform(self): + """Return user supplied part of marker transform.""" + if self._user_transform is not None: + return self._user_transform.frozen() + + def transformed(self, transform: Affine2D): + """ + Return a new version of this marker with the transform applied. + + Parameters + ---------- + transform : Affine2D, default: None + Transform will be combined with current user supplied transform. + """ + new_marker = MarkerStyle(self) + if new_marker._user_transform is not None: + new_marker._user_transform += transform + else: + new_marker._user_transform = transform + return new_marker + + def rotated(self, *, deg=None, rad=None): + """ + Return a new version of this marker rotated by specified angle. + + Parameters + ---------- + deg : float, default: None + Rotation angle in degrees. + + rad : float, default: None + Rotation angle in radians. + + .. note:: You must specify exactly one of deg or rad. + """ + if deg is None and rad is None: + raise ValueError('One of deg or rad is required') + if deg is not None and rad is not None: + raise ValueError('Only one of deg and rad can be supplied') + new_marker = MarkerStyle(self) + if new_marker._user_transform is None: + new_marker._user_transform = Affine2D() + + if deg is not None: + new_marker._user_transform.rotate_deg(deg) + if rad is not None: + new_marker._user_transform.rotate(rad) + + return new_marker + + def scaled(self, sx, sy=None): + """ + Return new marker scaled by specified scale factors. + + If *sy* is None, the same scale is applied in both the *x*- and + *y*-directions. + + Parameters + ---------- + sx : float + *X*-direction scaling factor. + sy : float, default: None + *Y*-direction scaling factor. + """ + if sy is None: + sy = sx + + new_marker = MarkerStyle(self) + _transform = new_marker._user_transform or Affine2D() + new_marker._user_transform = _transform.scale(sx, sy) + return new_marker + def _set_nothing(self): self._filled = False @@ -413,14 +512,14 @@ def _set_tuple_marker(self): symstyle = marker[1] if symstyle == 0: self._path = Path.unit_regular_polygon(numsides) - self._joinstyle = JoinStyle.miter + self._joinstyle = self._user_joinstyle or JoinStyle.miter elif symstyle == 1: self._path = Path.unit_regular_star(numsides) - self._joinstyle = JoinStyle.bevel + self._joinstyle = self._user_joinstyle or JoinStyle.bevel elif symstyle == 2: self._path = Path.unit_regular_asterisk(numsides) self._filled = False - self._joinstyle = JoinStyle.bevel + self._joinstyle = self._user_joinstyle or JoinStyle.bevel else: raise ValueError(f"Unexpected tuple marker: {marker}") self._transform = Affine2D().scale(0.5).rotate_deg(rotation) @@ -521,7 +620,7 @@ def _set_triangle(self, rot, skip): self._alt_transform = self._transform - self._joinstyle = JoinStyle.miter + self._joinstyle = self._user_joinstyle or JoinStyle.miter def _set_triangle_up(self): return self._set_triangle(0.0, 0) @@ -551,7 +650,7 @@ def _set_square(self): self._transform.rotate_deg(rotate) self._alt_transform = self._transform - self._joinstyle = JoinStyle.miter + self._joinstyle = self._user_joinstyle or JoinStyle.miter def _set_diamond(self): self._transform = Affine2D().translate(-0.5, -0.5).rotate_deg(45) @@ -565,7 +664,7 @@ def _set_diamond(self): rotate = {'right': 0, 'top': 90, 'left': 180, 'bottom': 270}[fs] self._transform.rotate_deg(rotate) self._alt_transform = self._transform - self._joinstyle = JoinStyle.miter + self._joinstyle = self._user_joinstyle or JoinStyle.miter def _set_thin_diamond(self): self._set_diamond() @@ -592,7 +691,7 @@ def _set_pentagon(self): }[self.get_fillstyle()] self._alt_transform = self._transform - self._joinstyle = JoinStyle.miter + self._joinstyle = self._user_joinstyle or JoinStyle.miter def _set_star(self): self._transform = Affine2D().scale(0.5) @@ -614,7 +713,7 @@ def _set_star(self): }[self.get_fillstyle()] self._alt_transform = self._transform - self._joinstyle = JoinStyle.bevel + self._joinstyle = self._user_joinstyle or JoinStyle.bevel def _set_hexagon1(self): self._transform = Affine2D().scale(0.5) @@ -638,7 +737,7 @@ def _set_hexagon1(self): }[self.get_fillstyle()] self._alt_transform = self._transform - self._joinstyle = JoinStyle.miter + self._joinstyle = self._user_joinstyle or JoinStyle.miter def _set_hexagon2(self): self._transform = Affine2D().scale(0.5).rotate_deg(30) @@ -664,7 +763,7 @@ def _set_hexagon2(self): }[self.get_fillstyle()] self._alt_transform = self._transform - self._joinstyle = JoinStyle.miter + self._joinstyle = self._user_joinstyle or JoinStyle.miter def _set_octagon(self): self._transform = Affine2D().scale(0.5) @@ -685,7 +784,7 @@ def _set_octagon(self): {'left': 0, 'bottom': 90, 'right': 180, 'top': 270}[fs]) self._alt_transform = self._transform.frozen().rotate_deg(180.0) - self._joinstyle = JoinStyle.miter + self._joinstyle = self._user_joinstyle or JoinStyle.miter _line_marker_path = Path([[0.0, -1.0], [0.0, 1.0]]) @@ -759,7 +858,7 @@ def _set_caretdown(self): self._snap_threshold = 3.0 self._filled = False self._path = self._caret_path - self._joinstyle = JoinStyle.miter + self._joinstyle = self._user_joinstyle or JoinStyle.miter def _set_caretup(self): self._set_caretdown() @@ -825,7 +924,7 @@ def _set_x(self): def _set_plus_filled(self): self._transform = Affine2D() self._snap_threshold = 5.0 - self._joinstyle = JoinStyle.miter + self._joinstyle = self._user_joinstyle or JoinStyle.miter if not self._half_fill(): self._path = self._plus_filled_path else: @@ -849,7 +948,7 @@ def _set_plus_filled(self): def _set_x_filled(self): self._transform = Affine2D() self._snap_threshold = 5.0 - self._joinstyle = JoinStyle.miter + self._joinstyle = self._user_joinstyle or JoinStyle.miter if not self._half_fill(): self._path = self._x_filled_path else: diff --git a/lib/matplotlib/tests/test_marker.py b/lib/matplotlib/tests/test_marker.py index 75681b0e1a9b..894dead24e84 100644 --- a/lib/matplotlib/tests/test_marker.py +++ b/lib/matplotlib/tests/test_marker.py @@ -4,6 +4,7 @@ from matplotlib._api.deprecation import MatplotlibDeprecationWarning from matplotlib.path import Path from matplotlib.testing.decorators import check_figures_equal +from matplotlib.transforms import Affine2D import pytest @@ -204,3 +205,99 @@ def test_marker_clipping(fig_ref, fig_test): ax_test.set(xlim=(-0.5, ncol), ylim=(-0.5, 2 * nrow)) ax_ref.axis('off') ax_test.axis('off') + + +def test_marker_init_transforms(): + """Test that initializing marker with transform is a simple addition.""" + marker = markers.MarkerStyle("o") + t = Affine2D().translate(1, 1) + t_marker = markers.MarkerStyle("o", transform=t) + assert marker.get_transform() + t == t_marker.get_transform() + + +def test_marker_init_joinstyle(): + marker = markers.MarkerStyle("*") + jstl = markers.JoinStyle.round + styled_marker = markers.MarkerStyle("*", joinstyle=jstl) + assert styled_marker.get_joinstyle() == jstl + assert marker.get_joinstyle() != jstl + + +def test_marker_init_captyle(): + marker = markers.MarkerStyle("*") + capstl = markers.CapStyle.round + styled_marker = markers.MarkerStyle("*", capstyle=capstl) + assert styled_marker.get_capstyle() == capstl + assert marker.get_capstyle() != capstl + + +@pytest.mark.parametrize("marker,transform,expected", [ + (markers.MarkerStyle("o"), Affine2D().translate(1, 1), + Affine2D().translate(1, 1)), + (markers.MarkerStyle("o", transform=Affine2D().translate(1, 1)), + Affine2D().translate(1, 1), Affine2D().translate(2, 2)), + # (markers.MarkerStyle("$|||$", transform=Affine2D().translate(1, 1)), + # Affine2D().translate(1, 1), Affine2D().translate(2, 2)), + (markers.MarkerStyle( + markers.TICKLEFT, transform=Affine2D().translate(1, 1)), + Affine2D().translate(1, 1), Affine2D().translate(2, 2)), +]) +def test_marker_transformed(marker, transform, expected): + new_marker = marker.transformed(transform) + assert new_marker is not marker + assert new_marker.get_user_transform() == expected + assert marker._user_transform is not new_marker._user_transform + + +def test_marker_rotated_invalid(): + marker = markers.MarkerStyle("o") + with pytest.raises(ValueError): + new_marker = marker.rotated() + with pytest.raises(ValueError): + new_marker = marker.rotated(deg=10, rad=10) + + +@pytest.mark.parametrize("marker,deg,rad,expected", [ + (markers.MarkerStyle("o"), 10, None, Affine2D().rotate_deg(10)), + (markers.MarkerStyle("o"), None, 0.01, Affine2D().rotate(0.01)), + (markers.MarkerStyle("o", transform=Affine2D().translate(1, 1)), + 10, None, Affine2D().translate(1, 1).rotate_deg(10)), + (markers.MarkerStyle("o", transform=Affine2D().translate(1, 1)), + None, 0.01, Affine2D().translate(1, 1).rotate(0.01)), + # (markers.MarkerStyle("$|||$", transform=Affine2D().translate(1, 1)), + # 10, None, Affine2D().translate(1, 1).rotate_deg(10)), + (markers.MarkerStyle( + markers.TICKLEFT, transform=Affine2D().translate(1, 1)), + 10, None, Affine2D().translate(1, 1).rotate_deg(10)), +]) +def test_marker_rotated(marker, deg, rad, expected): + new_marker = marker.rotated(deg=deg, rad=rad) + assert new_marker is not marker + assert new_marker.get_user_transform() == expected + assert marker._user_transform is not new_marker._user_transform + + +def test_marker_scaled(): + marker = markers.MarkerStyle("1") + new_marker = marker.scaled(2) + assert new_marker is not marker + assert new_marker.get_user_transform() == Affine2D().scale(2) + assert marker._user_transform is not new_marker._user_transform + + new_marker = marker.scaled(2, 3) + assert new_marker is not marker + assert new_marker.get_user_transform() == Affine2D().scale(2, 3) + assert marker._user_transform is not new_marker._user_transform + + marker = markers.MarkerStyle("1", transform=Affine2D().translate(1, 1)) + new_marker = marker.scaled(2) + assert new_marker is not marker + expected = Affine2D().translate(1, 1).scale(2) + assert new_marker.get_user_transform() == expected + assert marker._user_transform is not new_marker._user_transform + + +def test_alt_transform(): + m1 = markers.MarkerStyle("o", "left") + m2 = markers.MarkerStyle("o", "left", Affine2D().rotate_deg(90)) + assert m1.get_alt_transform().rotate_deg(90) == m2.get_alt_transform()