From 089b335d559cb03194c4f18e077186fc983065fc Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=C3=89tienne=20T=C3=A9treault-Pinard?= <etienne@plot.ly>
Date: Thu, 11 Feb 2016 15:00:38 -0500
Subject: [PATCH 01/21] factor out isSelectable routine

---
 src/components/modebar/manage.js | 101 +++++++++++++++++--------------
 1 file changed, 56 insertions(+), 45 deletions(-)

diff --git a/src/components/modebar/manage.js b/src/components/modebar/manage.js
index 0eacce6497a..9b64bfac938 100644
--- a/src/components/modebar/manage.js
+++ b/src/components/modebar/manage.js
@@ -71,10 +71,15 @@ module.exports = function manageModeBar(gd) {
 // logic behind which buttons are displayed by default
 function getButtonGroups(gd, buttonsToRemove, buttonsToAdd) {
     var fullLayout = gd._fullLayout,
-        fullData = gd._fullData,
-        groups = [],
-        i,
-        trace;
+        fullData = gd._fullData;
+
+    var hasCartesian = fullLayout._hasCartesian,
+        hasGL3D = fullLayout._hasGL3D,
+        hasGeo = fullLayout._hasGeo,
+        hasPie = fullLayout._hasPie,
+        hasGL2D = fullLayout._hasGL2D;
+
+    var groups = [];
 
     function addGroup(newGroup) {
         var out = [];
@@ -88,52 +93,42 @@ function getButtonGroups(gd, buttonsToRemove, buttonsToAdd) {
         groups.push(out);
     }
 
+    function appendButtonsToAdd(groups) {
+        if(buttonsToAdd.length) {
+            if(Array.isArray(buttonsToAdd[0])) {
+                for(var i = 0; i < buttonsToAdd.length; i++) {
+                    groups.push(buttonsToAdd[i]);
+                }
+            }
+            else groups.push(buttonsToAdd);
+        }
+
+        return groups;
+    }
+
     // buttons common to all plot types
     addGroup(['toImage', 'sendDataToCloud']);
 
-    if(fullLayout._hasGL3D) {
+    if(hasGL3D) {
         addGroup(['zoom3d', 'pan3d', 'orbitRotation', 'tableRotation']);
         addGroup(['resetCameraDefault3d', 'resetCameraLastSave3d']);
         addGroup(['hoverClosest3d']);
     }
 
-    if(fullLayout._hasGeo) {
+    if(hasGeo) {
         addGroup(['zoomInGeo', 'zoomOutGeo', 'resetGeo']);
         addGroup(['hoverClosestGeo']);
     }
 
-    var hasCartesian = fullLayout._hasCartesian,
-        hasGL2D = fullLayout._hasGL2D,
-        allAxesFixed = areAllAxesFixed(fullLayout),
+    var allAxesFixed = areAllAxesFixed(fullLayout),
         dragModeGroup = [];
 
     if((hasCartesian || hasGL2D) && !allAxesFixed) {
         dragModeGroup = ['zoom2d', 'pan2d'];
     }
-    if(hasCartesian) {
-        // look for traces that support selection
-        // to be updated as we add more selectPoints handlers
-        var selectable = false;
-        for(i = 0; i < fullData.length; i++) {
-            if(selectable) break;
-            trace = fullData[i];
-            if(!trace._module || !trace._module.selectPoints) continue;
-
-            if(trace.type === 'scatter') {
-                if(scatterSubTypes.hasMarkers(trace) || scatterSubTypes.hasText(trace)) {
-                    selectable = true;
-                }
-            }
-            // assume that in general if the trace module has selectPoints,
-            // then it's selectable. Scatter is an exception to this because it must
-            // have markers or text, not just be a scatter type.
-            else selectable = true;
-        }
-
-        if(selectable) {
-            dragModeGroup.push('select2d');
-            dragModeGroup.push('lasso2d');
-        }
+    if(hasCartesian && isSelectable(fullData)) {
+        dragModeGroup.push('select2d');
+        dragModeGroup.push('lasso2d');
     }
     if(dragModeGroup.length) addGroup(dragModeGroup);
 
@@ -147,21 +142,11 @@ function getButtonGroups(gd, buttonsToRemove, buttonsToAdd) {
     if(hasGL2D) {
         addGroup(['hoverClosestGl2d']);
     }
-    if(fullLayout._hasPie) {
+    if(hasPie) {
         addGroup(['hoverClosestPie']);
     }
 
-    // append buttonsToAdd to the groups
-    if(buttonsToAdd.length) {
-        if(Array.isArray(buttonsToAdd[0])) {
-            for(i = 0; i < buttonsToAdd.length; i++) {
-                groups.push(buttonsToAdd[i]);
-            }
-        }
-        else groups.push(buttonsToAdd);
-    }
-
-    return groups;
+    return appendButtonsToAdd(groups);
 }
 
 function areAllAxesFixed(fullLayout) {
@@ -178,6 +163,32 @@ function areAllAxesFixed(fullLayout) {
     return allFixed;
 }
 
+// look for traces that support selection
+// to be updated as we add more selectPoints handlers
+function isSelectable(fullData) {
+    var selectable = false;
+
+    for(var i = 0; i < fullData.length; i++) {
+        if(selectable) break;
+
+        var trace = fullData[i];
+
+        if(!trace._module || !trace._module.selectPoints) continue;
+
+        if(trace.type === 'scatter') {
+            if(scatterSubTypes.hasMarkers(trace) || scatterSubTypes.hasText(trace)) {
+                selectable = true;
+            }
+        }
+        // assume that in general if the trace module has selectPoints,
+        // then it's selectable. Scatter is an exception to this because it must
+        // have markers or text, not just be a scatter type.
+        else selectable = true;
+    }
+
+    return selectable;
+}
+
 // fill in custom buttons referring to default mode bar buttons
 function fillCustomButton(customButtons) {
     for(var i = 0; i < customButtons.length; i++) {

From fcdf05a136285e45294f07798a738c36ebe45518 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=C3=89tienne=20T=C3=A9treault-Pinard?= <etienne@plot.ly>
Date: Thu, 11 Feb 2016 17:03:59 -0500
Subject: [PATCH 02/21] generalize is-active modebar update,

 - using nested property to dive into fullLayout
---
 src/components/modebar/index.js | 11 ++++++++---
 1 file changed, 8 insertions(+), 3 deletions(-)

diff --git a/src/components/modebar/index.js b/src/components/modebar/index.js
index 3a536829c40..9c446686bee 100644
--- a/src/components/modebar/index.js
+++ b/src/components/modebar/index.js
@@ -9,9 +9,10 @@
 
 'use strict';
 
-var Plotly = require('../../plotly');
 var d3 = require('d3');
 
+var Plotly = require('../../plotly');
+var Lib = require('../../lib');
 var Icons = require('../../../build/ploticon');
 
 
@@ -204,7 +205,11 @@ proto.updateActiveButton = function(buttonClicked) {
             }
         }
         else {
-            button3.classed('active', fullLayout[dataAttr]===thisval);
+            var val = (dataAttr === null) ?
+                dataAttr :
+                Lib.nestedProperty(fullLayout, dataAttr).get();
+
+            button3.classed('active', val === thisval);
         }
 
     });
@@ -260,7 +265,7 @@ proto.removeAllButtons = function() {
 };
 
 proto.destroy = function() {
-    Plotly.Lib.removeElement(this.container.querySelector('.modebar'));
+    Lib.removeElement(this.container.querySelector('.modebar'));
 };
 
 function createModeBar(gd, buttons) {

From 91ce34f2155db59790933917b839973994e8ccc3 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=C3=89tienne=20T=C3=A9treault-Pinard?= <etienne@plot.ly>
Date: Thu, 11 Feb 2016 17:21:20 -0500
Subject: [PATCH 03/21] make dragmode and hovermode attribute of each scenes:

- Use layout.dragmode and layout.hovermode as default
  for layout.scene?.dragmode and layout.scene?.hovermode
  if plot has only GL3D (backward compat) and if valid
---
 src/plots/cartesian/graph_interact.js      |  3 +-
 src/plots/gl3d/layout/defaults.js          | 32 ++++++++++++++++++----
 src/plots/gl3d/layout/layout_attributes.js | 19 +++++++++++++
 3 files changed, 47 insertions(+), 7 deletions(-)

diff --git a/src/plots/cartesian/graph_interact.js b/src/plots/cartesian/graph_interact.js
index 6a8327ced22..9d8e5cacbfb 100644
--- a/src/plots/cartesian/graph_interact.js
+++ b/src/plots/cartesian/graph_interact.js
@@ -26,6 +26,7 @@ fx.layoutAttributes = {
         valType: 'enumerated',
         role: 'info',
         values: ['zoom', 'pan', 'select', 'lasso', 'orbit', 'turntable'],
+        dflt: 'zoom',
         description: [
             'Determines the mode of drag interactions.',
             '*select* and *lasso* apply only to scatter traces with',
@@ -50,7 +51,7 @@ fx.supplyLayoutDefaults = function(layoutIn, layoutOut, fullData) {
                                  attr, dflt);
     }
 
-    coerce('dragmode', layoutOut._hasGL3D ? 'turntable' : 'zoom');
+    coerce('dragmode');
 
     if(layoutOut._hasCartesian) {
         // flag for 'horizontal' plots:
diff --git a/src/plots/gl3d/layout/defaults.js b/src/plots/gl3d/layout/defaults.js
index 257ce63ef3f..7a1fa4d6261 100644
--- a/src/plots/gl3d/layout/defaults.js
+++ b/src/plots/gl3d/layout/defaults.js
@@ -29,8 +29,25 @@ module.exports = function supplyLayoutDefaults(layoutIn, layoutOut, fullData) {
         return Lib.coerce(sceneLayoutIn, sceneLayoutOut, layoutAttributes, attr, dflt);
     }
 
+    // some layout-wide attribute are used in all scenes
+    // if 3D is the only visible plot type
+    function getDfltFromLayout(attr) {
+        var isOnlyGL3D = !(
+            layoutOut._hasCartesian ||
+            layoutOut._hasGeo ||
+            layoutOut._hasGL2D ||
+            layoutOut._hasPie
+        );
+
+        var isValid = layoutAttributes[attr].values.indexOf(layoutIn[attr]) !== -1;
+
+        var dflt;
+        if(isOnlyGL3D && isValid) return layoutIn[attr];
+    }
+
     for(var i = 0; i < scenesLength; i++) {
         var scene = scenes[i];
+
         /*
          * Scene numbering proceeds as follows
          * scene
@@ -85,18 +102,21 @@ module.exports = function supplyLayoutDefaults(layoutIn, layoutOut, fullData) {
             if(aspectMode === 'manual') sceneLayoutOut.aspectmode = 'auto';
         }
 
-         /*
-          * scene arrangements need to be implemented: For now just splice
-          * along the horizontal direction. ie.
-          * x:[0,1] -> x:[0,0.5], x:[0.5,1] ->
-          *     x:[0, 0.333] x:[0.333,0.666] x:[0.666, 1]
-          */
+        /*
+         * scene arrangements need to be implemented: For now just splice
+         * along the horizontal direction. ie.
+         * x:[0,1] -> x:[0,0.5], x:[0.5,1] ->
+         *     x:[0, 0.333] x:[0.333,0.666] x:[0.666, 1]
+         */
         supplyGl3dAxisLayoutDefaults(sceneLayoutIn, sceneLayoutOut, {
             font: layoutOut.font,
             scene: scene,
             data: fullData
         });
 
+        coerce('dragmode', getDfltFromLayout('dragmode'));
+        coerce('hovermode', getDfltFromLayout('hovermode'));
+
         layoutOut[scene] = sceneLayoutOut;
     }
 };
diff --git a/src/plots/gl3d/layout/layout_attributes.js b/src/plots/gl3d/layout/layout_attributes.js
index d433ac99682..d101230306c 100644
--- a/src/plots/gl3d/layout/layout_attributes.js
+++ b/src/plots/gl3d/layout/layout_attributes.js
@@ -139,6 +139,25 @@ module.exports = {
     yaxis: gl3dAxisAttrs,
     zaxis: gl3dAxisAttrs,
 
+    dragmode: {
+        valType: 'enumerated',
+        role: 'info',
+        values: ['orbit', 'turntable', 'zoom', 'pan'],
+        dflt: 'turntable',
+        description: [
+            'Determines the mode of drag interactions for this scene.'
+        ].join(' ')
+    },
+    hovermode: {
+        valType: 'enumerated',
+        role: 'info',
+        values: ['closest', false],
+        dflt: 'closest',
+        description: [
+            'Determines the mode of hover interactions for this scene.'
+        ].join(' ')
+    },
+
     _deprecated: {
         cameraposition: {
             valType: 'info_array',

From 74e7e33f6190cc7d3899048590ac36a1df1e3dc2 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=C3=89tienne=20T=C3=A9treault-Pinard?= <etienne@plot.ly>
Date: Thu, 11 Feb 2016 17:21:35 -0500
Subject: [PATCH 04/21] add fx and gl3d defaults tests

---
 test/jasmine/tests/fx_test.js         | 84 +++++++++++++++++++++++++++
 test/jasmine/tests/gl3dlayout_test.js | 76 +++++++++++++++++++-----
 2 files changed, 147 insertions(+), 13 deletions(-)
 create mode 100644 test/jasmine/tests/fx_test.js

diff --git a/test/jasmine/tests/fx_test.js b/test/jasmine/tests/fx_test.js
new file mode 100644
index 00000000000..b4a27fadc17
--- /dev/null
+++ b/test/jasmine/tests/fx_test.js
@@ -0,0 +1,84 @@
+var Fx = require('@src/plots/cartesian/graph_interact');
+
+
+describe('Test FX', function() {
+    'use strict';
+
+    describe('defaults', function() {
+
+        it('should default (blank version)', function() {
+            var layoutIn = {};
+            var layoutOut = {};
+            var fullData = [{}];
+
+            Fx.supplyLayoutDefaults(layoutIn, layoutOut, fullData);
+            expect(layoutOut.hovermode).toBe('closest', 'hovermode to closest');
+            expect(layoutOut.dragmode).toBe('zoom', 'dragmode to zoom');
+        });
+
+        it('should default (cartesian version)', function() {
+            var layoutIn = {};
+            var layoutOut = {
+                _hasCartesian: true
+            };
+            var fullData = [{}];
+
+            Fx.supplyLayoutDefaults(layoutIn, layoutOut, fullData);
+            expect(layoutOut.hovermode).toBe('x', 'hovermode to x');
+            expect(layoutOut.dragmode).toBe('zoom', 'dragmode to zoom');
+            expect(layoutOut._isHoriz).toBe(false, 'isHoriz to false');
+        });
+
+        it('should default (cartesian horizontal version)', function() {
+            var layoutIn = {};
+            var layoutOut = {
+                _hasCartesian: true
+            };
+            var fullData = [{
+                orientation: 'h'
+            }];
+
+            Fx.supplyLayoutDefaults(layoutIn, layoutOut, fullData);
+            expect(layoutOut.hovermode).toBe('y', 'hovermode to y');
+            expect(layoutOut.dragmode).toBe('zoom', 'dragmode to zoom');
+            expect(layoutOut._isHoriz).toBe(true, 'isHoriz to true');
+        });
+
+        it('should default (gl3d version)', function() {
+            var layoutIn = {};
+            var layoutOut = {
+                _hasGL3D: true
+            };
+            var fullData = [{}];
+
+            Fx.supplyLayoutDefaults(layoutIn, layoutOut, fullData);
+            expect(layoutOut.hovermode).toBe('closest', 'hovermode to closest');
+            expect(layoutOut.dragmode).toBe('zoom', 'dragmode to zoom');
+        });
+
+        it('should default (geo version)', function() {
+            var layoutIn = {};
+            var layoutOut = {
+                _hasGeo: true
+            };
+            var fullData = [{}];
+
+            Fx.supplyLayoutDefaults(layoutIn, layoutOut, fullData);
+            expect(layoutOut.hovermode).toBe('closest', 'hovermode to closest');
+            expect(layoutOut.dragmode).toBe('zoom', 'dragmode to zoom');
+        });
+
+        it('should default (multi plot type version)', function() {
+            var layoutIn = {};
+            var layoutOut = {
+                _hasCartesian: true,
+                _hasGL3D: true
+            };
+            var fullData = [{}];
+
+            Fx.supplyLayoutDefaults(layoutIn, layoutOut, fullData);
+            expect(layoutOut.hovermode).toBe('x', 'hovermode to x');
+            expect(layoutOut.dragmode).toBe('zoom', 'dragmode to zoom');
+        });
+    });
+});
diff --git a/test/jasmine/tests/gl3dlayout_test.js b/test/jasmine/tests/gl3dlayout_test.js
index 606a9c6e703..86c32aa3d5a 100644
--- a/test/jasmine/tests/gl3dlayout_test.js
+++ b/test/jasmine/tests/gl3dlayout_test.js
@@ -1,19 +1,16 @@
 var Gl3d = require('@src/plots/gl3d');
 
 
-describe('Test Gl3d layout defaults', function() {
+fdescribe('Test Gl3d layout defaults', function() {
     'use strict';
 
     describe('supplyLayoutDefaults', function() {
-        var layoutIn,
-            layoutOut;
-
         var supplyLayoutDefaults = Gl3d.supplyLayoutDefaults;
+        var layoutIn, layoutOut, fullData;
 
         beforeEach(function() {
-            layoutOut = {
-                _hasGL3D: true
-            };
+            layoutOut = {_hasGL3D: true};
+            fullData = [{scene: 'scene', type: 'scatter3d'}];
         });
 
         it('should coerce aspectmode=ratio when ratio data is valid', function() {
@@ -39,7 +36,7 @@ describe('Test Gl3d layout defaults', function() {
                 }
             };
 
-            supplyLayoutDefaults(layoutIn, layoutOut, [{scene: 'scene', type: 'scatter3d'}]);
+            supplyLayoutDefaults(layoutIn, layoutOut, fullData);
             expect(layoutOut.scene.aspectmode).toBe(expected.scene.aspectmode);
             expect(layoutOut.scene.aspectratio).toEqual(expected.scene.aspectratio);
             expect(layoutOut.scene.bgcolor).toBe(expected.scene.bgcolor);
@@ -68,7 +65,7 @@ describe('Test Gl3d layout defaults', function() {
                 }
             };
 
-            supplyLayoutDefaults(layoutIn, layoutOut, [{scene: 'scene', type: 'scatter3d'}]);
+            supplyLayoutDefaults(layoutIn, layoutOut, fullData);
             expect(layoutOut.scene.aspectmode).toBe(expected.scene.aspectmode);
             expect(layoutOut.scene.aspectratio).toEqual(expected.scene.aspectratio);
         });
@@ -96,7 +93,7 @@ describe('Test Gl3d layout defaults', function() {
                 }
             };
 
-            supplyLayoutDefaults(layoutIn, layoutOut, [{scene: 'scene', type: 'scatter3d'}]);
+            supplyLayoutDefaults(layoutIn, layoutOut, fullData);
             expect(layoutOut.scene.aspectmode).toBe(expected.scene.aspectmode);
             expect(layoutOut.scene.aspectratio).toEqual(expected.scene.aspectratio);
         });
@@ -124,7 +121,7 @@ describe('Test Gl3d layout defaults', function() {
                 }
             };
 
-            supplyLayoutDefaults(layoutIn, layoutOut, [{scene: 'scene', type: 'scatter3d'}]);
+            supplyLayoutDefaults(layoutIn, layoutOut, fullData);
             expect(layoutOut.scene.aspectmode).toBe(expected.scene.aspectmode);
             expect(layoutOut.scene.aspectratio).toEqual(expected.scene.aspectratio);
         });
@@ -152,12 +149,65 @@ describe('Test Gl3d layout defaults', function() {
                 }
             };
 
-            supplyLayoutDefaults(layoutIn, layoutOut, [{scene: 'scene', type: 'scatter3d'}]);
+            supplyLayoutDefaults(layoutIn, layoutOut, fullData);
             expect(layoutOut.scene.aspectmode).toBe(expected.scene.aspectmode);
             expect(layoutOut.scene.aspectratio).toEqual(expected.scene.aspectratio);
         });
 
+        it('should coerce dragmode', function() {
+            layoutIn = {};
+            supplyLayoutDefaults(layoutIn, layoutOut, fullData);
+            expect(layoutOut.scene.dragmode)
+                .toBe('turntable', 'to turntable by default');
+
+            layoutIn = { scene: { dragmode: 'orbit' } };
+            supplyLayoutDefaults(layoutIn, layoutOut, fullData);
+            expect(layoutOut.scene.dragmode)
+                .toBe('orbit', 'to user val if valid');
+
+            layoutIn = { dragmode: 'orbit' };
+            supplyLayoutDefaults(layoutIn, layoutOut, fullData);
+            expect(layoutOut.scene.dragmode)
+                .toBe('orbit', 'to user layout val if valid and 3d only');
+
+            layoutIn = { dragmode: 'orbit' };
+            layoutOut._hasCartesian = true;
+            supplyLayoutDefaults(layoutIn, layoutOut, fullData);
+            expect(layoutOut.scene.dragmode)
+                .toBe('turntable', 'to default if not 3d only');
+
+            layoutIn = { dragmode: 'not gonna work' };
+            supplyLayoutDefaults(layoutIn, layoutOut, fullData);
+            expect(layoutOut.scene.dragmode)
+                .toBe('turntable', 'to default if not valid');
+        });
 
-
+        it('should coerce hovermode', function() {
+            layoutIn = {};
+            supplyLayoutDefaults(layoutIn, layoutOut, fullData);
+            expect(layoutOut.scene.hovermode)
+                .toBe('closest', 'to closest by default');
+
+            layoutIn = { scene: { hovermode: false } };
+            supplyLayoutDefaults(layoutIn, layoutOut, fullData);
+            expect(layoutOut.scene.hovermode)
+                .toBe(false, 'to user val if valid');
+
+            layoutIn = { hovermode: false };
+            supplyLayoutDefaults(layoutIn, layoutOut, fullData);
+            expect(layoutOut.scene.hovermode)
+                .toBe(false, 'to user layout val if valid and 3d only');
+
+            layoutIn = { hovermode: false };
+            layoutOut._hasCartesian = true;
+            supplyLayoutDefaults(layoutIn, layoutOut, fullData);
+            expect(layoutOut.scene.hovermode)
+                .toBe('closest', 'to default if not 3d only');
+
+            layoutIn = { hovermode: 'not gonna work' };
+            supplyLayoutDefaults(layoutIn, layoutOut, fullData);
+            expect(layoutOut.scene.hovermode)
+                .toBe('closest', 'to default if not valid');
+        });
     });
 });

From 760bda35b059dce8328d27826448702a6f4da73d Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=C3=89tienne=20T=C3=A9treault-Pinard?= <etienne@plot.ly>
Date: Thu, 11 Feb 2016 17:22:38 -0500
Subject: [PATCH 05/21] don't show 3d hover labels when scene.hovermode is
 false

---
 src/plots/gl3d/scene.js | 26 ++++++++++++++------------
 1 file changed, 14 insertions(+), 12 deletions(-)

diff --git a/src/plots/gl3d/scene.js b/src/plots/gl3d/scene.js
index db742d5bb0b..5a58f4982ef 100644
--- a/src/plots/gl3d/scene.js
+++ b/src/plots/gl3d/scene.js
@@ -79,18 +79,20 @@ function render(scene) {
             if(hoverinfoParts.indexOf('name') === -1) lastPicked.name = undefined;
         }
 
-        Fx.loneHover({
-            x: (0.5 + 0.5 * pdata[0]/pdata[3]) * width,
-            y: (0.5 - 0.5 * pdata[1]/pdata[3]) * height,
-            xLabel: formatter('xaxis', selection.traceCoordinate[0]),
-            yLabel: formatter('yaxis', selection.traceCoordinate[1]),
-            zLabel: formatter('zaxis', selection.traceCoordinate[2]),
-            text: selection.textLabel,
-            name: lastPicked.name,
-            color: lastPicked.color
-        }, {
-            container: svgContainer
-        });
+        if(scene.fullSceneLayout.hovermode) {
+            Fx.loneHover({
+                x: (0.5 + 0.5 * pdata[0]/pdata[3]) * width,
+                y: (0.5 - 0.5 * pdata[1]/pdata[3]) * height,
+                xLabel: formatter('xaxis', selection.traceCoordinate[0]),
+                yLabel: formatter('yaxis', selection.traceCoordinate[1]),
+                zLabel: formatter('zaxis', selection.traceCoordinate[2]),
+                text: selection.textLabel,
+                name: lastPicked.name,
+                color: lastPicked.color
+            }, {
+                container: svgContainer
+            });
+        }
     }
     else Fx.loneUnhover(svgContainer);
 }

From 1180a8f9a52934215fc4e7bac34d8daa45d72253 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=C3=89tienne=20T=C3=A9treault-Pinard?= <etienne@plot.ly>
Date: Tue, 16 Feb 2016 13:26:04 -0500
Subject: [PATCH 06/21] make 3d drag modebar button update scene.dragmode,

 instead of layout.dragmode
---
 src/components/modebar/buttons.js | 20 +++++++++++---------
 1 file changed, 11 insertions(+), 9 deletions(-)

diff --git a/src/components/modebar/buttons.js b/src/components/modebar/buttons.js
index 2d93bc83365..0114bd7b743 100644
--- a/src/components/modebar/buttons.js
+++ b/src/components/modebar/buttons.js
@@ -267,7 +267,7 @@ function handleCartesian(gd, ev) {
 modeBarButtons.zoom3d = {
     name: 'zoom3d',
     title: 'Zoom',
-    attr: 'dragmode',
+    attr: 'scene.dragmode',
     val: 'zoom',
     icon: Icons.zoombox,
     click: handleDrag3d
@@ -276,7 +276,7 @@ modeBarButtons.zoom3d = {
 modeBarButtons.pan3d = {
     name: 'pan3d',
     title: 'Pan',
-    attr: 'dragmode',
+    attr: 'scene.dragmode',
     val: 'pan',
     icon: Icons.pan,
     click: handleDrag3d
@@ -285,7 +285,7 @@ modeBarButtons.pan3d = {
 modeBarButtons.orbitRotation = {
     name: 'orbitRotation',
     title: 'orbital rotation',
-    attr: 'dragmode',
+    attr: 'scene.dragmode',
     val: 'orbit',
     icon: Icons['3d_rotate'],
     click: handleDrag3d
@@ -294,7 +294,7 @@ modeBarButtons.orbitRotation = {
 modeBarButtons.tableRotation = {
     name: 'tableRotation',
     title: 'turntable rotation',
-    attr: 'dragmode',
+    attr: 'scene.dragmode',
     val: 'turntable',
     icon: Icons['z-axis'],
     click: handleDrag3d
@@ -304,14 +304,16 @@ function handleDrag3d(gd, ev) {
     var button = ev.currentTarget,
         attr = button.getAttribute('data-attr'),
         val = button.getAttribute('data-val') || true,
+        fullLayout = gd._fullLayout,
+        sceneIds = Plotly.Plots.getSubplotIds(fullLayout, 'gl3d'),
         layoutUpdate = {};
 
-    layoutUpdate[attr] = val;
+    var parts = attr.split('.');
+
+    for(var i = 0; i < sceneIds.length; i++) {
+        layoutUpdate[sceneIds[i] + '.' + parts[1]] = val;
+    }
 
-    /*
-     * Dragmode will go through the relayout -> doplot -> scene.plot()
-     * routine where the dragmode will be set in scene.plot()
-     */
     Plotly.relayout(gd, layoutUpdate);
 }
 

From 9010006748a78ad163ee13d72a7b8b755a8447a0 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=C3=89tienne=20T=C3=A9treault-Pinard?= <etienne@plot.ly>
Date: Tue, 16 Feb 2016 13:27:56 -0500
Subject: [PATCH 07/21] make camera modebar button update camera via
 fullLayou.scene?,

 - which is more robust then via layout (in case where
   users partly supply the camera object.
---
 src/components/modebar/buttons.js | 14 ++------------
 1 file changed, 2 insertions(+), 12 deletions(-)

diff --git a/src/components/modebar/buttons.js b/src/components/modebar/buttons.js
index 0114bd7b743..6de2fb3c8d9 100644
--- a/src/components/modebar/buttons.js
+++ b/src/components/modebar/buttons.js
@@ -336,29 +336,19 @@ modeBarButtons.resetCameraLastSave3d = {
 function handleCamera3d(gd, ev) {
     var button = ev.currentTarget,
         attr = button.getAttribute('data-attr'),
-        layout = gd.layout,
         fullLayout = gd._fullLayout,
         sceneIds = Plotly.Plots.getSubplotIds(fullLayout, 'gl3d');
 
     for(var i = 0; i < sceneIds.length; i++) {
         var sceneId = sceneIds[i],
-            sceneLayout = layout[sceneId],
             fullSceneLayout = fullLayout[sceneId],
             scene = fullSceneLayout._scene;
 
-        if(!sceneLayout || attr==='resetDefault') scene.setCameraToDefault();
+        if(attr === 'resetDefault') scene.setCameraToDefault();
         else if(attr === 'resetLastSave') {
-
-            var cameraPos = sceneLayout.camera;
-            if(cameraPos) scene.setCamera(cameraPos);
-            else scene.setCameraToDefault();
+            scene.setCamera(fullSceneLayout.camera);
         }
     }
-
-    /*
-     * TODO have a sceneLastTouched in _fullLayout to only
-     * update the camera of the scene last touched by the user
-     */
 }
 
 modeBarButtons.hoverClosest3d = {

From 11189aeb0fb0fc6e9f5e1a867508289b7d519c78 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=C3=89tienne=20T=C3=A9treault-Pinard?= <etienne@plot.ly>
Date: Tue, 16 Feb 2016 13:29:51 -0500
Subject: [PATCH 08/21] make hover button extend current scene layout,

  - the previous version overrode the current scene layout
---
 src/components/modebar/buttons.js | 86 +++++++++++++++++--------------
 1 file changed, 47 insertions(+), 39 deletions(-)

diff --git a/src/components/modebar/buttons.js b/src/components/modebar/buttons.js
index 6de2fb3c8d9..a6a09189825 100644
--- a/src/components/modebar/buttons.js
+++ b/src/components/modebar/buttons.js
@@ -359,50 +359,58 @@ modeBarButtons.hoverClosest3d = {
     toggle: true,
     icon: Icons.tooltip_basic,
     gravity: 'ne',
-    click: function(gd, ev) {
-        var button = ev.currentTarget,
-            val = JSON.parse(button.getAttribute('data-val')) || false,
-            fullLayout = gd._fullLayout,
-            sceneIds = Plotly.Plots.getSubplotIds(fullLayout, 'gl3d');
-
-        var axes = ['xaxis', 'yaxis', 'zaxis'],
-            spikeAttrs = ['showspikes', 'spikesides', 'spikethickness', 'spikecolor'];
-
-        // initialize 'current spike' object to be stored in the DOM
-        var currentSpikes = {},
-            axisSpikes = {},
-            layoutUpdate = {};
-
-        if(val) {
-            layoutUpdate = val;
-            button.setAttribute('data-val', JSON.stringify(null));
-        }
-        else {
-            layoutUpdate = {'allaxes.showspikes': false};
-
-            for(var i = 0; i < sceneIds.length; i++) {
-                var sceneId = sceneIds[i],
-                    sceneLayout = fullLayout[sceneId],
-                    sceneSpikes = currentSpikes[sceneId] = {};
-
-                // copy all the current spike attrs
-                for(var j = 0; j < 3; j++) {
-                    var axis = axes[j];
-                    axisSpikes = sceneSpikes[axis] = {};
-
-                    for(var k = 0; k < spikeAttrs.length; k++) {
-                        var spikeAttr = spikeAttrs[k];
-                        axisSpikes[spikeAttr] = sceneLayout[axis][spikeAttr];
-                    }
+    click: handleHover3d
+};
+
+function handleHover3d(gd, ev) {
+    var button = ev.currentTarget,
+        val = JSON.parse(button.getAttribute('data-val')) || false,
+        layout = gd.layout,
+        fullLayout = gd._fullLayout,
+        sceneIds = Plotly.Plots.getSubplotIds(fullLayout, 'gl3d');
+
+    var axes = ['xaxis', 'yaxis', 'zaxis'],
+        spikeAttrs = ['showspikes', 'spikesides', 'spikethickness', 'spikecolor'];
+
+    // initialize 'current spike' object to be stored in the DOM
+    var currentSpikes = {},
+        axisSpikes = {},
+        layoutUpdate = {};
+
+    if(val) {
+        layoutUpdate = Lib.extendDeep(layout, val);
+        button.setAttribute('data-val', JSON.stringify(null));
+    }
+    else {
+        layoutUpdate = {
+            'allaxes.showspikes': false
+        };
+
+        for(var i = 0; i < sceneIds.length; i++) {
+            var sceneId = sceneIds[i],
+                sceneLayout = fullLayout[sceneId],
+                sceneSpikes = currentSpikes[sceneId] = {};
+
+            sceneSpikes.hovermode = sceneLayout.hovermode;
+            layoutUpdate[sceneId + '.hovermode'] = false;
+
+            // copy all the current spike attrs
+            for(var j = 0; j < 3; j++) {
+                var axis = axes[j];
+                axisSpikes = sceneSpikes[axis] = {};
+
+                for(var k = 0; k < spikeAttrs.length; k++) {
+                    var spikeAttr = spikeAttrs[k];
+                    axisSpikes[spikeAttr] = sceneLayout[axis][spikeAttr];
                 }
             }
-
-            button.setAttribute('data-val', JSON.stringify(currentSpikes));
         }
 
-        Plotly.relayout(gd, layoutUpdate);
+        button.setAttribute('data-val', JSON.stringify(currentSpikes));
     }
-};
+
+    Plotly.relayout(gd, layoutUpdate);
+}
 
 modeBarButtons.zoomInGeo = {
     name: 'zoomInGeo',

From 524ce133040dde2c485eff7bcf47b0d5af8acdc6 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=C3=89tienne=20T=C3=A9treault-Pinard?= <etienne@plot.ly>
Date: Tue, 16 Feb 2016 13:35:55 -0500
Subject: [PATCH 09/21] use camera spec of own scene to init the camera,

 - this fixes an to this point undiscovered bug, where the
   camera position of non-first scenes in multi-scene
   wasn't set properly.
---
 src/plots/gl3d/scene.js | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/src/plots/gl3d/scene.js b/src/plots/gl3d/scene.js
index 5a58f4982ef..894ec16d6ae 100644
--- a/src/plots/gl3d/scene.js
+++ b/src/plots/gl3d/scene.js
@@ -149,7 +149,7 @@ function initializeGLPlot(scene, fullLayout, canvas, gl) {
     }
 
     if(!scene.camera) {
-        var cameraData = fullLayout.scene.camera;
+        var cameraData = scene.fullSceneLayout.camera;
         scene.camera = createCamera(scene.container, {
             center: [cameraData.center.x, cameraData.center.y, cameraData.center.z],
             eye: [cameraData.eye.x, cameraData.eye.y, cameraData.eye.z],
@@ -167,7 +167,6 @@ function initializeGLPlot(scene, fullLayout, canvas, gl) {
         scene.recoverContext();
     };
 
-
     scene.glplot.onrender = render.bind(null, scene);
 
     //List of scene objects
@@ -203,6 +202,7 @@ function Scene(options, fullLayout) {
 
     this.fullLayout = fullLayout;
     this.id = options.id || 'scene';
+    this.fullSceneLayout = fullLayout[this.id];
 
     //Saved from last call to plot()
     this.plotArgs = [ [], {}, {} ];

From 9354fb3d17e995919022c6cc1b7d6704d69e5080 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=C3=89tienne=20T=C3=A9treault-Pinard?= <etienne@plot.ly>
Date: Tue, 16 Feb 2016 13:40:53 -0500
Subject: [PATCH 10/21] rename scene.proto.handleDragmode -->
 scene.proto.updateFx

- to be consistent with scene2d
- note that updateFx is called w/o passing through scene.proto.plot
  for Plotly.relayout when 'layout.dragmode' and/or
  'layout.hovermode' are updated
---
 src/plot_api/plot_api.js |  4 ++--
 src/plots/gl3d/scene.js  | 15 +++++++++------
 2 files changed, 11 insertions(+), 8 deletions(-)

diff --git a/src/plot_api/plot_api.js b/src/plot_api/plot_api.js
index 2a2bb78e64b..15567dc1cd9 100644
--- a/src/plot_api/plot_api.js
+++ b/src/plot_api/plot_api.js
@@ -2275,7 +2275,7 @@ Plotly.relayout = function relayout(gd, astr, val) {
              * hovermode and dragmode don't need any redrawing, since they just
              * affect reaction to user input. everything else, assume full replot.
              * height, width, autosize get dealt with below. Except for the case of
-             * of subplots - scenes - which require scene.handleDragmode to be called.
+             * of subplots - scenes - which require scene.updateFx to be called.
              */
             else if(['hovermode', 'dragmode'].indexOf(ai) !== -1) domodebar = true;
             else if(['hovermode','dragmode','height',
@@ -2343,7 +2343,7 @@ Plotly.relayout = function relayout(gd, astr, val) {
             subplotIds = Plots.getSubplotIds(fullLayout, 'gl3d');
             for(i = 0; i < subplotIds.length; i++) {
                 scene = fullLayout[subplotIds[i]]._scene;
-                scene.handleDragmode(fullLayout.dragmode);
+                scene.updateFx(fullLayout.dragmode, fullLayout.hovermode);
             }
 
             subplotIds = Plots.getSubplotIds(fullLayout, 'gl2d');
diff --git a/src/plots/gl3d/scene.js b/src/plots/gl3d/scene.js
index 894ec16d6ae..daa90d1b09d 100644
--- a/src/plots/gl3d/scene.js
+++ b/src/plots/gl3d/scene.js
@@ -300,7 +300,7 @@ proto.plot = function(sceneData, fullLayout, layout) {
     this.spikeOptions.merge(fullSceneLayout);
 
     // Update camera mode
-    this.handleDragmode(fullLayout.dragmode);
+    this.updateFx(fullSceneLayout.dragmode, fullSceneLayout.hovermode);
 
     //Update scene
     this.glplot.update({});
@@ -586,16 +586,16 @@ proto.saveCamera = function saveCamera(layout) {
     return hasChanged;
 };
 
-proto.handleDragmode = function(dragmode) {
-
+proto.updateFx = function(dragmode, hovermode) {
     var camera = this.camera;
-    if (camera) {
+
+    if(camera) {
         // rotate and orbital are synonymous
-        if (dragmode === 'orbit') {
+        if(dragmode === 'orbit') {
             camera.mode = 'orbit';
             camera.keyBindingMode = 'rotate';
 
-        } else if (dragmode === 'turntable') {
+        } else if(dragmode === 'turntable') {
             camera.up = [0, 0, 1];
             camera.mode = 'turntable';
             camera.keyBindingMode = 'rotate';
@@ -606,6 +606,9 @@ proto.handleDragmode = function(dragmode) {
             camera.keyBindingMode = dragmode;
         }
     }
+
+    // to put dragmode and hovermode on the same grounds from relayout
+    this.fullSceneLayout.hovermode = hovermode;
 };
 
 proto.toImage = function(format) {

From 48d5957b87c4a07ac30678a49d304eace6e5ee5a Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=C3=89tienne=20T=C3=A9treault-Pinard?= <etienne@plot.ly>
Date: Tue, 16 Feb 2016 13:41:20 -0500
Subject: [PATCH 11/21] lint

---
 src/components/modebar/index.js       |  1 -
 src/plot_api/plot_api.js              | 31 +++++++++++++--------------
 src/plots/cartesian/graph_interact.js | 20 ++++++++---------
 src/plots/gl3d/layout/defaults.js     |  1 -
 src/plots/gl3d/scene.js               |  3 ++-
 test/jasmine/tests/gl3dlayout_test.js |  2 +-
 6 files changed, 28 insertions(+), 30 deletions(-)

diff --git a/src/components/modebar/index.js b/src/components/modebar/index.js
index 9c446686bee..5d86d57f740 100644
--- a/src/components/modebar/index.js
+++ b/src/components/modebar/index.js
@@ -11,7 +11,6 @@
 
 var d3 = require('d3');
 
-var Plotly = require('../../plotly');
 var Lib = require('../../lib');
 var Icons = require('../../../build/ploticon');
 
diff --git a/src/plot_api/plot_api.js b/src/plot_api/plot_api.js
index 15567dc1cd9..4d07ee32f8a 100644
--- a/src/plot_api/plot_api.js
+++ b/src/plot_api/plot_api.js
@@ -598,29 +598,28 @@ function cleanLayout(layout) {
      * Clean up Scene layouts
      */
     var sceneIds = Plots.getSubplotIds(layout, 'gl3d');
-    var scene, cameraposition, rotation,
-        radius, center, mat, eye;
-    for (i = 0; i < sceneIds.length; i++) {
-        scene = layout[sceneIds[i]];
-
-        /*
-         * Clean old Camera coords
-         */
-        cameraposition = scene.cameraposition;
-        if (Array.isArray(cameraposition) && cameraposition[0].length === 4) {
-            rotation = cameraposition[0];
-            center = cameraposition[1];
-            radius = cameraposition[2];
-            mat = m4FromQuat([], rotation);
-            eye = [];
-            for (j = 0; j < 3; ++j) {
+    for(i = 0; i < sceneIds.length; i++) {
+        var scene = layout[sceneIds[i]];
+
+        // clean old Camera coords
+        var cameraposition = scene.cameraposition;
+        if(Array.isArray(cameraposition) && cameraposition[0].length === 4) {
+            var rotation = cameraposition[0],
+                center = cameraposition[1],
+                radius = cameraposition[2],
+                mat = m4FromQuat([], rotation),
+                eye = [];
+
+            for(j = 0; j < 3; ++j) {
                 eye[j] = center[i] + radius * mat[2 + 4 * j];
             }
+
             scene.camera = {
                 eye: {x: eye[0], y: eye[1], z: eye[2]},
                 center: {x: center[0], y: center[1], z: center[2]},
                 up: {x: mat[1], y: mat[5], z: mat[9]}
             };
+
             delete scene.cameraposition;
         }
     }
diff --git a/src/plots/cartesian/graph_interact.js b/src/plots/cartesian/graph_interact.js
index 9d8e5cacbfb..1d0f554da53 100644
--- a/src/plots/cartesian/graph_interact.js
+++ b/src/plots/cartesian/graph_interact.js
@@ -14,6 +14,7 @@ var tinycolor = require('tinycolor2');
 var isNumeric = require('fast-isnumeric');
 
 var Plotly = require('../../plotly');
+var Lib = require('../../lib');
 var Events = require('../../lib/events');
 
 var prepSelect = require('./select');
@@ -43,20 +44,17 @@ fx.layoutAttributes = {
 };
 
 fx.supplyLayoutDefaults = function(layoutIn, layoutOut, fullData) {
-    var isHoriz, hovermodeDflt;
-
     function coerce(attr, dflt) {
-        return Plotly.Lib.coerce(layoutIn, layoutOut,
-                                 fx.layoutAttributes,
-                                 attr, dflt);
+        return Lib.coerce(layoutIn, layoutOut, fx.layoutAttributes, attr, dflt);
     }
 
     coerce('dragmode');
 
+    var hovermodeDflt;
     if(layoutOut._hasCartesian) {
         // flag for 'horizontal' plots:
         // determines the state of the mode bar 'compare' hovermode button
-        isHoriz = layoutOut._isHoriz = fx.isHoriz(fullData);
+        var isHoriz = layoutOut._isHoriz = fx.isHoriz(fullData);
         hovermodeDflt = isHoriz ? 'y' : 'x';
     }
     else hovermodeDflt = 'closest';
@@ -66,14 +64,16 @@ fx.supplyLayoutDefaults = function(layoutIn, layoutOut, fullData) {
 
 fx.isHoriz = function(fullData) {
     var isHoriz = true;
-    var i, trace;
-    for (i = 0; i < fullData.length; i++) {
-        trace = fullData[i];
-        if (trace.orientation !== 'h') {
+
+    for(var i = 0; i < fullData.length; i++) {
+        var trace = fullData[i];
+
+        if(trace.orientation !== 'h') {
             isHoriz = false;
             break;
         }
     }
+
     return isHoriz;
 };
 
diff --git a/src/plots/gl3d/layout/defaults.js b/src/plots/gl3d/layout/defaults.js
index 7a1fa4d6261..d31eb42a87b 100644
--- a/src/plots/gl3d/layout/defaults.js
+++ b/src/plots/gl3d/layout/defaults.js
@@ -41,7 +41,6 @@ module.exports = function supplyLayoutDefaults(layoutIn, layoutOut, fullData) {
 
         var isValid = layoutAttributes[attr].values.indexOf(layoutIn[attr]) !== -1;
 
-        var dflt;
         if(isOnlyGL3D && isValid) return layoutIn[attr];
     }
 
diff --git a/src/plots/gl3d/scene.js b/src/plots/gl3d/scene.js
index daa90d1b09d..36eafe78fa4 100644
--- a/src/plots/gl3d/scene.js
+++ b/src/plots/gl3d/scene.js
@@ -131,7 +131,8 @@ function initializeGLPlot(scene, fullLayout, canvas, gl) {
 
     try {
         scene.glplot = createPlot(glplotOptions);
-    } catch (e) {
+    }
+    catch (e) {
         /*
         * createPlot will throw when webgl is not enabled in the client.
         * Lets return an instance of the module with all functions noop'd.
diff --git a/test/jasmine/tests/gl3dlayout_test.js b/test/jasmine/tests/gl3dlayout_test.js
index 86c32aa3d5a..bd1d7fb0d80 100644
--- a/test/jasmine/tests/gl3dlayout_test.js
+++ b/test/jasmine/tests/gl3dlayout_test.js
@@ -1,7 +1,7 @@
 var Gl3d = require('@src/plots/gl3d');
 
 
-fdescribe('Test Gl3d layout defaults', function() {
+describe('Test Gl3d layout defaults', function() {
     'use strict';
 
     describe('supplyLayoutDefaults', function() {

From bc90474b7f9c3ba2eee4ee24ef574750e025771c Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=C3=89tienne=20T=C3=A9treault-Pinard?= <etienne@plot.ly>
Date: Tue, 16 Feb 2016 13:42:44 -0500
Subject: [PATCH 12/21] add several 3d mode bar jasmine tests,

 - these will only run locally at the moment as
   and until https://github.com/plotly/plotly.js/issues/241
   is resolved.
---
 test/jasmine/assets/modebar_button.js       |  23 +++
 test/jasmine/tests/gl_plot_interact_test.js | 170 ++++++++++++++++++++
 2 files changed, 193 insertions(+)
 create mode 100644 test/jasmine/assets/modebar_button.js

diff --git a/test/jasmine/assets/modebar_button.js b/test/jasmine/assets/modebar_button.js
new file mode 100644
index 00000000000..dd0ad296ae4
--- /dev/null
+++ b/test/jasmine/assets/modebar_button.js
@@ -0,0 +1,23 @@
+'use strict';
+
+var d3 = require('d3');
+
+var modeBarButtons = require('@src/components/modebar/buttons');
+
+
+module.exports = function selectButton(modeBar, name) {
+    var button = d3.select(modeBar.element)
+        .select('[data-title="' + modeBarButtons[name].title + '"]')
+        .node();
+
+    button.click = function() {
+        var ev = new window.MouseEvent('click');
+        button.dispatchEvent(ev);
+    };
+
+    button.isActive = function() {
+        return d3.select(button).classed('active');
+    };
+
+    return button;
+};
diff --git a/test/jasmine/tests/gl_plot_interact_test.js b/test/jasmine/tests/gl_plot_interact_test.js
index 2f7bf0b04a5..a7ed9547e74 100644
--- a/test/jasmine/tests/gl_plot_interact_test.js
+++ b/test/jasmine/tests/gl_plot_interact_test.js
@@ -1,9 +1,12 @@
 var d3 = require('d3');
 
 var Plotly = require('@lib/index');
+var Plots = require('@src/plots/plots');
+var Lib = require('@src/lib');
 
 var createGraphDiv = require('../assets/create_graph_div');
 var destroyGraphDiv = require('../assets/destroy_graph_div');
+var selectButton = require('../assets/modebar_button');
 
 /*
  * WebGL interaction test cases fail on the CircleCI
@@ -43,4 +46,171 @@ describe('Test plot structure', function() {
         });
     });
 
+    describe('gl3d modebar click handlers', function() {
+        var gd, modeBar;
+
+        beforeEach(function(done) {
+            var mockData = [{
+                type: 'scatter3d'
+            }, {
+                type: 'surface', scene: 'scene2'
+            }];
+
+            var mockLayout = {
+                scene: { camera: { eye: { x: 0.1, y: 0.1, z: 1 }}},
+                scene2: { camera: { eye: { x: 2.5, y: 2.5, z: 2.5 }}}
+            };
+
+            gd = createGraphDiv();
+            Plotly.plot(gd, mockData, mockLayout).then(function() {
+                modeBar = gd._fullLayout._modeBar;
+                done();
+            });
+        });
+
+        function assertScenes(cont, attr, val) {
+            var sceneIds = Plots.getSubplotIds(cont, 'gl3d');
+
+            sceneIds.forEach(function(sceneId) {
+                var thisVal = Lib.nestedProperty(cont[sceneId], attr).get();
+                expect(thisVal).toEqual(val);
+            });
+        }
+
+        describe('button zoom3d', function() {
+            it('should updates the scene dragmode and dragmode button', function() {
+                var buttonTurntable = selectButton(modeBar, 'tableRotation'),
+                    buttonZoom3d = selectButton(modeBar, 'zoom3d');
+
+                assertScenes(gd._fullLayout, 'dragmode', 'turntable');
+                expect(buttonTurntable.isActive()).toBe(true);
+                expect(buttonZoom3d.isActive()).toBe(false);
+
+                buttonZoom3d.click();
+                assertScenes(gd.layout, 'dragmode', 'zoom');
+                expect(gd.layout.dragmode).toBe(undefined);
+                expect(gd._fullLayout.dragmode).toBe('zoom');
+                expect(buttonTurntable.isActive()).toBe(false);
+                expect(buttonZoom3d.isActive()).toBe(true);
+
+                buttonTurntable.click();
+                assertScenes(gd._fullLayout, 'dragmode', 'turntable');
+                expect(buttonTurntable.isActive()).toBe(true);
+                expect(buttonZoom3d.isActive()).toBe(false);
+            });
+        });
+
+        describe('button pan3d', function() {
+            it('should updates the scene dragmode and dragmode button', function() {
+                var buttonTurntable = selectButton(modeBar, 'tableRotation'),
+                    buttonPan3d = selectButton(modeBar, 'pan3d');
+
+                assertScenes(gd._fullLayout, 'dragmode', 'turntable');
+                expect(buttonTurntable.isActive()).toBe(true);
+                expect(buttonPan3d.isActive()).toBe(false);
+
+                buttonPan3d.click();
+                assertScenes(gd.layout, 'dragmode', 'pan');
+                expect(gd.layout.dragmode).toBe(undefined);
+                expect(gd._fullLayout.dragmode).toBe('zoom');
+                expect(buttonTurntable.isActive()).toBe(false);
+                expect(buttonPan3d.isActive()).toBe(true);
+
+                buttonTurntable.click();
+                assertScenes(gd._fullLayout, 'dragmode', 'turntable');
+                expect(buttonTurntable.isActive()).toBe(true);
+                expect(buttonPan3d.isActive()).toBe(false);
+            });
+        });
+
+        describe('button orbitRotation', function() {
+            it('should updates the scene dragmode and dragmode button', function() {
+                var buttonTurntable = selectButton(modeBar, 'tableRotation'),
+                    buttonOrbit = selectButton(modeBar, 'orbitRotation');
+
+                assertScenes(gd._fullLayout, 'dragmode', 'turntable');
+                expect(buttonTurntable.isActive()).toBe(true);
+                expect(buttonOrbit.isActive()).toBe(false);
+
+                buttonOrbit.click();
+                assertScenes(gd.layout, 'dragmode', 'orbit');
+                expect(gd.layout.dragmode).toBe(undefined);
+                expect(gd._fullLayout.dragmode).toBe('zoom');
+                expect(buttonTurntable.isActive()).toBe(false);
+                expect(buttonOrbit.isActive()).toBe(true);
+
+                buttonTurntable.click();
+                assertScenes(gd._fullLayout, 'dragmode', 'turntable');
+                expect(buttonTurntable.isActive()).toBe(true);
+                expect(buttonOrbit.isActive()).toBe(false);
+            });
+        });
+
+        describe('buttons resetCameraDefault3d and resetCameraLastSave3d', function() {
+            // changes in scene objects are not instantaneous
+            var DELAY = 1000;
+
+            it('should update the scene camera', function(done) {
+                var sceneLayout = gd._fullLayout.scene,
+                    sceneLayout2 = gd._fullLayout.scene2,
+                    scene = sceneLayout._scene,
+                    scene2 = sceneLayout2._scene;
+
+                expect(sceneLayout.camera.eye)
+                    .toEqual({x: 0.1, y: 0.1, z: 1});
+                expect(sceneLayout2.camera.eye)
+                    .toEqual({x: 2.5, y: 2.5, z: 2.5});
+
+                selectButton(modeBar, 'resetCameraDefault3d').click();
+                setTimeout(function() {
+                    expect(sceneLayout.camera.eye)
+                        .toEqual({x: 0.1, y: 0.1, z: 1}, 'does not change the layout objects');
+                    expect(scene.camera.eye)
+                        .toEqual([1.2500000000000002, 1.25, 1.25]);
+                    expect(sceneLayout2.camera.eye)
+                        .toEqual({x: 2.5, y: 2.5, z: 2.5}, 'does not change the layout objects');
+                    expect(scene2.camera.eye)
+                        .toEqual([1.2500000000000002, 1.25, 1.25]);
+
+                    selectButton(modeBar, 'resetCameraLastSave3d').click();
+                    setTimeout(function() {
+                        expect(sceneLayout.camera.eye)
+                            .toEqual({x: 0.1, y: 0.1, z: 1}, 'does not change the layout objects');
+                        expect(scene.camera.eye)
+                            .toEqual([ 0.10000000000000016, 0.10000000000000016, 1]);
+                        expect(sceneLayout2.camera.eye)
+                            .toEqual({x: 2.5, y: 2.5, z: 2.5}, 'does not change the layout objects');
+                        expect(scene2.camera.eye)
+                            .toEqual([2.500000000000001, 2.5000000000000004, 2.5000000000000004]);
+
+                        done();
+                    }, DELAY);
+                }, DELAY);
+            });
+        });
+
+        describe('button hoverClosest3d', function() {
+            it('should update the scene hovermode and spikes', function() {
+                var buttonHover = selectButton(modeBar, 'hoverClosest3d');
+
+                assertScenes(gd._fullLayout, 'hovermode', 'closest');
+                expect(buttonHover.isActive()).toBe(true);
+
+                buttonHover.click();
+                assertScenes(gd._fullLayout, 'hovermode', false);
+                assertScenes(gd._fullLayout, 'xaxis.showspikes', false);
+                assertScenes(gd._fullLayout, 'yaxis.showspikes', false);
+                assertScenes(gd._fullLayout, 'zaxis.showspikes', false);
+                expect(buttonHover.isActive()).toBe(false);
+
+                buttonHover.click();
+                assertScenes(gd._fullLayout, 'hovermode', 'closest');
+                assertScenes(gd._fullLayout, 'xaxis.showspikes', true);
+                assertScenes(gd._fullLayout, 'yaxis.showspikes', true);
+                assertScenes(gd._fullLayout, 'zaxis.showspikes', true);
+                expect(buttonHover.isActive()).toBe(true);
+            });
+        });
+
+    });
 });

From 986e8b0028379f9703b58736af4b189cdb77e74f Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=C3=89tienne=20T=C3=A9treault-Pinard?= <etienne@plot.ly>
Date: Tue, 16 Feb 2016 16:14:16 -0500
Subject: [PATCH 13/21] make modebar button assest abstraction more robust

---
 test/jasmine/assets/modebar_button.js | 8 +++++---
 1 file changed, 5 insertions(+), 3 deletions(-)

diff --git a/test/jasmine/assets/modebar_button.js b/test/jasmine/assets/modebar_button.js
index dd0ad296ae4..3464cf7b403 100644
--- a/test/jasmine/assets/modebar_button.js
+++ b/test/jasmine/assets/modebar_button.js
@@ -6,17 +6,19 @@ var modeBarButtons = require('@src/components/modebar/buttons');
 
 
 module.exports = function selectButton(modeBar, name) {
-    var button = d3.select(modeBar.element)
+    var button = {};
+
+    var node = button.node = d3.select(modeBar.element)
         .select('[data-title="' + modeBarButtons[name].title + '"]')
         .node();
 
     button.click = function() {
         var ev = new window.MouseEvent('click');
-        button.dispatchEvent(ev);
+        node.dispatchEvent(ev);
     };
 
     button.isActive = function() {
-        return d3.select(button).classed('active');
+        return d3.select(node).classed('active');
     };
 
     return button;

From fe0d8bbd09fb71ff6aba14d09cd7ce99e22165f3 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=C3=89tienne=20T=C3=A9treault-Pinard?= <etienne@plot.ly>
Date: Tue, 16 Feb 2016 17:11:45 -0500
Subject: [PATCH 14/21] add geo.proto.updateFx method:

- propatet hovermode update into geo using updateFx
- port relayout shortcut from modebar to Plotly.relayout
- make toogle hover mode bar using stock Plotly.relayout
---
 src/components/modebar/buttons.js |  3 +--
 src/plot_api/plot_api.js          |  8 +++++++-
 src/plots/geo/geo.js              | 11 ++++++++++-
 3 files changed, 18 insertions(+), 4 deletions(-)

diff --git a/src/components/modebar/buttons.js b/src/components/modebar/buttons.js
index a6a09189825..867b3f43e10 100644
--- a/src/components/modebar/buttons.js
+++ b/src/components/modebar/buttons.js
@@ -447,7 +447,7 @@ modeBarButtons.hoverClosestGeo = {
     toggle: true,
     icon: Icons.tooltip_basic,
     gravity: 'ne',
-    click: handleGeo
+    click: toggleHover
 };
 
 function handleGeo(gd, ev) {
@@ -468,7 +468,6 @@ function handleGeo(gd, ev) {
             geo.render();
         }
         else if(attr === 'reset') geo.zoomReset();
-        else if(attr === 'hovermode') geo.showHover = !geo.showHover;
     }
 }
 
diff --git a/src/plot_api/plot_api.js b/src/plot_api/plot_api.js
index 4d07ee32f8a..d37e78635bd 100644
--- a/src/plot_api/plot_api.js
+++ b/src/plot_api/plot_api.js
@@ -2336,9 +2336,9 @@ Plotly.relayout = function relayout(gd, astr, val) {
 
         // this is decoupled enough it doesn't need async regardless
         if(domodebar) {
+            var subplotIds;
             manageModeBar(gd);
 
-            var subplotIds;
             subplotIds = Plots.getSubplotIds(fullLayout, 'gl3d');
             for(i = 0; i < subplotIds.length; i++) {
                 scene = fullLayout[subplotIds[i]]._scene;
@@ -2350,6 +2350,12 @@ Plotly.relayout = function relayout(gd, astr, val) {
                 scene = fullLayout._plots[subplotIds[i]]._scene2d;
                 scene.updateFx(fullLayout);
             }
+
+            subplotIds = Plots.getSubplotIds(fullLayout, 'geo');
+            for(i = 0; i < subplotIds.length; i++) {
+                var geo = fullLayout[subplotIds[i]]._geo;
+                geo.updateFx(fullLayout.hovermode);
+            }
         }
     }
 
diff --git a/src/plots/geo/geo.js b/src/plots/geo/geo.js
index ed0c5a9ac0c..d2bfd2cf912 100644
--- a/src/plots/geo/geo.js
+++ b/src/plots/geo/geo.js
@@ -39,7 +39,6 @@ function Geo(options, fullLayout) {
     // a subset of https://github.com/d3/d3-geo-projection
     addProjectionsToD3();
 
-    this.showHover = (fullLayout.hovermode === 'closest');
     this.hoverContainer = null;
 
     this.topojsonName = null;
@@ -56,6 +55,7 @@ function Geo(options, fullLayout) {
     this.zoomReset = null;
 
     this.makeFramework();
+    this.updateFx(fullLayout.hovermode);
 }
 
 module.exports = Geo;
@@ -174,6 +174,15 @@ proto.onceTopojsonIsLoaded = function(geoData, geoLayout) {
     this.render();
 };
 
+proto.updateFx = function(hovermode) {
+    this.showHover = (hovermode !== false);
+
+    // TODO should more strict, any layout.hovermode other
+    // then false will make all geo subplot display hover text.
+    // Instead each geo should have its own geo.hovermode
+    // to control hover visibility independently of other subplots.
+};
+
 proto.makeProjection = function(geoLayout) {
     var projLayout = geoLayout.projection,
         projType = projLayout.type,

From 0c5cf5633cac8aecae0850871e4c05c6c39fb8cd Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=C3=89tienne=20T=C3=A9treault-Pinard?= <etienne@plot.ly>
Date: Tue, 16 Feb 2016 17:15:10 -0500
Subject: [PATCH 15/21] add multi-plot-type graphs mode bar button logic:

- graphs with more than one plot types get 'union buttons'
  which reset the view or toggle hover labels across all subplots.
---
 src/components/modebar/buttons.js  | 39 +++++++++++++
 src/components/modebar/manage.js   | 18 ++++--
 test/jasmine/tests/modebar_test.js | 89 +++++++++++++++++++++++++++++-
 3 files changed, 140 insertions(+), 6 deletions(-)

diff --git a/src/components/modebar/buttons.js b/src/components/modebar/buttons.js
index 867b3f43e10..21025ce49e5 100644
--- a/src/components/modebar/buttons.js
+++ b/src/components/modebar/buttons.js
@@ -497,3 +497,42 @@ function toggleHover(gd) {
 
     Plotly.relayout(gd, 'hovermode', newHover);
 }
+
+// buttons when more then one plot types are present
+
+modeBarButtons.toggleHover = {
+    name: 'toggleHover',
+    title: 'Toggle show closest data on hover',
+    attr: 'hovermode',
+    val: null,
+    toggle: true,
+    icon: Icons.tooltip_basic,
+    gravity: 'ne',
+    click: function(gd, ev) {
+        toggleHover(gd);
+
+        // the 3d hovermode update must come
+        // last so that layout.hovermode update does not
+        // override scene?.hovermode?.layout.
+        handleHover3d(gd, ev);
+    }
+};
+
+modeBarButtons.resetViews = {
+    name: 'resetViews',
+    title: 'Reset views',
+    icon: Icons.home,
+    click: function(gd, ev) {
+        var button = ev.currentTarget;
+
+        button.setAttribute('data-attr', 'zoom');
+        button.setAttribute('data-val', 'reset');
+        handleCartesian(gd, ev);
+
+        button.setAttribute('data-attr', 'resetLastSave');
+        handleCamera3d(gd, ev);
+
+        // N.B handleCamera3d also triggers a replot for
+        // geo subplots.
+    }
+};
diff --git a/src/components/modebar/manage.js b/src/components/modebar/manage.js
index 9b64bfac938..217f218d298 100644
--- a/src/components/modebar/manage.js
+++ b/src/components/modebar/manage.js
@@ -109,6 +109,13 @@ function getButtonGroups(gd, buttonsToRemove, buttonsToAdd) {
     // buttons common to all plot types
     addGroup(['toImage', 'sendDataToCloud']);
 
+    // graphs with more than one plot types get 'union buttons'
+    // which reset the view or toggle hover labels across all subplots.
+    if((hasCartesian || hasGL2D || hasPie) + hasGeo + hasGL3D > 1) {
+        addGroup(['resetViews', 'toggleHover']);
+        return appendButtonsToAdd(groups);
+    }
+
     if(hasGL3D) {
         addGroup(['zoom3d', 'pan3d', 'orbitRotation', 'tableRotation']);
         addGroup(['resetCameraDefault3d', 'resetCameraLastSave3d']);
@@ -136,13 +143,16 @@ function getButtonGroups(gd, buttonsToRemove, buttonsToAdd) {
         addGroup(['zoomIn2d', 'zoomOut2d', 'autoScale2d', 'resetScale2d']);
     }
 
-    if(hasCartesian) {
-        addGroup(['hoverClosestCartesian', 'hoverCompareCartesian']);
+    if(hasCartesian && hasPie) {
+        addGroup(['toggleHover']);
     }
-    if(hasGL2D) {
+    else if(hasGL2D) {
         addGroup(['hoverClosestGl2d']);
     }
-    if(hasPie) {
+    else if(hasCartesian) {
+        addGroup(['hoverClosestCartesian', 'hoverCompareCartesian']);
+    }
+    else if(hasPie) {
         addGroup(['hoverClosestPie']);
     }
 
diff --git a/test/jasmine/tests/modebar_test.js b/test/jasmine/tests/modebar_test.js
index 06d64351469..4987af40bc1 100644
--- a/test/jasmine/tests/modebar_test.js
+++ b/test/jasmine/tests/modebar_test.js
@@ -202,9 +202,9 @@ describe('ModeBar', function() {
             gd._fullLayout._hasCartesian = true;
             gd._fullLayout.xaxis = {fixedrange: false};
             gd._fullData = [{
-                type:'scatter',
+                type: 'scatter',
                 visible: true,
-                mode:'markers',
+                mode: 'markers',
                 _module: {selectPoints: true}
             }];
 
@@ -295,6 +295,91 @@ describe('ModeBar', function() {
             checkButtons(modeBar, buttons, 1);
         });
 
+        it('creates mode bar (cartesian + gl3d version)', function() {
+            var buttons = getButtons([
+                ['toImage', 'sendDataToCloud'],
+                ['resetViews', 'toggleHover']
+            ]);
+
+            var gd = getMockGraphInfo();
+            gd._fullLayout._hasCartesian = true;
+            gd._fullLayout._hasGL3D = true;
+            gd._fullLayout._hasGeo = false;
+            gd._fullLayout._hasGL2D = false;
+            gd._fullLayout._hasPie = false;
+
+            manageModeBar(gd);
+            var modeBar = gd._fullLayout._modeBar;
+
+            checkButtons(modeBar, buttons, 1);
+        });
+
+        it('creates mode bar (cartesian + geo version)', function() {
+            var buttons = getButtons([
+                ['toImage', 'sendDataToCloud'],
+                ['resetViews', 'toggleHover']
+            ]);
+
+            var gd = getMockGraphInfo();
+            gd._fullLayout._hasCartesian = true;
+            gd._fullLayout._hasGL3D = false;
+            gd._fullLayout._hasGeo = true;
+            gd._fullLayout._hasGL2D = false;
+            gd._fullLayout._hasPie = false;
+
+            manageModeBar(gd);
+            var modeBar = gd._fullLayout._modeBar;
+
+            checkButtons(modeBar, buttons, 1);
+        });
+
+        it('creates mode bar (cartesian + pie version)', function() {
+            var buttons = getButtons([
+                ['toImage', 'sendDataToCloud'],
+                ['zoom2d', 'pan2d', 'select2d', 'lasso2d'],
+                ['zoomIn2d', 'zoomOut2d', 'autoScale2d', 'resetScale2d'],
+                ['toggleHover']
+            ]);
+
+            var gd = getMockGraphInfo();
+            gd._fullLayout._hasCartesian = true;
+            gd._fullData = [{
+                type: 'scatter',
+                visible: true,
+                mode: 'markers',
+                _module: {selectPoints: true}
+            }];
+            gd._fullLayout.xaxis = {fixedrange: false};
+            gd._fullLayout._hasGL3D = false;
+            gd._fullLayout._hasGeo = false;
+            gd._fullLayout._hasGL2D = false;
+            gd._fullLayout._hasPie = true;
+
+            manageModeBar(gd);
+            var modeBar = gd._fullLayout._modeBar;
+
+            checkButtons(modeBar, buttons, 1);
+        });
+
+        it('creates mode bar (gl3d + geo version)', function() {
+            var buttons = getButtons([
+                ['toImage', 'sendDataToCloud'],
+                ['resetViews', 'toggleHover']
+            ]);
+
+            var gd = getMockGraphInfo();
+            gd._fullLayout._hasCartesian = false;
+            gd._fullLayout._hasGL3D = true;
+            gd._fullLayout._hasGeo = true;
+            gd._fullLayout._hasGL2D = false;
+            gd._fullLayout._hasPie = false;
+
+            manageModeBar(gd);
+            var modeBar = gd._fullLayout._modeBar;
+
+            checkButtons(modeBar, buttons, 1);
+        });
+
         it('throws an error if modeBarButtonsToRemove isn\'t an array', function() {
             var gd = getMockGraphInfo();
             gd._context.modeBarButtonsToRemove = 'not gonna work';

From 266caf0dbe03d335f468ba6188cc1b2fda7510dd Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=C3=89tienne=20T=C3=A9treault-Pinard?= <etienne@plot.ly>
Date: Tue, 16 Feb 2016 17:15:58 -0500
Subject: [PATCH 16/21] generalize toggleHover mode bar button handler,

 so that cartesian plots keep retrieve their 'x' and 'y' hovermode
 when toggling.
---
 src/components/modebar/buttons.js | 10 +++++++++-
 1 file changed, 9 insertions(+), 1 deletion(-)

diff --git a/src/components/modebar/buttons.js b/src/components/modebar/buttons.js
index 21025ce49e5..bff47961854 100644
--- a/src/components/modebar/buttons.js
+++ b/src/components/modebar/buttons.js
@@ -493,7 +493,15 @@ modeBarButtons.hoverClosestPie = {
 };
 
 function toggleHover(gd) {
-    var newHover = gd._fullLayout.hovermode ? false : 'closest';
+    var fullLayout = gd._fullLayout;
+
+    var onHoverVal;
+    if(fullLayout._hasCartesian) {
+        onHoverVal = fullLayout._isHoriz ? 'y' : 'x';
+    }
+    else onHoverVal = 'closest';
+
+    var newHover = gd._fullLayout.hovermode ? false : onHoverVal;
 
     Plotly.relayout(gd, 'hovermode', newHover);
 }

From 790bc73dfaca9ac5e2a76c2ca8e2bd3b2add2176 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=C3=89tienne=20T=C3=A9treault-Pinard?= <etienne@plot.ly>
Date: Tue, 16 Feb 2016 17:16:37 -0500
Subject: [PATCH 17/21] add cartesian, geo and pie mode bar click handler tests

---
 test/jasmine/tests/modebar_test.js | 223 +++++++++++++++++++++++++++++
 1 file changed, 223 insertions(+)

diff --git a/test/jasmine/tests/modebar_test.js b/test/jasmine/tests/modebar_test.js
index 4987af40bc1..9ac840cd41d 100644
--- a/test/jasmine/tests/modebar_test.js
+++ b/test/jasmine/tests/modebar_test.js
@@ -3,6 +3,11 @@ var d3 = require('d3');
 var createModeBar = require('@src/components/modebar');
 var manageModeBar = require('@src/components/modebar/manage');
 
+var Plotly = require('@lib/index');
+var createGraphDiv = require('../assets/create_graph_div');
+var destroyGraphDiv = require('../assets/destroy_graph_div');
+var selectButton = require('../assets/modebar_button');
+
 
 describe('ModeBar', function() {
     'use strict';
@@ -553,4 +558,222 @@ describe('ModeBar', function() {
 
     });
 
+    describe('modebar on clicks', function() {
+        var gd, modeBar;
+
+        afterEach(destroyGraphDiv);
+
+        function assertRange(actual, expected) {
+            var PRECISION = 4;
+            expect(actual[0]).toBeCloseTo(expected[0], PRECISION);
+            expect(actual[1]).toBeCloseTo(expected[1], PRECISION);
+        }
+
+        function assertActive(buttons, activeButton) {
+            for(var i = 0; i < buttons.length; i++) {
+                expect(buttons[i].isActive()).toBe(
+                    buttons[i] === activeButton
+                );
+            }
+        }
+
+        describe('cartesian handlers', function() {
+
+            beforeEach(function(done) {
+                var mockData = [{
+                    type: 'scatter',
+                    y: [2, 1, 2]
+                }, {
+                    type: 'bar',
+                    y: [2, 1, 2],
+                    xaxis: 'x2',
+                    yaxis: 'y2'
+                }];
+
+                var mockLayout = {
+                    xaxis: {
+                        anchor: 'y',
+                        domain: [0, 0.5],
+                        range: [0, 5]
+                    },
+                    yaxis: {
+                        anchor: 'x',
+                        range: [0, 3]
+                    },
+                    xaxis2: {
+                        anchor: 'y2',
+                        domain: [0.5, 1],
+                        range: [-1, 4]
+                    },
+                    yaxis2: {
+                        anchor: 'x2',
+                        range: [0, 4]
+                    }
+                };
+
+                gd = createGraphDiv();
+                Plotly.plot(gd, mockData, mockLayout).then(function() {
+                    modeBar = gd._fullLayout._modeBar;
+                    done();
+                });
+            });
+
+            describe('buttons zoomIn2d, zoomOut2d, autoScale2d and resetScale2d', function() {
+                it('should update axis ranges', function() {
+                    var buttonZoomIn = selectButton(modeBar, 'zoomIn2d'),
+                        buttonZoomOut = selectButton(modeBar, 'zoomOut2d'),
+                        buttonAutoScale = selectButton(modeBar, 'autoScale2d'),
+                        buttonResetScale = selectButton(modeBar, 'resetScale2d');
+
+                    assertRange(gd._fullLayout.xaxis.range, [0, 5]);
+                    assertRange(gd._fullLayout.yaxis.range, [0, 3]);
+                    assertRange(gd._fullLayout.xaxis2.range, [-1, 4]);
+                    assertRange(gd._fullLayout.yaxis2.range, [0, 4]);
+
+                    buttonZoomIn.click();
+                    assertRange(gd._fullLayout.xaxis.range, [1.25, 3.75]);
+                    assertRange(gd._fullLayout.yaxis.range, [0.75, 2.25]);
+                    assertRange(gd._fullLayout.xaxis2.range, [0.25, 2.75]);
+                    assertRange(gd._fullLayout.yaxis2.range, [1, 3]);
+
+                    buttonZoomOut.click();
+                    assertRange(gd._fullLayout.xaxis.range, [0, 5]);
+                    assertRange(gd._fullLayout.yaxis.range, [0, 3]);
+                    assertRange(gd._fullLayout.xaxis2.range, [-1, 4]);
+                    assertRange(gd._fullLayout.yaxis2.range, [0, 4]);
+
+                    buttonZoomIn.click();
+                    buttonAutoScale.click();
+                    assertRange(gd._fullLayout.xaxis.range, [-0.1375913, 2.137591]);
+                    assertRange(gd._fullLayout.yaxis.range, [0.92675159, 2.073248]);
+                    assertRange(gd._fullLayout.xaxis2.range, [-0.5, 2.5]);
+                    assertRange(gd._fullLayout.yaxis2.range, [0, 2.105263]);
+
+                    buttonResetScale.click();
+                    assertRange(gd._fullLayout.xaxis.range, [0, 5]);
+                    assertRange(gd._fullLayout.yaxis.range, [0, 3]);
+                    assertRange(gd._fullLayout.xaxis2.range, [-1, 4]);
+                    assertRange(gd._fullLayout.yaxis2.range, [0, 4]);
+                });
+            });
+
+            describe('buttons zoom2d, pan2d, select2d and lasso2d', function() {
+                it('should update the layout dragmode', function() {
+                    var zoom2d = selectButton(modeBar, 'zoom2d'),
+                        pan2d = selectButton(modeBar, 'pan2d'),
+                        select2d = selectButton(modeBar, 'select2d'),
+                        lasso2d = selectButton(modeBar, 'lasso2d'),
+                        buttons = [zoom2d, pan2d, select2d, lasso2d];
+
+                    expect(gd._fullLayout.dragmode).toBe('zoom');
+                    assertActive(buttons, zoom2d);
+
+                    pan2d.click();
+                    expect(gd._fullLayout.dragmode).toBe('pan');
+                    assertActive(buttons, pan2d);
+
+                    select2d.click();
+                    expect(gd._fullLayout.dragmode).toBe('select');
+                    assertActive(buttons, select2d);
+
+                    lasso2d.click();
+                    expect(gd._fullLayout.dragmode).toBe('lasso');
+                    assertActive(buttons, lasso2d);
+
+                    zoom2d.click();
+                    expect(gd._fullLayout.dragmode).toBe('zoom');
+                    assertActive(buttons, zoom2d);
+                });
+            });
+
+            describe('buttons hoverCompareCartesian and hoverClosestCartesian ', function() {
+                it('should update layout hovermode', function() {
+                    var buttonCompare = selectButton(modeBar, 'hoverCompareCartesian'),
+                        buttonClosest = selectButton(modeBar, 'hoverClosestCartesian'),
+                        buttons = [buttonCompare, buttonClosest];
+
+                    expect(gd._fullLayout.hovermode).toBe('x');
+                    assertActive(buttons, buttonCompare);
+
+                    buttonClosest.click();
+                    expect(gd._fullLayout.hovermode).toBe('closest');
+                    assertActive(buttons, buttonClosest);
+
+                    buttonCompare.click();
+                    expect(gd._fullLayout.hovermode).toBe('x');
+                    assertActive(buttons, buttonCompare);
+                });
+            });
+        });
+
+        describe('pie handlers', function() {
+
+            beforeEach(function(done) {
+                var mockData = [{
+                    type: 'pie',
+                    labels: ['apples', 'bananas', 'grapes'],
+                    values: [10, 20, 30]
+                }];
+
+                gd = createGraphDiv();
+                Plotly.plot(gd, mockData).then(function() {
+                    modeBar = gd._fullLayout._modeBar;
+                    done();
+                });
+            });
+
+            describe('buttons hoverClosestPie', function() {
+                it('should update layout hovermode', function() {
+                    var button = selectButton(modeBar, 'hoverClosestPie');
+
+                    expect(gd._fullLayout.hovermode).toBe('closest');
+                    expect(button.isActive()).toBe(true);
+
+                    button.click();
+                    expect(gd._fullLayout.hovermode).toBe(false);
+                    expect(button.isActive()).toBe(false);
+
+                    button.click();
+                    expect(gd._fullLayout.hovermode).toBe('closest');
+                    expect(button.isActive()).toBe(true);
+                });
+            });
+        });
+
+        describe('geo handlers', function() {
+
+            beforeEach(function(done) {
+                var mockData = [{
+                    type: 'scattergeo',
+                    lon: [10, 20, 30],
+                    lat: [10, 20, 30]
+                }];
+
+                gd = createGraphDiv();
+                Plotly.plot(gd, mockData).then(function() {
+                    modeBar = gd._fullLayout._modeBar;
+                    done();
+                });
+            });
+
+            describe('buttons hoverClosestGeo', function() {
+                it('should update layout hovermode', function() {
+                    var button = selectButton(modeBar, 'hoverClosestGeo');
+
+                    expect(gd._fullLayout.hovermode).toBe('closest');
+                    expect(button.isActive()).toBe(true);
+
+                    button.click();
+                    expect(gd._fullLayout.hovermode).toBe(false);
+                    expect(button.isActive()).toBe(false);
+
+                    button.click();
+                    expect(gd._fullLayout.hovermode).toBe('closest');
+                    expect(button.isActive()).toBe(true);
+                });
+            });
+
+        });
+
+    });
 });

From 05a1b4fb51a9adb663c4c0bfdd500f82d201a7ee Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=C3=89tienne=20T=C3=A9treault-Pinard?= <etienne@plot.ly>
Date: Tue, 16 Feb 2016 17:24:54 -0500
Subject: [PATCH 18/21] bump down precision for circleCI

---
 test/jasmine/tests/modebar_test.js | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/test/jasmine/tests/modebar_test.js b/test/jasmine/tests/modebar_test.js
index 9ac840cd41d..933aafd28f9 100644
--- a/test/jasmine/tests/modebar_test.js
+++ b/test/jasmine/tests/modebar_test.js
@@ -564,7 +564,7 @@ describe('ModeBar', function() {
         afterEach(destroyGraphDiv);
 
         function assertRange(actual, expected) {
-            var PRECISION = 4;
+            var PRECISION = 2;
             expect(actual[0]).toBeCloseTo(expected[0], PRECISION);
             expect(actual[1]).toBeCloseTo(expected[1], PRECISION);
         }

From 4babccbcd4f3fb21587cf50ff9993a966225c7c4 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=C3=89tienne=20T=C3=A9treault-Pinard?= <etienne@plot.ly>
Date: Wed, 17 Feb 2016 13:20:12 -0500
Subject: [PATCH 19/21] make append buttons to add step more readable

---
 src/components/modebar/manage.js | 30 +++++++++++++++---------------
 1 file changed, 15 insertions(+), 15 deletions(-)

diff --git a/src/components/modebar/manage.js b/src/components/modebar/manage.js
index 217f218d298..65ea0c16ff3 100644
--- a/src/components/modebar/manage.js
+++ b/src/components/modebar/manage.js
@@ -93,19 +93,6 @@ function getButtonGroups(gd, buttonsToRemove, buttonsToAdd) {
         groups.push(out);
     }
 
-    function appendButtonsToAdd(groups) {
-        if(buttonsToAdd.length) {
-            if(Array.isArray(buttonsToAdd[0])) {
-                for(var i = 0; i < buttonsToAdd.length; i++) {
-                    groups.push(buttonsToAdd[i]);
-                }
-            }
-            else groups.push(buttonsToAdd);
-        }
-
-        return groups;
-    }
-
     // buttons common to all plot types
     addGroup(['toImage', 'sendDataToCloud']);
 
@@ -113,7 +100,7 @@ function getButtonGroups(gd, buttonsToRemove, buttonsToAdd) {
     // which reset the view or toggle hover labels across all subplots.
     if((hasCartesian || hasGL2D || hasPie) + hasGeo + hasGL3D > 1) {
         addGroup(['resetViews', 'toggleHover']);
-        return appendButtonsToAdd(groups);
+        return appendButtonsToGroups(groups, buttonsToAdd);
     }
 
     if(hasGL3D) {
@@ -156,7 +143,7 @@ function getButtonGroups(gd, buttonsToRemove, buttonsToAdd) {
         addGroup(['hoverClosestPie']);
     }
 
-    return appendButtonsToAdd(groups);
+    return appendButtonsToGroups(groups, buttonsToAdd);
 }
 
 function areAllAxesFixed(fullLayout) {
@@ -199,6 +186,19 @@ function isSelectable(fullData) {
     return selectable;
 }
 
+function appendButtonsToGroups(groups, buttons) {
+    if(buttons.length) {
+        if(Array.isArray(buttons[0])) {
+            for(var i = 0; i < buttons.length; i++) {
+                groups.push(buttons[i]);
+            }
+        }
+        else groups.push(buttons);
+    }
+
+    return groups;
+}
+
 // fill in custom buttons referring to default mode bar buttons
 function fillCustomButton(customButtons) {
     for(var i = 0; i < customButtons.length; i++) {

From 9f3d14ef3602eaa62892d6d794673935fcf1f0ec Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=C3=89tienne=20T=C3=A9treault-Pinard?= <etienne@plot.ly>
Date: Wed, 17 Feb 2016 13:52:54 -0500
Subject: [PATCH 20/21] store previous scene state in button obj,

  instead of in JSON stringify/parse via the DOM element.
---
 src/components/modebar/buttons.js | 6 +++---
 1 file changed, 3 insertions(+), 3 deletions(-)

diff --git a/src/components/modebar/buttons.js b/src/components/modebar/buttons.js
index bff47961854..cef24a656eb 100644
--- a/src/components/modebar/buttons.js
+++ b/src/components/modebar/buttons.js
@@ -364,7 +364,7 @@ modeBarButtons.hoverClosest3d = {
 
 function handleHover3d(gd, ev) {
     var button = ev.currentTarget,
-        val = JSON.parse(button.getAttribute('data-val')) || false,
+        val = button._previousVal || false,
         layout = gd.layout,
         fullLayout = gd._fullLayout,
         sceneIds = Plotly.Plots.getSubplotIds(fullLayout, 'gl3d');
@@ -379,7 +379,7 @@ function handleHover3d(gd, ev) {
 
     if(val) {
         layoutUpdate = Lib.extendDeep(layout, val);
-        button.setAttribute('data-val', JSON.stringify(null));
+        button._previousVal = null;
     }
     else {
         layoutUpdate = {
@@ -406,7 +406,7 @@ function handleHover3d(gd, ev) {
             }
         }
 
-        button.setAttribute('data-val', JSON.stringify(currentSpikes));
+        button._previousVal = Lib.extendDeep({}, currentSpikes);
     }
 
     Plotly.relayout(gd, layoutUpdate);

From da5ea2136e88d63821ddcb1c0806ded7ff8c2797 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=C3=89tienne=20T=C3=A9treault-Pinard?= <etienne@plot.ly>
Date: Wed, 17 Feb 2016 13:53:00 -0500
Subject: [PATCH 21/21] :cow2:

---
 src/plots/gl3d/scene.js | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/src/plots/gl3d/scene.js b/src/plots/gl3d/scene.js
index 36eafe78fa4..cb3f6a504cb 100644
--- a/src/plots/gl3d/scene.js
+++ b/src/plots/gl3d/scene.js
@@ -81,8 +81,8 @@ function render(scene) {
 
         if(scene.fullSceneLayout.hovermode) {
             Fx.loneHover({
-                x: (0.5 + 0.5 * pdata[0]/pdata[3]) * width,
-                y: (0.5 - 0.5 * pdata[1]/pdata[3]) * height,
+                x: (0.5 + 0.5 * pdata[0] / pdata[3]) * width,
+                y: (0.5 - 0.5 * pdata[1] / pdata[3]) * height,
                 xLabel: formatter('xaxis', selection.traceCoordinate[0]),
                 yLabel: formatter('yaxis', selection.traceCoordinate[1]),
                 zLabel: formatter('zaxis', selection.traceCoordinate[2]),