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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,11 @@ This project adheres to [Semantic Versioning](http://semver.org/).

## Unreleased

### Added
- Add support for converting matplotlib reference lines (`axhline`, `axvline`, and `axline`) as well as axes-coordinate lines into Plotly layout shapes in `mpl_to_plotly`, including support for date axes, with thanks to @robertoffmoura for the contribution!

### Fixed
- 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, with thanks to @robertoffmoura for the contribution!
- Fix concurrent first access to lazily initialized graph object properties, which could raise `ValueError("Invalid value")` [[#3441](https://github.com/plotly/plotly.py/issues/3441)], with thanks to @hb1915 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 [[#5765](https://github.com/plotly/plotly.py/issues/5765)], with thanks to @Irahan2 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 @@ -55,34 +55,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 @@ -570,10 +576,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
159 changes: 158 additions & 1 deletion 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,12 +7,21 @@

"""

import datetime
import math
import warnings

from matplotlib import dates as mdates
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

# 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 _export_color(color):
"""Export a matplotlib color for use as a plotly color.
Expand Down Expand Up @@ -458,9 +467,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):
self._draw_axes_line(props)
else:
self.msg += " Line didn't have 'data' coordinates, not drawing\n"
warnings.warn(
Expand All @@ -469,6 +480,152 @@ 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, 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 span their axes-fraction extent along one axis and sit
at a data value on the other. Segments in axes coordinates keep their
exact endpoints in axes domain coordinates. axline is extended in data
coordinates."""
if not props.get("linestyle") or len(props.get("data", [])) != 2:
return
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. 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:
# Diagonal line: extend endpoints in data coordinates so it spans
# across zoom levels while staying locked to data coordinates on pan/zoom
extension_factor = 100.0
x0_ext = x0 - extension_factor * dx
y0_ext = y0 - extension_factor * dy
x1_ext = x1 + extension_factor * dx
y1_ext = y1 + extension_factor * dy

if self.x_is_mpl_date:
min_date_num = float(mdates.date2num(datetime.datetime(1, 1, 1)))
max_date_num = float(
mdates.date2num(datetime.datetime(9999, 12, 31))
)
slope = dy / dx
if x0_ext < min_date_num:
y0_ext = y0 + slope * (min_date_num - x0)
x0_ext = min_date_num
elif x0_ext > max_date_num:
y0_ext = y0 + slope * (max_date_num - x0)
x0_ext = max_date_num
if x1_ext > max_date_num:
y1_ext = y1 + slope * (max_date_num - x1)
x1_ext = max_date_num
elif x1_ext < min_date_num:
y1_ext = y1 + slope * (min_date_num - x1)
x1_ext = min_date_num
x0, x1 = self._convert_x_dates([x0_ext, x1_ext])
else:
x0, x1 = float(x0_ext), float(x1_ext)
y0, y1 = float(y0_ext), float(y1_ext)
xref = x_axis
yref = y_axis

color = mpltools.merge_color_and_opacity(
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"

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

Expand Down
Loading