Skip to content
Merged
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ This project adheres to [Semantic Versioning](http://semver.org/).
- Fix concurrent first access to lazily initialized graph object properties, which could raise `ValueError("Invalid value")` [[#5691](https://github.com/plotly/plotly.py/pull/5691)], with thanks to @hb1915 for the contribution!
- Fix `mpl_to_plotly` crashing on touching bars (such as `plt.hist`) due to floating-point noise producing negative `bargap` values by clamping `bargap` to `[0, 1]` [[#5696](https://github.com/plotly/plotly.py/pull/5696)], with thanks to @robertoffmoura for the contribution!
- Fix `mpl_to_plotly` drawing step plots with diagonal lines instead of vertical/horizontal step segments by mapping matplotlib step drawstyles (`steps-pre`, `steps-post`, `steps-mid`) to plotly line shapes [[#5697](https://github.com/plotly/plotly.py/pull/5697)], with thanks to @robertoffmoura for the contribution!
- Fix `mpl_to_plotly` dropping matplotlib stairs plots by rendering `StepPatch` artists as step lines or filled step areas from their path vertices and edge style [[#5698](https://github.com/plotly/plotly.py/pull/5698)], with thanks to @robertoffmoura for the contribution!
- Fix `mpl_to_plotly` showing unwanted legends with placeholder or internal label entries (`_nolegend_`, `_childN`) by defaulting `layout.showlegend=False` unless the matplotlib figure explicitly defines a legend [[#5699](https://github.com/plotly/plotly.py/pull/5699)], with thanks to @robertoffmoura for the contribution!
- Fix `mpl_to_plotly` crashing on figures with fully transparent or alpha-carrying colors, such as boxplot outlier markers (`facecolor="none"`), rgba line colors, and `color="none"` text and axis labels; all matplotlib colors are now exported through a single helper that maps `"none"` to transparent rgba and keeps or overrides alpha as matplotlib does [[#5700](https://github.com/plotly/plotly.py/pull/5700)], with thanks to @robertoffmoura for the contribution!
- Export tick label colors to the plotly tickfont in `mpl_to_plotly` [[#5716](https://github.com/plotly/plotly.py/pull/5716)], with thanks to @robertoffmoura for the contribution!
Expand Down
66 changes: 63 additions & 3 deletions plotly/matplotlylib/renderer.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

import warnings

import matplotlib.patches as mpatches
import plotly.graph_objs as go
from plotly.matplotlylib.mplexporter import Renderer
from plotly.matplotlylib import mpltools
Expand Down Expand Up @@ -749,11 +750,12 @@ def _draw_filled_path_collection(self, props):
)

def draw_path(self, **props):
"""Draw path, currently only attempts to draw bar charts.
"""Draw a bar chart path or a matplotlib step patch.

This function attempts to sort a given path into a collection of
horizontal or vertical bar charts. Most of the actual code takes
place in functions from mpltools.py.
horizontal or vertical bar charts, and draws matplotlib StepPatch
artists as step traces. Most of the actual code takes place in
functions from mpltools.py.

props.keys() -- [
'data', (a list of vertices for the path)
Expand All @@ -777,13 +779,71 @@ def draw_path(self, **props):
is_bar = mpltools.is_bar(self.current_mpl_ax.containers, **props)
if is_bar:
self.current_bars += [props]
elif isinstance(props["mplobj"], mpatches.StepPatch):
self.msg += " Drawing a step path\n"
self._draw_step_path(props)
else:
self.msg += " This path isn't a bar, not drawing\n"
warnings.warn(
"I found a path object that I don't think is part "
"of a bar chart. Ignoring."
)

def _draw_step_path(self, props):
"""Draw a matplotlib StepPatch as a step trace."""
if props["coordinates"] != "data":
self.msg += " Step path is not in data coordinates, not drawing\n"
return
style = props["style"]
segments = []
segment_x = []
segment_y = []
for (x0, y0), code in zip(props["data"], props["pathcodes"]):
if code == "M" and segment_x:
segments.append((segment_x, segment_y))
segment_x = []
segment_y = []
if not segment_x or x0 != segment_x[-1] or y0 != segment_y[-1]:
segment_x.append(x0)
segment_y.append(y0)
if segment_x:
segments.append((segment_x, segment_y))
x = []
y = []
for sub_x, sub_y in segments:
if x:
x.append(None)
y.append(None)
x.extend(self._convert_x_dates(sub_x))
y.extend(sub_y)
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(
go.Scatter(
x=x,
y=y,
mode="lines",
name=label,
showlegend=showlegend,
line=go.scatter.Line(
color=_export_color(style["edgecolor"], style["alpha"]),
width=style["edgewidth"],
dash=mpltools.convert_dash(style["dasharray"]),
),
fill="toself" if style["facecolor"] != "none" else None,
fillcolor=_export_color(style["facecolor"]),
xaxis="x{0}".format(self.axis_ct),
yaxis="y{0}".format(self.axis_ct),
)
)

def draw_text(self, **props):
"""Create an annotation dict for a text obj.

Expand Down
95 changes: 95 additions & 0 deletions plotly/matplotlylib/tests/test_renderer.py
Original file line number Diff line number Diff line change
Expand Up @@ -542,6 +542,101 @@ def test_background_colors_from_matplotlib_defaults():
assert plotly_fig.layout.paper_bgcolor == "#FFFFFF"


def test_stairs_converts_to_step_line():
fig, ax = plt.subplots()
ax.stairs([0.0, 1.0, 0.0], [0.0, 1.0, 2.0, 3.0])
plotly_fig = tls.mpl_to_plotly(fig)
assert len(plotly_fig.data) == 1
trace = plotly_fig.data[0]
assert trace.mode == "lines"
assert tuple(trace.x) == (0.0, 1.0, 1.0, 2.0, 2.0, 3.0)
assert tuple(trace.y) == (0.0, 0.0, 1.0, 1.0, 0.0, 0.0)


def test_stairs_date_xaxis():
"""Stairs with date x-values must export date strings."""
dates = [
datetime.datetime(2023, 1, 1) + datetime.timedelta(days=i) for i in range(4)
]
fig, ax = plt.subplots()
ax.stairs([0.0, 1.0, 0.0], dates)

plotly_fig = tls.mpl_to_plotly(fig)

assert plotly_fig.layout.xaxis.type == "date"
trace = plotly_fig.data[0]
assert all(isinstance(x, str) for x in trace.x)


def test_stairs_fill_converts_to_filled_area():
"""Filled stairs must export the patch facecolor as a filled area."""
fig, ax = plt.subplots()
ax.stairs([0.0, 1.0, 0.0], [0.0, 1.0, 2.0, 3.0], fill=True)

plotly_fig = tls.mpl_to_plotly(fig)

trace = plotly_fig.data[0]
assert trace.fill == "toself"
assert trace.fillcolor == "#1F77B4"
assert tuple(trace.x) == (0.0, 1.0, 1.0, 2.0, 2.0, 3.0)
assert tuple(trace.y) == (0.0, 0.0, 1.0, 1.0, 0.0, 0.0)


def test_stairs_nan_values_split_into_disjoint_steps():
"""NaN values split stairs into separate step regions."""
fig, ax = plt.subplots()
ax.stairs([1.0, np.nan, 0.5], [0.0, 1.0, 2.0, 3.0], baseline=0.2)

plotly_fig = tls.mpl_to_plotly(fig)

trace = plotly_fig.data[0]
assert tuple(trace.x) == (0.0, 0.0, 1.0, 1.0, None, 2.0, 2.0, 3.0, 3.0)
assert tuple(trace.y) == (0.2, 1.0, 1.0, 0.2, None, 0.2, 0.5, 0.5, 0.2)


def test_stairs_date_xaxis_with_nan_values():
"""NaN-separated stairs on a date axis must export date strings."""
dates = [
datetime.datetime(2023, 1, 1) + datetime.timedelta(days=i) for i in range(4)
]
fig, ax = plt.subplots()
ax.stairs([1.0, np.nan, 0.5], dates)

plotly_fig = tls.mpl_to_plotly(fig)

trace = plotly_fig.data[0]
assert None in trace.x
assert all(isinstance(x, str) or x is None for x in trace.x)


def test_stairs_label_used_in_legend():
"""A labeled stairs patch must become a named legend entry."""
fig, ax = plt.subplots()
ax.stairs([0.0, 1.0, 0.0], [0.0, 1.0, 2.0, 3.0], label="my stairs")
ax.legend()

plotly_fig = tls.mpl_to_plotly(fig)

assert plotly_fig.layout.showlegend is True
assert plotly_fig.data[0].name == "my stairs"
assert plotly_fig.data[0].showlegend is not False


def test_stairs_without_label_hidden_from_legend():
"""An unlabeled stairs patch must not appear in the plotly legend."""
fig, ax = plt.subplots()
ax.stairs([0.0, 1.0, 0.0], [0.0, 1.0, 2.0, 3.0])
ax.plot([0, 1], [2, 2], label="Labeled line")
ax.legend()

plotly_fig = tls.mpl_to_plotly(fig)

assert plotly_fig.layout.showlegend is True
stairs = [trace for trace in plotly_fig.data if trace.name is None]
assert len(stairs) == 1
assert stairs[0].showlegend is False


def test_custom_background_colors_are_preserved():
fig, ax = plt.subplots()
fig.patch.set_facecolor("lightyellow")
Expand Down
Loading