Repository: bokeh/bokeh

Issue:
Feature-request: Selection-Bar
This is a feature-request for a Selection-Bar. You can see an example about Selection-Bar [here](https://webapps.qlik.com/masters/index.html#/2018-stats/). I am interested to help to do this feature but I need to understand a few things.

**How would you implement Selection-Bar on Bokeh? With Extension or build it to the base package?**


Discussion:
Hi @heoa I think this would be a good addition, and I think that now all the necessary parts are in place to make it fairly straightforward to add to the core library. Here is what I would suggest:

* Have a new `RangeTool`, declared on the Python as roughly:

    ```
    class RangeTool(Tool): 
        x_range = Instance(Range1d)
        y_range = Instance(Range1d)
    ```

* On the JS side, the implementation will *have* a (internal) `BoxAnnotation` and it will use the ranges to set the dimensions of the box
  - One of the ranges may be `None`, in this case the box spans that entire dimension 
  - The tool will respond to drag events: if the drag starts in the middle of the box, the entire box is moved (by updating the ranges appropriately). If the drag starts near a border, only that border is moved (by updating the range)
  - The tool will respond to move move events, and if the mouse is near a border, will display some kind of handle or highlight to suggest that it is draggable

With this in place you could do something like:
```
p1 = figure(...)

p2 = figure(...)
pe.add_tool(RangeTool(x_range=p1.x_range))
```
then you'd automatically be able to control the range of `p1` by manipulating the box on `p2`. You could also add any standard JS or python callbacks to the ranges for other behaviours. 

I think this would be a *fantastic* task for a new contributors. Are you interested in working up a PR for this? I am not sure of your background, so I do not know if this is enough context and information to get started, but can always go into more detail. 

Resolving patch:
diff --git a/bokeh/core/validation/errors.py b/bokeh/core/validation/errors.py
--- a/bokeh/core/validation/errors.py
+++ b/bokeh/core/validation/errors.py
@@ -62,6 +62,9 @@
 1017 *(INCOMPATIBLE_POLY_EDIT_VERTEX_RENDERER)*
     The PolyEditTool vertex_renderer may only reference XYGlyph models.
 
+1018 *(NO_RANGE_TOOL_RANGES)*
+    The RangeTool must have at least one of x_range or y_range configured
+
 9999 *(EXT)*
     Indicates that a custom error check has failed.
 
@@ -84,7 +87,8 @@
     1014: ("INCOMPATIBLE_BOX_EDIT_RENDERER", "BoxEditTool renderers may only reference Rect glyph models"),
     1015: ("INCOMPATIBLE_POLY_DRAW_RENDERER", "PolyDrawTool renderers may only reference MultiLine and Patches glyph models."),
     1016: ("INCOMPATIBLE_POLY_EDIT_RENDERER", "PolyEditTool renderers may only reference MultiLine and Patches glyph models"),
-    1017: ("INCOMPATIBLE_POLY_EDIT_VERTEX_RENDERER", " PolyEditTool vertex_renderer may only reference XYGlyph models"),
+    1017: ("INCOMPATIBLE_POLY_EDIT_VERTEX_RENDERER", "PolyEditTool vertex_renderer may only reference XYGlyph models"),
+    1018: ("NO_RANGE_TOOL_RANGES", "RangeTool must have at least one of x_range or y_range configured"),
     9999: ("EXT",                    "Custom extension reports error"),
 }
 
diff --git a/bokeh/models/tools.py b/bokeh/models/tools.py
--- a/bokeh/models/tools.py
+++ b/bokeh/models/tools.py
@@ -38,13 +38,14 @@
 from ..core.validation.errors import (
     INCOMPATIBLE_BOX_EDIT_RENDERER, INCOMPATIBLE_POINT_DRAW_RENDERER,
     INCOMPATIBLE_POLY_DRAW_RENDERER, INCOMPATIBLE_POLY_EDIT_RENDERER,
-    INCOMPATIBLE_POLY_EDIT_VERTEX_RENDERER
+    INCOMPATIBLE_POLY_EDIT_VERTEX_RENDERER, NO_RANGE_TOOL_RANGES
 )
 from ..model import Model
 
 from .annotations import BoxAnnotation, PolyAnnotation
 from .callbacks import Callback
 from .glyphs import XYGlyph, Rect, Patches, MultiLine
+from .ranges import Range1d
 from .renderers import Renderer, GlyphRenderer
 from .layouts import LayoutDOM
 
@@ -62,28 +63,35 @@ class Action(Tool):
     pass
 
 @abstract
-class Drag(Tool):
+class Gesture(Tool):
     ''' A base class for tools that respond to drag events.
 
     '''
     pass
 
 @abstract
