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; + } /* * @@ -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]}
Year: %{x[0]}'}, + {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);