Skip to content

Commit cb82f63

Browse files
authored
Merge pull request #5698 from robertoffmoura/rm/fix-stairs-plot
Convert matplotlib stairs plots to plotly step lines
2 parents bbc24ec + f547f89 commit cb82f63

3 files changed

Lines changed: 159 additions & 3 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ This project adheres to [Semantic Versioning](http://semver.org/).
1010
- 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!
1111
- 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!
1212
- 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!
13+
- 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!
1314
- 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!
1415
- 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!
1516
- 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!

‎plotly/matplotlylib/renderer.py‎

Lines changed: 63 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
import warnings
1111

12+
import matplotlib.patches as mpatches
1213
import plotly.graph_objs as go
1314
from plotly.matplotlylib.mplexporter import Renderer
1415
from plotly.matplotlylib import mpltools
@@ -753,11 +754,12 @@ def _draw_filled_path_collection(self, props):
753754
)
754755

755756
def draw_path(self, **props):
756-
"""Draw path, currently only attempts to draw bar charts.
757+
"""Draw a bar chart path or a matplotlib step patch.
757758
758759
This function attempts to sort a given path into a collection of
759-
horizontal or vertical bar charts. Most of the actual code takes
760-
place in functions from mpltools.py.
760+
horizontal or vertical bar charts, and draws matplotlib StepPatch
761+
artists as step traces. Most of the actual code takes place in
762+
functions from mpltools.py.
761763
762764
props.keys() -- [
763765
'data', (a list of vertices for the path)
@@ -781,13 +783,71 @@ def draw_path(self, **props):
781783
is_bar = mpltools.is_bar(self.current_mpl_ax.containers, **props)
782784
if is_bar:
783785
self.current_bars += [props]
786+
elif isinstance(props["mplobj"], mpatches.StepPatch):
787+
self.msg += " Drawing a step path\n"
788+
self._draw_step_path(props)
784789
else:
785790
self.msg += " This path isn't a bar, not drawing\n"
786791
warnings.warn(
787792
"I found a path object that I don't think is part "
788793
"of a bar chart. Ignoring."
789794
)
790795

796+
def _draw_step_path(self, props):
797+
"""Draw a matplotlib StepPatch as a step trace."""
798+
if props["coordinates"] != "data":
799+
self.msg += " Step path is not in data coordinates, not drawing\n"
800+
return
801+
style = props["style"]
802+
segments = []
803+
segment_x = []
804+
segment_y = []
805+
for (x0, y0), code in zip(props["data"], props["pathcodes"]):
806+
if code == "M" and segment_x:
807+
segments.append((segment_x, segment_y))
808+
segment_x = []
809+
segment_y = []
810+
if not segment_x or x0 != segment_x[-1] or y0 != segment_y[-1]:
811+
segment_x.append(x0)
812+
segment_y.append(y0)
813+
if segment_x:
814+
segments.append((segment_x, segment_y))
815+
x = []
816+
y = []
817+
for sub_x, sub_y in segments:
818+
if x:
819+
x.append(None)
820+
y.append(None)
821+
x.extend(self._convert_x_dates(sub_x))
822+
y.extend(sub_y)
823+
if len(x) < 2:
824+
self.msg += " Step path has fewer than 2 points, not drawing\n"
825+
return
826+
label = props["mplobj"].get_label()
827+
if not label or (isinstance(label, str) and label.startswith("_")):
828+
label = None
829+
showlegend = False
830+
else:
831+
showlegend = None
832+
self.plotly_fig.add_trace(
833+
go.Scatter(
834+
x=x,
835+
y=y,
836+
mode="lines",
837+
name=label,
838+
showlegend=showlegend,
839+
line=go.scatter.Line(
840+
color=_export_color(style["edgecolor"], style["alpha"]),
841+
width=style["edgewidth"],
842+
dash=mpltools.convert_dash(style["dasharray"]),
843+
),
844+
fill="toself" if style["facecolor"] != "none" else None,
845+
fillcolor=_export_color(style["facecolor"]),
846+
xaxis="x{0}".format(self.axis_ct),
847+
yaxis="y{0}".format(self.axis_ct),
848+
)
849+
)
850+
791851
def draw_text(self, **props):
792852
"""Create an annotation dict for a text obj.
793853

‎plotly/matplotlylib/tests/test_renderer.py‎

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -592,6 +592,101 @@ def test_background_colors_from_matplotlib_defaults():
592592
assert plotly_fig.layout.paper_bgcolor == "#FFFFFF"
593593

594594

595+
def test_stairs_converts_to_step_line():
596+
fig, ax = plt.subplots()
597+
ax.stairs([0.0, 1.0, 0.0], [0.0, 1.0, 2.0, 3.0])
598+
plotly_fig = tls.mpl_to_plotly(fig)
599+
assert len(plotly_fig.data) == 1
600+
trace = plotly_fig.data[0]
601+
assert trace.mode == "lines"
602+
assert tuple(trace.x) == (0.0, 1.0, 1.0, 2.0, 2.0, 3.0)
603+
assert tuple(trace.y) == (0.0, 0.0, 1.0, 1.0, 0.0, 0.0)
604+
605+
606+
def test_stairs_date_xaxis():
607+
"""Stairs with date x-values must export date strings."""
608+
dates = [
609+
datetime.datetime(2023, 1, 1) + datetime.timedelta(days=i) for i in range(4)
610+
]
611+
fig, ax = plt.subplots()
612+
ax.stairs([0.0, 1.0, 0.0], dates)
613+
614+
plotly_fig = tls.mpl_to_plotly(fig)
615+
616+
assert plotly_fig.layout.xaxis.type == "date"
617+
trace = plotly_fig.data[0]
618+
assert all(isinstance(x, str) for x in trace.x)
619+
620+
621+
def test_stairs_fill_converts_to_filled_area():
622+
"""Filled stairs must export the patch facecolor as a filled area."""
623+
fig, ax = plt.subplots()
624+
ax.stairs([0.0, 1.0, 0.0], [0.0, 1.0, 2.0, 3.0], fill=True)
625+
626+
plotly_fig = tls.mpl_to_plotly(fig)
627+
628+
trace = plotly_fig.data[0]
629+
assert trace.fill == "toself"
630+
assert trace.fillcolor == "#1F77B4"
631+
assert tuple(trace.x) == (0.0, 1.0, 1.0, 2.0, 2.0, 3.0)
632+
assert tuple(trace.y) == (0.0, 0.0, 1.0, 1.0, 0.0, 0.0)
633+
634+
635+
def test_stairs_nan_values_split_into_disjoint_steps():
636+
"""NaN values split stairs into separate step regions."""
637+
fig, ax = plt.subplots()
638+
ax.stairs([1.0, np.nan, 0.5], [0.0, 1.0, 2.0, 3.0], baseline=0.2)
639+
640+
plotly_fig = tls.mpl_to_plotly(fig)
641+
642+
trace = plotly_fig.data[0]
643+
assert tuple(trace.x) == (0.0, 0.0, 1.0, 1.0, None, 2.0, 2.0, 3.0, 3.0)
644+
assert tuple(trace.y) == (0.2, 1.0, 1.0, 0.2, None, 0.2, 0.5, 0.5, 0.2)
645+
646+
647+
def test_stairs_date_xaxis_with_nan_values():
648+
"""NaN-separated stairs on a date axis must export date strings."""
649+
dates = [
650+
datetime.datetime(2023, 1, 1) + datetime.timedelta(days=i) for i in range(4)
651+
]
652+
fig, ax = plt.subplots()
653+
ax.stairs([1.0, np.nan, 0.5], dates)
654+
655+
plotly_fig = tls.mpl_to_plotly(fig)
656+
657+
trace = plotly_fig.data[0]
658+
assert None in trace.x
659+
assert all(isinstance(x, str) or x is None for x in trace.x)
660+
661+
662+
def test_stairs_label_used_in_legend():
663+
"""A labeled stairs patch must become a named legend entry."""
664+
fig, ax = plt.subplots()
665+
ax.stairs([0.0, 1.0, 0.0], [0.0, 1.0, 2.0, 3.0], label="my stairs")
666+
ax.legend()
667+
668+
plotly_fig = tls.mpl_to_plotly(fig)
669+
670+
assert plotly_fig.layout.showlegend is True
671+
assert plotly_fig.data[0].name == "my stairs"
672+
assert plotly_fig.data[0].showlegend is not False
673+
674+
675+
def test_stairs_without_label_hidden_from_legend():
676+
"""An unlabeled stairs patch must not appear in the plotly legend."""
677+
fig, ax = plt.subplots()
678+
ax.stairs([0.0, 1.0, 0.0], [0.0, 1.0, 2.0, 3.0])
679+
ax.plot([0, 1], [2, 2], label="Labeled line")
680+
ax.legend()
681+
682+
plotly_fig = tls.mpl_to_plotly(fig)
683+
684+
assert plotly_fig.layout.showlegend is True
685+
stairs = [trace for trace in plotly_fig.data if trace.name is None]
686+
assert len(stairs) == 1
687+
assert stairs[0].showlegend is False
688+
689+
595690
def test_custom_background_colors_are_preserved():
596691
fig, ax = plt.subplots()
597692
fig.patch.set_facecolor("lightyellow")

0 commit comments

Comments
 (0)