diff --git a/src/core/core.animations.js b/src/core/core.animations.js index 4ee61b84b0d..00ca73d4b61 100644 --- a/src/core/core.animations.js +++ b/src/core/core.animations.js @@ -1,12 +1,13 @@ import animator from './core.animator.js'; import Animation from './core.animation.js'; import defaults from './core.defaults.js'; -import {isArray, isObject} from '../helpers/helpers.core.js'; +import {isArray, isObject, _splitKey} from '../helpers/helpers.core.js'; export default class Animations { constructor(chart, config) { this._chart = chart; this._properties = new Map(); + this._pathProperties = new Map(); this.configure(config); } @@ -34,6 +35,9 @@ export default class Animations { } }); }); + + const pathAnimatedProps = this._pathProperties; + loadPathOptions(animatedProps, pathAnimatedProps); } /** @@ -67,23 +71,15 @@ export default class Animations { */ _createAnimations(target, values) { const animatedProps = this._properties; + const pathAnimatedProps = this._pathProperties; const animations = []; const running = target.$animations || (target.$animations = {}); const props = Object.keys(values); const date = Date.now(); - let i; - for (i = props.length - 1; i >= 0; --i) { - const prop = props[i]; - if (prop.charAt(0) === '$') { - continue; - } - - if (prop === 'options') { - animations.push(...this._animateOptions(target, values)); - continue; - } - const value = values[prop]; + const manageItem = function(tgt, vals, prop, subProp) { + const key = subProp || prop; + const value = vals[key]; let animation = running[prop]; const cfg = animatedProps.get(prop); @@ -91,30 +87,52 @@ export default class Animations { if (cfg && animation.active()) { // There is an existing active animation, let's update that animation.update(cfg, value, date); - continue; - } else { - animation.cancel(); + return; } + animation.cancel(); } if (!cfg || !cfg.duration) { // not animated, set directly to new value - target[prop] = value; - continue; + tgt[key] = value; + return; } - - running[prop] = animation = new Animation(cfg, target, prop, value); + running[prop] = animation = new Animation(cfg, tgt, key, value); animations.push(animation); + }; + + let i; + for (i = props.length - 1; i >= 0; --i) { + const prop = props[i]; + if (prop.charAt(0) === '$') { + continue; + } + if (prop === 'options') { + animations.push(...this._animateOptions(target, values)); + continue; + } + const propValue = pathAnimatedProps.get(prop); + if (propValue) { + propValue.forEach(function(item) { + const newTarget = getInnerObject(target, item); + const newValues = newTarget && getInnerObject(values, item); + if (newValues) { + manageItem(newTarget, newValues, item.prop, item.key); + } + }); + } else { + manageItem(target, values, prop); + } } return animations; } /** - * Update `target` properties to new values, using configured animations - * @param {object} target - object to update - * @param {object} values - new target properties - * @returns {boolean|undefined} - `true` if animations were started - **/ + * Update `target` properties to new values, using configured animations + * @param {object} target - object to update + * @param {object} values - new target properties + * @returns {boolean|undefined} - `true` if animations were started + **/ update(target, values) { if (this._properties.size === 0) { // Nothing is animated, just apply the new values. @@ -131,6 +149,18 @@ export default class Animations { } } +function loadPathOptions(props, pathProps) { + props.forEach(function(v, k) { + const value = parserPathOptions(k); + if (value) { + const mapKey = value.path[0]; + const mapValue = pathProps.get(mapKey) || []; + mapValue.push(value); + pathProps.set(mapKey, mapValue); + } + }); +} + function awaitAll(animations, properties) { const running = []; const keys = Object.keys(properties); @@ -160,3 +190,39 @@ function resolveTargetOptions(target, newOptions) { } return options; } + +function parserPathOptions(key) { + if (key.includes('.')) { + return parseKeys(key, _splitKey(key)); + } +} + +function parseKeys(key, keys) { + const result = { + prop: key, + path: [] + }; + for (let i = 0, n = keys.length; i < n; i++) { + const k = keys[i]; + if (!k.trim().length) { // empty string + return; + } + if (i === (n - 1)) { + result.key = k; + } else { + result.path.push(k); + } + } + return result; +} + +function getInnerObject(target, pathOpts) { + let obj = target; + for (const p of pathOpts.path) { + obj = obj[p]; + if (!isObject(obj)) { + return; + } + } + return obj; +} diff --git a/test/specs/core.animations.tests.js b/test/specs/core.animations.tests.js index 6fddb20445f..bdd319694bc 100644 --- a/test/specs/core.animations.tests.js +++ b/test/specs/core.animations.tests.js @@ -93,6 +93,216 @@ describe('Chart.animations', function() { }, 300); }); + it('should update path properties to target during animation', function(done) { + const chart = { + draw: function() {}, + options: { + } + }; + const anims = new Chart.Animations(chart, {value: {properties: ['level.value'], type: 'number', duration: 500}}); + + const from = 0; + const to = 100; + const target = { + level: { + value: from + } + }; + expect(anims.update(target, { + level: { + value: to + } + })).toBeTrue(); + + const ended = function() { + const value = target.level.value; + expect(value === to).toBeTrue(); + Chart.animator.remove(chart); + done(); + }; + + Chart.animator.listen(chart, 'complete', ended); + Chart.animator.start(chart); + setTimeout(function() { + const value = target.level.value; + expect(value > from).toBeTrue(); + expect(value < to).toBeTrue(); + }, 250); + }); + + it('should update multiple path properties with the same root to target during animation', function(done) { + const chart = { + draw: function() {}, + options: { + } + }; + const anims = new Chart.Animations(chart, {value: {properties: ['level.value1', 'level.value2'], type: 'number', duration: 500}}); + + const from = 0; + const to = 100; + const target = { + level: { + value1: from, + value2: from + } + }; + expect(anims.update(target, { + level: { + value1: to, + value2: to + } + })).toBeTrue(); + + const ended = function() { + const value1 = target.level.value1; + expect(value1 === to).toBeTrue(); + const value2 = target.level.value2; + expect(value2 === to).toBeTrue(); + Chart.animator.remove(chart); + done(); + }; + + Chart.animator.listen(chart, 'complete', ended); + Chart.animator.start(chart); + setTimeout(function() { + const value1 = target.level.value1; + const value2 = target.level.value2; + expect(value1 > from).toBeTrue(); + expect(value1 < to).toBeTrue(); + expect(value2 > from).toBeTrue(); + expect(value2 < to).toBeTrue(); + }, 250); + }); + + it('should not update path properties to target during animation because not an object', function() { + const chart = { + draw: function() {}, + options: { + } + }; + const anims = new Chart.Animations(chart, {value: {properties: ['level.value'], type: 'number'}}); + + const from = 0; + const to = 100; + const target = { + level: from + }; + expect(anims.update(target, { + level: to + })).toBeUndefined(); + }); + + it('should not update path properties to target during animation because missing target', function() { + const chart = { + draw: function() {}, + options: { + } + }; + const anims = new Chart.Animations(chart, {value: {properties: ['level.value'], type: 'number'}}); + + const from = 0; + const to = 100; + const target = { + foo: from + }; + + expect(anims.update(target, { + foo: to + })).toBeUndefined(); + }); + + it('should not update path properties to target during animation because properties not consistent', function() { + const chart = { + draw: function() {}, + options: { + } + }; + const anims = new Chart.Animations(chart, {value: {properties: ['.value', 'value.', 'value..end'], type: 'number'}}); + expect(anims._pathProperties.size === 0).toBeTrue(); + }); + + it('should update path (2 levels) properties to target during animation', function(done) { + const chart = { + draw: function() {}, + options: { + } + }; + const anims = new Chart.Animations(chart, {value: {properties: ['level1.level2.value'], type: 'number', duration: 500}}); + + const from = 0; + const to = 100; + const target = { + level1: { + level2: { + value: from + } + } + }; + expect(anims.update(target, { + level1: { + level2: { + value: to + } + } + })).toBeTrue(); + + const ended = function() { + const value = target.level1.level2.value; + expect(value === to).toBeTrue(); + Chart.animator.remove(chart); + done(); + }; + + Chart.animator.listen(chart, 'complete', ended); + Chart.animator.start(chart); + setTimeout(function() { + const value = target.level1.level2.value; + expect(value > from).toBeTrue(); + expect(value < to).toBeTrue(); + }, 250); + }); + + it('should update path properties to target options during animation', function(done) { + const chart = { + draw: function() {}, + options: { + } + }; + const anims = new Chart.Animations(chart, {value: {properties: ['level.value'], type: 'number', duration: 500}}); + + const from = 0; + const to = 100; + const target = { + options: { + level: { + value: from + } + } + }; + expect(anims.update(target, { + options: { + level: { + value: to + } + } + })).toBeTrue(); + + const ended = function() { + const value = target.options.level.value; + expect(value === to).toBeTrue(); + Chart.animator.remove(chart); + done(); + }; + + Chart.animator.listen(chart, 'complete', ended); + Chart.animator.start(chart); + setTimeout(function() { + const value = target.options.level.value; + expect(value > from).toBeTrue(); + expect(value < to).toBeTrue(); + }, 250); + }); + it('should not assign shared options to target when animations are cancelled', function(done) { const chart = { draw: function() {},