diff --git a/src/CreateGraphPath.ts b/src/CreateGraphPath.ts index 0c7f1c0..23187ae 100644 --- a/src/CreateGraphPath.ts +++ b/src/CreateGraphPath.ts @@ -87,6 +87,10 @@ export const getXPositionInRange = ( const diff = xRange.max.getTime() - xRange.min.getTime(); const x = date.getTime(); + if (diff === 0) { + return x === xRange.min.getTime() ? 0.5 : Number.NaN; + } + return (x - xRange.min.getTime()) / diff; }; @@ -174,48 +178,95 @@ function createGraphPathBase({ return endX; }; - for ( - let pixel = startX; - startX <= pixel && pixel <= endX; - pixel = getNextPixelValue(pixel) - ) { - const index = getGraphDataIndex(pixel); - - // Draw first point only on the very first pixel - if (index === 0 && pixel !== startX) continue; - // Draw last point only on the very last pixel - - if (index === graphData.length - 1 && pixel !== endX) continue; - - if (index !== 0 && index !== graphData.length - 1) { - // Only draw point, when the point is exact - const exactPointX = - getXInRange(drawingWidth, graphData[index]!.date, range.x) + - horizontalPadding; - - const isExactPointInsidePixelRatio = Array(PIXEL_RATIO) - .fill(0) - .some((_value, additionalPixel) => { - return pixel + additionalPixel === exactPointX; - }); - - if (!isExactPointInsidePixelRatio) continue; - } - + const addPoint = (index: number, x: number) => { const value = graphData[index]!.value; const y = drawingHeight - getYInRange(drawingHeight, value, range.y) + verticalPadding; - points.push({ x: pixel, y: y }); + points.push({ x, y }); + }; + + let allPointsShareDate = false; + let minValueIndex = 0; + let maxValueIndex = 0; + + if (endX === startX) { + const firstPointTime = graphData[0]!.date.getTime(); + let minValue = graphData[0]!.value; + let maxValue = minValue; + allPointsShareDate = true; + + for (let index = 1; index < graphData.length; index++) { + const point = graphData[index]!; + if (point.date.getTime() !== firstPointTime) { + allPointsShareDate = false; + break; + } + + if (point.value < minValue) { + minValue = point.value; + minValueIndex = index; + } + if (point.value > maxValue) { + maxValue = point.value; + maxValueIndex = index; + } + } + } + + if (allPointsShareDate) { + const indices = [ + ...new Set([0, minValueIndex, maxValueIndex, graphData.length - 1]), + ].sort((a, b) => a - b); + indices.forEach((index) => addPoint(index, startX)); + } else { + for ( + let pixel = startX; + startX <= pixel && pixel <= endX; + pixel = getNextPixelValue(pixel) + ) { + const index = getGraphDataIndex(pixel); + + // Draw first point only on the very first pixel + if (index === 0 && pixel !== startX) continue; + // Draw last point only on the very last pixel + + if (index === graphData.length - 1 && pixel !== endX) continue; + + if (index !== 0 && index !== graphData.length - 1) { + // Only draw point, when the point is exact + const exactPointX = + getXInRange(drawingWidth, graphData[index]!.date, range.x) + + horizontalPadding; + + const isExactPointInsidePixelRatio = Array(PIXEL_RATIO) + .fill(0) + .some((_value, additionalPixel) => { + return pixel + additionalPixel === exactPointX; + }); + + if (!isExactPointInsidePixelRatio) continue; + } + + addPoint(index, pixel); + } } for (let i = 0; i < points.length; i++) { const point = points[i]!; // first point needs to start the path - if (i === 0) path.moveTo(point.x, point.y); + if (i === 0) { + path.moveTo(point.x, point.y); + continue; + } + + if (allPointsShareDate) { + path.cubicTo(point.x, point.y, point.x, point.y, point.x, point.y); + continue; + } const prev = points[i - 1]; const prevPrev = points[i - 2]; diff --git a/src/__tests__/CreateGraphPath.test.ts b/src/__tests__/CreateGraphPath.test.ts index dc015dd..e879fc6 100644 --- a/src/__tests__/CreateGraphPath.test.ts +++ b/src/__tests__/CreateGraphPath.test.ts @@ -13,7 +13,11 @@ jest.mock('@shopify/react-native-skia', () => ({ }, })); -import { createGraphPath } from '../CreateGraphPath'; +import { + createGraphPath, + getGraphPathRange, + getPointsInRange, +} from '../CreateGraphPath'; beforeEach(() => jest.clearAllMocks()); @@ -43,3 +47,87 @@ it('creates a finite path when every graph point maps to the same pixel', () => expect(mockPath.moveTo).toHaveBeenCalledTimes(1); expect(mockPath.moveTo.mock.calls[0]?.every(Number.isFinite)).toBe(true); }); + +it('creates a visible path when graph points share the same date', () => { + const date = new Date('2023-01-01'); + const points = [ + { date, value: 1 }, + { date, value: 2 }, + ]; + const range = getGraphPathRange(points); + const pointsInRange = getPointsInRange(points, range); + + createGraphPath({ + pointsInRange, + range, + horizontalPadding: 0, + verticalPadding: 0, + canvasHeight: 200, + canvasWidth: 300, + }); + + expect(pointsInRange).toEqual(points); + expect(mockPath.moveTo).toHaveBeenCalledTimes(1); + expect(mockPath.cubicTo).toHaveBeenCalled(); + expect( + [...mockPath.moveTo.mock.calls, ...mockPath.cubicTo.mock.calls] + .flat() + .every(Number.isFinite) + ).toBe(true); +}); + +it('keeps a large same-date path bounded without losing its value range', () => { + const date = new Date('2023-01-01'); + const points = Array.from({ length: 10_000 }, (_, index) => ({ + date, + value: index === 2_500 ? 0 : index === 7_500 ? 100 : 50, + })); + const range = getGraphPathRange(points); + + createGraphPath({ + pointsInRange: getPointsInRange(points, range), + range, + horizontalPadding: 0, + verticalPadding: 0, + canvasHeight: 200, + canvasWidth: 300, + }); + + expect(mockPath.moveTo).toHaveBeenCalledWith(150, 100); + expect(mockPath.cubicTo).toHaveBeenCalledTimes(3); + expect(mockPath.cubicTo).toHaveBeenNthCalledWith( + 1, + 150, + 200, + 150, + 200, + 150, + 200 + ); + expect(mockPath.cubicTo).toHaveBeenNthCalledWith(2, 150, 0, 150, 0, 150, 0); + expect(mockPath.cubicTo).toHaveBeenNthCalledWith( + 3, + 150, + 100, + 150, + 100, + 150, + 100 + ); +}); + +it('filters different dates from a zero-duration range', () => { + const date = new Date('2023-01-01'); + const points = [ + { date: new Date('2022-12-31'), value: 1 }, + { date, value: 2 }, + { date: new Date('2023-01-02'), value: 3 }, + ]; + + expect( + getPointsInRange(points, { + x: { min: date, max: date }, + y: { min: 1, max: 3 }, + }) + ).toEqual([points[1]]); +});