Skip to content

Commit a5974ce

Browse files
authored
Merge pull request #5702 from robertoffmoura/rm/fix-violin-plot
Render matplotlib path collections (violin plots, pcolor, stems, etc.) in plotly
2 parents de4f21b + a8fac51 commit a5974ce

3 files changed

Lines changed: 179 additions & 15 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ This project adheres to [Semantic Versioning](http://semver.org/).
77
### Fixed
88
- 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!
99
- Add `<!doctype html>` 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!
10+
- 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!
1011

1112

1213
## [6.9.0] - 2026-07-09

plotly/matplotlylib/renderer.py

Lines changed: 63 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,19 @@
1414
from plotly.matplotlylib import mpltools
1515

1616

17+
def _export_color(color):
18+
"""Export a matplotlib color for use as a plotly color.
19+
20+
matplotlib uses "none" for fully transparent colors, which plotly does not
21+
accept, so transparent colors are exported as transparent black.
22+
Colors already exported by the mplexporter (hex or rgba strings) are
23+
passed through unchanged.
24+
"""
25+
if isinstance(color, str):
26+
return "rgba(0,0,0,0)" if color == "none" else color
27+
return [_export_color(c) for c in color]
28+
29+
1730
class PlotlyRenderer(Renderer):
1831
"""A renderer class inheriting from base for rendering mpl plots in plotly.
1932
@@ -55,6 +68,15 @@ def __init__(self):
5568
self._processing_legend = False
5669
self._legend_visible = False
5770

71+
def _convert_x_dates(self, x):
72+
"""Convert x values to date strings when the x-axis is a date axis."""
73+
if self.x_is_mpl_date:
74+
formatter = (
75+
self.current_mpl_ax.get_xaxis().get_major_formatter().__class__.__name__
76+
)
77+
x = mpltools.mpl_dates_to_datestrings(x, formatter)
78+
return x
79+
5880
def open_figure(self, fig, props):
5981
"""Creates a new figure by beginning to fill out layout dict.
6082
@@ -286,13 +308,7 @@ def draw_bar(self, coll):
286308
[bar["x0"] for bar in trace], [bar["x1"] for bar in trace]
287309
)
288310
if self.x_is_mpl_date:
289-
x = [bar["x0"] for bar in trace]
290-
formatter = (
291-
self.current_mpl_ax.get_xaxis()
292-
.get_major_formatter()
293-
.__class__.__name__
294-
)
295-
x = mpltools.mpl_dates_to_datestrings(x, formatter)
311+
x = self._convert_x_dates([bar["x0"] for bar in trace])
296312
else:
297313
self.msg += " Attempting to draw a horizontal bar chart\n"
298314
old_rights = [bar_props["x1"] for bar_props in trace]
@@ -436,14 +452,7 @@ def draw_marked_line(self, **props):
436452
marker=marker,
437453
)
438454
if self.x_is_mpl_date:
439-
formatter = (
440-
self.current_mpl_ax.get_xaxis()
441-
.get_major_formatter()
442-
.__class__.__name__
443-
)
444-
marked_line["x"] = mpltools.mpl_dates_to_datestrings(
445-
marked_line["x"], formatter
446-
)
455+
marked_line["x"] = self._convert_x_dates(marked_line["x"])
447456
self.plotly_fig.add_trace(marked_line)
448457
self.msg += " Heck yeah, I drew that line\n"
449458
elif props["coordinates"] == "axes":
@@ -513,6 +522,9 @@ def draw_path_collection(self, **props):
513522
}
514523
self.msg += " Drawing path collection as markers\n"
515524
self.draw_marked_line(**scatter_props)
525+
elif props["path_coordinates"] == "data":
526+
self.msg += " Drawing path collection as filled polygons\n"
527+
self._draw_filled_path_collection(props)
516528
else:
517529
self.msg += " Path collection not linked to 'data', not drawing\n"
518530
warnings.warn(
@@ -522,6 +534,42 @@ def draw_path_collection(self, **props):
522534
"collections linked to 'data' coordinates"
523535
)
524536

