Repository: bokeh/bokeh

Issue:
Feature request: Constrain zoom out to fixed range/domain
On Twitter @nfaggian asked about being able to constrain the zoom tool to within a fixed range/domain on a plot. It sounds like a reasonable feature to me.



Discussion:
I was just looking at #1450 which asks for some similar improvements. 

Are these constraints a feature on a the WheelZoomTool or on the axis?

I'd imagine it'd be on the zoom tool where the python spelling would be like:

``` python
from bokeh.models import WheelZoomTool
wheelzoom = WheelZoomTool(x_range=(0,100), y_range(0,100))
### or
wheelzoom = WheelZoomTool(x_min=0, x_max=100, y_min=0, y_max=100) 
```

Then on the BokehJS side, you just kill the zoom event if you're at the limit of the zoom range.

Folks! really glad to see this being discussed. Wonder if in the absence of explicitly setting constraints on the zoom tool, rather a fraction of the bounding box extent could be used to infer max/min zoom.

+1 this would be pretty useful. Too easy to zoom/scroll away from the plot entirely, or zoom in crazy far. Thanks for thinking about it.

A PR was recently merged to the BoxZoomTool where the zoom was killed if the zoom box was smaller than 5 pixels in height/width - assuming the user didn't intend the zoom and tried to "close" the box. I think it is a fairly opinionated feature, but feels very natural in terms of UX.

I'm becoming convinced that @nfaggian 's recommendation of adding zoom constraints based on the data ranges (perhaps a 1x data range buffer around the default range) would be a beneficial feature.
I can't immediately think of a use case where zooming way beyond the plot data is valuable to the user. 

To make this optional, we could add a zoom/pan_limit bool flag to the plotObject to make this optional.

It is kind of a big change. @birdsarah @bryevdv Do either you of have feedback on adding a scroll constraint in terms of UX and cases where it'd be a problem?

also ref issue #2427, #2565, #572, #2113, 

Would this proposal have any effect on the limits of the PanZoomTool?  If this does turn out to be a big change, is this the place to introduce aspect ratio on the zooming too?

I'm glad there is discussion on this we should implement some solution soon. Just so that it is on the radar: the complicating case is when there are multiple ranges. 

> On Oct 5, 2015, at 06:42, Tony Fast notifications@github.com wrote:
> 
> Would this proposal have any effect on the limits of the PanZoomTool? If this does turn out to be a big change, is this the place to introduce aspect ratio on the zooming too?
> 
> —
> Reply to this email directly or view it on GitHub.

@bryevdv Also really glad to see this being discussed! What about just using a factor of the maximum range in both x and y?

> I can't immediately think of a use case where zooming way beyond the
> plot data is valuable to the user.

For me, if your data is updating based on your ranges e.g. in a map or
in any kind of remote data source, then you definitely want to zoom
outside the current plot data.

Having said that having optional/configurable constraints would be super
helpful.

To my mind its particularly helpful when you are zooming out beyond plot
data that you can't see yet (because it needs to load from a server) as
it constrains that action appropriately when the user has no other
option for feedback.


Resolving patch:
diff --git a/bokeh/core/properties.py b/bokeh/core/properties.py
--- a/bokeh/core/properties.py
+++ b/bokeh/core/properties.py
@@ -1359,9 +1359,50 @@ def __str__(self):
         return self.__class__.__name__
 
 
+class MinMaxBounds(Either):
+    """ Accepts min and max bounds for use with Ranges.
+
+    Bounds are provided as a tuple of ``(min, max)`` so regardless of whether your range is
+    increasing or decreasing, the first item should be the minimum value of the range and the
+    second item should be the maximum. Setting min > max will result in a ``ValueError``.
+
+    Setting bounds to None will allow your plot to pan/zoom as far as you want. If you only
+    want to constrain one end of the plot, you can set min or max to
+    ``None`` e.g. ``DataRange1d(bounds=(None, 12))`` """
+
+    def __init__(self, accept_datetime=False, default='auto', help=None):
+        if accept_datetime:
+            types = (
+                Auto,
+                Tuple(Float, Float),
+                Tuple(Datetime, Datetime),
+            )
+        else:
+            types = (
+                Auto,
+                Tuple(Float, Float),
+            )
+        super(MinMaxBounds, self).__init__(*types, default=default, help=help)
+
+    def validate(self, value):
+        super(MinMaxBounds, self).validate(value)
+
+        if value is None:
+            pass
+
+        elif value[0] is None or value[1] is None:
+            pass
+
+        elif value[0] >= value[1]:
+            raise ValueError('Invalid bounds: maximum smaller than minimum. Correct usage: bounds=(min, max)')
+
+        return True
+
+
 class Align(PropertyDescriptor):
     pass
 
