diff --git a/src/plot_api/plot_api.js b/src/plot_api/plot_api.js index e950d614426..cc3a304f522 100644 --- a/src/plot_api/plot_api.js +++ b/src/plot_api/plot_api.js @@ -1932,22 +1932,34 @@ exports.relayout = relayout; // Optimization mostly for large splom traces where // Plots.supplyDefaults can take > 100ms function axRangeSupplyDefaultsByPass(gd, flags, specs) { - var k; + var fullLayout = gd._fullLayout; if(!flags.axrange) return false; - for(k in flags) { + for(var k in flags) { if(k !== 'axrange' && flags[k]) return false; } - for(k in specs.rangesAltered) { - var axName = Axes.id2name(k); + for(var axId in specs.rangesAltered) { + var axName = Axes.id2name(axId); var axIn = gd.layout[axName]; - var axOut = gd._fullLayout[axName]; + var axOut = fullLayout[axName]; axOut.autorange = axIn.autorange; axOut.range = axIn.range.slice(); axOut.cleanRange(); + + if(axOut._matchGroup) { + for(var axId2 in axOut._matchGroup) { + if(axId2 !== axId) { + var ax2 = fullLayout[Axes.id2name(axId2)]; + ax2.autorange = axOut.autorange; + ax2.range = axOut.range.slice(); + ax2._input.range = axOut.range.slice(); + } + } + } } + return true; } @@ -1957,14 +1969,25 @@ function addAxRangeSequence(seq, rangesAltered) { // executed after drawData var drawAxes = rangesAltered ? function(gd) { - var opts = {skipTitle: true}; + var axIds = []; + var skipTitle = true; + for(var id in rangesAltered) { - if(Axes.getFromId(gd, id).automargin) { - opts = {}; - break; + var ax = Axes.getFromId(gd, id); + axIds.push(id); + + if(ax._matchGroup) { + for(var id2 in ax._matchGroup) { + if(!rangesAltered[id2]) { + axIds.push(id2); + } + } } + + if(ax.automargin) skipTitle = false; } - return Axes.draw(gd, Object.keys(rangesAltered), opts); + + return Axes.draw(gd, axIds, {skipTitle: skipTitle}); } : function(gd) { return Axes.draw(gd, 'redraw'); diff --git a/src/plot_api/subroutines.js b/src/plot_api/subroutines.js index a2bb7083dd1..d108bec0966 100644 --- a/src/plot_api/subroutines.js +++ b/src/plot_api/subroutines.js @@ -697,17 +697,49 @@ exports.redrawReglTraces = function(gd) { }; exports.doAutoRangeAndConstraints = function(gd) { + var fullLayout = gd._fullLayout; var axList = Axes.list(gd, '', true); + var matchGroups = fullLayout._axisMatchGroups || []; + var ax; for(var i = 0; i < axList.length; i++) { - var ax = axList[i]; + ax = axList[i]; cleanAxisConstraints(gd, ax); - // in case margins changed, update scale - ax.setScale(); doAutoRange(gd, ax); } enforceAxisConstraints(gd); + + groupLoop: + for(var j = 0; j < matchGroups.length; j++) { + var group = matchGroups[j]; + var rng = null; + var id; + + for(id in group) { + ax = Axes.getFromId(gd, id); + if(ax.autorange === false) continue groupLoop; + + if(rng) { + if(rng[0] < rng[1]) { + rng[0] = Math.min(rng[0], ax.range[0]); + rng[1] = Math.max(rng[1], ax.range[1]); + } else { + rng[0] = Math.max(rng[0], ax.range[0]); + rng[1] = Math.min(rng[1], ax.range[1]); + } + } else { + rng = ax.range; + } + } + + for(id in group) { + ax = Axes.getFromId(gd, id); + ax.range = rng.slice(); + ax._input.range = rng.slice(); + ax.setScale(); + } + } }; // An initial paint must be completed before these components can be diff --git a/src/plots/cartesian/autorange.js b/src/plots/cartesian/autorange.js index 55cf30c279b..153ac9f3f52 100644 --- a/src/plots/cartesian/autorange.js +++ b/src/plots/cartesian/autorange.js @@ -237,6 +237,8 @@ function concatExtremes(gd, ax) { } function doAutoRange(gd, ax) { + ax.setScale(); + if(ax.autorange) { ax.range = getAutoRange(gd, ax); diff --git a/src/plots/cartesian/constraint_defaults.js b/src/plots/cartesian/constraint_defaults.js deleted file mode 100644 index 3b275c457e4..00000000000 --- a/src/plots/cartesian/constraint_defaults.js +++ /dev/null @@ -1,152 +0,0 @@ -/** -* Copyright 2012-2019, Plotly, Inc. -* All rights reserved. -* -* This source code is licensed under the MIT license found in the -* LICENSE file in the root directory of this source tree. -*/ - - -'use strict'; - -var Lib = require('../../lib'); -var id2name = require('./axis_ids').id2name; - - -module.exports = function handleConstraintDefaults(containerIn, containerOut, coerce, allAxisIds, layoutOut) { - var constraintGroups = layoutOut._axisConstraintGroups; - var thisID = containerOut._id; - var letter = thisID.charAt(0); - - if(containerOut.fixedrange) return; - - // coerce the constraint mechanics even if this axis has no scaleanchor - // because it may be the anchor of another axis. - coerce('constrain'); - Lib.coerce(containerIn, containerOut, { - constraintoward: { - valType: 'enumerated', - values: letter === 'x' ? ['left', 'center', 'right'] : ['bottom', 'middle', 'top'], - dflt: letter === 'x' ? 'center' : 'middle' - } - }, 'constraintoward'); - - if(!containerIn.scaleanchor) return; - - var constraintOpts = getConstraintOpts(constraintGroups, thisID, allAxisIds, layoutOut); - - var scaleanchor = Lib.coerce(containerIn, containerOut, { - scaleanchor: { - valType: 'enumerated', - values: constraintOpts.linkableAxes - } - }, 'scaleanchor'); - - if(scaleanchor) { - var scaleratio = coerce('scaleratio'); - // TODO: I suppose I could do attribute.min: Number.MIN_VALUE to avoid zero, - // but that seems hacky. Better way to say "must be a positive number"? - // Of course if you use several super-tiny values you could eventually - // force a product of these to zero and all hell would break loose... - // Likewise with super-huge values. - if(!scaleratio) scaleratio = containerOut.scaleratio = 1; - - updateConstraintGroups(constraintGroups, constraintOpts.thisGroup, - thisID, scaleanchor, scaleratio); - } - else if(allAxisIds.indexOf(containerIn.scaleanchor) !== -1) { - Lib.warn('ignored ' + containerOut._name + '.scaleanchor: "' + - containerIn.scaleanchor + '" to avoid either an infinite loop ' + - 'and possibly inconsistent scaleratios, or because the target' + - 'axis has fixed range.'); - } -}; - -function getConstraintOpts(constraintGroups, thisID, allAxisIds, layoutOut) { - // If this axis is already part of a constraint group, we can't - // scaleanchor any other axis in that group, or we'd make a loop. - // Filter allAxisIds to enforce this, also matching axis types. - - var thisType = layoutOut[id2name(thisID)].type; - - var i, j, idj, axj; - - var linkableAxes = []; - for(j = 0; j < allAxisIds.length; j++) { - idj = allAxisIds[j]; - if(idj === thisID) continue; - - axj = layoutOut[id2name(idj)]; - if(axj.type === thisType && !axj.fixedrange) linkableAxes.push(idj); - } - - for(i = 0; i < constraintGroups.length; i++) { - if(constraintGroups[i][thisID]) { - var thisGroup = constraintGroups[i]; - - var linkableAxesNoLoops = []; - for(j = 0; j < linkableAxes.length; j++) { - idj = linkableAxes[j]; - if(!thisGroup[idj]) linkableAxesNoLoops.push(idj); - } - return {linkableAxes: linkableAxesNoLoops, thisGroup: thisGroup}; - } - } - - return {linkableAxes: linkableAxes, thisGroup: null}; -} - - -/* - * Add this axis to the axis constraint groups, which is the collection - * of axes that are all constrained together on scale. - * - * constraintGroups: a list of objects. each object is - * {axis_id: scale_within_group}, where scale_within_group is - * only important relative to the rest of the group, and defines - * the relative scales between all axes in the group - * - * thisGroup: the group the current axis is already in - * thisID: the id if the current axis - * scaleanchor: the id of the axis to scale it with - * scaleratio: the ratio of this axis to the scaleanchor axis - */ -function updateConstraintGroups(constraintGroups, thisGroup, thisID, scaleanchor, scaleratio) { - var i, j, groupi, keyj, thisGroupIndex; - - if(thisGroup === null) { - thisGroup = {}; - thisGroup[thisID] = 1; - thisGroupIndex = constraintGroups.length; - constraintGroups.push(thisGroup); - } - else { - thisGroupIndex = constraintGroups.indexOf(thisGroup); - } - - var thisGroupKeys = Object.keys(thisGroup); - - // we know that this axis isn't in any other groups, but we don't know - // about the scaleanchor axis. If it is, we need to merge the groups. - for(i = 0; i < constraintGroups.length; i++) { - groupi = constraintGroups[i]; - if(i !== thisGroupIndex && groupi[scaleanchor]) { - var baseScale = groupi[scaleanchor]; - for(j = 0; j < thisGroupKeys.length; j++) { - keyj = thisGroupKeys[j]; - groupi[keyj] = baseScale * scaleratio * thisGroup[keyj]; - } - constraintGroups.splice(thisGroupIndex, 1); - return; - } - } - - // otherwise, we insert the new scaleanchor axis as the base scale (1) - // in its group, and scale the rest of the group to it - if(scaleratio !== 1) { - for(j = 0; j < thisGroupKeys.length; j++) { - thisGroup[thisGroupKeys[j]] *= scaleratio; - } - } - thisGroup[scaleanchor] = 1; -} diff --git a/src/plots/cartesian/constraints.js b/src/plots/cartesian/constraints.js index cac5d918189..4cf2f1b70b1 100644 --- a/src/plots/cartesian/constraints.js +++ b/src/plots/cartesian/constraints.js @@ -6,18 +6,188 @@ * LICENSE file in the root directory of this source tree. */ - 'use strict'; +var Lib = require('../../lib'); var id2name = require('./axis_ids').id2name; var scaleZoom = require('./scale_zoom'); var makePadFn = require('./autorange').makePadFn; var concatExtremes = require('./autorange').concatExtremes; var ALMOST_EQUAL = require('../../constants/numerical').ALMOST_EQUAL; - var FROM_BL = require('../../constants/alignment').FROM_BL; +exports.handleConstraintDefaults = function(containerIn, containerOut, coerce, allAxisIds, layoutOut) { + var constraintGroups = layoutOut._axisConstraintGroups; + var matchGroups = layoutOut._axisMatchGroups; + var axId = containerOut._id; + var axLetter = axId.charAt(0); + var splomStash = ((layoutOut._splomAxes || {})[axLetter] || {})[axId] || {}; + var thisID = containerOut._id; + var letter = thisID.charAt(0); + + // coerce the constraint mechanics even if this axis has no scaleanchor + // because it may be the anchor of another axis. + var constrain = coerce('constrain'); + Lib.coerce(containerIn, containerOut, { + constraintoward: { + valType: 'enumerated', + values: letter === 'x' ? ['left', 'center', 'right'] : ['bottom', 'middle', 'top'], + dflt: letter === 'x' ? 'center' : 'middle' + } + }, 'constraintoward'); + + var matches, matchOpts; + + if((containerIn.matches || splomStash.matches) && !containerOut.fixedrange) { + matchOpts = getConstraintOpts(matchGroups, thisID, allAxisIds, layoutOut); + matches = Lib.coerce(containerIn, containerOut, { + matches: { + valType: 'enumerated', + values: matchOpts.linkableAxes || [], + dflt: splomStash.matches + } + }, 'matches'); + } + + // 'matches' wins over 'scaleanchor' (for now) + var scaleanchor, scaleOpts; + + if(!matches && containerIn.scaleanchor && !(containerOut.fixedrange && constrain !== 'domain')) { + scaleOpts = getConstraintOpts(constraintGroups, thisID, allAxisIds, layoutOut, constrain); + scaleanchor = Lib.coerce(containerIn, containerOut, { + scaleanchor: { + valType: 'enumerated', + values: scaleOpts.linkableAxes || [] + } + }, 'scaleanchor'); + } + + if(matches) { + delete containerOut.constrain; + updateConstraintGroups(matchGroups, matchOpts.thisGroup, thisID, matches, 1); + } else if(allAxisIds.indexOf(containerIn.matches) !== -1) { + Lib.warn('ignored ' + containerOut._name + '.matches: "' + + containerIn.matches + '" to avoid either an infinite loop ' + + 'or because the target axis has fixed range.'); + } + + if(scaleanchor) { + var scaleratio = coerce('scaleratio'); + + // TODO: I suppose I could do attribute.min: Number.MIN_VALUE to avoid zero, + // but that seems hacky. Better way to say "must be a positive number"? + // Of course if you use several super-tiny values you could eventually + // force a product of these to zero and all hell would break loose... + // Likewise with super-huge values. + if(!scaleratio) scaleratio = containerOut.scaleratio = 1; + + updateConstraintGroups(constraintGroups, scaleOpts.thisGroup, thisID, scaleanchor, scaleratio); + } else if(allAxisIds.indexOf(containerIn.scaleanchor) !== -1) { + Lib.warn('ignored ' + containerOut._name + '.scaleanchor: "' + + containerIn.scaleanchor + '" to avoid either an infinite loop ' + + 'and possibly inconsistent scaleratios, or because the target ' + + 'axis has fixed range or this axis declares a *matches* constraint.'); + } +}; + +// If this axis is already part of a constraint group, we can't +// scaleanchor any other axis in that group, or we'd make a loop. +// Filter allAxisIds to enforce this, also matching axis types. +function getConstraintOpts(groups, thisID, allAxisIds, layoutOut, constrain) { + var doesNotConstrainRange = constrain !== 'range'; + var thisType = layoutOut[id2name(thisID)].type; + var i, j, idj, axj; + + var linkableAxes = []; + for(j = 0; j < allAxisIds.length; j++) { + idj = allAxisIds[j]; + if(idj === thisID) continue; + + axj = layoutOut[id2name(idj)]; + if(axj.type === thisType) { + if(!axj.fixedrange) { + linkableAxes.push(idj); + } else if(doesNotConstrainRange && axj.anchor) { + // allow domain constraints on subplots where + // BOTH axes have fixedrange:true and constrain:domain + var counterAxj = layoutOut[id2name(axj.anchor)]; + if(counterAxj.fixedrange) { + linkableAxes.push(idj); + } + } + } + } + + for(i = 0; i < groups.length; i++) { + if(groups[i][thisID]) { + var thisGroup = groups[i]; + + var linkableAxesNoLoops = []; + for(j = 0; j < linkableAxes.length; j++) { + idj = linkableAxes[j]; + if(!thisGroup[idj]) linkableAxesNoLoops.push(idj); + } + return {linkableAxes: linkableAxesNoLoops, thisGroup: thisGroup}; + } + } + + return {linkableAxes: linkableAxes, thisGroup: null}; +} + +/* + * Add this axis to the axis constraint groups, which is the collection + * of axes that are all constrained together on scale. + * + * constraintGroups: a list of objects. each object is + * {axis_id: scale_within_group}, where scale_within_group is + * only important relative to the rest of the group, and defines + * the relative scales between all axes in the group + * + * thisGroup: the group the current axis is already in + * thisID: the id if the current axis + * scaleanchor: the id of the axis to scale it with + * scaleratio: the ratio of this axis to the scaleanchor axis + */ +function updateConstraintGroups(constraintGroups, thisGroup, thisID, scaleanchor, scaleratio) { + var i, j, groupi, keyj, thisGroupIndex; + + if(thisGroup === null) { + thisGroup = {}; + thisGroup[thisID] = 1; + thisGroupIndex = constraintGroups.length; + constraintGroups.push(thisGroup); + } + else { + thisGroupIndex = constraintGroups.indexOf(thisGroup); + } + + var thisGroupKeys = Object.keys(thisGroup); + + // we know that this axis isn't in any other groups, but we don't know + // about the scaleanchor axis. If it is, we need to merge the groups. + for(i = 0; i < constraintGroups.length; i++) { + groupi = constraintGroups[i]; + if(i !== thisGroupIndex && groupi[scaleanchor]) { + var baseScale = groupi[scaleanchor]; + for(j = 0; j < thisGroupKeys.length; j++) { + keyj = thisGroupKeys[j]; + groupi[keyj] = baseScale * scaleratio * thisGroup[keyj]; + } + constraintGroups.splice(thisGroupIndex, 1); + return; + } + } + + // otherwise, we insert the new scaleanchor axis as the base scale (1) + // in its group, and scale the rest of the group to it + if(scaleratio !== 1) { + for(j = 0; j < thisGroupKeys.length; j++) { + thisGroup[thisGroupKeys[j]] *= scaleratio; + } + } + thisGroup[scaleanchor] = 1; +} exports.enforce = function enforceAxisConstraints(gd) { var fullLayout = gd._fullLayout; diff --git a/src/plots/cartesian/dragbox.js b/src/plots/cartesian/dragbox.js index 8f44b934554..8ffd84fb657 100644 --- a/src/plots/cartesian/dragbox.js +++ b/src/plots/cartesian/dragbox.js @@ -76,12 +76,12 @@ function makeDragBox(gd, plotinfo, x, y, w, h, ns, ew) { // which are the x/y {ax._id: ax} hash objects and their values // for linked axis relative to this subplot var links; + // similar to `links` but for matching axes + var matches; // set to ew/ns val when active, set to '' when inactive var xActive, yActive; // are all axes in this subplot are fixed? var allFixedRanges; - // is subplot constrained? - var isSubplotConstrained; // do we need to edit x/y ranges? var editX, editY; // graph-wide optimization flags @@ -119,10 +119,10 @@ function makeDragBox(gd, plotinfo, x, y, w, h, ns, ew) { yActive = isDirectionActive(yaxes, ns); allFixedRanges = !yActive && !xActive; - links = calcLinks(gd, xaHash, yaHash); - isSubplotConstrained = links.isSubplotConstrained; - editX = ew || isSubplotConstrained; - editY = ns || isSubplotConstrained; + links = calcLinks(gd, gd._fullLayout._axisConstraintGroups, xaHash, yaHash); + matches = calcLinks(gd, gd._fullLayout._axisMatchGroups, xaHash, yaHash); + editX = ew || links.isSubplotConstrained || matches.isSubplotConstrained; + editY = ns || links.isSubplotConstrained || matches.isSubplotConstrained; var fullLayout = gd._fullLayout; hasScatterGl = fullLayout._has('scattergl'); @@ -280,16 +280,22 @@ function makeDragBox(gd, plotinfo, x, y, w, h, ns, ew) { dragElement.init(dragOptions); - var x0, - y0, - box, - lum, - path0, - dimmed, - zoomMode, - zb, - corners; - + // x/y px position at start of drag + var x0, y0; + // bbox object of the zoombox + var box; + // luminance of bg behind zoombox + var lum; + // zoombox path outline + var path0; + // is zoombox dimmed (during drag) + var dimmed; + // 'x'-only, 'y' or 'xy' zooming + var zoomMode; + // zoombox d3 selection + var zb; + // zoombox corner d3 selection + var corners; // zoom takes over minDrag, so it also has to take over gd._dragged var zoomDragged; @@ -305,9 +311,7 @@ function makeDragBox(gd, plotinfo, x, y, w, h, ns, ew) { dimmed = false; zoomMode = 'xy'; zoomDragged = false; - zb = makeZoombox(zoomlayer, lum, xs, ys, path0); - corners = makeCorners(zoomlayer, xs, ys); } @@ -333,22 +337,36 @@ function makeDragBox(gd, plotinfo, x, y, w, h, ns, ew) { corners.attr('d', 'M0,0Z'); } - if(isSubplotConstrained) { + if(links.isSubplotConstrained) { if(dx > MINZOOM || dy > MINZOOM) { zoomMode = 'xy'; if(dx / pw > dy / ph) { dy = dx * ph / pw; if(y0 > y1) box.t = y0 - dy; else box.b = y0 + dy; - } - else { + } else { dx = dy * pw / ph; if(x0 > x1) box.l = x0 - dx; else box.r = x0 + dx; } corners.attr('d', xyCorners(box)); + } else { + noZoom(); } - else { + } + else if(matches.isSubplotConstrained) { + if(dx > MINZOOM || dy > MINZOOM) { + zoomMode = 'xy'; + + var r0 = Math.min(box.l / pw, (ph - box.b) / ph); + var r1 = Math.max(box.r / pw, (ph - box.t) / ph); + + box.l = r0 * pw; + box.r = r1 * pw; + box.b = (1 - r0) * ph; + box.t = (1 - r1) * ph; + corners.attr('d', xyCorners(box)); + } else { noZoom(); } } @@ -396,9 +414,11 @@ function makeDragBox(gd, plotinfo, x, y, w, h, ns, ew) { // TODO: edit linked axes in zoomAxRanges and in dragTail if(zoomMode === 'xy' || zoomMode === 'x') { zoomAxRanges(xaxes, box.l / pw, box.r / pw, updates, links.xaxes); + updateMatchedAxRange('x', updates); } if(zoomMode === 'xy' || zoomMode === 'y') { zoomAxRanges(yaxes, (ph - box.b) / ph, (ph - box.t) / ph, updates, links.yaxes); + updateMatchedAxRange('y', updates); } removeZoombox(gd); @@ -464,6 +484,7 @@ function makeDragBox(gd, plotinfo, x, y, w, h, ns, ew) { for(i = 0; i < xaxes.length; i++) { zoomWheelOneAxis(xaxes[i], xfrac, zoom); } + updateMatchedAxRange('x'); scrollViewBox[2] *= zoom; scrollViewBox[0] += scrollViewBox[2] * xfrac * (1 / zoom - 1); @@ -474,6 +495,7 @@ function makeDragBox(gd, plotinfo, x, y, w, h, ns, ew) { for(i = 0; i < yaxes.length; i++) { zoomWheelOneAxis(yaxes[i], yfrac, zoom); } + updateMatchedAxRange('y'); scrollViewBox[3] *= zoom; scrollViewBox[1] += scrollViewBox[3] * (1 - yfrac) * (1 / zoom - 1); @@ -510,8 +532,14 @@ function makeDragBox(gd, plotinfo, x, y, w, h, ns, ew) { gd._fullLayout._replotting = true; if(xActive === 'ew' || yActive === 'ns') { - if(xActive) dragAxList(xaxes, dx); - if(yActive) dragAxList(yaxes, dy); + if(xActive) { + dragAxList(xaxes, dx); + updateMatchedAxRange('x'); + } + if(yActive) { + dragAxList(yaxes, dy); + updateMatchedAxRange('y'); + } updateSubplots([xActive ? -dx : 0, yActive ? -dy : 0, pw, ph]); ticksAndAnnotations(); return; @@ -542,7 +570,7 @@ function makeDragBox(gd, plotinfo, x, y, w, h, ns, ew) { (movedAx._rl[end] - movedAx._rl[otherEnd]); } - if(isSubplotConstrained && xActive && yActive) { + if(links.isSubplotConstrained && xActive && yActive) { // dragging a corner of a constrained subplot: // respect the fixed corner, but harmonize dx and dy var dxySign = ((xActive === 'w') === (yActive === 'n')) ? 1 : -1; @@ -562,7 +590,7 @@ function makeDragBox(gd, plotinfo, x, y, w, h, ns, ew) { var x0 = (xActive === 'w') ? dx : 0; var y0 = (yActive === 'n') ? dy : 0; - if(isSubplotConstrained) { + if(links.isSubplotConstrained) { var i; if(!xActive && yActive.length === 1) { // dragging one end of the y axis of a constrained subplot @@ -584,10 +612,39 @@ function makeDragBox(gd, plotinfo, x, y, w, h, ns, ew) { } } + updateMatchedAxRange('x'); + updateMatchedAxRange('y'); updateSubplots([x0, y0, pw - dx, ph - dy]); ticksAndAnnotations(); } + function updateMatchedAxRange(axLetter, out) { + var matchedAxes = matches.isSubplotConstrained ? + {x: yaxes, y: xaxes}[axLetter] : + matches[axLetter + 'axes']; + + var constrainedAxes = matches.isSubplotConstrained ? + {x: xaxes, y: yaxes}[axLetter] : + []; + + for(var i = 0; i < matchedAxes.length; i++) { + var ax = matchedAxes[i]; + var axId = ax._id; + var axId2 = matches.xLinks[axId] || matches.yLinks[axId]; + var ax2 = constrainedAxes[0] || xaHash[axId2] || yaHash[axId2]; + + if(ax2) { + var rng = ax2.range; + if(out) { + out[ax._name + '.range[0]'] = rng[0]; + out[ax._name + '.range[1]'] = rng[1]; + } else { + ax.range = rng; + } + } + } + } + // Draw ticks and annotations (and other components) when ranges change. // Also records the ranges that have changed for use by update at the end. function ticksAndAnnotations() { @@ -603,10 +660,12 @@ function makeDragBox(gd, plotinfo, x, y, w, h, ns, ew) { if(editX) { pushActiveAxIds(xaxes); pushActiveAxIds(links.xaxes); + pushActiveAxIds(matches.xaxes); } if(editY) { pushActiveAxIds(yaxes); pushActiveAxIds(links.yaxes); + pushActiveAxIds(matches.yaxes); } updates = {}; @@ -625,9 +684,14 @@ function makeDragBox(gd, plotinfo, x, y, w, h, ns, ew) { if(gd._transitioningWithDuration) return; var doubleClickConfig = gd._context.doubleClick; - var axList = (xActive ? xaxes : []).concat(yActive ? yaxes : []); - var attrs = {}; + var axList = []; + if(xActive) axList = axList.concat(xaxes); + if(yActive) axList = axList.concat(yaxes); + if(matches.xaxes) axList = axList.concat(matches.xaxes); + if(matches.yaxes) axList = axList.concat(matches.yaxes); + + var attrs = {}; var ax, i, rangeInitial; // For reset+autosize mode: @@ -664,10 +728,10 @@ function makeDragBox(gd, plotinfo, x, y, w, h, ns, ew) { else if(doubleClickConfig === 'reset') { // when we're resetting, reset all linked axes too, so we get back // to the fully-auto-with-constraints situation - if(xActive || isSubplotConstrained) axList = axList.concat(links.xaxes); - if(yActive && !isSubplotConstrained) axList = axList.concat(links.yaxes); + if(xActive || links.isSubplotConstrained) axList = axList.concat(links.xaxes); + if(yActive && !links.isSubplotConstrained) axList = axList.concat(links.yaxes); - if(isSubplotConstrained) { + if(links.isSubplotConstrained) { if(!xActive) axList = axList.concat(xaxes); else if(!yActive) axList = axList.concat(yaxes); } @@ -709,10 +773,6 @@ function makeDragBox(gd, plotinfo, x, y, w, h, ns, ew) { ], gd); } - // x/y scaleFactor stash, - // minimizes number of per-point DOM updates in updateSubplots below - var xScaleFactorOld, yScaleFactorOld; - // updateSubplots - find all plot viewboxes that should be // affected by this drag, and update them. look for all plots // sharing an affected axis (including the one being dragged), @@ -764,6 +824,14 @@ function makeDragBox(gd, plotinfo, x, y, w, h, ns, ew) { if(editX2) { xScaleFactor2 = xScaleFactor; clipDx = ew ? viewBox[0] : getShift(xa, xScaleFactor2); + } else if(matches.xaHash[xa._id]) { + xScaleFactor2 = xScaleFactor; + clipDx = viewBox[0] * xa._length / xa0._length; + } else if(matches.yaHash[xa._id]) { + xScaleFactor2 = yScaleFactor; + clipDx = yActive === 'ns' ? + -viewBox[1] * xa._length / ya0._length : + getShift(xa, xScaleFactor2, {n: 'top', s: 'bottom'}[yActive]); } else { xScaleFactor2 = getLinkedScaleFactor(xa, xScaleFactor, yScaleFactor); clipDx = scaleAndGetShift(xa, xScaleFactor2); @@ -772,6 +840,14 @@ function makeDragBox(gd, plotinfo, x, y, w, h, ns, ew) { if(editY2) { yScaleFactor2 = yScaleFactor; clipDy = ns ? viewBox[1] : getShift(ya, yScaleFactor2); + } else if(matches.yaHash[ya._id]) { + yScaleFactor2 = yScaleFactor; + clipDy = viewBox[1] * ya._length / ya0._length; + } else if(matches.xaHash[ya._id]) { + yScaleFactor2 = xScaleFactor; + clipDy = xActive === 'ew' ? + -viewBox[0] * ya._length / xa0._length : + getShift(ya, yScaleFactor2, {e: 'right', w: 'left'}[xActive]); } else { yScaleFactor2 = getLinkedScaleFactor(ya, xScaleFactor, yScaleFactor); clipDy = scaleAndGetShift(ya, yScaleFactor2); @@ -805,7 +881,7 @@ function makeDragBox(gd, plotinfo, x, y, w, h, ns, ew) { // the scale of the trace group. // apply only when scale changes, as adjusting the scale of // all the points can be expansive. - if(xScaleFactor2 !== xScaleFactorOld || yScaleFactor2 !== yScaleFactorOld) { + if(xScaleFactor2 !== sp.xScaleFactor || yScaleFactor2 !== sp.yScaleFactor) { Drawing.setPointGroupScale(sp.zoomScalePts, xScaleFactor2, yScaleFactor2); Drawing.setTextPointsScale(sp.zoomScaleTxt, xScaleFactor2, yScaleFactor2); } @@ -813,8 +889,8 @@ function makeDragBox(gd, plotinfo, x, y, w, h, ns, ew) { Drawing.hideOutsideRangePoints(sp.clipOnAxisFalseTraces, sp); // update x/y scaleFactor stash - xScaleFactorOld = xScaleFactor2; - yScaleFactorOld = yScaleFactor2; + sp.xScaleFactor = xScaleFactor2; + sp.yScaleFactor = yScaleFactor2; } } } @@ -828,7 +904,7 @@ function makeDragBox(gd, plotinfo, x, y, w, h, ns, ew) { if(editX && links.xaHash[ax._id]) { return xScaleFactor; } - if(editY && (isSubplotConstrained ? links.xaHash : links.yaHash)[ax._id]) { + if(editY && (links.isSubplotConstrained ? links.xaHash : links.yaHash)[ax._id]) { return yScaleFactor; } return 0; @@ -843,8 +919,8 @@ function makeDragBox(gd, plotinfo, x, y, w, h, ns, ew) { return 0; } - function getShift(ax, scaleFactor) { - return ax._length * (1 - scaleFactor) * FROM_TL[ax.constraintoward || 'middle']; + function getShift(ax, scaleFactor, from) { + return ax._length * (1 - scaleFactor) * FROM_TL[from || ax.constraintoward || 'middle']; } return dragger; @@ -897,17 +973,12 @@ function getEndText(ax, end) { } function zoomAxRanges(axList, r0Fraction, r1Fraction, updates, linkedAxes) { - var i, - axi, - axRangeLinear0, - axRangeLinearSpan; - - for(i = 0; i < axList.length; i++) { - axi = axList[i]; + for(var i = 0; i < axList.length; i++) { + var axi = axList[i]; if(axi.fixedrange) continue; - axRangeLinear0 = axi._rl[0]; - axRangeLinearSpan = axi._rl[1] - axRangeLinear0; + var axRangeLinear0 = axi._rl[0]; + var axRangeLinearSpan = axi._rl[1] - axRangeLinear0; axi.range = [ axi.l2r(axRangeLinear0 + axRangeLinearSpan * r0Fraction), axi.l2r(axRangeLinear0 + axRangeLinearSpan * r1Fraction) @@ -920,8 +991,7 @@ function zoomAxRanges(axList, r0Fraction, r1Fraction, updates, linkedAxes) { // zoom linked axes about their centers if(linkedAxes && linkedAxes.length) { var linkedR0Fraction = (r0Fraction + (1 - r1Fraction)) / 2; - - zoomAxRanges(linkedAxes, linkedR0Fraction, 1 - linkedR0Fraction, updates); + zoomAxRanges(linkedAxes, linkedR0Fraction, 1 - linkedR0Fraction, updates, [], []); } } @@ -1048,15 +1118,14 @@ function xyCorners(box) { 'h' + clen + 'v3h-' + (clen + 3) + 'Z'; } -function calcLinks(gd, xaHash, yaHash) { - var constraintGroups = gd._fullLayout._axisConstraintGroups; +function calcLinks(gd, groups, xaHash, yaHash) { var isSubplotConstrained = false; var xLinks = {}; var yLinks = {}; var xID, yID, xLinkID, yLinkID; - for(var i = 0; i < constraintGroups.length; i++) { - var group = constraintGroups[i]; + for(var i = 0; i < groups.length; i++) { + var group = groups[i]; // check if any of the x axes we're dragging is in this constraint group for(xID in xaHash) { if(group[xID]) { @@ -1065,7 +1134,7 @@ function calcLinks(gd, xaHash, yaHash) { // to match the changes in the dragged x axes for(xLinkID in group) { if(!(xLinkID.charAt(0) === 'x' ? xaHash : yaHash)[xLinkID]) { - xLinks[xLinkID] = 1; + xLinks[xLinkID] = xID; } } @@ -1082,7 +1151,7 @@ function calcLinks(gd, xaHash, yaHash) { if(group[yID]) { for(yLinkID in group) { if(!(yLinkID.charAt(0) === 'x' ? xaHash : yaHash)[yLinkID]) { - yLinks[yLinkID] = 1; + yLinks[yLinkID] = yID; } } } @@ -1118,6 +1187,8 @@ function calcLinks(gd, xaHash, yaHash) { yaHash: yaHashLinked, xaxes: xaxesLinked, yaxes: yaxesLinked, + xLinks: xLinks, + yLinks: yLinks, isSubplotConstrained: isSubplotConstrained }; } diff --git a/src/plots/cartesian/layout_attributes.js b/src/plots/cartesian/layout_attributes.js index c0f7f9b3a83..8c920ecbac0 100644 --- a/src/plots/cartesian/layout_attributes.js +++ b/src/plots/cartesian/layout_attributes.js @@ -169,7 +169,9 @@ module.exports = { 'or the same letter (to match scales across subplots).', 'Loops (`yaxis: {scaleanchor: *x*}, xaxis: {scaleanchor: *y*}` or longer) are redundant', 'and the last constraint encountered will be ignored to avoid possible', - 'inconsistent constraints via `scaleratio`.' + 'inconsistent constraints via `scaleratio`.', + 'Note that setting axes simultaneously in both a `scaleanchor` and a `matches` constraint', + 'is currently forbidden.' ].join(' ') }, scaleratio: { @@ -211,6 +213,24 @@ module.exports = { 'and *right* for x axes, and *top*, *middle* (default), and *bottom* for y axes.' ].join(' ') }, + matches: { + valType: 'enumerated', + values: [ + constants.idRegex.x.toString(), + constants.idRegex.y.toString() + ], + role: 'info', + editType: 'calc', + description: [ + 'If set to another axis id (e.g. `x2`, `y`), the range of this axis', + 'will match the range of the corresponding axis in data-coordinates space.', + 'Moreover, matching axes share auto-range values, category lists and', + 'histogram auto-bins.', + 'Note that setting axes simultaneously in both a `scaleanchor` and a `matches` constraint', + 'is currently forbidden.', + 'Moreover, note that matching axes must have the same `type`.' + ].join(' ') + }, // ticks tickmode: { valType: 'enumerated', diff --git a/src/plots/cartesian/layout_defaults.js b/src/plots/cartesian/layout_defaults.js index 8a3a05d940d..8a6f6235b10 100644 --- a/src/plots/cartesian/layout_defaults.js +++ b/src/plots/cartesian/layout_defaults.js @@ -17,7 +17,7 @@ var basePlotLayoutAttributes = require('../layout_attributes'); var layoutAttributes = require('./layout_attributes'); var handleTypeDefaults = require('./type_defaults'); var handleAxisDefaults = require('./axis_defaults'); -var handleConstraintDefaults = require('./constraint_defaults'); +var handleConstraintDefaults = require('./constraints').handleConstraintDefaults; var handlePositionDefaults = require('./position_defaults'); var axisIds = require('./axis_ids'); @@ -124,6 +124,7 @@ module.exports = function supplyLayoutDefaults(layoutIn, layoutOut, fullData) { } var counterAxes = {x: getCounterAxes('x'), y: getCounterAxes('y')}; + var allAxisIds = counterAxes.x.concat(counterAxes.y); function getOverlayableAxes(axLetter, axName) { var list = (axLetter === 'x') ? xNames : yNames; @@ -199,14 +200,12 @@ module.exports = function supplyLayoutDefaults(layoutIn, layoutOut, fullData) { delete axLayoutOut.spikesnap; } - var positioningOptions = { + handlePositionDefaults(axLayoutIn, axLayoutOut, coerce, { letter: axLetter, counterAxes: counterAxes[axLetter], overlayableAxes: overlayableAxes, grid: layoutOut.grid - }; - - handlePositionDefaults(axLayoutIn, axLayoutOut, coerce, positioningOptions); + }); axLayoutOut._input = axLayoutIn; } @@ -247,22 +246,80 @@ module.exports = function supplyLayoutDefaults(layoutIn, layoutOut, fullData) { coerce('fixedrange', fixedRangeDflt); } - // Finally, handle scale constraints. We need to do this after all axes have - // coerced both `type` (so we link only axes of the same type) and + // Finally, handle scale constraints and matching axes. + // + // We need to do this after all axes have coerced both `type` + // (so we link only axes of the same type) and // `fixedrange` (so we can avoid linking from OR TO a fixed axis). // sets of axes linked by `scaleanchor` along with the scaleratios compounded // together, populated in handleConstraintDefaults - layoutOut._axisConstraintGroups = []; - var allAxisIds = counterAxes.x.concat(counterAxes.y); + var constraintGroups = layoutOut._axisConstraintGroups = []; + // similar to _axisConstraintGroups, but for matching axes + var matchGroups = layoutOut._axisMatchGroups = []; for(i = 0; i < axNames.length; i++) { axName = axNames[i]; axLetter = axName.charAt(0); - axLayoutIn = layoutIn[axName]; axLayoutOut = layoutOut[axName]; handleConstraintDefaults(axLayoutIn, axLayoutOut, coerce, allAxisIds, layoutOut); } + + for(i = 0; i < matchGroups.length; i++) { + var group = matchGroups[i]; + var rng = null; + var autorange = null; + var axId; + + // find 'matching' range attrs + for(axId in group) { + axLayoutOut = layoutOut[id2name(axId)]; + if(!axLayoutOut.matches) { + rng = axLayoutOut.range; + autorange = axLayoutOut.autorange; + } + } + // if `ax.matches` values are reciprocal, + // pick values of first axis in group + if(rng === null || autorange === null) { + for(axId in group) { + axLayoutOut = layoutOut[id2name(axId)]; + rng = axLayoutOut.range; + autorange = axLayoutOut.autorange; + break; + } + } + // apply matching range attrs + for(axId in group) { + axLayoutOut = layoutOut[id2name(axId)]; + if(axLayoutOut.matches) { + axLayoutOut.range = rng.slice(); + axLayoutOut.autorange = autorange; + } + axLayoutOut._matchGroup = group; + } + + // remove matching axis from scaleanchor constraint groups (for now) + if(constraintGroups.length) { + for(axId in group) { + for(j = 0; j < constraintGroups.length; j++) { + var group2 = constraintGroups[j]; + for(var axId2 in group2) { + if(axId === axId2) { + Lib.warn('Axis ' + axId2 + ' is set with both ' + + 'a *scaleanchor* and *matches* constraint; ' + + 'ignoring the scale constraint.'); + + delete group2[axId2]; + if(Object.keys(group2).length < 2) { + constraintGroups.splice(j, 1); + } + } + } + } + } + } + } }; diff --git a/src/plots/cartesian/set_convert.js b/src/plots/cartesian/set_convert.js index 3e40f35acd7..e22f2609728 100644 --- a/src/plots/cartesian/set_convert.js +++ b/src/plots/cartesian/set_convert.js @@ -6,7 +6,6 @@ * LICENSE file in the root directory of this source tree. */ - 'use strict'; var d3 = require('d3'); @@ -63,7 +62,8 @@ function isValidCategory(v) { module.exports = function setConvert(ax, fullLayout) { fullLayout = fullLayout || {}; - var axLetter = (ax._id || 'x').charAt(0); + var axId = (ax._id || 'x'); + var axLetter = axId.charAt(0); function toLog(v, clip) { if(v > 0) return Math.log(v) / Math.LN10; @@ -307,10 +307,25 @@ module.exports = function setConvert(ax, fullLayout) { var traceIndices = ax._traceIndices; var i, j; + var matchGroups = fullLayout._axisMatchGroups; + if(matchGroups && matchGroups.length && ax._categories.length === 0) { + for(i = 0; i < matchGroups.length; i++) { + var group = matchGroups[i]; + if(group[axId]) { + for(var axId2 in group) { + if(axId2 !== axId) { + var ax2 = fullLayout[axisIds.id2name(axId2)]; + traceIndices = traceIndices.concat(ax2._traceIndices); + } + } + } + } + } + // [ [cnt, {$cat: index}], for 1,2 ] - var seen = ax._multicatSeen = [[0, {}], [0, {}]]; + var seen = [[0, {}], [0, {}]]; // [ [arrayIn[0][i], arrayIn[1][i]], for i .. N ] - var list = ax._multicatList = []; + var list = []; for(i = 0; i < traceIndices.length; i++) { var trace = fullData[traceIndices[i]]; @@ -558,15 +573,45 @@ module.exports = function setConvert(ax, fullLayout) { } }; + // should skip if not category nor multicategory ax.clearCalc = function() { - // initialize the category list, if there is one, so we start over - // to be filled in later by ax.d2c - ax._categories = (ax._initialCategories || []).slice(); - - // Build the lookup map for initialized categories - ax._categoriesMap = {}; - for(var j = 0; j < ax._categories.length; j++) { - ax._categoriesMap[ax._categories[j]] = j; + var matchGroups = fullLayout._axisMatchGroups; + + if(matchGroups && matchGroups.length) { + for(var i = 0; i < matchGroups.length; i++) { + var group = matchGroups[i]; + + if(group[axId]) { + var categories = null; + var categoriesMap = null; + + for(var axId2 in group) { + var ax2 = fullLayout[axisIds.id2name(axId2)]; + if(ax2._categories) { + categories = ax2._categories; + categoriesMap = ax2._categoriesMap; + break; + } + } + + if(categories && categoriesMap) { + ax._categories = categories; + ax._categoriesMap = categoriesMap; + } else { + ax._categories = []; + ax._categoriesMap = {}; + } + } + } + } else { + ax._categories = []; + ax._categoriesMap = {}; + } + + if(ax._initialCategories) { + for(var j = 0; j < ax._initialCategories.length; j++) { + setCategoryIndex(ax._initialCategories[j]); + } } }; diff --git a/src/traces/histogram/cross_trace_defaults.js b/src/traces/histogram/cross_trace_defaults.js index 0a037c7a3ef..4df1814fc71 100644 --- a/src/traces/histogram/cross_trace_defaults.js +++ b/src/traces/histogram/cross_trace_defaults.js @@ -50,15 +50,16 @@ module.exports = function crossTraceDefaults(fullData, fullLayout) { binDirection = traceOut.orientation === 'v' ? 'x' : 'y'; // in overlay mode make a separate group for each trace // otherwise collect all traces of the same subplot & orientation - group = isOverlay ? traceOut.uid : (traceOut.xaxis + traceOut.yaxis + binDirection); - traceOut._groupName = group; - + group = traceOut._groupName = isOverlay ? traceOut.uid : ( + getAxisGroup(fullLayout, traceOut.xaxis) + + getAxisGroup(fullLayout, traceOut.yaxis) + + binDirection + ); binOpts = allBinOpts[group]; if(binOpts) { binOpts.traces.push(traceOut); - } - else { + } else { binOpts = allBinOpts[group] = { traces: [traceOut], direction: binDirection @@ -110,3 +111,13 @@ module.exports = function crossTraceDefaults(fullData, fullLayout) { } } }; + +function getAxisGroup(fullLayout, axId) { + var matchGroups = fullLayout._axisMatchGroups; + + for(var i = 0; i < matchGroups.length; i++) { + var group = matchGroups[i]; + if(group[axId]) return 'g' + i; + } + return axId; +} diff --git a/src/traces/splom/attributes.js b/src/traces/splom/attributes.js index 38ca5188545..889eefbab24 100644 --- a/src/traces/splom/attributes.js +++ b/src/traces/splom/attributes.js @@ -100,6 +100,20 @@ module.exports = { ].join(' ') }, + // TODO make 'true' the default in v2? + matches: { + valType: 'boolean', + dflt: false, + role: 'info', + editType: 'calc', + description: [ + 'Determines whether or not the x & y axes generated by this', + 'dimension match.', + 'Equivalent to setting the `matches` axis attribute in the layout', + 'with the correct axis id.' + ].join(' ') + }, + editType: 'calc+clearAxisTypes' }, diff --git a/src/traces/splom/defaults.js b/src/traces/splom/defaults.js index d363d299964..2970f3ac6a7 100644 --- a/src/traces/splom/defaults.js +++ b/src/traces/splom/defaults.js @@ -63,6 +63,7 @@ function dimensionDefaults(dimIn, dimOut) { else coerce('visible'); coerce('axis.type'); + coerce('axis.matches'); } function handleAxisDefaults(traceIn, traceOut, layout, coerce) { @@ -97,7 +98,7 @@ function handleAxisDefaults(traceIn, traceOut, layout, coerce) { var xList = []; var yList = []; - function fillAxisStashes(axId, dim, list) { + function fillAxisStashes(axId, counterAxId, dim, list) { if(!axId) return; var axLetter = axId.charAt(0); @@ -111,7 +112,8 @@ function handleAxisDefaults(traceIn, traceOut, layout, coerce) { if(dim) { s.label = dim.label || ''; if(dim.visible && dim.axis) { - s.type = dim.axis.type; + if(dim.axis.type) s.type = dim.axis.type; + if(dim.axis.matches) s.matches = counterAxId; } } } @@ -136,8 +138,8 @@ function handleAxisDefaults(traceIn, traceOut, layout, coerce) { undefined : yaxes[i]; - fillAxisStashes(xaId, dim, xList); - fillAxisStashes(yaId, dim, yList); + fillAxisStashes(xaId, yaId, dim, xList); + fillAxisStashes(yaId, xaId, dim, yList); diag[i] = [xaId, yaId]; } diff --git a/test/image/baselines/axes_scaleanchor-constrain-domain-fixedrange.png b/test/image/baselines/axes_scaleanchor-constrain-domain-fixedrange.png new file mode 100644 index 00000000000..60bd3831990 Binary files /dev/null and b/test/image/baselines/axes_scaleanchor-constrain-domain-fixedrange.png differ diff --git a/test/image/baselines/axes_scaleanchor-with-matches.png b/test/image/baselines/axes_scaleanchor-with-matches.png new file mode 100644 index 00000000000..56599d8d2ce Binary files /dev/null and b/test/image/baselines/axes_scaleanchor-with-matches.png differ diff --git a/test/image/baselines/hists-on-matching-axes.png b/test/image/baselines/hists-on-matching-axes.png new file mode 100644 index 00000000000..9e208c4ae1b Binary files /dev/null and b/test/image/baselines/hists-on-matching-axes.png differ diff --git a/test/image/baselines/matching-categories.png b/test/image/baselines/matching-categories.png new file mode 100644 index 00000000000..e5c61632275 Binary files /dev/null and b/test/image/baselines/matching-categories.png differ diff --git a/test/image/baselines/splom_iris-matching.png b/test/image/baselines/splom_iris-matching.png new file mode 100644 index 00000000000..c3d13e9bdeb Binary files /dev/null and b/test/image/baselines/splom_iris-matching.png differ diff --git a/test/image/baselines/splom_lower-nodiag-matching.png b/test/image/baselines/splom_lower-nodiag-matching.png new file mode 100644 index 00000000000..3872b87968f Binary files /dev/null and b/test/image/baselines/splom_lower-nodiag-matching.png differ diff --git a/test/image/mocks/axes_scaleanchor-constrain-domain-fixedrange.json b/test/image/mocks/axes_scaleanchor-constrain-domain-fixedrange.json new file mode 100644 index 00000000000..47b6c7b3f86 --- /dev/null +++ b/test/image/mocks/axes_scaleanchor-constrain-domain-fixedrange.json @@ -0,0 +1,28 @@ +{ + "data": [ + { + "y": [2, 3, 4, 5, 6], + "x": [2, 3, 4, 5, 6], + "z": [ + [0.5, 0.8, 0.6, 0.8, 0.2], + [0.4, 0.3, 0.7, 0.2, 0.1], + [0.7, 0.5, 0.9, 0.5, 0.3], + [0.5, 0.8, 0.6, 0.8, 0.2], + [0.4, 0.3, 0.7, 0.2, 0.1] + ], + "type": "heatmap" + } + ], + "layout": { + "xaxis": { + "constrain": "domain", + "fixedrange": true + }, + "yaxis": { + "constrain": "domain", + "scaleanchor": "x", + "scaleratio": 1, + "fixedrange": true + } + } +} diff --git a/test/image/mocks/axes_scaleanchor-with-matches.json b/test/image/mocks/axes_scaleanchor-with-matches.json new file mode 100644 index 00000000000..27d6ae1de0d --- /dev/null +++ b/test/image/mocks/axes_scaleanchor-with-matches.json @@ -0,0 +1,30 @@ +{ + "data":[ + {"x": [0,1,1,0,0,1,1,2,2,3,3,2,2,3], "y": [0,0,1,1,3,3,2,2,3,3,1,1,0,0]}, + {"x": [0,1,2,3], "y": [1,2,4,8], "xaxis": "x2", "yaxis":"y2"} + ], + "layout":{ + "width": 500, + "height": 500, + "title": {"text": "Bottom subplot has scaleanchor constrained axes
Top subplot has matching axes"}, + "xaxis": { + "constrain": "range" + }, + "yaxis": { + "scaleanchor": "x", + "constrain": "range", + "domain": [0, 0.45], + "title": {"text": "1:1
x|y scale constrain range"} + }, + "xaxis2": { + "anchor": "y2" + }, + "yaxis2": { + "anchor": "x2", + "matches": "x2", + "domain": [0.55, 1], + "title": {"text": "matching x|y"} + }, + "showlegend": false + } +} diff --git a/test/image/mocks/hists-on-matching-axes.json b/test/image/mocks/hists-on-matching-axes.json new file mode 100644 index 00000000000..189712b4666 --- /dev/null +++ b/test/image/mocks/hists-on-matching-axes.json @@ -0,0 +1,84 @@ +{ + "data": [ + { + "type": "histogram", + "name": "sample A - matched", + "x": [1, 2, 3, 1, 1, 2, 3, 3], + "hoverlabel": { + "namelength": -1 + } + }, + { + "type": "histogram", + "name": "sample B - matched", + "x": [2.1, 2.1, 3.4, 1.3, 2.2, 2.1, 3.2, 4.1, 3.1], + "hoverlabel": { + "namelength": -1 + }, + "xaxis": "x2", + "yaxis": "y2" + }, + { + "type": "histogram", + "name": "sample A - not on matching axes", + "x": [1, 2, 3, 1, 1, 2, 3, 3], + "hoverlabel": { + "namelength": -1 + }, + "xaxis": "x3", + "yaxis": "y3" + }, + { + "type": "histogram", + "name": "sample B - not on matching axes", + "x": [2.1, 2.1, 3.4, 1.3, 2.2, 2.1, 3.2, 4.1, 3.1], + "hoverlabel": { + "namelength": -1 + }, + "xaxis": "x4", + "yaxis": "y4" + } + ], + "layout": { + "margin": {"t": 20, "b": 20}, + "showlegend": false, + "xaxis": { + "domain": [0, 0.4] + }, + "yaxis": { + "domain": [0.45, 1], + "title": { + "text": "Matched Axes" + } + }, + "xaxis2": { + "domain": [0.45, 1], + "anchor": "y2", + "matches": "x" + }, + "yaxis2": { + "domain": [0.45, 1], + "anchor": "x2", + "matches": "y" + }, + "xaxis3": { + "domain": [0, 0.4], + "anchor": "y3" + }, + "yaxis3": { + "domain": [0, 0.4], + "anchor": "x3", + "title": { + "text": "NOT Matched Axes" + } + }, + "xaxis4": { + "domain": [0.45, 1], + "anchor": "y4" + }, + "yaxis4": { + "domain": [0, 0.4], + "anchor": "x4" + } + } +} diff --git a/test/image/mocks/matching-categories.json b/test/image/mocks/matching-categories.json new file mode 100644 index 00000000000..40554588ab7 --- /dev/null +++ b/test/image/mocks/matching-categories.json @@ -0,0 +1,92 @@ +{ + "data": [ + { + "x": [ "a", "b", "c" ], + "y": [ 1, 2, 1 ] + }, + { + "x": [ "b", "c", "d", "e" ], + "y": [ 2, 1, 2, 3 ], + "xaxis": "x2", + "yaxis": "y2" + }, + { + "x": [ + [ 2018, 2019, 2019 ], + [ "q1", "q1", "q2" ] + ], + "y": [ 1, 2, 1 ], + "xaxis": "x3", + "yaxis": "y3" + }, + { + "x": [ + [ 2018, 2018, 2019, 2019 ], + [ "q1", "q2", "q1", "q2" ] + ], + "y": [ 2, 1, 2, 3 ], + "xaxis": "x4", + "yaxis": "y4" + } + ], + "layout": { + "xaxis": { + "domain": [ 0, 0.2 ] + }, + "yaxis": { + "domain": [ 0.65, 1 ] + }, + "xaxis2": { + "matches": "x", + "anchor": "y2", + "domain": [ 0.3, 1 ] + }, + "yaxis2": { + "anchor": "x2", + "domain": [ 0.65, 1 ] + }, + "xaxis3": { + "anchor": "y3", + "domain": [ 0, 0.2 ] + }, + "yaxis3": { + "anchor": "x3", + "domain": [ 0, 0.5 ] + }, + "xaxis4": { + "matches": "x3", + "anchor": "y4", + "domain": [ 0.3, 1 ] + }, + "yaxis4": { + "anchor": "x4", + "domain": [ 0, 0.5 ] + }, + "showlegend": false, + "margin": { "t": 40, "b": 40, "l": 20, "r": 20 }, + "annotations": [ + { + "text": "matching category x-axes", + "showarrow": false, + "xref": "paper", + "yref": "paper", + "x": 0, + "xanchor": "left", + "y": 1, + "yanchor": "bottom", + "bgcolor": "#d3d3d3" + }, + { + "text": "matching multicategory x-axes", + "showarrow": false, + "xref": "paper", + "yref": "paper", + "x": 0, + "xanchor": "left", + "y": 0.5, + "yanchor": "bottom", + "bgcolor": "#d3d3d3" + } + ] + } +} diff --git a/test/image/mocks/splom_iris-matching.json b/test/image/mocks/splom_iris-matching.json new file mode 100644 index 00000000000..9e98efa612c --- /dev/null +++ b/test/image/mocks/splom_iris-matching.json @@ -0,0 +1,700 @@ +{ + "data": [ + { + "type": "splom", + "name": "Setosa", + "dimensions": [ + { + "label": "SepalLength", + "values": [ + "5.1", + "4.9", + "4.7", + "4.6", + "5.0", + "5.4", + "4.6", + "5.0", + "4.4", + "4.9", + "5.4", + "4.8", + "4.8", + "4.3", + "5.8", + "5.7", + "5.4", + "5.1", + "5.7", + "5.1", + "5.4", + "5.1", + "4.6", + "5.1", + "4.8", + "5.0", + "5.0", + "5.2", + "5.2", + "4.7", + "4.8", + "5.4", + "5.2", + "5.5", + "4.9", + "5.0", + "5.5", + "4.9", + "4.4", + "5.1", + "5.0", + "4.5", + "4.4", + "5.0", + "5.1", + "4.8", + "5.1", + "4.6", + "5.3", + "5.0" + ] + }, + { + "label": "SepalWidth", + "values": [ + "3.5", + "3.0", + "3.2", + "3.1", + "3.6", + "3.9", + "3.4", + "3.4", + "2.9", + "3.1", + "3.7", + "3.4", + "3.0", + "3.0", + "4.0", + "4.4", + "3.9", + "3.5", + "3.8", + "3.8", + "3.4", + "3.7", + "3.6", + "3.3", + "3.4", + "3.0", + "3.4", + "3.5", + "3.4", + "3.2", + "3.1", + "3.4", + "4.1", + "4.2", + "3.1", + "3.2", + "3.5", + "3.1", + "3.0", + "3.4", + "3.5", + "2.3", + "3.2", + "3.5", + "3.8", + "3.0", + "3.8", + "3.2", + "3.7", + "3.3" + ] + }, + { + "label": "PetalLength", + "values": [ + "1.4", + "1.4", + "1.3", + "1.5", + "1.4", + "1.7", + "1.4", + "1.5", + "1.4", + "1.5", + "1.5", + "1.6", + "1.4", + "1.1", + "1.2", + "1.5", + "1.3", + "1.4", + "1.7", + "1.5", + "1.7", + "1.5", + "1.0", + "1.7", + "1.9", + "1.6", + "1.6", + "1.5", + "1.4", + "1.6", + "1.6", + "1.5", + "1.5", + "1.4", + "1.5", + "1.2", + "1.3", + "1.5", + "1.3", + "1.5", + "1.3", + "1.3", + "1.3", + "1.6", + "1.9", + "1.4", + "1.6", + "1.4", + "1.5", + "1.4" + ] + }, + { + "label": "PetalWidth", + "values": [ + "0.2", + "0.2", + "0.2", + "0.2", + "0.2", + "0.4", + "0.3", + "0.2", + "0.2", + "0.1", + "0.2", + "0.2", + "0.1", + "0.1", + "0.2", + "0.4", + "0.4", + "0.3", + "0.3", + "0.3", + "0.2", + "0.4", + "0.2", + "0.5", + "0.2", + "0.2", + "0.4", + "0.2", + "0.2", + "0.2", + "0.2", + "0.4", + "0.1", + "0.2", + "0.1", + "0.2", + "0.2", + "0.1", + "0.2", + "0.2", + "0.3", + "0.3", + "0.2", + "0.6", + "0.4", + "0.3", + "0.2", + "0.2", + "0.2", + "0.2" + ] + } + ], + "marker": { + "color": "red" + } + }, + { + "type": "splom", + "name": "Versicolor", + "dimensions": [ + { + "label": "SepalLength", + "values": [ + "7.0", + "6.4", + "6.9", + "5.5", + "6.5", + "5.7", + "6.3", + "4.9", + "6.6", + "5.2", + "5.0", + "5.9", + "6.0", + "6.1", + "5.6", + "6.7", + "5.6", + "5.8", + "6.2", + "5.6", + "5.9", + "6.1", + "6.3", + "6.1", + "6.4", + "6.6", + "6.8", + "6.7", + "6.0", + "5.7", + "5.5", + "5.5", + "5.8", + "6.0", + "5.4", + "6.0", + "6.7", + "6.3", + "5.6", + "5.5", + "5.5", + "6.1", + "5.8", + "5.0", + "5.6", + "5.7", + "5.7", + "6.2", + "5.1", + "5.7" + ] + }, + { + "label": "SepalWidth", + "values": [ + "3.2", + "3.2", + "3.1", + "2.3", + "2.8", + "2.8", + "3.3", + "2.4", + "2.9", + "2.7", + "2.0", + "3.0", + "2.2", + "2.9", + "2.9", + "3.1", + "3.0", + "2.7", + "2.2", + "2.5", + "3.2", + "2.8", + "2.5", + "2.8", + "2.9", + "3.0", + "2.8", + "3.0", + "2.9", + "2.6", + "2.4", + "2.4", + "2.7", + "2.7", + "3.0", + "3.4", + "3.1", + "2.3", + "3.0", + "2.5", + "2.6", + "3.0", + "2.6", + "2.3", + "2.7", + "3.0", + "2.9", + "2.9", + "2.5", + "2.8" + ] + }, + { + "label": "PetalLength", + "values": [ + "4.7", + "4.5", + "4.9", + "4.0", + "4.6", + "4.5", + "4.7", + "3.3", + "4.6", + "3.9", + "3.5", + "4.2", + "4.0", + "4.7", + "3.6", + "4.4", + "4.5", + "4.1", + "4.5", + "3.9", + "4.8", + "4.0", + "4.9", + "4.7", + "4.3", + "4.4", + "4.8", + "5.0", + "4.5", + "3.5", + "3.8", + "3.7", + "3.9", + "5.1", + "4.5", + "4.5", + "4.7", + "4.4", + "4.1", + "4.0", + "4.4", + "4.6", + "4.0", + "3.3", + "4.2", + "4.2", + "4.2", + "4.3", + "3.0", + "4.1" + ] + }, + { + "label": "PetalWidth", + "values": [ + "1.4", + "1.5", + "1.5", + "1.3", + "1.5", + "1.3", + "1.6", + "1.0", + "1.3", + "1.4", + "1.0", + "1.5", + "1.0", + "1.4", + "1.3", + "1.4", + "1.5", + "1.0", + "1.5", + "1.1", + "1.8", + "1.3", + "1.5", + "1.2", + "1.3", + "1.4", + "1.4", + "1.7", + "1.5", + "1.0", + "1.1", + "1.0", + "1.2", + "1.6", + "1.5", + "1.6", + "1.5", + "1.3", + "1.3", + "1.3", + "1.2", + "1.4", + "1.2", + "1.0", + "1.3", + "1.2", + "1.3", + "1.3", + "1.1", + "1.3" + ] + } + ], + "marker": { + "color": "green" + } + }, + { + "type": "splom", + "name": "Virginica", + "dimensions": [ + { + "label": "SepalLength", + "values": [ + "6.3", + "5.8", + "7.1", + "6.3", + "6.5", + "7.6", + "4.9", + "7.3", + "6.7", + "7.2", + "6.5", + "6.4", + "6.8", + "5.7", + "5.8", + "6.4", + "6.5", + "7.7", + "7.7", + "6.0", + "6.9", + "5.6", + "7.7", + "6.3", + "6.7", + "7.2", + "6.2", + "6.1", + "6.4", + "7.2", + "7.4", + "7.9", + "6.4", + "6.3", + "6.1", + "7.7", + "6.3", + "6.4", + "6.0", + "6.9", + "6.7", + "6.9", + "5.8", + "6.8", + "6.7", + "6.7", + "6.3", + "6.5", + "6.2", + "5.9" + ] + }, + { + "label": "SepalWidth", + "values": [ + "3.3", + "2.7", + "3.0", + "2.9", + "3.0", + "3.0", + "2.5", + "2.9", + "2.5", + "3.6", + "3.2", + "2.7", + "3.0", + "2.5", + "2.8", + "3.2", + "3.0", + "3.8", + "2.6", + "2.2", + "3.2", + "2.8", + "2.8", + "2.7", + "3.3", + "3.2", + "2.8", + "3.0", + "2.8", + "3.0", + "2.8", + "3.8", + "2.8", + "2.8", + "2.6", + "3.0", + "3.4", + "3.1", + "3.0", + "3.1", + "3.1", + "3.1", + "2.7", + "3.2", + "3.3", + "3.0", + "2.5", + "3.0", + "3.4", + "3.0" + ] + }, + { + "label": "PetalLength", + "values": [ + "6.0", + "5.1", + "5.9", + "5.6", + "5.8", + "6.6", + "4.5", + "6.3", + "5.8", + "6.1", + "5.1", + "5.3", + "5.5", + "5.0", + "5.1", + "5.3", + "5.5", + "6.7", + "6.9", + "5.0", + "5.7", + "4.9", + "6.7", + "4.9", + "5.7", + "6.0", + "4.8", + "4.9", + "5.6", + "5.8", + "6.1", + "6.4", + "5.6", + "5.1", + "5.6", + "6.1", + "5.6", + "5.5", + "4.8", + "5.4", + "5.6", + "5.1", + "5.1", + "5.9", + "5.7", + "5.2", + "5.0", + "5.2", + "5.4", + "5.1" + ] + }, + { + "label": "PetalWidth", + "values": [ + "2.5", + "1.9", + "2.1", + "1.8", + "2.2", + "2.1", + "1.7", + "1.8", + "1.8", + "2.5", + "2.0", + "1.9", + "2.1", + "2.0", + "2.4", + "2.3", + "1.8", + "2.2", + "2.3", + "1.5", + "2.3", + "2.0", + "2.0", + "1.8", + "2.1", + "1.8", + "1.8", + "1.8", + "2.1", + "1.6", + "1.9", + "2.0", + "2.2", + "1.5", + "1.4", + "2.3", + "2.4", + "1.8", + "1.8", + "2.1", + "2.4", + "2.3", + "1.9", + "2.3", + "2.5", + "2.3", + "1.9", + "2.0", + "2.3", + "1.8" + ] + } + ], + "marker": { + "color": "blue" + } + } + ], + "layout": { + "title": "Iris dataset splom", + "xaxis": {"matches": "y"}, + "xaxis2": {"matches": "y2"}, + "xaxis3": {"matches": "y3"}, + "xaxis4": {"matches": "y4"}, + "width": 600, + "height": 500 + } +} diff --git a/test/image/mocks/splom_lower-nodiag-matching.json b/test/image/mocks/splom_lower-nodiag-matching.json new file mode 100644 index 00000000000..5dbecdb2b30 --- /dev/null +++ b/test/image/mocks/splom_lower-nodiag-matching.json @@ -0,0 +1,718 @@ +{ + "data": [ + { + "type": "splom", + "name": "Setosa", + "showupperhalf": false, + "diagonal": {"visible": false}, + "dimensions": [ + { + "axis": {"matches": true}, + "label": "SepalLength", + "values": [ + "5.1", + "4.9", + "4.7", + "4.6", + "5.0", + "5.4", + "4.6", + "5.0", + "4.4", + "4.9", + "5.4", + "4.8", + "4.8", + "4.3", + "5.8", + "5.7", + "5.4", + "5.1", + "5.7", + "5.1", + "5.4", + "5.1", + "4.6", + "5.1", + "4.8", + "5.0", + "5.0", + "5.2", + "5.2", + "4.7", + "4.8", + "5.4", + "5.2", + "5.5", + "4.9", + "5.0", + "5.5", + "4.9", + "4.4", + "5.1", + "5.0", + "4.5", + "4.4", + "5.0", + "5.1", + "4.8", + "5.1", + "4.6", + "5.3", + "5.0" + ] + }, + { + "axis": {"matches": true}, + "label": "SepalWidth", + "values": [ + "3.5", + "3.0", + "3.2", + "3.1", + "3.6", + "3.9", + "3.4", + "3.4", + "2.9", + "3.1", + "3.7", + "3.4", + "3.0", + "3.0", + "4.0", + "4.4", + "3.9", + "3.5", + "3.8", + "3.8", + "3.4", + "3.7", + "3.6", + "3.3", + "3.4", + "3.0", + "3.4", + "3.5", + "3.4", + "3.2", + "3.1", + "3.4", + "4.1", + "4.2", + "3.1", + "3.2", + "3.5", + "3.1", + "3.0", + "3.4", + "3.5", + "2.3", + "3.2", + "3.5", + "3.8", + "3.0", + "3.8", + "3.2", + "3.7", + "3.3" + ] + }, + { + "axis": {"matches": true}, + "label": "PetalLength", + "values": [ + "1.4", + "1.4", + "1.3", + "1.5", + "1.4", + "1.7", + "1.4", + "1.5", + "1.4", + "1.5", + "1.5", + "1.6", + "1.4", + "1.1", + "1.2", + "1.5", + "1.3", + "1.4", + "1.7", + "1.5", + "1.7", + "1.5", + "1.0", + "1.7", + "1.9", + "1.6", + "1.6", + "1.5", + "1.4", + "1.6", + "1.6", + "1.5", + "1.5", + "1.4", + "1.5", + "1.2", + "1.3", + "1.5", + "1.3", + "1.5", + "1.3", + "1.3", + "1.3", + "1.6", + "1.9", + "1.4", + "1.6", + "1.4", + "1.5", + "1.4" + ] + }, + { + "axis": {"matches": true}, + "label": "PetalWidth", + "values": [ + "0.2", + "0.2", + "0.2", + "0.2", + "0.2", + "0.4", + "0.3", + "0.2", + "0.2", + "0.1", + "0.2", + "0.2", + "0.1", + "0.1", + "0.2", + "0.4", + "0.4", + "0.3", + "0.3", + "0.3", + "0.2", + "0.4", + "0.2", + "0.5", + "0.2", + "0.2", + "0.4", + "0.2", + "0.2", + "0.2", + "0.2", + "0.4", + "0.1", + "0.2", + "0.1", + "0.2", + "0.2", + "0.1", + "0.2", + "0.2", + "0.3", + "0.3", + "0.2", + "0.6", + "0.4", + "0.3", + "0.2", + "0.2", + "0.2", + "0.2" + ] + } + ], + "marker": { + "color": "red" + } + }, + { + "type": "splom", + "name": "Versicolor", + "showupperhalf": false, + "diagonal": {"visible": false}, + "dimensions": [ + { + "axis": {"matches": true}, + "label": "SepalLength", + "values": [ + "7.0", + "6.4", + "6.9", + "5.5", + "6.5", + "5.7", + "6.3", + "4.9", + "6.6", + "5.2", + "5.0", + "5.9", + "6.0", + "6.1", + "5.6", + "6.7", + "5.6", + "5.8", + "6.2", + "5.6", + "5.9", + "6.1", + "6.3", + "6.1", + "6.4", + "6.6", + "6.8", + "6.7", + "6.0", + "5.7", + "5.5", + "5.5", + "5.8", + "6.0", + "5.4", + "6.0", + "6.7", + "6.3", + "5.6", + "5.5", + "5.5", + "6.1", + "5.8", + "5.0", + "5.6", + "5.7", + "5.7", + "6.2", + "5.1", + "5.7" + ] + }, + { + "axis": {"matches": true}, + "label": "SepalWidth", + "values": [ + "3.2", + "3.2", + "3.1", + "2.3", + "2.8", + "2.8", + "3.3", + "2.4", + "2.9", + "2.7", + "2.0", + "3.0", + "2.2", + "2.9", + "2.9", + "3.1", + "3.0", + "2.7", + "2.2", + "2.5", + "3.2", + "2.8", + "2.5", + "2.8", + "2.9", + "3.0", + "2.8", + "3.0", + "2.9", + "2.6", + "2.4", + "2.4", + "2.7", + "2.7", + "3.0", + "3.4", + "3.1", + "2.3", + "3.0", + "2.5", + "2.6", + "3.0", + "2.6", + "2.3", + "2.7", + "3.0", + "2.9", + "2.9", + "2.5", + "2.8" + ] + }, + { + "axis": {"matches": true}, + "label": "PetalLength", + "values": [ + "4.7", + "4.5", + "4.9", + "4.0", + "4.6", + "4.5", + "4.7", + "3.3", + "4.6", + "3.9", + "3.5", + "4.2", + "4.0", + "4.7", + "3.6", + "4.4", + "4.5", + "4.1", + "4.5", + "3.9", + "4.8", + "4.0", + "4.9", + "4.7", + "4.3", + "4.4", + "4.8", + "5.0", + "4.5", + "3.5", + "3.8", + "3.7", + "3.9", + "5.1", + "4.5", + "4.5", + "4.7", + "4.4", + "4.1", + "4.0", + "4.4", + "4.6", + "4.0", + "3.3", + "4.2", + "4.2", + "4.2", + "4.3", + "3.0", + "4.1" + ] + }, + { + "axis": {"matches": true}, + "label": "PetalWidth", + "values": [ + "1.4", + "1.5", + "1.5", + "1.3", + "1.5", + "1.3", + "1.6", + "1.0", + "1.3", + "1.4", + "1.0", + "1.5", + "1.0", + "1.4", + "1.3", + "1.4", + "1.5", + "1.0", + "1.5", + "1.1", + "1.8", + "1.3", + "1.5", + "1.2", + "1.3", + "1.4", + "1.4", + "1.7", + "1.5", + "1.0", + "1.1", + "1.0", + "1.2", + "1.6", + "1.5", + "1.6", + "1.5", + "1.3", + "1.3", + "1.3", + "1.2", + "1.4", + "1.2", + "1.0", + "1.3", + "1.2", + "1.3", + "1.3", + "1.1", + "1.3" + ] + } + ], + "marker": { + "color": "green" + } + }, + { + "type": "splom", + "name": "Virginica", + "showupperhalf": false, + "diagonal": {"visible": false}, + "dimensions": [ + { + "axis": {"matches": true}, + "label": "SepalLength", + "values": [ + "6.3", + "5.8", + "7.1", + "6.3", + "6.5", + "7.6", + "4.9", + "7.3", + "6.7", + "7.2", + "6.5", + "6.4", + "6.8", + "5.7", + "5.8", + "6.4", + "6.5", + "7.7", + "7.7", + "6.0", + "6.9", + "5.6", + "7.7", + "6.3", + "6.7", + "7.2", + "6.2", + "6.1", + "6.4", + "7.2", + "7.4", + "7.9", + "6.4", + "6.3", + "6.1", + "7.7", + "6.3", + "6.4", + "6.0", + "6.9", + "6.7", + "6.9", + "5.8", + "6.8", + "6.7", + "6.7", + "6.3", + "6.5", + "6.2", + "5.9" + ] + }, + { + "axis": {"matches": true}, + "label": "SepalWidth", + "values": [ + "3.3", + "2.7", + "3.0", + "2.9", + "3.0", + "3.0", + "2.5", + "2.9", + "2.5", + "3.6", + "3.2", + "2.7", + "3.0", + "2.5", + "2.8", + "3.2", + "3.0", + "3.8", + "2.6", + "2.2", + "3.2", + "2.8", + "2.8", + "2.7", + "3.3", + "3.2", + "2.8", + "3.0", + "2.8", + "3.0", + "2.8", + "3.8", + "2.8", + "2.8", + "2.6", + "3.0", + "3.4", + "3.1", + "3.0", + "3.1", + "3.1", + "3.1", + "2.7", + "3.2", + "3.3", + "3.0", + "2.5", + "3.0", + "3.4", + "3.0" + ] + }, + { + "axis": {"matches": true}, + "label": "PetalLength", + "values": [ + "6.0", + "5.1", + "5.9", + "5.6", + "5.8", + "6.6", + "4.5", + "6.3", + "5.8", + "6.1", + "5.1", + "5.3", + "5.5", + "5.0", + "5.1", + "5.3", + "5.5", + "6.7", + "6.9", + "5.0", + "5.7", + "4.9", + "6.7", + "4.9", + "5.7", + "6.0", + "4.8", + "4.9", + "5.6", + "5.8", + "6.1", + "6.4", + "5.6", + "5.1", + "5.6", + "6.1", + "5.6", + "5.5", + "4.8", + "5.4", + "5.6", + "5.1", + "5.1", + "5.9", + "5.7", + "5.2", + "5.0", + "5.2", + "5.4", + "5.1" + ] + }, + { + "axis": {"matches": true}, + "label": "PetalWidth", + "values": [ + "2.5", + "1.9", + "2.1", + "1.8", + "2.2", + "2.1", + "1.7", + "1.8", + "1.8", + "2.5", + "2.0", + "1.9", + "2.1", + "2.0", + "2.4", + "2.3", + "1.8", + "2.2", + "2.3", + "1.5", + "2.3", + "2.0", + "2.0", + "1.8", + "2.1", + "1.8", + "1.8", + "1.8", + "2.1", + "1.6", + "1.9", + "2.0", + "2.2", + "1.5", + "1.4", + "2.3", + "2.4", + "1.8", + "1.8", + "2.1", + "2.4", + "2.3", + "1.9", + "2.3", + "2.5", + "2.3", + "1.9", + "2.0", + "2.3", + "1.8" + ] + } + ], + "marker": { + "color": "blue" + } + } + ], + "layout": { + "title": "Iris dataset splom", + "width": 600, + "height": 500, + "legend": { + "x": 1, + "xanchor": "right" + } + } +} diff --git a/test/jasmine/assets/custom_matchers.js b/test/jasmine/assets/custom_matchers.js index e77caf56e6a..f1d877d8d51 100644 --- a/test/jasmine/assets/custom_matchers.js +++ b/test/jasmine/assets/custom_matchers.js @@ -64,25 +64,11 @@ var matchers = { }; }, - // toBeCloseTo... but for arrays toBeCloseToArray: function() { return { compare: function(actual, expected, precision, msgExtra) { - precision = coercePosition(precision); - - var passed; - - if(Array.isArray(actual) && Array.isArray(expected)) { - var tested = actual.map(function(element, i) { - return isClose(element, expected[i], precision); - }); - - passed = ( - expected.length === actual.length && - tested.indexOf(false) < 0 - ); - } - else passed = false; + var testFn = makeIsCloseFn(coercePosition(precision)); + var passed = assertArray(actual, expected, testFn); var message = [ 'Expected', actual, 'to be close to', expected, msgExtra @@ -96,30 +82,11 @@ var matchers = { }; }, - // toBeCloseTo... but for 2D arrays toBeCloseTo2DArray: function() { return { compare: function(actual, expected, precision, msgExtra) { - precision = coercePosition(precision); - - var passed = true; - - if(expected.length !== actual.length) passed = false; - else { - for(var i = 0; i < expected.length; ++i) { - if(expected[i].length !== actual[i].length) { - passed = false; - break; - } - - for(var j = 0; j < expected[i].length; ++j) { - if(!isClose(actual[i][j], expected[i][j], precision)) { - passed = false; - break; - } - } - } - } + var testFn = makeIsCloseFn(coercePosition(precision)); + var passed = assert2DArray(actual, expected, testFn); var message = [ 'Expected', @@ -140,7 +107,29 @@ var matchers = { toBeWithin: function() { return { compare: function(actual, expected, tolerance, msgExtra) { - var passed = Math.abs(actual - expected) < tolerance; + var testFn = makeIsWithinFn(tolerance); + var passed = testFn(actual, expected); + + var message = [ + 'Expected', actual, + 'to be close to', expected, + 'within', tolerance, + msgExtra + ].join(' '); + + return { + pass: passed, + message: message + }; + } + }; + }, + + toBeWithinArray: function() { + return { + compare: function(actual, expected, tolerance, msgExtra) { + var testFn = makeIsWithinFn(tolerance); + var passed = assertArray(actual, expected, testFn); var message = [ 'Expected', actual, @@ -183,15 +172,59 @@ var matchers = { } }; -function isClose(actual, expected, precision) { - if(isNumeric(actual) && isNumeric(expected)) { - return Math.abs(actual - expected) < precision; +function assertArray(actual, expected, testFn) { + if(Array.isArray(actual) && Array.isArray(expected)) { + var tested = actual.map(function(element, i) { + return testFn(element, expected[i]); + }); + + return ( + expected.length === actual.length && + tested.indexOf(false) < 0 + ); } + return false; +} - return ( - actual === expected || - (isNaN(actual) && isNaN(expected)) - ); +function assert2DArray(actual, expected, testFn) { + if(expected.length !== actual.length) return false; + + for(var i = 0; i < expected.length; i++) { + if(expected[i].length !== actual[i].length) { + return false; + } + + for(var j = 0; j < expected[i].length; j++) { + if(!testFn(actual[i][j], expected[i][j])) { + return false; + } + } + } + return true; +} + +function makeIsCloseFn(precision) { + return function isClose(actual, expected) { + if(isNumeric(actual) && isNumeric(expected)) { + return Math.abs(actual - expected) < precision; + } + return ( + actual === expected || + (isNaN(actual) && isNaN(expected)) + ); + }; +} + +function makeIsWithinFn(tolerance) { + return function isWithin(actual, expected) { + if(isNumeric(actual) && isNumeric(expected)) { + return Math.abs(actual - expected) < tolerance; + } + return ( + actual === expected || + (isNaN(actual) && isNaN(expected)) + ); + }; } function coercePosition(precision) { diff --git a/test/jasmine/assets/drag.js b/test/jasmine/assets/drag.js index 64721d36151..6777ceb2204 100644 --- a/test/jasmine/assets/drag.js +++ b/test/jasmine/assets/drag.js @@ -2,37 +2,63 @@ var isNumeric = require('fast-isnumeric'); var mouseEvent = require('./mouse_event'); var getNodeCoords = require('./get_node_coords'); -/* - * drag: grab a node and drag it (dx, dy) pixels - * optionally specify an edge ('n', 'se', 'w' etc) - * to grab it by an edge or corner (otherwise the middle is used) - */ -function drag(node, dx, dy, edge, x0, y0, nsteps, noCover) { - nsteps = nsteps || 1; +function makeFns(node, dx, dy, opts) { + opts = opts || {}; + + var nsteps = opts.nsteps || 1; + var edge = opts.edge || ''; + var noCover = Boolean(opts.noCover); var coords = getNodeCoords(node, edge); - var fromX = isNumeric(x0) ? x0 : coords.x; - var fromY = isNumeric(y0) ? y0 : coords.y; + var fromX = isNumeric(opts.x0) ? opts.x0 : coords.x; + var fromY = isNumeric(opts.y0) ? opts.y0 : coords.y; - mouseEvent('mousemove', fromX, fromY, {element: node}); - mouseEvent('mousedown', fromX, fromY, {element: node}); + var dragCoverNode; + var toX; + var toY; - var promise = (noCover ? Promise.resolve(node) : waitForDragCover()) - .then(function(dragCoverNode) { - var toX; - var toY; + function start() { + mouseEvent('mousemove', fromX, fromY, {element: node}); + mouseEvent('mousedown', fromX, fromY, {element: node}); - for(var i = 1; i <= nsteps; i++) { - toX = fromX + i * dx / nsteps; - toY = fromY + i * dy / nsteps; - mouseEvent('mousemove', toX, toY, {element: dragCoverNode}); - } + return (noCover ? Promise.resolve(node) : waitForDragCover()) + .then(function(_dragCoverNode) { + dragCoverNode = _dragCoverNode; + + for(var i = 1; i <= nsteps; i++) { + toX = fromX + i * dx / nsteps; + toY = fromY + i * dy / nsteps; + mouseEvent('mousemove', toX, toY, {element: dragCoverNode}); + } + }); + } + function end() { mouseEvent('mouseup', toX, toY, {element: dragCoverNode}); return noCover || waitForDragCoverRemoval(); + } + + return { + start: start, + end: end + }; +} + +/* + * drag: grab a node and drag it (dx, dy) pixels + * optionally specify an edge ('n', 'se', 'w' etc) + * to grab it by an edge or corner (otherwise the middle is used) + */ +function drag(node, dx, dy, edge, x0, y0, nsteps, noCover) { + var fns = makeFns(node, dx, dy, { + edge: edge, + x0: x0, + y0: y0, + nsteps: nsteps, + noCover: noCover }); - return promise; + return fns.start().then(fns.end); } function waitForDragCover() { @@ -78,5 +104,6 @@ function waitForDragCoverRemoval() { } module.exports = drag; +drag.makeFns = makeFns; drag.waitForDragCover = waitForDragCover; drag.waitForDragCoverRemoval = waitForDragCoverRemoval; diff --git a/test/jasmine/tests/axes_test.js b/test/jasmine/tests/axes_test.js index 03eb6563cad..9a17b09222f 100644 --- a/test/jasmine/tests/axes_test.js +++ b/test/jasmine/tests/axes_test.js @@ -588,8 +588,8 @@ describe('Test axes', function() { }); var warnTxt = ' to avoid either an infinite loop and possibly ' + - 'inconsistent scaleratios, or because the targetaxis has ' + - 'fixed range.'; + 'inconsistent scaleratios, or because the target axis has ' + + 'fixed range or this axis declares a *matches* constraint.'; it('breaks scaleanchor loops and drops conflicting ratios', function() { var warnings = []; @@ -670,6 +670,85 @@ describe('Test axes', function() { }); }); + it('will not match axes of different types', function() { + layoutIn = { + xaxis: {type: 'linear'}, + yaxis: {type: 'log', matches: 'x'}, + xaxis2: {type: 'date', matches: 'y'}, + yaxis2: {type: 'category', matches: 'x2'} + }; + layoutOut._subplots.cartesian.push('x2y2'); + layoutOut._subplots.yaxis.push('x2', 'y2'); + + supplyLayoutDefaults(layoutIn, layoutOut, fullData); + + expect(layoutOut._axisMatchGroups).toEqual([]); + + ['xaxis', 'yaxis', 'xaxis2', 'yaxis2'].forEach(function(axName) { + expect(layoutOut[axName].matches).toBeUndefined(); + }); + }); + + it('disallow constraining AND matching range', function() { + layoutIn = { + xaxis: {}, + xaxis2: {matches: 'x', scaleanchor: 'x'} + }; + layoutOut._subplots.cartesian.push('x2y'); + layoutOut._subplots.xaxis.push('x2'); + + supplyLayoutDefaults(layoutIn, layoutOut, fullData); + + expect(layoutOut.xaxis2.matches).toBe('x'); + expect(layoutOut.xaxis2.scaleanchor).toBe(undefined); + expect(layoutOut.xaxis2.constrain).toBe(undefined); + + expect(layoutOut._axisConstraintGroups).toEqual([]); + expect(layoutOut._axisMatchGroups).toEqual([{x: 1, x2: 1}]); + }); + + it('remove axes from constraint groups if they are in a match group', function() { + layoutIn = { + // this one is ok + xaxis: {}, + yaxis: {scaleanchor: 'x'}, + // this one too + xaxis2: {}, + yaxis2: {matches: 'x2'}, + // not these ones + xaxis3: {scaleanchor: 'x2'}, + yaxis3: {scaleanchor: 'y2'} + }; + layoutOut._subplots.cartesian.push('x2y2, x3y3'); + layoutOut._subplots.xaxis.push('x2', 'x3'); + layoutOut._subplots.yaxis.push('y2', 'y3'); + + supplyLayoutDefaults(layoutIn, layoutOut, fullData); + + expect(layoutOut._axisMatchGroups.length).toBe(1); + expect(layoutOut._axisMatchGroups).toContain({x2: 1, y2: 1}); + + expect(layoutOut._axisConstraintGroups.length).toBe(1); + expect(layoutOut._axisConstraintGroups).toContain({x: 1, y: 1}); + }); + + it('remove constraint group if they are one or zero items left in it', function() { + layoutIn = { + xaxis: {}, + yaxis: {matches: 'x'}, + xaxis2: {scaleanchor: 'y'} + }; + layoutOut._subplots.cartesian.push('x2y'); + layoutOut._subplots.xaxis.push('x2'); + + supplyLayoutDefaults(layoutIn, layoutOut, fullData); + + expect(layoutOut._axisMatchGroups.length).toBe(1); + expect(layoutOut._axisMatchGroups).toContain({x: 1, y: 1}); + + expect(layoutOut._axisConstraintGroups.length).toBe(0); + }); + it('drops scaleanchor settings if either the axis or target has fixedrange', function() { // some of these will create warnings... not too important, so not going to test, // just want to keep the output clean @@ -697,6 +776,26 @@ describe('Test axes', function() { }); }); + it('drops *matches* settings if either the axis or target has fixedrange', function() { + layoutIn = { + xaxis: {fixedrange: true, matches: 'y'}, + yaxis: {matches: 'x2'}, // only this one should survive + xaxis2: {}, + yaxis2: {matches: 'x'} + }; + layoutOut._subplots.cartesian.push('x2y2'); + layoutOut._subplots.yaxis.push('x2', 'y2'); + + supplyLayoutDefaults(layoutIn, layoutOut, fullData); + + expect(layoutOut._axisMatchGroups).toEqual([{x2: 1, y: 1}]); + expect(layoutOut.yaxis.matches).toBe('x2'); + + ['xaxis', 'yaxis2', 'xaxis2'].forEach(function(axName) { + expect(layoutOut[axName].matches).toBeUndefined(); + }); + }); + it('should coerce hoverformat even on visible: false axes', function() { layoutIn = { xaxis: { @@ -708,6 +807,69 @@ describe('Test axes', function() { supplyLayoutDefaults(layoutIn, layoutOut, fullData); expect(layoutOut.xaxis.hoverformat).toEqual('g'); }); + + it('should find matching groups', function() { + layoutIn = { + // both linked to 'base' ax + xaxis: {}, + xaxis2: {matches: 'x'}, + xaxis3: {matches: 'x'}, + // cascading links + yaxis: {}, + yaxis2: {anchor: 'x2', matches: 'y'}, + yaxis3: {anchor: 'x3', matches: 'y2'}, + }; + layoutOut._subplots.cartesian.push('x2y2', 'x3y3'); + layoutOut._subplots.xaxis.push('x2', 'x3'); + layoutOut._subplots.yaxis.push('y2', 'y3'); + + supplyLayoutDefaults(layoutIn, layoutOut, fullData); + + expect(layoutOut._axisMatchGroups.length).toBe(2); + expect(layoutOut._axisMatchGroups).toContain({x: 1, x2: 1, x3: 1}); + expect(layoutOut._axisMatchGroups).toContain({y: 1, y2: 1, y3: 1}); + }); + + it('should match set axis range value for matching axes', function() { + layoutIn = { + // autorange case + xaxis: {}, + xaxis2: {matches: 'x'}, + // matchee ax has range + yaxis: {range: [0, 1]}, + yaxis2: {matches: 'y'}, + // matcher ax has range (gets ignored) + xaxis3: {}, + yaxis3: {range: [-1, 1], matches: 'x3'}, + // both ax have range + xaxis4: {range: [0, 2], matches: 'y4'}, + yaxis4: {range: [-1, 3], matches: 'x4'} + }; + layoutOut._subplots.cartesian.push('x2y2', 'x3y3', 'x4y4'); + layoutOut._subplots.xaxis.push('x2', 'x3', 'x4'); + layoutOut._subplots.yaxis.push('y2', 'y3', 'y4'); + + supplyLayoutDefaults(layoutIn, layoutOut, fullData); + + expect(layoutOut._axisMatchGroups.length).toBe(4); + expect(layoutOut._axisMatchGroups).toContain({x: 1, x2: 1}); + expect(layoutOut._axisMatchGroups).toContain({y: 1, y2: 1}); + expect(layoutOut._axisMatchGroups).toContain({x3: 1, y3: 1}); + expect(layoutOut._axisMatchGroups).toContain({x4: 1, y4: 1}); + + function _assertMatchingAxes(names, autorange, rng) { + names.forEach(function(n) { + var ax = layoutOut[n]; + expect(ax.autorange).toBe(autorange, n); + expect(ax.range).toEqual(rng); + }); + } + + _assertMatchingAxes(['xaxis', 'xaxis2'], true, [-1, 6]); + _assertMatchingAxes(['yaxis', 'yaxis2'], false, [0, 1]); + _assertMatchingAxes(['xaxis3', 'yaxis3'], true, [-1, 6]); + _assertMatchingAxes(['xaxis4', 'yaxis4'], false, [-1, 3]); + }); }); describe('constraints relayout', function() { @@ -1072,6 +1234,66 @@ describe('Test axes', function() { }); }); + describe('matching axes relayout calls', function() { + var gd; + + beforeEach(function() { + gd = createGraphDiv(); + }); + + afterEach(destroyGraphDiv); + + function assertRanges(msg, exp) { + exp.forEach(function(expi) { + var axNames = expi[0]; + var rng = expi[1]; + var autorng = expi[2]; + + axNames.forEach(function(n) { + var msgi = n + ' - ' + msg; + expect(gd._fullLayout[n].range).toBeCloseToArray(rng, 1.5, msgi + ' |range'); + expect(gd._fullLayout[n].autorange).toBe(autorng, msgi + ' |autorange'); + }); + }); + } + + it('should auto-range according to all matching trace data', function(done) { + Plotly.plot(gd, [ + { y: [1, 2, 1] }, + { y: [2, 1, 2, 3], xaxis: 'x2' }, + { y: [0, 1], xaxis: 'x3' } + ], { + xaxis: {domain: [0, 0.2]}, + xaxis2: {matches: 'x', domain: [0.3, 0.6]}, + xaxis3: {matches: 'x', domain: [0.65, 1]}, + width: 800, + height: 500, + }) + .then(function() { + assertRanges('base (autoranged)', [ + [['xaxis', 'xaxis2', 'xaxis3'], [-0.245, 3.245], true], + [['yaxis'], [-0.211, 3.211], true] + ]); + }) + .then(function() { return Plotly.relayout(gd, 'xaxis.range', [-1, 4]); }) + .then(function() { + assertRanges('set range', [ + [['xaxis', 'xaxis2', 'xaxis3'], [-1, 4], false], + [['yaxis'], [-0.211, 3.211], true] + ]); + }) + .then(function() { return Plotly.relayout(gd, 'xaxis2.autorange', true); }) + .then(function() { + assertRanges('back to autorange', [ + [['xaxis', 'xaxis2', 'xaxis3'], [-0.245, 3.245], true], + [['yaxis'], [-0.211, 3.211], true] + ]); + }) + .catch(failTest) + .then(done); + }); + }); + describe('categoryorder', function() { var gd; diff --git a/test/jasmine/tests/cartesian_interact_test.js b/test/jasmine/tests/cartesian_interact_test.js index aeca9bf68a0..ed33efe04e2 100644 --- a/test/jasmine/tests/cartesian_interact_test.js +++ b/test/jasmine/tests/cartesian_interact_test.js @@ -2,6 +2,8 @@ var d3 = require('d3'); var Plotly = require('@lib/index'); var Lib = require('@src/lib'); +var Axes = require('@src/plots/cartesian/axes'); +var Drawing = require('@src/components/drawing'); var constants = require('@src/plots/cartesian/constants'); var createGraphDiv = require('../assets/create_graph_div'); @@ -283,76 +285,6 @@ describe('axis zoom/pan and main plot zoom', function() { afterEach(destroyGraphDiv); - var initialRange = [0, 2]; - var autoRange = [-0.1594, 2.1594]; - - function makePlot(constrainScales, layoutEdits) { - // mock with 4 subplots, 3 of which share some axes: - // - // | | - // y2| xy2 y3| x3y3 - // | | - // +--------- +---------- - // x3 - // | | - // y| xy | x2y - // | | - // +--------- +---------- - // x x2 - // - // each subplot is 200x200 px - // if constrainScales is used, x/x2/y/y2 are linked, as are x3/y3 - // layoutEdits are other changes to make to the layout - - var data = [ - {y: [0, 1, 2]}, - {y: [0, 1, 2], xaxis: 'x2'}, - {y: [0, 1, 2], yaxis: 'y2'}, - {y: [0, 1, 2], xaxis: 'x3', yaxis: 'y3'} - ]; - - var layout = { - width: 700, - height: 620, - margin: {l: 100, r: 100, t: 20, b: 100}, - showlegend: false, - xaxis: {domain: [0, 0.4], range: [0, 2]}, - yaxis: {domain: [0.15, 0.55], range: [0, 2]}, - xaxis2: {domain: [0.6, 1], range: [0, 2]}, - yaxis2: {domain: [0.6, 1], range: [0, 2]}, - xaxis3: {domain: [0.6, 1], range: [0, 2], anchor: 'y3'}, - yaxis3: {domain: [0.6, 1], range: [0, 2], anchor: 'x3'} - }; - - var config = {scrollZoom: true}; - - if(constrainScales) { - layout.yaxis.scaleanchor = 'x'; - layout.yaxis2.scaleanchor = 'x'; - layout.xaxis2.scaleanchor = 'y'; - layout.yaxis3.scaleanchor = 'x3'; - } - - if(layoutEdits) Lib.extendDeep(layout, layoutEdits); - - return Plotly.newPlot(gd, data, layout, config) - .then(checkRanges({}, 'initial')) - .then(function() { - expect(Object.keys(gd._fullLayout._plots).sort()) - .toEqual(['xy', 'xy2', 'x2y', 'x3y3'].sort()); - - // nsew, n, ns, s, w, ew, e, ne, nw, se, sw - expect(document.querySelectorAll('.drag[data-subplot="xy"]').length).toBe(11); - // same but no w, ew, e because x is on xy only - expect(document.querySelectorAll('.drag[data-subplot="xy2"]').length).toBe(8); - // y is on xy only so no n, ns, s - expect(document.querySelectorAll('.drag[data-subplot="x2y"]').length).toBe(8); - // all 11, as this is a fully independent subplot - expect(document.querySelectorAll('.drag[data-subplot="x3y3"]').length).toBe(11); - }); - - } - function getDragger(subplot, directions) { return document.querySelector('.' + directions + 'drag[data-subplot="' + subplot + '"]'); } @@ -371,164 +303,227 @@ describe('axis zoom/pan and main plot zoom', function() { }; } - function checkRanges(newRanges, msg) { - msg = msg || ''; - if(msg) msg = ' - ' + msg; - + function doScroll(subplot, directions, deltaY, opts) { return function() { - var allRanges = { - xaxis: initialRange.slice(), - yaxis: initialRange.slice(), - xaxis2: initialRange.slice(), - yaxis2: initialRange.slice(), - xaxis3: initialRange.slice(), - yaxis3: initialRange.slice() + opts = opts || {}; + var edge = opts.edge || ''; + var dx = opts.dx || 0; + var dy = opts.dy || 0; + var dragger = getDragger(subplot, directions); + var coords = getNodeCoords(dragger, edge); + mouseEvent('scroll', coords.x + dx, coords.y + dy, {deltaY: deltaY, element: dragger}); + return delay(constants.REDRAWDELAY + 10)(); + }; + } + + function makeDragFns(subplot, directions, dx, dy, x0, y0) { + var dragger = getDragger(subplot, directions); + return drag.makeFns(dragger, dx, dy, {x0: x0, y0: y0}); + } + + describe('subplots with shared axes', function() { + var initialRange = [0, 2]; + var autoRange = [-0.1594, 2.1594]; + + function makePlot(constrainScales, layoutEdits) { + // mock with 4 subplots, 3 of which share some axes: + // + // | | + // y2| xy2 y3| x3y3 + // | | + // +--------- +---------- + // x3 + // | | + // y| xy | x2y + // | | + // +--------- +---------- + // x x2 + // + // each subplot is 200x200 px + // if constrainScales is used, x/x2/y/y2 are linked, as are x3/y3 + // layoutEdits are other changes to make to the layout + + var data = [ + {y: [0, 1, 2]}, + {y: [0, 1, 2], xaxis: 'x2'}, + {y: [0, 1, 2], yaxis: 'y2'}, + {y: [0, 1, 2], xaxis: 'x3', yaxis: 'y3'} + ]; + + var layout = { + width: 700, + height: 620, + margin: {l: 100, r: 100, t: 20, b: 100}, + showlegend: false, + xaxis: {domain: [0, 0.4], range: [0, 2]}, + yaxis: {domain: [0.15, 0.55], range: [0, 2]}, + xaxis2: {domain: [0.6, 1], range: [0, 2]}, + yaxis2: {domain: [0.6, 1], range: [0, 2]}, + xaxis3: {domain: [0.6, 1], range: [0, 2], anchor: 'y3'}, + yaxis3: {domain: [0.6, 1], range: [0, 2], anchor: 'x3'} }; - Lib.extendDeep(allRanges, newRanges); - for(var axName in allRanges) { - expect(gd.layout[axName].range).toBeCloseToArray(allRanges[axName], 3, axName + msg); - expect(gd._fullLayout[axName].range).toBeCloseToArray(gd.layout[axName].range, 6, axName + msg); + var config = {scrollZoom: true}; + + if(constrainScales) { + layout.yaxis.scaleanchor = 'x'; + layout.yaxis2.scaleanchor = 'x'; + layout.xaxis2.scaleanchor = 'y'; + layout.yaxis3.scaleanchor = 'x3'; } - }; - } - it('updates with correlated subplots & no constraints - zoom, dblclick, axis ends', function(done) { - makePlot() - // zoombox into a small point - drag starts from the center unless you specify otherwise - .then(doDrag('xy', 'nsew', 100, -50)) - .then(checkRanges({xaxis: [1, 2], yaxis: [1, 1.5]}, 'zoombox')) + if(layoutEdits) Lib.extendDeep(layout, layoutEdits); + + return Plotly.newPlot(gd, data, layout, config) + .then(checkRanges({}, 'initial')) + .then(function() { + expect(Object.keys(gd._fullLayout._plots).sort()) + .toEqual(['xy', 'xy2', 'x2y', 'x3y3'].sort()); + + // nsew, n, ns, s, w, ew, e, ne, nw, se, sw + expect(document.querySelectorAll('.drag[data-subplot="xy"]').length).toBe(11); + // same but no w, ew, e because x is on xy only + expect(document.querySelectorAll('.drag[data-subplot="xy2"]').length).toBe(8); + // y is on xy only so no n, ns, s + expect(document.querySelectorAll('.drag[data-subplot="x2y"]').length).toBe(8); + // all 11, as this is a fully independent subplot + expect(document.querySelectorAll('.drag[data-subplot="x3y3"]').length).toBe(11); + }); + } - // first dblclick reverts to saved ranges - .then(doDblClick('xy', 'nsew')) - .then(checkRanges({}, 'dblclick #1')) - // next dblclick autoscales (just that plot) - .then(doDblClick('xy', 'nsew')) - .then(checkRanges({xaxis: autoRange, yaxis: autoRange}, 'dblclick #2')) - // dblclick on one axis reverts just that axis to saved - .then(doDblClick('xy', 'ns')) - .then(checkRanges({xaxis: autoRange}, 'dblclick y')) - // dblclick the plot at this point (one axis default, the other autoscaled) - // and the whole thing is reverted to default - .then(doDblClick('xy', 'nsew')) - .then(checkRanges({}, 'dblclick #3')) - - // 1D zoombox - use the linked subplots - .then(doDrag('xy2', 'nsew', -100, 0)) - .then(checkRanges({xaxis: [0, 1]}, 'xy2 zoombox')) - .then(doDrag('x2y', 'nsew', 0, 50)) - .then(checkRanges({xaxis: [0, 1], yaxis: [0.5, 1]}, 'x2y zoombox')) - // dblclick on linked subplots just changes the linked axis - .then(doDblClick('xy2', 'nsew')) - .then(checkRanges({yaxis: [0.5, 1]}, 'dblclick xy2')) - .then(doDblClick('x2y', 'nsew')) - .then(checkRanges({}, 'dblclick x2y')) - // drag on axis ends - all these 1D draggers the opposite axis delta is irrelevant - .then(doDrag('xy2', 'n', 53, 100)) - .then(checkRanges({yaxis2: [0, 4]}, 'drag y2n')) - .then(doDrag('xy', 's', 53, -100)) - .then(checkRanges({yaxis: [-2, 2], yaxis2: [0, 4]}, 'drag ys')) - // expanding drag is highly nonlinear - .then(doDrag('x2y', 'e', 50, 53)) - .then(checkRanges({yaxis: [-2, 2], yaxis2: [0, 4], xaxis2: [0, 0.8751]}, 'drag x2e')) - .then(doDrag('x2y', 'w', -50, 53)) - .then(checkRanges({yaxis: [-2, 2], yaxis2: [0, 4], xaxis2: [0.4922, 0.8751]}, 'drag x2w')) - // reset all from the modebar - .then(function() { selectButton(gd._fullLayout._modeBar, 'resetScale2d').click(); }) - .then(checkRanges({}, 'final reset')) - .catch(failTest) - .then(done); - }); + function checkRanges(newRanges, msg) { + msg = msg || ''; + if(msg) msg = ' - ' + msg; + + return function() { + var allRanges = { + xaxis: initialRange.slice(), + yaxis: initialRange.slice(), + xaxis2: initialRange.slice(), + yaxis2: initialRange.slice(), + xaxis3: initialRange.slice(), + yaxis3: initialRange.slice() + }; + Lib.extendDeep(allRanges, newRanges); + + for(var axName in allRanges) { + expect(gd.layout[axName].range).toBeCloseToArray(allRanges[axName], 3, axName + msg); + expect(gd._fullLayout[axName].range).toBeCloseToArray(gd.layout[axName].range, 6, axName + msg); + } + }; + } - it('updates with correlated subplots & no constraints - middles, corners, and scrollwheel', function(done) { - makePlot() - // drag axis middles - .then(doDrag('x3y3', 'ew', 100, 0)) - .then(checkRanges({xaxis3: [-1, 1]}, 'drag x3ew')) - .then(doDrag('x3y3', 'ns', 53, 100)) - .then(checkRanges({xaxis3: [-1, 1], yaxis3: [1, 3]}, 'drag y3ns')) - // drag corners - .then(doDrag('x3y3', 'ne', -100, 100)) - .then(checkRanges({xaxis3: [-1, 3], yaxis3: [1, 5]}, 'zoom x3y3ne')) - .then(doDrag('x3y3', 'sw', 100, -100)) - .then(checkRanges({xaxis3: [-5, 3], yaxis3: [-3, 5]}, 'zoom x3y3sw')) - .then(doDrag('x3y3', 'nw', -50, -50)) - .then(checkRanges({xaxis3: [-0.5006, 3], yaxis3: [-3, 0.5006]}, 'zoom x3y3nw')) - .then(doDrag('x3y3', 'se', 50, 50)) - .then(checkRanges({xaxis3: [-0.5006, 1.0312], yaxis3: [-1.0312, 0.5006]}, 'zoom x3y3se')) - .then(doDblClick('x3y3', 'nsew')) - .then(checkRanges({}, 'reset x3y3')) - // scroll wheel - .then(function() { - var mainDrag = getDragger('xy', 'nsew'); - var mainDragCoords = getNodeCoords(mainDrag, 'se'); - mouseEvent('scroll', mainDragCoords.x, mainDragCoords.y, {deltaY: 20, element: mainDrag}); - }) - .then(delay(constants.REDRAWDELAY + 10)) - .then(checkRanges({xaxis: [-0.2103, 2], yaxis: [0, 2.2103]}, 'xy main scroll')) - .then(function() { - var ewDrag = getDragger('xy', 'ew'); - var ewDragCoords = getNodeCoords(ewDrag); - mouseEvent('scroll', ewDragCoords.x - 50, ewDragCoords.y, {deltaY: -20, element: ewDrag}); - }) - .then(delay(constants.REDRAWDELAY + 10)) - .then(checkRanges({xaxis: [-0.1578, 1.8422], yaxis: [0, 2.2103]}, 'x scroll')) - .then(function() { - var nsDrag = getDragger('xy', 'ns'); - var nsDragCoords = getNodeCoords(nsDrag); - mouseEvent('scroll', nsDragCoords.x, nsDragCoords.y - 50, {deltaY: -20, element: nsDrag}); - }) - .then(delay(constants.REDRAWDELAY + 10)) - .then(checkRanges({xaxis: [-0.1578, 1.8422], yaxis: [0.1578, 2.1578]}, 'y scroll')) - .catch(failTest) - .then(done); - }); + it('updates with correlated subplots & no constraints - zoom, dblclick, axis ends', function(done) { + makePlot() + // zoombox into a small point - drag starts from the center unless you specify otherwise + .then(doDrag('xy', 'nsew', 100, -50)) + .then(checkRanges({xaxis: [1, 2], yaxis: [1, 1.5]}, 'zoombox')) + + // first dblclick reverts to saved ranges + .then(doDblClick('xy', 'nsew')) + .then(checkRanges({}, 'dblclick #1')) + // next dblclick autoscales (just that plot) + .then(doDblClick('xy', 'nsew')) + .then(checkRanges({xaxis: autoRange, yaxis: autoRange}, 'dblclick #2')) + // dblclick on one axis reverts just that axis to saved + .then(doDblClick('xy', 'ns')) + .then(checkRanges({xaxis: autoRange}, 'dblclick y')) + // dblclick the plot at this point (one axis default, the other autoscaled) + // and the whole thing is reverted to default + .then(doDblClick('xy', 'nsew')) + .then(checkRanges({}, 'dblclick #3')) + + // 1D zoombox - use the linked subplots + .then(doDrag('xy2', 'nsew', -100, 0)) + .then(checkRanges({xaxis: [0, 1]}, 'xy2 zoombox')) + .then(doDrag('x2y', 'nsew', 0, 50)) + .then(checkRanges({xaxis: [0, 1], yaxis: [0.5, 1]}, 'x2y zoombox')) + // dblclick on linked subplots just changes the linked axis + .then(doDblClick('xy2', 'nsew')) + .then(checkRanges({yaxis: [0.5, 1]}, 'dblclick xy2')) + .then(doDblClick('x2y', 'nsew')) + .then(checkRanges({}, 'dblclick x2y')) + // drag on axis ends - all these 1D draggers the opposite axis delta is irrelevant + .then(doDrag('xy2', 'n', 53, 100)) + .then(checkRanges({yaxis2: [0, 4]}, 'drag y2n')) + .then(doDrag('xy', 's', 53, -100)) + .then(checkRanges({yaxis: [-2, 2], yaxis2: [0, 4]}, 'drag ys')) + // expanding drag is highly nonlinear + .then(doDrag('x2y', 'e', 50, 53)) + .then(checkRanges({yaxis: [-2, 2], yaxis2: [0, 4], xaxis2: [0, 0.8751]}, 'drag x2e')) + .then(doDrag('x2y', 'w', -50, 53)) + .then(checkRanges({yaxis: [-2, 2], yaxis2: [0, 4], xaxis2: [0.4922, 0.8751]}, 'drag x2w')) + // reset all from the modebar + .then(function() { selectButton(gd._fullLayout._modeBar, 'resetScale2d').click(); }) + .then(checkRanges({}, 'final reset')) + .catch(failTest) + .then(done); + }); - it('updates linked axes when there are constraints', function(done) { - makePlot(true) - // zoombox - this *would* be 1D (dy=-1) but that's not allowed - .then(doDrag('xy', 'nsew', 100, -1)) - .then(checkRanges({xaxis: [1, 2], yaxis: [1, 2], xaxis2: [0.5, 1.5], yaxis2: [0.5, 1.5]}, 'zoombox xy')) - // first dblclick reverts to saved ranges - .then(doDblClick('xy', 'nsew')) - .then(checkRanges({}, 'dblclick xy')) - // next dblclick autoscales ALL linked plots - .then(doDblClick('xy', 'ns')) - .then(checkRanges({xaxis: autoRange, yaxis: autoRange, xaxis2: autoRange, yaxis2: autoRange}, 'dblclick y')) - // revert again - .then(doDblClick('xy', 'nsew')) - .then(checkRanges({}, 'dblclick xy #2')) - // corner drag - full distance in one direction and no shift in the other gets averaged - // into half distance in each - .then(doDrag('xy', 'ne', -200, 0)) - .then(checkRanges({xaxis: [0, 4], yaxis: [0, 4], xaxis2: [-1, 3], yaxis2: [-1, 3]}, 'zoom xy ne')) - // drag one end - .then(doDrag('xy', 's', 53, -100)) - .then(checkRanges({xaxis: [-2, 6], yaxis: [-4, 4], xaxis2: [-3, 5], yaxis2: [-3, 5]}, 'zoom y s')) - // middle of an axis - .then(doDrag('xy', 'ew', -100, 53)) - .then(checkRanges({xaxis: [2, 10], yaxis: [-4, 4], xaxis2: [-3, 5], yaxis2: [-3, 5]}, 'drag x ew')) - // revert again - .then(doDblClick('xy', 'nsew')) - .then(checkRanges({}, 'dblclick xy #3')) - // scroll wheel - .then(function() { - var mainDrag = getDragger('xy', 'nsew'); - var mainDragCoords = getNodeCoords(mainDrag, 'se'); - mouseEvent('scroll', mainDragCoords.x, mainDragCoords.y, {deltaY: 20, element: mainDrag}); - }) - .then(delay(constants.REDRAWDELAY + 10)) - .then(checkRanges({xaxis: [-0.2103, 2], yaxis: [0, 2.2103], xaxis2: [-0.1052, 2.1052], yaxis2: [-0.1052, 2.1052]}, - 'scroll xy')) - .then(function() { - var ewDrag = getDragger('xy', 'ew'); - var ewDragCoords = getNodeCoords(ewDrag); - mouseEvent('scroll', ewDragCoords.x - 50, ewDragCoords.y, {deltaY: -20, element: ewDrag}); - }) - .then(delay(constants.REDRAWDELAY + 10)) - .then(checkRanges({xaxis: [-0.1578, 1.8422], yaxis: [0.1052, 2.1052]}, 'scroll x')) - .catch(failTest) - .then(done); + it('updates with correlated subplots & no constraints - middles, corners, and scrollwheel', function(done) { + makePlot() + // drag axis middles + .then(doDrag('x3y3', 'ew', 100, 0)) + .then(checkRanges({xaxis3: [-1, 1]}, 'drag x3ew')) + .then(doDrag('x3y3', 'ns', 53, 100)) + .then(checkRanges({xaxis3: [-1, 1], yaxis3: [1, 3]}, 'drag y3ns')) + // drag corners + .then(doDrag('x3y3', 'ne', -100, 100)) + .then(checkRanges({xaxis3: [-1, 3], yaxis3: [1, 5]}, 'zoom x3y3ne')) + .then(doDrag('x3y3', 'sw', 100, -100)) + .then(checkRanges({xaxis3: [-5, 3], yaxis3: [-3, 5]}, 'zoom x3y3sw')) + .then(doDrag('x3y3', 'nw', -50, -50)) + .then(checkRanges({xaxis3: [-0.5006, 3], yaxis3: [-3, 0.5006]}, 'zoom x3y3nw')) + .then(doDrag('x3y3', 'se', 50, 50)) + .then(checkRanges({xaxis3: [-0.5006, 1.0312], yaxis3: [-1.0312, 0.5006]}, 'zoom x3y3se')) + .then(doDblClick('x3y3', 'nsew')) + .then(checkRanges({}, 'reset x3y3')) + // scroll wheel + .then(doScroll('xy', 'nsew', 20, {edge: 'se'})) + .then(checkRanges({xaxis: [-0.2103, 2], yaxis: [0, 2.2103]}, 'xy main scroll')) + .then(doScroll('xy', 'ew', -20, {dx: -50})) + .then(checkRanges({xaxis: [-0.1578, 1.8422], yaxis: [0, 2.2103]}, 'x scroll')) + .then(doScroll('xy', 'ns', -20, {dy: -50})) + .then(checkRanges({xaxis: [-0.1578, 1.8422], yaxis: [0.1578, 2.1578]}, 'y scroll')) + .catch(failTest) + .then(done); + }); + + it('updates linked axes when there are constraints', function(done) { + makePlot(true) + // zoombox - this *would* be 1D (dy=-1) but that's not allowed + .then(doDrag('xy', 'nsew', 100, -1)) + .then(checkRanges({xaxis: [1, 2], yaxis: [1, 2], xaxis2: [0.5, 1.5], yaxis2: [0.5, 1.5]}, 'zoombox xy')) + // first dblclick reverts to saved ranges + .then(doDblClick('xy', 'nsew')) + .then(checkRanges({}, 'dblclick xy')) + // next dblclick autoscales ALL linked plots + .then(doDblClick('xy', 'ns')) + .then(checkRanges({xaxis: autoRange, yaxis: autoRange, xaxis2: autoRange, yaxis2: autoRange}, 'dblclick y')) + // revert again + .then(doDblClick('xy', 'nsew')) + .then(checkRanges({}, 'dblclick xy #2')) + // corner drag - full distance in one direction and no shift in the other gets averaged + // into half distance in each + .then(doDrag('xy', 'ne', -200, 0)) + .then(checkRanges({xaxis: [0, 4], yaxis: [0, 4], xaxis2: [-1, 3], yaxis2: [-1, 3]}, 'zoom xy ne')) + // drag one end + .then(doDrag('xy', 's', 53, -100)) + .then(checkRanges({xaxis: [-2, 6], yaxis: [-4, 4], xaxis2: [-3, 5], yaxis2: [-3, 5]}, 'zoom y s')) + // middle of an axis + .then(doDrag('xy', 'ew', -100, 53)) + .then(checkRanges({xaxis: [2, 10], yaxis: [-4, 4], xaxis2: [-3, 5], yaxis2: [-3, 5]}, 'drag x ew')) + // revert again + .then(doDblClick('xy', 'nsew')) + .then(checkRanges({}, 'dblclick xy #3')) + // scroll wheel + .then(doScroll('xy', 'nsew', 20, {edge: 'se'})) + .then(checkRanges({xaxis: [-0.2103, 2], yaxis: [0, 2.2103], xaxis2: [-0.1052, 2.1052], yaxis2: [-0.1052, 2.1052]}, 'scroll xy')) + .then(doScroll('xy', 'ew', -20, {dx: -50})) + .then(checkRanges({xaxis: [-0.1578, 1.8422], yaxis: [0.1052, 2.1052]}, 'scroll x')) + .catch(failTest) + .then(done); + }); }); it('updates linked axes when there are constraints (axes_scaleanchor mock)', function(done) { @@ -589,67 +584,46 @@ describe('axis zoom/pan and main plot zoom', function() { }); it('should draw correct zoomboxes corners', function(done) { - var dragCoverNode; - var p1; - - function _dragStart(p0, dp) { - var node = getDragger('xy', 'nsew'); - 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) { - var zl = d3.select(gd).select('g.zoomlayer'); - var d = zl.select('.zoombox-corners').attr('d'); - - if(exp.cornerCnt) { - var actual = (d.match(/Z/g) || []).length; - expect(actual).toBe(exp.cornerCnt, 'zoombox corner cnt: ' + msg); - } else { - expect(d).toBe('M0,0Z', 'no zoombox corners: ' + msg); - } - - mouseEvent('mouseup', p1[0], p1[1], {element: dragCoverNode}); - return drag.waitForDragCoverRemoval(); + function _run(msg, dp, exp) { + var drag = makeDragFns('xy', 'nsew', dp[0], dp[1], 170, 170); + + return drag.start().then(function() { + var zl = d3.select(gd).select('g.zoomlayer'); + var d = zl.select('.zoombox-corners').attr('d'); + + if(exp.cornerCnt) { + var actual = (d.match(/Z/g) || []).length; + expect(actual).toBe(exp.cornerCnt, 'zoombox corner cnt: ' + msg); + } else { + expect(d).toBe('M0,0Z', 'no zoombox corners: ' + msg); + } + }) + .then(drag.end); } Plotly.plot(gd, [{ y: [1, 2, 1] }]) - .then(function() { return _dragStart([170, 170], [30, 30]); }) .then(function() { - return _assertAndDragEnd('full-x full-y', {cornerCnt: 4}); + return _run('full-x full-y', [30, 30], {cornerCnt: 4}); }) - .then(function() { return _dragStart([170, 170], [5, 30]); }) .then(function() { - return _assertAndDragEnd('full-y', {cornerCnt: 2}); + return _run('full-y', [5, 30], {cornerCnt: 2}); }) - .then(function() { return _dragStart([170, 170], [30, 2]); }) .then(function() { - return _assertAndDragEnd('full-x', {cornerCnt: 2}); + return _run('full-x', [30, 2], {cornerCnt: 2}); }) .then(function() { return Plotly.relayout(gd, 'xaxis.fixedrange', true); }) - .then(function() { return _dragStart([170, 170], [30, 30]); }) .then(function() { - return _assertAndDragEnd('full-x full-y w/ fixed xaxis', {cornerCnt: 2}); + return _run('full-x full-y w/ fixed xaxis', [30, 30], {cornerCnt: 2}); }) - .then(function() { return _dragStart([170, 170], [30, 5]); }) .then(function() { - return _assertAndDragEnd('full-x w/ fixed xaxis', {cornerCnt: 0}); + return _run('full-x w/ fixed xaxis', [30, 5], {cornerCnt: 0}); }) .then(function() { return Plotly.relayout(gd, {'xaxis.fixedrange': false, 'yaxis.fixedrange': true}); }) - .then(function() { return _dragStart([170, 170], [30, 30]); }) .then(function() { - return _assertAndDragEnd('full-x full-y w/ fixed yaxis', {cornerCnt: 2}); + return _run('full-x full-y w/ fixed yaxis', [30, 30], {cornerCnt: 2}); }) - .then(function() { return _dragStart([170, 170], [5, 30]); }) .then(function() { - return _assertAndDragEnd('full-y w/ fixed yaxis', {cornerCnt: 0}); + return _run('full-y w/ fixed yaxis', [5, 30], {cornerCnt: 0}); }) .catch(failTest) .then(done); @@ -677,28 +651,6 @@ describe('axis zoom/pan and main plot zoom', function() { 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'); @@ -720,6 +672,13 @@ describe('axis zoom/pan and main plot zoom', function() { }); } + function _run(msg, dp, exp) { + var drag = makeDragFns('xy', 'e', dp[0], dp[1], 585, 390); + return drag.start() + .then(function() { _assertLabels(msg, exp); }) + .then(drag.end); + } + Plotly.plot(gd, fig) .then(function() { _assertLabels('base', { @@ -727,16 +686,14 @@ describe('axis zoom/pan and main plot zoom', function() { y: [406, 406] }); }) - .then(function() { return _dragStart('edrag', [585, 390], [-340, 0]); }) .then(function() { - return _assertAndDragEnd('drag to wide-range -> rotates labels', { + return _run('drag to wide-range -> rotates labels', [-340, 0], { 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', { + return _run('drag to narrow-range -> un-rotates labels', [100, 0], { angle: [0, 0, 0, 0, 0, 0, 0], y: [406, 406] }); @@ -744,6 +701,703 @@ describe('axis zoom/pan and main plot zoom', function() { .catch(failTest) .then(done); }); + + describe('updates matching axes', function() { + var TOL = 1.5; + var eventData; + + function assertRanges(msg, exp) { + exp.forEach(function(expi) { + var axNames = expi[0]; + var rng = expi[1]; + var opts = expi[2] || {}; + + axNames.forEach(function(n) { + var msgi = n + ' - ' + msg; + if(!opts.skipInput) expect(gd.layout[n].range).toBeCloseToArray(rng, TOL, msgi + ' |input'); + expect(gd._fullLayout[n].range).toBeCloseToArray(rng, TOL, msgi + ' |full'); + }); + }); + } + + function assertEventData(msg, exp) { + if(eventData === null) { + return fail('plotly_relayout did not get triggered - ' + msg); + } + + exp.forEach(function(expi) { + var axNames = expi[0]; + var rng = expi[1]; + var opts = expi[2] || {}; + + axNames.forEach(function(n) { + var msgi = n + ' - ' + msg; + if(opts.autorange) { + expect(eventData[n + '.autorange']).toBe(true, 2, msgi + '|event data'); + } else if(!opts.noChange) { + expect(eventData[n + '.range[0]']).toBeCloseTo(rng[0], TOL, msgi + '|event data [0]'); + expect(eventData[n + '.range[1]']).toBeCloseTo(rng[1], TOL, msgi + '|event data [1]'); + } + }); + }); + + eventData = null; + } + + function assertAxesDrawCalls(msg, exp) { + var cnt = 0; + exp.forEach(function(expi) { + var axNames = expi[0]; + var opts = expi[2] || {}; + axNames.forEach(function() { + if(!opts.noChange) { + cnt++; + // called twice as many times on drag: + // - once per axis during mousemouve + // - once per raxis on mouseup + if(opts.dragged) cnt++; + } + }); + }); + + expect(Axes.drawOne).toHaveBeenCalledTimes(cnt); + Axes.drawOne.calls.reset(); + } + + function assertSubplotTranslateAndScale(msg, spIds, trans, scale) { + var gClips = d3.select(gd).select('g.clips'); + var uid = gd._fullLayout._uid; + var transActual = []; + var scaleActual = []; + var trans0 = []; + var scale1 = []; + + spIds.forEach(function(id) { + var rect = gClips.select('#clip' + uid + id + 'plot > rect'); + var t = Drawing.getTranslate(rect); + var s = Drawing.getScale(rect); + transActual.push(t.x, t.y); + scaleActual.push(s.x, s.y); + trans0.push(0, 0); + scale1.push(1, 1); + }); + + var transExp = trans ? trans : trans0; + var scaleExp = scale ? scale : scale1; + var msg1 = msg + ' [' + spIds.map(function(id) { return '..' + id; }).join(', ') + ']'; + expect(transActual).toBeWithinArray(transExp, 3, msg1 + ' clip translate'); + expect(scaleActual).toBeWithinArray(scaleExp, 3, msg1 + ' clip scale'); + } + + function _assert(msg, exp) { + return function() { + assertRanges(msg, exp); + assertEventData(msg, exp); + assertAxesDrawCalls(msg, exp); + }; + } + + function makePlot(data, layout, s) { + s = s || {}; + + var fig = {}; + fig.data = Lib.extendDeep([], data); + fig.layout = Lib.extendDeep({}, layout, {dragmode: s.dragmode}); + fig.config = {scrollZoom: true}; + + spyOn(Axes, 'drawOne').and.callThrough(); + eventData = null; + + return Plotly.plot(gd, fig).then(function() { + Axes.drawOne.calls.reset(); + gd.on('plotly_relayout', function(d) { eventData = d; }); + }); + } + + describe('no-constrained x-axes matching x-axes subplot case', function() { + var data = [ + { y: [1, 2, 1] }, + { y: [2, 1, 2, 3], xaxis: 'x2' }, + { y: [0, 1], xaxis: 'x3' } + ]; + + // N.B. ax._length are not equal here + var layout = { + xaxis: {domain: [0, 0.2]}, + xaxis2: {matches: 'x', domain: [0.3, 0.6]}, + xaxis3: {matches: 'x', domain: [0.65, 1]}, + yaxis: {}, + width: 800, + height: 500, + dragmode: 'zoom' + }; + + var xr0 = [-0.245, 3.245]; + var yr0 = [-0.211, 3.211]; + + var specs = [{ + desc: 'zoombox on xy', + drag: ['xy', 'nsew', 30, 30], + exp: [ + [['xaxis', 'xaxis2', 'xaxis3'], [1.494, 2.350]], + [['yaxis'], [1.179, 1.50]] + ], + dblclickSubplot: 'xy' + }, { + desc: 'x-only zoombox on xy', + drag: ['xy', 'nsew', 30, 0], + exp: [ + [['xaxis', 'xaxis2', 'xaxis3'], [1.494, 2.350]], + [['yaxis'], yr0, {noChange: true}] + ], + dblclickSubplot: 'x2y' + }, { + desc: 'y-only zoombox on xy', + drag: ['xy', 'nsew', 0, 30], + exp: [ + [['xaxis', 'xaxis2', 'xaxis3'], xr0, {noChange: true}], + [['yaxis'], [1.179, 1.50]] + ], + dblclickSubplot: 'x3y' + }, { + desc: 'zoombox on x2y', + drag: ['x2y', 'nsew', 30, 30], + exp: [ + // N.B. slightly different range result + // due difference in ax._length + [['xaxis', 'xaxis2', 'xaxis3'], [1.492, 2.062]], + [['yaxis'], [1.179, 1.50]] + ], + dblclickSubplot: 'x3y' + }, { + desc: 'zoombox on x3y', + drag: ['x3y', 'nsew', 30, 30], + exp: [ + // Similarly here slightly different range result + // due difference in ax._length + [['xaxis', 'xaxis2', 'xaxis3'], [1.485, 1.974]], + [['yaxis'], [1.179, 1.50]] + ], + dblclickSubplot: 'xy' + }, { + desc: 'drag ew on x2y', + drag: ['x2y', 'ew', 30, 0], + exp: [ + [['xaxis', 'xaxis2', 'xaxis3'], [-0.816, 2.675], {dragged: true}], + [['yaxis'], yr0, {noChange: true}] + ], + dblclickSubplot: 'x3y' + }, { + desc: 'drag ew on x3y', + drag: ['x3y', 'ew', 30, 0], + exp: [ + [['xaxis', 'xaxis2', 'xaxis3'], [-0.734, 2.756], {dragged: true}], + [['yaxis'], yr0, {noChange: true}] + ], + dblclickSubplot: 'xy' + }, { + desc: 'drag e on xy', + drag: ['xy', 'e', 30, 30], + exp: [ + [['xaxis', 'xaxis2', 'xaxis3'], [xr0[0], 1.366], {dragged: true}], + [['yaxis'], yr0, {noChange: true}] + ], + dblclickSubplot: 'x3y' + }, { + desc: 'drag nw on x3y', + drag: ['xy', 'nw', 30, 30], + exp: [ + [['xaxis', 'xaxis2', 'xaxis3'], [-1.379, 3.245], {dragged: true}], + [['yaxis'], [-0.211, 3.565], {dragged: true}] + ], + dblclickSubplot: 'x3y' + }, { + desc: 'panning on xy subplot', + dragmode: 'pan', + drag: ['xy', 'nsew', 30, 30], + exp: [ + [['xaxis', 'xaxis2', 'xaxis3'], [-1.101, 2.390], {dragged: true}], + [['yaxis'], [0.109, 3.532], {dragged: true}] + ], + dblclickSubplot: 'x3y' + }, { + desc: 'panning on x2y subplot', + dragmode: 'pan', + drag: ['x2y', 'nsew', 30, 30], + exp: [ + [['xaxis', 'xaxis2', 'xaxis3'], [-0.816, 2.675], {dragged: true}], + [['yaxis'], [0.109, 3.532], {dragged: true}] + ], + dblclickSubplot: 'x2y' + }, { + desc: 'panning on x3y subplot', + dragmode: 'pan', + drag: ['x3y', 'nsew', 30, 30], + exp: [ + [['xaxis', 'xaxis2', 'xaxis3'], [-0.734, 2.756], {dragged: true}], + [['yaxis'], [0.109, 3.532], {dragged: true}] + ], + dblclickSubplot: 'xy' + }, { + desc: 'scrolling on x3y subplot', + scroll: ['x3y', 20], + exp: [ + [['xaxis', 'xaxis2', 'xaxis3'], [-0.613, 3.245], {dragged: true}], + [['yaxis'], [-0.211, 3.571], {dragged: true}] + ], + dblclickSubplot: 'xy' + }, { + desc: 'scrolling on x2y subplot', + scroll: ['x2y', 20], + exp: [ + [['xaxis', 'xaxis2', 'xaxis3'], [-0.613, 3.245], {dragged: true}], + [['yaxis'], [-0.211, 3.571], {dragged: true}] + ], + dblclickSubplot: 'xy' + }, { + desc: 'scrolling on xy subplot', + scroll: ['xy', 20], + exp: [ + [['xaxis', 'xaxis2', 'xaxis3'], [-0.613, 3.245], {dragged: true}], + [['yaxis'], [-0.211, 3.571], {dragged: true}] + ], + dblclickSubplot: 'x2y' + }]; + + specs.forEach(function(s) { + var msg = 'after ' + s.desc; + var msg2 = ['after dblclick on subplot', s.dblclickSubplot, msg].join(' '); + + it(s.desc, function(done) { + makePlot(data, layout, s).then(function() { + assertRanges('base', [ + [['xaxis', 'xaxis2', 'xaxis3'], xr0], + [['yaxis'], yr0] + ]); + }) + .then(function() { + if(s.scroll) { + return doScroll(s.scroll[0], 'nsew', s.scroll[1], {edge: 'se'})(); + } else { + return doDrag(s.drag[0], s.drag[1], s.drag[2], s.drag[3])(); + } + }) + .then(_assert(msg, s.exp)) + .then(doDblClick(s.dblclickSubplot, 'nsew')) + .then(_assert(msg2, [ + [['xaxis', 'xaxis2', 'xaxis3'], xr0, {autorange: true}], + [['yaxis'], yr0, {autorange: true}] + ])) + .catch(failTest) + .then(done); + }); + }); + }); + + describe('y-axes matching y-axes case', function() { + var data = [ + { y: [1, 2, 1] }, + { y: [2, 1, 2, 3], yaxis: 'y2' }, + { y: [0, 1], yaxis: 'y3' } + ]; + + // N.B. ax._length are not equal here + var layout = { + yaxis: {domain: [0, 0.2]}, + yaxis2: {matches: 'y', domain: [0.3, 0.6]}, + yaxis3: {matches: 'y2', domain: [0.65, 1]}, + width: 500, + height: 800, + dragmode: 'pan' + }; + + var xr0 = [-0.211, 3.211]; + var yr0 = [-0.077, 3.163]; + + var specs = [{ + desc: 'pan on xy', + drag: ['xy', 'nsew', 30, 30], + exp: [ + [['xaxis'], [-0.534, 2.888], {dragged: true}], + [['yaxis', 'yaxis2', 'yaxis3'], [0.706, 3.947], {dragged: true}], + ], + trans: [-30, -30, -30, -45, -30, -52.5] + }, { + desc: 'pan on xy2', + drag: ['xy2', 'nsew', 30, 30], + exp: [ + [['xaxis'], [-0.534, 2.888], {dragged: true}], + [['yaxis', 'yaxis2', 'yaxis3'], [0.444, 3.685], {dragged: true}], + ], + trans: [-30, -20, -30, -30, -30, -35] + }, { + desc: 'pan on xy3', + drag: ['xy3', 'nsew', 30, 30], + exp: [ + [['xaxis'], [-0.534, 2.888], {dragged: true}], + [['yaxis', 'yaxis2', 'yaxis3'], [0.370, 3.611], {dragged: true}], + ], + trans: [-30, -17.142, -30, -25.71, -30, -30] + }, { + desc: 'drag ns dragger on xy2', + drag: ['xy2', 'ns', 0, 30], + exp: [ + [['xaxis'], xr0, {noChange: true}], + [['yaxis', 'yaxis2', 'yaxis3'], [0.444, 3.685], {dragged: true}], + ], + trans: [0, -20, 0, -30, 0, -35] + }, { + desc: 'drag n dragger on xy3', + drag: ['xy3', 'n', 0, 30], + exp: [ + [['xaxis'], xr0, {noChange: true}], + [['yaxis', 'yaxis2', 'yaxis3'], [yr0[0], 3.683], {dragged: true}], + ], + trans: [0, -19.893, 0, -29.839, 0, -34.812], + scale: [1, 1.160, 1, 1.160, 1, 1.160] + }, { + desc: 'drag s dragger on xy', + drag: ['xy', 's', 0, 30], + exp: [ + [['xaxis'], xr0, {noChange: true}], + [['yaxis', 'yaxis2', 'yaxis3'], [1.617, yr0[1]], {dragged: true}], + ], + trans: [0, 0, 0, 0, 0, 0], + scale: [1, 0.476, 1, 0.476, 1, 0.476] + }]; + + specs.forEach(function(s) { + var msg = 'after ' + s.desc; + + it(s.desc, function(done) { + makePlot(data, layout, s).then(function() { + assertRanges('base', [ + [['xaxis'], xr0], + [['yaxis', 'yaxis2', 'yaxis3'], yr0] + ]); + }) + .then(function() { + var drag = makeDragFns(s.drag[0], s.drag[1], s.drag[2], s.drag[3]); + return drag.start().then(function() { + assertSubplotTranslateAndScale(msg, ['xy', 'xy2', 'xy3'], s.trans, s.scale); + }) + .then(drag.end); + }) + .then(_assert(msg, s.exp)) + .catch(failTest) + .then(done); + }); + }); + }); + + describe('x <--> y axes matching case', function() { + /* + * y | y2 | + * | | + * m | | + * a | | + * t | | + * c | | + * h | | + * e | | + * s | | + * | | + * x2 ________________________ _________________________ + * + * x x2 matches y + */ + + var data = [ + { y: [1, 2, 1] }, + { y: [2, 3, -1, 5], xaxis: 'x2', yaxis: 'y2' }, + ]; + + var layout = { + xaxis: {domain: [0, 0.4]}, + xaxis2: {anchor: 'y2', domain: [0.6, 1], matches: 'y'}, + yaxis: {matches: 'x2'}, + yaxis2: {anchor: 'x2'}, + width: 700, + height: 500, + dragmode: 'pan' + }; + + var rm0 = [-0.237, 3.237]; + var rx0 = [-0.158, 2.158]; + var ry20 = [-1.422, 5.422]; + + var specs = [{ + desc: 'pan on xy subplot', + drag: ['xy', 'nsew', 30, 30], + exp: [ + [['yaxis', 'xaxis2'], [0.0886, 3.562], {dragged: true}], + [['xaxis'], [-0.496, 1.820], {dragged: true}], + [['yaxis2'], ry20, {noChange: true}], + ], + trans: [-30, -30, 19.275, 0] + }, { + desc: 'pan on x2y2 subplot', + drag: ['x2y2', 'nsew', 30, 30], + exp: [ + [['yaxis', 'xaxis2'], [-0.744, 2.730], {dragged: true}], + [['xaxis'], rx0, {noChange: true}], + [['yaxis2'], [-0.787, 6.064], {dragged: true}], + ], + trans: [0, 46.69, -30, -30] + }, { + desc: 'drag xy ns dragger', + drag: ['xy', 'ns', 0, 30], + exp: [ + [['yaxis', 'xaxis2'], [0.0886, 3.562], {dragged: true}], + [['xaxis'], rx0, {noChange: true}], + [['yaxis2'], ry20, {noChange: true}], + ], + trans: [0, -30, 19.275, 0] + }, { + desc: 'drag x2y2 ew dragger', + drag: ['x2y2', 'ew', 30, 0], + exp: [ + [['yaxis', 'xaxis2'], [-0.744, 2.730], {dragged: true}], + [['xaxis'], rx0, {noChange: true}], + [['yaxis2'], ry20, {noChange: true}], + ], + trans: [0, 46.692, -30, 0] + }, { + desc: 'drag xy n corner', + drag: ['xy', 'n', 0, 30], + exp: [ + [['yaxis', 'xaxis2'], [rm0[0], 3.596], {dragged: true}], + [['xaxis'], rx0, {noChange: true}], + [['yaxis2'], ry20, {noChange: true}], + ], + trans: [0, -33.103, 0, 0], + scale: [1, 1.103, 1.103, 1] + }, { + desc: 'drag xy s corner', + drag: ['xy', 's', 0, 30], + exp: [ + [['yaxis', 'xaxis2'], [0.174, rm0[1]], {dragged: true}], + [['xaxis'], rx0, {noChange: true}], + [['yaxis2'], ry20, {noChange: true}], + ], + trans: [0, 0, 24.367, 0], + scale: [1, 0.881, 0.881, 1] + }, { + desc: 'drag x2y2 e corner', + drag: ['x2y2', 'e', 30, 0], + exp: [ + [['yaxis', 'xaxis2'], [rm0[0], 2.486], {dragged: true}], + [['xaxis'], rx0, {noChange: true}], + [['yaxis2'], ry20, {noChange: true}], + ], + trans: [0, 69.094, 0, 0], + scale: [1, 0.784, 0.784, 1] + }, { + desc: 'drag x2y2 w corner', + drag: ['x2y2', 'w', 30, 0], + exp: [ + [['yaxis', 'xaxis2'], [-0.830, rm0[1]], {dragged: true}], + [['xaxis'], rx0, {noChange: true}], + [['yaxis2'], ry20, {noChange: true}], + ], + trans: [0, 0, -35.125, 0], + scale: [1, 1.170, 1.170, 1] + }]; + + specs.forEach(function(s) { + var msg = 'after ' + s.desc; + + it(s.desc, function(done) { + makePlot(data, layout, s).then(function() { + assertRanges('base', [ + [['yaxis', 'xaxis2'], rm0], + [['xaxis'], rx0], + [['yaxis2'], ry20], + ]); + }) + .then(function() { + var drag = makeDragFns(s.drag[0], s.drag[1], s.drag[2], s.drag[3]); + return drag.start().then(function() { + assertSubplotTranslateAndScale(msg, ['xy', 'x2y2'], s.trans, s.scale); + }) + .then(drag.end); + }) + .then(_assert(msg, s.exp)) + .catch(failTest) + .then(done); + }); + }); + }); + + describe('constrained subplot case', function() { + var data = [{ + type: 'splom', + // N.B. subplots xy and x2y2 are "constrained", + dimensions: [{ + values: [1, 2, 5, 3, 4], + label: 'A', + axis: {matches: true} + }, { + values: [2, 1, 0, 3, 4], + label: 'B', + axis: {matches: true} + }] + }]; + + var layout = {width: 500, height: 500}; + + var rng0 = {xy: [0.648, 5.351], x2y2: [-0.351, 4.351]}; + + var specs = [{ + desc: 'zoombox on constrained xy subplot', + drag: ['xy', 'nsew', 30, 30], + exp: [ + [['xaxis', 'yaxis'], [2.093, 3.860]], + [['xaxis2', 'yaxis2'], rng0.x2y2, {noChange: true}] + ], + zoombox: [60.509, 56.950], + dblclickSubplot: 'xy' + }, { + desc: 'zoombox on constrained x2y2 subplot', + drag: ['x2y2', 'nsew', 30, 30], + exp: [ + [['xaxis', 'yaxis'], rng0.xy, {noChange: true}], + [['xaxis2', 'yaxis2'], [1.075, 2.862]] + ], + zoombox: [61.177, 57.578], + dblclickSubplot: 'xy' + }, { + desc: 'drag ew on x2y2', + drag: ['x2y2', 'ew', 30, 0], + exp: [ + [['xaxis', 'yaxis'], rng0.xy, {noChange: true}], + [['xaxis2', 'yaxis2'], [-1.227, 3.475], {dragged: true}] + ], + dblclickSubplot: 'x2y2' + }, { + desc: 'scrolling on xy subplot', + scroll: ['xy', 20], + exp: [ + [['xaxis', 'yaxis'], [0.151, 5.896], {dragged: true}], + [['xaxis2', 'yaxis2'], rng0.x2y2, {noChange: true}] + ], + dblclickSubplot: 'x2y2' + }]; + + specs.forEach(function(s) { + var msg = 'after ' + s.desc; + var msg2 = ['after dblclick on subplot', s.dblclickSubplot, msg].join(' '); + var spmatch = s.dblclickSubplot.match(constants.SUBPLOT_PATTERN); + + it(s.desc, function(done) { + makePlot(data, layout, s).then(function() { + assertRanges('base', [ + [['xaxis', 'yaxis'], rng0.xy], + [['xaxis2', 'yaxis2'], rng0.x2y2] + ]); + }) + .then(function() { + if(s.scroll) { + return doScroll(s.scroll[0], 'nsew', s.scroll[1], {edge: 'se'})(); + } else { + var drag = makeDragFns(s.drag[0], s.drag[1], s.drag[2], s.drag[3]); + return drag.start().then(function() { + if(s.drag[1] === 'nsew') { + var zb = d3.select(gd).select('g.zoomlayer > path.zoombox'); + var d = zb.attr('d'); + var v = Number(d.split('v')[1].split('h')[0]); + var h = Number(d.split('h')[1].split('v')[0]); + expect(h).toBeCloseTo(s.zoombox[0], 1, 'zoombox horizontal span -' + msg); + expect(v).toBeCloseTo(s.zoombox[1], 1, 'zoombox vertical span -' + msg); + } + }) + .then(drag.end); + } + }) + .then(_assert(msg, s.exp)) + .then(doDblClick(s.dblclickSubplot, 'nsew')) + .then(_assert(msg2, [[ + ['xaxis' + spmatch[1], 'yaxis' + spmatch[2]], + rng0[s.dblclickSubplot], + {autorange: true} + ]])) + .catch(failTest) + .then(done); + }); + }); + }); + + it('panning a matching overlaying axis', function(done) { + /* + * y | | y2 y3 | + * | | | + * | | o m | + * | | v a | + * | | e t | + * | | r c | + * | | l h | + * | | a e | + * | | y s | + * | | | + * ______________________ y y2 ____________________ + * + * x x2 + */ + var data = [ + { y: [1, 2, 1] }, + { y: [2, 1, 3, 4], yaxis: 'y2' }, + { y: [2, 3, -1, 5], xaxis: 'x2', yaxis: 'y3' } + ]; + + var layout = { + xaxis: {domain: [0, 0.4]}, + xaxis2: {anchor: 'y2', domain: [0.5, 1]}, + yaxis2: {anchor: 'x', overlaying: 'y', side: 'right'}, + yaxis3: {anchor: 'x2', matches: 'y2'}, + width: 700, + height: 500, + dragmode: 'pan' + }; + + makePlot(data, layout).then(function() { + assertRanges('base', [ + [['yaxis2', 'yaxis3'], [-1.422, 5.422]], + [['xaxis'], [-0.237, 3.237]], + [['yaxis'], [0.929, 2.070]], + [['xaxis2'], [-0.225, 3.222]] + ]); + }) + .then(function() { + var drag = makeDragFns('xy', 'nsew', 30, 30); + return drag.start().then(function() { + // N.B. it is with these values that Axes.drawOne gets called! + assertRanges('during drag', [ + [['yaxis2', 'yaxis3'], [-0.788, 6.0641], {skipInput: true}], + [['xaxis'], [-0.744, 2.730], {skipInput: true}], + [['yaxis'], [1.036, 2.177], {skipInput: true}], + [['xaxis2'], [-0.225, 3.222]] + ]); + }) + .then(drag.end); + }) + .then(_assert('after drag on xy subplot', [ + [['yaxis2', 'yaxis3'], [-0.788, 6.0641], {dragged: true}], + [['xaxis'], [-0.744, 2.730], {dragged: true}], + [['yaxis'], [1.036, 2.177], {dragged: true}], + [['xaxis2'], [-0.225, 3.222], {noChange: true}] + ])) + .then(function() { return Plotly.relayout(gd, 'dragmode', 'zoom'); }) + .then(doDrag('xy', 'nsew', 30, 30)) + .then(_assert('after zoombox on xy subplot', [ + [['yaxis2', 'yaxis3'], [2, 2.6417]], + [['xaxis'], [0.979, 1.486]], + [['yaxis'], [1.5, 1.609]], + [['xaxis2'], [-0.225, 3.222], {noChange: true}] + ])) + .catch(failTest) + .then(done); + }); + }); }); describe('Event data:', function() { diff --git a/test/jasmine/tests/polar_test.js b/test/jasmine/tests/polar_test.js index 2d48c4afd1f..5e78ae92c12 100644 --- a/test/jasmine/tests/polar_test.js +++ b/test/jasmine/tests/polar_test.js @@ -1300,8 +1300,6 @@ describe('Test polar *gridshape linear* interactions', function() { it('should rotate all non-symmetrical layers on angular drag', function(done) { var evtCnt = 0; var evtData = {}; - var dragCoverNode; - var p1; var layersRotateFromZero = ['.plotbg > path', '.radial-grid']; var layersRotateFromRadialAxis = ['.radial-axis', '.radial-line > line']; @@ -1317,29 +1315,19 @@ describe('Test polar *gridshape linear* interactions', function() { } } - function _dragStart(p0, dp) { + function _run(msg, p0, dp, exp) { var node = d3.select('.polar > .draglayer > .angulardrag').node(); - mouseEvent('mousemove', p0[0], p0[1], {element: node}); - mouseEvent('mousedown', p0[0], p0[1], {element: node}); + var dragFns = drag.makeFns(node, dp[0], dp[1], {x0: p0[0], y0: p0[1]}); - 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) { - layersRotateFromZero.forEach(function(q) { - _assertTransformRotate(msg, q, exp.fromZero); - }); - layersRotateFromRadialAxis.forEach(function(q) { - _assertTransformRotate(msg, q, exp.fromRadialAxis); - }); - - mouseEvent('mouseup', p1[0], p1[1], {element: dragCoverNode}); - return drag.waitForDragCoverRemoval(); + return dragFns.start().then(function() { + layersRotateFromZero.forEach(function(q) { + _assertTransformRotate(msg, q, exp.fromZero); + }); + layersRotateFromRadialAxis.forEach(function(q) { + _assertTransformRotate(msg, q, exp.fromRadialAxis); + }); + }) + .then(dragFns.end); } Plotly.plot(gd, [{ @@ -1370,9 +1358,8 @@ describe('Test polar *gridshape linear* interactions', function() { _assertTransformRotate('base', q, -90); }); }) - .then(function() { return _dragStart([150, 20], [30, 30]); }) .then(function() { - return _assertAndDragEnd('rotate clockwise', { + return _run('rotate clockwise', [150, 20], [30, 30], { fromZero: 7.2, fromRadialAxis: -82.8 }); @@ -1390,9 +1377,6 @@ describe('Test polar *gridshape linear* interactions', function() { }); it('should place zoombox handles at correct place on main drag', function(done) { - var dragCoverNode; - var p1; - // d attr to array of segment [x,y] function path2coords(path) { if(!path.size()) return [[]]; @@ -1407,29 +1391,19 @@ describe('Test polar *gridshape linear* interactions', function() { .reduce(function(a, b) { return a.concat(b); }); } - function _dragStart(p0, dp) { + function _run(msg, p0, dp, exp) { var node = d3.select('.polar > .draglayer > .maindrag').node(); - 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; - } + var dragFns = drag.makeFns(node, dp[0], dp[1], {x0: p0[0], y0: p0[1]}); - function _assertAndDragEnd(msg, exp) { - var zl = d3.select(gd).select('g.zoomlayer'); + return dragFns.start().then(function() { + var zl = d3.select(gd).select('g.zoomlayer'); - expect(path2coords(zl.select('.zoombox'))) - .toBeCloseTo2DArray(exp.zoombox, 2, msg + ' - zoombox'); - expect(path2coords(zl.select('.zoombox-corners'))) - .toBeCloseTo2DArray(exp.corners, 2, msg + ' - corners'); - - mouseEvent('mouseup', p1[0], p1[1], {element: dragCoverNode}); - return drag.waitForDragCoverRemoval(); + expect(path2coords(zl.select('.zoombox'))) + .toBeCloseTo2DArray(exp.zoombox, 2, msg + ' - zoombox'); + expect(path2coords(zl.select('.zoombox-corners'))) + .toBeCloseTo2DArray(exp.corners, 2, msg + ' - corners'); + }) + .then(dragFns.end); } Plotly.plot(gd, [{ @@ -1445,9 +1419,8 @@ describe('Test polar *gridshape linear* interactions', function() { height: 400, margin: {l: 50, t: 50, b: 50, r: 50} }) - .then(function() { return _dragStart([170, 170], [220, 220]); }) .then(function() { - _assertAndDragEnd('drag outward toward bottom right', { + return _run('drag outward toward bottom right', [170, 170], [220, 220], { zoombox: [ [-142.658, -46.353], [-88.167, 121.352], [88.167, 121.352], [142.658, -46.352], @@ -1470,9 +1443,8 @@ describe('Test polar *gridshape linear* interactions', function() { .then(function() { return Plotly.relayout(gd, 'polar.sector', [-90, 90]); }) - .then(function() { return _dragStart([200, 200], [200, 230]); }) .then(function() { - _assertAndDragEnd('half-sector, drag outward', { + return _run('half-sector, drag outward', [200, 200], [200, 230], { zoombox: [ [0, 121.352], [88.167, 121.352], [142.658, -46.352], [0, -150], diff --git a/test/jasmine/tests/splom_test.js b/test/jasmine/tests/splom_test.js index 3e09444efd9..f0659631cbb 100644 --- a/test/jasmine/tests/splom_test.js +++ b/test/jasmine/tests/splom_test.js @@ -232,6 +232,7 @@ describe('Test splom trace defaults:', function() { expect(subplots.yaxis).toEqual(['y6', 'y7']); expect(subplots.cartesian).toEqual(['x6y6', 'x7y6', 'x7y7']); }); + it('should use special `grid.xside` and `grid.yside` defaults on splom w/o lower half generated grids', function() { var gridOut; @@ -525,6 +526,96 @@ describe('Test splom trace defaults:', function() { expect(fullLayout.xaxis2.type).toBe('category'); expect(fullLayout.yaxis2.type).toBe('category'); }); + + it('axis *matches* setting should propagate to layout axis containers', function() { + _supply({ + dimensions: [ + {values: [1, 2, 1], axis: {matches: true}}, + {values: [-1, 2, 3], axis: {matches: true}} + ] + }, {}); + + var fullLayout = gd._fullLayout; + expect(fullLayout.xaxis.matches).toBe('y'); + expect(fullLayout.yaxis.matches).toBe('x'); + expect(fullLayout.xaxis2.matches).toBe('y2'); + expect(fullLayout.yaxis2.matches).toBe('x2'); + + var groups = fullLayout._axisMatchGroups; + expect(groups.length).toBe(2); + expect(groups).toContain({x: 1, y: 1}); + expect(groups).toContain({x2: 1, y2: 1}); + }); + + it('axis *matches* setting should propagate to layout axis containers (lower + no-diag case)', function() { + _supply({ + diagonal: {visible: false}, + showlowerhalf: false, + dimensions: [ + {values: [1, 2, 1], axis: {matches: true}}, + {values: [-1, 2, 3], axis: {matches: true}}, + {values: [-10, 9, 3], axis: {matches: true}} + ] + }, {}); + + var fullLayout = gd._fullLayout; + expect(fullLayout.xaxis).toBe(undefined); + expect(fullLayout.yaxis.matches).toBe(undefined); + expect(fullLayout.xaxis2.matches).toBe('y2'); + expect(fullLayout.yaxis2.matches).toBe('x2'); + expect(fullLayout.xaxis3.matches).toBe(undefined); + expect(fullLayout.yaxis3).toBe(undefined); + + var groups = fullLayout._axisMatchGroups; + expect(groups.length).toBe(1); + expect(groups).toContain({x2: 1, y2: 1}); + }); + + it('axis *matches* setting should propagate to layout axis containers (upper + no-diag case)', function() { + _supply({ + diagonal: {visible: false}, + showupperhalf: false, + dimensions: [ + {values: [1, 2, 1], axis: {matches: true}}, + {values: [-1, 2, 3], axis: {matches: true}}, + {values: [-10, 9, 3], axis: {matches: true}} + ] + }, {}); + + var fullLayout = gd._fullLayout; + expect(fullLayout.xaxis.matches).toBe(undefined); + expect(fullLayout.yaxis).toBe(undefined); + expect(fullLayout.xaxis2.matches).toBe('y2'); + expect(fullLayout.yaxis2.matches).toBe('x2'); + expect(fullLayout.xaxis3).toBe(undefined); + expect(fullLayout.yaxis3.matches).toBe(undefined); + + var groups = fullLayout._axisMatchGroups; + expect(groups.length).toBe(1); + expect(groups).toContain({x2: 1, y2: 1}); + }); + + it('axis *matches* in layout take precedence over dimensions settings', function() { + _supply({ + dimensions: [ + {values: [1, 2, 1], axis: {matches: true}}, + {values: [-1, 2, 3], axis: {matches: true}} + ] + }, { + xaxis: {}, + xaxis2: {matches: 'x'} + }); + + var fullLayout = gd._fullLayout; + expect(fullLayout.xaxis.matches).toBe('y'); + expect(fullLayout.yaxis.matches).toBe('x'); + expect(fullLayout.xaxis2.matches).toBe('x'); + expect(fullLayout.yaxis2.matches).toBe('x2'); + + var groups = fullLayout._axisMatchGroups; + expect(groups.length).toBe(1); + expect(groups).toContain({x: 1, y: 1, x2: 1, y2: 1}); + }); }); describe('Test splom trace calc step:', function() {