Skip to content
Closed
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
53 changes: 49 additions & 4 deletions plotly/basedatatypes.py
Original file line number Diff line number Diff line change
Expand Up @@ -1447,10 +1447,12 @@ def _select_layout_subplots_by_prefix(

layout_keys_filters = [
lambda k: k.startswith(prefix) and self.layout[k] is not None,
lambda k: row is None
or container_to_row_col.get(k, (None, None, None))[0] == row,
lambda k: col is None
or container_to_row_col.get(k, (None, None, None))[1] == col,
lambda k: (
row is None or container_to_row_col.get(k, (None, None, None))[0] == row
),
lambda k: (
col is None or container_to_row_col.get(k, (None, None, None))[1] == col
),
lambda k: (
secondary_y is None
or container_to_row_col.get(k, (None, None, None))[2] == secondary_y
Expand Down Expand Up @@ -3991,6 +3993,32 @@ def _perform_update(plotly_obj, update_obj, overwrite=False):
# Assign non-compound value
plotly_obj[key] = val

# Handle partial autorange for axis range with None elements
# -----------------------------------------------------------
# When 'range' is set with None elements on an axis object that
# has 'autorange', set autorange to the appropriate value so
# that plotly.js partial autoranging works correctly.
# Without this, plotly.js impliedEdits sets autorange=false when
# range is specified, preventing partial autorange behavior.
if "range" in update_obj and "autorange" in plotly_obj._valid_props:
if "autorange" in update_obj:
# User explicitly provided autorange in the same update.
# Re-apply it to ensure it takes precedence over any
# implicit autorange set by the __setitem__ handler
# for 'range'.
plotly_obj["autorange"] = update_obj["autorange"]
else:
range_val = update_obj["range"]
if isinstance(range_val, (list, tuple)) and len(range_val) == 2:
has_null_lower = range_val[0] is None
has_null_upper = range_val[1] is None
if has_null_lower and has_null_upper:
plotly_obj["autorange"] = True
elif has_null_lower:
plotly_obj["autorange"] = "min"
elif has_null_upper:
plotly_obj["autorange"] = "max"

elif isinstance(plotly_obj, tuple):
if len(update_obj) == 0:
# Nothing to do
Expand Down Expand Up @@ -4930,6 +4958,23 @@ def __setitem__(self, prop, value):
# ### Handle simple property ###
else:
self._set_prop(prop, value)

# Handle partial autorange when 'range' is set directly
# with None elements on an axis object.
# When range contains a None (null) element, plotly.js
# should use partial autoranging, but its impliedEdits
# mechanism sets autorange=false when range is specified.
# We counteract this by explicitly setting autorange.
if prop == "range" and "autorange" in self._valid_props:
if isinstance(value, (list, tuple)) and len(value) == 2:
has_null_lower = value[0] is None
has_null_upper = value[1] is None
if has_null_lower and has_null_upper:
self._set_prop("autorange", True)
elif has_null_lower:
self._set_prop("autorange", "min")
elif has_null_upper:
self._set_prop("autorange", "max")
else:
# Make sure properties dict is initialized
self._init_props()
Expand Down
66 changes: 66 additions & 0 deletions tests/test_core/test_update_objects/test_update_subplots.py
Original file line number Diff line number Diff line change
Expand Up @@ -599,3 +599,69 @@ def test_update_subplot_overwrite(self):
fig.layout.xaxis.to_plotly_json(),
{"title": {"font": {"family": "Courier"}}},
)


class TestPartialAutorange(TestCase):
"""Tests for automatic autorange setting when range contains None.

When a user sets range=[value, None] or range=[None, value], plotly.js
should use partial autoranging. However, plotly.js impliedEdits sets
autorange=false when range is specified. We counteract this by
explicitly setting autorange to the appropriate value.

See: https://github.com/plotly/plotly.py/issues/5536
"""

def test_range_upper_none_sets_autorange_max(self):
fig = go.Figure()
fig.add_trace(go.Scatter(y=[0, 6]))
fig.update_yaxes(range=[0, None])
self.assertEqual(fig.layout.yaxis.autorange, "max")

def test_range_lower_none_sets_autorange_min(self):
fig = go.Figure()
fig.add_trace(go.Scatter(y=[0, 6]))
fig.update_yaxes(range=[None, 5])
self.assertEqual(fig.layout.yaxis.autorange, "min")

def test_range_both_none_sets_autorange_true(self):
fig = go.Figure()
fig.add_trace(go.Scatter(y=[0, 6]))
fig.update_yaxes(range=[None, None])
self.assertEqual(fig.layout.yaxis.autorange, True)

def test_range_no_none_does_not_set_autorange(self):
fig = go.Figure()
fig.add_trace(go.Scatter(y=[0, 6]))
fig.update_yaxes(range=[0, 10])
self.assertIsNone(fig.layout.yaxis.autorange)

def test_xaxis_range_with_none(self):
fig = go.Figure()
fig.add_trace(go.Scatter(y=[0, 6]))
fig.update_xaxes(range=[None, 5])
self.assertEqual(fig.layout.xaxis.autorange, "min")

def test_explicit_autorange_overrides_implicit(self):
fig = go.Figure()
fig.add_trace(go.Scatter(y=[0, 6]))
fig.update_yaxes(range=[0, None], autorange="max reversed")
self.assertEqual(fig.layout.yaxis.autorange, "max reversed")

def test_explicit_autorange_dict_order_preserved(self):
fig = go.Figure()
fig.add_trace(go.Scatter(y=[0, 6]))
fig.update_yaxes(**{"autorange": "max reversed", "range": [0, None]})
self.assertEqual(fig.layout.yaxis.autorange, "max reversed")

def test_direct_property_assignment(self):
fig = go.Figure()
fig.add_trace(go.Scatter(y=[0, 6]))
fig.layout.yaxis.range = [0, None]
self.assertEqual(fig.layout.yaxis.autorange, "max")

def test_update_layout_yaxis_range(self):
fig = go.Figure()
fig.add_trace(go.Scatter(y=[0, 6]))
fig.update_layout(yaxis_range=[None, 5])
self.assertEqual(fig.layout.yaxis.autorange, "min")