+
 class DashPattern(Either):
     """ Dash type property.
 
diff --git a/bokeh/models/ranges.py b/bokeh/models/ranges.py
--- a/bokeh/models/ranges.py
+++ b/bokeh/models/ranges.py
@@ -8,10 +8,13 @@
 from ..model import Model
 from ..core.enums import StartEnd
 from ..core.properties import abstract
-from ..core.properties import Auto, Bool, Int, Float, String, Datetime, Instance, List, Either, Enum
+from ..core.properties import (
+    Auto, Bool, Int, Float, String, Datetime, Instance, List, Either, Enum, MinMaxBounds
+)
 from .callbacks import Callback
 from .renderers import Renderer
 
+
 @abstract
 class Range(Model):
     """ A base class for all range types. ``Range`` is not generally
@@ -23,6 +26,7 @@ class Range(Model):
     A callback to run in the browser whenever the range is updated.
     """)
 
+
 class Range1d(Range):
     """ A fixed, closed range [start, end] in a continuous scalar
     dimension.
@@ -43,16 +47,38 @@ class Range1d(Range):
     The end of the range.
     """)
 
+    bounds = MinMaxBounds(accept_datetime=True, help="""
+    The bounds that the range is allowed to go to - typically used to prevent
+    the user from panning/zooming/etc away from the data.
+
+    By default, the bounds will be computed to the start and end of the Range.
+
+    Bounds are provided as a tuple of ``(min, max)`` so regardless of whether your range is
+    increasing or decreasing, the first item should be the minimum value of the range and the
+    second item should be the maximum. Setting min > max will result in a ``ValueError``.
+
+    Setting bounds to ``None`` will allow your plot to pan/zoom as far as you want. If you only
+    want to constrain one end of the plot, you can set min or max to None.
+
+    Examples:
+
+        Range1d(0, 1)  # Increasing range, auto-bounded to 0 and 1 (Default behavior)
+        Range1d(start=0, end=1, bounds=None)  # Unbounded range
+    """)
+
     def __init__(self, *args, **kwargs):
         if args and ('start' in kwargs or 'end' in kwargs):
             raise ValueError("'start' and 'end' keywords cannot be used with positional arguments")
-        elif args and len(args) != 2:
+        if args and len(args) != 2:
             raise ValueError('Only Range1d(start, end) acceptable when using positional arguments')
-        elif args:
+
+        if args:
             kwargs['start'] = args[0]
             kwargs['end'] = args[1]
+
         super(Range1d, self).__init__(**kwargs)
 
+
 @abstract
 class DataRange(Range):
     """ A base class for all data range types. ``DataRange`` is not
@@ -71,9 +97,10 @@ class DataRange(Range):
     defaults to all renderers on a plot.
     """)
 
+
 class DataRange1d(DataRange):
     """ An auto-fitting range in a continuous scalar dimension.
-
+    The upper and lower bounds are set to the min and max of the data.
     """
 
     range_padding = Float(default=0.1, help="""
@@ -91,6 +118,21 @@ class DataRange1d(DataRange):
     automatically computed end value.
     """)
 
+    bounds = MinMaxBounds(accept_datetime=False, help="""
+    The bounds that the range is allowed to go to - typically used to prevent
+    the user from panning/zooming/etc away from the data.
+
+    By default, the bounds will be computed to be the same as the start and end of the DataRange1d.
+
+    Bounds are provided as a tuple of ``(min, max)`` so regardless of whether your range is
+    increasing or decreasing, the first item should be the minimum value of the range and the
+    second item should be the maximum. Setting min > max will result in a ``ValueError``.
+
+    Setting bounds to None will allow your plot to pan/zoom as far as you want. If you only
+    want to constrain one end of the plot, you can set min or max to
+    ``None`` e.g. ``DataRange1d(bounds=(None, 12))``
+    """)
+
     flipped = Bool(default=False, help="""
     Whether the range should be "flipped" from its normal direction when
     auto-ranging.
@@ -111,6 +153,7 @@ class DataRange1d(DataRange):
     If set to ``None`` (default), then auto-ranging does not follow, and
     the range will encompass both the minimum and maximum data values.
 
+    ``follow`` cannot be used with bounds, and if set, bounds will be set to ``None``.
     """)
 
     follow_interval = Float(default=None, help="""
@@ -127,6 +170,10 @@ class DataRange1d(DataRange):
     A default width for the interval, in case ``start`` is equal to ``end``.
     """)
 
+    def __init__(self, *args, **kwargs):
+        if kwargs.get('follow') is not None:
+            kwargs['bounds'] = None
+        super(DataRange1d, self).__init__(**kwargs)
 
 
 class FactorRange(Range):
@@ -159,6 +206,42 @@ class FactorRange(Range):
     this categorical range.
     """)
 
