diff --git a/CHANGELOG.md b/CHANGELOG.md index 32590d344f..d85a725998 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 fill polygons (`plt.fill`) to filled scatter traces in `mpl_to_plotly` [[#5795](https://github.com/plotly/plotly.py/pull/5795)], 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/mpltools.py b/plotly/matplotlylib/mpltools.py index 892282418e..b886b85007 100644 --- a/plotly/matplotlylib/mpltools.py +++ b/plotly/matplotlylib/mpltools.py @@ -329,6 +329,7 @@ def get_markerstyle_from_collection(props): # markersize=convert_size_array(props['styles']['size']), # TODO! markersize=convert_size_array(props["mplobj"].get_sizes()), edgecolor=convert_rgba_array(props["styles"]["edgecolor"]), + zorder=props["styles"]["zorder"], ) return markerstyle diff --git a/plotly/matplotlylib/renderer.py b/plotly/matplotlylib/renderer.py index 0a3c829945..9f1da88e2f 100644 --- a/plotly/matplotlylib/renderer.py +++ b/plotly/matplotlylib/renderer.py @@ -41,6 +41,20 @@ def _per_path(values, i, default): return values[i % n] if n else default +def _legend_label(label): + """Return the (name, showlegend) pair for a trace from an artist label. + + matplotlib marks artists it leaves out of legends with "_nolegend_" and + auto-generated "_childN" labels. Empty labels and labels starting with + "_" give (None, False), which hides the trace from the plotly legend. + Other labels give (label, None), which keeps plotly's default legend + entry. + """ + if not label or (isinstance(label, str) and label.startswith("_")): + return None, False + return label, None + + def _convert_collection_dash(linestyle): """Convert a matplotlib collection line style to a plotly dash string. @@ -97,6 +111,29 @@ def __init__(self): self._processing_legend = False self._legend_visible = False self.axes_list = [] + self._trace_zorders = [] + + def _add_trace(self, trace, zorder): + """Add a trace to the figure, recording its matplotlib zorder.""" + self.plotly_fig.add_trace(trace) + self._trace_zorders.append(zorder) + + def _sort_traces_by_zorder(self): + """Order the current axes' traces by matplotlib zorder. + + plotly draws later traces on top and matplotlib draws artists in + ascending zorder, so the traces are stably sorted by zorder. Traces + with equal zorder keep the order they were drawn in. + """ + n = len(self._trace_zorders) + start = len(self.plotly_fig.data) - n + order = sorted(range(n), key=self._trace_zorders.__getitem__) + if order != list(range(n)): + axes_traces = self.plotly_fig.data[start:] + self.plotly_fig.data = self.plotly_fig.data[:start] + tuple( + axes_traces[i] for i in order + ) + self._trace_zorders = [] def _convert_x_dates(self, x): """Convert x values to date strings when the x-axis is a date axis.""" @@ -304,6 +341,7 @@ def close_axes(self, ax): """ self.draw_bars(self.current_bars) + self._sort_traces_by_zorder() self.msg += " Closing axes\n" self.x_is_mpl_date = False @@ -433,7 +471,7 @@ def draw_bar(self, coll): ) # TODO ditto if len(bar["x"]) > 1: self.msg += " Heck yeah, I drew that bar chart\n" - self.plotly_fig.add_trace(bar) + self._add_trace(bar, trace[0]["zorder"]) if bar_gap is not None: self.plotly_fig["layout"]["bargap"] = bar_gap else: @@ -532,14 +570,7 @@ def draw_marked_line(self, **props): ), ) if props["coordinates"] == "data": - label = props["label"] - # matplotlib uses "_nolegend_" and auto-generated "_childN" - # labels for artists that must not appear in a legend - if not label or (isinstance(label, str) and label.startswith("_")): - label = None - showlegend = False - else: - showlegend = None + label, showlegend = _legend_label(props["label"]) marked_line = go.Scatter( mode=mode, name=label, @@ -553,7 +584,8 @@ def draw_marked_line(self, **props): ) if self.x_is_mpl_date: marked_line["x"] = self._convert_x_dates(marked_line["x"]) - self.plotly_fig.add_trace(marked_line) + style = props["linestyle"] or props["markerstyle"] + self._add_trace(marked_line, style["zorder"]) self.msg += " Heck yeah, I drew that line\n" elif props["coordinates"] == "axes" and self._processing_legend: # dealing with legend graphical elements @@ -771,7 +803,7 @@ def _draw_line_collection(self, props): def flush(): if current_style is not None and grouped_x: ec, lw, d = current_style - self.plotly_fig.add_trace( + self._add_trace( go.Scatter( x=grouped_x, y=grouped_y, @@ -784,7 +816,8 @@ def flush(): ), xaxis="x{0}".format(self.axis_ct), yaxis="y{0}".format(self.axis_ct), - ) + ), + props["styles"]["zorder"], ) for i, (verts, codes) in enumerate(props["paths"]): @@ -850,6 +883,33 @@ def flush(): flush() + def _add_filled_trace( + self, + verts, + facecolor, + edgecolor, + linewidth, + zorder, + name=None, + showlegend=None, + ): + """Add a filled scatter trace outlining `verts` on the current axes.""" + self._add_trace( + go.Scatter( + name=name, + showlegend=showlegend, + x=self._convert_x_dates([v[0] for v in verts]), + y=[v[1] for v in verts], + mode="lines", + line=go.scatter.Line(color=_export_color(edgecolor), width=linewidth), + fill="toself", + fillcolor=_export_color(facecolor), + xaxis="x{0}".format(self.axis_ct), + yaxis="y{0}".format(self.axis_ct), + ), + zorder, + ) + def _draw_filled_path_collection(self, props): """Draw a path collection (e.g. violin plot bodies) as filled polygons.""" facecolors = mpltools.convert_rgba_array(props["styles"]["facecolor"]) @@ -857,22 +917,12 @@ def _draw_filled_path_collection(self, props): linewidths = mpltools.convert_linewidth_array(props["styles"]["linewidth"]) for i, (verts, codes) in enumerate(props["paths"]): - facecolor = _per_path(facecolors, i, "rgba(0,0,0,0)") - edgecolor = _per_path(edgecolors, i, "rgba(0,0,0,0)") - linewidth = _per_path(linewidths, i, 0) - self.plotly_fig.add_trace( - go.Scatter( - x=self._convert_x_dates([v[0] for v in verts]), - y=[v[1] for v in verts], - mode="lines", - line=go.scatter.Line( - color=_export_color(edgecolor), width=linewidth - ), - fill="toself", - fillcolor=_export_color(facecolor), - xaxis="x{0}".format(self.axis_ct), - yaxis="y{0}".format(self.axis_ct), - ) + self._add_filled_trace( + verts, + facecolor=_per_path(facecolors, i, "rgba(0,0,0,0)"), + edgecolor=_per_path(edgecolors, i, "rgba(0,0,0,0)"), + linewidth=_per_path(linewidths, i, 0), + zorder=props["styles"]["zorder"], ) def draw_path(self, **props): @@ -908,6 +958,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 ( + isinstance(props["mplobj"], mpatches.Polygon) + and props["coordinates"] == "data" + ): + self.msg += " Drawing a filled polygon\n" + self._draw_filled_polygon(props) else: self.msg += " This path isn't a bar, not drawing\n" warnings.warn( @@ -945,13 +1001,8 @@ def _draw_step_path(self, props): if len(x) < 2: self.msg += " Step path has fewer than 2 points, not drawing\n" return - label = props["mplobj"].get_label() - if not label or (isinstance(label, str) and label.startswith("_")): - label = None - showlegend = False - else: - showlegend = None - self.plotly_fig.add_trace( + label, showlegend = _legend_label(props["mplobj"].get_label()) + self._add_trace( go.Scatter( x=x, y=y, @@ -967,7 +1018,22 @@ def _draw_step_path(self, props): fillcolor=_export_color(style["facecolor"]), xaxis="x{0}".format(self.axis_ct), yaxis="y{0}".format(self.axis_ct), - ) + ), + style["zorder"], + ) + + def _draw_filled_polygon(self, props): + """Draw a matplotlib Polygon patch as a filled scatter trace.""" + style = props["style"] + label, showlegend = _legend_label(props["mplobj"].get_label()) + self._add_filled_trace( + props["data"], + facecolor=style["facecolor"], + edgecolor=style["edgecolor"], + linewidth=style["edgewidth"], + zorder=style["zorder"], + name=label, + showlegend=showlegend, ) def draw_text(self, **props): diff --git a/plotly/matplotlylib/tests/test_renderer.py b/plotly/matplotlylib/tests/test_renderer.py index 90f73bff36..3b60d852f8 100644 --- a/plotly/matplotlylib/tests/test_renderer.py +++ b/plotly/matplotlylib/tests/test_renderer.py @@ -1150,6 +1150,71 @@ def test_uneven_custom_date_xtickvals_are_converted(): ) +def test_fill_converts(): + """plt.fill polygons convert to filled scatter traces.""" + x = np.linspace(0, 2 * np.pi, 50) + fig, ax = plt.subplots() + ax.fill(x, np.sin(x), "g") + + plotly_fig = tls.mpl_to_plotly(fig) + + assert len(plotly_fig.data) == 1 + trace = plotly_fig.data[0] + assert trace.type == "scatter" + assert trace.fill == "toself" + assert np.allclose(trace.x, x) + assert np.allclose(trace.y, np.sin(x)) + assert trace.fillcolor == "#007F00" + assert trace.line.color == "rgba(0,0,0,0)" + + +def test_fill_with_legend_label(): + """plt.fill polygons with labels export their name and show in legend.""" + fig, ax = plt.subplots() + ax.fill([0, 1, 2], [0, 2, 0], "b", label="Shaded Area") + ax.fill([0, 1, 2], [0, 1, 0], "g") + ax.plot([0, 1, 2], [1, 1, 1], "r-", label="Threshold") + ax.legend() + + plotly_fig = tls.mpl_to_plotly(fig) + + assert plotly_fig.layout.showlegend is True + assert len(plotly_fig.data) == 3 + + labeled_fill = next( + t for t in plotly_fig.data if t.fill == "toself" and t.name == "Shaded Area" + ) + assert labeled_fill.showlegend is not False + + unlabeled_fill = next( + t for t in plotly_fig.data if t.fill == "toself" and t.name is None + ) + assert unlabeled_fill.showlegend is False + + +def test_fill_stacks_below_line_by_zorder(): + """A fill (zorder 1) exports before a line (zorder 2), so plotly draws + the line on top as matplotlib does.""" + fig, ax = plt.subplots() + ax.fill([0, 1, 1], [0, 0, 1], "b") + ax.plot([0, 1], [0.5, 0.5], "r") + + plotly_fig = tls.mpl_to_plotly(fig) + + assert [t.fill for t in plotly_fig.data] == ["toself", None] + + +def test_fill_with_higher_zorder_stacks_above_line(): + """A fill given a higher zorder than a line exports after the line.""" + fig, ax = plt.subplots() + ax.fill([0, 1, 1], [0, 0, 1], "b", zorder=3) + ax.plot([0, 1], [0.5, 0.5], "r") + + plotly_fig = tls.mpl_to_plotly(fig) + + assert [t.fill for t in plotly_fig.data] == [None, "toself"] + + def test_custom_date_xtickvals_given_as_numbers_are_converted(): """Custom date ticks given as matplotlib date numbers must be converted to date strings."""