diff --git a/CHANGELOG.md b/CHANGELOG.md index 4f4cc3ac0ed..bc5b3cc5e9a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,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! - Add `` to the `to_html()` template to comply with modern web standards [[#5693](https://github.com/plotly/plotly.py/pull/5693)], with thanks to @mishrakushal for the contribution! +- 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 diff --git a/plotly/matplotlylib/renderer.py b/plotly/matplotlylib/renderer.py index 7c2340180cc..a282c67cec1 100644 --- a/plotly/matplotlylib/renderer.py +++ b/plotly/matplotlylib/renderer.py @@ -14,6 +14,19 @@ 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 + return [_export_color(c) for c in color] + + class PlotlyRenderer(Renderer): """A renderer class inheriting from base for rendering mpl plots in plotly. @@ -55,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. @@ -286,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] @@ -436,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": @@ -513,6 +522,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 +534,42 @@ 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"]) + + 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[i % n] 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=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_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..f56d8309174 100644 --- a/plotly/matplotlylib/tests/test_renderer.py +++ b/plotly/matplotlylib/tests/test_renderer.py @@ -1,3 +1,6 @@ +import datetime + +import numpy as np import matplotlib.pyplot as plt import plotly.tools as tls @@ -84,3 +87,115 @@ 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_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() + 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 + + +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)