+    bounds = Either(Auto, List(String), List(Int), help="""
+    The bounds that the range is allowed to go to - typically used to prevent
+    the user from panning/zooming/etc away from the data.
+
+    Unlike Range1d and DataRange1d, factors do not have an order and so a min and max cannot be
+    provied in the same way. bounds accepts a list of factors, that constrain the displayed factors.
+
+    The plot is then constrained to not pan or zoom beyond the first and last item in the list of
+    factors. Providing None allows unlimited panning or zooming.
+
+    By default, bounds will be the same as factors and the plot will not be able to
+    pan or zoom beyond the first and last items in factors.
+
+    If you provide a list, then only the factors that are in that list will be displayed on the
+    plot and the plot will not pan or zoom outside the first and last items in the shortened
+    factors list. Note the order of factors is the defining order for your plot.
+
+    Values of bounds that are not in factors are acceptable and will simply have no impact
+    on the plot.
+
+    Examples
+    --------
+
+    Default behavior:
+        x_range = FactorRange(factors=["apples", "dogs", "peaches", "bananas", "pigs"])
+
+        The plot will display all the factors and you will not be able to pan left of apples or right
+        of pigs.
+
+    Constraining behavior:
+        x_range = FactorRange(factors=["apples", "dogs", "peaches", "bananas", "pigs"], bounds=["apples", "bananas", "peaches"])
+
+        The plot will display the chart with only the factors ["apples", "peaches", "bananas"] (in that order)
+        and the plot will not pan left of apples or right of bananas.
+    """)
+
     def __init__(self, *args, **kwargs):
         if args and "factors" in kwargs:
             raise ValueError("'factors' keyword cannot be used with positional arguments")
