Skip to content

Instantly share code, notes, and snippets.

@bohnacker
Last active August 20, 2021 15:31
Show Gist options
  • Star 2 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save bohnacker/12d3298163c4a4852227c287e3fa7752 to your computer and use it in GitHub Desktop.
Save bohnacker/12d3298163c4a4852227c287e3fa7752 to your computer and use it in GitHub Desktop.
A javascript function that takes a list of points and calculates a curvy path that passes all these points. See https://hartmut-bohnacker.de/projects/points-to-curve for more information.
// Calculates a curve that goes through a number of points.
// There are lots of mathematical approaches to do so
// (see: https://en.wikipedia.org/wiki/Cubic_Hermite_spline).
// The most commonly used seems to be the Catmull–Rom spline.
// My approch here is not intended to be a new general
// solution to this problem, but it should fit some geometrical
// and graphical needs. See
// https://hartmut-bohnacker.de/projects/points-to-curve
// for more explanation.
// points: Array of points [x1, y1, x2, y2, ..., xn, yn]; must
// be at least 4 points
// returns an object {d: "...", pointsInfo: [...]}
// 'd' is a path string (to use directly as the 'd' attribute in
// an svg path element) and pointsInfo holds lots of information
// that was needed for calculation.
function pointsToCurve(points) {
var PI = Math.PI;
// first: calculate normals and angles for all points
var pinfo = [];
for (var i = 0; i < points.length; i += 2) {
var p = {};
p.x1 = i >= 2 ? points[i - 2] : points[i];
p.y1 = i >= 2 ? points[i - 1] : points[i + 1];
p.x = points[i];
p.y = points[i + 1];
p.x3 = i < points.length - 2 ? points[i + 2] : points[i];
p.y3 = i < points.length - 2 ? points[i + 3] : points[i + 1];
p.d1x = p.x - p.x1;
p.d1y = p.y - p.y1;
p.a1 = Math.atan2(p.d1y, p.d1x);
p.l1 = Math.sqrt(p.d1x * p.d1x + p.d1y * p.d1y);
if (p.l1 == 0) p.l1 = 0.0000001;
p.d2x = p.x - p.x3;
p.d2y = p.y - p.y3;
p.a2 = Math.atan2(p.d2y, p.d2x);
p.l2 = Math.sqrt(p.d2x * p.d2x + p.d2y * p.d2y);
if (p.l2 == 0) p.l2 = 0.0000001;
p.d3x = p.x3 - p.x1;
p.d3y = p.y3 - p.y1;
p.l3 = Math.sqrt(p.d3x * p.d3x + p.d3y * p.d3y);
p.ad = angleDifference(p.a1, p.a2);
p.na = angleAverage([p.a1, p.a2], [1, 1]);
if (p.na == 0 && p.d1x == 0 && p.d1y > 0) p.na = PI;
p.nx = Math.cos(p.na);
p.ny = Math.sin(p.na);
// calculate length of the anchors for all points except first and last
if (i >= 2 && i < points.length - 2) {
// the normal should have length 0 if angle difference is 180 degrees
// or if l1 or l2 is 0.
var nfac = Math.sqrt(p.l1 * p.l2);
var nfac1 = Math.pow(2 * p.l1 / (p.l1 + p.l2), 1);
var nfac2 = Math.pow(2 * p.l2 / (p.l1 + p.l2), 1);
// If angle diff is 90 degrees, bfac should be adjust the anchor
// length to make an exact circle.
// https://stackoverflow.com/questions/1734745/how-to-create-circle-with-b%C3%A9zier-curves
var bfac = Math.abs((4 / 3) * Math.tan(p.ad / 4) * Math.SQRT1_2);
// lfac should be 1 if ad is 90 degrees and smaller if ad is
// below or above 90
var lfac = Math.SQRT2 * (0.5 * PI / (Math.abs(p.ad) + 0.00001) - 1) + 1;
lfac *= (2 * Math.atan(20 * Math.abs(p.ad))) / PI;
p.f = lfac * nfac * bfac;
p.f1 = Math.sign(p.ad) * p.f * nfac1;
p.f2 = Math.sign(p.ad) * p.f * nfac2;
p.a1x = p.ny * p.f1;
p.a1y = -p.nx * p.f1;
p.a2x = -p.ny * p.f2;
p.a2y = p.nx * p.f2;
}
pinfo.push(p);
}
// adjust direction of normals where ad is close to 180 deg
for (var i = 2; i < pinfo.length - 2; i++) {
var pp = pinfo[i - 1];
var p = pinfo[i];
var pn = pinfo[i + 1];
var vx = Math.sign(pp.ad) * pp.nx * pp.f + Math.sign(pn.ad) * pn.nx * pn.f;
var vy = Math.sign(pp.ad) * pp.ny * pp.f + Math.sign(pn.ad) * pn.ny * pn.f;
var a = Math.atan2(-Math.sign(p.ad) * vy, -Math.sign(p.ad) * vx);
var l = Math.sqrt(vx * vx + vy * vy);
var weight = Math.pow(Math.abs(p.ad) / PI, 4) * Math.atan(1 * l) / PI;
var nanew = angleAverage([p.na, a], [1, weight]);
p.nx = Math.cos(nanew);
p.ny = Math.sin(nanew);
p.a1x = p.ny * p.f1;
p.a1y = -p.nx * p.f1;
p.a2x = -p.ny * p.f2;
p.a2y = p.nx * p.f2;
}
// third: make d-string for svg path elements
var d = ["M", trim(pinfo[1].x, 2), trim(pinfo[1].y, 2)].join(' ');
for (var i = 1; i < pinfo.length - 1; i++) {
var p1 = pinfo[i];
var p2 = pinfo[i + 1];
var bezPoints = [" C"];
bezPoints.push(trim(p1.x + p1.a2x, 2));
bezPoints.push(trim(p1.y + p1.a2y, 2));
bezPoints.push(trim(p2.x + p2.a1x, 2));
bezPoints.push(trim(p2.y + p2.a1y, 2));
bezPoints.push(trim(p2.x, 2));
bezPoints.push(trim(p2.y, 2));
d += bezPoints.join(" ");
}
return { d: d, pointsInfo: pinfo };
// helping function that calculates the difference of two angles
function angleDifference(angle1, angle2) {
var TWO_PI = Math.PI * 2;
var a1 = (angle1 % TWO_PI + TWO_PI) % TWO_PI;
var a2 = (angle2 % TWO_PI + TWO_PI) % TWO_PI;
if (a2 > a1) {
var d1 = a2 - a1;
var d2 = a1 + TWO_PI - a2;
if (d1 <= d2) {
return -d1;
} else {
return d2;
}
} else {
var d1 = a1 - a2;
var d2 = a2 + TWO_PI - a1;
if (d1 <= d2) {
return d1;
} else {
return -d2;
}
}
}
// calculates the weighted average from a list of angles
function angleAverage(angles, weights) {
var resx = 0;
var resy = 0;
for (var i = 0; i < angles.length; i++) {
resx += Math.cos(angles[i]) * weights[i];
resy += Math.sin(angles[i]) * weights[i];
}
return Math.atan2(resy, resx);
}
// shorten floating point numbers
function trim(value, dig) {
var f = Math.pow(10, dig);
return Math.round(f * value) / f;
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment