]> git.ipfire.org Git - thirdparty/Chart.js.git/commitdiff
Filtering data before decimation (#8843)
authorNico-DF <difalco.nicola@gmail.com>
Wed, 7 Apr 2021 20:40:45 +0000 (22:40 +0200)
committerGitHub <noreply@github.com>
Wed, 7 Apr 2021 20:40:45 +0000 (16:40 -0400)
* Filtering data before decimation

Using only points between the currently displayed x-axis for the decimation algorithm.
Allows better resolution, especially if using a zoom

If data are outside range, they will not be displayed, hence the line graph will not show the trend at extremities

* Fix LTTB algorithm

* Adding test file

* Simplifying count algorithm for decimation plugin

src/controllers/controller.line.js
src/plugins/plugin.decimation.js
test/specs/plugin.decimation.tests.js [new file with mode: 0644]

index 53233baf707aea29c367165eac0074ba8a2d5e3e..142c1150044559459886eda8f1e47540426de883 100644 (file)
@@ -1,5 +1,5 @@
 import DatasetController from '../core/core.datasetController';
-import {isNumber, _limitValue} from '../helpers/helpers.math';
+import {_limitValue, isNumber} from '../helpers/helpers.math';
 import {_lookupByKey} from '../helpers/helpers.collection';
 
 export default class LineController extends DatasetController {
@@ -137,6 +137,7 @@ function getStartAndCountOfVisiblePoints(meta, points, animationsDisabled) {
     const {iScale, _parsed} = meta;
     const axis = iScale.axis;
     const {min, max, minDefined, maxDefined} = iScale.getUserBounds();
+
     if (minDefined) {
       start = _limitValue(Math.min(
         _lookupByKey(_parsed, iScale.axis, min).lo,
index 988526413d1476c7ea9d74b14805d5bea9bf4c21..f9bada37d24d39baf631b7e1a88988efcce5237e 100644 (file)
@@ -1,6 +1,6 @@
-import {isNullOrUndef, resolve} from '../helpers';
+import {_limitValue, _lookupByKey, isNullOrUndef, resolve} from '../helpers';
 
-function lttbDecimation(data, availableWidth, options) {
+function lttbDecimation(data, start, count, availableWidth, options) {
   /**
    * Implementation of the Largest Triangle Three Buckets algorithm.
    *
@@ -10,32 +10,43 @@ function lttbDecimation(data, availableWidth, options) {
    * The original implementation is MIT licensed.
    */
   const samples = options.samples || availableWidth;
+  // There is less points than the threshold, returning the whole array
+  if (samples >= count) {
+    return data.slice(start, start + count);
+  }
+
   const decimated = [];
 
-  const bucketWidth = (data.length - 2) / (samples - 2);
+  const bucketWidth = (count - 2) / (samples - 2);
   let sampledIndex = 0;
-  let a = 0;
+  const endIndex = start + count - 1;
+  // Starting from offset
+  let a = start;
   let i, maxAreaPoint, maxArea, area, nextA;
+
   decimated[sampledIndex++] = data[a];
 
   for (i = 0; i < samples - 2; i++) {
     let avgX = 0;
     let avgY = 0;
     let j;
-    const avgRangeStart = Math.floor((i + 1) * bucketWidth) + 1;
-    const avgRangeEnd = Math.min(Math.floor((i + 2) * bucketWidth) + 1, data.length);
+
+    // Adding offset
+    const avgRangeStart = Math.floor((i + 1) * bucketWidth) + 1 + start;
+    const avgRangeEnd = Math.min(Math.floor((i + 2) * bucketWidth) + 1, count) + start;
     const avgRangeLength = avgRangeEnd - avgRangeStart;
 
     for (j = avgRangeStart; j < avgRangeEnd; j++) {
-      avgX = data[j].x;
-      avgY = data[j].y;
+      avgX += data[j].x;
+      avgY += data[j].y;
     }
 
     avgX /= avgRangeLength;
     avgY /= avgRangeLength;
 
-    const rangeOffs = Math.floor(i * bucketWidth) + 1;
-    const rangeTo = Math.floor((i + 1) * bucketWidth) + 1;
+    // Adding offset
+    const rangeOffs = Math.floor(i * bucketWidth) + 1 + start;
+    const rangeTo = Math.floor((i + 1) * bucketWidth) + 1 + start;
     const {x: pointAx, y: pointAy} = data[a];
 
     // Note that this is changed from the original algorithm which initializes these
@@ -63,22 +74,23 @@ function lttbDecimation(data, availableWidth, options) {
   }
 
   // Include the last point
-  decimated[sampledIndex++] = data[data.length - 1];
+  decimated[sampledIndex++] = data[endIndex];
 
   return decimated;
 }
 
-function minMaxDecimation(data, availableWidth) {
+function minMaxDecimation(data, start, count, availableWidth) {
   let avgX = 0;
   let countX = 0;
   let i, point, x, y, prevX, minIndex, maxIndex, startIndex, minY, maxY;
   const decimated = [];
+  const endIndex = start + count - 1;
 
-  const xMin = data[0].x;
-  const xMax = data[data.length - 1].x;
+  const xMin = data[start].x;
+  const xMax = data[endIndex].x;
   const dx = xMax - xMin;
 
-  for (i = 0; i < data.length; ++i) {
+  for (i = start; i < start + count; ++i) {
     point = data[i];
     x = (point.x - xMin) / dx * availableWidth;
     y = point.y;
@@ -152,6 +164,27 @@ function cleanDecimatedData(chart) {
   });
 }
 
+function getStartAndCountOfVisiblePointsSimplified(meta, points) {
+  const pointCount = points.length;
+
+  let start = 0;
+  let count;
+
+  const {iScale} = meta;
+  const {min, max, minDefined, maxDefined} = iScale.getUserBounds();
+
+  if (minDefined) {
+    start = _limitValue(_lookupByKey(points, iScale.axis, min).lo, 0, pointCount - 1);
+  }
+  if (maxDefined) {
+    count = _limitValue(_lookupByKey(points, iScale.axis, max).hi + 1, start, pointCount) - start;
+  } else {
+    count = pointCount - start;
+  }
+
+  return {start, count};
+}
+
 export default {
   id: 'decimation',
 
@@ -196,7 +229,8 @@ export default {
         return;
       }
 
-      if (data.length <= 4 * availableWidth) {
+      let {start, count} = getStartAndCountOfVisiblePointsSimplified(meta, data);
+      if (count <= 4 * availableWidth) {
         // No decimation is required until we are above this threshold
         return;
       }
@@ -223,10 +257,10 @@ export default {
       let decimated;
       switch (options.algorithm) {
       case 'lttb':
-        decimated = lttbDecimation(data, availableWidth, options);
+        decimated = lttbDecimation(data, start, count, availableWidth, options);
         break;
       case 'min-max':
-        decimated = minMaxDecimation(data, availableWidth);
+        decimated = minMaxDecimation(data, start, count, availableWidth);
         break;
       default:
         throw new Error(`Unsupported decimation algorithm '${options.algorithm}'`);
diff --git a/test/specs/plugin.decimation.tests.js b/test/specs/plugin.decimation.tests.js
new file mode 100644 (file)
index 0000000..11b7d6a
--- /dev/null
@@ -0,0 +1,144 @@
+describe('Plugin.decimation', function() {
+
+  describe('auto', jasmine.fixture.specs('plugin.decimation'));
+
+  describe('lttb', function() {
+    const originalData = [
+      {x: 0, y: 0},
+      {x: 1, y: 1},
+      {x: 2, y: 2},
+      {x: 3, y: 3},
+      {x: 4, y: 4},
+      {x: 5, y: 5},
+      {x: 6, y: 6},
+      {x: 7, y: 7},
+      {x: 8, y: 8},
+      {x: 9, y: 9}];
+
+    it('should draw all element if sample is greater than data', function() {
+      var chart = window.acquireChart({
+        type: 'line',
+        data: {
+          datasets: [{
+            data: originalData,
+            label: 'dataset1'
+          }]
+        },
+        scales: {
+          x: {
+            type: 'linear',
+            min: 0,
+            max: 9
+          }
+        },
+        options: {
+          plugins: {
+            decimation: {
+              enabled: true,
+              algorithm: 'lttb',
+              samples: 100
+            }
+          }
+        }
+      }, {
+        canvas: {
+          height: 1,
+          width: 1
+        },
+        wrapper: {
+          height: 1,
+          width: 1
+        }
+      });
+
+      expect(chart.data.datasets[0].data.length).toBe(10);
+    });
+
+    it('should draw the specified number of elements', function() {
+      var chart = window.acquireChart({
+        type: 'line',
+        data: {
+          datasets: [{
+            data: originalData,
+            label: 'dataset1'
+          }]
+        },
+        options: {
+          parsing: false,
+          scales: {
+            x: {
+              type: 'linear',
+              min: 0,
+              max: 9
+            }
+          },
+          plugins: {
+            decimation: {
+              enabled: true,
+              algorithm: 'lttb',
+              samples: 7
+            }
+          }
+        }
+      }, {
+        canvas: {
+          height: 1,
+          width: 1
+        },
+        wrapper: {
+          height: 1,
+          width: 1
+        }
+      });
+
+      expect(chart.data.datasets[0].data.length).toBe(7);
+    });
+
+    it('should draw all element only in range', function() {
+      var chart = window.acquireChart({
+        type: 'line',
+        data: {
+          datasets: [{
+            data: originalData,
+            label: 'dataset1'
+          }]
+        },
+        options: {
+          parsing: false,
+          scales: {
+            x: {
+              type: 'linear',
+              min: 3,
+              max: 6
+            }
+          },
+          plugins: {
+            decimation: {
+              enabled: true,
+              algorithm: 'lttb',
+              samples: 7
+            }
+          }
+        }
+      }, {
+        canvas: {
+          height: 1,
+          width: 1
+        },
+        wrapper: {
+          height: 1,
+          width: 1
+        }
+      });
+
+      // Data range is 4 (3->6) and the first point is added
+      const expectedPoints = 5;
+      expect(chart.data.datasets[0].data.length).toBe(expectedPoints);
+      expect(chart.data.datasets[0].data[0].x).toBe(originalData[2].x);
+      expect(chart.data.datasets[0].data[1].x).toBe(originalData[3].x);
+      expect(chart.data.datasets[0].data[2].x).toBe(originalData[4].x);
+      expect(chart.data.datasets[0].data[3].x).toBe(originalData[5].x);
+      expect(chart.data.datasets[0].data[4].x).toBe(originalData[6].x);
+    });
+  });
+});