diff --git a/examples/interactions/prevent_user_zooming_or_panning_too_far.py b/examples/interactions/prevent_user_zooming_or_panning_too_far.py
new file mode 100644
--- /dev/null
+++ b/examples/interactions/prevent_user_zooming_or_panning_too_far.py
@@ -0,0 +1,112 @@
+from datetime import date
+import numpy as np
+from bokeh.charts import HeatMap
+from bokeh.io import output_file, show, vplot, hplot
+from bokeh.models import Range1d, LinearAxis
+from bokeh.plotting import figure
+from bokeh.sampledata.stocks import AAPL, GOOG
+
+output_file('prevent_user_zooming_or_panning_too_far.html')
+
+## Plot where limits are set automatically by DataRange1d
+N = 4000
+x = np.random.random(size=N) * 100
+y = np.random.random(size=N) * 100
+radii = np.random.random(size=N) * 1.5
+colors = ["#%02x%02x%02x" % (int(r), int(g), 150) for r, g in zip(50 + 2 * x, 30 + 2 * y)]
+plot_datarange = figure(tools='pan, box_zoom, wheel_zoom, reset', title="Auto bounded (cannot pan outside data)")
+plot_datarange.scatter(x, y, radius=radii, fill_color=colors, fill_alpha=0.6, line_color=None)
+
+
+## Plot where limits are manually set
+
+###### -- ranges set here -- ########
+x_range = Range1d(0, 3, bounds=(-1, 3.5))
+y_range = Range1d(0, 3, bounds=(-0.5, 4))
+###### -- end -- ########
+plot_range = figure(tools='pan, wheel_zoom, reset', x_range=x_range, y_range=y_range, title="Manual bounds x:(-1, 3.5) y:(-0.5, 4)")
+plot_range.rect(x=[1, 2], y=[1, 1], width=0.9, height=0.9)
+
+
+
+## Manually unbounded (except for y_max) - range1d defaults to Auto so if you don't want bounds, you need to say so
+
+###### -- ranges set here -- ########
+x_range = Range1d(0, 3, bounds=None)
+y_range = Range1d(0, 3, bounds=(None, 4))
+###### -- end -- ########
+plot_range_un = figure(tools='pan, wheel_zoom, reset', x_range=x_range, y_range=y_range, title="Unbounded (except for y_max=4)")
+plot_range_un.rect(x=[1, 2], y=[1, 1], width=0.9, height=0.9, color='#043A8D')
+
+
+
+## Bounded on reversed ranges (except for y_max)
+
+###### -- ranges set here -- ########
+x_range = Range1d(3, 0, bounds=(-1, 3.5))
+y_range = Range1d(3, 0, bounds=(-0.5, 4))
+###### -- end -- ########
+plot_range_rev = figure(tools='pan, wheel_zoom, reset', x_range=x_range, y_range=y_range, title="Manual bounds x:(-1, 3.5) y:(-0.5, 4) (reverse ranges)")
+plot_range_rev.rect(x=[1, 2], y=[1, 1], width=0.9, height=0.9, color='#8CBEDB')
+
+
+
+## Chart where limits are set on a categorical range
+fruits = {
+    'fruit': [
+        'apples', 'apples', 'apples', 'apples', 'apples',
+        'pears', 'pears', 'pears', 'pears', 'pears',
+        'bananas', 'bananas', 'bananas', 'bananas', 'bananas'
+    ],
+    'fruit_count': [
+        4, 5, 8, 12, 4,
+        6, 5, 4, 8, 7,
+        1, 2, 4, 8, 12
+    ],
+    'year': [
+        '2009', '2010', '2011', '2012', '2013',
+        '2009', '2010', '2011', '2012', '2013',
+        '2009', '2010', '2011', '2012', '2013']
+}
+plot_cat_autobounded = HeatMap(fruits, y='year', x='fruit', values='fruit_count', stat=None, title="Heatmap (autobounded)")
+
+plot_cat_unbounded = HeatMap(fruits, y='year', x='fruit', values='fruit_count', stat=None, title="Heatmap (unbounded)")
+###### -- ranges set here -- ########
+plot_cat_unbounded.x_range.bounds = None
+plot_cat_unbounded.y_range.bounds = None
+###### -- end -- ########
+
+plot_cat_bounded = HeatMap(fruits, y='year', x='fruit', values='fruit_count', stat=None, title="Heatmap with bounds x:['apples', 'pears'], y:['2009', '2010', '2013']")
+###### -- ranges set here -- ########
+plot_cat_bounded.x_range.bounds = ['apples', 'pears']
+plot_cat_bounded.y_range.bounds = ['2009', '2010', '2013']
+###### -- end -- ########
+
+
+## Plot with multiple ranges that are bounded
+x = np.array(AAPL['date'], dtype=np.datetime64)
+x = x[0:1000]
+apple_y = AAPL['adj_close'][0:1000]
+google_y = GOOG['adj_close'][0:1000]
+
+###### -- ranges set here -- ########
+x_range = Range1d(
+    start=date(2000, 1, 1), end=date(2004, 12, 31),
+    bounds=(date(2001, 1, 1), date(2006, 12, 31))
+)
+y_range = Range1d(start=00, end=40, bounds=(10, 60))
+y_range_extra = Range1d(start=300, end=700, bounds=(200, 1000))
+###### -- end -- ########
+
+plot_extra = figure(x_axis_type="datetime", x_range=x_range, y_range=y_range, title="Multiple ranges x:(2001/1/1, 2006/12/31), y1:(10, 60), y2:(200, 1000)")
+plot_extra.line(x, apple_y, color='lightblue')
+plot_extra.extra_y_ranges = {'goog': y_range_extra}
+plot_extra.line(x, google_y, color='pink', y_range_name='goog')
+plot_extra.add_layout(LinearAxis(y_range_name="goog", major_label_text_color='pink'), 'left')
+
+# Tweak the formats to make it all readable
+plots = [plot_datarange, plot_range, plot_range_un, plot_range_rev, plot_cat_autobounded, plot_cat_unbounded, plot_cat_bounded, plot_extra]
+for plot in plots:
+    plot.title_text_font_size = '12pt'
+
+show(vplot(plot_datarange, hplot(plot_range, plot_range_un, plot_range_rev), hplot(plot_cat_autobounded, plot_cat_unbounded, plot_cat_bounded), plot_extra))


Test patch:
diff --git a/bokeh/core/tests/test_properties.py b/bokeh/core/tests/test_properties.py
--- a/bokeh/core/tests/test_properties.py
+++ b/bokeh/core/tests/test_properties.py
@@ -1,4 +1,5 @@
 from __future__ import absolute_import
+import datetime
 import unittest
 import numpy as np
 from copy import copy
@@ -7,7 +8,7 @@
     HasProps, NumberSpec, ColorSpec, Bool, Int, Float, Complex, String,
     Regex, List, Dict, Tuple, Array, Instance, Any, Interval, Either,
     Enum, Color, Align, DashPattern, Size, Percent, Angle, AngleSpec,
-    DistanceSpec, Override, Include)
+    DistanceSpec, Override, Include, MinMaxBounds)
 
 class Basictest(unittest.TestCase):
 
@@ -1415,6 +1416,35 @@ def test_Angle(self):
         self.assertFalse(prop.is_valid({}))
         self.assertFalse(prop.is_valid(Foo()))
 
