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 @@ -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 reference spans (`axhspan` and `axvspan`) into Plotly layout shapes in `mpl_to_plotly`, with axes-fraction extents (`xmin`/`xmax`, `ymin`/`ymax`) on the axis domain, support for date and log axes, and legend entries for labeled spans, 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
116 changes: 116 additions & 0 deletions plotly/matplotlylib/renderer.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,49 @@ def _convert_collection_dash(linestyle):
return ",".join("{0:g}px".format(d) for d in dashes)


def _find_blended_transform(transform):
"""Return the first blended transform within a matplotlib transform, or
None when it contains no blended transform."""
if isinstance(
transform, (transforms.BlendedGenericTransform, transforms.BlendedAffine2D)
):
return transform
if hasattr(transform, "_a") and hasattr(transform, "_b"):
blended = _find_blended_transform(transform._a)
if blended is None:
blended = _find_blended_transform(transform._b)
return blended
if hasattr(transform, "_transform"):
return _find_blended_transform(transform._transform)
return None


def _has_blended_transform(transform):
"""Check whether a matplotlib transform contains a blended transform."""
return _find_blended_transform(transform) is not None


def _is_axis_aligned_rectangle(points):
"""Check whether polygon vertices trace an axis-aligned rectangle: every
vertex is a corner of the bounding box and all four corners appear."""
xs = [float(x) for x, _ in points]
ys = [float(y) for _, y in points]
if not xs:
return False
x_min, x_max = min(xs), max(xs)
y_min, y_max = min(ys), max(ys)
corners = set()
for x, y in zip(xs, ys):
at_x_min = math.isclose(x, x_min, abs_tol=1e-9)
at_x_max = math.isclose(x, x_max, abs_tol=1e-9)
at_y_min = math.isclose(y, y_min, abs_tol=1e-9)
at_y_max = math.isclose(y, y_max, abs_tol=1e-9)
if not (at_x_min or at_x_max) or not (at_y_min or at_y_max):
return False
corners.add((at_x_max, at_y_max))
return len(corners) == 4


class PlotlyRenderer(Renderer):
"""A renderer class inheriting from base for rendering mpl plots in plotly.

Expand Down Expand Up @@ -683,6 +726,73 @@ def _draw_axes_line(self, props):
self.msg += " Heck yeah, I drew that reference line\n"
return True

def _is_axes_span(self, props):
"""Check whether a path qualifies as a reference span (e.g. axhspan,
axvspan): an axis-aligned rectangle in a blended axes/data
transform."""
if not isinstance(props["mplobj"], (mpatches.Rectangle, mpatches.Polygon)):
return False
if not (
props["coordinates"] == "display"
and _has_blended_transform(props["mplobj"].get_transform())
):
return False
return _is_axis_aligned_rectangle(props["data"])

def _draw_axes_span(self, props):
"""Draw a reference span (axhspan/axvspan) as a layout shape.

