Repository: bokeh/bokeh

Issue:
Feature request: add wheel pan tool
Example Motivation:

When interacting with a time series plot, a user would like to pan through different time eras. This tool would be limited to a single dimension.

Why not WheelZoomTool?

I can imagine wanting to have a specific view window size (say 5 years) and want to explore my data with that specific aspect ratio.

Why not PanTool?

If the data range is much larger than the desired window (say there's 100 years of data but the visualization only "makes sense" when viewed in a 5 year view window, that'd be 20+ click-and-drag pans versus a smoother scrolling interaction.



Discussion:

Resolving patch:
diff --git a/bokeh/models/tools.py b/bokeh/models/tools.py
--- a/bokeh/models/tools.py
+++ b/bokeh/models/tools.py
@@ -174,6 +174,23 @@ class PanTool(Drag):
     """)
 
 
+class WheelPanTool(Scroll):
+    """ *toolbar icon*: |wheel_pan_icon|
+
+    The wheel pan tool allows the user to pan the plot along the configured
+    dimension using the scroll wheel.
+
+    .. |wheel_pan_icon| image:: /_images/icons/WheelPan.png
+        :height: 18pt
+
+    """
+
+    dimension = Enum(Dimension, default="width", help="""
+    Which dimension the wheel pan tool is constrained to act in. By
+    default the wheel pan tool will pan the plot along the x-axis.
+    """)
+
+
 class WheelZoomTool(Scroll):
     """ *toolbar icon*: |wheel_zoom_icon|
 
diff --git a/bokeh/plotting/helpers.py b/bokeh/plotting/helpers.py
--- a/bokeh/plotting/helpers.py
+++ b/bokeh/plotting/helpers.py
@@ -15,7 +15,7 @@
     FactorRange, Grid, HelpTool, HoverTool, LassoSelectTool, Legend, LinearAxis,
     LogAxis, PanTool, PolySelectTool, ContinuousTicker,
     SaveTool, Range, Range1d, UndoTool, RedoTool, ResetTool, ResizeTool, Tool,
-    WheelZoomTool, ColumnDataSource, GlyphRenderer)
+    WheelPanTool, WheelZoomTool, ColumnDataSource, GlyphRenderer)
 
 from ..core.properties import ColorSpec, Datetime
 from ..util.string import nice_join
@@ -203,6 +203,8 @@ def _get_num_minor_ticks(axis_class, num_minor_ticks):
     "wheel_zoom": lambda: WheelZoomTool(dimensions=["width", "height"]),
     "xwheel_zoom": lambda: WheelZoomTool(dimensions=["width"]),
     "ywheel_zoom": lambda: WheelZoomTool(dimensions=["height"]),
+    "xwheel_pan": lambda: WheelPanTool(dimension="width"),
+    "ywheel_pan": lambda: WheelPanTool(dimension="height"),
     "resize": lambda: ResizeTool(),
     "click": lambda: TapTool(behavior="inspect"),
     "tap": lambda: TapTool(),


Test patch:
diff --git a/bokehjs/test/models/tools/gestures/index.coffee b/bokehjs/test/models/tools/gestures/index.coffee
--- a/bokehjs/test/models/tools/gestures/index.coffee
+++ b/bokehjs/test/models/tools/gestures/index.coffee
@@ -1 +1,2 @@
 require "./resize_tool"
+require "./wheel_pan_tool"
diff --git a/bokehjs/test/models/tools/gestures/wheel_pan_tool.coffee b/bokehjs/test/models/tools/gestures/wheel_pan_tool.coffee
new file mode 100644
--- /dev/null
+++ b/bokehjs/test/models/tools/gestures/wheel_pan_tool.coffee
@@ -0,0 +1,83 @@
+_ = require "underscore"
+{expect} = require "chai"
+utils = require "../../../utils"
+sinon = require 'sinon'
+
+{Document} = utils.require("document")
+WheelPanTool = utils.require("models/tools/gestures/wheel_pan_tool.coffee").Model
+WheelPanToolView = utils.require("models/tools/gestures/wheel_pan_tool.coffee").View
+Range1d = utils.require("models/ranges/range1d").Model
+Plot = utils.require("models/plots/plot").Model
+Toolbar = utils.require("models/tools/toolbar").Model
+
+describe "WheelPanTool", ->
+
+  describe "Model", ->
+
+    it "should create proper tooltip", ->
+      x_tool = new WheelPanTool({dimension: 'width'})
+      expect(x_tool.tooltip).to.be.equal('Wheel Pan (x-axis)')
+
+      y_tool = new WheelPanTool({dimension: 'height'})
+      expect(y_tool.tooltip).to.be.equal('Wheel Pan (y-axis)')
+
+  describe "View", ->
+
+    afterEach ->
+      utils.unstub_canvas()
+
+    beforeEach ->
+      utils.stub_canvas()
+
+      @plot = new Plot({
+         x_range: new Range1d({start: 0, end: 1})
+         y_range: new Range1d({start: 0, end: 1})
+      })
+
+      document = new Document()
+      document.add_root(@plot)
+
+      @plot_canvas_view = new @plot.plot_canvas.default_view({
+        model: @plot.plot_canvas
+      })
+
+    it "should translate x-range in positive direction", ->
+      x_wheel_pan_tool = new WheelPanTool()
+
+      @plot.add_tools(x_wheel_pan_tool)
+
+      wheel_pan_tool_view = new x_wheel_pan_tool.default_view({
+        model: x_wheel_pan_tool
+        plot_model: @plot.plot_canvas
+        plot_view: @plot_canvas_view
+      })
+
+      # negative factors move in positive x-data direction
+      wheel_pan_tool_view._update_ranges(-0.5)
+      hr = @plot_canvas_view.frame.x_ranges['default']
+      # should be translated by -factor units
+      expect([hr.start, hr.end]).to.be.deep.equal([0.5, 1.5])
+      vr = @plot_canvas_view.frame.y_ranges['default']
+      # should be unchanged from initialized value
+      expect([vr.start, vr.end]).to.be.deep.equal([0, 1])
+
+    it "should translate y-range in negative direction", ->
+      x_wheel_pan_tool = new WheelPanTool({dimension: 'height'})
+
+      @plot.add_tools(x_wheel_pan_tool)
+
+      wheel_pan_tool_view = new x_wheel_pan_tool.default_view({
+        model: x_wheel_pan_tool
+        plot_model: @plot.plot_canvas
+        plot_view: @plot_canvas_view
+      })
+
+      # positive factors move in positive y-data direction
+      wheel_pan_tool_view._update_ranges(0.75)
+      hr = @plot_canvas_view.frame.x_ranges['default']
+      # should be unchanged from initialized value
+      expect([hr.start, hr.end]).to.be.deep.equal([0, 1])
+
+      vr = @plot_canvas_view.frame.y_ranges['default']
+      # should be translated by -factor units
+      expect([vr.start, vr.end]).to.be.deep.equal([0.75, 1.75])