+    def test_MinMaxBounds_with_no_datetime(self):
+        prop = MinMaxBounds(accept_datetime=False)
+
+        # Valid values
+        self.assertTrue(prop.is_valid('auto'))
+        self.assertTrue(prop.is_valid(None))
+        self.assertTrue(prop.is_valid((12, 13)))
+        self.assertTrue(prop.is_valid((-32, -13)))
+        self.assertTrue(prop.is_valid((12.1, 13.1)))
+        self.assertTrue(prop.is_valid((None, 13.1)))
+        self.assertTrue(prop.is_valid((-22, None)))
+
+        # Invalid values
+        self.assertFalse(prop.is_valid('string'))
+        self.assertFalse(prop.is_valid(12))
+        self.assertFalse(prop.is_valid(('a', 'b')))
+        self.assertFalse(prop.is_valid((13, 12)))
+        self.assertFalse(prop.is_valid((13.1, 12.2)))
+        self.assertFalse(prop.is_valid((datetime.date(2012, 10, 1), datetime.date(2012, 12, 2))))
+
+    def test_MinMaxBounds_with_datetime(self):
+        prop = MinMaxBounds(accept_datetime=True)
+
+        # Valid values
+        self.assertTrue(prop.is_valid((datetime.date(2012, 10, 1), datetime.date(2012, 12, 2))))
+
+        # Invalid values
+        self.assertFalse(prop.is_valid((datetime.date(2012, 10, 1), 22)))
+
 def test_HasProps_clone():
     from bokeh.models import Plot
     p1 = Plot(plot_width=1000)
