diff --git a/plotly/matplotlylib/renderer.py b/plotly/matplotlylib/renderer.py index 65bbcfabb1..56b44c85ea 100644 --- a/plotly/matplotlylib/renderer.py +++ b/plotly/matplotlylib/renderer.py @@ -526,8 +526,13 @@ def draw_path_collection(self, **props): self.msg += " Drawing path collection as markers\n" self.draw_marked_line(**scatter_props) elif props["path_coordinates"] == "data": - self.msg += " Drawing path collection as filled polygons\n" - self._draw_filled_path_collection(props) + if len(props["styles"]["facecolor"]) == 0: + # no face colors: a line collection (e.g. contour lines) + self.msg += " Drawing path collection as lines\n" + self._draw_line_collection(props) + else: + self.msg += " Drawing path collection as filled polygons\n" + self._draw_filled_path_collection(props) else: self.msg += " Path collection not linked to 'data', not drawing\n" warnings.warn( @@ -537,6 +542,81 @@ def draw_path_collection(self, **props): "collections linked to 'data' coordinates" ) + def _draw_line_collection(self, props): + """Draw a path collection without face colors (e.g. contour lines) + as plain lines.""" + edgecolors = mpltools.convert_rgba_array(props["styles"]["edgecolor"]) + linewidths = mpltools.convert_linewidth_array(props["styles"]["linewidth"]) + + def per_path(colors, i, default): + if isinstance(colors, str): + return colors + if colors is None: + return default + try: + n = len(colors) + except TypeError: + return colors + return colors[i % n] if n else default + + for i, (verts, codes) in enumerate(props["paths"]): + edgecolor = per_path(edgecolors, i, "rgba(0,0,0,0)") + linewidth = per_path(linewidths, i, 0) + # a path may contain several disjoint lines (e.g. contour lines + # of the same level); separate disjoint subpaths with None so + # plotly does not connect them. + # In SVG paths, codes carry different numbers of vertices: + # M/L: 1, C: 3 (cubic curve), S: 2 (smooth/quad curve), Z: 0. + code_steps = {"M": 1, "L": 1, "C": 3, "S": 2, "Z": 0} + subpaths = [] + current = [] + closed = False + vi = 0 + for c in codes: + step = code_steps.get(c, 1) + if c == "M": + if current: + subpaths.append((current, closed)) + current = [verts[vi]] + closed = False + vi += 1 + elif c == "Z": + closed = True + else: + current.extend(verts[vi : vi + step]) + vi += step + if current: + subpaths.append((current, closed)) + x_combined = [] + y_combined = [] + for sub, closed in subpaths: + if len(sub) < 2: + continue + # a closed subpath (Z code) must be closed explicitly since + # plotly's lines mode does not close the loop + if closed: + sub = sub + [sub[0]] + sub_x = self._convert_x_dates([v[0] for v in sub]) + sub_y = [v[1] for v in sub] + if x_combined: + x_combined.append(None) + y_combined.append(None) + x_combined.extend(sub_x) + y_combined.extend(sub_y) + if x_combined: + self.plotly_fig.add_trace( + go.Scatter( + x=x_combined, + y=y_combined, + mode="lines", + line=go.scatter.Line( + color=_export_color(edgecolor), width=linewidth + ), + xaxis="x{0}".format(self.axis_ct), + yaxis="y{0}".format(self.axis_ct), + ) + ) + def _draw_filled_path_collection(self, props): """Draw a path collection (e.g. violin plot bodies) as filled polygons.""" facecolors = mpltools.convert_rgba_array(props["styles"]["facecolor"]) diff --git a/plotly/matplotlylib/tests/test_renderer.py b/plotly/matplotlylib/tests/test_renderer.py index 18ce2d02b3..b4a28e8f85 100644 --- a/plotly/matplotlylib/tests/test_renderer.py +++ b/plotly/matplotlylib/tests/test_renderer.py @@ -2,6 +2,7 @@ import numpy as np import matplotlib.pyplot as plt +import matplotlib.dates as mdates import plotly.tools as tls @@ -167,13 +168,15 @@ def test_stem_plot_renders(): def test_contour_lines_convert(): - """Contour lines used to crash with an ndarray line width.""" + """Contour lines must render as lines, not filled polygons.""" x = np.linspace(-3, 3, 30) X, Y = np.meshgrid(x, x) fig, ax = plt.subplots() ax.contour(X, Y, np.sin(X) * np.cos(Y), 10) plotly_fig = tls.mpl_to_plotly(fig) assert len(plotly_fig.data) > 0 + assert all(t.fill is None for t in plotly_fig.data) + assert all(t.mode == "lines" for t in plotly_fig.data) def test_contourf_bands_render(): @@ -351,3 +354,36 @@ def test_custom_date_xtickvals_given_as_numbers_are_converted(): "2023-01-07 00:00:00", "2023-01-10 00:00:00", ) + + +def test_contour_rings_are_closed(): + """Closed contour loops (Z codes) must close in plotly, not leave a gap.""" + x = np.linspace(-3, 3, 50) + X, Y = np.meshgrid(x, x) + fig, ax = plt.subplots() + ax.contour(X, Y, X**2 + Y**2, levels=[1, 4]) + plotly_fig = tls.mpl_to_plotly(fig) + + assert len(plotly_fig.data) == 2 + assert plotly_fig.data[0].x[0] == plotly_fig.data[0].x[-1] + assert plotly_fig.data[0].y[0] == plotly_fig.data[0].y[-1] + assert plotly_fig.data[1].x[0] == plotly_fig.data[1].x[-1] + assert plotly_fig.data[1].y[0] == plotly_fig.data[1].y[-1] + + +def test_line_collection_date_xaxis(): + """Line collections with date x-values must export date strings, + not raw matplotlib date numbers.""" + dates = [ + datetime.datetime(2023, 1, 1) + datetime.timedelta(days=i) for i in range(10) + ] + y = np.linspace(0, 10, 10) + X, Y = np.meshgrid(mdates.date2num(dates), y) + fig, ax = plt.subplots() + ax.xaxis_date() + ax.contour(X, Y, np.sin(X) * np.cos(Y), 5) + plotly_fig = tls.mpl_to_plotly(fig) + lines = [t for t in plotly_fig.data if t.mode == "lines"] + assert len(lines) >= 1 + assert any(isinstance(x, str) for t in lines for x in t.x) + assert all(x is None or isinstance(x, str) for t in lines for x in t.x)