diff --git a/CHANGELOG.md b/CHANGELOG.md index 0583fec45c..d8cd9cf085 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ This project adheres to [Semantic Versioning](http://semver.org/). ## Unreleased ### Fixed +- Fix `mpl_to_plotly` conversion of matplotlib contour lines and line collections: close contour rings ending with `Z` codes, support dash styles, convert date x-axes, separate disjoint subpaths with `None` separators, hide line collection traces from the legend by default, and group consecutive same-style lines into single traces [[#5770](https://github.com/plotly/plotly.py/pull/5770)], with thanks to @robertoffmoura for the contribution! - Fix concurrent first access to lazily initialized graph object properties, which could raise `ValueError("Invalid value")` [[#3441](https://github.com/plotly/plotly.py/issues/3441)], with thanks to @hb1915 for the contribution! - Fix `px.sunburst`, `px.treemap` and `px.icicle` listing sectors in a different order on every run when `path` is used with a Polars DataFrame; sectors now follow their order of first appearance for all dataframe backends [[#5765](https://github.com/plotly/plotly.py/issues/5765)], with thanks to @Irahan2 for the contribution! diff --git a/plotly/matplotlylib/mplexporter/exporter.py b/plotly/matplotlylib/mplexporter/exporter.py index bbd17568e9..f027fe4157 100644 --- a/plotly/matplotlylib/mplexporter/exporter.py +++ b/plotly/matplotlylib/mplexporter/exporter.py @@ -282,6 +282,7 @@ def draw_collection( "linewidth": collection.get_linewidths(), "facecolor": collection.get_facecolors(), "edgecolor": collection.get_edgecolors(), + "linestyle": collection.get_linestyle(), "alpha": collection._alpha, "zorder": collection.get_zorder(), } diff --git a/plotly/matplotlylib/renderer.py b/plotly/matplotlylib/renderer.py index 65bbcfabb1..f2cb9e16b8 100644 --- a/plotly/matplotlylib/renderer.py +++ b/plotly/matplotlylib/renderer.py @@ -27,6 +27,39 @@ def _export_color(color): return [_export_color(c) for c in color] +def _per_path(values, i, default): + """Return the style value for the i-th path of a path collection. + + A scalar (or string) value applies to every path, a sequence is cycled + through like matplotlib does, and None or an empty sequence yields + `default`. + """ + if isinstance(values, str): + return values + if values is None: + return default + try: + n = len(values) + except TypeError: + return values + return values[i % n] if n else default + + +def _convert_collection_dash(linestyle): + """Convert a matplotlib collection line style to a plotly dash string. + + Collections report line styles as (offset, dashes) tuples, with dashes + in points and already scaled by line width (None for solid lines). Line + widths are exported with their point values used as px, so the dashes + are exported the same way, which keeps matplotlib's dash-to-width ratio. + plotly has no dash offset, so the offset is dropped. + """ + dashes = linestyle[1] if linestyle is not None else None + if not dashes: + return "solid" + return ",".join("{0:g}px".format(d) for d in dashes) + + class PlotlyRenderer(Renderer): """A renderer class inheriting from base for rendering mpl plots in plotly. @@ -508,6 +541,7 @@ def draw_path_collection(self, **props): 'linewidth', (one or more linewidths) 'facecolor', (one or more facecolors for path) 'edgecolor', (one or more edgecolors for path) + 'linestyle', (one or more (offset, dashes) line styles) 'alpha', (one or more opacites for path) 'zorder', (precedence when stacked) ] @@ -526,8 +560,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,27 +576,109 @@ 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, grouping consecutive same-style paths into single traces.""" + edgecolors = mpltools.convert_rgba_array(props["styles"]["edgecolor"]) + linewidths = mpltools.convert_linewidth_array(props["styles"]["linewidth"]) + linestyles = props["styles"].get("linestyle") + + current_style = None + grouped_x = [] + grouped_y = [] + + def flush(): + if current_style is not None and grouped_x: + ec, lw, d = current_style + self.plotly_fig.add_trace( + go.Scatter( + x=grouped_x, + y=grouped_y, + mode="lines", + showlegend=False, + line=go.scatter.Line( + color=_export_color(ec), + width=lw, + dash=d, + ), + xaxis="x{0}".format(self.axis_ct), + yaxis="y{0}".format(self.axis_ct), + ) + ) + + for i, (verts, codes) in enumerate(props["paths"]): + # 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)) + path_x = [] + path_y = [] + 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 path_x: + path_x.append(None) + path_y.append(None) + path_x.extend(sub_x) + path_y.extend(sub_y) + if not path_x: + continue + + edgecolor = _per_path(edgecolors, i, "rgba(0,0,0,0)") + linewidth = _per_path(linewidths, i, 0) + dash = _convert_collection_dash(_per_path(linestyles, i, None)) + style = (edgecolor, linewidth, dash) + + if style != current_style: + flush() + current_style = style + grouped_x = list(path_x) + grouped_y = list(path_y) + else: + grouped_x.append(None) + grouped_y.append(None) + grouped_x.extend(path_x) + grouped_y.extend(path_y) + + flush() + 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"]) 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"]): - 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) + 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) 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 18ce2d02b3..11acc0d254 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 @@ -111,7 +112,9 @@ def test_eventplot_segments_render(): fig, ax = plt.subplots() ax.eventplot([np.random.randn(20) for _ in range(5)]) plotly_fig = tls.mpl_to_plotly(fig) - assert len(plotly_fig.data) == 100 + # Each of the 5 event rows is a line collection whose 20 segments are grouped + assert len(plotly_fig.data) == 5 + assert all(t.x.count(None) == 19 for t in plotly_fig.data) def test_stackplot_areas_render(): @@ -163,17 +166,23 @@ def test_stem_plot_renders(): fig, ax = plt.subplots() ax.stem(x, np.sin(x)) plotly_fig = tls.mpl_to_plotly(fig) - assert len(plotly_fig.data) >= 20 + # The 20 vertical stem lines are grouped into a single line trace with 19 None separators + stem_lines = [ + t for t in plotly_fig.data if t.mode == "lines" and t.x.count(None) == 19 + ] + assert len(stem_lines) == 1 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 +360,181 @@ 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_disjoint_contour_subpaths_are_separated_by_none(): + """Disjoint subpaths of one contour level are drawn in a single trace, + separated by None so plotly does not connect them.""" + x = np.linspace(-3, 3, 61) + X, Y = np.meshgrid(x, x) + # two separate bumps: the 0.5 level is two disjoint rings in one path + Z = np.exp(-((X + 1.5) ** 2 + Y**2)) + np.exp(-((X - 1.5) ** 2 + Y**2)) + fig, ax = plt.subplots() + ax.contour(X, Y, Z, levels=[0.5]) + plotly_fig = tls.mpl_to_plotly(fig) + + assert len(plotly_fig.data) == 1 + xs = list(plotly_fig.data[0].x) + ys = list(plotly_fig.data[0].y) + x_gaps = [i for i, v in enumerate(xs) if v is None] + y_gaps = [i for i, v in enumerate(ys) if v is None] + assert len(x_gaps) == 1 + assert x_gaps == y_gaps + + gap = x_gaps[0] + rings = [(xs[:gap], ys[:gap]), (xs[gap + 1 :], ys[gap + 1 :])] + for ring_x, ring_y in rings: + assert len(ring_x) > 2 + # each ring is closed + assert ring_x[0] == ring_x[-1] + assert ring_y[0] == ring_y[-1] + # one ring around each bump, so no segment bridges the two + left, right = sorted(rings, key=lambda ring: ring[0][0]) + assert max(left[0]) < 0 + assert min(right[0]) > 0 + + +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) + + +def test_contour_line_dash_styles(): + """Each contour level keeps its matplotlib dash pattern, exported as a + px dash list (matplotlib's pattern is already scaled by line width).""" + 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), + levels=[-0.5, -0.25, 0.25, 0.5], + colors="k", + linewidths=1.5, + linestyles=["dashed", "solid", "dotted", (0, (5, 2, 1, 2))], + ) + plotly_fig = tls.mpl_to_plotly(fig) + + assert [t.line.dash for t in plotly_fig.data] == [ + "5.55px,2.4px", + "solid", + "1.5px,2.475px", + "7.5px,3px,1.5px,3px", + ] + + +def test_contour_line_dash_scales_with_linewidth(): + """matplotlib scales dash patterns by line width; the export follows.""" + 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), levels=[0.5], linewidths=3, linestyles="dashed" + ) + plotly_fig = tls.mpl_to_plotly(fig) + + assert len(plotly_fig.data) == 1 + assert plotly_fig.data[0].line.width == 3 + assert plotly_fig.data[0].line.dash == "11.1px,4.8px" + + +def test_contour_lines_showlegend_false(): + """Contour line traces have showlegend=False so they do not produce legend entries.""" + 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), levels=[-0.5, 0.5]) + plotly_fig = tls.mpl_to_plotly(fig) + + assert len(plotly_fig.data) >= 1 + assert all(t.showlegend is False for t in plotly_fig.data) + + +def test_contour_lines_not_in_legend(): + """Contour lines do not appear in the legend even when a figure legend is present.""" + x = np.linspace(-3, 3, 30) + X, Y = np.meshgrid(x, x) + fig, ax = plt.subplots() + ax.plot([0, 1], [0, 1], label="Line") + ax.contour(X, Y, np.sin(X) * np.cos(Y), levels=[-0.5, 0.5]) + ax.legend() + plotly_fig = tls.mpl_to_plotly(fig) + + contour_traces = [t for t in plotly_fig.data if t.name != "Line"] + assert len(contour_traces) >= 1 + assert all(t.showlegend is False for t in contour_traces) + + +def test_consecutive_same_style_lines_grouped_into_one_trace(): + """Consecutive paths with identical styles are grouped into a single trace.""" + x = np.linspace(-3, 3, 30) + X, Y = np.meshgrid(x, x) + fig, ax = plt.subplots() + # 4 contour levels, all black, solid, width 1.5 + ax.contour( + X, + Y, + X**2 + Y**2, + levels=[1, 2, 3, 4], + colors="k", + linestyles="solid", + linewidths=1.5, + ) + plotly_fig = tls.mpl_to_plotly(fig) + + # All 4 levels share the same style, so they should be grouped into 1 trace + assert len(plotly_fig.data) == 1 + trace = plotly_fig.data[0] + # The trace combines the distinct levels separated by None + assert trace.x.count(None) >= 3 + + +def test_mixed_style_lines_group_consecutive_matches(): + """Paths are grouped by consecutive matching style (color, width, dash).""" + x = np.linspace(-3, 3, 30) + X, Y = np.meshgrid(x, x) + fig, ax = plt.subplots() + # 4 levels: 2 negative (dashed by default in mpl), 2 positive (solid by default) + ax.contour( + X, + Y, + np.sin(X) * np.cos(Y), + levels=[-0.5, -0.25, 0.25, 0.5], + colors="k", + linewidths=1.5, + ) + plotly_fig = tls.mpl_to_plotly(fig) + + # 2 dashed levels grouped into 1 trace, 2 solid levels grouped into 1 trace -> 2 traces total + assert len(plotly_fig.data) == 2 + assert plotly_fig.data[0].line.dash != "solid" + assert plotly_fig.data[1].line.dash == "solid"