diff --git a/src/components/colorbar/draw.js b/src/components/colorbar/draw.js
index c76324ca067..0afb0c11c54 100644
--- a/src/components/colorbar/draw.js
+++ b/src/components/colorbar/draw.js
@@ -170,7 +170,10 @@ module.exports = function draw(gd, id) {
                 anchor: 'free',
                 position: 1
             },
-            cbAxisOut = {},
+            cbAxisOut = {
+                type: 'linear',
+                _id: 'y' + id
+            },
             axisOptions = {
                 letter: 'y',
                 font: fullLayout.font,
@@ -188,8 +191,6 @@ module.exports = function draw(gd, id) {
         handleAxisDefaults(cbAxisIn, cbAxisOut, coerce, axisOptions, fullLayout);
         handleAxisPositionDefaults(cbAxisIn, cbAxisOut, coerce, axisOptions);
 
-        cbAxisOut._id = 'y' + id;
-
         // position can't go in through supplyDefaults
         // because that restricts it to [0,1]
         cbAxisOut.position = opts.x + xpadFrac + thickFrac;
diff --git a/src/constants/numerical.js b/src/constants/numerical.js
index c881daa72c4..6b0d6b55ed2 100644
--- a/src/constants/numerical.js
+++ b/src/constants/numerical.js
@@ -42,5 +42,10 @@ module.exports = {
      * For fast conversion btwn world calendars and epoch ms, the Julian Day Number
      * of the unix epoch. From calendars.instance().newDate(1970, 1, 1).toJD()
      */
-    EPOCHJD: 2440587.5
+    EPOCHJD: 2440587.5,
+
+    /*
+     * Are two values nearly equal? Compare to 1PPM
+     */
+    ALMOST_EQUAL: 1 - 1e-6
 };
diff --git a/src/plot_api/plot_api.js b/src/plot_api/plot_api.js
index 1b35e32ac77..c6e8d0e6cb1 100644
--- a/src/plot_api/plot_api.js
+++ b/src/plot_api/plot_api.js
@@ -32,6 +32,8 @@ var manageArrays = require('./manage_arrays');
 var helpers = require('./helpers');
 var subroutines = require('./subroutines');
 var cartesianConstants = require('../plots/cartesian/constants');
+var enforceAxisConstraints = require('../plots/cartesian/constraints');
+var axisIds = require('../plots/cartesian/axis_ids');
 
 
 /**
@@ -151,10 +153,6 @@ Plotly.plot = function(gd, data, layout, config) {
         makePlotFramework(gd);
     }
 
-    // save initial axis range once per graph
-    if(graphWasEmpty) Plotly.Axes.saveRangeInitial(gd);
-
-
     // prepare the data and find the autorange
 
     // generate calcdata, if we need to
@@ -256,18 +254,24 @@ Plotly.plot = function(gd, data, layout, config) {
         return Lib.syncOrAsync([
             Registry.getComponentMethod('shapes', 'calcAutorange'),
             Registry.getComponentMethod('annotations', 'calcAutorange'),
-            doAutoRange,
+            doAutoRangeAndConstraints,
             Registry.getComponentMethod('rangeslider', 'calcAutorange')
         ], gd);
     }
 
-    function doAutoRange() {
+    function doAutoRangeAndConstraints() {
         if(gd._transitioning) return;
 
         var axList = Plotly.Axes.list(gd, '', true);
         for(var i = 0; i < axList.length; i++) {
             Plotly.Axes.doAutoRange(axList[i]);
         }
+
+        enforceAxisConstraints(gd);
+
+        // store initial ranges *after* enforcing constraints, otherwise
+        // we will never look like we're at the initial ranges
+        if(graphWasEmpty) Plotly.Axes.saveRangeInitial(gd);
     }
 
     // draw ticks, titles, and calculate axis scaling (._b, ._m)
@@ -1857,6 +1861,16 @@ function _relayout(gd, aobj) {
         return (ax || {}).autorange;
     }
 
+    // for constraint enforcement: keep track of all axes (as {id: name})
+    // we're editing the (auto)range of, so we can tell the others constrained
+    // to scale with them that it's OK for them to shrink
+    var rangesAltered = {};
+
+    function recordAlteredAxis(pleafPlus) {
+        var axId = axisIds.name2id(pleafPlus.split('.')[0]);
+        rangesAltered[axId] = 1;
+    }
+
     // alter gd.layout
     for(var ai in aobj) {
         if(helpers.hasParent(aobj, ai)) {
@@ -1891,15 +1905,17 @@ function _relayout(gd, aobj) {
         //
         // To do so, we must manually set them back here using the _initialAutoSize cache.
         if(['width', 'height'].indexOf(ai) !== -1 && vi === null) {
-            gd._fullLayout[ai] = gd._initialAutoSize[ai];
+            fullLayout[ai] = gd._initialAutoSize[ai];
         }
         // check autorange vs range
         else if(pleafPlus.match(/^[xyz]axis[0-9]*\.range(\[[0|1]\])?$/)) {
             doextra(ptrunk + '.autorange', false);
+            recordAlteredAxis(pleafPlus);
         }
         else if(pleafPlus.match(/^[xyz]axis[0-9]*\.autorange$/)) {
             doextra([ptrunk + '.range[0]', ptrunk + '.range[1]'],
                 undefined);
+            recordAlteredAxis(pleafPlus);
         }
         else if(pleafPlus.match(/^aspectratio\.[xyz]$/)) {
             doextra(proot + '.aspectmode', 'manual');
@@ -2063,6 +2079,18 @@ function _relayout(gd, aobj) {
             else if(proot.indexOf('geo') === 0) flags.doplot = true;
             else if(proot.indexOf('ternary') === 0) flags.doplot = true;
             else if(ai === 'paper_bgcolor') flags.doplot = true;
+            else if(proot === 'margin' ||
+                    pp1 === 'autorange' ||
+                    pp1 === 'rangemode' ||
+                    pp1 === 'type' ||
+                    pp1 === 'domain' ||
+                    pp1 === 'fixedrange' ||
+                    pp1 === 'scaleanchor' ||
+                    pp1 === 'scaleratio' ||
+                    ai.indexOf('calendar') !== -1 ||
+                    ai.match(/^(bar|box|font)/)) {
+                flags.docalc = true;
+            }
             else if(fullLayout._has('gl2d') &&
                 (ai.indexOf('axis') !== -1 || ai === 'plot_bgcolor')
             ) flags.doplot = true;
@@ -2086,15 +2114,6 @@ function _relayout(gd, aobj) {
             else if(ai === 'margin.pad') {
                 flags.doticks = flags.dolayoutstyle = true;
             }
-            else if(proot === 'margin' ||
-                    pp1 === 'autorange' ||
-                    pp1 === 'rangemode' ||
-                    pp1 === 'type' ||
-                    pp1 === 'domain' ||
-                    ai.indexOf('calendar') !== -1 ||
-                    ai.match(/^(bar|box|font)/)) {
-                flags.docalc = true;
-            }
             /*
              * hovermode and dragmode don't need any redrawing, since they just
              * affect reaction to user input, everything else, assume full replot.
@@ -2118,16 +2137,37 @@ function _relayout(gd, aobj) {
         if(!finished) flags.doplot = true;
     }
 
-    var oldWidth = gd._fullLayout.width,
-        oldHeight = gd._fullLayout.height;
+    // figure out if we need to recalculate axis constraints
+    var constraints = fullLayout._axisConstraintGroups;
+    for(var axId in rangesAltered) {
+        for(i = 0; i < constraints.length; i++) {
+            var group = constraints[i];
+            if(group[axId]) {
+                // Always recalc if we're changing constrained ranges.
+                // Otherwise it's possible to violate the constraints by
+                // specifying arbitrary ranges for all axes in the group.
+                // this way some ranges may expand beyond what's specified,
+                // as they do at first draw, to satisfy the constraints.
+                flags.docalc = true;
+                for(var groupAxId in group) {
+                    if(!rangesAltered[groupAxId]) {
+                        axisIds.getFromId(gd, groupAxId)._constraintShrinkable = true;
+                    }
+                }
+            }
+        }
+    }
+
+    var oldWidth = fullLayout.width,
+        oldHeight = fullLayout.height;
 
     // calculate autosizing
-    if(gd.layout.autosize) Plots.plotAutoSize(gd, gd.layout, gd._fullLayout);
+    if(gd.layout.autosize) Plots.plotAutoSize(gd, gd.layout, fullLayout);
 
     // avoid unnecessary redraws
     var hasSizechanged = aobj.height || aobj.width ||
-        (gd._fullLayout.width !== oldWidth) ||
-        (gd._fullLayout.height !== oldHeight);
+        (fullLayout.width !== oldWidth) ||
+        (fullLayout.height !== oldHeight);
 
     if(hasSizechanged) flags.docalc = true;
 
diff --git a/src/plots/cartesian/axis_defaults.js b/src/plots/cartesian/axis_defaults.js
index 1ed0a669392..c0f0ea8e35a 100644
--- a/src/plots/cartesian/axis_defaults.js
+++ b/src/plots/cartesian/axis_defaults.js
@@ -22,8 +22,6 @@ var handleTickLabelDefaults = require('./tick_label_defaults');
 var handleCategoryOrderDefaults = require('./category_order_defaults');
 var setConvert = require('./set_convert');
 var orderedCategories = require('./ordered_categories');
-var axisIds = require('./axis_ids');
-var autoType = require('./axis_autotype');
 
 
 /**
@@ -31,12 +29,11 @@ var autoType = require('./axis_autotype');
  *
  *  letter: 'x' or 'y'
  *  title: name of the axis (ie 'Colorbar') to go in default title
- *  name: axis object name (ie 'xaxis') if one should be stored
  *  font: the default font to inherit
  *  outerTicks: boolean, should ticks default to outside?
  *  showGrid: boolean, should gridlines be shown by default?
  *  noHover: boolean, this axis doesn't support hover effects?
- *  data: the plot data to use in choosing auto type
+ *  data: the plot data, used to manage categories
  *  bgColor: the plot background color, to calculate default gridline colors
  */
 module.exports = function handleAxisDefaults(containerIn, containerOut, coerce, options, layoutOut) {
@@ -50,28 +47,7 @@ module.exports = function handleAxisDefaults(containerIn, containerOut, coerce,
         return Lib.coerce2(containerIn, containerOut, layoutAttributes, attr, dflt);
     }
 
-    // set up some private properties
-    if(options.name) {
-        containerOut._name = options.name;
-        containerOut._id = axisIds.name2id(options.name);
-    }
-
-    // now figure out type and do some more initialization
-    var axType = coerce('type');
-    if(axType === '-') {
-        setAutoType(containerOut, options.data);
-
-        if(containerOut.type === '-') {
-            containerOut.type = 'linear';
-        }
-        else {
-            // copy autoType back to input axis
-            // note that if this object didn't exist
-            // in the input layout, we have to put it in
-            // this happens in the main supplyDefaults function
-            axType = containerIn.type = containerOut.type;
-        }
-    }
+    var axType = containerOut.type;
 
     if(axType === 'date') {
         var handleCalendarDefaults = Registry.getComponentMethod('calendars', 'handleDefaults');
@@ -140,87 +116,3 @@ module.exports = function handleAxisDefaults(containerIn, containerOut, coerce,
 
     return containerOut;
 };
-
-function setAutoType(ax, data) {
-    // new logic: let people specify any type they want,
-    // only autotype if type is '-'
-    if(ax.type !== '-') return;
-
-    var id = ax._id,
-        axLetter = id.charAt(0);
-
-    // support 3d
-    if(id.indexOf('scene') !== -1) id = axLetter;
-
-    var d0 = getFirstNonEmptyTrace(data, id, axLetter);
-    if(!d0) return;
-
-    // first check for histograms, as the count direction
-    // should always default to a linear axis
-    if(d0.type === 'histogram' &&
-            axLetter === {v: 'y', h: 'x'}[d0.orientation || 'v']) {
-        ax.type = 'linear';
-        return;
-    }
-
-    var calAttr = axLetter + 'calendar',
-        calendar = d0[calAttr];
-
-    // check all boxes on this x axis to see
-    // if they're dates, numbers, or categories
-    if(isBoxWithoutPositionCoords(d0, axLetter)) {
-        var posLetter = getBoxPosLetter(d0),
-            boxPositions = [],
-            trace;
-
-        for(var i = 0; i < data.length; i++) {
-            trace = data[i];
-            if(!Registry.traceIs(trace, 'box') ||
-               (trace[axLetter + 'axis'] || axLetter) !== id) continue;
-
-            if(trace[posLetter] !== undefined) boxPositions.push(trace[posLetter][0]);
-            else if(trace.name !== undefined) boxPositions.push(trace.name);
-            else boxPositions.push('text');
-
-            if(trace[calAttr] !== calendar) calendar = undefined;
-        }
-
-        ax.type = autoType(boxPositions, calendar);
-    }
-    else {
-        ax.type = autoType(d0[axLetter] || [d0[axLetter + '0']], calendar);
-    }
-}
-
-function getBoxPosLetter(trace) {
-    return {v: 'x', h: 'y'}[trace.orientation || 'v'];
-}
-
-function isBoxWithoutPositionCoords(trace, axLetter) {
-    var posLetter = getBoxPosLetter(trace),
-        isBox = Registry.traceIs(trace, 'box'),
-        isCandlestick = Registry.traceIs(trace._fullInput || {}, 'candlestick');
-
-    return (
-        isBox &&
-        !isCandlestick &&
-        axLetter === posLetter &&
-        trace[posLetter] === undefined &&
-        trace[posLetter + '0'] === undefined
-    );
-}
-
-function getFirstNonEmptyTrace(data, id, axLetter) {
-    for(var i = 0; i < data.length; i++) {
-        var trace = data[i];
-
-        if((trace[axLetter + 'axis'] || axLetter) === id) {
-            if(isBoxWithoutPositionCoords(trace, axLetter)) {
-                return trace;
-            }
-            else if((trace[axLetter] || []).length || trace[axLetter + '0']) {
-                return trace;
-            }
-        }
-    }
-}
diff --git a/src/plots/cartesian/constraint_defaults.js b/src/plots/cartesian/constraint_defaults.js
new file mode 100644
index 00000000000..5224676dfe2
--- /dev/null
+++ b/src/plots/cartesian/constraint_defaults.js
@@ -0,0 +1,137 @@
+/**
+* Copyright 2012-2017, Plotly, Inc.
+* All rights reserved.
+*
+* This source code is licensed under the MIT license found in the
+* LICENSE file in the root directory of this source tree.
+*/
+
+
+'use strict';
+
+var Lib = require('../../lib');
+var id2name = require('./axis_ids').id2name;
+
+
+module.exports = function handleConstraintDefaults(containerIn, containerOut, coerce, allAxisIds, layoutOut) {
+    var constraintGroups = layoutOut._axisConstraintGroups;
+
+    if(containerOut.fixedrange || !containerIn.scaleanchor) return;
+
+    var constraintOpts = getConstraintOpts(constraintGroups, containerOut._id, allAxisIds, layoutOut);
+
+    var scaleanchor = Lib.coerce(containerIn, containerOut, {
+        scaleanchor: {
+            valType: 'enumerated',
+            values: constraintOpts.linkableAxes
+        }
+    }, 'scaleanchor');
+
+    if(scaleanchor) {
+        var scaleratio = coerce('scaleratio');
+        // TODO: I suppose I could do attribute.min: Number.MIN_VALUE to avoid zero,
+        // but that seems hacky. Better way to say "must be a positive number"?
+        // Of course if you use several super-tiny values you could eventually
+        // force a product of these to zero and all hell would break loose...
+        // Likewise with super-huge values.
+        if(!scaleratio) scaleratio = containerOut.scaleratio = 1;
+
+        updateConstraintGroups(constraintGroups, constraintOpts.thisGroup,
+            containerOut._id, scaleanchor, scaleratio);
+    }
+    else if(allAxisIds.indexOf(containerIn.scaleanchor) !== -1) {
+        Lib.warn('ignored ' + containerOut._name + '.scaleanchor: "' +
+            containerIn.scaleanchor + '" to avoid either an infinite loop ' +
+            'and possibly inconsistent scaleratios, or because the target' +
+            'axis has fixed range.');
+    }
+};
+
+function getConstraintOpts(constraintGroups, thisID, allAxisIds, layoutOut) {
+    // If this axis is already part of a constraint group, we can't
+    // scaleanchor any other axis in that group, or we'd make a loop.
+    // Filter allAxisIds to enforce this, also matching axis types.
+
+    var thisType = layoutOut[id2name(thisID)].type;
+
+    var i, j, idj, axj;
+
+    var linkableAxes = [];
+    for(j = 0; j < allAxisIds.length; j++) {
+        idj = allAxisIds[j];
+        if(idj === thisID) continue;
+
+        axj = layoutOut[id2name(idj)];
+        if(axj.type === thisType && !axj.fixedrange) linkableAxes.push(idj);
+    }
+
+    for(i = 0; i < constraintGroups.length; i++) {
+        if(constraintGroups[i][thisID]) {
+            var thisGroup = constraintGroups[i];
+
+            var linkableAxesNoLoops = [];
+            for(j = 0; j < linkableAxes.length; j++) {
+                idj = linkableAxes[j];
+                if(!thisGroup[idj]) linkableAxesNoLoops.push(idj);
+            }
+            return {linkableAxes: linkableAxesNoLoops, thisGroup: thisGroup};
+        }
+    }
+
+    return {linkableAxes: linkableAxes, thisGroup: null};
+}
+
+
+/*
+ * Add this axis to the axis constraint groups, which is the collection
+ * of axes that are all constrained together on scale.
+ *
+ * constraintGroups: a list of objects. each object is
+ * {axis_id: scale_within_group}, where scale_within_group is
+ * only important relative to the rest of the group, and defines
+ * the relative scales between all axes in the group
+ *
+ * thisGroup: the group the current axis is already in
+ * thisID: the id if the current axis
+ * scaleanchor: the id of the axis to scale it with
+ * scaleratio: the ratio of this axis to the scaleanchor axis
+ */
+function updateConstraintGroups(constraintGroups, thisGroup, thisID, scaleanchor, scaleratio) {
+    var i, j, groupi, keyj, thisGroupIndex;
+
+    if(thisGroup === null) {
+        thisGroup = {};
+        thisGroup[thisID] = 1;
+        thisGroupIndex = constraintGroups.length;
+        constraintGroups.push(thisGroup);
+    }
+    else {
+        thisGroupIndex = constraintGroups.indexOf(thisGroup);
+    }
+
+    var thisGroupKeys = Object.keys(thisGroup);
+
+    // we know that this axis isn't in any other groups, but we don't know
+    // about the scaleanchor axis. If it is, we need to merge the groups.
+    for(i = 0; i < constraintGroups.length; i++) {
+        groupi = constraintGroups[i];
+        if(i !== thisGroupIndex && groupi[scaleanchor]) {
+            var baseScale = groupi[scaleanchor];
+            for(j = 0; j < thisGroupKeys.length; j++) {
+                keyj = thisGroupKeys[j];
+                groupi[keyj] = baseScale * scaleratio * thisGroup[keyj];
+            }
+            constraintGroups.splice(thisGroupIndex, 1);
+            return;
+        }
+    }
+
+    // otherwise, we insert the new scaleanchor axis as the base scale (1)
+    // in its group, and scale the rest of the group to it
+    if(scaleratio !== 1) {
+        for(j = 0; j < thisGroupKeys.length; j++) {
+            thisGroup[thisGroupKeys[j]] *= scaleratio;
+        }
+    }
+    thisGroup[scaleanchor] = 1;
+}
diff --git a/src/plots/cartesian/constraints.js b/src/plots/cartesian/constraints.js
new file mode 100644
index 00000000000..8ef140e58f3
--- /dev/null
+++ b/src/plots/cartesian/constraints.js
@@ -0,0 +1,74 @@
+/**
+* Copyright 2012-2017, Plotly, Inc.
+* All rights reserved.
+*
+* This source code is licensed under the MIT license found in the
+* LICENSE file in the root directory of this source tree.
+*/
+
+
+'use strict';
+
+var id2name = require('./axis_ids').id2name;
+var scaleZoom = require('./scale_zoom');
+
+var ALMOST_EQUAL = require('../../constants/numerical').ALMOST_EQUAL;
+
+
+module.exports = function enforceAxisConstraints(gd) {
+    var fullLayout = gd._fullLayout;
+    var constraintGroups = fullLayout._axisConstraintGroups;
+
+    var i, j, axisID, ax, normScale;
+
+    for(i = 0; i < constraintGroups.length; i++) {
+        var group = constraintGroups[i];
+        var axisIDs = Object.keys(group);
+
+        var minScale = Infinity;
+        var maxScale = 0;
+        // mostly matchScale will be the same as minScale
+        // ie we expand axis ranges to encompass *everything*
+        // that's currently in any of their ranges, but during
+        // autorange of a subset of axes we will ignore other
+        // axes for this purpose.
+        var matchScale = Infinity;
+        var normScales = {};
+        var axes = {};
+
+        // find the (normalized) scale of each axis in the group
+        for(j = 0; j < axisIDs.length; j++) {
+            axisID = axisIDs[j];
+            axes[axisID] = ax = fullLayout[id2name(axisID)];
+
+            // set axis scale here so we can use _m rather than
+            // having to calculate it from length and range
+            ax.setScale();
+
+            // abs: inverted scales still satisfy the constraint
+            normScales[axisID] = normScale = Math.abs(ax._m) / group[axisID];
+            minScale = Math.min(minScale, normScale);
+            if(ax._constraintShrinkable) {
+                // this has served its purpose, so remove it
+                delete ax._constraintShrinkable;
+            }
+            else {
+                matchScale = Math.min(matchScale, normScale);
+            }
+            maxScale = Math.max(maxScale, normScale);
+        }
+
+        // Do we have a constraint mismatch? Give a small buffer for rounding errors
+        if(minScale > ALMOST_EQUAL * maxScale) continue;
+
+        // now increase any ranges we need to until all normalized scales are equal
+        for(j = 0; j < axisIDs.length; j++) {
+            axisID = axisIDs[j];
+            normScale = normScales[axisID];
+
+            if(normScale !== matchScale) {
+                scaleZoom(axes[axisID], normScale / matchScale);
+            }
+        }
+    }
+};
diff --git a/src/plots/cartesian/dragbox.js b/src/plots/cartesian/dragbox.js
index dd46b9037be..3c174224e60 100644
--- a/src/plots/cartesian/dragbox.js
+++ b/src/plots/cartesian/dragbox.js
@@ -21,9 +21,14 @@ var Drawing = require('../../components/drawing');
 var setCursor = require('../../lib/setcursor');
 var dragElement = require('../../components/dragelement');
 
-var Axes = require('./axes');
+var doTicks = require('./axes').doTicks;
+var getFromId = require('./axis_ids').getFromId;
 var prepSelect = require('./select');
+var scaleZoom = require('./scale_zoom');
+
 var constants = require('./constants');
+var MINDRAG = constants.MINDRAG;
+var MINZOOM = constants.MINZOOM;
 
 
 // flag for showing "doubleclick to zoom out" only at the beginning
@@ -46,48 +51,71 @@ module.exports = function dragBox(gd, plotinfo, x, y, w, h, ns, ew) {
     // dragged stores whether a drag has occurred, so we don't have to
     // redraw unnecessarily, ie if no move bigger than MINDRAG or MINZOOM px
     var fullLayout = gd._fullLayout,
+        zoomlayer = gd._fullLayout._zoomlayer,
+        isMainDrag = (ns + ew === 'nsew'),
+        subplots,
+        xa,
+        ya,
+        xs,
+        ys,
+        pw,
+        ph,
+        xActive,
+        yActive,
+        cursor,
+        isSubplotConstrained,
+        xaLinked,
+        yaLinked;
+
+    function recomputeAxisLists() {
+        xa = [plotinfo.xaxis];
+        ya = [plotinfo.yaxis];
+        var xa0 = xa[0];
+        var ya0 = ya[0];
+        pw = xa0._length;
+        ph = ya0._length;
+
+        var constraintGroups = fullLayout._axisConstraintGroups;
+        var xIDs = [xa0._id];
+        var yIDs = [ya0._id];
+
         // if we're dragging two axes at once, also drag overlays
-        subplots = [plotinfo].concat((ns && ew) ? plotinfo.overlays : []),
-        xa = [plotinfo.xaxis],
-        ya = [plotinfo.yaxis],
-        pw = xa[0]._length,
-        ph = ya[0]._length,
-        MINDRAG = constants.MINDRAG,
-        MINZOOM = constants.MINZOOM,
-        isMainDrag = (ns + ew === 'nsew');
-
-    for(var i = 1; i < subplots.length; i++) {
-        var subplotXa = subplots[i].xaxis,
-            subplotYa = subplots[i].yaxis;
-        if(xa.indexOf(subplotXa) === -1) xa.push(subplotXa);
-        if(ya.indexOf(subplotYa) === -1) ya.push(subplotYa);
-    }
+        subplots = [plotinfo].concat((ns && ew) ? plotinfo.overlays : []);
+
+        for(var i = 1; i < subplots.length; i++) {
+            var subplotXa = subplots[i].xaxis,
+                subplotYa = subplots[i].yaxis;
 
-    function isDirectionActive(axList, activeVal) {
-        for(var i = 0; i < axList.length; i++) {
-            if(!axList[i].fixedrange) return activeVal;
+            if(xa.indexOf(subplotXa) === -1) {
+                xa.push(subplotXa);
+                xIDs.push(subplotXa._id);
+            }
+
+            if(ya.indexOf(subplotYa) === -1) {
+                ya.push(subplotYa);
+                yIDs.push(subplotYa._id);
+            }
         }
-        return '';
-    }
 
-    var allaxes = xa.concat(ya),
-        xActive = isDirectionActive(xa, ew),
-        yActive = isDirectionActive(ya, ns),
-        cursor = getDragCursor(yActive + xActive, fullLayout.dragmode),
-        dragClass = ns + ew + 'drag';
+        xActive = isDirectionActive(xa, ew);
+        yActive = isDirectionActive(ya, ns);
+        cursor = getDragCursor(yActive + xActive, fullLayout.dragmode);
+        xs = xa0._offset;
+        ys = ya0._offset;
 
-    var dragger3 = plotinfo.draglayer.selectAll('.' + dragClass).data([0]);
+        var links = calcLinks(constraintGroups, xIDs, yIDs);
+        isSubplotConstrained = links.xy;
 
-    dragger3.enter().append('rect')
-        .classed('drag', true)
-        .classed(dragClass, true)
-        .style({fill: 'transparent', 'stroke-width': 0})
-        .attr('data-subplot', plotinfo.id);
+        // finally make the list of axis objects to link
+        xaLinked = [];
+        for(var xLinkID in links.x) { xaLinked.push(getFromId(gd, xLinkID)); }
+        yaLinked = [];
+        for(var yLinkID in links.y) { yaLinked.push(getFromId(gd, yLinkID)); }
+    }
 
-    dragger3.call(Drawing.setRect, x, y, w, h)
-        .call(setCursor, cursor);
+    recomputeAxisLists();
 
-    var dragger = dragger3.node();
+    var dragger = makeDragger(plotinfo, ns + ew + 'drag', cursor, x, y, w, h);
 
     // still need to make the element if the axes are disabled
     // but nuke its events (except for maindrag which needs them for hover)
@@ -102,8 +130,6 @@ module.exports = function dragBox(gd, plotinfo, x, y, w, h, ns, ew) {
         element: dragger,
         gd: gd,
         plotinfo: plotinfo,
-        xaxes: xa,
-        yaxes: ya,
         doubleclick: doubleClick,
         prepFn: function(e, startX, startY) {
             var dragModeNow = gd._fullLayout.dragmode;
@@ -125,14 +151,22 @@ module.exports = function dragBox(gd, plotinfo, x, y, w, h, ns, ew) {
             if(dragModeNow === 'zoom') {
                 dragOptions.moveFn = zoomMove;
                 dragOptions.doneFn = zoomDone;
+
+                // zoomMove takes care of the threshold, but we need to
+                // minimize this so that constrained zoom boxes will flip
+                // orientation at the right place
+                dragOptions.minDrag = 1;
+
                 zoomPrep(e, startX, startY);
             }
             else if(dragModeNow === 'pan') {
                 dragOptions.moveFn = plotDrag;
                 dragOptions.doneFn = dragDone;
-                clearSelect();
+                clearSelect(zoomlayer);
             }
             else if(isSelectOrLasso(dragModeNow)) {
+                dragOptions.xaxes = xa;
+                dragOptions.yaxes = ya;
                 prepSelect(e, startX, startY, dragOptions, dragModeNow);
             }
         }
@@ -140,10 +174,7 @@ module.exports = function dragBox(gd, plotinfo, x, y, w, h, ns, ew) {
 
     dragElement.init(dragOptions);
 
-    var zoomlayer = gd._fullLayout._zoomlayer,
-        xs = plotinfo.xaxis._offset,
-        ys = plotinfo.yaxis._offset,
-        x0,
+    var x0,
         y0,
         box,
         lum,
@@ -153,28 +184,6 @@ module.exports = function dragBox(gd, plotinfo, x, y, w, h, ns, ew) {
         zb,
         corners;
 
-    function recomputeAxisLists() {
-        xa = [plotinfo.xaxis];
-        ya = [plotinfo.yaxis];
-        pw = xa[0]._length;
-        ph = ya[0]._length;
-
-        for(var i = 1; i < subplots.length; i++) {
-            var subplotXa = subplots[i].xaxis,
-                subplotYa = subplots[i].yaxis;
-            if(xa.indexOf(subplotXa) === -1) xa.push(subplotXa);
-            if(ya.indexOf(subplotYa) === -1) ya.push(subplotYa);
-        }
-        allaxes = xa.concat(ya);
-        xActive = isDirectionActive(xa, ew);
-        yActive = isDirectionActive(ya, ns);
-        cursor = getDragCursor(yActive + xActive, fullLayout.dragmode);
-        xs = plotinfo.xaxis._offset;
-        ys = plotinfo.yaxis._offset;
-        dragOptions.xa = xa;
-        dragOptions.ya = ya;
-    }
-
     function zoomPrep(e, startX, startY) {
         var dragBBox = dragger.getBoundingClientRect();
         x0 = startX - dragBBox.left;
@@ -187,34 +196,11 @@ module.exports = function dragBox(gd, plotinfo, x, y, w, h, ns, ew) {
         dimmed = false;
         zoomMode = 'xy';
 
-        zb = zoomlayer.append('path')
-            .attr('class', 'zoombox')
-            .style({
-                'fill': lum > 0.2 ? 'rgba(0,0,0,0)' : 'rgba(255,255,255,0)',
-                'stroke-width': 0
-            })
-            .attr('transform', 'translate(' + xs + ', ' + ys + ')')
-            .attr('d', path0 + 'Z');
-
-        corners = zoomlayer.append('path')
-            .attr('class', 'zoombox-corners')
-            .style({
-                fill: Color.background,
-                stroke: Color.defaultLine,
-                'stroke-width': 1,
-                opacity: 0
-            })
-            .attr('transform', 'translate(' + xs + ', ' + ys + ')')
-            .attr('d', 'M0,0Z');
-
-        clearSelect();
-    }
+        zb = makeZoombox(zoomlayer, lum, xs, ys, path0);
+
+        corners = makeCorners(zoomlayer, xs, ys);
 
-    function clearSelect() {
-        // until we get around to persistent selections, remove the outline
-        // here. The selection itself will be removed when the plot redraws
-        // at the end.
-        zoomlayer.selectAll('.select-outline').remove();
+        clearSelect(zoomlayer);
     }
 
     function zoomMove(dx0, dy0) {
@@ -225,93 +211,67 @@ module.exports = function dragBox(gd, plotinfo, x, y, w, h, ns, ew) {
         var x1 = Math.max(0, Math.min(pw, dx0 + x0)),
             y1 = Math.max(0, Math.min(ph, dy0 + y0)),
             dx = Math.abs(x1 - x0),
-            dy = Math.abs(y1 - y0),
-            clen = Math.floor(Math.min(dy, dx, MINZOOM) / 2);
+            dy = Math.abs(y1 - y0);
 
         box.l = Math.min(x0, x1);
         box.r = Math.max(x0, x1);
         box.t = Math.min(y0, y1);
         box.b = Math.max(y0, y1);
 
+        function noZoom() {
+            zoomMode = '';
+            box.r = box.l;
+            box.t = box.b;
+            corners.attr('d', 'M0,0Z');
+        }
+
+        if(isSubplotConstrained) {
+            if(dx > MINZOOM || dy > MINZOOM) {
+                zoomMode = 'xy';
+                if(dx / pw > dy / ph) {
+                    dy = dx * ph / pw;
+                    if(y0 > y1) box.t = y0 - dy;
+                    else box.b = y0 + dy;
+                }
+                else {
+                    dx = dy * pw / ph;
+                    if(x0 > x1) box.l = x0 - dx;
+                    else box.r = x0 + dx;
+                }
+                corners.attr('d', xyCorners(box));
+            }
+            else {
+                noZoom();
+            }
+        }
         // look for small drags in one direction or the other,
         // and only drag the other axis
-        if(!yActive || dy < Math.min(Math.max(dx * 0.6, MINDRAG), MINZOOM)) {
+        else if(!yActive || dy < Math.min(Math.max(dx * 0.6, MINDRAG), MINZOOM)) {
             if(dx < MINDRAG) {
-                zoomMode = '';
-                box.r = box.l;
-                box.t = box.b;
-                corners.attr('d', 'M0,0Z');
+                noZoom();
             }
             else {
                 box.t = 0;
                 box.b = ph;
                 zoomMode = 'x';
-                corners.attr('d',
-                    'M' + (box.l - 0.5) + ',' + (y0 - MINZOOM - 0.5) +
-                    'h-3v' + (2 * MINZOOM + 1) + 'h3ZM' +
-                    (box.r + 0.5) + ',' + (y0 - MINZOOM - 0.5) +
-                    'h3v' + (2 * MINZOOM + 1) + 'h-3Z');
+                corners.attr('d', xCorners(box, y0));
             }
         }
         else if(!xActive || dx < Math.min(dy * 0.6, MINZOOM)) {
             box.l = 0;
             box.r = pw;
             zoomMode = 'y';
-            corners.attr('d',
-                'M' + (x0 - MINZOOM - 0.5) + ',' + (box.t - 0.5) +
-                'v-3h' + (2 * MINZOOM + 1) + 'v3ZM' +
-                (x0 - MINZOOM - 0.5) + ',' + (box.b + 0.5) +
-                'v3h' + (2 * MINZOOM + 1) + 'v-3Z');
+            corners.attr('d', yCorners(box, x0));
         }
         else {
             zoomMode = 'xy';
-            corners.attr('d',
-                'M' + (box.l - 3.5) + ',' + (box.t - 0.5 + clen) + 'h3v' + (-clen) +
-                        'h' + clen + 'v-3h-' + (clen + 3) + 'ZM' +
-                    (box.r + 3.5) + ',' + (box.t - 0.5 + clen) + 'h-3v' + (-clen) +
-                        'h' + (-clen) + 'v-3h' + (clen + 3) + 'ZM' +
-                    (box.r + 3.5) + ',' + (box.b + 0.5 - clen) + 'h-3v' + clen +
-                        'h' + (-clen) + 'v3h' + (clen + 3) + 'ZM' +
-                    (box.l - 3.5) + ',' + (box.b + 0.5 - clen) + 'h3v' + clen +
-                        'h' + clen + 'v3h-' + (clen + 3) + 'Z');
+            corners.attr('d', xyCorners(box));
         }
         box.w = box.r - box.l;
         box.h = box.b - box.t;
 
-        // Not sure about the addition of window.scrollX/Y...
-        // seems to work but doesn't seem robust.
-        zb.attr('d',
-            path0 + 'M' + (box.l) + ',' + (box.t) + 'v' + (box.h) +
-            'h' + (box.w) + 'v-' + (box.h) + 'h-' + (box.w) + 'Z');
-        if(!dimmed) {
-            zb.transition()
-                .style('fill', lum > 0.2 ? 'rgba(0,0,0,0.4)' :
-                    'rgba(255,255,255,0.3)')
-                .duration(200);
-            corners.transition()
-                .style('opacity', 1)
-                .duration(200);
-            dimmed = true;
-        }
-    }
-
-    function zoomAxRanges(axList, r0Fraction, r1Fraction) {
-        var i,
-            axi,
-            axRangeLinear0,
-            axRangeLinearSpan;
-
-        for(i = 0; i < axList.length; i++) {
-            axi = axList[i];
-            if(axi.fixedrange) continue;
-
-            axRangeLinear0 = axi._rl[0];
-            axRangeLinearSpan = axi._rl[1] - axRangeLinear0;
-            axi.range = [
-                axi.l2r(axRangeLinear0 + axRangeLinearSpan * r0Fraction),
-                axi.l2r(axRangeLinear0 + axRangeLinearSpan * r1Fraction)
-            ];
-        }
+        updateZoombox(zb, corners, box, path0, dimmed, lum);
+        dimmed = true;
     }
 
     function zoomDone(dragged, numClicks) {
@@ -321,8 +281,9 @@ module.exports = function dragBox(gd, plotinfo, x, y, w, h, ns, ew) {
             return removeZoombox(gd);
         }
 
-        if(zoomMode === 'xy' || zoomMode === 'x') zoomAxRanges(xa, box.l / pw, box.r / pw);
-        if(zoomMode === 'xy' || zoomMode === 'y') zoomAxRanges(ya, (ph - box.b) / ph, (ph - box.t) / ph);
+        // TODO: edit linked axes in zoomAxRanges and in dragTail
+        if(zoomMode === 'xy' || zoomMode === 'x') zoomAxRanges(xa, box.l / pw, box.r / pw, xaLinked);
+        if(zoomMode === 'xy' || zoomMode === 'y') zoomAxRanges(ya, (ph - box.b) / ph, (ph - box.t) / ph, yaLinked);
 
         removeZoombox(gd);
         dragTail(zoomMode);
@@ -354,7 +315,7 @@ module.exports = function dragBox(gd, plotinfo, x, y, w, h, ns, ew) {
             else if(ew === 'e') hAlign = 'right';
 
             if(gd._context.showAxisRangeEntryBoxes) {
-                dragger3
+                d3.select(dragger)
                     .call(svgTextUtils.makeEditable, null, {
                         immediate: true,
                         background: fullLayout.paper_bgcolor,
@@ -418,9 +379,7 @@ module.exports = function dragBox(gd, plotinfo, x, y, w, h, ns, ew) {
             gbb = mainplot.draglayer.select('.nsewdrag')
                 .node().getBoundingClientRect(),
             xfrac = (e.clientX - gbb.left) / gbb.width,
-            vbx0 = scrollViewBox[0] + scrollViewBox[2] * xfrac,
             yfrac = (gbb.bottom - e.clientY) / gbb.height,
-            vby0 = scrollViewBox[1] + scrollViewBox[3] * (1 - yfrac),
             i;
 
         function zoomWheelOneAxis(ax, centerFraction, zoom) {
@@ -432,15 +391,23 @@ module.exports = function dragBox(gd, plotinfo, x, y, w, h, ns, ew) {
             ax.range = axRange.map(doZoom);
         }
 
-        if(ew) {
+        if(ew || isSubplotConstrained) {
+            // if we're only zooming this axis because of constraints,
+            // zoom it about the center
+            if(!ew) xfrac = 0.5;
+
             for(i = 0; i < xa.length; i++) zoomWheelOneAxis(xa[i], xfrac, zoom);
+
             scrollViewBox[2] *= zoom;
-            scrollViewBox[0] = vbx0 - scrollViewBox[2] * xfrac;
+            scrollViewBox[0] += scrollViewBox[2] * xfrac * (1 / zoom - 1);
         }
-        if(ns) {
+        if(ns || isSubplotConstrained) {
+            if(!ns) yfrac = 0.5;
+
             for(i = 0; i < ya.length; i++) zoomWheelOneAxis(ya[i], yfrac, zoom);
+
             scrollViewBox[3] *= zoom;
-            scrollViewBox[1] = vby0 - scrollViewBox[3] * (1 - yfrac);
+            scrollViewBox[1] += scrollViewBox[3] * (1 - yfrac) * (1 / zoom - 1);
         }
 
         // viewbox redraw at first
@@ -451,7 +418,12 @@ module.exports = function dragBox(gd, plotinfo, x, y, w, h, ns, ew) {
         // no more scrolling is coming
         redrawTimer = setTimeout(function() {
             scrollViewBox = [0, 0, pw, ph];
-            dragTail();
+
+            var zoomMode;
+            if(isSubplotConstrained) zoomMode = 'xy';
+            else zoomMode = (ew ? 'x' : '') + (ns ? 'y' : '');
+
+            dragTail(zoomMode);
         }, REDRAWDELAY);
 
         return Lib.pauseEvent(e);
@@ -473,18 +445,6 @@ module.exports = function dragBox(gd, plotinfo, x, y, w, h, ns, ew) {
 
         recomputeAxisLists();
 
-        function dragAxList(axList, pix) {
-            for(var i = 0; i < axList.length; i++) {
-                var axi = axList[i];
-                if(!axi.fixedrange) {
-                    axi.range = [
-                        axi.l2r(axi._rl[0] - pix / axi._m),
-                        axi.l2r(axi._rl[1] - pix / axi._m)
-                    ];
-                }
-            }
-        }
-
         if(xActive === 'ew' || yActive === 'ns') {
             if(xActive) dragAxList(xa, dx);
             if(yActive) dragAxList(ya, dy);
@@ -493,16 +453,6 @@ module.exports = function dragBox(gd, plotinfo, x, y, w, h, ns, ew) {
             return;
         }
 
-        // common transform for dragging one end of an axis
-        // d>0 is compressing scale (cursor is over the plot,
-        //  the axis end should move with the cursor)
-        // d<0 is expanding (cursor is off the plot, axis end moves
-        //  nonlinearly so you can expand far)
-        function dZoom(d) {
-            return 1 - ((d >= 0) ? Math.min(d, 0.9) :
-                1 / (1 / Math.max(d, -0.3) + 3.222));
-        }
-
         // dz: set a new value for one end (0 or 1) of an axis array axArray,
         // and return a pixel shift for that end for the viewbox
         // based on pixel drag distance d
@@ -528,6 +478,15 @@ module.exports = function dragBox(gd, plotinfo, x, y, w, h, ns, ew) {
                 (movedAx._rl[end] - movedAx._rl[otherEnd]);
         }
 
+        if(isSubplotConstrained && xActive && yActive) {
+            // dragging a corner of a constrained subplot:
+            // respect the fixed corner, but harmonize dx and dy
+            var dxySign = ((xActive === 'w') === (yActive === 'n')) ? 1 : -1;
+            var dxyFraction = (dx / pw + dxySign * dy / ph) / 2;
+            dx = dxyFraction * pw;
+            dy = dxySign * dxyFraction * ph;
+        }
+
         if(xActive === 'w') dx = dz(xa, 0, dx);
         else if(xActive === 'e') dx = dz(xa, 1, -dx);
         else if(!xActive) dx = 0;
@@ -536,12 +495,32 @@ module.exports = function dragBox(gd, plotinfo, x, y, w, h, ns, ew) {
         else if(yActive === 's') dy = dz(ya, 0, -dy);
         else if(!yActive) dy = 0;
 
-        updateSubplots([
-            (xActive === 'w') ? dx : 0,
-            (yActive === 'n') ? dy : 0,
-            pw - dx,
-            ph - dy
-        ]);
+        var x0 = (xActive === 'w') ? dx : 0;
+        var y0 = (yActive === 'n') ? dy : 0;
+
+        if(isSubplotConstrained) {
+            var i;
+            if(!xActive && yActive.length === 1) {
+                // dragging one end of the y axis of a constrained subplot
+                // scale the other axis the same about its middle
+                for(i = 0; i < xa.length; i++) {
+                    xa[i].range = xa[i]._r.slice();
+                    scaleZoom(xa[i], 1 - dy / ph);
+                }
+                dx = dy * pw / ph;
+                x0 = dx / 2;
+            }
+            if(!yActive && xActive.length === 1) {
+                for(i = 0; i < ya.length; i++) {
+                    ya[i].range = ya[i]._r.slice();
+                    scaleZoom(ya[i], 1 - dx / pw);
+                }
+                dy = dx * ph / pw;
+                y0 = dy / 2;
+            }
+        }
+
+        updateSubplots([x0, y0, pw - dx, ph - dy]);
         ticksAndAnnotations(yActive, xActive);
     }
 
@@ -555,20 +534,28 @@ module.exports = function dragBox(gd, plotinfo, x, y, w, h, ns, ew) {
             }
         }
 
-        if(ew) pushActiveAxIds(xa);
-        if(ns) pushActiveAxIds(ya);
+        if(ew || isSubplotConstrained) {
+            pushActiveAxIds(xa);
+            pushActiveAxIds(xaLinked);
+        }
+        if(ns || isSubplotConstrained) {
+            pushActiveAxIds(ya);
+            pushActiveAxIds(yaLinked);
+        }
 
         for(i = 0; i < activeAxIds.length; i++) {
-            Axes.doTicks(gd, activeAxIds[i], true);
+            doTicks(gd, activeAxIds[i], true);
         }
 
-        function redrawObjs(objArray, method) {
+        function redrawObjs(objArray, method, shortCircuit) {
             for(i = 0; i < objArray.length; i++) {
                 var obji = objArray[i];
 
                 if((ew && activeAxIds.indexOf(obji.xref) !== -1) ||
                     (ns && activeAxIds.indexOf(obji.yref) !== -1)) {
                     method(gd, i);
+                    // once is enough for images (which doesn't use the `i` arg anyway)
+                    if(shortCircuit) return;
                 }
             }
         }
@@ -578,7 +565,7 @@ module.exports = function dragBox(gd, plotinfo, x, y, w, h, ns, ew) {
 
         redrawObjs(fullLayout.annotations || [], Registry.getComponentMethod('annotations', 'drawOne'));
         redrawObjs(fullLayout.shapes || [], Registry.getComponentMethod('shapes', 'drawOne'));
-        redrawObjs(fullLayout.images || [], Registry.getComponentMethod('images', 'draw'));
+        redrawObjs(fullLayout.images || [], Registry.getComponentMethod('images', 'draw'), true);
     }
 
     function doubleClick() {
@@ -590,39 +577,56 @@ module.exports = function dragBox(gd, plotinfo, x, y, w, h, ns, ew) {
 
         var ax, i, rangeInitial;
 
-        if(doubleClickConfig === 'autosize') {
+        // For reset+autosize mode:
+        // If *any* of the main axes is not at its initial range
+        // (or autoranged, if we have no initial range, to match the logic in
+        // doubleClickConfig === 'reset' below), we reset.
+        // If they are *all* at their initial ranges, then we autosize.
+        if(doubleClickConfig === 'reset+autosize') {
+
+            doubleClickConfig = 'autosize';
+
             for(i = 0; i < axList.length; i++) {
                 ax = axList[i];
-                if(!ax.fixedrange) attrs[ax._name + '.autorange'] = true;
+                if((ax._rangeInitial && (
+                        ax.range[0] !== ax._rangeInitial[0] ||
+                        ax.range[1] !== ax._rangeInitial[1]
+                    )) ||
+                    (!ax._rangeInitial && !ax.autorange)
+                ) {
+                    doubleClickConfig = 'reset';
+                    break;
+                }
             }
         }
-        else if(doubleClickConfig === 'reset') {
+
+        if(doubleClickConfig === 'autosize') {
+            // don't set the linked axes here, so relayout marks them as shrinkable
+            // and we autosize just to the requested axis/axes
             for(i = 0; i < axList.length; i++) {
                 ax = axList[i];
-
-                if(!ax._rangeInitial) {
-                    attrs[ax._name + '.autorange'] = true;
-                }
-                else {
-                    rangeInitial = ax._rangeInitial.slice();
-                    attrs[ax._name + '.range[0]'] = rangeInitial[0];
-                    attrs[ax._name + '.range[1]'] = rangeInitial[1];
-                }
+                if(!ax.fixedrange) attrs[ax._name + '.autorange'] = true;
             }
         }
-        else if(doubleClickConfig === 'reset+autosize') {
+        else if(doubleClickConfig === 'reset') {
+            // when we're resetting, reset all linked axes too, so we get back
+            // to the fully-auto-with-constraints situation
+            if(xActive || isSubplotConstrained) axList = axList.concat(xaLinked);
+            if(yActive && !isSubplotConstrained) axList = axList.concat(yaLinked);
+
+            if(isSubplotConstrained) {
+                if(!xActive) axList = axList.concat(xa);
+                else if(!yActive) axList = axList.concat(ya);
+            }
+
             for(i = 0; i < axList.length; i++) {
                 ax = axList[i];
 
-                if(ax.fixedrange) continue;
-                if(ax._rangeInitial === undefined ||
-                    ax.range[0] === ax._rangeInitial[0] &&
-                    ax.range[1] === ax._rangeInitial[1]
-                ) {
+                if(!ax._rangeInitial) {
                     attrs[ax._name + '.autorange'] = true;
                 }
                 else {
-                    rangeInitial = ax._rangeInitial.slice();
+                    rangeInitial = ax._rangeInitial;
                     attrs[ax._name + '.range[0]'] = rangeInitial[0];
                     attrs[ax._name + '.range[1]'] = rangeInitial[1];
                 }
@@ -635,18 +639,22 @@ module.exports = function dragBox(gd, plotinfo, x, y, w, h, ns, ew) {
 
     // dragTail - finish a drag event with a redraw
     function dragTail(zoommode) {
+        if(zoommode === undefined) zoommode = (ew ? 'x' : '') + (ns ? 'y' : '');
+
         var attrs = {};
         // revert to the previous axis settings, then apply the new ones
         // through relayout - this lets relayout manage undo/redo
-        for(var i = 0; i < allaxes.length; i++) {
-            var axi = allaxes[i];
-            if(zoommode && zoommode.indexOf(axi._id.charAt(0)) === -1) {
-                continue;
-            }
+        var axesToModify;
+        if(zoommode === 'xy') axesToModify = xa.concat(ya);
+        else if(zoommode === 'x') axesToModify = xa;
+        else if(zoommode === 'y') axesToModify = ya;
+
+        for(var i = 0; i < axesToModify.length; i++) {
+            var axi = axesToModify[i];
             if(axi._r[0] !== axi.range[0]) attrs[axi._name + '.range[0]'] = axi.range[0];
             if(axi._r[1] !== axi.range[1]) attrs[axi._name + '.range[1]'] = axi.range[1];
 
-            axi.range = axi._r.slice();
+            axi.range = axi._input.range = axi._r.slice();
         }
 
         updateSubplots([0, 0, pw, ph]);
@@ -657,71 +665,116 @@ module.exports = function dragBox(gd, plotinfo, x, y, w, h, ns, ew) {
     // affected by this drag, and update them. look for all plots
     // sharing an affected axis (including the one being dragged)
     function updateSubplots(viewBox) {
-        var j;
-        var plotinfos = fullLayout._plots,
-            subplots = Object.keys(plotinfos);
+        var plotinfos = fullLayout._plots;
+        var subplots = Object.keys(plotinfos);
+        var xScaleFactor = viewBox[2] / xa[0]._length;
+        var yScaleFactor = viewBox[3] / ya[0]._length;
+        var editX = ew || isSubplotConstrained;
+        var editY = ns || isSubplotConstrained;
+
+        var i, xScaleFactor2, yScaleFactor2, clipDx, clipDy;
+
+        // Find the appropriate scaling for this axis, if it's linked to the
+        // dragged axes by constraints. 0 is special, it means this axis shouldn't
+        // ever be scaled (will be converted to 1 if the other axis is scaled)
+        function getLinkedScaleFactor(ax) {
+            if(ax.fixedrange) return 0;
+
+            if(editX && xaLinked.indexOf(ax) !== -1) {
+                return xScaleFactor;
+            }
+            if(editY && (isSubplotConstrained ? xaLinked : yaLinked).indexOf(ax) !== -1) {
+                return yScaleFactor;
+            }
+            return 0;
+        }
+
+        function scaleAndGetShift(ax, scaleFactor) {
+            if(scaleFactor) {
+                ax.range = ax._r.slice();
+                scaleZoom(ax, scaleFactor);
+                return ax._length * (1 - scaleFactor) / 2;
+            }
+            return 0;
+        }
 
-        for(var i = 0; i < subplots.length; i++) {
+        for(i = 0; i < subplots.length; i++) {
 
             var subplot = plotinfos[subplots[i]],
                 xa2 = subplot.xaxis,
                 ya2 = subplot.yaxis,
-                editX = ew && !xa2.fixedrange,
-                editY = ns && !ya2.fixedrange;
-
-            if(editX) {
-                var isInX = false;
-                for(j = 0; j < xa.length; j++) {
-                    if(xa[j]._id === xa2._id) {
-                        isInX = true;
-                        break;
-                    }
-                }
-                editX = editX && isInX;
-            }
+                editX2 = editX && !xa2.fixedrange && (xa.indexOf(xa2) !== -1),
+                editY2 = editY && !ya2.fixedrange && (ya.indexOf(ya2) !== -1);
 
-            if(editY) {
-                var isInY = false;
-                for(j = 0; j < ya.length; j++) {
-                    if(ya[j]._id === ya2._id) {
-                        isInY = true;
-                        break;
-                    }
-                }
-                editY = editY && isInY;
+            if(editX2) {
+                xScaleFactor2 = xScaleFactor;
+                clipDx = viewBox[0];
+            }
+            else {
+                xScaleFactor2 = getLinkedScaleFactor(xa2);
+                clipDx = scaleAndGetShift(xa2, xScaleFactor2);
             }
 
-            var xScaleFactor = editX ? xa2._length / viewBox[2] : 1,
-                yScaleFactor = editY ? ya2._length / viewBox[3] : 1;
+            if(editY2) {
+                yScaleFactor2 = yScaleFactor;
+                clipDy = viewBox[1];
+            }
+            else {
+                yScaleFactor2 = getLinkedScaleFactor(ya2);
+                clipDy = scaleAndGetShift(ya2, yScaleFactor2);
+            }
 
-            var clipDx = editX ? viewBox[0] : 0,
-                clipDy = editY ? viewBox[1] : 0;
+            // don't scale at all if neither axis is scalable here
+            if(!xScaleFactor2 && !yScaleFactor2) continue;
 
-            var fracDx = editX ? (viewBox[0] / viewBox[2] * xa2._length) : 0,
-                fracDy = editY ? (viewBox[1] / viewBox[3] * ya2._length) : 0;
+            // but if only one is, reset the other axis scaling
+            if(!xScaleFactor2) xScaleFactor2 = 1;
+            if(!yScaleFactor2) yScaleFactor2 = 1;
 
-            var plotDx = xa2._offset - fracDx,
-                plotDy = ya2._offset - fracDy;
+            var plotDx = xa2._offset - clipDx / xScaleFactor2,
+                plotDy = ya2._offset - clipDy / yScaleFactor2;
 
             fullLayout._defs.selectAll('#' + subplot.clipId)
                 .call(Drawing.setTranslate, clipDx, clipDy)
-                .call(Drawing.setScale, 1 / xScaleFactor, 1 / yScaleFactor);
+                .call(Drawing.setScale, xScaleFactor2, yScaleFactor2);
 
             subplot.plot
                 .call(Drawing.setTranslate, plotDx, plotDy)
-                .call(Drawing.setScale, xScaleFactor, yScaleFactor)
+                .call(Drawing.setScale, 1 / xScaleFactor2, 1 / yScaleFactor2)
 
                 // This is specifically directed at scatter traces, applying an inverse
                 // scale to individual points to counteract the scale of the trace
                 // as a whole:
                 .select('.scatterlayer').selectAll('.points').selectAll('.point')
-                    .call(Drawing.setPointGroupScale, 1 / xScaleFactor, 1 / yScaleFactor);
+                    .call(Drawing.setPointGroupScale, xScaleFactor2, yScaleFactor2);
         }
     }
 
     return dragger;
 };
 
+function makeDragger(plotinfo, dragClass, cursor, x, y, w, h) {
+    var dragger3 = plotinfo.draglayer.selectAll('.' + dragClass).data([0]);
+
+    dragger3.enter().append('rect')
+        .classed('drag', true)
+        .classed(dragClass, true)
+        .style({fill: 'transparent', 'stroke-width': 0})
+        .attr('data-subplot', plotinfo.id);
+
+    dragger3.call(Drawing.setRect, x, y, w, h)
+        .call(setCursor, cursor);
+
+    return dragger3.node();
+}
+
+function isDirectionActive(axList, activeVal) {
+    for(var i = 0; i < axList.length; i++) {
+        if(!axList[i].fixedrange) return activeVal;
+    }
+    return '';
+}
+
 function getEndText(ax, end) {
     var initialVal = ax.range[end],
         diff = Math.abs(initialVal - ax.range[1 - end]),
@@ -743,6 +796,54 @@ function getEndText(ax, end) {
     }
 }
 
+function zoomAxRanges(axList, r0Fraction, r1Fraction, linkedAxes) {
+    var i,
+        axi,
+        axRangeLinear0,
+        axRangeLinearSpan;
+
+    for(i = 0; i < axList.length; i++) {
+        axi = axList[i];
+        if(axi.fixedrange) continue;
+
+        axRangeLinear0 = axi._rl[0];
+        axRangeLinearSpan = axi._rl[1] - axRangeLinear0;
+        axi.range = [
+            axi.l2r(axRangeLinear0 + axRangeLinearSpan * r0Fraction),
+            axi.l2r(axRangeLinear0 + axRangeLinearSpan * r1Fraction)
+        ];
+    }
+
+    // zoom linked axes about their centers
+    if(linkedAxes && linkedAxes.length) {
+        var linkedR0Fraction = (r0Fraction + (1 - r1Fraction)) / 2;
+
+        zoomAxRanges(linkedAxes, linkedR0Fraction, 1 - linkedR0Fraction);
+    }
+}
+
+function dragAxList(axList, pix) {
+    for(var i = 0; i < axList.length; i++) {
+        var axi = axList[i];
+        if(!axi.fixedrange) {
+            axi.range = [
+                axi.l2r(axi._rl[0] - pix / axi._m),
+                axi.l2r(axi._rl[1] - pix / axi._m)
+            ];
+        }
+    }
+}
+
+// common transform for dragging one end of an axis
+// d>0 is compressing scale (cursor is over the plot,
+//  the axis end should move with the cursor)
+// d<0 is expanding (cursor is off the plot, axis end moves
+//  nonlinearly so you can expand far)
+function dZoom(d) {
+    return 1 - ((d >= 0) ? Math.min(d, 0.9) :
+        1 / (1 / Math.max(d, -0.3) + 3.222));
+}
+
 function getDragCursor(nsew, dragmode) {
     if(!nsew) return 'pointer';
     if(nsew === 'nsew') {
@@ -752,6 +853,52 @@ function getDragCursor(nsew, dragmode) {
     return nsew.toLowerCase() + '-resize';
 }
 
+function makeZoombox(zoomlayer, lum, xs, ys, path0) {
+    return zoomlayer.append('path')
+        .attr('class', 'zoombox')
+        .style({
+            'fill': lum > 0.2 ? 'rgba(0,0,0,0)' : 'rgba(255,255,255,0)',
+            'stroke-width': 0
+        })
+        .attr('transform', 'translate(' + xs + ', ' + ys + ')')
+        .attr('d', path0 + 'Z');
+}
+
+function makeCorners(zoomlayer, xs, ys) {
+    return zoomlayer.append('path')
+        .attr('class', 'zoombox-corners')
+        .style({
+            fill: Color.background,
+            stroke: Color.defaultLine,
+            'stroke-width': 1,
+            opacity: 0
+        })
+        .attr('transform', 'translate(' + xs + ', ' + ys + ')')
+        .attr('d', 'M0,0Z');
+}
+
+function clearSelect(zoomlayer) {
+    // until we get around to persistent selections, remove the outline
+    // here. The selection itself will be removed when the plot redraws
+    // at the end.
+    zoomlayer.selectAll('.select-outline').remove();
+}
+
+function updateZoombox(zb, corners, box, path0, dimmed, lum) {
+    zb.attr('d',
+        path0 + 'M' + (box.l) + ',' + (box.t) + 'v' + (box.h) +
+        'h' + (box.w) + 'v-' + (box.h) + 'h-' + (box.w) + 'Z');
+    if(!dimmed) {
+        zb.transition()
+            .style('fill', lum > 0.2 ? 'rgba(0,0,0,0.4)' :
+                'rgba(255,255,255,0.3)')
+            .duration(200);
+        corners.transition()
+            .style('opacity', 1)
+            .duration(200);
+    }
+}
+
 function removeZoombox(gd) {
     d3.select(gd)
         .selectAll('.zoombox,.js-zoombox-backdrop,.js-zoombox-menu,.zoombox-corners')
@@ -763,3 +910,87 @@ function isSelectOrLasso(dragmode) {
 
     return modes.indexOf(dragmode) !== -1;
 }
+
+function xCorners(box, y0) {
+    return 'M' +
+        (box.l - 0.5) + ',' + (y0 - MINZOOM - 0.5) +
+        'h-3v' + (2 * MINZOOM + 1) + 'h3ZM' +
+        (box.r + 0.5) + ',' + (y0 - MINZOOM - 0.5) +
+        'h3v' + (2 * MINZOOM + 1) + 'h-3Z';
+}
+
+function yCorners(box, x0) {
+    return 'M' +
+        (x0 - MINZOOM - 0.5) + ',' + (box.t - 0.5) +
+        'v-3h' + (2 * MINZOOM + 1) + 'v3ZM' +
+        (x0 - MINZOOM - 0.5) + ',' + (box.b + 0.5) +
+        'v3h' + (2 * MINZOOM + 1) + 'v-3Z';
+}
+
+function xyCorners(box) {
+    var clen = Math.floor(Math.min(box.b - box.t, box.r - box.l, MINZOOM) / 2);
+    return 'M' +
+        (box.l - 3.5) + ',' + (box.t - 0.5 + clen) + 'h3v' + (-clen) +
+            'h' + clen + 'v-3h-' + (clen + 3) + 'ZM' +
+        (box.r + 3.5) + ',' + (box.t - 0.5 + clen) + 'h-3v' + (-clen) +
+            'h' + (-clen) + 'v-3h' + (clen + 3) + 'ZM' +
+        (box.r + 3.5) + ',' + (box.b + 0.5 - clen) + 'h-3v' + clen +
+            'h' + (-clen) + 'v3h' + (clen + 3) + 'ZM' +
+        (box.l - 3.5) + ',' + (box.b + 0.5 - clen) + 'h3v' + clen +
+            'h' + clen + 'v3h-' + (clen + 3) + 'Z';
+}
+
+function calcLinks(constraintGroups, xIDs, yIDs) {
+    var isSubplotConstrained = false;
+    var xLinks = {};
+    var yLinks = {};
+    var i, j, k;
+
+    var group, xLinkID, yLinkID;
+    for(i = 0; i < constraintGroups.length; i++) {
+        group = constraintGroups[i];
+        // check if any of the x axes we're dragging is in this constraint group
+        for(j = 0; j < xIDs.length; j++) {
+            if(group[xIDs[j]]) {
+                // put the rest of these axes into xLinks, if we're not already
+                // dragging them, so we know to scale these axes automatically too
+                // to match the changes in the dragged x axes
+                for(xLinkID in group) {
+                    if((xLinkID.charAt(0) === 'x' ? xIDs : yIDs).indexOf(xLinkID) === -1) {
+                        xLinks[xLinkID] = 1;
+                    }
+                }
+
+                // check if the x and y axes of THIS drag are linked
+                for(k = 0; k < yIDs.length; k++) {
+                    if(group[yIDs[k]]) isSubplotConstrained = true;
+                }
+            }
+        }
+
+        // now check if any of the y axes we're dragging is in this constraint group
+        // only look for outside links, as we've already checked for links within the dragger
+        for(j = 0; j < yIDs.length; j++) {
+            if(group[yIDs[j]]) {
+                for(yLinkID in group) {
+                    if((yLinkID.charAt(0) === 'x' ? xIDs : yIDs).indexOf(yLinkID) === -1) {
+                        yLinks[yLinkID] = 1;
+                    }
+                }
+            }
+        }
+    }
+
+    if(isSubplotConstrained) {
+        // merge xLinks and yLinks if the subplot is constrained,
+        // since we'll always apply both anyway and the two will contain
+        // duplicates
+        Lib.extendFlat(xLinks, yLinks);
+        yLinks = {};
+    }
+    return {
+        x: xLinks,
+        y: yLinks,
+        xy: isSubplotConstrained
+    };
+}
diff --git a/src/plots/cartesian/layout_attributes.js b/src/plots/cartesian/layout_attributes.js
index b917c4cf8e1..f826ca522ba 100644
--- a/src/plots/cartesian/layout_attributes.js
+++ b/src/plots/cartesian/layout_attributes.js
@@ -98,7 +98,6 @@ module.exports = {
             'number from zero in the order it appears.'
         ].join(' ')
     },
-
     fixedrange: {
         valType: 'boolean',
         dflt: false,
@@ -108,6 +107,42 @@ module.exports = {
             'If true, then zoom is disabled.'
         ].join(' ')
     },
+    // scaleanchor: not used directly, just put here for reference
+    // values are any opposite-letter axis id
+    scaleanchor: {
+        valType: 'enumerated',
+        values: [
+            constants.idRegex.x.toString(),
+            constants.idRegex.y.toString()
+        ],
+        role: 'info',
+        description: [
+            'If set to an opposite-letter axis id (e.g. `x2`, `y`), the range of this axis',
+            'changes together with the range of the corresponding opposite-letter axis.',
+            'such that the scale of pixels per unit is in a constant ratio.',
+            'Both axes are still zoomable, but when you zoom one, the other will',
+            'zoom the same amount, keeping a fixed midpoint.',
+            'Autorange will also expand about the midpoints to satisfy the constraint.',
+            'You can chain these, ie `yaxis: {scaleanchor: *x*}, xaxis2: {scaleanchor: *y*}`',
+            'but you can only link axes of the same `type`.',
+            'Loops (`yaxis: {scaleanchor: *x*}, xaxis: {scaleanchor: *y*}` or longer) are redundant',
+            'and the last constraint encountered will be ignored to avoid possible',
+            'inconsistent constraints via `scaleratio`.'
+        ].join(' ')
+    },
+    scaleratio: {
+        valType: 'number',
+        min: 0,
+        dflt: 1,
+        role: 'info',
+        description: [
+            'If this axis is linked to another by `scaleanchor`, this determines the pixel',
+            'to unit scale ratio. For example, if this value is 10, then every unit on',
+            'this axis spans 10 times the number of pixels as a unit on the linked axis.',
+            'Use this for example to create an elevation profile where the vertical scale',
+            'is exaggerated a fixed amount with respect to the horizontal.'
+        ].join(' ')
+    },
     // ticks
     tickmode: {
         valType: 'enumerated',
@@ -430,7 +465,7 @@ module.exports = {
         ],
         role: 'info',
         description: [
-            'If set to an opposite-letter axis id (e.g. `xaxis2`, `yaxis`), this axis is bound to',
+            'If set to an opposite-letter axis id (e.g. `x2`, `y`), this axis is bound to',
             'the corresponding opposite-letter axis.',
             'If set to *free*, this axis\' position is determined by `position`.'
         ].join(' ')
diff --git a/src/plots/cartesian/layout_defaults.js b/src/plots/cartesian/layout_defaults.js
index 6b6cb4ee3d0..468e685234b 100644
--- a/src/plots/cartesian/layout_defaults.js
+++ b/src/plots/cartesian/layout_defaults.js
@@ -16,7 +16,9 @@ var basePlotLayoutAttributes = require('../layout_attributes');
 
 var constants = require('./constants');
 var layoutAttributes = require('./layout_attributes');
+var handleTypeDefaults = require('./type_defaults');
 var handleAxisDefaults = require('./axis_defaults');
+var handleConstraintDefaults = require('./constraint_defaults');
 var handlePositionDefaults = require('./position_defaults');
 var axisIds = require('./axis_ids');
 
@@ -115,7 +117,7 @@ module.exports = function supplyLayoutDefaults(layoutIn, layoutOut, fullData) {
 
     var bgColor = Color.combine(plot_bgcolor, layoutOut.paper_bgcolor);
 
-    var axName, axLayoutIn, axLayoutOut;
+    var axName, axLetter, axLayoutIn, axLayoutOut;
 
     function coerce(attr, dflt) {
         return Lib.coerce(axLayoutIn, axLayoutOut, layoutAttributes, attr, dflt);
@@ -126,6 +128,8 @@ module.exports = function supplyLayoutDefaults(layoutIn, layoutOut, fullData) {
         return Lib.simpleMap(list, axisIds.name2id);
     }
 
+    var counterAxes = {x: getCounterAxes('x'), y: getCounterAxes('y')};
+
     function getOverlayableAxes(axLetter, axName) {
         var list = {x: xaList, y: yaList}[axLetter];
         var out = [];
@@ -141,6 +145,7 @@ module.exports = function supplyLayoutDefaults(layoutIn, layoutOut, fullData) {
         return out;
     }
 
+    // first pass creates the containers, determines types, and handles most of the settings
     for(i = 0; i < axesList.length; i++) {
         axName = axesList[i];
 
@@ -151,14 +156,16 @@ module.exports = function supplyLayoutDefaults(layoutIn, layoutOut, fullData) {
         axLayoutIn = layoutIn[axName];
         axLayoutOut = layoutOut[axName] = {};
 
-        var axLetter = axName.charAt(0);
+        handleTypeDefaults(axLayoutIn, axLayoutOut, coerce, fullData, axName);
+
+        axLetter = axName.charAt(0);
+        var overlayableAxes = getOverlayableAxes(axLetter, axName);
 
         var defaultOptions = {
             letter: axLetter,
             font: layoutOut.font,
             outerTicks: outerTicks[axName],
             showGrid: !noGrids[axName],
-            name: axName,
             data: fullData,
             bgColor: bgColor,
             calendar: layoutOut.calendar
@@ -168,8 +175,8 @@ module.exports = function supplyLayoutDefaults(layoutIn, layoutOut, fullData) {
 
         var positioningOptions = {
             letter: axLetter,
-            counterAxes: getCounterAxes(axLetter),
-            overlayableAxes: getOverlayableAxes(axLetter, axName)
+            counterAxes: counterAxes[axLetter],
+            overlayableAxes: overlayableAxes
         };
 
         handlePositionDefaults(axLayoutIn, axLayoutOut, coerce, positioningOptions);
@@ -216,4 +223,23 @@ module.exports = function supplyLayoutDefaults(layoutIn, layoutOut, fullData) {
 
         coerce('fixedrange', fixedRangeDflt);
     }
+
+    // Finally, handle scale constraints. We need to do this after all axes have
+    // coerced both `type` (so we link only axes of the same type) and
+    // `fixedrange` (so we can avoid linking from OR TO a fixed axis).
+
+    // sets of axes linked by `scaleanchor` along with the scaleratios compounded
+    // together, populated in handleConstraintDefaults
+    layoutOut._axisConstraintGroups = [];
+    var allAxisIds = counterAxes.x.concat(counterAxes.y);
+
+    for(i = 0; i < axesList.length; i++) {
+        axName = axesList[i];
+        axLetter = axName.charAt(0);
+
+        axLayoutIn = layoutIn[axName];
+        axLayoutOut = layoutOut[axName];
+
+        handleConstraintDefaults(axLayoutIn, axLayoutOut, coerce, allAxisIds, layoutOut);
+    }
 };
diff --git a/src/plots/cartesian/scale_zoom.js b/src/plots/cartesian/scale_zoom.js
new file mode 100644
index 00000000000..7669f742301
--- /dev/null
+++ b/src/plots/cartesian/scale_zoom.js
@@ -0,0 +1,23 @@
+/**
+* Copyright 2012-2017, Plotly, Inc.
+* All rights reserved.
+*
+* This source code is licensed under the MIT license found in the
+* LICENSE file in the root directory of this source tree.
+*/
+
+
+'use strict';
+
+module.exports = function scaleZoom(ax, factor, centerFraction) {
+    if(centerFraction === undefined) centerFraction = 0.5;
+
+    var rangeLinear = [ax.r2l(ax.range[0]), ax.r2l(ax.range[1])];
+    var center = rangeLinear[0] + (rangeLinear[1] - rangeLinear[0]) * centerFraction;
+    var newHalfSpan = (center - rangeLinear[0]) * factor;
+
+    ax.range = ax._input.range = [
+        ax.l2r(center - newHalfSpan),
+        ax.l2r(center + newHalfSpan)
+    ];
+};
diff --git a/src/plots/cartesian/type_defaults.js b/src/plots/cartesian/type_defaults.js
new file mode 100644
index 00000000000..a82712763dd
--- /dev/null
+++ b/src/plots/cartesian/type_defaults.js
@@ -0,0 +1,126 @@
+/**
+* Copyright 2012-2017, Plotly, Inc.
+* All rights reserved.
+*
+* This source code is licensed under the MIT license found in the
+* LICENSE file in the root directory of this source tree.
+*/
+
+
+'use strict';
+
+var Registry = require('../../registry');
+var autoType = require('./axis_autotype');
+var name2id = require('./axis_ids').name2id;
+
+/*
+ *  data: the plot data to use in choosing auto type
+ *  name: axis object name (ie 'xaxis') if one should be stored
+ */
+module.exports = function handleTypeDefaults(containerIn, containerOut, coerce, data, name) {
+    // set up some private properties
+    if(name) {
+        containerOut._name = name;
+        containerOut._id = name2id(name);
+    }
+
+    var axType = coerce('type');
+    if(axType === '-') {
+        setAutoType(containerOut, data);
+
+        if(containerOut.type === '-') {
+            containerOut.type = 'linear';
+        }
+        else {
+            // copy autoType back to input axis
+            // note that if this object didn't exist
+            // in the input layout, we have to put it in
+            // this happens in the main supplyDefaults function
+            containerIn.type = containerOut.type;
+        }
+    }
+};
+
+function setAutoType(ax, data) {
+    // new logic: let people specify any type they want,
+    // only autotype if type is '-'
+    if(ax.type !== '-') return;
+
+    var id = ax._id,
+        axLetter = id.charAt(0);
+
+    // support 3d
+    if(id.indexOf('scene') !== -1) id = axLetter;
+
+    var d0 = getFirstNonEmptyTrace(data, id, axLetter);
+    if(!d0) return;
+
+    // first check for histograms, as the count direction
+    // should always default to a linear axis
+    if(d0.type === 'histogram' &&
+            axLetter === {v: 'y', h: 'x'}[d0.orientation || 'v']) {
+        ax.type = 'linear';
+        return;
+    }
+
+    var calAttr = axLetter + 'calendar',
+        calendar = d0[calAttr];
+
+    // check all boxes on this x axis to see
+    // if they're dates, numbers, or categories
+    if(isBoxWithoutPositionCoords(d0, axLetter)) {
+        var posLetter = getBoxPosLetter(d0),
+            boxPositions = [],
+            trace;
+
+        for(var i = 0; i < data.length; i++) {
+            trace = data[i];
+            if(!Registry.traceIs(trace, 'box') ||
+               (trace[axLetter + 'axis'] || axLetter) !== id) continue;
+
+            if(trace[posLetter] !== undefined) boxPositions.push(trace[posLetter][0]);
+            else if(trace.name !== undefined) boxPositions.push(trace.name);
+            else boxPositions.push('text');
+
+            if(trace[calAttr] !== calendar) calendar = undefined;
+        }
+
+        ax.type = autoType(boxPositions, calendar);
+    }
+    else {
+        ax.type = autoType(d0[axLetter] || [d0[axLetter + '0']], calendar);
+    }
+}
+
+function getFirstNonEmptyTrace(data, id, axLetter) {
+    for(var i = 0; i < data.length; i++) {
+        var trace = data[i];
+
+        if((trace[axLetter + 'axis'] || axLetter) === id) {
+            if(isBoxWithoutPositionCoords(trace, axLetter)) {
+                return trace;
+            }
+            else if((trace[axLetter] || []).length || trace[axLetter + '0']) {
+                return trace;
+            }
+        }
+    }
+}
+
+function getBoxPosLetter(trace) {
+    return {v: 'x', h: 'y'}[trace.orientation || 'v'];
+}
+
+function isBoxWithoutPositionCoords(trace, axLetter) {
+    var posLetter = getBoxPosLetter(trace),
+        isBox = Registry.traceIs(trace, 'box'),
+        isCandlestick = Registry.traceIs(trace._fullInput || {}, 'candlestick');
+
+    return (
+        isBox &&
+        !isCandlestick &&
+        axLetter === posLetter &&
+        trace[posLetter] === undefined &&
+        trace[posLetter + '0'] === undefined
+    );
+}
diff --git a/src/plots/gl2d/camera.js b/src/plots/gl2d/camera.js
index 405795b6b57..6913bd77d86 100644
--- a/src/plots/gl2d/camera.js
+++ b/src/plots/gl2d/camera.js
@@ -11,6 +11,7 @@
 
 var mouseChange = require('mouse-change');
 var mouseWheel = require('mouse-wheel');
+var cartesianConstants = require('../cartesian/constants');
 
 module.exports = createCamera;
 
@@ -22,8 +23,10 @@ function Camera2D(element, plot) {
     this.lastInputTime = Date.now();
     this.lastPos = [0, 0];
     this.boxEnabled = false;
+    this.boxInited = false;
     this.boxStart = [0, 0];
     this.boxEnd = [0, 0];
+    this.dragStart = [0, 0];
 }
 
 
@@ -37,6 +40,21 @@ function createCamera(scene) {
         scene.yaxis.autorange = false;
     }
 
+    function getSubplotConstraint() {
+        // note: this assumes we only have one x and one y axis on this subplot
+        // when this constraint is lifted this block won't make sense
+        var constraints = scene.graphDiv._fullLayout._axisConstraintGroups;
+        var xaId = scene.xaxis._id;
+        var yaId = scene.yaxis._id;
+        for(var i = 0; i < constraints.length; i++) {
+            if(constraints[i][xaId] !== -1) {
+                if(constraints[i][yaId] !== -1) return true;
+                break;
+            }
+        }
+        return false;
+    }
+
     result.mouseListener = mouseChange(element, function(buttons, x, y) {
         var dataBox = scene.calcDataBox(),
             viewBox = plot.viewBox;
@@ -44,6 +62,11 @@ function createCamera(scene) {
         var lastX = result.lastPos[0],
             lastY = result.lastPos[1];
 
+        var MINDRAG = cartesianConstants.MINDRAG * plot.pixelRatio;
+        var MINZOOM = cartesianConstants.MINZOOM * plot.pixelRatio;
+
+        var dx, dy;
+
         x *= plot.pixelRatio;
         y *= plot.pixelRatio;
 
@@ -76,32 +99,114 @@ function createCamera(scene) {
                             (viewBox[3] - viewBox[1]) * (dataBox[3] - dataBox[1]) +
                         dataBox[1];
 
-                    if(!result.boxEnabled) {
+                    if(!result.boxInited) {
                         result.boxStart[0] = dataX;
                         result.boxStart[1] = dataY;
+                        result.dragStart[0] = x;
+                        result.dragStart[1] = y;
                     }
 
                     result.boxEnd[0] = dataX;
                     result.boxEnd[1] = dataY;
 
-                    result.boxEnabled = true;
+                    // we need to mark the box as initialized right away
+                    // so that we can tell the start and end pionts apart
+                    result.boxInited = true;
+
+                    // but don't actually enable the box until the cursor moves
+                    if(!result.boxEnabled && (
+                        result.boxStart[0] !== result.boxEnd[0] ||
+                        result.boxStart[1] !== result.boxEnd[1])
+                    ) {
+                        result.boxEnabled = true;
+                    }
+
+                    // constrain aspect ratio if the axes require it
+                    var smallDx = Math.abs(result.dragStart[0] - x) < MINZOOM;
+                    var smallDy = Math.abs(result.dragStart[1] - y) < MINZOOM;
+                    if(getSubplotConstraint() && !(smallDx && smallDy)) {
+                        dx = result.boxEnd[0] - result.boxStart[0];
+                        dy = result.boxEnd[1] - result.boxStart[1];
+                        var dydx = (dataBox[3] - dataBox[1]) / (dataBox[2] - dataBox[0]);
+
+                        if(Math.abs(dx * dydx) > Math.abs(dy)) {
+                            result.boxEnd[1] = result.boxStart[1] +
+                                Math.abs(dx) * dydx * (Math.sign(dy) || 1);
+
+                            // gl-select-box clips to the plot area bounds,
+                            // which breaks the axis constraint, so don't allow
+                            // this box to go out of bounds
+                            if(result.boxEnd[1] < dataBox[1]) {
+                                result.boxEnd[1] = dataBox[1];
+                                result.boxEnd[0] = result.boxStart[0] +
+                                    (dataBox[1] - result.boxStart[1]) / Math.abs(dydx);
+                            }
+                            else if(result.boxEnd[1] > dataBox[3]) {
+                                result.boxEnd[1] = dataBox[3];
+                                result.boxEnd[0] = result.boxStart[0] +
+                                    (dataBox[3] - result.boxStart[1]) / Math.abs(dydx);
+                            }
+                        }
+                        else {
+                            result.boxEnd[0] = result.boxStart[0] +
+                                Math.abs(dy) / dydx * (Math.sign(dx) || 1);
+
+                            if(result.boxEnd[0] < dataBox[0]) {
+                                result.boxEnd[0] = dataBox[0];
+                                result.boxEnd[1] = result.boxStart[1] +
+                                    (dataBox[0] - result.boxStart[0]) * Math.abs(dydx);
+                            }
+                            else if(result.boxEnd[0] > dataBox[2]) {
+                                result.boxEnd[0] = dataBox[2];
+                                result.boxEnd[1] = result.boxStart[1] +
+                                    (dataBox[2] - result.boxStart[0]) * Math.abs(dydx);
+                            }
+                        }
+                    }
+                    // otherwise clamp small changes to the origin so we get 1D zoom
+                    else {
+                        if(smallDx) result.boxEnd[0] = result.boxStart[0];
+                        if(smallDy) result.boxEnd[1] = result.boxStart[1];
+                    }
                 }
                 else if(result.boxEnabled) {
-                    updateRange(0, result.boxStart[0], result.boxEnd[0]);
-                    updateRange(1, result.boxStart[1], result.boxEnd[1]);
-                    unSetAutoRange();
+                    dx = result.boxStart[0] !== result.boxEnd[0];
+                    dy = result.boxStart[1] !== result.boxEnd[1];
+                    if(dx || dy) {
+                        if(dx) {
+                            updateRange(0, result.boxStart[0], result.boxEnd[0]);
+                            scene.xaxis.autorange = false;
+                        }
+                        if(dy) {
+                            updateRange(1, result.boxStart[1], result.boxEnd[1]);
+                            scene.yaxis.autorange = false;
+                        }
+                        scene.relayoutCallback();
+                    }
+                    else {
+                        scene.glplot.setDirty();
+                    }
                     result.boxEnabled = false;
-                    scene.relayoutCallback();
+                    result.boxInited = false;
                 }
                 break;
 
             case 'pan':
                 result.boxEnabled = false;
+                result.boxInited = false;
 
                 if(buttons) {
-                    var dx = (lastX - x) * (dataBox[2] - dataBox[0]) /
+                    if(!result.panning) {
+                        result.dragStart[0] = x;
+                        result.dragStart[1] = y;
+                    }
+
+                    if(Math.abs(result.dragStart[0] - x) < MINDRAG) x = result.dragStart[0];
+                    if(Math.abs(result.dragStart[1] - y) < MINDRAG) y = result.dragStart[1];
+
+                    dx = (lastX - x) * (dataBox[2] - dataBox[0]) /
                         (plot.viewBox[2] - plot.viewBox[0]);
-                    var dy = (lastY - y) * (dataBox[3] - dataBox[1]) /
+                    dy = (lastY - y) * (dataBox[3] - dataBox[1]) /
                         (plot.viewBox[3] - plot.viewBox[1]);
 
                     dataBox[0] += dx;
diff --git a/src/plots/gl2d/scene2d.js b/src/plots/gl2d/scene2d.js
index 935c6951e05..01b1fdb2b56 100644
--- a/src/plots/gl2d/scene2d.js
+++ b/src/plots/gl2d/scene2d.js
@@ -22,6 +22,7 @@ var createOptions = require('./convert');
 var createCamera = require('./camera');
 var convertHTMLToUnicode = require('../../lib/html2unicode');
 var showNoWebGlMsg = require('../../lib/show_no_webgl_msg');
+var enforceAxisConstraints = require('../../plots/cartesian/constraints');
 
 var AXES = ['xaxis', 'yaxis'];
 var STATIC_CANVAS, STATIC_CONTEXT;
@@ -426,6 +427,13 @@ proto.plot = function(fullData, calcData, fullLayout) {
         ax.setScale();
     }
 
+    var mockLayout = {
+        _axisConstraintGroups: this.graphDiv._fullLayout._axisConstraintGroups,
+        xaxis: this.xaxis,
+        yaxis: this.yaxis
+    };
+    enforceAxisConstraints({_fullLayout: mockLayout});
+
     options.ticks = this.computeTickMarks();
 
     options.dataBox = this.calcDataBox();
@@ -544,26 +552,36 @@ proto.draw = function() {
     var x = mouseListener.x * glplot.pixelRatio;
     var y = this.canvas.height - glplot.pixelRatio * mouseListener.y;
 
+    var result;
+
     if(camera.boxEnabled && fullLayout.dragmode === 'zoom') {
         this.selectBox.enabled = true;
 
-        this.selectBox.selectBox = [
+        var selectBox = this.selectBox.selectBox = [
             Math.min(camera.boxStart[0], camera.boxEnd[0]),
             Math.min(camera.boxStart[1], camera.boxEnd[1]),
             Math.max(camera.boxStart[0], camera.boxEnd[0]),
             Math.max(camera.boxStart[1], camera.boxEnd[1])
         ];
 
+        // 1D zoom
+        for(var i = 0; i < 2; i++) {
+            if(camera.boxStart[i] === camera.boxEnd[i]) {
+                selectBox[i] = glplot.dataBox[i];
+                selectBox[i + 2] = glplot.dataBox[i + 2];
+            }
+        }
+
         glplot.setDirty();
     }
-    else {
+    else if(!camera.panning) {
         this.selectBox.enabled = false;
 
         var size = fullLayout._size,
             domainX = this.xaxis.domain,
             domainY = this.yaxis.domain;
 
-        var result = glplot.pick(
+        result = glplot.pick(
             (x / glplot.pixelRatio) + size.l + domainX[0] * size.w,
             (y / glplot.pixelRatio) - (size.t + (1 - domainY[1]) * size.h)
         );
@@ -629,12 +647,15 @@ proto.draw = function() {
                 });
             }
         }
-        else if(!result && this.lastPickResult) {
-            this.spikes.update({});
-            this.lastPickResult = null;
-            this.graphDiv.emit('plotly_unhover');
-            Fx.loneUnhover(this.svgContainer);
-        }
+    }
+
+    // Remove hover effects if we're not over a point OR
+    // if we're zooming or panning (in which case result is not set)
+    if(!result && this.lastPickResult) {
+        this.spikes.update({});
+        this.lastPickResult = null;
+        this.graphDiv.emit('plotly_unhover');
+        Fx.loneUnhover(this.svgContainer);
     }
 
     glplot.draw();
diff --git a/src/plots/gl3d/layout/axis_defaults.js b/src/plots/gl3d/layout/axis_defaults.js
index 353d03eb379..d65756b1e06 100644
--- a/src/plots/gl3d/layout/axis_defaults.js
+++ b/src/plots/gl3d/layout/axis_defaults.js
@@ -14,6 +14,7 @@ var colorMix = require('tinycolor2').mix;
 var Lib = require('../../../lib');
 
 var layoutAttributes = require('./axis_attributes');
+var handleTypeDefaults = require('../../cartesian/type_defaults');
 var handleAxisDefaults = require('../../cartesian/axis_defaults');
 
 var axesNames = ['xaxis', 'yaxis', 'zaxis'];
@@ -33,12 +34,14 @@ module.exports = function supplyLayoutDefaults(layoutIn, layoutOut, options) {
         var axName = axesNames[j];
         containerIn = layoutIn[axName] || {};
 
-        containerOut = {
+        containerOut = layoutOut[axName] = {
             _id: axName[0] + options.scene,
             _name: axName
         };
 
-        layoutOut[axName] = containerOut = handleAxisDefaults(
+        handleTypeDefaults(containerIn, containerOut, coerce, options.data);
+
+        handleAxisDefaults(
             containerIn,
             containerOut,
             coerce, {
diff --git a/src/plots/ternary/layout/defaults.js b/src/plots/ternary/layout/defaults.js
index a6b3cface36..cf7246910bc 100644
--- a/src/plots/ternary/layout/defaults.js
+++ b/src/plots/ternary/layout/defaults.js
@@ -39,7 +39,7 @@ function handleTernaryDefaults(ternaryLayoutIn, ternaryLayoutOut, coerce, option
     for(var j = 0; j < axesNames.length; j++) {
         axName = axesNames[j];
         containerIn = ternaryLayoutIn[axName] || {};
-        containerOut = ternaryLayoutOut[axName] = {_name: axName};
+        containerOut = ternaryLayoutOut[axName] = {_name: axName, type: 'linear'};
 
         handleAxisDefaults(containerIn, containerOut, options);
     }
diff --git a/src/traces/scattergl/convert.js b/src/traces/scattergl/convert.js
index a77d883792e..143f9295446 100644
--- a/src/traces/scattergl/convert.js
+++ b/src/traces/scattergl/convert.js
@@ -310,7 +310,7 @@ proto.update = function(options) {
 //      representing the epoch milliseconds in a typed array;
 //      also, perhaps the Python / R interfaces take care of String->Date conversions
 //      such that there's no need to check for string dates in plotly.js)
-// Patterned from axis_defaults.js:moreDates
+// Patterned from axis_autotype.js:moreDates
 // Code DRYing is not done to preserve the most direct compilation possible for speed;
 // also, there are quite a few differences
 function allFastTypesLikely(a) {
diff --git a/test/image/baselines/axes_scaleanchor.png b/test/image/baselines/axes_scaleanchor.png
new file mode 100644
index 00000000000..d7aa4a6e78f
Binary files /dev/null and b/test/image/baselines/axes_scaleanchor.png differ
diff --git a/test/image/mocks/axes_scaleanchor.json b/test/image/mocks/axes_scaleanchor.json
new file mode 100644
index 00000000000..5c1e5b1a086
--- /dev/null
+++ b/test/image/mocks/axes_scaleanchor.json
@@ -0,0 +1,20 @@
+{
+  "data":[
+    {"x": [0,1,1,0,0,1,1,2,2,3,3,2,2,3], "y": [0,0,1,1,3,3,2,2,3,3,1,1,0,0]},
+    {"x": [0,1,2,3], "y": [1,2,4,8], "yaxis":"y2"},
+    {"x": [1,10,100,10,1], "y": [0,1,2,3,4], "xaxis":"x2", "yaxis":"y3"},
+    {"x": [1,100,30,80,1], "y": [1,1.5,2,2.5,3], "xaxis":"x2", "yaxis":"y4"}
+  ],
+  "layout":{
+    "width": 800,
+    "height":500,
+    "title": "fixed-ratio axes",
+    "xaxis": {"nticks": 10, "domain": [0, 0.45], "title": "shared X axis"},
+    "yaxis": {"scaleanchor": "x", "domain": [0, 0.45], "title": "1:1"},
+    "yaxis2": {"scaleanchor": "x", "scaleratio": 0.2, "domain": [0.55,1], "title": "1:5"},
+    "xaxis2": {"type": "log", "domain": [0.55, 1], "anchor": "y3", "title": "unconstrained log X"},
+    "yaxis3": {"domain": [0, 0.45], "anchor": "x2", "title": "Scale matches ->"},
+    "yaxis4": {"scaleanchor": "y3", "domain": [0.55, 1], "anchor": "x2", "title": "Scale matches <-"},
+    "showlegend": false
+  }
+}
diff --git a/test/jasmine/assets/double_click.js b/test/jasmine/assets/double_click.js
index 2e66c90f952..c40c27ee4ca 100644
--- a/test/jasmine/assets/double_click.js
+++ b/test/jasmine/assets/double_click.js
@@ -1,7 +1,19 @@
 var click = require('./click');
+var getNodeCoords = require('./get_node_coords');
 var DBLCLICKDELAY = require('@src/constants/interactions').DBLCLICKDELAY;
 
+/*
+ * double click on a point.
+ * you can either specify x,y as pixels, or
+ * you can specify node and optionally an edge ('n', 'se', 'w' etc)
+ * to grab it by an edge or corner (otherwise the middle is used)
+ */
 module.exports = function doubleClick(x, y) {
+    if(typeof x === 'object') {
+        var coords = getNodeCoords(x, y);
+        x = coords.x;
+        y = coords.y;
+    }
     return new Promise(function(resolve) {
         click(x, y);
 
diff --git a/test/jasmine/assets/drag.js b/test/jasmine/assets/drag.js
index 16020b07493..d120f291080 100644
--- a/test/jasmine/assets/drag.js
+++ b/test/jasmine/assets/drag.js
@@ -1,4 +1,5 @@
-var mouseEvent = require('../assets/mouse_event');
+var mouseEvent = require('./mouse_event');
+var getNodeCoords = require('./get_node_coords');
 
 /*
  * drag: grab a node and drag it (dx, dy) pixels
@@ -7,21 +8,12 @@ var mouseEvent = require('../assets/mouse_event');
  */
 module.exports = function(node, dx, dy, edge) {
 
-    edge = edge || '';
-    var bbox = node.getBoundingClientRect(),
-        fromX, fromY;
+    var coords = getNodeCoords(node, edge);
+    var fromX = coords.x;
+    var fromY = coords.y;
 
-    if(edge.indexOf('n') !== -1) fromY = bbox.top;
-    else if(edge.indexOf('s') !== -1) fromY = bbox.bottom;
-    else fromY = (bbox.bottom + bbox.top) / 2;
-
-    if(edge.indexOf('w') !== -1) fromX = bbox.left;
-    else if(edge.indexOf('e') !== -1) fromX = bbox.right;
-    else fromX = (bbox.left + bbox.right) / 2;
-
-
-    var toX = fromX + dx,
-        toY = fromY + dy;
+    var toX = fromX + dx;
+    var toY = fromY + dy;
 
     mouseEvent('mousemove', fromX, fromY, {element: node});
     mouseEvent('mousedown', fromX, fromY, {element: node});
diff --git a/test/jasmine/assets/get_node_coords.js b/test/jasmine/assets/get_node_coords.js
new file mode 100644
index 00000000000..c2242cc755c
--- /dev/null
+++ b/test/jasmine/assets/get_node_coords.js
@@ -0,0 +1,20 @@
+/*
+ * get the pixel coordinates of a node on screen
+ * optionally specify an edge ('n', 'se', 'w' etc)
+ * to return an edge or corner (otherwise the middle is used)
+ */
+module.exports = function(node, edge) {
+    edge = edge || '';
+    var bbox = node.getBoundingClientRect(),
+        x, y;
+
+    if(edge.indexOf('n') !== -1) y = bbox.top;
+    else if(edge.indexOf('s') !== -1) y = bbox.bottom;
+    else y = (bbox.bottom + bbox.top) / 2;
+
+    if(edge.indexOf('w') !== -1) x = bbox.left;
+    else if(edge.indexOf('e') !== -1) x = bbox.right;
+    else x = (bbox.left + bbox.right) / 2;
+
+    return {x: x, y: y};
+};
diff --git a/test/jasmine/assets/mouse_event.js b/test/jasmine/assets/mouse_event.js
index 153314c5abd..7a976d048e3 100644
--- a/test/jasmine/assets/mouse_event.js
+++ b/test/jasmine/assets/mouse_event.js
@@ -1,3 +1,5 @@
+var Lib = require('@src/lib');
+
 module.exports = function(type, x, y, opts) {
     var fullOpts = {
         bubbles: true,
@@ -14,7 +16,7 @@ module.exports = function(type, x, y, opts) {
         ev;
 
     if(type === 'scroll') {
-        ev = new window.WheelEvent('wheel', opts);
+        ev = new window.WheelEvent('wheel', Lib.extendFlat({}, fullOpts, opts));
     } else {
         ev = new window.MouseEvent(type, fullOpts);
     }
diff --git a/test/jasmine/tests/axes_test.js b/test/jasmine/tests/axes_test.js
index 6f82ef2b067..d835ec32df2 100644
--- a/test/jasmine/tests/axes_test.js
+++ b/test/jasmine/tests/axes_test.js
@@ -10,6 +10,8 @@ var Axes = PlotlyInternal.Axes;
 
 var createGraphDiv = require('../assets/create_graph_div');
 var destroyGraphDiv = require('../assets/destroy_graph_div');
+var customMatchers = require('../assets/custom_matchers');
+var failTest = require('../assets/fail_test');
 
 
 describe('Test axes', function() {
@@ -444,6 +446,173 @@ describe('Test axes', function() {
                 expect(ax.autorange).toBe(false, ax._name);
             });
         });
+
+        it('finds scaling groups and calculates relative scales', function() {
+            layoutIn = {
+                // first group: linked in series, scales compound
+                xaxis: {},
+                yaxis: {scaleanchor: 'x', scaleratio: 2},
+                xaxis2: {scaleanchor: 'y', scaleratio: 3},
+                yaxis2: {scaleanchor: 'x2', scaleratio: 5},
+                // second group: linked in parallel, scales don't compound
+                yaxis3: {},
+                xaxis3: {scaleanchor: 'y3'},  // default scaleratio: 1
+                xaxis4: {scaleanchor: 'y3', scaleratio: 7},
+                xaxis5: {scaleanchor: 'y3', scaleratio: 9}
+            };
+
+            supplyLayoutDefaults(layoutIn, layoutOut, fullData);
+
+            expect(layoutOut._axisConstraintGroups).toEqual([
+                {x: 1, y: 2, x2: 2 * 3, y2: 2 * 3 * 5},
+                {y3: 1, x3: 1, x4: 7, x5: 9}
+            ]);
+        });
+
+        var warnTxt = ' to avoid either an infinite loop and possibly ' +
+            'inconsistent scaleratios, or because the targetaxis has ' +
+            'fixed range.';
+
+        it('breaks scaleanchor loops and drops conflicting ratios', function() {
+            var warnings = [];
+            spyOn(Lib, 'warn').and.callFake(function(msg) {
+                warnings.push(msg);
+            });
+
+            layoutIn = {
+                xaxis: {scaleanchor: 'y', scaleratio: 2},
+                yaxis: {scaleanchor: 'x', scaleratio: 3}, // dropped loop
+
+                xaxis2: {scaleanchor: 'y2', scaleratio: 5},
+                yaxis2: {scaleanchor: 'x3', scaleratio: 7},
+                xaxis3: {scaleanchor: 'y3', scaleratio: 9},
+                yaxis3: {scaleanchor: 'x2', scaleratio: 11}, // dropped loop
+
+                xaxis4: {scaleanchor: 'x', scaleratio: 13}, // x<->x is OK now
+                yaxis4: {scaleanchor: 'y', scaleratio: 17}, // y<->y is OK now
+            };
+
+            supplyLayoutDefaults(layoutIn, layoutOut, fullData);
+
+            expect(layoutOut._axisConstraintGroups).toEqual([
+                {x: 2, y: 1, x4: 2 * 13, y4: 17},
+                {x2: 5 * 7 * 9, y2: 7 * 9, y3: 1, x3: 9}
+            ]);
+
+            expect(warnings).toEqual([
+                'ignored yaxis.scaleanchor: "x"' + warnTxt,
+                'ignored yaxis3.scaleanchor: "x2"' + warnTxt
+            ]);
+        });
+
+        it('silently drops invalid scaleanchor values', function() {
+            var warnings = [];
+            spyOn(Lib, 'warn').and.callFake(function(msg) {
+                warnings.push(msg);
+            });
+
+            layoutIn = {
+                xaxis: {scaleanchor: 'x', scaleratio: 2}, // can't link to itself - this one isn't ignored...
+                yaxis: {scaleanchor: 'x4', scaleratio: 3}, // doesn't exist
+                xaxis2: {scaleanchor: 'yaxis', scaleratio: 5} // must be an id, not a name
+            };
+
+            supplyLayoutDefaults(layoutIn, layoutOut, fullData);
+
+            expect(layoutOut._axisConstraintGroups).toEqual([]);
+            expect(warnings).toEqual(['ignored xaxis.scaleanchor: "x"' + warnTxt]);
+
+            ['xaxis', 'yaxis', 'xaxis2'].forEach(function(axName) {
+                expect(layoutOut[axName].scaleanchor).toBeUndefined(axName);
+                expect(layoutOut[axName].scaleratio).toBeUndefined(axName);
+            });
+        });
+
+        it('will not link axes of different types', function() {
+            layoutIn = {
+                xaxis: {type: 'linear'},
+                yaxis: {type: 'log', scaleanchor: 'x', scaleratio: 2},
+                xaxis2: {type: 'date', scaleanchor: 'y', scaleratio: 3},
+                yaxis2: {type: 'category', scaleanchor: 'x2', scaleratio: 5}
+            };
+
+            supplyLayoutDefaults(layoutIn, layoutOut, fullData);
+
+            expect(layoutOut._axisConstraintGroups).toEqual([]);
+
+            ['xaxis', 'yaxis', 'xaxis2', 'yaxis2'].forEach(function(axName) {
+                expect(layoutOut[axName].scaleanchor).toBeUndefined(axName);
+                expect(layoutOut[axName].scaleratio).toBeUndefined(axName);
+            });
+        });
+
+        it('drops scaleanchor settings if either the axis or target has fixedrange', function() {
+            // some of these will create warnings... not too important, so not going to test,
+            // just want to keep the output clean
+            // spyOn(Lib, 'warn');
+
+            layoutIn = {
+                xaxis: {fixedrange: true, scaleanchor: 'y', scaleratio: 2},
+                yaxis: {scaleanchor: 'x2', scaleratio: 3}, // only this one should survive
+                xaxis2: {},
+                yaxis2: {scaleanchor: 'x', scaleratio: 5}
+            };
+
+            supplyLayoutDefaults(layoutIn, layoutOut, fullData);
+
+            expect(layoutOut._axisConstraintGroups).toEqual([{x2: 1, y: 3}]);
+
+            expect(layoutOut.yaxis.scaleanchor).toBe('x2');
+            expect(layoutOut.yaxis.scaleratio).toBe(3);
+
+            ['xaxis', 'yaxis2', 'xaxis2'].forEach(function(axName) {
+                expect(layoutOut[axName].scaleanchor).toBeUndefined();
+                expect(layoutOut[axName].scaleratio).toBeUndefined();
+            });
+        });
+    });
+
+    describe('constraints relayout', function() {
+        var gd;
+
+        beforeEach(function() {
+            gd = createGraphDiv();
+            jasmine.addMatchers(customMatchers);
+        });
+
+        afterEach(destroyGraphDiv);
+
+        it('updates ranges when adding, removing, or changing a constraint', function(done) {
+            PlotlyInternal.plot(gd,
+                [{z: [[0, 1], [2, 3]], type: 'heatmap'}],
+                // plot area is 200x100 px
+                {width: 400, height: 300, margin: {l: 100, r: 100, t: 100, b: 100}}
+            )
+            .then(function() {
+                expect(gd.layout.xaxis.range).toBeCloseToArray([-0.5, 1.5], 5);
+                expect(gd.layout.yaxis.range).toBeCloseToArray([-0.5, 1.5], 5);
+
+                return PlotlyInternal.relayout(gd, {'xaxis.scaleanchor': 'y'});
+            })
+            .then(function() {
+                expect(gd.layout.xaxis.range).toBeCloseToArray([-1.5, 2.5], 5);
+                expect(gd.layout.yaxis.range).toBeCloseToArray([-0.5, 1.5], 5);
+
+                return PlotlyInternal.relayout(gd, {'xaxis.scaleratio': 10});
+            })
+            .then(function() {
+                expect(gd.layout.xaxis.range).toBeCloseToArray([-0.5, 1.5], 5);
+                expect(gd.layout.yaxis.range).toBeCloseToArray([-4.5, 5.5], 5);
+
+                return PlotlyInternal.relayout(gd, {'xaxis.scaleanchor': null});
+            })
+            .then(function() {
+                expect(gd.layout.xaxis.range).toBeCloseToArray([-0.5, 1.5], 5);
+                expect(gd.layout.yaxis.range).toBeCloseToArray([-0.5, 1.5], 5);
+            })
+            .catch(failTest)
+            .then(done);
+        });
     });
 
     describe('categoryorder', function() {
diff --git a/test/jasmine/tests/cartesian_interact_test.js b/test/jasmine/tests/cartesian_interact_test.js
new file mode 100644
index 00000000000..11106fa89c3
--- /dev/null
+++ b/test/jasmine/tests/cartesian_interact_test.js
@@ -0,0 +1,434 @@
+var d3 = require('d3');
+
+var Plotly = require('@lib/index');
+var Lib = require('@src/lib');
+var constants = require('@src/plots/cartesian/constants');
+
+var createGraphDiv = require('../assets/create_graph_div');
+var destroyGraphDiv = require('../assets/destroy_graph_div');
+var mouseEvent = require('../assets/mouse_event');
+var failTest = require('../assets/fail_test');
+var customMatchers = require('../assets/custom_matchers');
+var selectButton = require('../assets/modebar_button');
+var drag = require('../assets/drag');
+var doubleClick = require('../assets/double_click');
+var getNodeCoords = require('../assets/get_node_coords');
+var delay = require('../assets/delay');
+
+var MODEBAR_DELAY = 500;
+
+describe('zoom box element', function() {
+    var mock = require('@mocks/14.json');
+
+    var gd;
+    beforeEach(function(done) {
+        gd = createGraphDiv();
+
+        var mockCopy = Lib.extendDeep({}, mock);
+        mockCopy.layout.dragmode = 'zoom';
+
+        Plotly.plot(gd, mockCopy.data, mockCopy.layout).then(done);
+    });
+
+    afterEach(destroyGraphDiv);
+
+    it('should be appended to the zoom layer', function() {
+        var x0 = 100;
+        var y0 = 200;
+        var x1 = 150;
+        var y1 = 200;
+
+        mouseEvent('mousemove', x0, y0);
+        expect(d3.selectAll('.zoomlayer > .zoombox').size())
+            .toEqual(0);
+        expect(d3.selectAll('.zoomlayer > .zoombox-corners').size())
+            .toEqual(0);
+
+        mouseEvent('mousedown', x0, y0);
+        mouseEvent('mousemove', x1, y1);
+        expect(d3.selectAll('.zoomlayer > .zoombox').size())
+            .toEqual(1);
+        expect(d3.selectAll('.zoomlayer > .zoombox-corners').size())
+            .toEqual(1);
+
+        mouseEvent('mouseup', x1, y1);
+        expect(d3.selectAll('.zoomlayer > .zoombox').size())
+            .toEqual(0);
+        expect(d3.selectAll('.zoomlayer > .zoombox-corners').size())
+            .toEqual(0);
+    });
+});
+
+
+describe('main plot pan', function() {
+
+    var mock = require('@mocks/10.json'),
+        gd, modeBar, relayoutCallback;
+
+    beforeEach(function(done) {
+        gd = createGraphDiv();
+
+        Plotly.plot(gd, mock.data, mock.layout).then(function() {
+
+            modeBar = gd._fullLayout._modeBar;
+            relayoutCallback = jasmine.createSpy('relayoutCallback');
+
+            gd.on('plotly_relayout', relayoutCallback);
+
+            done();
+        });
+    });
+
+    afterEach(destroyGraphDiv);
+
+    it('should respond to pan interactions', function(done) {
+
+        jasmine.addMatchers(customMatchers);
+
+        var precision = 5;
+
+        var buttonPan = selectButton(modeBar, 'pan2d');
+
+        var originalX = [-0.6225, 5.5];
+        var originalY = [-1.6340975059013805, 7.166241526218911];
+
+        var newX = [-2.0255729166666665, 4.096927083333333];
+        var newY = [-0.3769062155984817, 8.42343281652181];
+
+        expect(gd.layout.xaxis.range).toBeCloseToArray(originalX, precision);
+        expect(gd.layout.yaxis.range).toBeCloseToArray(originalY, precision);
+
+        // Switch to pan mode
+        expect(buttonPan.isActive()).toBe(false); // initially, zoom is active
+        buttonPan.click();
+        expect(buttonPan.isActive()).toBe(true); // switched on dragmode
+
+        // Switching mode must not change visible range
+        expect(gd.layout.xaxis.range).toBeCloseToArray(originalX, precision);
+        expect(gd.layout.yaxis.range).toBeCloseToArray(originalY, precision);
+
+        setTimeout(function() {
+
+            expect(relayoutCallback).toHaveBeenCalledTimes(1);
+            relayoutCallback.calls.reset();
+
+            // Drag scene along the X axis
+
+            mouseEvent('mousedown', 110, 150);
+            mouseEvent('mousemove', 220, 150);
+            mouseEvent('mouseup', 220, 150);
+
+            expect(gd.layout.xaxis.range).toBeCloseToArray(newX, precision);
+            expect(gd.layout.yaxis.range).toBeCloseToArray(originalY, precision);
+
+            // Drag scene back along the X axis (not from the same starting point but same X delta)
+
+            mouseEvent('mousedown', 280, 150);
+            mouseEvent('mousemove', 170, 150);
+            mouseEvent('mouseup', 170, 150);
+
+            expect(gd.layout.xaxis.range).toBeCloseToArray(originalX, precision);
+            expect(gd.layout.yaxis.range).toBeCloseToArray(originalY, precision);
+
+            // Drag scene along the Y axis
+
+            mouseEvent('mousedown', 110, 150);
+            mouseEvent('mousemove', 110, 190);
+            mouseEvent('mouseup', 110, 190);
+
+            expect(gd.layout.xaxis.range).toBeCloseToArray(originalX, precision);
+            expect(gd.layout.yaxis.range).toBeCloseToArray(newY, precision);
+
+            // Drag scene back along the Y axis (not from the same starting point but same Y delta)
+
+            mouseEvent('mousedown', 280, 130);
+            mouseEvent('mousemove', 280, 90);
+            mouseEvent('mouseup', 280, 90);
+
+            expect(gd.layout.xaxis.range).toBeCloseToArray(originalX, precision);
+            expect(gd.layout.yaxis.range).toBeCloseToArray(originalY, precision);
+
+            // Drag scene along both the X and Y axis
+
+            mouseEvent('mousedown', 110, 150);
+            mouseEvent('mousemove', 220, 190);
+            mouseEvent('mouseup', 220, 190);
+
+            expect(gd.layout.xaxis.range).toBeCloseToArray(newX, precision);
+            expect(gd.layout.yaxis.range).toBeCloseToArray(newY, precision);
+
+            // Drag scene back along the X and Y axis (not from the same starting point but same delta vector)
+
+            mouseEvent('mousedown', 280, 130);
+            mouseEvent('mousemove', 170, 90);
+            mouseEvent('mouseup', 170, 90);
+
+            expect(gd.layout.xaxis.range).toBeCloseToArray(originalX, precision);
+            expect(gd.layout.yaxis.range).toBeCloseToArray(originalY, precision);
+
+            setTimeout(function() {
+
+                expect(relayoutCallback).toHaveBeenCalledTimes(6); // X and back; Y and back; XY and back
+
+                done();
+
+            }, MODEBAR_DELAY);
+
+        }, MODEBAR_DELAY);
+    });
+});
+
+describe('axis zoom/pan and main plot zoom', function() {
+    var gd;
+
+    beforeEach(function() {
+        gd = createGraphDiv();
+        jasmine.addMatchers(customMatchers);
+    });
+
+    afterEach(destroyGraphDiv);
+
+    var initialRange = [0, 2];
+    var autoRange = [-0.1594, 2.1594];
+
+    function makePlot(constrainScales, layoutEdits) {
+        // mock with 4 subplots, 3 of which share some axes:
+        //
+        //   |            |
+        // y2|    xy2   y3|   x3y3
+        //   |            |
+        //   +---------   +----------
+        //                     x3
+        //   |            |
+        //  y|    xy      |   x2y
+        //   |            |
+        //   +---------   +----------
+        //        x            x2
+        //
+        // each subplot is 200x200 px
+        // if constrainScales is used, x/x2/y/y2 are linked, as are x3/y3
+        // layoutEdits are other changes to make to the layout
+        var data = [
+            {y: [0, 1, 2]},
+            {y: [0, 1, 2], xaxis: 'x2'},
+            {y: [0, 1, 2], yaxis: 'y2'},
+            {y: [0, 1, 2], xaxis: 'x3', yaxis: 'y3'}
+        ];
+
+        var layout = {
+            width: 700,
+            height: 620,
+            margin: {l: 100, r: 100, t: 20, b: 100},
+            showlegend: false,
+            xaxis: {domain: [0, 0.4], range: [0, 2]},
+            yaxis: {domain: [0.15, 0.55], range: [0, 2]},
+            xaxis2: {domain: [0.6, 1], range: [0, 2]},
+            yaxis2: {domain: [0.6, 1], range: [0, 2]},
+            xaxis3: {domain: [0.6, 1], range: [0, 2], anchor: 'y3'},
+            yaxis3: {domain: [0.6, 1], range: [0, 2], anchor: 'x3'}
+        };
+
+        var config = {scrollZoom: true};
+
+        if(constrainScales) {
+            layout.yaxis.scaleanchor = 'x';
+            layout.yaxis2.scaleanchor = 'x';
+            layout.xaxis2.scaleanchor = 'y';
+            layout.yaxis3.scaleanchor = 'x3';
+        }
+
+        if(layoutEdits) Lib.extendDeep(layout, layoutEdits);
+
+        return Plotly.newPlot(gd, data, layout, config).then(function() {
+            [
+                'xaxis', 'yaxis', 'xaxis2', 'yaxis2', 'xaxis3', 'yaxis3'
+            ].forEach(function(axName) {
+                expect(gd._fullLayout[axName].range).toEqual(initialRange);
+            });
+
+            expect(Object.keys(gd._fullLayout._plots))
+                .toEqual(['xy', 'xy2', 'x2y', 'x3y3']);
+
+            // nsew, n, ns, s, w, ew, e, ne, nw, se, sw
+            expect(document.querySelectorAll('.drag[data-subplot="xy"]').length).toBe(11);
+            // same but no w, ew, e because x is on xy only
+            expect(document.querySelectorAll('.drag[data-subplot="xy2"]').length).toBe(8);
+            // y is on xy only so no n, ns, s
+            expect(document.querySelectorAll('.drag[data-subplot="x2y"]').length).toBe(8);
+            // all 11, as this is a fully independent subplot
+            expect(document.querySelectorAll('.drag[data-subplot="x3y3"]').length).toBe(11);
+        });
+
+    }
+
+    function getDragger(subplot, directions) {
+        return document.querySelector('.' + directions + 'drag[data-subplot="' + subplot + '"]');
+    }
+
+    function doDrag(subplot, directions, dx, dy) {
+        return function() {
+            var dragger = getDragger(subplot, directions);
+            return drag(dragger, dx, dy);
+        };
+    }
+
+    function doDblClick(subplot, directions) {
+        return function() { return doubleClick(getDragger(subplot, directions)); };
+    }
+
+    function checkRanges(newRanges) {
+        return function() {
+            var allRanges = {
+                xaxis: initialRange.slice(),
+                yaxis: initialRange.slice(),
+                xaxis2: initialRange.slice(),
+                yaxis2: initialRange.slice(),
+                xaxis3: initialRange.slice(),
+                yaxis3: initialRange.slice()
+            };
+            Lib.extendDeep(allRanges, newRanges);
+
+            for(var axName in allRanges) {
+                expect(gd.layout[axName].range).toBeCloseToArray(allRanges[axName], 3, axName);
+                expect(gd._fullLayout[axName].range).toBeCloseToArray(gd.layout[axName].range, 6, axName);
+            }
+        };
+    }
+
+    it('updates with correlated subplots & no constraints - zoom, dblclick, axis ends', function(done) {
+        makePlot()
+        // zoombox into a small point - drag starts from the center unless you specify otherwise
+        .then(doDrag('xy', 'nsew', 100, -50))
+        .then(checkRanges({xaxis: [1, 2], yaxis: [1, 1.5]}))
+
+        // first dblclick reverts to saved ranges
+        .then(doDblClick('xy', 'nsew'))
+        .then(checkRanges())
+        // next dblclick autoscales (just that plot)
+        .then(doDblClick('xy', 'nsew'))
+        .then(checkRanges({xaxis: autoRange, yaxis: autoRange}))
+        // dblclick on one axis reverts just that axis to saved
+        .then(doDblClick('xy', 'ns'))
+        .then(checkRanges({xaxis: autoRange}))
+        // dblclick the plot at this point (one axis default, the other autoscaled)
+        // and the whole thing is reverted to default
+        .then(doDblClick('xy', 'nsew'))
+        .then(checkRanges())
+
+        // 1D zoombox - use the linked subplots
+        .then(doDrag('xy2', 'nsew', -100, 0))
+        .then(checkRanges({xaxis: [0, 1]}))
+        .then(doDrag('x2y', 'nsew', 0, 50))
+        .then(checkRanges({xaxis: [0, 1], yaxis: [0.5, 1]}))
+        // dblclick on linked subplots just changes the linked axis
+        .then(doDblClick('xy2', 'nsew'))
+        .then(checkRanges({yaxis: [0.5, 1]}))
+        .then(doDblClick('x2y', 'nsew'))
+        .then(checkRanges())
+        // drag on axis ends - all these 1D draggers the opposite axis delta is irrelevant
+        .then(doDrag('xy2', 'n', 53, 100))
+        .then(checkRanges({yaxis2: [0, 4]}))
+        .then(doDrag('xy', 's', 53, -100))
+        .then(checkRanges({yaxis: [-2, 2], yaxis2: [0, 4]}))
+        // expanding drag is highly nonlinear
+        .then(doDrag('x2y', 'e', 50, 53))
+        .then(checkRanges({yaxis: [-2, 2], yaxis2: [0, 4], xaxis2: [0, 0.8751]}))
+        .then(doDrag('x2y', 'w', -50, 53))
+        .then(checkRanges({yaxis: [-2, 2], yaxis2: [0, 4], xaxis2: [0.4922, 0.8751]}))
+        // reset all from the modebar
+        .then(function() { selectButton(gd._fullLayout._modeBar, 'resetScale2d').click(); })
+        .then(checkRanges())
+        .catch(failTest)
+        .then(done);
+    });
+
+    it('updates with correlated subplots & no constraints - middles, corners, and scrollwheel', function(done) {
+        makePlot()
+        // drag axis middles
+        .then(doDrag('x3y3', 'ew', 100, 0))
+        .then(checkRanges({xaxis3: [-1, 1]}))
+        .then(doDrag('x3y3', 'ns', 53, 100))
+        .then(checkRanges({xaxis3: [-1, 1], yaxis3: [1, 3]}))
+        // drag corners
+        .then(doDrag('x3y3', 'ne', -100, 100))
+        .then(checkRanges({xaxis3: [-1, 3], yaxis3: [1, 5]}))
+        .then(doDrag('x3y3', 'sw', 100, -100))
+        .then(checkRanges({xaxis3: [-5, 3], yaxis3: [-3, 5]}))
+        .then(doDrag('x3y3', 'nw', -50, -50))
+        .then(checkRanges({xaxis3: [-0.5006, 3], yaxis3: [-3, 0.5006]}))
+        .then(doDrag('x3y3', 'se', 50, 50))
+        .then(checkRanges({xaxis3: [-0.5006, 1.0312], yaxis3: [-1.0312, 0.5006]}))
+        .then(doDblClick('x3y3', 'nsew'))
+        .then(checkRanges())
+        // scroll wheel
+        .then(function() {
+            var mainDrag = getDragger('xy', 'nsew');
+            var mainDragCoords = getNodeCoords(mainDrag, 'se');
+            mouseEvent('scroll', mainDragCoords.x, mainDragCoords.y, {deltaY: 20, element: mainDrag});
+        })
+        .then(delay(constants.REDRAWDELAY + 10))
+        .then(checkRanges({xaxis: [-0.4428, 2], yaxis: [0, 2.4428]}))
+        .then(function() {
+            var ewDrag = getDragger('xy', 'ew');
+            var ewDragCoords = getNodeCoords(ewDrag);
+            mouseEvent('scroll', ewDragCoords.x - 50, ewDragCoords.y, {deltaY: -20, element: ewDrag});
+        })
+        .then(delay(constants.REDRAWDELAY + 10))
+        .then(checkRanges({xaxis: [-0.3321, 1.6679], yaxis: [0, 2.4428]}))
+        .then(function() {
+            var nsDrag = getDragger('xy', 'ns');
+            var nsDragCoords = getNodeCoords(nsDrag);
+            mouseEvent('scroll', nsDragCoords.x, nsDragCoords.y - 50, {deltaY: -20, element: nsDrag});
+        })
+        .then(delay(constants.REDRAWDELAY + 10))
+        .then(checkRanges({xaxis: [-0.3321, 1.6679], yaxis: [0.3321, 2.3321]}))
+        .catch(failTest)
+        .then(done);
+    });
+
+    it('updates linked axes when there are constraints', function(done) {
+        makePlot(true)
+        // zoombox - this *would* be 1D (dy=-1) but that's not allowed
+        .then(doDrag('xy', 'nsew', 100, -1))
+        .then(checkRanges({xaxis: [1, 2], yaxis: [1, 2], xaxis2: [0.5, 1.5], yaxis2: [0.5, 1.5]}))
+        // first dblclick reverts to saved ranges
+        .then(doDblClick('xy', 'nsew'))
+        .then(checkRanges())
+        // next dblclick autoscales ALL linked plots
+        .then(doDblClick('xy', 'ns'))
+        .then(checkRanges({xaxis: autoRange, yaxis: autoRange, xaxis2: autoRange, yaxis2: autoRange}))
+        // revert again
+        .then(doDblClick('xy', 'nsew'))
+        .then(checkRanges())
+        // corner drag - full distance in one direction and no shift in the other gets averaged
+        // into half distance in each
+        .then(doDrag('xy', 'ne', -200, 0))
+        .then(checkRanges({xaxis: [0, 4], yaxis: [0, 4], xaxis2: [-1, 3], yaxis2: [-1, 3]}))
+        // drag one end
+        .then(doDrag('xy', 's', 53, -100))
+        .then(checkRanges({xaxis: [-2, 6], yaxis: [-4, 4], xaxis2: [-3, 5], yaxis2: [-3, 5]}))
+        // middle of an axis
+        .then(doDrag('xy', 'ew', -100, 53))
+        .then(checkRanges({xaxis: [2, 10], yaxis: [-4, 4], xaxis2: [-3, 5], yaxis2: [-3, 5]}))
+        // revert again
+        .then(doDblClick('xy', 'nsew'))
+        .then(checkRanges())
+        // scroll wheel
+        .then(function() {
+            var mainDrag = getDragger('xy', 'nsew');
+            var mainDragCoords = getNodeCoords(mainDrag, 'se');
+            mouseEvent('scroll', mainDragCoords.x, mainDragCoords.y, {deltaY: 20, element: mainDrag});
+        })
+        .then(delay(constants.REDRAWDELAY + 10))
+        .then(checkRanges({xaxis: [-0.4428, 2], yaxis: [0, 2.4428], xaxis2: [-0.2214, 2.2214], yaxis2: [-0.2214, 2.2214]}))
+        .then(function() {
+            var ewDrag = getDragger('xy', 'ew');
+            var ewDragCoords = getNodeCoords(ewDrag);
+            mouseEvent('scroll', ewDragCoords.x - 50, ewDragCoords.y, {deltaY: -20, element: ewDrag});
+        })
+        .then(delay(constants.REDRAWDELAY + 10))
+        .then(checkRanges({xaxis: [-0.3321, 1.6679], yaxis: [0.2214, 2.2214]}))
+        .catch(failTest)
+        .then(done);
+    });
+});
diff --git a/test/jasmine/tests/cartesian_test.js b/test/jasmine/tests/cartesian_test.js
index b634ffa3081..f6e930affda 100644
--- a/test/jasmine/tests/cartesian_test.js
+++ b/test/jasmine/tests/cartesian_test.js
@@ -6,52 +6,9 @@ var Drawing = require('@src/components/drawing');
 
 var createGraphDiv = require('../assets/create_graph_div');
 var destroyGraphDiv = require('../assets/destroy_graph_div');
-var mouseEvent = require('../assets/mouse_event');
 var failTest = require('../assets/fail_test');
 
 
-describe('zoom box element', function() {
-    var mock = require('@mocks/14.json');
-
-    var gd;
-    beforeEach(function(done) {
-        gd = createGraphDiv();
-
-        var mockCopy = Lib.extendDeep({}, mock);
-        mockCopy.layout.dragmode = 'zoom';
-
-        Plotly.plot(gd, mockCopy.data, mockCopy.layout).then(done);
-    });
-
-    afterEach(destroyGraphDiv);
-
-    it('should be appended to the zoom layer', function() {
-        var x0 = 100;
-        var y0 = 200;
-        var x1 = 150;
-        var y1 = 200;
-
-        mouseEvent('mousemove', x0, y0);
-        expect(d3.selectAll('.zoomlayer > .zoombox').size())
-            .toEqual(0);
-        expect(d3.selectAll('.zoomlayer > .zoombox-corners').size())
-            .toEqual(0);
-
-        mouseEvent('mousedown', x0, y0);
-        mouseEvent('mousemove', x1, y1);
-        expect(d3.selectAll('.zoomlayer > .zoombox').size())
-            .toEqual(1);
-        expect(d3.selectAll('.zoomlayer > .zoombox-corners').size())
-            .toEqual(1);
-
-        mouseEvent('mouseup', x1, y1);
-        expect(d3.selectAll('.zoomlayer > .zoombox').size())
-            .toEqual(0);
-        expect(d3.selectAll('.zoomlayer > .zoombox-corners').size())
-            .toEqual(0);
-    });
-});
-
 describe('restyle', function() {
     describe('scatter traces', function() {
         var gd;
diff --git a/test/jasmine/tests/click_test.js b/test/jasmine/tests/click_test.js
index 9e13b9ffd12..531f4261040 100644
--- a/test/jasmine/tests/click_test.js
+++ b/test/jasmine/tests/click_test.js
@@ -758,7 +758,7 @@ describe('Test click interactions:', function() {
             var plot = gd._fullLayout._plots.xy.plot;
 
             mouseEvent('mousemove', 393, 243);
-            mouseEvent('scroll', 393, 243, { deltaX: 0, deltaY: -1000 });
+            mouseEvent('scroll', 393, 243, { deltaX: 0, deltaY: -20 });
 
             var transform = plot.attr('transform');
 
@@ -771,7 +771,7 @@ describe('Test click interactions:', function() {
             var translate = Drawing.getTranslate(mockEl),
                 scale = Drawing.getScale(mockEl);
 
-            expect([translate.x, translate.y]).toBeCloseToArray([61.070, 97.712]);
+            expect([translate.x, translate.y]).toBeCloseToArray([-25.941, 43.911]);
             expect([scale.x, scale.y]).toBeCloseToArray([1.221, 1.221]);
         });
     });
diff --git a/test/jasmine/tests/gl2d_click_test.js b/test/jasmine/tests/gl2d_click_test.js
index f53282b6f8f..ac6df004e25 100644
--- a/test/jasmine/tests/gl2d_click_test.js
+++ b/test/jasmine/tests/gl2d_click_test.js
@@ -246,12 +246,20 @@ describe('Test hover and click interactions', function() {
             pointNumber: 0
         });
 
+        // after the restyle, autorange changes the y range
+        var run2 = makeRunner([435, 106], {
+            x: 8,
+            y: 18,
+            curveNumber: 2,
+            pointNumber: 0
+        });
+
         Plotly.plot(gd, _mock)
         .then(run)
         .then(function() {
             return Plotly.restyle(gd, 'visible', false, [1]);
         })
-        .then(run)
+        .then(run2)
         .catch(fail)
         .then(done);
     });
@@ -269,12 +277,23 @@ describe('Test hover and click interactions', function() {
             pointNumber: 0
         });
 
+        // after the restyle, autorange changes the x AND y ranges
+        // I don't get why the x range changes, nor why the y changes in
+        // a different way than in the previous test, but they do look
+        // correct on the screen during the test.
+        var run2 = makeRunner([426, 116], {
+            x: 8,
+            y: 18,
+            curveNumber: 2,
+            pointNumber: 0
+        });
+
         Plotly.plot(gd, _mock)
         .then(run)
         .then(function() {
             return Plotly.restyle(gd, 'visible', false, [1]);
         })
-        .then(run)
+        .then(run2)
         .catch(fail)
         .then(done);
     });
diff --git a/test/jasmine/tests/gl_plot_interact_test.js b/test/jasmine/tests/gl_plot_interact_test.js
index c9f4f0ae299..d0b5ac0e199 100644
--- a/test/jasmine/tests/gl_plot_interact_test.js
+++ b/test/jasmine/tests/gl_plot_interact_test.js
@@ -716,6 +716,13 @@ describe('Test gl2d plots', function() {
         destroyGraphDiv();
     });
 
+    function mouseTo(p0, p1) {
+        mouseEvent('mousemove', p0[0], p0[1]);
+        mouseEvent('mousedown', p0[0], p0[1], { buttons: 1 });
+        mouseEvent('mousemove', p1[0], p1[1], { buttons: 1 });
+        mouseEvent('mouseup', p1[0], p1[1]);
+    }
+
     it('should respond to drag interactions', function(done) {
         var _mock = Lib.extendDeep({}, mock);
         var relayoutCallback = jasmine.createSpy('relayoutCallback');
@@ -726,13 +733,6 @@ describe('Test gl2d plots', function() {
         var newY = [-1.2962655110623016, 4.768255474123081];
         var precision = 5;
 
-        function mouseTo(p0, p1) {
-            mouseEvent('mousemove', p0[0], p0[1]);
-            mouseEvent('mousedown', p0[0], p0[1], { buttons: 1 });
-            mouseEvent('mousemove', p1[0], p1[1], { buttons: 1 });
-            mouseEvent('mouseup', p1[0], p1[1]);
-        }
-
         Plotly.plot(gd, _mock)
         .then(delay)
         .then(function() {
@@ -871,6 +871,103 @@ describe('Test gl2d plots', function() {
         .then(done);
 
     });
+
+    it('supports 1D and 2D Zoom', function(done) {
+        var centerX, centerY;
+        Plotly.newPlot(gd,
+            [{type: 'scattergl', x: [1, 15], y: [1, 15]}],
+            {
+                width: 400,
+                height: 400,
+                margin: {t: 100, b: 100, l: 100, r: 100},
+                xaxis: {range: [0, 16]},
+                yaxis: {range: [0, 16]}
+            }
+        )
+        .then(function() {
+            var bBox = gd.getBoundingClientRect();
+            centerX = bBox.left + 200;
+            centerY = bBox.top + 200;
+
+            // 2D
+            mouseTo([centerX - 50, centerY], [centerX + 50, centerY + 50]);
+            expect(gd.layout.xaxis.range).toBeCloseToArray([4, 12], 3);
+            expect(gd.layout.yaxis.range).toBeCloseToArray([4, 8], 3);
+
+            // x only
+            mouseTo([centerX - 50, centerY], [centerX, centerY + 5]);
+            expect(gd.layout.xaxis.range).toBeCloseToArray([6, 8], 3);
+            expect(gd.layout.yaxis.range).toBeCloseToArray([4, 8], 3);
+
+            // y only
+            mouseTo([centerX, centerY - 50], [centerX - 5, centerY + 50]);
+            expect(gd.layout.xaxis.range).toBeCloseToArray([6, 8], 3);
+            expect(gd.layout.yaxis.range).toBeCloseToArray([5, 7], 3);
+
+            // no change - too small
+            mouseTo([centerX, centerY], [centerX - 5, centerY + 5]);
+            expect(gd.layout.xaxis.range).toBeCloseToArray([6, 8], 3);
+            expect(gd.layout.yaxis.range).toBeCloseToArray([5, 7], 3);
+        })
+        .catch(fail)
+        .then(done);
+    });
+
+    it('supports axis constraints with zoom', function(done) {
+        var centerX, centerY;
+        Plotly.newPlot(gd,
+            [{type: 'scattergl', x: [1, 15], y: [1, 15]}],
+            {
+                width: 400,
+                height: 400,
+                margin: {t: 100, b: 100, l: 100, r: 100},
+                xaxis: {range: [0, 16]},
+                yaxis: {range: [0, 16]}
+            }
+        )
+        .then(function() {
+            var bBox = gd.getBoundingClientRect();
+            centerX = bBox.left + 200;
+            centerY = bBox.top + 200;
+
+            return Plotly.relayout(gd, {
+                'yaxis.scaleanchor': 'x',
+                'yaxis.scaleratio': 2
+            });
+        })
+        .then(function() {
+            // x range is adjusted to fit constraint
+            expect(gd.layout.xaxis.range).toBeCloseToArray([-8, 24], 3);
+            expect(gd.layout.yaxis.range).toBeCloseToArray([0, 16], 3);
+
+            // now there should only be 2D zooming
+            // dy>>dx
+            mouseTo([centerX, centerY], [centerX - 1, centerY - 50]);
+            expect(gd.layout.xaxis.range).toBeCloseToArray([0, 8], 3);
+            expect(gd.layout.yaxis.range).toBeCloseToArray([8, 12], 3);
+
+            // dx>>dy
+            mouseTo([centerX, centerY], [centerX + 50, centerY + 1]);
+            expect(gd.layout.xaxis.range).toBeCloseToArray([4, 6], 3);
+            expect(gd.layout.yaxis.range).toBeCloseToArray([9, 10], 3);
+
+            // no change - too small
+            mouseTo([centerX, centerY], [centerX - 5, centerY + 5]);
+            expect(gd.layout.xaxis.range).toBeCloseToArray([4, 6], 3);
+            expect(gd.layout.yaxis.range).toBeCloseToArray([9, 10], 3);
+
+            return Plotly.relayout(gd, {
+                'xaxis.autorange': true,
+                'yaxis.autorange': true
+            });
+        })
+        .then(function() {
+            expect(gd.layout.xaxis.range).toBeCloseToArray([-8.09195, 24.09195], 3);
+            expect(gd.layout.yaxis.range).toBeCloseToArray([-0.04598, 16.04598], 3);
+        })
+        .catch(fail)
+        .then(done);
+    });
 });
 
 describe('Test removal of gl contexts', function() {
diff --git a/test/jasmine/tests/plot_interact_test.js b/test/jasmine/tests/plot_interact_test.js
index d6a0efda994..f8fba331d24 100644
--- a/test/jasmine/tests/plot_interact_test.js
+++ b/test/jasmine/tests/plot_interact_test.js
@@ -6,10 +6,10 @@ var Lib = require('@src/lib');
 var createGraphDiv = require('../assets/create_graph_div');
 var destroyGraphDiv = require('../assets/destroy_graph_div');
 var customMatchers = require('../assets/custom_matchers');
-var mouseEvent = require('../assets/mouse_event');
-var selectButton = require('../assets/modebar_button');
 
-var MODEBAR_DELAY = 500;
+// This suite is more of a test of the structure of interaction elements on
+// various plot types. Tests of actual mouse interactions on cartesian plots
+// are in cartesian_interact_test.js
 
 describe('Test plot structure', function() {
     'use strict';
@@ -165,122 +165,6 @@ describe('Test plot structure', function() {
             });
         });
 
-        describe('scatter drag', function() {
-
-            var mock = require('@mocks/10.json'),
-                gd, modeBar, relayoutCallback;
-
-            beforeEach(function(done) {
-                gd = createGraphDiv();
-
-                Plotly.plot(gd, mock.data, mock.layout).then(function() {
-
-                    modeBar = gd._fullLayout._modeBar;
-                    relayoutCallback = jasmine.createSpy('relayoutCallback');
-
-                    gd.on('plotly_relayout', relayoutCallback);
-
-                    done();
-                });
-            });
-
-            it('scatter plot should respond to drag interactions', function(done) {
-
-                jasmine.addMatchers(customMatchers);
-
-                var precision = 5;
-
-                var buttonPan = selectButton(modeBar, 'pan2d');
-
-                var originalX = [-0.6225, 5.5];
-                var originalY = [-1.6340975059013805, 7.166241526218911];
-
-                var newX = [-2.0255729166666665, 4.096927083333333];
-                var newY = [-0.3769062155984817, 8.42343281652181];
-
-                expect(gd.layout.xaxis.range).toBeCloseToArray(originalX, precision);
-                expect(gd.layout.yaxis.range).toBeCloseToArray(originalY, precision);
-
-                // Switch to pan mode
-                expect(buttonPan.isActive()).toBe(false); // initially, zoom is active
-                buttonPan.click();
-                expect(buttonPan.isActive()).toBe(true); // switched on dragmode
-
-                // Switching mode must not change visible range
-                expect(gd.layout.xaxis.range).toBeCloseToArray(originalX, precision);
-                expect(gd.layout.yaxis.range).toBeCloseToArray(originalY, precision);
-
-                setTimeout(function() {
-
-                    expect(relayoutCallback).toHaveBeenCalledTimes(1);
-                    relayoutCallback.calls.reset();
-
-                    // Drag scene along the X axis
-
-                    mouseEvent('mousedown', 110, 150);
-                    mouseEvent('mousemove', 220, 150);
-                    mouseEvent('mouseup', 220, 150);
-
-                    expect(gd.layout.xaxis.range).toBeCloseToArray(newX, precision);
-                    expect(gd.layout.yaxis.range).toBeCloseToArray(originalY, precision);
-
-                    // Drag scene back along the X axis (not from the same starting point but same X delta)
-
-                    mouseEvent('mousedown', 280, 150);
-                    mouseEvent('mousemove', 170, 150);
-                    mouseEvent('mouseup', 170, 150);
-
-                    expect(gd.layout.xaxis.range).toBeCloseToArray(originalX, precision);
-                    expect(gd.layout.yaxis.range).toBeCloseToArray(originalY, precision);
-
-                    // Drag scene along the Y axis
-
-                    mouseEvent('mousedown', 110, 150);
-                    mouseEvent('mousemove', 110, 190);
-                    mouseEvent('mouseup', 110, 190);
-
-                    expect(gd.layout.xaxis.range).toBeCloseToArray(originalX, precision);
-                    expect(gd.layout.yaxis.range).toBeCloseToArray(newY, precision);
-
-                    // Drag scene back along the Y axis (not from the same starting point but same Y delta)
-
-                    mouseEvent('mousedown', 280, 130);
-                    mouseEvent('mousemove', 280, 90);
-                    mouseEvent('mouseup', 280, 90);
-
-                    expect(gd.layout.xaxis.range).toBeCloseToArray(originalX, precision);
-                    expect(gd.layout.yaxis.range).toBeCloseToArray(originalY, precision);
-
-                    // Drag scene along both the X and Y axis
-
-                    mouseEvent('mousedown', 110, 150);
-                    mouseEvent('mousemove', 220, 190);
-                    mouseEvent('mouseup', 220, 190);
-
-                    expect(gd.layout.xaxis.range).toBeCloseToArray(newX, precision);
-                    expect(gd.layout.yaxis.range).toBeCloseToArray(newY, precision);
-
-                    // Drag scene back along the X and Y axis (not from the same starting point but same delta vector)
-
-                    mouseEvent('mousedown', 280, 130);
-                    mouseEvent('mousemove', 170, 90);
-                    mouseEvent('mouseup', 170, 90);
-
-                    expect(gd.layout.xaxis.range).toBeCloseToArray(originalX, precision);
-                    expect(gd.layout.yaxis.range).toBeCloseToArray(originalY, precision);
-
-                    setTimeout(function() {
-
-                        expect(relayoutCallback).toHaveBeenCalledTimes(6); // X and back; Y and back; XY and back
-
-                        done();
-
-                    }, MODEBAR_DELAY);
-
-                }, MODEBAR_DELAY);
-            });
-        });
-
         describe('contour/heatmap traces', function() {
             var mock = require('@mocks/connectgaps_2d.json');
             var gd;