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) {
this.update(true);
},
+ /**
+ * @private
+ */
+ destroy: function() {
+ if (this._data) {
+ unlistenArrayEvents(this._data, this);
+ }
+ },
+
createMetaDataset: function() {
var me = this;
var type = me.datasetElementType;
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;
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;
--- /dev/null
+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]);
+ });
+ });
+});