diff --git a/bokeh/models/tests/test_ranges.py b/bokeh/models/tests/test_ranges.py
new file mode 100644
--- /dev/null
+++ b/bokeh/models/tests/test_ranges.py
@@ -0,0 +1,109 @@
+from __future__ import absolute_import
+
+import pytest
+
+from bokeh.models import Range1d, DataRange1d, FactorRange
+
+
+# ------------------------
+# Range1d test
+# ------------------------
+
+def test_range1d_init_with_positional_arguments():
+    range1d = Range1d(1, 2)
+    assert range1d.start == 1
+    assert range1d.end == 2
+    assert range1d.bounds == 'auto'
+
+
+def test_range1d_init_with_keyword_arguments():
+    range1d = Range1d(start=1, end=2)
+    assert range1d.start == 1
+    assert range1d.end == 2
+    assert range1d.bounds == 'auto'
+
+
+def test_range1d_cannot_initialize_with_both_keyword_and_positional_arguments():
+    with pytest.raises(ValueError):
+        Range1d(1, 2, start=1, end=2)
+
+
+def test_range1d_cannot_initialize_with_three_positional_arguments():
+    with pytest.raises(ValueError):
+        Range1d(1, 2, 3)
+
+
+def test_range1d_with_max_bound_smaller_than_min_bounded_raises_valueerror():
+    with pytest.raises(ValueError):
+        Range1d(1, 2, bounds=(1, 0))
+    with pytest.raises(ValueError):
+        Range1d(1, 2, bounds=[1, 0])
+
+
+def test_range1d_bounds_with_text_rejected_as_the_correct_value_error():
+    with pytest.raises(ValueError) as e:
+        Range1d(1, 2, bounds="21")  # The string is indexable, so this may not fail properly
+    assert e.value.args[0].startswith('expected an element of either')
+
+
+def test_range1d_bounds_with_three_item_tuple_raises_valueerror():
+    with pytest.raises(ValueError):
+        Range1d(1, 2, bounds=(0, 1, 2))
+
+
+# ------------------------
+# DataRange1d test
+# ------------------------
+
+def test_datarange1d_init_with_no_arguments():
+    datarange1d = DataRange1d()
+    assert datarange1d.start is None
+    assert datarange1d.end is None
+    assert datarange1d.bounds == 'auto'
+
+
+def test_datarange1d_init_with_follow_sets_bounds_to_none():
+    datarange1d = DataRange1d(follow="start")
+    assert datarange1d.follow == "start"
+    assert datarange1d.bounds is None
+
+
+def test_datarange1d_init_with_bad_bounds():
+    with pytest.raises(ValueError):
+        DataRange1d(1, 2, bounds=(1, 0))
+    with pytest.raises(ValueError):
+        DataRange1d(1, 2, bounds=[1, 0])
+    with pytest.raises(ValueError):
+        Range1d(1, 2, bounds="21")
+
+
+# ------------------------
+# Factor range test
+# ------------------------
+
+def test_factorrange_init_with_positional_arguments():
+    factor_range = FactorRange(1, 2)
+    assert factor_range.factors == [1, 2]
+    assert factor_range.bounds == 'auto'
+
+
+def test_factorrange_init_with_keyword_arguments():
+    factor_range = FactorRange(factors=[1, 2, 3, 4, 5])
+    assert factor_range.factors == [1, 2, 3, 4, 5]
+    assert factor_range.bounds == 'auto'
+
+
+def test_factorrange_with_bounds():
+    factor_range = FactorRange(factors=[1, 2, 3, 4, 5], bounds=[2, 6])
+    assert factor_range.factors == [1, 2, 3, 4, 5]
+    assert factor_range.bounds == [2, 6]
+
+
+def test_factorrange_cannot_initialize_with_both_keyword_and_positional_arguments():
+    with pytest.raises(ValueError):
+        Range1d([1, 2, 3], factors=[1, 2, 3])
+
+
+def test_factorrange_cannot_initialize_with_list_as_positional_argument():
+    with pytest.raises(ValueError):
+        FactorRange([1, 2, 3, 4])
diff --git a/bokeh/tests/test_models_ranges.py b/bokeh/tests/test_models_ranges.py
deleted file mode 100644
--- a/bokeh/tests/test_models_ranges.py
+++ /dev/null
@@ -1,36 +0,0 @@
-from __future__ import absolute_import
-import unittest
-
-
-class TestRange1d(unittest.TestCase):
-
-    def setUp(self):
-        from bokeh.models.ranges import Range1d
-        self.range1d = Range1d
-
-    def test_init(self):
-        self.assertRaises(ValueError, self.range1d, 1, 2, start=1, end=2)
-        self.assertRaises(ValueError, self.range1d, 1, 2, 3)
-        range1d = self.range1d(1, 2)
-        assert range1d
-        range1d = self.range1d(start=1, end=2)
-        assert range1d
-
-class TestFactorRange(unittest.TestCase):
-
-    def setUp(self):
-        from bokeh.models.ranges import FactorRange
-        self.factorRange = FactorRange
-
-    def test_init(self):
-        self.assertRaises(ValueError, self.factorRange, [1, 2, 3], factors=[1, 2, 3])
-        self.assertRaises(ValueError, self.factorRange, [1, 2, 3, 4])
-        factorRange = self.factorRange(1, 2)
-        assert factorRange
-        factorRange = self.factorRange(factors=[1, 2, 3, 4, 5])
-        assert factorRange
-
-
-
-if __name__ == "__main__":
-    unittest.main()
diff --git a/tests/integration/interaction/test_range_min_max.py b/tests/integration/interaction/test_range_min_max.py
new file mode 100644
--- /dev/null
+++ b/tests/integration/interaction/test_range_min_max.py
@@ -0,0 +1,269 @@
+from __future__ import absolute_import
+
+from bokeh.io import save
+from bokeh.plotting import figure
+from bokeh.models import CustomJS, Range1d, DataRange1d
+from selenium.webdriver.common.action_chains import ActionChains
+
+import pytest
+pytestmark = pytest.mark.integration
+
+
+def make_pan_plot_with_callback(xr=None, yr=None):
+    if xr is None:
+        x_range = Range1d(0, 3, bounds=None)
+    else:
+        x_range = xr
+    x_callback = CustomJS(args=dict(x_range=x_range), code="""
+        window.get_x_range_start = function() {
+            return x_range.get('start');
+        }
+        window.get_x_range_end = function() {
+            return x_range.get('end');
+        }
+    """)
+    x_range.callback = x_callback
+
+    if yr is None:
+        y_range = Range1d(0, 3, bounds=None)
+    else:
+        y_range = yr
+    y_callback = CustomJS(args=dict(y_range=y_range), code="""
+        window.get_y_range_start = function() {
+            return y_range.get('start');
+        }
+        window.get_y_range_end = function() {
+            return y_range.get('end');
+        }
+    """)
+    y_range.callback = y_callback
+
+    plot = figure(
+        height=400, width=400, tools='pan,box_zoom,reset', x_range=x_range, y_range=y_range
+    )
+    plot.min_border = 0
+    plot.rect(x=[1, 2], y=[1, 1], width=0.9, height=0.9)
+    return plot
+
+
+def pan_plot(selenium, pan_x=None, pan_y=None):
+    # Enable the pan tool
+    pan_buttons = selenium.find_elements_by_css_selector('.bk-button-bar-list[type="pan"] button')
+    pan_button = pan_buttons[0]
+    if 'active' not in pan_button.get_attribute('class'):
+        pan_button.click()
+
+    canvas = selenium.find_element_by_tag_name('canvas')
+    actions = ActionChains(selenium)
+    actions.move_to_element_with_offset(canvas, 200, 200)
+    actions.click_and_hold()
+    actions.move_by_offset(pan_x, pan_y)
+    actions.release()
+    actions.perform()
+
+
+def zoom_plot(selenium):
+    # Enable the box zoom tool
+    pan_buttons = selenium.find_elements_by_css_selector('.bk-button-bar-list[type="pan"] button')
+    zoom_button = pan_buttons[1]
+    if 'active' not in zoom_button.get_attribute('class'):
+        zoom_button.click()
+
+    canvas = selenium.find_element_by_tag_name('canvas')
+    actions = ActionChains(selenium)
+    actions.move_to_element_with_offset(canvas, 10, 10)
+    actions.click_and_hold()
+    actions.move_by_offset(200, 200)
+    actions.release()
+    actions.perform()
+
+
+def test_range_with_callback_triggers_alert(output_file_url, selenium):
+    # Simple test to ensure range callbacks are working
+    # Rest of tests in this file depend on range callback.
+
+    plot = make_pan_plot_with_callback()
+    initial_start = plot.x_range.start
+    save(plot)
+    selenium.get(output_file_url)
+
+    # Pan plot and test for new range value
+    pan_plot(selenium, pan_x=100, pan_y=100)
+    new_range_start = float(selenium.execute_script("""alert(window.get_x_range_start())"""))
+    selenium.switch_to_alert().dismiss()
+    assert new_range_start < initial_start
+
+
+def test_x_range_does_not_pan_left_of_x_min(output_file_url, selenium):
+    x_range_min = -1
+    plot = make_pan_plot_with_callback(xr=Range1d(0, 3, bounds=(x_range_min, None)))
+    save(plot)
+    selenium.get(output_file_url)
+
+    # Pan plot and test for new range value
+    pan_plot(selenium, pan_x=200, pan_y=0)
+    new_range_start = float(selenium.execute_script("""alert(window.get_x_range_start())"""))
+    selenium.switch_to_alert().dismiss()
+    assert round(new_range_start) == x_range_min
+
+
+def test_x_range_does_not_pan_right_of_x_max(output_file_url, selenium):
+    x_range_max = 4
+    plot = make_pan_plot_with_callback(xr=Range1d(0, 3, bounds=(None, x_range_max)))
+    save(plot)
+    selenium.get(output_file_url)
+
+    # Pan plot and test for new range value
+    pan_plot(selenium, pan_x=-200, pan_y=0)
+    new_range_end = float(selenium.execute_script("""alert(window.get_x_range_end())"""))
+    selenium.switch_to_alert().dismiss()  # This is not necessary but assists debugging
+    assert round(new_range_end) == x_range_max
+
+
+def test_y_range_does_not_pan_below_y_min(output_file_url, selenium):
+    y_range_min = -1
+    plot = make_pan_plot_with_callback(yr=Range1d(0, 3, bounds=(y_range_min, None)))
+    save(plot)
+    selenium.get(output_file_url)
+
+    # Pan plot and test for new range value
+    pan_plot(selenium, pan_x=50, pan_y=-150)
+    new_range_start = float(selenium.execute_script("""alert(window.get_y_range_start())"""))
+    selenium.switch_to_alert().dismiss()  # This is not necessary but assists debugging
+    assert round(new_range_start) == y_range_min
+
+
+def test_y_range_does_not_pan_above_y_max(output_file_url, selenium):
+    y_range_max = 4
+    plot = make_pan_plot_with_callback(yr=Range1d(0, 3, bounds=(None, y_range_max)))
+    save(plot)
+    selenium.get(output_file_url)
+
+    # Pan plot and test for new range value
+    pan_plot(selenium, pan_x=50, pan_y=150)
+    new_range_end = float(selenium.execute_script("""alert(window.get_y_range_end())"""))
+    selenium.switch_to_alert().dismiss()  # This is not necessary but assists debugging
+    assert round(new_range_end) == y_range_max
+
+
+############################
+# Test reversed ranges
+############################
+
+def test_reversed_x_range_does_not_pan_right_of_x_min(output_file_url, selenium):
+    x_range_min = -1
+    plot = make_pan_plot_with_callback(xr=Range1d(3, 0, bounds=(x_range_min, None)))
+    save(plot)
+    selenium.get(output_file_url)
+
+    # Pan plot and test for new range value
+    pan_plot(selenium, pan_x=-200, pan_y=0)
+    new_range_start = float(selenium.execute_script("""alert(window.get_x_range_end())"""))
+    selenium.switch_to_alert().dismiss()
+    assert round(new_range_start) == x_range_min
+
+
+def test_reversed_x_range_does_not_pan_left_of_x_max(output_file_url, selenium):
+    x_range_max = 4
+    plot = make_pan_plot_with_callback(xr=Range1d(3, 0, bounds=(None, x_range_max)))
+    save(plot)
+    selenium.get(output_file_url)
+
+    # Pan plot and test for new range value
+    pan_plot(selenium, pan_x=200, pan_y=0)
+    new_range_end = float(selenium.execute_script("""alert(window.get_x_range_start())"""))
+    selenium.switch_to_alert().dismiss()  # This is not necessary but assists debugging
+    assert round(new_range_end) == x_range_max
+
+
+def test_reversed_y_range_does_not_pan_above_y_min(output_file_url, selenium):
+    y_range_min = -1
+    plot = make_pan_plot_with_callback(yr=Range1d(3, 0, bounds=(y_range_min, None)))
+    save(plot)
+    selenium.get(output_file_url)
+
+    # Pan plot and test for new range value
+    pan_plot(selenium, pan_x=50, pan_y=150)
+
+    new_range_start = float(selenium.execute_script("""alert(window.get_y_range_end())"""))
+    selenium.switch_to_alert().dismiss()
+    assert round(new_range_start) == y_range_min
+
+
+def test_reversed_y_range_does_not_pan_below_y_max(output_file_url, selenium):
+    y_range_max = 4
+    plot = make_pan_plot_with_callback(yr=Range1d(3, 0, bounds=(None, y_range_max)))
+    save(plot)
+    selenium.get(output_file_url)
+
+    # Pan plot and test for new range value
+    pan_plot(selenium, pan_x=50, pan_y=-150)
+    new_range_end = float(selenium.execute_script("""alert(window.get_y_range_start())"""))
+    selenium.switch_to_alert().dismiss()
+    assert round(new_range_end) == y_range_max
+
+
+############################
+# Test auto bounds
+############################
+
+def _assert_autorange_prevents_panning_but_can_zoom(output_file_url, selenium):
+    selenium.get(output_file_url)
+
+    # Zoom into plot so we can pan around a little
+    zoom_plot(selenium)
+
+    # Now the plot is zoomed in, try a little to the right
+    pan_plot(selenium, pan_x=-50, pan_y=0)
+    x_range_start = float(selenium.execute_script("""alert(window.get_x_range_start())"""))
+    selenium.switch_to_alert().dismiss()
+    assert x_range_start > 0.5
+
+    # Now try panning far to left to check bounds
+    pan_plot(selenium, pan_x=200, pan_y=0)
+    x_range_start = float(selenium.execute_script("""alert(window.get_x_range_start())"""))
+    selenium.switch_to_alert().dismiss()
+    assert x_range_start > 0.4
+    assert x_range_start < 0.5
+
+
+def test_autorange_prevents_panning_but_can_zoom_in_with_datarange1d(output_file_url, selenium):
+    plot = make_pan_plot_with_callback(xr=DataRange1d(), yr=DataRange1d())
+    save(plot)
+    _assert_autorange_prevents_panning_but_can_zoom(output_file_url, selenium)
+
+
+def test_autorange_prevents_panning_but_can_zoom_in_with_range1d(output_file_url, selenium):
+    plot = make_pan_plot_with_callback(xr=Range1d(0.45, 3), yr=DataRange1d(0, 3))
+    save(plot)
+    _assert_autorange_prevents_panning_but_can_zoom(output_file_url, selenium)
+
+
+############################
+# Test no bounds
+############################
+
+#def _assert_no_bounds_allows_unlimited_panning(output_file_url, selenium):
+#    selenium.get(output_file_url)
+#
+#    pan_plot(selenium, pan_x=-1000, pan_y=2000)
+#
+#    x_range_start = float(selenium.execute_script("""alert(window.get_x_range_start())"""))
+#    selenium.switch_to_alert().dismiss()
+#    assert x_range_start > 5
+#
+#    y_range_start = float(selenium.execute_script("""alert(window.get_y_range_start())"""))
+#    selenium.switch_to_alert().dismiss()
+#    assert y_range_start > 5
+#
+#
+#def test_no_bounds_allows_unlimited_panning_with_datarange1d(output_file_url, selenium):
+#    plot = make_pan_plot_with_callback(xr=DataRange1d(bounds=None), yr=DataRange1d(bounds=None))
+#    save(plot)
+#    _assert_no_bounds_allows_unlimited_panning(output_file_url, selenium)
+#
+#
+#def test_no_bounds_allows_unlimited_panning_with_range1d(output_file_url, selenium):
+#    plot = make_pan_plot_with_callback(xr=Range1d(0.45, 3, bounds=None), yr=DataRange1d(0, 3, bounds=None))
+#    save(plot)
+#    _assert_no_bounds_allows_unlimited_panning(output_file_url, selenium)