diff --git a/plotly/basedatatypes.py b/plotly/basedatatypes.py index 1384e08d543..360202063c0 100644 --- a/plotly/basedatatypes.py +++ b/plotly/basedatatypes.py @@ -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 @@ -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 @@ -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() diff --git a/tests/test_core/test_update_objects/test_update_subplots.py b/tests/test_core/test_update_objects/test_update_subplots.py index 159b995ca40..71b25840f43 100644 --- a/tests/test_core/test_update_objects/test_update_subplots.py +++ b/tests/test_core/test_update_objects/test_update_subplots.py @@ -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")