diff --git a/src/components/legend/defaults.js b/src/components/legend/defaults.js
index ada758b9955..5b653c02884 100644
--- a/src/components/legend/defaults.js
+++ b/src/components/legend/defaults.js
@@ -86,7 +86,7 @@ module.exports = function legendDefaults(layoutIn, layoutOut, fullData) {
     coerce('orientation');
     if(containerOut.orientation === 'h') {
         var xaxis = layoutIn.xaxis;
-        if(xaxis && xaxis.rangeslider && xaxis.rangeslider.visible) {
+        if(Registry.getComponentMethod('rangeslider', 'isVisible')(xaxis)) {
             defaultX = 0;
             defaultXAnchor = 'left';
             defaultY = 1.1;
diff --git a/src/components/rangeslider/draw.js b/src/components/rangeslider/draw.js
index 76ef7a04a78..1a7fe176df6 100644
--- a/src/components/rangeslider/draw.js
+++ b/src/components/rangeslider/draw.js
@@ -19,7 +19,7 @@ var Color = require('../color');
 var Titles = require('../titles');
 
 var Cartesian = require('../../plots/cartesian');
-var Axes = require('../../plots/cartesian/axes');
+var axisIDs = require('../../plots/cartesian/axis_ids');
 
 var dragElement = require('../dragelement');
 var setCursor = require('../../lib/setcursor');
@@ -27,8 +27,13 @@ var setCursor = require('../../lib/setcursor');
 var constants = require('./constants');
 
 module.exports = function(gd) {
-    var fullLayout = gd._fullLayout,
-        rangeSliderData = makeRangeSliderData(fullLayout);
+    var fullLayout = gd._fullLayout;
+    var rangeSliderData = fullLayout._rangeSliderData;
+    for(var i = 0; i < rangeSliderData.length; i++) {
+        var opts = rangeSliderData[i][constants.name];
+        // fullLayout._uid may not exist when we call makeData
+        opts._clipId = opts._id + '-' + fullLayout._uid;
+    }
 
     /*
      * <g container />
@@ -55,10 +60,6 @@ module.exports = function(gd) {
         .selectAll('g.' + constants.containerClassName)
         .data(rangeSliderData, keyFunction);
 
-    rangeSliders.enter().append('g')
-        .classed(constants.containerClassName, true)
-        .attr('pointer-events', 'all');
-
     // remove exiting sliders and their corresponding clip paths
     rangeSliders.exit().each(function(axisOpts) {
         var opts = axisOpts[constants.name];
@@ -68,12 +69,16 @@ module.exports = function(gd) {
     // return early if no range slider is visible
     if(rangeSliderData.length === 0) return;
 
+    rangeSliders.enter().append('g')
+        .classed(constants.containerClassName, true)
+        .attr('pointer-events', 'all');
+
     // for all present range sliders
     rangeSliders.each(function(axisOpts) {
-        var rangeSlider = d3.select(this),
-            opts = axisOpts[constants.name],
-            oppAxisOpts = fullLayout[Axes.id2name(axisOpts.anchor)],
-            oppAxisRangeOpts = opts[Axes.id2name(axisOpts.anchor)];
+        var rangeSlider = d3.select(this);
+        var opts = axisOpts[constants.name];
+        var oppAxisOpts = fullLayout[axisIDs.id2name(axisOpts.anchor)];
+        var oppAxisRangeOpts = opts[axisIDs.id2name(axisOpts.anchor)];
 
         // update range
         // Expand slider range to the axis range
@@ -97,19 +102,9 @@ module.exports = function(gd) {
         var domain = axisOpts.domain;
         var tickHeight = (axisOpts._boundingBox || {}).height || 0;
 
-        var oppBottom = Infinity;
-        var subplotData = Axes.getSubplots(gd, axisOpts);
-        for(var i = 0; i < subplotData.length; i++) {
-            var oppAxis = Axes.getFromId(gd, subplotData[i].substr(subplotData[i].indexOf('y')));
-            oppBottom = Math.min(oppBottom, oppAxis.domain[0]);
-        }
-
-        opts._id = constants.name + axisOpts._id;
-        opts._clipId = opts._id + '-' + fullLayout._uid;
+        var oppBottom = opts._oppBottom;
 
         opts._width = graphSize.w * (domain[1] - domain[0]);
-        opts._height = (fullLayout.height - margin.b - margin.t) * opts.thickness;
-        opts._offsetShift = Math.floor(opts.borderwidth / 2);
 
         var x = Math.round(margin.l + (graphSize.w * domain[0]));
 
@@ -177,36 +172,9 @@ module.exports = function(gd) {
                 }
             });
         }
-
-        // update margins
-        Plots.autoMargin(gd, opts._id, {
-            x: domain[0],
-            y: oppBottom,
-            l: 0,
-            r: 0,
-            t: 0,
-            b: opts._height + margin.b + tickHeight,
-            pad: constants.extraPad + opts._offsetShift * 2
-        });
     });
 };
 
-function makeRangeSliderData(fullLayout) {
-    var axes = Axes.list({ _fullLayout: fullLayout }, 'x', true),
-        name = constants.name,
-        out = [];
-
-    if(fullLayout._has('gl2d')) return out;
-
-    for(var i = 0; i < axes.length; i++) {
-        var ax = axes[i];
-
-        if(ax[name] && ax[name].visible) out.push(ax);
-    }
-
-    return out;
-}
-
 function setupDragElement(rangeSlider, gd, axisOpts, opts) {
     var slideBox = rangeSlider.select('rect.' + constants.slideBoxClassName).node(),
         grabAreaMin = rangeSlider.select('rect.' + constants.grabAreaMinClassName).node(),
@@ -393,11 +361,10 @@ function addClipPath(rangeSlider, gd, axisOpts, opts) {
 }
 
 function drawRangePlot(rangeSlider, gd, axisOpts, opts) {
-    var subplotData = Axes.getSubplots(gd, axisOpts),
-        calcData = gd.calcdata;
+    var calcData = gd.calcdata;
 
     var rangePlots = rangeSlider.selectAll('g.' + constants.rangePlotClassName)
-        .data(subplotData, Lib.identity);
+        .data(axisOpts._subplotsWith, Lib.identity);
 
     rangePlots.enter().append('g')
         .attr('class', function(id) { return constants.rangePlotClassName + ' ' + id; })
@@ -413,7 +380,7 @@ function drawRangePlot(rangeSlider, gd, axisOpts, opts) {
         var plotgroup = d3.select(this),
             isMainPlot = (i === 0);
 
-        var oppAxisOpts = Axes.getFromId(gd, id, 'y'),
+        var oppAxisOpts = axisIDs.getFromId(gd, id, 'y'),
             oppAxisName = oppAxisOpts._name,
             oppAxisRangeOpts = opts[oppAxisName];
 
@@ -445,6 +412,11 @@ function drawRangePlot(rangeSlider, gd, axisOpts, opts) {
         var xa = mockFigure._fullLayout.xaxis;
         var ya = mockFigure._fullLayout[oppAxisName];
 
+        xa.clearCalc();
+        xa.setScale();
+        ya.clearCalc();
+        ya.setScale();
+
         var plotinfo = {
             id: id,
             plotgroup: plotgroup,
diff --git a/src/components/rangeslider/helpers.js b/src/components/rangeslider/helpers.js
new file mode 100644
index 00000000000..6009d0d51a1
--- /dev/null
+++ b/src/components/rangeslider/helpers.js
@@ -0,0 +1,67 @@
+/**
+* Copyright 2012-2018, 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 axisIDs = require('../../plots/cartesian/axis_ids');
+var constants = require('./constants');
+var name = constants.name;
+
+function isVisible(ax) {
+    var rangeSlider = ax && ax[name];
+    return rangeSlider && rangeSlider.visible;
+}
+exports.isVisible = isVisible;
+
+exports.makeData = function(fullLayout) {
+    var axes = axisIDs.list({ _fullLayout: fullLayout }, 'x', true);
+    var margin = fullLayout.margin;
+    var rangeSliderData = [];
+
+    if(!fullLayout._has('gl2d')) {
+        for(var i = 0; i < axes.length; i++) {
+            var ax = axes[i];
+
+            if(isVisible(ax)) {
+                rangeSliderData.push(ax);
+
+                var opts = ax[name];
+                opts._id = name + ax._id;
+                opts._height = (fullLayout.height - margin.b - margin.t) * opts.thickness;
+                opts._offsetShift = Math.floor(opts.borderwidth / 2);
+            }
+        }
+    }
+
+    fullLayout._rangeSliderData = rangeSliderData;
+};
+
+exports.autoMarginOpts = function(gd, ax) {
+    var opts = ax[name];
+
+    var oppBottom = Infinity;
+    var counterAxes = ax._counterAxes;
+    for(var j = 0; j < counterAxes.length; j++) {
+        var counterId = counterAxes[j];
+        var oppAxis = axisIDs.getFromId(gd, counterId);
+        oppBottom = Math.min(oppBottom, oppAxis.domain[0]);
+    }
+    opts._oppBottom = oppBottom;
+
+    var tickHeight = (ax.side === 'bottom' && ax._boundingBox.height) || 0;
+
+    return {
+        x: 0,
+        y: oppBottom,
+        l: 0,
+        r: 0,
+        t: 0,
+        b: opts._height + gd._fullLayout.margin.b + tickHeight,
+        pad: constants.extraPad + opts._offsetShift * 2
+    };
+};
diff --git a/src/components/rangeslider/index.js b/src/components/rangeslider/index.js
index 2983d72c58e..fd3395ff114 100644
--- a/src/components/rangeslider/index.js
+++ b/src/components/rangeslider/index.js
@@ -11,6 +11,7 @@
 var Lib = require('../../lib');
 var attrs = require('./attributes');
 var oppAxisAttrs = require('./oppaxis_attributes');
+var helpers = require('./helpers');
 
 module.exports = {
     moduleType: 'component',
@@ -29,5 +30,8 @@ module.exports = {
     layoutAttributes: require('./attributes'),
     handleDefaults: require('./defaults'),
     calcAutorange: require('./calc_autorange'),
-    draw: require('./draw')
+    draw: require('./draw'),
+    isVisible: helpers.isVisible,
+    makeData: helpers.makeData,
+    autoMarginOpts: helpers.autoMarginOpts
 };
diff --git a/src/components/shapes/calc_autorange.js b/src/components/shapes/calc_autorange.js
index a05a6bc11c2..b1aa79ad935 100644
--- a/src/components/shapes/calc_autorange.js
+++ b/src/components/shapes/calc_autorange.js
@@ -6,7 +6,6 @@
 * LICENSE file in the root directory of this source tree.
 */
 
-
 'use strict';
 
 var Lib = require('../../lib');
@@ -84,7 +83,7 @@ function calcPaddingOptions(lineWidth, sizeMode, v0, v1, path, isYAxis) {
 }
 
 function shapeBounds(ax, v0, v1, path, paramsToUse) {
-    var convertVal = (ax.type === 'category') ? ax.r2c : ax.d2c;
+    var convertVal = (ax.type === 'category' || ax.type === 'multicategory') ? ax.r2c : ax.d2c;
 
     if(v0 !== undefined) return [convertVal(v0), convertVal(v1)];
     if(!path) return;
diff --git a/src/lib/array.js b/src/lib/array.js
index a80f4b3f8ca..cd96886d34e 100644
--- a/src/lib/array.js
+++ b/src/lib/array.js
@@ -132,3 +132,26 @@ exports.concat = function() {
     }
     return out;
 };
+
+exports.maxRowLength = function(z) {
+    return _rowLength(z, Math.max, 0);
+};
+
+exports.minRowLength = function(z) {
+    return _rowLength(z, Math.min, Infinity);
+};
+
+function _rowLength(z, fn, len0) {
+    if(isArrayOrTypedArray(z)) {
+        if(isArrayOrTypedArray(z[0])) {
+            var len = len0;
+            for(var i = 0; i < z.length; i++) {
+                len = fn(len, z[i].length);
+            }
+            return len;
+        } else {
+            return z.length;
+        }
+    }
+    return 0;
+}
diff --git a/src/lib/index.js b/src/lib/index.js
index 2d9de4b1ac1..9c3d46c7841 100644
--- a/src/lib/index.js
+++ b/src/lib/index.js
@@ -31,6 +31,8 @@ lib.isArrayOrTypedArray = arrayModule.isArrayOrTypedArray;
 lib.isArray1D = arrayModule.isArray1D;
 lib.ensureArray = arrayModule.ensureArray;
 lib.concat = arrayModule.concat;
+lib.maxRowLength = arrayModule.maxRowLength;
+lib.minRowLength = arrayModule.minRowLength;
 
 var modModule = require('./mod');
 lib.mod = modModule.mod;
diff --git a/src/plot_api/plot_api.js b/src/plot_api/plot_api.js
index 828797411f2..d95a31ca72e 100644
--- a/src/plot_api/plot_api.js
+++ b/src/plot_api/plot_api.js
@@ -332,8 +332,7 @@ exports.plot = function(gd, data, layout, config) {
         return Lib.syncOrAsync([
             Registry.getComponentMethod('shapes', 'calcAutorange'),
             Registry.getComponentMethod('annotations', 'calcAutorange'),
-            doAutoRangeAndConstraints,
-            Registry.getComponentMethod('rangeslider', 'calcAutorange')
+            doAutoRangeAndConstraints
         ], gd);
     }
 
@@ -345,6 +344,11 @@ exports.plot = function(gd, data, layout, config) {
         // store initial ranges *after* enforcing constraints, otherwise
         // we will never look like we're at the initial ranges
         if(graphWasEmpty) Axes.saveRangeInitial(gd);
+
+        // this one is different from shapes/annotations calcAutorange
+        // the others incorporate those components into ax._extremes,
+        // this one actually sets the ranges in rangesliders.
+        Registry.getComponentMethod('rangeslider', 'calcAutorange')(gd);
     }
 
     // draw ticks, titles, and calculate axis scaling (._b, ._m)
diff --git a/src/plot_api/subroutines.js b/src/plot_api/subroutines.js
index 68874a4eae7..3033462e785 100644
--- a/src/plot_api/subroutines.js
+++ b/src/plot_api/subroutines.js
@@ -55,7 +55,7 @@ function lsInner(gd) {
     var gs = fullLayout._size;
     var pad = gs.p;
     var axList = Axes.list(gd, '', true);
-    var i, subplot, plotinfo, xa, ya;
+    var i, subplot, plotinfo, ax, xa, ya;
 
     fullLayout._paperdiv.style({
         width: (gd._context.responsive && fullLayout.autosize && !gd._context._hasZeroWidth && !gd.layout.width) ? '100%' : fullLayout.width + 'px',
@@ -91,10 +91,7 @@ function lsInner(gd) {
 
     // some preparation of axis position info
     for(i = 0; i < axList.length; i++) {
-        var ax = axList[i];
-
-        // reset scale in case the margins have changed
-        ax.setScale();
+        ax = axList[i];
 
         var counterAx = ax._anchorAxis;
 
@@ -113,11 +110,6 @@ function lsInner(gd) {
         ax._mainMirrorPosition = (ax.mirror && counterAx) ?
             getLinePosition(ax, counterAx,
                 alignmentConstants.OPPOSITE_SIDE[ax.side]) : null;
-
-        // Figure out which subplot to draw ticks, labels, & axis lines on
-        // do this as a separate loop so we already have all the
-        // _mainAxis and _anchorAxis links set
-        ax._mainSubplot = findMainSubplot(ax, fullLayout);
     }
 
     // figure out which backgrounds we need to draw,
@@ -358,48 +350,6 @@ function lsInner(gd) {
     return gd._promises.length && Promise.all(gd._promises);
 }
 
-function findMainSubplot(ax, fullLayout) {
-    var subplotList = fullLayout._subplots;
-    var ids = subplotList.cartesian.concat(subplotList.gl2d || []);
-    var mockGd = {_fullLayout: fullLayout};
-
-    var isX = ax._id.charAt(0) === 'x';
-    var anchorAx = ax._mainAxis._anchorAxis;
-    var mainSubplotID = '';
-    var nextBestMainSubplotID = '';
-    var anchorID = '';
-
-    // First try the main ID with the anchor
-    if(anchorAx) {
-        anchorID = anchorAx._mainAxis._id;
-        mainSubplotID = isX ? (ax._id + anchorID) : (anchorID + ax._id);
-    }
-
-    // Then look for a subplot with the counteraxis overlaying the anchor
-    // If that fails just use the first subplot including this axis
-    if(!mainSubplotID || !fullLayout._plots[mainSubplotID]) {
-        mainSubplotID = '';
-
-        for(var j = 0; j < ids.length; j++) {
-            var id = ids[j];
-            var yIndex = id.indexOf('y');
-            var idPart = isX ? id.substr(0, yIndex) : id.substr(yIndex);
-            var counterPart = isX ? id.substr(yIndex) : id.substr(0, yIndex);
-
-            if(idPart === ax._id) {
-                if(!nextBestMainSubplotID) nextBestMainSubplotID = id;
-                var counterAx = Axes.getFromId(mockGd, counterPart);
-                if(anchorID && counterAx.overlaying === anchorID) {
-                    mainSubplotID = id;
-                    break;
-                }
-            }
-        }
-    }
-
-    return mainSubplotID || nextBestMainSubplotID;
-}
-
 function shouldShowLinesOrTicks(ax, subplot) {
     return (ax.ticks || ax.showline) &&
         (subplot === ax._mainSubplot || ax.mirror === 'all' || ax.mirror === 'allticks');
@@ -752,6 +702,8 @@ exports.doAutoRangeAndConstraints = function(gd) {
     for(var i = 0; i < axList.length; i++) {
         var ax = axList[i];
         cleanAxisConstraints(gd, ax);
+        // in case margins changed, update scale
+        ax.setScale();
         doAutoRange(gd, ax);
     }
 
diff --git a/src/plots/cartesian/autorange.js b/src/plots/cartesian/autorange.js
index 3fe613d5e7d..c77914096f7 100644
--- a/src/plots/cartesian/autorange.js
+++ b/src/plots/cartesian/autorange.js
@@ -237,10 +237,6 @@ function concatExtremes(gd, ax) {
 }
 
 function doAutoRange(gd, ax) {
-    if(!ax._length) ax.setScale();
-
-    var axIn;
-
     if(ax.autorange) {
         ax.range = getAutoRange(gd, ax);
 
@@ -250,7 +246,7 @@ function doAutoRange(gd, ax) {
         // doAutoRange will get called on fullLayout,
         // but we want to report its results back to layout
 
-        axIn = ax._input;
+        var axIn = ax._input;
 
         // before we edit _input, store preGUI values
         var edits = {};
@@ -262,15 +258,16 @@ function doAutoRange(gd, ax) {
         axIn.autorange = ax.autorange;
     }
 
-    if(ax._anchorAxis && ax._anchorAxis.rangeslider) {
-        var axeRangeOpts = ax._anchorAxis.rangeslider[ax._name];
+    var anchorAx = ax._anchorAxis;
+
+    if(anchorAx && anchorAx.rangeslider) {
+        var axeRangeOpts = anchorAx.rangeslider[ax._name];
         if(axeRangeOpts) {
             if(axeRangeOpts.rangemode === 'auto') {
                 axeRangeOpts.range = getAutoRange(gd, ax);
             }
         }
-        axIn = ax._anchorAxis._input;
-        axIn.rangeslider[ax._name] = Lib.extendFlat({}, axeRangeOpts);
+        anchorAx._input.rangeslider[ax._name] = Lib.extendFlat({}, axeRangeOpts);
     }
 }
 
diff --git a/src/plots/cartesian/axes.js b/src/plots/cartesian/axes.js
index f45b50d6318..9adbe25fb55 100644
--- a/src/plots/cartesian/axes.js
+++ b/src/plots/cartesian/axes.js
@@ -157,6 +157,7 @@ var getDataConversions = axes.getDataConversions = function(gd, trace, target, t
                 ax.d2c(targetArray[i]);
             }
         }
+        // TODO what to do for transforms?
     } else {
         ax = axes.getFromTrace(gd, trace, d2cTarget);
     }
@@ -197,7 +198,7 @@ axes.counterLetter = function(id) {
 axes.minDtick = function(ax, newDiff, newFirst, allow) {
     // doesn't make sense to do forced min dTick on log or category axes,
     // and the plot itself may decide to cancel (ie non-grouped bars)
-    if(['log', 'category'].indexOf(ax.type) !== -1 || !allow) {
+    if(['log', 'category', 'multicategory'].indexOf(ax.type) !== -1 || !allow) {
         ax._minDtick = 0;
     }
     // undefined means there's nothing there yet
@@ -229,18 +230,15 @@ axes.minDtick = function(ax, newDiff, newFirst, allow) {
 // save a copy of the initial axis ranges in fullLayout
 // use them in mode bar and dblclick events
 axes.saveRangeInitial = function(gd, overwrite) {
-    var axList = axes.list(gd, '', true),
-        hasOneAxisChanged = false;
+    var axList = axes.list(gd, '', true);
+    var hasOneAxisChanged = false;
 
     for(var i = 0; i < axList.length; i++) {
         var ax = axList[i];
-
         var isNew = (ax._rangeInitial === undefined);
-        var hasChanged = (
-            isNew || !(
-                ax.range[0] === ax._rangeInitial[0] &&
-                ax.range[1] === ax._rangeInitial[1]
-            )
+        var hasChanged = isNew || !(
+            ax.range[0] === ax._rangeInitial[0] &&
+            ax.range[1] === ax._rangeInitial[1]
         );
 
         if((isNew && ax.autorange === false) || (overwrite && hasChanged)) {
@@ -254,21 +252,16 @@ axes.saveRangeInitial = function(gd, overwrite) {
 
 // save a copy of the initial spike visibility
 axes.saveShowSpikeInitial = function(gd, overwrite) {
-    var axList = axes.list(gd, '', true),
-        hasOneAxisChanged = false,
-        allSpikesEnabled = 'on';
+    var axList = axes.list(gd, '', true);
+    var hasOneAxisChanged = false;
+    var allSpikesEnabled = 'on';
 
     for(var i = 0; i < axList.length; i++) {
         var ax = axList[i];
-
         var isNew = (ax._showSpikeInitial === undefined);
-        var hasChanged = (
-            isNew || !(
-                ax.showspikes === ax._showspikes
-            )
-        );
+        var hasChanged = isNew || !(ax.showspikes === ax._showspikes);
 
-        if((isNew) || (overwrite && hasChanged)) {
+        if(isNew || (overwrite && hasChanged)) {
             ax._showSpikeInitial = ax.showspikes;
             hasOneAxisChanged = true;
         }
@@ -285,7 +278,7 @@ axes.autoBin = function(data, ax, nbins, is2d, calendar, size) {
     var dataMin = Lib.aggNums(Math.min, null, data);
     var dataMax = Lib.aggNums(Math.max, null, data);
 
-    if(ax.type === 'category') {
+    if(ax.type === 'category' || ax.type === 'multicategory') {
         return {
             start: dataMin - 0.5,
             end: dataMax + 0.5,
@@ -303,8 +296,7 @@ axes.autoBin = function(data, ax, nbins, is2d, calendar, size) {
             type: 'linear',
             range: [dataMin, dataMax]
         };
-    }
-    else {
+    } else {
         dummyAx = {
             type: ax.type,
             range: Lib.simpleMap([dataMin, dataMax], ax.c2r, 0, calendar),
@@ -389,10 +381,10 @@ axes.autoBin = function(data, ax, nbins, is2d, calendar, size) {
 
 
 function autoShiftNumericBins(binStart, data, ax, dataMin, dataMax) {
-    var edgecount = 0,
-        midcount = 0,
-        intcount = 0,
-        blankCount = 0;
+    var edgecount = 0;
+    var midcount = 0;
+    var intcount = 0;
+    var blankCount = 0;
 
     function nearEdge(v) {
         // is a value within 1% of a bin edge?
@@ -483,14 +475,14 @@ axes.prepTicks = function(ax) {
 
     // calculate max number of (auto) ticks to display based on plot size
     if(ax.tickmode === 'auto' || !ax.dtick) {
-        var nt = ax.nticks,
-            minPx;
+        var nt = ax.nticks;
+        var minPx;
+
         if(!nt) {
-            if(ax.type === 'category') {
+            if(ax.type === 'category' || ax.type === 'multicategory') {
                 minPx = ax.tickfont ? (ax.tickfont.size || 12) * 1.2 : 15;
                 nt = ax._length / minPx;
-            }
-            else {
+            } else {
                 minPx = ax._id.charAt(0) === 'y' ? 40 : 80;
                 nt = Lib.constrain(ax._length / minPx, 4, 9) + 1;
             }
@@ -552,7 +544,7 @@ axes.calcTicks = function calcTicks(ax) {
 
     // return the full set of tick vals
     var vals = [];
-    if(ax.type === 'category') {
+    if(ax.type === 'category' || ax.type === 'multicategory') {
         endTick = (axrev) ? Math.max(-0.5, endTick) :
             Math.min(ax._categories.length - 0.5, endTick);
     }
@@ -596,23 +588,22 @@ axes.calcTicks = function calcTicks(ax) {
 };
 
 function arrayTicks(ax) {
-    var vals = ax.tickvals,
-        text = ax.ticktext,
-        ticksOut = new Array(vals.length),
-        rng = Lib.simpleMap(ax.range, ax.r2l),
-        r0expanded = rng[0] * 1.0001 - rng[1] * 0.0001,
-        r1expanded = rng[1] * 1.0001 - rng[0] * 0.0001,
-        tickMin = Math.min(r0expanded, r1expanded),
-        tickMax = Math.max(r0expanded, r1expanded),
-        vali,
-        i,
-        j = 0;
+    var vals = ax.tickvals;
+    var text = ax.ticktext;
+    var ticksOut = new Array(vals.length);
+    var rng = Lib.simpleMap(ax.range, ax.r2l);
+    var r0expanded = rng[0] * 1.0001 - rng[1] * 0.0001;
+    var r1expanded = rng[1] * 1.0001 - rng[0] * 0.0001;
+    var tickMin = Math.min(r0expanded, r1expanded);
+    var tickMax = Math.max(r0expanded, r1expanded);
+    var j = 0;
 
     // without a text array, just format the given values as any other ticks
     // except with more precision to the numbers
     if(!Array.isArray(text)) text = [];
 
     // make sure showing ticks doesn't accidentally add new categories
+    // TODO multicategory, if we allow ticktext / tickvals
     var tickVal2l = ax.type === 'category' ? ax.d2l_noadd : ax.d2l;
 
     // array ticks on log axes always show the full number
@@ -621,8 +612,8 @@ function arrayTicks(ax) {
         ax.dtick = 'L' + Math.pow(10, Math.floor(Math.min(ax.range[0], ax.range[1])) - 1);
     }
 
-    for(i = 0; i < vals.length; i++) {
-        vali = tickVal2l(vals[i]);
+    for(var i = 0; i < vals.length; i++) {
+        var vali = tickVal2l(vals[i]);
         if(vali > tickMin && vali < tickMax) {
             if(text[i] === undefined) ticksOut[j] = axes.tickText(ax, vali);
             else ticksOut[j] = tickTextObj(ax, vali, String(text[i]));
@@ -635,17 +626,17 @@ function arrayTicks(ax) {
     return ticksOut;
 }
 
-var roundBase10 = [2, 5, 10],
-    roundBase24 = [1, 2, 3, 6, 12],
-    roundBase60 = [1, 2, 5, 10, 15, 30],
-    // 2&3 day ticks are weird, but need something btwn 1&7
-    roundDays = [1, 2, 3, 7, 14],
-    // approx. tick positions for log axes, showing all (1) and just 1, 2, 5 (2)
-    // these don't have to be exact, just close enough to round to the right value
-    roundLog1 = [-0.046, 0, 0.301, 0.477, 0.602, 0.699, 0.778, 0.845, 0.903, 0.954, 1],
-    roundLog2 = [-0.301, 0, 0.301, 0.699, 1],
-    // N.B. `thetaunit; 'radians' angular axes must be converted to degrees
-    roundAngles = [15, 30, 45, 90, 180];
+var roundBase10 = [2, 5, 10];
+var roundBase24 = [1, 2, 3, 6, 12];
+var roundBase60 = [1, 2, 5, 10, 15, 30];
+// 2&3 day ticks are weird, but need something btwn 1&7
+var roundDays = [1, 2, 3, 7, 14];
+// approx. tick positions for log axes, showing all (1) and just 1, 2, 5 (2)
+// these don't have to be exact, just close enough to round to the right value
+var roundLog1 = [-0.046, 0, 0.301, 0.477, 0.602, 0.699, 0.778, 0.845, 0.903, 0.954, 1];
+var roundLog2 = [-0.301, 0, 0.301, 0.699, 1];
+// N.B. `thetaunit; 'radians' angular axes must be converted to degrees
+var roundAngles = [15, 30, 45, 90, 180];
 
 function roundDTick(roughDTick, base, roundingSet) {
     return base * Lib.roundUp(roughDTick / base, roundingSet);
@@ -736,7 +727,7 @@ axes.autoTicks = function(ax, roughDTick) {
             ax.dtick = (roughDTick > 0.3) ? 'D2' : 'D1';
         }
     }
-    else if(ax.type === 'category') {
+    else if(ax.type === 'category' || ax.type === 'multicategory') {
         ax.tick0 = 0;
         ax.dtick = Math.ceil(Math.max(roughDTick, 1));
     }
@@ -776,7 +767,7 @@ function autoTickRound(ax) {
         dtick = 1;
     }
 
-    if(ax.type === 'category') {
+    if(ax.type === 'category' || ax.type === 'multicategory') {
         ax._tickround = null;
     }
     if(ax.type === 'date') {
@@ -868,36 +859,34 @@ axes.tickIncrement = function(x, dtick, axrev, calendar) {
 
 // calculate the first tick on an axis
 axes.tickFirst = function(ax) {
-    var r2l = ax.r2l || Number,
-        rng = Lib.simpleMap(ax.range, r2l),
-        axrev = rng[1] < rng[0],
-        sRound = axrev ? Math.floor : Math.ceil,
-        // add a tiny extra bit to make sure we get ticks
-        // that may have been rounded out
-        r0 = rng[0] * 1.0001 - rng[1] * 0.0001,
-        dtick = ax.dtick,
-        tick0 = r2l(ax.tick0);
+    var r2l = ax.r2l || Number;
+    var rng = Lib.simpleMap(ax.range, r2l);
+    var axrev = rng[1] < rng[0];
+    var sRound = axrev ? Math.floor : Math.ceil;
+    // add a tiny extra bit to make sure we get ticks
+    // that may have been rounded out
+    var r0 = rng[0] * 1.0001 - rng[1] * 0.0001;
+    var dtick = ax.dtick;
+    var tick0 = r2l(ax.tick0);
 
     if(isNumeric(dtick)) {
         var tmin = sRound((r0 - tick0) / dtick) * dtick + tick0;
 
         // make sure no ticks outside the category list
-        if(ax.type === 'category') {
+        if(ax.type === 'category' || ax.type === 'multicategory') {
             tmin = Lib.constrain(tmin, 0, ax._categories.length - 1);
         }
         return tmin;
     }
 
-    var tType = dtick.charAt(0),
-        dtNum = Number(dtick.substr(1));
+    var tType = dtick.charAt(0);
+    var dtNum = Number(dtick.substr(1));
 
     // Dates: months (or years)
     if(tType === 'M') {
-        var cnt = 0,
-            t0 = tick0,
-            t1,
-            mult,
-            newDTick;
+        var cnt = 0;
+        var t0 = tick0;
+        var t1, mult, newDTick;
 
         // This algorithm should work for *any* nonlinear (but close to linear!)
         // tick spacing. Limit to 10 iterations, for gregorian months it's normally <=3.
@@ -939,16 +928,18 @@ axes.tickFirst = function(ax) {
 // hover is a (truthy) flag for whether to show numbers with a bit
 // more precision for hovertext
 axes.tickText = function(ax, x, hover) {
-    var out = tickTextObj(ax, x),
-        hideexp,
-        arrayMode = ax.tickmode === 'array',
-        extraPrecision = hover || arrayMode,
-        i,
-        tickVal2l = ax.type === 'category' ? ax.d2l_noadd : ax.d2l;
+    var out = tickTextObj(ax, x);
+    var arrayMode = ax.tickmode === 'array';
+    var extraPrecision = hover || arrayMode;
+    var axType = ax.type;
+    // TODO multicategory, if we allow ticktext / tickvals
+    var tickVal2l = axType === 'category' ? ax.d2l_noadd : ax.d2l;
+    var i;
 
     if(arrayMode && Array.isArray(ax.ticktext)) {
-        var rng = Lib.simpleMap(ax.range, ax.r2l),
-            minDiff = Math.abs(rng[1] - rng[0]) / 10000;
+        var rng = Lib.simpleMap(ax.range, ax.r2l);
+        var minDiff = Math.abs(rng[1] - rng[0]) / 10000;
+
         for(i = 0; i < ax.ticktext.length; i++) {
             if(Math.abs(x - tickVal2l(ax.tickvals[i])) < minDiff) break;
         }
@@ -959,28 +950,25 @@ axes.tickText = function(ax, x, hover) {
     }
 
     function isHidden(showAttr) {
-        var first_or_last;
-
         if(showAttr === undefined) return true;
         if(hover) return showAttr === 'none';
 
-        first_or_last = {
+        var firstOrLast = {
             first: ax._tmin,
             last: ax._tmax
         }[showAttr];
 
-        return showAttr !== 'all' && x !== first_or_last;
+        return showAttr !== 'all' && x !== firstOrLast;
     }
 
-    if(hover) {
-        hideexp = 'never';
-    } else {
-        hideexp = ax.exponentformat !== 'none' && isHidden(ax.showexponent) ? 'hide' : '';
-    }
+    var hideexp = hover ?
+        'never' :
+        ax.exponentformat !== 'none' && isHidden(ax.showexponent) ? 'hide' : '';
 
-    if(ax.type === 'date') formatDate(ax, out, hover, extraPrecision);
-    else if(ax.type === 'log') formatLog(ax, out, hover, extraPrecision, hideexp);
-    else if(ax.type === 'category') formatCategory(ax, out);
+    if(axType === 'date') formatDate(ax, out, hover, extraPrecision);
+    else if(axType === 'log') formatLog(ax, out, hover, extraPrecision, hideexp);
+    else if(axType === 'category') formatCategory(ax, out);
+    else if(axType === 'multicategory') formatMultiCategory(ax, out, hover);
     else if(isAngular(ax)) formatAngle(ax, out, hover, extraPrecision, hideexp);
     else formatLinear(ax, out, hover, extraPrecision, hideexp);
 
@@ -988,6 +976,20 @@ axes.tickText = function(ax, x, hover) {
     if(ax.tickprefix && !isHidden(ax.showtickprefix)) out.text = ax.tickprefix + out.text;
     if(ax.ticksuffix && !isHidden(ax.showticksuffix)) out.text += ax.ticksuffix;
 
+    // Setup ticks and grid lines boundaries
+    // at 1/2 a 'category' to the left/bottom
+    if(ax.tickson === 'boundaries' || ax.showdividers) {
+        var inbounds = function(v) {
+            var p = ax.l2p(v);
+            return p >= 0 && p <= ax._length ? v : null;
+        };
+
+        out.xbnd = [
+            inbounds(out.x - 0.5),
+            inbounds(out.x + ax.dtick - 0.5)
+        ];
+    }
+
     return out;
 };
 
@@ -1037,8 +1039,8 @@ function tickTextObj(ax, x, text) {
 }
 
 function formatDate(ax, out, hover, extraPrecision) {
-    var tr = ax._tickround,
-        fmt = (hover && ax.hoverformat) || axes.getTickFormat(ax);
+    var tr = ax._tickround;
+    var fmt = (hover && ax.hoverformat) || axes.getTickFormat(ax);
 
     if(extraPrecision) {
         // second or sub-second precision: extra always shows max digits.
@@ -1047,8 +1049,8 @@ function formatDate(ax, out, hover, extraPrecision) {
         else tr = {y: 'm', m: 'd', d: 'M', M: 'S', S: 4}[tr];
     }
 
-    var dateStr = Lib.formatDate(out.x, fmt, tr, ax._dateFormat, ax.calendar, ax._extraFormat),
-        headStr;
+    var dateStr = Lib.formatDate(out.x, fmt, tr, ax._dateFormat, ax.calendar, ax._extraFormat);
+    var headStr;
 
     var splitIndex = dateStr.indexOf('\n');
     if(splitIndex !== -1) {
@@ -1163,19 +1165,21 @@ function formatCategory(ax, out) {
     var tt = ax._categories[Math.round(out.x)];
     if(tt === undefined) tt = '';
     out.text = String(tt);
+}
 
-    // Setup ticks and grid lines boundaries
-    // at 1/2 a 'category' to the left/bottom
-    if(ax.tickson === 'boundaries') {
-        var inbounds = function(v) {
-            var p = ax.l2p(v);
-            return p >= 0 && p <= ax._length ? v : null;
-        };
+function formatMultiCategory(ax, out, hover) {
+    var v = Math.round(out.x);
+    var cats = ax._categories[v] || [];
+    var tt = cats[1] === undefined ? '' : String(cats[1]);
+    var tt2 = cats[0] === undefined ? '' : String(cats[0]);
 
-        out.xbnd = [
-            inbounds(out.x - 0.5),
-            inbounds(out.x + ax.dtick - 0.5)
-        ];
+    if(hover) {
+        // TODO is this what we want?
+        out.text = tt2 + ' - ' + tt;
+    } else {
+        // setup for secondary labels
+        out.text = tt;
+        out.text2 = tt2;
     }
 }
 
@@ -1284,14 +1288,13 @@ function beyondSI(exponent) {
 }
 
 function numFormat(v, ax, fmtoverride, hover) {
-        // negative?
-    var isNeg = v < 0,
-        // max number of digits past decimal point to show
-        tickRound = ax._tickround,
-        exponentFormat = fmtoverride || ax.exponentformat || 'B',
-        exponent = ax._tickexponent,
-        tickformat = axes.getTickFormat(ax),
-        separatethousands = ax.separatethousands;
+    var isNeg = v < 0;
+    // max number of digits past decimal point to show
+    var tickRound = ax._tickround;
+    var exponentFormat = fmtoverride || ax.exponentformat || 'B';
+    var exponent = ax._tickexponent;
+    var tickformat = axes.getTickFormat(ax);
+    var separatethousands = ax.separatethousands;
 
     // special case for hover: set exponent just for this value, and
     // add a couple more digits of precision over tick labels
@@ -1464,6 +1467,9 @@ axes.getTickFormat = function(ax) {
 // as an array of items like 'xy', 'x2y', 'x2y2'...
 // sorted by x (x,x2,x3...) then y
 // optionally restrict to only subplots containing axis object ax
+//
+// NOTE: this is currently only used OUTSIDE plotly.js (toolpanel, webapp)
+// ideally we get rid of it there (or just copy this there) and remove it here
 axes.getSubplots = function(gd, ax) {
     var subplotObj = gd._fullLayout._subplots;
     var allSubplots = subplotObj.cartesian.concat(subplotObj.gl2d || []);
@@ -1482,6 +1488,8 @@ axes.getSubplots = function(gd, ax) {
 };
 
 // find all subplots with axis 'ax'
+// NOTE: this is only used in axes.getSubplots (only used outside plotly.js) and
+// gl2d/convert (where it restricts axis subplots to only those with gl2d)
 axes.findSubplotsWithAxis = function(subplots, ax) {
     var axMatch = new RegExp(
         (ax._id.charAt(0) === 'x') ? ('^' + ax._id + 'y') : (ax._id + '$')
@@ -1576,6 +1584,10 @@ axes.draw = function(gd, arg, opts) {
 
             plotinfo.xaxislayer.selectAll('.' + xa._id + 'tick').remove();
             plotinfo.yaxislayer.selectAll('.' + ya._id + 'tick').remove();
+            if(xa.type === 'multicategory') {
+                plotinfo.xaxislayer.selectAll('.' + xa._id + 'tick2').remove();
+                plotinfo.xaxislayer.selectAll('.' + xa._id + 'divider').remove();
+            }
             if(plotinfo.gridlayer) plotinfo.gridlayer.selectAll('path').remove();
             if(plotinfo.zerolinelayer) plotinfo.zerolinelayer.selectAll('path').remove();
             fullLayout._infolayer.select('.g-' + xa._id + 'title').remove();
@@ -1585,7 +1597,7 @@ axes.draw = function(gd, arg, opts) {
 
     var axList = (!arg || arg === 'redraw') ? axes.listIds(gd) : arg;
 
-    Lib.syncOrAsync(axList.map(function(axId) {
+    return Lib.syncOrAsync(axList.map(function(axId) {
         return function() {
             if(!axId) return;
 
@@ -1620,44 +1632,46 @@ axes.drawOne = function(gd, ax, opts) {
     var axLetter = axId.charAt(0);
     var counterLetter = axes.counterLetter(axId);
     var mainSubplot = ax._mainSubplot;
+    var mainLinePosition = ax._mainLinePosition;
+    var mainMirrorPosition = ax._mainMirrorPosition;
     var mainPlotinfo = fullLayout._plots[mainSubplot];
-    var subplotsWithAx = axes.getSubplots(gd, ax);
+    var mainAxLayer = mainPlotinfo[axLetter + 'axislayer'];
+    var subplotsWithAx = ax._subplotsWith;
 
     var vals = ax._vals = axes.calcTicks(ax);
 
+    // Add a couple of axis properties that should cause us to recreate
+    // elements. Used in d3 data function.
+    var axInfo = [ax.mirror, mainLinePosition, mainMirrorPosition].join('_');
+    for(i = 0; i < vals.length; i++) {
+        vals[i].axInfo = axInfo;
+    }
+
     if(!ax.visible) return;
 
-    var transFn = axes.makeTransFn(ax);
+    // stash selections to avoid DOM queries e.g.
+    // - stash tickLabels selection, so that drawTitle can use it to scoot title
+    ax._selections = {};
+    // stash tick angle (including the computed 'auto' values) per tick-label class
+    ax._tickAngles = {};
 
+    var transFn = axes.makeTransFn(ax);
+    var tickVals;
     // We remove zero lines, grid lines, and inside ticks if they're within 1px of the end
     // The key case here is removing zero lines when the axis bound is zero
     var valsClipped;
-    var tickVals;
-    var gridVals;
-
-    if(ax.tickson === 'boundaries' && vals.length) {
-        // valsBoundaries is not used for labels;
-        // no need to worry about the other tickTextObj keys
-        var valsBoundaries = [];
-        var _push = function(d, bndIndex) {
-            var xb = d.xbnd[bndIndex];
-            if(xb !== null) {
-                valsBoundaries.push(Lib.extendFlat({}, d, {x: xb}));
-            }
-        };
-        for(i = 0; i < vals.length; i++) _push(vals[i], 0);
-        _push(vals[i - 1], 1);
 
-        valsClipped = axes.clipEnds(ax, valsBoundaries);
-        tickVals = ax.ticks === 'inside' ? valsClipped : valsBoundaries;
-        gridVals = valsClipped;
+    if(ax.tickson === 'boundaries') {
+        var boundaryVals = getBoundaryVals(ax, vals);
+        valsClipped = axes.clipEnds(ax, boundaryVals);
+        tickVals = ax.ticks === 'inside' ? valsClipped : boundaryVals;
     } else {
         valsClipped = axes.clipEnds(ax, vals);
         tickVals = ax.ticks === 'inside' ? valsClipped : vals;
-        gridVals = valsClipped;
     }
 
-    ax._valsClipped = valsClipped;
+    var gridVals = ax._gridVals = valsClipped;
+    var dividerVals = getDividerVals(ax, vals);
 
     if(!fullLayout._hasOnlyLargeSploms) {
         // keep track of which subplots (by main conteraxis) we've already
@@ -1679,6 +1693,7 @@ axes.drawOne = function(gd, ax, opts) {
 
             axes.drawGrid(gd, ax, {
                 vals: gridVals,
+                counterAxis: counterAxis,
                 layer: plotinfo.gridlayer.select('.' + axId),
                 path: gridPath,
                 transFn: transFn
@@ -1696,15 +1711,34 @@ axes.drawOne = function(gd, ax, opts) {
     var tickSubplots = [];
 
     if(ax.ticks) {
-        var mainTickPath = axes.makeTickPath(ax, ax._mainLinePosition, tickSigns[2]);
+        var mainTickPath = axes.makeTickPath(ax, mainLinePosition, tickSigns[2]);
+        var mirrorTickPath;
+        var fullTickPath;
         if(ax._anchorAxis && ax.mirror && ax.mirror !== true) {
-            mainTickPath += axes.makeTickPath(ax, ax._mainMirrorPosition, tickSigns[3]);
+            mirrorTickPath = axes.makeTickPath(ax, mainMirrorPosition, tickSigns[3]);
+            fullTickPath = mainTickPath + mirrorTickPath;
+        } else {
+            mirrorTickPath = '';
+            fullTickPath = mainTickPath;
+        }
+
+        var tickPath;
+        if(ax.showdividers && ax.ticks === 'outside' && ax.tickson === 'boundaries') {
+            var dividerLookup = {};
+            for(i = 0; i < dividerVals.length; i++) {
+                dividerLookup[dividerVals[i].x] = 1;
+            }
+            tickPath = function(d) {
+                return dividerLookup[d.x] ? mirrorTickPath : fullTickPath;
+            };
+        } else {
+            tickPath = fullTickPath;
         }
 
         axes.drawTicks(gd, ax, {
             vals: tickVals,
-            layer: mainPlotinfo[axLetter + 'axislayer'],
-            path: mainTickPath,
+            layer: mainAxLayer,
+            path: tickPath,
             transFn: transFn
         });
 
@@ -1727,33 +1761,55 @@ axes.drawOne = function(gd, ax, opts) {
         });
     }
 
-    var labelFns = axes.makeLabelFns(ax, ax._mainLinePosition);
-    // stash tickLabels selection, so that drawTitle can use it
-    // to scoot title w/o having to query the axis layer again
-    ax._tickLabels = null;
-
     var seq = [];
 
     // tick labels - for now just the main labels.
     // TODO: mirror labels, esp for subplots
-    if(ax._mainLinePosition) {
+
+    seq.push(function() {
+        var labelFns = axes.makeLabelFns(ax, mainLinePosition);
+        return axes.drawLabels(gd, ax, {
+            vals: vals,
+            layer: mainAxLayer,
+            transFn: transFn,
+            labelXFn: labelFns.labelXFn,
+            labelYFn: labelFns.labelYFn,
+            labelAnchorFn: labelFns.labelAnchorFn,
+        });
+    });
+
+    if(ax.type === 'multicategory') {
+        var labelLength = 0;
+        var pad = {x: 2, y: 10}[axLetter];
+
         seq.push(function() {
+            labelLength += getLabelLevelSpan(ax, axId + 'tick') + pad;
+            labelLength += ax._tickAngles[axId + 'tick'] ? ax.tickfont.size * LINE_SPACING : 0;
+            var secondaryPosition = mainLinePosition + labelLength * tickSigns[2];
+            var secondaryLabelFns = axes.makeLabelFns(ax, secondaryPosition);
+
             return axes.drawLabels(gd, ax, {
-                vals: vals,
-                layer: mainPlotinfo[axLetter + 'axislayer'],
+                vals: getSecondaryLabelVals(ax, vals),
+                layer: mainAxLayer,
+                cls: axId + 'tick2',
+                repositionOnUpdate: true,
+                secondary: true,
                 transFn: transFn,
-                labelXFn: labelFns.labelXFn,
-                labelYFn: labelFns.labelYFn,
-                labelAnchorFn: labelFns.labelAnchorFn,
+                labelXFn: secondaryLabelFns.labelXFn,
+                labelYFn: secondaryLabelFns.labelYFn,
+                labelAnchorFn: secondaryLabelFns.labelAnchorFn,
             });
         });
-    }
 
-    if(!opts.skipTitle &&
-        !((ax.rangeslider || {}).visible && ax._boundingBox && ax.side === 'bottom')
-    ) {
         seq.push(function() {
-            return axes.drawTitle(gd, ax);
+            labelLength += getLabelLevelSpan(ax, axId + 'tick2');
+
+            return drawDividers(gd, ax, {
+                vals: dividerVals,
+                layer: mainAxLayer,
+                path: axes.makeTickPath(ax, mainLinePosition, tickSigns[2], labelLength),
+                transFn: transFn
+            });
         });
     }
 
@@ -1762,10 +1818,10 @@ axes.drawOne = function(gd, ax, opts) {
         range[1] = Math.max(range[1], newRange[1]);
     }
 
-    seq.push(function calcBoundingBox() {
+    function calcBoundingBox() {
         if(ax.showticklabels) {
             var gdBB = gd.getBoundingClientRect();
-            var bBox = mainPlotinfo[axLetter + 'axislayer'].node().getBoundingClientRect();
+            var bBox = mainAxLayer.node().getBoundingClientRect();
 
             /*
              * the way we're going to use this, the positioning that matters
@@ -1842,38 +1898,143 @@ axes.drawOne = function(gd, ax, opts) {
                     [ax._boundingBox.right, ax._boundingBox.left]);
             }
         }
-    });
+    }
+
+    var hasRangeSlider = Registry.getComponentMethod('rangeslider', 'isVisible')(ax);
 
-    seq.push(function doAutoMargins() {
-        var pushKey = ax._name + '.automargin';
+    function doAutoMargins() {
+        var push, rangeSliderPush;
 
-        if(!ax.automargin) {
-            Plots.autoMargin(gd, pushKey);
-            return;
+        if(hasRangeSlider) {
+            rangeSliderPush = Registry.getComponentMethod('rangeslider', 'autoMarginOpts')(gd, ax);
         }
+        Plots.autoMargin(gd, rangeSliderAutoMarginID(ax), rangeSliderPush);
 
         var s = ax.side.charAt(0);
-        var push = {x: 0, y: 0, r: 0, l: 0, t: 0, b: 0};
+        if(ax.automargin && (!hasRangeSlider || s !== 'b')) {
+            push = {x: 0, y: 0, r: 0, l: 0, t: 0, b: 0};
 
-        if(axLetter === 'x') {
-            push.y = (ax.anchor === 'free' ? ax.position :
-                ax._anchorAxis.domain[s === 't' ? 1 : 0]);
-            push[s] += ax._boundingBox.height;
+            if(axLetter === 'x') {
+                push.y = (ax.anchor === 'free' ? ax.position :
+                    ax._anchorAxis.domain[s === 't' ? 1 : 0]);
+                push[s] += ax._boundingBox.height;
+            } else {
+                push.x = (ax.anchor === 'free' ? ax.position :
+                    ax._anchorAxis.domain[s === 'r' ? 1 : 0]);
+                push[s] += ax._boundingBox.width;
+            }
+
+            if(ax.title.text !== fullLayout._dfltTitle[axLetter]) {
+                push[s] += ax.title.font.size;
+            }
+        }
+
+        Plots.autoMargin(gd, axAutoMarginID(ax), push);
+    }
+
+    seq.push(calcBoundingBox, doAutoMargins);
+
+    if(!opts.skipTitle &&
+        !(hasRangeSlider && ax._boundingBox && ax.side === 'bottom')
+    ) {
+        seq.push(function() { return drawTitle(gd, ax); });
+    }
+
+    return Lib.syncOrAsync(seq);
+};
+
+function getBoundaryVals(ax, vals) {
+    var out = [];
+    var i;
+
+    // boundaryVals are never used for labels;
+    // no need to worry about the other tickTextObj keys
+    var _push = function(d, bndIndex) {
+        var xb = d.xbnd[bndIndex];
+        if(xb !== null) {
+            out.push(Lib.extendFlat({}, d, {x: xb}));
+        }
+    };
+
+    if(vals.length) {
+        for(i = 0; i < vals.length; i++) {
+            _push(vals[i], 0);
+        }
+        _push(vals[i - 1], 1);
+    }
+
+    return out;
+}
+
+function getSecondaryLabelVals(ax, vals) {
+    var out = [];
+    var lookup = {};
+
+    for(var i = 0; i < vals.length; i++) {
+        var d = vals[i];
+        if(lookup[d.text2]) {
+            lookup[d.text2].push(d.x);
         } else {
-            push.x = (ax.anchor === 'free' ? ax.position :
-                ax._anchorAxis.domain[s === 'r' ? 1 : 0]);
-            push[s] += ax._boundingBox.width;
+            lookup[d.text2] = [d.x];
         }
+    }
+
+    for(var k in lookup) {
+        out.push(tickTextObj(ax, Lib.interp(lookup[k], 0.5), k));
+    }
+
+    return out;
+}
 
-        if(ax.title.text !== fullLayout._dfltTitle[axLetter]) {
-            push[s] += ax.title.font.size;
+function getDividerVals(ax, vals) {
+    var out = [];
+    var i, current;
+
+    // never used for labels;
+    // no need to worry about the other tickTextObj keys
+    var _push = function(d, bndIndex) {
+        var xb = d.xbnd[bndIndex];
+        if(xb !== null) {
+            out.push(Lib.extendFlat({}, d, {x: xb}));
+        }
+    };
+
+    if(ax.showdividers && vals.length) {
+        for(i = 0; i < vals.length; i++) {
+            var d = vals[i];
+            if(d.text2 !== current) {
+                _push(d, 0);
+            }
+            current = d.text2;
         }
+        _push(vals[i - 1], 1);
+    }
+
+    return out;
+}
 
-        Plots.autoMargin(gd, pushKey, push);
+function getLabelLevelSpan(ax, cls) {
+    var axLetter = ax._id.charAt(0);
+    var angle = ax._tickAngles[cls] || 0;
+    var rad = Lib.deg2rad(angle);
+    var sinA = Math.sin(rad);
+    var cosA = Math.cos(rad);
+    var maxX = 0;
+    var maxY = 0;
+
+    // N.B. Drawing.bBox does not take into account rotate transforms
+
+    ax._selections[cls].each(function() {
+        var thisLabel = selectTickLabel(this);
+        var bb = Drawing.bBox(thisLabel.node());
+        var w = bb.width;
+        var h = bb.height;
+        maxX = Math.max(maxX, cosA * w, sinA * h);
+        maxY = Math.max(maxY, sinA * w, cosA * h);
     });
 
-    return Lib.syncOrAsync(seq);
-};
+    return {x: maxY, y: maxX}[axLetter];
+}
 
 /**
  * Which direction do the 'ax.side' values, and free ticks go?
@@ -1926,12 +2087,15 @@ axes.makeTransFn = function(ax) {
  *  - {number} linewidth
  * @param {number} shift along direction of ticklen
  * @param {1 or -1} sng tick sign
+ * @param {number (optional)} len tick length
  * @return {string}
  */
-axes.makeTickPath = function(ax, shift, sgn) {
+axes.makeTickPath = function(ax, shift, sgn, len) {
+    len = len !== undefined ? len : ax.ticklen;
+
     var axLetter = ax._id.charAt(0);
     var pad = (ax.linewidth || 1) / 2;
-    var len = ax.ticklen;
+
     return axLetter === 'x' ?
         'M0,' + (shift + pad * sgn) + 'v' + (len * sgn) :
         'M' + (shift + pad * sgn) + ',0h' + (len * sgn);
@@ -2016,10 +2180,8 @@ axes.makeLabelFns = function(ax, shift, angle) {
     return out;
 };
 
-function makeDataFn(ax) {
-    return function(d) {
-        return [d.text, d.x, ax.mirror, d.font, d.fontSize, d.fontColor].join('_');
-    };
+function tickDataFn(d) {
+    return [d.text, d.x, d.axInfo, d.font, d.fontSize, d.fontColor].join('_');
 }
 
 /**
@@ -2044,7 +2206,7 @@ axes.drawTicks = function(gd, ax, opts) {
     var cls = ax._id + 'tick';
 
     var ticks = opts.layer.selectAll('path.' + cls)
-        .data(ax.ticks ? opts.vals : [], makeDataFn(ax));
+        .data(ax.ticks ? opts.vals : [], tickDataFn);
 
     ticks.exit().remove();
 
@@ -2074,6 +2236,8 @@ axes.drawTicks = function(gd, ax, opts) {
  * @param {object} opts
  * - {array of object} vals (calcTicks output-like)
  * - {d3 selection} layer
+ * - {object} counterAxis (full axis object corresponding to counter axis)
+ *     optional - only required if this axis supports zero lines
  * - {string or fn} path
  * - {fn} transFn
  * - {boolean} crisp (set to false to unset crisp-edge SVG rendering)
@@ -2082,28 +2246,41 @@ axes.drawGrid = function(gd, ax, opts) {
     opts = opts || {};
 
     var cls = ax._id + 'grid';
+    var vals = opts.vals;
+    var counterAx = opts.counterAxis;
+    if(ax.showgrid === false) {
+        vals = [];
+    }
+    else if(counterAx && axes.shouldShowZeroLine(gd, ax, counterAx)) {
+        var isArrayMode = ax.tickmode === 'array';
+        for(var i = 0; i < vals.length; i++) {
+            var xi = vals[i].x;
+            if(isArrayMode ? !xi : (Math.abs(xi) < ax.dtick / 100)) {
+                vals = vals.slice(0, i).concat(vals.slice(i + 1));
+                // In array mode you can in principle have multiple
+                // ticks at 0, so test them all. Otherwise once we found
+                // one we can stop.
+                if(isArrayMode) i--;
+                else break;
+            }
+        }
+    }
 
     var grid = opts.layer.selectAll('path.' + cls)
-        .data((ax.showgrid === false) ? [] : opts.vals, makeDataFn(ax));
+        .data(vals, tickDataFn);
 
     grid.exit().remove();
 
     grid.enter().append('path')
         .classed(cls, 1)
-        .classed('crisp', opts.crisp !== false)
-        .attr('d', opts.path)
-        .each(function(d) {
-            if(ax.zeroline && (ax.type === 'linear' || ax.type === '-') &&
-                    Math.abs(d.x) < ax.dtick / 100) {
-                d3.select(this).remove();
-            }
-        });
+        .classed('crisp', opts.crisp !== false);
 
-    ax._gridWidthCrispRound = Drawing.crispRound(gd, ax.gridwidth, 1);
+    ax._gw = Drawing.crispRound(gd, ax.gridwidth, 1);
 
     grid.attr('transform', opts.transFn)
+        .attr('d', opts.path)
         .call(Color.stroke, ax.gridcolor || '#ddd')
-        .style('stroke-width', ax._gridWidthCrispRound + 'px');
+        .style('stroke-width', ax._gw + 'px');
 
     if(typeof opts.path === 'function') grid.attr('d', opts.path);
 };
@@ -2119,7 +2296,6 @@ axes.drawGrid = function(gd, ax, opts) {
  *  - {string} zerolinecolor
  *  - {number (optional)} _gridWidthCrispRound
  * @param {object} opts
- * - {array of object} vals (calcTicks output-like)
  * - {d3 selection} layer
  * - {object} counterAxis (full axis object corresponding to counter axis)
  * - {string or fn} path
@@ -2141,7 +2317,6 @@ axes.drawZeroLine = function(gd, ax, opts) {
         .classed(cls, 1)
         .classed('zl', 1)
         .classed('crisp', opts.crisp !== false)
-        .attr('d', opts.path)
         .each(function() {
             // use the fact that only one element can enter to trigger a sort.
             // If several zerolines enter at the same time we will sort once per,
@@ -2151,14 +2326,10 @@ axes.drawZeroLine = function(gd, ax, opts) {
             });
         });
 
-    var strokeWidth = Drawing.crispRound(gd,
-        ax.zerolinewidth,
-        ax._gridWidthCrispRound || 1
-    );
-
     zl.attr('transform', opts.transFn)
+        .attr('d', opts.path)
         .call(Color.stroke, ax.zerolinecolor || Color.defaultLine)
-        .style('stroke-width', strokeWidth + 'px');
+        .style('stroke-width', Drawing.crispRound(gd, ax.zerolinewidth, ax._gw || 1) + 'px');
 };
 
 /**
@@ -2169,9 +2340,14 @@ axes.drawZeroLine = function(gd, ax, opts) {
  *  - {string} _id
  *  - {boolean} showticklabels
  *  - {number} tickangle
+ *  - {object (optional)} _selections
+ *  - {object} (optional)} _tickAngles
  * @param {object} opts
  * - {array of object} vals (calcTicks output-like)
  * - {d3 selection} layer
+ * - {string (optional)} cls (node className)
+ * - {boolean} repositionOnUpdate (set to true to reposition update selection)
+ * - {boolean} secondary
  * - {fn} transFn
  * - {fn} labelXFn
  * - {fn} labelYFn
@@ -2182,14 +2358,16 @@ axes.drawLabels = function(gd, ax, opts) {
 
     var axId = ax._id;
     var axLetter = axId.charAt(0);
-    var cls = axId + 'tick';
+    var cls = opts.cls || axId + 'tick';
     var vals = opts.vals;
     var labelXFn = opts.labelXFn;
     var labelYFn = opts.labelYFn;
     var labelAnchorFn = opts.labelAnchorFn;
+    var tickAngle = opts.secondary ? 0 : ax.tickangle;
+    var lastAngle = (ax._tickAngles || {})[cls];
 
     var tickLabels = opts.layer.selectAll('g.' + cls)
-        .data(ax.showticklabels ? vals : [], makeDataFn(ax));
+        .data(ax.showticklabels ? vals : [], tickDataFn);
 
     var labelsReady = [];
 
@@ -2215,20 +2393,17 @@ axes.drawLabels = function(gd, ax, opts) {
                     // instead position the label and promise this in
                     // labelsReady
                     labelsReady.push(gd._promises.pop().then(function() {
-                        positionLabels(thisLabel, ax.tickangle);
+                        positionLabels(thisLabel, tickAngle);
                     }));
                 } else {
                     // sync label: just position it now.
-                    positionLabels(thisLabel, ax.tickangle);
+                    positionLabels(thisLabel, tickAngle);
                 }
             });
 
     tickLabels.exit().remove();
 
-    ax._tickLabels = tickLabels;
-
-    // TODO ??
-    if(isAngular(ax)) {
+    if(opts.repositionOnUpdate) {
         tickLabels.each(function(d) {
             d3.select(this).select('text')
                 .call(svgTextUtils.positionText, labelXFn(d), labelYFn(d));
@@ -2295,33 +2470,34 @@ axes.drawLabels = function(gd, ax, opts) {
     // do this without waiting, using the last calculated angle to
     // minimize flicker, then do it again when we know all labels are
     // there, putting back the prescribed angle to check for overlaps.
-    positionLabels(tickLabels, ax._lastangle || ax.tickangle);
+    positionLabels(tickLabels, lastAngle || tickAngle);
 
     function allLabelsReady() {
         return labelsReady.length && Promise.all(labelsReady);
     }
 
     function fixLabelOverlaps() {
-        positionLabels(tickLabels, ax.tickangle);
+        positionLabels(tickLabels, tickAngle);
+
+        var autoangle = null;
 
         // check for auto-angling if x labels overlap
         // don't auto-angle at all for log axes with
         // base and digit format
-        if(vals.length && axLetter === 'x' && !isNumeric(ax.tickangle) &&
+        if(vals.length && axLetter === 'x' && !isNumeric(tickAngle) &&
             (ax.type !== 'log' || String(ax.dtick).charAt(0) !== 'D')
         ) {
+            autoangle = 0;
+
             var maxFontSize = 0;
             var lbbArray = [];
             var i;
 
             tickLabels.each(function(d) {
-                var s = d3.select(this);
-                var thisLabel = s.select('.text-math-group');
-                if(thisLabel.empty()) thisLabel = s.select('text');
-
                 maxFontSize = Math.max(maxFontSize, d.fontSize);
 
                 var x = ax.l2p(d.x);
+                var thisLabel = selectTickLabel(this);
                 var bb = Drawing.bBox(thisLabel.node());
 
                 lbbArray.push({
@@ -2336,12 +2512,12 @@ axes.drawLabels = function(gd, ax, opts) {
                 });
             });
 
-            var autoangle = 0;
-
-            if(ax.tickson === 'boundaries') {
+            if((ax.tickson === 'boundaries' || ax.showdividers) && !opts.secondary) {
                 var gap = 2;
                 if(ax.ticks) gap += ax.tickwidth / 2;
 
+                // TODO should secondary labels also fall into this fix-overlap regime?
+
                 for(i = 0; i < lbbArray.length; i++) {
                     var xbnd = vals[i].xbnd;
                     var lbb = lbbArray[i];
@@ -2356,12 +2532,12 @@ axes.drawLabels = function(gd, ax, opts) {
             } else {
                 var vLen = vals.length;
                 var tickSpacing = Math.abs((vals[vLen - 1].x - vals[0].x) * ax._m) / (vLen - 1);
-                var fitBetweenTicks = tickSpacing < maxFontSize * 2.5;
+                var rotate90 = (tickSpacing < maxFontSize * 2.5) || ax.type === 'multicategory';
 
                 // any overlap at all - set 30 degrees or 90 degrees
                 for(i = 0; i < lbbArray.length - 1; i++) {
                     if(Lib.bBoxIntersect(lbbArray[i], lbbArray[i + 1])) {
-                        autoangle = fitBetweenTicks ? 90 : 30;
+                        autoangle = rotate90 ? 90 : 30;
                         break;
                     }
                 }
@@ -2370,40 +2546,75 @@ axes.drawLabels = function(gd, ax, opts) {
             if(autoangle) {
                 positionLabels(tickLabels, autoangle);
             }
-            ax._lastangle = autoangle;
+        }
+
+        if(ax._tickAngles) {
+            ax._tickAngles[cls] = autoangle === null ?
+                (isNumeric(tickAngle) ? tickAngle : 0) :
+                autoangle;
         }
     }
 
+    if(ax._selections) {
+        ax._selections[cls] = tickLabels;
+    }
+
     var done = Lib.syncOrAsync([allLabelsReady, fixLabelOverlaps]);
     if(done && done.then) gd._promises.push(done);
     return done;
 };
 
-axes.drawTitle = function(gd, ax) {
-    var fullLayout = gd._fullLayout;
-    var tickLabels = ax._tickLabels;
+/**
+ * Draw axis dividers
+ *
+ * @param {DOM element} gd
+ * @param {object} ax (full) axis object
+ *  - {string} _id
+ *  - {string} showdividers
+ *  - {number} dividerwidth
+ *  - {string} dividercolor
+ * @param {object} opts
+ * - {array of object} vals (calcTicks output-like)
+ * - {d3 selection} layer
+ * - {fn} path
+ * - {fn} transFn
+ */
+function drawDividers(gd, ax, opts) {
+    var cls = ax._id + 'divider';
+    var vals = opts.vals;
 
-    var avoid = {
-        selection: tickLabels,
-        side: ax.side
-    };
+    var dividers = opts.layer.selectAll('path.' + cls)
+        .data(vals, tickDataFn);
+
+    dividers.exit().remove();
+
+    dividers.enter().insert('path', ':first-child')
+        .classed(cls, 1)
+        .classed('crisp', 1)
+        .call(Color.stroke, ax.dividercolor)
+        .style('stroke-width', Drawing.crispRound(gd, ax.dividerwidth, 1) + 'px');
 
+    dividers
+        .attr('transform', opts.transFn)
+        .attr('d', opts.path);
+}
+
+function drawTitle(gd, ax) {
+    var fullLayout = gd._fullLayout;
     var axId = ax._id;
     var axLetter = axId.charAt(0);
-    var offsetBase = 1.5;
     var gs = fullLayout._size;
     var fontSize = ax.title.font.size;
 
-    var transform, counterAxis, x, y;
-
-    if(tickLabels && tickLabels.node() && tickLabels.node().parentNode) {
-        var translation = Drawing.getTranslate(tickLabels.node().parentNode);
-        avoid.offsetLeft = translation.x;
-        avoid.offsetTop = translation.y;
+    var titleStandoff;
+    if(ax.type === 'multicategory') {
+        titleStandoff = ax._boundingBox[{x: 'height', y: 'width'}[axLetter]];
+    } else {
+        var offsetBase = 1.5;
+        titleStandoff = 10 + fontSize * offsetBase + (ax.linewidth ? ax.linewidth - 1 : 0);
     }
 
-    var titleStandoff = 10 + fontSize * offsetBase +
-        (ax.linewidth ? ax.linewidth - 1 : 0);
+    var transform, counterAxis, x, y;
 
     if(axLetter === 'x') {
         counterAxis = (ax.anchor === 'free') ?
@@ -2419,15 +2630,13 @@ axes.drawTitle = function(gd, ax) {
                 fontSize * (ax.showticklabels ? 1.5 : 0.5);
         }
         y += counterAxis._offset;
-
-        if(!avoid.side) avoid.side = 'bottom';
-    }
-    else {
+    } else {
         counterAxis = (ax.anchor === 'free') ?
             {_offset: gs.l + (ax.position || 0) * gs.w, _length: 0} :
             axisIds.getFromId(gd, ax.anchor);
 
         y = ax._offset + ax._length / 2;
+
         if(ax.side === 'right') {
             x = counterAxis._length + titleStandoff +
                 fontSize * (ax.showticklabels ? 1 : 0.5);
@@ -2437,10 +2646,26 @@ axes.drawTitle = function(gd, ax) {
         x += counterAxis._offset;
 
         transform = {rotate: '-90', offset: 0};
-        if(!avoid.side) avoid.side = 'left';
     }
 
-    Titles.draw(gd, axId + 'title', {
+    var avoid;
+
+    if(ax.type !== 'multicategory') {
+        var tickLabels = ax._selections[ax._id + 'tick'];
+
+        avoid = {
+            selection: tickLabels,
+            side: ax.side
+        };
+
+        if(tickLabels && tickLabels.node() && tickLabels.node().parentNode) {
+            var translation = Drawing.getTranslate(tickLabels.node().parentNode);
+            avoid.offsetLeft = translation.x;
+            avoid.offsetTop = translation.y;
+        }
+    }
+
+    return Titles.draw(gd, axId + 'title', {
         propContainer: ax,
         propName: ax._name + '.title.text',
         placeholder: fullLayout._dfltTitle[axLetter],
@@ -2448,7 +2673,7 @@ axes.drawTitle = function(gd, ax) {
         transform: transform,
         attributes: {x: x, y: y, 'text-anchor': 'middle'}
     });
-};
+}
 
 axes.shouldShowZeroLine = function(gd, ax, counterAxis) {
     var rng = Lib.simpleMap(ax.range, ax.r2l);
@@ -2456,7 +2681,7 @@ axes.shouldShowZeroLine = function(gd, ax, counterAxis) {
         (rng[0] * rng[1] <= 0) &&
         ax.zeroline &&
         (ax.type === 'linear' || ax.type === '-') &&
-        ax._valsClipped.length &&
+        ax._gridVals.length &&
         (
             clipEnds(ax, 0) ||
             !anyCounterAxLineAtZero(gd, ax, counterAxis, rng) ||
@@ -2544,6 +2769,12 @@ function hasBarsOrFill(gd, ax) {
     return false;
 }
 
+function selectTickLabel(gTick) {
+    var s = d3.select(gTick);
+    var mj = s.select('.text-math-group');
+    return mj.empty() ? s.select('text') : mj;
+}
+
 /**
  * Find all margin pushers for 2D axes and reserve them for later use
  * Both label and rangeslider automargin calculations happen later so
@@ -2558,14 +2789,17 @@ axes.allowAutoMargin = function(gd) {
     for(var i = 0; i < axList.length; i++) {
         var ax = axList[i];
         if(ax.automargin) {
-            Plots.allowAutoMargin(gd, ax._name + '.automargin');
+            Plots.allowAutoMargin(gd, axAutoMarginID(ax));
         }
-        if(ax.rangeslider && ax.rangeslider.visible) {
-            Plots.allowAutoMargin(gd, 'rangeslider' + ax._id);
+        if(Registry.getComponentMethod('rangeslider', 'isVisible')(ax)) {
+            Plots.allowAutoMargin(gd, rangeSliderAutoMarginID(ax));
         }
     }
 };
 
+function axAutoMarginID(ax) { return ax._id + '.automargin'; }
+function rangeSliderAutoMarginID(ax) { return ax._id + '.rangeslider'; }
+
 // swap all the presentation attributes of the axes showing these traces
 axes.swap = function(gd, traces) {
     var axGroups = makeAxisGroups(gd, traces);
@@ -2621,11 +2855,10 @@ function mergeAxisGroups(intoSet, fromSet) {
 }
 
 function swapAxisGroup(gd, xIds, yIds) {
-    var i,
-        j,
-        xFullAxes = [],
-        yFullAxes = [],
-        layout = gd.layout;
+    var xFullAxes = [];
+    var yFullAxes = [];
+    var layout = gd.layout;
+    var i, j;
 
     for(i = 0; i < xIds.length; i++) xFullAxes.push(axes.getFromId(gd, xIds[i]));
     for(i = 0; i < yIds.length; i++) yFullAxes.push(axes.getFromId(gd, yIds[i]));
@@ -2638,12 +2871,12 @@ function swapAxisGroup(gd, xIds, yIds) {
     var numericTypes = ['linear', 'log'];
 
     for(i = 0; i < allAxKeys.length; i++) {
-        var keyi = allAxKeys[i],
-            xVal = xFullAxes[0][keyi],
-            yVal = yFullAxes[0][keyi],
-            allEqual = true,
-            coerceLinearX = false,
-            coerceLinearY = false;
+        var keyi = allAxKeys[i];
+        var xVal = xFullAxes[0][keyi];
+        var yVal = yFullAxes[0][keyi];
+        var allEqual = true;
+        var coerceLinearX = false;
+        var coerceLinearY = false;
         if(keyi.charAt(0) === '_' || typeof xVal === 'function' ||
                 noSwapAttrs.indexOf(keyi) !== -1) {
             continue;
@@ -2689,10 +2922,11 @@ function swapAxisAttrs(layout, key, xFullAxes, yFullAxes, dfltTitle) {
     // in case the value is the default for either axis,
     // look at the first axis in each list and see if
     // this key's value is undefined
-    var np = Lib.nestedProperty,
-        xVal = np(layout[xFullAxes[0]._name], key).get(),
-        yVal = np(layout[yFullAxes[0]._name], key).get(),
-        i;
+    var np = Lib.nestedProperty;
+    var xVal = np(layout[xFullAxes[0]._name], key).get();
+    var yVal = np(layout[yFullAxes[0]._name], key).get();
+    var i;
+
     if(key === 'title') {
         // special handling of placeholder titles
         if(xVal && xVal.text === dfltTitle.x) {
diff --git a/src/plots/cartesian/axis_autotype.js b/src/plots/cartesian/axis_autotype.js
index c7b90565985..3cb03c9fe4d 100644
--- a/src/plots/cartesian/axis_autotype.js
+++ b/src/plots/cartesian/axis_autotype.js
@@ -14,7 +14,10 @@ var isNumeric = require('fast-isnumeric');
 var Lib = require('../../lib');
 var BADNUM = require('../../constants/numerical').BADNUM;
 
-module.exports = function autoType(array, calendar) {
+module.exports = function autoType(array, calendar, opts) {
+    opts = opts || {};
+
+    if(!opts.noMultiCategory && multiCategory(array)) return 'multicategory';
     if(moreDates(array, calendar)) return 'date';
     if(category(array)) return 'category';
     if(linearOK(array)) return 'linear';
@@ -81,3 +84,10 @@ function category(a) {
 
     return curvecats > curvenums * 2;
 }
+
+// very-loose requirements for multicategory,
+// trace modules that should never auto-type to multicategory
+// should be declared with 'noMultiCategory'
+function multiCategory(a) {
+    return Lib.isArrayOrTypedArray(a[0]) && Lib.isArrayOrTypedArray(a[1]);
+}
diff --git a/src/plots/cartesian/axis_defaults.js b/src/plots/cartesian/axis_defaults.js
index 6b8e944b039..79440117ceb 100644
--- a/src/plots/cartesian/axis_defaults.js
+++ b/src/plots/cartesian/axis_defaults.js
@@ -90,9 +90,23 @@ module.exports = function handleAxisDefaults(containerIn, containerOut, coerce,
 
     if(options.automargin) coerce('automargin');
 
+    var isMultiCategory = containerOut.type === 'multicategory';
+
     if(!options.noTickson &&
-        containerOut.type === 'category' && (containerOut.ticks || containerOut.showgrid)) {
-        coerce('tickson');
+        (containerOut.type === 'category' || isMultiCategory) &&
+        (containerOut.ticks || containerOut.showgrid)
+    ) {
+        var ticksonDflt;
+        if(isMultiCategory) ticksonDflt = 'boundaries';
+        coerce('tickson', ticksonDflt);
+    }
+
+    if(isMultiCategory) {
+        var showDividers = coerce('showdividers');
+        if(showDividers) {
+            coerce('dividercolor');
+            coerce('dividerwidth');
+        }
     }
 
     return containerOut;
diff --git a/src/plots/cartesian/constraints.js b/src/plots/cartesian/constraints.js
index 70859a2ad0b..36c27ec08b3 100644
--- a/src/plots/cartesian/constraints.js
+++ b/src/plots/cartesian/constraints.js
@@ -139,7 +139,6 @@ exports.enforce = function enforceAxisConstraints(gd) {
                         var getPad = makePadFn(ax);
 
                         updateDomain(ax, factor);
-                        ax.setScale();
                         var m = Math.abs(ax._m);
                         var extremes = concatExtremes(gd, ax);
                         var minArray = extremes.min;
@@ -206,4 +205,5 @@ function updateDomain(ax, factor) {
         center + (inputDomain[0] - center) / factor,
         center + (inputDomain[1] - center) / factor
     ];
+    ax.setScale();
 }
diff --git a/src/plots/cartesian/dragbox.js b/src/plots/cartesian/dragbox.js
index 6cc75dc0443..8b91a5dab5b 100644
--- a/src/plots/cartesian/dragbox.js
+++ b/src/plots/cartesian/dragbox.js
@@ -516,6 +516,9 @@ function makeDragBox(gd, plotinfo, x, y, w, h, ns, ew) {
             return;
         }
 
+        // prevent axis drawing from monkeying with margins until we're done
+        gd._fullLayout._replotting = true;
+
         if(xActive === 'ew' || yActive === 'ns') {
             if(xActive) dragAxList(xaxes, dx);
             if(yActive) dragAxList(yaxes, dy);
@@ -726,7 +729,10 @@ function makeDragBox(gd, plotinfo, x, y, w, h, ns, ew) {
         // accumulated MathJax promises - wait for them before we relayout.
         Lib.syncOrAsync([
             Plots.previousPromises,
-            function() { Registry.call('_guiRelayout', gd, updates); }
+            function() {
+                gd._fullLayout._replotting = false;
+                Registry.call('_guiRelayout', gd, updates);
+            }
         ], gd);
     }
 
diff --git a/src/plots/cartesian/layout_attributes.js b/src/plots/cartesian/layout_attributes.js
index 09a11d54a4d..d98b10f7dea 100644
--- a/src/plots/cartesian/layout_attributes.js
+++ b/src/plots/cartesian/layout_attributes.js
@@ -67,7 +67,7 @@ module.exports = {
         // '-' means we haven't yet run autotype or couldn't find any data
         // it gets turned into linear in gd._fullLayout but not copied back
         // to gd.data like the others are.
-        values: ['-', 'linear', 'log', 'date', 'category'],
+        values: ['-', 'linear', 'log', 'date', 'category', 'multicategory'],
         dflt: '-',
         role: 'info',
         editType: 'calc',
@@ -323,7 +323,7 @@ module.exports = {
         description: [
             'Determines where ticks and grid lines are drawn with respect to their',
             'corresponding tick labels.',
-            'Only has an effect for axes of `type` *category*.',
+            'Only has an effect for axes of `type` *category* or *multicategory*.',
             'When set to *boundaries*, ticks and grid lines are drawn half a category',
             'to the left/bottom of labels.'
         ].join(' ')
@@ -666,6 +666,40 @@ module.exports = {
         editType: 'ticks',
         description: 'Sets the width (in px) of the zero line.'
     },
+
+    showdividers: {
+        valType: 'boolean',
+        dflt: true,
+        role: 'style',
+        editType: 'ticks',
+        description: [
+            'Determines whether or not a dividers are drawn',
+            'between the category levels of this axis.',
+            'Only has an effect on *multicategory* axes.'
+        ].join(' ')
+    },
+    dividercolor: {
+        valType: 'color',
+        dflt: colorAttrs.defaultLine,
+        role: 'style',
+        editType: 'ticks',
+        description: [
+            'Sets the color of the dividers',
+            'Only has an effect on *multicategory* axes.'
+        ].join(' ')
+    },
+    dividerwidth: {
+        valType: 'number',
+        dflt: 1,
+        role: 'style',
+        editType: 'ticks',
+        description: [
+            'Sets the width (in px) of the dividers',
+            'Only has an effect on *multicategory* axes.'
+        ].join(' ')
+    },
+    // TODO dividerlen: that would override "to label base" length?
+
     // positioning attributes
     // anchor: not used directly, just put here for reference
     // values are any opposite-letter axis id
diff --git a/src/plots/cartesian/layout_defaults.js b/src/plots/cartesian/layout_defaults.js
index e0e6e18dc08..6bf2f652fc6 100644
--- a/src/plots/cartesian/layout_defaults.js
+++ b/src/plots/cartesian/layout_defaults.js
@@ -156,6 +156,8 @@ module.exports = function supplyLayoutDefaults(layoutIn, layoutOut, fullData) {
         axLayoutOut._traceIndices = traces.map(function(t) { return t._expandedIndex; });
         axLayoutOut._annIndices = [];
         axLayoutOut._shapeIndices = [];
+        axLayoutOut._subplotsWith = [];
+        axLayoutOut._counterAxes = [];
 
         // set up some private properties
         axLayoutOut._name = axLayoutOut._attr = axName;
@@ -239,11 +241,7 @@ module.exports = function supplyLayoutDefaults(layoutIn, layoutOut, fullData) {
 
         var anchoredAxis = layoutOut[id2name(axLayoutOut.anchor)];
 
-        var fixedRangeDflt = (
-            anchoredAxis &&
-            anchoredAxis.rangeslider &&
-            anchoredAxis.rangeslider.visible
-        );
+        var fixedRangeDflt = getComponentMethod('rangeslider', 'isVisible')(anchoredAxis);
 
         coerce('fixedrange', fixedRangeDflt);
     }
diff --git a/src/plots/cartesian/set_convert.js b/src/plots/cartesian/set_convert.js
index e057aba7b20..2f5c25cb04f 100644
--- a/src/plots/cartesian/set_convert.js
+++ b/src/plots/cartesian/set_convert.js
@@ -30,6 +30,10 @@ function fromLog(v) {
     return Math.pow(10, v);
 }
 
+function isValidCategory(v) {
+    return v !== null && v !== undefined;
+}
+
 /**
  * Define the conversion functions for an axis data is used in 5 ways:
  *
@@ -123,7 +127,7 @@ module.exports = function setConvert(ax, fullLayout) {
      * a disconnect between the array and the index returned
      */
     function setCategoryIndex(v) {
-        if(v !== null && v !== undefined) {
+        if(isValidCategory(v)) {
             if(ax._categoriesMap === undefined) {
                 ax._categoriesMap = {};
             }
@@ -142,14 +146,58 @@ module.exports = function setConvert(ax, fullLayout) {
         return BADNUM;
     }
 
+    function setMultiCategoryIndex(arrayIn, len) {
+        var arrayOut = new Array(len);
+        var i;
+
+        // [ [arrayIn[0][i], arrayIn[1][i]], for i .. len ]
+        var tmp = new Array(len);
+        // [ [cnt, {$cat: index}], for j .. arrayIn.length ]
+        var seen = [[0, {}], [0, {}]];
+
+        if(Lib.isArrayOrTypedArray(arrayIn[0]) && Lib.isArrayOrTypedArray(arrayIn[1])) {
+            for(i = 0; i < len; i++) {
+                var v0 = arrayIn[0][i];
+                var v1 = arrayIn[1][i];
+                if(isValidCategory(v0) && isValidCategory(v1)) {
+                    tmp[i] = [v0, v1];
+                    if(!(v0 in seen[0][1])) {
+                        seen[0][1][v0] = seen[0][0]++;
+                    }
+                    if(!(v1 in seen[1][1])) {
+                        seen[1][1][v1] = seen[1][0]++;
+                    }
+                }
+            }
+
+            tmp.sort(function(a, b) {
+                var ind0 = seen[0][1];
+                var d = ind0[a[0]] - ind0[b[0]];
+                if(d) return d;
+
+                var ind1 = seen[1][1];
+                return ind1[a[1]] - ind1[b[1]];
+            });
+        }
+
+        for(i = 0; i < len; i++) {
+            arrayOut[i] = setCategoryIndex(tmp[i]);
+        }
+
+        return arrayOut;
+    }
+
     function getCategoryIndex(v) {
-        // d2l/d2c variant that that won't add categories but will also
-        // allow numbers to be mapped to the linearized axis positions
         if(ax._categoriesMap) {
-            var index = ax._categoriesMap[v];
-            if(index !== undefined) return index;
+            return ax._categoriesMap[v];
         }
+    }
 
+    function getCategoryPosition(v) {
+        // d2l/d2c variant that that won't add categories but will also
+        // allow numbers to be mapped to the linearized axis positions
+        var index = getCategoryIndex(v);
+        if(index !== undefined) return index;
         if(isNumeric(v)) return +v;
     }
 
@@ -235,15 +283,15 @@ module.exports = function setConvert(ax, fullLayout) {
         ax.d2c = ax.d2l = setCategoryIndex;
         ax.r2d = ax.c2d = ax.l2d = getCategoryName;
 
-        ax.d2r = ax.d2l_noadd = getCategoryIndex;
+        ax.d2r = ax.d2l_noadd = getCategoryPosition;
 
         ax.r2c = function(v) {
-            var index = getCategoryIndex(v);
+            var index = getCategoryPosition(v);
             return index !== undefined ? index : ax.fraction2r(0.5);
         };
 
         ax.l2r = ax.c2r = ensureNumber;
-        ax.r2l = getCategoryIndex;
+        ax.r2l = getCategoryPosition;
 
         ax.d2p = function(v) { return ax.l2p(ax.r2c(v)); };
         ax.p2d = function(px) { return getCategoryName(p2l(px)); };
@@ -255,6 +303,34 @@ module.exports = function setConvert(ax, fullLayout) {
             return ensureNumber(v);
         };
     }
+    else if(ax.type === 'multicategory') {
+        // N.B. multicategory axes don't define d2c and d2l,
+        // as 'data-to-calcdata' conversion needs to take into
+        // account all data array items as in ax.makeCalcdata.
+
+        ax.r2d = ax.c2d = ax.l2d = getCategoryName;
+        ax.d2r = ax.d2l_noadd = getCategoryPosition;
+
+        ax.r2c = function(v) {
+            var index = getCategoryPosition(v);
+            return index !== undefined ? index : ax.fraction2r(0.5);
+        };
+
+        ax.r2c_just_indices = getCategoryIndex;
+
+        ax.l2r = ax.c2r = ensureNumber;
+        ax.r2l = getCategoryPosition;
+
+        ax.d2p = function(v) { return ax.l2p(ax.r2c(v)); };
+        ax.p2d = function(px) { return getCategoryName(p2l(px)); };
+        ax.r2p = ax.d2p;
+        ax.p2r = p2l;
+
+        ax.cleanPos = function(v) {
+            if(Array.isArray(v) || (typeof v === 'string' && v !== '')) return v;
+            return ensureNumber(v);
+        };
+    }
 
     // find the range value at the specified (linear) fraction of the axis
     ax.fraction2r = function(v) {
@@ -348,11 +424,6 @@ module.exports = function setConvert(ax, fullLayout) {
     ax.setScale = function(usePrivateRange) {
         var gs = fullLayout._size;
 
-        // TODO cleaner way to handle this case
-        if(!ax._categories) ax._categories = [];
-        // Add a map to optimize the performance of category collection
-        if(!ax._categoriesMap) ax._categoriesMap = {};
-
         // make sure we have a domain (pull it in from the axis
         // this one is overlaying if necessary)
         if(ax.overlaying) {
@@ -407,7 +478,7 @@ module.exports = function setConvert(ax, fullLayout) {
 
         if(axLetter in trace) {
             arrayIn = trace[axLetter];
-            len = trace._length || arrayIn.length;
+            len = trace._length || Lib.minRowLength(arrayIn);
 
             if(Lib.isTypedArray(arrayIn) && (axType === 'linear' || axType === 'log')) {
                 if(len === arrayIn.length) {
@@ -417,6 +488,10 @@ module.exports = function setConvert(ax, fullLayout) {
                 }
             }
 
+            if(axType === 'multicategory') {
+                return setMultiCategoryIndex(arrayIn, len);
+            }
+
             arrayOut = new Array(len);
             for(i = 0; i < len; i++) {
                 arrayOut[i] = ax.d2c(arrayIn[i], 0, cal);
diff --git a/src/plots/cartesian/tick_value_defaults.js b/src/plots/cartesian/tick_value_defaults.js
index f5aff20aefa..222f729509f 100644
--- a/src/plots/cartesian/tick_value_defaults.js
+++ b/src/plots/cartesian/tick_value_defaults.js
@@ -6,22 +6,18 @@
 * LICENSE file in the root directory of this source tree.
 */
 
-
 'use strict';
 
 var cleanTicks = require('./clean_ticks');
 
-
 module.exports = function handleTickValueDefaults(containerIn, containerOut, coerce, axType) {
     var tickmode;
 
     if(containerIn.tickmode === 'array' &&
             (axType === 'log' || axType === 'date')) {
         tickmode = containerOut.tickmode = 'auto';
-    }
-    else {
-        var tickmodeDefault =
-            Array.isArray(containerIn.tickvals) ? 'array' :
+    } else {
+        var tickmodeDefault = Array.isArray(containerIn.tickvals) ? 'array' :
             containerIn.dtick ? 'linear' :
             'auto';
         tickmode = coerce('tickmode', tickmodeDefault);
@@ -36,8 +32,7 @@ module.exports = function handleTickValueDefaults(containerIn, containerOut, coe
             containerIn.dtick, axType);
         containerOut.tick0 = cleanTicks.tick0(
             containerIn.tick0, axType, containerOut.calendar, dtick);
-    }
-    else {
+    } else if(axType !== 'multicategory') {
         var tickvals = coerce('tickvals');
         if(tickvals === undefined) containerOut.tickmode = 'auto';
         else coerce('ticktext');
diff --git a/src/plots/cartesian/type_defaults.js b/src/plots/cartesian/type_defaults.js
index 1234f8a24a6..04a050f8938 100644
--- a/src/plots/cartesian/type_defaults.js
+++ b/src/plots/cartesian/type_defaults.js
@@ -8,7 +8,7 @@
 
 'use strict';
 
-var Registry = require('../../registry');
+var traceIs = require('../../registry').traceIs;
 var autoType = require('./axis_autotype');
 
 /*
@@ -57,6 +57,7 @@ function setAutoType(ax, data) {
 
     var calAttr = axLetter + 'calendar';
     var calendar = d0[calAttr];
+    var opts = {noMultiCategory: !traceIs(d0, 'cartesian') || traceIs(d0, 'noMultiCategory')};
     var i;
 
     // check all boxes on this x axis to see
@@ -67,8 +68,7 @@ function setAutoType(ax, data) {
 
         for(i = 0; i < data.length; i++) {
             var trace = data[i];
-            if(!Registry.traceIs(trace, 'box-violin') ||
-               (trace[axLetter + 'axis'] || axLetter) !== id) continue;
+            if(!traceIs(trace, 'box-violin') || (trace[axLetter + 'axis'] || axLetter) !== id) continue;
 
             if(trace[posLetter] !== undefined) boxPositions.push(trace[posLetter][0]);
             else if(trace.name !== undefined) boxPositions.push(trace.name);
@@ -77,7 +77,7 @@ function setAutoType(ax, data) {
             if(trace[calAttr] !== calendar) calendar = undefined;
         }
 
-        ax.type = autoType(boxPositions, calendar);
+        ax.type = autoType(boxPositions, calendar, opts);
     }
     else if(d0.type === 'splom') {
         var dimensions = d0.dimensions;
@@ -85,13 +85,13 @@ function setAutoType(ax, data) {
         for(i = 0; i < dimensions.length; i++) {
             var dim = dimensions[i];
             if(dim.visible && (diag[i][0] === id || diag[i][1] === id)) {
-                ax.type = autoType(dim.values, calendar);
+                ax.type = autoType(dim.values, calendar, opts);
                 break;
             }
         }
     }
     else {
-        ax.type = autoType(d0[axLetter] || [d0[axLetter + '0']], calendar);
+        ax.type = autoType(d0[axLetter] || [d0[axLetter + '0']], calendar, opts);
     }
 }
 
@@ -122,9 +122,9 @@ function getBoxPosLetter(trace) {
 }
 
 function isBoxWithoutPositionCoords(trace, axLetter) {
-    var posLetter = getBoxPosLetter(trace),
-        isBox = Registry.traceIs(trace, 'box-violin'),
-        isCandlestick = Registry.traceIs(trace._fullInput || {}, 'candlestick');
+    var posLetter = getBoxPosLetter(trace);
+    var isBox = traceIs(trace, 'box-violin');
+    var isCandlestick = traceIs(trace._fullInput || {}, 'candlestick');
 
     return (
         isBox &&
diff --git a/src/plots/gl3d/layout/axis_attributes.js b/src/plots/gl3d/layout/axis_attributes.js
index e2c2c9482bc..ef3a2fa4b9a 100644
--- a/src/plots/gl3d/layout/axis_attributes.js
+++ b/src/plots/gl3d/layout/axis_attributes.js
@@ -13,7 +13,6 @@ var axesAttrs = require('../../cartesian/layout_attributes');
 var extendFlat = require('../../../lib/extend').extendFlat;
 var overrideAll = require('../../../plot_api/edit_types').overrideAll;
 
-
 module.exports = overrideAll({
     visible: axesAttrs.visible,
     showspikes: {
@@ -73,7 +72,9 @@ module.exports = overrideAll({
     categoryorder: axesAttrs.categoryorder,
     categoryarray: axesAttrs.categoryarray,
     title: axesAttrs.title,
-    type: axesAttrs.type,
+    type: extendFlat({}, axesAttrs.type, {
+        values: ['-', 'linear', 'log', 'date', 'category']
+    }),
     autorange: axesAttrs.autorange,
     rangemode: axesAttrs.rangemode,
     range: axesAttrs.range,
diff --git a/src/plots/plots.js b/src/plots/plots.js
index e2a28e87a39..aa52e9e05ef 100644
--- a/src/plots/plots.js
+++ b/src/plots/plots.js
@@ -497,15 +497,11 @@ plots.supplyDefaults = function(gd, opts) {
         if(uids[uid] === 'old') delete tracePreGUI[uid];
     }
 
-    // TODO may return a promise
-    plots.doAutoMargin(gd);
+    // set up containers for margin calculations
+    initMargins(newFullLayout);
 
-    // set scale after auto margin routine
-    var axList = axisIDs.list(gd);
-    for(i = 0; i < axList.length; i++) {
-        var ax = axList[i];
-        ax.setScale();
-    }
+    // collect and do some initial calculations for rangesliders
+    Registry.getComponentMethod('rangeslider', 'makeData')(newFullLayout);
 
     // update object references in calcdata
     if(!skipUpdateCalc && oldCalcdata.length === newFullData.length) {
@@ -815,6 +811,12 @@ plots.linkSubplots = function(newFullData, newFullLayout, oldFullData, oldFullLa
             plotinfo.id = id;
         }
 
+        // add these axis ids to each others' subplot lists
+        xaxis._counterAxes.push(yaxis._id);
+        yaxis._counterAxes.push(xaxis._id);
+        xaxis._subplotsWith.push(id);
+        yaxis._subplotsWith.push(id);
+
         // update x and y axis layout object refs
         plotinfo.xaxis = xaxis;
         plotinfo.yaxis = yaxis;
@@ -842,8 +844,9 @@ plots.linkSubplots = function(newFullData, newFullLayout, oldFullData, oldFullLa
     // while we're at it, link overlaying axes to their main axes and
     // anchored axes to the axes they're anchored to
     var axList = axisIDs.list(mockGd, null, true);
+    var ax;
     for(i = 0; i < axList.length; i++) {
-        var ax = axList[i];
+        ax = axList[i];
         var mainAx = null;
 
         if(ax.overlaying) {
@@ -871,8 +874,53 @@ plots.linkSubplots = function(newFullData, newFullLayout, oldFullData, oldFullLa
             null :
             axisIDs.getFromId(mockGd, ax.anchor);
     }
+
+    // finally, we can find the main subplot for each axis
+    // (on which the ticks & labels are drawn)
+    for(i = 0; i < axList.length; i++) {
+        ax = axList[i];
+        ax._counterAxes.sort(axisIDs.idSort);
+        ax._subplotsWith.sort(Lib.subplotSort);
+        ax._mainSubplot = findMainSubplot(ax, newFullLayout);
+    }
 };
 
+function findMainSubplot(ax, fullLayout) {
+    var mockGd = {_fullLayout: fullLayout};
+
+    var isX = ax._id.charAt(0) === 'x';
+    var anchorAx = ax._mainAxis._anchorAxis;
+    var mainSubplotID = '';
+    var nextBestMainSubplotID = '';
+    var anchorID = '';
+
+    // First try the main ID with the anchor
+    if(anchorAx) {
+        anchorID = anchorAx._mainAxis._id;
+        mainSubplotID = isX ? (ax._id + anchorID) : (anchorID + ax._id);
+    }
+
+    // Then look for a subplot with the counteraxis overlaying the anchor
+    // If that fails just use the first subplot including this axis
+    if(!mainSubplotID || !fullLayout._plots[mainSubplotID]) {
+        mainSubplotID = '';
+
+        var counterIDs = ax._counterAxes;
+        for(var j = 0; j < counterIDs.length; j++) {
+            var counterPart = counterIDs[j];
+            var id = isX ? (ax._id + counterPart) : (counterPart + ax._id);
+            if(!nextBestMainSubplotID) nextBestMainSubplotID = id;
+            var counterAx = axisIDs.getFromId(mockGd, counterPart);
+            if(anchorID && counterAx.overlaying === anchorID) {
+                mainSubplotID = id;
+                break;
+            }
+        }
+    }
+
+    return mainSubplotID || nextBestMainSubplotID;
+}
+
 // This function clears any trace attributes with valType: color and
 // no set dflt filed in the plot schema. This is needed because groupby (which
 // is the only transform for which this currently applies) supplies parent
@@ -1686,7 +1734,20 @@ plots.allowAutoMargin = function(gd, id) {
     gd._fullLayout._pushmarginIds[id] = 1;
 };
 
-function setupAutoMargin(fullLayout) {
+function initMargins(fullLayout) {
+    var margin = fullLayout.margin;
+
+    if(!fullLayout._size) {
+        var gs = fullLayout._size = {
+            l: Math.round(margin.l),
+            r: Math.round(margin.r),
+            t: Math.round(margin.t),
+            b: Math.round(margin.b),
+            p: Math.round(margin.pad)
+        };
+        gs.w = Math.round(fullLayout.width) - gs.l - gs.r;
+        gs.h = Math.round(fullLayout.height) - gs.t - gs.b;
+    }
     if(!fullLayout._pushmargin) fullLayout._pushmargin = {};
     if(!fullLayout._pushmarginIds) fullLayout._pushmarginIds = {};
 }
@@ -1709,8 +1770,6 @@ function setupAutoMargin(fullLayout) {
 plots.autoMargin = function(gd, id, o) {
     var fullLayout = gd._fullLayout;
 
-    setupAutoMargin(fullLayout);
-
     var pushMargin = fullLayout._pushmargin;
     var pushMarginIds = fullLayout._pushmarginIds;
 
@@ -1754,18 +1813,19 @@ plots.autoMargin = function(gd, id, o) {
 plots.doAutoMargin = function(gd) {
     var fullLayout = gd._fullLayout;
     if(!fullLayout._size) fullLayout._size = {};
-    setupAutoMargin(fullLayout);
+    initMargins(fullLayout);
 
-    var gs = fullLayout._size,
-        oldmargins = JSON.stringify(gs);
+    var gs = fullLayout._size;
+    var oldmargins = JSON.stringify(gs);
+    var margin = fullLayout.margin;
 
     // adjust margins for outside components
     // fullLayout.margin is the requested margin,
     // fullLayout._size has margins and plotsize after adjustment
-    var ml = Math.max(fullLayout.margin.l || 0, 0);
-    var mr = Math.max(fullLayout.margin.r || 0, 0);
-    var mt = Math.max(fullLayout.margin.t || 0, 0);
-    var mb = Math.max(fullLayout.margin.b || 0, 0);
+    var ml = margin.l;
+    var mr = margin.r;
+    var mt = margin.t;
+    var mb = margin.b;
     var pushMargin = fullLayout._pushmargin;
     var pushMarginIds = fullLayout._pushmarginIds;
 
@@ -1835,7 +1895,7 @@ plots.doAutoMargin = function(gd) {
     gs.r = Math.round(mr);
     gs.t = Math.round(mt);
     gs.b = Math.round(mb);
-    gs.p = Math.round(fullLayout.margin.pad);
+    gs.p = Math.round(margin.pad);
     gs.w = Math.round(fullLayout.width) - gs.l - gs.r;
     gs.h = Math.round(fullLayout.height) - gs.t - gs.b;
 
diff --git a/src/plots/polar/layout_attributes.js b/src/plots/polar/layout_attributes.js
index 4d878a9fd43..c40dc8d042c 100644
--- a/src/plots/polar/layout_attributes.js
+++ b/src/plots/polar/layout_attributes.js
@@ -57,7 +57,9 @@ var axisTickAttrs = overrideAll({
 
 var radialAxisAttrs = {
     visible: extendFlat({}, axesAttrs.visible, {dflt: true}),
-    type: axesAttrs.type,
+    type: extendFlat({}, axesAttrs.type, {
+        values: ['-', 'linear', 'log', 'date', 'category']
+    }),
 
     autorange: extendFlat({}, axesAttrs.autorange, {editType: 'plot'}),
     rangemode: {
diff --git a/src/plots/polar/polar.js b/src/plots/polar/polar.js
index 5e56eb258d7..e9d2d8ff8b0 100644
--- a/src/plots/polar/polar.js
+++ b/src/plots/polar/polar.js
@@ -643,6 +643,7 @@ proto.updateAngularAxis = function(fullLayout, polarLayout) {
         Axes.drawLabels(gd, ax, {
             vals: vals,
             layer: layers['angular-axis'],
+            repositionOnUpdate: true,
             transFn: transFn,
             labelXFn: labelXFn,
             labelYFn: labelYFn,
diff --git a/src/traces/bar/cross_trace_calc.js b/src/traces/bar/cross_trace_calc.js
index e80255413b4..ff300d24ca5 100644
--- a/src/traces/bar/cross_trace_calc.js
+++ b/src/traces/bar/cross_trace_calc.js
@@ -126,9 +126,14 @@ function initBase(gd, pa, sa, calcTraces) {
         // time. But included here for completeness.
         var scalendar = trace.orientation === 'h' ? trace.xcalendar : trace.ycalendar;
 
+        // 'base' on categorical axes makes no sense
+        var d2c = sa.type === 'category' || sa.type === 'multicategory' ?
+            function() { return null; } :
+            sa.d2c;
+
         if(isArrayOrTypedArray(base)) {
             for(j = 0; j < Math.min(base.length, cd.length); j++) {
-                b = sa.d2c(base[j], 0, scalendar);
+                b = d2c(base[j], 0, scalendar);
                 if(isNumeric(b)) {
                     cd[j].b = +b;
                     cd[j].hasB = 1;
@@ -139,7 +144,7 @@ function initBase(gd, pa, sa, calcTraces) {
                 cd[j].b = 0;
             }
         } else {
-            b = sa.d2c(base, 0, scalendar);
+            b = d2c(base, 0, scalendar);
             var hasBase = isNumeric(b);
             b = hasBase ? b : 0;
             for(j = 0; j < cd.length; j++) {
diff --git a/src/traces/box/calc.js b/src/traces/box/calc.js
index 095048f19a7..58d6d55b831 100644
--- a/src/traces/box/calc.js
+++ b/src/traces/box/calc.js
@@ -178,7 +178,10 @@ function getPos(trace, posLetter, posAxis, val, num) {
         pos0 = num;
     }
 
-    var pos0c = posAxis.d2c(pos0, 0, trace[posLetter + 'calendar']);
+    var pos0c = posAxis.type === 'multicategory' ?
+        posAxis.r2c_just_indices(pos0) :
+        posAxis.d2c(pos0, 0, trace[posLetter + 'calendar']);
+
     return val.map(function() { return pos0c; });
 }
 
diff --git a/src/traces/box/defaults.js b/src/traces/box/defaults.js
index dc75d7f1266..96bbdc8df79 100644
--- a/src/traces/box/defaults.js
+++ b/src/traces/box/defaults.js
@@ -45,16 +45,15 @@ function handleSampleDefaults(traceIn, traceOut, coerce, layout) {
     if(y && y.length) {
         defaultOrientation = 'v';
         if(hasX) {
-            len = Math.min(x.length, y.length);
-        }
-        else {
+            len = Math.min(Lib.minRowLength(x), Lib.minRowLength(y));
+        } else {
             coerce('x0');
-            len = y.length;
+            len = Lib.minRowLength(y);
         }
     } else if(hasX) {
         defaultOrientation = 'h';
         coerce('y0');
-        len = x.length;
+        len = Lib.minRowLength(x);
     } else {
         traceOut.visible = false;
         return;
diff --git a/src/traces/carpet/index.js b/src/traces/carpet/index.js
index 2ebbb14a766..236129c9e1a 100644
--- a/src/traces/carpet/index.js
+++ b/src/traces/carpet/index.js
@@ -21,7 +21,7 @@ Carpet.isContainer = true; // so carpet traces get `calc` before other traces
 Carpet.moduleType = 'trace';
 Carpet.name = 'carpet';
 Carpet.basePlotModule = require('../../plots/cartesian');
-Carpet.categories = ['cartesian', 'svg', 'carpet', 'carpetAxis', 'notLegendIsolatable'];
+Carpet.categories = ['cartesian', 'svg', 'carpet', 'carpetAxis', 'notLegendIsolatable', 'noMultiCategory'];
 Carpet.meta = {
     description: [
         'The data describing carpet axis layout is set in `y` and (optionally)',
diff --git a/src/traces/contourcarpet/calc.js b/src/traces/contourcarpet/calc.js
index 1a645d5f261..878c481b8de 100644
--- a/src/traces/contourcarpet/calc.js
+++ b/src/traces/contourcarpet/calc.js
@@ -9,11 +9,10 @@
 'use strict';
 
 var colorscaleCalc = require('../../components/colorscale/calc');
-var isArray1D = require('../../lib').isArray1D;
+var Lib = require('../../lib');
 
 var convertColumnData = require('../heatmap/convert_column_xyz');
 var clean2dArray = require('../heatmap/clean_2d_array');
-var maxRowLength = require('../heatmap/max_row_length');
 var interp2d = require('../heatmap/interp2d');
 var findEmpties = require('../heatmap/find_empties');
 var makeBoundArray = require('../heatmap/make_bound_array');
@@ -70,7 +69,7 @@ function heatmappishCalc(gd, trace) {
     aax._minDtick = 0;
     bax._minDtick = 0;
 
-    if(isArray1D(trace.z)) convertColumnData(trace, aax, bax, 'a', 'b', ['z']);
+    if(Lib.isArray1D(trace.z)) convertColumnData(trace, aax, bax, 'a', 'b', ['z']);
     a = trace._a = trace._a || trace.a;
     b = trace._b = trace._b || trace.b;
 
@@ -87,7 +86,7 @@ function heatmappishCalc(gd, trace) {
     interp2d(z, trace._emptypoints);
 
     // create arrays of brick boundaries, to be used by autorange and heatmap.plot
-    var xlen = maxRowLength(z),
+    var xlen = Lib.maxRowLength(z),
         xIn = trace.xtype === 'scaled' ? '' : a,
         xArray = makeBoundArray(trace, xIn, a0, da, xlen, aax),
         yIn = trace.ytype === 'scaled' ? '' : b,
diff --git a/src/traces/heatmap/calc.js b/src/traces/heatmap/calc.js
index 86ebf03aa83..c6c9ad70b1d 100644
--- a/src/traces/heatmap/calc.js
+++ b/src/traces/heatmap/calc.js
@@ -16,7 +16,6 @@ var Axes = require('../../plots/cartesian/axes');
 var histogram2dCalc = require('../histogram2d/calc');
 var colorscaleCalc = require('../../components/colorscale/calc');
 var convertColumnData = require('./convert_column_xyz');
-var maxRowLength = require('./max_row_length');
 var clean2dArray = require('./clean_2d_array');
 var interp2d = require('./interp2d');
 var findEmpties = require('./find_empties');
@@ -116,7 +115,7 @@ module.exports = function calc(gd, trace) {
     }
 
     // create arrays of brick boundaries, to be used by autorange and heatmap.plot
-    var xlen = maxRowLength(z);
+    var xlen = Lib.maxRowLength(z);
     var xIn = trace.xtype === 'scaled' ? '' : x;
     var xArray = makeBoundArray(trace, xIn, x0, dx, xlen, xa);
     var yIn = trace.ytype === 'scaled' ? '' : y;
diff --git a/src/traces/heatmap/convert_column_xyz.js b/src/traces/heatmap/convert_column_xyz.js
index 57638ace45e..5f7ff06f1f2 100644
--- a/src/traces/heatmap/convert_column_xyz.js
+++ b/src/traces/heatmap/convert_column_xyz.js
@@ -14,43 +14,36 @@ var BADNUM = require('../../constants/numerical').BADNUM;
 
 module.exports = function convertColumnData(trace, ax1, ax2, var1Name, var2Name, arrayVarNames) {
     var colLen = trace._length;
-    var col1 = trace[var1Name].slice(0, colLen);
-    var col2 = trace[var2Name].slice(0, colLen);
+    var col1 = ax1.makeCalcdata(trace, var1Name);
+    var col2 = ax2.makeCalcdata(trace, var2Name);
     var textCol = trace.text;
     var hasColumnText = (textCol !== undefined && Lib.isArray1D(textCol));
-    var col1Calendar = trace[var1Name + 'calendar'];
-    var col2Calendar = trace[var2Name + 'calendar'];
+    var i, j;
 
-    var i, j, arrayVar, newArray, arrayVarName;
-
-    for(i = 0; i < colLen; i++) {
-        col1[i] = ax1.d2c(col1[i], 0, col1Calendar);
-        col2[i] = ax2.d2c(col2[i], 0, col2Calendar);
-    }
-
-    var col1dv = Lib.distinctVals(col1),
-        col1vals = col1dv.vals,
-        col2dv = Lib.distinctVals(col2),
-        col2vals = col2dv.vals,
-        newArrays = [];
+    var col1dv = Lib.distinctVals(col1);
+    var col1vals = col1dv.vals;
+    var col2dv = Lib.distinctVals(col2);
+    var col2vals = col2dv.vals;
+    var newArrays = [];
+    var text;
 
     for(i = 0; i < arrayVarNames.length; i++) {
         newArrays[i] = Lib.init2dArray(col2vals.length, col1vals.length);
     }
 
-    var i1, i2, text;
-
-    if(hasColumnText) text = Lib.init2dArray(col2vals.length, col1vals.length);
+    if(hasColumnText) {
+        text = Lib.init2dArray(col2vals.length, col1vals.length);
+    }
 
     for(i = 0; i < colLen; i++) {
         if(col1[i] !== BADNUM && col2[i] !== BADNUM) {
-            i1 = Lib.findBin(col1[i] + col1dv.minDiff / 2, col1vals);
-            i2 = Lib.findBin(col2[i] + col2dv.minDiff / 2, col2vals);
+            var i1 = Lib.findBin(col1[i] + col1dv.minDiff / 2, col1vals);
+            var i2 = Lib.findBin(col2[i] + col2dv.minDiff / 2, col2vals);
 
             for(j = 0; j < arrayVarNames.length; j++) {
-                arrayVarName = arrayVarNames[j];
-                arrayVar = trace[arrayVarName];
-                newArray = newArrays[j];
+                var arrayVarName = arrayVarNames[j];
+                var arrayVar = trace[arrayVarName];
+                var newArray = newArrays[j];
                 newArray[i2][i1] = arrayVar[i];
             }
 
diff --git a/src/traces/heatmap/find_empties.js b/src/traces/heatmap/find_empties.js
index df5c442119d..27c9244a420 100644
--- a/src/traces/heatmap/find_empties.js
+++ b/src/traces/heatmap/find_empties.js
@@ -8,7 +8,7 @@
 
 'use strict';
 
-var maxRowLength = require('./max_row_length');
+var maxRowLength = require('../../lib').maxRowLength;
 
 /* Return a list of empty points in 2D array z
  * each empty point z[i][j] gives an array [i, j, neighborCount]
diff --git a/src/traces/heatmap/make_bound_array.js b/src/traces/heatmap/make_bound_array.js
index bf0e67b7a9d..ea3ec9a33a3 100644
--- a/src/traces/heatmap/make_bound_array.js
+++ b/src/traces/heatmap/make_bound_array.js
@@ -67,10 +67,15 @@ module.exports = function makeBoundArray(trace, arrayIn, v0In, dvIn, numbricks,
 
         var calendar = trace[ax._id.charAt(0) + 'calendar'];
 
-        if(isHist || ax.type === 'category') v0 = ax.r2c(v0In, 0, calendar) || 0;
-        else if(isArrayOrTypedArray(arrayIn) && arrayIn.length === 1) v0 = arrayIn[0];
-        else if(v0In === undefined) v0 = 0;
-        else v0 = ax.d2c(v0In, 0, calendar);
+        if(isHist || ax.type === 'category' || ax.type === 'multicategory') {
+            v0 = ax.r2c(v0In, 0, calendar) || 0;
+        } else if(isArrayOrTypedArray(arrayIn) && arrayIn.length === 1) {
+            v0 = arrayIn[0];
+        } else if(v0In === undefined) {
+            v0 = 0;
+        } else {
+            v0 = ax.d2c(v0In, 0, calendar);
+        }
 
         for(i = (isContour || isGL2D) ? 0 : -0.5; i < numbricks; i++) {
             arrayOut.push(v0 + dv * i);
diff --git a/src/traces/heatmap/max_row_length.js b/src/traces/heatmap/max_row_length.js
deleted file mode 100644
index 44e55256ba2..00000000000
--- a/src/traces/heatmap/max_row_length.js
+++ /dev/null
@@ -1,20 +0,0 @@
-/**
-* Copyright 2012-2018, 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 maxRowLength(z) {
-    var len = 0;
-
-    for(var i = 0; i < z.length; i++) {
-        len = Math.max(len, z[i].length);
-    }
-
-    return len;
-};
diff --git a/src/traces/heatmap/plot.js b/src/traces/heatmap/plot.js
index 48130d77238..ef309be4f80 100644
--- a/src/traces/heatmap/plot.js
+++ b/src/traces/heatmap/plot.js
@@ -17,8 +17,6 @@ var Lib = require('../../lib');
 var Colorscale = require('../../components/colorscale');
 var xmlnsNamespaces = require('../../constants/xmlns_namespaces');
 
-var maxRowLength = require('./max_row_length');
-
 module.exports = function(gd, plotinfo, cdheatmaps, heatmapLayer) {
     var xa = plotinfo.xaxis;
     var ya = plotinfo.yaxis;
@@ -38,7 +36,7 @@ module.exports = function(gd, plotinfo, cdheatmaps, heatmapLayer) {
 
         // get z dims
         var m = z.length;
-        var n = maxRowLength(z);
+        var n = Lib.maxRowLength(z);
         var xrev = false;
         var yrev = false;
 
diff --git a/src/traces/heatmap/xyz_defaults.js b/src/traces/heatmap/xyz_defaults.js
index c457abfbfb4..6bfafcbdbc1 100644
--- a/src/traces/heatmap/xyz_defaults.js
+++ b/src/traces/heatmap/xyz_defaults.js
@@ -6,7 +6,6 @@
 * LICENSE file in the root directory of this source tree.
 */
 
-
 'use strict';
 
 var isNumeric = require('fast-isnumeric');
@@ -26,10 +25,13 @@ module.exports = function handleXYZDefaults(traceIn, traceOut, coerce, layout, x
         x = coerce(xName);
         y = coerce(yName);
 
+        var xlen = Lib.minRowLength(x);
+        var ylen = Lib.minRowLength(y);
+
         // column z must be accompanied by xName and yName arrays
-        if(!(x && x.length && y && y.length)) return 0;
+        if(xlen === 0 || ylen === 0) return 0;
 
-        traceOut._length = Math.min(x.length, y.length, z.length);
+        traceOut._length = Math.min(xlen, ylen, z.length);
     }
     else {
         x = coordDefaults(xName, coerce);
@@ -50,10 +52,8 @@ module.exports = function handleXYZDefaults(traceIn, traceOut, coerce, layout, x
 };
 
 function coordDefaults(coordStr, coerce) {
-    var coord = coerce(coordStr),
-        coordType = coord ?
-            coerce(coordStr + 'type', 'array') :
-            'scaled';
+    var coord = coerce(coordStr);
+    var coordType = coord ? coerce(coordStr + 'type', 'array') : 'scaled';
 
     if(coordType === 'scaled') {
         coerce(coordStr + '0');
diff --git a/src/traces/histogram/calc.js b/src/traces/histogram/calc.js
index a03f3cec3ae..8dd1a5f92d3 100644
--- a/src/traces/histogram/calc.js
+++ b/src/traces/histogram/calc.js
@@ -270,7 +270,8 @@ function calcAllAutoBins(gd, trace, pa, mainData, _overlayEdgeCase) {
 
         // Edge case: single-valued histogram overlaying others
         // Use them all together to calculate the bin size for the single-valued one
-        if(isOverlay && newBinSpec._dataSpan === 0 && pa.type !== 'category') {
+        if(isOverlay && newBinSpec._dataSpan === 0 &&
+            pa.type !== 'category' && pa.type !== 'multicategory') {
             // Several single-valued histograms! Stop infinite recursion,
             // just return an extra flag that tells handleSingleValueOverlays
             // to sort out this trace too
@@ -327,7 +328,7 @@ function calcAllAutoBins(gd, trace, pa, mainData, _overlayEdgeCase) {
             Lib.aggNums(Math.min, null, pos0);
 
         var dummyAx = {
-            type: pa.type === 'category' ? 'linear' : pa.type,
+            type: (pa.type === 'category' || pa.type === 'multicategory') ? 'linear' : pa.type,
             r2l: pa.r2l,
             dtick: binOpts.size,
             tick0: mainStart,
diff --git a/src/traces/histogram/defaults.js b/src/traces/histogram/defaults.js
index 9d5d3715461..96212f84328 100644
--- a/src/traces/histogram/defaults.js
+++ b/src/traces/histogram/defaults.js
@@ -36,7 +36,9 @@ module.exports = function supplyDefaults(traceIn, traceOut, defaultColor, layout
     var sampleLetter = orientation === 'v' ? 'x' : 'y';
     var aggLetter = orientation === 'v' ? 'y' : 'x';
 
-    var len = (x && y) ? Math.min(x.length && y.length) : (traceOut[sampleLetter] || []).length;
+    var len = (x && y) ?
+        Math.min(Lib.minRowLength(x) && Lib.minRowLength(y)) :
+        Lib.minRowLength(traceOut[sampleLetter] || []);
 
     if(!len) {
         traceOut.visible = false;
diff --git a/src/traces/histogram2d/sample_defaults.js b/src/traces/histogram2d/sample_defaults.js
index aca6acf6595..5ad6e547ecf 100644
--- a/src/traces/histogram2d/sample_defaults.js
+++ b/src/traces/histogram2d/sample_defaults.js
@@ -6,24 +6,26 @@
 * LICENSE file in the root directory of this source tree.
 */
 
-
 'use strict';
 
 var Registry = require('../../registry');
+var Lib = require('../../lib');
 
 module.exports = function handleSampleDefaults(traceIn, traceOut, coerce, layout) {
     var x = coerce('x');
     var y = coerce('y');
+    var xlen = Lib.minRowLength(x);
+    var ylen = Lib.minRowLength(y);
 
     // we could try to accept x0 and dx, etc...
     // but that's a pretty weird use case.
     // for now require both x and y explicitly specified.
-    if(!(x && x.length && y && y.length)) {
+    if(!xlen || !ylen) {
         traceOut.visible = false;
         return;
     }
 
-    traceOut._length = Math.min(x.length, y.length);
+    traceOut._length = Math.min(xlen, ylen);
 
     var handleCalendarDefaults = Registry.getComponentMethod('calendars', 'handleTraceDefaults');
     handleCalendarDefaults(traceIn, traceOut, ['x', 'y'], layout);
diff --git a/src/traces/ohlc/ohlc_defaults.js b/src/traces/ohlc/ohlc_defaults.js
index b1a6a83e10b..51baf99986f 100644
--- a/src/traces/ohlc/ohlc_defaults.js
+++ b/src/traces/ohlc/ohlc_defaults.js
@@ -6,11 +6,10 @@
 * LICENSE file in the root directory of this source tree.
 */
 
-
 'use strict';
 
 var Registry = require('../../registry');
-
+var Lib = require('../../lib');
 
 module.exports = function handleOHLC(traceIn, traceOut, coerce, layout) {
     var x = coerce('x');
@@ -27,9 +26,7 @@ module.exports = function handleOHLC(traceIn, traceOut, coerce, layout) {
     if(!(open && high && low && close)) return;
 
     var len = Math.min(open.length, high.length, low.length, close.length);
-
-    if(x) len = Math.min(len, x.length);
-
+    if(x) len = Math.min(len, Lib.minRowLength(x));
     traceOut._length = len;
 
     return len;
diff --git a/src/traces/scatter/xy_defaults.js b/src/traces/scatter/xy_defaults.js
index 27a987af471..b5ef293ee09 100644
--- a/src/traces/scatter/xy_defaults.js
+++ b/src/traces/scatter/xy_defaults.js
@@ -6,34 +6,32 @@
 * LICENSE file in the root directory of this source tree.
 */
 
-
 'use strict';
 
+var Lib = require('../../lib');
 var Registry = require('../../registry');
 
-
 module.exports = function handleXYDefaults(traceIn, traceOut, layout, coerce) {
-    var len,
-        x = coerce('x'),
-        y = coerce('y');
+    var x = coerce('x');
+    var y = coerce('y');
+    var len;
 
     var handleCalendarDefaults = Registry.getComponentMethod('calendars', 'handleTraceDefaults');
     handleCalendarDefaults(traceIn, traceOut, ['x', 'y'], layout);
 
     if(x) {
+        var xlen = Lib.minRowLength(x);
         if(y) {
-            len = Math.min(x.length, y.length);
-        }
-        else {
-            len = x.length;
+            len = Math.min(xlen, Lib.minRowLength(y));
+        } else {
+            len = xlen;
             coerce('y0');
             coerce('dy');
         }
-    }
-    else {
+    } else {
         if(!y) return 0;
 
-        len = traceOut.y.length;
+        len = Lib.minRowLength(y);
         coerce('x0');
         coerce('dx');
     }
diff --git a/src/traces/violin/calc.js b/src/traces/violin/calc.js
index 1994f4235c3..90a91b7ff10 100644
--- a/src/traces/violin/calc.js
+++ b/src/traces/violin/calc.js
@@ -123,7 +123,9 @@ function calcSpan(trace, cdi, valAxis, bandwidth) {
 
     function calcSpanItem(index) {
         var s = spanIn[index];
-        var sc = valAxis.d2c(s, 0, trace[cdi.valLetter + 'calendar']);
+        var sc = valAxis.type === 'multicategory' ?
+            valAxis.r2c(s) :
+            valAxis.d2c(s, 0, trace[cdi.valLetter + 'calendar']);
         return sc === BADNUM ? spanLoose[index] : sc;
     }
 
diff --git a/test/image/baselines/box-violin-multicategory-on-val-axis.png b/test/image/baselines/box-violin-multicategory-on-val-axis.png
new file mode 100644
index 00000000000..a9791da61ea
Binary files /dev/null and b/test/image/baselines/box-violin-multicategory-on-val-axis.png differ
diff --git a/test/image/baselines/box-violin-x0-category-position.png b/test/image/baselines/box-violin-x0-category-position.png
new file mode 100644
index 00000000000..36e4b170345
Binary files /dev/null and b/test/image/baselines/box-violin-x0-category-position.png differ
diff --git a/test/image/baselines/box_grouped-multicategory.png b/test/image/baselines/box_grouped-multicategory.png
new file mode 100644
index 00000000000..395b10cc106
Binary files /dev/null and b/test/image/baselines/box_grouped-multicategory.png differ
diff --git a/test/image/baselines/finance_multicategory.png b/test/image/baselines/finance_multicategory.png
new file mode 100644
index 00000000000..f7bc6c99e8c
Binary files /dev/null and b/test/image/baselines/finance_multicategory.png differ
diff --git a/test/image/baselines/heatmap_multicategory.png b/test/image/baselines/heatmap_multicategory.png
new file mode 100644
index 00000000000..fd9d8ec5c93
Binary files /dev/null and b/test/image/baselines/heatmap_multicategory.png differ
diff --git a/test/image/baselines/multicategory-mirror.png b/test/image/baselines/multicategory-mirror.png
new file mode 100644
index 00000000000..88a03453a83
Binary files /dev/null and b/test/image/baselines/multicategory-mirror.png differ
diff --git a/test/image/baselines/multicategory-y.png b/test/image/baselines/multicategory-y.png
new file mode 100644
index 00000000000..f8986f8a8e6
Binary files /dev/null and b/test/image/baselines/multicategory-y.png differ
diff --git a/test/image/baselines/multicategory.png b/test/image/baselines/multicategory.png
new file mode 100644
index 00000000000..ce31358c8d7
Binary files /dev/null and b/test/image/baselines/multicategory.png differ
diff --git a/test/image/baselines/multicategory2.png b/test/image/baselines/multicategory2.png
new file mode 100644
index 00000000000..ccabc1796dc
Binary files /dev/null and b/test/image/baselines/multicategory2.png differ
diff --git a/test/image/baselines/multicategory_histograms.png b/test/image/baselines/multicategory_histograms.png
new file mode 100644
index 00000000000..a12bd2d0e64
Binary files /dev/null and b/test/image/baselines/multicategory_histograms.png differ
diff --git a/test/image/baselines/range_slider_rangemode.png b/test/image/baselines/range_slider_rangemode.png
index 0915d37b06d..40b03a5eb77 100644
Binary files a/test/image/baselines/range_slider_rangemode.png and b/test/image/baselines/range_slider_rangemode.png differ
diff --git a/test/image/baselines/violin_grouped_horz-multicategory.png b/test/image/baselines/violin_grouped_horz-multicategory.png
new file mode 100644
index 00000000000..b7ca2616b5d
Binary files /dev/null and b/test/image/baselines/violin_grouped_horz-multicategory.png differ
diff --git a/test/image/mocks/box-violin-multicategory-on-val-axis.json b/test/image/mocks/box-violin-multicategory-on-val-axis.json
new file mode 100644
index 00000000000..410563823fc
--- /dev/null
+++ b/test/image/mocks/box-violin-multicategory-on-val-axis.json
@@ -0,0 +1,38 @@
+{
+  "data": [
+    {
+      "type": "violin",
+      "x": [
+        [ "2016", "2016", "2016", "2016", "2016", "2016", "2016", "2016", "2016", "2016", "2016", "2016",
+          "2017", "2017", "2017", "2017", "2017", "2017", "2017", "2017", "2017", "2017", "2017", "2017",
+          "2018", "2018", "2018", "2018", "2018", "2018", "2018", "2018", "2018", "2018", "2018", "2018"
+        ],
+        [ "day 1", "day 1", "day 1", "day 1", "day 1", "day 1", "day 2",
+          "day 2", "day 2", "day 2", "day 2", "day 2", "day 1", "day 1",
+          "day 1", "day 1", "day 1", "day 1", "day 2", "day 2", "day 2",
+          "day 2", "day 2", "day 2", "day 1", "day 1", "day 1", "day 1",
+          "day 1", "day 1", "day 2", "day 2", "day 2", "day 2", "day 2", "day 2"
+        ]
+      ],
+      "span": [0, 5]
+    },
+    {
+      "type": "box",
+      "x": [
+        [ "2016", "2016", "2016", "2016", "2016", "2016", "2016", "2016", "2016", "2016", "2016", "2016",
+          "2017", "2017", "2017", "2017", "2017", "2017", "2017", "2017", "2017", "2017", "2017", "2017",
+          "2018", "2018", "2018", "2018", "2018", "2018", "2018", "2018", "2018", "2018", "2018", "2018"
+        ],
+        [ "day 1", "day 1", "day 1", "day 1", "day 1", "day 1", "day 2", "day 2",
+          "day 2", "day 2", "day 2", "day 2", "day 1", "day 1", "day 1", "day 1",
+          "day 1", "day 1", "day 2", "day 2", "day 2", "day 2", "day 2", "day 2",
+          "day 1", "day 1", "day 1", "day 1", "day 1", "day 1", "day 2", "day 2",
+          "day 2", "day 2", "day 2", "day 2"
+        ]
+      ]
+    }
+  ],
+  "layout": {
+    "showlegend": false
+  }
+}
diff --git a/test/image/mocks/box-violin-x0-category-position.json b/test/image/mocks/box-violin-x0-category-position.json
new file mode 100644
index 00000000000..54d8eebb99a
--- /dev/null
+++ b/test/image/mocks/box-violin-x0-category-position.json
@@ -0,0 +1,99 @@
+{
+  "data": [
+    {
+      "y": [ 0.2, 0.2, 0.6, 1, 0.5, 0.4, 0.2, 0.7, 0.9, 0.1, 0.5, 0.3, 0.6, 0.7, 0.3, 0.6, 0, 0.5, 0.7, 0.9, 0.5, 0.8, 0.7, 0.2 ],
+      "x": [
+        [ "2016", "2016", "2016", "2016", "2016", "2016", "2016", "2016", "2016", "2016", "2016", "2016",
+          "2017", "2017", "2017", "2017", "2017", "2017", "2017", "2017", "2017", "2017", "2017", "2017"
+        ],
+        [ "day 1", "day 1", "day 1", "day 1", "day 1", "day 1",
+            "day 2", "day 2", "day 2", "day 2", "day 2", "day 2",
+            "day 1", "day 1", "day 1", "day 1", "day 1", "day 1",
+          "day 2", "day 2", "day 2", "day 2", "day 2", "day 2"
+        ]
+      ],
+      "type": "box"
+    },
+    {
+      "y": [ 0.6, 0.7, 0.3, 0.6, 0, 0.5, 0.7, 0.9, 0.5, 0.8, 0.7, 0.2, 0.1, 0.3, 0.1, 0.9, 0.6, 0.6, 0.9, 1, 0.3, 0.6, 0.8, 0.5, 0.6, 0.7, 0.3, 0.6, 0, 0.5, 0.7, 0.9, 0.5, 0.8, 0.7, 0.2 ],
+      "x0": ["2017", "day 1"],
+      "type": "violin"
+    },
+    {
+      "y": [ 0.6, 0.7, 0.3, 0.6, 0, 0.5, 0.7, 0.9, 0.5, 0.8, 0.7, 0.2, 0.1, 0.3, 0.1, 0.9, 0.6, 0.6, 0.9, 1, 0.3, 0.6, 0.8, 0.5, 0.6, 0.7, 0.3, 0.6, 0, 0.5, 0.7, 0.9, 0.5, 0.8, 0.7, 0.2 ],
+      "x0": "2017,day 2",
+      "type": "violin"
+    },
+    {
+      "y": [ 0.6, 0.7, 0.3, 0.6, 0, 0.5, 0.7, 0.9, 0.5, 0.8, 0.7, 0.2, 0.1, 0.3, 0.1, 0.9, 0.6, 0.6, 0.9, 1, 0.3, 0.6, 0.8, 0.5, 0.6, 0.7, 0.3, 0.6, 0, 0.5, 0.7, 0.9, 0.5, 0.8, 0.7, 0.2 ],
+      "x0": "1",
+      "type": "violin",
+      "name": "SHOULD NOT BE VISIBLE"
+    },
+
+    {
+      "y": [ 0.2, 0.2, 0.6, 1, 0.5, 0.4, 0.2, 0.7, 0.9, 0.1, 0.5, 0.3, 0.6, 0.7, 0.3, 0.6, 0, 0.5, 0.7, 0.9, 0.5, 0.8, 0.7, 0.2 ],
+      "x": [ "day 1", "day 1", "day 1", "day 1", "day 1", "day 1", "day 2",
+        "day 2", "day 2", "day 2", "day 2", "day 2",
+        "day 1", "day 1", "day 1", "day 1", "day 1", "day 1",
+        "day 2", "day 2", "day 2", "day 2", "day 2", "day 2"
+      ],
+      "type": "box",
+      "xaxis": "x2",
+      "yaxis": "y2"
+    },
+    {
+      "y": [ 0.6, 0.7, 0.3, 0.6, 0, 0.5, 0.7, 0.9, 0.5, 0.8, 0.7, 0.2, 0.1, 0.3, 0.1, 0.9, 0.6, 0.6, 0.9, 1, 0.3, 0.6, 0.8, 0.5, 0.6, 0.7, 0.3, 0.6, 0, 0.5, 0.7, 0.9, 0.5, 0.8, 0.7, 0.2 ],
+      "x0": "day 2",
+      "type": "violin",
+      "xaxis": "x2",
+      "yaxis": "y2"
+    },
+    {
+      "y": [ 0.6, 0.7, 0.3, 0.6, 0, 0.5, 0.7, 0.9, 0.5, 0.8, 0.7, 0.2, 0.1, 0.3, 0.1, 0.9, 0.6, 0.6, 0.9, 1, 0.3, 0.6, 0.8, 0.5, 0.6, 0.7, 0.3, 0.6, 0, 0.5, 0.7, 0.9, 0.5, 0.8, 0.7, 0.2 ],
+      "x0": 2,
+      "type": "violin",
+      "xaxis": "x2",
+      "yaxis": "y2"
+    }
+  ],
+  "layout": {
+    "grid": {
+      "rows": 2,
+      "columns": 1,
+      "pattern": "independent"
+    },
+    "annotations": [
+      {
+        "xref": "x",
+        "yref": "y",
+        "x": ["2017", "day 1"],
+        "text": "violin at x0:[2017,day 1]",
+        "y": 1.5
+      },
+      {
+        "xref": "x",
+        "yref": "y",
+        "x": "2017,day 2",
+        "text": "violin at x0:\"2017,day 2\"",
+        "y": 1.5,
+        "ax": 20
+      },
+      {
+        "xref": "x2",
+        "yref": "y2",
+        "x": "day 2",
+        "text": "violin at x0:\"day 2\"",
+        "y": 1.5
+      },
+      {
+        "xref": "x2",
+        "yref": "y2",
+        "x": 2,
+        "text": "violin at x0:2",
+        "y": 1.5
+      }
+    ],
+    "showlegend": false
+  }
+}
diff --git a/test/image/mocks/box_grouped-multicategory.json b/test/image/mocks/box_grouped-multicategory.json
new file mode 100644
index 00000000000..c745ca643da
--- /dev/null
+++ b/test/image/mocks/box_grouped-multicategory.json
@@ -0,0 +1,100 @@
+{
+   "data":[
+      {
+         "y":[
+             0.2, 0.2, 0.6, 1, 0.5, 0.4, 0.2, 0.7, 0.9, 0.1, 0.5, 0.3,
+            0.6, 0.7, 0.3, 0.6, 0, 0.5, 0.7, 0.9, 0.5, 0.8, 0.7, 0.2,
+             0.2, 0.2, 0.6, 1, 0.5, 0.4, 0.2, 0.7, 0.9, 0.1, 0.5, 0.3
+         ],
+         "x":[
+            ["2016", "2016", "2016", "2016", "2016", "2016",
+            "2016", "2016", "2016", "2016", "2016", "2016",
+            "2017", "2017", "2017", "2017", "2017", "2017",
+            "2017", "2017", "2017", "2017", "2017", "2017",
+            "2018", "2018", "2018", "2018", "2018", "2018",
+            "2018", "2018", "2018", "2018", "2018", "2018"],
+
+            ["day 1", "day 1", "day 1", "day 1", "day 1", "day 1",
+            "day 2", "day 2", "day 2", "day 2", "day 2", "day 2",
+            "day 1", "day 1", "day 1", "day 1", "day 1", "day 1",
+            "day 2", "day 2", "day 2", "day 2", "day 2", "day 2",
+            "day 1", "day 1", "day 1", "day 1", "day 1", "day 1",
+            "day 2", "day 2", "day 2", "day 2", "day 2", "day 2"]
+         ],
+         "name":"kale",
+         "marker":{
+            "color":"#3D9970"
+         },
+         "type":"box"
+      },
+      {
+         "y":[
+            0.6, 0.7, 0.3, 0.6, 0, 0.5, 0.7, 0.9, 0.5, 0.8, 0.7, 0.2,
+             0.1, 0.3, 0.1, 0.9, 0.6, 0.6, 0.9, 1, 0.3, 0.6, 0.8, 0.5,
+            0.6, 0.7, 0.3, 0.6, 0, 0.5, 0.7, 0.9, 0.5, 0.8, 0.7, 0.2
+         ],
+         "x":[
+            ["2016", "2016", "2016", "2016", "2016", "2016",
+            "2016", "2016", "2016", "2016", "2016", "2016",
+            "2017", "2017", "2017", "2017", "2017", "2017",
+            "2017", "2017", "2017", "2017", "2017", "2017",
+            "2018", "2018", "2018", "2018", "2018", "2018",
+            "2018", "2018", "2018", "2018", "2018", "2018"],
+
+            ["day 1", "day 1", "day 1", "day 1", "day 1", "day 1",
+            "day 2", "day 2", "day 2", "day 2", "day 2", "day 2",
+            "day 1", "day 1", "day 1", "day 1", "day 1", "day 1",
+            "day 2", "day 2", "day 2", "day 2", "day 2", "day 2",
+            "day 1", "day 1", "day 1", "day 1", "day 1", "day 1",
+            "day 2", "day 2", "day 2", "day 2", "day 2", "day 2"]
+         ],
+         "name":"radishes",
+         "marker":{
+            "color":"#FF4136"
+         },
+         "type":"box"
+      },
+      {
+         "y":[
+             0.1, 0.3, 0.1, 0.9, 0.6, 0.6, 0.9, 1, 0.3, 0.6, 0.8, 0.5,
+             0.1, 0.3, 0.1, 0.9, 0.6, 0.6, 0.9, 1, 0.3, 0.6, 0.8, 0.5,
+             0.2, 0.2, 0.6, 1, 0.5, 0.4, 0.2, 0.7, 0.9, 0.1, 0.5, 0.3
+         ],
+         "x":[
+            ["2016", "2016", "2016", "2016", "2016", "2016",
+            "2016", "2016", "2016", "2016", "2016", "2016",
+            "2017", "2017", "2017", "2017", "2017", "2017",
+            "2017", "2017", "2017", "2017", "2017", "2017",
+            "2018", "2018", "2018", "2018", "2018", "2018",
+            "2018", "2018", "2018", "2018", "2018", "2018"],
+
+            ["day 1", "day 1", "day 1", "day 1", "day 1", "day 1",
+            "day 2", "day 2", "day 2", "day 2", "day 2", "day 2",
+            "day 1", "day 1", "day 1", "day 1", "day 1", "day 1",
+            "day 2", "day 2", "day 2", "day 2", "day 2", "day 2",
+            "day 1", "day 1", "day 1", "day 1", "day 1", "day 1",
+            "day 2", "day 2", "day 2", "day 2", "day 2", "day 2"]
+         ],
+         "name":"carrots",
+         "marker":{
+            "color":"#FF851B"
+         },
+         "type":"box"
+      }
+   ],
+   "layout":{
+      "xaxis": {
+         "tickangle": 90
+      },
+      "yaxis":{
+         "zeroline":false,
+         "title":"normalized moisture"
+      },
+      "boxmode":"group",
+      "legend": {
+        "x": 0,
+        "y": 1,
+        "yanchor": "bottom"
+      }
+   }
+}
diff --git a/test/image/mocks/finance_multicategory.json b/test/image/mocks/finance_multicategory.json
new file mode 100644
index 00000000000..ba27d340f5f
--- /dev/null
+++ b/test/image/mocks/finance_multicategory.json
@@ -0,0 +1,41 @@
+{
+  "data": [
+    {
+      "name": "ohlc",
+      "type": "ohlc",
+      "open": [ 10, 11, 12, 13, 12, 13, 14, 15, 16 ],
+      "high": [ 15, 16, 17, 18, 17, 18, 19, 20, 21 ],
+      "low": [ 7, 8, 9, 10, 9, 10, 11, 12, 13 ],
+      "close": [ 9, 10, 12, 13, 13, 12, 14, 14, 17 ],
+      "x": [
+        [ "Group 1", "Group 1", "Group 1", "Group 2", "Group 2", "Group 2", "Group 3", "Group 3", "Group 3" ],
+        [ "a", "b", "c", "a", "b", "c", "a", "b", "c" ]
+      ]
+    },
+    {
+      "name": "candlestick",
+      "type": "candlestick",
+      "open": [ 20, 21, 22, 23, 22, 23, 24, 25, 26 ],
+      "high": [ 25, 26, 27, 28, 27, 28, 29, 30, 31 ],
+      "low": [ 17, 18, 19, 20, 19, 20, 21, 22, 23 ],
+      "close": [ 19, 20, 22, 23, 23, 22, 24, 24, 27 ],
+      "x": [
+        [ "Group 1", "Group 1", "Group 1", "Group 2", "Group 2", "Group 2", "Group 3", "Group 3", "Group 3" ],
+        [ "a", "b", "c", "a", "b", "c", "a", "b", "c" ]
+      ]
+    }
+  ],
+  "layout": {
+    "title": {
+      "text": "Finance traces on multicategory x-axis",
+      "xref": "paper",
+      "x": 0
+    },
+    "legend": {
+      "x": 1,
+      "xanchor": "right",
+      "y": 1,
+      "yanchor": "bottom"
+    }
+  }
+}
diff --git a/test/image/mocks/heatmap_multicategory.json b/test/image/mocks/heatmap_multicategory.json
new file mode 100644
index 00000000000..bcbb22c2c13
--- /dev/null
+++ b/test/image/mocks/heatmap_multicategory.json
@@ -0,0 +1,113 @@
+{
+  "data": [{
+    "type": "heatmap",
+    "name": "w/ 2d z",
+    "x": [
+      ["2017", "2017", "2017", "2017", "2018", "2018", "2018"],
+      ["q1", "q2", "q3", "q4", "q1", "q2", "q3"]
+    ],
+    "y": [
+      ["Group 1", "Group 1", "Group 1", "Group 2", "Group 2", "Group 2", "Group 3", "Group 3", "Group 3"],
+      ["A", "B", "C", "A", "B", "C", "A", "B", "C"]
+    ],
+    "z": [
+      [ 0.304, 1.465, 2.474, 3.05, 4.38, 5.245, 6.12 ],
+      [ 0.3515, 1.326, 2.18, 3.26, 4.41, 5.25, 6.11 ],
+      [ 0.3994, 1.167, 2.09, 3.306, 4.305, 5.35, 6.00 ],
+      [ 0.297, 1.295, 2.49, 3.428, 4.13, 5.41, 6.38 ],
+      [ 0.4602, 1.2256, 2.3356, 3.0667, 4.498, 5.411, 6.29 ],
+      [ 0.0197, 1.274, 2.407, 3.22, 4.47, 5.44, 6.28 ],
+      [ 0.32, 1.44, 2.303, 3.115, 4.49, 5.25, 6.46 ],
+      [ 0.4446, 1.223, 2.367, 3.253, 4.385, 5.08, 6.19 ],
+      [ 0.1304, 1.046, 2.45, 3.226, 4.34, 5.40, 6.05 ]
+    ],
+    "colorbar": {
+      "x": -0.15,
+      "len": 0.25,
+      "y": 1,
+      "yanchor": "top"
+    }
+  }, {
+    "type": "contour",
+    "name": "w/ 2d z",
+    "x": [
+      ["2017", "2017", "2017", "2017", "2018", "2018", "2018"],
+      ["q1", "q2", "q3", "q4", "q1", "q2", "q3"]
+    ],
+    "y": [
+      ["Group 1", "Group 1", "Group 1", "Group 2", "Group 2", "Group 2", "Group 3", "Group 3", "Group 3"],
+      ["A", "B", "C", "A", "B", "C", "A", "B", "C"]
+    ],
+    "z":[
+      [0, 10, 0, null, 0, 10, 0],
+      [10, 80, 20, null, 10, 80, 20],
+      [0, 40, 0, null, 0, 40, 0],
+      [0, 10, 0, null, 0, 10, 0],
+      [10, 80, 20, null, 10, 80, 20],
+      [0, 40, 0, null, 0, 40, 0],
+      [0, 10, 0, null, 0, 10, 0],
+      [10, 80, 20, null, 10, 80, 20],
+      [0, 40, 0, null, 0, 40, 0]
+    ],
+    "contours": {
+      "coloring": "lines",
+      "showlabels": true
+    },
+    "reversescale": true,
+    "line": {
+      "width": 4
+    },
+    "colorbar": {
+      "x": -0.15,
+      "len": 0.25,
+      "y": 0.65
+    }
+  }, {
+    "type": "heatmap",
+    "name": "w/ 1d z",
+    "x": [
+      ["2017", "2017", "2017", "2017", "2018", "2018", "2018"],
+      ["q1", "q2", "q3", "q4", "q1", "q2", "q3"]
+    ],
+    "y": [
+      ["Group 1", "Group 1", "Group 1", "Group 2", "Group 2", "Group 2", "Group 3", "Group 3", "Group 3"],
+      ["A", "B", "C", "A", "B", "C", "A", "B", "C"]
+    ],
+    "z": [1, 2, 3, 6, 5, 4, 11, 12, 13],
+    "opacity": 0.4,
+    "colorscale": "Greens",
+    "colorbar": {
+      "x": -0.15,
+      "len": 0.25,
+      "y": 0.35
+    }
+  }, {
+    "type": "heatmap",
+    "name": "w/ x0|y0",
+    "x0": 2,
+    "y0": 2,
+    "z": [[1, 2], [12, 13]],
+    "opacity": 0.4,
+    "colorscale": "Reds",
+    "colorbar": {
+      "x": -0.15,
+      "len": 0.25,
+      "y": 0,
+      "yanchor": "bottom"
+    }
+  }],
+  "layout": {
+    "title": {
+      "text": "Multi-category heatmap/contour",
+      "x": 0,
+      "xref": "paper"
+    },
+    "xaxis": {
+      "tickson": "boundaries"
+    },
+    "yaxis": {
+      "tickson": "boundaries",
+      "side": "right"
+    }
+  }
+}
diff --git a/test/image/mocks/multicategory-mirror.json b/test/image/mocks/multicategory-mirror.json
new file mode 100644
index 00000000000..dd11116228e
--- /dev/null
+++ b/test/image/mocks/multicategory-mirror.json
@@ -0,0 +1,34 @@
+{
+  "data": [
+    {
+      "type": "bar",
+      "x": [
+        ["2017", "2017", "2017", "2017", "2018", "2018", "2018"],
+        ["q1", "q2", "q3", "q4", "q1", "q2", "q3" ]
+      ],
+      "y": [1, 2, 3, 1, 3, 2, 3, 1]
+    },
+    {
+      "type": "bar",
+      "x": [
+        ["2017", "2017", "2017", "2017", "2018", "2018", "2018"],
+        ["q1", "q2", "q3", "q4", "q1", "q2", "q3"]
+      ],
+      "y": [1.12, 2.15, 3.07, 1.48, 2.78, 1.95, 2.54, 0.64]
+    }
+  ],
+  "layout": {
+    "xaxis": {
+      "ticks": "outside",
+      "showline": true,
+      "mirror": "ticks",
+      "zeroline": false
+    },
+    "yaxis": {
+      "showline": true,
+      "ticks": "outside",
+      "mirror": "ticks",
+      "range": [-0.5, 3.5]
+    }
+  }
+}
diff --git a/test/image/mocks/multicategory-y.json b/test/image/mocks/multicategory-y.json
new file mode 100644
index 00000000000..263ab1db6c7
--- /dev/null
+++ b/test/image/mocks/multicategory-y.json
@@ -0,0 +1,77 @@
+{
+  "data": [
+    {
+      "type": "bar",
+      "orientation": "h",
+      "y": [
+        ["2017", "2017", "2017", "2017", "2018", "2018", "2018"],
+        ["q1", "q2", "q3", "q4", "q1", "q2", "q3" ]
+      ],
+      "x": [1, 2, 3, 1, 3, 2, 3, 1]
+    },
+    {
+      "type": "scatter",
+      "y": [
+        ["2017", "2017", "2017", "2017", "2018", "2018", "2018"],
+        ["q1", "q2", "q3", "q4", "q1", "q2", "q3"]
+      ],
+      "x": [1.12, 2.15, 3.07, 1.48, 2.78, 1.95, 2.54, 0.64]
+    },
+
+    {
+      "mode": "markers",
+      "marker": {
+        "symbol": "square"
+      },
+      "y": [
+        ["2017", "2017", "2017", "2017", "2018", "2018", "2018"],
+        ["q1", "looooooooooooooooong", "q3", "q4", "q1", "q2", "q3"]
+      ],
+      "x": [1, 2, 3, 1, 3, 2, 3, 1],
+      "xaxis": "x2",
+      "yaxis": "y2"
+    }
+  ],
+  "layout": {
+    "grid": {
+      "rows": 2,
+      "columns": 1,
+      "pattern": "independent",
+      "ygap": 0.2
+    },
+    "yaxis": {
+      "title": "MULTI-CATEGORY",
+      "tickfont": {"size": 16},
+      "ticks": "outside",
+      "tickson": "boundaries"
+    },
+    "yaxis2": {
+      "title": "MULTI-CATEGORY",
+      "tickfont": {"size": 12},
+      "ticks": "outside",
+      "tickson": "boundaries"
+    },
+    "xaxis": {
+      "domain": [0.05, 1]
+    },
+    "xaxis2": {
+      "domain": [0.3, 1]
+    },
+    "showlegend": false,
+    "hovermode": "y",
+    "width": 600,
+    "height": 700,
+    "annotations": [{
+      "text": "LOOK",
+      "xref": "x", "x": 3,
+      "yref": "y", "y": 6,
+      "ax": 50, "ay": -50
+    }],
+    "shapes": [{
+      "type": "line",
+      "xref": "paper", "x0": 0.05, "x1": 1,
+      "yref": "y", "y0": 7, "y1": 7,
+      "line": {"color": "red"}
+    }]
+  }
+}
diff --git a/test/image/mocks/multicategory.json b/test/image/mocks/multicategory.json
new file mode 100644
index 00000000000..815358e3175
--- /dev/null
+++ b/test/image/mocks/multicategory.json
@@ -0,0 +1,27 @@
+{
+  "data": [
+    {
+      "type": "bar",
+      "x": [
+        ["2017", "2017", "2017", "2017", "2018", "2018", "2018"],
+        ["q1", "q2", "q3", "q4", "q1", "q2", "q3" ]
+      ],
+      "y": [1, 2, 3, 1, 3, 2, 3, 1]
+    },
+    {
+      "type": "bar",
+      "x": [
+        ["2017", "2017", "2017", "2017", "2018", "2018", "2018"],
+        ["q1", "q2", "q3", "q4", "q1", "q2", "q3"]
+      ],
+      "y": [1.12, 2.15, 3.07, 1.48, 2.78, 1.95, 2.54, 0.64]
+    }
+  ],
+  "layout": {
+    "xaxis": {
+      "title": "MULTI-CATEGORY",
+      "tickfont": {"size": 16},
+      "ticks": "outside"
+    }
+  }
+}
diff --git a/test/image/mocks/multicategory2.json b/test/image/mocks/multicategory2.json
new file mode 100644
index 00000000000..8f068be2077
--- /dev/null
+++ b/test/image/mocks/multicategory2.json
@@ -0,0 +1,37 @@
+{
+  "data": [
+    {
+      "mode": "markers",
+      "marker": {
+        "symbol": "square"
+      },
+      "x": [
+        ["2017", "2017", "2017", "2017", "2018", "2018", "2018"],
+        ["q1", "looooooooooooooooong", "q3", "q4", "q1", "q2", "q3"]
+      ],
+      "y": [1, 2, 3, 1, 3, 2, 3, 1]
+    },
+    {
+      "mode": "markers",
+      "marker": {
+        "symbol": "square"
+      },
+      "x": [
+        ["2017", "2017", "2017", "2017", "2018", "2018", "2018"],
+        ["q1", "looooooooooooooooong", "q3", "q4", "q1", "q2", "q3"]
+      ],
+      "y": [0.63, 2.17, 3.11, 1.07, 3.08, 1.94, 2.55, 0.59]
+    }
+  ],
+  "layout": {
+    "xaxis": {
+      "title": "MULTI-CATEGORY ON TOP",
+      "side": "top",
+      "automargin": true,
+      "tickson": "labels"
+    },
+    "showlegend": false,
+    "width": 400,
+    "height": 800
+  }
+}
diff --git a/test/image/mocks/multicategory_histograms.json b/test/image/mocks/multicategory_histograms.json
new file mode 100644
index 00000000000..114c05e993e
--- /dev/null
+++ b/test/image/mocks/multicategory_histograms.json
@@ -0,0 +1,121 @@
+{
+  "data": [
+    {
+      "type": "histogram2d",
+      "name": "hist2d",
+      "x": [
+        [ 2017, 2018, 2017, 2018, 2017, 2018, 2017, 2018, 2017, 2018, 2017, 2018 ],
+        [ "a", "a", "a", "a", "a", "a", "b", "b", "c", "c", "c", "c" ]
+      ],
+      "y": [
+        [ 2017, 2018, 2017, 2018, 2017, 2018, 2017, 2018, 2017, 2018, 2017, 2018, 2018, 2017, 2017 ],
+        [ "a", "a", "a", "a", "a", "a", "b", "b", "c", "c", "c", "c", "b", "c", "a" ]
+      ],
+      "colorscale": "Viridis",
+      "opacity": 0.8,
+      "colorbar": {
+        "x": 0.7,
+        "xanchor": "left",
+        "y": 0.7,
+        "yanchor": "bottom",
+        "len": 0.3,
+        "title": {
+          "side": "right",
+          "text": "hist2d"
+        }
+      }
+    },
+    {
+      "mode": "markers",
+      "x": [
+        [ 2017, 2018, 2017, 2018, 2017, 2018, 2017, 2018, 2017, 2018, 2017, 2018 ],
+        [ "a", "a", "a", "a", "a", "a", "b", "b", "c", "c", "c", "c" ]
+      ],
+      "y": [
+        [ 2017, 2018, 2017, 2018, 2017, 2018, 2017, 2018, 2017, 2018, 2017, 2018, 2018, 2017, 2017 ],
+        [ "a", "a", "a", "a", "a", "a", "b", "b", "c", "c", "c", "c", "b", "c", "a" ]
+      ],
+      "marker": {
+        "color": "#d3d3d3",
+        "size": 18,
+        "opacity": 0.3,
+        "line": {"color": "black", "width": 1}
+      }
+    },
+
+    {
+      "type": "histogram2dcontour",
+      "name": "hist2dcontour",
+      "x": [
+        [ 2017, 2018, 2017, 2018, 2017, 2018, 2017, 2018, 2017, 2018, 2017, 2018 ],
+        [ "a", "a", "a", "a", "a", "a", "b", "b", "c", "c", "c", "c" ]
+      ],
+      "y": [
+        [ 2017, 2018, 2017, 2018, 2017, 2018, 2017, 2018, 2017, 2018, 2017, 2018, 2018, 2017, 2017 ],
+        [ "a", "a", "a", "a", "a", "a", "b", "b", "c", "c", "c", "c", "b", "c", "a" ]
+      ],
+      "contours": {
+        "coloring": "lines"
+      },
+      "line": {
+        "width": 4
+      },
+      "colorscale": "Viridis",
+      "colorbar": {
+        "x": 1,
+        "xanchor": "right",
+        "y": 0.7,
+        "yanchor": "bottom",
+        "len": 0.3,
+        "title": {
+          "side": "right",
+          "text": "hist2dcontour"
+        }
+      }
+    },
+    {
+      "type": "histogram",
+      "name": "hist-x",
+      "x": [
+        [ 2017, 2018, 2017, 2018, 2017, 2018, 2017, 2018, 2017, 2018, 2017, 2018 ],
+        [ "a", "a", "a", "a", "a", "a", "b", "b", "c", "c", "c", "c" ]
+      ],
+      "yaxis": "y2",
+      "marker": {
+        "color": "#008080"
+      }
+    },
+    {
+      "type": "histogram",
+      "name": "hist-y",
+      "y": [
+        [ 2017, 2018, 2017, 2018, 2017, 2018, 2017, 2018, 2017, 2018, 2017, 2018, 2018, 2017, 2017 ],
+        [ "a", "a", "a", "a", "a", "a", "b", "b", "c", "c", "c", "c", "b", "c", "a" ]
+      ],
+      "xaxis": "x2",
+      "marker": {
+        "color": "#008080"
+      }
+    }
+  ],
+  "layout": {
+    "title": {
+      "text": "Multi-category histograms",
+      "xref": "paper",
+      "x": 0
+    },
+    "xaxis": {
+      "domain": [0, 0.65]
+    },
+    "yaxis": {
+      "domain": [0, 0.65]
+    },
+    "xaxis2": {
+      "domain": [0.7, 1]
+    },
+    "yaxis2": {
+      "domain": [0.7, 1]
+    },
+    "showlegend": false
+  }
+}
diff --git a/test/image/mocks/violin_grouped_horz-multicategory.json b/test/image/mocks/violin_grouped_horz-multicategory.json
new file mode 100644
index 00000000000..633c665e8d6
--- /dev/null
+++ b/test/image/mocks/violin_grouped_horz-multicategory.json
@@ -0,0 +1,102 @@
+{
+   "data":[
+      {
+         "x":[
+             0.2, 0.2, 0.6, 1, 0.5, 0.4, 0.2, 0.7, 0.9, 0.1, 0.5, 0.3,
+            0.6, 0.7, 0.3, 0.6, 0, 0.5, 0.7, 0.9, 0.5, 0.8, 0.7, 0.2,
+             0.2, 0.2, 0.6, 1, 0.5, 0.4, 0.2, 0.7, 0.9, 0.1, 0.5, 0.3
+         ],
+         "y":[
+            ["2016", "2016", "2016", "2016", "2016", "2016",
+            "2016", "2016", "2016", "2016", "2016", "2016",
+            "2017", "2017", "2017", "2017", "2017", "2017",
+            "2017", "2017", "2017", "2017", "2017", "2017",
+            "2018", "2018", "2018", "2018", "2018", "2018",
+            "2018", "2018", "2018", "2018", "2018", "2018"],
+
+            ["day 1", "day 1", "day 1", "day 1", "day 1", "day 1",
+            "day 2", "day 2", "day 2", "day 2", "day 2", "day 2",
+            "day 1", "day 1", "day 1", "day 1", "day 1", "day 1",
+            "day 2", "day 2", "day 2", "day 2", "day 2", "day 2",
+            "day 1", "day 1", "day 1", "day 1", "day 1", "day 1",
+            "day 2", "day 2", "day 2", "day 2", "day 2", "day 2"]
+         ],
+         "name":"kale",
+         "marker":{
+            "color":"#3D9970"
+         },
+         "orientation": "h",
+         "type":"violin"
+      },
+      {
+         "x":[
+            0.6, 0.7, 0.3, 0.6, 0, 0.5, 0.7, 0.9, 0.5, 0.8, 0.7, 0.2,
+             0.1, 0.3, 0.1, 0.9, 0.6, 0.6, 0.9, 1, 0.3, 0.6, 0.8, 0.5,
+            0.6, 0.7, 0.3, 0.6, 0, 0.5, 0.7, 0.9, 0.5, 0.8, 0.7, 0.2
+         ],
+         "y":[
+            ["2016", "2016", "2016", "2016", "2016", "2016",
+            "2016", "2016", "2016", "2016", "2016", "2016",
+            "2017", "2017", "2017", "2017", "2017", "2017",
+            "2017", "2017", "2017", "2017", "2017", "2017",
+            "2018", "2018", "2018", "2018", "2018", "2018",
+            "2018", "2018", "2018", "2018", "2018", "2018"],
+
+            ["day 1", "day 1", "day 1", "day 1", "day 1", "day 1",
+            "day 2", "day 2", "day 2", "day 2", "day 2", "day 2",
+            "day 1", "day 1", "day 1", "day 1", "day 1", "day 1",
+            "day 2", "day 2", "day 2", "day 2", "day 2", "day 2",
+            "day 1", "day 1", "day 1", "day 1", "day 1", "day 1",
+            "day 2", "day 2", "day 2", "day 2", "day 2", "day 2"]
+         ],
+         "name":"radishes",
+         "marker":{
+            "color":"#FF4136"
+         },
+         "orientation": "h",
+         "type":"violin"
+      },
+      {
+         "x":[
+             0.1, 0.3, 0.1, 0.9, 0.6, 0.6, 0.9, 1, 0.3, 0.6, 0.8, 0.5,
+             0.1, 0.3, 0.1, 0.9, 0.6, 0.6, 0.9, 1, 0.3, 0.6, 0.8, 0.5,
+             0.2, 0.2, 0.6, 1, 0.5, 0.4, 0.2, 0.7, 0.9, 0.1, 0.5, 0.3
+         ],
+         "y":[
+            ["2016", "2016", "2016", "2016", "2016", "2016",
+            "2016", "2016", "2016", "2016", "2016", "2016",
+            "2017", "2017", "2017", "2017", "2017", "2017",
+            "2017", "2017", "2017", "2017", "2017", "2017",
+            "2018", "2018", "2018", "2018", "2018", "2018",
+            "2018", "2018", "2018", "2018", "2018", "2018"],
+
+            ["day 1", "day 1", "day 1", "day 1", "day 1", "day 1",
+            "day 2", "day 2", "day 2", "day 2", "day 2", "day 2",
+            "day 1", "day 1", "day 1", "day 1", "day 1", "day 1",
+            "day 2", "day 2", "day 2", "day 2", "day 2", "day 2",
+            "day 1", "day 1", "day 1", "day 1", "day 1", "day 1",
+            "day 2", "day 2", "day 2", "day 2", "day 2", "day 2"]
+         ],
+         "name":"carrots",
+         "marker":{
+            "color":"#FF851B"
+         },
+         "orientation": "h",
+         "type":"violin"
+      }
+   ],
+   "layout":{
+      "yaxis":{
+         "zeroline":false,
+         "title":"normalized moisture"
+      },
+      "violinmode":"group",
+      "legend": {
+        "x": 0,
+        "xanchor": "right",
+        "y": 1,
+        "yanchor": "bottom"
+      },
+      "margin": {"l": 100}
+   }
+}
diff --git a/test/jasmine/assets/mock_lists.js b/test/jasmine/assets/mock_lists.js
index 7b6387ab140..1918a44f77f 100644
--- a/test/jasmine/assets/mock_lists.js
+++ b/test/jasmine/assets/mock_lists.js
@@ -21,6 +21,7 @@ var svgMockList = [
     ['geo_first', require('@mocks/geo_first.json')],
     ['layout_image', require('@mocks/layout_image.json')],
     ['layout-colorway', require('@mocks/layout-colorway.json')],
+    ['multicategory', require('@mocks/multicategory.json')],
     ['polar_categories', require('@mocks/polar_categories.json')],
     ['polar_direction', require('@mocks/polar_direction.json')],
     ['polar_wind-rose', require('@mocks/polar_wind-rose.json')],
diff --git a/test/jasmine/tests/axes_test.js b/test/jasmine/tests/axes_test.js
index 39969dd2f57..a4f31b78d11 100644
--- a/test/jasmine/tests/axes_test.js
+++ b/test/jasmine/tests/axes_test.js
@@ -267,6 +267,68 @@ describe('Test axes', function() {
                 });
                 checkTypes('date', 'linear');
             });
+
+            it('2d coordinate array are considered *multicategory*', function() {
+                supplyWithTrace({
+                    x: [
+                        [2018, 2018, 2017, 2017],
+                        ['a', 'b', 'a', 'b']
+                    ],
+                    y: [
+                        ['a', 'b', 'c'],
+                        ['d', 'e', 'f']
+                    ]
+                });
+                checkTypes('multicategory', 'multicategory');
+
+                supplyWithTrace({
+                    x: [
+                        [2018, 2018, 2017, 2017],
+                        [2018, 2018, 2017, 2017]
+                    ],
+                    y: [
+                        ['2018', '2018', '2017', '2017'],
+                        ['2018', '2018', '2017', '2017']
+                    ]
+                });
+                checkTypes('multicategory', 'multicategory');
+
+                supplyWithTrace({
+                    x: [
+                        new Float32Array([2018, 2018, 2017, 2017]),
+                        [2018, 2018, 2017, 2017]
+                    ],
+                    y: [
+                        [2018, 2018, 2017, 2017],
+                        new Float64Array([2018, 2018, 2017, 2017])
+                    ]
+                });
+                checkTypes('multicategory', 'multicategory');
+
+                supplyWithTrace({
+                    x: [
+                        [2018, 2018, 2017, 2017]
+                    ],
+                    y: [
+                        null,
+                        ['d', 'e', 'f']
+                    ]
+                });
+                checkTypes('linear', 'linear');
+
+                supplyWithTrace({
+                    type: 'carpet',
+                    x: [
+                        [2018, 2018, 2017, 2017],
+                        ['a', 'b', 'a', 'b']
+                    ],
+                    y: [
+                        ['a', 'b', 'c'],
+                        ['d', 'e', 'f']
+                    ]
+                });
+                checkTypes('linear', 'linear');
+            });
         });
 
         it('should set undefined linewidth/linecolor if linewidth, linecolor or showline is not supplied', function() {
@@ -1347,6 +1409,14 @@ describe('Test axes', function() {
             expect(axOut.tickvals).toEqual([2, 4, 6, 8]);
             expect(axOut.ticktext).toEqual(['who', 'do', 'we', 'appreciate']);
         });
+
+        it('should not coerce ticktext/tickvals on multicategory axes', function() {
+            var axIn = {tickvals: [1, 2, 3], ticktext: ['4', '5', '6']};
+            var axOut = {};
+            mockSupplyDefaults(axIn, axOut, 'multicategory');
+            expect(axOut.tickvals).toBe(undefined);
+            expect(axOut.ticktext).toBe(undefined);
+        });
     });
 
     describe('saveRangeInitial', function() {
@@ -2769,6 +2839,112 @@ describe('Test axes', function() {
                 expect(out).toEqual([946684800000, 978307200000, 1009843200000]);
             });
         });
+
+        describe('should set up category maps correctly for multicategory axes', function() {
+            it('case 1', function() {
+                var out = _makeCalcdata({
+                    x: [['1', '1', '2', '2'], ['a', 'b', 'a', 'b']]
+                }, 'x', 'multicategory');
+
+                expect(out).toEqual([0, 1, 2, 3]);
+                expect(ax._categories).toEqual([['1', 'a'], ['1', 'b'], ['2', 'a'], ['2', 'b']]);
+                expect(ax._categoriesMap).toEqual({'1,a': 0, '1,b': 1, '2,a': 2, '2,b': 3});
+            });
+
+            it('case 2', function() {
+                var out = _makeCalcdata({
+                    x: [['1', '2', '1', '2'], ['a', 'a', 'b', 'b']]
+                }, 'x', 'multicategory');
+
+                expect(out).toEqual([0, 1, 2, 3]);
+                expect(ax._categories).toEqual([['1', 'a'], ['1', 'b'], ['2', 'a'], ['2', 'b']]);
+                expect(ax._categoriesMap).toEqual({'1,a': 0, '1,b': 1, '2,a': 2, '2,b': 3});
+            });
+
+            it('case invalid in x[0]', function() {
+                var out = _makeCalcdata({
+                    x: [['1', '2', null, '2'], ['a', 'a', 'b', 'b']]
+                }, 'x', 'multicategory');
+
+                expect(out).toEqual([0, 1, 2, BADNUM]);
+                expect(ax._categories).toEqual([['1', 'a'], ['2', 'a'], ['2', 'b']]);
+                expect(ax._categoriesMap).toEqual({'1,a': 0, '2,a': 1, '2,b': 2});
+            });
+
+            it('case invalid in x[1]', function() {
+                var out = _makeCalcdata({
+                    x: [['1', '2', '1', '2'], ['a', 'a', null, 'b']]
+                }, 'x', 'multicategory');
+
+                expect(out).toEqual([0, 1, 2, BADNUM]);
+                expect(ax._categories).toEqual([['1', 'a'], ['2', 'a'], ['2', 'b']]);
+                expect(ax._categoriesMap).toEqual({'1,a': 0, '2,a': 1, '2,b': 2});
+            });
+
+            it('case 1D coordinate array', function() {
+                var out = _makeCalcdata({
+                    x: ['a', 'b', 'c']
+                }, 'x', 'multicategory');
+
+                expect(out).toEqual([BADNUM, BADNUM, BADNUM]);
+                expect(ax._categories).toEqual([]);
+                expect(ax._categoriesMap).toEqual(undefined);
+            });
+
+            it('case 2D 1-row coordinate array', function() {
+                var out = _makeCalcdata({
+                    x: [['a', 'b', 'c']]
+                }, 'x', 'multicategory');
+
+                expect(out).toEqual([BADNUM, BADNUM, BADNUM]);
+                expect(ax._categories).toEqual([]);
+                expect(ax._categoriesMap).toEqual(undefined);
+            });
+
+            it('case 2D with empty x[0] row coordinate array', function() {
+                var out = _makeCalcdata({
+                    x: [null, ['a', 'b', 'c']]
+                }, 'x', 'multicategory');
+
+                expect(out).toEqual([BADNUM, BADNUM]);
+                expect(ax._categories).toEqual([]);
+                expect(ax._categoriesMap).toEqual(undefined);
+            });
+
+            it('case with inner typed arrays and set type:multicategory', function() {
+                var out = _makeCalcdata({
+                    x: [
+                        new Float32Array([1, 2, 1, 2]),
+                        new Float32Array([10, 10, 20, 20])
+                    ]
+                }, 'x', 'multicategory');
+
+                expect(out).toEqual([0, 1, 2, 3]);
+                expect(ax._categories).toEqual([[1, 10], [1, 20], [2, 10], [2, 20]]);
+                expect(ax._categoriesMap).toEqual({'1,10': 0, '1,20': 1, '2,10': 2, '2,20': 3});
+            });
+        });
+
+        describe('2d coordinate array on non-multicategory axes should return BADNUMs', function() {
+            var axTypes = ['linear', 'log', 'date'];
+
+            axTypes.forEach(function(t) {
+                it('- case ' + t, function() {
+                    var out = _makeCalcdata({
+                        x: [['1', '1', '2', '2'], ['a', 'b', 'a', 'b']]
+                    }, 'x', t);
+                    expect(out).toEqual([BADNUM, BADNUM, BADNUM, BADNUM]);
+                });
+            });
+
+            it('- case category', function() {
+                var out = _makeCalcdata({
+                    x: [['1', '1', '2', '2'], ['a', 'b', 'a', 'b']]
+                }, 'x', 'category');
+                // picks out length=4
+                expect(out).toEqual([0, 1, undefined, undefined]);
+            });
+        });
     });
 
     describe('automargin', function() {
@@ -2800,7 +2976,7 @@ describe('Test axes', function() {
 
             Plotly.plot(gd, data)
             .then(function() {
-                expect(gd._fullLayout.xaxis._lastangle).toBe(30);
+                expect(gd._fullLayout.xaxis._tickAngles.xtick).toBe(30);
 
                 initialSize = previousSize = Lib.extendDeep({}, gd._fullLayout._size);
                 return Plotly.relayout(gd, {'yaxis.automargin': true});
diff --git a/test/jasmine/tests/bar_test.js b/test/jasmine/tests/bar_test.js
index e950af844ee..f2b4911222a 100644
--- a/test/jasmine/tests/bar_test.js
+++ b/test/jasmine/tests/bar_test.js
@@ -878,6 +878,24 @@ describe('Bar.crossTraceCalc (formerly known as setPositions)', function() {
         var ya = gd._fullLayout.yaxis;
         expect(Axes.getAutoRange(gd, ya)).toBeCloseToArray([1.496, 2.027], undefined, '(ya.range)');
     });
+
+    it('should ignore *base* on category axes', function() {
+        var gd = mockBarPlot([
+            {x: ['a', 'b', 'c'], base: [0.2, -0.2, 1]},
+        ]);
+
+        expect(gd._fullLayout.xaxis.type).toBe('category');
+        assertPointField(gd.calcdata, 'b', [[0, 0, 0]]);
+    });
+
+    it('should ignore *base* on multicategory axes', function() {
+        var gd = mockBarPlot([
+            {x: [['a', 'a', 'b', 'b'], ['1', '2', '1', '2']], base: 10}
+        ]);
+
+        expect(gd._fullLayout.xaxis.type).toBe('multicategory');
+        assertPointField(gd.calcdata, 'b', [[0, 0, 0, 0]]);
+    });
 });
 
 describe('A bar plot', function() {
diff --git a/test/jasmine/tests/cartesian_interact_test.js b/test/jasmine/tests/cartesian_interact_test.js
index 4843939de02..b124e9cc2fb 100644
--- a/test/jasmine/tests/cartesian_interact_test.js
+++ b/test/jasmine/tests/cartesian_interact_test.js
@@ -673,6 +673,77 @@ describe('axis zoom/pan and main plot zoom', function() {
         .catch(failTest)
         .then(done);
     });
+
+    it('should compute correct multicategory tick label span during drag', function(done) {
+        var fig = Lib.extendDeep({}, require('@mocks/multicategory.json'));
+
+        var dragCoverNode;
+        var p1;
+
+        function _dragStart(draggerClassName, p0, dp) {
+            var node = getDragger('xy', draggerClassName);
+            mouseEvent('mousemove', p0[0], p0[1], {element: node});
+            mouseEvent('mousedown', p0[0], p0[1], {element: node});
+
+            var promise = drag.waitForDragCover().then(function(dcn) {
+                dragCoverNode = dcn;
+                p1 = [p0[0] + dp[0], p0[1] + dp[1]];
+                mouseEvent('mousemove', p1[0], p1[1], {element: dragCoverNode});
+            });
+            return promise;
+        }
+
+        function _assertAndDragEnd(msg, exp) {
+            _assertLabels(msg, exp);
+            mouseEvent('mouseup', p1[0], p1[1], {element: dragCoverNode});
+            return drag.waitForDragCoverRemoval();
+        }
+
+        function _assertLabels(msg, exp) {
+            var tickLabels = d3.select(gd).selectAll('.xtick > text');
+            expect(tickLabels.size()).toBe(exp.angle.length, msg + ' - # of tick labels');
+
+            tickLabels.each(function(_, i) {
+                var t = d3.select(this).attr('transform');
+                var rotate = (t.split('rotate(')[1] || '').split(')')[0];
+                var angle = rotate.split(',')[0];
+                expect(Number(angle)).toBe(exp.angle[i], msg + ' - node ' + i);
+
+            });
+
+            var tickLabels2 = d3.select(gd).selectAll('.xtick2 > text');
+            expect(tickLabels2.size()).toBe(exp.y.length, msg + ' - # of secondary labels');
+
+            tickLabels2.each(function(_, i) {
+                var y = d3.select(this).attr('y');
+                expect(Number(y)).toBeWithin(exp.y[i], 5, msg + ' - node ' + i);
+            });
+        }
+
+        Plotly.plot(gd, fig)
+        .then(function() {
+            _assertLabels('base', {
+                angle: [0, 0, 0, 0, 0, 0, 0],
+                y: [406, 406]
+            });
+        })
+        .then(function() { return _dragStart('edrag', [585, 390], [-340, 0]); })
+        .then(function() {
+            return _assertAndDragEnd('drag to wide-range -> rotates labels', {
+                angle: [90, 90, 90, 90, 90, 90, 90],
+                y: [430, 430]
+            });
+        })
+        .then(function() { return _dragStart('edrag', [585, 390], [100, 0]); })
+        .then(function() {
+            return _assertAndDragEnd('drag to narrow-range -> un-rotates labels', {
+                angle: [0, 0, 0, 0, 0, 0, 0],
+                y: [406, 406]
+            });
+        })
+        .catch(failTest)
+        .then(done);
+    });
 });
 
 describe('Event data:', function() {
diff --git a/test/jasmine/tests/heatmap_test.js b/test/jasmine/tests/heatmap_test.js
index 28e18931514..7152e38429d 100644
--- a/test/jasmine/tests/heatmap_test.js
+++ b/test/jasmine/tests/heatmap_test.js
@@ -164,15 +164,10 @@ describe('heatmap convertColumnXYZ', function() {
     'use strict';
 
     var trace;
-
-    function makeMockAxis() {
-        return {
-            d2c: function(v) { return v; }
-        };
-    }
-
-    var xa = makeMockAxis();
-    var ya = makeMockAxis();
+    var xa = {type: 'linear'};
+    var ya = {type: 'linear'};
+    setConvert(xa);
+    setConvert(ya);
 
     function checkConverted(trace, x, y, z) {
         trace._length = Math.min(trace.x.length, trace.y.length, trace.z.length);
@@ -303,6 +298,13 @@ describe('heatmap calc', function() {
 
         fullTrace._extremes = {};
 
+        // we used to call ax.setScale during supplyDefaults, and this had a
+        // fallback to provide _categories and _categoriesMap. Now neither of
+        // those is true... anyway the right way to do this though is
+        // ax.clearCalc.
+        fullLayout.xaxis.clearCalc();
+        fullLayout.yaxis.clearCalc();
+
         var out = Heatmap.calc(gd, fullTrace)[0];
         out._xcategories = fullLayout.xaxis._categories;
         out._ycategories = fullLayout.yaxis._categories;
diff --git a/test/jasmine/tests/hover_label_test.js b/test/jasmine/tests/hover_label_test.js
index b8b3bc16dad..5e0ea9a037b 100644
--- a/test/jasmine/tests/hover_label_test.js
+++ b/test/jasmine/tests/hover_label_test.js
@@ -2055,6 +2055,81 @@ describe('hover on fill', function() {
     });
 });
 
+describe('Hover on multicategory axes', function() {
+    var gd;
+    var eventData;
+
+    beforeEach(function() {
+        gd = createGraphDiv();
+    });
+
+    afterEach(destroyGraphDiv);
+
+    function _hover(x, y) {
+        delete gd._hoverdata;
+        Lib.clearThrottle();
+        mouseEvent('mousemove', x, y);
+    }
+
+    it('should work for bar traces', function(done) {
+        Plotly.plot(gd, [{
+            type: 'bar',
+            x: [['2018', '2018', '2019', '2019'], ['a', 'b', 'a', 'b']],
+            y: [1, 2, -1, 3]
+        }], {
+            bargap: 0,
+            width: 400,
+            height: 400
+        })
+        .then(function() {
+            gd.on('plotly_hover', function(d) {
+                eventData = d.points[0];
+            });
+        })
+        .then(function() { _hover(200, 200); })
+        .then(function() {
+            assertHoverLabelContent({ nums: '−1', axis: '2019 - a' });
+            expect(eventData.x).toEqual(['2019', 'a']);
+        })
+        .then(function() {
+            return Plotly.update(gd,
+                {hovertemplate: 'Sample: %{x[1]}<br>Year: %{x[0]}<extra></extra>'},
+                {hovermode: 'closest'}
+            );
+        })
+        .then(function() { _hover(140, 200); })
+        .then(function() {
+            assertHoverLabelContent({ nums: 'Sample: b\nYear: 2018' });
+            expect(eventData.x).toEqual(['2018', 'b']);
+        })
+        .catch(failTest)
+        .then(done);
+    });
+
+    it('should work on heatmap traces', function(done) {
+        var fig = Lib.extendDeep({}, require('@mocks/heatmap_multicategory.json'));
+        fig.data = [fig.data[0]];
+        fig.layout.width = 500;
+        fig.layout.height = 500;
+
+        Plotly.plot(gd, fig)
+        .then(function() {
+            gd.on('plotly_hover', function(d) {
+                eventData = d.points[0];
+            });
+        })
+        .then(function() { _hover(200, 200); })
+        .then(function() {
+            assertHoverLabelContent({
+                nums: 'x: 2017 - q3\ny: Group 3 - A\nz: 2.303'
+            });
+            expect(eventData.x).toEqual(['2017', 'q3']);
+        })
+        .catch(failTest)
+        .then(done);
+    });
+});
+
 describe('hover updates', function() {
     'use strict';
 
diff --git a/test/jasmine/tests/plot_api_react_test.js b/test/jasmine/tests/plot_api_react_test.js
index 812eb6f63e5..4dbf48ddc8a 100644
--- a/test/jasmine/tests/plot_api_react_test.js
+++ b/test/jasmine/tests/plot_api_react_test.js
@@ -468,6 +468,51 @@ describe('@noCIdep Plotly.react', function() {
         .then(done);
     });
 
+    it('can change from scatter to category scatterpolar and back', function(done) {
+        function scatter() {
+            return {
+                data: [{x: ['a', 'b'], y: [1, 2]}],
+                layout: {width: 400, height: 400, margin: {r: 80, t: 20}}
+            };
+        }
+
+        function scatterpolar() {
+            return {
+                // the bug https://github.com/plotly/plotly.js/issues/3255
+                // required all of this to change:
+                // - type -> scatterpolar
+                // - category theta
+                // - margins changed
+                data: [{type: 'scatterpolar', r: [1, 2, 3], theta: ['a', 'b', 'c']}],
+                layout: {width: 400, height: 400, margin: {r: 80, t: 50}}
+            };
+        }
+
+        function countTraces(scatterTraces, polarTraces) {
+            expect(document.querySelectorAll('.scatter').length)
+                .toBe(scatterTraces + polarTraces);
+            expect(document.querySelectorAll('.xy .scatter').length)
+                .toBe(scatterTraces);
+            expect(document.querySelectorAll('.polar .scatter').length)
+                .toBe(polarTraces);
+        }
+
+        Plotly.newPlot(gd, scatter())
+        .then(function() {
+            countTraces(1, 0);
+            return Plotly.react(gd, scatterpolar());
+        })
+        .then(function() {
+            countTraces(0, 1);
+            return Plotly.react(gd, scatter());
+        })
+        .then(function() {
+            countTraces(1, 0);
+        })
+        .catch(failTest)
+        .then(done);
+    });
+
     it('can change data in candlesticks multiple times', function(done) {
         // test that we've fixed the original issue in
         // https://github.com/plotly/plotly.js/issues/2510
diff --git a/test/jasmine/tests/plots_test.js b/test/jasmine/tests/plots_test.js
index 49c7c33ed54..11db2d50a96 100644
--- a/test/jasmine/tests/plots_test.js
+++ b/test/jasmine/tests/plots_test.js
@@ -83,9 +83,9 @@ describe('Test Plots', function() {
             expect(gd._fullLayout.someFunc).toBe(oldFullLayout.someFunc);
 
             expect(gd._fullLayout.xaxis.c2p)
-                .not.toBe(oldFullLayout.xaxis.c2p, '(set during ax.setScale');
+                .not.toBe(oldFullLayout.xaxis.c2p, '(set during setConvert)');
             expect(gd._fullLayout.yaxis._m)
-                .not.toBe(oldFullLayout.yaxis._m, '(set during ax.setScale');
+                .toBe(oldFullLayout.yaxis._m, '(we don\'t run ax.setScale here)');
         });
 
         it('should include the correct reference to user data', function() {
diff --git a/test/jasmine/tests/range_slider_test.js b/test/jasmine/tests/range_slider_test.js
index 6ba00809e06..a6246a251f4 100644
--- a/test/jasmine/tests/range_slider_test.js
+++ b/test/jasmine/tests/range_slider_test.js
@@ -28,9 +28,7 @@ function getRangeSliderChild(index) {
 }
 
 function countRangeSliderClipPaths() {
-    return d3.selectAll('defs').selectAll('*').filter(function() {
-        return this.id.indexOf('rangeslider') !== -1;
-    }).size();
+    return document.querySelectorAll('defs [id*=rangeslider]').length;
 }
 
 function testTranslate1D(node, val) {
diff --git a/test/jasmine/tests/scatter_test.js b/test/jasmine/tests/scatter_test.js
index 19dea8d9ffd..6e97b1531b1 100644
--- a/test/jasmine/tests/scatter_test.js
+++ b/test/jasmine/tests/scatter_test.js
@@ -203,6 +203,72 @@ describe('Test scatter', function() {
             });
         });
 
+        describe('should find correct coordinate length', function() {
+            function _supply() {
+                supplyDefaults(traceIn, traceOut, defaultColor, layout);
+            }
+
+            it('- x 2d', function() {
+                traceIn = {
+                    x: [
+                        ['1', '2', '1', '2', '1', '2'],
+                        ['a', 'a', 'b', 'b']
+                    ],
+                };
+                _supply();
+                expect(traceOut._length).toBe(4);
+            });
+
+            it('- y 2d', function() {
+                traceIn = {
+                    y: [
+                        ['1', '2', '1', '2', '1', '2'],
+                        ['a', 'a', 'b', 'b']
+                    ],
+                };
+                _supply();
+                expect(traceOut._length).toBe(4);
+            });
+
+            it('- x 2d / y 1d', function() {
+                traceIn = {
+                    x: [
+                        ['1', '2', '1', '2', '1', '2'],
+                        ['a', 'a', 'b', 'b']
+                    ],
+                    y: [1, 2, 3, 4, 5, 6]
+                };
+                _supply();
+                expect(traceOut._length).toBe(4);
+            });
+
+            it('- x 1d / y 2d', function() {
+                traceIn = {
+                    y: [
+                        ['1', '2', '1', '2', '1', '2'],
+                        ['a', 'a', 'b', 'b']
+                    ],
+                    x: [1, 2, 3, 4, 5, 6]
+                };
+                _supply();
+                expect(traceOut._length).toBe(4);
+            });
+
+            it('- x 2d / y 2d', function() {
+                traceIn = {
+                    x: [
+                        ['1', '2', '1', '2', '1', '2'],
+                        ['a', 'a', 'b', 'b', 'c', 'c']
+                    ],
+                    y: [
+                        ['1', '2', '1', '2', '1', '2'],
+                        ['a', 'a', 'b', 'b', 'c', 'c', 'd', 'd']
+                    ]
+                };
+                _supply();
+                expect(traceOut._length).toBe(6);
+            });
+        });
     });
 
     describe('isBubble', function() {
diff --git a/test/jasmine/tests/splom_test.js b/test/jasmine/tests/splom_test.js
index 3c4d81911b3..3e09444efd9 100644
--- a/test/jasmine/tests/splom_test.js
+++ b/test/jasmine/tests/splom_test.js
@@ -820,11 +820,11 @@ describe('Test splom interactions:', function() {
 
         function _assert(msg, exp) {
             var splomScenes = gd._fullLayout._splomScenes;
-            var ids = Object.keys(splomScenes);
+            var ids = gd._fullData.map(function(trace) { return trace.uid; });
 
             for(var i = 0; i < 3; i++) {
                 var drawFn = splomScenes[ids[i]].draw;
-                expect(drawFn).toHaveBeenCalledTimes(exp[i], msg + ' - trace ' + i);
+                expect(drawFn.calls.count()).toBe(exp[i], msg + ' - trace ' + i);
                 drawFn.calls.reset();
             }
         }
@@ -869,7 +869,7 @@ describe('Test splom interactions:', function() {
 
         methods.forEach(function(m) { spyOn(Plots, m).and.callThrough(); });
 
-        function assetsFnCall(msg, exp) {
+        function assertFnCall(msg, exp) {
             methods.forEach(function(m) {
                 expect(Plots[m]).toHaveBeenCalledTimes(exp[m], msg);
                 Plots[m].calls.reset();
@@ -879,7 +879,7 @@ describe('Test splom interactions:', function() {
         spyOn(Lib, 'log');
 
         Plotly.plot(gd, fig).then(function() {
-            assetsFnCall('base', {
+            assertFnCall('base', {
                 cleanPlot: 1,       // called once from inside Plots.supplyDefaults
                 supplyDefaults: 1,
                 doCalcdata: 1
@@ -892,9 +892,9 @@ describe('Test splom interactions:', function() {
             return Plotly.relayout(gd, {width: 4810, height: 3656});
         })
         .then(function() {
-            assetsFnCall('after', {
-                cleanPlot: 4,       // 3 three from supplyDefaults, once in drawFramework
-                supplyDefaults: 3,  // 1 from relayout, 1 from automargin, 1 in drawFramework
+            assertFnCall('after', {
+                cleanPlot: 3,       // 2 from supplyDefaults, once in drawFramework
+                supplyDefaults: 2,  // 1 from relayout, 1 in drawFramework
                 doCalcdata: 1       // once in drawFramework
             });
             assertDims('after', 4810, 3656);