Skip to content

Commit c4ecd22

Browse files
authored
Merge pull request #5770 from robertoffmoura/rm/fix-contour-plot
Fix conversion of matplotlib contour lines
2 parents 893e977 + be6be6c commit c4ecd22

4 files changed

Lines changed: 329 additions & 19 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ This project adheres to [Semantic Versioning](http://semver.org/).
1313
- Fix `mpl_to_plotly` crashing on figures with fully transparent or alpha-carrying colors, such as boxplot outlier markers (`facecolor="none"`), rgba line colors, and `color="none"` text and axis labels; all matplotlib colors are now exported through a single helper that maps `"none"` to transparent rgba and keeps or overrides alpha as matplotlib does [[#5700](https://github.com/plotly/plotly.py/pull/5700)], with thanks to @robertoffmoura for the contribution!
1414
- Export tick label colors to the plotly tickfont in `mpl_to_plotly` [[#5716](https://github.com/plotly/plotly.py/pull/5716)], with thanks to @robertoffmoura for the contribution!
1515
- 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 [[#5766](https://github.com/plotly/plotly.py/pull/5766)], with thanks to @Irahan2 for the contribution!
16+
- 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!
1617

1718
## [7.1.0] - 2026-09-15
1819

‎plotly/matplotlylib/mplexporter/exporter.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -282,6 +282,7 @@ def draw_collection(
282282
"linewidth": collection.get_linewidths(),
283283
"facecolor": collection.get_facecolors(),
284284
"edgecolor": collection.get_edgecolors(),
285+
"linestyle": collection.get_linestyle(),
285286
"alpha": collection._alpha,
286287
"zorder": collection.get_zorder(),
287288
}

‎plotly/matplotlylib/renderer.py‎

Lines changed: 137 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,39 @@
1717
from plotly.matplotlylib.mpltools import _export_color
1818

1919

20+
def _per_path(values, i, default):
21+
"""Return the style value for the i-th path of a path collection.
22+
23+
A scalar (or string) value applies to every path, a sequence is cycled
24+
through like matplotlib does, and None or an empty sequence yields
25+
`default`.
26+
"""
27+
if isinstance(values, str):
28+
return values
29+
if values is None:
30+
return default
31+
try:
32+
n = len(values)
33+
except TypeError:
34+
return values
35+
return values[i % n] if n else default
36+
37+
38+
def _convert_collection_dash(linestyle):
39+
"""Convert a matplotlib collection line style to a plotly dash string.
40+
41+
Collections report line styles as (offset, dashes) tuples, with dashes
42+
in points and already scaled by line width (None for solid lines). Line
43+
widths are exported with their point values used as px, so the dashes
44+
are exported the same way, which keeps matplotlib's dash-to-width ratio.
45+
plotly has no dash offset, so the offset is dropped.
46+
"""
47+
dashes = linestyle[1] if linestyle is not None else None
48+
if not dashes:
49+
return "solid"
50+
return ",".join("{0:g}px".format(d) for d in dashes)
51+
52+
2053
class PlotlyRenderer(Renderer):
2154
"""A renderer class inheriting from base for rendering mpl plots in plotly.
2255
@@ -562,6 +595,7 @@ def draw_path_collection(self, **props):
562595
'linewidth', (one or more linewidths)
563596
'facecolor', (one or more facecolors for path)
564597
'edgecolor', (one or more edgecolors for path)
598+
'linestyle', (one or more (offset, dashes) line styles)
565599
'alpha', (one or more opacites for path)
566600
'zorder', (precedence when stacked)
567601
]
@@ -580,8 +614,13 @@ def draw_path_collection(self, **props):
580614
self.msg += " Drawing path collection as markers\n"
581615
self.draw_marked_line(**scatter_props)
582616
elif props["path_coordinates"] == "data":
583-
self.msg += " Drawing path collection as filled polygons\n"
584-
self._draw_filled_path_collection(props)
617+
if len(props["styles"]["facecolor"]) == 0:
618+
# no face colors: a line collection (e.g. contour lines)
619+
self.msg += " Drawing path collection as lines\n"
620+
self._draw_line_collection(props)
621+
else:
622+
self.msg += " Drawing path collection as filled polygons\n"
623+
self._draw_filled_path_collection(props)
585624
else:
586625
self.msg += " Path collection not linked to 'data', not drawing\n"
587626
warnings.warn(
@@ -591,27 +630,109 @@ def draw_path_collection(self, **props):
591630
"collections linked to 'data' coordinates"
592631
)
593632

633+
def _draw_line_collection(self, props):
634+
"""Draw a path collection without face colors (e.g. contour lines)
635+
as plain lines, grouping consecutive same-style paths into single traces."""
636+
edgecolors = mpltools.convert_rgba_array(props["styles"]["edgecolor"])
637+
linewidths = mpltools.convert_linewidth_array(props["styles"]["linewidth"])
638+
linestyles = props["styles"].get("linestyle")
639+
640+
current_style = None
641+
grouped_x = []
642+
grouped_y = []
643+
644+
def flush():
645+
if current_style is not None and grouped_x:
646+
ec, lw, d = current_style
647+
self.plotly_fig.add_trace(
648+
go.Scatter(
649+
x=grouped_x,
650+
y=grouped_y,
651+
mode="lines",
652+
showlegend=False,
653+
line=go.scatter.Line(
654+
color=_export_color(ec),
655+
width=lw,
656+
dash=d,
657+
),
658+
xaxis="x{0}".format(self.axis_ct),
659+
yaxis="y{0}".format(self.axis_ct),
660+
)
661+
)
662+
663+
for i, (verts, codes) in enumerate(props["paths"]):
664+
# a path may contain several disjoint lines (e.g. contour lines
665+
# of the same level); separate disjoint subpaths with None so
666+
# plotly does not connect them.
667+
# In SVG paths, codes carry different numbers of vertices:
668+
# M/L: 1, C: 3 (cubic curve), S: 2 (smooth/quad curve), Z: 0.
669+
code_steps = {"M": 1, "L": 1, "C": 3, "S": 2, "Z": 0}
670+
subpaths = []
671+
current = []
672+
closed = False
673+
vi = 0
674+
for c in codes:
675+
step = code_steps.get(c, 1)
676+
if c == "M":
677+
if current:
678+
subpaths.append((current, closed))
679+
current = [verts[vi]]
680+
closed = False
681+
vi += 1
682+
elif c == "Z":
683+
closed = True
684+
else:
685+
current.extend(verts[vi : vi + step])
686+
vi += step
687+
if current:
688+
subpaths.append((current, closed))
689+
path_x = []
690+
path_y = []
691+
for sub, closed in subpaths:
692+
if len(sub) < 2:
693+
continue
694+
# a closed subpath (Z code) must be closed explicitly since
695+
# plotly's lines mode does not close the loop
696+
if closed:
697+
sub = sub + [sub[0]]
698+
sub_x = self._convert_x_dates([v[0] for v in sub])
699+
sub_y = [v[1] for v in sub]
700+
if path_x:
701+
path_x.append(None)
702+
path_y.append(None)
703+
path_x.extend(sub_x)
704+
path_y.extend(sub_y)
705+
if not path_x:
706+
continue
707+
708+
edgecolor = _per_path(edgecolors, i, "rgba(0,0,0,0)")
709+
linewidth = _per_path(linewidths, i, 0)
710+
dash = _convert_collection_dash(_per_path(linestyles, i, None))
711+
style = (edgecolor, linewidth, dash)
712+
713+
if style != current_style:
714+
flush()
715+
current_style = style
716+
grouped_x = list(path_x)
717+
grouped_y = list(path_y)
718+
else:
719+
grouped_x.append(None)
720+
grouped_y.append(None)
721+
grouped_x.extend(path_x)
722+
grouped_y.extend(path_y)
723+
724+
flush()
725+
594726
def _draw_filled_path_collection(self, props):
595727
"""Draw a path collection (e.g. violin plot bodies) as filled polygons."""
596728
facecolors = mpltools.convert_rgba_array(props["styles"]["facecolor"])
597729
edgecolors = mpltools.convert_rgba_array(props["styles"]["edgecolor"])
598730
linewidths = mpltools.convert_linewidth_array(props["styles"]["linewidth"])
599731

600-
def per_path(colors, i, default):
601-
if isinstance(colors, str):
602-
return colors
603-
if colors is None:
604-
return default
605-
try:
606-
n = len(colors)
607-
except TypeError:
608-
return colors
609-
return colors[i % n] if n else default
610-
611732
for i, (verts, codes) in enumerate(props["paths"]):
612-
facecolor = per_path(facecolors, i, "rgba(0,0,0,0)")
613-
edgecolor = per_path(edgecolors, i, "rgba(0,0,0,0)")
614-
linewidth = per_path(linewidths, i, 0)
733+
facecolor = _per_path(facecolors, i, "rgba(0,0,0,0)")
734+
edgecolor = _per_path(edgecolors, i, "rgba(0,0,0,0)")
735+
linewidth = _per_path(linewidths, i, 0)
615736
self.plotly_fig.add_trace(
616737
go.Scatter(
617738
x=self._convert_x_dates([v[0] for v in verts]),

0 commit comments

Comments
 (0)