Skip to content

Commit 861395e

Browse files
Drop support for diagonal axline shapes
1 parent 7902257 commit 861395e

3 files changed

Lines changed: 30 additions & 166 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ This project adheres to [Semantic Versioning](http://semver.org/).
55
## Unreleased
66

77
### Added
8-
- 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 [[#5719](https://github.com/plotly/plotly.py/pull/5719)], with thanks to @robertoffmoura for the contribution!
8+
- 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!
99

1010
### Fixed
1111
- 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!

‎plotly/matplotlylib/renderer.py‎

Lines changed: 14 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -7,26 +7,22 @@
77
88
"""
99

10-
import datetime
1110
import math
1211
import warnings
1312

1413
import matplotlib.patches as mpatches
15-
from matplotlib import dates as mdates
1614
from matplotlib import lines as mlines
1715
from matplotlib import transforms
1816
import plotly.graph_objs as go
1917
from plotly.matplotlylib.mplexporter import Renderer
2018
from plotly.matplotlylib import mpltools
19+
from plotly.matplotlylib.mpltools import _export_color
2120

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

2625

27-
from plotly.matplotlylib.mpltools import _export_color
28-
29-
3026
def _per_path(values, i, default):
3127
"""Return the style value for the i-th path of a path collection.
3228
@@ -562,8 +558,8 @@ def draw_marked_line(self, **props):
562558
elif props["coordinates"] == "axes" and self._processing_legend:
563559
# dealing with legend graphical elements
564560
self.msg += " Using native legend\n"
565-
elif self._is_axes_reference_line(props):
566-
self._draw_axes_line(props)
561+
elif self._is_axes_reference_line(props) and self._draw_axes_line(props):
562+
pass
567563
else:
568564
self.msg += " Line didn't have 'data' coordinates, not drawing\n"
569565
warnings.warn(
@@ -574,8 +570,9 @@ def draw_marked_line(self, **props):
574570

575571
def _is_axes_reference_line(self, props):
576572
"""Check whether a line qualifies as an axes-coordinate reference line
577-
(e.g. axhline, axvline, axline, or a 2-point segment drawn with
578-
``transform=ax.transAxes``) that can be drawn as a 2-point layout shape."""
573+
(e.g. axhline, axvline, horizontal/vertical axline, or a 2-point
574+
segment drawn with ``transform=ax.transAxes``) that can be drawn as a
575+
2-point layout shape."""
579576
if not props.get("linestyle") or len(props.get("data", [])) != 2:
580577
return False
581578
if props["coordinates"] == "axes" and not self._processing_legend:
@@ -589,12 +586,12 @@ def _is_axes_reference_line(self, props):
589586
def _draw_axes_line(self, props):
590587
"""Draw an axes-coordinate reference line as a layout shape.
591588
592-
axhline/axvline span their axes-fraction extent along one axis and sit
593-
at a data value on the other. Segments in axes coordinates keep their
594-
exact endpoints in axes domain coordinates. axline is extended in data
595-
coordinates."""
589+
axhline/axvline and horizontal/vertical axline span the axes domain
590+
along one axis and sit at a data value on the other. Segments in axes
591+
coordinates keep their exact endpoints in axes domain coordinates.
592+
Returns True when a shape is drawn."""
596593
if not props.get("linestyle") or len(props.get("data", [])) != 2:
597-
return
594+
return False
598595
ax = self.current_mpl_ax
599596
line = props["mplobj"]
600597
trans = line.get_transform()
@@ -635,7 +632,7 @@ def _draw_axes_line(self, props):
635632
xref = x_domain
636633
yref = y_domain
637634
else:
638-
# general reference line (e.g. axline)
635+
# general reference line (e.g. horizontal or vertical axline)
639636
if props["coordinates"] == "display":
640637
px_points = props["data"]
641638
elif props["coordinates"] == "axes":
@@ -664,38 +661,7 @@ def _draw_axes_line(self, props):
664661
xref = x_axis
665662
yref = y_domain
666663
else:
667-
# Diagonal line: extend endpoints in data coordinates so it spans
668-
# across zoom levels while staying locked to data coordinates on pan/zoom
669-
extension_factor = 100.0
670-
x0_ext = x0 - extension_factor * dx
671-
y0_ext = y0 - extension_factor * dy
672-
x1_ext = x1 + extension_factor * dx
673-
y1_ext = y1 + extension_factor * dy
674-
675-
if self.x_is_mpl_date:
676-
min_date_num = float(mdates.date2num(datetime.datetime(1, 1, 1)))
677-
max_date_num = float(
678-
mdates.date2num(datetime.datetime(9999, 12, 31))
679-
)
680-
slope = dy / dx
681-
if x0_ext < min_date_num:
682-
y0_ext = y0 + slope * (min_date_num - x0)
683-
x0_ext = min_date_num
684-
elif x0_ext > max_date_num:
685-
y0_ext = y0 + slope * (max_date_num - x0)
686-
x0_ext = max_date_num
687-
if x1_ext > max_date_num:
688-
y1_ext = y1 + slope * (max_date_num - x1)
689-
x1_ext = max_date_num
690-
elif x1_ext < min_date_num:
691-
y1_ext = y1 + slope * (min_date_num - x1)
692-
x1_ext = min_date_num
693-
x0, x1 = self._convert_x_dates([x0_ext, x1_ext])
694-
else:
695-
x0, x1 = float(x0_ext), float(x1_ext)
696-
y0, y1 = float(y0_ext), float(y1_ext)
697-
xref = x_axis
698-
yref = y_axis
664+
return False
699665

700666
color = _export_color(props["linestyle"]["color"], props["linestyle"]["alpha"])
701667
shape = go.layout.Shape(
@@ -715,6 +681,7 @@ def _draw_axes_line(self, props):
715681
)
716682
self.plotly_fig["layout"]["shapes"] += (shape,)
717683
self.msg += " Heck yeah, I drew that reference line\n"
684+
return True
718685

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

‎plotly/matplotlylib/tests/test_renderer.py‎

Lines changed: 15 additions & 118 deletions
Original file line numberDiff line numberDiff line change
@@ -876,123 +876,8 @@ def test_axvline_converts():
876876
assert shape.yref == "y domain"
877877

878878

879-
def test_axline_converts():
880-
"""axline converts to a layout shape extended in data coordinates."""
881-
fig, ax = plt.subplots()
882-
ax.axline((0.5, 0.5), slope=1)
883-
884-
plotly_fig = tls.mpl_to_plotly(fig)
885-
886-
assert len(plotly_fig.data) == 0
887-
assert len(plotly_fig.layout.shapes) == 1
888-
shape = plotly_fig.layout.shapes[0]
889-
assert shape.type == "line"
890-
assert shape.xref == "x"
891-
assert shape.yref == "y"
892-
x_min, x_max = ax.get_xlim()
893-
assert shape.x0 < x_min
894-
assert shape.x1 > x_max
895-
slope = (shape.y1 - shape.y0) / (shape.x1 - shape.x0)
896-
assert abs(slope - 1.0) < 1e-9
897-
# Passes through (0.5, 0.5)
898-
y_at_05 = shape.y0 + slope * (0.5 - shape.x0)
899-
assert abs(y_at_05 - 0.5) < 1e-9
900-
901-
902-
def test_axline_arbitrary_slope_and_limits():
903-
"""axline with non-trivial slopes and limits extends in data coordinates."""
904-
fig, ax = plt.subplots()
905-
ax.scatter([1, 2, 4, 7, 9], [2, 5, 4, 8, 10])
906-
ax.axline((0, 1), slope=1.0)
907-
ax.axline((1, 8), (8, 2))
908-
ax.set_xlim(0, 10)
909-
ax.set_ylim(0, 12)
910-
911-
plotly_fig = tls.mpl_to_plotly(fig)
912-
shapes = plotly_fig.layout.shapes
913-
assert len(shapes) == 2
914-
915-
# Line 1: (0, 1), slope 1, xlim [0, 10], ylim [0, 12]
916-
assert shapes[0].xref == "x"
917-
assert shapes[0].yref == "y"
918-
assert shapes[0].x0 < -500
919-
assert shapes[0].x1 > 500
920-
slope1 = (shapes[0].y1 - shapes[0].y0) / (shapes[0].x1 - shapes[0].x0)
921-
assert abs(slope1 - 1.0) < 1e-9
922-
y_at_0 = shapes[0].y0 + slope1 * (0.0 - shapes[0].x0)
923-
assert abs(y_at_0 - 1.0) < 1e-9
924-
925-
# Line 2: (1, 8) to (8, 2) with slope -6/7
926-
assert shapes[1].xref == "x"
927-
assert shapes[1].yref == "y"
928-
assert shapes[1].x0 < -500
929-
assert shapes[1].x1 > 500
930-
slope2 = (shapes[1].y1 - shapes[1].y0) / (shapes[1].x1 - shapes[1].x0)
931-
assert abs(slope2 - (-6.0 / 7.0)) < 1e-9
932-
y_at_1 = shapes[1].y0 + slope2 * (1.0 - shapes[1].x0)
933-
assert abs(y_at_1 - 8.0) < 1e-9
934-
935-
936-
def _shape_x_datenums(shape):
937-
"""Return a shape's date-string x endpoints as matplotlib date numbers."""
938-
return [
939-
matplotlib.dates.date2num(datetime.datetime.fromisoformat(x))
940-
for x in (shape.x0, shape.x1)
941-
]
942-
943-
944-
def test_axline_on_pre_1970_date_axis():
945-
"""axline on a date axis before the matplotlib epoch (1970) extends on both
946-
sides of the data and stays on the original line."""
947-
dates = [datetime.datetime(1950, 1, i) for i in range(1, 10)]
948-
fig, ax = plt.subplots()
949-
ax.plot(dates, range(len(dates)))
950-
x_ref = matplotlib.dates.date2num(dates[0])
951-
ax.axline((x_ref, 0), slope=1)
952-
953-
plotly_fig = tls.mpl_to_plotly(fig)
954-
shapes = plotly_fig.layout.shapes
955-
assert len(shapes) == 1
956-
shape = shapes[0]
957-
assert shape.xref == "x"
958-
assert shape.yref == "y"
959-
960-
n0, n1 = _shape_x_datenums(shape)
961-
x_min, x_max = ax.get_xlim()
962-
assert n0 < x_min
963-
assert n1 > x_max
964-
slope = (shape.y1 - shape.y0) / (n1 - n0)
965-
assert abs(slope - 1.0) < 1e-6
966-
assert abs(shape.y0 + slope * (x_ref - n0)) < 1e-6
967-
968-
969-
def test_axline_on_date_axis_clamps_to_matplotlib_date_range():
970-
"""When extending a diagonal axline would leave matplotlib's supported date
971-
range (years 0001-9999), its endpoints stop at the range limits while
972-
staying on the original line."""
973-
dates = [datetime.datetime(1900, 1, 1), datetime.datetime(2000, 1, 1)]
974-
fig, ax = plt.subplots()
975-
ax.plot(dates, [0, 1])
976-
# Nearly flat line crossing the whole century, so the 100x extension of the
977-
# visible segment reaches past both year 0001 and year 9999
978-
x_ref = matplotlib.dates.date2num(dates[0])
979-
ax.axline((x_ref, 0.5), slope=1e-6)
980-
981-
plotly_fig = tls.mpl_to_plotly(fig)
982-
shape = plotly_fig.layout.shapes[0]
983-
assert isinstance(shape.x0, str)
984-
assert isinstance(shape.x1, str)
985-
assert shape.x0.startswith("0001-01-01")
986-
assert shape.x1.startswith("9999-12-31")
987-
988-
n0, n1 = _shape_x_datenums(shape)
989-
slope = (shape.y1 - shape.y0) / (n1 - n0)
990-
assert abs(slope - 1e-6) < 1e-12
991-
assert abs(shape.y0 + slope * (x_ref - n0) - 0.5) < 1e-6
992-
993-
994879
def test_axline_horizontal_and_vertical():
995-
"""Horizontal and vertical axline use domain coordinates appropriately."""
880+
"""Horizontal and vertical axline use domain coordinates on the spanned axis."""
996881
fig, ax = plt.subplots()
997882
ax.axline((0, 5), slope=0)
998883
ax.axline((3, 0), (3, 10))
@@ -1018,9 +903,21 @@ def test_axline_horizontal_and_vertical():
1018903
assert abs(shapes[1].y1 - 1.0) < 1e-9
1019904

1020905

906+
def test_diagonal_axline_warns_and_is_skipped():
907+
"""Diagonal axline emits a UserWarning for non-data Line2D coordinates."""
908+
fig, ax = plt.subplots()
909+
ax.axline((0.5, 0.5), slope=1)
910+
911+
with pytest.warns(UserWarning, match="Line2D objects from matplotlib"):
912+
plotly_fig = tls.mpl_to_plotly(fig)
913+
914+
assert len(plotly_fig.data) == 0
915+
assert len(plotly_fig.layout.shapes) == 0
916+
917+
1021918
def test_axes_coordinate_segments_keep_their_endpoints():
1022-
"""Two-point lines in axes coordinates (not axline) convert to shapes in
1023-
axes domain coordinates with their exact endpoints, without extension."""
919+
"""Two-point lines in axes coordinates convert to shapes in axes domain
920+
coordinates with their exact endpoints."""
1024921
fig, ax = plt.subplots()
1025922
ax.set_xlim(0, 10)
1026923
ax.set_ylim(0, 10)

0 commit comments

Comments
 (0)