Repository: holoviz/holoviews

Issue:
Defining hard bounds when panning and zooming in bokeh backend
Currently when plotting using bokeh the axes are unbounded letting you zoom and pan to potentially empty parts of the data space. Since bokeh 0.11 it has been possible to bound the axis ranges to stop you from zooming or panning outside the desired region. We should decide how to expose this? Should axes ranges always be hard bounded or should setting an explicit dimension range bound them? There's a variety of behaviors we could consider here and just wanted to get discussion around this started.


Discussion:
Sounds like something else that could go into the Dimension properties once that is implemented...
>Sounds like something else that could go into the Dimension properties once that is implemented...

We already have range and soft_range so I don't think anything new is needed. Just need to define the semantics.
@jbednar Any opinion on this? I think it would be nice to offer hard bounds on the axes, should we say that if you explicitly set an ``Dimension`` ``range`` that becomes a hard-bound for the axis?
My intuition is that we should be using the hard range of a Dimension automatically in this way, but I would have to get some experience with it in practice for different plot types to be sure about that and to know whether it should be configurable.  I *think* I'd always want such bounds, but can't be sure until I experience it.
>I think I'd always want such bounds, but can't be sure until I experience it.

I think so too, but not actually having used it I don't know either. I know that zooming out far beyond the actual data is rarely useful and usually bad behavior.
Right; "far beyond" is not useful and is very confusing.  The question in my mind is about how big "far" is; we might need a configurable buffer, such that we can zoom out to 150% of the range, e.g., but not further.  And of course anything like that then gets tricky for log axes, in which it would need to be 150% of the pixel area corresponding to the range.  So, need to try it out to see if any such buffer is needed; it's simpler if it turns out not to be.
Did anyone succeed in writing a work around solution for this? I think hard bounds are a very important feature, especially for boundaries of Image/Raster plots.
For anyone wondering, as of now, there's a workaround by accessing the underlying bokeh-figure:

```
import holoviews as hv
from bokeh.io import show
import dask.array as da

renderer = hv.renderer('bokeh')
sx = sy = 1000

arr = da.random.random((sx, sy))
fig = renderer.get_plot(hv.Image((range(sx), range(sy), arr)))

fig.state.x_range.bounds = (0, sx)
fig.state.y_range.bounds = (0, sy)

show(fig.state)
```

Other suggestions:

```python
import holoviews as hv
import dask.array as da
import panel as pn

sx = sy = 1000

arr = da.random.random((sx, sy))
img = hv.Image((range(sx), range(sy), arr))
fig = hv.render(img)

fig.x_range.bounds = (0, sx)
fig.y_range.bounds = (0, sy)

pn.panel(fig)
```

or using a hook:

```python
import holoviews as hv
from bokeh.io import show
import dask.array as da

renderer = hv.renderer('bokeh')
sx = sy = 1000

arr = da.random.random((sx, sy))
img = hv.Image((range(sx), range(sy), arr))

def set_bounds(fig, element):
    fig.state.x_range.bounds = (0, sx)
    fig.state.y_range.bounds = (0, sy)

img.opts(hooks=[set_bounds])
```
Trying out the hook for that example makes me more confident that HoloViews should enforce hard bounds by default. It's a much better user experience. My guess is that now that HoloViews adds padding automatically, the padded range is already precisely the range that is also the most useful default hard bounds for pan and zoom. There are no doubt some cases and some users who want to be able to zoom out beyond that, but my gut is that those are very rare and that the current behavior is much more surprising and annoying to a huge class of users than it will be for those rare users who find that they want it to behave the current way. So it's important to be configurable, but I am still strongly advocating for enabling hard bounds by default.

It would be nice to have a dev build of the HoloViews website where such bounds are enabled by default for all plots and we can try them out with all the different plot types in the reference gallery to further validate it.
you can now achieve this with HoloView's support of [backend_opts](https://holoviews.org/user_guide/Plotting_with_Bokeh.html#setting-backend-opts), e.g.:

backend_opts={
        "x_range.bounds": (0, 10),
        "y_range.bounds": (0, 20)
}

