]> source.dussan.org Git - svg.js.git/commitdiff
99% line coverage - BAAAAM
authorUlrich-Matthias Schäfer <ulima.ums@googlemail.com>
Sun, 3 May 2020 02:32:34 +0000 (12:32 +1000)
committerUlrich-Matthias Schäfer <ulima.ums@googlemail.com>
Sun, 3 May 2020 02:32:34 +0000 (12:32 +1000)
CHANGELOG.md
spec/setupBrowser.js
spec/spec/animation/Morphable.js
spec/spec/animation/Runner.js
src/animation/Morphable.js
src/animation/Runner.js
src/types/Color.js

index 9789900cd78d1e3e2791badd5d908efd3b6619f6..90bd0f5b358fbc77185f5ca8454b61a4a49cfc37 100644 (file)
@@ -35,6 +35,9 @@ The document follows the conventions described in [“Keep a CHANGELOG”](http:
  - fixed `Timeline.reverse()` which did exactly the opposite of what you would expect when passing `true/false`
  - fixed cancelAnimationFrame-mock for tests
  - fixed animate when=after to be really "now" when no runner is on the timeline
+ - fixed animate attr which is also retargetable now
+ - fixed internals of ObjectBag which can hold other Morphable values now
+ - fixed animate transform which didnt change its origin on retarget for declaritive animations
 
 ### Added
  - added second Parameter to `SVG(el, isHTML)` which allows to explicitely create elements in the HTML namespace (#1058)
index b2441c13ff148525b78f50ec193d70cd58ccc43c..429fa88fae8562032b729261fcf843effebe0d5a 100644 (file)
@@ -1,7 +1,7 @@
 /* globals beforeEach, afterEach, jasmine */
 import { buildCanvas, clear } from './helpers.js'
 
-jasmine.DEFAULT_TIMEOUT_INTERVAL = 200
+jasmine.DEFAULT_TIMEOUT_INTERVAL = 500
 
 beforeEach(() => {
   // buildFixtures()
index c1f27e811600e279d7c050d66a840052b0eeb3db..f703f979190ab363c4af5a6b51a297d14fe55497 100644 (file)
@@ -313,20 +313,39 @@ describe('Morphable.js', () => {
   describe('ObjectBag', () => {
     describe('()', () => {
       it('wraps an object into a morphable object by passing an array', () => {
-        const bag = new ObjectBag([ 'foo', 1, 'bar', 2, 'baz', 3 ])
-        expect(bag.values).toEqual([ 'foo', 1, 'bar', 2, 'baz', 3 ])
+        const bag = new ObjectBag([ 'foo', SVGNumber, 2, 1, '', 'bar', SVGNumber, 2, 2, '', 'baz', SVGNumber, 2, 3, '' ])
+        expect(bag.values).toEqual([ 'foo', SVGNumber, 2, 1, '', 'bar', SVGNumber, 2, 2, '', 'baz', SVGNumber, 2, 3, '' ])
       })
 
       it('wraps an object into a morphable object by passing an object', () => {
         const bag = new ObjectBag({ foo: 1, bar: 2, baz: 3 })
-        expect(bag.values).toEqual([ 'bar', 2, 'baz', 3, 'foo', 1 ])
+        expect(bag.values).toEqual([ 'bar', SVGNumber, 2, 2, '', 'baz', SVGNumber, 2, 3, '', 'foo', SVGNumber, 2, 1, '' ])
+      })
+
+      it('wraps an object with morphable values in an ObjectBag', () => {
+        const bag = new ObjectBag({ fill: new Color(), bar: 2 })
+        expect(bag.values).toEqual([ 'bar', SVGNumber, 2, 2, '', 'fill', Color, 5, 0, 0, 0, 0, 'rgb' ])
+      })
+
+      it('wraps an array with morphable representation in an ObjectBag', () => {
+        const bag = new ObjectBag([ 'bar', SVGNumber, 2, 2, '', 'fill', Color, 5, 0, 0, 0, 0, 'rgb' ])
+        expect(bag.toArray()).toEqual([ 'bar', SVGNumber, 2, 2, '', 'fill', Color, 5, 0, 0, 0, 0, 'rgb' ])
       })
     })
 
     describe('toArray()', () => {
       it('creates an array out of the object', () => {
         const bag = new ObjectBag({ foo: 1, bar: 2, baz: 3 })
-        expect(bag.toArray()).toEqual([ 'bar', 2, 'baz', 3, 'foo', 1 ])
+        expect(bag.toArray()).toEqual(
+          [ 'bar', SVGNumber, 2, 2, '', 'baz', SVGNumber, 2, 3, '', 'foo', SVGNumber, 2, 1, '' ]
+        )
+      })
+
+      it('creates a flattened array out of the object with morphable values', () => {
+        const bag = new ObjectBag({ fill: new Color(), bar: 2 })
+        expect(bag.toArray()).toEqual(
+          [ 'bar', SVGNumber, 2, 2, '', 'fill', Color, 5, 0, 0, 0, 0, 'rgb' ]
+        )
       })
     })
 
@@ -335,6 +354,11 @@ describe('Morphable.js', () => {
         const bag = new ObjectBag({ foo: 1, bar: 2, baz: 3 })
         expect(bag.valueOf()).toEqual({ foo: 1, bar: 2, baz: 3 })
       })
+
+      it('creates also morphable objects from the stored values', () => {
+        const bag = new ObjectBag({ fill: new Color(), bar: 2 })
+        expect(bag.valueOf()).toEqual({ fill: objectContaining(new Color()), bar: 2 })
+      })
     })
   })
 })
index 63d1bb8908b673864ff707df91f3d6c8ec16f32a..08fdbdcb68c2645bc5493c291afeb81f7980dbae 100644 (file)
 /* globals describe, expect, it, beforeEach, afterEach, spyOn, jasmine */
 
-import { Runner, defaults, Ease, Controller, SVG, Timeline } from '../../../src/main.js'
+import { Runner, defaults, Ease, Controller, SVG, Timeline, Rect, Morphable, Animator, Queue, Matrix, Color, Box, Polygon, PathArray, PointArray } from '../../../src/main.js'
+import { FakeRunner, RunnerArray } from '../../../src/animation/Runner.js'
 import { getWindow } from '../../../src/utils/window.js'
+import SVGNumber from '../../../src/types/SVGNumber.js'
 
-const { createSpy, objectContaining, arrayContaining } = jasmine
+const { any, createSpy, objectContaining, arrayContaining } = jasmine
 
 describe('Runner.js', () => {
 
-  var initFn = createSpy('initFn')
-  var runFn = createSpy('runFn')
-
-  beforeEach(() => {
-    jasmine.RequestAnimationFrame.install(getWindow())
-    initFn.calls.reset()
-    runFn.calls.reset()
-  })
-
-  afterEach(() => {
-    jasmine.RequestAnimationFrame.uninstall(getWindow())
-  })
+  describe('Runner', () => {
+    var initFn = createSpy('initFn')
+    var runFn = createSpy('runFn')
+
+    beforeEach(() => {
+      jasmine.RequestAnimationFrame.install(getWindow())
+      Animator.timeouts = new Queue()
+      Animator.frames = new Queue()
+      Animator.immediates = new Queue()
+      Animator.nextDraw = null
+      initFn.calls.reset()
+      runFn.calls.reset()
+    })
 
-  describe('sanitise()', () => {
-    it('can handle all form of input', () => {
-      var fn = Runner.sanitise
-
-      expect(fn(200, 200, 'now')).toEqual(objectContaining({
-        duration: 200,
-        delay: 200,
-        when: 'now',
-        times: 1,
-        wait: 0,
-        swing: false
-      }))
-
-      expect(fn(200, 200)).toEqual(objectContaining({
-        duration: 200,
-        delay: 200,
-        when: 'last',
-        times: 1,
-        wait: 0,
-        swing: false
-      }))
-
-      expect(fn(200)).toEqual(objectContaining({
-        duration: 200,
-        delay: defaults.timeline.delay,
-        when: 'last',
-        times: 1,
-        wait: 0,
-        swing: false
-      }))
-
-      expect(fn(runFn)).toEqual(objectContaining({
-        duration: runFn,
-        delay: defaults.timeline.delay,
-        when: 'last',
-        times: 1,
-        wait: 0,
-        swing: false
-      }))
-
-      expect(fn({ delay: 200 })).toEqual(objectContaining({
-        duration: defaults.timeline.duration,
-        delay: 200,
-        when: 'last',
-        times: 1,
-        wait: 0,
-        swing: false
-      }))
-
-      expect(fn({ times: 3, delay: 200, when: 'now', swing: true, wait: 200 })).toEqual(objectContaining({
-        duration: defaults.timeline.duration,
-        delay: 200,
-        when: 'now',
-        times: 3,
-        wait: 200,
-        swing: true
-      }))
+    afterEach(() => {
+      jasmine.RequestAnimationFrame.uninstall(getWindow())
     })
-  })
 
-  describe('())', () => {
-    it('creates a runner with defaults', () => {
-      var runner = new Runner()
-      expect(runner instanceof Runner).toBe(true)
-      expect(runner._duration).toBe(defaults.timeline.duration)
-      expect(runner._stepper instanceof Ease).toBe(true)
+    describe('sanitise()', () => {
+      it('can handle all form of input', () => {
+        var fn = Runner.sanitise
+
+        expect(fn(200, 200, 'now')).toEqual(objectContaining({
+          duration: 200,
+          delay: 200,
+          when: 'now',
+          times: 1,
+          wait: 0,
+          swing: false
+        }))
+
+        expect(fn(200, 200)).toEqual(objectContaining({
+          duration: 200,
+          delay: 200,
+          when: 'last',
+          times: 1,
+          wait: 0,
+          swing: false
+        }))
+
+        expect(fn(200)).toEqual(objectContaining({
+          duration: 200,
+          delay: defaults.timeline.delay,
+          when: 'last',
+          times: 1,
+          wait: 0,
+          swing: false
+        }))
+
+        expect(fn(runFn)).toEqual(objectContaining({
+          duration: runFn,
+          delay: defaults.timeline.delay,
+          when: 'last',
+          times: 1,
+          wait: 0,
+          swing: false
+        }))
+
+        expect(fn({ delay: 200 })).toEqual(objectContaining({
+          duration: defaults.timeline.duration,
+          delay: 200,
+          when: 'last',
+          times: 1,
+          wait: 0,
+          swing: false
+        }))
+
+        expect(fn({ times: 3, delay: 200, when: 'now', swing: true, wait: 200 })).toEqual(objectContaining({
+          duration: defaults.timeline.duration,
+          delay: 200,
+          when: 'now',
+          times: 3,
+          wait: 200,
+          swing: true
+        }))
+      })
     })
 
-    it('creates a runner with duration set', () => {
-      var runner = new Runner(1000)
-      expect(runner instanceof Runner).toBe(true)
-      expect(runner._duration).toBe(1000)
-      expect(runner._stepper instanceof Ease).toBe(true)
+    describe('())', () => {
+      it('creates a runner with defaults', () => {
+        var runner = new Runner()
+        expect(runner instanceof Runner).toBe(true)
+        expect(runner._duration).toBe(defaults.timeline.duration)
+        expect(runner._stepper instanceof Ease).toBe(true)
+      })
+
+      it('creates a runner with duration set', () => {
+        var runner = new Runner(1000)
+        expect(runner instanceof Runner).toBe(true)
+        expect(runner._duration).toBe(1000)
+        expect(runner._stepper instanceof Ease).toBe(true)
+      })
+
+      it('creates a runner with controller set', () => {
+        var runner = new Runner(runFn)
+        expect(runner instanceof Runner).toBe(true)
+        expect(runner._duration).toBeFalsy()
+        expect(runner._stepper instanceof Controller).toBe(true)
+      })
     })
 
-    it('creates a runner with controller set', () => {
-      var runner = new Runner(runFn)
-      expect(runner instanceof Runner).toBe(true)
-      expect(runner._duration).toBeFalsy()
-      expect(runner._stepper instanceof Controller).toBe(true)
+    describe('queue()', () => {
+      it('adds another closure to the runner', () => {
+        var runner = new Runner()
+        runner.queue(initFn, runFn, true)
+
+        expect(runner._queue[0]).toEqual(objectContaining({
+          initialiser: initFn,
+          initialised: false,
+          runner: runFn,
+          finished: false
+        }))
+      })
     })
-  })
 
-  describe('constructors', () => {
-    // FIXME: Not possible to spy like this in es6
-    // describe('animate()',  () => {
-    //   it('creates a runner with the element set and schedules it on the timeline',  () => {
-    //     var orginalRunner = Runner
-    //     spyOn(SVG, 'Runner').and.callFake(()  =>{
-    //       return new orginalRunner()
-    //     })
-    //
-    //     var element = SVG('<rect />')
-    //     var runner = element.animate()
-    //     expect(Runner).toHaveBeenCalled();
-    //     expect(runner instanceof Runner)
-    //     expect(runner.element()).toBe(element)
-    //     expect(runner.timeline()).toBe(element.timeline())
-    //   })
-    // })
+    describe('step()', () => {
 
-    describe('delay()', () => {
-      it('calls animate with correct parameters', () => {
-        var element = SVG('<rect />')
+      it('returns itself', () => {
+        var runner = new Runner()
+        expect(runner.step()).toBe(runner)
+      })
 
-        spyOn(element, 'animate')
-        element.delay(100, 'now')
-        expect(element.animate).toHaveBeenCalledWith(0, 100, 'now')
+      it('does nothing when not active', () => {
+        const runner = new Runner().active(false)
+        const frozen = Object.freeze(runner)
+        expect(frozen.step()).toEqual(runner)
       })
-    })
-  })
 
-  describe('queue()', () => {
-    it('adds another closure to the runner', () => {
-      var runner = new Runner()
-      runner.queue(initFn, runFn, true)
+      it('calls initFn once and runFn at every step', () => {
+        var runner = new Runner()
+        runner.queue(initFn, runFn, false)
 
-      expect(runner._queue[0]).toEqual(objectContaining({
-        initialiser: initFn,
-        initialised: false,
-        runner: runFn,
-        finished: false
-      }))
-    })
-  })
+        runner.step()
+        expect(initFn).toHaveBeenCalled()
+        expect(runFn).toHaveBeenCalled()
 
-  describe('step()', () => {
+        runner.step()
+        expect(initFn.calls.count()).toBe(1)
+        expect(runFn.calls.count()).toBe(2)
+      })
 
-    it('returns itself', () => {
-      var runner = new Runner()
-      expect(runner.step()).toBe(runner)
-    })
+      it('calls initFn on every step if its declaritive', () => {
+        var runner = new Runner(new Controller())
+        runner.queue(initFn, runFn, true)
 
-    it('calls initFn once and runFn at every step', () => {
-      var runner = new Runner()
-      runner.queue(initFn, runFn, false)
+        runner.step()
+        expect(initFn).toHaveBeenCalled()
+        expect(runFn).toHaveBeenCalled()
 
-      runner.step()
-      expect(initFn).toHaveBeenCalled()
-      expect(runFn).toHaveBeenCalled()
+        runner.step()
+        expect(initFn.calls.count()).toBe(2)
+        expect(runFn.calls.count()).toBe(2)
+      })
 
-      runner.step()
-      expect(initFn.calls.count()).toBe(1)
-      expect(runFn.calls.count()).toBe(2)
-    })
+      function getLoop (r) {
+        var loopDuration = r._duration + r._wait
+        var loopsDone = Math.floor(r._time / loopDuration)
+        return loopsDone
+      }
+
+      // step in time
+      it('steps forward a certain time', () => {
+        var spy = createSpy('stepper')
+        var r = new Runner(1000).loop(10, false, 100)
+        r.queue(null, spy)
+
+        r.step(300) // should be 0.3s
+        expect(spy).toHaveBeenCalledWith(0.3)
+        expect(getLoop(r)).toBe(0)
+
+        r.step(300) // should be 0.6s
+        expect(spy).toHaveBeenCalledWith(0.6)
+        expect(getLoop(r)).toBe(0)
+
+        r.step(600) // should be 0.1s
+        expect(spy).toHaveBeenCalledWith(0.1)
+        expect(getLoop(r)).toBe(1)
+
+        r.step(-300) // should be 0.9s
+        expect(spy).toHaveBeenCalledWith(0.9)
+        expect(getLoop(r)).toBe(0)
+
+        r.step(2000) // should be 0.7s
+        expect(spy).toHaveBeenCalledWith(0.7)
+        expect(getLoop(r)).toBe(2)
+
+        r.step(-2000) // should be 0.9s
+        expect(spy).toHaveBeenCalledWith(0.9)
+        expect(getLoop(r)).toBe(0)
+      })
 
-    it('calls initFn on every step if its declaritive', () => {
-      var runner = new Runner(new Controller())
-      runner.queue(initFn, runFn, true)
+      it('handles dts which are bigger than the animation time', () => {
+        var runner = new Runner(1000)
+        runner.queue(initFn, runFn, true)
 
-      runner.step()
-      expect(initFn).toHaveBeenCalled()
-      expect(runFn).toHaveBeenCalled()
+        runner.step(1100)
+        expect(initFn).toHaveBeenCalled()
+        expect(runFn).toHaveBeenCalledWith(1)
+      })
 
-      runner.step()
-      expect(initFn.calls.count()).toBe(2)
-      expect(runFn.calls.count()).toBe(2)
-    })
+      describe('looping', () => {
+        describe('without wait', () => {
+          describe('unreversed', () => {
+            describe('nonswinging', () => {
+              it('does behave correctly at the end of an even loop', () => {
+                var spy = createSpy('stepper')
+                var runner = new Runner(1000).loop(6, false)
+                runner.queue(null, spy)
+
+                runner.step(5750)
+                expect(spy).toHaveBeenCalledWith(0.75)
+                runner.step(250)
+                expect(spy).toHaveBeenCalledWith(1)
+              })
+
+              it('does behave correctly at the end of an uneven loop', () => {
+                var spy = createSpy('stepper')
+                var runner = new Runner(1000).loop(5, false)
+                runner.queue(null, spy)
+
+                runner.step(4750)
+                expect(spy).toHaveBeenCalledWith(0.75)
+                runner.step(250)
+                expect(spy).toHaveBeenCalledWith(1)
+              })
+            })
 
-    function getLoop (r) {
-      var loopDuration = r._duration + r._wait
-      var loopsDone = Math.floor(r._time / loopDuration)
-      return loopsDone
-    }
+            describe('swinging', () => {
+              it('does behave correctly at the end of an even loop', () => {
+                var spy = createSpy('stepper')
+                var runner = new Runner(1000).loop(6, true)
+                runner.queue(null, spy)
+
+                runner.step(5750)
+                expect(spy).toHaveBeenCalledWith(0.25)
+                runner.step(250)
+                expect(spy).toHaveBeenCalledWith(0)
+              })
+
+              it('does behave correctly at the end of an uneven loop', () => {
+                var spy = createSpy('stepper')
+                var runner = new Runner(1000).loop(5, true)
+                runner.queue(null, spy)
+
+                runner.step(4750)
+                expect(spy).toHaveBeenCalledWith(0.75)
+                runner.step(250)
+                expect(spy).toHaveBeenCalledWith(1)
+              })
+            })
+          })
 
-    // step in time
-    it('steps forward a certain time', () => {
-      var spy = createSpy('stepper')
-      var r = new Runner(1000).loop(10, false, 100)
-      r.queue(null, spy)
+          describe('reversed', () => {
+            describe('nonswinging', () => {
+              it('does behave correctly at the end of an even loop', () => {
+                var spy = createSpy('stepper')
+                var runner = new Runner(1000).loop(6, false).reverse()
+                runner.queue(null, spy)
+
+                runner.step(5750)
+                expect(spy).toHaveBeenCalledWith(0.25)
+                runner.step(250)
+                expect(spy).toHaveBeenCalledWith(0)
+              })
+
+              it('does behave correctly at the end of an uneven loop', () => {
+                var spy = createSpy('stepper')
+                var runner = new Runner(1000).loop(5, false).reverse()
+                runner.queue(null, spy)
+
+                runner.step(4750)
+                expect(spy).toHaveBeenCalledWith(0.25)
+                runner.step(250)
+                expect(spy).toHaveBeenCalledWith(0)
+              })
+            })
 
-      r.step(300) // should be 0.3s
-      expect(spy).toHaveBeenCalledWith(0.3)
-      expect(getLoop(r)).toBe(0)
+            describe('swinging', () => {
+              it('does behave correctly at the end of an even loop', () => {
+                var spy = createSpy('stepper')
+                var runner = new Runner(1000).loop(6, true).reverse()
+                runner.queue(null, spy)
+
+                runner.step(5750)
+                expect(spy).toHaveBeenCalledWith(0.75)
+                runner.step(250)
+                expect(spy).toHaveBeenCalledWith(1)
+              })
+
+              it('does behave correctly at the end of an uneven loop', () => {
+                var spy = createSpy('stepper')
+                var runner = new Runner(1000).loop(5, true).reverse()
+                runner.queue(null, spy)
+
+                runner.step(4750)
+                expect(spy).toHaveBeenCalledWith(0.25)
+                runner.step(250)
+                expect(spy).toHaveBeenCalledWith(0)
+              })
+            })
+          })
+        })
 
-      r.step(300) // should be 0.6s
-      expect(spy).toHaveBeenCalledWith(0.6)
-      expect(getLoop(r)).toBe(0)
+        describe('with wait', () => {
+          describe('unreversed', () => {
+            describe('nonswinging', () => {
+              it('does behave correctly at the end of an even loop', () => {
+                var spy = createSpy('stepper')
+                var runner = new Runner(1000).loop(6, false, 100)
+                runner.queue(null, spy)
+
+                runner.step(5450)
+                expect(spy).toHaveBeenCalledWith(1)
+                spy.calls.reset()
+
+                runner.step(800)
+                expect(spy).toHaveBeenCalledWith(0.75)
+                runner.step(250)
+                expect(spy).toHaveBeenCalledWith(1)
+              })
+
+              it('does behave correctly at the end of an uneven loop', () => {
+                var spy = createSpy('stepper')
+                var runner = new Runner(1000).loop(5, false, 100)
+                runner.queue(null, spy)
+
+                runner.step(4350)
+                expect(spy).toHaveBeenCalledWith(1)
+                spy.calls.reset()
+
+                runner.step(800)
+                expect(spy).toHaveBeenCalledWith(0.75)
+                runner.step(250)
+                expect(spy).toHaveBeenCalledWith(1)
+              })
+            })
 
-      r.step(600) // should be 0.1s
-      expect(spy).toHaveBeenCalledWith(0.1)
-      expect(getLoop(r)).toBe(1)
+            describe('swinging', () => {
+              it('does behave correctly at the end of an even loop', () => {
+                var spy = createSpy('stepper')
+                var runner = new Runner(1000).loop(6, true, 100)
+                runner.queue(null, spy)
+
+                runner.step(5450)
+                expect(spy).toHaveBeenCalledWith(1)
+                spy.calls.reset()
+
+                runner.step(800)
+                expect(spy).toHaveBeenCalledWith(0.25)
+                runner.step(250)
+                expect(spy).toHaveBeenCalledWith(0)
+              })
+
+              it('does behave correctly at the end of an uneven loop', () => {
+                var spy = createSpy('stepper')
+                var runner = new Runner(1000).loop(5, true, 100)
+                runner.queue(null, spy)
+
+                runner.step(4350)
+                expect(spy).toHaveBeenCalledWith(0)
+                spy.calls.reset()
+
+                runner.step(800)
+                expect(spy).toHaveBeenCalledWith(0.75)
+
+                runner.step(250)
+                expect(spy).toHaveBeenCalledWith(1)
+              })
+            })
+          })
 
-      r.step(-300) // should be 0.9s
-      expect(spy).toHaveBeenCalledWith(0.9)
-      expect(getLoop(r)).toBe(0)
+          describe('reversed', () => {
+            describe('nonswinging', () => {
+              it('does behave correctly at the end of an even loop', () => {
+                var spy = createSpy('stepper')
+                var runner = new Runner(1000).loop(6, false, 100).reverse()
+                runner.queue(null, spy)
+
+                runner.step(5450)
+                expect(spy).toHaveBeenCalledWith(0)
+                spy.calls.reset()
+
+                runner.step(800)
+                expect(spy).toHaveBeenCalledWith(0.25)
+                runner.step(250)
+                expect(spy).toHaveBeenCalledWith(0)
+              })
+
+              it('does behave correctly at the end of an uneven loop', () => {
+                var spy = createSpy('stepper')
+                var runner = new Runner(1000).loop(5, false, 100).reverse()
+                runner.queue(null, spy)
+
+                runner.step(4350)
+                expect(spy).toHaveBeenCalledWith(0)
+                spy.calls.reset()
+
+                runner.step(800)
+                expect(spy).toHaveBeenCalledWith(0.25)
+                runner.step(250)
+                expect(spy).toHaveBeenCalledWith(0)
+              })
+            })
 
-      r.step(2000) // should be 0.7s
-      expect(spy).toHaveBeenCalledWith(0.7)
-      expect(getLoop(r)).toBe(2)
+            describe('swinging', () => {
+              it('does behave correctly at the end of an even loop', () => {
+                var spy = createSpy('stepper')
+                var runner = new Runner(1000).loop(6, true, 100).reverse()
+                runner.queue(null, spy)
+
+                runner.step(5450)
+                expect(spy).toHaveBeenCalledWith(0)
+                spy.calls.reset()
+
+                runner.step(800)
+                expect(spy).toHaveBeenCalledWith(0.75)
+                runner.step(250)
+                expect(spy).toHaveBeenCalledWith(1)
+              })
+
+              it('does behave correctly at the end of an uneven loop', () => {
+                var spy = createSpy('stepper')
+                var runner = new Runner(1000).loop(5, true, 100).reverse()
+                runner.queue(null, spy)
+
+                runner.step(4350)
+                expect(spy).toHaveBeenCalledWith(1)
+                spy.calls.reset()
+
+                runner.step(800)
+                expect(spy).toHaveBeenCalledWith(0.25)
+                runner.step(250)
+                expect(spy).toHaveBeenCalledWith(0)
+              })
+            })
+          })
+        })
+      })
 
-      r.step(-2000) // should be 0.9s
-      expect(spy).toHaveBeenCalledWith(0.9)
-      expect(getLoop(r)).toBe(0)
     })
 
-    it('handles dts which are bigger than the animation time', () => {
-      var runner = new Runner(1000)
-      runner.queue(initFn, runFn, true)
+    describe('active()', () => {
+      it('acts as a getter without parameters', () => {
+        var runner = new Runner()
+        expect(runner.active()).toBe(true)
+      })
+
+      it('disables the runner when false is passed', () => {
+        var runner = new Runner()
+        expect(runner.active(false)).toBe(runner)
+        expect(runner.active()).toBe(false)
+      })
 
-      runner.step(1100)
-      expect(initFn).toHaveBeenCalled()
-      expect(runFn).toHaveBeenCalledWith(1)
+      it('enables the runner when true is passed', () => {
+        var runner = new Runner()
+        expect(runner.active(false)).toBe(runner)
+        expect(runner.active(true)).toBe(runner)
+        expect(runner.active()).toBe(true)
+      })
     })
 
-    describe('looping', () => {
-      describe('without wait', () => {
-        describe('unreversed', () => {
-          describe('nonswinging', () => {
-            it('does behave correctly at the end of an even loop', () => {
-              var spy = createSpy('stepper')
-              var runner = new Runner(1000).loop(6, false)
-              runner.queue(null, spy)
+    describe('duration()', () => {
+      it('return the full duration of the runner including all loops and waits', () => {
+        var runner = new Runner(800).loop(10, true, 200)
+        expect(runner.duration()).toBe(9800)
+      })
+    })
 
-              runner.step(5750)
-              expect(spy).toHaveBeenCalledWith(0.75)
-              runner.step(250)
-              expect(spy).toHaveBeenCalledWith(1)
-            })
+    describe('loop()', () => {
+      it('makes this runner looping', () => {
+        var runner = new Runner(1000).loop(5)
+        expect(runner.duration()).toBe(5000)
+      })
 
-            it('does behave correctly at the end of an uneven loop', () => {
-              var spy = createSpy('stepper')
-              var runner = new Runner(1000).loop(5, false)
-              runner.queue(null, spy)
+      it('makes this runner indefinitey by passing true', () => {
+        var runner = new Runner(1000).loop(true)
+        expect(runner.duration()).toBe(Infinity)
+      })
 
-              runner.step(4750)
-              expect(spy).toHaveBeenCalledWith(0.75)
-              runner.step(250)
-              expect(spy).toHaveBeenCalledWith(1)
-            })
-          })
+      it('makes this runner indefinitey by passing nothing', () => {
+        var runner = new Runner(1000).loop()
+        expect(runner.duration()).toBe(Infinity)
+      })
+    })
 
-          describe('swinging', () => {
-            it('does behave correctly at the end of an even loop', () => {
-              var spy = createSpy('stepper')
-              var runner = new Runner(1000).loop(6, true)
-              runner.queue(null, spy)
+    describe('time()', () => {
+      it('returns itself', () => {
+        var runner = new Runner()
+        expect(runner.time(0)).toBe(runner)
+      })
 
-              runner.step(5750)
-              expect(spy).toHaveBeenCalledWith(0.25)
-              runner.step(250)
-              expect(spy).toHaveBeenCalledWith(0)
-            })
+      it('acts as a getter with no parameter passed', () => {
+        var runner = new Runner()
+        expect(runner.time()).toBe(0)
+      })
 
-            it('does behave correctly at the end of an uneven loop', () => {
-              var spy = createSpy('stepper')
-              var runner = new Runner(1000).loop(5, true)
-              runner.queue(null, spy)
+      it('reschedules the runner to a new time', () => {
+        var runner = new Runner()
+        runner.time(10)
 
-              runner.step(4750)
-              expect(spy).toHaveBeenCalledWith(0.75)
-              runner.step(250)
-              expect(spy).toHaveBeenCalledWith(1)
-            })
-          })
-        })
+        expect(runner.time()).toBe(10)
+      })
 
-        describe('reversed', () => {
-          describe('nonswinging', () => {
-            it('does behave correctly at the end of an even loop', () => {
-              var spy = createSpy('stepper')
-              var runner = new Runner(1000).loop(6, false).reverse()
-              runner.queue(null, spy)
+      it('calls step to reschedule', () => {
+        var runner = new Runner()
+        spyOn(runner, 'step')
+        runner.time(10)
 
-              runner.step(5750)
-              expect(spy).toHaveBeenCalledWith(0.25)
-              runner.step(250)
-              expect(spy).toHaveBeenCalledWith(0)
-            })
+        expect(runner.step).toHaveBeenCalledWith(10)
+      })
+    })
 
-            it('does behave correctly at the end of an uneven loop', () => {
-              var spy = createSpy('stepper')
-              var runner = new Runner(1000).loop(5, false).reverse()
-              runner.queue(null, spy)
+    describe('loops()', () => {
+      it('get the loops of a runner', () => {
+        var spy = createSpy('stepper')
+        var runner = new Runner(1000).queue(null, spy)
 
-              runner.step(4750)
-              expect(spy).toHaveBeenCalledWith(0.25)
-              runner.step(250)
-              expect(spy).toHaveBeenCalledWith(0)
-            })
-          })
+        runner.step(300)
+        expect(spy).toHaveBeenCalledWith(0.3)
 
-          describe('swinging', () => {
-            it('does behave correctly at the end of an even loop', () => {
-              var spy = createSpy('stepper')
-              var runner = new Runner(1000).loop(6, true).reverse()
-              runner.queue(null, spy)
+        expect(runner.loops()).toBe(0.3)
+      })
+      it('sets the loops of the runner', () => {
+        var spy = createSpy('stepper')
+        var runner = new Runner(1000).queue(null, spy)
 
-              runner.step(5750)
-              expect(spy).toHaveBeenCalledWith(0.75)
-              runner.step(250)
-              expect(spy).toHaveBeenCalledWith(1)
-            })
+        expect(runner.loops(0.5).loops()).toBe(0.5)
+        expect(spy).toHaveBeenCalledWith(0.5)
 
-            it('does behave correctly at the end of an uneven loop', () => {
-              var spy = createSpy('stepper')
-              var runner = new Runner(1000).loop(5, true).reverse()
-              runner.queue(null, spy)
+        expect(runner.loops(0.1).loops()).toBe(0.1)
+        expect(spy).toHaveBeenCalledWith(0.1)
 
-              runner.step(4750)
-              expect(spy).toHaveBeenCalledWith(0.25)
-              runner.step(250)
-              expect(spy).toHaveBeenCalledWith(0)
-            })
-          })
-        })
+        expect(runner.loops(1.5).loops()).toBe(1)
+        expect(spy).toHaveBeenCalledWith(1)
       })
+      it('sets the loops of the runner in a loop', () => {
+        var spy = createSpy('stepper')
+        var runner = new Runner(1000).loop(5, true, 500).queue(null, spy)
 
-      describe('with wait', () => {
-        describe('unreversed', () => {
-          describe('nonswinging', () => {
-            it('does behave correctly at the end of an even loop', () => {
-              var spy = createSpy('stepper')
-              var runner = new Runner(1000).loop(6, false, 100)
-              runner.queue(null, spy)
+        expect(runner.loops(1.3).loops()).toBe(1.3)
+        expect(spy).toHaveBeenCalledWith(0.7)
 
-              runner.step(5450)
-              expect(spy).toHaveBeenCalledWith(1)
-              spy.calls.reset()
+        expect(runner.loops(0.3).loops()).toBe(0.3)
+      })
+    })
 
-              runner.step(800)
-              expect(spy).toHaveBeenCalledWith(0.75)
-              runner.step(250)
-              expect(spy).toHaveBeenCalledWith(1)
-            })
+    describe('progress()', () => {
+      it('gets the progress of a runner', () => {
+        var spy = createSpy('stepper')
+        var runner = new Runner(1000).queue(null, spy)
 
-            it('does behave correctly at the end of an uneven loop', () => {
-              var spy = createSpy('stepper')
-              var runner = new Runner(1000).loop(5, false, 100)
-              runner.queue(null, spy)
+        runner.step(300)
+        expect(spy).toHaveBeenCalledWith(0.3)
 
-              runner.step(4350)
-              expect(spy).toHaveBeenCalledWith(1)
-              spy.calls.reset()
+        expect(runner.progress()).toBe(0.3)
+      })
 
-              runner.step(800)
-              expect(spy).toHaveBeenCalledWith(0.75)
-              runner.step(250)
-              expect(spy).toHaveBeenCalledWith(1)
-            })
-          })
+      it('gets the progress of a runner when looping', () => {
+        var spy = createSpy('stepper')
+        var runner = new Runner(800).queue(null, spy).loop(10, false, 200) // duration should be 9800
 
-          describe('swinging', () => {
-            it('does behave correctly at the end of an even loop', () => {
-              var spy = createSpy('stepper')
-              var runner = new Runner(1000).loop(6, true, 100)
-              runner.queue(null, spy)
+        // middle of animation, in the middle of wait time
+        runner.step(4900)
+        expect(runner.progress()).toBe(0.5)
+        expect(spy).toHaveBeenCalledWith(1)
 
-              runner.step(5450)
-              expect(spy).toHaveBeenCalledWith(1)
-              spy.calls.reset()
+        // start of next loop
+        runner.step(100)
+        expect(spy).toHaveBeenCalledWith(0)
 
-              runner.step(800)
-              expect(spy).toHaveBeenCalledWith(0.25)
-              runner.step(250)
-              expect(spy).toHaveBeenCalledWith(0)
-            })
+        // move 400 into current loop which is 0.5 progress
+        // the progress value is 5400 / 9800
+        runner.step(400)
+        expect(spy).toHaveBeenCalledWith(0.5)
+        expect(runner.progress()).toBe(5400 / 9800)
+      })
 
-            it('does behave correctly at the end of an uneven loop', () => {
-              var spy = createSpy('stepper')
-              var runner = new Runner(1000).loop(5, true, 100)
-              runner.queue(null, spy)
+      it('sets the progress of a runner', () => {
+        var spy = createSpy('stepper')
+        var runner = new Runner(1000).queue(null, spy)
 
-              runner.step(4350)
-              expect(spy).toHaveBeenCalledWith(0)
-              spy.calls.reset()
+        expect(runner.progress(0.5).progress()).toBe(0.5)
+        expect(spy).toHaveBeenCalledWith(0.5)
+      })
 
-              runner.step(800)
-              expect(spy).toHaveBeenCalledWith(0.75)
+      it('sets the progress of a runner when looping', () => {
+        var spy = createSpy('stepper')
+        var runner = new Runner(800).queue(null, spy).loop(10, false, 200)
 
-              runner.step(250)
-              expect(spy).toHaveBeenCalledWith(1)
-            })
-          })
-        })
+        // progress 0.5 somewhere in the middle of wait time
+        expect(runner.progress(0.5).progress()).toBe(0.5)
+        expect(spy).toHaveBeenCalledWith(1)
 
-        describe('reversed', () => {
-          describe('nonswinging', () => {
-            it('does behave correctly at the end of an even loop', () => {
-              var spy = createSpy('stepper')
-              var runner = new Runner(1000).loop(6, false, 100).reverse()
-              runner.queue(null, spy)
+        // start of next loop
+        runner.step(100)
+        expect(spy).toHaveBeenCalledWith(0)
 
-              runner.step(5450)
-              expect(spy).toHaveBeenCalledWith(0)
-              spy.calls.reset()
+        // should move 0.5 into the next loop
+        expect(runner.progress(5400 / 9800).progress()).toBe(5400 / 9800)
+        expect(spy.calls.mostRecent().args[0]).toBeCloseTo(0.5)
+      })
+    })
 
-              runner.step(800)
-              expect(spy).toHaveBeenCalledWith(0.25)
-              runner.step(250)
-              expect(spy).toHaveBeenCalledWith(0)
-            })
+    describe('position()', () => {
 
-            it('does behave correctly at the end of an uneven loop', () => {
-              var spy = createSpy('stepper')
-              var runner = new Runner(1000).loop(5, false, 100).reverse()
-              runner.queue(null, spy)
+      it('gets the position of a runner', () => {
+        var spy = createSpy('stepper')
+        var runner = new Runner(1000).queue(null, spy)
 
-              runner.step(4350)
-              expect(spy).toHaveBeenCalledWith(0)
-              spy.calls.reset()
+        runner.step(300)
+        expect(spy).toHaveBeenCalledWith(0.3)
 
-              runner.step(800)
-              expect(spy).toHaveBeenCalledWith(0.25)
-              runner.step(250)
-              expect(spy).toHaveBeenCalledWith(0)
-            })
-          })
+        expect(runner.position()).toBe(0.3)
+      })
 
-          describe('swinging', () => {
-            it('does behave correctly at the end of an even loop', () => {
-              var spy = createSpy('stepper')
-              var runner = new Runner(1000).loop(6, true, 100).reverse()
-              runner.queue(null, spy)
+      it('gets the position of a runner when looping', () => {
+        var spy = createSpy('stepper')
+        var runner = new Runner(1000).loop(5, true, 100).queue(null, spy)
 
-              runner.step(5450)
-              expect(spy).toHaveBeenCalledWith(0)
-              spy.calls.reset()
+        runner.step(1200)
+        expect(spy).toHaveBeenCalledWith(0.9)
 
-              runner.step(800)
-              expect(spy).toHaveBeenCalledWith(0.75)
-              runner.step(250)
-              expect(spy).toHaveBeenCalledWith(1)
-            })
+        expect(runner.position()).toBe(0.9)
+      })
 
-            it('does behave correctly at the end of an uneven loop', () => {
-              var spy = createSpy('stepper')
-              var runner = new Runner(1000).loop(5, true, 100).reverse()
-              runner.queue(null, spy)
+      it('sets the position of a runner', () => {
+        var spy = createSpy('stepper')
+        var runner = new Runner(1000).queue(null, spy)
 
-              runner.step(4350)
-              expect(spy).toHaveBeenCalledWith(1)
-              spy.calls.reset()
+        expect(runner.position(0.5).position()).toBe(0.5)
+        expect(spy).toHaveBeenCalledWith(0.5)
+      })
 
-              runner.step(800)
-              expect(spy).toHaveBeenCalledWith(0.25)
-              runner.step(250)
-              expect(spy).toHaveBeenCalledWith(0)
-            })
-          })
-        })
+      it('sets the position of a runner in a loop', () => {
+        var spy = createSpy('stepper')
+        var runner = new Runner(1000).loop(5, true, 100).queue(null, spy)
+
+        runner.step(1200)
+        expect(runner.position(0.4).position()).toBe(0.4)
+        expect(spy).toHaveBeenCalledWith(0.4)
+
+        expect(runner.position(0).position()).toBe(0)
+        expect(spy).toHaveBeenCalledWith(0)
+
+        expect(runner.position(1).position()).toBe(1)
+        expect(spy).toHaveBeenCalledWith(1)
       })
     })
 
-  })
+    describe('element()', () => {
+      it('returns the element bound to this runner if any', () => {
+        var runner1 = new Runner()
+        expect(runner1.element()).toBe(null)
+
+        var element = SVG('<rect />')
+        var runner2 = element.animate()
+        expect(runner2.element()).toBe(element)
+      })
 
-  describe('active()', () => {
-    it('acts as a getter without parameters', () => {
-      var runner = new Runner()
-      expect(runner.active()).toBe(true)
+      it('sets an element to be bound to the runner', () => {
+        var runner = new Runner()
+        var element = SVG('<rect />')
+        expect(runner.element(element)).toBe(runner)
+        expect(runner.element()).toBe(element)
+      })
     })
 
-    it('disables the runner when false is passed', () => {
-      var runner = new Runner()
-      expect(runner.active(false)).toBe(runner)
-      expect(runner.active()).toBe(false)
+    describe('timeline()', () => {
+      it('returns the timeline bound to this runner if any', () => {
+        var runner1 = new Runner()
+        expect(runner1.element()).toBe(null)
+
+        var element = SVG('<rect />')
+        var runner2 = element.animate()
+        expect(runner2.timeline()).toBe(element.timeline())
+      })
+
+      it('sets a timeline to be bound to the runner', () => {
+        var runner = new Runner()
+        var timeline = new Timeline()
+        expect(runner.timeline(timeline)).toBe(runner)
+        expect(runner.timeline()).toBe(timeline)
+      })
     })
 
-    it('enables the runner when true is passed', () => {
-      var runner = new Runner()
-      expect(runner.active(false)).toBe(runner)
-      expect(runner.active(true)).toBe(runner)
-      expect(runner.active()).toBe(true)
+    describe('schedule()', () => {
+      it('schedules the runner on a timeline', () => {
+        var runner = new Runner()
+        var timeline = new Timeline()
+        var spy = spyOn(timeline, 'schedule').and.callThrough()
+
+        expect(runner.schedule(timeline, 200, 'now')).toBe(runner)
+        expect(runner.timeline()).toBe(timeline)
+        expect(spy).toHaveBeenCalledWith(runner, 200, 'now')
+      })
+
+      it('schedules the runner on its own timeline', () => {
+        var runner = new Runner()
+        var timeline = new Timeline()
+        var spy = spyOn(timeline, 'schedule')
+        runner.timeline(timeline)
+
+        expect(runner.schedule(200, 'now')).toBe(runner)
+        expect(runner.timeline()).toBe(timeline)
+        expect(spy).toHaveBeenCalledWith(runner, 200, 'now')
+      })
+
+      it('throws if no timeline is given', () => {
+        var runner = new Runner()
+        expect(() => runner.schedule(200, 'now')).toThrowError('Runner cannot be scheduled without timeline')
+      })
+
     })
-  })
 
-  describe('duration()', () => {
-    it('return the full duration of the runner including all loops and waits', () => {
-      var runner = new Runner(800).loop(10, true, 200)
-      expect(runner.duration()).toBe(9800)
+    describe('unschedule()', () => {
+      it('unschedules this runner from its timeline', () => {
+        var runner = new Runner()
+        var timeline = new Timeline()
+        var spy = spyOn(timeline, 'unschedule').and.callThrough()
+
+        expect(runner.schedule(timeline, 200, 'now')).toBe(runner)
+        expect(runner.unschedule()).toBe(runner)
+        expect(spy).toHaveBeenCalledWith(runner)
+        expect(runner.timeline()).toBe(null)
+      })
     })
-  })
 
-  describe('loop()', () => {
-    it('makes this runner looping', () => {
-      var runner = new Runner(1000).loop(5)
-      expect(runner.duration()).toBe(5000)
+    describe('animate()', () => {
+      it('creates a new runner scheduled after the first', () => {
+        var runner = new Runner(1000)
+        var timeline = new Timeline()
+
+        runner.schedule(timeline)
+
+        var runner2 = runner.animate(500, 1000)
+
+        var t = timeline.time()
+
+        expect(runner2.timeline()).toBe(timeline)
+        expect(runner2.time()).toBe(0)
+
+        expect(timeline.schedule()).toEqual(arrayContaining([
+          objectContaining({ start: t, duration: 1000, end: t + 1000, runner: runner }),
+          objectContaining({ start: t + 2000, duration: 500, end: t + 2500, runner: runner2 })
+        ]))
+      })
+
+      it('reuses timeline and element of current runner', () => {
+        const element = new Rect()
+        const timeline = new Timeline()
+        const runner = new Runner().element(element).timeline(timeline)
+        const after = runner.animate()
+        expect(after.timeline()).toBe(timeline)
+        expect(after.element()).toBe(element)
+      })
+
+      it('doesnt reuse element if not set', () => {
+        const timeline = new Timeline()
+        const runner = new Runner().timeline(timeline)
+        const after = runner.animate()
+        expect(after.element()).toBe(null)
+      })
     })
-  })
 
-  describe('time()', () => {
-    it('returns itself', () => {
-      var runner = new Runner()
-      expect(runner.time(0)).toBe(runner)
+    describe('delay()', () => {
+      it('calls animate with delay parameters', () => {
+        var runner = new Runner(1000)
+        spyOn(runner, 'animate')
+
+        runner.delay(500)
+        expect(runner.animate).toHaveBeenCalledWith(0, 500)
+      })
     })
 
-    it('acts as a getter with no parameter passed', () => {
-      var runner = new Runner()
-      expect(runner.time()).toBe(0)
+    describe('during()', () => {
+      it('returns itself', () => {
+        var runner = new Runner()
+        expect(runner.during(runFn)).toBe(runner)
+      })
+
+      it('calls queue passing only a function to call on every step', () => {
+        var runner = new Runner()
+        spyOn(runner, 'queue')
+        runner.during(runFn)
+
+        expect(runner.queue).toHaveBeenCalledWith(null, runFn)
+      })
     })
 
-    it('reschedules the runner to a new time', () => {
-      var runner = new Runner()
-      runner.time(10)
+    describe('after()', () => {
+      it('returns itself', () => {
+        var runner = new Runner()
+        expect(runner.after(runFn)).toBe(runner)
+      })
+
+      it('binds a function to the after event', () => {
+        var runner = new Runner()
+        spyOn(runner, 'on')
+        runner.after(runFn)
 
-      expect(runner.time()).toBe(10)
+        expect(runner.on).toHaveBeenCalledWith('finished', runFn)
+      })
     })
 
-    it('calls step to reschedule', () => {
-      var runner = new Runner()
-      spyOn(runner, 'step')
-      runner.time(10)
+    describe('finish()', () => {
+      it('returns itself', () => {
+        var runner = new Runner()
+        expect(runner.finish()).toBe(runner)
+      })
+
+      it('calls step with Infinity as argument', () => {
+        var runner = new Runner()
+        spyOn(runner, 'step')
+        runner.finish()
 
-      expect(runner.step).toHaveBeenCalledWith(10)
+        expect(runner.step).toHaveBeenCalledWith(Infinity)
+      })
     })
-  })
 
-  describe('loops()', () => {
-    it('get the loops of a runner', () => {
-      var spy = createSpy('stepper')
-      var runner = new Runner(1000).queue(null, spy)
+    describe('reverse()', () => {
+      it('returns itself', () => {
+        var runner = new Runner()
+        expect(runner.reverse()).toBe(runner)
+      })
 
-      runner.step(300)
-      expect(spy).toHaveBeenCalledWith(0.3)
+      it('reverses the runner', () => {
+        var spy = createSpy('stepper')
+        var runner = new Runner(1000).reverse().queue(null, spy)
+        runner.step(750)
+        expect(spy).toHaveBeenCalledWith(0.25)
+      })
+
+      it('reverses the runner when true is passed', () => {
+        var spy = createSpy('stepper')
+        var runner = new Runner(1000).reverse(true).queue(null, spy)
+        runner.step(750)
+        expect(spy).toHaveBeenCalledWith(0.25)
+      })
 
-      expect(runner.loops()).toBe(0.3)
+      it('unreverses the runner when true is passed', () => {
+        var spy = createSpy('stepper')
+        var runner = new Runner(1000).reverse(false).queue(null, spy)
+        runner.step(750)
+        expect(spy).toHaveBeenCalledWith(0.75)
+      })
     })
-    it('sets the loops of the runner', () => {
-      var spy = createSpy('stepper')
-      var runner = new Runner(1000).queue(null, spy)
 
-      expect(runner.loops(0.5).loops()).toBe(0.5)
-      expect(spy).toHaveBeenCalledWith(0.5)
+    describe('ease()', () => {
+      it('returns itself', () => {
+        var runner = new Runner()
+        expect(runner.ease(() => {})).toBe(runner)
+      })
 
-      expect(runner.loops(0.1).loops()).toBe(0.1)
-      expect(spy).toHaveBeenCalledWith(0.1)
+      it('creates an easing Controller from the easing function', () => {
+        var runner = new Runner()
+        runner.ease(() => {})
 
-      expect(runner.loops(1.5).loops()).toBe(1)
-      expect(spy).toHaveBeenCalledWith(1)
+        expect(runner._stepper instanceof Ease).toBe(true)
+      })
     })
-    it('sets the loops of the runner in a loop', () => {
-      var spy = createSpy('stepper')
-      var runner = new Runner(1000).loop(5, true, 500).queue(null, spy)
 
-      expect(runner.loops(1.3).loops()).toBe(1.3)
-      expect(spy).toHaveBeenCalledWith(0.7)
+    describe('reset()', () => {
+      it('resets the runner by setting it to time 0', () => {
+        var runner = new Runner().step(16)
+        expect(runner.time()).toBe(16)
+        expect(runner.reset()).toBe(runner)
+        expect(runner.time()).toBe(0)
+      })
 
-      expect(runner.loops(0.3).loops()).toBe(0.3)
+      it('doesnt reset if already reseted', () => {
+        var runner = Object.freeze(new Runner().reset())
+        expect(runner.reset()).toBe(runner)
+      })
     })
-  })
 
-  describe('progress()', () => {
-    it('gets the progress of a runner', () => {
-      var spy = createSpy('stepper')
-      var runner = new Runner(1000).queue(null, spy)
+    describe('private Methods', () => {
+      describe('_rememberMorpher()', () => {
+        it('adds a morper for a method to the runner', () => {
+          const runner = new Runner()
+          const morpher = new Morphable()
+          runner._rememberMorpher('move', morpher)
+          expect(runner._history.move).toEqual({ morpher, caller: undefined })
+        })
 
-      runner.step(300)
-      expect(spy).toHaveBeenCalledWith(0.3)
+        it('resumes the timeline in case this runner uses a controller', () => {
+          const timeline = new Timeline()
+          const spy = spyOn(timeline, 'play')
+          const runner = new Runner(new Controller(() => 0)).timeline(timeline)
+          const morpher = new Morphable()
+          runner._rememberMorpher('move', morpher)
+          expect(spy).toHaveBeenCalled()
+        })
+      })
 
-      expect(runner.progress()).toBe(0.3)
-    })
+      describe('_tryRetarget()', () => {
+        it('tries to retarget a morpher for the animation and returns true', () => {
+          const rect = new Rect().move(0, 0)
+          const runner = rect.animate().move(10, 10)
+          jasmine.RequestAnimationFrame.tick(16)
+          expect(runner._tryRetarget('x', 20)).toBe(true)
+          expect(runner._history.x.morpher.to()).toEqual([ 20, '' ])
+        })
 
-    it('gets the progress of a runner when looping', () => {
-      var spy = createSpy('stepper')
-      var runner = new Runner(800).queue(null, spy).loop(10, false, 200) // duration should be 9800
+        it('throws away the morpher if it wasnt initialized yet and returns false', () => {
+          const rect = new Rect().move(0, 0)
+          const runner = rect.animate().move(10, 10)
+          // In that case tryRetarget is not successfull
+          expect(runner._tryRetarget('x', 20)).toBe(false)
+        })
 
-      // middle of animation, in the middle of wait time
-      runner.step(4900)
-      expect(runner.progress()).toBe(0.5)
-      expect(spy).toHaveBeenCalledWith(1)
+        it('does nothing if method wasnt found', () => {
+          const rect = new Rect().move(0, 0)
+          const runner = rect.animate().move(10, 10)
+          jasmine.RequestAnimationFrame.tick(16)
+          // In that case tryRetarget is not successfull
+          expect(runner._tryRetarget('foo', 20)).toBe(false)
+        })
 
-      // start of next loop
-      runner.step(100)
-      expect(spy).toHaveBeenCalledWith(0)
+        it('does only work with controller for transformations and uses retarget function when retargeting transformations', () => {
+          const rect = new Rect()
+          const runner = rect.animate(new Controller(() => 0)).transform({ translate: [ 10, 10 ] })
+          jasmine.RequestAnimationFrame.tick(16)
+          // In that case tryRetarget is not successfull
+          expect(runner._tryRetarget('transform', { translate: [ 20, 20 ] })).toBe(true)
+        })
 
-      // move 400 into current loop which is 0.5 progress
-      // the progress value is 5400 / 9800
-      runner.step(400)
-      expect(spy).toHaveBeenCalledWith(0.5)
-      expect(runner.progress()).toBe(5400 / 9800)
-    })
+        it('starts the timeline if retarget was successfull', () => {
+          const timeline = new Timeline()
+          const rect = new Rect().move(0, 0).timeline(timeline)
+          const runner = rect.animate().move(10, 10)
+          jasmine.RequestAnimationFrame.tick(16)
+          const spy = spyOn(timeline, 'play')
+          expect(runner._tryRetarget('x', 20)).toBe(true)
+          expect(runner._history.x.morpher.to()).toEqual([ 20, '' ])
+          expect(spy).toHaveBeenCalledTimes(1)
+        })
+      })
+
+      describe('_initialise', () => {
+        it('does nothing if false is passed', () => {
+          const runner = Object.freeze(new Runner())
+          expect(runner._initialise(false)).toBe(undefined)
+        })
 
-    it('sets the progress of a runner', () => {
-      var spy = createSpy('stepper')
-      var runner = new Runner(1000).queue(null, spy)
+        it('does nothing if true is passed and runner is not declaritive', () => {
+          const runner = Object.freeze(new Runner())
+          expect(runner._initialise(true)).toBe(undefined)
+        })
 
-      expect(runner.progress(0.5).progress()).toBe(0.5)
-      expect(spy).toHaveBeenCalledWith(0.5)
-    })
+        it('calls the initializer function on the queue when runner is declaritive', () => {
+          const runner = new Runner(() => 0).queue(initFn, runFn)
+          runner._initialise()
+          expect(initFn).toHaveBeenCalledTimes(1)
+        })
+
+        it('calls the initializer function on the queue when true is passed and runner is not declaritive', () => {
+          const runner = new Runner().queue(initFn, runFn)
+          runner._initialise(true)
+          expect(initFn).toHaveBeenCalledTimes(1)
+        })
+
+        it('does nothing if function is already initialized', () => {
+          const runner = new Runner().queue(initFn, runFn)
+          runner._initialise(true)
+          runner._initialise(true)
+          expect(initFn).toHaveBeenCalledTimes(1)
+        })
+      })
 
-    it('sets the progress of a runner when looping', () => {
-      var spy = createSpy('stepper')
-      var runner = new Runner(800).queue(null, spy).loop(10, false, 200)
+      describe('_run()', () => {
+        it('runs each queued function for the position or dt given', () => {
+          const runner = new Runner().queue(initFn, runFn)
+          runner._run(16)
+          expect(runFn).toHaveBeenCalledWith(16)
+        })
 
-      // progress 0.5 somewhere in the middle of wait time
-      expect(runner.progress(0.5).progress()).toBe(0.5)
-      expect(spy).toHaveBeenCalledWith(1)
+        it('returns true if all runners converged', () => {
+          const spy = createSpy().and.returnValue(true)
+          const runner = new Runner().queue(initFn, spy)
+          expect(runner._run(16)).toBe(true)
+        })
+
+        it('returns true if all runners finished', () => {
+          const spy = createSpy().and.returnValue(true)
+          const runner = new Runner(100).queue(initFn, spy)
+          runner._run(200)
+          expect(runner._run(1)).toBe(true)
+        })
+      })
+
+      describe('addTransform()', () => {
+        it('adds a transformation by multiplying', () => {
+          const runner = new Runner()
+          runner.addTransform({ translate: [ 10, 10 ] })
+          expect(runner.transforms).toEqual(new Matrix(1, 0, 0, 1, 10, 10))
+        })
+      })
 
-      // start of next loop
-      runner.step(100)
-      expect(spy).toHaveBeenCalledWith(0)
+      describe('clearTransform()', () => {
+        it('resets the transformations to identity', () => {
+          const runner = new Runner()
+          runner.addTransform({ translate: [ 10, 10 ] })
+          runner.clearTransform()
+          expect(runner.transforms).toEqual(new Matrix())
+        })
+      })
 
-      // should move 0.5 into the next loop
-      expect(runner.progress(5400 / 9800).progress()).toBe(5400 / 9800)
-      expect(spy.calls.mostRecent().args[0]).toBeCloseTo(0.5)
+      describe('clearTransformsFromQueue', () => {
+        it('deletes all functions from the queue which are transformations', () => {
+          const runner = new Runner().queue(initFn, runFn)
+          runner.transform({ translate: [ 10, 20 ] })
+          runner.clearTransformsFromQueue()
+          expect(runner._queue.length).toBe(1)
+        })
+      })
     })
-  })
 
-  describe('position()', () => {
+    describe('Element', () => {
+      describe('animate()', () => {
+        it('creates a runner with the element set and schedules it on the timeline', () => {
+          var element = SVG('<rect />')
+          var runner = element.animate()
+          expect(runner instanceof Runner)
+          expect(runner.element()).toBe(element)
+          expect(runner.timeline()).toBe(element.timeline())
+          expect(element.timeline().getLastRunnerInfo().runner).toBe(runner)
+        })
+      })
 
-    it('gets the position of a runner', () => {
-      var spy = createSpy('stepper')
-      var runner = new Runner(1000).queue(null, spy)
+      describe('delay()', () => {
+        it('calls animate with correct parameters', () => {
+          var element = SVG('<rect />')
 
-      runner.step(300)
-      expect(spy).toHaveBeenCalledWith(0.3)
+          spyOn(element, 'animate')
+          element.delay(100, 'now')
+          expect(element.animate).toHaveBeenCalledWith(0, 100, 'now')
+        })
+      })
 
-      expect(runner.position()).toBe(0.3)
-    })
+      describe('_clearTransformRunnersBefore()', () => {
+        it('calls clearBefore on the runner array', () => {
+          const rect = new Rect()
+          rect._prepareRunner()
+          const spy = spyOn(rect._transformationRunners, 'clearBefore')
+          rect._clearTransformRunnersBefore({ id: 1 })
+          expect(spy).toHaveBeenCalledWith(1)
+        })
+      })
 
-    it('gets the position of a runner when looping', () => {
-      var spy = createSpy('stepper')
-      var runner = new Runner(1000).loop(5, true, 100).queue(null, spy)
+      describe('_currentTransform()', () => {
+        it('calculates the current transformation of this element', () => {
+          const rect = new Rect()
+          rect._prepareRunner()
+          const runner1 = new Runner().addTransform({ translate: [ 10, 20 ] })
+          const runner2 = new Runner().addTransform({ rotate: 45 })
+          const runner3 = new Runner().addTransform({ translate: [ 10, 20 ] })
+
+          rect._addRunner(runner1)
+          rect._addRunner(runner2)
+          rect._addRunner(runner3)
+          expect(rect._currentTransform(runner3)).toEqual(
+            new Matrix({ translate: [ 10, 20 ] })
+              .rotate(45)
+              .translate(10, 20)
+          )
+        })
+      })
 
-      runner.step(1200)
-      expect(spy).toHaveBeenCalledWith(0.9)
+      describe('_addRunner()', () => {
+        it('adds a runenr to the runner array of this element', () => {
+          const rect = new Rect()
+          rect._prepareRunner()
+          const spy = spyOn(rect._transformationRunners, 'add')
+          const runner = new Runner()
+          rect._addRunner(runner)
+          expect(spy).toHaveBeenCalledWith(runner)
+        })
+      })
 
-      expect(runner.position()).toBe(0.9)
+      describe('_prepareRunner()', () => {
+        it('adds a runner array to the element', () => {
+          const rect = new Rect()
+          expect(rect._transformationRunners).toBe(undefined)
+          rect._prepareRunner()
+          expect(rect._transformationRunners).toEqual(any(RunnerArray))
+        })
+
+        it('only adds it if no animation is in progress', () => {
+          const rect = new Rect()
+          expect(rect._transformationRunners).toBe(undefined)
+          rect._prepareRunner()
+          const arr = rect._transformationRunners
+          rect._frameId = 1
+          rect._prepareRunner()
+          expect(rect._transformationRunners).toBe(arr)
+        })
+      })
     })
 
-    it('sets the position of a runner', () => {
-      var spy = createSpy('stepper')
-      var runner = new Runner(1000).queue(null, spy)
+    describe('methods', () => {
+      describe('attr()', () => {
+        it('relays to styleAttr with "attr" as parameter', () => {
+          const runner = new Runner()
+          const spy = spyOn(runner, 'styleAttr')
+          runner.attr(1, 2)
+          expect(spy).toHaveBeenCalledWith('attr', 1, 2)
+        })
+      })
+
+      describe('css()', () => {
+        it('relays to styleAttr with "css" as parameter', () => {
+          const runner = new Runner()
+          const spy = spyOn(runner, 'styleAttr')
+          runner.css(1, 2)
+          expect(spy).toHaveBeenCalledWith('css', 1, 2)
+        })
+      })
 
-      expect(runner.position(0.5).position()).toBe(0.5)
-      expect(spy).toHaveBeenCalledWith(0.5)
-    })
+      describe('styleAttr()', () => {
+        it('adds a morpher for attr', () => {
+          const runner = new Runner()
+          runner.styleAttr('attr', 'x', 5)
+          expect(runner._history.attr.morpher).toEqual(any(Morphable))
+          expect(runner._history.attr.morpher.to()).toEqual([ 'x', SVGNumber, 2, 5, '' ])
+        })
 
-    it('sets the position of a runner in a loop', () => {
-      var spy = createSpy('stepper')
-      var runner = new Runner(1000).loop(5, true, 100).queue(null, spy)
+        it('adds a morpher for css', () => {
+          const runner = new Runner()
+          runner.styleAttr('css', 'x', 5)
+          expect(runner._history.css.morpher).toEqual(any(Morphable))
+          expect(runner._history.css.morpher.to()).toEqual([ 'x', SVGNumber, 2, 5, '' ])
+        })
 
-      runner.step(1200)
-      expect(runner.position(0.4).position()).toBe(0.4)
-      expect(spy).toHaveBeenCalledWith(0.4)
+        it('adds init and run fn for execution when runner runs', () => {
+          const element = new Rect().move(0, 0)
+          const runner = new Runner(100).ease('-').element(element)
+          runner.styleAttr('attr', 'x', 5)
+          runner.step(50)
+          expect(runner._history.attr.morpher.from()).toEqual([ 'x', SVGNumber, 2, 0, '' ])
+          expect(runner._history.attr.morpher.to()).toEqual([ 'x', SVGNumber, 2, 5, '' ])
+          expect(element.x()).toBe(2.5)
+        })
 
-      expect(runner.position(0).position()).toBe(0)
-      expect(spy).toHaveBeenCalledWith(0)
+        it('it also works when the object contains other morphable values', () => {
+          const element = new Rect().fill('#fff').stroke('#000')
+          const runner = new Runner(100).ease('-').element(element)
+          runner.styleAttr('attr', { fill: '#000', stroke: new Color('#fff') })
+          runner.step(50)
+          expect(runner._history.attr.morpher.from()).toEqual(
+            [ 'fill', Color, 5, 255, 255, 255, 0, 'rgb', 'stroke', Color, 5, 0, 0, 0, 0, 'rgb' ]
+          )
+
+          expect(runner._history.attr.morpher.to()).toEqual(
+            [ 'fill', Color, 5, 0, 0, 0, 0, 'rgb', 'stroke', Color, 5, 255, 255, 255, 0, 'rgb' ]
+          )
+          const result = runner._history.attr.morpher.at(0.5).valueOf()
+          expect(result.fill).toEqual(any(Color))
+          expect(result.stroke).toEqual(any(Color))
+          expect(result.fill.toArray()).toEqual([ 127.5, 127.5, 127.5, 0, 'rgb' ])
+          expect(result.stroke.toArray()).toEqual([ 127.5, 127.5, 127.5, 0, 'rgb' ])
+        })
 
-      expect(runner.position(1).position()).toBe(1)
-      expect(spy).toHaveBeenCalledWith(1)
-    })
-  })
+        it('it changes color space', () => {
+          const element = new Rect().fill('#fff')
+          const runner = new Runner(100).ease('-').element(element)
+          runner.styleAttr('attr', { fill: new Color(100, 12, 12, 'hsl') })
+          runner.step(50)
+          expect(runner._history.attr.morpher.from()).toEqual(
+            [ 'fill', Color, 5, 0, 0, 100, 0, 'hsl' ]
+          )
+
+          expect(runner._history.attr.morpher.to()).toEqual(
+            [ 'fill', Color, 5, 100, 12, 12, 0, 'hsl' ]
+          )
+          const result = runner._history.attr.morpher.at(0.5).valueOf()
+          expect(result.fill).toEqual(any(Color))
+          expect(result.fill.toArray()).toEqual([ 50, 6, 56, 0, 'hsl' ])
+          expect(element.fill()).toBe('#969388')
+        })
 
-  describe('element()', () => {
-    it('returns the element bound to this runner if any', () => {
-      var runner1 = new Runner()
-      expect(runner1.element()).toBe(null)
+        it('retargets if called two times with new key', () => {
+          const element = new Rect().fill('#fff')
+          const runner = new Runner(100).ease('-').element(element)
+          runner.styleAttr('attr', { fill: new Color(100, 12, 12, 'hsl') })
+          runner.step(50)
+          expect(element.fill()).toBe('#969388')
+          runner.styleAttr('attr', { fill: new Color(100, 50, 50, 'hsl'), x: 50 })
+          runner.step(25)
+          expect(element.fill()).toBe('#b1c37c')
+          expect(element.x()).toBe(37.5)
+        })
 
-      var element = SVG('<rect />')
-      var runner2 = element.animate()
-      expect(runner2.element()).toBe(element)
-    })
+        it('retargets if called two times without new key', () => {
+          const element = new Rect().fill('#fff')
+          const runner = new Runner(100).ease('-').element(element)
+          runner.styleAttr('attr', { fill: new Color(100, 12, 12, 'hsl') })
+          runner.step(50)
+          expect(element.fill()).toBe('#969388')
+          runner.styleAttr('attr', { fill: new Color(100, 50, 50, 'hsl') })
+          runner.step(25)
+          expect(element.fill()).toBe('#b1c37c')
+        })
+      })
 
-    it('sets an element to be bound to the runner', () => {
-      var runner = new Runner()
-      var element = SVG('<rect />')
-      expect(runner.element(element)).toBe(runner)
-      expect(runner.element()).toBe(element)
-    })
-  })
+      function closeTo (number, precision = 2) {
+        return {
+          /*
+           * The asymmetricMatch function is required, and must return a boolean.
+           */
+          asymmetricMatch: function (compareTo) {
+            const factor = 10 ** precision
+            return Math.round((~~(compareTo * factor)) / factor) === Math.round((~~(number * factor)) / factor)
+          },
+
+          /*
+           * The jasmineToString method is used in the Jasmine pretty printer, and will
+           * be seen by the user in the message when a test fails.
+           */
+          jasmineToString: function () {
+            return '<close to: ' + number + ' with precision: ' + precision + '>'
+          }
+        }
+      }
+
+      function equal (obj) {
+        return {
+          /*
+           * The asymmetricMatch function is required, and must return a boolean.
+           */
+          asymmetricMatch: function (compareTo) {
+            if (!(compareTo instanceof obj.constructor)) return false
+
+            const keys = Object.keys(obj)
+            const difference = Object.keys(compareTo).filter((el) => !keys.includes(el))
+
+            if (difference.length) return false
+
+            for (const key in obj) {
+              if (obj[key] !== compareTo[key]) return false
+            }
+
+            return true
+          },
+
+          /*
+           * The jasmineToString method is used in the Jasmine pretty printer, and will
+           * be seen by the user in the message when a test fails.
+           */
+          jasmineToString: function () {
+            return '<equal to: ' + obj + '>'
+          }
+        }
+      }
+
+      describe('zoom()', () => {
+        it('adds a zoom morpher to the queue', () => {
+          const element = SVG().size(100, 100).viewbox(0, 0, 100, 100)
+          const runner = new Runner(100).ease('-').element(element)
+          runner.zoom(2, { x: 0, y: 0 })
+          runner.step(50)
+          expect(runner._history.zoom.morpher.from()).toEqual(
+            [ 1, '' ]
+          )
+          expect(runner._history.zoom.morpher.to()).toEqual(
+            [ 2, '' ]
+          )
+
+          expect(element.zoom()).toBeCloseTo(1.5, 10)
+          expect(element.viewbox().toArray()).toEqual([ 0, 0, closeTo(66.666, 3), closeTo(66.666, 3) ])
+        })
 
-  describe('timeline()', () => {
-    it('returns the timeline bound to this runner if any', () => {
-      var runner1 = new Runner()
-      expect(runner1.element()).toBe(null)
+        it('retargets if called twice', () => {
+          const element = SVG().size(100, 100).viewbox(0, 0, 100, 100)
+          const runner = new Runner(100).ease('-').element(element)
+          runner.zoom(2, { x: 0, y: 0 })
+          runner.step(50)
+          runner.zoom(4, { x: 0, y: 0 })
+          expect(runner._history.zoom.morpher.from()).toEqual(
+            [ 1, '' ]
+          )
+          expect(runner._history.zoom.morpher.to()).toEqual(
+            [ 4, '' ]
+          )
+
+          runner.step(25)
+          expect(element.zoom()).toBeCloseTo(3.25, 10)
+          expect(element.viewbox().toArray()).toEqual([ 0, 0, closeTo(30.769, 3), closeTo(30.769, 3) ])
+        })
+      })
 
-      var element = SVG('<rect />')
-      var runner2 = element.animate()
-      expect(runner2.timeline()).toBe(element.timeline())
-    })
+      describe('transform()', () => {
+        it('does not retarget for non-declaritive transformations', () => {
+          const runner = new Runner()
+          const spy = spyOn(runner, '_tryRetarget')
+          runner.transform({ translate: [ 10, 20 ] })
+          expect(spy).not.toHaveBeenCalled()
+        })
 
-    it('sets a timeline to be bound to the runner', () => {
-      var runner = new Runner()
-      var timeline = new Timeline()
-      expect(runner.timeline(timeline)).toBe(runner)
-      expect(runner.timeline()).toBe(timeline)
-    })
-  })
+        it('does not retarget for relative transformations', () => {
+          const runner = new Runner(new Controller(() => 0))
+          const spy = spyOn(runner, '_tryRetarget')
+          runner.transform({ translate: [ 10, 20 ] }, true)
+          expect(spy).not.toHaveBeenCalled()
+        })
 
-  describe('schedule()', () => {
-    it('schedules the runner on a timeline', () => {
-      var runner = new Runner()
-      var timeline = new Timeline()
-      var spy = spyOn(timeline, 'schedule').and.callThrough()
+        it('does retarget for absolute declaritive transformations', () => {
+          const runner = new Runner(new Controller(() => 0))
+          const spy = spyOn(runner, '_tryRetarget')
+          runner.transform({ translate: [ 10, 20 ] })
+          expect(spy).toHaveBeenCalled()
+        })
 
-      expect(runner.schedule(timeline, 200, 'now')).toBe(runner)
-      expect(runner.timeline()).toBe(timeline)
-      expect(spy).toHaveBeenCalledWith(runner, 200, 'now')
-    })
+        it('calls queue with isTransform=true', () => {
+          const runner = new Runner()
+          const spy = spyOn(runner, 'queue')
+          runner.transform({ translate: [ 10, 20 ] })
+          expect(spy).toHaveBeenCalledWith(any(Function), any(Function), any(Function), true)
+        })
 
-    it('schedules the runner on its own timeline', () => {
-      var runner = new Runner()
-      var timeline = new Timeline()
-      var spy = spyOn(timeline, 'schedule')
-      runner.timeline(timeline)
+        it('steps an affine transformation correctly', () => {
+          const element = new Rect()
+          const runner = new Runner(100).ease('-').element(element)
+          runner.transform({ translate: [ 10, 20 ], scale: 2, rotate: 90 })
+          runner.step(50)
+          // transform sets an immediate callback to apply all merged transforms
+          // when every runner had the chance to add its bit of tranforms
+          jasmine.RequestAnimationFrame.tick(1)
+          expect(element.matrix().decompose()).toEqual(objectContaining({
+            translateX: 5,
+            translateY: 10,
+            scaleX: closeTo(1.5, 10),
+            scaleY: closeTo(1.5),
+            rotate: closeTo(45, 10)
+          }))
+        })
 
-      expect(runner.schedule(200, 'now')).toBe(runner)
-      expect(runner.timeline()).toBe(timeline)
-      expect(spy).toHaveBeenCalledWith(runner, 200, 'now')
-    })
-  })
+        it('retargets an affine transformation correctly', () => {
+          const element = new Rect()
+          const runner = new Runner(() => 1).element(element)
+          runner.transform({ translate: [ 10, 20 ], scale: 2, rotate: 90 })
+          runner.step(50)
+          runner.transform({ scale: 2 })
+
+          // transform sets its to-target to the morpher in the initialisation step
+          // because it depends on the from-target. Declaritive animation run the init-step
+          // on every frame. Thats why we step here to see the effect of our retargeting
+          runner.step(25)
+
+          expect(runner._history.transform.morpher.to()).toEqual(
+            [ 2, 2, 0, 0, 0, 0, 0, 0 ]
+          )
+        })
 
-  describe('unschedule()', () => {
-    it('unschedules this runner from its timeline', () => {
-      var runner = new Runner()
-      var timeline = new Timeline()
-      var spy = spyOn(timeline, 'unschedule').and.callThrough()
+        it('retargets an affine transformation correctly and sets new origin', () => {
+          const element = new Rect()
+          const runner = new Runner(() => 1).element(element)
+          runner.transform({ translate: [ 10, 20 ], scale: 2, rotate: 90 })
+          runner.step(50)
+          runner.transform({ scale: 2, origin: [ 10, 10 ] })
+
+          // transform sets its to-target to the morpher in the initialisation step
+          // because it depends on the from-target. Declaritive animation run the init-step
+          // on every frame. Thats why we step here to see the effect of our retargeting
+          runner.step(25)
+
+          expect(runner._history.transform.morpher.to()).toEqual(
+            [ 2, 2, 0, 0, 0, 0, 10, 10 ]
+          )
+        })
 
-      expect(runner.schedule(timeline, 200, 'now')).toBe(runner)
-      expect(runner.unschedule()).toBe(runner)
-      expect(spy).toHaveBeenCalledWith(runner)
-      expect(runner.timeline()).toBe(null)
-    })
-  })
+        it('steps multiple relative animations correctly', () => {
+          const element = new Rect()
+          const runner = new Runner(100).ease('-').element(element)
+          runner.translate(10, 20).scale(2).rotate(45)
+          runner.step(50)
+          // transform sets an immediate callback to apply all merged transforms
+          // when every runner had the chance to add its bit of tranforms
+          jasmine.RequestAnimationFrame.tick(1)
+
+          // The origin is transformed with every
+          expect(element.matrix()).toEqual(
+            new Matrix().translate(5, 10).scale(1.5, 5, 10).rotate(22.5, 5, 10)
+          )
+        })
+
+        it('steps multiple relative animations correctly from multiple runners', () => {
+          const element = new Rect()
+          const runner1 = new Runner(100).ease('-').element(element)
+          const runner2 = new Runner(100).ease('-').element(element)
+          const runner3 = new Runner(100).ease('-').element(element)
+          runner1.translate(10, 20)
+          runner2.scale(2)
+          runner3.rotate(45)
+          runner1.step(50)
+          runner2.step(50)
+          runner3.step(50)
+          // transform sets an immediate callback to apply all merged transforms
+          // when every runner had the chance to add its bit of tranforms
+          jasmine.RequestAnimationFrame.tick(1)
+
+          // The origin is transformed with every
+          expect(element.matrix()).toEqual(
+            new Matrix().translate(5, 10).scale(1.5, 5, 10).rotate(22.5, 5, 10)
+          )
+        })
+
+        it('absolute transformations correctly overwrite relatives', () => {
+          const element = new Rect()
+          const runner1 = new Runner(100).ease('-').element(element)
+          const runner2 = new Runner(100).ease('-').element(element)
+          const runner3 = new Runner(100).ease('-').element(element)
+          runner1.translate(10, 20)
+          runner2.transform({ scale: 2 })
+          runner3.rotate(45)
+          runner1.step(50)
+          runner2.step(50)
+          runner3.step(50)
+          // transform sets an immediate callback to apply all merged transforms
+          // when every runner had the chance to add its bit of tranforms
+          jasmine.RequestAnimationFrame.tick(1)
+
+          expect(runner1._queue.length).toBe(0)
+
+          // The origin is transformed with every
+          expect(element.matrix()).toEqual(
+            new Matrix().scale(1.5).rotate(22.5)
+          )
+        })
+
+        it('correctly animates matrices directly', () => {
+          const element = new Rect()
+          const runner = new Runner(100).ease('-').element(element)
+          runner.transform(new Matrix({ rotate: 90 }))
+          runner.step(50)
+          // transform sets an immediate callback to apply all merged transforms
+          // when every runner had the chance to add its bit of tranforms
+          jasmine.RequestAnimationFrame.tick(1)
+
+          // The origin is transformed with every
+          expect(element.matrix()).toEqual(
+            new Matrix(0.5, 0.5, -0.5, 0.5, 0, 0)
+          )
+        })
+
+        it('correctly animates matrices affine', () => {
+          const element = new Rect()
+          const runner = new Runner(100).ease('-').element(element)
+          runner.transform(Object.assign({ affine: true }, new Matrix({ rotate: 90 })))
+          runner.step(50)
+          // transform sets an immediate callback to apply all merged transforms
+          // when every runner had the chance to add its bit of tranforms
+          jasmine.RequestAnimationFrame.tick(1)
+
+          // The origin is transformed with every
+          expect(element.matrix()).toEqual(
+            new Matrix({ rotate: 45 })
+          )
+        })
 
-  describe('animate()', () => {
-    it('creates a new runner scheduled after the first', () => {
-      var runner = new Runner(1000)
-      var timeline = new Timeline()
+        it('correctly animates matrices affine by passing third parameter', () => {
+          const element = new Rect()
+          const runner = new Runner(100).ease('-').element(element)
+          runner.transform(new Matrix({ rotate: 90 }), true, true)
+          runner.step(50)
+          // transform sets an immediate callback to apply all merged transforms
+          // when every runner had the chance to add its bit of tranforms
+          jasmine.RequestAnimationFrame.tick(1)
+
+          // The origin is transformed with every
+          expect(element.matrix()).toEqual(
+            new Matrix({ rotate: 45 })
+          )
+        })
 
-      runner.schedule(timeline)
+        it('correctly animates a declaritive relative rotation', () => {
+          const element = new Rect()
+          const runner = new Runner(() => 1).element(element)
+          runner.transform({ rotate: 90 }, true)
+          runner.step(16)
+          jasmine.RequestAnimationFrame.tick(1)
+          runner.step(16)
+          jasmine.RequestAnimationFrame.tick(1)
+          expect(element.matrix()).not.toEqual(new Matrix())
+        })
+      })
 
-      var runner2 = runner.animate(500, 1000)
+      describe('x()', () => {
+        it('queues a numer', () => {
+          const runner = new Runner()
+          const spy = spyOn(runner, '_queueNumber')
+          runner.x(10)
+          expect(spy).toHaveBeenCalledWith('x', 10)
+        })
+      })
 
-      var t = timeline.time()
+      describe('y()', () => {
+        it('queues a numer', () => {
+          const runner = new Runner()
+          const spy = spyOn(runner, '_queueNumber')
+          runner.y(10)
+          expect(spy).toHaveBeenCalledWith('y', 10)
+        })
+      })
 
-      expect(runner2.timeline()).toBe(timeline)
-      expect(runner2.time()).toBe(0)
+      describe('dx()', () => {
+        it('queues a numer', () => {
+          const runner = new Runner()
+          const spy = spyOn(runner, '_queueNumberDelta')
+          runner.dx(10)
+          expect(spy).toHaveBeenCalledWith('x', 10)
+        })
 
-      expect(timeline.schedule()).toEqual(arrayContaining([
-        objectContaining({ start: t, duration: 1000, end: t + 1000, runner: runner }),
-        objectContaining({ start: t + 2000, duration: 500, end: t + 2500, runner: runner2 })
-      ]))
+        it('uses a delta of 0 by default', () => {
+          const runner = new Runner()
+          const spy = spyOn(runner, '_queueNumberDelta')
+          runner.dx()
+          expect(spy).toHaveBeenCalledWith('x', 0)
+        })
+      })
+
+      describe('dy()', () => {
+        it('queues a number', () => {
+          const runner = new Runner()
+          const spy = spyOn(runner, '_queueNumberDelta')
+          runner.dy(10)
+          expect(spy).toHaveBeenCalledWith('y', 10)
+        })
+
+        it('uses a delta of 0 by default', () => {
+          const runner = new Runner()
+          const spy = spyOn(runner, '_queueNumberDelta')
+          runner.dy()
+          expect(spy).toHaveBeenCalledWith('y', 0)
+        })
+      })
+
+      describe('dmove()', () => {
+        it('calls dx and dy', () => {
+          const runner = new Runner()
+          const spy1 = spyOn(runner, 'dx').and.returnValue(runner)
+          const spy2 = spyOn(runner, 'dy').and.returnValue(runner)
+          runner.dmove(10, 20)
+          expect(spy1).toHaveBeenCalledWith(10)
+          expect(spy2).toHaveBeenCalledWith(20)
+        })
+      })
+
+      describe('_queueNumberDelta', () => {
+        it('queues a morpher of type SVGNumber', () => {
+          const element = new Rect().x(10)
+          const runner = new Runner(100).ease('-').element(element)
+          runner._queueNumberDelta('x', 10)
+          runner.step(50)
+          expect(runner._history.x.morpher.type()).toEqual(SVGNumber)
+          expect(runner._history.x.morpher.from()).toEqual([ 10, '' ])
+          expect(runner._history.x.morpher.to()).toEqual([ 20, '' ])
+
+          expect(element.x()).toBe(15)
+        })
+
+        it('retargets corectly', () => {
+          const element = new Rect().x(10)
+          const runner = new Runner(100).ease('-').element(element)
+          runner._queueNumberDelta('x', 10)
+          runner.step(25)
+          runner._queueNumberDelta('x', 20)
+
+          expect(runner._history.x.morpher.to()).toEqual([ 30, '' ])
+
+          runner.step(25)
+          expect(element.x()).toBe(20)
+        })
+      })
+
+      describe('_queueObject', () => {
+        it('queues a morphable object', () => {
+          const element = new Rect().x(10)
+          const runner = new Runner(100).ease('-').element(element)
+          runner._queueObject('x', new SVGNumber(20))
+          runner.step(50)
+          expect(runner._history.x.morpher.type()).toEqual(SVGNumber)
+          expect(runner._history.x.morpher.from()).toEqual([ 10, '' ])
+          expect(runner._history.x.morpher.to()).toEqual([ 20, '' ])
+
+          expect(element.x()).toBe(15)
+        })
+
+        it('queues a morphable primitive', () => {
+          const element = new Rect().fill('#000')
+          const runner = new Runner(100).ease('-').element(element)
+          runner._queueObject('fill', '#fff')
+          runner.step(50)
+          expect(runner._history.fill.morpher.type()).toEqual(Color)
+
+          expect(element.fill()).toBe('#808080')
+        })
+
+        it('retargets corectly', () => {
+          const element = new Rect().x(10)
+          const runner = new Runner(100).ease('-').element(element)
+          runner._queueObject('x', 20)
+
+          runner.step(25)
+
+          runner._queueObject('x', 30)
+          runner.step(25)
+          expect(element.x()).toBe(20)
+        })
+      })
+
+      describe('_queueNumber', () => {
+        it('queues an SVGNumber with _queueObject', () => {
+          const runner = new Runner()
+          const spy = spyOn(runner, '_queueObject')
+          runner._queueNumber('x', 10)
+          expect(spy).toHaveBeenCalledWith('x', equal(new SVGNumber(10)))
+        })
+      })
+
+      describe('cy()', () => {
+        it('queues a numer', () => {
+          const runner = new Runner()
+          const spy = spyOn(runner, '_queueNumber')
+          runner.cy(10)
+          expect(spy).toHaveBeenCalledWith('cy', 10)
+        })
+      })
+
+      describe('cx()', () => {
+        it('queues a numer', () => {
+          const runner = new Runner()
+          const spy = spyOn(runner, '_queueNumber')
+          runner.cx(10)
+          expect(spy).toHaveBeenCalledWith('cx', 10)
+        })
+      })
+
+      describe('move()', () => {
+        it('calls x and y', () => {
+          const runner = new Runner()
+          const spy1 = spyOn(runner, 'x').and.returnValue(runner)
+          const spy2 = spyOn(runner, 'y').and.returnValue(runner)
+          runner.move(10, 20)
+          expect(spy1).toHaveBeenCalledWith(10)
+          expect(spy2).toHaveBeenCalledWith(20)
+        })
+      })
+
+      describe('center()', () => {
+        it('calls cx and cy', () => {
+          const runner = new Runner()
+          const spy1 = spyOn(runner, 'cx').and.returnValue(runner)
+          const spy2 = spyOn(runner, 'cy').and.returnValue(runner)
+          runner.center(10, 20)
+          expect(spy1).toHaveBeenCalledWith(10)
+          expect(spy2).toHaveBeenCalledWith(20)
+        })
+      })
+
+      describe('size()', () => {
+        it('calls width and height', () => {
+          const runner = new Runner()
+          const spy1 = spyOn(runner, 'width').and.returnValue(runner)
+          const spy2 = spyOn(runner, 'height').and.returnValue(runner)
+          runner.size(10, 20)
+          expect(spy1).toHaveBeenCalledWith(10)
+          expect(spy2).toHaveBeenCalledWith(20)
+        })
+
+        it('figures out height if only width given', () => {
+          const element = new Rect().size(10, 10)
+          const runner = new Runner().element(element)
+          const spy1 = spyOn(runner, 'width').and.returnValue(runner)
+          const spy2 = spyOn(runner, 'height').and.returnValue(runner)
+          runner.size(20)
+          expect(spy1).toHaveBeenCalledWith(20)
+          expect(spy2).toHaveBeenCalledWith(20)
+        })
+
+        it('figures out width if only height given', () => {
+          const element = new Rect().size(10, 10)
+          const runner = new Runner().element(element)
+          const spy1 = spyOn(runner, 'width').and.returnValue(runner)
+          const spy2 = spyOn(runner, 'height').and.returnValue(runner)
+          runner.size(null, 20)
+          expect(spy1).toHaveBeenCalledWith(20)
+          expect(spy2).toHaveBeenCalledWith(20)
+        })
+      })
+
+      describe('width()', () => {
+        it('queues a numer', () => {
+          const runner = new Runner()
+          const spy = spyOn(runner, '_queueNumber')
+          runner.width(10)
+          expect(spy).toHaveBeenCalledWith('width', 10)
+        })
+      })
+
+      describe('height()', () => {
+        it('queues a numer', () => {
+          const runner = new Runner()
+          const spy = spyOn(runner, '_queueNumber')
+          runner.height(10)
+          expect(spy).toHaveBeenCalledWith('height', 10)
+        })
+      })
+
+      describe('plot()', () => {
+        it('queues a morphable array', () => {
+          const element = new Polygon().plot([ 10, 10, 20, 20 ])
+          const runner = new Runner(100).ease('-').element(element)
+          runner.plot(20, 20, 30, 30)
+          runner.step(50)
+          expect(runner._history.plot.morpher.from()).toEqual([ 10, 10, 20, 20 ])
+          expect(runner._history.plot.morpher.to()).toEqual([ 20, 20, 30, 30 ])
+          expect(element.array()).toEqual(new PointArray([ 15, 15, 25, 25 ]))
+        })
+
+        it('retargets correctly', () => {
+          const element = new Polygon().plot([ 10, 10, 20, 20 ])
+          const runner = new Runner(100).ease('-').element(element)
+          runner.plot(20, 20, 30, 30)
+          runner.step(25)
+          runner.plot(30, 30, 40, 40)
+          runner.step(25)
+          expect(runner._history.plot.morpher.from()).toEqual([ 10, 10, 20, 20 ])
+          expect(runner._history.plot.morpher.to()).toEqual([ 30, 30, 40, 40 ])
+          expect(element.array()).toEqual(new PointArray([ 20, 20, 30, 30 ]))
+        })
+      })
+
+      describe('leading()', () => {
+        it('queues a numer', () => {
+          const runner = new Runner()
+          const spy = spyOn(runner, '_queueNumber')
+          runner.leading(10)
+          expect(spy).toHaveBeenCalledWith('leading', 10)
+        })
+      })
+
+      describe('viewbox()', () => {
+        it('queues a numer', () => {
+          const runner = new Runner()
+          const spy = spyOn(runner, '_queueObject')
+          runner.viewbox(10, 10, 100, 100)
+          expect(spy).toHaveBeenCalledWith('viewbox', equal(new Box(10, 10, 100, 100)))
+        })
+      })
+
+      describe('update()', () => {
+        it('relays to attr call', () => {
+          const runner = new Runner()
+          const spy = spyOn(runner, 'attr')
+          runner.update(0.5, '#fff', 1)
+          expect(spy).toHaveBeenCalledWith('offset', 0.5)
+          expect(spy).toHaveBeenCalledWith('stop-color', '#fff')
+          expect(spy).toHaveBeenCalledWith('stop-opacity', 1)
+        })
+      })
     })
   })
 
-  describe('delay()', () => {
-    it('calls animate with delay parameters', () => {
-      var runner = new Runner(1000)
-      spyOn(runner, 'animate')
+  describe('FakeRunner', () => {
+    describe('()', () => {
+      it('creates a new FakeRunner with a new matrix which is always done', () => {
+        const runner = new FakeRunner()
+        expect(runner.transforms).toEqual(new Matrix())
+        expect(runner.id).toBe(-1)
+        expect(runner.done).toBe(true)
+      })
+    })
 
-      runner.delay(500)
-      expect(runner.animate).toHaveBeenCalledWith(0, 500)
+    describe('mergeWith()', () => {
+      it('merges the transformations of a runner with another and returns a FakeRunner', () => {
+        const fake = new FakeRunner()
+        const runner = new Runner().addTransform({ translate: [ 10, 20 ] })
+        const newRunner = fake.mergeWith(runner)
+        expect(newRunner).toEqual(any(FakeRunner))
+        expect(newRunner.transforms).toEqual(new Matrix({ translate: [ 10, 20 ] }))
+      })
     })
   })
 
-  describe('during()', () => {
-    it('returns itself', () => {
-      var runner = new Runner()
-      expect(runner.during(runFn)).toBe(runner)
+  describe('RunnerArray', () => {
+    describe('add()', () => {
+      it('adds a runner to the runner array', () => {
+        const runner = new Runner()
+        const arr = new RunnerArray()
+        arr.add(runner)
+        expect(arr.length()).toBe(1)
+      })
+
+      it('does not add the same runner twice', () => {
+        const runner = new Runner()
+        const arr = new RunnerArray()
+        arr.add(runner)
+        arr.add(runner)
+        expect(arr.length()).toBe(1)
+      })
     })
 
-    it('calls queue passing only a function to call on every step', () => {
-      var runner = new Runner()
-      spyOn(runner, 'queue')
-      runner.during(runFn)
+    describe('getByID()', () => {
+      it('returns a runner by its id', () => {
+        const runner = new Runner()
+        const arr = new RunnerArray()
+        arr.add(runner)
+        expect(arr.getByID(runner.id)).toBe(runner)
+      })
+    })
 
-      expect(runner.queue).toHaveBeenCalledWith(null, runFn)
+    describe('remove()', () => {
+      it('removes a runner by its id', () => {
+        const runner = new Runner()
+        const arr = new RunnerArray()
+        arr.add(runner)
+        arr.remove(runner.id)
+        expect(arr.length()).toBe(0)
+      })
     })
-  })
 
-  // describe('after()',  () => {
-  //   it('returns itself',  () => {
-  //     var runner = new Runner()
-  //     expect(runner.after(runFn)).toBe(runner)
-  //   })
-  //
-  //   it('binds a function to the after event',  () => {
-  //     var runner = new Runner()
-  //     spyOn(runner, 'on')
-  //     runner.after(runFn)
-  //
-  //     expect(runner.on).toHaveBeenCalledWith('finish', runFn)
-  //   })
-  // })
-  //
-  // describe('finish()',  () => {
-  //   it('returns itself',  () => {
-  //     var runner = new Runner()
-  //     expect(runner.finish()).toBe(runner)
-  //   })
-  //
-  //   it('calls step with Infinity as argument',  () => {
-  //     var runner = new Runner()
-  //     spyOn(runner, 'step')
-  //     runner.finish()
-  //
-  //     expect(runner.step).toHaveBeenCalledWith(Infinity)
-  //   })
-  // })
-
-  describe('reverse()', () => {
-    it('returns itself', () => {
-      var runner = new Runner()
-      expect(runner.reverse()).toBe(runner)
-    })
-
-    it('reverses the runner', () => {
-      var spy = createSpy('stepper')
-      var runner = new Runner(1000).reverse().queue(null, spy)
-      runner.step(750)
-      expect(spy).toHaveBeenCalledWith(0.25)
+    describe('merge()', () => {
+      it('merges all runners which are done', () => {
+        const runner1 = new Runner().addTransform({ translate: [ 10, 20 ] })
+        const runner2 = new Runner().addTransform({ rotate: 45 })
+        const runner3 = new Runner().addTransform({ translate: [ 10, 20 ] })
+        const arr = new RunnerArray()
+        arr.add(runner1).add(runner2).add(runner3)
+        runner1.done = true
+        runner2.done = true
+        runner3.done = true
+        arr.merge()
+        expect(arr.runners[0]).toEqual(any(FakeRunner))
+        expect(arr.runners[0].transforms).toEqual(
+          new Matrix({ translate: [ 10, 20 ] })
+            .rotate(45)
+            .translate(10, 20)
+        )
+      })
+
+      it('skips runners which are not done', () => {
+        const runner1 = new Runner().addTransform({ translate: [ 10, 20 ] })
+        const runner2 = new Runner().addTransform({ rotate: 45 })
+        const runner3 = new Runner().addTransform({ rotate: 45 })
+        const runner4 = new Runner().addTransform({ translate: [ 10, 20 ] })
+        const runner5 = new Runner().addTransform({ rotate: 45 })
+        const arr = new RunnerArray()
+        arr.add(runner1).add(runner2).add(runner3).add(runner4).add(runner5)
+        runner1.done = true
+        runner2.done = true
+        runner3.done = false
+        runner4.done = true
+        runner5.done = true
+        arr.merge()
+        expect(arr.runners[0]).toEqual(any(FakeRunner))
+        expect(arr.runners[0].transforms).toEqual(
+          new Matrix({ translate: [ 10, 20 ] })
+            .rotate(45)
+        )
+
+        expect(arr.runners[2]).toEqual(any(FakeRunner))
+        expect(arr.runners[2].transforms).toEqual(
+          new Matrix({ translate: [ 10, 20 ] })
+            .rotate(45)
+        )
+
+        expect(arr.runners[1]).toBe(runner3)
+      })
+
+      it('skips runners which have a timeline and are scheduled on that timeline', () => {
+        const runner1 = new Runner().addTransform({ translate: [ 10, 20 ] })
+        const runner2 = new Runner().addTransform({ rotate: 45 })
+        const runner3 = new Runner().addTransform({ rotate: 45 })
+        const runner4 = new Runner().addTransform({ translate: [ 10, 20 ] })
+        const runner5 = new Runner().addTransform({ rotate: 45 })
+        const arr = new RunnerArray()
+        arr.add(runner1).add(runner2).add(runner3).add(runner4).add(runner5)
+        runner1.done = true
+        runner2.done = true
+        runner3.done = true
+        runner4.done = true
+        runner5.done = true
+
+        runner3.schedule(new Timeline())
+        arr.merge()
+        expect(arr.runners[0]).toEqual(any(FakeRunner))
+        expect(arr.runners[0].transforms).toEqual(
+          new Matrix({ translate: [ 10, 20 ] })
+            .rotate(45)
+        )
+
+        expect(arr.runners[2]).toEqual(any(FakeRunner))
+        expect(arr.runners[2].transforms).toEqual(
+          new Matrix({ translate: [ 10, 20 ] })
+            .rotate(45)
+        )
+
+        expect(arr.runners[1]).toBe(runner3)
+      })
     })
-  })
 
-  describe('ease()', () => {
-    it('returns itself', () => {
-      var runner = new Runner()
-      expect(runner.ease(() => {})).toBe(runner)
+    describe('edit()', () => {
+      it('replaces one runner with another', () => {
+        const arr = new RunnerArray()
+        const runner1 = new Runner()
+        const runner2 = new Runner()
+        arr.add(runner1)
+        arr.edit(runner1.id, runner2)
+        expect(arr.length()).toBe(1)
+        expect(arr.runners[0]).toBe(runner2)
+      })
     })
 
-    it('creates an easing Controller from the easing function', () => {
-      var runner = new Runner()
-      runner.ease(() => {})
+    describe('length()', () => {
+      it('returns the number of runners in the array', () => {
+        const arr = new RunnerArray().add(new Runner()).add(new Runner())
+        expect(arr.length()).toBe(2)
+      })
+    })
 
-      expect(runner._stepper instanceof Ease).toBe(true)
+    describe('clearBefore', () => {
+      it('removes all runners before a certain runner', () => {
+        const runner1 = new Runner()
+        const runner2 = new Runner()
+        const runner3 = new Runner()
+        const runner4 = new Runner()
+        const runner5 = new Runner()
+        const arr = new RunnerArray()
+        arr.add(runner1).add(runner2).add(runner3).add(runner4).add(runner5)
+        arr.clearBefore(runner3.id)
+        expect(arr.length()).toBe(4)
+        expect(arr.runners).toEqual([ any(FakeRunner), runner3, runner4, runner5 ])
+      })
     })
   })
 })
index 0a28e8e9a9013ff3109fe90a31464aa3c0196b85..2d48e103c887c71ca3386980fbc98c69c28189e9 100644 (file)
@@ -10,6 +10,34 @@ import PathArray from '../types/PathArray.js'
 import SVGArray from '../types/SVGArray.js'
 import SVGNumber from '../types/SVGNumber.js'
 
+const getClassForType = (value) => {
+  const type = typeof value
+
+  if (type === 'number') {
+    return SVGNumber
+  } else if (type === 'string') {
+    if (Color.isColor(value)) {
+      return Color
+    } else if (delimiter.test(value)) {
+      return pathLetters.test(value)
+        ? PathArray
+        : SVGArray
+    } else if (numberAndUnit.test(value)) {
+      return SVGNumber
+    } else {
+      return NonMorphable
+    }
+  } else if (morphableTypes.indexOf(value.constructor) > -1) {
+    return value.constructor
+  } else if (Array.isArray(value)) {
+    return SVGArray
+  } else if (type === 'object') {
+    return ObjectBag
+  } else {
+    return NonMorphable
+  }
+}
+
 export default class Morphable {
   constructor (stepper) {
     this._stepper = stepper || new Ease('-')
@@ -52,32 +80,7 @@ export default class Morphable {
 
   _set (value) {
     if (!this._type) {
-      var type = typeof value
-
-      if (type === 'number') {
-        this.type(SVGNumber)
-      } else if (type === 'string') {
-        if (Color.isColor(value)) {
-          this.type(Color)
-        } else if (delimiter.test(value)) {
-          this.type(pathLetters.test(value)
-            ? PathArray
-            : SVGArray
-          )
-        } else if (numberAndUnit.test(value)) {
-          this.type(SVGNumber)
-        } else {
-          this.type(NonMorphable)
-        }
-      } else if (morphableTypes.indexOf(value.constructor) > -1) {
-        this.type(value.constructor)
-      } else if (Array.isArray(value)) {
-        this.type(SVGArray)
-      } else if (type === 'object') {
-        this.type(ObjectBag)
-      } else {
-        this.type(NonMorphable)
-      }
+      this.type(getClassForType(value))
     }
 
     var result = (new this._type(value))
@@ -86,6 +89,13 @@ export default class Morphable {
         : this._from ? result[this._from[4]]()
         : result
     }
+
+    if (this._type === ObjectBag) {
+      result = this._to ? result.align(this._to)
+        : this._from ? result.align(this._from)
+        : result
+    }
+
     result = result.toArray()
 
     this._morphObj = this._morphObj || new this._type()
@@ -208,7 +218,7 @@ export class ObjectBag {
     this.values = []
 
     if (Array.isArray(objOrArr)) {
-      this.values = objOrArr
+      this.values = objOrArr.slice()
       return
     }
 
@@ -216,7 +226,9 @@ export class ObjectBag {
     var entries = []
 
     for (const i in objOrArr) {
-      entries.push([ i, objOrArr[i] ])
+      const Type = getClassForType(objOrArr[i])
+      const val = new Type(objOrArr[i]).toArray()
+      entries.push([ i, Type, val.length, ...val ])
     }
 
     entries.sort(sortByKey)
@@ -229,8 +241,13 @@ export class ObjectBag {
     var obj = {}
     var arr = this.values
 
-    for (var i = 0, len = arr.length; i < len; i += 2) {
-      obj[arr[i]] = arr[i + 1]
+    // for (var i = 0, len = arr.length; i < len; i += 2) {
+    while (arr.length) {
+      const key = arr.shift()
+      const Type = arr.shift()
+      const num = arr.shift()
+      const values = arr.splice(0, num)
+      obj[key] = new Type(values).valueOf()
     }
 
     return obj
@@ -239,6 +256,17 @@ export class ObjectBag {
   toArray () {
     return this.values
   }
+
+  align (other) {
+    for (let i = 0, il = this.values.length; i < il; ++i) {
+      if (this.values[i] === Color) {
+        const space = other[i + 6]
+        const color = new Color(this.values.splice(i + 2, 5))[space]().toArray()
+        this.values.splice(i + 2, 0, ...color)
+      }
+    }
+    return this
+  }
 }
 
 const morphableTypes = [
index 8c5642345144f99b84cf64e4372044336f30402e..e2ba3800944f4be77ac79a66955828ea61985576 100644 (file)
@@ -13,6 +13,7 @@ import Morphable, { TransformBag } from './Morphable.js'
 import Point from '../types/Point.js'
 import SVGNumber from '../types/SVGNumber.js'
 import Timeline from './Timeline.js'
+import { ObjectBag } from '../main.js'
 
 export default class Runner extends EventTarget {
   constructor (options) {
@@ -381,7 +382,7 @@ export default class Runner extends EventTarget {
       // for the case of transformations, we use the special retarget function
       // which has access to the outer scope
       if (this._history[method].caller.retarget) {
-        this._history[method].caller.retarget(target, extra)
+        this._history[method].caller.retarget.call(this, target, extra)
         // for everything else a simple morpher change is sufficient
       } else {
         this._history[method].morpher.to(target)
@@ -487,7 +488,7 @@ export default class Runner extends EventTarget {
 
 Runner.id = 0
 
-class FakeRunner {
+export class FakeRunner {
   constructor (transforms = new Matrix(), id = -1, done = true) {
     this.transforms = transforms
     this.id = id
@@ -527,7 +528,7 @@ function mergeTransforms () {
   }
 }
 
-class RunnerArray {
+export class RunnerArray {
   constructor () {
     this.runners = []
     this.ids = []
@@ -556,7 +557,8 @@ class RunnerArray {
 
   merge () {
     let lastRunner = null
-    this.runners.forEach((runner, i) => {
+    for (let i = 0; i < this.runners.length; ++i) {
+      const runner = this.runners[i]
 
       const condition = lastRunner
         && runner.done && lastRunner.done
@@ -567,11 +569,14 @@ class RunnerArray {
       if (condition) {
         // the +1 happens in the function
         this.remove(runner.id)
-        this.edit(lastRunner.id, runner.mergeWith(lastRunner))
+        const newRunner = runner.mergeWith(lastRunner)
+        this.edit(lastRunner.id, newRunner)
+        lastRunner = newRunner
+        --i
+      } else {
+        lastRunner = runner
       }
-
-      lastRunner = runner
-    })
+    }
 
     return this
   }
@@ -649,6 +654,14 @@ registerMethods({
   }
 })
 
+const intersect = (a, b) => {
+  var setB = new Set(b)
+  return [ ...new Set(a) ].filter(x => setB.has(x))
+}
+
+// Will output the elements from array A that are not in the array B
+const difference = (a, b) => a.filter(x => !b.includes(x))
+
 extend(Runner, {
   attr (a, v) {
     return this.styleAttr('attr', a, v)
@@ -659,24 +672,56 @@ extend(Runner, {
     return this.styleAttr('css', s, v)
   },
 
-  styleAttr (type, name, val) {
-    // apply attributes individually
-    if (typeof name === 'object') {
-      for (var key in name) {
-        this.styleAttr(type, key, name[key])
-      }
-      return this
+  styleAttr (type, nameOrAttrs, val) {
+    if (typeof nameOrAttrs === 'string') {
+      return this.styleAttr(type, { [nameOrAttrs]: val })
     }
 
-    var morpher = new Morphable(this._stepper).to(val)
+    let attrs = nameOrAttrs
+    if (this._tryRetarget(type, attrs)) return this
+
+    var morpher = new Morphable(this._stepper).to(attrs)
+    let keys = Object.keys(attrs)
 
     this.queue(function () {
-      morpher = morpher.from(this.element()[type](name))
+      morpher = morpher.from(this.element()[type](keys))
     }, function (pos) {
-      this.element()[type](name, morpher.at(pos))
+      this.element()[type](morpher.at(pos).valueOf())
       return morpher.done()
+    }, function (newToAttrs) {
+
+      // Check if any new keys were added
+      const newKeys = Object.keys(newToAttrs)
+      const differences = difference(newKeys, keys)
+
+      // If their are new keys, initialize them and add them to morpher
+      if (differences.length) {
+        // Get the values
+        const addedFromAttrs = this.element()[type](differences)
+
+        // Get the already initialized values
+        const oldFromAttrs = new ObjectBag(morpher.from()).valueOf()
+
+        // Merge old and new
+        Object.assign(oldFromAttrs, addedFromAttrs)
+        morpher.from(oldFromAttrs)
+      }
+
+      // Get the object from the morpher
+      const oldToAttrs = new ObjectBag(morpher.to()).valueOf()
+
+      // Merge in new attributes
+      Object.assign(oldToAttrs, newToAttrs)
+
+      // Change morpher target
+      morpher.to(oldToAttrs)
+
+      // Make sure that we save the work we did so we don't need it to do again
+      keys = newKeys
+      attrs = newToAttrs
     })
 
+    this._rememberMorpher(type, morpher)
     return this
   },
 
@@ -812,7 +857,7 @@ extend(Runner, {
         (newTransforms.origin || 'center').toString()
         !== (transforms.origin || 'center').toString()
       ) {
-        origin = getOrigin(transforms, element)
+        origin = getOrigin(newTransforms, element)
       }
 
       // overwrite the old transformations with the new ones
index eb6368ebbc2510dd6a934a864a9216de1f5b6880..eb6168f24a17f8ab7b41fe9cf0c42be5aac63486 100644 (file)
@@ -28,7 +28,7 @@ function is (object, space) {
 }
 
 function getParameters (a, b) {
-  const params = is(a, 'rgb') ? { _a: a.r, _b: a.g, _c: a.b, space: 'rgb' }
+  const params = is(a, 'rgb') ? { _a: a.r, _b: a.g, _c: a.b, _d: 0, space: 'rgb' }
     : is(a, 'xyz') ? { _a: a.x, _b: a.y, _c: a.z, _d: 0, space: 'xyz' }
     : is(a, 'hsl') ? { _a: a.h, _b: a.s, _c: a.l, _d: 0, space: 'hsl' }
     : is(a, 'lab') ? { _a: a.l, _b: a.a, _c: a.b, _d: 0, space: 'lab' }