diff --git a/src/AnimatedLineGraph.tsx b/src/AnimatedLineGraph.tsx index bee225a..3b91388 100644 --- a/src/AnimatedLineGraph.tsx +++ b/src/AnimatedLineGraph.tsx @@ -71,6 +71,7 @@ export function AnimatedLineGraph({ verticalPadding = lineThickness, TopAxisLabel, BottomAxisLabel, + enableSmoothing = true, ...props }: AnimatedLineGraphProps): React.ReactElement { const [width, setWidth] = useState(0) @@ -169,7 +170,7 @@ export function AnimatedLineGraph({ () => Math.floor(lineWidth) + horizontalPadding ) const indicatorY = useDerivedValue( - () => getYForX(commands.value, indicatorX.value) || 0 + () => getYForX(commands.value, indicatorX.value, enableSmoothing) || 0 ) const indicatorPulseColor = useMemo(() => hexToRgba(color, 0.4), [color]) @@ -196,6 +197,7 @@ export function AnimatedLineGraph({ verticalPadding, canvasHeight: height, canvasWidth: width, + enableSmoothing, } if (shouldFillGradient) { @@ -370,7 +372,7 @@ export function AnimatedLineGraph({ (fingerX: number) => { 'worklet' - const y = getYForX(commands.value, fingerX) + const y = getYForX(commands.value, fingerX, enableSmoothing) if (y != null) { circleX.value = fingerX diff --git a/src/CreateGraphPath.ts b/src/CreateGraphPath.ts index 67e9a2e..6361478 100644 --- a/src/CreateGraphPath.ts +++ b/src/CreateGraphPath.ts @@ -43,6 +43,11 @@ type GraphPathConfig = { * Range of the graph's x and y-axis */ range: GraphPathRange + /** + * Enables smoothing of the graph line using a cubic bezier curve. + * When disabled, the graph will be more accurate according to the dataset + */ + enableSmoothing: boolean } type GraphPathConfigWithGradient = GraphPathConfig & { @@ -140,6 +145,7 @@ function createGraphPathBase({ canvasHeight: height, canvasWidth: width, shouldFillGradient, + enableSmoothing, }: GraphPathConfigWithGradient | GraphPathConfigWithoutGradient): | SkPath | GraphPathWithGradient { @@ -208,27 +214,35 @@ function createGraphPathBase({ 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) - - const prev = points[i - 1] - const prevPrev = points[i - 2] - - if (prev == null) continue - - const p0 = prevPrev ?? prev - const p1 = prev - const cp1x = (2 * p0.x + p1.x) / 3 - const cp1y = (2 * p0.y + p1.y) / 3 - const cp2x = (p0.x + 2 * p1.x) / 3 - const cp2y = (p0.y + 2 * p1.y) / 3 - 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) - - if (i === points.length - 1) { - path.cubicTo(point.x, point.y, point.x, point.y, point.x, point.y) + // Start the path or add a line directly to the next point + if (i === 0) { + path.moveTo(point.x, point.y) + } else { + if (enableSmoothing) { + // Continue using smoothing + const prev = points[i - 1] + const prevPrev = points[i - 2] + + if (prev == null) continue + + const p0 = prevPrev ?? prev + const p1 = prev + const cp1x = (2 * p0.x + p1.x) / 3 + const cp1y = (2 * p0.y + p1.y) / 3 + const cp2x = (p0.x + 2 * p1.x) / 3 + const cp2y = (p0.y + 2 * p1.y) / 3 + 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) + + if (i === points.length - 1) { + path.cubicTo(point.x, point.y, point.x, point.y, point.x, point.y) + } + } else { + // Direct line to the next point for no smoothing + path.lineTo(point.x, point.y) + } } } diff --git a/src/GetYForX.ts b/src/GetYForX.ts index d243ac7..22fa433 100644 --- a/src/GetYForX.ts +++ b/src/GetYForX.ts @@ -1,8 +1,9 @@ import type { Vector, PathCommand } from '@shopify/react-native-skia' import { PathVerb, vec } from '@shopify/react-native-skia' -// code from William Candillon +const GET_Y_FOR_X_PRECISION = 2 +// code from William Candillon const round = (value: number, precision = 0): number => { 'worklet' @@ -126,45 +127,103 @@ interface Cubic { to: Vector } -export const selectCurve = ( +const linearInterpolation = (x: number, from: Vector, to: Vector): number => { + // Handles vertical lines or when 'from' and 'to' have the same x-coordinate + if (from.x === to.x) return from.y // Return the y-value of 'from' (or 'to') if the line is vertical + + // Calculate the y-coordinate for the given x using linear interpolation + // (y - y1) / (x - x1) = (y2 - y1) / (x2 - x1) + // This equation comes from the slope formula m = (y2 - y1) / (x2 - x1), + // rearranged to find 'y' given 'x'. + return from.y + ((to.y - from.y) * (x - from.x)) / (to.x - from.x) +} + +export const selectSegment = ( cmds: PathCommand[], - x: number -): Cubic | undefined => { + x: number, + enableSmoothing: boolean +): Cubic | { from: Vector; to: Vector } | undefined => { 'worklet' + // Starting point for path segments let from: Vector = vec(0, 0) + for (let i = 0; i < cmds.length; i++) { const cmd = cmds[i] - if (cmd == null) return undefined - if (cmd[0] === PathVerb.Move) { - from = vec(cmd[1], cmd[2]) - } else if (cmd[0] === PathVerb.Cubic) { - const c1 = vec(cmd[1], cmd[2]) - const c2 = vec(cmd[3], cmd[4]) - const to = vec(cmd[5], cmd[6]) - if (x >= from.x && x <= to.x) { - return { - from, - c1, - c2, - to, + // Skip null commands, ensuring robustness + if (cmd == null) continue + + switch (cmd[0]) { + case PathVerb.Move: + // Set the starting point for the next segment + from = vec(cmd[1], cmd[2]) + break + case PathVerb.Line: + // Handle direct line segments + const lineTo = vec(cmd[1], cmd[2]) + // Check if 'x' is within the horizontal span of the line segment + if ( + x >= Math.min(from.x, lineTo.x) && + x <= Math.max(from.x, lineTo.x) + ) { + // Return the segment as a simple line + return { from, to: lineTo } } - } - from = to + // Update 'from' to the endpoint of the line for the next segment + from = lineTo + break + case PathVerb.Cubic: + // Handle cubic bezier curves + const cubicTo = vec(cmd[5], cmd[6]) + if (enableSmoothing) { + // Construct the cubic curve segment if smoothing is enabled + const c1 = vec(cmd[1], cmd[2]) + const c2 = vec(cmd[3], cmd[4]) + if ( + x >= Math.min(from.x, cubicTo.x) && + x <= Math.max(from.x, cubicTo.x) + ) { + return { from, c1, c2, to: cubicTo } + } + } else { + // Treat the cubic curve as a straight line if smoothing is disabled + if ( + x >= Math.min(from.x, cubicTo.x) && + x <= Math.max(from.x, cubicTo.x) + ) { + return { from, to: cubicTo } + } + } + // Move 'from' to the end of the cubic curve + from = cubicTo + break } } + + // Return undefined if no segment matches the given 'x' return undefined } export const getYForX = ( cmds: PathCommand[], x: number, - precision = 2 + enableSmoothing: boolean ): number | undefined => { 'worklet' - const c = selectCurve(cmds, x) - if (c == null) return undefined - - return cubicBezierYForX(x, c.from, c.c1, c.c2, c.to, precision) + const segment = selectSegment(cmds, x, enableSmoothing) + if (!segment) return undefined + + if ('c1' in segment) { + return cubicBezierYForX( + x, + segment.from, + segment.c1, + segment.c2, + segment.to, + GET_Y_FOR_X_PRECISION + ) + } else { + return linearInterpolation(x, segment.from, segment.to) + } } diff --git a/src/LineGraphProps.ts b/src/LineGraphProps.ts index 21e147f..0ae45af 100644 --- a/src/LineGraphProps.ts +++ b/src/LineGraphProps.ts @@ -47,6 +47,11 @@ interface BaseLineGraphProps extends ViewProps { * Enable the Fade-In Gradient Effect at the beginning of the Graph */ enableFadeInMask?: boolean + /** + * Enables smoothing of the graph line using a cubic bezier curve. + * When disabled, the graph will be more accurate according to the dataset + */ + enableSmoothing?: boolean } export type StaticLineGraphProps = BaseLineGraphProps & { diff --git a/src/StaticLineGraph.tsx b/src/StaticLineGraph.tsx index 5790400..72e4e99 100644 --- a/src/StaticLineGraph.tsx +++ b/src/StaticLineGraph.tsx @@ -16,6 +16,7 @@ export function StaticLineGraph({ color, lineThickness = 3, enableFadeInMask, + enableSmoothing = true, style, ...props }: StaticLineGraphProps): React.ReactElement { @@ -49,8 +50,9 @@ export function StaticLineGraph({ canvasWidth: width, horizontalPadding: lineThickness, verticalPadding: lineThickness, + enableSmoothing, }), - [height, lineThickness, pathRange, pointsInRange, width] + [enableSmoothing, height, lineThickness, pathRange, pointsInRange, width] ) const gradientColors = useMemo(