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