Skip to content
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<!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!
- 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
Expand Down
78 changes: 63 additions & 15 deletions plotly/matplotlylib/renderer.py
Comment thread
robertoffmoura marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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":
Expand Down Expand Up @@ -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(
Expand All @@ -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.

Expand Down
115 changes: 115 additions & 0 deletions plotly/matplotlylib/tests/test_renderer.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import datetime

import numpy as np
import matplotlib.pyplot as plt
import plotly.tools as tls

Expand Down Expand Up @@ -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)
Loading