Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Monotone cubic interpolation #3112

Merged
merged 7 commits into from
Aug 22, 2016
Merged
3 changes: 2 additions & 1 deletion docs/03-Line-Chart.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 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
borderColor | `Color` | The color of the line.
Expand Down
113 changes: 113 additions & 0 deletions samples/line-cubicInterpolationMode.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
<!doctype html>
<html>

<head>
<title>Line Chart - Cubic interpolation mode</title>
<script src="../dist/Chart.bundle.js"></script>
<script src="http://cdnjs.cloudflare.com/ajax/libs/jquery/2.1.3/jquery.min.js"></script>
<style>
canvas{
-moz-user-select: none;
-webkit-user-select: none;
-ms-user-select: none;
}
</style>
</head>

<body>
<div style="width:75%;">
<canvas id="canvas"></canvas>
</div>
<br>
<br>
<button id="randomizeData">Randomize Data</button>
<script>

var randomScalingFactor = function() {
return Math.round(Math.random() * 100);
//return 0;
};
var randomColorFactor = function() {
return Math.round(Math.random() * 255);
};
var randomColor = function(opacity) {
return 'rgba(' + randomColorFactor() + ',' + randomColorFactor() + ',' + randomColorFactor() + ',' + (opacity || '.3') + ')';
};

var datapoints = [0, 20, 20, 60, 60, 120, NaN, 180, 120, 125, 105, 110, 170];
var config = {
type: 'line',
data: {
labels: ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12"],
datasets: [{
label: "Cubic interpolation (monotone)",
data: datapoints,
borderColor: 'rgba(255, 0, 0, 0.7)',
backgroundColor: 'rgba(0, 0, 0, 0)',
fill: false,
cubicInterpolationMode: 'monotone'
}, {
label: "Cubic interpolation (default)",
data: datapoints,
borderColor: 'rgba(0, 0, 255, 0.3)',
backgroundColor: 'rgba(0, 0, 0, 0)',
fill: false,
}, {
label: "Linear interpolation",
data: datapoints,
borderColor: 'rgba(0, 0, 0, 0.10)',
backgroundColor: 'rgba(0, 0, 0, 0)',
fill: false,
lineTension: 0
}]
},
options: {
responsive: true,
title:{
display:true,
text:'Chart.js Line Chart - Cubic interpolation mode'
},
tooltips: {
mode: 'label'
},
hover: {
mode: 'dataset'
},
scales: {
xAxes: [{
display: true,
scaleLabel: {
display: true
}
}],
yAxes: [{
display: true,
scaleLabel: {
display: true,
labelString: 'Value'
},
ticks: {
suggestedMin: -10,
suggestedMax: 200,
}
}]
}
}
};

window.onload = function() {
var ctx = document.getElementById("canvas").getContext("2d");
window.myLine = new Chart(ctx, config);
};

$('#randomizeData').click(function() {
for (var i = 0, len = datapoints.length; i < len; ++i) {
datapoints[i] = Math.random() < 0.05 ? NaN : randomScalingFactor();
}
window.myLine.update();
});

</script>
</body>

</html>
51 changes: 33 additions & 18 deletions src/controllers/controller.line.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -248,30 +249,44 @@ 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;

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is it right to remove this setting? It's helpful when the graph is zoomed and we want the control points to be allowed to go outside of the drawing area.

Copy link
Contributor Author

@MatthieuRivaud MatthieuRivaud Aug 8, 2016

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's not removed, if I've done that right. It is moved after the calls to splineCurve or splineCurveMonotone.

I also think it should be faster this way, because very few JavaScript compilers are able to remove or hoist this needToCap check (thus ASM jump) in the capIfNecessary function (called in the loop). It is also a lot more difficult to avoid/not execute/not compile the loop and call.

capControlPoint should be quite easily inlined by most recent compilers, and the loop can be completely ignored (and even not compiled at all for very recent compilers that do branch pruning). Not that it matters much for loops on < 100 items ^^,

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right, I missed that the capControlPoint was behind the setting.

You are right that it will probably be a bit faster :)

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 (meta.dataset._model.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) {
Expand Down
71 changes: 71 additions & 0 deletions src/core/core.helpers.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should use helpers.sign to support IE and Safari

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I somehow missed the compatibility table for Math.sign despite having checked Math.EPSILON ... I'll fix that.

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.model.skip || pointAfter.model.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];
Expand Down
Loading