Repository: bokeh/bokeh

Issue:
Add zoom button that allows zoom by steps
This would add a plus and minus button to the tools that zooms in and out at the current center of the plot.

Examples to consider: Google Maps, Leaflet, Mapbox, basically any slippy map. :-)



Discussion:
This should be quite simple to add on the toolbar, so plan to do that. Perhaps we can get more fancy later with some sort of over-plot thing.


Resolving patch:
diff --git a/bokeh/models/tools.py b/bokeh/models/tools.py
--- a/bokeh/models/tools.py
+++ b/bokeh/models/tools.py
@@ -23,8 +23,8 @@
 from __future__ import absolute_import
 
 from ..model import Model
-from ..core.properties import abstract, Float, Color
 from ..core.properties import (
+    abstract, Float, Color, Percent,
     Any, Auto, Bool, String, Enum, Instance, Either, List, Dict, Tuple, Override
 )
 from ..core.enums import Dimension, Location, Anchor
@@ -409,6 +409,49 @@ class BoxZoomTool(Drag):
 
     """)
 
+class ZoomInTool(Action):
+    """ *toolbar icon*: |zoom_in_icon|
+
+    The zoom-in tool allows users to click a button to zoom in
+    by a fixed amount.
+
+    .. |zoom_in_icon| image:: /_images/icons/ZoomIn.png
+        :height: 18pt
+
+    """
+    # TODO ZoomInTool dimensions should probably be constrained to be the same as ZoomOutTool
+    dimensions = List(Enum(Dimension), default=["width", "height"], help="""
+    Which dimensions the zoom-in tool is constrained to act in. By
+    default the zoom-in zoom tool will zoom in any dimension, but can be
+    configured to only zoom horizontally across the width of the plot, or
+    vertically across the height of the plot.
+    """)
+
+    factor = Percent(default=0.1, help="""
+    Percentage to zoom for each click of the zoom-in tool.
+    """)
+
+class ZoomOutTool(Action):
+    """ *toolbar icon*: |zoom_out_icon|
+
+    The zoom-out tool allows users to click a button to zoom out
+    by a fixed amount.
+
+    .. |zoom_out_icon| image:: /_images/icons/ZoomOut.png
+        :height: 18pt
+
+    """
+    dimensions = List(Enum(Dimension), default=["width", "height"], help="""
+    Which dimensions the zoom-out tool is constrained to act in. By
+    default the zoom-out tool will zoom in any dimension, but can be
+    configured to only zoom horizontally across the width of the plot, or
+    vertically across the height of the plot.
+    """)
+
+    factor = Percent(default=0.1, help="""
+    Percentage to zoom for each click of the zoom-in tool.
+    """)
+
 
 class BoxSelectTool(Drag):
     """ *toolbar icon*: |box_select_icon|
diff --git a/bokeh/plotting/helpers.py b/bokeh/plotting/helpers.py
--- a/bokeh/plotting/helpers.py
+++ b/bokeh/plotting/helpers.py
@@ -13,7 +13,7 @@
     BoxSelectTool, BoxZoomTool, CategoricalAxis,
     TapTool, CrosshairTool, DataRange1d, DatetimeAxis,
     FactorRange, Grid, HelpTool, HoverTool, LassoSelectTool, Legend, LinearAxis,
-    LogAxis, PanTool, PolySelectTool, ContinuousTicker,
+    LogAxis, PanTool, ZoomInTool, ZoomOutTool, PolySelectTool, ContinuousTicker,
     SaveTool, Range, Range1d, UndoTool, RedoTool, ResetTool, ResizeTool, Tool,
     WheelPanTool, WheelZoomTool, ColumnDataSource, GlyphRenderer)
 
@@ -220,6 +220,12 @@ 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"]),
+    "zoom_in": lambda: ZoomInTool(dimensions=["width", "height"]),
+    "xzoom_in": lambda: ZoomInTool(dimensions=["width"]),
+    "yzoom_in": lambda: ZoomInTool(dimensions=["height"]),
+    "zoom_out": lambda: ZoomOutTool(dimensions=["width", "height"]),
+    "xzoom_out": lambda: ZoomOutTool(dimensions=["width"]),
+    "yzoom_out": lambda: ZoomOutTool(dimensions=["height"]),
     "xwheel_pan": lambda: WheelPanTool(dimension="width"),
     "ywheel_pan": lambda: WheelPanTool(dimension="height"),
     "resize": lambda: ResizeTool(),
