From 12870a58c6635255d9911e986b1e40cf756e3e0d Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Fri, 17 Feb 2023 16:56:57 +0100 Subject: [PATCH 1/2] feat: improve createGraphPath function and simplify range logic --- src/AnimatedLineGraph.tsx | 74 +++++++------- src/CreateGraphPath.ts | 208 +++++++++++++++++++++----------------- src/StaticLineGraph.tsx | 16 ++- 3 files changed, 165 insertions(+), 133 deletions(-) diff --git a/src/AnimatedLineGraph.tsx b/src/AnimatedLineGraph.tsx index fc4b1bb..960e41e 100644 --- a/src/AnimatedLineGraph.tsx +++ b/src/AnimatedLineGraph.tsx @@ -24,7 +24,8 @@ import { createGraphPathWithGradient, getGraphPathRange, GraphPathRange, - pixelFactorX, + getXInRange, + getPointsInRange, } from './CreateGraphPath' import Reanimated, { runOnJS, @@ -51,7 +52,7 @@ const INDICATOR_PULSE_BLUR_RADIUS_BIG = INDICATOR_RADIUS * INDICATOR_BORDER_MULTIPLIER + 20 export function AnimatedLineGraph({ - points, + points: allPoints, color, smoothing = 0.2, gradientFillColors, @@ -67,7 +68,7 @@ export function AnimatedLineGraph({ enableIndicator = false, indicatorPulsating = false, horizontalPadding = enableIndicator - ? INDICATOR_RADIUS * INDICATOR_BORDER_MULTIPLIER + ? Math.ceil(INDICATOR_RADIUS * INDICATOR_BORDER_MULTIPLIER) : 0, verticalPadding = lineThickness, TopAxisLabel, @@ -136,27 +137,27 @@ export function AnimatedLineGraph({ const pointSelectedIndex = useRef() const pathRange: GraphPathRange = useMemo( - () => getGraphPathRange(points, range), - [points, range] + () => getGraphPathRange(allPoints, range), + [allPoints, range] ) - const drawingWidth = useMemo(() => { - return Math.max(Math.floor(width - 2 * horizontalPadding), 0) - }, [horizontalPadding, width]) + const pointsInRange = useMemo( + () => getPointsInRange(allPoints, pathRange), + [allPoints, pathRange] + ) + + const drawingWidth = useMemo( + () => width - 2 * horizontalPadding, + [horizontalPadding, width] + ) const lineWidth = useMemo(() => { - const lastPoint = points[points.length - 1] + const lastPoint = pointsInRange[pointsInRange.length - 1] - if (lastPoint == null) return width - 2 * horizontalPadding + if (lastPoint == null) return drawingWidth - return Math.max( - Math.floor( - (width - 2 * horizontalPadding) * - pixelFactorX(lastPoint.date, pathRange.x.min, pathRange.x.max) - ), - 0 - ) - }, [horizontalPadding, pathRange.x.max, pathRange.x.min, points, width]) + return Math.max(getXInRange(drawingWidth, lastPoint.date, pathRange.x), 0) + }, [drawingWidth, pathRange.x, pointsInRange]) const indicatorX = useMemo( () => @@ -181,7 +182,7 @@ export function AnimatedLineGraph({ // view is not yet measured! return } - if (points.length < 1) { + if (pointsInRange.length < 1) { // points are still empty! return } @@ -190,7 +191,7 @@ export function AnimatedLineGraph({ let gradientPath const createGraphPathProps = { - points: points, + pointsInRange: pointsInRange, range: pathRange, smoothing: smoothing, horizontalPadding: horizontalPadding, @@ -270,7 +271,7 @@ export function AnimatedLineGraph({ paths, shouldFillGradient, gradientPaths, - points, + pointsInRange, range, straightLine, verticalPadding, @@ -342,28 +343,30 @@ export function AnimatedLineGraph({ const setFingerX = useCallback( (fingerX: number) => { - const lowerBound = horizontalPadding - const upperBound = drawingWidth + horizontalPadding - - const fingerXInRange = Math.min(Math.max(fingerX, lowerBound), upperBound) - const y = getYForX(commands.current, fingerXInRange) + const y = getYForX(commands.current, fingerX) if (y != null) { + circleX.current = fingerX circleY.current = y - circleX.current = fingerXInRange } - if (isActive.value) pathEnd.current = fingerXInRange / width + if (isActive.value) pathEnd.current = fingerX / width - const actualFingerX = fingerX - horizontalPadding + const fingerXInRange = Math.max(fingerX - horizontalPadding, 0) const index = Math.round( - (actualFingerX / drawingWidth) * (points.length - 1) + (fingerXInRange / + getXInRange( + drawingWidth, + pointsInRange[pointsInRange.length - 1]!.date, + pathRange.x + )) * + (pointsInRange.length - 1) ) - const pointIndex = Math.min(Math.max(index, 0), points.length - 1) + const pointIndex = Math.min(Math.max(index, 0), pointsInRange.length - 1) if (pointSelectedIndex.current !== pointIndex) { - const dataPoint = points[pointIndex] + const dataPoint = pointsInRange[pointIndex] pointSelectedIndex.current = pointIndex if (dataPoint != null) { @@ -379,7 +382,8 @@ export function AnimatedLineGraph({ isActive.value, onPointSelected, pathEnd, - points, + pathRange.x, + pointsInRange, width, ] ) @@ -432,9 +436,9 @@ export function AnimatedLineGraph({ ) useEffect(() => { - if (points.length !== 0 && commands.current.length !== 0) + if (pointsInRange.length !== 0 && commands.current.length !== 0) pathEnd.current = 1 - }, [commands, pathEnd, points.length]) + }, [commands, pathEnd, pointsInRange.length]) useEffect(() => { if (indicatorPulsating) { diff --git a/src/CreateGraphPath.ts b/src/CreateGraphPath.ts index 28d37b7..5e80fda 100644 --- a/src/CreateGraphPath.ts +++ b/src/CreateGraphPath.ts @@ -1,11 +1,12 @@ import { SkPath, Skia, - Vector, cartesian2Polar, + SkPoint, } from '@shopify/react-native-skia' import type { GraphPoint, GraphRange } from './LineGraphProps' -import { createSplineFunction } from './Maths' + +const PIXEL_RATIO = 2 export interface GraphXRange { min: Date @@ -26,7 +27,7 @@ type GraphPathConfig = { /** * Graph Points to use for the Path. Will be normalized and centered. */ - points: GraphPoint[] + pointsInRange: GraphPoint[] /** * Optional Padding (left, right) for the Graph to correctly round the Path. */ @@ -63,10 +64,10 @@ type GraphPathConfigWithoutGradient = GraphPathConfig & { export const controlPoint = ( reverse: boolean, smoothing: number, - current: Vector, - previous: Vector, - next: Vector -): Vector => { + current: SkPoint, + previous: SkPoint, + next: SkPoint +): SkPoint => { const p = previous const n = next // Properties of the opposed-line @@ -110,28 +111,50 @@ export function getGraphPathRange( } } -export const pixelFactorX = ( +export const getXPositionInRange = ( date: Date, - minValue: Date, - maxValue: Date + xRange: GraphXRange ): number => { - const diff = maxValue.getTime() - minValue.getTime() + const diff = xRange.max.getTime() - xRange.min.getTime() const x = date.getTime() - if (x < minValue.getTime() || x > maxValue.getTime()) return 0 - return (x - minValue.getTime()) / diff + return (x - xRange.min.getTime()) / diff +} + +export const getXInRange = ( + width: number, + date: Date, + xRange: GraphXRange +): number => { + return Math.floor(width * getXPositionInRange(date, xRange)) } -export const pixelFactorY = ( +export const getYPositionInRange = ( value: number, - minValue: number, - maxValue: number + yRange: GraphYRange ): number => { - const diff = maxValue - minValue + const diff = yRange.max - yRange.min const y = value - if (y < minValue || y > maxValue) return 0 - return (y - minValue) / diff + return (y - yRange.min) / diff +} + +export const getYInRange = ( + height: number, + value: number, + yRange: GraphYRange +): number => { + return Math.floor(height * getYPositionInRange(value, yRange)) +} + +export const getPointsInRange = ( + allPoints: GraphPoint[], + range: GraphPathRange +) => { + return allPoints.filter((point) => { + const portionFactorX = getXPositionInRange(point.date, range.x) + return portionFactorX <= 1 && portionFactorX >= 0 + }) } type GraphPathWithGradient = { path: SkPath; gradientPath: SkPath } @@ -142,8 +165,8 @@ function createGraphPathBase( function createGraphPathBase(props: GraphPathConfigWithoutGradient): SkPath function createGraphPathBase({ - points, - smoothing, + pointsInRange: graphData, + smoothing: _smoothing, range, horizontalPadding, verticalPadding, @@ -155,99 +178,98 @@ function createGraphPathBase({ | GraphPathWithGradient { const path = Skia.Path.Make() - const actualWidth = width - 2 * horizontalPadding - const actualHeight = height - 2 * verticalPadding + // Canvas width substracted by the horizontal padding => Actual drawing width + const drawingWidth = width - 2 * horizontalPadding + // Canvas height substracted by the vertical padding => Actual drawing height + const drawingHeight = height - 2 * verticalPadding + + if (graphData[0] == null) return path + + const points: SkPoint[] = [] - const areSameValues = range.y.min === range.y.max + const startX = + getXInRange(drawingWidth, graphData[0]!.date, range.x) + horizontalPadding + const endX = + getXInRange(drawingWidth, graphData[graphData.length - 1]!.date, range.x) + + horizontalPadding - const getGraphPoint = (point: GraphPoint): Vector => { - const x = - actualWidth * pixelFactorX(point.date, range.x.min, range.x.max) + - horizontalPadding - const y = areSameValues - ? actualHeight / 2 + verticalPadding - : actualHeight - - actualHeight * pixelFactorY(point.value, range.y.min, range.y.max) + - verticalPadding + const getGraphDataIndex = (pixel: number) => + Math.round(((pixel - startX) / (endX - startX)) * (graphData.length - 1)) - return { x: x, y: y } + const getNextPixelValue = (pixel: number) => { + if (pixel === endX || pixel + PIXEL_RATIO < endX) return pixel + PIXEL_RATIO + return endX } - if (points[0] == null) return path + for ( + let pixel = startX; + startX <= pixel && pixel <= endX; + pixel = getNextPixelValue(pixel) + ) { + const index = getGraphDataIndex(pixel) - const firstPoint = getGraphPoint(points[0]) - path.moveTo(firstPoint.x, firstPoint.y) + // Draw first point only on the very first pixel + if (index === 0 && pixel !== startX) continue + // Draw last point only on the very last pixel - points.forEach((point, i) => { - if (i === 0) { - return + 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 } - if (point.date < range.x.min || point.date > range.x.max) return + const value = graphData[index]!.value + const y = + drawingHeight - + getYInRange(drawingHeight, value, range.y) + + verticalPadding - const prev = points[i - 1] + points.push({ x: pixel, y: y }) + } - if (prev == null) return - const prevPrev = points[i - 2] ?? prev - const next = points[i + 1] ?? point - - const currentPoint = getGraphPoint(point) - const prevPoint = getGraphPoint(prev) - const prevPrevPoint = getGraphPoint(prevPrev) - const nextPoint = getGraphPoint(next) - - const cps = controlPoint( - false, - smoothing, - prevPoint, - prevPrevPoint, - currentPoint - ) - const cpe = controlPoint( - true, - smoothing, - currentPoint, - prevPoint, - nextPoint - ) + for (let i = 0; i < points.length; i++) { + const point = points[i]! - const splineFunction = createSplineFunction( - prevPoint, - cps, - cpe, - currentPoint - ) + // 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 - // Calculates how many points between two points must be - // calculated and drawn onto the canvas - const spanX = range.x.max.getTime() - range.x.min.getTime() - const deltaX = point.date.getTime() - prev.date.getTime() - const drawingFactor = deltaX / spanX - const drawingPixels = actualWidth * drawingFactor + horizontalPadding - const numberOfDrawingPoints = Math.floor(drawingPixels) + 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 - for (let i2 = 0; i2 <= numberOfDrawingPoints; i2++) { - const p = splineFunction(i2 / numberOfDrawingPoints) + path.cubicTo(cp1x, cp1y, cp2x, cp2y, cp3x, cp3y) - if (p == null) return - path.cubicTo(p.x, p.y, p.x, p.y, p.x, p.y) + if (i === points.length - 1) { + path.cubicTo(point.x, point.y, point.x, point.y, point.x, point.y) } - }) + } if (!shouldFillGradient) return path const gradientPath = path.copy() - const lastPointX = pixelFactorX( - points[points.length - 1]!.date, - range.x.min, - range.x.max - ) - - gradientPath.lineTo( - actualWidth * lastPointX + horizontalPadding, - height + verticalPadding - ) + gradientPath.lineTo(endX, height + verticalPadding) gradientPath.lineTo(0 + horizontalPadding, height + verticalPadding) return { path: path, gradientPath: gradientPath } diff --git a/src/StaticLineGraph.tsx b/src/StaticLineGraph.tsx index 3ac1a5c..2cf544f 100644 --- a/src/StaticLineGraph.tsx +++ b/src/StaticLineGraph.tsx @@ -5,12 +5,13 @@ import { View, StyleSheet, LayoutChangeEvent } from 'react-native' import { createGraphPath, getGraphPathRange, + getPointsInRange, GraphPathRange, } from './CreateGraphPath' import type { StaticLineGraphProps } from './LineGraphProps' export function StaticLineGraph({ - points, + points: allPoints, range, color, smoothing = 0.2, @@ -31,14 +32,19 @@ export function StaticLineGraph({ ) const pathRange: GraphPathRange = useMemo( - () => getGraphPathRange(points, range), - [points, range] + () => getGraphPathRange(allPoints, range), + [allPoints, range] + ) + + const pointsInRange = useMemo( + () => getPointsInRange(allPoints, pathRange), + [allPoints, pathRange] ) const path = useMemo( () => createGraphPath({ - points: points, + pointsInRange: pointsInRange, range: pathRange, smoothing: smoothing, canvasHeight: height, @@ -46,7 +52,7 @@ export function StaticLineGraph({ horizontalPadding: lineThickness, verticalPadding: lineThickness, }), - [height, lineThickness, pathRange, points, smoothing, width] + [height, lineThickness, pathRange, pointsInRange, smoothing, width] ) const gradientColors = useMemo( From 231f830de4b1902b61b801653a5f0c7d1fb79523 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Fri, 17 Feb 2023 17:21:54 +0100 Subject: [PATCH 2/2] remove unused code --- src/AnimatedLineGraph.tsx | 2 -- src/CreateGraphPath.ts | 35 +---------------------------------- src/LineGraphProps.ts | 4 ---- src/Maths.ts | 23 ----------------------- src/StaticLineGraph.tsx | 4 +--- 5 files changed, 2 insertions(+), 66 deletions(-) delete mode 100644 src/Maths.ts diff --git a/src/AnimatedLineGraph.tsx b/src/AnimatedLineGraph.tsx index 960e41e..7bba35a 100644 --- a/src/AnimatedLineGraph.tsx +++ b/src/AnimatedLineGraph.tsx @@ -54,7 +54,6 @@ const INDICATOR_PULSE_BLUR_RADIUS_BIG = export function AnimatedLineGraph({ points: allPoints, color, - smoothing = 0.2, gradientFillColors, lineThickness = 3, range, @@ -193,7 +192,6 @@ export function AnimatedLineGraph({ const createGraphPathProps = { pointsInRange: pointsInRange, range: pathRange, - smoothing: smoothing, horizontalPadding: horizontalPadding, verticalPadding: verticalPadding, canvasHeight: height, diff --git a/src/CreateGraphPath.ts b/src/CreateGraphPath.ts index 5e80fda..67e9a2e 100644 --- a/src/CreateGraphPath.ts +++ b/src/CreateGraphPath.ts @@ -1,9 +1,4 @@ -import { - SkPath, - Skia, - cartesian2Polar, - SkPoint, -} from '@shopify/react-native-skia' +import { SkPath, Skia, SkPoint } from '@shopify/react-native-skia' import type { GraphPoint, GraphRange } from './LineGraphProps' const PIXEL_RATIO = 2 @@ -44,10 +39,6 @@ type GraphPathConfig = { * Width of the Canvas (Measured with onLayout) */ canvasWidth: number - /** - * Smoothing of the graph path (usually between 0.2 and 0.5) - */ - smoothing: number /** * Range of the graph's x and y-axis */ @@ -61,29 +52,6 @@ type GraphPathConfigWithoutGradient = GraphPathConfig & { shouldFillGradient: false } -export const controlPoint = ( - reverse: boolean, - smoothing: number, - current: SkPoint, - previous: SkPoint, - next: SkPoint -): SkPoint => { - const p = previous - const n = next - // Properties of the opposed-line - const lengthX = n.x - p.x - const lengthY = n.y - p.y - - const o = cartesian2Polar({ x: lengthX, y: lengthY }) - // If is end-control-point, add PI to the angle to go backward - const angle = o.theta + (reverse ? Math.PI : 0) - const length = o.radius * smoothing - // The control point position is relative to the current point - const x = current.x + Math.cos(angle) * length - const y = current.y + Math.sin(angle) * length - return { x, y } -} - export function getGraphPathRange( points: GraphPoint[], range?: GraphRange @@ -166,7 +134,6 @@ function createGraphPathBase(props: GraphPathConfigWithoutGradient): SkPath function createGraphPathBase({ pointsInRange: graphData, - smoothing: _smoothing, range, horizontalPadding, verticalPadding, diff --git a/src/LineGraphProps.ts b/src/LineGraphProps.ts index 6397923..f3f27d7 100644 --- a/src/LineGraphProps.ts +++ b/src/LineGraphProps.ts @@ -33,10 +33,6 @@ interface BaseLineGraphProps extends ViewProps { * Color of the graph line (path) */ color: string - /** - * Smoothing value of the graph (Radius of the edge points) - */ - smoothing?: number /** * (Optional) Colors for the fill gradient below the graph line */ diff --git a/src/Maths.ts b/src/Maths.ts deleted file mode 100644 index b28d795..0000000 --- a/src/Maths.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { add, Vector } from '@shopify/react-native-skia' - -const mul = (v: Vector, f: number): Vector => ({ x: v.x * f, y: v.y * f }) - -export type SplineFunction = (t: number) => Vector | undefined - -export const createSplineFunction = ( - p0: Vector, - p1: Vector, - p2: Vector, - p3: Vector -): SplineFunction => { - return (t: number) => { - if (t > 1 || t < 0) return - - const p0Formula = mul(p0, Math.pow(1 - t, 3)) - const p1Formula = mul(p1, 3 * Math.pow(1 - t, 2) * t) - const p2Formula = mul(p2, 3 * (1 - t) * Math.pow(t, 2)) - const p3Formula = mul(p3, Math.pow(t, 3)) - - return add(add(p0Formula, p1Formula), add(p2Formula, p3Formula)) - } -} diff --git a/src/StaticLineGraph.tsx b/src/StaticLineGraph.tsx index 2cf544f..cd5f0aa 100644 --- a/src/StaticLineGraph.tsx +++ b/src/StaticLineGraph.tsx @@ -14,7 +14,6 @@ export function StaticLineGraph({ points: allPoints, range, color, - smoothing = 0.2, lineThickness = 3, enableFadeInMask, style, @@ -46,13 +45,12 @@ export function StaticLineGraph({ createGraphPath({ pointsInRange: pointsInRange, range: pathRange, - smoothing: smoothing, canvasHeight: height, canvasWidth: width, horizontalPadding: lineThickness, verticalPadding: lineThickness, }), - [height, lineThickness, pathRange, pointsInRange, smoothing, width] + [height, lineThickness, pathRange, pointsInRange, width] ) const gradientColors = useMemo(