diff --git a/CHANGELOG.md b/CHANGELOG.md index cae0a8a502..cc67531e89 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,9 @@ This project adheres to [Semantic Versioning](http://semver.org/). ## Unreleased +### Added +- Add support for converting matplotlib polar plots to plotly polar subplots in `mpl_to_plotly`, including theta direction and rotation and background, grid, and frame colors [[#5771](https://github.com/plotly/plotly.py/pull/5771)], 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! - 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! diff --git a/plotly/matplotlylib/mplexporter/utils.py b/plotly/matplotlylib/mplexporter/utils.py index 84bf950ad8..5746dadfcb 100644 --- a/plotly/matplotlylib/mplexporter/utils.py +++ b/plotly/matplotlylib/mplexporter/utils.py @@ -278,13 +278,14 @@ def get_axis_properties(axis): def get_grid_style(axis): gridlines = axis.get_gridlines() - if axis.get_tick_params()["gridOn"] and len(gridlines) > 0: - color = export_color(gridlines[0].get_color()) - alpha = gridlines[0].get_alpha() - dasharray = get_dasharray(gridlines[0]) - return dict(gridOn=True, color=color, dasharray=dasharray, alpha=alpha) - else: + if len(gridlines) == 0: return {"gridOn": False} + return dict( + gridOn=axis.get_tick_params().get("gridOn", False), + color=export_color(gridlines[0].get_color()), + dasharray=get_dasharray(gridlines[0]), + alpha=gridlines[0].get_alpha(), + ) def get_figure_properties(fig): @@ -308,6 +309,17 @@ def get_axes_properties(ax): "axes": [get_axis_properties(ax.xaxis), get_axis_properties(ax.yaxis)], } + if getattr(ax, "name", None) == "polar": + frame = ax.spines.get("polar") + if frame is not None: + props["polar_frame"] = { + "color": export_color(frame.get_edgecolor()), + "linewidth": frame.get_linewidth(), + "visible": frame.get_visible(), + } + else: + props["polar_frame"] = None + for axname in ["x", "y"]: axis = getattr(ax, axname + "axis") domain = getattr(ax, "get_{0}lim".format(axname))() diff --git a/plotly/matplotlylib/renderer.py b/plotly/matplotlylib/renderer.py index bb62dc36b4..64004418f8 100644 --- a/plotly/matplotlylib/renderer.py +++ b/plotly/matplotlylib/renderer.py @@ -9,6 +9,7 @@ import warnings +import numpy as np import plotly.graph_objs as go from plotly.matplotlylib.mplexporter import Renderer from plotly.matplotlylib import mpltools @@ -85,6 +86,9 @@ def __init__(self): self.current_bars = [] self.axis_ct = 0 self.x_is_mpl_date = False + self.current_is_polar = False + self.polar_ct = 0 + self.current_polar_subplot = None self.mpl_x_bounds = (0, 1) self.mpl_y_bounds = (0, 1) self.msg = "Initialized PlotlyRenderer\n" @@ -101,6 +105,51 @@ def _convert_x_dates(self, x): x = mpltools.mpl_dates_to_datestrings(x, formatter) return x + def _open_polar_axes(self, ax, props): + """Create a plotly polar layout object for a matplotlib polar axes. + + matplotlib polar data coordinates are (theta, r) with theta in + radians measured from the positive x-axis (east). Plotly angular + values are in degrees, with the rotation property setting the + position of angular value 0 and direction setting the direction + of positive angles, so both are taken from the matplotlib axes + to map the two coordinate systems onto each other. Background, + grid, and frame colors are taken from the matplotlib axes like + they are for cartesian axes. + """ + self.polar_ct += 1 + self.current_polar_subplot = ( + "polar{0}".format(self.polar_ct) if self.polar_ct > 1 else "polar" + ) + theta_offset = ax.get_theta_offset() + theta_direction = ax.get_theta_direction() + axes = props.get("axes", []) + angular_grid = axes[0].get("grid", {}) if len(axes) > 0 else {} + radial_grid = axes[1].get("grid", {}) if len(axes) > 1 else {} + frame = props.get("polar_frame") + self.plotly_fig["layout"][self.current_polar_subplot] = go.layout.Polar( + bgcolor=_export_color(props["axesbg"]), + angularaxis=dict( + rotation=float(np.degrees(theta_offset)), + direction=("counterclockwise" if theta_direction >= 0 else "clockwise"), + tickvals=[float(t) for t in np.degrees(ax.xaxis.get_majorticklocs())], + ticktext=[t.get_text() for t in ax.xaxis.get_majorticklabels()], + showgrid=angular_grid.get("gridOn", True), + gridcolor=_export_color(angular_grid.get("color", "#B0B0B0")), + showline=frame["visible"] if frame else True, + linecolor=(_export_color(frame["color"]) if frame else "black"), + linewidth=frame["linewidth"] if frame else 1, + ), + radialaxis=dict( + range=[float(v) for v in ax.get_ylim()], + tickvals=[float(t) for t in ax.yaxis.get_majorticklocs()], + ticktext=[t.get_text() for t in ax.yaxis.get_majorticklabels()], + showgrid=radial_grid.get("gridOn", True), + gridcolor=_export_color(radial_grid.get("color", "#B0B0B0")), + showline=False, + ), + ) + def open_figure(self, fig, props): """Creates a new figure by beginning to fill out layout dict. @@ -194,6 +243,11 @@ def open_axes(self, ax, props): if c.__class__.__name__ == "BarContainer" ] self.current_bars = [] + self.current_is_polar = getattr(ax, "name", None) == "polar" + if self.current_is_polar: + self.msg += " Opening polar axes\n" + self._open_polar_axes(ax, props) + return self.axis_ct += 1 if props.get("patch_visible", True): self.plotly_fig["layout"].plot_bgcolor = _export_color(props["axesbg"]) @@ -296,6 +350,7 @@ def close_axes(self, ax): self.draw_bars(self.current_bars) self.msg += " Closing axes\n" self.x_is_mpl_date = False + self.current_is_polar = False def open_legend(self, legend, props): """Enable Plotly's native legend when matplotlib legend is detected. @@ -530,6 +585,41 @@ def draw_marked_line(self, **props): showlegend = False else: showlegend = None + if self.current_is_polar: + self.plotly_fig.add_trace( + go.Scatterpolar( + mode=mode, + name=label, + theta=np.degrees([xy_pair[0] for xy_pair in props["data"]]), + r=[xy_pair[1] for xy_pair in props["data"]], + subplot=self.current_polar_subplot, + line=( + go.scatterpolar.Line( + color=line.color, + width=line.width, + dash=line.dash, + ) + if props["linestyle"] + else None + ), + marker=( + go.scatterpolar.Marker( + opacity=marker.opacity, + color=marker.color, + symbol=marker.symbol, + size=marker.size, + line=dict( + color=marker.line.color, + width=marker.line.width, + ), + ) + if props["markerstyle"] + else None + ), + ) + ) + self.msg += " Heck yeah, I drew that line on polar axes\n" + return marked_line = go.Scatter( mode=mode, name=label, @@ -733,6 +823,21 @@ def _draw_filled_path_collection(self, props): 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) + if self.current_is_polar: + self.plotly_fig.add_trace( + go.Scatterpolar( + theta=np.degrees([v[0] for v in verts]), + r=[v[1] for v in verts], + mode="lines", + line=go.scatterpolar.Line( + color=_export_color(edgecolor), width=linewidth + ), + fill="toself", + fillcolor=_export_color(facecolor), + subplot=self.current_polar_subplot, + ) + ) + continue self.plotly_fig.add_trace( go.Scatter( x=self._convert_x_dates([v[0] for v in verts]), diff --git a/plotly/matplotlylib/tests/test_renderer.py b/plotly/matplotlylib/tests/test_renderer.py index 0b81f1e184..62d8aea885 100644 --- a/plotly/matplotlylib/tests/test_renderer.py +++ b/plotly/matplotlylib/tests/test_renderer.py @@ -707,6 +707,55 @@ def test_uneven_custom_date_xtickvals_are_converted(): ) +def test_polar_plot_converts(): + """Polar plots convert to scatterpolar traces on a plotly polar layout, + with theta converted from radians to degrees.""" + t = np.linspace(0, 2 * np.pi, 200) + fig, ax = plt.subplots(subplot_kw={"projection": "polar"}) + ax.plot(t, 1 + 0.5 * np.sin(3 * t)) + + plotly_fig = tls.mpl_to_plotly(fig) + + trace = plotly_fig.data[0] + assert trace.type == "scatterpolar" + assert trace.subplot == "polar" + assert np.allclose(trace.theta[0], 0) + assert np.allclose(trace.r[0], 1) + assert np.allclose(trace.theta[-1], 360) + polar = plotly_fig.layout.polar + assert polar.angularaxis.direction == "counterclockwise" + assert polar.angularaxis.rotation == 0 + assert polar.angularaxis.ticktext[0] == "0°" + assert polar.radialaxis.range == tuple(float(v) for v in ax.get_ylim()) + assert polar.bgcolor == "#FFFFFF" + assert polar.angularaxis.gridcolor == "#B0B0B0" + assert polar.radialaxis.gridcolor == "#B0B0B0" + assert polar.angularaxis.linecolor == "#000000" + assert polar.angularaxis.linewidth == 0.8 + assert polar.radialaxis.showline is False + + +def test_polar_grid_with_tuple_color(): + """Polar axes with tuple grid colors convert successfully.""" + fig, ax = plt.subplots(subplot_kw={"projection": "polar"}) + ax.grid(color=(0.5, 0.5, 0.5)) + plotly_fig = tls.mpl_to_plotly(fig) + + assert plotly_fig.layout.polar.angularaxis.gridcolor == "#7F7F7F" + assert plotly_fig.layout.polar.radialaxis.gridcolor == "#7F7F7F" + + +def test_cartesian_grid_with_tuple_color(): + """Cartesian axes with tuple grid colors convert successfully.""" + fig, ax = plt.subplots() + ax.grid(color=(0.5, 0.5, 0.5)) + ax.plot([0, 1], [0, 1]) + plotly_fig = tls.mpl_to_plotly(fig) + + assert plotly_fig.layout.xaxis.showgrid is True + assert plotly_fig.layout.yaxis.showgrid is True + + def test_custom_date_xtickvals_given_as_numbers_are_converted(): """Custom date ticks given as matplotlib date numbers must be converted to date strings."""