Repository: plotly/plotly.py
Pull request #1503 (2019-04-09 08:45:48)

Adding animation opts to python interface
#1496 Added kwarg in plotly/offline/offline.py to allow animation options to be passed through the python interface to the plotly.js functions. 

jonmmease: Hi @TakodaS, thanks for the PR.

I think I'll manually merge your changes into https://github.com/plotly/plotly.py/pull/1474 since that PR does a big refactor to the HTML handling code and so this would cause some merge conflicts.

In thinking about the API a little more, here are two thoughts.
 1. I'd like for users to specify the animation options as a Python `dict` rather than a string containing JSON.  This is more consistent with how the rest of the plotly.py APIs behave.
 2. We recently added the `auto_play` argument to `ploty.offline.iplot` and `plotly.offline.plot` to control whether the animation is started automatically.  I'm thinking about overloading this argument to handle the animation options rather than introduce a new argument that would have to be consistent with `auto_play`. Here's the behavior I'm picturing:

   - `False`, don't autostart the animation
   - `True` (the default), automatically start the animation with default properties
   - a `dict` (e.g. `{'frame': {'duration': 1}}`), automatically start the animation with the specified properties.

How does that sound?
TakodaS: Hi @jonmmease,

1) Absolutely.

2) I am against this.` animation_opts` serves a completely different role to `auto_play` and syntactically makes the python API similar to the [R plotly API](https://www.rdocumentation.org/packages/plotly/versions/4.8.0/topics/animation_opts). I would suggest keeping the two separate or in fact including `auto_play` as an option in `animation_opts`.
jonmmease: > I am against this. animation_opts syntactically makes the python API similar to the R plotly API

Here's my reasoning. In the R API, I believe the `animation_opts` argument is applied when you press the play button (do animations also auto play with the R API?).  But this will not be the case for the animation options passed to `plot`/`iplot` as these options are only applied when calling the initial `Plotly.animate` call to kick off the initial animation sequence.  The animation options applied when pressing a button are controlled by the button arguments in the figure specification.  So they are more like `auto_play_animation_opts`, because if a user sets `auto_play` to `False`, then any animation options passed to `plot`/`iplot` won't be used for anything.  I was thinking that this fact might be clearer in the API if the options were included as `auto_play` options.  I would like to default the `auto_play` argument to `False` in version 4, so I'm concerned that it will be confusing that passing `animation_opts` has no effect unless `auto_play` is also set to `True`.

Separating animation options from auto_play does make sense for `plotly_express` though, because plotly_express, like the R API, constructs the slider/animation button internally and so it could make sure to use the same options for button press as it would use for `auto_play`.

What do you think @nicolaskruchten?
nicolaskruchten: I think Jon's reasoning makes sense that `animation_opts` won't do anything outside of `auto_play` mode, and in the short run we can't remove the `auto_play` kwarg to move it into `animation_opts` without breaking backwards compatibility for this newly-introduced kwarg. I'd be fine with leaving the new kwarg on its own given that `auto_play` currently defaults to `True` and then revisiting this later: either rolling `auto_play` into `animation_opts` or vice versa (I have no super-strong preference there).
jonmmease: I updated this PR to use the new renderers framework introduced in https://github.com/plotly/plotly.py/pull/1474.  I kept the separate `animation_opts` argument and updated it to expect a `dict` instance.
jonmmease: Merging

Patch:
diff --git a/plotly/io/_base_renderers.py b/plotly/io/_base_renderers.py
index 5f82d098f..b8a684feb 100644
--- a/plotly/io/_base_renderers.py
+++ b/plotly/io/_base_renderers.py
@@ -229,7 +229,8 @@ class HtmlRenderer(MimetypeRenderer):
                  global_init=False,
                  config=None,
                  auto_play=False,
-                 post_script=None):
+                 post_script=None,
+                 animation_opts=None):
 
         self.config = dict(config) if config else {}
         self.auto_play = auto_play
@@ -238,6 +239,7 @@ class HtmlRenderer(MimetypeRenderer):
         self.requirejs = requirejs
         self.full_html = full_html
         self.post_script = post_script
+        self.animation_opts = animation_opts
 
     def activate(self):
         if self.global_init:
@@ -315,7 +317,8 @@ class HtmlRenderer(MimetypeRenderer):
             include_plotlyjs=include_plotlyjs,
             include_mathjax=include_mathjax,
             post_script=self.post_script,
-            full_html=self.full_html)
+            full_html=self.full_html,
+            animation_opts=self.animation_opts)
 
         return {'text/html': html}
 