537+
def _draw_filled_path_collection(self, props):
538+
"""Draw a path collection (e.g. violin plot bodies) as filled polygons."""
539+
facecolors = mpltools.convert_rgba_array(props["styles"]["facecolor"])
540+
edgecolors = mpltools.convert_rgba_array(props["styles"]["edgecolor"])
541+
linewidths = mpltools.convert_linewidth_array(props["styles"]["linewidth"])
542+
543+
def per_path(colors, i, default):
544+
if isinstance(colors, str):
545+
return colors
546+
if colors is None:
547+
return default
548+
try:
549+
n = len(colors)
550+
except TypeError:
551+
return colors
552+
return colors[i % n] if n else default
553+
554+
for i, (verts, codes) in enumerate(props["paths"]):
555+
facecolor = per_path(facecolors, i, "rgba(0,0,0,0)")
556+
edgecolor = per_path(edgecolors, i, "rgba(0,0,0,0)")
557+
linewidth = per_path(linewidths, i, 0)
558+
self.plotly_fig.add_trace(
559+
go.Scatter(
560+
x=self._convert_x_dates([v[0] for v in verts]),
561+
y=[v[1] for v in verts],
562+
mode="lines",
563+
line=go.scatter.Line(
564+
color=_export_color(edgecolor), width=linewidth
565+
),
566+
fill="toself",
567+
fillcolor=_export_color(facecolor),
568+
xaxis="x{0}".format(self.axis_ct),
569+
yaxis="y{0}".format(self.axis_ct),
570+
)
571+
)
572+
525573
def draw_path(self, **props):
526574
"""Draw path, currently only attempts to draw bar charts.
527575

plotly/matplotlylib/tests/test_renderer.py

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
import datetime
2+
3+
import numpy as np
14
import matplotlib.pyplot as plt
25
import plotly.tools as tls
36

@@ -84,3 +87,115 @@ def test_multiple_traces_native_legend():
8487
assert plotly_fig.data[0].mode == "lines"
8588
assert plotly_fig.data[1].mode == "markers"
8689
assert plotly_fig.data[2].mode == "lines+markers"
90+
91+
92+
def test_violinplot_bodies_are_filled_polygons():
93+
fig, ax = plt.subplots()
94+
ax.violinplot(np.random.randn(100, 3))
95+
plotly_fig = tls.mpl_to_plotly(fig)
96+
bodies = [t for t in plotly_fig.data if t.fill == "toself" and len(t.x) > 100]
97+
assert len(bodies) >= 3
98+
99+
100+
def test_pcolor_rectangles_render():
101+
x = np.linspace(-3, 3, 10)
102+
X, Y = np.meshgrid(x, x)
103+
fig, ax = plt.subplots()
104+
ax.pcolor(X, Y, np.sin(X) * np.cos(Y))
105+
plotly_fig = tls.mpl_to_plotly(fig)
106+
assert len(plotly_fig.data) == 100
107+
assert all(len(t.x) >= 4 for t in plotly_fig.data)
108+
109+
110+
def test_eventplot_segments_render():
111+
fig, ax = plt.subplots()
112+
ax.eventplot([np.random.randn(20) for _ in range(5)])
113+
plotly_fig = tls.mpl_to_plotly(fig)
114+
assert len(plotly_fig.data) == 100
115+
116+
117+
def test_stackplot_areas_render():
118+
x = np.arange(10)
119+
fig, ax = plt.subplots()
120+
ax.stackplot(x, np.random.rand(10), np.random.rand(10), np.random.rand(10))
121+
plotly_fig = tls.mpl_to_plotly(fig)
122+
assert len(plotly_fig.data) >= 3
123+
124+
125+
def test_fill_between_renders():
126+
x = np.linspace(0, 2 * np.pi, 50)
127+
fig, ax = plt.subplots()
128+
ax.fill_between(x, np.sin(x), np.cos(x))
129+
plotly_fig = tls.mpl_to_plotly(fig)
130+
assert len(plotly_fig.data) >= 1
131+
132+
133+
def test_collection_alpha():
134+
"""Collection alpha is baked into the facecolor rgba by matplotlib. if
135+
fillcolor has an alpha channel, the opacity field should not be set."""
136+
x = np.linspace(0, 2 * np.pi, 50)
137+
fig, ax = plt.subplots()
138+
ax.fill_between(x, np.sin(x), np.cos(x), color="red", alpha=0.4)
139+
plotly_fig = tls.mpl_to_plotly(fig)
140+
trace = plotly_fig.data[0]
141+
assert trace.fillcolor == "rgba(255,0,0,0.4)"
142+
assert trace.opacity is None
143+
144+
145+
def test_violin_body_default_alpha():
146+
"""Violin bodies default to alpha=0.3 in matplotlib, which is
147+
embedded in their facecolor rgba. If the alpha channel in fillcolor
148+
is set, the opacity field should not be set."""
149+
fig, ax = plt.subplots()
150+
ax.violinplot(np.random.randn(100, 3))
151+
plotly_fig = tls.mpl_to_plotly(fig)
152+
bodies = [
153+
t
154+
for t in plotly_fig.data
155+
if t.fill == "toself" and t.fillcolor == "rgba(31,119,180,0.3)"
156+
]
157+
assert len(bodies) >= 3
158+
assert all(t.opacity is None for t in bodies)
159+
160+
161+
def test_stem_plot_renders():
162+
x = np.linspace(0, 2 * np.pi, 20)
163+
fig, ax = plt.subplots()
164+
ax.stem(x, np.sin(x))
165+
plotly_fig = tls.mpl_to_plotly(fig)
166+
assert len(plotly_fig.data) >= 20
167+
168+
169+
def test_contour_lines_convert():
170+
"""Contour lines used to crash with an ndarray line width."""
171+
x = np.linspace(-3, 3, 30)
172+
X, Y = np.meshgrid(x, x)
173+
fig, ax = plt.subplots()
174+
ax.contour(X, Y, np.sin(X) * np.cos(Y), 10)
175+
plotly_fig = tls.mpl_to_plotly(fig)
176+
assert len(plotly_fig.data) > 0
177+
178+
179+
def test_contourf_bands_render():
180+
"""Contourf bands (multi-subpath collections) must render as fills."""
181+
x = np.linspace(-3, 3, 30)
182+
X, Y = np.meshgrid(x, x)
183+
fig, ax = plt.subplots()
184+
ax.contourf(X, Y, np.sin(X) * np.cos(Y), 10)
185+
plotly_fig = tls.mpl_to_plotly(fig)
186+
filled = [t for t in plotly_fig.data if t.fill == "toself"]
187+
assert len(filled) > 0
188+
189+
190+
def test_filled_path_collection_date_xaxis():
191+
"""Filled path collections with date x-values must export date strings,
192+
not raw matplotlib date numbers."""
193+
dates = [
194+
datetime.datetime(2023, 1, 1) + datetime.timedelta(days=i) for i in range(10)
195+
]
196+
fig, ax = plt.subplots()
197+
ax.fill_between(dates, np.sin(np.arange(10)), np.cos(np.arange(10)))
198+
plotly_fig = tls.mpl_to_plotly(fig)
199+
filled = [t for t in plotly_fig.data if t.fill == "toself"]
200+
assert len(filled) >= 1
201+
assert all(isinstance(x, str) for x in filled[0].x)

0 commit comments

Comments
 (0)