]> git.ipfire.org Git - thirdparty/Chart.js.git/commitdiff
Better animation when adding or removing data
authorSimon Brunel <simonbrunel@users.noreply.github.com>
Sat, 1 Oct 2016 13:38:19 +0000 (15:38 +0200)
committerEvert Timberg <evert.timberg+github@gmail.com>
Mon, 3 Oct 2016 20:01:38 +0000 (16:01 -0400)
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.

src/controllers/controller.line.js
src/controllers/controller.radar.js
src/core/core.controller.js
src/core/core.datasetController.js
test/core.datasetController.tests.js [new file with mode: 0644]

index bfc061af22bfae78c128f2ba74d037be0704415e..7c36396bb01f63ae1cadaffb9a0aa98a2cfd1458 100644 (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();
index 6232165f38b638a8cdd904dcd3328f5cff6ac091..cd62da4768e1dd5ead24a304cfd3132fcee12b52 100644 (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();
                },
index 52f24d2ce510933ee0d9d288d647b820fe9dd9f0..b2928c6b863f5245ae8885bcf1c66c23216468e7 100644 (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);
index 29b1aacea3593b958cc0b0e9299d1cb7eb716f39..2faff1b6e9c0bcdd93f010de39c98c0eafd79b50 100644 (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;
-
-                       // 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);
+                       var me = this;
+                       var dataset = me.getDataset();
+                       var data = dataset.data || (dataset.data = []);
+
+                       // 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;
diff --git a/test/core.datasetController.tests.js b/test/core.datasetController.tests.js
new file mode 100644 (file)
index 0000000..5ab9dd5
--- /dev/null
@@ -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]);
+               });
+       });
+});