Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 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
d09ec45
Export the line style of fill polygon outlines
robertoffmoura Oct 23, 2026
41c9fb3
Close fill polygon outlines at their first vertex
robertoffmoura Oct 23, 2026
b5e6ef4
Keep the open polygon test outline inside the axes frame
robertoffmoura Oct 23, 2026
bfdfa1b
Share the SVG subpath parser between line and filled path traces
robertoffmoura Oct 23, 2026
f86f5a1
Export unfilled fill polygons as outline-only traces
robertoffmoura Oct 23, 2026
37110e2
Skip hidden fill polygons
robertoffmoura Oct 23, 2026
c00332a
Convert patches in data coordinates to filled traces
robertoffmoura Oct 23, 2026
d84d3bb
Move fill tests after the custom date tick tests
robertoffmoura Oct 23, 2026
2edef1c
Update changelog for patch conversion
robertoffmoura Oct 23, 2026
5e60ad0
Separate the subpaths of filled traces with None
robertoffmoura Oct 23, 2026
40d0485
Set fillcolor only on filled traces
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
1 change: 1 addition & 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`) 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 Down
235 changes: 179 additions & 56 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 subpaths of a path, 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.
"""
subpaths = []
for sub, closed in _split_subpaths(*_flatten_curves(verts, codes)):
if closed and tuple(sub[-1]) != tuple(sub[0]):
sub = sub + [sub[0]]
subpaths.append(sub)
return subpaths


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 @@ -532,14 +632,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 Down Expand Up @@ -791,31 +884,9 @@ def flush():
# 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 +921,70 @@ def flush():

flush()

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

The subpaths of the path are separated by None. The path is filled
with `facecolor`; a facecolor of "none" gives an outline-only trace.
"""
x = []
y = []
for sub in _close_subpaths(verts, codes):
if x:
x.append(None)
y.append(None)
x.extend(self._convert_x_dates([v[0] for v in sub]))
y.extend(v[1] for v in sub)
filled = facecolor != "none"
self.plotly_fig.add_trace(
go.Scatter(
name=name,
showlegend=showlegend,
x=x,
y=y,
mode="lines",
line=go.scatter.Line(
color=_export_color(edgecolor), width=linewidth, dash=dash
),
fill="toself" if filled else None,
fillcolor=_export_color(facecolor) if filled else None,
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"])
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),
)

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 +1012,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,12 +1055,7 @@ 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
label, showlegend = _legend_label(props["mplobj"].get_label())
self.plotly_fig.add_trace(
go.Scatter(
x=x,
Expand All @@ -970,6 +1075,24 @@ def _draw_step_path(self, props):
)
)

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"],
dash=mpltools.convert_dash(style["dasharray"]),
name=label,
showlegend=showlegend,
)

def draw_text(self, **props):
"""Create an annotation dict for a text obj.

Expand Down
Loading