diff --git a/package/src/AnimatedLineGraph.tsx b/package/src/AnimatedLineGraph.tsx index 6b12417..168e00f 100644 --- a/package/src/AnimatedLineGraph.tsx +++ b/package/src/AnimatedLineGraph.tsx @@ -25,7 +25,6 @@ import { Canvas, LinearGradient, Path, - Skia, vec, Group, mix, @@ -39,6 +38,7 @@ import { SelectionDot as DefaultSelectionDot } from './SelectionDot'; import { createGraphPath, createGraphPathWithGradient, + makePathBuilder, getGraphPathRange, getXInRange, getPointsInRange, @@ -133,15 +133,15 @@ export function AnimatedLineGraph({ ); const straightLine = useMemo(() => { - const path = Skia.Path.Make(); - path.moveTo(0, height / 2); + const builder = makePathBuilder(); + builder.moveTo(0, height / 2); for (let i = 0; i < width - 1; i += 2) { const x = i; const y = height / 2; - path.cubicTo(x, y, x, y, x, y); + builder.cubicTo(x, y, x, y, x, y); } - return path; + return builder.build(); }, [height, width]); const paths = useSharedValue<{ from?: SkPath; to?: SkPath }>({}); diff --git a/package/src/CreateGraphPath.ts b/package/src/CreateGraphPath.ts index 23187ae..a426c7d 100644 --- a/package/src/CreateGraphPath.ts +++ b/package/src/CreateGraphPath.ts @@ -4,6 +4,46 @@ import type { GraphPoint, GraphRange } from './LineGraphProps'; const PIXEL_RATIO = 2; +interface GraphPathBuilder { + moveTo(x: number, y: number): unknown; + lineTo(x: number, y: number): unknown; + cubicTo( + x1: number, + y1: number, + x2: number, + y2: number, + x3: number, + y3: number + ): unknown; + build(): SkPath; +} + +// SkPath before Skia v3, where paths are mutated in place. +type MutableSkPath = SkPath & Omit; + +/** + * Creates a path builder, optionally starting from an existing path. + * + * Skia v3 made SkPath immutable, so paths have to be built with + * Skia.PathBuilder. @shopify/react-native-skia before 2.6.0 has no + * Skia.PathBuilder, so we fall back to the mutable SkPath API there. + */ +export function makePathBuilder(from?: SkPath): GraphPathBuilder { + if (Skia.PathBuilder != null) { + return from == null + ? Skia.PathBuilder.Make() + : Skia.PathBuilder.MakeFromPath(from); + } + + const path = (from == null ? Skia.Path.Make() : from.copy()) as MutableSkPath; + return { + moveTo: (x, y) => path.moveTo(x, y), + lineTo: (x, y) => path.lineTo(x, y), + cubicTo: (x1, y1, x2, y2, x3, y3) => path.cubicTo(x1, y1, x2, y2, x3, y3), + build: () => path, + }; +} + export interface GraphXRange { min: Date; max: Date; @@ -148,14 +188,14 @@ function createGraphPathBase({ }: GraphPathConfigWithGradient | GraphPathConfigWithoutGradient): | SkPath | GraphPathWithGradient { - const path = Skia.Path.Make(); + const builder = makePathBuilder(); // Canvas width substracted by the horizontal padding => Actual drawing width const drawingWidth = width - 2 * horizontalPadding; // Canvas height substracted by the vertical padding => Actual drawing height const drawingHeight = height - 2 * verticalPadding; - if (graphData[0] == null) return path; + if (graphData[0] == null) return builder.build(); const points: SkPoint[] = []; @@ -259,12 +299,12 @@ function createGraphPathBase({ // first point needs to start the path if (i === 0) { - path.moveTo(point.x, point.y); + builder.moveTo(point.x, point.y); continue; } if (allPointsShareDate) { - path.cubicTo(point.x, point.y, point.x, point.y, point.x, point.y); + builder.cubicTo(point.x, point.y, point.x, point.y, point.x, point.y); continue; } @@ -282,19 +322,20 @@ function createGraphPathBase({ const cp3x = (p0.x + 4 * p1.x + point.x) / 6; const cp3y = (p0.y + 4 * p1.y + point.y) / 6; - path.cubicTo(cp1x, cp1y, cp2x, cp2y, cp3x, cp3y); + builder.cubicTo(cp1x, cp1y, cp2x, cp2y, cp3x, cp3y); if (i === points.length - 1) { - path.cubicTo(point.x, point.y, point.x, point.y, point.x, point.y); + builder.cubicTo(point.x, point.y, point.x, point.y, point.x, point.y); } } + const path = builder.build(); if (!shouldFillGradient) return path; - const gradientPath = path.copy(); - - gradientPath.lineTo(endX, height + verticalPadding); - gradientPath.lineTo(0 + horizontalPadding, height + verticalPadding); + const gradientBuilder = makePathBuilder(path); + gradientBuilder.lineTo(endX, height + verticalPadding); + gradientBuilder.lineTo(0 + horizontalPadding, height + verticalPadding); + const gradientPath = gradientBuilder.build(); return { path: path, gradientPath: gradientPath }; } diff --git a/package/src/__tests__/CreateGraphPath.test.ts b/package/src/__tests__/CreateGraphPath.test.ts index e879fc6..6213801 100644 --- a/package/src/__tests__/CreateGraphPath.test.ts +++ b/package/src/__tests__/CreateGraphPath.test.ts @@ -15,6 +15,7 @@ jest.mock('@shopify/react-native-skia', () => ({ import { createGraphPath, + createGraphPathWithGradient, getGraphPathRange, getPointsInRange, } from '../CreateGraphPath'; @@ -116,6 +117,31 @@ it('keeps a large same-date path bounded without losing its value range', () => ); }); +it('copies the line path for the gradient when Skia.PathBuilder is missing', () => { + const gradientCopy = { lineTo: jest.fn() }; + mockPath.copy.mockReturnValueOnce(gradientCopy); + const points = [ + { date: new Date('2024-01-01'), value: 10 }, + { date: new Date('2024-01-02'), value: 20 }, + ]; + const range = getGraphPathRange(points); + + const { path, gradientPath } = createGraphPathWithGradient({ + pointsInRange: getPointsInRange(points, range), + range, + horizontalPadding: 0, + verticalPadding: 0, + canvasHeight: 200, + canvasWidth: 300, + }); + + expect(path).toBe(mockPath); + expect(mockPath.lineTo).not.toHaveBeenCalled(); + expect(gradientPath).toBe(gradientCopy); + expect(gradientCopy.lineTo).toHaveBeenNthCalledWith(1, 300, 200); + expect(gradientCopy.lineTo).toHaveBeenNthCalledWith(2, 0, 200); +}); + it('filters different dates from a zero-duration range', () => { const date = new Date('2023-01-01'); const points = [ diff --git a/package/src/__tests__/PathBuilder.test.ts b/package/src/__tests__/PathBuilder.test.ts new file mode 100644 index 0000000..8d8c760 --- /dev/null +++ b/package/src/__tests__/PathBuilder.test.ts @@ -0,0 +1,100 @@ +// Mirrors the react-native-skia v3 API: SkPath is immutable (no moveTo, +// cubicTo or lineTo) and paths are created with Skia.PathBuilder. +interface MockBuilder { + from: object | undefined; + moveTo: jest.Mock; + lineTo: jest.Mock; + cubicTo: jest.Mock; + build: jest.Mock; +} + +const mockBuiltPaths: object[] = []; +const mockBuilders: MockBuilder[] = []; + +function mockMakeBuilder(from?: object): MockBuilder { + const builder: MockBuilder = { + from, + moveTo: jest.fn(() => builder), + lineTo: jest.fn(() => builder), + cubicTo: jest.fn(() => builder), + build: jest.fn(() => { + const path = {}; + mockBuiltPaths.push(path); + return path; + }), + }; + mockBuilders.push(builder); + return builder; +} + +jest.mock('@shopify/react-native-skia', () => ({ + Skia: { + Path: { + Make: () => { + throw new Error('Skia.Path.Make() should not be used with v3'); + }, + }, + PathBuilder: { + Make: () => mockMakeBuilder(), + MakeFromPath: (path: object) => mockMakeBuilder(path), + }, + }, +})); + +import { + createGraphPath, + createGraphPathWithGradient, + getGraphPathRange, + getPointsInRange, +} from '../CreateGraphPath'; + +beforeEach(() => { + mockBuiltPaths.length = 0; + mockBuilders.length = 0; +}); + +const points = [ + { date: new Date('2024-01-01'), value: 10 }, + { date: new Date('2024-01-02'), value: 30 }, + { date: new Date('2024-01-03'), value: 20 }, +]; +const range = getGraphPathRange(points); +const config = { + pointsInRange: getPointsInRange(points, range), + range, + horizontalPadding: 0, + verticalPadding: 0, + canvasHeight: 200, + canvasWidth: 300, +}; + +it('builds the graph path with Skia.PathBuilder', () => { + const path = createGraphPath(config); + + expect(mockBuilders).toHaveLength(1); + const builder = mockBuilders[0]!; + expect(builder.moveTo).toHaveBeenCalledTimes(1); + expect(builder.cubicTo).toHaveBeenCalled(); + expect(builder.build).toHaveBeenCalledTimes(1); + expect(path).toBe(mockBuiltPaths[0]); +}); + +it('builds the gradient path from the line path without mutating it', () => { + const { path, gradientPath } = createGraphPathWithGradient(config); + + expect(mockBuilders).toHaveLength(2); + const [lineBuilder, gradientBuilder] = mockBuilders; + expect(lineBuilder!.lineTo).not.toHaveBeenCalled(); + expect(gradientBuilder!.from).toBe(path); + expect(gradientBuilder!.lineTo).toHaveBeenNthCalledWith(1, 300, 200); + expect(gradientBuilder!.lineTo).toHaveBeenNthCalledWith(2, 0, 200); + expect(gradientPath).not.toBe(path); + expect(gradientPath).toBe(mockBuiltPaths[1]); +}); + +it('returns an empty built path when there are no points', () => { + const path = createGraphPath({ ...config, pointsInRange: [] }); + + expect(mockBuilders[0]!.moveTo).not.toHaveBeenCalled(); + expect(path).toBe(mockBuiltPaths[0]); +});