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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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!

Expand Down
1 change: 1 addition & 0 deletions plotly/matplotlylib/mplexporter/exporter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
}
Expand Down
153 changes: 137 additions & 16 deletions plotly/matplotlylib/renderer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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)
]
Expand All @@ -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(
Expand All @@ -537,27 +576,109 @@ def draw_path_collection(self, **props):
"collections linked to 'data' coordinates"
)

def _draw_line_collection(self, props):
Comment thread
robertoffmoura marked this conversation as resolved.
"""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]),
Expand Down
Loading