From adcee6759d7c0e88f8d3fc23c5360d478360f65c Mon Sep 17 00:00:00 2001 From: Roberto Moura Date: Tue, 11 Aug 2026 13:02:59 +0000 Subject: [PATCH 1/5] Fix contour lines and simplify collection fills --- plotly/matplotlylib/renderer.py | 58 +++++++++++++++++++++- plotly/matplotlylib/tests/test_renderer.py | 4 +- 2 files changed, 59 insertions(+), 3 deletions(-) diff --git a/plotly/matplotlylib/renderer.py b/plotly/matplotlylib/renderer.py index 65bbcfabb1..2d40315b44 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,55 @@ 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); drawing them in one trace would connect + # them, so draw each subpath separately + subpaths = [] + current = [] + for v, c in zip(verts, codes): + if c == "M" and current: + subpaths.append(current) + current = [v] + else: + current.append(v) + if current: + subpaths.append(current) + for sub in subpaths: + if len(sub) < 2: + continue + self.plotly_fig.add_trace( + go.Scatter( + x=[v[0] for v in sub], + y=[v[1] for v in sub], + 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..f64cf65b39 100644 --- a/plotly/matplotlylib/tests/test_renderer.py +++ b/plotly/matplotlylib/tests/test_renderer.py @@ -167,13 +167,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(): From d670be4749c62c8287b641881f1e98cb8a00b48e Mon Sep 17 00:00:00 2001 From: Roberto Moura Date: Tue, 11 Aug 2026 13:37:09 +0000 Subject: [PATCH 2/5] Close contour rings that end with a Z code --- plotly/matplotlylib/renderer.py | 29 ++++++++++++++++------ plotly/matplotlylib/tests/test_renderer.py | 15 +++++++++++ 2 files changed, 36 insertions(+), 8 deletions(-) diff --git a/plotly/matplotlylib/renderer.py b/plotly/matplotlylib/renderer.py index 2d40315b44..95ffc075ef 100644 --- a/plotly/matplotlylib/renderer.py +++ b/plotly/matplotlylib/renderer.py @@ -564,20 +564,33 @@ def per_path(colors, i, default): linewidth = per_path(linewidths, i, 0) # a path may contain several disjoint lines (e.g. contour lines # of the same level); drawing them in one trace would connect - # them, so draw each subpath separately + # them, so draw each subpath separately. The Z (close) codes + # carry no vertex, so the codes are iterated by index. subpaths = [] current = [] - for v, c in zip(verts, codes): - if c == "M" and current: - subpaths.append(current) - current = [v] + closed = False + vi = 0 + for c in codes: + if c == "M": + if current: + subpaths.append((current, closed)) + current = [verts[vi]] + closed = False + vi += 1 + elif c == "Z": + closed = True else: - current.append(v) + current.append(verts[vi]) + vi += 1 if current: - subpaths.append(current) - for sub in subpaths: + subpaths.append((current, closed)) + 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]] self.plotly_fig.add_trace( go.Scatter( x=[v[0] for v in sub], diff --git a/plotly/matplotlylib/tests/test_renderer.py b/plotly/matplotlylib/tests/test_renderer.py index f64cf65b39..f4066b7ba8 100644 --- a/plotly/matplotlylib/tests/test_renderer.py +++ b/plotly/matplotlylib/tests/test_renderer.py @@ -353,3 +353,18 @@ 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, 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) + rings = [ + t + for t in plotly_fig.data + if len(t.x) > 30 and t.x[0] == t.x[-1] and t.y[0] == t.y[-1] + ] + assert len(rings) >= 2 From f99d47f00c7c862e281a7d52dec159ad2cc8df23 Mon Sep 17 00:00:00 2001 From: Roberto Moura Date: Tue, 22 Sep 2026 23:39:58 +0000 Subject: [PATCH 3/5] Convert date x-values for line collections --- plotly/matplotlylib/renderer.py | 2 +- plotly/matplotlylib/tests/test_renderer.py | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/plotly/matplotlylib/renderer.py b/plotly/matplotlylib/renderer.py index 95ffc075ef..d37a004d29 100644 --- a/plotly/matplotlylib/renderer.py +++ b/plotly/matplotlylib/renderer.py @@ -593,7 +593,7 @@ def per_path(colors, i, default): sub = sub + [sub[0]] self.plotly_fig.add_trace( go.Scatter( - x=[v[0] for v in sub], + x=self._convert_x_dates([v[0] for v in sub]), y=[v[1] for v in sub], mode="lines", line=go.scatter.Line( diff --git a/plotly/matplotlylib/tests/test_renderer.py b/plotly/matplotlylib/tests/test_renderer.py index f4066b7ba8..0764ea9fc9 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 @@ -368,3 +369,20 @@ def test_contour_rings_are_closed(): if len(t.x) > 30 and t.x[0] == t.x[-1] and t.y[0] == t.y[-1] ] assert len(rings) >= 2 + + +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 all(isinstance(x, str) for t in lines for x in t.x) From 4f7e7f7c5dbccfc82dbc0b72226860f7651dd53e Mon Sep 17 00:00:00 2001 From: Roberto Moura Date: Tue, 22 Sep 2026 23:41:20 +0000 Subject: [PATCH 4/5] Handle multi-vertex path codes in line collection parser --- plotly/matplotlylib/renderer.py | 11 +++++++---- plotly/matplotlylib/tests/test_renderer.py | 1 + 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/plotly/matplotlylib/renderer.py b/plotly/matplotlylib/renderer.py index d37a004d29..0f883e3fa1 100644 --- a/plotly/matplotlylib/renderer.py +++ b/plotly/matplotlylib/renderer.py @@ -564,13 +564,16 @@ def per_path(colors, i, default): linewidth = per_path(linewidths, i, 0) # a path may contain several disjoint lines (e.g. contour lines # of the same level); drawing them in one trace would connect - # them, so draw each subpath separately. The Z (close) codes - # carry no vertex, so the codes are iterated by index. + # them, so draw each subpath separately. + # 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)) @@ -580,8 +583,8 @@ def per_path(colors, i, default): elif c == "Z": closed = True else: - current.append(verts[vi]) - vi += 1 + current.extend(verts[vi : vi + step]) + vi += step if current: subpaths.append((current, closed)) for sub, closed in subpaths: diff --git a/plotly/matplotlylib/tests/test_renderer.py b/plotly/matplotlylib/tests/test_renderer.py index 0764ea9fc9..0491895457 100644 --- a/plotly/matplotlylib/tests/test_renderer.py +++ b/plotly/matplotlylib/tests/test_renderer.py @@ -386,3 +386,4 @@ def test_line_collection_date_xaxis(): lines = [t for t in plotly_fig.data if t.mode == "lines"] assert len(lines) >= 1 assert all(isinstance(x, str) for t in lines for x in t.x) + From 3768215f31fcd92fa6fadde3f084c8d050e9f1ee Mon Sep 17 00:00:00 2001 From: Roberto Moura Date: Tue, 22 Sep 2026 23:44:41 +0000 Subject: [PATCH 5/5] Combine disjoint line collection subpaths using None separators --- plotly/matplotlylib/renderer.py | 18 ++++++++++++++---- plotly/matplotlylib/tests/test_renderer.py | 20 ++++++++++---------- 2 files changed, 24 insertions(+), 14 deletions(-) diff --git a/plotly/matplotlylib/renderer.py b/plotly/matplotlylib/renderer.py index 0f883e3fa1..56b44c85ea 100644 --- a/plotly/matplotlylib/renderer.py +++ b/plotly/matplotlylib/renderer.py @@ -563,8 +563,8 @@ def per_path(colors, i, default): 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); drawing them in one trace would connect - # them, so draw each subpath separately. + # 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} @@ -587,6 +587,8 @@ def per_path(colors, i, default): vi += step if current: subpaths.append((current, closed)) + x_combined = [] + y_combined = [] for sub, closed in subpaths: if len(sub) < 2: continue @@ -594,10 +596,18 @@ def per_path(colors, i, default): # 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=self._convert_x_dates([v[0] for v in sub]), - y=[v[1] for v in sub], + x=x_combined, + y=y_combined, mode="lines", line=go.scatter.Line( color=_export_color(edgecolor), width=linewidth diff --git a/plotly/matplotlylib/tests/test_renderer.py b/plotly/matplotlylib/tests/test_renderer.py index 0491895457..b4a28e8f85 100644 --- a/plotly/matplotlylib/tests/test_renderer.py +++ b/plotly/matplotlylib/tests/test_renderer.py @@ -358,17 +358,17 @@ def test_custom_date_xtickvals_given_as_numbers_are_converted(): def test_contour_rings_are_closed(): """Closed contour loops (Z codes) must close in plotly, not leave a gap.""" - x = np.linspace(-3, 3, 30) + x = np.linspace(-3, 3, 50) X, Y = np.meshgrid(x, x) fig, ax = plt.subplots() - ax.contour(X, Y, np.sin(X) * np.cos(Y), 10) + ax.contour(X, Y, X**2 + Y**2, levels=[1, 4]) plotly_fig = tls.mpl_to_plotly(fig) - rings = [ - t - for t in plotly_fig.data - if len(t.x) > 30 and t.x[0] == t.x[-1] and t.y[0] == t.y[-1] - ] - assert len(rings) >= 2 + + 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(): @@ -385,5 +385,5 @@ def test_line_collection_date_xaxis(): plotly_fig = tls.mpl_to_plotly(fig) lines = [t for t in plotly_fig.data if t.mode == "lines"] assert len(lines) >= 1 - assert all(isinstance(x, str) for t in lines for x in t.x) - + 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)