diff --git a/README.md b/README.md index 08bee8e..4438a9d 100644 --- a/README.md +++ b/README.md @@ -231,7 +231,7 @@ Returns an interpolator between the two hue angles *a* and *b*. If either hue is ### Splines -Whereas standard interpolators blend from a starting value *a* at *t* = 0 to an ending value *b* at *t* = 1, spline interpolators smoothly blend multiple input values for *t* in [0,1] using piecewise polynomial functions. Only cubic uniform nonrational [B-splines](https://en.wikipedia.org/wiki/B-spline) are currently supported, also known as basis splines. +Whereas standard interpolators blend from a starting value *a* at *t* = 0 to an ending value *b* at *t* = 1, spline interpolators smoothly blend multiple input values for *t* in [0,1] using piecewise polynomial functions. Cubic uniform nonrational [B-splines](https://en.wikipedia.org/wiki/B-spline), also known as basis splines, are supported, as well as the [cubic Hermite splines](https://en.wikipedia.org/wiki/Cubic_Hermite_spline) and [monotone cubic splines](https://en.wikipedia.org/wiki/Monotone_cubic_interpolation). # d3.interpolateBasis(values) · [Source](https://github.com/d3/d3-interpolate/blob/master/src/basis.js), [Examples](https://observablehq.com/@d3/d3-interpolatebasis) @@ -241,6 +241,24 @@ Returns a uniform nonrational B-spline interpolator through the specified array Returns a uniform nonrational B-spline interpolator through the specified array of *values*, which must be numbers. The control points are implicitly repeated such that the resulting one-dimensional spline has cyclical C² continuity when repeated around *t* in [0,1]. See also [d3.curveBasisClosed](https://github.com/d3/d3-shape/blob/master/README.md#curveBasisClosed). +# d3.interpolateCubic(values) · [Source](https://github.com/d3/d3-interpolate/blob/master/src/cubic.js) + +Returns a cubic Hermite spline interpolator through the specified array of *values*, which must be numbers. The interpolator returns *values*[*i*] at *t* = *i* / (*values*.length - 1). + +# d3.interpolateCubicClosed(values) · [Source](https://github.com/d3/d3-interpolate/blob/master/src/cubic.js) + +Returns a closed cubic Hermite spline interpolator through the specified array of *values*, which must be numbers. The interpolator returns *values*[*i*] at *t* = *i* / (*values*.length), and is cyclical (*f*(1 + *t*) = *f*(*t*)). + + +# d3.interpolateMonotone(values) · [Source](https://github.com/d3/d3-interpolate/blob/master/src/monotone.js) + +Returns a monotone cubic spline interpolator through the specified array of *values*, which must be numbers. The interpolator returns *values*[*i*] at *t* = *i* / (*values*.length - 1). + +# d3.interpolateMonotoneClosed(values) · [Source](https://github.com/d3/d3-interpolate/blob/master/src/monotone.js) + +Returns a closed monotone cubic spline interpolator through the specified array of *values*, which must be numbers. The interpolator returns *values*[*i*] at *t* = *i* / (*values*.length), and is cyclical (*f*(1 + *t*) = *f*(*t*)). + + ### Piecewise # d3.piecewise(interpolate, values) · [Source](https://github.com/d3/d3-interpolate/blob/master/src/piecewise.js), [Examples](https://observablehq.com/@d3/d3-piecewise) diff --git a/src/cubic.js b/src/cubic.js new file mode 100644 index 0000000..4564f5b --- /dev/null +++ b/src/cubic.js @@ -0,0 +1,40 @@ +import {clamp, floor, frac, min} from "./math.js"; + +export default function cubic(values, type) { + let n = values.length - 1, k; + values = values.slice(); + switch (type) { + case "closed": + values.unshift(values[n]); + values.push(values[1]); + values.push(values[2]); + n += 2; + k = 1 - 1 / n; + return t => cubic(k * frac(t)); + case "open": + throw new Error('open cubic spline not implemented yet'); + case "clamped": + default: + values.push(2 * values[n] - values[n - 1]); + values.unshift(2 * values[0] - values[1]); + return t => cubic(clamp(t, 0, 1)); + } + + function cubic(t) { + const i = min(n - 1, floor(t * n)), + v0 = values[i], + v1 = values[i + 1], + v2 = values[i + 2], + v3 = values[i + 3], + d = t * n - i, + s20 = v2 - v0, + s31 = v3 - v1, + s21 = (v2 - v1) * 2; + return (((s20 + s31 - 2 * s21) * d + (3 * s21 - 2 * s20 - s31)) * d + s20) + * d / 2 + v1; + } +} + +export function closed (values) { + return cubic(values, "closed"); +} diff --git a/src/index.js b/src/index.js index b4dce7d..7578b2d 100644 --- a/src/index.js +++ b/src/index.js @@ -5,6 +5,8 @@ export {default as interpolateBasisClosed} from "./basisClosed.js"; export {default as interpolateDate} from "./date.js"; export {default as interpolateDiscrete} from "./discrete.js"; export {default as interpolateHue} from "./hue.js"; +export {default as interpolateCubic, closed as interpolateCubicClosed} from "./cubic.js"; +export {default as interpolateMonotone, closed as interpolateMonotoneClosed} from "./monotone.js"; export {default as interpolateNumber} from "./number.js"; export {default as interpolateNumberArray} from "./numberArray.js"; export {default as interpolateObject} from "./object.js"; diff --git a/src/math.js b/src/math.js new file mode 100644 index 0000000..cbee5e8 --- /dev/null +++ b/src/math.js @@ -0,0 +1,11 @@ +export var abs = Math.abs; +export var floor = Math.floor; +export var max = Math.max; +export var min = Math.min; +export var sign = Math.sign || function(x) { return x > 0 ? 1 : x < 0 ? -1 : 0; }; +export function frac(t) { + return t - floor(t); +} +export function clamp(t, lo, hi) { + return t < lo ? lo : t > hi ? hi : t; +} diff --git a/src/monotone.js b/src/monotone.js new file mode 100644 index 0000000..95504bc --- /dev/null +++ b/src/monotone.js @@ -0,0 +1,42 @@ +import {abs, clamp, frac, min, sign} from "./math.js"; + +export default function monotone(values, type) { + let n = values.length - 1, k; + values = values.slice(); + switch (type) { + case "closed": + values.unshift(values[n]); + values.push(values[1]); + values.push(values[2]); + n += 2; + k = 1 - 1 / n; + return t => monotone(k * frac(t)); + case "open": + throw new Error('open monotone spline not implemented yet'); + case "clamped": + default: + values.push(2 * values[n] - values[n - 1]); + values.unshift(2 * values[0] - values[1]); + return t => monotone(clamp(t, 0, 1)); + } + + function monotone(t) { + const i = Math.min(n - 1, Math.floor(t * n)), + y_im1 = values[i], + y_i = values[i + 1], + y_ip1 = values[i + 2], + y_ip2 = values[i + 3], + d = t * n - i, + s_im1 = n * (y_i - y_im1), + s_i = n * (y_ip1 - y_i), + s_ip1 = n * (y_ip2 - y_ip1), + yp_i = (sign(s_im1) + sign(s_i)) * min(abs(s_im1), abs(s_i), 0.25 * n * abs(y_ip1 - y_im1)), + yp_ip1 = (sign(s_i) + sign(s_ip1)) * min(abs(s_i), abs(s_ip1), 0.25 * n * abs(y_ip2 - y_i)); + + return (((yp_i + yp_ip1 - 2 * s_i) * d + (3 * s_i - 2 * yp_i - yp_ip1)) * d + yp_i) * (d / n) + y_i; + } +} + +export function closed (values) { + return monotone(values, "closed"); +} diff --git a/test/basis-test.js b/test/basis-test.js new file mode 100644 index 0000000..56dd034 --- /dev/null +++ b/test/basis-test.js @@ -0,0 +1,27 @@ +var tape = require("tape"), + interpolate = require("../"); + +require("./inDelta"); + +tape("interpolateBasis(values)(t) returns the expected values", function(test) { + var i = interpolate.interpolateBasis([0, 0, 3]); + test.equal(i(-1), 0); + test.equal(i(0), 0); + test.inDelta(i(0.19), 0.027436); + test.inDelta(i(0.21), 0.037044); + test.equal(i(1), 3); + test.equal(i(1.19), 3); + test.end(); +}); + +tape("interpolateBasisClosed(values)(t) returns the expected values", function(test) { + var i = interpolate.interpolateBasisClosed([0, 0, 3]); + test.equal(i(-1), 0.5); + test.equal(i(0), 0.5); + test.inDelta(i(0.19), 0.132350); + test.inDelta(i(0.21), 0.150350); + test.equal(i(1), 0.5); + test.inDelta(i(1.19), 0.132350); + test.inDelta(i(0.19 - 3), 0.132350); + test.end(); +}); diff --git a/test/cubic-test.js b/test/cubic-test.js new file mode 100644 index 0000000..ca85508 --- /dev/null +++ b/test/cubic-test.js @@ -0,0 +1,37 @@ +var tape = require("tape"), + interpolate = require("../"); + +require("./inDelta"); + +tape("interpolateCubic(values)(t) returns the expected values", function(test) { + var i = interpolate.interpolateCubic([0, 0, 3, 4, 1]); + test.equal(i(-1), 0); + test.equal(i(0), 0); + test.equal(i(0.25), 0); + test.equal(i(0.5), 3); + test.equal(i(0.75), 4); + test.equal(i(1), 1); + test.inDelta(i(0.1), -0.144); + test.inDelta(i(0.19), -0.207936); + test.inDelta(i(0.21), -0.169344); + test.equal(i(2), 1); + test.end(); +}); + +tape("interpolateCubicClosed(values)(t) returns the expected values", function(test) { + var i = interpolate.interpolateCubicClosed([0, 0, 3, 4, 1]); + test.equal(i(0), 0); + test.equal(i(0.2), 0); + test.equal(i(0.4), 3); + test.equal(i(0.6), 4); + test.equal(i(0.8), 1); + test.equal(i(1), 0); + test.inDelta(i(0.1), -0.25); + test.inDelta(i(0.19), -0.068875); + test.inDelta(i(0.21), 0.0846875); + test.inDelta(i(1.1), -0.25); + test.inDelta(i(1.19), -0.068875); + test.equal(i(-1), 0); + test.equal(i(2), 0); + test.end(); +}); diff --git a/test/monotone-test.js b/test/monotone-test.js new file mode 100644 index 0000000..66031aa --- /dev/null +++ b/test/monotone-test.js @@ -0,0 +1,31 @@ +var tape = require("tape"), + interpolate = require("../"); + +require("./inDelta"); + +tape("interpolateMonotone(values)(t) returns the expected values", function(test) { + var i = interpolate.interpolateCubic([3, 2.8, 2.5, 1, 0.95, 0.8, 0.5, 0.1, 0.05]); + test.equal(i(-1), 3); + test.inDelta(i(0), 3); + test.inDelta(i(0.25), 2.5); + test.inDelta(i(0.5), 0.95); + test.inDelta(i(0.6), 0.8412); + test.inDelta(i(0.75), 0.5); + test.inDelta(i(1), 0.05); + test.inDelta(i(2), 0.05); + test.end(); +}); + +tape("interpolateMonotoneClosed(values)(t) returns the expected values", function(test) { + var i = interpolate.interpolateMonotoneClosed([0, 0, 3, 4, 1]); + test.equal(i(0), 0); + test.inDelta(i(0.2), 0); + test.inDelta(i(0.4), 3); + test.inDelta(i(0.5), 3.75); + test.inDelta(i(0.6), 4); + test.inDelta(i(0.8), 1); + test.inDelta(i(1), 0); + test.inDelta(i(-1), 0); + test.inDelta(i(2), 0); + test.end(); +});