Resolving patch:
diff --git a/holoviews/plotting/bokeh/element.py b/holoviews/plotting/bokeh/element.py
--- a/holoviews/plotting/bokeh/element.py
+++ b/holoviews/plotting/bokeh/element.py
@@ -105,6 +105,11 @@ class ElementPlot(BokehPlot, GenericElementPlot):
     align = param.ObjectSelector(default='start', objects=['start', 'center', 'end'], doc="""
         Alignment (vertical or horizontal) of the plot in a layout.""")
 
+    apply_hard_bounds = param.Boolean(default=False, doc="""
+        If True, the navigable bounds of the plot will be set based
+        on the more extreme of extents between the data or xlim/ylim ranges.
+        If dim ranges are set, the hard bounds will be set to the dim ranges.""")
+
     autorange = param.ObjectSelector(default=None, objects=['x', 'y', None], doc="""
         Whether to auto-range along either the x- or y-axis, i.e.
         when panning or zooming along the orthogonal axis it will
@@ -1894,6 +1899,10 @@ def initialize_plot(self, ranges=None, plot=None, plots=None, source=None):
             if self._subcoord_overlaid:
                 if style_element.label in plot.extra_y_ranges:
                     self.handles['y_range'] = plot.extra_y_ranges.pop(style_element.label)
+
+        if self.apply_hard_bounds:
+            self._apply_hard_bounds(element, ranges)
+
         self.handles['plot'] = plot
 
         if self.autorange:
@@ -1919,6 +1928,32 @@ def initialize_plot(self, ranges=None, plot=None, plots=None, source=None):
 
         return plot
 
+    def _apply_hard_bounds(self, element, ranges):
+        """
+        Apply hard bounds to the x and y ranges of the plot. If xlim/ylim is set, limit the
+        initial viewable range to xlim/ylim, but allow navigation up to the abs max between
+        the data range and xlim/ylim. If dim range is set (e.g. via redim.range), enforce
+        as hard bounds.
+
+        """
+
+        def validate_bound(bound):
+            return bound if util.isfinite(bound) else None
+
+        min_extent_x, min_extent_y, max_extent_x, max_extent_y = map(
+            validate_bound, self.get_extents(element, ranges, range_type='combined', lims_as_soft_ranges=True)
+        )
+
+        def set_bounds(axis, min_extent, max_extent):
+            """Set the bounds for a given axis, using None if both extents are None or identical"""
+            try:
+                self.handles[axis].bounds = None if min_extent == max_extent else (min_extent, max_extent)
+            except ValueError:
+                self.handles[axis].bounds = None
+
+        set_bounds('x_range', min_extent_x, max_extent_x)
+        set_bounds('y_range', min_extent_y, max_extent_y)
+
     def _setup_data_callbacks(self, plot):
         if not self._js_on_data_callbacks:
             return
@@ -2044,6 +2079,9 @@ def update_frame(self, key, ranges=None, plot=None, element=None):
                 cds = self.handles['cds']
                 self._postprocess_hover(renderer, cds)
 
+        if self.apply_hard_bounds:
+            self._apply_hard_bounds(element, ranges)
+
         self._update_glyphs(element, ranges, self.style[self.cyclic_index])
         self._execute_hooks(element)
 
diff --git a/holoviews/plotting/plot.py b/holoviews/plotting/plot.py
--- a/holoviews/plotting/plot.py
+++ b/holoviews/plotting/plot.py
@@ -1423,7 +1423,7 @@ def _get_range_extents(self, element, ranges, range_type, xdim, ydim, zdim):
 
         return (x0, y0, x1, y1)
 
-    def get_extents(self, element, ranges, range_type='combined', dimension=None, xdim=None, ydim=None, zdim=None, **kwargs):
+    def get_extents(self, element, ranges, range_type='combined', dimension=None, xdim=None, ydim=None, zdim=None, lims_as_soft_ranges=False, **kwargs):
         """
         Gets the extents for the axes from the current Element. The globally
         computed ranges can optionally override the extents.
@@ -1444,6 +1444,12 @@ def get_extents(self, element, ranges, range_type='combined', dimension=None, xd
 
         This allows Overlay plots to obtain each range and combine them
         appropriately for all the objects in the overlay.
+
+        If lims_as_soft_ranges is set to True, the xlim and ylim will be treated as
+        soft ranges instead of the default case as hard ranges while computing the extents.
+        This is used e.g. when apply_hard_bounds is True and xlim/ylim is set, in which
+        case we limit the initial viewable range to xlim/ylim, but allow navigation up to
+        the abs max between the data range and xlim/ylim.
         """
         num = 6 if (isinstance(self.projection, str) and self.projection == '3d') else 4
         if self.apply_extents and range_type in ('combined', 'extents'):
@@ -1486,8 +1492,15 @@ def get_extents(self, element, ranges, range_type='combined', dimension=None, xd
         else:
             x0, y0, x1, y1 = combined
 
-        x0, x1 = util.dimension_range(x0, x1, self.xlim, (None, None))
-        y0, y1 = util.dimension_range(y0, y1, self.ylim, (None, None))
+        if lims_as_soft_ranges:
+            # run x|ylim through max_range to ensure datetime-dtype matching with ranges
+            xlim_soft_ranges = util.max_range([self.xlim])
+            ylim_soft_ranges = util.max_range([self.ylim])
+            x0, x1 = util.dimension_range(x0, x1, (None, None), xlim_soft_ranges)
+            y0, y1 = util.dimension_range(y0, y1, (None, None), ylim_soft_ranges)
+        else:
+            x0, x1 = util.dimension_range(x0, x1, self.xlim, (None, None))
+            y0, y1 = util.dimension_range(y0, y1, self.ylim, (None, None))
 
         if not self.drawn:
             x_range, y_range = ((y0, y1), (x0, x1)) if self.invert_axes else ((x0, x1), (y0, y1))


Test patch:
diff --git a/holoviews/tests/plotting/bokeh/test_elementplot.py b/holoviews/tests/plotting/bokeh/test_elementplot.py
--- a/holoviews/tests/plotting/bokeh/test_elementplot.py
+++ b/holoviews/tests/plotting/bokeh/test_elementplot.py
@@ -3,6 +3,7 @@
 
 import numpy as np
 import panel as pn
+import param
 import pytest
 from bokeh.document import Document
 from bokeh.models import (
@@ -16,7 +17,8 @@
     tools,
 )
 
-from holoviews.core import DynamicMap, HoloMap, NdOverlay
+from holoviews import opts
+from holoviews.core import DynamicMap, HoloMap, NdOverlay, Overlay
 from holoviews.core.util import dt_to_int
 from holoviews.element import Curve, HeatMap, Image, Labels, Scatter
 from holoviews.plotting.util import process_cmap
@@ -993,3 +995,96 @@ def test_clim_percentile(self):
         low, high = plot.ranges[('Image',)]['z']['robust']
         assert low > 0
         assert high < 1
+
+class TestApplyHardBounds(TestBokehPlot):
+    def test_apply_hard_bounds(self):
+        """Test `apply_hard_bounds` with a single element."""
+        x_values = np.linspace(10, 50, 5)
+        y_values = np.array([10, 20, 30, 40, 50])
+        curve = Curve((x_values, y_values)).opts(apply_hard_bounds=True)
+        plot = bokeh_renderer.get_plot(curve)
+        assert plot.handles['x_range'].bounds == (10, 50)
+
+    def test_apply_hard_bounds_overlay(self):
+        """Test `apply_hard_bounds` with an overlay of curves."""
+        x1_values = np.linspace(10, 50, 5)
+        x2_values = np.linspace(10, 90, 5)
+        y_values = np.array([10, 20, 30, 40, 50])
+        curve1 = Curve((x1_values, y_values))
+        curve2 = Curve((x2_values, y_values))
+        overlay = Overlay([curve1, curve2]).opts(opts.Curve(apply_hard_bounds=True))
+        plot = bokeh_renderer.get_plot(overlay)
+        # Check if the large of the data range can be navigated to
+        assert plot.handles['x_range'].bounds == (10, 90)
+
+    def test_apply_hard_bounds_with_xlim(self):
+        """Test `apply_hard_bounds` with `xlim` set. Initial view should be within xlim but allow panning to data range."""
+        x_values = np.linspace(10, 50, 5)
+        y_values = np.array([10, 20, 30, 40, 50])
+        curve = Curve((x_values, y_values)).opts(apply_hard_bounds=True, xlim=(15, 35))
+        plot = bokeh_renderer.get_plot(curve)
+        initial_view_range = (plot.handles['x_range'].start, plot.handles['x_range'].end)
+        assert initial_view_range == (15, 35)
+        # Check if data beyond xlim can be navigated to
+        assert plot.handles['x_range'].bounds == (10, 50)
+
+    def test_apply_hard_bounds_with_redim_range(self):
+        """Test `apply_hard_bounds` with `.redim.range(x=...)`. Hard bounds should strictly apply."""
+        x_values = np.linspace(10, 50, 5)
+        y_values = np.array([10, 20, 30, 40, 50])
+        curve = Curve((x_values, y_values)).redim.range(x=(25, None)).opts(apply_hard_bounds=True)
+        plot = bokeh_renderer.get_plot(curve)
+        # Expected to strictly adhere to any redim.range bounds, otherwise the data range
+        assert (plot.handles['x_range'].start, plot.handles['x_range'].end)  == (25, 50)
+        assert plot.handles['x_range'].bounds == (25, 50)
+
+    def test_apply_hard_bounds_datetime(self):
+        """Test datetime axes with hard bounds."""
+        target_xlim_l = dt.datetime(2020, 1, 3)
+        target_xlim_h = dt.datetime(2020, 1, 7)
+        dates = [dt.datetime(2020, 1, i) for i in range(1, 11)]
+        values = np.linspace(0, 100, 10)
+        curve = Curve((dates, values)).opts(
+            apply_hard_bounds=True,
+            xlim=(target_xlim_l, target_xlim_h)
+        )
+        plot = bokeh_renderer.get_plot(curve)
+        initial_view_range = (dt_to_int(plot.handles['x_range'].start), dt_to_int(plot.handles['x_range'].end))
+        assert initial_view_range == (dt_to_int(target_xlim_l), dt_to_int(target_xlim_h))
+        # Validate navigation bounds include entire data range
+        hard_bounds = (dt_to_int(plot.handles['x_range'].bounds[0]), dt_to_int(plot.handles['x_range'].bounds[1]))
+        assert hard_bounds == (dt_to_int(dt.datetime(2020, 1, 1)), dt_to_int(dt.datetime(2020, 1, 10)))
+
+    def test_dynamic_map_bounds_update(self):
+        """Test that `apply_hard_bounds` applies correctly when DynamicMap is updated."""
+
+        def curve_data(choice):
+            datasets = {
+                'set1': (np.linspace(0, 5, 100), np.random.rand(100)),
+                'set2': (np.linspace(0, 20, 100), np.random.rand(100)),
+            }
+            x, y = datasets[choice]
+            return Curve((x, y))
+
+        ChoiceStream = Stream.define(
+            'Choice',
+            choice=param.ObjectSelector(default='set1', objects=['set1', 'set2'])
+        )
+        choice_stream = ChoiceStream()
+        dmap = DynamicMap(curve_data, kdims=[], streams=[choice_stream])
+        dmap = dmap.opts(opts.Curve(apply_hard_bounds=True, xlim=(2,3), framewise=True))
+        dmap = dmap.redim.values(choice=['set1', 'set2'])
+        plot = bokeh_renderer.get_plot(dmap)
+
+        # Keeping the xlim consistent between updates, and change data range bounds
+        # Initially select 'set1'
+        dmap.event(choice='set1')
+        assert plot.handles['x_range'].start == 2
+        assert plot.handles['x_range'].end == 3
+        assert plot.handles['x_range'].bounds == (0, 5)
+
+        # Update to 'set2'
+        dmap.event(choice='set2')
+        assert plot.handles['x_range'].start == 2
+        assert plot.handles['x_range'].end == 3
+        assert plot.handles['x_range'].bounds == (0, 20)