svg-getpointatlength
Version:
alternative to native pointAtLength() and getTotalLength() method
1,408 lines (1,114 loc) • 61.9 kB
JavaScript
/* v 1.3.2 */
(function (root, factory) {
if (typeof module !== 'undefined' && module.exports) {
// CommonJS (Node.js) environment
module.exports = factory();
} else if (typeof define === 'function' && define.amd) {
// AMD environment
define([], factory);
} else {
// Browser environment
root.pathDataLength = factory();
}
})(this, function () {
var pathDataLength = {};
const { abs, acos, atan, atan2, cos, sin, log, max, min, sqrt, tan, PI, pow } = Math;
// Legendre Gauss weight and abscissa values
const lgVals = {
}
// LG weight/abscissae generator
const getLegendreGaussValues = (n, x1 = -1, x2 = 1) => {
//console.log('add new LG', n);
let waArr = []
let z1, z, xm, xl, pp, p3, p2, p1;
const m = (n + 1) >> 1;
xm = 0.5 * (x2 + x1);
xl = 0.5 * (x2 - x1);
for (let i = m - 1; i >= 0; i--) {
z = cos((PI * (i + 0.75)) / (n + 0.5));
do {
p1 = 1;
p2 = 0;
for (let j = 0; j < n; j++) {
//Loop up the recurrence relation to get the Legendre polynomial evaluated at z.
p3 = p2;
p2 = p1;
p1 = ((2 * j + 1) * z * p2 - j * p3) / (j + 1);
}
pp = (n * (z * p1 - p2)) / (z * z - 1);
z1 = z;
z = z1 - p1 / pp; //Newton’s method
} while (abs(z - z1) > 1.0e-14);
let weight = (2 * xl) / ((1 - z * z) * pp * pp);
let abscissa = xm + xl * z;
waArr.push(
[weight, -abscissa],
[weight, abscissa],
)
}
return waArr;
}
// get angle helper
const getAngle = (p1, p2) => {
return atan2(p2.y - p1.y, p2.x - p1.x);
}
function pointAtT(pts, t = 0.5, getTangent = false) {
/**
* Linear interpolation (LERP) helper
*/
const interpolate = (p1, p2, t, getTangent = false) => {
let pt = {
x: (p2.x - p1.x) * t + p1.x,
y: (p2.y - p1.y) * t + p1.y,
};
if (getTangent) {
pt.angle = getAngle(p1, p2)
// normalize negative angles
if (pt.angle < 0) pt.angle += PI * 2
}
return pt
}
const getPointAtBezierT = (pts, t, getTangent = false) => {
let isCubic = pts.length === 4;
let p0 = pts[0];
let cp1 = pts[1];
let cp2 = isCubic ? pts[2] : pts[1];
let p = pts[pts.length - 1];
let pt = { x: 0, y: 0 };
if (getTangent) {
let m0, m1, m2, m3, m4;
let shortCp1 = p0.x === cp1.x && p0.y === cp1.y;
let shortCp2 = p.x === cp2.x && p.y === cp2.y;
if (t === 0 && !shortCp1) {
pt.x = p0.x;
pt.y = p0.y;
pt.angle = getAngle(p0, cp1)
}
else if (t === 1 && !shortCp2) {
pt.x = p.x;
pt.y = p.y;
pt.angle = getAngle(cp2, p)
}
else {
// adjust if cps are on start or end point
if (shortCp1) t += 0.0000001;
if (shortCp2) t -= 0.0000001;
m0 = interpolate(p0, cp1, t);
if (isCubic) {
m1 = interpolate(cp1, cp2, t);
m2 = interpolate(cp2, p, t);
m3 = interpolate(m0, m1, t);
m4 = interpolate(m1, m2, t);
pt = interpolate(m3, m4, t);
pt.angle = getAngle(m3, m4)
} else {
m1 = interpolate(p0, cp1, t);
m2 = interpolate(cp1, p, t);
pt = interpolate(m1, m2, t);
pt.angle = getAngle(m1, m2)
}
}
}
// take simplified calculations without tangent angles
else {
let t1 = 1 - t;
// cubic beziers
if (isCubic) {
pt = {
x:
t1 ** 3 * p0.x +
3 * t1 ** 2 * t * cp1.x +
3 * t1 * t ** 2 * cp2.x +
t ** 3 * p.x,
y:
t1 ** 3 * p0.y +
3 * t1 ** 2 * t * cp1.y +
3 * t1 * t ** 2 * cp2.y +
t ** 3 * p.y,
};
}
// quadratic beziers
else {
pt = {
x: t1 * t1 * p0.x + 2 * t1 * t * cp1.x + t ** 2 * p.x,
y: t1 * t1 * p0.y + 2 * t1 * t * cp1.y + t ** 2 * p.y,
};
}
}
return pt
}
let pt;
if (pts.length > 2) {
pt = getPointAtBezierT(pts, t, getTangent);
}
else {
pt = interpolate(pts[0], pts[1], t, getTangent)
}
// normalize negative angles
if (getTangent && pt.angle < 0) pt.angle += PI * 2
return pt
}
function PathLengthObject(totalLength, segments) {
this.totalLength = totalLength || 0;
this.segments = segments || [];
}
//pathLengthLookup
PathLengthObject.prototype.getPointAtLength = function (length = 0, getTangent = false, getSegment = false) {
let { segments, totalLength } = this;
// disable tangents if no angles present in lookup
if (!segments[0].angles.length) getSegment = false
// 1st segment
let seg0 = segments[0];
let seglast = segments[segments.length - 1];
let M = seg0.points[0];
let angle0 = seg0.angles[0]
angle0 = angle0 < 0 ? angle0 + PI * 2 : angle0;
let newT = 0;
let foundSegment = false;
let pt = { x: M.x, y: M.y };
// tangent angles for Arcs
let tangentAngle, rx, ry, xAxisRotation, deltaAngle, perpendicularAdjust;
if (getTangent) {
pt.angle = angle0;
if (seg0.type === 'A') {
({ rx, ry, xAxisRotation, deltaAngle } = seg0.points[1]);
if (rx !== ry) {
// adjust for clockwise or counter clockwise
perpendicularAdjust = deltaAngle < 0 ? PI * -0.5 : PI * 0.5;
// calulate tangent angle
tangentAngle = getTangentAngle(rx, ry, angle0) - xAxisRotation;
// adjust for axis rotation
tangentAngle = xAxisRotation ? tangentAngle + perpendicularAdjust : tangentAngle;
pt.angle = tangentAngle;
}
}
}
// return segment data
if (getSegment) {
pt.index = segments[0].index;
pt.com = segments[0].com;
}
// first or last point on path
if (length === 0) {
return pt;
}
//return last on-path point when length is larger or equals total length
else if (length >= totalLength) {
let ptLast = seglast.points.slice(-1)[0]
let angleLast = seglast.angles.slice(-1)[0]
pt.x = ptLast.x;
pt.y = ptLast.y;
if (getTangent) {
pt.angle = angleLast;
if (seglast.type === 'A') {
({ rx, ry, xAxisRotation } = seglast.points[1]);
if (rx !== ry) {
// calulate tangent angle
tangentAngle = getTangentAngle(rx, ry, angleLast) - xAxisRotation;
// adjust for clockwise or counter clockwise
perpendicularAdjust = deltaAngle < 0 ? PI * -0.5 : PI * 0.5;
// adjust for axis rotation
tangentAngle = xAxisRotation ? tangentAngle + perpendicularAdjust : tangentAngle;
pt.angle = tangentAngle;
}
}
}
if (getSegment) {
pt.index = segments.length - 1;
pt.com = segments[segments.length - 1].com;
}
return pt;
}
//loop through segments
for (let i = 0; i < segments.length && !foundSegment; i++) {
let segment = segments[i];
let { type, lengths, points, total, angles, com } = segment;
let end = lengths[lengths.length - 1];
let tStep = 1 / (lengths.length - 1);
// find path segment
if (end >= length) {
foundSegment = true;
let foundT = false;
let diffLength;
switch (type) {
case 'L':
diffLength = end - length;
newT = 1 - (1 / total) * diffLength;
pt = pointAtT(points, newT)
pt.type = 'L'
if (getTangent) pt.angle = angles[0];
break;
case 'A':
diffLength = end - length;
let { rx, ry, cx, cy, startAngle, endAngle, deltaAngle, xAxisRotation } = segment.points[1];
// is ellipse
if (rx !== ry) {
// adjust for clockwise or counter clockwise
perpendicularAdjust = deltaAngle < 0 ? PI * -0.5 : PI * 0.5;
for (let i = 1; i < lengths.length && !foundT; i++) {
let lengthN = lengths[i];
if (length < lengthN) {
// length is in this range
foundT = true;
let lengthPrev = lengths[i - 1]
let lengthSeg = lengthN - lengthPrev;
let lengthDiff = lengthN - length;
let rat = (1 / lengthSeg) * lengthDiff || 1;
let anglePrev = angles[i - 1];
let angle = angles[i];
// interpolated angle
let angleI = (anglePrev - angle) * rat + angle;
// get point on ellipse
pt = getPointOnEllipse(cx, cy, rx, ry, angleI, xAxisRotation, false, false);
// calulate tangent angle
tangentAngle = getTangentAngle(rx, ry, angleI) - xAxisRotation;
// adjust for axis rotation
tangentAngle = xAxisRotation ? tangentAngle + perpendicularAdjust : tangentAngle
// return angle
pt.angle = tangentAngle;
}
}
} else {
newT = 1 - (1 / total) * diffLength;
let newAngle = -deltaAngle * newT;
// rotate point
let cosA = cos(newAngle);
let sinA = sin(newAngle);
p0 = segment.points[0]
pt = {
x: (cosA * (p0.x - cx)) + (sinA * (p0.y - cy)) + cx,
y: (cosA * (p0.y - cy)) - (sinA * (p0.x - cx)) + cy
}
// angle
if (getTangent) {
let angleOff = deltaAngle > 0 ? PI / 2 : PI / -2;
pt.angle = startAngle + (deltaAngle * newT) + angleOff
}
}
break;
case 'C':
case 'Q':
// is curve
for (let i = 0; i < lengths.length && !foundT; i++) {
let lengthAtT = lengths[i];
if (getTangent) pt.angle = angles[0];
// first or last point in segment
if (i === 0) {
pt.x = com.p0.x
pt.y = com.p0.y
}
else if (lengthAtT === length) {
pt.x = points[points.length - 1].x
pt.y = points[points.length - 1].y
}
// found length at t range
else if (lengthAtT > length && i > 0) {
foundT = true;
let lengthAtTPrev = i > 0 ? lengths[i - 1] : lengths[i];
let t = tStep * i;
// length between previous and current t
let tSegLength = lengthAtT - lengthAtTPrev;
// difference between length at t and exact length
let diffLength = lengthAtT - length;
// ratio between segment length and difference
let tScale = (1 / tSegLength) * diffLength || 0;
newT = t - tStep * tScale || 0;
// return point and optionally angle
pt = pointAtT(points, newT, getTangent)
}
}
break;
}
pt.t = newT;
}
if (getSegment) {
pt.index = segment.index;
pt.com = segment.com;
}
}
return pt;
}
function getPathLengthLookup(d, precision = 'medium', onlyLength = false, getTangent = true) {
// disable tangent calculation in length-only mode
if (onlyLength) getTangent = false;
const checkFlatnessByPolygonArea = (points, tolerance = 0.001) => {
let area = 0;
for (let i = 0, len = points.length; len && i < len; i++) {
let addX = points[i].x;
let addY = points[i === points.length - 1 ? 0 : i + 1].y;
let subX = points[i === points.length - 1 ? 0 : i + 1].x;
let subY = points[i].y;
area += addX * addY * 0.5 - subX * subY * 0.5;
}
return abs(area) < tolerance;
}
/**
* auto adjust legendre-gauss accuracy
* precision for arc approximation
*/
let auto_lg = precision === 'high' ? true : false;
let lg = precision === 'medium' ? 24 : 12;
let lgArr = [12, 24, 36, 48, 60, 64, 72, 96];
let tDivisionsQ = precision === 'low' ? 10 : 12;
let tDivisionsC = precision === 'low' ? 15 : (precision === 'medium' ? 23 : 35);
let tDivisions = tDivisionsC;
// get pathdata
let type = Array.isArray(d) ? 'array' : typeof d;
// if string is SVG - take first geometry element
if(type==='string' && d.startsWith('<svg')){
let svg = new DOMParser().parseFromString(d, 'text/html').querySelector('svg');
let allowed = ['path', 'polygon', 'polyline', 'line', 'rect', 'circle', 'ellipse'];
let children = [...svg.children].filter(node=>{return allowed.includes(node.nodeName.toLowerCase()) })
d = children.length ? children[0] : null;
if(d) type='element'
}
if(!d) throw Error("No path data defined");
let pathData = type === 'array' ? d : (type === 'string' ? parsePathDataNormalized(d) : getPathDataFromEl(d));
let pathLength = 0;
let M = pathData[0];
let lengthLookup = { totalLength: 0, segments: [] };
let wa;
for (let i = 1; i < pathData.length; i++) {
let comPrev = pathData[i - 1];
let valuesPrevL = comPrev.values.slice(-2)
let p0 = { x: valuesPrevL[0], y: valuesPrevL[1] };
let com = pathData[i];
let { type, values } = com;
let valuesL = values.slice(-2);
let p = { x: valuesL[0], y: valuesL[1] };
let cp1, cp2, t, angle;
let len = 0;
// collect segment data in object
let lengthObj = {
type: type,
index: i,
com: { type: type, values: values, p0: p0 },
lengths: [],
points: [],
angles: [],
total: 0,
lastSeg: 0,
lastSub: 0,
lastLength: 0
};
// interpret closePath as lineto
switch (type) {
case "M":
// new M
M = pathData[i];
len = 0;
break;
case "Z":
case "z":
case "L":
if (type.toLowerCase() === 'z') {
// line to previous M
p = { x: M.values[0], y: M.values[1] };
lengthObj.type = "L";
}
len = getLength([p0, p]);
lengthObj.points.push(p0, p);
if (getTangent) {
angle = getAngle(p0, p)
lengthObj.angles.push(angle);
}
break;
case "A":
p = {
x: com.values[5],
y: com.values[6]
}
let xAxisRotation = com.values[2],
largeArc = com.values[3],
sweep = com.values[4];
// get parametrized arc properties
let arcData = svgArcToCenterParam(p0.x, p0.y, com.values[0], com.values[1], com.values[2], largeArc, sweep, p.x, p.y)
let { cx, cy, rx, ry, startAngle, endAngle, deltaAngle } = arcData
/**
* if arc is elliptic
*/
if (rx !== ry) {
// values are alredy in radians
let degrees = false;
// add weight/abscissa values if not existent
let wa_key = `wa${lg}`;
if (!lgVals[wa_key]) {
lgVals[wa_key] = getLegendreGaussValues(lg)
}
wa = lgVals[wa_key];
/**
* convert angles to parametric
* adjusted for xAxisRotation
* increases performance
*/
// convert x-axis-rotation to radians
xAxisRotation = xAxisRotation * PI / 180;
startAngle = toParametricAngle((startAngle - xAxisRotation), rx, ry)
endAngle = toParametricAngle((endAngle - xAxisRotation), rx, ry)
// adjust end angle
if (sweep && startAngle > endAngle) {
endAngle += PI * 2
}
if (!sweep && startAngle < endAngle) {
endAngle -= PI * 2
}
// precision
let lenNew = 0;
// first length and angle
lengthObj.lengths.push(pathLength);
lengthObj.angles.push(startAngle);
for (let i = 1; i < tDivisionsC; i++) {
let endAngle = startAngle + deltaAngle / tDivisionsC * i;
lenNew = getEllipseLengthLG(rx, ry, startAngle, endAngle, 0, false, degrees, wa);
len += lenNew;
lengthObj.lengths.push(lenNew + pathLength)
lengthObj.angles.push(endAngle)
}
// last angle
lengthObj.angles.push(endAngle);
// last length
len = getEllipseLengthLG(rx, ry, startAngle, endAngle, 0, false, degrees, wa);
}
// circular arc
else {
/**
* get arc length:
* perfect circle length can be linearly interpolated
* according to delta angle
*/
len = 2 * PI * rx * (1 / 360 * abs(deltaAngle * 180 / PI))
if (getTangent) {
let startA = deltaAngle < 0 ? startAngle - PI : startAngle;
let endA = deltaAngle < 0 ? endAngle - PI : endAngle;
// save only start and end angle
lengthObj.angles = [startA + PI * 0.5, endA + PI * 0.5];
}
}
lengthObj.points = [
p0,
{
startAngle: startAngle,
deltaAngle: deltaAngle,
endAngle: endAngle,
xAxisRotation: xAxisRotation,
rx: rx,
ry: ry,
cx: cx,
cy: cy
}, p];
break;
case "C":
case "Q":
cp1 = { x: values[0], y: values[1] };
cp2 = type === 'C' ? { x: values[2], y: values[3] } : cp1;
let pts = type === 'C' ? [p0, cp1, cp2, p] : [p0, cp1, p];
tDivisions = (type === 'Q') ? tDivisionsQ : tDivisionsC
// length at t=0
lengthObj.lengths.push(pathLength);
// is flat/linear – treat as lineto
let isFlat = checkFlatnessByPolygonArea(pts);
/**
* check if controlpoints are outside
* command bounding box
* to calculate lengths - won't work for quadratic
*/
let cpsOutside = false;
if (isFlat) {
let top = min(p0.y, p.y)
let left = min(p0.x, p.x)
let right = max(p0.x, p.x)
let bottom = max(p0.y, p.y)
if (
cp1.y < top || cp1.y > bottom ||
cp2.y < top || cp2.y > bottom ||
cp1.x < left || cp1.x > right ||
cp2.x < left && cp2.x > right
) {
cpsOutside = true;
isFlat = false;
}
}
// convert quadratic to cubic
if (cpsOutside && type === 'Q') {
let cp1N = {
x: p0.x + 2 / 3 * (cp1.x - p0.x),
y: p0.y + 2 / 3 * (cp1.y - p0.y)
}
cp2 = {
x: p.x + 2 / 3 * (cp1.x - p.x),
y: p.y + 2 / 3 * (cp1.y - p.y)
}
cp1 = cp1N;
type = 'C';
lengthObj.type = "C";
pts = [p0, cp1, cp2, p];
}
// treat flat bézier as lineto
if (isFlat) {
pts = [p0, p]
len = getLength(pts)
lengthObj.type = "L";
lengthObj.points.push(p0, p);
if (getTangent) {
angle = atan2(p.y - p0.y, p.x - p0.x)
lengthObj.angles.push(angle);
}
break;
} else {
// no adaptive lg accuracy - take 24n
len = !auto_lg ? getLength(pts, 1, lg) : getLength(pts, 1, lgArr[0]);
/**
* auto adjust accuracy for cubic bezier approximation
* up to n72
*/
if (type === 'C' && auto_lg) {
let lenNew;
let foundAccuracy = false
let tol = 0.001
let diff = 0;
for (let i = 1; i < lgArr.length && !foundAccuracy; i++) {
lgNew = lgArr[i];
lenNew = getLength(pts, 1, lgNew)
//precise enough or last
diff = abs(lenNew - len)
if (diff < tol || i === lgArr.length - 1) {
lg = lgArr[i - 1]
foundAccuracy = true
}
// not precise
else {
len = lenNew
}
}
}
}
if (!onlyLength && !isFlat) {
if (getTangent) {
let angleStart = pointAtT(pts, 0, true).angle
// add only start and end angles for béziers
lengthObj.angles.push(angleStart, pointAtT(pts, 1, true).angle);
}
for (let d = 1; d < tDivisions; d++) {
t = (1 / tDivisions) * d;
lengthObj.lengths.push(getLength(pts, t, lg) + pathLength);
}
lengthObj.points = pts;
}
break;
default:
len = 0;
break;
}
if (!onlyLength) {
lengthObj.lengths.push(len + pathLength);
lengthObj.total = len;
}
pathLength += len;
// ignore M starting point commands
if (type !== "M") {
lengthLookup.segments.push(lengthObj);
}
lengthLookup.totalLength = pathLength;
// add original command if it was converted for eliptic arcs
if (com.index) {
lengthObj.index = com.index;
lengthObj.com = com.com;
}
// interpret z closepaths as linetos
if (type === 'Z') {
lengthObj.com.values = [p.x, p.y];
}
}
if (onlyLength) {
return pathLength;
} else {
return new PathLengthObject(lengthLookup.totalLength, lengthLookup.segments);
}
}
function getPathLengthFromD(d, lg = 0) {
let pathData = parsePathDataNormalized(d);
return getPathDataLength(pathData, lg)
}
// only total pathlength
function getPathDataLength(pathData, lg = 0) {
return getPathLengthLookup(pathData, lg, true)
}
/**
* lenght calculation
* helper for
* lines, quadratic or cubic béziers
*/
function getLength(pts, t = 1, lg = 0) {
const lineLength = (p1, p2) => {
return sqrt(
(p2.x - p1.x) * (p2.x - p1.x) + (p2.y - p1.y) * (p2.y - p1.y)
);
}
/**
* Based on snap.svg bezlen() function
* https://github.com/adobe-webplatform/Snap.svg/blob/master/dist/snap.svg.js#L5786
*/
const cubicBezierLength = (p0, cp1, cp2, p, t, lg) => {
if (t === 0) {
return 0;
}
const base3 = (t, p1, p2, p3, p4) => {
let t1 = -3 * p1 + 9 * p2 - 9 * p3 + 3 * p4,
t2 = t * t1 + 6 * p1 - 12 * p2 + 6 * p3;
return t * t2 - 3 * p1 + 3 * p2;
};
t = t > 1 ? 1 : t < 0 ? 0 : t;
let t2 = t / 2;
/**
* set higher legendre gauss weight abscissae values
* by more accurate weight/abscissae lookups
* https://pomax.github.io/bezierinfo/legendre-gauss.html
*/
// generate values if not existent
let wa_key = `wa${lg}`;
if (!lgVals[wa_key]) lgVals[wa_key] = getLegendreGaussValues(lg)
const wa = lgVals[wa_key];
let sum = 0;
for (let i = 0, len = wa.length; i < len; i++) {
// weight and abscissae
let [w, a] = [wa[i][0], wa[i][1]];
let ct1_t = t2 * a;
let ct0 = -ct1_t + t2;
let xbase0 = base3(ct0, p0.x, cp1.x, cp2.x, p.x)
let ybase0 = base3(ct0, p0.y, cp1.y, cp2.y, p.y)
let comb0 = xbase0 * xbase0 + ybase0 * ybase0;
sum += w * sqrt(comb0)
}
return t2 * sum;
}
const quadraticBezierLength = (p0, cp1, p, t, checkFlat = false) => {
if (t === 0) {
return 0;
}
// is flat/linear – treat as line
if (checkFlat) {
let l1 = lineLength(p0, cp1) + lineLength(cp1, p);
let l2 = lineLength(p0, p);
if (l1 === l2) {
return l2;
}
}
let a, b, c, d, e, e1, d1, v1x, v1y;
v1x = cp1.x * 2;
v1y = cp1.y * 2;
d = p0.x - v1x + p.x;
d1 = p0.y - v1y + p.y;
e = v1x - 2 * p0.x;
e1 = v1y - 2 * p0.y;
a = 4 * (d * d + d1 * d1);
b = 4 * (d * e + d1 * e1);
c = e * e + e1 * e1;
const bt = b / (2 * a),
ct = c / a,
ut = t + bt,
k = ct - bt ** 2;
return (
(sqrt(a) / 2) *
(ut * sqrt(ut ** 2 + k) -
bt * sqrt(bt ** 2 + k) +
k *
log((ut + sqrt(ut ** 2 + k)) / (bt + sqrt(bt ** 2 + k))))
);
}
let length
if (pts.length === 4) {
length = cubicBezierLength(pts[0], pts[1], pts[2], pts[3], t, lg)
}
else if (pts.length === 3) {
length = quadraticBezierLength(pts[0], pts[1], pts[2], t)
}
else {
length = lineLength(pts[0], pts[1])
}
return length;
}
/**
* parse pathData from d attribute
* the core function to parse the pathData array from a d string
**/
function parsePathDataNormalized(d, { toAbsolute = true, toLonghands = true } = {}) {
d = d
// remove new lines, tabs an comma with whitespace
.replace(/[\n\r\t|,]/g, " ")
// pre trim left and right whitespace
.trim()
// add space before minus sign
.replace(/(\d)-/g, '$1 -')
// decompose multiple adjacent decimal delimiters like 0.5.5.5 => 0.5 0.5 0.5
.replace(/(\.)(?=(\d+\.\d+)+)(\d+)/g, "$1$3 ")
let pathData = [];
let cmdRegEx = /([mlcqazvhst])([^mlcqazvhst]*)/gi;
let commands = d.match(cmdRegEx);
// valid command value lengths
let comLengths = { m: 2, a: 7, c: 6, h: 1, l: 2, q: 4, s: 4, t: 2, v: 1, z: 0 };
let hasShorthands = toLonghands ? /[vhst]/gi.test(d) : false;
let hasRelative = toAbsolute ? /[lcqamts]/g.test(d.substring(1, d.length - 1)) : false;
// offsets for absolute conversion
let offX, offY, lastX, lastY, M, lastType='m';
for (let c = 0, len = commands.length; c < len; c++) {
let com = commands[c];
let type = com.substring(0, 1);
let typeRel = type.toLowerCase();
let typeAbs = type.toUpperCase();
let isRel = type === typeRel;
let chunkSize = comLengths[typeRel];
// split values to array
let values = com.substring(1, com.length)
.trim()
.split(" ").filter(Boolean);
/**
* A - Arc commands
* large arc and sweep flags
* are boolean and can be concatenated like
* 11 or 01
* or be concatenated with the final on path points like
* 1110 10 => 1 1 10 10
*/
if (typeRel === "a" && values.length != comLengths.a) {
let n = 0,
arcValues = [];
for (let i = 0; i < values.length; i++) {
let value = values[i];
// reset counter
if (n >= chunkSize) {
n = 0;
}
// if 3. or 4. parameter longer than 1
if ((n === 3 || n === 4) && value.length > 1) {
let largeArc = n === 3 ? value.substring(0, 1) : "";
let sweep = n === 3 ? value.substring(1, 2) : value.substring(0, 1);
let finalX = n === 3 ? value.substring(2) : value.substring(1);
let comN = [largeArc, sweep, finalX].filter(Boolean);
arcValues.push(comN);
n += comN.length;
} else {
// regular
arcValues.push(value);
n++;
}
}
values = arcValues.flat().filter(Boolean);
}
// string to number
values = values.map(Number)
// if string contains repeated shorthand commands - split them
let hasMultiple = values.length > chunkSize;
let chunk = hasMultiple ? values.slice(0, chunkSize) : values;
let comChunks = [{ type: type, values: chunk }];
// has implicit or repeated commands – split into chunks
if (hasMultiple) {
let typeImplicit = typeRel === "m" ? (isRel ? "l" : "L") : type;
for (let i = chunkSize; i < values.length; i += chunkSize) {
let chunk = values.slice(i, i + chunkSize);
comChunks.push({ type: typeImplicit, values: chunk });
}
}
//search for omited M commands
for(let i=0, len=comChunks.length; i<len; i++){
let com=comChunks[i];
if(com.type.toLowerCase()!=='m' && lastType==='z'){
//console.log('omitted M', com);
hasRelative=true;
comChunks.splice(i, 0, { type: 'M', values: [M.x, M.y] });
i++;
}
}
// no relative, shorthand or arc command - return current
if (!hasRelative && !hasShorthands) {
comChunks.forEach((com) => {
pathData.push(com);
});
}
/**
* convert to absolute
* init offset from 1st M
*/
else {
if (c === 0) {
offX = values[0];
offY = values[1];
lastX = offX;
lastY = offY;
M = { x: values[0], y: values[1] };
}
let typeFirst = comChunks[0].type;
typeAbs = typeFirst.toUpperCase()
// first M is always absolute
isRel = typeFirst.toLowerCase() === typeFirst && pathData.length ? true : false;
for (let i = 0,len=comChunks.length; i < len; i++) {
let com = comChunks[i];
let type = com.type;
let values = com.values;
let valuesL = values.length;
let comPrev = comChunks[i - 1]
? comChunks[i - 1]
: c > 0 && pathData[pathData.length - 1]
? pathData[pathData.length - 1]
: comChunks[i];
let valuesPrev = comPrev.values;
let valuesPrevL = valuesPrev.length;
isRel = comChunks.length > 1 ? type.toLowerCase() === type && pathData.length : isRel;
if (isRel) {
com.type = comChunks.length > 1 ? type.toUpperCase() : typeAbs;
switch (typeRel) {
case "a":
com.values = [
values[0],
values[1],
values[2],
values[3],
values[4],
values[5] + offX,
values[6] + offY
];
break;
case "h":
case "v":
com.values = type === "h" ? [values[0] + offX] : [values[0] + offY];
break;
case "m":
case "l":
case "t":
//update last M
if (type === 'm') {
M = { x: values[0] + offX, y: values[1] + offY };
}
com.values = [values[0] + offX, values[1] + offY];
break;
case "c":
com.values = [
values[0] + offX,
values[1] + offY,
values[2] + offX,
values[3] + offY,
values[4] + offX,
values[5] + offY
];
break;
case "q":
case "s":
com.values = [
values[0] + offX,
values[1] + offY,
values[2] + offX,
values[3] + offY
];
break;
case 'z':
case 'Z':
lastX = M.x;
lastY = M.y;
break;
}
}
// is absolute
else {
offX = 0;
offY = 0;
// set new M
if (type === 'M') {
M = { x: values[0], y: values[1] };
}
}
/**
* convert shorthands
*/
if (hasShorthands) {
let cp1X, cp1Y, cpN1X, cpN1Y, cp2X, cp2Y;
if (com.type === "H" || com.type === "V") {
com.values =
com.type === "H" ? [com.values[0], lastY] : [lastX, com.values[0]];
com.type = "L";
} else if (com.type === "T" || com.type === "S") {
[cp1X, cp1Y] = [valuesPrev[0], valuesPrev[1]];
[cp2X, cp2Y] =
valuesPrevL > 2
? [valuesPrev[2], valuesPrev[3]]
: [valuesPrev[0], valuesPrev[1]];
// new control point
cpN1X = com.type === "T" ? lastX * 2 - cp1X : lastX * 2 - cp2X;
cpN1Y = com.type === "T" ? lastY * 2 - cp1Y : lastY * 2 - cp2Y;
com.values = [cpN1X, cpN1Y, com.values].flat();
com.type = com.type === "T" ? "Q" : "C";
}
}
// update last type for omitted M commands
lastType=type.toLowerCase();
// add to pathData array
pathData.push(com);
// update offsets
lastX =
valuesL > 1
? values[valuesL - 2] + offX
: typeRel === "h"
? values[0] + offX
: lastX;
lastY =
valuesL > 1
? values[valuesL - 1] + offY
: typeRel === "v"
? values[0] + offY
: lastY;
offX = lastX;
offY = lastY;
}
} // end toAbsolute
}
/**
* first M is always absolute/uppercase -
* unless it adds relative linetos
* (facilitates d concatenating)
*/
pathData[0].type = "M";
return pathData;
}
/**
* based on @cuixiping;
* https://stackoverflow.com/questions/9017100/calculate-center-of-svg-arc/12329083#12329083
*/
function svgArcToCenterParam(x1, y1, rx, ry, xAxisRotation, largeArc, sweep, x2, y2) {
// helper for angle calculation
const getAngle = (cx, cy, x, y) => {
return atan2(y - cy, x - cx);
};
// make sure rx, ry are positive
rx = abs(rx);
ry = abs(ry);
// create data object
let arcData = {
cx: 0,
cy: 0,
// rx/ry values may be deceptive in arc commands
rx: rx,
ry: ry,
startAngle: 0,
endAngle: 0,
deltaAngle: 0,
clockwise: sweep
};
if (rx == 0 || ry == 0) {
// invalid arguments
throw Error("rx and ry can not be 0");
}
let shortcut = true
//console.log('short');
if (rx === ry && shortcut) {
// test semicircles
let diffX = Math.abs(x2 - x1)
let diffY = Math.abs(y2 - y1)
let r = diffX;
let xMin = Math.min(x1, x2),
yMin = Math.min(y1, y2),
PIHalf = Math.PI * 0.5
// semi circles
if (diffX === 0 && diffY || diffY === 0 && diffX) {
//console.log('semi');
r = diffX === 0 && diffY ? diffY / 2 : diffX / 2;
arcData.rx = r
arcData.ry = r
// verical
if (diffX === 0 && diffY) {
arcData.cx = x1;
arcData.cy = yMin + diffY / 2;
arcData.startAngle = y1 > y2 ? PIHalf : -PIHalf
arcData.endAngle = y1 > y2 ? -PIHalf : PIHalf
arcData.deltaAngle = sweep ? Math.PI : -Math.PI
}
// horizontal
else if (diffY === 0 && diffX) {
arcData.cx = xMin + diffX / 2;
arcData.cy = y1
arcData.startAngle = x1 > x2 ? Math.PI : 0
arcData.endAngle = x1 > x2 ? -Math.PI : Math.PI
arcData.deltaAngle = sweep ? Math.PI : -Math.PI
}
//console.log(arcData);
return arcData;
}
}
/**
* if rx===ry x-axis rotation is ignored
* otherwise convert degrees to radians
*/
let phi = rx === ry ? 0 : (xAxisRotation * PI) / 180;
let cx, cy
let s_phi = !phi ? 0 : sin(phi);
let c_phi = !phi ? 1 : cos(phi);
let hd_x = (x1 - x2) / 2;
let hd_y = (y1 - y2) / 2;
let hs_x = (x1 + x2) / 2;
let hs_y = (y1 + y2) / 2;
// F6.5.1
let x1_ = !phi ? hd_x : c_phi * hd_x + s_phi * hd_y;
let y1_ = !phi ? hd_y : c_phi * hd_y - s_phi * hd_x;
// F.6.6 Correction of out-of-range radii
// Step 3: Ensure radii are large enough
let lambda = (x1_ * x1_) / (rx * rx) + (y1_ * y1_) / (ry * ry);
if (lambda > 1) {
rx = rx * sqrt(lambda);
ry = ry * sqrt(lambda);
// save real rx/ry
arcData.rx = rx;
arcData.ry = ry;
}
let rxry = rx * ry;
let rxy1_ = rx * y1_;
let ryx1_ = ry * x1_;
let sum_of_sq = rxy1_ * rxy1_ + ryx1_ * ryx1_; // sum of square
if (!sum_of_sq) {
throw Error("start point can not be same as end point");
}
let coe = sqrt(abs((rxry * rxry - sum_of_sq) / sum_of_sq));
if (largeArc == sweep) {
coe = -coe;
}
// F6.5.2
let cx_ = (coe * rxy1_) / ry;
let cy_ = (-coe * ryx1_) / rx;
/** F6.5.3
* center point of ellipse
*/
cx = !phi ? hs_x + cx_ : c_phi * cx_ - s_phi * cy_ + hs_x;
cy = !phi ? hs_y + cy_ : s_phi * cx_ + c_phi * cy_ + hs_y;
arcData.cy = cy;
arcData.cx = cx;
/** F6.5.5
* calculate angles between center point and
* commands starting and final on path point
*/
let startAngle = getAngle(cx, cy, x1, y1);
let endAngle = getAngle(cx, cy, x2, y2);
// adjust end angle
if (!sweep && endAngle > startAngle) {
//console.log('adj neg');
endAngle -= Math.PI * 2
}
if (sweep && startAngle > endAngle) {
//console.log('adj pos');
endAngle = endAngle <= 0 ? endAngle + Math.PI * 2 : endAngle
}
let deltaAngle = endAngle - startAngle
arcData.startAngle = startAngle;
arcData.endAngle = endAngle;
arcData.deltaAngle = deltaAngle;
//console.log('arc', arcData);
return arcData;
}
/**
* ellipse helpers
*/
function getEllipseLengthLG(rx, ry, startAngle, endAngle, xAxisRotation = 0, convertParametric = true, degrees = false, wa = []) {
// convert to radians
if (degrees) {
startAngle = (startAngle * PI) / 180;
endAngle = (endAngle * PI) / 180;
xAxisRotation = xAxisRotation * PI / 180
}
// adjust for axis rotation
if (xAxisRotation && !convertParametric) {
startAngle = toParametricAngle(toNonParametricAngle(startAngle, rx, ry) - xAxisRotati