Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ This project adheres to [Semantic Versioning](http://semver.org/).

## Unreleased

### 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!

### 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!
- Fix `mpl_to_plotly` losing matplotlib axis line colors by exporting the color of each axis's displayed spine to the plotly x and y axis `linecolor` [[#5311](https://github.com/plotly/plotly.py/pull/5311)], with thanks to @robertoffmoura for the contribution!
Expand All @@ -15,6 +18,7 @@ This project adheres to [Semantic Versioning](http://semver.org/).
- 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!
- 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!
- Fix Plotly Express mutating lists passed to the `x` or `y` arguments in wide mode [[#5727](https://github.com/plotly/plotly.py/pull/5727)], with thanks to @cpruijsen for the contribution!
- 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!

Expand Down
6 changes: 0 additions & 6 deletions plotly/matplotlylib/mplexporter/exporter.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,12 +84,6 @@ def process_transform(
Data transformed to match the given coordinate code.
Returned only if data is specified
"""
if isinstance(transform, transforms.BlendedGenericTransform):
warnings.warn(
"Blended transforms not yet supported. "
"Zoom behavior may not work as expected."
)

if force_trans is not None:
if data is not None:
data = (transform - force_trans).transform(data)
Expand Down
50 changes: 29 additions & 21 deletions plotly/matplotlylib/mpltools.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,34 +57,40 @@ def check_corners(inner_obj, outer_obj):
return True


VALID_DASH = {"solid", "dot", "dash", "longdash", "dashdot", "longdashdot"}


def convert_dash(mpl_dash):
"""Convert mpl line symbol to plotly line symbol and return symbol."""
if not mpl_dash:
return "solid"
if mpl_dash in DASH_MAP:
return DASH_MAP[mpl_dash]
else:
dash_array = mpl_dash.split(",")
if mpl_dash in VALID_DASH:
return mpl_dash
if mpl_dash in ("dashed", "--"):
return "dash"
if mpl_dash in ("dotted", ":"):
return "dot"

dash_array = mpl_dash.replace("px", "").replace(" ", "").split(",")
cleaned = ",".join(dash_array)
if cleaned in DASH_MAP:
return DASH_MAP[cleaned]

if len(dash_array) < 2:
return "solid"
if len(dash_array) < 2:
return "solid"

# Catch the exception where the off length is zero, in case
# matplotlib 'solid' changes from '10,0' to 'N,0'
if math.isclose(float(dash_array[1]), 0.0):
return "solid"
try:
off = float(dash_array[1])
except ValueError:
return "solid"

# If we can't find the dash pattern in the map, convert it
# into custom values in px, e.g. '7,5' -> '7px,5px'
dashpx = ",".join([x + "px" for x in dash_array])
if math.isclose(off, 0.0):
return "solid"

# TODO: rewrite the convert_dash code
# only strings 'solid', 'dashed', etc allowed
if dashpx == "7.4px,3.2px":
dashpx = "dashed"
elif dashpx == "12.8px,3.2px,2.0px,3.2px":
dashpx = "dashdot"
elif dashpx == "2.0px,3.3px":
dashpx = "dotted"
return dashpx
# Convert custom dash pattern into px list (e.g. '7,5' -> '7px,5px')
return ",".join([x + "px" for x in dash_array])


def convert_path(path):
Expand Down Expand Up @@ -603,10 +609,12 @@ def mpl_dates_to_datestrings(dates, mpl_formatter):
DASH_MAP = {
"10,0": "solid",
"6,6": "dash",
"2,2": "circle",
"2,2": "dot",
"4,4,2,4": "dashdot",
"none": "solid",
"7.4,3.2": "dash",
"12.8,3.2,2.0,3.2": "dashdot",
"2.0,3.3": "dot",
}

PATH_MAP = {
Expand Down
128 changes: 125 additions & 3 deletions plotly/matplotlylib/renderer.py
Comment thread
robertoffmoura marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,21 @@

"""

import math
import warnings

import matplotlib.patches as mpatches
from matplotlib import lines as mlines
from matplotlib import transforms
import plotly.graph_objs as go
from plotly.matplotlylib.mplexporter import Renderer
from plotly.matplotlylib import mpltools


from plotly.matplotlylib.mpltools import _export_color

# Artist class created by ``Axes.axline``: ``AxLine`` in matplotlib >= 3.8,
# ``_AxLine`` in earlier versions.
_AXLINE_CLASS = getattr(mlines, "AxLine", None) or getattr(mlines, "_AxLine", ())


def _per_path(values, i, default):
"""Return the style value for the i-th path of a path collection.
Expand Down Expand Up @@ -550,9 +555,11 @@ def draw_marked_line(self, **props):
marked_line["x"] = self._convert_x_dates(marked_line["x"])
self.plotly_fig.add_trace(marked_line)
self.msg += " Heck yeah, I drew that line\n"
elif props["coordinates"] == "axes":
elif props["coordinates"] == "axes" and self._processing_legend:
# dealing with legend graphical elements
self.msg += " Using native legend\n"
elif self._is_axes_reference_line(props) and self._draw_axes_line(props):
pass
else:
self.msg += " Line didn't have 'data' coordinates, not drawing\n"
warnings.warn(
Expand All @@ -561,6 +568,121 @@ def draw_marked_line(self, **props):
"coordinates!"
)

def _is_axes_reference_line(self, props):
"""Check whether a line qualifies as an axes-coordinate reference line
(e.g. axhline, axvline, horizontal/vertical axline, or a 2-point
segment drawn with ``transform=ax.transAxes``) that can be drawn as a
2-point layout shape."""
if not props.get("linestyle") or len(props.get("data", [])) != 2:
return False
if props["coordinates"] == "axes" and not self._processing_legend:
return True
if props["coordinates"] == "display" and isinstance(
props["mplobj"].get_transform(), transforms.BlendedGenericTransform
):
return True
return False

def _draw_axes_line(self, props):
"""Draw an axes-coordinate reference line as a layout shape.

axhline/axvline and horizontal/vertical axline span the axes domain
along one axis and sit at a data value on the other. Segments in axes
coordinates keep their exact endpoints in axes domain coordinates.
Returns True when a shape is drawn."""
if not props.get("linestyle") or len(props.get("data", [])) != 2:
return False
ax = self.current_mpl_ax
line = props["mplobj"]
trans = line.get_transform()

axis_suffix = str(self.axis_ct) if self.axis_ct > 1 else ""
x_axis = "x{0}".format(axis_suffix)
y_axis = "y{0}".format(axis_suffix)
x_domain = "{0} domain".format(x_axis).strip()
y_domain = "{0} domain".format(y_axis).strip()

if isinstance(trans, transforms.BlendedGenericTransform) and (
trans == ax.get_yaxis_transform() or trans._x == ax.transAxes
):
# axhline: x spans the axes domain [xmin, xmax], y is in data coordinates
x_data = line.get_xdata(orig=False)
y_data = line.get_ydata(orig=False)
x0, x1 = float(x_data[0]), float(x_data[1])
y0, y1 = float(y_data[0]), float(y_data[1])
xref = x_domain
yref = y_axis
elif isinstance(trans, transforms.BlendedGenericTransform) and (
trans == ax.get_xaxis_transform() or trans._y == ax.transAxes
):
# axvline: x is in data coordinates, y spans the axes domain [ymin, ymax]
x_data = line.get_xdata(orig=False)
y_data = line.get_ydata(orig=False)
x0, x1 = float(x_data[0]), float(x_data[1])
y0, y1 = float(y_data[0]), float(y_data[1])
if self.x_is_mpl_date:
x0, x1 = self._convert_x_dates([x0, x1])
xref = x_axis
yref = y_domain
elif props["coordinates"] == "axes" and not isinstance(line, _AXLINE_CLASS):
# Segment fixed to the axes (e.g. transform=ax.transAxes): props["data"]
# holds its endpoints as axes fractions, which map directly onto the
# axes domain, so the segment keeps its extent and stays put on pan/zoom
(x0, y0), (x1, y1) = [(float(x), float(y)) for x, y in props["data"]]
xref = x_domain
yref = y_domain
else:
# general reference line (e.g. horizontal or vertical axline)
if props["coordinates"] == "display":
px_points = props["data"]
elif props["coordinates"] == "axes":
px_points = [ax.transAxes.transform(pt) for pt in props["data"]]
else:
px_points = [trans.transform(pt) for pt in props["data"]]
(x0, y0), (x1, y1) = [
ax.transData.inverted().transform(pt) for pt in px_points
]

dx = x1 - x0
dy = y1 - y0
if math.isclose(dy, 0.0, abs_tol=1e-12):
# Horizontal line: use x domain so it spans the chart, y in data coordinates
x0, x1 = 0.0, 1.0
y0, y1 = float(y0), float(y1)
xref = x_domain
yref = y_axis
elif math.isclose(dx, 0.0, abs_tol=1e-12):
# Vertical line: use y domain so it spans the chart, x in data coordinates
y0, y1 = 0.0, 1.0
if self.x_is_mpl_date:
x0, x1 = self._convert_x_dates([x0, x1])
else:
x0, x1 = float(x0), float(x1)
xref = x_axis
yref = y_domain
else:
return False

color = _export_color(props["linestyle"]["color"], props["linestyle"]["alpha"])
shape = go.layout.Shape(
type="line",
x0=x0,
y0=y0,
x1=x1,
y1=y1,
xref=xref,
yref=yref,
line=go.layout.shape.Line(
color=color,
width=props["linestyle"]["linewidth"],
dash=mpltools.convert_dash(props["linestyle"]["dasharray"]),
),
layer="above",
)
self.plotly_fig["layout"]["shapes"] += (shape,)
self.msg += " Heck yeah, I drew that reference line\n"
return True

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

Expand Down
Loading
Loading