diff --git a/CHANGELOG.md b/CHANGELOG.md index 32590d344f..85e8301bb5 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`) and other patches in data coordinates (such as `Circle`, `Ellipse`, `Wedge`, `Rectangle`, and `PathPatch`) to filled scatter traces in `mpl_to_plotly`, with closed outlines that keep the edge color, width, and line style; those without a face color export as outlines, and hidden ones are skipped [[#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/renderer.py b/plotly/matplotlylib/renderer.py index 0a3c829945..fc14c46357 100644 --- a/plotly/matplotlylib/renderer.py +++ b/plotly/matplotlylib/renderer.py @@ -41,6 +41,106 @@ def _per_path(values, i, default): return values[i % n] if n else default +# In SVG paths, codes carry different numbers of vertices: +# M/L: 1, C: 3 (cubic curve), S: 2 (smooth/quad curve), Z: 0. +_VERTICES_PER_CODE = {"M": 1, "L": 1, "C": 3, "S": 2, "Z": 0} + + +def _split_subpaths(verts, codes): + """Split SVG path vertices into subpaths, one per "M" code. + + Returns a list of (vertices, closed) pairs; `closed` is True for + subpaths that have a "Z" code. + """ + subpaths = [] + current = [] + closed = False + vi = 0 + for c in codes: + step = _VERTICES_PER_CODE.get(c, 1) + if c == "M": + if current: + subpaths.append((current, closed)) + current = [verts[vi]] + closed = False + vi += 1 + elif c == "Z": + closed = True + else: + current.extend(verts[vi : vi + step]) + vi += step + if current: + subpaths.append((current, closed)) + return subpaths + + +def _bezier_point(points, t): + """Return the point at parameter `t` of the Bezier curve with the given + control points (de Casteljau's algorithm).""" + points = [tuple(p) for p in points] + while len(points) > 1: + points = [ + ((1 - t) * a[0] + t * b[0], (1 - t) * a[1] + t * b[1]) + for a, b in zip(points, points[1:]) + ] + return points[0] + + +def _flatten_curves(verts, codes, samples=16): + """Return the path vertices and codes with Bezier curves as line segments. + + Each cubic ("C") or quadratic ("S") curve is sampled at `samples` points + along the curve, ending at its end point, and each sample becomes an "L" + segment. Other codes keep their vertices. + """ + flat_verts = [] + flat_codes = [] + vi = 0 + for c in codes: + step = _VERTICES_PER_CODE.get(c, 1) + points = list(verts[vi : vi + step]) + vi += step + if c in ("C", "S") and flat_verts: + control = [flat_verts[-1]] + points + for k in range(1, samples + 1): + flat_verts.append(_bezier_point(control, k / samples)) + flat_codes.append("L") + else: + flat_verts.extend(points) + flat_codes.append(c) + return flat_verts, flat_codes + + +def _close_subpaths(verts, codes): + """Return the subpaths of a path, each closed subpath ending at its start. + + Bezier curves are flattened into line segments first. The start vertex + of each closed subpath is appended to it, which draws the closing + segment of the outline. Subpaths that already end at their start vertex + keep their vertices as given. + """ + subpaths = [] + for sub, closed in _split_subpaths(*_flatten_curves(verts, codes)): + if closed and tuple(sub[-1]) != tuple(sub[0]): + sub = sub + [sub[0]] + subpaths.append(sub) + return subpaths + + +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. @@ -532,14 +632,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, @@ -791,31 +884,9 @@ def flush(): # a path may contain several disjoint lines (e.g. contour lines # of the same level); separate disjoint subpaths with None so # plotly does not connect them. - # In SVG paths, codes carry different numbers of vertices: - # M/L: 1, C: 3 (cubic curve), S: 2 (smooth/quad curve), Z: 0. - code_steps = {"M": 1, "L": 1, "C": 3, "S": 2, "Z": 0} - subpaths = [] - current = [] - closed = False - vi = 0 - for c in codes: - step = code_steps.get(c, 1) - if c == "M": - if current: - subpaths.append((current, closed)) - current = [verts[vi]] - closed = False - vi += 1 - elif c == "Z": - closed = True - else: - current.extend(verts[vi : vi + step]) - vi += step - if current: - subpaths.append((current, closed)) path_x = [] path_y = [] - for sub, closed in subpaths: + for sub, closed in _split_subpaths(verts, codes): if len(sub) < 2: continue # a closed subpath (Z code) must be closed explicitly since @@ -850,6 +921,48 @@ def flush(): flush() + def _add_filled_trace( + self, + verts, + codes, + facecolor, + edgecolor, + linewidth, + dash=None, + name=None, + showlegend=None, + ): + """Add a scatter trace outlining a path on the current axes. + + The subpaths of the path are separated by None. The path is filled + with `facecolor`; a facecolor of "none" gives an outline-only trace. + """ + x = [] + y = [] + for sub in _close_subpaths(verts, codes): + if x: + x.append(None) + y.append(None) + x.extend(self._convert_x_dates([v[0] for v in sub])) + y.extend(v[1] for v in sub) + filled = facecolor != "none" + self.plotly_fig.add_trace( + go.Scatter( + name=name, + showlegend=showlegend, + x=x, + y=y, + mode="lines", + line=go.scatter.Line( + color=_export_color(edgecolor), width=linewidth, dash=dash + ), + fill="toself" if filled else None, + fillcolor=_export_color(facecolor) if filled else None, + xaxis="x{0}".format(self.axis_ct), + yaxis="y{0}".format(self.axis_ct), + ) + ) + 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,30 +970,21 @@ 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, + codes, + 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), ) def draw_path(self, **props): - """Draw a bar chart path or a matplotlib step patch. + """Draw a bar chart path, a matplotlib step patch, or another patch. This function attempts to sort a given path into a collection of - horizontal or vertical bar charts, and draws matplotlib StepPatch - artists as step traces. Most of the actual code takes place in + horizontal or vertical bar charts, draws matplotlib StepPatch + artists as step traces, and draws other patches in data coordinates + as filled scatter traces. Most of the bar chart code takes place in functions from mpltools.py. props.keys() -- [ @@ -908,6 +1012,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.Patch) + and props["coordinates"] == "data" + ): + self.msg += " Drawing a filled patch\n" + self._draw_filled_patch(props) else: self.msg += " This path isn't a bar, not drawing\n" warnings.warn( @@ -945,12 +1055,7 @@ 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 + label, showlegend = _legend_label(props["mplobj"].get_label()) self.plotly_fig.add_trace( go.Scatter( x=x, @@ -970,6 +1075,24 @@ def _draw_step_path(self, props): ) ) + def _draw_filled_patch(self, props): + """Draw a visible matplotlib patch as a filled scatter trace.""" + if not props["mplobj"].get_visible(): + self.msg += " Patch is hidden, not drawing\n" + return + style = props["style"] + label, showlegend = _legend_label(props["mplobj"].get_label()) + self._add_filled_trace( + props["data"], + props["pathcodes"], + facecolor=style["facecolor"], + edgecolor=style["edgecolor"], + linewidth=style["edgewidth"], + dash=mpltools.convert_dash(style["dasharray"]), + name=label, + showlegend=showlegend, + ) + def draw_text(self, **props): """Create an annotation dict for a text obj. diff --git a/plotly/matplotlylib/tests/test_renderer.py b/plotly/matplotlylib/tests/test_renderer.py index 90f73bff36..fde3711030 100644 --- a/plotly/matplotlylib/tests/test_renderer.py +++ b/plotly/matplotlylib/tests/test_renderer.py @@ -1170,6 +1170,206 @@ def test_custom_date_xtickvals_given_as_numbers_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, np.append(x, x[0])) + assert np.allclose(trace.y, np.append(np.sin(x), np.sin(x[0]))) + 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 + + +@pytest.mark.parametrize( + "linestyle, dash", + [("-", "solid"), ("--", "dash"), (":", "dot"), ("-.", "dashdot")], +) +def test_fill_outline_exports_line_style(linestyle, dash): + """plt.fill polygon outlines export their matplotlib line style as dash.""" + fig, ax = plt.subplots() + ax.fill([0, 1, 1], [0, 0, 1], edgecolor="k", linewidth=3, linestyle=linestyle) + + plotly_fig = tls.mpl_to_plotly(fig) + + assert plotly_fig.data[0].line.dash == dash + + +def test_fill_outline_returns_to_first_vertex(): + """plt.fill polygon outlines end at their first vertex, so plotly draws + the closing edge.""" + fig, ax = plt.subplots() + ax.fill([0, 1, 1], [0, 0, 1], edgecolor="k") + + plotly_fig = tls.mpl_to_plotly(fig) + + assert plotly_fig.data[0].x == (0, 1, 1, 0) + assert plotly_fig.data[0].y == (0, 0, 1, 0) + + +def test_open_polygon_outline_ends_at_last_vertex(): + """Polygons with closed=False export their vertices as given.""" + fig, ax = plt.subplots() + ax.add_patch( + matplotlib.patches.Polygon( + [[0, 0], [1, 0], [1, 1]], closed=False, fill=False, edgecolor="k" + ) + ) + ax.set_xlim(-1, 2) + ax.set_ylim(-1, 2) + + plotly_fig = tls.mpl_to_plotly(fig) + + assert plotly_fig.data[0].x == (0, 1, 1) + assert plotly_fig.data[0].y == (0, 0, 1) + + +def test_fill_between_outline_ends_at_first_vertex_once(): + """fill_between paths, which end at their first vertex, export each + vertex once.""" + fig, ax = plt.subplots() + ax.fill_between([0, 1, 2], [0, 1, 0], [1, 2, 1], edgecolor="k") + + plotly_fig = tls.mpl_to_plotly(fig) + + assert plotly_fig.data[0].x == (0, 0, 1, 2, 2, 2, 1, 0) + assert plotly_fig.data[0].y == (1, 0, 1, 0, 1, 1, 2, 1) + + +def test_unfilled_polygons_export_outline_only(): + """Polygons without a face (step histograms, fill=False, facecolor + "none") export as outline-only traces, with no fill color.""" + fig, ax = plt.subplots() + ax.hist([1, 2, 2, 3], bins=3, histtype="step") + ax.add_patch( + matplotlib.patches.Polygon( + [[1, 0.5], [2, 2.5], [3, 0.5]], fill=False, edgecolor="g" + ) + ) + ax.fill([0, 1, 1], [0, 0, 1], facecolor="none", edgecolor="k") + + plotly_fig = tls.mpl_to_plotly(fig) + + assert len(plotly_fig.data) == 3 + assert [t.fill for t in plotly_fig.data] == [None, None, None] + assert [t.fillcolor for t in plotly_fig.data] == [None, None, None] + + +def test_only_visible_fill_polygons_export(): + """Only visible plt.fill polygons export as traces.""" + fig, ax = plt.subplots() + ax.fill([0, 1, 1], [0, 0, 1], "b") + hidden = ax.fill([0, 0, 1], [0, 1, 1], "r") + hidden[0].set_visible(False) + + plotly_fig = tls.mpl_to_plotly(fig) + + assert len(plotly_fig.data) == 1 + assert plotly_fig.data[0].fillcolor == "#0000FF" + + +@pytest.mark.parametrize( + "make_patch", + [ + lambda: matplotlib.patches.Circle((0.5, 0.5), 0.3, color="r"), + lambda: matplotlib.patches.Ellipse((0.5, 0.5), 0.6, 0.3, angle=30, color="r"), + lambda: matplotlib.patches.Wedge((0.5, 0.5), 0.4, 30, 270, color="r"), + lambda: matplotlib.patches.PathPatch( + matplotlib.path.Path( + [(0.2, 0.2), (0.8, 0.2), (0.8, 0.8), (0.5, 0.6), (0.2, 0.2)], + [ + matplotlib.path.Path.MOVETO, + matplotlib.path.Path.LINETO, + matplotlib.path.Path.LINETO, + matplotlib.path.Path.LINETO, + matplotlib.path.Path.CLOSEPOLY, + ], + ), + color="r", + ), + lambda: matplotlib.patches.Rectangle((0.2, 0.3), 0.5, 0.4, color="r"), + ], + ids=["circle", "ellipse", "wedge", "pathpatch", "rectangle"], +) +def test_data_patches_export_as_filled_traces(make_patch): + """Patches in data coordinates export as filled scatter traces.""" + fig, ax = plt.subplots() + ax.add_patch(make_patch()) + + plotly_fig = tls.mpl_to_plotly(fig) + + assert len(plotly_fig.data) == 1 + assert plotly_fig.data[0].fill == "toself" + assert plotly_fig.data[0].fillcolor == "#FF0000" + + +def test_circle_outline_points_lie_on_the_circle(): + """Circle outlines export points on the circle: the Bezier curves of the + circle path are sampled along each curve.""" + fig, ax = plt.subplots() + ax.add_patch(matplotlib.patches.Circle((0.5, 0.5), 0.3)) + + plotly_fig = tls.mpl_to_plotly(fig) + + x = np.array(plotly_fig.data[0].x) + y = np.array(plotly_fig.data[0].y) + assert len(x) > 25 + assert np.allclose(np.hypot(x - 0.5, y - 0.5), 0.3, atol=1e-3) + + +def test_patch_subpaths_export_separated_by_none(): + """Each subpath of a patch exports as its own closed outline, with None + between subpaths.""" + M, L, Z = ( + matplotlib.path.Path.MOVETO, + matplotlib.path.Path.LINETO, + matplotlib.path.Path.CLOSEPOLY, + ) + path = matplotlib.path.Path( + [(0, 0), (1, 0), (1, 1), (0, 1), (0, 0)] + + [(2, 2), (3, 2), (3, 3), (2, 3), (2, 2)], + [M, L, L, L, Z] * 2, + ) + fig, ax = plt.subplots() + ax.add_patch(matplotlib.patches.PathPatch(path, edgecolor="k", facecolor="c")) + + plotly_fig = tls.mpl_to_plotly(fig) + + assert plotly_fig.data[0].x == (0, 1, 1, 0, 0, None, 2, 3, 3, 2, 2) + assert plotly_fig.data[0].y == (0, 0, 1, 1, 0, None, 2, 2, 3, 3, 2) + + def test_tick_label_color_exports(): """Tick label colors are exported to the plotly tickfont.""" fig, ax = plt.subplots()