Repository: holoviz/holoviews

Issue:
Expose `max_interval` and `min_interval` as options for Bokeh plots
#### Is your feature request related to a problem? Please describe.
Restricting the possible viewport range of the zoom can be achieved with Bokeh with the `min_interval` and [`max_interval`](https://docs.bokeh.org/en/latest/docs/reference/models/ranges.html#bokeh.models.Range1d.max_interval) properties. Restricting the zoomable range is useful for large data handling and, in general, to prevent a user from entering an unhelpful zoom level.

#### Describe the solution you'd like
Expose `min_interval` and `max_interval` in opts.

#### Additional context
The use case in mind is actually restricting the possible zoom-out of a minimap/RangeToolLink plot/widget - which is dependent on [Bokeh #13661](https://github.com/bokeh/bokeh/pull/13561).



Discussion:
Just checking which of my issues are still valid.

This is still a valid issue.

Resolving patch:
diff --git a/holoviews/plotting/bokeh/links.py b/holoviews/plotting/bokeh/links.py
--- a/holoviews/plotting/bokeh/links.py
+++ b/holoviews/plotting/bokeh/links.py
@@ -12,6 +12,7 @@
     VertexTableLink,
 )
 from ..plot import GenericElementPlot, GenericOverlayPlot
+from .util import bokeh34
 
 
 class LinkCallback:
@@ -141,23 +142,46 @@ def __init__(self, root_model, link, source_plot, target_plot):
                 continue
 
             axes[f'{axis}_range'] = target_plot.handles[f'{axis}_range']
-            bounds = getattr(link, f'bounds{axis}', None)
-            if bounds is None:
-                continue
+            interval = getattr(link, f'intervals{axis}', None)
+            if interval is not None and bokeh34:
+                min, max = interval
+                if min is not None:
+                    axes[f'{axis}_range'].min_interval = min
+                if max is not None:
+                    axes[f'{axis}_range'].max_interval = max
+                    self._set_range_for_interval(axes[f'{axis}_range'], max)
 
-            start, end = bounds
-            if start is not None:
-                axes[f'{axis}_range'].start = start
-                axes[f'{axis}_range'].reset_start = start
-            if end is not None:
-                axes[f'{axis}_range'].end = end
-                axes[f'{axis}_range'].reset_end = end
+            bounds = getattr(link, f'bounds{axis}', None)
+            if bounds is not None:
+                start, end = bounds
+                if start is not None:
+                    axes[f'{axis}_range'].start = start
+                    axes[f'{axis}_range'].reset_start = start
+                if end is not None:
+                    axes[f'{axis}_range'].end = end
+                    axes[f'{axis}_range'].reset_end = end
 
         tool = RangeTool(**axes)
         source_plot.state.add_tools(tool)
         if toolbars:
             toolbars[0].tools.append(tool)
 
+    def _set_range_for_interval(self, axis, max):
+        # Changes the existing Range1d axis range to be in the interval
+        for n in ("", "reset_"):
+            start = getattr(axis, f"{n}start")
+            try:
+                end = start + max
+            except Exception as e:
+                # Handle combinations of datetime axis and timedelta interval
+                # Likely a better way to do this
+                try:
+                    import pandas as pd
+                    end = (pd.array([start]) + pd.array([max]))[0]
+                except Exception:
+                    raise e from None
+            setattr(axis, f"{n}end", end)
+
 
 class DataLinkCallback(LinkCallback):
     """
diff --git a/holoviews/plotting/links.py b/holoviews/plotting/links.py
--- a/holoviews/plotting/links.py
+++ b/holoviews/plotting/links.py
@@ -109,6 +109,12 @@ class RangeToolLink(Link):
     boundsy = param.Tuple(default=None, length=2, doc="""
         (start, end) bounds for the y-axis""")
 
+    intervalsx = param.Tuple(default=None, length=2, doc="""
+        (min, max) intervals for the x-axis""")
+
+    intervalsy = param.Tuple(default=None, length=2, doc="""
+        (min, max) intervals for the y-axis""")
+
     _requires_target = True
 
 


Test patch:
diff --git a/holoviews/tests/conftest.py b/holoviews/tests/conftest.py
--- a/holoviews/tests/conftest.py
+++ b/holoviews/tests/conftest.py
@@ -74,33 +74,30 @@ def ibis_sqlite_backend():
         ibis.set_backend(None)
 
 
-@pytest.fixture
-def bokeh_backend():
-    hv.renderer("bokeh")
+def _plotting_backend(backend):
+    pytest.importorskip(backend)
+    if not hv.extension._loaded:
+        hv.extension(backend)
+    hv.renderer(backend)
     prev_backend = hv.Store.current_backend
-    hv.Store.current_backend = "bokeh"
+    hv.Store.current_backend = backend
     yield
     hv.Store.current_backend = prev_backend
 
 
+@pytest.fixture
+def bokeh_backend():
+    yield from _plotting_backend("bokeh")
+
+
 @pytest.fixture
 def mpl_backend():
-    pytest.importorskip("matplotlib")
-    hv.renderer("matplotlib")
-    prev_backend = hv.Store.current_backend
-    hv.Store.current_backend = "matplotlib"
-    yield
-    hv.Store.current_backend = prev_backend
+    yield from _plotting_backend("matplotlib")
 
 
 @pytest.fixture
 def plotly_backend():
-    pytest.importorskip("plotly")
-    hv.renderer("plotly")
-    prev_backend = hv.Store.current_backend
-    hv.Store.current_backend = "plotly"
-    yield
-    hv.Store.current_backend = prev_backend
+    yield from _plotting_backend("plotly")
 
 
 @pytest.fixture
diff --git a/holoviews/tests/ui/bokeh/test_links.py b/holoviews/tests/ui/bokeh/test_links.py
new file mode 100644
--- /dev/null
+++ b/holoviews/tests/ui/bokeh/test_links.py
@@ -0,0 +1,53 @@
+from datetime import timedelta
+
+import numpy as np
+import pandas as pd
+import pytest
+from bokeh.sampledata.stocks import AAPL
+
+import holoviews as hv
+from holoviews.plotting.links import RangeToolLink
+
+from .. import expect
+
+pytestmark = pytest.mark.ui
+
+
+@pytest.mark.usefixtures("bokeh_backend")
+@pytest.mark.parametrize(
+    ["index", "intervalsx", "x_range_src", "x_range_tgt"],
+    [
+        (range(len(AAPL["date"])), (100, 365), (0, 365), (0, 3269)),
+        (
+            pd.to_datetime(AAPL["date"]),
+            (timedelta(days=100), timedelta(days=365)),
+            (
+                np.array(["2000-03-01"], dtype="datetime64[ns]")[0],
+                pd.Timestamp("2001-03-01"),
+            ),
+            np.array(["2000-03-01", "2013-03-01"], dtype="datetime64[ns]"),
+        ),
+    ],
+    ids=["int", "datetime"],
+)
+def test_rangetool_link_interval(serve_hv, index, intervalsx, x_range_src, x_range_tgt):
+    df = pd.DataFrame(AAPL["close"], columns=["close"], index=index)
+    df.index.name = "Date"
+
+    aapl_curve = hv.Curve(df, "Date", ("close", "Price ($)"))
+    tgt = aapl_curve.relabel("AAPL close price").opts(width=800, labelled=["y"])
+    src = aapl_curve.opts(width=800, height=100, yaxis=None)
+
+    RangeToolLink(src, tgt, axes=["x", "y"], intervalsx=intervalsx)
+    layout = (tgt + src).cols(1)
+    layout.opts(hv.opts.Layout(shared_axes=False))
+
+    page = serve_hv(layout)
+    hv_plot = page.locator(".bk-events")
+    expect(hv_plot).to_have_count(2)
+
+    bk_model = hv.render(layout)
+    bk_src = bk_model.children[0][0]
+    np.testing.assert_equal((bk_src.x_range.start, bk_src.x_range.end), x_range_src)
+    bk_tgt = bk_model.children[1][0]
+    np.testing.assert_equal((bk_tgt.x_range.start, bk_tgt.x_range.end), x_range_tgt)