From 47d2df413740270dc0f9ce9f099ef9b77261015d Mon Sep 17 00:00:00 2001 From: Roberto Moura Date: Wed, 2 Sep 2026 15:48:28 +0000 Subject: [PATCH 01/20] Convert fill polygons to filled scatter traces --- plotly/matplotlylib/renderer.py | 25 ++++++++++++++++++++++ plotly/matplotlylib/tests/test_renderer.py | 18 ++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/plotly/matplotlylib/renderer.py b/plotly/matplotlylib/renderer.py index 0a3c829945..69c781280d 100644 --- a/plotly/matplotlylib/renderer.py +++ b/plotly/matplotlylib/renderer.py @@ -908,6 +908,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( @@ -970,6 +976,25 @@ def _draw_step_path(self, props): ) ) + def _draw_filled_polygon(self, props): + """Draw a matplotlib Polygon patch as a filled scatter trace.""" + style = props["style"] + verts = props["data"] + facecolor = _export_color(style["facecolor"]) + edgecolor = _export_color(style["edgecolor"]) + 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=edgecolor, width=style["edgewidth"]), + fill="toself", + fillcolor=facecolor, + xaxis="x{0}".format(self.axis_ct), + yaxis="y{0}".format(self.axis_ct), + ) + ) + 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..2cc7be3dea 100644 --- a/plotly/matplotlylib/tests/test_renderer.py +++ b/plotly/matplotlylib/tests/test_renderer.py @@ -1150,6 +1150,24 @@ 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_custom_date_xtickvals_given_as_numbers_are_converted(): """Custom date ticks given as matplotlib date numbers must be converted to date strings.""" From 211c63d41c8f9c1452ea7b9458aeb9f732f7ff07 Mon Sep 17 00:00:00 2001 From: Roberto Moura Date: Tue, 6 Oct 2026 08:41:32 +0000 Subject: [PATCH 02/20] Support legend labels on filled polygon traces --- plotly/matplotlylib/renderer.py | 9 ++++++++ plotly/matplotlylib/tests/test_renderer.py | 24 ++++++++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/plotly/matplotlylib/renderer.py b/plotly/matplotlylib/renderer.py index 69c781280d..a5cd4e3518 100644 --- a/plotly/matplotlylib/renderer.py +++ b/plotly/matplotlylib/renderer.py @@ -982,8 +982,17 @@ def _draw_filled_polygon(self, props): verts = props["data"] facecolor = _export_color(style["facecolor"]) edgecolor = _export_color(style["edgecolor"]) + mplobj = props.get("mplobj") + label = mplobj.get_label() if mplobj is not None else None + if not label or (isinstance(label, str) and label.startswith("_")): + label = None + showlegend = False + else: + showlegend = None self.plotly_fig.add_trace( go.Scatter( + name=label, + showlegend=showlegend, x=self._convert_x_dates([v[0] for v in verts]), y=[v[1] for v in verts], mode="lines", diff --git a/plotly/matplotlylib/tests/test_renderer.py b/plotly/matplotlylib/tests/test_renderer.py index 2cc7be3dea..1b29b6178f 100644 --- a/plotly/matplotlylib/tests/test_renderer.py +++ b/plotly/matplotlylib/tests/test_renderer.py @@ -1168,6 +1168,30 @@ def test_fill_converts(): 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 == 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_custom_date_xtickvals_given_as_numbers_are_converted(): """Custom date ticks given as matplotlib date numbers must be converted to date strings.""" From adf16d7dd1af7770353304d5b04105ed254f1e33 Mon Sep 17 00:00:00 2001 From: Roberto Moura Date: Tue, 6 Oct 2026 08:46:11 +0000 Subject: [PATCH 03/20] Add changelog entry --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 32590d344f..b3bf115d85 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,8 +4,10 @@ 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! +- Add support for converting matplotlib fill polygons (`plt.fill`) to filled scatter traces in `mpl_to_plotly`, 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! From 37b9679baf9e2132ddd71b77178759ff92dba9a1 Mon Sep 17 00:00:00 2001 From: Roberto Moura Date: Tue, 6 Oct 2026 09:03:23 +0000 Subject: [PATCH 04/20] Update changelog entry with PR number 5795 --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b3bf115d85..9ab5a5ba03 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,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`, 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! From c00d54f60fff0f82bf5a2118144dfc40eb34df4f Mon Sep 17 00:00:00 2001 From: Roberto Moura Date: Fri, 23 Oct 2026 00:07:33 +0000 Subject: [PATCH 05/20] Read the polygon label from props['mplobj'] directly --- plotly/matplotlylib/renderer.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/plotly/matplotlylib/renderer.py b/plotly/matplotlylib/renderer.py index a5cd4e3518..231ae24998 100644 --- a/plotly/matplotlylib/renderer.py +++ b/plotly/matplotlylib/renderer.py @@ -982,8 +982,7 @@ def _draw_filled_polygon(self, props): verts = props["data"] facecolor = _export_color(style["facecolor"]) edgecolor = _export_color(style["edgecolor"]) - mplobj = props.get("mplobj") - label = mplobj.get_label() if mplobj is not None else None + label = props["mplobj"].get_label() if not label or (isinstance(label, str) and label.startswith("_")): label = None showlegend = False From 27631a35847fed522c025901e2b3b5bc3296f2be Mon Sep 17 00:00:00 2001 From: Roberto Moura Date: Fri, 23 Oct 2026 00:08:17 +0000 Subject: [PATCH 06/20] Share legend label handling in a _legend_label helper --- plotly/matplotlylib/renderer.py | 37 +++++++++++++++------------------ 1 file changed, 17 insertions(+), 20 deletions(-) diff --git a/plotly/matplotlylib/renderer.py b/plotly/matplotlylib/renderer.py index 231ae24998..88bb55e673 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. @@ -532,14 +546,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, @@ -951,12 +958,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, @@ -982,12 +984,7 @@ def _draw_filled_polygon(self, props): verts = props["data"] facecolor = _export_color(style["facecolor"]) edgecolor = _export_color(style["edgecolor"]) - 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( name=label, From e141300ef183163e222592526b054bd95977eda6 Mon Sep 17 00:00:00 2001 From: Roberto Moura Date: Fri, 23 Oct 2026 00:08:56 +0000 Subject: [PATCH 07/20] Build filled polygon traces in a shared _add_filled_trace helper --- plotly/matplotlylib/renderer.py | 63 ++++++++++++++++----------------- 1 file changed, 31 insertions(+), 32 deletions(-) diff --git a/plotly/matplotlylib/renderer.py b/plotly/matplotlylib/renderer.py index 88bb55e673..75ba7503db 100644 --- a/plotly/matplotlylib/renderer.py +++ b/plotly/matplotlylib/renderer.py @@ -857,6 +857,25 @@ def flush(): flush() + def _add_filled_trace( + self, verts, facecolor, edgecolor, linewidth, name=None, showlegend=None + ): + """Add a filled scatter trace outlining `verts` on the current axes.""" + self.plotly_fig.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), + ) + ) + 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"]) @@ -864,22 +883,11 @@ 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), ) def draw_path(self, **props): @@ -981,23 +989,14 @@ def _draw_step_path(self, props): def _draw_filled_polygon(self, props): """Draw a matplotlib Polygon patch as a filled scatter trace.""" style = props["style"] - verts = props["data"] - facecolor = _export_color(style["facecolor"]) - edgecolor = _export_color(style["edgecolor"]) label, showlegend = _legend_label(props["mplobj"].get_label()) - self.plotly_fig.add_trace( - go.Scatter( - name=label, - 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=edgecolor, width=style["edgewidth"]), - fill="toself", - fillcolor=facecolor, - xaxis="x{0}".format(self.axis_ct), - yaxis="y{0}".format(self.axis_ct), - ) + self._add_filled_trace( + props["data"], + facecolor=style["facecolor"], + edgecolor=style["edgecolor"], + linewidth=style["edgewidth"], + name=label, + showlegend=showlegend, ) def draw_text(self, **props): From 79c0e126a09a7af7a00e0a86fdae501038546060 Mon Sep 17 00:00:00 2001 From: Roberto Moura Date: Fri, 23 Oct 2026 00:44:05 +0000 Subject: [PATCH 08/20] Use an identity check for showlegend in test_fill_with_legend_label --- plotly/matplotlylib/tests/test_renderer.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plotly/matplotlylib/tests/test_renderer.py b/plotly/matplotlylib/tests/test_renderer.py index 1b29b6178f..10c2646812 100644 --- a/plotly/matplotlylib/tests/test_renderer.py +++ b/plotly/matplotlylib/tests/test_renderer.py @@ -1178,7 +1178,7 @@ def test_fill_with_legend_label(): plotly_fig = tls.mpl_to_plotly(fig) - assert plotly_fig.layout.showlegend == True + assert plotly_fig.layout.showlegend is True assert len(plotly_fig.data) == 3 labeled_fill = next( From c576c8407c897eb571e1b11cb2b27d7d4e231407 Mon Sep 17 00:00:00 2001 From: Roberto Moura Date: Fri, 23 Oct 2026 00:44:08 +0000 Subject: [PATCH 09/20] Remove extra blank line under Unreleased in changelog --- CHANGELOG.md | 1 - 1 file changed, 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9ab5a5ba03..d85a725998 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,6 @@ 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! - 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! From d09ec45cb1bffc4fcc4853b8b12aea31c0e77232 Mon Sep 17 00:00:00 2001 From: Roberto Moura Date: Fri, 23 Oct 2026 11:20:56 +0000 Subject: [PATCH 10/20] Export the line style of fill polygon outlines --- plotly/matplotlylib/renderer.py | 14 ++++++++++++-- plotly/matplotlylib/tests/test_renderer.py | 14 ++++++++++++++ 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/plotly/matplotlylib/renderer.py b/plotly/matplotlylib/renderer.py index 75ba7503db..af5778fadf 100644 --- a/plotly/matplotlylib/renderer.py +++ b/plotly/matplotlylib/renderer.py @@ -858,7 +858,14 @@ def flush(): flush() def _add_filled_trace( - self, verts, facecolor, edgecolor, linewidth, name=None, showlegend=None + self, + verts, + facecolor, + edgecolor, + linewidth, + dash=None, + name=None, + showlegend=None, ): """Add a filled scatter trace outlining `verts` on the current axes.""" self.plotly_fig.add_trace( @@ -868,7 +875,9 @@ def _add_filled_trace( 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), + line=go.scatter.Line( + color=_export_color(edgecolor), width=linewidth, dash=dash + ), fill="toself", fillcolor=_export_color(facecolor), xaxis="x{0}".format(self.axis_ct), @@ -995,6 +1004,7 @@ def _draw_filled_polygon(self, props): facecolor=style["facecolor"], edgecolor=style["edgecolor"], linewidth=style["edgewidth"], + dash=mpltools.convert_dash(style["dasharray"]), name=label, showlegend=showlegend, ) diff --git a/plotly/matplotlylib/tests/test_renderer.py b/plotly/matplotlylib/tests/test_renderer.py index 10c2646812..c3ed278a0b 100644 --- a/plotly/matplotlylib/tests/test_renderer.py +++ b/plotly/matplotlylib/tests/test_renderer.py @@ -1192,6 +1192,20 @@ def test_fill_with_legend_label(): 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_custom_date_xtickvals_given_as_numbers_are_converted(): """Custom date ticks given as matplotlib date numbers must be converted to date strings.""" From 41c9fb3f2947173732ea24ebc6a659f8336dc468 Mon Sep 17 00:00:00 2001 From: Roberto Moura Date: Fri, 23 Oct 2026 11:28:08 +0000 Subject: [PATCH 11/20] Close fill polygon outlines at their first vertex --- plotly/matplotlylib/renderer.py | 32 +++++++++++++++- plotly/matplotlylib/tests/test_renderer.py | 43 +++++++++++++++++++++- 2 files changed, 72 insertions(+), 3 deletions(-) diff --git a/plotly/matplotlylib/renderer.py b/plotly/matplotlylib/renderer.py index af5778fadf..c28f586e8a 100644 --- a/plotly/matplotlylib/renderer.py +++ b/plotly/matplotlylib/renderer.py @@ -41,6 +41,32 @@ def _per_path(values, i, default): return values[i % n] if n else default +_VERTICES_PER_CODE = {"M": 1, "L": 1, "S": 2, "C": 3, "Z": 0} + + +def _close_subpaths(verts, codes): + """Return the path vertices with each closed subpath ending at its start. + + At each "Z" code the start vertex of the subpath is appended, which + draws the closing segment of the outline. Subpaths that already end at + their start vertex keep their vertices as given. + """ + closed = [] + start = None + i = 0 + for code in codes: + if code == "Z": + if start is not None and tuple(closed[-1]) != tuple(start): + closed.append(start) + continue + n = _VERTICES_PER_CODE[code] + if code == "M": + start = verts[i] + closed.extend(verts[i : i + n]) + i += n + return closed + + def _legend_label(label): """Return the (name, showlegend) pair for a trace from an artist label. @@ -860,6 +886,7 @@ def flush(): def _add_filled_trace( self, verts, + codes, facecolor, edgecolor, linewidth, @@ -867,7 +894,8 @@ def _add_filled_trace( name=None, showlegend=None, ): - """Add a filled scatter trace outlining `verts` on the current axes.""" + """Add a filled scatter trace outlining a path on the current axes.""" + verts = _close_subpaths(verts, codes) self.plotly_fig.add_trace( go.Scatter( name=name, @@ -894,6 +922,7 @@ def _draw_filled_path_collection(self, props): for i, (verts, codes) in enumerate(props["paths"]): 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), @@ -1001,6 +1030,7 @@ def _draw_filled_polygon(self, props): 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"], diff --git a/plotly/matplotlylib/tests/test_renderer.py b/plotly/matplotlylib/tests/test_renderer.py index c3ed278a0b..5e691edbd9 100644 --- a/plotly/matplotlylib/tests/test_renderer.py +++ b/plotly/matplotlylib/tests/test_renderer.py @@ -1162,8 +1162,8 @@ def test_fill_converts(): 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 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)" @@ -1206,6 +1206,45 @@ def test_fill_outline_exports_line_style(linestyle, dash): 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" + ) + ) + + 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_custom_date_xtickvals_given_as_numbers_are_converted(): """Custom date ticks given as matplotlib date numbers must be converted to date strings.""" From b5e6ef4236d454fcec14178e2effb1528a211710 Mon Sep 17 00:00:00 2001 From: Roberto Moura Date: Fri, 23 Oct 2026 11:59:39 +0000 Subject: [PATCH 12/20] Keep the open polygon test outline inside the axes frame --- plotly/matplotlylib/tests/test_renderer.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/plotly/matplotlylib/tests/test_renderer.py b/plotly/matplotlylib/tests/test_renderer.py index 5e691edbd9..cef3db0a0f 100644 --- a/plotly/matplotlylib/tests/test_renderer.py +++ b/plotly/matplotlylib/tests/test_renderer.py @@ -1226,6 +1226,8 @@ def test_open_polygon_outline_ends_at_last_vertex(): [[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) From bfdfa1b3c2de3552118f66763bfd9adf0783345a Mon Sep 17 00:00:00 2001 From: Roberto Moura Date: Fri, 23 Oct 2026 11:34:29 +0000 Subject: [PATCH 13/20] Share the SVG subpath parser between line and filled path traces --- plotly/matplotlylib/renderer.py | 82 ++++++++++++++++----------------- 1 file changed, 41 insertions(+), 41 deletions(-) diff --git a/plotly/matplotlylib/renderer.py b/plotly/matplotlylib/renderer.py index c28f586e8a..d63911a610 100644 --- a/plotly/matplotlylib/renderer.py +++ b/plotly/matplotlylib/renderer.py @@ -41,30 +41,52 @@ def _per_path(values, i, default): return values[i % n] if n else default -_VERTICES_PER_CODE = {"M": 1, "L": 1, "S": 2, "C": 3, "Z": 0} +# 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 _close_subpaths(verts, codes): """Return the path vertices with each closed subpath ending at its start. - At each "Z" code the start vertex of the subpath is appended, which - draws the closing segment of the outline. Subpaths that already end at - their start vertex keep their vertices as given. + 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. """ - closed = [] - start = None - i = 0 - for code in codes: - if code == "Z": - if start is not None and tuple(closed[-1]) != tuple(start): - closed.append(start) - continue - n = _VERTICES_PER_CODE[code] - if code == "M": - start = verts[i] - closed.extend(verts[i : i + n]) - i += n - return closed + closed_verts = [] + for sub, closed in _split_subpaths(verts, codes): + if closed and tuple(sub[-1]) != tuple(sub[0]): + sub = sub + [sub[0]] + closed_verts.extend(sub) + return closed_verts def _legend_label(label): @@ -824,31 +846,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 From f86f5a120a9624f56027fd9d473dba9e619a9888 Mon Sep 17 00:00:00 2001 From: Roberto Moura Date: Fri, 23 Oct 2026 12:29:42 +0000 Subject: [PATCH 14/20] Export unfilled fill polygons as outline-only traces --- plotly/matplotlylib/renderer.py | 8 ++++++-- plotly/matplotlylib/tests/test_renderer.py | 18 ++++++++++++++++++ 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/plotly/matplotlylib/renderer.py b/plotly/matplotlylib/renderer.py index d63911a610..32288f52fc 100644 --- a/plotly/matplotlylib/renderer.py +++ b/plotly/matplotlylib/renderer.py @@ -894,7 +894,11 @@ def _add_filled_trace( name=None, showlegend=None, ): - """Add a filled scatter trace outlining a path on the current axes.""" + """Add a scatter trace outlining a path on the current axes. + + The path is filled with `facecolor`; a facecolor of "none" gives an + outline-only trace. + """ verts = _close_subpaths(verts, codes) self.plotly_fig.add_trace( go.Scatter( @@ -906,7 +910,7 @@ def _add_filled_trace( line=go.scatter.Line( color=_export_color(edgecolor), width=linewidth, dash=dash ), - fill="toself", + fill="toself" if facecolor != "none" else None, fillcolor=_export_color(facecolor), xaxis="x{0}".format(self.axis_ct), yaxis="y{0}".format(self.axis_ct), diff --git a/plotly/matplotlylib/tests/test_renderer.py b/plotly/matplotlylib/tests/test_renderer.py index cef3db0a0f..1e37739fad 100644 --- a/plotly/matplotlylib/tests/test_renderer.py +++ b/plotly/matplotlylib/tests/test_renderer.py @@ -1247,6 +1247,24 @@ def test_fill_between_outline_ends_at_first_vertex_once(): 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.""" + 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] + + def test_custom_date_xtickvals_given_as_numbers_are_converted(): """Custom date ticks given as matplotlib date numbers must be converted to date strings.""" From 37110e2e3799e69126786f30bb90f636a7701023 Mon Sep 17 00:00:00 2001 From: Roberto Moura Date: Fri, 23 Oct 2026 12:36:02 +0000 Subject: [PATCH 15/20] Skip hidden fill polygons --- plotly/matplotlylib/renderer.py | 5 ++++- plotly/matplotlylib/tests/test_renderer.py | 13 +++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/plotly/matplotlylib/renderer.py b/plotly/matplotlylib/renderer.py index 32288f52fc..0eada030fa 100644 --- a/plotly/matplotlylib/renderer.py +++ b/plotly/matplotlylib/renderer.py @@ -1029,7 +1029,10 @@ def _draw_step_path(self, props): ) def _draw_filled_polygon(self, props): - """Draw a matplotlib Polygon patch as a filled scatter trace.""" + """Draw a visible matplotlib Polygon patch as a filled scatter trace.""" + if not props["mplobj"].get_visible(): + self.msg += " Polygon is hidden, not drawing\n" + return style = props["style"] label, showlegend = _legend_label(props["mplobj"].get_label()) self._add_filled_trace( diff --git a/plotly/matplotlylib/tests/test_renderer.py b/plotly/matplotlylib/tests/test_renderer.py index 1e37739fad..ee44cabc93 100644 --- a/plotly/matplotlylib/tests/test_renderer.py +++ b/plotly/matplotlylib/tests/test_renderer.py @@ -1265,6 +1265,19 @@ def test_unfilled_polygons_export_outline_only(): assert [t.fill 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" + + def test_custom_date_xtickvals_given_as_numbers_are_converted(): """Custom date ticks given as matplotlib date numbers must be converted to date strings.""" From c00332ac86a72b5e32d63be81b122fe6b9ed6759 Mon Sep 17 00:00:00 2001 From: Roberto Moura Date: Fri, 23 Oct 2026 13:37:03 +0000 Subject: [PATCH 16/20] Convert patches in data coordinates to filled traces --- plotly/matplotlylib/renderer.py | 65 +++++++++++++++++----- plotly/matplotlylib/tests/test_renderer.py | 49 ++++++++++++++++ 2 files changed, 101 insertions(+), 13 deletions(-) diff --git a/plotly/matplotlylib/renderer.py b/plotly/matplotlylib/renderer.py index 0eada030fa..7dcf4c4d08 100644 --- a/plotly/matplotlylib/renderer.py +++ b/plotly/matplotlylib/renderer.py @@ -74,15 +74,53 @@ def _split_subpaths(verts, codes): 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 path vertices with each closed subpath ending at its start. - 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. + 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. """ closed_verts = [] - for sub, closed in _split_subpaths(verts, codes): + for sub, closed in _split_subpaths(*_flatten_curves(verts, codes)): if closed and tuple(sub[-1]) != tuple(sub[0]): sub = sub + [sub[0]] closed_verts.extend(sub) @@ -933,11 +971,12 @@ def _draw_filled_path_collection(self, props): ) 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() -- [ @@ -966,11 +1005,11 @@ def draw_path(self, **props): self.msg += " Drawing a step path\n" self._draw_step_path(props) elif ( - isinstance(props["mplobj"], mpatches.Polygon) + isinstance(props["mplobj"], mpatches.Patch) and props["coordinates"] == "data" ): - self.msg += " Drawing a filled polygon\n" - self._draw_filled_polygon(props) + 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( @@ -1028,10 +1067,10 @@ def _draw_step_path(self, props): ) ) - def _draw_filled_polygon(self, props): - """Draw a visible matplotlib Polygon patch as a filled scatter trace.""" + def _draw_filled_patch(self, props): + """Draw a visible matplotlib patch as a filled scatter trace.""" if not props["mplobj"].get_visible(): - self.msg += " Polygon is hidden, not drawing\n" + self.msg += " Patch is hidden, not drawing\n" return style = props["style"] label, showlegend = _legend_label(props["mplobj"].get_label()) diff --git a/plotly/matplotlylib/tests/test_renderer.py b/plotly/matplotlylib/tests/test_renderer.py index ee44cabc93..2558d35001 100644 --- a/plotly/matplotlylib/tests/test_renderer.py +++ b/plotly/matplotlylib/tests/test_renderer.py @@ -1278,6 +1278,55 @@ def test_only_visible_fill_polygons_export(): 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_custom_date_xtickvals_given_as_numbers_are_converted(): """Custom date ticks given as matplotlib date numbers must be converted to date strings.""" From d84d3bbce59c714c827c9f3203ad6d829c5648b6 Mon Sep 17 00:00:00 2001 From: Roberto Moura Date: Fri, 23 Oct 2026 13:48:28 +0000 Subject: [PATCH 17/20] Move fill tests after the custom date tick tests --- plotly/matplotlylib/tests/test_renderer.py | 40 +++++++++++----------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/plotly/matplotlylib/tests/test_renderer.py b/plotly/matplotlylib/tests/test_renderer.py index 2558d35001..0e4e330026 100644 --- a/plotly/matplotlylib/tests/test_renderer.py +++ b/plotly/matplotlylib/tests/test_renderer.py @@ -1150,6 +1150,26 @@ def test_uneven_custom_date_xtickvals_are_converted(): ) +def test_custom_date_xtickvals_given_as_numbers_are_converted(): + """Custom date ticks given as matplotlib date numbers must be converted + to date strings.""" + import matplotlib.dates as mdates + + dates = [datetime.datetime(2023, 1, i) for i in range(1, 11)] + fig, ax = plt.subplots() + ax.plot(dates, np.random.rand(10)) + ax.set_xticks([mdates.date2num(d) for d in dates[::3]]) + + plotly_fig = tls.mpl_to_plotly(fig) + + assert plotly_fig.layout.xaxis.tickvals == ( + "2023-01-01 00:00:00", + "2023-01-04 00:00:00", + "2023-01-07 00:00:00", + "2023-01-10 00:00:00", + ) + + def test_fill_converts(): """plt.fill polygons convert to filled scatter traces.""" x = np.linspace(0, 2 * np.pi, 50) @@ -1327,26 +1347,6 @@ def test_circle_outline_points_lie_on_the_circle(): assert np.allclose(np.hypot(x - 0.5, y - 0.5), 0.3, atol=1e-3) -def test_custom_date_xtickvals_given_as_numbers_are_converted(): - """Custom date ticks given as matplotlib date numbers must be converted - to date strings.""" - import matplotlib.dates as mdates - - dates = [datetime.datetime(2023, 1, i) for i in range(1, 11)] - fig, ax = plt.subplots() - ax.plot(dates, np.random.rand(10)) - ax.set_xticks([mdates.date2num(d) for d in dates[::3]]) - - plotly_fig = tls.mpl_to_plotly(fig) - - assert plotly_fig.layout.xaxis.tickvals == ( - "2023-01-01 00:00:00", - "2023-01-04 00:00:00", - "2023-01-07 00:00:00", - "2023-01-10 00:00:00", - ) - - def test_tick_label_color_exports(): """Tick label colors are exported to the plotly tickfont.""" fig, ax = plt.subplots() From 2edef1cb7dc46f972cfd508c59c3eb6a44e80c31 Mon Sep 17 00:00:00 2001 From: Roberto Moura Date: Fri, 23 Oct 2026 14:13:51 +0000 Subject: [PATCH 18/20] Update changelog for patch conversion --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d85a725998..85e8301bb5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +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! +- 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! From 5e60ad023ed86e78dc835283ef76b00905ee486b Mon Sep 17 00:00:00 2001 From: Roberto Moura Date: Fri, 23 Oct 2026 14:56:16 +0000 Subject: [PATCH 19/20] Separate the subpaths of filled traces with None --- plotly/matplotlylib/renderer.py | 25 ++++++++++++++-------- plotly/matplotlylib/tests/test_renderer.py | 22 +++++++++++++++++++ 2 files changed, 38 insertions(+), 9 deletions(-) diff --git a/plotly/matplotlylib/renderer.py b/plotly/matplotlylib/renderer.py index 7dcf4c4d08..ca7d653abc 100644 --- a/plotly/matplotlylib/renderer.py +++ b/plotly/matplotlylib/renderer.py @@ -112,19 +112,19 @@ def _flatten_curves(verts, codes, samples=16): def _close_subpaths(verts, codes): - """Return the path vertices with each closed subpath ending at its start. + """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. """ - closed_verts = [] + subpaths = [] for sub, closed in _split_subpaths(*_flatten_curves(verts, codes)): if closed and tuple(sub[-1]) != tuple(sub[0]): sub = sub + [sub[0]] - closed_verts.extend(sub) - return closed_verts + subpaths.append(sub) + return subpaths def _legend_label(label): @@ -934,16 +934,23 @@ def _add_filled_trace( ): """Add a scatter trace outlining a path on the current axes. - The path is filled with `facecolor`; a facecolor of "none" gives an - outline-only trace. + The subpaths of the path are separated by None. The path is filled + with `facecolor`; a facecolor of "none" gives an outline-only trace. """ - verts = _close_subpaths(verts, codes) + 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) self.plotly_fig.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], + x=x, + y=y, mode="lines", line=go.scatter.Line( color=_export_color(edgecolor), width=linewidth, dash=dash diff --git a/plotly/matplotlylib/tests/test_renderer.py b/plotly/matplotlylib/tests/test_renderer.py index 0e4e330026..4f5a97d5a8 100644 --- a/plotly/matplotlylib/tests/test_renderer.py +++ b/plotly/matplotlylib/tests/test_renderer.py @@ -1347,6 +1347,28 @@ def test_circle_outline_points_lie_on_the_circle(): 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() From 40d048551b95a4143c3c5baff1bd79241327135c Mon Sep 17 00:00:00 2001 From: Roberto Moura Date: Fri, 23 Oct 2026 15:15:58 +0000 Subject: [PATCH 20/20] Set fillcolor only on filled traces --- plotly/matplotlylib/renderer.py | 5 +++-- plotly/matplotlylib/tests/test_renderer.py | 3 ++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/plotly/matplotlylib/renderer.py b/plotly/matplotlylib/renderer.py index ca7d653abc..fc14c46357 100644 --- a/plotly/matplotlylib/renderer.py +++ b/plotly/matplotlylib/renderer.py @@ -945,6 +945,7 @@ def _add_filled_trace( 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, @@ -955,8 +956,8 @@ def _add_filled_trace( line=go.scatter.Line( color=_export_color(edgecolor), width=linewidth, dash=dash ), - fill="toself" if facecolor != "none" else None, - fillcolor=_export_color(facecolor), + 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), ) diff --git a/plotly/matplotlylib/tests/test_renderer.py b/plotly/matplotlylib/tests/test_renderer.py index 4f5a97d5a8..fde3711030 100644 --- a/plotly/matplotlylib/tests/test_renderer.py +++ b/plotly/matplotlylib/tests/test_renderer.py @@ -1269,7 +1269,7 @@ def test_fill_between_outline_ends_at_first_vertex_once(): def test_unfilled_polygons_export_outline_only(): """Polygons without a face (step histograms, fill=False, facecolor - "none") export as outline-only traces.""" + "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( @@ -1283,6 +1283,7 @@ def test_unfilled_polygons_export_outline_only(): 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():