Merge pull request #627 from ammojamo/develop

Monotone cubic interpolation
This commit is contained in:
Gion Kunz 2016-04-08 10:40:44 +02:00
commit 13982b066f
4 changed files with 296 additions and 36 deletions

View File

@ -995,4 +995,71 @@ var Chartist = {
};
};
/**
* Splits a list of coordinates and associated values into segments. Each returned segment contains a pathCoordinates
* valueData property describing the segment.
*
* With the default options, segments consist of contiguous sets of points that do not have an undefined value. Any
* points with undefined values are discarded.
*
* **Options**
* The following options are used to determine how segments are formed
* ```javascript
* var options = {
* // If fillHoles is true, undefined values are simply discarded without creating a new segment. Assuming other options are default, this returns single segment.
* fillHoles: false,
* // If increasingX is true, the coordinates in all segments have strictly increasing x-values.
* increasingX: false
* };
* ```
*
* @memberof Chartist.Core
* @param {Array} pathCoordinates List of point coordinates to be split in the form [x1, y1, x2, y2 ... xn, yn]
* @param {Array} values List of associated point values in the form [v1, v2 .. vn]
* @param {Object} options Options set by user
* @return {Array} List of segments, each containing a pathCoordinates and valueData property.
*/
Chartist.splitIntoSegments = function(pathCoordinates, valueData, options) {
var defaultOptions = {
increasingX: false,
fillHoles: false
};
options = Chartist.extend({}, defaultOptions, options);
var segments = [];
var hole = true;
for(var i = 0; i < pathCoordinates.length; i += 2) {
// If this value is a "hole" we set the hole flag
if(valueData[i / 2].value === undefined) {
if(!options.fillHoles) {
hole = true;
}
} else {
if(options.increasingX && i >= 2 && pathCoordinates[i] <= pathCoordinates[i-2]) {
// X is not increasing, so we need to make sure we start a new segment
hole = true;
}
// If it's a valid value we need to check if we're coming out of a hole and create a new empty segment
if(hole) {
segments.push({
pathCoordinates: [],
valueData: []
});
// As we have a valid value now, we are not in a "hole" anymore
hole = false;
}
// Add to the segment pathCoordinates and valueData
segments[segments.length - 1].pathCoordinates.push(pathCoordinates[i], pathCoordinates[i + 1]);
segments[segments.length - 1].valueData.push(valueData[i / 2]);
}
}
return segments;
};
}(window, document, Chartist));

View File

