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