Skip to content

Commit b1ba93c

Browse files
Extend axline shapes in data coordinates to span across zoom levels
1 parent f8bc3e8 commit b1ba93c

2 files changed

Lines changed: 118 additions & 11 deletions

File tree

‎plotly/matplotlylib/renderer.py‎

Lines changed: 46 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
88
"""
99

10+
import math
1011
import warnings
1112

1213
from matplotlib import transforms
@@ -528,15 +529,56 @@ def _draw_axes_line(self, props):
528529
# general reference line (e.g. axline)
529530
if props["coordinates"] == "display":
530531
px_points = props["data"]
532+
elif props["coordinates"] == "axes":
533+
px_points = [ax.transAxes.transform(pt) for pt in props["data"]]
531534
else:
532535
px_points = [trans.transform(pt) for pt in props["data"]]
533536
(x0, y0), (x1, y1) = [
534537
ax.transData.inverted().transform(pt) for pt in px_points
535538
]
536-
if self.x_is_mpl_date:
537-
x0, x1 = self._convert_x_dates([x0, x1])
538-
xref = x_axis
539-
yref = y_axis
539+
540+
dx = x1 - x0
541+
dy = y1 - y0
542+
if math.isclose(dy, 0.0, abs_tol=1e-12):
543+
# Horizontal line: use x domain so it spans the chart, y in data coordinates
544+
x0, x1 = 0.0, 1.0
545+
y0, y1 = float(y0), float(y1)
546+
xref = x_domain
547+
yref = y_axis
548+
elif math.isclose(dx, 0.0, abs_tol=1e-12):
549+
# Vertical line: use y domain so it spans the chart, x in data coordinates
550+
y0, y1 = 0.0, 1.0
551+
if self.x_is_mpl_date:
552+
x0, x1 = self._convert_x_dates([x0, x1])
553+
else:
554+
x0, x1 = float(x0), float(x1)
555+
xref = x_axis
556+
yref = y_domain
557+
else:
558+
# Diagonal line: extend endpoints in data coordinates so it spans
559+
# across zoom levels while staying locked to data coordinates on pan/zoom
560+
extension_factor = 100.0
561+
x0_ext = x0 - extension_factor * dx
562+
y0_ext = y0 - extension_factor * dy
563+
x1_ext = x1 + extension_factor * dx
564+
y1_ext = y1 + extension_factor * dy
565+
566+
if self.x_is_mpl_date:
567+
min_date_num = 1.0
568+
max_date_num = 3652000.0
569+
slope = dy / dx
570+
if x0_ext < min_date_num:
571+
y0_ext = y0 + slope * (min_date_num - x0)
572+
x0_ext = min_date_num
573+
if x1_ext > max_date_num:
574+
y1_ext = y1 + slope * (max_date_num - x1)
575+
x1_ext = max_date_num
576+
x0, x1 = self._convert_x_dates([x0_ext, x1_ext])
577+
else:
578+
x0, x1 = float(x0_ext), float(x1_ext)
579+
y0, y1 = float(y0_ext), float(y1_ext)
580+
xref = x_axis
581+
yref = y_axis
540582

541583
color = mpltools.merge_color_and_opacity(
542584
props["linestyle"]["color"], props["linestyle"]["alpha"]

‎plotly/matplotlylib/tests/test_renderer.py‎

Lines changed: 72 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -358,7 +358,7 @@ def test_axvline_converts():
358358

359359

360360
def test_axline_converts():
361-
"""axline converts to a layout shape spanning the whole axes box."""
361+
"""axline converts to a layout shape extended in data coordinates."""
362362
fig, ax = plt.subplots()
363363
ax.axline((0.5, 0.5), slope=1)
364364

@@ -368,12 +368,77 @@ def test_axline_converts():
368368
assert len(plotly_fig.layout.shapes) == 1
369369
shape = plotly_fig.layout.shapes[0]
370370
assert shape.type == "line"
371-
x0, x1 = ax.get_xlim()
372-
y0, y1 = ax.get_ylim()
373-
assert abs(shape.x0 - x0) < 1e-9
374-
assert abs(shape.x1 - x1) < 1e-9
375-
assert abs(shape.y0 - y0) < 1e-9
376-
assert abs(shape.y1 - y1) < 1e-9
371+
assert shape.xref == "x"
372+
assert shape.yref == "y"
373+
x_min, x_max = ax.get_xlim()
374+
assert shape.x0 < x_min
375+
assert shape.x1 > x_max
376+
slope = (shape.y1 - shape.y0) / (shape.x1 - shape.x0)
377+
assert abs(slope - 1.0) < 1e-9
378+
# Passes through (0.5, 0.5)
379+
y_at_05 = shape.y0 + slope * (0.5 - shape.x0)
380+
assert abs(y_at_05 - 0.5) < 1e-9
381+
382+
383+
def test_axline_arbitrary_slope_and_limits():
384+
"""axline with non-trivial slopes and limits extends in data coordinates."""
385+
fig, ax = plt.subplots()
386+
ax.scatter([1, 2, 4, 7, 9], [2, 5, 4, 8, 10])
387+
ax.axline((0, 1), slope=1.0)
388+
ax.axline((1, 8), (8, 2))
389+
ax.set_xlim(0, 10)
390+
ax.set_ylim(0, 12)
391+
392+
plotly_fig = tls.mpl_to_plotly(fig)
393+
shapes = plotly_fig.layout.shapes
394+
assert len(shapes) == 2
395+
396+
# Line 1: (0, 1), slope 1, xlim [0, 10], ylim [0, 12]
397+
assert shapes[0].xref == "x"
398+
assert shapes[0].yref == "y"
399+
assert shapes[0].x0 < -500
400+
assert shapes[0].x1 > 500
401+
slope1 = (shapes[0].y1 - shapes[0].y0) / (shapes[0].x1 - shapes[0].x0)
402+
assert abs(slope1 - 1.0) < 1e-9
403+
y_at_0 = shapes[0].y0 + slope1 * (0.0 - shapes[0].x0)
404+
assert abs(y_at_0 - 1.0) < 1e-9
405+
406+
# Line 2: (1, 8) to (8, 2) with slope -6/7
407+
assert shapes[1].xref == "x"
408+
assert shapes[1].yref == "y"
409+
assert shapes[1].x0 < -500
410+
assert shapes[1].x1 > 500
411+
slope2 = (shapes[1].y1 - shapes[1].y0) / (shapes[1].x1 - shapes[1].x0)
412+
assert abs(slope2 - (-6.0 / 7.0)) < 1e-9
413+
y_at_1 = shapes[1].y0 + slope2 * (1.0 - shapes[1].x0)
414+
assert abs(y_at_1 - 8.0) < 1e-9
415+
416+
417+
def test_axline_horizontal_and_vertical():
418+
"""Horizontal and vertical axline use domain coordinates appropriately."""
419+
fig, ax = plt.subplots()
420+
ax.axline((0, 5), slope=0)
421+
ax.axline((3, 0), (3, 10))
422+
423+
plotly_fig = tls.mpl_to_plotly(fig)
424+
shapes = plotly_fig.layout.shapes
425+
assert len(shapes) == 2
426+
427+
# Horizontal axline: xref is domain, y is data
428+
assert shapes[0].xref == "x domain"
429+
assert shapes[0].yref == "y"
430+
assert abs(shapes[0].x0 - 0.0) < 1e-9
431+
assert abs(shapes[0].x1 - 1.0) < 1e-9
432+
assert abs(shapes[0].y0 - 5.0) < 1e-9
433+
assert abs(shapes[0].y1 - 5.0) < 1e-9
434+
435+
# Vertical axline: xref is data, yref is domain
436+
assert shapes[1].xref == "x"
437+
assert shapes[1].yref == "y domain"
438+
assert abs(shapes[1].x0 - 3.0) < 1e-9
439+
assert abs(shapes[1].x1 - 3.0) < 1e-9
440+
assert abs(shapes[1].y0 - 0.0) < 1e-9
441+
assert abs(shapes[1].y1 - 1.0) < 1e-9
377442

378443

379444
def test_axvline_and_axhline_on_date_xaxis():

0 commit comments

Comments
 (0)