@ -162,43 +162,12 @@
var t = Math.min(1, Math.max(0, options.tension)),
c = 1 - t;
// This function will help us to split pathCoordinates and valueData into segments that also contain pathCoordinates
// and valueData. This way the existing functions can be reused and the segment paths can be joined afterwards.
// This functionality is necessary to treat "holes" in the line charts
function splitIntoSegments(pathCoordinates, valueData) {
var segments = [];
var hole = true;
for(var i = 0; i < pathCoordinates.length; i += 2) {
// If this value is a "hole" we set the hole flag
if(valueData[i / 2].value === undefined) {
if(!options.fillHoles) {
hole = true;
}
} else {
// If it's a valid value we need to check if we're coming out of a hole and create a new empty segment
if(hole) {
segments.push({
pathCoordinates: [],
valueData: []
});
// As we have a valid value now, we are not in a "hole" anymore
hole = false;
}
// Add to the segment pathCoordinates and valueData
segments[segments.length - 1].pathCoordinates.push(pathCoordinates[i], pathCoordinates[i + 1]);
segments[segments.length - 1].valueData.push(valueData[i / 2]);
}
}
return segments;
}
return function cardinal(pathCoordinates, valueData) {
// First we try to split the coordinates into segments
// This is necessary to treat "holes" in line charts
var segments = splitIntoSegments(pathCoordinates, valueData);
var segments = Chartist.splitIntoSegments(pathCoordinates, valueData, {
fillHoles: options.fillHoles
});
if(!segments.length) {
// If there were no segments return 'Chartist.Interpolation.none'
@ -268,6 +237,137 @@
};
};
/**
* Monotone Cubic spline interpolation produces a smooth curve which preserves monotonicity. Unlike cardinal splines, the curve will not extend beyond the range of y-values of the original data points.
*
* Monotone Cubic splines can only be created if there are more than two data points. If this is not the case this smoothing will fallback to `Chartist.Smoothing.none`.
*
* The x-values of subsequent points must be increasing to fit a Monotone Cubic spline. If this condition is not met for a pair of adjacent points, then there will be a break in the curve between those data points.
*
* All smoothing functions within Chartist are factory functions that accept an options parameter.
*
* @example
* var chart = new Chartist.Line('.ct-chart', {
* labels: [1, 2, 3, 4, 5],
* series: [[1, 2, 8, 1, 7]]
* }, {
* lineSmooth: Chartist.Interpolation.monotoneCubic({
* fillHoles: false
* })
* });
*
* @memberof Chartist.Interpolation
* @param {Object} options The options of the monotoneCubic factory function.
* @return {Function}
*/
Chartist.Interpolation.monotoneCubic = function(options) {
var defaultOptions = {
fillHoles: false
};
options = Chartist.extend({}, defaultOptions, options);
return function monotoneCubic(pathCoordinates, valueData) {
// First we try to split the coordinates into segments
// This is necessary to treat "holes" in line charts
var segments = Chartist.splitIntoSegments(pathCoordinates, valueData, {
fillHoles: options.fillHoles,
increasingX: true
});
if(!segments.length) {
// If there were no segments return 'Chartist.Interpolation.none'
return Chartist.Interpolation.none()([]);
} else if(segments.length > 1) {
// If the split resulted in more that one segment we need to interpolate each segment individually and join them
// afterwards together into a single path.
var paths = [];
// For each segment we will recurse the monotoneCubic fn function
segments.forEach(function(segment) {
paths.push(monotoneCubic(segment.pathCoordinates, segment.valueData));
});
// Join the segment path data into a single path and return
return Chartist.Svg.Path.join(paths);
} else {
// If there was only one segment we can proceed regularly by using pathCoordinates and valueData from the first
// segment
pathCoordinates = segments[0].pathCoordinates;
valueData = segments[0].valueData;
// If less than three points we need to fallback to no smoothing
if(pathCoordinates.length <= 4) {
return Chartist.Interpolation.none()(pathCoordinates, valueData);
}
var xs = [],
ys = [],
i,
n = pathCoordinates.length / 2,
ms = [],
ds = [], dys = [], dxs = [],
path;
// Populate x and y coordinates into separate arrays, for readability
for(i = 0; i < n; i++) {
xs[i] = pathCoordinates[i * 2];
ys[i] = pathCoordinates[i * 2 + 1];
}
// Calculate deltas and derivative
for(i = 0; i < n - 1; i++) {
dys[i] = ys[i + 1] - ys[i];
dxs[i] = xs[i + 1] - xs[i];
ds[i] = dys[i] / dxs[i];
}
// Determine desired slope (m) at each point using Fritsch-Carlson method
// See: http://math.stackexchange.com/questions/45218/implementation-of-monotone-cubic-interpolation
ms[0] = ds[0];
ms[n - 1] = ds[n - 2];
for(i = 1; i < n - 1; i++) {
if(ds[i] === 0 || ds[i - 1] === 0 || (ds[i - 1] > 0) !== (ds[i] > 0)) {
ms[i] = 0;
} else {
ms[i] = 3 * (dxs[i - 1] + dxs[i]) / (
(2 * dxs[i] + dxs[i - 1]) / ds[i - 1] +
(dxs[i] + 2 * dxs[i - 1]) / ds[i]);
if(!isFinite(ms[i])) {
ms[i] = 0;
}
}
}
// Now build a path from the slopes
path = new Chartist.Svg.Path().move(xs[0], ys[0], false, valueData[0]);
for(i = 0; i < n - 1; i++) {
path.curve(
// First control point
xs[i] + dxs[i] / 3,
ys[i] + ms[i] * dxs[i] / 3,
// Second control point
xs[i + 1] - dxs[i] / 3,
ys[i + 1] - ms[i + 1] * dxs[i] / 3,
// End point
xs[i + 1],
ys[i + 1],
false,
valueData[i + 1]
);
}
return path;
}
};
};
/**
* Step interpolation will cause the line chart to move in steps rather than diagonal or smoothed lines. This interpolation will create additional points that will also be drawn when the `showPoint` option is enabled.
*

View File

@ -396,6 +396,65 @@ describe('Chartist core', function() {
});
});
describe('splitIntoSegments', function() {
function makeValues(arr) {
return arr.map(function(x) {
return { value: x };
});
}
it('should return empty array for empty input', function() {
expect(Chartist.splitIntoSegments([],[])).toEqual([]);
});
it('should remove undefined values', function() {
var coords = [1,2,3,4,5,6,7,8,9,10,11,12];
var values = makeValues([1,undefined,undefined,4,undefined,6]);
expect(Chartist.splitIntoSegments(coords, values)).toEqual([{
pathCoordinates: [1,2],
valueData: makeValues([1])
}, {
pathCoordinates: [7, 8],
valueData: makeValues([4])
}, {
pathCoordinates: [11, 12],
valueData: makeValues([6])
}]);
});
it('should respect fillHoles option', function() {
var coords = [1,2,3,4,5,6,7,8,9,10,11,12];
var values = makeValues([1,undefined,undefined,4,undefined,6]);
var options = {
fillHoles: true
};
expect(Chartist.splitIntoSegments(coords, values, options)).toEqual([{
pathCoordinates: [1,2,7,8,11,12],
valueData: makeValues([1,4,6])
}]);
});
it('should respect increasingX option', function() {
var coords = [1,2,3,4,5,6,5,6,7,8,1,2];
var values = makeValues([1,2,3,4,5,6]);
var options = {
increasingX: true
};
expect(Chartist.splitIntoSegments(coords, values, options)).toEqual([{
pathCoordinates: [1,2,3,4,5,6],
valueData: makeValues([1,2,3])
}, {
pathCoordinates: [5,6,7,8],
valueData: makeValues([4,5])
}, {
pathCoordinates: [1,2],
valueData: makeValues([6])
}]);
});
});
});

View File

@ -209,6 +209,40 @@ describe('Line chart tests', function () {
});
});
it('should render correctly with Interpolation.monotoneCubic and holes everywhere', function (done) {
jasmine.getFixtures().set('<div class="ct-chart ct-golden-section"></div>');
var chart = new Chartist.Line('.ct-chart', {
labels: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10],
series: [
[NaN, 15, 0, null, 2, 3, 4, undefined, {value: 1, meta: 'meta data'}, null]
]
}, {
lineSmooth: Chartist.Interpolation.monotoneCubic()
});
chart.on('draw', function (context) {
if (context.type === 'line') {
expect(context.path.pathElements.map(function (pathElement) {
return {
command: pathElement.command,
data: pathElement.data
};
})).toEqual([
{command: 'M', data: {valueIndex: 1, value: {x: undefined, y: 15}, meta: undefined}},
// Monotone cubic should create Line path segment if only one connection
{command: 'L', data: {valueIndex: 2, value: {x: undefined, y: 0}, meta: undefined}},
{command: 'M', data: {valueIndex: 4, value: {x: undefined, y: 2}, meta: undefined}},
// Monotone cubic should create Curve path segment for 2 or more connections
{command: 'C', data: {valueIndex: 5, value: {x: undefined, y: 3}, meta: undefined}},
{command: 'C', data: {valueIndex: 6, value: {x: undefined, y: 4}, meta: undefined}},
{command: 'M', data: {valueIndex: 8, value: {x: undefined, y: 1}, meta: 'meta data'}}
]);
done();
}
});
});
it('should render correctly with Interpolation.simple and holes everywhere', function (done) {
jasmine.getFixtures().set('<div class="ct-chart ct-golden-section"></div>');