-class Scroll(Tool):
+class Drag(Gesture):
+    ''' A base class for tools that respond to drag events.
+
+    '''
+    pass
+
+@abstract
+class Scroll(Gesture):
     ''' A base class for tools that respond to scroll events.
 
     '''
     pass
 
 @abstract
-class Tap(Tool):
+class Tap(Gesture):
     ''' A base class for tools that respond to tap/click events.
 
     '''
     pass
 
 @abstract
-class Inspection(Tool):
+class Inspection(Gesture):
     ''' A base class for tools that perform "inspections", e.g. ``HoverTool``.
 
     '''
@@ -130,6 +138,16 @@ class Toolbar(ToolbarBase):
     Specify a tap/click tool to be active when the plot is displayed.
     """)
 
+    active_multi = Instance((Gesture), help="""
+    Specify an active multi-gesture tool, for instance an edit tool or a range
+    tool.
+
+    Note that activating a multi-gesture tool will deactivate any other gesture
+    tools as appropriate. For example, if a pan tool is set as the active drag,
+    and this property is set to a ``BoxEditTool`` instance, the pan tool will
+    be deactivated (i.e. the multi-gesture tool will take precedence).
+    """)
+
 class ProxyToolbar(ToolbarBase):
     ''' A toolbar that allow to merge and proxy tools of toolbars in multiple plots. '''
 
@@ -169,6 +187,73 @@ class PanTool(Drag):
     height of the plot.
     """)
 
+DEFAULT_RANGE_OVERLAY = lambda: BoxAnnotation(
+    level="overlay",
+    render_mode="css",
+    fill_color="lightgrey",
+    fill_alpha=0.5,
+    line_color="black",
+    line_alpha=1.0,
+    line_width=0.5,
+    line_dash=[2,2],
+)
+
+class RangeTool(Drag):
+    ''' *toolbar icon*: |range_icon|
+
+    The range tool allows the user to update range objects for either or both
+    of the x- or y-dimensions by dragging a corresponding shaded annotation to
+    move it or change its boundaries.
+
+    A common use case is to add this tool to a plot with a large fixed range,
+    but to configure the tool range from a different plot. When the user
+    manipulates the overlay, the range of the second plot will be updated
+    automatically.
+
+    .. |range_icon| image:: /_images/icons/Range.png
+        :height: 18pt
+
+    '''
+
+    x_range = Instance(Range1d, help="""
+    A range synchronized to the x-dimension of the overlay. If None, the overlay
+    will span the entire x-dimension.
+    """)
+
+    x_interaction = Bool(default=True, help="""
+    Whether to respond to horizontal pan motions when an ``x_range`` is present.
+
+    By default, when an ``x_range`` is specified, it is possible to adjust the
+    horizontal position of the range box by panning horizontally inside the
+    box, or along the top or bottom edge of the box. To disable this, and fix
+    the  range box in place horizontally, set to False. (The box will still
+    update if the ``x_range`` is updated programmatically.)
+    """)
+
+    y_range = Instance(Range1d, help="""
+    A range synchronized to the y-dimension of the overlay. If None, the overlay
+    will span the entire y-dimension.
+    """)
+
+    y_interaction = Bool(default=True, help="""
+    Whether to respond to vertical pan motions when a ``y_range`` is present.
+
+    By default, when a ``y_range`` is specified, it is possible to adjust the
+    vertical position of the range box by panning vertically inside the box, or
+    along the top or bottom edge of the box. To disable this, and fix the range
+    box in place vertically, set to False. (The box will still update if the
+    ``y_range`` is updated programmatically.)
+    """)
+
+    overlay = Instance(BoxAnnotation, default=DEFAULT_RANGE_OVERLAY, help="""
+    A shaded annotation drawn to indicate the configured ranges.
+    """)
+
+    @error(NO_RANGE_TOOL_RANGES)
+    def _check_no_range_tool_ranges(self):
+        if self.x_range is None and self.y_range is None:
+            return "At least one of RangeTool.x_range or RangeTool.y_range must be configured"
+
 class WheelPanTool(Scroll):
     ''' *toolbar icon*: |wheel_pan_icon|
 
@@ -1060,7 +1145,7 @@ class RedoTool(Action):
     '''
 
 @abstract
-class EditTool(Tool):
+class EditTool(Gesture):
     ''' A base class for all interactive draw tool types.
 
     '''
