diff --git a/README.md b/README.md
index 0227b02..ad1efa8 100644
--- a/README.md
+++ b/README.md
@@ -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. |
diff --git a/example/src/screens/GraphPage.tsx b/example/src/screens/GraphPage.tsx
index 040a2ef..3a52720 100644
--- a/example/src/screens/GraphPage.tsx
+++ b/example/src/screens/GraphPage.tsx
@@ -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] =
@@ -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}
@@ -113,6 +115,11 @@ export function GraphPage() {
isEnabled={isAnimated}
setIsEnabled={setIsAnimated}
/>
+
{
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) {
@@ -370,6 +384,7 @@ export function AnimatedLineGraph({
}
},
[
+ checkExactPoints,
drawingWidth,
horizontalPadding,
isActive,
@@ -434,7 +449,7 @@ export function AnimatedLineGraph({
runOnJS(setFingerPoint)(fingerX);
}
},
- [isActive, setFingerX, width, x]
+ [isActive, setFingerX, setFingerPoint, width, x]
);
useAnimatedReaction(
diff --git a/package/src/CreateGraphPath.ts b/package/src/CreateGraphPath.ts
index 23187ae..f9032a8 100644
--- a/package/src/CreateGraphPath.ts
+++ b/package/src/CreateGraphPath.ts
@@ -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 & {
@@ -145,6 +147,7 @@ function createGraphPathBase({
canvasHeight: height,
canvasWidth: width,
shouldFillGradient,
+ checkExactPoints = true,
}: GraphPathConfigWithGradient | GraphPathConfigWithoutGradient):
| SkPath
| GraphPathWithGradient {
@@ -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);
diff --git a/package/src/LineGraphProps.ts b/package/src/LineGraphProps.ts
index 4c73f1e..9a9ba20 100644
--- a/package/src/LineGraphProps.ts
+++ b/package/src/LineGraphProps.ts
@@ -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 & {
diff --git a/package/src/StaticLineGraph.tsx b/package/src/StaticLineGraph.tsx
index 8a00fff..99288d4 100644
--- a/package/src/StaticLineGraph.tsx
+++ b/package/src/StaticLineGraph.tsx
@@ -18,6 +18,7 @@ export function StaticLineGraph({
lineThickness = 3,
enableFadeInMask,
style,
+ checkExactPoints = true,
...props
}: StaticLineGraphProps): React.ReactElement {
const [width, setWidth] = useState(0);
@@ -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(
diff --git a/package/src/__tests__/CreateGraphPath.test.ts b/package/src/__tests__/CreateGraphPath.test.ts
index e879fc6..5e619ef 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';
@@ -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);
+});
diff --git a/package/src/__tests__/getClosestPointIndex.test.ts b/package/src/__tests__/getClosestPointIndex.test.ts
new file mode 100644
index 0000000..65ec563
--- /dev/null
+++ b/package/src/__tests__/getClosestPointIndex.test.ts
@@ -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);
+});
diff --git a/package/src/utils/getClosestPointIndex.ts b/package/src/utils/getClosestPointIndex.ts
new file mode 100644
index 0000000..0144fc8
--- /dev/null
+++ b/package/src/utils/getClosestPointIndex.ts
@@ -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;
+}