diff --git a/src/components/titles/index.js b/src/components/titles/index.js index 13586b78a6e..0e6b4a07eeb 100644 --- a/src/components/titles/index.js +++ b/src/components/titles/index.js @@ -202,6 +202,8 @@ function draw(gd, titleClass, options) { } }); shift = Math.min(maxshift, shift); + // Keeping track of this for calculation of full axis size if needed + cont._titleScoot = Math.abs(shift); } if(shift > 0 || maxshift < 0) { diff --git a/src/plot_api/plot_schema.js b/src/plot_api/plot_schema.js index b8f8f812dc1..29ffef77c8c 100644 --- a/src/plot_api/plot_schema.js +++ b/src/plot_api/plot_schema.js @@ -548,6 +548,13 @@ function getLayoutAttributes() { delete layoutAttributes.yaxis[xkey]; } } + + /* + * Also some attributes e.g. shift & autoshift only implemented on the yaxis + * at the moment. Remove them from the xaxis. + */ + delete layoutAttributes.xaxis.shift; + delete layoutAttributes.xaxis.autoshift; } else if(_module.name === 'colorscale') { extendDeepAll(layoutAttributes, _module.layoutAttributes); } else if(_module.layoutAttributes) { diff --git a/src/plot_api/subroutines.js b/src/plot_api/subroutines.js index 03e775879a7..9ec735d48de 100644 --- a/src/plot_api/subroutines.js +++ b/src/plot_api/subroutines.js @@ -240,6 +240,9 @@ function lsInner(gd) { } function yLinePathFree(x) { + if(ya._shift !== undefined) { + x += ya._shift; + } return 'M' + x + ',' + ya._offset + 'v' + ya._length; } diff --git a/src/plots/cartesian/axes.js b/src/plots/cartesian/axes.js index 4a5bed02b15..52ea00ddb09 100644 --- a/src/plots/cartesian/axes.js +++ b/src/plots/cartesian/axes.js @@ -2248,13 +2248,32 @@ axes.draw = function(gd, arg, opts) { var axList = (!arg || arg === 'redraw') ? axes.listIds(gd) : arg; + var fullAxList = axes.list(gd); + // Get the list of the overlaying axis for all 'shift' axes + var overlayingShiftedAx = fullAxList.filter(function(ax) { + return ax.autoshift; + }).map(function(ax) { + return ax.overlaying; + }); + + + var axShifts = {'false': {'left': 0, 'right': 0}}; + return Lib.syncOrAsync(axList.map(function(axId) { return function() { if(!axId) return; var ax = axes.getFromId(gd, axId); + + if(!opts) opts = {}; + opts.axShifts = axShifts; + opts.overlayingShiftedAx = overlayingShiftedAx; + var axDone = axes.drawOne(gd, ax, opts); + if(ax._shiftPusher) { + incrementShift(ax, ax._fullDepth || 0, axShifts, true); + } ax._r = ax.range.slice(); ax._rl = Lib.simpleMap(ax._r, ax.r2l); @@ -2293,6 +2312,9 @@ axes.draw = function(gd, arg, opts) { axes.drawOne = function(gd, ax, opts) { opts = opts || {}; + var axShifts = opts.axShifts || {}; + var overlayingShiftedAx = opts.overlayingShiftedAx || []; + var i, sp, plotinfo; ax.setScale(); @@ -2306,15 +2328,35 @@ axes.drawOne = function(gd, ax, opts) { // this happens when updating matched group with 'missing' axes if(!mainPlotinfo) return; + ax._shiftPusher = ax.autoshift || + overlayingShiftedAx.indexOf(ax._id) !== -1 || + overlayingShiftedAx.indexOf(ax.overlaying) !== -1; + // An axis is also shifted by 1/2 of its own linewidth and inside tick length if applicable + // as well as its manually specified `shift` val if we're in the context of `autoshift` + if(ax._shiftPusher & ax.anchor === 'free') { + var selfPush = (ax.linewidth / 2 || 0); + if(ax.ticks === 'inside') { + selfPush += ax.ticklen; + } + incrementShift(ax, selfPush, axShifts, true); + incrementShift(ax, (ax.shift || 0), axShifts, false); + } + + // Somewhat inelegant way of making sure that the shift value is only updated when the + // Axes.DrawOne() function is called from the right context. An issue when redrawing the + // axis as result of using the dragbox, for example. + if(opts.skipTitle !== true || ax._shift === undefined) ax._shift = setShiftVal(ax, axShifts); + var mainAxLayer = mainPlotinfo[axLetter + 'axislayer']; var mainLinePosition = ax._mainLinePosition; + var mainLinePositionShift = mainLinePosition += ax._shift; var mainMirrorPosition = ax._mainMirrorPosition; 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('_'); + var axInfo = [ax.mirror, mainLinePositionShift, mainMirrorPosition].join('_'); for(i = 0; i < vals.length; i++) { vals[i].axInfo = axInfo; } @@ -2409,8 +2451,8 @@ axes.drawOne = function(gd, ax, opts) { var minorTickSigns = axes.getTickSigns(ax, 'minor'); if(ax.ticks || (ax.minor && ax.minor.ticks)) { - var majorTickPath = axes.makeTickPath(ax, mainLinePosition, majorTickSigns[2]); - var minorTickPath = axes.makeTickPath(ax, mainLinePosition, minorTickSigns[2], { minor: true }); + var majorTickPath = axes.makeTickPath(ax, mainLinePositionShift, majorTickSigns[2]); + var minorTickPath = axes.makeTickPath(ax, mainLinePositionShift, minorTickSigns[2], { minor: true }); var mirrorMajorTickPath; var mirrorMinorTickPath; @@ -2496,7 +2538,7 @@ axes.drawOne = function(gd, ax, opts) { layer: mainAxLayer, plotinfo: plotinfo, transFn: transTickLabelFn, - labelFns: axes.makeLabelFns(ax, mainLinePosition) + labelFns: axes.makeLabelFns(ax, mainLinePositionShift) }); }); @@ -2515,28 +2557,34 @@ axes.drawOne = function(gd, ax, opts) { repositionOnUpdate: true, secondary: true, transFn: transTickFn, - labelFns: axes.makeLabelFns(ax, mainLinePosition + standoff * majorTickSigns[4]) + labelFns: axes.makeLabelFns(ax, mainLinePositionShift + standoff * majorTickSigns[4]) }); }); seq.push(function() { - ax._depth = majorTickSigns[4] * (getLabelLevelBbox('tick2')[ax.side] - mainLinePosition); + ax._depth = majorTickSigns[4] * (getLabelLevelBbox('tick2')[ax.side] - mainLinePositionShift); return drawDividers(gd, ax, { vals: dividerVals, layer: mainAxLayer, - path: axes.makeTickPath(ax, mainLinePosition, majorTickSigns[4], { len: ax._depth }), + path: axes.makeTickPath(ax, mainLinePositionShift, majorTickSigns[4], { len: ax._depth }), transFn: transTickFn }); }); } else if(ax.title.hasOwnProperty('standoff')) { seq.push(function() { - ax._depth = majorTickSigns[4] * (getLabelLevelBbox()[ax.side] - mainLinePosition); + ax._depth = majorTickSigns[4] * (getLabelLevelBbox()[ax.side] - mainLinePositionShift); }); } var hasRangeSlider = Registry.getComponentMethod('rangeslider', 'isVisible')(ax); + if(!opts.skipTitle && + !(hasRangeSlider && ax.side === 'bottom') + ) { + seq.push(function() { return drawTitle(gd, ax); }); + } + seq.push(function() { var s = ax.side.charAt(0); var sMirror = OPPOSITE_SIDE[ax.side].charAt(0); @@ -2548,7 +2596,7 @@ axes.drawOne = function(gd, ax, opts) { var mirrorPush; var rangeSliderPush; - if(ax.automargin || hasRangeSlider) { + if(ax.automargin || hasRangeSlider || ax._shiftPusher) { if(ax.type === 'multicategory') { llbbox = getLabelLevelBbox('tick2'); } else { @@ -2559,10 +2607,27 @@ axes.drawOne = function(gd, ax, opts) { } } + var axDepth = 0; + var titleDepth = 0; + if(ax._shiftPusher) { + axDepth = Math.max( + outsideTickLen, + llbbox.height > 0 ? (s === 'l' ? pos - llbbox.left : llbbox.right - pos) : 0 + ); + if(ax.title.text !== fullLayout._dfltTitle[axLetter]) { + titleDepth = (ax._titleStandoff || 0) + (ax._titleScoot || 0); + if(s === 'l') { + titleDepth += approxTitleDepth(ax); + } + } + + ax._fullDepth = Math.max(axDepth, titleDepth); + } + if(ax.automargin) { push = {x: 0, y: 0, r: 0, l: 0, t: 0, b: 0}; var domainIndices = [0, 1]; - + var shift = typeof ax._shift === 'number' ? ax._shift : 0; if(axLetter === 'x') { if(s === 'b') { push[s] = ax._depth; @@ -2585,9 +2650,11 @@ axes.drawOne = function(gd, ax, opts) { } } else { if(s === 'l') { - push[s] = ax._depth = Math.max(llbbox.height > 0 ? pos - llbbox.left : 0, outsideTickLen); + ax._depth = Math.max(llbbox.height > 0 ? pos - llbbox.left : 0, outsideTickLen); + push[s] = ax._depth - shift; } else { - push[s] = ax._depth = Math.max(llbbox.height > 0 ? llbbox.right - pos : 0, outsideTickLen); + ax._depth = Math.max(llbbox.height > 0 ? llbbox.right - pos : 0, outsideTickLen); + push[s] = ax._depth + shift; domainIndices.reverse(); } @@ -2626,7 +2693,6 @@ axes.drawOne = function(gd, ax, opts) { } } } - if(hasRangeSlider) { rangeSliderPush = Registry.getComponentMethod('rangeslider', 'autoMarginOpts')(gd, ax); } @@ -2641,12 +2707,6 @@ axes.drawOne = function(gd, ax, opts) { Plots.autoMargin(gd, rangeSliderAutoMarginID(ax), rangeSliderPush); }); - if(!opts.skipTitle && - !(hasRangeSlider && ax.side === 'bottom') - ) { - seq.push(function() { return drawTitle(gd, ax); }); - } - return Lib.syncOrAsync(seq); }; @@ -3779,7 +3839,7 @@ axes.getPxPosition = function(gd, ax) { }; } else if(axLetter === 'y') { anchorAxis = { - _offset: gs.l + (ax.position || 0) * gs.w, + _offset: gs.l + (ax.position || 0) * gs.w + ax._shift, _length: 0 }; } @@ -3902,6 +3962,8 @@ function drawTitle(gd, ax) { } } + ax._titleStandoff = titleStandoff; + return Titles.draw(gd, axId + 'title', { propContainer: ax, propName: ax._name + '.title.text', @@ -4211,3 +4273,27 @@ function hideCounterAxisInsideTickLabels(ax, opts) { } } } + +function incrementShift(ax, shiftVal, axShifts, normalize) { + // Need to set 'overlay' for anchored axis + var overlay = ((ax.anchor !== 'free') && ((ax.overlaying === undefined) || (ax.overlaying === false))) ? ax._id : ax.overlaying; + var shiftValAdj; + if(normalize) { + shiftValAdj = ax.side === 'right' ? shiftVal : -shiftVal; + } else { + shiftValAdj = shiftVal; + } + if(!(overlay in axShifts)) { + axShifts[overlay] = {}; + } + if(!(ax.side in axShifts[overlay])) { + axShifts[overlay][ax.side] = 0; + } + axShifts[overlay][ax.side] += shiftValAdj; +} + +function setShiftVal(ax, axShifts) { + return ax.autoshift ? + axShifts[ax.overlaying][ax.side] : + (ax.shift || 0); +} diff --git a/src/plots/cartesian/axis_defaults.js b/src/plots/cartesian/axis_defaults.js index 969fee18e7f..cb647101f16 100644 --- a/src/plots/cartesian/axis_defaults.js +++ b/src/plots/cartesian/axis_defaults.js @@ -163,8 +163,6 @@ module.exports = function handleAxisDefaults(containerIn, containerOut, coerce, // mirror if(containerOut.showline || containerOut.ticks) coerce('mirror'); - if(options.automargin) coerce('automargin'); - var isMultiCategory = axType === 'multicategory'; if(!options.noTickson && diff --git a/src/plots/cartesian/dragbox.js b/src/plots/cartesian/dragbox.js index 8e88bb03673..dbe068226a7 100644 --- a/src/plots/cartesian/dragbox.js +++ b/src/plots/cartesian/dragbox.js @@ -88,6 +88,9 @@ function makeDragBox(gd, plotinfo, x, y, w, h, ns, ew) { var scaleX; var scaleY; + // offset the x location of the box if needed + x += plotinfo.yaxis._shift; + function recomputeAxisLists() { xa0 = plotinfo.xaxis; ya0 = plotinfo.yaxis; diff --git a/src/plots/cartesian/layout_attributes.js b/src/plots/cartesian/layout_attributes.js index a693fb4cb76..66786bc819b 100644 --- a/src/plots/cartesian/layout_attributes.js +++ b/src/plots/cartesian/layout_attributes.js @@ -1003,6 +1003,30 @@ module.exports = { 'Only has an effect if `anchor` is set to *free*.' ].join(' ') }, + autoshift: { + valType: 'boolean', + dflt: false, + editType: 'plot', + description: [ + 'Automatically reposition the axis to avoid', + 'overlap with other axes with the same `overlaying` value.', + 'This repositioning will account for any `shift` amount applied to other', + 'axes on the same side with `autoshift` is set to true.', + 'Only has an effect if `anchor` is set to *free*.', + ].join(' ') + }, + shift: { + valType: 'number', + editType: 'plot', + description: [ + 'Moves the axis a given number of pixels from where it would have been otherwise.', + 'Accepts both positive and negative values, which will shift the axis either right', + 'or left, respectively.', + 'If `autoshift` is set to true, then this defaults to a padding of -3 if `side` is set to *left*.', + 'and defaults to +3 if `side` is set to *right*. Defaults to 0 if `autoshift` is set to false.', + 'Only has an effect if `anchor` is set to *free*.' + ].join(' ') + }, categoryorder: { valType: 'enumerated', values: [ diff --git a/src/plots/cartesian/layout_defaults.js b/src/plots/cartesian/layout_defaults.js index 1c07600ad6a..97f332a8430 100644 --- a/src/plots/cartesian/layout_defaults.js +++ b/src/plots/cartesian/layout_defaults.js @@ -266,11 +266,24 @@ module.exports = function supplyLayoutDefaults(layoutIn, layoutOut, fullData) { delete axLayoutOut.spikesnap; } + // If it exists, the the domain of the axis for the anchor of the overlaying axis + var overlayingAxis = id2name(axLayoutIn.overlaying); + var overlayingAnchorDomain = [0, 1]; + + if(layoutOut[overlayingAxis] !== undefined) { + var overlayingAnchor = id2name(layoutOut[overlayingAxis].anchor); + if(layoutOut[overlayingAnchor] !== undefined) { + overlayingAnchorDomain = layoutOut[overlayingAnchor].domain; + } + } + handlePositionDefaults(axLayoutIn, axLayoutOut, coerce, { letter: axLetter, counterAxes: counterAxes[axLetter], overlayableAxes: getOverlayableAxes(axLetter, axName), - grid: layoutOut.grid + grid: layoutOut.grid, + overlayingDomain: overlayingAnchorDomain + }); coerce('title.standoff'); diff --git a/src/plots/cartesian/position_defaults.js b/src/plots/cartesian/position_defaults.js index d6fea08172e..cd693439661 100644 --- a/src/plots/cartesian/position_defaults.js +++ b/src/plots/cartesian/position_defaults.js @@ -10,8 +10,8 @@ module.exports = function handlePositionDefaults(containerIn, containerOut, coer var overlayableAxes = options.overlayableAxes || []; var letter = options.letter; var grid = options.grid; - - var dfltAnchor, dfltDomain, dfltSide, dfltPosition; + var overlayingDomain = options.overlayingDomain; + var dfltAnchor, dfltDomain, dfltSide, dfltPosition, dfltShift, dfltAutomargin; if(grid) { dfltDomain = grid._domains[letter][grid._axisMap[containerOut._id]]; @@ -27,6 +27,8 @@ module.exports = function handlePositionDefaults(containerIn, containerOut, coer dfltAnchor = dfltAnchor || (isNumeric(containerIn.position) ? 'free' : (counterAxes[0] || 'free')); dfltSide = dfltSide || (letter === 'x' ? 'bottom' : 'left'); dfltPosition = dfltPosition || 0; + dfltShift = 0; + dfltAutomargin = false; var anchor = Lib.coerce(containerIn, containerOut, { anchor: { @@ -36,9 +38,7 @@ module.exports = function handlePositionDefaults(containerIn, containerOut, coer } }, 'anchor'); - if(anchor === 'free') coerce('position', dfltPosition); - - Lib.coerce(containerIn, containerOut, { + var side = Lib.coerce(containerIn, containerOut, { side: { valType: 'enumerated', values: letter === 'x' ? ['bottom', 'top'] : ['left', 'right'], @@ -46,6 +46,20 @@ module.exports = function handlePositionDefaults(containerIn, containerOut, coer } }, 'side'); + if(anchor === 'free') { + if(letter === 'y') { + var autoshift = coerce('autoshift'); + if(autoshift) { + dfltPosition = side === 'left' ? overlayingDomain[0] : overlayingDomain[1]; + dfltAutomargin = containerOut.automargin ? containerOut.automargin : true; + dfltShift = side === 'left' ? -3 : 3; + } + coerce('shift', dfltShift); + } + coerce('position', dfltPosition); + } + coerce('automargin', dfltAutomargin); + var overlaying = false; if(overlayableAxes.length) { overlaying = Lib.coerce(containerIn, containerOut, { diff --git a/src/plots/plots.js b/src/plots/plots.js index 29b375d62fa..e01f7558205 100644 --- a/src/plots/plots.js +++ b/src/plots/plots.js @@ -1955,6 +1955,17 @@ plots.autoMargin = function(gd, id, o) { } }; +function needsRedrawForShift(gd) { + if('_redrawFromAutoMarginCount' in gd._fullLayout) { + return false; + } + var axList = axisIDs.list(gd, '', true); + for(var ax in axList) { + if(axList[ax].autoshift || axList[ax].shift) return true; + } + return false; +} + plots.doAutoMargin = function(gd) { var fullLayout = gd._fullLayout; var width = fullLayout.width; @@ -2073,7 +2084,7 @@ plots.doAutoMargin = function(gd) { gs.h = Math.round(height) - gs.t - gs.b; // if things changed and we're not already redrawing, trigger a redraw - if(!fullLayout._replotting && plots.didMarginChange(oldMargins, gs)) { + if(!fullLayout._replotting && (plots.didMarginChange(oldMargins, gs) || needsRedrawForShift(gd))) { if('_redrawFromAutoMarginCount' in fullLayout) { fullLayout._redrawFromAutoMarginCount++; } else { diff --git a/test/image/baselines/zz-mult-yaxes-manual-shift.png b/test/image/baselines/zz-mult-yaxes-manual-shift.png new file mode 100644 index 00000000000..b29c78f3cff Binary files /dev/null and b/test/image/baselines/zz-mult-yaxes-manual-shift.png differ diff --git a/test/image/baselines/zz-mult-yaxes-redraw.png b/test/image/baselines/zz-mult-yaxes-redraw.png new file mode 100644 index 00000000000..c7c575f8c81 Binary files /dev/null and b/test/image/baselines/zz-mult-yaxes-redraw.png differ diff --git a/test/image/baselines/zz-mult-yaxes-simple.png b/test/image/baselines/zz-mult-yaxes-simple.png new file mode 100644 index 00000000000..ee5345f52fb Binary files /dev/null and b/test/image/baselines/zz-mult-yaxes-simple.png differ diff --git a/test/image/baselines/zz-mult-yaxes-subplots-stacked.png b/test/image/baselines/zz-mult-yaxes-subplots-stacked.png new file mode 100644 index 00000000000..32de1b96d8b Binary files /dev/null and b/test/image/baselines/zz-mult-yaxes-subplots-stacked.png differ diff --git a/test/image/mocks/zz-mult-yaxes-manual-shift.json b/test/image/mocks/zz-mult-yaxes-manual-shift.json new file mode 100644 index 00000000000..741b7be0c8e --- /dev/null +++ b/test/image/mocks/zz-mult-yaxes-manual-shift.json @@ -0,0 +1,116 @@ +{ + "data": [ + { + "x": [ + 1, + 2, + 3 + ], + "y": [ + 4, + 5, + 6 + ], + "name": "yaxis1 data", + "type": "scatter" + }, + { + "x": [ + 2, + 3, + 4 + ], + "y": [ + 40, + 50, + 60 + ], + "name": "yaxis2 data", + "yaxis": "y2", + "type": "scatter" + }, + { + "x": [ + 3, + 4, + 5 + ], + "y": [ + 400, + 500, + 600 + ], + "name": "yaxis3 data", + "yaxis": "y3", + "type": "scatter" + }, + { + "x": [ + 4, + 5, + 6 + ], + "y": [ + 1000, + 2000, + 3000 + ], + "name": "yaxis4 data", + "yaxis": "y4", + "type": "scatter" + }, + { + "x": [ + 3, + 4, + 5 + ], + "y": [ + 400, + 500, + 600 + ], + "name": "yaxis5 data", + "yaxis": "y5", + "type": "scatter" + } + ], + "layout": { + "title": { + "text": "multiple y-axes example - manual shift" + }, + "width": 800, + "xaxis": {"domain": [0.25, 0.75]}, + "yaxis": {"showline": true, "title": {"text": "yaxis title"}}, + "yaxis2": { + "title": {"text": "yaxis2 title"}, + "overlaying": "y", + "showline": true, + "side": "right" + }, + "yaxis3": { + "title": {"text": "yaxis3 title"}, + "anchor": "free", + "overlaying": "y", + "showline": true, + "autoshift": true, + "shift": -25 + }, + "yaxis4": { + "title": {"text": "yaxis4 title"}, + "anchor": "free", + "overlaying": "y", + "showline": true, + "autoshift": true + }, + "yaxis5": { + "title": {"text": "yaxis5 title"}, + "anchor": "free", + "overlaying": "y", + "position": 0.8, + "shift": 100, + "showline": true, + "side": "left" + } + } +} diff --git a/test/image/mocks/zz-mult-yaxes-redraw.json b/test/image/mocks/zz-mult-yaxes-redraw.json new file mode 100644 index 00000000000..d2fc66c55a7 --- /dev/null +++ b/test/image/mocks/zz-mult-yaxes-redraw.json @@ -0,0 +1,96 @@ +{ + "data": [ + { + "x": [ + 1, + 2, + 3 + ], + "y": [ + 4, + 5, + 6 + ], + "name": "yaxis1 data", + "type": "scatter" + }, + { + "x": [ + 2, + 3, + 4 + ], + "y": [ + 4, + 5, + 6 + ], + "name": "yaxis2 data", + "yaxis": "y2", + "type": "scatter" + }, + { + "x": [ + 3, + 4, + 5 + ], + "y": [ + 4, + 5, + 6 + ], + "name": "yaxis3 data", + "yaxis": "y3", + "type": "scatter" + }, + { + "x": [ + 4, + 5, + 6 + ], + "y": [ + 1, + 2, + 3 + ], + "name": "yaxis4 data", + "yaxis": "y4", + "type": "scatter" + } + ], + "layout": { + "title": { + "text": "multiple y-axes example" + }, + "xaxis": {"domain": [0.25, 0.75]}, + "width": 800, + "yaxis": { + "showline": true, + "title": {"text": "axis 1"} + }, + "yaxis2": { + "overlaying": "y", + "showline": true, + "side": "right", + "title": {"text": "axis 2"} + }, + "yaxis3": { + "anchor": "free", + "overlaying": "y", + "showline": true, + "autoshift": true, + "side": "right", + "title": {"text": "axis 3"} + }, + "yaxis4": { + "anchor": "free", + "overlaying": "y", + "showline": true, + "autoshift": true, + "side": "left", + "title": {"text": "axis 4"} + } + } +} diff --git a/test/image/mocks/zz-mult-yaxes-simple.json b/test/image/mocks/zz-mult-yaxes-simple.json new file mode 100644 index 00000000000..db7c14f483b --- /dev/null +++ b/test/image/mocks/zz-mult-yaxes-simple.json @@ -0,0 +1,174 @@ +{ + "data": [ + { + "x": [ + 1, + 2, + 3 + ], + "y": [ + 4, + 5, + 6 + ], + "name": "yaxis1 data", + "type": "scatter" + }, + { + "x": [ + 2, + 3, + 4 + ], + "y": [ + 4, + 5, + 6 + ], + "name": "yaxis2 data", + "yaxis": "y2", + "type": "scatter" + }, + { + "x": [ + 3, + 4, + 5 + ], + "y": [ + 4, + 5, + 6 + ], + "name": "yaxis3 data", + "yaxis": "y3", + "type": "scatter" + }, + { + "x": [ + 4, + 5, + 6 + ], + "y": [ + 1, + 2, + 3 + ], + "name": "yaxis4 data", + "yaxis": "y4", + "type": "scatter" + }, + { + "x": [ + 5, + 6, + 7 + ], + "y": [ + 1, + 2, + 3 + ], + "name": "yaxis5 data", + "yaxis": "y5", + "type": "scatter" + }, + { + "x": [ + 5, + 6, + 7 + ], + "y": [ + 1, + 2, + 3 + ], + "name": "yaxis6 data", + "yaxis": "y6", + "type": "scatter" + } + ], + "layout": { + "title": { + "text": "multiple y-axes example" + }, + "width": 800, + "yaxis": { + "showline": true, + "ticklen": 15, + "automargin": true, + "linewidth": 10, + "tickfont": {"size": 8}, + "tickformat" : ".5f", + "title": { + "text": "yaxis", + "standoff": 30, + "font": {"size": 8}} + }, + "yaxis2": { + "overlaying": "y", + "showline": true, + "side": "right", + "automargin": true, + "linewidth": 40, + "ticklen": 20, + "ticks": "inside", + "tickformat" : ".1f", + "title": {"text": "yaxis 2"} + }, + "yaxis3": { + "anchor": "free", + "overlaying": "y", + "showline": true, + "autoshift": true, + "linewidth": 20, + "ticklen": 30, + "side": "right", + "tickfont": {"size": 24}, + "tickformat" : ".2f", + "title": { + "text": "yaxis 3", + "standoff": 0, + "font": {"size": 24}} + + }, + "yaxis4": { + "anchor": "free", + "overlaying": "y", + "showline": true, + "autoshift": true, + "side": "right", + "linewidth": 0, + "ticklen": 20, + "ticks": "inside", + "tickformat" : ".2f" + }, + "yaxis5": { + "anchor": "free", + "overlaying": "y", + "showline": true, + "autoshift": true, + "linewidth": 0, + "tickformat" : ".2f", + "title": {"text": "yaxis 5"} + }, + "yaxis6": { + "anchor": "free", + "overlaying": "y", + "showline": true, + "autoshift": true, + "linewidth": 0, + "side": "right", + "tickfont": {"size": 24}, + "tickformat" : ".2f", + "title": {"text": "yaxis 6"} + }, + "legend": { + "x": 1, + "xanchor" : "right", + "y": 1 + } + } +} diff --git a/test/image/mocks/zz-mult-yaxes-subplots-stacked.json b/test/image/mocks/zz-mult-yaxes-subplots-stacked.json new file mode 100644 index 00000000000..a0e66cc44ce --- /dev/null +++ b/test/image/mocks/zz-mult-yaxes-subplots-stacked.json @@ -0,0 +1,113 @@ +{ + "data": [ + { + "x": [ + 1, + 2, + 3 + ], + "y": [ + 4, + 5, + 6 + ], + "name": "yaxis1 data", + "type": "scatter" + }, + { + "x": [ + 2, + 3, + 4 + ], + "y": [ + 40, + 50, + 60 + ], + "name": "yaxis2 data", + "yaxis": "y2", + "xaxis": "x2", + "type": "scatter" + }, + { + "x": [ + 3, + 4, + 5 + ], + "y": [ + 400, + 500, + 600 + ], + "name": "yaxis3 data", + "yaxis": "y3", + "type": "scatter" + }, + { + "x": [ + 4, + 5, + 6 + ], + "y": [ + 1000, + 2000, + 3000 + ], + "name": "yaxis4 data", + "yaxis": "y4", + "xaxis": "x2", + "type": "scatter" + }, + { + "x": [ + 5, + 6, + 7 + ], + "y": [ + 0.5, + 1, + 1.5 + ], + "name": "yaxis5 data", + "yaxis": "y5", + "type": "scatter" + } + ], + "layout": { + "title": { + "text": "multiple y-axes example - stacked subplots" + }, + "grid": {"rows": 2, "columns": 1, "pattern": "independent"}, + "width": 800, + "yaxis": {"showline": true, "title": {"text": "yaxis title"}}, + "yaxis2": { + "title": {"text": "yaxis2 title"}, + "showline": true + }, + "yaxis3": { + "title": {"text": "yaxis3 title"}, + "anchor": "free", + "overlaying": "y", + "showline": true, + "autoshift": true + }, + "yaxis4": { + "title": {"text": "yaxis4 title"}, + "anchor": "free", + "overlaying": "y2", + "showline": true, + "autoshift": true + }, + "yaxis5": { + "title": {"text": "yaxis5 title"}, + "anchor": "free", + "overlaying": "y", + "showline": true, + "autoshift": true + } + } +} diff --git a/test/jasmine/tests/axes_test.js b/test/jasmine/tests/axes_test.js index 14b9419ebcf..1c4b12d4e2a 100644 --- a/test/jasmine/tests/axes_test.js +++ b/test/jasmine/tests/axes_test.js @@ -1432,6 +1432,66 @@ describe('Test axes', function() { expect(layoutOut.xaxis10.rangebreaks[0].enabled).toBe(false, 'reject false'); expect(layoutOut.xaxis11.rangebreaks[0].enabled).toBe(false, 'reject true'); }); + + it('should coerce autoshift and shift only if anchor is *free*', function() { + layoutIn = { + xaxis: {}, + yaxis: {anchor: 'free'} + }; + supplyLayoutDefaults(layoutIn, layoutOut, fullData); + expect(layoutOut.yaxis.autoshift).toBe(false); + expect(layoutOut.yaxis.shift).toEqual(0); + + layoutIn.yaxis.autoshift = true; + supplyLayoutDefaults(layoutIn, layoutOut, fullData); + expect(layoutOut.yaxis.autoshift).toBe(true); + expect(layoutOut.yaxis.shift).toEqual(-3); + + layoutIn.yaxis.anchor = 'x'; + supplyLayoutDefaults(layoutIn, layoutOut, fullData); + expect(layoutOut.yaxis.autoshift).toBeUndefined(); + expect(layoutOut.yaxis.shift).toBeUndefined(); + }); + + it('should set automargin to *true* when shift is *true*', function() { + layoutIn = { + xaxis: {}, + yaxis: {autoshift: true, anchor: 'free'} + }; + supplyLayoutDefaults(layoutIn, layoutOut, fullData); + expect(layoutOut.yaxis.automargin).toBe(true); + }); + + it('should set automargin to *false* when shift is numeric', function() { + layoutIn = { + xaxis: {}, + yaxis: {shift: 100, anchor: 'free'} + }; + supplyLayoutDefaults(layoutIn, layoutOut, fullData); + expect(layoutOut.yaxis.automargin).toBe(false); + }); + + it('should set default axis position if shift is *true* according to overlaying domain', function() { + layoutIn = { + xaxis: {domain: [0.2, 0.5]}, + yaxis: {}, + yaxis2: {autoshift: true, anchor: 'free', overlaying: 'y'} + }; + + layoutOut._subplots.cartesian.push('xy2'); + layoutOut._subplots.yaxis.push('y2'); + supplyLayoutDefaults(layoutIn, layoutOut, fullData); + expect(layoutOut.yaxis2.position).toBe(0.2); + + layoutIn.yaxis2.side = 'right'; + supplyLayoutDefaults(layoutIn, layoutOut, fullData); + expect(layoutOut.yaxis2.position).toBe(0.5); + + // Same should apply if shift is numeric + layoutIn.yaxis2.shift = 100; + supplyLayoutDefaults(layoutIn, layoutOut, fullData); + expect(layoutOut.yaxis2.position).toBe(0.5); + }); }); describe('autorange relayout', function() { @@ -7896,3 +7956,39 @@ describe('more matching axes tests', function() { .then(done, done.fail); }); }); + +describe('shift tests', function() { + var gd; + + beforeEach(function() { + gd = createGraphDiv(); + }); + + function checkLine(selector, position) { + var path = d3Select(gd).select(selector); + var pos = (path.split('d="M')[1]).split(',')[0]; + expect(Number(pos)).toBeCloseTo(position, 2); + } + + afterEach(destroyGraphDiv); + + it('should set y-axis shifts correctly on first draw when shift=true', function() { + var fig = require('@mocks/zz-mult-yaxes-simple.json'); + Plotly.newPlot(gd, fig).then(function() { + checkLine('path.xy3-y.crisp', 550); + checkLine('path.xy4-y.crisp', 691); + expect(gd._fullLayout.yaxis3._shift).toBeCloseTo(97, 2); + expect(gd._fullLayout.yaxis4._shift).toBeCloseTo(243, 2); + }); + }); + + it('should set y-axis shifts correctly on first draw when shift=', function() { + var fig = require('@mocks/zz-mult-yaxes-manual-shift.json'); + Plotly.newPlot(gd, fig).then(function() { + checkLine('path.xy3-y.crisp', 97); + checkLine('path.xy4-y.crisp', 616); + expect(gd._fullLayout.yaxis3._shift).toBeCloseTo(-100, 2); + expect(gd._fullLayout.yaxis4._shift).toBeCloseTo(100, 2); + }); + }); +}); diff --git a/test/plot-schema.json b/test/plot-schema.json index c32d24547c3..0007eb19a4d 100644 --- a/test/plot-schema.json +++ b/test/plot-schema.json @@ -11227,6 +11227,12 @@ "reversed" ] }, + "autoshift": { + "description": "Automatically reposition the axis to avoid overlap with other axes with the same `overlaying` value. This repositioning will account for any `shift` amount applied to other axes on the same side with `autoshift` is set to true. Only has an effect if `anchor` is set to *free*.", + "dflt": false, + "editType": "plot", + "valType": "boolean" + }, "autotypenumbers": { "description": "Using *strict* a numeric string in trace data is not converted to a number. Using *convert types* a numeric string in trace data may be treated as a number during automatic axis `type` detection. Defaults to layout.autotypenumbers.", "dflt": "convert types", @@ -11732,6 +11738,11 @@ "editType": "ticks", "valType": "boolean" }, + "shift": { + "description": "Moves the axis a given number of pixels from where it would have been otherwise. Accepts both positive and negative values, which will shift the axis either right or left, respectively. If `autoshift` is set to true, then this defaults to a padding of -3 if `side` is set to *left*. and defaults to +3 if `side` is set to *right*. Defaults to 0 if `autoshift` is set to false. Only has an effect if `anchor` is set to *free*.", + "editType": "plot", + "valType": "number" + }, "showdividers": { "description": "Determines whether or not a dividers are drawn between the category levels of this axis. Only has an effect on *multicategory* axes.", "dflt": true,