diff --git a/src/components/annotations/draw.js b/src/components/annotations/draw.js index 240a8a06a7a..5ad8f8d09ee 100644 --- a/src/components/annotations/draw.js +++ b/src/components/annotations/draw.js @@ -584,7 +584,7 @@ function drawRaw(gd, options, index, subplotId, xa, ya) { }); }, doneFn: function() { - Registry.call('relayout', gd, getUpdateObj()); + Registry.call('_guiRelayout', gd, getUpdateObj()); var notesBox = document.querySelector('.js-notes-box-panel'); if(notesBox) notesBox.redraw(notesBox.selectedObj); } @@ -667,7 +667,7 @@ function drawRaw(gd, options, index, subplotId, xa, ya) { }, doneFn: function() { setCursor(annTextGroupInner); - Registry.call('relayout', gd, getUpdateObj()); + Registry.call('_guiRelayout', gd, getUpdateObj()); var notesBox = document.querySelector('.js-notes-box-panel'); if(notesBox) notesBox.redraw(notesBox.selectedObj); } @@ -691,7 +691,7 @@ function drawRaw(gd, options, index, subplotId, xa, ya) { modifyBase(ya._name + '.autorange', true); } - Registry.call('relayout', gd, getUpdateObj()); + Registry.call('_guiRelayout', gd, getUpdateObj()); }); } else annText.call(textLayout); diff --git a/src/components/colorbar/draw.js b/src/components/colorbar/draw.js index b1941b46b85..8e5b968fa06 100644 --- a/src/components/colorbar/draw.js +++ b/src/components/colorbar/draw.js @@ -485,15 +485,10 @@ module.exports = function draw(gd, id) { } function drawTitle(titleClass, titleOpts) { - var trace = getTrace(); - var propName = 'colorbar.title'; - var containerName = trace._module.colorbar.container; - if(containerName) propName = containerName + '.' + propName; - var dfltTitleOpts = { propContainer: cbAxisOut, - propName: propName, - traceIndex: trace.index, + propName: getPropName('title'), + traceIndex: getTrace().index, placeholder: fullLayout._dfltTitle.colorbar, containerGroup: container.select('.cbtitle') }; @@ -645,11 +640,10 @@ module.exports = function draw(gd, id) { setCursor(container); if(xf !== undefined && yf !== undefined) { - Registry.call('restyle', - gd, - {'colorbar.x': xf, 'colorbar.y': yf}, - getTrace().index - ); + var update = {}; + update[getPropName('x')] = xf; + update[getPropName('y')] = yf; + Registry.call('_guiRestyle', gd, update, getTrace().index); } } }); @@ -667,6 +661,14 @@ module.exports = function draw(gd, id) { } } + function getPropName(suffix) { + var trace = getTrace(); + var propName = 'colorbar.'; + var containerName = trace._module.colorbar.container; + if(containerName) propName = containerName + '.' + propName; + return propName + suffix; + } + // setter/getters for every item defined in opts Object.keys(opts).forEach(function(name) { component[name] = function(v) { diff --git a/src/components/legend/attributes.js b/src/components/legend/attributes.js index 72d03f1b817..258ebc0cfc4 100644 --- a/src/components/legend/attributes.js +++ b/src/components/legend/attributes.js @@ -120,7 +120,15 @@ module.exports = { 'or *bottom* of the legend.' ].join(' ') }, - editType: 'legend', + uirevision: { + valType: 'any', + role: 'info', + editType: 'none', + description: [ + 'Controls persistence of legend-driven changes in trace and pie label', + 'visibility. Defaults to `layout.uirevision`.' + ].join(' ') + }, valign: { valType: 'enumerated', values: ['top', 'middle', 'bottom'], @@ -130,5 +138,6 @@ module.exports = { description: [ 'Sets the vertical alignment of the symbols with respect to their associated text.', ].join(' ') - } + }, + editType: 'legend' }; diff --git a/src/components/legend/defaults.js b/src/components/legend/defaults.js index 6e8132212c2..ada758b9955 100644 --- a/src/components/legend/defaults.js +++ b/src/components/legend/defaults.js @@ -66,7 +66,7 @@ module.exports = function legendDefaults(layoutIn, layoutOut, fullData) { basePlotLayoutAttributes, 'showlegend', legendReallyHasATrace && legendTraceCount > 1); - if(showLegend === false) return; + if(showLegend === false && !containerIn.uirevision) return; var containerOut = Template.newContainer(layoutOut, 'legend'); @@ -74,6 +74,10 @@ module.exports = function legendDefaults(layoutIn, layoutOut, fullData) { return Lib.coerce(containerIn, containerOut, attributes, attr, dflt); } + coerce('uirevision', layoutOut.uirevision); + + if(showLegend === false) return; + coerce('bgcolor', layoutOut.paper_bgcolor); coerce('bordercolor'); coerce('borderwidth'); diff --git a/src/components/legend/draw.js b/src/components/legend/draw.js index e9b69b620fe..76c5e2c08ca 100644 --- a/src/components/legend/draw.js +++ b/src/components/legend/draw.js @@ -338,7 +338,7 @@ module.exports = function draw(gd) { }, doneFn: function() { if(xf !== undefined && yf !== undefined) { - Registry.call('relayout', gd, {'legend.x': xf, 'legend.y': yf}); + Registry.call('_guiRelayout', gd, {'legend.x': xf, 'legend.y': yf}); } }, clickFn: function(numClicks, e) { @@ -445,7 +445,7 @@ function drawTexts(g, gd, maxLength) { update.name = newName; } - return Registry.call('restyle', gd, update, traceIndex); + return Registry.call('_guiRestyle', gd, update, traceIndex); }); } else { textLayout(textEl); diff --git a/src/components/legend/handle_click.js b/src/components/legend/handle_click.js index b12bf50e8e3..608c4a272da 100644 --- a/src/components/legend/handle_click.js +++ b/src/components/legend/handle_click.js @@ -111,7 +111,7 @@ module.exports = function handleClick(g, gd, numClicks) { } } - Registry.call('relayout', gd, 'hiddenlabels', hiddenSlices); + Registry.call('_guiRelayout', gd, 'hiddenlabels', hiddenSlices); } else { var hasLegendgroup = legendgroup && legendgroup.length; var traceIndicesInGroup = []; @@ -217,6 +217,6 @@ module.exports = function handleClick(g, gd, numClicks) { } } - Registry.call('restyle', gd, attrUpdate, attrIndices); + Registry.call('_guiRestyle', gd, attrUpdate, attrIndices); } }; diff --git a/src/components/modebar/buttons.js b/src/components/modebar/buttons.js index 22875894d53..eca5d398ead 100644 --- a/src/components/modebar/buttons.js +++ b/src/components/modebar/buttons.js @@ -261,7 +261,7 @@ function handleCartesian(gd, ev) { aobj[astr] = val; } - Registry.call('relayout', gd, aobj); + Registry.call('_guiRelayout', gd, aobj); } modeBarButtons.zoom3d = { @@ -317,7 +317,7 @@ function handleDrag3d(gd, ev) { var val2d = (val === 'pan') ? val : 'zoom'; layoutUpdate.dragmode = val2d; - Registry.call('relayout', gd, layoutUpdate); + Registry.call('_guiRelayout', gd, layoutUpdate); } modeBarButtons.resetCameraDefault3d = { @@ -356,7 +356,7 @@ function handleCamera3d(gd, ev) { } } - Registry.call('relayout', gd, aobj); + Registry.call('_guiRelayout', gd, aobj); } modeBarButtons.hoverClosest3d = { @@ -370,54 +370,48 @@ modeBarButtons.hoverClosest3d = { click: handleHover3d }; -function handleHover3d(gd, ev) { +function getNextHover3d(gd, ev) { var button = ev.currentTarget; - var val = button._previousVal || false; - var layout = gd.layout; + var val = button._previousVal; var fullLayout = gd._fullLayout; var sceneIds = fullLayout._subplots.gl3d; var axes = ['xaxis', 'yaxis', 'zaxis']; - var spikeAttrs = ['showspikes', 'spikesides', 'spikethickness', 'spikecolor']; // initialize 'current spike' object to be stored in the DOM var currentSpikes = {}; - var axisSpikes = {}; var layoutUpdate = {}; if(val) { - layoutUpdate = Lib.extendDeep(layout, val); + layoutUpdate = val; button._previousVal = null; } else { - layoutUpdate = { - 'allaxes.showspikes': false - }; - for(var i = 0; i < sceneIds.length; i++) { - var sceneId = sceneIds[i], - sceneLayout = fullLayout[sceneId], - sceneSpikes = currentSpikes[sceneId] = {}; + var sceneId = sceneIds[i]; + var sceneLayout = fullLayout[sceneId]; - sceneSpikes.hovermode = sceneLayout.hovermode; - layoutUpdate[sceneId + '.hovermode'] = false; + var hovermodeAStr = sceneId + '.hovermode'; + currentSpikes[hovermodeAStr] = sceneLayout.hovermode; + layoutUpdate[hovermodeAStr] = false; // copy all the current spike attrs for(var j = 0; j < 3; j++) { var axis = axes[j]; - axisSpikes = sceneSpikes[axis] = {}; - - for(var k = 0; k < spikeAttrs.length; k++) { - var spikeAttr = spikeAttrs[k]; - axisSpikes[spikeAttr] = sceneLayout[axis][spikeAttr]; - } + var spikeAStr = sceneId + '.' + axis + '.showspikes'; + layoutUpdate[spikeAStr] = false; + currentSpikes[spikeAStr] = sceneLayout[axis].showspikes; } } - button._previousVal = Lib.extendDeep({}, currentSpikes); + button._previousVal = currentSpikes; } + return layoutUpdate; +} - Registry.call('relayout', gd, layoutUpdate); +function handleHover3d(gd, ev) { + var layoutUpdate = getNextHover3d(gd, ev); + Registry.call('_guiRelayout', gd, layoutUpdate); } modeBarButtons.zoomInGeo = { @@ -473,7 +467,7 @@ function handleGeo(gd, ev) { var scale = geoLayout.projection.scale; var newScale = (val === 'in') ? 2 * scale : 0.5 * scale; - Registry.call('relayout', gd, id + '.projection.scale', newScale); + Registry.call('_guiRelayout', gd, id + '.projection.scale', newScale); } else if(attr === 'reset') { resetView(gd, 'geo'); } @@ -501,18 +495,20 @@ modeBarButtons.hoverClosestPie = { click: toggleHover }; -function toggleHover(gd) { +function getNextHover(gd) { var fullLayout = gd._fullLayout; - var onHoverVal; + if(fullLayout.hovermode) return false; + if(fullLayout._has('cartesian')) { - onHoverVal = fullLayout._isHoriz ? 'y' : 'x'; + return fullLayout._isHoriz ? 'y' : 'x'; } - else onHoverVal = 'closest'; - - var newHover = gd._fullLayout.hovermode ? false : onHoverVal; + return 'closest'; +} - Registry.call('relayout', gd, 'hovermode', newHover); +function toggleHover(gd) { + var newHover = getNextHover(gd); + Registry.call('_guiRelayout', gd, 'hovermode', newHover); } // buttons when more then one plot types are present @@ -526,12 +522,10 @@ modeBarButtons.toggleHover = { icon: Icons.tooltip_basic, gravity: 'ne', click: function(gd, ev) { - toggleHover(gd); + var layoutUpdate = getNextHover3d(gd, ev); + layoutUpdate.hovermode = getNextHover(gd); - // the 3d hovermode update must come - // last so that layout.hovermode update does not - // override scene?.hovermode?.layout. - handleHover3d(gd, ev); + Registry.call('_guiRelayout', gd, layoutUpdate); } }; @@ -567,7 +561,7 @@ modeBarButtons.toggleSpikelines = { var aobj = setSpikelineVisibility(gd); - Registry.call('relayout', gd, aobj); + Registry.call('_guiRelayout', gd, aobj); } }; @@ -614,5 +608,5 @@ function resetView(gd, subplotType) { } } - Registry.call('relayout', gd, aObj); + Registry.call('_guiRelayout', gd, aObj); } diff --git a/src/components/rangeselector/draw.js b/src/components/rangeselector/draw.js index 65209412e70..1e6cc145d2e 100644 --- a/src/components/rangeselector/draw.js +++ b/src/components/rangeselector/draw.js @@ -68,7 +68,7 @@ module.exports = function draw(gd) { button.on('click', function() { if(gd._dragged) return; - Registry.call('relayout', gd, update); + Registry.call('_guiRelayout', gd, update); }); button.on('mouseover', function() { diff --git a/src/components/rangeslider/draw.js b/src/components/rangeslider/draw.js index c885e1e9c65..76ef7a04a78 100644 --- a/src/components/rangeslider/draw.js +++ b/src/components/rangeslider/draw.js @@ -286,7 +286,7 @@ function setDataRange(rangeSlider, gd, axisOpts, opts) { dataMax = clamp(opts.p2d(opts._pixelMax)); window.requestAnimationFrame(function() { - Registry.call('relayout', gd, axisOpts._name + '.range', [dataMin, dataMax]); + Registry.call('_guiRelayout', gd, axisOpts._name + '.range', [dataMin, dataMax]); }); } diff --git a/src/components/shapes/draw.js b/src/components/shapes/draw.js index 024c13928d6..04cc4cd7084 100644 --- a/src/components/shapes/draw.js +++ b/src/components/shapes/draw.js @@ -307,7 +307,7 @@ function setupDragElement(gd, shapePath, shapeOptions, index, shapeLayer) { // Don't rely on clipPath being activated during re-layout setClipPath(shapePath, gd, shapeOptions); - Registry.call('relayout', gd, editHelpers.getUpdateObj()); + Registry.call('_guiRelayout', gd, editHelpers.getUpdateObj()); } function abortDrag() { diff --git a/src/components/titles/index.js b/src/components/titles/index.js index 8b3f9c81fa3..4a8989755f0 100644 --- a/src/components/titles/index.js +++ b/src/components/titles/index.js @@ -239,9 +239,9 @@ function draw(gd, titleClass, options) { el.call(svgTextUtils.makeEditable, {gd: gd}) .on('edit', function(text) { if(traceIndex !== undefined) { - Registry.call('restyle', gd, prop, text, traceIndex); + Registry.call('_guiRestyle', gd, prop, text, traceIndex); } else { - Registry.call('relayout', gd, prop, text); + Registry.call('_guiRelayout', gd, prop, text); } }) .on('cancel', function() { diff --git a/src/core.js b/src/core.js index 3ebda0bf235..48395bee90f 100644 --- a/src/core.js +++ b/src/core.js @@ -29,7 +29,8 @@ var plotApi = require('./plot_api'); var methodNames = Object.keys(plotApi); for(var i = 0; i < methodNames.length; i++) { var name = methodNames[i]; - exports[name] = plotApi[name]; + // _ -> private API methods, but still registered for internal use + if(name.charAt(0) !== '_') exports[name] = plotApi[name]; register({ moduleType: 'apiMethod', name: name, diff --git a/src/plot_api/index.js b/src/plot_api/index.js index ac81c327b05..410b2da6f87 100644 --- a/src/plot_api/index.js +++ b/src/plot_api/index.js @@ -16,6 +16,10 @@ exports.restyle = main.restyle; exports.relayout = main.relayout; exports.redraw = main.redraw; exports.update = main.update; +exports._guiRestyle = main._guiRestyle; +exports._guiRelayout = main._guiRelayout; +exports._guiUpdate = main._guiUpdate; +exports._storeDirectGUIEdit = main._storeDirectGUIEdit; exports.react = main.react; exports.extendTraces = main.extendTraces; exports.prependTraces = main.prependTraces; diff --git a/src/plot_api/manage_arrays.js b/src/plot_api/manage_arrays.js index 6c84a5b1c67..99def8f1508 100644 --- a/src/plot_api/manage_arrays.js +++ b/src/plot_api/manage_arrays.js @@ -9,7 +9,6 @@ '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'); @@ -66,11 +65,15 @@ var isRemoveVal = exports.isRemoveVal = function isRemoveVal(val) { * 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 + * @param {function} _nestedProperty + * a (possibly modified for gui edits) nestedProperty constructor + * The modified version takes a 3rd argument, for a prefix to the attribute + * string necessary for storing GUI edits * * @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) { +exports.applyContainerArrayChanges = function applyContainerArrayChanges(gd, np, edits, flags, _nestedProperty) { var componentType = np.astr, supplyComponentDefaults = Registry.getComponentMethod(componentType, 'supplyLayoutDefaults'), draw = Registry.getComponentMethod(componentType, 'draw'), @@ -110,7 +113,7 @@ exports.applyContainerArrayChanges = function applyContainerArrayChanges(gd, np, // 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(); + componentArrayFull = _nestedProperty(fullLayout, componentType).get(); var deletes = [], firstIndexChange = -1, @@ -121,7 +124,7 @@ exports.applyContainerArrayChanges = function applyContainerArrayChanges(gd, np, objEdits, objKeys, objVal, - adding; + adding, prefix; // first make the add and edit changes for(i = 0; i < componentNums.length; i++) { @@ -160,7 +163,9 @@ exports.applyContainerArrayChanges = function applyContainerArrayChanges(gd, np, } else { for(j = 0; j < objKeys.length; j++) { - nestedProperty(componentArray[componentNum], objKeys[j]).set(objEdits[objKeys[j]]); + prefix = componentType + '[' + componentNum + '].'; + _nestedProperty(componentArray[componentNum], objKeys[j], prefix) + .set(objEdits[objKeys[j]]); } } } diff --git a/src/plot_api/plot_api.js b/src/plot_api/plot_api.js index 15c4c4d0b0d..828797411f2 100644 --- a/src/plot_api/plot_api.js +++ b/src/plot_api/plot_api.js @@ -15,6 +15,8 @@ var isNumeric = require('fast-isnumeric'); var hasHover = require('has-hover'); var Lib = require('../lib'); +var nestedProperty = Lib.nestedProperty; + var Events = require('../lib/events'); var Queue = require('../lib/queue'); @@ -851,7 +853,7 @@ function getExtendProperties(gd, update, indices, maxPoints) { * instance that references the key and value for this particular trace. */ trace = gd.data[indices[j]]; - prop = Lib.nestedProperty(trace, key); + prop = nestedProperty(trace, key); /* * Target is the existing gd.data.trace.dataArray value like "x" or "marker.size" @@ -1322,7 +1324,7 @@ exports.moveTraces = function moveTraces(gd, currentIndices, newIndices) { * If the array is too short, it will wrap around (useful for * style files that want to specify cyclical default values). */ -exports.restyle = function restyle(gd, astr, val, _traces) { +function restyle(gd, astr, val, _traces) { gd = Lib.getGraphDiv(gd); helpers.clearPromiseQueue(gd); @@ -1392,7 +1394,8 @@ exports.restyle = function restyle(gd, astr, val, _traces) { gd.emit('plotly_restyle', specs.eventData); return gd; }); -}; +} +exports.restyle = restyle; // for undo: undefined initial vals must be turned into nulls // so that we unset rather than ignore them @@ -1401,12 +1404,75 @@ function undefinedToNull(val) { return val; } +/** + * Factory function to wrap nestedProperty with GUI edits if necessary + * with GUI edits we add an optional prefix to the nestedProperty constructor + * to prepend to the attribute string in the preGUI store. + */ +function makeNP(preGUI, guiEditFlag) { + if(!guiEditFlag) return nestedProperty; + + return function(container, attr, prefix) { + var np = nestedProperty(container, attr); + var npSet = np.set; + np.set = function(val) { + var fullAttr = (prefix || '') + attr; + storeCurrent(fullAttr, np.get(), val, preGUI); + npSet(val); + }; + return np; + }; +} + +function storeCurrent(attr, val, newVal, preGUI) { + if(Array.isArray(val) || Array.isArray(newVal)) { + var arrayVal = Array.isArray(val) ? val : []; + var arrayNew = Array.isArray(newVal) ? newVal : []; + var maxLen = Math.max(arrayVal.length, arrayNew.length); + for(var i = 0; i < maxLen; i++) { + storeCurrent(attr + '[' + i + ']', arrayVal[i], arrayNew[i], preGUI); + } + } + else if(Lib.isPlainObject(val) || Lib.isPlainObject(newVal)) { + var objVal = Lib.isPlainObject(val) ? val : {}; + var objNew = Lib.isPlainObject(newVal) ? newVal : {}; + var objBoth = Lib.extendFlat({}, objVal, objNew); + for(var key in objBoth) { + storeCurrent(attr + '.' + key, objVal[key], objNew[key], preGUI); + } + } + else if(preGUI[attr] === undefined) { + preGUI[attr] = undefinedToNull(val); + } +} + +/** + * storeDirectGUIEdit: for routines that skip restyle/relayout and mock it + * by emitting a plotly_restyle or plotly_relayout event, this routine + * keeps track of the initial state in _preGUI for use by uirevision + * Does *not* apply these changes to data/layout - that's the responsibility + * of the calling routine. + * + * @param {object} container: the input attributes container (eg `layout` or a `trace`) + * @param {object} preGUI: where original values should be stored, either + * `layout._preGUI` or `layout._tracePreGUI[uid]` + * @param {object} edits: the {attr: val} object as normally passed to `relayout` etc + */ +exports._storeDirectGUIEdit = function(container, preGUI, edits) { + for(var attr in edits) { + var np = nestedProperty(container, attr); + storeCurrent(attr, np.get(), edits[attr], preGUI); + } +}; + function _restyle(gd, aobj, traces) { - var fullLayout = gd._fullLayout, - fullData = gd._fullData, - data = gd.data, - eventData = Lib.extendDeepAll({}, aobj), - i; + var fullLayout = gd._fullLayout; + var fullData = gd._fullData; + var data = gd.data; + var guiEditFlag = fullLayout._guiEditing; + var layoutNP = makeNP(fullLayout._preGUI, guiEditFlag); + var eventData = Lib.extendDeepAll({}, aobj); + var i; cleanDeprecatedAttributeKeys(aobj); @@ -1432,6 +1498,16 @@ function _restyle(gd, aobj, traces) { function rangeAttr(axName) { return 'LAYOUT' + axName + '.range'; } + function getFullTrace(traceIndex) { + // usually fullData maps 1:1 onto data, but with groupby transforms + // the fullData index can be greater. Take the *first* matching trace. + for(var j = traceIndex; j < fullData.length; j++) { + if(fullData[j]._input === data[traceIndex]) return fullData[j]; + } + // should never get here - and if we *do* it should cause an error + // later on undefined fullTrace is passed to nestedProperty. + } + // for attrs that interact (like scales & autoscales), save the // old vals before making the change // val=undefined will not set a value, just record what the value was. @@ -1447,9 +1523,11 @@ function _restyle(gd, aobj, traces) { var extraparam; if(attr.substr(0, 6) === 'LAYOUT') { - extraparam = Lib.nestedProperty(gd.layout, attr.replace('LAYOUT', '')); + extraparam = layoutNP(gd.layout, attr.replace('LAYOUT', '')); } else { - extraparam = Lib.nestedProperty(data[traces[i]], attr); + var tracei = traces[i]; + var preGUI = fullLayout._tracePreGUI[getFullTrace(tracei)._fullInput.uid]; + extraparam = makeNP(preGUI, guiEditFlag)(data[tracei], attr); } if(!(attr in undoit)) { @@ -1504,7 +1582,7 @@ function _restyle(gd, aobj, traces) { redoit[ai] = vi; if(ai.substr(0, 6) === 'LAYOUT') { - param = Lib.nestedProperty(gd.layout, ai.replace('LAYOUT', '')); + param = layoutNP(gd.layout, ai.replace('LAYOUT', '')); undoit[ai] = [undefinedToNull(param.get())]; // since we're allowing val to be an array, allow it here too, // even though that's meaningless @@ -1519,8 +1597,9 @@ function _restyle(gd, aobj, traces) { undoit[ai] = a0(); for(i = 0; i < traces.length; i++) { cont = data[traces[i]]; - contFull = fullData[traces[i]]; - param = Lib.nestedProperty(cont, ai); + contFull = getFullTrace(traces[i]); + var preGUI = fullLayout._tracePreGUI[contFull._fullInput.uid]; + param = makeNP(preGUI, guiEditFlag)(cont, ai); oldVal = param.get(); newVal = Array.isArray(vi) ? vi[i % vi.length] : vi; @@ -1530,7 +1609,7 @@ function _restyle(gd, aobj, traces) { var prefix = ai.substr(0, ai.length - finalPart.length - 1); var prefixDot = prefix ? prefix + '.' : ''; var innerContFull = prefix ? - Lib.nestedProperty(contFull, prefix).get() : contFull; + nestedProperty(contFull, prefix).get() : contFull; valObject = PlotSchema.getTraceValObject(contFull, param.parts); @@ -1577,14 +1656,14 @@ function _restyle(gd, aobj, traces) { Lib.swapAttrs(cont, ['?', '?src'], 'values', valuesTo); if(oldVal === 'pie') { - Lib.nestedProperty(cont, 'marker.color') - .set(Lib.nestedProperty(cont, 'marker.colors').get()); + nestedProperty(cont, 'marker.color') + .set(nestedProperty(cont, 'marker.colors').get()); // super kludgy - but if all pies are gone we won't remove them otherwise fullLayout._pielayer.selectAll('g.trace').remove(); } else if(Registry.traceIs(cont, 'cartesian')) { - Lib.nestedProperty(cont, 'marker.colors') - .set(Lib.nestedProperty(cont, 'marker.color').get()); + nestedProperty(cont, 'marker.colors') + .set(nestedProperty(cont, 'marker.color').get()); } } @@ -1655,7 +1734,7 @@ function _restyle(gd, aobj, traces) { // swap hovermode if set to "compare x/y data" if(ai === 'orientationaxes') { - var hovermode = Lib.nestedProperty(gd.layout, 'hovermode'); + var hovermode = nestedProperty(gd.layout, 'hovermode'); if(hovermode.get() === 'x') { hovermode.set('y'); } else if(hovermode.get() === 'y') { @@ -1761,7 +1840,7 @@ function cleanDeprecatedAttributeKeys(aobj) { * attribute object `{astr1: val1, astr2: val2 ...}` * allows setting multiple attributes simultaneously */ -exports.relayout = function relayout(gd, astr, val) { +function relayout(gd, astr, val) { gd = Lib.getGraphDiv(gd); helpers.clearPromiseQueue(gd); @@ -1824,7 +1903,8 @@ exports.relayout = function relayout(gd, astr, val) { gd.emit('plotly_relayout', specs.eventData); return gd; }); -}; +} +exports.relayout = relayout; // Optimization mostly for large splom traces where // Plots.supplyDefaults can take > 100ms @@ -1869,15 +1949,16 @@ var AX_AUTORANGE_RE = /^[xyz]axis[0-9]*\.autorange$/; var AX_DOMAIN_RE = /^[xyz]axis[0-9]*\.domain(\[[0|1]\])?$/; function _relayout(gd, aobj) { - var layout = gd.layout, - fullLayout = gd._fullLayout, - axes = Axes.list(gd), - eventData = Lib.extendDeepAll({}, aobj), - arrayEdits = {}, - keys, - arrayStr, - i, - j; + var layout = gd.layout; + var fullLayout = gd._fullLayout; + var guiEditFlag = fullLayout._guiEditing; + var layoutNP = makeNP(fullLayout._preGUI, guiEditFlag); + var keys = Object.keys(aobj); + var axes = Axes.list(gd); + var eventData = Lib.extendDeepAll({}, aobj); + var arrayEdits = {}; + + var arrayStr, i, j; cleanDeprecatedAttributeKeys(aobj); keys = Object.keys(aobj); @@ -1920,7 +2001,7 @@ function _relayout(gd, aobj) { // 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); + var p = layoutNP(layout, attr); if(!(attr in undoit)) { undoit[attr] = undefinedToNull(p.get()); } @@ -1945,7 +2026,7 @@ function _relayout(gd, aobj) { throw new Error('cannot set ' + ai + 'and a parent attribute simultaneously'); } - var p = Lib.nestedProperty(layout, ai); + var p = layoutNP(layout, ai); var vi = aobj[ai]; var plen = p.parts.length; // p.parts may end with an index integer if the property is an array @@ -1957,8 +2038,8 @@ function _relayout(gd, aobj) { var pleafPlus = p.parts[pend - 1] + '.' + pleaf; // trunk nodes (everything except the leaf) var ptrunk = p.parts.slice(0, pend).join('.'); - var parentIn = Lib.nestedProperty(gd.layout, ptrunk).get(); - var parentFull = Lib.nestedProperty(fullLayout, ptrunk).get(); + var parentIn = nestedProperty(gd.layout, ptrunk).get(); + var parentFull = nestedProperty(fullLayout, ptrunk).get(); var vOld = p.get(); if(vi === undefined) continue; @@ -2003,12 +2084,12 @@ function _relayout(gd, aobj) { // check autorange vs range else if(pleafPlus.match(AX_RANGE_RE)) { recordAlteredAxis(pleafPlus); - Lib.nestedProperty(fullLayout, ptrunk + '._inputRange').set(null); + nestedProperty(fullLayout, ptrunk + '._inputRange').set(null); } else if(pleafPlus.match(AX_AUTORANGE_RE)) { recordAlteredAxis(pleafPlus); - Lib.nestedProperty(fullLayout, ptrunk + '._inputRange').set(null); - var axFull = Lib.nestedProperty(fullLayout, ptrunk).get(); + nestedProperty(fullLayout, ptrunk + '._inputRange').set(null); + var axFull = nestedProperty(fullLayout, ptrunk).get(); if(axFull._inputDomain) { // if we're autoranging and this axis has a constrained domain, // reset it so we don't get locked into a shrunken size @@ -2016,7 +2097,7 @@ function _relayout(gd, aobj) { } } else if(pleafPlus.match(AX_DOMAIN_RE)) { - Lib.nestedProperty(fullLayout, ptrunk + '._inputDomain').set(null); + nestedProperty(fullLayout, ptrunk + '._inputDomain').set(null); } // toggling axis type between log and linear: we need to convert @@ -2085,10 +2166,10 @@ function _relayout(gd, aobj) { doextra(ptrunk + '.autorange', true); doextra(ptrunk + '.range', null); } - Lib.nestedProperty(fullLayout, ptrunk + '._inputRange').set(null); + nestedProperty(fullLayout, ptrunk + '._inputRange').set(null); } else if(pleaf.match(AX_NAME_PATTERN)) { - var fullProp = Lib.nestedProperty(fullLayout, ai).get(), + var fullProp = nestedProperty(fullLayout, ai).get(), newType = (vi || {}).type; // This can potentially cause strange behavior if the autotype is not @@ -2110,8 +2191,6 @@ function _relayout(gd, aobj) { arrayStr = containerArrayMatch.array; i = containerArrayMatch.index; var propStr = containerArrayMatch.property; - var componentArray = Lib.nestedProperty(layout, arrayStr); - var obji = (componentArray || [])[i] || {}; var updateValObject = valObject || {editType: 'calc'}; if(i !== '' && propStr === '') { @@ -2121,7 +2200,7 @@ function _relayout(gd, aobj) { if(manageArrays.isAddVal(vi)) { undoit[ai] = null; } else if(manageArrays.isRemoveVal(vi)) { - undoit[ai] = obji; + undoit[ai] = (nestedProperty(layout, arrayStr).get() || [])[i]; } else { Lib.warn('unrecognized full object value', aobj); } @@ -2165,7 +2244,7 @@ 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); + layoutNP(layout, arrayStr), arrayEdits[arrayStr], flags, layoutNP); if(!finished) flags.plot = true; } @@ -2243,7 +2322,7 @@ function updateAutosize(gd) { * integer or array of integers for the traces to alter (all if omitted) * */ -exports.update = function update(gd, traceUpdate, layoutUpdate, _traces) { +function update(gd, traceUpdate, layoutUpdate, _traces) { gd = Lib.getGraphDiv(gd); helpers.clearPromiseQueue(gd); @@ -2323,7 +2402,235 @@ exports.update = function update(gd, traceUpdate, layoutUpdate, _traces) { return gd; }); -}; +} +exports.update = update; + +/* + * internal-use-only restyle/relayout/update variants that record the initial + * values in (fullLayout|fullTrace)._preGUI so changes can be persisted across + * Plotly.react data updates, dependent on uirevision attributes + */ +function guiEdit(func) { + return function wrappedEdit(gd) { + gd._fullLayout._guiEditing = true; + var p = func.apply(null, arguments); + gd._fullLayout._guiEditing = false; + return p; + }; +} +exports._guiRestyle = guiEdit(restyle); +exports._guiRelayout = guiEdit(relayout); +exports._guiUpdate = guiEdit(update); + +// For connecting edited layout attributes to uirevision attrs +// If no `attr` we use `match[1] + '.uirevision'` +// Ordered by most common edits first, to minimize our search time +var layoutUIControlPatterns = [ + {pattern: /^hiddenlabels/, attr: 'legend.uirevision'}, + {pattern: /^((x|y)axis\d*)\.((auto)?range|title\.text)/}, + + // showspikes and modes include those nested inside scenes + {pattern: /axis\d*\.showspikes$/, attr: 'modebar.uirevision'}, + {pattern: /(hover|drag)mode$/, attr: 'modebar.uirevision'}, + + {pattern: /^(scene\d*)\.camera/}, + {pattern: /^(geo\d*)\.(projection|center)/}, + {pattern: /^(ternary\d*\.[abc]axis)\.(min|title\.text)$/}, + {pattern: /^(polar\d*\.radialaxis)\.((auto)?range|angle|title\.text)/}, + {pattern: /^(polar\d*\.angularaxis)\.rotation/}, + {pattern: /^(mapbox\d*)\.(center|zoom|bearing|pitch)/}, + + {pattern: /^legend\.(x|y)$/, attr: 'editrevision'}, + {pattern: /^(shapes|annotations)/, attr: 'editrevision'}, + {pattern: /^title\.text$/, attr: 'editrevision'} +]; + +// same for trace attributes: if `attr` is given it's in layout, +// or with no `attr` we use `trace.uirevision` +var traceUIControlPatterns = [ + {pattern: /^selectedpoints$/, attr: 'selectionrevision'}, + // "visible" includes trace.transforms[i].styles[j].value.visible + {pattern: /(^|value\.)visible$/, attr: 'legend.uirevision'}, + {pattern: /^dimensions\[\d+\]\.constraintrange/}, + + // below this you must be in editable: true mode + // TODO: I still put name and title with `trace.uirevision` + // reasonable or should these be `editrevision`? + // Also applies to axis titles up in the layout section + + // "name" also includes transform.styles + {pattern: /(^|value\.)name$/}, + // including nested colorbar attributes (ie marker.colorbar) + {pattern: /colorbar\.title\.text$/}, + {pattern: /colorbar\.(x|y)$/, attr: 'editrevision'} +]; + +function findUIPattern(key, patternSpecs) { + for(var i = 0; i < patternSpecs.length; i++) { + var spec = patternSpecs[i]; + var match = key.match(spec.pattern); + if(match) { + return {head: match[1], attr: spec.attr}; + } + } +} + +// We're finding the new uirevision before supplyDefaults, so do the +// inheritance manually. Note that only `undefined` inherits - other +// falsy values are returned. +function getNewRev(revAttr, container) { + var newRev = nestedProperty(container, revAttr).get(); + if(newRev !== undefined) return newRev; + + var parts = revAttr.split('.'); + parts.pop(); + while(parts.length > 1) { + parts.pop(); + newRev = nestedProperty(container, parts.join('.') + '.uirevision').get(); + if(newRev !== undefined) return newRev; + } + + return container.uirevision; +} + +function getFullTraceIndexFromUid(uid, fullData) { + for(var i = 0; i < fullData.length; i++) { + if(fullData[i]._fullInput.uid === uid) return i; + } + return -1; +} + +function getTraceIndexFromUid(uid, data, tracei) { + for(var i = 0; i < data.length; i++) { + if(data[i].uid === uid) return i; + } + // fall back on trace order, but only if user didn't provide a uid for that trace + return data[tracei].uid ? -1 : tracei; +} + +function valsMatch(v1, v2) { + var v1IsObj = Lib.isPlainObject(v1); + var v1IsArray = Array.isArray(v1); + if(v1IsObj || v1IsArray) { + return ( + (v1IsObj && Lib.isPlainObject(v2)) || + (v1IsArray && Array.isArray(v2)) + ) && JSON.stringify(v1) === JSON.stringify(v2); + } + return v1 === v2; +} + +function applyUIRevisions(data, layout, oldFullData, oldFullLayout) { + var layoutPreGUI = oldFullLayout._preGUI; + var key, revAttr, oldRev, newRev, match, preGUIVal, newNP, newVal; + var bothInheritAutorange = []; + var newRangeAccepted = {}; + for(key in layoutPreGUI) { + match = findUIPattern(key, layoutUIControlPatterns); + if(match) { + revAttr = match.attr || (match.head + '.uirevision'); + oldRev = nestedProperty(oldFullLayout, revAttr).get(); + newRev = oldRev && getNewRev(revAttr, layout); + if(newRev && (newRev === oldRev)) { + preGUIVal = layoutPreGUI[key]; + if(preGUIVal === null) preGUIVal = undefined; + newNP = nestedProperty(layout, key); + newVal = newNP.get(); + if(valsMatch(newVal, preGUIVal)) { + if(newVal === undefined && key.substr(key.length - 9) === 'autorange') { + bothInheritAutorange.push(key.substr(0, key.length - 10)); + } + newNP.set(undefinedToNull(nestedProperty(oldFullLayout, key).get())); + continue; + } + } + } + else { + Lib.warn('unrecognized GUI edit: ' + key); + } + // if we got this far, the new value was accepted as the new starting + // point (either because it changed or revision changed) + // so remove it from _preGUI for next time. + delete layoutPreGUI[key]; + + if(key.substr(key.length - 8, 6) === 'range[') { + newRangeAccepted[key.substr(0, key.length - 9)] = 1; + } + } + + // Special logic for `autorange`, since it interacts with `range`: + // If the new figure's matching `range` was kept, and `autorange` + // wasn't supplied explicitly in either the original or the new figure, + // we shouldn't alter that - but we may just have done that, so fix it. + for(var i = 0; i < bothInheritAutorange.length; i++) { + var axAttr = bothInheritAutorange[i]; + if(newRangeAccepted[axAttr]) { + var newAx = nestedProperty(layout, axAttr).get(); + if(newAx) delete newAx.autorange; + } + } + + // Now traces - try to match them up by uid (in case we added/deleted in + // the middle), then fall back on index. + var allTracePreGUI = oldFullLayout._tracePreGUI; + for(var uid in allTracePreGUI) { + var tracePreGUI = allTracePreGUI[uid]; + var newTrace = null; + var fullInput; + for(key in tracePreGUI) { + // wait until we know we have preGUI values to look for traces + // but if we don't find both, stop looking at this uid + if(!newTrace) { + var fulli = getFullTraceIndexFromUid(uid, oldFullData); + if(fulli < 0) { + // Somehow we didn't even have this trace in oldFullData... + // I guess this could happen with `deleteTraces` or something + delete allTracePreGUI[uid]; + break; + } + var fullTrace = oldFullData[fulli]; + fullInput = fullTrace._fullInput; + + var newTracei = getTraceIndexFromUid(uid, data, fullInput.index); + if(newTracei < 0) { + // No match in new data + delete allTracePreGUI[uid]; + break; + } + newTrace = data[newTracei]; + } + + match = findUIPattern(key, traceUIControlPatterns); + if(match) { + if(match.attr) { + oldRev = nestedProperty(oldFullLayout, match.attr).get(); + newRev = oldRev && getNewRev(match.attr, layout); + } + else { + oldRev = fullInput.uirevision; + // inheritance for trace.uirevision is simple, just layout.uirevision + newRev = newTrace.uirevision; + if(newRev === undefined) newRev = layout.uirevision; + } + + if(newRev && newRev === oldRev) { + preGUIVal = tracePreGUI[key]; + if(preGUIVal === null) preGUIVal = undefined; + newNP = nestedProperty(newTrace, key); + newVal = newNP.get(); + if(valsMatch(newVal, preGUIVal)) { + newNP.set(undefinedToNull(nestedProperty(fullInput, key).get())); + continue; + } + } + } + else { + Lib.warn('unrecognized GUI edit: ' + key + ' in trace uid ' + uid); + } + delete tracePreGUI[key]; + } + } +} /** * Plotly.react: @@ -2387,6 +2694,8 @@ exports.react = function(gd, data, layout, config) { gd.layout = layout || {}; helpers.cleanLayout(gd.layout); + applyUIRevisions(gd.data, gd.layout, oldFullData, oldFullLayout); + // "true" skips updating calcdata and remapping arrays from calcTransforms, // which supplyDefaults usually does at the end, but we may need to NOT do // if the diff (which we haven't determined yet) says we'll recalc diff --git a/src/plots/attributes.js b/src/plots/attributes.js index 653176fd260..b6db8871022 100644 --- a/src/plots/attributes.js +++ b/src/plots/attributes.js @@ -164,5 +164,27 @@ module.exports = { 'An array of operations that manipulate the trace data,', 'for example filtering or sorting the data arrays.' ].join(' ') + }, + uirevision: { + valType: 'any', + role: 'info', + editType: 'none', + description: [ + 'Controls persistence of some user-driven changes to the trace:', + '`constraintrange` in `parcoords` traces, as well as some', + '`editable: true` modifications such as `name` and `colorbar.title`.', + 'Defaults to `layout.uirevision`.', + 'Note that other user-driven trace attribute changes are controlled', + 'by `layout` attributes:', + '`trace.visible` is controlled by `layout.legend.uirevision`,', + '`selectedpoints` is controlled by `layout.selectionrevision`,', + 'and `colorbar.(x|y)` (accessible with `config: {editable: true}`)', + 'is controlled by `layout.editrevision`.', + 'Trace changes are tracked by `uid`, which only falls back on trace', + 'index if no `uid` is provided. So if your app can add/remove traces', + 'before the end of the `data` array, such that the same trace has a', + 'different index, you can still preserve user-driven changes if you', + 'give each trace a `uid` that stays with it as it moves.' + ].join(' ') } }; diff --git a/src/plots/cartesian/autorange.js b/src/plots/cartesian/autorange.js index 3343fb4a899..3fe613d5e7d 100644 --- a/src/plots/cartesian/autorange.js +++ b/src/plots/cartesian/autorange.js @@ -12,6 +12,7 @@ var isNumeric = require('fast-isnumeric'); var Lib = require('../../lib'); var FP_SAFE = require('../../constants/numerical').FP_SAFE; +var Registry = require('../../registry'); module.exports = { getAutoRange: getAutoRange, @@ -250,6 +251,13 @@ function doAutoRange(gd, ax) { // but we want to report its results back to layout axIn = ax._input; + + // before we edit _input, store preGUI values + var edits = {}; + edits[ax._attr + '.range'] = ax.range; + edits[ax._attr + '.autorange'] = ax.autorange; + Registry.call('_storeDirectGUIEdit', gd.layout, gd._fullLayout._preGUI, edits); + axIn.range = ax.range.slice(); axIn.autorange = ax.autorange; } diff --git a/src/plots/cartesian/dragbox.js b/src/plots/cartesian/dragbox.js index abeebf0fe80..6cc75dc0443 100644 --- a/src/plots/cartesian/dragbox.js +++ b/src/plots/cartesian/dragbox.js @@ -271,7 +271,7 @@ function makeDragBox(gd, plotinfo, x, y, w, h, ns, ew) { .on('edit', function(text) { var v = ax.d2r(text); if(v !== undefined) { - Registry.call('relayout', gd, attrStr, v); + Registry.call('_guiRelayout', gd, attrStr, v); } }); } @@ -712,7 +712,7 @@ function makeDragBox(gd, plotinfo, x, y, w, h, ns, ew) { } gd.emit('plotly_doubleclick', null); - Registry.call('relayout', gd, attrs); + Registry.call('_guiRelayout', gd, attrs); } // dragTail - finish a drag event with a redraw @@ -726,7 +726,7 @@ function makeDragBox(gd, plotinfo, x, y, w, h, ns, ew) { // accumulated MathJax promises - wait for them before we relayout. Lib.syncOrAsync([ Plots.previousPromises, - function() { Registry.call('relayout', gd, updates); } + function() { Registry.call('_guiRelayout', gd, updates); } ], gd); } diff --git a/src/plots/cartesian/layout_attributes.js b/src/plots/cartesian/layout_attributes.js index 0dd84907dce..09a11d54a4d 100644 --- a/src/plots/cartesian/layout_attributes.js +++ b/src/plots/cartesian/layout_attributes.js @@ -790,6 +790,16 @@ module.exports = { 'Used with `categoryorder`.' ].join(' ') }, + uirevision: { + valType: 'any', + role: 'info', + editType: 'none', + description: [ + 'Controls persistence of user-driven changes in axis `range`,', + '`autorange`, and `title` if in `editable: true` configuration.', + 'Defaults to `layout.uirevision`.' + ].join(' ') + }, editType: 'calc', _deprecated: { diff --git a/src/plots/cartesian/layout_defaults.js b/src/plots/cartesian/layout_defaults.js index 63abbc2364b..e0e6e18dc08 100644 --- a/src/plots/cartesian/layout_defaults.js +++ b/src/plots/cartesian/layout_defaults.js @@ -158,7 +158,7 @@ module.exports = function supplyLayoutDefaults(layoutIn, layoutOut, fullData) { axLayoutOut._shapeIndices = []; // set up some private properties - axLayoutOut._name = axName; + axLayoutOut._name = axLayoutOut._attr = axName; var id = axLayoutOut._id = name2id(axName); var overlayableAxes = getOverlayableAxes(axLetter, axName); @@ -176,6 +176,8 @@ module.exports = function supplyLayoutDefaults(layoutIn, layoutOut, fullData) { splomStash: ((layoutOut._splomAxes || {})[axLetter] || {})[id] }; + coerce('uirevision', layoutOut.uirevision); + handleTypeDefaults(axLayoutIn, axLayoutOut, coerce, defaultOptions); handleAxisDefaults(axLayoutIn, axLayoutOut, coerce, defaultOptions, layoutOut); diff --git a/src/plots/cartesian/select.js b/src/plots/cartesian/select.js index 35d39d285a6..f1efad89e54 100644 --- a/src/plots/cartesian/select.js +++ b/src/plots/cartesian/select.js @@ -666,13 +666,22 @@ function isOnlyOnePointSelected(searchTraces) { function updateSelectedState(gd, searchTraces, eventData) { var i, searchInfo, cd, trace; + // before anything else, update preGUI if necessary + for(i = 0; i < searchTraces.length; i++) { + var fullInputTrace = searchTraces[i].cd[0].trace._fullInput; + var tracePreGUI = gd._fullLayout._tracePreGUI[fullInputTrace.uid]; + if(tracePreGUI.selectedpoints === undefined) { + tracePreGUI.selectedpoints = fullInputTrace._input.selectedpoints || null; + } + } + if(eventData) { var pts = eventData.points || []; for(i = 0; i < searchTraces.length; i++) { trace = searchTraces[i].cd[0].trace; - trace.selectedpoints = []; - trace._input.selectedpoints = []; + trace._input.selectedpoints = trace._fullInput.selectedpoints = []; + if(trace._fullInput !== trace) trace.selectedpoints = []; } for(i = 0; i < pts.length; i++) { @@ -682,10 +691,14 @@ function updateSelectedState(gd, searchTraces, eventData) { if(pt.pointIndices) { [].push.apply(data.selectedpoints, pt.pointIndices); - [].push.apply(fullData.selectedpoints, pt.pointIndices); + if(trace._fullInput !== trace) { + [].push.apply(fullData.selectedpoints, pt.pointIndices); + } } else { data.selectedpoints.push(pt.pointIndex); - fullData.selectedpoints.push(pt.pointIndex); + if(trace._fullInput !== trace) { + fullData.selectedpoints.push(pt.pointIndex); + } } } } @@ -694,6 +707,9 @@ function updateSelectedState(gd, searchTraces, eventData) { trace = searchTraces[i].cd[0].trace; delete trace.selectedpoints; delete trace._input.selectedpoints; + if(trace._fullInput !== trace) { + delete trace._fullInput.selectedpoints; + } } } diff --git a/src/plots/geo/geo.js b/src/plots/geo/geo.js index db222a49d6a..4f551176014 100644 --- a/src/plots/geo/geo.js +++ b/src/plots/geo/geo.js @@ -367,7 +367,7 @@ proto.updateFx = function(fullLayout, geoLayout) { updateObj[_this.id + '.' + k] = viewInitial[k]; } - Registry.call('relayout', gd, updateObj); + Registry.call('_guiRelayout', gd, updateObj); gd.emit('plotly_doubleclick', null); } diff --git a/src/plots/geo/layout/layout_attributes.js b/src/plots/geo/layout/layout_attributes.js index f326c816550..848aa52038e 100644 --- a/src/plots/geo/layout/layout_attributes.js +++ b/src/plots/geo/layout/layout_attributes.js @@ -65,7 +65,7 @@ var geoAxesAttrs = { } }; -module.exports = overrideAll({ +var attrs = module.exports = overrideAll({ domain: domainAttrs({name: 'geo'}, { description: [ 'Note that geo subplots are constrained by domain.', @@ -311,3 +311,14 @@ module.exports = overrideAll({ lonaxis: geoAxesAttrs, lataxis: geoAxesAttrs }, 'plot', 'from-root'); + +// set uirevision outside of overrideAll so it can be `editType: 'none'` +attrs.uirevision = { + valType: 'any', + role: 'info', + editType: 'none', + description: [ + 'Controls persistence of user-driven changes in the view', + '(projection and center). Defaults to `layout.uirevision`.' + ].join(' ') +}; diff --git a/src/plots/geo/zoom.js b/src/plots/geo/zoom.js index f55e123f202..247ed1553c4 100644 --- a/src/plots/geo/zoom.js +++ b/src/plots/geo/zoom.js @@ -11,6 +11,7 @@ var d3 = require('d3'); var Lib = require('../../lib'); +var Registry = require('../../registry'); var radians = Math.PI / 180; var degrees = 180 / Math.PI; @@ -47,8 +48,10 @@ function initZoom(geo, projection) { function sync(geo, projection, cb) { var id = geo.id; var gd = geo.graphDiv; - var userOpts = gd.layout[id]; - var fullOpts = gd._fullLayout[id]; + var layout = gd.layout; + var userOpts = layout[id]; + var fullLayout = gd._fullLayout; + var fullOpts = fullLayout[id]; var eventData = {}; @@ -64,6 +67,7 @@ function sync(geo, projection, cb) { cb(set); set('projection.scale', projection.scale() / geo.fitScale); + Registry.call('_storeDirectGUIEdit', layout, fullLayout._preGUI, eventData); gd.emit('plotly_relayout', eventData); } diff --git a/src/plots/gl2d/scene2d.js b/src/plots/gl2d/scene2d.js index 892169cb934..7cb2080c629 100644 --- a/src/plots/gl2d/scene2d.js +++ b/src/plots/gl2d/scene2d.js @@ -312,27 +312,31 @@ proto.updateRefs = function(newFullLayout) { }; proto.relayoutCallback = function() { - var graphDiv = this.graphDiv, - xaxis = this.xaxis, - yaxis = this.yaxis, - layout = graphDiv.layout; - - // update user layout - layout.xaxis.autorange = xaxis.autorange; - layout.xaxis.range = xaxis.range.slice(0); - layout.yaxis.autorange = yaxis.autorange; - layout.yaxis.range = yaxis.range.slice(0); - - // make a meaningful value to be passed on to the possible 'plotly_relayout' subscriber(s) - // scene.camera has no many useful projection or scale information - // helps determine which one is the latest input (if async) - var update = { - lastInputTime: this.camera.lastInputTime - }; - - update[xaxis._name] = xaxis.range.slice(0); - update[yaxis._name] = yaxis.range.slice(0); - + var graphDiv = this.graphDiv; + var xaxis = this.xaxis; + var yaxis = this.yaxis; + var layout = graphDiv.layout; + + // make a meaningful value to be passed on to possible 'plotly_relayout' subscriber(s) + var update = {}; + var xrange = update[xaxis._name + '.range'] = xaxis.range.slice(); + var yrange = update[yaxis._name + '.range'] = yaxis.range.slice(); + update[xaxis._name + '.autorange'] = xaxis.autorange; + update[yaxis._name + '.autorange'] = yaxis.autorange; + + Registry.call('_storeDirectGUIEdit', graphDiv.layout, graphDiv._fullLayout._preGUI, update); + + // update the input layout + var xaIn = layout[xaxis._name]; + xaIn.range = xrange; + xaIn.autorange = xaxis.autorange; + + var yaIn = layout[yaxis._name]; + yaIn.range = yrange; + yaIn.autorange = yaxis.autorange; + + // lastInputTime helps determine which one is the latest input (if async) + update.lastInputTime = this.camera.lastInputTime; graphDiv.emit('plotly_relayout', update); }; diff --git a/src/plots/gl3d/layout/layout_attributes.js b/src/plots/gl3d/layout/layout_attributes.js index 00b940885c4..693d2d9bf65 100644 --- a/src/plots/gl3d/layout/layout_attributes.js +++ b/src/plots/gl3d/layout/layout_attributes.js @@ -155,6 +155,15 @@ module.exports = { 'Determines the mode of hover interactions for this scene.' ].join(' ') }, + uirevision: { + valType: 'any', + role: 'info', + editType: 'none', + description: [ + 'Controls persistence of user-driven changes in camera attributes.', + 'Defaults to `layout.uirevision`.' + ].join(' ') + }, editType: 'plot', _deprecated: { diff --git a/src/plots/gl3d/scene.js b/src/plots/gl3d/scene.js index 59ba7a4446e..cfc7eee11bc 100644 --- a/src/plots/gl3d/scene.js +++ b/src/plots/gl3d/scene.js @@ -701,10 +701,10 @@ proto.setCamera = function setCamera(cameraData) { // save camera to user layout (i.e. gd.layout) proto.saveCamera = function saveCamera(layout) { - var cameraData = this.getCamera(), - cameraNestedProp = Lib.nestedProperty(layout, this.id + '.camera'), - cameraDataLastSave = cameraNestedProp.get(), - hasChanged = false; + var cameraData = this.getCamera(); + var cameraNestedProp = Lib.nestedProperty(layout, this.id + '.camera'); + var cameraDataLastSave = cameraNestedProp.get(); + var hasChanged = false; function same(x, y, i, j) { var vectors = ['up', 'center', 'eye'], @@ -724,7 +724,14 @@ proto.saveCamera = function saveCamera(layout) { } } - if(hasChanged) cameraNestedProp.set(cameraData); + if(hasChanged) { + cameraNestedProp.set(cameraData); + + var fullLayout = this.fullLayout; + var cameraFullNP = Lib.nestedProperty(fullLayout, this.id + '.camera'); + cameraFullNP.set(cameraData); + Registry.call('_storeDirectGUIEdit', layout, fullLayout._preGUI, cameraData); + } return hasChanged; }; @@ -743,6 +750,26 @@ proto.updateFx = function(dragmode, hovermode) { camera.mode = 'turntable'; camera.keyBindingMode = 'rotate'; + // The setter for camera.mode animates the transition to z-up, + // but only if we *don't* explicitly set z-up earlier via the + // relayout. So push `up` back to layout & fullLayout manually now. + var gd = this.graphDiv; + var fullLayout = gd._fullLayout; + var fullCamera = this.fullSceneLayout.camera; + var x = fullCamera.up.x; + var y = fullCamera.up.y; + var z = fullCamera.up.z; + // only push `up` back to (full)layout if it's going to change + if(z / Math.sqrt(x * x + y * y + z * z) > 0.999) return; + + var attr = this.id + '.camera.up'; + var zUp = {x: 0, y: 0, z: 1}; + var edits = {}; + edits[attr] = zUp; + var layout = gd.layout; + Registry.call('_storeDirectGUIEdit', layout, fullLayout._preGUI, edits); + fullCamera.up = zUp; + Lib.nestedProperty(layout, attr).set(zUp); } else { // none rotation modes [pan or zoom] diff --git a/src/plots/layout_attributes.js b/src/plots/layout_attributes.js index 356ac5f8161..f49cf3a5959 100644 --- a/src/plots/layout_attributes.js +++ b/src/plots/layout_attributes.js @@ -306,6 +306,47 @@ module.exports = { 'different identity from its predecessor contains new data.' ].join(' ') }, + uirevision: { + valType: 'any', + role: 'info', + editType: 'none', + description: [ + 'Used to allow user interactions with the plot to persist after', + '`Plotly.react` calls that are unaware of these interactions.', + 'If `uirevision` is omitted, or if it is given and it changed from', + 'the previous `Plotly.react` call, the exact new figure is used.', + 'If `uirevision` is truthy and did NOT change, any attribute', + 'that has been affected by user interactions and did not receive a', + 'different value in the new figure will keep the interaction value.', + '`layout.uirevision` attribute serves as the default for', + '`uirevision` attributes in various sub-containers. For finer', + 'control you can set these sub-attributes directly. For example,', + 'if your app separately controls the data on the x and y axes you', + 'might set `xaxis.uirevision=*time*` and `yaxis.uirevision=*cost*`.', + 'Then if only the y data is changed, you can update', + '`yaxis.uirevision=*quantity*` and the y axis range will reset but', + 'the x axis range will retain any user-driven zoom.' + ].join(' ') + }, + editrevision: { + valType: 'any', + role: 'info', + editType: 'none', + description: [ + 'Controls persistence of user-driven changes in `editable: true`', + 'configuration, other than trace names and axis titles.', + 'Defaults to `layout.uirevision`.' + ].join(' ') + }, + selectionrevision: { + valType: 'any', + role: 'info', + editType: 'none', + description: [ + 'Controls persistence of user-driven changes in selected points', + 'from all traces.' + ].join(' ') + }, template: { valType: 'any', role: 'info', @@ -356,6 +397,16 @@ module.exports = { editType: 'modebar', description: 'Sets the color of the active or hovered on icons in the modebar.' }, + uirevision: { + valType: 'any', + role: 'info', + editType: 'none', + description: [ + 'Controls persistence of user-driven changes related to the modebar,', + 'including `hovermode`, `dragmode`, and `showspikes` at both the', + 'root level and inside subplots. Defaults to `layout.uirevision`.' + ].join(' ') + }, editType: 'modebar' }, _deprecated: { diff --git a/src/plots/mapbox/layout_attributes.js b/src/plots/mapbox/layout_attributes.js index 4d59225f208..e07767b913d 100644 --- a/src/plots/mapbox/layout_attributes.js +++ b/src/plots/mapbox/layout_attributes.js @@ -25,7 +25,7 @@ var fontAttr = fontAttrs({ }); fontAttr.family.dflt = 'Open Sans Regular, Arial Unicode MS Regular'; -module.exports = overrideAll({ +var attrs = module.exports = overrideAll({ _arrayAttrRegexps: [Lib.counterRegex('mapbox', '.layers', true)], domain: domainAttrs({name: 'mapbox'}), @@ -245,3 +245,14 @@ module.exports = overrideAll({ } }) }, 'plot', 'from-root'); + +// set uirevision outside of overrideAll so it can be `editType: 'none'` +attrs.uirevision = { + valType: 'any', + role: 'info', + editType: 'none', + description: [ + 'Controls persistence of user-driven changes in the view:', + '`center`, `zoom`, `bearing`, `pitch`. Defaults to `layout.uirevision`.' + ].join(' ') +}; diff --git a/src/plots/mapbox/mapbox.js b/src/plots/mapbox/mapbox.js index 77da3871017..f1ddfdb01dc 100644 --- a/src/plots/mapbox/mapbox.js +++ b/src/plots/mapbox/mapbox.js @@ -13,6 +13,7 @@ var mapboxgl = require('mapbox-gl'); var Fx = require('../../components/fx'); var Lib = require('../../lib'); +var Registry = require('../../registry'); var dragElement = require('../../components/dragelement'); var prepSelect = require('../cartesian/select').prepSelect; var selectOnClick = require('../cartesian/select').selectOnClick; @@ -137,22 +138,22 @@ proto.createMap = function(calcData, fullLayout, resolve, reject) { map.on('moveend', function(eventData) { if(!self.map) return; - var view = self.getView(); - - opts._input.center = opts.center = view.center; - opts._input.zoom = opts.zoom = view.zoom; - opts._input.bearing = opts.bearing = view.bearing; - opts._input.pitch = opts.pitch = view.pitch; - // 'moveend' gets triggered by map.setCenter, map.setZoom, // map.setBearing and map.setPitch. // - // Here, we make sure that 'plotly_relayout' is - // triggered here only when the 'moveend' originates from a + // Here, we make sure that state updates amd 'plotly_relayout' + // are triggered only when the 'moveend' originates from a // mouse target (filtering out API calls) to not // duplicate 'plotly_relayout' events. if(eventData.originalEvent || wheeling) { + var view = self.getView(); + + opts._input.center = opts.center = view.center; + opts._input.zoom = opts.zoom = view.zoom; + opts._input.bearing = opts.bearing = view.bearing; + opts._input.pitch = opts.pitch = view.pitch; + emitRelayoutFromView(view); } wheeling = false; @@ -210,6 +211,7 @@ proto.createMap = function(calcData, fullLayout, resolve, reject) { for(var k in view) { evtData[id + '.' + k] = view[k]; } + Registry.call('_storeDirectGUIEdit', gd.layout, gd._fullLayout._preGUI, evtData); gd.emit('plotly_relayout', evtData); } @@ -306,8 +308,8 @@ proto.updateData = function(calcData) { }; proto.updateLayout = function(fullLayout) { - var map = this.map, - opts = this.opts; + var map = this.map; + var opts = this.opts; map.setCenter(convertCenter(opts.center)); map.setZoom(opts.zoom); diff --git a/src/plots/plots.js b/src/plots/plots.js index 0fd72fd7341..e2a28e87a39 100644 --- a/src/plots/plots.js +++ b/src/plots/plots.js @@ -479,6 +479,24 @@ plots.supplyDefaults = function(gd, opts) { // relink functions and _ attributes to promote consistency between plots relinkPrivateKeys(newFullLayout, oldFullLayout); + // For persisting GUI-driven changes in layout + // _preGUI and _tracePreGUI were already copied over in relinkPrivateKeys + if(!newFullLayout._preGUI) newFullLayout._preGUI = {}; + // track trace GUI changes by uid rather than by trace index + if(!newFullLayout._tracePreGUI) newFullLayout._tracePreGUI = {}; + var tracePreGUI = newFullLayout._tracePreGUI; + var uids = {}; + var uid; + for(uid in tracePreGUI) uids[uid] = 'old'; + for(i = 0; i < newFullData.length; i++) { + uid = newFullData[i]._fullInput.uid; + if(!uids[uid]) tracePreGUI[uid] = {}; + uids[uid] = 'new'; + } + for(uid in uids) { + if(uids[uid] === 'old') delete tracePreGUI[uid]; + } + // TODO may return a promise plots.doAutoMargin(gd); @@ -1122,6 +1140,8 @@ plots.supplyTraceDefaults = function(traceIn, traceOut, colorIndex, layout, trac coerce('type'); coerce('name', layout._traceWord + ' ' + traceInIndex); + coerce('uirevision', layout.uirevision); + // we want even invisible traces to make their would-be subplots visible // so coerce the subplot id(s) now no matter what var _module = plots.getModule(traceOut); @@ -1375,12 +1395,16 @@ plots.supplyLayoutGlobalDefaults = function(layoutIn, layoutOut, formatObj) { coerce('colorway'); coerce('datarevision'); + var uirevision = coerce('uirevision'); + coerce('editrevision', uirevision); + coerce('selectionrevision', uirevision); coerce('modebar.orientation'); coerce('modebar.bgcolor', Color.addOpacity(layoutOut.paper_bgcolor, 0.5)); var modebarDefaultColor = Color.contrast(Color.rgb(layoutOut.modebar.bgcolor)); coerce('modebar.color', Color.addOpacity(modebarDefaultColor, 0.3)); coerce('modebar.activecolor', Color.addOpacity(modebarDefaultColor, 0.7)); + coerce('modebar.uirevision', uirevision); Registry.getComponentMethod( 'calendars', diff --git a/src/plots/polar/layout_attributes.js b/src/plots/polar/layout_attributes.js index ec630027526..4d878a9fd43 100644 --- a/src/plots/polar/layout_attributes.js +++ b/src/plots/polar/layout_attributes.js @@ -117,6 +117,17 @@ var radialAxisAttrs = { hoverformat: axesAttrs.hoverformat, + uirevision: { + valType: 'any', + role: 'info', + editType: 'none', + description: [ + 'Controls persistence of user-driven changes in axis `range`,', + '`autorange`, `angle`, and `title` if in `editable: true` configuration.', + 'Defaults to `polar.uirevision`.' + ].join(' ') + }, + editType: 'calc', _deprecated: { @@ -125,6 +136,9 @@ var radialAxisAttrs = { } }; +// radial title is not gui-editable, so it needs dflt: '', similar to carpet axes. +radialAxisAttrs.title.text.dflt = ''; + extendFlat( radialAxisAttrs, @@ -219,6 +233,16 @@ var angularAxisAttrs = { hoverformat: axesAttrs.hoverformat, + uirevision: { + valType: 'any', + role: 'info', + editType: 'none', + description: [ + 'Controls persistence of user-driven changes in axis `rotation`.', + 'Defaults to `polar.uirevision`.' + ].join(' ') + }, + editType: 'calc' }; @@ -298,5 +322,16 @@ module.exports = { // TODO maybe? // annotations: + uirevision: { + valType: 'any', + role: 'info', + editType: 'none', + description: [ + 'Controls persistence of user-driven changes in axis attributes,', + 'if not overridden in the individual axes.', + 'Defaults to `layout.uirevision`.' + ].join(' ') + }, + editType: 'calc' }; diff --git a/src/plots/polar/layout_defaults.js b/src/plots/polar/layout_defaults.js index 69c031acef3..3064d451b2f 100644 --- a/src/plots/polar/layout_defaults.js +++ b/src/plots/polar/layout_defaults.js @@ -53,6 +53,7 @@ function handleDefaults(contIn, contOut, coerce, opts) { var axIn = contIn[axName]; var axOut = Template.newContainer(contOut, axName); axOut._id = axOut._name = axName; + axOut._attr = opts.id + '.' + axName; axOut._traceIndices = subplotData.map(function(t) { return t._expandedIndex; }); var dataAttr = constants.axisName2dataArray[axName]; @@ -66,6 +67,8 @@ function handleDefaults(contIn, contOut, coerce, opts) { var visible = coerceAxis('visible'); setConvert(axOut, contOut, layoutOut); + coerceAxis('uirevision', contOut.uirevision); + var dfltColor; var dfltFontColor; diff --git a/src/plots/polar/polar.js b/src/plots/polar/polar.js index 9d4c8d50611..5e56eb258d7 100644 --- a/src/plots/polar/polar.js +++ b/src/plots/polar/polar.js @@ -906,7 +906,7 @@ proto.updateMainDrag = function(fullLayout) { rl[0] + (r0 - innerRadius) * m, rl[0] + (r1 - innerRadius) * m ]; - Registry.call('relayout', gd, _this.id + '.radialaxis.range', newRng); + Registry.call('_guiRelayout', gd, _this.id + '.radialaxis.range', newRng); } function zoomClick(numClicks, evt) { @@ -922,7 +922,7 @@ proto.updateMainDrag = function(fullLayout) { } gd.emit('plotly_doubleclick', null); - Registry.call('relayout', gd, updateObj); + Registry.call('_guiRelayout', gd, updateObj); } if(clickMode.indexOf('select') > -1 && numClicks === 1) { @@ -1049,9 +1049,9 @@ proto.updateRadialDrag = function(fullLayout, polarLayout, rngIndex) { function doneFn() { if(angle1 !== null) { - Registry.call('relayout', gd, _this.id + '.radialaxis.angle', angle1); + Registry.call('_guiRelayout', gd, _this.id + '.radialaxis.angle', angle1); } else if(rprime !== null) { - Registry.call('relayout', gd, _this.id + '.radialaxis.range[' + rngIndex + ']', rprime); + Registry.call('_guiRelayout', gd, _this.id + '.radialaxis.range[' + rngIndex + ']', rprime); } } @@ -1256,7 +1256,7 @@ proto.updateAngularDrag = function(fullLayout) { updateObj[_this.id + '.radialaxis.angle'] = rrot1; } - Registry.call('relayout', gd, updateObj); + Registry.call('_guiRelayout', gd, updateObj); } dragOpts.prepFn = function(evt, startX, startY) { diff --git a/src/plots/subplot_defaults.js b/src/plots/subplot_defaults.js index 9e83049b773..98f55abeeef 100644 --- a/src/plots/subplot_defaults.js +++ b/src/plots/subplot_defaults.js @@ -67,6 +67,12 @@ module.exports = function handleSubplotDefaults(layoutIn, layoutOut, fullData, o subplotLayoutOut = Template.newContainer(layoutOut, id, baseId); + // All subplot containers get a `uirevision` inheriting from the base. + // Currently all subplots containers have some user interaction + // attributes, but if we ever add one that doesn't, we would need an + // option to skip this step. + coerce('uirevision', layoutOut.uirevision); + var dfltDomains = {}; dfltDomains[partition] = [i / idsLength, (i + 1) / idsLength]; handleDomainDefaults(subplotLayoutOut, layoutOut, coerce, dfltDomains); diff --git a/src/plots/ternary/layout_attributes.js b/src/plots/ternary/layout_attributes.js index 86ae1f9d910..00baf134829 100644 --- a/src/plots/ternary/layout_attributes.js +++ b/src/plots/ternary/layout_attributes.js @@ -69,7 +69,7 @@ var ternaryAxesAttrs = { } }; -module.exports = overrideAll({ +var attrs = module.exports = overrideAll({ domain: domainAttrs({name: 'ternary'}), bgcolor: { @@ -92,3 +92,26 @@ module.exports = overrideAll({ baxis: ternaryAxesAttrs, caxis: ternaryAxesAttrs }, 'plot', 'from-root'); + +// set uirevisions outside of `overrideAll` so we can get `editType: none` +attrs.uirevision = { + valType: 'any', + role: 'info', + editType: 'none', + description: [ + 'Controls persistence of user-driven changes in axis `min` and `title`,', + 'if not overridden in the individual axes.', + 'Defaults to `layout.uirevision`.' + ].join(' ') +}; + +attrs.aaxis.uirevision = attrs.baxis.uirevision = attrs.caxis.uirevision = { + valType: 'any', + role: 'info', + editType: 'none', + description: [ + 'Controls persistence of user-driven changes in axis `min`,', + 'and `title` if in `editable: true` configuration.', + 'Defaults to `ternary.uirevision`.' + ].join(' ') +}; diff --git a/src/plots/ternary/layout_defaults.js b/src/plots/ternary/layout_defaults.js index 0ccbbd2dfd8..d530175a2d9 100644 --- a/src/plots/ternary/layout_defaults.js +++ b/src/plots/ternary/layout_defaults.js @@ -46,7 +46,7 @@ function handleTernaryDefaults(ternaryLayoutIn, ternaryLayoutOut, coerce, option containerOut = Template.newContainer(ternaryLayoutOut, axName); containerOut._name = axName; - handleAxisDefaults(containerIn, containerOut, options); + handleAxisDefaults(containerIn, containerOut, options, ternaryLayoutOut); } // if the min values contradict each other, set them all to default (0) @@ -65,13 +65,15 @@ function handleTernaryDefaults(ternaryLayoutIn, ternaryLayoutOut, coerce, option } } -function handleAxisDefaults(containerIn, containerOut, options) { +function handleAxisDefaults(containerIn, containerOut, options, ternaryLayoutOut) { var axAttrs = layoutAttributes[containerOut._name]; function coerce(attr, dflt) { return Lib.coerce(containerIn, containerOut, axAttrs, attr, dflt); } + coerce('uirevision', ternaryLayoutOut.uirevision); + containerOut.type = 'linear'; // no other types allowed for ternary var dfltColor = coerce('color'); diff --git a/src/plots/ternary/ternary.js b/src/plots/ternary/ternary.js index f13ebfa9199..6d681b5f107 100644 --- a/src/plots/ternary/ternary.js +++ b/src/plots/ternary/ternary.js @@ -540,18 +540,22 @@ proto.initInteractions = function() { var x0, y0, mins0, span0, mins, lum, path0, dimmed, zb, corners; + function makeUpdate(_mins) { + var attrs = {}; + attrs[_this.id + '.aaxis.min'] = _mins.a; + attrs[_this.id + '.baxis.min'] = _mins.b; + attrs[_this.id + '.caxis.min'] = _mins.c; + return attrs; + } + function clickZoomPan(numClicks, evt) { var clickMode = gd._fullLayout.clickmode; removeZoombox(gd); if(numClicks === 2) { - var attrs = {}; - attrs[_this.id + '.aaxis.min'] = 0; - attrs[_this.id + '.baxis.min'] = 0; - attrs[_this.id + '.caxis.min'] = 0; gd.emit('plotly_doubleclick', null); - Registry.call('relayout', gd, attrs); + Registry.call('_guiRelayout', gd, makeUpdate({a: 0, b: 0, c: 0})); } if(clickMode.indexOf('select') > -1 && numClicks === 1) { @@ -655,12 +659,7 @@ proto.initInteractions = function() { if(mins === mins0) return; - var attrs = {}; - attrs[_this.id + '.aaxis.min'] = mins.a; - attrs[_this.id + '.baxis.min'] = mins.b; - attrs[_this.id + '.caxis.min'] = mins.c; - - Registry.call('relayout', gd, attrs); + Registry.call('_guiRelayout', gd, makeUpdate(mins)); if(SHOWZOOMOUTTIP && gd.data && gd._context.showTips) { Lib.notifier(_(gd, 'Double-click to zoom back out'), 'long'); @@ -733,12 +732,7 @@ proto.initInteractions = function() { } function dragDone() { - var attrs = {}; - attrs[_this.id + '.aaxis.min'] = mins.a; - attrs[_this.id + '.baxis.min'] = mins.b; - attrs[_this.id + '.caxis.min'] = mins.c; - - Registry.call('relayout', gd, attrs); + Registry.call('_guiRelayout', gd, makeUpdate(mins)); } // finally, set up hover and click diff --git a/src/traces/histogram/attributes.js b/src/traces/histogram/attributes.js index 260a4a7b267..0bd1fe4b249 100644 --- a/src/traces/histogram/attributes.js +++ b/src/traces/histogram/attributes.js @@ -174,11 +174,6 @@ module.exports = { dflt: null, role: 'style', editType: 'calc', - impliedEdits: { - 'ybins.start': undefined, - 'ybins.end': undefined, - 'ybins.size': undefined - }, description: [ 'Obsolete: since v1.42 each bin attribute is auto-determined', 'separately and `autobiny` is not needed. However, we accept', diff --git a/src/traces/parcoords/plot.js b/src/traces/parcoords/plot.js index aef8c9581c4..3607ec6091a 100644 --- a/src/traces/parcoords/plot.js +++ b/src/traces/parcoords/plot.js @@ -22,12 +22,17 @@ module.exports = function plot(gd, cdparcoords) { var gdDimensions = {}; var gdDimensionsOriginalOrder = {}; + var fullIndices = {}; + var inputIndices = {}; var size = fullLayout._size; cdparcoords.forEach(function(d, i) { - gdDimensions[i] = gd.data[i].dimensions; - gdDimensionsOriginalOrder[i] = gd.data[i].dimensions.slice(); + var trace = d[0].trace; + fullIndices[i] = trace.index; + var iIn = inputIndices[i] = trace._fullInput.index; + gdDimensions[i] = gd.data[iIn].dimensions; + gdDimensionsOriginalOrder[i] = gd.data[iIn].dimensions.slice(); }); var filterChanged = function(i, originalDimensionIndex, newRanges) { @@ -37,21 +42,36 @@ module.exports = function plot(gd, cdparcoords) { var gdDimension = gdDimensionsOriginalOrder[i][originalDimensionIndex]; var newConstraints = newRanges.map(function(r) { return r.slice(); }); + + // Store constraint range in preGUI + // This one doesn't work if it's stored in pieces in _storeDirectGUIEdit + // because it's an array of variable dimensionality. So store the whole + // thing at once manually. + var aStr = 'dimensions[' + originalDimensionIndex + '].constraintrange'; + var preGUI = fullLayout._tracePreGUI[gd._fullData[fullIndices[i]]._fullInput.uid]; + if(preGUI[aStr] === undefined) { + var initialVal = gdDimension.constraintrange; + preGUI[aStr] = initialVal || null; + } + + var fullDimension = gd._fullData[fullIndices[i]].dimensions[originalDimensionIndex]; + if(!newConstraints.length) { delete gdDimension.constraintrange; + delete fullDimension.constraintrange; newConstraints = null; } else { if(newConstraints.length === 1) newConstraints = newConstraints[0]; gdDimension.constraintrange = newConstraints; + fullDimension.constraintrange = newConstraints.slice(); // wrap in another array for restyle event data newConstraints = [newConstraints]; } var restyleData = {}; - var aStr = 'dimensions[' + originalDimensionIndex + '].constraintrange'; restyleData[aStr] = newConstraints; - gd.emit('plotly_restyle', [restyleData, [i]]); + gd.emit('plotly_restyle', [restyleData, [inputIndices[i]]]); }; var hover = function(eventData) { @@ -103,7 +123,17 @@ module.exports = function plot(gd, cdparcoords) { gdDimensions[i].splice(gdDimensionsOriginalOrder[i].indexOf(d), 0, d); // insert at original index }); - gd.emit('plotly_restyle'); + // TODO: we can't really store this part of the interaction state + // directly as below, since it incudes data arrays. If we want to + // persist column order we may have to do something special for this + // case to just store the order itself. + // Registry.call('_storeDirectGUIEdit', + // gd.data[inputIndices[i]], + // fullLayout._tracePreGUI[gd._fullData[fullIndices[i]]._fullInput.uid], + // {dimensions: gdDimensions[i]} + // ); + + gd.emit('plotly_restyle', [{dimensions: [gdDimensions[i]]}, [inputIndices[i]]]); }; parcoords( diff --git a/src/traces/table/plot.js b/src/traces/table/plot.js index defcf758c6c..f17a926d7cd 100644 --- a/src/traces/table/plot.js +++ b/src/traces/table/plot.js @@ -591,6 +591,9 @@ function columnMoved(gd, calcdata, indices) { calcdata.columnorder = indices; + // TODO: there's no data here, but also this reordering is not reflected + // in gd.data or even gd._fullData. + // For now I will not attempt to persist this in _preGUI gd.emit('plotly_restyle'); } diff --git a/test/jasmine/assets/custom_assertions.js b/test/jasmine/assets/custom_assertions.js index 86496bb2033..b33cbe3aeea 100644 --- a/test/jasmine/assets/custom_assertions.js +++ b/test/jasmine/assets/custom_assertions.js @@ -59,9 +59,7 @@ exports.assertHoverLabelStyle = function(g, expectation, msg, textSelector) { expect(textStyle.fill).toBe(expectation.fontColor, msg + ': font.color'); }; -function assertLabelContent(label, expectation, msg) { - if(!expectation) expectation = ''; - +function getLabelContent(label) { var lines = label.selectAll('tspan.line'); var content = []; @@ -77,8 +75,15 @@ function assertLabelContent(label, expectation, msg) { } else { fill(label); } + return content.join('\n'); +} + +function assertLabelContent(label, expectation, msg) { + if(!expectation) expectation = ''; + + var content = getLabelContent(label); - expect(content.join('\n')).toBe(expectation, msg + ': text content'); + expect(content).toBe(expectation, msg + ': text content'); } function count(selector) { @@ -130,35 +135,63 @@ exports.assertHoverLabelContent = function(expectation, msg) { expect(ptCnt) .toBe(expectation.name.length, ptMsg + ' # of visible labels'); - var bboxes = []; + var observed = []; + var expected = expectation.nums.map(function(num, i) { + return { + num: num, + name: expectation.name[i], + order: (expectation.hOrder || expectation.vOrder || []).indexOf(i) + }; + }); d3.selectAll(ptSelector).each(function(_, i) { var g = d3.select(this); var numsSel = g.select('text.nums'); var nameSel = g.select('text.name'); - assertLabelContent(numsSel, expectation.nums[i], ptMsg + ' (nums ' + i + ')'); - assertLabelContent(nameSel, expectation.name[i], ptMsg + ' (name ' + i + ')'); + // Label selection can be out of order... dunno why, but on AJ's Mac, + // just for certain box and violin cases, the order looks correct but + // it's different from what we see in CI (and presumably on + // other systems) which looks wrong. + // Anyway we don't *really* care about the order within the selection, + // we just care that each label is correct. So collect all the info + // about each label, and sort both observed and expected identically. + observed.push({ + num: getLabelContent(numsSel), + name: getLabelContent(nameSel), + bbox: this.getBoundingClientRect(), + order: -1 + }); if('isRotated' in expectation) { expect(g.attr('transform').match(reRotate)) .negateIf(expectation.isRotated) .toBe(null, ptMsg + ' ' + i + ' should be rotated'); } - - bboxes.push({bbox: this.getBoundingClientRect(), index: i}); }); - if(expectation.vOrder) { - bboxes.sort(function(a, b) { - return (a.bbox.top + a.bbox.bottom - b.bbox.top - b.bbox.bottom) / 2; + if(expectation.vOrder || expectation.hOrder) { + var o2 = observed.slice(); + o2.sort(function(a, b) { + return expectation.vOrder ? + (a.bbox.top + a.bbox.bottom - b.bbox.top - b.bbox.bottom) / 2 : + (b.bbox.left + b.bbox.right - a.bbox.left - a.bbox.right) / 2; }); - expect(bboxes.map(function(d) { return d.index; })).toEqual(expectation.vOrder); - } - if(expectation.hOrder) { - bboxes.sort(function(a, b) { - return (b.bbox.left + b.bbox.right - a.bbox.left - a.bbox.right) / 2; + o2.forEach(function(item, i) { + item.order = i; + delete item.bbox; }); - expect(bboxes.map(function(d) { return d.index; })).toEqual(expectation.hOrder); } + observed.sort(labelSorter); + expected.sort(labelSorter); + // don't use .toEqual here because we want the message + expect(observed.length).toBe(expected.length, ptMsg); + observed.forEach(function(obsi, i) { + var expi = expected[i]; + expect(obsi.num).toBe(expi.num, ptMsg + ' (nums ' + i + ')'); + expect(obsi.name).toBe(expi.name, ptMsg + ' (name ' + i + ')'); + if(expectation.vOrder || expectation.hOrder) { + expect(obsi.order).toBe(expi.order, ptMsg + ' (order ' + i + ')'); + } + }); } else { if(expectation.nums) { fail(ptMsg + ': expecting *nums* labels, did not find any.'); @@ -181,6 +214,12 @@ exports.assertHoverLabelContent = function(expectation, msg) { } }; +function labelSorter(a, b) { + if(a.name !== b.name) return a.name > b.name ? 1 : -1; + if(a.num !== b.num) return a.num > b.num ? 1 : -1; + return a.order - b.order; +} + exports.assertClip = function(sel, isClipped, size, msg) { expect(sel.size()).toBe(size, msg + ' clip path (selection size)'); diff --git a/test/jasmine/assets/drag.js b/test/jasmine/assets/drag.js index 10c8320ce91..1658ca9a288 100644 --- a/test/jasmine/assets/drag.js +++ b/test/jasmine/assets/drag.js @@ -7,7 +7,7 @@ var getNodeCoords = require('./get_node_coords'); * optionally specify an edge ('n', 'se', 'w' etc) * to grab it by an edge or corner (otherwise the middle is used) */ -function drag(node, dx, dy, edge, x0, y0, nsteps) { +function drag(node, dx, dy, edge, x0, y0, nsteps, noCover) { nsteps = nsteps || 1; var coords = getNodeCoords(node, edge); @@ -17,7 +17,8 @@ function drag(node, dx, dy, edge, x0, y0, nsteps) { mouseEvent('mousemove', fromX, fromY, {element: node}); mouseEvent('mousedown', fromX, fromY, {element: node}); - var promise = waitForDragCover().then(function(dragCoverNode) { + var promise = (noCover ? Promise.resolve(node) : waitForDragCover()) + .then(function(dragCoverNode) { var toX; var toY; @@ -28,7 +29,7 @@ function drag(node, dx, dy, edge, x0, y0, nsteps) { } mouseEvent('mouseup', toX, toY, {element: dragCoverNode}); - return waitForDragCoverRemoval(); + return noCover || waitForDragCoverRemoval(); }); return promise; diff --git a/test/jasmine/tests/annotations_test.js b/test/jasmine/tests/annotations_test.js index 2b863f862c8..7ac6b6d0cbe 100644 --- a/test/jasmine/tests/annotations_test.js +++ b/test/jasmine/tests/annotations_test.js @@ -1,6 +1,7 @@ var Annotations = require('@src/components/annotations'); var Plotly = require('@lib/index'); +var Queue = require('@src/lib/queue'); var Plots = require('@src/plots/plots'); var Lib = require('@src/lib'); var Loggers = require('@src/lib/loggers'); @@ -191,11 +192,20 @@ describe('annotations relayout', function() { Plotly.plot(gd, mockData, mockLayout).then(done); spyOn(Loggers, 'warn'); + + Plotly.setPlotConfig({queueLength: 3}); }); - afterEach(destroyGraphDiv); + afterEach(function() { + destroyGraphDiv(); + Plotly.setPlotConfig({queueLength: 0}); + }); function countAnnotations() { + // also check that no annotations are empty objects + (gd.layout.annotations || []).forEach(function(ann, i) { + expect(JSON.stringify(ann)).not.toBe(JSON.stringify({}), i); + }); return d3.selectAll('g.annotation').size(); } @@ -220,11 +230,31 @@ describe('annotations relayout', function() { .then(function() { expect(countAnnotations()).toEqual(len); + return Queue.undo(gd); + }) + .then(function() { + expect(countAnnotations()).toBe(len + 1); + + return Queue.redo(gd); + }) + .then(function() { + expect(countAnnotations()).toBe(len); + return Plotly.relayout(gd, 'annotations[0]', null); }) .then(function() { expect(countAnnotations()).toEqual(len - 1); + return Queue.undo(gd); + }) + .then(function() { + expect(countAnnotations()).toBe(len); + + return Queue.redo(gd); + }) + .then(function() { + expect(countAnnotations()).toBe(len - 1); + return Plotly.relayout(gd, 'annotations[0].visible', false); }) .then(function() { diff --git a/test/jasmine/tests/axes_test.js b/test/jasmine/tests/axes_test.js index 3d502b3dfb5..39969dd2f57 100644 --- a/test/jasmine/tests/axes_test.js +++ b/test/jasmine/tests/axes_test.js @@ -3212,14 +3212,14 @@ describe('Test axes', function() { angle: [90, 90, 90] }); - return Plotly.relayout(gd, 'xaxis.range', [-0.5, 1.5]); + return Plotly.relayout(gd, 'xaxis.range', [-0.4, 1.4]); }) .then(function() { _assert('narrower range - unrotated', { angle: [0, 0] }); - return Plotly.relayout(gd, 'xaxis.tickwidth', 10); + return Plotly.relayout(gd, 'xaxis.tickwidth', 30); }) .then(function() { _assert('narrow range / wide ticks - rotated', { diff --git a/test/jasmine/tests/colorbar_test.js b/test/jasmine/tests/colorbar_test.js index 6456ea4eb1d..1a4b2239078 100644 --- a/test/jasmine/tests/colorbar_test.js +++ b/test/jasmine/tests/colorbar_test.js @@ -8,6 +8,7 @@ var destroyGraphDiv = require('../assets/destroy_graph_div'); var failTest = require('../assets/fail_test'); var supplyAllDefaults = require('../assets/supply_defaults'); var assertPlotSize = require('../assets/custom_assertions').assertPlotSize; +var drag = require('../assets/drag'); describe('Test colorbar:', function() { @@ -354,5 +355,49 @@ describe('Test colorbar:', function() { .catch(failTest) .then(done); }); + + function getCBNode() { + return document.querySelector('.colorbar'); + } + + it('can drag root-level colorbars in editable mode', function(done) { + Plotly.newPlot(gd, + [{z: [[1, 2], [3, 4]], type: 'heatmap'}], + {width: 400, height: 400}, + {editable: true} + ) + .then(function() { + expect(gd.data[0].colorbar).toBeUndefined(); + expect(gd._fullData[0].colorbar.x).toBe(1.02); + expect(gd._fullData[0].colorbar.y).toBe(0.5); + return drag(getCBNode(), -100, 100); + }) + .then(function() { + expect(gd.data[0].colorbar.x).toBeWithin(0.591, 0.01); + expect(gd.data[0].colorbar.y).toBeWithin(0.045, 0.01); + }) + .catch(failTest) + .then(done); + }); + + it('can drag marker-level colorbars in editable mode', function(done) { + Plotly.newPlot(gd, + [{y: [1, 2, 1], marker: {color: [0, 1, 2], showscale: true}}], + {width: 400, height: 400}, + {editable: true} + ) + .then(function() { + expect(gd.data[0].marker.colorbar).toBeUndefined(); + expect(gd._fullData[0].marker.colorbar.x).toBe(1.02); + expect(gd._fullData[0].marker.colorbar.y).toBe(0.5); + return drag(getCBNode(), -100, 100); + }) + .then(function() { + expect(gd.data[0].marker.colorbar.x).toBeWithin(0.591, 0.01); + expect(gd.data[0].marker.colorbar.y).toBeWithin(0.045, 0.01); + }) + .catch(failTest) + .then(done); + }); }); }); diff --git a/test/jasmine/tests/lib_test.js b/test/jasmine/tests/lib_test.js index 65c28b9fe1d..79b02b3efa0 100644 --- a/test/jasmine/tests/lib_test.js +++ b/test/jasmine/tests/lib_test.js @@ -2696,9 +2696,8 @@ 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]': {} }); + .toEqual({ 'updatemenus[0]': { buttons: [] } }); expect(gd.undoQueue.queue[1].redo.args[0][1]) .toEqual({ 'updatemenus[0]': null }); diff --git a/test/jasmine/tests/plot_api_react_test.js b/test/jasmine/tests/plot_api_react_test.js new file mode 100644 index 00000000000..812eb6f63e5 --- /dev/null +++ b/test/jasmine/tests/plot_api_react_test.js @@ -0,0 +1,1763 @@ +var Plotly = require('@lib/index'); +var plotApi = require('@src/plot_api/plot_api'); +var Lib = require('@src/lib'); +var Axes = require('@src/plots/cartesian/axes'); +var subroutines = require('@src/plot_api/subroutines'); +var annotations = require('@src/components/annotations'); +var images = require('@src/components/images'); +var Registry = require('@src/registry'); + +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 supplyAllDefaults = require('../assets/supply_defaults'); +var mockLists = require('../assets/mock_lists'); +var drag = require('../assets/drag'); + +var MAPBOX_ACCESS_TOKEN = require('@build/credentials.json').MAPBOX_ACCESS_TOKEN; + +describe('@noCIdep Plotly.react', function() { + var mockedMethods = [ + 'doTraceStyle', + 'doColorBars', + 'doLegend', + 'layoutStyles', + 'doTicksRelayout', + 'doModeBar', + 'doCamera' + ]; + + var gd; + var afterPlotCnt; + + beforeEach(function() { + gd = createGraphDiv(); + + spyOn(plotApi, 'plot').and.callThrough(); + spyOn(Registry, 'call').and.callThrough(); + + mockedMethods.forEach(function(m) { + spyOn(subroutines, m).and.callThrough(); + subroutines[m].calls.reset(); + }); + + spyOn(annotations, 'drawOne').and.callThrough(); + spyOn(annotations, 'draw').and.callThrough(); + spyOn(images, 'draw').and.callThrough(); + spyOn(Axes, 'draw').and.callThrough(); + }); + + afterEach(destroyGraphDiv); + + function countPlots() { + plotApi.plot.calls.reset(); + subroutines.layoutStyles.calls.reset(); + annotations.draw.calls.reset(); + annotations.drawOne.calls.reset(); + images.draw.calls.reset(); + + afterPlotCnt = 0; + gd.on('plotly_afterplot', function() { afterPlotCnt++; }); + } + + function countCalls(counts) { + var callsFinal = Lib.extendFlat({}, counts); + callsFinal.layoutStyles = (counts.layoutStyles || 0) + (counts.plot || 0); + + mockedMethods.forEach(function(m) { + expect(subroutines[m]).toHaveBeenCalledTimes(callsFinal[m] || 0); + subroutines[m].calls.reset(); + }); + + // calls to Plotly.plot via plot_api.js or Registry.call('plot') + var plotCalls = plotApi.plot.calls.count() + + Registry.call.calls.all() + .filter(function(d) { return d.args[0] === 'plot'; }) + .length; + expect(plotCalls).toBe(counts.plot || 0, 'Plotly.plot calls'); + plotApi.plot.calls.reset(); + Registry.call.calls.reset(); + + // only consider annotation and image draw calls if we *don't* do a full plot. + if(!counts.plot) { + expect(annotations.draw).toHaveBeenCalledTimes(counts.annotationDraw || 0); + expect(annotations.drawOne).toHaveBeenCalledTimes(counts.annotationDrawOne || 0); + expect(images.draw).toHaveBeenCalledTimes(counts.imageDraw || 0); + } + annotations.draw.calls.reset(); + annotations.drawOne.calls.reset(); + images.draw.calls.reset(); + + expect(afterPlotCnt).toBe(1, 'plotly_afterplot should be called only once per edit'); + afterPlotCnt = 0; + } + + it('can add / remove traces', function(done) { + var data1 = [{y: [1, 2, 3], mode: 'markers'}]; + var data2 = [data1[0], {y: [2, 3, 1], mode: 'markers'}]; + var layout = {}; + Plotly.newPlot(gd, data1, layout) + .then(countPlots) + .then(function() { + expect(d3.selectAll('.point').size()).toBe(3); + + return Plotly.react(gd, data2, layout); + }) + .then(function() { + expect(d3.selectAll('.point').size()).toBe(6); + + return Plotly.react(gd, data1, layout); + }) + .then(function() { + expect(d3.selectAll('.point').size()).toBe(3); + }) + .catch(failTest) + .then(done); + }); + + it('should notice new data by ===, without layout.datarevision', function(done) { + var data = [{y: [1, 2, 3], mode: 'markers'}]; + var layout = {}; + + Plotly.newPlot(gd, data, layout) + .then(countPlots) + .then(function() { + expect(d3.selectAll('.point').size()).toBe(3); + + data[0].y.push(4); + return Plotly.react(gd, data, layout); + }) + .then(function() { + // didn't pick it up, as we modified in place!!! + expect(d3.selectAll('.point').size()).toBe(3); + countCalls({plot: 0}); + + data[0].y = [1, 2, 3, 4, 5]; + return Plotly.react(gd, data, layout); + }) + .then(function() { + // new object, we picked it up! + expect(d3.selectAll('.point').size()).toBe(5); + countCalls({plot: 1}); + }) + .catch(failTest) + .then(done); + }); + + it('should notice new layout.datarevision', function(done) { + var data = [{y: [1, 2, 3], mode: 'markers'}]; + var layout = {datarevision: 1}; + + Plotly.newPlot(gd, data, layout) + .then(countPlots) + .then(function() { + expect(d3.selectAll('.point').size()).toBe(3); + + data[0].y.push(4); + return Plotly.react(gd, data, layout); + }) + .then(function() { + // didn't pick it up, as we didn't modify datarevision + expect(d3.selectAll('.point').size()).toBe(3); + countCalls({plot: 0}); + + data[0].y.push(5); + layout.datarevision = 'bananas'; + return Plotly.react(gd, data, layout); + }) + .then(function() { + // new revision, we picked it up! + expect(d3.selectAll('.point').size()).toBe(5); + + countCalls({plot: 1}); + }) + .catch(failTest) + .then(done); + }); + + it('picks up partial redraws', function(done) { + var data = [{y: [1, 2, 3], mode: 'markers'}]; + var layout = {}; + + Plotly.newPlot(gd, data, layout) + .then(countPlots) + .then(function() { + layout.title = 'XXXXX'; + layout.hovermode = 'closest'; + data[0].marker = {color: 'rgb(0, 100, 200)'}; + return Plotly.react(gd, data, layout); + }) + .then(function() { + countCalls({layoutStyles: 1, doTraceStyle: 1, doModeBar: 1}); + expect(d3.select('.gtitle').text()).toBe('XXXXX'); + var points = d3.selectAll('.point'); + expect(points.size()).toBe(3); + points.each(function() { + expect(window.getComputedStyle(this).fill).toBe('rgb(0, 100, 200)'); + }); + + layout.showlegend = true; + layout.xaxis.tick0 = 0.1; + layout.xaxis.dtick = 0.3; + return Plotly.react(gd, data, layout); + }) + .then(function() { + // legend and ticks get called initially, but then plot gets added during automargin + countCalls({doLegend: 1, doTicksRelayout: 1, plot: 1}); + + data = [{z: [[1, 2], [3, 4]], type: 'surface'}]; + layout = {}; + + return Plotly.react(gd, data, layout); + }) + .then(function() { + // we get an extra call to layoutStyles from marginPushersAgain due to the colorbar. + // Really need to simplify that pipeline... + countCalls({plot: 1, layoutStyles: 1}); + + layout.scene.camera = {up: {x: 1, y: -1, z: 0}}; + + return Plotly.react(gd, data, layout); + }) + .then(function() { + countCalls({doCamera: 1}); + + data[0].type = 'heatmap'; + delete layout.scene; + return Plotly.react(gd, data, layout); + }) + .then(function() { + countCalls({plot: 1}); + + // ideally we'd just do this with `surface` but colorbar attrs have editType 'calc' there + // TODO: can we drop them to type: 'colorbars' even for the 3D types? + data[0].colorbar = {len: 0.6}; + return Plotly.react(gd, data, layout); + }) + .then(function() { + countCalls({doColorBars: 1, plot: 1}); + }) + .catch(failTest) + .then(done); + }); + + it('picks up special dtick geo case', function(done) { + var data = [{type: 'scattergeo'}]; + var layout = {}; + + function countLines() { + var path = d3.select(gd).select('.lataxis > path'); + return path.attr('d').split('M').length; + } + + Plotly.react(gd, data) + .then(countPlots) + .then(function() { + layout.geo = {lataxis: {showgrid: true, dtick: 10}}; + return Plotly.react(gd, data, layout); + }) + .then(function() { + countCalls({plot: 1}); + expect(countLines()).toBe(18); + }) + .then(function() { + layout.geo.lataxis.dtick = 30; + return Plotly.react(gd, data, layout); + }) + .then(function() { + countCalls({plot: 1}); + expect(countLines()).toBe(6); + }) + .catch(failTest) + .then(done); + }); + + it('picks up minimal sequence for cartesian axis range updates', function(done) { + var data = [{y: [1, 2, 1]}]; + var layout = {xaxis: {range: [1, 2]}}; + var layout2 = {xaxis: {range: [0, 1]}}; + + Plotly.newPlot(gd, data, layout) + .then(countPlots) + .then(function() { + expect(Axes.draw).toHaveBeenCalledWith(gd, ''); + return Plotly.react(gd, data, layout2); + }) + .then(function() { + expect(Axes.draw).toHaveBeenCalledWith(gd, 'redraw'); + expect(subroutines.layoutStyles).not.toHaveBeenCalled(); + }) + .catch(failTest) + .then(done); + }); + + it('redraws annotations one at a time', function(done) { + var data = [{y: [1, 2, 3], mode: 'markers'}]; + var layout = {}; + var ymax; + + Plotly.newPlot(gd, data, layout) + .then(countPlots) + .then(function() { + ymax = layout.yaxis.range[1]; + + layout.annotations = [{ + x: 1, + y: 4, + text: 'Way up high', + showarrow: false + }, { + x: 1, + y: 2, + text: 'On the data', + showarrow: false + }]; + return Plotly.react(gd, data, layout); + }) + .then(function() { + // autoranged - so we get a full replot + countCalls({plot: 1}); + expect(d3.selectAll('.annotation').size()).toBe(2); + + layout.annotations[1].bgcolor = 'rgb(200, 100, 0)'; + return Plotly.react(gd, data, layout); + }) + .then(function() { + countCalls({annotationDrawOne: 1}); + expect(window.getComputedStyle(d3.select('.annotation[data-index="1"] .bg').node()).fill) + .toBe('rgb(200, 100, 0)'); + expect(layout.yaxis.range[1]).not.toBeCloseTo(ymax, 0); + + layout.annotations[0].font = {color: 'rgb(0, 255, 0)'}; + layout.annotations[1].bgcolor = 'rgb(0, 0, 255)'; + return Plotly.react(gd, data, layout); + }) + .then(function() { + countCalls({annotationDrawOne: 2}); + expect(window.getComputedStyle(d3.select('.annotation[data-index="0"] text').node()).fill) + .toBe('rgb(0, 255, 0)'); + expect(window.getComputedStyle(d3.select('.annotation[data-index="1"] .bg').node()).fill) + .toBe('rgb(0, 0, 255)'); + + Lib.extendFlat(layout.annotations[0], {yref: 'paper', y: 0.8}); + + return Plotly.react(gd, data, layout); + }) + .then(function() { + countCalls({plot: 1}); + expect(layout.yaxis.range[1]).toBeCloseTo(ymax, 0); + }) + .catch(failTest) + .then(done); + }); + + it('redraws images all at once', function(done) { + var data = [{y: [1, 2, 3], mode: 'markers'}]; + var layout = {}; + var jsLogo = 'https://images.plot.ly/language-icons/api-home/js-logo.png'; + + var x, y, height, width; + + Plotly.newPlot(gd, data, layout) + .then(countPlots) + .then(function() { + layout.images = [{ + source: jsLogo, + xref: 'paper', + yref: 'paper', + x: 0.1, + y: 0.1, + sizex: 0.2, + sizey: 0.2 + }, { + source: jsLogo, + xref: 'x', + yref: 'y', + x: 1, + y: 2, + sizex: 1, + sizey: 1 + }]; + return Plotly.react(gd, data, layout); + }) + .then(function() { + countCalls({imageDraw: 1}); + expect(d3.selectAll('image').size()).toBe(2); + + var n = d3.selectAll('image').node(); + x = n.attributes.x.value; + y = n.attributes.y.value; + height = n.attributes.height.value; + width = n.attributes.width.value; + + layout.images[0].y = 0.8; + layout.images[0].sizey = 0.4; + return Plotly.react(gd, data, layout); + }) + .then(function() { + countCalls({imageDraw: 1}); + var n = d3.selectAll('image').node(); + expect(n.attributes.x.value).toBe(x); + expect(n.attributes.width.value).toBe(width); + expect(n.attributes.y.value).not.toBe(y); + expect(n.attributes.height.value).not.toBe(height); + }) + .catch(failTest) + .then(done); + }); + + it('can change config, and always redraws', function(done) { + var data = [{y: [1, 2, 3]}]; + var layout = {}; + + Plotly.newPlot(gd, data, layout) + .then(countPlots) + .then(function() { + expect(d3.selectAll('.drag').size()).toBe(11); + expect(d3.selectAll('.gtitle').size()).toBe(0); + + return Plotly.react(gd, data, layout, {editable: true}); + }) + .then(function() { + expect(d3.selectAll('.drag').size()).toBe(11); + expect(d3.selectAll('.gtitle').text()).toBe('Click to enter Plot title'); + countCalls({plot: 1}); + + return Plotly.react(gd, data, layout, {staticPlot: true}); + }) + .then(function() { + expect(d3.selectAll('.drag').size()).toBe(0); + expect(d3.selectAll('.gtitle').size()).toBe(0); + countCalls({plot: 1}); + + return Plotly.react(gd, data, layout, {}); + }) + .then(function() { + expect(d3.selectAll('.drag').size()).toBe(11); + expect(d3.selectAll('.gtitle').size()).toBe(0); + countCalls({plot: 1}); + }) + .catch(failTest) + .then(done); + }); + + it('can put polar plots into staticPlot mode', function(done) { + // tested separately since some of the relevant code is actually + // in cartesian/graph_interact... hopefully we'll fix that + // sometime and the test will still pass. + var data = [{r: [1, 2, 3], theta: [0, 120, 240], type: 'scatterpolar'}]; + var layout = {}; + + Plotly.newPlot(gd, data, layout) + .then(countPlots) + .then(function() { + expect(d3.select(gd).selectAll('.drag').size()).toBe(4); + + return Plotly.react(gd, data, layout, {staticPlot: true}); + }) + .then(function() { + expect(d3.select(gd).selectAll('.drag').size()).toBe(0); + + return Plotly.react(gd, data, layout, {}); + }) + .then(function() { + expect(d3.select(gd).selectAll('.drag').size()).toBe(4); + }) + .catch(failTest) + .then(done); + }); + + it('can change data in candlesticks multiple times', function(done) { + // test that we've fixed the original issue in + // https://github.com/plotly/plotly.js/issues/2510 + + function assertCalc(open, high, low, close) { + expect(gd.calcdata[0][0]).toEqual(jasmine.objectContaining({ + min: low, + max: high, + med: close, + q1: Math.min(open, close), + q3: Math.max(open, close), + dir: close >= open ? 'increasing' : 'decreasing' + })); + } + var trace = { + type: 'candlestick', + low: [1], + open: [2], + close: [3], + high: [4] + }; + Plotly.newPlot(gd, [trace]) + .then(function() { + assertCalc(2, 4, 1, 3); + + trace.low = [0]; + return Plotly.react(gd, [trace]); + }) + .then(function() { + assertCalc(2, 4, 0, 3); + + trace.low = [-1]; + return Plotly.react(gd, [trace]); + }) + .then(function() { + assertCalc(2, 4, -1, 3); + + trace.close = [1]; + return Plotly.react(gd, [trace]); + }) + .then(function() { + assertCalc(2, 4, -1, 1); + }) + .catch(failTest) + .then(done); + }); + + function aggregatedPie(i) { + var labels = i <= 1 ? + ['A', 'B', 'A', 'C', 'A', 'B', 'C', 'A', 'B', 'C', 'A'] : + ['X', 'Y', 'Z', 'Z', 'Y', 'Z', 'X', 'Z', 'Y', 'Z', 'X']; + var trace = { + type: 'pie', + values: [4, 1, 4, 4, 1, 4, 4, 2, 1, 1, 15], + labels: labels, + transforms: [{ + type: 'aggregate', + groups: labels, + aggregations: [{target: 'values', func: 'sum'}] + }] + }; + return { + data: [trace], + layout: { + datarevision: i, + colorway: ['red', 'orange', 'yellow', 'green', 'blue', 'violet'] + } + }; + } + + var aggPie1CD = [[ + {v: 26, label: 'A', color: 'red', i: 0}, + {v: 9, label: 'C', color: 'orange', i: 2}, + {v: 6, label: 'B', color: 'yellow', i: 1} + ]]; + + var aggPie2CD = [[ + {v: 23, label: 'X', color: 'red', i: 0}, + {v: 15, label: 'Z', color: 'orange', i: 2}, + {v: 3, label: 'Y', color: 'yellow', i: 1} + ]]; + + function aggregatedScatter(i) { + return { + data: [{ + x: [1, 2, 3, 4, 6, 5], + y: [2, 1, 3, 5, 6, 4], + transforms: [{ + type: 'aggregate', + groups: [1, -1, 1, -1, 1, -1], + aggregations: i > 1 ? [{func: 'last', target: 'x'}] : [] + }] + }], + layout: {daterevision: i + 10} + }; + } + + var aggScatter1CD = [[ + {x: 1, y: 2, i: 0}, + {x: 2, y: 1, i: 1} + ]]; + + var aggScatter2CD = [[ + {x: 6, y: 2, i: 0}, + {x: 5, y: 1, i: 1} + ]]; + + function aggregatedParcoords(i) { + return { + data: [{ + type: 'parcoords', + dimensions: [ + {label: 'A', values: [1, 2, 3, 4]}, + {label: 'B', values: [4, 3, 2, 1]} + ], + transforms: i ? [{ + type: 'aggregate', + groups: [1, 2, 1, 2], + aggregations: [ + {target: 'dimensions[0].values', func: i > 1 ? 'avg' : 'first'}, + {target: 'dimensions[1].values', func: i > 1 ? 'first' : 'avg'} + ] + }] : + [] + }] + }; + } + + var aggParcoords0Vals = [[1, 2, 3, 4], [4, 3, 2, 1]]; + var aggParcoords1Vals = [[1, 2], [3, 2]]; + var aggParcoords2Vals = [[2, 3], [4, 3]]; + + function checkCalcData(expectedCD) { + return function() { + expect(gd.calcdata.length).toBe(expectedCD.length); + expectedCD.forEach(function(expectedCDi, i) { + var cdi = gd.calcdata[i]; + expect(cdi.length).toBe(expectedCDi.length, i); + expectedCDi.forEach(function(expectedij, j) { + expect(cdi[j]).toEqual(jasmine.objectContaining(expectedij)); + }); + }); + }; + } + + function checkValues(expectedVals) { + return function() { + expect(gd._fullData.length).toBe(1); + var dims = gd._fullData[0].dimensions; + expect(dims.length).toBe(expectedVals.length); + expectedVals.forEach(function(expected, i) { + expect(dims[i].values).toEqual(expected); + }); + }; + } + + function reactTo(fig) { + return function() { return Plotly.react(gd, fig); }; + } + + it('can change pie aggregations', function(done) { + Plotly.newPlot(gd, aggregatedPie(1)) + .then(checkCalcData(aggPie1CD)) + + .then(reactTo(aggregatedPie(2))) + .then(checkCalcData(aggPie2CD)) + + .then(reactTo(aggregatedPie(1))) + .then(checkCalcData(aggPie1CD)) + .catch(failTest) + .then(done); + }); + + it('can change scatter aggregations', function(done) { + Plotly.newPlot(gd, aggregatedScatter(1)) + .then(checkCalcData(aggScatter1CD)) + + .then(reactTo(aggregatedScatter(2))) + .then(checkCalcData(aggScatter2CD)) + + .then(reactTo(aggregatedScatter(1))) + .then(checkCalcData(aggScatter1CD)) + .catch(failTest) + .then(done); + }); + + it('can change parcoords aggregations', function(done) { + Plotly.newPlot(gd, aggregatedParcoords(0)) + .then(checkValues(aggParcoords0Vals)) + + .then(reactTo(aggregatedParcoords(1))) + .then(checkValues(aggParcoords1Vals)) + + .then(reactTo(aggregatedParcoords(2))) + .then(checkValues(aggParcoords2Vals)) + + .then(reactTo(aggregatedParcoords(0))) + .then(checkValues(aggParcoords0Vals)) + + .catch(failTest) + .then(done); + }); + + it('can change type with aggregations', function(done) { + Plotly.newPlot(gd, aggregatedScatter(1)) + .then(checkCalcData(aggScatter1CD)) + + .then(reactTo(aggregatedPie(1))) + .then(checkCalcData(aggPie1CD)) + + .then(reactTo(aggregatedParcoords(1))) + .then(checkValues(aggParcoords1Vals)) + + .then(reactTo(aggregatedScatter(1))) + .then(checkCalcData(aggScatter1CD)) + + .then(reactTo(aggregatedParcoords(2))) + .then(checkValues(aggParcoords2Vals)) + + .then(reactTo(aggregatedPie(2))) + .then(checkCalcData(aggPie2CD)) + + .then(reactTo(aggregatedScatter(2))) + .then(checkCalcData(aggScatter2CD)) + + .then(reactTo(aggregatedParcoords(0))) + .then(checkValues(aggParcoords0Vals)) + .catch(failTest) + .then(done); + }); + + it('can change frames without redrawing', function(done) { + var data = [{y: [1, 2, 3]}]; + var layout = {}; + var frames = [{name: 'frame1'}]; + + Plotly.newPlot(gd, {data: data, layout: layout, frames: frames}) + .then(countPlots) + .then(function() { + var frameData = gd._transitionData._frames; + expect(frameData.length).toBe(1); + expect(frameData[0].name).toBe('frame1'); + + frames[0].name = 'frame2'; + return Plotly.react(gd, {data: data, layout: layout, frames: frames}); + }) + .then(function() { + countCalls({}); + var frameData = gd._transitionData._frames; + expect(frameData.length).toBe(1); + expect(frameData[0].name).toBe('frame2'); + }) + .catch(failTest) + .then(done); + }); + + // make sure we've included every trace type in this suite + var typesTested = {}; + var itemType; + for(itemType in Registry.modules) { typesTested[itemType] = 0; } + for(itemType in Registry.transformsRegistry) { typesTested[itemType] = 0; } + + // Not really being supported... This isn't part of the main bundle, and it's pretty broken, + // but it gets registered and used by a couple of the gl2d tests. + delete typesTested.contourgl; + + function _runReactMock(mockSpec, done) { + var mock = mockSpec[1]; + var initialJson; + + function fullJson() { + var out = JSON.parse(Plotly.Plots.graphJson({ + data: gd._fullData.map(function(trace) { return trace._fullInput; }), + layout: gd._fullLayout + })); + + // TODO: does it matter that ax.tick0/dtick/range and zmin/zmax + // are often not regenerated without a calc step? + // in as far as editor and others rely on _full, I think the + // answer must be yes, but I'm not sure about within plotly.js + [ + 'xaxis', 'xaxis2', 'xaxis3', 'xaxis4', 'xaxis5', + 'yaxis', 'yaxis2', 'yaxis3', 'yaxis4', + 'zaxis' + ].forEach(function(axName) { + var ax = out.layout[axName]; + if(ax) { + delete ax.dtick; + delete ax.tick0; + + // TODO this one I don't understand and can't reproduce + // in the dashboard but it's needed here? + delete ax.range; + } + if(out.layout.scene) { + ax = out.layout.scene[axName]; + if(ax) { + delete ax.dtick; + delete ax.tick0; + // TODO: this is the only one now that uses '_input_' + key + // as a hack to tell Plotly.react to ignore changes. + // Can we kill this? + delete ax.range; + } + } + }); + out.data.forEach(function(trace) { + if(trace.type === 'contourcarpet') { + delete trace.zmin; + delete trace.zmax; + } + }); + + return out; + } + + // Make sure we define `_length` in every trace *in supplyDefaults*. + // This is only relevant for traces that *have* a 1D concept of length, + // and in addition to simplifying calc/plot logic later on, ths serves + // as a signal to transforms about how they should operate. For traces + // that do NOT have a 1D length, `_length` should be `null`. + var mockGD = Lib.extendDeep({}, mock); + supplyAllDefaults(mockGD); + expect(mockGD._fullData.length).not.toBeLessThan((mock.data || []).length, mockSpec[0]); + mockGD._fullData.forEach(function(trace, i) { + var len = trace._length; + if(trace.visible !== false && len !== null) { + expect(typeof len).toBe('number', mockSpec[0] + ' trace ' + i + ': type=' + trace.type); + } + + typesTested[trace.type]++; + + if(trace.transforms) { + trace.transforms.forEach(function(transform) { + typesTested[transform.type]++; + }); + } + }); + + Plotly.newPlot(gd, mock) + .then(countPlots) + .then(function() { + initialJson = fullJson(); + + return Plotly.react(gd, mock); + }) + .then(function() { + expect(fullJson()).toEqual(initialJson); + countCalls({}); + }) + .catch(failTest) + .then(done); + } + + mockLists.svg.forEach(function(mockSpec) { + it('can redraw "' + mockSpec[0] + '" with no changes as a noop (svg mocks)', function(done) { + _runReactMock(mockSpec, done); + }); + }); + + mockLists.gl.forEach(function(mockSpec) { + it('can redraw "' + mockSpec[0] + '" with no changes as a noop (gl mocks)', function(done) { + _runReactMock(mockSpec, done); + }); + }); + + mockLists.mapbox.forEach(function(mockSpec) { + it('@noCI can redraw "' + mockSpec[0] + '" with no changes as a noop (mapbpox mocks)', function(done) { + Plotly.setPlotConfig({ + mapboxAccessToken: MAPBOX_ACCESS_TOKEN + }); + _runReactMock(mockSpec, done); + }); + }); + + // since CI breaks up gl/svg types, and drops scattermapbox, this test won't work there + // but I should hope that if someone is doing something as major as adding a new type, + // they'll run the full test suite locally! + it('@noCI tested every trace & transform type at least once', function() { + for(var itemType in typesTested) { + expect(typesTested[itemType]).toBeGreaterThan(0, itemType + ' was not tested'); + } + }); +}); + +describe('resizing with Plotly.relayout and Plotly.react', function() { + var gd; + + beforeEach(function() { + gd = createGraphDiv(); + }); + + afterEach(destroyGraphDiv); + + it('recalculates autoranges when height/width change', function(done) { + Plotly.newPlot(gd, + [{y: [1, 2], marker: {size: 100}}], + {width: 400, height: 400, margin: {l: 100, r: 100, t: 100, b: 100}} + ) + .then(function() { + expect(gd.layout.xaxis.range).toBeCloseToArray([-1.31818, 2.31818], 3); + expect(gd.layout.yaxis.range).toBeCloseToArray([-0.31818, 3.31818], 3); + + return Plotly.relayout(gd, {height: 800, width: 800}); + }) + .then(function() { + expect(gd.layout.xaxis.range).toBeCloseToArray([-0.22289, 1.22289], 3); + expect(gd.layout.yaxis.range).toBeCloseToArray([0.77711, 2.22289], 3); + + gd.layout.width = 500; + gd.layout.height = 500; + return Plotly.react(gd, gd.data, gd.layout); + }) + .then(function() { + expect(gd.layout.xaxis.range).toBeCloseToArray([-0.53448, 1.53448], 3); + expect(gd.layout.yaxis.range).toBeCloseToArray([0.46552, 2.53448], 3); + }) + .catch(failTest) + .then(done); + }); +}); + + +describe('Plotly.react and uirevision attributes', function() { + var gd; + + beforeEach(function() { + gd = createGraphDiv(); + }); + + afterEach(destroyGraphDiv); + + function checkCloseIfArray(val1, val2, msg) { + if(Array.isArray(val1) && Array.isArray(val2)) { + if(Array.isArray(val1[0]) && Array.isArray(val2[0])) { + expect(val1).toBeCloseTo2DArray(val2, 2, msg); + } + else { + expect(val1).toBeCloseToArray(val2, 2, msg); + } + } + else { + expect(val1).toBe(val2, msg); + } + } + + function checkState(dataKeys, layoutKeys, msg) { + var np = Lib.nestedProperty; + return function() { + dataKeys.forEach(function(traceKeys, i) { + var trace = gd.data[i]; + var fullTrace = gd._fullData.filter(function(ft) { + return ft._fullInput.index === i; + })[0]._fullInput; + + for(var key in traceKeys) { + var val = traceKeys[key]; + var valIn = Array.isArray(val) ? val[0] : val; + var valOut = Array.isArray(val) ? val[val.length - 1] : val; + checkCloseIfArray(np(trace, key).get(), valIn, msg + ': data[' + i + '].' + key); + checkCloseIfArray(np(fullTrace, key).get(), valOut, msg + ': _fullData[' + i + '].' + key); + checkCloseIfArray(np(trace, key).get(), valIn, msg + ': data[' + i + '].' + key); + checkCloseIfArray(np(fullTrace, key).get(), valOut, msg + ': _fullData[' + i + '].' + key); + } + }); + + for(var key in (layoutKeys || {})) { + var val = layoutKeys[key]; + var valIn = Array.isArray(val) ? val[0] : val; + var valOut = Array.isArray(val) ? val[val.length - 1] : val; + checkCloseIfArray(np(gd.layout, key).get(), valIn, msg + ': layout.' + key); + checkCloseIfArray(np(gd._fullLayout, key).get(), valOut, msg + ': _fullLayout.' + key); + } + }; + } + + function _react(fig) { + return function() { + return Plotly.react(gd, fig); + }; + } + + it('preserves zoom and trace visibility state until uirevision changes', function(done) { + var checkNoEdits = checkState([{ + }, { + visible: [undefined, true] + }], { + 'xaxis.autorange': true, + 'yaxis.autorange': true + }, 'initial'); + + var checkHasEdits = checkState([{ + }, { + visible: 'legendonly' + }], { + 'xaxis.range[0]': 0, + 'xaxis.range[1]': 1, + 'xaxis.autorange': false, + 'yaxis.range[0]': 1, + 'yaxis.range[1]': 2, + 'yaxis.autorange': false + }, 'with GUI edits'); + + var i = 0; + function fig(rev) { + i++; + return { + data: [{y: [1, 3, i]}, {y: [2, 1, i + 1]}], + layout: {uirevision: rev} + }; + } + + function setEdits() { + return Registry.call('_guiRelayout', gd, { + 'xaxis.range': [0, 1], + 'yaxis.range': [1, 2] + }) + .then(function() { + return Registry.call('_guiRestyle', gd, 'visible', 'legendonly', [1]); + }); + } + + Plotly.newPlot(gd, fig('something')) + .then(checkNoEdits) + .then(setEdits) + .then(checkHasEdits) + .then(_react(fig('something'))) + .then(checkHasEdits) + .then(_react(fig('something else!'))) + .then(checkNoEdits) + .then(_react(fig('something'))) + // back to the first uirevision, but the changes are gone forever + .then(checkNoEdits) + // falsy uirevision - does not preserve edits + .then(_react(fig(false))) + .then(checkNoEdits) + .then(setEdits) + .then(checkHasEdits) + .then(_react(fig(false))) + .then(checkNoEdits) + .catch(failTest) + .then(done); + }); + + it('moves trace visibility with uid', function(done) { + Plotly.newPlot(gd, + [{y: [1, 3, 1], uid: 'a'}, {y: [2, 1, 2], uid: 'b'}], + {uirevision: 'something'} + ) + .then(function() { + return Registry.call('_guiRestyle', gd, 'visible', 'legendonly', [1]); + }) + // we hid the second trace, with uid b + .then(checkState([{visible: [undefined, true]}, {visible: 'legendonly'}])) + .then(_react({ + data: [{y: [1, 3, 1], uid: 'b'}, {y: [2, 1, 2], uid: 'a'}], + layout: {uirevision: 'something'} + })) + // now the first trace is hidden, because it has uid b now! + .then(checkState([{visible: 'legendonly'}, {visible: [undefined, true]}])) + .catch(failTest) + .then(done); + }); + + it('controls axis edits with axis.uirevision', function(done) { + function fig(mainRev, xRev, yRev, x2Rev, y2Rev) { + return { + data: [{y: [1, 2, 1]}, {y: [3, 4, 3], xaxis: 'x2', yaxis: 'y2'}], + layout: { + uirevision: mainRev, + grid: {columns: 2, pattern: 'independent'}, + xaxis: {uirevision: xRev}, + yaxis: {uirevision: yRev}, + xaxis2: {uirevision: x2Rev}, + yaxis2: {uirevision: y2Rev} + } + }; + } + + function checkAutoRange(x, y, x2, y2, msg) { + return checkState([], { + 'xaxis.autorange': x, + 'yaxis.autorange': y, + 'xaxis2.autorange': x2, + 'yaxis2.autorange': y2 + }, msg); + } + + function setExplicitRanges() { + return Registry.call('_guiRelayout', gd, { + 'xaxis.range': [1, 2], + 'yaxis.range': [2, 3], + 'xaxis2.range': [3, 4], + 'yaxis2.range': [4, 5] + }); + } + + Plotly.newPlot(gd, fig('a', 'x1a', 'y1a', 'x2a', 'y2a')) + .then(checkAutoRange(true, true, true, true)) + .then(setExplicitRanges) + .then(checkAutoRange(false, false, false, false)) + // change main rev (no effect) and y1 and x2 + .then(_react(fig('b', 'x1a', 'y1b', 'x2b', 'y2a'))) + .then(checkAutoRange(false, true, true, false)) + // now reset with falsy revisions for x2 & y2 but undefined for x1 & y1 + // to show that falsy says "never persist changes here" but undefined + // will be inherited + .then(_react(fig('a', undefined, undefined, false, ''))) + .then(checkAutoRange(true, true, true, true)) + .then(setExplicitRanges) + .then(checkAutoRange(false, false, false, false)) + .then(_react(fig('a', undefined, undefined, false, ''))) + .then(checkAutoRange(false, false, true, true)) + .then(_react(fig('b', undefined, undefined, false, ''))) + .then(checkAutoRange(true, true, true, true)) + .catch(failTest) + .then(done); + }); + + it('respects reverting an explicit cartesian axis range to auto', function(done) { + function fig(xRange, yRange) { + return { + data: [{z: [[1, 2], [3, 4]], type: 'heatmap', x: [0, 1, 2], y: [3, 4, 5]}], + layout: { + xaxis: {range: xRange}, + yaxis: {range: yRange}, + uirevision: 'a' + } + }; + } + + function setRanges(xRange, yRange) { + return function() { + return Registry.call('_guiRelayout', gd, { + 'xaxis.range': xRange, + 'yaxis.range': yRange + }); + }; + } + + function checkRanges(xRange, yRange) { + return checkState([], { + 'xaxis.range': [xRange], + 'yaxis.range': [yRange] + }); + } + + Plotly.newPlot(gd, fig([1, 3], [4, 6])) + .then(checkRanges([1, 3], [4, 6])) + .then(setRanges([2, 4], [5, 7])) + .then(checkRanges([2, 4], [5, 7])) + .then(_react(fig(undefined, undefined))) + .then(checkRanges([0, 2], [3, 5])) + .catch(failTest) + .then(done); + }); + + it('respects reverting an explicit polar axis range to auto', function(done) { + function fig(range) { + return { + data: [{type: 'barpolar', r: [1, 1], theta: [0, 90]}], + layout: { + polar: {radialaxis: {range: range}}, + uirevision: 'a' + } + }; + } + + function setRange(range) { + return function() { + return Registry.call('_guiRelayout', gd, { + 'polar.radialaxis.range': range + }); + }; + } + + function checkRange(range) { + return checkState([], {'polar.radialaxis.range': [range]}); + } + + Plotly.newPlot(gd, fig([1, 3])) + .then(checkRange([1, 3])) + .then(setRange([2, 4])) + .then(checkRange([2, 4])) + .then(_react(fig(undefined))) + .then(checkRange([0, 1.05263])) + .catch(failTest) + .then(done); + }); + + function _run(figFn, editFn, checkInitial, checkEdited) { + // figFn should take 2 args (main uirevision and partial uirevision) + // and return a figure {data, layout} + // editFn, checkInitial, checkEdited are functions of no args + return Plotly.newPlot(gd, figFn('main a', 'part a')) + .then(checkInitial) + .then(editFn) + .then(checkEdited) + .then(_react(figFn('main b', 'part a'))) + .then(checkEdited) + .then(_react(figFn('main b', 'part b'))) + .then(checkInitial) + .catch(failTest); + } + + it('controls trace and pie label visibility from legend.uirevision', function(done) { + function fig(mainRev, legendRev) { + return { + data: [ + {y: [1, 2]}, + {y: [2, 1]}, + {type: 'pie', labels: ['a', 'b', 'c'], values: [1, 2, 3]} + ], + layout: { + uirevision: mainRev, + legend: {uirevision: legendRev} + } + }; + } + + function hideSome() { + return Registry.call('_guiUpdate', gd, + {visible: 'legendonly'}, + {hiddenlabels: ['b', 'c']}, + [0] + ); + } + + function checkVisible(traces, hiddenlabels) { + return checkState( + traces.map(function(v) { + return {visible: v ? [undefined, true] : 'legendonly'}; + }), + {hiddenlabels: hiddenlabels} + ); + } + var checkAllVisible = checkVisible([true, true], undefined); + // wrap [b, c] in another array to distinguish it from + // [layout, fullLayout] + var checkSomeHidden = checkVisible([false, true], [['b', 'c']]); + + _run(fig, hideSome, checkAllVisible, checkSomeHidden).then(done); + }); + + it('preserves groupby group visibility', function(done) { + // TODO: there's a known problem if the groups change... unlike + // traces we will keep visibility by group in order, not by group value + + function fig(mainRev, legendRev) { + return { + data: [{ + y: [1, 2, 3, 4, 5, 6], + transforms: [{ + type: 'groupby', + groups: ['a', 'b', 'c', 'a', 'b', 'c'] + }] + }, { + y: [7, 8] + }], + layout: { + uirevision: mainRev, + legend: {uirevision: legendRev} + } + }; + } + + function hideSome() { + return Registry.call('_guiRestyle', gd, { + 'transforms[0].styles[0].value.visible': 'legendonly', + 'transforms[0].styles[2].value.visible': 'legendonly' + }, [0]) + .then(function() { + return Registry.call('_guiRestyle', gd, 'visible', 'legendonly', [1]); + }); + } + + function checkVisible(groups, extraTrace) { + var trace0edits = {}; + groups.forEach(function(visi, i) { + var attr = 'transforms[0].styles[' + i + '].value.visible'; + trace0edits[attr] = visi ? undefined : 'legendonly'; + }); + return checkState([ + trace0edits, + {visible: extraTrace ? [undefined, true] : 'legendonly'} + ]); + } + var checkAllVisible = checkVisible([true, true, true], true); + var checkSomeHidden = checkVisible([false, true, false], false); + + _run(fig, hideSome, checkAllVisible, checkSomeHidden).then(done); + }); + + it('@gl preserves modebar interactions using modebar.uirevision', function(done) { + function fig(mainRev, modebarRev) { + return { + data: [ + {type: 'surface', z: [[1, 2], [3, 4]]}, + {y: [1, 2]} + ], + layout: { + scene: { + domain: {x: [0, 0.4]}, + hovermode: 'closest', + dragmode: 'zoom' + }, + xaxis: {domain: [0.6, 1], showspikes: true}, + yaxis: {showspikes: true}, + uirevision: mainRev, + modebar: {uirevision: modebarRev}, + hovermode: 'closest', + dragmode: 'zoom' + } + }; + } + + function attrs(original) { + var dragmode = original ? 'zoom' : 'pan'; + var hovermode = original ? 'closest' : false; + var spikes = original ? true : false; + var spikes3D = original ? [undefined, true] : false; + return { + dragmode: dragmode, + hovermode: hovermode, + 'xaxis.showspikes': spikes, + 'yaxis.showspikes': spikes, + 'scene.dragmode': dragmode, + 'scene.hovermode': hovermode, + 'scene.xaxis.showspikes': spikes3D, + 'scene.yaxis.showspikes': spikes3D, + 'scene.zaxis.showspikes': spikes3D + }; + } + + function editModes() { + return Registry.call('_guiRelayout', gd, attrs()); + } + + var checkOriginalModes = checkState([], attrs(true)); + var checkEditedModes = checkState([], attrs()); + + _run(fig, editModes, checkOriginalModes, checkEditedModes).then(done); + }); + + it('preserves geo viewport changes using geo.uirevision', function(done) { + function fig(mainRev, geoRev) { + return { + data: [{ + type: 'scattergeo', lon: [0, -75], lat: [0, 45] + }], + layout: { + uirevision: mainRev, + geo: {uirevision: geoRev} + } + }; + } + + function attrs(original) { + return { + 'geo.projection.scale': original ? [undefined, 1] : 3, + 'geo.projection.rotation.lon': original ? [undefined, 0] : -45, + 'geo.center.lat': original ? [undefined, 0] : 22, + 'geo.center.lon': original ? [undefined, 0] : -45 + }; + } + + function editView() { + return Registry.call('_guiRelayout', gd, attrs()); + } + + var checkOriginalView = checkState([], attrs(true)); + var checkEditedView = checkState([], attrs()); + + _run(fig, editView, checkOriginalView, checkEditedView).then(done); + }); + + it('@gl preserves 3d camera changes using scene.uirevision', function(done) { + function fig(mainRev, sceneRev) { + return { + data: [{type: 'surface', z: [[1, 2], [3, 4]]}], + layout: { + uirevision: mainRev, + scene: {uirevision: sceneRev} + } + }; + } + + function editCamera() { + return Registry.call('_guiRelayout', gd, { + 'scene.camera': { + center: {x: 1, y: 2, z: 3}, + eye: {x: 2, y: 3, z: 4}, + up: {x: 0, y: 0, z: 1} + } + }); + } + + function _checkCamera(original) { + return checkState([], { + 'scene.camera.center.x': original ? [undefined, 0] : 1, + 'scene.camera.center.y': original ? [undefined, 0] : 2, + 'scene.camera.center.z': original ? [undefined, 0] : 3, + 'scene.camera.eye.x': original ? [undefined, 1.25] : 2, + 'scene.camera.eye.y': original ? [undefined, 1.25] : 3, + 'scene.camera.eye.z': original ? [undefined, 1.25] : 4, + 'scene.camera.up.x': original ? [undefined, 0] : 0, + 'scene.camera.up.y': original ? [undefined, 0] : 0, + 'scene.camera.up.z': original ? [undefined, 1] : 1 + }); + } + var checkOriginalCamera = _checkCamera(true); + var checkEditedCamera = _checkCamera(false); + + _run(fig, editCamera, checkOriginalCamera, checkEditedCamera).then(done); + }); + + it('preserves selectedpoints using selectionrevision', function(done) { + function fig(mainRev, selectionRev) { + return { + data: [{y: [1, 3, 1]}, {y: [2, 1, 3]}], + layout: { + uirevision: mainRev, + selectionrevision: selectionRev, + dragmode: 'select', + width: 400, + height: 400, + margin: {l: 100, t: 100, r: 100, b: 100} + } + }; + } + + function editSelection() { + // drag across the upper right quadrant, so we'll select + // curve 0 point 1 and curve 1 point 2 + return drag(document.querySelector('.nsewdrag'), + 148, 100, '', 150, 102); + } + + var checkNoSelection = checkState([ + {selectedpoints: undefined}, + {selectedpoints: undefined} + ]); + var checkSelection = checkState([ + {selectedpoints: [[1]]}, + {selectedpoints: [[2]]} + ]); + + _run(fig, editSelection, checkNoSelection, checkSelection).then(done); + }); + + it('preserves selectedpoints using selectedrevision (groupby case)', function(done) { + function fig(mainRev, selectionRev) { + return { + data: [{ + x: [1, 2, 3, 1, 2, 3, 1, 2, 3], + y: [1, 1, 1, 2, 2, 2, 3, 3, 3], + mode: 'markers', + marker: {size: 20}, + transforms: [{ + type: 'groupby', + groups: [1, 2, 3, 2, 3, 1, 3, 1, 2] + }] + }], + layout: { + uirevision: mainRev, + selectionrevision: selectionRev, + dragmode: 'select', + width: 400, + height: 400, + margin: {l: 100, t: 100, r: 100, b: 100} + } + }; + } + + function editSelection() { + // drag across the upper right quadrant, so we'll select + // curve 0 point 1 and curve 1 point 2 + return drag(document.querySelector('.nsewdrag'), + 148, 148, '', 150, 102); + } + + var checkNoSelection = checkState([{selectedpoints: undefined}]); + // the funny point order here is from the grouping: + // points 5 & 7 come first as they're in group 1 + // point 8 is next, in group 2 + // point 4 is last, in group 3 + var checkSelection = checkState([{selectedpoints: [[5, 7, 8, 4]]}]); + + _run(fig, editSelection, checkNoSelection, checkSelection).then(done); + }); + + it('preserves polar view changes using polar.uirevision', function(done) { + // polar you can control either at the subplot or the axis level + function fig(mainRev, polarRev) { + return { + data: [{r: [1, 2], theta: [1, 2], type: 'scatterpolar', mode: 'lines'}], + layout: { + uirevision: mainRev, + polar: {uirevision: polarRev} + } + }; + } + + function fig2(mainRev, polarRev) { + return { + data: [{r: [1, 2], theta: [1, 2], type: 'scatterpolar', mode: 'lines'}], + layout: { + uirevision: mainRev, + polar: { + angularaxis: {uirevision: polarRev}, + radialaxis: {uirevision: polarRev} + } + } + }; + } + + function attrs(original) { + return { + 'polar.radialaxis.range[0]': original ? 0 : -2, + 'polar.radialaxis.range[1]': original ? 2 : 4, + 'polar.radialaxis.angle': original ? [undefined, 0] : 45, + 'polar.angularaxis.rotation': original ? [undefined, 0] : -90 + }; + } + + function editPolar() { + return Registry.call('_guiRelayout', gd, attrs()); + } + + var checkInitial = checkState([], attrs(true)); + var checkEdited = checkState([], attrs()); + + _run(fig, editPolar, checkInitial, checkEdited) + .then(function() { + return _run(fig2, editPolar, checkInitial, checkEdited); + }) + .then(done); + }); + + it('preserves ternary view changes using ternary.uirevision', function(done) { + function fig(mainRev, ternaryRev) { + return { + data: [{a: [1, 2, 3], b: [2, 3, 1], c: [3, 1, 2], type: 'scatterternary'}], + layout: { + uirevision: mainRev, + ternary: {uirevision: ternaryRev} + } + }; + } + + function fig2(mainRev, ternaryRev) { + return { + data: [{a: [1, 2, 3], b: [2, 3, 1], c: [3, 1, 2], type: 'scatterternary'}], + layout: { + uirevision: mainRev, + ternary: { + aaxis: {uirevision: ternaryRev}, + baxis: {uirevision: ternaryRev}, + caxis: {uirevision: ternaryRev} + } + } + }; + } + + function attrs(original) { + return { + 'ternary.aaxis.min': original ? [undefined, 0] : 0.1, + 'ternary.baxis.min': original ? [undefined, 0] : 0.2, + 'ternary.caxis.min': original ? [undefined, 0] : 0.3, + }; + } + + function editTernary() { + return Registry.call('_guiRelayout', gd, attrs()); + } + + var checkInitial = checkState([], attrs(true)); + var checkEdited = checkState([], attrs()); + + _run(fig, editTernary, checkInitial, checkEdited) + .then(function() { + return _run(fig2, editTernary, checkInitial, checkEdited); + }) + .then(done); + }); + + it('@gl preserves mapbox view changes using mapbox.uirevision', function(done) { + function fig(mainRev, mapboxRev) { + return { + data: [{lat: [1, 2], lon: [1, 2], type: 'scattermapbox'}], + layout: { + uirevision: mainRev, + mapbox: {uirevision: mapboxRev} + } + }; + } + + function attrs(original) { + return { + 'mapbox.center.lat': original ? [undefined, 0] : 1, + 'mapbox.center.lon': original ? [undefined, 0] : 2, + 'mapbox.zoom': original ? [undefined, 1] : 3, + 'mapbox.bearing': original ? [undefined, 0] : 4, + 'mapbox.pitch': original ? [undefined, 0] : 5 + }; + } + + function editMap() { + return Registry.call('_guiRelayout', gd, attrs()); + } + + var checkInitial = checkState([], attrs(true)); + var checkEdited = checkState([], attrs()); + + Plotly.setPlotConfig({ + mapboxAccessToken: MAPBOX_ACCESS_TOKEN + }); + + _run(fig, editMap, checkInitial, checkEdited).then(done); + }); + + it('preserves editable: true shape & annotation edits using editrevision', function(done) { + function fig(mainRev, editRev) { + return {layout: { + shapes: [{x0: 0, x1: 0.5, y0: 0, y1: 0.5}], + annotations: [ + {x: 1, y: 0, text: 'hi'}, + {x: 1, y: 1, text: 'bye', showarrow: true, ax: -20, ay: 20} + ], + xaxis: {range: [0, 1]}, + yaxis: {range: [0, 1]}, + uirevision: mainRev, + editrevision: editRev + }}; + } + + function attrs(original) { + return { + 'shapes[0].x0': original ? 0 : 0.1, + 'shapes[0].x1': original ? 0.5 : 0.2, + 'shapes[0].y0': original ? 0 : 0.3, + 'shapes[0].y1': original ? 0.5 : 0.4, + 'annotations[1].x': original ? 1 : 0.5, + 'annotations[1].y': original ? 1 : 0.6, + 'annotations[1].ax': original ? -20 : -30, + 'annotations[1].ay': original ? 20 : 30, + 'annotations[1].text': original ? 'bye' : 'buy' + }; + } + + function editComponents() { + return Registry.call('_guiRelayout', gd, attrs()); + } + + var checkInitial = checkState([], attrs(true)); + var checkEdited = checkState([], attrs()); + + _run(fig, editComponents, checkInitial, checkEdited).then(done); + }); + + it('preserves editable: true plot title and legend & colorbar positions using editrevision', function(done) { + function fig(mainRev, editRev) { + return { + data: [{y: [1, 2]}, {y: [2, 1]}, {z: [[1, 2], [3, 4]], type: 'heatmap'}], + layout: { + uirevision: mainRev, + editrevision: editRev + }, + config: {editable: true} + }; + } + + function editEditable() { + return Registry.call('_guiUpdate', gd, + {'colorbar.x': 0.8, 'colorbar.y': 0.6}, + {'title.text': 'yep', 'legend.x': 1.1, 'legend.y': 0.9}, + [2] + ); + } + + function checkAttrs(original) { + return checkState([{}, {}, { + 'colorbar.x': original ? [undefined, 1.02] : 0.8, + 'colorbar.y': original ? [undefined, 0.5] : 0.6 + }], { + 'title.text': original ? [undefined, 'Click to enter Plot title'] : 'yep', + 'legend.x': original ? [undefined, 1.02] : 1.1, + 'legend.y': original ? [undefined, 1] : 0.9 + }); + } + + _run(fig, editEditable, checkAttrs(true), checkAttrs).then(done); + }); + + it('preserves editable: true name, colorbar title and parcoords constraint range via trace.uirevision', function(done) { + function fig(mainRev, traceRev) { + return { + data: [{ + type: 'parcoords', + dimensions: [ + {label: 'a', values: [1, 2, 3, 5], constraintrange: [2.5, 3.5]}, + {label: 'b', values: [7, 9, 5, 6]} + ], + line: {showscale: true, color: [1, 2, 3, 4]}, + uirevision: traceRev + }], + layout: { + uirevision: mainRev, + width: 400, + height: 400, + margin: {l: 100, r: 100, t: 100, b: 100} + }, + config: {editable: true} + }; + } + + function attrs(original) { + return { + 'dimensions[0].constraintrange': original ? [[2.5, 3.5]] : [[[1.5, 2.5], [2.938, 3.979]]], + 'dimensions[1].constraintrange': original ? undefined : [[6.937, 7.979]], + 'line.colorbar.title.text': original ? [undefined, 'Click to enter Colorscale title'] : 'color', + name: original ? [undefined, 'trace 0'] : 'name' + }; + } + + function axisDragNode(i) { + return document.querySelectorAll('.axis-brush .background')[i]; + } + + function editTrace() { + var _; + return Registry.call('_guiRestyle', gd, + {'line.colorbar.title.text': 'color', name: 'name'}, + [0] + ) + .then(function() { + return drag(axisDragNode(0), 0, 50, _, _, _, _, true); + }) + .then(function() { + return drag(axisDragNode(0), 0, -50, _, _, _, _, true); + }) + .then(function() { + return drag(axisDragNode(1), 0, -50, _, _, _, _, true); + }); + } + + _run(fig, editTrace, checkState([attrs(true)]), checkState([attrs()])).then(done); + }); + + it('preserves editable: true axis titles using the axis uirevisions', function(done) { + function fig(mainRev, axRev) { + return { + data: [ + {y: [1, 2]}, + {a: [1, 2], b: [2, 1], c: [1, 1], type: 'scatterternary'}, + {r: [1, 2], theta: [1, 2], type: 'scatterpolar'} + ], + layout: { + uirevision: mainRev, + xaxis: {uirevision: axRev}, + yaxis: {uirevision: axRev}, + ternary: { + aaxis: {uirevision: axRev}, + baxis: {uirevision: axRev}, + caxis: {uirevision: axRev} + }, + polar: {radialaxis: {uirevision: axRev}} + }, + config: {editable: true} + }; + } + + function attrs(original) { + return { + 'xaxis.title.text': original ? [undefined, 'Click to enter X axis title'] : 'XXX', + 'yaxis.title.text': original ? [undefined, 'Click to enter Y axis title'] : 'YYY', + 'ternary.aaxis.title.text': original ? [undefined, 'Component A'] : 'AAA', + 'ternary.baxis.title.text': original ? [undefined, 'Component B'] : 'BBB', + 'ternary.caxis.title.text': original ? [undefined, 'Component C'] : 'CCC', + 'polar.radialaxis.title.text': original ? [undefined, ''] : 'RRR' + }; + } + + function editComponents() { + return Registry.call('_guiRelayout', gd, attrs()); + } + + var checkInitial = checkState([], attrs(true)); + var checkEdited = checkState([], attrs()); + + _run(fig, editComponents, checkInitial, checkEdited).then(done); + }); +}); diff --git a/test/jasmine/tests/plot_api_test.js b/test/jasmine/tests/plot_api_test.js index 4d8141cf2e6..5d12e336e34 100644 --- a/test/jasmine/tests/plot_api_test.js +++ b/test/jasmine/tests/plot_api_test.js @@ -11,9 +11,6 @@ var pkg = require('../../../package.json'); var subroutines = require('@src/plot_api/subroutines'); var helpers = require('@src/plot_api/helpers'); var editTypes = require('@src/plot_api/edit_types'); -var annotations = require('@src/components/annotations'); -var images = require('@src/components/images'); -var Registry = require('@src/registry'); var d3 = require('d3'); var createGraphDiv = require('../assets/create_graph_div'); @@ -21,7 +18,6 @@ var destroyGraphDiv = require('../assets/destroy_graph_div'); var failTest = require('../assets/fail_test'); var checkTicks = require('../assets/custom_assertions').checkTicks; var supplyAllDefaults = require('../assets/supply_defaults'); -var mockLists = require('../assets/mock_lists'); describe('Test plot api', function() { 'use strict'; @@ -1290,28 +1286,52 @@ describe('Test plot api', function() { .then(done); }); - it('sets x/ytype scaled when editing heatmap x0/dx/y0/dy', function(done) { - var x0 = 3; - var dy = 5; + function checkScaling(xyType, xyTypeIn, iIn, iOut) { + expect(gd._fullData[iOut].xtype).toBe(xyType); + expect(gd._fullData[iOut].ytype).toBe(xyType); + expect(gd.data[iIn].xtype).toBe(xyTypeIn); + expect(gd.data[iIn].ytype).toBe(xyTypeIn); + } - function check(scaled, msg) { - expect(gd.data[0].x0).negateIf(!scaled).toBe(x0, msg); - expect(gd.data[0].xtype).toBe(scaled ? 'scaled' : undefined, msg); - expect(gd.data[0].dy).negateIf(!scaled).toBe(dy, msg); - expect(gd.data[0].ytype).toBe(scaled ? 'scaled' : undefined, msg); - } + it('sets heatmap xtype/ytype when you edit x/y data or scaling params', function(done) { + Plotly.plot(gd, [{type: 'heatmap', z: [[0, 1], [2, 3]]}]) + .then(function() { + // TODO would probably be better to actively default to 'array' here... + checkScaling(undefined, undefined, 0, 0); + return Plotly.restyle(gd, {x: [[2, 4]], y: [[3, 5]]}, [0]); + }) + .then(function() { + checkScaling('array', 'array', 0, 0); + return Plotly.restyle(gd, {x0: 1, dy: 3}, [0]); + }) + .then(function() { + checkScaling('scaled', 'scaled', 0, 0); + }) + .catch(failTest) + .then(done); + }); - Plotly.plot(gd, [{x: [1, 2, 4], y: [2, 3, 5], z: [[1, 2], [3, 4]], type: 'heatmap'}]) + it('sets heatmap xtype/ytype even when data/fullData indices mismatch', function(done) { + Plotly.plot(gd, [ + { + // importantly, this is NOT a heatmap trace, so _fullData[1] + // will not have the same attributes as data[1] + x: [1, -1, -2, 0], + y: [1, 2, 3, 1], + transforms: [{type: 'groupby', groups: ['a', 'b', 'a', 'b']}] + }, + {type: 'heatmap', z: [[0, 1], [2, 3]]} + ]) .then(function() { - check(false, 'initial'); - return Plotly.restyle(gd, {x0: x0, dy: dy}); + checkScaling(undefined, undefined, 1, 2); + return Plotly.restyle(gd, {x: [[2, 4]], y: [[3, 5]]}, [1]); }) .then(function() { - check(true, 'set x0 & dy'); - return Queue.undo(gd); + checkScaling('array', 'array', 1, 2); + return Plotly.restyle(gd, {x0: 1, dy: 3}, [1]); }) .then(function() { - check(false, 'undo'); + checkScaling('scaled', 'scaled', 1, 2); }) .catch(failTest) .then(done); @@ -2659,879 +2679,6 @@ describe('Test plot api', function() { Plotly.update(gd, {'type': 'scatter', 'marker.color': 'red'}, {}, [1]); }); }); - - describe('@noCIdep Plotly.react', function() { - var mockedMethods = [ - 'doTraceStyle', - 'doColorBars', - 'doLegend', - 'layoutStyles', - 'doTicksRelayout', - 'doModeBar', - 'doCamera' - ]; - - var gd; - var afterPlotCnt; - - beforeEach(function() { - gd = createGraphDiv(); - - spyOn(plotApi, 'plot').and.callThrough(); - spyOn(Registry, 'call').and.callThrough(); - - mockedMethods.forEach(function(m) { - spyOn(subroutines, m).and.callThrough(); - subroutines[m].calls.reset(); - }); - - spyOn(annotations, 'drawOne').and.callThrough(); - spyOn(annotations, 'draw').and.callThrough(); - spyOn(images, 'draw').and.callThrough(); - spyOn(Axes, 'draw').and.callThrough(); - }); - - afterEach(destroyGraphDiv); - - function countPlots() { - plotApi.plot.calls.reset(); - subroutines.layoutStyles.calls.reset(); - annotations.draw.calls.reset(); - annotations.drawOne.calls.reset(); - images.draw.calls.reset(); - - afterPlotCnt = 0; - gd.on('plotly_afterplot', function() { afterPlotCnt++; }); - } - - function countCalls(counts) { - var callsFinal = Lib.extendFlat({}, counts); - callsFinal.layoutStyles = (counts.layoutStyles || 0) + (counts.plot || 0); - - mockedMethods.forEach(function(m) { - expect(subroutines[m]).toHaveBeenCalledTimes(callsFinal[m] || 0); - subroutines[m].calls.reset(); - }); - - // calls to Plotly.plot via plot_api.js or Registry.call('plot') - var plotCalls = plotApi.plot.calls.count() + - Registry.call.calls.all() - .filter(function(d) { return d.args[0] === 'plot'; }) - .length; - expect(plotCalls).toBe(counts.plot || 0, 'Plotly.plot calls'); - plotApi.plot.calls.reset(); - Registry.call.calls.reset(); - - // only consider annotation and image draw calls if we *don't* do a full plot. - if(!counts.plot) { - expect(annotations.draw).toHaveBeenCalledTimes(counts.annotationDraw || 0); - expect(annotations.drawOne).toHaveBeenCalledTimes(counts.annotationDrawOne || 0); - expect(images.draw).toHaveBeenCalledTimes(counts.imageDraw || 0); - } - annotations.draw.calls.reset(); - annotations.drawOne.calls.reset(); - images.draw.calls.reset(); - - expect(afterPlotCnt).toBe(1, 'plotly_afterplot should be called only once per edit'); - afterPlotCnt = 0; - } - - it('can add / remove traces', function(done) { - var data1 = [{y: [1, 2, 3], mode: 'markers'}]; - var data2 = [data1[0], {y: [2, 3, 1], mode: 'markers'}]; - var layout = {}; - Plotly.newPlot(gd, data1, layout) - .then(countPlots) - .then(function() { - expect(d3.selectAll('.point').size()).toBe(3); - - return Plotly.react(gd, data2, layout); - }) - .then(function() { - expect(d3.selectAll('.point').size()).toBe(6); - - return Plotly.react(gd, data1, layout); - }) - .then(function() { - expect(d3.selectAll('.point').size()).toBe(3); - }) - .catch(failTest) - .then(done); - }); - - it('should notice new data by ===, without layout.datarevision', function(done) { - var data = [{y: [1, 2, 3], mode: 'markers'}]; - var layout = {}; - - Plotly.newPlot(gd, data, layout) - .then(countPlots) - .then(function() { - expect(d3.selectAll('.point').size()).toBe(3); - - data[0].y.push(4); - return Plotly.react(gd, data, layout); - }) - .then(function() { - // didn't pick it up, as we modified in place!!! - expect(d3.selectAll('.point').size()).toBe(3); - countCalls({plot: 0}); - - data[0].y = [1, 2, 3, 4, 5]; - return Plotly.react(gd, data, layout); - }) - .then(function() { - // new object, we picked it up! - expect(d3.selectAll('.point').size()).toBe(5); - countCalls({plot: 1}); - }) - .catch(failTest) - .then(done); - }); - - it('should notice new layout.datarevision', function(done) { - var data = [{y: [1, 2, 3], mode: 'markers'}]; - var layout = {datarevision: 1}; - - Plotly.newPlot(gd, data, layout) - .then(countPlots) - .then(function() { - expect(d3.selectAll('.point').size()).toBe(3); - - data[0].y.push(4); - return Plotly.react(gd, data, layout); - }) - .then(function() { - // didn't pick it up, as we didn't modify datarevision - expect(d3.selectAll('.point').size()).toBe(3); - countCalls({plot: 0}); - - data[0].y.push(5); - layout.datarevision = 'bananas'; - return Plotly.react(gd, data, layout); - }) - .then(function() { - // new revision, we picked it up! - expect(d3.selectAll('.point').size()).toBe(5); - - countCalls({plot: 1}); - }) - .catch(failTest) - .then(done); - }); - - it('picks up partial redraws', function(done) { - var data = [{y: [1, 2, 3], mode: 'markers'}]; - var layout = {}; - - Plotly.newPlot(gd, data, layout) - .then(countPlots) - .then(function() { - layout.title = 'XXXXX'; - layout.hovermode = 'closest'; - data[0].marker = {color: 'rgb(0, 100, 200)'}; - return Plotly.react(gd, data, layout); - }) - .then(function() { - countCalls({layoutStyles: 1, doTraceStyle: 1, doModeBar: 1}); - expect(d3.select('.gtitle').text()).toBe('XXXXX'); - var points = d3.selectAll('.point'); - expect(points.size()).toBe(3); - points.each(function() { - expect(window.getComputedStyle(this).fill).toBe('rgb(0, 100, 200)'); - }); - - layout.showlegend = true; - layout.xaxis.tick0 = 0.1; - layout.xaxis.dtick = 0.3; - return Plotly.react(gd, data, layout); - }) - .then(function() { - // legend and ticks get called initially, but then plot gets added during automargin - countCalls({doLegend: 1, doTicksRelayout: 1, plot: 1}); - - data = [{z: [[1, 2], [3, 4]], type: 'surface'}]; - layout = {}; - - return Plotly.react(gd, data, layout); - }) - .then(function() { - // we get an extra call to layoutStyles from marginPushersAgain due to the colorbar. - // Really need to simplify that pipeline... - countCalls({plot: 1, layoutStyles: 1}); - - layout.scene.camera = {up: {x: 1, y: -1, z: 0}}; - - return Plotly.react(gd, data, layout); - }) - .then(function() { - countCalls({doCamera: 1}); - - data[0].type = 'heatmap'; - delete layout.scene; - return Plotly.react(gd, data, layout); - }) - .then(function() { - countCalls({plot: 1}); - - // ideally we'd just do this with `surface` but colorbar attrs have editType 'calc' there - // TODO: can we drop them to type: 'colorbars' even for the 3D types? - data[0].colorbar = {len: 0.6}; - return Plotly.react(gd, data, layout); - }) - .then(function() { - countCalls({doColorBars: 1, plot: 1}); - }) - .catch(failTest) - .then(done); - }); - - it('picks up special dtick geo case', function(done) { - var data = [{type: 'scattergeo'}]; - var layout = {}; - - function countLines() { - var path = d3.select(gd).select('.lataxis > path'); - return path.attr('d').split('M').length; - } - - Plotly.react(gd, data) - .then(countPlots) - .then(function() { - layout.geo = {lataxis: {showgrid: true, dtick: 10}}; - return Plotly.react(gd, data, layout); - }) - .then(function() { - countCalls({plot: 1}); - expect(countLines()).toBe(18); - }) - .then(function() { - layout.geo.lataxis.dtick = 30; - return Plotly.react(gd, data, layout); - }) - .then(function() { - countCalls({plot: 1}); - expect(countLines()).toBe(6); - }) - .catch(failTest) - .then(done); - }); - - it('picks up minimal sequence for cartesian axis range updates', function(done) { - var data = [{y: [1, 2, 1]}]; - var layout = {xaxis: {range: [1, 2]}}; - var layout2 = {xaxis: {range: [0, 1]}}; - - Plotly.newPlot(gd, data, layout) - .then(countPlots) - .then(function() { - expect(Axes.draw).toHaveBeenCalledWith(gd, ''); - return Plotly.react(gd, data, layout2); - }) - .then(function() { - expect(Axes.draw).toHaveBeenCalledWith(gd, 'redraw'); - expect(subroutines.layoutStyles).not.toHaveBeenCalled(); - }) - .catch(failTest) - .then(done); - }); - - it('redraws annotations one at a time', function(done) { - var data = [{y: [1, 2, 3], mode: 'markers'}]; - var layout = {}; - var ymax; - - Plotly.newPlot(gd, data, layout) - .then(countPlots) - .then(function() { - ymax = layout.yaxis.range[1]; - - layout.annotations = [{ - x: 1, - y: 4, - text: 'Way up high', - showarrow: false - }, { - x: 1, - y: 2, - text: 'On the data', - showarrow: false - }]; - return Plotly.react(gd, data, layout); - }) - .then(function() { - // autoranged - so we get a full replot - countCalls({plot: 1}); - expect(d3.selectAll('.annotation').size()).toBe(2); - - layout.annotations[1].bgcolor = 'rgb(200, 100, 0)'; - return Plotly.react(gd, data, layout); - }) - .then(function() { - countCalls({annotationDrawOne: 1}); - expect(window.getComputedStyle(d3.select('.annotation[data-index="1"] .bg').node()).fill) - .toBe('rgb(200, 100, 0)'); - expect(layout.yaxis.range[1]).not.toBeCloseTo(ymax, 0); - - layout.annotations[0].font = {color: 'rgb(0, 255, 0)'}; - layout.annotations[1].bgcolor = 'rgb(0, 0, 255)'; - return Plotly.react(gd, data, layout); - }) - .then(function() { - countCalls({annotationDrawOne: 2}); - expect(window.getComputedStyle(d3.select('.annotation[data-index="0"] text').node()).fill) - .toBe('rgb(0, 255, 0)'); - expect(window.getComputedStyle(d3.select('.annotation[data-index="1"] .bg').node()).fill) - .toBe('rgb(0, 0, 255)'); - - Lib.extendFlat(layout.annotations[0], {yref: 'paper', y: 0.8}); - - return Plotly.react(gd, data, layout); - }) - .then(function() { - countCalls({plot: 1}); - expect(layout.yaxis.range[1]).toBeCloseTo(ymax, 0); - }) - .catch(failTest) - .then(done); - }); - - it('redraws images all at once', function(done) { - var data = [{y: [1, 2, 3], mode: 'markers'}]; - var layout = {}; - var jsLogo = 'https://images.plot.ly/language-icons/api-home/js-logo.png'; - - var x, y, height, width; - - Plotly.newPlot(gd, data, layout) - .then(countPlots) - .then(function() { - layout.images = [{ - source: jsLogo, - xref: 'paper', - yref: 'paper', - x: 0.1, - y: 0.1, - sizex: 0.2, - sizey: 0.2 - }, { - source: jsLogo, - xref: 'x', - yref: 'y', - x: 1, - y: 2, - sizex: 1, - sizey: 1 - }]; - return Plotly.react(gd, data, layout); - }) - .then(function() { - countCalls({imageDraw: 1}); - expect(d3.selectAll('image').size()).toBe(2); - - var n = d3.selectAll('image').node(); - x = n.attributes.x.value; - y = n.attributes.y.value; - height = n.attributes.height.value; - width = n.attributes.width.value; - - layout.images[0].y = 0.8; - layout.images[0].sizey = 0.4; - return Plotly.react(gd, data, layout); - }) - .then(function() { - countCalls({imageDraw: 1}); - var n = d3.selectAll('image').node(); - expect(n.attributes.x.value).toBe(x); - expect(n.attributes.width.value).toBe(width); - expect(n.attributes.y.value).not.toBe(y); - expect(n.attributes.height.value).not.toBe(height); - }) - .catch(failTest) - .then(done); - }); - - it('can change config, and always redraws', function(done) { - var data = [{y: [1, 2, 3]}]; - var layout = {}; - - Plotly.newPlot(gd, data, layout) - .then(countPlots) - .then(function() { - expect(d3.selectAll('.drag').size()).toBe(11); - expect(d3.selectAll('.gtitle').size()).toBe(0); - - return Plotly.react(gd, data, layout, {editable: true}); - }) - .then(function() { - expect(d3.selectAll('.drag').size()).toBe(11); - expect(d3.selectAll('.gtitle').text()).toBe('Click to enter Plot title'); - countCalls({plot: 1}); - - return Plotly.react(gd, data, layout, {staticPlot: true}); - }) - .then(function() { - expect(d3.selectAll('.drag').size()).toBe(0); - expect(d3.selectAll('.gtitle').size()).toBe(0); - countCalls({plot: 1}); - - return Plotly.react(gd, data, layout, {}); - }) - .then(function() { - expect(d3.selectAll('.drag').size()).toBe(11); - expect(d3.selectAll('.gtitle').size()).toBe(0); - countCalls({plot: 1}); - }) - .catch(failTest) - .then(done); - }); - - it('can put polar plots into staticPlot mode', function(done) { - // tested separately since some of the relevant code is actually - // in cartesian/graph_interact... hopefully we'll fix that - // sometime and the test will still pass. - var data = [{r: [1, 2, 3], theta: [0, 120, 240], type: 'scatterpolar'}]; - var layout = {}; - - Plotly.newPlot(gd, data, layout) - .then(countPlots) - .then(function() { - expect(d3.select(gd).selectAll('.drag').size()).toBe(4); - - return Plotly.react(gd, data, layout, {staticPlot: true}); - }) - .then(function() { - expect(d3.select(gd).selectAll('.drag').size()).toBe(0); - - return Plotly.react(gd, data, layout, {}); - }) - .then(function() { - expect(d3.select(gd).selectAll('.drag').size()).toBe(4); - }) - .catch(failTest) - .then(done); - }); - - it('can change data in candlesticks multiple times', function(done) { - // test that we've fixed the original issue in - // https://github.com/plotly/plotly.js/issues/2510 - - function assertCalc(open, high, low, close) { - expect(gd.calcdata[0][0]).toEqual(jasmine.objectContaining({ - min: low, - max: high, - med: close, - q1: Math.min(open, close), - q3: Math.max(open, close), - dir: close >= open ? 'increasing' : 'decreasing' - })); - } - var trace = { - type: 'candlestick', - low: [1], - open: [2], - close: [3], - high: [4] - }; - Plotly.newPlot(gd, [trace]) - .then(function() { - assertCalc(2, 4, 1, 3); - - trace.low = [0]; - return Plotly.react(gd, [trace]); - }) - .then(function() { - assertCalc(2, 4, 0, 3); - - trace.low = [-1]; - return Plotly.react(gd, [trace]); - }) - .then(function() { - assertCalc(2, 4, -1, 3); - - trace.close = [1]; - return Plotly.react(gd, [trace]); - }) - .then(function() { - assertCalc(2, 4, -1, 1); - }) - .catch(failTest) - .then(done); - }); - - function aggregatedPie(i) { - var labels = i <= 1 ? - ['A', 'B', 'A', 'C', 'A', 'B', 'C', 'A', 'B', 'C', 'A'] : - ['X', 'Y', 'Z', 'Z', 'Y', 'Z', 'X', 'Z', 'Y', 'Z', 'X']; - var trace = { - type: 'pie', - values: [4, 1, 4, 4, 1, 4, 4, 2, 1, 1, 15], - labels: labels, - transforms: [{ - type: 'aggregate', - groups: labels, - aggregations: [{target: 'values', func: 'sum'}] - }] - }; - return { - data: [trace], - layout: { - datarevision: i, - colorway: ['red', 'orange', 'yellow', 'green', 'blue', 'violet'] - } - }; - } - - var aggPie1CD = [[ - {v: 26, label: 'A', color: 'red', i: 0}, - {v: 9, label: 'C', color: 'orange', i: 2}, - {v: 6, label: 'B', color: 'yellow', i: 1} - ]]; - - var aggPie2CD = [[ - {v: 23, label: 'X', color: 'red', i: 0}, - {v: 15, label: 'Z', color: 'orange', i: 2}, - {v: 3, label: 'Y', color: 'yellow', i: 1} - ]]; - - function aggregatedScatter(i) { - return { - data: [{ - x: [1, 2, 3, 4, 6, 5], - y: [2, 1, 3, 5, 6, 4], - transforms: [{ - type: 'aggregate', - groups: [1, -1, 1, -1, 1, -1], - aggregations: i > 1 ? [{func: 'last', target: 'x'}] : [] - }] - }], - layout: {daterevision: i + 10} - }; - } - - var aggScatter1CD = [[ - {x: 1, y: 2, i: 0}, - {x: 2, y: 1, i: 1} - ]]; - - var aggScatter2CD = [[ - {x: 6, y: 2, i: 0}, - {x: 5, y: 1, i: 1} - ]]; - - function aggregatedParcoords(i) { - return { - data: [{ - type: 'parcoords', - dimensions: [ - {label: 'A', values: [1, 2, 3, 4]}, - {label: 'B', values: [4, 3, 2, 1]} - ], - transforms: i ? [{ - type: 'aggregate', - groups: [1, 2, 1, 2], - aggregations: [ - {target: 'dimensions[0].values', func: i > 1 ? 'avg' : 'first'}, - {target: 'dimensions[1].values', func: i > 1 ? 'first' : 'avg'} - ] - }] : - [] - }] - }; - } - - var aggParcoords0Vals = [[1, 2, 3, 4], [4, 3, 2, 1]]; - var aggParcoords1Vals = [[1, 2], [3, 2]]; - var aggParcoords2Vals = [[2, 3], [4, 3]]; - - function checkCalcData(expectedCD) { - return function() { - expect(gd.calcdata.length).toBe(expectedCD.length); - expectedCD.forEach(function(expectedCDi, i) { - var cdi = gd.calcdata[i]; - expect(cdi.length).toBe(expectedCDi.length, i); - expectedCDi.forEach(function(expectedij, j) { - expect(cdi[j]).toEqual(jasmine.objectContaining(expectedij)); - }); - }); - }; - } - - function checkValues(expectedVals) { - return function() { - expect(gd._fullData.length).toBe(1); - var dims = gd._fullData[0].dimensions; - expect(dims.length).toBe(expectedVals.length); - expectedVals.forEach(function(expected, i) { - expect(dims[i].values).toEqual(expected); - }); - }; - } - - function reactTo(fig) { - return function() { return Plotly.react(gd, fig); }; - } - - it('can change pie aggregations', function(done) { - Plotly.newPlot(gd, aggregatedPie(1)) - .then(checkCalcData(aggPie1CD)) - - .then(reactTo(aggregatedPie(2))) - .then(checkCalcData(aggPie2CD)) - - .then(reactTo(aggregatedPie(1))) - .then(checkCalcData(aggPie1CD)) - .catch(failTest) - .then(done); - }); - - it('can change scatter aggregations', function(done) { - Plotly.newPlot(gd, aggregatedScatter(1)) - .then(checkCalcData(aggScatter1CD)) - - .then(reactTo(aggregatedScatter(2))) - .then(checkCalcData(aggScatter2CD)) - - .then(reactTo(aggregatedScatter(1))) - .then(checkCalcData(aggScatter1CD)) - .catch(failTest) - .then(done); - }); - - it('can change parcoords aggregations', function(done) { - Plotly.newPlot(gd, aggregatedParcoords(0)) - .then(checkValues(aggParcoords0Vals)) - - .then(reactTo(aggregatedParcoords(1))) - .then(checkValues(aggParcoords1Vals)) - - .then(reactTo(aggregatedParcoords(2))) - .then(checkValues(aggParcoords2Vals)) - - .then(reactTo(aggregatedParcoords(0))) - .then(checkValues(aggParcoords0Vals)) - - .catch(failTest) - .then(done); - }); - - it('can change type with aggregations', function(done) { - Plotly.newPlot(gd, aggregatedScatter(1)) - .then(checkCalcData(aggScatter1CD)) - - .then(reactTo(aggregatedPie(1))) - .then(checkCalcData(aggPie1CD)) - - .then(reactTo(aggregatedParcoords(1))) - .then(checkValues(aggParcoords1Vals)) - - .then(reactTo(aggregatedScatter(1))) - .then(checkCalcData(aggScatter1CD)) - - .then(reactTo(aggregatedParcoords(2))) - .then(checkValues(aggParcoords2Vals)) - - .then(reactTo(aggregatedPie(2))) - .then(checkCalcData(aggPie2CD)) - - .then(reactTo(aggregatedScatter(2))) - .then(checkCalcData(aggScatter2CD)) - - .then(reactTo(aggregatedParcoords(0))) - .then(checkValues(aggParcoords0Vals)) - .catch(failTest) - .then(done); - }); - - it('can change frames without redrawing', function(done) { - var data = [{y: [1, 2, 3]}]; - var layout = {}; - var frames = [{name: 'frame1'}]; - - Plotly.newPlot(gd, {data: data, layout: layout, frames: frames}) - .then(countPlots) - .then(function() { - var frameData = gd._transitionData._frames; - expect(frameData.length).toBe(1); - expect(frameData[0].name).toBe('frame1'); - - frames[0].name = 'frame2'; - return Plotly.react(gd, {data: data, layout: layout, frames: frames}); - }) - .then(function() { - countCalls({}); - var frameData = gd._transitionData._frames; - expect(frameData.length).toBe(1); - expect(frameData[0].name).toBe('frame2'); - }) - .catch(failTest) - .then(done); - }); - - // make sure we've included every trace type in this suite - var typesTested = {}; - var itemType; - for(itemType in Registry.modules) { typesTested[itemType] = 0; } - for(itemType in Registry.transformsRegistry) { typesTested[itemType] = 0; } - - // Not really being supported... This isn't part of the main bundle, and it's pretty broken, - // but it gets registered and used by a couple of the gl2d tests. - delete typesTested.contourgl; - - function _runReactMock(mockSpec, done) { - var mock = mockSpec[1]; - var initialJson; - - function fullJson() { - var out = JSON.parse(Plotly.Plots.graphJson({ - data: gd._fullData.map(function(trace) { return trace._fullInput; }), - layout: gd._fullLayout - })); - - // TODO: does it matter that ax.tick0/dtick/range and zmin/zmax - // are often not regenerated without a calc step? - // in as far as editor and others rely on _full, I think the - // answer must be yes, but I'm not sure about within plotly.js - [ - 'xaxis', 'xaxis2', 'xaxis3', 'xaxis4', 'xaxis5', - 'yaxis', 'yaxis2', 'yaxis3', 'yaxis4', - 'zaxis' - ].forEach(function(axName) { - var ax = out.layout[axName]; - if(ax) { - delete ax.dtick; - delete ax.tick0; - - // TODO this one I don't understand and can't reproduce - // in the dashboard but it's needed here? - delete ax.range; - } - if(out.layout.scene) { - ax = out.layout.scene[axName]; - if(ax) { - delete ax.dtick; - delete ax.tick0; - // TODO: this is the only one now that uses '_input_' + key - // as a hack to tell Plotly.react to ignore changes. - // Can we kill this? - delete ax.range; - } - } - }); - out.data.forEach(function(trace) { - if(trace.type === 'contourcarpet') { - delete trace.zmin; - delete trace.zmax; - } - }); - - return out; - } - - // Make sure we define `_length` in every trace *in supplyDefaults*. - // This is only relevant for traces that *have* a 1D concept of length, - // and in addition to simplifying calc/plot logic later on, ths serves - // as a signal to transforms about how they should operate. For traces - // that do NOT have a 1D length, `_length` should be `null`. - var mockGD = Lib.extendDeep({}, mock); - supplyAllDefaults(mockGD); - expect(mockGD._fullData.length).not.toBeLessThan((mock.data || []).length, mockSpec[0]); - mockGD._fullData.forEach(function(trace, i) { - var len = trace._length; - if(trace.visible !== false && len !== null) { - expect(typeof len).toBe('number', mockSpec[0] + ' trace ' + i + ': type=' + trace.type); - } - - typesTested[trace.type]++; - - if(trace.transforms) { - trace.transforms.forEach(function(transform) { - typesTested[transform.type]++; - }); - } - }); - - Plotly.newPlot(gd, mock) - .then(countPlots) - .then(function() { - initialJson = fullJson(); - - return Plotly.react(gd, mock); - }) - .then(function() { - expect(fullJson()).toEqual(initialJson); - countCalls({}); - }) - .catch(failTest) - .then(done); - } - - mockLists.svg.forEach(function(mockSpec) { - it('can redraw "' + mockSpec[0] + '" with no changes as a noop (svg mocks)', function(done) { - _runReactMock(mockSpec, done); - }); - }); - - mockLists.gl.forEach(function(mockSpec) { - it('can redraw "' + mockSpec[0] + '" with no changes as a noop (gl mocks)', function(done) { - _runReactMock(mockSpec, done); - }); - }); - - mockLists.mapbox.forEach(function(mockSpec) { - it('@noCI can redraw "' + mockSpec[0] + '" with no changes as a noop (mapbpox mocks)', function(done) { - Plotly.setPlotConfig({ - mapboxAccessToken: require('@build/credentials.json').MAPBOX_ACCESS_TOKEN - }); - _runReactMock(mockSpec, done); - }); - }); - - // since CI breaks up gl/svg types, and drops scattermapbox, this test won't work there - // but I should hope that if someone is doing something as major as adding a new type, - // they'll run the full test suite locally! - it('@noCI tested every trace & transform type at least once', function() { - for(var itemType in typesTested) { - expect(typesTested[itemType]).toBeGreaterThan(0, itemType + ' was not tested'); - } - }); - }); - - describe('resizing with Plotly.relayout and Plotly.react', function() { - var gd; - - beforeEach(function() { - gd = createGraphDiv(); - }); - - afterEach(destroyGraphDiv); - - it('recalculates autoranges when height/width change', function(done) { - Plotly.newPlot(gd, - [{y: [1, 2], marker: {size: 100}}], - {width: 400, height: 400, margin: {l: 100, r: 100, t: 100, b: 100}} - ) - .then(function() { - expect(gd.layout.xaxis.range).toBeCloseToArray([-1.31818, 2.31818], 3); - expect(gd.layout.yaxis.range).toBeCloseToArray([-0.31818, 3.31818], 3); - - return Plotly.relayout(gd, {height: 800, width: 800}); - }) - .then(function() { - expect(gd.layout.xaxis.range).toBeCloseToArray([-0.22289, 1.22289], 3); - expect(gd.layout.yaxis.range).toBeCloseToArray([0.77711, 2.22289], 3); - - gd.layout.width = 500; - gd.layout.height = 500; - return Plotly.react(gd, gd.data, gd.layout); - }) - .then(function() { - expect(gd.layout.xaxis.range).toBeCloseToArray([-0.53448, 1.53448], 3); - expect(gd.layout.yaxis.range).toBeCloseToArray([0.46552, 2.53448], 3); - }) - .catch(failTest) - .then(done); - }); - }); }); describe('plot_api helpers', function() { diff --git a/test/jasmine/tests/titles_test.js b/test/jasmine/tests/titles_test.js index 2dd4ba2230f..6696cda814c 100644 --- a/test/jasmine/tests/titles_test.js +++ b/test/jasmine/tests/titles_test.js @@ -528,7 +528,7 @@ describe('Plot title', function() { var topDistance = titleBB.top - refElemBB.top; var bottomDistance = refElemBB.bottom - titleBB.bottom; - var tolerance = 1.1; + var tolerance = 1.3; var msg = 'Title centered vertically within ' + elemSelector.desc; expect(topDistance).toBeWithin(bottomDistance, tolerance, msg); }