@@ -336,7 +339,8 @@ class NotebookRenderer(HtmlRenderer):
                  connected=False,
                  config=None,
                  auto_play=False,
-                 post_script=None):
+                 post_script=None,
+                 animation_opts=None):
         super(NotebookRenderer, self).__init__(
             connected=connected,
             full_html=False,
@@ -344,7 +348,8 @@ class NotebookRenderer(HtmlRenderer):
             global_init=True,
             config=config,
             auto_play=auto_play,
-            post_script=post_script)
+            post_script=post_script,
+            animation_opts=animation_opts)
 
 
 class KaggleRenderer(HtmlRenderer):
@@ -358,7 +363,12 @@ class KaggleRenderer(HtmlRenderer):
 
     mime type: 'text/html'
     """
-    def __init__(self, config=None, auto_play=False, post_script=None):
+    def __init__(self,
+                 config=None,
+                 auto_play=False,
+                 post_script=None,
+                 animation_opts=None):
+
         super(KaggleRenderer, self).__init__(
             connected=True,
             full_html=False,
@@ -366,7 +376,8 @@ class KaggleRenderer(HtmlRenderer):
             global_init=True,
             config=config,
             auto_play=auto_play,
-            post_script=post_script)
+            post_script=post_script,
+            animation_opts=animation_opts)
 
 
 class ColabRenderer(HtmlRenderer):
@@ -377,7 +388,12 @@ class ColabRenderer(HtmlRenderer):
 
     mime type: 'text/html'
     """
-    def __init__(self, config=None, auto_play=False, post_script=None):
+    def __init__(self,
+                 config=None,
+                 auto_play=False,
+                 post_script=None,
+                 animation_opts=None):
+
         super(ColabRenderer, self).__init__(
             connected=True,
             full_html=True,
@@ -385,7 +401,8 @@ class ColabRenderer(HtmlRenderer):
             global_init=False,
             config=config,
             auto_play=auto_play,
-            post_script=post_script)
+            post_script=post_script,
+            animation_opts=animation_opts)
 
 
 class IFrameRenderer(MimetypeRenderer):
@@ -414,11 +431,13 @@ class IFrameRenderer(MimetypeRenderer):
     def __init__(self,
                  config=None,
                  auto_play=False,
-                 post_script=None):
+                 post_script=None,
+                 animation_opts=None):
 
         self.config = config
         self.auto_play = auto_play
         self.post_script = post_script
+        self.animation_opts = animation_opts
 
     def to_mimebundle(self, fig_dict):
         from plotly.io import write_html
@@ -457,6 +476,7 @@ class IFrameRenderer(MimetypeRenderer):
                    include_mathjax='cdn',
                    auto_open=False,
                    post_script=self.post_script,
+                   animation_opts=self.animation_opts,
                    validate=False)
 
         # Build IFrame
@@ -547,7 +567,8 @@ class BrowserRenderer(ExternalRenderer):
                  using=None,
                  new=0,
                  autoraise=True,
-                 post_script=None):
+                 post_script=None,
+                 animation_opts=None):
 
         self.config = config
         self.auto_play = auto_play
@@ -555,6 +576,7 @@ class BrowserRenderer(ExternalRenderer):
         self.new = new
         self.autoraise = autoraise
         self.post_script = post_script
+        self.animation_opts = animation_opts
 
     def render(self, fig_dict):
         renderer = HtmlRenderer(
@@ -564,7 +586,8 @@ class BrowserRenderer(ExternalRenderer):
             global_init=False,
             config=self.config,
             auto_play=self.auto_play,
-            post_script=self.post_script)
+            post_script=self.post_script,
+            animation_opts=self.animation_opts)
 
         bundle = renderer.to_mimebundle(fig_dict)
         html = bundle['text/html']
diff --git a/plotly/io/_html.py b/plotly/io/_html.py
index f70d269f7..62596ae98 100644
--- a/plotly/io/_html.py
+++ b/plotly/io/_html.py
@@ -30,6 +30,7 @@ def to_html(fig,
             include_mathjax=False,
             post_script=None,
             full_html=True,
+            animation_opts=None,
             validate=True):
     """
     Convert a figure to an HTML string representation.
@@ -102,6 +103,12 @@ def to_html(fig,
         If True, produce a string containing a complete HTML document
         starting with an <html> tag.  If False, produce a string containing
         a single <div> element.
+    animation_opts: dict or None (default None)
+        dict of custom animation parameters to be passed to the function
+        Plotly.animate in Plotly.js. See
+        https://github.com/plotly/plotly.js/blob/master/src/plots/animation_attributes.js
+        for available options. Has no effect if the figure does not contain
+        frames, or auto_play is False.
     validate: bool (default True)
         True if the figure should be validated before being converted to
         JSON, False otherwise.
@@ -164,9 +171,14 @@ def to_html(fig,
                         }})""".format(id=plotdivid, frames=jframes)
 
         if auto_play:
