From 513e432b4886c0edf956ad038408a91eedf667aa Mon Sep 17 00:00:00 2001 From: Roberto Moura Date: Tue, 11 Aug 2026 12:32:38 +0000 Subject: [PATCH 1/8] Draw data-coordinate path collections as filled polygons --- plotly/matplotlylib/renderer.py | 59 ++++++++++++++++++ plotly/matplotlylib/tests/test_renderer.py | 71 ++++++++++++++++++++++ 2 files changed, 130 insertions(+) diff --git a/plotly/matplotlylib/renderer.py b/plotly/matplotlylib/renderer.py index 7c2340180cc..1ce4ea2b1ff 100644 --- a/plotly/matplotlylib/renderer.py +++ b/plotly/matplotlylib/renderer.py @@ -14,6 +14,24 @@ from plotly.matplotlylib import mpltools +def _export_color(color): + """Export a matplotlib color for use as a plotly color. + + matplotlib uses "none" for fully transparent colors, which plotly does not + accept, so transparent colors are exported as transparent black. + Colors already exported by the mplexporter (hex or rgba strings) are + passed through unchanged. + """ + if isinstance(color, str): + return "rgba(0,0,0,0)" if color == "none" else color + if isinstance(color, (list, tuple)) and all( + isinstance(c, str) for c in color + ): + return [_export_color(c) for c in color] + bgcolor = export_color(color) + return "rgba(0,0,0,0)" if bgcolor == "none" else bgcolor + + class PlotlyRenderer(Renderer): """A renderer class inheriting from base for rendering mpl plots in plotly. @@ -513,6 +531,9 @@ def draw_path_collection(self, **props): } self.msg += " Drawing path collection as markers\n" self.draw_marked_line(**scatter_props) + elif props["path_coordinates"] == "data": + self.msg += " Drawing path collection as filled polygons\n" + self._draw_filled_path_collection(props) else: self.msg += " Path collection not linked to 'data', not drawing\n" warnings.warn( @@ -522,6 +543,44 @@ def draw_path_collection(self, **props): "collections linked to 'data' coordinates" ) + 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"]) + edgecolors = mpltools.convert_rgba_array(props["styles"]["edgecolor"]) + linewidths = mpltools.convert_linewidth_array(props["styles"]["linewidth"]) + alpha = props["styles"]["alpha"] + + def per_path(colors, i, default): + if isinstance(colors, str): + return colors + if colors is None: + return default + try: + n = len(colors) + except TypeError: + return colors + return colors[min(i, n - 1)] if n else default + + 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=[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), + opacity=alpha, + xaxis="x{0}".format(self.axis_ct), + yaxis="y{0}".format(self.axis_ct), + ) + ) + def draw_path(self, **props): """Draw path, currently only attempts to draw bar charts. diff --git a/plotly/matplotlylib/tests/test_renderer.py b/plotly/matplotlylib/tests/test_renderer.py index 0d63e4815b9..bed5450508f 100644 --- a/plotly/matplotlylib/tests/test_renderer.py +++ b/plotly/matplotlylib/tests/test_renderer.py @@ -1,3 +1,4 @@ +import numpy as np import matplotlib.pyplot as plt import plotly.tools as tls @@ -84,3 +85,73 @@ def test_multiple_traces_native_legend(): assert plotly_fig.data[0].mode == "lines" assert plotly_fig.data[1].mode == "markers" assert plotly_fig.data[2].mode == "lines+markers" + + +def test_violinplot_bodies_are_filled_polygons(): + fig, ax = plt.subplots() + ax.violinplot(np.random.randn(100, 3)) + plotly_fig = tls.mpl_to_plotly(fig) + bodies = [t for t in plotly_fig.data if t.fill == "toself" and len(t.x) > 100] + assert len(bodies) >= 3 + + +def test_pcolor_rectangles_render(): + x = np.linspace(-3, 3, 10) + X, Y = np.meshgrid(x, x) + fig, ax = plt.subplots() + ax.pcolor(X, Y, np.sin(X) * np.cos(Y)) + plotly_fig = tls.mpl_to_plotly(fig) + assert len(plotly_fig.data) == 100 + assert all(len(t.x) >= 4 for t in plotly_fig.data) + + +def test_eventplot_segments_render(): + fig, ax = plt.subplots() + ax.eventplot([np.random.randn(20) for _ in range(5)]) + plotly_fig = tls.mpl_to_plotly(fig) + assert len(plotly_fig.data) == 100 + + +def test_stackplot_areas_render(): + x = np.arange(10) + fig, ax = plt.subplots() + ax.stackplot(x, np.random.rand(10), np.random.rand(10), np.random.rand(10)) + plotly_fig = tls.mpl_to_plotly(fig) + assert len(plotly_fig.data) >= 3 + + +def test_fill_between_renders(): + x = np.linspace(0, 2 * np.pi, 50) + fig, ax = plt.subplots() + ax.fill_between(x, np.sin(x), np.cos(x)) + plotly_fig = tls.mpl_to_plotly(fig) + assert len(plotly_fig.data) >= 1 + + +def test_stem_plot_renders(): + x = np.linspace(0, 2 * np.pi, 20) + fig, ax = plt.subplots() + ax.stem(x, np.sin(x)) + plotly_fig = tls.mpl_to_plotly(fig) + assert len(plotly_fig.data) >= 20 + + +def test_contour_lines_convert(): + """Contour lines used to crash with an ndarray line width.""" + x = np.linspace(-3, 3, 30) + X, Y = np.meshgrid(x, x) + fig, ax = plt.subplots() + ax.contour(X, Y, np.sin(X) * np.cos(Y), 10) + plotly_fig = tls.mpl_to_plotly(fig) + assert len(plotly_fig.data) > 0 + + +def test_contourf_bands_render(): + """Contourf bands (multi-subpath collections) must render as fills.""" + x = np.linspace(-3, 3, 30) + X, Y = np.meshgrid(x, x) + fig, ax = plt.subplots() + ax.contourf(X, Y, np.sin(X) * np.cos(Y), 10) + plotly_fig = tls.mpl_to_plotly(fig) + filled = [t for t in plotly_fig.data if t.fill == "toself"] + assert len(filled) > 0 From f0c56c0f78c77648967e2592aa90f4837e6e6413 Mon Sep 17 00:00:00 2001 From: Roberto Moura Date: Fri, 21 Aug 2026 09:48:22 +0100 Subject: [PATCH 2/8] Run ruff format --- plotly/matplotlylib/renderer.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/plotly/matplotlylib/renderer.py b/plotly/matplotlylib/renderer.py index 1ce4ea2b1ff..37a08ac35ad 100644 --- a/plotly/matplotlylib/renderer.py +++ b/plotly/matplotlylib/renderer.py @@ -24,9 +24,7 @@ def _export_color(color): """ if isinstance(color, str): return "rgba(0,0,0,0)" if color == "none" else color - if isinstance(color, (list, tuple)) and all( - isinstance(c, str) for c in color - ): + if isinstance(color, (list, tuple)) and all(isinstance(c, str) for c in color): return [_export_color(c) for c in color] bgcolor = export_color(color) return "rgba(0,0,0,0)" if bgcolor == "none" else bgcolor From 738e81d2c9410207d1d0178a9377eb9a928713d8 Mon Sep 17 00:00:00 2001 From: Roberto Moura Date: Fri, 21 Aug 2026 11:39:00 +0100 Subject: [PATCH 3/8] Remove opacity field from _draw_filled_path_collection --- plotly/matplotlylib/renderer.py | 2 -- plotly/matplotlylib/tests/test_renderer.py | 28 ++++++++++++++++++++++ 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/plotly/matplotlylib/renderer.py b/plotly/matplotlylib/renderer.py index 37a08ac35ad..b58708a278a 100644 --- a/plotly/matplotlylib/renderer.py +++ b/plotly/matplotlylib/renderer.py @@ -546,7 +546,6 @@ def _draw_filled_path_collection(self, props): facecolors = mpltools.convert_rgba_array(props["styles"]["facecolor"]) edgecolors = mpltools.convert_rgba_array(props["styles"]["edgecolor"]) linewidths = mpltools.convert_linewidth_array(props["styles"]["linewidth"]) - alpha = props["styles"]["alpha"] def per_path(colors, i, default): if isinstance(colors, str): @@ -573,7 +572,6 @@ def per_path(colors, i, default): ), fill="toself", fillcolor=_export_color(facecolor), - opacity=alpha, 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 bed5450508f..93a9fe0a4ff 100644 --- a/plotly/matplotlylib/tests/test_renderer.py +++ b/plotly/matplotlylib/tests/test_renderer.py @@ -128,6 +128,34 @@ def test_fill_between_renders(): assert len(plotly_fig.data) >= 1 +def test_collection_alpha(): + """Collection alpha is baked into the facecolor rgba by matplotlib. if + fillcolor has an alpha channel, the opacity field should not be set.""" + x = np.linspace(0, 2 * np.pi, 50) + fig, ax = plt.subplots() + ax.fill_between(x, np.sin(x), np.cos(x), color="red", alpha=0.4) + plotly_fig = tls.mpl_to_plotly(fig) + trace = plotly_fig.data[0] + assert trace.fillcolor == "rgba(255,0,0,0.4)" + assert trace.opacity is None + + +def test_violin_body_default_alpha(): + """Violin bodies default to alpha=0.3 in matplotlib, which is + embedded in their facecolor rgba. If the alpha channel in fillcolor + is set, the opacity field should not be set.""" + fig, ax = plt.subplots() + ax.violinplot(np.random.randn(100, 3)) + plotly_fig = tls.mpl_to_plotly(fig) + bodies = [ + t + for t in plotly_fig.data + if t.fill == "toself" and t.fillcolor == "rgba(31,119,180,0.3)" + ] + assert len(bodies) >= 3 + assert all(t.opacity is None for t in bodies) + + def test_stem_plot_renders(): x = np.linspace(0, 2 * np.pi, 20) fig, ax = plt.subplots() From 74fac13f8cd2b0ccbedfb487fa6e35c38ddc0593 Mon Sep 17 00:00:00 2001 From: Roberto Moura Date: Fri, 21 Aug 2026 11:49:17 +0100 Subject: [PATCH 4/8] Replace colors[min(i, n - 1)] with colors[i % n] --- plotly/matplotlylib/renderer.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plotly/matplotlylib/renderer.py b/plotly/matplotlylib/renderer.py index b58708a278a..f8cd2d6b22b 100644 --- a/plotly/matplotlylib/renderer.py +++ b/plotly/matplotlylib/renderer.py @@ -556,7 +556,7 @@ def per_path(colors, i, default): n = len(colors) except TypeError: return colors - return colors[min(i, n - 1)] if n else default + return colors[i % n] if n else default for i, (verts, codes) in enumerate(props["paths"]): facecolor = per_path(facecolors, i, "rgba(0,0,0,0)") From 755875cfab4ae84fce75ebd80651e6a50986dadb Mon Sep 17 00:00:00 2001 From: Roberto Moura Date: Fri, 21 Aug 2026 12:05:38 +0100 Subject: [PATCH 5/8] Remove unreachable non-string fallback from _export_color --- plotly/matplotlylib/renderer.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/plotly/matplotlylib/renderer.py b/plotly/matplotlylib/renderer.py index f8cd2d6b22b..9c926790bbd 100644 --- a/plotly/matplotlylib/renderer.py +++ b/plotly/matplotlylib/renderer.py @@ -24,10 +24,7 @@ def _export_color(color): """ if isinstance(color, str): return "rgba(0,0,0,0)" if color == "none" else color - if isinstance(color, (list, tuple)) and all(isinstance(c, str) for c in color): - return [_export_color(c) for c in color] - bgcolor = export_color(color) - return "rgba(0,0,0,0)" if bgcolor == "none" else bgcolor + return [_export_color(c) for c in color] class PlotlyRenderer(Renderer): From a71efd8e60452b371259826972ac9b4ed02e09f9 Mon Sep 17 00:00:00 2001 From: Roberto Moura Date: Fri, 21 Aug 2026 12:21:17 +0100 Subject: [PATCH 6/8] Convert date x-values for filled path collections --- plotly/matplotlylib/renderer.py | 28 ++++++++++------------ plotly/matplotlylib/tests/test_renderer.py | 16 +++++++++++++ 2 files changed, 28 insertions(+), 16 deletions(-) diff --git a/plotly/matplotlylib/renderer.py b/plotly/matplotlylib/renderer.py index 9c926790bbd..a282c67cec1 100644 --- a/plotly/matplotlylib/renderer.py +++ b/plotly/matplotlylib/renderer.py @@ -68,6 +68,15 @@ def __init__(self): self._processing_legend = False self._legend_visible = False + def _convert_x_dates(self, x): + """Convert x values to date strings when the x-axis is a date axis.""" + if self.x_is_mpl_date: + formatter = ( + self.current_mpl_ax.get_xaxis().get_major_formatter().__class__.__name__ + ) + x = mpltools.mpl_dates_to_datestrings(x, formatter) + return x + def open_figure(self, fig, props): """Creates a new figure by beginning to fill out layout dict. @@ -299,13 +308,7 @@ def draw_bar(self, coll): [bar["x0"] for bar in trace], [bar["x1"] for bar in trace] ) if self.x_is_mpl_date: - x = [bar["x0"] for bar in trace] - formatter = ( - self.current_mpl_ax.get_xaxis() - .get_major_formatter() - .__class__.__name__ - ) - x = mpltools.mpl_dates_to_datestrings(x, formatter) + x = self._convert_x_dates([bar["x0"] for bar in trace]) else: self.msg += " Attempting to draw a horizontal bar chart\n" old_rights = [bar_props["x1"] for bar_props in trace] @@ -449,14 +452,7 @@ def draw_marked_line(self, **props): marker=marker, ) if self.x_is_mpl_date: - formatter = ( - self.current_mpl_ax.get_xaxis() - .get_major_formatter() - .__class__.__name__ - ) - marked_line["x"] = mpltools.mpl_dates_to_datestrings( - marked_line["x"], formatter - ) + 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": @@ -561,7 +557,7 @@ def per_path(colors, i, default): linewidth = per_path(linewidths, i, 0) self.plotly_fig.add_trace( go.Scatter( - x=[v[0] for v in verts], + x=self._convert_x_dates([v[0] for v in verts]), y=[v[1] for v in verts], mode="lines", line=go.scatter.Line( diff --git a/plotly/matplotlylib/tests/test_renderer.py b/plotly/matplotlylib/tests/test_renderer.py index 93a9fe0a4ff..f56d8309174 100644 --- a/plotly/matplotlylib/tests/test_renderer.py +++ b/plotly/matplotlylib/tests/test_renderer.py @@ -1,3 +1,5 @@ +import datetime + import numpy as np import matplotlib.pyplot as plt import plotly.tools as tls @@ -183,3 +185,17 @@ def test_contourf_bands_render(): plotly_fig = tls.mpl_to_plotly(fig) filled = [t for t in plotly_fig.data if t.fill == "toself"] assert len(filled) > 0 + + +def test_filled_path_collection_date_xaxis(): + """Filled path collections with date x-values must export date strings, + not raw matplotlib date numbers.""" + dates = [ + datetime.datetime(2023, 1, 1) + datetime.timedelta(days=i) for i in range(10) + ] + fig, ax = plt.subplots() + ax.fill_between(dates, np.sin(np.arange(10)), np.cos(np.arange(10))) + plotly_fig = tls.mpl_to_plotly(fig) + filled = [t for t in plotly_fig.data if t.fill == "toself"] + assert len(filled) >= 1 + assert all(isinstance(x, str) for x in filled[0].x) From c694e2289dcc9d81f0b33bcb4c7ff6bbe733bd42 Mon Sep 17 00:00:00 2001 From: Roberto Moura Date: Fri, 21 Aug 2026 12:59:01 +0100 Subject: [PATCH 7/8] Add changelog entry for path collection rendering fix --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index adcd44308da..1623f388e79 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ This project adheres to [Semantic Versioning](http://semver.org/). ### Fixed - Fix `hex_to_rgb` parsing of 3-digit shorthand hexadecimal colors such as `#FFF` [[#5662](https://github.com/plotly/plotly.py/pull/5662)], with thanks to @genrichez for the contribution! +- Fix `mpl_to_plotly` silently dropping matplotlib path collections in data coordinates (violin plots, pcolor, event plots, stack plots, fill_between, and stem plots) by rendering them as filled polygons or lines [[#5702](https://github.com/plotly/plotly.py/pull/5702)]. ## [6.9.0] - 2026-07-09 From 2eded50b1d69d29bf140f26fcf0362cf39f29130 Mon Sep 17 00:00:00 2001 From: Cameron DeCoster Date: Fri, 21 Aug 2026 10:19:59 -0600 Subject: [PATCH 8/8] Update changelog entry --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1623f388e79..dc28c07c693 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ This project adheres to [Semantic Versioning](http://semver.org/). ### Fixed - Fix `hex_to_rgb` parsing of 3-digit shorthand hexadecimal colors such as `#FFF` [[#5662](https://github.com/plotly/plotly.py/pull/5662)], with thanks to @genrichez for the contribution! -- Fix `mpl_to_plotly` silently dropping matplotlib path collections in data coordinates (violin plots, pcolor, event plots, stack plots, fill_between, and stem plots) by rendering them as filled polygons or lines [[#5702](https://github.com/plotly/plotly.py/pull/5702)]. +- Fix `mpl_to_plotly` silently dropping matplotlib path collections in data coordinates (such as violin plots, pcolor, event plots, stack plots, fill_between, and stem plots) by rendering them as filled polygons or lines [[#5702](https://github.com/plotly/plotly.py/pull/5702)], with thanks to @robertoffmoura for the contribution! ## [6.9.0] - 2026-07-09