From d0af01b66f7ba06fa5559aa70581ff89a14bfcdb Mon Sep 17 00:00:00 2001 From: alexcjohnson Date: Thu, 1 Jun 2017 16:58:58 -0400 Subject: [PATCH 01/22] axis.constrain and axis.constraintoward --- src/constants/alignment.js | 32 ++++++ src/plot_api/plot_api.js | 63 ++++++++--- src/plots/cartesian/axes.js | 7 ++ src/plots/cartesian/constraint_defaults.js | 21 +++- src/plots/cartesian/constraints.js | 116 ++++++++++++++++++++- src/plots/cartesian/dragbox.js | 13 ++- src/plots/cartesian/layout_attributes.js | 31 +++++- src/plots/cartesian/scale_zoom.js | 11 +- 8 files changed, 265 insertions(+), 29 deletions(-) create mode 100644 src/constants/alignment.js diff --git a/src/constants/alignment.js b/src/constants/alignment.js new file mode 100644 index 00000000000..66ce5bb0121 --- /dev/null +++ b/src/constants/alignment.js @@ -0,0 +1,32 @@ +/** +* 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'; + +// fraction of some size to get to a named position +module.exports = { + // from bottom left: this is the origin of our paper-reference + // positioning system + FROM_BL: { + left: 0, + center: 0.5, + right: 1, + bottom: 0, + middle: 0.5, + top: 1 + }, + // from top left: this is the screen pixel positioning origin + FROM_TL: { + left: 0, + center: 0.5, + right: 1, + bottom: 1, + middle: 0.5, + top: 0 + } +}; diff --git a/src/plot_api/plot_api.js b/src/plot_api/plot_api.js index 1643b821025..fa65449efae 100644 --- a/src/plot_api/plot_api.js +++ b/src/plot_api/plot_api.js @@ -190,8 +190,7 @@ Plotly.plot = function(gd, data, layout, config) { return Lib.syncOrAsync([ subroutines.layoutStyles, - drawAxes, - initInteractions + drawAxes ], gd); } @@ -220,19 +219,19 @@ Plotly.plot = function(gd, data, layout, config) { // in case the margins changed, draw margin pushers again function marginPushersAgain() { - var seq = JSON.stringify(fullLayout._size) === oldmargins ? - [] : - [marginPushers, subroutines.layoutStyles]; + if(JSON.stringify(fullLayout._size) === oldmargins) return; - // re-initialize cartesian interaction, - // which are sometimes cleared during marginPushers - seq = seq.concat(initInteractions); - - return Lib.syncOrAsync(seq, gd); + return Lib.syncOrAsync([ + marginPushers, + subroutines.layoutStyles + ], gd); } function positionAndAutorange() { - if(!recalc) return; + if(!recalc) { + enforceAxisConstraints(gd); + return; + } var subplots = Plots.getSubplotIds(fullLayout, 'cartesian'), modules = fullLayout._modules; @@ -270,7 +269,26 @@ Plotly.plot = function(gd, data, layout, config) { var axList = Plotly.Axes.list(gd, '', true); for(var i = 0; i < axList.length; i++) { - Plotly.Axes.doAutoRange(axList[i]); + // before autoranging, check if this axis was previously constrained + // by domain but no longer is + var ax = axList[i]; + if(ax._inputDomain) { + var isConstrained = false; + var axId = ax._id; + var constraintGroups = gd._fullLayout._axisConstraintGroups; + for(var j = 0; j < constraintGroups.length; j++) { + if(constraintGroups[j][axId]) { + isConstrained = true; + break; + } + } + if(!isConstrained || ax.constrain !== 'domain') { + ax._input.domain = ax.domain = ax._inputDomain; + delete ax._inputDomain; + } + } + + Plotly.Axes.doAutoRange(ax); } enforceAxisConstraints(gd); @@ -370,6 +388,7 @@ Plotly.plot = function(gd, data, layout, config) { drawAxes, drawData, finalDraw, + initInteractions, Plots.rehover ]; @@ -1917,10 +1936,12 @@ function _relayout(gd, aobj) { // 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 = {}; + var axId; function recordAlteredAxis(pleafPlus) { var axId = axisIds.name2id(pleafPlus.split('.')[0]); rangesAltered[axId] = 1; + return axId; } // alter gd.layout @@ -1963,11 +1984,26 @@ function _relayout(gd, aobj) { else if(pleafPlus.match(/^[xyz]axis[0-9]*\.range(\[[0|1]\])?$/)) { doextra(ptrunk + '.autorange', false); recordAlteredAxis(pleafPlus); + Lib.nestedProperty(fullLayout, ptrunk + '._inputRange').set(null); } else if(pleafPlus.match(/^[xyz]axis[0-9]*\.autorange$/)) { doextra([ptrunk + '.range[0]', ptrunk + '.range[1]'], undefined); recordAlteredAxis(pleafPlus); + Lib.nestedProperty(fullLayout, ptrunk + '._inputRange').set(null); + var axFull = Lib.nestedProperty(fullLayout, ptrunk).get(); + if(axFull._inputDomain) { + // if we're autoranging and this axis has a constrained domain, + // reset it so we don't get locked into a shrunken size + axFull._input.domain = axFull._inputDomain.slice(); + } + } + else if(pleafPlus.match(/^[xyz]axis[0-9]*\.domain(\[[0|1]\])?$/)) { + axId = recordAlteredAxis(pleafPlus); + Lib.nestedProperty(fullLayout, ptrunk + '._inputDomain').set(null); + } + else if(pleafPlus.match(/^[xyz]axis[0-9]*\.constrain.*$/)) { + flags.docalc = true; } else if(pleafPlus.match(/^aspectratio\.[xyz]$/)) { doextra(proot + '.aspectmode', 'manual'); @@ -2047,6 +2083,7 @@ function _relayout(gd, aobj) { // will not make sense, so autorange it. doextra(ptrunk + '.autorange', true); } + Lib.nestedProperty(fullLayout, ptrunk + '._inputRange').set(null); } else if(pleaf.match(cartesianConstants.AX_NAME_PATTERN)) { var fullProp = Lib.nestedProperty(fullLayout, ai).get(), @@ -2193,7 +2230,7 @@ function _relayout(gd, aobj) { // figure out if we need to recalculate axis constraints var constraints = fullLayout._axisConstraintGroups; - for(var axId in rangesAltered) { + for(axId in rangesAltered) { for(i = 0; i < constraints.length; i++) { var group = constraints[i]; if(group[axId]) { diff --git a/src/plots/cartesian/axes.js b/src/plots/cartesian/axes.js index f2560c08f12..86e60b16b56 100644 --- a/src/plots/cartesian/axes.js +++ b/src/plots/cartesian/axes.js @@ -455,6 +455,13 @@ axes.expand = function(ax, data, options) { i, j, v, di, dmin, dmax, ppadiplus, ppadiminus, includeThis, vmin, vmax; + // domain-constrained axes: base extrappad on the unconstrained + // domain so it's consistent as the domain changes + if(extrappad && ax._inputDomain) { + extrappad *= (ax._inputDomain[1] - ax._inputDomain[0]) / + (ax.domain[1] - ax.domain[0]); + } + function getPad(item) { if(Array.isArray(item)) { return function(i) { return Math.max(Number(item[i]||0), 0); }; diff --git a/src/plots/cartesian/constraint_defaults.js b/src/plots/cartesian/constraint_defaults.js index 5224676dfe2..faf1e600a36 100644 --- a/src/plots/cartesian/constraint_defaults.js +++ b/src/plots/cartesian/constraint_defaults.js @@ -15,10 +15,25 @@ var id2name = require('./axis_ids').id2name; module.exports = function handleConstraintDefaults(containerIn, containerOut, coerce, allAxisIds, layoutOut) { var constraintGroups = layoutOut._axisConstraintGroups; + var thisID = containerOut._id; + var letter = thisID.charAt(0); - if(containerOut.fixedrange || !containerIn.scaleanchor) return; + if(containerOut.fixedrange) return; - var constraintOpts = getConstraintOpts(constraintGroups, containerOut._id, allAxisIds, layoutOut); + // coerce the constraint mechanics even if this axis has no scaleanchor + // because it may be the anchor of another axis. + coerce('constrain'); + Lib.coerce(containerIn, containerOut, { + constraintoward: { + valType: 'enumerated', + values: letter === 'x' ? ['left', 'center', 'right'] : ['bottom', 'middle', 'top'], + dflt: letter === 'x' ? 'center' : 'middle' + } + }, 'constraintoward'); + + if(!containerIn.scaleanchor) return; + + var constraintOpts = getConstraintOpts(constraintGroups, thisID, allAxisIds, layoutOut); var scaleanchor = Lib.coerce(containerIn, containerOut, { scaleanchor: { @@ -37,7 +52,7 @@ module.exports = function handleConstraintDefaults(containerIn, containerOut, co if(!scaleratio) scaleratio = containerOut.scaleratio = 1; updateConstraintGroups(constraintGroups, constraintOpts.thisGroup, - containerOut._id, scaleanchor, scaleratio); + thisID, scaleanchor, scaleratio); } else if(allAxisIds.indexOf(containerIn.scaleanchor) !== -1) { Lib.warn('ignored ' + containerOut._name + '.scaleanchor: "' + diff --git a/src/plots/cartesian/constraints.js b/src/plots/cartesian/constraints.js index 8ef140e58f3..7ffbca53f0e 100644 --- a/src/plots/cartesian/constraints.js +++ b/src/plots/cartesian/constraints.js @@ -14,12 +14,14 @@ var scaleZoom = require('./scale_zoom'); var ALMOST_EQUAL = require('../../constants/numerical').ALMOST_EQUAL; +var FROM_BL = require('../../constants/alignment').FROM_BL; + module.exports = function enforceAxisConstraints(gd) { var fullLayout = gd._fullLayout; var constraintGroups = fullLayout._axisConstraintGroups; - var i, j, axisID, ax, normScale; + var i, j, axisID, ax, normScale, mode, factor; for(i = 0; i < constraintGroups.length; i++) { var group = constraintGroups[i]; @@ -41,6 +43,9 @@ module.exports = function enforceAxisConstraints(gd) { axisID = axisIDs[j]; axes[axisID] = ax = fullLayout[id2name(axisID)]; + if(!ax._inputDomain) ax._inputDomain = ax.domain.slice(); + if(!ax._inputRange) ax._inputRange = ax.range.slice(); + // set axis scale here so we can use _m rather than // having to calculate it from length and range ax.setScale(); @@ -65,10 +70,115 @@ module.exports = function enforceAxisConstraints(gd) { for(j = 0; j < axisIDs.length; j++) { axisID = axisIDs[j]; normScale = normScales[axisID]; + ax = axes[axisID]; + mode = ax.constrain; + + // even if the scale didn't change, if we're shrinking domain + // we need to recalculate in case `constraintoward` changed + if(normScale !== matchScale || mode === 'domain') { + factor = normScale / matchScale; + + if(mode === 'range') { + scaleZoom(ax, factor); + } + else { + // mode === 'domain' + + var inputDomain = ax._inputDomain; + var domainShrunk = (ax.domain[1] - ax.domain[0]) / + (inputDomain[1] - inputDomain[0]); + var rangeShrunk = (ax.r2l(ax.range[1]) - ax.r2l(ax.range[0])) / + (ax.r2l(ax._inputRange[1]) - ax.r2l(ax._inputRange[0])); + + factor /= domainShrunk; + + if(factor * rangeShrunk < 1) { + // we've asked to magnify the axis more than we can just by + // enlarging the domain - so we need to constrict range + ax.domain = ax._input.domain = inputDomain.slice(); + scaleZoom(ax, factor); + continue; + } + + if(rangeShrunk < 1) { + // the range has previously been constricted by ^^, but we've + // switched to the domain-constricted regime, so reset range + ax.range = ax._input.range = ax._inputRange.slice(); + factor *= rangeShrunk; + } - if(normScale !== matchScale) { - scaleZoom(axes[axisID], normScale / matchScale); + // TODO + if(ax.autorange) { + /* + * range & factor may need to change because range was + * calculated for the larger scaling, so some pixel + * paddings may get cut off when we reduce the domain. + * + * This is easier than the regular autorange calculation + * because we already know the scaling `m`, but we still + * need to cut out impossible constraints (like + * annotations with super-long arrows). That's what + * outerMin/Max are for - if the expansion was going to + * go beyond the original domain, it must be impossible + */ + var rangeMin = Math.min(ax.range[0], ax.range[1]); + var rangeMax = Math.max(ax.range[0], ax.range[1]); + var rangeCenter = (rangeMin + rangeMax) / 2; + var halfRange = rangeMax - rangeCenter; + var outerMin = rangeCenter - halfRange * factor; + var outerMax = rangeCenter + halfRange * factor; + + updateDomain(ax, factor); + ax.setScale(); + var m = Math.abs(ax._m); + var newVal; + var k; + + for(k = 0; k < ax._min.length; k++) { + newVal = ax._min[i].val - ax._min[i].pad / m; + if(newVal > outerMin && newVal < rangeMin) { + rangeMin = newVal; + } + } + + for(k = 0; k < ax._max.length; k++) { + newVal = ax._max[i].val + ax._max[i].pad / m; + if(newVal < outerMax && newVal > rangeMax) { + rangeMax = newVal; + } + } + + ax.range = ax._input.range = (ax.range[0] < ax.range[1]) ? + [rangeMin, rangeMax] : [rangeMax, rangeMin]; + + /* + * In principle this new range can be shifted vs. what + * you saw at the end of a zoom operation, like if you + * have a big bubble on one side and a small bubble on + * the other. + * To fix this we'd have to be doing this calculation + * continuously during the zoom, but it's enough of an + * edge case and a subtle enough effect that I'm going + * to ignore it for now. + */ + var domainExpand = (rangeMax - rangeMin) / (2 * halfRange); + factor /= domainExpand; + } + + updateDomain(ax, factor); + } } } } }; + +function updateDomain(ax, factor) { + var inputDomain = ax._inputDomain; + var centerFraction = FROM_BL[ax.constraintoward]; + var center = inputDomain[0] + (inputDomain[1] - inputDomain[0]) * centerFraction; + + ax.domain = ax._input.domain = [ + center + (inputDomain[0] - center) / factor, + center + (inputDomain[1] - center) / factor + ]; +} diff --git a/src/plots/cartesian/dragbox.js b/src/plots/cartesian/dragbox.js index df0d64511c9..eb3fc4015fd 100644 --- a/src/plots/cartesian/dragbox.js +++ b/src/plots/cartesian/dragbox.js @@ -20,6 +20,7 @@ var Color = require('../../components/color'); var Drawing = require('../../components/drawing'); var setCursor = require('../../lib/setcursor'); var dragElement = require('../../components/dragelement'); +var FROM_TL = require('../../constants/alignment').FROM_TL; var doTicks = require('./axes').doTicks; var getFromId = require('./axis_ids').getFromId; @@ -692,11 +693,15 @@ module.exports = function dragBox(gd, plotinfo, x, y, w, h, ns, ew) { if(scaleFactor) { ax.range = ax._r.slice(); scaleZoom(ax, scaleFactor); - return ax._length * (1 - scaleFactor) / 2; + return getShift(ax, scaleFactor); } return 0; } + function getShift(ax, scaleFactor) { + return ax._length * (1 - scaleFactor) * FROM_TL[ax.constraintoward || 'middle']; + } + for(i = 0; i < subplots.length; i++) { var subplot = plotinfos[subplots[i]], @@ -707,7 +712,7 @@ module.exports = function dragBox(gd, plotinfo, x, y, w, h, ns, ew) { if(editX2) { xScaleFactor2 = xScaleFactor; - clipDx = viewBox[0]; + clipDx = ew ? viewBox[0] : getShift(xa2, xScaleFactor2); } else { xScaleFactor2 = getLinkedScaleFactor(xa2); @@ -716,7 +721,7 @@ module.exports = function dragBox(gd, plotinfo, x, y, w, h, ns, ew) { if(editY2) { yScaleFactor2 = yScaleFactor; - clipDy = viewBox[1]; + clipDy = ns ? viewBox[1] : getShift(ya2, yScaleFactor2); } else { yScaleFactor2 = getLinkedScaleFactor(ya2); @@ -733,6 +738,8 @@ module.exports = function dragBox(gd, plotinfo, x, y, w, h, ns, ew) { var plotDx = xa2._offset - clipDx / xScaleFactor2, plotDy = ya2._offset - clipDy / yScaleFactor2; + // console.log(subplot.id, editX2, editY2, xScaleFactor2, yScaleFactor2, clipDx, clipDy, plotDx, plotDy); + fullLayout._defs.selectAll('#' + subplot.clipId) .call(Drawing.setTranslate, clipDx, clipDy) .call(Drawing.setScale, xScaleFactor2, yScaleFactor2); diff --git a/src/plots/cartesian/layout_attributes.js b/src/plots/cartesian/layout_attributes.js index b8a61414063..ef75a5e91c5 100644 --- a/src/plots/cartesian/layout_attributes.js +++ b/src/plots/cartesian/layout_attributes.js @@ -127,14 +127,16 @@ module.exports = { ], 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.', + 'If set to another axis id (e.g. `x2`, `y`), the range of this axis', + 'changes together with the range of the corresponding 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.', + '`constrain` and `constraintoward` determine how we enforce the constraint.', 'You can chain these, ie `yaxis: {scaleanchor: *x*}, xaxis2: {scaleanchor: *y*}`', 'but you can only link axes of the same `type`.', + 'The linked axis can have the opposite letter (to constrain the aspect ratio)', + 'or the same letter (to match scales across subplots).', 'Loops (`yaxis: {scaleanchor: *x*}, xaxis: {scaleanchor: *y*}` or longer) are redundant', 'and the last constraint encountered will be ignored to avoid possible', 'inconsistent constraints via `scaleratio`.' @@ -153,6 +155,29 @@ module.exports = { 'is exaggerated a fixed amount with respect to the horizontal.' ].join(' ') }, + constrain: { + valType: 'enumerated', + values: ['range', 'domain'], + dflt: 'range', + role: 'info', + description: [ + 'If this axis needs to be compressed (either due to its own `scaleanchor` and', + '`scaleratio` or those of the other axis), determines how that happens:', + 'by increasing the *range* (default), or by decreasing the *domain*.' + ].join(' ') + }, + // constraintoward: not used directly, just put here for reference + constraintoward: { + valType: 'enumerated', + values: ['left', 'center', 'right', 'top', 'middle', 'bottom'], + role: 'info', + description: [ + 'If this axis needs to be compressed (either due to its own `scaleanchor` and', + '`scaleratio` or those of the other axis), determines which direction we push', + 'the originally specified plot area. Options are *left*, *center* (default),', + 'and *right* for x axes, and *top*, *middle* (default), and *bottom* for y axes.' + ].join(' ') + }, // ticks tickmode: { valType: 'enumerated', diff --git a/src/plots/cartesian/scale_zoom.js b/src/plots/cartesian/scale_zoom.js index 7669f742301..d8777ff1ee0 100644 --- a/src/plots/cartesian/scale_zoom.js +++ b/src/plots/cartesian/scale_zoom.js @@ -9,15 +9,18 @@ 'use strict'; +var FROM_BL = require('../../constants/alignment').FROM_BL; + module.exports = function scaleZoom(ax, factor, centerFraction) { - if(centerFraction === undefined) centerFraction = 0.5; + if(centerFraction === undefined) { + centerFraction = FROM_BL[ax.constraintoward || 'center']; + } 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) + ax.l2r(center + (rangeLinear[0] - center) * factor), + ax.l2r(center + (rangeLinear[1] - center) * factor) ]; }; From cc705c3030fed68add315971c0bec6d83b58bc19 Mon Sep 17 00:00:00 2001 From: alexcjohnson Date: Thu, 1 Jun 2017 17:03:43 -0400 Subject: [PATCH 02/22] robustify plot_test --- test/jasmine/tests/plots_test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/jasmine/tests/plots_test.js b/test/jasmine/tests/plots_test.js index 4634488c94e..9ecb2f15d6a 100644 --- a/test/jasmine/tests/plots_test.js +++ b/test/jasmine/tests/plots_test.js @@ -415,7 +415,7 @@ describe('Test Plots', function() { ]; Plots.purge(gd); - expect(Object.keys(gd)).toEqual(expectedKeys); + expect(Object.keys(gd).sort()).toEqual(expectedKeys.sort()); expect(gd.data).toBeUndefined(); expect(gd.layout).toBeUndefined(); expect(gd._fullData).toBeUndefined(); From b3a3bb7e05976fa7d6918ea8578256364521ee4f Mon Sep 17 00:00:00 2001 From: alexcjohnson Date: Thu, 1 Jun 2017 17:07:57 -0400 Subject: [PATCH 03/22] simplify plot_test --- test/jasmine/tests/plots_test.js | 33 +++++++++++--------------------- 1 file changed, 11 insertions(+), 22 deletions(-) diff --git a/test/jasmine/tests/plots_test.js b/test/jasmine/tests/plots_test.js index 9ecb2f15d6a..b333989aeae 100644 --- a/test/jasmine/tests/plots_test.js +++ b/test/jasmine/tests/plots_test.js @@ -414,30 +414,19 @@ describe('Test Plots', function() { '_hmpixcount', '_hmlumcount', '_mouseDownTime', '_legendMouseDownTime', ]; + var expectedUndefined = [ + 'data', 'layout', '_fullData', '_fullLayout', 'calcdata', 'framework', + 'empty', 'fid', 'undoqueue', 'undonum', 'autoplay', 'changed', + '_promises', '_redrawTimer', 'firstscatter', 'hmlumcount', 'hmpixcount', + 'numboxes', '_hoverTimer', '_lastHoverTime', '_transitionData', + '_transitioning' + ]; + Plots.purge(gd); expect(Object.keys(gd).sort()).toEqual(expectedKeys.sort()); - expect(gd.data).toBeUndefined(); - expect(gd.layout).toBeUndefined(); - expect(gd._fullData).toBeUndefined(); - expect(gd._fullLayout).toBeUndefined(); - expect(gd.calcdata).toBeUndefined(); - expect(gd.framework).toBeUndefined(); - expect(gd.empty).toBeUndefined(); - expect(gd.fid).toBeUndefined(); - expect(gd.undoqueue).toBeUndefined(); - expect(gd.undonum).toBeUndefined(); - expect(gd.autoplay).toBeUndefined(); - expect(gd.changed).toBeUndefined(); - expect(gd._promises).toBeUndefined(); - expect(gd._redrawTimer).toBeUndefined(); - expect(gd.firstscatter).toBeUndefined(); - expect(gd.hmlumcount).toBeUndefined(); - expect(gd.hmpixcount).toBeUndefined(); - expect(gd.numboxes).toBeUndefined(); - expect(gd._hoverTimer).toBeUndefined(); - expect(gd._lastHoverTime).toBeUndefined(); - expect(gd._transitionData).toBeUndefined(); - expect(gd._transitioning).toBeUndefined(); + expectedUndefined.forEach(function(key) { + expect(gd[key]).toBeUndefined(key); + }); }); }); From 27fc2a95d488f5ef52b26f4c6b5687c542dac47c Mon Sep 17 00:00:00 2001 From: alexcjohnson Date: Thu, 1 Jun 2017 17:18:04 -0400 Subject: [PATCH 04/22] robustify plot_api_test --- test/jasmine/tests/plot_api_test.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test/jasmine/tests/plot_api_test.js b/test/jasmine/tests/plot_api_test.js index dee0dc7a315..95418c57349 100644 --- a/test/jasmine/tests/plot_api_test.js +++ b/test/jasmine/tests/plot_api_test.js @@ -309,21 +309,21 @@ describe('Test plot api', function() { }] }) .then(function() { - expect(getAnnotationPos()).toBeCloseToArray([247.5, 210.1]); + expect(getAnnotationPos()).toBeCloseToArray([247.5, 210.1], -0.5); expect(getShapePos()).toBeCloseToArray([350, 369]); expect(getImagePos()).toBeCloseToArray([170, 272.52]); return Plotly.relayout(gd, 'xaxis.range', [0, 2]); }) .then(function() { - expect(getAnnotationPos()).toBeCloseToArray([337.5, 210.1]); + expect(getAnnotationPos()).toBeCloseToArray([337.5, 210.1], -0.5); expect(getShapePos()).toBeCloseToArray([620, 369]); expect(getImagePos()).toBeCloseToArray([80, 272.52]); return Plotly.relayout(gd, 'xaxis.range', [-1, 5]); }) .then(function() { - expect(getAnnotationPos()).toBeCloseToArray([247.5, 210.1]); + expect(getAnnotationPos()).toBeCloseToArray([247.5, 210.1], -0.5); expect(getShapePos()).toBeCloseToArray([350, 369]); expect(getImagePos()).toBeCloseToArray([170, 272.52]); }) From d937732c436a630b78e15ba07402b9171732d20c Mon Sep 17 00:00:00 2001 From: alexcjohnson Date: Thu, 1 Jun 2017 23:54:26 -0400 Subject: [PATCH 05/22] simplify hover_label_test --- test/jasmine/tests/hover_label_test.js | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/test/jasmine/tests/hover_label_test.js b/test/jasmine/tests/hover_label_test.js index 00b99793c63..5a723bc871f 100644 --- a/test/jasmine/tests/hover_label_test.js +++ b/test/jasmine/tests/hover_label_test.js @@ -754,6 +754,7 @@ describe('hover info on overlaid subplots', function() { describe('hover after resizing', function() { 'use strict'; + var gd; afterEach(destroyGraphDiv); function _click(pos) { @@ -767,22 +768,17 @@ describe('hover after resizing', function() { } function assertLabelCount(pos, cnt, msg) { - return new Promise(function(resolve) { - mouseEvent('mousemove', pos[0], pos[1]); - - setTimeout(function() { - var hoverText = d3.selectAll('g.hovertext'); - expect(hoverText.size()).toEqual(cnt, msg); + delete gd._lastHoverTime; + mouseEvent('mousemove', pos[0], pos[1]); - resolve(); - }, HOVERMINTIME); - }); + var hoverText = d3.selectAll('g.hovertext'); + expect(hoverText.size()).toBe(cnt, msg); } it('should work', function(done) { var data = [{ y: [2, 1, 2] }], - layout = { width: 600, height: 500 }, - gd = createGraphDiv(); + layout = { width: 600, height: 500 }; + gd = createGraphDiv(); var pos0 = [305, 403], pos1 = [401, 122]; From 1570274c426dfda4cf3e57c312d7fa14b531a696 Mon Sep 17 00:00:00 2001 From: alexcjohnson Date: Fri, 2 Jun 2017 00:02:58 -0400 Subject: [PATCH 06/22] no really, initInteractions! fix #1044... again I don't understand why we need to initInteractions twice, but it seems necessary to do both before and after finalDraw --- src/plot_api/plot_api.js | 1 + 1 file changed, 1 insertion(+) diff --git a/src/plot_api/plot_api.js b/src/plot_api/plot_api.js index fa65449efae..f2a075bb208 100644 --- a/src/plot_api/plot_api.js +++ b/src/plot_api/plot_api.js @@ -383,6 +383,7 @@ Plotly.plot = function(gd, data, layout, config) { drawFramework, marginPushers, marginPushersAgain, + initInteractions, positionAndAutorange, subroutines.layoutStyles, drawAxes, From 72896ecf58821fd4f4ed23a42ee0bd2fb3f8d08d Mon Sep 17 00:00:00 2001 From: alexcjohnson Date: Fri, 2 Jun 2017 00:19:02 -0400 Subject: [PATCH 07/22] robustify drawing_test --- test/jasmine/tests/drawing_test.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/test/jasmine/tests/drawing_test.js b/test/jasmine/tests/drawing_test.js index 810dbbfefe1..fb5a283ea78 100644 --- a/test/jasmine/tests/drawing_test.js +++ b/test/jasmine/tests/drawing_test.js @@ -355,11 +355,11 @@ describe('Drawing', function() { afterEach(destroyGraphDiv); function assertBBox(actual, expected) { - expect(actual.height).toEqual(expected.height, 'height'); - expect(actual.top).toEqual(expected.top, 'top'); - expect(actual.bottom).toEqual(expected.bottom, 'bottom'); - var TOL = 3; + expect(actual.height).toBeWithin(expected.height, TOL, 'height'); + expect(actual.top).toBeWithin(expected.top, TOL, 'top'); + expect(actual.bottom).toBeWithin(expected.bottom, TOL, 'bottom'); + expect(actual.width).toBeWithin(expected.width, TOL, 'width'); expect(actual.left).toBeWithin(expected.left, TOL, 'left'); expect(actual.right).toBeWithin(expected.right, TOL, 'right'); From ee3211a1c22e66e017498a07097d4807cd5b10ec Mon Sep 17 00:00:00 2001 From: alexcjohnson Date: Fri, 2 Jun 2017 00:39:35 -0400 Subject: [PATCH 08/22] robustify gl_plot_interact_test standardize on assets/delay and increase the updateCamera delay --- test/jasmine/tests/gl_plot_interact_test.js | 58 ++++++++------------- 1 file changed, 23 insertions(+), 35 deletions(-) diff --git a/test/jasmine/tests/gl_plot_interact_test.js b/test/jasmine/tests/gl_plot_interact_test.js index 18eb06b8358..de8df21bae8 100644 --- a/test/jasmine/tests/gl_plot_interact_test.js +++ b/test/jasmine/tests/gl_plot_interact_test.js @@ -11,20 +11,7 @@ var fail = require('../assets/fail_test'); var mouseEvent = require('../assets/mouse_event'); var selectButton = require('../assets/modebar_button'); var customMatchers = require('../assets/custom_matchers'); - -// useful to put callback in the event queue -function delay() { - return new Promise(function(resolve) { - setTimeout(resolve, 20); - }); -} - -// updating the camera requires some waiting -function waitForCamera() { - return new Promise(function(resolve) { - setTimeout(resolve, 200); - }); -} +var delay = require('../assets/delay'); function countCanvases() { return d3.selectAll('canvas').size(); @@ -100,18 +87,18 @@ describe('Test gl3d plots', function() { function _hover() { mouseEvent('mouseover', 605, 271); - return delay(); + return delay(20)(); } Plotly.plot(gd, _mock) - .then(delay) + .then(delay(20)) .then(function() { gd.on('plotly_hover', function(eventData) { ptData = eventData.points[0]; }); }) .then(_hover) - .then(delay) + .then(delay(20)) .then(function() { assertHoverText('x: 140.72', 'y: −96.97', 'z: −96.97'); assertEventData(140.72, -96.97, -96.97, 0, 2); @@ -205,18 +192,18 @@ describe('Test gl3d plots', function() { function _hover() { mouseEvent('mouseover', 605, 271); - return delay(); + return delay(20)(); } Plotly.plot(gd, _mock) - .then(delay) + .then(delay(20)) .then(function() { gd.on('plotly_hover', function(eventData) { ptData = eventData.points[0]; }); }) .then(_hover) - .then(delay) + .then(delay(20)) .then(function() { assertHoverText('x: 1', 'y: 2', 'z: 43', 'one two'); assertEventData(1, 2, 43, 0, [1, 2]); @@ -256,18 +243,18 @@ describe('Test gl3d plots', function() { // with button 1 pressed function _click() { mouseEvent('mouseover', 605, 271, {buttons: 1}); - return delay(); + return delay(20)(); } Plotly.plot(gd, _mock) - .then(delay) + .then(delay(20)) .then(function() { gd.on('plotly_click', function(eventData) { ptData = eventData.points[0]; }); }) .then(_click) - .then(delay) + .then(delay(20)) .then(function() { assertEventData(140.72, -96.97, -96.97, 0, 2); }) @@ -279,7 +266,7 @@ describe('Test gl3d plots', function() { var sceneLayout = { aspectratio: { x: 1, y: 1, z: 1 } }; Plotly.plot(gd, _mock) - .then(delay) + .then(delay(20)) .then(function() { expect(countCanvases()).toEqual(1); expect(gd.layout.scene).toEqual(sceneLayout); @@ -316,7 +303,7 @@ describe('Test gl3d plots', function() { var _mock = Lib.extendDeep({}, mock2); Plotly.plot(gd, _mock) - .then(delay) + .then(delay(20)) .then(function() { return Plotly.deleteTraces(gd, [0]); }) @@ -355,7 +342,7 @@ describe('Test gl3d plots', function() { } Plotly.plot(gd, _mock) - .then(delay) + .then(delay(20)) .then(function() { assertObjects(order0); @@ -436,7 +423,7 @@ describe('Test gl3d modebar handlers', function() { }; Plotly.plot(gd, mock) - .then(delay) + .then(delay(20)) .then(function() { modeBar = gd._fullLayout._modeBar; }) @@ -660,7 +647,7 @@ describe('Test gl3d drag and wheel interactions', function() { }; Plotly.plot(gd, mock) - .then(delay) + .then(delay(20)) .then(function() { relayoutCallback = jasmine.createSpy('relayoutCallback'); gd.on('plotly_relayout', relayoutCallback); @@ -846,7 +833,7 @@ describe('Test gl2d plots', function() { var precision = 5; Plotly.plot(gd, _mock) - .then(delay) + .then(delay(20)) .then(function() { expect(gd.layout.xaxis.autorange).toBe(true); expect(gd.layout.yaxis.autorange).toBe(true); @@ -863,7 +850,7 @@ describe('Test gl2d plots', function() { expect(gd.layout.xaxis.range).toBeCloseToArray(originalX, precision); expect(gd.layout.yaxis.range).toBeCloseToArray(originalY, precision); }) - .then(waitForCamera) + .then(delay(200)) .then(function() { gd.on('plotly_relayout', relayoutCallback); @@ -906,7 +893,7 @@ describe('Test gl2d plots', function() { expect(gd.layout.xaxis.range).toBeCloseToArray(originalX, precision); expect(gd.layout.yaxis.range).toBeCloseToArray(originalY, precision); }) - .then(waitForCamera) + .then(delay(200)) .then(function() { // callback count expectation: X and back; Y and back; XY and back expect(relayoutCallback).toHaveBeenCalledTimes(6); @@ -932,7 +919,7 @@ describe('Test gl2d plots', function() { }; Plotly.plot(gd, _mock) - .then(delay) + .then(delay(20)) .then(function() { expect(objects().length).toEqual(OBJECT_PER_TRACE); @@ -1384,7 +1371,7 @@ describe('Test gl3d annotations', function() { } function assertAnnotationsXY(expectations, msg) { - var TOL = 1.5; + var TOL = 2.5; var anns = d3.selectAll('g.annotation-text-g'); expect(anns.size()).toBe(expectations.length, msg); @@ -1405,6 +1392,9 @@ describe('Test gl3d annotations', function() { camera.eye = {x: x, y: y, z: z}; scene.setCamera(camera); + // need a fairly long delay to let the camera update here + // 200 was not robust for me (AJ), 300 seems to be. + return delay(300)(); } it('should move with camera', function(done) { @@ -1433,13 +1423,11 @@ describe('Test gl3d annotations', function() { return updateCamera(1.5, 2.5, 1.5); }) - .then(waitForCamera) .then(function() { assertAnnotationsXY([[340, 187], [341, 142], [325, 221]], 'after camera update'); return updateCamera(2.1, 0.1, 0.9); }) - .then(waitForCamera) .then(function() { assertAnnotationsXY([[262, 199], [257, 135], [325, 233]], 'base 0'); }) From f12ff7394e375df8d32065333aed57429d622302 Mon Sep 17 00:00:00 2001 From: alexcjohnson Date: Fri, 2 Jun 2017 01:11:58 -0400 Subject: [PATCH 09/22] streamline gl2d_click_test --- test/jasmine/tests/gl2d_click_test.js | 81 +++++++++++++++------------ 1 file changed, 46 insertions(+), 35 deletions(-) diff --git a/test/jasmine/tests/gl2d_click_test.js b/test/jasmine/tests/gl2d_click_test.js index 95b7ee5f6f1..c95a8d498ff 100644 --- a/test/jasmine/tests/gl2d_click_test.js +++ b/test/jasmine/tests/gl2d_click_test.js @@ -12,6 +12,7 @@ var fail = require('../assets/fail_test.js'); // a click event on mouseup var click = require('../assets/timed_click'); var hover = require('../assets/hover'); +var delay = require('../assets/delay'); // contourgl is not part of the dist plotly.js bundle initially Plotly.register([ @@ -62,13 +63,6 @@ var mock4 = { describe('Test hover and click interactions', function() { var gd; - // need to wait a little bit before canvas can properly catch mouse events - function wait() { - return new Promise(function(resolve) { - setTimeout(resolve, 100); - }); - } - function makeHoverFn(gd, x, y) { return function() { return new Promise(function(resolve) { @@ -90,26 +84,27 @@ describe('Test hover and click interactions', function() { function makeUnhoverFn(gd, x0, y0) { return function() { return new Promise(function(resolve) { - var eventData = null; - - gd.on('plotly_unhover', function() { - eventData = 'emitted plotly_unhover'; - }); - // fairly realistic simulation of moving with the cursor var canceler = setInterval(function() { - hover(x0--, y0--); + x0 -= 2; + y0 -= 2; + hover(x0, y0); }, 10); + gd.on('plotly_unhover', function() { + clearInterval(canceler); + resolve('emitted plotly_unhover'); + }); + setTimeout(function() { clearInterval(canceler); - resolve(eventData); + resolve(null); }, 350); }); }; } - function assertEventData(actual, expected) { + function assertEventData(actual, expected, msg) { expect(actual.points.length).toEqual(1, 'points length'); var pt = actual.points[0]; @@ -119,30 +114,30 @@ describe('Test hover and click interactions', function() { 'data', 'fullData', 'xaxis', 'yaxis' ], 'event data keys'); - expect(typeof pt.data.uid).toEqual('string', 'uid'); - expect(pt.xaxis.domain.length).toEqual(2, 'xaxis'); - expect(pt.yaxis.domain.length).toEqual(2, 'yaxis'); + expect(typeof pt.data.uid).toBe('string', msg + ' - uid'); + expect(pt.xaxis.domain.length).toBe(2, msg + ' - xaxis'); + expect(pt.yaxis.domain.length).toBe(2, msg + ' - yaxis'); - expect(pt.x).toEqual(expected.x, 'x'); - expect(pt.y).toEqual(expected.y, 'y'); - expect(pt.curveNumber).toEqual(expected.curveNumber, 'curve number'); - expect(pt.pointNumber).toEqual(expected.pointNumber, 'point number'); + expect(pt.x).toBe(expected.x, msg + ' - x'); + expect(pt.y).toBe(expected.y, msg + ' - y'); + expect(pt.curveNumber).toBe(expected.curveNumber, msg + ' - curve number'); + expect(String(pt.pointNumber)).toBe(String(expected.pointNumber), msg + ' - point number'); } - function assertHoverLabelStyle(sel, expected) { + function assertHoverLabelStyle(sel, expected, msg) { if(sel.node() === null) { expect(expected.noHoverLabel).toBe(true); return; } var path = sel.select('path'); - expect(path.style('fill')).toEqual(expected.bgColor, 'bgcolor'); - expect(path.style('stroke')).toEqual(expected.borderColor, 'bordercolor'); + expect(path.style('fill')).toBe(expected.bgColor, msg + ' - bgcolor'); + expect(path.style('stroke')).toBe(expected.borderColor, msg + ' - bordercolor'); var text = sel.select('text.nums'); - expect(parseInt(text.style('font-size'))).toEqual(expected.fontSize, 'font.size'); - expect(text.style('font-family').split(',')[0]).toEqual(expected.fontFamily, 'font.family'); - expect(text.style('fill')).toEqual(expected.fontColor, 'font.color'); + expect(parseInt(text.style('font-size'))).toBe(expected.fontSize, msg + ' - font.size'); + expect(text.style('font-family').split(',')[0]).toBe(expected.fontFamily, msg + ' - font.family'); + expect(text.style('fill')).toBe(expected.fontColor, msg + ' - font.color'); } function assertHoveLabelContent(expected) { @@ -176,20 +171,20 @@ describe('Test hover and click interactions', function() { makeUnhoverFn(gd, pos[0], pos[1]); return function() { - return wait() + return delay(100)() .then(_hover) .then(function(eventData) { assertEventData(eventData, expected); - assertHoverLabelStyle(d3.select('g.hovertext'), expected); + assertHoverLabelStyle(d3.select('g.hovertext'), expected, opts.msg); assertHoveLabelContent(expected); }) .then(_click) .then(function(eventData) { - assertEventData(eventData, expected); + assertEventData(eventData, expected, opts.msg); }) .then(_unhover) .then(function(eventData) { - expect(eventData).toEqual('emitted plotly_unhover'); + expect(eventData).toBe('emitted plotly_unhover', opts.msg); }); }; } @@ -233,6 +228,8 @@ describe('Test hover and click interactions', function() { fontSize: 20, fontFamily: 'Arial', fontColor: 'rgb(255, 255, 0)' + }, { + msg: 'scattergl' }); Plotly.plot(gd, _mock) @@ -251,6 +248,8 @@ describe('Test hover and click interactions', function() { curveNumber: 0, pointNumber: 33, noHoverLabel: true + }, { + msg: 'scattergl with hoverinfo' }); Plotly.plot(gd, _mock) @@ -277,6 +276,8 @@ describe('Test hover and click interactions', function() { fontSize: 8, fontFamily: 'Arial', fontColor: 'rgb(255, 255, 255)' + }, { + msg: 'pointcloud' }); Plotly.plot(gd, _mock) @@ -308,7 +309,8 @@ describe('Test hover and click interactions', function() { fontFamily: 'Roboto', fontColor: 'rgb(255, 255, 255)' }, { - noUnHover: true + noUnHover: true, + msg: 'heatmapgl' }); Plotly.plot(gd, _mock) @@ -330,6 +332,8 @@ describe('Test hover and click interactions', function() { fontSize: 13, fontFamily: 'Arial', fontColor: 'rgb(255, 255, 255)' + }, { + msg: 'scattergl before visibility restyle' }); // after the restyle, autorange changes the y range @@ -343,6 +347,8 @@ describe('Test hover and click interactions', function() { fontSize: 13, fontFamily: 'Arial', fontColor: 'rgb(68, 68, 68)' + }, { + msg: 'scattergl after visibility restyle' }); Plotly.plot(gd, _mock) @@ -371,6 +377,8 @@ describe('Test hover and click interactions', function() { fontSize: 13, fontFamily: 'Arial', fontColor: 'rgb(255, 255, 255)' + }, { + msg: 'scattergl fancy before visibility restyle' }); // after the restyle, autorange changes the x AND y ranges @@ -387,6 +395,8 @@ describe('Test hover and click interactions', function() { fontSize: 13, fontFamily: 'Arial', fontColor: 'rgb(68, 68, 68)' + }, { + msg: 'scattergl fancy after visibility restyle' }); Plotly.plot(gd, _mock) @@ -417,7 +427,8 @@ describe('Test hover and click interactions', function() { fontFamily: 'Arial', fontColor: 'rgb(255, 255, 255)' }, { - noUnHover: true + noUnHover: true, + msg: 'contourgl' }); Plotly.plot(gd, _mock) From 5f5214e3525c29b79becec65516ed8d66119349d Mon Sep 17 00:00:00 2001 From: alexcjohnson Date: Fri, 2 Jun 2017 01:58:27 -0400 Subject: [PATCH 10/22] fix bug with unhover in gl2d if you mouseout of the gl2d plot while hovering on a point, this ensures that unhover will be triggered. prior to this the test "scattergl after visibility restyle" consistently failed for me locally --- src/plots/gl2d/scene2d.js | 28 +++++++++++++++++++++++---- test/jasmine/tests/gl2d_click_test.js | 7 +++++++ 2 files changed, 31 insertions(+), 4 deletions(-) diff --git a/src/plots/gl2d/scene2d.js b/src/plots/gl2d/scene2d.js index 65e854f5e96..c32537fad52 100644 --- a/src/plots/gl2d/scene2d.js +++ b/src/plots/gl2d/scene2d.js @@ -67,6 +67,11 @@ function Scene2D(options, fullLayout) { // last pick result this.pickResult = null; + // is the mouse over the plot? + // it's OK if this says true when it's not, so long as + // when we get a mouseout we set it to false before handling + this.isMouseOver = true; + this.bounds = [Infinity, Infinity, -Infinity, -Infinity]; // flag to stop render loop @@ -153,6 +158,15 @@ proto.makeFramework = function() { container.appendChild(canvas); container.appendChild(svgContainer); container.appendChild(mouseContainer); + + var self = this; + mouseContainer.addEventListener('mouseout', function() { + self.isMouseOver = false; + self.unhover(); + }); + mouseContainer.addEventListener('mouseover', function() { + self.isMouseOver = true; + }); }; proto.toImage = function(format) { @@ -574,7 +588,7 @@ proto.draw = function() { glplot.setDirty(); } - else if(!camera.panning) { + else if(!camera.panning && this.isMouseOver) { this.selectBox.enabled = false; var size = fullLayout._size, @@ -658,14 +672,20 @@ proto.draw = function() { // 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) { + if(!result) { + this.unhover(); + } + + glplot.draw(); +}; + +proto.unhover = function() { + if(this.lastPickResult) { this.spikes.update({}); this.lastPickResult = null; this.graphDiv.emit('plotly_unhover'); Fx.loneUnhover(this.svgContainer); } - - glplot.draw(); }; proto.hoverFormatter = function(axisName, val) { diff --git a/test/jasmine/tests/gl2d_click_test.js b/test/jasmine/tests/gl2d_click_test.js index c95a8d498ff..ef9896fb254 100644 --- a/test/jasmine/tests/gl2d_click_test.js +++ b/test/jasmine/tests/gl2d_click_test.js @@ -13,6 +13,7 @@ var fail = require('../assets/fail_test.js'); var click = require('../assets/timed_click'); var hover = require('../assets/hover'); var delay = require('../assets/delay'); +var mouseEvent = require('../assets/mouse_event'); // contourgl is not part of the dist plotly.js bundle initially Plotly.register([ @@ -84,11 +85,17 @@ describe('Test hover and click interactions', function() { function makeUnhoverFn(gd, x0, y0) { return function() { return new Promise(function(resolve) { + var initialElement = document.elementFromPoint(x0, y0); // fairly realistic simulation of moving with the cursor var canceler = setInterval(function() { x0 -= 2; y0 -= 2; hover(x0, y0); + + var nowElement = document.elementFromPoint(x0, y0); + if(nowElement !== initialElement) { + mouseEvent('mouseout', x0, y0, {element: initialElement}); + } }, 10); gd.on('plotly_unhover', function() { From 9df6903f91f4c3c9072caa5c05148bcd1013f347 Mon Sep 17 00:00:00 2001 From: alexcjohnson Date: Fri, 2 Jun 2017 09:07:03 -0400 Subject: [PATCH 11/22] robustify mapbox_test seems like chrome has a more efficient image encoder now --- test/jasmine/tests/mapbox_test.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/test/jasmine/tests/mapbox_test.js b/test/jasmine/tests/mapbox_test.js index d0a155bc72d..49b4583ff7f 100644 --- a/test/jasmine/tests/mapbox_test.js +++ b/test/jasmine/tests/mapbox_test.js @@ -983,7 +983,9 @@ describe('@noCI, mapbox plots', function() { }); describe('@noCI, mapbox toImage', function() { - var MINIMUM_LENGTH = 1e5; + // decreased from 1e5 - perhaps chrome got better at encoding these + // because I get 99330 and the image still looks correct + var MINIMUM_LENGTH = 8e4; var gd; From d0b15c83bb14a7bd0be3b4dc2ca0f3d573caa421 Mon Sep 17 00:00:00 2001 From: alexcjohnson Date: Fri, 2 Jun 2017 14:51:39 -0400 Subject: [PATCH 12/22] tests of axis domain constraints --- test/image/baselines/axes_scaleanchor.png | Bin 40722 -> 40767 bytes test/image/mocks/axes_scaleanchor.json | 4 +- test/jasmine/assets/custom_matchers.js | 21 +- test/jasmine/tests/axes_test.js | 244 ++++++++++++++++++++-- 4 files changed, 236 insertions(+), 33 deletions(-) diff --git a/test/image/baselines/axes_scaleanchor.png b/test/image/baselines/axes_scaleanchor.png index d7aa4a6e78fe424e8268d651a2d859852cf4b6f3..ea4c3118c69437548d03440a589f14ecbac8c521 100644 GIT binary patch literal 40767 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

wI-6z{xiu9^D8Q^LCg(u_U%1e`#R`IR^ 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 literal 40722 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 diff --git a/test/image/mocks/axes_scaleanchor.json b/test/image/mocks/axes_scaleanchor.json index 5c1e5b1a086..a280b91c89b 100644 --- a/test/image/mocks/axes_scaleanchor.json +++ b/test/image/mocks/axes_scaleanchor.json @@ -9,9 +9,9 @@ "width": 800, "height":500, "title": "fixed-ratio axes", - "xaxis": {"nticks": 10, "domain": [0, 0.45], "title": "shared X axis"}, + "xaxis": {"constrain": "domain", "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"}, + "yaxis2": {"constrain": "domain", "constraintoward": "bottom", "scaleanchor": "x", "scaleratio": 0.2, "domain": [0.55,1], "title": "1:5"}, "xaxis2": {"type": "log", "domain": [0.55, 1], "anchor": "y3", "title": "unconstrained log X"}, "yaxis3": {"domain": [0, 0.45], "anchor": "x2", "title": "Scale matches ->"}, "yaxis4": {"scaleanchor": "y3", "domain": [0.55, 1], "anchor": "x2", "title": "Scale matches <-"}, diff --git a/test/jasmine/assets/custom_matchers.js b/test/jasmine/assets/custom_matchers.js index c81dc82d29a..f56255d82a2 100644 --- a/test/jasmine/assets/custom_matchers.js +++ b/test/jasmine/assets/custom_matchers.js @@ -55,14 +55,19 @@ module.exports = { compare: function(actual, expected, precision, msgExtra) { precision = coercePosition(precision); - var tested = actual.map(function(element, i) { - return isClose(element, expected[i], precision); - }); - - var passed = ( - expected.length === actual.length && - tested.indexOf(false) < 0 - ); + var passed; + + if(Array.isArray(actual) && Array.isArray(expected)) { + var tested = actual.map(function(element, i) { + return isClose(element, expected[i], precision); + }); + + passed = ( + expected.length === actual.length && + tested.indexOf(false) < 0 + ); + } + else passed = false; var message = [ 'Expected', actual, 'to be close to', expected, msgExtra diff --git a/test/jasmine/tests/axes_test.js b/test/jasmine/tests/axes_test.js index fa413acc1d0..70ba9af2378 100644 --- a/test/jasmine/tests/axes_test.js +++ b/test/jasmine/tests/axes_test.js @@ -1,4 +1,4 @@ -var PlotlyInternal = require('@src/plotly'); +var Plotly = require('@lib/index'); var Plots = require('@src/plots/plots'); var Lib = require('@src/lib'); @@ -6,7 +6,7 @@ var Color = require('@src/components/color'); var tinycolor = require('tinycolor2'); var handleTickValueDefaults = require('@src/plots/cartesian/tick_value_defaults'); -var Axes = PlotlyInternal.Axes; +var Axes = require('@src/plots/cartesian/axes'); var createGraphDiv = require('../assets/create_graph_div'); var destroyGraphDiv = require('../assets/destroy_graph_div'); @@ -583,7 +583,7 @@ describe('Test axes', function() { afterEach(destroyGraphDiv); it('updates ranges when adding, removing, or changing a constraint', function(done) { - PlotlyInternal.plot(gd, + Plotly.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}} @@ -592,19 +592,19 @@ describe('Test axes', 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'}); + return Plotly.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}); + return Plotly.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}); + return Plotly.relayout(gd, {'xaxis.scaleanchor': null}); }) .then(function() { expect(gd.layout.xaxis.range).toBeCloseToArray([-0.5, 1.5], 5); @@ -613,6 +613,204 @@ describe('Test axes', function() { .catch(failTest) .then(done); }); + + function assertRangeDomain(axName, range, domainIn, domainOut, msg) { + var ax = gd._fullLayout[axName]; + var axIn = ax._input; + + msg = msg || axName; + + expect(ax.domain).toBeCloseToArray(domainOut, 5, + 'full domain, ' + msg); + + // the actual domain in layout is changed, but the original is + // cached in _fullLayout for responsiveness to later changes. + // (or may be deleted if domain is not adjusted) + expect(axIn.domain || ax.domain).toBeCloseToArray(ax.domain, 5, + 'layout domain, ' + msg); + expect(ax._inputDomain || ax.domain).toBeCloseToArray(domainIn, 5, + '_inputDomain, ' + msg); + + // input and full range always match + expect(ax.range.map(ax.r2l)).toBeCloseToArray(range.map(ax.r2l), 5, + 'range, ' + msg + ': ' + ax.range); + expect(axIn.range.map(ax.r2l)).toBeCloseToArray(ax.range.map(ax.r2l), 5, + 'input range, ' + msg + ': ' + ax.range); + } + + it('can change per-axis constrain:domain/range and constraintoward', function(done) { + Plotly.plot(gd, + // start with a heatmap as it has no padding so calculations are easy + [{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}, + xaxis: {constrain: 'domain'}, + yaxis: {constraintoward: 'top', 'scaleanchor': 'x'} + } + ) + .then(function() { + // x axis is constrained, but by domain rather than by range + assertRangeDomain('xaxis', [-0.5, 1.5], [0, 1], [0.25, 0.75]); + assertRangeDomain('yaxis', [-0.5, 1.5], [0, 1], [0, 1]); + + return Plotly.relayout(gd, { + 'xaxis.constraintoward': 'right', + 'xaxis.domain': [0.05, 0.95], + // no effect for now, y is not constrained + 'yaxis.constraintoward': 'bottom', + 'yaxis.constrain': 'domain' + }); + }) + .then(function() { + // debatable I guess... you asked for an explicit domain but got a + // smaller one due to the constraint, which is not how it works + // if you ask for a new range (in that case you get exactly that + // range and other axes adjust to accommodate that) but my rationale + // is that modifying domain is usually done at an earlier stage in + // making the chart so should affect the "envelope", not the more + // dynamic behavior of interaction like when you set a range. + assertRangeDomain('xaxis', [-0.5, 1.5], [0.05, 0.95], [0.45, 0.95]); + assertRangeDomain('yaxis', [-0.5, 1.5], [0, 1], [0, 1]); + + return Plotly.relayout(gd, {'xaxis.constrain': 'range'}); + }) + .then(function() { + assertRangeDomain('xaxis', [-2.1, 1.5], [0.05, 0.95], [0.05, 0.95]); + assertRangeDomain('yaxis', [-0.5, 1.5], [0, 1], [0, 1]); + + return Plotly.relayout(gd, { + 'xaxis.domain': null, + 'xaxis.range[0]': -6.5 + }); + }) + .then(function() { + assertRangeDomain('xaxis', [-6.5, 1.5], [0, 1], [0, 1]); + assertRangeDomain('yaxis', [-0.5, 1.5], [0, 1], [0, 0.5]); + + return Plotly.relayout(gd, {'yaxis.constraintoward': 'middle'}); + }) + .then(function() { + assertRangeDomain('yaxis', [-0.5, 1.5], [0, 1], [0.25, 0.75]); + + return Plotly.relayout(gd, {'yaxis.constraintoward': 'top'}); + }) + .then(function() { + assertRangeDomain('yaxis', [-0.5, 1.5], [0, 1], [0.5, 1]); + + return Plotly.relayout(gd, {'yaxis.constrain': 'range'}); + }) + .then(function() { + assertRangeDomain('xaxis', [-6.5, 1.5], [0, 1], [0, 1]); + assertRangeDomain('yaxis', [-2.5, 1.5], [0, 1], [0, 1]); + + return Plotly.relayout(gd, { + 'xaxis.autorange': true, + 'xaxis.constrain': 'domain', + 'xaxis.constraintoward': 'left', + 'yaxis.autorange': true, + 'yaxis.constrain': 'domain' + }); + }) + .then(function() { + assertRangeDomain('xaxis', [-0.5, 1.5], [0, 1], [0, 0.5]); + assertRangeDomain('yaxis', [-0.5, 1.5], [0, 1], [0, 1]); + + return Plotly.relayout(gd, {'xaxis.range': [-3.5, 4.5]}); + }) + .then(function() { + assertRangeDomain('xaxis', [-3.5, 4.5], [0, 1], [0, 1]); + assertRangeDomain('yaxis', [-0.5, 1.5], [0, 1], [0.5, 1]); + + return Plotly.relayout(gd, {'xaxis.range': [0, 1]}); + }) + .then(function() { + assertRangeDomain('xaxis', [0, 1], [0, 1], [0, 0.25]); + assertRangeDomain('yaxis', [-0.5, 1.5], [0, 1], [0, 1]); + }) + .catch(failTest) + .then(done); + }); + + it('autoranges consistently with padding', function(done) { + var xAutoPad = 0.09523809523809526; + var xAutorange = [-xAutoPad, 1 + xAutoPad]; + var yAutoPad = 0.15476190476190477; + var yAutorange = [-yAutoPad, 1 + yAutoPad]; + Plotly.plot(gd, [ + {y: [0, 1], mode: 'markers', marker: {size: 4}}, + {y: [0, 1], mode: 'markers', marker: {size: 4}, xaxis: 'x2', yaxis: 'y2'} + ], { + xaxis: {domain: [0, 0.5], constrain: 'domain'}, + yaxis: {constrain: 'domain', scaleanchor: 'x'}, + xaxis2: {domain: [0.5, 1], constrain: 'domain'}, + yaxis2: {constrain: 'domain', scaleanchor: 'x2'}, + // plot area 200x200px, so y axes should be squished to + // (a little over due to autoranging) half their input domain + width: 400, + height: 400, + margin: {l: 100, r: 100, t: 100, b: 100, p: 0}, + showlegend: false + }) + .then(function() { + assertRangeDomain('xaxis', xAutorange, [0, 0.5], [0, 0.5]); + assertRangeDomain('yaxis', yAutorange, [0, 1], [0.225, 0.775]); + assertRangeDomain('xaxis2', xAutorange, [0.5, 1], [0.5, 1]); + assertRangeDomain('yaxis2', yAutorange, [0, 1], [0.225, 0.775]); + + return Plotly.relayout(gd, {'xaxis.range': [-1, 2]}); + }) + .then(function() { + assertRangeDomain('xaxis', [-1, 2], [0, 0.5], [0, 0.5]); + assertRangeDomain('yaxis', [-0.39, 1.39], [0, 1], [0.3516667, 0.6483333]); + assertRangeDomain('xaxis2', xAutorange, [0.5, 1], [0.5, 1]); + assertRangeDomain('yaxis2', yAutorange, [0, 1], [0.225, 0.775]); + + return Plotly.relayout(gd, {'xaxis.autorange': true}); + }) + .then(function() { + assertRangeDomain('xaxis', xAutorange, [0, 0.5], [0, 0.5]); + assertRangeDomain('yaxis', yAutorange, [0, 1], [0.225, 0.775]); + assertRangeDomain('xaxis2', xAutorange, [0.5, 1], [0.5, 1]); + assertRangeDomain('yaxis2', yAutorange, [0, 1], [0.225, 0.775]); + }) + .catch(failTest) + .then(done); + }); + + it('can constrain date axes', function(done) { + Plotly.plot(gd, [{ + x: ['2001-01-01', '2002-01-01'], + y: ['2001-01-01', '2002-01-01'], + mode: 'markers', + marker: {size: 4} + }], { + yaxis: {scaleanchor: 'x'}, + width: 400, + height: 300, + margin: {l: 100, r: 100, t: 100, b: 100, p: 0} + }) + .then(function() { + assertRangeDomain('xaxis', ['2000-04-23 23:25:42.8572', '2002-09-10 00:34:17.1428'], [0, 1], [0, 1]); + assertRangeDomain('yaxis', ['2000-11-27 05:42:51.4286', '2002-02-04 18:17:08.5714'], [0, 1], [0, 1]); + + return Plotly.relayout(gd, { + 'xaxis.constrain': 'domain', + 'yaxis.constrain': 'domain' + }); + }) + .then(function() { + // you'd have thought the x axis would end up exactly the same total size as y + // (which would be domain [.25, .75]) but it doesn't, because the padding is + // calculated as 5% of the original axis size, not of the constrained size. + assertRangeDomain('xaxis', ['2000-11-05 12:17:08.5714', '2002-02-26 11:42:51.4286'], [0, 1], [0.225, 0.775]); + assertRangeDomain('yaxis', ['2000-11-27 05:42:51.4286', '2002-02-04 18:17:08.5714'], [0, 1], [0, 1]); + }) + .catch(failTest) + .then(done); + }); }); describe('categoryorder', function() { @@ -628,25 +826,25 @@ describe('Test axes', function() { describe('setting, or not setting categoryorder if it is not explicitly declared', function() { it('should set categoryorder to default if categoryorder and categoryarray are not supplied', function() { - PlotlyInternal.plot(gd, [{x: ['c', 'a', 'e', 'b', 'd'], y: [15, 11, 12, 13, 14]}], {xaxis: {type: 'category'}}); + Plotly.plot(gd, [{x: ['c', 'a', 'e', 'b', 'd'], y: [15, 11, 12, 13, 14]}], {xaxis: {type: 'category'}}); expect(gd._fullLayout.xaxis.categoryorder).toBe('trace'); expect(gd._fullLayout.xaxis.categorarray).toBe(undefined); }); it('should set categoryorder to default even if type is not set to category explicitly', function() { - PlotlyInternal.plot(gd, [{x: ['c', 'a', 'e', 'b', 'd'], y: [15, 11, 12, 13, 14]}]); + Plotly.plot(gd, [{x: ['c', 'a', 'e', 'b', 'd'], y: [15, 11, 12, 13, 14]}]); expect(gd._fullLayout.xaxis.categoryorder).toBe('trace'); expect(gd._fullLayout.xaxis.categorarray).toBe(undefined); }); it('should NOT set categoryorder to default if type is not category', function() { - PlotlyInternal.plot(gd, [{x: ['c', 'a', 'e', 'b', 'd'], y: [15, 11, 12, 13, 14]}]); + Plotly.plot(gd, [{x: ['c', 'a', 'e', 'b', 'd'], y: [15, 11, 12, 13, 14]}]); expect(gd._fullLayout.yaxis.categoryorder).toBe(undefined); expect(gd._fullLayout.xaxis.categorarray).toBe(undefined); }); it('should set categoryorder to default if type is overridden to be category', function() { - PlotlyInternal.plot(gd, [{x: [1, 2, 3, 4, 5], y: [15, 11, 12, 13, 14]}], {yaxis: {type: 'category'}}); + Plotly.plot(gd, [{x: [1, 2, 3, 4, 5], y: [15, 11, 12, 13, 14]}], {yaxis: {type: 'category'}}); expect(gd._fullLayout.xaxis.categoryorder).toBe(undefined); expect(gd._fullLayout.yaxis.categorarray).toBe(undefined); expect(gd._fullLayout.yaxis.categoryorder).toBe('trace'); @@ -658,7 +856,7 @@ describe('Test axes', function() { describe('setting categoryorder to "array"', function() { it('should leave categoryorder on "array" if it is supplied', function() { - PlotlyInternal.plot(gd, [{x: ['c', 'a', 'e', 'b', 'd'], y: [15, 11, 12, 13, 14]}], { + Plotly.plot(gd, [{x: ['c', 'a', 'e', 'b', 'd'], y: [15, 11, 12, 13, 14]}], { xaxis: {type: 'category', categoryorder: 'array', categoryarray: ['b', 'a', 'd', 'e', 'c']} }); expect(gd._fullLayout.xaxis.categoryorder).toBe('array'); @@ -666,7 +864,7 @@ describe('Test axes', function() { }); it('should switch categoryorder on "array" if it is not supplied but categoryarray is supplied', function() { - PlotlyInternal.plot(gd, [{x: ['c', 'a', 'e', 'b', 'd'], y: [15, 11, 12, 13, 14]}], { + Plotly.plot(gd, [{x: ['c', 'a', 'e', 'b', 'd'], y: [15, 11, 12, 13, 14]}], { xaxis: {type: 'category', categoryarray: ['b', 'a', 'd', 'e', 'c']} }); expect(gd._fullLayout.xaxis.categoryorder).toBe('array'); @@ -674,7 +872,7 @@ describe('Test axes', function() { }); it('should revert categoryorder to "trace" if "array" is supplied but there is no list', function() { - PlotlyInternal.plot(gd, [{x: ['c', 'a', 'e', 'b', 'd'], y: [15, 11, 12, 13, 14]}], { + Plotly.plot(gd, [{x: ['c', 'a', 'e', 'b', 'd'], y: [15, 11, 12, 13, 14]}], { xaxis: {type: 'category', categoryorder: 'array'} }); expect(gd._fullLayout.xaxis.categoryorder).toBe('trace'); @@ -686,7 +884,7 @@ describe('Test axes', function() { describe('do not set categoryorder to "array" if list exists but empty', function() { it('should switch categoryorder to default if list is not supplied', function() { - PlotlyInternal.plot(gd, [{x: ['c', 'a', 'e', 'b', 'd'], y: [15, 11, 12, 13, 14]}], { + Plotly.plot(gd, [{x: ['c', 'a', 'e', 'b', 'd'], y: [15, 11, 12, 13, 14]}], { xaxis: {type: 'category', categoryorder: 'array', categoryarray: []} }); expect(gd._fullLayout.xaxis.categoryorder).toBe('trace'); @@ -694,7 +892,7 @@ describe('Test axes', function() { }); it('should not switch categoryorder on "array" if categoryarray is supplied but empty', function() { - PlotlyInternal.plot(gd, [{x: ['c', 'a', 'e', 'b', 'd'], y: [15, 11, 12, 13, 14]}], { + Plotly.plot(gd, [{x: ['c', 'a', 'e', 'b', 'd'], y: [15, 11, 12, 13, 14]}], { xaxis: {type: 'category', categoryarray: []} }); expect(gd._fullLayout.xaxis.categoryorder).toBe('trace'); @@ -705,7 +903,7 @@ describe('Test axes', function() { describe('do NOT set categoryorder to "array" if it has some other proper value', function() { it('should use specified categoryorder if it is supplied even if categoryarray exists', function() { - PlotlyInternal.plot(gd, [{x: ['c', 'a', 'e', 'b', 'd'], y: [15, 11, 12, 13, 14]}], { + Plotly.plot(gd, [{x: ['c', 'a', 'e', 'b', 'd'], y: [15, 11, 12, 13, 14]}], { xaxis: {type: 'category', categoryorder: 'trace', categoryarray: ['b', 'a', 'd', 'e', 'c']} }); expect(gd._fullLayout.xaxis.categoryorder).toBe('trace'); @@ -713,7 +911,7 @@ describe('Test axes', function() { }); it('should use specified categoryorder if it is supplied even if categoryarray exists', function() { - PlotlyInternal.plot(gd, [{x: ['c', 'a', 'e', 'b', 'd'], y: [15, 11, 12, 13, 14]}], { + Plotly.plot(gd, [{x: ['c', 'a', 'e', 'b', 'd'], y: [15, 11, 12, 13, 14]}], { xaxis: {type: 'category', categoryorder: 'category ascending', categoryarray: ['b', 'a', 'd', 'e', 'c']} }); expect(gd._fullLayout.xaxis.categoryorder).toBe('category ascending'); @@ -721,7 +919,7 @@ describe('Test axes', function() { }); it('should use specified categoryorder if it is supplied even if categoryarray exists', function() { - PlotlyInternal.plot(gd, [{x: ['c', 'a', 'e', 'b', 'd'], y: [15, 11, 12, 13, 14]}], { + Plotly.plot(gd, [{x: ['c', 'a', 'e', 'b', 'd'], y: [15, 11, 12, 13, 14]}], { xaxis: {type: 'category', categoryorder: 'category descending', categoryarray: ['b', 'a', 'd', 'e', 'c']} }); expect(gd._fullLayout.xaxis.categoryorder).toBe('category descending'); @@ -733,7 +931,7 @@ describe('Test axes', function() { describe('setting categoryorder to the default if the value is unexpected', function() { it('should switch categoryorder to "trace" if mode is supplied but invalid', function() { - PlotlyInternal.plot(gd, [{x: ['c', 'a', 'e', 'b', 'd'], y: [15, 11, 12, 13, 14]}], { + Plotly.plot(gd, [{x: ['c', 'a', 'e', 'b', 'd'], y: [15, 11, 12, 13, 14]}], { xaxis: {type: 'category', categoryorder: 'invalid value'} }); expect(gd._fullLayout.xaxis.categoryorder).toBe('trace'); @@ -741,7 +939,7 @@ describe('Test axes', function() { }); it('should switch categoryorder to "array" if mode is supplied but invalid and list is supplied', function() { - PlotlyInternal.plot(gd, [{x: ['c', 'a', 'e', 'b', 'd'], y: [15, 11, 12, 13, 14]}], { + Plotly.plot(gd, [{x: ['c', 'a', 'e', 'b', 'd'], y: [15, 11, 12, 13, 14]}], { xaxis: {type: 'category', categoryorder: 'invalid value', categoryarray: ['b', 'a', 'd', 'e', 'c']} }); expect(gd._fullLayout.xaxis.categoryorder).toBe('array'); @@ -774,7 +972,7 @@ describe('Test axes', function() { } }; - PlotlyInternal.plot(gd, data, layout); + Plotly.plot(gd, data, layout); var yaxis = gd._fullLayout.yaxis; expect(yaxis.ticklen).toBe(5); @@ -798,7 +996,7 @@ describe('Test axes', function() { } }; - PlotlyInternal.plot(gd, data, layout); + Plotly.plot(gd, data, layout); var yaxis = gd._fullLayout.yaxis; expect(yaxis.ticklen).toBe(10); @@ -818,7 +1016,7 @@ describe('Test axes', function() { } }; - PlotlyInternal.plot(gd, data, layout); + Plotly.plot(gd, data, layout); var yaxis = gd._fullLayout.yaxis; expect(yaxis.tickangle).toBeUndefined(); From d67829e2393d2823b1e83ede92c96389a4251365 Mon Sep 17 00:00:00 2001 From: alexcjohnson Date: Thu, 8 Jun 2017 09:17:30 -0400 Subject: [PATCH 13/22] create constraints.clean to get it out of the main Plotly.plot code --- src/plot_api/plot_api.js | 23 ++++------------------- src/plots/cartesian/constraints.js | 22 +++++++++++++++++++++- src/plots/gl2d/scene2d.js | 20 +++++++++++++------- 3 files changed, 38 insertions(+), 27 deletions(-) diff --git a/src/plot_api/plot_api.js b/src/plot_api/plot_api.js index f2a075bb208..709fb9d2251 100644 --- a/src/plot_api/plot_api.js +++ b/src/plot_api/plot_api.js @@ -32,7 +32,9 @@ var manageArrays = require('./manage_arrays'); var helpers = require('./helpers'); var subroutines = require('./subroutines'); var cartesianConstants = require('../plots/cartesian/constants'); -var enforceAxisConstraints = require('../plots/cartesian/constraints'); +var axisConstraints = require('../plots/cartesian/constraints'); +var enforceAxisConstraints = axisConstraints.enforce; +var cleanAxisConstraints = axisConstraints.clean; var axisIds = require('../plots/cartesian/axis_ids'); @@ -269,24 +271,8 @@ Plotly.plot = function(gd, data, layout, config) { var axList = Plotly.Axes.list(gd, '', true); for(var i = 0; i < axList.length; i++) { - // before autoranging, check if this axis was previously constrained - // by domain but no longer is var ax = axList[i]; - if(ax._inputDomain) { - var isConstrained = false; - var axId = ax._id; - var constraintGroups = gd._fullLayout._axisConstraintGroups; - for(var j = 0; j < constraintGroups.length; j++) { - if(constraintGroups[j][axId]) { - isConstrained = true; - break; - } - } - if(!isConstrained || ax.constrain !== 'domain') { - ax._input.domain = ax.domain = ax._inputDomain; - delete ax._inputDomain; - } - } + cleanAxisConstraints(gd, ax); Plotly.Axes.doAutoRange(ax); } @@ -2000,7 +1986,6 @@ function _relayout(gd, aobj) { } } else if(pleafPlus.match(/^[xyz]axis[0-9]*\.domain(\[[0|1]\])?$/)) { - axId = recordAlteredAxis(pleafPlus); Lib.nestedProperty(fullLayout, ptrunk + '._inputDomain').set(null); } else if(pleafPlus.match(/^[xyz]axis[0-9]*\.constrain.*$/)) { diff --git a/src/plots/cartesian/constraints.js b/src/plots/cartesian/constraints.js index 7ffbca53f0e..64bf10c0fdc 100644 --- a/src/plots/cartesian/constraints.js +++ b/src/plots/cartesian/constraints.js @@ -17,7 +17,7 @@ var ALMOST_EQUAL = require('../../constants/numerical').ALMOST_EQUAL; var FROM_BL = require('../../constants/alignment').FROM_BL; -module.exports = function enforceAxisConstraints(gd) { +exports.enforce = function enforceAxisConstraints(gd) { var fullLayout = gd._fullLayout; var constraintGroups = fullLayout._axisConstraintGroups; @@ -172,6 +172,26 @@ module.exports = function enforceAxisConstraints(gd) { } }; +// For use before autoranging, check if this axis was previously constrained +// by domain but no longer is +exports.clean = function cleanConstraints(gd, ax) { + if(ax._inputDomain) { + var isConstrained = false; + var axId = ax._id; + var constraintGroups = gd._fullLayout._axisConstraintGroups; + for(var j = 0; j < constraintGroups.length; j++) { + if(constraintGroups[j][axId]) { + isConstrained = true; + break; + } + } + if(!isConstrained || ax.constrain !== 'domain') { + ax._input.domain = ax.domain = ax._inputDomain; + delete ax._inputDomain; + } + } +}; + function updateDomain(ax, factor) { var inputDomain = ax._inputDomain; var centerFraction = FROM_BL[ax.constraintoward]; diff --git a/src/plots/gl2d/scene2d.js b/src/plots/gl2d/scene2d.js index c32537fad52..00fc49969f0 100644 --- a/src/plots/gl2d/scene2d.js +++ b/src/plots/gl2d/scene2d.js @@ -22,7 +22,9 @@ 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 axisConstraints = require('../../plots/cartesian/constraints'); +var enforceAxisConstraints = axisConstraints.enforce; +var cleanAxisConstraints = axisConstraints.clean; var AXES = ['xaxis', 'yaxis']; var STATIC_CANVAS, STATIC_CONTEXT; @@ -395,6 +397,15 @@ proto.plot = function(fullData, calcData, fullLayout) { options.merge(fullLayout); options.screenBox = [0, 0, width, height]; + var mockGraphDiv = {_fullLayout: { + _axisConstraintGroups: this.graphDiv._fullLayout._axisConstraintGroups, + xaxis: this.xaxis, + yaxis: this.yaxis + }}; + + cleanAxisConstraints(mockGraphDiv, this.xaxis); + cleanAxisConstraints(mockGraphDiv, this.yaxis); + var size = fullLayout._size, domainX = this.xaxis.domain, domainY = this.yaxis.domain; @@ -441,12 +452,7 @@ proto.plot = function(fullData, calcData, fullLayout) { ax.setScale(); } - var mockLayout = { - _axisConstraintGroups: this.graphDiv._fullLayout._axisConstraintGroups, - xaxis: this.xaxis, - yaxis: this.yaxis - }; - enforceAxisConstraints({_fullLayout: mockLayout}); + enforceAxisConstraints(mockGraphDiv); options.ticks = this.computeTickMarks(); From b1c6b24ed457310c20f4651c621460348fb729d5 Mon Sep 17 00:00:00 2001 From: alexcjohnson Date: Thu, 8 Jun 2017 09:19:57 -0400 Subject: [PATCH 14/22] more robust way to generate relayout args during axis drag --- src/plots/cartesian/dragbox.js | 57 ++++++++++++++++++---------------- 1 file changed, 30 insertions(+), 27 deletions(-) diff --git a/src/plots/cartesian/dragbox.js b/src/plots/cartesian/dragbox.js index eb3fc4015fd..1ed404f653d 100644 --- a/src/plots/cartesian/dragbox.js +++ b/src/plots/cartesian/dragbox.js @@ -184,6 +184,9 @@ module.exports = function dragBox(gd, plotinfo, x, y, w, h, ns, ew) { zb, corners; + // collected changes to be made to the plot by relayout at the end + var updates = {}; + function zoomPrep(e, startX, startY) { var dragBBox = dragger.getBoundingClientRect(); x0 = startX - dragBBox.left; @@ -282,8 +285,8 @@ module.exports = function dragBox(gd, plotinfo, x, y, w, h, ns, ew) { } // 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); + if(zoomMode === 'xy' || zoomMode === 'x') zoomAxRanges(xa, box.l / pw, box.r / pw, updates, xaLinked); + if(zoomMode === 'xy' || zoomMode === 'y') zoomAxRanges(ya, (ph - box.b) / ph, (ph - box.t) / ph, updates, yaLinked); removeZoombox(gd); dragTail(zoomMode); @@ -335,11 +338,11 @@ module.exports = function dragBox(gd, plotinfo, x, y, w, h, ns, ew) { } // scroll zoom, on all draggers except corners - var scrollViewBox = [0, 0, pw, ph], - // wait a little after scrolling before redrawing - redrawTimer = null, - REDRAWDELAY = constants.REDRAWDELAY, - mainplot = plotinfo.mainplot ? + var scrollViewBox = [0, 0, pw, ph]; + // wait a little after scrolling before redrawing + var redrawTimer = null; + var REDRAWDELAY = constants.REDRAWDELAY; + var mainplot = plotinfo.mainplot ? fullLayout._plots[plotinfo.mainplot] : plotinfo; function zoomWheel(e) { @@ -524,6 +527,8 @@ module.exports = function dragBox(gd, plotinfo, x, y, w, h, ns, ew) { ticksAndAnnotations(yActive, xActive); } + // Draw ticks and annotations (and other components) when ranges change. + // Also records the ranges that have changed for use by update at the end. function ticksAndAnnotations(ns, ew) { var activeAxIds = [], i; @@ -543,8 +548,12 @@ module.exports = function dragBox(gd, plotinfo, x, y, w, h, ns, ew) { pushActiveAxIds(yaLinked); } + updates = {}; for(i = 0; i < activeAxIds.length; i++) { - doTicks(gd, activeAxIds[i], true); + var axId = activeAxIds[i]; + doTicks(gd, axId, true); + var ax = getFromId(gd, axId); + updates[ax._name + '.range'] = ax.range.slice(); } function redrawObjs(objArray, method, shortCircuit) { @@ -641,29 +650,17 @@ module.exports = function dragBox(gd, plotinfo, x, y, w, h, ns, ew) { 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 - var axesToModify; - if(zoommode === 'xy') axesToModify = xa.concat(ya); - else if(zoommode === 'x') axesToModify = xa; - else if(zoommode === 'y') axesToModify = ya; - - for(var i = 0; i < axesToModify.length; i++) { - var axi = axesToModify[i]; - if(axi._r[0] !== axi.range[0]) attrs[axi._name + '.range[0]'] = axi.range[0]; - if(axi._r[1] !== axi.range[1]) attrs[axi._name + '.range[1]'] = axi.range[1]; - - axi.range = axi._input.range = axi._r.slice(); - } - + // put the subplot viewboxes back to default (Because we're going to) + // be repositioning the data in the relayout. But DON'T call + // ticksAndAnnotations again - it's unnecessary and would overwrite `updates` updateSubplots([0, 0, pw, ph]); - Plotly.relayout(gd, attrs); + Plotly.relayout(gd, updates); } // updateSubplots - find all plot viewboxes that should be // affected by this drag, and update them. look for all plots // sharing an affected axis (including the one being dragged) + // returns all the new axis ranges as an update object function updateSubplots(viewBox) { var plotinfos = fullLayout._plots; var subplots = Object.keys(plotinfos); @@ -674,6 +671,8 @@ module.exports = function dragBox(gd, plotinfo, x, y, w, h, ns, ew) { var i, xScaleFactor2, yScaleFactor2, clipDx, clipDy; + var attrs = {}; + // 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) @@ -693,6 +692,7 @@ module.exports = function dragBox(gd, plotinfo, x, y, w, h, ns, ew) { if(scaleFactor) { ax.range = ax._r.slice(); scaleZoom(ax, scaleFactor); + attrs[ax._name + '.range'] = ax.range.slice(); return getShift(ax, scaleFactor); } return 0; @@ -758,6 +758,8 @@ module.exports = function dragBox(gd, plotinfo, x, y, w, h, ns, ew) { .selectAll('.points').selectAll('.textpoint') .call(Drawing.setTextPointsScale, xScaleFactor2, yScaleFactor2); } + + return attrs; } return dragger; @@ -806,7 +808,7 @@ function getEndText(ax, end) { } } -function zoomAxRanges(axList, r0Fraction, r1Fraction, linkedAxes) { +function zoomAxRanges(axList, r0Fraction, r1Fraction, updates, linkedAxes) { var i, axi, axRangeLinear0, @@ -822,13 +824,14 @@ function zoomAxRanges(axList, r0Fraction, r1Fraction, linkedAxes) { axi.l2r(axRangeLinear0 + axRangeLinearSpan * r0Fraction), axi.l2r(axRangeLinear0 + axRangeLinearSpan * r1Fraction) ]; + updates[axi._name + '.range'] = axi.range.slice(); } // zoom linked axes about their centers if(linkedAxes && linkedAxes.length) { var linkedR0Fraction = (r0Fraction + (1 - r1Fraction)) / 2; - zoomAxRanges(linkedAxes, linkedR0Fraction, 1 - linkedR0Fraction); + zoomAxRanges(linkedAxes, linkedR0Fraction, 1 - linkedR0Fraction, updates); } } From 59a092a1fa685fffa25a7027a70ee17040d63588 Mon Sep 17 00:00:00 2001 From: alexcjohnson Date: Thu, 8 Jun 2017 09:23:31 -0400 Subject: [PATCH 15/22] update constraints.js to handle some more editing edge cases --- src/plots/cartesian/constraints.js | 59 +++++++++++++++--------------- 1 file changed, 29 insertions(+), 30 deletions(-) diff --git a/src/plots/cartesian/constraints.js b/src/plots/cartesian/constraints.js index 64bf10c0fdc..ac5cca0b979 100644 --- a/src/plots/cartesian/constraints.js +++ b/src/plots/cartesian/constraints.js @@ -37,13 +37,16 @@ exports.enforce = function enforceAxisConstraints(gd) { var matchScale = Infinity; var normScales = {}; var axes = {}; + var hasAnyDomainConstraint = false; // 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)]; - if(!ax._inputDomain) ax._inputDomain = ax.domain.slice(); + if(ax._inputDomain) ax.domain = ax._inputDomain.slice(); + else ax._inputDomain = ax.domain.slice(); + if(!ax._inputRange) ax._inputRange = ax.range.slice(); // set axis scale here so we can use _m rather than @@ -53,18 +56,19 @@ exports.enforce = 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 { + if(ax.constrain === 'domain' || !ax._constraintShrinkable) { matchScale = Math.min(matchScale, normScale); } + + // this has served its purpose, so remove it + delete ax._constraintShrinkable; maxScale = Math.max(maxScale, normScale); + + if(ax.constrain === 'domain') hasAnyDomainConstraint = true; } // Do we have a constraint mismatch? Give a small buffer for rounding errors - if(minScale > ALMOST_EQUAL * maxScale) continue; + if(minScale > ALMOST_EQUAL * maxScale && !hasAnyDomainConstraint) continue; // now increase any ranges we need to until all normalized scales are equal for(j = 0; j < axisIDs.length; j++) { @@ -107,8 +111,7 @@ exports.enforce = function enforceAxisConstraints(gd) { factor *= rangeShrunk; } - // TODO - if(ax.autorange) { + if(ax.autorange && ax._min.length && ax._max.length) { /* * range & factor may need to change because range was * calculated for the larger scaling, so some pixel @@ -121,12 +124,16 @@ exports.enforce = function enforceAxisConstraints(gd) { * outerMin/Max are for - if the expansion was going to * go beyond the original domain, it must be impossible */ - var rangeMin = Math.min(ax.range[0], ax.range[1]); - var rangeMax = Math.max(ax.range[0], ax.range[1]); - var rangeCenter = (rangeMin + rangeMax) / 2; - var halfRange = rangeMax - rangeCenter; - var outerMin = rangeCenter - halfRange * factor; - var outerMax = rangeCenter + halfRange * factor; + var rl0 = ax.r2l(ax.range[0]); + var rl1 = ax.r2l(ax.range[1]); + var rangeCenter = (rl0 + rl1) / 2; + var rangeMin = rangeCenter; + var rangeMax = rangeCenter; + var halfRange = Math.abs(rl1 - rangeCenter); + // extra tiny bit for rounding errors, in case we actually + // *are* expanding to the full domain + var outerMin = rangeCenter - halfRange * factor * 1.0001; + var outerMax = rangeCenter + halfRange * factor * 1.0001; updateDomain(ax, factor); ax.setScale(); @@ -135,34 +142,26 @@ exports.enforce = function enforceAxisConstraints(gd) { var k; for(k = 0; k < ax._min.length; k++) { - newVal = ax._min[i].val - ax._min[i].pad / m; + newVal = ax._min[k].val - ax._min[k].pad / m; if(newVal > outerMin && newVal < rangeMin) { rangeMin = newVal; } } for(k = 0; k < ax._max.length; k++) { - newVal = ax._max[i].val + ax._max[i].pad / m; + newVal = ax._max[k].val + ax._max[k].pad / m; if(newVal < outerMax && newVal > rangeMax) { rangeMax = newVal; } } - ax.range = ax._input.range = (ax.range[0] < ax.range[1]) ? - [rangeMin, rangeMax] : [rangeMax, rangeMin]; - - /* - * In principle this new range can be shifted vs. what - * you saw at the end of a zoom operation, like if you - * have a big bubble on one side and a small bubble on - * the other. - * To fix this we'd have to be doing this calculation - * continuously during the zoom, but it's enough of an - * edge case and a subtle enough effect that I'm going - * to ignore it for now. - */ var domainExpand = (rangeMax - rangeMin) / (2 * halfRange); factor /= domainExpand; + + rangeMin = ax.l2r(rangeMin); + rangeMax = ax.l2r(rangeMax); + ax.range = ax._input.range = (rl0 < rl1) ? + [rangeMin, rangeMax] : [rangeMax, rangeMin]; } updateDomain(ax, factor); From 51fc2c694b4e89055bfb9a02e392d1ca41e4ed03 Mon Sep 17 00:00:00 2001 From: alexcjohnson Date: Thu, 8 Jun 2017 13:11:37 -0400 Subject: [PATCH 16/22] put range 0,1 parts separately into GUI relayout calls so that user apps that listen to relayouts get the same form as before --- 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 1ed404f653d..83647fb8f60 100644 --- a/src/plots/cartesian/dragbox.js +++ b/src/plots/cartesian/dragbox.js @@ -553,7 +553,8 @@ module.exports = function dragBox(gd, plotinfo, x, y, w, h, ns, ew) { var axId = activeAxIds[i]; doTicks(gd, axId, true); var ax = getFromId(gd, axId); - updates[ax._name + '.range'] = ax.range.slice(); + updates[ax._name + '.range[0]'] = ax.range[0]; + updates[ax._name + '.range[1]'] = ax.range[1]; } function redrawObjs(objArray, method, shortCircuit) { @@ -824,7 +825,8 @@ function zoomAxRanges(axList, r0Fraction, r1Fraction, updates, linkedAxes) { axi.l2r(axRangeLinear0 + axRangeLinearSpan * r0Fraction), axi.l2r(axRangeLinear0 + axRangeLinearSpan * r1Fraction) ]; - updates[axi._name + '.range'] = axi.range.slice(); + updates[axi._name + '.range[0]'] = axi.range[0]; + updates[axi._name + '.range[1]'] = axi.range[1]; } // zoom linked axes about their centers From 39319734530d192db712ac8e9f3f0e79180a43da Mon Sep 17 00:00:00 2001 From: alexcjohnson Date: Thu, 8 Jun 2017 13:17:13 -0400 Subject: [PATCH 17/22] rm unused `attrs` variable --- src/plots/cartesian/dragbox.js | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/plots/cartesian/dragbox.js b/src/plots/cartesian/dragbox.js index 83647fb8f60..1f0361a17b5 100644 --- a/src/plots/cartesian/dragbox.js +++ b/src/plots/cartesian/dragbox.js @@ -661,7 +661,6 @@ module.exports = function dragBox(gd, plotinfo, x, y, w, h, ns, ew) { // updateSubplots - find all plot viewboxes that should be // affected by this drag, and update them. look for all plots // sharing an affected axis (including the one being dragged) - // returns all the new axis ranges as an update object function updateSubplots(viewBox) { var plotinfos = fullLayout._plots; var subplots = Object.keys(plotinfos); @@ -672,8 +671,6 @@ module.exports = function dragBox(gd, plotinfo, x, y, w, h, ns, ew) { var i, xScaleFactor2, yScaleFactor2, clipDx, clipDy; - var attrs = {}; - // 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) @@ -693,7 +690,6 @@ module.exports = function dragBox(gd, plotinfo, x, y, w, h, ns, ew) { if(scaleFactor) { ax.range = ax._r.slice(); scaleZoom(ax, scaleFactor); - attrs[ax._name + '.range'] = ax.range.slice(); return getShift(ax, scaleFactor); } return 0; @@ -759,8 +755,6 @@ module.exports = function dragBox(gd, plotinfo, x, y, w, h, ns, ew) { .selectAll('.points').selectAll('.textpoint') .call(Drawing.setTextPointsScale, xScaleFactor2, yScaleFactor2); } - - return attrs; } return dragger; From 1271382e9ed31a238544fdc59cbe3c3f11cd9e6c Mon Sep 17 00:00:00 2001 From: alexcjohnson Date: Thu, 8 Jun 2017 13:19:21 -0400 Subject: [PATCH 18/22] axes.expand: ensure domain constraint before adjusting extrappad --- src/plots/cartesian/axes.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/plots/cartesian/axes.js b/src/plots/cartesian/axes.js index 86e60b16b56..32536757cec 100644 --- a/src/plots/cartesian/axes.js +++ b/src/plots/cartesian/axes.js @@ -457,7 +457,7 @@ axes.expand = function(ax, data, options) { // domain-constrained axes: base extrappad on the unconstrained // domain so it's consistent as the domain changes - if(extrappad && ax._inputDomain) { + if(extrappad && (ax.constrain === 'domain') && ax._inputDomain) { extrappad *= (ax._inputDomain[1] - ax._inputDomain[0]) / (ax.domain[1] - ax.domain[0]); } From 6d3a48ab38b0296cd7b97515ad6c4c97ac1bf0ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89tienne=20T=C3=A9treault-Pinard?= Date: Thu, 8 Jun 2017 15:46:59 -0400 Subject: [PATCH 19/22] add special validateFunction for enumerated valType - so that attribute that declare possible values as regex e.g. axis anchor and overlaying pass `Lib.validate()` --- src/lib/coerce.js | 14 ++++++++++++++ test/jasmine/tests/lib_test.js | 6 ++++++ 2 files changed, 20 insertions(+) diff --git a/src/lib/coerce.js b/src/lib/coerce.js index 667fdbf0dc1..eacdfe1f637 100644 --- a/src/lib/coerce.js +++ b/src/lib/coerce.js @@ -44,6 +44,20 @@ exports.valObjects = { if(opts.coerceNumber) v = +v; if(opts.values.indexOf(v) === -1) propOut.set(dflt); else propOut.set(v); + }, + validateFunction: function(v, opts) { + if(opts.coerceNumber) v = +v; + + var values = opts.values; + for(var i = 0; i < values.length; i++) { + var k = String(values[i]); + + if((k.charAt(0) === '/' && k.charAt(k.length - 1) === '/')) { + var regex = new RegExp(k.substr(1, k.length - 2)); + if(regex.test(v)) return true; + } else if(v === values[i]) return true; + } + return false; } }, 'boolean': { diff --git a/test/jasmine/tests/lib_test.js b/test/jasmine/tests/lib_test.js index edd5abfdf71..69573e1aa85 100644 --- a/test/jasmine/tests/lib_test.js +++ b/test/jasmine/tests/lib_test.js @@ -978,6 +978,12 @@ describe('Test lib.js:', function() { arrayOk: true, dflt: 'a' }); + + assert(['x', 'x2'], ['xx', 'x0', undefined], { + valType: 'enumerated', + values: ['/^x([2-9]|[1-9][0-9]+)?$/'], + dflt: 'x' + }); }); it('should work for valType \'boolean\' where', function() { From 3b0d829f9c749ab87c62c7b27aea1723615d0593 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89tienne=20T=C3=A9treault-Pinard?= Date: Thu, 8 Jun 2017 15:48:27 -0400 Subject: [PATCH 20/22] add special handler in Plotly.validate for enumerated w/ dynamic values - e.g. axis 'anchor' declare both x and y values, but coerce x or y values depending on the container. - similarly for 'overlaying' and 'contraintoward' --- src/plot_api/validate.js | 21 ++++++++++++--- test/jasmine/tests/validate_test.js | 41 +++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 3 deletions(-) diff --git a/src/plot_api/validate.js b/src/plot_api/validate.js index 40e9977f448..91c0dc5d15d 100644 --- a/src/plot_api/validate.js +++ b/src/plot_api/validate.js @@ -219,6 +219,11 @@ function crawl(objIn, objOut, schema, list, base, path) { else if(!Lib.validate(valIn, nestedSchema)) { list.push(format('value', base, p, valIn)); } + else if(nestedSchema.valType === 'enumerated' && + ((nestedSchema.coerceNumber && valIn !== +valOut) || valIn !== valOut) + ) { + list.push(format('dynamic', base, p, valIn, valOut)); + } } return list; @@ -267,6 +272,16 @@ var code2msgFunc = { return inBase(base) + target + ' ' + astr + ' did not get coerced'; }, + dynamic: function(base, astr, valIn, valOut) { + return [ + inBase(base) + 'key', + astr, + '(set to \'' + valIn + '\')', + 'got reset to', + '\'' + valOut + '\'', + 'during defaults.' + ].join(' '); + }, invisible: function(base) { return 'Trace ' + base[1] + ' got defaulted to be not visible'; }, @@ -284,7 +299,7 @@ function inBase(base) { return 'In ' + base + ', '; } -function format(code, base, path, valIn) { +function format(code, base, path, valIn, valOut) { path = path || ''; var container, trace; @@ -301,8 +316,8 @@ function format(code, base, path, valIn) { trace = null; } - var astr = convertPathToAttributeString(path), - msg = code2msgFunc[code](base, astr, valIn); + var astr = convertPathToAttributeString(path); + var msg = code2msgFunc[code](base, astr, valIn, valOut); // log to console if logger config option is enabled Lib.log(msg); diff --git a/test/jasmine/tests/validate_test.js b/test/jasmine/tests/validate_test.js index b95061938db..d5597f23322 100644 --- a/test/jasmine/tests/validate_test.js +++ b/test/jasmine/tests/validate_test.js @@ -392,4 +392,45 @@ describe('Plotly.validate', function() { 'In data trace 2, key transforms[0].type is set to an invalid value (no gonna work)' ); }); + + it('should catch input errors for attribute with dynamic defaults', function() { + var out = Plotly.validate([], { + xaxis: { + constrain: 'domain', + constraintoward: 'bottom' + }, + yaxis: { + constrain: 'domain', + constraintoward: 'left' + }, + xaxis2: { + anchor: 'x3' + }, + yaxis2: { + overlaying: 'x' + } + }); + + expect(out.length).toBe(4); + assertErrorContent( + out[0], 'dynamic', 'layout', null, + ['xaxis', 'constraintoward'], 'xaxis.constraintoward', + 'In layout, key xaxis.constraintoward (set to \'bottom\') got reset to \'center\' during defaults.' + ); + assertErrorContent( + out[1], 'dynamic', 'layout', null, + ['yaxis', 'constraintoward'], 'yaxis.constraintoward', + 'In layout, key yaxis.constraintoward (set to \'left\') got reset to \'middle\' during defaults.' + ); + assertErrorContent( + out[2], 'dynamic', 'layout', null, + ['xaxis2', 'anchor'], 'xaxis2.anchor', + 'In layout, key xaxis2.anchor (set to \'x3\') got reset to \'y\' during defaults.' + ); + assertErrorContent( + out[3], 'dynamic', 'layout', null, + ['yaxis2', 'overlaying'], 'yaxis2.overlaying', + 'In layout, key yaxis2.overlaying (set to \'x\') got reset to \'false\' during defaults.' + ); + }); }); From fa78376fea1cb78c1b1483a142e45541517872f4 Mon Sep 17 00:00:00 2001 From: alexcjohnson Date: Fri, 9 Jun 2017 10:36:22 -0400 Subject: [PATCH 21/22] :hocho: debug cruft --- src/plots/cartesian/dragbox.js | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/plots/cartesian/dragbox.js b/src/plots/cartesian/dragbox.js index 1f0361a17b5..7be44097c2a 100644 --- a/src/plots/cartesian/dragbox.js +++ b/src/plots/cartesian/dragbox.js @@ -735,8 +735,6 @@ module.exports = function dragBox(gd, plotinfo, x, y, w, h, ns, ew) { var plotDx = xa2._offset - clipDx / xScaleFactor2, plotDy = ya2._offset - clipDy / yScaleFactor2; - // console.log(subplot.id, editX2, editY2, xScaleFactor2, yScaleFactor2, clipDx, clipDy, plotDx, plotDy); - fullLayout._defs.selectAll('#' + subplot.clipId) .call(Drawing.setTranslate, clipDx, clipDy) .call(Drawing.setScale, xScaleFactor2, yScaleFactor2); From 67ea3a8344e8907ebc53c035d8e1222954fd1f30 Mon Sep 17 00:00:00 2001 From: alexcjohnson Date: Fri, 9 Jun 2017 11:24:08 -0400 Subject: [PATCH 22/22] test axis constraints (range & domain) with log and category axes --- test/jasmine/tests/axes_test.js | 59 +++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/test/jasmine/tests/axes_test.js b/test/jasmine/tests/axes_test.js index 70ba9af2378..a48525f952f 100644 --- a/test/jasmine/tests/axes_test.js +++ b/test/jasmine/tests/axes_test.js @@ -811,6 +811,65 @@ describe('Test axes', function() { .catch(failTest) .then(done); }); + + it('can constrain category axes', function(done) { + Plotly.plot(gd, [{ + x: ['a', 'b'], + y: ['c', 'd'], + mode: 'markers', + marker: {size: 4} + }], { + yaxis: {scaleanchor: 'x'}, + width: 300, + height: 400, + margin: {l: 100, r: 100, t: 100, b: 100, p: 0} + }) + .then(function() { + assertRangeDomain('xaxis', [-0.095238095, 1.095238095], [0, 1], [0, 1]); + assertRangeDomain('yaxis', [-0.69047619, 1.69047619], [0, 1], [0, 1]); + + return Plotly.relayout(gd, { + 'xaxis.constrain': 'domain', + 'yaxis.constrain': 'domain' + }); + }) + .then(function() { + assertRangeDomain('xaxis', [-0.095238095, 1.095238095], [0, 1], [0, 1]); + assertRangeDomain('yaxis', [-0.1547619, 1.1547619], [0, 1], [0.225, 0.775]); + }) + .catch(failTest) + .then(done); + }); + + it('can constrain log axes', function(done) { + Plotly.plot(gd, [{ + x: [1, 10], + y: [1, 10], + mode: 'markers', + marker: {size: 4} + }], { + xaxis: {type: 'log'}, + yaxis: {type: 'log', scaleanchor: 'x'}, + width: 300, + height: 400, + margin: {l: 100, r: 100, t: 100, b: 100, p: 0} + }) + .then(function() { + assertRangeDomain('xaxis', [-0.095238095, 1.095238095], [0, 1], [0, 1]); + assertRangeDomain('yaxis', [-0.69047619, 1.69047619], [0, 1], [0, 1]); + + return Plotly.relayout(gd, { + 'xaxis.constrain': 'domain', + 'yaxis.constrain': 'domain' + }); + }) + .then(function() { + assertRangeDomain('xaxis', [-0.095238095, 1.095238095], [0, 1], [0, 1]); + assertRangeDomain('yaxis', [-0.1547619, 1.1547619], [0, 1], [0.225, 0.775]); + }) + .catch(failTest) + .then(done); + }); }); describe('categoryorder', function() {