+            if animation_opts:
+                animation_opts_arg = ', ' + json.dumps(animation_opts)
+            else:
+                animation_opts_arg = ''
             then_animate = """.then(function(){{
-                            Plotly.animate('{id}');
-                        }})""".format(id=plotdivid)
+                            Plotly.animate('{id}', null{animation_opts});
+                        }})""".format(id=plotdivid,
+                                      animation_opts=animation_opts_arg)
 
     script = """
                 if (document.getElementById("{id}")) {{
@@ -299,6 +311,7 @@ def write_html(fig,
                include_mathjax=False,
                post_script=None,
                full_html=True,
+               animation_opts=None,
                validate=True,
                auto_open=False):
     """
@@ -390,6 +403,12 @@ def write_html(fig,
         If True, produce a string containing a complete HTML document
         starting with an <html> tag.  If False, produce a string containing
         a single <div> element.
+    animation_opts: dict or None (default None)
+        dict of custom animation parameters to be passed to the function
+        Plotly.animate in Plotly.js. See
+        https://github.com/plotly/plotly.js/blob/master/src/plots/animation_attributes.js
+        for available options. Has no effect if the figure does not contain
+        frames, or auto_play is False.
     validate: bool (default True)
         True if the figure should be validated before being converted to
         JSON, False otherwise.
@@ -411,7 +430,8 @@ def write_html(fig,
         include_mathjax=include_mathjax,
         post_script=post_script,
         full_html=full_html,
-        validate=validate)
+        validate=validate,
+        animation_opts=animation_opts)
 
     # Check if file is a string
     file_is_str = isinstance(file, six.string_types)
diff --git a/plotly/offline/offline.py b/plotly/offline/offline.py
index 5cad091f0..94a7fd07a 100644
--- a/plotly/offline/offline.py
+++ b/plotly/offline/offline.py
@@ -294,7 +294,7 @@ def init_notebook_mode(connected=False):
 
 def iplot(figure_or_data, show_link=False, link_text='Export to plot.ly',
           validate=True, image=None, filename='plot_image', image_width=800,
-          image_height=600, config=None, auto_play=True):
+          image_height=600, config=None, auto_play=True, animation_opts=None):
     """
     Draw plotly graphs inside an IPython or Jupyter notebook
 
@@ -329,6 +329,11 @@ def iplot(figure_or_data, show_link=False, link_text='Export to plot.ly',
     auto_play (default=True) -- Whether to automatically start the animation
         sequence if the figure contains frames. Has no effect if the figure
         does not contain frames.
+    animation_opts (default=None) -- dict of custom animation parameters to be
+        passed to the function Plotly.animate in Plotly.js. See
+        https://github.com/plotly/plotly.js/blob/master/src/plots/animation_attributes.js
+        for available options.  Has no effect if the figure
+        does not contain frames, or auto_play is False.
 
     Example:
     ```
@@ -339,6 +344,20 @@ def iplot(figure_or_data, show_link=False, link_text='Export to plot.ly',
     format you want. e.g. `image='png'`
     iplot([{'x': [1, 2, 3], 'y': [5, 2, 7]}], image='png')
     ```
+
+    animation_opts Example:
+    ```
+    from plotly.offline import iplot
+    figure = {'data': [{'x': [0, 1], 'y': [0, 1]}],
+              'layout': {'xaxis': {'range': [0, 5], 'autorange': False},
+                         'yaxis': {'range': [0, 5], 'autorange': False},
+                         'title': 'Start Title'},
+              'frames': [{'data': [{'x': [1, 2], 'y': [1, 2]}]},
+                         {'data': [{'x': [1, 4], 'y': [1, 4]}]},
+                         {'data': [{'x': [3, 4], 'y': [3, 4]}],
+                          'layout': {'title': 'End Title'}}]}
+    iplot(figure,animation_opts="{frame: {duration: 1}}")
+    ```
     """
     import plotly.io as pio
 
@@ -361,14 +380,16 @@ def iplot(figure_or_data, show_link=False, link_text='Export to plot.ly',
              validate=validate,
              config=config,
              auto_play=auto_play,
-             post_script=post_script)
+             post_script=post_script,
+             animation_opts=animation_opts)
 
 
 def plot(figure_or_data, show_link=False, link_text='Export to plot.ly',
          validate=True, output_type='file', include_plotlyjs=True,
          filename='temp-plot.html', auto_open=True, image=None,
          image_filename='plot_image', image_width=800, image_height=600,
-         config=None, include_mathjax=False, auto_play=True):
+         config=None, include_mathjax=False, auto_play=True,
+         animation_opts=None):
     """ Create a plotly graph locally as an HTML document or string.
 
     Example:
@@ -481,6 +502,25 @@ def plot(figure_or_data, show_link=False, link_text='Export to plot.ly',
     auto_play (default=True) -- Whether to automatically start the animation
         sequence on page load if the figure contains frames. Has no effect if
         the figure does not contain frames.
+    animation_opts (default=None) -- dict of custom animation parameters to be
+        passed to the function Plotly.animate in Plotly.js. See
+        https://github.com/plotly/plotly.js/blob/master/src/plots/animation_attributes.js
+        for available options. Has no effect if the figure does not contain
+        frames, or auto_play is False.
+
+    Example:
+    ```
+    from plotly.offline import plot
+    figure = {'data': [{'x': [0, 1], 'y': [0, 1]}],
+              'layout': {'xaxis': {'range': [0, 5], 'autorange': False},
+                         'yaxis': {'range': [0, 5], 'autorange': False},
+                         'title': 'Start Title'},
+              'frames': [{'data': [{'x': [1, 2], 'y': [1, 2]}]},
+                         {'data': [{'x': [1, 4], 'y': [1, 4]}]},
+                         {'data': [{'x': [3, 4], 'y': [3, 4]}],
+                          'layout': {'title': 'End Title'}}]}
+    plot(figure,animation_opts="{frame: {duration: 1}}")
+    ```
     """
     import plotly.io as pio
 
@@ -522,6 +562,7 @@ def plot(figure_or_data, show_link=False, link_text='Export to plot.ly',
             post_script=post_script,
             full_html=True,
             validate=validate,
+            animation_opts=animation_opts,
             auto_open=auto_open)
         return filename
     else:
@@ -533,7 +574,8 @@ def plot(figure_or_data, show_link=False, link_text='Export to plot.ly',
             include_mathjax=include_mathjax,
             post_script=post_script,
             full_html=False,
-            validate=validate)
+            validate=validate,
+            animation_opts=animation_opts)
 
 
 def plot_mpl(mpl_fig, resize=False, strip_style=False,


Test patch:
diff --git a/plotly/tests/test_core/test_offline/test_offline.py b/plotly/tests/test_core/test_offline/test_offline.py
index da1a93e21..b7d543a6b 100644
--- a/plotly/tests/test_core/test_offline/test_offline.py
+++ b/plotly/tests/test_core/test_offline/test_offline.py
@@ -418,9 +418,28 @@ class PlotlyOfflineTestCase(PlotlyOfflineBaseTestCase):
 
     def test_no_auto_play(self):
         html = plotly.offline.plot(
-            fig_frames, output_type='div',auto_play=False)
+            fig_frames, output_type='div', auto_play=False)
+        self.assertIn(add_frames, html)
+        self.assertNotIn(do_auto_play, html)
+
+    def test_animation_opts(self):
+        animation_opts = {'frame': {'duration': 5000}}
+        expected_opts_str = json.dumps(animation_opts)
+
+        # When auto_play is False, animation options are skipped
+        html = plotly.offline.plot(
+            fig_frames, output_type='div', auto_play=False, animation_opts=animation_opts)
         self.assertIn(add_frames, html)
         self.assertNotIn(do_auto_play, html)
+        self.assertNotIn(expected_opts_str, html)
+
+        # When auto_play is True, animation options are included
+        html = plotly.offline.plot(
+            fig_frames, output_type='div', auto_play=True,
+            animation_opts=animation_opts)
+        self.assertIn(add_frames, html)
+        self.assertIn(do_auto_play, html)
+        self.assertIn(expected_opts_str, html)
 
     def test_download_image(self):
         # Not download image by default
@@ -431,4 +450,4 @@ class PlotlyOfflineTestCase(PlotlyOfflineBaseTestCase):
         # Request download image
         html = plotly.offline.plot(
             fig_frames, output_type='div', auto_play=False, image='png')
-        self.assertIn(download_image, html)
\ No newline at end of file
+        self.assertIn(download_image, html)