From 513e432b4886c0edf956ad038408a91eedf667aa Mon Sep 17 00:00:00 2001 From: Roberto Moura Date: Tue, 11 Aug 2026 12:32:38 +0000 Subject: [PATCH] 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