Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
47d2df4
Convert fill polygons to filled scatter traces
robertoffmoura Sep 2, 2026
211c63d
Support legend labels on filled polygon traces
robertoffmoura Oct 6, 2026
adf16d7
Add changelog entry
robertoffmoura Oct 6, 2026
37b9679
Update changelog entry with PR number 5795
robertoffmoura Oct 6, 2026
c00d54f
Read the polygon label from props['mplobj'] directly
robertoffmoura Oct 23, 2026
27631a3
Share legend label handling in a _legend_label helper
robertoffmoura Oct 23, 2026
e141300
Build filled polygon traces in a shared _add_filled_trace helper
robertoffmoura Oct 23, 2026
79c0e12
Use an identity check for showlegend in test_fill_with_legend_label
robertoffmoura Oct 23, 2026
c576c84
Remove extra blank line under Unreleased in changelog
robertoffmoura Oct 23, 2026
1a77a0e
Order traces within each axes by matplotlib zorder
robertoffmoura Oct 23, 2026
68f5676
Export the line style of fill polygon outlines
robertoffmoura Oct 23, 2026
6d93d8d
Close fill polygon outlines at their first vertex
robertoffmoura Oct 23, 2026
0800058
Keep the open polygon test outline inside the axes frame
robertoffmoura Oct 23, 2026
e01e145
Share the SVG subpath parser between line and filled path traces
robertoffmoura Oct 23, 2026
5a03e81
Export unfilled fill polygons as outline-only traces
robertoffmoura Oct 23, 2026
9b94664
Skip hidden fill polygons
robertoffmoura Oct 23, 2026
de75f9f
Convert patches in data coordinates to filled traces
robertoffmoura Oct 23, 2026
62d7426
Move fill tests after the custom date tick tests
robertoffmoura Oct 23, 2026
b407dc8
Update changelog for patch conversion and trace ordering
robertoffmoura Oct 23, 2026
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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ This project adheres to [Semantic Versioning](http://semver.org/).

### Added
- Add support for converting matplotlib reference lines (`axhline`, `axvline`, and horizontal/vertical `axline`) as well as axes-coordinate lines into Plotly layout shapes in `mpl_to_plotly`, including support for date axes [[#5719](https://github.com/plotly/plotly.py/pull/5719)], with thanks to @robertoffmoura for the contribution!
- Add support for converting matplotlib fill polygons (`plt.fill`) and other patches in data coordinates (such as `Circle`, `Ellipse`, `Wedge`, `Rectangle`, and `PathPatch`, including pie chart wedges and `boxplot(patch_artist=True)` boxes) to filled scatter traces in `mpl_to_plotly`, with closed outlines that keep the edge color, width, and line style; those without a face color export as outlines, and hidden ones are skipped [[#5795](https://github.com/plotly/plotly.py/pull/5795)], with thanks to @robertoffmoura for the contribution!

### Fixed
- Fix `mpl_to_plotly` tick marker mirroring, axis positioning, and trace visibility for twinned and overlaid subplots (such as `twinx` and `twiny`) [[#5310](https://github.com/plotly/plotly.py/pull/5310)], with thanks to @robertoffmoura for the contribution!
Expand All @@ -21,6 +22,7 @@ This project adheres to [Semantic Versioning](http://semver.org/).
- Fix `convert_dash` mapping dotted lines (`linestyle=':'`) to invalid `'circle'` in Plotly, correctly converting them to `'dot'` and preserving custom dash patterns as pixel lists, [[#5719](https://github.com/plotly/plotly.py/pull/5719)] with thanks to @robertoffmoura 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 [[#5766](https://github.com/plotly/plotly.py/pull/5766)], with thanks to @Irahan2 for the contribution!
- 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 `mpl_to_plotly` drawing bars and filled areas over lines by ordering the traces of each axes by matplotlib `zorder` [[#5795](https://github.com/plotly/plotly.py/pull/5795)], with thanks to @robertoffmoura for the contribution!

## [7.1.0] - 2026-09-15

Expand Down
1 change: 1 addition & 0 deletions plotly/matplotlylib/mpltools.py
Original file line number Diff line number Diff line change
Expand Up @@ -329,6 +329,7 @@ def get_markerstyle_from_collection(props):
# markersize=convert_size_array(props['styles']['size']), # TODO!
markersize=convert_size_array(props["mplobj"].get_sizes()),
edgecolor=convert_rgba_array(props["styles"]["edgecolor"]),
zorder=props["styles"]["zorder"],
)
return markerstyle

Expand Down
270 changes: 208 additions & 62 deletions plotly/matplotlylib/renderer.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,106 @@ def _per_path(values, i, default):
return values[i % n] if n else default


# In SVG paths, codes carry different numbers of vertices:
# M/L: 1, C: 3 (cubic curve), S: 2 (smooth/quad curve), Z: 0.
_VERTICES_PER_CODE = {"M": 1, "L": 1, "C": 3, "S": 2, "Z": 0}


def _split_subpaths(verts, codes):
"""Split SVG path vertices into subpaths, one per "M" code.

Returns a list of (vertices, closed) pairs; `closed` is True for
subpaths that have a "Z" code.
"""
subpaths = []
current = []
closed = False
vi = 0
for c in codes:
step = _VERTICES_PER_CODE.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))
return subpaths


def _bezier_point(points, t):
"""Return the point at parameter `t` of the Bezier curve with the given
control points (de Casteljau's algorithm)."""
points = [tuple(p) for p in points]
while len(points) > 1:
points = [
((1 - t) * a[0] + t * b[0], (1 - t) * a[1] + t * b[1])
for a, b in zip(points, points[1:])
]
return points[0]


def _flatten_curves(verts, codes, samples=16):
"""Return the path vertices and codes with Bezier curves as line segments.

Each cubic ("C") or quadratic ("S") curve is sampled at `samples` points
along the curve, ending at its end point, and each sample becomes an "L"
segment. Other codes keep their vertices.
"""
flat_verts = []
flat_codes = []
vi = 0
for c in codes:
step = _VERTICES_PER_CODE.get(c, 1)
points = list(verts[vi : vi + step])
vi += step
if c in ("C", "S") and flat_verts:
control = [flat_verts[-1]] + points
for k in range(1, samples + 1):
flat_verts.append(_bezier_point(control, k / samples))
flat_codes.append("L")
else:
flat_verts.extend(points)
flat_codes.append(c)
return flat_verts, flat_codes


def _close_subpaths(verts, codes):
"""Return the path vertices with each closed subpath ending at its start.

Bezier curves are flattened into line segments first. The start vertex
of each closed subpath is appended to it, which draws the closing
segment of the outline. Subpaths that already end at their start vertex
keep their vertices as given.
"""
closed_verts = []
for sub, closed in _split_subpaths(*_flatten_curves(verts, codes)):
if closed and tuple(sub[-1]) != tuple(sub[0]):
sub = sub + [sub[0]]
closed_verts.extend(sub)
return closed_verts


def _legend_label(label):
"""Return the (name, showlegend) pair for a trace from an artist label.

matplotlib marks artists it leaves out of legends with "_nolegend_" and
auto-generated "_childN" labels. Empty labels and labels starting with
"_" give (None, False), which hides the trace from the plotly legend.
Other labels give (label, None), which keeps plotly's default legend
entry.
"""
if not label or (isinstance(label, str) and label.startswith("_")):
return None, False
return label, None


def _convert_collection_dash(linestyle):
"""Convert a matplotlib collection line style to a plotly dash string.

Expand Down Expand Up @@ -97,6 +197,29 @@ def __init__(self):
self._processing_legend = False
self._legend_visible = False
self.axes_list = []
self._trace_zorders = []

def _add_trace(self, trace, zorder):
"""Add a trace to the figure, recording its matplotlib zorder."""
self.plotly_fig.add_trace(trace)
self._trace_zorders.append(zorder)

def _sort_traces_by_zorder(self):
"""Order the current axes' traces by matplotlib zorder.

plotly draws later traces on top and matplotlib draws artists in
ascending zorder, so the traces are stably sorted by zorder. Traces
with equal zorder keep the order they were drawn in.
"""
n = len(self._trace_zorders)
start = len(self.plotly_fig.data) - n
order = sorted(range(n), key=self._trace_zorders.__getitem__)
if order != list(range(n)):
axes_traces = self.plotly_fig.data[start:]
self.plotly_fig.data = self.plotly_fig.data[:start] + tuple(
axes_traces[i] for i in order
)
self._trace_zorders = []

def _convert_x_dates(self, x):
"""Convert x values to date strings when the x-axis is a date axis."""
Expand Down Expand Up @@ -304,6 +427,7 @@ def close_axes(self, ax):

"""
self.draw_bars(self.current_bars)
self._sort_traces_by_zorder()
self.msg += " Closing axes\n"
self.x_is_mpl_date = False

Expand Down Expand Up @@ -433,7 +557,7 @@ def draw_bar(self, coll):
) # TODO ditto
if len(bar["x"]) > 1:
self.msg += " Heck yeah, I drew that bar chart\n"
self.plotly_fig.add_trace(bar)
self._add_trace(bar, trace[0]["zorder"])
if bar_gap is not None:
self.plotly_fig["layout"]["bargap"] = bar_gap
else:
Expand Down Expand Up @@ -532,14 +656,7 @@ def draw_marked_line(self, **props):
),
)
if props["coordinates"] == "data":
label = props["label"]
# matplotlib uses "_nolegend_" and auto-generated "_childN"
# labels for artists that must not appear in a legend
if not label or (isinstance(label, str) and label.startswith("_")):
label = None
showlegend = False
else:
showlegend = None
label, showlegend = _legend_label(props["label"])
marked_line = go.Scatter(
mode=mode,
name=label,
Expand All @@ -553,7 +670,8 @@ def draw_marked_line(self, **props):
)
if self.x_is_mpl_date:
marked_line["x"] = self._convert_x_dates(marked_line["x"])
self.plotly_fig.add_trace(marked_line)
style = props["linestyle"] or props["markerstyle"]
self._add_trace(marked_line, style["zorder"])
self.msg += " Heck yeah, I drew that line\n"
elif props["coordinates"] == "axes" and self._processing_legend:
# dealing with legend graphical elements
Expand Down Expand Up @@ -771,7 +889,7 @@ def _draw_line_collection(self, props):
def flush():
if current_style is not None and grouped_x:
ec, lw, d = current_style
self.plotly_fig.add_trace(
self._add_trace(
go.Scatter(
x=grouped_x,
y=grouped_y,
Expand All @@ -784,38 +902,17 @@ def flush():
),
xaxis="x{0}".format(self.axis_ct),
yaxis="y{0}".format(self.axis_ct),
)
),
props["styles"]["zorder"],
)

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:
for sub, closed in _split_subpaths(verts, codes):
if len(sub) < 2:
continue
# a closed subpath (Z code) must be closed explicitly since
Expand Down Expand Up @@ -850,37 +947,65 @@ def flush():

flush()

def _add_filled_trace(
self,
verts,
codes,
facecolor,
edgecolor,
linewidth,
zorder,
dash=None,
name=None,
showlegend=None,
):
"""Add a scatter trace outlining a path on the current axes.

The path is filled with `facecolor`; a facecolor of "none" gives an
outline-only trace.
"""
verts = _close_subpaths(verts, codes)
self._add_trace(
go.Scatter(
name=name,
showlegend=showlegend,
x=self._convert_x_dates([v[0] for v in verts]),
y=[v[1] for v in verts],
mode="lines",
line=go.scatter.Line(
color=_export_color(edgecolor), width=linewidth, dash=dash
),
fill="toself" if facecolor != "none" else None,
fillcolor=_export_color(facecolor),
xaxis="x{0}".format(self.axis_ct),
yaxis="y{0}".format(self.axis_ct),
),
zorder,
)

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"])

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)
self.plotly_fig.add_trace(
go.Scatter(
x=self._convert_x_dates([v[0] for v in verts]),
y=[v[1] for v in verts],
mode="lines",
line=go.scatter.Line(
color=_export_color(edgecolor), width=linewidth
),
fill="toself",
fillcolor=_export_color(facecolor),
xaxis="x{0}".format(self.axis_ct),
yaxis="y{0}".format(self.axis_ct),
)
self._add_filled_trace(
verts,
codes,
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),
zorder=props["styles"]["zorder"],
)

def draw_path(self, **props):
"""Draw a bar chart path or a matplotlib step patch.
"""Draw a bar chart path, a matplotlib step patch, or another patch.

This function attempts to sort a given path into a collection of
horizontal or vertical bar charts, and draws matplotlib StepPatch
artists as step traces. Most of the actual code takes place in
horizontal or vertical bar charts, draws matplotlib StepPatch
artists as step traces, and draws other patches in data coordinates
as filled scatter traces. Most of the bar chart code takes place in
functions from mpltools.py.

props.keys() -- [
Expand Down Expand Up @@ -908,6 +1033,12 @@ def draw_path(self, **props):
elif isinstance(props["mplobj"], mpatches.StepPatch):
self.msg += " Drawing a step path\n"
self._draw_step_path(props)
elif (
isinstance(props["mplobj"], mpatches.Patch)
and props["coordinates"] == "data"
):
self.msg += " Drawing a filled patch\n"
self._draw_filled_patch(props)
else:
self.msg += " This path isn't a bar, not drawing\n"
warnings.warn(
Expand Down Expand Up @@ -945,13 +1076,8 @@ def _draw_step_path(self, props):
if len(x) < 2:
self.msg += " Step path has fewer than 2 points, not drawing\n"
return
label = props["mplobj"].get_label()
if not label or (isinstance(label, str) and label.startswith("_")):
label = None
showlegend = False
else:
showlegend = None
self.plotly_fig.add_trace(
label, showlegend = _legend_label(props["mplobj"].get_label())
self._add_trace(
go.Scatter(
x=x,
y=y,
Expand All @@ -967,7 +1093,27 @@ def _draw_step_path(self, props):
fillcolor=_export_color(style["facecolor"]),
xaxis="x{0}".format(self.axis_ct),
yaxis="y{0}".format(self.axis_ct),
)
),
style["zorder"],
)

def _draw_filled_patch(self, props):
"""Draw a visible matplotlib patch as a filled scatter trace."""
if not props["mplobj"].get_visible():
self.msg += " Patch is hidden, not drawing\n"
return
style = props["style"]
label, showlegend = _legend_label(props["mplobj"].get_label())
self._add_filled_trace(
props["data"],
props["pathcodes"],
facecolor=style["facecolor"],
edgecolor=style["edgecolor"],
linewidth=style["edgewidth"],
zorder=style["zorder"],
dash=mpltools.convert_dash(style["dasharray"]),
name=label,
showlegend=showlegend,
)

def draw_text(self, **props):
Expand Down
Loading