Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ This project adheres to [Semantic Versioning](http://semver.org/).

### Added
- Add optional `font` parameter for `make_subplots` [[#5393](https://github.com/plotly/plotly.py/pull/5393)]
- Add `set_frame_durations` method to `Figure` for setting per-frame animation durations, and `animate_frames` method to `FigureWidget` for animating through data states with per-frame durations [[#XXXX](https://github.com/plotly/plotly.py/pull/XXXX)]

### Fixed
- Fix issue where user-specified `color_continuous_scale` was ignored when template had `autocolorscale=True` [[#5439](https://github.com/plotly/plotly.py/pull/5439)], with thanks to @antonymilne for the contribution!
Expand Down
56 changes: 52 additions & 4 deletions plotly/basedatatypes.py
Original file line number Diff line number Diff line change
Expand Up @@ -1447,10 +1447,12 @@ def _select_layout_subplots_by_prefix(

layout_keys_filters = [
lambda k: k.startswith(prefix) and self.layout[k] is not None,
lambda k: row is None
or container_to_row_col.get(k, (None, None, None))[0] == row,
lambda k: col is None
or container_to_row_col.get(k, (None, None, None))[1] == col,
lambda k: (
row is None or container_to_row_col.get(k, (None, None, None))[0] == row
),
lambda k: (
col is None or container_to_row_col.get(k, (None, None, None))[1] == col
),
lambda k: (
secondary_y is None
or container_to_row_col.get(k, (None, None, None))[2] == secondary_y
Expand Down Expand Up @@ -2872,6 +2874,52 @@ def frames(self, new_frames):
# Validate frames
self._frame_objs = self._frames_validator.validate_coerce(new_frames)

def set_frame_durations(self, durations):
"""
Set per-frame durations for animations.

Parameters
----------
durations : int or float or list
Duration in milliseconds for each frame. A scalar applies the
same duration to all frames. A list must have one entry per
frame, in frame order.

Returns
-------
BaseFigure
The Figure object that the method was called on
"""
frames = self._frame_objs
if not frames:
raise ValueError(
"Figure has no frames. Add frames before setting durations."
)

if isinstance(durations, (int, float)):
durations = [durations] * len(frames)
elif isinstance(durations, (list, tuple)):
durations = list(durations)
else:
raise ValueError("durations must be a number or a list of numbers.")

if len(durations) != len(frames):
raise ValueError(
f"len(durations) ({len(durations)}) must equal "
f"the number of frames ({len(frames)})."
)

for d in durations:
if not isinstance(d, (int, float)):
raise ValueError("All durations must be numbers.")
if d < 0:
raise ValueError("Durations must be non-negative.")

for frame, d in zip(frames, durations):
frame._props["duration"] = d

return self

# Update
# ------
def plotly_update(
Expand Down
35 changes: 35 additions & 0 deletions plotly/basewidget.py
Original file line number Diff line number Diff line change
Expand Up @@ -800,6 +800,41 @@ def _display_frames_error():
Note: Frames are supported by the plotly.graph_objs.Figure class"""
raise ValueError(msg)

def animate_frames(self, frames_data, durations):
"""
Animate through a sequence of data states with per-frame durations.

Each entry in frames_data is a dict of property updates applied to
data[0]. The corresponding entry in durations controls how long
that step is displayed (in milliseconds).

Parameters
----------
frames_data : list[dict]
List of property-update dicts, each applied to data[0].
durations : list of int or float
Duration in milliseconds for each frame.
"""
if not isinstance(frames_data, (list, tuple)):
raise ValueError("frames_data must be a list.")
if not isinstance(durations, (list, tuple)):
raise ValueError("durations must be a list of numbers.")
if len(frames_data) != len(durations):
raise ValueError(
f"len(frames_data) ({len(frames_data)}) must equal "
f"len(durations) ({len(durations)})."
)
for d in durations:
if not isinstance(d, (int, float)):
raise ValueError("All durations must be numbers.")
if d < 0:
raise ValueError("Durations must be non-negative.")

for frame_data, duration in zip(frames_data, durations):
with self.batch_animate(duration=duration):
for prop, val in frame_data.items():
self.data[0][prop] = val

# Static Helpers
# --------------
@staticmethod
Expand Down
40 changes: 36 additions & 4 deletions plotly/io/_html.py
Original file line number Diff line number Diff line change
Expand Up @@ -216,11 +216,43 @@ def to_html(
}})""".format(id=plotdivid, frames=jframes)

if auto_play:
if animation_opts:
animation_opts_arg = ", " + _json.dumps(animation_opts)
frames_list = fig_dict.get("frames", [])
per_frame_durations = [f.get("duration") for f in frames_list]

if any(d is not None for d in per_frame_durations):
durations_js = _json.dumps(per_frame_durations)
frame_names_js = _json.dumps(
[f.get("name", str(i)) for i, f in enumerate(frames_list)]
)
then_animate = """.then(function(){{
var gd = document.getElementById('{id}');
var durations = {durations};
var frameNames = {frame_names};
var idx = 0;
function playNext() {{
if (idx >= frameNames.length) return;
var dur = durations[idx] !== null ? durations[idx] : 500;
Plotly.animate(gd, [frameNames[idx]], {{
frame: {{duration: dur, redraw: true}},
transition: {{duration: dur}},
mode: 'immediate'
}}).then(function() {{
idx++;
setTimeout(playNext, dur);
}});
}}
playNext();
}})""".format(
id=plotdivid,
durations=durations_js,
frame_names=frame_names_js,
)
else:
animation_opts_arg = ""
then_animate = """.then(function(){{
if animation_opts:
animation_opts_arg = ", " + _json.dumps(animation_opts)
else:
animation_opts_arg = ""
then_animate = """.then(function(){{
Plotly.animate('{id}', null{animation_opts});
}})""".format(id=plotdivid, animation_opts=animation_opts_arg)

Expand Down
174 changes: 174 additions & 0 deletions tests/test_core/test_figure_messages/test_set_frame_durations.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
from unittest import TestCase

import plotly.graph_objs as go
from plotly.io import to_html

from unittest.mock import MagicMock

try:
go.FigureWidget()
figure_widget_available = True
except ImportError:
figure_widget_available = False


class TestSetFrameDurations(TestCase):
def _make_figure(self, n=3):
return go.Figure(
data=[go.Scatter(y=[1, 2, 3])],
frames=[go.Frame(name=str(i)) for i in range(n)],
)

# --- set_frame_durations: valid inputs ---

def test_scalar_sets_all_frames(self):
fig = self._make_figure(3)
fig.set_frame_durations(500)
for frame in fig._frame_objs:
self.assertEqual(frame._props["duration"], 500)

def test_list_sets_individual_durations(self):
fig = self._make_figure(3)
fig.set_frame_durations([100, 200, 300])
durations = [f._props["duration"] for f in fig._frame_objs]
self.assertEqual(durations, [100, 200, 300])

def test_returns_self(self):
fig = self._make_figure(2)
result = fig.set_frame_durations(100)
self.assertIs(result, fig)

def test_zero_duration_is_valid(self):
fig = self._make_figure(2)
fig.set_frame_durations(0)
for frame in fig._frame_objs:
self.assertEqual(frame._props["duration"], 0)

def test_float_duration_is_valid(self):
fig = self._make_figure(2)
fig.set_frame_durations([100.5, 200.0])
durations = [f._props["duration"] for f in fig._frame_objs]
self.assertEqual(durations, [100.5, 200.0])

# --- set_frame_durations: invalid inputs ---

def test_no_frames_raises(self):
fig = go.Figure(data=[go.Scatter(y=[1, 2, 3])])
with self.assertRaises(ValueError):
fig.set_frame_durations(100)

def test_wrong_length_raises(self):
fig = self._make_figure(3)
with self.assertRaises(ValueError):
fig.set_frame_durations([100, 200])

def test_negative_duration_raises(self):
fig = self._make_figure(2)
with self.assertRaises(ValueError):
fig.set_frame_durations([100, -1])

def test_invalid_type_raises(self):
fig = self._make_figure(2)
with self.assertRaises(ValueError):
fig.set_frame_durations("fast")

def test_list_with_invalid_element_raises(self):
fig = self._make_figure(2)
with self.assertRaises(ValueError):
fig.set_frame_durations([100, "slow"])

# --- serialization ---

def test_duration_appears_in_to_dict(self):
fig = self._make_figure(3)
fig.set_frame_durations([100, 200, 300])
d = fig.to_dict()
durations = [f["duration"] for f in d["frames"]]
self.assertEqual(durations, [100, 200, 300])

def test_scalar_duration_appears_in_to_dict(self):
fig = self._make_figure(2)
fig.set_frame_durations(500)
d = fig.to_dict()
durations = [f["duration"] for f in d["frames"]]
self.assertEqual(durations, [500, 500])


class TestAnimateFrames(TestCase):
if figure_widget_available:

def _make_widget(self):
fig = go.FigureWidget(data=[go.Scatter(y=[1, 2, 3])])
fig._send_animate_msg = MagicMock()
return fig

def test_animate_frames_calls_batch_animate_per_frame(self):
fig = self._make_widget()
frames_data = [{"y": [1, 2, 3]}, {"y": [4, 5, 6]}, {"y": [7, 8, 9]}]
durations = [100, 200, 300]
fig.animate_frames(frames_data, durations)
self.assertEqual(fig._send_animate_msg.call_count, 3)

def test_animate_frames_duration_controls_frame_and_transition(self):
fig = self._make_widget()
fig.animate_frames([{"y": [1, 2, 3]}], [200])
opts = fig._send_animate_msg.call_args.kwargs["animation_opts"]
self.assertEqual(opts["frame"]["duration"], 200)
self.assertEqual(opts["transition"]["duration"], 200)

def test_animate_frames_length_mismatch_raises(self):
fig = self._make_widget()
with self.assertRaises(ValueError):
fig.animate_frames([{"y": [1, 2]}], [100, 200])

def test_animate_frames_negative_duration_raises(self):
fig = self._make_widget()
with self.assertRaises(ValueError):
fig.animate_frames([{"y": [1, 2]}], [-100])

def test_animate_frames_invalid_duration_type_raises(self):
fig = self._make_widget()
with self.assertRaises(ValueError):
fig.animate_frames([{"y": [1, 2]}], ["fast"])

def test_animate_frames_invalid_frames_data_type_raises(self):
fig = self._make_widget()
with self.assertRaises(ValueError):
fig.animate_frames("not a list", [100])


class TestToHtmlPerFrameDurations(TestCase):
def _make_figure_with_durations(self, durations):
n = len(durations)
fig = go.Figure(
data=[go.Scatter(y=[1, 2, 3])],
frames=[go.Frame(data=[go.Scatter(y=[i])], name=str(i)) for i in range(n)],
)
fig.set_frame_durations(durations)
return fig

def test_per_frame_html_contains_custom_js_loop(self):
fig = self._make_figure_with_durations([200, 2000])
html = to_html(fig, auto_play=True)
self.assertIn("playNext", html)
self.assertIn("frameNames", html)
self.assertIn("durations", html)

def test_per_frame_html_contains_correct_durations(self):
fig = self._make_figure_with_durations([100, 5000, 300])
html = to_html(fig, auto_play=True)
self.assertIn("[100, 5000, 300]", html)

def test_per_frame_html_contains_frame_names(self):
fig = self._make_figure_with_durations([200, 400])
html = to_html(fig, auto_play=True)
self.assertIn('["0", "1"]', html)

def test_no_per_frame_durations_uses_global_animate(self):
fig = go.Figure(
data=[go.Scatter(y=[1, 2, 3])],
frames=[go.Frame(data=[go.Scatter(y=[i])]) for i in range(2)],
)
html = to_html(fig, auto_play=True)
self.assertNotIn("playNext", html)
self.assertIn("Plotly.animate", html)