diff --git a/examples/plotting/file/color_scatter.py b/examples/plotting/file/color_scatter.py
--- a/examples/plotting/file/color_scatter.py
+++ b/examples/plotting/file/color_scatter.py
@@ -10,7 +10,7 @@
     "#%02x%02x%02x" % (int(r), int(g), 150) for r, g in zip(50+2*x, 30+2*y)
 ]
 
-TOOLS="hover,crosshair,pan,wheel_zoom,box_zoom,undo,redo,reset,tap,save,box_select,poly_select,lasso_select"
+TOOLS="hover,crosshair,pan,wheel_zoom,zoom_in,zoom_out,box_zoom,undo,redo,reset,tap,save,box_select,poly_select,lasso_select,"
 
 p = figure(tools=TOOLS)
 


Test patch:
diff --git a/bokeh/tests/test_objects.py b/bokeh/tests/test_objects.py
--- a/bokeh/tests/test_objects.py
+++ b/bokeh/tests/test_objects.py
@@ -13,7 +13,7 @@
 def large_plot(n):
     from bokeh.models import (
         Plot, LinearAxis, Grid, GlyphRenderer,
-        ColumnDataSource, DataRange1d, PanTool, WheelZoomTool, BoxZoomTool,
+        ColumnDataSource, DataRange1d, PanTool, ZoomInTool, ZoomOutTool, WheelZoomTool, BoxZoomTool,
         BoxSelectTool, ResizeTool, SaveTool, ResetTool
     )
     from bokeh.models.layouts import VBox
@@ -36,13 +36,15 @@ def large_plot(n):
         renderer = GlyphRenderer(data_source=source, glyph=glyph)
         plot.renderers.append(renderer)
         pan = PanTool()
+        zoom_in = ZoomInTool()
+        zoom_out = ZoomOutTool()
         wheel_zoom = WheelZoomTool()
         box_zoom = BoxZoomTool()
         box_select = BoxSelectTool()
         resize = ResizeTool()
         save = SaveTool()
         reset = ResetTool()