Sides given in axes fractions (e.g. axhspan xmin/xmax) map onto the
axis domain, so they stay fixed to the axes on pan/zoom; sides given
in data coordinates map onto the data axis."""
ax = self.current_mpl_ax
px_points = props["data"]
blended = _find_blended_transform(props["mplobj"].get_transform())
x_in_axes = blended._x == ax.transAxes
y_in_axes = blended._y == ax.transAxes
axes_points = ax.transAxes.inverted().transform(px_points)
data_points = ax.transData.inverted().transform(px_points)
xs = (axes_points if x_in_axes else data_points)[:, 0]
ys = (axes_points if y_in_axes else data_points)[:, 1]
x0, x1 = float(min(xs)), float(max(xs))
y0, y1 = float(min(ys)), float(max(ys))
if self.x_is_mpl_date and not x_in_axes:
x0, x1 = self._convert_x_dates([x0, x1])
axis_suffix = str(self.axis_ct) if self.axis_ct > 1 else ""
xref = "x{0}".format(axis_suffix)
yref = "y{0}".format(axis_suffix)
if x_in_axes:
xref += " domain"
if y_in_axes:
yref += " domain"
style = props["style"]
fillcolor = _export_color(style["facecolor"])
edgecolor = _export_color(style["edgecolor"])
linewidth = style["edgewidth"] if style["edgecolor"] != "none" else 0
label = props["mplobj"].get_label()
if not label or (isinstance(label, str) and label.startswith("_")):
label = None
shape = go.layout.Shape(
type="rect",
x0=x0,
y0=y0,
x1=x1,
y1=y1,
xref=xref,
yref=yref,
fillcolor=fillcolor,
line=go.layout.shape.Line(
color=edgecolor,
width=linewidth,
dash=mpltools.convert_dash(style["dasharray"]),
),
layer="below" if style.get("zorder", 1) < 2 else "above",
name=label,
showlegend=True if label is not None else None,
)
self.plotly_fig["layout"]["shapes"] += (shape,)
self.msg += " Heck yeah, I drew that reference span\n"

def draw_image(self, **props):
"""Draw image.

