Repository: bokeh/bokeh

Issue:
Axis rescaled when legend item visibility changed [FEATURE]
Currently, if legend click_policy = "hide" the legend item is hidden but the axes are not rescaled. This is fine most of the time, but there are circumstances where you'd like the axes to be rescaled, for example, if the hidden item (e.g. line) is much greater than the other items.

Here's a view of the current behavior. Chart 1 shows the full chart, chart 2 shows what's shown when Y3 is hidden.

Chart 1
![bokeh_plot (1)](https://user-images.githubusercontent.com/17951720/62465475-f661f580-b75c-11e9-9ad4-4f0b394a572f.png)

Chart 2
![bokeh_plot (2)](https://user-images.githubusercontent.com/17951720/62465498-07126b80-b75d-11e9-8c85-3097ad213ce0.png)

Here's what I would like to happen if the appropriate setting is made:
Chart 3
![bokeh_plot (3)](https://user-images.githubusercontent.com/17951720/62465563-2f9a6580-b75d-11e9-9b75-6ab538fdeea4.png)

I don't think this should be the default behavior. I suggest a new attribute something like: hide_rescale which is False by default (current behavior). If hide_rescale is set to True, the chart axes would be rescaled.

This has been discussed on the Discourse group: https://discourse.bokeh.org/t/bokeh-update-zoom-plot-or-axis-rescaling-when-hide-series-on-legend/2708/4







Discussion:
I think will have to implement more fine grained hiding policies. Currently `click_policy = "hide"` only affects rendering and hit testing but nothing else, e.g. data ranges computation, which is the reason for the lack of rescaling.
I've had some issues implementing the workaround which leads me to suggest some tests for when this is implemented.

I suggest whatever solution is chosen is tested for a chart nested within in a Panel in a multi-tab display or some equivalent. The point is, the chart is not the top level object in the Bokeh hierarchy. Further, I suggest the solution is tested in a Bokeh app running on the Bokeh server. 
I've just bumped into another use case here.

My initial discussion focused on rescaling the y axis, but the x axis should be rescaled too.
> My initial discussion focused on rescaling the y axis, but the x axis should be rescaled too.

Whether an axis will be rescaled or not, depends on the type of ranges there are in use. If both axes have data ranges assigned, then both will be updated. So, this feature boils down to making sure that data ranges know which renderers are visible/active/enabled (whatever we are going to name that) and update when state changes.

Resolving patch:
diff --git a/bokeh/models/ranges.py b/bokeh/models/ranges.py
--- a/bokeh/models/ranges.py
+++ b/bokeh/models/ranges.py
@@ -258,6 +258,11 @@ class DataRange1d(DataRange):
     (if used with a log axis, default_span is in powers of 10).
     """)
 
+    only_visible = Bool(default=False, help="""
+    If True, renderers that that are not visible will be excluded from automatic
+    bounds computations.
+    """)
+
     def __init__(self, *args, **kwargs):
         if kwargs.get('follow') is not None:
             kwargs['bounds'] = None
diff --git a/examples/plotting/file/interactive_legend.py b/examples/plotting/file/interactive_legend.py
--- a/examples/plotting/file/interactive_legend.py
+++ b/examples/plotting/file/interactive_legend.py
@@ -14,6 +14,7 @@
 
 p.legend.location = 'top_left'
 p.legend.click_policy = 'hide'
+p.y_range.only_visible = True
 
 output_file('interactive_legend.html', title='interactive_legend.py example')
 


Test patch:
diff --git a/bokeh/models/tests/test_ranges.py b/bokeh/models/tests/test_ranges.py
--- a/bokeh/models/tests/test_ranges.py
+++ b/bokeh/models/tests/test_ranges.py
@@ -135,7 +135,8 @@ def test_basic(self):
             "end",
             "bounds",
             "min_interval",
-            "max_interval"],
+            "max_interval",
+            "only_visible"],
         )
 
     def test_init_with_no_arguments(self):
diff --git a/bokehjs/test/models/ranges/data_range1d.ts b/bokehjs/test/models/ranges/data_range1d.ts
--- a/bokehjs/test/models/ranges/data_range1d.ts
+++ b/bokehjs/test/models/ranges/data_range1d.ts
@@ -295,6 +295,38 @@ describe("datarange1d module", () => {
       expect(r._compute_plot_bounds([g1], bds)).to.be.deep.equal({x0: 0, x1: 10, y0: 5, y1: 6})
       expect(r._compute_plot_bounds([g1, g2], bds)).to.be.deep.equal({x0: 0, x1: 15, y0: 5, y1: 6})
     })
+
+    it("should use invisble renderers by default", () => {
+      const r = new DataRange1d()
+
+      const g1 = new GlyphRenderer({id: "1", visible: false})
+      const g2 = new GlyphRenderer({id: "2"})
+
+      const bds = {
+        1: {x0: 0, x1: 10, y0: 5, y1: 6},
+        2: {x0: 0, x1: 15, y0: 5.5, y1: 5.6},
+        3: {x0: -10, x1: 15, y0: 0, y1: 2},
+      }
+
+      expect(r._compute_plot_bounds([g1], bds)).to.be.deep.equal({x0: 0, x1: 10, y0: 5, y1: 6})
+      expect(r._compute_plot_bounds([g1, g2], bds)).to.be.deep.equal({x0: 0, x1: 15, y0: 5, y1: 6})
+    })
+
+    it("should skip invisble renderers if only_visible=false", () => {
+      const r = new DataRange1d({only_visible: true})
+
+      const g1 = new GlyphRenderer({id: "1"})
+      const g2 = new GlyphRenderer({id: "2", visible: false})
+
+      const bds = {
+        1: {x0: 0, x1: 10, y0: 5, y1: 6},
+        2: {x0: 0, x1: 15, y0: 5.5, y1: 5.6},
+        3: {x0: -10, x1: 15, y0: 0, y1: 2},
+      }
+
+      expect(r._compute_plot_bounds([g1], bds)).to.be.deep.equal({x0: 0, x1: 10, y0: 5, y1: 6})
+      expect(r._compute_plot_bounds([g1, g2], bds)).to.be.deep.equal({x0: 0, x1: 10, y0: 5, y1: 6})
+    })
   })
 
   describe("update", () => {
diff --git a/tests/integration/models/test_datarange1d.py b/tests/integration/models/test_datarange1d.py
new file mode 100644
--- /dev/null
+++ b/tests/integration/models/test_datarange1d.py
@@ -0,0 +1,120 @@
+#-----------------------------------------------------------------------------
+# Copyright (c) 2012 - 2017, Anaconda, Inc. All rights reserved.
+#
+# Powered by the Bokeh Development Team.
+#
+# The full license is in the file LICENSE.txt, distributed with this software.
+#-----------------------------------------------------------------------------
+
+#-----------------------------------------------------------------------------
+# Boilerplate
+#-----------------------------------------------------------------------------
+from __future__ import absolute_import, division, print_function, unicode_literals
+
+import pytest ; pytest
+
+#-----------------------------------------------------------------------------
+# Imports
+#-----------------------------------------------------------------------------
+
+# Standard library imports
+
+# External imports
+
+# Bokeh imports
+from bokeh.layouts import column
+from bokeh.models import Button, Circle, ColumnDataSource, CustomAction, CustomJS, DataRange1d, Plot
+from bokeh._testing.util.selenium import RECORD
+
+#-----------------------------------------------------------------------------
+# Tests
+#-----------------------------------------------------------------------------
+
+pytest_plugins = (
+    "bokeh._testing.plugins.bokeh",
+)
+
+def _make_plot(**kw):
+    source = ColumnDataSource(dict(x=[1, 2], y1=[0, 1], y2=[10,11]))
+    plot = Plot(plot_height=400, plot_width=400, x_range=DataRange1d(), y_range=DataRange1d(**kw), min_border=0)
+    plot.add_glyph(source, Circle(x='x', y='y1'))
+    glyph = plot.add_glyph(source, Circle(x='x', y='y2'))
+    glyph.visible = False
+    code = RECORD("yrstart", "p.y_range.start") + RECORD("yrend", "p.y_range.end")
+    plot.add_tools(CustomAction(callback=CustomJS(args=dict(p=plot), code=code)))
+    plot.toolbar_sticky = False
+    return plot, glyph
+
+@pytest.mark.integration
+@pytest.mark.selenium
+class Test_DataRange1d(object):
+
+    def test_includes_hidden_glyphs_by_default(self, single_plot_page):
+        plot, glyph = _make_plot()
+
+        page = single_plot_page(plot)
+
+        page.click_custom_action()
+
+        results = page.results
+        assert results['yrstart'] <= 0
+        assert results['yrend'] >= 11
+
+        assert page.has_no_console_errors()
+
+    def test_includes_hidden_glyphs_when_asked(self, single_plot_page):
+        plot, glyph = _make_plot(only_visible=False)
+
+        page = single_plot_page(plot)
+
+        page.click_custom_action()
+
+        results = page.results
+        assert results['yrstart'] <= 0
+        assert results['yrend'] >= 11
+
+        assert page.has_no_console_errors()
+
+    def test_excludes_hidden_glyphs_when_asked(self, single_plot_page):
+        plot, glyph = _make_plot(only_visible=True)
+
+        page = single_plot_page(plot)
+
+        page.click_custom_action()
+
+        results = page.results
+        assert results['yrstart'] <= 0
+        assert results['yrend'] < 5
+
+        assert page.has_no_console_errors()
+
+
+    def test_updates_when_visibility_is_toggled(self, single_plot_page):
+        source = ColumnDataSource(dict(x=[1, 2], y1=[0, 1], y2=[10,11]))
+        plot = Plot(plot_height=400, plot_width=400, x_range=DataRange1d(), y_range=DataRange1d(only_visible=True), min_border=0)
+        plot.add_glyph(source, Circle(x='x', y='y1'))
+        glyph = plot.add_glyph(source, Circle(x='x', y='y2'))
+        code = RECORD("yrstart", "p.y_range.start") + RECORD("yrend", "p.y_range.end")
+        plot.add_tools(CustomAction(callback=CustomJS(args=dict(p=plot), code=code)))
+        plot.toolbar_sticky = False
+        button = Button(css_classes=['foo'])
+        button.js_on_click(CustomJS(args=dict(glyph=glyph), code="glyph.visible=false"))
+
+        page = single_plot_page(column(plot, button))
+
+        page.click_custom_action()
+
+        results = page.results
+        assert results['yrstart'] <= 0
+        assert results['yrend'] >= 11
+
+        button = page.driver.find_element_by_css_selector('.foo .bk-btn')
+        button.click()
+
+        page.click_custom_action()
+
+        results = page.results
+        assert results['yrstart'] <= 0
+        assert results['yrend'] < 5
+
+        assert page.has_no_console_errors()