From 575b50fe7b9a0eebf33f289f4f72f180f1e6c919 Mon Sep 17 00:00:00 2001 From: Thomas Nicholas Date: Mon, 8 Apr 2019 22:17:03 +0100 Subject: [PATCH 1/8] Added method to add blocks to an animation --- animatplot/animation.py | 62 +++++++++++++++++++++++++++++++++++------ 1 file changed, 53 insertions(+), 9 deletions(-) diff --git a/animatplot/animation.py b/animatplot/animation.py index ce19522..c4b953c 100644 --- a/animatplot/animation.py +++ b/animatplot/animation.py @@ -4,6 +4,7 @@ import numpy as np from animatplot import Timeline +from animatplot.blocks.base import Block class Animation: @@ -33,17 +34,33 @@ def __init__(self, blocks, timeline=None, fig=None): else: self.timeline = timeline + self.fig = plt.gcf() if fig is None else fig + + self.animation = self._animate(blocks) + + def _animate(self, blocks): + """ + + Parameters + ---------- + blocks + + Returns + ------- + + """ _len_time = len(self.timeline) for block in blocks: if len(block) != _len_time: - raise ValueError("All blocks must animate for the same amount of time") + print(len(block)) + raise ValueError( + "All blocks must animate for the same amount of time") self.blocks = blocks - self.fig = plt.gcf() if fig is None else fig self._has_slider = False self._pause = False - def animate(i): + def update_all(i): updates = [] for block in self.blocks: updates.append(block._update(self.timeline.index)) @@ -52,11 +69,8 @@ def animate(i): self.timeline._update() return updates - self.animation = FuncAnimation( - self.fig, animate, - frames=self.timeline._len, - interval=1000/self.timeline.fps - ) + return FuncAnimation(self.fig, update_all, frames=self.timeline._len, + interval=1000 / self.timeline.fps) def toggle(self, ax=None): """Creates a play/pause button to start/stop the animation @@ -176,7 +190,8 @@ def save_gif(self, filename): the name of the file to be created without the file extension """ self.timeline.index -= 1 # required for proper starting point for save - self.animation.save(filename+'.gif', writer=PillowWriter(fps=self.timeline.fps)) + self.animation.save(filename+'.gif', + writer=PillowWriter(fps=self.timeline.fps)) def save(self, *args, **kwargs): """Saves an animation @@ -185,3 +200,32 @@ def save(self, *args, **kwargs): """ self.timeline.index -= 1 # required for proper starting point for save self.animation.save(*args, **kwargs) + + def add(self, new): + """ + Updates the animation object by adding additional blocks. + + Parameters + ---------- + new : animatplot.animation, or list of animatplot.block.Block objects + Either blocks to add to animation instance, or another animation + instance whose blocks should be combined with this animation. + """ + + if isinstance(new, Animation): + raise NotImplementedError("Cannot append to animation instances yet") + + else: + if not isinstance(new, list): + new = [new] + + for i, block in enumerate(new): + if not isinstance(block, Block): + err = f"Block number {i} passed is of type {type(block)}, " \ + f" not of type animatplot.blocks.Block (or a subclass)." + raise TypeError(err) + + self.blocks.append(block) + + self.animation = self._animate(self.blocks) + return self From 71e077b957a5bce4b72cb0fa5badc992ce487e46 Mon Sep 17 00:00:00 2001 From: Thomas Nicholas Date: Mon, 8 Apr 2019 22:17:18 +0100 Subject: [PATCH 2/8] Added tests --- tests/test_animation.py | 39 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/tests/test_animation.py b/tests/test_animation.py index 203bf74..bb79beb 100644 --- a/tests/test_animation.py +++ b/tests/test_animation.py @@ -5,6 +5,8 @@ import numpy as np import matplotlib.pyplot as plt from matplotlib.animation import PillowWriter +import numpy.testing as npt + import animatplot as amp from tests.tools import animation_compare @@ -41,3 +43,40 @@ def test_save(): anim.save_gif(base+'save') plt.close('all') assert os.path.exists(base+'save.gif') + + +@pytest.fixture() +def line_block(): + def make_line_block(t_length=5): + x = np.linspace(0, 1, 10) + t = np.linspace(0, 1, t_length) + x_grid, t_grid = np.meshgrid(x, t) + y_data = np.sin(2 * np.pi * (x_grid + t_grid)) + + return amp.blocks.Line(x, y_data) + return make_line_block + + +class TestAdd: + def test_add_blocks(self, line_block): + anim = amp.Animation([line_block()]) + anim2 = anim.add(line_block()) + + assert isinstance(anim2, amp.Animation) + assert len(anim2.blocks) == 2 + for actual in anim2.blocks: + assert len(actual) == 5 + npt.assert_equal(actual.line.get_xdata(), + np.linspace(0, 1, 10)) + + def test_wrong_length_block(self, line_block): + anim = amp.Animation([line_block()]) + + with pytest.raises(ValueError): + anim.add(line_block(t_length=6)) + + def test_wrong_type(self, line_block): + anim = amp.Animation([line_block()]) + + with pytest.raises(TypeError): + anim.add('not a block') From 257ed0ae51ca9e16640797fe25276016a2b619df Mon Sep 17 00:00:00 2001 From: Thomas Nicholas Date: Tue, 9 Apr 2019 00:40:14 +0100 Subject: [PATCH 3/8] Extended add method to accept animations as well as lists of blocks --- animatplot/animation.py | 51 +++++++++++++++++++---------------------- 1 file changed, 24 insertions(+), 27 deletions(-) diff --git a/animatplot/animation.py b/animatplot/animation.py index c4b953c..ccaab76 100644 --- a/animatplot/animation.py +++ b/animatplot/animation.py @@ -36,25 +36,14 @@ def __init__(self, blocks, timeline=None, fig=None): self.fig = plt.gcf() if fig is None else fig - self.animation = self._animate(blocks) + self.animation = self._animate(blocks, self.timeline) - def _animate(self, blocks): - """ - - Parameters - ---------- - blocks - - Returns - ------- - - """ - _len_time = len(self.timeline) + def _animate(self, blocks, timeline): + _len_time = len(timeline) for block in blocks: if len(block) != _len_time: - print(len(block)) - raise ValueError( - "All blocks must animate for the same amount of time") + raise ValueError("All blocks must animate for the same amount " + "of time") self.blocks = blocks self._has_slider = False @@ -205,27 +194,35 @@ def add(self, new): """ Updates the animation object by adding additional blocks. + The new blocks can be passed as a list, or as part of a second animaion. + If passed as part of a new animation, the timeline of this new + animation object will replace the old one. + Parameters ---------- - new : animatplot.animation, or list of animatplot.block.Block objects + new : amp.animation.Animation, or list of amp.block.Block objects Either blocks to add to animation instance, or another animation instance whose blocks should be combined with this animation. """ if isinstance(new, Animation): - raise NotImplementedError("Cannot append to animation instances yet") + new_blocks = new.blocks + new_timeline = new.timeline else: if not isinstance(new, list): - new = [new] + new_blocks = [new] + else: + new_blocks = new + new_timeline = self.timeline - for i, block in enumerate(new): - if not isinstance(block, Block): - err = f"Block number {i} passed is of type {type(block)}, " \ - f" not of type animatplot.blocks.Block (or a subclass)." - raise TypeError(err) + for i, block in enumerate(new_blocks): + if not isinstance(block, Block): + raise TypeError(f"Block number {i} passed is of type " + f"{type(block)}, not of type " + f"animatplot.blocks.Block (or a subclass)") - self.blocks.append(block) + self.blocks.append(block) - self.animation = self._animate(self.blocks) - return self + self.animation = self._animate(self.blocks, new_timeline) + return self From 442c6583c1d6c99e555b972b63d33417784bd6b7 Mon Sep 17 00:00:00 2001 From: Thomas Nicholas Date: Tue, 9 Apr 2019 00:40:32 +0100 Subject: [PATCH 4/8] Tests for adding animations to animations --- tests/test_animation.py | 60 ++++++++++++++++++++++++++++++++++++++--- 1 file changed, 56 insertions(+), 4 deletions(-) diff --git a/tests/test_animation.py b/tests/test_animation.py index bb79beb..70f81f9 100644 --- a/tests/test_animation.py +++ b/tests/test_animation.py @@ -1,16 +1,21 @@ -from matplotlib.testing import setup -setup() import os + import pytest -import numpy as np + +import matplotlib.testing import matplotlib.pyplot as plt from matplotlib.animation import PillowWriter + +import numpy as np import numpy.testing as npt import animatplot as amp from tests.tools import animation_compare +matplotlib.testing.setup() + + @pytest.mark.xfail @animation_compare(baseline_images='Animation/controls', nframes=5, tol=.5) def test_controls(): @@ -57,7 +62,25 @@ def make_line_block(t_length=5): return make_line_block -class TestAdd: +@pytest.fixture() +def line_anim(): + def make_line_anim(t_length=5, timeline=False): + x = np.linspace(0, 1, 10) + t = np.linspace(0, 1, t_length) + x_grid, t_grid = np.meshgrid(x, t) + y_data = np.sin(2 * np.pi * (x_grid + t_grid)) + + block = amp.blocks.Line(x, y_data) + + if timeline: + return amp.Animation([block], timeline=amp.Timeline(t)) + else: + return amp.Animation([block]) + + return make_line_anim + + +class TestAddBlocks: def test_add_blocks(self, line_block): anim = amp.Animation([line_block()]) anim2 = anim.add(line_block()) @@ -80,3 +103,32 @@ def test_wrong_type(self, line_block): with pytest.raises(TypeError): anim.add('not a block') + + +class TestAddAnimations: + def test_add_animations(self, line_anim): + anim = line_anim() + anim2 = anim.add(line_anim()) + + assert isinstance(anim2, amp.Animation) + + def test_add_animation_with_timeline(self, line_anim): + anim = line_anim() + anim2 = anim.add(line_anim(timeline=True)) + + assert isinstance(anim2, amp.Animation) + assert len(anim2.timeline) == 5 + + @pytest.mark.xfail(reason="This isn't testing desired behaviour" + "because both timelines are the same") + def test_add_animations_both_with_timelines(self, line_anim): + anim = line_anim(timeline=True) + anim2 = anim.add(line_anim(timeline=True)) + + assert isinstance(anim2, amp.Animation) + assert len(anim2.timeline) == 5 + + def test_add_animations_different_lengths(self, line_anim): + anim = line_anim() + with pytest.raises(ValueError): + anim.add(line_anim(t_length=6)) From 6f7c5d7f011386e66242321f91f09be4df7deaff Mon Sep 17 00:00:00 2001 From: Thomas Nicholas Date: Tue, 9 Apr 2019 10:59:58 +0100 Subject: [PATCH 5/8] Now properly replaces timeline when adding animations --- animatplot/animation.py | 12 ++++++------ tests/test_animation.py | 35 +++++++++++++++++++++-------------- 2 files changed, 27 insertions(+), 20 deletions(-) diff --git a/animatplot/animation.py b/animatplot/animation.py index ccaab76..20948c5 100644 --- a/animatplot/animation.py +++ b/animatplot/animation.py @@ -27,6 +27,11 @@ class Animation: a matplotlib animation returned from FuncAnimation """ def __init__(self, blocks, timeline=None, fig=None): + self.fig = plt.gcf() if fig is None else fig + + self.animation = self._animate(blocks, timeline) + + def _animate(self, blocks, timeline): if timeline is None: self.timeline = Timeline(range(len(blocks[0]))) elif not isinstance(timeline, Timeline): @@ -34,12 +39,7 @@ def __init__(self, blocks, timeline=None, fig=None): else: self.timeline = timeline - self.fig = plt.gcf() if fig is None else fig - - self.animation = self._animate(blocks, self.timeline) - - def _animate(self, blocks, timeline): - _len_time = len(timeline) + _len_time = len(self.timeline) for block in blocks: if len(block) != _len_time: raise ValueError("All blocks must animate for the same amount " diff --git a/tests/test_animation.py b/tests/test_animation.py index 70f81f9..27933d1 100644 --- a/tests/test_animation.py +++ b/tests/test_animation.py @@ -83,11 +83,11 @@ def make_line_anim(t_length=5, timeline=False): class TestAddBlocks: def test_add_blocks(self, line_block): anim = amp.Animation([line_block()]) - anim2 = anim.add(line_block()) + result = anim.add(line_block()) - assert isinstance(anim2, amp.Animation) - assert len(anim2.blocks) == 2 - for actual in anim2.blocks: + assert isinstance(result, amp.Animation) + assert len(result.blocks) == 2 + for actual in result.blocks: assert len(actual) == 5 npt.assert_equal(actual.line.get_xdata(), np.linspace(0, 1, 10)) @@ -108,25 +108,32 @@ def test_wrong_type(self, line_block): class TestAddAnimations: def test_add_animations(self, line_anim): anim = line_anim() - anim2 = anim.add(line_anim()) + result = anim.add(line_anim()) - assert isinstance(anim2, amp.Animation) + assert isinstance(result, amp.Animation) def test_add_animation_with_timeline(self, line_anim): anim = line_anim() - anim2 = anim.add(line_anim(timeline=True)) + result = anim.add(line_anim(timeline=True)) - assert isinstance(anim2, amp.Animation) - assert len(anim2.timeline) == 5 + assert isinstance(result, amp.Animation) + assert len(result.timeline) == 5 - @pytest.mark.xfail(reason="This isn't testing desired behaviour" - "because both timelines are the same") def test_add_animations_both_with_timelines(self, line_anim): anim = line_anim(timeline=True) - anim2 = anim.add(line_anim(timeline=True)) + anim2 = line_anim() + t = 10*np.arange(5) + anim2.timeline = amp.Timeline(t) - assert isinstance(anim2, amp.Animation) - assert len(anim2.timeline) == 5 + print(anim2.timeline) + + result = anim.add(anim2) + + print(result.timeline) + + assert isinstance(result, amp.Animation) + assert len(result.timeline) == 5 + npt.assert_equal(result.timeline.t, t) def test_add_animations_different_lengths(self, line_anim): anim = line_anim() From f322b4d2def78038822e3a87a97eaab3aa87b9b7 Mon Sep 17 00:00:00 2001 From: Thomas Nicholas Date: Tue, 9 Apr 2019 11:04:54 +0100 Subject: [PATCH 6/8] Animation.add() method now returns None --- animatplot/animation.py | 1 - tests/test_animation.py | 30 +++++++++++++----------------- 2 files changed, 13 insertions(+), 18 deletions(-) diff --git a/animatplot/animation.py b/animatplot/animation.py index 20948c5..7bf99fe 100644 --- a/animatplot/animation.py +++ b/animatplot/animation.py @@ -225,4 +225,3 @@ def add(self, new): self.blocks.append(block) self.animation = self._animate(self.blocks, new_timeline) - return self diff --git a/tests/test_animation.py b/tests/test_animation.py index 27933d1..da3475a 100644 --- a/tests/test_animation.py +++ b/tests/test_animation.py @@ -83,11 +83,11 @@ def make_line_anim(t_length=5, timeline=False): class TestAddBlocks: def test_add_blocks(self, line_block): anim = amp.Animation([line_block()]) - result = anim.add(line_block()) + anim.add(line_block()) - assert isinstance(result, amp.Animation) - assert len(result.blocks) == 2 - for actual in result.blocks: + assert isinstance(anim, amp.Animation) + assert len(anim.blocks) == 2 + for actual in anim.blocks: assert len(actual) == 5 npt.assert_equal(actual.line.get_xdata(), np.linspace(0, 1, 10)) @@ -108,16 +108,16 @@ def test_wrong_type(self, line_block): class TestAddAnimations: def test_add_animations(self, line_anim): anim = line_anim() - result = anim.add(line_anim()) + anim.add(line_anim()) - assert isinstance(result, amp.Animation) + assert isinstance(anim, amp.Animation) def test_add_animation_with_timeline(self, line_anim): anim = line_anim() - result = anim.add(line_anim(timeline=True)) + anim.add(line_anim(timeline=True)) - assert isinstance(result, amp.Animation) - assert len(result.timeline) == 5 + assert isinstance(anim, amp.Animation) + assert len(anim.timeline) == 5 def test_add_animations_both_with_timelines(self, line_anim): anim = line_anim(timeline=True) @@ -125,15 +125,11 @@ def test_add_animations_both_with_timelines(self, line_anim): t = 10*np.arange(5) anim2.timeline = amp.Timeline(t) - print(anim2.timeline) + anim.add(anim2) - result = anim.add(anim2) - - print(result.timeline) - - assert isinstance(result, amp.Animation) - assert len(result.timeline) == 5 - npt.assert_equal(result.timeline.t, t) + assert isinstance(anim, amp.Animation) + assert len(anim.timeline) == 5 + npt.assert_equal(anim.timeline.t, t) def test_add_animations_different_lengths(self, line_anim): anim = line_anim() From 706b7f9358f68869a481d68d2f660015da2b3b11 Mon Sep 17 00:00:00 2001 From: Thomas Nicholas Date: Mon, 29 Apr 2019 13:54:53 +0100 Subject: [PATCH 7/8] Resize slider depending on text size --- animatplot/animation.py | 58 +++++++++++++++++++++++++++++------------ 1 file changed, 42 insertions(+), 16 deletions(-) diff --git a/animatplot/animation.py b/animatplot/animation.py index 7bf99fe..d902af0 100644 --- a/animatplot/animation.py +++ b/animatplot/animation.py @@ -1,6 +1,8 @@ from matplotlib.animation import FuncAnimation, PillowWriter from matplotlib.widgets import Button, Slider +from matplotlib.text import Text import matplotlib.pyplot as plt + import numpy as np from animatplot import Timeline @@ -71,7 +73,8 @@ def toggle(self, ax=None): """ if ax is None: adjust_plot = {'bottom': .2} - rect = [.78, .03, .1, .07] + left, bottom, width, height = (.78, .03, .1, .07) + rect = (left, bottom, width, height) plt.subplots_adjust(**adjust_plot) self.button_ax = plt.axes(rect) @@ -80,7 +83,7 @@ def toggle(self, ax=None): self.button = Button(self.button_ax, "Pause") self.button.label2 = self.button_ax.text( - 0.5, 0.5, 'Play', + x=0.5, y=0.5, s='Play', verticalalignment='center', horizontalalignment='center', transform=self.button_ax.transAxes @@ -115,14 +118,6 @@ def timeline_slider(self, text='Time', ax=None, valfmt=None, color=None): color : The color of the slider. """ - if ax is None: - adjust_plot = {'bottom': .2} - rect = [.18, .05, .5, .03] - - plt.subplots_adjust(**adjust_plot) - self.slider_ax = plt.axes(rect) - else: - self.slider_ax = ax if valfmt is None: if (np.issubdtype(self.timeline.t.dtype, np.datetime64) @@ -130,26 +125,57 @@ def timeline_slider(self, text='Time', ax=None, valfmt=None, color=None): valfmt = '%s' else: valfmt = '%1.2f' - if self.timeline.log: valfmt = '$10^{%s}$' % valfmt + if ax is None: + # Try to intelligently decide slider width to avoid overlap + + renderer = self.fig.canvas.get_renderer() + + # Calculate width of widest time value on plot + def text_width(txt): + t_val_text = Text(text=txt, figure=self.fig) + bbox = t_val_text.get_window_extent(renderer=renderer) + extents = self.fig.transFigure.inverted().transform(bbox) + return extents[1][0] - extents[0][0] + + text_val_width = max(text_width(valfmt % (self.timeline[i])) + for i in range(len(self.timeline))) + label_width = text_width(text) + + # Calculate width of slider + default_button_width = 0.1 + width = 0.73 - text_val_width - label_width - default_button_width + + adjust_plot = {'bottom': .2} + left, bottom, height = (.18, .05, .03) + rect = (left, bottom, width, height) + + plt.subplots_adjust(**adjust_plot) + self.slider_ax = plt.axes(rect) + else: + self.slider_ax = ax + self.slider = Slider( - self.slider_ax, text, 0, self.timeline._len-1, + self.slider_ax, label=text, valmin=0, valmax=self.timeline._len-1, valinit=0, valfmt=(valfmt+self.timeline.units), valstep=1, color=color ) self._has_slider = True - def set_time(t): - self.timeline.index = int(self.slider.val) + def set_time(new_slider_val): + # Update slider value and text on each step + self.timeline.index = int(new_slider_val) self.slider.valtext.set_text( self.slider.valfmt % (self.timeline[self.timeline.index])) + if self._pause: for block in self.blocks: block._update(self.timeline.index) self.fig.canvas.draw() + self.slider.on_changed(set_time) def controls(self, timeline_slider_args={}, toggle_args={}): @@ -170,8 +196,8 @@ def controls(self, timeline_slider_args={}, toggle_args={}): def save_gif(self, filename): """Saves the animation to a gif - A convience function. Provided to let the user avoid dealing - with writers. + A convenience function. Provided to let the user avoid dealing + with writers - uses PillowWriter. Parameters ---------- From 628a45dc390834a008f76f1bd0d6cc7e533cd76d Mon Sep 17 00:00:00 2001 From: Thomas Nicholas Date: Mon, 29 Apr 2019 13:55:36 +0100 Subject: [PATCH 8/8] Added test to check text does not overlap button --- tests/test_animation.py | 27 ++++++++++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/tests/test_animation.py b/tests/test_animation.py index da3475a..3f3747d 100644 --- a/tests/test_animation.py +++ b/tests/test_animation.py @@ -64,7 +64,7 @@ def make_line_block(t_length=5): @pytest.fixture() def line_anim(): - def make_line_anim(t_length=5, timeline=False): + def make_line_anim(t_length=5, timeline=False, controls=False): x = np.linspace(0, 1, 10) t = np.linspace(0, 1, t_length) x_grid, t_grid = np.meshgrid(x, t) @@ -73,13 +73,34 @@ def make_line_anim(t_length=5, timeline=False): block = amp.blocks.Line(x, y_data) if timeline: - return amp.Animation([block], timeline=amp.Timeline(t)) + anim = amp.Animation([block], timeline=amp.Timeline(t)) else: - return amp.Animation([block]) + anim = amp.Animation([block]) + if controls: + anim.controls() + + return anim return make_line_anim +class TestSlider: + def test_slider_size(self, line_anim): + """Test text not overlapping with button (GH issue #32)""" + anim = line_anim(timeline=True, controls=True) + + slider_rhs = anim.slider_ax.get_position().x1 + + valtext_bbox = anim.slider.valtext.get_window_extent() + valtext_extents = anim.fig.transFigure.inverted().transform(valtext_bbox) + valtext_rhs = valtext_extents[1][0] + + button_lhs = anim.button_ax.get_position().x0 + + assert slider_rhs < button_lhs + assert valtext_rhs < button_lhs + + class TestAddBlocks: def test_add_blocks(self, line_block): anim = amp.Animation([line_block()])