Skip to content

Commit

Permalink
feat(📈): curve lines (#380)
Browse files Browse the repository at this point in the history
  • Loading branch information
wcandillon authored Oct 21, 2020
1 parent af87fea commit 336ca95
Show file tree
Hide file tree
Showing 2 changed files with 169 additions and 2 deletions.
116 changes: 115 additions & 1 deletion src/Paths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import absSVG from "abs-svg-path";
import normalizeSVG from "normalize-svg-path";

import { Vector } from "./Vectors";
import { cartesian2Polar } from "./Coordinates";
import { cubicBezierYForX } from "./Math";

type SVGCloseCommand = ["Z"];
Expand Down Expand Up @@ -167,7 +168,7 @@ export const createPath = (move: Vector): Path => {
};

/**
* @summary Add a Bèzier curve command to a path.
* @summary Add a cubic Bèzier curve command to a path.
*/
export const addCurve = (path: Path, c: Curve) => {
"worklet";
Expand All @@ -178,6 +179,26 @@ export const addCurve = (path: Path, c: Curve) => {
});
};

/**
* @summary Add a quadratic Bèzier curve command to a path.
*/
export const addQuadraticCurve = (path: Path, cp: Vector, to: Vector) => {
"worklet";
const last = path.curves[path.curves.length - 1];
const from = last ? last.to : path.move;
path.curves.push({
c1: {
x: from.x / 3 + (2 / 3) * cp.x,
y: from.y / 3 + (2 / 3) * cp.y,
},
c2: {
x: to.x / 3 + (2 / 3) * cp.x,
y: to.y / 3 + (2 / 3) * cp.y,
},
to,
});
};

/**
* @summary Add a close command to a path.
*/
Expand Down Expand Up @@ -251,3 +272,96 @@ export const getYForX = (path: Path, x: number, precision = 2) => {
precision
);
};

const controlPoint = (
current: Vector,
previous: Vector,
next: Vector,
reverse: boolean,
smoothing: number
) => {
"worklet";
const p = previous || current;
const n = next || current;
// Properties of the opposed-line
const lengthX = n.x - p.x;
const lengthY = n.y - p.y;

const o = cartesian2Polar({ x: lengthX, y: lengthY });
// If is end-control-point, add PI to the angle to go backward
const angle = o.theta + (reverse ? Math.PI : 0);
const length = o.radius * smoothing;
// The control point position is relative to the current point
const x = current.x + Math.cos(angle) * length;
const y = current.y + Math.sin(angle) * length;
return { x, y };
};

const exhaustiveCheck = (a: never): never => {
throw new Error(`Unexhaustive handling for ${a}`);
};

/**
* @summary Link points via a smooth cubic Bézier curves
* from https://github.com/rainbow-me/rainbow
*/
export const curveLines = (
points: Vector<number>[],
smoothing: number,
strategy: "complex" | "bezier" | "simple"
) => {
"worklet";
const path = createPath(points[0]);
// build the d attributes by looping over the points
for (let i = 0; i < points.length; i++) {
if (i === 0) {
continue;
}
const point = points[i];
const next = points[i + 1];
const prev = points[i - 1];
const cps = controlPoint(prev, points[i - 2], point, false, smoothing);
const cpe = controlPoint(point, prev, next, true, smoothing);
switch (strategy) {
case "simple":
const cp = {
x: (cps.x + cpe.x) / 2,
y: (cps.y + cpe.y) / 2,
};
addQuadraticCurve(path, cp, point);
break;
case "bezier":
const p0 = points[i - 2] || prev;
const p1 = points[i - 1];
const cp1x = (2 * p0.x + p1.x) / 3;
const cp1y = (2 * p0.y + p1.y) / 3;
const cp2x = (p0.x + 2 * p1.x) / 3;
const cp2y = (p0.y + 2 * p1.y) / 3;
const cp3x = (p0.x + 4 * p1.x + point.x) / 6;
const cp3y = (p0.y + 4 * p1.y + point.y) / 6;
path.curves.push({
c1: { x: cp1x, y: cp1y },
c2: { x: cp2x, y: cp2y },
to: { x: cp3x, y: cp3y },
});
if (i === points.length - 1) {
path.curves.push({
to: points[points.length - 1],
c1: points[points.length - 1],
c2: points[points.length - 1],
});
}
break;
case "complex":
path.curves.push({
to: point,
c1: cps,
c2: cpe,
});
break;
default:
exhaustiveCheck(strategy);
}
}
return path;
};
55 changes: 54 additions & 1 deletion src/__tests__/Paths.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,39 @@
import { serialize, parse, getYForX } from "../Paths";
import { serialize, parse, getYForX, curveLines } from "../Paths";
import { Vector } from "../Vectors";

import { d1, d2 } from "./paths";

