From f2d268d06fbaa5098b503e1130bacfd7f0e06f63 Mon Sep 17 00:00:00 2001 From: Ricky Reusser Date: Tue, 4 Oct 2016 18:40:24 -0400 Subject: [PATCH 01/28] Start implementing command execution wrapper --- src/plots/command.js | 59 ++++++++++++++++++++ src/plots/plots.js | 4 ++ test/jasmine/tests/command_test.js | 87 ++++++++++++++++++++++++++++++ 3 files changed, 150 insertions(+) create mode 100644 src/plots/command.js create mode 100644 test/jasmine/tests/command_test.js diff --git a/src/plots/command.js b/src/plots/command.js new file mode 100644 index 00000000000..d78b5907d7d --- /dev/null +++ b/src/plots/command.js @@ -0,0 +1,59 @@ +/** +* 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 helpers = require('../plot_api/helpers'); + +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]); + } + + if (!apiMethod) { + return Promise.reject(); + } + + return apiMethod.apply(null, allArgs); +}; + +exports.computeAPICommandBindings = function(gd, method, args) { + + switch(method) { + case 'restyle': + var traces + + // Logic copied from Plotly.restyle: + var astr = args[0]; + var val = args[1]; + var traces = args[2]; + var 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 { + // This is the failure case, but it's not a concern of this method to fail + // on bad input. This will just return no bindings: + return []; + } + + console.log('aobj:', aobj); + + return ['data[0].marker.size']; + break; + default: + throw new Error('Unimplemented'); + } +}; diff --git a/src/plots/plots.js b/src/plots/plots.js index 195981f6185..29d94ad0d85 100644 --- a/src/plots/plots.js +++ b/src/plots/plots.js @@ -36,6 +36,10 @@ var transformsRegistry = plots.transformsRegistry; var ErrorBars = require('../components/errorbars'); +var commandModule = require('./command'); +plots.executeAPICommand = commandModule.executeAPICommand; +plots.computeAPICommandBindings = commandModule.computeAPICommandBindings; + /** * Find subplot ids in data. * Meant to be used in the defaults step. diff --git a/test/jasmine/tests/command_test.js b/test/jasmine/tests/command_test.js new file mode 100644 index 00000000000..ae8c65218c1 --- /dev/null +++ b/test/jasmine/tests/command_test.js @@ -0,0 +1,87 @@ +var Plotly = require('@lib/index'); +var PlotlyInternal = require('@src/plotly'); +var Lib = require('@src/lib'); +var Plots = Plotly.Plots; +var createGraphDiv = require('../assets/create_graph_div'); +var destroyGraphDiv = require('../assets/destroy_graph_div'); +var fail = require('../assets/fail_test'); + +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.computeAPICommandBindings', function() { + 'use strict'; + + var gd; + + beforeEach(function() { + gd = createGraphDiv(); + + Plotly.plot(gd, [{x: [1, 2, 3], y: [4, 5, 6]}]); + }); + + afterEach(function() { + destroyGraphDiv(gd); + }); + + describe('restyle', function() { + describe('astr + val notation', function() { + it('computes the binding', function() { + var result = Plots.computeAPICommandBindings(gd, 'restyle', ['marker.size', 7]); + + expect(result).toEqual(['data[0].marker.size']); + }); + }); + }); +}); From 65f618dc1960fa23c9483e97d3b3d5e204bedc93 Mon Sep 17 00:00:00 2001 From: Ricky Reusser Date: Thu, 6 Oct 2016 11:36:43 -0400 Subject: [PATCH 02/28] Write failing tests for binding computation --- src/plots/command.js | 60 ++++++++++------ test/jasmine/tests/command_test.js | 107 +++++++++++++++++++++++++++-- 2 files changed, 141 insertions(+), 26 deletions(-) diff --git a/src/plots/command.js b/src/plots/command.js index d78b5907d7d..f5deacba6a3 100644 --- a/src/plots/command.js +++ b/src/plots/command.js @@ -10,17 +10,18 @@ 'use strict'; var Plotly = require('../plotly'); +var Lib = require('../lib'); var helpers = require('../plot_api/helpers'); exports.executeAPICommand = function(gd, method, args) { var apiMethod = Plotly[method]; var allArgs = [gd]; - for (var i = 0; i < args.length; i++) { + for(var i = 0; i < args.length; i++) { allArgs.push(args[i]); } - if (!apiMethod) { + if(!apiMethod) { return Promise.reject(); } @@ -30,30 +31,49 @@ exports.executeAPICommand = function(gd, method, args) { exports.computeAPICommandBindings = function(gd, method, args) { switch(method) { - case 'restyle': - var traces + case 'restyle': + var traces; // Logic copied from Plotly.restyle: - var astr = args[0]; - var val = args[1]; - var traces = args[2]; - var aobj = {}; - if(typeof astr === 'string') aobj[astr] = val; - else if(Lib.isPlainObject(astr)) { + var astr = args[0]; + var val = args[1]; + var traces = args[2]; + var 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 { + aobj = astr; + if(traces === undefined) traces = val; + } else { // This is the failure case, but it's not a concern of this method to fail // on bad input. This will just return no bindings: - return []; - } + return []; + } - console.log('aobj:', aobj); + console.log('aobj:', aobj); - return ['data[0].marker.size']; - break; - default: - throw new Error('Unimplemented'); + return ['data[0].marker.size']; + break; + default: + // The unknown case. We'll elect to fail-non-fatal since this is a correct + // answer and since this is not a validation method. + return []; } }; + +function crawl(attrs, callback, path) { + if(path === undefined) { + path = ''; + } + + Object.keys(attrs).forEach(function(attrName) { + var attr = attrs[attrName]; + + if(exports.UNDERSCORE_ATTRS.indexOf(attrName) !== -1) return; + + callback(attr, attrName, attrs, level); + + if(isValObject(attr)) return; + if(isPlainObject(attr)) crawl(attr, callback, level + 1); + }); +} diff --git a/test/jasmine/tests/command_test.js b/test/jasmine/tests/command_test.js index ae8c65218c1..c430db07e18 100644 --- a/test/jasmine/tests/command_test.js +++ b/test/jasmine/tests/command_test.js @@ -23,7 +23,7 @@ describe('Plots.executeAPICommand', function() { beforeEach(function() { spyOn(PlotlyInternal, 'restyle').and.callFake(function() { return Promise.resolve('resolution'); - }) + }); }); it('calls the API method and resolves', function(done) { @@ -43,7 +43,7 @@ describe('Plots.executeAPICommand', function() { beforeEach(function() { spyOn(PlotlyInternal, 'restyle').and.callFake(function() { return Promise.reject('rejection'); - }) + }); }); it('calls the API method and rejects', function(done) { @@ -68,7 +68,10 @@ describe('Plots.computeAPICommandBindings', function() { beforeEach(function() { gd = createGraphDiv(); - Plotly.plot(gd, [{x: [1, 2, 3], y: [4, 5, 6]}]); + Plotly.plot(gd, [ + {x: [1, 2, 3], y: [1, 2, 3]}, + {x: [1, 2, 3], y: [4, 5, 6]}, + ]); }); afterEach(function() { @@ -77,10 +80,102 @@ describe('Plots.computeAPICommandBindings', function() { describe('restyle', function() { describe('astr + val notation', function() { - it('computes the binding', function() { - var result = Plots.computeAPICommandBindings(gd, 'restyle', ['marker.size', 7]); + describe('with a single attribute', function() { + it('with a scalar value', function() { + var result = Plots.computeAPICommandBindings(gd, 'restyle', ['marker.size', 7]); + expect(result).toEqual(['data[0].marker.size', 'data[1].marker.size']); + }); + + it('with an array value and no trace specified', function() { + var result = Plots.computeAPICommandBindings(gd, 'restyle', ['marker.size', [7]]); + expect(result).toEqual(['data[0].marker.size', 'data[1].marker.size']); + }); + + it('with trace specified', function() { + var result = Plots.computeAPICommandBindings(gd, 'restyle', ['marker.size', 7, [0]]); + expect(result).toEqual(['data[0].marker.size']); + }); + + it('with a different trace specified', function() { + var result = Plots.computeAPICommandBindings(gd, 'restyle', ['marker.size', 7, [1]]); + expect(result).toEqual(['data[1].marker.size']); + }); + + it('with an array value', function() { + var result = Plots.computeAPICommandBindings(gd, 'restyle', ['marker.size', [7], [0]]); + expect(result).toEqual(['data[1].marker.size']); + }); + + 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(['data[0].marker.size', 'data[1].marker.size']); + }); + + it('with traces specified in reverse order', function() { + var result = Plots.computeAPICommandBindings(gd, 'restyle', ['marker.size', [7, 5], [1, 0]]); + expect(result).toEqual(['data[1].marker.size', 'data[0].marker.size']); + }); + + it('with two values and a single trace specified', function() { + var result = Plots.computeAPICommandBindings(gd, 'restyle', ['marker.size', [7, 5], [0]]); + expect(result).toEqual(['data[0].marker.size']); + }); + + it('with two values and a different trace specified', function() { + var result = Plots.computeAPICommandBindings(gd, 'restyle', ['marker.size', [7, 5], [1]]); + expect(result).toEqual(['data[1].marker.size']); + }); + }); + }); + + describe('aobj notation', function() { + describe('with a single attribute', function() { + it('with a scalar value', function() { + var result = Plots.computeAPICommandBindings(gd, 'restyle', [{'marker.size': 7}]); + expect(result).toEqual(['data[0].marker.size', 'data[1].marker.size']); + }); + + it('with trace specified', function() { + var result = Plots.computeAPICommandBindings(gd, 'restyle', [{'marker.size': 7}, [0]]); + expect(result).toEqual(['data[0].marker.size']); + }); + + it('with a different trace specified', function() { + var result = Plots.computeAPICommandBindings(gd, 'restyle', [{'marker.size': 7}, [1]]); + expect(result).toEqual(['data[1].marker.size']); + }); + + it('with an array value', function() { + var result = Plots.computeAPICommandBindings(gd, 'restyle', [{'marker.size': [7]}, [0]]); + expect(result).toEqual(['data[1].marker.size']); + }); + + 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(['data[0].marker.size', 'data[1].marker.size']); + }); + + it('with traces specified in reverse order', function() { + var result = Plots.computeAPICommandBindings(gd, 'restyle', [{'marker.size': [7, 5]}, [1, 0]]); + expect(result).toEqual(['data[1].marker.size', 'data[0].marker.size']); + }); + + it('with two values and a single trace specified', function() { + var result = Plots.computeAPICommandBindings(gd, 'restyle', [{'marker.size': [7, 5]}, [0]]); + expect(result).toEqual(['data[0].marker.size']); + }); + + it('with two values and a different trace specified', function() { + var result = Plots.computeAPICommandBindings(gd, 'restyle', [{'marker.size': [7, 5]}, [1]]); + expect(result).toEqual(['data[1].marker.size']); + }); + }); - expect(result).toEqual(['data[0].marker.size']); + describe('with multiple attributes', function() { + it('with a scalar value', function() { + var result = Plots.computeAPICommandBindings(gd, 'restyle', [{'marker.size': 7, 'text.color': 'blue'}]); + expect(result).toEqual(['data[0].marker.size', 'data[1].marker.size', 'data[0].text.color', 'data[1].text.color']); + }); }); }); }); From 7ef04040cd021567b2b0b2692ee73061a292712f Mon Sep 17 00:00:00 2001 From: Ricky Reusser Date: Mon, 10 Oct 2016 18:16:00 -0400 Subject: [PATCH 03/28] Add a lot of tests for binding computation --- src/plots/command.js | 120 ++++++++++++++++++------- test/jasmine/tests/command_test.js | 138 ++++++++++++++++++++++++++--- 2 files changed, 218 insertions(+), 40 deletions(-) diff --git a/src/plots/command.js b/src/plots/command.js index f5deacba6a3..1861ed98bc0 100644 --- a/src/plots/command.js +++ b/src/plots/command.js @@ -29,39 +29,95 @@ exports.executeAPICommand = function(gd, method, args) { }; exports.computeAPICommandBindings = function(gd, method, args) { - + var bindings; switch(method) { case 'restyle': - var traces; - - // Logic copied from Plotly.restyle: - var astr = args[0]; - var val = args[1]; - var traces = args[2]; - var 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 { - // This is the failure case, but it's not a concern of this method to fail - // on bad input. This will just return no bindings: - return []; - } - - console.log('aobj:', aobj); - - return ['data[0].marker.size']; + bindings = computeDataBindings(gd, args); + break; + case 'relayout': + bindings = computeLayoutBindings(gd, args); + break; + case 'animate': + bindings = computeDataBindings(gd, [args[0], args[2]]) + .concat(computeLayoutBindings(gd, [args[1]])); break; default: - // The unknown case. We'll elect to fail-non-fatal since this is a correct - // answer and since this is not a validation method. - return []; + // We'll elect to fail-non-fatal since this is a correct + // answer and since this is not a validation method. + bindings = []; } + return bindings; }; -function crawl(attrs, callback, path) { +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('layout' + path); + }); + + return bindings; +} + +function computeDataBindings (gd, args) { + var i, traces, astr, attr, val, traces, 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) { + traces = []; + for (i = 0; i < gd.data.length; i++) { + traces.push(i); + } + } + + crawl(aobj, function (path, attrName, attr) { + var nAttr; + if (Array.isArray(attr)) { + nAttr = Math.min(attr.length, traces.length); + } else { + nAttr = traces.length; + } + for (var j = 0; j < nAttr; j++) { + bindings.push('data[' + traces[j] + ']' + path); + } + }); + + return bindings; +} + +function crawl(attrs, callback, path, depth) { + if(depth === undefined) { + depth = 0; + } + if(path === undefined) { path = ''; } @@ -69,11 +125,15 @@ function crawl(attrs, callback, path) { Object.keys(attrs).forEach(function(attrName) { var attr = attrs[attrName]; - if(exports.UNDERSCORE_ATTRS.indexOf(attrName) !== -1) return; + if(attrName[0] === '_') return; - callback(attr, attrName, attrs, level); + var thisPath = path + '.' + attrName; - if(isValObject(attr)) return; - if(isPlainObject(attr)) crawl(attr, callback, level + 1); + 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/test/jasmine/tests/command_test.js b/test/jasmine/tests/command_test.js index c430db07e18..b1b0a1301df 100644 --- a/test/jasmine/tests/command_test.js +++ b/test/jasmine/tests/command_test.js @@ -79,8 +79,15 @@ describe('Plots.computeAPICommandBindings', function() { }); describe('restyle', function() { - describe('astr + val notation', function() { - describe('with a single attribute', 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(['data[0].marker.size', 'data[1].marker.size']); @@ -88,7 +95,7 @@ describe('Plots.computeAPICommandBindings', function() { it('with an array value and no trace specified', function() { var result = Plots.computeAPICommandBindings(gd, 'restyle', ['marker.size', [7]]); - expect(result).toEqual(['data[0].marker.size', 'data[1].marker.size']); + expect(result).toEqual(['data[0].marker.size']); }); it('with trace specified', function() { @@ -97,12 +104,12 @@ describe('Plots.computeAPICommandBindings', function() { }); it('with a different trace specified', function() { - var result = Plots.computeAPICommandBindings(gd, 'restyle', ['marker.size', 7, [1]]); - expect(result).toEqual(['data[1].marker.size']); + var result = Plots.computeAPICommandBindings(gd, 'restyle', ['marker.size', 7, [0]]); + expect(result).toEqual(['data[0].marker.size']); }); it('with an array value', function() { - var result = Plots.computeAPICommandBindings(gd, 'restyle', ['marker.size', [7], [0]]); + var result = Plots.computeAPICommandBindings(gd, 'restyle', ['marker.size', [7], [1]]); expect(result).toEqual(['data[1].marker.size']); }); @@ -128,8 +135,8 @@ describe('Plots.computeAPICommandBindings', function() { }); }); - describe('aobj notation', function() { - describe('with a single attribute', function() { + 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(['data[0].marker.size', 'data[1].marker.size']); @@ -146,7 +153,7 @@ describe('Plots.computeAPICommandBindings', function() { }); it('with an array value', function() { - var result = Plots.computeAPICommandBindings(gd, 'restyle', [{'marker.size': [7]}, [0]]); + var result = Plots.computeAPICommandBindings(gd, 'restyle', [{'marker.size': [7]}, [1]]); expect(result).toEqual(['data[1].marker.size']); }); @@ -171,12 +178,123 @@ describe('Plots.computeAPICommandBindings', function() { }); }); - describe('with multiple attributes', function() { + 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(['data[0].marker.size', 'data[1].marker.size', 'data[0].text.color', 'data[1].text.color']); }); }); }); + + 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([ + 'data[0].y', + 'data[0].marker.size', + 'data[1].marker.size', + 'data[0].line.color', + 'data[1].line.color', + 'data[0].line.width', + 'data[1].line.width', + ]); + }); + + 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]]); + + // The results are definitely not completely intuitive, so this + // is based upon empirical results with a codepen example: + expect(result).toEqual([ + 'data[1].y', + 'data[1].marker.size', + 'data[0].marker.size', + 'data[1].line.color', + 'data[0].line.color', + 'data[1].line.width', + 'data[0].line.width', + ]); + }); + + 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]]); + + // The results are definitely not completely intuitive, so this + // is based upon empirical results with a codepen example: + expect(result).toEqual([ + 'data[1].y', + 'data[1].marker.size', + 'data[1].line.color', + 'data[1].line.width', + ]); + }); + }); + }); + + 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(['layout.height']); + }); + + it('and two attributes', function () { + var result = Plots.computeAPICommandBindings(gd, 'relayout', [{height: 500, width: 100}]); + expect(result).toEqual(['layout.height', 'layout.width']); + }); + }); + + describe('with astr + val notation', function() { + it('and an attribute', function () { + var result = Plots.computeAPICommandBindings(gd, 'relayout', ['width', 100]); + expect(result).toEqual(['layout.width']); + }); + + it('and nested atributes', function () { + var result = Plots.computeAPICommandBindings(gd, 'relayout', ['margin.l', 10]); + expect(result).toEqual(['layout.margin.l']); + }); + }); + + describe('with mixed notation', function() { + it('containing aob + astr', function () { + var result = Plots.computeAPICommandBindings(gd, 'relayout', [{ + 'width': 100, + 'margin.l': 10 + }]); + expect(result).toEqual(['layout.width', 'layout.margin.l']); + }); + }); }); }); From edb6773912591da6e2270939cea821924831f7a4 Mon Sep 17 00:00:00 2001 From: Ricky Reusser Date: Mon, 10 Oct 2016 18:24:36 -0400 Subject: [PATCH 04/28] Implement animate and update binding computation --- src/plots/command.js | 35 +++++++++++----------- test/jasmine/tests/command_test.js | 48 +++++++++++++++++++++++------- 2 files changed, 56 insertions(+), 27 deletions(-) diff --git a/src/plots/command.js b/src/plots/command.js index 1861ed98bc0..070761b9626 100644 --- a/src/plots/command.js +++ b/src/plots/command.js @@ -11,7 +11,6 @@ var Plotly = require('../plotly'); var Lib = require('../lib'); -var helpers = require('../plot_api/helpers'); exports.executeAPICommand = function(gd, method, args) { var apiMethod = Plotly[method]; @@ -37,10 +36,16 @@ exports.computeAPICommandBindings = function(gd, method, args) { case 'relayout': bindings = computeLayoutBindings(gd, args); break; - case 'animate': + case 'update': bindings = computeDataBindings(gd, [args[0], args[2]]) .concat(computeLayoutBindings(gd, [args[1]])); break; + case 'animate': + // This case could be analyzed more in-depth, but for a start, + // we'll assume that the only relevant modification an animation + // makes that's meaningfully tracked is the frame: + bindings = ['layout._currentFrame']; + break; default: // We'll elect to fail-non-fatal since this is a correct // answer and since this is not a validation method. @@ -49,7 +54,7 @@ exports.computeAPICommandBindings = function(gd, method, args) { return bindings; }; -function computeLayoutBindings (gd, args) { +function computeLayoutBindings(gd, args) { var bindings = []; var astr = args[0]; @@ -62,15 +67,15 @@ function computeLayoutBindings (gd, args) { return bindings; } - crawl(aobj, function (path, attrName, attr) { + crawl(aobj, function(path) { bindings.push('layout' + path); }); return bindings; } -function computeDataBindings (gd, args) { - var i, traces, astr, attr, val, traces, aobj; +function computeDataBindings(gd, args) { + var i, traces, astr, val, aobj; var bindings = []; // Logic copied from Plotly.restyle: @@ -91,21 +96,21 @@ function computeDataBindings (gd, args) { return bindings; } - if (traces === undefined) { + if(traces === undefined) { traces = []; - for (i = 0; i < gd.data.length; i++) { + for(i = 0; i < gd.data.length; i++) { traces.push(i); } } - crawl(aobj, function (path, attrName, attr) { + crawl(aobj, function(path, attrName, attr) { var nAttr; - if (Array.isArray(attr)) { + if(Array.isArray(attr)) { nAttr = Math.min(attr.length, traces.length); } else { nAttr = traces.length; } - for (var j = 0; j < nAttr; j++) { + for(var j = 0; j < nAttr; j++) { bindings.push('data[' + traces[j] + ']' + path); } }); @@ -113,11 +118,7 @@ function computeDataBindings (gd, args) { return bindings; } -function crawl(attrs, callback, path, depth) { - if(depth === undefined) { - depth = 0; - } - +function crawl(attrs, callback, path) { if(path === undefined) { path = ''; } @@ -130,7 +131,7 @@ function crawl(attrs, callback, path, depth) { var thisPath = path + '.' + attrName; if(Lib.isPlainObject(attr)) { - crawl(attr, callback, thisPath, depth + 1); + crawl(attr, callback, thisPath); } else { // Only execute the callback on leaf nodes: callback(thisPath, attrName, attr); diff --git a/test/jasmine/tests/command_test.js b/test/jasmine/tests/command_test.js index b1b0a1301df..7acb271ca30 100644 --- a/test/jasmine/tests/command_test.js +++ b/test/jasmine/tests/command_test.js @@ -1,6 +1,5 @@ var Plotly = require('@lib/index'); var PlotlyInternal = require('@src/plotly'); -var Lib = require('@src/lib'); var Plots = Plotly.Plots; var createGraphDiv = require('../assets/create_graph_div'); var destroyGraphDiv = require('../assets/destroy_graph_div'); @@ -220,8 +219,6 @@ describe('Plots.computeAPICommandBindings', function() { } }, [1, 0]]); - // The results are definitely not completely intuitive, so this - // is based upon empirical results with a codepen example: expect(result).toEqual([ 'data[1].y', 'data[1].marker.size', @@ -243,8 +240,6 @@ describe('Plots.computeAPICommandBindings', function() { } }, [1]]); - // The results are definitely not completely intuitive, so this - // is based upon empirical results with a codepen example: expect(result).toEqual([ 'data[1].y', 'data[1].marker.size', @@ -264,31 +259,31 @@ describe('Plots.computeAPICommandBindings', function() { }); describe('with aobj notation', function() { - it('and a single attribute', function () { + it('and a single attribute', function() { var result = Plots.computeAPICommandBindings(gd, 'relayout', [{height: 500}]); expect(result).toEqual(['layout.height']); }); - it('and two attributes', function () { + it('and two attributes', function() { var result = Plots.computeAPICommandBindings(gd, 'relayout', [{height: 500, width: 100}]); expect(result).toEqual(['layout.height', 'layout.width']); }); }); describe('with astr + val notation', function() { - it('and an attribute', function () { + it('and an attribute', function() { var result = Plots.computeAPICommandBindings(gd, 'relayout', ['width', 100]); expect(result).toEqual(['layout.width']); }); - it('and nested atributes', function () { + it('and nested atributes', function() { var result = Plots.computeAPICommandBindings(gd, 'relayout', ['margin.l', 10]); expect(result).toEqual(['layout.margin.l']); }); }); describe('with mixed notation', function() { - it('containing aob + astr', function () { + it('containing aob + astr', function() { var result = Plots.computeAPICommandBindings(gd, 'relayout', [{ 'width': 100, 'margin.l': 10 @@ -297,4 +292,37 @@ describe('Plots.computeAPICommandBindings', function() { }); }); }); + + 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([ + 'data[1].y', + 'data[1].marker.size', + 'data[1].line.color', + 'data[1].line.width', + 'layout.margin.l', + 'layout.width' + ]); + }); + }); + + describe('animate', function() { + it('computes bindings', function() { + var result = Plots.computeAPICommandBindings(gd, 'animate', [{}]); + + expect(result).toEqual(['layout._currentFrame']); + }); + }); }); From f50fcaafc510329e1ee916cd387c49b644f73050 Mon Sep 17 00:00:00 2001 From: Ricky Reusser Date: Tue, 11 Oct 2016 11:39:09 -0400 Subject: [PATCH 05/28] Switch return format of bindings to structured output --- src/components/updatemenus/draw.js | 24 +++++++ src/plots/command.js | 102 ++++++++++++++++++++------ src/plots/plots.js | 2 + test/jasmine/tests/binding_test.js | 27 +++++++ test/jasmine/tests/command_test.js | 112 ++++++++++++++++------------- 5 files changed, 197 insertions(+), 70 deletions(-) create mode 100644 test/jasmine/tests/binding_test.js diff --git a/src/components/updatemenus/draw.js b/src/components/updatemenus/draw.js index afd4d809081..440e31d59ba 100644 --- a/src/components/updatemenus/draw.js +++ b/src/components/updatemenus/draw.js @@ -21,6 +21,28 @@ var anchorUtils = require('../legend/anchor_utils'); var constants = require('./constants'); +function computeBindings(gd, menuOpts) { + var bindings = [], newBindings; + var buttons = menuOpts.buttons; + for(var i = 0; i < buttons.length; i++) { + newBindings = Plots.computeAPICommandBindings(gd, buttons[i].method, buttons[i].args); + + if(i > 0 && !Plots.bindingsAreConsistent(bindings, newBindings)) { + bindings = null; + break; + } + + for(var j = 0; j < newBindings.length; j++) { + var b = newBindings[j]; + + if(bindings.indexOf(b) === -1) { + bindings.push(b); + } + } + } + + return bindings; +} module.exports = function draw(gd) { var fullLayout = gd._fullLayout, @@ -115,6 +137,8 @@ module.exports = function draw(gd) { headerGroups.each(function(menuOpts) { var gHeader = d3.select(this); + computeBindings(gd, menuOpts); + if(menuOpts.type === 'dropdown') { drawHeader(gd, gHeader, gButton, menuOpts); diff --git a/src/plots/command.js b/src/plots/command.js index 070761b9626..ccd62d2759a 100644 --- a/src/plots/command.js +++ b/src/plots/command.js @@ -12,6 +12,60 @@ var Plotly = require('../plotly'); var Lib = require('../lib'); +var attrPrefixRegex = /^(data|layout)(\[(-?[0-9]*)\])?\.(.*)$/; + +/* + * This function checks to see if a set of bindings 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 + */ +exports.bindingsAreConsistent = function(currentBindings, newBindings) { + // If they're not both arrays of equal length, return false: + if(!newBindings || !currentBindings || currentBindings.length !== newBindings.length) { + return false; + } + + var n = currentBindings.length; + + for(var i = 0; i < n; i++) { + // This is not the most efficient check, but the pathological case where there + // are an excessive number of bindings should be rare, and at any rate we really + // try to bail out early at every opportunity. + if(currentBindings.indexOf(newBindings[i]) === -1) { + return false; + } + } + + return true; +}; + +exports.evaluateAPICommandBinding = function(gd, attrName) { + var match = attrName.match(attrPrefixRegex); + + if(!match) { + return null; + } + + var group = match[1]; + var propStr = match[4]; + var container; + + switch(group) { + case 'data': + container = gd._fullData[parseInt(match[3])]; + break; + case 'layout': + container = gd._fullLayout; + break; + default: + return null; + } + + return Lib.nestedProperty(container, propStr).get(); +}; + exports.executeAPICommand = function(gd, method, args) { var apiMethod = Plotly[method]; @@ -44,7 +98,7 @@ exports.computeAPICommandBindings = function(gd, method, args) { // This case could be analyzed more in-depth, but for a start, // we'll assume that the only relevant modification an animation // makes that's meaningfully tracked is the frame: - bindings = ['layout._currentFrame']; + bindings = [{type: 'layout', prop: '_currentFrame'}]; break; default: // We'll elect to fail-non-fatal since this is a correct @@ -68,14 +122,14 @@ function computeLayoutBindings(gd, args) { } crawl(aobj, function(path) { - bindings.push('layout' + path); - }); + bindings.push({type: 'layout', prop: path}); + }, '', 0); return bindings; } function computeDataBindings(gd, args) { - var i, traces, astr, val, aobj; + var traces, astr, val, aobj; var bindings = []; // Logic copied from Plotly.restyle: @@ -97,41 +151,45 @@ function computeDataBindings(gd, args) { } if(traces === undefined) { - traces = []; - for(i = 0; i < gd.data.length; i++) { - traces.push(i); - } + // Explicitly assign this to null instead of undefined: + traces = null; } crawl(aobj, function(path, attrName, attr) { - var nAttr; + var thisTraces; if(Array.isArray(attr)) { - nAttr = Math.min(attr.length, traces.length); + 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 { - nAttr = traces.length; - } - for(var j = 0; j < nAttr; j++) { - bindings.push('data[' + traces[j] + ']' + path); + thisTraces = traces ? traces.slice(0) : null; } - }); + + bindings.push({ + type: 'data', + prop: path, + traces: thisTraces + }); + }, '', 0); return bindings; } -function crawl(attrs, callback, path) { - if(path === undefined) { - path = ''; - } - +function crawl(attrs, callback, path, depth) { Object.keys(attrs).forEach(function(attrName) { var attr = attrs[attrName]; if(attrName[0] === '_') return; - var thisPath = path + '.' + attrName; + var thisPath = path + (depth > 0 ? '.' : '') + attrName; if(Lib.isPlainObject(attr)) { - crawl(attr, callback, thisPath); + 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 29d94ad0d85..0b9f145ce32 100644 --- a/src/plots/plots.js +++ b/src/plots/plots.js @@ -39,6 +39,8 @@ var ErrorBars = require('../components/errorbars'); var commandModule = require('./command'); plots.executeAPICommand = commandModule.executeAPICommand; plots.computeAPICommandBindings = commandModule.computeAPICommandBindings; +plots.evaluateAPICommandBinding = commandModule.evaluateAPICommandBinding; +plots.bindingsAreConsistent = commandModule.bindingsAreConsistent; /** * Find subplot ids in data. diff --git a/test/jasmine/tests/binding_test.js b/test/jasmine/tests/binding_test.js new file mode 100644 index 00000000000..531822950e9 --- /dev/null +++ b/test/jasmine/tests/binding_test.js @@ -0,0 +1,27 @@ +var Lib = require('@src/lib/index'); +var Plotly = require('@lib/index'); +var createGraphDiv = require('../assets/create_graph_div'); +var destroyGraphDiv = require('../assets/destroy_graph_div'); +var fail = require('../assets/fail_test'); + +describe('updateBindings', function() { + 'use strict'; + + var gd; + var mock = require('@mocks/updatemenus.json'); + + beforeEach(function(done) { + gd = createGraphDiv(); + + var mockCopy = Lib.extendDeep({}, mock); + Plotly.plot(gd, mockCopy.data, mockCopy.layout).then(done); + }); + + afterEach(function() { + destroyGraphDiv(gd); + }); + + it('updates bindings on plot', function(done) { + Plotly.restyle(gd, 'marker.size', 10).catch(fail).then(done); + }); +}); diff --git a/test/jasmine/tests/command_test.js b/test/jasmine/tests/command_test.js index 7acb271ca30..7a1f01218ba 100644 --- a/test/jasmine/tests/command_test.js +++ b/test/jasmine/tests/command_test.js @@ -5,6 +5,22 @@ var createGraphDiv = require('../assets/create_graph_div'); var destroyGraphDiv = require('../assets/destroy_graph_div'); var fail = require('../assets/fail_test'); +describe('Plots.evaluateAPICommandBinding', function() { + it('evaluates a data binding', function() { + var gd = {_fullData: [null, {line: {width: 7}}]}; + var astr = 'data[1].line.width'; + + expect(Plots.evaluateAPICommandBinding(gd, astr)).toEqual(7); + }); + + it('evaluates a layout binding', function() { + var gd = {_fullLayout: {margin: {t: 100}}}; + var astr = 'layout.margin.t'; + + expect(Plots.evaluateAPICommandBinding(gd, astr)).toEqual(100); + }); +}); + describe('Plots.executeAPICommand', function() { 'use strict'; @@ -89,47 +105,47 @@ describe('Plots.computeAPICommandBindings', 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(['data[0].marker.size', 'data[1].marker.size']); + expect(result).toEqual([{prop: 'marker.size', traces: null, type: 'data'}]); }); it('with an array value and no trace specified', function() { var result = Plots.computeAPICommandBindings(gd, 'restyle', ['marker.size', [7]]); - expect(result).toEqual(['data[0].marker.size']); + expect(result).toEqual([{prop: 'marker.size', traces: [0], type: 'data'}]); }); it('with trace specified', function() { var result = Plots.computeAPICommandBindings(gd, 'restyle', ['marker.size', 7, [0]]); - expect(result).toEqual(['data[0].marker.size']); + expect(result).toEqual([{prop: 'marker.size', traces: [0], type: 'data'}]); }); it('with a different trace specified', function() { var result = Plots.computeAPICommandBindings(gd, 'restyle', ['marker.size', 7, [0]]); - expect(result).toEqual(['data[0].marker.size']); + expect(result).toEqual([{prop: 'marker.size', traces: [0], type: 'data'}]); }); it('with an array value', function() { var result = Plots.computeAPICommandBindings(gd, 'restyle', ['marker.size', [7], [1]]); - expect(result).toEqual(['data[1].marker.size']); + expect(result).toEqual([{prop: 'marker.size', traces: [1], type: 'data'}]); }); 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(['data[0].marker.size', 'data[1].marker.size']); + expect(result).toEqual([{prop: 'marker.size', traces: [0, 1], type: 'data'}]); }); it('with traces specified in reverse order', function() { var result = Plots.computeAPICommandBindings(gd, 'restyle', ['marker.size', [7, 5], [1, 0]]); - expect(result).toEqual(['data[1].marker.size', 'data[0].marker.size']); + expect(result).toEqual([{prop: 'marker.size', traces: [1, 0], type: 'data'}]); }); it('with two values and a single trace specified', function() { var result = Plots.computeAPICommandBindings(gd, 'restyle', ['marker.size', [7, 5], [0]]); - expect(result).toEqual(['data[0].marker.size']); + expect(result).toEqual([{prop: 'marker.size', traces: [0], type: 'data'}]); }); it('with two values and a different trace specified', function() { var result = Plots.computeAPICommandBindings(gd, 'restyle', ['marker.size', [7, 5], [1]]); - expect(result).toEqual(['data[1].marker.size']); + expect(result).toEqual([{prop: 'marker.size', traces: [1], type: 'data'}]); }); }); }); @@ -138,49 +154,52 @@ describe('Plots.computeAPICommandBindings', 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(['data[0].marker.size', 'data[1].marker.size']); + expect(result).toEqual([{type: 'data', prop: 'marker.size', traces: null}]); }); it('with trace specified', function() { var result = Plots.computeAPICommandBindings(gd, 'restyle', [{'marker.size': 7}, [0]]); - expect(result).toEqual(['data[0].marker.size']); + expect(result).toEqual([{type: 'data', prop: 'marker.size', traces: [0]}]); }); it('with a different trace specified', function() { var result = Plots.computeAPICommandBindings(gd, 'restyle', [{'marker.size': 7}, [1]]); - expect(result).toEqual(['data[1].marker.size']); + expect(result).toEqual([{type: 'data', prop: 'marker.size', traces: [1]}]); }); it('with an array value', function() { var result = Plots.computeAPICommandBindings(gd, 'restyle', [{'marker.size': [7]}, [1]]); - expect(result).toEqual(['data[1].marker.size']); + expect(result).toEqual([{type: 'data', prop: 'marker.size', traces: [1]}]); }); 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(['data[0].marker.size', 'data[1].marker.size']); + expect(result).toEqual([{type: 'data', prop: 'marker.size', traces: [0, 1]}]); }); it('with traces specified in reverse order', function() { var result = Plots.computeAPICommandBindings(gd, 'restyle', [{'marker.size': [7, 5]}, [1, 0]]); - expect(result).toEqual(['data[1].marker.size', 'data[0].marker.size']); + expect(result).toEqual([{type: 'data', prop: 'marker.size', traces: [1, 0]}]); }); it('with two values and a single trace specified', function() { var result = Plots.computeAPICommandBindings(gd, 'restyle', [{'marker.size': [7, 5]}, [0]]); - expect(result).toEqual(['data[0].marker.size']); + expect(result).toEqual([{type: 'data', prop: 'marker.size', traces: [0]}]); }); it('with two values and a different trace specified', function() { var result = Plots.computeAPICommandBindings(gd, 'restyle', [{'marker.size': [7, 5]}, [1]]); - expect(result).toEqual(['data[1].marker.size']); + expect(result).toEqual([{type: 'data', prop: 'marker.size', traces: [1]}]); }); }); 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(['data[0].marker.size', 'data[1].marker.size', 'data[0].text.color', 'data[1].text.color']); + expect(result).toEqual([ + {type: 'data', prop: 'marker.size', traces: null}, + {type: 'data', prop: 'text.color', traces: null} + ]); }); }); }); @@ -199,13 +218,10 @@ describe('Plots.computeAPICommandBindings', function() { // The results are definitely not completely intuitive, so this // is based upon empirical results with a codepen example: expect(result).toEqual([ - 'data[0].y', - 'data[0].marker.size', - 'data[1].marker.size', - 'data[0].line.color', - 'data[1].line.color', - 'data[0].line.width', - 'data[1].line.width', + {type: 'data', prop: 'y', traces: [0]}, + {type: 'data', prop: 'marker.size', traces: [0, 1]}, + {type: 'data', prop: 'line.color', traces: null}, + {type: 'data', prop: 'line.width', traces: [0, 1]} ]); }); @@ -220,13 +236,10 @@ describe('Plots.computeAPICommandBindings', function() { }, [1, 0]]); expect(result).toEqual([ - 'data[1].y', - 'data[1].marker.size', - 'data[0].marker.size', - 'data[1].line.color', - 'data[0].line.color', - 'data[1].line.width', - 'data[0].line.width', + {type: 'data', prop: 'y', traces: [1]}, + {type: 'data', prop: 'marker.size', traces: [1, 0]}, + {type: 'data', prop: 'line.color', traces: [1, 0]}, + {type: 'data', prop: 'line.width', traces: [1, 0]} ]); }); @@ -241,10 +254,10 @@ describe('Plots.computeAPICommandBindings', function() { }, [1]]); expect(result).toEqual([ - 'data[1].y', - 'data[1].marker.size', - 'data[1].line.color', - 'data[1].line.width', + {type: 'data', prop: 'y', traces: [1]}, + {type: 'data', prop: 'marker.size', traces: [1]}, + {type: 'data', prop: 'line.color', traces: [1]}, + {type: 'data', prop: 'line.width', traces: [1]} ]); }); }); @@ -261,24 +274,24 @@ describe('Plots.computeAPICommandBindings', function() { describe('with aobj notation', function() { it('and a single attribute', function() { var result = Plots.computeAPICommandBindings(gd, 'relayout', [{height: 500}]); - expect(result).toEqual(['layout.height']); + expect(result).toEqual([{type: 'layout', prop: 'height'}]); }); it('and two attributes', function() { var result = Plots.computeAPICommandBindings(gd, 'relayout', [{height: 500, width: 100}]); - expect(result).toEqual(['layout.height', 'layout.width']); + expect(result).toEqual([{type: 'layout', prop: 'height'}, {type: 'layout', prop: 'width'}]); }); }); describe('with astr + val notation', function() { it('and an attribute', function() { var result = Plots.computeAPICommandBindings(gd, 'relayout', ['width', 100]); - expect(result).toEqual(['layout.width']); + expect(result).toEqual([{type: 'layout', prop: 'width'}]); }); it('and nested atributes', function() { var result = Plots.computeAPICommandBindings(gd, 'relayout', ['margin.l', 10]); - expect(result).toEqual(['layout.margin.l']); + expect(result).toEqual([{type: 'layout', prop: 'margin.l'}]); }); }); @@ -288,7 +301,10 @@ describe('Plots.computeAPICommandBindings', function() { 'width': 100, 'margin.l': 10 }]); - expect(result).toEqual(['layout.width', 'layout.margin.l']); + expect(result).toEqual([ + {type: 'layout', prop: 'width'}, + {type: 'layout', prop: 'margin.l'} + ]); }); }); }); @@ -308,12 +324,12 @@ describe('Plots.computeAPICommandBindings', function() { }, [1]]); expect(result).toEqual([ - 'data[1].y', - 'data[1].marker.size', - 'data[1].line.color', - 'data[1].line.width', - 'layout.margin.l', - 'layout.width' + {type: 'data', prop: 'y', traces: [1]}, + {type: 'data', prop: 'marker.size', traces: [1]}, + {type: 'data', prop: 'line.color', traces: [1]}, + {type: 'data', prop: 'line.width', traces: [1]}, + {type: 'layout', prop: 'margin.l'}, + {type: 'layout', prop: 'width'} ]); }); }); @@ -322,7 +338,7 @@ describe('Plots.computeAPICommandBindings', function() { it('computes bindings', function() { var result = Plots.computeAPICommandBindings(gd, 'animate', [{}]); - expect(result).toEqual(['layout._currentFrame']); + expect(result).toEqual([{type: 'layout', prop: '_currentFrame'}]); }); }); }); From 4bc8387e9520411e4be420ad77ec12666e2d3801 Mon Sep 17 00:00:00 2001 From: Ricky Reusser Date: Mon, 17 Oct 2016 12:57:58 -0400 Subject: [PATCH 06/28] Command to decide when bindings are simple --- src/plots/command.js | 62 +++++++++++++++++---- src/plots/plots.js | 2 +- test/jasmine/tests/command_test.js | 89 ++++++++++++++++++++++++++++++ 3 files changed, 140 insertions(+), 13 deletions(-) diff --git a/src/plots/command.js b/src/plots/command.js index ccd62d2759a..2df1f211de6 100644 --- a/src/plots/command.js +++ b/src/plots/command.js @@ -15,27 +15,65 @@ var Lib = require('../lib'); var attrPrefixRegex = /^(data|layout)(\[(-?[0-9]*)\])?\.(.*)$/; /* - * This function checks to see if a set of bindings is compatible - * with automatic two-way binding. The criteria right now are that + * 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.bindingsAreConsistent = function(currentBindings, newBindings) { - // If they're not both arrays of equal length, return false: - if(!newBindings || !currentBindings || currentBindings.length !== newBindings.length) { - return false; - } +exports.hasSimpleBindings = function(gd, commandList) { + var n = commandList.length; - var n = currentBindings.length; + var refBinding; for(var i = 0; i < n; i++) { - // This is not the most efficient check, but the pathological case where there - // are an excessive number of bindings should be rare, and at any rate we really - // try to bail out early at every opportunity. - if(currentBindings.indexOf(newBindings[i]) === -1) { + 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 { + var 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; + } + } + } } return true; diff --git a/src/plots/plots.js b/src/plots/plots.js index df4d80eef9c..09c8e74208e 100644 --- a/src/plots/plots.js +++ b/src/plots/plots.js @@ -42,7 +42,7 @@ var commandModule = require('./command'); plots.executeAPICommand = commandModule.executeAPICommand; plots.computeAPICommandBindings = commandModule.computeAPICommandBindings; plots.evaluateAPICommandBinding = commandModule.evaluateAPICommandBinding; -plots.bindingsAreConsistent = commandModule.bindingsAreConsistent; +plots.hasSimpleBindings = commandModule.hasSimpleBindings; /** * Find subplot ids in data. diff --git a/test/jasmine/tests/command_test.js b/test/jasmine/tests/command_test.js index 7a1f01218ba..4cb47e16d2a 100644 --- a/test/jasmine/tests/command_test.js +++ b/test/jasmine/tests/command_test.js @@ -75,6 +75,95 @@ describe('Plots.executeAPICommand', function() { }); }); +describe('Plots.hasSimpleBindings', 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 true when bindings are simple', function() { + var isSimple = Plots.hasSimpleBindings(gd, [{ + method: 'restyle', + args: [{'marker.size': 10}] + }, { + method: 'restyle', + args: [{'marker.size': 20}] + }]); + + expect(isSimple).toBe(true); + }); + + it('return false when properties are not the same', function() { + var isSimple = Plots.hasSimpleBindings(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.hasSimpleBindings(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.hasSimpleBindings(gd, [{ + method: 'restyle', + args: [{'marker.color': 10}, [0]] + }, { + method: 'restyle', + args: [{'marker.color': 20}, [1]] + }]); + + expect(isSimple).toBe(false); + }); + + it('return true when commands affect the same traces', function() { + var isSimple = Plots.hasSimpleBindings(gd, [{ + method: 'restyle', + args: [{'marker.color': 10}, [1]] + }, { + method: 'restyle', + args: [{'marker.color': 20}, [1]] + }]); + + expect(isSimple).toBe(true); + }); + + it('return true when commands affect the same traces in different order', function() { + var isSimple = Plots.hasSimpleBindings(gd, [{ + method: 'restyle', + args: [{'marker.color': 10}, [1, 2]] + }, { + method: 'restyle', + args: [{'marker.color': 20}, [2, 1]] + }]); + + expect(isSimple).toBe(true); + }); +}); + describe('Plots.computeAPICommandBindings', function() { 'use strict'; From 93b825364dc4e77234f6ec61e3e1b391ad9b094c Mon Sep 17 00:00:00 2001 From: Ricky Reusser Date: Tue, 18 Oct 2016 11:33:38 -0400 Subject: [PATCH 07/28] First working version of bindings --- src/components/sliders/draw.js | 10 +- src/plot_api/plot_api.js | 5 + src/plots/command.js | 165 +++++++++++++++++++++++++++-- src/plots/plots.js | 2 + test/jasmine/tests/command_test.js | 99 +++++++++-------- 5 files changed, 224 insertions(+), 57 deletions(-) diff --git a/src/components/sliders/draw.js b/src/components/sliders/draw.js index c24e322c385..1debb819574 100644 --- a/src/components/sliders/draw.js +++ b/src/components/sliders/draw.js @@ -52,6 +52,9 @@ module.exports = function draw(gd) { sliderGroups.exit().each(function(sliderOpts) { d3.select(this).remove(); + sliderOpts._bindingObserver.remove(); + delete sliderOpts._bindingObserver; + Plots.autoMargin(gd, constants.autoMarginIdRoot + sliderOpts._index); }); @@ -67,10 +70,15 @@ module.exports = function draw(gd) { computeLabelSteps(sliderOpts); + if(!sliderOpts._bindingObserver) { + sliderOpts._bindingObserver = Plots.createBindingObserver(gd, sliderOpts.steps, function(data) { + setActive(gd, d3.select(this), sliderOpts, data.index, false, true); + }.bind(this)); + } + drawSlider(gd, d3.select(this), sliderOpts); // makeInputProxy(gd, d3.select(this), sliderOpts); - }); }; diff --git a/src/plot_api/plot_api.js b/src/plot_api/plot_api.js index 9b1976b4f7f..b6be0df5dd9 100644 --- a/src/plot_api/plot_api.js +++ b/src/plot_api/plot_api.js @@ -1204,6 +1204,7 @@ Plotly.restyle = function restyle(gd, astr, val, traces) { return plotDone.then(function() { gd.emit('plotly_restyle', specs.eventData); + gd.emit('plotly_plotmodified'); return gd; }); }; @@ -1710,6 +1711,7 @@ Plotly.relayout = function relayout(gd, astr, val) { return plotDone.then(function() { gd.emit('plotly_relayout', specs.eventData); + gd.emit('plotly_plotmodified'); return gd; }); }; @@ -2124,6 +2126,7 @@ Plotly.update = function update(gd, traceUpdate, layoutUpdate, traces) { data: restyleSpecs.eventData, layout: relayoutSpecs.eventData }); + gd.emit('plotly_plotmodified'); return gd; }); @@ -2324,6 +2327,8 @@ Plotly.animate = function(gd, frameOrGroupNameOrFrameList, animationOpts) { newFrame.frameOpts, newFrame.transitionOpts ); + + gd.emit('plotly_plotmodified'); } 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 index 2df1f211de6..19497ce617e 100644 --- a/src/plots/command.js +++ b/src/plots/command.js @@ -23,12 +23,13 @@ var attrPrefixRegex = /^(data|layout)(\[(-?[0-9]*)\])?\.(.*)$/; * 2. only one property may be affected * 3. the same property must be affected by all commands */ -exports.hasSimpleBindings = function(gd, commandList) { +exports.hasSimpleBindings = 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; @@ -50,7 +51,7 @@ exports.hasSimpleBindings = function(gd, commandList) { refBinding.traces.sort(); } } else { - var binding = bindings[0]; + binding = bindings[0]; if(binding.type !== refBinding.type) { return false; } @@ -74,9 +75,129 @@ exports.hasSimpleBindings = function(gd, commandList) { } } } + + binding = bindings[0]; + var value = binding.value[0]; + if(Array.isArray(value)) { + value = value[0]; + } + bindingsByValue[value] = i; + } + + return refBinding; +}; + +exports.createBindingObserver = function(gd, commandList, onchange) { + var cache = {}; + var lookupTable = {}; + var check, remove; + var enabled = true; + + // Determine whether there's anything to do for this binding: + var binding; + if((binding = exports.hasSimpleBindings(gd, commandList, lookupTable))) { + exports.bindingValueHasChanged(gd, binding, cache); + + check = function check() { + if(!enabled) return; + + 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; + + if(changed && onchange) { + // Disable checks for the duration of this command in order to avoid + // infinite loops: + if(lookupTable[value] !== undefined) { + disable(); + Promise.resolve(onchange({ + value: value, + type: binding.type, + prop: binding.prop, + traces: binding.traces, + index: lookupTable[value] + })).then(enable, enable); + } + } + + return changed; + }; + + gd._internalOn('plotly_plotmodified', check); + + remove = function() { + gd._removeInternalListener('plotly_plotmodified', check); + }; + } else { + lookupTable = {}; + remove = function() {}; + } + + function disable() { + enabled = false; } - return true; + function enable() { + enabled = true; + } + + return { + disable: disable, + enable: enable, + remove: remove + }; +}; + +exports.bindingValueHasChanged = function(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; }; exports.evaluateAPICommandBinding = function(gd, attrName) { @@ -133,10 +254,7 @@ exports.computeAPICommandBindings = function(gd, method, args) { .concat(computeLayoutBindings(gd, [args[1]])); break; case 'animate': - // This case could be analyzed more in-depth, but for a start, - // we'll assume that the only relevant modification an animation - // makes that's meaningfully tracked is the frame: - bindings = [{type: 'layout', prop: '_currentFrame'}]; + bindings = computeAnimateBindings(gd, args); break; default: // We'll elect to fail-non-fatal since this is a correct @@ -146,6 +264,16 @@ exports.computeAPICommandBindings = function(gd, method, args) { 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 = []; @@ -159,8 +287,8 @@ function computeLayoutBindings(gd, args) { return bindings; } - crawl(aobj, function(path) { - bindings.push({type: 'layout', prop: path}); + crawl(aobj, function(path, attrName, attr) { + bindings.push({type: 'layout', prop: path, value: attr}); }, '', 0); return bindings; @@ -208,10 +336,27 @@ function computeDataBindings(gd, args) { 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 + traces: thisTraces, + value: attr }); }, '', 0); diff --git a/src/plots/plots.js b/src/plots/plots.js index 09c8e74208e..3e77f00724b 100644 --- a/src/plots/plots.js +++ b/src/plots/plots.js @@ -43,6 +43,8 @@ plots.executeAPICommand = commandModule.executeAPICommand; plots.computeAPICommandBindings = commandModule.computeAPICommandBindings; plots.evaluateAPICommandBinding = commandModule.evaluateAPICommandBinding; plots.hasSimpleBindings = commandModule.hasSimpleBindings; +plots.bindingValueHasChanged = commandModule.bindingValueHasChanged; +plots.createBindingObserver = commandModule.createBindingObserver; /** * Find subplot ids in data. diff --git a/test/jasmine/tests/command_test.js b/test/jasmine/tests/command_test.js index 4cb47e16d2a..046413ad953 100644 --- a/test/jasmine/tests/command_test.js +++ b/test/jasmine/tests/command_test.js @@ -194,91 +194,92 @@ describe('Plots.computeAPICommandBindings', 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'}]); + 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'}]); + 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'}]); + 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'}]); + 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'}]); + 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'}]); + 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'}]); + 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'}]); + 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'}]); + 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}]); + 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]}]); + 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]}]); + 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]}]); + 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]}]); + 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]}]); + 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]}]); + 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]}]); + expect(result).toEqual([{type: 'data', prop: 'marker.size', traces: [1], value: [7]}]); }); }); @@ -286,8 +287,8 @@ describe('Plots.computeAPICommandBindings', 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}, - {type: 'data', prop: 'text.color', traces: null} + {type: 'data', prop: 'marker.size', traces: null, value: 7}, + {type: 'data', prop: 'text.color', traces: null, value: 'blue'} ]); }); }); @@ -307,10 +308,10 @@ describe('Plots.computeAPICommandBindings', function() { // 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]}, - {type: 'data', prop: 'marker.size', traces: [0, 1]}, - {type: 'data', prop: 'line.color', traces: null}, - {type: 'data', prop: 'line.width', traces: [0, 1]} + {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]} ]); }); @@ -325,10 +326,10 @@ describe('Plots.computeAPICommandBindings', function() { }, [1, 0]]); expect(result).toEqual([ - {type: 'data', prop: 'y', traces: [1]}, - {type: 'data', prop: 'marker.size', traces: [1, 0]}, - {type: 'data', prop: 'line.color', traces: [1, 0]}, - {type: 'data', prop: 'line.width', traces: [1, 0]} + {type: 'data', prop: 'y', traces: [1], value: [[3, 4, 5]]}, + {type: 'data', prop: 'marker.size', traces: [1, 0], value: [10, 20]}, + {type: 'data', prop: 'line.color', traces: [1, 0], value: ['red', 'red']}, + {type: 'data', prop: 'line.width', traces: [1, 0], value: [2, 8]} ]); }); @@ -343,10 +344,10 @@ describe('Plots.computeAPICommandBindings', function() { }, [1]]); expect(result).toEqual([ - {type: 'data', prop: 'y', traces: [1]}, - {type: 'data', prop: 'marker.size', traces: [1]}, - {type: 'data', prop: 'line.color', traces: [1]}, - {type: 'data', prop: 'line.width', traces: [1]} + {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]} ]); }); }); @@ -363,24 +364,24 @@ describe('Plots.computeAPICommandBindings', function() { 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'}]); + 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'}, {type: 'layout', prop: 'width'}]); + 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'}]); + 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'}]); + expect(result).toEqual([{type: 'layout', prop: 'margin.l', value: 10}]); }); }); @@ -391,8 +392,8 @@ describe('Plots.computeAPICommandBindings', function() { 'margin.l': 10 }]); expect(result).toEqual([ - {type: 'layout', prop: 'width'}, - {type: 'layout', prop: 'margin.l'} + {type: 'layout', prop: 'width', value: 100}, + {type: 'layout', prop: 'margin.l', value: 10} ]); }); }); @@ -413,21 +414,27 @@ describe('Plots.computeAPICommandBindings', function() { }, [1]]); expect(result).toEqual([ - {type: 'data', prop: 'y', traces: [1]}, - {type: 'data', prop: 'marker.size', traces: [1]}, - {type: 'data', prop: 'line.color', traces: [1]}, - {type: 'data', prop: 'line.width', traces: [1]}, - {type: 'layout', prop: 'margin.l'}, - {type: 'layout', prop: 'width'} + {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('computes bindings', function() { - var result = Plots.computeAPICommandBindings(gd, 'animate', [{}]); + 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([{type: 'layout', prop: '_currentFrame'}]); + expect(result).toEqual([]); }); }); }); From 43dc3d4149aedd81f2958ea10b7bfb43a315f44e Mon Sep 17 00:00:00 2001 From: Ricky Reusser Date: Tue, 18 Oct 2016 11:47:23 -0400 Subject: [PATCH 08/28] Change the method name --- src/components/sliders/draw.js | 8 ++++---- src/plots/command.js | 2 +- src/plots/plots.js | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/components/sliders/draw.js b/src/components/sliders/draw.js index 1debb819574..523960def8c 100644 --- a/src/components/sliders/draw.js +++ b/src/components/sliders/draw.js @@ -52,8 +52,8 @@ module.exports = function draw(gd) { sliderGroups.exit().each(function(sliderOpts) { d3.select(this).remove(); - sliderOpts._bindingObserver.remove(); - delete sliderOpts._bindingObserver; + sliderOpts._commandObserver.remove(); + delete sliderOpts._commandObserver; Plots.autoMargin(gd, constants.autoMarginIdRoot + sliderOpts._index); }); @@ -70,8 +70,8 @@ module.exports = function draw(gd) { computeLabelSteps(sliderOpts); - if(!sliderOpts._bindingObserver) { - sliderOpts._bindingObserver = Plots.createBindingObserver(gd, sliderOpts.steps, function(data) { + if(!sliderOpts._commandObserver) { + sliderOpts._commandObserver = Plots.createCommandObserver(gd, sliderOpts.steps, function(data) { setActive(gd, d3.select(this), sliderOpts, data.index, false, true); }.bind(this)); } diff --git a/src/plots/command.js b/src/plots/command.js index 19497ce617e..3eb130e6665 100644 --- a/src/plots/command.js +++ b/src/plots/command.js @@ -87,7 +87,7 @@ exports.hasSimpleBindings = function(gd, commandList, bindingsByValue) { return refBinding; }; -exports.createBindingObserver = function(gd, commandList, onchange) { +exports.createCommandObserver = function(gd, commandList, onchange) { var cache = {}; var lookupTable = {}; var check, remove; diff --git a/src/plots/plots.js b/src/plots/plots.js index 3e77f00724b..dad82caa1eb 100644 --- a/src/plots/plots.js +++ b/src/plots/plots.js @@ -44,7 +44,7 @@ plots.computeAPICommandBindings = commandModule.computeAPICommandBindings; plots.evaluateAPICommandBinding = commandModule.evaluateAPICommandBinding; plots.hasSimpleBindings = commandModule.hasSimpleBindings; plots.bindingValueHasChanged = commandModule.bindingValueHasChanged; -plots.createBindingObserver = commandModule.createBindingObserver; +plots.createCommandObserver = commandModule.createCommandObserver; /** * Find subplot ids in data. From 65d24f3cac7465121b9c4c2a1d2a0a28f7bebeae Mon Sep 17 00:00:00 2001 From: Ricky Reusser Date: Tue, 18 Oct 2016 12:51:05 -0400 Subject: [PATCH 09/28] Fix updatemenus bindings --- src/components/sliders/draw.js | 6 ++-- src/components/updatemenus/draw.js | 58 ++++++++++++------------------ src/plots/command.js | 2 +- 3 files changed, 28 insertions(+), 38 deletions(-) diff --git a/src/components/sliders/draw.js b/src/components/sliders/draw.js index 523960def8c..be9e80338c7 100644 --- a/src/components/sliders/draw.js +++ b/src/components/sliders/draw.js @@ -68,12 +68,14 @@ 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); if(!sliderOpts._commandObserver) { sliderOpts._commandObserver = Plots.createCommandObserver(gd, sliderOpts.steps, function(data) { - setActive(gd, d3.select(this), sliderOpts, data.index, false, true); - }.bind(this)); + setActive(gd, gSlider, sliderOpts, data.index, false, true); + }); } drawSlider(gd, d3.select(this), sliderOpts); diff --git a/src/components/updatemenus/draw.js b/src/components/updatemenus/draw.js index 237acdec137..eb48c0ce070 100644 --- a/src/components/updatemenus/draw.js +++ b/src/components/updatemenus/draw.js @@ -21,29 +21,6 @@ var anchorUtils = require('../legend/anchor_utils'); var constants = require('./constants'); -function computeBindings(gd, menuOpts) { - var bindings = [], newBindings; - var buttons = menuOpts.buttons; - for(var i = 0; i < buttons.length; i++) { - newBindings = Plots.computeAPICommandBindings(gd, buttons[i].method, buttons[i].args); - - if(i > 0 && !Plots.bindingsAreConsistent(bindings, newBindings)) { - bindings = null; - break; - } - - for(var j = 0; j < newBindings.length; j++) { - var b = newBindings[j]; - - if(bindings.indexOf(b) === -1) { - bindings.push(b); - } - } - } - - return bindings; -} - module.exports = function draw(gd) { var fullLayout = gd._fullLayout, menuData = makeMenuData(fullLayout); @@ -137,7 +114,12 @@ module.exports = function draw(gd) { headerGroups.each(function(menuOpts) { var gHeader = d3.select(this); - computeBindings(gd, menuOpts); + if(!menuOpts._commandObserver) { + var _gButton = menuOpts.type === 'dropdown' ? gButton : null; + menuOpts._commandObserver = Plots.createCommandObserver(gd, 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); @@ -317,17 +299,7 @@ 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'); - - if(menuOpts.type === 'dropdown') { - drawHeader(gd, gHeader, gButton, menuOpts); - } - - drawButtons(gd, gHeader, gButton, menuOpts); + setActive(gd, menuOpts, buttonOpts, gHeader, gButton, buttonIndex); // call button method var args = buttonOpts.args; @@ -350,6 +322,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/plots/command.js b/src/plots/command.js index 3eb130e6665..d3e217aa2a8 100644 --- a/src/plots/command.js +++ b/src/plots/command.js @@ -77,7 +77,7 @@ exports.hasSimpleBindings = function(gd, commandList, bindingsByValue) { } binding = bindings[0]; - var value = binding.value[0]; + var value = binding.value; if(Array.isArray(value)) { value = value[0]; } From d2696e78b21d31e36ac8c29e8c58ea047116169e Mon Sep 17 00:00:00 2001 From: Ricky Reusser Date: Tue, 18 Oct 2016 14:00:57 -0400 Subject: [PATCH 10/28] Hook up animations to sliders via bindings --- src/plot_api/plot_api.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/plot_api/plot_api.js b/src/plot_api/plot_api.js index b6be0df5dd9..a7d492d46c5 100644 --- a/src/plot_api/plot_api.js +++ b/src/plot_api/plot_api.js @@ -2305,6 +2305,8 @@ Plotly.animate = function(gd, frameOrGroupNameOrFrameList, animationOpts) { var newFrame = trans._currentFrame = trans._frameQueue.shift(); if(newFrame) { + gd._fullLayout._currentFrame = newFrame.name; + gd.emit('plotly_animatingframe', { name: newFrame.name, frame: newFrame.frame, From 7db7f039ade0a905876f1d160c1ac0be6735e783 Mon Sep 17 00:00:00 2001 From: Ricky Reusser Date: Thu, 20 Oct 2016 15:29:44 -0400 Subject: [PATCH 11/28] Clean up slider positioning code --- src/components/sliders/draw.js | 15 ++++++++------- src/components/updatemenus/draw.js | 4 +--- 2 files changed, 9 insertions(+), 10 deletions(-) diff --git a/src/components/sliders/draw.js b/src/components/sliders/draw.js index be9e80338c7..eb84fca6165 100644 --- a/src/components/sliders/draw.js +++ b/src/components/sliders/draw.js @@ -385,14 +385,11 @@ function setActive(gd, sliderGroup, sliderOpts, index, doCallback, doTransition) if(!_step.method) return; sliderOpts._invokingCommand = true; - Plotly[_step.method](gd, args[0], args[1], args[2]).then(function() { + + Plots.executeAPICommand(gd, _step.method, _step.args).then(function() { sliderOpts._invokingCommand = false; - }, function() { + }, 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.'); }); sliderGroup._nextMethod = null; @@ -477,8 +474,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 eb48c0ce070..b4be0da547f 100644 --- a/src/components/updatemenus/draw.js +++ b/src/components/updatemenus/draw.js @@ -301,9 +301,7 @@ function drawButtons(gd, gHeader, gButton, menuOpts) { button.on('click', function() { setActive(gd, menuOpts, buttonOpts, gHeader, gButton, buttonIndex); - // 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() { From 57fcf96b4b654c30aac7f37f988342b692aad27c Mon Sep 17 00:00:00 2001 From: Ricky Reusser Date: Thu, 20 Oct 2016 16:02:41 -0400 Subject: [PATCH 12/28] Add mock for bindings --- src/components/sliders/draw.js | 6 +- src/components/updatemenus/draw.js | 1 - src/plots/command.js | 41 +++-------- src/plots/plots.js | 4 +- test/image/mocks/binding.json | 110 +++++++++++++++++++++++++++++ test/jasmine/tests/command_test.js | 57 ++++++++------- 6 files changed, 149 insertions(+), 70 deletions(-) create mode 100644 test/image/mocks/binding.json diff --git a/src/components/sliders/draw.js b/src/components/sliders/draw.js index eb84fca6165..7c25e5b3a52 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'); @@ -381,14 +380,13 @@ 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; Plots.executeAPICommand(gd, _step.method, _step.args).then(function() { sliderOpts._invokingCommand = false; - }, function () { + }, function() { sliderOpts._invokingCommand = false; }); @@ -476,7 +474,7 @@ function setGripPosition(sliderGroup, sliderOpts, position, doTransition) { // If this is true, then *this component* is already invoking its own command // and has triggered its own animation. - if (sliderOpts._invokingCommand) return; + if(sliderOpts._invokingCommand) return; var el = grip; if(doTransition && sliderOpts.transition.duration > 0) { diff --git a/src/components/updatemenus/draw.js b/src/components/updatemenus/draw.js index b4be0da547f..99a4f5d5977 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'); diff --git a/src/plots/command.js b/src/plots/command.js index d3e217aa2a8..cb95591b3bd 100644 --- a/src/plots/command.js +++ b/src/plots/command.js @@ -12,8 +12,6 @@ var Plotly = require('../plotly'); var Lib = require('../lib'); -var attrPrefixRegex = /^(data|layout)(\[(-?[0-9]*)\])?\.(.*)$/; - /* * This function checks to see if an array of objects containing * method and args properties is compatible with automatic two-way @@ -23,7 +21,7 @@ var attrPrefixRegex = /^(data|layout)(\[(-?[0-9]*)\])?\.(.*)$/; * 2. only one property may be affected * 3. the same property must be affected by all commands */ -exports.hasSimpleBindings = function(gd, commandList, bindingsByValue) { +exports.hasSimpleAPICommandBindings = function(gd, commandList, bindingsByValue) { var n = commandList.length; var refBinding; @@ -81,7 +79,9 @@ exports.hasSimpleBindings = function(gd, commandList, bindingsByValue) { if(Array.isArray(value)) { value = value[0]; } - bindingsByValue[value] = i; + if(bindingsByValue) { + bindingsByValue[value] = i; + } } return refBinding; @@ -95,8 +95,8 @@ exports.createCommandObserver = function(gd, commandList, onchange) { // Determine whether there's anything to do for this binding: var binding; - if((binding = exports.hasSimpleBindings(gd, commandList, lookupTable))) { - exports.bindingValueHasChanged(gd, binding, cache); + if((binding = exports.hasSimpleAPICommandBindings(gd, commandList, lookupTable))) { + bindingValueHasChanged(gd, binding, cache); check = function check() { if(!enabled) return; @@ -170,7 +170,7 @@ exports.createCommandObserver = function(gd, commandList, onchange) { }; }; -exports.bindingValueHasChanged = function(gd, binding, cache) { +function bindingValueHasChanged(gd, binding, cache) { var container, value, obj; var changed = false; @@ -198,32 +198,7 @@ exports.bindingValueHasChanged = function(gd, binding, cache) { obj[binding.prop] = value; return changed; -}; - -exports.evaluateAPICommandBinding = function(gd, attrName) { - var match = attrName.match(attrPrefixRegex); - - if(!match) { - return null; - } - - var group = match[1]; - var propStr = match[4]; - var container; - - switch(group) { - case 'data': - container = gd._fullData[parseInt(match[3])]; - break; - case 'layout': - container = gd._fullLayout; - break; - default: - return null; - } - - return Lib.nestedProperty(container, propStr).get(); -}; +} exports.executeAPICommand = function(gd, method, args) { var apiMethod = Plotly[method]; diff --git a/src/plots/plots.js b/src/plots/plots.js index dad82caa1eb..25d3bfd9860 100644 --- a/src/plots/plots.js +++ b/src/plots/plots.js @@ -41,10 +41,8 @@ var ErrorBars = require('../components/errorbars'); var commandModule = require('./command'); plots.executeAPICommand = commandModule.executeAPICommand; plots.computeAPICommandBindings = commandModule.computeAPICommandBindings; -plots.evaluateAPICommandBinding = commandModule.evaluateAPICommandBinding; -plots.hasSimpleBindings = commandModule.hasSimpleBindings; -plots.bindingValueHasChanged = commandModule.bindingValueHasChanged; plots.createCommandObserver = commandModule.createCommandObserver; +plots.hasSimpleAPICommandBindings = commandModule.hasSimpleAPICommandBindings; /** * Find subplot ids in data. 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 index 046413ad953..d8af84cc96b 100644 --- a/test/jasmine/tests/command_test.js +++ b/test/jasmine/tests/command_test.js @@ -5,22 +5,6 @@ var createGraphDiv = require('../assets/create_graph_div'); var destroyGraphDiv = require('../assets/destroy_graph_div'); var fail = require('../assets/fail_test'); -describe('Plots.evaluateAPICommandBinding', function() { - it('evaluates a data binding', function() { - var gd = {_fullData: [null, {line: {width: 7}}]}; - var astr = 'data[1].line.width'; - - expect(Plots.evaluateAPICommandBinding(gd, astr)).toEqual(7); - }); - - it('evaluates a layout binding', function() { - var gd = {_fullLayout: {margin: {t: 100}}}; - var astr = 'layout.margin.t'; - - expect(Plots.evaluateAPICommandBinding(gd, astr)).toEqual(100); - }); -}); - describe('Plots.executeAPICommand', function() { 'use strict'; @@ -75,7 +59,7 @@ describe('Plots.executeAPICommand', function() { }); }); -describe('Plots.hasSimpleBindings', function() { +describe('Plots.hasSimpleAPICommandBindings', function() { 'use strict'; var gd; beforeEach(function() { @@ -91,8 +75,8 @@ describe('Plots.hasSimpleBindings', function() { destroyGraphDiv(gd); }); - it('return true when bindings are simple', function() { - var isSimple = Plots.hasSimpleBindings(gd, [{ + it('return the binding when bindings are simple', function() { + var isSimple = Plots.hasSimpleAPICommandBindings(gd, [{ method: 'restyle', args: [{'marker.size': 10}] }, { @@ -100,11 +84,16 @@ describe('Plots.hasSimpleBindings', function() { args: [{'marker.size': 20}] }]); - expect(isSimple).toBe(true); + 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.hasSimpleBindings(gd, [{ + var isSimple = Plots.hasSimpleAPICommandBindings(gd, [{ method: 'restyle', args: [{'marker.size': 10}] }, { @@ -116,7 +105,7 @@ describe('Plots.hasSimpleBindings', function() { }); it('return false when a command binds to more than one property', function() { - var isSimple = Plots.hasSimpleBindings(gd, [{ + var isSimple = Plots.hasSimpleAPICommandBindings(gd, [{ method: 'restyle', args: [{'marker.color': 10, 'marker.size': 12}] }, { @@ -128,7 +117,7 @@ describe('Plots.hasSimpleBindings', function() { }); it('return false when commands affect different traces', function() { - var isSimple = Plots.hasSimpleBindings(gd, [{ + var isSimple = Plots.hasSimpleAPICommandBindings(gd, [{ method: 'restyle', args: [{'marker.color': 10}, [0]] }, { @@ -139,8 +128,8 @@ describe('Plots.hasSimpleBindings', function() { expect(isSimple).toBe(false); }); - it('return true when commands affect the same traces', function() { - var isSimple = Plots.hasSimpleBindings(gd, [{ + it('return the binding when commands affect the same traces', function() { + var isSimple = Plots.hasSimpleAPICommandBindings(gd, [{ method: 'restyle', args: [{'marker.color': 10}, [1]] }, { @@ -148,11 +137,16 @@ describe('Plots.hasSimpleBindings', function() { args: [{'marker.color': 20}, [1]] }]); - expect(isSimple).toBe(true); + expect(isSimple).toEqual({ + type: 'data', + prop: 'marker.color', + traces: [ 1 ], + value: [ 10 ] + }); }); - it('return true when commands affect the same traces in different order', function() { - var isSimple = Plots.hasSimpleBindings(gd, [{ + 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]] }, { @@ -160,7 +154,12 @@ describe('Plots.hasSimpleBindings', function() { args: [{'marker.color': 20}, [2, 1]] }]); - expect(isSimple).toBe(true); + expect(isSimple).toEqual({ + type: 'data', + prop: 'marker.color', + traces: [ 1, 2 ], + value: [ 10, 10 ] + }); }); }); From 7db5f1fa85a5e6cb5c3b592c883410aa4db060cc Mon Sep 17 00:00:00 2001 From: Ricky Reusser Date: Thu, 20 Oct 2016 16:27:32 -0400 Subject: [PATCH 13/28] emove custom plotmodified event --- src/plot_api/plot_api.js | 21 +++++++---------- src/plots/command.js | 17 +++++++++++-- test/jasmine/tests/command_test.js | 38 ++++++++++++++++++++++++++++++ 3 files changed, 61 insertions(+), 15 deletions(-) diff --git a/src/plot_api/plot_api.js b/src/plot_api/plot_api.js index a7d492d46c5..ad6fea341df 100644 --- a/src/plot_api/plot_api.js +++ b/src/plot_api/plot_api.js @@ -1204,7 +1204,6 @@ Plotly.restyle = function restyle(gd, astr, val, traces) { return plotDone.then(function() { gd.emit('plotly_restyle', specs.eventData); - gd.emit('plotly_plotmodified'); return gd; }); }; @@ -1711,7 +1710,6 @@ Plotly.relayout = function relayout(gd, astr, val) { return plotDone.then(function() { gd.emit('plotly_relayout', specs.eventData); - gd.emit('plotly_plotmodified'); return gd; }); }; @@ -2126,7 +2124,6 @@ Plotly.update = function update(gd, traceUpdate, layoutUpdate, traces) { data: restyleSpecs.eventData, layout: relayoutSpecs.eventData }); - gd.emit('plotly_plotmodified'); return gd; }); @@ -2307,15 +2304,6 @@ Plotly.animate = function(gd, frameOrGroupNameOrFrameList, animationOpts) { if(newFrame) { gd._fullLayout._currentFrame = newFrame.name; - gd.emit('plotly_animatingframe', { - name: newFrame.name, - frame: newFrame.frame, - animation: { - frame: newFrame.frameOpts, - transition: newFrame.transitionOpts, - } - }); - trans._lastFrameAt = Date.now(); trans._timeToNext = newFrame.frameOpts.duration; @@ -2330,7 +2318,14 @@ Plotly.animate = function(gd, frameOrGroupNameOrFrameList, animationOpts) { newFrame.transitionOpts ); - gd.emit('plotly_plotmodified'); + 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 index cb95591b3bd..d3ff50a9911 100644 --- a/src/plots/command.js +++ b/src/plots/command.js @@ -145,10 +145,23 @@ exports.createCommandObserver = function(gd, commandList, onchange) { return changed; }; - gd._internalOn('plotly_plotmodified', check); + 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], check); + } remove = function() { - gd._removeInternalListener('plotly_plotmodified', check); + for(var i = 0; i < checkEvents.length; i++) { + gd._removeInternalListener(checkEvents[i], check); + } }; } else { lookupTable = {}; diff --git a/test/jasmine/tests/command_test.js b/test/jasmine/tests/command_test.js index d8af84cc96b..05ea23da2f8 100644 --- a/test/jasmine/tests/command_test.js +++ b/test/jasmine/tests/command_test.js @@ -4,6 +4,7 @@ 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'; @@ -437,3 +438,40 @@ describe('Plots.computeAPICommandBindings', function() { }); }); }); + +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('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 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); + }); +}); From 26ad1ff46c0ae24ac0827fcd9017f48696b7cf35 Mon Sep 17 00:00:00 2001 From: Ricky Reusser Date: Thu, 20 Oct 2016 16:44:22 -0400 Subject: [PATCH 14/28] Add binding baseline image --- test/image/baselines/binding.png | Bin 0 -> 29562 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 test/image/baselines/binding.png diff --git a/test/image/baselines/binding.png b/test/image/baselines/binding.png new file mode 100644 index 0000000000000000000000000000000000000000..6c69d0ea29c992677f76fc6656ab6d2c5a8aefb8 GIT binary patch literal 29562 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%0A7?i$p8GdF> z%nuPpi=C;W<=}y#LQ4w7LS;F?fkmEM7$Z@HN8e>M)P}(BhjJT%v@g&u;{9EIg}(nT zzs5)rJ9BfpV6~u9`7HlV@c0n$NUO1Ty=E^;XP%aL)xU#oZ9RggkR7?ouSCSV^?562;>_}6@VbohLTk>%Ox-vP7WLLaEYVP_tJ6vBSzbvcAs<~EZb5hhw> zTRZpV+Yun&`jH6grA8j>Z8#Fg%9Ez8H4Bv28}IT6Dp-r0i&$H}0{h4R<|nWh_@BB2 zeHKw9%qE7@^T)s_QEXQN+MeS*#fJZmqhR{vf{B^P?yFSMKWJU*7fu@7fS`PG3^LyQ2%})TaWyV25ALvkXU#M}PAc6D14k5;b!cv=_*T9P^z-wE$vmX-+ zb#On}F+U-+k9pVJ(SoNxFed$cpS=Uf5WAb1dIzychJL#(Wq1)j@hk7XHa`utd7j6* z04(Xen;Fi2>;UK^BI@wH=1+!;E|Ry1F_A2d2K?*M4Um6-QekkiJVkXQ z2@KqgmYM#bp=^}Ih(+30@8d$EBAAD!3H|X*rJI%#pdWK)eyrh65Y#g>eihW50EsyH z?=^=Pi)6GpyHE2-K7wPPbgB!;BgMxj`(&|5wry?|6#yk)C;})+*&)ny0^bU8)v5*h zYhd5+s8G$KMu;CXZN=CHfxxak;m5N>XqQ#=F&e{U4hFmO3F>cK1Wu| z)|vryYBqyA_=&x&n7)Su{WhUKVH%wAQkJAR7RiSjl2V07hsznbtTk9?gs5q-l*DPU zi1PN!bFKyx+fjMvu(kqkc*g&o-=GzQ1W@J{u@}4?Q^rVMr=CTW9lEcgB@Yij(xVXn zZxE)*b9o;g<`?Ff7A2$7*#fkAhrkjb$VZep3dR-B!7&}q1+{|wg zDZPH5p=Ycq+M@HNGSLdLu^4jN{Y4`DOK3&bK25MW%8toSqj(9bvM>3MXixtU?G=D! z6Qljx1I77+cnVR*8g!06eDXs)7nRJ{RKWDEx5;eJF$9$r6le+6d~*i23nW|T6GILR zqko(EDFpP`6eDFQp*}GGC1~XQ&^g3RLym)<1`oMN>H<-^d7b(^haPt}8LmJ=#VUXf z@CJ^?QeI30t9RbegOyeaevBp~Fu9`-%`ItHd=4PHH&pKAK*>ThtvuGc-CAEZHfNOb zOf|)CSaU5vapQTrAAItqHgZ!w)n9I7VK|K@P_Bm*7TW(lly-czTN`#AP9^)zak?ir zZYt+PviHKdvq`ScR;QyEBRPxR_2GAo*ZfnON%utu?Y-Ek>pIc%JLY#|lV+#UW@Qc& z>4l(WN^F$^l}z>#NXPQ#LC`++Gd=Ha;Wg=DGf7++3xvnc*4je&^nBS!?4U!X=0eqm zDY3DBgDP|A00btX;J$Ynm}-)=$BC6`V{mF;Yndi?$W)FL0UbCzHe;<9)Vd=yxJ$sq zi%XA`F)XNtDK89|z(cqS8rfdpiIB&Ai=QTXP zSaJ#f*THdZ^3d#S*UwB`5ve{!oefZGxg#{0jLIg}>#4*xAxQ2?n* z6XAi!207Q!82M_S;=-rnY>vnc}7UaO7LZV2cG0c|&zY0!2%>dh0#iHX16W>1aqFk1avZcZIq z4wbs_cNb$+hY>h>&;2C^fjvy));)nmQa8#~%EL#fRQwX%HFmxQ13^4T%FA(gLn6{; znjPab732fDqV6)|IYhG$>FNk*Q3k4|3Lu~dA071YCsZ>>?DA?_82qG#H{XCX?<~Oq zdcFNNVT6I^Tnv?;g|HUYLe}a@5#z+ zEr-RccQn^>zcdDOJ3ouhouEEVl)&@mALK%Nj*qycN8h`_L9r%DXMrctjK&?% z2=_vpf*b#YB{wLDBoLjGn_KDig8|+a7v1ZkG4<2$HRQzfo)|=%{_9Jw_#PiLkkB{g z0o&ag4QYDhFHLclA>S1NWje2(Ph+ZXZEwW?&+>f^$gDqGBwJ&}1HL8{S@c!lJCefu zR_qx4XExPZ|GJd!FA*c|189&aTa?*qde^Gy=}Q7C1|_rN_;Ww#h7xger@OmXUx?eB zj~BOjibs5ji6GREo?PySz#6BYqbTr#?Yxbk;5~iIeS;2*gw)@bItO&u-&JQ0qSASX zsB{+gFyKpK47zRpwO!YNXZb&T4Ktv1=K*+Fuvwx7aLUws#fwoxse=zM(J$z3-nODojsUgU8hX$^&x z#NHb3aQu-j!P9unI9M_fvYg%ddY22c#5mD)7>guZ*B}8%{9WQ8do03Z3v=d@53V~R z=DU#0x4*nVqWJI28+w$ev+Xz;g0_oJ{l{j+;bW&VlTE-maL+4ZJ&tcpfENN2Nz?L& zJSYMx{4pM6KFf&9-3Jv<;>L5KSAf1q)WS_psZcFMpW|u?ABl(yNPN@ZFkY&=HjhHIk8CkY1ew7GT{@vP!QP6BNt{MTu}J%91h_F zyGg{&5$}jCydAg7-r{zIS`>*qJnZ>IuM0rH{|*5KI#c@LF5ltfT*n%pzby$qK4~OL zw3CPs?If>|z9SIW))O5@V52!|OP$1tk$?ZxkcQ(pkfg+aLFSbUp)Z=aFc%M!Jdh-I zD_B9>p7p7MT>v5Y#nnrqU{xH+OX9A9?czz3)#_%;b^~2oKvgaokYkbL4bKv?|V68T=Hfs&fWf0WqAehB*52ZK+ff$rj=yr&7A z7>z;AOI`>XYn?qYVAa375y)xDD?#>|d+jT_&c0_kF}{fc zr3!Gt%yr+^U!in-Lg?{NFTfAaD>vESAxx_}e}-0-;*v(I7D)a{c`x-JgXSHBK4^mG zUB&>luIhTc?IS`$n81y9)?_H0fx#jwciVk!7wvU27#I_ja-!;EzyvA>}z*P5*NDM85&nkmx)<%;MtwXCM+!c@P)+Jz8;3D|tX9z#FJ%p#=eni&{tf&tY%mMNVeiO|CIP){h zn?4*sXNz(bbqe~KqzVM~%{Bg9EI|Adzr}5G9E%j`wA)sM7mX)$-QW}_OR&p|kz_39o_oq01Ns7Yx%P+paHh{`490cjt{7{|HY2fRt{v({A z`&B`qgP>bmxq}8`6mzoAk~-0_4O9*(qOT+)pgR zK_gv20UW|3H-GiVfuZ49s~7~;I`kmdK@I5R*jN2z#?abh>(95A6#G%LWTL6=5SW}f zw=tl$7doC)D#C?Hl0P~^U^lpHXOH12&PQ|e+h~CRT6nS@TVW2;-?QSPMKXMAvZp>5 zC+1d^mK6&Edb%Kc1Mu(&@v?)|xSzEb`>$YJ<<18LDaVBxZ^vXV^lu zVkT1k-jAkN{C+fdN3yShMjdc!iGiPI-clk_fw$aF6t;>Wj@%VGjj9mGfoX6`LQ;~} z;RmorA7)3(BG1RU_^vFl4%aux_*6CnZDX!a@X8znCLdsD$+W-jxJ#STOYdnf!3nqH zFMAdydd*}HLQu_WtPNn?mcQY&M6XLd_VKz_y~JZxxr{hjhjka9nQ^z}DKb<`ZTpVu?nZW> z(tw?G*8D(mlyi-&+Xc`VPa}UU+in#w^s+LQIeD2&f z;1ob_iaS@fXJYDmH`=zVa&~>Wdo;L7)qVJ4w-=*;3CIz@(i>-+smYSoj9Mv{cjF)qb(ji0gIU0pDW%?|8dEOLrjCvHhjT#uqV})iml1pYm3Foku{f zV~%-|zrJrt(FK;2GY*bJe{U~Aq~e^cy}Z5U>bJ|qMuXk2pJ2ZwJ#x9z>|H(Lex z&aI<8;Ef-A5lUg%mUzK3&9qfr;o;0wS+OEGJBC4L1;zNc137>8Z&7^ChSqdA^V(M0 zt#0Cj9;G+(afLMw(p|n|589ZLuD8G_SHbCn$etgv7Y)cN)BM{i5Y;FVE{|=57=yCPij!3X31H=u7{4X{< z@EW451cYiaYQqC?@WwSkuw9<_DuGa8!(&42f319nQt)B*vP}V{<_pF z!jBh}?)flZYf^j%IT#dEdhYo`=wL}^)x~G#vB2#ZuYN<11EUs2j$$JwfS0-mVm7!2 zvO(&F6GCTzyTG1wi%YmFTA{o0+#5{}JiE~pCB8M-#WbBA;~Ayx2Ym8A_0|zUic^Eu zRnvfW`^1pjGpvyVGC-!sZqHx(o~u1u_WEFu1-%rJmpuTyYz3cO_Zk9Yc;3x?n8*=w zfC>&2!;%$otP#jp8!zcw!C@#n$6`FF3UWH$;bgs7&|JC-yvQnf{1F5eTU>M+pn_PB zu5@tkw5_j!YzXY+8!V;MlMu=+ptG zL?B&&>9+0QdlpuF`0)!UVAaAjX30885FePYY$dLGLf;AOslPqxv=yDz5d@U7iqNhM zuWf(bMM?vVsXrji0!;*WUJo$bg0Bm|_HeP)mqZ zNZM~!&*#CQfpZAyUPkmV6wTLsKlvN)?Y*YNg1UAb!E=`Ob4!C}iAd+0EpURHxtf5v zk=xpGJ2Jy`c){@U*O*SlWPrafPiM)BVUaSU z7!@$D!o8ZkumlR!}YQ^uel3j89t3jr(rAn(16U({&@|5vD)OG-5Hm;)0}aFimG9y`YR zT5ACqJ(lVXjQ}j_ad#y#q2`vMmD4~$=+bEg$!oVG&u@ci(U%zt+M8I*32sE6hAurl zur454-va|m=nF%E?~WU!rX$E89yMnxexB8QP;_sAq8k)j1(b}c4=;7WKv0@RE=Yj< zsLY=49hmOpT0z7D8afzKc|SYWj5sOoB)rte8*5Qyr zP<_Br_4E0xXwTL(nhh-bQ0_k!+kATj_)+t=k!Fucm|l@EcrTE@1?!38tu^Yw{ow!^T9xJJoqy6MF4fs=$B z%uKzLOXbtyQywF;`i|Bw2ZWC9(gaQdi}XH{%>WYKV`v{}Q5Y{Zu(A+QUJ7N7JY3Ia z@_hH%gsKUUWmh{Y&)w_?^#?f0eqxYYf9O3c)8Rmft)@}64r``?`oHR}Ehfxl(hoe! zg^x6#a&XNCgHrfUv{KYrfh7u?^vZDN#aesxkMe&3UFxw=M%FTOLmpnlnba;0fyoRN zG122&AG@xPvSa#%wVjm^P_+ccbqhVXIsiPkRtt^l?7diY2P@6l9Hu;UuJAkjb}S5u z$0Dk=#tZ@7BmbfcuyI=4XoWto=Yz{h6CnK_oj>%~AU+HzbusHi zWRtdfw+NGB!*J)@Z$_y(d1=Lq=p_FGhp8F?=OxuUsR#1P2}7{R(z^s9|*; zdq-u$2h0=iPxAaS6Mqm@g10J{>b)bL_hqo0U<;+4-1N$p(gx`$eHXR%tL1%=VeVt{ zN>t$aXBhHL|0#gi2n2y=J6gr&0N_!~Lgrs$LHC4s5KzX5v~?iU{TC#5$sVZJ3DV`O zCHkOXT`rH@%t|;)KjU4;j^W6BW~@m}oQiYD@e~(h?ty`eX#5L<^AMQn*xd+ZF<~ZW zUOLR69H6oNyt1cdNA0-vdnFjYUTEPk>N;zJL&0dTyxd z${7#6BJiLEJ@f$g(#KVvyXTPxw)}0|U7-C@&Gd(d5-b}eXQPNOMK~)3CMpElKX~OT z5yQasOxg527GT!fNCiupQgLdc)52PRzn<@bdj3uLN-jVrzgZq1Aj(#Ci+cBuBB;yn zxpK-CiIxo>EpVIfb)BLhu)3)LSsFZr%u9W;Jj4Ar!Q2}twq72vMK%59e|Q`WgGRGT zd)XbfT^?&Hyx+-b-JKb}T~AkVe9&sSJebGx!rno`Kd3lQClHG>KY_C1( zhLG>AtN(eZJJ5=q(74i^ahL7&7zq&=3CTi`rx-(RRq^gvhO zgPC6er?Jr)uiu83MHavi^Hv&}wJBvg3df#OvAmbScqi-;rt;8Ee3f_8ql41-zCL6v z)E*JCj#V5vM{^CYch1I3FjItO*iz$mcc!Mpd(g#|8I58TBKtW`OmtN-)Lv8xK7#Yz zwkHm%wm>7(eYV#<$&FrI%)J2zFF=t+(+|6}OP6spc5BO;fI(l8&CkbV%k|2MW8AAV zJuSx@|L#4n?7BZJvF)>Tzj*oWHwuY`;2L{j(;A1<B*RkCNjy*2OYEv`3U;93jVv}^LQCN;iQ+ag(qoCod8nyNN%{9qK<#Np&$ z9btmS!?pM9d;3!slmLDQWvCk??MG((bRtzl%%I9~9ktm;L*!D-VGOpW2!nTnH5ogr*|Ax~Hm#@Dbn8NsV7gdZl3QbS-71mROm z_|^l?T8hL$H0}GJa_CHlLsm*qKAPYKe9wa`MJ^NmI=tXO{$uJHwE*6`H{?e`!8GRk zNesMN$8#)7)wmKvm9kBkFyR_CP&t6le}arDqBi9R?YOhQx8v%;Z`ZwboxZW};3p0= zn|KWO*uy&5(glsnuN9PkAuW5HyXU@CcT(zS)BCZ5&;148s#|6x5LoZ~B4#S0&xMtX z1LJwE-2p%asoqnVn+ii3aQ2ws|NC0x% zFE`Q95JBoE08%;To`e7w4N3A65C-f25@rQ-;}kr&7Faj1TIOI3_2}zcpxprGmQsL4 z^Fg_lDBu5zSGerE3aEgqAVRzkh=#u`G9Q6OMn^TV4Okx}jTNWpiNkxVV0f?lF0(>A z1O_?xN(c1CDEsn&gb0c%0TflN+~FBYVoR6qDuCh-nXUn8`hOv5f~^0F(bNnAgU54& zK^P=m(}OJ{31amIOCW~5Ed?xk;XF}TYMjyql0jhkwi9_vrU<{w^`QiK#SFg1fIA1~ zo>gG(>7f0;x;xXLrn6;%gQy6KxFITF1{F{kKn0A5EDDN(vd9t@Nnpf_f*}G4n|6ZrB1sA`(crow=iMt6sf&U*3oJK2TJhI{8=r zC#Snl_v!xiH58&!DFMUr+*+EU;TTMW!`jHf|F-OqdLvzP``W&IiPL7ea3K`EI(r>d z=4iWMSEF5m59Pz*(yAUVHJHxm|3UFAm|Qd@MWAm-z;(k?rQ^fV|3&Vw7*TQ<3T6&R zUZz4_;eQ6Kf0X|LqNG_J?6sKX@y6bG0S^Wt=^P~7+bp|);sS_L;C3X*EkMfvF`56_ zLTdq9CJPWTS#Rw3{KuoBml(=- z{|?g3=-VAPt!met1Q2BCWEwjOAsUZmJomJ1J_B$F+(x8NxAQ;6n2wM8hJP&<35M&w z9gQrId3vV!M9#50Tf~shp*$+on^v`X5&Q@0Z%89T?Zl)^fNli*Ug?5DY23n)ie2vZ z7FfG|{q#PQ{M>Z%PUDrHJ$^&2kA0f^CLhd|vW_~rL%L8#6Tyk|J7zX>78sB|tGdSk zRp?i(8e8zmbGxNBq5bKpLLb(?J3HMY@B<_N*{KQKt*I$59p2SXUrSx{vx*#jAv$PkQ}1!BhvJD&pkfiVx?8 z*=L1bCE40?f7?uJyjI;0qd5~3|8`F}ur!Yxj#u6>CuGlIFK=;jHm>yEKCXxfZ6>Nc zaoJ#1Rewj1h5D8LKK$*yPi*=5~pSuJ(ao52B)7#L( z5JyzIf-pyGM80S*KCQvo>xzLLjIM|$n`XxIVEk*-atq$ zAC$#8oS-KhBft0tq|Y^14ny4iaHMz62+$jO>x4$czd<36%FCSe6UjgXovYIddgur0Z0}#E2DS` zV)xJNoUY*yO+~G>xpecPi!x9C5$Q{Vd+cj5CX>X<4<51#_wn7Z(=ALnu-lhgwMxLx za z#~ca9wVC>MTJlNRto3p^0jZ=;r{vEx2?Zl~;b+SaO!*EGgZMr1bgT3!9~B_mK09Sc zS@RvlWQ05V>OzZpAjYyR*Kg+A=(!(BW%9d2*p@>V z2o#~wDf=DsiqLEc_&&1fzOwT}VJh&I?>&9AZeGo%FM@OJlBT^&=7(Od1T|#i>~2$Fl)t|0fXHI%}?3sm^2VHGhHZR*5)=g@iCTE{%E|%3eSSQ-C3@un#*H3>F#cr{g<&Xv;ef%rEu3ZQ#N${*%WG?CIFjI5%T3JynZ*2xg8^>>xeZM7ihv zayp;pF-mWk0Bv}Ljdnf9EH{P;dt^{%feRKj4)lNwTAoTlYgylS+2Lx$Ji@+Lax^z} zWy81f0HOM+XA^S4XGfQIc+=2@4pSdxoDZNDe0&*BmigM6TB>7xt3vZKm;M|-_A-*W zEDwXxZQMV1&1H~PgbSqlpIU$?vO3W#m`}1Ez z2Glw{&2Y{kYpWx4er^#n2iVK?-Up{fOkRv*cBjwT^k|hFk8Mrv$<{5^+!{0T@qW+k z;7j{{3WQnE+VZ6${wK{-w7%mV{xtNn5Yj+#GUIVh*+{u{<4NJA-9P7sy>4H8vM(<9 z(Eji5c2D=dHn~CDt*_!t+p~XqMAoo(tJH7K?*dB_VSKN`zZHVp zb%{dno%)bwcD6r-m(adhSmj?BJn6Da^ONHbd17pPEn?{p<}i_!N{QMuwcll~xBL`k zvszF?x5E-c#@{_}%uiAY8n>v|->dRd-CP8uUtF-$`1!uu5{C1`5061st;Bd<9Us+pMQsVz}*Re`Q zd)tPGn9Mr8#)>T^-$qs8k4sn?_jAigEY5@)wXCmRMMV^zPOwile)!B*VgR@OlNXgT zL<#yr2qyk}TR$C9zb)zdQ@Fx&;I*QtGOU)F*$PY|nHp*?#&+K`B3xfN+F5MBr-GmI z)RlIsC-|Q#gI#%fE*&6t$14?@ROWY-*;&}H*O-w#xq+jCxWd7*%fmkGjC=T=j4b@% z3*xmbhQa(~iOM>IdG+{+$Oaxq82I>fe(l>pKjx`>sn4tOC&0i@ zJHQ1NVIuI;^doOCzrmou1n!#xs&!l_sj=iyDvg_nGgg-vgkM;_*c_ z5`Iyt)ms-mU6Fdza_RN0D-LX4?RKsyY5(ppW%p$hS0uCwa$sio|F@ZL;dU#3E^(My zA^hwf!3?DZLvAqidQpb*4{?AlEFK_CH4_h!3x-~U5ei>&(C~)rWh(NWey3q#-#mSHv84CnmBu`~Y2g@otk!3i z>;lyz2h-OqTG%xqv!f;WxZ^>Cr~V`Ww$}|bq<#7RIYBXU$^~AhN`s!^NB*?8G3GmV z0D_x!-qx6(^jF{{P6=5XCMne~E>f`1I{Yj<QS~v8-=Fss29R==xrRqb8!hmQL684kC zP;{MJxerLEy}Z2sa;&`PfVobms-p5wtCd}zN`VL&N=#aK?d0+N_hCwj%|61=HgOV8 z+H0e+(ybT)<5stKI$#2|%a@*B)1!zEdUJ0Q&hcGkIlw)%KztGVFLeO@^QerH@lpQL zQ$Z#hPS)$O=E%*56l=uTUz))cbC0?dZJkIl;t)-mJ{MT_ZL|wTf|GPzVELp9xNmo7 zM?6janIecEaYQBA6)0`jNlh%R+wb=omnkY)XwBmXzgult&1ufMn!ZBv915%`QNX() z20cPJuD?IgHh@XroXw0SsZowW%&n{GvzQ}o*c&FbsYjQ@&%=o0YQyNu;^Lbbqxa4G zC5aE3rj`heCO;_Qs=BO`tO(N7mZzw-@95 z#raM%7VL83Wer=w79}_~uYDp8PJRS`JBoH}Jycy};|Cey|IrXdzLvdwz36SiT0yb<>j zS=lMYX$y~CF5*+`s!YD^nucZ;zmP#7zJT+7v{$pR<2@9Q+`Twdlpjm#bqHrXJsl#) zk-jnzh4OE@nEdt1>a8Br6JI989V{sHAx#bJzL;-$zYzdNQIm!%@7PHhc(f=NcT9N; zFgqTIn@^O_u*T@bs9Gn(fUzD{neT8~nW&+e8M9gYTsETJg+Aca$&-q`UjklS9XL{m zsc;YQm$_rgmUWAh?ui=ggWIOUfjRlnbyyH?VY*!)J0@;ipz&6K-(Alm6ix;n%+$t#IB&R-_IXb;&y56RFX zs^tiBC|XL2XD)@$d}ioJekdDE3#TM;5Hv{3*Wlj#8#R4k2<{6@x9gVn=%L)eGLHCS>G5RA9|b zH3${F$VV&D4^s1VQ?F76=AgOB4M6(WMJ!qF0igu$+sxWzw3KG4^4OZDWR%c3>qX~7 zavEb~xlzOPh>KW}c4@t4-Dk;Yhz@WOQ{yeq@;ZQeU6M z;!LL|iW(UCt3zf7ZOMLJ{k*P3pz`;x`|#z;xcYT-|FjkY!5X!Sul43bESKHZ_71}G zFZ;;t!Yj}X=1`DJ%|X+v!?IM$nW7h!T|pJCsRrKN(yPwpNn(&-=c@3oh|fG8`qK##1Il&s3Jq5kFRzBXLWO4Hf#d=n0N*oEzv znO&KO&~dy@_+7y=c#!~|f0@BoLK!DfMcC#k1s3{y7hCkweU}x+TwrvAfeddb^U;@X zD0s0f6HCy=wJ4zdG#?yauf3{g@phy}DV=&8Do5qaP#NfuLA4H0^Q`kB7!zSuw&`yz z$aSvATNT$r_k-As-49K#Gup}-o6$tQrnav+8#%dzn}*01+q`j6#>+03x=&8c0C^!)@i zSB#!!L9MgtilNvQ#8HlHnQVb9g;LGQKzQK^hq^dJYE>nRdFEHR=DOI53-#H>NN!3Y z1G^fmB*ev0E*Ipf=qftKmCVa!(tUi-m&T$jn-Eb$$OyQUN^^;BmG2c`V?}bBqX*iU z4@mhG#3df#9s1kA^X93m9nU>W5cnbI$AbbQTy`y)pj;otJGwgFxZJ`G6y)OxU<0$Kf zQ1+S_L6uEKErYs-uk|Z&*Pz$($}Ui?MP}^VguDm}ZxEs6TGWP>X*idTIV2pL6H&X6 z`C2El5n7z>`7%_JRRsjByMy<|`(T@pTN`XlUvU6egJ?sp7Ygj=s-0#saGSD`u?*U# z{IaE|ORUWY8*+J4?-@f*7mBReXLw4hjq&o2DfRk2YcZpzIJ11El?q#l4IJq|?i*jG zQ+){vrG|Z2?L3g?DB6V9@s7b1&9WRmMNg30?DQrl@IupSp0S16F^s$*%iQ(IMw8H> zw=>Zct0C#&2D@z3xsy{Z{uiyy*c#51GlS(SjA1%HS0x!^jJLNSUwL|$uT%~mha*sw z7*Nv0M&TtbD)Jfm{(`{A*(Lhd9YiP~+mV+#u39BY^lvsr)A!{Bb~MSWX*vlA1r)R& zXl>Jzk9b2&j`H-{CR`jZZI!SfCzc!7PIcnvxvX5~s3i#+K8%65r0&Z}krwt=x<^8c zYOBVR`YT~1Su&)5cQapwaB>5Lk`X-#_Bh15XWHS6(I9;ElWiw2)!qv0@kHT#odjMk zxVqfJuJ4v7rk$%ftE%stG2|nAS2uNdZt#SBij~G6wUs4>vL{h^eym)!OWa_71pdML zs+WuLUGWs|kX0r{S1^mNrRZ%|JsA* a5J#AyQi-^ePobx##L+`G7L Date: Mon, 24 Oct 2016 10:32:48 -0400 Subject: [PATCH 15/28] Fix irritating self-interaction when dragging slider --- src/components/sliders/draw.js | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/components/sliders/draw.js b/src/components/sliders/draw.js index 7c25e5b3a52..1239bbf3cad 100644 --- a/src/components/sliders/draw.js +++ b/src/components/sliders/draw.js @@ -73,6 +73,9 @@ module.exports = function draw(gd) { if(!sliderOpts._commandObserver) { sliderOpts._commandObserver = Plots.createCommandObserver(gd, sliderOpts.steps, function(data) { + if (sliderOpts.active === data.index) return; + if (sliderOpts._dragging) return; + setActive(gd, gSlider, sliderOpts, data.index, false, true); }); } @@ -358,6 +361,7 @@ function handleInput(gd, sliderGroup, sliderOpts, normalizedPosition, doTransiti var quantizedPosition = Math.round(normalizedPosition * (sliderOpts.steps.length - 1)); if(quantizedPosition !== sliderOpts.active) { + setActive(gd, sliderGroup, sliderOpts, quantizedPosition, true, doTransition); } } @@ -382,13 +386,7 @@ function setActive(gd, sliderGroup, sliderOpts, index, doCallback, doTransition) var _step = sliderGroup._nextMethod.step; if(!_step.method) return; - sliderOpts._invokingCommand = true; - - Plots.executeAPICommand(gd, _step.method, _step.args).then(function() { - sliderOpts._invokingCommand = false; - }, function() { - sliderOpts._invokingCommand = false; - }); + Plots.executeAPICommand(gd, _step.method, _step.args); sliderGroup._nextMethod = null; sliderGroup._nextMethodRaf = null; @@ -410,6 +408,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]); @@ -417,6 +416,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); From 20ac69ff7bac1bcdb41a1e10efefd269607453e5 Mon Sep 17 00:00:00 2001 From: Ricky Reusser Date: Mon, 24 Oct 2016 10:33:50 -0400 Subject: [PATCH 16/28] Fix lint issue --- src/components/sliders/draw.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/sliders/draw.js b/src/components/sliders/draw.js index 1239bbf3cad..ec177431928 100644 --- a/src/components/sliders/draw.js +++ b/src/components/sliders/draw.js @@ -73,8 +73,8 @@ module.exports = function draw(gd) { if(!sliderOpts._commandObserver) { sliderOpts._commandObserver = Plots.createCommandObserver(gd, sliderOpts.steps, function(data) { - if (sliderOpts.active === data.index) return; - if (sliderOpts._dragging) return; + if(sliderOpts.active === data.index) return; + if(sliderOpts._dragging) return; setActive(gd, gSlider, sliderOpts, data.index, false, true); }); From f993ed78c2577d09f4642a1a9cacd9c343016eb8 Mon Sep 17 00:00:00 2001 From: Ricky Reusser Date: Mon, 24 Oct 2016 11:20:22 -0400 Subject: [PATCH 17/28] Change failure modes for command API --- src/plots/command.js | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/src/plots/command.js b/src/plots/command.js index d3ff50a9911..11f09f308d9 100644 --- a/src/plots/command.js +++ b/src/plots/command.js @@ -221,11 +221,9 @@ exports.executeAPICommand = function(gd, method, args) { allArgs.push(args[i]); } - if(!apiMethod) { - return Promise.reject(); - } - - return apiMethod.apply(null, allArgs); + return apiMethod.apply(null, allArgs).catch(function (err) { + Lib.warn('API call to Plotly.' + method + ' rejected.', err) + }); }; exports.computeAPICommandBindings = function(gd, method, args) { @@ -245,9 +243,9 @@ exports.computeAPICommandBindings = function(gd, method, args) { bindings = computeAnimateBindings(gd, args); break; default: - // We'll elect to fail-non-fatal since this is a correct - // answer and since this is not a validation method. - bindings = []; + // This is the case where someone forgot to whitelist and implement + // a new API method, so focus on failing visibly. + throw new Error('Command bindings for Plotly.' + method + ' not implemented'); } return bindings; }; From 99d7c8e7bf4d96ae8ba0ac30427a75f9ec2786f4 Mon Sep 17 00:00:00 2001 From: Ricky Reusser Date: Mon, 24 Oct 2016 11:22:26 -0400 Subject: [PATCH 18/28] Ugh linter again --- src/plots/command.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/plots/command.js b/src/plots/command.js index 11f09f308d9..2c37ac8b5c4 100644 --- a/src/plots/command.js +++ b/src/plots/command.js @@ -221,8 +221,8 @@ exports.executeAPICommand = function(gd, method, args) { allArgs.push(args[i]); } - return apiMethod.apply(null, allArgs).catch(function (err) { - Lib.warn('API call to Plotly.' + method + ' rejected.', err) + return apiMethod.apply(null, allArgs).catch(function(err) { + Lib.warn('API call to Plotly.' + method + ' rejected.', err); }); }; From f8c0094d3291a24f4f8877224ed06416d79dbdfc Mon Sep 17 00:00:00 2001 From: Ricky Reusser Date: Mon, 24 Oct 2016 15:19:09 -0400 Subject: [PATCH 19/28] Improve robustness of command bindings --- src/components/sliders/draw.js | 12 +- src/components/updatemenus/draw.js | 10 +- src/plots/command.js | 247 ++++++++++++++++++----------- test/jasmine/tests/command_test.js | 86 +++++++++- 4 files changed, 249 insertions(+), 106 deletions(-) diff --git a/src/components/sliders/draw.js b/src/components/sliders/draw.js index ec177431928..d16814586e8 100644 --- a/src/components/sliders/draw.js +++ b/src/components/sliders/draw.js @@ -71,14 +71,12 @@ module.exports = function draw(gd) { computeLabelSteps(sliderOpts); - if(!sliderOpts._commandObserver) { - sliderOpts._commandObserver = Plots.createCommandObserver(gd, sliderOpts.steps, function(data) { - if(sliderOpts.active === data.index) return; - if(sliderOpts._dragging) return; + Plots.createCommandObserver(gd, sliderOpts, sliderOpts.steps, function(data) { + if(sliderOpts.active === data.index) return; + if(sliderOpts._dragging) return; - setActive(gd, gSlider, sliderOpts, data.index, false, true); - }); - } + setActive(gd, gSlider, sliderOpts, data.index, false, true); + }); drawSlider(gd, d3.select(this), sliderOpts); diff --git a/src/components/updatemenus/draw.js b/src/components/updatemenus/draw.js index 99a4f5d5977..2614b08f132 100644 --- a/src/components/updatemenus/draw.js +++ b/src/components/updatemenus/draw.js @@ -113,12 +113,10 @@ module.exports = function draw(gd) { headerGroups.each(function(menuOpts) { var gHeader = d3.select(this); - if(!menuOpts._commandObserver) { - var _gButton = menuOpts.type === 'dropdown' ? gButton : null; - menuOpts._commandObserver = Plots.createCommandObserver(gd, menuOpts.buttons, function(data) { - setActive(gd, menuOpts, menuOpts.buttons[data.index], gHeader, _gButton, data.index, true); - }); - } + var _gButton = menuOpts.type === 'dropdown' ? gButton : null; + Plots.createCommandObserver(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); diff --git a/src/plots/command.js b/src/plots/command.js index 2c37ac8b5c4..8af5aefba6e 100644 --- a/src/plots/command.js +++ b/src/plots/command.js @@ -13,92 +13,63 @@ var Plotly = require('../plotly'); var Lib = require('../lib'); /* - * 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 + * 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. * - * 1. multiple traces may be affected - * 2. only one property may be affected - * 3. the same property must be affected by all commands + * @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.hasSimpleAPICommandBindings = function(gd, commandList, bindingsByValue) { - var n = commandList.length; +exports.createCommandObserver = function(gd, container, commandList, onchange) { + var ret = {}; + var enabled = true; - var refBinding; + if(container && container._commandObserver) { + ret = container._commandObserver; + } - for(var i = 0; i < n; i++) { - var binding; - var command = commandList[i]; - var method = command.method; - var args = command.args; + if(!ret.cache) { + ret.cache = {}; + } - // If any command has no method, refuse to bind: - if(!method) { - return false; - } - var bindings = exports.computeAPICommandBindings(gd, method, args); + // Either create or just recompute this: + ret.lookupTable = {}; - // Right now, handle one and *only* one property being set: - if(bindings.length !== 1) { - return false; - } + var binding = exports.hasSimpleAPICommandBindings(gd, commandList, ret.lookupTable); - if(!refBinding) { - refBinding = bindings[0]; - if(Array.isArray(refBinding.traces)) { - refBinding.traces.sort(); + 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 { - 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; - } - } - } + // 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; + - binding = bindings[0]; - var value = binding.value; - if(Array.isArray(value)) { - value = value[0]; - } - if(bindingsByValue) { - bindingsByValue[value] = i; } } - return refBinding; -}; - -exports.createCommandObserver = function(gd, commandList, onchange) { - var cache = {}; - var lookupTable = {}; - var check, remove; - var enabled = true; - // Determine whether there's anything to do for this binding: - var binding; - if((binding = exports.hasSimpleAPICommandBindings(gd, commandList, lookupTable))) { - bindingValueHasChanged(gd, binding, cache); - check = function check() { + if(binding) { + bindingValueHasChanged(gd, binding, ret.cache); + + ret.check = function check() { if(!enabled) return; var container, value, obj; @@ -117,7 +88,7 @@ exports.createCommandObserver = function(gd, commandList, onchange) { value = Lib.nestedProperty(container, binding.prop).get(); - obj = cache[binding.type] = cache[binding.type] || {}; + obj = ret.cache[binding.type] = ret.cache[binding.type] || {}; if(obj.hasOwnProperty(binding.prop)) { if(obj[binding.prop] !== value) { @@ -130,15 +101,15 @@ exports.createCommandObserver = function(gd, commandList, onchange) { if(changed && onchange) { // Disable checks for the duration of this command in order to avoid // infinite loops: - if(lookupTable[value] !== undefined) { - disable(); + if(ret.lookupTable[value] !== undefined) { + ret.disable(); Promise.resolve(onchange({ value: value, type: binding.type, prop: binding.prop, traces: binding.traces, - index: lookupTable[value] - })).then(enable, enable); + index: ret.lookupTable[value] + })).then(ret.enable, ret.enable); } } @@ -155,32 +126,111 @@ exports.createCommandObserver = function(gd, commandList, onchange) { ]; for(var i = 0; i < checkEvents.length; i++) { - gd._internalOn(checkEvents[i], check); + gd._internalOn(checkEvents[i], ret.check); } - remove = function() { + ret.remove = function() { for(var i = 0; i < checkEvents.length; i++) { - gd._removeInternalListener(checkEvents[i], check); + gd._removeInternalListener(checkEvents[i], ret.check); } }; } else { - lookupTable = {}; - remove = function() {}; + // 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() {}; } - function disable() { + ret.disable = function disable() { enabled = false; - } + }; - function enable() { + ret.enable = function enable() { enabled = true; + }; + + if(container) { + container._commandObserver = ret; } - return { - disable: disable, - enable: enable, - remove: remove - }; + 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) { @@ -213,6 +263,17 @@ function bindingValueHasChanged(gd, binding, cache) { return changed; } +/* + * 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]; @@ -223,6 +284,7 @@ exports.executeAPICommand = function(gd, method, args) { return apiMethod.apply(null, allArgs).catch(function(err) { Lib.warn('API call to Plotly.' + method + ' rejected.', err); + return Promise.reject(err); }); }; @@ -243,9 +305,10 @@ exports.computeAPICommandBindings = function(gd, method, args) { bindings = computeAnimateBindings(gd, args); break; default: - // This is the case where someone forgot to whitelist and implement - // a new API method, so focus on failing visibly. - throw new Error('Command bindings for Plotly.' + method + ' not implemented'); + // 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; }; diff --git a/test/jasmine/tests/command_test.js b/test/jasmine/tests/command_test.js index 05ea23da2f8..707bba7c652 100644 --- a/test/jasmine/tests/command_test.js +++ b/test/jasmine/tests/command_test.js @@ -456,6 +456,42 @@ describe('component bindings', function() { destroyGraphDiv(gd); }); + it('creates an observer', function(done) { + var count = 0; + Plots.createCommandObserver(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.createCommandObserver(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); @@ -464,7 +500,7 @@ describe('component bindings', function() { }).catch(fail).then(done); }); - it('udpates bound components when the computed changes', function(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. @@ -475,3 +511,51 @@ describe('component bindings', function() { }).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 bindings when events are added', 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: 'first', method: 'restyle', args: ['marker.color', 'blue']}, + ] + }, { + // This one does *not*: + steps: [ + {label: 'first', method: 'restyle', args: ['line.color', 'red']}, + {label: 'first', method: 'restyle', args: ['marker.color', 'blue']}, + ] + }] + }).then(function() { + // Check that it has attached a listener: + expect(typeof gd._internalEv._events.plotly_animatingframe).toBe('function'); + + return Plotly.relayout(gd, {'sliders[0].steps[0].args[1]': 'green'}); + }).then(function() { + // Check that it still has one attached listener: + expect(typeof gd._internalEv._events.plotly_animatingframe).toBe('function'); + + 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); + }); +}); From 6abec151eedf9f878db4645e608b7c17c6e6611c Mon Sep 17 00:00:00 2001 From: Ricky Reusser Date: Mon, 24 Oct 2016 15:25:44 -0400 Subject: [PATCH 20/28] Test more behavior of bindings --- test/jasmine/tests/command_test.js | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/test/jasmine/tests/command_test.js b/test/jasmine/tests/command_test.js index 707bba7c652..99478c7fcab 100644 --- a/test/jasmine/tests/command_test.js +++ b/test/jasmine/tests/command_test.js @@ -546,11 +546,30 @@ describe('attaching component bindings', function() { // Check that it has attached a listener: expect(typeof gd._internalEv._events.plotly_animatingframe).toBe('function'); - return Plotly.relayout(gd, {'sliders[0].steps[0].args[1]': 'green'}); + // Confirm the first position is selected: + expect(gd.layout.sliders[0].active).toBe(0); + + // 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 From 5df94a7e48151984890f0fd0281b029edcbdfd89 Mon Sep 17 00:00:00 2001 From: Ricky Reusser Date: Mon, 24 Oct 2016 15:26:42 -0400 Subject: [PATCH 21/28] Fix linter issue --- test/jasmine/tests/command_test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/jasmine/tests/command_test.js b/test/jasmine/tests/command_test.js index 99478c7fcab..2b88c92a4e8 100644 --- a/test/jasmine/tests/command_test.js +++ b/test/jasmine/tests/command_test.js @@ -551,7 +551,7 @@ describe('attaching component bindings', function() { // Modify the plot return Plotly.restyle(gd, {'marker.color': 'blue'}); - }).then(function () { + }).then(function() { // Confirm that this has changed the slider position: expect(gd.layout.sliders[0].active).toBe(1); From d35ee35cc22cda294633beddb0fb99cb0ad70e5f Mon Sep 17 00:00:00 2001 From: Ricky Reusser Date: Mon, 24 Oct 2016 15:29:29 -0400 Subject: [PATCH 22/28] Remove binding test file --- test/jasmine/tests/binding_test.js | 27 --------------------------- 1 file changed, 27 deletions(-) delete mode 100644 test/jasmine/tests/binding_test.js diff --git a/test/jasmine/tests/binding_test.js b/test/jasmine/tests/binding_test.js deleted file mode 100644 index 531822950e9..00000000000 --- a/test/jasmine/tests/binding_test.js +++ /dev/null @@ -1,27 +0,0 @@ -var Lib = require('@src/lib/index'); -var Plotly = require('@lib/index'); -var createGraphDiv = require('../assets/create_graph_div'); -var destroyGraphDiv = require('../assets/destroy_graph_div'); -var fail = require('../assets/fail_test'); - -describe('updateBindings', function() { - 'use strict'; - - var gd; - var mock = require('@mocks/updatemenus.json'); - - beforeEach(function(done) { - gd = createGraphDiv(); - - var mockCopy = Lib.extendDeep({}, mock); - Plotly.plot(gd, mockCopy.data, mockCopy.layout).then(done); - }); - - afterEach(function() { - destroyGraphDiv(gd); - }); - - it('updates bindings on plot', function(done) { - Plotly.restyle(gd, 'marker.size', 10).catch(fail).then(done); - }); -}); From a554cadd4271008cf1a5c4f3edea8e75e668478c Mon Sep 17 00:00:00 2001 From: Ricky Reusser Date: Mon, 24 Oct 2016 15:55:01 -0400 Subject: [PATCH 23/28] Add equivalent command API test for udpatemenus --- src/components/sliders/draw.js | 1 - src/components/updatemenus/defaults.js | 6 ++- test/jasmine/tests/command_test.js | 59 ++++++++++++++++++++++++-- 3 files changed, 61 insertions(+), 5 deletions(-) diff --git a/src/components/sliders/draw.js b/src/components/sliders/draw.js index d16814586e8..8a5d2540c23 100644 --- a/src/components/sliders/draw.js +++ b/src/components/sliders/draw.js @@ -359,7 +359,6 @@ function handleInput(gd, sliderGroup, sliderOpts, normalizedPosition, doTransiti var quantizedPosition = Math.round(normalizedPosition * (sliderOpts.steps.length - 1)); if(quantizedPosition !== sliderOpts.active) { - setActive(gd, sliderGroup, sliderOpts, quantizedPosition, true, doTransition); } } diff --git a/src/components/updatemenus/defaults.js b/src/components/updatemenus/defaults.js index d32a8c1892a..3e63e07f69d 100644 --- a/src/components/updatemenus/defaults.js +++ b/src/components/updatemenus/defaults.js @@ -33,6 +33,8 @@ module.exports = function updateMenusDefaults(layoutIn, layoutOut) { // used to determine object constancy menuOut._index = i; + menuOut._input.active = menuOut.active; + contOut.push(menuOut); } }; @@ -48,7 +50,9 @@ function menuDefaults(menuIn, menuOut, layoutOut) { var visible = coerce('visible', buttons.length > 0); if(!visible) return; - coerce('active'); + // Default to zero since active must be *something*: + coerce('active', 0); + coerce('direction'); coerce('type'); coerce('showactive'); diff --git a/test/jasmine/tests/command_test.js b/test/jasmine/tests/command_test.js index 2b88c92a4e8..aab3a05bbdf 100644 --- a/test/jasmine/tests/command_test.js +++ b/test/jasmine/tests/command_test.js @@ -525,7 +525,7 @@ describe('attaching component bindings', function() { destroyGraphDiv(gd); }); - it('attaches bindings when events are added', function(done) { + it('attaches and updates bindings for sliders', function(done) { expect(gd._internalEv._events.plotly_animatingframe).toBeUndefined(); Plotly.relayout(gd, { @@ -533,13 +533,13 @@ describe('attaching component bindings', function() { // This one gets bindings: steps: [ {label: 'first', method: 'restyle', args: ['marker.color', 'red']}, - {label: 'first', method: 'restyle', args: ['marker.color', 'blue']}, + {label: 'second', method: 'restyle', args: ['marker.color', 'blue']}, ] }, { // This one does *not*: steps: [ {label: 'first', method: 'restyle', args: ['line.color', 'red']}, - {label: 'first', method: 'restyle', args: ['marker.color', 'blue']}, + {label: 'second', method: 'restyle', args: ['marker.color', 'blue']}, ] }] }).then(function() { @@ -577,4 +577,57 @@ describe('attaching component bindings', function() { 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).toBe(0); + + // 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); + }); }); From e5a80ee2353f606ecc55ab0f179ecbef9093f4f7 Mon Sep 17 00:00:00 2001 From: Ricky Reusser Date: Mon, 24 Oct 2016 16:03:49 -0400 Subject: [PATCH 24/28] DRY up binding change check --- src/plots/command.js | 42 +++++++++++------------------------------- 1 file changed, 11 insertions(+), 31 deletions(-) diff --git a/src/plots/command.js b/src/plots/command.js index 8af5aefba6e..3e298776811 100644 --- a/src/plots/command.js +++ b/src/plots/command.js @@ -67,53 +67,30 @@ exports.createCommandObserver = function(gd, container, commandList, onchange) { // 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 container, value, obj; - var changed = false; + var update = bindingValueHasChanged(gd, binding, ret.cache); - 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 = ret.cache[binding.type] = ret.cache[binding.type] || {}; - - if(obj.hasOwnProperty(binding.prop)) { - if(obj[binding.prop] !== value) { - changed = true; - } - } - - obj[binding.prop] = value; - - if(changed && onchange) { + if(update.changed && onchange) { // Disable checks for the duration of this command in order to avoid // infinite loops: - if(ret.lookupTable[value] !== undefined) { + if(ret.lookupTable[update.value] !== undefined) { ret.disable(); Promise.resolve(onchange({ - value: value, + value: update.value, type: binding.type, prop: binding.prop, traces: binding.traces, - index: ret.lookupTable[value] + index: ret.lookupTable[update.value] })).then(ret.enable, ret.enable); } } - return changed; + return update.changed; }; var checkEvents = [ @@ -260,7 +237,10 @@ function bindingValueHasChanged(gd, binding, cache) { obj[binding.prop] = value; - return changed; + return { + changed: changed, + value: value + }; } /* From 45717b13ea371210fe40dc005c1abece97cbae7b Mon Sep 17 00:00:00 2001 From: Ricky Reusser Date: Mon, 24 Oct 2016 16:07:43 -0400 Subject: [PATCH 25/28] Add note about test failure --- test/jasmine/tests/command_test.js | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/test/jasmine/tests/command_test.js b/test/jasmine/tests/command_test.js index aab3a05bbdf..9e4a8a78ff7 100644 --- a/test/jasmine/tests/command_test.js +++ b/test/jasmine/tests/command_test.js @@ -328,7 +328,15 @@ describe('Plots.computeAPICommandBindings', function() { 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]} ]); }); From 52de9e4a46c07390fdfabc835eb662e71efeca31 Mon Sep 17 00:00:00 2001 From: Ricky Reusser Date: Tue, 25 Oct 2016 10:34:49 -0400 Subject: [PATCH 26/28] createCommandObserver --> manageCommandObserver --- src/components/sliders/draw.js | 2 +- src/components/updatemenus/draw.js | 2 +- src/plots/command.js | 2 +- src/plots/plots.js | 2 +- test/jasmine/tests/command_test.js | 4 ++-- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/components/sliders/draw.js b/src/components/sliders/draw.js index 8a5d2540c23..887d37d2b1d 100644 --- a/src/components/sliders/draw.js +++ b/src/components/sliders/draw.js @@ -71,7 +71,7 @@ module.exports = function draw(gd) { computeLabelSteps(sliderOpts); - Plots.createCommandObserver(gd, sliderOpts, sliderOpts.steps, function(data) { + Plots.manageCommandObserver(gd, sliderOpts, sliderOpts.steps, function(data) { if(sliderOpts.active === data.index) return; if(sliderOpts._dragging) return; diff --git a/src/components/updatemenus/draw.js b/src/components/updatemenus/draw.js index 2614b08f132..39abd0fcf11 100644 --- a/src/components/updatemenus/draw.js +++ b/src/components/updatemenus/draw.js @@ -114,7 +114,7 @@ module.exports = function draw(gd) { var gHeader = d3.select(this); var _gButton = menuOpts.type === 'dropdown' ? gButton : null; - Plots.createCommandObserver(gd, menuOpts, menuOpts.buttons, function(data) { + Plots.manageCommandObserver(gd, menuOpts, menuOpts.buttons, function(data) { setActive(gd, menuOpts, menuOpts.buttons[data.index], gHeader, _gButton, data.index, true); }); diff --git a/src/plots/command.js b/src/plots/command.js index 3e298776811..cb3b42fb8d3 100644 --- a/src/plots/command.js +++ b/src/plots/command.js @@ -28,7 +28,7 @@ var Lib = require('../lib'); * A listener called when the value is changed. Receives data object * with information about the new state. */ -exports.createCommandObserver = function(gd, container, commandList, onchange) { +exports.manageCommandObserver = function(gd, container, commandList, onchange) { var ret = {}; var enabled = true; diff --git a/src/plots/plots.js b/src/plots/plots.js index 25d3bfd9860..5232397292b 100644 --- a/src/plots/plots.js +++ b/src/plots/plots.js @@ -41,7 +41,7 @@ var ErrorBars = require('../components/errorbars'); var commandModule = require('./command'); plots.executeAPICommand = commandModule.executeAPICommand; plots.computeAPICommandBindings = commandModule.computeAPICommandBindings; -plots.createCommandObserver = commandModule.createCommandObserver; +plots.manageCommandObserver = commandModule.manageCommandObserver; plots.hasSimpleAPICommandBindings = commandModule.hasSimpleAPICommandBindings; /** diff --git a/test/jasmine/tests/command_test.js b/test/jasmine/tests/command_test.js index 9e4a8a78ff7..7d2e602f6bb 100644 --- a/test/jasmine/tests/command_test.js +++ b/test/jasmine/tests/command_test.js @@ -466,7 +466,7 @@ describe('component bindings', function() { it('creates an observer', function(done) { var count = 0; - Plots.createCommandObserver(gd, {}, [ + Plots.manageCommandObserver(gd, {}, [ { method: 'restyle', args: ['marker.color', 'red'] }, { method: 'restyle', args: ['marker.color', 'green'] } ], function(data) { @@ -492,7 +492,7 @@ describe('component bindings', function() { warnings++; }); - Plots.createCommandObserver(gd, {}, [ + Plots.manageCommandObserver(gd, {}, [ { method: 'restyle', args: ['marker.color', 'red'] }, { method: 'restyle', args: [{'line.color': 'green', 'marker.color': 'green'}] } ]); From 0c40b02f43f5cefb9896e2ffea931cab702718b4 Mon Sep 17 00:00:00 2001 From: Ricky Reusser Date: Tue, 25 Oct 2016 10:40:15 -0400 Subject: [PATCH 27/28] Remove hard-coded updatemenus active default --- src/components/updatemenus/defaults.js | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/components/updatemenus/defaults.js b/src/components/updatemenus/defaults.js index 3e63e07f69d..6f06b0acfd1 100644 --- a/src/components/updatemenus/defaults.js +++ b/src/components/updatemenus/defaults.js @@ -50,9 +50,7 @@ function menuDefaults(menuIn, menuOut, layoutOut) { var visible = coerce('visible', buttons.length > 0); if(!visible) return; - // Default to zero since active must be *something*: - coerce('active', 0); - + coerce('active'); coerce('direction'); coerce('type'); coerce('showactive'); From df2d5bb4c42b6ba55ac4f6518c7654927060c6a8 Mon Sep 17 00:00:00 2001 From: Ricky Reusser Date: Tue, 25 Oct 2016 11:15:31 -0400 Subject: [PATCH 28/28] Revert updatemenus initialization and fix sliders initialization --- src/components/sliders/attributes.js | 2 +- src/components/sliders/draw.js | 4 +++- src/components/updatemenus/defaults.js | 2 -- test/jasmine/tests/command_test.js | 4 ++-- test/jasmine/tests/sliders_test.js | 29 +++++++++++++++++++++++++- test/jasmine/tests/updatemenus_test.js | 27 ++++++++++++++++++++++++ 6 files changed, 61 insertions(+), 7 deletions(-) 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 887d37d2b1d..953d131ad17 100644 --- a/src/components/sliders/draw.js +++ b/src/components/sliders/draw.js @@ -237,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) { diff --git a/src/components/updatemenus/defaults.js b/src/components/updatemenus/defaults.js index 6f06b0acfd1..d32a8c1892a 100644 --- a/src/components/updatemenus/defaults.js +++ b/src/components/updatemenus/defaults.js @@ -33,8 +33,6 @@ module.exports = function updateMenusDefaults(layoutIn, layoutOut) { // used to determine object constancy menuOut._index = i; - menuOut._input.active = menuOut.active; - contOut.push(menuOut); } }; diff --git a/test/jasmine/tests/command_test.js b/test/jasmine/tests/command_test.js index 7d2e602f6bb..b88e2613c5a 100644 --- a/test/jasmine/tests/command_test.js +++ b/test/jasmine/tests/command_test.js @@ -555,7 +555,7 @@ describe('attaching component bindings', function() { expect(typeof gd._internalEv._events.plotly_animatingframe).toBe('function'); // Confirm the first position is selected: - expect(gd.layout.sliders[0].active).toBe(0); + expect(gd.layout.sliders[0].active).toBeUndefined(); // Modify the plot return Plotly.restyle(gd, {'marker.color': 'blue'}); @@ -608,7 +608,7 @@ describe('attaching component bindings', function() { expect(typeof gd._internalEv._events.plotly_animatingframe).toBe('function'); // Confirm the first position is selected: - expect(gd.layout.updatemenus[0].active).toBe(0); + expect(gd.layout.updatemenus[0].active).toBeUndefined(); // Modify the plot return Plotly.restyle(gd, {'marker.color': 'blue'}); 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';