From 51e7a36ee7efda154d651771ac26dc5273500509 Mon Sep 17 00:00:00 2001 From: Roberto Moura Date: Tue, 1 Sep 2026 14:41:38 +0000 Subject: [PATCH 1/6] Convert polar plots to plotly polar subplots --- plotly/matplotlylib/renderer.py | 89 ++++++++++++++++++++++ plotly/matplotlylib/tests/test_renderer.py | 22 ++++++ 2 files changed, 111 insertions(+) diff --git a/plotly/matplotlylib/renderer.py b/plotly/matplotlylib/renderer.py index bb62dc36b4..90936d60ba 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,35 @@ def _convert_x_dates(self, x): x = mpltools.mpl_dates_to_datestrings(x, formatter) return x + def _open_polar_axes(self, ax): + """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 polar + angles are in degrees measured from 12 o'clock, so the angular + axis rotation and direction are set to map the two systems onto + each other. + """ + 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() + self.plotly_fig["layout"][self.current_polar_subplot] = go.layout.Polar( + angularaxis=dict( + rotation=90 - float(np.degrees(theta_offset)), + direction=("clockwise" if theta_direction >= 0 else "counterclockwise"), + tickvals=[float(t) for t in np.degrees(ax.xaxis.get_majorticklocs())], + ticktext=[t.get_text() for t in ax.xaxis.get_majorticklabels()], + ), + 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()], + ), + ) + def open_figure(self, fig, props): """Creates a new figure by beginning to fill out layout dict. @@ -194,6 +227,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) + return self.axis_ct += 1 if props.get("patch_visible", True): self.plotly_fig["layout"].plot_bgcolor = _export_color(props["axesbg"]) @@ -296,6 +334,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 +569,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 +807,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..cb453384d3 100644 --- a/plotly/matplotlylib/tests/test_renderer.py +++ b/plotly/matplotlylib/tests/test_renderer.py @@ -707,6 +707,28 @@ 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 == "clockwise" + assert polar.angularaxis.rotation == 90 + assert polar.angularaxis.ticktext[0] == "0°" + assert polar.radialaxis.range == tuple(float(v) for v in ax.get_ylim()) + + def test_custom_date_xtickvals_given_as_numbers_are_converted(): """Custom date ticks given as matplotlib date numbers must be converted to date strings.""" From 01690cd9d572ac79a957f47945a5e20f1b9cb857 Mon Sep 17 00:00:00 2001 From: Roberto Moura Date: Tue, 1 Sep 2026 14:56:55 +0000 Subject: [PATCH 2/6] Fix polar angular axis direction and rotation --- plotly/matplotlylib/renderer.py | 15 +++++++++------ plotly/matplotlylib/tests/test_renderer.py | 4 ++-- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/plotly/matplotlylib/renderer.py b/plotly/matplotlylib/renderer.py index 90936d60ba..4b827a0281 100644 --- a/plotly/matplotlylib/renderer.py +++ b/plotly/matplotlylib/renderer.py @@ -109,10 +109,11 @@ def _open_polar_axes(self, ax): """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 polar - angles are in degrees measured from 12 o'clock, so the angular - axis rotation and direction are set to map the two systems onto - each other. + 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. """ self.polar_ct += 1 self.current_polar_subplot = ( @@ -122,8 +123,10 @@ def _open_polar_axes(self, ax): theta_direction = ax.get_theta_direction() self.plotly_fig["layout"][self.current_polar_subplot] = go.layout.Polar( angularaxis=dict( - rotation=90 - float(np.degrees(theta_offset)), - direction=("clockwise" if theta_direction >= 0 else "counterclockwise"), + 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()], ), diff --git a/plotly/matplotlylib/tests/test_renderer.py b/plotly/matplotlylib/tests/test_renderer.py index cb453384d3..edaeed0fa1 100644 --- a/plotly/matplotlylib/tests/test_renderer.py +++ b/plotly/matplotlylib/tests/test_renderer.py @@ -723,8 +723,8 @@ def test_polar_plot_converts(): assert np.allclose(trace.r[0], 1) assert np.allclose(trace.theta[-1], 360) polar = plotly_fig.layout.polar - assert polar.angularaxis.direction == "clockwise" - assert polar.angularaxis.rotation == 90 + 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()) From ec133b2fb064e56bc7cc2117315cdc54179f3a58 Mon Sep 17 00:00:00 2001 From: Roberto Moura Date: Tue, 1 Sep 2026 16:32:50 +0000 Subject: [PATCH 3/6] Take polar background, grid, and frame colors from matplotlib --- plotly/matplotlylib/renderer.py | 38 ++++++++++++++++++---- plotly/matplotlylib/tests/test_renderer.py | 6 ++++ 2 files changed, 38 insertions(+), 6 deletions(-) diff --git a/plotly/matplotlylib/renderer.py b/plotly/matplotlylib/renderer.py index 4b827a0281..d84177d1ee 100644 --- a/plotly/matplotlylib/renderer.py +++ b/plotly/matplotlylib/renderer.py @@ -105,7 +105,7 @@ def _convert_x_dates(self, x): x = mpltools.mpl_dates_to_datestrings(x, formatter) return x - def _open_polar_axes(self, ax): + 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 @@ -113,7 +113,9 @@ def _open_polar_axes(self, ax): 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. + 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 = ( @@ -121,19 +123,43 @@ def _open_polar_axes(self, ax): ) theta_offset = ax.get_theta_offset() theta_direction = ax.get_theta_direction() + angular_gridlines = ax.xaxis.get_gridlines() + radial_gridlines = ax.yaxis.get_gridlines() + angular_grid = ( + (angular_gridlines[0].get_color(), angular_gridlines[0].get_visible()) + if len(angular_gridlines) + else ("#b0b0b0", True) + ) + radial_grid = ( + (radial_gridlines[0].get_color(), radial_gridlines[0].get_visible()) + if len(radial_gridlines) + else ("#b0b0b0", True) + ) + frame = ax.spines.get("polar") 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" - ), + 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[1], + gridcolor=_export_color(angular_grid[0]), + showline=frame.get_visible() if frame is not None else True, + linecolor=( + _export_color(frame.get_edgecolor()) + if frame is not None + else "black" + ), + linewidth=frame.get_linewidth() if frame is not None 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[1], + gridcolor=_export_color(radial_grid[0]), + showline=False, ), ) @@ -233,7 +259,7 @@ def open_axes(self, ax, props): 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) + self._open_polar_axes(ax, props) return self.axis_ct += 1 if props.get("patch_visible", True): diff --git a/plotly/matplotlylib/tests/test_renderer.py b/plotly/matplotlylib/tests/test_renderer.py index edaeed0fa1..dfd2179944 100644 --- a/plotly/matplotlylib/tests/test_renderer.py +++ b/plotly/matplotlylib/tests/test_renderer.py @@ -727,6 +727,12 @@ def test_polar_plot_converts(): 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_custom_date_xtickvals_given_as_numbers_are_converted(): From fa0f8501a8ee9f2931c5b7eb8f9c581c11be9849 Mon Sep 17 00:00:00 2001 From: Roberto Moura Date: Wed, 23 Sep 2026 02:18:14 +0000 Subject: [PATCH 4/6] Export polar frame properties in mplexporter --- plotly/matplotlylib/mplexporter/utils.py | 11 +++++++++++ plotly/matplotlylib/renderer.py | 12 ++++-------- 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/plotly/matplotlylib/mplexporter/utils.py b/plotly/matplotlylib/mplexporter/utils.py index 84bf950ad8..fc84666f96 100644 --- a/plotly/matplotlylib/mplexporter/utils.py +++ b/plotly/matplotlylib/mplexporter/utils.py @@ -308,6 +308,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 d84177d1ee..bc60074908 100644 --- a/plotly/matplotlylib/renderer.py +++ b/plotly/matplotlylib/renderer.py @@ -135,7 +135,7 @@ def _open_polar_axes(self, ax, props): if len(radial_gridlines) else ("#b0b0b0", True) ) - frame = ax.spines.get("polar") + frame = props.get("polar_frame") self.plotly_fig["layout"][self.current_polar_subplot] = go.layout.Polar( bgcolor=_export_color(props["axesbg"]), angularaxis=dict( @@ -145,13 +145,9 @@ def _open_polar_axes(self, ax, props): ticktext=[t.get_text() for t in ax.xaxis.get_majorticklabels()], showgrid=angular_grid[1], gridcolor=_export_color(angular_grid[0]), - showline=frame.get_visible() if frame is not None else True, - linecolor=( - _export_color(frame.get_edgecolor()) - if frame is not None - else "black" - ), - linewidth=frame.get_linewidth() if frame is not None else 1, + 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()], From d3bc4768fe0e7e7698e5988629ee0c6a3d9d72a1 Mon Sep 17 00:00:00 2001 From: Roberto Moura Date: Wed, 23 Sep 2026 13:30:54 +0000 Subject: [PATCH 5/6] Export polar grid colors in mplexporter --- plotly/matplotlylib/mplexporter/utils.py | 13 +++++------ plotly/matplotlylib/renderer.py | 23 ++++++-------------- plotly/matplotlylib/tests/test_renderer.py | 25 ++++++++++++++++++++-- 3 files changed, 37 insertions(+), 24 deletions(-) diff --git a/plotly/matplotlylib/mplexporter/utils.py b/plotly/matplotlylib/mplexporter/utils.py index fc84666f96..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): diff --git a/plotly/matplotlylib/renderer.py b/plotly/matplotlylib/renderer.py index bc60074908..64004418f8 100644 --- a/plotly/matplotlylib/renderer.py +++ b/plotly/matplotlylib/renderer.py @@ -123,18 +123,9 @@ def _open_polar_axes(self, ax, props): ) theta_offset = ax.get_theta_offset() theta_direction = ax.get_theta_direction() - angular_gridlines = ax.xaxis.get_gridlines() - radial_gridlines = ax.yaxis.get_gridlines() - angular_grid = ( - (angular_gridlines[0].get_color(), angular_gridlines[0].get_visible()) - if len(angular_gridlines) - else ("#b0b0b0", True) - ) - radial_grid = ( - (radial_gridlines[0].get_color(), radial_gridlines[0].get_visible()) - if len(radial_gridlines) - else ("#b0b0b0", True) - ) + 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"]), @@ -143,8 +134,8 @@ def _open_polar_axes(self, ax, props): 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[1], - gridcolor=_export_color(angular_grid[0]), + 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, @@ -153,8 +144,8 @@ def _open_polar_axes(self, ax, props): 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[1], - gridcolor=_export_color(radial_grid[0]), + showgrid=radial_grid.get("gridOn", True), + gridcolor=_export_color(radial_grid.get("color", "#B0B0B0")), showline=False, ), ) diff --git a/plotly/matplotlylib/tests/test_renderer.py b/plotly/matplotlylib/tests/test_renderer.py index dfd2179944..62d8aea885 100644 --- a/plotly/matplotlylib/tests/test_renderer.py +++ b/plotly/matplotlylib/tests/test_renderer.py @@ -728,13 +728,34 @@ def test_polar_plot_converts(): 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.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.""" From 53ab4fcee3a2ebc3bb7fc2996a92d9b0b9bd30cc Mon Sep 17 00:00:00 2001 From: Roberto Moura Date: Thu, 8 Oct 2026 15:17:27 +0000 Subject: [PATCH 6/6] Add changelog entry --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) 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!