Expand Down Expand Up @@ -908,6 +1018,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 self._is_axes_span(props):
if self._processing_legend:
self.msg += " Using native legend\n"
else:
self.msg += " Drawing an axes span\n"
self._draw_axes_span(props)
else:
self.msg += " This path isn't a bar, not drawing\n"
warnings.warn(
Expand Down
149 changes: 149 additions & 0 deletions plotly/matplotlylib/tests/test_renderer.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import matplotlib
import matplotlib.pyplot as plt
import matplotlib.dates as mdates
import matplotlib.patches as mpatches
from packaging.version import Version
from matplotlib import transforms
import plotly.tools as tls
Expand Down Expand Up @@ -1392,3 +1393,151 @@ def test_mixed_style_lines_group_consecutive_matches():
else:
# older matplotlib emits one collection per level
assert dashes == ["5.55px,2.4px"] * 2 + ["solid"] * 2


def test_axhspan_converts():
"""axhspan converts to a layout shape spanning the x axis domain."""
fig, ax = plt.subplots()
ax.axhspan(0.25, 0.75)

plotly_fig = tls.mpl_to_plotly(fig)

assert len(plotly_fig.data) == 0
assert len(plotly_fig.layout.shapes) == 1
shape = plotly_fig.layout.shapes[0]
assert shape.type == "rect"
assert abs(shape.x0 - 0) < 1e-9
assert abs(shape.x1 - 1) < 1e-9
assert abs(shape.y0 - 0.25) < 1e-9
assert abs(shape.y1 - 0.75) < 1e-9
assert shape.xref == "x domain"
assert shape.yref == "y"
assert shape.fillcolor == "#1F77B4"
assert shape.layer == "below"


def test_axvspan_converts():
"""axvspan converts to a layout shape spanning the y axis domain."""
fig, ax = plt.subplots()
ax.axvspan(0.25, 0.75)

plotly_fig = tls.mpl_to_plotly(fig)

assert len(plotly_fig.data) == 0
assert len(plotly_fig.layout.shapes) == 1
shape = plotly_fig.layout.shapes[0]
assert shape.type == "rect"
assert abs(shape.x0 - 0.25) < 1e-9
assert abs(shape.x1 - 0.75) < 1e-9
assert abs(shape.y0 - 0) < 1e-9
assert abs(shape.y1 - 1) < 1e-9
assert shape.xref == "x"
assert shape.yref == "y domain"
assert shape.fillcolor == "#1F77B4"
assert shape.layer == "below"


def test_axhspan_axes_fractions_convert():
"""axhspan xmin/xmax convert to x axis domain fractions and axvspan
ymin/ymax convert to y axis domain fractions."""
fig, ax = plt.subplots()
ax.plot([0, 10], [0, 10])
ax.axhspan(2, 4, xmin=0.2, xmax=0.6)
ax.axvspan(6, 8, ymin=0.1, ymax=0.5)

plotly_fig = tls.mpl_to_plotly(fig)

hspan, vspan = plotly_fig.layout.shapes
assert hspan.xref == "x domain"
assert hspan.yref == "y"
assert np.allclose([hspan.x0, hspan.x1, hspan.y0, hspan.y1], [0.2, 0.6, 2, 4])
assert vspan.xref == "x"
assert vspan.yref == "y domain"
assert np.allclose([vspan.x0, vspan.x1, vspan.y0, vspan.y1], [6, 8, 0.1, 0.5])


def test_axhspan_on_second_subplot_references_its_domain():
"""Spans on a second subplot reference that subplot's axis domain."""
fig, (ax1, ax2) = plt.subplots(1, 2)
ax2.axhspan(2, 4)

plotly_fig = tls.mpl_to_plotly(fig)

(shape,) = plotly_fig.layout.shapes
assert shape.xref == "x2 domain"
assert shape.yref == "y2"


def test_axhspan_with_custom_style_converts():
"""axhspan with custom colors and alpha converts to a styled layout shape."""
fig, ax = plt.subplots()
ax.axhspan(0.25, 0.75, facecolor="red", edgecolor="blue", alpha=0.5, linestyle="--")

plotly_fig = tls.mpl_to_plotly(fig)

assert len(plotly_fig.data) == 0
assert len(plotly_fig.layout.shapes) == 1
shape = plotly_fig.layout.shapes[0]
assert shape.type == "rect"
assert shape.fillcolor == "rgba(255, 0, 0, 0.5)"
assert shape.line.color == "rgba(0, 0, 255, 0.5)"
assert shape.line.dash == "dash"
assert shape.line.width == 1.0


def test_blended_unaligned_shapes_do_not_convert_to_spans():
"""A triangle and a rotated rectangle in a blended axes/data transform
warn and leave the layout shapes empty."""
fig, ax = plt.subplots()
ax.plot([0, 10], [0, 10])
ax.add_patch(
mpatches.Polygon(
[[5, 0.1], [9, 0.1], [7, 0.5]],
transform=ax.get_xaxis_transform(),
color="b",
)
)
ax.add_patch(
mpatches.Rectangle(
(6, 0.6), 2, 0.2, angle=30, transform=ax.get_xaxis_transform(), color="g"
)
)

with pytest.warns(UserWarning, match="path object"):
plotly_fig = tls.mpl_to_plotly(fig)

assert len(plotly_fig.layout.shapes) == 0


def test_axvspan_on_date_xaxis_converts_to_dates():
"""axvspan on a date x axis exports its x extent as date strings."""
dates = [datetime.datetime(2023, 1, i) for i in range(1, 10)]
fig, ax = plt.subplots()
ax.plot(dates, range(len(dates)))
ax.axvspan(dates[2], dates[5])
ax.axhspan(2, 4)

plotly_fig = tls.mpl_to_plotly(fig)

vspan, hspan = plotly_fig.layout.shapes
assert vspan.xref == "x"
assert vspan.x0.startswith("2023-01-03")
assert vspan.x1.startswith("2023-01-06")
assert hspan.xref == "x domain"
assert np.allclose([hspan.x0, hspan.x1], [0, 1])


def test_labeled_axvspan_shows_in_legend():
"""A labeled span exports its label as the shape legend entry."""
fig, ax = plt.subplots()
ax.plot([0, 10], [0, 10], label="line")
ax.axvspan(2, 4, label="span")
ax.axhspan(6, 8)
ax.legend()

plotly_fig = tls.mpl_to_plotly(fig)

labeled, unlabeled = plotly_fig.layout.shapes
assert labeled.name == "span"
assert labeled.showlegend is True
assert unlabeled.showlegend is None
Loading