diff --git a/examples/plotting/file/range_tool.py b/examples/plotting/file/range_tool.py
new file mode 100644
--- /dev/null
+++ b/examples/plotting/file/range_tool.py
@@ -0,0 +1,31 @@
+import numpy as np
+
+from bokeh.io import show
+from bokeh.layouts import column
+from bokeh.models import ColumnDataSource, RangeTool
+from bokeh.plotting import figure
+from bokeh.sampledata.stocks import AAPL
+
+dates = np.array(AAPL['date'], dtype=np.datetime64)
+source = ColumnDataSource(data=dict(date=dates, close=AAPL['adj_close']))
+
+p = figure(plot_height=300, plot_width=800, tools="", toolbar_location=None,
+           x_axis_type="datetime", x_range=(dates[1500], dates[2500]))
+
+p.line('date', 'close', source=source)
+p.yaxis.axis_label = 'Price'
+
+select = figure(plot_height=150, plot_width=800, y_range=p.y_range,
+                x_axis_type="datetime", y_axis_type=None,
+                tools="", toolbar_location=None)
+
+range_rool = RangeTool(x_range=p.x_range)
+range_rool.overlay.fill_color = "navy"
+range_rool.overlay.fill_alpha = 0.2
+
+select.line('date', 'close', source=source)
+select.ygrid.grid_line_color = None
+select.add_tools(range_rool)
+select.toolbar.active_multi = range_rool
+
+show(column(p, select))


Test patch:
diff --git a/bokehjs/test/models/tools/gestures/index.ts b/bokehjs/test/models/tools/gestures/index.ts
--- a/bokehjs/test/models/tools/gestures/index.ts
+++ b/bokehjs/test/models/tools/gestures/index.ts
@@ -1,3 +1,4 @@
 import "./box_zoom_tool"
+import "./range_tool"
 import "./wheel_pan_tool"
 import "./wheel_zoom_tool"
