Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 82 additions & 2 deletions plotly/matplotlylib/renderer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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"])
Expand Down
38 changes: 37 additions & 1 deletion plotly/matplotlylib/tests/test_renderer.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import numpy as np
import matplotlib.pyplot as plt
import matplotlib.dates as mdates
import plotly.tools as tls


Expand Down Expand Up @@ -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():
Expand Down Expand Up @@ -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)