diff --git a/README.md b/README.md index 2d6cb7f..c9599bb 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,12 @@ [![npm version](https://badge.fury.io/js/d3-interpolate-path.svg)](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/ ![d3-interpolate-path demo](http://peterbeshai.com/vis/d3-interpolate-path/d3-interpolate-path-demo.gif) @@ -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) { '
' + ''); } - - loopPath(path, d.a, d.b, pathTextRoot); + if (useInterpolatePath) { + loopPath(path, d.a, d.b, pathTextRoot, svg, d.excludeSegment); + } else { + loopPathBasic(path, d.a, d.b); + } showDValues(pathTextRoot, d.a, d.b, path.node()); + showPathPoints(svg); } var showExamples = examples.filter(function (d, i) { @@ -367,30 +468,15 @@ if (showMainExample) { // Initialize example grid var root = d3.select('.examples'); -const selection = root.selectAll('.example') +var selection = root.selectAll('.example') .data(showExamples) .enter(); selection.append('div') .attr('class', 'example') - .style('width', exampleWidth + 'px') - .each(makeExample); - -d3.select('#toggle-interpolate-path') - .on('click', function () { - useInterpolatePath = !useInterpolatePath; - - var nextInterpolationMode; - var currentInterpolator; - if (useInterpolatePath) { - nextInterpolationMode = 'D3 default interpolation'; - currentInterpolator = 'd3-interpolate-path'; - } else { - nextInterpolationMode = 'd3-interpolate-path'; - currentInterpolator = 'D3 default interpolation'; - } - - d3.select(this).text('Switch to ' + nextInterpolationMode); - d3.select('#interpolator-status').text('Interpolating with ' + currentInterpolator + '.'); - console.log('Use d3-interpolate-path', useInterpolatePath); + // .style('width', exampleWidth + 'px') + .each(function (d) { + makeExample.call(this, d, true); + makeExample.call(this, d, false); }); + diff --git a/docs/index.html b/docs/index.html index 931959c..1f14fbb 100644 --- a/docs/index.html +++ b/docs/index.html @@ -4,65 +4,26 @@ d3-interpolate-path -

d3-interpolate-path

+

+ d3-interpolate-path is a D3 plugin that adds an + interpolator + optimized for SVG <path> elements. See the GitHub page + for details on usage. +

+

+ A previous version of the library is currently better at extending lines. + See how the older v1.2.0 performs. +

Code on GitHub

Visual Test Examples

-
- - Interpolating with d3-interpolate-path. - - -
diff --git a/docs/v1/d3-interpolate-path-v1.2.0.js b/docs/v1/d3-interpolate-path-v1.2.0.js new file mode 100644 index 0000000..1c5a656 --- /dev/null +++ b/docs/v1/d3-interpolate-path-v1.2.0.js @@ -0,0 +1,285 @@ +(function (global, factory) { + typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports, require('d3-interpolate')) : + typeof define === 'function' && define.amd ? define(['exports', 'd3-interpolate'], factory) : + (factory((global.d3 = global.d3 || {}),global.d3)); +}(this, (function (exports,d3Interpolate) { 'use strict'; + +/** + * List of params for each command type in a path `d` attribute + */ +var typeMap = { + M: ['x', 'y'], + L: ['x', 'y'], + H: ['x'], + V: ['y'], + C: ['x1', 'y1', 'x2', 'y2', 'x', 'y'], + S: ['x2', 'y2', 'x', 'y'], + Q: ['x1', 'y1', 'x', 'y'], + T: ['x', 'y'], + A: ['rx', 'ry', 'xAxisRotation', 'largeArcFlag', 'sweepFlag', 'x', 'y'] +}; + +/** + * 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) { + // convert all spaces to commas + commandString = commandString.trim().replace(/ /g, ','); + + var type = commandString[0]; + 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]; + return obj; + }, { type: type }); +} + +/** + * Converts a command object to a string to be used in a `d` attribute + * @param {Object} command A command object + * @return {String} The string for the `d` attribute + */ +function commandToString(command) { + var type = command.type; + + var params = typeMap[type.toUpperCase()]; + return '' + type + params.map(function (p) { + return command[p]; + }).join(','); +} + +/** + * Converts command A to have the same type as command B. + * + * e.g., L0,5 -> C0,5,0,5,0,5 + * + * Uses these rules: + * x1 <- x + * x2 <- x + * y1 <- y + * y2 <- y + * rx <- 0 + * ry <- 0 + * xAxisRotation <- read from B + * largeArcFlag <- read from B + * sweepflag <- read from B + * + * @param {Object} aCommand Command object from path `d` attribute + * @param {Object} bCommand Command object from path `d` attribute to match against + * @return {Object} aCommand converted to type of bCommand + */ +function convertToSameType(aCommand, bCommand) { + var conversionMap = { + x1: 'x', + y1: 'y', + x2: 'x', + y2: 'y' + }; + + var readFromBKeys = ['xAxisRotation', 'largeArcFlag', 'sweepFlag']; + + // convert (but ignore M types) + if (aCommand.type !== bCommand.type && bCommand.type.toUpperCase() !== 'M') { + (function () { + var aConverted = {}; + Object.keys(bCommand).forEach(function (bKey) { + var bValue = bCommand[bKey]; + // first read from the A command + var aValue = aCommand[bKey]; + + // if it is one of these values, read from B no matter what + if (aValue === undefined) { + if (readFromBKeys.includes(bKey)) { + aValue = bValue; + } else { + // if it wasn't in the A command, see if an equivalent was + if (aValue === undefined && conversionMap[bKey]) { + aValue = aCommand[conversionMap[bKey]]; + } + + // if it doesn't have a converted value, use 0 + if (aValue === undefined) { + aValue = 0; + } + } + } + + aConverted[bKey] = aValue; + }); + + // update the type to match B + aConverted.type = bCommand.type; + aCommand = aConverted; + })(); + } + + return aCommand; +} + +/** + * 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. + * + * @param {Object[]} commandsToExtend The commands array to extend + * @param {Object[]} referenceCommands The commands array to match + * @return {Object[]} The extended commands1 array + */ +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; + } else { + initialCommandIndex = 0; + } + + var counts = referenceCommands.reduce(function (counts, refCommand, i) { + // skip first M + if (i === 0 && refCommand.type === 'M') { + counts[0] = 1; + return counts; + } + + 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; + } + } + + 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 = 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; + } + + if (commandToAdd.x2 !== undefined) { + commandToAdd.x2 = commandToAdd.x; + commandToAdd.y2 = commandToAdd.y; + } + } + extended.push(commandToAdd); + numExtended += 1; + } + } + + return extended; +} + +/** + * Interpolate from A to B by extending A and B during interpolation to have + * the same number of points. This allows for a smooth transition when they + * have a different number of points. + * + * Ignores the `Z` character in paths unless both A and B end with it. + * + * @param {String} a The `d` attribute for a path + * @param {String} b The `d` attribute for a path + */ +function interpolatePath(a, b) { + // 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'); + var aPoints = aNormalized === '' ? [] : aNormalized.split(/(?=[MLCSTQAHV])/gi); + var bPoints = bNormalized === '' ? [] : bNormalized.split(/(?=[MLCSTQAHV])/gi); + + // if both are empty, interpolation is always the empty string. + if (!aPoints.length && !bPoints.length) { + return function nullInterpolator() { + return ''; + }; + } + + // if A is empty, treat it as if it used to contain just the first point + // of B. This makes it so the line extends out of from that first point. + if (!aPoints.length) { + aPoints.push(bPoints[0]); + + // otherwise if B is empty, treat it as if it contains the first point + // of A. This makes it so the line retracts into the first point. + } else if (!bPoints.length) { + bPoints.push(aPoints[0]); + } + + // convert to command objects so we can match types + var aCommands = aPoints.map(commandObject); + var bCommands = bPoints.map(commandObject); + + // extend to match equal size + var numPointsToExtend = Math.abs(bPoints.length - aPoints.length); + + 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); + + // else if A has more points than B, add more points to B + } else if (bCommands.length < aCommands.length) { + bCommands = extend(bCommands, aCommands, numPointsToExtend); + } + } + + // commands have same length now. + // convert A to the same type of B + aCommands = aCommands.map(function (aCommand, i) { + return convertToSameType(aCommand, bCommands[i]); + }); + + var aProcessed = aCommands.map(commandToString).join(''); + var bProcessed = bCommands.map(commandToString).join(''); + + // if both A and B end with Z add it back in + if ((a == null || a[a.length - 1] === 'Z') && (b == null || b[b.length - 1] === 'Z')) { + aProcessed += 'Z'; + bProcessed += 'Z'; + } + + var stringInterpolator = d3Interpolate.interpolateString(aProcessed, bProcessed); + + return function pathInterpolator(t) { + // at 1 return the final value without the extensions used during interpolation + if (t === 1) { + return b == null ? '' : b; + } + + return stringInterpolator(t); + }; +} + +exports.interpolatePath = interpolatePath; + +Object.defineProperty(exports, '__esModule', { value: true }); + +}))); \ No newline at end of file diff --git a/docs/v1/example.css b/docs/v1/example.css new file mode 100644 index 0000000..25ecf4f --- /dev/null +++ b/docs/v1/example.css @@ -0,0 +1,85 @@ +.main-header { + margin: 0 0 4px; +} + +.main-link a { + color: #888; +} +.main-link { + margin-top: 0; +} + +body { + font: 14px sans-serif; + padding: 15px; +} + +.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/v1/examples.js b/docs/v1/examples.js new file mode 100644 index 0000000..8c03eb0 --- /dev/null +++ b/docs/v1/examples.js @@ -0,0 +1,483 @@ +/** + * Apologies for this code. It's kind of hacked together to quickly demonstrate things. + */ +var version = 'v1.2.0'; +var exampleWidth = 250; +var exampleHeight = 200; +var showMainExample = !window.location.search.includes('showMainExample=0'); +var showPathValues = window.location.search.includes('showPathValues=1'); +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); + +// helper to loop a path between two points +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(delayTime) + .duration(duration) + .attr('d', dPath2) + .transition() + .delay(delayTime) + .duration(duration) + .attr('d', dPath1) + .on('end', looper); + }; + looper(); +} + +// helper to loop a path between two points using d3-interpolate-path +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(delayTime) + .duration(duration) + .attrTween('d', function () { + return d3.interpolatePath(d3.select(this).attr('d'), dPath2, excludeSegment); + }) + .on('start', function (a) { + if (pathTextRoot) { + // 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(delayTime) + .duration(duration) + .attrTween('d', function () { + return d3.interpolatePath(d3.select(this).attr('d'), dPath1, excludeSegment); + }) + .on('start', function (a) { + if (pathTextRoot) { + // 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); + }; + looper(); +} + +function mainExample() { + var dataLine1 = [[0, 0], [200, 100], [400, 50], [500, 80]]; + var dataLine2 = [[0, 100], [100, 50], [220, 80], [250, 0], + [300, 20], [350, 30], [400, 100], [420, 10], [430, 90], + [500, 40]]; + var data = dataLine1.concat(dataLine2); + + var width = 600; + var height = 480; + var lineHeight = 120; + + var x = d3.scaleLinear() + .domain(d3.extent(data, function (d) { return d[0]; })) + .range([0, width]); + + var y = d3 + .scaleLinear() + .domain(d3.extent(data, function (d) { return d[1]; })) + .range([lineHeight * 0.8, lineHeight * 0.5]); + + + var svg = d3.select('.chart-container').append('svg') + .attr('width', width) + .attr('height', height) + + var line = d3.line() + .curve(d3.curveLinear) + .x(function (d) { return x(d[0]); }) + .y(function (d) { return y(d[1]); }); + + + var g = svg.append('g'); + + g.append('path') + .datum(dataLine1) + .attr('d', line); + + g.append('text') + .attr('y', 25) + .text('Line A'); + + g = svg.append('g') + .attr('transform', 'translate(0 ' + lineHeight + ')') + .attr('class', 'using-d3-default'); + + loopPathBasic(g.append('path'), line(dataLine1), line(dataLine2), true); + + g.append('text') + .attr('y', 25) + .text('d3 default interpolation'); + + g = svg.append('g') + .attr('transform', 'translate(0 ' + lineHeight * 2 + ')') + + loopPath(g.append('path'), line(dataLine1), line(dataLine2), null, null, null, true); + + g.append('text') + .attr('y', 25) + .text('d3-interpolate-path ' + version); + + g = svg.append('g') + .attr('transform', 'translate(0 ' + lineHeight * 3 + ')') + + g.append('path') + .datum(dataLine2) + .attr('d', line); + + g.append('text') + .attr('y', 25) + .text('Line B'); + +} + + +var examples = [ + { + 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', + a: 'M150,0 L280,100 L150,200 L20,100Z', + b: 'M150,10 L230,30 L270,100 L230,170 L150,190 L70,170 L30,100 L70,30Z', + scale: false, + className: 'filled', + }, + { + name: 'simple vertical line test', + a: 'M0,0 L300,200', + b: 'M100,20 L150,80 L200,140 L300,200', + scale: false, + }, + { + name: 'vertical line test', + a: 'M150,0 L200,40 L100,60 L120,80 L50,100 L250,150 L120,200', + b: 'M120,0 L100,30 L20,45 L220,60 L270,90 L180,95 L50,130 L85,140 L140,150 L190,175 L60,195', + scale: false, + }, + { + name: 'curve test', + 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', + b: 'M354.49306197556837,528.5099972371023L344.61144068364655,289.8103838246071L333.8706761621328,30.357378766024', + }, + { + name: 'line to line: len(A) = len(b)', + a: 'M0,0L10,10L100,100', + b: 'M10,10L20,20L200,200', + }, + { + name: 'line to line: len(A) > len(b)', + a: 'M0,0L10,10L100,100', + b: 'M10,10L20,20', + }, + { + name: 'line to line: len(A) < len(b)', + a: 'M0,0L10,10', + b: 'M10,10L20,20L200,200', + }, + { + name: 'line to line: len(A)=1', + a: 'M0,0Z', + b: 'M10,10L20,20L200,200', + }, + { + name: 'line to line: len(B)=1', + a: 'M0,0L10,10L100,100', + b: 'M10,10Z', + }, + { + name: 'line to line: A is null', + a: null, + b: 'M10,10L20,20L200,200', + }, + { + name: 'line to line: B is null', + a: 'M0,0L10,10L100,100', + b: null, + }, + { + name: 'line to line: A is null and B is null', + a: null, + b: null, + }, + { + name: 'where both A and B end in Z', + a: 'M0,0Z', + b: 'M10,10L20,20Z', + }, + { + name: 'where A=null, B ends in Z', + a: null, + b: 'M10,10L20,20Z', + }, + { + name: 'with other valid `d` characters', + a: 'M0,0m0,0L0,0l0,0H0V0Q0,0,0,0q0,0,0,0C0,0,0,0,0,0c0,0,0,0,0,0T0,0t0,0' + + 'S0,0,0,0s0,0,0,0A0,0,0,0,0,0,0', + b: 'M4,4m4,4L4,4l4,4H4V4Q4,4,4,4q4,4,4,4C4,4,4,4,4,4c4,4,4,4,4,4T4,4t4,4' + + 'S4,4,4,4s4,4,4,4A4,4,0,0,0,4,4', + }, + { + name: 'converts points in A to match types in B', + a: 'M2,2 L3,3 C4,4,4,4,4,4 C5,5,5,5,5,5 L6,6 L7,7', + b: 'M4,4 C5,5,5,5,5,5 L6,6 S7,7,7,7 H8 V9', + }, + { + name: 'curves of different length', + a: 'M0,0L3,3C1,1,2,2,4,4C3,3,4,4,6,6L8,0', + 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', + }, + { + name: 'adds to the closest point', + a: 'M0,0L4,0L20,0', + b: 'M0,4L1,4L3,0L4,0L10,0L14,0L18,0', + }, + { + name: 'handles the case where path commands are followed by a space', + a: 'M 0 0 L 10 10 L 100 100', + b: 'M10,10L20,20', + }, + { + name: 'includes M when extending if it is the only item', + a: 'M0,0', + b: 'M10,10L20,20L30,30', + }, + { + name: 'handles negative numbers properly', + a: 'M0,0L0,0', + b: 'M-10,-10L20,20', + }, +]; + +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; + } + + root.select('.path-d-original').html(formatDString(dLine1)); + root.select('.path-d-end').html(formatDString(dLine2)); + // var current = root.select('.path-d').html(formatDString(dLine1)); + var currentD = d3.select(pathNode).attr('d'); + 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')); + }; + }); + } +} + +function pathStringToExtent(str) { + var asNumbers = str.replace(/([A-Z])/gi, ' ') + .replace(/\s+/g, ',') + .replace(/,,/g, ',') + .replace(/^,/, '') + .split(',') + .map(function (d) { return +d; }) + .filter(function (d) { return !isNaN(d); }); + return d3.extent(asNumbers); +} + +function makeExample(d, useInterpolatePath) { + var width = exampleWidth; + var height = exampleHeight; + 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 ' + version) : 'd3 default interpolation'); + + // scale the paths to fit nicely in the box + var extent = pathStringToExtent(d.a + ' ' + d.b); + var scaleFactorWidth = Math.min(1, width / extent[1]); + var scaleFactorHeight = Math.min(1, height / extent[1]); + + // add the SVG + var svg = container.append('svg') + .attr('width', width) + .attr('height', height) + .append('g'); + + if (d.scale !== false) { + svg.attr('transform', 'scale(' + scaleFactorWidth + ' ' + scaleFactorHeight + ')'); + } else { + svg.attr('transform', 'scale(' + scaleFactorWidth + ')'); + } + + // adjust the stroke for the scale factor + var strokeWidth = 1.5 / Math.min(scaleFactorWidth, scaleFactorHeight); + + var path = svg.append('path') + .style('stroke-width', strokeWidth) + .attr('class', d.className); + + + // add in the Path text + if (showPathValues) { + var pathTextRoot = container.append('div'); + pathTextRoot.html( + '
' + + 'Path A' + + '
' + + '
' + + '
' + + 'Current d' + + '
' + + '
' + + '
' + + 'Path B' + + '
' + + '
'); + } + if (useInterpolatePath) { + loopPath(path, d.a, d.b, pathTextRoot, svg, d.excludeSegment); + } else { + loopPathBasic(path, d.a, d.b); + } + showDValues(pathTextRoot, d.a, d.b, path.node()); + showPathPoints(svg); +} + +var showExamples = examples.filter(function (d, i) { + return typeof activeExamples === 'undefined' || activeExamples.includes(i); +}); + +// Initialize main example area +if (showMainExample) { + mainExample(); +} + +// Initialize example grid +var root = d3.select('.examples'); +var selection = root.selectAll('.example') + .data(showExamples) + .enter(); + +selection.append('div') + .attr('class', 'example') + // .style('width', exampleWidth + 'px') + .each(function (d) { + makeExample.call(this, d, true); + makeExample.call(this, d, false); + }); + diff --git a/docs/v1/index.html b/docs/v1/index.html new file mode 100644 index 0000000..be32c1e --- /dev/null +++ b/docs/v1/index.html @@ -0,0 +1,31 @@ + + + + d3-interpolate-path v1.2.0 + + + + +

d3-interpolate-path v1.2.0

+ +

This example page shows an older version, v1.2.0. See the latest version.

+

+ d3-interpolate-path is a D3 plugin that adds an + interpolator + optimized for SVG <path> elements. See the GitHub page + for details on usage. +

+
+

+ Code on GitHub +

+ +

Visual Test Examples

+ +
+
+ + + + + \ 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(); });