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; +}