diff --git a/bokehjs/test/models/tools/gestures/range_tool.coffee b/bokehjs/test/models/tools/gestures/range_tool.coffee
new file mode 100644
--- /dev/null
+++ b/bokehjs/test/models/tools/gestures/range_tool.coffee
@@ -0,0 +1,193 @@
+{expect} = require "chai"
+
+{Document} = require("document")
+{compute_value, is_near, is_inside, update_range, RangeTool} = require("models/tools/gestures/range_tool")
+{Range1d} = require("models/ranges/range1d")
+{LinearScale} = require("models/scales/linear_scale")
+{stdoutTrap, stderrTrap} = require 'logtrap'
+logging = require "core/logging"
+
+describe "range_tool module", ->
+  source = {start: 0, end: 10}
+  target = {start: 0, end: 100}
+
+  generate_scale = ->
+    new LinearScale({
+      source_range: new Range1d(source)
+      target_range: new Range1d(target)
+    })
+
+  describe "is_near", ->
+    scale = generate_scale()
+
+    it "should return false if value == null", ->
+      expect(is_near(-10, null, scale, 3)).to.be.false
+      expect(is_near(0, null, scale, 3)).to.be.false
+      expect(is_near(10, null, scale, 3)).to.be.false
+      expect(is_near(10.2, null, scale, 3)).to.be.false
+
+    it "should return false if value maps far from screen pos", ->
+      expect(is_near(-10, 5, scale, 3)).to.be.false
+      expect(is_near(0, 5, scale, 3)).to.be.false
+      expect(is_near(10, 5, scale, 3)).to.be.false
+      expect(is_near(47, 5, scale, 3)).to.be.false
+      expect(is_near(53, 5, scale, 3)).to.be.false
+      expect(is_near(60, 5, scale, 3)).to.be.false
+      expect(is_near(100, 5, scale, 3)).to.be.false
+
+    it "should return true if value maps close to screen pos", ->
+      expect(is_near(47.1, 5, scale, 3)).to.be.true
+      expect(is_near(50, 5, scale, 3)).to.be.true
+      expect(is_near(52.9, 5, scale, 3)).to.be.true
+
+
+  describe "is_inside", ->
+    x_range = new Range1d(source)
+    y_range = new Range1d(source)
+    x_scale = generate_scale()
+    y_scale = generate_scale()
+
+    it "should return true if sx/sy are inside the overlay", ->
+      rt = new RangeTool({x_range: x_range})
+      rt.update_overlay_from_ranges()
+      r = is_inside(50, 50, x_scale, y_scale, rt.overlay)
+      expect(r).to.be.true
+
+      rt = new RangeTool({y_range: y_range})
+      rt.update_overlay_from_ranges()
+      r = is_inside(50, 50, x_scale, y_scale, rt.overlay)
+      expect(r).to.be.true
+
+      rt = new RangeTool({x_range: x_range, y_range: y_range})
+      rt.update_overlay_from_ranges()
+      r = is_inside(50, 50, x_scale, y_scale, rt.overlay)
+      expect(r).to.be.true
+
+    it "should return false if sx/sy are outside the overlay", ->
+      rt = new RangeTool({x_range: x_range})
+      rt.update_overlay_from_ranges()
+      r = is_inside(-1, 50, x_scale, y_scale, rt.overlay)
+      expect(r).to.be.false
+      r = is_inside(101, 50, x_scale, y_scale, rt.overlay)
+      expect(r).to.be.false
+
+      rt = new RangeTool({y_range: y_range})
+      rt.update_overlay_from_ranges()
+      r = is_inside(50, -1, x_scale, y_scale, rt.overlay)
+      expect(r).to.be.false
+      r = is_inside(50, 101, x_scale, y_scale, rt.overlay)
+      expect(r).to.be.false
+
+  describe "compute_value", ->
+    scale = generate_scale()
+
+    it "should return value as-is if new value would be outside range", ->
+      range = new Range1d(source)
+      r = compute_value(5, scale, 51, range)
+      expect(r).to.be.equal 5
+      r = compute_value(5, scale, -51, range)
+      expect(r).to.be.equal 5
+
+    it "should return new_value if new value would be inside range", ->
+      range = new Range1d(source)
+      r = compute_value(5, scale, 50, range)
+      expect(r).to.be.equal 10
+      r = compute_value(5, scale, 0, range)
+      expect(r).to.be.equal 5
+      r = compute_value(5, scale, -50, range)
+      expect(r).to.be.equal 0
+
+  describe "update_range", ->
+    scale = generate_scale()
+
+    it "should not update range if new start/end would be outside plot_range", ->
+      plot_range = new Range1d({start:-2, end:12})
+
+      range = new Range1d(source)
+      update_range(range, scale, 21, plot_range)
+      expect(range.start).to.be.equal 0
+      expect(range.end).to.be.equal 10
+
+      range = new Range1d(source)
+      update_range(range, scale, -21, plot_range)
+      expect(range.start).to.be.equal 0
+      expect(range.end).to.be.equal 10
+
+    it "should update range if new start/end would be inside plot_range", ->
+      plot_range = new Range1d({start:-2, end:12})
+
+      range = new Range1d(source)
+      update_range(range, scale, 20, plot_range)
+      expect(range.start).to.be.equal 2
+      expect(range.end).to.be.equal 12
+
+      range = new Range1d(source)
+      update_range(range, scale, -20, plot_range)
+      expect(range.start).to.be.equal -2
+      expect(range.end).to.be.equal 8
+
+  describe "RangeTool", ->
+
+    describe "construction", ->
+      x_range = new Range1d(source)
+      y_range = new Range1d(source)
+
+      it "should set overlay in_cursor", ->
+        rt = new RangeTool()
+        expect(rt.overlay.in_cursor).to.be.equal "grab"
+
+      it "should set overlay ns_cursor", ->
+        rt = new RangeTool()
+        expect(rt.overlay.ns_cursor).to.be.equal null
+
+        rt = new RangeTool({y_range: y_range})
+        expect(rt.overlay.ns_cursor).to.be.equal "ns-resize"
+
+        rt = new RangeTool({y_range: y_range, y_interaction: false})
+        expect(rt.overlay.ns_cursor).to.be.equal null
+
+      it "should set overlay ew_cursor", ->
+        rt = new RangeTool()
+        expect(rt.overlay.ew_cursor).to.be.equal null
+
+        rt = new RangeTool({x_range: x_range})
+        expect(rt.overlay.ew_cursor).to.be.equal "ew-resize"
+
+        rt = new RangeTool({x_range: x_range, x_interaction: false})
+        expect(rt.overlay.ew_cursor).to.be.equal null
+
+    describe "update_overlay_from_ranges", ->
+      x_range = new Range1d(source)
+      y_range = new Range1d(source)
+
+      # it "should warn if no ranges are set", ->
+      #   old_log_level = logging.logger.level.name
+      #   logging.set_log_level("warn")
+      #   logging.set_log_level(old_log_level)
+      #   rt = new RangeTool()
+      #   out = stdoutTrap(() -> rt.update_overlay_from_ranges())
+      #   expect(out).to.be.equal 'RangeTool not configured with any Ranges.'
+
+      it "should set overlay coords to null if no ranges are set", ->
+        rt = new RangeTool()
+        rt.update_overlay_from_ranges()
+        expect(rt.overlay.left).to.be.null
+        expect(rt.overlay.right).to.be.null
+        expect(rt.overlay.top).to.be.null
+        expect(rt.overlay.bottom).to.be.null
+
+      it "should set top/bottom overlay coords to null if y range is null", ->
+        rt = new RangeTool({x_range: x_range})
+        rt.update_overlay_from_ranges()
+        expect(rt.overlay.left).to.not.be.null
+        expect(rt.overlay.right).to.not.be.null
+        expect(rt.overlay.top).to.be.null
+        expect(rt.overlay.bottom).to.be.null
+
+      it "should set left/right overlay coords to null if x range is null", ->
+        rt = new RangeTool({y_range: y_range})
+        rt.update_overlay_from_ranges()
+        expect(rt.overlay.left).to.be.null
+        expect(rt.overlay.right).to.be.null
+        expect(rt.overlay.top).to.not.be.null
+        expect(rt.overlay.bottom).to.not.be.null