Better animation when adding or removing data

In order to simulate real-time chart updates (i.e. horizontal animation), it's necessary to distinguish a removed or added value from a simple update. The dataset controller now hooks array methods that alter the data array length to synchronize metadata accordingly. Also remove the duplicate calls of updateBezierControlPoints() for line and radar charts.
This commit is contained in:
Simon Brunel
2016-10-01 15:38:19 +02:00
committed by Evert Timberg
parent 9deebf8371
commit 1484520692
5 changed files with 362 additions and 42 deletions

View File

@@ -34,19 +34,6 @@ module.exports = function(Chart) {
dataElementType: Chart.elements.Point,
addElementAndReset: function(index) {
var me = this;
var options = me.chart.options;
var meta = me.getMeta();
Chart.DatasetController.prototype.addElementAndReset.call(me, index);
// Make sure bezier control points are updated
if (lineEnabled(me.getDataset(), options) && meta.dataset._model.tension !== 0) {
me.updateBezierControlPoints();
}
},
update: function(reset) {
var me = this;
var meta = me.getMeta();

View File

@@ -24,13 +24,6 @@ module.exports = function(Chart) {
linkScales: helpers.noop,
addElementAndReset: function(index) {
Chart.DatasetController.prototype.addElementAndReset.call(this, index);
// Make sure bezier control points are updated
this.updateBezierControlPoints();
},
update: function(reset) {
var me = this;
var meta = me.getMeta();
@@ -79,7 +72,6 @@ module.exports = function(Chart) {
me.updateElement(point, index, reset);
}, me);
// Update bezier control points
me.updateBezierControlPoints();
},

View File

@@ -684,10 +684,20 @@ module.exports = function(Chart) {
destroy: function() {
var me = this;
var canvas = me.chart.canvas;
var meta, i, ilen;
me.stop();
me.clear();
// dataset controllers need to cleanup associated data
for (i = 0, ilen = me.data.datasets.length; i < ilen; ++i) {
meta = me.getDatasetMeta(i);
if (meta.controller) {
meta.controller.destroy();
meta.controller = null;
}
}
if (canvas) {
helpers.unbindEvents(me, me.events);
helpers.removeResizeListener(canvas.parentNode);

View File

@@ -3,7 +3,77 @@
module.exports = function(Chart) {
var helpers = Chart.helpers;
var noop = helpers.noop;
var arrayEvents = ['push', 'pop', 'shift', 'splice', 'unshift'];
/**
* Hooks the array methods that add or remove values ('push', pop', 'shift', 'splice',
* 'unshift') and notify the listener AFTER the array has been altered. Listeners are
* called on the 'onData*' callbacks (e.g. onDataPush, etc.) with same arguments.
*/
function listenArrayEvents(array, listener) {
if (array._chartjs) {
array._chartjs.listeners.push(listener);
return;
}
Object.defineProperty(array, '_chartjs', {
configurable: true,
enumerable: false,
value: {
listeners: [listener]
}
});
arrayEvents.forEach(function(key) {
var method = 'onData' + key.charAt(0).toUpperCase() + key.slice(1);
var base = array[key];
Object.defineProperty(array, key, {
configurable: true,
enumerable: false,
value: function() {
var args = Array.prototype.slice.call(arguments);
var res = base.apply(this, args);
helpers.each(array._chartjs.listeners, function(object) {
if (typeof object[method] === 'function') {
object[method].apply(object, args);
}
});
return res;
}
});
});
}
/**
* Removes the given array event listener and cleanup extra attached properties (such as
* the _chartjs stub and overridden methods) if array doesn't have any more listeners.
*/
function unlistenArrayEvents(array, listener) {
var stub = array._chartjs;
if (!stub) {
return;
}
var listeners = stub.listeners;
var index = listeners.indexOf(listener);
if (index !== -1) {
listeners.splice(index, 1);
}
if (listeners.length > 0) {
return;
}
arrayEvents.forEach(function(key) {
delete array[key];
});
delete array._chartjs;
}
// Base class for all dataset controllers (line, bar, etc)
Chart.DatasetController = function(chart, datasetIndex) {
@@ -65,6 +135,15 @@ module.exports = function(Chart) {
this.update(true);
},
/**
* @private
*/
destroy: function() {
if (this._data) {
unlistenArrayEvents(this._data, this);
}
},
createMetaDataset: function() {
var me = this;
var type = me.datasetElementType;
@@ -92,39 +171,42 @@ module.exports = function(Chart) {
var i, ilen;
for (i=0, ilen=data.length; i<ilen; ++i) {
metaData[i] = metaData[i] || me.createMetaData(meta, i);
metaData[i] = metaData[i] || me.createMetaData(i);
}
meta.dataset = meta.dataset || me.createMetaDataset();
},
addElementAndReset: function(index) {
var me = this;
var element = me.createMetaData(index);
me.getMeta().data.splice(index, 0, element);
me.updateElement(element, index, true);
var element = this.createMetaData(index);
this.getMeta().data.splice(index, 0, element);
this.updateElement(element, index, true);
},
buildOrUpdateElements: function() {
// Handle the number of data points changing
var meta = this.getMeta(),
md = meta.data,
numData = this.getDataset().data.length,
numMetaData = md.length;
var me = this;
var dataset = me.getDataset();
var data = dataset.data || (dataset.data = []);
// Make sure that we handle number of datapoints changing
if (numData < numMetaData) {
// Remove excess bars for data points that have been removed
md.splice(numData, numMetaData - numData);
} else if (numData > numMetaData) {
// Add new elements
for (var index = numMetaData; index < numData; ++index) {
this.addElementAndReset(index);
// In order to correctly handle data addition/deletion animation (an thus simulate
// real-time charts), we need to monitor these data modifications and synchronize
// the internal meta data accordingly.
if (me._data !== data) {
if (me._data) {
// This case happens when the user replaced the data array instance.
unlistenArrayEvents(me._data, me);
}
listenArrayEvents(data, me);
me._data = data;
}
// Re-sync meta data in case the user replaced the data array or if we missed
// any updates and so make sure that we handle number of datapoints changing.
me.resyncElements();
},
update: noop,
update: helpers.noop,
draw: function(ease) {
var easingDecimal = ease || 1;
@@ -156,8 +238,69 @@ module.exports = function(Chart) {
model.backgroundColor = custom.hoverBackgroundColor ? custom.hoverBackgroundColor : valueOrDefault(dataset.hoverBackgroundColor, index, getHoverColor(model.backgroundColor));
model.borderColor = custom.hoverBorderColor ? custom.hoverBorderColor : valueOrDefault(dataset.hoverBorderColor, index, getHoverColor(model.borderColor));
model.borderWidth = custom.hoverBorderWidth ? custom.hoverBorderWidth : valueOrDefault(dataset.hoverBorderWidth, index, model.borderWidth);
}
},
/**
* @private
*/
resyncElements: function() {
var me = this;
var meta = me.getMeta();
var data = me.getDataset().data;
var numMeta = meta.data.length;
var numData = data.length;
if (numData < numMeta) {
meta.data.splice(numData, numMeta - numData);
} else if (numData > numMeta) {
me.insertElements(numMeta, numData - numMeta);
}
},
/**
* @private
*/
insertElements: function(start, count) {
for (var i=0; i<count; ++i) {
this.addElementAndReset(start + i);
}
},
/**
* @private
*/
onDataPush: function() {
this.insertElements(this.getDataset().data.length-1, arguments.length);
},
/**
* @private
*/
onDataPop: function() {
this.getMeta().data.pop();
},
/**
* @private
*/
onDataShift: function() {
this.getMeta().data.shift();
},
/**
* @private
*/
onDataSplice: function(start, count) {
this.getMeta().data.splice(start, count);
this.insertElements(start, arguments.length - 2);
},
/**
* @private
*/
onDataUnshift: function() {
this.insertElements(0, arguments.length);
}
});
Chart.DatasetController.extend = helpers.inherits;

View File

@@ -0,0 +1,188 @@
describe('Chart.DatasetController', function() {
it('should listen for dataset data insertions or removals', function() {
var data = [0, 1, 2, 3, 4, 5];
var chart = acquireChart({
type: 'line',
data: {
datasets: [{
data: data
}]
}
});
var controller = chart.getDatasetMeta(0).controller;
var methods = [
'onDataPush',
'onDataPop',
'onDataShift',
'onDataSplice',
'onDataUnshift'
];
methods.forEach(function(method) {
spyOn(controller, method);
});
data.push(6, 7, 8);
data.push(9);
data.pop();
data.shift();
data.shift();
data.shift();
data.splice(1, 4, 10, 11);
data.unshift(12, 13, 14, 15);
data.unshift(16, 17);
[2, 1, 3, 1, 2].forEach(function(expected, index) {
expect(controller[methods[index]].calls.count()).toBe(expected);
});
});
it('should synchronize metadata when data are inserted or removed', function() {
var data = [0, 1, 2, 3, 4, 5];
var chart = acquireChart({
type: 'line',
data: {
datasets: [{
data: data
}]
}
});
var meta = chart.getDatasetMeta(0);
var first, second, last;
first = meta.data[0];
last = meta.data[5];
data.push(6, 7, 8);
data.push(9);
expect(meta.data.length).toBe(10);
expect(meta.data[0]).toBe(first);
expect(meta.data[5]).toBe(last);
last = meta.data[9];
data.pop();
expect(meta.data.length).toBe(9);
expect(meta.data[0]).toBe(first);
expect(meta.data.indexOf(last)).toBe(-1);
last = meta.data[8];
data.shift();
data.shift();
data.shift();
expect(meta.data.length).toBe(6);
expect(meta.data.indexOf(first)).toBe(-1);
expect(meta.data[5]).toBe(last);
first = meta.data[0];
second = meta.data[1];
last = meta.data[5];
data.splice(1, 4, 10, 11);
expect(meta.data.length).toBe(4);
expect(meta.data[0]).toBe(first);
expect(meta.data[3]).toBe(last);
expect(meta.data.indexOf(second)).toBe(-1);
data.unshift(12, 13, 14, 15);
data.unshift(16, 17);
expect(meta.data.length).toBe(10);
expect(meta.data[6]).toBe(first);
expect(meta.data[9]).toBe(last);
});
it('should re-synchronize metadata when the data object reference changes', function() {
var data0 = [0, 1, 2, 3, 4, 5];
var data1 = [6, 7, 8];
var chart = acquireChart({
type: 'line',
data: {
datasets: [{
data: data0
}]
}
});
var meta = chart.getDatasetMeta(0);
expect(meta.data.length).toBe(6);
chart.data.datasets[0].data = data1;
chart.update();
expect(meta.data.length).toBe(3);
data1.push(9, 10, 11);
expect(meta.data.length).toBe(6);
});
it('should re-synchronize metadata when data are unusually altered', function() {
var data = [0, 1, 2, 3, 4, 5];
var chart = acquireChart({
type: 'line',
data: {
datasets: [{
data: data
}]
}
});
var meta = chart.getDatasetMeta(0);
expect(meta.data.length).toBe(6);
data.length = 2;
chart.update();
expect(meta.data.length).toBe(2);
data.length = 42;
chart.update();
expect(meta.data.length).toBe(42);
});
it('should cleanup attached properties when the reference changes or when the chart is destroyed', function() {
var data0 = [0, 1, 2, 3, 4, 5];
var data1 = [6, 7, 8];
var chart = acquireChart({
type: 'line',
data: {
datasets: [{
data: data0
}]
}
});
var hooks = ['push', 'pop', 'shift', 'splice', 'unshift'];
expect(data0._chartjs).toBeDefined();
hooks.forEach(function(hook) {
expect(data0[hook]).not.toBe(Array.prototype[hook]);
});
expect(data1._chartjs).not.toBeDefined();
hooks.forEach(function(hook) {
expect(data1[hook]).toBe(Array.prototype[hook]);
});
chart.data.datasets[0].data = data1;
chart.update();
expect(data0._chartjs).not.toBeDefined();
hooks.forEach(function(hook) {
expect(data0[hook]).toBe(Array.prototype[hook]);
});
expect(data1._chartjs).toBeDefined();
hooks.forEach(function(hook) {
expect(data1[hook]).not.toBe(Array.prototype[hook]);
});
chart.destroy();
expect(data1._chartjs).not.toBeDefined();
hooks.forEach(function(hook) {
expect(data1[hook]).toBe(Array.prototype[hook]);
});
});
});