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/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/src/plot_api/plot_api.js b/src/plot_api/plot_api.js index 1643b821025..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'); @@ -190,8 +192,7 @@ Plotly.plot = function(gd, data, layout, config) { return Lib.syncOrAsync([ subroutines.layoutStyles, - drawAxes, - initInteractions + drawAxes ], gd); } @@ -220,19 +221,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 +271,10 @@ 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]); + var ax = axList[i]; + cleanAxisConstraints(gd, ax); + + Plotly.Axes.doAutoRange(ax); } enforceAxisConstraints(gd); @@ -365,11 +369,13 @@ Plotly.plot = function(gd, data, layout, config) { drawFramework, marginPushers, marginPushersAgain, + initInteractions, positionAndAutorange, subroutines.layoutStyles, drawAxes, drawData, finalDraw, + initInteractions, Plots.rehover ]; @@ -1917,10 +1923,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 +1971,25 @@ 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]\])?$/)) { + 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 +2069,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 +2216,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/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/src/plots/cartesian/axes.js b/src/plots/cartesian/axes.js index f2560c08f12..32536757cec 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.constrain === 'domain') && 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..ac5cca0b979 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) { + +exports.enforce = 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]; @@ -35,12 +37,18 @@ module.exports = 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.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 // having to calculate it from length and range ax.setScale(); @@ -48,27 +56,148 @@ 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 { + 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++) { 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(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 + * 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 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; - if(normScale !== matchScale) { - scaleZoom(axes[axisID], normScale / matchScale); + 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[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[k].val + ax._max[k].pad / m; + if(newVal < outerMax && newVal > rangeMax) { + rangeMax = newVal; + } + } + + 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); + } } } } }; + +// 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]; + 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..7be44097c2a 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; @@ -183,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; @@ -281,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); @@ -334,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) { @@ -523,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; @@ -542,8 +548,13 @@ 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[0]'] = ax.range[0]; + updates[ax._name + '.range[1]'] = ax.range[1]; } function redrawObjs(objArray, method, shortCircuit) { @@ -640,24 +651,11 @@ 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 @@ -692,11 +690,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 +709,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 +718,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); @@ -799,7 +801,7 @@ function getEndText(ax, end) { } } -function zoomAxRanges(axList, r0Fraction, r1Fraction, linkedAxes) { +function zoomAxRanges(axList, r0Fraction, r1Fraction, updates, linkedAxes) { var i, axi, axRangeLinear0, @@ -815,13 +817,15 @@ function zoomAxRanges(axList, r0Fraction, r1Fraction, linkedAxes) { axi.l2r(axRangeLinear0 + axRangeLinearSpan * r0Fraction), axi.l2r(axRangeLinear0 + axRangeLinearSpan * r1Fraction) ]; + updates[axi._name + '.range[0]'] = axi.range[0]; + updates[axi._name + '.range[1]'] = axi.range[1]; } // 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); } } 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) ]; }; diff --git a/src/plots/gl2d/scene2d.js b/src/plots/gl2d/scene2d.js index 65e854f5e96..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; @@ -67,6 +69,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 +160,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) { @@ -381,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; @@ -427,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(); @@ -574,7 +594,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 +678,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/image/baselines/axes_scaleanchor.png b/test/image/baselines/axes_scaleanchor.png index d7aa4a6e78f..ea4c3118c69 100644 Binary files a/test/image/baselines/axes_scaleanchor.png and b/test/image/baselines/axes_scaleanchor.png differ diff --git a/test/image/mocks/axes_scaleanchor.json b/test/image/mocks/axes_scaleanchor.json 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..a48525f952f 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,263 @@ 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); + }); + + 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() { @@ -628,25 +885,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 +915,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 +923,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 +931,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 +943,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 +951,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 +962,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 +970,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 +978,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 +990,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 +998,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 +1031,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 +1055,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 +1075,7 @@ describe('Test axes', function() { } }; - PlotlyInternal.plot(gd, data, layout); + Plotly.plot(gd, data, layout); var yaxis = gd._fullLayout.yaxis; expect(yaxis.tickangle).toBeUndefined(); 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'); diff --git a/test/jasmine/tests/gl2d_click_test.js b/test/jasmine/tests/gl2d_click_test.js index 95b7ee5f6f1..ef9896fb254 100644 --- a/test/jasmine/tests/gl2d_click_test.js +++ b/test/jasmine/tests/gl2d_click_test.js @@ -12,6 +12,8 @@ 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'); +var mouseEvent = require('../assets/mouse_event'); // contourgl is not part of the dist plotly.js bundle initially Plotly.register([ @@ -62,13 +64,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 +85,33 @@ 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'; - }); - + var initialElement = document.elementFromPoint(x0, y0); // fairly realistic simulation of moving with the cursor var canceler = setInterval(function() { - hover(x0--, y0--); + 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() { + 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 +121,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 +178,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 +235,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 +255,8 @@ describe('Test hover and click interactions', function() { curveNumber: 0, pointNumber: 33, noHoverLabel: true + }, { + msg: 'scattergl with hoverinfo' }); Plotly.plot(gd, _mock) @@ -277,6 +283,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 +316,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 +339,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 +354,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 +384,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 +402,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 +434,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) 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'); }) 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]; 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() { 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; 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]); }) diff --git a/test/jasmine/tests/plots_test.js b/test/jasmine/tests/plots_test.js index 4634488c94e..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)).toEqual(expectedKeys); - 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(); + expect(Object.keys(gd).sort()).toEqual(expectedKeys.sort()); + expectedUndefined.forEach(function(key) { + expect(gd[key]).toBeUndefined(key); + }); }); }); 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.' + ); + }); });