Skip to content
Merged
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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ function App() {
| `animated` | `boolean` | `false` | Always | Uses the animated renderer when `true` and the lightweight static renderer when `false`. |
| `points` | `GraphPoint[]` | None. Required. | Always | The points to draw. Each point contains a numeric `value` and a `Date`. The graph scales to fit them unless `range` overrides an axis. |
| `color` | `string` | None. Required. | Always | The graph line color. |
| `checkExactPoints` | `boolean` | `true` | Always | Samples points to limit path density. Set to `false` to use every point at its date-derived horizontal position. Large datasets create larger paths. |
| `range` | `GraphRange` | `undefined`. Both axes are inferred from `points`. | Always | Overrides all or part of the visible x-axis and y-axis ranges. |
| `gradientFillColors` | `Color[]` | `undefined`. No area fill is drawn. | `animated={true}` | Colors for the vertical gradient below the graph line. |
| `lineThickness` | `number` | `3` | Always | The graph line width in points. |
Expand Down
7 changes: 7 additions & 0 deletions example/src/screens/GraphPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export function GraphPage() {
const colors = useColors();

const [isAnimated, setIsAnimated] = useState(true);
const [drawEveryPoint, setDrawEveryPoint] = useState(false);
const [enablePanGesture, setEnablePanGesture] = useState(true);
const [enableFadeInEffect, setEnableFadeInEffect] = useState(false);
const [enableCustomSelectionDot, setEnableCustomSelectionDot] =
Expand Down Expand Up @@ -91,6 +92,7 @@ export function GraphPage() {
animated={isAnimated}
color={COLOR}
points={points}
checkExactPoints={!drawEveryPoint}
gradientFillColors={enableGradient ? GRADIENT_FILL_COLORS : undefined}
enablePanGesture={enablePanGesture}
enableFadeInMask={enableFadeInEffect}
Expand All @@ -113,6 +115,11 @@ export function GraphPage() {
isEnabled={isAnimated}
setIsEnabled={setIsAnimated}
/>
<Toggle
title="Draw every point:"
isEnabled={drawEveryPoint}
setIsEnabled={setDrawEveryPoint}
/>
<Toggle
title="Enable Gesture:"
isEnabled={enablePanGesture}
Expand Down
1 change: 1 addition & 0 deletions package/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ function App() {
| `animated` | `boolean` | `false` | Always | Uses the animated renderer when `true` and the lightweight static renderer when `false`. |
| `points` | `GraphPoint[]` | None. Required. | Always | The points to draw. Each point contains a numeric `value` and a `Date`. The graph scales to fit them unless `range` overrides an axis. |
| `color` | `string` | None. Required. | Always | The graph line color. |
| `checkExactPoints` | `boolean` | `true` | Always | Samples points to limit path density. Set to `false` to use every point at its date-derived horizontal position. Large datasets create larger paths. |
| `range` | `GraphRange` | `undefined`. Both axes are inferred from `points`. | Always | Overrides all or part of the visible x-axis and y-axis ranges. |
| `gradientFillColors` | `Color[]` | `undefined`. No area fill is drawn. | `animated={true}` | Colors for the vertical gradient below the graph line. |
| `lineThickness` | `number` | `3` | Always | The graph line width in points. |
Expand Down
35 changes: 25 additions & 10 deletions package/src/AnimatedLineGraph.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@
import { usePanGesture } from './hooks/usePanGesture';
import { getYForX } from './GetYForX';
import { hexToRgba } from './utils/hexToRgba';
import { getClosestPointIndex } from './utils/getClosestPointIndex';

const INDICATOR_RADIUS = 7;
const INDICATOR_BORDER_MULTIPLIER = 1.3;
Expand Down Expand Up @@ -77,6 +78,7 @@
verticalPadding = lineThickness,
TopAxisLabel,
BottomAxisLabel,
checkExactPoints = true,
...props
}: AnimatedLineGraphProps): React.ReactElement {
const [width, setWidth] = useState(0);
Expand Down Expand Up @@ -136,7 +138,7 @@
const path = Skia.Path.Make();
path.moveTo(0, height / 2);
for (let i = 0; i < width - 1; i += 2) {
const x = i;

Check warning on line 141 in package/src/AnimatedLineGraph.tsx

View workflow job for this annotation

GitHub Actions / lint

'x' is already declared in the upper scope on line 88 column 30
const y = height / 2;
path.cubicTo(x, y, x, y, x, y);
}
Expand Down Expand Up @@ -204,6 +206,7 @@
verticalPadding,
canvasHeight: height,
canvasWidth: width,
checkExactPoints,
};

if (shouldFillGradient) {
Expand Down Expand Up @@ -269,6 +272,7 @@
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
checkExactPoints,
height,
horizontalPadding,
interpolateProgress,
Expand Down Expand Up @@ -349,15 +353,25 @@
(fingerX: number) => {
const fingerXInRange = Math.max(fingerX - horizontalPadding, 0);

const index = Math.round(
(fingerXInRange /
getXInRange(
drawingWidth,
pointsInRange[pointsInRange.length - 1]!.date,
pathRange.x
)) *
(pointsInRange.length - 1)
);
if (!checkExactPoints && drawingWidth <= 0) return;

const index = checkExactPoints
? Math.round(
(fingerXInRange /
getXInRange(
drawingWidth,
pointsInRange[pointsInRange.length - 1]!.date,
pathRange.x
)) *
(pointsInRange.length - 1)
)
: getClosestPointIndex(
pointsInRange,
pathRange.x.min.getTime() +
(fingerXInRange / drawingWidth) *
(pathRange.x.max.getTime() - pathRange.x.min.getTime())
);
if (index == null) return;
const pointIndex = Math.min(Math.max(index, 0), pointsInRange.length - 1);

if (pointSelectedIndex.current !== pointIndex) {
Expand All @@ -370,6 +384,7 @@
}
},
[
checkExactPoints,
drawingWidth,
horizontalPadding,
isActive,
Expand Down Expand Up @@ -434,7 +449,7 @@
runOnJS(setFingerPoint)(fingerX);
}
},
[isActive, setFingerX, width, x]
[isActive, setFingerX, setFingerPoint, width, x]
);

useAnimatedReaction(
Expand Down
12 changes: 11 additions & 1 deletion package/src/CreateGraphPath.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ type GraphPathConfig = {
* Range of the graph's x and y-axis
*/
range: GraphPathRange;
/** Sample points by default. Set to false to use every point. */
checkExactPoints?: boolean;
};

type GraphPathConfigWithGradient = GraphPathConfig & {
Expand Down Expand Up @@ -145,6 +147,7 @@ function createGraphPathBase({
canvasHeight: height,
canvasWidth: width,
shouldFillGradient,
checkExactPoints = true,
}: GraphPathConfigWithGradient | GraphPathConfigWithoutGradient):
| SkPath
| GraphPathWithGradient {
Expand Down Expand Up @@ -216,7 +219,14 @@ function createGraphPathBase({
}
}

if (allPointsShareDate) {
if (!checkExactPoints) {
graphData.forEach((point, index) =>
addPoint(
index,
getXInRange(drawingWidth, point.date, range.x) + horizontalPadding
)
);
} else if (allPointsShareDate) {
const indices = [
...new Set([0, minValueIndex, maxValueIndex, graphData.length - 1]),
].sort((a, b) => a - b);
Expand Down
7 changes: 7 additions & 0 deletions package/src/LineGraphProps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,13 @@ interface BaseLineGraphProps extends ViewProps {
* Enable the Fade-In Gradient Effect at the beginning of the Graph
*/
enableFadeInMask?: boolean;
/**
* Sample points to limit path density. Set to false to use every point at its
* date-derived horizontal position. Large datasets create larger paths.
*
* @default true
*/
checkExactPoints?: boolean;
}

export type StaticLineGraphProps = BaseLineGraphProps & {
Expand Down
4 changes: 3 additions & 1 deletion package/src/StaticLineGraph.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export function StaticLineGraph({
lineThickness = 3,
enableFadeInMask,
style,
checkExactPoints = true,
...props
}: StaticLineGraphProps): React.ReactElement {
const [width, setWidth] = useState(0);
Expand Down Expand Up @@ -50,8 +51,9 @@ export function StaticLineGraph({
canvasWidth: width,
horizontalPadding: lineThickness,
verticalPadding: lineThickness,
checkExactPoints,
}),
[height, lineThickness, pathRange, pointsInRange, width]
[checkExactPoints, height, lineThickness, pathRange, pointsInRange, width]
);

const gradientColors = useMemo(
Expand Down
135 changes: 135 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 @@ -131,3 +132,137 @@ it('filters different dates from a zero-duration range', () => {
})
).toEqual([points[1]]);
});

it('uses date-derived positions for every point when sampling is disabled', () => {
const points = [
{ date: new Date(0), value: 0 },
{ date: new Date(9), value: 100 },
{ date: new Date(10), value: 0 },
];

createGraphPath({
pointsInRange: points,
range: getGraphPathRange(points),
horizontalPadding: 5,
verticalPadding: 5,
canvasHeight: 210,
canvasWidth: 110,
checkExactPoints: false,
});

expect(mockPath.moveTo).toHaveBeenCalledWith(5, 205);
expect(mockPath.cubicTo).toHaveBeenCalledTimes(3);
expect(mockPath.cubicTo).toHaveBeenNthCalledWith(
2,
35,
415 / 3,
65,
215 / 3,
490 / 6,
430 / 6
);
expect(mockPath.cubicTo).toHaveBeenLastCalledWith(
105,
205,
105,
205,
105,
205
);
});

it('preserves a dense spike that the default sampling skips', () => {
const points = Array.from({ length: 11 }, (_, index) => ({
date: new Date(index),
value: index === 1 ? 100 : 0,
}));
const config = {
pointsInRange: points,
range: getGraphPathRange(points),
horizontalPadding: 0,
verticalPadding: 0,
canvasHeight: 100,
canvasWidth: 4,
};

createGraphPath(config);
const defaultCalls = [...mockPath.cubicTo.mock.calls];
expect(defaultCalls).toHaveLength(3);
expect(
defaultCalls.every(
(call) => call[1] === 100 && call[3] === 100 && call[5] === 100
)
).toBe(true);

jest.clearAllMocks();
createGraphPath({ ...config, checkExactPoints: true });
expect(mockPath.cubicTo.mock.calls).toEqual(defaultCalls);

jest.clearAllMocks();
createGraphPath({ ...config, checkExactPoints: false });
expect(mockPath.cubicTo).toHaveBeenCalledTimes(points.length);
expect(
mockPath.cubicTo.mock.calls.some(
(call) => call[1] < 100 || call[3] < 100 || call[5] < 100
)
).toBe(true);
});

it('fills the same full-point path when a gradient is requested', () => {
const points = [
{ date: new Date(0), value: 0 },
{ date: new Date(1), value: 100 },
{ date: new Date(10), value: 0 },
];
const gradientPath = { lineTo: jest.fn() };
mockPath.copy.mockReturnValue(gradientPath);
const config = {
pointsInRange: points,
range: getGraphPathRange(points),
horizontalPadding: 5,
verticalPadding: 5,
canvasHeight: 210,
canvasWidth: 110,
checkExactPoints: false,
};

createGraphPath(config);
const lineCalls = [...mockPath.cubicTo.mock.calls];
jest.clearAllMocks();

const result = createGraphPathWithGradient(config);
expect(result).toEqual({ path: mockPath, gradientPath });
expect(mockPath.cubicTo.mock.calls).toEqual(lineCalls);
expect(mockPath.copy).toHaveBeenCalledTimes(1);
expect(gradientPath.lineTo).toHaveBeenNthCalledWith(1, 105, 215);
expect(gradientPath.lineTo).toHaveBeenNthCalledWith(2, 5, 215);
});

it('uses every same-date point only when sampling is explicitly disabled', () => {
const points = Array.from({ length: 20 }, (_, index) => ({
date: new Date(0),
value: index % 3,
}));

createGraphPath({
pointsInRange: points,
range: getGraphPathRange(points),
horizontalPadding: 0,
verticalPadding: 0,
canvasHeight: 100,
canvasWidth: 100,
checkExactPoints: false,
});

expect(mockPath.moveTo).toHaveBeenCalledWith(50, 100);
expect(mockPath.cubicTo).toHaveBeenCalledTimes(points.length - 1);
expect(
mockPath.cubicTo.mock.calls.every(
(call) =>
call[0] === 50 &&
call[2] === 50 &&
call[4] === 50 &&
call.every(Number.isFinite)
)
).toBe(true);
});
25 changes: 25 additions & 0 deletions package/src/__tests__/getClosestPointIndex.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { getClosestPointIndex } from '../utils/getClosestPointIndex';

const points = [0, 9, 10].map((timestamp) => ({
date: new Date(timestamp),
value: timestamp,
}));

it.each([
[-1, 0],
[0, 0],
[4.5, 0],
[8, 1],
[9, 1],
[9.75, 2],
[10, 2],
[11, 2],
])('selects the nearest date for timestamp %s', (timestamp, index) => {
expect(getClosestPointIndex(points, timestamp)).toBe(index);
});

it('handles empty, single-point and equal-date data', () => {
expect(getClosestPointIndex([], 0)).toBeUndefined();
expect(getClosestPointIndex([points[1]!], 0)).toBe(0);
expect(getClosestPointIndex([points[1]!, points[1]!], 9)).toBe(0);
});
28 changes: 28 additions & 0 deletions package/src/utils/getClosestPointIndex.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import type { GraphPoint } from '../LineGraphProps';

/** Finds the closest timestamp in points ordered from oldest to newest. */
export function getClosestPointIndex(
points: GraphPoint[],
timestamp: number
): number | undefined {
if (points.length === 0) return undefined;

let left = 0;
let right = points.length - 1;
while (left < right) {
const middle = Math.floor((left + right) / 2);
if (points[middle]!.date.getTime() < timestamp) {
left = middle + 1;
} else {
right = middle;
}
}

const previous = points[left - 1];
if (previous == null) return left;

const next = points[left]!;
return timestamp - previous.date.getTime() <= next.date.getTime() - timestamp
? left - 1
: left;
}
Loading