diff options
author | Ulrich-Matthias Schäfer <ulima.ums@googlemail.com> | 2018-11-06 13:48:05 +0100 |
---|---|---|
committer | Ulrich-Matthias Schäfer <ulima.ums@googlemail.com> | 2018-11-06 13:48:05 +0100 |
commit | a0b13ebcacfd74b9f521110c7225bb404325bcd3 (patch) | |
tree | a07c5cc422645e31d7dfef81ce4e54f03f0945f6 /src/animation | |
parent | 9f2696e8a2cf7e4eebc1cc7e31027fe2070094fa (diff) | |
download | svg.js-a0b13ebcacfd74b9f521110c7225bb404325bcd3.tar.gz svg.js-a0b13ebcacfd74b9f521110c7225bb404325bcd3.zip |
reordered modules, add es6 build
Diffstat (limited to 'src/animation')
-rw-r--r-- | src/animation/Animator.js | 85 | ||||
-rw-r--r-- | src/animation/Controller.js | 173 | ||||
-rw-r--r-- | src/animation/Queue.js | 59 | ||||
-rw-r--r-- | src/animation/Runner.js | 928 | ||||
-rw-r--r-- | src/animation/Timeline.js | 271 |
5 files changed, 1516 insertions, 0 deletions
diff --git a/src/animation/Animator.js b/src/animation/Animator.js new file mode 100644 index 0000000..fdb2326 --- /dev/null +++ b/src/animation/Animator.js @@ -0,0 +1,85 @@ +import Queue from './Queue.js' + +const Animator = { + nextDraw: null, + frames: new Queue(), + timeouts: new Queue(), + timer: window.performance || window.Date, + transforms: [], + + frame (fn) { + // Store the node + var node = Animator.frames.push({ run: fn }) + + // Request an animation frame if we don't have one + if (Animator.nextDraw === null) { + Animator.nextDraw = window.requestAnimationFrame(Animator._draw) + } + + // Return the node so we can remove it easily + return node + }, + + transform_frame (fn, id) { + Animator.transforms[id] = fn + }, + + timeout (fn, delay) { + delay = delay || 0 + + // Work out when the event should fire + var time = Animator.timer.now() + delay + + // Add the timeout to the end of the queue + var node = Animator.timeouts.push({ run: fn, time: time }) + + // Request another animation frame if we need one + if (Animator.nextDraw === null) { + Animator.nextDraw = window.requestAnimationFrame(Animator._draw) + } + + return node + }, + + cancelFrame (node) { + Animator.frames.remove(node) + }, + + clearTimeout (node) { + Animator.timeouts.remove(node) + }, + + _draw (now) { + // Run all the timeouts we can run, if they are not ready yet, add them + // to the end of the queue immediately! (bad timeouts!!! [sarcasm]) + var nextTimeout = null + var lastTimeout = Animator.timeouts.last() + while ((nextTimeout = Animator.timeouts.shift())) { + // Run the timeout if its time, or push it to the end + if (now >= nextTimeout.time) { + nextTimeout.run() + } else { + Animator.timeouts.push(nextTimeout) + } + + // If we hit the last item, we should stop shifting out more items + if (nextTimeout === lastTimeout) break + } + + // Run all of the animation frames + var nextFrame = null + var lastFrame = Animator.frames.last() + while ((nextFrame !== lastFrame) && (nextFrame = Animator.frames.shift())) { + nextFrame.run() + } + + Animator.transforms.forEach(function (el) { el() }) + + // If we have remaining timeouts or frames, draw until we don't anymore + Animator.nextDraw = Animator.timeouts.first() || Animator.frames.first() + ? window.requestAnimationFrame(Animator._draw) + : null + } +} + +export default Animator diff --git a/src/animation/Controller.js b/src/animation/Controller.js new file mode 100644 index 0000000..1716545 --- /dev/null +++ b/src/animation/Controller.js @@ -0,0 +1,173 @@ +import { timeline } from '../modules/core/defaults.js' +import { extend } from '../utils/adopter.js' + +/*** +Base Class +========== +The base stepper class that will be +***/ + +function makeSetterGetter (k, f) { + return function (v) { + if (v == null) return this[v] + this[k] = v + if (f) f.call(this) + return this + } +} + +export let easing = { + '-': function (pos) { return pos }, + '<>': function (pos) { return -Math.cos(pos * Math.PI) / 2 + 0.5 }, + '>': function (pos) { return Math.sin(pos * Math.PI / 2) }, + '<': function (pos) { return -Math.cos(pos * Math.PI / 2) + 1 }, + bezier: function (t0, x0, t1, x1) { + return function (t) { + // TODO: FINISH + } + } +} + +export class Stepper { + done () { return false } +} + +/*** +Easing Functions +================ +***/ + +export class Ease extends Stepper { + constructor (fn) { + super() + this.ease = easing[fn || timeline.ease] || fn + } + + step (from, to, pos) { + if (typeof from !== 'number') { + return pos < 1 ? from : to + } + return from + (to - from) * this.ease(pos) + } +} + +/*** +Controller Types +================ +***/ + +export class Controller extends Stepper { + constructor (fn) { + super() + this.stepper = fn + } + + step (current, target, dt, c) { + return this.stepper(current, target, dt, c) + } + + done (c) { + return c.done + } +} + +function recalculate () { + // Apply the default parameters + var duration = (this._duration || 500) / 1000 + var overshoot = this._overshoot || 0 + + // Calculate the PID natural response + var eps = 1e-10 + var pi = Math.PI + var os = Math.log(overshoot / 100 + eps) + var zeta = -os / Math.sqrt(pi * pi + os * os) + var wn = 3.9 / (zeta * duration) + + // Calculate the Spring values + this.d = 2 * zeta * wn + this.k = wn * wn +} + +export class Spring extends Controller { + constructor (duration, overshoot) { + super() + this.duration(duration || 500) + .overshoot(overshoot || 0) + } + + step (current, target, dt, c) { + if (typeof current === 'string') return current + c.done = dt === Infinity + if (dt === Infinity) return target + if (dt === 0) return current + + if (dt > 100) dt = 16 + + dt /= 1000 + + // Get the previous velocity + var velocity = c.velocity || 0 + + // Apply the control to get the new position and store it + var acceleration = -this.d * velocity - this.k * (current - target) + var newPosition = current + + velocity * dt + + acceleration * dt * dt / 2 + + // Store the velocity + c.velocity = velocity + acceleration * dt + + // Figure out if we have converged, and if so, pass the value + c.done = Math.abs(target - newPosition) + Math.abs(velocity) < 0.002 + return c.done ? target : newPosition + } +} + +extend(Spring, { + duration: makeSetterGetter('_duration', recalculate), + overshoot: makeSetterGetter('_overshoot', recalculate) +}) + +export class PID extends Controller { + constructor (p, i, d, windup) { + super() + + p = p == null ? 0.1 : p + i = i == null ? 0.01 : i + d = d == null ? 0 : d + windup = windup == null ? 1000 : windup + this.p(p).i(i).d(d).windup(windup) + } + + step (current, target, dt, c) { + if (typeof current === 'string') return current + c.done = dt === Infinity + + if (dt === Infinity) return target + if (dt === 0) return current + + var p = target - current + var i = (c.integral || 0) + p * dt + var d = (p - (c.error || 0)) / dt + var windup = this.windup + + // antiwindup + if (windup !== false) { + i = Math.max(-windup, Math.min(i, windup)) + } + + c.error = p + c.integral = i + + c.done = Math.abs(p) < 0.001 + + return c.done ? target : current + (this.P * p + this.I * i + this.D * d) + } +} + +extend(PID, { + windup: makeSetterGetter('windup'), + p: makeSetterGetter('P'), + i: makeSetterGetter('I'), + d: makeSetterGetter('D') +}) diff --git a/src/animation/Queue.js b/src/animation/Queue.js new file mode 100644 index 0000000..14b92b4 --- /dev/null +++ b/src/animation/Queue.js @@ -0,0 +1,59 @@ +export default class Queue { + constructor () { + this._first = null + this._last = null + } + + push (value) { + // An item stores an id and the provided value + var item = value.next ? value : { value: value, next: null, prev: null } + + // Deal with the queue being empty or populated + if (this._last) { + item.prev = this._last + this._last.next = item + this._last = item + } else { + this._last = item + this._first = item + } + + // Update the length and return the current item + return item + } + + shift () { + // Check if we have a value + var remove = this._first + if (!remove) return null + + // If we do, remove it and relink things + this._first = remove.next + if (this._first) this._first.prev = null + this._last = this._first ? this._last : null + return remove.value + } + + // Shows us the first item in the list + first () { + return this._first && this._first.value + } + + // Shows us the last item in the list + last () { + return this._last && this._last.value + } + + // Removes the item that was returned from the push + remove (item) { + // Relink the previous item + if (item.prev) item.prev.next = item.next + if (item.next) item.next.prev = item.prev + if (item === this._last) this._last = item.prev + if (item === this._first) this._first = item.next + + // Invalidate item + item.prev = null + item.next = null + } +} diff --git a/src/animation/Runner.js b/src/animation/Runner.js new file mode 100644 index 0000000..f752185 --- /dev/null +++ b/src/animation/Runner.js @@ -0,0 +1,928 @@ +import { Controller, Ease, Stepper } from './Controller.js' +import { extend } from '../utils/adopter.js' +import { getOrigin } from '../utils/utils.js' +import { noop, timeline } from '../modules/core/defaults.js' +import { registerMethods } from '../utils/methods.js' +import Animator from './Animator.js' +import Box from '../types/Box.js' +import EventTarget from '../types/EventTarget.js' +import Matrix from '../types/Matrix.js' +import Morphable, { TransformBag } from '../types/Morphable.js' +import Point from '../types/Point.js' +import SVGNumber from '../types/SVGNumber.js' +import Timeline from './Timeline.js' + +export default class Runner extends EventTarget { + constructor (options) { + super() + + // Store a unique id on the runner, so that we can identify it later + this.id = Runner.id++ + + // Ensure a default value + options = options == null + ? timeline.duration + : options + + // Ensure that we get a controller + options = typeof options === 'function' + ? new Controller(options) + : options + + // Declare all of the variables + this._element = null + this._timeline = null + this.done = false + this._queue = [] + + // Work out the stepper and the duration + this._duration = typeof options === 'number' && options + this._isDeclarative = options instanceof Controller + this._stepper = this._isDeclarative ? options : new Ease() + + // We copy the current values from the timeline because they can change + this._history = {} + + // Store the state of the runner + this.enabled = true + this._time = 0 + this._last = 0 + + // Save transforms applied to this runner + this.transforms = new Matrix() + this.transformId = 1 + + // Looping variables + this._haveReversed = false + this._reverse = false + this._loopsDone = 0 + this._swing = false + this._wait = 0 + this._times = 1 + } + + /* + Runner Definitions + ================== + These methods help us define the runtime behaviour of the Runner or they + help us make new runners from the current runner + */ + + element (element) { + if (element == null) return this._element + this._element = element + element._prepareRunner() + return this + } + + timeline (timeline) { + // check explicitly for undefined so we can set the timeline to null + if (typeof timeline === 'undefined') return this._timeline + this._timeline = timeline + return this + } + + animate (duration, delay, when) { + var o = Runner.sanitise(duration, delay, when) + var runner = new Runner(o.duration) + if (this._timeline) runner.timeline(this._timeline) + if (this._element) runner.element(this._element) + return runner.loop(o).schedule(delay, when) + } + + schedule (timeline, delay, when) { + // The user doesn't need to pass a timeline if we already have one + if (!(timeline instanceof Timeline)) { + when = delay + delay = timeline + timeline = this.timeline() + } + + // If there is no timeline, yell at the user... + if (!timeline) { + throw Error('Runner cannot be scheduled without timeline') + } + + // Schedule the runner on the timeline provided + timeline.schedule(this, delay, when) + return this + } + + unschedule () { + var timeline = this.timeline() + timeline && timeline.unschedule(this) + return this + } + + loop (times, swing, wait) { + // Deal with the user passing in an object + if (typeof times === 'object') { + swing = times.swing + wait = times.wait + times = times.times + } + + // Sanitise the values and store them + this._times = times || Infinity + this._swing = swing || false + this._wait = wait || 0 + return this + } + + delay (delay) { + return this.animate(0, delay) + } + + /* + Basic Functionality + =================== + These methods allow us to attach basic functions to the runner directly + */ + + queue (initFn, runFn, isTransform) { + this._queue.push({ + initialiser: initFn || noop, + runner: runFn || noop, + isTransform: isTransform, + initialised: false, + finished: false + }) + var timeline = this.timeline() + timeline && this.timeline()._continue() + return this + } + + during (fn) { + return this.queue(null, fn) + } + + after (fn) { + return this.on('finish', fn) + } + + /* + Runner animation methods + ======================== + Control how the animation plays + */ + + time (time) { + if (time == null) { + return this._time + } + let dt = time - this._time + this.step(dt) + return this + } + + duration () { + return this._times * (this._wait + this._duration) - this._wait + } + + loops (p) { + var loopDuration = this._duration + this._wait + if (p == null) { + var loopsDone = Math.floor(this._time / loopDuration) + var relativeTime = (this._time - loopsDone * loopDuration) + var position = relativeTime / this._duration + return Math.min(loopsDone + position, this._times) + } + var whole = Math.floor(p) + var partial = p % 1 + var time = loopDuration * whole + this._duration * partial + return this.time(time) + } + + position (p) { + // Get all of the variables we need + var x = this._time + var d = this._duration + var w = this._wait + var t = this._times + var s = this._swing + var r = this._reverse + var position + + if (p == null) { + /* + This function converts a time to a position in the range [0, 1] + The full explanation can be found in this desmos demonstration + https://www.desmos.com/calculator/u4fbavgche + The logic is slightly simplified here because we can use booleans + */ + + // Figure out the value without thinking about the start or end time + const f = function (x) { + var swinging = s * Math.floor(x % (2 * (w + d)) / (w + d)) + var backwards = (swinging && !r) || (!swinging && r) + var uncliped = Math.pow(-1, backwards) * (x % (w + d)) / d + backwards + var clipped = Math.max(Math.min(uncliped, 1), 0) + return clipped + } + + // Figure out the value by incorporating the start time + var endTime = t * (w + d) - w + position = x <= 0 ? Math.round(f(1e-5)) + : x < endTime ? f(x) + : Math.round(f(endTime - 1e-5)) + return position + } + + // Work out the loops done and add the position to the loops done + var loopsDone = Math.floor(this.loops()) + var swingForward = s && (loopsDone % 2 === 0) + var forwards = (swingForward && !r) || (r && swingForward) + position = loopsDone + (forwards ? p : 1 - p) + return this.loops(position) + } + + progress (p) { + if (p == null) { + return Math.min(1, this._time / this.duration()) + } + return this.time(p * this.duration()) + } + + step (dt) { + // If we are inactive, this stepper just gets skipped + if (!this.enabled) return this + + // Update the time and get the new position + dt = dt == null ? 16 : dt + this._time += dt + var position = this.position() + + // Figure out if we need to run the stepper in this frame + var running = this._lastPosition !== position && this._time >= 0 + this._lastPosition = position + + // Figure out if we just started + var duration = this.duration() + var justStarted = this._lastTime < 0 && this._time > 0 + var justFinished = this._lastTime < this._time && this.time > duration + this._lastTime = this._time + if (justStarted) { + this.fire('start', this) + } + + // Work out if the runner is finished set the done flag here so animations + // know, that they are running in the last step (this is good for + // transformations which can be merged) + var declarative = this._isDeclarative + this.done = !declarative && !justFinished && this._time >= duration + + // Call initialise and the run function + if (running || declarative) { + this._initialise(running) + + // clear the transforms on this runner so they dont get added again and again + this.transforms = new Matrix() + var converged = this._run(declarative ? dt : position) + this.fire('step', this) + } + // correct the done flag here + // declaritive animations itself know when they converged + this.done = this.done || (converged && declarative) + if (this.done) { + this.fire('finish', this) + } + return this + } + + finish () { + return this.step(Infinity) + } + + reverse (reverse) { + this._reverse = reverse == null ? !this._reverse : reverse + return this + } + + ease (fn) { + this._stepper = new Ease(fn) + return this + } + + active (enabled) { + if (enabled == null) return this.enabled + this.enabled = enabled + return this + } + + /* + Private Methods + =============== + Methods that shouldn't be used externally + */ + + // Save a morpher to the morpher list so that we can retarget it later + _rememberMorpher (method, morpher) { + this._history[method] = { + morpher: morpher, + caller: this._queue[this._queue.length - 1] + } + } + + // Try to set the target for a morpher if the morpher exists, otherwise + // do nothing and return false + _tryRetarget (method, target) { + if (this._history[method]) { + // if the last method wasnt even initialised, throw it away + if (!this._history[method].caller.initialised) { + let index = this._queue.indexOf(this._history[method].caller) + this._queue.splice(index, 1) + return false + } + + // for the case of transformations, we use the special retarget function + // which has access to the outer scope + if (this._history[method].caller.isTransform) { + this._history[method].caller.isTransform(target) + // for everything else a simple morpher change is sufficient + } else { + this._history[method].morpher.to(target) + } + + this._history[method].caller.finished = false + var timeline = this.timeline() + timeline && timeline._continue() + return true + } + return false + } + + // Run each initialise function in the runner if required + _initialise (running) { + // If we aren't running, we shouldn't initialise when not declarative + if (!running && !this._isDeclarative) return + + // Loop through all of the initialisers + for (var i = 0, len = this._queue.length; i < len; ++i) { + // Get the current initialiser + var current = this._queue[i] + + // Determine whether we need to initialise + var needsIt = this._isDeclarative || (!current.initialised && running) + running = !current.finished + + // Call the initialiser if we need to + if (needsIt && running) { + current.initialiser.call(this) + current.initialised = true + } + } + } + + // Run each run function for the position or dt given + _run (positionOrDt) { + // Run all of the _queue directly + var allfinished = true + for (var i = 0, len = this._queue.length; i < len; ++i) { + // Get the current function to run + var current = this._queue[i] + + // Run the function if its not finished, we keep track of the finished + // flag for the sake of declarative _queue + var converged = current.runner.call(this, positionOrDt) + current.finished = current.finished || (converged === true) + allfinished = allfinished && current.finished + } + + // We report when all of the constructors are finished + return allfinished + } + + addTransform (transform, index) { + this.transforms.lmultiplyO(transform) + return this + } + + clearTransform () { + this.transforms = new Matrix() + return this + } + + static sanitise (duration, delay, when) { + // Initialise the default parameters + var times = 1 + var swing = false + var wait = 0 + duration = duration || timeline.duration + delay = delay || timeline.delay + when = when || 'last' + + // If we have an object, unpack the values + if (typeof duration === 'object' && !(duration instanceof Stepper)) { + delay = duration.delay || delay + when = duration.when || when + swing = duration.swing || swing + times = duration.times || times + wait = duration.wait || wait + duration = duration.duration || timeline.duration + } + + return { + duration: duration, + delay: delay, + swing: swing, + times: times, + wait: wait, + when: when + } + } +} + +Runner.id = 0 + +class FakeRunner { + constructor (transforms = new Matrix(), id = -1, done = true) { + this.transforms = transforms + this.id = id + this.done = done + } +} + +extend([Runner, FakeRunner], { + mergeWith (runner) { + return new FakeRunner( + runner.transforms.lmultiply(this.transforms), + runner.id + ) + } +}) + +// FakeRunner.emptyRunner = new FakeRunner() + +const lmultiply = (last, curr) => last.lmultiplyO(curr) +const getRunnerTransform = (runner) => runner.transforms + +function mergeTransforms () { + // Find the matrix to apply to the element and apply it + let runners = this._transformationRunners.runners + let netTransform = runners + .map(getRunnerTransform) + .reduce(lmultiply, new Matrix()) + + this.transform(netTransform) + + this._transformationRunners.merge() + + if (this._transformationRunners.length() === 1) { + this._frameId = null + } +} + +class RunnerArray { + constructor () { + this.runners = [] + this.ids = [] + } + + add (runner) { + if (this.runners.includes(runner)) return + + let id = runner.id + 1 + + let leftSibling = this.ids.reduce((last, curr) => { + if (curr > last && curr < id) return curr + return last + }, 0) + + let index = this.ids.indexOf(leftSibling) + 1 + + this.ids.splice(index, 0, id) + this.runners.splice(index, 0, runner) + + return this + } + + getByID (id) { + return this.runners[this.ids.indexOf(id + 1)] + } + + remove (id) { + let index = this.ids.indexOf(id + 1) + this.ids.splice(index, 1) + this.runners.splice(index, 1) + return this + } + + merge () { + let lastRunner = null + this.runners.forEach((runner, i) => { + if (lastRunner && runner.done && lastRunner.done) { + this.remove(runner.id) + this.edit(lastRunner.id, runner.mergeWith(lastRunner)) + } + + lastRunner = runner + }) + + return this + } + + edit (id, newRunner) { + let index = this.ids.indexOf(id + 1) + this.ids.splice(index, 1, id) + this.runners.splice(index, 1, newRunner) + return this + } + + length () { + return this.ids.length + } + + clearBefore (id) { + let deleteCnt = this.ids.indexOf(id + 1) || 1 + this.ids.splice(0, deleteCnt, 0) + this.runners.splice(0, deleteCnt, new FakeRunner()) + return this + } +} + +let frameId = 0 +registerMethods({ + Element: { + animate (duration, delay, when) { + var o = Runner.sanitise(duration, delay, when) + var timeline = this.timeline() + return new Runner(o.duration) + .loop(o) + .element(this) + .timeline(timeline) + .schedule(delay, when) + }, + + delay (by, when) { + return this.animate(0, by, when) + }, + + // this function searches for all runners on the element and deletes the ones + // which run before the current one. This is because absolute transformations + // overwfrite anything anyway so there is no need to waste time computing + // other runners + _clearTransformRunnersBefore (currentRunner) { + this._transformationRunners.clearBefore(currentRunner.id) + }, + + _currentTransform (current) { + return this._transformationRunners.runners + // we need the equal sign here to make sure, that also transformations + // on the same runner which execute before the current transformation are + // taken into account + .filter((runner) => runner.id <= current.id) + .map(getRunnerTransform) + .reduce(lmultiply, new Matrix()) + }, + + addRunner (runner) { + this._transformationRunners.add(runner) + + Animator.transform_frame( + mergeTransforms.bind(this), this._frameId + ) + }, + + _prepareRunner () { + if (this._frameId == null) { + this._transformationRunners = new RunnerArray() + .add(new FakeRunner(new Matrix(this))) + + this._frameId = frameId++ + } + } + } +}) + +extend(Runner, { + attr (a, v) { + return this.styleAttr('attr', a, v) + }, + + // Add animatable styles + css (s, v) { + return this.styleAttr('css', s, v) + }, + + styleAttr (type, name, val) { + // apply attributes individually + if (typeof name === 'object') { + for (var key in val) { + this.styleAttr(type, key, val[key]) + } + } + + var morpher = new Morphable(this._stepper).to(val) + + this.queue(function () { + morpher = morpher.from(this.element()[type](name)) + }, function (pos) { + this.element()[type](name, morpher.at(pos)) + return morpher.done() + }) + + return this + }, + + zoom (level, point) { + var morpher = new Morphable(this._stepper).to(new SVGNumber(level)) + + this.queue(function () { + morpher = morpher.from(this.zoom()) + }, function (pos) { + this.element().zoom(morpher.at(pos), point) + return morpher.done() + }) + + return this + }, + + /** + ** absolute transformations + **/ + + // + // M v -----|-----(D M v = F v)------|-----> T v + // + // 1. define the final state (T) and decompose it (once) + // t = [tx, ty, the, lam, sy, sx] + // 2. on every frame: pull the current state of all previous transforms + // (M - m can change) + // and then write this as m = [tx0, ty0, the0, lam0, sy0, sx0] + // 3. Find the interpolated matrix F(pos) = m + pos * (t - m) + // - Note F(0) = M + // - Note F(1) = T + // 4. Now you get the delta matrix as a result: D = F * inv(M) + + transform (transforms, relative, affine) { + // If we have a declarative function, we should retarget it if possible + relative = transforms.relative || relative + if (this._isDeclarative && !relative && this._tryRetarget('transform', transforms)) { + return this + } + + // Parse the parameters + var isMatrix = Matrix.isMatrixLike(transforms) + affine = transforms.affine != null + ? transforms.affine + : (affine != null ? affine : !isMatrix) + + // Create a morepher and set its type + const morpher = new Morphable() + .type(affine ? TransformBag : Matrix) + .stepper(this._stepper) + + let origin + let element + let current + let currentAngle + let startTransform + + function setup () { + // make sure element and origin is defined + element = element || this.element() + origin = origin || getOrigin(transforms, element) + + startTransform = new Matrix(relative ? undefined : element) + + // add the runner to the element so it can merge transformations + element.addRunner(this) + + // Deactivate all transforms that have run so far if we are absolute + if (!relative) { + element._clearTransformRunnersBefore(this) + } + } + + function run (pos) { + // clear all other transforms before this in case something is saved + // on this runner. We are absolute. We dont need these! + if (!relative) this.clearTransform() + + let { x, y } = new Point(origin).transform(element._currentTransform(this)) + + let target = new Matrix({ ...transforms, origin: [x, y] }) + let start = this._isDeclarative && current + ? current + : startTransform + + if (affine) { + target = target.decompose(x, y) + start = start.decompose(x, y) + + // Get the current and target angle as it was set + const rTarget = target.rotate + const rCurrent = start.rotate + + // Figure out the shortest path to rotate directly + const possibilities = [rTarget - 360, rTarget, rTarget + 360] + const distances = possibilities.map(a => Math.abs(a - rCurrent)) + const shortest = Math.min(...distances) + const index = distances.indexOf(shortest) + target.rotate = possibilities[index] + } + + if (relative) { + // we have to be careful here not to overwrite the rotation + // with the rotate method of Matrix + if (!isMatrix) { + target.rotate = transforms.rotate || 0 + } + if (this._isDeclarative && currentAngle) { + start.rotate = currentAngle + } + } + + morpher.from(start) + morpher.to(target) + + let affineParameters = morpher.at(pos) + currentAngle = affineParameters.rotate + current = new Matrix(affineParameters) + + this.addTransform(current) + return morpher.done() + } + + function retarget (newTransforms) { + // only get a new origin if it changed since the last call + if ( + (newTransforms.origin || 'center').toString() !== + (transforms.origin || 'center').toString() + ) { + origin = getOrigin(transforms, element) + } + + // overwrite the old transformations with the new ones + transforms = { ...newTransforms, origin } + } + + this.queue(setup, run, retarget) + this._isDeclarative && this._rememberMorpher('transform', morpher) + return this + }, + + // Animatable x-axis + x (x, relative) { + return this._queueNumber('x', x) + }, + + // Animatable y-axis + y (y) { + return this._queueNumber('y', y) + }, + + dx (x) { + return this._queueNumberDelta('dx', x) + }, + + dy (y) { + return this._queueNumberDelta('dy', y) + }, + + _queueNumberDelta (method, to) { + to = new SVGNumber(to) + + // Try to change the target if we have this method already registerd + if (this._tryRetargetDelta(method, to)) return this + + // Make a morpher and queue the animation + var morpher = new Morphable(this._stepper).to(to) + this.queue(function () { + var from = this.element()[method]() + morpher.from(from) + morpher.to(from + to) + }, function (pos) { + this.element()[method](morpher.at(pos)) + return morpher.done() + }) + + // Register the morpher so that if it is changed again, we can retarget it + this._rememberMorpher(method, morpher) + return this + }, + + _queueObject (method, to) { + // Try to change the target if we have this method already registerd + if (this._tryRetarget(method, to)) return this + + // Make a morpher and queue the animation + var morpher = new Morphable(this._stepper).to(to) + this.queue(function () { + morpher.from(this.element()[method]()) + }, function (pos) { + this.element()[method](morpher.at(pos)) + return morpher.done() + }) + + // Register the morpher so that if it is changed again, we can retarget it + this._rememberMorpher(method, morpher) + return this + }, + + _queueNumber (method, value) { + return this._queueObject(method, new SVGNumber(value)) + }, + + // Animatable center x-axis + cx (x) { + return this._queueNumber('cx', x) + }, + + // Animatable center y-axis + cy (y) { + return this._queueNumber('cy', y) + }, + + // Add animatable move + move (x, y) { + return this.x(x).y(y) + }, + + // Add animatable center + center (x, y) { + return this.cx(x).cy(y) + }, + + // Add animatable size + size (width, height) { + // animate bbox based size for all other elements + var box + + if (!width || !height) { + box = this._element.bbox() + } + + if (!width) { + width = box.width / box.height * height + } + + if (!height) { + height = box.height / box.width * width + } + + return this + .width(width) + .height(height) + }, + + // Add animatable width + width (width) { + return this._queueNumber('width', width) + }, + + // Add animatable height + height (height) { + return this._queueNumber('height', height) + }, + + // Add animatable plot + plot (a, b, c, d) { + // Lines can be plotted with 4 arguments + if (arguments.length === 4) { + return this.plot([a, b, c, d]) + } + + // FIXME: this needs to be rewritten such that the element is only accesed + // in the init function + return this._queueObject('plot', new this._element.MorphArray(a)) + + /* + var morpher = this._element.morphArray().to(a) + + this.queue(function () { + morpher.from(this._element.array()) + }, function (pos) { + this._element.plot(morpher.at(pos)) + }) + + return this + */ + }, + + // Add leading method + leading (value) { + return this._queueNumber('leading', value) + }, + + // Add animatable viewbox + viewbox (x, y, width, height) { + return this._queueObject('viewbox', new Box(x, y, width, height)) + }, + + update (o) { + if (typeof o !== 'object') { + return this.update({ + offset: arguments[0], + color: arguments[1], + opacity: arguments[2] + }) + } + + if (o.opacity != null) this.attr('stop-opacity', o.opacity) + if (o.color != null) this.attr('stop-color', o.color) + if (o.offset != null) this.attr('offset', o.offset) + + return this + } +}) diff --git a/src/animation/Timeline.js b/src/animation/Timeline.js new file mode 100644 index 0000000..619e50a --- /dev/null +++ b/src/animation/Timeline.js @@ -0,0 +1,271 @@ +import { registerMethods } from '../utils/methods.js' +import Animator from './Animator.js' + +var time = window.performance || Date + +var makeSchedule = function (runnerInfo) { + var start = runnerInfo.start + var duration = runnerInfo.runner.duration() + var end = start + duration + return { start: start, duration: duration, end: end, runner: runnerInfo.runner } +} + +export default class Timeline { + // Construct a new timeline on the given element + constructor () { + this._timeSource = function () { + return time.now() + } + + this._dispatcher = document.createElement('div') + + // Store the timing variables + this._startTime = 0 + this._speed = 1.0 + + // Play control variables control how the animation proceeds + this._reverse = false + this._persist = 0 + + // Keep track of the running animations and their starting parameters + this._nextFrame = null + this._paused = false + this._runners = [] + this._order = [] + this._time = 0 + this._lastSourceTime = 0 + this._lastStepTime = 0 + } + + getEventTarget () { + return this._dispatcher + } + + /** + * + */ + + // schedules a runner on the timeline + schedule (runner, delay, when) { + if (runner == null) { + return this._runners.map(makeSchedule).sort(function (a, b) { + return (a.start - b.start) || (a.duration - b.duration) + }) + } + + if (!this.active()) { + this._step() + if (when == null) { + when = 'now' + } + } + + // The start time for the next animation can either be given explicitly, + // derived from the current timeline time or it can be relative to the + // last start time to chain animations direclty + var absoluteStartTime = 0 + delay = delay || 0 + + // Work out when to start the animation + if (when == null || when === 'last' || when === 'after') { + // Take the last time and increment + absoluteStartTime = this._startTime + } else if (when === 'absolute' || when === 'start') { + absoluteStartTime = delay + delay = 0 + } else if (when === 'now') { + absoluteStartTime = this._time + } else if (when === 'relative') { + let runnerInfo = this._runners[runner.id] + if (runnerInfo) { + absoluteStartTime = runnerInfo.start + delay + delay = 0 + } + } else { + throw new Error('Invalid value for the "when" parameter') + } + + // Manage runner + runner.unschedule() + runner.timeline(this) + runner.time(-delay) + + // Save startTime for next runner + this._startTime = absoluteStartTime + runner.duration() + delay + + // Save runnerInfo + this._runners[runner.id] = { + persist: this.persist(), + runner: runner, + start: absoluteStartTime + } + + // Save order and continue + this._order.push(runner.id) + this._continue() + return this + } + + // Remove the runner from this timeline + unschedule (runner) { + var index = this._order.indexOf(runner.id) + if (index < 0) return this + + delete this._runners[runner.id] + this._order.splice(index, 1) + runner.timeline(null) + return this + } + + play () { + // Now make sure we are not paused and continue the animation + this._paused = false + return this._continue() + } + + pause () { + // Cancel the next animation frame and pause + this._nextFrame = null + this._paused = true + return this + } + + stop () { + // Cancel the next animation frame and go to start + this.seek(-this._time) + return this.pause() + } + + finish () { + this.seek(Infinity) + return this.pause() + } + + speed (speed) { + if (speed == null) return this._speed + this._speed = speed + return this + } + + reverse (yes) { + var currentSpeed = this.speed() + if (yes == null) return this.speed(-currentSpeed) + + var positive = Math.abs(currentSpeed) + return this.speed(yes ? positive : -positive) + } + + seek (dt) { + this._time += dt + return this._continue() + } + + time (time) { + if (time == null) return this._time + this._time = time + return this + } + + persist (dtOrForever) { + if (dtOrForever == null) return this._persist + this._persist = dtOrForever + return this + } + + source (fn) { + if (fn == null) return this._timeSource + this._timeSource = fn + return this + } + + _step () { + // If the timeline is paused, just do nothing + if (this._paused) return + + // Get the time delta from the last time and update the time + // TODO: Deal with window.blur window.focus to pause animations + var time = this._timeSource() + var dtSource = time - this._lastSourceTime + var dtTime = this._speed * dtSource + (this._time - this._lastStepTime) + this._lastSourceTime = time + + // Update the time + this._time += dtTime + this._lastStepTime = this._time + // this.fire('time', this._time) + + // Run all of the runners directly + var runnersLeft = false + for (var i = 0, len = this._order.length; i < len; i++) { + // Get and run the current runner and ignore it if its inactive + var runnerInfo = this._runners[this._order[i]] + var runner = runnerInfo.runner + let dt = dtTime + + // Make sure that we give the actual difference + // between runner start time and now + let dtToStart = this._time - runnerInfo.start + + // Dont run runner if not started yet + if (dtToStart < 0) { + runnersLeft = true + continue + } else if (dtToStart < dt) { + // Adjust dt to make sure that animation is on point + dt = dtToStart + } + + if (!runner.active()) continue + + // If this runner is still going, signal that we need another animation + // frame, otherwise, remove the completed runner + var finished = runner.step(dt).done + if (!finished) { + runnersLeft = true + // continue + } else if (runnerInfo.persist !== true) { + // runner is finished. And runner might get removed + + // TODO: Figure out end time of runner + var endTime = runner.duration() - runner.time() + this._time + + if (endTime + this._persist < this._time) { + // Delete runner and correct index + delete this._runners[this._order[i]] + this._order.splice(i--, 1) && --len + runner.timeline(null) + } + } + } + + // Get the next animation frame to keep the simulation going + if (runnersLeft) { + this._nextFrame = Animator.frame(this._step.bind(this)) + } else { + this._nextFrame = null + } + return this + } + + // Checks if we are running and continues the animation + _continue () { + if (this._paused) return this + if (!this._nextFrame) { + this._nextFrame = Animator.frame(this._step.bind(this)) + } + return this + } + + active () { + return !!this._nextFrame + } +} + +registerMethods({ + Element: { + timeline: function () { + this._timeline = (this._timeline || new Timeline()) + return this._timeline + } + } +}) |