diff --git a/CHANGELOG.md b/CHANGELOG.md index c92c3ea6d6..32590d344f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,9 @@ This project adheres to [Semantic Versioning](http://semver.org/). ## Unreleased +### Added +- Add support for converting matplotlib reference lines (`axhline`, `axvline`, and horizontal/vertical `axline`) as well as axes-coordinate lines into Plotly layout shapes in `mpl_to_plotly`, including support for date axes [[#5719](https://github.com/plotly/plotly.py/pull/5719)], with thanks to @robertoffmoura for the contribution! + ### Fixed - Fix `mpl_to_plotly` tick marker mirroring, axis positioning, and trace visibility for twinned and overlaid subplots (such as `twinx` and `twiny`) [[#5310](https://github.com/plotly/plotly.py/pull/5310)], with thanks to @robertoffmoura for the contribution! - Fix `mpl_to_plotly` losing matplotlib axis line colors by exporting the color of each axis's displayed spine to the plotly x and y axis `linecolor` [[#5311](https://github.com/plotly/plotly.py/pull/5311)], with thanks to @robertoffmoura for the contribution! @@ -15,6 +18,7 @@ This project adheres to [Semantic Versioning](http://semver.org/). - Fix `mpl_to_plotly` crashing on figures with fully transparent or alpha-carrying colors, such as boxplot outlier markers (`facecolor="none"`), rgba line colors, and `color="none"` text and axis labels; all matplotlib colors are now exported through a single helper that maps `"none"` to transparent rgba and keeps or overrides alpha as matplotlib does [[#5700](https://github.com/plotly/plotly.py/pull/5700)], with thanks to @robertoffmoura for the contribution! - Export tick label colors to the plotly tickfont in `mpl_to_plotly` [[#5716](https://github.com/plotly/plotly.py/pull/5716)], with thanks to @robertoffmoura for the contribution! - Fix Plotly Express mutating lists passed to the `x` or `y` arguments in wide mode [[#5727](https://github.com/plotly/plotly.py/pull/5727)], with thanks to @cpruijsen for the contribution! +- Fix `convert_dash` mapping dotted lines (`linestyle=':'`) to invalid `'circle'` in Plotly, correctly converting them to `'dot'` and preserving custom dash patterns as pixel lists, [[#5719](https://github.com/plotly/plotly.py/pull/5719)] with thanks to @robertoffmoura for the contribution! - Fix `px.sunburst`, `px.treemap` and `px.icicle` listing sectors in a different order on every run when `path` is used with a Polars DataFrame; sectors now follow their order of first appearance for all dataframe backends [[#5766](https://github.com/plotly/plotly.py/pull/5766)], with thanks to @Irahan2 for the contribution! - Fix `mpl_to_plotly` conversion of matplotlib contour lines and line collections: close contour rings ending with `Z` codes, support dash styles, convert date x-axes, separate disjoint subpaths with `None` separators, hide line collection traces from the legend by default, and group consecutive same-style lines into single traces [[#5770](https://github.com/plotly/plotly.py/pull/5770)], with thanks to @robertoffmoura for the contribution! diff --git a/plotly/matplotlylib/mplexporter/exporter.py b/plotly/matplotlylib/mplexporter/exporter.py index f027fe4157..969e4a231e 100644 --- a/plotly/matplotlylib/mplexporter/exporter.py +++ b/plotly/matplotlylib/mplexporter/exporter.py @@ -84,12 +84,6 @@ def process_transform( Data transformed to match the given coordinate code. Returned only if data is specified """ - if isinstance(transform, transforms.BlendedGenericTransform): - warnings.warn( - "Blended transforms not yet supported. " - "Zoom behavior may not work as expected." - ) - if force_trans is not None: if data is not None: data = (transform - force_trans).transform(data) diff --git a/plotly/matplotlylib/mpltools.py b/plotly/matplotlylib/mpltools.py index 19615eb298..892282418e 100644 --- a/plotly/matplotlylib/mpltools.py +++ b/plotly/matplotlylib/mpltools.py @@ -57,34 +57,40 @@ def check_corners(inner_obj, outer_obj): return True +VALID_DASH = {"solid", "dot", "dash", "longdash", "dashdot", "longdashdot"} + + def convert_dash(mpl_dash): """Convert mpl line symbol to plotly line symbol and return symbol.""" + if not mpl_dash: + return "solid" if mpl_dash in DASH_MAP: return DASH_MAP[mpl_dash] - else: - dash_array = mpl_dash.split(",") + if mpl_dash in VALID_DASH: + return mpl_dash + if mpl_dash in ("dashed", "--"): + return "dash" + if mpl_dash in ("dotted", ":"): + return "dot" + + dash_array = mpl_dash.replace("px", "").replace(" ", "").split(",") + cleaned = ",".join(dash_array) + if cleaned in DASH_MAP: + return DASH_MAP[cleaned] - if len(dash_array) < 2: - return "solid" + if len(dash_array) < 2: + return "solid" - # Catch the exception where the off length is zero, in case - # matplotlib 'solid' changes from '10,0' to 'N,0' - if math.isclose(float(dash_array[1]), 0.0): - return "solid" + try: + off = float(dash_array[1]) + except ValueError: + return "solid" - # If we can't find the dash pattern in the map, convert it - # into custom values in px, e.g. '7,5' -> '7px,5px' - dashpx = ",".join([x + "px" for x in dash_array]) + if math.isclose(off, 0.0): + return "solid" - # TODO: rewrite the convert_dash code - # only strings 'solid', 'dashed', etc allowed - if dashpx == "7.4px,3.2px": - dashpx = "dashed" - elif dashpx == "12.8px,3.2px,2.0px,3.2px": - dashpx = "dashdot" - elif dashpx == "2.0px,3.3px": - dashpx = "dotted" - return dashpx + # Convert custom dash pattern into px list (e.g. '7,5' -> '7px,5px') + return ",".join([x + "px" for x in dash_array]) def convert_path(path): @@ -603,10 +609,12 @@ def mpl_dates_to_datestrings(dates, mpl_formatter): DASH_MAP = { "10,0": "solid", "6,6": "dash", - "2,2": "circle", + "2,2": "dot", "4,4,2,4": "dashdot", "none": "solid", "7.4,3.2": "dash", + "12.8,3.2,2.0,3.2": "dashdot", + "2.0,3.3": "dot", } PATH_MAP = { diff --git a/plotly/matplotlylib/renderer.py b/plotly/matplotlylib/renderer.py index be10cd9af9..0a3c829945 100644 --- a/plotly/matplotlylib/renderer.py +++ b/plotly/matplotlylib/renderer.py @@ -7,16 +7,21 @@ """ +import math import warnings import matplotlib.patches as mpatches +from matplotlib import lines as mlines +from matplotlib import transforms import plotly.graph_objs as go from plotly.matplotlylib.mplexporter import Renderer from plotly.matplotlylib import mpltools - - from plotly.matplotlylib.mpltools import _export_color +# Artist class created by ``Axes.axline``: ``AxLine`` in matplotlib >= 3.8, +# ``_AxLine`` in earlier versions. +_AXLINE_CLASS = getattr(mlines, "AxLine", None) or getattr(mlines, "_AxLine", ()) + def _per_path(values, i, default): """Return the style value for the i-th path of a path collection. @@ -550,9 +555,11 @@ def draw_marked_line(self, **props): marked_line["x"] = self._convert_x_dates(marked_line["x"]) self.plotly_fig.add_trace(marked_line) self.msg += " Heck yeah, I drew that line\n" - elif props["coordinates"] == "axes": + elif props["coordinates"] == "axes" and self._processing_legend: # dealing with legend graphical elements self.msg += " Using native legend\n" + elif self._is_axes_reference_line(props) and self._draw_axes_line(props): + pass else: self.msg += " Line didn't have 'data' coordinates, not drawing\n" warnings.warn( @@ -561,6 +568,121 @@ def draw_marked_line(self, **props): "coordinates!" ) + def _is_axes_reference_line(self, props): + """Check whether a line qualifies as an axes-coordinate reference line + (e.g. axhline, axvline, horizontal/vertical axline, or a 2-point + segment drawn with ``transform=ax.transAxes``) that can be drawn as a + 2-point layout shape.""" + if not props.get("linestyle") or len(props.get("data", [])) != 2: + return False + if props["coordinates"] == "axes" and not self._processing_legend: + return True + if props["coordinates"] == "display" and isinstance( + props["mplobj"].get_transform(), transforms.BlendedGenericTransform + ): + return True + return False + + def _draw_axes_line(self, props): + """Draw an axes-coordinate reference line as a layout shape. + + axhline/axvline and horizontal/vertical axline span the axes domain + along one axis and sit at a data value on the other. Segments in axes + coordinates keep their exact endpoints in axes domain coordinates. + Returns True when a shape is drawn.""" + if not props.get("linestyle") or len(props.get("data", [])) != 2: + return False + ax = self.current_mpl_ax + line = props["mplobj"] + trans = line.get_transform() + + axis_suffix = str(self.axis_ct) if self.axis_ct > 1 else "" + x_axis = "x{0}".format(axis_suffix) + y_axis = "y{0}".format(axis_suffix) + x_domain = "{0} domain".format(x_axis).strip() + y_domain = "{0} domain".format(y_axis).strip() + + if isinstance(trans, transforms.BlendedGenericTransform) and ( + trans == ax.get_yaxis_transform() or trans._x == ax.transAxes + ): + # axhline: x spans the axes domain [xmin, xmax], y is in data coordinates + x_data = line.get_xdata(orig=False) + y_data = line.get_ydata(orig=False) + x0, x1 = float(x_data[0]), float(x_data[1]) + y0, y1 = float(y_data[0]), float(y_data[1]) + xref = x_domain + yref = y_axis + elif isinstance(trans, transforms.BlendedGenericTransform) and ( + trans == ax.get_xaxis_transform() or trans._y == ax.transAxes + ): + # axvline: x is in data coordinates, y spans the axes domain [ymin, ymax] + x_data = line.get_xdata(orig=False) + y_data = line.get_ydata(orig=False) + x0, x1 = float(x_data[0]), float(x_data[1]) + y0, y1 = float(y_data[0]), float(y_data[1]) + if self.x_is_mpl_date: + x0, x1 = self._convert_x_dates([x0, x1]) + xref = x_axis + yref = y_domain + elif props["coordinates"] == "axes" and not isinstance(line, _AXLINE_CLASS): + # Segment fixed to the axes (e.g. transform=ax.transAxes): props["data"] + # holds its endpoints as axes fractions, which map directly onto the + # axes domain, so the segment keeps its extent and stays put on pan/zoom + (x0, y0), (x1, y1) = [(float(x), float(y)) for x, y in props["data"]] + xref = x_domain + yref = y_domain + else: + # general reference line (e.g. horizontal or vertical axline) + if props["coordinates"] == "display": + px_points = props["data"] + elif props["coordinates"] == "axes": + px_points = [ax.transAxes.transform(pt) for pt in props["data"]] + else: + px_points = [trans.transform(pt) for pt in props["data"]] + (x0, y0), (x1, y1) = [ + ax.transData.inverted().transform(pt) for pt in px_points + ] + + dx = x1 - x0 + dy = y1 - y0 + if math.isclose(dy, 0.0, abs_tol=1e-12): + # Horizontal line: use x domain so it spans the chart, y in data coordinates + x0, x1 = 0.0, 1.0 + y0, y1 = float(y0), float(y1) + xref = x_domain + yref = y_axis + elif math.isclose(dx, 0.0, abs_tol=1e-12): + # Vertical line: use y domain so it spans the chart, x in data coordinates + y0, y1 = 0.0, 1.0 + if self.x_is_mpl_date: + x0, x1 = self._convert_x_dates([x0, x1]) + else: + x0, x1 = float(x0), float(x1) + xref = x_axis + yref = y_domain + else: + return False + + color = _export_color(props["linestyle"]["color"], props["linestyle"]["alpha"]) + shape = go.layout.Shape( + type="line", + x0=x0, + y0=y0, + x1=x1, + y1=y1, + xref=xref, + yref=yref, + line=go.layout.shape.Line( + color=color, + width=props["linestyle"]["linewidth"], + dash=mpltools.convert_dash(props["linestyle"]["dasharray"]), + ), + layer="above", + ) + self.plotly_fig["layout"]["shapes"] += (shape,) + self.msg += " Heck yeah, I drew that reference line\n" + return True + def draw_image(self, **props): """Draw image. diff --git a/plotly/matplotlylib/tests/test_renderer.py b/plotly/matplotlylib/tests/test_renderer.py index a355db7dc4..3e9ae9bb76 100644 --- a/plotly/matplotlylib/tests/test_renderer.py +++ b/plotly/matplotlylib/tests/test_renderer.py @@ -1,10 +1,12 @@ import datetime +import pytest import numpy as np import matplotlib import matplotlib.pyplot as plt import matplotlib.dates as mdates from packaging.version import Version +from matplotlib import transforms import plotly.tools as tls @@ -834,6 +836,286 @@ def test_custom_date_xtickvals_are_converted(): ) +def test_axhline_converts(): + """axhline converts to a layout shape spanning the axes width using x domain.""" + fig, ax = plt.subplots() + ax.axhline(0.5) + + plotly_fig = tls.mpl_to_plotly(fig) + + assert len(plotly_fig.data) == 0 + assert len(plotly_fig.layout.shapes) == 1 + shape = plotly_fig.layout.shapes[0] + assert shape.type == "line" + assert shape.x0 == 0 + assert shape.x1 == 1 + assert abs(shape.y0 - 0.5) < 1e-9 + assert abs(shape.y1 - 0.5) < 1e-9 + assert shape.xref == "x domain" + assert shape.yref == "y" + assert shape.line.color == "rgba(31, 119, 180, 1)" + + +def test_axvline_converts(): + """axvline converts to a layout shape spanning the axes height using y domain.""" + fig, ax = plt.subplots() + ax.axvline(0.5) + + plotly_fig = tls.mpl_to_plotly(fig) + + assert len(plotly_fig.data) == 0 + assert len(plotly_fig.layout.shapes) == 1 + shape = plotly_fig.layout.shapes[0] + assert shape.type == "line" + assert abs(shape.x0 - 0.5) < 1e-9 + assert abs(shape.x1 - 0.5) < 1e-9 + assert shape.y0 == 0 + assert shape.y1 == 1 + assert shape.xref == "x" + assert shape.yref == "y domain" + + +def test_axline_horizontal_and_vertical(): + """Horizontal and vertical axline use domain coordinates on the spanned axis.""" + fig, ax = plt.subplots() + ax.axline((0, 5), slope=0) + ax.axline((3, 0), (3, 10)) + + plotly_fig = tls.mpl_to_plotly(fig) + shapes = plotly_fig.layout.shapes + assert len(shapes) == 2 + + # Horizontal axline: xref is domain, y is data + assert shapes[0].xref == "x domain" + assert shapes[0].yref == "y" + assert abs(shapes[0].x0 - 0.0) < 1e-9 + assert abs(shapes[0].x1 - 1.0) < 1e-9 + assert abs(shapes[0].y0 - 5.0) < 1e-9 + assert abs(shapes[0].y1 - 5.0) < 1e-9 + + # Vertical axline: xref is data, yref is domain + assert shapes[1].xref == "x" + assert shapes[1].yref == "y domain" + assert abs(shapes[1].x0 - 3.0) < 1e-9 + assert abs(shapes[1].x1 - 3.0) < 1e-9 + assert abs(shapes[1].y0 - 0.0) < 1e-9 + assert abs(shapes[1].y1 - 1.0) < 1e-9 + + +def test_diagonal_axline_warns_and_is_skipped(): + """Diagonal axline emits a UserWarning for non-data Line2D coordinates.""" + fig, ax = plt.subplots() + ax.axline((0.5, 0.5), slope=1) + + with pytest.warns(UserWarning, match="Line2D objects from matplotlib"): + plotly_fig = tls.mpl_to_plotly(fig) + + assert len(plotly_fig.data) == 0 + assert len(plotly_fig.layout.shapes) == 0 + + +def test_axes_coordinate_segments_keep_their_endpoints(): + """Two-point lines in axes coordinates convert to shapes in axes domain + coordinates with their exact endpoints.""" + fig, ax = plt.subplots() + ax.set_xlim(0, 10) + ax.set_ylim(0, 10) + ax.plot([0.2, 0.4], [0.5, 0.5], transform=ax.transAxes) + ax.plot([0.2, 0.4], [0.2, 0.6], transform=ax.transAxes) + + plotly_fig = tls.mpl_to_plotly(fig) + shapes = plotly_fig.layout.shapes + assert len(shapes) == 2 + + expected = [((0.2, 0.5), (0.4, 0.5)), ((0.2, 0.2), (0.4, 0.6))] + for shape, ((x0, y0), (x1, y1)) in zip(shapes, expected): + assert shape.type == "line" + assert shape.xref == "x domain" + assert shape.yref == "y domain" + assert abs(shape.x0 - x0) < 1e-9 + assert abs(shape.y0 - y0) < 1e-9 + assert abs(shape.x1 - x1) < 1e-9 + assert abs(shape.y1 - y1) < 1e-9 + + +def test_axes_coordinate_segment_on_subplot_uses_subplot_domain(): + """Axes-coordinate segments on a second subplot reference that subplot's domain.""" + fig, (ax1, ax2) = plt.subplots(1, 2) + ax2.plot([0.1, 0.3], [0.7, 0.9], transform=ax2.transAxes) + + plotly_fig = tls.mpl_to_plotly(fig) + shapes = plotly_fig.layout.shapes + assert len(shapes) == 1 + assert shapes[0].xref == "x2 domain" + assert shapes[0].yref == "y2 domain" + assert abs(shapes[0].x0 - 0.1) < 1e-9 + assert abs(shapes[0].y0 - 0.7) < 1e-9 + assert abs(shapes[0].x1 - 0.3) < 1e-9 + assert abs(shapes[0].y1 - 0.9) < 1e-9 + + +def test_axvline_and_axhline_on_date_xaxis(): + """axvline and axhline on a date x-axis use proper domain and data coordinate references.""" + dates = [datetime.datetime(2023, 1, i) for i in range(1, 10)] + fig, ax = plt.subplots() + ax.plot(dates, range(len(dates))) + ax.axvline(dates[4]) + ax.axhline(4) + + plotly_fig = tls.mpl_to_plotly(fig) + shapes = plotly_fig.layout.shapes + assert len(shapes) == 2 + + vline_shape = shapes[0] + assert isinstance(vline_shape.x0, str) + assert isinstance(vline_shape.x1, str) + assert vline_shape.x0.startswith("2023-01-05") + assert vline_shape.x1.startswith("2023-01-05") + assert vline_shape.y0 == 0 + assert vline_shape.y1 == 1 + assert vline_shape.xref == "x" + assert vline_shape.yref == "y domain" + + hline_shape = shapes[1] + assert hline_shape.x0 == 0 + assert hline_shape.x1 == 1 + assert hline_shape.y0 == 4 + assert hline_shape.y1 == 4 + assert hline_shape.xref == "x domain" + assert hline_shape.yref == "y" + + +def test_axhline_on_date_yaxis(): + """axhline with a datetime on a date y-axis converts without crashing.""" + dates = [datetime.datetime(2023, 1, i) for i in range(1, 10)] + fig, ax = plt.subplots() + ax.plot(range(len(dates)), dates) + ax.axhline(dates[3]) + + plotly_fig = tls.mpl_to_plotly(fig) + shapes = plotly_fig.layout.shapes + assert len(shapes) == 1 + shape = shapes[0] + assert shape.type == "line" + assert shape.xref == "x domain" + assert shape.yref == "y" + assert abs(shape.x0 - 0.0) < 1e-9 + assert abs(shape.x1 - 1.0) < 1e-9 + expected_y = float(matplotlib.dates.date2num(dates[3])) + assert abs(shape.y0 - expected_y) < 1e-9 + assert abs(shape.y1 - expected_y) < 1e-9 + + +def test_reference_lines_custom_limits_and_subplots(): + """axhline and axvline respect custom domain limits and subplot axis references.""" + fig, (ax1, ax2) = plt.subplots(1, 2) + ax1.axhline(0.5, xmin=0.2, xmax=0.8) + ax2.axvline(3.0, ymin=0.1, ymax=0.9) + + plotly_fig = tls.mpl_to_plotly(fig) + shapes = plotly_fig.layout.shapes + assert len(shapes) == 2 + + # First subplot + assert shapes[0].xref == "x domain" + assert shapes[0].yref == "y" + assert abs(shapes[0].x0 - 0.2) < 1e-9 + assert abs(shapes[0].x1 - 0.8) < 1e-9 + assert abs(shapes[0].y0 - 0.5) < 1e-9 + assert abs(shapes[0].y1 - 0.5) < 1e-9 + + # Second subplot + assert shapes[1].xref == "x2" + assert shapes[1].yref == "y2 domain" + assert abs(shapes[1].x0 - 3.0) < 1e-9 + assert abs(shapes[1].x1 - 3.0) < 1e-9 + assert abs(shapes[1].y0 - 0.1) < 1e-9 + assert abs(shapes[1].y1 - 0.9) < 1e-9 + + +def test_axes_line_with_more_than_two_points_does_not_crash(): + """Axes-coordinate line with != 2 points does not crash and is ignored with a warning.""" + fig, ax = plt.subplots() + ax.plot([0.1, 0.5, 0.9], [0.1, 0.5, 0.9], transform=ax.transAxes) + + with pytest.warns(UserWarning, match="Line2D objects from matplotlib"): + plotly_fig = tls.mpl_to_plotly(fig) + assert len(plotly_fig.layout.shapes) == 0 + + +def test_axes_line_two_points_markers_only_does_not_crash(): + """Axes-coordinate line with exactly two points but markers only (linestyle is None) does not crash.""" + fig, ax = plt.subplots() + # Exactly two points, marker-only (linestyle=None) + lines = ax.plot([0.1, 0.9], [0.1, 0.9], "o", transform=ax.transAxes) + assert len(lines[0].get_xydata()) == 2 + assert lines[0].get_linestyle() == "None" + + with pytest.warns(UserWarning, match="Line2D objects from matplotlib"): + plotly_fig = tls.mpl_to_plotly(fig) + assert len(plotly_fig.layout.shapes) == 0 + + +def test_blended_transform_line_invalid_does_not_crash(): + """Blended transform line with != 2 points or marker only does not crash.""" + fig, ax = plt.subplots() + trans = transforms.blended_transform_factory(ax.transData, ax.transAxes) + line1 = matplotlib.lines.Line2D([0.1, 0.5, 0.9], [0.1, 0.5, 0.9], transform=trans) + line2 = matplotlib.lines.Line2D( + [0.1, 0.9], [0.1, 0.9], linestyle="None", marker="o", transform=trans + ) + ax.add_line(line1) + ax.add_line(line2) + + with pytest.warns(UserWarning, match="Line2D objects from matplotlib"): + plotly_fig = tls.mpl_to_plotly(fig) + assert len(plotly_fig.layout.shapes) == 0 + + +def test_dotted_line_dash_converts_to_dot(): + """Dotted lines (linestyle=':') convert to dash='dot'.""" + fig, ax = plt.subplots() + ax.plot([0, 1], [0, 1], linestyle=":") + ax.axhline(0.5, linestyle=":") + + plotly_fig = tls.mpl_to_plotly(fig) + assert plotly_fig.data[0].line.dash == "dot" + assert plotly_fig.layout.shapes[0].line.dash == "dot" + + +def test_convert_dash_returns_valid_plotly_dash_styles(): + """convert_dash maps standard styles to Plotly names and custom patterns to px lists.""" + import plotly.graph_objs as go + from plotly.matplotlylib.mpltools import convert_dash + + expected_mappings = { + "10,0": "solid", + "6,6": "dash", + "2,2": "dot", + "4,4,2,4": "dashdot", + "none": "solid", + "7.4,3.2": "dash", + "2.0,3.3": "dot", + "12.8,3.2,2.0,3.2": "dashdot", + "5,5": "5px,5px", + "12,3,2,3": "12px,3px,2px,3px", + "dashed": "dash", + "dotted": "dot", + "--": "dash", + ":": "dot", + "": "solid", + None: "solid", + } + for inp, expected in expected_mappings.items(): + res = convert_dash(inp) + assert res == expected, f"Input {inp!r} produced {res!r}, expected {expected!r}" + # Confirm Plotly layout shape line and scatter line accept the converted dash + shape_line = go.layout.shape.Line(dash=res) + scatter_line = go.scatter.Line(dash=res) + assert shape_line.dash == res + assert scatter_line.dash == res + + def test_uneven_custom_date_xtickvals_are_converted(): """Unevenly spaced custom date ticks must be converted to date strings.""" dates = [datetime.datetime(2023, 1, i) for i in range(1, 11)]