]> git.ipfire.org Git - thirdparty/Chart.js.git/commitdiff
Implement monotone cubic interpolation (see issue #3086).
authorMatthieuRivaud <matthieu.rivaud+github@gmail.com>
Mon, 8 Aug 2016 12:01:30 +0000 (14:01 +0200)
committerMatthieuRivaud <matthieu.rivaud+github@gmail.com>
Mon, 8 Aug 2016 12:01:30 +0000 (14:01 +0200)
docs/03-Line-Chart.md
src/controllers/controller.line.js
src/core/core.helpers.js
test/core.helpers.tests.js

index 8a77ae0833b19b7d47df6065597296b47f4795ab..35e51801804e6c5b49036f852fcfab7ae4a62809 100644 (file)
@@ -39,7 +39,8 @@ label | `String` | The label for the dataset which appears in the legend and too
 xAxisID | `String` | The ID of the x axis to plot this dataset on
 yAxisID | `String` | The ID of the y axis to plot this dataset on
 fill | `Boolean` | If true, fill the area under the line
-lineTension | `Number` | Bezier curve tension of the line. Set to 0 to draw straightlines. *Note* This was renamed from 'tension' but the old name still works.
+cubicInterpolationMode | `String` | Algorithm used to interpolate a smooth curve from the discrete data points. Options are 'default' and 'monotone'. The 'default' algorithm uses a custom weighted cubic interpolation, which produces pleasant curves for all types of datasets. The 'monotone' algorithm is more suited to `y = f(x)` datasets : it preserves monotonicity (or piecewise monotonicity) of the dataset being interpolated, and ensures local extremums (if any) stay at input data points. If unknown or `undefined`, this options is treated as 'default'.
+lineTension | `Number` | Bezier curve tension of the line. Set to 0 to draw straightlines. This option is ignored if monotone cubic interpolation is used. *Note* This was renamed from 'tension' but the old name still works.
 backgroundColor | `Color` | The fill color under the line. See [Colors](#chart-configuration-colors)
 borderWidth | `Number` | The width of the line in pixels
 borderColor | `Color` | The color of the line.
index 54bbb2308fc56289d9e9dfd7158cabb3385049df..5b453b44b75c7a149e93479c66690aed3d501a2b 100644 (file)
@@ -252,26 +252,40 @@ module.exports = function(Chart) {
                        var points = (meta.data || []).filter(function(pt) { return !pt._model.skip; });
                        var i, ilen, point, model, controlPoints;
 
-                       var needToCap = me.chart.options.elements.line.capBezierPoints;
-                       function capIfNecessary(pt, min, max) {
-                               return needToCap ? Math.max(Math.min(pt, max), min) : pt;
+                       function capControlPoint(pt, min, max) {
+                               return Math.max(Math.min(pt, max), min);
                        }
 
-                       for (i=0, ilen=points.length; i<ilen; ++i) {
-                               point = points[i];
-                               model = point._model;
-                               controlPoints = helpers.splineCurve(
-                                       helpers.previousItem(points, i)._model,
-                                       model,
-                                       helpers.nextItem(points, i)._model,
-                                       meta.dataset._model.tension
-                               );
-
-                               model.controlPointPreviousX = capIfNecessary(controlPoints.previous.x, area.left, area.right);
-                               model.controlPointPreviousY = capIfNecessary(controlPoints.previous.y, area.top, area.bottom);
-                               model.controlPointNextX = capIfNecessary(controlPoints.next.x, area.left, area.right);
-                               model.controlPointNextY = capIfNecessary(controlPoints.next.y, area.top, area.bottom);
+                       if (me.chart.options.elements.line.cubicInterpolationMode == 'monotone') {
+                               helpers.splineCurveMonotone(points);
+                       }
+                       else {
+                               for (i = 0, ilen = points.length; i < ilen; ++i) {
+                                       point = points[i];
+                                       model = point._model;
+                                       controlPoints = helpers.splineCurve(
+                                               helpers.previousItem(points, i)._model,
+                                               model,
+                                               helpers.nextItem(points, i)._model,
+                                               meta.dataset._model.tension
+                                       );
+                                       model.controlPointPreviousX = controlPoints.previous.x;
+                                       model.controlPointPreviousY = controlPoints.previous.y;
+                                       model.controlPointNextX = controlPoints.next.x;
+                                       model.controlPointNextY = controlPoints.next.y;
+                               }
                        }
+
+                       if (me.chart.options.elements.line.capBezierPoints) {
+                               for (i = 0, ilen = points.length; i < ilen; ++i) {
+                                       model = points[i]._model;
+                                       model.controlPointPreviousX = capControlPoint(model.controlPointPreviousX, area.left, area.right);
+                                       model.controlPointPreviousY = capControlPoint(model.controlPointPreviousY, area.top, area.bottom);
+                                       model.controlPointNextX = capControlPoint(model.controlPointNextX, area.left, area.right);
+                                       model.controlPointNextY = capControlPoint(model.controlPointNextY, area.top, area.bottom);
+                               }
+                       }
+
                },
 
                draw: function(ease) {
index 6f634fa7c93e5862d56a845b64bd8cac1937d7ca..1108536d1fa21e3f78541151900cb2c5c0cae03e 100644 (file)
@@ -338,6 +338,77 @@ module.exports = function(Chart) {
                        }
                };
        };
+       helpers.EPSILON = Number.EPSILON || 1e-14;
+       helpers.splineCurveMonotone = function(points) {
+               // This function calculates Bézier control points in a similar way than |splineCurve|,
+               // but preserves monotonicity of the provided data and ensures no local extremums are added
+               // between the dataset discrete points due to the interpolation.
+               // See : https://en.wikipedia.org/wiki/Monotone_cubic_interpolation
+
+               var pointsWithTangents = (points || []).map(function(point) {
+                       return {
+                               model: point._model,
+                               deltaK: 0,
+                               mK: 0
+                       };
+               });
+
+               // Calculate slopes (deltaK) and initialize tangents (mK)
+               var pointsLen = pointsWithTangents.length;
+               var i, pointBefore, pointCurrent, pointAfter;
+               for (i = 0; i < pointsLen; ++i) {
+                       pointCurrent = pointsWithTangents[i];
+                       if (pointCurrent.model.skip) continue;
+                       pointBefore = i > 0 ? pointsWithTangents[i - 1] : null;
+                       pointAfter = i < pointsLen - 1 ? pointsWithTangents[i + 1] : null;
+                       if (pointAfter && !pointAfter.model.skip) {
+                               pointCurrent.deltaK = (pointAfter.model.y - pointCurrent.model.y) / (pointAfter.model.x - pointCurrent.model.x);
+                       }
+                       if (!pointBefore || pointBefore.model.skip) pointCurrent.mK = pointCurrent.deltaK;
+                       else if (!pointAfter || pointAfter.model.skip) pointCurrent.mK = pointBefore.deltaK;
+                       else if (Math.sign(pointBefore.deltaK) != Math.sign(pointCurrent.deltaK)) pointCurrent.mK = 0;
+                       else pointCurrent.mK = (pointBefore.deltaK + pointCurrent.deltaK) / 2;
+               }
+
+               // Adjust tangents to ensure monotonic properties
+               var alphaK, betaK, tauK, squaredMagnitude;
+               for (i = 0; i < pointsLen - 1; ++i) {
+                       pointCurrent = pointsWithTangents[i];
+                       pointAfter = pointsWithTangents[i + 1];
+                       if (pointCurrent.skip || pointAfter.skip) continue;
+                       if (helpers.almostEquals(pointCurrent.deltaK, 0, this.EPSILON))
+                       {
+                               pointCurrent.mK = pointAfter.mK = 0;
+                               continue;
+                       }
+                       alphaK = pointCurrent.mK / pointCurrent.deltaK;
+                       betaK = pointAfter.mK / pointCurrent.deltaK;
+                       squaredMagnitude = Math.pow(alphaK, 2) + Math.pow(betaK, 2);
+                       if (squaredMagnitude <= 9) continue;
+                       tauK = 3 / Math.sqrt(squaredMagnitude);
+                       pointCurrent.mK = alphaK * tauK * pointCurrent.deltaK;
+                       pointAfter.mK = betaK  * tauK * pointCurrent.deltaK;
+               }
+
+               // Compute control points
+               var deltaX;
+               for (i = 0; i < pointsLen; ++i) {
+                       pointCurrent = pointsWithTangents[i];
+                       if (pointCurrent.model.skip) continue;
+                       pointBefore = i > 0 ? pointsWithTangents[i - 1] : null;
+                       pointAfter = i < pointsLen - 1 ? pointsWithTangents[i + 1] : null;
+                       if (pointBefore && !pointBefore.model.skip) {
+                               deltaX = (pointCurrent.model.x - pointBefore.model.x) / 3;
+                               pointCurrent.model.controlPointPreviousX = pointCurrent.model.x - deltaX;
+                               pointCurrent.model.controlPointPreviousY = pointCurrent.model.y - deltaX * pointCurrent.mK;
+                       }
+                       if (pointAfter && !pointAfter.model.skip) {
+                               deltaX = (pointAfter.model.x - pointCurrent.model.x) / 3;
+                               pointCurrent.model.controlPointNextX = pointCurrent.model.x + deltaX;
+                               pointCurrent.model.controlPointNextY = pointCurrent.model.y + deltaX * pointCurrent.mK;
+                       }
+               }
+       };
        helpers.nextItem = function(collection, index, loop) {
                if (loop) {
                        return index >= collection.length - 1 ? collection[0] : collection[index + 1];
index ccc59c14ace2ce6f673c9c20fd8ee9da1a12e806..160313c11c3bfa5bacf0c57d6917ad55709f7853 100644 (file)
@@ -425,6 +425,40 @@ describe('Core helper tests', function() {
                });
        });
 
+       it('should spline curves with monotone cubic interpolation', function() {
+               var dataPoints = [
+                       { x:   0, y:   0, skip: false },
+                       { x:   3, y:   6, skip: false },
+                       { x:   9, y:   6, skip: false },
+                       { x:  12, y:  60, skip: false },
+                       { x:  15, y:  60, skip: false },
+                       { x:  18, y: 120, skip: false },
+                       { x: NaN, y: NaN, skip: true  },
+                       { x:  21, y: 180, skip: false },
+                       { x:  24, y: 120, skip: false },
+                       { x:  27, y: 125, skip: false },
+                       { x:  30, y: 105, skip: false },
+                       { x:  33, y: 110, skip: false },
+                       { x:  36, y: 170, skip: false }
+               ];
+               helpers.splineCurveMonotone(dataPoints);
+               expect(dataPoints).toEqual([
+                       { x:   0, y:   0, skip: false, controlPointPreviousX: undefined, controlPointPreviousY: undefined, controlPointNextX: 1        , controlPointNextY: 2         },
+                       { x:   3, y:   6, skip: false, controlPointPreviousX: 2        , controlPointPreviousY: 6        , controlPointNextX: 5        , controlPointNextY: 6         },
+                       { x:   9, y:   6, skip: false, controlPointPreviousX: 7        , controlPointPreviousY: 6        , controlPointNextX: 10       , controlPointNextY: 6         },
+                       { x:  12, y:  60, skip: false, controlPointPreviousX: 11       , controlPointPreviousY: 60       , controlPointNextX: 13       , controlPointNextY: 60        },
+                       { x:  15, y:  60, skip: false, controlPointPreviousX: 14       , controlPointPreviousY: 60       , controlPointNextX: 16       , controlPointNextY: 60        },
+                       { x:  18, y: 120, skip: false, controlPointPreviousX: 17       , controlPointPreviousY: 100      , controlPointNextX: undefined, controlPointNextY: undefined },
+                       { x: NaN, y: NaN, skip: true , controlPointPreviousX: undefined, controlPointPreviousY: undefined, controlPointNextX: undefined, controlPointNextY: undefined },
+                       { x:  21, y: 180, skip: false, controlPointPreviousX: undefined, controlPointPreviousY: undefined, controlPointNextX: 22       , controlPointNextY: 160       },
+                       { x:  24, y: 120, skip: false, controlPointPreviousX: 23       , controlPointPreviousY: 120      , controlPointNextX: 25       , controlPointNextY: 120       },
+                       { x:  27, y: 125, skip: false, controlPointPreviousX: 26       , controlPointPreviousY: 125      , controlPointNextX: 28       , controlPointNextY: 125       },
+                       { x:  30, y: 105, skip: false, controlPointPreviousX: 29       , controlPointPreviousY: 105      , controlPointNextX: 31       , controlPointNextY: 105       },
+                       { x:  33, y: 110, skip: false, controlPointPreviousX: 32       , controlPointPreviousY: 105      , controlPointNextX: 34       , controlPointNextY: 115       },
+                       { x:  36, y: 170, skip: false, controlPointPreviousX: 35       , controlPointPreviousY: 150      , controlPointNextX: undefined, controlPointNextY: undefined }
+               ]);
+       });
+
        it('should get the next or previous item in an array', function() {
                var testData = [0, 1, 2];