diff --git a/src/components/sliders/attributes.js b/src/components/sliders/attributes.js index 420287f7ded..cb0203b7801 100644 --- a/src/components/sliders/attributes.js +++ b/src/components/sliders/attributes.js @@ -68,7 +68,7 @@ module.exports = { active: { valType: 'number', role: 'info', - min: -10, + min: 0, dflt: 0, description: [ 'Determines which button (by index starting from 0) is', diff --git a/src/components/sliders/draw.js b/src/components/sliders/draw.js index c24e322c385..953d131ad17 100644 --- a/src/components/sliders/draw.js +++ b/src/components/sliders/draw.js @@ -11,7 +11,6 @@ var d3 = require('d3'); -var Plotly = require('../../plotly'); var Plots = require('../../plots/plots'); var Lib = require('../../lib'); var Color = require('../color'); @@ -52,6 +51,9 @@ module.exports = function draw(gd) { sliderGroups.exit().each(function(sliderOpts) { d3.select(this).remove(); + sliderOpts._commandObserver.remove(); + delete sliderOpts._commandObserver; + Plots.autoMargin(gd, constants.autoMarginIdRoot + sliderOpts._index); }); @@ -65,12 +67,20 @@ module.exports = function draw(gd) { // If it has fewer than two options, it's not really a slider: if(sliderOpts.steps.length < 2) return; + var gSlider = d3.select(this); + computeLabelSteps(sliderOpts); + Plots.manageCommandObserver(gd, sliderOpts, sliderOpts.steps, function(data) { + if(sliderOpts.active === data.index) return; + if(sliderOpts._dragging) return; + + setActive(gd, gSlider, sliderOpts, data.index, false, true); + }); + drawSlider(gd, d3.select(this), sliderOpts); // makeInputProxy(gd, d3.select(this), sliderOpts); - }); }; @@ -227,7 +237,9 @@ function drawSlider(gd, sliderGroup, sliderOpts) { // Position the rectangle: Lib.setTranslate(sliderGroup, sliderOpts.lx + sliderOpts.pad.l, sliderOpts.ly + sliderOpts.pad.t); - setActive(gd, sliderGroup, sliderOpts, sliderOpts.active, false, false); + sliderGroup.call(setGripPosition, sliderOpts, sliderOpts.active / (sliderOpts.steps.length - 1), false); + sliderGroup.call(drawCurrentValue, sliderOpts); + } function drawCurrentValue(sliderGroup, sliderOpts, valueOverride) { @@ -371,19 +383,9 @@ function setActive(gd, sliderGroup, sliderOpts, index, doCallback, doTransition) sliderGroup._nextMethod = {step: step, doCallback: doCallback, doTransition: doTransition}; sliderGroup._nextMethodRaf = window.requestAnimationFrame(function() { var _step = sliderGroup._nextMethod.step; - var args = _step.args; if(!_step.method) return; - sliderOpts._invokingCommand = true; - Plotly[_step.method](gd, args[0], args[1], args[2]).then(function() { - sliderOpts._invokingCommand = false; - }, function() { - sliderOpts._invokingCommand = false; - - // This is not a disaster. Some methods like `animate` reject if interrupted - // and *should* nicely log a warning. - Lib.warn('Warning: Plotly.' + _step.method + ' was called and rejected.'); - }); + Plots.executeAPICommand(gd, _step.method, _step.args); sliderGroup._nextMethod = null; sliderGroup._nextMethodRaf = null; @@ -405,6 +407,7 @@ function attachGripEvents(item, gd, sliderGroup, sliderOpts) { var normalizedPosition = positionToNormalizedValue(sliderOpts, d3.mouse(node)[0]); handleInput(gd, sliderGroup, sliderOpts, normalizedPosition, true); + sliderOpts._dragging = true; $gd.on('mousemove', function() { var normalizedPosition = positionToNormalizedValue(sliderOpts, d3.mouse(node)[0]); @@ -412,6 +415,7 @@ function attachGripEvents(item, gd, sliderGroup, sliderOpts) { }); $gd.on('mouseup', function() { + sliderOpts._dragging = false; grip.call(Color.fill, sliderOpts.bgcolor); $gd.on('mouseup', null); $gd.on('mousemove', null); @@ -467,8 +471,12 @@ function setGripPosition(sliderGroup, sliderOpts, position, doTransition) { var x = normalizedValueToPosition(sliderOpts, position); + // If this is true, then *this component* is already invoking its own command + // and has triggered its own animation. + if(sliderOpts._invokingCommand) return; + var el = grip; - if(doTransition && sliderOpts.transition.duration > 0 && !sliderOpts._invokingCommand) { + if(doTransition && sliderOpts.transition.duration > 0) { el = el.transition() .duration(sliderOpts.transition.duration) .ease(sliderOpts.transition.easing); diff --git a/src/components/updatemenus/draw.js b/src/components/updatemenus/draw.js index 6da09277e69..39abd0fcf11 100644 --- a/src/components/updatemenus/draw.js +++ b/src/components/updatemenus/draw.js @@ -11,7 +11,6 @@ var d3 = require('d3'); -var Plotly = require('../../plotly'); var Plots = require('../../plots/plots'); var Lib = require('../../lib'); var Color = require('../color'); @@ -21,7 +20,6 @@ var anchorUtils = require('../legend/anchor_utils'); var constants = require('./constants'); - module.exports = function draw(gd) { var fullLayout = gd._fullLayout, menuData = makeMenuData(fullLayout); @@ -115,6 +113,11 @@ module.exports = function draw(gd) { headerGroups.each(function(menuOpts) { var gHeader = d3.select(this); + var _gButton = menuOpts.type === 'dropdown' ? gButton : null; + Plots.manageCommandObserver(gd, menuOpts, menuOpts.buttons, function(data) { + setActive(gd, menuOpts, menuOpts.buttons[data.index], gHeader, _gButton, data.index, true); + }); + if(menuOpts.type === 'dropdown') { drawHeader(gd, gHeader, gButton, menuOpts); @@ -293,21 +296,9 @@ function drawButtons(gd, gHeader, gButton, menuOpts) { .call(setItemPosition, menuOpts, posOpts); button.on('click', function() { - // update 'active' attribute in menuOpts - menuOpts._input.active = menuOpts.active = buttonIndex; - - // fold up buttons and redraw header - gButton.attr(constants.menuIndexAttrName, '-1'); + setActive(gd, menuOpts, buttonOpts, gHeader, gButton, buttonIndex); - if(menuOpts.type === 'dropdown') { - drawHeader(gd, gHeader, gButton, menuOpts); - } - - drawButtons(gd, gHeader, gButton, menuOpts); - - // call button method - var args = buttonOpts.args; - Plotly[buttonOpts.method](gd, args[0], args[1], args[2]); + Plots.executeAPICommand(gd, buttonOpts.method, buttonOpts.args); }); button.on('mouseover', function() { @@ -326,6 +317,22 @@ function drawButtons(gd, gHeader, gButton, menuOpts) { Lib.setTranslate(gButton, menuOpts.lx, menuOpts.ly); } +function setActive(gd, menuOpts, buttonOpts, gHeader, gButton, buttonIndex, isSilentUpdate) { + // update 'active' attribute in menuOpts + menuOpts._input.active = menuOpts.active = buttonIndex; + + if(menuOpts.type === 'dropdown') { + // fold up buttons and redraw header + gButton.attr(constants.menuIndexAttrName, '-1'); + + drawHeader(gd, gHeader, gButton, menuOpts); + } + + if(!isSilentUpdate || menuOpts.type === 'buttons') { + drawButtons(gd, gHeader, gButton, menuOpts); + } +} + function drawItem(item, menuOpts, itemOpts) { item.call(drawItemRect, menuOpts) .call(drawItemText, menuOpts, itemOpts); diff --git a/src/plot_api/plot_api.js b/src/plot_api/plot_api.js index 9b1976b4f7f..ad6fea341df 100644 --- a/src/plot_api/plot_api.js +++ b/src/plot_api/plot_api.js @@ -2302,14 +2302,7 @@ Plotly.animate = function(gd, frameOrGroupNameOrFrameList, animationOpts) { var newFrame = trans._currentFrame = trans._frameQueue.shift(); if(newFrame) { - gd.emit('plotly_animatingframe', { - name: newFrame.name, - frame: newFrame.frame, - animation: { - frame: newFrame.frameOpts, - transition: newFrame.transitionOpts, - } - }); + gd._fullLayout._currentFrame = newFrame.name; trans._lastFrameAt = Date.now(); trans._timeToNext = newFrame.frameOpts.duration; @@ -2324,6 +2317,15 @@ Plotly.animate = function(gd, frameOrGroupNameOrFrameList, animationOpts) { newFrame.frameOpts, newFrame.transitionOpts ); + + gd.emit('plotly_animatingframe', { + name: newFrame.name, + frame: newFrame.frame, + animation: { + frame: newFrame.frameOpts, + transition: newFrame.transitionOpts, + } + }); } else { // If there are no more frames, then stop the RAF loop: stopAnimationLoop(); diff --git a/src/plots/command.js b/src/plots/command.js new file mode 100644 index 00000000000..cb3b42fb8d3 --- /dev/null +++ b/src/plots/command.js @@ -0,0 +1,410 @@ +/** +* Copyright 2012-2016, Plotly, Inc. +* All rights reserved. +* +* This source code is licensed under the MIT license found in the +* LICENSE file in the root directory of this source tree. +*/ + + +'use strict'; + +var Plotly = require('../plotly'); +var Lib = require('../lib'); + +/* + * Create or update an observer. This function is designed to be + * idempotent so that it can be called over and over as the component + * updates, and will attach and detach listeners as needed. + * + * @param {optional object} container + * An object on which the observer is stored. This is the mechanism + * by which it is idempotent. If it already exists, another won't be + * added. Each time it's called, the value lookup table is updated. + * @param {array} commandList + * An array of commands, following either `buttons` of `updatemenus` + * or `steps` of `sliders`. + * @param {function} onchange + * A listener called when the value is changed. Receives data object + * with information about the new state. + */ +exports.manageCommandObserver = function(gd, container, commandList, onchange) { + var ret = {}; + var enabled = true; + + if(container && container._commandObserver) { + ret = container._commandObserver; + } + + if(!ret.cache) { + ret.cache = {}; + } + + // Either create or just recompute this: + ret.lookupTable = {}; + + var binding = exports.hasSimpleAPICommandBindings(gd, commandList, ret.lookupTable); + + if(container && container._commandObserver) { + if(!binding) { + // If container exists and there are no longer any bindings, + // remove existing: + if(container._commandObserver.remove) { + container._commandObserver.remove(); + container._commandObserver = null; + return ret; + } + } else { + // If container exists and there *are* bindings, then the lookup + // table should have been updated and check is already attached, + // so there's nothing to be done: + return ret; + + + } + } + + // Determine whether there's anything to do for this binding: + + if(binding) { + // Build the cache: + bindingValueHasChanged(gd, binding, ret.cache); + + ret.check = function check() { + if(!enabled) return; + + var update = bindingValueHasChanged(gd, binding, ret.cache); + + if(update.changed && onchange) { + // Disable checks for the duration of this command in order to avoid + // infinite loops: + if(ret.lookupTable[update.value] !== undefined) { + ret.disable(); + Promise.resolve(onchange({ + value: update.value, + type: binding.type, + prop: binding.prop, + traces: binding.traces, + index: ret.lookupTable[update.value] + })).then(ret.enable, ret.enable); + } + } + + return update.changed; + }; + + var checkEvents = [ + 'plotly_relayout', + 'plotly_redraw', + 'plotly_restyle', + 'plotly_update', + 'plotly_animatingframe', + 'plotly_afterplot' + ]; + + for(var i = 0; i < checkEvents.length; i++) { + gd._internalOn(checkEvents[i], ret.check); + } + + ret.remove = function() { + for(var i = 0; i < checkEvents.length; i++) { + gd._removeInternalListener(checkEvents[i], ret.check); + } + }; + } else { + // TODO: It'd be really neat to actually give a *reason* for this, but at least a warning + // is a start + Lib.warn('Unable to automatically bind plot updates to API command'); + + ret.lookupTable = {}; + ret.remove = function() {}; + } + + ret.disable = function disable() { + enabled = false; + }; + + ret.enable = function enable() { + enabled = true; + }; + + if(container) { + container._commandObserver = ret; + } + + return ret; +}; + +/* + * This function checks to see if an array of objects containing + * method and args properties is compatible with automatic two-way + * binding. The criteria right now are that + * + * 1. multiple traces may be affected + * 2. only one property may be affected + * 3. the same property must be affected by all commands + */ +exports.hasSimpleAPICommandBindings = function(gd, commandList, bindingsByValue) { + var n = commandList.length; + + var refBinding; + + for(var i = 0; i < n; i++) { + var binding; + var command = commandList[i]; + var method = command.method; + var args = command.args; + + // If any command has no method, refuse to bind: + if(!method) { + return false; + } + var bindings = exports.computeAPICommandBindings(gd, method, args); + + // Right now, handle one and *only* one property being set: + if(bindings.length !== 1) { + return false; + } + + if(!refBinding) { + refBinding = bindings[0]; + if(Array.isArray(refBinding.traces)) { + refBinding.traces.sort(); + } + } else { + binding = bindings[0]; + if(binding.type !== refBinding.type) { + return false; + } + if(binding.prop !== refBinding.prop) { + return false; + } + if(Array.isArray(refBinding.traces)) { + if(Array.isArray(binding.traces)) { + binding.traces.sort(); + for(var j = 0; j < refBinding.traces.length; j++) { + if(refBinding.traces[j] !== binding.traces[j]) { + return false; + } + } + } else { + return false; + } + } else { + if(binding.prop !== refBinding.prop) { + return false; + } + } + } + + binding = bindings[0]; + var value = binding.value; + if(Array.isArray(value)) { + value = value[0]; + } + if(bindingsByValue) { + bindingsByValue[value] = i; + } + } + + return refBinding; +}; + +function bindingValueHasChanged(gd, binding, cache) { + var container, value, obj; + var changed = false; + + if(binding.type === 'data') { + // If it's data, we need to get a trace. Based on the limited scope + // of what we cover, we can just take the first trace from the list, + // or otherwise just the first trace: + container = gd._fullData[binding.traces !== null ? binding.traces[0] : 0]; + } else if(binding.type === 'layout') { + container = gd._fullLayout; + } else { + return false; + } + + value = Lib.nestedProperty(container, binding.prop).get(); + + obj = cache[binding.type] = cache[binding.type] || {}; + + if(obj.hasOwnProperty(binding.prop)) { + if(obj[binding.prop] !== value) { + changed = true; + } + } + + obj[binding.prop] = value; + + return { + changed: changed, + value: value + }; +} + +/* + * Execute an API command. There's really not much to this; it just provides + * a common hook so that implementations don't need to be synchronized across + * multiple components with the ability to invoke API commands. + * + * @param {string} method + * The name of the plotly command to execute. Must be one of 'animate', + * 'restyle', 'relayout', 'update'. + * @param {array} args + * A list of arguments passed to the API command + */ +exports.executeAPICommand = function(gd, method, args) { + var apiMethod = Plotly[method]; + + var allArgs = [gd]; + for(var i = 0; i < args.length; i++) { + allArgs.push(args[i]); + } + + return apiMethod.apply(null, allArgs).catch(function(err) { + Lib.warn('API call to Plotly.' + method + ' rejected.', err); + return Promise.reject(err); + }); +}; + +exports.computeAPICommandBindings = function(gd, method, args) { + var bindings; + switch(method) { + case 'restyle': + bindings = computeDataBindings(gd, args); + break; + case 'relayout': + bindings = computeLayoutBindings(gd, args); + break; + case 'update': + bindings = computeDataBindings(gd, [args[0], args[2]]) + .concat(computeLayoutBindings(gd, [args[1]])); + break; + case 'animate': + bindings = computeAnimateBindings(gd, args); + break; + default: + // This is the case where intelligent logic about what affects + // this command is not implemented. It causes no ill effects. + // For example, addFrames simply won't bind to a control component. + bindings = []; + } + return bindings; +}; + +function computeAnimateBindings(gd, args) { + // We'll assume that the only relevant modification an animation + // makes that's meaningfully tracked is the frame: + if(Array.isArray(args[0]) && args[0].length === 1 && typeof args[0][0] === 'string') { + return [{type: 'layout', prop: '_currentFrame', value: args[0][0]}]; + } else { + return []; + } +} + +function computeLayoutBindings(gd, args) { + var bindings = []; + + var astr = args[0]; + var aobj = {}; + if(typeof astr === 'string') { + aobj[astr] = args[1]; + } else if(Lib.isPlainObject(astr)) { + aobj = astr; + } else { + return bindings; + } + + crawl(aobj, function(path, attrName, attr) { + bindings.push({type: 'layout', prop: path, value: attr}); + }, '', 0); + + return bindings; +} + +function computeDataBindings(gd, args) { + var traces, astr, val, aobj; + var bindings = []; + + // Logic copied from Plotly.restyle: + astr = args[0]; + val = args[1]; + traces = args[2]; + aobj = {}; + if(typeof astr === 'string') { + aobj[astr] = val; + } else if(Lib.isPlainObject(astr)) { + // the 3-arg form + aobj = astr; + + if(traces === undefined) { + traces = val; + } + } else { + return bindings; + } + + if(traces === undefined) { + // Explicitly assign this to null instead of undefined: + traces = null; + } + + crawl(aobj, function(path, attrName, attr) { + var thisTraces; + if(Array.isArray(attr)) { + var nAttr = Math.min(attr.length, gd.data.length); + if(traces) { + nAttr = Math.min(nAttr, traces.length); + } + thisTraces = []; + for(var j = 0; j < nAttr; j++) { + thisTraces[j] = traces ? traces[j] : j; + } + } else { + thisTraces = traces ? traces.slice(0) : null; + } + + // Convert [7] to just 7 when traces is null: + if(thisTraces === null) { + if(Array.isArray(attr)) { + attr = attr[0]; + } + } else if(Array.isArray(thisTraces)) { + if(!Array.isArray(attr)) { + var tmp = attr; + attr = []; + for(var i = 0; i < thisTraces.length; i++) { + attr[i] = tmp; + } + } + attr.length = Math.min(thisTraces.length, attr.length); + } + + bindings.push({ + type: 'data', + prop: path, + traces: thisTraces, + value: attr + }); + }, '', 0); + + return bindings; +} + +function crawl(attrs, callback, path, depth) { + Object.keys(attrs).forEach(function(attrName) { + var attr = attrs[attrName]; + + if(attrName[0] === '_') return; + + var thisPath = path + (depth > 0 ? '.' : '') + attrName; + + if(Lib.isPlainObject(attr)) { + crawl(attr, callback, thisPath, depth + 1); + } else { + // Only execute the callback on leaf nodes: + callback(thisPath, attrName, attr); + } + }); +} diff --git a/src/plots/plots.js b/src/plots/plots.js index 56aa8a9b696..5232397292b 100644 --- a/src/plots/plots.js +++ b/src/plots/plots.js @@ -38,6 +38,12 @@ var transformsRegistry = plots.transformsRegistry; var ErrorBars = require('../components/errorbars'); +var commandModule = require('./command'); +plots.executeAPICommand = commandModule.executeAPICommand; +plots.computeAPICommandBindings = commandModule.computeAPICommandBindings; +plots.manageCommandObserver = commandModule.manageCommandObserver; +plots.hasSimpleAPICommandBindings = commandModule.hasSimpleAPICommandBindings; + /** * Find subplot ids in data. * Meant to be used in the defaults step. diff --git a/test/image/baselines/binding.png b/test/image/baselines/binding.png new file mode 100644 index 00000000000..6c69d0ea29c Binary files /dev/null and b/test/image/baselines/binding.png differ diff --git a/test/image/mocks/binding.json b/test/image/mocks/binding.json new file mode 100644 index 00000000000..510090bd6bf --- /dev/null +++ b/test/image/mocks/binding.json @@ -0,0 +1,110 @@ +{ + "data": [ + { + "x": [0, 1, 2], + "y": [0.5, 1, 2.5] + } + ], + "layout": { + "sliders": [{ + "active": 0, + "steps": [{ + "label": "red", + "method": "restyle", + "args": [{"marker.color": "red"}] + }, { + "label": "orange", + "method": "restyle", + "args": [{"marker.color": "orange"}] + }, { + "label": "yellow", + "method": "restyle", + "args": [{"marker.color": "yellow"}] + }, { + "label": "green", + "method": "restyle", + "args": [{"marker.color": "green"}] + }, { + "label": "blue", + "method": "restyle", + "args": [{"marker.color": "blue"}] + }, { + "label": "purple", + "method": "restyle", + "args": [{"marker.color": "purple"}] + }], + "visible": true, + "x": 0, + "len": 1.0, + "xanchor": "left", + "y": 0, + "yanchor": "top", + "currentvalue": { + "visible": false + }, + + "transition": { + "duration": 150, + "easing": "cubic-in-out" + }, + + "pad": { + "r": 20, + "t": 40 + }, + + "font": {} + }], + "updatemenus": [{ + "active": 0, + "type": "buttons", + "buttons": [{ + "label": "red", + "method": "restyle", + "args": [{"marker.color": "red"}] + }, { + "label": "orange", + "method": "restyle", + "args": [{"marker.color": "orange"}] + }, { + "label": "yellow", + "method": "restyle", + "args": [{"marker.color": "yellow"}] + }, { + "label": "green", + "method": "restyle", + "args": [{"marker.color": "green"}] + }, { + "label": "blue", + "method": "restyle", + "args": [{"marker.color": "blue"}] + }, { + "label": "purple", + "method": "restyle", + "args": [{"marker.color": "purple"}] + }], + "visible": true, + "direction": "right", + "x": 0, + "xanchor": "left", + "y": 1.05, + "yanchor": "bottom", + "pad": { + "l": 20, + "t": 20 + } + }], + "xaxis": { + "range": [0, 2], + "autorange": true + }, + "yaxis": { + "type": "linear", + "range": [0, 3], + "autorange": true + }, + "height": 450, + "width": 1100, + "autosize": true + } +} diff --git a/test/jasmine/tests/command_test.js b/test/jasmine/tests/command_test.js new file mode 100644 index 00000000000..b88e2613c5a --- /dev/null +++ b/test/jasmine/tests/command_test.js @@ -0,0 +1,641 @@ +var Plotly = require('@lib/index'); +var PlotlyInternal = require('@src/plotly'); +var Plots = Plotly.Plots; +var createGraphDiv = require('../assets/create_graph_div'); +var destroyGraphDiv = require('../assets/destroy_graph_div'); +var fail = require('../assets/fail_test'); +var Lib = require('@src/lib'); + +describe('Plots.executeAPICommand', function() { + 'use strict'; + + var gd; + + beforeEach(function() { + gd = createGraphDiv(); + }); + + afterEach(function() { + destroyGraphDiv(gd); + }); + + describe('with a successful API command', function() { + beforeEach(function() { + spyOn(PlotlyInternal, 'restyle').and.callFake(function() { + return Promise.resolve('resolution'); + }); + }); + + it('calls the API method and resolves', function(done) { + Plots.executeAPICommand(gd, 'restyle', ['foo', 'bar']).then(function(value) { + var m = PlotlyInternal.restyle; + expect(m).toHaveBeenCalled(); + expect(m.calls.count()).toEqual(1); + expect(m.calls.argsFor(0)).toEqual([gd, 'foo', 'bar']); + + expect(value).toEqual('resolution'); + }).catch(fail).then(done); + }); + + }); + + describe('with an unsuccessful command', function() { + beforeEach(function() { + spyOn(PlotlyInternal, 'restyle').and.callFake(function() { + return Promise.reject('rejection'); + }); + }); + + it('calls the API method and rejects', function(done) { + Plots.executeAPICommand(gd, 'restyle', ['foo', 'bar']).then(fail, function(value) { + var m = PlotlyInternal.restyle; + expect(m).toHaveBeenCalled(); + expect(m.calls.count()).toEqual(1); + expect(m.calls.argsFor(0)).toEqual([gd, 'foo', 'bar']); + + expect(value).toEqual('rejection'); + }).catch(fail).then(done); + }); + + }); +}); + +describe('Plots.hasSimpleAPICommandBindings', function() { + 'use strict'; + var gd; + beforeEach(function() { + gd = createGraphDiv(); + + Plotly.plot(gd, [ + {x: [1, 2, 3], y: [1, 2, 3]}, + {x: [1, 2, 3], y: [4, 5, 6]}, + ]); + }); + + afterEach(function() { + destroyGraphDiv(gd); + }); + + it('return the binding when bindings are simple', function() { + var isSimple = Plots.hasSimpleAPICommandBindings(gd, [{ + method: 'restyle', + args: [{'marker.size': 10}] + }, { + method: 'restyle', + args: [{'marker.size': 20}] + }]); + + expect(isSimple).toEqual({ + type: 'data', + prop: 'marker.size', + traces: null, + value: 10 + }); + }); + + it('return false when properties are not the same', function() { + var isSimple = Plots.hasSimpleAPICommandBindings(gd, [{ + method: 'restyle', + args: [{'marker.size': 10}] + }, { + method: 'restyle', + args: [{'marker.color': 20}] + }]); + + expect(isSimple).toBe(false); + }); + + it('return false when a command binds to more than one property', function() { + var isSimple = Plots.hasSimpleAPICommandBindings(gd, [{ + method: 'restyle', + args: [{'marker.color': 10, 'marker.size': 12}] + }, { + method: 'restyle', + args: [{'marker.color': 20}] + }]); + + expect(isSimple).toBe(false); + }); + + it('return false when commands affect different traces', function() { + var isSimple = Plots.hasSimpleAPICommandBindings(gd, [{ + method: 'restyle', + args: [{'marker.color': 10}, [0]] + }, { + method: 'restyle', + args: [{'marker.color': 20}, [1]] + }]); + + expect(isSimple).toBe(false); + }); + + it('return the binding when commands affect the same traces', function() { + var isSimple = Plots.hasSimpleAPICommandBindings(gd, [{ + method: 'restyle', + args: [{'marker.color': 10}, [1]] + }, { + method: 'restyle', + args: [{'marker.color': 20}, [1]] + }]); + + expect(isSimple).toEqual({ + type: 'data', + prop: 'marker.color', + traces: [ 1 ], + value: [ 10 ] + }); + }); + + it('return the binding when commands affect the same traces in different order', function() { + var isSimple = Plots.hasSimpleAPICommandBindings(gd, [{ + method: 'restyle', + args: [{'marker.color': 10}, [1, 2]] + }, { + method: 'restyle', + args: [{'marker.color': 20}, [2, 1]] + }]); + + expect(isSimple).toEqual({ + type: 'data', + prop: 'marker.color', + traces: [ 1, 2 ], + value: [ 10, 10 ] + }); + }); +}); + +describe('Plots.computeAPICommandBindings', function() { + 'use strict'; + + var gd; + + beforeEach(function() { + gd = createGraphDiv(); + + Plotly.plot(gd, [ + {x: [1, 2, 3], y: [1, 2, 3]}, + {x: [1, 2, 3], y: [4, 5, 6]}, + ]); + }); + + afterEach(function() { + destroyGraphDiv(gd); + }); + + describe('restyle', function() { + describe('with invalid notation', function() { + it('with a scalar value', function() { + var result = Plots.computeAPICommandBindings(gd, 'restyle', [['x']]); + expect(result).toEqual([]); + }); + }); + + describe('with astr + val notation', function() { + describe('and a single attribute', function() { + it('with a scalar value', function() { + var result = Plots.computeAPICommandBindings(gd, 'restyle', ['marker.size', 7]); + expect(result).toEqual([{prop: 'marker.size', traces: null, type: 'data', value: 7}]); + }); + + it('with an array value and no trace specified', function() { + var result = Plots.computeAPICommandBindings(gd, 'restyle', ['marker.size', [7]]); + expect(result).toEqual([{prop: 'marker.size', traces: [0], type: 'data', value: [7]}]); + }); + + it('with trace specified', function() { + var result = Plots.computeAPICommandBindings(gd, 'restyle', ['marker.size', 7, [0]]); + expect(result).toEqual([{prop: 'marker.size', traces: [0], type: 'data', value: [7]}]); + }); + + it('with a different trace specified', function() { + var result = Plots.computeAPICommandBindings(gd, 'restyle', ['marker.size', 7, [0]]); + expect(result).toEqual([{prop: 'marker.size', traces: [0], type: 'data', value: [7]}]); + }); + + it('with an array value', function() { + var result = Plots.computeAPICommandBindings(gd, 'restyle', ['marker.size', [7], [1]]); + expect(result).toEqual([{prop: 'marker.size', traces: [1], type: 'data', value: [7]}]); + }); + + it('with two array values and two traces specified', function() { + var result = Plots.computeAPICommandBindings(gd, 'restyle', ['marker.size', [7, 5], [0, 1]]); + expect(result).toEqual([{prop: 'marker.size', traces: [0, 1], type: 'data', value: [7, 5]}]); + }); + + it('with traces specified in reverse order', function() { + var result = Plots.computeAPICommandBindings(gd, 'restyle', ['marker.size', [7, 5], [1, 0]]); + expect(result).toEqual([{prop: 'marker.size', traces: [1, 0], type: 'data', value: [7, 5]}]); + }); + + it('with two values and a single trace specified', function() { + var result = Plots.computeAPICommandBindings(gd, 'restyle', ['marker.size', [7, 5], [0]]); + expect(result).toEqual([{prop: 'marker.size', traces: [0], type: 'data', value: [7]}]); + }); + + it('with two values and a different trace specified', function() { + var result = Plots.computeAPICommandBindings(gd, 'restyle', ['marker.size', [7, 5], [1]]); + expect(result).toEqual([{prop: 'marker.size', traces: [1], type: 'data', value: [7]}]); + }); + }); + }); + + + describe('with aobj notation', function() { + describe('and a single attribute', function() { + it('with a scalar value', function() { + var result = Plots.computeAPICommandBindings(gd, 'restyle', [{'marker.size': 7}]); + expect(result).toEqual([{type: 'data', prop: 'marker.size', traces: null, value: 7}]); + }); + + it('with trace specified', function() { + var result = Plots.computeAPICommandBindings(gd, 'restyle', [{'marker.size': 7}, [0]]); + expect(result).toEqual([{type: 'data', prop: 'marker.size', traces: [0], value: [7]}]); + }); + + it('with a different trace specified', function() { + var result = Plots.computeAPICommandBindings(gd, 'restyle', [{'marker.size': 7}, [1]]); + expect(result).toEqual([{type: 'data', prop: 'marker.size', traces: [1], value: [7]}]); + }); + + it('with an array value', function() { + var result = Plots.computeAPICommandBindings(gd, 'restyle', [{'marker.size': [7]}, [1]]); + expect(result).toEqual([{type: 'data', prop: 'marker.size', traces: [1], value: [7]}]); + }); + + it('with two array values and two traces specified', function() { + var result = Plots.computeAPICommandBindings(gd, 'restyle', [{'marker.size': [7, 5]}, [0, 1]]); + expect(result).toEqual([{type: 'data', prop: 'marker.size', traces: [0, 1], value: [7, 5]}]); + }); + + it('with traces specified in reverse order', function() { + var result = Plots.computeAPICommandBindings(gd, 'restyle', [{'marker.size': [7, 5]}, [1, 0]]); + expect(result).toEqual([{type: 'data', prop: 'marker.size', traces: [1, 0], value: [7, 5]}]); + }); + + it('with two values and a single trace specified', function() { + var result = Plots.computeAPICommandBindings(gd, 'restyle', [{'marker.size': [7, 5]}, [0]]); + expect(result).toEqual([{type: 'data', prop: 'marker.size', traces: [0], value: [7]}]); + }); + + it('with two values and a different trace specified', function() { + var result = Plots.computeAPICommandBindings(gd, 'restyle', [{'marker.size': [7, 5]}, [1]]); + expect(result).toEqual([{type: 'data', prop: 'marker.size', traces: [1], value: [7]}]); + }); + }); + + describe('and multiple attributes', function() { + it('with a scalar value', function() { + var result = Plots.computeAPICommandBindings(gd, 'restyle', [{'marker.size': 7, 'text.color': 'blue'}]); + expect(result).toEqual([ + {type: 'data', prop: 'marker.size', traces: null, value: 7}, + {type: 'data', prop: 'text.color', traces: null, value: 'blue'} + ]); + }); + }); + }); + + describe('with mixed notation', function() { + it('and nested object and nested attr', function() { + var result = Plots.computeAPICommandBindings(gd, 'restyle', [{ + y: [[3, 4, 5]], + 'marker.size': [10, 20, 25], + 'line.color': 'red', + line: { + width: [2, 8] + } + }]); + + // The results are definitely not completely intuitive, so this + // is based upon empirical results with a codepen example: + expect(result).toEqual([ + {type: 'data', prop: 'y', traces: [0], value: [[3, 4, 5]]}, + {type: 'data', prop: 'marker.size', traces: [0, 1], value: [10, 20]}, + {type: 'data', prop: 'line.color', traces: null, value: 'red'}, + {type: 'data', prop: 'line.width', traces: [0, 1], value: [2, 8]} + ]); + }); + + it('and traces specified', function() { + var result = Plots.computeAPICommandBindings(gd, 'restyle', [{ + y: [[3, 4, 5]], + 'marker.size': [10, 20, 25], + 'line.color': 'red', + line: { + width: [2, 8] + } + }, [1, 0]]); + + expect(result).toEqual([ + {type: 'data', prop: 'y', traces: [1], value: [[3, 4, 5]]}, + {type: 'data', prop: 'marker.size', traces: [1, 0], value: [10, 20]}, + + // This result is actually not quite correct. Setting `line` should override + // this—or actually it's technically undefined since the iteration order of + // objects is not strictly defined but is at least consistent across browsers. + // The worst-case scenario right now isn't too bad though since it's an obscure + // case that will definitely cause bailout anyway before any bindings would + // happen. + {type: 'data', prop: 'line.color', traces: [1, 0], value: ['red', 'red']}, + + {type: 'data', prop: 'line.width', traces: [1, 0], value: [2, 8]} + ]); + }); + + it('and more data than traces', function() { + var result = Plots.computeAPICommandBindings(gd, 'restyle', [{ + y: [[3, 4, 5]], + 'marker.size': [10, 20, 25], + 'line.color': 'red', + line: { + width: [2, 8] + } + }, [1]]); + + expect(result).toEqual([ + {type: 'data', prop: 'y', traces: [1], value: [[3, 4, 5]]}, + {type: 'data', prop: 'marker.size', traces: [1], value: [10]}, + {type: 'data', prop: 'line.color', traces: [1], value: ['red']}, + {type: 'data', prop: 'line.width', traces: [1], value: [2]} + ]); + }); + }); + }); + + describe('relayout', function() { + describe('with invalid notation', function() { + it('and a scalar value', function() { + var result = Plots.computeAPICommandBindings(gd, 'relayout', [['x']]); + expect(result).toEqual([]); + }); + }); + + describe('with aobj notation', function() { + it('and a single attribute', function() { + var result = Plots.computeAPICommandBindings(gd, 'relayout', [{height: 500}]); + expect(result).toEqual([{type: 'layout', prop: 'height', value: 500}]); + }); + + it('and two attributes', function() { + var result = Plots.computeAPICommandBindings(gd, 'relayout', [{height: 500, width: 100}]); + expect(result).toEqual([{type: 'layout', prop: 'height', value: 500}, {type: 'layout', prop: 'width', value: 100}]); + }); + }); + + describe('with astr + val notation', function() { + it('and an attribute', function() { + var result = Plots.computeAPICommandBindings(gd, 'relayout', ['width', 100]); + expect(result).toEqual([{type: 'layout', prop: 'width', value: 100}]); + }); + + it('and nested atributes', function() { + var result = Plots.computeAPICommandBindings(gd, 'relayout', ['margin.l', 10]); + expect(result).toEqual([{type: 'layout', prop: 'margin.l', value: 10}]); + }); + }); + + describe('with mixed notation', function() { + it('containing aob + astr', function() { + var result = Plots.computeAPICommandBindings(gd, 'relayout', [{ + 'width': 100, + 'margin.l': 10 + }]); + expect(result).toEqual([ + {type: 'layout', prop: 'width', value: 100}, + {type: 'layout', prop: 'margin.l', value: 10} + ]); + }); + }); + }); + + describe('update', function() { + it('computes bindings', function() { + var result = Plots.computeAPICommandBindings(gd, 'update', [{ + y: [[3, 4, 5]], + 'marker.size': [10, 20, 25], + 'line.color': 'red', + line: { + width: [2, 8] + } + }, { + 'margin.l': 50, + width: 10 + }, [1]]); + + expect(result).toEqual([ + {type: 'data', prop: 'y', traces: [1], value: [[3, 4, 5]]}, + {type: 'data', prop: 'marker.size', traces: [1], value: [10]}, + {type: 'data', prop: 'line.color', traces: [1], value: ['red']}, + {type: 'data', prop: 'line.width', traces: [1], value: [2]}, + {type: 'layout', prop: 'margin.l', value: 50}, + {type: 'layout', prop: 'width', value: 10} + ]); + }); + }); + + describe('animate', function() { + it('binds to the frame for a simple animate command', function() { + var result = Plots.computeAPICommandBindings(gd, 'animate', [['framename']]); + + expect(result).toEqual([{type: 'layout', prop: '_currentFrame', value: 'framename'}]); + }); + + it('binds to nothing for a multi-frame animate command', function() { + var result = Plots.computeAPICommandBindings(gd, 'animate', [['frame1', 'frame2']]); + + expect(result).toEqual([]); + }); + }); +}); + +describe('component bindings', function() { + 'use strict'; + + var gd; + var mock = require('@mocks/binding.json'); + + beforeEach(function(done) { + var mockCopy = Lib.extendDeep({}, mock); + gd = createGraphDiv(); + + Plotly.plot(gd, mockCopy.data, mockCopy.layout).then(done); + }); + + afterEach(function() { + destroyGraphDiv(gd); + }); + + it('creates an observer', function(done) { + var count = 0; + Plots.manageCommandObserver(gd, {}, [ + { method: 'restyle', args: ['marker.color', 'red'] }, + { method: 'restyle', args: ['marker.color', 'green'] } + ], function(data) { + count++; + expect(data.index).toEqual(1); + }); + + // Doesn't trigger the callback: + Plotly.relayout(gd, 'width', 400).then(function() { + // Triggers the callback: + return Plotly.restyle(gd, 'marker.color', 'green'); + }).then(function() { + // Doesn't trigger a callback: + return Plotly.restyle(gd, 'marker.width', 8); + }).then(function() { + expect(count).toEqual(1); + }).catch(fail).then(done); + }); + + it('logs a warning if unable to create an observer', function() { + var warnings = 0; + spyOn(Lib, 'warn').and.callFake(function() { + warnings++; + }); + + Plots.manageCommandObserver(gd, {}, [ + { method: 'restyle', args: ['marker.color', 'red'] }, + { method: 'restyle', args: [{'line.color': 'green', 'marker.color': 'green'}] } + ]); + + expect(warnings).toEqual(1); + }); + + it('udpates bound components when the value changes', function(done) { + expect(gd.layout.sliders[0].active).toBe(0); + + Plotly.restyle(gd, 'marker.color', 'blue').then(function() { + expect(gd.layout.sliders[0].active).toBe(4); + }).catch(fail).then(done); + }); + + it('udpates bound components when the computed value changes', function(done) { + expect(gd.layout.sliders[0].active).toBe(0); + + // The default line color comes from the marker color, if specified. + // That is, the fact that the marker color changes is just incidental, but + // nonetheless is bound by value to the component. + Plotly.restyle(gd, 'line.color', 'blue').then(function() { + expect(gd.layout.sliders[0].active).toBe(4); + }).catch(fail).then(done); + }); +}); + +describe('attaching component bindings', function() { + 'use strict'; + var gd; + + beforeEach(function(done) { + gd = createGraphDiv(); + Plotly.plot(gd, [{x: [1, 2, 3], y: [1, 2, 3]}]).then(done); + }); + + afterEach(function() { + destroyGraphDiv(gd); + }); + + it('attaches and updates bindings for sliders', function(done) { + expect(gd._internalEv._events.plotly_animatingframe).toBeUndefined(); + + Plotly.relayout(gd, { + sliders: [{ + // This one gets bindings: + steps: [ + {label: 'first', method: 'restyle', args: ['marker.color', 'red']}, + {label: 'second', method: 'restyle', args: ['marker.color', 'blue']}, + ] + }, { + // This one does *not*: + steps: [ + {label: 'first', method: 'restyle', args: ['line.color', 'red']}, + {label: 'second', method: 'restyle', args: ['marker.color', 'blue']}, + ] + }] + }).then(function() { + // Check that it has attached a listener: + expect(typeof gd._internalEv._events.plotly_animatingframe).toBe('function'); + + // Confirm the first position is selected: + expect(gd.layout.sliders[0].active).toBeUndefined(); + + // Modify the plot + return Plotly.restyle(gd, {'marker.color': 'blue'}); + }).then(function() { + // Confirm that this has changed the slider position: + expect(gd.layout.sliders[0].active).toBe(1); + + // Swap the values of the components: + return Plotly.relayout(gd, { + 'sliders[0].steps[0].args[1]': 'green', + 'sliders[0].steps[1].args[1]': 'red' + }); + }).then(function() { + return Plotly.restyle(gd, {'marker.color': 'green'}); + }).then(function() { + // Confirm that the lookup table has been updated: + expect(gd.layout.sliders[0].active).toBe(0); + + // Check that it still has one attached listener: + expect(typeof gd._internalEv._events.plotly_animatingframe).toBe('function'); + + // Change this to a non-simple binding: + return Plotly.relayout(gd, {'sliders[0].steps[0].args[0]': 'line.color'}); + }).then(function() { + // Bindings are no longer simple, so check to ensure they have + // been removed + expect(gd._internalEv._events.plotly_animatingframe).toBeUndefined(); + }).catch(fail).then(done); + }); + + it('attaches and updates bindings for updatemenus', function(done) { + expect(gd._internalEv._events.plotly_animatingframe).toBeUndefined(); + + Plotly.relayout(gd, { + updatemenus: [{ + // This one gets bindings: + buttons: [ + {label: 'first', method: 'restyle', args: ['marker.color', 'red']}, + {label: 'second', method: 'restyle', args: ['marker.color', 'blue']}, + ] + }, { + // This one does *not*: + buttons: [ + {label: 'first', method: 'restyle', args: ['line.color', 'red']}, + {label: 'second', method: 'restyle', args: ['marker.color', 'blue']}, + ] + }] + }).then(function() { + // Check that it has attached a listener: + expect(typeof gd._internalEv._events.plotly_animatingframe).toBe('function'); + + // Confirm the first position is selected: + expect(gd.layout.updatemenus[0].active).toBeUndefined(); + + // Modify the plot + return Plotly.restyle(gd, {'marker.color': 'blue'}); + }).then(function() { + // Confirm that this has changed the slider position: + expect(gd.layout.updatemenus[0].active).toBe(1); + + // Swap the values of the components: + return Plotly.relayout(gd, { + 'updatemenus[0].buttons[0].args[1]': 'green', + 'updatemenus[0].buttons[1].args[1]': 'red' + }); + }).then(function() { + return Plotly.restyle(gd, {'marker.color': 'green'}); + }).then(function() { + // Confirm that the lookup table has been updated: + expect(gd.layout.updatemenus[0].active).toBe(0); + + // Check that it still has one attached listener: + expect(typeof gd._internalEv._events.plotly_animatingframe).toBe('function'); + + // Change this to a non-simple binding: + return Plotly.relayout(gd, {'updatemenus[0].buttons[0].args[0]': 'line.color'}); + }).then(function() { + // Bindings are no longer simple, so check to ensure they have + // been removed + expect(gd._internalEv._events.plotly_animatingframe).toBeUndefined(); + }).catch(fail).then(done); + }); +}); diff --git a/test/jasmine/tests/sliders_test.js b/test/jasmine/tests/sliders_test.js index f23a6926f4e..58b328c3272 100644 --- a/test/jasmine/tests/sliders_test.js +++ b/test/jasmine/tests/sliders_test.js @@ -182,7 +182,34 @@ describe('sliders defaults', function() { }); }); -describe('update sliders interactions', function() { +describe('sliders initialization', function() { + 'use strict'; + var gd; + + beforeEach(function(done) { + gd = createGraphDiv(); + + Plotly.plot(gd, [{x: [1, 2, 3]}], { + sliders: [{ + steps: [ + {method: 'restyle', args: [], label: 'first'}, + {method: 'restyle', args: [], label: 'second'}, + ] + }] + }).then(done); + }); + + afterEach(function() { + Plotly.purge(gd); + destroyGraphDiv(); + }); + + it('does not set active on initial plot', function() { + expect(gd.layout.sliders[0].active).toBeUndefined(); + }); +}); + +describe('sliders interactions', function() { 'use strict'; var mock = require('@mocks/sliders.json'); diff --git a/test/jasmine/tests/updatemenus_test.js b/test/jasmine/tests/updatemenus_test.js index f3e1e95a06e..e7d22f73d29 100644 --- a/test/jasmine/tests/updatemenus_test.js +++ b/test/jasmine/tests/updatemenus_test.js @@ -225,6 +225,33 @@ describe('update menus buttons', function() { } }); +describe('update menus initialization', function() { + 'use strict'; + var gd; + + beforeEach(function(done) { + gd = createGraphDiv(); + + Plotly.plot(gd, [{x: [1, 2, 3]}], { + updatemenus: [{ + buttons: [ + {method: 'restyle', args: [], label: 'first'}, + {method: 'restyle', args: [], label: 'second'}, + ] + }] + }).then(done); + }); + + afterEach(function() { + Plotly.purge(gd); + destroyGraphDiv(); + }); + + it('does not set active on initial plot', function() { + expect(gd.layout.updatemenus[0].active).toBeUndefined(); + }); +}); + describe('update menus interactions', function() { 'use strict';