diff --git a/src/Paths.ts b/src/Paths.ts index c4df965e..5235e39b 100644 --- a/src/Paths.ts +++ b/src/Paths.ts @@ -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"]; @@ -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"; @@ -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. */ @@ -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[], + 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; +}; diff --git a/src/__tests__/Paths.test.ts b/src/__tests__/Paths.test.ts index 50e4aec1..09536fcc 100644 --- a/src/__tests__/Paths.test.ts +++ b/src/__tests__/Paths.test.ts @@ -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 "; @@ -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)); +});