Merge pull request #627 from ammojamo/develop
Monotone cubic interpolation
This commit is contained in:
commit
13982b066f
@ -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));
|
||||
|
||||
@ -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.
|
||||
*
|
||||
|
||||
@ -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])
|
||||
}]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -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>');
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user