From e9a89c2ca35b72e9d393d9fe57a24e2b3155b865 Mon Sep 17 00:00:00 2001 From: alexcjohnson Date: Fri, 17 Mar 2017 01:01:31 -0400 Subject: [PATCH 01/16] split out axis type defaults and calculate before anything else --- src/components/colorbar/draw.js | 7 +- src/plots/cartesian/axis_defaults.js | 112 +--------------------- src/plots/cartesian/layout_defaults.js | 26 ++++- src/plots/cartesian/type_defaults.js | 126 +++++++++++++++++++++++++ src/plots/gl3d/layout/axis_defaults.js | 7 +- src/plots/ternary/layout/defaults.js | 2 +- src/traces/scattergl/convert.js | 2 +- 7 files changed, 161 insertions(+), 121 deletions(-) create mode 100644 src/plots/cartesian/type_defaults.js 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/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/layout_defaults.js b/src/plots/cartesian/layout_defaults.js index 6b6cb4ee3d0..203438caa0b 100644 --- a/src/plots/cartesian/layout_defaults.js +++ b/src/plots/cartesian/layout_defaults.js @@ -16,6 +16,7 @@ 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 handlePositionDefaults = require('./position_defaults'); var axisIds = require('./axis_ids'); @@ -141,6 +142,12 @@ module.exports = function supplyLayoutDefaults(layoutIn, layoutOut, fullData) { return out; } + // sets of axes linked by `scalewith` along with the scaleratios compounded + // together, populated in handleConstraintDefaults + layoutOut._axisConstraintGroups = []; + + // first pass creates the containers and determines types, because + // we need to have all types predetermined before setting constraints for(i = 0; i < axesList.length; i++) { axName = axesList[i]; @@ -151,14 +158,25 @@ module.exports = function supplyLayoutDefaults(layoutIn, layoutOut, fullData) { axLayoutIn = layoutIn[axName]; axLayoutOut = layoutOut[axName] = {}; + handleTypeDefaults(axLayoutIn, axLayoutOut, coerce, fullData, axName); + } + + // second pass handles most of the settings + for(i = 0; i < axesList.length; i++) { + axName = axesList[i]; + + axLayoutIn = layoutIn[axName]; + axLayoutOut = layoutOut[axName]; + var axLetter = axName.charAt(0); + var counterAxes = getCounterAxes(axLetter); + 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 +186,8 @@ module.exports = function supplyLayoutDefaults(layoutIn, layoutOut, fullData) { var positioningOptions = { letter: axLetter, - counterAxes: getCounterAxes(axLetter), - overlayableAxes: getOverlayableAxes(axLetter, axName) + counterAxes: counterAxes, + overlayableAxes: overlayableAxes }; handlePositionDefaults(axLayoutIn, axLayoutOut, coerce, positioningOptions); @@ -177,7 +195,7 @@ module.exports = function supplyLayoutDefaults(layoutIn, layoutOut, fullData) { axLayoutOut._input = axLayoutIn; } - // quick second pass for range slider and selector defaults + // quick third pass for range slider and selector defaults var rangeSliderDefaults = Registry.getComponentMethod('rangeslider', 'handleDefaults'), rangeSelectorDefaults = Registry.getComponentMethod('rangeselector', 'handleDefaults'); 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/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) { From d9a8ab7bff603025f362eca8b0cdf9d6c25526b7 Mon Sep 17 00:00:00 2001 From: alexcjohnson Date: Fri, 17 Mar 2017 01:06:22 -0400 Subject: [PATCH 02/16] static axis scalewith/scaleratio constraints all constraints are satisfied on initial plot, but not yet on zooms --- src/plot_api/plot_api.js | 7 +- src/plots/cartesian/constraint_defaults.js | 128 +++++++++++++++++++++ src/plots/cartesian/constraints.js | 67 +++++++++++ src/plots/cartesian/layout_attributes.js | 39 ++++++- src/plots/cartesian/layout_defaults.js | 3 + test/image/baselines/axes_scalewith.png | Bin 0 -> 25196 bytes test/image/mocks/axes_scalewith.json | 15 +++ test/jasmine/tests/axes_test.js | 76 ++++++++++++ 8 files changed, 331 insertions(+), 4 deletions(-) create mode 100644 src/plots/cartesian/constraint_defaults.js create mode 100644 src/plots/cartesian/constraints.js create mode 100644 test/image/baselines/axes_scalewith.png create mode 100644 test/image/mocks/axes_scalewith.json diff --git a/src/plot_api/plot_api.js b/src/plot_api/plot_api.js index 1b35e32ac77..f984e0f4419 100644 --- a/src/plot_api/plot_api.js +++ b/src/plot_api/plot_api.js @@ -32,6 +32,7 @@ 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'); /** @@ -256,18 +257,20 @@ 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); } // draw ticks, titles, and calculate axis scaling (._b, ._m) diff --git a/src/plots/cartesian/constraint_defaults.js b/src/plots/cartesian/constraint_defaults.js new file mode 100644 index 00000000000..3118b44cb7d --- /dev/null +++ b/src/plots/cartesian/constraint_defaults.js @@ -0,0 +1,128 @@ +/** +* 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, counterAxes, layoutOut) { + var constraintGroups = layoutOut._axisConstraintGroups; + + if(!containerIn.scalewith) return; + + var constraintOpts = getConstraintOpts(constraintGroups, containerOut._id, counterAxes, layoutOut); + + var scalewith = Lib.coerce(containerIn, containerOut, { + scalewith: { + valType: 'enumerated', + values: constraintOpts.linkableAxes + } + }, 'scalewith'); + + if(scalewith) { + 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, scalewith, scaleratio); + } + else if(counterAxes.indexOf(containerIn.scalewith) !== -1) { + Lib.warn('ignored ' + containerOut._name + '.scalewith: "' + + containerIn.scalewith + '" to avoid an infinite loop ' + + 'and possibly inconsistent scaleratios.'); + } +}; + +function getConstraintOpts(constraintGroups, thisID, counterAxes, layoutOut) { + // If this axis is already part of a constraint group, we can't + // scalewith any other axis in that group, or we'd make a loop. + // Filter counterAxes to enforce this, also matching axis types. + + var thisType = layoutOut[id2name(thisID)].type; + + var i, j, idj; + for(i = 0; i < constraintGroups.length; i++) { + if(constraintGroups[i][thisID]) { + var thisGroup = constraintGroups[i]; + + var linkableAxes = []; + for(j = 0; j < counterAxes.length; j++) { + idj = counterAxes[j]; + if(!thisGroup[idj] && layoutOut[id2name(idj)].type === thisType) { + linkableAxes.push(idj); + } + } + return {linkableAxes: linkableAxes, thisGroup: thisGroup}; + } + } + + return {linkableAxes: counterAxes, 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 + * scalewith: the id of the axis to scale it with + * scaleratio: the ratio of this axis to the scalewith axis + */ +function updateConstraintGroups(constraintGroups, thisGroup, thisID, scalewith, 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 scalewith axis. If it is, we need to merge the groups. + for(i = 0; i < constraintGroups.length; i++) { + groupi = constraintGroups[i]; + if(i !== thisGroupIndex && groupi[scalewith]) { + var baseScale = groupi[scalewith]; + 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 scalewith 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[scalewith] = 1; +} diff --git a/src/plots/cartesian/constraints.js b/src/plots/cartesian/constraints.js new file mode 100644 index 00000000000..90a6cff783b --- /dev/null +++ b/src/plots/cartesian/constraints.js @@ -0,0 +1,67 @@ +/** +* 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 ALMOST_EQUAL = 1 - 1e-6; + + +module.exports = function enforceAxisConstraints(gd) { + var fullLayout = gd._fullLayout; + var layout = gd.layout; + 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; + 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); + 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 > minScale) { + ax = axes[axisID]; + var rangeLinear = [ax.r2l(ax.range[0]), ax.r2l(ax.range[1])]; + var center = (rangeLinear[0] + rangeLinear[1]) / 2; + var newHalfSpan = (center - rangeLinear[0]) * normScale / minScale; + ax.range = layout[id2name(axisID)].range = [ + ax.l2r(center - newHalfSpan), + ax.l2r(center + newHalfSpan) + ]; + } + } + } +}; diff --git a/src/plots/cartesian/layout_attributes.js b/src/plots/cartesian/layout_attributes.js index b917c4cf8e1..7674d6300e6 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(' ') }, + // scalewith: not used directly, just put here for reference + // values are any opposite-letter axis id + scalewith: { + 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: {scalewith: *x*}, xaxis2: {scalewith: *y*}`', + 'but you can only link axes of the same `type`.', + 'Loops (`yaxis: {scalewith: *x*}, xaxis: {scalewith: *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 `scalewith`, 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 203438caa0b..424e38b00fc 100644 --- a/src/plots/cartesian/layout_defaults.js +++ b/src/plots/cartesian/layout_defaults.js @@ -18,6 +18,7 @@ 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'); @@ -184,6 +185,8 @@ module.exports = function supplyLayoutDefaults(layoutIn, layoutOut, fullData) { handleAxisDefaults(axLayoutIn, axLayoutOut, coerce, defaultOptions, layoutOut); + handleConstraintDefaults(axLayoutIn, axLayoutOut, coerce, counterAxes, layoutOut); + var positioningOptions = { letter: axLetter, counterAxes: counterAxes, diff --git a/test/image/baselines/axes_scalewith.png b/test/image/baselines/axes_scalewith.png new file mode 100644 index 0000000000000000000000000000000000000000..833ceda42cfbf645a38938bc701024e245190a62 GIT binary patch literal 25196 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 och0{ Date: Mon, 27 Mar 2017 00:05:01 -0400 Subject: [PATCH 03/16] short-sircuit image redraw during drags --- src/plots/cartesian/dragbox.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/plots/cartesian/dragbox.js b/src/plots/cartesian/dragbox.js index dd46b9037be..9e92f84be60 100644 --- a/src/plots/cartesian/dragbox.js +++ b/src/plots/cartesian/dragbox.js @@ -562,13 +562,15 @@ module.exports = function dragBox(gd, plotinfo, x, y, w, h, ns, ew) { Axes.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 +580,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() { From 98900a27b411e34b2447217abb9ba6ea87baaa48 Mon Sep 17 00:00:00 2001 From: alexcjohnson Date: Mon, 27 Mar 2017 00:05:27 -0400 Subject: [PATCH 04/16] refactor dragbox --- src/constants/numerical.js | 7 +- src/plots/cartesian/constraints.js | 16 +- src/plots/cartesian/dragbox.js | 372 +++++++++++++++-------------- src/plots/cartesian/scale_zoom.js | 23 ++ 4 files changed, 230 insertions(+), 188 deletions(-) create mode 100644 src/plots/cartesian/scale_zoom.js 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/plots/cartesian/constraints.js b/src/plots/cartesian/constraints.js index 90a6cff783b..f1b24ad5176 100644 --- a/src/plots/cartesian/constraints.js +++ b/src/plots/cartesian/constraints.js @@ -10,13 +10,13 @@ 'use strict'; var id2name = require('./axis_ids').id2name; +var scaleZoom = require('./scale_zoom'); -var ALMOST_EQUAL = 1 - 1e-6; +var ALMOST_EQUAL = require('../../constants/numerical').ALMOST_EQUAL; module.exports = function enforceAxisConstraints(gd) { var fullLayout = gd._fullLayout; - var layout = gd.layout; var constraintGroups = fullLayout._axisConstraintGroups; var i, j, axisID, ax, normScale; @@ -52,16 +52,8 @@ module.exports = function enforceAxisConstraints(gd) { for(j = 0; j < axisIDs.length; j++) { axisID = axisIDs[j]; normScale = normScales[axisID]; - if(normScale > minScale) { - ax = axes[axisID]; - var rangeLinear = [ax.r2l(ax.range[0]), ax.r2l(ax.range[1])]; - var center = (rangeLinear[0] + rangeLinear[1]) / 2; - var newHalfSpan = (center - rangeLinear[0]) * normScale / minScale; - ax.range = layout[id2name(axisID)].range = [ - ax.l2r(center - newHalfSpan), - ax.l2r(center + newHalfSpan) - ]; - } + + if(normScale > minScale) scaleZoom(axes[axisID], normScale / minScale); } } }; diff --git a/src/plots/cartesian/dragbox.js b/src/plots/cartesian/dragbox.js index 9e92f84be60..1ebe680e8c3 100644 --- a/src/plots/cartesian/dragbox.js +++ b/src/plots/cartesian/dragbox.js @@ -23,7 +23,11 @@ var dragElement = require('../../components/dragelement'); var Axes = require('./axes'); 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 +50,48 @@ 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, + allaxes, + xActive, + yActive, + cursor; + + function recomputeAxisLists() { + xa = [plotinfo.xaxis]; + ya = [plotinfo.yaxis]; + var xa0 = xa[0]; + var ya0 = ya[0]; + pw = xa0._length; + ph = ya0._length; + // 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 : []); - function isDirectionActive(axList, activeVal) { - for(var i = 0; i < axList.length; i++) { - if(!axList[i].fixedrange) return activeVal; + 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); } - return ''; + allaxes = xa.concat(ya); + xActive = isDirectionActive(xa, ew); + yActive = isDirectionActive(ya, ns); + cursor = getDragCursor(yActive + xActive, fullLayout.dragmode); + xs = xa0._offset; + ys = ya0._offset; } - var allaxes = xa.concat(ya), - xActive = isDirectionActive(xa, ew), - yActive = isDirectionActive(ya, ns), - cursor = getDragCursor(yActive + xActive, fullLayout.dragmode), - dragClass = ns + ew + 'drag'; + recomputeAxisLists(); - 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); - - 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 +106,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; @@ -130,7 +132,7 @@ module.exports = function dragBox(gd, plotinfo, x, y, w, h, ns, ew) { else if(dragModeNow === 'pan') { dragOptions.moveFn = plotDrag; dragOptions.doneFn = dragDone; - clearSelect(); + clearSelect(zoomlayer); } else if(isSelectOrLasso(dragModeNow)) { prepSelect(e, startX, startY, dragOptions, dragModeNow); @@ -140,10 +142,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 +152,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 +164,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,8 +179,7 @@ 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); @@ -246,72 +199,24 @@ module.exports = function dragBox(gd, plotinfo, x, y, w, h, ns, ew) { 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, Math.floor(Math.min(dy, dx, MINZOOM) / 2))); } 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) { @@ -354,7 +259,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, @@ -426,10 +331,12 @@ module.exports = function dragBox(gd, plotinfo, x, y, w, h, ns, ew) { function zoomWheelOneAxis(ax, centerFraction, zoom) { if(ax.fixedrange) return; - var axRange = Lib.simpleMap(ax.range, ax.r2l), - v0 = axRange[0] + (axRange[1] - axRange[0]) * centerFraction; - function doZoom(v) { return ax.l2r(v0 + (v - v0) * zoom); } - ax.range = axRange.map(doZoom); + // var axRange = Lib.simpleMap(ax.range, ax.r2l), + // v0 = axRange[0] + (axRange[1] - axRange[0]) * centerFraction; + // function doZoom(v) { return ax.l2r(v0 + (v - v0) * zoom); } + // ax.range = axRange.map(doZoom); + + scaleZoom(ax, zoom, centerFraction); } if(ew) { @@ -473,18 +380,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 +388,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 @@ -648,7 +533,7 @@ module.exports = function dragBox(gd, plotinfo, x, y, w, h, ns, ew) { 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]); @@ -724,6 +609,28 @@ module.exports = function dragBox(gd, plotinfo, x, y, w, h, ns, ew) { 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]), @@ -745,6 +652,47 @@ function getEndText(ax, end) { } } +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) + ]; + } +} + +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') { @@ -754,6 +702,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') @@ -765,3 +759,31 @@ 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, clen) { + 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'; +} 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) + ]; +}; From 84d95a5bab9ecc53bdc9dda13aa941dde330d981 Mon Sep 17 00:00:00 2001 From: alexcjohnson Date: Tue, 28 Mar 2017 00:57:44 -0400 Subject: [PATCH 05/16] dynamic axis constraints --- src/plots/cartesian/constraints.js | 27 ++- src/plots/cartesian/dragbox.js | 349 +++++++++++++++++++++++------ 2 files changed, 302 insertions(+), 74 deletions(-) diff --git a/src/plots/cartesian/constraints.js b/src/plots/cartesian/constraints.js index f1b24ad5176..7ddcf34665e 100644 --- a/src/plots/cartesian/constraints.js +++ b/src/plots/cartesian/constraints.js @@ -27,6 +27,12 @@ module.exports = function enforceAxisConstraints(gd) { 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 = {}; @@ -42,6 +48,13 @@ module.exports = function enforceAxisConstraints(gd) { // 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); } @@ -53,7 +66,19 @@ module.exports = function enforceAxisConstraints(gd) { axisID = axisIDs[j]; normScale = normScales[axisID]; - if(normScale > minScale) scaleZoom(axes[axisID], normScale / minScale); + if(normScale !== matchScale) { + ax = axes[axisID]; + // if range matches _rangeInitial before the constraint is applied, + // change _rangeInitial to the new range - otherwise a doubleclick + // will never autorange because we're not starting at the reset point. + var wasAtInitial = (ax._rangeInitial && + ax.range[0] === ax._rangeInitial[0] && + ax.range[1] === ax._rangeInitial[1]); + + scaleZoom(ax, normScale / matchScale); + + if(wasAtInitial) ax._rangeInitial = ax.range.slice(); + } } } }; diff --git a/src/plots/cartesian/dragbox.js b/src/plots/cartesian/dragbox.js index 1ebe680e8c3..2b2c0247926 100644 --- a/src/plots/cartesian/dragbox.js +++ b/src/plots/cartesian/dragbox.js @@ -21,7 +21,8 @@ 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'); @@ -59,10 +60,12 @@ module.exports = function dragBox(gd, plotinfo, x, y, w, h, ns, ew) { ys, pw, ph, - allaxes, xActive, yActive, - cursor; + cursor, + isSubplotConstrained, + xaLinked, + yaLinked; function recomputeAxisLists() { xa = [plotinfo.xaxis]; @@ -72,21 +75,42 @@ module.exports = function dragBox(gd, plotinfo, x, y, w, h, ns, ew) { 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 : []); 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); + + if(xa.indexOf(subplotXa) === -1) { + xa.push(subplotXa); + xIDs.push(subplotXa._id); + } + + if(ya.indexOf(subplotYa) === -1) { + ya.push(subplotYa); + yIDs.push(subplotYa._id); + } } - allaxes = xa.concat(ya); + xActive = isDirectionActive(xa, ew); yActive = isDirectionActive(ya, ns); cursor = getDragCursor(yActive + xActive, fullLayout.dragmode); xs = xa0._offset; ys = ya0._offset; + + var links = calcLinks(constraintGroups, xIDs, yIDs); + isSubplotConstrained = links.xy; + + // 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)); } } recomputeAxisLists(); @@ -135,6 +159,8 @@ module.exports = function dragBox(gd, plotinfo, x, y, w, h, ns, ew) { clearSelect(zoomlayer); } else if(isSelectOrLasso(dragModeNow)) { + dragOptions.xaxes = xa; + dragOptions.yaxes = ya; prepSelect(e, startX, startY, dragOptions, dragModeNow); } } @@ -186,14 +212,37 @@ module.exports = function dragBox(gd, plotinfo, x, y, w, h, ns, ew) { 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; @@ -210,7 +259,7 @@ module.exports = function dragBox(gd, plotinfo, x, y, w, h, ns, ew) { } else { zoomMode = 'xy'; - corners.attr('d', xyCorners(box, Math.floor(Math.min(dy, dx, MINZOOM) / 2))); + corners.attr('d', xyCorners(box)); } box.w = box.r - box.l; box.h = box.b - box.t; @@ -226,8 +275,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); @@ -323,31 +373,35 @@ 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) { if(ax.fixedrange) return; - // var axRange = Lib.simpleMap(ax.range, ax.r2l), - // v0 = axRange[0] + (axRange[1] - axRange[0]) * centerFraction; - // function doZoom(v) { return ax.l2r(v0 + (v - v0) * zoom); } - // ax.range = axRange.map(doZoom); - - scaleZoom(ax, zoom, centerFraction); + var axRange = Lib.simpleMap(ax.range, ax.r2l), + v0 = axRange[0] + (axRange[1] - axRange[0]) * centerFraction; + function doZoom(v) { return ax.l2r(v0 + (v - v0) * zoom); } + 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 @@ -358,7 +412,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); @@ -413,6 +472,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; @@ -421,12 +489,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); } @@ -440,11 +528,17 @@ 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, shortCircuit) { @@ -473,43 +567,67 @@ module.exports = function dragBox(gd, plotinfo, x, y, w, h, ns, ew) { var doubleClickConfig = gd._context.doubleClick, axList = (xActive ? xa : []).concat(yActive ? ya : []), + linkedAxList = (xActive || isSubplotConstrained ? xaLinked : []) + .concat(yActive ? yaLinked : []), attrs = {}; + if(isSubplotConstrained) { + if(!xActive) linkedAxList = linkedAxList.concat(xa); + else if(!yActive) linkedAxList = linkedAxList.concat(ya); + } + 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') { + // when we're autosizing one or a few axes, mark the other axes as + // "shrinkable" so that we don't expand to cover whatever their + // current ranges might be, but instead we just autosize to the + // selected axes + for(i = 0; i < linkedAxList.length; i++) { + linkedAxList[i]._constraintShrinkable = true; + } + 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 + axList = axList.concat(linkedAxList); + 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]; } @@ -522,14 +640,21 @@ 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 === 'x' || zoommode === 'xy') { + axesToModify = xa.concat(xaLinked); + } + if(zoommode === 'y' || zoommode === 'xy') { + axesToModify = axesToModify.concat(ya, yaLinked); + } + + 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]; @@ -553,10 +678,12 @@ module.exports = function dragBox(gd, plotinfo, x, y, w, h, ns, ew) { var subplot = plotinfos[subplots[i]], xa2 = subplot.xaxis, ya2 = subplot.yaxis, - editX = ew && !xa2.fixedrange, - editY = ns && !ya2.fixedrange; + editX = (viewBox[0] !== 0 || viewBox[2] !== pw) && !xa2.fixedrange, + editY = (viewBox[1] !== 0 || viewBox[3] !== ph) && !ya2.fixedrange; if(editX) { + // TODO: now that we're doing recomputeAxisLists can this be turned into + // just xa.indexOf(xa2) !== -1? var isInX = false; for(j = 0; j < xa.length; j++) { if(xa[j]._id === xa2._id) { @@ -578,31 +705,44 @@ module.exports = function dragBox(gd, plotinfo, x, y, w, h, ns, ew) { editY = editY && isInY; } - var xScaleFactor = editX ? xa2._length / viewBox[2] : 1, - yScaleFactor = editY ? ya2._length / viewBox[3] : 1; + var xScaleFactor = editX ? viewBox[2] / xa2._length : 1, + yScaleFactor = editY ? viewBox[3] / ya2._length : 1; var clipDx = editX ? viewBox[0] : 0, clipDy = editY ? viewBox[1] : 0; - var fracDx = editX ? (viewBox[0] / viewBox[2] * xa2._length) : 0, - fracDy = editY ? (viewBox[1] / viewBox[3] * ya2._length) : 0; + // modify these if needed for linked axes + if(editX && !editY && xaLinked.indexOf(ya2) !== -1) { + yScaleFactor = xScaleFactor; + clipDy = ya2._length * (1 - yScaleFactor) / 2; + + // update range for the linked axis. + ya2.range = ya2._r.slice(); + scaleZoom(ya2, yScaleFactor); + } + if(editY && !editX && (isSubplotConstrained ? xaLinked : yaLinked).indexOf(xa2) !== -1) { + xScaleFactor = yScaleFactor; + clipDx = xa2._length * (1 - xScaleFactor) / 2; + xa2.range = xa2._r.slice(); + scaleZoom(xa2, xScaleFactor); + } - var plotDx = xa2._offset - fracDx, - plotDy = ya2._offset - fracDy; + var plotDx = xa2._offset - clipDx / xScaleFactor, + plotDy = ya2._offset - clipDy / yScaleFactor; fullLayout._defs.selectAll('#' + subplot.clipId) .call(Drawing.setTranslate, clipDx, clipDy) - .call(Drawing.setScale, 1 / xScaleFactor, 1 / yScaleFactor); + .call(Drawing.setScale, xScaleFactor, yScaleFactor); subplot.plot .call(Drawing.setTranslate, plotDx, plotDy) - .call(Drawing.setScale, xScaleFactor, yScaleFactor) + .call(Drawing.setScale, 1 / xScaleFactor, 1 / yScaleFactor) // 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, xScaleFactor, yScaleFactor); } } @@ -652,7 +792,7 @@ function getEndText(ax, end) { } } -function zoomAxRanges(axList, r0Fraction, r1Fraction) { +function zoomAxRanges(axList, r0Fraction, r1Fraction, linkedAxes) { var i, axi, axRangeLinear0, @@ -669,6 +809,13 @@ function zoomAxRanges(axList, r0Fraction, r1Fraction) { 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) { @@ -776,7 +923,8 @@ function yCorners(box, x0) { 'v3h' + (2 * MINZOOM + 1) + 'v-3Z'; } -function xyCorners(box, clen) { +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' + @@ -787,3 +935,58 @@ function xyCorners(box, clen) { (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 + }; +} From f521227fd25a31d158268355f610debf624c67dd Mon Sep 17 00:00:00 2001 From: alexcjohnson Date: Wed, 29 Mar 2017 00:01:56 -0400 Subject: [PATCH 06/16] push final constraint management into relayout so it always works --- src/plot_api/plot_api.js | 46 +++++++++++++++++++++++++++++----- src/plots/cartesian/dragbox.js | 36 ++++++++++---------------- 2 files changed, 53 insertions(+), 29 deletions(-) diff --git a/src/plot_api/plot_api.js b/src/plot_api/plot_api.js index f984e0f4419..e305616032a 100644 --- a/src/plot_api/plot_api.js +++ b/src/plot_api/plot_api.js @@ -33,6 +33,7 @@ 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'); /** @@ -1860,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)) { @@ -1894,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'); @@ -2121,16 +2134,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/dragbox.js b/src/plots/cartesian/dragbox.js index 2b2c0247926..0f434c68bff 100644 --- a/src/plots/cartesian/dragbox.js +++ b/src/plots/cartesian/dragbox.js @@ -567,15 +567,8 @@ module.exports = function dragBox(gd, plotinfo, x, y, w, h, ns, ew) { var doubleClickConfig = gd._context.doubleClick, axList = (xActive ? xa : []).concat(yActive ? ya : []), - linkedAxList = (xActive || isSubplotConstrained ? xaLinked : []) - .concat(yActive ? yaLinked : []), attrs = {}; - if(isSubplotConstrained) { - if(!xActive) linkedAxList = linkedAxList.concat(xa); - else if(!yActive) linkedAxList = linkedAxList.concat(ya); - } - var ax, i, rangeInitial; // For reset+autosize mode: @@ -602,14 +595,8 @@ module.exports = function dragBox(gd, plotinfo, x, y, w, h, ns, ew) { } if(doubleClickConfig === 'autosize') { - // when we're autosizing one or a few axes, mark the other axes as - // "shrinkable" so that we don't expand to cover whatever their - // current ranges might be, but instead we just autosize to the - // selected axes - for(i = 0; i < linkedAxList.length; i++) { - linkedAxList[i]._constraintShrinkable = true; - } - + // 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.fixedrange) attrs[ax._name + '.autorange'] = true; @@ -618,7 +605,13 @@ module.exports = function dragBox(gd, plotinfo, x, y, w, h, ns, ew) { else if(doubleClickConfig === 'reset') { // when we're resetting, reset all linked axes too, so we get back // to the fully-auto-with-constraints situation - axList = axList.concat(linkedAxList); + 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]; @@ -645,13 +638,10 @@ module.exports = function dragBox(gd, plotinfo, x, y, w, h, ns, ew) { var attrs = {}; // revert to the previous axis settings, then apply the new ones // through relayout - this lets relayout manage undo/redo - var axesToModify = []; - if(zoommode === 'x' || zoommode === 'xy') { - axesToModify = xa.concat(xaLinked); - } - if(zoommode === 'y' || zoommode === 'xy') { - axesToModify = axesToModify.concat(ya, yaLinked); - } + 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]; From 13f848e431cc15524377578d1e40710ed1813f38 Mon Sep 17 00:00:00 2001 From: alexcjohnson Date: Wed, 29 Mar 2017 00:08:18 -0400 Subject: [PATCH 07/16] scalewith -> scaleanchor --- src/plots/cartesian/constraint_defaults.js | 36 +++++++++--------- src/plots/cartesian/layout_attributes.js | 10 ++--- src/plots/cartesian/layout_defaults.js | 2 +- ...xes_scalewith.png => axes_scaleanchor.png} | Bin ...s_scalewith.json => axes_scaleanchor.json} | 4 +- 5 files changed, 26 insertions(+), 26 deletions(-) rename test/image/baselines/{axes_scalewith.png => axes_scaleanchor.png} (100%) rename test/image/mocks/{axes_scalewith.json => axes_scaleanchor.json} (65%) diff --git a/src/plots/cartesian/constraint_defaults.js b/src/plots/cartesian/constraint_defaults.js index 3118b44cb7d..a8c6a0c1ae1 100644 --- a/src/plots/cartesian/constraint_defaults.js +++ b/src/plots/cartesian/constraint_defaults.js @@ -16,18 +16,18 @@ var id2name = require('./axis_ids').id2name; module.exports = function handleConstraintDefaults(containerIn, containerOut, coerce, counterAxes, layoutOut) { var constraintGroups = layoutOut._axisConstraintGroups; - if(!containerIn.scalewith) return; + if(!containerIn.scaleanchor) return; var constraintOpts = getConstraintOpts(constraintGroups, containerOut._id, counterAxes, layoutOut); - var scalewith = Lib.coerce(containerIn, containerOut, { - scalewith: { + var scaleanchor = Lib.coerce(containerIn, containerOut, { + scaleanchor: { valType: 'enumerated', values: constraintOpts.linkableAxes } - }, 'scalewith'); + }, 'scaleanchor'); - if(scalewith) { + 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"? @@ -37,18 +37,18 @@ module.exports = function handleConstraintDefaults(containerIn, containerOut, co if(!scaleratio) scaleratio = containerOut.scaleratio = 1; updateConstraintGroups(constraintGroups, constraintOpts.thisGroup, - containerOut._id, scalewith, scaleratio); + containerOut._id, scaleanchor, scaleratio); } - else if(counterAxes.indexOf(containerIn.scalewith) !== -1) { - Lib.warn('ignored ' + containerOut._name + '.scalewith: "' + - containerIn.scalewith + '" to avoid an infinite loop ' + + else if(counterAxes.indexOf(containerIn.scaleanchor) !== -1) { + Lib.warn('ignored ' + containerOut._name + '.scaleanchor: "' + + containerIn.scaleanchor + '" to avoid an infinite loop ' + 'and possibly inconsistent scaleratios.'); } }; function getConstraintOpts(constraintGroups, thisID, counterAxes, layoutOut) { // If this axis is already part of a constraint group, we can't - // scalewith any other axis in that group, or we'd make a loop. + // scaleanchor any other axis in that group, or we'd make a loop. // Filter counterAxes to enforce this, also matching axis types. var thisType = layoutOut[id2name(thisID)].type; @@ -84,10 +84,10 @@ function getConstraintOpts(constraintGroups, thisID, counterAxes, layoutOut) { * * thisGroup: the group the current axis is already in * thisID: the id if the current axis - * scalewith: the id of the axis to scale it with - * scaleratio: the ratio of this axis to the scalewith 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, scalewith, scaleratio) { +function updateConstraintGroups(constraintGroups, thisGroup, thisID, scaleanchor, scaleratio) { var i, j, groupi, keyj, thisGroupIndex; if(thisGroup === null) { @@ -103,11 +103,11 @@ function updateConstraintGroups(constraintGroups, thisGroup, thisID, scalewith, var thisGroupKeys = Object.keys(thisGroup); // we know that this axis isn't in any other groups, but we don't know - // about the scalewith axis. If it is, we need to merge the groups. + // 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[scalewith]) { - var baseScale = groupi[scalewith]; + 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]; @@ -117,12 +117,12 @@ function updateConstraintGroups(constraintGroups, thisGroup, thisID, scalewith, } } - // otherwise, we insert the new scalewith axis as the base scale (1) + // 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[scalewith] = 1; + thisGroup[scaleanchor] = 1; } diff --git a/src/plots/cartesian/layout_attributes.js b/src/plots/cartesian/layout_attributes.js index 7674d6300e6..f826ca522ba 100644 --- a/src/plots/cartesian/layout_attributes.js +++ b/src/plots/cartesian/layout_attributes.js @@ -107,9 +107,9 @@ module.exports = { 'If true, then zoom is disabled.' ].join(' ') }, - // scalewith: not used directly, just put here for reference + // scaleanchor: not used directly, just put here for reference // values are any opposite-letter axis id - scalewith: { + scaleanchor: { valType: 'enumerated', values: [ constants.idRegex.x.toString(), @@ -123,9 +123,9 @@ module.exports = { '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: {scalewith: *x*}, xaxis2: {scalewith: *y*}`', + 'You can chain these, ie `yaxis: {scaleanchor: *x*}, xaxis2: {scaleanchor: *y*}`', 'but you can only link axes of the same `type`.', - 'Loops (`yaxis: {scalewith: *x*}, xaxis: {scalewith: *y*}` or longer) are redundant', + '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(' ') @@ -136,7 +136,7 @@ module.exports = { dflt: 1, role: 'info', description: [ - 'If this axis is linked to another by `scalewith`, this determines the pixel', + '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', diff --git a/src/plots/cartesian/layout_defaults.js b/src/plots/cartesian/layout_defaults.js index 424e38b00fc..2e66c53789c 100644 --- a/src/plots/cartesian/layout_defaults.js +++ b/src/plots/cartesian/layout_defaults.js @@ -143,7 +143,7 @@ module.exports = function supplyLayoutDefaults(layoutIn, layoutOut, fullData) { return out; } - // sets of axes linked by `scalewith` along with the scaleratios compounded + // sets of axes linked by `scaleanchor` along with the scaleratios compounded // together, populated in handleConstraintDefaults layoutOut._axisConstraintGroups = []; diff --git a/test/image/baselines/axes_scalewith.png b/test/image/baselines/axes_scaleanchor.png similarity index 100% rename from test/image/baselines/axes_scalewith.png rename to test/image/baselines/axes_scaleanchor.png diff --git a/test/image/mocks/axes_scalewith.json b/test/image/mocks/axes_scaleanchor.json similarity index 65% rename from test/image/mocks/axes_scalewith.json rename to test/image/mocks/axes_scaleanchor.json index 7cac78d54ac..4f47c10755a 100644 --- a/test/image/mocks/axes_scalewith.json +++ b/test/image/mocks/axes_scaleanchor.json @@ -8,8 +8,8 @@ "height":600, "title": "fixed-ratio axes", "xaxis": {"nticks": 10, "title": "shared X axis"}, - "yaxis": {"scalewith": "x", "domain": [0, 0.45], "title": "1:1"}, - "yaxis2": {"scalewith": "x", "scaleratio": 0.2, "domain": [0.55,1], "title": "1:5"}, + "yaxis": {"scaleanchor": "x", "domain": [0, 0.45], "title": "1:1"}, + "yaxis2": {"scaleanchor": "x", "scaleratio": 0.2, "domain": [0.55,1], "title": "1:5"}, "showlegend": false } } From dee136d2eab529f27cc7472a93bcb6c75f8cfed4 Mon Sep 17 00:00:00 2001 From: alexcjohnson Date: Wed, 29 Mar 2017 00:44:23 -0400 Subject: [PATCH 08/16] forbid scale constraints along with fixedrange --- src/plot_api/plot_api.js | 1 + src/plots/cartesian/constraint_defaults.js | 31 +++++--- src/plots/cartesian/layout_defaults.js | 46 ++++++----- test/jasmine/tests/axes_test.js | 89 ++++++++++++++++------ 4 files changed, 112 insertions(+), 55 deletions(-) diff --git a/src/plot_api/plot_api.js b/src/plot_api/plot_api.js index e305616032a..9446c6a964c 100644 --- a/src/plot_api/plot_api.js +++ b/src/plot_api/plot_api.js @@ -2107,6 +2107,7 @@ function _relayout(gd, aobj) { pp1 === 'rangemode' || pp1 === 'type' || pp1 === 'domain' || + pp1 === 'fixedrange' || ai.indexOf('calendar') !== -1 || ai.match(/^(bar|box|font)/)) { flags.docalc = true; diff --git a/src/plots/cartesian/constraint_defaults.js b/src/plots/cartesian/constraint_defaults.js index a8c6a0c1ae1..4110dafd27b 100644 --- a/src/plots/cartesian/constraint_defaults.js +++ b/src/plots/cartesian/constraint_defaults.js @@ -16,7 +16,7 @@ var id2name = require('./axis_ids').id2name; module.exports = function handleConstraintDefaults(containerIn, containerOut, coerce, counterAxes, layoutOut) { var constraintGroups = layoutOut._axisConstraintGroups; - if(!containerIn.scaleanchor) return; + if(containerOut.fixedrange || !containerIn.scaleanchor) return; var constraintOpts = getConstraintOpts(constraintGroups, containerOut._id, counterAxes, layoutOut); @@ -41,8 +41,9 @@ module.exports = function handleConstraintDefaults(containerIn, containerOut, co } else if(counterAxes.indexOf(containerIn.scaleanchor) !== -1) { Lib.warn('ignored ' + containerOut._name + '.scaleanchor: "' + - containerIn.scaleanchor + '" to avoid an infinite loop ' + - 'and possibly inconsistent scaleratios.'); + containerIn.scaleanchor + '" to avoid either an infinite loop ' + + 'and possibly inconsistent scaleratios, or because the target' + + 'axis has fixed range.'); } }; @@ -53,23 +54,29 @@ function getConstraintOpts(constraintGroups, thisID, counterAxes, layoutOut) { var thisType = layoutOut[id2name(thisID)].type; - var i, j, idj; + var i, j, idj, axj; + + var linkableAxes = []; + for(j = 0; j < counterAxes.length; j++) { + idj = counterAxes[j]; + 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 linkableAxes = []; - for(j = 0; j < counterAxes.length; j++) { - idj = counterAxes[j]; - if(!thisGroup[idj] && layoutOut[id2name(idj)].type === thisType) { - linkableAxes.push(idj); - } + var linkableAxesNoLoops = []; + for(j = 0; j < linkableAxes.length; j++) { + idj = linkableAxes[j]; + if(!thisGroup[idj]) linkableAxesNoLoops.push(idj); } - return {linkableAxes: linkableAxes, thisGroup: thisGroup}; + return {linkableAxes: linkableAxesNoLoops, thisGroup: thisGroup}; } } - return {linkableAxes: counterAxes, thisGroup: null}; + return {linkableAxes: linkableAxes, thisGroup: null}; } diff --git a/src/plots/cartesian/layout_defaults.js b/src/plots/cartesian/layout_defaults.js index 2e66c53789c..ae436a8eaac 100644 --- a/src/plots/cartesian/layout_defaults.js +++ b/src/plots/cartesian/layout_defaults.js @@ -117,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); @@ -128,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 = []; @@ -143,12 +145,7 @@ module.exports = function supplyLayoutDefaults(layoutIn, layoutOut, fullData) { return out; } - // sets of axes linked by `scaleanchor` along with the scaleratios compounded - // together, populated in handleConstraintDefaults - layoutOut._axisConstraintGroups = []; - - // first pass creates the containers and determines types, because - // we need to have all types predetermined before setting constraints + // first pass creates the containers, determines types, and handles most of the settings for(i = 0; i < axesList.length; i++) { axName = axesList[i]; @@ -160,17 +157,8 @@ module.exports = function supplyLayoutDefaults(layoutIn, layoutOut, fullData) { axLayoutOut = layoutOut[axName] = {}; handleTypeDefaults(axLayoutIn, axLayoutOut, coerce, fullData, axName); - } - // second pass handles most of the settings - for(i = 0; i < axesList.length; i++) { - axName = axesList[i]; - - axLayoutIn = layoutIn[axName]; - axLayoutOut = layoutOut[axName]; - - var axLetter = axName.charAt(0); - var counterAxes = getCounterAxes(axLetter); + axLetter = axName.charAt(0); var overlayableAxes = getOverlayableAxes(axLetter, axName); var defaultOptions = { @@ -185,11 +173,9 @@ module.exports = function supplyLayoutDefaults(layoutIn, layoutOut, fullData) { handleAxisDefaults(axLayoutIn, axLayoutOut, coerce, defaultOptions, layoutOut); - handleConstraintDefaults(axLayoutIn, axLayoutOut, coerce, counterAxes, layoutOut); - var positioningOptions = { letter: axLetter, - counterAxes: counterAxes, + counterAxes: counterAxes[axLetter], overlayableAxes: overlayableAxes }; @@ -198,7 +184,7 @@ module.exports = function supplyLayoutDefaults(layoutIn, layoutOut, fullData) { axLayoutOut._input = axLayoutIn; } - // quick third pass for range slider and selector defaults + // quick second pass for range slider and selector defaults var rangeSliderDefaults = Registry.getComponentMethod('rangeslider', 'handleDefaults'), rangeSelectorDefaults = Registry.getComponentMethod('rangeselector', 'handleDefaults'); @@ -237,4 +223,22 @@ 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 = []; + + for(i = 0; i < axesList.length; i++) { + axName = axesList[i]; + axLetter = axName.charAt(0); + + axLayoutIn = layoutIn[axName]; + axLayoutOut = layoutOut[axName]; + + handleConstraintDefaults(axLayoutIn, axLayoutOut, coerce, counterAxes[axLetter], layoutOut); + } }; diff --git a/test/jasmine/tests/axes_test.js b/test/jasmine/tests/axes_test.js index f54c05ce8fe..f12d4094afd 100644 --- a/test/jasmine/tests/axes_test.js +++ b/test/jasmine/tests/axes_test.js @@ -449,14 +449,14 @@ describe('Test axes', function() { layoutIn = { // first group: linked in series, scales compound xaxis: {}, - yaxis: {scalewith: 'x', scaleratio: 2}, - xaxis2: {scalewith: 'y', scaleratio: 3}, - yaxis2: {scalewith: 'x2', scaleratio: 5}, + 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: {scalewith: 'y3'}, // default scaleratio: 1 - xaxis4: {scalewith: 'y3', scaleratio: 7}, - xaxis5: {scalewith: 'y3', scaleratio: 9} + xaxis3: {scaleanchor: 'y3'}, // default scaleratio: 1 + xaxis4: {scaleanchor: 'y3', scaleratio: 7}, + xaxis5: {scaleanchor: 'y3', scaleratio: 9} }; supplyLayoutDefaults(layoutIn, layoutOut, fullData); @@ -467,20 +467,20 @@ describe('Test axes', function() { ]); }); - it('breaks scalewith loops and drops conflicting ratios', function() { + it('breaks scaleanchor loops and drops conflicting ratios', function() { var warnings = []; spyOn(Lib, 'warn').and.callFake(function(msg) { warnings.push(msg); }); layoutIn = { - xaxis: {scalewith: 'y', scaleratio: 2}, - yaxis: {scalewith: 'x', scaleratio: 3}, + xaxis: {scaleanchor: 'y', scaleratio: 2}, + yaxis: {scaleanchor: 'x', scaleratio: 3}, - xaxis2: {scalewith: 'y2', scaleratio: 5}, - yaxis2: {scalewith: 'x3', scaleratio: 7}, - xaxis3: {scalewith: 'y3', scaleratio: 9}, - yaxis3: {scalewith: 'x2', scaleratio: 11} + xaxis2: {scaleanchor: 'y2', scaleratio: 5}, + yaxis2: {scaleanchor: 'x3', scaleratio: 7}, + xaxis3: {scaleanchor: 'y3', scaleratio: 9}, + yaxis3: {scaleanchor: 'x2', scaleratio: 11} }; supplyLayoutDefaults(layoutIn, layoutOut, fullData); @@ -490,24 +490,26 @@ describe('Test axes', function() { {x2: 5 * 7 * 9, y2: 7 * 9, y3: 1, x3: 9} ]); + var warnTxt = ' to avoid either an infinite loop and possibly ' + + 'inconsistent scaleratios, or because the targetaxis has ' + + 'fixed range.'; + expect(warnings).toEqual([ - 'ignored yaxis.scalewith: "x" to avoid an infinite loop ' + - 'and possibly inconsistent scaleratios.', - 'ignored yaxis3.scalewith: "x2" to avoid an infinite loop ' + - 'and possibly inconsistent scaleratios.' + 'ignored yaxis.scaleanchor: "x"' + warnTxt, + 'ignored yaxis3.scaleanchor: "x2"' + warnTxt ]); }); - it('silently drops invalid scalewith values', function() { + it('silently drops invalid scaleanchor values', function() { var warnings = []; spyOn(Lib, 'warn').and.callFake(function(msg) { warnings.push(msg); }); layoutIn = { - xaxis: {scalewith: 'x2', scaleratio: 2}, // must be opposite letter - yaxis: {scalewith: 'x4', scaleratio: 3}, // doesn't exist - xaxis2: {scalewith: 'yaxis', scaleratio: 5} // must be an id, not a name + xaxis: {scaleanchor: 'x2', scaleratio: 2}, // must be opposite letter + yaxis: {scaleanchor: 'x4', scaleratio: 3}, // doesn't exist + xaxis2: {scaleanchor: 'yaxis', scaleratio: 5} // must be an id, not a name }; supplyLayoutDefaults(layoutIn, layoutOut, fullData); @@ -516,7 +518,50 @@ describe('Test axes', function() { expect(warnings).toEqual([]); ['xaxis', 'yaxis', 'xaxis2'].forEach(function(axName) { - expect(layoutOut[axName].scalewith).toBeUndefined(); + 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(); }); }); From ef3ca48e06a3a942e3ba04cf53bfa1313843c3a0 Mon Sep 17 00:00:00 2001 From: alexcjohnson Date: Wed, 29 Mar 2017 15:18:53 -0400 Subject: [PATCH 09/16] allow constraints with same axis letter --- src/plots/cartesian/constraint_defaults.js | 16 +-- src/plots/cartesian/dragbox.js | 112 +++++++++++---------- src/plots/cartesian/layout_defaults.js | 3 +- test/image/baselines/axes_scaleanchor.png | Bin 25196 -> 40722 bytes test/image/mocks/axes_scaleanchor.json | 13 ++- test/jasmine/tests/axes_test.js | 11 +- 6 files changed, 87 insertions(+), 68 deletions(-) diff --git a/src/plots/cartesian/constraint_defaults.js b/src/plots/cartesian/constraint_defaults.js index 4110dafd27b..5224676dfe2 100644 --- a/src/plots/cartesian/constraint_defaults.js +++ b/src/plots/cartesian/constraint_defaults.js @@ -13,12 +13,12 @@ var Lib = require('../../lib'); var id2name = require('./axis_ids').id2name; -module.exports = function handleConstraintDefaults(containerIn, containerOut, coerce, counterAxes, layoutOut) { +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, counterAxes, layoutOut); + var constraintOpts = getConstraintOpts(constraintGroups, containerOut._id, allAxisIds, layoutOut); var scaleanchor = Lib.coerce(containerIn, containerOut, { scaleanchor: { @@ -39,7 +39,7 @@ module.exports = function handleConstraintDefaults(containerIn, containerOut, co updateConstraintGroups(constraintGroups, constraintOpts.thisGroup, containerOut._id, scaleanchor, scaleratio); } - else if(counterAxes.indexOf(containerIn.scaleanchor) !== -1) { + 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' + @@ -47,18 +47,20 @@ module.exports = function handleConstraintDefaults(containerIn, containerOut, co } }; -function getConstraintOpts(constraintGroups, thisID, counterAxes, layoutOut) { +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 counterAxes to enforce this, also matching axis types. + // 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 < counterAxes.length; j++) { - idj = counterAxes[j]; + 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); } diff --git a/src/plots/cartesian/dragbox.js b/src/plots/cartesian/dragbox.js index 0f434c68bff..2172369d5d4 100644 --- a/src/plots/cartesian/dragbox.js +++ b/src/plots/cartesian/dragbox.js @@ -659,80 +659,88 @@ 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 = (viewBox[0] !== 0 || viewBox[2] !== pw) && !xa2.fixedrange, - editY = (viewBox[1] !== 0 || viewBox[3] !== ph) && !ya2.fixedrange; - - if(editX) { - // TODO: now that we're doing recomputeAxisLists can this be turned into - // just xa.indexOf(xa2) !== -1? - 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 ? viewBox[2] / xa2._length : 1, - yScaleFactor = editY ? viewBox[3] / ya2._length : 1; - - var clipDx = editX ? viewBox[0] : 0, - clipDy = editY ? viewBox[1] : 0; - - // modify these if needed for linked axes - if(editX && !editY && xaLinked.indexOf(ya2) !== -1) { - yScaleFactor = xScaleFactor; - clipDy = ya2._length * (1 - yScaleFactor) / 2; - - // update range for the linked axis. - ya2.range = ya2._r.slice(); - scaleZoom(ya2, yScaleFactor); + if(editY2) { + yScaleFactor2 = yScaleFactor; + clipDy = viewBox[1]; } - if(editY && !editX && (isSubplotConstrained ? xaLinked : yaLinked).indexOf(xa2) !== -1) { - xScaleFactor = yScaleFactor; - clipDx = xa2._length * (1 - xScaleFactor) / 2; - xa2.range = xa2._r.slice(); - scaleZoom(xa2, xScaleFactor); + else { + yScaleFactor2 = getLinkedScaleFactor(ya2); + clipDy = scaleAndGetShift(ya2, yScaleFactor2); } - var plotDx = xa2._offset - clipDx / xScaleFactor, - plotDy = ya2._offset - clipDy / yScaleFactor; + // don't scale at all if neither axis is scalable here + if(!xScaleFactor2 && !yScaleFactor2) continue; + + // but if only one is, reset the other axis scaling + if(!xScaleFactor2) xScaleFactor2 = 1; + if(!yScaleFactor2) yScaleFactor2 = 1; + + var plotDx = xa2._offset - clipDx / xScaleFactor2, + plotDy = ya2._offset - clipDy / yScaleFactor2; fullLayout._defs.selectAll('#' + subplot.clipId) .call(Drawing.setTranslate, clipDx, clipDy) - .call(Drawing.setScale, xScaleFactor, yScaleFactor); + .call(Drawing.setScale, xScaleFactor2, yScaleFactor2); subplot.plot .call(Drawing.setTranslate, plotDx, plotDy) - .call(Drawing.setScale, 1 / xScaleFactor, 1 / 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, xScaleFactor, yScaleFactor); + .call(Drawing.setPointGroupScale, xScaleFactor2, yScaleFactor2); } } diff --git a/src/plots/cartesian/layout_defaults.js b/src/plots/cartesian/layout_defaults.js index ae436a8eaac..468e685234b 100644 --- a/src/plots/cartesian/layout_defaults.js +++ b/src/plots/cartesian/layout_defaults.js @@ -231,6 +231,7 @@ module.exports = function supplyLayoutDefaults(layoutIn, layoutOut, fullData) { // 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]; @@ -239,6 +240,6 @@ module.exports = function supplyLayoutDefaults(layoutIn, layoutOut, fullData) { axLayoutIn = layoutIn[axName]; axLayoutOut = layoutOut[axName]; - handleConstraintDefaults(axLayoutIn, axLayoutOut, coerce, counterAxes[axLetter], layoutOut); + handleConstraintDefaults(axLayoutIn, axLayoutOut, coerce, allAxisIds, layoutOut); } }; diff --git a/test/image/baselines/axes_scaleanchor.png b/test/image/baselines/axes_scaleanchor.png index 833ceda42cfbf645a38938bc701024e245190a62..d7aa4a6e78fe424e8268d651a2d859852cf4b6f3 100644 GIT binary patch literal 40722 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 literal 25196 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 och0{"}, + "yaxis4": {"scaleanchor": "y3", "domain": [0.55, 1], "anchor": "x2", "title": "Scale matches <-"}, "showlegend": false } } diff --git a/test/jasmine/tests/axes_test.js b/test/jasmine/tests/axes_test.js index f12d4094afd..248ccdc3698 100644 --- a/test/jasmine/tests/axes_test.js +++ b/test/jasmine/tests/axes_test.js @@ -475,18 +475,21 @@ describe('Test axes', function() { layoutIn = { xaxis: {scaleanchor: 'y', scaleratio: 2}, - yaxis: {scaleanchor: 'x', scaleratio: 3}, + 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} + 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}, + {x: 2, y: 1, x4: 2 * 13, y4: 17}, {x2: 5 * 7 * 9, y2: 7 * 9, y3: 1, x3: 9} ]); @@ -507,7 +510,7 @@ describe('Test axes', function() { }); layoutIn = { - xaxis: {scaleanchor: 'x2', scaleratio: 2}, // must be opposite letter + xaxis: {scaleanchor: 'x', scaleratio: 2}, // can't link to itself yaxis: {scaleanchor: 'x4', scaleratio: 3}, // doesn't exist xaxis2: {scaleanchor: 'yaxis', scaleratio: 5} // must be an id, not a name }; From 25c2cfb18969c555eb21c8c2897d30a81fb7d12f Mon Sep 17 00:00:00 2001 From: alexcjohnson Date: Wed, 29 Mar 2017 16:12:09 -0400 Subject: [PATCH 10/16] little test fix --- test/jasmine/tests/axes_test.js | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/test/jasmine/tests/axes_test.js b/test/jasmine/tests/axes_test.js index 248ccdc3698..111d668c28e 100644 --- a/test/jasmine/tests/axes_test.js +++ b/test/jasmine/tests/axes_test.js @@ -467,6 +467,10 @@ describe('Test axes', function() { ]); }); + 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) { @@ -493,10 +497,6 @@ describe('Test axes', function() { {x2: 5 * 7 * 9, y2: 7 * 9, y3: 1, x3: 9} ]); - var warnTxt = ' to avoid either an infinite loop and possibly ' + - 'inconsistent scaleratios, or because the targetaxis has ' + - 'fixed range.'; - expect(warnings).toEqual([ 'ignored yaxis.scaleanchor: "x"' + warnTxt, 'ignored yaxis3.scaleanchor: "x2"' + warnTxt @@ -510,7 +510,7 @@ describe('Test axes', function() { }); layoutIn = { - xaxis: {scaleanchor: 'x', scaleratio: 2}, // can't link to itself + 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 }; @@ -518,7 +518,7 @@ describe('Test axes', function() { supplyLayoutDefaults(layoutIn, layoutOut, fullData); expect(layoutOut._axisConstraintGroups).toEqual([]); - expect(warnings).toEqual([]); + expect(warnings).toEqual(['ignored xaxis.scaleanchor: "x"' + warnTxt]); ['xaxis', 'yaxis', 'xaxis2'].forEach(function(axName) { expect(layoutOut[axName].scaleanchor).toBeUndefined(axName); From 86e0d5a400766f7b2dedec98275cf619422ad869 Mon Sep 17 00:00:00 2001 From: alexcjohnson Date: Thu, 30 Mar 2017 23:10:31 -0400 Subject: [PATCH 11/16] test dynamic axis ranging with and without constraints plus a couple small bugfixes encountered in the process --- src/plot_api/plot_api.js | 8 +- src/plots/cartesian/constraints.js | 12 +- src/plots/cartesian/dragbox.js | 6 + test/image/mocks/axes_scaleanchor.json | 2 +- test/jasmine/assets/double_click.js | 12 + test/jasmine/assets/drag.js | 22 +- test/jasmine/assets/get_node_coords.js | 20 + test/jasmine/assets/mouse_event.js | 2 +- test/jasmine/tests/cartesian_interact_test.js | 434 ++++++++++++++++++ test/jasmine/tests/cartesian_test.js | 43 -- test/jasmine/tests/click_test.js | 4 +- test/jasmine/tests/plot_interact_test.js | 122 +---- 12 files changed, 491 insertions(+), 196 deletions(-) create mode 100644 test/jasmine/assets/get_node_coords.js create mode 100644 test/jasmine/tests/cartesian_interact_test.js diff --git a/src/plot_api/plot_api.js b/src/plot_api/plot_api.js index 9446c6a964c..6cc163b3a8b 100644 --- a/src/plot_api/plot_api.js +++ b/src/plot_api/plot_api.js @@ -153,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 @@ -272,6 +268,10 @@ Plotly.plot = function(gd, data, layout, config) { } 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) diff --git a/src/plots/cartesian/constraints.js b/src/plots/cartesian/constraints.js index 7ddcf34665e..8ef140e58f3 100644 --- a/src/plots/cartesian/constraints.js +++ b/src/plots/cartesian/constraints.js @@ -67,17 +67,7 @@ module.exports = function enforceAxisConstraints(gd) { normScale = normScales[axisID]; if(normScale !== matchScale) { - ax = axes[axisID]; - // if range matches _rangeInitial before the constraint is applied, - // change _rangeInitial to the new range - otherwise a doubleclick - // will never autorange because we're not starting at the reset point. - var wasAtInitial = (ax._rangeInitial && - ax.range[0] === ax._rangeInitial[0] && - ax.range[1] === ax._rangeInitial[1]); - - scaleZoom(ax, normScale / matchScale); - - if(wasAtInitial) ax._rangeInitial = ax.range.slice(); + scaleZoom(axes[axisID], normScale / matchScale); } } } diff --git a/src/plots/cartesian/dragbox.js b/src/plots/cartesian/dragbox.js index 2172369d5d4..3c174224e60 100644 --- a/src/plots/cartesian/dragbox.js +++ b/src/plots/cartesian/dragbox.js @@ -151,6 +151,12 @@ 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') { diff --git a/test/image/mocks/axes_scaleanchor.json b/test/image/mocks/axes_scaleanchor.json index 1d7c01869f9..5c1e5b1a086 100644 --- a/test/image/mocks/axes_scaleanchor.json +++ b/test/image/mocks/axes_scaleanchor.json @@ -12,7 +12,7 @@ "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], "title": "unconstrained log X"}, + "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..6f33828469f 100644 --- a/test/jasmine/assets/mouse_event.js +++ b/test/jasmine/assets/mouse_event.js @@ -14,7 +14,7 @@ module.exports = function(type, x, y, opts) { ev; if(type === 'scroll') { - ev = new window.WheelEvent('wheel', opts); + ev = new window.WheelEvent('wheel', Object.assign({}, fullOpts, opts)); } else { ev = new window.MouseEvent(type, fullOpts); } diff --git a/test/jasmine/tests/cartesian_interact_test.js b/test/jasmine/tests/cartesian_interact_test.js new file mode 100644 index 00000000000..348a4f755cd --- /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: 700, + margin: {l: 100, r: 100, t: 100, b: 100}, + showlegend: false, + xaxis: {domain: [0, 0.4], range: [0, 2]}, + yaxis: {domain: [0, 0.4], 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/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; From 745b9535a06c1c850013a99504c53912f5b30700 Mon Sep 17 00:00:00 2001 From: alexcjohnson Date: Thu, 30 Mar 2017 23:32:01 -0400 Subject: [PATCH 12/16] don't use Object.assign even in tests, just in case --- test/jasmine/assets/mouse_event.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/test/jasmine/assets/mouse_event.js b/test/jasmine/assets/mouse_event.js index 6f33828469f..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', Object.assign({}, fullOpts, opts)); + ev = new window.WheelEvent('wheel', Lib.extendFlat({}, fullOpts, opts)); } else { ev = new window.MouseEvent(type, fullOpts); } From 7fc9cbd31aa273e8021f28701384915266d9ca5e Mon Sep 17 00:00:00 2001 From: alexcjohnson Date: Thu, 30 Mar 2017 23:49:19 -0400 Subject: [PATCH 13/16] squish interaction test plot vertically so it fits on screen --- test/jasmine/tests/cartesian_interact_test.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test/jasmine/tests/cartesian_interact_test.js b/test/jasmine/tests/cartesian_interact_test.js index 348a4f755cd..11106fa89c3 100644 --- a/test/jasmine/tests/cartesian_interact_test.js +++ b/test/jasmine/tests/cartesian_interact_test.js @@ -217,11 +217,11 @@ describe('axis zoom/pan and main plot zoom', function() { var layout = { width: 700, - height: 700, - margin: {l: 100, r: 100, t: 100, b: 100}, + height: 620, + margin: {l: 100, r: 100, t: 20, b: 100}, showlegend: false, xaxis: {domain: [0, 0.4], range: [0, 2]}, - yaxis: {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'}, From ffd65fd05a31c0aef494206b5fa9ac0a6f26b512 Mon Sep 17 00:00:00 2001 From: alexcjohnson Date: Fri, 31 Mar 2017 15:45:26 -0400 Subject: [PATCH 14/16] make sure scaleanchor/scaleratio changes trigger a recalc --- src/plot_api/plot_api.js | 2 ++ test/jasmine/tests/axes_test.js | 45 +++++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+) diff --git a/src/plot_api/plot_api.js b/src/plot_api/plot_api.js index 6cc163b3a8b..3b9df8bfb07 100644 --- a/src/plot_api/plot_api.js +++ b/src/plot_api/plot_api.js @@ -2108,6 +2108,8 @@ function _relayout(gd, aobj) { pp1 === 'type' || pp1 === 'domain' || pp1 === 'fixedrange' || + pp1 === 'scaleanchor' || + pp1 === 'scaleratio' || ai.indexOf('calendar') !== -1 || ai.match(/^(bar|box|font)/)) { flags.docalc = true; diff --git a/test/jasmine/tests/axes_test.js b/test/jasmine/tests/axes_test.js index 111d668c28e..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() { @@ -570,6 +572,49 @@ describe('Test axes', function() { }); }); + 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() { var gd; From b4d11e6f5ed2662a2a5be6ca4a03d3e3dbfcc511 Mon Sep 17 00:00:00 2001 From: alexcjohnson Date: Fri, 31 Mar 2017 20:26:28 -0400 Subject: [PATCH 15/16] make axis constraints work with gl2d --- src/plot_api/plot_api.js | 24 ++-- src/plots/gl2d/camera.js | 121 ++++++++++++++++++-- src/plots/gl2d/scene2d.js | 39 +++++-- test/jasmine/tests/gl_plot_interact_test.js | 111 ++++++++++++++++-- 4 files changed, 259 insertions(+), 36 deletions(-) diff --git a/src/plot_api/plot_api.js b/src/plot_api/plot_api.js index 3b9df8bfb07..c6e8d0e6cb1 100644 --- a/src/plot_api/plot_api.js +++ b/src/plot_api/plot_api.js @@ -2079,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; @@ -2102,18 +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' || - pp1 === 'fixedrange' || - pp1 === 'scaleanchor' || - pp1 === 'scaleratio' || - 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. 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/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() { From 23859e388855f799245c13ba88ec61226df9dc8b Mon Sep 17 00:00:00 2001 From: alexcjohnson Date: Fri, 31 Mar 2017 23:28:18 -0400 Subject: [PATCH 16/16] fix gl2d_click_test --- test/jasmine/tests/gl2d_click_test.js | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) 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); });