diff --git a/src/utils/d3-decorators.js b/src/utils/d3-decorators.js index bc1f861b5..c6e54ecfe 100644 --- a/src/utils/d3-decorators.js +++ b/src/utils/d3-decorators.js @@ -593,7 +593,7 @@ var d3_createPathTween = ( } var points = utils.unique(data, idGetter).map(pointConvertor); - var interpolateLine = getLineInterpolator(interpolationType); + var interpolateLine = getLineInterpolator(interpolationType) || getLineInterpolator('linear'); var pointsTo = interpolateLine(points); var pointsFrom = this[pointStore]; diff --git a/src/utils/path/bezier.js b/src/utils/path/bezier.js index a081d7dc7..79af91c75 100644 --- a/src/utils/path/bezier.js +++ b/src/utils/path/bezier.js @@ -23,7 +23,16 @@ export function getBezierPoint(t, ...p) { x.unshift(t); y.unshift(t); return { - x: bezier.apply(null, x), - y: bezier.apply(null, y) + x: bezier(...x), + y: bezier(...y) }; } + +export function splitCubicSegment(t, p0, c0, c1, p1) { + var c2 = getBezierPoint(t, p0, c0); + var c3 = getBezierPoint(t, p0, c0, c1); + var c4 = getBezierPoint(t, c0, c1, p1); + var c5 = getBezierPoint(t, c1, p1); + var m = getBezierPoint(t, c3, c4); + return [p0, c2, c3, m, c4, c5, p1]; +} diff --git a/src/utils/path/interpolators/path-points.js b/src/utils/path/interpolators/path-points.js index 428f69b3b..644e79190 100644 --- a/src/utils/path/interpolators/path-points.js +++ b/src/utils/path/interpolators/path-points.js @@ -1,5 +1,5 @@ import {utils} from '../../utils'; -import {bezier, getBezierPoint} from '../bezier'; +import {getBezierPoint, splitCubicSegment as split} from '../bezier'; /** * Returns intermediate line or curve between two sources. @@ -694,22 +694,15 @@ function getDistance(x0, y0, x, y) { } function splitCubicSegment(t, [p0, c0, c1, p1]) { - var r = Object.keys(p1) - .reduce((memo, k) => { - if (k === 'x' || k === 'y') { - memo[k] = bezier(t, p0[k], c0[k], c1[k], p1[k]); - } else if (k !== 'id') { - memo[k] = interpolateValue(p0[k], p1[k], t); - } - return memo; - }, {}); - var c2 = getBezierPoint(t, p0, c0); - var c3 = getBezierPoint(t, p0, c0, c1); - var c4 = getBezierPoint(t, c0, c1, p1); - var c5 = getBezierPoint(t, c1, p1); - [c2, c3, c4, c5].forEach(c => c.isCubicControl = true); - - return [p0, c2, c3, r, c4, c5, p1]; + var seg = split(t, p0, c0, c1, p1); + [seg[1], seg[2], seg[4], seg[5]].forEach(c => c.isCubicControl = true); + Object.keys(p1).forEach((k) => { + if (k !== 'x' && k !== 'y' && k !== 'id') { + seg[3][k] = interpolateValue(p0[k], p1[k], t); + } + }); + + return seg; } function multipleSplitCubicSegment(ts, seg) { diff --git a/src/utils/path/interpolators/smooth.js b/src/utils/path/interpolators/smooth.js index 0dcae4090..2f7adefb2 100644 --- a/src/utils/path/interpolators/smooth.js +++ b/src/utils/path/interpolators/smooth.js @@ -1,4 +1,4 @@ -import {bezier, getBezierPoint} from '../bezier'; +import {getBezierPoint} from '../bezier'; /** * Returns smooth cubic spline. @@ -37,7 +37,7 @@ function getCubicSpline(points, limited) { } var curve = new Array((points.length - 1) * 3 + 1); - var c0, p1, c3, c1x, c1y, c2x, c2y, qx, qy, qt, tan, dx1, dx2; + var c0, p1, c3, c1x, c1y, c2x, c2y, qx, qy, qt, tan, dx1, dx2, kl; for (var i = 0; i < points.length; i++) { curve[i * 3] = points[i]; if (i > 0) { @@ -51,19 +51,19 @@ function getCubicSpline(points, limited) { c0 = result[i - 5]; p1 = result[i - 3]; c3 = result[i - 1]; - if ((p1.x - c0.x) * (c3.x - p1.x) === 0) { - c1x = bezier(0.5, c0.x, p1.x); - c2x = bezier(0.5, p1.x, c3.x); - c1y = bezier(0.5, c0.y, p1.y); - c2y = bezier(0.5, p1.y, c3.y); + if ((p1.x - c0.x) * (c3.x - p1.x) * 1e12 < 1) { + c1x = interpolate(c0.x, p1.x, 0.5); + c2x = interpolate(p1.x, c3.x, 0.5); + c1y = interpolate(c0.y, p1.y, 0.5); + c2y = interpolate(p1.y, c3.y, 0.5); } else { qt = (p1.x - c0.x) / (c3.x - c0.x); qx = (p1.x - c0.x * (1 - qt) * (1 - qt) - c3.x * qt * qt) / (2 * (1 - qt) * qt); qy = (p1.y - c0.y * (1 - qt) * (1 - qt) - c3.y * qt * qt) / (2 * (1 - qt) * qt); - c1x = bezier(qt, c0.x, qx); - c2x = bezier(qt, qx, c3.x); - c1y = bezier(qt, c0.y, qy); - c2y = bezier(qt, qy, c3.y); + c1x = interpolate(c0.x, qx, qt); + c2x = interpolate(qx, c3.x, qt); + c1y = interpolate(c0.y, qy, qt); + c2y = interpolate(qy, c3.y, qt); if (limited) { dx1 = (p1.x - c1x); @@ -73,10 +73,14 @@ function getCubicSpline(points, limited) { tan = 0; } else { if (p1.y > c0.y === c2y > c3.y) { - dx2 = dx2 * (c3.y - p1.y) / (c2y - p1.y); + kl = ((c3.y - p1.y) / (c2y - p1.y)); + dx2 = interpolate(dx2 * kl, dx2, 1 / (1 + Math.abs(kl))); + tan = (c3.y - p1.y) / dx2; } if (p1.y > c0.y === c1y < c0.y) { - dx1 = dx1 * (p1.y - c0.y) / (p1.y - c1y); + kl = ((p1.y - c0.y) / (p1.y - c1y)); + dx1 = interpolate(dx1 * kl, dx1, 1 / (1 + Math.abs(kl))); + tan = (p1.y - c0.y) / dx1; } } c1x = p1.x - dx1; diff --git a/src/utils/path/svg/brush-line.js b/src/utils/path/svg/brush-line.js index a4c9f637c..f28c7c779 100644 --- a/src/utils/path/svg/brush-line.js +++ b/src/utils/path/svg/brush-line.js @@ -1,4 +1,7 @@ -import {bezier, getBezierPoint} from '../bezier'; +import { + getBezierPoint as bezierPt, + splitCubicSegment as split +} from '../bezier'; /** * Returns line with variable width. @@ -9,11 +12,11 @@ export function getBrushLine(points) { return ''; } if (points.length === 1) { - return getSegment(points[0], points[0]); + return getCirclePath(points[0]); } var segments = []; for (var i = 1; i < points.length; i++) { - segments.push(getSegment(points[i - 1], points[i])); + segments.push(getStraightSegmentPath(points[i - 1], points[i])); } return segments.join(' '); } @@ -27,178 +30,188 @@ export function getBrushCurve(points) { return ''; } if (points.length === 1) { - return getSegment(points[0], points[0]); + return getCirclePath(points[0]); } - - // NOTE: Split segments for better visual result. - // TODO: Split when necessary (e.g. some angle change threshold). - points = points.slice(0); - for (var i = points.length - 1, a, c1, c2, b, seg; i >= 3; i -= 3) { - a = points[i - 3]; - c1 = points[i - 2]; - c2 = points[i - 1]; - b = points[i]; - seg = splitCurveSegment(a, c1, c2, b); - points.splice.apply(points, [i - 2, 2].concat(seg.slice(1, 6))); - } - var segments = []; - for (i = 3; i < points.length; i += 3) { - segments.push(getCurveSegment(points[i - 3], points[i - 2], points[i - 1], points[i])); + for (var i = 3; i < points.length; i += 3) { + segments.push(getCurveSegmentPath(points[i - 3], points[i - 2], points[i - 1], points[i])); } return segments.join(' '); } -/** - * Returns single circle as part of SVG path. - */ -function getCircle(x, y, r) { +function getCirclePath(pt) { + var r = (pt.size / 2); return [ - `M${x},${y - r}`, + `M${pt.x},${pt.y - r}`, `A${r},${r} 0 0 1`, - `${x},${y + r}`, + `${pt.x},${pt.y + r}`, `A${r},${r} 0 0 1`, - `${x},${y - r}`, + `${pt.x},${pt.y - r}`, 'Z' ].join(' '); } -/** - * Returns two circles joined with tangents. - */ -function getSegment(a, b) { - var mainDistance = getDistance(a, b); - if (mainDistance === 0 || - (mainDistance + a.size / 2 <= b.size / 2) || - (mainDistance + b.size / 2 <= a.size / 2) - ) { - // Return single circle, if one is inside another - var largerPt = a.size > b.size ? a : b; - var radius = largerPt.size / 2; - return getCircle(largerPt.x, largerPt.y, radius); +function getStraightSegmentPath(a, b) { + var tan = getCirclesTangents(a, b); + if (!tan) { + return getCirclePath((a.size > b.size ? a : b)); } - - var mainAngle = getAngle(a, b); - var tangentAngle = Math.asin((a.size - b.size) / mainDistance / 2); - var angleLeft = mainAngle - Math.PI / 2 + tangentAngle; - var angleRight = mainAngle + Math.PI / 2 - tangentAngle; - - var tangentLeftA = getPolarPoint(a, a.size / 2, angleLeft); - var tangentLeftB = getPolarPoint(b, b.size / 2, angleLeft); - var tangentRightA = getPolarPoint(a, a.size / 2, angleRight); - var tangentRightB = getPolarPoint(b, b.size / 2, angleRight); - return [ - `M${tangentLeftA.x},${tangentLeftA.y}`, - `L${tangentLeftB.x},${tangentLeftB.y}`, - `A${b.size / 2},${b.size / 2} 0 ${Number(tangentAngle < 0)} 1`, - `${tangentRightB.x},${tangentRightB.y}`, - `L${tangentRightA.x},${tangentRightA.y}`, - `A${a.size / 2},${a.size / 2} 0 ${Number(tangentAngle > 0)} 1`, - `${tangentLeftA.x},${tangentLeftA.y}`, + `M${tan.left[0].x},${tan.left[0].y}`, + `L${tan.left[1].x},${tan.left[1].y}`, + `A${b.size / 2},${b.size / 2} 0 ${Number(a.size < b.size)} 1`, + `${tan.right[1].x},${tan.right[1].y}`, + `L${tan.right[0].x},${tan.right[0].y}`, + `A${a.size / 2},${a.size / 2} 0 ${Number(a.size > b.size)} 1`, + `${tan.left[0].x},${tan.left[0].y}`, 'Z' ].join(' '); } -/** - * Returns two circles joined with cubic curves. - */ -function getCurveSegment(a, c1, c2, b) { - var mainDistance = getDistance(a, b); - if (mainDistance === 0 || - (mainDistance + a.size / 2 <= b.size / 2) || - (mainDistance + b.size / 2 <= a.size / 2) - ) { - // Return single circle, if one is inside another - var largerPt = a.size > b.size ? a : b; - var radius = largerPt.size / 2; - return getCircle(largerPt.x, largerPt.y, radius); +function getCurveSegmentPath(a, ca, cb, b) { + var ctan = getCirclesCurveTangents(a, ca, cb, b); + if (!ctan) { + return getStraightSegmentPath(a, b); } + var qa = rotation(angle(a, ctan.right[0]), angle(a, ctan.left[0])); + var qb = rotation(angle(b, ctan.right[1]), angle(b, ctan.left[1])); + return [ + `M${ctan.left[0].x},${ctan.left[0].y}`, + `C${ctan.left[1].x},${ctan.left[1].y}`, + `${ctan.left[2].x},${ctan.left[2].y}`, + `${ctan.left[3].x},${ctan.left[3].y}`, + `A${b.size / 2},${b.size / 2} 0 ${Number(qa > Math.PI)} 1`, + `${ctan.right[3].x},${ctan.right[3].y}`, + `C${ctan.right[2].x},${ctan.right[2].y}`, + `${ctan.right[1].x},${ctan.right[1].y}`, + `${ctan.right[0].x},${ctan.right[0].y}`, + `A${a.size / 2},${a.size / 2} 0 ${Number(qb > Math.PI)} 1`, + `${ctan.left[0].x},${ctan.left[0].y}`, + 'Z' + ].join(' '); +} - // NOTE: Replace self-intersected segment with straight. - if (a.x + a.size / 2 >= b.x || b.x - b.size / 2 <= a.x) { - return getSegment(a, b); - } +function angle(a, b) { + return Math.atan2(b.y - a.y, b.x - a.x); +} - var mainAngle = getAngle(a, b); - var tangentAngle = Math.asin((a.size - b.size) / mainDistance / 2); - var angleLeft = mainAngle - Math.PI / 2 + tangentAngle; - var angleRight = mainAngle + Math.PI / 2 - tangentAngle; +function rotation(a, b) { + if (b < a) { + b += 2 * Math.PI; + } + return (b - a); +} - var angleA = getAngle(a, c1); - var angleB = getAngle(c2, b); +function dist(...p) { + var total = 0; + for (var i = 1; i < p.length; i++) { + total += Math.sqrt( + (p[i].x - p[i - 1].x) * (p[i].x - p[i - 1].x) + + (p[i].y - p[i - 1].y) * (p[i].y - p[i - 1].y) + ); + } + return total; +} - var tangentLeftA = getPolarPoint(a, a.size / 2, angleLeft + angleA - mainAngle); - var tangentLeftB = getPolarPoint(b, b.size / 2, angleLeft + angleB - mainAngle); - var tangentRightA = getPolarPoint(a, a.size / 2, angleRight + angleA - mainAngle); - var tangentRightB = getPolarPoint(b, b.size / 2, angleRight + angleB - mainAngle); +function polar(start, d, a) { + return { + x: (start.x + d * Math.cos(a)), + y: (start.y + d * Math.sin(a)) + }; +} - var cLeftA = getPolarPoint( - tangentLeftA, - getDistance(a, c1) / mainDistance * getDistance(tangentLeftA, tangentLeftB), - angleLeft + angleA - mainAngle + Math.PI / 2 - ); - var cLeftB = getPolarPoint( - tangentLeftB, - getDistance(c2, b) / mainDistance * getDistance(tangentLeftA, tangentLeftB), - angleLeft + angleB - mainAngle - Math.PI / 2 - ); - var cRightA = getPolarPoint( - tangentRightA, - getDistance(a, c1) / mainDistance * getDistance(tangentRightA, tangentRightB), - angleRight + angleA - mainAngle - Math.PI / 2 - ); - var cRightB = getPolarPoint( - tangentRightB, - getDistance(c2, b) / mainDistance * getDistance(tangentRightA, tangentRightB), - angleRight + angleB - mainAngle + Math.PI / 2 +function splitCurveSegment(t, p0, c0, c1, p1) { + var seg = split(t, p0, c0, c1, p1); + var tl = 1 / (1 + + dist(seg[3], seg[4], seg[5], seg[6], seg[3]) / + dist(seg[0], seg[1], seg[2], seg[3], seg[0]) ); + seg[3].size = (p0.size * (1 - tl) + p1.size * tl); - return [ - `M${tangentLeftA.x},${tangentLeftA.y}`, - `C${cLeftA.x},${cLeftA.y}`, - `${cLeftB.x},${cLeftB.y}`, - `${tangentLeftB.x},${tangentLeftB.y}`, - `A${b.size / 2},${b.size / 2} 0 ${Number(tangentAngle < 0)} 1`, - `${tangentRightB.x},${tangentRightB.y}`, - `C${cRightB.x},${cRightB.y}`, - `${cRightA.x},${cRightA.y}`, - `${tangentRightA.x},${tangentRightA.y}`, - `A${a.size / 2},${a.size / 2} 0 ${Number(tangentAngle > 0)} 1`, - `${tangentLeftA.x},${tangentLeftA.y}`, - 'Z' - ].join(' '); + return seg; } -function getAngle(a, b) { - return Math.atan2(b.y - a.y, b.x - a.x); +function approximateQuadCurve(p0, p1, p2) { + var m = bezierPt(dist(p0, p1) / dist(p0, p1, p2), p0, p2); + var c = bezierPt(2, m, p1); + return [p0, c, p2]; } -function getDistance(a, b) { - return Math.sqrt((b.y - a.y) * (b.y - a.y) + (b.x - a.x) * (b.x - a.x)); -} +function getCirclesTangents(a, b) { + var d = dist(a, b); + if (d === 0 || + (d + a.size / 2 <= b.size / 2) || + (d + b.size / 2 <= a.size / 2) + ) { + return null; + } + + var ma = angle(a, b); + var ta = Math.asin((a.size - b.size) / d / 2); + var aleft = (ma - Math.PI / 2 + ta); + var aright = (ma + Math.PI / 2 - ta); -function getPolarPoint(start, distance, angle) { return { - x: start.x + distance * Math.cos(angle), - y: start.y + distance * Math.sin(angle) + left: [ + polar(a, a.size / 2, aleft), + polar(b, b.size / 2, aleft) + ], + right: [ + polar(a, a.size / 2, aright), + polar(b, b.size / 2, aright) + ] }; } -function splitCurveSegment(p0, c0, c1, p1) { - var c2 = getBezierPoint(0.5, p0, c0); - var c3 = getBezierPoint(0.5, p0, c0, c1); - var r = Object.keys(p1) - .reduce((memo, k) => { - if (k === 'x' || k === 'y') { - memo[k] = bezier(0.5, p0[k], c0[k], c1[k], p1[k]); - } else if (k === 'size') { - memo.size = (p0.size + p1.size) / 2; - } - return memo; - }, {}); - var c4 = getBezierPoint(0.5, c0, c1, p1); - var c5 = getBezierPoint(0.5, c1, p1); - - return [p0, c2, c3, r, c4, c5, p1]; +function getCirclesCurveTangents(a, ca, cb, b) { + var d = dist(a, b); + if (d === 0 || + (d + a.size / 2 <= b.size / 2) || + (d + b.size / 2 <= a.size / 2) + ) { + return null; + } + + // Get approximate endings tangents + // TODO: Use formulas instead of approximate equations. + const kt = 1 / 12; + var getTangentsVectors = (isEnd) => { + var curve = (isEnd ? [b, cb, ca, a] : [a, ca, cb, b]); + var seg1 = splitCurveSegment(2 * kt, ...curve); + var seg2 = splitCurveSegment(0.5, ...seg1.slice(0, 4)); + + var m = seg2[3]; + var n = seg2[6]; + var mtan = getCirclesTangents(curve[0], m); + var ntan = getCirclesTangents(m, n); + + var lpoints = [ + mtan.left[0], + bezierPt(0.5, mtan.left[1], ntan.left[0]), + ntan.left[1] + ]; + var rpoints = [ + mtan.right[0], + bezierPt(0.5, mtan.right[1], ntan.right[0]), + ntan.right[1] + ]; + + var lq = approximateQuadCurve(...lpoints)[1]; + var rq = approximateQuadCurve(...rpoints)[1]; + var lc = bezierPt(1 / 3 / kt, mtan.left[0], lq); + var rc = bezierPt(1 / 3 / kt, mtan.right[0], rq); + + return { + left: (isEnd ? [rc, rpoints[0]] : [lpoints[0], lc]), + right: (isEnd ? [lc, lpoints[0]] : [rpoints[0], rc]) + }; + }; + + var tstart = getTangentsVectors(false); + var tend = getTangentsVectors(true); + + return { + left: [...tstart.left, ...tend.left], + right: [...tstart.right, ...tend.right] + }; } diff --git a/test/utils.path.test.js b/test/utils.path.test.js index de59040f2..0c24f010f 100644 --- a/test/utils.path.test.js +++ b/test/utils.path.test.js @@ -60,10 +60,10 @@ define(function (require) { {x: 40, y: 0}, {x: 60, y: 0, size: 60, id: 2}, {x: 70, y: 0}, - {x: 81, y: 8}, + {x: 80, y: 7}, {x: 90, y: 30, size: 30, id: 3}, - {x: 110, y: 77}, - {x: 130, y: 187}, + {x: 110, y: 76}, + {x: 130, y: 186}, {x: 150, y: 360, size: 60, id: 4} ]); @@ -530,29 +530,17 @@ define(function (require) { {x: 60, y: 30, size: 30} ])); expect(testUtils.roundNumbersInString(path)).to.be.equal([ - 'M-14,54', - 'L5,11', - 'A11,11 0 0 1 26,18', - 'L15,64', - 'A15,15 0 1 1 -14,54', + 'M-14,55', + 'C-0,17 15,-8 30,-7', + 'A8,8 0 1 1 32,7', + 'C26,7 20,24 15,62', + 'A15,15 0 0 1 -14,55', 'Z', - 'M6,8', - 'C15,-4 24,-9 31,-7', - 'A8,8 0 0 1 31,7', - 'C29,8 28,12 26,18', - 'A11,11 0 1 1 6,8', - 'Z', - 'M28,-7', - 'C35,-9 43,-7 51,-2', - 'A11,11 0 1 1 35,13', - 'C33,10 31,8 28,7', - 'A8,8 0 0 1 28,-7', - 'Z', - 'M53,0', - 'L71,20', - 'A15,15 0 1 1 46,37', - 'L35,12', - 'A11,11 0 0 1 53,0', + 'M29,-7', + 'C42,-9 59,3 72,21', + 'A15,15 0 0 1 46,35', + 'C40,15 36,8 28,7', + 'A8,8 0 0 1 29,-7', 'Z' ].join(' ')); @@ -569,10 +557,6 @@ define(function (require) { {x: 100, y: 100, size: 40} ])); expect(singlePoint2).to.be.equal([ - 'M95,85', - 'A15,15 0 0 1 95,115', - 'A15,15 0 0 1 95,85', - 'Z', 'M100,80', 'A20,20 0 0 1 100,120', 'A20,20 0 0 1 100,80',