diff --git a/CHANGELOG.md b/CHANGELOG.md index 32590d344f..abf88523cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ This project adheres to [Semantic Versioning](http://semver.org/). ### 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! +- Add support for converting matplotlib reference spans (`axhspan` and `axvspan`) into Plotly layout shapes in `mpl_to_plotly`, with axes-fraction extents (`xmin`/`xmax`, `ymin`/`ymax`) on the axis domain, support for date and log axes, and legend entries for labeled spans, 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! diff --git a/plotly/matplotlylib/renderer.py b/plotly/matplotlylib/renderer.py index 0a3c829945..9bb1945999 100644 --- a/plotly/matplotlylib/renderer.py +++ b/plotly/matplotlylib/renderer.py @@ -56,6 +56,49 @@ def _convert_collection_dash(linestyle): return ",".join("{0:g}px".format(d) for d in dashes) +def _find_blended_transform(transform): + """Return the first blended transform within a matplotlib transform, or + None when it contains no blended transform.""" + if isinstance( + transform, (transforms.BlendedGenericTransform, transforms.BlendedAffine2D) + ): + return transform + if hasattr(transform, "_a") and hasattr(transform, "_b"): + blended = _find_blended_transform(transform._a) + if blended is None: + blended = _find_blended_transform(transform._b) + return blended + if hasattr(transform, "_transform"): + return _find_blended_transform(transform._transform) + return None + + +def _has_blended_transform(transform): + """Check whether a matplotlib transform contains a blended transform.""" + return _find_blended_transform(transform) is not None + + +def _is_axis_aligned_rectangle(points): + """Check whether polygon vertices trace an axis-aligned rectangle: every + vertex is a corner of the bounding box and all four corners appear.""" + xs = [float(x) for x, _ in points] + ys = [float(y) for _, y in points] + if not xs: + return False + x_min, x_max = min(xs), max(xs) + y_min, y_max = min(ys), max(ys) + corners = set() + for x, y in zip(xs, ys): + at_x_min = math.isclose(x, x_min, abs_tol=1e-9) + at_x_max = math.isclose(x, x_max, abs_tol=1e-9) + at_y_min = math.isclose(y, y_min, abs_tol=1e-9) + at_y_max = math.isclose(y, y_max, abs_tol=1e-9) + if not (at_x_min or at_x_max) or not (at_y_min or at_y_max): + return False + corners.add((at_x_max, at_y_max)) + return len(corners) == 4 + + class PlotlyRenderer(Renderer): """A renderer class inheriting from base for rendering mpl plots in plotly. @@ -683,6 +726,73 @@ def _draw_axes_line(self, props): self.msg += " Heck yeah, I drew that reference line\n" return True + def _is_axes_span(self, props): + """Check whether a path qualifies as a reference span (e.g. axhspan, + axvspan): an axis-aligned rectangle in a blended axes/data + transform.""" + if not isinstance(props["mplobj"], (mpatches.Rectangle, mpatches.Polygon)): + return False + if not ( + props["coordinates"] == "display" + and _has_blended_transform(props["mplobj"].get_transform()) + ): + return False + return _is_axis_aligned_rectangle(props["data"]) + + def _draw_axes_span(self, props): + """Draw a reference span (axhspan/axvspan) as a layout shape. + + Sides given in axes fractions (e.g. axhspan xmin/xmax) map onto the + axis domain, so they stay fixed to the axes on pan/zoom; sides given + in data coordinates map onto the data axis.""" + ax = self.current_mpl_ax + px_points = props["data"] + blended = _find_blended_transform(props["mplobj"].get_transform()) + x_in_axes = blended._x == ax.transAxes + y_in_axes = blended._y == ax.transAxes + axes_points = ax.transAxes.inverted().transform(px_points) + data_points = ax.transData.inverted().transform(px_points) + xs = (axes_points if x_in_axes else data_points)[:, 0] + ys = (axes_points if y_in_axes else data_points)[:, 1] + x0, x1 = float(min(xs)), float(max(xs)) + y0, y1 = float(min(ys)), float(max(ys)) + if self.x_is_mpl_date and not x_in_axes: + x0, x1 = self._convert_x_dates([x0, x1]) + axis_suffix = str(self.axis_ct) if self.axis_ct > 1 else "" + xref = "x{0}".format(axis_suffix) + yref = "y{0}".format(axis_suffix) + if x_in_axes: + xref += " domain" + if y_in_axes: + yref += " domain" + style = props["style"] + fillcolor = _export_color(style["facecolor"]) + edgecolor = _export_color(style["edgecolor"]) + linewidth = style["edgewidth"] if style["edgecolor"] != "none" else 0 + label = props["mplobj"].get_label() + if not label or (isinstance(label, str) and label.startswith("_")): + label = None + shape = go.layout.Shape( + type="rect", + x0=x0, + y0=y0, + x1=x1, + y1=y1, + xref=xref, + yref=yref, + fillcolor=fillcolor, + line=go.layout.shape.Line( + color=edgecolor, + width=linewidth, + dash=mpltools.convert_dash(style["dasharray"]), + ), + layer="below" if style.get("zorder", 1) < 2 else "above", + name=label, + showlegend=True if label is not None else None, + ) + self.plotly_fig["layout"]["shapes"] += (shape,) + self.msg += " Heck yeah, I drew that reference span\n" + def draw_image(self, **props): """Draw image. @@ -908,6 +1018,12 @@ def draw_path(self, **props): elif isinstance(props["mplobj"], mpatches.StepPatch): self.msg += " Drawing a step path\n" self._draw_step_path(props) + elif self._is_axes_span(props): + if self._processing_legend: + self.msg += " Using native legend\n" + else: + self.msg += " Drawing an axes span\n" + self._draw_axes_span(props) else: self.msg += " This path isn't a bar, not drawing\n" warnings.warn( diff --git a/plotly/matplotlylib/tests/test_renderer.py b/plotly/matplotlylib/tests/test_renderer.py index 90f73bff36..5c816aca76 100644 --- a/plotly/matplotlylib/tests/test_renderer.py +++ b/plotly/matplotlylib/tests/test_renderer.py @@ -5,6 +5,7 @@ import matplotlib import matplotlib.pyplot as plt import matplotlib.dates as mdates +import matplotlib.patches as mpatches from packaging.version import Version from matplotlib import transforms import plotly.tools as tls @@ -1392,3 +1393,151 @@ def test_mixed_style_lines_group_consecutive_matches(): else: # older matplotlib emits one collection per level assert dashes == ["5.55px,2.4px"] * 2 + ["solid"] * 2 + + +def test_axhspan_converts(): + """axhspan converts to a layout shape spanning the x axis domain.""" + fig, ax = plt.subplots() + ax.axhspan(0.25, 0.75) + + 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 == "rect" + assert abs(shape.x0 - 0) < 1e-9 + assert abs(shape.x1 - 1) < 1e-9 + assert abs(shape.y0 - 0.25) < 1e-9 + assert abs(shape.y1 - 0.75) < 1e-9 + assert shape.xref == "x domain" + assert shape.yref == "y" + assert shape.fillcolor == "#1F77B4" + assert shape.layer == "below" + + +def test_axvspan_converts(): + """axvspan converts to a layout shape spanning the y axis domain.""" + fig, ax = plt.subplots() + ax.axvspan(0.25, 0.75) + + 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 == "rect" + assert abs(shape.x0 - 0.25) < 1e-9 + assert abs(shape.x1 - 0.75) < 1e-9 + assert abs(shape.y0 - 0) < 1e-9 + assert abs(shape.y1 - 1) < 1e-9 + assert shape.xref == "x" + assert shape.yref == "y domain" + assert shape.fillcolor == "#1F77B4" + assert shape.layer == "below" + + +def test_axhspan_axes_fractions_convert(): + """axhspan xmin/xmax convert to x axis domain fractions and axvspan + ymin/ymax convert to y axis domain fractions.""" + fig, ax = plt.subplots() + ax.plot([0, 10], [0, 10]) + ax.axhspan(2, 4, xmin=0.2, xmax=0.6) + ax.axvspan(6, 8, ymin=0.1, ymax=0.5) + + plotly_fig = tls.mpl_to_plotly(fig) + + hspan, vspan = plotly_fig.layout.shapes + assert hspan.xref == "x domain" + assert hspan.yref == "y" + assert np.allclose([hspan.x0, hspan.x1, hspan.y0, hspan.y1], [0.2, 0.6, 2, 4]) + assert vspan.xref == "x" + assert vspan.yref == "y domain" + assert np.allclose([vspan.x0, vspan.x1, vspan.y0, vspan.y1], [6, 8, 0.1, 0.5]) + + +def test_axhspan_on_second_subplot_references_its_domain(): + """Spans on a second subplot reference that subplot's axis domain.""" + fig, (ax1, ax2) = plt.subplots(1, 2) + ax2.axhspan(2, 4) + + plotly_fig = tls.mpl_to_plotly(fig) + + (shape,) = plotly_fig.layout.shapes + assert shape.xref == "x2 domain" + assert shape.yref == "y2" + + +def test_axhspan_with_custom_style_converts(): + """axhspan with custom colors and alpha converts to a styled layout shape.""" + fig, ax = plt.subplots() + ax.axhspan(0.25, 0.75, facecolor="red", edgecolor="blue", alpha=0.5, linestyle="--") + + 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 == "rect" + assert shape.fillcolor == "rgba(255, 0, 0, 0.5)" + assert shape.line.color == "rgba(0, 0, 255, 0.5)" + assert shape.line.dash == "dash" + assert shape.line.width == 1.0 + + +def test_blended_unaligned_shapes_do_not_convert_to_spans(): + """A triangle and a rotated rectangle in a blended axes/data transform + warn and leave the layout shapes empty.""" + fig, ax = plt.subplots() + ax.plot([0, 10], [0, 10]) + ax.add_patch( + mpatches.Polygon( + [[5, 0.1], [9, 0.1], [7, 0.5]], + transform=ax.get_xaxis_transform(), + color="b", + ) + ) + ax.add_patch( + mpatches.Rectangle( + (6, 0.6), 2, 0.2, angle=30, transform=ax.get_xaxis_transform(), color="g" + ) + ) + + with pytest.warns(UserWarning, match="path object"): + plotly_fig = tls.mpl_to_plotly(fig) + + assert len(plotly_fig.layout.shapes) == 0 + + +def test_axvspan_on_date_xaxis_converts_to_dates(): + """axvspan on a date x axis exports its x extent as date strings.""" + dates = [datetime.datetime(2023, 1, i) for i in range(1, 10)] + fig, ax = plt.subplots() + ax.plot(dates, range(len(dates))) + ax.axvspan(dates[2], dates[5]) + ax.axhspan(2, 4) + + plotly_fig = tls.mpl_to_plotly(fig) + + vspan, hspan = plotly_fig.layout.shapes + assert vspan.xref == "x" + assert vspan.x0.startswith("2023-01-03") + assert vspan.x1.startswith("2023-01-06") + assert hspan.xref == "x domain" + assert np.allclose([hspan.x0, hspan.x1], [0, 1]) + + +def test_labeled_axvspan_shows_in_legend(): + """A labeled span exports its label as the shape legend entry.""" + fig, ax = plt.subplots() + ax.plot([0, 10], [0, 10], label="line") + ax.axvspan(2, 4, label="span") + ax.axhspan(6, 8) + ax.legend() + + plotly_fig = tls.mpl_to_plotly(fig) + + labeled, unlabeled = plotly_fig.layout.shapes + assert labeled.name == "span" + assert labeled.showlegend is True + assert unlabeled.showlegend is None