Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions package/src/AnimatedLineGraph.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,6 @@ import {
Canvas,
LinearGradient,
Path,
Skia,
vec,
Group,
mix,
Expand All @@ -39,6 +38,7 @@ import { SelectionDot as DefaultSelectionDot } from './SelectionDot';
import {
createGraphPath,
createGraphPathWithGradient,
makePathBuilder,
getGraphPathRange,
getXInRange,
getPointsInRange,
Expand Down Expand Up @@ -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 }>({});
Expand Down
61 changes: 51 additions & 10 deletions package/src/CreateGraphPath.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<GraphPathBuilder, 'build'>;

/**
* 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;
Expand Down Expand Up @@ -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[] = [];

Expand Down Expand Up @@ -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;
}

Expand All @@ -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 };
}
Expand Down
26 changes: 26 additions & 0 deletions package/src/__tests__/CreateGraphPath.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ jest.mock('@shopify/react-native-skia', () => ({

import {
createGraphPath,
createGraphPathWithGradient,
getGraphPathRange,
getPointsInRange,
} from '../CreateGraphPath';
Expand Down Expand Up @@ -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 = [
Expand Down
100 changes: 100 additions & 0 deletions package/src/__tests__/PathBuilder.test.ts
Original file line number Diff line number Diff line change
@@ -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]);
});