From 1f37479f14c7d89894bc6f26c464a2474043f2a9 Mon Sep 17 00:00:00 2001 From: kirthi_b Date: Sat, 18 Jul 2026 19:29:58 -0700 Subject: [PATCH] Add add_abline for drawing a line from slope and intercept Figure.add_abline(slope=1, intercept=0, ...) draws a straight line the way matplotlib's axline or R's abline do, computing the line's endpoints from the current x-axis range (or, if that range hasn't been set explicitly, from the data already plotted on that axis) at the time it is called. The method mirrors the signature style of add_hline/add_vline: slope and intercept play the role of y/x, and row, col, exclude_empty_subplots, annotation and annotation_* all behave the same way. Annotation placement reuses the existing vline positioning logic in shapeannotation.py, which was already written generically enough to place text relative to a slanted line's endpoints. Unlike add_hline/add_vline, the new line is not anchored to the plot edges: its endpoints are ordinary data coordinates fixed at add time, so panning or zooming afterwards won't move or extend it. This is called out explicitly in the docstring and in the shapes doc page. Closes #3166 --- CHANGELOG.md | 3 + doc/python/shapes.md | 22 ++ plotly/basedatatypes.py | 199 ++++++++++++++++++ plotly/shapeannotation.py | 2 +- .../test_autoshapes/test_add_abline.py | 191 +++++++++++++++++ 5 files changed, 416 insertions(+), 1 deletion(-) create mode 100644 tests/test_optional/test_autoshapes/test_add_abline.py diff --git a/CHANGELOG.md b/CHANGELOG.md index e52c392dc7..c348f4735c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,9 @@ This project adheres to [Semantic Versioning](http://semver.org/). ## Unreleased +### Added +- Add `add_abline` method to `Figure` for drawing a straight line from a slope and an intercept, alongside the existing `add_hline`/`add_vline`/`add_hrect`/`add_vrect` convenience methods [[#3166](https://github.com/plotly/plotly.py/issues/3166)] + ## [6.9.0] - 2026-07-09 diff --git a/doc/python/shapes.md b/doc/python/shapes.md index ac993d4283..3e4a8061c2 100644 --- a/doc/python/shapes.md +++ b/doc/python/shapes.md @@ -132,6 +132,28 @@ fig.update_shapes(dict(xref='x', yref='y')) fig.show() ``` +#### Diagonal Line From a Slope and an Intercept + +To draw a straight line from a slope and an intercept rather than from +endpoint coordinates, use `add_abline`, which computes the line's endpoints +from the current x-axis range (or from the range of the data already plotted, +if the x-axis range has not been set explicitly) at the time it is called. + +```python +import plotly.graph_objects as go + +fig = go.Figure() +fig.add_trace(go.Scatter(x=[0, 1, 2, 3], y=[0, 1, 4, 9])) +fig.add_abline(slope=3, intercept=-2, line=dict(color="MediumPurple", dash="dot")) +fig.show() +``` + +Note that, unlike `add_hline` and `add_vline`, the line added by +`add_abline` is not re-anchored to the axes: its endpoints are ordinary data +coordinates fixed at the time the line is added, so panning or zooming the +figure afterwards, or adding data that falls outside the original range, +will not extend or move it. + #### Lines Positioned Relative to the Plot & to the Axis Data ```python diff --git a/plotly/basedatatypes.py b/plotly/basedatatypes.py index ec4038b7fa..449821e323 100644 --- a/plotly/basedatatypes.py +++ b/plotly/basedatatypes.py @@ -4236,6 +4236,205 @@ def add_hrect( add_hrect.__doc__ = _axis_spanning_shapes_docstr("hrect") + def _abline_xrange(self, xaxis, xref): + """ + Determine the (x0, x1) pair of x-axis coordinates to use as the + endpoints of a line drawn from a slope and an intercept, such as in + add_abline. + + If the axis's `range` has been explicitly set (either by the user or + by a previous render that fed values back into the figure, e.g. a + FigureWidget), that range is used as-is, including the case where it + is reversed (range[0] > range[1]). + + Otherwise, the numeric x-values already present in the traces drawn + on this axis are used to compute a (min, max) pair. Traces that have + no explicit `x` values but do have `y` values are treated as if they + were plotted against their implied integer index (0, 1, 2, ...), + matching how plotly.js positions them. + + Returns None if neither an explicit range nor any usable data could + be found, which happens for an axis that has no traces plotted on it + yet and no range set. + """ + if xaxis.range is not None: + return tuple(xaxis.range) + + xs = [] + for trace in self.data: + trace_xref = "x" if trace.xaxis is None else trace.xaxis + if trace_xref != xref: + continue + + trace_x = getattr(trace, "x", None) + numeric_x = [] + if trace_x is not None: + for v in trace_x: + try: + numeric_x.append(float(v)) + except (TypeError, ValueError): + # skip non-numeric values (e.g. categories or dates); + # add_abline does not attempt to support category or + # date x-axes + pass + + if numeric_x: + xs.extend(numeric_x) + else: + trace_y = getattr(trace, "y", None) + if trace_y is not None and len(trace_y) > 0: + xs.extend([0, len(trace_y) - 1]) + + if not xs: + return None + return (min(xs), max(xs)) + + def add_abline( + self, + slope=1, + intercept=0, + row="all", + col="all", + exclude_empty_subplots=True, + annotation=None, + **kwargs, + ): + """ + Add a straight line to a plot or subplot defined by a slope and an + intercept, in the style of matplotlib's `axline` or R's `abline`. + + Unlike add_hline and add_vline, this line's position is not fixed + relative to the plot's edges: the shape's endpoints are ordinary + data-coordinate points, computed once at the time this method is + called, from either the axis's currently-set `range` or, if that is + not set, from the minimum and maximum x-values already plotted on + that axis. Because of this, the line is NOT automatically + recalculated or extended if the figure is later panned or zoomed, or + if new data with a wider x-range is added afterwards; it will simply + stay at the x-range it was drawn with, the same way any other shape + added with `add_shape` behaves. If you change the axis range and + want the line to reflect it, call `add_abline` again (after removing + the previous line, if needed). + + This method only supports linear x-axes. It does not attempt to + compute a sensible line for logarithmic, date, or category x-axes. + + Parameters + ---------- + slope: float or int + The slope of the line. Defaults to 1. + intercept: float or int + The y-value of the line at x=0. Defaults to 0. + exclude_empty_subplots: Boolean + If True (default) do not place the shape on subplots that have no + data plotted on them. + row: None, int or 'all' + Subplot row for shape indexed starting at 1. If 'all', addresses + all rows in the specified column(s). If both row and col are + None, addresses the first subplot if subplots exist, or the only + plot. By default is "all". + col: None, int or 'all' + Subplot column for shape indexed starting at 1. If 'all', + addresses all rows in the specified column(s). If both row and + col are None, addresses the first subplot if subplots exist, or + the only plot. By default is "all". + annotation: dict or plotly.graph_objects.layout.Annotation. If dict(), + it is interpreted as describing an annotation. The annotation is + placed relative to the line based on annotation_position (see + below) unless its x or y value has been specified for the + annotation passed here. xref and yref are always the same as for + the added shape and cannot be overridden. + annotation_position: a string containing optionally ["top", "bottom"] + and ["left", "right"] specifying where the text should be + anchored to on the line. Example positions are "bottom left", + "right top", "right", "bottom". If an annotation is added but + annotation_position is not specified, this defaults to "top + right". + annotation_*: any parameters to go.layout.Annotation can be passed as + keywords by prefixing them with "annotation_". For example, to + specify the annotation text "example" you can pass + annotation_text="example" as a keyword argument. + **kwargs: + Any named function parameters that can be passed to 'add_shape', + except for x0, x1, y0, y1 or type. + + Returns + ------- + Figure + """ + if (row is not None or col is not None) and (not self._has_subplots()): + # this has no subplots to address, so we force row and col to be + # None, matching the behaviour of add_hline/add_vline + row = None + col = None + + if row is not None and _is_select_subplot_coordinates_arg(row, col): + rows_cols = self._select_subplot_coordinates(row, col) + for r, c in rows_cols: + self.add_abline( + slope=slope, + intercept=intercept, + row=r, + col=c, + exclude_empty_subplots=exclude_empty_subplots, + annotation=annotation, + **kwargs, + ) + return self + + if row is None: + xaxis = self.layout.xaxis + xref = "x" + add_kwargs = dict() + else: + grid_ref = self._validate_get_grid_ref() + if row > len(grid_ref) or col > len(grid_ref[row - 1]): + raise IndexError("row/col index (%d, %d) out-of-bounds" % (row, col)) + refs = grid_ref[row - 1][col - 1] + if not refs or refs[0].subplot_type != "xy": + # nothing sensible to draw an abline on here + return self + xaxis_key, yaxis_key = refs[0].layout_keys + xref = xaxis_key.replace("axis", "") + yref = yaxis_key.replace("axis", "") + if exclude_empty_subplots and not self._subplot_not_empty( + xref, yref, selector=bool(exclude_empty_subplots) + ): + return self + xaxis = self.layout[xaxis_key] + add_kwargs = dict(row=row, col=col) + + x_range = self._abline_xrange(xaxis, xref) + if x_range is None: + # no data and no explicit range to compute the line's endpoints + # from; fall back to the default plotly.js x-axis range for an + # otherwise-empty axis + x0, x1 = 0, 1 + else: + x0, x1 = x_range + y0 = intercept + slope * x0 + y1 = intercept + slope * x1 + + shape_args = dict(type="line", x0=x0, x1=x1, y0=y0, y1=y1) + shape_kwargs, annotation_kwargs = shapeannotation.split_dict_by_key_prefix( + kwargs, "annotation_" + ) + augmented_annotation = shapeannotation.axis_spanning_shape_annotation( + annotation, "abline", shape_args, annotation_kwargs + ) + self.add_shape( + exclude_empty_subplots=exclude_empty_subplots, + **_combine_dicts([shape_args, shape_kwargs]), + **add_kwargs, + ) + if augmented_annotation is not None: + self.add_annotation( + augmented_annotation, + exclude_empty_subplots=exclude_empty_subplots, + **add_kwargs, + ) + return self + def _has_subplots(self): """Returns True if figure contains subplots, otherwise it contains a single plot and so this returns False.""" diff --git a/plotly/shapeannotation.py b/plotly/shapeannotation.py index 41173054a1..40944b2f15 100644 --- a/plotly/shapeannotation.py +++ b/plotly/shapeannotation.py @@ -106,7 +106,7 @@ def annotation_params_for_line(shape_type, shape_args, position): aaX = _argmax(X) aiX = _argmin(X) position, pos_str = _prepare_position(position) - if shape_type == "vline": + if shape_type in ("vline", "abline"): if position == set(["top", "left"]): return _df_anno(R, T, X[aaY], aY) if position == set(["top", "right"]): diff --git a/tests/test_optional/test_autoshapes/test_add_abline.py b/tests/test_optional/test_autoshapes/test_add_abline.py new file mode 100644 index 0000000000..c7140aed0d --- /dev/null +++ b/tests/test_optional/test_autoshapes/test_add_abline.py @@ -0,0 +1,191 @@ +import plotly.graph_objects as go +from plotly.subplots import make_subplots +from itertools import product +import pytest +from .common import _cmp_partial_dict, _check_figure_layout_objects + + +@pytest.fixture +def single_plot_fixture(): + fig = go.Figure() + fig.update_xaxes(range=[0, 10]) + fig.add_trace(go.Scatter(x=[], y=[])) + return fig + + +@pytest.fixture +def multi_plot_fixture(): + fig = make_subplots(2, 2) + for r, c in product(range(2), range(2)): + r += 1 + c += 1 + fig.update_xaxes(row=r, col=c, range=[0, 10]) + fig.add_trace(go.Scatter(x=[], y=[]), row=r, col=c) + return fig + + +@pytest.mark.parametrize( + "test_input,expected", + [ + ( + (go.Figure.add_abline, dict()), + [{"type": "line", "x0": 0, "x1": 10, "y0": 0, "y1": 10}], + ), + ( + (go.Figure.add_abline, dict(slope=2, intercept=1)), + [{"type": "line", "x0": 0, "x1": 10, "y0": 1, "y1": 21}], + ), + ( + (go.Figure.add_abline, dict(slope=-1, intercept=5)), + [{"type": "line", "x0": 0, "x1": 10, "y0": 5, "y1": -5}], + ), + ], +) +def test_add_abline_single_plot(test_input, expected, single_plot_fixture): + _check_figure_layout_objects(test_input, expected, single_plot_fixture) + + +def test_add_abline_default_slope_intercept(): + # slope=1, intercept=0 are the documented defaults + fig = go.Figure() + fig.update_xaxes(range=[-3, 3]) + fig.add_abline() + shape = fig.layout.shapes[0] + assert (shape.x0, shape.x1) == (-3, 3) + assert (shape.y0, shape.y1) == (-3, 3) + + +def test_add_abline_uses_data_range_when_no_explicit_range(): + fig = go.Figure() + fig.add_trace(go.Scatter(x=[0, 5, 10], y=[1, 2, 3])) + fig.add_abline(slope=2, intercept=1) + shape = fig.layout.shapes[0] + assert (shape.x0, shape.x1) == (0.0, 10.0) + assert (shape.y0, shape.y1) == (1.0, 21.0) + + +def test_add_abline_explicit_range_takes_priority_over_data(): + fig = go.Figure() + fig.add_trace(go.Scatter(x=[0, 5, 10], y=[1, 2, 3])) + fig.update_xaxes(range=[-5, 5]) + fig.add_abline(slope=1, intercept=0) + shape = fig.layout.shapes[0] + assert (shape.x0, shape.x1) == (-5, 5) + assert (shape.y0, shape.y1) == (-5, 5) + + +def test_add_abline_falls_back_to_default_range_with_no_data_or_range(): + # No data and no explicit range: falls back to plotly's default (0, 1) + # x-range rather than raising, matching the documented behavior. + fig = go.Figure() + fig.add_abline(slope=3, intercept=2) + shape = fig.layout.shapes[0] + assert (shape.x0, shape.x1) == (0, 1) + assert (shape.y0, shape.y1) == (2, 5) + + +def test_add_abline_uses_implied_index_range_when_trace_has_no_x(): + # A trace with only y values is drawn against an implied 0..len(y)-1 + # x-index by plotly.js, so add_abline should use that range too. + fig = go.Figure() + fig.add_trace(go.Scatter(y=[10, 20, 30, 40])) + fig.add_abline(slope=1, intercept=0) + shape = fig.layout.shapes[0] + assert (shape.x0, shape.x1) == (0, 3) + + +@pytest.mark.parametrize( + "test_input,expected", + [ + ( + (go.Figure.add_abline, dict(slope=1, intercept=0, row=1, col=1)), + [{"type": "line", "x0": 0, "x1": 10, "xref": "x", "yref": "y"}], + ), + ( + (go.Figure.add_abline, dict(slope=1, intercept=0, row=2, col=2)), + [{"type": "line", "x0": 0, "x1": 10, "xref": "x4", "yref": "y4"}], + ), + ], +) +def test_add_abline_subplot_targeting(test_input, expected, multi_plot_fixture): + _check_figure_layout_objects(test_input, expected, multi_plot_fixture) + + +def test_add_abline_row_col_all(multi_plot_fixture): + multi_plot_fixture.add_abline(slope=1, intercept=0, row="all", col="all") + assert len(multi_plot_fixture.layout.shapes) == 4 + ax_nums = ["", "2", "3", "4"] + for s, n in zip(multi_plot_fixture.layout.shapes, ax_nums): + assert _cmp_partial_dict( + s, + { + "type": "line", + "x0": 0, + "x1": 10, + "xref": "x%s" % (n,), + "yref": "y%s" % (n,), + }, + ) + + +def test_add_abline_excludes_empty_subplots_by_default(): + fig = make_subplots(rows=1, cols=2) + fig.update_xaxes(range=[0, 10]) + fig.add_trace(go.Scatter(x=[0, 10], y=[0, 1]), row=1, col=1) + # col 2 has no trace, so it should be skipped by default + fig.add_abline(slope=1, intercept=0) + assert len(fig.layout.shapes) == 1 + assert fig.layout.shapes[0].xref == "x" + + +def test_add_abline_no_annotation_by_default(multi_plot_fixture): + multi_plot_fixture.add_abline(slope=1, intercept=0, row="all", col="all") + assert len(multi_plot_fixture.layout.annotations) == 0 + assert len(multi_plot_fixture.layout.shapes) == 4 + + +def test_add_abline_annotation_single_plot(single_plot_fixture): + single_plot_fixture.add_abline(slope=1, intercept=0, annotation_text="my line") + assert len(single_plot_fixture.layout.annotations) == 1 + annotation = single_plot_fixture.layout.annotations[0] + assert annotation.text == "my line" + # default annotation_position is "top right": the endpoint with the + # larger y-value, right-anchored so the text sits to its left + assert annotation.x == 10 + assert annotation.y == 10 + assert annotation.xanchor == "left" + assert annotation.yanchor == "top" + + +def test_add_abline_annotation_position(single_plot_fixture): + single_plot_fixture.add_abline( + slope=1, + intercept=0, + annotation_text="my line", + annotation_position="bottom left", + ) + annotation = single_plot_fixture.layout.annotations[0] + assert annotation.x == 0 + assert annotation.y == 0 + assert annotation.xanchor == "right" + assert annotation.yanchor == "bottom" + + +def test_add_abline_annotation_multi_plot(multi_plot_fixture): + multi_plot_fixture.add_abline(slope=1, intercept=0, annotation_text="A") + ax_nums = ["", "2", "3", "4"] + assert len(multi_plot_fixture.layout.annotations) == 4 + for sh, n in zip(multi_plot_fixture.layout.annotations, ax_nums): + assert _cmp_partial_dict( + sh, + { + "text": "A", + "xref": "x%s" % (n,), + "yref": "y%s" % (n,), + }, + ) + + +def test_add_abline_returns_figure(single_plot_fixture): + ret = single_plot_fixture.add_abline(slope=1, intercept=0) + assert ret is single_plot_fixture