diff --git a/src/components/annotations/convert_coords.js b/src/components/annotations/convert_coords.js new file mode 100644 index 00000000000..ba3441f0b2f --- /dev/null +++ b/src/components/annotations/convert_coords.js @@ -0,0 +1,59 @@ +/** +* Copyright 2012-2017, Plotly, Inc. +* All rights reserved. +* +* This source code is licensed under the MIT license found in the +* LICENSE file in the root directory of this source tree. +*/ + + +'use strict'; + +var isNumeric = require('fast-isnumeric'); +var toLogRange = require('../../lib/to_log_range'); + +/* + * convertCoords: when converting an axis between log and linear + * you need to alter any annotations on that axis to keep them + * pointing at the same data point. + * In v2.0 this will become obsolete + * + * gd: the plot div + * ax: the axis being changed + * newType: the type it's getting + * doExtra: function(attr, val) from inside relayout that sets the attribute. + * Use this to make the changes as it's aware if any other changes in the + * same relayout call should override this conversion. + */ +module.exports = function convertCoords(gd, ax, newType, doExtra) { + var toLog = (newType === 'log') && (ax.type === 'linear'), + fromLog = (newType === 'linear') && (ax.type === 'log'); + + if(!(toLog || fromLog)) return; + + var annotations = gd._fullLayout.annotations, + axLetter = ax._id.charAt(0), + ann, + attrPrefix; + + function convert(attr) { + var currentVal = ann[attr], + newVal = null; + + if(toLog) newVal = toLogRange(currentVal, ax.range); + else newVal = Math.pow(10, currentVal); + + // if conversion failed, delete the value so it gets a default value + if(!isNumeric(newVal)) newVal = null; + + doExtra(attrPrefix + attr, newVal); + } + + for(var i = 0; i < annotations.length; i++) { + ann = annotations[i]; + attrPrefix = 'annotations[' + i + '].'; + + if(ann[axLetter + 'ref'] === ax._id) convert(axLetter); + if(ann['a' + axLetter + 'ref'] === ax._id) convert('a' + axLetter); + } +}; diff --git a/src/components/annotations/draw.js b/src/components/annotations/draw.js index f6cb37b8515..97d95aa3f49 100644 --- a/src/components/annotations/draw.js +++ b/src/components/annotations/draw.js @@ -10,7 +10,6 @@ 'use strict'; var d3 = require('d3'); -var isNumeric = require('fast-isnumeric'); var Plotly = require('../../plotly'); var Plots = require('../../plots/plots'); @@ -22,8 +21,6 @@ var svgTextUtils = require('../../lib/svg_text_utils'); var setCursor = require('../../lib/setcursor'); var dragElement = require('../dragelement'); -var handleAnnotationDefaults = require('./annotation_defaults'); -var supplyLayoutDefaults = require('./defaults'); var drawArrowHead = require('./draw_arrow_head'); @@ -41,6 +38,9 @@ module.exports = { drawOne: drawOne }; +/* + * draw: draw all annotations without any new modifications + */ function draw(gd) { var fullLayout = gd._fullLayout; @@ -55,196 +55,26 @@ function draw(gd) { return Plots.previousPromises(gd); } -function drawOne(gd, index, opt, value) { +/* + * drawOne: draw a single annotation, potentially with modifications + * + * index (int): the annotation to draw + */ +function drawOne(gd, index) { var layout = gd.layout, fullLayout = gd._fullLayout, - i; - - if(!isNumeric(index) || index === -1) { - - // no index provided - we're operating on ALL annotations - if(!index && Array.isArray(value)) { - // a whole annotation array is passed in - // (as in, redo of delete all) - layout.annotations = value; - supplyLayoutDefaults(layout, fullLayout); - draw(gd); - return; - } - else if(value === 'remove') { - // delete all - delete layout.annotations; - fullLayout.annotations = []; - draw(gd); - return; - } - else if(opt && value !== 'add') { - // make the same change to all annotations - for(i = 0; i < fullLayout.annotations.length; i++) { - drawOne(gd, i, opt, value); - } - return; - } - else { - // add a new empty annotation - index = fullLayout.annotations.length; - fullLayout.annotations.push({}); - } - } - - if(!opt && value) { - if(value === 'remove') { - fullLayout._infolayer.selectAll('.annotation[data-index="' + index + '"]') - .remove(); - fullLayout.annotations.splice(index, 1); - layout.annotations.splice(index, 1); - for(i = index; i < fullLayout.annotations.length; i++) { - fullLayout._infolayer - .selectAll('.annotation[data-index="' + (i + 1) + '"]') - .attr('data-index', String(i)); - - // redraw all annotations past the removed one, - // so they bind to the right events - drawOne(gd, i); - } - return; - } - else if(value === 'add' || Lib.isPlainObject(value)) { - fullLayout.annotations.splice(index, 0, {}); - - var rule = Lib.isPlainObject(value) ? - Lib.extendFlat({}, value) : - {text: 'New text'}; - - if(layout.annotations) { - layout.annotations.splice(index, 0, rule); - } else { - layout.annotations = [rule]; - } - - for(i = fullLayout.annotations.length - 1; i > index; i--) { - fullLayout._infolayer - .selectAll('.annotation[data-index="' + (i - 1) + '"]') - .attr('data-index', String(i)); - drawOne(gd, i); - } - } - } + gs = gd._fullLayout._size; // remove the existing annotation if there is one fullLayout._infolayer.selectAll('.annotation[data-index="' + index + '"]').remove(); // remember a few things about what was already there, var optionsIn = layout.annotations[index], - oldPrivate = fullLayout.annotations[index]; - - // not sure how we're getting here... but C12 is seeing a bug - // where we fail here when they add/remove annotations - if(!optionsIn) return; + options = fullLayout.annotations[index]; - // alter the input annotation as requested - var optionsEdit = {}; - if(typeof opt === 'string' && opt) optionsEdit[opt] = value; - else if(Lib.isPlainObject(opt)) optionsEdit = opt; - - var optionKeys = Object.keys(optionsEdit); - for(i = 0; i < optionKeys.length; i++) { - var k = optionKeys[i]; - Lib.nestedProperty(optionsIn, k).set(optionsEdit[k]); - } - - // return early in visible: false updates - if(optionsIn.visible === false) return; - - var gs = fullLayout._size; - var oldRef = {xref: optionsIn.xref, yref: optionsIn.yref}; - - var axLetters = ['x', 'y']; - for(i = 0; i < 2; i++) { - var axLetter = axLetters[i]; - // if we don't have an explicit position already, - // don't set one just because we're changing references - // or axis type. - // the defaults will be consistent most of the time anyway, - // except in log/linear changes - if(optionsEdit[axLetter] !== undefined || - optionsIn[axLetter] === undefined) { - continue; - } - - var axOld = Axes.getFromId(gd, Axes.coerceRef(oldRef, {}, gd, axLetter, '', 'paper')), - axNew = Axes.getFromId(gd, Axes.coerceRef(optionsIn, {}, gd, axLetter, '', 'paper')), - position = optionsIn[axLetter], - axTypeOld = oldPrivate['_' + axLetter + 'type']; - - if(optionsEdit[axLetter + 'ref'] !== undefined) { - - // TODO: include ax / ay / axref / ayref here if not 'pixel' - // or even better, move all of this machinery out of here and into - // streambed as extra attributes to a regular relayout call - // we should do this after v2.0 when it can work equivalently for - // annotations, shapes, and images. - - var autoAnchor = optionsIn[axLetter + 'anchor'] === 'auto', - plotSize = (axLetter === 'x' ? gs.w : gs.h), - halfSizeFrac = (oldPrivate['_' + axLetter + 'size'] || 0) / - (2 * plotSize); - if(axOld && axNew) { // data -> different data - // go to the same fraction of the axis length - // whether or not these axes share a domain - - position = axNew.fraction2r(axOld.r2fraction(position)); - } - else if(axOld) { // data -> paper - // first convert to fraction of the axis - position = axOld.r2fraction(position); - - // next scale the axis to the whole plot - position = axOld.domain[0] + - position * (axOld.domain[1] - axOld.domain[0]); - - // finally see if we need to adjust auto alignment - // because auto always means middle / center alignment for data, - // but it changes for page alignment based on the closest side - if(autoAnchor) { - var posPlus = position + halfSizeFrac, - posMinus = position - halfSizeFrac; - if(position + posMinus < 2 / 3) position = posMinus; - else if(position + posPlus > 4 / 3) position = posPlus; - } - } - else if(axNew) { // paper -> data - // first see if we need to adjust auto alignment - if(autoAnchor) { - if(position < 1 / 3) position += halfSizeFrac; - else if(position > 2 / 3) position -= halfSizeFrac; - } - - // next convert to fraction of the axis - position = (position - axNew.domain[0]) / - (axNew.domain[1] - axNew.domain[0]); - - // finally convert to data coordinates - position = axNew.fraction2r(position); - } - } - - if(axNew && axNew === axOld && axTypeOld) { - if(axTypeOld === 'log' && axNew.type !== 'log') { - position = Math.pow(10, position); - } - else if(axTypeOld !== 'log' && axNew.type === 'log') { - position = (position > 0) ? - Math.log(position) / Math.LN10 : undefined; - } - } - - optionsIn[axLetter] = position; - } - - var options = {}; - handleAnnotationDefaults(optionsIn, options, fullLayout); - fullLayout.annotations[index] = options; + // this annotation is gone - quit now after deleting it + // TODO: use d3 idioms instead of deleting and redrawing every time + if(!optionsIn || options.visible === false) return; var xa = Axes.getFromId(gd, options.xref), ya = Axes.getFromId(gd, options.yref), @@ -457,9 +287,6 @@ function drawOne(gd, index, opt, value) { options['_' + axLetter + 'padplus'] = (annSize / 2) + textPadShift; options['_' + axLetter + 'padminus'] = (annSize / 2) - textPadShift; - - // save the current axis type for later log/linear changes - options['_' + axLetter + 'type'] = ax && ax.type; }); if(annotationIsOffscreen) { diff --git a/src/components/annotations/index.js b/src/components/annotations/index.js index bb32b6b69df..aea3d914aa6 100644 --- a/src/components/annotations/index.js +++ b/src/components/annotations/index.js @@ -24,5 +24,7 @@ module.exports = { drawOne: drawModule.drawOne, hasClickToShow: clickModule.hasClickToShow, - onClick: clickModule.onClick + onClick: clickModule.onClick, + + convertCoords: require('./convert_coords') }; diff --git a/src/components/images/convert_coords.js b/src/components/images/convert_coords.js new file mode 100644 index 00000000000..a2dd2a8608e --- /dev/null +++ b/src/components/images/convert_coords.js @@ -0,0 +1,79 @@ +/** +* Copyright 2012-2017, Plotly, Inc. +* All rights reserved. +* +* This source code is licensed under the MIT license found in the +* LICENSE file in the root directory of this source tree. +*/ + + +'use strict'; + +var isNumeric = require('fast-isnumeric'); +var toLogRange = require('../../lib/to_log_range'); + +/* + * convertCoords: when converting an axis between log and linear + * you need to alter any images on that axis to keep them + * pointing at the same data point. + * In v2.0 this will become obsolete (or perhaps size will still need conversion?) + * we convert size by declaring that the maximum extent *in data units* should be + * the same, assuming the image is anchored by its center (could remove that restriction + * if we think it's important) even though the actual left and right values will not be + * quite the same since the scale becomes nonlinear (and central anchor means the pixel + * center of the image, not the data units center) + * + * gd: the plot div + * ax: the axis being changed + * newType: the type it's getting + * doExtra: function(attr, val) from inside relayout that sets the attribute. + * Use this to make the changes as it's aware if any other changes in the + * same relayout call should override this conversion. + */ +module.exports = function convertCoords(gd, ax, newType, doExtra) { + var toLog = (newType === 'log') && (ax.type === 'linear'), + fromLog = (newType === 'linear') && (ax.type === 'log'); + + if(!(toLog || fromLog)) return; + + var images = gd._fullLayout.images, + axLetter = ax._id.charAt(0), + image, + attrPrefix; + + for(var i = 0; i < images.length; i++) { + image = images[i]; + attrPrefix = 'images[' + i + '].'; + + if(image[axLetter + 'ref'] === ax._id) { + var currentPos = image[axLetter], + currentSize = image['size' + axLetter], + newPos = null, + newSize = null; + + if(toLog) { + newPos = toLogRange(currentPos, ax.range); + + // this is the inverse of the conversion we do in fromLog below + // so that the conversion is reversible (notice the fromLog conversion + // is like sinh, and this one looks like arcsinh) + var dx = currentSize / Math.pow(10, newPos) / 2; + newSize = 2 * Math.log(dx + Math.sqrt(1 + dx * dx)) / Math.LN10; + } + else { + newPos = Math.pow(10, currentPos); + newSize = newPos * (Math.pow(10, currentSize / 2) - Math.pow(10, -currentSize / 2)); + } + + // if conversion failed, delete the value so it can get a default later on + if(!isNumeric(newPos)) { + newPos = null; + newSize = null; + } + else if(!isNumeric(newSize)) newSize = null; + + doExtra(attrPrefix + axLetter, newPos); + doExtra(attrPrefix + 'size' + axLetter, newSize); + } + } +}; diff --git a/src/components/images/defaults.js b/src/components/images/defaults.js index 0c6c5b32c93..8073db69aa9 100644 --- a/src/components/images/defaults.js +++ b/src/components/images/defaults.js @@ -37,8 +37,6 @@ function imageDefaults(imageIn, imageOut, fullLayout) { if(!visible) return imageOut; coerce('layer'); - coerce('x'); - coerce('y'); coerce('xanchor'); coerce('yanchor'); coerce('sizex'); @@ -51,7 +49,10 @@ function imageDefaults(imageIn, imageOut, fullLayout) { for(var i = 0; i < 2; i++) { // 'paper' is the fallback axref - Axes.coerceRef(imageIn, imageOut, gdMock, axLetters[i], 'paper'); + var axLetter = axLetters[i], + axRef = Axes.coerceRef(imageIn, imageOut, gdMock, axLetter, 'paper'); + + Axes.coercePosition(imageOut, gdMock, coerce, axRef, axLetter, 0); } return imageOut; diff --git a/src/components/images/index.js b/src/components/images/index.js index d7ce308ae28..3a4269ef8df 100644 --- a/src/components/images/index.js +++ b/src/components/images/index.js @@ -15,5 +15,7 @@ module.exports = { layoutAttributes: require('./attributes'), supplyLayoutDefaults: require('./defaults'), - draw: require('./draw') + draw: require('./draw'), + + convertCoords: require('./convert_coords') }; diff --git a/src/components/shapes/draw.js b/src/components/shapes/draw.js index 314cd339f76..fc019c7a1f8 100644 --- a/src/components/shapes/draw.js +++ b/src/components/shapes/draw.js @@ -9,8 +9,6 @@ 'use strict'; -var isNumeric = require('fast-isnumeric'); - var Plotly = require('../../plotly'); var Lib = require('../../lib'); var Axes = require('../../plots/cartesian/axes'); @@ -22,8 +20,6 @@ var setCursor = require('../../lib/setcursor'); var constants = require('./constants'); var helpers = require('./helpers'); -var handleShapeDefaults = require('./shape_defaults'); -var supplyLayoutDefaults = require('./defaults'); // Shapes are stored in gd.layout.shapes, an array of objects @@ -58,183 +54,21 @@ function draw(gd) { // return Plots.previousPromises(gd); } -function drawOne(gd, index, opt, value) { - if(!isNumeric(index) || index === -1) { - - // no index provided - we're operating on ALL shapes - if(!index && Array.isArray(value)) { - replaceAllShapes(gd, value); - return; - } - else if(value === 'remove') { - deleteAllShapes(gd); - return; - } - else if(opt && value !== 'add') { - updateAllShapes(gd, opt, value); - return; - } - else { - // add a new empty annotation - index = gd._fullLayout.shapes.length; - gd._fullLayout.shapes.push({}); - } - } - - if(!opt && value) { - if(value === 'remove') { - deleteShape(gd, index); - return; - } - else if(value === 'add' || Lib.isPlainObject(value)) { - insertShape(gd, index, value); - } - } - - updateShape(gd, index, opt, value); -} - -function replaceAllShapes(gd, newShapes) { - gd.layout.shapes = newShapes; - supplyLayoutDefaults(gd.layout, gd._fullLayout); - draw(gd); -} - -function deleteAllShapes(gd) { - delete gd.layout.shapes; - gd._fullLayout.shapes = []; - draw(gd); -} - -function updateAllShapes(gd, opt, value) { - for(var i = 0; i < gd._fullLayout.shapes.length; i++) { - drawOne(gd, i, opt, value); - } -} - -function deleteShape(gd, index) { - getShapeLayer(gd, index) - .selectAll('[data-index="' + index + '"]') - .remove(); - - gd._fullLayout.shapes.splice(index, 1); - - gd.layout.shapes.splice(index, 1); - - for(var i = index; i < gd._fullLayout.shapes.length; i++) { - // redraw all shapes past the removed one, - // so they bind to the right events - getShapeLayer(gd, i) - .selectAll('[data-index="' + (i + 1) + '"]') - .attr('data-index', i); - drawOne(gd, i); - } -} - -function insertShape(gd, index, newShape) { - gd._fullLayout.shapes.splice(index, 0, {}); - - var rule = Lib.isPlainObject(newShape) ? - Lib.extendFlat({}, newShape) : - {text: 'New text'}; - - if(gd.layout.shapes) { - gd.layout.shapes.splice(index, 0, rule); - } else { - gd.layout.shapes = [rule]; - } - - // there is no need to call shapes.draw(gd, index), - // because updateShape() is called from within shapes.draw() - - for(var i = gd._fullLayout.shapes.length - 1; i > index; i--) { - getShapeLayer(gd, i) - .selectAll('[data-index="' + (i - 1) + '"]') - .attr('data-index', i); - drawOne(gd, i); - } -} - -function updateShape(gd, index, opt, value) { +function drawOne(gd, index) { var i, n; - // remove the existing shape if there is one - getShapeLayer(gd, index) - .selectAll('[data-index="' + index + '"]') + // remove the existing shape if there is one. + // because indices can change, we need to look in all shape layers + gd._fullLayout._paper + .selectAll('.shapelayer [data-index="' + index + '"]') .remove(); - // remember a few things about what was already there, - var optionsIn = gd.layout.shapes[index]; - - // (from annos...) not sure how we're getting here... but C12 is seeing a bug - // where we fail here when they add/remove annotations - // TODO: clean this up and remove it. - if(!optionsIn) return; - - // alter the input shape as requested - var optionsEdit = {}; - if(typeof opt === 'string' && opt) optionsEdit[opt] = value; - else if(Lib.isPlainObject(opt)) optionsEdit = opt; - - var optionKeys = Object.keys(optionsEdit); - for(i = 0; i < optionKeys.length; i++) { - var k = optionKeys[i]; - Lib.nestedProperty(optionsIn, k).set(optionsEdit[k]); - } - - // return early in visible: false updates - if(optionsIn.visible === false) return; - - var oldRef = {xref: optionsIn.xref, yref: optionsIn.yref}, - posAttrs = ['x0', 'x1', 'y0', 'y1']; - - for(i = 0; i < 4; i++) { - var posAttr = posAttrs[i]; - // if we don't have an explicit position already, - // don't set one just because we're changing references - // or axis type. - // the defaults will be consistent most of the time anyway, - // except in log/linear changes - if(optionsEdit[posAttr] !== undefined || - optionsIn[posAttr] === undefined) { - continue; - } - - var axLetter = posAttr.charAt(0), - axOld = Axes.getFromId(gd, - Axes.coerceRef(oldRef, {}, gd, axLetter, '', 'paper')), - axNew = Axes.getFromId(gd, - Axes.coerceRef(optionsIn, {}, gd, axLetter, '', 'paper')), - position = optionsIn[posAttr], - rangePosition; - - if(optionsEdit[axLetter + 'ref'] !== undefined) { - // first convert to fraction of the axis - if(axOld) { - rangePosition = helpers.shapePositionToRange(axOld)(position); - position = axOld.r2fraction(rangePosition); - } else { - position = (position - axNew.domain[0]) / - (axNew.domain[1] - axNew.domain[0]); - } - - if(axNew) { - // then convert to new data coordinates at the same fraction - rangePosition = axNew.fraction2r(position); - position = helpers.rangeToShapePosition(axNew)(rangePosition); - } else { - // or scale to the whole plot - position = axOld.domain[0] + - position * (axOld.domain[1] - axOld.domain[0]); - } - } - - optionsIn[posAttr] = position; - } + var optionsIn = gd.layout.shapes[index], + options = gd._fullLayout.shapes[index]; - var options = {}; - handleShapeDefaults(optionsIn, options, gd._fullLayout); - gd._fullLayout.shapes[index] = options; + // this shape is gone - quit now after deleting it + // TODO: use d3 idioms instead of deleting and redrawing every time + if(!optionsIn || options.visible === false) return; var clipAxes; if(options.layer !== 'below') { @@ -437,22 +271,6 @@ function setupDragElement(gd, shapePath, shapeOptions, index) { } } -function getShapeLayer(gd, index) { - var shape = gd._fullLayout.shapes[index], - shapeLayer = gd._fullLayout._shapeUpperLayer; - - if(!shape) { - Lib.log('getShapeLayer: undefined shape: index', index); - } - else if(shape.layer === 'below') { - shapeLayer = (shape.xref === 'paper' && shape.yref === 'paper') ? - gd._fullLayout._shapeLowerLayer : - gd._fullLayout._shapeSubplotLayer; - } - - return shapeLayer; -} - function isShapeInSubplot(gd, shape, plotinfo) { var xa = Axes.getFromId(gd, plotinfo.id, 'x')._id, ya = Axes.getFromId(gd, plotinfo.id, 'y')._id, diff --git a/src/components/updatemenus/attributes.js b/src/components/updatemenus/attributes.js index f04465f9ec3..9dd9f9b155d 100644 --- a/src/components/updatemenus/attributes.js +++ b/src/components/updatemenus/attributes.js @@ -49,6 +49,7 @@ var buttonsAttrs = { module.exports = { _isLinkedToArray: 'updatemenu', + _arrayAttrRegexps: [/^updatemenus\[(0|[1-9][0-9]+)\]\.buttons/], visible: { valType: 'boolean', diff --git a/src/lib/identity.js b/src/lib/identity.js new file mode 100644 index 00000000000..426b69699a4 --- /dev/null +++ b/src/lib/identity.js @@ -0,0 +1,14 @@ +/** +* 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'; + +// Simple helper functions +// none of these need any external deps + +module.exports = function identity(d) { return d; }; diff --git a/src/lib/index.js b/src/lib/index.js index 9544f4b3794..e1b6475a29b 100644 --- a/src/lib/index.js +++ b/src/lib/index.js @@ -17,6 +17,8 @@ lib.nestedProperty = require('./nested_property'); lib.isPlainObject = require('./is_plain_object'); lib.isArray = require('./is_array'); lib.mod = require('./mod'); +lib.toLogRange = require('./to_log_range'); +lib.relinkPrivateKeys = require('./relink_private'); var coerceModule = require('./coerce'); lib.valObjects = coerceModule.valObjects; @@ -80,10 +82,13 @@ lib.notifier = require('./notifier'); lib.filterUnique = require('./filter_unique'); lib.filterVisible = require('./filter_visible'); - +lib.pushUnique = require('./push_unique'); lib.cleanNumber = require('./clean_number'); +lib.noop = require('./noop'); +lib.identity = require('./identity'); + /** * swap x and y of the same attribute in container cont * specify attr with a ? in place of x/y @@ -135,12 +140,6 @@ lib.bBoxIntersect = function(a, b, pad) { b.top <= a.bottom + pad); }; -// minor convenience/performance booster for d3... -lib.identity = function(d) { return d; }; - -// minor convenience helper -lib.noop = function() {}; - /* * simpleMap: alternative to Array.map that only * passes on the element and up to 2 extra args you @@ -337,23 +336,6 @@ lib.noneOrAll = function(containerIn, containerOut, attrList) { } }; -/** - * Push array with unique items - * - * @param {array} array - * array to be filled - * @param {any} item - * item to be or not to be inserted - * @return {array} - * ref to array (now possibly containing one more item) - * - */ -lib.pushUnique = function(array, item) { - if(item && array.indexOf(item) === -1) array.push(item); - - return array; -}; - lib.mergeArray = function(traceAttr, cd, cdAttr) { if(Array.isArray(traceAttr)) { var imax = Math.min(traceAttr.length, cd.length); diff --git a/src/lib/nested_property.js b/src/lib/nested_property.js index a00cd17137a..42db6baf574 100644 --- a/src/lib/nested_property.js +++ b/src/lib/nested_property.js @@ -11,6 +11,8 @@ var isNumeric = require('fast-isnumeric'); var isArray = require('./is_array'); +var isPlainObject = require('./is_plain_object'); +var containerArrayMatch = require('../plot_api/container_array_match'); /** * convert a string s (such as 'xaxis.range[0]') @@ -66,7 +68,7 @@ module.exports = function nestedProperty(container, propStr) { } return { - set: npSet(container, propParts), + set: npSet(container, propParts, propStr), get: npGet(container, propParts), astr: propStr, parts: propParts, @@ -113,24 +115,52 @@ function npGet(cont, parts) { } /* - * Check known non-data-array arrays (containers). Data arrays only contain scalars, - * so parts[end] values, such as -1 or n, indicate we are not dealing with a dataArray. - * The ONLY case we are looking for is where the entire array is selected, parts[end] === 'x' - * AND the replacement value is an array. + * Can this value be deleted? We can delete any empty object (null, undefined, [], {}) + * EXCEPT empty data arrays, {} inside an array, or anything INSIDE an *args* array. + * + * Info arrays can be safely deleted, but not deleting them has no ill effects other + * than leaving a trace or layout object with some cruft in it. + * + * Deleting data arrays can change the meaning of the object, as `[]` means there is + * data for this attribute, it's just empty right now while `undefined` means the data + * should be filled in with defaults to match other data arrays. + * + * `{}` inside an array means "the default object" which is clearly different from + * popping it off the end of the array, or setting it `undefined` inside the array. + * + * *args* arrays get passed directly to API methods and we should respect precisely + * what the user has put there - although if the whole *args* array is empty it's fine + * to delete that. + * + * So we do some simple tests here to find known non-data arrays but don't worry too + * much about not deleting some arrays that would actually be safe to delete. */ -function isDataArray(val, key) { +var INFO_PATTERNS = /(^|\.)((domain|range)(\.[xy])?|args|parallels)$/; +var ARGS_PATTERN = /(^|\.)args\[/; +function isDeletable(val, propStr) { + if(!emptyObj(val) || + (isPlainObject(val) && propStr.charAt(propStr.length - 1) === ']') || + (propStr.match(ARGS_PATTERN) && val !== undefined) + ) { + return false; + } + if(!isArray(val)) return true; - var containers = ['annotations', 'shapes', 'range', 'domain', 'buttons'], - isNotAContainer = containers.indexOf(key) === -1; + if(propStr.match(INFO_PATTERNS)) return true; - return isArray(val) && isNotAContainer; + var match = containerArrayMatch(propStr); + // if propStr matches the container array itself, index is an empty string + // otherwise we've matched something inside the container array, which may + // still be a data array. + return match && (match.index === ''); } -function npSet(cont, parts) { +function npSet(cont, parts, propStr) { return function(val) { var curCont = cont, - containerLevels = [cont], - toDelete = emptyObj(val) && !isDataArray(val, parts[parts.length - 1]), + propPart = '', + containerLevels = [[cont, propPart]], + toDelete = isDeletable(val, propStr), curPart, i; @@ -143,7 +173,7 @@ function npSet(cont, parts) { // handle special -1 array index if(curPart === -1) { - toDelete = !setArrayAll(curCont, parts.slice(i + 1), val); + toDelete = !setArrayAll(curCont, parts.slice(i + 1), val, propStr); if(toDelete) break; else return; } @@ -158,7 +188,9 @@ function npSet(cont, parts) { throw 'container is not an object'; } - containerLevels.push(curCont); + propPart = joinPropStr(propPart, curPart); + + containerLevels.push([curCont, propPart]); } if(toDelete) { @@ -169,25 +201,35 @@ function npSet(cont, parts) { }; } +function joinPropStr(propStr, newPart) { + var toAdd = newPart; + if(isNumeric(newPart)) toAdd = '[' + newPart + ']'; + else if(propStr) toAdd = '.' + newPart; + + return propStr + toAdd; +} + // handle special -1 array index -function setArrayAll(containerArray, innerParts, val) { +function setArrayAll(containerArray, innerParts, val, propStr) { var arrayVal = isArray(val), allSet = true, thisVal = val, - deleteThis = arrayVal ? false : emptyObj(val), + thisPropStr = propStr.replace('-1', 0), + deleteThis = arrayVal ? false : isDeletable(val, thisPropStr), firstPart = innerParts[0], i; for(i = 0; i < containerArray.length; i++) { + thisPropStr = propStr.replace('-1', i); if(arrayVal) { thisVal = val[i % val.length]; - deleteThis = emptyObj(thisVal); + deleteThis = isDeletable(thisVal, thisPropStr); } if(deleteThis) allSet = false; if(!checkNewContainer(containerArray, i, firstPart, deleteThis)) { continue; } - npSet(containerArray[i], innerParts)(thisVal); + npSet(containerArray[i], innerParts, propStr.replace('-1', i))(thisVal); } return allSet; } @@ -211,14 +253,17 @@ function pruneContainers(containerLevels) { var i, j, curCont, + propPart, keys, remainingKeys; for(i = containerLevels.length - 1; i >= 0; i--) { - curCont = containerLevels[i]; + curCont = containerLevels[i][0]; + propPart = containerLevels[i][1]; + remainingKeys = false; if(isArray(curCont)) { for(j = curCont.length - 1; j >= 0; j--) { - if(emptyObj(curCont[j])) { + if(isDeletable(curCont[j], joinPropStr(propPart, j))) { if(remainingKeys) curCont[j] = undefined; else curCont.pop(); } @@ -229,7 +274,9 @@ function pruneContainers(containerLevels) { keys = Object.keys(curCont); remainingKeys = false; for(j = keys.length - 1; j >= 0; j--) { - if(emptyObj(curCont[keys[j]]) && !isDataArray(curCont[keys[j]], keys[j])) delete curCont[keys[j]]; + if(isDeletable(curCont[keys[j]], joinPropStr(propPart, keys[j]))) { + delete curCont[keys[j]]; + } else remainingKeys = true; } } diff --git a/src/lib/noop.js b/src/lib/noop.js new file mode 100644 index 00000000000..e15fff56632 --- /dev/null +++ b/src/lib/noop.js @@ -0,0 +1,14 @@ +/** +* 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'; + +// Simple helper functions +// none of these need any external deps + +module.exports = function noop() {}; diff --git a/src/lib/push_unique.js b/src/lib/push_unique.js new file mode 100644 index 00000000000..b0c8e54e91d --- /dev/null +++ b/src/lib/push_unique.js @@ -0,0 +1,36 @@ +/** +* 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'; + +/** + * Push array with unique items + * + * @param {array} array + * array to be filled + * @param {any} item + * item to be or not to be inserted + * @return {array} + * ref to array (now possibly containing one more item) + * + */ +module.exports = function pushUnique(array, item) { + if(item instanceof RegExp) { + var itemStr = item.toString(), + i; + for(i = 0; i < array.length; i++) { + if(array[i] instanceof RegExp && array[i].toString() === itemStr) { + return array; + } + } + array.push(item); + } + else if(item && array.indexOf(item) === -1) array.push(item); + + return array; +}; diff --git a/src/lib/relink_private.js b/src/lib/relink_private.js new file mode 100644 index 00000000000..223ac3c5fc6 --- /dev/null +++ b/src/lib/relink_private.js @@ -0,0 +1,55 @@ +/** +* Copyright 2012-2017, Plotly, Inc. +* All rights reserved. +* +* This source code is licensed under the MIT license found in the +* LICENSE file in the root directory of this source tree. +*/ + + +'use strict'; + +var isArray = require('./is_array'); +var isPlainObject = require('./is_plain_object'); + +/** + * Relink private _keys and keys with a function value from one container + * to the new container. + * Relink means copying if object is pass-by-value and adding a reference + * if object is pass-by-ref. + * This prevents deepCopying massive structures like a webgl context. + */ +module.exports = function relinkPrivateKeys(toContainer, fromContainer) { + var keys = Object.keys(fromContainer || {}); + + for(var i = 0; i < keys.length; i++) { + var k = keys[i], + fromVal = fromContainer[k], + toVal = toContainer[k]; + + if(k.charAt(0) === '_' || typeof fromVal === 'function') { + + // if it already exists at this point, it's something + // that we recreate each time around, so ignore it + if(k in toContainer) continue; + + toContainer[k] = fromVal; + } + else if(isArray(fromVal) && isArray(toVal) && isPlainObject(fromVal[0])) { + + // recurse into arrays containers + for(var j = 0; j < fromVal.length; j++) { + if(isPlainObject(fromVal[j]) && isPlainObject(toVal[j])) { + relinkPrivateKeys(toVal[j], fromVal[j]); + } + } + } + else if(isPlainObject(fromVal) && isPlainObject(toVal)) { + + // recurse into objects, but only if they still exist + relinkPrivateKeys(toVal, fromVal); + + if(!Object.keys(toVal).length) delete toContainer[k]; + } + } +}; diff --git a/src/lib/to_log_range.js b/src/lib/to_log_range.js new file mode 100644 index 00000000000..624eac2d896 --- /dev/null +++ b/src/lib/to_log_range.js @@ -0,0 +1,26 @@ +/** +* Copyright 2012-2017, Plotly, Inc. +* All rights reserved. +* +* This source code is licensed under the MIT license found in the +* LICENSE file in the root directory of this source tree. +*/ + +'use strict'; + +var isNumeric = require('fast-isnumeric'); + +/** + * convert a linear value into a logged value, folding negative numbers into + * the given range + */ +module.exports = function toLogRange(val, range) { + if(val > 0) return Math.log(val) / Math.LN10; + + // move a negative value reference to a log axis - just put the + // result at the lowest range value on the plot (or if the range also went negative, + // one millionth of the top of the range) + var newVal = Math.log(Math.min(range[0], range[1])) / Math.LN10; + if(!isNumeric(newVal)) newVal = Math.log(Math.max(range[0], range[1])) / Math.LN10 - 6; + return newVal; +}; diff --git a/src/plot_api/container_array_match.js b/src/plot_api/container_array_match.js new file mode 100644 index 00000000000..af844cd8ce8 --- /dev/null +++ b/src/plot_api/container_array_match.js @@ -0,0 +1,56 @@ +/** +* Copyright 2012-2017, Plotly, Inc. +* All rights reserved. +* +* This source code is licensed under the MIT license found in the +* LICENSE file in the root directory of this source tree. +*/ + + +'use strict'; + +var Registry = require('../registry'); + +/* + * containerArrayMatch: does this attribute string point into a + * layout container array? + * + * @param {String} astr: an attribute string, like *annotations[2].text* + * + * @returns {Object | false} Returns false if `astr` doesn't match a container + * array. If it does, returns: + * {array: {String}, index: {Number}, property: {String}} + * ie the attribute string for the array, the index within the array (or '' + * if the whole array) and the property within that (or '' if the whole array + * or the whole object) + */ +module.exports = function containerArrayMatch(astr) { + var rootContainers = Registry.layoutArrayContainers, + regexpContainers = Registry.layoutArrayRegexes, + rootPart = astr.split('[')[0], + arrayStr, + match; + + // look for regexp matches first, because they may be nested inside root matches + // eg updatemenus[i].buttons is nested inside updatemenus + for(var i = 0; i < regexpContainers.length; i++) { + match = astr.match(regexpContainers[i]); + if(match && match.index === 0) { + arrayStr = match[0]; + break; + } + } + + // now look for root matches + if(!arrayStr) arrayStr = rootContainers[rootContainers.indexOf(rootPart)]; + + if(!arrayStr) return false; + + var tail = astr.substr(arrayStr.length); + if(!tail) return {array: arrayStr, index: '', property: ''}; + + match = tail.match(/^\[(0|[1-9][0-9]*)\](\.(.+))?$/); + if(!match) return false; + + return {array: arrayStr, index: Number(match[1]), property: match[3] || ''}; +}; diff --git a/src/plot_api/helpers.js b/src/plot_api/helpers.js index 62a4b7e38d5..26c03943d6a 100644 --- a/src/plot_api/helpers.js +++ b/src/plot_api/helpers.js @@ -439,7 +439,7 @@ exports.coerceTraceIndices = function(gd, traceIndices) { /** * Manages logic around array container item creation / deletion / update - * that nested property along can't handle. + * that nested property alone can't handle. * * @param {Object} np * nested property of update attribute string about trace or layout object @@ -484,3 +484,36 @@ exports.manageArrayContainers = function(np, newVal, undoit) { np.set(newVal); } }; + +/* + * Match the part to strip off to turn an attribute into its parent + * really it should be either '.some_characters' or '[number]' + * but we're a little more permissive here and match either + * '.not_brackets_or_dot' or '[not_brackets_or_dot]' + */ +var ATTR_TAIL_RE = /(\.[^\[\]\.]+|\[[^\[\]\.]+\])$/; + +function getParent(attr) { + var tail = attr.search(ATTR_TAIL_RE); + if(tail > 0) return attr.substr(0, tail); +} + +/* + * hasParent: does an attribute object contain a parent of the given attribute? + * for example, given 'images[2].x' do we also have 'images' or 'images[2]'? + * + * @param {Object} aobj + * update object, whose keys are attribute strings and values are their new settings + * @param {string} attr + * the attribute string to test against + * @returns {Boolean} + * is a parent of attr present in aobj? + */ +exports.hasParent = function(aobj, attr) { + var attrParent = getParent(attr); + while(attrParent) { + if(attrParent in aobj) return true; + attrParent = getParent(attrParent); + } + return false; +}; diff --git a/src/plot_api/manage_arrays.js b/src/plot_api/manage_arrays.js new file mode 100644 index 00000000000..d408496c208 --- /dev/null +++ b/src/plot_api/manage_arrays.js @@ -0,0 +1,211 @@ +/** +* Copyright 2012-2017, Plotly, Inc. +* All rights reserved. +* +* This source code is licensed under the MIT license found in the +* LICENSE file in the root directory of this source tree. +*/ + + +'use strict'; + +var nestedProperty = require('../lib/nested_property'); +var isPlainObject = require('../lib/is_plain_object'); +var noop = require('../lib/noop'); +var Loggers = require('../lib/loggers'); +var Registry = require('../registry'); + + +exports.containerArrayMatch = require('./container_array_match'); + +var isAddVal = exports.isAddVal = function isAddVal(val) { + return val === 'add' || isPlainObject(val); +}; + +var isRemoveVal = exports.isRemoveVal = function isRemoveVal(val) { + return val === null || val === 'remove'; +}; + +/* + * applyContainerArrayChanges: for managing arrays of layout components in relayout + * handles them all with a consistent interface. + * + * Here are the supported actions -> relayout calls -> edits we get here + * (as prepared in _relayout): + * + * add an empty obj -> {'annotations[2]': 'add'} -> {2: {'': 'add'}} + * add a specific obj -> {'annotations[2]': {attrs}} -> {2: {'': {attrs}}} + * delete an obj -> {'annotations[2]': 'remove'} -> {2: {'': 'remove'}} + * -> {'annotations[2]': null} -> {2: {'': null}} + * delete the whole array -> {'annotations': 'remove'} -> {'': {'': 'remove'}} + * -> {'annotations': null} -> {'': {'': null}} + * edit an object -> {'annotations[2].text': 'boo'} -> {2: {'text': 'boo'}} + * + * You can combine many edits to different objects. Objects are added and edited + * in ascending order, then removed in descending order. + * For example, starting with [a, b, c], if you want to: + * - replace b with d: + * {'annotations[1]': d, 'annotations[2]': null} (b is item 2 after adding d) + * - add a new item d between a and b, and edit b: + * {'annotations[1]': d, 'annotations[2].x': newX} (b is item 2 after adding d) + * - delete b and edit c: + * {'annotations[1]': null, 'annotations[2].x': newX} (c is edited before b is removed) + * + * You CANNOT combine adding/deleting an item at index `i` with edits to the same index `i` + * You CANNOT combine replacing/deleting the whole array with anything else (for the same array). + * + * @param {HTMLDivElement} gd + * the DOM element of the graph container div + * @param {Lib.nestedProperty} componentType: the array we are editing + * @param {Object} edits + * the changes to make; keys are indices to edit, values are themselves objects: + * {attr: newValue} of changes to make to that index (with add/remove behavior + * in special values of the empty attr) + * @param {Object} flags + * the flags for which actions we're going to perform to display these (and + * any other) changes. If we're already `recalc`ing, we don't need to redraw + * individual items + * + * @returns {bool} `true` if it managed to complete drawing of the changes + * `false` would mean the parent should replot. + */ +exports.applyContainerArrayChanges = function applyContainerArrayChanges(gd, np, edits, flags) { + var componentType = np.astr, + supplyComponentDefaults = Registry.getComponentMethod(componentType, 'supplyLayoutDefaults'), + draw = Registry.getComponentMethod(componentType, 'draw'), + drawOne = Registry.getComponentMethod(componentType, 'drawOne'), + replotLater = flags.replot || flags.recalc || (supplyComponentDefaults === noop) || + (draw === noop), + layout = gd.layout, + fullLayout = gd._fullLayout; + + if(edits['']) { + if(Object.keys(edits).length > 1) { + Loggers.warn('Full array edits are incompatible with other edits', + componentType); + } + + var fullVal = edits['']['']; + + if(isRemoveVal(fullVal)) np.set(null); + else if(Array.isArray(fullVal)) np.set(fullVal); + else { + Loggers.warn('Unrecognized full array edit value', componentType, fullVal); + return true; + } + + if(replotLater) return false; + + supplyComponentDefaults(layout, fullLayout); + draw(gd); + return true; + } + + var componentNums = Object.keys(edits).map(Number).sort(), + componentArrayIn = np.get(), + componentArray = componentArrayIn || [], + // componentArrayFull is used just to keep splices in line between + // full and input arrays, so private keys can be copied over after + // redoing supplyDefaults + // TODO: this assumes componentArray is in gd.layout - which will not be + // true after we extend this to restyle + componentArrayFull = nestedProperty(fullLayout, componentType).get(); + + var deletes = [], + firstIndexChange = -1, + maxIndex = componentArray.length, + i, + j, + componentNum, + objEdits, + objKeys, + objVal, + adding; + + // first make the add and edit changes + for(i = 0; i < componentNums.length; i++) { + componentNum = componentNums[i]; + objEdits = edits[componentNum]; + objKeys = Object.keys(objEdits); + objVal = objEdits[''], + adding = isAddVal(objVal); + + if(componentNum < 0 || componentNum > componentArray.length - (adding ? 0 : 1)) { + Loggers.warn('index out of range', componentType, componentNum); + continue; + } + + if(objVal !== undefined) { + if(objKeys.length > 1) { + Loggers.warn( + 'Insertion & removal are incompatible with edits to the same index.', + componentType, componentNum); + } + + if(isRemoveVal(objVal)) { + deletes.push(componentNum); + } + else if(adding) { + if(objVal === 'add') objVal = {}; + componentArray.splice(componentNum, 0, objVal); + if(componentArrayFull) componentArrayFull.splice(componentNum, 0, {}); + } + else { + Loggers.warn('Unrecognized full object edit value', + componentType, componentNum, objVal); + } + + if(firstIndexChange === -1) firstIndexChange = componentNum; + } + else { + for(j = 0; j < objKeys.length; j++) { + nestedProperty(componentArray[componentNum], objKeys[j]).set(objEdits[objKeys[j]]); + } + } + } + + // now do deletes + for(i = deletes.length - 1; i >= 0; i--) { + componentArray.splice(deletes[i], 1); + // TODO: this drops private keys that had been stored in componentArrayFull + // does this have any ill effects? + if(componentArrayFull) componentArrayFull.splice(deletes[i], 1); + } + + if(!componentArray.length) np.set(null); + else if(!componentArrayIn) np.set(componentArray); + + if(replotLater) return false; + + supplyComponentDefaults(layout, fullLayout); + + // finally draw all the components we need to + // if we added or removed any, redraw all after it + if(drawOne !== noop) { + var indicesToDraw; + if(firstIndexChange === -1) { + // there's no re-indexing to do, so only redraw components that changed + indicesToDraw = componentNums; + } + else { + // in case the component array was shortened, we still need do call + // drawOne on the latter items so they get properly removed + maxIndex = Math.max(componentArray.length, maxIndex); + indicesToDraw = []; + for(i = 0; i < componentNums.length; i++) { + componentNum = componentNums[i]; + if(componentNum >= firstIndexChange) break; + indicesToDraw.push(componentNum); + } + for(i = firstIndexChange; i < maxIndex; i++) { + indicesToDraw.push(i); + } + } + for(i = 0; i < indicesToDraw.length; i++) { + drawOne(gd, indicesToDraw[i]); + } + } + else draw(gd); + + return true; +}; diff --git a/src/plot_api/plot_api.js b/src/plot_api/plot_api.js index 8f6e6803d3b..7e4aa88a064 100644 --- a/src/plot_api/plot_api.js +++ b/src/plot_api/plot_api.js @@ -28,8 +28,10 @@ var ErrorBars = require('../components/errorbars'); var xmlnsNamespaces = require('../constants/xmlns_namespaces'); var svgTextUtils = require('../lib/svg_text_utils'); +var manageArrays = require('./manage_arrays'); var helpers = require('./helpers'); var subroutines = require('./subroutines'); +var cartesianConstants = require('../plots/cartesian/constants'); /** @@ -249,7 +251,8 @@ Plotly.plot = function(gd, data, layout, config) { // calc and autorange for errorbars ErrorBars.calc(gd); - // TODO: autosize extra for text markers + // TODO: autosize extra for text markers and images + // see https://github.com/plotly/plotly.js/issues/1111 return Lib.syncOrAsync([ Registry.getComponentMethod('shapes', 'calcAutorange'), Registry.getComponentMethod('annotations', 'calcAutorange'), @@ -1373,7 +1376,7 @@ function _restyle(gd, aobj, _traces) { return; } // quit if explicitly setting this elsewhere - if(attr in aobj) return; + if(attr in aobj || helpers.hasParent(aobj, attr)) return; var extraparam; if(attr.substr(0, 6) === 'LAYOUT') { @@ -1396,6 +1399,10 @@ function _restyle(gd, aobj, _traces) { // now make the changes to gd.data (and occasionally gd.layout) // and figure out what kind of graphics update we need to do for(var ai in aobj) { + if(helpers.hasParent(aobj, ai)) { + throw new Error('cannot set ' + ai + 'and a parent attribute simultaneously'); + } + var vi = aobj[ai], cont, contFull, @@ -1557,6 +1564,7 @@ function _restyle(gd, aobj, _traces) { helpers.swapXYData(cont); } else if(Plots.dataArrayContainers.indexOf(param.parts[0]) !== -1) { + // TODO: use manageArrays.applyContainerArrayChanges here too helpers.manageArrayContainers(param, newVal, undoit); flags.docalc = true; } @@ -1717,12 +1725,16 @@ Plotly.relayout = function relayout(gd, astr, val) { if(flags.docalc) gd.calcdata = undefined; // fill in redraw sequence - var seq = []; + + // even if we don't have anything left in aobj, + // something may have happened within relayout that we + // need to wait for + var seq = [Plots.previousPromises]; if(flags.layoutReplot) { seq.push(subroutines.layoutReplot); - } else if(Object.keys(aobj).length) { - seq.push(Plots.previousPromises); + } + else if(Object.keys(aobj).length) { Plots.supplyDefaults(gd); if(flags.dolegend) seq.push(subroutines.doLegend); @@ -1753,13 +1765,16 @@ function _relayout(gd, aobj) { fullLayout = gd._fullLayout, keys = Object.keys(aobj), axes = Plotly.Axes.list(gd), - i; + arrayEdits = {}, + arrayStr, + i, + j; // look for 'allaxes', split out into all axes // in case of 3D the axis are nested within a scene which is held in _id for(i = 0; i < keys.length; i++) { if(keys[i].indexOf('allaxes') === 0) { - for(var j = 0; j < axes.length; j++) { + for(j = 0; j < axes.length; j++) { var scene = axes[j]._id.substr(1), axisAttr = (scene.indexOf('scene') !== -1) ? (scene + '.') : '', newkey = keys[i].replace('allaxes', axisAttr + axes[j]._name); @@ -1797,8 +1812,10 @@ function _relayout(gd, aobj) { attr.forEach(function(a) { doextra(a, val); }); return; } - // quit if explicitly setting this elsewhere - if(attr in aobj) return; + + // if we have another value for this attribute (explicitly or + // via a parent) do not override with this auto-generated extra + if(attr in aobj || helpers.hasParent(aobj, attr)) return; var p = Lib.nestedProperty(layout, attr); if(!(attr in undoit)) undoit[attr] = p.get(); @@ -1806,19 +1823,37 @@ function _relayout(gd, aobj) { } // for editing annotations or shapes - is it on autoscaled axes? - function refAutorange(obj, axletter) { - var axName = Plotly.Axes.id2name(obj[axletter + 'ref'] || axletter); - return (fullLayout[axName] || {}).autorange; + function refAutorange(obj, axLetter) { + if(!Lib.isPlainObject(obj)) return false; + var axRef = obj[axLetter + 'ref'] || axLetter, + ax = Plotly.Axes.getFromId(gd, axRef); + + if(!ax && axRef.charAt(0) === axLetter) { + // fall back on the primary axis in case we've referenced a + // nonexistent axis (as we do above if axRef is missing). + // This assumes the object defaults to data referenced, which + // is the case for shapes and annotations but not for images. + // The only thing this is used for is to determine whether to + // do a full `recalc`, so the only ill effect of this error is + // to waste some time. + ax = Plotly.Axes.getFromId(gd, axLetter); + } + return (ax || {}).autorange; } // alter gd.layout for(var ai in aobj) { + if(helpers.hasParent(aobj, ai)) { + throw new Error('cannot set ' + ai + 'and a parent attribute simultaneously'); + } + var p = Lib.nestedProperty(layout, ai), vi = aobj[ai], plen = p.parts.length, // p.parts may end with an index integer if the property is an array pend = typeof p.parts[plen - 1] === 'string' ? (plen - 1) : (plen - 2), // last property in chain (leaf node) + proot = p.parts[0], pleaf = p.parts[pend], // leaf plus immediate parent pleafPlus = p.parts[pend - 1] + '.' + pleaf, @@ -1851,7 +1886,7 @@ function _relayout(gd, aobj) { undefined); } else if(pleafPlus.match(/^aspectratio\.[xyz]$/)) { - doextra(p.parts[0] + '.aspectmode', 'manual'); + doextra(proot + '.aspectmode', 'manual'); } else if(pleafPlus.match(/^aspectmode$/)) { doextra([ptrunk + '.x', ptrunk + '.y', ptrunk + '.z'], undefined); @@ -1876,142 +1911,170 @@ function _relayout(gd, aobj) { flags.docalc = true; } - // toggling log without autorange: need to also recalculate ranges - // logical XOR (ie are we toggling log) - if(pleaf === 'type' && ((parentFull.type === 'log') !== (vi === 'log'))) { - var ax = parentIn; + // toggling axis type between log and linear: we need to convert + // positions for components that are still using linearized values, + // not data values like newer components. + // previously we did this for log <-> not-log, but now only do it + // for log <-> linear + if(pleaf === 'type') { + var ax = parentIn, + toLog = parentFull.type === 'linear' && vi === 'log', + fromLog = parentFull.type === 'log' && vi === 'linear'; - if(!ax || !ax.range) { - doextra(ptrunk + '.autorange', true); - } - else if(!parentFull.autorange) { - var r0 = ax.range[0], - r1 = ax.range[1]; - if(vi === 'log') { - // if both limits are negative, autorange - if(r0 <= 0 && r1 <= 0) { - doextra(ptrunk + '.autorange', true); + if(toLog || fromLog) { + if(!ax || !ax.range) { + doextra(ptrunk + '.autorange', true); + } + else if(!parentFull.autorange) { + // toggling log without autorange: need to also recalculate ranges + // because log axes use linearized values for range endpoints + var r0 = ax.range[0], + r1 = ax.range[1]; + if(toLog) { + // if both limits are negative, autorange + if(r0 <= 0 && r1 <= 0) { + doextra(ptrunk + '.autorange', true); + } + // if one is negative, set it 6 orders below the other. + if(r0 <= 0) r0 = r1 / 1e6; + else if(r1 <= 0) r1 = r0 / 1e6; + // now set the range values as appropriate + doextra(ptrunk + '.range[0]', Math.log(r0) / Math.LN10); + doextra(ptrunk + '.range[1]', Math.log(r1) / Math.LN10); + } + else { + doextra(ptrunk + '.range[0]', Math.pow(10, r0)); + doextra(ptrunk + '.range[1]', Math.pow(10, r1)); } - // if one is negative, set it 6 orders below the other. - if(r0 <= 0) r0 = r1 / 1e6; - else if(r1 <= 0) r1 = r0 / 1e6; - // now set the range values as appropriate - doextra(ptrunk + '.range[0]', Math.log(r0) / Math.LN10); - doextra(ptrunk + '.range[1]', Math.log(r1) / Math.LN10); } - else { - doextra(ptrunk + '.range[0]', Math.pow(10, r0)); - doextra(ptrunk + '.range[1]', Math.pow(10, r1)); + else if(toLog) { + // just make sure the range is positive and in the right + // order, it'll get recalculated later + ax.range = (ax.range[1] > ax.range[0]) ? [1, 2] : [2, 1]; } - } - else if(vi === 'log') { - // just make sure the range is positive and in the right - // order, it'll get recalculated later - ax.range = (ax.range[1] > ax.range[0]) ? [1, 2] : [2, 1]; - } - } - // handle axis reversal explicitly, as there's no 'reverse' flag - if(pleaf === 'reverse') { - if(parentIn.range) parentIn.range.reverse(); + // Annotations and images also need to convert to/from linearized coords + // Shapes do not need this :) + Registry.getComponentMethod('annotations', 'convertCoords')(gd, parentFull, vi, doextra); + Registry.getComponentMethod('images', 'convertCoords')(gd, parentFull, vi, doextra); + } else { + // any other type changes: the range from the previous type + // will not make sense, so autorange it. doextra(ptrunk + '.autorange', true); - parentIn.range = [1, 0]; } + } + else if(pleaf.match(cartesianConstants.AX_NAME_PATTERN)) { + var fullProp = Lib.nestedProperty(fullLayout, ai).get(), + newType = (vi || {}).type; - if(parentFull.autorange) flags.docalc = true; - else flags.doplot = true; + // This can potentially cause strange behavior if the autotype is not + // numeric (linear, because we don't auto-log) but the previous type + // was log. That's a very strange edge case though + if(!newType || newType === '-') newType = 'linear'; + Registry.getComponentMethod('annotations', 'convertCoords')(gd, fullProp, newType, doextra); + Registry.getComponentMethod('images', 'convertCoords')(gd, fullProp, newType, doextra); } - // send annotation and shape mods one-by-one through Annotations.draw(), - // don't set via nestedProperty - // that's because add and remove are special - else if(p.parts[0] === 'annotations' || p.parts[0] === 'shapes') { - var objNum = p.parts[1], - objType = p.parts[0], - objList = layout[objType] || [], - obji = objList[objNum] || {}; - - // if p.parts is just an annotation number, and val is either - // 'add' or an entire annotation to add, the undo is 'remove' - // if val is 'remove' then undo is the whole annotation object - if(p.parts.length === 2) { - - // new API, remove annotation / shape with `null` - if(vi === null) aobj[ai] = 'remove'; - - if(aobj[ai] === 'add' || Lib.isPlainObject(aobj[ai])) { - undoit[ai] = 'remove'; + + // alter gd.layout + + // collect array component edits for execution all together + // so we can ensure consistent behavior adding/removing items + // and order-independence for add/remove/edit all together in + // one relayout call + var containerArrayMatch = manageArrays.containerArrayMatch(ai); + if(containerArrayMatch) { + arrayStr = containerArrayMatch.array; + i = containerArrayMatch.index; + var propStr = containerArrayMatch.property, + componentArray = Lib.nestedProperty(layout, arrayStr), + obji = (componentArray || [])[i] || {}; + + if(i === '') { + // replacing the entire array: too much going on, force recalc + if(ai.indexOf('updatemenus') === -1) flags.docalc = true; + } + else if(propStr === '') { + // special handling of undoit if we're adding or removing an element + // ie 'annotations[2]' which can be {...} (add) or null (remove) + var toggledObj = vi; + if(manageArrays.isAddVal(vi)) { + undoit[ai] = null; } - else if(aobj[ai] === 'remove') { - if(objNum === -1) { - undoit[objType] = objList; - delete undoit[ai]; - } - else undoit[ai] = obji; + else if(manageArrays.isRemoveVal(vi)) { + undoit[ai] = obji; + toggledObj = obji; } - else Lib.log('???', aobj); - } + else Lib.warn('unrecognized full object value', aobj); - if((refAutorange(obji, 'x') || refAutorange(obji, 'y')) && - !Lib.containsAny(ai, ['color', 'opacity', 'align', 'dash'])) { + if(refAutorange(toggledObj, 'x') || refAutorange(toggledObj, 'y') && + ai.indexOf('updatemenus') === -1) { + flags.docalc = true; + } + } + else if((refAutorange(obji, 'x') || refAutorange(obji, 'y')) && + !Lib.containsAny(ai, ['color', 'opacity', 'align', 'dash', 'updatemenus'])) { flags.docalc = true; } - // TODO: combine all edits to a given annotation / shape into one call - // as it is we get separate calls for x and y (or ax and ay) on move + // prepare the edits object we'll send to applyContainerArrayChanges + if(!arrayEdits[arrayStr]) arrayEdits[arrayStr] = {}; + var objEdits = arrayEdits[arrayStr][i]; + if(!objEdits) objEdits = arrayEdits[arrayStr][i] = {}; + objEdits[propStr] = vi; - var drawOne = Registry.getComponentMethod(objType, 'drawOne'); - drawOne(gd, objNum, p.parts.slice(2).join('.'), aobj[ai]); delete aobj[ai]; } - else if( - Plots.layoutArrayContainers.indexOf(p.parts[0]) !== -1 || - (p.parts[0] === 'mapbox' && p.parts[1] === 'layers') - ) { - helpers.manageArrayContainers(p, vi, undoit); - flags.doplot = true; + // handle axis reversal explicitly, as there's no 'reverse' flag + else if(pleaf === 'reverse') { + if(parentIn.range) parentIn.range.reverse(); + else { + doextra(ptrunk + '.autorange', true); + parentIn.range = [1, 0]; + } + + if(parentFull.autorange) flags.docalc = true; + else flags.doplot = true; } - // alter gd.layout else { var pp1 = String(p.parts[1] || ''); // check whether we can short-circuit a full redraw // 3d or geo at this point just needs to redraw. - if(p.parts[0].indexOf('scene') === 0) { + if(proot.indexOf('scene') === 0) { if(p.parts[1] === 'camera') flags.docamera = true; else flags.doplot = true; } - else if(p.parts[0].indexOf('geo') === 0) flags.doplot = true; - else if(p.parts[0].indexOf('ternary') === 0) flags.doplot = true; + else if(proot.indexOf('geo') === 0) flags.doplot = true; + else if(proot.indexOf('ternary') === 0) flags.doplot = true; else if(ai === 'paper_bgcolor') flags.doplot = true; else if(fullLayout._has('gl2d') && - (ai.indexOf('axis') !== -1 || p.parts[0] === 'plot_bgcolor') + (ai.indexOf('axis') !== -1 || ai === 'plot_bgcolor') ) flags.doplot = true; else if(ai === 'hiddenlabels') flags.docalc = true; - else if(p.parts[0].indexOf('legend') !== -1) flags.dolegend = true; + else if(proot.indexOf('legend') !== -1) flags.dolegend = true; else if(ai.indexOf('title') !== -1) flags.doticks = true; - else if(p.parts[0].indexOf('bgcolor') !== -1) flags.dolayoutstyle = true; - else if(p.parts.length > 1 && - Lib.containsAny(pp1, ['tick', 'exponent', 'grid', 'zeroline'])) { + else if(proot.indexOf('bgcolor') !== -1) flags.dolayoutstyle = true; + else if(plen > 1 && Lib.containsAny(pp1, ['tick', 'exponent', 'grid', 'zeroline'])) { flags.doticks = true; } else if(ai.indexOf('.linewidth') !== -1 && ai.indexOf('axis') !== -1) { flags.doticks = flags.dolayoutstyle = true; } - else if(p.parts.length > 1 && pp1.indexOf('line') !== -1) { + else if(plen > 1 && pp1.indexOf('line') !== -1) { flags.dolayoutstyle = true; } - else if(p.parts.length > 1 && pp1 === 'mirror') { + else if(plen > 1 && pp1 === 'mirror') { flags.doticks = flags.dolayoutstyle = true; } else if(ai === 'margin.pad') { flags.doticks = flags.dolayoutstyle = true; } - else if(p.parts[0] === 'margin' || - p.parts[1] === 'autorange' || - p.parts[1] === 'rangemode' || - p.parts[1] === 'type' || - p.parts[1] === 'domain' || + else if(proot === 'margin' || + pp1 === 'autorange' || + pp1 === 'rangemode' || + pp1 === 'type' || + pp1 === 'domain' || ai.indexOf('calendar') !== -1 || ai.match(/^(bar|box|font)/)) { flags.docalc = true; @@ -2032,12 +2095,16 @@ function _relayout(gd, aobj) { } } + // now we've collected component edits - execute them all together + for(arrayStr in arrayEdits) { + var finished = manageArrays.applyContainerArrayChanges(gd, + Lib.nestedProperty(layout, arrayStr), arrayEdits[arrayStr], flags); + if(!finished) flags.doplot = true; + } + var oldWidth = gd._fullLayout.width, oldHeight = gd._fullLayout.height; - // coerce the updated layout - Plots.supplyDefaults(gd); - // calculate autosizing if(gd.layout.autosize) Plots.plotAutoSize(gd, gd.layout, gd._fullLayout); diff --git a/src/plot_api/plot_schema.js b/src/plot_api/plot_schema.js index 2075674108a..5109c15ba24 100644 --- a/src/plot_api/plot_schema.js +++ b/src/plot_api/plot_schema.js @@ -26,8 +26,9 @@ var extendDeep = Lib.extendDeep; var IS_SUBPLOT_OBJ = '_isSubplotObj'; var IS_LINKED_TO_ARRAY = '_isLinkedToArray'; +var ARRAY_ATTR_REGEXPS = '_arrayAttrRegexps'; var DEPRECATED = '_deprecated'; -var UNDERSCORE_ATTRS = [IS_SUBPLOT_OBJ, IS_LINKED_TO_ARRAY, DEPRECATED]; +var UNDERSCORE_ATTRS = [IS_SUBPLOT_OBJ, IS_LINKED_TO_ARRAY, ARRAY_ATTR_REGEXPS, DEPRECATED]; exports.IS_SUBPLOT_OBJ = IS_SUBPLOT_OBJ; exports.IS_LINKED_TO_ARRAY = IS_LINKED_TO_ARRAY; diff --git a/src/plots/array_container_defaults.js b/src/plots/array_container_defaults.js index 2754ed613c2..7a0093fa3f1 100644 --- a/src/plots/array_container_defaults.js +++ b/src/plots/array_container_defaults.js @@ -44,10 +44,13 @@ var Lib = require('../lib'); module.exports = function handleArrayContainerDefaults(parentObjIn, parentObjOut, opts) { var name = opts.name; - var contIn = Array.isArray(parentObjIn[name]) ? parentObjIn[name] : [], - contOut = parentObjOut[name] = []; + var previousContOut = parentObjOut[name]; - for(var i = 0; i < contIn.length; i++) { + var contIn = Lib.isArray(parentObjIn[name]) ? parentObjIn[name] : [], + contOut = parentObjOut[name] = [], + i; + + for(i = 0; i < contIn.length; i++) { var itemIn = contIn[i], itemOut = {}, itemOpts = {}; @@ -64,4 +67,13 @@ module.exports = function handleArrayContainerDefaults(parentObjIn, parentObjOut contOut.push(itemOut); } + + // in case this array gets its defaults rebuilt independent of the whole layout, + // relink the private keys just for this array. + if(Lib.isArray(previousContOut)) { + var len = Math.min(previousContOut.length, contOut.length); + for(i = 0; i < len; i++) { + Lib.relinkPrivateKeys(contOut[i], previousContOut[i]); + } + } }; diff --git a/src/plots/command.js b/src/plots/command.js index 62c060edd5e..830af6db804 100644 --- a/src/plots/command.js +++ b/src/plots/command.js @@ -156,6 +156,8 @@ exports.hasSimpleAPICommandBindings = function(gd, commandList, bindingsByValue) var method = command.method; var args = command.args; + if(!Array.isArray(args)) args = []; + // If any command has no method, refuse to bind: if(!method) { return false; @@ -263,6 +265,9 @@ exports.executeAPICommand = function(gd, method, args) { var apiMethod = Plotly[method]; var allArgs = [gd]; + + if(!Array.isArray(args)) args = []; + for(var i = 0; i < args.length; i++) { allArgs.push(args[i]); } @@ -275,6 +280,9 @@ exports.executeAPICommand = function(gd, method, args) { exports.computeAPICommandBindings = function(gd, method, args) { var bindings; + + if(!Array.isArray(args)) args = []; + switch(method) { case 'restyle': bindings = computeDataBindings(gd, args); diff --git a/src/plots/mapbox/layout_attributes.js b/src/plots/mapbox/layout_attributes.js index 4df5146acde..0578eaa0029 100644 --- a/src/plots/mapbox/layout_attributes.js +++ b/src/plots/mapbox/layout_attributes.js @@ -16,6 +16,7 @@ var textposition = require('../../traces/scatter/attributes').textposition; module.exports = { + _arrayAttrRegexps: [/^mapbox([2-9]|[1-9][0-9]+)?\.layers/], domain: { x: { valType: 'info_array', diff --git a/src/plots/plots.js b/src/plots/plots.js index 3b2e3301586..d241eb09a78 100644 --- a/src/plots/plots.js +++ b/src/plots/plots.js @@ -22,6 +22,8 @@ var plots = module.exports = {}; var animationAttrs = require('./animation_attributes'); var frameAttrs = require('./frame_attributes'); +var relinkPrivateKeys = Lib.relinkPrivateKeys; + // Expose registry methods on Plots for backward-compatibility Lib.extendFlat(plots, Registry); @@ -592,51 +594,6 @@ plots.cleanPlot = function(newFullData, newFullLayout, oldFullData, oldFullLayou } }; -/** - * Relink private _keys and keys with a function value from one container - * to the new container. - * Relink means copying if object is pass-by-value and adding a reference - * if object is pass-by-ref. - * This prevents deepCopying massive structures like a webgl context. - */ -function relinkPrivateKeys(toContainer, fromContainer) { - var isPlainObject = Lib.isPlainObject, - isArray = Array.isArray; - - var keys = Object.keys(fromContainer || {}); - - for(var i = 0; i < keys.length; i++) { - var k = keys[i], - fromVal = fromContainer[k], - toVal = toContainer[k]; - - if(k.charAt(0) === '_' || typeof fromVal === 'function') { - - // if it already exists at this point, it's something - // that we recreate each time around, so ignore it - if(k in toContainer) continue; - - toContainer[k] = fromVal; - } - else if(isArray(fromVal) && isArray(toVal) && isPlainObject(fromVal[0])) { - - // recurse into arrays containers - for(var j = 0; j < fromVal.length; j++) { - if(isPlainObject(fromVal[j]) && isPlainObject(toVal[j])) { - relinkPrivateKeys(toVal[j], fromVal[j]); - } - } - } - else if(isPlainObject(fromVal) && isPlainObject(toVal)) { - - // recurse into objects, but only if they still exist - relinkPrivateKeys(toVal, fromVal); - - if(!Object.keys(toVal).length) delete toContainer[k]; - } - } -} - plots.linkSubplots = function(newFullData, newFullLayout, oldFullData, oldFullLayout) { var oldSubplots = oldFullLayout._plots || {}, newSubplots = newFullLayout._plots = {}; @@ -1954,6 +1911,8 @@ plots.doCalcdata = function(gd, traces) { var trace, _module, i, j; + var hasCategoryAxis = false; + // XXX: Is this correct? Needs a closer look so that *some* traces can be recomputed without // *all* needing doCalcdata: var calcdata = new Array(fullData.length); @@ -1979,6 +1938,7 @@ plots.doCalcdata = function(gd, traces) { // to be filled in later by ax.d2c for(i = 0; i < axList.length; i++) { axList[i]._categories = axList[i]._initialCategories.slice(); + if(axList[i].type === 'category') hasCategoryAxis = true; } // If traces were specified and this trace was not included, @@ -2055,6 +2015,18 @@ plots.doCalcdata = function(gd, traces) { calcdata[i] = cd; } + + // To handle the case of components using category names as coordinates, we + // need to re-supply defaults for these objects now, after calc has + // finished populating the category mappings + // Any component that uses `Axes.coercePosition` falls into this category + if(hasCategoryAxis) { + var dataReferencedComponents = ['annotations', 'shapes', 'images']; + for(i = 0; i < dataReferencedComponents.length; i++) { + Registry.getComponentMethod(dataReferencedComponents[i], 'supplyLayoutDefaults')( + gd.layout, fullLayout, fullData); + } + } }; plots.rehover = function(gd) { diff --git a/src/registry.js b/src/registry.js index 5fb6f2256bd..2952742f630 100644 --- a/src/registry.js +++ b/src/registry.js @@ -9,7 +9,9 @@ 'use strict'; -var Lib = require('./lib'); +var Loggers = require('./lib/loggers'); +var noop = require('./lib/noop'); +var pushUnique = require('./lib/push_unique'); var basePlotAttributes = require('./plots/attributes'); exports.modules = {}; @@ -19,6 +21,7 @@ exports.subplotsRegistry = {}; exports.transformsRegistry = {}; exports.componentsRegistry = {}; exports.layoutArrayContainers = []; +exports.layoutArrayRegexes = []; /** * register a module as the handler for a trace type @@ -31,7 +34,7 @@ exports.layoutArrayContainers = []; */ exports.register = function(_module, thisType, categoriesIn, meta) { if(exports.modules[thisType]) { - Lib.log('Type ' + thisType + ' already registered'); + Loggers.log('Type ' + thisType + ' already registered'); return; } @@ -76,10 +79,15 @@ exports.registerSubplot = function(_module) { var plotType = _module.name; if(exports.subplotsRegistry[plotType]) { - Lib.log('Plot type ' + plotType + ' already registered.'); + Loggers.log('Plot type ' + plotType + ' already registered.'); return; } + // relayout array handling will look for component module methods with this + // name and won't find them because this is a subplot module... but that + // should be fine, it will just fall back on redrawing the plot. + findArrayRegexps(_module); + // not sure what's best for the 'cartesian' type at this point exports.subplotsRegistry[plotType] = _module; }; @@ -89,11 +97,25 @@ exports.registerComponent = function(_module) { exports.componentsRegistry[name] = _module; - if(_module.layoutAttributes && _module.layoutAttributes._isLinkedToArray) { - Lib.pushUnique(exports.layoutArrayContainers, name); + if(_module.layoutAttributes) { + if(_module.layoutAttributes._isLinkedToArray) { + pushUnique(exports.layoutArrayContainers, name); + } + findArrayRegexps(_module); } }; +function findArrayRegexps(_module) { + if(_module.layoutAttributes) { + var arrayAttrRegexps = _module.layoutAttributes._arrayAttrRegexps; + if(arrayAttrRegexps) { + for(var i = 0; i < arrayAttrRegexps.length; i++) { + pushUnique(exports.layoutArrayRegexes, arrayAttrRegexps[i]); + } + } + } +} + /** * Get registered module using trace object or trace type * @@ -104,7 +126,7 @@ exports.registerComponent = function(_module) { */ exports.getModule = function(trace) { if(trace.r !== undefined) { - Lib.warn('Tried to put a polar trace ' + + Loggers.warn('Tried to put a polar trace ' + 'on an incompatible graph of cartesian ' + 'data. Ignoring this dataset.', trace ); @@ -135,7 +157,7 @@ exports.traceIs = function(traceType, category) { if(!_module) { if(traceType && traceType !== 'area') { - Lib.log('Unrecognized trace type ' + traceType + '.'); + Loggers.log('Unrecognized trace type ' + traceType + '.'); } _module = exports.modules[basePlotAttributes.type.dflt]; @@ -145,7 +167,8 @@ exports.traceIs = function(traceType, category) { }; /** - * Retrieve component module method + * Retrieve component module method. Falls back on noop if either the + * module or the method is missing, so the result can always be safely called * * @param {string} name * name of component (as declared in component module) @@ -156,8 +179,8 @@ exports.traceIs = function(traceType, category) { exports.getComponentMethod = function(name, method) { var _module = exports.componentsRegistry[name]; - if(!_module) return Lib.noop; - return _module[method]; + if(!_module) return noop; + return _module[method] || noop; }; function getTraceType(traceType) { diff --git a/tasks/test_syntax.js b/tasks/test_syntax.js index 66a31a33472..7aa6213e1d7 100644 --- a/tasks/test_syntax.js +++ b/tasks/test_syntax.js @@ -119,13 +119,14 @@ function assertCircularDeps() { // as of v1.17.0 - 2016/09/08 // see https://github.com/plotly/plotly.js/milestone/9 // for more details - var MAX_ALLOWED_CIRCULAR_DEPS = 18; + var MAX_ALLOWED_CIRCULAR_DEPS = 17; if(circularDeps.length > MAX_ALLOWED_CIRCULAR_DEPS) { + console.log(circularDeps.join('\n')); logs.push('some new circular dependencies were added to src/'); } - log('circular dependencies', logs); + log('circular dependencies: ' + circularDeps.length, logs); }); } diff --git a/test/image/baselines/layout_image.png b/test/image/baselines/layout_image.png index def47ebab0f..1ea7021287a 100644 Binary files a/test/image/baselines/layout_image.png and b/test/image/baselines/layout_image.png differ diff --git a/test/image/baselines/shapes.png b/test/image/baselines/shapes.png index 51456a8d92d..0e15050b950 100644 Binary files a/test/image/baselines/shapes.png and b/test/image/baselines/shapes.png differ diff --git a/test/image/mocks/layout_image.json b/test/image/mocks/layout_image.json index 200fb0f9c51..a4af1a99804 100644 --- a/test/image/mocks/layout_image.json +++ b/test/image/mocks/layout_image.json @@ -2,7 +2,7 @@ "data": [ { "x": [1,2,3], - "y": [1,2,3] + "y": ["a", "b", "c"] }, { "x": ["2001-01-01","2002-01-01","2003-01-01"], "y": [10,100,1000], @@ -38,7 +38,7 @@ "xref": "x", "yref": "y", "x": 1.5, - "y": 2, + "y": "b", "sizex": 1, "sizey": 1, "xanchor": "right", diff --git a/test/image/mocks/shapes.json b/test/image/mocks/shapes.json index 86241c110d9..e5ba275b48d 100644 --- a/test/image/mocks/shapes.json +++ b/test/image/mocks/shapes.json @@ -29,6 +29,7 @@ {"path":"M0.5,3C0.5,9 0.9,9 0.9,3C0.9,1 0.5,1 0.5,3ZM0.6,4C0.6,5 0.66,5 0.66,4ZM0.74,4C0.74,5 0.8,5 0.8,4ZM0.6,3C0.63,2 0.77,2 0.8,3Z","fillcolor":"#fd2","line":{"width":1,"color":"black"}}, {"layer":"below","xref":"x2","yref":"y2","type":"circle","x0":"2000-01-01 02","x1":"2000-01-01 08:30:33.456","y0":0.1,"y1":0.9,"fillcolor":"rgba(0,0,0,0.5)","line":{"color":"rgba(0,255,0,0.5)", "width":5}}, {"xref":"x2","yref":"y2","path":"M2000-01-01_11:20:45.6,0.2Q2000-01-01_10:00,0.85 2000-01-01_21,0.8Q2000-01-01_22:20,0.15 2000-01-01_11:20:45.6,0.2Z","fillcolor":"rgb(151,73,58)"}, + {"xref":"x2","yref":"y2","type":"line","x0":"2000-01-01 11:00","x1":"2000-01-01 09:00","y0":"b","y1":"a","line":{"color":"#006","width":3}}, {"yref":"paper","type":"line","x0":0.1,"x1":0.4,"y0":0,"y1":0.4,"line":{"color":"#009","dash":"dot","width":1}}, {"yref":"paper","path":"M0.5,0H1.1L0.8,0.4Z","line":{"width":0},"fillcolor":"#ccd3ff"}, {"xref":"paper","x0":0.1,"x1":0.2,"y0":-1,"y1":3,"fillcolor":"#ccc"}, diff --git a/test/jasmine/tests/annotations_test.js b/test/jasmine/tests/annotations_test.js index ee155f9cf0d..d898a65341a 100644 --- a/test/jasmine/tests/annotations_test.js +++ b/test/jasmine/tests/annotations_test.js @@ -3,12 +3,14 @@ var Annotations = require('@src/components/annotations'); var Plotly = require('@lib/index'); var Plots = require('@src/plots/plots'); var Lib = require('@src/lib'); +var Loggers = require('@src/lib/loggers'); var Axes = require('@src/plots/cartesian/axes'); var d3 = require('d3'); var customMatchers = require('../assets/custom_matchers'); var createGraphDiv = require('../assets/create_graph_div'); var destroyGraphDiv = require('../assets/destroy_graph_div'); +var failTest = require('../assets/fail_test'); describe('Test annotations', function() { @@ -144,6 +146,8 @@ describe('annotations relayout', function() { mockLayout = Lib.extendDeep({}, mock.layout); Plotly.plot(gd, mockData, mockLayout).then(done); + + spyOn(Loggers, 'warn'); }); afterEach(destroyGraphDiv); @@ -152,12 +156,20 @@ describe('annotations relayout', function() { return d3.selectAll('g.annotation').size(); } + function assertText(index, expected) { + var query = '.annotation[data-index="' + index + '"]', + actual = d3.select(query).select('text').text(); + + expect(actual).toEqual(expected); + } + it('should be able to add /remove annotations', function(done) { expect(countAnnotations()).toEqual(len); var ann = { text: '' }; - Plotly.relayout(gd, 'annotations[' + len + ']', ann).then(function() { + Plotly.relayout(gd, 'annotations[' + len + ']', ann) + .then(function() { expect(countAnnotations()).toEqual(len + 1); return Plotly.relayout(gd, 'annotations[0]', 'remove'); @@ -175,25 +187,29 @@ describe('annotations relayout', function() { .then(function() { expect(countAnnotations()).toEqual(len - 2); - return Plotly.relayout(gd, { annotations: [] }); + return Plotly.relayout(gd, {annotations: []}); }) .then(function() { expect(countAnnotations()).toEqual(0); - done(); - }); + return Plotly.relayout(gd, {annotations: [ann, {text: 'boo', x: 1, y: 1}]}); + }) + .then(function() { + expect(countAnnotations()).toEqual(2); + + return Plotly.relayout(gd, {annotations: null}); + }) + .then(function() { + expect(countAnnotations()).toEqual(0); + expect(Loggers.warn).not.toHaveBeenCalled(); + }) + .catch(failTest) + .then(done); }); it('should be able update annotations', function(done) { var updateObj = { 'annotations[0].text': 'hello' }; - function assertText(index, expected) { - var query = '.annotation[data-index="' + index + '"]', - actual = d3.select(query).select('text').text(); - - expect(actual).toEqual(expected); - } - function assertUpdateObj() { // w/o mutating relayout update object expect(Object.keys(updateObj)).toEqual(['annotations[0].text']); @@ -227,11 +243,261 @@ describe('annotations relayout', function() { assertText(0, 'hello'); assertUpdateObj(); }) + .catch(failTest) + .then(done); + + }); + + it('can update several annotations and add and delete in one call', function(done) { + expect(countAnnotations()).toEqual(len); + var annos = gd.layout.annotations, + anno0 = Lib.extendFlat(annos[0]), + anno1 = Lib.extendFlat(annos[1]), + anno3 = Lib.extendFlat(annos[3]); + + // store some (unused) private keys and make sure they are copied over + // correctly during relayout + var fullAnnos = gd._fullLayout.annotations; + fullAnnos[0]._boo = 'hoo'; + fullAnnos[1]._foo = 'bar'; + fullAnnos[3]._cheese = ['gorgonzola', 'gouda', 'gloucester']; + // this one gets lost + fullAnnos[2]._splat = 'the cat'; + + Plotly.relayout(gd, { + 'annotations[0].text': 'tortilla', + 'annotations[0].x': 3.45, + 'annotations[1]': {text: 'chips', x: 1.1, y: 2.2}, // add new annotation btwn 0 and 1 + 'annotations[2].text': 'guacamole', // alter 2 (which was 1 before we started) + 'annotations[3]': null, // remove 3 (which was 2 before we started) + 'annotations[4].text': 'lime' // alter 4 (which was 3 before and will be 3 afterward) + }) + .then(function() { + expect(countAnnotations()).toEqual(len); + + var fullAnnosAfter = gd._fullLayout.annotations, + fullStr = JSON.stringify(fullAnnosAfter); + + assertText(0, 'tortilla'); + anno0.text = 'tortilla'; + expect(annos[0]).toEqual(anno0); + expect(fullAnnosAfter[0]._boo).toBe('hoo'); + + + assertText(1, 'chips'); + expect(annos[1]).toEqual({text: 'chips', x: 1.1, y: 2.2}); + expect(fullAnnosAfter[1]._foo).toBeUndefined(); + + assertText(2, 'guacamole'); + anno1.text = 'guacamole'; + expect(annos[2]).toEqual(anno1); + expect(fullAnnosAfter[2]._foo).toBe('bar'); + expect(fullAnnosAfter[2]._splat).toBeUndefined(); + + assertText(3, 'lime'); + anno3.text = 'lime'; + expect(annos[3]).toEqual(anno3); + expect(fullAnnosAfter[3]._cheese).toEqual(['gorgonzola', 'gouda', 'gloucester']); + + expect(fullStr.indexOf('_splat')).toBe(-1); + expect(fullStr.indexOf('the cat')).toBe(-1); + + expect(Loggers.warn).not.toHaveBeenCalled(); + + }) + .catch(failTest) + .then(done); + }); + + [ + {annotations: [{text: 'a'}], 'annotations[0]': {text: 'b'}}, + {annotations: null, 'annotations[0]': {text: 'b'}}, + {annotations: [{text: 'a'}], 'annotations[0]': null}, + {annotations: [{text: 'a'}], 'annotations[0].text': 'b'}, + {'annotations[0]': {text: 'a'}, 'annotations[0].text': 'b'}, + {'annotations[0]': null, 'annotations[0].text': 'b'}, + {annotations: {text: 'a'}}, + {'annotations[0]': 'not an object'}, + {'annotations[100]': {text: 'bad index'}} + ].forEach(function(update) { + it('warns on ambiguous combinations and invalid values: ' + JSON.stringify(update), function() { + Plotly.relayout(gd, update); + expect(Loggers.warn).toHaveBeenCalled(); + // we could test the results here, but they're ambiguous and/or undefined so why bother? + // the important thing is the developer is warned that something went wrong. + }); + }); + + it('handles xref/yref changes with or without x/y changes', function(done) { + Plotly.relayout(gd, { + + // #0: change all 4, with opposite ordering of keys + 'annotations[0].x': 2.2, + 'annotations[0].xref': 'x', + 'annotations[0].yref': 'y', + 'annotations[0].y': 3.3, + + // #1: change xref and yref without x and y: no longer changes x & y + 'annotations[1].xref': 'x', + 'annotations[1].yref': 'y', + + // #2: change x and y + 'annotations[2].x': 0.1, + 'annotations[2].y': 0.3 + }) + .then(function() { + var annos = gd.layout.annotations; + + // explicitly change all 4 + expect(annos[0].x).toBe(2.2); + expect(annos[0].y).toBe(3.3); + expect(annos[0].xref).toBe('x'); + expect(annos[0].yref).toBe('y'); + + // just change xref/yref -> we do NOT make any implicit changes + // to x/y within plotly.js + expect(annos[1].x).toBe(0.25); + expect(annos[1].y).toBe(1); + expect(annos[1].xref).toBe('x'); + expect(annos[1].yref).toBe('y'); + + // just change x/y -> nothing else changes + expect(annos[2].x).toBe(0.1); + expect(annos[2].y).toBe(0.3); + expect(annos[2].xref).toBe('paper'); + expect(annos[2].yref).toBe('paper'); + expect(Loggers.warn).not.toHaveBeenCalled(); + }) + .catch(failTest) + .then(done); + }); +}); + +describe('annotations log/linear axis changes', function() { + 'use strict'; + + var mock = { + data: [ + {x: [1, 2, 3], y: [1, 2, 3]}, + {x: [1, 2, 3], y: [3, 2, 1], yaxis: 'y2'} + ], + layout: { + annotations: [ + {x: 1, y: 1, text: 'boo', xref: 'x', yref: 'y'}, + {x: 1, y: 1, text: '', ax: 2, ay: 2, axref: 'x', ayref: 'y'} + ], + yaxis: {range: [1, 3]}, + yaxis2: {range: [0, 1], overlaying: 'y', type: 'log'} + } + }; + var gd; + + beforeEach(function(done) { + gd = createGraphDiv(); + + var mockData = Lib.extendDeep([], mock.data), + mockLayout = Lib.extendDeep({}, mock.layout); + + Plotly.plot(gd, mockData, mockLayout).then(done); + }); + + afterEach(destroyGraphDiv); + + it('doesnt try to update position automatically with ref changes', function(done) { + // we don't try to figure out the position on a new axis / canvas + // automatically when you change xref / yref, we leave it to the caller. + // previously this logic was part of plotly.js... But it's really only + // the plot.ly workspace that wants this and can assign an unambiguous + // meaning to it, so we'll move the logic there, where there are far + // fewer edge cases to consider because xref never gets edited along + // with anything else in one `relayout` call. + + // linear to log + Plotly.relayout(gd, {'annotations[0].yref': 'y2'}) + .then(function() { + expect(gd.layout.annotations[0].y).toBe(1); + + // log to paper + return Plotly.relayout(gd, {'annotations[0].yref': 'paper'}); + }) + .then(function() { + expect(gd.layout.annotations[0].y).toBe(1); + + // paper to log + return Plotly.relayout(gd, {'annotations[0].yref': 'y2'}); + }) + .then(function() { + expect(gd.layout.annotations[0].y).toBe(1); + + // log to linear + return Plotly.relayout(gd, {'annotations[0].yref': 'y'}); + }) + .then(function() { + expect(gd.layout.annotations[0].y).toBe(1); + + // y and yref together + return Plotly.relayout(gd, {'annotations[0].y': 0.2, 'annotations[0].yref': 'y2'}); + }) + .then(function() { + expect(gd.layout.annotations[0].y).toBe(0.2); + + // yref first, then y + return Plotly.relayout(gd, {'annotations[0].yref': 'y', 'annotations[0].y': 2}); + }) + .then(function() { + expect(gd.layout.annotations[0].y).toBe(2); + }) + .catch(failTest) + .then(done); + }); + + it('keeps the same data value if the axis type is changed without position', function(done) { + // because annotations (and images) use linearized positions on log axes, + // we have `relayout` update the positions so the data value the annotation + // points to is unchanged by the axis type change. + + Plotly.relayout(gd, {'yaxis.type': 'log'}) + .then(function() { + expect(gd.layout.annotations[0].y).toBe(0); + expect(gd.layout.annotations[1].y).toBe(0); + expect(gd.layout.annotations[1].ay).toBeCloseTo(Math.LN2 / Math.LN10, 6); + + return Plotly.relayout(gd, {'yaxis.type': 'linear'}); + }) + .then(function() { + expect(gd.layout.annotations[0].y).toBe(1); + expect(gd.layout.annotations[1].y).toBe(1); + expect(gd.layout.annotations[1].ay).toBeCloseTo(2, 6); + + return Plotly.relayout(gd, { + 'yaxis.type': 'log', + 'annotations[0].y': 0.2, + 'annotations[1].ay': 0.3 + }); + }) + .then(function() { + expect(gd.layout.annotations[0].y).toBe(0.2); + expect(gd.layout.annotations[1].y).toBe(0); + expect(gd.layout.annotations[1].ay).toBe(0.3); + + return Plotly.relayout(gd, { + 'annotations[0].y': 2, + 'annotations[1].ay': 2.5, + 'yaxis.type': 'linear' + }); + }) + .then(function() { + expect(gd.layout.annotations[0].y).toBe(2); + expect(gd.layout.annotations[1].y).toBe(1); + expect(gd.layout.annotations[1].ay).toBe(2.5); + }) + .catch(failTest) .then(done); }); + }); -describe('annotations autosize', function() { +describe('annotations autorange', function() { 'use strict'; var mock = Lib.extendDeep({}, require('@mocks/annotations-autorange.json')); @@ -239,35 +505,35 @@ describe('annotations autosize', function() { beforeAll(function() { jasmine.addMatchers(customMatchers); + + gd = createGraphDiv(); }); afterEach(destroyGraphDiv); - it('should adapt to relayout calls', function(done) { - gd = createGraphDiv(); - - function assertRanges(x, y, x2, y2, x3, y3) { - var fullLayout = gd._fullLayout; - var PREC = 1; - - // xaxis2 need a bit more tolerance to pass on CI - // this most likely due to the different text bounding box values - // on headfull vs headless browsers. - // but also because it's a date axis that we've converted to ms - var PRECX2 = -10; - // yaxis2 needs a bit more now too... - var PRECY2 = 0.2; - var dateAx = fullLayout.xaxis2; - - expect(fullLayout.xaxis.range).toBeCloseToArray(x, PREC, '- xaxis'); - expect(fullLayout.yaxis.range).toBeCloseToArray(y, PREC, '- yaxis'); - expect(Lib.simpleMap(dateAx.range, dateAx.r2l)) - .toBeCloseToArray(Lib.simpleMap(x2, dateAx.r2l), PRECX2, 'xaxis2 ' + dateAx.range); - expect(fullLayout.yaxis2.range).toBeCloseToArray(y2, PRECY2, 'yaxis2'); - expect(fullLayout.xaxis3.range).toBeCloseToArray(x3, PREC, 'xaxis3'); - expect(fullLayout.yaxis3.range).toBeCloseToArray(y3, PREC, 'yaxis3'); - } + function assertRanges(x, y, x2, y2, x3, y3) { + var fullLayout = gd._fullLayout; + var PREC = 1; + + // xaxis2 need a bit more tolerance to pass on CI + // this most likely due to the different text bounding box values + // on headfull vs headless browsers. + // but also because it's a date axis that we've converted to ms + var PRECX2 = -10; + // yaxis2 needs a bit more now too... + var PRECY2 = 0.2; + var dateAx = fullLayout.xaxis2; + + expect(fullLayout.xaxis.range).toBeCloseToArray(x, PREC, '- xaxis'); + expect(fullLayout.yaxis.range).toBeCloseToArray(y, PREC, '- yaxis'); + expect(Lib.simpleMap(dateAx.range, dateAx.r2l)) + .toBeCloseToArray(Lib.simpleMap(x2, dateAx.r2l), PRECX2, 'xaxis2 ' + dateAx.range); + expect(fullLayout.yaxis2.range).toBeCloseToArray(y2, PRECY2, 'yaxis2'); + expect(fullLayout.xaxis3.range).toBeCloseToArray(x3, PREC, 'xaxis3'); + expect(fullLayout.yaxis3.range).toBeCloseToArray(y3, PREC, 'yaxis3'); + } + it('should adapt to relayout calls', function(done) { Plotly.plot(gd, mock).then(function() { assertRanges( [0.97, 2.03], [0.97, 2.03], @@ -317,6 +583,31 @@ describe('annotations autosize', function() { [0.9, 2.1], [0.86, 2.14] ); }) + .catch(failTest) + .then(done); + }); + + it('catches bad xref/yref', function(done) { + Plotly.plot(gd, mock).then(function() { + return Plotly.relayout(gd, {'annotations[1]': { + text: 'LT', + x: -1, + y: 3, + xref: 'x5', // will be converted to 'x' and xaxis should autorange + yref: 'y5', // same 'y' -> yaxis + ax: 50, + ay: 50 + }}); + }) + .then(function() { + assertRanges( + [-1.09, 2.09], [0.94, 3.06], + // the other axes shouldn't change + ['2000-10-01 08:23:18.0583', '2001-06-05 19:20:23.301'], [-0.245, 4.245], + [0.9, 2.1], [0.86, 2.14] + ); + }) + .catch(failTest) .then(done); }); }); @@ -445,6 +736,7 @@ describe('annotation clicktoshow', function() { // finally click each one off .then(clickAndCheck({newPts: [[1, 2]], newCTS: true, on: [2], step: 15})) .then(clickAndCheck({newPts: [[2, 3]], newCTS: true, on: [], step: 16})) + .catch(failTest) .then(done); }); }); diff --git a/test/jasmine/tests/click_test.js b/test/jasmine/tests/click_test.js index 88a32102dcd..b38c7e1d476 100644 --- a/test/jasmine/tests/click_test.js +++ b/test/jasmine/tests/click_test.js @@ -14,7 +14,7 @@ var customMatchers = require('../assets/custom_matchers'); // from the mousemove events and then simulate // a click event on mouseup var click = require('../assets/click'); -var doubleClick = require('../assets/double_click'); +var doubleClickRaw = require('../assets/double_click'); describe('Test click interactions:', function() { @@ -52,6 +52,12 @@ describe('Test click interactions:', function() { }); } + function doubleClick(x, y) { + return doubleClickRaw(x, y).then(function() { + return Plotly.Plots.previousPromises(gd); + }); + } + describe('click events', function() { var futureData; diff --git a/test/jasmine/tests/command_test.js b/test/jasmine/tests/command_test.js index 808fe17dfc5..6d334dbb51c 100644 --- a/test/jasmine/tests/command_test.js +++ b/test/jasmine/tests/command_test.js @@ -594,7 +594,8 @@ describe('attaching component bindings', function() { expect(gd.layout.sliders[0].active).toBe(0); // Check that it still has one attached listener: - expect(typeof gd._internalEv._events.plotly_animatingframe).toBe('function'); + expect(typeof gd._internalEv._events.plotly_animatingframe).toBe('function', + gd._internalEv._events.plotly_animatingframe); // Change this to a non-simple binding: return Plotly.relayout(gd, {'sliders[0].steps[0].args[0]': 'line.color'}); diff --git a/test/jasmine/tests/layout_images_test.js b/test/jasmine/tests/layout_images_test.js index 71d7c2c8692..e38cab663f7 100644 --- a/test/jasmine/tests/layout_images_test.js +++ b/test/jasmine/tests/layout_images_test.js @@ -1,10 +1,12 @@ var Plotly = require('@lib/index'); var Plots = require('@src/plots/plots'); var Images = require('@src/components/images'); +var Lib = require('@src/lib'); var d3 = require('d3'); var createGraphDiv = require('../assets/create_graph_div'); var destroyGraphDiv = require('../assets/destroy_graph_div'); +var failTest = require('../assets/fail_test'); var mouseEvent = require('../assets/mouse_event'); var jsLogo = 'https://images.plot.ly/language-icons/api-home/js-logo.png'; @@ -326,6 +328,7 @@ describe('Layout images', function() { Plotly.plot(gd, data, layout).then(function() { assertImages(0); + expect(gd.layout.images).toBeUndefined(); return Plotly.relayout(gd, 'images[0]', makeImage(jsLogo, 0.1, 0.1)); }) @@ -337,7 +340,8 @@ describe('Layout images', function() { .then(function() { assertImages(2); - return Plotly.relayout(gd, 'images[2]', makeImage(pythonLogo, 0.2, 0.5)); + // insert an image not at the end of the array + return Plotly.relayout(gd, 'images[0]', makeImage(pythonLogo, 0.2, 0.5)); }) .then(function() { assertImages(3); @@ -355,7 +359,8 @@ describe('Layout images', function() { assertImages(3); expect(gd.layout.images.length).toEqual(3); - return Plotly.relayout(gd, 'images[2]', null); + // delete not from the end of the array + return Plotly.relayout(gd, 'images[0]', null); }) .then(function() { assertImages(2); @@ -371,7 +376,7 @@ describe('Layout images', function() { }) .then(function() { assertImages(0); - expect(gd.layout.images).toEqual([]); + expect(gd.layout.images).toBeUndefined(); done(); }); @@ -380,3 +385,122 @@ describe('Layout images', function() { }); }); + +describe('images log/linear axis changes', function() { + 'use strict'; + + var mock = { + data: [ + {x: [1, 2, 3], y: [1, 2, 3]}, + {x: [1, 2, 3], y: [3, 2, 1], yaxis: 'y2'} + ], + layout: { + images: [{ + source: 'https://images.plot.ly/language-icons/api-home/python-logo.png', + x: 1, + y: 1, + xref: 'x', + yref: 'y', + sizex: 2, + sizey: 2 + }], + yaxis: {range: [1, 3]}, + yaxis2: {range: [0, 1], overlaying: 'y', type: 'log'} + } + }; + var gd; + + beforeEach(function(done) { + gd = createGraphDiv(); + + var mockData = Lib.extendDeep([], mock.data), + mockLayout = Lib.extendDeep({}, mock.layout); + + Plotly.plot(gd, mockData, mockLayout).then(done); + }); + + afterEach(destroyGraphDiv); + + it('doesnt try to update position automatically with ref changes', function(done) { + // we don't try to figure out the position on a new axis / canvas + // automatically when you change xref / yref, we leave it to the caller. + + // linear to log + Plotly.relayout(gd, {'images[0].yref': 'y2'}) + .then(function() { + expect(gd.layout.images[0].y).toBe(1); + + // log to paper + return Plotly.relayout(gd, {'images[0].yref': 'paper'}); + }) + .then(function() { + expect(gd.layout.images[0].y).toBe(1); + + // paper to log + return Plotly.relayout(gd, {'images[0].yref': 'y2'}); + }) + .then(function() { + expect(gd.layout.images[0].y).toBe(1); + + // log to linear + return Plotly.relayout(gd, {'images[0].yref': 'y'}); + }) + .then(function() { + expect(gd.layout.images[0].y).toBe(1); + + // y and yref together + return Plotly.relayout(gd, {'images[0].y': 0.2, 'images[0].yref': 'y2'}); + }) + .then(function() { + expect(gd.layout.images[0].y).toBe(0.2); + + // yref first, then y + return Plotly.relayout(gd, {'images[0].yref': 'y', 'images[0].y': 2}); + }) + .then(function() { + expect(gd.layout.images[0].y).toBe(2); + }) + .catch(failTest) + .then(done); + }); + + it('keeps the same data value if the axis type is changed without position', function(done) { + // because images (and images) use linearized positions on log axes, + // we have `relayout` update the positions so the data value the annotation + // points to is unchanged by the axis type change. + + Plotly.relayout(gd, {'yaxis.type': 'log'}) + .then(function() { + expect(gd.layout.images[0].y).toBe(0); + expect(gd.layout.images[0].sizey).toBeCloseTo(0.765551370675726, 6); + + return Plotly.relayout(gd, {'yaxis.type': 'linear'}); + }) + .then(function() { + expect(gd.layout.images[0].y).toBe(1); + expect(gd.layout.images[0].sizey).toBeCloseTo(2, 6); + + return Plotly.relayout(gd, { + 'yaxis.type': 'log', + 'images[0].y': 0.2, + 'images[0].sizey': 0.3 + }); + }) + .then(function() { + expect(gd.layout.images[0].y).toBe(0.2); + expect(gd.layout.images[0].sizey).toBe(0.3); + + return Plotly.relayout(gd, { + 'images[0].y': 2, + 'images[0].sizey': 2.5, + 'yaxis.type': 'linear' + }); + }) + .then(function() { + expect(gd.layout.images[0].y).toBe(2); + expect(gd.layout.images[0].sizey).toBe(2.5); + }) + .catch(failTest) + .then(done); + }); +}); diff --git a/test/jasmine/tests/lib_test.js b/test/jasmine/tests/lib_test.js index 7181510d4cc..edd5abfdf71 100644 --- a/test/jasmine/tests/lib_test.js +++ b/test/jasmine/tests/lib_test.js @@ -10,6 +10,7 @@ var createGraphDiv = require('../assets/create_graph_div'); var destroyGraphDiv = require('../assets/destroy_graph_div'); var Plots = PlotlyInternal.Plots; var customMatchers = require('../assets/custom_matchers'); +var failTest = require('../assets/fail_test'); describe('Test lib.js:', function() { 'use strict'; @@ -292,7 +293,7 @@ describe('Test lib.js:', function() { prop.set(null); expect(prop.get()).toBe(undefined); - expect(obj).toEqual({arr: [undefined, undefined, {b: 3}]}); + expect(obj).toEqual({arr: [{}, {}, {b: 3}]}); prop.set([2, 3, 4]); expect(prop.get()).toEqual([2, 3, 4]); @@ -329,7 +330,7 @@ describe('Test lib.js:', function() { expect(obj).toEqual({a: false, b: '', c: 0, d: NaN}); }); - it('should remove containers but not data arrays', function() { + it('should not remove data arrays or empty objects inside container arrays', function() { var obj = { annotations: [{a: [1, 2, 3]}], c: [1, 2, 3], @@ -344,23 +345,23 @@ describe('Test lib.js:', function() { propR = np(obj, 'range'), propS = np(obj, 'shapes[0]'); - propA.set([]); + propA.set([[]]); propC.set([]); propD0.set(undefined); propD1.set(undefined); propR.set([]); propS.set(null); - expect(obj).toEqual({c: []}); + // 'a' and 'c' are both potentially data arrays so we need to keep them + expect(obj).toEqual({annotations: [{a: []}], c: []}); }); - it('should have no empty object sub-containers but contain empty data arrays', function() { + it('should allow empty object sub-containers only in arrays', function() { var obj = {}, prop = np(obj, 'a[1].b.c'), - expectedArr = []; - - expectedArr[1] = {b: {c: 'pizza'}}; + // we never set a value into a[0] so it doesn't even get {} + expectedArr = [undefined, {b: {c: 'pizza'}}]; expect(prop.get()).toBe(undefined); expect(obj).toEqual({}); @@ -371,7 +372,31 @@ describe('Test lib.js:', function() { prop.set(null); expect(prop.get()).toBe(undefined); - expect(obj).toEqual({a: []}); + expect(obj).toEqual({a: [undefined, {}]}); + }); + + it('does not prune inside `args` arrays', function() { + var obj = {}, + args = np(obj, 'args'); + + args.set([]); + expect(obj.args).toBeUndefined(); + + args.set([null]); + expect(obj.args).toEqual([null]); + + np(obj, 'args[1]').set([]); + expect(obj.args).toEqual([null, []]); + + np(obj, 'args[2]').set({}); + expect(obj.args).toEqual([null, [], {}]); + + np(obj, 'args[1]').set(); + expect(obj.args).toEqual([null, undefined, {}]); + + // we still trim undefined off the end of arrays, but nothing else. + np(obj, 'args[2]').set(); + expect(obj.args).toEqual([null]); }); it('should get empty, and fail on set, with a bad input object', function() { @@ -1282,6 +1307,18 @@ describe('Test lib.js:', function() { expect(this.array).toEqual(['a', 'b', 'c', { a: 'A' }]); }); + + it('should recognize matching RegExps', function() { + expect(this.array).toEqual(['a', 'b', 'c', { a: 'A' }]); + + var r1 = /a/, + r2 = /a/; + Lib.pushUnique(this.array, r1); + expect(this.array).toEqual(['a', 'b', 'c', { a: 'A' }, r1]); + + Lib.pushUnique(this.array, r2); + expect(this.array).toEqual(['a', 'b', 'c', { a: 'A' }, r1]); + }); }); describe('filterUnique', function() { @@ -1652,8 +1689,9 @@ describe('Queue', function() { return Plotly.relayout(gd, 'updatemenus[0]', null); }) .then(function() { + // buttons have been stripped out because it's an empty container array... expect(gd.undoQueue.queue[1].undo.args[0][1]) - .toEqual({ 'updatemenus[0]': { buttons: []} }); + .toEqual({ 'updatemenus[0]': {} }); expect(gd.undoQueue.queue[1].redo.args[0][1]) .toEqual({ 'updatemenus[0]': null }); @@ -1664,8 +1702,8 @@ describe('Queue', function() { .toEqual({ 'transforms[0]': [ { type: 'filter' } ]}); expect(gd.undoQueue.queue[1].redo.args[0][1]) .toEqual({ 'transforms[0]': null }); - - done(); - }); + }) + .catch(failTest) + .then(done); }); }); diff --git a/test/jasmine/tests/mapbox_test.js b/test/jasmine/tests/mapbox_test.js index b9146242e3c..61a7e868a9b 100644 --- a/test/jasmine/tests/mapbox_test.js +++ b/test/jasmine/tests/mapbox_test.js @@ -10,6 +10,7 @@ var destroyGraphDiv = require('../assets/destroy_graph_div'); var hasWebGLSupport = require('../assets/has_webgl_support'); var mouseEvent = require('../assets/mouse_event'); var customMatchers = require('../assets/custom_matchers'); +var failTest = require('../assets/fail_test'); var MAPBOX_ACCESS_TOKEN = require('@build/credentials.json').MAPBOX_ACCESS_TOKEN; var TRANSITION_DELAY = 500; @@ -504,9 +505,9 @@ describe('mapbox plots', function() { expect(relayoutCnt).toEqual(5); assertLayout('Mapbox Light', [0, 0], 6, [80, 100, 454, 135]); - - done(); - }); + }) + .catch(failTest) + .then(done); }, LONG_TIMEOUT_INTERVAL); it('should be able to add, update and remove layers', function(done) { @@ -544,17 +545,22 @@ describe('mapbox plots', function() { return layerLen; } + function getLayerLength(gd) { + return (gd.layout.mapbox.layers || []).length; + } + function assertLayerStyle(gd, expectations, index) { var mapInfo = getMapInfo(gd), layers = mapInfo.layers, layerNames = mapInfo.layoutLayers; var layer = layers[layerNames[index]]; + expect(layer).toBeDefined(layerNames[index]); return new Promise(function(resolve) { setTimeout(function() { Object.keys(expectations).forEach(function(k) { - expect(layer.paint[k]).toEqual(expectations[k]); + expect(((layer || {}).paint || {})[k]).toEqual(expectations[k]); }); resolve(); }, TRANSITION_DELAY); @@ -564,25 +570,26 @@ describe('mapbox plots', function() { expect(countVisibleLayers(gd)).toEqual(0); Plotly.relayout(gd, 'mapbox.layers[0]', layer0).then(function() { - expect(gd.layout.mapbox.layers.length).toEqual(1); + expect(getLayerLength(gd)).toEqual(1); expect(countVisibleLayers(gd)).toEqual(1); + // add a new layer at the beginning return Plotly.relayout(gd, 'mapbox.layers[1]', layer1); }) .then(function() { - expect(gd.layout.mapbox.layers.length).toEqual(2); + expect(getLayerLength(gd)).toEqual(2); expect(countVisibleLayers(gd)).toEqual(2); return Plotly.relayout(gd, mapUpdate); }) .then(function() { - expect(gd.layout.mapbox.layers.length).toEqual(2); + expect(getLayerLength(gd)).toEqual(2); expect(countVisibleLayers(gd)).toEqual(2); return Plotly.relayout(gd, styleUpdate0); }) .then(function() { - expect(gd.layout.mapbox.layers.length).toEqual(2); + expect(getLayerLength(gd)).toEqual(2); expect(countVisibleLayers(gd)).toEqual(2); return assertLayerStyle(gd, { @@ -592,13 +599,13 @@ describe('mapbox plots', function() { }, 0); }) .then(function() { - expect(gd.layout.mapbox.layers.length).toEqual(2); + expect(getLayerLength(gd)).toEqual(2); expect(countVisibleLayers(gd)).toEqual(2); return Plotly.relayout(gd, styleUpdate1); }) .then(function() { - expect(gd.layout.mapbox.layers.length).toEqual(2); + expect(getLayerLength(gd)).toEqual(2); expect(countVisibleLayers(gd)).toEqual(2); return assertLayerStyle(gd, { @@ -608,25 +615,26 @@ describe('mapbox plots', function() { }, 1); }) .then(function() { - expect(gd.layout.mapbox.layers.length).toEqual(2); + expect(getLayerLength(gd)).toEqual(2); expect(countVisibleLayers(gd)).toEqual(2); - return Plotly.relayout(gd, 'mapbox.layers[1]', null); + // delete the first layer + return Plotly.relayout(gd, 'mapbox.layers[0]', null); }) .then(function() { - expect(gd.layout.mapbox.layers.length).toEqual(1); + expect(getLayerLength(gd)).toEqual(1); expect(countVisibleLayers(gd)).toEqual(1); return Plotly.relayout(gd, 'mapbox.layers[0]', null); }) .then(function() { - expect(gd.layout.mapbox.layers.length).toEqual(0); + expect(getLayerLength(gd)).toEqual(0); expect(countVisibleLayers(gd)).toEqual(0); return Plotly.relayout(gd, 'mapbox.layers[0]', {}); }) .then(function() { - expect(gd.layout.mapbox.layers).toEqual([]); + expect(gd.layout.mapbox.layers).toEqual([{}]); expect(countVisibleLayers(gd)).toEqual(0); // layer with no source are not drawn @@ -634,11 +642,11 @@ describe('mapbox plots', function() { return Plotly.relayout(gd, 'mapbox.layers[0].source', layer0.source); }) .then(function() { - expect(gd.layout.mapbox.layers.length).toEqual(1); + expect(getLayerLength(gd)).toEqual(1); expect(countVisibleLayers(gd)).toEqual(1); - - done(); - }); + }) + .catch(failTest) + .then(done); }, LONG_TIMEOUT_INTERVAL); it('should be able to update the access token', function(done) { @@ -651,8 +659,9 @@ describe('mapbox plots', function() { }).then(function() { expect(gd._fullLayout.mapbox.accesstoken).toEqual(MAPBOX_ACCESS_TOKEN); expect(gd._promises.length).toEqual(0); - done(); - }); + }) + .catch(failTest) + .then(done); }, LONG_TIMEOUT_INTERVAL); it('should be able to update traces', function(done) { @@ -688,9 +697,9 @@ describe('mapbox plots', function() { }) .then(function() { assertDataPts([5, 5]); - - done(); - }); + }) + .catch(failTest) + .then(done); }, LONG_TIMEOUT_INTERVAL); it('should display to hover labels on mouse over', function(done) { @@ -704,7 +713,9 @@ describe('mapbox plots', function() { assertMouseMove(blankPos, 0).then(function() { return assertMouseMove(pointPos, 1); - }).then(done); + }) + .catch(failTest) + .then(done); }, LONG_TIMEOUT_INTERVAL); it('should respond to hover interactions by', function(done) { @@ -750,9 +761,9 @@ describe('mapbox plots', function() { .then(function() { expect(hoverCnt).toEqual(1); expect(unhoverCnt).toEqual(1); - - done(); - }); + }) + .catch(failTest) + .then(done); }, LONG_TIMEOUT_INTERVAL); it('should respond drag / scroll interactions', function(done) { @@ -808,6 +819,7 @@ describe('mapbox plots', function() { assertLayout([-19.651, 13.751], 1.234, { withUpdateData: true }); }) + .catch(failTest) .then(done); // TODO test scroll @@ -844,6 +856,7 @@ describe('mapbox plots', function() { expect(ptData.pointNumber).toEqual(0, 'returning the correct point number'); }); }) + .catch(failTest) .then(done); }, LONG_TIMEOUT_INTERVAL); diff --git a/test/jasmine/tests/plot_api_test.js b/test/jasmine/tests/plot_api_test.js index e6deca855b0..e46a661927a 100644 --- a/test/jasmine/tests/plot_api_test.js +++ b/test/jasmine/tests/plot_api_test.js @@ -7,6 +7,7 @@ var Bar = require('@src/traces/bar'); var Legend = require('@src/components/legend'); var pkg = require('../../../package.json'); var subroutines = require('@src/plot_api/subroutines'); +var helpers = require('@src/plot_api/helpers'); var d3 = require('d3'); var createGraphDiv = require('../assets/create_graph_div'); @@ -179,6 +180,42 @@ describe('Test plot api', function() { .then(done); }); + it('errors if child and parent are edited together', function(done) { + var edit1 = {rando: [{a: 1}, {b: 2}]}; + var edit2 = {'rando[1]': {c: 3}}; + var edit3 = {'rando[1].d': 4}; + + Plotly.plot(gd, [{ x: [1, 2, 3], y: [1, 2, 3] }]) + .then(function() { + return Plotly.relayout(gd, edit1); + }) + .then(function() { + expect(gd.layout.rando).toEqual([{a: 1}, {b: 2}]); + return Plotly.relayout(gd, edit2); + }) + .then(function() { + expect(gd.layout.rando).toEqual([{a: 1}, {c: 3}]); + return Plotly.relayout(gd, edit3); + }) + .then(function() { + expect(gd.layout.rando).toEqual([{a: 1}, {c: 3, d: 4}]); + + // OK, setup is done - test the failing combinations + [[edit1, edit2], [edit1, edit3], [edit2, edit3]].forEach(function(v) { + // combine properties in both orders - which results in the same object + // but the properties are iterated in opposite orders + expect(function() { + return Plotly.relayout(gd, Lib.extendFlat({}, v[0], v[1])); + }).toThrow(); + expect(function() { + return Plotly.relayout(gd, Lib.extendFlat({}, v[1], v[0])); + }).toThrow(); + }); + }) + .catch(fail) + .then(done); + }); + it('can set empty text nodes', function(done) { var data = [{ x: [1, 2, 3], @@ -332,7 +369,7 @@ describe('Test plot api', function() { destroyGraphDiv(); }); - it('should redo auto z/contour when editing z array', function() { + it('should redo auto z/contour when editing z array', function(done) { Plotly.plot(gd, [{type: 'contour', z: [[1, 2], [3, 4]]}]).then(function() { expect(gd.data[0].zauto).toBe(true, gd.data[0]); expect(gd.data[0].zmin).toBe(1); @@ -347,7 +384,45 @@ describe('Test plot api', function() { expect(gd.data[0].zmax).toBe(10); expect(gd.data[0].contours).toEqual({start: 3, end: 9, size: 1}); - }); + }) + .catch(fail) + .then(done); + }); + + it('errors if child and parent are edited together', function(done) { + var edit1 = {rando: [[{a: 1}, {b: 2}]]}; + var edit2 = {'rando[1]': {c: 3}}; + var edit3 = {'rando[1].d': 4}; + + Plotly.plot(gd, [{x: [1, 2, 3], y: [1, 2, 3], type: 'scatter'}]) + .then(function() { + return Plotly.restyle(gd, edit1); + }) + .then(function() { + expect(gd.data[0].rando).toEqual([{a: 1}, {b: 2}]); + return Plotly.restyle(gd, edit2); + }) + .then(function() { + expect(gd.data[0].rando).toEqual([{a: 1}, {c: 3}]); + return Plotly.restyle(gd, edit3); + }) + .then(function() { + expect(gd.data[0].rando).toEqual([{a: 1}, {c: 3, d: 4}]); + + // OK, setup is done - test the failing combinations + [[edit1, edit2], [edit1, edit3], [edit2, edit3]].forEach(function(v) { + // combine properties in both orders - which results in the same object + // but the properties are iterated in opposite orders + expect(function() { + return Plotly.restyle(gd, Lib.extendFlat({}, v[0], v[1])); + }).toThrow(); + expect(function() { + return Plotly.restyle(gd, Lib.extendFlat({}, v[1], v[0])); + }).toThrow(); + }); + }) + .catch(fail) + .then(done); }); }); @@ -1309,3 +1384,52 @@ describe('Test plot api', function() { }); }); }); + +describe('plot_api helpers', function() { + describe('hasParent', function() { + var attr = 'annotations[2].xref'; + var attr2 = 'marker.line.width'; + + it('does not match the attribute itself or other related non-parent attributes', function() { + var aobj = { + // '' wouldn't be valid as an attribute in our framework, but tested + // just in case this would count as a parent. + '': true, + 'annotations[1]': {}, // parent structure, just a different array element + 'xref': 1, // another substring + 'annotations[2].x': 0.5, // substring of the attribute, but not a parent + 'annotations[2].xref': 'x2' // the attribute we're testing - not its own parent + }; + + expect(helpers.hasParent(aobj, attr)).toBe(false); + + var aobj2 = { + 'marker.line.color': 'red', + 'marker.line.width': 2, + 'marker.color': 'blue', + 'line': {} + }; + + expect(helpers.hasParent(aobj2, attr2)).toBe(false); + }); + + it('is false when called on a top-level attribute', function() { + var aobj = { + '': true, + 'width': 100 + }; + + expect(helpers.hasParent(aobj, 'width')).toBe(false); + }); + + it('matches any kind of parent', function() { + expect(helpers.hasParent({'annotations': []}, attr)).toBe(true); + expect(helpers.hasParent({'annotations[2]': {}}, attr)).toBe(true); + + expect(helpers.hasParent({'marker': {}}, attr2)).toBe(true); + // this one wouldn't actually make sense: marker.line needs to be an object... + // but hasParent doesn't look at the values in aobj, just its keys. + expect(helpers.hasParent({'marker.line': 1}, attr2)).toBe(true); + }); + }); +}); diff --git a/test/jasmine/tests/plotschema_test.js b/test/jasmine/tests/plotschema_test.js index b7b227bc8a0..91cecf03bcd 100644 --- a/test/jasmine/tests/plotschema_test.js +++ b/test/jasmine/tests/plotschema_test.js @@ -204,7 +204,10 @@ describe('plot schema', function() { expect(plotSchema.defs.valObjects).toBeDefined(); expect(plotSchema.defs.metaKeys) - .toEqual(['_isSubplotObj', '_isLinkedToArray', '_deprecated', 'description', 'role']); + .toEqual([ + '_isSubplotObj', '_isLinkedToArray', '_arrayAttrRegexps', + '_deprecated', 'description', 'role' + ]); }); it('should list the correct frame attributes', function() { diff --git a/test/jasmine/tests/shapes_test.js b/test/jasmine/tests/shapes_test.js index 43714ebd8df..bfd3ed490b2 100644 --- a/test/jasmine/tests/shapes_test.js +++ b/test/jasmine/tests/shapes_test.js @@ -13,6 +13,7 @@ var d3 = require('d3'); var customMatchers = require('../assets/custom_matchers'); var createGraphDiv = require('../assets/create_graph_div'); var destroyGraphDiv = require('../assets/destroy_graph_div'); +var failTest = require('../assets/fail_test'); describe('Test shapes defaults:', function() { @@ -186,7 +187,9 @@ describe('Test shapes:', function() { expect(countShapeLowerLayerNodes()).toEqual(1); expect(countShapePathsInLowerLayer()) .toEqual(countShapesInLowerLayer()); - }).then(done); + }) + .catch(failTest) + .then(done); }); }); @@ -205,7 +208,9 @@ describe('Test shapes:', function() { expect(countShapeUpperLayerNodes()).toEqual(1); expect(countShapePathsInUpperLayer()) .toEqual(countShapesInUpperLayer()); - }).then(done); + }) + .catch(failTest) + .then(done); }); }); @@ -226,7 +231,9 @@ describe('Test shapes:', function() { .toEqual(countSubplots(gd)); expect(countShapePathsInSubplots()) .toEqual(countShapesInSubplots()); - }).then(done); + }) + .catch(failTest) + .then(done); }); }); @@ -261,7 +268,17 @@ describe('Test shapes:', function() { expect(countShapePathsInUpperLayer()).toEqual(pathCount + 1); expect(getLastShape(gd)).toEqual(shape); expect(countShapes(gd)).toEqual(index + 1); - }).then(done); + + // add a shape not at the end of the array + return Plotly.relayout(gd, 'shapes[0]', getRandomShape()); + }) + .then(function() { + expect(countShapePathsInUpperLayer()).toEqual(pathCount + 2); + expect(getLastShape(gd)).toEqual(shape); + expect(countShapes(gd)).toEqual(index + 2); + }) + .catch(failTest) + .then(done); }); it('should be able to remove a shape', function(done) { @@ -292,15 +309,32 @@ describe('Test shapes:', function() { expect(countShapePathsInUpperLayer()).toEqual(pathCount - 2); expect(countShapes(gd)).toEqual(index - 1); }) + .catch(failTest) .then(done); }); it('should be able to remove all shapes', function(done) { - Plotly.relayout(gd, { shapes: [] }).then(function() { + Plotly.relayout(gd, { shapes: null }).then(function() { expect(countShapePathsInUpperLayer()).toEqual(0); expect(countShapePathsInLowerLayer()).toEqual(0); expect(countShapePathsInSubplots()).toEqual(0); - }).then(done); + }) + .catch(failTest) + .then(done); + }); + + it('can replace the shapes array', function(done) { + Plotly.relayout(gd, { shapes: [ + getRandomShape(), + getRandomShape() + ]}).then(function() { + expect(countShapePathsInUpperLayer()).toEqual(2); + expect(countShapePathsInLowerLayer()).toEqual(0); + expect(countShapePathsInSubplots()).toEqual(0); + expect(gd.layout.shapes.length).toBe(2); + }) + .catch(failTest) + .then(done); }); it('should be able to update a shape layer', function(done) { @@ -340,7 +374,9 @@ describe('Test shapes:', function() { .toEqual(shapesInUpperLayer + 1); expect(getLastShape(gd)).toEqual(shape); expect(countShapes(gd)).toEqual(index + 1); - }).then(done); + }) + .catch(failTest) + .then(done); }); }); }); @@ -404,6 +440,7 @@ describe('shapes autosize', function() { .then(function() { assertRanges([0, 3], [0, 2]); }) + .catch(failTest) .then(done); }); }); @@ -450,7 +487,9 @@ describe('Test shapes: a plot with shapes and an overlaid axis', function() { afterEach(destroyGraphDiv); it('should not throw an exception', function(done) { - Plotly.plot(gd, data, layout).then(done); + Plotly.plot(gd, data, layout) + .catch(failTest) + .then(done); }); }); diff --git a/test/jasmine/tests/sliders_test.js b/test/jasmine/tests/sliders_test.js index 5a1274769a2..377c82d70aa 100644 --- a/test/jasmine/tests/sliders_test.js +++ b/test/jasmine/tests/sliders_test.js @@ -97,15 +97,15 @@ describe('sliders defaults', function() { expect(layoutOut.sliders[0].steps.length).toEqual(3); expect(layoutOut.sliders[0].steps).toEqual([{ - method: 'relayout', args: [], + method: 'relayout', label: 'Label #1', value: 'label-1' }, { - method: 'update', args: [], + method: 'update', label: 'Label #2', value: 'Label #2' }, { - method: 'animate', args: [], + method: 'animate', label: 'step-2', value: 'lacks-label' }]); diff --git a/test/jasmine/tests/updatemenus_test.js b/test/jasmine/tests/updatemenus_test.js index f57635e0d3f..852f456d12f 100644 --- a/test/jasmine/tests/updatemenus_test.js +++ b/test/jasmine/tests/updatemenus_test.js @@ -315,6 +315,7 @@ describe('update menus interactions', function() { }); it('should draw only visible menus', function(done) { + var initialUM1 = Lib.extendDeep({}, gd.layout.updatemenus[1]); assertMenus([0, 0]); expect(gd._fullLayout._pushmargin['updatemenu-0']).toBeDefined(); expect(gd._fullLayout._pushmargin['updatemenu-1']).toBeDefined(); @@ -333,7 +334,7 @@ describe('update menus interactions', function() { return Plotly.relayout(gd, { 'updatemenus[0].visible': true, - 'updatemenus[1].visible': true + 'updatemenus[1]': initialUM1 }); }) .then(function() { @@ -655,7 +656,7 @@ describe('update menus interactions', function() { }); function assertNodeCount(query, cnt) { - expect(d3.selectAll(query).size()).toEqual(cnt); + expect(d3.selectAll(query).size()).toEqual(cnt, query); } // call assertMenus([0, 3]); to check that the 2nd update menu is dropped