-        tools = [pan, wheel_zoom, box_zoom, box_select, resize, save, reset]
+        tools = [pan, zoom_in, zoom_out, wheel_zoom, box_zoom, box_select, resize, save, reset]
         plot.add_tools(*tools)
         vbox.children.append(plot)
         objects |= set([
diff --git a/bokehjs/test/models/tools/actions/index.coffee b/bokehjs/test/models/tools/actions/index.coffee
new file mode 100644
--- /dev/null
+++ b/bokehjs/test/models/tools/actions/index.coffee
@@ -0,0 +1,2 @@
+require "./zoom_in_tool"
+require "./zoom_out_tool"
diff --git a/bokehjs/test/models/tools/actions/zoom_in_tool.coffee b/bokehjs/test/models/tools/actions/zoom_in_tool.coffee
new file mode 100644
--- /dev/null
+++ b/bokehjs/test/models/tools/actions/zoom_in_tool.coffee
@@ -0,0 +1,101 @@
+_ = require "underscore"
+{expect} = require "chai"
+utils = require "../../../utils"
+sinon = require 'sinon'
+
+{Document} = utils.require("document")
+ZoomInTool = utils.require("models/tools/actions/zoom_in_tool").Model
+Range1d = utils.require("models/ranges/range1d").Model
+Plot = utils.require("models/plots/plot").Model
+Toolbar = utils.require("models/tools/toolbar").Model
+
+describe "ZoomInTool", ->
+
+  describe "Model", ->
+
+    it "should create proper tooltip", ->
+      tool = new ZoomInTool()
+      expect(tool.tooltip).to.be.equal('Zoom In')
+
+      x_tool = new ZoomInTool({dimensions: ['width']})
+      expect(x_tool.tooltip).to.be.equal('Zoom In (x-axis)')
+
+      y_tool = new ZoomInTool({dimensions: ['height']})
+      expect(y_tool.tooltip).to.be.equal('Zoom In (y-axis)')
+
+  describe "View", ->
+
+    afterEach ->
+      utils.unstub_canvas()
+
+    beforeEach ->
+      utils.stub_canvas()
+
+      @plot = new Plot({
+         x_range: new Range1d({start: -1, end: 1})
+         y_range: new Range1d({start: -1, end: 1})
+      })
+
+      document = new Document()
+      document.add_root(@plot)
+
+      @plot_canvas_view = new @plot.plot_canvas.default_view({
+        model: @plot.plot_canvas
+      })
+
+    it "should zoom into both ranges", ->
+      zoom_in_tool = new ZoomInTool()
+
+      @plot.add_tools(zoom_in_tool)
+
+      zoom_in_tool_view = new zoom_in_tool.default_view({
+        model: zoom_in_tool
+        plot_model: @plot.plot_canvas
+        plot_view: @plot_canvas_view
+      })
+
+      # perform the tool action
+      zoom_in_tool_view.do()
+
+      hr = @plot_canvas_view.frame.x_ranges['default']
+      expect([hr.start, hr.end]).to.be.deep.equal([-0.9, 0.9])
+      vr = @plot_canvas_view.frame.y_ranges['default']
+      expect([vr.start, vr.end]).to.be.deep.equal([-0.9, 0.9])
+
+    it "should zoom the x-axis only", ->
+      zoom_in_tool = new ZoomInTool({dimensions: ['width']})
+
+      @plot.add_tools(zoom_in_tool)
+
+      zoom_in_tool_view = new zoom_in_tool.default_view({
+        model: zoom_in_tool
+        plot_model: @plot.plot_canvas
+        plot_view: @plot_canvas_view
+      })
+
+      # perform the tool action
+      zoom_in_tool_view.do()
+
+      hr = @plot_canvas_view.frame.x_ranges['default']
+      expect([hr.start, hr.end]).to.be.deep.equal([-0.9, 0.9])
+      vr = @plot_canvas_view.frame.y_ranges['default']
+      expect([vr.start, vr.end]).to.be.deep.equal([-1.0, 1.0])
+
+    it "should zoom the y-axis only", ->
+      zoom_in_tool = new ZoomInTool({dimensions: ['height']})
+
+      @plot.add_tools(zoom_in_tool)
+
+      zoom_in_tool_view = new zoom_in_tool.default_view({
+        model: zoom_in_tool
+        plot_model: @plot.plot_canvas
+        plot_view: @plot_canvas_view
+      })
+
+      # perform the tool action
+      zoom_in_tool_view.do()
+
+      hr = @plot_canvas_view.frame.x_ranges['default']
+      expect([hr.start, hr.end]).to.be.deep.equal([-1.0, 1.0])
+      vr = @plot_canvas_view.frame.y_ranges['default']
+      expect([vr.start, vr.end]).to.be.deep.equal([-0.9, 0.9])
diff --git a/bokehjs/test/models/tools/actions/zoom_out_tool.coffee b/bokehjs/test/models/tools/actions/zoom_out_tool.coffee
new file mode 100644
--- /dev/null
+++ b/bokehjs/test/models/tools/actions/zoom_out_tool.coffee
@@ -0,0 +1,101 @@
+_ = require "underscore"
+{expect} = require "chai"
+utils = require "../../../utils"
+sinon = require 'sinon'
+
+{Document} = utils.require("document")
+ZoomOutTool = utils.require("models/tools/actions/zoom_out_tool").Model
+Range1d = utils.require("models/ranges/range1d").Model
+Plot = utils.require("models/plots/plot").Model
+Toolbar = utils.require("models/tools/toolbar").Model
+
+describe "ZoomOutTool", ->
+
+  describe "Model", ->
+
+    it "should create proper tooltip", ->
+      tool = new ZoomOutTool()
+      expect(tool.tooltip).to.be.equal('Zoom Out')
+
+      x_tool = new ZoomOutTool({dimensions: ['width']})
+      expect(x_tool.tooltip).to.be.equal('Zoom Out (x-axis)')
+
+      y_tool = new ZoomOutTool({dimensions: ['height']})
+      expect(y_tool.tooltip).to.be.equal('Zoom Out (y-axis)')
+
+  describe "View", ->
+
+    afterEach ->
+      utils.unstub_canvas()
+
+    beforeEach ->
+      utils.stub_canvas()
+
+      @plot = new Plot({
+         x_range: new Range1d({start: -1, end: 1})
+         y_range: new Range1d({start: -1, end: 1})
+      })
+
+      document = new Document()
+      document.add_root(@plot)
+
+      @plot_canvas_view = new @plot.plot_canvas.default_view({
+        model: @plot.plot_canvas
+      })
+
+    it "should zoom into both ranges", ->
+      zoom_out_tool = new ZoomOutTool()
+
+      @plot.add_tools(zoom_out_tool)
+
+      zoom_out_tool_view = new zoom_out_tool.default_view({
+        model: zoom_out_tool
+        plot_model: @plot.plot_canvas
+        plot_view: @plot_canvas_view
+      })
+
+      # perform the tool action
+      zoom_out_tool_view.do()
+
+      hr = @plot_canvas_view.frame.x_ranges['default']
+      expect([hr.start, hr.end]).to.be.deep.equal([-1.1, 1.1])
+      vr = @plot_canvas_view.frame.y_ranges['default']
+      expect([vr.start, vr.end]).to.be.deep.equal([-1.1, 1.1])
+
+    it "should zoom the x-axis only", ->
+      zoom_out_tool = new ZoomOutTool({dimensions: ['width']})
+
+      @plot.add_tools(zoom_out_tool)
+
+      zoom_out_tool_view = new zoom_out_tool.default_view({
+        model: zoom_out_tool
+        plot_model: @plot.plot_canvas
+        plot_view: @plot_canvas_view
+      })
+
+      # perform the tool action
+      zoom_out_tool_view.do()
+
+      hr = @plot_canvas_view.frame.x_ranges['default']
+      expect([hr.start, hr.end]).to.be.deep.equal([-1.1, 1.1])
+      vr = @plot_canvas_view.frame.y_ranges['default']
+      expect([vr.start, vr.end]).to.be.deep.equal([-1.0, 1.0])
+
+    it "should zoom the y-axis only", ->
+      zoom_out_tool = new ZoomOutTool({dimensions: ['height']})
+
+      @plot.add_tools(zoom_out_tool)
+
+      zoom_out_tool_view = new zoom_out_tool.default_view({
+        model: zoom_out_tool
+        plot_model: @plot.plot_canvas
+        plot_view: @plot_canvas_view
+      })
+
+      # perform the tool action
+      zoom_out_tool_view.do()
+
+      hr = @plot_canvas_view.frame.x_ranges['default']
+      expect([hr.start, hr.end]).to.be.deep.equal([-1.0, 1.0])
+      vr = @plot_canvas_view.frame.y_ranges['default']
+      expect([vr.start, vr.end]).to.be.deep.equal([-1.1, 1.1])
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,2 +1,3 @@
 require "./resize_tool"
 require "./wheel_pan_tool"
+require "./wheel_zoom_tool"
diff --git a/bokehjs/test/models/tools/gestures/wheel_zoom_tool.coffee b/bokehjs/test/models/tools/gestures/wheel_zoom_tool.coffee
new file mode 100644
--- /dev/null
+++ b/bokehjs/test/models/tools/gestures/wheel_zoom_tool.coffee
@@ -0,0 +1,138 @@
+_ = require "underscore"
+{expect} = require "chai"
+utils = require "../../../utils"
+sinon = require 'sinon'
+
+{Document} = utils.require("document")
+WheelZoomTool = utils.require("models/tools/gestures/wheel_zoom_tool").Model
+Range1d = utils.require("models/ranges/range1d").Model
+Plot = utils.require("models/plots/plot").Model
+Toolbar = utils.require("models/tools/toolbar").Model
+
+describe "WheelZoomTool", ->
+
+  describe "Model", ->
+
+    it "should create proper tooltip", ->
+      tool = new WheelZoomTool()
+      expect(tool.tooltip).to.be.equal('Wheel Zoom')
+
+      x_tool = new WheelZoomTool({dimensions: ['width']})
+      expect(x_tool.tooltip).to.be.equal('Wheel Zoom (x-axis)')
+
+      y_tool = new WheelZoomTool({dimensions: ['height']})
+      expect(y_tool.tooltip).to.be.equal('Wheel Zoom (y-axis)')
+
+  describe "View", ->
+
+    afterEach ->
+      utils.unstub_canvas()
+
+    beforeEach ->
+      utils.stub_canvas()
+
+      @plot = new Plot({
+         x_range: new Range1d({start: -1, end: 1})
+         y_range: new Range1d({start: -1, end: 1})
+      })
+
+      document = new Document()
+      document.add_root(@plot)
+
+      @plot_canvas_view = new @plot.plot_canvas.default_view({
+        model: @plot.plot_canvas
+      })
+
+    it "should zoom in both ranges", ->
+      wheel_zoom = new WheelZoomTool()
+
+      @plot.add_tools(wheel_zoom)
+
+      wheel_zoom_view = new wheel_zoom.default_view({
+        model: wheel_zoom
+        plot_model: @plot.plot_canvas
+        plot_view: @plot_canvas_view
+      })
+
+      # positive delta will zoom in
+      zoom_event = {"bokeh": {sx: 300, sy: 300, delta: 100}}
+
+      # perform the tool action
+      wheel_zoom_view._scroll(zoom_event)
+
+      hr = @plot_canvas_view.frame.x_ranges['default']
+      expect(hr.start).to.be.closeTo(-0.833, 0.01)
+      expect(hr.end).to.be.closeTo(0.833, 0.01)
+      vr = @plot_canvas_view.frame.y_ranges['default']
+      expect(vr.start).to.be.closeTo(-0.833, 0.01)
+      expect(vr.end).to.be.closeTo(0.833, 0.01)
+
+    it "should zoom out both ranges", ->
+      wheel_zoom = new WheelZoomTool()
+
+      @plot.add_tools(wheel_zoom)
+
+      wheel_zoom_view = new wheel_zoom.default_view({
+        model: wheel_zoom
+        plot_model: @plot.plot_canvas
+        plot_view: @plot_canvas_view
+      })
+
+      # positive delta will zoom out
+      zoom_event = {"bokeh": {sx: 300, sy: 300, delta: -100}}
+
+      # perform the tool action
+      wheel_zoom_view._scroll(zoom_event)
+
+      hr = @plot_canvas_view.frame.x_ranges['default']
+      expect(hr.start).to.be.closeTo(-1.166, 0.01)
+      expect(hr.end).to.be.closeTo(1.166, 0.01)
+      vr = @plot_canvas_view.frame.y_ranges['default']
+      expect(vr.start).to.be.closeTo(-1.166, 0.01)
+      expect(vr.end).to.be.closeTo(1.166, 0.01)
+
+    it "should zoom the x-axis only", ->
+      wheel_zoom = new WheelZoomTool({dimensions: ['width']})
+
+      @plot.add_tools(wheel_zoom)
+
+      wheel_zoom_view = new wheel_zoom.default_view({
+        model: wheel_zoom
+        plot_model: @plot.plot_canvas
+        plot_view: @plot_canvas_view
+      })
+
+      # positive delta will zoom in
+      zoom_event = {"bokeh": {sx: 300, sy: 300, delta: 100}}
+
+      # perform the tool action
+      wheel_zoom_view._scroll(zoom_event)
+
+      hr = @plot_canvas_view.frame.x_ranges['default']
+      expect(hr.start).to.be.closeTo(-0.833, 0.01)
+      expect(hr.end).to.be.closeTo(0.833, 0.01)
+      vr = @plot_canvas_view.frame.y_ranges['default']
+      expect([vr.start, vr.end]).to.be.deep.equal([-1.0, 1.0])
+
+    it "should zoom the y-axis only", ->
+      wheel_zoom = new WheelZoomTool({dimensions: ['height']})
+
+      @plot.add_tools(wheel_zoom)
+
+      wheel_zoom_view = new wheel_zoom.default_view({
+        model: wheel_zoom
+        plot_model: @plot.plot_canvas
+        plot_view: @plot_canvas_view
+      })
+
+      # positive delta will zoom in
+      zoom_event = {"bokeh": {sx: 300, sy: 300, delta: 100}}
+
+      # perform the tool action
+      wheel_zoom_view._scroll(zoom_event)
+
+      hr = @plot_canvas_view.frame.x_ranges['default']
+      expect([hr.start, hr.end]).to.be.deep.equal([-1.0, 1.0])
+      vr = @plot_canvas_view.frame.y_ranges['default']
+      expect(vr.start).to.be.closeTo(-0.833, 0.01)
+      expect(vr.end).to.be.closeTo(0.833, 0.01)
diff --git a/bokehjs/test/models/tools/index.coffee b/bokehjs/test/models/tools/index.coffee
--- a/bokehjs/test/models/tools/index.coffee
+++ b/bokehjs/test/models/tools/index.coffee
@@ -1,3 +1,4 @@
+require "./actions"
 require "./inspectors"
 require "./gestures"
 require "./toolbar"