// Graph line with random points
const vectors: Vector[] = [
{ x: 0, y: 192 },
{ x: 16.189944134078214, y: 192 },
{ x: 32.37988826815643, y: 192 },
{ x: 48.56983240223464, y: 192 },
{ x: 64.75977653631286, y: 192 },
{ x: 80.94972067039106, y: 192 },
{ x: 97.13966480446928, y: 192 },
{ x: 113.32960893854748, y: 192 },
{ x: 129.5195530726257, y: 192 },
{ x: 145.70949720670393, y: 192 },
{ x: 161.89944134078212, y: 192 },
{ x: 178.08938547486034, y: 192 },
{ x: 194.27932960893855, y: 192 },
{ x: 210.46927374301674, y: 192 },
{ x: 226.65921787709496, y: 192 },
{ x: 242.84916201117318, y: 192 },
{ x: 259.0391061452514, y: 192 },
{ x: 275.2290502793296, y: 192 },
{ x: 291.41899441340786, y: 192 },
{ x: 307.60893854748605, y: 192 },
{ x: 323.79888268156424, y: 192 },
{ x: 339.9888268156424, y: 192 },
{ x: 356.1787709497207, y: 192 },
{ x: 372.36871508379886, y: 192 },
{ x: 388.5586592178771, y: 192 },
{ x: 404.7486033519553, y: 192 },
{ x: 414, y: 192 },
];

test("parse()", () => {
const path =
"M150,0 C150,0 0,75 200,75 C75,200 200,225 200,225 C225,200 200,150 0,150 ";
Expand All @@ -28,3 +60,24 @@ test("getYForX3()", () => {
const p2 = parse(d2);
expect(getYForX(p2, 414)).toBe(15.75);
});

/*
Path generated by Rainbow react-native-animated-charts using `svgBezierPath(points, 0.1, 'complex')`
https://github.com/rainbow-me/rainbow/blob/master/src/react-native-animated-charts/src/smoothing/smoothSVG.js
*/
test("curveLines(simple)", () => {
expect(curveLines(vectors, 0.1, "simple")).toEqual(
parse(
// eslint-disable-next-line max-len
"M 0,192 Q 7.285474860335196,192 16.189944134078214,192 Q 24.28491620111732,192 32.37988826815643,192 Q 40.47486033519553,192 48.56983240223464,192 Q 56.66480446927375,192 64.75977653631286,192 Q 72.85474860335196,192 80.94972067039106,192 Q 89.04469273743017,192 97.13966480446928,192 Q 105.23463687150837,192 113.32960893854748,192 Q 121.42458100558659,192 129.5195530726257,192 Q 137.6145251396648,192 145.70949720670393,192 Q 153.80446927374302,192 161.89944134078212,192 Q 169.99441340782124,192 178.08938547486034,192 Q 186.18435754189943,192 194.27932960893855,192 Q 202.37430167597765,192 210.46927374301674,192 Q 218.56424581005587,192 226.65921787709496,192 Q 234.75418994413405,192 242.84916201117318,192 Q 250.9441340782123,192 259.0391061452514,192 Q 267.13407821229055,192 275.2290502793296,192 Q 283.32402234636874,192 291.41899441340786,192 Q 299.5139664804469,192 307.60893854748605,192 Q 315.70391061452517,192 323.79888268156424,192 Q 331.8938547486033,192 339.9888268156424,192 Q 348.08379888268155,192 356.1787709497207,192 Q 364.2737430167598,192 372.36871508379886,192 Q 380.463687150838,192 388.5586592178771,192 Q 397.00055865921786,192 404.7486033519553,192 Q 410.1837988826816,192 414,192"
)
);
});

test("curveLines(complex)", () => {
const referencePath =
// eslint-disable-next-line max-len
"M 0,192 C 1.6189944134078216,192 12.95195530726257,192 16.189944134078214,192 C 19.427932960893855,192 29.141899441340787,192 32.37988826815643,192 C 35.61787709497207,192 45.33184357541899,192 48.56983240223464,192 C 51.80782122905028,192 61.52178770949721,192 64.75977653631286,192 C 67.9977653631285,192 77.71173184357542,192 80.94972067039106,192 C 84.1877094972067,192 93.90167597765364,192 97.13966480446928,192 C 100.37765363128491,192 110.09162011173184,192 113.32960893854748,192 C 116.56759776536312,192 126.28156424581007,192 129.5195530726257,192 C 132.75754189944135,192 142.4715083798883,192 145.70949720670393,192 C 148.94748603351957,192 158.66145251396648,192 161.89944134078212,192 C 165.13743016759776,192 174.8513966480447,192 178.08938547486034,192 C 181.32737430167597,192 191.04134078212292,192 194.27932960893855,192 C 197.5173184357542,192 207.2312849162011,192 210.46927374301674,192 C 213.70726256983238,192 223.42122905027932,192 226.65921787709496,192 C 229.8972067039106,192 239.61117318435754,192 242.84916201117318,192 C 246.08715083798882,192 255.8011173184358,192 259.0391061452514,192 C 262.27709497206706,192 271.991061452514,192 275.2290502793296,192 C 278.46703910614525,192 288.1810055865922,192 291.41899441340786,192 C 294.6569832402235,192 304.3709497206704,192 307.60893854748605,192 C 310.8469273743017,192 320.5608938547486,192 323.79888268156424,192 C 327.0368715083799,192 336.7508379888268,192 339.9888268156424,192 C 343.22681564245806,192 352.94078212290503,192 356.1787709497207,192 C 359.4167597765363,192 369.1307262569832,192 372.36871508379886,192 C 375.6067039106145,192 385.32067039106147,192 388.5586592178771,192 C 391.79664804469274,192 402.20446927374303,192 404.7486033519553,192 C 407.29273743016756,192 413.0748603351955,192 414,192";
expect(() => curveLines(vectors, 0.1, "complex")).not.toThrow();
expect(curveLines(vectors, 0.1, "complex")).toEqual(parse(referencePath));
});

0 comments on commit 336ca95

Please sign in to comment.