Skip to content
Open
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ This project adheres to [Semantic Versioning](http://semver.org/).

### Added
- Add support for converting matplotlib reference lines (`axhline`, `axvline`, and horizontal/vertical `axline`) as well as axes-coordinate lines into Plotly layout shapes in `mpl_to_plotly`, including support for date axes [[#5719](https://github.com/plotly/plotly.py/pull/5719)], with thanks to @robertoffmoura for the contribution!
- Add support for converting matplotlib fill polygons (`plt.fill`) to filled scatter traces in `mpl_to_plotly` [[#5795](https://github.com/plotly/plotly.py/pull/5795)], with thanks to @robertoffmoura for the contribution!

### Fixed
- Fix `mpl_to_plotly` tick marker mirroring, axis positioning, and trace visibility for twinned and overlaid subplots (such as `twinx` and `twiny`) [[#5310](https://github.com/plotly/plotly.py/pull/5310)], with thanks to @robertoffmoura for the contribution!
Expand Down
1 change: 1 addition & 0 deletions plotly/matplotlylib/mpltools.py
Original file line number Diff line number Diff line change
Expand Up @@ -329,6 +329,7 @@ def get_markerstyle_from_collection(props):
# markersize=convert_size_array(props['styles']['size']), # TODO!
markersize=convert_size_array(props["mplobj"].get_sizes()),
edgecolor=convert_rgba_array(props["styles"]["edgecolor"]),
zorder=props["styles"]["zorder"],
)
return markerstyle

Expand Down
138 changes: 102 additions & 36 deletions plotly/matplotlylib/renderer.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,20 @@ def _per_path(values, i, default):
return values[i % n] if n else default


def _legend_label(label):
"""Return the (name, showlegend) pair for a trace from an artist label.

matplotlib marks artists it leaves out of legends with "_nolegend_" and
auto-generated "_childN" labels. Empty labels and labels starting with
"_" give (None, False), which hides the trace from the plotly legend.
Other labels give (label, None), which keeps plotly's default legend
entry.
"""
if not label or (isinstance(label, str) and label.startswith("_")):
return None, False
return label, None


def _convert_collection_dash(linestyle):
"""Convert a matplotlib collection line style to a plotly dash string.

Expand Down Expand Up @@ -97,6 +111,29 @@ def __init__(self):
self._processing_legend = False
self._legend_visible = False
self.axes_list = []
self._trace_zorders = []

def _add_trace(self, trace, zorder):
"""Add a trace to the figure, recording its matplotlib zorder."""
self.plotly_fig.add_trace(trace)
self._trace_zorders.append(zorder)

def _sort_traces_by_zorder(self):
"""Order the current axes' traces by matplotlib zorder.

plotly draws later traces on top and matplotlib draws artists in
ascending zorder, so the traces are stably sorted by zorder. Traces
with equal zorder keep the order they were drawn in.
"""
n = len(self._trace_zorders)
start = len(self.plotly_fig.data) - n
order = sorted(range(n), key=self._trace_zorders.__getitem__)
if order != list(range(n)):
axes_traces = self.plotly_fig.data[start:]
self.plotly_fig.data = self.plotly_fig.data[:start] + tuple(
axes_traces[i] for i in order
)
self._trace_zorders = []

def _convert_x_dates(self, x):
"""Convert x values to date strings when the x-axis is a date axis."""
Expand Down Expand Up @@ -304,6 +341,7 @@ def close_axes(self, ax):

"""
self.draw_bars(self.current_bars)
self._sort_traces_by_zorder()
self.msg += " Closing axes\n"
self.x_is_mpl_date = False

Expand Down Expand Up @@ -433,7 +471,7 @@ def draw_bar(self, coll):
) # TODO ditto
if len(bar["x"]) > 1:
self.msg += " Heck yeah, I drew that bar chart\n"
self.plotly_fig.add_trace(bar)
self._add_trace(bar, trace[0]["zorder"])
if bar_gap is not None:
self.plotly_fig["layout"]["bargap"] = bar_gap
else:
Expand Down Expand Up @@ -532,14 +570,7 @@ def draw_marked_line(self, **props):
),
)
if props["coordinates"] == "data":
label = props["label"]
# matplotlib uses "_nolegend_" and auto-generated "_childN"
# labels for artists that must not appear in a legend
if not label or (isinstance(label, str) and label.startswith("_")):
label = None
showlegend = False
else:
showlegend = None
label, showlegend = _legend_label(props["label"])
marked_line = go.Scatter(
mode=mode,
name=label,
Expand All @@ -553,7 +584,8 @@ def draw_marked_line(self, **props):
)
if self.x_is_mpl_date:
marked_line["x"] = self._convert_x_dates(marked_line["x"])
self.plotly_fig.add_trace(marked_line)
style = props["linestyle"] or props["markerstyle"]
self._add_trace(marked_line, style["zorder"])
self.msg += " Heck yeah, I drew that line\n"
elif props["coordinates"] == "axes" and self._processing_legend:
# dealing with legend graphical elements
Expand Down Expand Up @@ -771,7 +803,7 @@ def _draw_line_collection(self, props):
def flush():
if current_style is not None and grouped_x:
ec, lw, d = current_style
self.plotly_fig.add_trace(
self._add_trace(
go.Scatter(
x=grouped_x,
y=grouped_y,
Expand All @@ -784,7 +816,8 @@ def flush():
),
xaxis="x{0}".format(self.axis_ct),
yaxis="y{0}".format(self.axis_ct),
)
),
props["styles"]["zorder"],
)

for i, (verts, codes) in enumerate(props["paths"]):
Expand Down Expand Up @@ -850,29 +883,46 @@ def flush():

flush()

def _add_filled_trace(
self,
verts,
facecolor,
edgecolor,
linewidth,
zorder,
name=None,
showlegend=None,
):
"""Add a filled scatter trace outlining `verts` on the current axes."""
self._add_trace(
go.Scatter(
name=name,
showlegend=showlegend,
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),
),
zorder,
)

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"])

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),
)
self._add_filled_trace(
verts,
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),
zorder=props["styles"]["zorder"],
)

