From 2409908027a3762f142a04e7583bd2e88681a867 Mon Sep 17 00:00:00 2001 From: MatthieuRivaud Date: Mon, 8 Aug 2016 14:01:30 +0200 Subject: [PATCH 1/7] Implement monotone cubic interpolation (see issue #3086). --- docs/03-Line-Chart.md | 3 +- src/controllers/controller.line.js | 48 +++++++++++++------- src/core/core.helpers.js | 71 ++++++++++++++++++++++++++++++ test/core.helpers.tests.js | 34 ++++++++++++++ 4 files changed, 138 insertions(+), 18 deletions(-) diff --git a/docs/03-Line-Chart.md b/docs/03-Line-Chart.md index 8a77ae0833b..35e51801804 100644 --- a/docs/03-Line-Chart.md +++ b/docs/03-Line-Chart.md @@ -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. diff --git a/src/controllers/controller.line.js b/src/controllers/controller.line.js index 54bbb2308fc..5b453b44b75 100644 --- a/src/controllers/controller.line.js +++ b/src/controllers/controller.line.js @@ -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 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]; diff --git a/test/core.helpers.tests.js b/test/core.helpers.tests.js index ccc59c14ace..160313c11c3 100644 --- a/test/core.helpers.tests.js +++ b/test/core.helpers.tests.js @@ -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]; From d06fbc772f727ae7ab3fec7d7b18f9271d4df6ee Mon Sep 17 00:00:00 2001 From: MatthieuRivaud Date: Mon, 8 Aug 2016 15:35:46 +0200 Subject: [PATCH 2/7] - Added dataset option |cubicInterpolationMode| to allow for curves with different interpolation modes on the same graph (updated doc accordingly) - Added new sample file to demonstrate the monotone cubic interpolation mode - Fixed a typo in a comment in updateBezierControlPoints --- docs/03-Line-Chart.md | 2 +- samples/line-cubicInterpolationMode.html | 113 +++++++++++++++++++++++ src/controllers/controller.line.js | 5 +- 3 files changed, 117 insertions(+), 3 deletions(-) create mode 100644 samples/line-cubicInterpolationMode.html diff --git a/docs/03-Line-Chart.md b/docs/03-Line-Chart.md index 35e51801804..f62a20879df 100644 --- a/docs/03-Line-Chart.md +++ b/docs/03-Line-Chart.md @@ -39,7 +39,7 @@ 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 -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'. +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 left untouched (`undefined`), the global `options.elements.line.cubicInterpolationMode` property is used. 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 diff --git a/samples/line-cubicInterpolationMode.html b/samples/line-cubicInterpolationMode.html new file mode 100644 index 00000000000..97dac46ca43 --- /dev/null +++ b/samples/line-cubicInterpolationMode.html @@ -0,0 +1,113 @@ + + + + + Line Chart - Cubic interpolation mode + + + + + + +
+ +
+
+
+ + + + + \ No newline at end of file diff --git a/src/controllers/controller.line.js b/src/controllers/controller.line.js index 5b453b44b75..b103907eae5 100644 --- a/src/controllers/controller.line.js +++ b/src/controllers/controller.line.js @@ -90,6 +90,7 @@ module.exports = function(Chart) { borderJoinStyle: custom.borderJoinStyle ? custom.borderJoinStyle : (dataset.borderJoinStyle || lineElementOptions.borderJoinStyle), fill: custom.fill ? custom.fill : (dataset.fill !== undefined ? dataset.fill : lineElementOptions.fill), steppedLine: custom.steppedLine ? custom.steppedLine : helpers.getValueOrDefault(dataset.steppedLine, lineElementOptions.stepped), + cubicInterpolationMode: custom.cubicInterpolationMode ? custom.cubicInterpolationMode : helpers.getValueOrDefault(dataset.cubicInterpolationMode, lineElementOptions.cubicInterpolationMode), // Scale scaleTop: scale.top, scaleBottom: scale.bottom, @@ -248,7 +249,7 @@ module.exports = function(Chart) { var meta = me.getMeta(); var area = me.chart.chartArea; - // only consider points that are drawn in case the spanGaps option is ued + // Only consider points that are drawn in case the spanGaps option is used var points = (meta.data || []).filter(function(pt) { return !pt._model.skip; }); var i, ilen, point, model, controlPoints; @@ -256,7 +257,7 @@ module.exports = function(Chart) { return Math.max(Math.min(pt, max), min); } - if (me.chart.options.elements.line.cubicInterpolationMode == 'monotone') { + if (meta.dataset._model.cubicInterpolationMode == 'monotone') { helpers.splineCurveMonotone(points); } else { From 4aabc0cb82ab6af17e6e271ea19df072e0ecaa8c Mon Sep 17 00:00:00 2001 From: MatthieuRivaud Date: Mon, 8 Aug 2016 15:56:53 +0200 Subject: [PATCH 3/7] Recovered a fix lost when branching. --- src/core/core.helpers.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/core/core.helpers.js b/src/core/core.helpers.js index 1108536d1fa..b77035dcf68 100644 --- a/src/core/core.helpers.js +++ b/src/core/core.helpers.js @@ -375,7 +375,7 @@ module.exports = function(Chart) { for (i = 0; i < pointsLen - 1; ++i) { pointCurrent = pointsWithTangents[i]; pointAfter = pointsWithTangents[i + 1]; - if (pointCurrent.skip || pointAfter.skip) continue; + if (pointCurrent.model.skip || pointAfter.model.skip) continue; if (helpers.almostEquals(pointCurrent.deltaK, 0, this.EPSILON)) { pointCurrent.mK = pointAfter.mK = 0; From ef66bf5e6d1293e9ccc104c87f01f9578e5eea15 Mon Sep 17 00:00:00 2001 From: MatthieuRivaud Date: Mon, 8 Aug 2016 16:33:32 +0200 Subject: [PATCH 4/7] Fixed splineCurveMonotone unit test --- test/core.helpers.tests.js | 146 +++++++++++++++++++++++++++++++++---- 1 file changed, 131 insertions(+), 15 deletions(-) diff --git a/test/core.helpers.tests.js b/test/core.helpers.tests.js index 160313c11c3..17b77678e0f 100644 --- a/test/core.helpers.tests.js +++ b/test/core.helpers.tests.js @@ -442,21 +442,137 @@ describe('Core helper tests', function() { { 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 } - ]); + expect(dataPoints).toEqual([{ + _model: { + x: 0, + y: 0, + skip: false, + controlPointNextX: 1, + controlPointNextY: 2 + } + }, + { + _model: { + x: 3, + y: 6, + skip: false, + controlPointPreviousX: 2, + controlPointPreviousY: 6, + controlPointNextX: 5, + controlPointNextY: 6 + } + }, + { + _model: { + x: 9, + y: 6, + skip: false, + controlPointPreviousX: 7, + controlPointPreviousY: 6, + controlPointNextX: 10, + controlPointNextY: 6 + } + }, + { + _model: { + x: 12, + y: 60, + skip: false, + controlPointPreviousX: 11, + controlPointPreviousY: 60, + controlPointNextX: 13, + controlPointNextY: 60 + } + }, + { + _model: { + x: 15, + y: 60, + skip: false, + controlPointPreviousX: 14, + controlPointPreviousY: 60, + controlPointNextX: 16, + controlPointNextY: 60 + } + }, + { + _model: { + x: 18, + y: 120, + skip: false, + controlPointPreviousX: 17, + controlPointPreviousY: 100 + } + }, + { + _model: { + x: null, + y: null, + skip: true + } + }, + { + _model: { + x: 21, + y: 180, + skip: false, + controlPointNextX: 22, + controlPointNextY: 160 + } + }, + { + _model: { + x: 24, + y: 120, + skip: false, + controlPointPreviousX: 23, + controlPointPreviousY: 120, + controlPointNextX: 25, + controlPointNextY: 120 + } + }, + { + _model: { + x: 27, + y: 125, + skip: false, + controlPointPreviousX: 26, + controlPointPreviousY: 125, + controlPointNextX: 28, + controlPointNextY: 125 + } + }, + { + _model: { + x: 30, + y: 105, + skip: false, + controlPointPreviousX: 29, + controlPointPreviousY: 105, + controlPointNextX: 31, + controlPointNextY: 105 + } + }, + { + _model: { + x: 33, + y: 110, + skip: false, + controlPointPreviousX: 32, + controlPointPreviousY: 105, + controlPointNextX: 34, + controlPointNextY: 115 + } + }, + { + _model: { + x: 36, + y: 170, + skip: false, + controlPointPreviousX: 35, + controlPointPreviousY: 150 + } + }]); }); it('should get the next or previous item in an array', function() { From 566ede1ecd76b3ec30e8205928fa9b8cc4421bed Mon Sep 17 00:00:00 2001 From: MatthieuRivaud Date: Mon, 8 Aug 2016 17:02:58 +0200 Subject: [PATCH 5/7] Fixed splineCurveMonotone unit test (for real this time) --- test/core.helpers.tests.js | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/test/core.helpers.tests.js b/test/core.helpers.tests.js index 17b77678e0f..b8278171e8f 100644 --- a/test/core.helpers.tests.js +++ b/test/core.helpers.tests.js @@ -427,19 +427,19 @@ 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 } + { _model: { x: 0, y: 0, skip: false } }, + { _model: { x: 3, y: 6, skip: false } }, + { _model: { x: 9, y: 6, skip: false } }, + { _model: { x: 12, y: 60, skip: false } }, + { _model: { x: 15, y: 60, skip: false } }, + { _model: { x: 18, y: 120, skip: false } }, + { _model: { x: null, y: null, skip: true } }, + { _model: { x: 21, y: 180, skip: false } }, + { _model: { x: 24, y: 120, skip: false } }, + { _model: { x: 27, y: 125, skip: false } }, + { _model: { x: 30, y: 105, skip: false } }, + { _model: { x: 33, y: 110, skip: false } }, + { _model: { x: 36, y: 170, skip: false } } ]; helpers.splineCurveMonotone(dataPoints); expect(dataPoints).toEqual([{ From 3869dd110aebad93d4ae84401f5e41c665bcd0b2 Mon Sep 17 00:00:00 2001 From: MatthieuRivaud Date: Tue, 9 Aug 2016 09:28:09 +0200 Subject: [PATCH 6/7] Fix spanGaps probing in updateBezierControlPoints --- src/controllers/controller.line.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/controllers/controller.line.js b/src/controllers/controller.line.js index b103907eae5..567083e32e8 100644 --- a/src/controllers/controller.line.js +++ b/src/controllers/controller.line.js @@ -250,7 +250,8 @@ module.exports = function(Chart) { var area = me.chart.chartArea; // Only consider points that are drawn in case the spanGaps option is used - var points = (meta.data || []).filter(function(pt) { return !pt._model.skip; }); + var points = (meta.data || []); + if (meta.dataset._model.spanGaps) points = points.filter(function(pt) { return !pt._model.skip; }); var i, ilen, point, model, controlPoints; function capControlPoint(pt, min, max) { From 7635ab4a4e254217ea0eaf8da5aed3410548c4ea Mon Sep 17 00:00:00 2001 From: MatthieuRivaud Date: Tue, 9 Aug 2016 09:32:10 +0200 Subject: [PATCH 7/7] Calling |helpers.sign| instead of |Math.sign| directly for IE and Safari compatibility. --- src/core/core.helpers.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/core/core.helpers.js b/src/core/core.helpers.js index b77035dcf68..36cd4d37909 100644 --- a/src/core/core.helpers.js +++ b/src/core/core.helpers.js @@ -366,7 +366,7 @@ module.exports = function(Chart) { } 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 if (this.sign(pointBefore.deltaK) != this.sign(pointCurrent.deltaK)) pointCurrent.mK = 0; else pointCurrent.mK = (pointBefore.deltaK + pointCurrent.deltaK) / 2; }