Skip to content
Open
116 changes: 91 additions & 25 deletions src/core/core.animations.js
Original file line number Diff line number Diff line change
@@ -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);
}

Expand Down Expand Up @@ -34,6 +35,9 @@ export default class Animations {
}
});
});

const pathAnimatedProps = this._pathProperties;
loadPathOptions(animatedProps, pathAnimatedProps);
}

/**
Expand Down Expand Up @@ -67,54 +71,68 @@ 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);

if (animation) {
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.
Expand All @@ -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);
Expand Down Expand Up @@ -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;
}
210 changes: 210 additions & 0 deletions test/specs/core.animations.tests.js
Original file line number Diff line number Diff line change
Expand Up @@ -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() {},
Expand Down