diff --git a/CHANGELOG.md b/CHANGELOG.md index cae0a8a502..160476ebda 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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! diff --git a/plotly/matplotlylib/renderer.py b/plotly/matplotlylib/renderer.py index bb62dc36b4..15f4f024a1 100644 --- a/plotly/matplotlylib/renderer.py +++ b/plotly/matplotlylib/renderer.py @@ -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 @@ -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) @@ -777,6 +779,9 @@ 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( @@ -784,6 +789,61 @@ def draw_path(self, **props): "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. diff --git a/plotly/matplotlylib/tests/test_renderer.py b/plotly/matplotlylib/tests/test_renderer.py index 0b81f1e184..ef985314b5 100644 --- a/plotly/matplotlylib/tests/test_renderer.py +++ b/plotly/matplotlylib/tests/test_renderer.py @@ -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")