From b6ffb54b676803f21496e6a1ff1a6efd5284543d 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 be10cd9af9..7fbc74409e 100644 --- a/plotly/matplotlylib/renderer.py +++ b/plotly/matplotlylib/renderer.py @@ -10,6 +10,7 @@ import warnings import matplotlib.patches as mpatches +import numpy as np import plotly.graph_objs as go from plotly.matplotlylib.mplexporter import Renderer from plotly.matplotlylib import mpltools @@ -86,6 +87,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" @@ -102,6 +106,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. @@ -195,6 +228,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"]) @@ -301,6 +339,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. @@ -535,6 +574,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, @@ -738,6 +812,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 a355db7dc4..2a21ea8661 100644 --- a/plotly/matplotlylib/tests/test_renderer.py +++ b/plotly/matplotlylib/tests/test_renderer.py @@ -852,6 +852,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 9f1267bd713458ac703063a211f1d2db56859433 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 7fbc74409e..46ea1dd703 100644 --- a/plotly/matplotlylib/renderer.py +++ b/plotly/matplotlylib/renderer.py @@ -110,10 +110,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 = ( @@ -123,8 +124,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 2a21ea8661..5514e22138 100644 --- a/plotly/matplotlylib/tests/test_renderer.py +++ b/plotly/matplotlylib/tests/test_renderer.py @@ -868,8 +868,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 018bd99ff342d09b768f976dafd6159304bd3e8f 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 46ea1dd703..49c45e860a 100644 --- a/plotly/matplotlylib/renderer.py +++ b/plotly/matplotlylib/renderer.py @@ -106,7 +106,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 @@ -114,7 +114,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 = ( @@ -122,19 +124,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, ), ) @@ -234,7 +260,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 5514e22138..0354447c63 100644 --- a/plotly/matplotlylib/tests/test_renderer.py +++ b/plotly/matplotlylib/tests/test_renderer.py @@ -872,6 +872,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 fc628e1a21e0e7f63d17affaa6eb9db1166e232f 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 49c45e860a..c2b096943e 100644 --- a/plotly/matplotlylib/renderer.py +++ b/plotly/matplotlylib/renderer.py @@ -136,7 +136,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( @@ -146,13 +146,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 c98af049c86e00a6976897451d740f24f08e6b3b 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 c2b096943e..7154d39839 100644 --- a/plotly/matplotlylib/renderer.py +++ b/plotly/matplotlylib/renderer.py @@ -124,18 +124,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"]), @@ -144,8 +135,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, @@ -154,8 +145,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 0354447c63..53180d2cc2 100644 --- a/plotly/matplotlylib/tests/test_renderer.py +++ b/plotly/matplotlylib/tests/test_renderer.py @@ -873,13 +873,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 3968989c01cc568929c9fbd03f12419cb89aa236 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 b45532a79b..79df290fc0 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 `mpl_to_plotly` losing matplotlib axis line colors by exporting the color of each axis's displayed spine to the plotly x and y axis `linecolor` [[#5311](https://github.com/plotly/plotly.py/pull/5311)], with thanks to @robertoffmoura for the contribution!