def draw_path(self, **props):
Expand Down Expand Up @@ -908,6 +958,12 @@ def draw_path(self, **props):
elif isinstance(props["mplobj"], mpatches.StepPatch):
self.msg += " Drawing a step path\n"
self._draw_step_path(props)
elif (
isinstance(props["mplobj"], mpatches.Polygon)
and props["coordinates"] == "data"
):
self.msg += " Drawing a filled polygon\n"
self._draw_filled_polygon(props)
else:
self.msg += " This path isn't a bar, not drawing\n"
warnings.warn(
Expand Down Expand Up @@ -945,13 +1001,8 @@ def _draw_step_path(self, props):
if len(x) < 2:
self.msg += " Step path has fewer than 2 points, not drawing\n"
return
label = props["mplobj"].get_label()
if not label or (isinstance(label, str) and label.startswith("_")):
label = None
showlegend = False
else:
showlegend = None
self.plotly_fig.add_trace(
label, showlegend = _legend_label(props["mplobj"].get_label())
self._add_trace(
go.Scatter(
x=x,
y=y,
Expand All @@ -967,7 +1018,22 @@ def _draw_step_path(self, props):
fillcolor=_export_color(style["facecolor"]),
xaxis="x{0}".format(self.axis_ct),
yaxis="y{0}".format(self.axis_ct),
)
),
style["zorder"],
)

def _draw_filled_polygon(self, props):
"""Draw a matplotlib Polygon patch as a filled scatter trace."""
style = props["style"]
label, showlegend = _legend_label(props["mplobj"].get_label())
self._add_filled_trace(
props["data"],
facecolor=style["facecolor"],
edgecolor=style["edgecolor"],
linewidth=style["edgewidth"],
zorder=style["zorder"],
name=label,
showlegend=showlegend,
)

def draw_text(self, **props):
Expand Down
65 changes: 65 additions & 0 deletions plotly/matplotlylib/tests/test_renderer.py
Original file line number Diff line number Diff line change
Expand Up @@ -1150,6 +1150,71 @@ def test_uneven_custom_date_xtickvals_are_converted():
)


def test_fill_converts():
"""plt.fill polygons convert to filled scatter traces."""
x = np.linspace(0, 2 * np.pi, 50)
fig, ax = plt.subplots()
ax.fill(x, np.sin(x), "g")

plotly_fig = tls.mpl_to_plotly(fig)

assert len(plotly_fig.data) == 1
trace = plotly_fig.data[0]
assert trace.type == "scatter"
assert trace.fill == "toself"
assert np.allclose(trace.x, x)
assert np.allclose(trace.y, np.sin(x))
assert trace.fillcolor == "#007F00"
assert trace.line.color == "rgba(0,0,0,0)"


def test_fill_with_legend_label():
"""plt.fill polygons with labels export their name and show in legend."""
fig, ax = plt.subplots()
ax.fill([0, 1, 2], [0, 2, 0], "b", label="Shaded Area")
ax.fill([0, 1, 2], [0, 1, 0], "g")
ax.plot([0, 1, 2], [1, 1, 1], "r-", label="Threshold")
ax.legend()

plotly_fig = tls.mpl_to_plotly(fig)

assert plotly_fig.layout.showlegend is True
assert len(plotly_fig.data) == 3

labeled_fill = next(
t for t in plotly_fig.data if t.fill == "toself" and t.name == "Shaded Area"
)
assert labeled_fill.showlegend is not False

unlabeled_fill = next(
t for t in plotly_fig.data if t.fill == "toself" and t.name is None
)
assert unlabeled_fill.showlegend is False


def test_fill_stacks_below_line_by_zorder():
"""A fill (zorder 1) exports before a line (zorder 2), so plotly draws
the line on top as matplotlib does."""
fig, ax = plt.subplots()
ax.fill([0, 1, 1], [0, 0, 1], "b")
ax.plot([0, 1], [0.5, 0.5], "r")

plotly_fig = tls.mpl_to_plotly(fig)

assert [t.fill for t in plotly_fig.data] == ["toself", None]


def test_fill_with_higher_zorder_stacks_above_line():
"""A fill given a higher zorder than a line exports after the line."""
fig, ax = plt.subplots()
ax.fill([0, 1, 1], [0, 0, 1], "b", zorder=3)
ax.plot([0, 1], [0.5, 0.5], "r")

plotly_fig = tls.mpl_to_plotly(fig)

assert [t.fill for t in plotly_fig.data] == [None, "toself"]


def test_custom_date_xtickvals_given_as_numbers_are_converted():
"""Custom date ticks given as matplotlib date numbers must be converted
to date strings."""
Expand Down