diff --git a/README.md b/README.md
index 2d6cb7f..c9599bb 100644
--- a/README.md
+++ b/README.md
@@ -2,9 +2,12 @@
[](https://badge.fury.io/js/d3-interpolate-path)
+d3-interpolate-path is a D3 plugin that adds an [interpolator](https://github.com/d3/d3-interpolate)
+optimized for SVG <path> elements.
+
Blog: [Improving D3 Path Animation](https://bocoup.com/weblog/improving-d3-path-animation)
-Demo: http://pbeshai.github.io/d3-interpolate-path/
+Demo: http://peterbeshai.com/d3-interpolate-path/

@@ -52,7 +55,7 @@ If you use NPM, `npm install d3-interpolate-path`. Otherwise, download the [late
## API Reference
-#interpolatePath(*a*, *b*)
+#interpolatePath(*a*, *b*, *excludeSegment*)
Returns an interpolator between two path attribute `d` strings *a* and *b*. The interpolator extends *a* and *b* to have the same number of points before using [d3.interpolateString](https://github.com/d3/d3-interpolate#interpolateString) on them.
@@ -62,3 +65,22 @@ pathInterpolator(0) // 'M0,0 L10,10 L10,10'
pathInterpolator(0.5) // 'M5,5 L15,15 L20,20'
pathInterpolator(1) // 'M10,10 L20,20 L30,30'
```
+
+You can optionally provide a function *excludeSegment* that takes two adjacent path commands and returns true if that segment should be excluded when splitting the line. A command object has form `{ type, x, y }` (with possibly more attributes depending on type). An example object:
+
+```js
+// equivalent to M0,150 in a path `d` string
+{
+ type: 'M',
+ x: 0,
+ y: 150
+}
+```
+
+This is most useful when working with d3-area. Excluding the final segment (i.e. the vertical line at the end) from being split ensures a nice transition. If you know that highest `x` value in the path, you can exclude the final segment by passing an excludeSegment function similar to:
+
+```js
+function excludeSegment(a, b) {
+ return a.x === b.x && a.x === 300; // here 300 is the max X
+}
+```
diff --git a/docs/d3-interpolate-path.js b/docs/d3-interpolate-path.js
index a2b08fc..0b135bb 100644
--- a/docs/d3-interpolate-path.js
+++ b/docs/d3-interpolate-path.js
@@ -4,6 +4,150 @@
(factory((global.d3 = global.d3 || {}),global.d3));
}(this, (function (exports,d3Interpolate) { 'use strict';
+/**
+ * de Casteljau's algorithm for drawing and splitting bezier curves.
+ * Inspired by https://pomax.github.io/bezierinfo/
+ *
+ * @param {Number[][]} points Array of [x,y] points: [start, control1, control2, ..., end]
+ * The original segment to split.
+ * @param {Number} t Where to split the curve (value between [0, 1])
+ * @return {Object} An object { left, right } where left is the segment from 0..t and
+ * right is the segment from t..1.
+ */
+function decasteljau(points, t) {
+ var left = [];
+ var right = [];
+
+ function decasteljauRecurse(points, t) {
+ if (points.length === 1) {
+ left.push(points[0]);
+ right.push(points[0]);
+ } else {
+ var newPoints = Array(points.length - 1);
+
+ for (var i = 0; i < newPoints.length; i++) {
+ if (i === 0) {
+ left.push(points[0]);
+ }
+ if (i === newPoints.length - 1) {
+ right.push(points[i + 1]);
+ }
+
+ newPoints[i] = [(1 - t) * points[i][0] + t * points[i + 1][0], (1 - t) * points[i][1] + t * points[i + 1][1]];
+ }
+
+ decasteljauRecurse(newPoints, t);
+ }
+ }
+
+ if (points.length) {
+ decasteljauRecurse(points, t);
+ }
+
+ return { left: left, right: right.reverse() };
+}
+
+/**
+ * Convert segments represented as points back into a command object
+ *
+ * @param {Number[][]} points Array of [x,y] points: [start, control1, control2, ..., end]
+ * Represents a segment
+ * @return {Object} A command object representing the segment.
+ */
+function pointsToCommand(points) {
+ var command = {};
+
+ if (points.length === 4) {
+ command.x2 = points[2][0];
+ command.y2 = points[2][1];
+ }
+ if (points.length >= 3) {
+ command.x1 = points[1][0];
+ command.y1 = points[1][1];
+ }
+
+ command.x = points[points.length - 1][0];
+ command.y = points[points.length - 1][1];
+
+ if (points.length === 4) {
+ // start, control1, control2, end
+ command.type = 'C';
+ } else if (points.length === 3) {
+ // start, control, end
+ command.type = 'Q';
+ } else {
+ // start, end
+ command.type = 'L';
+ }
+
+ return command;
+}
+
+/**
+ * Runs de Casteljau's algorithm enough times to produce the desired number of segments.
+ *
+ * @param {Number[][]} points Array of [x,y] points for de Casteljau (the initial segment to split)
+ * @param {Number} segmentCount Number of segments to split the original into
+ * @return {Number[][][]} Array of segments
+ */
+function splitCurveAsPoints(points, segmentCount) {
+ segmentCount = segmentCount || 2;
+
+ var segments = [];
+ var remainingCurve = points;
+ var tIncrement = 1 / segmentCount;
+
+ // x-----x-----x-----x
+ // t= 0.33 0.66 1
+ // x-----o-----------x
+ // r= 0.33
+ // x-----o-----x
+ // r= 0.5 (0.33 / (1 - 0.33)) === tIncrement / (1 - (tIncrement * (i - 1))
+
+ // x-----x-----x-----x----x
+ // t= 0.25 0.5 0.75 1
+ // x-----o----------------x
+ // r= 0.25
+ // x-----o----------x
+ // r= 0.33 (0.25 / (1 - 0.25))
+ // x-----o----x
+ // r= 0.5 (0.25 / (1 - 0.5))
+
+ for (var i = 0; i < segmentCount - 1; i++) {
+ var tRelative = tIncrement / (1 - tIncrement * i);
+ var split = decasteljau(remainingCurve, tRelative);
+ segments.push(split.left);
+ remainingCurve = split.right;
+ }
+
+ // last segment is just to the end from the last point
+ segments.push(remainingCurve);
+
+ return segments;
+}
+
+/**
+ * Convert command objects to arrays of points, run de Casteljau's algorithm on it
+ * to split into to the desired number of segments.
+ *
+ * @param {Object} commandStart The start command object
+ * @param {Object} commandEnd The end command object
+ * @param {Number} segmentCount The number of segments to create
+ * @return {Object[]} An array of commands representing the segments in sequence
+ */
+function splitCurve(commandStart, commandEnd, segmentCount) {
+ var points = [[commandStart.x, commandStart.y]];
+ if (commandEnd.x1 != null) {
+ points.push([commandEnd.x1, commandEnd.y1]);
+ }
+ if (commandEnd.x2 != null) {
+ points.push([commandEnd.x2, commandEnd.y2]);
+ }
+ points.push([commandEnd.x, commandEnd.y]);
+
+ return splitCurveAsPoints(points, segmentCount).map(pointsToCommand);
+}
+
var _extends = Object.assign || function (target) {
for (var i = 1; i < arguments.length; i++) {
var source = arguments[i];
@@ -33,13 +177,22 @@ var typeMap = {
A: ['rx', 'ry', 'xAxisRotation', 'largeArcFlag', 'sweepFlag', 'x', 'y']
};
+function arrayOfLength(length, value) {
+ var array = Array(length);
+ for (var i = 0; i < length; i++) {
+ array[i] = value;
+ }
+
+ return array;
+}
+
/**
* Convert to object representation of the command from a string
*
* @param {String} commandString Token string from the `d` attribute (e.g., L0,0)
* @return {Object} An object representing this command.
*/
-function commandObject(commandString) {
+function commandToObject(commandString) {
// convert all spaces to commas
commandString = commandString.trim().replace(/ /g, ',');
@@ -47,7 +200,7 @@ function commandObject(commandString) {
var args = commandString.substring(1).split(',');
return typeMap[type.toUpperCase()].reduce(function (obj, param, i) {
// parse X as float since we need it to do distance checks for extending points
- obj[param] = param === 'x' ? parseFloat(args[i]) : args[i];
+ obj[param] = +args[i];
return obj;
}, { type: type });
}
@@ -135,79 +288,133 @@ function convertToSameType(aCommand, bCommand) {
}
/**
- * Extends an array of commands to the length of the second array
- * inserting points at the spot that is closest by X value. Ensures
- * all the points of commandsToExtend are in the extended array and that
- * only numPointsToExtend points are added.
+ * Interpolate between command objects commandStart and commandEnd segmentCount times.
+ * If the types are L, Q, or C then the curves are split as per de Casteljau's algorithm.
+ * Otherwise we just copy commandStart segmentCount - 1 times, finally ending with commandEnd.
*
- * @param {Object[]} commandsToExtend The commands array to extend
- * @param {Object[]} referenceCommands The commands array to match
- * @return {Object[]} The extended commands1 array
+ * @param {Object} commandStart Command object at the beginning of the segment
+ * @param {Object} commandEnd Command object at the end of the segment
+ * @param {Number} segmentCount The number of segments to split this into. If only 1
+ * Then [commandEnd] is returned.
+ * @return {Object[]} Array of ~segmentCount command objects between commandStart and
+ * commandEnd. (Can be segmentCount+1 objects if commandStart is type M).
*/
-function extend(commandsToExtend, referenceCommands, numPointsToExtend) {
- // map each command in B to a command in A by counting how many times ideally
- // a command in A was in the initial path (see https://github.com/pbeshai/d3-interpolate-path/issues/8)
- var initialCommandIndex = void 0;
- if (commandsToExtend.length > 1 && commandsToExtend[0].type === 'M') {
- initialCommandIndex = 1;
+function splitSegment(commandStart, commandEnd, segmentCount) {
+ var segments = [];
+
+ // line, quadratic bezier, or cubic bezier
+ if (commandEnd.type === 'L' || commandEnd.type === 'Q' || commandEnd.type === 'C') {
+ segments = segments.concat(splitCurve(commandStart, commandEnd, segmentCount));
+
+ // general case - just copy the same point
} else {
- initialCommandIndex = 0;
+ (function () {
+ var copyCommand = _extends({}, commandStart);
+
+ // convert M to L
+ if (copyCommand.type === 'M') {
+ copyCommand.type = 'L';
+ }
+
+ segments = segments.concat(arrayOfLength(segmentCount - 1).map(function () {
+ return copyCommand;
+ }));
+ segments.push(commandEnd);
+ })();
}
- var counts = referenceCommands.reduce(function (counts, refCommand, i) {
- // skip first M
- if (i === 0 && refCommand.type === 'M') {
- counts[0] = 1;
- return counts;
- }
+ return segments;
+}
+/**
+ * Extends an array of commandsToExtend to the length of the referenceCommands by
+ * splitting segments until the number of commands match. Ensures all the actual
+ * points of commandsToExtend are in the extended array.
+ *
+ * @param {Object[]} commandsToExtend The command object array to extend
+ * @param {Object[]} referenceCommands The command object array to match in length
+ * @param {Function} excludeSegment a function that takes a start command object and
+ * end command object and returns true if the segment should be excluded from splitting.
+ * @return {Object[]} The extended commandsToExtend array
+ */
+function extend(commandsToExtend, referenceCommands, excludeSegment) {
+ // compute insertion points:
+ // number of segments in the path to extend
+ var numSegmentsToExtend = commandsToExtend.length - 1;
+
+ // number of segments in the reference path.
+ var numReferenceSegments = referenceCommands.length - 1;
+
+ // this value is always between [0, 1].
+ var segmentRatio = numSegmentsToExtend / numReferenceSegments;
+
+ // create a map, mapping segments in referenceCommands to how many points
+ // should be added in that segment (should always be >= 1 since we need each
+ // point itself).
+ // 0 = segment 0-1, 1 = segment 1-2, n-1 = last vertex
+ var countPointsPerSegment = arrayOfLength(numReferenceSegments).reduce(function (accum, d, i) {
+ var insertIndex = Math.floor(segmentRatio * i);
+
+ // handle excluding segments
+ if (excludeSegment && insertIndex < commandsToExtend.length - 1 && excludeSegment(commandsToExtend[insertIndex], commandsToExtend[insertIndex + 1])) {
+ // set the insertIndex to the segment that this point should be added to:
+
+ // round the insertIndex essentially so we split half and half on
+ // neighbouring segments. hence the segmentRatio * i < 0.5
+ var addToPriorSegment = segmentRatio * i % 1 < 0.5;
+
+ // only skip segment if we already have 1 point in it (can't entirely remove a segment)
+ if (accum[insertIndex]) {
+ // TODO - Note this is a naive algorithm that should work for most d3-area use cases
+ // but if two adjacent segments are supposed to be skipped, this will not perform as
+ // expected. Could be updated to search for nearest segment to place the point in, but
+ // will only do that if necessary.
+
+ // add to the prior segment
+ if (addToPriorSegment) {
+ if (insertIndex > 0) {
+ insertIndex -= 1;
+
+ // not possible to add to previous so adding to next
+ } else if (insertIndex < commandsToExtend.length - 1) {
+ insertIndex += 1;
+ }
+ // add to next segment
+ } else if (insertIndex < commandsToExtend.length - 1) {
+ insertIndex += 1;
- var minDistance = Math.abs(commandsToExtend[initialCommandIndex].x - refCommand.x);
- var minCommand = initialCommandIndex;
-
- // find the closest point by X position in A
- for (var j = initialCommandIndex + 1; j < commandsToExtend.length; j++) {
- var distance = Math.abs(commandsToExtend[j].x - refCommand.x);
- if (distance < minDistance) {
- minDistance = distance;
- minCommand = j;
- // since we assume sorted by X, once we find a value farther, we can return the min.
- } else {
- break;
+ // not possible to add to next so adding to previous
+ } else if (insertIndex > 0) {
+ insertIndex -= 1;
+ }
}
}
- counts[minCommand] = (counts[minCommand] || 0) + 1;
- return counts;
- }, {});
-
- // now extend the array adding in at the appropriate place as needed
- var extended = [];
- var numExtended = 0;
- for (var i = 0; i < commandsToExtend.length; i++) {
- // add in the initial point for this A command
- extended.push(commandsToExtend[i]);
-
- for (var j = 1; j < counts[i] && numExtended < numPointsToExtend; j++) {
- var commandToAdd = _extends({}, commandsToExtend[i]);
- // don't allow multiple Ms
- if (commandToAdd.type === 'M') {
- commandToAdd.type = 'L';
- } else {
- // try to set control points to x and y
- if (commandToAdd.x1 !== undefined) {
- commandToAdd.x1 = commandToAdd.x;
- commandToAdd.y1 = commandToAdd.y;
- }
+ accum[insertIndex] = (accum[insertIndex] || 0) + 1;
- if (commandToAdd.x2 !== undefined) {
- commandToAdd.x2 = commandToAdd.x;
- commandToAdd.y2 = commandToAdd.y;
- }
+ return accum;
+ }, []);
+
+ // extend each segment to have the correct number of points for a smooth interpolation
+ var extended = countPointsPerSegment.reduce(function (extended, segmentCount, i) {
+ // if last command, just add `segmentCount` number of times
+ if (i === commandsToExtend.length - 1) {
+ var lastCommandCopies = arrayOfLength(segmentCount, _extends({}, commandsToExtend[commandsToExtend.length - 1]));
+
+ // convert M to L
+ if (lastCommandCopies[0].type === 'M') {
+ lastCommandCopies.forEach(function (d) {
+ d.type = 'L';
+ });
}
- extended.push(commandToAdd);
- numExtended += 1;
+ return extended.concat(lastCommandCopies);
}
- }
+
+ // otherwise, split the segment segmentCount times.
+ return extended.concat(splitSegment(commandsToExtend[i], commandsToExtend[i + 1], segmentCount));
+ }, []);
+
+ // add in the very first point since splitSegment only adds in the ones after it
+ extended.unshift(commandsToExtend[0]);
return extended;
}
@@ -221,11 +428,16 @@ function extend(commandsToExtend, referenceCommands, numPointsToExtend) {
*
* @param {String} a The `d` attribute for a path
* @param {String} b The `d` attribute for a path
+ * @param {Function} excludeSegment a function that takes a start command object and
+ * end command object and returns true if the segment should be excluded from splitting.
+ * @returns {Function} Interpolation functino that maps t ([0, 1]) to a path `d` string.
*/
-function interpolatePath(a, b) {
+function interpolatePath(a, b, excludeSegment) {
// remove Z, remove spaces after letters as seen in IE
var aNormalized = a == null ? '' : a.replace(/[Z]/gi, '').replace(/([MLCSTQAHV])\s*/gi, '$1');
var bNormalized = b == null ? '' : b.replace(/[Z]/gi, '').replace(/([MLCSTQAHV])\s*/gi, '$1');
+
+ // split so each command (e.g. L10,20 or M50,60) is its own entry in an array
var aPoints = aNormalized === '' ? [] : aNormalized.split(/(?=[MLCSTQAHV])/gi);
var bPoints = bNormalized === '' ? [] : bNormalized.split(/(?=[MLCSTQAHV])/gi);
@@ -248,8 +460,8 @@ function interpolatePath(a, b) {
}
// convert to command objects so we can match types
- var aCommands = aPoints.map(commandObject);
- var bCommands = bPoints.map(commandObject);
+ var aCommands = aPoints.map(commandToObject);
+ var bCommands = bPoints.map(commandToObject);
// extend to match equal size
var numPointsToExtend = Math.abs(bPoints.length - aPoints.length);
@@ -257,20 +469,21 @@ function interpolatePath(a, b) {
if (numPointsToExtend !== 0) {
// B has more points than A, so add points to A before interpolating
if (bCommands.length > aCommands.length) {
- aCommands = extend(aCommands, bCommands, numPointsToExtend);
+ aCommands = extend(aCommands, bCommands, excludeSegment);
// else if A has more points than B, add more points to B
} else if (bCommands.length < aCommands.length) {
- bCommands = extend(bCommands, aCommands, numPointsToExtend);
+ bCommands = extend(bCommands, aCommands, excludeSegment);
}
}
// commands have same length now.
- // convert A to the same type of B
+ // convert commands in A to the same type as those in B
aCommands = aCommands.map(function (aCommand, i) {
return convertToSameType(aCommand, bCommands[i]);
});
+ // convert back to command strings and concatenate to a path `d` string
var aProcessed = aCommands.map(commandToString).join('');
var bProcessed = bCommands.map(commandToString).join('');
@@ -280,6 +493,7 @@ function interpolatePath(a, b) {
bProcessed += 'Z';
}
+ // use d3's string interpolator to now interpolate between two path `d` strings.
var stringInterpolator = d3Interpolate.interpolateString(aProcessed, bProcessed);
return function pathInterpolator(t) {
diff --git a/docs/example.css b/docs/example.css
index 3f97dd1..25ecf4f 100644
--- a/docs/example.css
+++ b/docs/example.css
@@ -16,4 +16,70 @@ body {
.description {
max-width: 800px;
+}
+
+path {
+ stroke: #0bb;
+ stroke-width: 1.5px;
+ fill: none;
+}
+
+path.filled {
+ fill: #0bb;
+ fill-opacity: 0.2;
+}
+
+.example {
+ display: inline-block;
+ margin-right: 10px;
+ margin-bottom: 10px;
+ border: 1px solid #ccc;
+ vertical-align: top;
+}
+
+.example h4 {
+ margin: 0;
+}
+
+.path-d-string {
+ width: 80px;
+ display: inline-block;
+ overflow: hidden;
+ margin-right: 15px;
+ margin-top: 8px;
+ font-size: 12px;
+ font-family: sans-serif;
+ vertical-align: top;
+}
+
+.status, .status button {
+ font-size: 20px;
+ margin-bottom: 10px;
+}
+
+.example-container {
+ display: inline-block;
+ padding: 8px;
+}
+
+.using-d3-default {
+ background: #eee;
+ color: #666;
+}
+
+.using-d3-default h4 {
+ font-weight: 400;
+}
+
+.using-d3-default path {
+ stroke: #b1a776;
+}
+
+.using-d3-default path.filled {
+ fill: #bcb38b;
+}
+
+.interpolator-used {
+ color: #666;
+ font-size: 0.8em;
}
\ No newline at end of file
diff --git a/docs/examples.js b/docs/examples.js
index 0d16601..c1f7014 100644
--- a/docs/examples.js
+++ b/docs/examples.js
@@ -1,26 +1,38 @@
-var exampleWidth = 300;
+/**
+ * Apologies for this code. It's kind of hacked together to quickly demonstrate things.
+ */
+var exampleWidth = 250;
var exampleHeight = 200;
var showMainExample = !window.location.search.includes('showMainExample=0');
var showPathValues = window.location.search.includes('showPathValues=1');
-var useInterpolatePath = true;
-
-// var activeExamples = [0, 1]; // comment out for all examples
+var optionShowPathPoints = window.location.search.includes('showPathPoints=1');
+var maxNumLoops = 10; // comment out for infinite looping
+// var activeExamples = [13]; // comment out for all examples
+// var activeExamples = [2]; // comment out for all examples
+var delayTime = 0; // 1000
+var duration = 3000; // 2000
console.log('Show Main Example', showMainExample);
console.log('Show Path Values', showPathValues);
-console.log('Use d3-interpolate-path', useInterpolatePath);
// helper to loop a path between two points
-function loopPathBasic(path, dPath1, dPath2) {
+function loopPathBasic(path, dPath1, dPath2, loopForever) {
+ var loopCount = 0;
var looper = function () {
+ if (!loopForever && typeof maxNumLoops !== 'undefined' && loopCount >= maxNumLoops) {
+ return;
+ } else {
+ loopCount += 1;
+ }
+
path.attr('d', dPath1)
.transition()
- .delay(1000)
- .duration(2000)
+ .delay(delayTime)
+ .duration(duration)
.attr('d', dPath2)
.transition()
- .delay(1000)
- .duration(2000)
+ .delay(delayTime)
+ .duration(duration)
.attr('d', dPath1)
.on('end', looper);
};
@@ -28,37 +40,40 @@ function loopPathBasic(path, dPath1, dPath2) {
}
// helper to loop a path between two points using d3-interpolate-path
-function loopPath(path, dPath1, dPath2, pathTextRoot) {
+function loopPath(path, dPath1, dPath2, pathTextRoot, svg, excludeSegment, loopForever) {
+ var loopCount = 0;
var looper = function () {
+ if (!loopForever && typeof maxNumLoops !== 'undefined' && loopCount >= maxNumLoops) {
+ return;
+ } else {
+ loopCount += 1;
+ }
+
path.attr('d', dPath1)
.transition()
- .delay(1000)
- .duration(2000)
+ .delay(delayTime)
+ .duration(duration)
.attrTween('d', function () {
- try { // need to catch errors for d3 default interpolation on nulls
- return useInterpolatePath ?
- d3.interpolatePath(d3.select(this).attr('d'), dPath2) :
- d3.interpolate(d3.select(this).attr('d'), dPath2);
- } catch (e) { }
+ return d3.interpolatePath(d3.select(this).attr('d'), dPath2, excludeSegment);
})
.on('start', function (a) {
if (pathTextRoot) {
- showDValues(pathTextRoot, dPath1, dPath2, this, d3.transition().duration(2000));
+ // set timeout in case num points immediately after first tick changes
+ setTimeout(function () { showPathPoints(svg, d3.transition().duration(duration)); }, 0);
+ showDValues(pathTextRoot, dPath1, dPath2, this, d3.transition().duration(duration));
}
})
.transition()
- .delay(1000)
- .duration(2000)
+ .delay(delayTime)
+ .duration(duration)
.attrTween('d', function () {
- try {
- return useInterpolatePath ?
- d3.interpolatePath(d3.select(this).attr('d'), dPath1) :
- d3.interpolate(d3.select(this).attr('d'), dPath1);
- } catch (e) { }
+ return d3.interpolatePath(d3.select(this).attr('d'), dPath1, excludeSegment);
})
.on('start', function (a) {
if (pathTextRoot) {
- showDValues(pathTextRoot, dPath1, dPath2, this, d3.transition().duration(2000), true);
+ // set timeout in case num points immediately after first tick changes
+ setTimeout(function () { showPathPoints(svg, d3.transition().duration(duration)); }, 0);
+ showDValues(pathTextRoot, dPath1, dPath2, this, d3.transition().duration(duration), true);
}
})
.on('end', looper);
@@ -74,8 +89,8 @@ function mainExample() {
var data = dataLine1.concat(dataLine2);
var width = 600;
- var height = 600;
- var lineHeight = 150;
+ var height = 480;
+ var lineHeight = 120;
var x = d3.scaleLinear()
.domain(d3.extent(data, function (d) { return d[0]; }))
@@ -108,9 +123,10 @@ function mainExample() {
.text('Line A');
g = svg.append('g')
- .attr('transform', 'translate(0 ' + lineHeight + ')');
+ .attr('transform', 'translate(0 ' + lineHeight + ')')
+ .attr('class', 'using-d3-default');
- loopPathBasic(g.append('path'), line(dataLine1), line(dataLine2));
+ loopPathBasic(g.append('path'), line(dataLine1), line(dataLine2), true);
g.append('text')
.attr('y', 25)
@@ -119,7 +135,7 @@ function mainExample() {
g = svg.append('g')
.attr('transform', 'translate(0 ' + lineHeight * 2 + ')')
- loopPath(g.append('path'), line(dataLine1), line(dataLine2));
+ loopPath(g.append('path'), line(dataLine1), line(dataLine2), null, null, null, true);
g.append('text')
.attr('y', 25)
@@ -141,11 +157,31 @@ function mainExample() {
var examples = [
{
- name: 'area example',
- a: 'M10,74 L30,100 L60,86 L90,21 L120,70 L150,128 L180,92 L210,138 L240,146 L270,77 L290,100 L290,200 L10,200 Z',
- b: 'M5,132 L15,165 L30,28 L45,161 L60,67 L75,98 L90,82 L105,123 L120,129 L135,119 L150,65 L165,128 L180,69 L195,38 L210,69 L225,142 L240,56 L255,103 L270,139 L285,99 L285,200 L5,200 Z',
+ name: 'cubic simple',
+ a: 'M20,20 C160,90 90,120 100,160',
+ b: 'M20,20 C60,90 90,120 150,130 C150,0 180,100 250,100',
+ scale: false,
+ },
+ {
+ name: 'quadratic simple',
+ a: 'M0,70 Q160,20 200,100',
+ b: 'M0,70 Q50,0 100,30 Q120,130 200,100',
+ },
+ {
+ name: 'simple d3-area example',
+ a: 'M0,42L300,129L300,200L0,200Z',
+ b: 'M0,77L150,95L300,81L300,200L150,200L0,200Z',
+ scale: false,
+ className: 'filled',
+ excludeSegment: function (a, b) { return a.x === b.x && a.x === 300; },
+ },
+ {
+ name: 'bigger d3-area example',
+ a: 'M0,100L33,118L67,66L100,154L133,105L167,115L200,62L233,115L267,88L300,103L300,200L267,200L233,200L200,200L167,200L133,200L100,200L67,200L33,200L0,200Z',
+ b: 'M0,94L75,71L150,138L225,59L300,141L300,200L225,200L150,200L75,200L0,200Z',
scale: false,
className: 'filled',
+ excludeSegment: function (a, b) { return a.x === b.x && a.x === 300; },
},
{
name: 'shape example',
@@ -171,6 +207,12 @@ var examples = [
a: 'M0,32.432432432432506L5.533333333333334,47.39382239382246C11.066666666666668,62.355212355212416,22.133333333333336,92.27799227799233,33.2,108.39768339768345C44.26666666666667,124.51737451737455,55.333333333333336,126.83397683397686,66.39999999999999,136.38996138996143C77.46666666666667,145.94594594594597,88.53333333333335,162.74131274131278,99.59999999999998,156.3706563706564C110.66666666666667,150.00000000000003,121.73333333333335,120.4633204633205,132.8,96.42857142857149C143.86666666666667,72.39382239382245,154.93333333333334,53.861003861003915,166,40.83011583011588C177.0666666666667,27.79922779922784,188.13333333333333,20.2702702702703,199.20000000000002,19.78764478764482C210.26666666666665,19.30501930501934,221.33333333333334,25.86872586872592,232.4,35.328185328185384C243.4666666666667,44.787644787644844,254.5333333333334,57.14285714285719,265.6,71.91119691119695C276.6666666666667,86.67953667953672,287.73333333333335,103.86100386100391,298.8,119.11196911196915C309.8666666666667,134.3629343629344,320.93333333333334,147.68339768339771,332,133.30115830115832C343.06666666666666,118.9189189189189,354.1333333333334,76.8339768339768,365.2,49.99999999999997C376.26666666666665,23.166023166023137,387.3333333333333,11.583011583011569,398.40000000000003,7.046332046332036C409.4666666666667,2.509652509652502,420.5333333333333,5.019305019305004,431.6000000000001,13.6100386100386C442.6666666666667,22.200772200772196,453.7333333333334,36.872586872586886,464.8,55.59845559845562C475.86666666666673,74.32432432432437,486.9333333333334,97.10424710424714,498,109.94208494208497C509.06666666666666,122.7799227799228,520.1333333333333,125.67567567567568,531.2,121.23552123552123C542.2666666666668,116.79536679536677,553.3333333333334,105.01930501930501,564.4,96.71814671814673C575.4666666666667,88.41698841698843,586.5333333333334,83.59073359073363,597.6,93.72586872586878C608.6666666666666,103.86100386100391,619.7333333333332,128.95752895752898,630.8,149.32432432432435C641.8666666666667,169.69111969111972,652.9333333333333,185.32818532818533,664,189.86486486486487C675.0666666666666,194.40154440154438,686.1333333333332,187.83783783783784,697.1999999999999,183.01158301158299C708.2666666666665,178.1853281853282,719.3333333333334,175.09652509652508,730.4,173.45559845559845C741.4666666666667,171.8146718146718,752.5333333333333,171.62162162162164,763.6,159.45945945945948C774.6666666666666,147.29729729729732,785.7333333333332,123.16602316602318,796.7999999999998,109.16988416988418C807.8666666666667,95.17374517374519,818.9333333333334,91.31274131274132,824.4666666666667,89.38223938223939L830,87.45173745173747',
b: 'M0,55.22478736330493L2.194315928618639,59.325637910085C4.388631857237278,63.42648845686508,8.777263714474556,71.62818955042523,13.165895571711836,74.17982989064394C17.55452742894911,76.73147023086267,21.943159286186386,73.63304981773996,26.331791143423658,73.35965978128796C30.720423000660944,73.08626974483597,35.10905485789822,75.63791008505468,39.497686715135494,72.90400972053463C43.88631857237277,70.17010935601458,48.274950429610044,62.15066828675575,52.663582286847316,53.94896719319561C57.052214144084616,45.74726609963545,61.44084600132189,37.36330498177398,65.82947785855914,28.341433778857823C70.21810971579642,19.31956257594167,74.60674157303372,9.659781287970835,78.99537343027099,79.82989064398542C83.38400528750826,150,87.77263714474554,300,92.16126900198282,375C96.54990085922009,450,100.93853271645739,450,105.32716457369463,450C109.71579643093196,450,114.10442828816923,450,118.4930601454065,450C122.88169200264377,450,127.27032385988105,450,131.6589557171183,450C136.04758757435556,450,140.43621943159283,450,144.82485128883013,450C149.21348314606743,450,153.6021150033047,450,157.99074686054198,450C162.37937871777925,450,166.76801057501652,450,171.15664243225382,450C175.5452742894911,450,179.93390614672836,450,184.32253800396563,450C188.7111698612029,450,193.09980171844018,450,197.4884335756775,385.2065613608749C201.87706543291478,320.4131227217497,206.26569729015205,190.8262454434994,210.65432914738926,127.03523693803159C215.0429610046266,63.244228432563794,219.4315928618638,65.24908869987847,223.82022471910113,73.90643985419194C228.20885657633835,82.56379100850542,232.59748843357568,97.87363304981771,236.986120290813,109.35601458080191C241.37475214805022,120.83839611178614,245.76338400528755,128.4933171324423,250.15201586252476,126.03280680437426C254.5406477197621,123.57229647630619,258.9292795769993,110.99635479951398,263.3179114342366,97.32685297691371C267.7065432914739,83.65735115431347,272.0951751487112,68.89428918590521,276.48380700594845,61.512758201701075C280.8724388631857,54.13122721749693,285.261070720423,54.13122721749693,289.64970257766026,54.04009720534626C294.03833443489754,53.948967193195585,298.42696629213486,53.76670716889424,302.81559814937214,53.49331713244223C307.2042300066094,53.21992709599024,311.5928618638467,52.85540704738757,315.98149372108395,52.03523693803155C320.3701255783212,51.21506682867554,324.7587574355585,49.939246658566184,329.14738929279576,51.76184690157955C333.53602115003304,53.58444714459292,337.9246530072703,58.505467800729015,342.3132848645076,73.63304981773996C346.7019167217448,88.7606318347509,351.0905485789821,114.09477521263669,355.4791804362194,132.04738760631835C359.86781229345667,150,364.256444150694,160.57108140947753,368.64507600793127,162.30255164034023C373.03370786516854,164.03402187120292,377.42233972240575,156.9258809234508,381.8109715796431,149.81773997569866C386.19960343688035,142.70959902794652,390.5882352941176,135.6014580801944,394.97686715135495,128.58444714459293C399.3654990085922,121.56743620899147,403.7541308658295,114.64155528554068,408.1427627230668,103.52369380315913C412.5313945803041,92.40583232077762,416.9200264375413,77.09599027946535,421.3086582947787,80.92345078979342C425.6972901520159,84.7509113001215,430.0859220092531,107.7156743620899,434.4745538664904,131.318347509113C438.86318572372767,154.92102065613608,443.2518175809649,179.16160388821382,447.6404494382022,184.81166464155527C452.0290812954395,190.46172539489672,456.41771315267675,177.5212636695018,460.8063450099141,152.09599027946535C465.19497686715135,126.6707168894289,469.5836087243886,88.76063183475092,474.063670411985,67.61846901579587C478.5437320995814,46.476306196840824,483.1152236175369,42.102065613608715,487.5952853051333,42.92223572296473C492.0753469927297,43.74240583232074,496.46397884996696,49.75698663426488,500.8526107072042,66.88942891859053C505.24124256444156,84.02187120291619,509.6298744216788,112.27217496962335,514.0185062789161,127.49088699878496C518.4071381361533,142.70959902794655,522.7957699933908,144.8967193195626,527.184401850628,153.91859052247875C531.5730337078652,162.9404617253949,535.9616655651025,178.7970838396112,540.3502974223397,172.78250303766706C544.738929279577,166.76792223572292,549.1275611368143,138.88213851761842,553.5161929940515,116.19076549210202C557.9048248512887,93.49939246658563,562.2934567085262,76.00243013365734,566.6820885657634,63.69987849331713C571.0707204230007,51.397326852976924,575.4593522802379,44.289185905224805,579.8479841374752,43.83353584447147C584.2366159947125,43.377885783718135,588.6252478519497,49.57472660996357,593.013879709187,58.50546780072906C597.4025115664243,67.43620899149454,601.7911434236615,79.10085054678007,606.1797752808989,93.0437424058323C610.5684071381362,106.98663426488456,614.9570389953734,123.20777642770351,619.3456708526107,137.6063183475091C623.7343027098481,152.0048602673147,628.1229345670853,164.58080194410695,632.5115664243225,151.00243013365738C636.9001982815598,137.4240583232078,641.288830138797,97.6913730255164,645.6774619960344,72.3572296476306C650.0660938532716,47.023086269744816,654.454725710509,36.087484811664616,658.8433575677462,31.8043742405832C663.2319894249835,27.52126366950178,667.6206212822208,29.890643985419143,672.009253139458,38.00121506682863C676.3978849966953,46.11178614823812,680.7865168539325,59.96354799513974,685.17514871117,77.6427703523694C689.5637805684072,95.32199270959906,693.9524124256444,116.82867557715677,698.3410442828817,128.94896719319564C702.7296761401191,141.0692588092345,707.1183079973563,143.80315917375452,711.5069398545935,139.61117861482379C715.8955717118309,135.41919805589302,720.2842035690682,124.3013365735115,724.6728354263054,116.46415552855404C729.0614672835427,108.62697448359658,733.4500991407799,104.07047387606316,737.8387309980171,113.63912515188333C742.2273628552545,123.2077764277035,746.6159947124917,146.90157958687723,751.0046265697289,166.13001215066825C755.3932584269663,185.35844471445924,759.7818902842035,200.12150668286753,764.1705221414408,204.40461725394894C768.5591539986781,208.68772782503038,772.9477858559153,202.49088699878493,777.3364177131526,197.93438639125148C781.7250495703898,193.37788578371809,786.1136814276273,190.46172539489672,790.5023132848645,188.91251518833533C794.8909451421018,187.36330498177395,799.279576999339,187.18104495747264,803.6682088565764,175.69866342648842C808.0568407138136,164.21628189550424,812.4454725710508,141.43377885783715,816.8341044282882,128.21992709599024C821.2227362855255,115.00607533414336,825.6113681427627,111.36087484811662,827.8056840713813,109.53827460510325L830,107.71567436208989',
},
+ {
+ name: 'line extends example',
+ a: 'M0,81L13,128L27,84L40,83L53,114L67,114L80,137L93,116L107,95L120,57L133,87L147,93L160,163L173,95L187,123L200,113',
+ b: 'M0,81L13,128L27,84L40,83L53,114L67,114L80,137L93,116L107,95L120,57L133,87L147,93L160,163L173,95L187,123L200,113L210,96L228,145L246,92L264,106L282,56L300,90',
+ scale: false,
+ },
{
name: 'graticule test',
a: 'M325.1483457087596,531.4452502639945L340.7606028278758,399.7423780391654L359.3445610837574,268.6082938654016L380.395962152234,138.02316901947256L403.36162136396405,7.912231358580129',
@@ -269,6 +311,49 @@ function formatDString(str) {
return (str || '').split(/(?=[MLCSTQAHV])/gi).join(' ');
}
+function showPathPoints(svg, transition) {
+ if (!optionShowPathPoints) {
+ return;
+ }
+
+ var path = svg.select('path');
+
+ var points = path.attr('d').split(/[MLCSTQAHVZ\s]/gi)
+ .filter(function (d) { return d; })
+ .map(function (d) { return d.split(',').map(function (x) { return +x; }); });
+
+ var binding = svg.selectAll('circle').data(points);
+
+ var entering = binding.enter().append('circle')
+ .attr('r', 5)
+ .style('fill', '#b0b')
+ .style('fill-opacity', 0.2)
+ .style('stroke', '#b0b');
+
+ binding = binding.merge(entering)
+ .attr('cx', function (d) { return d[0]; })
+ .attr('cy', function (d) { return d[1]; });
+
+ if (transition) {
+ binding.transition(transition)
+ .tween('cx cy', function (d) {
+ var node = d3.select(this), i = points.indexOf(d);
+ return function (t) {
+ var currPoints = path.attr('d').split(/[MLCSTQAHVZ\s]/gi)
+ .filter(function (d) { return d; })
+ .map(function (d) { return d.split(',').map(function (x) { return +x; }); });
+
+ if (!currPoints[i]) {
+ node.remove();
+ } else {
+ node.attr('cx', currPoints[i][0]);
+ node.attr('cy', currPoints[i][1]);
+ }
+ };
+ });
+ }
+}
+
function showDValues(root, dLine1, dLine2, pathNode, transition) {
if (!showPathValues) {
return;
@@ -281,10 +366,16 @@ function showDValues(root, dLine1, dLine2, pathNode, transition) {
var current = root.select('.path-d').html(formatDString(currentD));
if (transition) {
+ var first = true;
current.transition(transition)
.tween('text', function () {
var node = this, i = d3.interpolateString(dLine1, dLine2);
return function (t) {
+
+ if (first || (t > 0.05 && Math.floor(t * 100) % 10 === 0)) {
+ first = false;
+ // console.log(d3.select(pathNode).attr('d'), t);
+ }
node.innerHTML = formatDString(d3.select(pathNode).attr('d'));
};
});
@@ -292,24 +383,28 @@ function showDValues(root, dLine1, dLine2, pathNode, transition) {
}
function pathStringToExtent(str) {
- const asNumbers = str.replace(/([A-Z])/gi, ' ')
+ var asNumbers = str.replace(/([A-Z])/gi, ' ')
.replace(/\s+/g, ',')
.replace(/,,/g, ',')
.replace(/^,/, '')
.split(',')
- .map(d => +d)
- .filter(d => !isNaN(d));
+ .map(function (d) { return +d; })
+ .filter(function (d) { return !isNaN(d); });
return d3.extent(asNumbers);
}
-function makeExample(d) {
- var bbox = this.getBoundingClientRect();
+function makeExample(d, useInterpolatePath) {
var width = exampleWidth;
var height = exampleHeight;
- var container = d3.select(this);
+ var container = d3.select(this).append('div')
+ .classed('example-container', true)
+ .classed('using-d3-interpolate-path', useInterpolatePath)
+ .classed('using-d3-default', !useInterpolatePath);
// set the title
container.append('h4').text(d.name);
+ container.append('div').attr('class', 'interpolator-used')
+ .text(useInterpolatePath ? 'd3-interpolate-path' : 'd3 default interpolation');
// scale the paths to fit nicely in the box
var extent = pathStringToExtent(d.a + ' ' + d.b);
@@ -324,10 +419,12 @@ function makeExample(d) {
if (d.scale !== false) {
svg.attr('transform', 'scale(' + scaleFactorWidth + ' ' + scaleFactorHeight + ')');
+ } else {
+ svg.attr('transform', 'scale(' + scaleFactorWidth + ')');
}
// adjust the stroke for the scale factor
- const strokeWidth = 1.5 / Math.min(scaleFactorWidth, scaleFactorHeight);
+ var strokeWidth = 1.5 / Math.min(scaleFactorWidth, scaleFactorHeight);
var path = svg.append('path')
.style('stroke-width', strokeWidth)
@@ -351,9 +448,13 @@ function makeExample(d) {
'
+
+
+
+
+
\ No newline at end of file
diff --git a/rollup.config.js b/rollup.config.js
index 36ff1a1..fa5e007 100644
--- a/rollup.config.js
+++ b/rollup.config.js
@@ -14,6 +14,6 @@ export default {
external: Object.keys(globals),
targets: [
{ format: 'umd', dest: 'build/d3-interpolate-path.js' },
- { format: 'umd', dest: 'example/d3-interpolate-path.js' },
+ { format: 'umd', dest: 'docs/d3-interpolate-path.js' },
]
};
diff --git a/src/interpolatePath.js b/src/interpolatePath.js
index aa50ac8..4990eaf 100644
--- a/src/interpolatePath.js
+++ b/src/interpolatePath.js
@@ -1,4 +1,5 @@
import { interpolateString } from 'd3-interpolate';
+import splitCurve from './split';
/**
* List of params for each command type in a path `d` attribute
@@ -15,13 +16,23 @@ const typeMap = {
A: ['rx', 'ry', 'xAxisRotation', 'largeArcFlag', 'sweepFlag', 'x', 'y'],
};
+
+function arrayOfLength(length, value) {
+ const array = Array(length);
+ for (let i = 0; i < length; i++) {
+ array[i] = value;
+ }
+
+ return array;
+}
+
/**
* Convert to object representation of the command from a string
*
* @param {String} commandString Token string from the `d` attribute (e.g., L0,0)
* @return {Object} An object representing this command.
*/
-function commandObject(commandString) {
+function commandToObject(commandString) {
// convert all spaces to commas
commandString = commandString.trim().replace(/ /g, ',');
@@ -29,7 +40,7 @@ function commandObject(commandString) {
const args = commandString.substring(1).split(',');
return typeMap[type.toUpperCase()].reduce((obj, param, i) => {
// parse X as float since we need it to do distance checks for extending points
- obj[param] = param === 'x' ? parseFloat(args[i]) : args[i];
+ obj[param] = +args[i];
return obj;
}, { type });
}
@@ -112,79 +123,132 @@ function convertToSameType(aCommand, bCommand) {
}
/**
- * Extends an array of commands to the length of the second array
- * inserting points at the spot that is closest by X value. Ensures
- * all the points of commandsToExtend are in the extended array and that
- * only numPointsToExtend points are added.
+ * Interpolate between command objects commandStart and commandEnd segmentCount times.
+ * If the types are L, Q, or C then the curves are split as per de Casteljau's algorithm.
+ * Otherwise we just copy commandStart segmentCount - 1 times, finally ending with commandEnd.
*
- * @param {Object[]} commandsToExtend The commands array to extend
- * @param {Object[]} referenceCommands The commands array to match
- * @return {Object[]} The extended commands1 array
+ * @param {Object} commandStart Command object at the beginning of the segment
+ * @param {Object} commandEnd Command object at the end of the segment
+ * @param {Number} segmentCount The number of segments to split this into. If only 1
+ * Then [commandEnd] is returned.
+ * @return {Object[]} Array of ~segmentCount command objects between commandStart and
+ * commandEnd. (Can be segmentCount+1 objects if commandStart is type M).
*/
-function extend(commandsToExtend, referenceCommands, numPointsToExtend) {
- // map each command in B to a command in A by counting how many times ideally
- // a command in A was in the initial path (see https://github.com/pbeshai/d3-interpolate-path/issues/8)
- let initialCommandIndex;
- if (commandsToExtend.length > 1 && commandsToExtend[0].type === 'M') {
- initialCommandIndex = 1;
+function splitSegment(commandStart, commandEnd, segmentCount) {
+ let segments = [];
+
+ // line, quadratic bezier, or cubic bezier
+ if (commandEnd.type === 'L' || commandEnd.type === 'Q' || commandEnd.type === 'C') {
+ segments = segments.concat(splitCurve(commandStart, commandEnd, segmentCount));
+
+ // general case - just copy the same point
} else {
- initialCommandIndex = 0;
- }
+ const copyCommand = Object.assign({}, commandStart);
- const counts = referenceCommands.reduce((counts, refCommand, i) => {
- // skip first M
- if (i === 0 && refCommand.type === 'M') {
- counts[0] = 1;
- return counts;
+ // convert M to L
+ if (copyCommand.type === 'M') {
+ copyCommand.type = 'L';
}
- let minDistance = Math.abs(commandsToExtend[initialCommandIndex].x - refCommand.x);
- let minCommand = initialCommandIndex;
-
- // find the closest point by X position in A
- for (let j = initialCommandIndex + 1; j < commandsToExtend.length; j++) {
- const distance = Math.abs(commandsToExtend[j].x - refCommand.x);
- if (distance < minDistance) {
- minDistance = distance;
- minCommand = j;
- // since we assume sorted by X, once we find a value farther, we can return the min.
- } else {
- break;
+ segments = segments.concat(arrayOfLength(segmentCount - 1).map(() => copyCommand));
+ segments.push(commandEnd);
+ }
+
+ return segments;
+}
+/**
+ * Extends an array of commandsToExtend to the length of the referenceCommands by
+ * splitting segments until the number of commands match. Ensures all the actual
+ * points of commandsToExtend are in the extended array.
+ *
+ * @param {Object[]} commandsToExtend The command object array to extend
+ * @param {Object[]} referenceCommands The command object array to match in length
+ * @param {Function} excludeSegment a function that takes a start command object and
+ * end command object and returns true if the segment should be excluded from splitting.
+ * @return {Object[]} The extended commandsToExtend array
+ */
+function extend(commandsToExtend, referenceCommands, excludeSegment) {
+ // compute insertion points:
+ // number of segments in the path to extend
+ const numSegmentsToExtend = commandsToExtend.length - 1;
+
+ // number of segments in the reference path.
+ const numReferenceSegments = referenceCommands.length - 1;
+
+ // this value is always between [0, 1].
+ const segmentRatio = numSegmentsToExtend / numReferenceSegments;
+
+ // create a map, mapping segments in referenceCommands to how many points
+ // should be added in that segment (should always be >= 1 since we need each
+ // point itself).
+ // 0 = segment 0-1, 1 = segment 1-2, n-1 = last vertex
+ const countPointsPerSegment = arrayOfLength(numReferenceSegments).reduce((accum, d, i) => {
+ let insertIndex = Math.floor(segmentRatio * i);
+
+ // handle excluding segments
+ if (excludeSegment && insertIndex < commandsToExtend.length - 1 &&
+ excludeSegment(commandsToExtend[insertIndex], commandsToExtend[insertIndex + 1])) {
+ // set the insertIndex to the segment that this point should be added to:
+
+ // round the insertIndex essentially so we split half and half on
+ // neighbouring segments. hence the segmentRatio * i < 0.5
+ const addToPriorSegment = ((segmentRatio * i) % 1) < 0.5;
+
+ // only skip segment if we already have 1 point in it (can't entirely remove a segment)
+ if (accum[insertIndex]) {
+ // TODO - Note this is a naive algorithm that should work for most d3-area use cases
+ // but if two adjacent segments are supposed to be skipped, this will not perform as
+ // expected. Could be updated to search for nearest segment to place the point in, but
+ // will only do that if necessary.
+
+ // add to the prior segment
+ if (addToPriorSegment) {
+ if (insertIndex > 0) {
+ insertIndex -= 1;
+
+ // not possible to add to previous so adding to next
+ } else if (insertIndex < commandsToExtend.length - 1) {
+ insertIndex += 1;
+ }
+ // add to next segment
+ } else if (insertIndex < commandsToExtend.length - 1) {
+ insertIndex += 1;
+
+ // not possible to add to next so adding to previous
+ } else if (insertIndex > 0) {
+ insertIndex -= 1;
+ }
}
}
- counts[minCommand] = (counts[minCommand] || 0) + 1;
- return counts;
- }, {});
-
- // now extend the array adding in at the appropriate place as needed
- const extended = [];
- let numExtended = 0;
- for (let i = 0; i < commandsToExtend.length; i++) {
- // add in the initial point for this A command
- extended.push(commandsToExtend[i]);
-
- for (let j = 1; j < counts[i] && numExtended < numPointsToExtend; j++) {
- const commandToAdd = Object.assign({}, commandsToExtend[i]);
- // don't allow multiple Ms
- if (commandToAdd.type === 'M') {
- commandToAdd.type = 'L';
- } else {
- // try to set control points to x and y
- if (commandToAdd.x1 !== undefined) {
- commandToAdd.x1 = commandToAdd.x;
- commandToAdd.y1 = commandToAdd.y;
- }
+ accum[insertIndex] = (accum[insertIndex] || 0) + 1;
- if (commandToAdd.x2 !== undefined) {
- commandToAdd.x2 = commandToAdd.x;
- commandToAdd.y2 = commandToAdd.y;
- }
+ return accum;
+ }, []);
+
+ // extend each segment to have the correct number of points for a smooth interpolation
+ const extended = countPointsPerSegment.reduce((extended, segmentCount, i) => {
+ // if last command, just add `segmentCount` number of times
+ if (i === commandsToExtend.length - 1) {
+ const lastCommandCopies = arrayOfLength(segmentCount,
+ Object.assign({}, commandsToExtend[commandsToExtend.length - 1]));
+
+ // convert M to L
+ if (lastCommandCopies[0].type === 'M') {
+ lastCommandCopies.forEach(d => {
+ d.type = 'L';
+ });
}
- extended.push(commandToAdd);
- numExtended += 1;
+ return extended.concat(lastCommandCopies);
}
- }
+
+ // otherwise, split the segment segmentCount times.
+ return extended.concat(splitSegment(commandsToExtend[i], commandsToExtend[i + 1],
+ segmentCount));
+ }, []);
+
+ // add in the very first point since splitSegment only adds in the ones after it
+ extended.unshift(commandsToExtend[0]);
return extended;
}
@@ -198,11 +262,16 @@ function extend(commandsToExtend, referenceCommands, numPointsToExtend) {
*
* @param {String} a The `d` attribute for a path
* @param {String} b The `d` attribute for a path
+ * @param {Function} excludeSegment a function that takes a start command object and
+ * end command object and returns true if the segment should be excluded from splitting.
+ * @returns {Function} Interpolation functino that maps t ([0, 1]) to a path `d` string.
*/
-export default function interpolatePath(a, b) {
+export default function interpolatePath(a, b, excludeSegment) {
// remove Z, remove spaces after letters as seen in IE
const aNormalized = a == null ? '' : a.replace(/[Z]/gi, '').replace(/([MLCSTQAHV])\s*/gi, '$1');
const bNormalized = b == null ? '' : b.replace(/[Z]/gi, '').replace(/([MLCSTQAHV])\s*/gi, '$1');
+
+ // split so each command (e.g. L10,20 or M50,60) is its own entry in an array
const aPoints = aNormalized === '' ? [] : aNormalized.split(/(?=[MLCSTQAHV])/gi);
const bPoints = bNormalized === '' ? [] : bNormalized.split(/(?=[MLCSTQAHV])/gi);
@@ -225,8 +294,8 @@ export default function interpolatePath(a, b) {
}
// convert to command objects so we can match types
- let aCommands = aPoints.map(commandObject);
- let bCommands = bPoints.map(commandObject);
+ let aCommands = aPoints.map(commandToObject);
+ let bCommands = bPoints.map(commandToObject);
// extend to match equal size
const numPointsToExtend = Math.abs(bPoints.length - aPoints.length);
@@ -234,18 +303,19 @@ export default function interpolatePath(a, b) {
if (numPointsToExtend !== 0) {
// B has more points than A, so add points to A before interpolating
if (bCommands.length > aCommands.length) {
- aCommands = extend(aCommands, bCommands, numPointsToExtend);
+ aCommands = extend(aCommands, bCommands, excludeSegment);
// else if A has more points than B, add more points to B
} else if (bCommands.length < aCommands.length) {
- bCommands = extend(bCommands, aCommands, numPointsToExtend);
+ bCommands = extend(bCommands, aCommands, excludeSegment);
}
}
// commands have same length now.
- // convert A to the same type of B
+ // convert commands in A to the same type as those in B
aCommands = aCommands.map((aCommand, i) => convertToSameType(aCommand, bCommands[i]));
+ // convert back to command strings and concatenate to a path `d` string
let aProcessed = aCommands.map(commandToString).join('');
let bProcessed = bCommands.map(commandToString).join('');
@@ -256,6 +326,7 @@ export default function interpolatePath(a, b) {
bProcessed += 'Z';
}
+ // use d3's string interpolator to now interpolate between two path `d` strings.
const stringInterpolator = interpolateString(aProcessed, bProcessed);
return function pathInterpolator(t) {
diff --git a/src/split.js b/src/split.js
new file mode 100644
index 0000000..e0cd3ee
--- /dev/null
+++ b/src/split.js
@@ -0,0 +1,144 @@
+/**
+ * de Casteljau's algorithm for drawing and splitting bezier curves.
+ * Inspired by https://pomax.github.io/bezierinfo/
+ *
+ * @param {Number[][]} points Array of [x,y] points: [start, control1, control2, ..., end]
+ * The original segment to split.
+ * @param {Number} t Where to split the curve (value between [0, 1])
+ * @return {Object} An object { left, right } where left is the segment from 0..t and
+ * right is the segment from t..1.
+ */
+function decasteljau(points, t) {
+ const left = [];
+ const right = [];
+
+ function decasteljauRecurse(points, t) {
+ if (points.length === 1) {
+ left.push(points[0]);
+ right.push(points[0]);
+ } else {
+ const newPoints = Array(points.length - 1);
+
+ for (let i = 0; i < newPoints.length; i++) {
+ if (i === 0) {
+ left.push(points[0]);
+ }
+ if (i === newPoints.length - 1) {
+ right.push(points[i + 1]);
+ }
+
+ newPoints[i] = [
+ ((1 - t) * points[i][0]) + (t * points[i + 1][0]),
+ ((1 - t) * points[i][1]) + (t * points[i + 1][1]),
+ ];
+ }
+
+ decasteljauRecurse(newPoints, t);
+ }
+ }
+
+ if (points.length) {
+ decasteljauRecurse(points, t);
+ }
+
+ return { left, right: right.reverse() };
+}
+
+/**
+ * Convert segments represented as points back into a command object
+ *
+ * @param {Number[][]} points Array of [x,y] points: [start, control1, control2, ..., end]
+ * Represents a segment
+ * @return {Object} A command object representing the segment.
+ */
+function pointsToCommand(points) {
+ const command = {};
+
+ if (points.length === 4) {
+ command.x2 = points[2][0];
+ command.y2 = points[2][1];
+ }
+ if (points.length >= 3) {
+ command.x1 = points[1][0];
+ command.y1 = points[1][1];
+ }
+
+ command.x = points[points.length - 1][0];
+ command.y = points[points.length - 1][1];
+
+ if (points.length === 4) { // start, control1, control2, end
+ command.type = 'C';
+ } else if (points.length === 3) { // start, control, end
+ command.type = 'Q';
+ } else { // start, end
+ command.type = 'L';
+ }
+
+ return command;
+}
+
+
+/**
+ * Runs de Casteljau's algorithm enough times to produce the desired number of segments.
+ *
+ * @param {Number[][]} points Array of [x,y] points for de Casteljau (the initial segment to split)
+ * @param {Number} segmentCount Number of segments to split the original into
+ * @return {Number[][][]} Array of segments
+ */
+function splitCurveAsPoints(points, segmentCount) {
+ segmentCount = segmentCount || 2;
+
+ const segments = [];
+ let remainingCurve = points;
+ const tIncrement = 1 / segmentCount;
+
+ // x-----x-----x-----x
+ // t= 0.33 0.66 1
+ // x-----o-----------x
+ // r= 0.33
+ // x-----o-----x
+ // r= 0.5 (0.33 / (1 - 0.33)) === tIncrement / (1 - (tIncrement * (i - 1))
+
+ // x-----x-----x-----x----x
+ // t= 0.25 0.5 0.75 1
+ // x-----o----------------x
+ // r= 0.25
+ // x-----o----------x
+ // r= 0.33 (0.25 / (1 - 0.25))
+ // x-----o----x
+ // r= 0.5 (0.25 / (1 - 0.5))
+
+ for (let i = 0; i < segmentCount - 1; i++) {
+ const tRelative = tIncrement / (1 - (tIncrement * (i)));
+ const split = decasteljau(remainingCurve, tRelative);
+ segments.push(split.left);
+ remainingCurve = split.right;
+ }
+
+ // last segment is just to the end from the last point
+ segments.push(remainingCurve);
+
+ return segments;
+}
+
+/**
+ * Convert command objects to arrays of points, run de Casteljau's algorithm on it
+ * to split into to the desired number of segments.
+ *
+ * @param {Object} commandStart The start command object
+ * @param {Object} commandEnd The end command object
+ * @param {Number} segmentCount The number of segments to create
+ * @return {Object[]} An array of commands representing the segments in sequence
+ */
+export default function splitCurve(commandStart, commandEnd, segmentCount) {
+ const points = [[commandStart.x, commandStart.y]];
+ if (commandEnd.x1 != null) {
+ points.push([commandEnd.x1, commandEnd.y1]);
+ }
+ if (commandEnd.x2 != null) {
+ points.push([commandEnd.x2, commandEnd.y2]);
+ }
+ points.push([commandEnd.x, commandEnd.y]);
+
+ return splitCurveAsPoints(points, segmentCount).map(pointsToCommand);
+}
diff --git a/test/interpolatePath-test.js b/test/interpolatePath-test.js
index 4ebbae5..00d41bb 100644
--- a/test/interpolatePath-test.js
+++ b/test/interpolatePath-test.js
@@ -1,6 +1,43 @@
/* eslint-disable */
-var tape = require('tape'),
- interpolatePath = require('../').interpolatePath;
+const tape = require('tape');
+const interpolatePath = require('../').interpolatePath;
+const APPROX_MAX_T = 0.999999999999;
+const MIN_T = 0;
+
+// helper to convert a path string to an array (e.g. 'M5,5 L10,10' => ['M', 5, 5, 'L', 10, 10]
+function pathToItems(path) {
+ return path
+ .replace(/\s/g, '')
+ .split(/([A-Z,])/)
+ .filter(d => d !== '' && d !== ',')
+ .map(d => (isNaN(+d) ? d : +d))
+}
+
+// helper to ensure path1 and path2 are roughly equal
+function approximatelyEqual(path1, path2) {
+ // convert to numbers and letters
+ const path1Items = pathToItems(path1);
+ const path2Items = pathToItems(path2);
+ const epsilon = 0.001;
+
+ if (path1Items.length !== path2Items.length) {
+ return false;
+ }
+
+ for (let i = 0; i< path1Items.length; i++) {
+ if (typeof path1Items[i] === 'string' && path1Items[i] !== path2Items[i]) {
+ return false;
+ }
+
+ // otherwise it's a number, check if approximately equal
+ if (Math.abs(path1Items[i] - path2Items[i]) > epsilon) {
+ return false;
+ }
+ }
+
+ return true;
+}
+
tape('interpolatePath() interpolates line to line: len(A) = len(b)', function (t) {
const a = 'M0,0L10,10L100,100';
@@ -25,9 +62,11 @@ tape('interpolatePath() interpolates line to line: len(A) > len(b)', function (t
// should not be extended anymore and should match exactly
t.equal(interpolator(1), b);
+ t.equal(approximatelyEqual(interpolator(APPROX_MAX_T), 'M10,10L15,15L20,20'), true);
// should be half way between the last point of B and the last point of A
- t.equal(interpolator(0.5), 'M5,5L15,15L60,60');
+ // here we get 12.5 since we split the 10,10-20,20 segment and end at L15,15
+ t.equal(interpolator(0.5), 'M5,5L12.5,12.5L60,60');
t.end();
});
@@ -40,11 +79,11 @@ tape('interpolatePath() interpolates line to line: len(A) < len(b)', function (t
const interpolator = interpolatePath(a, b);
// should be extended to match the length of b
- t.equal(interpolator(0), 'M0,0L10,10L10,10');
- t.equal(interpolator(1), b);
+ t.equal(interpolator(0), 'M0,0L5,5L10,10');
+ t.equal(approximatelyEqual(interpolator(APPROX_MAX_T), 'M10,10L20,20L200,200'), true);
// should be half way between the last point of B and the last point of A
- t.equal(interpolator(0.5), 'M5,5L15,15L105,105');
+ t.equal(interpolator(0.5), 'M5,5L12.5,12.5L105,105');
t.end();
});
@@ -176,7 +215,7 @@ tape('interpolatePath() interpolates with other valid `d` characters', function
// should be halfway towards the first point of a
t.equal(interpolator(0.5), 'M2,2m2,2L2,2l2,2H2V2Q2,2,2,2q2,2,2,2C2,2,2,2,2,2c2,2,2,2,2,2'+
- 'T2,2t2,2S2,2,2,2s2,2,2,2A2,2,2,2,2,2,2');
+ 'T2,2t2,2S2,2,2,2s2,2,2,2A2,2,0.5,0.5,0.5,2,2');
t.end();
});
@@ -198,38 +237,24 @@ tape('interpolatePath() converts points in A to match types in B', function (t)
tape('interpolatePath() interpolates curves of different length', function (t) {
- const a = 'M0,0L3,3C1,1,2,2,4,4C3,3,4,4,6,6L8,0';
- const b = 'M2,2L3,3C5,5,6,6,4,4C6,6,7,7,5,5C8,8,9,9,6,6C10,10,11,11,7,7L8,8';
-
+ const a = 'M0,0C1,1,2,2,4,4C3,3,4,4,6,6';
+ const b = 'M2,2C5,5,6,6,4,4C6,6,7,7,5,5C8,8,9,9,6,6C10,10,11,11,7,7';
const interpolator = interpolatePath(a, b);
- t.equal(interpolator(0), 'M0,0L3,3C1,1,2,2,4,4C4,4,4,4,4,4C3,3,4,4,6,6C6,6,6,6,6,6L8,0');
- t.equal(interpolator(1), b);
-
- // should be halfway towards the first point of a
- t.equal(interpolator(0.5), 'M1,1L3,3C3,3,4,4,4,4C5,5,5.5,5.5,4.5,4.5C5.5,5.5,6.5,6.5,6,6C8,8,8.5,8.5,6.5,6.5L8,4');
+ t.equal(interpolator(0),
+ 'M0,0C0.5,0.5,1,1,1.625,1.625C2.25,2.25,3,3,4,4C3.5,3.5,3.5,3.5,3.875,3.875C4.25,4.25,5,5,6,6');
- t.end();
-});
-
-
-tape('interpolatePath() adds to the closest point', function (t) {
- const a = 'M0,0L4,0L20,0';
- const b = 'M0,4L1,4L3,0L4,0L10,0L14,0L18,0';
-
-
- const interpolator = interpolatePath(a, b);
-
- t.equal(interpolator(0), 'M0,0L4,0L4,0L4,0L4,0L20,0L20,0');
t.equal(interpolator(1), b);
// should be halfway towards the first point of a
- t.equal(interpolator(0.5), 'M0,2L2.5,2L3.5,0L4,0L7,0L17,0L19,0');
+ t.equal(interpolator(0.5), 'M1,1C2.75,2.75,3.5,3.5,2.8125,2.8125C4.125,4.125,5,5,' +
+ '4.5,4.5C5.75,5.75,6.25,6.25,4.9375,4.9375C7.125,7.125,8,8,6.5,6.5');
t.end();
});
+
tape('interpolatePath() handles the case where path commands are followed by a space', function (t) {
// IE bug fix.
const a = 'M 0 0 L 10 10 L 100 100';
@@ -241,9 +266,11 @@ tape('interpolatePath() handles the case where path commands are followed by a s
// should not be extended anymore and should match exactly
t.equal(interpolator(1), b);
+ t.equal(approximatelyEqual(interpolator(APPROX_MAX_T), 'M10,10L15,15L20,20'), true);
// should be half way between the last point of B and the last point of A
- t.equal(interpolator(0.5), 'M5,5L15,15L60,60');
+ // here we get 12.5 since we split the 10,10-20,20 segment and end at L15,15
+ t.equal(interpolator(0.5), 'M5,5L12.5,12.5L60,60');
t.end();
});